DevInsight

개발의 판단과 맥락을 기록하는 곳

Data
조회 0약 5분 읽기

1번 조회가 100번이 되는 순간

에러 로그에는 남지 않으면서 DB 부하만 조용히 키우는 N+1 쿼리. ORM이 연관 엔티티를 개별 SELECT로 쪼개는 구조적 원인부터, 실행 로그와 계측 데이터에서 폭주 지점을 특정하는 순서, 그리고 연관 데이터의 수와 변동성에 따라 eager loading과 배치 조회를 나누는 판단 기준까지 실전 절차 순으로 담았다. 쿼리 개수 자체보다 트레이드오프를 보는 관점이 핵심이다.

DevInsight 편집팀 발행

AI 보조 초안과 편집 검수를 거쳐 발행했습니다.

#N+1#ORM#JPA#쿼리 최적화#Eager Loading#배치 조회#SQL 로그#성능 개선#데이터베이스

쿼리 로그에 같은 SELECT가 백 번 반복되는 순간이 있다. WHERE 절의 조건은 전부 동일하고 기본 키 값만 하나씩 바뀐다. 에러는 없다. 예외도, 경고도, 타임아웃도 남지 않는다. 그저 쿼리 개수만 조용히 불어났을 뿐이다.

N+1은 이렇게 다가온다. 화면 하나를 여는 데 DB 왕복이 백 번 필요해졌는데, 오류 검색에는 아무것도 걸리지 않는다. 응답 시간은 느려지고 DB 부하는 차오르는데, 그 증거는 로그 어디에도 없다. 그래서 이 문제는 발견부터 절차를 갖고 접근해야 한다.

SELECT가 1에서 101이 되는 구조

JPA 계열 ORM은 엔티티를 조회할 때 연관 객체를 실제로 채우지 않고 프록시만 끼워 둔다. 프록시가 코드에서 실제로 사용되는 순간, 그 객체 하나를 채우기 위한 SELECT가 한 번 나간다. 로딩 시점을 뒤로 미뤄두는 이 기본 동작이 1+N을 만들어낸다.

목록 화면 하나를 떠올려 보자. 게시글 100개를 조회하면 SELECT 1회다. 이후 작성자를 출력하는 반복문이 각 게시글의 프록시를 건드리면서 SELECT 100회가 추가된다. 합계 101회. 반복문이 한 겹 더 깊어지면 곱셈으로 번진다. 게시글마다 댓글 10개를 읽는다면 1 + 100 + 1000이 된다.

이 부하는 늘 같은 크기로 오지 않는다. 자식 데이터가 늘수록, 동시 호출이 늘수록 곡선이 가파라진다. 목록 한 번이 101개 쿼리를 만든다면, 동시 접속 100명은 1만 개를 만든다. DB 커넥션 풀이 모자란 순간 주변의 정상 쿼리까지 지연된다. 조용한 쿼리의 대가는 시스템 전체가 낸다.

여기서 쿼리 건수만 보고 판단하면 안 된다. 자식이 평균 2개인 연관과 평균 200개인 연관은 같은 N+1이라도 부하가 완전히 다르다. 그래서 첫 단계는 "부모가 몇 개이고 자식이 평균 몇 개인지"를 수치로 확정하는 일이다. 기준값이 없으면 어떤 해법을 쓰든 효과를 측정할 방법이 없다.

로그에서 폭주 지점을 좁히는 순서

SQL 로그부터 켜보는 것이 가장 빠르다. Hibernate의 show_sql 설정이나 JPA 구현체별 로그 레벨 조정으로 출력을 켠 뒤, 같은 PreparedStatement가 몇 번 반복되는지 센다. 같은 문장이 N번 연속으로 나온다면 그 지점이 N+1이다.

로그에 찍히는 간격도 단서다. 하나의 트랜잭션 안에서 조회 문장이 규칙적으로 반복되면 연관 로딩이다. 간격이 어긋나게 등장한다면 트랜잭션을 나눠 쓰는 경로거나 별개의 문제다. 100건 목록에서 100줄이 한 덩어리로 나오는 모습은 일대다 연관 로딩의 전형적인 패턴이다.

혼동하기 쉬운 지점이 하나 있다. N+1과 느린 쿼리 한 건은 표면이 비슷하다. 느린 쿼리 하나가 오래 도는 것도, 반복 쿼리가 쌓이는 것도 응답 시간은 비슷하게 늘어난다. 차이는 로그의 모양에서 갈린다. N+1은 같은 문장이 짧은 간격으로 연속 반복되고, 느린 쿼리는 서로 다른 문장이 하나씩 오래 도는 패턴을 보인다.

N+1이 숨는 자리도 대체로 정해져 있다. 반복문 안에서 연관을 읽는 뷰 계층, 조회 결과를 DTO로 옮기는 매핑 단계, 그리고 조회 뒤 별도 판단이 붙는 서비스 로직이 대표적이다. 이 세 군데는 SQL 로그를 켜 놓지 않으면 눈에 들어오지 않는다. 로그를 켰다면 반복문이 돌기 전과 도는 동안의 쿼리 수를 나눠서 봐야 한다.

SQL 로그를 켤 수 없는 운영 환경이라면 계측으로 넘어간다. 기준값은 호출 한 번당 실행되는 쿼리 개수다. Hibernate 통계나 데이터소스 프록시로 쿼리 수를 집계하거나, APM의 SQL 호출 수 메트릭을 활용하면 된다. 계측을 붙였다면 할 일은 "어떤 엔드포인트가 호출당 쿼리를 몇 개 실행하는지"를 상수로 찍어두는 것이다. 이 수치가 호출마다 들쭉날쭉하다면 연관 데이터의 변동성까지 함께 보인다.

이 시점에서 성급하게 코드를 고치지 말자. 기준값을 찍지 않고 고치기 시작하면, 고친 뒤 좋아졌는지 증명할 방법이 사라진다. 그래서 먼저 바꿔야 하는 것은 쿼리가 아니라 측정이다.

계측을 운영에 남겨두면 회귀도 잡힌다. N+1은 코드 리뷰를 통과하기도 한다. 리뷰어의 눈에는 프록시 호출이 보이지 않으니까. 호출당 쿼리 개수에 임계값을 걸어 알림을 받게 하면, 연관 구조가 바뀌는 순간 숫자가 치솟는 것을 놓치지 않는다. 100개면 경고, 500개면 알림으로 구분하는 식이다.

조인으로 묶기, 배치로 묶기

연관 데이터를 한 번에 가져오는 방법은 크게 두 갈래다.

fetch join으로 조회에 합치면 쿼리 한 번으로 연관 엔티티까지 채운다. 가장 확실하지만 성공 조건이 까다롭다. 일대다 조인은 부모 행을 자식 수만큼 중복시켜서 돌려주기 때문이다. 게시글 하나에 댓글 10개면 10행이 만들어진다. 여기에 또 다른 컬렉션을 조인으로 얹으면 행이 곱셈으로 불어나고, 페이징을 얹으면 상황이 더 나빠진다. 중복된 행 위에 limit이 걸리면서 페이지에 누락과 중복이 생긴다.

@Query("select p from Post p join fetch p.comments where p.id = :id") Post findWithComments(@Param("id") Long id);

배치 조회는 방향이 다르다. 연관을 미리 다 땡기지 않고, 프록시를 건드리는 시점에 where id in (?, ?, ?, ...) 형태로 묶어 한 번에 가져온다. @BatchSize나 구현체의 기본 배치 설정으로 조절한다. 100개 게시글의 작성자가 배치 30으로 묶이면 1 + 4회로 끝난다.

배치 크기를 정하는 규칙이 따로 있다. 평균 자식 수에 여유를 더한 값에서 출발해, 실행 로그를 보며 쿼리가 몇 개로 쪼개지는지 확인하고 내린다. 자식 수가 배치 크기를 넘는 구간에서는 여전히 쿼리가 나뉜다. 이 약점은 배치 조회를 고를 때부터 감안해야 한다.

반대 방향의 함정도 있다. 일대다 연관의 기본 전략을 EAGER로 통째로 바꾸는 경우다. 이렇게 하면 모든 조회가 연관을 강제로 가져온다. 목록이든 단건이든, 연관을 아예 안 쓰는 화면까지 조인 대가를 치른다. 전역 설정으로 고치는 것은 최후의 수단으로 두는 편이 안전하다.

연관의 방향도 기준에 넣어야 한다. 다대일, 일대일처럼 하나만 붙는 연관은 조인이 행을 늘리지 않으므로 fetch join이 안전하다. 게시글의 작성자처럼 반드시 하나인 값이라면 별 고민 없이 조인해도 된다. 반면 일대다 컬렉션은 자식 수만큼 부모 행이 복제된다. 컬렉션이 둘 이상 얹히는 순간 행 복제가 곱셈으로 바뀌므로, 그 경우에는 차라리 배치 조회를 고르는 편이 낫다.

수와 변동성으로 고르는 기준

판단 기준은 데이터의 수와 변동성으로 갈린다.

연관을 안 쓰는 화면이 없고 자식 수가 적고 안정적이라면 fetch join이 낫다. 미리 땡겨오는 비용이 낭비되지 않으니까. 반대로 연관을 가끔만 쓰고 자식 수가 들쭉날쭉하면 배치 조회다. 배치 조회는 안 쓰는 연관을 매번 가져오지 않는다. 대신 자식 수가 배치 크기를 넘는 순간부터 쿼리가 다시 갈라지고, fetch join은 그런 일이 없다.

조인으로 가져오는 것이 도리어 낭비인 경우도 있다. 연관 데이터가 목록보다 훨씬 커서 조인 결과가 메모리를 압박한다면, 처음부터 DTO로 필요한 컬럼만 뽑는 조회를 따로 두는 편이 낫다. 반대로 어쩌다 한 번 필요할 뿐이라면 N+1을 그대로 두는 것이 옳은 결정일 수도 있다. 쿼리 개수가 많다는 사실보다 그 쿼리들이 실제로 얼마나 오래 도는지가 우선이다. N이 100이라도 쿼리 하나가 0.5ms면 배치로 바꿔 얻는 이득은 미미하다. 그 0.5ms가 50ms로 늘어나 있는 상황이라면 이야기가 다르다.

목록과 페이징이 섞인 화면은 검증 지점이 하나 더 있다. fetch join과 Pageable을 함께 쓰면 결과 행 수가 자식 수만큼 늘어나므로, 총 개수와 페이지에 담긴 수가 어긋난다. 일부 구현체는 이를 보정하려고 별도의 count 쿼리를 돌리거나 중복 제거 패스를 추가한다. 그래서 쿼리 개수만 줄었는지 보지 말고, 고치고 나서 페이지 숫자가 실제 데이터와 일치하는지와 count 쿼리 비용까지 같이 봐야 한다.

이 판단을 반복하려면 측정 기준이 남아 있어야 한다. 배치 크기를 조정했다면 같은 엔드포인트의 호출당 쿼리 수와 응답 시간을 고치기 전후로 비교한다. 그리고 왜 fetch join이 아니라 배치를 골랐는지 그 근거를 리뷰에 남긴다. 지표가 좋아지고 끝이 아니다. 판단의 이유가 다음 사람에게 전달돼야 코드가 다시 나빠지지 않는다.

마지막으로 확인할 것은 Eager Loading으로 바꾼 연관이 정말 그 조회에서 항상 필요한지다. 안 쓰는 화면이 하나라도 있으면 그 화면은 조인 비용을 매번 낸다. 차라리 연관을 필요로 하는 쿼리에서만 fetch join을 걸고, 나머지는 기본 지연 로딩을 유지하는 쪽이 실수에 덜 흔들린다. 리뷰에서 "이 연관을 여기서 꼭 가져와야 하나"라는 질문이 나온다면 쿼리 최적화가 올바른 방향을 잡은 것이다. 쿼리 개수가 아니라, 그 비용을 낼 만한 곳인지가 기준이니까.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Data 전체 보기

이전 글

삽질 없이 CI를 줄이는 캐시 3종 세트

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독