5화: 자바 탓이라더니 범인은 인덱스였습니다




개발 환경 오라클 DB에서 어제 야간 배치 슬로우 쿼리를 똑같이 재현해 내는 작업은 생각만큼 쉽지 않았다. 개발 서버 DB에 들어 있는 테스트 데이터라 해봐야 고작 수십 줄에 불과한 더미 레코드뿐이었기 때문이다. 젠킨스(Jenkins) 콘솔에서 테스트 배치를 실행하면 0.2초 만에 깔끔하게 성공 메시지가 떠버렸다. 나는 스택 트레이스 콘솔을 몇 번이나 뒤적거렸지만, 0.2초라는 허탈한 실행 시간만 반복해서 모니터에 찍힐 뿐이었다.


"개발 DB 환경은 동시성 요청도 없고 수집 데이터가 적다 보니 슬로우 쿼리가 재현되지 않는군요. 하지만 운영 환경처럼 수천만 건의 DTO 객체가 매핑되면 이야기가 완전히 달라집니다. JVM 런타임 힙 메모리 파편화와 가비지 컬렉션(GC) 지연이 100% 원인입니다."


나는 모니터 화면의 0.2초 성공 기록을 지워버리며 내적 확신을 다졌다. 늙은 자바(Java) 8과 레거시 프레임워크 생태계가 대용량 DTO 객체를 런타임에 미친 듯이 생성하고 버리는 오버헤드를 견디지 못하고 터진 것이 명백했다. Rust처럼 컴파일 타임에 소유권(Ownership) 모델로 메모리를 철저히 제어했다면 결코 발생하지 않았을 프레임워크 수준의 런타임 체증이었다. 대용량 배치 루프 도중 GC 지연이 힙 메모리를 터트리면서 커넥션 반납을 지연시켰을 게 틀림없었다.


나는 모니터에 자바 쓰레드 덤프 분석 도구를 띄워 놓고 힙 메모리 할당 그래프를 훑어보며 내 이론을 뒷받침할 증거를 찾으려 애썼다.


그때, 어제 당직을 서고 한참 동안 모니터 콘솔을 응시하던 한지훈 선배가 조용히 내 자리 옆으로 의자를 끌고 다가왔다. 한 선배의 손에는 뜨거운 믹스커피 잔이 들려 있었고, 모니터 화면을 바라보는 눈빛은 밤을 새웠음에도 덤덤하고 날카로웠다.


"강현호 씨, 개발 DB 데이터가 적어서 재현이 안 되면... 운영 규모에 맞춰 더미 데이터를 생성한 뒤 실행 계획(Execution Plan)을 뽑아보면 됩니다."


한 선배는 무심한 표정으로 자신의 사내 노트북 화면을 내 쪽으로 돌려 보여주었다. 모니터 화면에는 오라클 DB 쿼리 콘솔과 함께, 한 선배가 개발 DB 환경에 더미 데이터 생성 프로시저를 돌려 1,000만 건의 재고 수불 레코드를 채워 넣은 테스트 스크립트가 떠 있었다.


"어제 슬로우 쿼리가 터진 재고 수불 집계 매퍼 SQL을 개발 DB에서 실행 계획(EXPLAIN PLAN)으로 출력해 본 결과입니다."


한 선배의 손가락이 터미널 화면 하단의 실행 계획 결과를 짚었다.


실행 계획 결과: TABLE ACCESS FULL. 대상 테이블 TB_HANBIT_STOCK_HIST. 예상 로우 1,000만 건, Cost 45,210, CPU 부담 99%. 인덱스를 전혀 사용하지 않는 풀 스캔이었다.


화면에 선명하게 찍힌 단어는 'TABLE ACCESS FULL'이었다.


"어제 멈춰 선 배치의 핵심 SQL은 재고 수불 이력 테이블을 조회하면서 인덱스(Index)를 전혀 타지 못하고 1,000만 건 전체를 풀 스캔(Full Scan)하고 있었습니다. 배치 파이프라인에서 수백 번 반복 실행되는 SQL이 풀 스캔을 때리니 DB 디스크 I/O가 99%에 달했고, 커넥션 풀을 반납하지 못한 세션들이 연쇄 락(Lock) 대기에 걸린 겁니다."


한 선배가 제시한 수치 데이터와 오라클 DB 트레이스 리포트는 지극히 냉정하고 확실했다. 원인은 자바 힙 메모리 파편화나 GC pause가 아니었다. 지난달 운영 DB 마이그레이션 과정에서 수불 일자와 상품 코드로 구성된 복합 인덱스가 누락되어, 오라클 DB 엔진이 1,000만 건의 디스크 블록을 일일이 순회하며 읽어 들이다가 세션 타임아웃을 일으킨 것이었다.


순간 내 머릿속이 멍해졌다.


'자바 GC 타임아웃이... 아니라고?'


내가 자바 8과 레거시 생태계의 구조적 한계라며 자신만만하게 주장했던 근거가 오라클 DB 복합 인덱스 하나 앞에 허망하게 깨져나가는 순간이었다. 명확하게 제시된 실행 계획 수치와 디스크 I/O 비용 앞에서 입이 잘 떨어지지 않았다. 내가 모니터링하던 자바 힙 메모리 그래프는 DB 응답 지연으로 인해 스레드가 대기 상태로 지연되면서 발생한 이차적인 현상에 불과했던 것이다.


하지만 나는 쉽게 내적 오만을 꺾고 싶지 않았다. 이대로 순순히 인정하면 지난 며칠간 목에 힘을 주고 주장했던 내 기술적 우월감이 수포로 돌아가는 기분이었다.


"선배님… 그렇다 해도 근본적인 원인은 MyBatis 매퍼 XML에서 매개변수 바인딩을 동적 문자열로 어설프게 이어 붙였기 때문이 아닙니까? 자바 애플리케이션에서 매개변수를 제대로 넘기지 못해 오라클 옵티마이저가 인덱스를 타지 않고 풀 스캔을 선택한 것 아닌가요?"


나는 겉으로는 한발 물러서는 척했지만, 예의 바른 존댓말로 억지 논리를 늘어놓았다. 자바 프레임워크와 애플리케이션 파싱 오버헤드가 DB 옵티마이저의 판단을 방해했다는 나만의 주장을 꺾지 않으려 안간힘을 썼다.


옆에서 두 사람의 대화를 듣고 있던 박정수 팀장님이 다가와 한지훈 선배의 모니터를 유심히 살폈다.


"지훈 씨, 분석이 아주 정확하네요. 지난달 운영 DB 마이그레이션 과정에서 재고 이력 테이블의 복합 인덱스 하나가 누락된 모양입니다. 강현호 씨가 말한 자바 런타임 병목이 아니라 DB 튜닝 이슈였군요."


박 팀장님은 한 선배의 실행 계획 분석 보고를 치하하며 내 의견을 가볍게 뒤로 밀어두었다. 팀장님은 즉시 메모장에 인덱스 추가 구문 스크립트를 정리하기 시작했다.


"임동현 PM님과 고객사 DBA 쪽에 이 실행 계획 덤프와 인덱스 생성 튜닝안을 전달하겠습니다. 다음 주 정기 변경 창구(Change Window)에 운영 DB 인덱스 생성 스크립트를 반영하면 슬로우 쿼리 장애는 깔끔하게 해결되겠네요."


팀장님의 결정에 한 선배는 덤덤하게 고개를 숙이며 자기 자리로 돌아갔다.


나는 사내 노트북 화면을 멍하니 바라보았다. 오라클 execution plan 수치 데이터 앞에 자바 런타임 타령을 하던 내 논리가 무색해진 것이 사실이었다.


'하지만... 내 판단이 완전히 틀린 것은 아니다. 자바 런타임의 동적 매퍼 구조가 SQL 복잡성을 은폐했기 때문에 일어난 일일 뿐이야.'


나는 여전히 내 사내 노트북 화면에 띄워진 인프라팀 승인 포털의 '외부 바이너리 사내 반입 및 넥서스 등록 신청서' 상태 창을 응시했다. 승인 대기 표시는 변함이 없었다.


'두고 봐라. 일주일 뒤 내 Rust 정적 분석 도구 반입 승인이 떨어지고 젠킨스 파이프라인에 탑재되는 순간, 이런 DB 매퍼 복잡도나 인덱스 누락 가능성까지 정적 분석 단계에서 수치로 깔끔하게 탐지해 낼 테니까. 그때가 되면 다들 이 레거시 시스템을 개선하려면 내 접근법이 필요했다는 걸 알게 될 것이다.'


나는 자바 생태계에 대한 냉소와 내적 오만을 내려놓지 않은 채, 다음 보안 승인 단계를 기다리며 덤덤하게 자세를 고쳐 앉았다.





<6화에서 계속>