DB Link를 없애자 트랜잭션 책임이 이동했습니다
기존 다운로드 시스템은 한 DB에서 다른 DB의 일부 데이터를 DB Link로 조회했습니다. 코드에서는 하나의 접근 경로처럼 보였지만, 실제 실행은 로컬 DB와 원격 DB, 둘 사이의 네트워크에 모두 기대고 있었습니다.
DB Link를 걷어내고 두 DB에 직접 연결하자 접근 대상은 분명해졌습니다. 대신 어떤 요청이 어느 데이터소스를 쓰는지, 두 DB가 함께 참여할 때 트랜잭션을 어디까지 묶을지를 애플리케이션에서 책임져야 했습니다. 요청마다 커넥션 풀과 SessionFactory를 만들던 문제도 이때 함께 정리했습니다.
조건에 따라 서로 다른 매퍼를 사용하는 서비스의 트랜잭션을 어떻게 관리해야 할까요?
DataSource와 Mapper를 먼저 분리했습니다
각 DB 연결은 별도의 DataSource, SessionFactory와 Mapper 영역을 갖도록 구성했습니다. 아래 이름은 공개 설명을 위한 가명입니다.
| 주 데이터 영역 | 보조 데이터 영역 |
|---|---|
PrimaryDataSourceConfig | SecondaryDataSourceConfig |
primaryDataSource | secondaryDataSource |
primarySessionFactory | secondarySessionFactory |
primaryMapper | secondaryMapper |
아래 코드는 실제 동작이 드러나도록 클래스·테이블·설정 이름을 바꿔 줄인 예시입니다.
@MapperScan(
basePackages = "example.primary",
sqlSessionTemplateRef = "primarySessionTemplate"
)
class PrimaryDataSourceConfig {
// DataSource와 SessionFactory를 애플리케이션 단위로 구성한다.
}SessionFactory마다 접속 대상을 고정하니 설정 실수와 Mapper 오접근을 찾기 쉬워졌습니다.
같은 서비스에서도 참여하는 리소스가 달라졌습니다
DB 선택은 URL 하나로 고정되지 않았습니다. 주문 체계, 업무 유형과 법인 조건에 따라 같은 서비스가 서로 다른 매퍼를 선택했습니다. 대부분의 처리는 한 번에 하나의 데이터소스만 사용했고, 모든 요청이 두 DB를 동시에 갱신한 것은 아닙니다.
boolean useLegacySource = routingPolicy.useLegacySource(orderKey);
List<OrderItem> items = useLegacySource
? primaryMapper.findItems(orderKey)
: secondaryMapper.findItems(orderKey);
if (useLegacySource) {
primaryMapper.markDownloaded(orderKey);
} else {
secondaryMapper.markDownloaded(orderKey);
}어느 DB가 트랜잭션에 참여할지는 메서드가 실행된 뒤 업무 조건에 따라 정해졌습니다. 데이터소스별 로컬 트랜잭션 매니저를 애너테이션에 고정하면 이 분기를 공통 서비스 선언으로 표현하기 어려웠습니다.
검토한 대안과 선택 기준
| 대안 | 장점 | 한계 |
|---|---|---|
| DataSource별 로컬 트랜잭션 | 단일 DB 처리에 단순함 | 실행 중 매퍼 분기와 선언한 매니저가 어긋날 수 있음 |
| 서비스를 DB별로 완전 분리 | 대상이 명확함 | 공통 업무 규칙과 법인 분기가 중복될 수 있음 |
| 여러 로컬 트랜잭션 조합 | 구성은 단순해 보임 | 표준 XA의 원자적 완료·복구 모델과 다름 |
| Atomikos 기반 JTA | 실제 참여한 XA 리소스를 하나의 Spring 트랜잭션으로 관리 | XA 설정과 운영 복잡도가 증가 |
데이터소스가 여러 개라고 무조건 JTA가 필요한 것은 아닙니다. 이 시스템에서는 같은 서비스가 실행 중인 업무 조건에 따라 다른 매퍼를 선택하면서도 공통 @Transactional 선언을 유지해야 했습니다. 단일 DB만 쓰는 처리에도 같은 관리 방식이 적용된다는 부담은 있지만, 두 XA 리소스가 함께 쓰이는 경우까지 하나의 선언으로 다룰 수 있다는 쪽을 택했습니다.
Atomikos와 XA DataSource의 역할
두 데이터소스는 Atomikos가 관리하는 XA 리소스로 구성하고 서로 다른 이름을 부여했습니다. 아래 이름은 공개 설명을 위한 가명입니다.
AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
dataSource.setUniqueResourceName("primaryResource");
dataSource.setXaDataSourceClassName(driverClassName);
dataSource.setXaProperties(connectionProperties);- XA 데이터소스는 DB가 XA 리소스로 참여할 수 있는 연결을 제공합니다.
- Atomikos는 XA 연결을 풀로 관리하고 리소스를 트랜잭션 매니저에 등록합니다.
- Spring JTA는
@Transactional의 시작·완료와 참여 리소스 등록을 연결합니다. - MyBatis
SessionTemplate은 각 매퍼가 지정된 데이터소스의 세션을 사용하게 합니다.
실제 실행에서 하나의 매퍼만 사용하면 해당 리소스만 참여합니다. 두 매퍼를 모두 사용하는 처리에서만 두 XA 리소스가 같은 JTA 트랜잭션에 함께 참여할 수 있습니다.
운영 설정과 점검 기준
내부 설정 키, 리소스 이름, 로그 경로와 세부 타임아웃·풀 설정값은 운영 정보이므로 공개하지 않습니다.
운영에서는 다음 항목을 확인해야 합니다.
- 리소스 이름이 환경별로 충돌하지 않습니까?
- 사용하지 않는 데이터소스가 실행에 참여하지 않습니까?
- 연결 검증 방식이 DB 종류와 맞습니까?
- 여러 인스턴스가 같은 복구 로그를 동시에 소유하지 않도록 설정했습니까?
- 법인별 분기가 비활성 리소스를 호출하지 않습니까?
JTA가 되돌리지 못하는 작업
서비스에는 DB 처리뿐 아니라 ZIP 생성, 파일 삭제와 외부 전송도 있었습니다. 파일 시스템과 일반 파일 전송은 XA 리소스가 아니므로 JTA가 자동으로 되돌리지 못합니다.
JTA가 롤백할 수 있는 범위
→ 참여한 XA DB 변경
별도 보상 설계가 필요한 범위
→ 생성한 파일
→ 삭제한 파일
→ 외부 시스템으로 전송한 파일따라서 파일 작업은 실행 순서와 임시 파일 관리, 재시도·보상·멱등성을 따로 설계해야 했습니다.
검증과 남은 트랜잭션 한계
DNA2 전체 기능 테스트는 20개 시나리오·557개 케이스로 진행했고, 처음 실패한 9건을 수정한 뒤 모두 통과했습니다. 이 557개가 모두 JTA만을 위한 테스트였던 것은 아닙니다. 전체 회귀 테스트 안에서 다중 데이터소스 설정과 조건별 매퍼 선택, 반복 요청 뒤 풀 추가 생성 여부, 기존 조회 결과가 유지되는지를 함께 확인했습니다.
두 DB를 함께 갱신하다 한쪽을 일부러 실패시켜 전체 롤백을 확인한 전용 테스트 기록은 남아 있지 않습니다. 확인한 것은 조건별 매퍼 선택, 반복 요청 뒤 풀 재생성 방지와 기존 조회 결과 유지까지입니다.
정리
DB Link를 없애면 숨어 있던 DB 선택과 트랜잭션 책임이 애플리케이션으로 이동합니다. 이 사례에서는 업무 조건에 따라 참여 리소스가 달라진다는 점을 기준으로 Atomikos를 선택하고, 데이터소스·매퍼·트랜잭션의 관계를 함께 정리했습니다.