
반복문 속 쿼리가 서버를 죽인다
PHP N+1 문제와 SQL 실행 최소화 실전 가이드
개발 서버에선 빠른데 운영 서버에선 왜 느릴까요? 높은 확률로 반복문 속 DB 쿼리가 원인입니다. 20년 차 개발자가 직접 겪고 다듬은 N+1 문제 해결법을 단계별로 정리했습니다.
개발 서버는 괜찮은데 운영이 느린 이유
웹 서비스를 운영하다 보면 이런 미스터리를 마주칩니다. "로직상 느릴 이유가 없는데 왜 특정 화면만 버벅거릴까?" 단건 테스트를 해보면 역시나 빠릅니다. 하지만 실사용 유저들은 수백~수천 건의 전체 데이터를 다루기 때문에 체감 속도가 완전히 다릅니다.
💡 핵심 원인: 개발 환경에서는 데이터가 1~10건이라 반복문이 몇 번 돌아도 눈에 띄지 않습니다. 하지만 운영 환경에서 데이터가 1,000건이 되면, 루프 안의 쿼리는 그대로 1,000번 실행됩니다.
결국 어딘가에 병목 현상(Bottleneck)이 있다는 뜻이고, 높은 확률로 반복문(Loop) 안에서 반복 실행되는 DB 조회 쿼리가 그 범인입니다. 이 현상을 흔히 N+1 문제라고 부릅니다.
루프 안의 숨은 폭탄 — N+1 문제란?
N+1 문제는 메인 쿼리 1번 실행 후, 그 결과 N개 각각에 대해 쿼리를 1번씩 더 실행하는 구조에서 발생합니다. 결과적으로 총 N+1번의 쿼리가 실행됩니다.
🚨 최악의 시나리오 계산:
메인 쿼리 결과 = 100건
루프 내부 쿼리 수 = 10개
총 DB 통신 횟수 = 1 + (100 × 10) = 1,001번
→ DB 서버와 네트워크 I/O가 1,001번 발생. 느릴 수밖에 없습니다.
단순히 쿼리가 느린 게 아니라, 쿼리 호출 횟수 자체가 기하급수적으로 늘어나는 구조적 문제입니다. 데이터가 적을 때는 안 보이다가, 운영 데이터가 쌓이면 갑자기 터지는 시한폭탄과 같습니다.
단계별 최적화 — 쿼리 실행 횟수를 줄여라
학생 10명의 수학·음악·역사 평균 점수를 구하는 시나리오로 3단계 최적화를 직접 비교해보겠습니다.
❌ WORST 루프 안에서 개별 조회 — 총 31번 실행
과목별로 따로 쿼리를 날리는 최악의 패턴입니다. 학생 10명 × 3과목 = 30번, 메인 쿼리 1번 포함 총 31번 실행됩니다.
-- 메인 쿼리 (1번 실행)
SELECT s_no FROM student;
-- 루프 내부: 학생 1명당 3번 실행 (10명 × 3 = 30번)
SELECT AVG(math) FROM test WHERE s_no = 1;
SELECT AVG(music) FROM test WHERE s_no = 1;
SELECT AVG(history) FROM test WHERE s_no = 1;
-- ... 학생 수만큼 계속 반복
⚠️ BETTER 루프 안에서 쿼리 병합 — 총 11번 실행
3개였던 과목 쿼리를 1개로 합쳤습니다. 이전보다는 낫지만, 여전히 루프 안에서 쿼리가 실행되므로 근본 해결은 아닙니다.
-- 루프 내부: 학생 1명당 1번 실행 (10명 × 1 = 10번)
SELECT AVG(math), AVG(music), AVG(history)
FROM test
WHERE s_no = 1;
✅ BEST-A JOIN + GROUP BY — 단 1번 실행
DB 엔진의 성능을 최대한 활용해 단 한 번의 쿼리로 원하는 결과를 모두 가져옵니다. 가장 이상적인 패턴입니다.
SELECT
s.s_no,
s.name,
AVG(t.math) AS avg_math,
AVG(t.music) AS avg_music,
AVG(t.history) AS avg_history
FROM student s
LEFT JOIN test t ON s.s_no = t.s_no
GROUP BY s.s_no, s.name;
✅ BEST-B IN절 + 배열 매핑 — 단 2번 실행
JOIN이 복잡해지거나 DB 부하를 분산해야 할 때 주로 사용하는 패턴입니다. 애플리케이션 레이어에서 배열로 데이터를 조립합니다. 현장에서 가장 자주 쓰는 실용적인 방법입니다.
<?php
// ① 학생 목록 조회 (쿼리 1번)
$students = DB::query("SELECT s_no, name FROM student");
// ② 학생 ID 배열 추출
$student_ids = array_column($students, 's_no');
// → [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// ③ IN절로 성적을 한 번에 조회 (쿼리 2번 — 끝!)
$ids_placeholder = implode(',', $student_ids); // "1,2,3,...,10"
$test_scores = DB::query("
SELECT s_no,
AVG(math) AS avg_math,
AVG(music) AS avg_music,
AVG(history) AS avg_history
FROM test
WHERE s_no IN ($ids_placeholder)
GROUP BY s_no
");
// ④ 성적 결과를 s_no를 Key로 하는 연관 배열로 재구성
$score_map = [];
foreach ($test_scores as $score) {
$score_map[$score['s_no']] = $score;
}
// ⑤ 최종 루프: DB 조회 없이 메모리(배열)에서만 매핑
foreach ($students as &$student) {
$student['scores'] = $score_map[$student['s_no']] ?? null;
}
// 데이터가 100건이든 10,000건이든 쿼리는 단 2번!
✅ BEST-B 포인트: array_column()으로 ID 배열을 만들고, IN ()으로 한 번에 조회한 뒤, 연관 배열($score_map)로 재구성해 루프에서 메모리 접근만 합니다. 데이터 건수가 아무리 늘어나도 쿼리는 항상 2번입니다.
로직 튜닝만으론 부족하다 — 쿼리 자체도 최적화하라
위 과정은 애플리케이션 프로세스 로직을 튜닝한 것입니다. 하지만 아무리 호출 횟수를 줄여도, 그 한두 번의 쿼리 자체가 느리다면 소용이 없습니다. 반드시 쿼리 자체의 최적화도 병행해야 합니다.
🗂 인덱스(Index) 활용
WHERE 절이나 JOIN에 사용되는 컬럼에 인덱스가 걸려 있는지 확인하세요. 인덱스 하나로 조회 속도가 수백 배 차이 날 수 있습니다.
🔍 실행 계획(EXPLAIN) 분석
EXPLAIN SELECT ...으로 풀 테이블 스캔(Full Scan)이 발생하는지 반드시 확인하세요. Full Scan은 데이터가 늘수록 치명적입니다.
-- 실행 계획 확인 예시 (MySQL / MariaDB)
EXPLAIN
SELECT s.s_no, AVG(t.math) AS avg_math
FROM student s
LEFT JOIN test t ON s.s_no = t.s_no
GROUP BY s.s_no;
-- type 컬럼이 'ALL' 이면 풀 테이블 스캔 → 인덱스 추가 필요
-- type 컬럼이 'ref', 'range', 'index' 이면 인덱스 활용 중
20년 차 개발자의 현장 결론
최초 설계 단계에서 DB 설계가 잘 이루어졌다면 이런 문제가 없을 수도 있습니다. 하지만 현실은 다릅니다. 시간이 흐를수록 초기 설계 범위를 넘어서는 요구사항이 쏟아지고, 이를 처리하기 위해 테이블을 덧대고 붙이다 보면 이런 병목 현상은 필연적으로 나타납니다.
특히 RDBMS는 구조 자체가 연관 테이블에 데이터를 나눠 저장하는 방식이기 때문에, 이런 현상은 피하기 어렵습니다. 중요한 건 이를 인지하고 설계 단계부터 습관을 만드는 것입니다.
🎯 현장에서 검증된 단 하나의 원칙
"쿼리는 최대한 루프 밖에서 실행하라"
이 원칙 하나만 지켜도, 프로그램이 느리다는 민원을 들을 일이 현저히 줄어듭니다. 개발할 때마다 "지금 이 쿼리가 루프 안에 있는가?" 라는 질문을 습관적으로 던져보세요.
✅ 이 글의 핵심 요약
- N+1 문제 = 메인 쿼리 1 + 루프 내 쿼리 N번 → 운영 데이터에서 터진다
- 루프 안 다중 쿼리 → 루프 안 단일 쿼리 → 루프 밖 단일/최소 쿼리 순으로 개선
JOIN + GROUP BY또는IN절 + 배열 매핑패턴을 활용하라- 쿼리 호출 수를 줄인 뒤, 인덱스와 EXPLAIN으로 쿼리 자체도 점검하라
💬 여러분은 N+1 문제를 어떻게 해결하셨나요?
현장에서 겪은 쿼리 병목 경험이나, 더 나은 최적화 방법이 있다면 댓글로 공유해주세요.
초급 개발자분들의 질문도 언제나 환영합니다. 😊
'Code > PHP' 카테고리의 다른 글
| EUC-KR 환경에서 JSON이 통째로 사라진 이유 — 코드 한 줄의 인코딩 함정 (0) | 2026.05.12 |
|---|---|
| PHP ftp_put 고정장 전문 오프셋 깨짐 해결 — 범인은 FTP_ASCII 모드였다 (0) | 2026.04.22 |
| [PHP] 화면엔 멀쩡한데 전송하면 깨진다? EUC-KR 고정 길이 전문 숨은 에러 완벽 해결 (0) | 2026.04.19 |
| PHP Imagick 멀티 TIFF 자동 변환이 서버를 다운시킨 이유와 해결법 (1) | 2026.04.15 |
| PHP 개발자의 에디터 변천사: 드림위버부터 AI CLI 환경까지 (0) | 2026.03.25 |