PROJECT/AIGOYA LABS

[AIGOYA LABS] 트랜잭션 내에서 외부 API 호출을 하겠다고요?!!!

깃짱 2025. 4. 21. 10:00
반응형

🌏 인트로

서비스를 개발할 때 종종 마주하게 되는 문제 중 하나는 외부 API 호출과 데이터 저장을 어떻게 함께 처리할 것인가이다. 특히 LLM이나 외부 시스템과의 연동은 언제든지 실패하거나, 지연될 수 있는 잠재적 위험을 내포한다. 내 애플리케이션을 아무리 잘 만들더라도, 외부 서버가 다운 되어있거나, 데이터의 형식이 변경된다던가 무튼 아무리 내가 잘 개발하더라도 언제든 호출이 제대로 되지 않을 수 있다는 말이다.

이번 글에서는 Kotlin + Spring Boot 환경에서, LLM 응답 처리와 DB 저장을 분리하여 트랜잭션을 효율적으로 관리한 경험을 소개하려고 한다.

🌏 문제 상황: 모든 처리를 트랜잭션으로 묶은 경우

@Transactional
fun processAllAtOnce(input: Input) {
    // 1. LLM 호출을 통한 데이터 생성 (외부 API)
    val result = llmClient.create(input)  // ⚠️ 지연/실패 가능

    // 2. 분류 결과를 한 번 더 가공 (또 LLM 호출)
    val finalResult = interpretResult(result)

    // 3. DB 저장 (트랜잭션 필수!)
    resultRepository.save(finalResult)

    // 4. 후처리 (알림 기능)
    sendNotification(finalResult)
}

처음에는 하나의 요청에서 다음과 같은 작업을 순차적으로 처리하고 있었다.

  1. 외부 API 호출 (LLM)
  2. 결과 가공 및 분류
  3. DB 저장
  4. 후처리 (예: 알림, 포스트 생성 등)

⚠️ 이 구조의 문제점

  • 외부 API가 수 초 이상 지연될 경우, DB 커넥션이 장시간 점유
  • 응답 실패 시 트랜잭션이 롤백되며 이전 처리 결과도 모두 무효화
  • 동시에 여러 요청이 들어오면 커넥션 풀 고갈 위험이 있음 (서비스가 아주 잘 될 경우에 한함ㅎㅎ..)
  • 데이터 정합성 보장을 위한 트랜잭션 설계가 오히려 병목이 됨
  • 무엇보다 외부 API는 트랜잭션으로 감쌀 필요가 없는데 감싸버렸으니 리소스 낭비임!!!!!

🌏 코드를 뜯어고치자!!!

✅ 개선 방법: 외부 API 호출은 트랜잭션 밖으로 빼낸다

이를 해결하기 위해 트랜잭션 경계를 외부 API 호출 이전과 이후로 명확히 나누는 방식을 적용했다.

// 트랜잭션 없음 
fun processWithSeparatedTransaction(input: Input) {
    // 1. LLM 호출을 통한 데이터 생성 (트랜잭션 외부)
    val result = llmClient.create(input)

    // 2. 분류 결과 추가 가공 (또 다른 LLM 호출 or 해석 로직)
    val finalResult = interpretResult(result)

    // 3. DB 저장 (트랜잭션 필요함!!! 외부 서비스 호출)
    resultSaveService.saveWithTransaction(finalResult)

    // 4. 후처리 (알림 전송 등)
    sendNotification(finalResult)
}
@Service
class ResultSaveService {

    @Transactional // 다른 곳에서도 사용할 것이라면 propagation = REQUIRES_NEW 옵션을 붙이는 것도 좋다 
    fun saveWithTransaction(finalResult: FinalResult) {
        resultRepository.save(finalResult)
    }
}

✅ 이 구조의 장점

  • 외부 API 호출은 트랜잭션 외부에서 실행 → 실패해도 DB 영향 없음
  • 저장 로직만 트랜잭션으로 감싸 커넥션 점유 시간 최소화
  • optional) REQUIRES_NEW를 통해 기존 흐름과 별도 커밋 → 장애 전파 방지

구현 시 주의사항은, 이 saveWithTransaction다른 빈(Bean)에서 호출되어야 한다는 점이다.

✅ 주의사항: 왜 별도 빈에서 호출해야 하는가?

Spring의 트랜잭션 처리(@Transactional)는 실제로 프록시 객체가 대상 메서드 앞뒤에 트랜잭션 시작/커밋 로직을 삽입하여 동작한다.

하지만 클래스 내부에서 this.saveWithTransaction()처럼 자기 자신의 메서드를 호출하면, 프록시를 거치지 않고 실제 인스턴스 메서드를 바로 호출하게 되어 AOP가 개입하지 못한다.

이 때문에 같은 빈 내부라면 REQUIRES_NEW를 붙이더라도 트랜잭션 속성이 무시되어 트랜잭션이 적용되지 않는다. 

즉, @Transactional(propagation = REQUIRES_NEW)를 선언한 메서드가 트랜잭션으로 동작하려면 반드시 다른 빈에서 호출되어야 한다.

 

✅ Simple is Best: 다른 방법은 없을까요? 

매번 이렇게 다른 빈을 만들고 호출하기가 조금 코드가 애매해질 수 있습니다. 이럴 때는 명시적으로 트랜잭션 매니저를 사용해 호출할 수 있습니다. 

 

@Service
class ResultSaveWithTxManagerService(
    private val transactionManager: PlatformTransactionManager,
    private val resultRepository: ResultRepository
) {

    fun saveWithExplicitTransaction(finalResult: FinalResult) {
        val txDefinition = DefaultTransactionDefinition().apply {
            propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
        }

        // 1. 트랜잭션 시작
        val status = transactionManager.getTransaction(txDefinition)

        try {
            // 2. DB 작업
            resultRepository.save(finalResult)

            // 3. 커밋
            transactionManager.commit(status)

        } catch (ex: Exception) {
            // 4. 롤백
            transactionManager.rollback(status)
            throw ex
        }
    }
}

 

 

이렇게 하면 해당 빈 내부에서 saveWithExplicitTransaction 메서드를 직접 호출해 self-invocation으로 AOP가 적용되지 않더라도, 트랜잭션이 동작하게 됩니다. 

 

✅ 처리 흐름

아래는 실제 처리 순서이다.

[요청 수신]
     ↓
[LLM 호출 (지연 가능)]   ← 트랜잭션 없음
     ↓
[응답 결과 분류]
     ↓
[DB 저장] (다른 빈의 @Transactional 메서드 → 트랜잭션 시작 및 커밋)
     ↓
[후처리]

 

이처럼 DB 저장에만 트랜잭션을 적용하면서 전체 흐름의 안정성과 효율성을 동시에 확보할 수 있었다.

 

 

🌏 오늘의 교훈

  • 외부 네트워크 요청은 트랜잭션 안에 포함시키지 말 것
    → 응답이 느린 상황에서 커넥션을 점유하고 있으면 다른 요청까지 영향을 받게 된다.
  • 트랜잭션은 '일관성 보장이 필요한 최소 단위'로만 적용할 것
    → 지나친 범위 설정은 오히려 장애를 키우고 복구를 어렵게 만든다.
  • @Transactional 메서드는 반드시 다른 빈에서 호출해야 적용된다
    → 이걸 모르면 왜 안되는지 알지 못하고 한참 헤맬 수 있다.
    → AOP 기반 동작 원리를 이해하고 올바른 호출 구조로 구성해야 한다.

 

 

 

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!

 

반응형