🔖Contents
🎯학습 목표
1. 모임 목록 조회 API가 어떤 스펙(요청/응답/무한 스크롤)으로 동작해야 하는지 설명할 수 있다.
2. V1 구현을 읽으며 N+1이 발생하는 실행 흐름(쿼리 1번처럼 보이는데 왜 늘어나는지)을 재현/설명할 수 있다.
3. V2 개선안(Projection + IN 쿼리 + Map 조립)으로 N+1을 구조적으로 제거하고, 목록+페이징에서 안정적인 설계를 선택할 수 있다.
문제와 결론 살펴보기
1. 문제 요약
모임 목록 조회는 “화면에서 가장 자주 호출되는 API”인 경우가 많고, 데이터가 쌓일수록 호출 비용이 그대로 서비스 비용과 UX로 이어집니다. 그래서 목록 API는 처음 설계부터 페이징 방식, 검색 방식, 응답 구조(카드형 DTO) 를 잘 잡아야 합니다. 그런데 구현을 조금만 엔티티 중심으로 풀어가면, 겉보기에는 그룹 목록 쿼리 1번으로 끝난 것처럼 보여도 실제로는 응답 DTO를 만들면서 LAZY 연관관계를 건드리는 순간마다 쿼리가 추가로 발생합니다. 이게 바로 흔히 말하는 N+1 문제고, 목록에서는 N이 페이지 크기(10~50)라서 “평소엔 괜찮아 보이다가” 트래픽이 늘면 갑자기 병목이 됩니다.
또 하나의 함정은 페이징입니다. 전통적인 OFFSET 기반 페이지네이션은 구현이 쉽지만, 데이터가 커질수록 DB가 앞부분을 읽고 버리는 비용을 계속 지불하게 됩니다. 즉 “인덱스를 탄다”는 사실만으로 성능이 보장되지 않고, OFFSET이 커질수록 뒤 페이지에서 체감 성능이 나빠질 수 있습니다. 반면 커서 기반은 “어디서부터 읽을지”를 커서 값으로 명확히 잡아주기 때문에 인덱스가 가장 잘하는 seek + range scan 형태로 동작하기 쉬워서, 목록처럼 “계속 스크롤로 이어지는 UX”에 더 자연스럽습니다.
정리하면 이 글의 문제는 크게 2개의 축으로 나뉩니다.
- 페이징 축: OFFSET 기반은 뒤로 갈수록 느려질 수 있고, 목록 UX(무한 스크롤)와도 잘 안 맞는다.
- 데이터 접근 축: 엔티티를 가져온 뒤 DTO 변환 과정에서 연관관계를 따라가면, 쿼리 개수가 폭증하며(N+1) 목록이 병목이 된다.
그리고 이 두 문제는 따로 존재하는 게 아니라, “목록 API”라는 동일한 지점에서 동시에 터져서 실제 서비스에서는 느린 목록 + 많은 쿼리 + DB 부하 증가로 묶여 나타납니다.
2. 결론 요약
해당 글에서 선택한 결론은 “목록 API는 엔티티 그래프를 따라가며 조립하지 말고, 화면에 필요한 모양으로 DB에서 미리 뽑아오자”입니다. 즉, N+1을 fetch join 같은 단발 튜닝으로 막는 게 아니라, 쿼리 발생 지점을 설계로 끊어버리는 방식을 택합니다.
구체적으로 V2는 다음 3단계로 정리됩니다.
- 커서 기반 페이징 + size+1 패턴으로 목록을 자른다.
COUNT(*)로 전체 건수를 세지 않고도 “다음 페이지가 있는지(hasNext)”를 판단하기 위해LIMIT (size+1)을 사용합니다. 응답은 size만 내려주고, 한 개 더 가져온 데이터로 다음 페이지 존재 여부를 판별합니다. 이 패턴은 목록 API에서 매우 실용적이고, “무한 스크롤/더보기 버튼” UI와 잘 맞습니다. - 목록의 기준은 ‘그룹 1개 = row 1개’로 고정한다.
목록 쿼리는 엔티티를 반환하지 않고, QueryDSL로GroupListRow같은 Projection(Row DTO) 을 바로 가져옵니다. 이 row에는 목록 카드에 필요한 기본 정보 + 작성자(host) 정보 + 참가자 수(count)까지 포함됩니다. 여기서 중요한 건 “엔티티를 안 가져온다”는 사실 자체입니다. 엔티티가 없으면group.getImages()같은 LAZY 접근이 일어날 통로가 사라지고, 즉 N+1이 구조적으로 끊깁니다. - 태그/이미지처럼 ‘컬렉션’은 JOIN으로 한 번에 해결하지 않는다.
목록 + 페이징에서 컬렉션을 join으로 붙이면 행이 폭발해서 페이징이 깨지기 쉽습니다.(그룹 1개가 태그×이미지 만큼 중복 행으로 늘어나게 됩니다.) 그래서 V2는 컬렉션을 별도 IN 쿼리로 가져온 뒤,groupId를 키로 Map으로 묶어서 최종 응답 DTO에 “붙이는 방식”을 선택합니다. 이 전략의 장점은 “완벽한 1쿼리”가 아니라 예측 가능한 3쿼리로 수렴한다는 점입니다. 목록에서는 쿼리 수 예측 가능성이 곧 안정성이고, 성능 튜닝의 출발점이 됩니다.
결론적으로 V2는 다음을 동시에 만족시키는 방향으로 정리됩니다.
- 목록 페이징이 안정적이다(행 폭발로 페이징 깨짐 회피)
- 쿼리 수가 고정되어 운영이 예측 가능하다(트래픽 증가 시에도 급격한 폭증 방지)
- N+1을 “코드 습관”이 아니라 “설계 구조”로 차단한다
- 목록 카드 응답이 가볍고, 프론트의 무한 스크롤 UX와 맞물린다
- 최소 10번 쿼리에서 예측 가능한 3(비회원) 또는 4(회원)번의 쿼리를 수행한다.

1. 모임 목록 조회 API 스펙 정리
GET /api/v1/groups 한눈에 보기
해당 API는 “노출 가능한 모임 목록”을 커서 기반 무한 스크롤 방식으로 가져오는 엔드포인트입니다. 목록 화면(메인 피드/탐색/검색 결과)처럼 사용자가 계속 내려보며 더 가져오는 UI를 전제로 하기 때문에, 조회 방식과 응답 구조가 “페이지 번호 기반”보다는 “커서 기반”에 더 잘 맞도록 설계되어 있습니다. 모임 목록 조회 요청은 인증이 필요 없는 공개 API로 정의되어 있어, 메인 화면에서 로그인 여부와 관계없이 바로 호출할 수 있습니다. 이런 경우 목록 API는 대개 트래픽이 높아지기 쉬우므로, 처음부터 “가벼운 응답 + 예측 가능한 쿼리”를 목표로 잡는 게 안전합니다.
요청 파라미터(keyword, cursor, size)와 사용 시나리오
size (required)
한 번에 가져올 최대 개수입니다. 커서 기반 목록에서는 사용자가 스크롤할 때마다 반복 호출될 수 있으므로, 서버는 size를 그대로 신뢰하기보다는 “허용 범위”를 두는 편이 일반적입니다.(최소 1, 최대 50으로 서비스에서 제한)
cursor (optional)
다음 페이지 조회를 위한 기준값입니다. 핵심 규칙은 딱 하나입니다.
- 이전 응답의
data.nextCursor를 다음 요청의cursor로 그대로 넣는다.
첫 페이지는 cursor를 생략하고, 다음 페이지부터는 cursor를 넣어 “이전 페이지의 마지막 항목 이후”를 이어서 가져오게 됩니다.
keyword (optional)
검색 키워드가 들어오면, 제목(title), 장소(location), 태그(tag) 중 어느 하나라도 포함되면 결과에 포함되도록 OR 검색을 제공합니다. 즉 UX 관점에서는 “검색창에 단어 하나 넣었을 때, 사용자가 기대하는 범위(제목/장소/태그)를 모두 커버하는 탐색형 검색”에 가깝습니다.
이 API가 좋은 점은 keyword와 cursor를 같이 받을 수 있다는 겁니다. 즉 “검색 결과 목록을 무한 스크롤로 계속 내리기”가 가능합니다.
- 검색 + 첫 페이지:
GET /api/v1/groups?keyword=스터디&size=20 - 검색 + 다음 페이지:
GET /api/v1/groups?keyword=스터디&size=20&cursor=11
응답 구조(items, nextCursor)와 무한 스크롤 UI 연결
이 API 응답은 크게 보면 다음 2가지만 기억하면 됩니다.
items: 이번에 렌더링할 모임 카드 목록nextCursor: 다음 페이지가 있으면 커서 값, 없으면null
여기서 nextCursor가 사실상 무한 스크롤 UI와 1:1로 연결됩니다.
- 프론트는
items를 카드로 렌더링하고 - 스크롤이 바닥에 닿으면
nextCursor를 사용해서 다음 요청을 보내며 nextCursor == null이면 “더 가져올 데이터가 없음”이므로 더보기/추가 요청을 멈춥니다.
이 구조가 좋은 이유는 “클라이언트가 다음 페이지 존재 여부를 판단하기 위해 별도의 totalCount를 요구하지 않는다”는 점입니다. 목록에서 totalCount는 생각보다 비싸질 수 있고(특히 검색이 결합되면 더), 무한 스크롤 UX에서는 굳이 총 페이지 수가 없어도 자연스럽게 동작합니다.
목록 응답이 “경량 DTO”여야 하는 이유
목록 화면은 상세 화면과 성격이 다릅니다. 상세는 “한 개를 깊게 보여주는 화면”이라 관련 정보(참여 상태, 참여자 목록, 세부 정책 등)가 많아져도 감당이 되지만, 목록은 “여러 개를 한 번에 보여주는 화면”이라 응답이 무거워지면 바로 체감이 옵니다.
그래서 지금 정의된 GroupListItemResponse는 목록 카드에 필요한 핵심만 담고 있습니다.
- 기본 정보:
id,title,location,locationDetail,startTime,endTime - 카드 미리보기용:
images(URL 리스트),tags(문자열 리스트),description - 상태 판단용:
participantCount,maxParticipants - 작성자 표시용:
createdBy(userId, nickName, profileImage) - 추적용/정렬용:
createdAt,updatedAt
여기서 눈여겨볼 포인트는 두 가지입니다.
1) 엔티티 전체를 내려주지 않는다
목록에서는 이미지 엔티티의 메타데이터나 태그 엔티티의 모든 필드가 필요하지 않습니다.
“목록 카드”가 요구하는 건 대부분 문자열(URL, 태그명) 수준이기 때문에, 그 이상을 내려주면 네트워크/직렬화/조립 비용만 늘어납니다.
2) UI에서 바로 쓰기 쉬운 형태로 내려준다createdBy를 별도 객체로 묶어 둔 것도 같은 맥락입니다.
목록/상세에서 “작성자 표기”는 반복되는데, 구조를 통일해두면 프론트도 재사용이 쉬워지고 API 문서도 더 깔끔해집니다.
3. V1 구현 구조: “쿼리 1번처럼 보이지만” 위험한 이유
V1이 처음엔 ‘괜찮아 보이는’ 이유는 V1의 서비스 코드를 보면 흐름이 단순합니다.
- 요청 파라미터 정리
- 네이티브 쿼리로 그룹 목록을 한 번에 가져오기
- 가져온 결과를 DTO로 변환해서 반환
겉으로 보면 “목록 조회 쿼리 1번이면 끝”처럼 보입니다. 실제로도 아래 호출 자체는 딱 한 번입니다.
@Transactional(readOnly = true)
public GetGroupListResponse getGroupList(String keyword, Long cursor, int size) {
int pageSize = clampPageSize(size);
String normalizedKeyword = normalizeKeyword(keyword);
List<Group> fetched = groupRepository.findGroupsWithKeywordAndCursor(
normalizedKeyword, cursor, pageSize + 1
);
return toGroupListResponse(fetched, pageSize);
}
문제는 여기서 끝나지 않는다는 데 있습니다. 목록 쿼리가 끝난 뒤, DTO를 만들기 시작하는 순간부터 위험이 시작됩니다.
V1은 List<Group>(엔티티)를 들고 있고, 이 엔티티의 연관관계가 LAZY라면 “값을 꺼내는 행위” 자체가 추가 쿼리를 만들 수 있기 때문입니다.
V1의 네이티브 쿼리가 하는 일
V1 네이티브 쿼리는 크게 3가지 요구사항을 한 번에 해결합니다.
- 삭제되지 않은 그룹만 노출 (
deleted_at IS NULL) - 커서 기반 페이징 (
cursor가 있으면 id < cursor) - 키워드 검색 (title/location/tag.name OR 검색)
여기서 중요한 건 “태그 이름 검색” 때문에 조인이 필요하다는 점입니다.
- groups 테이블만 보면 tag name이 없습니다.
- 그래서 group_tags + tags 조인이 붙습니다.
- 조인을 하면 그룹이 태그 개수만큼 행이 늘어날 수 있습니다.
- 그 중복을 제거하기 위해
DISTINCT g.*가 등장합니다.
이 구조는 “한 번에 해결”이라는 장점이 있지만, 동시에 DISTINCT + JOIN + ORDER BY + LIMIT 조합이 데이터가 커질수록 부담이 될 수 있는 포인트를 갖습니다. (이건 V2에서 구조를 바꾼 배경과 이어집니다)
DISTINCT가 필요한 이유, 그리고 숨은 비용
DISTINCT가 필요한 이유는 모임 1개에 태그가 3개라면 조인 결과는 이렇게 됩니다.
- group A + tag1
- group A + tag2
- group A + tag3
즉 같은 그룹이 3번 반복됩니다. 그런데 우리는 “그룹 목록”을 원하니까 중복을 제거해야 합니다.
그래서 DISTINCT g.*로 “그룹 단위로 한 번만” 나오게 만듭니다.
근데 DISTINCT는 공짜가 아닙니다.
MySQL은 DISTINCT를 처리하기 위해 상황에 따라
- 임시 테이블을 쓰거나,
- 정렬(filesort)을 하거나,
- 중복 제거 작업을 수행해야 할 수 있습니다.
즉 V1은 “정확한 결과를 위해 비용을 지불하는 구조”가 될 수 있습니다.
여기까지만 보면 그래도 “쿼리 1번인데 뭐가 문제?” 싶지만, 진짜 문제는 다음 단계에서 터집니다.
V1에서 N+1이 발생하는 순간: DTO 변환 흐름
V1의 DTO 변환 흐름은 대략 이런 형태였습니다.
List<GroupListItemResponse> items = content.stream()
.map(this::toGroupListItemResponse)
.toList();
이 한 줄이 위험한 이유는, toGroupListItemResponse(group) 내부에서 연관관계 접근이 시작되기 때문입니다.
private GroupListItemResponse toGroupListItemResponse(Group group) {
List<String> imageUrls = extractMainImageUrls(group);
List<String> tagNames = extractTagNames(group);
int participantCount = countAttenders(group);
CreatedByResponse createdBy = CreatedByResponse.from(group.getHost());
return GroupListItemResponse.of(group, imageUrls, tagNames, participantCount, createdBy);
}
여기서 중요한 사실은 리포지토리를 추가로 호출하지 않아도, group.getImages(), group.getGroupTags(), group.getUsers(), group.getHost()처럼 LAZY 연관관계를 “접근하는 순간” Hibernate가 쿼리를 날릴 수 있습니다. 즉 “쿼리 한 번으로 끝난 줄 알았던 목록 조회”가, DTO 조립 과정에서 연쇄적으로 쿼리를 발생시키는 구조로 바뀝니다.
N+1이 터지는 지점 1: 이미지 (group.getImages())
이미지 URL을 뽑는 로직이 다음과 같은 구조일 때,
group.getImages().stream() ...
Group.images가 LAZY일 경우 그룹 1개마다 이런 쿼리가 추가로 발생할 수 있습니다.
SELECT ... FROM group_images WHERE group_id = ?
페이지에 그룹이 10개면? 목록 쿼리 1번 + 이미지 쿼리 최대 10번으로 벌써 11번입니다. 이게 N+1의 가장 전형적인 모습입니다.
N+1이 터지는 지점 2: 태그 (group.getGroupTags() → getTag())
태그는 더 위험합니다. 왜냐하면 2단계로 폭발할 수 있기 때문입니다.
group.getGroupTags()접근: 그룹 수만큼 중간 테이블 조회 쿼리 발생 가능- 각 GroupTag에서
getTag()가 LAZY라면: 태그 개수만큼 또 쿼리 발생 가능
즉, 페이지에 그룹이 N개고, 각 그룹이 태그를 여러 개 갖고 있으면1(목록) + N(groupTags) + (전체 tag 수) 형태로 늘어날 수 있습니다. 이건 단순히 “쿼리가 많다”를 넘어서, 트래픽이 늘면 DB가 순식간에 버거워지는 패턴입니다.
N+1이 터지는 지점 3: 참가자 수 계산 (group.getUsers()로 전부 로딩)
참가자 수를 계산하는 코드가 다음과 같은 구조인 경우에 비용은 2배입니다.
return (int) group.getUsers().stream()
.filter(gu -> gu.getStatus() == ATTEND)
.count();
- 첫째,
Group.users가 LAZY면 그룹마다 추가 쿼리 발생(N+1) - 둘째, count만 필요한데도 row들을 전부 로딩한다는 낭비
즉 “쿼리 수 증가”와 “데이터 로딩 낭비”가 동시에 터집니다. 목록 API에서는 이게 특히 치명적입니다.
N+1이 터질 수 있는 지점 4: host (group.getHost())
host는 many-to-one이라 컬렉션만큼 위험하진 않지만, 모델/로딩 설정에 따라 group.getHost() 접근도 추가 쿼리를 유발할 수 있습니다. 그리고 목록에서 모임이 50개면 host도 50번 접근이 생길 수 있으니, 이 역시 누적되면 무시하기 어렵습니다. V1의 핵심 문제를 한 문장으로 정리하면, V1의 구조적 문제는 단순히 “쿼리가 많다”가 아닙니다. 바로, “응답을 만들기 위한 데이터 수집이 엔티티 접근(LAZY)에 숨어 있어서, 페이지 크기(N)에 비례해 쿼리 수가 폭증할 통로가 열려 있다.” 그래서 어떤 날은 괜찮다가, 어떤 날은 갑자기 느려지고, 어떤 경우에는 데이터 분포(태그 많은 그룹, 참가자 많은 그룹)에 따라 갑자기 더 느려지는 “불안정한 성능”이 되기 쉽습니다.
4. V2의 목표: N+1을 “튜닝”이 아니라 “구조”로 막는다
V2가 해결하려는 문제를 다시 정리하기
V1에서 문제가 되는 포인트는 명확했습니다. “목록 조회 쿼리 자체는 1번처럼 보이는데, DTO를 만들기 위해 엔티티의 LAZY 연관관계를 접근하는 순간, 그룹 개수(N)에 비례해서 쿼리가 늘어나는 통로가 열려 있다”는 점입니다. 즉, 겉으로는 목록 쿼리 1번인데, 실제로는 DTO 변환 과정에서 N+1이 터질 수 있는 구조였습니다.
여기서 중요한 건, 이 문제를 fetch join 같은 ‘부분 튜닝’으로 막으려고 하면 또 다른 문제를 만나기 쉽다는 점입니다. 특히 목록 API에서 images/tags/users 같은 컬렉션을 fetch join 해버리면, 쿼리 수는 줄어든 것처럼 보여도 결과 행이 뻥튀기되면서 행 폭발, 중복 제거(DISTINCT), 페이징 깨짐 같은 문제가 연쇄적으로 발생할 수 있습니다. “N+1을 잡으려고 fetch join을 썼는데 페이지 결과가 이상해지는” 상황이 흔한 이유가 바로 이 지점이에요.
그래서 V2의 목표는 한 문장으로 정리할 수 있습니다.
- 목록 화면에 필요한 값을 엔티티 그래프를 따라가며 모으지 말고, DB에서 ‘목록 전용 형태’로 미리 뽑아오자.
- 그리고 태그/이미지 같은 컬렉션은 페이징 안정성을 깨지 않는 방식(IN 쿼리 + 조립)으로 붙이자.
V2의 큰 전략 가이드 정하기
V2는 복잡해 보이지만, 전략은 사실 단순합니다.
- 목록 기준 데이터는 “그룹 1개 = row 1개”로 QueryDSL에서 바로 뽑는다.
- 태그/이미지처럼 컬렉션은 IN 쿼리로 한 번에 가져온다.
- 서비스에서
groupId를 키로Map을 만들어 메모리에서 조립한다.
이렇게 하면 목록 호출 1번당 쿼리 수가 “대부분” 고정됩니다. 그룹 row 조회 1번, 태그 IN 조회 1번, 이미지 IN 조회 1번으로 총 3번 쿼리가 기본 흐름으로 예측 가능해집니다. V1처럼 “그룹이 30개면 쿼리도 30개 이상으로 늘어나는 구조”가 아니라, 페이지 크기와 무관하게 쿼리 개수가 고정되는 구조가 됩니다.
핵심 변화 1: “엔티티를 반환하지 않는다”
V2에서 가장 큰 구조적 변화는 이겁니다.
- V1:
List<Group>(엔티티)를 가져와서 값 꺼내며 DTO 조립 - V2:
List<GroupListRow>(Projection)을 가져와서 처음부터 필요한 값만 보유
이게 왜 중요하냐면, N+1은 대부분 이런 흐름에서 터지기 때문입니다.
- “엔티티를 들고 있다”
- “연관관계를 getter로 접근한다”
- “LAZY 로딩이 발동한다”
- “추가 쿼리가 발생한다”
반대로 Projection(Row DTO)은 엔티티가 아닙니다.
row.title()을 호출해도 DB에 다녀올 일이 없고row.hostNickName()을 호출해도 DB에 다녀올 일이 없고row.participantCount()도 이미 DB에서 계산된 숫자입니다
즉 LAZY 로딩 트리거가 되는 ‘엔티티 접근’ 자체가 사라집니다. 이게 V2를 “구조적 해결”이라고 부를 수 있는 이유입니다.
핵심 변화 2: 목록에 필요한 값만 담은 Row DTO(Projection)
V2의 Row DTO는 이 API가 필요로 하는 “목록 화면 스펙 그 자체”입니다.
public record GroupListRow(
Long groupId,
String title,
String location,
String locationDetail,
LocalDateTime startTime,
LocalDateTime endTime,
String description,
int maxParticipants,
LocalDateTime createdAt,
LocalDateTime updatedAt,
Long hostId,
String hostNickName,
String hostProfileImage,
Long participantCount
) {}
1) 모임 기본 정보는 그룹 테이블만으로 충분
목록 카드에 필요한 텍스트, 시간, 위치, 설명 같은 값은 대부분 “모임 테이블(그룹 테이블)만” 봐도 해결됩니다. 목록에서는 상세 정보(예: joinedMembers 같은 깊은 연관 데이터)까지 필요하지 않으니, 목록용 스펙을 분리하는 게 더 자연스럽습니다.
2) host 정보를 row에 포함시키는 이유
V1에서 group.getHost()는 host가 LAZY인 경우 추가 쿼리를 만들 수 있었습니다. V2에서는 host 정보를 처음 목록 쿼리에서 같이 뽑아버립니다. 그래서 createdBy는 더 이상 group.getHost()로 만들지 않고, row.hostId, row.hostNickName, row.hostProfileImage 이 값으로 바로 조립합니다. 즉, “작성자 정보 때문에 엔티티를 따라 들어가는 통로”가 끊깁니다.
3) participantCount는 “DB에서 COUNT로 끝낸다”
- V1은 참가자 수를 세기 위해
group.getUsers()를 전부 로딩하고 stream으로 count했습니다. - V2는 아예 DB에서
COUNT()로 계산해서participantCount로 내려받습니다.
목록 API에서 “count는 DB가 제일 잘하는 작업”이고, 이걸 애플리케이션이 대신하면 N+1뿐 아니라 “불필요한 데이터 로딩” 비용까지 함께 커지기 쉽습니다. V2는 이 부분을 구조적으로 끊어냅니다.
핵심 변화 3: tags/images를 Row DTO에 넣지 않은 이유
여기서 오해로 인해 가장 많이 하는 의문이 이겁니다.
“그냥 join해서 tags/images까지 한 번에 다 가져오면 더 빠른 거 아닐까?”
목록 API에서는 오히려 그게 더 위험합니다. 이유는 “컬렉션 join은 행을 폭발시키기 때문”입니다. 예를 들어 그룹 1개에 태그 3개, 이미지 3개가 있다고 가정하면, 조인 방식에 따라 결과는 최소 9행(3×3)로 늘어날 수 있습니다. 그 상태에서 LIMIT 11을 걸면, DB는 “그룹 11개”가 아니라 “행 11개”를 잘라버립니다. 결과적으로,
- 그룹 기준 페이징이 깨지고
- 중복 제거(DISTINCT)나 그룹핑이 필요해지고
- 쿼리가 더 복잡해지고
- 데이터가 커질수록 더 불안정해집니다
즉, 목록에서 컬렉션을 “한 방에 join”은 쿼리 수를 줄이는 대신 페이징 안정성을 잃는 트레이드오프가 되기 쉽습니다. 그래서 V2는 목록 기준 row(그룹 1개 = row 1개)는 절대 깨지지 않게 유지하고, 태그/이미지 같은 컬렉션은 별도로 조회해서 붙이는 전략을 택합니다.
컬렉션 보강 전략: IN 쿼리 + Map 조립이 “목록 페이징”에서 안전한 이유
V2의 컬렉션 처리 방식은 한 문장으로 요약하면 “목록의 기준은 Row로 고정하고, 컬렉션은 ‘붙이는 데이터’로 취급한다.” 입니다. 왜냐하면 목록에서 가장 중요한 건 “그룹 단위로 안정적으로 잘리는 페이징”인데, 컬렉션 조인은 결과를 행 단위로 늘려서 그 안정성을 무너뜨릴 수 있기 때문입니다. 목록에서 컬렉션 join이 위험한 이유를 ‘페이징 관점’으로 보면 더 직관적입니다.
- 그룹 1개에 태그 3개 → 그룹이 3행으로 늘어남
- 그룹 1개에 이미지 3개까지 join → 3×3 = 9행
- 그 상태에서
LIMIT size+1을 걸면 “그룹 size+1개”가 아니라 “행 size+1개”가 잘림
→ 결과적으로 “한 페이지에 그룹이 몇 개 들어갈지”가 흔들리기 시작합니다.
그래서 V2는 “쿼리 1번”을 목표로 삼지 않고, 쿼리 수가 조금 늘더라도 페이징을 안정적으로 유지하는 방식을 선택합니다.
그 결과가 IN 쿼리 + Map 조립 패턴입니다.
V2의 전체 실행 흐름(쿼리 단위로 보기)
이 파트는 “쿼리 개수 관점”으로 정리하면 독자가 훨씬 잘 따라옵니다.
1) 목록 row 조회 (size + 1) — 1쿼리
List<GroupListRow> rows =
groupV2QueryRepository.fetchGroupRows(keyword, cursor, pageSize + 1);
여기서 이미 그룹 기본 정보 + host + participantCount가 해결됩니다.
중요한 점은 엔티티가 아니라 Projection을 가져온다는 것입니다.
2) hasNext / nextCursor 결정 — 메모리 작업(쿼리 0번)
boolean hasNext = rows.size() > pageSize;
List<GroupListRow> content = hasNext ? rows.subList(0, pageSize) : rows;
Long nextCursor = hasNext ? content.getLast().groupId() : null;
3) groupIds 추출 — 메모리 작업(쿼리 0번)
List<Long> groupIds = content.stream().map(GroupListRow::groupId).toList();
4) 컬렉션 데이터는 IN 쿼리로 보강 — 2쿼리
Map<Long, List<String>> imageMap = fetchMainImageUrlsByGroupIds(groupIds, 3);
Map<Long, List<String>> tagMap = fetchTagNamesByGroupIds(groupIds);
여기서 핵심은 DB가 “행 단위로” 결과를 주기 때문에, 서비스에서 groupId를 키로 Map을 만들고, 최종 DTO를 조립할 때 groupId로 붙인다는 점입니다.
5) 최종 DTO 조립 — 메모리 작업(쿼리 0번)
Row + Map만으로 조립하므로 엔티티 접근이 없고, 따라서 LAZY 로딩 발동도 없고, 추가 쿼리도 없습니다.
정리: V2가 N+1을 ‘튜닝’이 아니라 ‘구조’로 막는 이유
V2는 N+1을 “fetch join으로 억지로 막는” 방식이 아니라,
- 애초에 엔티티를 반환하지 않고
- 목록 전용 Projection으로 조회해서
- LAZY 로딩이 발동할 통로 자체를 제거했습니다.
그리고 태그/이미지처럼 페이징을 깨뜨리기 쉬운 컬렉션은
- join으로 “한 방에” 해결하지 않고
- IN 쿼리로 한 번에 가져온 뒤
- Map으로 조립해서 붙이는 방식으로
페이징 안정성과 쿼리 예측 가능성(대부분 3쿼리 고정)을 동시에 확보했습니다. 결국 V2의 목표는 이렇게 마무리할 수 있습니다.
- V1은 “엔티티 그래프를 따라가며 값을 모으는 방식”이라 LAZY 접근이 곧 쿼리 증가로 이어졌다.
- V2는 “목록에 필요한 값만 Projection으로 뽑고, 컬렉션은 IN 쿼리로 보강하는 방식”이라 쿼리 수가 페이지마다 고정된다.
- 따라서 V2는 N+1을 ‘튜닝’으로 막는 게 아니라, 구조로 막는다.
5. V2 핵심 쿼리 fetchGroupRows(): “모임 1개 = Row 1개”를 보장하는 목록용 집계 쿼리
fetchGroupRows()는 단순 조회 쿼리가 아닙니다. 이 쿼리의 목적은 딱 하나로 가장 먼저 이해해야 합니다.“목록에서 모임 1개가 결과에서 정확히 1행(row)만 차지하도록 만든다.” 이게 왜 중요하냐면, 목록 API는 결국 페이징을 해야 하고, 페이징은 “행”이 아니라 그룹 단위로 끊어져야 안정적이기 때문입니다. 그런데 여기서 바로 충돌하는 요구사항이 하나 있습니다.
- 목록 카드에는 participantCount(참석자 수) 가 필요하다
- 참석자 수를 구하려면
group_users같은 테이블을 붙여야 한다(조인) - 조인을 하면 모임 1개가 참석자 수만큼 행이 늘어날 수 있다
- 늘어난 행을 다시 “모임 1개당 1행”으로 접으려면 집계(COUNT) + GROUP BY가 필요하다
그래서 fetchGroupRows()는 본질적으로 목록용 집계(aggregate) 쿼리입니다.
먼저 QueryDSL 전체 구조를 “SQL로 번역”해서 살펴보기
분석할 QueryDSL은 이런 형태로 구현했습니다.
return queryFactory
.select(Projections.constructor(
GroupListRow.class,
group.id,
group.title,
group.location,
group.locationDetail,
group.startTime,
group.endTime,
group.description,
group.maxParticipants,
group.createdAt,
group.updatedAt,
user.id,
user.nickName,
user.profileImage,
groupUser.id.count()
))
.from(group)
.join(group.host, user)
.leftJoin(group.users, groupUser).on(groupUser.status.eq(GroupUserStatus.ATTEND))
.where(where)
.groupBy(
group.id,
group.title,
group.location,
group.locationDetail,
group.startTime,
group.endTime,
group.description,
group.maxParticipants,
group.createdAt,
group.updatedAt,
user.id,
user.nickName,
user.profileImage
)
.orderBy(group.id.desc())
.limit(limit)
.fetch();
이걸 SQL 느낌으로 보면 대략 이런 모양이라고 생각하면 됩니다.
SELECT
g.id,
g.title,
g.location,
g.location_detail,
g.start_time,
g.end_time,
g.description,
g.max_participants,
g.created_at,
g.updated_at,
u.id,
u.nick_name,
u.profile_image,
COUNT(gu.id) AS participant_count
FROM groups g
JOIN users u ON g.host_id = u.id
LEFT JOIN group_users gu
ON gu.group_id = g.id
AND gu.status = 'ATTEND'
WHERE ...
GROUP BY
g.id, g.title, g.location, g.location_detail, g.start_time, g.end_time,
g.description, g.max_participants, g.created_at, g.updated_at,
u.id, u.nick_name, u.profile_image
ORDER BY g.id DESC
LIMIT ?
이제부터는 “왜” 이렇게 구성됐는지 핵심 포인트를 하나씩 정리하겠습니다.
WHERE 조건(BooleanBuilder): deleted + keyword + cursor “3종 세트”
목록에서 필수로 들어가는 기본 조건은 V2의 where 조건은 목록 조회에서 거의 고정 템플릿입니다.
- 삭제 제외
- 검색(있을 때만)
- 커서 조건(있을 때만)
BooleanBuilder where = new BooleanBuilder();
where.and(group.deletedAt.isNull());
if (keyword != null && !keyword.trim().isBlank()) {
String key = keyword.trim();
where.and(
group.title.containsIgnoreCase(key)
.or(group.location.containsIgnoreCase(key))
.or(group.description.containsIgnoreCase(key))
);
}
if (cursor != null) {
where.and(group.id.lt(cursor));
}
여기서 포인트는 cursor 조건이 id < cursor라는 점입니다. 정렬이 id DESC이기 때문에 다음 페이지는 “이전 페이지 마지막 id보다 작은 것들”을 가져오면 되고 이 조건은 인덱스가 정말 좋아하는 형태(range/seek)라서 성능이 안정적입니다. 그리고 keyword는 containsIgnoreCase()라서 내부적으로는 대체로 LIKE '%keyword%' 형태가 됩니다. 이건 앞에 와일드카드가 붙어서 인덱스 효율이 떨어질 수 있습니다.(이건 나중에 “검색 최적화” 파트로 자연스럽게 확장 가능할 예정입니다.)
JOIN 구성: host는 JOIN, 참가자는 LEFT JOIN + ON
host 조인은 안전한 이유
.join(group.host, user)
host는 보통 ManyToOne입니다.
- 그룹 1개당 host는 1명
- 조인을 해도 행이 늘어나지 않는다
- 페이징이 깨지지 않는다
그리고 이 조인을 해두면 group.getHost() 같은 LAZY 접근 자체가 사라져서 N+1 가능성을 줄입니다.
참석자는 왜 LEFT JOIN인가
.leftJoin(group.users, groupUser)
참석자는 “0명일 수도 있다”가 핵심입니다.
- inner join이면 참석자가 없는 그룹은 결과에서 탈락할 수 있습니다.
- 목록에서는 참석자 0명인 그룹도 보여야 하는 경우가 존재합니다.
- 그래서 left join이 안전합니다
여기서 가장 중요한 디테일: status 조건은 WHERE가 아니라 ON
.leftJoin(group.users, groupUser)
.on(groupUser.status.eq(GroupUserStatus.ATTEND))
이 한 줄이 “목록 누락 버그”를 막아줍니다. 왜냐하면 조건을 where에 두면 left join이 무너질 수 있기 때문입니다. LEFT JOIN은 원래 오른쪽이 없어도 왼쪽을 살려줍니다. 그런데 WHERE groupUser.status = 'ATTEND'를 걸면 참석자가 없는 그룹은 groupUser.status가 NULL이고 조건을 만족 못 해서 결과에서 탈락합니다. 결국 left join이 사실상 inner join처럼 되어버립니다.
- ON에 조건을 두면: 그룹은 살리고(LEFT JOIN 유지), 참석자만 필터링한다
- WHERE에 조건을 두면: 그룹 자체가 걸러질 수 있다(LEFT JOIN 붕괴)
이건 진짜 자주 터지는 실수입니다. 이것을 통해 “아 이게 함정이구나”를 잘 잡을 수 있습니다.
COUNT를 넣었기 때문에 GROUP BY가 필수다
왜 groupBy가 이렇게 길어지는가
groupUser.id.count()
이건 SQL의 COUNT(gu.id)와 같습니다. 집계 함수가 SELECT에 등장하면 집계가 아닌 컬럼들은 GROUP BY에 포함되어야 한다라는 규칙이 생깁니다. 그래서 select에 들어간 그룹 컬럼 + host 컬럼이 groupBy에 전부 반복됩니다. “길어 보이지만 당연한 결과”입니다. 그리고 이 groupBy는 단순히 문법을 맞추기 위한 게 아니라, 앞에서 말한 목표를 달성하기 위한 핵심 장치입니다. join 때문에 그룹 1개가 참석자 수만큼 행이 늘어날 수 있고, 그 늘어난 행들을 다시 그룹 1개당 1행으로 접는 과정이 바로 groupBy입니다
COUNT(gu.id)가 0이 되는 이유
자주 헷갈리는 질문이 있습니다. “LEFT JOIN이면 gu가 없을 수도 있는데 COUNT가 어떻게 계산돼요?”
이 답변은 SQL에서 COUNT(column)은 NULL을 세지 않습니다.
- 참석자가 없는 그룹 → gu.id가 NULL
COUNT(gu.id)→ 0
그래서 participantCount는 “참석자 없는 그룹도 0으로 자연스럽게 나온다”가 포인트입니다.
마지막 조합: cursor 조건 + orderBy(desc) + limit(size+1)
이게 붙으면 커서 기반 페이징의 형태가 완성됩니다.
- 정렬: 최신순(
id DESC) - 다음 페이지 조건:
id < cursor - 조회:
LIMIT size + 1(hasNext 판별)
이 조합은 “다음 페이지를 빠르게 찾는 구조” + “다음 페이지 존재 여부를 싸게 확인하는 구조”가 같이 들어가서, 목록 API에서 가장 안정적인 템플릿이 됩니다.
V2 핵심 정리
fetchGroupRows()는 “그룹 목록을 조회한다” 수준의 단순 SELECT가 아니라, 목록 카드 1장을 ‘그룹 1행(Row)’으로 만들기 위한 집계(Aggregation) 쿼리입니다. 목록 화면은 “그룹 단위로 정확히 페이징되어야” 하므로, 쿼리 자체가 처음부터 그룹 1개 = 결과 1행을 보장하도록 설계되어야 합니다.
이를 위해 host는 ManyToOne 관계라 조인해도 행이 늘어나지 않는 안전한 조인(그룹 1행 유지)이고, 반대로 참석자는 “0명일 수 있는 컬렉션”이기 때문에 LEFT JOIN이 필수입니다. 특히 참석자 상태(ATTEND) 필터를 WHERE에 두면 LEFT JOIN이 사실상 INNER JOIN처럼 동작하면서, 참석자가 없는 그룹이 목록에서 누락되는 버그가 발생할 수 있습니다. 그래서 필터는 WHERE가 아니라 ON 절에 둬서 “그룹은 살리고, 붙는 참석자만 필터링”하는 형태가 되어야 안전합니다.
또한 참석자 수를 COUNT()로 계산하는 순간, 조인으로 인해 늘어난 행(그룹 × 참석자)을 다시 그룹 단위 1행으로 “접는 과정”이 필요해지고, 그 역할을 GROUP BY가 합니다. 즉 이 쿼리에서 GROUP BY는 옵션이 아니라, COUNT를 쓰는 순간 반드시 따라오는 구조적 요구사항입니다.
마지막으로 커서 기반 페이징은 id < cursor + ORDER BY id DESC 조합으로 “다음 구간”을 정확히 지정하고, LIMIT (size + 1)로 다음 페이지 존재 여부(hasNext)를 별도의 COUNT 쿼리 없이 판별하면서 완성됩니다. 이 조합 덕분에 OFFSET처럼 “읽고 버리는 비용”이 커지지 않고, 페이지가 뒤로 가도 비교적 일정한 성능을 유지할 수 있습니다.
목록 API에서 태그/이미지 같은 컬렉션을 join으로 한 번에 가져오려 하면, 그룹 1개가 태그 개수·이미지 개수만큼 여러 행으로 늘어나는 구조(행 폭발)가 만들어지기 쉽습니다. 그렇게 되면 LIMIT (size + 1)이 “그룹 개수”가 아니라 “행 개수”를 자르는 형태로 적용되어 페이징이 깨지고, 중복 제거를 위해 DISTINCT나 추가 그룹핑/재조립이 필요해지면서 쿼리도 복잡해지고 결과도 불안정해질 수 있습니다.
그래서 V2는 목록의 기준을 GroupListRow(그룹 1개 = row 1개)로 고정한 뒤, 태그/이미지처럼 “붙이는 컬렉션 데이터”는 IN 쿼리로 한 번에 조회하고, 결과를 groupId -> List 형태의 Map으로 만들어 서비스 계층에서 조립해 붙이는 방식을 선택합니다. 이 패턴은 페이징 안정성을 유지하면서도, 요청 1회당 쿼리 수가 일반적으로
fetchGroupRows()1번- 태그 IN 1번
- 이미지 IN 1번
으로 대부분 3번으로 고정되기 때문에, N+1을 “운 좋게 피하는 것”이 아니라 구조적으로 차단하고 성능을 예측 가능하게 만듭니다. N+1을 “fetch join으로 억지로 막는 방식”이 아니라 애초에 엔티티를 반환하지 않고 목록 전용 Projection으로 조회함으로써 LAZY 로딩이 발동할 통로 자체를 제거했습니다. 그리고 태그/이미지처럼 목록 페이징을 흔들기 쉬운 컬렉션은 join으로 한 번에 해결하지 않고, IN 쿼리 + Map 조립으로 분리해 페이징 안정성과 쿼리 예측 가능성(대부분 3쿼리 고정)을 동시에 확보한 설계 경험을 기억하고자 정리한 내용입니다.
'💭Retrospective' 카테고리의 다른 글
| 이미지 로딩 속도와 크기 70% 단축: WebP 변환부터 Redis 프리업로드로 URL을 보증하기 (0) | 2026.01.10 |
|---|---|
| 서평단: 그림으로 이해하는 도커와 쿠버네티스 (0) | 2026.01.09 |
| 서평단: Do it! HTML+CSS 웹 표준의 정석 탄탄한 웹 기본기를 위한 교과서 | 개정판 3 판 후기 (0) | 2025.12.17 |
| 마틴 파울러가 소개하는 소프트웨어 아키텍처 (1) | 2025.11.25 |
| 수십 만 건 이상의 주문 데이터와 시스템에서 인덱스로 조회 성능 최적화 (1) | 2025.11.13 |