데이터베이스 성능 작업은 보통 누군가 더 큰 인스턴스를 제안하는 것으로 시작해서, 페이지를 열 때마다 쿼리 하나가 400만 행을 순차 스캔하고 있었다는 발견으로 끝납니다. 제약은 처음부터 하드웨어가 아니었습니다. 제약은 실행 계획이었습니다.

이 패턴은 충분히 일관되게 반복되므로 기본 가정으로 삼을 만합니다. 애플리케이션이 느리고 데이터베이스가 바쁘다면, 원인은 거의 언제나 소수의 특정 쿼리이지 전반적인 용량 부족이 아닙니다. 그리고 서버를 키우는 일은 테이블이 다시 커지는 데 걸리는 시간만큼만 문제를 가려 줍니다.

무엇이든 바꾸기 전에 먼저 측정하십시오. 짐작으로 고른 쿼리를 최적화하는 것은, 쓰기를 느리게 만들면서 읽기는 조금도 빠르게 하지 않는 인덱스를 일주일 내내 추가하게 되는 지름길입니다. 어떤 데이터베이스든 어떤 문장이 총 시간을 가장 많이 소모하는지 알려 줄 수 있습니다. 거기서 시작해 가장 위에 있는 것을 고치고 다시 측정하십시오. 이런 반복을 두세 번 하면 대개 장애는 끝납니다.


데이터베이스 성능은 문제의 쿼리를 찾는 데서 시작한다

최악의 한 번보다 총 시간이 더 중요합니다. 2초가 걸리지만 하루에 두 번 실행되는 쿼리는 아무 의미가 없습니다. 40밀리초가 걸리지만 분당 8,000번 실행되는 쿼리가 진짜 문제이며, 임계값이 1초인 느린 쿼리 로그에는 결코 나타나지 않습니다.

Postgres에서는 pg_stat_statements 확장이 바로 이 값을 집계합니다. 정규화된 문장별로 호출 횟수, 총 시간, 평균 시간을 보여 줍니다. 총 시간 기준으로 정렬하면 범인은 보통 상위 세 줄 안에 있습니다. MySQL도 퍼포먼스 스키마를 통해 비슷한 집계를 제공합니다.

쿼리 자체에 잘못이 있다고 결론짓기 전에 확인할 것이 두 가지 있습니다. 매번 느린지, 아니면 특정 시간대에만 느린지입니다. 후자라면 실행 계획보다는 자원 경합을 가리킵니다. 그리고 단독으로 실행해도 느린지, 아니면 동시 접속이 많을 때만 느린지입니다. 후자라면 잠금이나 커넥션 한도를 가리킵니다.

짐작하지 말고 실행 계획을 읽어라

문장을 찾았다면 데이터베이스에게 그 문장을 어떻게 실행할 작정인지 물어보십시오. Postgres는 이를 EXPLAIN으로 보여 주며 , 중요한 변형은 EXPLAIN ANALYZE입니다. 이 명령은 쿼리를 실제로 실행해서 추정치가 아니라 실제 소요 시간을 보고합니다.

그 출력에서 신호의 대부분을 담고 있는 것은 세 가지입니다.

큰 테이블에 대한 순차 스캔. 데이터베이스가 모든 행을 읽고 있다는 뜻입니다. 작은 테이블에서는 이것이 옳고 빠릅니다. 큰 테이블에서는 여러분이 작성한 조건에 쓸 만한 인덱스가 없거나, 플래너가 그 인덱스를 쓸 가치가 없다고 판단했다는 뜻입니다.

추정 행 수와 실제 행 수의 큰 차이. 플래너는 통계를 근거로 전략을 고릅니다. 그래서 추정이 자릿수 단위로 어긋나면, 여러분의 쿼리와는 아무 상관 없는 이유로 나쁜 선택을 하게 됩니다. 오래된 통계는 흔하면서도 쉽게 고칠 수 있는 원인입니다.

한 노드에 몰린 시간. 실행 계획은 트리이고, 고쳐야 할 자리는 시간을 소모한 바로 그 노드입니다. 다른 곳을 아무리 최적화해도 달라지는 것은 없습니다.

순차 스캔을 보는 순간 인덱스를 추가하고 싶어지는 본능은 대개 옳지만, 그래도 30초쯤 참을 가치가 있습니다. 인덱스가 없다는 사실보다 인덱스가 사용되지 않는 이유가 더 중요할 때가 있기 때문입니다.

인덱스가 도움이 되지 않는 이유

존재하는 인덱스가 곧 사용되는 인덱스는 아닙니다.

조건이 sargable하지 않습니다. 컬럼을 함수로 감싸거나 컬럼에 산술 연산을 적용하면, 그 컬럼의 인덱스는 대체로 사용되지 못합니다. 인덱스는 변환된 값이 아니라 컬럼의 원래 값을 저장하기 때문입니다. 컬럼을 그대로 두도록 조건을 다시 쓰면 대개 인덱스 사용이 돌아옵니다.

복합 인덱스의 컬럼 순서가 잘못되었습니다. 복합 인덱스는 앞쪽 컬럼을 사용하는 쿼리를 지원합니다. 한 컬럼 다음에 다른 컬럼을 둔 인덱스는 두 번째 컬럼만으로 걸러 내는 쿼리에는 도움이 되지 않으며, 여기에 걸려 넘어지는 일이 끊이지 않습니다.

플래너가 전체 스캔이 더 싸다고 판단합니다. 쿼리가 테이블의 상당 부분을 반환한다면, 순차적으로 읽는 편이 인덱스를 건너뛰며 접근하는 것보다 실제로 빠릅니다. 이것은 올바른 동작이며, 해법은 더 적게 반환하는 것입니다.

통계가 오래되었습니다. 대량 적재나 대규모 삭제 이후에는 플래너가 가진 데이터의 그림이 통계를 갱신하기 전까지 크게 어긋나 있을 수 있습니다.

그리고 모든 인덱스에는 비용이 따릅니다. 쓰기 작업이 인덱스를 유지해야 하고, 그렇지 않았다면 데이터를 캐시했을 메모리를 인덱스가 차지합니다. 인덱스가 열다섯 개나 달린 테이블에는 대개 아무도 필요로 하지 않는 것이 여럿 있고, 그 하나하나가 모든 삽입을 느리게 만듭니다.

N+1 문제는 여전히 가장 큰 단일 원인이다

애플리케이션이 느려지는 원인은 어떤 실행 계획 문제보다도 여기서 더 많이 나옵니다. 게다가 개별 쿼리는 하나하나 빠르기 때문에 느린 쿼리로는 절대 드러나지 않습니다.

모양은 익숙합니다. 100개의 레코드 목록을 가져온 다음, 그 목록을 돌면서 각각의 연관 데이터를 다시 가져옵니다. 결과는 한두 번이면 충분했을 자리에 101번의 왕복입니다. 각 쿼리는 3밀리초 만에 돌아오지만 페이지는 여전히 0.5초가 걸립니다. 비용이 작업량이 아니라 왕복 자체에 있기 때문입니다.

객체 관계 매퍼는 이 실수를 실수인 줄 모르고 쓰게 만듭니다. 연관 데이터를 읽는 코드가 데이터베이스 호출이 아니라 속성 접근처럼 보이기 때문입니다. 해법은 연관 데이터를 부모 집합과 함께 한 번의 쿼리로 불러오는 것이며, 성숙한 ORM이라면 모두 지원하지만 대부분은 기본값으로 그렇게 하지 않습니다.

찾아내는 방법은 단순합니다. 요청당 쿼리 수를 세면 됩니다. 한 페이지가 화면에 보여 주는 항목 수에 비례하는 쿼리를 날리고 있다면 범인을 찾은 것입니다. 이것은 애플리케이션이 엣지에서 느릴 때 가장 가치가 큰 점검이기도 합니다. 저희 Cloudflare Hyperdrive 가이드에서 다루듯이, 거리가 멀어질수록 왕복 비용이 훨씬 커지기 때문입니다.

커넥션과 경합

느린 것처럼 보이지만 사실은 느린 것이 아닌 문제가 둘 있습니다.

커넥션 고갈. 모든 데이터베이스에는 동시 커넥션 상한이 있고, 커넥션 하나하나가 메모리를 소모합니다. 애플리케이션이 풀이 허용하는 것보다 많이 열면, 요청들이 커넥션을 기다리며 줄을 서고 데이터베이스는 놀고 있는데도 애플리케이션은 느려 보입니다. 증상은 데이터베이스 CPU는 낮은데 애플리케이션 지연 시간은 높은 것이고, 해법은 더 큰 장비가 아니라 커넥션 풀링입니다.

잠금 경합. 잠금을 쥔 채 오래 이어지는 트랜잭션은 뒤에 늘어선 모든 작업을 막습니다. 흔한 원인은 데이터베이스가 필요 없는 작업, 예를 들어 다른 서비스로 보내는 HTTP 호출까지 트랜잭션을 열어 둔 채로 감싸는 것입니다. 트랜잭션은 짧게 유지하고 데이터베이스 작업 자체에만 한정하십시오.

두 경우 모두 일찍 배제해 두는 편이 좋습니다. 둘 다 쿼리 문제로 잘못 읽히기 쉽고, 둘 다 인덱스로는 해결되지 않기 때문입니다.

무엇을 어떤 순서로 할 것인가

총 시간을 가장 많이 소모하는 문장을 찾으십시오. 그중 가장 나쁜 것에 EXPLAIN ANALYZE를 실행해 시간이 실제로 어디로 가는지 읽으십시오. 무엇이든 최적화하기 전에 요청당 쿼리 수를 확인해 N+1을 먼저 배제하십시오. 인덱스를 추가하기 전에 통계를 갱신하십시오. 그것만으로 해결되는 경우가 있기 때문입니다. 그다음에 조건을 만족하는 가장 좁은 인덱스를 추가하고 다시 측정하십시오.

Mecanik은 소프트웨어 개발 업무의 일부로 이 작업을 수행하며, 결과는 거의 언제나 같습니다. 두세 개의 쿼리가 원인이었고, 수정은 작았으며, 아무도 사지 않았던 더 큰 인스턴스는 끝내 필요하지 않았습니다.


관련 게시물: API 버저닝: 언제 깨뜨리고 어떻게 깨뜨리지 않을 것인가 , 2026년 웹 앱 개발 방법 - 영국 개발자 가이드 , 비밀번호 저장: 2026년에 무엇을 쓸 것인가 , 영국 맞춤형 소프트웨어 개발 - 구매자를 위한 완전한 가이드 .


자주 묻는 질문

어떤 쿼리가 애플리케이션을 느리게 하는지 어떻게 찾나요? 최악의 한 번이 아니라 총 시간 기준으로 정렬하십시오. 40밀리초짜리 쿼리가 분당 8,000번 실행되면, 2초짜리 쿼리가 하루에 두 번 실행되는 것보다 훨씬 큰 비용이 들며, 임계값이 1초인 느린 쿼리 로그에는 결코 나타나지 않습니다. Postgres에서는 pg_stat_statements가 문장별 호출 횟수와 총 시간을 집계하고, MySQL은 퍼포먼스 스키마를 통해 같은 것을 제공합니다.

EXPLAIN ANALYZE 출력에서 무엇을 봐야 하나요? 신호의 대부분은 세 가지에 담깁니다. 큰 테이블에 대한 순차 스캔은 쓸 만한 인덱스가 없다는 뜻입니다. 추정 행 수와 실제 행 수의 큰 차이는 플래너가 나쁜 통계로 일하고 있다는 뜻입니다. 그리고 실행 계획 트리의 한 노드에 몰린 시간은 바로 그곳이 고쳐야 할 자리라는 뜻입니다. 다른 노드를 최적화해도 달라지는 것은 없습니다.

제 인덱스가 왜 사용되지 않나요? 보통 네 가지 이유 중 하나입니다. 조건이 컬럼을 함수나 산술 연산으로 감싸서 인덱스가 더 이상 맞지 않는 경우입니다. 복합 인덱스의 컬럼 순서가 그 쿼리를 지원하지 않는 경우입니다. 쿼리가 테이블의 상당 부분을 반환해서 전체 스캔이 실제로 더 싼 경우입니다. 아니면 대량 적재나 삭제 이후 통계가 오래된 경우입니다.

N+1 쿼리 문제란 무엇인가요? 레코드 목록을 가져온 다음 각 레코드의 연관 데이터를 위해 별도의 쿼리를 날려서, 한두 번이면 충분했을 자리에 101번의 왕복을 만드는 것입니다. 각 쿼리가 빠르기 때문에 느린 쿼리 로그에는 나타나지 않으며, 비용은 왕복에 있습니다. 요청당 쿼리 수를 세어 화면에 보이는 항목 수에 비례하는지 확인하면 찾을 수 있습니다.

데이터베이스 서버를 키우면 느린 쿼리가 해결되나요? 드물게, 그리고 일시적으로만 그렇습니다. 애플리케이션이 느리고 데이터베이스가 바쁘다면 원인은 거의 언제나 용량 부족이 아니라 소수의 특정 쿼리이므로, 서버를 키워도 테이블이 다시 커질 때까지 문제를 가릴 뿐입니다. 데이터베이스 CPU는 낮은데 애플리케이션 지연 시간이 높다면 보통은 커넥션 풀 고갈을 뜻하며, 이는 하드웨어를 늘려도 해결되지 않습니다.