커넥션 풀이 고갈되기까지
AI 요청 지연이 DB 커넥션 누수로 이어진 과정
들어가며
포토부스 키오스크 행사 중 서버가 멈췄다. 참가자가 사진을 찍으면 AI 보정을 거쳐 결과물을 받는 구조였고, AI 요청이 쌓이면서 서비스 전체가 응답을 못 하게 됐다. 백엔드 담당자가 없는 상황이라 무작정 EC2를 재시작하면 같은 상황이 반복될 수 있었고, 일단 CloudWatch 로그부터 열었다.
로그 따라 원인 추적하기
T+0s - 요청 폭주
가장 먼저 눈에 띈 것은 AI 서버 헬스체크 로그가 초당 여러 건씩 반복되고 있다는 점이었다.
00:00.094 [http-nio-8080-exec-6] INFO
AI Server is already running. Checking instance tags for potential scale-out.
00:00.374 [http-nio-8080-exec-2] INFO
AI Server is already running. Checking instance tags for potential scale-out.
00:00.394 [http-nio-8080-exec-4] INFO
AI Server is already running. Checking instance tags for potential scale-out.
AI 처리 요청도 계속 들어오고 있었다. 문제는 이 요청들이 처리를 완료하지 못하고 쌓이고 있었다는 것이다.
T+1m 43s - 커넥션 누수 감지
약 2분 뒤, HikariCP(커넥션 풀 관리 라이브러리)의 하우스키퍼가 경고를 올렸다.
01:43.527 [HikariPool-1 housekeeper] WARN
Connection leak detection triggered for org.mariadb.jdbc.Connection@4a2f7c81
on thread http-nio-8080-exec-8, stack trace follows
java.lang.Exception: Apparent connection leak detected
...
at com.example.media.service.MediaService.uploadImage(MediaService.java:63)
at com.example.media.controller.PhotoController.requestAi(PhotoController.java:107)
HikariCP는 커넥션을 빌려간 뒤 일정 시간이 지나도 반환되지 않으면 "Apparent connection leak detected"라는 경고를 띄운다. 스택 트레이스는 PhotoController.requestAi에서 시작된 호출이 MediaService.uploadImage에서 커넥션을 붙들고 있다는 것을 가리키고 있었다.
T+1m 47s - NullPointerException 연쇄
몇 초 뒤, 전혀 다른 스레드에서 에러가 터지기 시작했다.
01:46.922 [reactor-http-nio-3] ERROR ai image upload error
java.lang.NullPointerException: Cannot read the array length because "b" is null
at java.base/java.io.FileOutputStream.write(FileOutputStream.java:336)
at com.example.media.service.MediaService.uploadAiImage(MediaService.java:81)
at com.example.media.service.AiResponseHandler.handleAiResponse(AiResponseHandler.java:39)
at com.example.media.service.AiServerClient.lambda$sendAiRequest$2(AiServerClient.java:58)
그리고 바로 이어서,
01:46.922 [reactor-http-nio-3] ERROR
AI request failed: a7f3b2c9-4d1e-4856-9f02-8c5a3b1e7d42
org.springframework.web.reactive.function.client.WebClientResponseException$GatewayTimeout:
504 Gateway Timeout from POST http://ai-server/api/process
AI 서버가 504(게이트웨이 타임아웃)를 반환하고 있었다. 요청이 몰리자 AI 서버가 처리를 감당하지 못하고 지연된 것이다. 이상한 건 같은 타임스탬프, 같은 스레드(reactor-http-nio-3)에서 504와 NPE가 동시에 찍히고 있다는 점이었다. 두 에러가 같은 요청 체인 안에서 엮여 있다는 뜻이었다.
T+2m 28s - 커넥션 풀 완전 고갈
누수가 반복되면서 결국 풀이 바닥났다.
02:27.853 [HikariPool-1 connection adder] WARN
Error: 1040-08004: Too many connections
에러 코드 1040-08004는 DB 서버의 max_connections 한계에 도달했다는 의미다. HikariCP 풀이 바닥난 것에 이어 DB 서버 자체가 더 이상 새 연결을 받지 않는 단계까지 간 것이다. 이 시점부터는 AI 보정 요청뿐 아니라 같은 DB를 쓰는 모든 기능이 동시에 멈췄다.
코드 들여다보기
로그만으로는 504가 어떻게 NPE로 이어지는지 설명되지 않아 실제 코드를 따라가봐야 했다.
AI 요청 흐름
이 서비스는 Spring Boot의 DeferredResult로 AI 서버 호출을 비동기 처리하고 있었다. DeferredResult는 JavaScript의 Promise와 비슷한 개념으로, "나중에 값이 채워질 빈 응답 객체"를 먼저 반환하고 실제 응답은 나중에 다른 스레드가 채워 넣는 패턴이다. 긴 외부 API 호출을 기다리는 동안 워커 스레드가 점유되는 걸 피하기 위한 구조다.
대략 이런 모양이다.
// DeferredResult 비동기 패턴 (일반 형태)
var deferred = new DeferredResult<Response>(timeoutMs);
// 응답이 오면 실행될 콜백 등록
handler.onResponse(requestId, response -> {
deferred.setResult(Response.ok(response));
});
// 외부 서비스에 비동기 요청
externalClient.send(request);
return deferred;
이 비동기 패턴 자체에는 결함이 없다. 문제는 내부 에러 처리가 AI 서버 실패 시 null을 fallback 값으로 콜백에 전달하는 방식이었는데, 그 null을 받는 쪽에서 체크가 없었다는 점이다. 즉 실패 시나리오가 null이라는 시그널로 표현됐지만, 이 시그널을 처리하는 방어 로직이 빠져 있었다.
NPE로 이어지는 null 전파 경로
AI 서버가 504를 반환하면 WebClient의 retrieve()가 기본 동작으로 이 응답을 WebClientResponseException$GatewayTimeout으로 변환한다. reactor 체인의 에러 핸들러가 이를 잡은 뒤, 응답 자리에 null을 넣어 내부 응답 핸들러로 전달한다. 여기까지는 "실패 시 null을 시그널로 전달하는" 내부 관행대로 동작한 셈이다.
문제는 이 null이 아무 방어 없이 계속 아래로 흘러갔다는 점이다. 응답 핸들러는 받은 값을 그대로 이미지 업로드 메서드에 넘겼고, 업로드 메서드는 이 값을 FileOutputStream.write(bytes)에 그대로 전달했다. 대략 이런 흐름이다.
// 단순화된 흐름
public void onResponse(String requestId, byte[] response) {
mediaService.upload(response); // null 체크 없이 통과
}
public void upload(byte[] bytes) {
try (var fos = new FileOutputStream(tempFile)) {
fos.write(bytes); // bytes가 null이면 NPE
}
}
fos.write(null)에서 NPE가 터진다. 상위의 try/catch가 이를 잡아 도메인 예외로 감싸 던지긴 했지만, 진짜 문제는 여기서부터였다.
커넥션 누수 발생 원인
NPE를 도메인 예외로 감싸 던지는 것 자체는 방어적으로 보인다. 그런데 왜 커넥션 누수가 발생했을까? 당시 이미지 업로드와 관련된 두 메서드(원본 업로드, AI 결과 업로드)에는 @Transactional 애너테이션이 없었다. 대략 이런 모양이었다.
// @Transactional 없음
public Media uploadImage(File image) {
String url = s3Uploader.upload(image);
return mediaRepository.save(Media.of(url));
}
public Media uploadAiImage(byte[] bytes) {
File tempFile = saveToTemp(bytes);
String url = s3Uploader.upload(tempFile);
return mediaRepository.save(Media.of(url));
}
JPA Repository로 DB 저장 로직을 호출하는데도 메서드 자체에는 트랜잭션 경계가 선언돼 있지 않은 상태였다.
@Transactional이 없으면 Spring이 이 메서드 호출을 명시적인 트랜잭션 경계로 인식하지 않는다. JPA가 내부적으로 커넥션을 획득하긴 하지만, 트랜잭션·커넥션 생명주기가 Spring의 트랜잭션 관리자에 묶이지 않기 때문에, 예외가 발생했을 때 "롤백 + 커넥션 반환"이 하나의 경계 안에서 자동으로 이뤄지지 않는다.
여기에 AI 요청 엔드포인트가 DeferredResult로 응답을 기다리는 비동기 구조라는 점이 겹치면서, 워커 스레드가 AI 응답 동안 물려 있는 사이 업로드 메서드에서 획득한 커넥션의 반환 시점이 불분명해진다. NPE가 터질 때마다 커넥션 하나가 깔끔하게 반환되지 못한 채 남고, 요청이 반복되면서 누수가 누적됐다.
결과적으로 @Transactional이 없어서 예외가 나도 커넥션이 안 돌아오는 구조를 만들었고, AI 서버 지연이 트리거가 되어 누수가 빠르게 쌓였다. HikariCP가 감지해 경고를 던졌고, pool 사이즈 30이 빠르게 소진되면서 "Too many connections" 상태에 이르렀다.
여기까지가 현장에서 로그와 코드를 보며 추정한 범위였다. "@Transactional이 없어서 커넥션이 안 돌아온다"는 가정으로 수정했을 때 실제로 문제가 풀렸지만, 정확한 메커니즘까지 파고들 여유는 현장에 없었다.
해결 방식
1. null 체크 추가
응답 핸들러와 컨트롤러 콜백 두 곳에 null 체크를 넣었다. 응답 핸들러는 업로드 메서드를 호출하기 전에 null이면 즉시 리턴해서 NPE 경로를 막고, 컨트롤러 콜백은 그래도 null이 도달하면 클라이언트에 503을 반환한다.
// 응답 핸들러
public void onResponse(String requestId, byte[] response) {
Consumer<byte[]> callback = callbacks.remove(requestId);
if (callback == null) return;
if (response == null) {
callback.accept(null);
return;
}
try {
mediaService.upload(response);
callback.accept(response);
} catch (Exception e) {
callback.accept(null);
}
}
// 컨트롤러 콜백
handler.onResponse(requestId, response -> {
if (response == null) {
deferred.setErrorResult(
Response.status(SERVICE_UNAVAILABLE).body("AI 서버가 응답하지 않습니다.")
);
} else {
deferred.setResult(Response.ok(response));
}
});
2. 업로드 메서드에 @Transactional 추가
이미지 업로드 관련 두 메서드에 @Transactional을 붙여서, Spring이 이 호출들을 명시적인 트랜잭션 경계로 관리하도록 했다.
@Transactional
public Media uploadImage(...) { ... }
@Transactional
public Media uploadAiImage(...) { ... }
이렇게 하면 메서드 실행 도중 예외가 발생해도 Spring의 트랜잭션 관리자가 롤백을 수행하면서 커넥션을 확실히 반환한다. 비동기 플로우에서 예외가 어떻게 튀든 커넥션 생명주기가 메서드 경계 안에 고정된다. 앞선 null 체크가 있어도 이게 없으면 비슷한 다른 예외가 터질 때 같은 누수가 재발할 수 있다.
3. HikariCP 풀 사이즈 증가
위 두 가지가 근본 픽스지만, 비슷한 부하가 다시 올 때를 대비해 풀 사이즈 자체도 늘렸다. 해결이라기보단 버퍼 확보에 가깝다.
hikari:
maximum-pool-size: 50
minimum-idle: 20
수정 후 코드를 EC2에 배포하고, 서버를 재시작했다.
OSIV가 만든 함정
이 글을 쓰면서 당시에 수정했던 부분들을 조금 더 파보았는데, 핵심은 Spring Boot의 OSIV 기본 설정에 있었다.
OSIV(Open Session In View)는 HTTP 요청 하나에 JPA 세션(EntityManager) 하나를 유지하는 설정이다. 요청이 끝날 때 세션이 닫히고 DB 커넥션도 풀로 돌아간다. 동기 요청에서는 문제없지만, DeferredResult 같은 비동기 구조에서는 HTTP 요청이 "완료"로 처리되는 시점이 AI 응답이 올 때까지 늦춰진다. 그 사이 DB 커넥션이 요청에 계속 묶여 있게 되는 거다. HikariCP 설정의 leak-detection-threshold: 60000(60초)이 이 시점을 감지해서 누수 경고를 띄웠다.
비유하자면 DB 커넥션은 렌터카와 같다. @Transactional이 없으면 여행(요청) 시작에 차를 빌려서 5분 운전(DB 저장) 후 식당에서 2시간(AI 응답 대기) 동안 주차장에 세워두고 여행이 끝나야 반납한다. @Transactional이 있으면 운전이 필요한 5분 동안만 빌렸다가 바로 반납하고, 식당에서 기다리는 동안 차는 다른 사람이 쓸 수 있다.
@Transactional 없음 | @Transactional 있음 | |
|---|---|---|
| 커넥션 잡는 시점 | 요청 시작 | 업로드 메서드 시작 |
| 커넥션 반환 시점 | AI 응답 올 때까지 (수십 초) | 메서드 끝나면 즉시 (몇 ms) |
| 커넥션 점유 시간 | 수십 초 | 몇 밀리초 |
| 풀 30개로 버틸 수 있나 | 못 버팀 (고갈) | 충분함 |
@Transactional을 붙이면 Spring은 그 메서드가 DB 작업 구간이라는 걸 인식하고, 메서드가 시작될 때 커넥션을 잡았다가 끝나면 바로 반환한다. @Transactional이 없으면 이 구간이 불명확해서 커넥션이 요청이 끝날 때까지 물려 있게 된다. 결국 @Transactional 추가의 진짜 효과는 커넥션을 물고 있는 시간을 수십 초에서 몇 밀리초로 줄인 것이었다.
사실 더 근본적인 해법은 spring.jpa.open-in-view=false로 OSIV 자체를 끄는 것이다. 다만 당시에는 OSIV의 존재 자체를 몰랐고, @Transactional을 붙여서 문제가 해결되니 거기서 멈췄다. OSIV를 끄는 게 더 나은 선택이라는 것을 정리하면서 알게 되었다.
정리
- AI 서버가 504를 반환하자
retrieve()가 기본 동작으로 예외를 던졌고, reactor 에러 핸들러가 그 자리에 null을 넣어 응답 처리 함수로 전달했다. 응답 처리 함수가 null 체크 없이 업로드 메서드로 그대로 흘려보내면서FileOutputStream.write(null)에서 NPE가 터졌다 - 여기에 업로드 메서드의
@Transactional부재가 결합되어, 예외 시 커넥션 반환이 깔끔하게 이뤄지지 않으면서 HikariCP 풀이 고갈됐다. 현장에선 이 수준까지 추정하고@Transactional추가로 급한 불을 껐다 - 고침은 세 가지로 정리됐다. 응답 핸들러와 컨트롤러 콜백 두 곳에 null 체크, 업로드 메서드에
@Transactional, HikariCP 풀 30 → 50 - 후에 다시 들여다보니 Spring Boot 기본 활성화된 OSIV와 비동기
DeferredResult의 조합이 원인이었다. OSIV가 요청이 끝날 때까지 커넥션을 물고 있는데, 비동기 요청은 AI 응답이 올 때까지 "끝나지 않아서" 커넥션이 수십 초 동안 반환되지 않았다.@Transactional을 붙이면 메서드 단위로 커넥션을 잡았다 반환하기 때문에 점유 시간이 수십 초에서 몇 밀리초로 줄어든 것이 실제 효과였다 - 프론트엔드 개발자라도 CloudWatch 로그와 스택 트레이스를 읽을 수 있으면 백엔드 장애의 원인 체인을 추적할 수 있다. 다만 현장에서 급히 내린 결론이 항상 완전하지는 않을 수 있다