문제점

MSA Database per Service 구조에서 발생하는 주요 문제는 데이터 일관성입니다. 각 서비스가 독립적인 데이터베이스를 사용하므로, 전체 시스템의 데이터 일관성을 유지하기가 어렵습니다.
예를 들어, Order Service와 Customer Service 각각에서 저장 중인 Coupon table의 데이터 정합성이 맞지 않는 경우입니다. 실제 서비스 상황에서는 모든 데이터가 완벽하게 정규화 되지 않아 이러한 일이 자주 벌어집니다.
Order 팀과, Customer 팀에서 각자의 데이터를 관리하게 되는 문제가 발생하고 있는 것입니다.
분산 트랜잭션을 통해 해당 변경 작업의 정합성을 보장하는 아키텍처를 설계 해보려 합니다.
실전 설계
1단계: 문제 이해 및 설계 범위 확정기능 요구사항
사용자는 어드민을 통해 정보 변경을 진행할 수 있다.
다양한 종류의 필드, 다건 데이터를 한번에 변경 요청할 수 있음.(Bulk 변경이 가능하다)
변경 요청한 건의 진행상태(성공, 실패)를 알림을 통해 확인할 수 있다.
2단계: 비기능 요구사항
Database per service 형태로 데이터베이스가 구축된 MSA 구조에서 데이터 일관성 문제를 해결하여야 함
일시적인 불일치는 용인됨. (일시적 불일치를 바로잡기 위해 락 또는 롤백을 고려할 필요 없음)
그러나 결과적인 정합성은 보장되어야 함.
3단계: 개략적 규모 추정
기존 운영중인 시스템 지표 등을 고려하여 추정
서비스 50 TPS 로 추정
기술 결정
잘 알려진 방법 중 Transaction log tailing 과 Polling publisher 중 결정한다.
해당 방법에 대한 자세한 설명은 https://www.youtube.com/watch?v=YPbGW3Fnmbc 를 참고하면 된다.
Transaction log tailing은 데이터베이스 종속적이고, 러닝커브가 큰 방법이다.
게다가 대고객 서비스가 아니므로, 트래픽이 낮으므로 도입하는 것에는 부담이 있다.
대신 Polling publisher 방식을 사용한다.
데이터베이스 테이블을 큐로 사용하고, 폴링하여 테이블에서 꺼낸 값을 반영한다.
CREATE TABLE `update_request`
(
`id` bigint auto_increment primary key comment 'id',
`recipient` varchar(40) NOT NULL COMMENT '요청자 이름 (LDAP)',
`key` varchar(40) NOT NULL COMMENT '변경 키',
`field` varchar(40) NOT NULL COMMENT '변경 필드',
`before_data` varchar(255) NULL COMMENT 'BEFORE 변경내용',
`after_data` varchar(255) NULL COMMENT 'AFTER 변경내용',
`status` varchar(40) NULL COMMENT '진행 상태',
`created_at` datetime(6) default CURRENT_TIMESTAMP(6) NOT NULL COMMENT '생성시간',
`created_by` varchar(50) NOT NULL COMMENT '생성자',
`updated_at` datetime(6) default CURRENT_TIMESTAMP(6) default CURRENT_TIMESTAMP(6) NOT NULL COMMENT '수정시간',
`updated_by` varchar(50) NOT NULL COMMENT '수정자'
) COMMENT '수정 요청';
신청 API 구현
public class UpdateRequestController {
private final UpdateRequestService updateRequestService;
@PostMapping("/request")
public void updateRequest(@RequestBody Dto dto) {
updateRequestService.request(dto) // 수정 요청 테이블에 데이터 생성
}
}
데이터 폴링 배치(스케쥴러) 구현
flowchart TD
B[UpdateRequest 테이블에서 row 불러오기] --> C[업데이트 수행]
C -->|성공| D[상태를 성공으로 마킹]
D --> E[프로세스 종료]
C -->|실패| F[상태를 실패로 마킹, 모니터링 시스템에 알림 발송]
F --> G[프로세스 종료]
G --> H[다음 배치에서 재처리]
스케쥴러를 통해 10분 간격으로 폴링이 일어나도록 한다.
UpdateRequest 테이블에서 row를 불러와서, 업데이트를 수행한다.
성공 시, 상태를 성공으로 마킹하고 종료한다.
실패 시, 상태를 실패로 마킹하고 종료하며, 롤백 프로세스는 하지 않는다.
실패된 태스크는 다음 배치에서 재처리 된다.
폴링 케이스
저장소 하나만 변경하면, 이벤트로 처리되는 경우 (단일 원장)
graph TD
X["데이터 변경 요청"] --> A
A["원장 저장소 (Ledger Repository)"] --> B[이벤트 발행]
B --> C[외부 서비스 1]
B --> D[외부 서비스 2]
B --> E[외부 서비스 N]
C -->|이벤트 수신 및 변경 반영| F[저장소 1]
D -->|이벤트 수신 및 변경 반영| G[저장소 2]
E -->|이벤트 수신 및 변경 반영| H[저장소 N]
한 원장 저장소에만 데이터 변경을 요청(local commit and publish) 으로 해결 가능하다.
해당 저장소는 이벤트를 발행하여 변경사실을 외부에 알리고, 나머지 저장소를 담당하는 서비스는 이벤트를 수신하여 변경사실을 반영한다.
단, 단일진실공급원이 성립하여야 한다.
여러 저장소에서 데이터 수정 권한을 가지게 되면 김빠진데이터(부정확한 정보) 조회의 문제를 유발하게 된다.
단일진실공급원 Primary Repository만 변경이 가능하며, A,B,C 로만 변경이 일어나야하는데, 개별 서비스 A,B,C에서 직접 변경하는 경우 문제가 될 수 있다.
이벤트 기반이 아니어서, 여러 저장소에 요청해야하는 경우 (다중 원장)
graph TD
Y["데이터 변경 요청"] --> X
X["오케스트레이터"] --> A["서비스 1"]
X --> B["서비스 2"]
X --> C["서비스 3"]
A -->|로컬 커밋 및 처리| D["원장 저장소 1 (Ledger 1)"]
B -->|로컬 커밋 및 처리| E["원장 저장소 2 (Ledger 2)"]
C -->|로컬 커밋 및 처리| F["원장 저장소 N (Ledger 3)"]
여러 저장소에 변경 요청한다.
단, API의 멱등성이 보장되어야 한다. A 성공, B 실패하는 경우, 보상트랜잭션이 일어나야 한다. 보상트랜잭션은 롤백 또는 재처리 방식이 있을 수 있다. 1회 재처리 해보고, 그래도 실패 시 큐에 넣어서 성공할 때까지 지속적으로 재처리하도록 한다. 그 과정에서 A 수정 API는 중복 호출이 발생하므로, 멱등성 있게 구축되어야 한다.
간단한 Q&A
롤백을 하지 않는 이유?
보상 트랜잭션이 모든 워크플로를 완벽하게 되돌려야하고, 트랜잭션으로 제약을 걸어야하는 것은 아니다.
계좌와 같은 도메인에서는 즉각 롤백이 필수적이겠지만, 재고 기반 시스템의 경우 새로운 재고를 주문하고, 고객에게는 배송 지연 사실을 알리는 식으로 보상 트랜잭션을 수행할 수 있다. 본 데이터 업데이트 아키텍처에서는 결과적 정합성만 보장되면 되는 요구사항이었으므로, 즉시 롤백할 필요는 없다. 재시도 전략을 통해 업데이트를 재시도하고, 최종적으로도 실패할 경우 모니터링 시스템을 통해서 이를 알리도록 하면 된다.
롤백을 해야한다면?
Success, Fail, Unknown 케이스를 고려하여 롤백 로직을 추가할 수 있다. 그러나 이 때도 롤백이 실패할 경우를 고려해야하므로, 재처리를 위한 데이터 폴링 배치 같은 아키텍처가 필요하다.
자세한 내용은 아래의 카카오페이 기술블로그를 참조.

용어 정리
