한 줄 요약: foreach 루프 안에서 쿼리를 돌리면 안 된다. 정말로, 절대로.
재고 관리 페이지가 30초, 길게는 1~2분씩 걸린다는 제보를 받고 최적화 작업에 착수했습니다. 결과적으로 최대 약 60배의 성능 개선을 달성했고, 그 과정에서 N+1 안티패턴 5종과 치명적인 데이터 버그 1건을 발견했습니다. 이 글은 그 진단과 해결 과정을 코딩 방법론 관점에서 정리한 기록입니다.
목차
1. 개선 결과 한눈에 보기
항목 Before After 개선율 stock_list.php (page=10) 30~60초 < 1초 약 30~60배 stock_list.php (page=100) 추정 60~120초 약 1.5초 약 40~80배 ajax_stock_list.php (검색/페이징) 24~30초 < 1초 약 24~30배 데이터 정확성 — 100% 동일 검증 완료 ✅
숫자만 보면 극적이지만, 더 중요한 건 이 개선이 인프라 증설 없이 순수한 코드 리팩토링만으로 이뤄졌다는 점입니다. DB 서버도 그대로, PHP 버전도 그대로, 서버 사양도 그대로입니다.
2. 왜 이렇게 느렸는가 — N+1 안티패턴의 정체
2-1. 쿼리 1000번은 "빠른 쿼리 1000번"이 아니다
많은 개발자가 오해하는 지점이 있습니다. 쿼리 1회 실행 시간이 1ms라고 해서 1000번 실행하면 1초일 것 같지만 실제로는 10초 이상 걸립니다. 이유는 각 쿼리마다 다음 오버헤드가 누적되기 때문입니다.
구간 소요 시간(대략) PHP ↔ MySQL 네트워크 왕복 1~5ms 쿼리 파싱 / 실행 계획 수립 1~3ms 실제 데이터 조회 0.1~1ms 결과 직렬화 및 PHP 수신 1~3ms 쿼리 1회당 총합 약 3~10ms
쿼리 자체는 순식간이지만, 왕복 횟수 자체가 병목입니다. 2,796번 호출하는 로직이 있다면 실제 SQL 처리 시간이 아니라 왕복 오버헤드만으로 20~30초가 날아갑니다.
2-2. 이번 사례의 규모 감각
이번 페이지는 한 번 열 때 다음과 같은 일이 벌어지고 있었습니다.
카운트용 쿼리 1회 (UNION + 전체 fetch)
본문 조회 1회 (페이지 20건)
행당 보조 쿼리 5개 × 20행 = 100회
행당 함수 호출 8개 × 20행 = 160회 (각 내부에 쿼리 존재)
페이지 시작 시 상수 조회 8회
그리고 결정타: 전체 상품 2,796개에 대해 원가 계산 함수 호출 = 2,796회
합산하면 한 페이지 로딩에 약 3,000번 전후의 DB 왕복이 발생했던 것입니다.
3. 진단된 5가지 N+1 패턴
프로파일링 결과 다음과 같은 안티패턴이 차례로 드러났습니다.
# 위치 패턴 호출 수 단일 영향 1 rowCount() 카운트 페이징용인데 전체 결과를 fetch — UNION 2회 → 약 150ms 2 foreach 내부 보조 쿼리 5종 (reqorder, releasewait, stock, order 2종) page_limit × 5 50쿼리/페이지 약 500ms 3 foreach 내부 함수 호출 8종 (get_recent_sales×3, get_past_sales, get_sell_max, get_pd_order_cost, get_cons×2) page_limit × 8 80쿼리/페이지 약 800ms 4 get_cons 8회 (페이지 시작 시) 동일 쿼리 반복 8 약 80ms 5 stock_value 계산 — 전체 상품(2,796) × get_pd_order_cost 2,796 호출 약 24,000ms ⚠️
3-1. 패턴 1: rowCount()로 전체 fetch
// ❌ 안 좋은 예: 전체 데이터를 다 가져와서 개수만 센다
$rows = $db->query($hugeUnionQuery)->fetchAll();
$total = count($rows);
페이징을 위한 전체 건수가 필요하면 SELECT COUNT(*) 로 충분합니다. 전체 데이터를 메모리에 올렸다가 버리는 건 네트워크, 메모리, CPU 세 자원을 동시에 낭비합니다.
3-2. 패턴 2~3: foreach 루프 안의 쿼리
// ❌ 전형적인 N+1
foreach ($products as $p) {
$p['recent_sales'] = get_recent_sales($p['pd_code']); // 쿼리 1
$p['past_sales'] = get_past_sales($p['pd_code']); // 쿼리 2
$p['sell_max'] = get_sell_max($p['pd_code']); // 쿼리 3
$p['order_cost'] = get_pd_order_cost($p['pd_code']); // 쿼리 4
// ... 총 13개의 쿼리가 상품 1개당 실행
}
상품 20개면 쿼리 260번. 상품 100개면 1,300번입니다.
3-3. 패턴 5: 집계를 위해 전체 루프를 도는 재앙
가장 치명적이었던 부분입니다. 전체 재고 가치(stock_value)를 계산하기 위해 2,796개 상품 전체를 순회하며 한 건씩 원가를 조회하고 있었습니다.
// ❌ 24초가 사라지는 현장
$totalValue = 0;
foreach ($allProducts as $p) {
$totalValue += $p['stock_qty'] * get_pd_order_cost($p['pd_code']);
}
이건 SQL의 SUM() 하나로 끝나야 할 일을 PHP 메모리에서 2,796번 왕복하며 처리한 것과 같습니다.
4. 치명적 버그: 잘못된 키로 Map 조회
N+1 최적화 중에 발견한 더 무서운 버그입니다.
// ❌ 버그 코드: cs_code로 저장했는데 cs_name으로 찾고 있음
$csMap = [];
foreach ($csRows as $row) {
$csMap[$row['cs_code']] = $row['cs_value']; // 키: cs_code
}
// ...
foreach ($products as $p) {
$cons = $csMap[$p['cs_name']] ?? 0; // 조회: cs_name으로!
// → 항상 매칭 실패 → fallback 0 또는 1 반환
}
전 제품의 상수 값이 소리 없이 0 또는 1로 대체되고 있었습니다. 페이지는 정상적으로 렌더링되고 에러도 없으니 아무도 눈치채지 못했지만, 데이터는 전부 잘못된 상태였습니다.
이 버그가 시사하는 것: N+1 최적화를 위해 Map 구조로 전환할 때는 키 타입과 키 이름을 반드시 명시적으로 맞춰야 합니다. 그리고 전후 결과를 비교하는 검증 단계가 없었다면 이 버그는 발견되지 못했을 겁니다.
5. 해결 전략: Prefetch와 In-Memory Join
5-1. 기본 패턴: IN 절로 한 번에 가져오기
// ✅ 개선 후
// 1단계: 대상 목록의 키를 수집
$pdCodes = array_column($products, 'pd_code');
$placeholders = implode(',', array_fill(0, count($pdCodes), '?'));
// 2단계: 연관 데이터를 한 번에 조회
$stmt = $db->prepare("
SELECT pd_code, SUM(qty) AS recent_sales
FROM sales_log
WHERE pd_code IN ($placeholders)
AND sales_date >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY pd_code
");
$stmt->execute($pdCodes);
// 3단계: Map으로 인덱싱
$salesMap = [];
foreach ($stmt->fetchAll() as $row) {
$salesMap[$row['pd_code']] = $row['recent_sales'];
}
// 4단계: 메모리에서 조인
foreach ($products as &$p) {
$p['recent_sales'] = $salesMap[$p['pd_code']] ?? 0;
}
쿼리 20번이 1번으로 줄어듭니다.
5-2. 집계는 SQL에서 끝내기
stock_value 같은 집계는 PHP 루프로 가져올 이유가 없습니다.
-- ✅ 24초 → 50ms
SELECT SUM(s.qty * c.order_cost) AS total_stock_value
FROM stock s
JOIN pd_order_cost c ON c.pd_code = s.pd_code;
DB는 집계에 최적화된 엔진입니다. 네트워크를 거치지 않고 인덱스와 병렬 처리를 활용해 한 방에 끝냅니다.
5-3. JOIN vs IN 절 — 언제 무엇을 쓸 것인가
JOIN이 유리한 경우
1:1 또는 N:1 관계 (예: 상품 → 카테고리)
연관 테이블 컬럼 수가 적음
두 테이블 모두 인덱스가 잘 걸려 있음
IN 절 2단계 조회가 유리한 경우
1:N 관계 (예: 상품 → 주문이력 여러 건). JOIN하면 상품 행이 주문 수만큼 중복(Cartesian 폭발)되어 네트워크 전송량이 폭증
연관 데이터 컬럼이 많거나 BLOB/TEXT를 포함
같은 연관 데이터를 여러 곳에서 재사용해야 할 때
이번 사례에서는 대부분 IN 절 + Map 인덱싱 방식을 채택했고, 집계성 계산만 순수 SQL로 돌렸습니다.
6. 데이터 정확성은 어떻게 검증했는가
성능 최적화의 가장 큰 위험은 결과가 살짝 달라지는 것입니다. 빠르기만 하고 틀리면 아무 의미가 없죠. 이번에는 다음 절차로 검증했습니다.
Before/After 동시 실행: 리팩토링된 함수와 기존 함수를 나란히 호출
전 필드 비교:
==가 아니라===로 엄격 비교하며 타입 차이까지 확인랜덤 샘플링: 전체 페이지 중 무작위 50페이지를 자동 순회하며 diff 검사
엣지 케이스 수동 확인: 데이터 없는 페이지, 단일 행 페이지, 검색 결과 0건 등
결과적으로 100% 동일 판정을 받았고, 4번에서 언급한 cs_name 버그도 이 단계에서 발견되었습니다. 검증 자동화가 없었다면 배포 후에 고객사로부터 "수치가 이상하다"는 연락을 받았을 겁니다.
7. 실무 체크리스트
다음 주 월요일부터 바로 쓸 수 있는 체크리스트입니다.
🔍 코드 리뷰 시 확인할 것
[ ] foreach, while 내부에 쿼리 호출이 있는가?
[ ] 루프 안에서 호출되는 함수가 내부적으로 쿼리를 날리진 않는가? (가장 놓치기 쉬움)
[ ]
SELECT COUNT(*)로 충분한데fetchAll() + count()를 쓰고 있진 않은가?[ ] 집계 계산을 PHP 루프로 하고 있는가? → SQL
SUM/AVG/COUNT로 이동[ ]
SELECT *를 남발하고 있는가? → 필요 컬럼만 명시
🛠️ 리팩토링할 때 지킬 것
[ ] Map 인덱싱 시 키 타입과 이름을 명시적으로 일치시킬 것
[ ] 리팩토링 전후의 결과를 동일 입력으로 비교 검증
[ ]
EXPLAIN으로 새 쿼리의 실행 계획을 확인 (type이 ALL이면 경고)[ ] Prepared Statement를 재사용해 파싱 비용 절감
[ ] 페이지네이션이 필요한 화면에 전체 데이터를 끌어오지 않기
📊 모니터링 붙일 것
[ ] 페이지별 쿼리 카운트를 개발 환경에 표시 (예: Debug Bar)
[ ] 느린 쿼리 로그(slow query log) 활성화
[ ] 주요 페이지 응답 시간 경보 (1초 초과 시 알림 등)
8. 마치며 — 코딩 방법론으로서의 교훈
이번 최적화에서 건진 가장 중요한 원칙은 한 줄로 요약됩니다.
루프 안에서 DB 쿼리가 돌면 일단 의심하라.
이것은 PHP/MySQL만의 문제가 아닙니다. Laravel의 with(), Doctrine의 fetchJoin, Python Django의 select_related/prefetch_related, SQLAlchemy의 joinedload/selectinload — 이 모든 도구가 존재하는 이유는 같은 문제를 풀기 위해서입니다. 언어와 프레임워크는 달라도 안티패턴은 동일합니다.
개인적으로 AI 에이전트를 활용해 개발할 때도 이 원칙을 코딩 규약 문서에 못 박아 둡니다. "foreach 안의 쿼리 금지, 반드시 prefetch" 한 줄이 코드 리뷰 단계에서 수십 시간의 디버깅을 막아주기 때문입니다.
성능 문제의 80%는 알고리즘이 아니라 데이터 접근 패턴에서 나옵니다. 인덱스를 튜닝하기 전에, 쿼리를 고쳐 쓰기 전에, 먼저 "이 루프는 몇 번 DB를 왕복하는가?" 를 세어보세요. 대부분의 문제는 그 카운팅 한 번으로 해결됩니다.
이 글이 도움이 되셨다면 댓글로 여러분이 발견했던 N+1 사례도 공유해주세요. 다른 개발자들의 시간을 아껴주는 가장 좋은 방법입니다.