DB
SQLite에서 PostgreSQL로 옮기며 날짜와 NULL을 다시 검증한 회고
작은 서비스의 데이터베이스를 옮길 때 날짜 비교와 NULL 처리 차이로 생긴 오류를 검증 기준과 함께 기록했습니다.
데이터는 들어갔는데 통계가 달랐다
개발용 SQLite 데이터를 PostgreSQL로 옮긴 뒤 가입자 수는 맞았지만 월별 통계가 달라졌습니다. 레코드 개수만 비교하고 성공했다고 판단한 것이 문제였습니다. 특히 날짜가 자정 근처인 사용자의 월이 한 달씩 밀려 있었습니다.
SQLite에서 문자열로 저장한 날짜와 PostgreSQL의 timestamp를 동일하게 취급할 수 없었고, 서버 시간대도 서로 달랐습니다. 이 차이를 마이그레이션 스크립트의 마지막에서야 발견했습니다.
변환 전에 기준을 고정했다
먼저 모든 시간을 UTC로 저장하고 화면에서만 KST로 변환하기로 결정했습니다. 기존 값에 시간대 정보가 없어서 임의로 서버 로컬 시간을 붙이지 않고, 데이터 소유자에게 기준을 확인한 뒤 변환했습니다.
SELECT COUNT(*) AS total,
MIN(created_at) AS oldest,
MAX(created_at) AS newest,
COUNT(*) FILTER (WHERE created_at IS NULL) AS missing_dates
FROM users;
SQLite에서 같은 의미의 집계를 먼저 출력해 CSV로 보관했습니다. PostgreSQL 이관 후에는 총합뿐 아니라 일자별 건수와 NULL 개수까지 비교했습니다.
NULL은 빈 문자열이 아니었다
기존 CSV 변환 단계에서 NULL을 빈 문자열로 바꾸는 옵션이 켜져 있었습니다. PostgreSQL의 timestamp 컬럼에는 빈 문자열을 넣을 수 없어 작업이 중단됐고, 이를 계기로 값의 상태를 세 가지로 나눴습니다.
- 값이 없는 NULL
- 사용자가 실제로 입력한 빈 문자열
- 변환할 수 없는 잘못된 날짜
잘못된 날짜는 조용히 현재 시각으로 대체하지 않고 별도 오류 파일에 남겼습니다. 누락값을 기본값으로 채우는 것은 데이터 복구가 아니라 새로운 사실을 만드는 일이기 때문입니다.
재실행 가능한 절차로 바꾼 부분
원본을 읽고 바로 운영 테이블에 쓰지 않고 임시 테이블에 적재한 뒤 검증하도록 순서를 바꿨습니다.
CREATE TABLE users_import (LIKE users INCLUDING DEFAULTS);
-- CSV 적재 후 변환 및 검증
INSERT INTO users (id, email, created_at)
SELECT id, email, created_at AT TIME ZONE 'UTC'
FROM users_import
WHERE created_at IS NOT NULL;
실제 운영에서는 ID 충돌과 중복 이메일을 검사하고, 차이가 있으면 본 테이블 반영을 중단합니다. 실행 로그에는 행 수와 오류 행 수만 기록해 개인정보가 남지 않게 했습니다.
이관 완료를 판단하는 표
| 검증 항목 | 기준 |
|---|---|
| 전체 행 수 | 원본과 동일 |
| 필수 컬럼 NULL | 0건 |
| 날짜 범위 | 원본의 최소·최대와 설명 가능한 차이 |
| 중복 키 | 0건 |
| 샘플 조회 | 경계 날짜 포함 수동 확인 |
이번 이관에서 배운 것은 “DB가 켜지고 쿼리가 실행된다”가 완료 조건이 아니라는 점입니다. 데이터의 의미를 보존했는지 확인하는 비교표가 마이그레이션 코드만큼 중요했습니다.