시스템을 새로 만드는 프로젝트에서 가장 자주 일정을 무너뜨리는 작업이 데이터 이관입니다.
이유는 단순합니다. 착수 전에는 데이터의 실제 상태를 아무도 모르기 때문입니다. 발주 측 담당자도 모릅니다. 그 시스템을 10년 동안 써오면서 쌓인 예외를 전부 기억하는 사람은 없습니다.
그래서 "회원 데이터 20만 건, 주문 데이터 150만 건"이라는 정보만 가지고 공수를 산정하면 거의 틀립니다. 건수는 이관 난이도와 큰 상관이 없습니다. 난이도를 결정하는 것은 그 안에 몇 가지 예외가 섞여 있는가입니다.
이 글은 데이터 이관을 준비하실 때 어떤 순서로 접근하면 되는지를 정리한 내용입니다.
1. 실제 데이터를 먼저 봅니다
설계서나 테이블 정의서를 보는 것과 실제 데이터를 여는 것은 다른 일입니다.
문서와 데이터는 다릅니다
오래된 시스템에서 반복적으로 발견되는 것들입니다.
| 유형 | 내용 |
|---|---|
| 정의되지 않은 값 | 코드 목록에 없는 상태값이 실제로 존재 |
| 형식 불일치 | 같은 날짜 컬럼에 여러 형식이 섞여 있음 |
| 용도 변경 | 비고 컬럼에 다른 정보를 넣어 쓰고 있음 |
| 관계 깨짐 | 참조하는 대상이 이미 삭제되어 없음 |
| 중복 | 같은 대상이 여러 건으로 등록됨 |
| 인코딩 | 문자셋이 섞여 일부가 깨져 있음 |
| 테스트 데이터 | 운영 데이터 사이에 시험용 데이터가 남아 있음 |
용도 변경이 특히 까다롭습니다. "비고" 컬럼에 특정 문구가 들어 있으면 그것을 별도 상태로 취급해 업무가 돌아가는 경우가 있습니다. 문서에는 없고 담당자만 아는 규칙입니다. 이런 것은 데이터를 훑어보고 이상한 패턴을 찾아 물어보는 방식으로만 발견됩니다.
조사에서 확인할 항목
계약 전, 또는 착수 직후에 확인할 목록입니다.
- 테이블별 실제 건수와 증가 추이
- 컬럼별 널 비율과 고유값 목록
- 날짜 컬럼의 최소·최대값과 형식 분포
- 코드값의 실제 분포 (정의서에 없는 값 포함)
- 외래키 관계가 실제로 지켜지는 비율
- 중복 판정 기준과 중복 건수
- 문자셋과 깨진 문자 여부
- 첨부 파일의 총 용량과 실제 존재 여부
첨부 파일이 자주 누락됩니다. 데이터베이스에는 파일 경로가 남아 있는데 실제 파일이 없는 경우가 흔합니다. 이관 대상 용량과 유실 비율을 미리 확인해야 합니다.
프로파일링 쿼리로 숫자를 뽑습니다
위 항목은 눈으로 훑어서는 채워지지 않습니다. 테이블마다 같은 쿼리를 돌려 숫자로 남겨야 산정 근거가 되고, 나중에 이관 결과와 비교할 기준선도 됩니다.
아래 예시는 원본을 복사해 둔 스테이징 스키마(legacy)에서 실행하는 것을 가정하며, PostgreSQL 17에서 실행해 확인했습니다. 테이블과 컬럼 이름은 설명용입니다. FILTER, btrim, ~ 같은 문법은 DBMS마다 다르므로 원본 DBMS에서 직접 돌릴 때는 해당 문법으로 바꿔야 합니다.
-- 컬럼 하나의 널·공백 비율, 고유값 수, 최대 길이(문자 수와 바이트 수)
SELECT count(*) AS total_rows,
count(*) FILTER (WHERE phone IS NULL) AS null_rows,
count(*) FILTER (WHERE btrim(phone) = '') AS blank_rows,
count(DISTINCT phone) AS distinct_values,
round(100.0 * count(*) FILTER (WHERE phone IS NULL OR btrim(phone) = '')
/ nullif(count(*), 0), 2) AS empty_pct,
max(char_length(phone)) AS max_chars,
max(octet_length(phone)) AS max_bytes
FROM legacy.member;
-- 코드값의 실제 분포. is_undefined 가 true 인 행이 정의서에 없는 값입니다
SELECT m.status, count(*) AS row_count, (c.code IS NULL) AS is_undefined
FROM legacy.member m
LEFT JOIN legacy.code_status c ON c.code = m.status
GROUP BY m.status, c.code
ORDER BY row_count DESC;
-- 문자열로 저장된 날짜 컬럼의 형식 분포
SELECT CASE
WHEN reg_date IS NULL OR btrim(reg_date) = '' THEN '(비어 있음)'
WHEN reg_date ~ '^\d{8}$' THEN 'YYYYMMDD'
WHEN reg_date ~ '^\d{4}-\d{2}-\d{2}$' THEN 'YYYY-MM-DD'
ELSE '기타'
END AS date_format,
count(*) AS row_count, min(reg_date) AS min_value, max(reg_date) AS max_value
FROM legacy.member
GROUP BY 1
ORDER BY row_count DESC;
널과 공백 문자열은 따로 셉니다. 화면에서는 둘 다 빈칸으로 보이지만 변환 규칙에서는 다르게 다뤄야 하기 때문입니다. 최대 길이를 문자 수와 바이트 수로 함께 뽑아두면 3장에서 다루는 자릿수 문제를 미리 확인할 수 있습니다.
중복과 고아 레코드는 판정 기준을 쿼리에 그대로 드러냅니다.
-- 중복 후보: 이름 + 생년월일 + 숫자만 남긴 전화번호가 같은 회원
SELECT name, birth_date, phone_digits, count(*) AS duplicate_count,
min(member_id) AS first_id, max(member_id) AS last_id
FROM (SELECT member_id, name, birth_date,
regexp_replace(phone, '[^0-9]', '', 'g') AS phone_digits
FROM legacy.member) m
GROUP BY name, birth_date, phone_digits
HAVING count(*) > 1;
-- 고아 레코드: 주문은 있는데 참조하는 회원이 없는 건수
SELECT count(*) AS orphan_rows
FROM legacy.orders o
WHERE o.member_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM legacy.member m WHERE m.member_id = o.member_id);
중복 쿼리가 돌려주는 것은 후보입니다. 이름과 생년월일이 같은 다른 사람일 수 있으므로, 무엇을 같은 대상으로 볼지는 업무 담당자가 정합니다. 전화번호처럼 표기가 흔들리는 값은 정규화한 뒤 비교하지 않으면 중복이 실제보다 적게 잡힙니다.
조사 쿼리는 운영 DB에 부하를 줍니다. 전체 테이블을 읽는 쿼리이므로 복제본이나 백업을 복원한 사본에서 돌리는 편이 안전하고, 운영 DB에서 직접 돌려야 한다면 시간대를 발주 측과 합의합니다.
조사 결과를 산정 근거로 씁니다
이 조사 자체를 별도 단계로 두고, 그 결과로 이관 범위와 공수를 정하는 방식을 권합니다.
계약 구조로는 두 가지가 가능합니다.
- 사전 조사를 별도 계약으로 분리하고, 결과를 근거로 본 계약을 맺는 방식
- 본 계약에 포함하되 조사 결과에 따라 범위를 조정한다는 조항을 넣는 방식
어느 쪽이든 "조사 전에는 정확한 산정이 불가능하다"는 점을 문서로 남겨두는 것이 양쪽 모두에게 안전합니다. 조사 결과로 범위가 달라졌을 때 이를 처리하는 절차는 SI 프로젝트에서 범위가 불어나는 구조와 이를 막는 변경관리 설계에서 따로 다뤘습니다.
2. 무엇을 어디까지 옮길지 정합니다
전부 옮기는 것이 당연해 보이지만, 그렇지 않은 경우가 많습니다.
이관하지 않을 것을 먼저 정합니다
- 오래된 이력 — 5년 전 로그까지 옮길 필요가 있는지
- 탈퇴 회원 — 보관 의무 기간이 지난 데이터
- 테스트·중복 데이터
- 더 이상 쓰지 않는 기능의 데이터
- 새 시스템에 대응되는 개념이 없는 데이터
제외하는 데이터가 있으면 어떻게 처리할지도 정해야 합니다. 폐기할 것인지, 조회 전용으로 별도 보관할 것인지, 파일로 내려 보관할 것인지입니다.
실무에서는 구 시스템을 조회 전용으로 일정 기간 유지하는 방식을 자주 씁니다. 과거 자료가 필요할 때 그쪽에서 확인하는 구성입니다. 이 경우 유지 기간과 접근 권한을 정해야 합니다.
개인정보가 들어 있는 데이터는 "옮길지 말지"보다 "지금 갖고 있어도 되는지"를 먼저 봐야 합니다. 「개인정보 보호법」 제21조는 보유기간이 지났거나 처리 목적을 달성해 불필요해진 개인정보를 지체 없이 파기하도록 하고, 다른 법령에 따라 보존해야 하는 경우에는 해당 개인정보를 다른 개인정보와 분리해 저장·관리하도록 정하고 있습니다.(개인정보 보호법) 이관 범위를 정하는 시점이 이를 정리하기에 가장 좋은 때입니다.
- 보유기간이 지난 탈퇴 회원 데이터는 이관 대상이 아니라 파기 대상입니다. 새 시스템으로 옮기면 파기했어야 할 데이터를 한 벌 더 만드는 셈이 됩니다.
- 다른 법령 때문에 보존해야 하는 데이터는 새 시스템에서도 운영 데이터와 분리된 위치에 두도록 설계에 반영합니다.
- 조회 전용으로 남겨둔 구 시스템과 내려받아 보관하는 파일에도 같은 기준이 적용됩니다. 유지 기간이 끝나면 누가 어떤 방법으로 파기할지까지 정해둡니다.
어떤 데이터가 어느 법령에 따라 얼마 동안 보존 대상인지는 업종마다 다릅니다. 이 판단은 발주 측 개인정보 보호책임자나 법무 담당자의 확인을 받아 문서로 남겨야 하고, 수행사가 추정해서 정할 일이 아닙니다.
대응되지 않는 구조를 어떻게 할지 정합니다
새 시스템의 구조가 기존과 다르면 그대로 옮길 수 없습니다.
자주 발생하는 상황입니다.
- 기존에는 한 칸이던 것이 새 시스템에서는 여러 항목으로 나뉨
- 기존 코드 체계가 새 체계와 일대일로 맞지 않음
- 필수 항목이 새로 생겼는데 과거 데이터에는 값이 없음
- 새 시스템에 없는 상태값이 기존에 존재함
각각에 대해 변환 규칙을 정하고 문서로 남겨야 합니다. 값이 없는 필수 항목을 무엇으로 채울지, 대응되지 않는 코드를 어디로 보낼지 같은 것입니다.
이 결정은 기술이 아니라 업무 판단입니다. 수행사가 임의로 정하면 안 되고, 업무 담당자의 확인이 필요합니다.
코드 체계를 맞추는 일은 생각보다 오래 걸립니다. 저희가 수행한 MES / BOM 구축에서도 부서마다 다르게 부르던 자재를 하나의 코드로 모으는 기준 정보 정리에 초기 기간의 상당 부분을 썼고, 그 과정에서 현업의 확인이 여러 번 필요했습니다. 이 시간을 개발 일정 안에 묻어 두지 말고 별도 일정으로 잡아야 합니다.
기준 시점을 정합니다
이관은 특정 시점의 데이터를 옮기는 작업입니다. 그런데 기존 시스템은 계속 돌아갑니다.
정해야 할 것들입니다.
- 이관 기준 시점
- 그 이후 발생한 데이터를 어떻게 따라잡을 것인가
- 전환 시점에 기존 시스템을 멈출 것인가, 멈춘다면 얼마나
멈출 수 있는 시간이 이관 방식을 결정합니다. 몇 시간 멈출 수 있다면 한 번에 옮기는 방식이 단순합니다. 멈출 수 없다면 단계적으로 옮기고 차이분을 따라잡는 구조가 필요하며, 난이도가 크게 올라갑니다.
첨부 파일은 별도 계획이 필요합니다
데이터베이스의 행을 옮기는 것과 파일을 옮기는 것은 다른 작업입니다.
확인할 것들입니다.
- 총 용량과 파일 수
- 저장 위치 — 서버 디스크인지, 외부 저장소인지
- 경로 규칙이 시기별로 다른지
- 데이터베이스에 기록된 경로와 실제 파일이 일치하는 비율
- 같은 파일이 여러 건에서 참조되는지
- 파일명에 한글이나 특수문자가 있는지
용량이 크면 전송 시간 자체가 일정 요소가 됩니다. 수백 기가바이트를 전환 당일 몇 시간 안에 옮기는 것은 어렵습니다. 이 경우 파일은 미리 옮겨두고 전환 시점에는 차이분만 동기화하는 방식을 씁니다.
실제 파일이 없는 경우의 처리도 정해야 합니다. 해당 건을 제외할지, 파일 없이 옮기고 목록을 남길지 결정이 필요합니다.
이관 방식을 고릅니다
| 방식 | 동작 | 적합한 경우 |
|---|---|---|
| 일괄 전환 | 정해진 시각에 멈추고 한 번에 옮김 | 중단이 허용되고 데이터가 크지 않을 때 |
| 단계 전환 | 업무 영역이나 지점 단위로 나눠 순차 전환 | 규모가 크고 위험을 나누고 싶을 때 |
| 병행 운영 | 두 시스템을 함께 돌리며 점진 전환 | 중단이 불가능할 때 |
단계 전환은 위험을 줄이지만 전환 기간 동안 두 시스템에 데이터가 나뉘어 존재하게 됩니다. 이 기간에 통합 조회가 필요한 업무가 있는지 먼저 확인해야 합니다.
병행 운영은 가장 복잡합니다. 어느 쪽이 정본인지 정해야 하고, 양방향 동기화가 필요한지 판단해야 하며, 어긋났을 때의 처리 규칙이 필요합니다. 비용이 크게 올라가므로 정말 중단이 불가능한지부터 확인하는 편이 낫습니다. 두 시스템 사이의 동기화는 사실상 시스템 연동이므로 외부 시스템 연동에서 장애가 생기는 자리 — 인터페이스 설계 원칙에서 다룬 재시도, 멱등성, 대사 원칙이 그대로 적용됩니다.
세 방식 중 무엇을 고를지는 아래 기준을 차례로 확인해 정합니다.
| 판단 기준 | 일괄 전환 | 단계 전환 | 병행 운영 |
|---|---|---|---|
| 허용되는 중단 시간 | 예행에서 잰 적재·검증 시간에 되돌리는 시간까지 더한 것보다 길어야 함 | 영역별로 짧게 여러 번 | 중단 없음 |
| 업무 영역의 분리 | 따지지 않음 | 영역·지점 사이에 공유 데이터가 적어야 함 | 따지지 않음 |
| 전환 기간의 이중 관리 | 없음 | 전환한 영역과 남은 영역 사이의 임시 연계 | 상시 동기화와 정본 규칙 |
| 검증 횟수 | 전체를 한 번 | 회차마다 반복 | 운영 기간 내내 대사 |
| 되돌리기 | 개방 전에는 구 시스템 차단만 풀면 됨 | 해당 영역만 되돌림 | 구 시스템이 정본인 동안은 쉬움 |
첫 줄이 가장 먼저 걸러내는 기준입니다. 허용 중단 시간이 8시간인데 예행에서 적재와 검증에 7시간이 걸렸다면(설명용 수치입니다), 되돌릴 여유가 없으므로 일괄 전환은 선택지에서 빠집니다. 이때는 과거 이력을 미리 옮겨 당일 적재량을 줄이거나, 단계 전환으로 바꾸는 쪽을 검토합니다.
3. 변환 규칙을 코드가 아니라 문서로 먼저 만듭니다
바로 스크립트를 짜기 시작하면, 나중에 왜 그렇게 변환했는지 아무도 설명할 수 없게 됩니다.
항목 단위로 정리합니다
이관 대상 항목마다 다음을 정합니다.
| 항목 | 내용 |
|---|---|
| 원본 위치 | 기존 시스템의 테이블·컬럼 |
| 대상 위치 | 새 시스템의 테이블·컬럼 |
| 자료형과 길이 | 원본과 대상의 자료형, 길이 단위(바이트인지 문자인지), 소수 자릿수 |
| 변환 규칙 | 그대로 / 형식 변환 / 코드 매핑 / 계산 |
| 값이 없을 때 | 기본값, 제외, 오류 처리 중 무엇인가. 널과 빈 문자열을 구분하는가 |
| 예외 처리 | 규칙에 맞지 않는 값을 만나면 |
| 검증 방법 | 이 항목을 건수·합계·해시 중 무엇으로 대조하는가 |
| 확인자 | 이 규칙을 승인한 업무 담당자와 확인 일자 |
마지막 확인자 항목이 중요합니다. 나중에 "왜 이렇게 됐냐"는 질문이 나왔을 때 근거가 됩니다.
검증 방법을 규칙과 같은 줄에 적어두는 이유는, 변환 규칙이 바뀌면 대조 쿼리도 함께 바뀌어야 하기 때문입니다. 따로 관리하면 규칙만 고치고 검증은 옛 규칙으로 돌리는 일이 생깁니다.
코드 매핑표를 별도로 만듭니다
상태값, 분류 코드처럼 대응 관계가 필요한 것은 표로 관리합니다.
기존 코드 → 새 코드 비고
01 (접수) → RECEIVED
02 (처리중) → PROCESSING
03 (완료) → COMPLETED
04 (보류) → ON_HOLD
99 (기타) → ? 업무 확인 필요
대응되지 않는 값을 물음표로 남겨두고 하나씩 채워가는 방식이 진행 상황을 보기에 좋습니다.
문자셋과 자릿수를 확인합니다
DBMS가 바뀌는 이관에서는 같은 값이 다르게 저장됩니다. 아래는 각 제품의 공식 문서에서 확인한 동작이며, 버전이 다르면 해당 버전의 문서를 다시 확인해야 합니다.
한글 인코딩. 오래된 시스템은 EUC-KR이나 CP949(MS949)로 저장된 경우가 많고, 새 시스템은 대개 UTF-8입니다. 둘은 같은 것이 아닙니다. CP949에는 EUC-KR에 없는 한글 음절이 들어 있어서, CP949 데이터를 EUC-KR이라고 선언하고 변환하면 일부 글자에서 실패합니다. PostgreSQL 17에서 확인해 보면 똠 한 글자는 UHC(CP949)로는 변환되지만 EUC_KR로는 대응 문자가 없다는 오류가 납니다. 사람 이름과 상호에 이런 글자가 섞여 있으므로, 원본이 EUC-KR이라고 전달받았더라도 CP949로 읽어 보는 편이 안전합니다.
PostgreSQL은 EUC_KR을 서버 인코딩으로 쓸 수 있지만 UHC는 클라이언트 인코딩으로만 지원하고, 변환할 수 없는 문자를 만나면 오류를 냅니다.(PostgreSQL 문서, Character Set Support) 그래서 CP949 파일은 인코딩을 명시해서 UTF-8 데이터베이스에 적재합니다.
-- psql 에서 실행. CP949 로 저장된 CSV 를 UTF-8 데이터베이스의 스테이징 테이블에 적재
\copy legacy.member FROM 'member.csv' WITH (FORMAT csv, HEADER true, ENCODING 'UHC')
ENCODING을 생략하면 현재 클라이언트 인코딩으로 해석합니다.(PostgreSQL 문서, COPY) 변환 오류가 나는 행은 원본이 이미 깨져 있거나 선언된 문자셋과 실제 바이트가 다른 경우이므로, 건너뛰지 말고 목록으로 뽑아 원인을 확인합니다.
MySQL의 utf8. MySQL에서 utf8은 utf8mb3의 별칭이며 사용이 권장되지 않습니다. utf8mb3는 한 글자에 최대 3바이트만 쓰고 기본 다국어 평면(BMP) 문자만 지원하므로, 이모지 같은 보충 문자를 저장하지 못합니다. 대상이 MySQL이라면 utf8mb4로 만듭니다.(MySQL 8.4 문서, utf8mb3, utf8mb4) 원본이 utf8로 선언된 MySQL이라면 4바이트 문자는 애초에 들어 있지 않으므로, 이 경우의 위험은 과거 데이터가 아니라 새 시스템에서 들어올 입력 쪽에 있습니다.
Oracle의 빈 문자열. Oracle Database는 길이가 0인 문자 값을 널로 취급합니다.(Oracle Database 19c SQL Language Reference, Nulls) 따라서 Oracle에서 나온 데이터에는 빈 문자열이 없고 널만 있습니다. PostgreSQL이나 MySQL은 둘을 구분하므로, 이관 후 새 애플리케이션이 빈 문자열을 저장하기 시작하면 한 컬럼에 "값 없음"이 두 가지 형태로 섞입니다. IS NULL로 조회하던 화면과 통계가 조용히 어긋나는 원인이 됩니다. 반대 방향, 즉 Oracle로 들어가는 이관에서는 빈 문자열이 널이 되어 NOT NULL 제약에 걸립니다. 컬럼마다 값 없음을 무엇으로 통일할지 매핑 정의서에 적어 둡니다.
길이 단위. Oracle의 VARCHAR2(20)처럼 한정자 없이 선언한 컬럼은 세션의 NLS_LENGTH_SEMANTICS 값을 따르며, 그 기본값은 BYTE입니다.(Oracle Database 19c SQL Language Reference, Data Types, Globalization Support Guide) 반면 PostgreSQL의 varchar(n)은 바이트가 아니라 문자 수입니다.(PostgreSQL 문서, Character Types) 한글 한 글자는 CP949에서 2바이트, UTF-8에서 3바이트이므로 문제는 두 방향으로 생깁니다.
- 바이트 단위 컬럼의 문자셋만 UTF-8로 바꾸면, 20바이트에 한글 10자가 들어가던 컬럼에 6자까지만 들어갑니다. 선언 길이를 넘는 값이 나옵니다.
- 원본의 숫자를 그대로 가져와 문자 단위 컬럼을 만들면 반대로 여유가 생기지만, 새 시스템에서 길어진 값이 화면, 출력물, 다른 시스템으로 보내는 전문의 자릿수를 넘을 수 있습니다.
1장의 프로파일링에서 뽑은 최대 문자 수와 최대 바이트 수를 기준으로 대상 길이를 정합니다. 길이를 넘는 값을 만났을 때 잘라서 넣는 방식은 피합니다. PostgreSQL은 길이를 넘는 값을 넣으면 오류를 내지만, varchar(n)으로 명시적 형 변환을 하면 오류 없이 잘라냅니다.(위 Character Types 문서) 변환 스크립트에 이런 형 변환이 들어가 있으면 이름과 주소가 잘린 채로 건수 검증을 통과합니다.
같은 문서에 따르면 PostgreSQL은 코드 0인 문자(NUL)를 문자열에 저장하지 못합니다. 고정 길이 전문을 그대로 넣어 둔 오래된 컬럼에서 이 문자가 발견되면 적재 전에 제거 규칙을 정합니다.
날짜와 시간대를 확인합니다
날짜는 형식만 맞추면 된다고 생각하기 쉽지만, 자료형의 의미가 제품마다 다릅니다.
| 원본에서 확인할 것 | 확인된 동작 | 이관할 때 정할 것 |
|---|---|---|
Oracle DATE |
연월일뿐 아니라 시·분·초까지 저장하며, 소수 초와 시간대는 없음 | 대상을 날짜형으로 받으면 시각이 사라짐. 시각이 업무에 쓰이는지 확인 |
MySQL TIMESTAMP |
저장할 때 현재 시간대에서 UTC로, 조회할 때 다시 현재 시간대로 변환. DATETIME은 변환하지 않음. 범위는 UTC 기준 1970-01-01 00:00:01 ~ 2038-01-19 03:14:07 |
추출하는 세션의 시간대에 따라 값이 달라짐. 추출 세션의 시간대를 고정 |
MySQL의 0000-00-00 |
SQL 모드에 따라 허용되는 "제로" 날짜. MySQL 8.4 기본 모드(엄격 모드와 NO_ZERO_DATE 포함)에서는 입력 시 오류 |
과거 설정에서 들어간 값이 남아 있을 수 있음. 널로 바꿀지 업무 확인 |
PostgreSQL timestamp with time zone |
내부적으로 UTC로 저장. 시간대 표기가 없는 입력은 세션의 TimeZone 설정 기준으로 해석 |
적재 세션의 시간대를 명시 |
(Oracle Database 19c SQL Language Reference, Data Types, MySQL 8.4 문서, DATE·DATETIME·TIMESTAMP, Server SQL Modes, PostgreSQL 문서, Date/Time Types)
레거시 데이터의 일시는 대부분 시간대 표기 없이 한국 시간으로 들어 있습니다. 이것을 UTC로 설정된 세션에서 timestamp with time zone 컬럼에 넣으면 9시간이 어긋나고, 자정 무렵의 건은 날짜가 하루 바뀝니다. PostgreSQL 17에서 확인한 결과입니다.
SET TIME ZONE 'UTC';
SELECT '2024-03-01 00:30:00'::timestamptz; -- 2024-03-01 00:30:00+00 (9시간 어긋남)
SET TIME ZONE 'Asia/Seoul';
SELECT '2024-03-01 00:30:00'::timestamptz; -- 2024-03-01 00:30:00+09 (의도한 값, UTC 로는 2월 29일 15:30)
적재 스크립트 첫 줄에서 세션 시간대를 명시하고, 서버나 접속 도구의 기본값에 맡기지 않습니다. PostgreSQL은 0000-00-00을 날짜로 받지 않으므로(17에서 범위 오류) 제로 날짜도 적재 전에 변환 규칙이 있어야 합니다.
숫자도 같은 방식으로 봅니다. Oracle NUMBER(p, s)는 정밀도를 넘는 값에는 오류를 내지만, 소수 자릿수를 넘는 값은 오류 없이 반올림합니다.(위 Data Types 문서) 대상 컬럼의 소수 자릿수가 원본보다 작으면 금액과 수량이 조용히 달라지므로, 숫자 항목은 합계 대조에서 빼지 않습니다.
되돌릴 수 있게 만듭니다
이관 스크립트는 여러 번 돌리게 됩니다. 처음부터 그것을 전제로 만들어야 합니다.
- 여러 번 실행해도 결과가 같아야 합니다
- 실행 전 상태로 되돌릴 수 있어야 합니다
- 일부만 다시 실행할 수 있어야 합니다
- 원본 데이터는 절대 수정하지 않습니다
원본 보호는 예외를 두지 않는 편이 좋습니다. 정제가 필요하면 복사본에서 하고, 원본은 읽기 전용으로 접근합니다.
원본 식별자를 남깁니다
새 시스템의 각 행에 기존 시스템에서의 식별자를 함께 저장해두면, 나중에 대조와 재이관이 쉬워집니다. 운영 중에 "이 건이 예전 시스템의 어느 건인가"를 확인해야 하는 상황도 생깁니다.
원본 식별자에 유일 제약을 걸어 두면 "여러 번 실행해도 결과가 같다"는 조건을 스크립트가 아니라 데이터베이스가 보장합니다. 아래는 PostgreSQL 기준 예시입니다(17에서 실행해 확인). app.orders.legacy_id에 유일 제약이 있다고 가정합니다.
INSERT INTO app.orders (legacy_id, member_id, ordered_on, amount, status)
SELECT o.order_id, m.id, o.order_date, o.amount,
CASE o.status WHEN '01' THEN 'RECEIVED' WHEN '02' THEN 'PROCESSING' END
FROM legacy.orders o
JOIN app.member m ON m.legacy_id = o.member_id
ON CONFLICT (legacy_id) DO UPDATE
SET member_id = EXCLUDED.member_id,
ordered_on = EXCLUDED.ordered_on,
amount = EXCLUDED.amount,
status = EXCLUDED.status;
같은 문장을 두 번 실행해도 행이 늘지 않고, 변환 규칙을 고친 뒤 다시 돌리면 기존 행이 새 규칙으로 갱신됩니다. 주의할 점이 두 가지 있습니다. 이 예시에서 매핑표에 없는 상태값은 CASE가 널을 돌려주므로, 대상 컬럼에 NOT NULL 제약이 있어야 조용히 들어가지 않고 오류로 드러납니다. 그리고 JOIN에서 탈락한 주문(회원이 없는 고아 레코드)은 아무 흔적 없이 빠지므로, 4장에서 다루는 실패 목록에 따로 기록해야 합니다.
4. 검증이 이관의 절반입니다
옮기는 것보다 제대로 옮겨졌는지 확인하는 것이 어렵습니다.
단계별로 확인합니다
| 단계 | 확인 내용 |
|---|---|
| 건수 | 원본 건수 = 이관 건수 + 제외 건수 |
| 합계 | 금액·수량 등 숫자 항목의 총합 일치 |
| 표본 | 무작위로 뽑은 건의 항목별 대조 |
| 경계 | 가장 오래된 건, 최신 건, 값이 특이한 건 |
| 관계 | 연결된 데이터가 함께 옮겨졌는지 |
| 업무 | 실제 업무 담당자가 화면에서 확인 |
건수만 맞추고 끝내면 안 됩니다. 건수는 맞는데 특정 컬럼이 전부 비어 있는 경우가 실제로 발생합니다.
대사 쿼리를 미리 만들어 둡니다
건수, 합계, 해시는 뒤로 갈수록 잡아내는 범위가 넓고 비용이 큽니다. 세 가지를 모두 쿼리로 만들어 예행 때마다 같은 것을 돌립니다. 아래는 원본 사본(legacy)과 대상(app)이 같은 PostgreSQL 데이터베이스에 있다고 가정한 예시이며, 17에서 실행해 확인했습니다.
-- 건수: source_rows = loaded_rows + rejected_rows 여야 합니다
SELECT (SELECT count(*) FROM legacy.orders) AS source_rows,
(SELECT count(*) FROM app.orders) AS loaded_rows,
(SELECT count(*) FROM app.migration_reject
WHERE run_id = 'rehearsal-2' AND source_table = 'orders') AS rejected_rows;
-- 합계: 월별 건수와 금액 합이 다른 달만 출력합니다. 결과가 0행이면 일치입니다
SELECT coalesce(s.month, t.month) AS month,
s.row_count AS source_rows, t.row_count AS target_rows,
s.amount_sum AS source_sum, t.amount_sum AS target_sum
FROM (SELECT to_char(order_date, 'YYYY-MM') AS month,
count(*) AS row_count, sum(amount) AS amount_sum
FROM legacy.orders GROUP BY 1) s
FULL OUTER JOIN
(SELECT to_char(ordered_on, 'YYYY-MM') AS month,
count(*) AS row_count, sum(amount) AS amount_sum
FROM app.orders GROUP BY 1) t ON t.month = s.month
WHERE s.row_count IS DISTINCT FROM t.row_count
OR s.amount_sum IS DISTINCT FROM t.amount_sum
ORDER BY 1;
합계를 전체 한 줄로만 비교하면 어디서 틀렸는지 알 수 없습니다. 월, 지점, 상태처럼 업무에서 쓰는 단위로 쪼개서 비교해야 차이가 난 구간을 바로 좁힐 수 있습니다. 제외했거나 실패한 건이 있으면 그 달의 건수와 합계가 다르게 나오는 것이 정상이므로, 차이가 실패 목록의 건수·금액과 맞아떨어지는지까지 확인합니다. IS DISTINCT FROM을 쓴 것은 한쪽에만 있는 달(널)도 불일치로 잡기 위해서입니다.
해시 대조는 행 단위로 항목값을 이어 붙여 해시를 만들고 양쪽을 비교합니다.
-- 해시: 항목값이 하나라도 다른 주문의 원본 식별자를 출력합니다
SELECT s.order_id
FROM (SELECT order_id,
md5(concat_ws('|', to_char(order_date, 'YYYY-MM-DD'), amount::text,
coalesce(status, ''))) AS row_hash
FROM legacy.orders) s
JOIN (SELECT legacy_id,
md5(concat_ws('|', to_char(ordered_on, 'YYYY-MM-DD'), amount::text,
CASE status WHEN 'RECEIVED' THEN '01'
WHEN 'PROCESSING' THEN '02' ELSE '' END)) AS row_hash
FROM app.orders) t ON t.legacy_id = s.order_id
WHERE s.row_hash <> t.row_hash;
해시 대조에서 지켜야 할 것들입니다.
- 양쪽을 같은 표현으로 맞춘 뒤 해시합니다. 코드가 바뀐 항목은 한쪽을 역변환하고, 날짜와 숫자는 형식을 고정해 문자열로 만듭니다. 그대로 두면 값은 같은데 표기가 달라 전부 불일치로 나옵니다.
- 널을 명시적으로 치환합니다.
concat_ws는 널인 인자를 건너뛰므로, 널이 될 수 있는 항목은coalesce로 자리를 채우지 않으면 서로 다른 행이 같은 해시를 낼 수 있습니다. - 해시는 변환 규칙의 오류를 잡지 못합니다. 대조 쿼리가 이관 스크립트와 같은 매핑을 쓰기 때문입니다. 매핑 자체가 틀렸는지는 업무 담당자 확인으로만 드러납니다.
- 원본과 대상의 DBMS가 다르면 함수 이름뿐 아니라 숫자·날짜를 문자열로 바꾸는 규칙, 후행 공백 처리가 달라 같은 값도 다른 해시가 나옵니다. 이 경우 양쪽에서 정해진 형식의 문자열로 내려받아 한곳에서 해시를 계산하는 방식이 덜 틀립니다.
건수가 많으면 전체 행을 비교하는 대신 원본 식별자 구간별로 해시를 묶어 먼저 비교하고, 어긋난 구간만 행 단위로 내려갑니다.
업무 담당자 확인이 필요합니다
기술적 검증으로는 잡히지 않는 것이 있습니다. 값은 옮겨졌는데 의미가 달라진 경우입니다.
- 코드 매핑이 잘못되어 상태가 다르게 표시됨
- 날짜가 시간대 차이로 하루 어긋남
- 금액의 단위가 달라짐
- 이름·주소가 잘렸는데 그럴듯해 보임
업무를 아는 사람이 익숙한 데이터를 직접 봐야 발견됩니다. 검증 일정에 업무 담당자의 시간을 확보해두어야 합니다. 이 시간을 잡지 않아서 검증이 형식적으로 끝나는 경우가 많습니다.
오류를 버리지 않고 모읍니다
변환 중에 규칙에 맞지 않는 데이터를 만나면, 그 건을 어떻게 할지 정해져 있어야 합니다.
- 전체를 중단할 것인가, 해당 건만 건너뛸 것인가
- 건너뛴 건을 어디에 기록할 것인가
- 원본 데이터와 실패 사유를 함께 남기는가
- 수정 후 그 건만 다시 처리할 수 있는가
조용히 건너뛰는 것이 가장 위험합니다. 이관은 끝났는데 몇천 건이 사라진 것을 몇 달 뒤에 발견하는 상황이 생깁니다. 실패 건은 반드시 목록으로 남기고, 그 수가 0이 될 때까지 처리하거나 제외 사유를 명시해야 합니다.
실패 목록은 로그 파일보다 테이블로 두는 편이 다루기 쉽습니다. 사유별로 집계할 수 있고, 건수 대사 쿼리에 그대로 쓸 수 있기 때문입니다. PostgreSQL 기준 예시입니다.
CREATE TABLE app.migration_reject (
reject_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id text NOT NULL, -- 몇 번째 예행인지
source_table text NOT NULL,
source_key text NOT NULL, -- 원본 식별자
reason text NOT NULL, -- 사유 분류
source_row jsonb NOT NULL, -- 원본 행 전체
created_at timestamptz NOT NULL DEFAULT now()
);
-- 회원이 없어 적재에서 빠지는 주문을 기록합니다
INSERT INTO app.migration_reject (run_id, source_table, source_key, reason, source_row)
SELECT 'rehearsal-2', 'orders', o.order_id::text, '회원 없음', to_jsonb(o)
FROM legacy.orders o
WHERE NOT EXISTS (SELECT 1 FROM legacy.member m WHERE m.member_id = o.member_id);
원본 행 전체를 함께 남기므로 이 테이블에도 개인정보가 들어갑니다. 접근 권한을 운영 테이블과 같은 수준으로 관리하고, 이관이 끝난 뒤 보관 기간과 파기 시점을 정해둡니다.
PostgreSQL 17부터 COPY에 ON_ERROR ignore 옵션이 생겨 오류가 난 행을 버리고 계속 적재할 수 있고, 18부터는 REJECT_LIMIT으로 허용 건수의 상한을 둘 수 있습니다.(PostgreSQL 17 릴리스 노트, PostgreSQL 문서, COPY) 다만 이 옵션이 다루는 것은 입력값을 자료형으로 바꾸다 나는 오류뿐이고, 버려진 행은 건수(LOG_VERBOSITY verbose를 주면 줄 번호와 컬럼 이름까지)가 알림으로 나올 뿐 어디에도 저장되지 않습니다. 앞에서 말한 "조용히 건너뛰기"에 가까운 동작이므로 이관 본 적재에는 권하지 않습니다. 모든 컬럼을 문자열로 둔 스테이징 테이블에 일단 전부 넣고, 변환 가능 여부를 SQL로 판정해 실패 목록에 남기는 쪽이 추적하기 좋습니다.
대용량은 처리 방식이 달라집니다
건수가 많아지면 단순한 방법으로는 시간 안에 끝나지 않습니다.
- 한 건씩 처리하지 않고 묶어서 처리합니다
- 적재 중에는 인덱스와 제약을 잠시 해제하고, 끝난 뒤 다시 거는 방식을 검토합니다
- 중간에 실패해도 처음부터 다시 하지 않도록 구간을 나눠 진행 상태를 기록합니다
- 메모리에 전부 올리지 않고 흘려보내며 처리합니다
중간 지점부터 재개할 수 있는 구조가 특히 중요합니다. 여섯 시간 걸리는 작업이 다섯 시간째에 실패했을 때 처음부터 다시 해야 한다면, 그날 전환은 실패합니다.
대상이 PostgreSQL일 때 공식 문서가 권하는 대량 적재 방법과, 각각에 따라붙는 주의점은 다음과 같습니다.(PostgreSQL 문서, Populating a Database)
| 방법 | 효과 | 주의할 점 |
|---|---|---|
INSERT 반복 대신 COPY 사용 |
한 명령으로 전체 행을 적재 | 기본 동작은 첫 오류에서 명령 전체가 실패. 실패 전까지 들어간 행은 보이지 않지만 디스크 공간을 차지하므로 VACUUM으로 회수 |
| 인덱스를 적재 후에 생성 | 행마다 인덱스를 갱신하지 않음 | 유일 인덱스를 빼면 그동안 중복 검사가 사라짐 |
| 외래키를 적재 후에 다시 설정 | 행 단위 대신 일괄 검사 | 제약이 없는 동안 잘못된 참조가 들어갈 수 있음. 다시 걸 때 위반이 있으면 실패 |
maintenance_work_mem 상향 |
인덱스 생성과 외래키 추가가 빨라짐 | COPY 자체에는 큰 효과가 없음 |
max_wal_size 일시 상향 |
적재 중 체크포인트 횟수 감소 | 적재 후 원래 값으로 복구 |
적재 후 ANALYZE |
실행 계획이 새 데이터 분포를 반영 | 빠뜨리면 개시 직후 조회가 느려질 수 있음 |
COPY에 서버의 파일 경로를 주면 데이터베이스 서버가 그 파일을 직접 읽으므로 서버 쪽 파일 접근 권한이 필요합니다. 작업자 PC나 배치 서버에 있는 파일은 psql의 \copy로 올립니다.(위 COPY 문서)
제약을 빼고 적재했다면 다시 거는 단계가 곧 검증입니다. 유일 인덱스나 외래키를 만들다 실패하면 중복이나 고아 레코드가 들어갔다는 뜻이므로, 이 단계의 소요 시간과 실패했을 때의 처리 절차도 예행에서 확인합니다.
식별자를 원본 값 그대로 넣는 경우에는 하나가 더 있습니다. PostgreSQL에서 식별자 컬럼에 값을 직접 넣으면 시퀀스는 올라가지 않습니다. 적재 후 시퀀스를 맞추지 않으면 개시 후 첫 등록에서 기본키 중복 오류가 납니다(17에서 재현해 확인).
-- 적재가 끝난 뒤 테이블마다 실행합니다
SELECT setval(pg_get_serial_sequence('app.member', 'id'),
(SELECT max(id) FROM app.member));
테이블이 비어 있으면 max(id)가 널이어서 시퀀스는 바뀌지 않고 그대로 유지됩니다.
여러 번 연습합니다
전환 당일에 처음 해보는 상황은 피해야 합니다.
- 전체 데이터로 최소 2~3회 예행합니다
- 매번 소요 시간을 측정합니다
- 발견된 문제를 정리하고 규칙을 고칩니다
- 마지막 예행은 실제와 같은 조건에서 합니다
소요 시간 측정이 특히 중요합니다. 일부 데이터로 시험하고 비례 계산하면 대체로 빗나갑니다. 데이터가 많아질수록 느려지는 구간이 있기 때문입니다. 전체 데이터로 재본 시간이 있어야 전환 계획을 세울 수 있습니다.
5. 전환 당일을 절차로 만듭니다
전환은 되돌리기 어려운 작업입니다. 그날 판단할 것을 최소로 줄여야 합니다.
시간표를 만듭니다
① 기존 시스템 사용 중단 공지 및 차단
② 최종 백업
③ 데이터 추출
④ 변환 및 적재
⑤ 검증 (건수·합계·표본)
⑥ 업무 담당자 확인
⑦ 서비스 개시 판단
⑧ 신규 시스템 개방
각 단계에 예상 소요 시간과 담당자를 적습니다. 예행에서 측정한 값을 씁니다.
단계마다 완료 기준을 적습니다
시간표만으로는 "이 단계가 끝났다"를 판단할 수 없습니다. 단계마다 무엇을 실행하고 무엇을 확인하면 다음으로 넘어가는지를 적어 둔 문서(런북)가 있어야, 새벽에 담당자가 바뀌어도 같은 기준으로 진행됩니다.
| 단계 | 실행할 것 | 다음 단계로 넘어가는 조건 |
|---|---|---|
| ① 차단 | 사용자 접속 차단, 배치 작업 중지, 다른 시스템과의 연계 중지 | 원본의 최종 변경 시각이 차단 시각 이후로 늘지 않음 |
| ② 최종 백업 | 원본 전체 백업, 백업 파일 보관 위치 기록 | 백업 완료와 파일 크기 확인. 복원 절차는 예행에서 검증된 것 |
| ③ 추출 | 테이블별 추출, 첨부 파일 차이분 동기화 | 테이블별 추출 건수를 기록하고 원본 건수와 일치 |
| ④ 변환·적재 | 정해진 순서로 적재, 구간별 진행 상태 기록 | 모든 구간 완료, 실패 목록 건수 확정 |
| ④-1 마무리 | 인덱스·제약 재생성, 시퀀스 보정, 통계 갱신 | 제약 생성이 오류 없이 끝남 |
| ⑤ 검증 | 건수·합계·해시 대사 쿼리 실행 | 불일치가 없거나, 모두 실패 목록으로 설명됨 |
| ⑥ 업무 확인 | 업무 담당자가 미리 정한 확인 시나리오 수행 | 필수 시나리오 전부 통과 |
| ⑦ 개시 판단 | 결정권자가 기준표에 따라 판정 | 계속 또는 되돌림 결정, 시각 기록 |
| ⑧ 개방 | 새 시스템 접속 허용, 구 시스템 조회 전용 전환, 연계 재개 | 첫 실제 거래의 정상 처리 확인 |
①에서 배치와 연계가 자주 빠집니다. 화면 접속은 막았는데 야간 배치나 외부 시스템에서 들어오는 연계가 돌면서 추출 이후에 원본이 바뀌면, 그 변경분은 어느 대사에도 걸리지 않고 사라집니다. 차단 대상 목록을 예행 전에 만들고, 연계 상대 기관에는 중단 일시를 미리 알립니다.
각 단계의 실제 시작·종료 시각을 그 자리에서 기록합니다. 예상보다 늦어지고 있다는 사실을 일찍 알아야 마지막 결정 시각 전에 판단할 수 있습니다.
중단 판단 기준을 미리 정합니다
전환 중에 문제가 발견되면 계속할지 되돌릴지 결정해야 합니다. 그 순간에 정하려 하면 판단이 흐려집니다.
- 어떤 문제가 발견되면 중단하는가
- 누가 중단을 결정하는가
- 되돌리는 절차는 무엇인가
- 되돌리는 데 얼마나 걸리는가
- 언제까지 결정해야 하는가 (마지막 시각)
마지막 결정 시각을 정해두는 것이 실무적으로 유용합니다. "오전 6시까지 검증이 끝나지 않으면 되돌린다"처럼 정해두면, 시간에 쫓기며 무리하게 강행하는 상황을 막을 수 있습니다.
판단 기준은 문장이 아니라 표로 만들어 결정권자의 승인을 받아 둡니다. 아래는 구성 예시이고, 실제 기준은 업무 특성에 맞춰 발주 측과 합의합니다.
| 항목 | 계속 진행 | 되돌림 |
|---|---|---|
| 건수 대사 | 원본 = 적재 + 제외 + 실패가 성립 | 설명되지 않는 차이가 있음 |
| 합계 대사 | 금액·수량 합계가 일치하거나 실패 목록으로 설명됨 | 원인을 모르는 불일치가 있음 |
| 실패 건 | 예행에서 확인되어 처리 방침이 정해진 유형뿐임 | 처음 보는 유형이 나왔거나 핵심 테이블에서 발생 |
| 업무 확인 | 필수 시나리오 전부 통과 | 필수 시나리오 중 하나라도 실패 |
| 시간 | 마지막 결정 시각 전에 검증 완료 | 결정 시각까지 검증이 끝나지 않음 |
되돌릴 수 있는 시점에는 끝이 있습니다. 새 시스템을 개방하기 전이라면 원본을 건드리지 않았으므로 구 시스템의 차단을 푸는 것으로 되돌릴 수 있습니다. 개방한 뒤에는 새 시스템에 새로운 데이터가 쌓이기 시작하고, 그때부터 되돌리려면 그 데이터를 구 시스템으로 다시 옮기거나 손으로 재입력해야 합니다. 그래서 다음 두 가지를 전환 전에 정합니다.
- 개방 후 몇 시간(또는 며칠)까지를 되돌림 가능 구간으로 볼 것인지, 그 구간에 들어온 데이터를 어떻게 구 시스템에 반영할 것인지
- 그 구간이 지난 뒤에 발견된 문제는 되돌리지 않고 새 시스템에서 고친다는 원칙
마지막 결정 시각은 업무 시작 시각에서 되돌리는 데 걸리는 시간을 뺀 값보다 늦을 수 없습니다. 되돌리는 절차도 예행에서 한 번은 실제로 해보고 시간을 재야 이 계산이 성립합니다.
사용자에게 미리 알립니다
기술 작업과 별개로, 시스템을 쓰는 사람들에게 알리는 절차가 필요합니다.
- 중단 일시와 소요 시간 예고
- 중단 기간에 업무를 어떻게 처리할지 안내
- 전환 후 달라지는 화면과 절차
- 과거 자료를 어디서 볼 수 있는지
- 문제 발생 시 연락처
중단 기간의 업무 처리 방법이 자주 빠집니다. 그 사이에 들어오는 주문이나 접수를 종이로 받아둘 것인지, 아예 받지 않을 것인지 현업과 합의해야 합니다. 정해두지 않으면 전환 직후 밀린 건을 수작업으로 입력하는 일이 생깁니다.
전환 일정을 잡을 때는 업무 성수기와 마감 시점을 피해야 합니다. 월말 정산 기간이나 연말에 전환하는 것은 피하는 편이 안전합니다.
개시 이후 며칠을 계획에 넣습니다
전환이 끝나도 바로 안정되지 않습니다.
- 초기에 집중적으로 들어오는 문의를 받을 인원
- 데이터 관련 오류를 즉시 수정할 수 있는 대기 체제
- 기존 시스템 조회 경로 안내
- 며칠간의 데이터 상태 점검
이 기간을 계약과 일정에 넣어두지 않으면, 전환 직후 인력이 빠져 대응이 늦어집니다.
6. 자주 나오는 질문
이관 공수는 어떻게 산정합니까
건수보다 대상 테이블 수와 변환 규칙의 복잡도에 비례합니다. 산정에 쓰는 요소입니다.
- 이관 대상 테이블 수
- 단순 복사로 끝나는 비율
- 코드 매핑이 필요한 항목 수
- 구조가 달라 재설계가 필요한 영역
- 정제가 필요한 데이터의 비율
- 검증과 예행 횟수
사전 조사 없이 산정해야 한다면, 조사 후 재산정을 전제로 한 개략 금액임을 명시하는 것이 맞습니다.
데이터 정제는 누가 합니까
원칙적으로 데이터 소유자의 일입니다. 어떤 값이 맞는지 판단할 수 있는 사람은 업무 담당자입니다.
다만 실무에서는 수행사가 정제 규칙을 제안하고 담당자가 확인하는 방식으로 진행됩니다. 이때 정리해둘 것이 있습니다.
- 자동으로 정제할 수 있는 유형과 규칙
- 사람이 하나씩 판단해야 하는 건수
- 판단에 필요한 담당자 시간
사람이 판단해야 하는 건수가 많으면 그 자체로 일정 요소가 됩니다. 수천 건을 확인해야 한다면 몇 주가 걸립니다.
기존 시스템을 멈출 수 없다면 어떻게 합니까
무중단 전환은 가능하지만 난이도와 비용이 올라갑니다. 방식은 대체로 이렇습니다.
- 과거 데이터를 미리 옮겨둡니다
- 두 시스템이 동시에 돌아가는 기간을 둡니다
- 그 사이 발생한 변경을 계속 따라잡습니다
- 시점을 정해 신규 입력을 새 시스템으로 돌립니다
이 방식에서는 양쪽 데이터가 어긋나는 구간이 반드시 생깁니다. 대사 절차와 불일치 처리 규칙이 함께 필요합니다.
정말 멈출 수 없는지는 다시 확인해볼 만합니다. 새벽 몇 시간이라면 허용되는 경우가 적지 않고, 그 경우 난이도가 크게 내려갑니다.
기존 시스템 담당자의 협조를 못 받으면 어떻게 합니까
교체되는 시스템을 만든 업체가 협조에 소극적인 경우가 있습니다. 실제로 드물지 않습니다.
이럴 때 확인할 것들입니다.
- 계약상 데이터 제공 의무가 어디까지 명시되어 있는지
- 데이터베이스 접근 권한을 직접 받을 수 있는지
- 전체 백업 파일 형태로 받을 수 있는지
- 시스템 화면의 내려받기 기능으로 얻을 수 있는 범위
발주 측이 자기 데이터에 접근할 권리는 계약에 명시되어 있는 것이 보통입니다. 기존 계약서를 먼저 확인하시는 것이 좋습니다.
문서나 설명을 받지 못하면 데이터만 보고 구조를 추정해야 하고, 그만큼 조사 기간과 오류 가능성이 늘어납니다. 이 상황이 예상되면 사전 조사 기간을 더 길게 잡아야 합니다.
개인정보가 포함된 데이터는 어떻게 다룹니까
이관 과정에서도 보호 조치가 필요합니다.
- 추출한 데이터 파일의 보관 위치와 접근 권한
- 작업 완료 후 중간 파일 파기
- 개발·시험 환경에서 실데이터를 쓸 것인지, 쓴다면 어떤 조치를 할 것인지
- 이관 작업 수행자의 접근 기록
시험 환경에서 실데이터를 그대로 쓰는 것이 관행적으로 이루어지는 경우가 있는데, 위험이 큽니다. 가명·익명 처리한 데이터로 시험하고, 실데이터 검증은 통제된 환경에서 제한된 인원이 수행하는 편이 안전합니다.
아래는 「개인정보 보호법」과 같은 법 시행령(2026년 9월 11일 시행본)에서 확인한 조문을 이관 작업에 대응시킨 것입니다.(개인정보 보호법, 개인정보 보호법 시행령)
| 이관에서 생기는 일 | 관련 조문 | 실무에서 할 일 |
|---|---|---|
| 수행사가 발주 측의 개인정보를 받아 작업 | 법 제26조: 처리 업무 위탁은 목적 외 처리 금지, 기술적·관리적 보호조치 등을 담은 문서로 하고, 수탁자는 위탁받은 업무 범위를 넘어 이용하거나 제3자에게 제공할 수 없음. 재위탁에는 위탁자의 동의 필요 | 이관 작업이 위탁 문서의 범위에 들어 있는지 확인. 하도급 업체가 데이터를 다루면 발주 측 동의를 먼저 받음 |
| 개발·시험 환경에 넣을 데이터 준비 | 법 제3조 제7항: 익명 처리로 목적을 달성할 수 있으면 익명으로, 그렇지 않으면 가명으로 처리될 수 있도록 하여야 함 | 기능 시험은 비식별 처리한 데이터로 수행. 실데이터가 꼭 필요한 검증은 대상과 인원, 환경을 한정 |
| 보유기간이 지난 데이터 발견 | 법 제21조: 불필요해진 개인정보는 지체 없이 파기. 다른 법령에 따라 보존하는 경우 분리 저장·관리 | 2장의 범위 결정에서 파기 대상과 분리 보관 대상을 구분 |
| 추출 파일, 스테이징 테이블, 실패 목록 정리 | 법 제21조 제2항, 시행령 제16조: 전자적 파일은 복원이 불가능한 방법으로 영구 삭제 | 중간 산출물의 위치를 목록으로 관리하고, 종료 시 파기한 뒤 확인서를 남김 |
| 작업 기간의 접근 통제와 기록 | 법 제29조: 접속기록 보관 등 안전성 확보에 필요한 기술적·관리적·물리적 조치 | 이관용 계정을 따로 발급하고 종료 후 회수. 작업자 PC로 파일을 내려받지 않음 |
비식별 처리를 했다고 해서 곧바로 개인정보가 아닌 것이 되지는 않습니다. 이름만 바꾸고 전화번호나 주소를 그대로 두면 여전히 특정 개인을 알아볼 수 있습니다. 어느 항목을 어떻게 바꿀지는 발주 측 개인정보 보호 담당자와 함께 정하고, 처리 결과를 확인받은 뒤 시험 환경에 넣습니다.
중간 산출물은 생각보다 여러 곳에 남습니다. 추출 파일, 스테이징 스키마, 실패 목록 테이블, 예행 때마다 만든 대상 데이터베이스 사본, 검증하려고 내려받은 엑셀 파일이 모두 해당합니다. 만들 때마다 목록에 적어 두지 않으면 종료 시점에 전부 찾아내기 어렵습니다.
위 표는 조문을 작업에 대응시킨 정리이며 법률 해석이 아닙니다. 접속기록 보관 기간 같은 세부 기준은 시행령과 고시에서 따로 정하고 있고 개정도 잦으므로, 프로젝트 시점의 원문과 발주 측 내부 관리계획을 함께 확인해야 합니다.
7. 착수 전 점검 목록
사전 조사
- 실제 데이터를 열어 확인했는가
- 컬럼별 널 비율과 고유값을 확인했는가
- 정의서에 없는 코드값이 있는지 확인했는가
- 외래키 관계가 깨진 건수를 파악했는가
- 첨부 파일의 용량과 유실 여부를 확인했는가
- 원본의 실제 문자셋(EUC-KR인지 CP949인지)과 컬럼별 최대 길이를 확인했는가
- 조사 결과를 근거로 범위와 공수를 재산정했는가
범위 결정
- 이관하지 않을 대상이 정의되어 있는가
- 제외 데이터의 처리 방법이 정해져 있는가
- 보유기간이 지난 개인정보를 파기 대상으로 분류했는가
- 구 시스템 조회 유지 기간이 합의되어 있는가
- 기준 시점과 이후 데이터 처리 방식이 정해져 있는가
- 기존 시스템을 멈출 수 있는 시간을 확인했는가
변환 규칙
- 항목별 변환 규칙이 문서로 있는가
- 각 규칙에 업무 담당자 확인이 있는가
- 코드 매핑표에 빈칸이 없는가
- 널과 빈 문자열, 길이 단위, 시간대 처리 기준이 정의서에 있는가
- 여러 번 실행해도 안전한가
- 원본을 수정하지 않는가
- 원본 식별자를 함께 저장하는가
검증
- 건수·합계·표본·경계 확인 방법이 정해져 있는가
- 대사 쿼리가 스크립트로 준비되어 예행마다 같은 것을 돌리는가
- 실패 건이 원본 행, 사유와 함께 목록으로 남는가
- 업무 담당자 확인 시간이 일정에 있는가
- 전체 데이터로 예행했는가
- 소요 시간을 측정했는가
전환
- 단계별 시간표와 담당자가 있는가
- 단계마다 다음으로 넘어가는 조건이 적혀 있는가
- 차단할 배치 작업과 연계 목록이 있는가
- 중단 판단 기준과 결정권자가 정해져 있는가
- 되돌리는 절차를 예행에서 실제로 해보고 소요 시간을 쟀는가
- 개방 후 되돌림 가능 구간과 그 이후의 원칙이 정해져 있는가
- 개시 이후 대응 인력이 계획에 있는가
- 개인정보 취급 절차가 정해져 있는가
- 추출 파일·스테이징 등 중간 산출물의 목록과 파기 계획이 있는가
마치며
데이터 이관에서 가장 큰 위험은 기술적 난이도가 아니라 모르는 상태로 시작하는 것입니다. 실제 데이터를 열어보기 전까지는 무엇이 들어 있는지 알 수 없고, 알 수 없는 것은 산정할 수도 계획할 수도 없습니다.
그래서 순서가 중요합니다. 데이터를 먼저 조사하고, 그 결과로 범위와 공수를 정하고, 변환 규칙을 문서로 합의하고, 여러 번 예행하고, 전환 당일은 정해진 절차를 따르는 것입니다.
조사 단계를 건너뛰면 그 비용은 사라지지 않고 프로젝트 후반에 더 큰 형태로 돌아옵니다.
저희는 기존 시스템을 대체하는 구축 사업에서 데이터 이관을 여러 차례 수행해왔습니다. 시스템 교체를 검토하고 계시다면, 보유 데이터의 상태를 확인하는 단계부터 함께 시작하시면 좋겠습니다.
함께 보면 좋은 글
- SI 프로젝트에서 범위가 불어나는 구조와 이를 막는 변경관리 설계
- 외부 시스템 연동에서 장애가 생기는 자리 — 인터페이스 설계 원칙
- 문서 보안 요구사항을 만족하는 시스템 설계 — 접근통제·워터마크·감사로그
참고 자료
- PostgreSQL 문서, COPY
- PostgreSQL 문서, Populating a Database
- PostgreSQL 문서, Character Set Support
- PostgreSQL 문서, Character Types
- PostgreSQL 문서, Date/Time Types
- PostgreSQL 17 릴리스 노트
- MySQL 8.4 문서, The utf8mb3 Character Set
- MySQL 8.4 문서, The utf8mb4 Character Set
- MySQL 8.4 문서, The DATE, DATETIME, and TIMESTAMP Types
- MySQL 8.4 문서, Server SQL Modes
- Oracle Database 19c SQL Language Reference, Nulls
- Oracle Database 19c SQL Language Reference, Data Types
- Oracle Database 19c Globalization Support Guide, Choosing a Character Set
- 개인정보 보호법
- 개인정보 보호법 시행령

