🌏 인트로
서비스를 개발할 때 종종 마주하게 되는 문제 중 하나는 외부 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)
}
처음에는 하나의 요청에서 다음과 같은 작업을 순차적으로 처리하고 있었다.
- 외부 API 호출 (LLM)
- 결과 가공 및 분류
- DB 저장
- 후처리 (예: 알림, 포스트 생성 등)
⚠️ 이 구조의 문제점
- 외부 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 기반 동작 원리를 이해하고 올바른 호출 구조로 구성해야 한다.

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