Post

트랜잭션 내 외부 API 호출로 인한 Connection Pool 고갈 문제 해결기

k6 기반 부하 테스트와 HikariCP Connection Pool 상태 확인하기

트랜잭션 내 외부 API 호출로 인한 Connection Pool 고갈 문제 해결기

개요

Mockly에서는 사용자가 면접 질문에 답변을 제출하고, 마지막 답변까지 완료되면 AI가 전체 답변을 기반으로 피드백을 생성하는 흐름으로 구성되어 있다.

초기 구현에서는 마지막 답변 제출 요청 안에서 피드백 생성까지 함께 처리하였다. 사용자의 마지막 답변을 저장한 뒤 외부 AI API를 호출하고, 생성된 결과를 다시 저장하는 구조였다.

문제는 외부 AI API 호출이 트랜잭션 내부에서 수행되고 있었다는 점이다. 외부 호출의 응답 시간이 길어질 경우, 트랜잭션이 유지되는 시간 또한 길어지고, 그동안 DB connection 역시 함께 점유될 수 있다.

이번 글에서는 이러한 구조가 실제 시스템에 어떤 영향을 주는지 부하 테스트를 통해 확인해보고, 이후 어떤 방식으로 구조를 개선했는지 정리해보려 한다.

기존 구조

먼저 기존 구조의 핵심 흐름만 요약하면 다음과 같다.

1
2
3
4
5
6
@Transactional
submitAnswer() {
	saveAnswer();
	if (isAllQuestionsAnswered) generateFeedback(); // 외부 API 호출
	saveFeedback();
}

Micrometer Timer를 활용하여 100개 샘플 데이터로 측정한 결과, 외부 AI 호출 부분만 평균 10초 가까이 소요된 것을 확인할 수 있었다.

generate-feedback-latency-report


부하 테스트 구성

목적

부하 테스트에서 확인하고 싶은 사항들은 다음과 같다:

  • 답변 제출 요청이 증가할 때 connection pool 상태가 어떻게 변하는가
  • 일반적인 API 요청도 영향을 받는가
  • maxPoolSize를 늘리면 문제가 해소되는가

테스트 환경

이번 테스트에서는 긴 외부 호출이 connection을 오래 점유하는 상황 자체만을 재현하는 것에 초점을 맞추었다.

앞서 Micrometer Timer로 측정한 결과, 실제 AI 피드백 생성 구간은 평균 10초 가까이 소요된 것을 확인하였다. 따라서 외부 AI API의 응답 시간 변동성을 제거하고 동일한 조건에서 재현하기 위해 AI 호출 부분을 약 10초 동안 대기하는 mock 로직으로 대체하였다.

또한 connection pool과 HTTP metric 관찰을 위해 Spring Actuator + Prometheus + Grafana로 metric 수집 환경을 구성하였고, API 부하는 k6를 활용하여 생성하였다.


부하 테스트 시나리오

시나리오는 두 가지 트래픽을 동시에 실행한다:

  1. POST /submit-answer (마지막 답변인 경우) : API를 점점 늘려가며 호출 (5 → 10 → 15 → 20 → 30 VU, 총 16분)
  2. GET /db-ping (SELECT 1) : 간단한 5 VU로 일정하게 호출한다.

VU(Virtual User)는 부하 테스트에서 동시에 요청을 보내는 가상의 사용자 수를 의미한다.

위 조건을 바탕으로 다음과 같은 시나리오로 테스트를 진행하였다.

시나리오구조 변경maxPoolSizesubmit-answer 부하db-ping 부하
Abefore1030 VU5 VU
B1before3030 VU5 VU
B2before3060 VU5 VU
Cafter1060 VU5 VU


[시나리오 A] default 커넥션 풀 크기에서 트래픽을 늘려보자

HikariCP default 설정인 maxPoolSize=10 환경에서 답변 제출 요청을 점진적으로 증가시켜 보았다.

scenario-a-hikaricp-graph

초기에는 큰 문제가 없어보였지만, submitAnswer 요청 수가 증가하면서 active connection 수가 빠르게 maxPoolSize에 도달했다. 이후 pending connection이 증가하기 시작했고, connection 획득 timeout도 함께 발생한 것을 확인할 수 있다.

즉 답변 제출 요청이 증가하면서, 새로운 요청이 DB connection을 즉시 획득하지 못하고 대기하는 상태가 되었다.

scenario-a-k6-summary

k6 결과에서도 같은 흐름을 확인할 수 있다. 답변 제출 API는 평균 10.45s, p95는 15.02s 소요되었고, 실패된 요청은 전체 요청의 약 27%였다.

여기서 문제는 단순히 SELECT 1 조회만 수행하는 db-ping API도 영향을 받았다는 점이다. p95가 5s까지 증가했고, 실패된 요청도 전체 요청의 약 10%였다.

답변 제출 API 하나만 느려진 것이 아니라, 같은 connection pool을 공유하는 다른 요청들도 영향을 받았음을 의미한다.

해당 테스트를 통해 트랜잭션 내부의 긴 외부 API 호출이 Connection Pool의 고갈로 이어지고, 결국 전체 시스템의 문제로 이어질 수 있음을 확인할 수 있었다.


[시나리오 B1] 커넥션 풀의 크기를 늘리면 해결될까?

이번에는 maxPoolSize를 30으로 늘리고, 동일한 부하로 진행해보았다.

scenario-b1-hikaricp-graph

A와 달리 active connection이 증가하더라도 pending connection 개수는 증가하지 않고, connection 획득 timeout 또한 발생하지 않았다.

scenario-b-k6-summary

k6 결과에서도 답변 제출 API와 db-ping API 모두 실패 없이 처리되었다.

답변 제출 API는 평균 10.01s, p95 기준 10.04s로 mock AI 호출 시간 수준이었다. db-ping API 또한 평균 7.53ms, p95 기준 13.80ms로 안정적이었다.

해당 결과만 보면 maxPoolSize를 늘리는 것만으로 문제를 해결할 수 있는 것처럼 보인다.


[시나리오 B2] 다시 트래픽을 늘린다면?

이번에는 maxPoolSize는 30으로 유지한 채, 답변 제출 요청을 더 늘려보았다.

scenario-b2-hikaricp-graph

부하가 증가하자 다시 active connection이 maxPoolSize에 도달했고, idle connection 수는 0에 가까워졌다. pool 크기를 늘렸더라도 동시에 처리해야 하는 요청이 더 많아지자 같은 문제가 다시 발생하였다. 즉 maxPoolSize를 늘린 것은 문제를 해결한 것이 아니라, 한계 시점을 뒤로 미룬 것일 뿐이었다.

결국 pool 크기를 늘리는 데는 한계가 있었고, connection을 오래 점유하고 있는 구조 자체를 바꿔야 했다.


외부 API를 트랜잭션 밖으로 꺼내보자

(1) 트랜잭션 경계를 조정하자

기존 요청-응답 흐름을 유지한 채, 트랜잭션 경계만 조정해서 connection 점유 문제를 줄이는 방향을 먼저 검토하였다.

1
2
3
4
5
6
7
8
9
10
[tx1]
1. 마지막 답변 저장
2. FEEDBACK_GENERATING 상태 변경

[tx 밖]
1. AI 피드백 생성

[tx2]
1. 피드백 저장
2. FEEDBACK_COMPLETED 상태 변경

트랜잭션 밖으로 분리하는 순간, 더 이상 하나의 원자적 작업으로 처리할 수 없기 때문에, 중간 실패 상태를 명시적으로 관리해야 한다. 다만 이 방식의 결정적인 한계는 긴 AI 작업이 여전히 요청 처리 스레드를 점유한다는 점이었다.

(2) AI 피드백 생성은 비동기 작업으로 분리하자

긴 AI 작업이 요청 처리 스레드를 계속 점유하지 않도록, AI 피드백 생성을 비동기 작업으로 전환하였다.

1
2
3
4
5
6
7
8
9
10
11
[POST /answers]
1. 마지막 답변 저장
2. FEEDBACK_PENDING 상태 변경
3. 피드백 생성 이벤트 발행
4. 클라이언트에 응답 반환

[background worker]
1. FEEDBACK_GENERATING 상태 변경
2. (tx 밖) AI 피드백 생성
3. AI 피드백 저장
4. FEEDBACK_COMPLETED 상태 변경

하지만 이처럼 비동기 작업으로 분리하면, 클라이언트는 피드백 생성 완료 시점을 별도로 확인해야 한다는 새로운 요구사항이 생긴다. 가장 단순한 방법은 polling이지만, 완료 여부를 확인하기 위해 반복적으로 요청을 보내는 구조는 비효율적이다.

(3) SSE로 클라이언트에게 상태를 알려주자

완료 시점을 클라이언트가 반복적으로 확인하기보다, 서버가 직접 알려주는 방식이 더 적합하다고 판단하여 SSE를 도입하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[POST /answers]
1. 마지막 답변 저장
2. FEEDBACK_PENDING 상태 변경
3. 피드백 생성 이벤트 발행
4. 클라이언트에 응답 반환

[프론트]
- 피드백 대기 화면에서 GET /feedback/events로 SSE 연결

[background worker]
1. FEEDBACK_GENERATING 상태 변경
2. (tx 밖) AI 피드백 생성
3. AI 피드백 저장
4. FEEDBACK_COMPLETED 상태 변경

[SSE endpoint]
- 상태 변경 이벤트 전달: GENERATING/COMPLETED/FAILED

[프론트]
- COMPLETED 수신 시, GET /feedback으로 피드백 결과 조회

피드백 생성 과정에서는 AI가 생성해준 결과를 그대로 클라이언트에게 전달하지 않고, 클라이언트가 사용하는 JSON 응답 형태로 가공한 뒤 저장하고 내려주는 구조다. 따라서 SSE를 피드백 내용을 전달하기 위한 용도가 아니라, 생성 완료 여부를 알리는 용도로 사용하였다.


[시나리오 C] 변경된 구조에 같은 부하를 준다면?

앞서 테스트를 통해 문제는 connection 개수 자체가 아니라, 하나의 요청이 connection을 장시간 점유하는 구조에 있다는 것을 확인할 수 있었다. 그렇다면 구조를 변경했을 때 같은 문제가 사라지는지도 다시 검증해보았다.

maxPoolSize는 default 설정인 10으로 줄이고, VU는 60으로 진행해보았다.

scenario-c-hikaricp-graph

구조 변경 이후에는 active connection이 maxPoolSize에 도달하지 않고, pending connection이나 connection 획득 timeout도 발생하지 않았음을 확인할 수 있다.

참고로 Prometheus scrape interval은 30초로 설정하였다.
따라서 짧은 DB 작업들이 모두 포착되지 않을 수 있다.
해당 테스트에서는 순간적인 active connection 값보다 active connection이 maxPoolSize에 장시간 머무르는지, pending connection과 connection 획득 timeout이 발생하는지를 중점적으로 확인하였다.

scenario-c-k6-summary

k6 결과에서도 답변 제출 API와 db-ping API 모두 안정적으로 처리되었고, 실패된 요청 역시 발생하지 않았다.

구조 변경 이후에는 특정 요청이 connection을 장시간 점유하지 않게 되었고, 이전처럼 다른 API까지 영향을 받는 현상도 발생하지 않았다.


✅ 정리

부하 테스트를 통해 트랜잭션 내 긴 외부 API 호출이 단순히 특정 API 하나의 응답 지연으로 끝나는 것이 아니라, connection pool 고갈로 이어져 전체 시스템에 영향을 줄 수 있음을 확인할 수 있었다. 트랜잭션 내부에서 응답 시간이 긴 작업이 수행되면, 요청 처리 스레드와 DB connection이 함께 오래 점유되고, 요청 수가 증가할수록 다른 요청들 또한 영향을 받게 된다.

이번 테스트를 진행하며 트랜잭션은 단순히 rollback 범위를 관리하는 개념이 아니라, 어떤 자원을 얼마나 오래 점유하는지도 함께 고려해야 한다는 것을 알게 됐다. 외부 API 호출처럼 응답 시간이 긴 작업이 트랜잭션 내부에 포함될 경우, 데이터 정합성 관점만으로는 보이지 않던 문제들이 실제 부하 상황에서 드러날 수 있었다. 따라서 maxPoolSize를 늘리는 방식은 문제를 해결하는 것이 아니라, 한계 시점을 뒤로 미루는 것에 가까웠다.

앞으로는 트랜잭션 경계 안에서 어떤 작업이 수행되는지, 정말 트랜잭션 안에서 처리되어야 하는 작업인지 더 신중하게 살펴보아야겠다.

This post is licensed under CC BY 4.0 by the author.