백엔드/NestJS

[TypeORM] Join없이 외래키값만 간단히 활용하기 - @RelationId, @Column

SparkIT 2024. 12. 12. 22:38

저는 NestJS에서 TypeORM을 활용해 데이터베이스를 이용하고 있습니다. 이때 RDB는 다양한 연관관계를 맺는 것이 일반적으로 외래키를 설정하는 경우가 많은데요. 이때 외래키를 이용한 효율적인 로직을 작성하고 싶을 때가 많습니다. 예를 들어 유저와 포스트의 관계를 생각해 보겠습니다. 한 유저는 다양한 포스트를 작성할 수 있으므로 유저와 포스트는 1:N 관계를 가집니다. 그럼 포스트 데이터에는 유저 기본키에 대한 외래키 컬럼이 존재하게 됩니다. 이때 기본키 1에 해당하는 유저가 작성한 포스트를 조회하고 싶다면 어떻게 해야할까요? 혹은 특정 포스트에 대한 정보와 해당 포스트를 작성한 유저의 기본키만 필요하다면 어떻게 해야 할까요? 오늘은 이런 상황에서 제가 경험한 외래키 활용 사례에 대해 공유해보겠습니다.

 


Entity 설정

먼저 제가 기존에 사용하던 엔티티를 간단하게 표현했습니다.

# Post 엔티티

@Entity()
export class Post {
    @PrimaryGeneratedColumn({ type: 'int' })
    id: number;

    @Column({ type: 'varchar' })
    title: string;

    @Column({ type: 'text' })
    content: string;

    @ManyToOne(() => User, (user) => user.posts)
    user: User;
}
# User 엔티티

@Entity()
export class User {
    @PrimaryGeneratedColumn({ type: 'int' })
    id: number;

    @Column({ type: 'varchar' })
    name: string;

    @OneToMany(() => Post, (post) => post.user)
    posts: Post[];
}

@OneToMany와 @ManyToOne 데코레이터를 활용해 1:N 관계를 설정했습니다.

 

 

특정 유저의 포스트 조회하기

특정 유저의 기본키(PK)를 알고 있다고 가정합시다. 이때 해당 유저의 포스트를 조회하기 위해서는 포스트 데이터의 유저 외래키를 활용하면 됩니다.

const posts = await this.postRepository.find({
    where: {
        user: { id: 1 },
    },
});

위 코드는 NestJS에서 TypeORM을 활용해 기본키가 1인 유저의 게시물을 모두 조회하는 메서드입니다. 이때 실행되는 쿼리를 간단하게 확인해 보겠습니다.

query:
SELECT
	( 생략 )
FROM
	`post` `Post`
LEFT JOIN
	`user` `Post__Post_user`
ON
	`Post__Post_user`.`id`=`Post`.`userId`
WHERE 
	((((`Post__Post_user`.`id` = ?))))
-- PARAMETERS: [1]

 쿼리를 보면 알 수 있듯이 typeorm의 find 메서드에서 where 옵션을 위와 같이 설정했을 때 실행되는 쿼리에는 join이 포함되어 있습니다. 이때 문제가 발생합니다. 기본적으로  join 쿼리가 실행되어 조금 복잡해진다는 문제가 있습니다. 또한 해당 쿼리로 반환된 결과에는 연관 관계로 설정된 유저 정보가 전혀 포함되지 않습니다. 이를 해결하기 위해 아래와 같은 방법들이 존재합니다.

 

Join 사용하지 않고 조회하는 방법들

1. 쿼리 빌더 사용하기

직접적으로 SQL을 작성한다면 이런 문제를 해결할 수 있습니다. 다음과 같이 쿼리 빌더를 활용해 SQL을 직접 짜봅니다.

const posts = await this.postRepository
    .createQueryBuilder('post')
    .where('post.userId = :userId', { userId: 1 })
    .getMany();

이 경우 실행되는 쿼리는 다음과 같습니다.

query:
SELECT
	( 생략 )
FROM
	`post` `post`
WHERE
	`post`.`userId` = ?
-- PARAMETERS: [1]

Join을 사용하지 않는 쿼리를 직접 쿼리 빌더로 짰기 때문에 SQL 결과에서도 join 없이 간단하게 조회가 가능합니다. 하지만 여기서도 문제는 완전히 해결되지 않습니다. 위 쿼리 반환 결과에는 단순 포스트 정보만 포함되어 있고 포스트를 작성한 유저 정보는 일절 포함되어 있지 않습니다.

 

2. @RelationId 사용하기

@RelationId는 데이터베이스 상에 컬럼을 만드는 방식이 아니라 메모리 상에서만 해당 값을 사용하는 방법입니다. 간단한 예제입니다.

# Post 엔티티

@Entity()
export class Post {
  @PrimaryGeneratedColumn({ type: 'int' })
  id: number;

  @Column({ type: 'varchar' })
  title: string;

  @Column({ type: 'text' })
  content: string;

  @ManyToOne(() => User, (user) => user.posts)
  user: User;

  @RelationId((post: Post) => post.user)
  userId: number;
}

위와 같이 설정했을 경우 포스트를 조회했을 때 userId 값(user 외래키값)도 조회가 됩니다. 하지만 이 경우에는 userId를 이용한 조회는 불가능합니다.

const posts = await this.postRepository.find({
  where: { userId: 1 },
});

@RelationId를 이용해 userId라는 컬럼을 만들었으니 해당 컬럼을 이용해 조회가 가능할 줄 알았지만 위 코드 실행 시 아래와 같은 에러가 발생합니다.

[Nest] 83981  - 2024. 12. 12. 오후 10:22:12   ERROR [ExceptionsHandler] Property "userId" was not found in "Post". Make sure your query is correct.

즉, userId 컬럼을 인식하지 못하는 모습입니다.

 

3. 외래키 컬럼 직접 명시하기

https://typeorm.io/relations-faq#how-to-use-relation-id-without-joining-relation

위 공식 문서에서도 알 수 있듯이 typeorm에서 연관 관계가 설정된 정보를 모두 조회할 필요는 없고 외래키값 자체만 필요하다면 해당 컬럼을 직접 명시해 주면 된다고 나와있습니다. 아래는 수정된 엔티티 예제입니다.

# Post 엔티티

@Entity()
export class Post {
    @PrimaryGeneratedColumn({ type: 'int' })
    id: number;

    @Column({ type: 'varchar' })
    title: string;

    @Column({ type: 'text' })
    content: string;

    @ManyToOne(() => User, (user) => user.posts)
    user: User;
    
    @Column()
    userId: number;
}

 

 

기본적으로 typeorm에서는 연관 관계로 설정된 컬럼명은 'Id'라는 문자열이 붙은 상태로 외래키 컬럼이 생성됩니다. 이를 활용하기 위해 직접 userId라는 이름의 컬럼을 생성했습니다.

const posts = await this.postRepository.find({
  where: { userId: 1 },
});
query:
SELECT
	( 생략 )
FROM
	`post` `Post`
WHERE
	((`Post`.`userId` = ?))
-- PARAMETERS: [1]

그럼 위와 같은 userId를 이용해 바로 조회가 가능해지고, 실행된 쿼리 또한 join 없이 간단하게 실행되는 모습을 확인할 수 있었습니다. 또한 쿼리 반환 결과에도 유저 외래키 정보가 포함된 모습을 확인할 수 있었습니다.

하지만 이 경우에도 문제가 존재합니다. 제약 조건을 추가했을 때 문제가 생기는 모습을 확인할 수 있었는데요. 다음 예제를 통해 확인해 보겠습니다.

# Post 엔티티

@Entity()
export class Post {
  @PrimaryGeneratedColumn({ type: 'int' })
  id: number;

  @Column({ type: 'varchar' })
  title: string;

  @Column({ type: 'text' })
  content: string;

  @ManyToOne(() => User, (user) => user.posts, { nullable: true }) // null 가능하게 제약조건 추가
  user: User;

  @Column()
  userId: number;
}

위와 같이 포스트를 작성한 유저가 없는 경우도 가능하게 해 주기 위해 user에 nullable true 옵션을 주었습니다. 하지만 null 값을 넣어 데이터를 추가하려면 에러가 발생합니다.

const posts = await this.postRepository.insert({
  title: 'test',
  content: 'test',
  user: null,
});
[Nest] 86234  - 2024. 12. 12. 오후 10:26:51   ERROR [ExceptionsHandler] Field 'userId' doesn't have a default value

 

 

이를 해결하기 위해 userId 컬럼에도 null 옵션을 추가해 보았습니다.

# Post 엔티티

@Entity()
export class Post {
  @PrimaryGeneratedColumn({ type: 'int' })
  id: number;

  @Column({ type: 'varchar' })
  title: string;

  @Column({ type: 'text' })
  content: string;

  @ManyToOne(() => User, (user) => user.posts, { nullable: true })
  user: User;

  @Column({ nullable: true }) // 여기도 같은 null 옵션 추가
  userId: number;
}

이렇게 설정하니 위 메서드가 문제없이 실행되는 모습을 확인할 수 있었습니다. 또한 @ManyToOne으로 설정된 user에서는 제약조건을 제거하고 @Column으로 설정된 userId에만 제약조건을 추가해도 문제없이 실행되는 모습을 확인할 수 있었습니다. 즉, 외래키 컬럼을 직접 명시하는 경우 직접 명시한 컬럼에 설정된 제약조건이 데이터베이스에 적용되는 것으로 확인되었습니다.

 

 


마무리

결과적으로는 효율적인 연관 관계를 맺고 조회를 하기 위해서는 외래키로 설정된 컬럼을 엔티티에 직접 한 번 더 명시하는 방법이 가장 좋다고 느꼈습니다. 해당 방법을 통해 외래키 정보를 간단하게 조회할 수 있었고, 외래키 정보만 가지고 join 없이 간단하게 조회도 가능했기 때문입니다.