평소에 잘 동작하던 API에서 갑자기 20초 넘는 타임아웃이 발생했다. 재현은 잘 안 되고, 특정 시간대에 간헐적으로 터졌다.
두 가지 문제가 동시에 있었다
문제 1: N+1 쿼리
학습 계획 목록을 upsert하는 메서드가 있었다.
@Transactional
public List<StudyPlan> upsertStudyPlanList(List<StudyPlan> plans) {
for (StudyPlan plan : plans) {
repository.upsertPlan(plan); // ← 1건마다 개별 쿼리
}
}
계획이 50건이면 쿼리가 50번 나간다. 평소엔 괜찮지만 배치가 몰릴 때는 DB 부하가 급격히 올라간다.
수정: 단일 트랜잭션으로 묶어 bulk 처리
@Transactional
public List<StudyPlan> upsertStudyPlanList(List<StudyPlan> plans) {
// 배치 내 중복 제거 후 한 번에 처리
List<StudyPlan> deduped = deduplicate(plans);
deduped.forEach(plan -> repository.upsertPlan(plan));
// 트랜잭션 커밋 시 flush 한 번 → 개별 커밋 제거
}
트랜잭션을 단일로 묶으면 JPA는 flush를 최소화하고, DB 입장에서도 연결 횟수가 줄어든다.
문제 2: LIMIT 없는 전체 조회
최근 학습 기록을 조회하는 API가 있었다.
// 문제 코드
List<StudyAction> actions = studyActionRepository
.findByMemberSeqOrderByCreatedAtDesc(memberSeq); // LIMIT 없음!
회원의 전체 학습 기록을 불러오고 있었다. 서비스 기간이 쌓일수록 데이터가 늘어나고, 특정 회원의 경우 수천 건을 모두 조회해오면서 타임아웃이 발생했다.
수정: LIMIT 1 (또는 필요한 만큼만 조회)
// JPA 메서드명으로 LIMIT 적용
Optional<StudyAction> latest = studyActionRepository
.findFirstByMemberSeqOrderByCreatedAtDesc(memberSeq); // LIMIT 1
// 또는 Pageable 사용
List<StudyAction> recent = studyActionRepository
.findByMemberSeqOrderByCreatedAtDesc(memberSeq, PageRequest.of(0, 10));
추가: 운영 서버 시작 시 배치 Job 자동 실행 방지
Spring Batch는 기본적으로 애플리케이션 시작 시 등록된 Job을 자동 실행한다. 배포 직후 서버가 뜨자마자 무거운 배치가 돌아서 DB에 부하를 주는 문제가 있었다.
spring:
batch:
job:
enabled: false # 자동 실행 방지
수동 또는 스케줄러를 통해서만 실행되도록 변경했다.
결과
- upsert N+1 → 단일 트랜잭션 bulk 처리로 DB 연결 횟수 대폭 감소
- 전체 조회 → LIMIT 적용으로 타임아웃 완전 제거
- 배포 시 배치 자동 실행 제거
배운 것
- JPA 메서드명의
First/Top은 LIMIT을 붙여준다.findFirst,findTop1,findTop10모두 동작한다. - N+1은 숫자가 작을 때는 안 보인다. 데이터가 쌓이거나 배치가 몰리는 순간 터진다.
- 운영 서버에서
spring.batch.job.enabled=false는 거의 필수 설정이다.