본문 바로가기

카테고리 없음

분산 트랜잭션과 외부 호출

320x100

 

개발하다 보면 저장 하나에도 외부 시스템이 여러 개 엮인다. 예를들어 이력서를 저장할때, 인적성검사 솔루션에 응시자를 등록하고, 지원 완료 메일이나 문자를 보내고, 전형 결과를 다른 서비스에 넘기는 식이다. DB 트랜잭션 하나로 묶어두면 안전할 것 같지만 실제로는 그렇지 않다. 이 글에서는 채용 도메인을 예시로 트랜잭션 롤백의 한계와 이를 보완하는 패턴들을 정리한다.

 

트랜잭션 안의 외부 호출

지원자가 지원서를 제출하는 로직을 단순하게 작성하면 이런 모습이 된다.

@Transactional
public void apply(ApplyRequest request) {
    applicationRepository.save(application);      // DB 쓰기
    aptitudeTestClient.register(application);     // 외부 인적성검사 솔루션 호출
    mailClient.sendApplyComplete(application);    // 지원 완료 메일 발송
}

로컬 DB 트랜잭션은 ACID를 보장하지만 롤백 범위는 그 DB 안으로 한정된다. 그래서 이 코드에는 몇 가지 문제가 있다.

  • 인적성검사 등록은 성공했는데 메일 발송에서 예외가 나면 지원서는 롤백되지만 인적성검사 쪽에는 응시자가 남는다.
  • 외부 솔루션 응답이 느리면 그동안 DB 커넥션을 계속 잡고 있다. 공고 마감 직전처럼 지원이 몰리는 시점에 커넥션 풀이 고갈될 수 있다.
  • 외부 호출은 성공했는데 응답 도중 타임아웃이 나면 실제로는 등록된 응시자를 실패로 처리하게 된다.

핵심은 트랜잭션 안에서 HTTP 호출, 메시지 발행, 메일 발송 같은 외부 I/O를 하지 않는 것이다. 트랜잭션은 짧게, DB 작업만 담는다.

 

사가 패턴

사가(Saga)는 하나의 긴 분산 트랜잭션을 여러 개의 로컬 트랜잭션으로 쪼개고, 중간에 실패하면 앞서 성공한 단계를 보상 트랜잭션(Compensating Transaction)으로 되돌리는 방식이다.

지원 접수 흐름에 적용하면 다음과 같다.

지원서 저장 → 인적성검사 응시자 등록 → 지원 완료 메일 발송
    T1              T2                      T3

T2 실패 시: C1(지원서 상태를 접수실패로 변경)

여기서 보상은 롤백이 아니라 의미적 취소다. 지원서를 DB에서 지워 없던 일로 만드는 게 아니라, 상태를 바꾸는 새 트랜잭션을 실행한다. 인적성검사 등록 이후 단계가 실패했다면 응시자 등록을 취소하는 API를 호출하는 것도 보상 트랜잭션이다.

사가를 설계할 때 신경 쓸 부분은 다음과 같다.

  • 보상 트랜잭션은 재시도될 수 있으므로 여러 번 실행해도 결과가 같아야 한다.
  • 메일이나 문자 발송처럼 되돌릴 수 없는 단계는 가장 마지막에 둔다. 합격 문자를 보낸 뒤에 앞 단계가 실패하면 되돌릴 방법이 없다.
  • 중간 상태가 외부에 노출된다. 지원서는 저장됐는데 인적성검사 등록은 아직인 상태가 존재하므로 PENDING 같은 상태값으로 구분해야 한다.

 

아웃박스 패턴

사가를 이벤트 기반으로 구현하면 DB 변경과 이벤트 발행이 함께 일어나야 한다. 이게 따로 놀면 이중 쓰기(Dual Write) 문제가 생긴다.

applicationRepository.save(application);   // 성공
kafkaTemplate.send("application-submitted", event);   // 실패하면 이벤트 유실

반대로 이벤트는 나갔는데 DB 커밋이 실패하면 존재하지 않는 지원서에 대한 이벤트가 떠돌게 된다.

아웃박스(Transactional Outbox) 패턴은 이벤트를 브로커로 바로 보내지 않고, 같은 트랜잭션 안에서 outbox 테이블에 저장한다.

BEGIN;
INSERT INTO application (...) VALUES (...);
INSERT INTO outbox (id, event_type, payload, created_at)
VALUES (..., 'ApplicationSubmitted', ..., now());
COMMIT;

로컬 트랜잭션 하나이기 때문에 지원서 저장과 이벤트 기록이 함께 성공하거나 함께 실패한다. 이후 별도 프로세스가 outbox를 주기적으로 읽어서 Kafka로 발행하고, 발행이 끝난 건은 완료 처리한다. 인적성검사 등록이나 메일 발송은 이 이벤트를 받는 쪽에서 처리하면 된다.

다만 아웃박스는 at-least-once 전달이다. 발행 후 완료 처리 전에 서버가 죽으면 같은 이벤트가 다시 나간다. 그래서 받는 쪽이 멱등해야 한다.

 

멱등성

재시도, 중복 메시지, 보상 재실행이 모두 여러 번 실행해도 결과가 같다는 전제 위에 있다. 채용 솔루션에서 멱등성이 깨지면 같은 지원자에게 합격 문자가 두 번 가거나, 인적성검사 응시자가 중복 등록되는 일이 생긴다.

대표적인 방법은 세 가지다.

Idempotency Key 요청마다 고유 키를 함께 보내고, 서버는 처리한 키를 저장해 두었다가 같은 키로 다시 들어오면 저장된 결과를 돌려준다. 외부 인적성검사 솔루션에 응시자를 등록할 때 지원서 ID를 키로 쓰면 재시도해도 중복 등록되지 않는다.

Inbox 테이블 소비자가 처리한 메시지 ID를 테이블에 저장하고, 비즈니스 처리와 같은 트랜잭션에서 INSERT한다. 이미 있는 ID면 건너뛴다. 결과 통보 메일 발송 이벤트를 중복으로 받아도 한 번만 발송된다.

조건부 갱신 상태 전이를 조건으로 거는 방식이다.

UPDATE application
SET status = 'PASSED'
WHERE id = ? AND status = 'IN_REVIEW';

이미 합격 처리된 지원서라면 영향받는 row가 0건이므로 후속 처리를 하지 않으면 된다.

외부 호출에는 타임아웃을 반드시 설정하고, 재시도는 멱등한 호출에만 건다. 타임아웃이 났다고 바로 실패로 판단하지 말고, 상태 조회 API로 실제 결과를 확인하거나 같은 멱등키로 재시도하는 편이 안전하다.

 

정리

문제 해결
트랜잭션 안의 외부 호출 트랜잭션 밖으로 분리하고 이벤트로 처리
여러 서비스에 걸친 원자성 사가 (로컬 트랜잭션과 보상)
DB 변경과 이벤트 발행의 원자성 아웃박스
중복 메시지와 재시도 멱등성 (Idempotency Key, Inbox)

채용 솔루션처럼 외부 솔루션 연동과 알림 발송이 많은 도메인에서는 트랜잭션을 짧게 유지하고, 외부 작업은 아웃박스로 넘긴 뒤, 받는 쪽을 멱등하게 만드는 흐름이 기본이 된다.

728x90