기술

화면엔 452개뿐인데, 매 조회마다 98만 행을 집계하고 있었습니다

2026.07.235분 읽기

팝업 하나 여는 데 1.6초가 걸렸습니다. 프랜차이즈 ERP의 물류 매입 화면에서 '필수품목 관리' 팝업을 열 때마다, 사용자는 매번 1.6초를 기다려야 했습니다. 목록에 뜨는 품목은 452개. 수백 개 규모의 목록이 이렇게 느릴 이유가 없어 보였습니다.

EXPLAIN을 돌려보고 이유를 알았습니다. 화면에 필요한 건 품목 452개인데, 매 조회마다 매입 원장 약 98만 행을 통째로 집계하고 있었습니다. 결과 행수와 스캔 행수 사이의 이 간극이 곧 낭비였습니다.


범인은 서브쿼리 속 MAX 하나였습니다

목록 쿼리 안에는 각 품목의 표시명을 뽑는 서브쿼리가 있었습니다. 같은 품목 코드로 여러 행이 쌓이는 원장에서 대표 이름 하나를 고르려고 MAX(item_name)을 쓴 구조였습니다. 문제는 item_name이 인덱스에 없는 컬럼이었다는 점입니다.

인덱스에 없는 컬럼을 MAX나 MIN으로 집계하면, 옵티마이저는 인덱스만으로 답을 낼 수 없으니 테이블 전체를 훑습니다. 품목 코드에는 인덱스가 있었지만 집계 대상이 인덱스 밖이라 소용이 없었습니다. 그렇게 452개를 만들려고 98만 행을 매번 읽는 구조가 완성돼 있었습니다.

1.png


무거운 하나를 가벼운 둘로 쪼갰습니다

창고에서 재고 목록을 만드는 상황에 비유하면 이렇습니다. 어떤 품목이 있는지는 입구의 명부만 보면 되는데, 상자에 붙은 라벨 이름까지 한 번에 적으려다 보니 상자를 전부 뜯어보게 된 셈입니다. 명부로 목록을 먼저 만들고, 라벨은 해당 상자만 열어 확인하면 일이 훨씬 가벼워집니다. 쿼리도 같은 방식으로 나눴습니다.

품목 목록은 인덱스만으로 뽑았습니다. GROUP BY item_cd HAVING MAX(stock_date) >= 기준일 형태로 바꾸자 실행계획에 'Using index for group-by'가 떴습니다. 인덱스만 훑어 그룹 대표를 뽑는 loose index scan으로, 값 컬럼은 아예 건드리지 않습니다.

표시명은 품목별 최신 행의 id로 PK 단건 조회했습니다. 목록 쿼리에서 이름 집계를 떼어내자 풀스캔이 사라졌습니다.

핵심은 인덱스를 새로 추가하지 않았다는 점입니다. 이미 있는 인덱스가 커버할 수 있는 모양으로 쿼리를 다시 짠 것뿐입니다. 대표 키는 인덱스만으로 뽑고, 표시값은 그 키로 정확히 한 건씩 가져오는 분업입니다.

2.png


빨라진 것만 확인하고 끝내지 않았습니다

운영 DB 실측으로 1.6초가 0.4초가 됐습니다. 여기서 멈추지 않고 결과 동일성을 대조했습니다. 결과 행수 452로 동일, 브랜드 판별·필터·품명 검색 결과도 완전히 일치했습니다.

표시명은 10건이 달라졌는데, 기준이 '알파벳순 MAX'에서 '최신 stock_date의 이름'으로 바뀐 결과였습니다. 같은 상품의 표기 차이라 오히려 더 정확해진 쪽이었지만, 달라진 건 달라진 것이라 이유를 설명할 수 있는 상태로 만들어 두고 배포했습니다. 성능 개선에서 EXPLAIN 실측과 결과 동일성 검증은 한 세트라고 생각합니다.

병목을 찾는 방식도 결국 같은 이야기입니다. 이번 건은 EXPLAIN이 바로 답을 줬지만, 어디가 느린지조차 모를 때는 구간별로 시간을 재는 것부터 시작합니다. 추측으로 고치면 엉뚱한 곳을 고치게 됩니다.

3.png


이 방식의 한계

loose index scan은 적절한 복합 인덱스가 있을 때만 성립합니다. 인덱스 설계가 선행 조건입니다.

표시값을 PK 단건 조회로 떼면 쿼리가 하나 늘어납니다. 목록이 수백 건 수준일 때 유효하고, 커지면 N+1을 조심할 필요가 있습니다.

MAX(name)에서 최신 기준으로 바꾸면 표시값이 미세하게 달라집니다. 더 정확해지더라도 변화는 변화라, 사용자에게 설명이 필요했습니다.

비슷한 목록 조회를 점검할 때 보는 항목을 정리하면 이렇습니다.

결과 행수와 스캔 행수의 간극이 얼마나 되는가 — 452개 결과에 98만 행 스캔이면 그 간극이 곧 비용이다

집계(MAX/MIN) 대상 컬럼이 인덱스 안에 있는가, 인덱스 밖 컬럼을 집계하고 있지 않은가

목록 뽑기와 표시값 채우기를 무거운 쿼리 하나에 욱여넣고 있지 않은가

개선 후 EXPLAIN 실측과 결과 동일성 검증을 한 세트로 했는가

표시값이 달라지는 개선이라면 사용자에게 설명할 준비가 됐는가

이번 개선에서 인덱스는 하나도 추가하지 않았습니다. 쿼리가 인덱스를 못 쓰는 이유를 찾고, 인덱스가 이미 커버하는 모양으로 일을 나눈 것이 전부였습니다. 목록이 느릴 때 인덱스를 추가하기 전에, 지금 있는 인덱스를 쿼리가 왜 못 쓰고 있는지부터 봅니다.

#쿼리성능#인덱스#풀스캔#EXPLAIN#looseindexscan#조회최적화