스케줄러 배치가 어쩔 땐 성공하고 어쩔 땐 실패한 이유 - 데드락 해결기
TL;DR
- 증상: 10분마다 실행되는 스케줄러가 간헐적으로 데드락 발생 (시간당 3회)
- 원인: 약 20초간 긴 트랜잭션으로 전체 테이블 Lock 유지, API 서버와 Lock 경합
- 해결: 4가지 방법 적용 (필터링, 트랜잭션 분리, REQUIRES_NEW, 500개 Chunking)
- 효과: 데드락 발생 주 3~4회 → 0회, Lock 유지 시간 99.9% 감소 (20초 → 1ms)
- 한계: Chunking으로 처리 시간 증가 (20초 → 40초), 메모리 사용량 증가, 복잡도 증가
- 트래픽: 피크 타임 100 TPS 미만 (WAS 2대)
- DB: MySQL 8.0, InnoDB
- 격리수준: READ COMMITTED
- 데이터 규모: 입주민 테이블 20만 건, 실제 동기화 대상 2만 건
글 머리말
어느 날 모니터링 대시보드를 확인하던 중, 스케줄러 실패 알림이 가끔씩 뜨는 걸 발견했습니다. 10분마다 실행되는 입주민 고객타입 동기화 스케줄러였는 데, 로그를 자세히 보니 이상한 패턴이 보였습니다.
- 10:00 - 성공
- 10:10 - 실패 (Deadlock found when trying to get lock)
- 10:20 - 성공
- 10:30 - 실패
처음엔 “그냥 일시적인 문제겠지” 하고 넘겼습니다. 한 번 실패해도 10분 뒤엔 다시 성공하고, 결국 20분 뒤엔 데이터가 맞춰지니까요. 항상 실패했다면 바로 대응했을 텐데, 간헐적으로만 발생하는 문제라 이슈를 늦게 알아챘습니다.
그런데 2주 동안 계속 모니터링하다 보니 패턴이 보이더군요. API 서버가 개별 입주민 데이터를 수정하는 순간에 스케줄러가 실행되면 데드락이 발생하는 거였습니다.
원인을 분석해보니, 스케줄러가 하나의 긴 트랜잭션으로 전체 테이블을 스캔하면서 약 20초간 Lock을 잡고 있었습니다. 2만 건 정도 처리하는 건 금방이었지만, 그 20초 동안 API 서버와 Lock 경합이 발생한 거였죠.
문제 상황
// 문제가 있던 초기 코드
@Scheduled(cron = "0 */10 * * * *")
@Transactional
public void syncResidentType() {
List<Resident> residents = residentRepository.findAll(); // 20만 건 전체 조회
for (Resident resident : residents) {
if (resident.needsTypeSync()) { // 메모리에서 필터링
resident.syncCustomerType();
}
}
// 약 20초간 Lock 유지 → API 서버와 충돌
}문제점:
- 20만 건 전체 조회 (실제 필요한 건 2만 건)
- 하나의 긴 트랜잭션 (약 20초)
- Lock 경합으로 간헐적 데드락
해결 과정
핵심은 “Lock을 짧게, 범위를 좁게”입니다. 4가지 방법을 조합했습니다.
1단계: 필요한 레코드만 조회 (20만 → 2만 건)
@Query("SELECT r FROM Resident r WHERE r.type IN ('TYPE_A', 'TYPE_B') AND r.lastSyncedAt < :threshold")
List<Resident> findResidentsNeedingTypeSync(@Param("threshold") LocalDateTime threshold);2단계: 트랜잭션 분리 + REQUIRES_NEW
// 개별 트랜잭션으로 즉시 커밋
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processSingleResident(Resident resident) {
resident.syncCustomerType();
// 메서드 종료 즉시 커밋 & Lock 해제 (약 1ms)
}3단계: Chunking (500개씩)
public void syncResidentType() {
int chunkSize = 500;
int offset = 0;
while (true) {
List<Resident> chunk = findChunk(offset, chunkSize);
if (chunk.isEmpty()) break;
for (Resident resident : chunk) {
processSingleResident(resident); // REQUIRES_NEW
}
offset += chunkSize;
Thread.sleep(50); // DB 부하 분산
}
}전체 흐름
graph TD
A[스케줄러 시작<br/>10분마다] --> B[500개씩 조회]
B --> C[개별 트랜잭션]
C --> D[즉시 커밋<br/>Lock 해제 1ms]
D --> E{다음 Chunk?}
E -->|Yes| B
E -->|No| F[완료]
style A fill:#e8f5e9
style C fill:#fff3e0
style D fill:#e3f2fd개선 결과
| 항목 | Before | After | 개선율 |
|---|---|---|---|
| 데드락 발생 | 시간당 3회 | 0회 | 100% ↓ |
| Lock 유지 시간 | 약 20초 | 약 1ms | 99.9% ↓ |
| UPDATE 건수 | 200,000건 | 20,000건 | 90% ↓ |
| 메모리 사용량 | 높음 (2만건) | 낮음 (500건씩) | 97% ↓ |
3개월 운영 결과:
- 데드락 발생: 0회 (이전 시간당 평균 3회)
- 스케줄러 성공률: 100%
- 알림 피로도 해소
이 접근의 아쉬운 점
처리 시간 증가 (20초 → 40초)
Chunking으로 쪼개면서 오버헤드가 생겼습니다. 하지만 10분마다 실행하니까 문제없습니다.
복잡도 증가
간단한 for문 10줄이 Chunking + REQUIRES_NEW로 50줄이 됐습니다. 주석과 코드 리뷰로 팀원들과 공유했습니다.
고려했지만 안 한 대안들
- Spring Batch: 간단한 스케줄러에는 오버스펙
- 비동기 큐 (Kafka): 팀 규모 (백엔드 3명) 고려 시 운영 부담
- 격리 수준 낮추기: 데이터 정합성이 생명이라 타협 불가
시스템 점검 체크리스트
저도 배포 전에 이 항목들을 꼭 확인합니다. 스케줄러 배치를 운영한다면 참고하시면 좋을 것 같습니다.
- 필터링 조건: 전체 테 이블 조회가 아니라 필요한 레코드만 조회하는가?
- 트랜잭션 범위: Lock 유지 시간이 최소화되었는가? (개별 트랜잭션)
- Chunking 크기: 메모리와 Lock 시간을 고려하여 적절한 크기(500~1000개)로 설정했는가?
- REQUIRES_NEW 적용: 개별 레코드 처리마다 즉시 커밋하는가?
- 데드락 모니터링: CloudWatch/Grafana로 데드락 발생률을 추적하고 있는가?
결론
스케줄러 데드락 문제는 4가지 방법으로 해결했습니다:
- 필요한 레코드만 조회 → Lock 최소화 (20만건 → 2만건)
- 트랜잭션 잘게 쪼개기 → Lock 유지 시간 단축 (20초 → 1ms)
- REQUIRES_NEW로 즉시 커밋 → Race Condition 감소
- Chunking (500개씩) → 메모리 효율 + 중간 재시작 가능
핵심은 “Lock을 짧게, 범위를 좁게”입니다.
2만 건을 처리하는 데 길어봐야 20초면 끝나는 작 업이었지만, 그 20초 동안 테이블 전체를 스캔하면서 Lock을 잡고 있었던 게 문제였습니다. 개별 트랜잭션으로 쪼개고 Chunking을 적용하니, Lock 유지 시간이 1ms로 줄어들면서 데드락이 사라졌습니다.
솔직히 처음엔 “10분마다 도는데 가끔 실패하니까 큰 문제 아니겠지”라고 생각했습니다. 근데 모니터링 대시보드에서 실패 알림이 계속 쌓이는 걸 보니 찝찝하더군요. 간헐적이라 우선순위를 못 올렸지만, 결국 2주 만에 시간을 내서 해결했습니다.
이 경험을 통해 한 가지 깨달은 게 있습니다.
“20초쯤은 괜찮겠지”라는 생각이 얼마나 위험한지 말이죠. 작은 규모 서비스라고 해서 안정성을 타협하면 안 됩니다. MAU 7,000명이든 20만명이든, 데이터 정합성과 안정성은 타협할 수 없는 가치입니다.
이 방법들을 적용한 후 3개월간 데드락이 단 한 번도 발생하지 않았습니다. 무엇보다 알림 피로도가 사라진 게 가장 큰 성과입니다.
다만 이 방법은 개별 레코드의 독립성이 보장될 때만 사용하세요. 전체 작업의 원자성이 중요한 경우(은행 계좌 이체, 주문 생성 + 재고 차감 등)에는 분산 락이나 Saga 패턴 같은 다른 접근을 고려해야 합니다.
읽어주셔서 감사합니다.🖐
참고:
Spring @Transactional 공식 문서
MySQL InnoDB Lock 메커니즘
Spring Batch Chunk-Oriented Processing
🤖 AI라면 이 장애를 어떻게 다뤘을까?
지금의 저는 Claude 기반 AI 에이전트로 모니터링 알림을 받습니다. 데드락 에러 로그가 누적되기 시작하면 슬랙/디스코드로 즉시 알림이 오고, 최근 유사 장애 해결 기록을 같이 보내줍니다. “2주 만에 시간을 내서”가 아니라, 알림 받은 날 바로 원인 추적을 시작할 수 있게 됐습니다.
