· · SPRING-BATCH#mydata#retrospective#savepoint#deadlock#virtual-thread
14분 · by Daniel
02 · SPRING-BATCH · 14분 분량

마이데이터 정기 전송 배치를 안정화한 1년

DeadLock, 커넥션풀 고갈, chunk 전체 rollback — 정기 전송 배치 한 곳에서 시작된 사고가 주간 통계까지 마비시켰던 과정과, 그걸 단계적으로 잡아온 회고.

COVER
STORY
D
WRITER · Daniel
발행 2026-05-15 · 14분 분량

한 배치가 다른 배치를 잡아먹는다

마이데이터 정기 전송은 매일 새벽에 도는 가장 큰 배치다. 사용자별 동의 스케줄을 따라 외부 마이데이터 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.

무엇이 무너졌나

처음 우리가 직면한 건 세 개의 서로 얽힌 문제였다.

  1. DeadLock 전파 — 정기 전송 로직 중 delete + insert를 동시에 하는 부분에서 DB 락이 발생했고, 이 락이 같은 DB를 쓰는 다른 어플리케이션(통계 배치, 운영 API)으로 전파됐다. 에러 메시지는 SearchTimestamp 테이블에서 락이 발생한 것처럼 보였지만, 원인은 한 유저의 정기 전송 트랜잭션이 너무 오래 들고 있던 락이었다.
  2. 커넥션풀 고갈 — 정기 전송이 Virtual Thread로 수천 개의 외부 API를 동시에 호출하면서, 각자 HikariCP 커넥션을 잡았다. maximum-pool-size: 20이 순식간에 100% 차고, 다른 배치는 커넥션을 못 빌려서 시작조차 못 했다.
  3. chunk 전체 rollback — Spring Batch chunk 단위로 bulk insert를 했는데, 그 중 한 row가 NOT NULL 또는 length 제약을 위반하면 chunk 전체가 rollback됐다. 외부 마이데이터 API 응답 한 줄 때문에 수백 row가 통째로 날아갔고, 그 chunk는 다시 시작점부터 재처리해야 했다.

이 셋은 서로를 악화시켰다. chunk가 rollback되면 재시도하면서 락을 더 오래 들었고, 락이 길어지면 커넥션이 더 오래 점유됐고, 커넥션이 모자라면 통계 배치가 시작 못 했다.

1단계 — 커넥션·락·thread 정리

가장 먼저 잡은 건 전파 경로 였다. 원인을 다 못 잡더라도 다른 배치까지 죽이는 건 멈춰야 했다.

커넥션풀 확장. application-live.ymlmaximum-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단계. 사고는 여전히 났지만, 다른 배치까지 같이 죽지는 않았다.

2단계 — 쿼리 튜닝 + 위반 row 사전 필터링

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는 회사 내 공식 채널이라 모든 팀원이 같은 채널에서 본다는 점이 컸다.

3단계 (현재 진행 중) — BulkInsertSafeExecutor

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());
    }
}

내부 동작:

  1. 외부 @Transactional 안에서 호출
  2. savepoint 생성 → bulk insert 시도
  3. 성공이면 savepoint release, 끝
  4. DataIntegrityViolationException 발생하면 savepoint rollback → row마다 nested savepoint 만들고 단건 insert
  5. 위반 row만 skip + 로그 + 메트릭, 정상 row는 commit
  6. DeadLock 등 다른 DataAccessException 은 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 추가 + 해당 서비스의 processPerKeyLockBulkInsertSafeExecutor.execute(...) wrap. 적용된 서비스부터 chunk 통째로 날아가는 일이 사라졌다.

작업 도중 회귀 하나도 잡았다 — Mockito byte-buddy + JUnit fork 조합에서 간헐적 클래스로딩 실패가 났다. forkEvery=50 → 25, maxParallelForks=1 로 처방했다. 본 작업과 무관해 보이지만, 테스트 안정성도 안정화의 일부 라고 보고 같은 사이클에 같이 잡았다.

부가적인 변화

본 사고와 직접 관련은 없지만 같은 시기에 정리한 것들:

  • 마이데이터 종합포털 토큰 갱신 / 만료 알림 배치 — 토큰이 만료된 줄도 모르고 정기 전송이 무더기로 실패하는 일을 막기 위해, 만료 직전 Dooray 알림 + 자동 갱신 배치를 추가했다.
  • 로그 레벨·logback 설정 정리 — 사고 분석할 때 로그가 너무 많아서 정작 필요한 로그를 못 찾는 일이 잦았다. ERROR / WARN / INFO 레벨을 다시 잡고, KeyLock 적용 시 null 필드는 undefined 로 변환해서 로그가 깨끗하게 나오게 했다.
  • DB 커넥션 점유 20초 이상이면 스택 트레이스 로그 기록 — 어떤 메서드가 커넥션을 오래 들고 있는지를 코드 위치로 바로 찾을 수 있게 했다. 이게 2단계 쿼리 튜닝 대상을 정확히 잡는 데 결정적이었다.

지금 어디까지 왔나

1년+ 의 작업으로 어디까지 왔는지 한 줄로:

  • 1단계 완료: 정기 전송 사고가 다른 배치(주간 통계 등)에 전파되는 일은 사실상 사라졌다. 통계 데이터는 약속한 시각에 안정적으로 전송되고 있다.
  • 2단계 완료: chunk 전체 rollback의 빈도는 사전 필터링으로 90%+ 줄였다.
  • 3단계 진행 중: 사전 필터링이 못 잡는 위반까지 BulkInsertSafeExecutor로 흡수 중. 13개 서비스 중 절반 이상 적용 완료. 적용된 영역에서는 chunk 통째로 날아가는 일이 0건이다.

회고 — 다시 한다면

다섯 가지로 압축한다.

  1. DB 락에 의존하지 말 것. 같은 DB를 쓰는 어플리케이션이 여럿이면, 한 곳의 락이 모두의 락이 된다. 어플리케이션 단 락 (@KeyLocked) 으로 옮긴 게 가장 큰 한 수였다.
  2. Virtual Thread는 처리량 이지 안정성 이 아니다. 무제한 동시성은 그만큼의 커넥션·메모리·외부 API rate limit을 요구한다. bounded executor + 외부 API 동시성 제한이 우선.
  3. 사전 검증 > 사후 복구. 사후 복구(savepoint fallback) 는 강력하지만 늦다. DB에 보내기 전에 컬럼 제약을 검증 하는 게 빠르고 싸다. 단, 모르는 위반에 대한 안전망(BulkInsertSafeExecutor) 은 별도로.
  4. 사고 가시성에 투자. 사고 자체보다 어디서 시작됐는지 를 찾는 데 시간이 더 들었다. 20초 이상 커넥션 점유 시 스택 트레이스 자동 로그가 그 다음 3개 PR의 방향 을 정해줬다.
  5. 충격 차단 → 원인 제거 순. 1단계 (커넥션·락·thread) 가 원인 해결은 아니었지만, 다른 배치까지 같이 죽는 걸 막은 블라스트 라디우스 감소 였다. 그게 없었으면 2·3단계 작업을 차분히 할 시간이 없었을 것이다.

다음 글에서는 BulkInsertSafeExecutor 의 savepoint 사용 패턴 — 왜 REQUIRES_NEW 가 아니라 nested savepoint 인지, JPA flush 타이밍을 어떻게 조정했는지 — 을 코드 레벨로 풀어볼 생각이다.