DB

Tasteam V2 이관기 1: 다른 AWS 계정으로 DB를 무중단으로 옮긴 이야기

clay.kim 2026. 2. 24. 17:47

Tasteam V2 이관기 1: 다른 AWS 계정으로 DB를 무중단으로 옮긴 이야기

안녕하세요, Tasteam에서 Cloud 팀을 맡고 있는 clay입니다.

V1에서 V2로 인프라를 전환하면서 저희는 애플리케이션뿐 아니라 DB까지 다른 AWS 계정으로 옮겨야 했습니다.

문제는 DB였습니다. 애플리케이션은 계정 B에 새로 띄우고 Cloudflare 오리진을 바꾸면 되지만, DB는 서비스가 계속 쓰고 있는 데이터라 같은 방식으로 단순히 바꿀 수 없었거든요.

당시 Tasteam은 이미 외부에 공개되어 사용자 데이터가 쌓이기 시작한 상태였습니다. 그래서 이관 방식도 단순히 "옮기면 된다"가 아니라, 서비스가 살아 있는 상태에서 데이터 정합성을 어떻게 유지할지까지 함께 고민해야 했습니다.

그래서 목표는 단순히 "DB를 옮긴다"가 아니라, 사용자 요청을 끊지 않고 DB의 쓰기 주체를 계정 A에서 계정 B로 넘기는 것이었습니다.

오늘은 왜 이 이관이 필요했는지, 왜 dump/restore 대신 PostgreSQL Logical Replication 기반 무중단 전환을 택했는지, 그리고 계정이 다르다는 게 어떤 문제를 만들었는지 공유합니다.


환경

항목 내용

Source DB 계정 A — EC2 self-hosted PostgreSQL 17
Target DB 계정 B — EC2 self-hosted PostgreSQL 17
Source App 계정 A EC2 Spring Boot
Target App 계정 B EC2 Spring Boot
계정 간 트래픽 전환 Cloudflare 오리진 IP 변경
외부 진입점 Cloudflare + Caddy
트래픽 특성 점심/저녁 피크 타임 집중
부하 테스트 k6 (검색 70% + 리뷰 작성 30%)
목표 사용자 요청을 끊지 않고 계정 A DB에서 계정 B DB로 전환

왜 옮겨야 했나

이관이 필요했던 이유는 크게 세 가지가 맞물렸습니다.

1. AWS 크레딧을 최대한 활용하기 위해

AWS는 신규 계정당 $200 크레딧을 제공합니다. 저희는 계정 두 개를 운영해서 크레딧을 두 번 받아 비용을 아끼는 구조를 택했고, V1 계정의 크레딧이 소진될 시점에 계정 B로 넘어가는 일정이 맞아떨어졌습니다.

2. V1 → V2 인프라 재편 타이밍이 딱 맞았습니다

V1은 단일 EC2 안에 Spring Boot와 PostgreSQL이 함께 올라가 있는 구조였습니다. V2로 넘어가면서 Spring, DB, Redis 등을 별도 인스턴스로 분리했습니다.

인프라 구조가 통째로 바뀌는 이 시점에 계정도 함께 전환하는 게 가장 자연스러웠습니다. 어차피 새 계정에 새 인프라를 구성해야 하니, DB까지 함께 옮기는 게 두 번 작업하는 것보다 낫다고 판단했어요.

3. DB가 가장 큰 단일 장애점이었습니다

애플리케이션은 계정 B에 미리 띄운 뒤 Cloudflare 오리진 IP를 바꿔 전환할 수 있어도, DB가 단일 EC2에 묶여 있으면 결국 가장 취약한 지점은 DB였습니다.

  • 애플리케이션 인스턴스를 아무리 잘 바꿔도 DB 장애가 나면 전체 서비스가 멈춤
  • 사용자 데이터의 가치와 민감도가 올라가면서 DB 계층의 안정성이 중요해짐

이 세 가지 이유가 겹치면서, 이 시점에 DB 이관을 하지 않을 이유가 없었습니다.


왜 굳이 무중단이어야 했나

규모만 보면 새벽에 잠깐 점검을 걸고 옮기는 방식도 가능했습니다. 저희도 처음부터 무조건 거창한 무중단 마이그레이션을 해야 한다고 생각한 건 아니었어요.

다만 이 시점의 Tasteam은 이미 외부에 공개된 서비스였고, 디스콰이엇을 통해 유입된 사용자를 포함해 약 60명의 사용자가 가입한 상태였습니다. 리뷰나 사용자 데이터처럼 다시 만들기 어려운 데이터도 쌓이기 시작했고요.

그래서 이번 이관은 트래픽 규모 때문이라기보다, 사용자가 생긴 뒤의 데이터 이관을 어떤 운영 기준으로 처리할 것인가에 가까웠습니다. 점검 시간을 길게 잡고 한 번에 옮기기보다, 서비스가 살아 있는 동안 대부분의 데이터를 따라붙이고 컷오버 순간만 짧게 가져가는 방식을 선택했습니다.


처음엔 가장 단순한 방법을 생각했습니다

DB를 옮긴다고 하면 가장 먼저 생각나는 건 이 방식입니다.

# 계정 A
pg_dump -d tasteam -F c -f backup.dump

# 계정 B
pg_restore -h {계정B_IP} -d tasteam backup.dump

 

개발 환경에서는 유용하겠지만, 저희의 운영 서비스에서는 문제가 큽니다.

  • 덤프를 뜨는 순간 이후의 변경분을 어떻게 처리할 것인가
  • 복원하는 동안 발생한 신규 쓰기는 어떻게 맞출 것인가
  • 전환 직전까지 서비스 쓰기를 막지 않고 정합성을 어떻게 맞출 것인가

즉, dump/restore는 초기 적재에는 유용하지만 계속 쓰기가 발생하는 운영 DB의 무중단 전환으로는 부족했습니다.

그다음 떠올린 건 애플리케이션 레벨 이중쓰기였습니다. 하지만 이것도 저희에겐 좋은 선택이 아니었어요.

  • 트랜잭션 단위 정합성을 애플리케이션 코드가 직접 떠안아야 함
  • 한쪽 성공, 한쪽 실패 케이스를 계속 처리해야 함
  • 단발성 이관을 위해 상시 복잡도를 애플리케이션에 넣게 됨

그래서 결국 PostgreSQL이 이미 제공하는 복제 메커니즘을 활용하는 편이 더 자연스럽다고 판단했습니다.


그래서 Logical Replication을 선택했습니다

최종적으로 선택한 방식은 다음 조합이었습니다.

  • PostgreSQL Logical Replication
  • 계정 B EC2를 Subscriber로 준비
  • 계정 B 애플리케이션을 새 DB 기준으로 미리 기동
  • 마지막 컷오버 시 Cloudflare 오리진 IP를 계정 B로 변경

핵심 흐름은 단순합니다.

계정 A EC2 PostgreSQL (Publisher) → 계정 B EC2 PostgreSQL (Subscriber)
                         \→ 변경분을 계속 전송

계정 A 애플리케이션 → 계정 A DB 사용
계정 B 애플리케이션 → 계정 B DB 바라보도록 기동

lag 확인 → 시퀀스 정렬 → 계정 B 앱 검증 → Cloudflare 오리진 전환

 

PostgreSQL은 모든 변경사항을 WAL(Write-Ahead Log)에 기록합니다. Logical Replication은 이 WAL을 읽어서 타겟 DB에 실시간으로 반영합니다. 계정 A에서 새 리뷰가 INSERT되면 수 밀리초 내에 계정 B에도 동일한 데이터가 생깁니다.

설정은 생각보다 간단합니다.

 

-- 계정 A (Publisher)
CREATE PUBLICATION migration_pub FOR ALL TABLES;

-- 계정 B (Subscriber)
CREATE SUBSCRIPTION migration_sub
  CONNECTION 'host={계정A_EC2_IP} port=5432 dbname=tasteam user=replication_user password=...'
  PUBLICATION migration_pub;

 

이 두 줄이 실행되는 순간 초기 전체 복사가 시작되고, 이후 A에서 발생하는 모든 변경이 실시간으로 B에 반영됩니다.

이 방식이 좋았던 이유는 세 가지였습니다.

쓰기를 막지 않고 변경분을 따라갈 수 있었습니다. Logical Replication은 Source DB의 변경 로그를 기준으로 Target이 계속 따라붙는 구조입니다. 대량의 기존 데이터뿐 아니라 전환 준비 중 발생한 신규 쓰기까지 점진적으로 맞춰갈 수 있었습니다.

컷오버 순간을 짧게 만들 수 있었습니다. Target이 거의 최신 상태까지 따라온 뒤에는, 전환 시점에 확인할 것이 명확해집니다. replication lag가 거의 0인지, 시퀀스가 충분히 앞으로 당겨져 있는지, 계정 B 애플리케이션이 정상 기동되는지 등입니다.

애플리케이션을 미리 띄워 검증하는 방식과 잘 맞았습니다. 계정 B 애플리케이션은 새 DB를 바라보도록 먼저 기동하고, 헬스체크와 읽기/쓰기 API를 확인한 뒤 외부 트래픽만 Cloudflare에서 넘기면 됩니다. 최종 전환은 포트 스위칭이 아니라 Cloudflare 오리진 IP 변경이었지만, "새 애플리케이션을 먼저 준비하고 검증한 뒤 트래픽을 넘긴다"는 흐름은 그대로 가져갈 수 있었습니다.


계정이 다르다는 게 모든 걸 바꿉니다

저희는 실전 전에 같은 계정, 같은 인스턴스 안에서 리허설을 먼저 진행했습니다. 리허설에서는 Caddy upstream 포트 전환으로 트래픽을 바꾸는 것이 전부였고 비교적 단순했습니다.

실전은 달랐습니다. 계정 A와 계정 B는 완전히 다른 AWS 계정입니다.

리허설 (같은 계정) 실전 (다른 계정)

네트워크 같은 VPC 내 통신 VPC Peering 필요
트래픽 전환 Caddy upstream 포트 변경 Cloudflare 오리진 IP 전환
롤백 포트 되돌리기 오리진 IP 되돌리기

VPC Peering

두 계정의 EC2가 서로 통신하려면 VPC Peering이 필요했습니다. Peering을 연결한 뒤, 계정 A EC2의 Security Group에 계정 B CIDR 대역으로부터의 5432 인바운드를 열어야 Logical Replication 연결이 가능합니다.

Cloudflare 오리진 전환

컷오버의 실체는 Cloudflare 대시보드에서 A 레코드 값을 바꾸는 것입니다.

변경 전: tasteam.kr  A  1.2.3.4 (계정 A)  🟠 Proxied
변경 후: tasteam.kr  A  5.6.7.8 (계정 B)  🟠 Proxied

 

Cloudflare Proxy(오렌지 클라우드)를 쓰면 클라이언트는 Cloudflare 엣지 IP에만 연결합니다. 오리진을 바꿔도 클라이언트 입장에서는 DNS가 바뀌지 않아요. Cloudflare 내부에서 "이 요청을 어느 서버로 보낼지"만 바뀌는 겁니다.

 

클라이언트 → Cloudflare 엣지 (IP 불변) → 오리진 A 또는 B

 

WebSocket 주의

한 가지 중요한 점이 있습니다. WebSocket은 TCP 연결 기반이라, 연결이 수립된 이후에는 DNS를 다시 조회하지 않습니다. 기존 WebSocket 연결은 이미 Cloudflare ↔ 계정 A 서버 사이에 TCP가 맺어진 상태입니다. 오리진을 B로 바꿔도 이 연결은 끊기지 않고 계속 A 서버로 흐릅니다.

그래서 계정 A 서버를 오리진 전환 직후 즉시 끄면 안 됩니다. 기존 WebSocket 연결이 A에 물려있는 상태에서 A가 꺼지면 클라이언트 연결이 강제로 끊깁니다. 신규 연결은 B로 가고 있으니, A의 연결이 자연스럽게 소진되는 것을 확인한 뒤 종료해야 합니다.


실제 전환 절차

1단계. Target DB를 먼저 준비합니다

계정 B EC2에 같은 major version의 PostgreSQL 17을 설치하고, postgresql.conf에서 wal_level=logical을 설정합니다. 애플리케이션이 쓰는 role과 필요한 extension도 먼저 맞춰야 합니다.

Target이 먼저 애플리케이션을 받을 준비가 되어 있어야 컷오버 시점이 짧아집니다.

2단계. Source DB를 Publisher로 설정합니다

계정 A EC2 PostgreSQL에서 wal_level=logical, replication slot, sender 수 등을 설정하고 publication을 열었습니다.

3단계. 스키마는 별도로 옮깁니다

Logical Replication은 데이터 변경분을 복제하지, 스키마 전체를 알아서 맞춰주지는 않습니다. 그래서 schema-only dump를 떠서 계정 B에 먼저 적용했습니다.

많은 분들이 "논리 복제면 다 복제되는 것 아닌가?"라고 생각하기 쉽습니다. 실제로는 스키마와 시퀀스는 별도 관리 포인트가 됩니다.

4단계. Subscription을 생성해 변경분을 따라붙입니다

계정 B가 계정 A를 구독하도록 subscription을 만들면, 그 시점부터 A의 변경이 B로 복제됩니다. 이때부터 중요한 건 "복제가 되고 있는가"가 아니라, 얼마나 따라왔는가입니다.

SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes
FROM pg_replication_slots;

lag_bytes를 확인하면서 전환 시점을 판단했습니다.

5단계. 컷오버 직전에 시퀀스를 맞춥니다

이 부분이 이번 작업에서 가장 중요한 포인트 중 하나였습니다.

Logical Replication은 시퀀스 값을 자동으로 복제하지 않습니다. 테이블 row는 잘 따라와도 review_id_seq 같은 값은 별도로 맞춰줘야 합니다. 이걸 놓치면 전환 직후 INSERT에서 PK 충돌이 납니다.

반드시 max(id) 기준으로 시퀀스를 맞춥니다.

SELECT setval('review_id_seq', (SELECT COALESCE(max(id), 0) FROM review) + 1000);

그리고 중요한 점이 하나 더 있습니다. 이 setval은 컷오버 직전에 해야 합니다.

Logical Replication이 돌고 있는 상황에서 setval을 일찍 해두면, 그 사이에 계속 데이터가 복제되면서 갭이 소진됩니다. k6 부하 환경에서 분당 ~100건이 복제되면 1000 갭은 10분이면 사라집니다. setval 직후 즉시 컷오버를 실행해야 이 문제가 없습니다.

6단계. 계정 B 애플리케이션을 새 DB 기준으로 띄웁니다

계정 A 애플리케이션은 기존 DB를 계속 바라보게 두고, 계정 B 애플리케이션은 새 DB endpoint를 바라보도록 기동했습니다. 헬스체크와 읽기/쓰기 API를 확인한 뒤 트래픽 전환 준비를 마쳤습니다.

7단계. 트래픽을 전환합니다

Cloudflare 대시보드에서 A 레코드 오리진을 계정 A IP에서 계정 B IP로 변경합니다.

핵심은 하나였습니다.

전환 직전까지는 계정 A가 쓰기 주체이고, 전환 이후부터는 계정 B가 쓰기 주체가 된다.

이 경계를 짧고 명확하게 만드는 것이 무중단 전환의 핵심이었습니다.


트러블슈팅

문제 1. 전환 직후 PK 충돌

첫 리허설에서 전환 직후 리뷰 작성 API에서 500 에러가 쏟아졌습니다. 조회는 정상이었고, INSERT만 전량 실패했어요.

DataIntegrityViolationException: duplicate key value violates unique constraint "review_pkey"
Detail: Key (id)=(2029) already exists.

원인은 시퀀스 동기화 방식이었습니다. 처음에는 이렇게 했습니다.

-- 잘못된 방식
SELECT setval('review_id_seq', last_value + 1000) FROM pg_sequences WHERE sequencename = 'review_id_seq';

문제는 Hibernate allocationSize=50 시퀀스에서 last_value가 실제 테이블의 max(id)보다 훨씬 낮을 수 있다는 점입니다. 실제로 last_value는 373이었는데 테이블의 max(id)는 이미 2029를 넘어있었어요.

Hibernate의 allocationSize 방식은 DB 시퀀스를 청크 단위로 미리 가져와 메모리에서 소진합니다. 그래서 DB에 저장된 last_value와 실제 애플리케이션이 부여한 최댓값 사이에 큰 갭이 생깁니다.

반드시 max(id) 기준으로 시퀀스를 맞춰야 합니다.

-- 올바른 방식
SELECT setval('review_id_seq', (SELECT COALESCE(max(id), 0) FROM review) + 1000);

문제 2. setval 타이밍 — 갭 소진

리허설에서 max(id) + 1000 기준으로 시퀀스를 맞추고 성공한 뒤, 다음 실험에서 컷오버 직후 에러율이 30%까지 치솟았습니다. k6의 쓰기 비율(30%)과 정확히 일치했어요. 리뷰 쓰기 전량이 PK 충돌로 실패한 겁니다.

원인을 추적해보니 setval 타이밍 문제였습니다.

setval 실행 (갭: +1000)
   ↓
k6가 계속 계정 A에 쓰기 요청 → A에 INSERT
   ↓
A에 쌓인 데이터가 Logical Replication으로 B에 실시간 복제
   ↓
갭 1000이 10분 만에 소진
   ↓
컷오버 → B 앱이 시퀀스로 INSERT → 이미 존재하는 ID와 충돌

해결은 단순했습니다. setval 직후 즉시 컷오버를 실행하니 문제가 사라졌습니다.


컷오버 당일 체크리스트

1. lag_bytes = 0 확인
   SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes
   FROM pg_replication_slots;

2. setval 실행 (모든 시퀀스, max(id) + 1000)

3. 즉시 Cloudflare 오리진 변경 (계정 A IP → 계정 B IP)

4. k6 에러율 / Grafana 모니터링

5. 안정화 확인 후 계정 A 서버 종료 (WebSocket 연결 소진 대기, 최소 15~30분)

 

실제 결과:

항목 값

http_req_failed 0.0%
checks (status 2xx) 100.0%
p95 응답시간 205ms
migration-seed 300건 누락 0건
VU별 순번 누락 0건

 


돌아보며

세 가지 교훈이 남았습니다.

시퀀스는 WAL로 복제되지 않는다. Logical Replication이 데이터를 복제한다고 해서 시퀀스까지 따라오는 건 아닙니다. 컷오버 직전 max(id) 기준으로 수동 동기화가 반드시 필요합니다.

setval 타이밍이 전부다. 갭 크기를 크게 잡는 것보다 setval 직후 즉시 컷오버하는 게 맞는 방법입니다. 복제가 돌고 있는 상황에서 시간이 지날수록 갭은 소진됩니다.

Cloudflare 오리진 전환은 DNS 변경이 아니다. 클라이언트가 보는 IP는 바뀌지 않습니다. 기존 연결(WebSocket 포함)은 구 서버에 유지되므로, 구 서버를 충분히 살려둬야 합니다.


그래서 DB는 왜 무중단이 가능했을까

DB에는 변경을 따라갈 수 있는 복제 메커니즘이 있었기 때문입니다.

  • row 단위 변경을 복제할 수 있고
  • lag를 수치로 확인할 수 있고
  • 전환 직전 확인해야 할 포인트가 비교적 명확합니다

그래서 "전환 전까지는 계정 A가 계속 쓰고, 계정 B가 거의 실시간으로 따라붙은 뒤, 짧은 컷오버로 주체를 바꾸는 방식"이 성립합니다.

반대로 다음 편에서 다룰 S3는 이 결이 다릅니다. 객체 스토리지는 row-level replication이나 sequence 같은 개념이 없고, 저희 구조에서는 업로드도 서버가 아니라 클라이언트가 Presigned POST로 직접 수행합니다.

그래서 DB에서 통했던 사고방식을 S3에 그대로 가져가면 오히려 더 헷갈립니다.

다음 편에서 이어집니다.

 

"그럼 DB는 이렇게 했는데, S3도 비슷하게 하면 되는 거 아닌가요?"

 

→ 다음 편: Tasteam V2 이관기 2: DB는 무중단이었는데 왜 S3 이관은 다르게 했을까? — aws s3 sync 기반 이관기