DeadLock, 커넥션풀 고갈, chunk 전체 rollback — 정기 전송 배치 한 곳에서 시작된 사고가 주간 통계까지 마비시켰던 과정과, 그걸 단계적으로 잡아온 회고.
마이데이터 정기 전송은 매일 새벽에 도는 가장 큰 배치다. 사용자별 동의 스케줄을 따라 외부 마이데이터 API에서 데이터를 받아 우리 DB에 적재한다. 만 명 단위, 수십 개 보험사, 수십만 row.
여기서 사고가 나면 그 사고가 정기 전송에서만 멈추지 않는 게 진짜 문제였다. 같은 DB를 쓰는 주간 통계 전송 배치가 약속한 시각에 못 끝나거나, 아예 시작도 못 했다. 정기 전송의 DeadLock이 같은 DB를 쓰는 다른 어플리케이션에 락을 전파했고, 통계 집계는 그 락을 못 풀어서 멈췄다.
이 글은 그 사고들을 한 번에 다 묘사하기보다는, 우리가 어떤 순서로 무엇을 잡아갔는지 — 1년에 걸친 1차·2차·3차 안정화 작업 — 를 정리한다. 같은 길을 가는 팀에 보내는 노트에 가깝다.
기술 스택은 단순하다: Spring Boot 3.5.3 / Spring Batch 5.2.2 / Java 21 (정식 Virtual Thread) / Quartz / QueryDSL / JPA.
처음 우리가 직면한 건 세 개의 서로 얽힌 문제였다.
delete + insert를 동시에 하는 부분에서 DB 락이 발생했고, 이 락이 같은 DB를 쓰는 다른 어플리케이션(통계 배치, 운영 API)으로 전파됐다. 에러 메시지는 SearchTimestamp 테이블에서 락이 발생한 것처럼 보였지만, 원인은 한 유저의 정기 전송 트랜잭션이 너무 오래 들고 있던 락이었다.maximum-pool-size: 20이 순식간에 100% 차고, 다른 배치는 커넥션을 못 빌려서 시작조차 못 했다.이 셋은 서로를 악화시켰다. chunk가 rollback되면 재시도하면서 락을 더 오래 들었고, 락이 길어지면 커넥션이 더 오래 점유됐고, 커넥션이 모자라면 통계 배치가 시작 못 했다.
가장 먼저 잡은 건 전파 경로 였다. 원인을 다 못 잡더라도 다른 배치까지 죽이는 건 멈춰야 했다.
커넥션풀 확장. application-live.yml 의 maximum-pool-size: 20 → 50 으로 올렸다. 근본 해결은 아니지만, 한 배치가 잡아도 다른 배치가 시작은 할 수 있게 됐다.
Virtual Thread 제한. Java 21의 Virtual Thread를 무제한으로 쓰면 동시에 수천 개의 외부 API 호출이 일어났고, 그만큼 커넥션을 점유했다. api_history 저장 부분의 Virtual Thread 설정을 제거하고, 외부 API 호출은 bounded executor로 제한했다.
커스텀 어플리케이션 락. 같은 사용자의 동시 처리에서 발생하는 DB DeadLock을, DB 락에 의존하지 않고 어플리케이션 단에서 막는 방식으로 바꿨다.
@KeyLocked(type = KeyLockType.USER_CONSENT_ID, key = "#userConsentScheduleId")
public void processPerKeyLock(Long userConsentScheduleId) {
// 같은 userConsentScheduleId 는 절대 동시 진입하지 않음
// DB 락 대신 InMemoryKeyLockManager 가 어플리케이션 단에서 직렬화
}KeyLockManager + KeyLockedAspect (AOP) 로 같은 키의 동시 진입을 어플리케이션 안에서 직렬화했다. DB 락에 의존하지 않으니 같은 DB를 쓰는 다른 어플리케이션에 락이 전파되지 않았다 — 이게 가장 큰 효과였다.
동기 처리로 일부 회귀. 비동기에 의존한 일부 로직은 오히려 동기 처리로 되돌렸다. 비동기로 얻은 처리량보다 락·커넥션 경합으로 잃은 안정성이 컸다.
여기까지가 1단계. 사고는 여전히 났지만, 다른 배치까지 같이 죽지는 않았다.
1단계로 충격 차단 은 됐는데, 정기 전송 자체는 여전히 느렸고 chunk rollback도 그대로였다.
쿼리 튜닝. UserConsentSchedule 의 paging 쿼리를 튜닝했다. 인덱스 활용을 보장하기 위한 조건 재배치, 불필요한 join 제거. 정기적 전송 요청 지연을 일으키던 쿼리 몇 개를 잡았다.
위반 row 사전 필터링. chunk 전체 rollback의 진짜 원인은 위반 row를 DB에 보내고 나서야 알게 된다는 점이었다. 외부 마이데이터 API 응답 중 NOT NULL 컬럼이 null이거나 VARCHAR length를 초과하는 row가 종종 섞여 들어왔다. 우리는 이걸 DB에 보내기 전에 사전 필터링하기로 했다.
// insurance_insure, insurance_insured_contract 등의 컬럼 제약을 사전 검증
List<Row> safeRows = rows.stream()
.filter(this::isWithinColumnConstraints)
.toList();
bulkInsert(safeRows);이 한 줄 차이로 chunk가 통째로 rollback되는 빈도가 거의 사라졌다. 하지만 사전 필터링은 우리가 미리 아는 제약만 잡을 수 있었다. 새로 추가된 컬럼이나 외부 시스템에서 바뀐 응답 포맷에는 여전히 무력했다.
Slack → Dooray 알림 이전. 사고 자체의 해결은 아니지만, 운영 가시성을 위해 주간 통계 전송 실패시 알림을 Slack에서 Dooray로 옮겼다. Dooray는 회사 내 공식 채널이라 모든 팀원이 같은 채널에서 본다는 점이 컸다.
2단계까지 와서도 남은 문제가 있었다: 사전 필터링이 못 잡는 위반. 이걸 잡으려면 chunk rollback 자체를 우회해야 했다.
해법은 같은 트랜잭션 안에서 savepoint를 쓰는 row-by-row fallback 이다.
@Transactional
public void processChunk(List<Row> rows) {
BulkInsertResult result = bulkInsertSafeExecutor.execute(
"insurance_insure",
rows,
this::bulkInsert, // 1차: 한 번에 다 insert
this::singleInsert, // 2차: 실패 시 row 단위 fallback
Row::toKey
);
if (result.hasSkipped()) {
log.warn("skipped={} samples={}", result.skippedCount(), result.skippedSamples());
}
}내부 동작:
@Transactional 안에서 호출DataIntegrityViolationException 발생하면 savepoint rollback → row마다 nested savepoint 만들고 단건 insertDataAccessException 은 catch하지 않고 propagate → 외부 @KeyLocked 의 retry가 처리핵심은 "같은 트랜잭션 안에서" savepoint를 쓴다는 것이다. 새 트랜잭션을 만들지 않으므로 outer transaction의 의도(원자성·격리)를 보존하면서, 부분 실패만 허용한다.
위반 row는 PII 마스킹 처리한 후 로그 + Micrometer counter (mydata.bulk_insert.row_skipped, tag: entity, reason) 로 기록해서 운영팀이 어떤 entity / 어떤 reason의 위반이 얼마나 자주 나는지 추적할 수 있게 했다.
3단계 작업은 큰 작업이라 13개 서비스에 순차 적용 중이다. 매 PR마다 BulkRepository.insertSingle(rec) delegate 추가 + 해당 서비스의 processPerKeyLock에 BulkInsertSafeExecutor.execute(...) wrap. 적용된 서비스부터 chunk 통째로 날아가는 일이 사라졌다.
작업 도중 회귀 하나도 잡았다 — Mockito byte-buddy + JUnit fork 조합에서 간헐적 클래스로딩 실패가 났다. forkEvery=50 → 25, maxParallelForks=1 로 처방했다. 본 작업과 무관해 보이지만, 테스트 안정성도 안정화의 일부 라고 보고 같은 사이클에 같이 잡았다.
본 사고와 직접 관련은 없지만 같은 시기에 정리한 것들:
undefined 로 변환해서 로그가 깨끗하게 나오게 했다.1년+ 의 작업으로 어디까지 왔는지 한 줄로:
BulkInsertSafeExecutor로 흡수 중. 13개 서비스 중 절반 이상 적용 완료. 적용된 영역에서는 chunk 통째로 날아가는 일이 0건이다.다섯 가지로 압축한다.
@KeyLocked) 으로 옮긴 게 가장 큰 한 수였다.다음 글에서는 BulkInsertSafeExecutor 의 savepoint 사용 패턴 — 왜 REQUIRES_NEW 가 아니라 nested savepoint 인지, JPA flush 타이밍을 어떻게 조정했는지 — 을 코드 레벨로 풀어볼 생각이다.