사용자에겐 실패, 서버에선 성공이던 결제 — 커넥션 풀 고갈 추적

결제가 간헐적으로 15분씩 밀렸다. 같은 결제 한 건을 두고 사용자 화면에는 “결제 실패”가 표시됐는데, 백엔드에는 약 15분 뒤 성공 기록이 남았다. 이 글은 그 상태 불일치를 추적해 커넥션 풀 설정을 바로잡은 기록이다. 당시의 진단과 글을 쓰면서 공식 문서로 확인한 동작은 나누어 설명한다.

1. 문제 — 실패인데 성공이던 결제

여러 외부사를 연결하는 결제 중계 구간에서 API Gateway를 담당하고 있었다. 결제 실패 건을 확인하던 중 DB에서 요청 기록은 찾았지만 대응하는 응답 기록은 찾지 못했다. 처음에는 실패로 판단했다. 그러나 약 15분 뒤 같은 요청의 응답이 기록됐고, 뒤쪽 백엔드에서는 결제가 완료돼 있었다.

앞단 게이트웨이는 응답을 기다리다 타임아웃으로 사용자에게 실패를 알렸다. 그 시점에도 백엔드의 작업은 끝나지 않았고 나중에 성공했다. 사용자에게 보이는 상태와 실제 처리 결과가 어긋난 것이다. 사용자가 재시도할 경우 중복 처리 위험이 생기고, 성공한 거래를 실패로 남겨 두면 후속 확인도 어려워진다. 실제 이중 결제가 발생했다는 뜻은 아니지만 단순한 응답 지연으로만 볼 수 없는 문제였다.

당시에는 요청이 어느 구간에 머무는지 확인할 모니터링 도구가 없었다. 먼저 지연된 요청과 뒤늦게 기록된 응답을 연결해 볼 방법이 필요했다.

2. 접근 — 코드에서 자원으로 추적 범위를 넓히다

서버 상태를 점검했지만 원인을 설명할 이상은 찾지 못했다. 코드와 오류 로그도 특정 지점을 가리키지 않았다. 간헐적으로 오래 기다린 뒤 처리가 이어진다는 증상에서 자원 부족 가능성을 의심했다. 과거에 풀 설정을 수정했던 기억을 단서로 PostgreSQL 커넥션 풀을 점검했다.

동시에 요청과 응답을 매핑하는 SQL 기반 추적 로깅을 만들었다. 같은 결제 요청 식별자로 두 기록을 연결해 응답이 아직 없는 건과 뒤늦게 응답이 남은 건을 찾았다. 비개발자도 처리 흐름을 확인할 수 있도록 대시보드도 붙였다.

이 로깅이 보여 주는 것은 요청의 미완료와 지연이었다. 응답 없는 요청 수만으로 풀 고갈이나 누수를 확정할 수는 없다. 외부 응답 대기나 느린 쿼리도 같은 증상을 만들 수 있기 때문이다. 풀의 점유·반납 상태와 런타임 설정을 함께 확인하는 과정이 필요했다.

3. 구현 — 설정 반영과 대기 정책을 구분하다

당시에는 반환되지 않은 커넥션이 풀을 점유하고, 새 요청이 커넥션을 얻지 못하는 상태로 진단했다. 코드에 풀 설정은 있었지만 런타임에 반영되지 않아 기본값으로 동작하고 있었다. HikariCP에서 Apache Commons DBCP 2(이하 DBCP2)로 교체한 구간을 점검했고, 풀 크기를 조정하며 설정이 실제 적용되도록 바로잡았다.

여기까지가 당시 확인하고 조치한 내용이다. 설정이 반영되지 않은 정확한 이유와 최종 설정값은 남은 기록만으로 복원하기 어렵다. 따라서 특정 프로퍼티 이름이나 설정 바인딩 오류를 당시 원인으로 추가하지 않는다. 반환되지 않았던 커넥션의 구체적인 실행 경로도 이 글에서 재현한 것은 아니다.

글을 쓰면서는 두 구현체가 풀 고갈 시 어떻게 대기하는지 비교했다. 아래는 공식 문서의 기본값을 Spring Boot 프로퍼티 형태로 적은 비교 예시다. 장애 당시 파일이나 해결 설정이 아니며, 두 구현체의 설정을 한꺼번에 적용하라는 의미도 아니다.

# HikariCP: 커넥션 획득 대기 기본값 30초
spring.datasource.hikari.connection-timeout=30000
# 풀 크기 기본값 10
spring.datasource.hikari.maximum-pool-size=10

# DBCP2: 풀 고갈 시 획득 대기 기본값은 무기한
spring.datasource.dbcp2.max-wait-millis=-1
# 풀 크기 기본값 8
spring.datasource.dbcp2.max-total=8
# 미사용으로 판정한 커넥션의 회수 기능은 기본적으로 꺼져 있음
spring.datasource.dbcp2.remove-abandoned-on-borrow=false
spring.datasource.dbcp2.remove-abandoned-on-maintenance=false

HikariCP의 connectionTimeout은 풀에서 커넥션을 얻기 위해 기다리는 시간이다. 기본값은 30초이며, 그동안 얻지 못하면 예외를 던진다. DBCP2의 maxWaitMillis=-1은 풀 고갈 시 반환을 무기한 기다리는 설정이다. 쿼리 실행 시간이나 HTTP 요청 전체의 제한 시간과는 구분해야 한다. 기본값은 각각 HikariCP 설정 문서와 DBCP2 설정 문서에서 확인했다.

이 차이는 게이트웨이가 먼저 응답 대기를 끝내도 백엔드는 계속 기다릴 수 있다는 설명과 맞는다. 다만 -1이 15분이라는 지연 시간을 만드는 것은 아니다. 당시 관찰한 약 15분 중 커넥션 획득 대기가 차지한 시간을 별도로 측정한 기록은 없다. 풀 크기 8과 10의 차이만으로 누수나 고갈의 원인을 확정할 수도 없다.

커넥션 수명과 누수 처리는 별개의 설정이다. HikariCP의 maxLifetime은 사용 중인 커넥션을 강제로 회수하지 않는다. 수명이 지난 커넥션도 반환된 뒤 제거하며, leakDetectionThreshold는 누수 가능성을 로그로 알리는 기능이다. HikariCP가 기본적으로 누수 커넥션을 회수하던 안전망이 교체 후 사라졌다고 설명하면 잘못이다. HikariCP 공식 설명

DBCP2의 removeAbandonedOnBorrow도 켜기만 하면 모든 오래된 커넥션을 회수하는 것은 아니다. 커넥션을 빌리는 시점에 active > maxTotal - 3, idle < 2를 만족하고, 해당 커넥션의 미사용 시간이 removeAbandonedTimeout을 넘겨야 한다. 유지보수 시 회수하는 removeAbandonedOnMaintenance는 별도 경로이며 유지보수 주기가 활성화돼 있어야 한다. 이런 회수 정책을 누수의 원인 제거와 동일하게 볼 수는 없다. DBCP2 회수 조건

아래 도표는 관찰한 상태 불일치에 풀 고갈 시의 대기 경로를 더한 개념도다. 당시 호출 추적을 시간순으로 재생한 자료는 아니며, 15분 전체가 풀 대기였다고 뜻하지 않는다.

도표 크게 보기

4. 결과

풀 설정이 실제로 적용되도록 수정한 뒤 결제 지연이 해소되고 요청과 응답의 매핑이 정상으로 돌아왔다. 이번 장애에서 관찰한 사용자 화면과 백엔드 결과의 불일치도 해소됐다. 다만 이 조치로 모든 종류의 타임아웃이나 결제 중복 처리를 방지했다고 말할 수는 없다. 이 글에서 다룬 변경은 커넥션 풀의 설정과 운영 상태다.

추적 로깅과 대시보드는 대응이 끝난 뒤에도 남았다. 이전에는 개별 요청의 DB 기록을 찾아가며 확인해야 했지만, 이후에는 응답이 없는 건과 지연된 흐름을 같은 화면에서 볼 수 있었다. 다만 요청 추적 화면이 풀의 활성·유휴 커넥션 수나 획득 대기 시간을 직접 측정하는 지표를 대신하는 것은 아니다.

5. 학습

구현체를 바꾸면 설정의 의미와 적용값을 함께 확인한다. 이름이 비슷한 설정도 역할과 기본값이 다를 수 있다. Spring Boot 역시 구현체별 설정 접두사를 구분한다. 이 사례에서 돌아봐야 할 지점은 코드에 설정이 있었다는 사실과 실행 중인 풀이 그 값을 사용한다는 사실을 같게 본 것이다.

증상, 진단, 사후 해석을 나누어 기록한다. 응답 없는 요청은 증상이고, 풀 고갈은 자원 상태에 대한 진단이다. 기본값 비교는 그 동작을 이해하는 데 도움을 주지만 당시 실행값이나 대기 시간을 대신 입증하지는 않는다. 이번 기록에서는 풀 설정을 바로잡아 정상화한 경험과 문서로 복기한 동작을 구분했다.

관측 도구는 무엇을 보여 주는지까지 설명한다. 요청·응답 추적은 처리가 지연된 건을 찾게 해 줬다. 그 지연이 어디서 발생했는지 좁히려면 커넥션의 점유와 반환, 획득 대기를 따로 확인해야 한다. 직접 만든 대시보드의 역할도 이 구분 안에서 설명할 수 있다.

← 전체 글 목록