PostgreSQL 데이터 이관: 비우는 순서와 같은 DB 가드
로컬에 둔 영업 DB 가 원본보다 뒤처져 있었습니다. 리드가 254건인데 원본은 282건이었습니다. 활동 기록은 171건 대 281건으로 더 벌어져 있었습니다.
문제는 그것만이 아니었습니다. 기능을 확인하느라 API 를 눌러 보는 동안 제가 남긴 통화 기록이 그 안에 섞여 있었습니다. 실제 고객 리드의 단계를 「거절」로 바꿔 놓은 것도 있었습니다. 한 번 되돌리긴 했지만, 되돌리는 일 자체가 원본과 한 줄씩 맞춰 보는 작업이었습니다.
그래서 증분으로 따라잡는 대신 통째로 지우고 다시 가져오기로 했습니다. 이 글은 그 작업에서 걸렸던 것들입니다.
증분 갱신이 아니라 초기화를 고른 이유
원본에서 새로 생긴 28건만 가져오면 될 것처럼 보입니다. 실제로는 그게 더 어렵습니다.
제가 남긴 흔적이 어느 것인지 가려내야 하기 때문입니다. 활동 기록에는 만든 사람이 적혀 있지 않습니다. 시각으로 가르려 해도 원본에도 같은 시간대에 진짜 통화 기록이 있었습니다. 실제로 되돌릴 때 원본 DB 에 붙어 활동 id 를 하나씩 대조해야 했습니다.
지우고 다시 넣으면 그 판단이 통째로 사라집니다. 원본이 정본이고 우리 쪽은 사본이라는 관계가 명확하면, 사본을 고치는 것보다 다시 만드는 편이 언제나 싸다는 뜻입니다.
같은 DB 를 가리키면 되돌릴 수 없습니다
이 스크립트는 대상 표를 비웁니다. 원본과 대상이 같은 곳을 가리키면 운영 데이터가 그 자리에서 사라집니다.
그래서 처음에 이런 가드를 넣어 두었습니다.
SELECT inet_server_addr()::text || '/' || current_database()두 쪽에서 이 값을 받아 같으면 멈추게 했습니다. 나중에 확인해 보니 이 가드는 아무 일도 하지 않고 있었습니다.
inet_server_addr() 는 유닉스 소켓으로 붙었을 때 NULL 을 돌려줍니다. Postgres 에서 NULL 이 섞인 문자열 연결은 통째로 NULL 이 됩니다. 즉 두 값이 다 NULL 이 되고, NULL = NULL 은 참이 아니라 NULL 이라 비교가 영영 성립하지 않습니다. 같은 DB 를 넣어도 통과하는 가드였습니다.
지금은 이걸 씁니다.
SELECT system_identifier::text || '/' || current_database() FROM pg_control_system()system_identifier 는 initdb 때 정해지는 값이라 같은 클러스터면 반드시 같습니다. 접속 방법과 무관합니다. 실제 실행에서는 이렇게 나옵니다.
원본 7669456479325499436/원본DB
대상 7675341779911888940/사본DB
(서로 다름을 확인했습니다)가드를 짤 때 「무엇을 비교할까」보다 「그 값이 언제 비는가」를 먼저 봤어야 했습니다.
표를 하나씩 비우면 막힙니다
비우는 코드를 처음에는 표마다 한 줄씩 돌렸습니다. 일곱 번째에서 멈췄습니다.
ERROR: cannot truncate a table referenced in a foreign key constraint
DETAIL: Table "LeadProjectField" references "LeadProject".
HINT: Truncate table "LeadProjectField" at the same time, or use TRUNCATE ... CASCADE.LeadProjectField 는 그때 0행이었습니다. 그런데도 막힙니다. Postgres 는 자식 표에 실제로 행이 있는지 보지 않고 참조 관계 자체를 봅니다. 비어 있으니 괜찮겠지 하는 기대가 틀렸습니다.
힌트가 CASCADE 를 권하지만 쓰지 않았습니다. CASCADE 는 우리가 적지 않은 표까지 따라가며 지웁니다. 스키마가 바뀌어 새 참조가 생기면 목록에 없는 표가 조용히 비워집니다. 그게 언제 일어났는지도 모릅니다.
대신 관련 표를 한 문장에 다 적었습니다.
TRUNCATE TABLE "Holiday", "LeadSearchRegion", "NpsWorkplace", ..., "LeadMapPlace"그 집합이 외래키로 닫혀 있으면 Postgres 가 허용합니다. 무엇이 비워지는지가 코드에 그대로 드러난다는 게 요점입니다. 스크립트를 읽는 사람이 목록만 보면 됩니다.
채우는 순서는 외래키 의존 순서입니다
비우는 것과 달리 채우는 것은 순서가 있습니다. Lead 보다 LeadActivity 를 먼저 넣으면 외래키 위반으로 멈춥니다.
표 배열의 순서가 곧 그 순서입니다.
1 참조 없는 것 Holiday · LeadSearchRegion · NpsWorkplace · BizItem …
2 LeadProject LeadProjectField·Lead 가 가리킨다
3 Lead 활동·견적·메일·공공장소·수집장소가 가리킨다
4 Lead 를 가리키는 것 LeadActivity · MailDraft · Quote …
5 Quote 를 가리키는 것 QuoteItem · Proposal
6 LeadMapRun 셀·장소가 가리킨다순서가 틀려도 조용히 넘어가지 않습니다. 그 자리에서 멈추므로 잘못을 늦게 알 일은 없습니다. 순서를 지키는 것보다 틀렸을 때 시끄러운 것이 중요합니다.
pg_dump 를 파이프로 넘깁니다
행이 72만 개입니다. 대부분이 국민연금 사업장 54만 7천 건과 공공 장소 14만 8천 건입니다.
ORM 으로 읽어서 쓰면 몇 시간이 걸립니다. COPY 는 몇 분에 끝납니다. 그래서 pg_dump 의 출력을 파일로 떨어뜨리지 않고 바로 psql 에 넘깁니다.
pg_dump --data-only --no-owner --no-privileges --table="public.\"$t\"" "$SRC" \
| psql -v ON_ERROR_STOP=1 "$DST"--data-only 인 이유는 스키마를 이미 Prisma 가 만들어 두었기 때문입니다. --no-owner 와 --no-privileges 는 원본의 롤 이름이 대상에 없어서 넣었습니다. 두 쪽 다 Postgres 라 형 변환이 없다는 점도 이 방법을 고른 이유입니다. 원본은 16.14 이고 대상은 18.6 인데, pg_dump 는 자기보다 낮은 서버를 읽을 수 있으므로 18 쪽 도구를 쓰면 됩니다.
ON_ERROR_STOP=1 이 없으면 psql 은 실패한 줄을 건너뛰고 계속 갑니다. 그러면 절반만 들어간 표를 성공으로 착각합니다.
표 하나는 일부러 뺐습니다
목록을 만들고 첫 확인을 돌렸더니 대상 쪽이 원본보다 많은 표가 하나 있었습니다.
Setting 원본 4 대상 6그 두 줄이 이 앱의 설정이었습니다. 기반 지도를 네이버로 쓸지 오픈소스로 쓸지, 어떤 색으로 그릴지를 거기 넣어 두었습니다. 원본 쪽 Setting 은 다른 앱의 것이라 이 앱이 읽지 않습니다.
그대로 덮었으면 지도 설정이 사라지고 기본값인 네이버로 돌아갔을 겁니다. 네이버 지도는 콘솔에 등록된 주소에서만 타일이 나오는데, 지금 쓰는 포트는 등록돼 있지 않습니다. 데이터를 성공적으로 옮기고 나서 지도가 통째로 안 보이는 상태가 됐을 겁니다.
그래서 Setting 을 목록에서 빼고 이유를 코드 옆에 적어 두었습니다.
「데이터를 초기화한다」의 대상은 영업 데이터이지 이 앱의 설정이 아닙니다.
이 구분은 표 이름만 봐서는 안 보입니다. 행 수를 나란히 찍어 보지 않았으면 놓쳤을 자리입니다.
검증은 옮기면서 합니다
표를 하나 넣을 때마다 원본과 대상의 행 수를 세어 비교합니다. 어긋나면 그 자리에서 멈춥니다.
Lead 282행
LeadActivity 281행
PublicPlace 148323행
NpsWorkplace 547443행끝나고 한 번에 세는 것과 다릅니다. 중간에 멈추면 어느 표에서 어긋났는지가 바로 나옵니다. 다 넣고 나서 총합만 비교하면 어디를 다시 봐야 하는지 알 수 없습니다.
행 수 말고 하나 더 봅니다.
리드에 연결된 수집 장소 92
리드에 연결된 공공 장소 138지도에서 모은 장소와 등록한 리드를 잇는 열쇠입니다. 이게 끊기면 행 수는 다 맞는데 지도에서 같은 업체가 「새 업체」로 보입니다. 행 수만 세면 못 잡는 종류의 손상이라 따로 셉니다.
지우기 전에 두 번 볼 수 있게 했습니다
되돌릴 수 없는 스크립트라 아무것도 쓰지 않는 모드를 둘 두었습니다.
--check 는 연결과 행 수만 보여 줍니다. --dry-run 은 옮길 양을 세어 보여 줍니다. 둘 다 대상에 손대지 않습니다.
실제로 이번 작업에서 --check 를 먼저 돌렸기 때문에 Setting 표의 차이를 발견했습니다. 바로 실행했으면 지도 설정이 사라진 뒤에 알았을 겁니다.
결과
18개 표, 72만 550행이 원본과 정확히 일치합니다.
리드 254 → 282
활동 171 → 281
메일 초안 3 → 10
공휴일 0 → 16공휴일이 0행이었던 것은 이관 목록에 그 표를 넣지 않았던 탓입니다. 영업일 계산이 그 표를 읽는데, 비어 있으면 공휴일 없이 셉니다. 다음 연락일이 공휴일에 잡히고 있었다는 뜻입니다. 이번에 16건이 들어왔습니다.
제가 검증하며 남긴 흔적은 사라졌습니다. 이름이 smoke- 로 시작하는 리드는 0건이고, 남아 있는 수집 하나는 8월 6일자로 원본에 있던 것입니다.
남은 문제
이관은 단방향입니다. 원본은 지금도 쓰이고 있으므로 내일이면 또 앞서 나갑니다. 이번에 28건 차이였던 것이 다음에는 더 벌어질 겁니다.
그때 또 통째로 지울지, 아니면 그때는 증분을 만들지 아직 정하지 않았습니다. 사본에서 하는 작업이 늘어나면 통째로 지우는 선택지가 없어집니다. 그 시점이 오기 전에 방향을 정해야 한다는 것만 알고 있습니다.
함께 읽기
- Supabase에서 PlanetScale로: 이관이 드러낸 커넥션 풀 기본값운영 데이터베이스를 Supabase에서 PlanetScale Postgres로 옮겼습니다. 옮기고 나서 사이트가 조금 굼떠진 것 같았습니다. 클라우드를 건너뛰게 됐으니 그 대가겠거니 생각했는데, 재보니 아니었습니다. 원인은 이관과 상관없이 원래 있던 커넥션 풀 설정이었고, 그것도 제 첫 측정으로는 보이지 않았습니다.
- PostgreSQL autovacuum: 지운 행이 디스크에서 사라지지 않는 이유테이블에서 100만 행을 지웠습니다. DELETE는 성공했고 SELECT count()는 0을 돌려줍니다.
- Neon·Supabase 대체 서비스: 2026년 비교와 추천 순위Neon과 Supabase를 대신할 서비스를 정리해 달라는 질문을 받았습니다. 처음에는 후보를 한 표에 놓고 유명한 순서부터 매기려 했습니다. 공식 문서를 다시 읽어 보니 출발점부터 잘못되었습니다.
- Supabase와 Neon 비교: 같은 PostgreSQL, 다른 역사와 구조Supabase와 Neon을 처음 보면 둘 다 관리형 PostgreSQL 서비스처럼 보입니다. 실제로 두 서비스 모두 표준 PostgreSQL 연결 방식과 SQL 생태계를 활용합니다. 그래서 기능표만 훑으면 가격과 무료 용량 정도만 비교하게 됩니다.
- 직원 20명 사내 시스템, Supabase Micro면 충분할까?사내 시스템을 새로 만들 때 인프라를 처음부터 크게 잡아야 할지 고민하게 됩니다. 특히 Supabase Pro를 검토하면 Micro와 Small의 월 비용 차이는 크지 않지만, 어떤 기준으로 선택해야 하는지는 가격표만 보고 판단하기 어렵습니다.