데이터베이스 연구실.log

Oracle에서 PostgreSQL로 이행

Oracle 데이터 사전으로 타깃 구조를 만들고 CSV와 psql로 데이터를 옮기는 실습 흐름 및 타입, 제약, 시퀀스의 범위

LISTAGGSET MARKUP CSVsearch_pathPostgreSQL 15CSVcopyNUMERICsetval

#핵심 요약

실습에서는 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

#작업 환경과 출력 설정

oracle 계정 셸객체 또는 설정을 변경함
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/psql
파일 (/home/oracle/mig/pg/set_gen.sql)객체 또는 설정을 변경함
SET 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. 테이블 생성문

SQL*Plus (<USERNAME>, gen_ddl.sql로 저장해 sqlplus -s로 실행)조회만 함
@/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
EXIT
oracle 계정 셸객체 또는 설정을 변경함
sqlplus -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 추출과 적재

SQL*Plus (<USERNAME>, gen_export.sql / gen_load.sql)조회만 함
-- 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
EXIT
파일 (/home/oracle/mig/pg/run_export.sql)조회만 함
ALTER 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
EXIT
oracle 계정 셸객체 또는 설정을 변경함
sqlplus -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. 제약조건, 인덱스, 시퀀스

SQL*Plus (<USERNAME>, gen_cons.sql)조회만 함
@/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
EXIT
oracle 계정 셸객체 또는 설정을 변경함
psql -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.sql
psql (<TARGET_DB_NAME>, <USERNAME>)객체 또는 설정을 변경함
SELECT 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 대체

SQL*Plus (<USERNAME>, gen_count.sql)조회만 함
@/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
EXIT
oracle 계정 셸조회만 함
echo exit | sqlplus -s <USERNAME>/<PASSWORD> @both_count.sql
psql -h localhost -U <USERNAME> -d <TARGET_DB_NAME> -f both_count.sql
SQL*Plus와 psql 양쪽조회만 함
SELECT 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;
psql (<TARGET_DB_NAME>, <USERNAME>)객체 또는 설정을 변경함
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으로 발급 시퀀스 값이 복원되지 않는다는 수업 자료 메모가 있음

SEO_HKabsumveritas@gmail.comBuilt with Claude Code & Codex

마지막 갱신 개인 학습 기록이며 운영 환경 적용 전 공식 문서 확인 필요

두 글자 이상 입력, 위아래 화살표로 이동하고 Enter로 열기