데이터베이스 연구실.log

MySQL에서 PostgreSQL로 이행

MySQL 사전 기반 DDL, TSV, 제약, IDENTITY 이행, 양쪽 건수, 합계 비교와 상품 이행 연습의 확인 기준

information_schemaTSVIDENTITYPL/pgSQL

#핵심 요약

MySQL 8.4.10의 업무 DB를 PostgreSQL 15.19의 새 DB로 이행하는 실습임
업무 스키마와 리포트 스키마를 나누고 테이블, 데이터, 채번, 저장 프로그램을 순서대로 옮김

실습 기록은 테이블 7개 50행 적재, 주문 금액 합계 700000, 급여 합계 15975의 양쪽 일치임
건수, 합계 일치는 비교한 항목의 결과이며 모든 열의 값과 동시 변경 통제까지 입증하는 기준은 아님

#개념과 동작 원리

단계 처리
준비 두 DB 접속, 버전 확인, 전용 작업 폴더와 타깃 DB, 스키마 준비
대상 확정 information_schema에서 테이블, 루틴, 트리거, 뷰 목록 조회
의존성 뷰 정의, 트리거 본문, 루틴 본문의 다른 DB 참조 확인
추출 mysqldump로 구조, 프로그램 추출, 사전 조회로 타깃 DDL 생성
데이터 MySQL 배치 TSV를 psql의 클라이언트 복사로 적재
적용 테이블 → 데이터 → PK, UNIQUE → FK → 일반 인덱스 → IDENTITY 보정
프로그램 리포트 스키마, 권한 준비 후 함수 → 뷰 → 트리거 → 프로시저
확인 양쪽 COUNT, SUM, 함수, 뷰 값, 정상, 실패 업무 호출 비교

리포트 DB는 타깃의 같은 DB 안에 별도 스키마로 대응함
기존 리포트 데이터 1건은 다른 시스템 소유이므로 이행 범위에서 제외하고 구조, 권한만 준비함

저장 프로그램의 변환 코드와 호출 확인은 저장 프로그램 이행에 있음

#필수 명령어 또는 SQL

#1 접속과 대상 목록 확인

DB 클라이언트 셸 (<WORK_DIR>)객체 또는 설정을 변경함
export PGPASSWORD='<PASSWORD>'
export PGCLIENTENCODING=UTF8
mkdir -p '<WORK_DIR>' && cd '<WORK_DIR>'
mysql -e "SELECT CURRENT_USER(), DATABASE(), VERSION()"
psql -h '<HOST>' -U '<USERNAME>' -d '<MAINTENANCE_DB>' -c "SELECT current_user, version()"

결과 해석: 접속 출력의 MySQL 버전은 8.4.10, PostgreSQL 버전은 15.19임
PGCLIENTENCODING은 UTF8이며 PGPASSWORD는 현재 셸에서 사용할 비밀번호 환경변수임

MySQL 클라이언트 (<SOURCE_DB_NAME>, 이행 계정)조회만 함
SELECT table_name, table_rows
  FROM information_schema.tables
 WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
 ORDER BY 1;

SELECT routine_type, routine_name
  FROM information_schema.routines
 WHERE routine_schema = DATABASE()
 ORDER BY 1, 2;

SELECT trigger_name, action_timing, event_manipulation, event_object_table
  FROM information_schema.triggers
 WHERE trigger_schema = DATABASE();

SELECT table_name AS view_name
  FROM information_schema.views
 WHERE table_schema = DATABASE();
테이블 소스 TABLE_ROWS 표시 이후 COUNT, 적재 기록
dept 4 4
emp 14 14
emp_m 6 6
emp_t 6 6
emp_t_audit 2 2
order_hist 9 9
orders 9 9
합계 50 50

결과 해석: 함수 get_grade 1개, 프로시저 add_order, raise_sal, send_report 3개, 트리거 trg_orders_bi, trg_orders_ai 2개, 뷰 v_emp_grade 1개임
TABLE_ROWS는 InnoDB 통계의 추정치이므로 최종 비교에는 COUNT(*)를 사용함

#2 의존관계와 생성 구문 추출

MySQL 클라이언트 (<SOURCE_DB_NAME>, 이행 계정)조회만 함
SELECT table_name, view_definition
  FROM information_schema.views
 WHERE table_schema = DATABASE()\G

SELECT trigger_name, action_statement
  FROM information_schema.triggers
 WHERE trigger_schema = DATABASE()\G

SELECT routine_name
  FROM information_schema.routines
 WHERE routine_schema = DATABASE()
   AND routine_definition LIKE '%<REPORT_USER>.%';
EXIT

결과 해석: v_emp_grade는 get_grade, 이력 트리거는 order_hist, send_report는 다른 DB의 rpt_daily를 참조함
본문 문자열 검색은 동적 SQL, 주석도 걸릴 수 있으므로 검색된 루틴의 정의를 함께 확인함

DB 클라이언트 셸 (<WORK_DIR>)객체 또는 설정을 변경함
mysqldump --no-data --routines --triggers --skip-comments --no-tablespaces '<SOURCE_DB_NAME>' > my_src.sql
grep -nE "^CREATE|CREATE DEFINER|^DELIMITER" my_src.sql

결과 해석: --no-data는 행을 제외하고 --routines, --triggers는 저장 프로그램을 포함함
트리거 생성 구문은 버전 주석 안에 있어 CREATE만 검색하면 빠질 수 있으며 DELIMITER 줄로도 위치를 확인함
실습에서는 PROCESS 권한이 없는 계정의 테이블스페이스 정보 추출을 피하려고 --no-tablespaces를 사용함

#3 타깃 DB, 스키마, 리포트 권한 준비

DB 클라이언트 셸 (<WORK_DIR>)현재 세션 설정만 변경함
PGPASSWORD='<PASSWORD>' psql -h '<HOST>' -U '<ADMIN_USER>'
psql (관리 계정)객체 또는 설정을 변경함
CREATE DATABASE <TARGET_DB_NAME> OWNER <USERNAME>;
\c <TARGET_DB_NAME>
CREATE SCHEMA <USERNAME> AUTHORIZATION <USERNAME>;
CREATE SCHEMA <REPORT_USER> AUTHORIZATION <REPORT_USER>;

CREATE TABLE <REPORT_USER>.rpt_daily
( rpt_date  TIMESTAMP(0),
  order_cnt NUMERIC(38,10),
  total_amt NUMERIC(38,10) );

ALTER TABLE <REPORT_USER>.rpt_daily OWNER TO <REPORT_USER>;

GRANT USAGE ON SCHEMA <REPORT_USER> TO <USERNAME>;

GRANT SELECT, INSERT ON <REPORT_USER>.rpt_daily TO <USERNAME>;
\dn
\q

결과 해석: DB 1개, 업무, 리포트 스키마 2개, 리포트 테이블과 USAGE, SELECT, INSERT 권한이 준비된 기록임
롤은 클러스터 전체에서 공유하므로 기존 롤을 사용함
기본 검색 경로의 "$user", public을 이용하는 조건이며 업무 계정과 같은 이름의 스키마를 준비함
실습에서는 업무 스키마가 없으면 public 스키마 생성 권한 오류가 발생하는 조건을 경고함

#4 타입과 테이블 DDL 생성

MySQL 타입, 속성 PostgreSQL 생성 규칙
bigint / int / smallint / tinyint BIGINT / INTEGER / SMALLINT / SMALLINT
decimal(p,s) NUMERIC(p,s)
varchar(n) / char(n) VARCHAR(n) / CHAR(n), 글자 수 기준
datetime(n) / date TIMESTAMP(n) / DATE
double / text / longtext / longblob DOUBLE PRECISION / TEXT / TEXT / BYTEA
auto_increment GENERATED BY DEFAULT AS IDENTITY
나머지 타입 TEXT와 확인 필요 주석 생성
ENGINE, CHARSET 테이블 생성문에서 제외하고 타깃 DB 인코딩 사용
DB 클라이언트 셸 (<WORK_DIR>)객체 또는 설정을 변경함
cat > gen_ddl_pg.sql <<'EOF'
SET SESSION group_concat_max_len = 1000000;
SELECT CONCAT('DROP TABLE IF EXISTS ', t.table_name, ' CASCADE;\n',
              'CREATE TABLE ', t.table_name, ' (\n',
              GROUP_CONCAT(CONCAT('  ', RPAD(c.column_name, 12, ' '), ' ',
                CASE c.data_type
                  WHEN 'bigint'   THEN 'BIGINT'
                  WHEN 'int'      THEN 'INTEGER'
                  WHEN 'smallint' THEN 'SMALLINT'
                  WHEN 'tinyint'  THEN 'SMALLINT'
                  WHEN 'decimal'  THEN CONCAT('NUMERIC(', c.numeric_precision, ',', c.numeric_scale, ')')
                  WHEN 'varchar'  THEN CONCAT('VARCHAR(', c.character_maximum_length, ')')
                  WHEN 'char'     THEN CONCAT('CHAR(', c.character_maximum_length, ')')
                  WHEN 'datetime' THEN CONCAT('TIMESTAMP(', c.datetime_precision, ')')
                  WHEN 'date'     THEN 'DATE'
                  WHEN 'double'   THEN 'DOUBLE PRECISION'
                  WHEN 'text'     THEN 'TEXT'
                  WHEN 'longtext' THEN 'TEXT'
                  WHEN 'longblob' THEN 'BYTEA'
                  ELSE CONCAT('TEXT /* 확인 필요: ', c.column_type, ' */')
                END,
                IF(c.extra LIKE '%auto_increment%', ' GENERATED BY DEFAULT AS IDENTITY', ''),
                IF(c.is_nullable = 'NO', ' NOT NULL', ''))
                ORDER BY c.ordinal_position SEPARATOR ',\n'),
              '\n);\n')
  FROM information_schema.tables t
  JOIN information_schema.columns c
    ON c.table_schema = t.table_schema AND c.table_name = t.table_name
 WHERE t.table_schema = DATABASE()
   AND t.table_type = 'BASE TABLE'
 GROUP BY t.table_name
 ORDER BY t.table_name;
EOF

mysql -N -B --raw < gen_ddl_pg.sql > pg_01_tables.sql
cat pg_01_tables.sql

결과 해석: 테이블 7개의 CREATE TABLE이 생성되고 orders.order_id에 BY DEFAULT IDENTITY가 붙은 기록임
기존 번호를 명시해 적재해야 하므로 BY DEFAULT를 사용함
GROUP_CONCAT 길이를 늘려 열 목록 잘림을 피하며 -N은 머리글 제외, -B는 배치 형식, --raw는 생성문의 개행을 그대로 출력하는 용도임

#5 TSV 추출과 적재 구문 생성

DB 클라이언트 셸 (<WORK_DIR>)객체 또는 설정을 변경함
cat > export_tsv.sh <<'EOF'
#!/bin/bash
# 테이블마다 TSV로 내려받고, 적재 명령(\copy)을 pg_02_load.sql 로 만든다
mkdir -p data
for t in $(mysql -N -B -e "SELECT table_name FROM information_schema.tables
                            WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY 1"); do
  mysql -N -B -e "SELECT * FROM \`$t\`" > data/$t.tsv
  echo "\copy $t FROM 'data/$t.tsv' WITH (FORMAT text, NULL 'NULL')"
done > pg_02_load.sql
wc -l data/*.tsv
EOF

chmod +x export_tsv.sh
./export_tsv.sh
cat pg_02_load.sql
head -3 data/orders.tsv data/emp.tsv

결과 해석: 파일별 행 수는 위 표와 같고 합계는 50행임
MySQL 배치 출력의 NULL은 글자 NULL로 나타나므로 적재 구문은 NULL 'NULL'을 지정함
클라이언트 복사는 psql이 파일을 읽으므로 DB 서버 OS 계정의 작업 폴더 읽기 권한과 구분됨

자료의 배치 TSV와 COPY text가 같다는 설명은 이 실습의 파일 규칙에 한정함
FORMAT text의 기본 NULL 표식은 \N이며 여기서 지정한 글자 NULL과 다름
실제 NULL과 문자열 'NULL'이 같은 표식으로 출력되면 구별할 수 없음
기본 NULL 표식과 문자 그대로의 역슬래시 N은 올바르게 이스케이프하면 구분 가능함
NULL 표식 판정은 역슬래시 제거보다 먼저 수행되므로 파일의 인코딩, 열 순서, 탭, 개행, 캐리지 리턴, 역슬래시 규칙을 함께 확인함

#6 제약조건, 인덱스, IDENTITY 생성

DB 클라이언트 셸 (<WORK_DIR>)객체 또는 설정을 변경함
cat > gen_cons_pg.sql <<'EOF'
SELECT ddl FROM (
  -- 기본키·유니크
  SELECT 1 AS ord, tc.table_name AS tn,
         CONCAT('ALTER TABLE ', tc.table_name, ' ADD ',
                IF(tc.constraint_type = 'PRIMARY KEY', 'PRIMARY KEY (',
                   CONCAT('CONSTRAINT ', tc.constraint_name, ' UNIQUE (')),
                GROUP_CONCAT(k.column_name ORDER BY k.ordinal_position), ');') AS ddl
    FROM information_schema.table_constraints tc
    JOIN information_schema.key_column_usage k
      ON k.constraint_schema = tc.constraint_schema
     AND k.constraint_name = tc.constraint_name AND k.table_name = tc.table_name
   WHERE tc.table_schema = DATABASE() AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
   GROUP BY tc.table_name, tc.constraint_name, tc.constraint_type
  UNION ALL
  -- 외래키
  SELECT 2, k.table_name,
         CONCAT('ALTER TABLE ', k.table_name, ' ADD CONSTRAINT ', k.constraint_name,
                ' FOREIGN KEY (', GROUP_CONCAT(k.column_name ORDER BY k.ordinal_position),
                ') REFERENCES ', k.referenced_table_name,
                ' (', GROUP_CONCAT(k.referenced_column_name ORDER BY k.ordinal_position), ')',
                IF(r.delete_rule = 'CASCADE', ' ON DELETE CASCADE', ''), ';')
    FROM information_schema.key_column_usage k
    JOIN information_schema.referential_constraints r
      ON r.constraint_schema = k.constraint_schema AND r.constraint_name = k.constraint_name
   WHERE k.table_schema = DATABASE() AND k.referenced_table_name IS NOT NULL
   GROUP BY k.table_name, k.constraint_name, k.referenced_table_name, r.delete_rule
  UNION ALL
  -- 제약조건이 아닌 일반 인덱스
  SELECT 3, s.table_name,
         CONCAT('CREATE ', IF(s.non_unique = 0, 'UNIQUE ', ''), 'INDEX ', s.index_name,
                ' ON ', s.table_name, ' (', GROUP_CONCAT(s.column_name ORDER BY s.seq_in_index), ');')
    FROM information_schema.statistics s
   WHERE s.table_schema = DATABASE() AND s.index_name <> 'PRIMARY'
     AND s.index_name NOT IN (SELECT constraint_name FROM information_schema.table_constraints
                               WHERE table_schema = DATABASE())
   GROUP BY s.table_name, s.index_name, s.non_unique
) x
ORDER BY ord, tn;
EOF

cat > gen_seq_pg.sql <<'EOF'
SET SESSION information_schema_stats_expiry = 0;   -- 캐시된 AUTO_INCREMENT 말고 지금 값을 읽음
SELECT CONCAT('SELECT setval(pg_get_serial_sequence(''', c.table_name, ''', ''', c.column_name, '''), ',
              t.auto_increment, ', false);')
  FROM information_schema.columns c
  JOIN information_schema.tables t
    ON t.table_schema = c.table_schema AND t.table_name = c.table_name
 WHERE c.table_schema = DATABASE()
   AND c.extra LIKE '%auto_increment%';
EOF

mysql -N -B --raw < gen_cons_pg.sql > pg_03_constraints.sql
mysql -N -B --raw < gen_seq_pg.sql  > pg_04_identity.sql

cat pg_03_constraints.sql pg_04_identity.sql

결과 해석: 실습 출력은 emp_m.empno, emp_t.empno, orders.order_id의 PK 3개와 아래 번호 보정 구문임
이 소스에는 FK, 일반 인덱스가 없어 해당 생성 분기의 실제 적용 결과는 기재 없음

psql (<TARGET_DB_NAME>, <USERNAME>)객체 또는 설정을 변경함
SELECT setval(pg_get_serial_sequence('orders', 'order_id'), 1010, false);

결과 해석: 1010은 소스의 다음 AUTO_INCREMENT 값이며 false는 다음 nextval이 지정값을 반환하도록 하는 설정임
기존 번호 적재와 후속 번호 보정은 별도 작업임
information_schema_stats_expiry를 0으로 설정해 캐시된 통계 대신 현재 AUTO_INCREMENT 값을 읽음
기본 통계 캐시 만료 시간은 실습 설명에서 86400초로 제시됨

자료는 생성기를 다른 DB에 그대로 재사용할 수 있다고 설명하지만 여기서 확인된 적용은 해당 실습 구조에 한정됨
FK 생성은 DELETE CASCADE만 반영하며 일반 인덱스 이름, 구성은 별도 점검 대상임
여러 테이블에 같은 인덱스 이름을 쓸 수 있는 MySQL과 달리 PostgreSQL은 스키마 전체에서 인덱스 이름 충돌을 확인해야 함

#7 데이터 이행 묶음 적용과 건수 비교

DB 클라이언트 셸 (<WORK_DIR>)데이터 또는 파일을 삭제함, 되돌릴 수 없음
cat > mig_data.sh <<'EOF'
#!/bin/bash
set -e
PSQL="psql -h <HOST> -U <USERNAME> -d <TARGET_DB_NAME> -v ON_ERROR_STOP=1"

echo "[1] MySQL에서 생성문·데이터 만들기"
mysql -N -B --raw < gen_ddl_pg.sql  > pg_01_tables.sql
mysql -N -B --raw < gen_cons_pg.sql > pg_03_constraints.sql
mysql -N -B --raw < gen_seq_pg.sql  > pg_04_identity.sql
./export_tsv.sh

echo "[2] PostgreSQL에 적용"
$PSQL -q -f pg_01_tables.sql
$PSQL    -f pg_02_load.sql
$PSQL -q -f pg_03_constraints.sql
$PSQL    -f pg_04_identity.sql
echo "[완료]"
EOF

chmod +x mig_data.sh
./mig_data.sh

결과 해석: COPY 4, 14, 6, 6, 2, 9, 9와 setval 1010이 출력된 기록임
첫 실행의 table does not exist, skipping NOTICE는 삭제할 테이블이 없다는 안내임

DB 클라이언트 셸 (<WORK_DIR>)객체 또는 설정을 변경함
cat > both_count.sql <<'EOF'
SELECT 'dept' AS table_name, COUNT(*) AS cnt FROM dept
UNION ALL SELECT 'emp',         COUNT(*) FROM emp
UNION ALL SELECT 'emp_m',       COUNT(*) FROM emp_m
UNION ALL SELECT 'emp_t',       COUNT(*) FROM emp_t
UNION ALL SELECT 'emp_t_audit', COUNT(*) FROM emp_t_audit
UNION ALL SELECT 'order_hist',  COUNT(*) FROM order_hist
UNION ALL SELECT 'orders',      COUNT(*) FROM orders
ORDER BY 1;
SELECT SUM(amount) AS sum_amount FROM orders;
SELECT SUM(sal) AS sum_sal FROM emp_m;
EOF

echo "===== MySQL ====="
mysql -t < both_count.sql
echo "===== PostgreSQL ====="
psql -h '<HOST>' -U '<USERNAME>' -d '<TARGET_DB_NAME>' -f both_count.sql
비교 항목 MySQL PostgreSQL
테이블별 COUNT 4 / 14 / 6 / 6 / 2 / 9 / 9 4 / 14 / 6 / 6 / 2 / 9 / 9
orders 금액 합계 700000.0000000000 700000.0000000000
emp_m 급여 합계 15975.0000000000 15975.0000000000

결과 해석: 같은 SQL 파일을 양쪽에서 조회한 실습 기록임
이 단계는 데이터 비교임
주문 이력과 리포트 호출은 저장 프로그램을 적용한 뒤 확인함

#8 상품 테이블 단독 이행 연습

MySQL 클라이언트 (<SOURCE_DB_NAME>, 이행 계정)데이터 또는 파일을 삭제함, 되돌릴 수 없음
DROP TABLE IF EXISTS products;

CREATE TABLE products
( prod_id    INT          NOT NULL AUTO_INCREMENT,
  prod_name  VARCHAR(50)  NOT NULL,
  category   VARCHAR(20),
  price      DECIMAL(10,0),
  stock      INT,
  reg_date   DATETIME,
  PRIMARY KEY (prod_id)
);

INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('갤럭시 버즈3', '전자', 189000, 40, '2026-09-01 09:10:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('아이패드 에어', '전자', 929000, 12, '2026-09-01 10:20:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('로지텍 마우스', '전자', 39000, 85, '2026-09-02 11:00:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('기계식 키보드', '전자', 129000, 30, '2026-09-02 14:30:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('27인치 모니터', '전자', 349000, 15, '2026-09-03 09:00:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('USB-C 충전기', '전자', 29000, 120, '2026-09-03 16:45:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('SQL 200제', '도서', 28000, 60, '2026-09-04 10:00:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('친절한 SQL 튜닝', '도서', 36000, 45, '2026-09-04 13:20:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('오라클 19c 관리', '도서', 42000, 25, '2026-09-05 09:30:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('PostgreSQL 입문', '도서', 32000, NULL, '2026-09-05 15:10:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('리눅스 첫걸음', '도서', 25000, 50, '2026-09-06 11:40:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('파이썬 데이터분석', '도서', 34000, 38, '2026-09-06 17:00:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('제주 감귤 5kg', '식품', 27000, 70, '2026-09-07 08:50:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('유기농 우유 1L', '식품', 3500, 200, '2026-09-07 09:15:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('햇반 24개입', '식품', 23900, 90, '2026-09-08 10:05:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('커피 원두 1kg', '식품', 31000, 35, '2026-09-08 13:30:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('견과류 선물세트', '식품', 45000, 20, '2026-09-09 10:10:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('생수 2L 12병', '식품', 9900, 150, '2026-09-09 14:00:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('후드 티셔츠', '의류', 49000, 55, '2026-09-10 09:40:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('청바지', '의류', 59000, 40, '2026-09-10 12:00:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('경량 패딩', '의류', 89000, 22, '2026-09-11 10:30:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('운동화', '의류', 99000, 30, '2026-09-11 15:20:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('니트 가디건', '의류', 69000, 18, '2026-09-12 11:00:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('양말 10족', '의류', 12900, 100, '2026-09-12 16:30:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('원목 책상', '가구', 259000, 8, '2026-09-13 10:00:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('사무용 의자', '가구', 189000, 14, '2026-09-13 13:45:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('3단 책장', '가구', 79000, 20, '2026-09-14 09:20:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('스탠드 조명', '가구', 45000, 33, '2026-09-14 14:10:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('수납 선반', '가구', 39000, 27, '2026-09-15 10:50:00');
INSERT INTO products (prod_name, category, price, stock, reg_date) VALUES ('접이식 테이블', '가구', 55000, 16, '2026-09-15 17:30:00');

CREATE UNIQUE INDEX uk_products_name ON products (prod_name);
CREATE INDEX idx_products_cat_price  ON products (category, price);
CREATE INDEX idx_products_regdate    ON products (reg_date);

SELECT COUNT(*) FROM products;
SHOW INDEX FROM products;
EXIT

결과 해석: 준비 입력은 30건이며 PostgreSQL 입문 상품의 stock 한 건은 NULL임
PK 1개, 유니크 1개, 일반 인덱스 2개를 확인하는 구성임

MySQL 클라이언트 (<SOURCE_DB_NAME>, 이행 계정)현재 세션 설정만 변경함
-- mysql 에서
SHOW CREATE TABLE products\G

SELECT index_name, non_unique, seq_in_index, column_name
  FROM information_schema.statistics
 WHERE table_schema = DATABASE() AND table_name = 'products'
 ORDER BY index_name, seq_in_index;

SET SESSION information_schema_stats_expiry = 0;

SELECT auto_increment FROM information_schema.tables
 WHERE table_schema = DATABASE() AND table_name = 'products';
EXIT

결과 해석: 정답 기준은 인덱스 4개, 컬럼 구성 5행, AUTO_INCREMENT 다음 값 31임
idx_products_cat_price는 category → price 순서의 결합 인덱스임

DB 클라이언트 셸 (<WORK_DIR>)객체 또는 설정을 변경함
mkdir -p data
mysql -N -B -e "SELECT prod_id, prod_name, category, price, stock, reg_date FROM products ORDER BY prod_id" > data/products.tsv
wc -l data/products.tsv
grep -n NULL data/products.tsv

결과 해석: 정답 설명의 파일 행 수는 30, NULL이 있는 데이터 행은 한 줄임
열 이름을 명시해 추출하고 타깃 열 순서와 맞춤

DB 클라이언트 셸 (<WORK_DIR>)데이터 또는 파일을 삭제함, 되돌릴 수 없음
cat > q4_products.sql <<'EOF'
DROP TABLE IF EXISTS products CASCADE;
CREATE TABLE products (
  prod_id    INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
  prod_name  VARCHAR(50) NOT NULL,
  category   VARCHAR(20),
  price      NUMERIC(10,0),
  stock      INTEGER,
  reg_date   TIMESTAMP(0)
);

\copy products FROM 'data/products.tsv' WITH (FORMAT text, NULL 'NULL')

ALTER TABLE products ADD PRIMARY KEY (prod_id);
ALTER TABLE products ADD CONSTRAINT uk_products_name UNIQUE (prod_name);
CREATE INDEX idx_products_cat_price ON products (category, price);
CREATE INDEX idx_products_regdate   ON products (reg_date);

SELECT setval(pg_get_serial_sequence('products', 'prod_id'), 31, false);
EOF

psql -h '<HOST>' -U '<USERNAME>' -d '<TARGET_DB_NAME>' -v ON_ERROR_STOP=1 -f q4_products.sql

결과 해석: 정답 확인 기준은 COPY 30과 setval 31임
유니크를 제약조건으로 만들면 해당 유니크 인덱스가 함께 생성됨
독립 UNIQUE INDEX로 만들어도 중복을 막지만 제약조건 사전에서 보이는 구성은 다름

DB 클라이언트 셸 (<WORK_DIR>)객체 또는 설정을 변경함
cat > q4_check.sql <<'EOF'
SELECT COUNT(*) AS cnt, SUM(price) AS sum_price, SUM(stock) AS sum_stock,
       COUNT(*) - COUNT(stock) AS null_stock
  FROM products;
SELECT category, COUNT(*) AS cnt, SUM(price) AS sum_price
  FROM products GROUP BY category ORDER BY category;
EOF

echo "===== MySQL =====";      mysql -t < q4_check.sql
echo "===== PostgreSQL ====="; psql -h '<HOST>' -U '<USERNAME>' -d '<TARGET_DB_NAME>' -f q4_check.sql
psql -h '<HOST>' -U '<USERNAME>' -d '<TARGET_DB_NAME>' -c "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'products' ORDER BY 1"
확인 항목 정답 기준
건수 / 가격 합계 / 재고 합계 / 재고 NULL 30 / 3045200 / 1468 / 1
가구 / 도서 / 식품 / 의류 / 전자 각 6건
카테고리별 가격 합계 666000 / 197000 / 140300 / 377900 / 1664000
타깃 인덱스 idx_products_cat_price, idx_products_regdate, products_pkey, uk_products_name

결과 해석: SUM(stock)은 NULL을 빼고 더하므로 NULL이 0으로 바뀌어도 합계는 같을 수 있음
NULL 건수를 별도 비교하고 pg_indexes에서 인덱스 구성과 열 순서를 확인함

psql (<TARGET_DB_NAME>, <USERNAME>)객체 또는 설정을 변경함
-- psql -h '<HOST>' -U '<USERNAME>' -d '<TARGET_DB_NAME>'
BEGIN;
INSERT INTO products (prod_name, category, price, stock, reg_date)
VALUES ('테스트 상품', '기타', 1000, 1, now()) RETURNING prod_id;
INSERT INTO products (prod_name, category, price, stock, reg_date)
VALUES ('SQL 200제', '도서', 28000, 1, now());
ROLLBACK;

결과 해석: 정답 설명은 첫 입력의 prod_id 31과 두 번째 입력의 uk_products_name 중복 오류를 제시함
오류 뒤 트랜잭션은 ROLLBACK으로 끝내는 구성임

#사용 조건과 주의사항

IDENTITY 통계 캐시를 끄는 작업과 이행 중 동시 주문을 통제하는 작업은 별도임
실제 동시 변경 통제, 전체 열 값의 보존, 모든 타입의 TSV 호환 결과는 확인 불가

번호 보정 1010과 이후 트리거 테스트에서 1010을 소비한 기록이 있으므로 첫 업무 주문 1011과 구분함
정상 주문 뒤 주문, 이력 각 10건과 리포트 합계 733000은 후속 호출 결과이며 이행 직후의 주문 9건, 합계 700000과 비교 시점이 다름

실습의 건수, 합계 비교를 모든 행, 열의 완전 일치나 운영 전환 완료로 확대하지 않음

SEO_HKabsumveritas@gmail.comBuilt with Claude Code & Codex

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

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