데이터베이스, ORM/TypeORM

[TypeORM] @OneToMany, @ManyToOne - 옵션: eager / lazy

SparkIT 2024. 11. 23. 07:15
[TypeORM] @OneToMany, @ManyToOne - 옵션: cascade
[TypeORM] @OneToMany, @ManyToOne - 옵션: eager / lazy
[TypeORM] @OneToMany, @ManyToOne - 옵션: onDelete / onUpdate

 

typeorm 사용해서 1:N 관계, N:1 관계를 구현할 때 사용되는 eager / lazy 옵션에 대한 설명입니다.

 


eager / lazy

설명에 앞서 설명에서 사용될 용어들을 미리 설명하고 가겠습니다.

  • 부모(엔티티)
    1:N 관계에서 1 쪽 엔티티를 의미. 게시물과 댓글 관계에서 게시물이 부모 역할을 한다.
  • 자식(엔티티)
    1:N 관계에서 N 쪽 엔티티를 의미. 게시물과 댓글 관계에서 댓글이 자식 역할을 한다.

정의

연관된 데이터의 로딩 옵션입니다. 두 옵션 모두 boolean값이고, eager loading 할지 말지, lazy loading을 할지 말지 정하는 옵션입니다. typeorm에서는 기본적으로 lazy loading, eager loading 모두 적용되지 않습니다.(특정 엔티티 조회 후 연관 관계 맺고 있는 엔티티 정보 요구하면 undefined 처리해 버림)

하지만 eager 옵션을 true로 설정한다면 엔티티 데이터를 조회할 때 relations 옵션을 사용하지 않아도 연관된 엔티티 데이터도 모두 조회합니다. 반대로 lazy 옵션을 true로 설정한다면 엔티티 데이터를 조회할 때 연관된 엔티티 데이터는 조회해주지 않지만, 이후에 await 조회한 엔티티변수명. lazy로딩하고 싶은 엔티티변수명이런 식으로 추가 조회 가능합니다.( 더 정확한 예시는 아래 예제에서 )

 

📄 설정값

  • eager
    true / false
  • lazy
    true / false

제가 직접 사용해 본 결과 기본적으로 eager과 lazy 옵션은 false디폴트값으로 보입니다. 두 옵션 모두 사용하지 않은 채 부모 엔티티를 조회하거나 자식 엔티티를 조회해도 연관 관계 맺고 있는 엔티티 정보는 확인되지 않고, 조회된 값을 조회하면 undefined 처리됩니다. 즉, 해당 옵션들을 사용하기 위해서는 true 설정을 해줘야 합니다.

또한 eager과 lazy 옵션은 부모 엔티티, 자식 엔티티 모두에서 사용 가능한 옵션입니다. eager/lazy 옵션을 설정하면 해당 값을 eager loading 할지 lazy loading 할지 정하게 됩니다. 예를 들어 부모 엔티티에서 @OneToMany로 설정한 자식 엔티티에 관한 값에 { eager: true } 옵션이 적용되면 부모 엔티티 조회 시 자식 엔티티 정보도 한 번에 조회합니다. 또는 자식 엔티티에서 @ManyToOne으로 설정한 부모 엔티티에 대한 값에 { lazy: true } 옵션이 적용되면 자식 엔티티 조회 시 부모 엔티티 정보는 조회되지 않지만, 추후에 해당 부모 엔티티 정보를 손쉽게 추가 요청할 수 있습니다.( 이 추가 요청 방법은 아래 예제에서 설명 )

 

🔍  예제

1. 부모 엔티티에서 @OneToMany() 값에 { eager: true } 적용

# 게시물

@Entity()
export class Post {
  @OneToMany(() => Comment, (comment) => comment.post, { eager: true })
  comments: Comment[];
}
# 댓글 

@Entity()
export class Comment {
  @ManyToOne(() => Post, (post) => post.comments)
  post: Post;
}

게시물(Post) 엔티티에서 @OneToMany 설정한 댓글 변수(comments)에 { eager: true } 옵션을 적용한 예제입니다.

이 경우 부모 엔티티에 eager: true 옵션을 적용했으므로 부모(게시물)를 조회한다면 자식(댓글) 정보도 모두 조회됩니다.

# 서비스 코드 중 일부

@Injectable()
export class PostService {
	constructor(
		@InjectRepository(Post)
		private readonly postRepository: Repository<Post>,
		@InjectRepository(PostComment)
		private readonly postCommentRepository: Repository<PostComment>,
	) {}
    
    async findOne() {
		const posts = await this.postRepository.findBy({
			id: 1,
		});
        	console.log(posts); // 여기서 댓글 정보도 모두 조회됨
		return `This action returns a 1 post`;
	}
    
 }

 

2. 자식 엔티티에서 @ManyToOne() 값에 { lazy: true } 적용

# 게시물

@Entity()
export class Post {
  @OneToMany(() => Comment, (comment) => comment.post)
  comments: Comment[];
}
# 댓글 

@Entity()
export class Comment {
  @ManyToOne(() => Post, (post) => post.comments, { lazy: true })
  post: Post;
}

댓글(Comment) 엔티티에서 @ManyToOne 설정한 게시물 변수(post)에 { lazy: true } 옵션을 적용한 예제입니다.

이 경우 자식 엔티티(댓글)를 조회해도 처음에는 게시물 정보가 표현되지 않습니다. 하지만 아래와 같이 게시물 정보를 요청하면 게시물 정보를 조회하기 위한 쿼리를 한 번 더 보냅니다. 결과적으로 댓글이 달린 게시물의 정보도 확인 가능합니다.

# 서비스 코드 중 일부

@Injectable()
export class PostService {
	constructor(
		@InjectRepository(Post)
		private readonly postRepository: Repository<Post>,
		@InjectRepository(PostComment)
		private readonly postCommentRepository: Repository<PostComment>,
	) {}
    
    async findOne() {
		const postComments = await this.postCommentRepository.findBy({
			id: 1,
		});
		console.log(postComments); // 여기서는 댓글 정보만 표현됨. 게시물 정보는 보이지 않음
		console.log(await postComments[0].post);  // await postComments.post로 게시물 정보 요청 쿼리 보냄. 이를 통해 게시물 정보도 조회 가능
		return `This action returns a 1 postComment`;
	}
 }

 

🧐 예제에서 사용되는 실제 쿼리

위 예제에서 사용되는 실제 쿼리를 보면서 typeorm 동작 방식에 대해 더 알아봅시다

1. 부모 엔티티에서 @OneToMany() 값에 { eager: true } 적용

// query
query:
SELECT
    `Post`.`id` AS `Post_id`,
    `Post`.`topic` AS `Post_topic`,
    `Post`.`title` AS `Post_title`,
    `Post`.`tag` AS `Post_tag`,
    `Post`.`content` AS `Post_content`,
    `Post`.`createdAt` AS `Post_createdAt`,
    `Post__postComments`.`id` AS `Post__postComments_id`,
    `Post__postComments`.`content` AS `Post__postComments_content`,
    `Post__postComments`.`postId` AS `Post__postComments_postId`
FROM `post` `Post`
LEFT JOIN `post_comment` `Post__postComments`
ON `Post__postComments`.`postId`=`Post`.`id`
WHERE ((`Post`.`id` = ?)) -- PARAMETERS: [1]

// result
[
  Post {
    id: 1,
    topic: 'it',
    title: '첫번째 게시물',
    tag: 'db',
    content: '본문내용입니다',
    createdAt: 2024-11-22T19:47:30.001Z,
    postComments: [ [PostComment], [PostComment] ]
  }
]

eager loading을 이용해 게시물 정보를 조회할 때,  LEFT JOIN을 활용해 댓글들도 모두 조회하는 모습을 확인할 수 있습니다.

 

2. 자식 엔티티에서 @ManyToOne() 값에 { lazy: true } 적용

// query
query:
SELECT
    `PostComment`.`id` AS `PostComment_id`,
    `PostComment`.`content` AS `PostComment_content`,
    `PostComment`.`postId` AS `PostComment_postId`
FROM `post_comment` `PostComment`
WHERE ((`PostComment`.`id` = ?)) -- PARAMETERS: [1]

// result
[ PostComment { id: 1, content: '첫번째 댓글입니다' } ]


// query - await postComments[0].post 실행 시 추가 요청됨
query:
SELECT
    `post`.`id` AS `post_id`,
    `post`.`topic` AS `post_topic`,
    `post`.`title` AS `post_title`,
    `post`.`tag` AS `post_tag`,
    `post`.`content` AS `post_content`,
    `post`.`createdAt` AS `post_createdAt`
FROM `post` `post`
INNER JOIN `post_comment` `PostComment`
ON `PostComment`.`postId` = `post`.`id`
WHERE `PostComment`.`id` IN (?) -- PARAMETERS: [1]

// result
Post {
  id: 1,
  topic: 'it',
  title: '첫번째 게시물',
  tag: 'db',
  content: '본문내용입니다',
  createdAt: 2024-11-22T19:47:30.001Z
}

lazy loading을 이용해 댓글에 대한 게시물 정보를 요청할 때 추가 쿼리를 실행하는 모습을 확인할 수 있습니다.

 

 


추가 정보

1. { eager: true } 옵션을 부모, 자식 엔티티 양쪽에 모두 설정하면 순환 참조 문제가 발생하지 않나?

  • 순환 참조란?
    eager 옵션을 1:N 관계의 두 엔티티에 모두 적용한다면 무한 순환 참조 문제가 발생합니다. 예를 들어 게시물과 댓글 엔티티 모두에 eager 옵션을 true로 설정했다고 가정합시다. 이때 게시물에 대한 정보를 조회한다면 게시물 엔티티의 eager 옵션으로 인해 댓글 정보들도 모두 로드됩니다. 하지만 이렇게 댓글 정보를 로드하는 과정에서도 댓글 엔티티에 eager 옵션이 true로 설정되어 있기 때문에 해당 댓글과 연관 관계를 맺고 있는 게시물 정보를 조회하죠. 하지만 게시물 정보를 조회할 때도 마찬가지로 해당 게시물 정보와 연관 관계를 맺고 있는 댓글 정보를 로드하고... (무한반복)

맞습니다. 그래서 typeorm에서 이런 경우 에러를 발생시킵니다. 아래는 NestJS에서 typeorm 사용 시 부모, 자식 엔티티에 모두 eager: true 옵션을 적용했을 때 발생되는 에러 예시입니다.

[Nest] 75708  - 11/23/2024, 6:57:43 AM   ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
TypeORMError: Circular eager relations are disallowed. PostComment#post contains "eager: true", and its inverse side Post#postComments contains "eager: true" as well. Remove "eager: true" from one side of the relation.
    at <anonymous> (/usr/src/app/.yarn/__virtual__/typeorm-virtual-72bdba46f7/0/cache/typeorm-npm-0.3.20-3cdc45367a-9d6e5ecd06.zip/node_modules/typeorm/metadata-builder/src/metadata-builder/EntityMetadataValidator.ts:360:27)

 

2. { eager: true } 옵션 사용 시 JOIN 쿼리문 이전에 'SELECT DISTINCT ~'  쿼리문이 실행돼요

query: SELECT DISTINCT `distinctAlias`.`Post_id` AS `ids_Post_id` FROM (SELECT `Post`.`id` AS `Post_id`, `Post`.`topic` AS `Post_topic`, `Post`.`title` AS `Post_title`, `Post`.`tag` AS `Post_tag`, `Post`.`content` AS `Post_content`, `Post`.`createdAt` AS `Post_createdAt`, `Post__postComments`.`id` AS `Post__postComments_id`, `Post__postComments`.`content` AS `Post__postComments_content`, `Post__postComments`.`postId` AS `Post__postComments_postId` FROM `post` `Post` LEFT JOIN `post_comment` `Post__postComments` ON `Post__postComments`.`postId`=`Post`.`id` WHERE ((`Post`.`id` = ?))) `distinctAlias` ORDER BY `Post_id` ASC LIMIT 1 -- PARAMETERS: [1]

저는 typeorm에서 eager: true 옵션을 사용하면 join 쿼리문 하나만 실행된다고 생각했습니다. 하지만 어떤 경우는 두 개의 쿼리가 실행되고 있었습니다. 여러 테스트 결과 이는 아마도 typeorm의 findOne, find 메서드 차이인 것 같습니다. findOne 메서드는 find와 다르게 단 하나의 데이터만 반환합니다. 이를 위해서 SELECT DISTINCT로 시작되는 쿼리가 실행되어 단 하나의 값만 반환하는 쿼리가 한 번 실행되는 것 같습니다.

예를 들어 다음과 같은 코드를 작성했다고 가정합시다.

async findOne(id: number) {
    const post = await this.postRepository.findOneBy({
        topic: 'it',
    });
    console.log(post);
    return `This action returns a #${id} post`;
}

// query
query:
SELECT DISTINCT
    `distinctAlias`.`Post_id` AS `ids_Post_id`
FROM (
      SELECT
          `Post`.`id` AS `Post_id`,
          `Post`.`topic` AS `Post_topic`,
          `Post`.`title` AS `Post_title`,
          `Post`.`tag` AS `Post_tag`,
          `Post`.`content` AS `Post_content`,
          `Post`.`createdAt` AS `Post_createdAt`,
          `Post__postComments`.`id` AS `Post__postComments_id`,
         `Post__postComments`.`content` AS `Post__postComments_content`,
         `Post__postComments`.`postId` AS `Post__postComments_postId`
      FROM `post` `Post`
      LEFT JOIN `post_comment` `Post__postComments`
      ON `Post__postComments`.`postId`=`Post`.`id`
      WHERE ((`Post`.`topic` = ?))) `distinctAlias`
      ORDER BY `Post_id` ASC LIMIT 1 -- PARAMETERS: ["it"]
query:
SELECT
    `Post`.`id` AS `Post_id`,
    `Post`.`topic` AS `Post_topic`,
    `Post`.`title` AS `Post_title`,
    `Post`.`tag` AS `Post_tag`,
    `Post`.`content` AS `Post_content`,
    `Post`.`createdAt` AS `Post_createdAt`,
    `Post__postComments`.`id` AS `Post__postComments_id`,
    `Post__postComments`.`content` AS `Post__postComments_content`,
    `Post__postComments`.`postId` AS `Post__postComments_postId`
FROM `post` `Post`
LEFT JOIN `post_comment` `Post__postComments`
ON `Post__postComments`.`postId`=`Post`.`id`
WHERE ( ((`Post`.`topic` = ?)) )
AND ( `Post`.`id` IN (1) ) -- PARAMETERS: ["it"]

이때 post 테이블에서 topic 칼럼은 중복 가능하다고 가정합시다. 그러면 topic이 it인 게시물을 굉장히 많을 수 있습니다. 이때 findOneBy 메서드를 사용했으므로 topic이 it인 게시물 중 하나만 골라서 해당 게시물의 댓글들까지 모두 조회해야 합니다. 이를 위해서는 SELECT DISTINCT로 시작되는 쿼리가 꼭 필요하겠죠? 만약 findOneBy가 아니라 find 메서드라면 어떻게 될까요?

query:
SELECT
    `Post`.`id` AS `Post_id`,
    `Post`.`topic` AS `Post_topic`,
    `Post`.`title` AS `Post_title`,
    `Post`.`tag` AS `Post_tag`,
    `Post`.`content` AS `Post_content`,
    `Post`.`createdAt` AS `Post_createdAt`,
    `Post__postComments`.`id` AS `Post__postComments_id`,
    `Post__postComments`.`content` AS `Post__postComments_content`,
    `Post__postComments`.`postId` AS `Post__postComments_postId`
FROM `post` `Post`
LEFT JOIN `post_comment` `Post__postComments`
ON `Post__postComments`.`postId`=`Post`.`id`
WHERE ((`Post`.`topic` = ?)) -- PARAMETERS: ["it"]

// result
[
  Post {
    id: 1,
    topic: 'it',
    title: '첫번째 게시물',
    tag: 'db',
    content: '본문내용입니다',
    createdAt: 2024-11-22T19:47:30.001Z,
    postComments: [ [PostComment], [PostComment] ]
  },
  Post {
    id: 2,
    topic: 'it',
    title: '음식게시물',
    tag: '한식',
    content: '백종원짱짱맨',
    createdAt: 2024-11-23T02:22:08.382Z,
    postComments: [ [PostComment], [PostComment] ]
  }
]

위처럼 SELECT DISTINCT 쿼리는 사라지고 단순 JOIN 쿼리만 실행됩니다. 결과적으로는 topic이 it인 게시물이 모두 반환되고 각 게시물에 대한 댓글들도 모두 조회되고 있습니다.

결과적으로 제가 생각하기에는 PK값 같이 명확하게 데이터를 특정할 수 있는 값을 이용해 조회한다면 findOne 메서드 대신 find 메서드를 사용해 SELECT DISTINCT 쿼리를 실행하지 않고 원하는 데이터를 조회하는 것이 좋다고 생각합니다. 반대로 여러 데이터 중 하나만 조회하려면 findOne 메서드를 사용해 직접 SELECT DISTINCT 쿼리문을 짜지 않고 간단하게 원하는 데이터를 조회할 수 있을 것 입니다.