[친구하자] @Transactional 안에서 외부 네트워크를 호출하면 안 되는 이유
@Transactional 범위 안에서 외부 네트워크 호출(Gemini WebSocket)을 처리했다가, DB 커넥션 점유 시간이 길어져 커넥션 풀 고갈 위험이 생긴 문제와 해결(TransactionTemplate)을 정리합니다.
@Transactional범위 안에 외부 네트워크 호출을 끼워 넣으면 DB 커넥션 풀이 고갈될 수 있다. 그리고 “메서드만 분리하면 되겠지”라는 생각은 Spring의 프록시 구조(self-invocation)에 막힌다.
- 들어가며
- 처음 작성한 코드
- 왜 문제인가?
- 1차 시도: 메서드를 분리하면 되겠지?
- Spring @Transactional의 작동 원리: 프록시
- 2차 시도: TransactionTemplate
- 추가로 발견한 것: try-finally의 중요성
- 정리
- 참고
들어가며
AI 통화 기능을 개발하고 있었다. 사용자가 앱에서 AI와 실시간으로 음성 통화를 나누는 기능이다. 기술 스택은 Spring WebSocket(raw) + Google Gemini Live API. 사용자의 목소리는 PCM16 바이너리로 WebSocket을 통해 서버에 전달되고, 서버는 이를 Gemini로 중계한다. Gemini가 답변 음성을 내려주면 다시 사용자에게 스트리밍한다.
통화가 끊길 때 정리해야 할 것이 두 가지였다.
- DB 상태 업데이트 –
AiCall을 COMPLETED/FAILED로 전환,AiCallSession을 LEFT로 전환 - Gemini 네트워크 연결 종료 – WebSocket 연결을 닫아 리소스 해제
처음에는 이 두 작업을 하나의 @Transactional 메서드 안에서 처리했다.
처음 작성한 코드
@Transactional
public void disconnectSession(Long aiCallId, String wsSessionId, boolean isNormal) {
// 1. DB 상태 업데이트
aiCallSessionRepository.findByWebsocketSessionId(wsSessionId)
.ifPresent(session -> {
session.leaveSession();
aiCallSessionRepository.save(session);
});
aiCallRepository.findById(aiCallId).ifPresent(aiCall -> {
if (isNormal) aiCall.endCall();
else aiCall.failCall();
aiCallRepository.save(aiCall);
});
// 2. Gemini 네트워크 연결 종료 ← 여기가 문제!
geminiLiveApiClient.disconnect(String.valueOf(aiCallId));
}
코드 리뷰에서 이 부분이 지적됐다. “@Transactional 메서드 안에서 외부 네트워크 호출을 하면 안 됩니다.” 처음엔 “DB 저장하고, 네트워크 끊고, 커밋하면 되는 거 아닌가?” 싶었다.
왜 문제인가?
아래처럼 @Transactional이 걸린 메서드 안에 “DB 작업 + 외부 네트워크 호출”이 함께 들어가면, 네트워크 대기 시간 동안 DB 커넥션이 계속 점유된다.
시간 →
[트랜잭션 시작] ── [DB 작업] ── [외부 네트워크 호출(최대 수 초)] ── [커밋/롤백] ── [DB 커넥션 반납]
▲
└─ 이 구간 내내 커넥션을 붙들고 있음
Spring의 @Transactional은 메서드 시작 시 DB 커넥션을 커넥션 풀에서 가져오고, 메서드가 끝날 때까지 그 커넥션을 계속 붙들고 있는다. 커밋이나 롤백은 메서드가 완전히 종료될 때 일어난다.
Gemini Live API WebSocket 연결을 끊는 작업이 얼마나 걸릴지는 모른다. 네트워크 상태에 따라 수백ms에서 수 초까지 늘어날 수 있다. 이 시간 동안 DB 커넥션은 아무 것도 안 하면서 자리만 차지하고 있다.
커넥션 풀 기본값은 보통 10개 내외다. 동시 통화가 10건 이상 발생해 동시에 종료된다면?
커넥션 풀 고갈 → 새 DB 요청 대기 → 타임아웃 → 서비스 장애
단순히 비효율의 문제가 아니라, 트래픽이 몰리면 서비스 전체가 멈출 수 있는 문제였다.
간단히 커넥션 풀을 10개라고 가정하면, 통화 종료가 몰리는 순간은 이런 모양이 된다.
커넥션 풀(10)
[1][2][3][4][5][6][7][8][9][10]
W W W W W W W W W W (W = 네트워크 대기 중, 커넥션 점유)
새 요청: ───────────────▶ (획득 실패) 대기/타임아웃
1차 시도: 메서드를 분리하면 되겠지?
“그러면 DB 작업 메서드와 네트워크 호출 메서드를 분리하면 되지 않나?”라고 생각했다.
// 트랜잭션은 여기서만
@Transactional
private void updateDbState(Long aiCallId, String wsSessionId, boolean isNormal) {
// DB 작업만...
}
// 네트워크는 트랜잭션 밖에서
public void disconnectSession(Long aiCallId, String wsSessionId, boolean isNormal) {
updateDbState(aiCallId, wsSessionId, isNormal); // @Transactional 메서드 호출
geminiLiveApiClient.disconnect(...); // 트랜잭션 밖
}
실행해봤다. DB가 업데이트되지 않았다. @Transactional이 아무 효과가 없었다.
Spring @Transactional의 작동 원리: 프록시
핵심은 프록시를 거쳐야 트랜잭션이 열린다는 점이다. 같은 클래스 내부에서 this로 호출하면 프록시를 우회한다.
외부 호출
Controller ──▶ [프록시] ──▶ (실제 빈).updateDbState() ✅ 트랜잭션 적용
내부 호출(self-invocation)
(실제 빈).disconnectSession()
└── this.updateDbState() ❌ 프록시 우회
Spring @Transactional은 AOP(Aspect Oriented Programming) 기반이다. Spring이 빈(Bean)을 등록할 때 @Transactional이 붙은 메서드가 있으면, 그 빈을 프록시 객체로 감싼다. 외부에서 해당 메서드를 호출하면 프록시가 가로채서 트랜잭션을 열고, 실제 메서드를 실행하고, 커밋/롤백을 처리한다.
문제는 같은 클래스 내부에서 this.메서드()를 호출하면 프록시를 거치지 않는다는 점이다. this는 프록시가 아닌 실제 객체를 가리키기 때문에, @Transactional이 붙어 있어도 트랜잭션이 시작되지 않는다. 이것이 Spring의 유명한 self-invocation 문제다.
외부 호출: Controller → [프록시] → disconnectSession() ✅ 트랜잭션 적용
내부 호출: disconnectSession() → this.updateDbState() ❌ 프록시 우회, 트랜잭션 없음
단순히 private를 public으로 바꿔도 소용없다. 같은 클래스 안에서 this로 호출하는 이상, 항상 프록시를 우회한다.
2차 시도: TransactionTemplate
Self-invocation 문제를 피하는 방법은 여러 가지가 있다:
| 방법 | 설명 | 단점 |
|---|---|---|
| 메서드를 다른 클래스로 분리 | 새 빈으로 분리하면 프록시 적용됨 | 억지로 클래스를 쪼개야 함, 응집도 저하 |
ApplicationContext로 자기 자신 주입 | self.updateDbState() 형태로 프록시 통해 호출 | 코드가 지저분, 순환 의존성 느낌 |
TransactionTemplate 직접 사용 | 프로그래밍 방식으로 트랜잭션 경계 명시 | 약간 verbose하지만 명확함 |
이 상황에서는 TransactionTemplate이 가장 깔끔했다. DB 작업의 범위를 코드로 직접 명시할 수 있고, 트랜잭션이 끝나는 시점을 정확하게 제어할 수 있다.
// PlatformTransactionManager 주입
private final PlatformTransactionManager transactionManager;
public void disconnectSession(Long aiCallId, String wsSessionId, boolean isNormal) {
try {
// ① 트랜잭션 범위를 람다로 명시적으로 감싼다
new TransactionTemplate(transactionManager).executeWithoutResult(txStatus ->
updateDbStateOnDisconnect(aiCallId, wsSessionId, isNormal)
);
// ② 트랜잭션 커밋 완료 후, 커넥션 반납 후 네트워크 호출
geminiLiveApiClient.disconnect(String.valueOf(aiCallId));
} catch (Exception e) {
log.error("[릴레이] 세션 정리 중 오류: aiCallId={}", aiCallId, e);
} finally {
// ③ DB/네트워크 오류와 관계없이 인메모리 리소스는 반드시 정리
sessionManager.cancelRelay(String.valueOf(aiCallId));
}
}
// @Transactional 없이 그냥 private 메서드
private void updateDbStateOnDisconnect(Long aiCallId, String wsSessionId, boolean isNormal) {
// DB 작업만
aiCallSessionRepository.findByWebsocketSessionId(wsSessionId)
.ifPresent(session -> { session.leaveSession(); aiCallSessionRepository.save(session); });
aiCallRepository.findById(aiCallId).ifPresent(aiCall -> {
if (isNormal) aiCall.endCall();
else aiCall.failCall();
aiCallRepository.save(aiCall);
});
}
이제 타임라인이 아래처럼 바뀐다. 커밋과 커넥션 반납이 먼저 끝나고, 그 다음에 네트워크 호출이 시작된다.
시간 →
[트랜잭션 시작] ── [DB 작업] ── [커밋 & 커넥션 반납] ── [외부 네트워크 호출]
TransactionTemplate.executeWithoutResult() 람다가 종료되는 순간 커밋이 일어나고, DB 커넥션이 풀로 반환된다. 그 이후에야 Gemini 네트워크 연결 종료가 시작된다. 아무리 오래 걸려도 커넥션 풀에는 영향을 주지 않는다.
추가로 발견한 것: try-finally의 중요성
수정 과정에서 또 다른 패턴을 적용했다. finally 블록에서 인메모리 리소스를 정리하는 것이다.
} finally {
if (isOwner) {
sessionManager.cancelRelay(callId); // Reactor 구독 취소
sessionManager.unregisterSession(callId, wsSessionId); // 세션 맵에서 제거
}
}
DB 업데이트나 Gemini 연결 종료에서 예외가 발생하더라도, 인메모리의 릴레이 구독과 세션 참조는 반드시 해제되어야 한다. 메모리 누수와 “고아” Reactor 구독을 막기 위해서다.
정리
| 수정 전 | 수정 후 | |
|---|---|---|
| DB 커넥션 점유 시간 | DB 작업 + 네트워크 대기 | DB 작업만 |
| 트랜잭션 경계 | @Transactional 메서드 전체 | TransactionTemplate 람다 내부만 |
| 동시 통화 종료 내성 | 커넥션 풀 고갈 위험 | 안전 |
| 코드 명확성 | 트랜잭션 범위 암묵적 | 트랜잭션 범위 코드로 명시 |
핵심 원칙 두 가지:
@Transactional메서드 안에는 DB 작업만 넣어라. 외부 HTTP/WebSocket 호출, 파일 I/O, 긴 계산은 모두 트랜잭션 밖으로 꺼내야 한다.같은 클래스 안에서 트랜잭션 범위를 세밀하게 제어해야 한다면
TransactionTemplate을 써라. Self-invocation 문제를 피하면서, 트랜잭션 경계를 코드로 명시적으로 표현할 수 있다.