느린 쿼리의 두 얼굴 — 인덱스를 살릴 때와, 인덱스가 독이 될 때

같은 느림, 다른 처방

조회가 느릴 때 인덱스는 중요한 검토 대상이다. 다만 인덱스를 추가해야 하는지, 이미 있는 인덱스를 제대로 활용하지 못하는지부터 구분해야 한다. 나는 두 번의 튜닝에서 서로 다른 선택을 했다. 한쪽은 조건식을 바꿨고, 다른 쪽은 추가한 인덱스를 되돌린 뒤 쿼리 구조를 바꿨다.

두 번째 사례는 쿼리를 분해한 시점에 해결이 끝난 것으로 판단했지만, 이후 다른 입력값에서 지연이 남아 있음을 확인했다. 이 글에는 그 한계를 반영했다. 추가 진단과 후속 조치는 정정편 「계획은 읽었다, 한 장씩」에 따로 정리했다.

첫 번째 사례: 기존 인덱스를 활용하도록 조건식을 바꾸다

한 시스템에서 날짜로 거르는 조회와 로그인 시 계정을 확인하는 조회가 각각 수 초씩 걸렸다. 인덱스가 있는 컬럼을 조건으로 사용했지만, 당시 확인한 실행계획은 테이블 전체 스캔(Full Table Scan)이었다.

날짜 조건에서는 문자열 컬럼의 앞부분을 잘라 비교했고, 로그인 조회에서도 계정 식별자의 일부를 가공해 비교했다. 원본 컬럼에 대한 일반적인 인덱스가 있어도 함수의 결과를 비교하는 조건은 검색 범위를 좁히기 어려울 수 있다. 인덱스로 검색 범위를 좁힐 수 있는 조건을 sargable이라고 부른다.

아래는 날짜 조건을 범위 검색으로 바꾸는 방식을 보여주는 재구성 예제다. reg_dt에는 유효한 날짜만 8자리 YYYYMMDD 문자열로 저장되고, 문자열 정렬 순서가 날짜 순서와 일치한다고 가정한다. 실제 업무 쿼리를 그대로 옮긴 것은 아니다.

-- 변경 전: 컬럼의 앞 6자리를 계산해 비교
WHERE SUBSTRING(reg_dt, 1, 6) = '202501'

-- 변경 후: 원본 컬럼 값의 범위로 비교
WHERE reg_dt >= '20250101'
  AND reg_dt <  '20250201'

이 전제에서는 두 조건이 같은 달을 선택한다. 하지만 20250100 같은 잘못된 값이 섞여 있으면 결과가 달라진다. 실제 컬럼이 날짜형이라면 문자열 예제를 그대로 쓰기보다 자료형에 맞는 경계값으로 비교해야 한다. 로그인 조건도 날짜 범위와 똑같이 바꿀 수 있는 것은 아니므로, 가공 전후의 비교 의미가 같은지 별도로 확인해야 한다.

당시에는 조건식을 고친 뒤 인덱스 범위 스캔(Index Range Scan)으로 바뀌었고, 수 초 걸리던 조회가 1초 이내로 줄었다. 새 인덱스를 추가하지 않고 기존 인덱스를 활용한 사례다. 이 결과는 당시 확인한 경험이며, 범위 조건으로 바꾸면 언제나 같은 계획이 선택된다는 뜻은 아니다.

함수를 썼다고 반드시 전체 스캔이 되는 것도 아니다. 데이터베이스가 조건식을 변환하거나 표현식에 맞는 인덱스를 활용할 수 있다. 예를 들어 SQL Server는 요건을 충족하는 계산 열에 인덱스를 만들 수 있다. 따라서 함수 유무만으로 결론 내리기보다 실제 접근 경로를 확인해야 한다. 계산 열의 인덱스

두 번째 사례: 인덱스 추가 뒤 다른 조회가 느려지다

다른 시스템에서는 승인 흐름이 얽힌 목록 조회가 30초 안팎으로 느렸다. 같은 승인 테이블을 단계별로 여러 번 조인하고, 늘어난 결과를 SELECT DISTINCT로 중복 제거하는 구조였다. 실행계획에는 정렬 작업과 암묵적 형변환 경고가 있었고, 메모리에서 처리하지 못한 중간 데이터를 tempdb에 기록하는 spill도 확인했다.

DISTINCT 자체가 잘못된 것은 아니다. 필요한 결과가 중복을 허용하지 않는다면 정당한 연산이다. 이 사례에서는 그 앞의 조인이 필요한 것보다 많은 행을 만드는지 살펴볼 필요가 있었다. 정렬이나 해시 연산의 spill은 처리량과 메모리 할당을 함께 확인해야 하며, 경고 하나만으로 원인을 특정할 수는 없다. 메모리 할당과 spill 진단

나는 먼저 인덱스를 추가했다. 대상 조회는 빨라졌지만, 같은 테이블을 쓰는 다른 조회 프로시저들이 느려져 애플리케이션 타임아웃을 넘겼다. 해당 화면에서 조회가 실패했고 인덱스를 되돌렸다. 운영과 별개인 개발·테스트 DB에서 검증했어도, 그때 실행해 본 것은 대상 조회 하나였다.

인덱스는 특정 쿼리에만 적용되는 설정이 아니다. 다른 쿼리에도 접근 경로를 제공하고 데이터 변경 시 유지 비용이 든다. 다만 추가했다고 모든 쿼리의 계획이 반드시 바뀌는 것은 아니다. 원문에서 제시했던 Key Lookup 증가나 조인 순서 변경은 가능한 원인이지만, 당시 다른 조회 각각의 지연 원인으로 확정할 근거는 부족하다. 확인된 것은 추가 이후의 조회 장애와 롤백 경험이다. 인덱스 설계와 워크로드 고려사항

쿼리 분해: 후보 키를 먼저 확정한다

이후에는 공유 테이블의 인덱스를 더하는 대신 프로시저 안의 쿼리를 분해했다. 필요한 후보를 임시 테이블에 먼저 담고, 그 집합을 다음 조회의 입력으로 사용했다. 복잡한 조인을 한 문장에 모두 맡기는 구조에서 중간 결과를 명시하는 구조로 바꾼 것이다.

다음은 그중 후보를 추리는 부분만 재구성한 예제다. 요청 키는 유일하며, 날짜는 앞 예제와 같은 문자열 형식이라고 가정한다. @from은 포함하는 시작일, @to는 포함하지 않는 종료일이다. 승인 레코드의 내용이 아니라 해당 단계의 존재 여부만 필요하므로 EXISTS를 사용했다.

-- request.req_id는 유일한 int 키라고 가정한다.
CREATE TABLE #cand (
    req_id int NOT NULL PRIMARY KEY
);

INSERT INTO #cand (req_id)
SELECT r.req_id
FROM request AS r
WHERE r.req_dt >= @from
  AND r.req_dt < @to
  AND r.status = 'OPEN'
  AND EXISTS (
      SELECT 1
      FROM approval AS a
      WHERE a.req_id = r.req_id
        AND a.step = '100'
  );

-- 후보 요청을 한 번씩 조회하는 최소 예제
SELECT r.req_id, r.category_id
FROM #cand AS c
JOIN request AS r ON r.req_id = c.req_id;

DROP TABLE #cand;

원문의 예제처럼 후보를 JOIN으로 적재하면 같은 단계의 승인 레코드가 여러 개일 때 요청 키도 중복될 수 있다. EXISTS는 승인 레코드가 하나 이상 있는지만 확인하므로 그 중복을 만들지 않는다. 임시 테이블의 기본 키는 후보 키의 유일성을 제약으로도 표현한다. 이후 상세 데이터를 붙일 때도 일대다 관계를 그대로 조인하면 행은 다시 늘어날 수 있으므로, 필요한 결과의 단위에 맞춰 집계나 존재 여부 검사를 선택해야 한다.

SQL Server는 임시 테이블의 통계를 활용해 다음 문장의 행 수를 추정할 수 있다. 그렇다고 모든 연산의 추정 행 수가 실제 행 수와 같아지는 것은 아니다. 데이터 분포, 조건 간 관계, 컴파일 시점과 계획 재사용에 따라 추정이 달라진다. 임시 테이블 적재에도 비용이 들고 tempdb와 CPU 같은 자원은 다른 조회와 공유한다. 수정 범위를 프로시저 안으로 줄인 것과 성능 영향을 완전히 격리한 것은 구분해야 한다. 임시 테이블의 통계, 행 수 추정

결과: 빨라진 실행과 남아 있던 지연

관리 도구에서 원본과 분해한 쿼리에 같은 파라미터 값을 넣어 실행 시간과 실행계획을 비교했다. 수백 밀리초 수준으로 줄어든 실행이 있었고 화면에서도 개선을 확인했다. 다만 30초 안팎에서 수백 밀리초로 줄었다는 경험을 모든 입력과 애플리케이션 요청의 측정 결과로 확대할 수는 없다.

실행계획의 추정 비용도 따로 봐야 한다. 원문의 ‘약 35에서 9로 감소’는 분해 전 조회 문장과 분해 후 일부 적재 문장의 비용을 비교한 수치였다. 이를 프로시저 전체 비용의 전후 비교로 쓰는 것은 적절하지 않다. 추정 비용은 실행 시간이 아니며, 분해 후에는 후보 적재부터 최종 조회까지 함께 측정해야 한다.

남아 있는 계획에는 분해 후 spill이 없는 실행도 있지만, 다른 입력에서 spill과 지연이 나타난 실행도 있다. 실제로 사용자 재신고를 받고 캐시된 계획의 실행 통계를 확인한 뒤 추가 조치를 했다. 따라서 이 글의 결과는 쿼리 분해가 유효했던 실행과 그 검증 범위까지다. 입력값에 따른 지연과 계획 재사용 문제를 어떻게 확인했는지는 정정편에서 이어 다룬다.

느린 쿼리 앞에서 확인할 것

두 사례에서 실행계획은 변경할 지점을 찾는 근거였다. 이제는 Scan과 Seek라는 이름만 보지 않고 읽은 행 수, 추정과 실제 행 수의 차이, 논리적 읽기와 실행 시간도 함께 확인하려 한다. 추정이 어긋난 이유도 형변환 하나로 단정하지 않고 통계와 데이터 분포, 입력값까지 살펴야 한다.


후속 진단과 조치: 계획은 읽었다, 한 장씩 — 튜닝을 두 번 잘못 판정한 기록. 해당 글에 인용된 선행글 문장은 이번 검수 전 발행본의 표현이다.

← 전체 글 목록