About VATOS
home
Company
home

techlog_11

← Tech Insights

인덱스: 인덱스는 구조가 아니라 해석 대상이다

인덱스가 DB마다 다르게 해석되는 이유와 이관 후 성능을 지키는 검증 기준을 정리합니다.

앞 글에서 식별자가 어떻게 채번되어 테이블에 들어가는지 — 시퀀스를 다뤘습니다. 그 식별자가 들어간 데이터를 빠르게 찾는 메타데이터가 인덱스입니다.

이기종 마이그레이션에서 인덱스 이관은 DDL을 복사하면 되는 것처럼 보입니다. Oracle DDL을 추출해서 오픈소스에 생성했고, 오류도 없었습니다. 그런데 운영에 들어가면 타임아웃이 납니다. 인덱스는 존재합니다. 옵티마이저가 선택하지 않았을 뿐입니다.

인덱스 이관이 어려운 이유는 구조의 복잡함이 아닙니다. 물리적 껍데기는 복사되지만, 그 인덱스를 선택하게 만들던 판단 기준은 복사되지 않는다는 점입니다. Oracle이 암묵적으로 제공하던 스캔 전략과 옵티마이저 철학은 DDL 안에 없습니다. 인덱스를 옮기는 것과 인덱스를 '타게' 만드는 것은 완전히 다른 작업입니다.

이 글은 양방향, 즉 Oracle의 풍부한 인덱스 옵션을 오픈소스의 단순한 모델로 흡수하는 방향과, 반대로 오픈소스의 특수 인덱스 — Partial Index, BRIN, GIN — 가 Oracle로 옮겨질 때의 비대칭을 모두 다룹니다.


세 DB의 인덱스 모델 — 같은 B-tree, 다른 옵션

항목OraclePostgreSQLMySQL (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 ScanIndex Fast Full Scan / 컬럼 포함 인덱스INCLUDE 절 (11+) + visibility map 필요Secondary Index에 필요 컬럼 모두 포함 필요
Function-Based Index지원 (혼합 정렬 시 내부 SYS_OP_DESCEND 등)Expression IndexFunctional 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 IndexGiST, GIN, BRIN, SP-GiST, HashSpatial(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 시절의 전략은 오픈소스로 넘어오면서 "패턴별로 인덱스를 쪼개고 분리한다"는 전략으로 바뀌어야 합니다.

그렇다고 쿼리 패턴마다 인덱스를 무작정 늘릴 수도 없습니다. 인덱스가 하나 추가될 때마다 쓰기(DML) 오버헤드가 발생하기 때문입니다. 특히 PostgreSQL의 경우, 인덱스가 많아지면 단순히 INSERT/UPDATE가 느려지는 것을 넘어 HOT(Heap-Only Tuple) 업데이트 효율이 떨어져 VACUUM 부하가 시스템 전체에 영향을 미치게 됩니다. Skip Scan의 비대칭이 결국 데이터베이스 아키텍처 전반의 부하 설계로 연결되는 것입니다.

오픈소스 → Oracle 방향에서는 반대 결정의 기회가 생깁니다. PostgreSQL/MySQL에서 패턴별로 잘게 쪼개 두었던 다중 인덱스가 Oracle에서는 Skip Scan 덕에 더 적은 수의 복합 인덱스로 통합 가능할 수 있습니다. 이관 직후에는 1:1로 옮기되, 운영 안정화 후 인덱스 정리 차원의 재설계 기회로 삼는 것이 일반적입니다.


인덱스만으로 결과를 돌려주던 쿼리 — Covering의 차이

테이블을 거치지 않고 인덱스 블록만 훑어 결과를 반환할 수 있는 쿼리들이 있습니다. Oracle에서는 Index Fast Full Scan 등을 통해 처리합니다.

오픈소스에서는 같은 기능을 다루는 모델이 다릅니다. Oracle 인덱스는 리프 노드에 테이블의 ROWID(물리적 주소)를 들고 있지만, MySQL(InnoDB)의 Secondary Index는 클러스터링 인덱스(PK)의 값을 들고 있습니다. 따라서 MySQL에서는 인덱스만으로 쿼리를 끝내는 Covering Index를 만들기 위해 SELECT 절에 필요한 컬럼들을 인덱스에 명시적으로 포함시켜야 하는 제약이 강합니다. PK 값이 길수록 Secondary Index의 비용이 함께 커지므로 비대화 부담도 따라옵니다.

PostgreSQL은 INCLUDE 절(11+)을 통해 인덱스에 추가 컬럼을 저장함으로써 Index Only Scan을 가능하게 합니다. 다만 이는 인덱스만으로 결과를 반환하기 위한 '필요 조건'일 뿐, 충분 조건은 아닙니다. 실제 Index Only Scan은 visibility map 상태에 따라 결정되며, 해당 페이지가 all-visible 상태가 아닐 경우 Heap 접근이 발생합니다. 결국 오픈소스 환경에서는 단순히 인덱스를 설계하는 것을 넘어, VACUUM과 데이터 변경 패턴까지 고려해야 인덱스의 성능을 온전히 끌어낼 수 있습니다.

오픈소스 → Oracle 방향에서는 Covering 설계의 부담이 완화됩니다. Oracle에서도 인덱스만으로 결과를 반환하려면 필요한 컬럼이 인덱스에 포함되어 있어야 합니다. 다만 Oracle Secondary Index는 ROWID(고정 길이)만 들고 있어, InnoDB처럼 Secondary Index마다 긴 PK 값이 반복 저장되는 구조가 아니므로 covering 목적의 인덱스 비대화 부담은 상대적으로 작습니다. MySQL InnoDB에서 비대해진 Covering 인덱스를 Oracle로 옮길 때는 동일한 컬럼 구성을 유지하면서도 저장 비용이 줄어드는 경우가 많고, 운영 안정화 후 인덱스 구성을 다시 점검할 수 있습니다.


PK 없는 테이블 — 오픈소스에서는 대가를 치른다

PK가 없으면 오픈소스에서 어떤 일이 생기는가

Oracle은 테이블과 인덱스가 물리적으로 독립된 세그먼트이기 때문에 PK가 없어도 테이블 구조 자체에 영향이 없습니다. 이력이나 로그를 쌓는 테이블은 물론이고, 업무 트랜잭션 테이블인데도 PK 없이 Unique 인덱스만 걸려 있거나 아무 제약도 없는 경우가 있습니다. Oracle은 기본적으로 힙(Heap) 구조 테이블을 사용하고 데이터의 물리적 주소인 ROWID를 내부적으로 관리하기 때문에, PK가 없어도 운영에 큰 지장이 없었습니다.

타겟이 MySQL InnoDB인 경우, 테이블 자체가 PK 기준으로 정렬되는 클러스터링 인덱스(Clustered Index) 구조가 됩니다. PK가 없으면 UNIQUE NOT NULL 인덱스를 찾고, 그것도 없으면 내부적으로 6바이트의 숨겨진 ROW_ID를 강제로 생성해 클러스터링 키로 사용합니다. 이 숨겨진 키는 사용자가 제어할 수 없고, Secondary Index 전체가 이 키를 포인터로 들고 다닙니다. 이 숨겨진 ROW_ID를 채번하는 뮤텍스(Mutex)가 데이터베이스 인스턴스 전체에서 전역적으로 관리되므로, 여러 테이블에서 동시에 대량의 INSERT가 발생하면 이 키를 발급받는 과정에서 병목(Contention)이 발생하고 시스템 전체의 쓰기 성능이 저하될 수 있습니다.

PostgreSQL은 Oracle과 같은 힙 구조를 쓰기 때문에 구조적인 제약은 덜합니다. 다만 PostgreSQL의 논리적 복제(Logical Replication)나 CDC(Change Data Capture) 솔루션을 사용할 때, PK가 없는 테이블의 UPDATE/DELETE는 원본의 어떤 행을 수정해야 할지 식별할 수 없습니다. 결국 테이블의 전체 컬럼을 비교하는 REPLICA IDENTITY FULL 모드로 동작하게 되어 디스크 I/O와 네트워크 부하가 증가합니다.

양쪽 모두, GoldenGate나 DMS 같은 CDC 기반 이관 도구를 쓴다면 PK 없는 테이블은 변경 데이터 캡처 자체가 제한되거나 성능이 떨어집니다. "PK가 없어도 됐던 Oracle의 관용"이 오픈소스에서는 성능, 복제, 이관 전반에 걸쳐 대가를 요구합니다.

PK를 구성할 때 — 타겟 DB가 전략을 결정한다

PK를 새로 구성해야 한다는 판단이 섰을 때, 가장 자연스러운 선택은 기존에 Unique 인덱스로 관리하던 컬럼 조합을 그대로 PK로 승격시키는 것입니다. 그러나 이 선택이 적절한지는 타겟 DB에 따라 판단이 갈립니다.

타겟이 PostgreSQL이라면 복합 자연키를 그대로 가져가도 됩니다. PostgreSQL은 힙 기반이므로 PK 크기가 Secondary Index에 영향을 주지 않습니다. Oracle과 저장 구조가 유사하기 때문에 PK 설계를 바꿀 성능적 이유가 약합니다.

타겟이 MySQL InnoDB라면 이야기가 다릅니다. InnoDB의 Secondary Index 리프 노드에는 PK 값 전체가 복사됩니다. Oracle에서는 Secondary Index가 ROWID만 들고 있으므로 PK가 길어도 영향이 없었지만, InnoDB에서는 복합 자연키가 50바이트라면 Secondary Index 하나에 매 행마다 50바이트가 추가됩니다. Secondary Index가 5개면 이 비용이 5배로 곱해집니다. 테이블이 수천만 건이면 인덱스 전체 크기가 Oracle 대비 수 배로 커지고, 버퍼 풀 효율이 떨어지며 디스크 I/O가 늘어납니다.

이 경우 복합 자연키 대신 BIGINT AUTO_INCREMENT 컬럼을 인조키(Surrogate Key) PK로 구성하는 것이 성능 관점에서 유리합니다. Secondary Index 리프에 8바이트만 추가되므로 인덱스 크기가 크게 줄어듭니다. InnoDB는 PK 순서로 데이터를 물리적으로 저장하기 때문에, 값이 항상 증가하는 AUTO_INCREMENT PK는 INSERT 시 리프 블록 끝에 추가되어 페이지 분할이 최소화됩니다. UUID나 무작위 값을 PK로 쓰면 페이지 분할이 빈번해지고 쓰기 성능이 불안정해집니다. Oracle에서는 불필요했던 이 고려들이, InnoDB에서는 PK의 값 패턴 자체가 쓰기 성능에 직결됩니다(시퀀스/UUID 결정은 10편 참조).

다만 인공 식별자를 도입하면 트레이드오프가 따릅니다. 기존에 자연키로 조인하던 SQL이 있으면, 인공 PK를 도입한 뒤에도 자연키 컬럼에 UNIQUE 인덱스가 여전히 필요합니다. 기존 조인 조건과 FK 관계, 애플리케이션 코드까지 영향이 갑니다. PK를 바꾸는 것은 인덱스 성능 문제에서 시작하지만, 실제로는 프로젝트 전체의 수정 범위를 결정하는 작업이 됩니다.

PK 설계는 DBA 혼자 결정할 수 없다

오픈소스로의 마이그레이션에서 운영 트랜잭션 테이블은 PK 또는 안정적인 행 식별자를 갖는 것이 원칙입니다. 이관 전 초기 실사 단계에서 PK 없는 테이블을 전수 조사하고, 어떤 컬럼 조합으로 PK를 구성할 것인지, 복합 자연키를 유지할 것인지 인공 식별자를 도입할 것인지를 결정해야 합니다. 이 결정은 DBA 혼자 할 수 없습니다. 어떤 컬럼이 행의 유일성을 보장하는지는 데이터가 아니라 업무 규칙이 결정하기 때문입니다. Oracle에서는 관대한 선택이었던 PK가, 오픈소스에서는 아키텍처를 지탱하는 필수 조건입니다.


Function-Based Index — DDL 추출이 만드는 착시

Oracle에서 Function-Based Index(FBI)로 분류된 인덱스를 DDL 추출 도구로 뽑아보면, 오픈소스에 그대로 넣을 수 없는 형태로 나오는 경우가 있습니다. Oracle 내부적으로 FBI로 분류되는 기준이 오픈소스와 다르기 때문입니다. FBI를 이관할 때는 DDL을 그대로 옮기는 것이 아니라, 해당 인덱스가 왜 FBI로 만들어졌는지를 먼저 파악해야 합니다.

가장 자주 마주치는 패턴은 혼합 정렬 복합 인덱스입니다. Oracle은 CREATE INDEX idx ON t(a ASC, b DESC)처럼 같은 인덱스 안에서 컬럼별 정렬 방향이 섞인 경우를 내부적으로 FBI로 분류합니다. DDL을 추출하면 SYS_OP_DESCEND 같은 내부 함수 표현식이 붙어 나오고, 이걸 그대로 오픈소스에 넣으면 생성이 실패하거나 의도와 다른 인덱스가 만들어집니다. 오픈소스에서는 별도의 FBI가 아니라 인덱스 생성 시 컬럼별 정렬 방향을 직접 지정하는 방식으로 재작성해야 합니다. PostgreSQL은 컬럼별 ASC/DESC 지정을 지원하고, MySQL은 8.0.1부터 실제 DESC 인덱스를 지원합니다(이전 버전은 DESC 구문이 파싱만 되고 무시되었습니다).

반면 단일 컬럼 DESC 인덱스는 별도 인덱스 객체가 필요하지 않습니다. Oracle과 PostgreSQL 모두 일반 인덱스를 양방향 스캔으로 처리할 수 있어 단일 컬럼 DESC 인덱스는 통상 만들 필요가 없고, PostgreSQL 문서도 이 점을 명시합니다. 이관 시 단일 컬럼에 붙은 DESC 정의가 있다면 일반 인덱스로 정리할 수 있습니다.

UPPER(col), TRUNC(date_col) 같은 표현식 기반 FBI는 오픈소스에서도 동일한 역할을 합니다. PostgreSQL의 Expression Index, MySQL 8.0.13 이후의 Functional Index로 대응됩니다. 다만 함수 이름과 동작이 다를 수 있으므로 표현식 자체의 변환이 필요합니다. TRUNCDATE_TRUNC로, NVLCOALESCE로 바뀌어야 하고, 인자 순서나 반환 타입이 달라지는 경우도 있으므로 기계적 치환이 아니라 동작 확인이 필요합니다.

오픈소스 → Oracle 방향에서는 Expression Index/Functional Index를 Oracle FBI로 옮기는 작업이 비교적 깔끔합니다. PostgreSQL의 CREATE INDEX ... ON t (UPPER(col))가 Oracle에서 동일한 구문으로 작동합니다. 다만 PostgreSQL에서 자주 쓰는 Partial Index와 결합된 표현식 인덱스(CREATE INDEX ... ON t (lower(email)) WHERE active = true)는 Oracle에서 단일 객체로 재현이 어렵습니다(아래 Partial Index 참조).

결국 FBI 이관의 판단 기준은 DDL이 아니라 해당 인덱스를 사용하는 SQL입니다. DDL만 보고 옮기거나 버리면 안 되고, 어떤 쿼리가 이 인덱스에 의존하고 있는지를 확인한 뒤에 유지, 재작성, 제거를 결정해야 합니다.


특수 인덱스의 비대칭 — Partial, BRIN, Bitmap

각 DB가 가진 특수 인덱스는 직접 대응이 없거나 동작 모델이 다릅니다. 이관 시 가장 자주 누락되는 영역이기도 합니다.

PostgreSQL Partial Index. CREATE INDEX ... WHERE 조건으로 일부 행만 인덱싱합니다. NULL 비중이 매우 높은 컬럼, 상태 플래그 컬럼, 소프트 삭제 패턴에서 인덱스 크기를 크게 줄입니다. Oracle은 직접 대응이 없으며, FBI로 부분 우회(CASE WHEN 조건 THEN col END)할 수 있지만 의미가 완전히 같지는 않습니다. MySQL은 대응 자체가 없습니다. PostgreSQL → Oracle/MySQL 방향에서 Partial Index 정의가 광범위하다면, 인덱스 크기 증가와 함께 옵티마이저의 선택 행동까지 달라지므로 사전 검토가 필요합니다.

PostgreSQL BRIN. 매우 큰 테이블에서 물리 정렬도가 좋은 컬럼(시간순으로 적재되는 로그 등)에 효과적인 가벼운 인덱스입니다. B-tree 대비 크기가 훨씬 작고 쓰기 부담도 적습니다. 13편(통계 정보)에서 다룬 pg_stats.correlation이 좋은 컬럼이 BRIN의 적합 대상입니다. Oracle/MySQL에는 직접 대응이 없습니다. Oracle은 파티셔닝 + Local Index로 비슷한 효과를 내고, MySQL은 마땅한 대응이 없어 일반 B-tree 인덱스의 비용을 감수해야 합니다.

PostgreSQL GIN/GiST. 전문 검색, JSON 인덱싱, 배열, 범위 타입처럼 다중 값/복합 키를 인덱싱하는 데 쓰입니다. Oracle은 Domain Index/Oracle Text, MySQL은 Full-Text Index/Spatial Index가 일부 영역에서 대응하지만 모델이 다릅니다.

Oracle Bitmap Index. 카디널리티가 낮은 컬럼의 DW/OLAP 워크로드에서 효과적입니다. PostgreSQL과 MySQL에는 영구 Bitmap 인덱스가 없습니다. PostgreSQL은 실행 시점에 여러 B-tree 인덱스 결과를 비트맵으로 변환해 결합하는 'Bitmap Index Scan'을 사용하지만, 이는 영구 객체가 아닙니다. Oracle → 오픈소스 방향에서 Bitmap 인덱스에 의존하던 분석 쿼리는 일반 B-tree로 옮겨졌을 때 동작 패턴이 달라지므로, 쿼리 자체의 재설계 또는 별도 분석 엔진(컬럼 스토어 등) 도입 검토로 이어지는 경우가 많습니다.

특수 인덱스는 이관 도구가 매핑해 주지 못하는 영역이고, 동작 모델 자체가 다르므로 이관 시점에 1:1 대체보다는 의도 기반 재설계가 필요합니다.


NULL과 인덱스 — 양쪽 모두에 함정이 있다

Oracle과 오픈소스는 NULL을 다루는 모델이 다릅니다. 이 차이는 인덱스 크기, Unique 동작, 쿼리 플랜에 걸쳐 영향을 미치며, Oracle → 오픈소스 방향과 오픈소스 → Oracle 방향 양쪽 모두에서 함정을 만듭니다.

Oracle → 오픈소스: 인덱스가 커진다

Oracle B-tree 인덱스는 인덱스 컬럼의 값이 모두 NULL인 행을 인덱스 트리에 저장하지 않습니다. 반면 PostgreSQL과 MySQL은 NULL 값도 인덱스에 넣습니다. 이 차이는 NULL이 많은 컬럼에서 두드러집니다. 대부분의 값이 NULL인 상태 플래그 컬럼이 대표적인데, Oracle에서는 인덱스가 자연스럽게 가벼워지지만 오픈소스에서는 NULL 행까지 포함되어 같은 컬럼의 인덱스라도 크기가 커집니다. PostgreSQL이라면 CREATE INDEX ... WHERE col IS NOT NULL로 Partial Index를 만들어 Oracle과 유사한 동작을 재현할 수 있지만, MySQL에는 이 옵션이 없어서 NULL 포함 인덱스가 강제됩니다.

오픈소스 → Oracle: 빈 문자열이 NULL이 되면서 유일성이 깨진다

역방향에서는 성격이 다른 함정이 기다리고 있습니다. Oracle은 빈 문자열('')을 NULL로 처리합니다. 오픈소스에서는 ''와 NULL이 별개의 값입니다.

오픈소스 테이블에 (A, B) Unique 인덱스가 있고, B 컬럼이 Nullable이라고 가정합니다. 데이터에 A=1, B=''인 행과 A=1, B=NULL인 행이 각각 존재합니다. 오픈소스에서는 ''와 NULL이 다른 값이므로 Unique 위반이 아닙니다. 이 데이터를 Oracle로 이관하면 ''가 NULL로 변환되면서 A=1, B=NULL인 행이 두 건이 됩니다.

PK라면 NULL 자체가 허용되지 않으므로 Duplicate 에러가 발생하면서 INSERT가 실패합니다. 하지만 Unique Index + Nullable 컬럼 조합의 경우 Oracle Unique 인덱스는 NULL 조합의 중복을 허용하기 때문에 INSERT가 성공합니다. 에러는 없지만 업무적으로는 의도하지 않은 중복이 생긴 상태입니다. 이관 건수도 맞고, 로그에도 에러가 없지만, 운영 중 한참 뒤에야 결과가 달라짐을 인식하게 됩니다(제약조건 편(09)에서도 같은 함정을 UNIQUE 제약 관점에서 다뤘습니다).

이관 전에 오픈소스 소스 테이블에서 Nullable 컬럼을 포함하는 Unique 인덱스를 전수 조사하고, 해당 컬럼에 빈 문자열이 존재하는지 확인해야 합니다. 존재한다면 이관 전에 데이터를 정제할 것인지, Unique 제약을 재설계할 것인지를 결정해야 합니다. 이 판단을 이관 후로 미루면, 에러 없이 들어간 중복 데이터를 운영 중에 추적해야 하는 상황이 됩니다.


같은 인덱스, 다른 판단 — 옵티마이저는 DB마다 다르게 생각한다

데이터를 모두 옮기고, 이관 후 전체 테이블에 대한 통계 정보 수집(ANALYZE)도 마쳤습니다. 양쪽 옵티마이저 모두 정확한 데이터 분포와 카디널리티를 알고 있는 상태입니다. 그런데도 같은 쿼리를 던지면 Oracle은 Index Scan을, 오픈소스 DB는 Full Table Scan을 선택하는 일이 발생합니다.

이것은 통계의 부재 때문이 아니라, 옵티마이저가 쥐고 있는 비용 산정(Costing) 공식 자체가 다르기 때문입니다.

대표적인 것이 디스크 I/O를 바라보는 가중치입니다. PostgreSQL의 옵티마이저는 기본적으로 랜덤 액세스(random_page_cost)를 순차 액세스(seq_page_cost)보다 4배 더 비싼 작업으로 간주합니다. 데이터가 디스크에 흩어져 있는 상태(Correlation/Clustering Factor가 나쁜 상태)에서 인덱스 접근의 랜덤 I/O 비용은 행 수에 선형적으로 누적되며, 일정 규모를 넘으면 옵티마이저는 "인덱스로 점프하며 읽기보다 테이블 전체를 순차 스캔하는 편이 싸다"고 판단합니다. Oracle에서는 같은 선택도(Selectivity)에서도 인덱스를 선택할 수 있는 경우가 다릅니다(SSD/NVMe 환경의 random_page_cost 조정은 13편 참조).

조인(Join)을 대하는 모델도 다릅니다. MySQL은 오랫동안 Nested Loop 계열 조인에 강하게 의존해 왔고, 8.0 이후 Hash Join이 도입되었지만 Oracle/PostgreSQL과 동일한 조인 최적화 모델로 볼 수는 없습니다. Oracle은 비용 모델을 통해 Nested Loop, Hash, Sort Merge 조인 사이에서 선택하고, PostgreSQL은 비용 모델상 대량 조인에서 Hash Join을 선택하는 경우가 많고, 메모리 설정과 통계 정확도에 따라 조인 방식이 크게 달라집니다.

결국 "왜 실행 계획이 달라졌는가"에 대한 답은 인덱스를 잘못 만들어서가 아닙니다. 두 데이터베이스가 비용을 산정하는 기준과 모델이 다르기 때문입니다.

이관 후의 튜닝은 단순히 잃어버린 인덱스를 찾는 과정이 아닙니다. 타겟 데이터베이스의 비용 산정 파라미터를 실제 스토리지 환경에 맞춰 조정하고, 통계 정보의 수집 주기와 정확도를 확인하며, 주요 쿼리의 실행 계획을 Source와 비교해 의도한 인덱스가 선택되고 있는지를 검증하는 과정입니다. 이 영역의 메타데이터(통계·시스템 통계·Plan 안정화)는 13편에서 본격적으로 다룹니다.


인덱스 사용 모니터링 — 옮긴 인덱스가 실제로 쓰이는가

이관 후 가장 자주 놓치는 점검이 "옮긴 인덱스가 실제로 쓰이고 있는가" 입니다. 인덱스 사용 모니터링 메커니즘은 세 DB가 모두 다릅니다.

  • Oracle: ALTER INDEX ... MONITORING USAGE로 명시적 모니터링을 켜고 V$OBJECT_USAGE에서 확인. 12c+는 DBA_INDEX_USAGE로 자동 누적 통계를 제공합니다.
  • PostgreSQL: pg_stat_user_indexes 시스템 뷰에서 인덱스별 스캔 횟수(idx_scan)와 읽힌 튜플 수가 자동으로 누적됩니다.
  • MySQL: sys.schema_unused_indexes 뷰가 Performance Schema 기반으로 사용되지 않은 인덱스를 보여줍니다.

이관 후 한두 달 정도 운영 데이터가 쌓인 시점에 이 모니터링을 일괄 확인하면, 옮겼지만 한 번도 쓰이지 않는 인덱스가 발견됩니다. Skip Scan 부재로 죽은 복합 인덱스, FBI 변환에서 의도와 다르게 만들어진 인덱스, 옵티마이저가 비용 모델 차이로 회피하는 인덱스 등이 여기 포함됩니다. 옮길 때 1:1 매핑을 했더라도 운영 안정화 후 한 번의 정리 라운드는 사실상 필수입니다.


인덱스 이관은 DDL 복사가 아니다

인덱스 마이그레이션에서 가장 경계해야 할 태도는 DDL 추출 툴을 돌려놓고 "변환이 끝났다"고 믿는 것입니다.

인덱스를 있는 그대로 옮기는 것은 물리적 이관에 불과합니다. 옮겨진 인덱스가 타겟 데이터베이스의 아키텍처 위에서 원본과 '동일한 역할'을 수행하는지는 아직 확인되지 않았습니다.

결국 다섯 가지 질문으로 수렴됩니다.

첫째, 선두 컬럼 없이 복합 인덱스를 타는 SQL이 존재하는가. Skip Scan을 폭넓게 활용하기 어려운 오픈소스에서 인덱스를 어떻게 쪼개고 분리할 것인가.

둘째, PK가 없는 테이블이 몇 개이고, PK를 어떤 전략(복합 자연키 vs 인공 식별자)으로 구성할 것인가. InnoDB로 가는 경우 PK 길이가 Secondary Index에 미치는 영향은 검토되었는가.

셋째, Oracle에서 FBI로 분류된 인덱스(특히 혼합 정렬 복합 인덱스) 중 오픈소스에서 재작성하거나 제거해야 할 것이 존재하는가. 반대로 PostgreSQL의 Partial Index/BRIN/GIN 같은 특수 인덱스에 의존하던 워크로드는 타겟 DB에서 어떻게 재현되는가.

넷째, Nullable + Unique 조합에서 빈 문자열과 NULL의 경계가 이관 후에도 유지되는가. 특히 오픈소스 → Oracle 방향에서 의도하지 않은 중복이 생기는 경우는 사전에 점검되었는가.

다섯째, 이관 후 인덱스 사용 모니터링(Oracle DBA_INDEX_USAGE, PostgreSQL pg_stat_user_indexes, MySQL sys.schema_unused_indexes) 절차가 운영 안정화 일정에 포함되어 있는가.

이 질문들에 답이 나오기 전에 인덱스 변환을 완료했다고 판단하면, 그 판단은 추정입니다. 인덱스는 존재하지만 단 한 번도 선택되지 않는 상태로 운영에 들어가게 됩니다. 그리고 그 사실은 실행 계획을 직접 확인한 사람만 알 수 있습니다.

인덱스가 실제로 어떤 계획에서 선택되는지, 그 선택의 입력이 되는 통계 정보는 어떻게 관리되는지는 13편에서 살펴봅니다. 다음 글에서는 인덱스 결정 흐름의 뒷단인 내부 로직 — 시스템 안에 사는 코드가 어떻게 옮겨지는지 — 를 먼저 다룹니다.

← 목록으로 돌아가기