안녕하세요 기술 인사이트 모음집 “기술로그” 입니다!
이기종 데이터베이스 마이그레이션, 메타데이터 변환이 성패를 가른다
"데이터만 잘 옮기면 되는 것 아닌가요?"
이기종 마이그레이션 프로젝트를 수행할 때 가장 많이 듣는 질문 중 하나입니다. 실제로 많은 분들이 마이그레이션을 단순히 A 데이터베이스에서 B 데이터베이스로 데이터를 복사하는 작업 정도로 생각하십니다. 하지만 지난 몇 년간 수많은 이기종 마이그레이션 프로젝트를 수행하면서 깨달은 진실은 이와 완전히 달랐습니다.
단순 이관은 기술이지만, 메타데이터 변환은 전략이다
프로젝트 초기, 한 금융회사의 마이그레이션을 진행했던 경험이 있습니다. 데이터 이관 자체는 순조롭게 진행되었습니다. 수 TB 데이터가 문제없이 새로운 이기종 데이터베이스로 옮겨졌고, 데이터 건수도 완벽하게 일치했습니다. 프로젝트팀은 성공을 확신했습니다.
하지만 애플리케이션을 새 데이터베이스에 연결하는 순간, 문제가 시작되었습니다. 일부 화면에서 한글이 깨져서 표시되었고, 숫자가 이상하게 나타났으며, 날짜 계산이 맞지 않았습니다. 특정 조회 기능은 아예 동작하지 않았고, 데이터 입력 시 예상치 못한 에러가 발생했습니다. 데이터는 분명 모두 이관되었는데 왜 이런 문제가 발생한 걸까요?
원인은 바로 메타데이터 변환에 있었습니다. 한글 깨짐은 캐릭터셋(Character Set)을 제대로 변환하지 않아서였고, 정렬이 맞지 않는 문제는 Collation 설정이 문제였고, 숫자 오류는 데이터 타입 변환이 잘못되었기 때문이었으며, 입력 에러는 Check 제약조건과 Default 값이 누락되어 발생한 것이었습니다.
메타데이터는 데이터베이스의 설계도입니다. 건물을 짓기 전에 정확한 설계도가 필요하듯이, 데이터베이스도 메타데이터라는 설계도에 따라 작동합니다. 많은 분들이 메타데이터를 단순히 테이블과 컬럼 정보 정도로 생각하시는데, 실제로는 훨씬 더 광범위하고 복잡한 정보를 포함합니다. 즉, 정교하지 못한 메타데이터 변환은 시스템 전체의 기능 마비를 야기할 수 있습니다.
메타데이터 네 가지 관점에서 이해하면 훨씬 명확해진다
메타데이터는 단순히 테이블과 컬럼의 정의가 아닙니다. 데이터가 어떻게 저장되고, 어떻게 흐르고, 누구에게 열려 있고, 어떤 로직이 그 안에서 작동하는지를 정의하는 네 축의 설계 시스템입니다.
먼저 데이터가 어떤 형태로 저장되는가의 문제가 있습니다. 타입, 캐릭터셋, 저장 구조 같은 기초 설정이 여기 해당합니다. 이 축이 어긋나면 데이터가 담긴 것처럼 보이지만 의미가 달라져 있습니다.
그 위에는 데이터가 어떤 규칙을 따라 흘러야 하는지를 정하는 축이 있습니다. 제약조건, 기본값, 시퀀스가 이 역할을 하며, 이 규칙들이 옮겨지지 않으면 이관 후에 설명하기 어려운 데이터가 하나둘 쌓입니다.
세 번째는 데이터가 얼마나 빠르게 누구에게 열려 있는가입니다. 인덱스와 권한이 이 축을 결정하며, 사용자가 가장 먼저 체감하는 영역이기도 합니다.
00. 메타데이터 변환이 성패를 가른다
Character Set: 마이그레이션의 첫 번째 관문
마이그레이션 프로젝트에서 Character Set 변환을 소홀히 하면, 그 대가는 혹독합니다.
이관이 끝나고 시스템이 안정화되었다고 판단한 시점, 이런 민원이 들어옵니다. "특정 고객 이름이 검색되지 않습니다." 확인해보면 해당 고객의 이름에 포함된 한자가 물음표로 바뀌어 있습니다. 데이터는 존재하지만 원래의 문자가 아닙니다.
기술팀은 이관 도구의 문제를 의심합니다. 그런데 이관 로그에는 에러가 없습니다. 건수도 맞습니다. 이관 직후 샘플링 검증도 통과했습니다. 문제는 전체 데이터 중 극히 일부, 멀티바이트 문자가 포함된 레코드에서만 발생하고 있었고, 샘플링에 걸리지 않았을 뿐입니다.
이것이 Character Set 문제의 전형적인 발견 패턴입니다. 이관 시점이 아니라 운영 중에, 에러가 아니라 민원으로 발견됩니다. 그리고 발견 시점에는 이미 Source 시스템이 폐기되었거나, 원본과의 비교가 불가능한 상태인 경우가 대부분입니다.
데이터 타입을 잘못 선택해도 수정할 수 있습니다. 인덱스를 빼먹어도 나중에 추가할 수 있습니다. 하지만 Character Set을 잘못 선택하면, 데이터 자체를 믿을 수 없게 됩니다. 더 심각한 것은, 이 문제가 오픈 직후에 바로 드러나지 않는 경우가 더 많다는 점입니다.
그런데 이 문제의 원인을 추적하려면, Character Set이 실제로 무엇을 결정하는지부터 알아야 합니다. 데이터 타입이나 인덱스와 달리, Character Set은 이관 과정에서 검토 대상으로 인식되지 않는 경우가 많습니다. 그래서 문제가 발생해도 원인을 Character Set으로 연결하지 못하는 경우가 많습니다.
Character Set은 마이그레이션의 첫 번째 관문입니다. 여기서 어긋나면, 그 이후 모든 작업이 흔들립니다.
Character Set은 무엇을 결정하는가
Character Set은 흔히 "한글이 되느냐 안 되느냐" 정도로만 이해됩니다. 하지만 실제로 Character Set이 결정하는 것은 그보다 훨씬 근본적인 문제입니다. 그것은 바로 문자를 어떤 비트 패턴으로 저장하고, 그 비트를 어떤 문자로 해석할 것인가에 대한 약속입니다.
데이터베이스는 문자를 "글자"로 저장하지 않습니다. 내부적으로는 모두 숫자, 즉 비트의 조합으로 저장합니다. Character Set은 이 비트 조합이 어떤 문자를 의미하는지를 정의하는 사전과도 같습니다. 같은 비트라도 어떤 Character Set으로 해석하느냐에 따라 전혀 다른 문자로 보일 수 있습니다.
이 때문에 Character Set이 달라지는 순간, 데이터의 의미 자체가 달라질 수 있습니다. 기존 시스템에서는 정상적인 문자였던 데이터가 새로운 시스템에서는 존재하지 않는 문자로 취급되거나, 전혀 다른 문자로 해석될 수 있습니다. 이 경우 데이터는 손상되지만, 시스템은 이를 오류로 인식하지 못하는 경우도 많습니다.
이기종 마이그레이션에서 이것이 문제가 되는 이유는, 데이터베이스마다 Character Set의 구현 방식이 다르기 때문입니다. 실무에서 가장 자주 마주치는 Character Set은 크게 세 부류로 나뉩니다.
부류 | 주요 명칭 | 특징 | 마이그레이션 시 주의점 |
레거시 계열 | EUC-KR, KO16MSWIN949 | 한글 위주의 좁은 표현 범위 | 특수 한자, 고어(古語) 표현 불가 |
표준 유니코드 | UTF-8, AL32UTF8 | 전 세계 언어 수용 (글로벌 표준) | DB 제품별 바이트(Byte) 처리 방식 확인 필수 |
특수 변종 | MySQL utf8, utf8mb4 | 이름은 같으나 이모지 지원 여부 다름 | utf8mb4를 써야 이모지 저장 가능 |
01. 캐릭터셋 (character set) 전환
숫자를 옮기지 말고 재설계하라: VARCHAR 변환 전략
이기종 마이그레이션 프로젝트를 되짚어보면, 전체의 60% 이상이 오픈을 불과 2주 앞두고 메타데이터를 전면 재정의해야 했습니다. 특정 테이블 몇 개가 아닌 메타데이터 전체를 다시 구성해야 할 만큼 상황은 시급했습니다. '툴로 자동 변환했으니 문제없겠지'라고 생각했다가, 실제 데이터를 넣어보니 에러가 발생했습니다.
가장 많은 원인은 복잡한 로직이 아니라, 가장 기본이라 생각했던 문자형 데이터 타입이었습니다.
데이터 타입은 그 문자를 어떤 형태로 정의하고 다룰 것인가에 대한 약속입니다. 같은 데이터라도 어떤 타입으로 정의하느냐에 따라 저장 공간이 달라지고, 인덱스 전략이 달라지고, SQL 처리 방식이 달라집니다. 그리고 이 모든 차이는 메타데이터 변환 과정에서 반드시 이해하고 결정해야 할 사항입니다.
문자형 데이터 타입, 같은 이름, 그러나 다른 세계
문자형 데이터 타입 변환은 단순히 타입의 이름을 바꾸는 것이 아니라, 데이터의 성질과 시스템 성능, 서비스 무결성을 결정짓는 고도의 설계 작업입니다. 데이터베이스마다 문자형을 정의하는 철학이 달라서, 같은 이름의 타입이라도 내부 구현과 허용 길이, 심지어 저장 방식까지 다르기 때문입니다.
먼저, 전체 타입 매핑을 확인하겠습니다. 대부분의 자동 변환 툴이 아래 기준으로 변환하지만, 각 타입마다 숨은 함정이 있습니다.
분류 | Oracle | MySQL | PostgreSQL |
고정 길이 문자 | CHAR(n) (1~2000 BYTE) | CHAR(n) (0~255) | CHAR(n) |
가변 길이 문자 | VARCHAR2(n) (1~4000 BYTE) | VARCHAR(n) (0~65,535) | VARCHAR(n) |
유니코드 고정 | NCHAR(n) (1~2000 BYTE) | - | CHAR(n) |
유니코드 가변 | NVARCHAR2(n) | VARCHAR(n) | VARCHAR(n) |
중형 텍스트 | VARCHAR2(4000) 초과는 CLOB | TEXT (최대 65,535 BYTE) MEDIUMTEXT (최대 16MB) | TEXT (최대 1GB) |
대용량 텍스트 | CLOB (최대 4GB) | LONGTEXT (최대 4GB) | TEXT (최대 1GB) |
레거시 대용량 | LONG (최대 2GB) (Deprecated) | - | - |
유니코드 대용량 | NCLOB (최대 4GB) | LONGTEXT | TEXT |
위에 언급한 대로 데이터베이스마다 문자형 타입을 정의하는 철학이 다르기 때문에 이 기계적 매핑만으로는 부족합니다. 매핑되는 데이터 타입만 보고 안심하면, 그 뒤에 숨은 차이를 놓치게 됩니다.
VARCHAR와 CHAR: 길이라는 함정
VARCHAR(100) "문자형 데이터 100개를 저장할 수 있다"는 것까지는 명확합니다. 하지만 무엇의 100개인가? 여기서부터 데이터베이스마다 이야기가 달라집니다.
Oracle은 BYTE 단위와 CHAR 단위를 명시적으로 구분합니다. 단위를 명시하지 않으면 데이터베이스의 NLS_LENGTH_SEMANTICS 파라미터 설정에 따라 결정됩니다. 많은 레거시 시스템이 BYTE 단위를 기본값으로 사용하고 있습니다. 즉, VARCHAR2(100) 는 VARCHAR2(100 BYTE)와 같다고 표현할 수 있습니다. 하지만 PostgreSQL과 MySQL은 이런 구분이 없습니다. 이들 오픈소스 데이터베이스에서 VARCHAR(100)은 항상 100글자를 의미합니다.
여기에 Character Set 변환이 겹치면 계산이 복잡해집니다. Character Set이 EUC-KR에서 UTF-8로 바뀌면, 한글 한 글자가 차지하는 바이트가 2바이트에서 3바이트로 늘어납니다. 영문과 숫자는 여전히 1바이트지만, 한글은 1.5배로 커집니다.
02. 문자형 데이터 타입 (varchar) 전환
CHAR/N 타입 변환의 전략
CHAR와 NVARCHAR/NCHAR는 가장 단순해 보이지만, 실무에서는 자주 복병이 되는 타입입니다. 둘 다 문자열이고, 대부분의 이관 도구는 자동으로 변환해주며, 겉으로 보기에는 데이터도 정상적으로 들어가기 때문에 이 타입들은 종종 "신경 쓰지 않아도 되는 영역"으로 취급됩니다.
하지만 CHAR의 공백 패딩은 PostgreSQL에서 조용한 장애를, NVARCHAR의 인코딩 계약은 외부 연계 단절을 만들어냅니다. 문제는 타입 자체가 아니라, 이 타입이 과거 시스템에서 어떤 약속을 담고 있었는지를 알 수 없다는 점입니다.
이 문서는 메타데이터 변환에 집중합니다. 데이터를 어떻게 옮길 것인가가 아니라, 타입 정의를 어떻게 바꿀 것인가가 핵심입니다. CHAR → VARCHAR 전환이 데이터 값은 그대로 유지하더라도, 비교 방식, 저장 구조, 애플리케이션 동작을 바꿀 수 있습니다. 이 문서의 목적은 단 하나입니다. 언제 타입을 바꿔도 되는지, 언제 바꾸면 안 되는지를 판단할 수 있게 하는 것.
CHAR: 고정 길이가 만드는 공백
CHAR는 VARCHAR와 달리 고정 길이 타입으로서 CHAR(10)으로 정의하면 무조건 10바이트(또는 10문자)를 차지합니다. 만약 'A'를 저장하면 1글자만 입력했지만, 나머지 9바이트는 공백으로 채워지는데 이것을 공백 패딩(Blank Padding)이라고 합니다.
CHAR는 모든 데이터베이스에서 동일하게 동작한다?
DB 내부에서는 그렇습니다. 하지만 애플리케이션 레벨에서는 아닙니다. CHAR 타입은 Oracle, PostgreSQL, 그리고 MySQL의 기본적인 설정에서는 비교 연산 시(=, <, >, IN 등) trailing space를 무시합니다. 다만 SELECT 결과로 반환되는 값이나 LENGTH() 같은 함수에서는 trailing space가 포함됩니다.
이로 인해 데이터베이스 내부 비교는 일관된 결과를 제공하지만, 다음 영역에서는 주의가 필요합니다:
•
애플리케이션 바인딩: JDBC/ODBC로 읽은 값에는 trailing space가 포함되므로, 애플리케이션에서 equals() 비교 시 불일치 발생. trim() 처리 필요
•
ORM 매핑: JPA, MyBatis 등의 문자열 처리 동작 확인 필요
•
문자열 함수: CONCAT, SUBSTR 등에서 공백 포함 동작
•
외부 출력: JSON, CSV, API 응답에 공백 포함될 수 있음
•
MySQL collation: PAD SPACE vs NO PAD 설정에 따라 비교 동작 자체가 달라질 수 있음
아래 표는 CHAR(10) 컬럼 기준으로 DB 내부 비교 연산을 정리한 것입니다.
03. 문자형 데이터 타입 (char_nchar) 전환
LOB 변환은 타입 치환이 아니다
이기종 마이그레이션에서 LOB 컬럼이 만드는 문제들
"컬럼 타입 하나 바꾸는 건데, 그게 그렇게 어렵습니까?"
이기종 마이그레이션 프로젝트에서 LOB 컬럼 변환 작업이 시작될 때 종종 듣는 말입니다. CLOB이나 BLOB을 오픈소스 데이터베이스의 TEXT나 BYTEA로 바꾸는 작업을 단순한 타입 치환 정도로 보는 시각은 생각보다 흔합니다. 반대로 오픈소스에서 Oracle로 올 때도 마찬가지입니다. TEXT니까 CLOB으로 받으면 되는 것 아니냐는 질문도 자주 받습니다.
하지만 LOB 변환은 그 순간부터 이야기가 달라집니다.
타입 하나가 바뀌었을 뿐인데
CLOB 컬럼에 = 비교, DISTINCT, JOIN, ORDER BY를 사용하면 Oracle은 ORA-00932를 포함한 다양한 오류를 발생시킵니다. LIKE도 예외가 아닙니다. CLOB 컬럼에 LIKE를 직접 사용하면 ORA-00932 오류가 발생합니다. SUBSTR로 범위를 제한하거나 Oracle Text 인덱스를 사용하는 우회 방법이 필요하며, 어느 쪽이든 인덱스를 활용할 수 없어 성능과 예측 가능성 측면에서 문제가 되기 쉽습니다.
문제는 이런 SQL이 기존 코드 어딘가에 반드시 존재한다는 점입니다. VARCHAR2였을 때는 아무 문제가 없었기 때문입니다. 에러 메시지가 친절하게 알려주면 오히려 다행입니다. 에러도 없이 빈 결과만 돌려주는 경우가 더 찾기 어렵습니다.
그리고 이 문제는 Oracle 안에서만 발생하는 것이 아닙니다. Oracle에서 오픈소스로 가져갈 때, 반대로 오픈소스에서 Oracle로 이행할 때, 방향에 따라 다른 형태의 문제가 기다리고 있습니다.
LOB은 단순히 큰 문자열이 아니다
CLOB을 "크기가 큰 VARCHAR2" 정도로 이해하는 경우가 많습니다. 하지만 데이터베이스 내부에서 LOB은 완전히 다른 존재입니다.
일반 컬럼의 데이터는 행(Row)과 함께 같은 공간에 저장됩니다. 조회하면 그냥 읽히고, 비교하면 그냥 비교됩니다. 반면 LOB은 데이터베이스가 별도의 저장 공간을 만들어 관리합니다. Oracle이라면 LOB Segment와 LOB Index가 자동으로 생성되고, 실제 데이터는 그곳에 따로 보관됩니다. 행에는 그 위치를 가리키는 locator만 남습니다.
04. 문자형 데이터 타입 (lob) 전환
날짜형 데이터, 타입보다 데이터와 SQL을 먼저 보아야 합니다
데이터 타입 변환에서 문자형과 숫자형은 그나마 직관적입니다. VARCHAR2가 TEXT로, NUMBER가 NUMERIC으로 바뀌는 것은 이름이 달라도 의미가 크게 다르지 않습니다. 그래서 매핑표 한 줄로 정리하고 넘어가는 경우가 많습니다.
날짜형은 다릅니다. DATE라는 이름은 세 DB 모두에 있습니다. 그런데 Oracle의 DATE는 시간까지 담고, PostgreSQL의 DATE는 날짜만 담습니다. 그리고 이 차이는 매핑 시점에 오류를 내지 않습니다. 데이터가 들어간 것처럼 보이고, 이관은 완료됩니다. 문제는 나중에, 그것도 예상하지 못한 방식으로 드러납니다.
날짜형 변환이 어려운 이유는 기술적 복잡함이 아닙니다. 틀려도 바로 티가 나지 않는다는 점입니다.
이름이 같아도 담긴 것이 다르다
날짜형 이관은 저장의 문제가 아니라 해석의 문제입니다. 이 해석을 검증하지 않으면, 데이터는 결국 틀어집니다.
날짜형 변환에서 가장 자주 발생하는 실수는 대부분 같은 곳에서 출발합니다. Oracle의 DATE를 PostgreSQL의 DATE로, MySQL의 DATETIME을 Oracle의 DATE로 그대로 매핑하는 것입니다. 이름이 같으니 같은 것이라는 가정입니다.
Oracle의 DATE는 년/월/일/시/분/초를 모두 담는 7바이트 타입입니다. 반면 PostgreSQL의 DATE는 날짜만 저장합니다. 시간 정보는 아예 담을 수 없습니다. Oracle DATE를 PostgreSQL DATE로 매핑하는 순간, 수십 년간 쌓인 이력 데이터의 시간 정보가 전부 사라집니다. 오류도 없이. 같은 날 발생한 거래, 변경, 처리 이력은 모두 자정으로 수렴하고, 그 안에서의 순서는 복원할 수 없습니다. 데이터는 들어간 것처럼 보이지만 이미 일부가 사라진 상태입니다.
문제는 이 판단을 타입 이름에서 시작하는 데 있습니다. 실제로 그 컬럼에 시간 데이터가 들어 있는지, 타임존에 의존하고 있는지를 먼저 봐야 합니다. 컬럼이 날짜만 저장하는 용도였다면 TIMESTAMP(0)으로 받을 이유가 없고, 시간까지 쓰고 있었다면 DATE로 받을 수 없습니다. 타입 매핑표는 그 확인이 끝난 뒤에 만들어야 합니다.
타임존은 이관 후에 드러난다
타임존 문제가 까다로운 이유는 이관 직후에는 정상처럼 보인다는 점입니다. 같은 타임존 환경에서 테스트하기 때문입니다. 문제는 글로벌 사용자가 접근하거나, 시간 비교 로직이 실제로 동작하는 시점에 드러납니다. 서울과 뉴욕의 사용자가 같은 레코드를 서로 다른 시간으로 읽는 상황이 그렇게 만들어집니다.
Oracle의 두 타임존 타입, 이름보다 동작을 먼저 보자
감사나 규제 대응 시스템에서는 "언제"만큼이나 "어디서"가 중요합니다. 어느 타임존에서 입력된 데이터인지가 의미를 갖는 경우입니다. Oracle이 타임존을 다루는 두 가지 TIMESTAMP 타입을 제공하는 이유이기도 합니다.
05. 날자형 데이터타입 전환
숫자형 타입 변환: 당신의 스키마를 설계한 건 당신이 아니다
숫자형 변환은 실패하지 않는다. 그래서 가장 위험하다.
장면은 대부분 비슷합니다. 개발이 마무리되고 업무 담당자가 직접 화면을 검증하는 시점, 이런 말을 꺼냅니다. "Oracle에서는 12로 보이던 값이 왜 12.0000000000으로 나와요?"
기술팀 입장에서 이건 간단해 보입니다. 타입 하나 고치면 되는 것 아닌가. 그런데 실제로 고쳐보려고 하면 금방 복잡해집니다. 그 컬럼을 쓰는 화면이 몇 개인지, 외부 연계 전문에 이 값이 포함되어 있는지, 동일한 패턴으로 매핑된 컬럼이 몇 개나 더 있는지를 파악하는 순간, 이건 타입 하나의 문제가 아니라는 걸 알게 됩니다.
그리고 이 시점에서 가장 먼저 나오는 질문이 있습니다. "이 컬럼 타입을 누가 결정했죠?" 답은 대개 이렇습니다. "도구 기본값이요."
이것이 숫자형 타입 변환 문제의 실체입니다. 기술적 실수가 아니라, 초기에 아무도 결정하지 않은 항목이 도구의 기본값으로 채워진 것입니다. Oracle이 암묵적으로 처리하던 결정을 타겟 DB의 명시적 선택으로 전환하지 못해 발생하는 이 공백이 바로 Implicit Decision Gap입니다.
왜 숫자형만 이렇게 되는가
문자형이 깨지면 바로 보입니다. 날짜형이 맞지 않으면 예외가 발생합니다. 그런데 숫자형은 정수 값이 소수점 이하 열 자리를 달고 출력되어도 INSERT는 성공하고, 건수는 맞고, 마이그레이션 리포트는 성공이라 표시됩니다.
바로 이 성공이라는 메시지 때문에 숫자형 타입 변환은 검증 대상에서 빠집니다. 실패처럼 보이지 않으니, 점검할 이유를 느끼지 못하는 것입니다.
그런데 숫자형 타입에서의 성공 메시지가 문제가 없다고는 말할 수 없습니다. 애플리케이션은 숫자를 항상 수학적 값으로만 다루지 않습니다. 화면 출력, 문자열 변환, 외부 연계 전문의 자릿수 규약, 다운스트림 시스템의 파싱 로직 — 이 레이어들은 숫자의 표현 방식에 민감합니다. 수학적으로 같은 값이 시스템 동작 관점에서는 다른 값이 될 수 있습니다.
특히 외부 전문 연계가 있는 시스템에서는 이 문제가 더 직접적으로 나타납니다. 전문의 자릿수와 타입은 우리 쪽에서 임의로 바꿀 수 있는 속성이 아닙니다. 상대 시스템과 사전에 합의된 규약이기 때문에, 숫자 표현이 바뀌는 순간 그 규약이 깨집니다. 이건 내부 표현의 문제가 아니라 외부 계약의 문제입니다.
이 문제는 장애가 아니라 데이터 왜곡입니다. 그리고 데이터 왜곡은 로그에 남지 않습니다. 그래서 발견이 늦고, 발견될 때는 영향 범위가 이미 넓어져 있습니다.
왜 이 문제가 구조적으로 발생하는가
06. 숫자형 데이터타입 전환
사용자, 스키마, 권한: 같은 단어가 다른 구조를 가리킨다
이기종 마이그레이션에서 사용자와 권한 이관은 종종 작업의 뒷자리로 밀립니다. "DDL이 끝나면 USER 만들고 GRANT 좀 정리하면 되는 일"이라는 인식 때문입니다. 그런데 실제로 작업을 시작하면 가장 먼저 막히는 곳도 이 영역입니다.
이유는 단순합니다. USER, SCHEMA, ROLE이라는 같은 단어가 DB마다 다른 구조를 가리키기 때문입니다. Oracle의 USER는 계정이자 스키마이자 저장 단위이고, PostgreSQL의 ROLE은 계정일 수도 권한 묶음일 수도 있으며, MySQL의 DATABASE는 곧 SCHEMA입니다. 단어 하나가 양쪽에서 다른 것을 의미하기 때문에, 어느 쪽으로 옮기든 1:1 매핑은 성립하지 않습니다.
이 글은 양방향, 즉 Oracle → 오픈소스와 오픈소스 → Oracle을 모두 다룹니다. 어느 방향이든 USER를 그대로 옮기는 작업이 아니라, 각 USER가 실제로 어떤 역할을 하고 있는지 먼저 분해하고, 타겟 DB의 묶음 방식에 맞춰 다시 조립하는 작업이 필요합니다.
세 DB의 USER, SCHEMA, ROLE — 같은 단어, 다른 구조
개념 | Oracle | PostgreSQL | MySQL |
접속 주체 | USER | ROLE (LOGIN 속성) | 'user'@'host' |
권한 묶음 | ROLE | ROLE (NOLOGIN 속성) | ROLE (8.0+) |
스키마(네임스페이스) | USER ≡ SCHEMA | SCHEMA | DATABASE ≡ SCHEMA |
객체 컨테이너 계층 | (CDB →) PDB → SCHEMA | CLUSTER → DATABASE → SCHEMA | INSTANCE → DATABASE |
SQL상 객체 식별 | SCHEMA.OBJECT | SCHEMA.OBJECT (동일 DATABASE 내) | DATABASE.OBJECT |
무자격 객체 참조 | SYNONYM (PUBLIC/PRIVATE) | search_path | USE database |
표에서 "객체 컨테이너 계층"과 "SQL상 객체 식별"을 분리한 이유는, 두 축이 같지 않기 때문입니다. PostgreSQL은 한 CLUSTER 안에 여러 DATABASE가 존재하지만, SQL 안에서 다른 DATABASE의 객체를 database.schema.object로 참조할 수는 없습니다. cross-database 조회 자체가 불가능하고, 필요하다면 dblink 또는 postgres_fdw로 우회해야 합니다. MySQL은 반대로 한 INSTANCE 안의 모든 DATABASE를 database.table 자격으로 직접 조회할 수 있습니다.
Oracle은 11g까지는 한 DATABASE 안에 모든 USER/SCHEMA가 존재하는 단일 구조였지만, 12c부터 Multitenant 아키텍처가 도입되어 한 CDB(Container Database) 안에 여러 PDB(Pluggable Database)가 존재할 수 있게 되었습니다. PDB는 PostgreSQL의 DATABASE에 가까운 격리 단위로 동작합니다. 19c 이후로는 CDB 구성이 사실상 표준이고, 멀티테넌트 환경의 PDB 단위 격리는 이관 시 중요한 매핑 후보가 됩니다.
이 표가 보여주는 것은 명칭의 차이가 아닙니다. 세 DB가 "사용자라는 개념"을 서로 다른 방식으로 쪼개고 묶어두었다는 사실입니다. Oracle은 USER 안에 계정, 스키마, 저장 단위를 묶었고 ROLE은 별도의 권한 묶음으로 두었습니다. PostgreSQL은 USER와 GROUP을 ROLE 하나로 통합하면서 스키마는 따로 떼어 냈습니다. MySQL은 DATABASE와 SCHEMA를 통합하면서, 사용자는 호스트와 결합된 형태로 별도 관리합니다. 어느 쪽으로 옮기든, 한 DB에서 묶여 있던 것을 풀어내고 다른 DB의 묶음에 다시 끼워 넣어야 합니다.
USER를 옮기기 전에, USER의 실체를 먼저 분류해야 한다
이관의 시작은 타겟 DB의 어떤 객체를 만들지가 아니라, 소스 DB의 USER가 실제로 어떤 역할을 하고 있는지 분류하는 것입니다. 이 분류 없이 USER를 1:1로 옮기면, 권한 묶음으로만 쓰이던 계정이 접속 가능한 사용자가 되거나, 사람이 접속하던 계정이 권한이 없는 빈 컨테이너가 됩니다.
Oracle USER는 보통 다음 네 가지 역할 중 하나 또는 복수에 해당합니다.
첫째, 실제 접속하는 사람 또는 애플리케이션 계정입니다. 자신의 객체를 가질 수도 있고, 다른 스키마의 객체에만 접근할 수도 있습니다.
07. 사용자와 스키마 전환
저장구조: 같은 테이블이 같은 모양으로 놓이지 않는다
앞 글에서 USER가 묶고 있던 "누구의 것인가"를 분해했습니다. 그런데 USER가 함께 들고 있던 메타데이터가 하나 더 있습니다. DEFAULT TABLESPACE와 QUOTA — "어디에 놓일 것인가"입니다. 객체의 소유자가 결정되면, 그 객체가 어떤 물리 공간에 어떻게 놓일지가 따라옵니다.
그런데 저장구조 이관은 USER에 묶여 있던 TABLESPACE 기본값을 재배치하는 것에 그치지 않습니다. 세 DB가 "테이블을 저장한다"는 작업 자체를 다르게 정의하기 때문입니다. Oracle은 풍부한 저장 옵션을 제공하고, PostgreSQL은 Heap 중심의 단순한 기본형을 유지하면서 일부 저장 옵션을 조정하는 구조이고, MySQL InnoDB는 모든 테이블을 PK 기반 클러스터드 인덱스로 만듭니다. 같은 테이블이 같은 모양으로 놓이지 않습니다.
이 글은 양방향, 즉 Oracle의 풍부한 저장 메타데이터를 오픈소스의 단순한 모델로 흡수하는 방향과, 반대로 오픈소스의 단순한 저장 결정을 Oracle의 다층 옵션으로 재설계하는 방향 모두를 다룹니다.
세 DB의 저장구조 — 같은 단어, 다른 단위
개념 | Oracle | PostgreSQL | MySQL (InnoDB) |
논리 저장 영역 | TABLESPACE 기반 (모든 Segment를 TABLESPACE에 저장) | TABLESPACE (선택) | System / file-per-table / General Tablespace |
물리 단위 | DATAFILE | Relation file / segment file (TABLESPACE는 디렉토리 매핑) | file-per-table 기본 (.ibd) |
테이블 기본형 | Heap | Heap | Clustered Index (PK 강제) |
인덱스 조직 테이블 | IOT | (없음, CLUSTER는 일회성 정렬) | InnoDB 자체가 Clustered |
파티셔닝 | 별도 옵션 (라이센스 정책 확인 필요) | 10+ Range/List, 11+ Hash | 5.1+ (FK 미지원 등 제약 큼) |
압축 | Basic / Advanced(OLTP) / HCC | TOAST + 14 LZ4 | InnoDB Compression / Page Compression |
이 표가 보여주는 것은 단순한 기능 매트릭스가 아닙니다. 저장구조에 대한 결정권이 DB마다 누구에게 있는지를 보여줍니다. Oracle은 설계자가 결정합니다. TABLESPACE 분리, IOT 선택, 파티셔닝, 압축 옵션이 모두 설계 결정의 결과입니다. 오픈소스는 일정 부분을 DB가 결정합니다. PostgreSQL은 Heap이 강제되고, MySQL InnoDB는 클러스터드 구조가 강제됩니다. 설계자가 선택할 수 있는 폭이 좁은 만큼, 설계 단순성과 운영 일관성이 따라옵니다.
TABLESPACE — 같은 단어, 다른 무게감
Oracle에서 TABLESPACE는 핵심 자원입니다. 모든 객체는 어떤 TABLESPACE에든 속해야 하고, USER마다 DEFAULT TABLESPACE와 TEMP TABLESPACE, QUOTA가 정의됩니다. 운영 환경에서는 데이터와 인덱스를 다른 TABLESPACE에 분리하고, 대용량 테이블을 전용 TABLESPACE로 빼고, LOB도 별도 TABLESPACE에 둡니다. 12c+ Multitenant에서는 각 PDB가 자체 TABLESPACE 셋을 가지므로 격리 단위가 한 층 더 명확해집니다.
PostgreSQL의 TABLESPACE는 디렉토리 매핑에 가깝습니다. 객체 생성 시 명시하지 않으면 pg_default에 저장되며, 별도 TABLESPACE 사용은 선택입니다. 테이블별, 인덱스별로 다른 TABLESPACE 지정이 가능하지만, Oracle처럼 다층 분리를 강제하는 운영 관행은 일반적이지 않습니다.
MySQL InnoDB의 저장 구조는 여러 종류의 tablespace로 구성됩니다. 시스템 tablespace(ibdata1)는 5.7 기준 데이터 딕셔너리와 doublewrite buffer 같은 공용 구조를 담았고(8.0부터는 데이터 딕셔너리가 InnoDB 트랜잭셔널 DD인 mysql.ibd로, doublewrite buffer가 8.0.20+에서 별도 .dblwr 파일로 각각 분리되었습니다), file-per-table tablespace는 각 테이블이 자체 .ibd 파일을 갖는 기본 모드이며, General Tablespace(5.7+)는 여러 테이블을 하나의 공유 tablespace에 묶을 수 있는 선택지입니다. 운영 환경에서는 대부분 file-per-table 모드가 기본이어서, OS 파일 시스템 자체가 분리 단위 역할을 합니다.
Oracle → 오픈소스 방향에서는 다중 TABLESPACE 분리가 어떤 의도로 만들어져 있었는지를 먼저 파악해야 합니다. 과거 분리 디스크 시대에 강조되었던 데이터/인덱스 I/O 분산 의도는, ASM이나 Exadata, 외부 스토리지 가상화 환경에서는 실제 I/O 분산 효과가 약합니다. 실질적인 가치는 공간 통제, 백업·복구 단위 분리, 객체 수명 주기 관리(예: 과거 파티션을 별도 TABLESPACE로 옮긴 뒤 read-only로 전환) 쪽으로 옮겨가 있습니다. 따라서 TABLESPACE 이름만 옮기지 말고, 각 분리의 현재 의도가 무엇인지부터 정리하고, 그 의도를 타겟 DB의 어떤 메커니즘(파티션 단위 백업, 별도 디렉토리, OS 파일 시스템 등)으로 재현할지 결정해야 합니다.
오픈소스 → Oracle 방향에서는 반대 결정이 필요합니다. 단일 영역에 저장되던 객체들을 Oracle TABLESPACE 설계로 다시 분리할 것인지, 단일 TABLESPACE에 모아둘 것인지를 결정해야 합니다. 분리의 목적이 명확히 있을 때만 분리하는 것이 합리적이며, 이관 직후에는 단순 구조로 시작해 운영 안정화 후 필요한 분리를 단계적으로 도입하는 접근도 가능합니다. 어느 쪽이든 USER의 DEFAULT TABLESPACE와 QUOTA가 새로 설계되어야 하므로, 앞 글에서 정리한 USER 분류 결과를 입력으로 삼아야 합니다.
08. 저장구조 전환
제약조건과 DEFAULT: 데이터의 의미는 어디에서 결정되는가
앞 글에서 객체가 어디에 어떻게 놓이는지 — 저장구조를 다뤘습니다. 이제 그 객체에 데이터가 들어오고 변할 때 따라야 할 규칙으로 넘어옵니다. 제약조건과 DEFAULT입니다.
DDL 변환은 대부분 성공합니다. 제약조건과 DEFAULT도 문제없이 생성됩니다. 그런데 운영에 들어가면 데이터가 달라집니다.
제약조건은 단순히 데이터를 막는 장치가 아니라, 데이터의 의미를 강제하는 규칙입니다. 제약조건 이관이 어려운 이유는 구문의 복잡함이 아닙니다. 정의는 복사되지만, 그 정의가 데이터를 통제하던 방식은 복사되지 않는다는 점입니다. Oracle에서 당연하게 작동하던 규칙이 오픈소스에서는 다르게 작동하거나, 아예 작동하지 않습니다. 그리고 그 차이는 DDL이 아니라 데이터 안에서 드러납니다.
이 글은 양방향, 즉 Oracle의 제약조건·DEFAULT 패턴을 오픈소스로 옮기는 방향과, 반대로 오픈소스에서 운영되던 규칙을 Oracle로 옮기는 방향을 모두 다룹니다.
세 DB의 제약조건·DEFAULT 모델
항목 | Oracle | PostgreSQL | MySQL |
DEFAULT (컬럼 생략 시) | 지원 | 지원 | 지원 |
명시 NULL 가로채기 | DEFAULT ON NULL (12c+) | 미지원 (일반 DEFAULT는 명시 NULL을 가로채지 않음; trigger로만 재현) | 미지원 |
표현식 DEFAULT (함수/식) | 함수 호출 자유 | 함수 호출 자유 | 8.0.13+ 표현식 DEFAULT 지원 |
갱신 시각 자동 갱신 | trigger | trigger | ON UPDATE CURRENT_TIMESTAMP 컬럼 속성 |
시간 함수 평가 시점 | SYSDATE/SYSTIMESTAMP 호출 시점의 현재 시각 | now() 트랜잭션 시작 시점 / clock_timestamp() 호출 순간 | NOW()/CURRENT_TIMESTAMP statement 시작 시점 |
CHECK 상태 | ENABLE/DISABLE × VALIDATE/NOVALIDATE 4조합 | 상시 활성, 적재 시 NOT VALID로 검증 유예 가능 | 8.0.16+ 정식 지원(이전엔 파싱만) |
UNIQUE에서의 NULL | NULL 중복 허용 (NULL은 중복으로 보지 않음) | NULL 중복 허용 (기본 NULLS DISTINCT); 15+ UNIQUE NULLS NOT DISTINCT 옵션 | NULL 중복 허용 |
FK 동작 옵션 | CASCADE/SET NULL/NO ACTION (SET DEFAULT 미지원) | CASCADE/SET NULL/SET DEFAULT/RESTRICT/NO ACTION | CASCADE/SET NULL/RESTRICT/NO ACTION (SET DEFAULT 파싱은 되나 무시) |
FK 검증 시점 | IMMEDIATE / DEFERRABLE INITIALLY DEFERRED | 지원하지만 DDL에 DEFERRABLE 절 명시 필요(아래 참조) | DEFERRABLE 미지원 (항상 즉시 검증) |
EXCLUDE 제약 | (없음) | 지원 (GiST 인덱스 기반) | (없음) |
표가 보여주는 핵심은 같은 이름의 제약조건이 같은 의미로 작동하지 않는다는 점입니다. Oracle의 DEFAULT ON NULL, PostgreSQL의 EXCLUDE, MySQL의 ON UPDATE CURRENT_TIMESTAMP는 각 DB가 가진 고유 기능이고, 이름이 비슷한 옵션도 검증 시점·기본값·지원 범위가 다릅니다. DDL이 옮겨졌다고 동작이 따라오지는 않습니다.
DDL은 복사됐지만 의미가 달라지는 것들
정의는 그대로인데 데이터가 담는 의미가 달라진다면, 복사는 성공했지만 이관은 실패한 것입니다.
갱신 시각 컬럼. Oracle에서 갱신일시를 관리하는 일반적인 패턴은 trigger입니다. 행이 UPDATE될 때마다 trigger가 발동해 해당 컬럼에 현재 시각을 기록합니다. 이 패턴은 DDL에 명시되지 않습니다. 이관 도구는 테이블 정의와 DEFAULT 구문을 옮기지만, trigger는 별도 객체이므로 누락되거나 타겟 DB에 맞게 재작성되지 않으면 이관 후 갱신일시 컬럼은 INSERT 시점 이후로 더 이상 갱신되지 않습니다. MySQL은 ON UPDATE CURRENT_TIMESTAMP라는 컬럼 속성으로 trigger 없이 이 동작을 처리하지만, 그 동작은 explicit_defaults_for_timestamp 설정과 TIMESTAMP/DATETIME 타입 선택에 따라 미묘하게 갈립니다(특히 테이블의 첫 TIMESTAMP 컬럼에 대한 자동 초기화 동작은 이 시스템 변수에 따라 달라지며, 구버전 호환 모드에서는 명시하지 않아도 자동 적용되는 케이스가 있습니다). 방향이 바뀌어 MySQL → Oracle/PostgreSQL이라면 컬럼 속성에 담겨 있던 동작을 BEFORE UPDATE trigger로 풀어내야 하고(AFTER 시점에서는 컬럼 값을 수정할 수 없습니다), 이 변환이 빠지면 데이터는 들어가 있고 오류도 없지만 이력 추적의 의미가 사라진 상태가 됩니다.
시간 함수의 평가 시점. PostgreSQL의 now()/CURRENT_TIMESTAMP는 트랜잭션 시작 시점으로 고정됩니다. 하나의 트랜잭션 안에서 수십만 건을 INSERT해도 timestamp는 동일합니다. Oracle의 SYSDATE/SYSTIMESTAMP는 호출 시점의 현재 시각을 반환하므로 같은 SQL 안에서는 같은 값처럼 보일 수 있지만, PostgreSQL now()처럼 트랜잭션 시작 시점으로 고정되는 모델과는 다릅니다. MySQL의 NOW()/CURRENT_TIMESTAMP는 statement 시작 시점에 평가되어 같은 statement 안에서는 동일한 값을 반환합니다. 행 단위로 다른 시각이 찍혀야 하는 이력 테이블이라면, PostgreSQL에서는 clock_timestamp()를 명시적으로 써야 합니다. DEFAULT에 어떤 함수를 쓰느냐가 아니라, 그 함수가 어떤 시점 기준으로 평가되는지를 확인하지 않으면 이 차이는 데이터 안에 그대로 반영됩니다.
명시 NULL 가로채기. Oracle 12c+의 DEFAULT ... ON NULL은 애플리케이션이 명시적으로 NULL을 보내더라도 DB가 기본값으로 가로채는 동작입니다. 오픈소스의 일반 DEFAULT는 컬럼이 생략됐을 때만 작동하고, 표현식 DEFAULT 역시 마찬가지여서 명시 NULL을 가로채지 못합니다. 명시적으로 NULL을 보내면 그대로 저장되거나, NOT NULL 제약이 걸려 있다면 에러로 돌아옵니다. 같은 INSERT 문이 Oracle에서는 성공하고 오픈소스에서는 실패하는 상황이 여기서 발생합니다. 오픈소스에서 이 동작을 재현하려면 BEFORE INSERT trigger로 NULL을 가로채는 방법밖에 없습니다. Oracle → 오픈소스 방향에서는 애플리케이션의 NULL 전송 로직을 함께 점검하거나, trigger 설계를 추가해야 합니다.
09. 제약조건과 DEFAULT 전환
식별자 채번: 같은 번호가 두 번 찍히는 사고가 가장 자주 나는 곳
앞 글에서 데이터가 따라야 할 규칙 — 제약조건과 DEFAULT — 을 다뤘습니다. 그 규칙 가운데, 가장 단순해 보이면서 이관 후 가장 자주 사고가 나는 영역이 식별자 채번입니다.
장면은 거의 정해져 있습니다. 데이터 이관이 끝나고, 건수도 맞고, 무결성 검증도 통과합니다. 오픈 직후 첫 INSERT가 들어오는 순간 PK 충돌 에러가 발생합니다. 원인은 단 하나 — 시퀀스의 NEXTVAL이 현재 PK의 최대값보다 작은 상태로 이관되었기 때문입니다. 시퀀스 동기화가 누락된 것입니다.
채번 사고가 빈번한 이유는 채번 메커니즘 자체가 단순해 보이기 때문입니다. "다음 번호를 받는다"는 한 줄의 동작 뒤에, 세 DB는 서로 다른 객체와 서로 다른 동시성 모델, 서로 다른 갭 정책을 가지고 있습니다. 이 차이를 모르고 옮기면, 같은 번호가 두 번 찍히거나, 번호가 비정상적으로 건너뛰거나, 다중 노드에서 순서가 뒤집힙니다.
이 글은 시퀀스/IDENTITY/AUTO_INCREMENT처럼 DB가 발급하는 채번 방식을 중심에 두지만, UUID/GUID 같은 분산 친화적 채번과 애플리케이션이 직접 MAX(id)+1을 계산하는 패턴까지 포함해 다룹니다. 채번은 시퀀스만의 일이 아니고, 이관은 채번 방식 자체를 재검토할 기회이기도 합니다. 방향은 Oracle → 오픈소스와 오픈소스 → Oracle 모두를 고려합니다.
세 DB의 채번 모델 — 객체가 있는가, 속성만 있는가
항목 | Oracle | PostgreSQL | MySQL |
독립 시퀀스 객체 | SEQUENCE | SEQUENCE | (없음; MariaDB 10.3+ 별도) |
컬럼 채번 속성 | IDENTITY (12c+) | IDENTITY (10+), SERIAL (legacy) | AUTO_INCREMENT |
호출 방식 | seq.NEXTVAL / seq.CURRVAL | nextval('seq') / currval('seq') / lastval() | INSERT 후 LAST_INSERT_ID() |
캐시 기본값 | CACHE 20 | CACHE 1 | innodb_autoinc_lock_mode에 따름 |
다중 컬럼/테이블 공유 | 시퀀스를 어디서든 참조 가능 | 시퀀스를 어디서든 참조 가능 | AUTO_INCREMENT는 테이블당 1컬럼 한정 |
트랜잭션 격리 | 비격리 (ROLLBACK해도 갭 발생) | 비격리 (ROLLBACK해도 갭 발생) | 비격리 (lock_mode에 따라 갭 패턴 다름) |
UUID 네이티브 지원 | SYS_GUID() (RAW(16)) | uuid 타입, gen_random_uuid() | UUID(), UUID_TO_BIN() (8.0+) |
표가 보여주는 핵심은 두 가지입니다. 첫째, 독립된 SEQUENCE 객체의 존재 여부가 DB마다 다릅니다. Oracle과 PostgreSQL은 시퀀스를 별도 객체로 노출하므로 여러 테이블이 같은 시퀀스를 공유하거나, INSERT 외 컨텍스트에서 NEXTVAL을 호출하는 패턴이 가능합니다. MySQL은 AUTO_INCREMENT가 컬럼 속성이라 시퀀스를 객체로 다루지 못합니다(MariaDB 10.3+에서 SEQUENCE 객체가 도입되었지만 표준 MySQL은 여전히 부재). 둘째, 시퀀스는 어느 DB에서든 트랜잭션 격리 대상이 아닙니다. ROLLBACK해도 발급된 번호는 되돌아오지 않습니다. 갭은 정상이고, 갭이 없기를 기대하는 설계는 어디서든 위험합니다.
시퀀스 객체의 위상 — Oracle/PostgreSQL이 가지고 있는 자유, MySQL이 갖지 못하는 자유
Oracle과 PostgreSQL의 SEQUENCE는 독립 객체입니다. 여러 테이블이 하나의 시퀀스를 공유할 수 있고, 시퀀스를 SQL 컨텍스트(SELECT, 함수 인자, INSERT … VALUES 등) 어디에서나 참조할 수 있습니다. 글로벌 ID 발급기, 비-PK 컬럼의 채번, 코드 생성 등 응용 범위가 넓습니다.
MySQL의 AUTO_INCREMENT는 다릅니다. 컬럼에 묶인 속성이며 객체로 노출되지 않습니다. 한 테이블에는 하나의 AUTO_INCREMENT 컬럼만 둘 수 있으며, 해당 컬럼은 인덱스에 포함되어 MySQL이 다음 값을 찾을 수 있어야 합니다. InnoDB에서는 인덱스 설계가 AUTO_INCREMENT 동작과 밀접하게 연결됩니다. 여러 테이블이 같은 채번 공간을 공유하는 패턴은 native 기능으로는 구현되지 않습니다. AUTO_INCREMENT 값을 단독으로 미리 발급받아 다른 컬럼에 채워 넣는 패턴도 자연스럽지 않습니다.
Oracle/PostgreSQL → MySQL 방향에서 가장 큰 함정이 여기에 있습니다. Oracle에서 여러 테이블이 GLOBAL_ID_SEQ 같은 공용 시퀀스를 참조하던 경우, MySQL에서는 직접 대응이 어렵습니다. 보통은 채번 전용 테이블을 만들어 INSERT ... ON DUPLICATE KEY UPDATE로 우회하거나 별도 채번 서비스를 두는데, 동시성과 성능 모두 native 시퀀스에 미치지 못합니다. 이관 전에 "이 시퀀스가 정말 공유되어야 하는가, 아니면 테이블별 채번으로 분리 가능한가"를 먼저 결정해야 합니다.
MySQL → Oracle/PostgreSQL 방향에서는 반대로 자유의 폭이 넓어집니다. AUTO_INCREMENT가 SEQUENCE 객체로 분리되면서 채번 정책을 더 정교하게 설계할 수 있게 됩니다. 다만 이관 시점에는 1:1 대응(테이블당 시퀀스 1개)으로 가져가고, 통합·재설계는 이관 후 별도 작업으로 분리하는 것이 안전합니다.
10. 시퀀스와 식별자 채번 전환
인덱스: 인덱스는 구조가 아니라 해석 대상이다
앞 글에서 식별자가 어떻게 채번되어 테이블에 들어가는지 — 시퀀스를 다뤘습니다. 그 식별자가 들어간 데이터를 빠르게 찾는 메타데이터가 인덱스입니다.
이기종 마이그레이션에서 인덱스 이관은 DDL을 복사하면 되는 것처럼 보입니다. Oracle DDL을 추출해서 오픈소스에 생성했고, 오류도 없었습니다. 그런데 운영에 들어가면 타임아웃이 납니다. 인덱스는 존재합니다. 옵티마이저가 선택하지 않았을 뿐입니다.
인덱스 이관이 어려운 이유는 구조의 복잡함이 아닙니다. 물리적 껍데기는 복사되지만, 그 인덱스를 선택하게 만들던 판단 기준은 복사되지 않는다는 점입니다. Oracle이 암묵적으로 제공하던 스캔 전략과 옵티마이저 철학은 DDL 안에 없습니다. 인덱스를 옮기는 것과 인덱스를 '타게' 만드는 것은 완전히 다른 작업입니다.
이 글은 양방향, 즉 Oracle의 풍부한 인덱스 옵션을 오픈소스의 단순한 모델로 흡수하는 방향과, 반대로 오픈소스의 특수 인덱스 — Partial Index, BRIN, GIN — 가 Oracle로 옮겨질 때의 비대칭을 모두 다룹니다.
세 DB의 인덱스 모델 — 같은 B-tree, 다른 옵션
항목 | Oracle | PostgreSQL | MySQL (InnoDB) |
기본 인덱스 구조 | B-tree (Heap 분리) | B-tree (Heap 분리) | B+tree (Clustered: 테이블 = PK 인덱스) |
Secondary Index 리프 | ROWID(고정 길이) 참조 | TID(ctid) 참조 | PK 값 전체 복사 |
Index Skip Scan | 지원 (커버링 요건 없음) | 18+ 도입(제한적; 폭넓은 활용 모델 아님) | 8.0+ (제한적; 인덱스 구성/비용에 크게 의존) |
Covering / Index Only Scan | Index Fast Full Scan / 컬럼 포함 인덱스 | INCLUDE 절 (11+) + visibility map 필요 | Secondary Index에 필요 컬럼 모두 포함 필요 |
Function-Based Index | 지원 (혼합 정렬 시 내부 SYS_OP_DESCEND 등) | Expression Index | Functional Index (8.0.13+) |
인덱스 컬럼 정렬 방향 | ASC/DESC 자유 | ASC/DESC 자유 | 실제 DESC 인덱스 (8.0.1+) |
NULL의 인덱스 저장 | 전부 NULL인 행은 인덱스 제외 | NULL도 인덱스에 저장 | NULL도 인덱스에 저장 |
Partial Index | (직접 대응 없음; FBI로 부분 우회) | CREATE INDEX ... WHERE 지원 | 미지원 |
Bitmap Index | 지원 (DW 위주) | 미지원 (Bitmap Index Scan은 실행 시 동적 변환) | 미지원 |
특수 인덱스 | Reverse Key, IOT, Domain Index | GiST, GIN, BRIN, SP-GiST, Hash | Spatial(R-tree), Full-Text, Hash(MEMORY) |
사용 모니터링 | Index Monitoring (ALTER INDEX ... MONITORING USAGE) | pg_stat_user_indexes (자동 수집) | sys.schema_unused_indexes |
표가 보여주는 핵심은 세 가지입니다. 첫째, B-tree라는 같은 이름 뒤의 저장 구조가 다릅니다 — 특히 InnoDB의 Secondary Index 리프에 PK 값이 통째로 복사된다는 점은 인덱스 비용 산정 전체를 바꿉니다(저장구조 편 참조). 둘째, Skip Scan과 Covering 같은 스캔 기법이 DB마다 비대칭입니다. 셋째, 각 DB가 가진 특수 인덱스(Oracle Bitmap, PostgreSQL Partial/BRIN/GIN)는 직접 대응이 없거나 매우 다른 동작 모델을 가집니다. 이름만 같은 인덱스를 1:1로 옮기겠다는 전제는 첫 번째 함정입니다.
복합 인덱스의 선두 컬럼이 없으면 — Skip Scan의 비대칭
Oracle 환경에서는 인덱스를 촘촘하게 설계하지 않는 경우가 많습니다. (A, B, C)라는 복합 인덱스가 있을 때, 쿼리의 조건절에 선두 컬럼인 A가 빠지고 B와 C만 들어와도 옵티마이저는 Index Skip Scan으로 인덱스를 활용할 수 있습니다. Oracle의 Skip Scan은 커버링 요건이 없으며, 선두 컬럼의 distinct 값 수가 낮을수록 효과가 크게 나타납니다.
오픈소스의 상황은 다릅니다. PostgreSQL은 18에서 B-tree skip scan이 정식 도입되었지만, Oracle처럼 일반적인 Index Skip Scan을 폭넓게 활용하는 모델이 아니며, 선두 컬럼이 빠진 복합 인덱스 접근은 대부분 별도 인덱스 재설계 대상으로 보는 것이 안전합니다. 18 이전 버전에서는 옵티마이저가 Full Table Scan(또는 Sequential Scan)을 선택하는 경우가 많습니다.
MySQL은 8.0부터 Index Skip Scan을 지원하지만, 선두 컬럼의 카디널리티가 낮고 비용상 유리할 때 제한적으로 선택되며, 커버링 여부 등 인덱스 구성에 크게 영향을 받습니다. Oracle Skip Scan과 같은 폭으로 동작한다고 전제할 수 없습니다.
이로 인해 "인덱스 하나로 여러 패턴을 커버한다"는 Oracle 시절의 전략은 오픈소스로 넘어오면서 "패턴별로 인덱스를 쪼개고 분리한다"는 전략으로 바뀌어야 합니다.
11. 인덱스 전환
내부 로직: 시스템 안에 사는 코드가 가장 옮기기 어렵다
데이터베이스 안에는 데이터가 아닌 코드도 함께 살고 있습니다. 프로시저, 함수, 패키지, 트리거, 뷰, 머티리얼라이즈드 뷰, 그리고 잡 스케줄러입니다.
이 코드는 보이지 않습니다. 애플리케이션 화면이나 API 응답에 드러나지 않고, DDL을 추출하면 다른 객체에 비해 분량이 가장 큽니다. 그리고 어느 DB에서든 가장 깊이 박힌 메타데이터입니다. 데이터 타입과 저장구조를 모두 정확히 옮겨도, 내부 로직이 정확히 옮겨지지 않으면 시스템의 행동이 달라집니다. 사용자는 이 변화를 "기능이 사라졌다", "결과가 미묘하게 다르다"는 형태로 마주합니다.
내부 로직 이관이 가장 어려운 이유는 세 가지입니다. 첫째, 코드의 양이 가장 많고 방언이 가장 깊습니다. PL/SQL, PL/pgSQL, MySQL stored routine은 표면 문법은 비슷해 보여도 의미와 동작이 다른 곳에서 갈립니다. 둘째, DB 외부에서는 보이지 않습니다. 애플리케이션 코드 리뷰만으로는 잡히지 않는 로직이 DB 안에 누적되어 있습니다. 셋째, 객체 카테고리 자체가 다른 경우가 많습니다. Oracle의 패키지는 다른 DB에 직접 대응이 없고, 머티리얼라이즈드 뷰의 동작 모델은 DB마다 근본적으로 다릅니다.
이 글은 양방향, 즉 Oracle의 풍부한 PL/SQL 자산을 오픈소스로 옮기는 방향과 오픈소스의 단순한 내부 로직을 Oracle로 옮기는 방향 모두를 다룹니다.
세 DB의 내부 로직 모델 — 같은 객체 이름, 다른 깊이
내부 로직 영역은 두 축에서 차이가 납니다. 하나는 객체 카테고리 자체의 비대칭이고, 다른 하나는 언어와 런타임 특성의 비대칭입니다. 표를 둘로 나누어 봅니다.
객체 카테고리
객체 | Oracle | PostgreSQL | MySQL |
프로시저 | PROCEDURE | PROCEDURE (11+) | PROCEDURE |
함수 | FUNCTION | FUNCTION | FUNCTION (DETERMINISTIC/SQL DATA 명시) |
패키지 | PACKAGE / PACKAGE BODY | (없음; SCHEMA + 함수 prefix로 우회) | (없음) |
트리거 단위 | FOR EACH ROW / STATEMENT, COMPOUND | FOR EACH ROW / STATEMENT (trigger function 별도) | FOR EACH ROW만 |
INSTEAD OF 트리거 | 지원 (뷰) | 지원 (뷰) | 미지원 |
뷰 옵션 | WITH READ ONLY, WITH CHECK OPTION | WITH CHECK OPTION, INSTEAD OF 트리거 | WITH CHECK OPTION, ALGORITHM |
머티리얼라이즈드 뷰 | MV + MV Log (Fast Refresh, ON COMMIT/DEMAND, Query Rewrite) | MV (전체 재생성 / CONCURRENTLY) | 네이티브 부재 |
Job / Scheduler | DBMS_SCHEDULER, DBMS_JOB(legacy) | pg_cron, pgAgent 등 외부 확장 | Event Scheduler (native) |
언어·런타임 특성
특성 | Oracle | PostgreSQL | MySQL |
표준 PL 언어 | PL/SQL | PL/pgSQL (+ PL/Python, PL/V8, PL/Perl 등) | stored routine (SQL/PSM 기반) |
자율 트랜잭션 | PRAGMA AUTONOMOUS_TRANSACTION | (없음; PROCEDURE 11+ 내 COMMIT은 다른 의미) | (없음) |
컬럼/행 타입 참조 | %TYPE, %ROWTYPE | %TYPE, %ROWTYPE (PL/pgSQL) | (없음) |
컬렉션 타입 | Index-By Table, VARRAY, Nested Table | ARRAY, 복합 타입 | (없음; JSON/문자열로 우회) |
예외 모델 | 명명 예외, PRAGMA EXCEPTION_INIT, SQLCODE/SQLERRM | SQLSTATE 기반 EXCEPTION WHEN | DECLARE ... HANDLER, SIGNAL SQLSTATE |
함수 오버로딩 | 지원 | 지원 | 미지원 |
두 표가 함께 보여주는 핵심은 객체와 언어 양쪽에서 비대칭이라는 점입니다. 객체 측에서는 패키지, 자율 트랜잭션 객체화, MV Fast Refresh — Oracle만 가지는 것이 분명합니다. 반대로 PostgreSQL의 다중 PL 언어와 EVENT TRIGGER는 Oracle에 직접 대응이 없습니다. 언어 측에서는 예외 모델·컬렉션 타입·오버로딩 같은 기본 빌딩 블록부터 다릅니다. 이름만 같은 객체를 1:1로 옮기겠다는 전제는 첫 번째 함정입니다.
프로시저, 함수, 그리고 Oracle 패키지
12. 내부 로직 전환
통계 정보: 옵티마이저가 같은 SQL을 다르게 해석하는 이유
인덱스 편(11)의 결론에서 "옵티마이저는 DB마다 다르게 생각한다"고 했습니다. 그 차이는 어디서 오는가 — 비용 모델의 가중치, 조인 알고리즘의 편향, 인덱스 선택 휴리스틱이 있지만, 옵티마이저가 무엇을 보고 결정하는지의 입력 자체가 다르기 때문이기도 합니다.
그 입력이 바로 통계 정보입니다. 테이블 행 수, 컬럼별 값의 분포, 히스토그램, 다중 컬럼 상관관계, 인덱스의 물리 순서와 테이블 정렬도의 일치 정도, 시스템의 I/O 비용. 옵티마이저는 이 메타데이터를 보고 실행 계획을 만듭니다. 통계 정보가 부정확하거나 누락되면, 같은 SQL이 전혀 다른 계획으로 실행됩니다.
이관 시점에 통계 정보는 메타데이터로 인식되지 않는 경우가 많습니다. DDL에 적혀 있지 않고, 데이터 자체도 아니며, "데이터를 적재하면 알아서 따라오는 것"으로 여겨집니다. 하지만 통계는 적재되지 않습니다. 이관 직후에는 통계가 비어 있거나 부정확한 상태이고, 첫 번째 자동 수집이 돌기 전까지 옵티마이저는 잘못된 입력으로 계획을 만듭니다. 이 사이의 운영 안정성을 어떻게 확보하느냐가 이관 후 첫 며칠의 성능을 결정합니다.
이 글은 양방향, 즉 Oracle의 풍부한 통계 메타데이터를 오픈소스의 단순한 모델로 흡수하는 방향과, 반대로 오픈소스의 통계 모델이 Oracle로 옮겨질 때 새로 활용할 수 있는 옵션을 모두 다룹니다.
세 DB의 통계 정보 모델 — 같은 목적, 다른 모델
항목 | Oracle | PostgreSQL | MySQL (InnoDB) |
수집 명령 | DBMS_STATS.GATHER_* | ANALYZE | ANALYZE TABLE |
자동 수집 | 11g+ maintenance window 기본 활성화 | autovacuum이 분석 자동 트리거 | innodb_stats_auto_recalc (5.6.6+ 기본 ON) |
통계 저장 위치 | DBA_TAB_STATISTICS, DBA_TAB_COL_STATISTICS, DBA_HISTOGRAMS | pg_class, pg_statistic(pg_stats 뷰) | mysql.innodb_table_stats, mysql.innodb_index_stats |
샘플링 기본값 | AUTO_SAMPLE_SIZE (11g+ 근사 NDV 알고리즘) | default_statistics_target 기본 100 | innodb_stats_persistent_sample_pages 기본 20 |
히스토그램 | Frequency, Height-Balanced, Top-Frequency / Hybrid (12c+) | MCV + equi-depth 히스토그램 | 8.0+ singleton / equi-height |
확장 통계 | Extended Statistics (11g+; 컬럼 그룹·표현식) | CREATE STATISTICS (10+: ndistinct/dependencies, 12+: mcv, 14+: expression) | (없음) |
인덱스 | CLUSTERING_FACTOR (USER_INDEXES) | pg_stats.correlation (컬럼 단위) | (별도 통계 없음; 클러스터드 구조에 흡수) |
시스템 통계 | DBMS_STATS.GATHER_SYSTEM_STATS (CPU/IO) | seq_page_cost, random_page_cost 등 GUC | 별도 시스템 통계 객체 없음 (서버 변수 기반) |
통계 잠금/이력 | LOCK_TABLE_STATS, RESTORE_TABLE_STATS | (네이티브 부재; 백업 필요) | (네이티브 부재) |
실행 계획 안정화 | SQL Plan Baseline, SQL Profile, SQL Patch, Outline(legacy) | pg_hint_plan(외부 확장) 또는 GUC 조정 | Optimizer Hints (인라인) |
표가 보여주는 핵심은 세 가지입니다. 첫째, 수집 메커니즘은 비슷하지만 자동화 주체가 다릅니다. Oracle은 별도 maintenance window의 잡으로, PostgreSQL은 autovacuum 데몬으로, MySQL은 InnoDB가 변경량 기반으로 트리거합니다. 둘째, 히스토그램·확장 통계·인덱스 정렬도 통계의 표현력이 비대칭입니다. 같은 목적을 다른 방식으로 풀어내며, 일부는 다른 DB에 대응 자체가 없습니다. 셋째, 실행 계획 안정화는 Oracle이 압도적으로 풍부합니다. SQL Plan Baseline 같은 객체가 다른 DB에는 직접 대응이 없습니다.
수집 메커니즘 — 누가 언제 통계를 모으는가
Oracle은 11g부터 자동 통계 수집이 기본 활성화되어 있습니다. 평일 밤과 주말의 maintenance window 동안 DBMS_STATS.GATHER_DATABASE_STATS_JOB_PROC이 돌며 변경량이 임계치를 넘은 객체를 분석합니다. 샘플링은 AUTO_SAMPLE_SIZE가 기본인데, 11g 이후 자동 샘플링 알고리즘(HLL 계열 근사 NDV 추정 등)이 도입되어 전수 스캔에 가까운 정확도를 더 낮은 비용으로 얻도록 동작합니다. 운영자는 잡 스케줄을 조정하거나, 특정 객체에 다른 옵션(METHOD_OPT, ESTIMATE_PERCENT 등)을 설정해 커스터마이즈합니다.
PostgreSQL은 autovacuum 데몬이 분석을 함께 처리합니다. autovacuum_analyze_scale_factor(기본 0.1, 즉 10% 변경 시 트리거)와 autovacuum_analyze_threshold로 시점이 결정됩니다. 샘플 크기는 컬럼별 default_statistics_target(기본 100)에 비례하며, 기본값에서는 대략 수만 행 수준의 샘플을 기반으로 통계를 만듭니다. 큰 테이블이나 분포가 까다로운 컬럼은 ALTER TABLE ... ALTER COLUMN ... SET STATISTICS N으로 컬럼별 조정합니다. autovacuum이 꺼져 있거나 임계치를 넘지 못한 테이블의 통계는 갱신되지 않습니다.
MySQL InnoDB는 Persistent Statistics가 5.6.6부터 기본입니다(innodb_stats_persistent = ON). innodb_stats_auto_recalc가 켜져 있으면 테이블 변경량이 10%를 넘을 때 자동 재계산하고, 결과는 mysql.innodb_table_stats/mysql.innodb_index_stats에 영구 저장됩니다. 샘플링 페이지 수는 innodb_stats_persistent_sample_pages(기본 20)로 결정되며, 카디널리티 추정이 부정확한 큰 테이블에서는 이 값을 늘려야 하는 경우가 많습니다.
Oracle → 오픈소스 방향에서 가장 흔한 사고가 자동 수집 메커니즘의 격차에서 나옵니다. Oracle은 maintenance window에 차분히 정밀한 통계를 모으는 운영 관행에 익숙해져 있지만, PostgreSQL autovacuum은 변경량 임계치 기반이라 변경이 적은 테이블의 통계는 영원히 갱신되지 않을 수도 있습니다. 대용량 로그 테이블, 거의 변하지 않는 마스터 테이블에서 이 격차가 드러납니다. MySQL은 샘플 페이지 기본값(20)이 작아, 큰 테이블의 카디널리티 오차가 Oracle보다 크게 잡히는 경우가 잦습니다.
13. 통계 정보와 옵티마이저 전환
마치며: 데이터가 아니라 의미를 옮기는 일
13편을 마쳤습니다. 캐릭터셋부터 통계 정보까지, 데이터베이스 안에 살고 있는 모든 종류의 메타데이터를 살펴봤습니다. 이 글은 13편을 가로지르는 통합 정리이자, 시리즈를 처음 시작할 때 던졌던 질문 — "데이터만 잘 옮기면 되는 것 아닌가요?" — 에 대한 마지막 응답입니다.
13편을 관통하는 한 가지 사실이 있다면, 모든 편이 같은 말을 다른 각도에서 반복했다는 점입니다. DDL은 복사되지만, 그 DDL이 데이터를 통제하던 방식은 복사되지 않습니다. 같은 컬럼 정의가 다른 의미를 만들고, 같은 객체 이름이 다른 모델로 동작하며, 같은 SQL이 다른 계획으로 실행됩니다. 이 사실을 어디서 어떻게 마주치는지가 13편의 내용이었습니다.
13편이 채운 메타데이터의 지도
서론(00)에서 메타데이터를 네 축으로 정의했습니다. 각 편이 그 네 축에 어떻게 매핑되는지, 그리고 4축 분류 바깥에 추가된 한 축(옵티마이저 입력)이 어디에 위치하는지를 정리하면 다음과 같습니다.
메타데이터 축 | 다룬 편 | 핵심 메시지 |
저장 형태 | 01 캐릭터셋 · 02 VARCHAR · 03 CHAR/NVARCHAR · 04 LOB · 05 날짜형 · 06 숫자형 · 08 저장구조 | 같은 값이 같은 모양으로 저장되지 않는다. 도구의 기본값이 결정을 대신하지 못한다 |
흐름의 규칙 | 09 제약조건·DEFAULT · 10 시퀀스·식별자 채번 | 규칙은 정의가 아니라 실행 상태로 옮겨진다. 이관 직후 동기화가 마지막 매듭 |
접근성 | 07 사용자·스키마·권한 · 11 인덱스 | 같은 단어가 다른 구조를 가리키고, 같은 인덱스가 다르게 해석된다 |
내부 로직 | 12 프로시저·트리거·뷰·MV·Scheduler | 가장 깊이 박힌 코드가 가장 늦게 옮겨지고 가장 자주 누락된다 |
(확장) 옵티마이저 입력 | 13 통계 정보 | 같은 SQL이 다르게 해석되는 이유는 통계 격차에 있다 |
13편(통계)은 4축에 정확히 들어가지 않습니다. 통계는 객체 자체가 아니라 옵티마이저의 입력이라는 별도 위상을 가지기 때문입니다. 인덱스 편(11)에서 옵티마이저가 다르게 생각하는 이유를 비용 모델 차이로 설명했지만, 그 비용 모델의 입력값이 통계입니다. 4축 모두를 정확히 옮겨도, 통계가 비어 있거나 부정확하면 옵티마이저는 잘못된 입력으로 계획을 만듭니다. 그래서 시리즈 마지막에 별도의 축으로 위치를 부여했습니다.
시리즈를 가로지르는 세 가지 공통 메시지
13편이 다룬 영역은 서로 다르지만, 글을 가로지르는 세 가지 메시지는 같습니다.
첫째, 도구의 기본값은 우리의 결정이 아닙니다.
자동 변환 도구는 합리적인 기본값을 적용할 뿐, 우리 시스템의 의도를 알지 못합니다. 숫자형 편(06)에서 본 NUMBER → NUMERIC(38,10)의 표현 왜곡, 캐릭터셋 편(01)의 묵음 변형(Silent Corruption), LOB 편(04)의 단순 타입 치환, 시퀀스 편(10)의 동기화 누락 — 이 모든 사고가 "도구가 결정했고 우리는 검토하지 않았다"의 결과입니다. Oracle의 모호함(precision 없는 NUMBER, USER ≡ SCHEMA의 일체성)은 오픈소스로 넘어오는 순간 누군가가 명시적으로 결정해야 하는 공백이 됩니다. 그 공백을 도구가 채우면, 스키마의 결정이 우리의 결정이 아니게 됩니다.
둘째, 1:1 매핑은 환상입니다.
같은 단어가 다른 구조를 가리킵니다. Oracle USER ≡ SCHEMA, PostgreSQL의 ROLE / DATABASE / SCHEMA 분리, MySQL의 DATABASE ≡ SCHEMA — 모두 "사용자"라는 한 개념을 다르게 쪼개고 묶은 결과입니다. 같은 옵션이 다른 기본값을 갖습니다. 시퀀스 CACHE 기본값 20 대 1, MySQL innodb_autoinc_lock_mode 5.7의 1 대 8.0의 2. 같은 객체가 다른 모델로 동작합니다. 트리거 모델, 머티리얼라이즈드 뷰의 refresh 정책, FK 검증 시점, NULL의 인덱스 저장 여부. 같은 함수가 다른 시점에 평가됩니다. PostgreSQL now()의 트랜잭션 시작 시점 대 Oracle SYSDATE의 호출 시점. 매핑은 항상 의도의 번역이고, 번역에는 정보 손실과 재해석이 따릅니다.
14. 마치며 - 데이터가 아니라 의미를 옮기는 일

