데이터베이스/JPA

지연로딩 vs 즉시로딩 vs 패치조인 vs JPQL일반조인 vs 일반조인

정재익 2026. 1. 12. 19:44

1. 엔티티

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @Column(nullable = false)
    private String content;

}

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Comment {

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

    @Column(nullable = false)
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;
}

다대일 단방향으로 했다. 평소에 내가 자주쓰는 방법이다.

 

2. 실험

1. 즉시 로딩

@Test
@Transactional
public void eager() { // 일반 즉시 로딩을 실행한다.
    Comment comment = commentRepository.eagerFindById(1L);
    Post post = comment.getPost();
    System.out.println("post.getContent() = " + post.getContent());
    System.out.println("comment.getContent() = " + comment.getContent());
}

 

즉시로딩으로 댓글을 반환하여 엔티티탐색으로 글을 가져왔다.

@EntityGraph(attributePaths = {"post"})
@Query("SELECT c FROM Comment c WHERE c.id = :id")
Comment eagerFindById(@Param("id") Long id);

엔티티 그래프를 통한 리얼 즉시로딩을 했다.

 

결과는??

[Hibernate] 
    select
        c1_0.comment_id,
        c1_0.content,
        c1_0.post_id,
        p1_0.post_id,
        p1_0.content 
    from
        comment c1_0 
    join
        post p1_0 
            on p1_0.post_id=c1_0.post_id 
    where
        c1_0.comment_id=?
post.getContent() = aa
comment.getContent() = hi
총 실행된 쿼리 횟수: 1
2026-01-12T19:22:54.489+09:00  INFO 46520 --- [jpatest] [           main] i.StatisticalLoggingSessionEventListener : Session Metrics {
    21400 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    249400 nanoseconds spent preparing 1 JDBC statements;
    771500 nanoseconds spent executing 1 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    10800 nanoseconds spent executing 1 pre-partial-flushes;
    5600 nanoseconds spent executing 1 partial-flushes (flushing a total of 0 entities and 0 collections)
}

 

executing 1 JDBC statements이걸 보면 한번의 쿼리로 댓글과 글을 가져왔다.

당연히 엔티티 탐색으로 글을 호출했을때도 쿼리가 일어나지 않았을 것

다만 다대일 양방향에서 다를 대상으로 이걸했으면 바로 N+1이다.

 

2. 지연 로딩

@Test
@Transactional
public void lazy() { // 지연 로딩을 실행한다.
    Comment comment = commentRepository.findById(1L).orElseThrow();
    Post post = comment.getPost();
    System.out.println("post.getContent() = " + post.getContent());
    System.out.println("comment.getContent() = " + comment.getContent());
}

댓글가져오고 엔티티탐색으로 글 탐색했다

[Hibernate] 
    select
        c1_0.comment_id,
        c1_0.content,
        c1_0.post_id 
    from
        comment c1_0 
    where
        c1_0.comment_id=?
[Hibernate] 
    select
        p1_0.post_id,
        p1_0.content 
    from
        post p1_0 
    where
        p1_0.post_id=?
post.getContent() = aa
comment.getContent() = hi
총 실행된 쿼리 횟수: 2
2026-01-12T19:22:54.467+09:00  INFO 46520 --- [jpatest] [           main] i.StatisticalLoggingSessionEventListener : Session Metrics {
    22400 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    454100 nanoseconds spent preparing 2 JDBC statements;
    1755400 nanoseconds spent executing 2 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    0 nanoseconds spent executing 0 pre-partial-flushes;
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}

executing 2 JDBC statements 보면 두번의 쿼리가 일어났다 지연로딩할때 하나더 일어난 것

 

3. 패치 조인

N+1을 없애는대신 즉시로딩하려고 흔히 쓰는 전략이다 그런데 이건 다쪽을 실험한게 아니라 큰 영향은 없다

@Test
@Transactional
public void fetchJoin() { // 패치조인을 통한 즉시 로딩을 실행한다.
    Comment comment = commentRepository.fetchJoinFindById(1L);
    Post post = comment.getPost();
    System.out.println("post.getContent() = " + post.getContent());
    System.out.println("comment.getContent() = " + comment.getContent());
}
@Query("SELECT c FROM Comment c JOIN FETCH c.post WHERE c.id = :id")
Comment fetchJoinFindById(@Param("id") Long id);
[Hibernate] 
    select
        c1_0.comment_id,
        c1_0.content,
        c1_0.post_id,
        p1_0.post_id,
        p1_0.content 
    from
        comment c1_0 
    join
        post p1_0 
            on p1_0.post_id=c1_0.post_id 
    where
        c1_0.comment_id=?
post.getContent() = aa
comment.getContent() = hi
총 실행된 쿼리 횟수: 1
2026-01-12T19:22:54.440+09:00  INFO 46520 --- [jpatest] [           main] i.StatisticalLoggingSessionEventListener : Session Metrics {
    22400 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    579200 nanoseconds spent preparing 1 JDBC statements;
    894900 nanoseconds spent executing 1 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    28800 nanoseconds spent executing 1 pre-partial-flushes;
    9700 nanoseconds spent executing 1 partial-flushes (flushing a total of 0 entities and 0 collections)
}

한번의 쿼리로 들고왔다. SQL도 즉시로딩과 완전히 같다. 양방향은 쓰지 않지만 양방향도 실험해 볼걸그랬다 ㅠ

 

4. JPQL로 일반조인

쿼리DSL로 일반조인을 하는 것과 같은 결과이다

@Test
@Transactional
public void jpaDbJoin() { // JPQL로 일반 DB조인을 해서 댓글을 반환한다.
    Comment comment = commentRepository.normalJoinFindBy(1L);
    Post post = comment.getPost();
    System.out.println("post.getContent() = " + post.getContent());
    System.out.println("comment.getContent() = " + comment.getContent());
}
@Query("SELECT c FROM Comment c JOIN c.post p WHERE c.id = :id")
Comment normalJoinFindBy(@Param("id") Long id);
[Hibernate] 
    select
        c1_0.comment_id,
        c1_0.content,
        c1_0.post_id 
    from
        comment c1_0 
    where
        c1_0.comment_id=?
[Hibernate] 
    select
        p1_0.post_id,
        p1_0.content 
    from
        post p1_0 
    where
        p1_0.post_id=?
post.getContent() = aa
comment.getContent() = hi
총 실행된 쿼리 횟수: 2
2026-01-12T19:22:54.504+09:00  INFO 46520 --- [jpatest] [           main] i.StatisticalLoggingSessionEventListener : Session Metrics {
    30700 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    391100 nanoseconds spent preparing 2 JDBC statements;
    1708900 nanoseconds spent executing 2 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    7900 nanoseconds spent executing 1 pre-partial-flushes;
    4800 nanoseconds spent executing 1 partial-flushes (flushing a total of 0 entities and 0 collections)
}

 

왜 쿼리가 두번일어났을까?. 이것이 패치조인과 일반조인을 관통하는 질문이다 사실 이거 때매 글썼다 ㅎ

JPA로 일반조인을 하면 영속성 컨텍스트는 활성화된다. 아래 통계는 하이버네이트가 활성화 되어야 나오는 결과다

쿼리를 보면 글을 조인까지했지만 반환은 댓글만 했다.

따라서 영속성 컨텍스트가 활성화 되어있어도 댓글의 글은 프록시 상태로 남는것이다.

그래서 객체탐색때 지연로딩을 하고 쿼리가 일어나는 것

 

내가 패치조인을 할 때 쓴 쿼리

@Query("SELECT c FROM Comment c JOIN FETCH c.post WHERE c.id = :id")

 

그리고 아래는 일반조인을 할 때 쓴 쿼리

@Query("SELECT c FROM Comment c JOIN c.post p WHERE c.id = :id")

 

아주 비슷하다 하지만 패치조인을 하면 댓글의 글에는 프록시가 아닌 진짜 엔티티가 들어간다. 

 

패치조인의 셀렉트절을 보자

    select
        c1_0.comment_id,
        c1_0.content,
        c1_0.post_id,
        p1_0.post_id,
        p1_0.content

 

아래는 일반조인의 셀렉트절이다

    select
        c1_0.comment_id,
        c1_0.content,
        c1_0.post_id

 

일반조인은 id만 조회하고 패치조인은 전체를 다 조회하는 것을 볼 수 있다.

JPA를 통한 일반조인을 할거면 쿼리의 반환값에서 직접 글과 댓글의 내용을 담은 DTO를 만들어야 효과를 볼 수 있다.

하지만 그렇게하면 객체기반의 데이터 처리라는 장점을 활용할 수 없다 지연로딩 이런것도 못한다.

 

fetch란 연관데이터를 지금 이 시점에 실제 객체로 가져와서 채운다 라는 뜻이다

 

5. 진짜 일반조인

JPA같은 영속성 컨텍스트가 관여하지 않는 진짜 생짜배기 일반 DB조인이다.

@Test
@Transactional
public void dbJoin() { // JDBC 템플릿으로 일반 DB조인을 해서 댓글을 반환한다.
    Comment comment = jdbcRepo.normalJoin(1L);
    Post post = comment.getPost();
    System.out.println("post.getContent() = " + post.getContent());
    System.out.println("comment.getContent() = " + comment.getContent());
}
public Comment normalJoin(Long id) {
    String sql = "SELECT c.comment_id, c.content AS c_content, " +
            "p.post_id, p.content AS p_content " +
            "FROM comment c " +
            "INNER JOIN post p ON c.post_id = p.post_id " +
            "WHERE c.comment_id = ?";

    return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
        Post post = Post.builder()
                .id(rs.getLong("post_id"))
                .content(rs.getString("p_content"))
                .build();

        return Comment.builder()
                .id(rs.getLong("comment_id"))
                .content(rs.getString("c_content"))
                .post(post)
                .build();
    }, id);
}

쿼리부터 극혐이다.

셀렉트 *를 하면 가져올수가 없어서 하나하나 다 적어줘야 되고 별칭도 적어줘야 한다 이것을 객체안에 직접 집어넣었다.

2026-01-12T19:22:54.302+09:00 DEBUG 46520 --- [jpatest] [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL query
2026-01-12T19:22:54.303+09:00 DEBUG 46520 --- [jpatest] [           main] o.s.jdbc.core.JdbcTemplate               :
Executing prepared SQL statement [SELECT c.comment_id, c.content AS c_content, p.post_id, p.content 
AS p_content FROM comment c INNER JOIN post p 
ON c.post_id = p.post_id WHERE c.comment_id = ?]
post.getContent() = aa
comment.getContent() = hi
2026-01-12T19:22:54.323+09:00  INFO 46520 --- [jpatest] [           main] i.StatisticalLoggingSessionEventListener : Session Metrics {
    17800 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    0 nanoseconds spent preparing 0 JDBC statements;
    0 nanoseconds spent executing 0 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    0 nanoseconds spent executing 0 pre-partial-flushes;
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}

 

하이버 네이트가 활성화 되지 않아 따로 설정을 활성화해서 쿼리를 로그했다 

우선 결과를 보면 죄다 0이다 JPA를 사용안했기 때문에 세션로그에 걸리지 않은 것이다.

쿼리를 보면 한번에 가져온 것을 알수있다.

이 상태에서 댓글의 글을 가져왔을때 추가 쿼리가 안일어난이유는 간단하다 객체에 직접 값을 집어넣었기 때문이다.

하지만 더티체킹은 불가능하다 영속성컨텍스트가 활성화되지않았기 때문이다.

실행된 쿼리를 보면 패치조인과 완전히 같은것을 알 수 있다 하지만 영속성 컨텍스트가 켜지지않은 것이 차이점이다.

'데이터베이스 > JPA' 카테고리의 다른 글

세션과 커넥션  (1) 2026.01.09
스레드로컬과 세션  (0) 2026.01.09