[친구하자] @Transactional 안에서 외부 네트워크를 호출하면 안 되는 이유

@Transactional 범위 안에서 외부 네트워크 호출(Gemini WebSocket)을 처리했다가, DB 커넥션 점유 시간이 길어져 커넥션 풀 고갈 위험이 생긴 문제와 해결(TransactionTemplate)을 정리합니다.

@Transactional 범위 안에 외부 네트워크 호출을 끼워 넣으면 DB 커넥션 풀이 고갈될 수 있다. 그리고 “메서드만 분리하면 되겠지”라는 생각은 Spring의 프록시 구조(self-invocation)에 막힌다.


들어가며

AI 통화 기능을 개발하고 있었다. 사용자가 앱에서 AI와 실시간으로 음성 통화를 나누는 기능이다. 기술 스택은 Spring WebSocket(raw) + Google Gemini Live API. 사용자의 목소리는 PCM16 바이너리로 WebSocket을 통해 서버에 전달되고, 서버는 이를 Gemini로 중계한다. Gemini가 답변 음성을 내려주면 다시 사용자에게 스트리밍한다.

통화가 끊길 때 정리해야 할 것이 두 가지였다.

  1. DB 상태 업데이트AiCall을 COMPLETED/FAILED로 전환, AiCallSession을 LEFT로 전환
  2. 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()   ❌ 프록시 우회, 트랜잭션 없음

단순히 privatepublic으로 바꿔도 소용없다. 같은 클래스 안에서 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 람다 내부만
동시 통화 종료 내성커넥션 풀 고갈 위험안전
코드 명확성트랜잭션 범위 암묵적트랜잭션 범위 코드로 명시

핵심 원칙 두 가지:

  1. @Transactional 메서드 안에는 DB 작업만 넣어라. 외부 HTTP/WebSocket 호출, 파일 I/O, 긴 계산은 모두 트랜잭션 밖으로 꺼내야 한다.

  2. 같은 클래스 안에서 트랜잭션 범위를 세밀하게 제어해야 한다면 TransactionTemplate을 써라. Self-invocation 문제를 피하면서, 트랜잭션 경계를 코드로 명시적으로 표현할 수 있다.


참고