Oracle에서 PostgreSQL로 이행
Oracle 데이터 사전으로 타깃 구조를 만들고 CSV와 psql로 데이터를 옮기는 실습 흐름 및 타입, 제약, 시퀀스의 범위
#핵심 요약
실습에서는 Oracle 데이터 사전 조회 SQL로 PostgreSQL용 테이블, 적재, 제약조건, 시퀀스 생성문을 출력함
SQL*Plus CSV 추출 후 psql의 \copy로 데이터를 적재함
테이블과 데이터 이동, 함수, 뷰, 트리거, 프로시저의 재작성은 별도 단계임
#개념과 동작 원리
| Oracle 타입 | 실습 생성기의 PostgreSQL 매핑 |
|---|---|
| 정밀도 없는 NUMBER | NUMERIC |
| 정밀도, 스케일 지정 NUMBER | NUMERIC의 정밀도, 스케일 지정 |
| VARCHAR2, NVARCHAR2 | 문자 길이 기준 VARCHAR |
| CHAR, NCHAR | CHAR |
| DATE | 시, 분, 초 보존을 위한 TIMESTAMP(0) |
| TIMESTAMP 계열 | TIMESTAMP 또는 시간대 포함 형식에 TIMESTAMPTZ |
| FLOAT, CLOB, BLOB | DOUBLE PRECISION, TEXT, BYTEA |
| 그 외 | TEXT와 확인 필요 주석 |
이는 실습 생성기의 매핑 규칙이며 모든 Oracle 타입, 값에 대한 변환 보장은 아님
CSV의 LOB, 이진값, 시간대 직렬화와 타깃 해석까지 확인한 출력은 기재 없음
| 단계 | 실습의 처리 |
|---|---|
| 대상 확정 | USER_OBJECTS, USER_TABLES 조회, 실습 검증용 테이블 제외 |
| 테이블 구조 | USER_TAB_COLUMNS와 LISTAGG로 생성문 출력, 소문자 식별자 사용 |
| CSV 추출 | NLS_LANG, 클라이언트 인코딩, 날짜, 숫자 출력 형식, SQL*Plus CSV 설정 |
| 데이터 적재 | psql 클라이언트가 CSV를 읽는 copy 명령, 오류 시 중단 옵션 |
| 제약, 인덱스 | 적재 후 기본키, 유니크, 외래키, 일반 인덱스 적용 |
| 시퀀스 | LAST_NUMBER, 증가값으로 생성문 출력 후 테이블 최대값과 비교하고 setval로 보정 |
| 대조 | 실제 건수, 합계, 날짜 범위, 한글, 구조, 인덱스, 미이행 객체 확인 |
소스 AFTER 트리거를 아직 옮기지 않은 단계에서는 주문만 늘고 이력이 생기지 않는 예가 있음
BEFORE 트리거의 채번, 날짜 기본값을 컬럼 DEFAULT로 일부 대체한 예와 후속 트리거 재작성 예를 구분함
#필수 명령어 또는 SQL
#작업 환경과 출력 설정
export ORACLE_SID=<SOURCE_DB_NAME>
export NLS_LANG=AMERICAN_AMERICA.AL32UTF8
export PGPASSWORD=<PASSWORD>
export PGCLIENTENCODING=UTF8
mkdir -p /home/oracle/mig/pg/data
cd /home/oracle/mig/pg
which psql || ls /usr/pgsql-*/bin/psqlSET PAGESIZE 0
SET LINESIZE 32767
SET LONG 100000
SET HEADING OFF
SET FEEDBACK OFF
SET VERIFY OFF
SET ECHO OFF
SET TERMOUT OFF
SET TRIMSPOOL ON
SET TAB OFF결과 해석:
NLS_LANG이 빠지거나 문자셋이 다르면 한글 CSV 데이터가 깨질 수 있음
set_gen.sql은 머리글, 건수 메시지, 명령 표시를 빼고 생성된 SQL 문장만 파일에 남기기 위한 설정임
#1. 테이블 생성문
@/home/oracle/mig/pg/set_gen.sql
SPOOL /home/oracle/mig/pg/pg_01_tables.sql
SELECT 'DROP TABLE IF EXISTS ' || LOWER(t.table_name) || ' CASCADE;' || CHR(10) ||
'CREATE TABLE ' || LOWER(t.table_name) || ' (' || CHR(10) ||
LISTAGG(' ' || RPAD(LOWER(c.column_name), 12) || ' ' ||
CASE
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NULL THEN 'NUMERIC'
WHEN c.data_type = 'NUMBER' AND c.data_scale = 0 THEN 'NUMERIC(' || c.data_precision || ')'
WHEN c.data_type = 'NUMBER' THEN 'NUMERIC(' || c.data_precision || ',' || c.data_scale || ')'
WHEN c.data_type IN ('VARCHAR2','NVARCHAR2') THEN 'VARCHAR(' || c.char_length || ')'
WHEN c.data_type IN ('CHAR','NCHAR') THEN 'CHAR(' || c.char_length || ')'
WHEN c.data_type = 'DATE' THEN 'TIMESTAMP(0)'
WHEN c.data_type LIKE 'TIMESTAMP%TIME ZONE' THEN 'TIMESTAMPTZ'
WHEN c.data_type LIKE 'TIMESTAMP%' THEN 'TIMESTAMP'
WHEN c.data_type = 'FLOAT' THEN 'DOUBLE PRECISION'
WHEN c.data_type = 'CLOB' THEN 'TEXT'
WHEN c.data_type = 'BLOB' THEN 'BYTEA'
ELSE 'TEXT /* 확인 필요: ' || c.data_type || ' */'
END ||
CASE WHEN c.nullable = 'N' THEN ' NOT NULL' END,
',' || CHR(10)) WITHIN GROUP (ORDER BY c.column_id)
|| CHR(10) || ');' || CHR(10)
FROM user_tables t
JOIN user_tab_columns c ON c.table_name = t.table_name
WHERE t.table_name <> 'MIG_CHK'
AND t.dropped = 'NO'
GROUP BY t.table_name
ORDER BY t.table_name;
SPOOL OFF
EXITsqlplus -s <USERNAME>/<PASSWORD> @gen_ddl.sql
cat pg_01_tables.sql
psql -h localhost -U <USERNAME> -d <TARGET_DB_NAME> -v ON_ERROR_STOP=1 -f pg_01_tables.sql
psql -h localhost -U <USERNAME> -d <TARGET_DB_NAME> -c '\dt'결과 해석:
PostgreSQL 기본 search_path는 "$user", public이라, 접속 계정과 같은 이름의 <USERNAME> 스키마에 테이블이 만들어짐(Oracle의 계정별 스키마와 비슷하게 씀)
#2. CSV 추출과 적재
-- gen_export.sql: 테이블마다 SPOOL → SELECT * → SPOOL OFF 명령 생성
@/home/oracle/mig/pg/set_gen.sql
SPOOL /home/oracle/mig/pg/ora_export.sql
SELECT 'SPOOL /home/oracle/mig/pg/data/' || LOWER(table_name) || '.csv' || CHR(10) ||
'SELECT * FROM ' || table_name || ';' || CHR(10) || 'SPOOL OFF'
FROM user_tables
WHERE table_name <> 'MIG_CHK' AND dropped = 'NO'
ORDER BY table_name;
SPOOL OFF
-- gen_load.sql: 테이블마다 psql \copy 명령 생성
SPOOL /home/oracle/mig/pg/pg_02_load.sql
SELECT '\copy ' || LOWER(table_name) ||
' FROM ''/home/oracle/mig/pg/data/' || LOWER(table_name) || '.csv'' WITH (FORMAT csv)'
FROM user_tables
WHERE table_name <> 'MIG_CHK' AND dropped = 'NO'
ORDER BY table_name;
SPOOL OFF
EXITALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS';
ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF6';
ALTER SESSION SET NLS_NUMERIC_CHARACTERS = '.,';
SET MARKUP CSV ON QUOTE ON
SET HEADING OFF
SET FEEDBACK OFF
SET TERMOUT OFF
SET NUMWIDTH 38
SET LINESIZE 32767
SET TRIMSPOOL ON
@/home/oracle/mig/pg/ora_export.sql
EXITsqlplus -s <USERNAME>/<PASSWORD> @run_export.sql
ls -l data/
head data/orders.csv
wc -l data/*.csv
psql -h localhost -U <USERNAME> -d <TARGET_DB_NAME> -v ON_ERROR_STOP=1 -f pg_02_load.sql결과 해석:
SET MARKUP CSV ON은 12.2 이상 SQL*Plus에서 되고, NUMWIDTH 38과 날짜 형식 지정은 큰 숫자가 1.2E+10처럼 지수로, 날짜가 17-DEC-80처럼 나와 타깃에서 다르게 해석되는 것을 막음
서버 쪽 COPY는 DB 서버 프로세스가 파일을 읽어 그 권한이 필요하지만, psql \copy는 클라이언트를 실행한 OS 계정(oracle)이 CSV를 읽어 보냄
따옴표 없는 빈 필드는 NULL로 들어감
#3. 제약조건, 인덱스, 시퀀스
@/home/oracle/mig/pg/set_gen.sql
SPOOL /home/oracle/mig/pg/pg_03_constraints.sql
SELECT ddl FROM (
SELECT 1 AS ord, c.table_name AS tn,
'ALTER TABLE ' || LOWER(c.table_name) || ' ADD CONSTRAINT ' || LOWER(c.constraint_name) ||
DECODE(c.constraint_type, 'P', ' PRIMARY KEY (', ' UNIQUE (') ||
LISTAGG(LOWER(cc.column_name), ', ') WITHIN GROUP (ORDER BY cc.position) || ');' AS ddl
FROM user_constraints c
JOIN user_cons_columns cc ON cc.constraint_name = c.constraint_name
WHERE c.constraint_type IN ('P', 'U') AND c.table_name <> 'MIG_CHK'
GROUP BY c.table_name, c.constraint_name, c.constraint_type
UNION ALL
SELECT 2, c.table_name,
'ALTER TABLE ' || LOWER(c.table_name) || ' ADD CONSTRAINT ' || LOWER(c.constraint_name) ||
' FOREIGN KEY (' || LISTAGG(LOWER(cc.column_name), ', ') WITHIN GROUP (ORDER BY cc.position) ||
') REFERENCES ' || LOWER(p.table_name) ||
DECODE(c.delete_rule, 'CASCADE', ' ON DELETE CASCADE', '') || ';'
FROM user_constraints c
JOIN user_cons_columns cc ON cc.constraint_name = c.constraint_name
JOIN user_constraints p ON p.constraint_name = c.r_constraint_name
WHERE c.constraint_type = 'R'
GROUP BY c.table_name, c.constraint_name, p.table_name, c.delete_rule
UNION ALL
SELECT 3, i.table_name,
'CREATE ' || DECODE(i.uniqueness, 'UNIQUE', 'UNIQUE ', '') || 'INDEX ' || LOWER(i.index_name) ||
' ON ' || LOWER(i.table_name) || ' (' ||
LISTAGG(LOWER(ic.column_name), ', ') WITHIN GROUP (ORDER BY ic.column_position) || ');'
FROM user_indexes i
JOIN user_ind_columns ic ON ic.index_name = i.index_name
WHERE i.index_type = 'NORMAL' AND i.table_name <> 'MIG_CHK'
AND i.index_name NOT IN (SELECT index_name FROM user_constraints WHERE index_name IS NOT NULL)
GROUP BY i.table_name, i.index_name, i.uniqueness
)
ORDER BY ord, tn;
SPOOL OFF
SPOOL /home/oracle/mig/pg/pg_04_sequences.sql
SELECT 'DROP SEQUENCE IF EXISTS ' || LOWER(sequence_name) || ';' || CHR(10) ||
'CREATE SEQUENCE ' || LOWER(sequence_name) ||
' START WITH ' || last_number || ' INCREMENT BY ' || increment_by ||
DECODE(cycle_flag, 'Y', ' CYCLE', '') || ';'
FROM user_sequences ORDER BY sequence_name;
SPOOL OFF
EXITpsql -h localhost -U <USERNAME> -d <TARGET_DB_NAME> -v ON_ERROR_STOP=1 -f pg_03_constraints.sql
psql -h localhost -U <USERNAME> -d <TARGET_DB_NAME> -c '\d orders'
psql -h localhost -U <USERNAME> -d <TARGET_DB_NAME> -v ON_ERROR_STOP=1 -f pg_04_sequences.sqlSELECT last_value FROM ord_seq;
SELECT MAX(order_id) FROM orders;
SELECT setval('ord_seq', (SELECT MAX(order_id)::BIGINT FROM orders));결과 해석:
제약조건은 데이터를 다 넣은 뒤 걸어 적재 중 검사 부담과 외래키 때문의 적재 순서 제약을 줄임
기본키 중복이 있으면 이 단계에서 오류가 나 정합성이 드러남
Oracle이 자동으로 붙인 SYS_C... 제약 이름도 그대로 옮겨지므로 실무에서는 의미 있는 이름으로 바꾸는 것을 검토함
setval 뒤 다음 nextval은 최대값 + 1임
#4. 건수, 값 대조와 DEFAULT 대체
@/home/oracle/mig/pg/set_gen.sql
SPOOL /home/oracle/mig/pg/both_count.sql
SELECT LISTAGG('SELECT ''' || LOWER(table_name) || ''' AS table_name, COUNT(*) AS cnt FROM ' || LOWER(table_name),
CHR(10) || 'UNION ALL' || CHR(10)) WITHIN GROUP (ORDER BY table_name)
|| CHR(10) || 'ORDER BY 1;'
FROM user_tables
WHERE table_name <> 'MIG_CHK' AND dropped = 'NO';
SPOOL OFF
EXITecho exit | sqlplus -s <USERNAME>/<PASSWORD> @both_count.sql
psql -h localhost -U <USERNAME> -d <TARGET_DB_NAME> -f both_count.sqlSELECT COUNT(*), SUM(amount), MIN(reg_date), MAX(reg_date) FROM orders;
SELECT SUM(sal) FROM emp_m;
SELECT order_id, cust_name FROM orders ORDER BY order_id;ALTER TABLE orders ALTER COLUMN order_id SET DEFAULT nextval('ord_seq');
ALTER TABLE orders ALTER COLUMN reg_date SET DEFAULT LOCALTIMESTAMP(0);
BEGIN;
INSERT INTO orders (cust_name, amount) VALUES ('<NAME>', 55000) RETURNING *;
SELECT COUNT(*) FROM orders;
SELECT COUNT(*) FROM order_hist;
ROLLBACK;결과 해석:
생성된 both_count.sql은 Oracle과 PostgreSQL에서 그대로 돌릴 수 있게 소문자 테이블 이름의 UNION ALL로 만들어짐
건수가 같다고 값이 같은 것은 아니므로 금액 합계, 날짜 범위, 한글 이름까지 비교하고, Oracle 쪽은 NLS_DATE_FORMAT을 맞춘 뒤 봄
소스 BEFORE 트리거가 하던 주문번호 채번과 등록일 기본값은 컬럼 DEFAULT로 대신했고, 테스트 INSERT에서 orders는 1건 늘었지만 order_hist는 그대로였음
AFTER 트리거, 프로시저, 함수, 그 함수를 쓰는 뷰는 PL/pgSQL로 다시 써야 함(저장 프로그램 이행)
#사용 조건과 주의사항
앞선 설치 실습에서는 PostgreSQL 15인데 경로 예시는 17임
수업 자료도 실제 설치 경로 확인을 요구하므로 하나로 고쳐 적지 않음
외래키 생성기는 부모 기본키 참조를 가정하고 일반 인덱스만 다룸
CHECK, 컬럼 기본값, 모든 인덱스 유형을 보존하는 일반 변환기로 확장하지 않음
실습에서는 캐시된 LAST_NUMBER만 신뢰하지 않고 데이터 최대값과 비교하도록 적음(확인 불가)
빈 테이블과 비기본 증가값을 포함한 모든 시퀀스 조건의 보정 결과는 기재 없음
트랜잭션 ROLLBACK으로 발급 시퀀스 값이 복원되지 않는다는 수업 자료 메모가 있음