본문으로 건너뛰기

커넥션 풀 고갈 방지

발생 상황

비동기 웹훅 브리지 설계를 구체화하면서 트랜잭션 범위를 분석하다가 발견한 문제입니다.

사주 분석 요청을 처리하는 메서드에는 @Transactional이 선언되어 있었습니다. 이 메서드 안에서 외부 API 콜백을 기다리는 CompletableFuture.get(timeout) 블로킹 대기가 수십 초간 진행되면, 트랜잭션이 유지된 채로 DB 커넥션이 계속 점유됩니다.

HikariCP의 기본 최대 커넥션 수는 10개입니다. 동시에 11번째 요청이 들어오는 순간, 해당 요청은 커넥션을 획득할 때까지 대기하고 이 대기가 타임아웃이 되면 Connection is not available 오류가 발생합니다.

동시 요청 10개가 모두 외부 API 응답 대기 중
→ HikariCP 커넥션 10개 전부 점유
→ 11번째 요청: 커넥션 획득 대기 → 타임아웃 오류
→ 회원 조회, 로그인 등 관계없는 API까지 DB 접근 불가

외부 API 대기 시간 동안 DB 커넥션을 들고 있을 이유가 전혀 없다는 것이 핵심이었습니다. 커넥션이 실제로 필요한 구간은 DB에서 데이터를 읽거나 쓸 때뿐입니다.


해결 방법

1. NOT_SUPPORTED 트랜잭션 전파

웹훅 대기를 담당하는 메서드에 @Transactional(propagation = NOT_SUPPORTED)를 적용했습니다. 이 설정은 메서드 진입 시 현재 트랜잭션을 일시 중단하고 커넥션을 풀에 반납합니다. 대기가 끝나면 트랜잭션은 다시 재개됩니다.

[요청 진입 - 트랜잭션 시작]
→ 외부 API 요청 전송

[NOT_SUPPORTED 메서드 진입]
→ 트랜잭션 일시 중단
→ DB 커넥션 풀 반납 ← 핵심
→ CompletableFuture.get() 블로킹 대기 (커넥션 미점유)
→ 콜백 수신 → 대기 완료

[NOT_SUPPORTED 메서드 종료]
→ 트랜잭션 재개
→ DB 저장 후 커밋

REQUIRES_NEW(새 트랜잭션 시작)도 검토했으나, 기존 트랜잭션을 중단하지 않고 새 커넥션을 추가로 사용하기 때문에 오히려 커넥션을 2개 소비하는 문제가 생깁니다. NOT_SUPPORTED만이 기존 커넥션을 반납하는 동작을 합니다.


2. afterCommit() — 미커밋 데이터 읽기 방지

NOT_SUPPORTED 적용 후 새로운 문제가 발생했습니다.

콜백이 도착하는 타이밍과 DB 커밋이 완료되는 타이밍이 일치하지 않을 수 있습니다. 콜백 수신 직후 Future.complete()를 호출하면, 아직 DB에 커밋이 완료되지 않은 시점에 클라이언트 응답이 나갈 수 있습니다. 클라이언트가 즉시 결과를 조회하면 빈 데이터를 읽게 됩니다.

콜백 수신 → Future.complete() → 클라이언트 결과 수신

DB 커밋이 아직 안 됐을 수 있음
→ 클라이언트가 바로 조회 → 빈 데이터

TransactionSynchronization.afterCommit()을 사용해, DB 커밋이 완전히 완료된 후에만 Future.complete()가 호출되도록 순서를 보장했습니다.

콜백 수신 → DB 저장 트랜잭션 → INSERT/UPDATE → 커밋 완료

afterCommit() 실행

Future.complete()

클라이언트 응답 반환

afterCompletion()은 롤백 시에도 호출되므로 사용하지 않았습니다. afterCommit()은 커밋이 성공한 경우에만 호출되므로 안전합니다.


이 방식을 선택한 이유

근본 원인은 "DB 커넥션이 필요하지 않은 구간에 트랜잭션이 열려 있다"는 것이었습니다. 해결 방향을 두 가지로 검토했습니다.

커넥션 풀 크기를 늘리는 방식 — 단기적으로는 동작하지만 외부 API 응답 지연이 길어질수록 필요한 커넥션 수가 선형으로 늘어납니다. 풀 크기를 키우면 메모리 사용량이 증가하고, 근본 원인인 불필요한 커넥션 점유를 해결하지 못합니다.

NOT_SUPPORTED로 커넥션 반납 — 대기 중에는 실제로 DB 작업이 없으므로 커넥션을 들고 있을 이유가 없습니다. 트랜잭션 범위를 실제로 DB 작업이 필요한 구간으로만 좁히는 것이 근본 해결책이라 판단했습니다.