데이터베이스 연구실.log

COPY를 이용한 데이터 적재

Oracle 배치 조회와 psycopg COPY 적재, 테이블별 트랜잭션 및 재이행과 NUL 문자, CLOB 추출 주의

`<SCHEMA>`markup csv\copyCOPY FROM STDINpsycopg copywrite_rowfetchmanyarraysizeprefetchrows배치NUL 문자drop cascade

#핵심 요약

실습 도구는 Oracle에서 배치(기본 5,000행)로 읽어 PostgreSQL COPY ... FROM STDIN으로 흘려 넣음
COPY는 INSERT를 한 줄씩 보내는 것보다 수십 배 빠르다고 실습에서는 적었음

테이블 하나가 트랜잭션 하나임

적재 순서는 기존 객체 삭제와 테이블 생성, 테이블별 COPY, PK → FK → 인덱스, 시퀀스 → 뷰 → 권한임

실습 결과는 1,100,000행 5.0초, 적재 후 DDL 16/16 성공임

#개념과 동작 원리

순서 실습 도구가 하는 일
1 대상 스키마의 같은 이름 뷰, 테이블, 시퀀스 삭제(drop ... if exists ... cascade) 후 스키마, 테이블 생성
2 테이블마다 소스 SELECT → copy 대상테이블 (컬럼들) from stdin
3 PK → FK → 인덱스
4 시퀀스 → 뷰 → 권한(GRANT)

DDL 묶음의 내용은 타입 변환과 DDL 생성에 있음

#필수 명령어 또는 SQL

#핵심 코드: 배치 fetch → COPY

PC (이행 엔진 파일 engine.py)객체 또는 설정을 변경함
oracledb.defaults.fetch_lobs = False      # CLOB/BLOB 을 str/bytes 로 바로 받는다
oracledb.defaults.fetch_decimals = True   # NUMBER 를 Decimal 로 받아 소수점 손실 방지

def copy_table(ora, pg, owner, t, target, batch=5000, progress=None):
    cols = [c.name for c in t.columns]
    cur = ora.cursor()
    cur.arraysize = batch
    cur.prefetchrows = batch + 1
    cur.execute(f'select {", ".join(chr(34) + c + chr(34) for c in cols)} from "{owner}"."{t.name}"')
...
    with pg.transaction():
        with pg.cursor().copy(copy_sql) as cp:
            while True:
                rows = cur.fetchmany()
                if not rows:
                    break
                for r in rows:
                    cp.write_row([_clean(v) for v in r])
                done += len(rows)
                if progress:
                    progress(done, time.time() - t0)

결과 해석:

읽는 법:

코드 역할
cur.arraysize = batch fetchmany()가 한 번에 가져올 행 수를 배치 크기로 맞춤
chr(34) + c + chr(34) 컬럼 이름을 큰따옴표로 감싸 Oracle 대문자 이름을 그대로 씀
pg.transaction() 테이블 하나의 COPY를 트랜잭션 하나로 묶음
cp.write_row(...) 한 행을 COPY 스트림에 씀
_clean()은 NUL 문자를 뺌
progress(...) 화면의 진행률과 초당 행 수를 갱신함

...로 줄인 부분(copy_sql, done, t0를 만드는 줄)은 수업 자료에서도 생략되어 있음
_clean() 본문도 기재 없음

#실습 결과

확인 기준 실습 결과
적재 건수 CUST 100,000 / ORDERS 1,000,000
속도 전체 5.0초, 초당 221,228행
로그 기준 CUST 0.3초, ORDERS 3.8초
적재 후 DDL 16/16 성공(제약조건 3, search_path, 시퀀스, 뷰 3, 권한 10)

결과 해석: ORDERS는 CLOB이 있어도 100만 행이 약 4초였음
화면 로그는 문장의 첫 줄만 90자까지 보여 주고, 실패하면 오류 메시지가 함께 나옴
프로시저 EXECUTE 권한은 PL/SQL을 옮긴 뒤 6단계에서 마무리함

#0단계: 도구 없이 손으로 해 보는 이행

SQL*Plus와 psql로 Oracle <SOURCE_DB_NAME>의 <SOURCE_SCHEMA> 스키마(CUST 10만 건, ORDERS 100만 건, 뷰, 시퀀스, 프로시저)를 PostgreSQL <TARGET_DB_NAME>의 전용 스키마 <SCHEMA>로 옮김
같은 방식을 다른 실습 스키마로 한 정리는 Oracle → PostgreSQL에 있음

VM 셸 (작업 사용자)객체 또는 설정을 변경함
mkdir -p '<WORK_DIR>'
cd '<WORK_DIR>'

결과 해석:

순서 손으로 하는 일 도구 화면
1 user_objects, user_tab_columns로 소스 객체, 컬럼 살펴보기 ② 분석
2 타입을 바꿔 빈 테이블 만들기 ③ 변환
3, 4 CSV로 내보내고 \copy로 적재 ④ 이행
5 PK, FK, 시퀀스, 뷰, 프로시저 ③, ④의 객체 변환
6 건수, 합계, 분포 비교 ⑤ 검증
Oracle PostgreSQL 이유
NUMBER(10,0), NUMBER(12,0) bigint 소수점 없는 정수는 정수형이 numeric보다 작고 빠름
NUMBER(12,2) numeric(12,2) 금액은 소수점까지 정확해야 함(float 금지)
VARCHAR2(n) / CHAR(n) varchar(n) / char(n) 이름만 다름
DATE timestamp(0) Oracle DATE에는 시, 분, 초가 있어 date로 바꾸면 시간이 사라짐
CLOB text 길이 제한 없이 1GB까지

추출 전에 메모의 최대 문자 수와 SQLPlus 출력 제한을 확인함
CLOB의 GETLENGTH는 문자 수이고 SET LONG은 바이트 제한이므로, 다중 바이트 문자가 있으면 문자 수만으로 충분한지 판단할 수 없음
클라이언트에서 CLOB 전체를 읽어 내보내기 인코딩의 최대 바이트 수를 확인하고 <LOB_OUTPUT_BYTES>를 그 이상으로 정함
출력 제한 안에 들어가는지 확인할 수 없으면 SQL
Plus CSV 추출 대신 위의 드라이버 COPY 방식을 사용함

SQL*Plus (소스 <SOURCE_SCHEMA>, 추출 전)조회만 함
SELECT MAX(DBMS_LOB.GETLENGTH(memo)) AS max_memo_chars FROM orders;
SHOW LONG

결과 해석:

<WORK_DIR>/exp_cust.sql과 <WORK_DIR>/exp_orders.sql에 각각 저장함

SQL*Plus (소스 <SOURCE_SCHEMA>, exp_cust.sql)현재 세션 설정만 변경함
set markup csv on quote on
set feedback off termout off
alter session set nls_date_format = 'YYYY-MM-DD HH24:MI:SS';
spool <WORK_DIR>/cust.csv
select cust_id, cust_nm, gender, tel, join_dt, grade_cd from cust order by cust_id;
spool off
exit
SQL*Plus (소스 <SOURCE_SCHEMA>, exp_orders.sql)현재 세션 설정만 변경함
set markup csv on quote on
set feedback off termout off
set long <LOB_OUTPUT_BYTES>
alter session set nls_date_format = 'YYYY-MM-DD HH24:MI:SS';
spool <WORK_DIR>/orders.csv
select order_id, cust_id, order_dt, amount, status, memo from orders order by order_id;
spool off
exit
VM 셸 (작업 사용자, <WORK_DIR>)객체 또는 설정을 변경함
time sqlplus -s <SOURCE_SCHEMA>/<PASSWORD>@//<HOST>:1521/<SERVICE_NAME> @exp_cust.sql
time sqlplus -s <SOURCE_SCHEMA>/<PASSWORD>@//<HOST>:1521/<SERVICE_NAME> @exp_orders.sql
VM 셸 (작업 사용자, <WORK_DIR>)조회만 함
head -n 2 cust.csv orders.csv
wc -l cust.csv orders.csv

결과 해석:

선두에 빈 줄 한 개가 있고 그 다음 줄이 컬럼 머리글임을 확인한 파일에만 아래 처리를 적용함
파일 전체의 빈 줄을 지우면 인용된 메모 필드 내부의 연속 개행도 사라지므로 첫 줄만 처리함

VM 셸 (작업 사용자, <WORK_DIR>)객체 또는 설정을 변경함
sed -i '1{/^\r\?$/d;}' cust.csv orders.csv
export PGPASSWORD=<PASSWORD>
time /usr/pgsql-15/bin/psql -h <HOST> -U <USERNAME> -d <TARGET_DB_NAME> -v ON_ERROR_STOP=1 \
  -c "\copy <SCHEMA>.cust from '<WORK_DIR>/cust.csv' with (format csv, header true)"
time /usr/pgsql-15/bin/psql -h <HOST> -U <USERNAME> -d <TARGET_DB_NAME> -v ON_ERROR_STOP=1 \
  -c "\copy <SCHEMA>.orders from '<WORK_DIR>/orders.csv' with (format csv, header true)"

결과 해석:

markup csv on quote on은 텍스트를 큰따옴표로 감싸고 termout off는 스크립트의 조회 결과를 화면에 출력하지 않음
SET LONG 기본값은 80바이트이며 4000으로 올려도 4000바이트를 넘는 CLOB은 잘릴 수 있음
건수, 금액 합계만 같아도 메모 내용의 보존이 보장되지 않음

PostgreSQL은 01-JAN-23 같은 문자열도 DateStyle에 따라 해석할 수 있음
시, 분, 초가 빠지면 시간을 복원할 수 없으므로 YYYY-MM-DD HH24:MI:SS로 명시해 추출하고 대상 timestamp에 시간을 보존함

wc -l은 물리적 개행 수이며 CSV 레코드 수가 아님
인용 필드 내부 개행이 있으면 데이터 건수와 머리글 수를 더한 값보다 커질 수 있음
선두 빈 줄이 있는 파일에서 이를 그대로 두면 COPY가 빈 줄을 머리글로 건너뛰고 진짜 머리글을 데이터로 읽을 수 있음

\copy는 psql이 파일을 읽어 보내고, 역슬래시 없는 COPY는 DB 서버가 직접 파일을 읽어 superuser 권한이 필요함

psql, pg_dump는 OS 기본 9.2 클라이언트가 먼저 잡히지 않도록 /usr/pgsql-15/bin/ 경로째 실행함

실습 기록: 내보내기 CUST 약 2초, ORDERS 1분 16초, 적재 COPY 100000 0.2초, COPY 1000000 1.7초
이행 시간 대부분은 소스에서 꺼내는 쪽임

psql (<USERNAME>, <WORK_DIR>/cons.sql)객체 또는 설정을 변경함
\set ON_ERROR_STOP on
alter table <SCHEMA>.cust   add constraint cust_pk   primary key (cust_id);
alter table <SCHEMA>.orders add constraint orders_pk primary key (order_id);
alter table <SCHEMA>.orders add constraint orders_cust_fk foreign key (cust_id) references <SCHEMA>.cust (cust_id);
create sequence <SCHEMA>.orders_seq;
select setval('<SCHEMA>.orders_seq', (select max(order_id) from <SCHEMA>.orders));
create view <SCHEMA>.v_cust_sales as
select c.cust_id, c.cust_nm, coalesce(sum(o.amount), 0) as total_amt
  from <SCHEMA>.cust c left join <SCHEMA>.orders o on c.cust_id = o.cust_id
 group by c.cust_id, c.cust_nm;
create procedure <SCHEMA>.p_upgrade_grade(p_min_amt numeric)
language plpgsql as $$
begin
  update <SCHEMA>.cust c set grade_cd = '01'
   where c.cust_id in (select o.cust_id from <SCHEMA>.orders o
                        group by o.cust_id having sum(o.amount) >= p_min_amt);
  commit;
end;
$$;
analyze <SCHEMA>.cust;
analyze <SCHEMA>.orders;

결과 해석:

PK는 데이터를 다 넣은 뒤 만들고(실습 기록: ORDERS PK 1.7초), FK 생성이 에러 없이 끝나면 모든 주문의 고객이 CUST에 있다는 무결성 검증도 됨

setval로 시퀀스를 소스 최대값에 맞추지 않으면 다음 주문 번호가 1부터 나와 PK 중복이 남
실습 기록: setval 1000000, 이후 nextval 1000001(소스 ORDERS_SEQ의 LAST_NUMBER와 같음)

뷰는 (+) → left join, NVL → coalesce로 바꾸고, 프로시저는 PL/SQL IS → as $$ ... $$, IN NUMBER → numeric으로 옮김

프로시저 안 테이블에는 스키마 이름을 붙임
없으면 search_path에 따라 다른 스키마를 보거나 relation "cust" does not exist가 나고, search_path를 프로시저에 SET으로 붙이면 안의 COMMIT이 invalid transaction termination으로 막힘

SQL*Plus (소스 <SOURCE_SCHEMA>, <WORK_DIR>/chk_ora.sql)현재 세션 설정만 변경함
set heading off feedback off pages 0 tab off lines 200
alter session set nls_date_format = 'YYYY-MM-DD HH24:MI:SS';
select 'cust      '||count(*)||' | '||sum(cust_id)||' | '||min(join_dt)||' ~ '||max(join_dt) from cust;
select 'orders    '||count(*)||' | '||to_char(sum(amount),'FM999999999999.00')||' | '||count(case when memo is not null then 1 end)||' | '||min(order_dt)||' ~ '||max(order_dt) from orders;
select 'grade     '||listagg(grade_cd||'='||cnt, ', ') within group (order by grade_cd) from (select grade_cd, count(*) cnt from cust group by grade_cd);
select 'view      '||count(*)||' | '||to_char(sum(total_amt),'FM999999999999.00') from v_cust_sales;
exit
psql (<USERNAME>, <WORK_DIR>/chk_pg.sql)조회만 함
\pset tuples_only on
\pset format unaligned
select 'cust      '||count(*)||' | '||sum(cust_id)||' | '||min(join_dt)||' ~ '||max(join_dt) from <SCHEMA>.cust;
select 'orders    '||count(*)||' | '||sum(amount)||' | '||count(memo)||' | '||min(order_dt)||' ~ '||max(order_dt) from <SCHEMA>.orders;
select 'grade     '||string_agg(grade_cd||'='||cnt, ', ' order by grade_cd) from (select grade_cd, count(*) cnt from <SCHEMA>.cust group by grade_cd) t;
select 'view      '||count(*)||' | '||sum(total_amt) from <SCHEMA>.v_cust_sales;

결과 해석:

항목 실습 결과(양쪽 같음)
CUST 건수 / cust_id 합 100000 / 5000050000
ORDERS 건수 / 금액 합 / 메모 있는 건수 1000000 / 250444357921.73 / 1000
주문일 범위 2023-01-01 00:00:00 ~ 2025-09-26 23:59:59
등급 분포 01=33333, 02=33334, 03=33333
뷰 건수 / 합계 100000 / 250444357921.73

건수만 같고 값이 틀린 경우(소수점, 시간 잘림)까지 잡으려고 합계와 최솟값~최댓값을 함께 봄

위 실습 기록에는 메모의 길이, 내용 일치 결과가 없으므로 CLOB 보존 여부는 확인 불가임
추가 검증에서는 ORDER_ID별로 소스 DBMS_LOB.GETLENGTH(memo)와 대상 char_length(memo)를 비교하고, 드라이버로 읽은 전체 문자열도 비교함
길이가 같아도 내용이 다를 수 있으므로 NULL, 빈 문자열, 필드 내부 개행까지 함께 확인함

Oracle에서는 CLOB에 count(memo)가 ORA-00932로 안 되므로 count(case when memo is not null then 1 end)로 셈

call <SCHEMA>.p_upgrade_grade(5000000)은 CALL로 끝났고 등급 분포는 그대로였음
주문 합계가 5,000,000원을 넘는 고객이 없어서이며, 확인할 것은 쓰기와 COMMIT이 에러 없이 끝난 점임

다시 할 때는 대상 전용 스키마와 작업 디렉터리에 다른 업무 데이터가 없는지 확인한 뒤 정리함
스키마 CASCADE 삭제와 디렉터리 재귀 삭제는 복구할 수 없으므로 다른 이행 결과를 포함하지 않는지 확인함

#메모 길이와 전체 내용 대조

PC (Python, 소스 ora·대상 pg 연결)조회만 함
from itertools import zip_longest

with ora.cursor() as src, pg.cursor(name="memo_check") as dst:
    src.execute("select order_id, memo, dbms_lob.getlength(memo) from orders order by order_id")
    dst.execute("select order_id, memo, char_length(memo) from <SCHEMA>.orders order by order_id")
    missing = object()
    for left, right in zip_longest(src, dst, fillvalue=missing):
        if left is missing or right is missing:
            raise ValueError("행 수 불일치")
        order_id, memo, chars = left
        if hasattr(memo, "read"):
            memo = memo.read()
        if (order_id, memo, chars) != right:
            raise ValueError(f"메모 길이 또는 내용 불일치: order_id={order_id}")

결과 해석: 이 코드는 행 누락, 길이 차이, 문자열 차이가 있으면 오류로 중단함
추가 검증의 실행 결과 확인 불가

#사용 조건과 주의사항

두 번째 이행부터 relation "orders_seq" already exists가 났음
테이블만 지우고 다시 만들면 시퀀스와 뷰가 남기 때문임
실습에서는 같은 이름의 뷰, 테이블, 시퀀스를 모두 지우고 시작하도록 고쳤음

Oracle은 문자열 안의 NUL(\x00) 문자를 허용하지만 PostgreSQL text는 허용하지 않아 COPY가 실패할 수 있음
실습 도구는 적재할 때 NUL 문자를 빼고 넣음
값이 바뀌므로 검증 단계에서 차이로 잡히는지는 기재 없음

SEO_HKabsumveritas@gmail.comBuilt with Claude Code & Codex

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

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