• 카테고리

    질문 & 답변
  • 세부 분야

    백엔드

  • 해결 여부

    해결됨

쿼리빌더 사용과 조인과 셀렉트에 관련된 질문입니다.

22.07.12 14:36 작성 조회수 735

2

 안녕하세요 조현영님. 이번에는 쿼리빌더로 원하는 데이터를 얻기 위해 조인과 셀렉트 과정에서 오류가 발생하여 질문드립니다. 일단 질문을 드리기 전에 제 상황에 대한 사전지식이 필요하기 때문에 코드를 같이 보여드리겠습니다. 

@Entity("users")
export class UserEntity extends CommonEntity {
  @OneToOne(() => UserProfileEntity, (common) => common.id)
  @JoinColumn({ name: "commonId", referencedColumnName: "id" })
  profile: UserProfileEntity;

  @OneToOne(() => UserAuthEntity, (auth) => auth.id)
  @JoinColumn({ name: "authId", referencedColumnName: "id" })
  auth: UserAuthEntity;

  @OneToOne(() => UserActivityEntity, (activity) => activity.id)
  @JoinColumn({ name: "activityId", referencedColumnName: "id" })
  activity: UserActivityEntity;
}

사용자에 관련된 엔티티입니다. 사용자와 관련된 컬럼들은 세부적으로 엔티티를 나눈 다음 그 엔티티안에 정의 해두었습니다. 사용자 엔티티에는 프로필, 인증, 활동 엔티티의 아이디만 존재합니다. 아래에는 사용자 엔티티에게 상속을 해주는 CommonEntity입니다.

export abstract class CommonEntity {
  @IsUUID()
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @CreateDateColumn({ type: "timestamp" })
  createdAt: Date;

  @UpdateDateColumn({ type: "timestamp" })
  updatedAt: Date;

  @DeleteDateColumn({ type: "timestamp" })
  deletedAt: Date | null;
}

아래에는 각각 프로필, 인증, 활동 엔티티의 대한 코드입니다.

@Entity("users profile")
export class UserProfileEntity {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @IsString()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 20, nullable: false })
  realname: string;

  @IsDateString()
  @IsNotEmpty()
  @Column({ type: "date", nullable: false })
  birth: Date;

  @IsEnum(["male", "female"])
  @IsNotEmpty()
  @Column({ type: "enum", enum: ["male", "female"] })
  gender: "male" | "female";

  @IsMobilePhone()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 15, unique: true, nullable: false })
  phonenumber: string;

  @OneToOne(() => UserEntity)
  user: UserEntity;
}
@Entity("users auth")
export class UserAuthEntity {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @IsString()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 20, unique: true, nullable: false })
  nickname: string;

  @IsEmail()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 60, unique: true, nullable: false })
  email: string;

  @IsString()
  @IsNotEmpty()
  @Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
  @Column({ type: "varchar", nullable: false })
  password: string;

  @Column({
    type: "enum",
    enum: ["general", "special", "admin"],
    default: "general",
  })
  userType: "general" | "special" | "admin";

  @OneToOne(() => UserEntity)
  user: UserEntity;

  @OneToMany(() => ImagesEntity, (join) => join.uploader)
  image: ImagesEntity[];
}
@Entity("users activity")
export class UserActivityEntity {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column({ type: "smallint", default: 0 })
  bonusPoint: number;

  @Column({ type: "smallint", default: 0 })
  purchaseCount: number;

  @Column({ type: "smallint", default: 0 })
  productInquiryCount: number;

  @Column({ type: "smallint", default: 0 })
  productReviewCount: number;

  @OneToOne(() => UserEntity)
  user: UserEntity;

  @OneToMany(() => ReviewEntity, (review) => review.reviewer)
  review: ReviewEntity;
}

이런식으로 한 사용자 엔티티에는 프로필, 인증, 활등 에 대한 엔티티와 1:1 관계를 설정 해 두었습니다. 그리고 이제 특정 사용자에 대한 정보를 가져올 때 사용되는 메서드입니다.

async findUserWithId(userId: string): Promise<UserEntity> {
    try {
      return await this.userRepository
        .createQueryBuilder("user")
        .innerJoinAndSelect("user.profile", "profile")
        .innerJoinAndSelect("user.auth", "auth")
        .innerJoinAndSelect("user.activity", "activity")
        .where("user.id = :id", { id: userId })
        .getOneOrFail();
    } catch (err) {
      throw new UnauthorizedException("해당 사용자아이디는 존재하지 않습니다.");
    }
  }

userId 매개변수는 CommenEntity에서 상속된 id컬럼을 사용합니다. 즉 사용자 엔티티 id라고 보시면 됩니다. userRepository또한 아래처럼 사용자 엔티티의 리파지토리입니다.

 @InjectRepository(UserEntity)
    private readonly userRepository: Repository<UserEntity>

이제 본격적으로 문제에 관련된 질문을 드리자면 innerJoinAndSelect를 사용할 시 관계가 맺어진 엔티티들의 정보들을 모두 가져오게 되어서 노출이 되어서는 안될 비밀번호등 까지 보여지게 됩니다. 그래서 저는 innerJoinAndSelect대신 innerJoin으로 관계가 맺어진 엔티티들을 조인하고 select메서드로 원하는 컬럼만 선택하려했습니다.

async findUserWithId(userId: string): Promise<UserEntity> {
    try {
      return await this.userRepository
        .createQueryBuilder("user")
        .select(this.select.UserInformationReturnProperty)
        .innerJoin("user.profile", "profile")
        .inneroin("user.auth", "auth")
        .innerJoin("user.activity", "activity")
        .where("user.id = :id", { id: userId })
        .getOneOrFail();
    } catch (err) {
      throw new UnauthorizedException("해당 사용자아이디는 존재하지 않습니다.");
    }
  }

select안에 인수는 아래와 같습니다.

UserInformationReturnProperty: [
    "profile.realname",
    "auth.nickname",
    "profile.birth",
    "profile.gender",
    "auth.email",
    "profile.phonenumber",
    "auth.userType",
    "activity.purchaseCount",
    "activity.bonusPoint",
    "activity.productInquiryCount",
    "activity.productReviewCount",
  ]

이렇게 보여줘도 상관없을 만한 정보들로 구성하였습니다.

문제는 이렇게 하고 쿼리를 돌렸을 때 'EntityNotFoundError: Could not find any entity of type "UserEntity" matching: [object Object]'이런 오류가 납니다. 해석해보면 '일치하는 "UserEntity" 유형의 엔터티를 찾을 수 없음' 이런 뜻인거 같은데 분명히 리파지토리도 UserEntity이고 select 대신 innerJoinAndSelect를 하면 정상적으로 불러와 지는데 어떤 부분이 잘못된건지 모르겠어서 질문드립니다.

답변 1

답변을 작성해보세요.

0

inneroin("user.auth", "auth") 오타있네요.
이승훈님의 프로필

이승훈

질문자

2022.07.12

아 그러네요 그런데 오타 수정을 해도 같은 상황입니다.

일단 구조가 이상합니다. joinColumn이 user테이블에 있는게요. 일반적으로는 auth profile 쪽에 있어야합니다.

이승훈님의 프로필

이승훈

질문자

2022.07.12

혹시 구조가 이상한 이유에 대해 알 수 있을까요? joinColumn이 auth, profile, activity 엔티티 등에 있으면 각 엔티티의 id를 사용자 엔티티에서 사용할 수 없어서요.

사용자 엔티티에서 왜 각 엔티티의 id를 사용해야 하나요?

일단 저 코드는 select를 innerJoin 뒤에 놔보세요.

이승훈님의 프로필

이승훈

질문자

2022.07.12

구글링을 해보니 만약 사용자 엔티티로 사용자 관계 컬럼에 접근하려할때 사용자 엔티티말고 사용자 관계 컬럼이 위치한곳에 joinColumn을 두는것이 맞더라구요. 그런데 의아한점이 만약 그렇게 정의하게 된다면 사용자 엔티티와 관계가 맺어진 테이블이 아래처럼됩니다.

사용자 엔티티는 아래 처럼 됩니다.

이러면 이전에 사용자를 찾는 메서드를 실행할 때 아래와 같은 오류가 납니다.

TypeError: Cannot read properties of undefined (reading 'joinColumns')

해석해보면 joinColumn을 읽다가 값이 undefined여서 읽을 수 없다라는 뜻으로 보이는데 이러면 오히려 사용자 엔티티쪽에 joinColumn을 두는게 맞지 않나 싶습니다. 그리고 제가 많이 보여드렸던 코드인데 상품과 이미지 엔티티가 서로 OneToOne관계인데 상품을 통해서 이미지에 접근하게 유도하고 아래처럼 했습니다.

@Entity("products")
export class ProductEntity extends CommonEntity {
  @IsString()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 20, unique: true, nullable: false })
  name: string;

  @IsNumber()
  @IsNotEmpty()
  @Column({ type: "int", unsigned: true, nullable: false })
  price: number;

  @IsString()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 20, nullable: false })
  origin: string;

  @IsString()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 20, nullable: false })
  type: string;

  @IsString()
  @IsNotEmpty()
  @Column({ type: "text", nullable: true })
  description: string;

  @Column({ type: "int", default: 50 })
  quantity: number;

  @OneToOne(() => StarRatingEntity, (starRating) => starRating.product)
  @JoinColumn({ name: "starRatingId", referencedColumnName: "id" })
  starRating: StarRatingEntity;

  @OneToOne(() => ImagesEntity, (image) => image.product)
  @JoinColumn({ name: "imageId", referencedColumnName: "id" })
  image: ImagesEntity;

  @ManyToMany(() => ReviewEntity, (review) => review.product)
  review: ReviewEntity[];
}
@Entity("images")
export class ImagesEntity extends CommonEntity {
  @OneToOne(() => ProductEntity, (product: ProductEntity) => product)
  product: ProductEntity;

  @Column({ type: "varchar", nullable: false, unique: true })
  url: string;

  @Column({
    type: "enum",
    enum: ["product image", "product no image", "review", "inquiry"],
  })
  uploadReason: "product image" | "product no image" | "review" | "inquiry";

  @ManyToOne(() => UserEntity, (user) => user)
  @JoinColumn({ name: "uploaderId", referencedColumnName: "id" })
  uploader: UserEntity;
}

만약 조현영님께서 joinColumn이 user테이블에 있는게요. 일반적으로는 auth profile 쪽에 있어야합니다.

라고 말씀하신이유가 A, B가 있다고 가정할 때 A를 통해서 B에 접근하려면 A대신에 B에 joinColumn을 붙혀야 합니다 같은 논리라면 상품(A)과 이미지(B)가 있으면 상품을 통해서 이미지에 접근하니 상품 대신에 이미지쪽에 joinColumn을 두는게 맞지 않나요? 

 

위 내용을 이 주소를 통해 알게 되었습니다.

https://huniroom.tistory.com/entry/typeorm-OneToOne-%EA%B4%80%EA%B3%84%EC%97%90-%EB%8C%80%ED%95%B4-%EC%A0%95%EB%A6%AC

네 당연히 상품 대신 이미지에 상품아이디 컬럼이 있어야 합니다. 다만 그게 A를 통해서 B를 접근한다의 논리가 아닙니다. joinColumn이 어디에 있든 쿼리만 잘 짜면 접근 됩니다. 실제로 user에 joinColumn 있어도 됐잖아요. 진짜 논리는 덜 중요한 테이블에 중요한 테이블의 외래키가 있어야 한다는 것입니다.

이승훈님의 프로필

이승훈

질문자

2022.07.12

그렇다면 joinColumn의 위치는 어디든 그렇게 중요하지 않다는 뜻이되는건가요? 그리고 궁금한게 더 있는데 만약 사용자 엔티티 대신 프로필 엔티티에 joinColumn을 사용한다면 프로필엔티티에 userId, 즉 외래키 컬럼이 붙게 되는데 이러면 userRepository(UserEntity)를 통해서 프로필, 인증, 활동 엔티티에 조인이 가능할까요?

joinColumn 위치가 어디있든 join은 가능합니다. 다만 일반적으로 user에 두지 않는다는 뜻입니다. 보통 설계를 할 때에요. 네, 조인은 전부 가능합니다. profile, auth, activity에  붙는게 더 일반적입니다. 그래야 user 안 거치고도 profile에서 auth도 찾을 수 있고, auth에서 activity도 찾을 수 있습니다.

이승훈님의 프로필

이승훈

질문자

2022.07.12

이 방법이 맞는지 오늘 알았네요 알려주셔서 감사합니다! 이제 본론으로 돌아가서 이전에 제가 질문했었던 내용을 되짚어 보겠습니다.

일단 사용자 엔티티와 그 외 엔티티들은 조현영님 말씀대로 수정하였습니다.

 

@Entity("users")
export class UserEntity extends CommonEntity {
  @OneToOne(() => UserProfileEntity, (profile) => profile.User)
  Profile: UserProfileEntity;

  @OneToOne(() => UserAuthEntity, (auth) => auth.User)
  Auth: UserAuthEntity;

  @OneToOne(() => UserActivityEntity, (activity) => activity.User)
  Activity: UserActivityEntity;
}
@Entity("users profile")
export class UserProfileEntity {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @OneToOne(() => UserEntity, (user) => user.Profile)
  @JoinColumn({ name: "userId", referencedColumnName: "id" })
  User: UserEntity;

  @IsString()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 20, nullable: false })
  realname: string;

  @IsDateString()
  @IsNotEmpty()
  @Column({ type: "date", nullable: false })
  birth: Date;

  @IsEnum(["male", "female"])
  @IsNotEmpty()
  @Column({ type: "enum", enum: ["male", "female"] })
  gender: "male" | "female";

  @IsMobilePhone()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 15, unique: true, nullable: false })
  phonenumber: string;
}
@Entity("users auth")
export class UserAuthEntity {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @OneToOne(() => UserEntity, (user) => user.Auth)
  @JoinColumn({ name: "userId", referencedColumnName: "id" })
  User: UserEntity;

  @IsString()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 20, unique: true, nullable: false })
  nickname: string;

  @IsEmail()
  @IsNotEmpty()
  @Column({ type: "varchar", length: 60, unique: true, nullable: false })
  email: string;

  @IsString()
  @IsNotEmpty()
  @Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
  @Column({ type: "varchar", nullable: false })
  password: string;

  @Column({
    type: "enum",
    enum: ["general", "special", "admin"],
    default: "general",
  })
  userType: "general" | "special" | "admin";
}
@Entity("users activity")
export class UserActivityEntity {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @OneToOne(() => UserEntity, (user) => user.Activity)
  @JoinColumn({ name: "userId", referencedColumnName: "id" })
  User: UserEntity;

  @Column({ type: "smallint", default: 0 })
  bonusPoint: number;

  @Column({ type: "smallint", default: 0 })
  purchaseCount: number;

  @Column({ type: "smallint", default: 0 })
  productInquiryCount: number;

  @Column({ type: "smallint", default: 0 })
  productReviewCount: number;

  @OneToMany(() => ReviewEntity, (review) => review.reviewer)
  review: ReviewEntity;
}

 얼래는 사용자 엔티티에 있었던 joinColumn들을 전부 각각 엔티티로 옮겼습니다. 그리고 관계 컬럼의 첫글자를 대문자로 수정하였습니다. 기존에 innerjoinAndSelect방식으로 전부 가져오는것은 가능했지만 여전히 아래 같은 방법으로는 에러가 납니다.

async findUserWithId(userId: string): Promise<UserEntity> {
    try {
      return await this.userRepository
        .createQueryBuilder("user")
        .innerJoin("user.Profile", "Profile")
        .innerJoin("user.Auth", "Auth")
        .innerJoin("user.Activity", "Activity")
        .select(this.select.UserInformationReturnProperty)
        .where("user.id = :id", { id: userId })
        .getOneOrFail();
    } catch (err) {
      throw new UnauthorizedException("해당 사용자아이디는 존재하지 않습니다.");
    }
  }

 this.select.UserInformationReturnProperty 변수의 내용도 앞글자가 대문자인거 빼고는 그대로입니다.

UserInformationReturnProperty: [
    "Profile.realname",
    "Auth.nickname",
    "Profile.birth",
    "Profile.gender",
    "Auth.email",
    "Profile.phonenumber",
    "Auth.userType",
    "Activity.purchaseCount",
    "Activity.bonusPoint",
    "Activity.productInquiryCount",
    "Activity.productReviewCount",
  ],

아까 말씀하신대로 innerJoin뒤에 select를 배치하였습니다. 그리고 아까 처럼 에러 이름도 같습니다.

EntityNotFoundError: Could not find any entity of type "UserEntity" matching: [object Object]

말씀하신대로 전부 해봤는데 아직 해결되지 않았습니다.

 

 

아, 지금 보니 getOneOrFail이네요. 쿼리 결과가 없어서 발생하는 에러입니다. 그리고 조언을 드리자면 항상 SQL 쿼리를 보세요. ORM만으로 하는건 아무짝에도 도움 안 됩니다. SQL 쿼리가 정상이어야 제대로 된 결과가 나오는 겁니다. SQL을 공부하세요. ORM 공부는 SQL이 완벽한 상태에서 하는 겁니다.

이승훈님의 프로필

이승훈

질문자

2022.07.12

아쉽지만 select를 빼고 innerJoin 대신 innerJoinAndSelect를 사용하게 되면 에러 없이 사용자에 대한 정보가 잘나오게 됩니다. 그래도 SQL 쿼리를 보라는 말씀은 명심하겠습니다. 이 오류에 대해서는 제가 구글링으로 알아볼게요 알려주셔서 감사합니다!

그니까 더더욱 sql문을 비교해보시라는 말씀입니다. 왜 innerjoinandselect는 되고 innerjoin + select는 안 되는지 알아야하잖아요

구글링으로는 절대 못찾습니다. sql 비교 안하면

이승훈님의 프로필

이승훈

질문자

2022.07.13

SQL 공부 열심히 하겠습니다!