파티션 테이블을 설계할 때 운영 관리성(DDL 가용성) 측면에서는 Local Index가 압도적으로 유리합니다. 이 때문에 실무에서는 "파티션 테이블의 인덱스는 무조건 Local로 만든다"는 식의 획일화된 가이드가 통용되기도 합니다.

 

하지만 대규모 OLTP(온라인 트랜잭션 처리) 환경에서 이러한 원칙만 고집하다가는 Buffer Cache Latch 경합으로 인해 CPU 사용률이 치솟거나, 비즈니스 데이터 무결성이 깨지는 심각한 문제에 직면할 수 있습니다. 파티션 유지보수의 불편함을 감수하고서라도 반드시 Global Index(글로벌 인덱스)를 선택해야만 하는 핵심 시나리오와 내부 동작 메커니즘을 데이터 아키텍처(DA) 및 DBA 관점에서 상세히 파헤쳐 보겠습니다.

 


1. 개요: 왜 관리가 까다로운 Global Index를 선택하는가?

Global Index는 테이블의 파티션 구조와 무관하게 독립적인 B-Tree 세그먼트를 유지하는 인덱스입니다. 파티션 DROP이나 TRUNCATE 시 인덱스가 UNUSABLE 상태에 빠질 위험(ORA-01502)이 있음에도 불구하고 Global Index를 포기할 수 없는 이유는 명확합니다. 바로 전역적인 데이터 유일성(Global Uniqueness) 보장과 파티션 키가 없는 OLTP 쿼리의 극단적인 I/O 최소화 때문입니다.

  • 데이터 무결성 관점: 파티션 키와 무관한 단일 식별자(예: 주문번호, 회원번호)의 중복을 엔진 레벨에서 원천 차단합니다.
  • 조회 성능 관점: 수백 개의 파티션으로 쪼개진 테이블에서도 단 3~4개의 블록 I/O만으로 원하는 로우를 즉시 찾아냅니다.

2. 내부 메커니즘: Global Index가 압도적인 성능을 내는 이유

왜 특정 조건에서는 Local Index보다 Global Index가 훨씬 빠를까요?

오라클 내부의 Index Probing(인덱스 탐색) 비용과 정렬(Sort) 오퍼레이션 메커니즘을 통해 그 이유를 확인할 수 있습니다.

2.1 Index Probing 비용과 Buffer Cache Latch 경합 감소

테이블이 일자별로 365개의 파티션으로 나뉘어 있고, 인덱스의 높이(Height, BLEVEL + 1)가 3이라고 가정해 보겠습니다. 이때 파티션 키(일자) 조건 없이 주문번호 = 12345로 단건 조회하는 핵심 OLTP 쿼리가 초당 1,000번씩 유입된다면 내부에서는 다음과 같은 차이가 발생합니다.

  • Local Index 스캔 시 (PARTITION RANGE ALL): 옵티마이저는 어느 파티션에 해당 주문번호가 있는지 알 수 없으므로 365개의 로컬 인덱스 세그먼트를 모두 뒤져야 합니다. 각 파티션의 Root 블록과 Branch 블록을 매번 읽어야 하므로, 단 1건의 데이터를 찾기 위해 최소 $365 \times 3 = 1,095$ 블록의 Logical I/O(Consistent Gets)가 발생합니다. 이 과정에서 수많은 세션이 동시에 인덱스 루트 블록을 읽으려 하면서 cache buffers chains Latch 경합이 폭발적으로 증가합니다.
  • Global Index 스캔 시 (단일 트리 탐색): 전체 데이터가 하나의 B-Tree에 정렬되어 있으므로 Root $\rightarrow$ Branch $\rightarrow$ Leaf까지 단 3블록의 Logical I/O로 탐색이 끝납니다. 리프 블록에 저장된 10바이트 Extended ROWID 내의 Data Object ID를 읽고 해당 테이블 파티션의 데이터 블록 1개만 추가로 읽으면 되므로, 총 4블록 I/O만에 쿼리가 종료됩니다.

2.2 Top-N 쿼리 및 정렬(ORDER BY) 시 MERGEJOIN SORT 생략

페이징 처리가 필요한 화면에서 파티션 키 조건 없이 최신 데이터 10건을 가져오는 Top-N 쿼리를 수행할 때도 내부 오퍼레이션이 완전히 달라집니다.

-- 파티션 키(SALE_DATE) 조건 없이 최근 주문번호 순으로 상위 10건 페이징 조회
SELECT *
  FROM (SELECT /*+ INDEX_DESC(A IDX_SALES_ORDER_NO) */ *
          FROM TB_SALES A
         WHERE CUST_GRADE = 'VIP'
         ORDER BY ORDER_NO DESC)
 WHERE ROWNUM <= 10;
  • Global Index: 인덱스 리프 블록 자체가 전체 테이블에 대해 ORDER_NO 순으로 완벽히 정렬되어 있습니다. 따라서 가장 우측 리프 블록부터 역방향으로 10건만 읽고 즉시 스캔을 멈춥니다(Stopkey 동작, Sort 생략).
  • Local Index: 365개 파티션 각각에서 상위 10건씩을 뽑아낸 뒤, PGA 메모리(Sort Area) 공간에서 이들의 순서를 다시 비교하여 최종 10건을 추려내는 작업이 필요합니다. 즉, 인덱스를 타더라도 내부적으로 정렬 비용이 수반되어 응답 속도가 크게 저하됩니다.

3. 주요 원인 분석: 실무에서 Global Index 도입이 불가피한 4가지 케이스

아키텍처 설계 단계에서 다음과 같은 요건이 존재한다면 Local Index 대신 Global Index를 채택해야 합니다.

3.1 파티션 키가 포함되지 않은 PK / Unique 제약조건 (비즈니스 무결성)

가장 대표적이면서도 타협이 불가능한 케이스입니다. 주문 테이블을 주문일자(ORDER_DATE) 기준으로 Range 파티셔닝했는데, 업무 규칙상 기본키(PK)는 시퀀스로 채번되는 단일 컬럼 주문번호(ORDER_ID)여야 하는 경우입니다.

  • 오라클 아키텍처상 파티션 키(ORDER_DATE)가 빠진 상태에서는 Local Unique Index를 생성할 수 없습니다(ORA-14039 에러 발생).
  • 애플리케이션에서 조회 시 항상 ORDER_ID 단독으로 조인하거나 조회한다면 PK에 억지로 ORDER_DATE를 결합할 수도 없으므로, 이때는 반드시 Global Unique Index로 PK를 구성해야 합니다.

3.2 파티션 키 조건 없이 유입되는 고빈도 OLTP 핵심 쿼리

테이블은 관리 목적상 생성일자 기준으로 파티셔닝되어 있지만, 실제 온라인 화면에서는 고객번호(CUST_ID)나 계약번호(CONT_NO)만으로 조회하는 트랜잭션이 전체 워크로드의 80% 이상을 차지하는 경우입니다.

  • 파티션 개수가 수십~수백 개 이상인 상태에서 이를 Local Non-Prefixed Index로 설계하면, 앞서 살펴본 Index Probing 현상으로 인해 DB 서버의 CPU가 100%에 도달할 수 있습니다.

3.3 시퀀스 기반 PK의 우측 리프 블록 경합 (Right-Hand Index Contention)

시퀀스(SEQUENCE)나 SYSDATE처럼 값이 단조 증가(Monotonically Increasing)하는 컬럼에 인덱스를 생성하고 수많은 세션이 동시에 INSERT를 수행하면, 가장 마지막(우측 끝) 인덱스 리프 블록 하나에 모든 변경이 집중됩니다.

  • 이로 인해 enq: TX - index contention 및 buffer busy waits 대기 이벤트가 발생하며 전체 트랜잭션이 멈칫거리는 현상이 나타납니다.
  • 이때 테이블 파티션 구조와 상관없이 인덱스만 해시(Hash) 기준으로 여러 세그먼트로 쪼개는 Global Hash Partitioned Index를 도입하면, 입력되는 키 값이 해시 함수를 통해 여러 인덱스 파티션의 리프 블록으로 고르게 분산되어 핫블록(Hot Block) 경합을 완벽히 제거할 수 있습니다.

3.4 테이블 파티션 기준과 주력 검색 범위 조건이 불일치하는 경우

예를 들어, 테이블은 데이터 생명주기(ILM) 관리를 위해 로그적재일자 기준으로 파티셔닝되어 있으나, 분석 및 온라인 배치에서는 정산기준년월이나 지역코드 범위로 대량 스캔이 일어나는 경우입니다.

  • 이때 인덱스를 정산기준년월 기준의 Global Range Partitioned Index로 구성하면, 테이블 파티션과 무관하게 인덱스 자체적인 파티션 프루닝(Index Partition Pruning) 효과를 얻을 수 있습니다.

4. 트러블슈팅 및 해결 방안: Global Index 최적 설계 및 운영 베스트 프랙티스

단순히 단일 세그먼트 형태의 Global Index만 쓰는 것이 아니라, 대용량 환경의 경합을 줄이고 관리 리스크를 통제하는 실무 적용 전략입니다.

4.1 Hot Block 경합 해소를 위한 Global Hash Partitioned Index 구축

동시 INSERT가 극심한 OLTP 테이블의 PK 인덱스에 buffer busy waits 경합이 발생한다면, 아래와 같이 Global Hash Partitioned Index로 전환하여 I/O 및 Latch 경합을 분산합니다. (파티션 수는 2의 거듭제곱인 4, 8, 16, 32 등으로 설정하는 것이 해시 분포상 유리합니다.)

-- 시퀀스 기반 PK에서 우측 리프 블록 경합(TX Index Contention)을 방지하기 위한 Global Hash 인덱스 생성
CREATE UNIQUE INDEX PK_TB_ORDERS ON TB_ORDERS (ORDER_ID)
GLOBAL PARTITION BY HASH (ORDER_ID)
PARTITIONS 16
TABLESPACE TBS_IDX_01;

-- 생성된 Global Partitioned Index 메타데이터 확인
SELECT INDEX_NAME, PARTITIONING_TYPE, PARTITION_COUNT, LOCALITY
  FROM USER_PART_INDEXES
 WHERE INDEX_NAME = 'PK_TB_ORDERS';

4.2 Index Probing으로 인한 Local Index 비효율 구간 진단 쿼리

현재 운영 중인 시스템에서 Local Index를 타느라 불필요한 블록 I/O(PARTITION RANGE ALL)를 과도하게 유발하고 있는 악성 SQL을 찾아내어 Global Index 전환 대상을 선별하는 튜닝 쿼리입니다.

-- V$SQL_PLAN에서 PARTITION RANGE ALL과 LOCAL INDEX 스캔이 결합되어 논리적 I/O(BUFFER_GETS)가 높은 SQL 추출
SELECT S.SQL_ID,
       S.EXECUTIONS,
       S.BUFFER_GETS,
       ROUND(S.BUFFER_GETS / DECODE(S.EXECUTIONS, 0, 1, S.EXECUTIONS), 1) AS GETS_PER_EXEC,
       P.OBJECT_NAME AS INDEX_NAME,
       SUBSTR(S.SQL_TEXT, 1, 80) AS SQL_TEXT_PART
  FROM V$SQL S
  JOIN V$SQL_PLAN P ON S.SQL_ID = P.SQL_ID AND S.CHILD_NUMBER = P.CHILD_NUMBER
 WHERE P.OPERATION = 'INDEX'
   AND P.OPTIONS LIKE '%RANGE SCAN%'
   AND EXISTS (
       SELECT 1 
         FROM V$SQL_PLAN P2 
        WHERE P2.SQL_ID = P.SQL_ID 
          AND P2.CHILD_NUMBER = P.CHILD_NUMBER
          AND P2.OPERATION = 'PARTITION RANGE' 
          AND P2.OPTIONS = 'ALL'
   )
   AND (S.BUFFER_GETS / DECODE(S.EXECUTIONS, 0, 1, S.EXECUTIONS)) > 500
 ORDER BY GETS_PER_EXEC DESC;

4.3 하이브리드(Hybrid) 인덱스 아키텍처 설계 표준

실무에서는 하나의 파티션 테이블에 Local과 Global 인덱스를 역할에 맞게 혼합하여 설계하는 것이 정석입니다.

인덱스 구분 적용 대상 및 설계 기준 권장 인덱스 아키텍처
주 식별자 (PK / UK) 파티션 키가 포함되지 않은 단일 식별자 (예: ORDER_ID) Global Unique Index (동시 입력 많을 시 Global Hash 적용)
핵심 OLTP 검색키 파티션 키 조건 없이 초당 수십 회 이상 단건/소량 조회되는 컬럼 Global Non-Partitioned Index
일반 검색키 / 배치용 화면에서 날짜(파티션 키) 조건이 필수로 들어오거나 야간 배치에서 사용되는 인덱스 Local Prefixed / Non-Prefixed Index

운영 시 필수 수칙:

테이블에 Global Index가 단 하나라도 존재한다면, 해당 테이블의 파티션을 관리하는 모든 배치 스크립트나 프로시저(DROP, TRUNCATE, MOVE, SPLIT, EXCHANGE)에는 반드시 UPDATE INDEXES 절을 기본 템플릿으로 포함시켜야 합니다.

 


"Local Index가 관리하기 편하다"는 이유만으로 모든 인덱스를 Local로 강제하면, 온라인 트랜잭션 성능과 데이터 무결성을 크게 해칠 수 있습니다.

  1. Global Uniqueness 보장: 파티션 키를 포함하지 않는 순수 식별자(PK/UK)의 중복을 막기 위해서는 Global Unique Index가 유일한 해법입니다.
  2. OLTP 극한의 응답 속도: 파티션 키 조건이 없는 단건 조회나 Top-N 정렬 쿼리에서는 불필요한 전체 파티션 탐색(Index Probing)과 정렬(Sort)을 생략하는 Global Index가 압도적인 성능을 발휘합니다.
  3. Global Hash를 통한 경합 해소: 대규모 동시 트랜잭션 환경에서 시퀀스 기반 인덱스의 우측 리프 블록 경합(enq: TX - index contention)을 해결할 때도 Global Hash Partitioned Index가 핵심 아키텍처로 활용됩니다.

1. Spring의 진화사: 왜 Spring AI는 필연적이었는가?

생성형 AI를 사내 시스템에 도입할 때 많은 개발팀이 "무조건 Python(LangChain, FastAPI)으로 별도 서버를 띄워야 하나?"를 고민합니다. 하지만 엔터프라이즈 시스템의 핵심 비즈니스 로직과 트랜잭션이 이미 Java와 Spring 위에 올라가 있다면, 별도의 파이썬 서버를 관리하는 것은 인프라와 도메인 로직의 파편화를 초래합니다.

Spring이 지난 20년간 어떻게 새로운 기술 패러다임을 흡수하며 진화해 왔는지 살펴보면, 왜 지금 Spring AI가 등장했는지 명확하게 이해할 수 있습니다.

시대 및 주요 버전 핵심 기술 키워드 아키텍처적 의미 Spring AI에 미친 직접적 영향
Spring 1.x ~ 3.x

(2004 ~ 2009)
IoC/DI, AOP, JavaConfig 특정 벤더나 인프라 기술(JDBC, 트랜잭션 등)이 바뀌어도 코드는 유지되는 일관된 서비스 추상화 확립 OpenAI, Claude, Gemini, Ollama를 코드 수정 없이 교체하는 ChatModel / VectorStore 추상화의 뿌리
Spring Boot 1.0

(2014)
Starter, Auto-Configuration, 내장 서버 복잡한 빈(Bean) 조립과 설정을 자동화하여 비즈니스 로직에만 집중하는 개발 표준 제시 starter 의존성 한 줄과 application.yml 설정만으로 LLM 클라이언트와 벡터 DB 즉시 구동
Spring 5.0 & Boot 2.x

(2017 ~ 2018)
WebFlux, Project Reactor (Flux/Mono), WebClient 대용량 I/O 처리를 위한 비동기 논블로킹(Non-blocking) 및 리액티브 스트리밍 아키텍처 도입 LLM이 생성하는 토큰을 실시간 청크 단위로 받아 화면에 뿌려주는 chatClient.prompt().stream() (Flux<String>)의 핵심 엔진
Spring 6.0 & Boot 3.x

(2022 ~ 2023)
Java 17+ 베이스라인, RestClient, Virtual Threads(Java 21) 불변 데이터 객체(record), 직관적인 Fluent HTTP API, 블로킹 I/O의 한계를 넘는 경량 가상 스레드 도입 ChatClient의 Fluent API 설계, record 기반 Structured Output 매핑, 장시간 대기하는 LLM API 호출의 스레드 풀 고갈 해결
Spring AI 1.x

(2024 ~ 현재)
ChatClient, Advisor, ETL Pipeline, MCP 엔터프라이즈 애플리케이션 내부로 AI 모델, 벡터 DB, 에이전트 도구를 완벽히 내재화 별도 파이썬 서버 없이 기존 Spring 백엔드 및 배치 파이프라인 내에서 RAG 및 AI 에이전트 구축 완성

1.1. Spring 5의 유산: 왜 AI에서 Reactive(Flux)가 다시 빛나는가?

Spring 5에서 처음 Spring WebFlux가 등장했을 때, 일반적인 RDBMS 기반 CRUD 시스템에서는 JDBC 드라이버의 블로킹 한계 때문에 전면 도입이 쉽지 않았습니다. 하지만 LLM 시대에는 상황이 완전히 달라졌습니다. LLM이 전체 답변을 다 만들 때까지 10초 동안 빈 화면을 보여주는 대신, 생성되는 단어(Token)마다 실시간 스트리밍(SSE, Server-Sent Events)으로 내려주는 UX가 필수적입니다. Spring 5부터 다져진 Flux<String> 파이프라인 덕분에 Spring AI에서는 단 한 줄의 메서드 변경(.call() 대신 .stream())만으로 논블로킹 토큰 스트리밍을 구현할 수 있습니다.

1.2. Spring 6 & Boot 3의 토대: Java 17 record와 가상 스레드

Spring AI가 최소 요구 사양으로 Spring Boot 3.x와 Java 17 이상을 강제한 데에는 명확한 이유가 있습니다.

  • Java record와 Text Blocks("""): 멀티라인 프롬프트를 깔끔하게 관리하고, LLM이 반환한 JSON 데이터를 불변 객체(record)의 리플렉션 메타데이터와 결합해 JSON 스키마로 자동 변환하는 데 핵심적인 역할을 합니다.
  • Fluent API (RestClient -> ChatClient): Spring 6.1(Boot 3.2)에서 기존의 투박한 RestTemplate을 대체하기 위해 도입된 RestClient의 체이닝 설계 사상이 ChatClient에 그대로 이식되었습니다.
  • 가상 스레드(Virtual Threads, Java 21): 외부 LLM API 호출은 평균 수 초가 걸리는 대표적인 고지연(High-latency) I/O 작업입니다. Boot 3.2+ 환경에서 spring.threads.virtual.enabled=true 설정만 켜면, 복잡한 비동기 코드 없이 동기 방식(call())으로 코딩하더라도 수천 개의 동시 LLM 대기 요청을 스레드 블로킹 없이 가볍게 처리할 수 있습니다.

2. Spring AI는 내부적으로 어떻게 동작하는가? (JPA와의 비유)

처음 접할 때 흔히 오해하는 부분 중 하나가 "Spring AI가 자체적으로 AI 모델을 가지고 연산을 수행하는가?"입니다.

 

결론부터 말하면 아닙니다.

가장 근접한 비유는 우리가 매일 사용하는 Spring Data JPA(또는 MyBatis)와 실제 데이터베이스(MySQL, Oracle)의 관계입니다.

  • Spring Data JPA: 스스로 데이터를 저장하거나 쿼리를 실행하는 엔진이 아닙니다. Java 객체와 SQL을 변환해서 외부 DB 서버로 네트워크 요청을 보내고 결과를 받아오는 '인터페이스 및 통신 대행자'입니다.
  • Spring AI: 스스로 추론하거나 글을 쓰는 뇌(LLM)가 아닙니다. Java 코드로 작성된 요청을 외부에 있는 LLM 서버(OpenAI, Claude, Gemini, 로컬 Ollama 등)가 알아들을 수 있는 HTTP/JSON 규격으로 변환해 전송하고, LLM이 만들어낸 답변을 다시 Java 객체로 깔끔하게 포장해 주는 '고급 통역사 및 손발' 역할을 수행합니다.

 

프레임워크가 뒤에서(내부적으로) 대신 처리해 주는 4가지 핵심 작업

[내 Spring Boot 서버 (Spring AI)]                       [외부 LLM 서버 (OpenAI, Gemini 등)]
1. 프롬프트 조립 + 이전 대화/RAG 문서 끼워 넣기 (Advisor)
2. Java Record 구조를 보고 JSON 스키마 지시문 자동 생성
3. 벤더별 REST API 규격으로 변환 후 HTTP 요청 전송  ──────>  4. LLM이 추론 및 텍스트(JSON) 생성
6. 마크다운 제거 후 Jackson으로 Java 객체 자동 변환  <──────  5. 생성된 텍스트 응답 반환
  1. 프롬프트 몰래 고쳐 쓰기 (JSON 스키마 강제 주입): 개발자가 .entity(MyRecord.class)라고 한 줄만 적으면, Spring AI는 내부적으로 리플렉션을 통해 MyRecord의 필드 구조를 읽어냅니다. 그리고 외부 LLM에 HTTP 요청을 보내기 직전 프롬프트 끝에 "너의 응답은 반드시 다음 JSON Schema 규격을 준수해야 하며 마크다운 백틱(```json)을 넣지 마라"는 영문 스키마 지시문을 자동으로 덧붙여서 전송합니다.
  2. 컨텍스트(기억·문서) 자동 조립 (Advisor 엔진): LLM은 원래 방금 한 질문만 기억하는 무상태(Stateless) API입니다. Advisor를 설정해 두면, Spring AI가 호출 직전에 메모리나 DB에서 이전 대화 내역을 꺼내 오고, 벡터 DB에서 관련 사내 문서를 검색해 온 뒤, 사용자 질문과 하나로 합쳐 거대한 프롬프트로 조립해 LLM에 전달합니다.
  3. 도구 실행 왕복 루프 대행 (@Tool / Tool Calling): 내 Spring 컴포넌트 메서드에 @Tool을 붙여두면 놀라운 일이 벌어집니다.
    • ① Spring AI가 LLM에 질문과 함께 "내 서버에 이런 함수(메서드)가 있다"고 명세를 보냅니다.
    • ② LLM이 "그 함수를 인자값 X로 실행해서 결과를 알려달라"고 응답합니다.
    • ③ Spring AI가 개발자 대신 내 서버 안의 해당 Java 메서드를 직접 실행하고, 그 결과값을 다시 LLM에 전송합니다.
    • ④ LLM이 그 결과를 바탕으로 최종 답변을 만들어 반환합니다. 개발자는 .call() 한 번만 호출했지만, 프레임워크가 뒤에서 2~3번의 왕복 통신과 메서드 실행을 모두 대행해 주는 것입니다.
  4. 텍스트 분할 및 벡터 임베딩 변환 (ETL Pipeline): 문서를 벡터 DB에 넣을 때도 문서를 토큰 단위로 쪼개고(TokenTextSplitter), 외부 임베딩 모델을 호출해 텍스트를 숫자 배열(float[])로 바꾼 뒤 PGVector 같은 저장소에 적재하는 전 과정을 파이프라인으로 처리합니다.

3. Spring의 철학을 계승한 5대 핵심 기능 요약

핵심 기능 영역 주요 인터페이스 / 클래스 기존 Spring 생태계 비유 핵심 역할 및 동작 흐름
모델 통신 ChatClient, ChatModel RestClient / WebClient Fluent API 기반으로 프롬프트를 조립하고 동기(call) 및 스트리밍(stream) 방식으로 LLM과 통신합니다.
정형 데이터 매핑 StructuredOutputConverter Jackson ObjectMapper LLM의 비정형 텍스트 응답에 JSON 스키마를 강제하고, 이를 Java record나 POJO로 즉시 역직렬화합니다.
미들웨어 체인 CallAdvisor, StreamAdvisor Spring AOP / Servlet Filter LLM 요청 전후를 가로채어 대화 이력(ChatMemory) 주입, RAG 문서 결합, 민감정보 마스킹 및 로깅을 수행합니다.
데이터 파이프라인 DocumentReader, VectorStore Spring Batch + Spring Data 문서를 추출(Read)-분할(Transform)-적재(Write)하고, 20여 종의 벡터 DB에 단일 인터페이스로 유사도 검색을 수행합니다.
외부 도구 연동 @Tool, FunctionCallback, MCP @EventListener / RPC 연동 @Bean이나 컴포넌트의 일반 Java 메서드를 LLM이 스스로 판단하여 호출(Tool Calling)하도록 연결합니다.

4. 개발자 입장에서는 무엇이 편해지는가? (Before vs After 비교)

Spring AI를 도입하면 개발자가 작성해야 할 인프라성 보일러플레이트 코드의 80% 이상이 사라집니다. 가장 빈번하게 구현하는 "LLM에 질문하고 JSON 결과를 Java 객체로 변환해 받는 작업"을 코드로 비교해 보겠습니다.

 Before: Spring AI 없이 순수 REST 호출로 직접 개발할 때

OpenAI 전용 DTO를 일일이 만들고, 프롬프트에 JSON 예시를 직접 적어야 하며, 응답에서 마크다운 기호를 제거하고 수동으로 파싱해야 합니다. 나중에 모델을 Claude로 바꾸려면 이 코드를 전부 새로 짜야 합니다.

// 1. 벤더 전용 Request/Response DTO 클래스를 수십 줄에 걸쳐 직접 정의해야 함
// 2. JSON 포맷 강제를 위해 프롬프트 문자열에 스키마 설명을 수동으로 하드코딩
String prompt = "이 리뷰를 분석해줘: " + reviewText + 
                "\n반드시 마크다운 없이 {\"sentiment\": \"...\", \"score\": 0.0} 형태의 순수 JSON으로만 답해.";

OpenAiRequest request = new OpenAiRequest("gpt-4o-mini", List.of(new Message("user", prompt)));

// 3. HTTP 헤더, 인증, 엔드포인트를 직접 지정하여 호출
OpenAiResponse response = restClient.post()
        .uri("[https://api.openai.com/v1/chat/completions](https://api.openai.com/v1/chat/completions)")
        .header("Authorization", "Bearer " + apiKey)
        .body(request)
        .retrieve()
        .body(OpenAiResponse.class);

// 4. 응답 텍스트를 꺼낸 뒤, LLM이 실수로 붙인 ```json 기호를 정규식/문자열 치환으로 제거 후 수동 파싱
String rawContent = response.choices().get(0).message().content();
String cleanedJson = rawContent.replace("```json", "").replace("```", "").trim();
SentimentResult result = objectMapper.readValue(cleanedJson, SentimentResult.class);

After: Spring AI를 사용할 때

벤더 전용 DTO가 완전히 사라지고, 스키마 주입부터 마크다운 제거, Jackson 역직렬화까지 .entity() 한 줄로 끝납니다. 모델을 바꾸고 싶다면 코드는 손대지 않고 application.yml만 수정하면 됩니다.

public record SentimentResult(String sentiment, double score) {}

// 프롬프트 조립, JSON 스키마 주입, HTTP 통신, 객체 역직렬화가 단 5줄의 체이닝으로 완료됨
SentimentResult result = chatClient.prompt()
        .user(u -> u.text("이 리뷰를 분석해줘: {review}").param("review", reviewText))
        .call()
        .entity(SentimentResult.class);

5. 핵심 쟁점: "LLM 응답이 내 의도와 다르게 튀면 어떻게 통제하는가?"

백엔드 개발자가 AI를 연동할 때 가장 당황하는 지점이 바로 비결정성(Non-determinism)입니다. 기존의 Java 코드나 SQL 쿼리는 1 + 1 = 2처럼 입력이 같으면 결과가 100% 동일한 결정론적(Deterministic) 시스템입니다. 반면 LLM은 확률에 기반해 다음 단어를 예측하므로, 동일한 프롬프트를 던져도 때에 따라 미묘하게 다른 값이 나오거나 의도하지 않은 동작을 할 수 있습니다.

5.1. 실무에서 겪는 대표적인 의도 이탈 사례

  • JSON 필드 값의 일관성 파괴: 위험도(riskLevel) 필드에 "HIGH", "MEDIUM", "LOW" 중 하나만 들어오길 기대했는데, 어느 날 갑자기 "매우 높음"이나 "Critical"이라는 문자열을 반환하는 경우.
  • 엉뚱한 @Tool 호출 및 인자 포맷 오류: 날짜 파라미터로 YYYY-MM-DD를 기대했는데 2026년 11월이라고 넘겨서 Java 메서드에서 파싱 에러가 터지는 경우.
  • 환각(Hallucination): 제공된 문서에 없는 내용을 LLM이 자체 학습 지식을 섞어 사실인 것처럼 지어내는 경우.

5.2. 고삐 풀린 LLM을 울타리 안에 가두는 4가지 엔지니어링 통제 기법

Spring AI에서는 이러한 불확실성을 코드 레벨에서 통제하기 위해 다음과 같은 명확한 안전장치를 사용합니다.

통제 계층 구체적인 구현 방법 기대 효과
1단계: 확률 통제

(temperature 고정)
application.yml에서 temperature: 0.0 (또는 0.1) 설정 모델의 창의성(무작위성)을 극도로 억제하여, 동일한 입력에 대해 항상 가장 확률이 높은 일관된 답변만 출력하도록 고정합니다.
2단계: 타입 강제

(String 대신 Enum 사용)
응답 record 필드에 열린 String 대신 Enum 타입 선언 JSON Schema에 "enum": ["LOW", "MEDIUM", "HIGH"] 제약이 걸려, 지정된 상수 외의 엉뚱한 문자열은 아예 생성하지 못하게 차단합니다.
3단계: 자가 교정 루프

(Validation Advisor)
스키마 검증 Advisor 또는 Spring Retry 적용 만약 LLM이 스키마에 어긋난 응답을 주면, 비즈니스 로직으로 넘기지 않고 프레임워크가 *"스키마가 틀렸으니 다시 맞춰서 응답해"*라고 자동 재요청합니다.
4단계: 방어적 코딩

(Guardrails & Fallback)
반환된 객체에 Bean Validation(@NotNull) 수행 및 예외 시 Fallback 처리 끝내 비정상 응답이 오거나 타임아웃이 발생하더라도 장애가 전파되지 않도록 차단하고, 기본값이나 기존 Rule 기반 로직으로 안전하게 우회시킵니다.

6. 실전 통합 코드: 불확실성을 통제하는 Spring Boot 3.x 표준 구현

앞서 살펴본 가상 스레드 설정, Enum을 활용한 엄격한 Structured Output 스키마 강제, 명확한 @Tool 파라미터 명세, 그리고 스트리밍 응답까지 모두 반영한 실전 코드입니다.

6.1. application.yml 설정

spring:
  threads:
    virtual:
      enabled: true # Java 21 가상 스레드 활성화로 LLM I/O 대기 시 스레드 풀 고갈 방지
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.0 # 비결정성(무작위성)을 최소화하기 위해 온도를 0으로 고정

6.2. Enum으로 선택지를 가둔 도메인 응답 객체 및 @Tool 정의

package com.example.ai.domain;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.List;

// 1. 열린 String 대신 Enum을 사용하여 LLM이 지정된 값 외에는 절대 응답하지 못하도록 강제합니다.
public enum RiskLevel {
    LOW, MEDIUM, HIGH, CRITICAL
}

// 2. Structured Output 매핑용 불변 Record
public record SystemInspectionReport(
        String targetService,
        RiskLevel riskLevel, // JSON Schema에 enum 제약조건으로 자동 변환됨
        boolean immediateActionRequired,
        List<String> actionItems
) {}

// 3. LLM이 필요시 호출할 내부 상태 조회 도구 (파라미터 포맷을 구체적으로 명시)
@Component
public class MetricMonitoringTools {

    @Tool(description = "특정 마이크로서비스의 현재 CPU 사용률과 에러율 지표를 조회합니다.")
    public String getServiceMetrics(
            @ToolParam(description = "조회할 서비스 영문 식별자 (예: order-service, payment-service)") String serviceId) {
        // 실제 운영 환경에서는 Prometheus나 모니터링 API를 조회합니다.
        return String.format("Service: %s | CPU: 88%% | ErrorRate: 4.2%% | Status: DEGRADED", serviceId);
    }
}

6.3. ChatClient 조립 및 방어적 서비스 구현

package com.example.ai.service;

import com.example.ai.domain.MetricMonitoringTools;
import com.example.ai.domain.RiskLevel;
import com.example.ai.domain.SystemInspectionReport;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.List;

@Service
public class SystemInspectionAiService {

    private final ChatClient chatClient;
    private final MetricMonitoringTools monitoringTools;

    public SystemInspectionAiService(ChatClient.Builder builder, MetricMonitoringTools monitoringTools) {
        this.monitoringTools = monitoringTools;
        this.chatClient = builder
                .defaultSystem("""
                        당신은 시니어 백엔드 엔지니어이자 시스템 안정성 분석가입니다.
                        1. 반드시 제공된 모니터링 도구를 먼저 호출하여 실제 지표를 확인한 뒤 분석하세요.
                        2. 확인되지 않은 사실을 추측해서 답변하지 마세요.
                        3. 에러율이 3% 이상이거나 CPU가 85% 이상이면 riskLevel을 HIGH 이상으로 판정하세요.
                        """)
                .defaultAdvisors(new SimpleLoggerAdvisor())
                .build();
    }

    // 주의: 외부 LLM HTTP 호출 구간에는 절대 @Transactional을 선언하지 않습니다!
    public SystemInspectionReport inspectService(String serviceId) {
        try {
            SystemInspectionReport report = chatClient.prompt()
                    .user(u -> u.text("{serviceId} 서비스의 현재 상태를 점검하고 조치 사항을 분석해줘.")
                                .param("serviceId", serviceId))
                    .tools(monitoringTools) // 도구 등록 (Spring AI가 내부에서 왕복 실행 대행)
                    .call()
                    .entity(SystemInspectionReport.class); // Record 및 Enum 스키마로 강제 매핑

            // 방어적 검증: 필수값 누락 시 Fallback 처리
            if (report == null || report.riskLevel() == null) {
                return createFallbackReport(serviceId, "LLM 응답 스키마 불일치로 인한 기본 리포트 반환");
            }
            return report;

        } catch (Exception e) {
            // 외부 LLM 서버 장애나 타임아웃 발생 시 시스템 전체 장애로 번지지 않도록 Fallback 반환
            return createFallbackReport(serviceId, "AI 분석 일시 지연 (수동 지표 확인 필요): " + e.getMessage());
        }
    }

    // Spring 5 WebFlux 기반의 실시간 토큰 스트리밍 응답
    public Flux<String> streamTroubleshootingGuide(String symptom) {
        return chatClient.prompt()
                .user(symptom + " 현상에 대한 단계별 장애 대응 가이드를 작성해줘.")
                .stream()
                .content();
    }

    private SystemInspectionReport createFallbackReport(String serviceId, String reason) {
        return new SystemInspectionReport(serviceId, RiskLevel.MEDIUM, false, List.of(reason));
    }
}

7. 실무 도입 및 운영 시 고려사항

Spring AI가 개발 생산성을 비약적으로 높여주지만, 이를 실제 프로덕션 환경에 배포할 때는 다음 세 가지 아키텍처 원칙을 반드시 지켜야 합니다.

① 외부 LLM 호출과 DB 트랜잭션(@Transactional) 경계 분리

가장 흔히 범하는 치명적인 실수가 서비스 메서드 상단에 @Transactional을 붙인 채로 chatClient.prompt().call()을 호출하는 것입니다. 가상 스레드를 켜서 수천 개의 요청을 동시에 받더라도, RDBMS의 물리적 커넥션 풀(HikariCP)은 보통 10~30개로 제한되어 있습니다. LLM이 답변을 생성하는 3~10초 동안 트랜잭션이 열린 채로 대기하면 DB 커넥션 풀이 순식간에 고갈(Connection Starvation)되어 일반 API까지 전면 마비됩니다.

  • 해결책: 사전 DB 조회(Read) -> 트랜잭션 종료 -> 외부 LLM 호출 -> 결과 저장용 신규 트랜잭션 시작(Write) 형태로 경계를 철저히 분리해야 합니다.

② LLM에게 최종 결정권(Write/Delete)을 직접 부여하지 않기

LLM의 비결정성 때문에, @Tool 메서드 내부에 중요한 데이터를 바로 DELETE하거나 결제·송금을 실행하는 로직을 넣는 것은 매우 위험합니다.

  • 해결책: LLM에게는 조회(Read-Only) 도구와 분석·분류·초안 생성 역할만 부여하고, 실제 상태를 변경하는 핵심 트랜잭션은 사용자의 최종 확인(Human-in-the-loop)을 거치거나 확정적인 일반 Java 검증 코드가 실행하도록 설계해야 합니다.

③ 대용량 문서 전처리(ETL) 시 배치 분할과 Rate Limit 제어

사내 문서를 VectorStore에 적재할 때 전체 데이터를 한 번에 임베딩 API로 던지면 메모리 부족(OOM)과 벤더의 분당 토큰 제한(TPM/RPM Rate Limit) 에러가 발생합니다. 문서를 적절한 토큰 단위(TokenTextSplitter)로 분할한 뒤 일정 청크 단위로 나누어 적재하고, 실패 시 해당 청크부터 재시도할 수 있도록 문서 식별자 메타데이터를 함께 관리해야 합니다.

 

엔터프라이즈 환경에서 수억 건 이상의 대용량 테이블을 다룰 때 파티셔닝(Partitioning)은 필수적인 아키텍처입니다. 하지만 현업 시스템을 진단해 보면, 테이블은 훌륭하게 파티셔닝해 두고 정작 그 위에 생성된 인덱스는 별다른 고민 없이 모두 Global Index(글로벌 인덱스)로만 도배해 놓은 경우를 흔히 목격합니다.

 

CREATE INDEX 구문 작성 시 맨 끝에 LOCAL 키워드 하나를 누락하면 오라클은 기본값(Default)으로 Global Index를 생성하기 때문입니다. 이 사소한 습관과 아키텍처 통제 부재가 누적되면, 파티션 테이블의 최대 장점인 데이터 생명주기 관리(ILM) 기능이 마비되고 야간 배치마다 대형 장애가 터지는 결과를 초래합니다.

 

데이터 아키텍처(DA) 및 DBA 관점에서 무분별한 Global Index 남용이 오라클 내부 메커니즘에 미치는 치명적인 악영향을 분석하고, 운영 중인 시스템에서 무중단으로 Global Index를 Local Index로 전환하는 실무 절차와 표준 거버넌스를 상세히 정리해 보겠습니다.

 


1. 개요: 왜 실무에는 Global Index가 넘쳐나는가?

파티션 테이블을 도입하는 가장 큰 목적은 분할 정복(Divide and Conquer)입니다. 조회 성능을 높이는 파티션 프루닝(Partition Pruning)뿐만 아니라, 오래된 과거 데이터를 DELETE 대신 DROP PARTITION이나 TRUNCATE PARTITION으로 1초 만에 부하 없이 날려버리는 운영 관리성이 핵심입니다.

하지만 테이블에 Global Index가 무분별하게 생성되어 있으면 이러한 파티션 단위의 독립적인 작업이 불가능해집니다. 실무에서 Global Index가 남용되는 주요 배경은 다음과 같습니다.

  • DDL 기본값의 함정: 개발자나 설계자가 인덱스 생성 스크립트를 작성할 때 명시적으로 LOCAL 옵션을 지정하지 않으면, 오라클 엔진은 무조건 Global Non-Partitioned Index로 생성합니다.
  • 단편적인 성능 튜닝: SQL 튜닝 시 조건절에 파티션 키(예: 기준일자)가 누락된 쿼리의 응답 속도만 보고, 애플리케이션 쿼리를 수정(파티션 키 조건 추가)하는 대신 손쉽게 Global Index를 생성하여 해결하려는 경향이 있습니다.

2. 내부 메커니즘: Global Index 과다 환경이 엔진에 주는 부하

테이블은 수십~수백 개의 파티션으로 쪼개져 있는데, 인덱스가 5~10개 모두 단일 세그먼트 형태의 Global Index로 묶여 있을 때 오라클 내부(Buffer Cache, Undo/Redo, B-Tree 구조)에서 어떤 문제가 발생하는지 살펴보겠습니다.

2.1 단일 B-Tree 비대화로 인한 BLEVEL 증가와 Buffer Cache 오염

테이블 데이터가 10억 건이고 100개의 파티션으로 나뉘어 있다면, 각 테이블 파티션은 1,000만 건씩 가볍게 유지됩니다. 만약 Local Index를 썼다면 각 인덱스 파티션도 1,000만 건 크기의 아담한 B-Tree(보통 BLEVEL = 2, Height 3)를 유지합니다.

반면, Global Non-Partitioned Index는 10억 건 전체의 키 값과 10바이트 크기의 Extended ROWID를 단 하나의 거대한 B-Tree 세그먼트에 모두 담아야 합니다.

  • 트리 깊이(BLEVEL) 증가: 단일 인덱스 세그먼트의 크기가 수십~수백 GB로 비대화되면서 BLEVEL이 3 또는 4(Height 4~5)로 깊어집니다. 이는 모든 인덱스 스캔 시 추가적인 브랜치 블록(Branch Block) I/O를 유발합니다.
  • Index Leaf Block Split 경합: 전 파티션으로 유입되는 DML이 단일 인덱스 트리에 집중되면서 리프 블록 분할(90-10 또는 50-50 Split)이 빈번하게 발생하고, 이 과정에서 enq: TX - index contention 대기 이벤트와 Redo 로그 생성량이 급증합니다.

2.2 파티션 DDL 수행 시 발생하는 UNDO / REDO 폭탄과 고아 엔트리

과거 파티션 하나(예: 1,000만 건)를 정리하기 위해 ALTER TABLE ... DROP PARTITION ... UPDATE GLOBAL INDEXES를 수행할 때의 내부 메커니즘입니다.

  • Oracle 11g 이하 (동기식 정비): 삭제되는 테이블 파티션의 1,000만 건에 해당하는 인덱스 엔트리를 Global Index에서 전부 찾아내어 실시간으로 지워야 합니다. 만약 Global Index가 5개라면 총 5,000만 건의 인덱스 DELETE 작업이 백그라운드에서 일어나며, 막대한 UNDO 세그먼트 소모와 REDO 로그 폭탄(Archive Log 풀 현상)을 일으킵니다.
  • Oracle 12c 이상 (비동기식 정비 - ORPHANED_ENTRIES): DDL 자체는 메타데이터에 마킹만 남기고 1초 만에 끝나지만, 근본적인 I/O 부하가 사라진 것이 아닙니다.
    1. 야간에 SYS.PMO_DEFERRED_GIDX_MAINT_JOB 스케줄러가 돌기 전까지, 주간 온라인 쿼리가 Global Index를 스캔할 때마다 삭제된 파티션의 고아 엔트리(Orphaned Entries)들이 리프 블록에 그대로 남아 있어 불필요한 블록 스캔(Dead Index Entry Scan)을 유발합니다.
    2. 야간 배치 시간대에 거대한 Global Index 5~6개를 동시에 청소(Cleanup)하느라 배치 윈도우 타임에 심각한 I/O 병목이 발생합니다.

3. 주요 원인 분석: 회사에서 Global Index 남용 시 겪는 4대 문제점

실무 운영 환경에서 Global Index가 주류를 이룰 때 발생하는 대표적인 장애와 병목 현상입니다.

3.1 배치 작업자의 실수로 인한 ORA-01502 대형 장애 상시 노출

파티션 테이블에 Global Index가 1개라도 존재하면, 해당 테이블에 수행되는 모든 파티션 DDL(DROP, TRUNCATE, MOVE, SPLIT, EXCHANGE)에 반드시 UPDATE INDEXES 절이 붙어야 합니다. 누군가 작업 스크립트에서 이 옵션 하나만 실수로 누락해도, 테이블에 걸린 모든 Global Index가 즉시 UNUSABLE 상태에 빠지며 전사 온라인 서비스가 먹통(ORA-01502)이 됩니다.

3.2 인덱스 리빌드(Rebuild) 및 통계 수집(Gather Stats) 시간 폭증

인덱스 단편화(Fragmentation)가 발생하여 작업을 하려 해도, Local Index는 최근 변경이 발생한 당월 파티션 1개(MODIFY PARTITION ... REBUILD)만 수십 초 내에 온라인 리빌드하면 끝납니다. 하지만 Global Index는 수백 GB에 달하는 인덱스 전체 세그먼트를 통째로 리빌드해야 하므로 작업 시간과 Temp Tablespace 소모량이 감당할 수 없을 정도로 커집니다.

3.3 EXCHANGE PARTITION을 활용한 초고속 데이터 적재 아키텍처 불가

대용량 DW나 마트 환경에서는 임시 테이블에 데이터를 적재하고 인덱스를 만든 뒤, 본 파티션 테이블과 메타데이터만 맞바꾸는 ALTER TABLE ... EXCHANGE PARTITION ... INCLUDING INDEXES 기법을 자주 씁니다. 하지만 본 테이블에 Global Index가 덕지덕지 붙어 있으면 세그먼트 1:1 매핑 구조가 깨져버려 메타데이터 스왑(0.1초 완료)이 불가능해지고, 결국 글로벌 인덱스를 갱신하느라 수십 분이 소요됩니다.

3.4 스토리지(Tablespace) 계층화 및 Read-Only 아카이빙 불가

오래된 과거 테이블 파티션을 저비용 스토리지(Cold 영역)로 이동(MOVE)시키거나 READ ONLY 테이블스페이스로 전환하려 해도, Global Index는 최신 데이터와 과거 데이터가 한 세그먼트에 뒤섞여 있어 인덱스 영역의 스토리지 수명주기 분리가 원천적으로 불가능합니다.

4. 트러블슈팅 및 해결 방안: Global $\rightarrow$ Local Index 전환 전략

이미 운영 중인 시스템에 무분별하게 생성된 Global Index들을 찾아내고, 서비스 영향도 없이 Local Index로 전환(Migration)하는 단계별 실무 가이드입니다.

4.1 1단계: 파티션 테이블 내 Global Index 전수 조사 및 전환 대상 식별

먼저 스키마 내에 존재하는 모든 파티션 테이블 중, PK/Unique 제약조건이 아님에도 불구하고(즉, 단순 검색용 NONUNIQUE 인덱스임에도) Global로 생성되어 있는 인덱스들을 추출합니다.

-- [진단 쿼리] 파티션 테이블에 생성된 Global Index 목록 및 크기(MB), 파티션 키 포함 여부 점검
SELECT I.TABLE_NAME,
       I.INDEX_NAME,
       I.UNIQUENESS,
       I.BLEVEL,
       I.STATUS,
       I.ORPHANED_ENTRIES,
       ROUND(S.BYTES / 1024 / 1024, 2) AS SIZE_MB,
       -- 파티션 키 컬럼 목록 추출
       (SELECT LISTAGG(COLUMN_NAME, ', ') WITHIN GROUP (ORDER BY COLUMN_POSITION)
          FROM DBA_PART_KEY_COLUMNS P
         WHERE P.NAME = I.TABLE_NAME AND P.OWNER = I.OWNER) AS PART_KEY_COLS,
       -- 현재 인덱스 구성 컬럼 목록 추출
       (SELECT LISTAGG(COLUMN_NAME, ', ') WITHIN GROUP (ORDER BY COLUMN_POSITION)
          FROM DBA_IND_COLUMNS C
         WHERE C.INDEX_NAME = I.INDEX_NAME AND C.INDEX_OWNER = I.OWNER) AS IDX_COLS
  FROM DBA_INDEXES I
  JOIN DBA_PART_TABLES PT ON I.TABLE_NAME = PT.TABLE_NAME AND I.OWNER = PT.OWNER
  LEFT JOIN DBA_SEGMENTS S ON I.INDEX_NAME = S.SEGMENT_NAME AND I.OWNER = S.OWNER
 WHERE I.OWNER = 'TARGET_SCHEMA' -- 대상 스키마명 입력
   AND I.PARTITIONED = 'NO'      -- Global Non-Partitioned Index 필터링
 ORDER BY SIZE_MB DESC NULLS LAST;

4.2 2단계: Local Index 전환 가능 여부 판단 기준 (아키텍처 분류)

위 쿼리로 추출된 Global Index들을 다음 3가지 기준으로 분류하여 작업 방향을 결정합니다.

분류 케이스 인덱스 특성 및 조건 전환 전략 및 조치 방안
Case 1. 즉시 전환 대상 NONUNIQUE 인덱스이면서, 이미 인덱스 구성 컬럼 내에 파티션 키가 포함되어 있거나 조회 SQL에 항상 파티션 키 조건이 함께 들어오는 경우 지체 없이 Local Index로 재생성 (LOCAL 옵션 부여)
Case 2. SQL 튜닝 병행 대상 NONUNIQUE 인덱스인데, 애플리케이션 SQL에서 파티션 키 조건 없이 이 인덱스 컬럼으로만 조회하고 있는 경우 애플리케이션 쿼리 WHERE 절에 파티션 키 범위 조건(예: 최근 3개월 등)을 추가하도록 유도한 뒤 Local Index로 전환
Case 3. PK / Unique 인덱스 단일 식별자 컬럼(예: ORDER_NO)으로 구성된 Unique Index (ORA-14039 제약으로 인해 단순 Local 전환 불가) 1안(권장): 업무적으로 허용 시 PK에 파티션 키를 결합(ORDER_NO + SALE_DATE)하여 Local Unique Index로 전환

2안: 결합 불가 시 해당 PK 1개만 Global Index로 유지

4.3 3단계: INVISIBLE 인덱스를 활용한 온라인 무중단 전환 (Oracle 11g R2 이상)

운영 중인 대용량 시스템에서 기존 Global Index를 바로 DROP하고 Local Index를 생성하면, 인덱스가 생성되는 수십 분 동안 해당 인덱스를 타던 모든 온라인 쿼리가 Full Table Scan을 타며 DB가 마비됩니다.

따라서 INVISIBLE 속성과 동일 컬럼 인덱스 우회 기법(더미 컬럼 추가 또는 교체), 혹은 작업 윈도우 내 Online 전환 절차를 활용해야 합니다.

방법 A: 야간 경량 시간대 표준 전환 스크립트 (Drop & Create Online)

가장 깔끔한 방법으로, 트랜잭션이 적은 시간대에 수행합니다.

-- 1. 기존 Global Index 삭제
DROP INDEX OWNER.IDX_SALES_CUST;

-- 2. Local Index로 온라인 병렬 생성 (파티션별로 세그먼트가 나뉘어 생성됨)
CREATE INDEX OWNER.IDX_SALES_CUST ON OWNER.TB_SALES (CUST_ID, SALE_DATE) 
LOCAL PARALLEL 8 NOLOGGING ONLINE;

-- 3. [필수] PARALLEL 및 LOGGING 속성 원복
ALTER INDEX OWNER.IDX_SALES_CUST NOPARALLEL LOGGING;

방법 B: 주간 무중단 검증 후 스위칭 (Invisible Index 활용)

오라클은 완전히 동일한 컬럼 순서로 두 개의 인덱스를 만들 수 없습니다(ORA-01408). 하지만 기존 Global Index가 (CUST_ID) 단일 컬럼이었다면, Local Index는 파티션 프루닝이나 커버링 인덱스 효과를 겸해 (CUST_ID, SALE_DATE) 형태로 컬럼을 구성함으로써 기존 Global Index를 유지한 채 새로운 Local Index를 미리 생성할 수 있습니다.

-- 1. 옵티마이저가 인지하지 못하도록 INVISIBLE 및 ONLINE 옵션으로 신규 Local Index 선발 생성
CREATE INDEX OWNER.IDX_SALES_CUST_LOCAL ON OWNER.TB_SALES (CUST_ID, SALE_DATE)
LOCAL INVISIBLE ONLINE PARALLEL 8 NOLOGGING;

-- 2. 병렬도 및 로깅 원복
ALTER INDEX OWNER.IDX_SALES_CUST_LOCAL NOPARALLEL LOGGING;

-- 3. 특정 세션에서만 신규 Local Index를 타도록 활성화하여 실행 계획(Buffers) 사전 검증
ALTER SESSION SET OPTIMIZER_USE_INVISIBLE_INDEXES = TRUE;
EXPLAIN PLAN FOR SELECT * FROM OWNER.TB_SALES WHERE CUST_ID = 1004 AND SALE_DATE >= SYSDATE - 30;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-- 4. 검증 완료 후: 기존 Global Index 삭제 및 신규 Local Index VISIBLE 전환 (1초 내 완료)
DROP INDEX OWNER.IDX_SALES_CUST;
ALTER INDEX OWNER.IDX_SALES_CUST_LOCAL VISIBLE;
ALTER INDEX OWNER.IDX_SALES_CUST_LOCAL RENAME TO IDX_SALES_CUST;

요약

회사 내에 만연한 Global Index 남용 문제를 해결하기 위해서는 명확한 표준 인덱스 거버넌스 수립이 필수적입니다.

  1. 원칙의 역전 (Local-First 정책): 파티션 테이블의 모든 인덱스는 "기본이 Local Index이고, 예외적인 경우에만 승인을 거쳐 Global Index를 허용한다"는 DDL 배포 표준을 수립해야 합니다.
  2. 모델링 단계의 복합 PK 표준화: 신규 파티션 테이블을 설계할 때는 초기 모델링 단계부터 기본키(PK)에 파티션 기준 컬럼(일자 등)을 포함시켜 PK조차도 Local Unique Index로 생성되도록 유도하는 것이 최선입니다.
  3. 기존 Global Index의 점진적 다이어트: DBA_INDEXES 뷰에서 PARTITIONED = 'NO'인 일반(NONUNIQUE) 인덱스들을 전수 조사하여, 파티션 키 조건과 함께 사용되는 인덱스부터 순차적으로 Local Index로 마이그레이션하면 배치 안정성과 스토리지 관리 효율을 극적으로 끌어올릴 수 있습니다.

+ Recent posts