Engineering Note

멀티 리전 서비스의 Home Region 쓰기: DB와 Kafka의 소유권을 함께 지키는 방법

멀티 리전 서비스에서 Home Region을 판정하고, 원격 쓰기 전달부터 DB 트랜잭션, Outbox, Kafka, epoch fencing, 소유권 이전까지 일관된 쓰기 경로를 설계하는 방법을 설명합니다.

이전 글에서는 Kafka 기반 Active-Active의 핵심이 클러스터 복제보다 쓰기 소유권이라고 설명했습니다.

그렇다면 쓰기 소유권은 실제 서비스에서 어떻게 동작해야 할까요?

서울과 싱가포르 리전이 모두 사용자 요청을 받고 있다고 가정해보겠습니다. 고객 tenant-42의 Home Region은 싱가포르지만, 사용자의 변경 요청은 서울 리전에 도착할 수 있습니다. 서울 애플리케이션이 자기 DB를 먼저 바꾸고 나중에 Kafka 이벤트만 싱가포르로 보낸다면, Kafka 경로는 연결되어도 서비스의 쓰기 소유권은 지켜지지 않습니다.

Home Region은 Kafka 목적지를 고르는 설정이 아닙니다.

특정 비즈니스 엔티티를 지금 어느 리전에서 변경할 수 있는가

이 결정을 API 진입점부터 DB 트랜잭션과 Outbox, Kafka 이벤트까지 같은 기준으로 적용해야 합니다.

이 글의 결론은 세 가지입니다.

요청을 받은 리전과 쓸 수 있는 리전은 다를 수 있다.
캐시는 요청을 안내할 수 있지만 쓰기를 허가할 수는 없다.
새 소유자를 활성화하기 전에 이전 소유자를 먼저 차단해야 한다.
동등한 네 개의 지역 작업소 중 한 곳에만 따뜻한 불이 켜지고 작은 관리자가 그 작업소 옆에 서 있는 수공예 풍경
여러 리전이 모두 준비되어 있어도, 하나의 대상은 현재 지정된 Home Region에서만 변경됩니다.

Home Region은 서비스 쓰기 정책이다

Home Region은 하나의 서비스 전체에 고정된 리전일 수도 있고, 테넌트나 계정 같은 단위마다 다를 수도 있습니다.

tenant-42  -> region-b
tenant-81  -> region-a
account-7  -> region-b

여기서 중요한 것은 물리적인 DB나 Kafka 클러스터보다 **소유권 단위(ownership key)**입니다. 쓰기 요청은 먼저 어떤 소유권에 속하는지 식별할 수 있어야 합니다.

대표적인 키는 다음과 같습니다.

  • SaaS 서비스의 tenant_id
  • 금융 업무의 account_id
  • 주문 업무의 order_id 또는 merchant_id
  • 협업 서비스의 workspace_id

좋은 소유권 키는 모든 쓰기 요청에서 안정적으로 추출할 수 있고, 하나의 트랜잭션 경계를 대표하며, 서로 다른 소유권을 한 번에 변경하는 작업이 드뭅니다.

너무 큰 단위는 장애 전환과 부하 분산의 범위를 키웁니다. 반대로 너무 작은 단위는 소유권 레코드와 캐시 갱신 비용을 늘리고, 여러 키에 걸친 업무 트랜잭션을 어렵게 만듭니다.

따라서 첫 번째 설계 질문은 “어느 Kafka에 쓸 것인가”가 아니라 다음입니다.

우리 서비스에서 독립적으로 쓰기 소유권을 옮길 수 있는 최소 업무 단위는 무엇인가?

전체 쓰기 경로를 하나의 결정으로 묶는다

사용자는 어느 리전으로든 들어올 수 있지만, 실제 변경은 Home Region에서만 커밋합니다.

리전 A에 도착한 요청이 로컬 소유권 캐시에서 리전 B가 홈 리전임을 확인한 뒤 리전 B의 쓰기 서비스로 전달되고, DB와 Outbox를 함께 커밋한 다음 Kafka로 이벤트를 발행하는 구조
요청 진입 리전은 쓰기 소유권이 아닙니다. 소유권 판정부터 DB와 Outbox 커밋, Kafka 발행까지 하나의 Home Region 결정이 이어져야 합니다.

정상적인 DB 중심 서비스의 쓰기 경로는 다음과 같습니다.

1. 요청에서 ownership key를 추출한다.
2. 로컬 캐시에서 Home Region과 owner epoch를 조회한다.
3. 현재 리전이 Home Region이 아니면 요청을 소유 리전으로 전달한다.
4. Home Region의 쓰기 서비스가 우회할 수 없는 쓰기 경계에서 소유권과 epoch를 다시 검증한다.
5. 업무 DB 변경과 Outbox 기록을 같은 트랜잭션으로 커밋한다.
6. Outbox relay가 이벤트를 Kafka에 발행한다.
7. 다른 리전은 복제된 이벤트를 Inbox로 멱등하게 반영한다.

여기서 Kafka는 확정된 변경을 전달합니다. DB가 system of record인 서비스라면 Kafka 발행 전에 DB 트랜잭션이 먼저 안전하게 완료되어야 합니다.

Event Sourcing을 사용하는 서비스는 순서가 다릅니다. 이 경우에는 command를 Home Region에서 검증한 뒤 Kafka 같은 event log에 기록하고, DB를 projection으로 갱신할 수 있습니다. 최초 기록 위치는 다르지만 “하나의 소유권 결정이 전체 쓰기 경로를 지배한다”는 원칙은 같습니다.

다른 리전으로 들어온 요청을 처리하는 세 가지 방법

비소유 리전으로 들어온 요청은 로컬 DB에 임시로 쓴 뒤 동기화하면 안 됩니다. 다음 세 방식 중 하나를 명시적으로 선택해야 합니다.

방식장점주의점
서버 간 forwarding클라이언트가 Home Region을 몰라도 됨리전 간 지연, 타임아웃, 중복 재시도를 처리해야 함
HTTP redirect서버 간 프록시 홉을 줄일 수 있음클라이언트가 redirect와 인증 정보를 안전하게 처리해야 함
쓰기 거절정책이 단순하고 잘못된 쓰기가 명확히 드러남가용성과 사용자 경험이 낮아질 수 있음

POSTPATCH를 redirect한다면 method와 body를 유지하는 307 Temporary Redirect를 클라이언트가 지원하는지 확인해야 합니다. Home Region은 바뀔 수 있으므로 영구 redirect를 캐시하게 만드는 방식은 피하고, 인증 정보도 신뢰할 수 있는 Home Region endpoint에만 전달해야 합니다.

일반적인 외부 API에는 서버 간 forwarding이 적용하기 쉽습니다. 다만 타임아웃은 실패가 아니라 결과를 모르는 상태일 수 있습니다. Home Region에서 커밋은 끝났지만 응답만 돌아오지 않았을 수 있기 때문입니다.

따라서 원격 쓰기에는 idempotency key가 필요합니다.

{
  "ownership_key": "tenant-42",
  "idempotency_key": "01K0A7V3Q9T6K8M2J4X5N7P1RC",
  "owner_epoch": 18,
  "command": "ChangeBillingAddress"
}

클라이언트가 같은 요청을 재시도하거나 ingress 리전이 forwarding을 다시 수행해도 Home Region은 같은 idempotency key에 대해 한 번만 커밋해야 합니다.

소유권 이전 직후 같은 요청이 새 Home Region으로 재시도될 수도 있습니다. 중복 방지 레코드는 최대 재시도 기간보다 오래 보존하고, 소유권과 함께 복제하거나 이전할 수 있어야 합니다. 그렇지 않으면 각 리전에서는 한 번씩만 처리했어도 전체 서비스에서는 두 번 커밋될 수 있습니다.

전달 루프도 막아야 합니다. 오래된 캐시를 가진 리전 A가 B로 보냈는데 B의 오래된 캐시가 다시 A를 가리키면 요청이 왕복할 수 있습니다. 내부 요청에는 최초 ingress, forwarding 횟수, 판정에 사용한 epoch를 포함하고, forwarding은 한 번만 허용하는 식으로 상한을 둬야 합니다. Home Region 서비스는 최종 소유권 판정을 다시 수행합니다.

Control Plane을 모든 요청의 hot path로 만들지 않는다

소유권 기준 저장소를 모든 쓰기 요청에서 원격 동기 조회하면 구조는 이해하기 쉽습니다.

request -> global ownership database -> write service -> business database

하지만 이 방식은 중앙 저장소를 모든 쓰기의 지연과 가용성에 연결합니다. 리전 간 네트워크가 느려지면 정상적인 로컬 쓰기도 영향을 받습니다.

실제 데이터 경로에서는 보통 지역별 ownership cache를 사용합니다.

OwnershipRecord {
  key: "tenant-42"
  homeRegion: "region-b"
  epoch: 18
  state: "ACTIVE"
  updatedAt: "2026-07-13T02:10:00Z"
}

Control Plane은 소유권 생성과 이전을 관리하고, 변경 이벤트를 각 리전의 캐시에 배포합니다. Write Gateway는 대부분의 요청을 로컬 캐시로 빠르게 라우팅합니다.

그러나 캐시는 권한 저장소가 아닙니다. 갱신 지연, 프로세스 일시 정지, 네트워크 단절 때문에 오래된 값을 가질 수 있습니다. 캐시가 알려주는 Home Region과 epoch는 routing hint이자 쓰기 허가를 얻기 위한 입력값으로 봐야 합니다. 최종 허가는 최신 소유권을 확인할 수 있고 이전 writer가 우회할 수 없는 쓰기 경계에서 내려야 합니다.

epoch는 검증될 때만 fencing token이 된다

소유권 레코드에 epoch 숫자를 추가하는 것만으로 이전 소유자가 차단되지는 않습니다.

리전 A의 오래된 캐시가 epoch 17 요청을 보내지만 쓰기 경계가 현재 소유자인 리전 B와 epoch 18을 확인해 거절하고, 갱신된 epoch 18 요청만 리전 B에서 커밋하는 흐름
캐시는 요청을 잘못 보낼 수 있습니다. 오래된 소유자와 epoch를 우회할 수 없는 쓰기 경계에서 검증해야 stale write가 실제로 차단됩니다.

예를 들어 현재 소유권이 다음과 같다고 하겠습니다.

owner = region-b
epoch = 18

리전 A의 오래된 캐시가 owner=region-a, epoch=17을 반환할 수 있습니다. 이 요청이 DB까지 그대로 통과하면 캐시에 epoch 필드가 있어도 소용이 없습니다.

모든 쓰기 경계는 최소한 다음을 검증해야 합니다.

request.home_region == current.home_region
request.owner_epoch == current.owner_epoch
current.state == ACTIVE

여기서 home_regionowner_epoch은 외부 클라이언트가 임의로 보낸 헤더를 신뢰한다는 뜻이 아닙니다. ingress와 Home Region 사이의 인증된 내부 요청이나 서버가 발급한 write permit에 포함하고, 최종 쓰기 경계가 현재 권한 상태와 대조해야 합니다.

검증 위치는 저장 구조에 따라 달라집니다.

공유 또는 전역 DB를 사용하는 경우

소유권 레코드와 업무 데이터를 같은 일관성 경계에서 확인할 수 있다면 조건부 update나 transaction 안에서 owner와 epoch를 검증할 수 있습니다.

UPDATE account
SET billing_address = :address,
    version = version + 1
WHERE account_id = :account_id
  AND owner_region = :request_region
  AND owner_epoch = :request_epoch;

영향받은 row가 없다면 stale owner 요청으로 보고 커밋하지 않습니다.

리전마다 독립 DB가 있는 경우

이전 리전의 로컬 DB에 epoch=17이 그대로 남아 있다면, 그 DB만 보는 조건부 update는 epoch 17 요청을 성공시킬 수 있습니다. 이것이 흔한 함정입니다.

이 구조에서는 새 리전을 활성화하기 전에 이전 리전의 쓰기 경로를 별도로 차단해야 합니다.

  • 쓰기 서비스의 DB credential 회수 또는 교체
  • DB를 read-only로 전환
  • network policy나 방화벽으로 이전 writer 차단
  • 만료 가능한 lease를 쓰기 서비스와 저장 계층에서 함께 검증
  • 우회할 수 없고 최신 소유권 또는 유효한 lease를 검증하는 공통 Write Gateway 사용

즉, epoch는 비교되는 장소가 최신 소유권을 알고 있고 이전 writer가 그 검증을 우회할 수 없을 때만 fencing token으로 작동합니다.

DB와 Outbox는 같은 소유권을 기록한다

Home Region에서 업무 변경이 성공했다면 그 사실을 다른 리전에 전달해야 합니다. DB 변경과 Kafka 발행을 직접 연속 호출하면 dual-write 문제가 다시 생깁니다.

따라서 Home Region의 로컬 트랜잭션은 업무 상태와 Outbox를 함께 기록합니다. Debezium의 Outbox Event Router 문서도 서비스 내부 상태와 소비자가 보는 이벤트 상태의 불일치를 줄이는 용도로 이 패턴을 설명합니다.

{
  "event_id": "01K0A80D4JYV6P2QH9W3B7N5XM",
  "aggregate_type": "Tenant",
  "aggregate_id": "tenant-42",
  "event_type": "BillingAddressChanged",
  "source_region": "region-b",
  "home_region": "region-b",
  "owner_epoch": 18,
  "aggregate_version": 73,
  "occurred_at": "2026-07-13T02:14:31Z"
}

event_id는 Inbox 중복 제거에 사용합니다. owner_epoch은 이벤트가 어느 소유권 세대에서 만들어졌는지 보여주고, aggregate_version은 수신 리전이 같은 엔티티의 적용 순서를 판단하는 근거가 됩니다.

다만 Kafka 이벤트에 epoch가 있다고 해서 이미 수행된 잘못된 DB 쓰기가 취소되지는 않습니다. epoch 검증은 이벤트 발행 전의 쓰기 경계에서 먼저 수행해야 합니다.

Kafka도 외부 DB의 변경까지 자동으로 exactly-once로 만들어주지는 않습니다. Kafka의 delivery semantics와 별개로 수신 리전은 Inbox 레코드와 업무 DB 변경을 하나의 로컬 트랜잭션으로 묶고, 같은 event_id를 다시 받아도 결과가 달라지지 않게 해야 합니다. 이 부분은 Outbox와 Inbox를 다룬 글에서 더 자세히 설명했습니다.

Home Region 이전은 상태 전이다

Home Region 변경을 라우팅 테이블의 문자열 하나를 바꾸는 작업으로 보면 가장 위험한 순간을 놓치게 됩니다.

리전 A epoch 17의 ACTIVE 상태에서 DRAINING, FENCED, 리전 B CATCH-UP 단계를 거쳐 리전 B epoch 18의 ACTIVE 상태로 이동하는 소유권 이전 상태도
계획된 이전은 기존 쓰기를 비우고 차단한 뒤 대상 리전의 반영 상태를 확인하고 새 epoch를 활성화하는 상태 전이입니다.

계획된 이전은 다음 순서로 진행할 수 있습니다.

1. Region A를 DRAINING으로 바꾸고 신규 쓰기를 중단한다.
2. 진행 중인 요청이 끝났는지 확인한다.
3. Outbox 발행과 리전 간 이벤트 복제가 따라왔는지 확인한다.
4. Region A의 쓰기 경로를 FENCED 상태로 만든다.
5. Region B의 Inbox와 DB가 목표 version까지 반영됐는지 확인한다.
6. owner를 Region B로 바꾸고 epoch를 18로 증가시킨다.
7. Region B를 ACTIVE로 바꾸고 쓰기를 허용한다.
8. stale epoch 거절과 지연 이벤트를 집중적으로 관찰한다.

핵심 순서는 FENCED(A) -> ACTIVE(B)입니다. 새 리전을 먼저 열고 이전 리전을 나중에 닫으면 두 리전이 동시에 쓰는 구간이 생깁니다.

비계획 장애에서는 이 순서를 완전히 증명하지 못할 수 있습니다. 리전 A가 단절되면 마지막 DB commit과 Outbox 이벤트가 어디까지 전달됐는지 알 수 없고, 이전 writer가 실제로 멈췄는지도 확인하기 어렵습니다.

이때는 기술이 결정을 숨겨주지 않습니다.

  • 쓰기를 계속 차단하고 정합성을 우선할 것인가
  • 정의된 RPO 안의 데이터 유실 가능성을 받아들이고 강제 전환할 것인가
  • 어떤 운영자가 어떤 근거로 강제 전환을 승인할 것인가

자동 장애 전환을 설계하려면 lease, quorum, 외부 fencing 장치처럼 이전 리전이 혼자 소유권을 연장할 수 없는 조건이 필요합니다. 예를 들어 Kubernetes도 Lease를 하나의 인스턴스만 활성 역할을 수행하게 하는 조정 수단으로 사용합니다. 다만 lease 레코드만 추가해서는 충분하지 않고, 만료된 holder의 실제 쓰기가 거절되는 경계까지 설계해야 합니다. 그렇지 않다면 수동 승인 절차가 더 정직하고 안전할 수 있습니다.

운영에서 함께 봐야 할 신호

Home Region 쓰기는 애플리케이션 기능이면서 운영 상태입니다. 다음 신호를 한 화면이나 하나의 runbook에서 연결해 볼 수 있어야 합니다.

영역확인할 신호
소유권현재 owner, epoch, state, 마지막 변경 시각과 작업자
요청비소유 리전 유입률, forwarding 지연과 실패, redirect 또는 거절 횟수
fencingstale epoch 거절, 이전 credential 사용 시도, read-only 위반
DB·Outboxcommit 오류, Outbox backlog, 가장 오래된 미발행 이벤트
Kafka·Inboxpublish 오류, 리전 간 복제 지연, Inbox backlog, 중복 이벤트
정합성소유 리전 version과 수신 리전 적용 version의 차이

특히 stale epoch rejection은 무조건 없애야 하는 오류가 아닙니다. 소유권 이전 직후에는 오래된 캐시와 지연 요청을 안전하게 막고 있다는 증거일 수 있습니다. 다만 일정 시간 이후에도 계속 발생한다면 캐시 갱신, 장기 실행 작업, 재시도 큐를 조사해야 합니다.

구현 전에 결정할 체크리스트

  • Home Region을 어떤 ownership key 단위로 관리할 것인가?
  • 하나의 요청이 서로 다른 소유권 키를 동시에 변경할 수 있는가?
  • 비소유 리전의 요청을 forwarding, redirect, reject 중 무엇으로 처리할 것인가?
  • 원격 요청의 idempotency key는 어디서 생성하고 얼마나 보존할 것인가?
  • 소유권 이전 시 idempotency 레코드도 새 Home Region에서 확인할 수 있는가?
  • Control Plane 장애 시 기존 소유권을 유지할 것인가, 쓰기를 중단할 것인가?
  • 오래된 캐시가 잘못 라우팅한 요청을 어느 경계에서 다시 검증할 것인가?
  • 이전 리전이 로컬 DB에 직접 쓰는 경로를 실제로 차단할 수 있는가?
  • DB 변경과 Outbox 기록이 같은 트랜잭션인가?
  • 이벤트에 event_id, owner_epoch, aggregate_version이 있는가?
  • 계획 이전에서 DRAINING, FENCED, CATCH-UP 상태를 관찰할 수 있는가?
  • 비계획 전환의 RPO와 승인 권한이 문서화되어 있는가?
  • failback도 failover와 같은 수준으로 연습했는가?

결론

Home Region은 요청을 특정 데이터센터로 보내는 라우팅 규칙보다 넓은 개념입니다.

하나의 비즈니스 엔티티를 어느 리전이 변경할 수 있는지 결정하고, 그 결정을 Write Gateway, 업무 서비스, DB 트랜잭션, Outbox, Kafka 이벤트, 장애 전환 절차가 모두 따라야 합니다.

요청은 어느 리전으로든 들어올 수 있다.
하지만 변경은 현재 소유권을 검증한 Home Region에서만 커밋된다.

Kafka는 그 결과를 다른 리전과 서비스로 전달하는 강력한 계층입니다. 하지만 DB와 Kafka의 소유권이 따로 움직인다면 Home Region은 지켜지지 않습니다.

멀티 리전 서비스의 안전성은 “새 리전이 쓸 수 있는가”보다 “이전 리전이 더 이상 쓸 수 없는가”를 얼마나 확실히 증명할 수 있는지에 달려 있습니다.

함께 읽기 좋은 글