프로젝트를 진행하는 과정에서 N+1 문제를 만나 Fetch Join으로 해결한 경험을 담아서 작성했습니다.
연관관계가 설정
된 Entity들 중에서 하나의 Entity를 조회
했을 때, 조회된 Entity(최대 N개)만큼 최대 N개의 쿼리문이 추가적으로 발생하는 경우입니다.
@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)
를 조회할 때 쿼리문이 나가게 됩니다.
즉시로딩을 지연로딩으로 바꾸니 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 문제가 발생하게 됩니다.
결국 지연로딩에서도 N+1 문제가 발생했습니다. 이 경우에도 문제를 해결할 수 있을까요?
해결할 수 있는 여러 방법 중에 제가 이번 프로젝트에서 적용해 보았던 FetchJoin에 대해서 이야기 해보겠습니다.
연관된 엔티티들을 한 번의 쿼리로 함께 조회하는 방식입니다. 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=?