스프링

Spring Batch 커넥션 풀 고갈 해결 — 조회·계산·쓰기 3-Phase 분리

Stitchhhh 2026. 9. 1. 19:56

커넥션 풀 고갈 장애를 만나면 대부분 풀 사이즈를 늘리는 것부터 시작합니다. 하지만 이는 증상 완화에 가깝습니다. 실제로 운영 중인 배치에서 커넥션 풀 고갈로 배치가 멈추는 장애를 겪었고, 풀 사이즈를 늘려도 장애는 반복됐습니다. 근본 원인은 풀의 크기가 아니라 커넥션을 붙잡은 채 추가 커넥션을 획득하는 구조에 있었기 때문입니다.

이 글에서는 장애의 원인을 분석하고, 배치를 조회 → 계산 → 쓰기 3-Phase로 분리해 커넥션 점유 자체를 없앤 재설계 과정을 정리합니다.

1. 장애: 배치가 조용히 멈춘다

외부에서 유입되는 이벤트에 점수를 매기는 배치가 있었습니다. 1분 주기로 미처리 이벤트를 조회해 점수를 계산하고, 결과를 저장한 뒤 누적 통계를 갱신하는 전형적인 배치입니다.

어느 날부터 이 배치가 운영 환경에서 간헐적으로 멈추기 시작했습니다. 로그에는 HikariCP의 커넥션 획득 타임아웃이 찍혔고, 배치는 진행도 실패도 아닌 상태로 커넥션을 기다리다 스케줄이 밀렸습니다. 처음에는 커넥션 풀 설정 문제로 보고 운영 서버의 풀 사이즈를 조정하며 대응했지만 장애는 반복됐고, CI 테스트 환경에서도 같은 증상으로 테스트가 실패하는 일까지 생겼습니다.

풀 사이즈로는 해결되지 않는다는 것이 분명해졌을 때, 코드를 다시 봤습니다.

2. 원인: 커넥션을 쥔 채 커넥션을 기다린다

당시 배치는 단일 Tasklet 하나가 모든 일을 처리했습니다.

// Before: 단일 Tasklet (요약)
override fun execute(...): RepeatStatus {
    while (loop++ < MAX_LOOPS && processed.get() < MAX_TOTAL) {
        // ① while 루프 안에서 미처리 이벤트 ID 조회 (JDBC)
        val ids = jdbcTemplate.query("SELECT id FROM event WHERE score IS NULL ...")

        // ② 가상 스레드 4개로 병렬 verify() 호출
        ids.chunked(CONCURRENCY).forEach { chunk ->
            val futures = chunk.map { id ->
                virtualThreadExecutor.submit {
                    verifyUseCase.verify(command)  // ← 각각 REQUIRES_NEW
                }
            }
            futures.forEach { it.get() }
        }
    }
}

그리고 verify()는 이렇게 생겼습니다.

// Before: verify() — 단건 처리 서비스
@Transactional(propagation = Propagation.REQUIRES_NEW)
override fun verify(command: VerifyCommand) {
    val event = eventRepository.findByIdOrThrow(...)        // JPA 조회
    val counts = frequencyService.recordAndGetCounts(...)   // 최근 빈도 조회/기록
    val score = scoreGenerator.next(risk)                   // 점수 계산
    val status = event.updateScore(score)                   // dirty checking

    relatedServiceA.process(...)                            // 후속 도메인 처리 1
    relatedServiceB.update(...)                             // 후속 도메인 처리 2

    eventPublisher.publishEvent(ScoreVerifiedEvent(...))    // ← 동기 리스너 체인
}

문제는 세 겹으로 겹쳐 있었습니다.

① 커넥션을 쥔 채 추가 커넥션을 요청하는 구조. Tasklet의 while 루프가 조회용 커넥션을 사용하는 동안, 가상 스레드 4개가 각각 REQUIRES_NEW로 새 커넥션을 요구합니다. 순간 커넥션 수요는 1(조회) + 4(병렬 verify). 그런데 당시 배치 모듈의 풀 사이즈는 2였습니다. 조회 스레드는 커넥션을 쥔 채 verify 완료를 기다리고, verify 스레드들은 커넥션 반납을 기다리는 — 전형적인 pool exhaustion 교착 조건입니다.

② 트랜잭션 안에서 벌어지는 너무 많은 일. verify() 하나가 커넥션을 잡고 있는 동안 JPA 조회, 빈도 기록, 후속 도메인 처리, 그리고 동기 @EventListener 체인까지 전부 실행됩니다. 커넥션 점유 시간이 트랜잭션에 필요한 시간이 아니라 부수 작업 전체의 시간이 됩니다.

③ 통계 row를 둘러싼 lock 경합. 이벤트 리스너 끝에서 누적 통계 테이블의 카운트를 갱신하는데, 이 갱신이 SELECT 후 dirty checking UPDATE 방식이었습니다. 병렬로 도는 verify N건이 각자의 트랜잭션에서 같은 통계 row를 두고 경합하니, lock 대기가 커넥션 점유 시간을 다시 늘리는 악순환이 생깁니다.

요약하면 이렇습니다. 배치 1건 처리에 필요한 커넥션 점유는 수 ms면 충분한데, 구조가 커넥션을 수백 ms에서 수 초씩 붙잡게 만들었고, 그 커넥션들이 서로를 기다렸습니다.

3. 응급조치, 그리고 첫 설계의 폐기

우선 풀 사이즈를 2 → 5로 올려 급한 불을 껐습니다. 하지만 이것은 시간을 벌었을 뿐입니다. 병렬도나 처리량이 조금만 늘어도 같은 장애가 재현될 구조였습니다.

처음 세운 수정 계획은 두 가지였습니다.

  1. 이벤트 리스너를 @TransactionalEventListener(AFTER_COMMIT) + REQUIRES_NEW로 전환해 verify 트랜잭션의 커넥션을 먼저 반납시키기
  2. Semaphore(poolSize - 1)로 동시 DB 접근 수를 풀 사이즈 안으로 제한하기

그런데 계획을 구체화하면서 이 접근의 한계가 보였습니다. 둘 다 커넥션 점유 시간을 줄이거나 경쟁을 통제하는 방법이지, 점유 자체를 없애는 방법이 아닙니다. AFTER_COMMIT으로 미루면 이번에는 N건의 커밋 후 리스너가 각각 REQUIRES_NEW 트랜잭션을 열어 같은 통계 row를 UPDATE합니다 — 교착의 위치만 옮겨질 뿐입니다. Semaphore는 풀 고갈은 막지만 처리량을 풀 사이즈에 묶어버립니다.

그래서 방향을 바꿨습니다. 배치에서 커넥션이 필요한 순간을 쓰기 한 곳으로 몰아넣는 것입니다.

4. 재설계: 조회 → 계산 → 쓰기 3-Phase

단일 Tasklet을 Spring Batch의 3-Step Job으로 분리했습니다.

scoreJob
├── Step 1  FetchTasklet       (ResourcelessTransactionManager)
│           JDBC로 미처리 이벤트 + 빈도 카운트 배치 조회 → 메모리 컨텍스트에 적재
├── Step 2  CalculateTasklet   (ResourcelessTransactionManager)
│           DB 커넥션 0개. parallelStream 순수 계산
└── Step 3  WriteTasklet       (실제 TransactionManager)
            JDBC batchUpdate + 통계 atomic increment + 후속 알림

Phase 1 — 조회: 트랜잭션 없이, 쿼리 단위로만

Fetch Step은 ResourcelessTransactionManager로 묶어 Step 트랜잭션이 커넥션을 선점하지 않게 했습니다. JdbcClient 쿼리가 실행되는 순간에만 커넥션을 빌리고 즉시 반납합니다. 조회 결과는 @JobScope 컨텍스트 객체에 담아 다음 Step으로 넘깁니다.

Phase 2 — 계산: 커넥션 0개

점수 계산 로직을 순수 계산 클래스로 추출했습니다. 입력(이벤트 + 빈도 카운트)만 받아 리스크 → 점수 → 상태를 결정하는 stateless 로직이라 DB 없이 parallelStream으로 마음껏 병렬화할 수 있습니다. 기존에는 verify() 병렬화가 커넥션 4개를 요구했지만, 이제 병렬화 비용은 CPU뿐입니다.

부수 효과도 있었습니다. 계산이 순수 함수가 되니 seed 주입으로 결정론적 재현 테스트(점수 분포, 리스크별 구간, stateless 검증)를 작성할 수 있게 됐고, 단건 verify() 경로도 같은 계산 클래스를 공유해 로직 이원화를 막았습니다.

Phase 3 — 쓰기: 배치 UPDATE + atomic increment

쓰기는 짧은 트랜잭션 하나로 끝냅니다.

-- 멱등성 가드를 포함한 JDBC batchUpdate
UPDATE event
SET score = ?, verified_at = ?, status = ?
WHERE id = ? AND score IS NULL   -- 재실행돼도 안전

핵심은 통계 갱신 방식의 전환입니다. 이벤트 리스너 체인을 배치 경로에서 제거하고, Tasklet이 결과를 그룹별로 집계한 뒤 SELECT 없이 증분 UPDATE 한 방으로 반영합니다.

// N건 × 개별 트랜잭션 × SELECT+UPDATE  →  1 트랜잭션 × row당 UPDATE 1회
jdbcTemplate.update(
    "UPDATE stat_summary SET verified_count = verified_count + ?", total
)
countsByGroup.forEach { (groupId, count) ->
    jdbcTemplate.update(
        "UPDATE group_stat_summary
         SET verified_count = verified_count + ? WHERE group_id = ?",
        count, groupId
    )
}

읽지 않고 더하기만 하므로 read lock이 필요 없고, row당 UPDATE가 1회로 줄어 데드락이 구조적으로 발생하지 않습니다. 200건짜리 배치라면 통계 UPDATE가 기존 200회(각각 SELECT 포함)에서 그룹 수만큼(+전체 1회)으로 줄어듭니다.

5. 남아 있던 함정: ResourcelessTransactionManager + JPA 혼합

재설계 후에도 한 번 더 교착을 겪었습니다. Fetch Step에 남아 있던 JPA 의존(빈도 카운트 조회 서비스) 때문입니다.

ResourcelessTransactionManager는 트랜잭션 커넥션을 만들지 않습니다. 그런데 그 안에서 JPA를 호출하면 JPA가 자체적으로 커넥션을 열고, JDBC 쿼리와 서로 다른 커넥션을 오가며 경합합니다. "커넥션을 잡지 않는 Step"이라는 전제가 JPA 한 줄로 깨진 것입니다.

해결은 두 가지였습니다.

  • 빈도 카운트(최근 10초/60초 이벤트 수)를 이벤트 조회 쿼리에 상관 서브쿼리로 통합해 JPA 의존을 완전히 제거했습니다. 건별 쿼리 200회/배치가 배치당 1회로 줄었습니다.
  • 서브쿼리가 타는 (group_id, user_id, created_at) 복합 인덱스를 추가했습니다.
SELECT e.id, e.group_id, e.user_id,
       (SELECT COUNT(*) FROM event prev
        WHERE prev.group_id = e.group_id
          AND prev.user_id = e.user_id
          AND prev.created_at BETWEEN DATE_SUB(e.created_at, INTERVAL 10 SECOND)
                                  AND e.created_at) AS last10s,
       ...
FROM event e
WHERE e.id IN (...)

마지막으로 Tasklet 안에 있던 while 루프를 스케줄러로 옮겼습니다. Job 1회 실행 = 1 슬라이스(batchSize건)로 단순화하고, 스케줄러가 전체 미처리 건수를 세어 슬라이스를 반복 실행합니다. AtomicBoolean으로 중복 실행을 막고, 슬라이스별 진행 로그와 적체 감지 ERROR 로그, 좀비 execution 정리(stale abandon)를 붙여 운영 가시성을 확보했습니다. "루프가 커넥션을 쥐고 도는" 패턴 자체가 코드에서 사라졌습니다.

6. 재발 방지: 배치 전수 감사

같은 사고를 다른 배치에서 반복하지 않기 위해 배치 모듈의 tasklet 14개를 전수 감사했습니다. 기준은 세 가지입니다 — JPA 사용 여부, 트랜잭션 중 외부 I/O 여부, 어떤 TransactionManager를 쓰는가.

결과, MEDIUM 리스크 7건이 나왔습니다. 패턴은 이번 장애와 정확히 같은 계열이었습니다.

패턴대상위험 시나리오

트랜잭션 중 외부 API 호출 배치 3개 외부 응답 지연 시 커넥션 수십 초 점유 + REQUIRES_NEW로 커넥션 2개 → 고갈
트랜잭션 중 외부 스토리지 업로드 배치 2개 재시도(RetryTemplate) 시 수 분간 커넥션 점유
대량 JPA 엔티티 로딩 데이터 변환 배치 영속성 컨텍스트 누적 + 파일 I/O 중 커넥션 점유
Reader/Writer JPA+JDBC 혼합 청크 기반 잡 같은 청크에서 EntityManager와 JdbcTemplate 경합

감사에서 도출한 공통 원칙은 세 가지입니다.

  1. 외부 I/O(API 호출, 스토리지 업로드 등)는 트랜잭션 밖에서 수행합니다. DB 커넥션은 네트워크 I/O를 기다리는 데 쓰는 자원이 아닙니다.
  2. 배치 Step에서는 JPA보다 JDBC를 우선합니다. 영속성 컨텍스트와 dirty checking은 배치에서 이점이 없고, 커넥션 관리만 불투명해집니다.
  3. REQUIRES_NEW는 풀 사이즈와 함께 검토합니다. 동시 커넥션 2개를 전제하는 전파 속성입니다. 풀이 작으면 그 자체로 교착 조건이 됩니다.

7. 후일담: atomic increment의 대가, 그리고 보정 배치

atomic increment 전환에는 트레이드오프가 있습니다. SELECT 없이 더하기만 하다 보니, 카운트 기준에 버그가 있으면 누적값이 조용히 어긋납니다(drift). 실제로 배치가 "성공" 건수가 아니라 "처리" 건수 전체를 더하고 있던 것을 뒤늦게 발견했습니다.

수정과 함께, 증분 방식의 안전망으로 일일 보정(reconcile) 배치를 추가했습니다. 매일 새벽 원본 테이블 기준으로 누적 통계를 재계산해 증분 누적값을 덮어씁니다. 증분은 실시간성을, 보정은 정합성을 책임지는 구조입니다.

증분 집계를 도입한다면 처음부터 짝으로 설계하길 권합니다. increment(빠른 반영) + reconcile(원본 기준 보정) 입니다.