여러 서비스의 상태를 함께 바꾸기 어렵다고 느끼면 보통 메시지나 분산 트랜잭션 도구부터 찾는다. 그러나 먼저 확인할 것은 정말 그 변경들이 서로 다른 서비스에 있어야 하는지다. 업무상 항상 함께 바뀌는 데이터를 기술적인 관심사만으로 나눴다면, 서비스 사이의 통신 비용을 늘리면서 원래 DB가 제공하던 트랜잭션 경계를 잃었을 수 있다.
가상 문서 처리에는 허용량 예약, 문서 변환, 결과 저장, 예약 확정이 있다. 네 함수가 있다고 네 서비스가 필요한 것은 아니다. 변환은 CPU 사용량이나 실행 시간이 길어 별도 프로세스가 적절할 수 있다. 반면 예약과 확정은 같은 사용량 원장을 다루므로 같은 서비스가 책임지는 편이 자연스러울 수 있다. 기능 개수와 배포 단위의 개수는 동일한 결정이 아니다.
로컬 트랜잭션이 해결하는 범위
한 데이터베이스에서 예약 행과 문서 요청 행을 같은 트랜잭션으로 쓰면, 커밋되기 전에 오류가 발생했을 때 두 변경을 함께 취소할 수 있다. 애플리케이션이 두 테이블을 소유하고 같은 트랜잭션 관리자를 사용하는 것이 전제다. 별도 서비스의 API를 호출했다고 그 상대 DB까지 현재 트랜잭션에 들어오는 것은 아니다.
begin transaction
insert document_request(status = 'PENDING')
insert reservation(status = 'RESERVED')
commit
이 코드는 개념 설명이며 특정 ORM의 API가 아니다. 여기서 두 번째 쓰기에 오류가 나면 첫 번째 쓰기도 커밋되지 않게 할 수 있다. 반면 첫 DB를 커밋한 뒤 다른 서비스에 HTTP로 예약을 요청하는 구조에서는 두 번째 호출의 실패가 첫 커밋을 자동으로 취소하지 않는다. 호출자를 @Transactional로 감싸는 것만으로 원격 서비스가 참여하지 않는다.
이 차이를 확인하려면 같은 업무를 두 가지로 만들어 본다. 첫 번째는 같은 DB 트랜잭션에 두 행을 쓰고, 두 번째는 요청 행을 먼저 커밋한 뒤 별도 예약 함수를 호출한다. 예약 단계에서 동일한 오류를 발생시킨다. 비교 대상은 에러 메시지가 아니라 남아 있는 데이터다. 로컬 트랜잭션판에서는 두 변경이 모두 없는 상태를 예상하고, 분리판에서는 요청 행만 남을 수 있다고 예상한다.
합칠 수 있다는 것과 합쳐야 한다는 것은 다르다
같은 DB로 옮기면 문제가 단순해진다고 해서 모든 서비스를 하나로 합치는 것이 답은 아니다. 서로 다른 조직이 데이터의 의미와 변경을 책임지거나, 수명과 확장 방식이 다르거나, 독립된 외부 시스템과 계약해야 하는 이유가 있을 수 있다. 경계를 판단할 때는 배포 편의보다 데이터 변경 권한과 업무 규칙을 먼저 본다. Database per Service 패턴도 서비스별 데이터 소유권과 물리적인 DB 서버 분리를 구별한다.
| 질문 | 같은 경계를 검토할 근거 | 분리를 유지할 근거 |
|---|---|---|
| 두 변경이 항상 함께 일어나는가 | 거의 모든 요청에서 강하게 연결됨 | 서로 독립적인 사용 사례가 많음 |
| 규칙을 누가 소유하는가 | 같은 책임자가 한 규칙으로 관리 | 별도 조직·외부 서비스가 규칙 소유 |
| 실행 특성이 같은가 | 비슷한 수명과 자원 사용 | 긴 계산과 짧은 원장 쓰기처럼 다름 |
| 부분 성공을 허용할 수 있는가 | 허용하기 어려움 | 대기·재시도·보상 정책이 명확함 |
이 표는 자동 판정기가 아니다. 문서 변환을 별도 Worker에서 실행하더라도 예약과 확정의 데이터 소유권은 하나로 유지할 수 있다. 반대로 같은 애플리케이션 안에 코드를 두더라도 실제로 서로 다른 외부 DB와 API를 사용하면 로컬 트랜잭션 하나로 모든 효과를 묶지 못한다. 프로세스 배치와 트랜잭션 참여 범위를 따로 그려야 한다.
또 다른 함정은 하나의 트랜잭션을 오래 열어 놓으면 전체 업무가 안전해질 것이라는 기대다. 수 분 이상 걸리는 변환과 사용자 승인을 기다리면서 DB 자원을 계속 점유하는 설계는 별도의 비용을 만든다. 작업의 대기 시간을 DB 트랜잭션 수명과 반드시 같게 둘 이유가 있는지 물어야 한다. 오래 걸리는 작업은 요청 상태를 먼저 저장하고 나중에 결과를 반영하는 방법이 더 적합할 수 있다.
실험에서 구분해서 적을 것
실험 기록에는 같은 서비스라는 표현만 남기지 않는다. 어떤 DB 연결과 트랜잭션을 사용했는지, 언제 커밋했는지, 외부 호출은 그 전인지 후인지 적는다. 실패를 발생시킨 위치도 명시한다. 커밋 이전 예외와 커밋 직후 프로세스 종료는 다른 결과를 만들기 때문이다. 이 글의 실험은 아직 수행하지 않았으며, 결과는 직접 만든 코드와 데이터 조회로 확인해야 한다.
- 요청과 예약을 같은 DB 트랜잭션으로 저장한다.
- 예약 쓰기에서 오류를 발생시키고 두 테이블을 확인한다.
- 요청을 먼저 커밋하는 형태로 바꾼 뒤 같은 오류를 발생시킨다.
- 두 번째 구조에서 남은 요청을 누가 복구할지 설명한다.
첫 구조에서 모든 변경이 사라졌다고 외부 파일이나 메시지까지 함께 취소됐다고 주장하면 안 된다. 실험이 다룬 것은 해당 DB 트랜잭션에 참여한 쓰기뿐이다. 다음 단계에서 메시지 발행이나 Temporal 시작 요청을 붙이면 다시 두 시스템에 쓰는 문제가 생길 수 있다.
경계 검토의 결과는 두 가지 모두 가능하다. 예약과 요청의 생성을 같은 곳으로 모아 문제를 줄일 수도 있고, 독립 소유권 때문에 분리를 유지할 수도 있다. 후자라면 그 선택에 따른 부분 성공을 인정하고 복구 전략을 설계한다. 이후의 2PC와 Saga 비교는 이 지점에서 시작한다.
이해 확인
하나의 Java 메서드에 @Transactional을 붙이고 원격 API를 호출하면 상대 DB도 자동으로 rollback될까? 문서 변환 Worker를 별도 배포한다는 결정은 예약 원장을 반드시 별도 DB로 옮겨야 한다는 뜻일까? 각각 코드의 위치와 데이터 변경의 원자성 범위를 나누어 답해 보자.
참고 자료
- Database per Service: 서비스별 데이터 소유권과 트랜잭션 경계.
- Saga 패턴: 여러 서비스의 로컬 트랜잭션을 연결해야 하는 상황.
- Transactional Outbox: DB 쓰기와 다른 시스템 전달이 함께 필요할 때 남는 문제.