N+1 문제
N+1 Problem
개념
목록을 가져온 뒤 항목마다 데이터베이스에 다시 물어보는 바람에 질의 횟수가 불어나 화면이 느려지는 문제입니다.
등장 배경
데이터베이스에 말을 걸 때는 원래 SQL이라는 언어로 문장을 직접 적어 보냈습니다. 무엇을 원하는지 한 줄 한 줄 적어야 했으니 불편한 대신, 지금 몇 번을 물어보고 있는지가 코드에 그대로 드러났습니다. 질의가 백 번 나가고 있으면 백 줄이 눈에 보였습니다.
ORM이 나오면서 이 부분이 편해졌습니다. ORM은 코드의 객체와 데이터베이스의 표를 이어 주는 도구라, 질의문을 적는 대신 코드에서 값을 꺼내듯 쓰면 됩니다. 주문.고객.이름 이라고 적으면 알아서 가져옵니다. 문제는 그 한 줄이 질의 한 번을 조용히 보낸다는 데 있습니다. 목록을 돌며 같은 줄을 반복하면 질의도 그 횟수만큼 나가는데, 코드에는 반복문 세 줄만 보입니다.
그래서 이 문제는 대개 개발자 노트북에서는 멀쩡하다가 운영 환경에서 터집니다. 시험용 데이터가 10건일 때는 질의 11번이 30밀리초에 끝나니 아무도 눈치채지 못합니다. 같은 코드가 실제 고객의 주문 3천 건을 받으면 질의가 3001번 나가고 화면은 9초를 넘깁니다. 코드는 한 글자도 바뀌지 않았는데 데이터가 늘었을 뿐입니다.
작동 방식
목록을 한 번 가져오는 질의 1개에, 그 목록의 항목 수 N만큼 질의가 따라붙어 모두 N+1번이 나가는 구조입니다. 이름의 N과 1이 각각 그 둘을 가리킵니다.
순서를 풀면 이렇습니다.
- 목록을 가져옵니다. "이번 달 주문 100건을 달라" 는 질의 한 번이 나갑니다. 여기까지는 정상입니다.
- 가져온 100건을 화면에 뿌리면서 줄마다 주문한 고객의 이름을 함께 보여 주려 합니다. 그런데 주문 데이터에는 고객 번호만 들어 있고 이름은 고객 표에 따로 있습니다.
- 그래서 줄마다 "12번 고객의 이름을 달라", "88번 고객의 이름을 달라" 하는 질의가 한 번씩 나갑니다. 100줄이면 100번입니다.
- 합쳐서 101번입니다.
질의 하나가 3밀리초에 끝난다고 해도 101번이면 303밀리초입니다. 여기에 데이터베이스가 다른 서버에 있어 오가는 시간이 매번 붙으면 1초를 쉽게 넘깁니다. 정작 옮기는 데이터의 양은 얼마 되지 않는데, 말을 거는 횟수 자체가 비용입니다. 짐을 백 번 나르는 것과 한 번에 싣고 오는 것의 차이입니다.
고치는 방법은 미리 묶어 가져오는 것입니다. 100건의 고객 번호를 먼저 모아 "이 100개 번호에 해당하는 고객을 한꺼번에 달라" 고 한 번만 묻습니다. 그러면 질의는 101번에서 2번으로 줄고, 두 표를 붙여 한 번에 가져오게 적으면 1번으로도 끝납니다. ORM 마다 이 기능에 붙인 이름이 다르지만 하는 일은 같습니다. 나중에 하나씩 물어볼 것을 지금 한꺼번에 가져오라고 지시하는 것입니다.
쓰이는 곳
여러 줄을 한 화면에 늘어놓는 자리라면 어디서든 나타납니다. 주문 목록, 게시글 목록, 사용자 관리 화면처럼 줄마다 다른 표의 값을 끌어다 붙이는 화면이 전형입니다. 중첩이 깊어지면 더 불어납니다. 주문 100건에 주문마다 상품 5개가 달려 있고 상품마다 판매자를 다시 물으면 질의는 600번을 넘깁니다.
GraphQL처럼 클라이언트가 필요한 항목을 골라 받는 방식에서도 자주 생깁니다. 요청하는 쪽이 중첩을 몇 단계까지 파고들지 서버가 미리 알 수 없어서, 화면 하나를 만들려고 보낸 요청이 서버 안에서 수백 번의 질의로 번역되기도 합니다.
찾아내는 방법은 정해져 있습니다. 개발 환경에서 질의 로그를 켜 두면 화면 한 번에 몇 번이 나갔는지 그대로 찍힙니다. 비슷한 모양의 문장이 숫자만 바뀌며 줄줄이 반복되면 그게 이 문제입니다. 운영 환경에서는 APM처럼 성능을 추적하는 도구가 요청 하나당 질의 횟수를 보여 주므로, 응답이 느린 화면부터 열어 보면 대개 여기서 걸립니다. 확실하게 가르려면 목록 건수를 바꿔 봅니다. 10건일 때 11번이던 질의가 50건일 때 51번으로 늘면 이 구조가 맞습니다.
실무 용례
"이 화면 N+1 걸려 있어요. 목록 100건 뿌리는데 질의가 300번 나가요."
한 화면을 그리는 동안 불필요한 질의가 반복해서 나가고 있다는 지적입니다.
"여기 N+1 나겠는데요. 미리 묶어 가져오게 바꾸죠."
코드 리뷰에서 아직 터지지 않은 자리를 미리 짚는 말입니다.
"로컬에서는 빨랐는데 운영에서 5초 걸려요. N+1인지부터 보죠."
데이터 양이 늘었을 때만 드러나는 문제이니 질의 횟수부터 확인하자는 제안입니다.
페이지덱에서 태오가 관리자 화면을 손볼 때 가장 먼저 여는 것이 질의 로그입니다. 문서 목록 화면이 3초 넘게 걸린다는 제보가 들어왔을 때도 로그를 켜니 같은 모양의 질의가 문서 개수만큼 찍혀 있었습니다. 줄마다 마지막으로 고친 사람의 이름을 보여 주는 부분이었습니다.
고친 뒤 질의는 200번대에서 3번으로 줄었고 화면은 0.2초에 떴습니다. 코드에서 바뀐 것은 목록을 가져오는 줄에 함께 가져올 항목을 지정한 한 줄뿐이었습니다. 태오는 이 일을 겪은 뒤 목록 화면을 다루는 리뷰에서는 질의 횟수를 함께 적어 달라고 팀에 요청했습니다.
흔한 오해
인덱스를 걸면 해결된다고 보는 경우가 많습니다. 느린 화면의 원인을 찾을 때 가장 먼저 배우는 것이 인덱스이고, 인덱스는 실제로 질의 하나를 빠르게 만들어 주니 그렇게 넘어가기 쉽습니다. 하지만 인덱스는 한 번의 질의가 백만 줄을 훑는 것을 몇 줄로 줄여 주는 도구이고, 여기서 문제는 질의 하나의 속도가 아니라 질의가 백 번 나간다는 것입니다. 인덱스를 걸면 3밀리초짜리가 1밀리초가 되어 303밀리초가 101밀리초로 줄 뿐, 100번을 1번으로 만들지는 못합니다. 원인을 잘못 짚으면 인덱스를 늘려 가며 시간을 쓰는 동안 화면은 여전히 느리고, 쓰지도 않는 인덱스가 쌓여 데이터를 쓸 때마다 같이 고쳐야 하는 짐만 늘어납니다.
N이 작으면 괜찮다고 두는 것도 흔한 판단입니다. 지금은 목록이 20건이라 질의 21번이면 60밀리초에 끝나니 문제로 보이지 않습니다. 그런데 이 구조는 데이터가 늘어나는 만큼 그대로 따라 늘어납니다. 1년 뒤 20건이 2천 건이 되면 같은 코드가 6초를 씁니다. 서비스가 잘될수록 느려지는 셈이라, 보이는 시점에는 이미 고객이 불편을 겪은 뒤입니다.
같이 보면 좋은 페이지
마지막 업데이트 2026-09-16