최근 신입사원을 위한 온보딩 서비스 WELKIT 프로젝트를 출시했고, 실제 트래픽이 조금씩 발생하고 있다. 아직 사용자가 많거나 수익이 발생하는 단계도 아니지만 서비스를 공개해 사용자가 직접 접속하고 경험할 수 있는 환경을 운영하며 실서비스 경험을 쌓으려고 노력중이다.
버전 1.0을 출시하면서 가장 먼저 신경 쓴 것은 JPA 성능 최적화였다. 단발성 프로젝트에서는 이런 작업을 해볼 기회가 많지 않았지만 실제 사용자가 있는 환경을 가정했을 때 가장 중요하게 고려해야 하는 부분은 결국 최적화라는 것을 느꼈다.
사실 이런 부분은 출시 이전에 점검했어야 했지만, Hibernate가 찍어주는 로그를 꼼꼼히 확인하지 않았던 것 같다. 그 결과 특정 로직에서 N+1 문제가 발생해 불필요하게 쿼리가 반복적으로 실행되고 있었다.
데이터베이스는 Google Cloud Platform의 Cloud SQL을 사용했다. 구글에서는 신규 사용자에게 3개월 무료 크레딧을 제공하고 있는데, 우리 팀은 사이드 프로젝트로 진행하고 있었기 때문에 서버 비용 지원이 없어서 이를 활용했다.
Cloud SQL에서는 쿼리 통계를 확인할 수 있으며 이 때 데이터베이스 로드가 가장 많이 발생하는 쿼리도 쉽게 파악할 수 있다.

문제 발생 과정
문제가 되는 부분은 회사 메일 인증을 한 신입사원들만 이용할 수 있는 커뮤니티 기능에서 발생했다.
댓글과 피드백을 조회할 때, 각 포스트마다 쿼리가 반복 실행되었다.
Hibernate:
select
cp1_0.id,
cp1_0.content,
cp1_0.created_date,
cp1_0.job_role,
cp1_0.last_modified_date,
cp1_0.status,
cp1_0.title,
cp1_0.user_id
from
community_posts cp1_0
order by
cp1_0.created_date desc
limit
?, ?
Hibernate:
select
c1_0.post_id,
c1_0.id,
c1_0.comment,
c1_0.created_date,
c1_0.last_modified_date,
c1_0.parent_comment_id,
c1_0.user_id
from
community_comments c1_0
where
c1_0.post_id=?
Hibernate:
select
f1_0.target_id,
f1_0.id,
f1_0.created_date,
f1_0.is_helpful,
f1_0.last_modified_date,
f1_0.target_type,
f1_0.user_id
from
community_feedbacks f1_0
where
f1_0.target_id=?
and (
f1_0.target_type = 'POSTS'
)
Hibernate:
select
c1_0.post_id,
c1_0.id,
c1_0.comment,
c1_0.created_date,
c1_0.last_modified_date,
c1_0.parent_comment_id,
c1_0.user_id
from
community_comments c1_0
where
c1_0.post_id=?
Hibernate:
select
f1_0.target_id,
f1_0.id,
f1_0.created_date,
f1_0.is_helpful,
f1_0.last_modified_date,
f1_0.target_type,
f1_0.user_id
from
community_feedbacks f1_0
where
f1_0.target_id=?
and (
f1_0.target_type = 'POSTS'
)
- 처음에 community_posts를 조회하는 쿼리가 1번 실행되고 그 다음 포스트마다 댓글(community_comments)과 피드백(community_feedbacks)을 조회하는 쿼리가 반복 실행되고 있다.
- 예를 들어 포스트가 8개라면 댓글 8번, 피드백 8번으로 총 17번의 쿼리가 실행되는 것이다!! (포스트 1 + 댓글 8 + 피드백 8)
/** 전체 게시글 조회 **/
public PostPagePlainResponse getAllCommunityPosts(JobRole jobRole, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdDate"));
Page<CommunityPosts> posts;
if (jobRole != null) {
posts = postRepository.findAllByJobRole(jobRole, pageable);
} else {
posts = postRepository.findAll(pageable);
}
List<PostSummaryPlainResponse> postSummaries = posts.getContent().stream()
.map(post -> {
String plainContent = MarkdownUtil.markdownToPlainText(post.getContent());
return PostSummaryPlainResponse.fromEntity(post, plainContent);
})
.toList();
PostPageInfoResponse pageInfo = getPostsInfo(posts);
return PostPagePlainResponse.builder()
.postInfo(pageInfo)
.posts(postSummaries)
.build();
}
getAllCommunityPosts() 메서드를 보면 실제로는 단순히 findAll() 또는 findAllByJobRole()을 호출하고 있고,
그 이후 PostSummaryPlainResponse.fromEntity(post, plainContent)에서 post.getContent() 같은 단일 필드만 사용하고 있다.
즉, 코드만 보면 comments나 feedbacks를 직접 조회하지 않기 때문에
겉보기에는 N+1 문제가 발생할 이유가 없어 보인다.
하지만 Hibernate 로그를 보면 실제로 community_comments와 community_feedbacks를 조회하는 쿼리가 반복 실행되고 있다.
이는 Post 엔티티가 comments와 feedbacks를 각각 @OneToMany(fetch = FetchType.LAZY)로 가지고 있기 때문이다.
어딘가에서 이 컬렉션에 접근하는 코드가 존재한다면? 예를 들어 PostSummaryPlainResponse.fromEntity(post, plainContent) 과정 중 post.getComments()나 post.getFeedbacks()를 호출하고 있으면 Hibernate는 Lazy 로딩된 컬렉션을 가져오기 위해 게시글마다 개별 SELECT 쿼리를 실행하게 된다.
결국 게시글이 8개라면 댓글 쿼리가 8번, 피드백 쿼리도 8번 실행되면서 두 컬렉션 각각에서 N+1 문제가 발생하는 구조가 된다.
문제의 근본적인 원인
- 엔티티 연관관계 설계
- CommunityPosts → @OneToMany(mappedBy="post", fetch = LAZY) → 댓글 (community_comments)
- CommunityPosts → @OneToMany(fetch = LAZY) + @Where(target_type='POSTS') → 피드백 (community_feedbacks)
즉, 게시글을 조회할 때 댓글과 피드백은 Lazy Loading으로 설정되어 있기 때문에 처음 select * from community_posts로 게시글 목록을 가져올 때는 로드되지 않는다. 하지만 이후 post.getComments()나 post.getFeedbacks()를 호출하는 시점에 Hibernate는 각 게시글마다 개별 SELECT를 날리게 된다.
구조적으로 취약했던 부분
DB 설계 자체가 N+1 문제의 직접적인 원인은 아니지만 아래와 같은 구조적 결함이 JPA 입장에서 최적화를 어렵게 만들고 있었다. (등잔 밑이 어둡다..)
- community_feedbacks의 다형적 관계 (target_type + target_id)
- target_id가 posts.id일 수도, comments.id일 수도 있어서→ @OneToMany(mappedBy="...") 같은 명시적 연관관계 매핑이 불가능하고,→ 이런 구조는 JPA가 fetch join 최적화를 자동으로 해줄 수 없기 때문에 N+1 해결이 어렵다.
- 결국 “쿼리 기반”(@Where)으로 조건을 걸 수밖에 없다.
- JPA 입장에서는 정확히 어떤 테이블을 참조하는지 알 수 없다.
- @Where 사용으로 인한 비효율
- @Where(clause = "target_type = 'POSTS'")는 Hibernate가 내부적으로 “subselect”나 “조건 필터링”을 추가하게 한다.
- Fetch Join을 사용하지 않으면, Post마다 Feedback을 따로 불러옴 → N+1
- 댓글 구조 (자기참조 구조)
- parent_comment_id를 사용하는 트리 구조는 JPA에서 Lazy Loading을 기본으로 처리하므로 대댓글 접근 시에도 추가 쿼리가 반복적으로 발생할 가능성이 높다.
해결 방법
1. @EntityGraph
- 필요한 연관 엔티티를 미리 fetch
- 컬렉션 두 개 이상일 때 MultipleBagFetchException 발생
@EntityGraph(attributePaths = {"comments", "feedbacks"})
Page<CommunityPosts> findAll(Pageable pageable);
2. JPQL fetch join
- 단일 컬렉션이면 가능하지만 나의 경우 하나의 엔티티에서 @OneToMany 컬렉션이 두 개 이상이므로 MultipleBagFetchException 발생
@Query("SELECT p FROM CommunityPosts p LEFT JOIN FETCH p.comments")
List<CommunityPosts> findAllWithComments();
3. DTO projection
- 두 컬렉션 모두 가져올 필요가 있을 때
- N+1 완전히 회피는 어려움
4. @BatchSize
- lazy 컬렉션 접근 시 한 번에 여러 엔티티 batch fetch
@OneToMany(mappedBy = "post")
@BatchSize(size = 10)
private List<Comment> comments;
@BatchSize를 통한 해결 시도
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
@BatchSize(size = 10)
private List<CommunityComments> comments = new ArrayList<>();
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
@Where(clause = "target_type = 'POSTS'")
@JoinColumn(name = "target_id", insertable = false, updatable = false)
@Builder.Default
@BatchSize(size = 10)
private List<CommunityFeedBack> feedbacks = new ArrayList<>();
Hibernate:
select
cp1_0.id,
cp1_0.content,
cp1_0.created_date,
cp1_0.job_role,
cp1_0.last_modified_date,
cp1_0.status,
cp1_0.title,
cp1_0.user_id
from
community_posts cp1_0
order by
cp1_0.created_date desc
limit
?, ?
Hibernate:
select
c1_0.post_id,
c1_0.id,
c1_0.comment,
c1_0.created_date,
c1_0.last_modified_date,
c1_0.parent_comment_id,
c1_0.user_id
from
community_comments c1_0
where
c1_0.post_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate:
select
f1_0.target_id,
f1_0.id,
f1_0.created_date,
f1_0.is_helpful,
f1_0.last_modified_date,
f1_0.target_type,
f1_0.user_id
from
community_feedbacks f1_0
where
f1_0.target_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
and (
f1_0.target_type = 'POSTS'
)
게시글 10개의 댓글을 한 번에 묶어서 조회하도록 했다.
- BatchSize=10이 적용되어 있어서, Lazy 로딩 시 게시글 단위가 아닌 Batch 단위(10개)로 묶어서 SELECT 쿼리를 날린다.
- BatchSize 덕분에 각 게시글마다 쿼리가 개별로 나가는 N+1 문제를 줄인 것이다.
마찬가지로, 게시글 10개의 피드백 또한 한 번에 묶어서 조회한다.
- BatchSize 적용 전에는 게시글 하나당 1번씩 쿼리가 나갔던 것을 생각하면, 쿼리 수가 확실히 줄었다.
위에서 언급한 구조적 문제로 인해 완벽한 해결은 어려웠지만, @BatchSize를 사용해 연관 엔티티를 한 번에 로딩하도록 설정함으로써 N+1 문제를 어느 정도 해결할 수 있었다.
N+1 문제 최적화 전/후 비교 (게시글 10개 기준, 페이징 적용)
- 쿼리 수: 최적화 전 22개 → 최적화 후 4개
- 응답 시간 (JDBC 실행 시간 기준): 11.34ms → 1.96m
응답 시간이 약 5~6배 이상 단축됨을 확인할 수 있었다.
결론
JPA 성능 최적화 강의에서 다양한 방법을 소개한 이유를 이제 이해할 수 있었다. 중요한 것은 “가장 좋은 방법 하나만 아는 것”이 아니라, 상황에 맞춰 유연하게 해결 방안을 적용할 수 있어야 한다는 것이었다. 강의에서는 어떤 방법이 “좋은 방법은 아니다”, “많이 쓰는 방법은 아니다”라고 언급했지만, 그럼에도 불구하고 소개한 이유는 상황마다 최적의 해결 방식이 다르기 때문에 알고는 있어야 한다는 것..
나 또한 당시에는 대충 넘어갔던 것 같은데, 실제로 경험해보니 그때 제대로 이해하지 못한 내 자신이 부끄러웠다.. 아무튼! 잘 학습해서 어제보다 더 나은 개발을 하자 💪