게시글 목록 API는 한 번만 호출했는데 SQL 로그에는 작성자를 찾는 SELECT가 여러 번 이어졌습니다. 목록 자체는 정상적으로 반환됐지만, 게시글 수가 늘어날수록 데이터베이스 왕복도 함께 늘어날 수 있는 구조였습니다.
이번 문제는 단순히 FetchType을 바꾸는 것으로 끝내지 않고, 어느 시점에 어떤 연관 데이터를 사용하는지부터 확인해야 했습니다.
목록 조회 뒤에 숨어 있던 추가 쿼리
게시글과 작성자는 ManyToOne 관계이고 Post.user는 지연 로딩으로 설정되어 있었습니다. 게시글
목록만 조회할 때는 User를 가져오지 않지만, 응답 DTO를 만드는 과정에서는 작성자의 닉네임과 프로필
이미지가 필요했습니다.
post.getUser().getNickname();
post.getUser().getProfileImageUrl();
이 코드에서 각 게시글의 user에 처음 접근할 때마다 Hibernate가 작성자를 조회했습니다.
SELECT ... FROM posts ORDER BY created_at DESC LIMIT ?;
SELECT ... FROM users WHERE id = ?;
SELECT ... FROM users WHERE id = ?;
SELECT ... FROM users WHERE id = ?;
...
목록을 가져오는 쿼리 1번 뒤에 연관 엔티티를 위한 쿼리가 추가되는 N+1 문제였습니다.
정확히 N번 추가되는 것은 아니었다
여기서 추가 쿼리 수가 항상 게시글 개수와 같지는 않습니다. 같은 영속성 컨텍스트에서 한 번 조회한 User는 1차 캐시에 남기 때문에, 여러 게시글의 작성자가 같다면 동일한 User를 다시 SELECT하지 않을 수 있습니다.
따라서 이번 로그는 정확히 1 + N이라기보다 1 + 서로 다른 작성자 수에 가까웠습니다. 다만 목록의
데이터 구성과 영속성 컨텍스트 상태에 따라 추가 쿼리 수가 달라지고, 최악의 경우 N개까지 늘어날 수
있다는 문제는 그대로였습니다.
왜 목록 API에서 더 신경 써야 했을까
추가 SELECT가 발생하면 단순히 로그가 길어지는 데서 끝나지 않습니다. 요청마다 SQL 왕복 횟수가 늘어나고, 데이터베이스 커넥션을 점유하는 시간도 길어집니다. 동시 요청이 많아지면 커넥션 풀에서 기다리는 요청이 늘어나 같은 자원으로 처리할 수 있는 요청량이 줄어들 수 있습니다.
특히 게시글 목록은 상세 페이지보다 자주 호출되고, 한 번에 여러 게시글을 반환합니다. 데이터가 늘어날수록 비용이 함께 커지는 구조를 그대로 두기보다 조회 전략을 명확히 정할 필요가 있었습니다.
FetchType 변경만으로 해결되지 않는 이유
LAZY는 연관 엔티티를 실제로 사용할 때 가져오고, EAGER는 엔티티를 조회할 때 함께 로딩합니다.
하지만 FetchType은 언제 로딩할지를 정할 뿐, 반드시 하나의 JOIN 쿼리로 가져오도록 보장하지는
않습니다.
- LAZY에서도 목록을 순회하며 연관 객체에 접근하면 추가 쿼리가 발생할 수 있습니다.
- EAGER로 바꿔도 조회 방식에 따라 연관 엔티티를 위한 SELECT가 따로 실행될 수 있습니다.
그래서 FetchType을 바꾸는 대신, 이번 목록 조회에서 필요한 관계를 쿼리에 명시하기로 했습니다.
현재 조회에는 Fetch Join이 맞았다
게시글 목록의 모든 항목에는 작성자 닉네임과 프로필 이미지가 필요했습니다. 조회 대상도 컬렉션이
아닌 Post → User의 ManyToOne 관계였습니다. 필요한 연관 데이터를 한 번의 쿼리로 함께 가져오는
Fetch Join이 현재 조건에 가장 직접적인 해결 방법이었습니다.
return queryFactory
.selectFrom(post)
.join(post.user, user).fetchJoin()
.orderBy(post.createdAt.desc(), post.id.desc())
.limit(pageSize + 1)
.fetch();
적용 후에는 posts와 users가 JOIN된 SQL 한 번만 실행됐고, DTO 변환 과정에서
post.getUser()에 접근해도 작성자 SELECT가 반복되지 않았습니다.
SELECT
p.*,
u.*
FROM posts p
JOIN users u ON u.id = p.user_id
ORDER BY p.created_at DESC, p.id DESC
LIMIT ?;
다른 관계에서는 같은 답이 아닐 수 있다
이번에는 ToOne 관계라 Fetch Join을 적용한 상태에서도 게시글 한 건이 결과 한 행에 대응합니다. 하지만
게시글 목록에 댓글 같은 OneToMany 컬렉션까지 Fetch Join하면 상황이 달라집니다.
댓글 수만큼 결과 행이 늘어나 게시글이 중복될 수 있고, 데이터베이스의 LIMIT이 게시글이 아니라 JOIN 결과 행에 적용되면서 페이징이 깨질 수도 있습니다. 여러 컬렉션을 동시에 JOIN하면 행 수가 곱으로 증가할 가능성도 있습니다.
Batch Fetch
부모 목록은 먼저 페이징하고, 필요한 자식들을 WHERE post_id IN (...)으로 묶어 조회합니다. 부모
기준 페이징을 유지하면서 N번의 개별 조회를 일정한 수의 묶음 조회로 줄일 수 있습니다.
별도 조회 후 그룹핑
게시글 ID 목록으로 필요한 댓글을 직접 조회한 뒤, 애플리케이션에서 postId를 기준으로 묶어 DTO에
담는 방식입니다. 조회 범위를 직접 제어할 수 있지만 구현 코드가 늘어납니다.
DTO Projection
엔티티 전체가 필요하지 않은 조회 전용 API라면, 처음부터 응답에 필요한 컬럼만 JOIN해 DTO로 조회할 수 있습니다. 불필요한 엔티티 로딩을 줄일 수 있지만 도메인 로직에 엔티티가 필요한 흐름과는 구분해 사용해야 합니다.
쿼리를 고치는 것보다 먼저 조회 범위를 줄이기
어떤 전략도 관계만 보고 기계적으로 선택할 수는 없습니다. 연관관계의 방향, 컬렉션 여부, 페이징, 응답에 실제로 필요한 데이터의 범위를 함께 봐야 합니다.
TRIPFEED의 목록 API는 게시글과 작성자 정도만 조회하고, 댓글 목록은 상세 API에서 별도로 가져오도록 유지했습니다. 댓글 수와 좋아요 수 역시 목록마다 연관 데이터를 조회해 계산하지 않고 게시글의 카운터 컬럼으로 관리했습니다.
이번 개선에서 Fetch Join 자체는 한 줄이었지만, 더 중요했던 것은 추가 쿼리가 발생한 지점을 로그로 확인하고 현재 조회 형태에 맞는 방법을 선택한 과정이었습니다. 문제가 생길 때마다 JOIN을 추가하기보다, 처음부터 목록에 꼭 필요한 데이터만 가져오도록 API와 데이터 구조를 설계하는 것 역시 조회 성능을 지키는 방법이라는 점을 확인할 수 있었습니다.