[Spring Boot 프로덕션] Day 3: 데이터 접근 성능 - 쿼리부터 커넥션 풀까지
이 글은 AI(Claude)의 도움을 받아 작성하고, 작성자가 검토·편집했습니다.
서론: 느린 API는 계층 전체의 문제다
Day 2에서 트랜잭션을 짧고 정확하게 잡았다. 하지만 요청 하나가 불필요한 쿼리 수십 개를 만들거나, 느린 쿼리가 연결을 오래 점유하면 트랜잭션 설계만으로는 버틸 수 없다. 애플리케이션의 스레드, 커넥션 풀, DB CPU와 락은 하나의 대기열로 연결되어 있다.
성능 튜닝의 출발점은 설정값을 키우는 것이 아니다. 어디서 얼마나 기다리는지 측정하고, 가장 앞의 병목을 줄이는 것이다.
1. 먼저 요청 예산을 쪼개서 본다
1
2
3
4
5
API 응답 800ms
├─ 커넥션 획득 대기 180ms
├─ SQL 실행 420ms (쿼리 31회)
├─ 외부 API 120ms
└─ 애플리케이션 처리 80ms
단순히 “DB가 느리다”고 말하면 행동할 수 없다. 최소한 다음을 함께 본다.
1
2
3
4
요청: 처리량, 평균보다 p95/p99 지연, 오류율
풀: active/idle/pending 연결, 획득 시간, 타임아웃 수
SQL: 호출 횟수, 실행 시간, 반환 행 수, 실행 계획
DB: CPU·I/O, 락 대기, 버퍼 캐시, 최대 연결 수
평균 50ms라도 p99가 3초면 일부 사용자는 계속 느리다. 병목은 꼬리 지연에서 먼저 드러난다.
2. N+1: 객체 순회가 쿼리 폭발로 바뀔 때
주문 20개를 한 번 조회한 뒤 각 주문의 상품을 접근하면 추가 쿼리 20개가 실행될 수 있다.
1
2
3
4
SELECT * FROM orders LIMIT 20; -- 1회
SELECT * FROM order_item WHERE order_id = 1;
SELECT * FROM order_item WHERE order_id = 2;
... -- N회
코드에서는 단순한 반복문이라 눈에 잘 띄지 않는다.
1
2
3
@EntityGraph(attributePaths = "items")
@Query("select o from Order o where o.id = :id")
Optional<Order> findDetailById(@Param("id") Long id);
해결 방법은 조회 목적에 따라 고른다.
1
2
3
4
5
6
7
8
Fetch Join / EntityGraph:
한 번에 필요한 연관 데이터를 가져옴. 단건 상세 조회에 적합.
DTO Projection:
화면에 필요한 컬럼만 직접 조회. 읽기 전용 목록에 적합.
Batch Fetch:
지연 로딩은 유지하되 여러 연관 조회를 IN 쿼리로 묶음.
모든 연관관계를 EAGER로 바꾸는 것은 해결이 아니다. 필요하지 않은 API까지 무거워지고, 어떤 쿼리가 나갈지 더 예측하기 어려워진다.
3. 인덱스는 실제 접근 패턴에 맞춘다
인덱스는 “자주 검색하는 컬럼” 하나씩 만드는 체크리스트가 아니다. WHERE, JOIN, 정렬과 데이터 분포를 함께 본다.
1
2
3
4
5
6
SELECT id, ordered_at, total_amount
FROM orders
WHERE customer_id = ?
AND status = 'PAID'
ORDER BY ordered_at DESC
LIMIT 20;
이 접근 패턴에는 예를 들어 (customer_id, status, ordered_at DESC) 복합 인덱스를 후보로 두고 실행 계획과 실제 부하로 검증한다.
1
2
3
4
5
확인할 것:
- 인덱스가 실제로 선택됐는가
- 예상 행 수와 실제 행 수가 크게 다른가
- 정렬·테이블 접근 비용이 남아 있는가
- 쓰기 성능과 저장 공간 비용은 감당 가능한가
인덱스가 많을수록 INSERT/UPDATE 때 함께 갱신할 구조가 늘어난다. 느린 쿼리 하나를 보고 중복 인덱스를 계속 추가하면 쓰기 경로가 병목이 된다.
4. 페이지네이션은 깊어질수록 전략이 달라진다
Offset 방식은 단순하고 임의 페이지 이동이 쉽다.
1
2
3
SELECT * FROM orders
ORDER BY id DESC
LIMIT 20 OFFSET 100000;
하지만 DB는 앞의 많은 행을 찾아 건너뛰어야 한다. 데이터가 계속 추가되면 페이지 사이에서 중복·누락도 생길 수 있다. 무한 스크롤이나 순차 탐색에는 Keyset 방식이 유리하다.
1
2
3
4
SELECT * FROM orders
WHERE id < :last_seen_id
ORDER BY id DESC
LIMIT 20;
1
2
Offset: 관리자 화면, 얕은 페이지, 정확한 페이지 번호가 필요할 때
Keyset: 대규모 피드, 다음 페이지 순회, 일관된 저지연이 중요할 때
정렬 키가 중복될 수 있으면 (ordered_at, id)처럼 유일한 보조 키를 커서에 함께 넣는다.
5. 커넥션 풀은 클수록 빠르지 않다
커넥션 풀은 연결 생성 비용을 줄이고 DB 동시성을 제한한다.
1
2
3
4
5
요청 스레드 200개
↓
HikariCP 최대 20개
↓
DB가 동시에 처리할 쿼리 최대 20개
풀을 20개에서 100개로 키우면 대기가 사라지는 것이 아니라 DB 안으로 이동할 수 있다. CPU 경합, I/O 대기, 락 경쟁이 늘어 전체 처리량과 지연이 더 나빠지기도 한다.
1
2
3
4
5
spring:
datasource:
hikari:
maximum-pool-size: 20
connection-timeout: 1000
위 숫자는 정답이 아니라 의도를 보여주는 예시다. 인스턴스 수를 곱한 전체 연결 수가 DB 허용량과 처리 능력을 넘지 않아야 한다. 작은 값에서 시작해 부하 테스트로 처리량·획득 대기·DB 포화도를 함께 보고 조정한다.
6. 타임아웃은 각 대기 구간에 둔다
무한 대기는 장애를 전파한다. 커넥션 획득, 쿼리 실행, 락 대기에 각각 상한이 필요하다.
1
2
3
4
5
6
7
8
연결 획득 타임아웃:
풀이 고갈되면 빠르게 실패해 요청 스레드를 보호
쿼리/트랜잭션 타임아웃:
비정상적으로 긴 DB 작업 중단
락 타임아웃:
다른 트랜잭션이 가진 락을 끝없이 기다리지 않음
타임아웃 값은 서로 모순되지 않아야 한다. 상위 HTTP 요청 제한이 2초인데 내부 쿼리가 5초까지 기다리면, 클라이언트는 이미 떠난 뒤에도 서버가 일을 계속한다.
7. 대량 쓰기는 한 건씩 왕복하지 않기
대량 데이터 처리에서 save()를 반복하면 애플리케이션과 DB 사이 왕복이 병목이 된다.
1
2
3
4
5
개선 순서:
1. JDBC/JPA 배치 쓰기로 여러 문장을 묶는다.
2. 일정 건수마다 flush/clear해 영속성 컨텍스트 메모리를 비운다.
3. 한 트랜잭션 크기를 제한해 락·로그·롤백 비용을 통제한다.
4. 온라인 요청과 배치 작업의 풀·실행 시간을 필요하면 분리한다.
1
2
3
4
5
6
7
for (int i = 0; i < rows.size(); i++) {
entityManager.persist(toEntity(rows.get(i)));
if ((i + 1) % 100 == 0) {
entityManager.flush();
entityManager.clear();
}
}
배치 크기는 메모리·쿼리 크기·락 시간의 절충값이므로 실제 데이터로 측정한다.
8. Day 3 체크리스트
- 요청 지연을 커넥션 대기·SQL·외부 호출·애플리케이션 시간으로 분해했다.
- N+1을 쿼리 수로 확인하고 조회 목적에 맞는 로딩 전략을 택했다.
- 실제 WHERE/JOIN/정렬 패턴과 실행 계획으로 인덱스를 검증했다.
- 깊은 목록에는 Keyset 페이지네이션을 검토했다.
- 커넥션 풀과 타임아웃을 DB 용량·인스턴스 수·부하 테스트로 조정했다.
다음 편 예고
DB 경로를 최적화해도 외부 결제·배송·인증 시스템은 언제든 느려지거나 실패한다. Day 4에서는 외부 연동과 복원력 — 타임아웃, 재시도, 서킷 브레이커, 멱등성, 비동기 메시징으로 장애의 경계를 세운다.