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