LIMIT 이 붙으면 계획이 통째로 바뀐다
LIMIT 10 을 붙였더니 오히려 느려졌어요. LIMIT 10 은 0.1초인데 LIMIT 100 은 12초가 걸리기도 하고요. LIMIT 값 하나에 옵티마이저가 계획을 통째로 바꾸는 이유와 대처법을 정리했어요.
목록 조회가 느려서 LIMIT 10 을 붙였어요. 가져오는 행이 줄었으니 당연히 빨라질 줄 알았는데 오히려 느려졌어요.
반대 경우도 겪어요. LIMIT 을 붙였을 때는 빠른데, 같은 조건으로 전체를 세는 쿼리는 멀쩡해요. 두 쿼리가 완전히 다른 계획으로 돌고 있는 거예요.
가장 헷갈리는 건 LIMIT 값에 따라 성능이 뒤집히는 경우예요. LIMIT 10 은 0.1초, LIMIT 100 은 12초. 열 배를 가져오는데 백 배가 넘게 걸려요.
데이터가 늘어난 것도 아니고 인덱스가 바뀐 것도 아닌데요. 옵티마이저가 LIMIT 값을 보고 아예 다른 계획을 고른 겁니다.
왜 그런가
옵티마이저는 LIMIT 을 알고 계획을 세워요. "앞의 몇 개만 필요하다"는 정보가 계산 자체를 바꿔요.
LIMIT 이 없으면 전체를 처리하는 총비용이 기준이에요. 그래서 대량 처리에 유리한 계획, 예를 들어 해시 조인이나 정렬 후 병합을 고르기 쉬워요. 준비 비용이 있지만 전체로 보면 싼 방식이죠.
LIMIT 이 있으면 첫 몇 행을 얼마나 빨리 낼 수 있느냐가 기준이 돼요. 그래서 준비 없이 바로 흘려보낼 수 있는 계획, 예를 들어 인덱스를 순서대로 읽으며 조건에 맞는 것을 만나면 바로 내보내는 방식을 선호해요.
여기서 도박이 시작돼요. 옵티마이저는 "조건에 맞는 행이 고르게 퍼져 있다"고 가정해요. 그 가정이 맞으면 인덱스를 조금만 읽고 10개를 채워요. 아주 빨라요.
그런데 조건에 맞는 행이 뒤쪽에 몰려 있으면요? 10개를 채우려고 인덱스를 끝까지 훑어요. 전체를 스캔하는 것보다 나쁠 수도 있어요. 임의 접근이 섞이니까요.
LIMIT 값에 따라 뒤집히는 이유도 여기 있어요. 10개는 앞에서 채워지는데 100개는 훨씬 멀리 가야 하는 분포일 수 있거든요.
코드로 보면
LIMIT 은 계획의 입력값이다:
· 정렬은 인덱스로, 조건은 선택도 높게
SELECT * FROM posts
WHERE status = 'draft' -- 전체의 0.1%, 뒤쪽에 몰림
ORDER BY created_at DESC LIMIT 10;
→ created_at 인덱스를 역순으로 훑으며 draft 를 찾는다
→ 10개 채우려고 수백만 행을 지나간다
· 조건과 정렬을 한 인덱스로 묶는다
CREATE INDEX idx ON posts (status, created_at DESC);
→ draft 구간으로 바로 들어가 앞에서 10개를 뗀다
· 계획이 뒤집히는 지점을 직접 확인한다
EXPLAIN (ANALYZE) ... LIMIT 10;
EXPLAIN (ANALYZE) ... LIMIT 100;
EXPLAIN (ANALYZE) ... LIMIT 1000;✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
이 문제의 핵심은 정렬과 조건이 같은 인덱스로 해결되는가예요.
LIMIT + ORDER BY 조합에서 옵티마이저가 좋아하는 계획은 "정렬 순서대로 읽으며 조건을 확인하고, 필요한 개수를 채우면 멈추는" 것이에요. 정렬이 인덱스로 이미 되어 있으니 정렬 비용이 0 이고, 조기 종료도 되니까요.
이 계획이 좋으냐 나쁘냐는 조건의 선택도와 분포에 달려 있어요. 조건에 맞는 행이 흔하면 금방 채워져서 아주 빠르고, 드물거나 뒤쪽에 몰려 있으면 재앙이 돼요.
그래서 해법이 명확해요. 조건 컬럼을 인덱스 앞에 두고 정렬 컬럼을 뒤에 두면, 조건에 맞는 구간으로 바로 들어가서 그 안에서 정렬된 순서로 앞부터 떼어 와요. 조기 종료도 되고 헛읽기도 없어요. 앞서 본 복합 인덱스 순서 규칙이 여기서 실전으로 나타납니다.
주의할 게 하나 더 있어요. OFFSET 이 붙으면 조기 종료의 이득이 사라져요. OFFSET 1000 LIMIT 10 은 1010 개를 만들어야 하니까요. 앞서 본 대로 커서 방식으로 바꾸는 게 근본 해결입니다.
그리고 계획이 뒤집히는 지점이 있다는 걸 알고 있어야 해요. LIMIT 을 바꿔 가며 EXPLAIN 을 돌려 보면 어느 값에서 계획이 바뀌는지 보여요. 운영에서 쓰는 값이 그 경계 근처면 위험해요 — 데이터가 조금만 변해도 계획이 왔다 갔다 합니다.
마지막으로 정렬 키에 동점이 많으면 LIMIT 결과가 실행마다 달라질 수 있어요. 순서가 정해지지 않은 구간에서 아무거나 열 개를 가져오니까요. 페이지네이션에서 중복과 누락이 생기는 원인이기도 해요.
처방
- 조건 컬럼을 앞, 정렬 컬럼을 뒤로 하는 복합 인덱스를 만드세요. LIMIT 쿼리의 표준 처방이에요.
- LIMIT 값을 바꿔 가며 EXPLAIN 을 돌려 계획이 뒤집히는 지점을 찾으세요. 운영 값이 경계 근처면 인덱스를 손봐야 합니다.
- 정렬 키에 유일한 컬럼을 마지막에 붙이세요. 동점이 있으면 결과가 불안정해지고 페이지네이션이 깨집니다.
- OFFSET 을 쓰고 있다면 커서 방식으로 바꾸세요. OFFSET 은 조기 종료의 이득을 없앱니다.
- 실제 파라미터로 확인하세요. 조건 값에 따라 분포가 다르면 계획도 다릅니다.
- 결과가 적을 것으로 예상되는 조건이라면, 정렬 없이 먼저 필터링한 뒤 정렬하는 형태로 다시 써 보세요. 서브쿼리로 대상을 좁히고 바깥에서 정렬하는 방식이 나을 때가 있어요.
- 목록 API 의 LIMIT 상한을 정하세요. 클라이언트가 큰 값을 넘기면 계획이 완전히 달라집니다.
지금 확인해 보세요
계획이 어디서 뒤집히는가:
· LIMIT 값을 바꿔 가며 계획을 비교한다
EXPLAIN (ANALYZE, BUFFERS) ... LIMIT 10;
EXPLAIN (ANALYZE, BUFFERS) ... LIMIT 100;
EXPLAIN (ANALYZE, BUFFERS) ... LIMIT 1000;
어느 지점에서 Index Scan → Seq Scan 으로 바뀌는지
· 조기 종료가 되고 있나 — 읽은 행 수를 본다
actual rows=10 인데 shared read 가 수십만이면
10개를 채우려고 한참 훑은 것이다
· 조건의 분포가 치우쳐 있는지
SELECT status, count(*),
min(created_at), max(created_at)
FROM posts GROUP BY status;
특정 상태가 오래된 쪽에만 남아 있으면 위험 신호핵심 한 줄LIMIT 은 결과를 자르는 게 아니라 계획을 바꾸는 입력이다.