SpringBoot N+1 문제 (feat. Fetch Join)

Jeong Choi(최현정)·2023년 9월 6일
0

프로젝트를 진행하는 과정에서 N+1 문제를 만나 Fetch Join으로 해결한 경험을 담아서 작성했습니다.

1) N+1 문제란❓

연관관계가 설정된 Entity들 중에서 하나의 Entity를 조회했을 때, 조회된 Entity(최대 N개)만큼 최대 N개의 쿼리문이 추가적으로 발생하는 경우입니다.

2) N+1 문제가 발생하는 경우: 즉시로딩

  • 즉시로딩(@ManyToOne(fetch = FetchType.EAGER))

즉시로딩(Eager)은 엔티티를 조회할 때, 그 엔티티와 연관된 모든 엔티티를 함께 조회하는 전략입니다.

@ManyToOne 의 기본값은 즉시로딩이며 프로젝트나 실무에서는 거의 쓰이지 않으며,

필규(현정이)는 프로젝트를 진행하면서 단 한 번도 즉시로딩을 써본 적이 없습니다.

[사용자(user)게시글(post)의 관계는 1:N]

public class Post extends BaseEntity {

  @Id
  @GeneratedValue
  @Column(name = "post_id", nullable = false)
  private Long id;

  @Column(name = "title", nullable = false, length = 100)
  private String title;

  @Column(name = "content", nullable = false, length = 500)
  private String content;

  @Column(name = "picture_address", nullable = false, length = 2000)
  private String picture_address;

  @ManyToOne(fetch = FetchType.**EAGER**) //즉시로딩으로 설정
  @JoinColumn(name = "user_id", nullable = false)
  private User user;

	..//아래 생략

[게시글(post) DB]

[사용자(user) DB]

게시글(post)을 전체 조회하면 아래와 같은 쿼리문이 발생합니다.

Hibernate: // 게시글 조회 쿼리 (1번)
    select
        post0_.post_id as post_id1_1_,
        post0_.created_at as created_2_1_,
        post0_.is_active as is_activ3_1_,
        post0_.updated_at as updated_4_1_,
        post0_.content as content5_1_,
        post0_.picture_address as picture_6_1_,
        post0_.title as title7_1_,
        post0_.user_id as user_id8_1_ 
    from
        post post0_ 
    where
        post0_.is_active=?
Hibernate: // 사용자 조회 쿼리 (2번)
    select
        user0_.user_id as user_id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.is_active as is_activ3_2_0_,
        user0_.updated_at as updated_4_2_0_,
        user0_.email as email5_2_0_,
        user0_.location as location6_2_0_,
        user0_.nickname as nickname7_2_0_,
        user0_.password as password8_2_0_ 
    from
        user user0_ 
    where
        user0_.user_id=?
Hibernate: // 사용자 조회 쿼리 (3번)
    select
        user0_.user_id as user_id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.is_active as is_activ3_2_0_,
        user0_.updated_at as updated_4_2_0_,
        user0_.email as email5_2_0_,
        user0_.location as location6_2_0_,
        user0_.nickname as nickname7_2_0_,
        user0_.password as password8_2_0_ 
    from
        user user0_ 
    where
        user0_.user_id=?
Hibernate: // 사용자 조회 쿼리 (4번)
    select
        user0_.user_id as user_id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.is_active as is_activ3_2_0_,
        user0_.updated_at as updated_4_2_0_,
        user0_.email as email5_2_0_,
        user0_.location as location6_2_0_,
        user0_.nickname as nickname7_2_0_,
        user0_.password as password8_2_0_ 
    from
        user user0_ 
    where
        user0_.user_id=?

게시글(post)을 조회했다면 1번의 select 쿼리문이 나가야 하는 데 총 4번의 쿼리문이 나갔습니다. (오잉? 모냐 🤪🤪🤪)

즉시로딩이기 때문에 단순히 게시글(post)만 조회를 해도 사용자(user)까지 조회하는 쿼리문이 나가게 됩니다.

지금은 사용자가(user) 3명이지만 수백만, 수천명이라면 단순히 게시글(post)를 조회할 때 추가적으로 수백, 수천만의 쿼리가 나갈 수 있기때문에 되도록 즉시로딩을 권장하진 않습니다.

단순히 게시글(post)만 조회할 때 발생하는 N+1 문제는 지연로딩으로 해결할 수 있습니다.

  • 지연로딩으로 해결(@ManyToOne(fetch = FetchType.*LAZY*))

지연로딩(Lazy)은 엔티티를 조회할 때, 그 엔티티와 연관된 모든 엔티티를 함께 조회하지 않습니다.

연관된 엔티티가 필요한 시점에 연관된 객체의 데이터를 조회하는 전략입니다.

[사용자(user)게시글(post)의 관계는 1:N]

public class Post extends BaseEntity {

  @Id
  @GeneratedValue
  @Column(name = "post_id", nullable = false)
  private Long id;

  @Column(name = "title", nullable = false, length = 100)
  private String title;

  @Column(name = "content", nullable = false, length = 500)
  private String content;

  @Column(name = "picture_address", nullable = false, length = 2000)
  private String picture_address;

  @ManyToOne(fetch = FetchType.**LAZY**) //지연로딩으로 설정
  @JoinColumn(name = "user_id", nullable = false)
  private User user;

	..//아래 생략

게시글(post)을 전체 조회하면 아래와 같은 쿼리문이 발생합니다.

Hibernate: // 게시글 조회 쿼리 (1번)
    select
        post0_.post_id as post_id1_1_,
        post0_.created_at as created_2_1_,
        post0_.is_active as is_activ3_1_,
        post0_.updated_at as updated_4_1_,
        post0_.content as content5_1_,
        post0_.picture_address as picture_6_1_,
        post0_.title as title7_1_,
        post0_.user_id as user_id8_1_ 
    from
        post post0_ 
    where
        post0_.is_active=?

지연로딩(Lazy)을 사용하면 게시글(post)을 조회하는 1번의 쿼리문만 나가게 됩니다.

즉, 게시글(post)을 조회하면 JPA는 사용자의 proxy 객체를 생성하게 되며 사용자(user)의 proxy 객체는 사용자(user)를 조회할 때 쿼리문이 나가게 됩니다.

3) N+1 문제가 발생하는 경우: 지연로딩

즉시로딩을 지연로딩으로 바꾸니 N+1 문제가 해결되었습니다. 그렇다면 지연로딩을 사용하면 N+1이 발생하지 않을까요? 안타깝게도 세상은 그렇게 호락하지 않습니다.

지연로딩은 연관된 Entity를 proxy 객체로 걸어두고, 그 Entity를 실제로 조회하고자 할 때 쿼리문이 날라가기 때문에 게시글(post)을 조회할 때에는 N+1 문제가 발생하지 않습니다.

하지만 추가적으로 게시글(post)을 조회 후에 게시글(post)의 사용자(user)에 대한 정보를 조회하는 시점에 추가적인 쿼리문이 날라가게 되어 N+1 문제가 발생하게 됩니다.

아래는 제가 진행하고 있던 프로젝트에서 지연로딩으로 진행했으나 N+1이 발생한 경우를 담고 있습니다.

[사용자(user)댓글(comment)의 관계는 1:N]

[게시글(post)댓글(comment)의 관계는 1:N]

public class Comment extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id", nullable = false)
    private Long id;

    @Column(name = "content", length = 100, nullable = false)
    private String content;

    @ManyToOne(fetch = FetchType.**LAZY**) //지연로딩으로 설정
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

		@ManyToOne(fetch = FetchType.**LAZY**) //지연로딩으로 설정
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

		//아래 생략

[댓글(comment) DB]

[사용자(user) DB]

[Swagger: 2번 게시글(post)댓글(comment) 조회]

2번 게시글(post)의 댓글(comment)을 조회하면 아래와 같은 쿼리문이 발생합니다.

Hibernate: //댓글 조회 쿼리 (1번)
    select
        comment0_.comment_id as comment_1_0_,
        comment0_.created_at as created_2_0_,
        comment0_.is_active as is_activ3_0_,
        comment0_.updated_at as updated_4_0_,
        comment0_.content as content5_0_,
        comment0_.post_id as post_id6_0_,
        comment0_.user_id as user_id7_0_ 
    from
        comment comment0_ 
    where
        comment0_.is_active=1 
        and comment0_.post_id=?
Hibernate: //사용자 조회 쿼리 (2번)
    select
        user0_.user_id as user_id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.is_active as is_activ3_2_0_,
        user0_.updated_at as updated_4_2_0_,
        user0_.email as email5_2_0_,
        user0_.location as location6_2_0_,
        user0_.nickname as nickname7_2_0_,
        user0_.password as password8_2_0_ 
    from
        user user0_ 
    where
        user0_.user_id=?
Hibernate: //사용자 조회 쿼리 (3번)
    select
        user0_.user_id as user_id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.is_active as is_activ3_2_0_,
        user0_.updated_at as updated_4_2_0_,
        user0_.email as email5_2_0_,
        user0_.location as location6_2_0_,
        user0_.nickname as nickname7_2_0_,
        user0_.password as password8_2_0_ 
    from
        user user0_ 
    where
        user0_.user_id=?
Hibernate: //사용자 조회 쿼리 (4번)
    select
        user0_.user_id as user_id1_2_0_,
        user0_.created_at as created_2_2_0_,
        user0_.is_active as is_activ3_2_0_,
        user0_.updated_at as updated_4_2_0_,
        user0_.email as email5_2_0_,
        user0_.location as location6_2_0_,
        user0_.nickname as nickname7_2_0_,
        user0_.password as password8_2_0_ 
    from
        user user0_ 
    where
        user0_.user_id=?

위에서 언급했듯이, 지연로딩으로 설정을 해놓아도 연관관계가 걸린 사용자(user)의 닉넴(nickname)을 조회하는 시점에서 쿼리문이 추가적으로 나가기 때문에 N+1 문제가 발생하게 됩니다.

4) N+1 문제 해결: Fetch Join

결국 지연로딩에서도 N+1 문제가 발생했습니다. 이 경우에도 문제를 해결할 수 있을까요?

해결할 수 있는 여러 방법 중에 제가 이번 프로젝트에서 적용해 보았던 FetchJoin에 대해서 이야기 해보겠습니다.

  • Fetch Join

연관된 엔티티들을 한 번의 쿼리로 함께 조회하는 방식입니다. Fetch Join을 사용하여 N+1 문제를 해결할 수 있습니다.

[Fetch Join을 추가]

public interface CommentRepository extends JpaRepository<Comment, Long> {

  @Query("SELECT DISTINCT c FROM Comment c JOIN FETCH c.user WHERE c.isActive = true AND c.post.id=:id")
  List<Comment> findByPostId(@Param("id") Long id);
}

2번 게시글의 댓글 조회하면 아래와 같은 쿼리문이 발생합니다. (FetchJoin 사용)

Hibernate: // 댓글 조회 쿼리 (1번)
    select
        distinct comment0_.comment_id as comment_1_0_0_,
        user1_.user_id as user_id1_2_1_,
        comment0_.created_at as created_2_0_0_,
        comment0_.is_active as is_activ3_0_0_,
        comment0_.updated_at as updated_4_0_0_,
        comment0_.content as content5_0_0_,
        comment0_.post_id as post_id6_0_0_,
        comment0_.user_id as user_id7_0_0_,
        user1_.created_at as created_2_2_1_,
        user1_.is_active as is_activ3_2_1_,
        user1_.updated_at as updated_4_2_1_,
        user1_.email as email5_2_1_,
        user1_.location as location6_2_1_,
        user1_.nickname as nickname7_2_1_,
        user1_.password as password8_2_1_ 
    from
        comment comment0_ 
    inner join
        user user1_ 
            on comment0_.user_id=user1_.user_id 
    where
        comment0_.is_active=1 
        and comment0_.post_id=?
profile
Node와 DB를 사랑하는 백엔드 개발자입니다:)

0개의 댓글