이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- JOIN, SORT, GROUP BY는 왜 API 성능 병목이 되기 쉬운가?
- 조인 쿼리의 실행 계획에서 어떤 부분을 봐야 하는가?
- 백엔드에서 집계와 정렬 요구사항을 어떻게 설계해야 하는가?
개요
많은 API 병목은 단순 조회보다 JOIN, SORT, GROUP BY, DISTINCT가 섞인 쿼리에서 생긴다. 이 연산들은 메모리, 임시 파일, 디스크 I/O, 많은 row 비교를 필요로 할 수 있다.
백엔드 개발자는 “한 번에 조인해서 가져오면 편하다”와 “DB가 처리해야 할 중간 결과가 얼마나 큰가”를 구분해야 한다.
원리
다음 쿼리는 회원별 주문 목록을 가져온다.
SELECT o.id, o.created_at, m.email
FROM orders o
JOIN members m ON m.id = o.member_id
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC
LIMIT 50;이 쿼리에서 DB는 다음을 결정한다.
orders를 먼저 읽을지members를 먼저 읽을지orders.status조건에 어떤 인덱스를 쓸지members조인을 어떤 방식으로 할지- 정렬을 인덱스로 해결할지 별도 Sort를 할지
- LIMIT을 어느 시점에 적용할 수 있을지
실행 계획에서 중간 row 수가 커지면 정렬과 조인 비용이 커진다.
실행 계획을 볼 때는 최종 LIMIT 50에 속으면 안 된다. DB가 limit을 적용하기 전에 수십만 row를 조인하고 정렬했다면, 응답 row는 50개여도 connection 점유 시간과 DB CPU는 크게 늘 수 있다.
JOIN 비용
조인은 테이블 간 관계를 합치는 과정이다. 조인 컬럼에 적절한 인덱스가 없으면 한쪽 테이블의 각 행마다 다른 테이블을 넓게 찾아야 할 수 있다.
SELECT o.id, p.name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.member_id = 10;필요한 인덱스는 PK만이 아닐 수 있다. orders.member_id로 회원의 주문을 찾고, order_items.order_id로 주문 항목을 찾는 경로가 중요하다.
CREATE INDEX idx_orders_member_id ON orders (member_id);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);ORM에서 연관관계를 설정했다고 조인 비용이 사라지는 것은 아니다. fetch join은 SQL 조인을 만들고, 잘못 쓰면 중복 row와 pagination 문제를 만들 수 있다.
SORT 비용
정렬은 많은 행을 비교해야 하므로 비용이 크다. 특히 조건으로 충분히 좁히지 못한 뒤 정렬하면 위험하다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
ORDER BY view_count DESC
LIMIT 20;PUBLISHED가 전체의 대부분이고 view_count 인덱스가 없다면 DB는 많은 게시글을 읽고 정렬해야 한다. 인기글 API라면 별도 인덱스, 캐시, 집계 테이블을 검토할 수 있다.
정렬 요구사항은 제품 요구사항과도 연결된다. “최신순”, “인기순”, “관련도순”은 서로 다른 인덱스와 데이터 구조를 요구한다.
정렬이 메모리 안에서 끝나지 않으면 임시 파일이나 디스크 작업으로 넘어갈 수 있다. PostgreSQL에서는 sort method, temp file 로그, work_mem 관련 지표를 볼 수 있고, MySQL에서는 Using filesort, Using temporary, sort buffer와 임시 테이블 관련 지표를 본다. 백엔드 개발자가 직접 DB 설정을 바꾸지는 않더라도, “정렬 요구가 DB 메모리와 디스크를 쓰게 만든다”는 사실은 알아야 한다.
GROUP BY와 집계
집계는 운영 DB에 큰 부하를 줄 수 있다.
SELECT member_id, count(*)
FROM orders
WHERE created_at >= timestamp '2026-06-01'
GROUP BY member_id;월간 주문 수를 매번 실시간으로 집계하면 데이터가 늘수록 비용이 커진다. 관리자 대시보드, 랭킹, 통계 API는 요구사항을 나눠야 한다.
- 실시간 정확도가 필요한가?
- 몇 분 지연을 허용할 수 있는가?
- 배치로 미리 집계할 수 있는가?
- 캐시나 materialized view를 사용할 수 있는가?
- 운영 DB가 아니라 분석 DB로 보낼 수 있는가?
백엔드 개발자는 제품 요구사항을 DB 비용 언어로 바꿔야 한다. “실시간 전체 기간 집계”는 비싸고, “5분 지연 허용 일별 집계”는 훨씬 운영하기 쉽다.
코드 예시
다음 JPA 코드는 주문 목록과 주문 항목을 한 번에 가져오려는 의도다.
@Query("""
select distinct o
from Order o
join fetch o.items
where o.member.id = :memberId
order by o.createdAt desc
""")
List<Order> findOrdersWithItems(Long memberId, Pageable pageable);컬렉션 fetch join과 pagination은 DB row 증가량과 페이지 자르기 위치를 먼저 계산해야 한다. DB row는 주문 항목 수만큼 늘어나고, Hibernate가 메모리에서 중복 제거를 하거나 pagination 경고를 낼 수 있다. 실무에서는 주문 목록을 먼저 가져온 뒤 항목을 batch fetch로 가져오거나, 화면에 필요한 DTO를 별도 쿼리로 가져오는 방식을 비교한다.
예를 들어 주문 20개를 기대했지만 각 주문에 item이 평균 15개라면 DB는 300 row를 반환할 수 있다. 여기에 여러 컬렉션을 fetch join하면 곱셈으로 늘어난다. 이 문제는 SQL 실행 계획과 Hibernate 로그를 함께 봐야 한다.
목록 API 리뷰에서는 row 폭을 숫자로 말해본다.
목표 응답: 주문 20개
평균 item 수: 15개
fetch join 결과 row: 약 300개
추가 컬렉션 coupon 평균 3개 조인: 최대 900개 수준이 계산은 정확한 예측이 아니라 위험 감지용이다. 응답 객체 수가 20개여도 DB와 네트워크, Hibernate 중복 제거 비용은 수백 row 기준으로 발생할 수 있다.
실전 팁
- 조인 쿼리는 각 단계의 row 수가 얼마나 늘어나는지 본다.
- 정렬은 인덱스로 해결되는지, 별도 Sort가 있는지 확인한다.
- 대시보드 집계는 실시간 요구사항을 제품과 다시 확인한다.
DISTINCT는 중복을 숨기지만 중간 결과가 커진 원인을 해결하지 못할 수 있다.- ORM fetch join은 SQL row 폭과 row 수를 같이 증가시킨다.
- 최종 limit보다 limit 전 단계에서 읽고 정렬한 row 수를 본다.
- temp file,
Using temporary,Using filesort, HashAggregate 메모리 초과 같은 신호를 운영 지표와 연결한다. - 컬렉션 fetch join 대신 2-step 조회, batch fetch, DTO projection, read model을 비교한다.
- 목록 API 리뷰에서는 응답 객체 수와 DB 반환 row 수를 분리해 추정한다.
- 집계 API는 “실시간 정확성, 지연 허용, 재계산 가능성”을 제품 요구사항으로 명시한다.
위험 신호!
- 목록 API 하나에 여러 컬렉션 fetch join이 들어간다.
- 관리자 대시보드가 운영 DB에서 매 요청마다 전체 기간 GROUP BY를 수행한다.
DISTINCT를 성능 문제 해결책처럼 붙인다.- 실행 계획에서 Sort, HashAggregate, Using temporary가 큰 row 수와 함께 보인다.
- 정렬 기준이 제품 요구사항 변화로 계속 바뀌는데 인덱스 설계가 따라가지 못한다.
LIMIT이 있으니 조인과 정렬 비용도 작을 것이라고 착각한다.- 여러 컬렉션 fetch join으로 DB row가 곱셈 증가하는데 Java 객체 수만 보고 판단한다.
- 대시보드 집계를 운영 DB 실시간 쿼리로 만든 뒤, 트래픽 증가 시 캐시나 집계 테이블로 옮길 계획이 없다.
확인 질문
- 조인 쿼리 실행 계획에서 row 수를 봐야 하는 이유는 무엇인가?
- 조인 중간 결과가 커질수록 다음 조인, 정렬, 집계 비용이 커지기 때문이다.
- 실시간 집계 요구사항을 그대로 받아들이면 위험한 이유는 무엇인가?
- 운영 DB가 매 요청마다 많은 데이터를 읽고 그룹화해야 하므로 지연과 부하가 커질 수 있기 때문이다.
- 컬렉션 fetch join과 pagination을 함께 쓸 때 조심해야 하는 이유는 무엇인가?
- DB row가 컬렉션 원소 수만큼 늘어나 중복과 메모리 처리, 잘못된 페이지 크기 문제가 생길 수 있기 때문이다.
- 최종 결과가 50건인 쿼리도 느릴 수 있는 이유는 무엇인가?
- limit이 적용되기 전에 많은 row를 조인, 정렬, 집계하거나 임시 파일을 만들 수 있기 때문이다.
- 목록 API에서 응답 객체 수와 DB row 수를 분리해서 봐야 하는 이유는 무엇인가?
- ORM이 중복 row를 객체로 합쳐도 DB 조인, 네트워크 전송, 메모리 중복 제거 비용은 실제 반환 row 수 기준으로 발생하기 때문이다.