Back to Posts
BackendSpring BootJPAHibernateSQL

N+1이란?

Spring Boot, JPA, Hibernate, SQL

N+1의 발생 원인과 확인 방법을 알아보고, 실제 친구 목록과 모집 신청 목록에서 해결한 과정을 코드와 쿼리 수로 비교합니다.

목록을 조회하는 Repository 메서드를 한 번 호출했는데, DB에는 수십 번의 SELECT가 실행될 수 있다. Java 코드에서 조회 메서드를 호출한 횟수와 실제 SQL 실행 횟수가 항상 같지는 않기 때문이다.

이 글에서는 N+1이 무엇이고 어떤 조건에서 발생하는지 정리한다. 이어서 팀 NexusOps의 Gamehouse(게임 매칭 플랫폼) 프로젝트에서, 다른 팀원이 작성한 백엔드를 로컬 부하 테스트하고 조회 코드를 분석하는 과정에서 발견해 개선한 친구 목록과 모집 신청 목록의 N+1 문제를 소개한다.

N+1이란?

N+1은 목록을 조회하는 쿼리 1번 이후, 각 항목에 필요한 데이터를 가져오기 위한 쿼리가 N번 추가되는 현상을 말한다.

친구 관계 30건을 조회한 뒤, 각 친구의 닉네임을 얻기 위해 사용자 정보를 하나씩 조회한다고 생각해 보자.

친구 관계 목록 조회 1번 친구 30명의 사용자 정보 조회 30번 ──────────────────────────────── 합계 31번

친구가 100명이면 101번이 된다. 각 SELECT가 빠르더라도 DB에 요청하고 응답받는 작업이 반복되면 전체 처리 시간이 늘어날 수 있다.

여기서 중요한 것은 정확히 31번이 실행됐는지가 아니다. API에는 다른 조회가 포함될 수 있고, 항목 하나가 여러 추가 조회를 일으킬 수도 있다. 목록 크기에 따라 항목별 추가 조회가 늘어나는 구조를 봐야 한다.

어떤 조건에서 발생할까?

Spring Boot에서 JPA와 Hibernate를 사용하는 경우, 다음 흐름에서 발생하기 쉽다.

  1. 목록의 기본 엔티티만 조회한다.
  2. 응답을 만들면서 아직 로딩하지 않은 연관 엔티티의 정보에 접근한다.
  3. Hibernate가 그 정보를 가져오기 위해 SELECT를 실행한다.
  4. 이 과정이 목록의 각 항목에서 반복된다.

지연 로딩(LAZY)은 연관 데이터를 실제로 필요로 할 때 가져오는 방식이다. 따라서 LAZY 설정 자체만으로 N+1이 생기는 것은 아니다. 연관 정보에 접근하지 않거나 이미 영속성 컨텍스트에 로딩돼 있다면 해당 추가 조회가 없을 수 있다.

반대로 즉시 로딩(EAGER)으로 바꾼다고 해결되는 것도 아니다. EAGER는 연관 데이터를 미리 로딩하도록 요구하지만, 항상 한 번의 JOIN으로 가져온다는 뜻은 아니다. 별도 SELECT가 반복될 수 있다. Hibernate 공식 문서

또 다른 발생 원인은 반복문 안에서 직접 조회 메서드를 호출하는 코드다. ORM의 지연 로딩을 사용하지 않아도 항목마다 DB를 조회하면 같은 문제가 생긴다. for뿐 아니라 stream().map(...) 안에서 호출하는 함수도 살펴봐야 한다.

실제 사례: 친구 목록과 모집 신청 목록

Gamehouse는 팀 NexusOps에서 진행한 게임 매칭 플랫폼 웹 애플리케이션 프로젝트다. 이 사례에서 내가 수행한 작업은 기존 백엔드에 대한 로컬 부하 테스트, 의심 API를 확인하기 위한 스크립트 작성, 그리고 발견한 N+1의 개선이었다.

조사 과정에서 게시글 조회, 채팅방 목록, 메시지 조회에서도 N+1 문제를 발견했다. 해당 부분은 다른 팀원에게 전달했고, 나는 친구 목록과 모집 신청 목록의 개선을 담당했다. 이 글에서는 직접 수정하고 검증한 두 사례를 다룬다.

부하 테스트와 조회 코드 분석에서 시작하기

기존 백엔드를 로컬에서 부하 테스트하던 중 OOM(메모리 부족)이 반복적으로 발생했다. 원인을 조사하면서 목록 API의 조회와 응답 생성 과정을 살펴봤고, DTO 변환 중 항목마다 추가 조회가 발생할 수 있는 코드를 발견했다. 이를 확인하기 위해 의심되는 API를 따로 호출하고 DB 활동을 측정했다. 이 글에서는 그 과정에서 발견하고 개선한 N+1 문제를 다룬다.

부하 테스트의 응답 시간만으로 N+1을 판별할 수는 없다. 그래서 의심되는 목록 API를 분리해 호출하고, DB 활동과 실제 조회 코드를 함께 확인하는 방식으로 조사했다.

목록 API를 따로 호출하는 프로브 만들기

목록의 각 항목을 DTO로 변환하는 과정에서 추가 조회가 발생할 수 있는 코드를 확인하고, 의심 API를 따로 호출하는 프로브(진단용 스크립트)를 만들었다. 목적은 여러 API가 섞인 부하 테스트에서 벗어나, 특정 목록 API를 호출했을 때 어느 테이블의 조회 작업이 늘어나는지 확인하는 것이었다.

프로브는 다음 역할로 나누어 구성했다.

구성하는 일필요한 이유
테스트 데이터 준비친구·모집 신청을 각각 30건 준비빈 목록이나 적은 데이터에서 반복 조회가 가려지는 것을 방지
API 호출인증 토큰으로 대상 API를 하나씩 반복 호출다른 API의 영향을 줄이고 같은 요청을 비교
응답 확인HTTP 상태와 실제 응답 항목 수 확인실패 응답이나 예상보다 작은 목록을 정상 측정으로 해석하지 않도록 처리
통계 수집호출 전후 테이블별 스캔 횟수 저장DB 활동이 어느 테이블에서 늘었는지 확인
결과 비교증가분을 요청 수와 항목 수로 나누어 출력서로 다른 목록의 크기를 고려해 의심 지점 탐색

1. 확인하려는 코드가 실행되도록 데이터 준비

친구 목록은 서로 다른 상대방을 포함하도록 만들었다. 신청 목록은 서로 다른 게시글에 대한 신청을 준비하고, 상태를 APPROVED로 설정했다. 승인된 신청에서만 채팅방 조회가 실행되므로, PENDING 데이터만 넣으면 해당 반복 조회를 확인할 수 없기 때문이다.

중요한 것은 데이터 개수뿐 아니라 문제가 의심되는 분기를 실제로 실행하는 데이터인가였다. 인증 토큰도 미리 준비해 로그인 과정의 DB 접근이 측정 구간에 섞이지 않게 했다.

2. API를 하나씩 호출하고 응답 확인

호출 부분의 핵심은 다음과 같다. 아래 코드는 구조를 설명하기 위한 발췌이며, 변수는 스크립트의 준비 단계에서 설정한다.

for ((i = 0; i < request_count; i++)); do status=$(curl -sS -o response.json -w '%{http_code}' \ -H "Authorization: Bearer $token" \ "$base_url/api/my/applications") # 실패 응답을 정상적인 조회 결과로 집계하지 않는다. [[ "$status" == 200 ]] || exit 1 done # 두 대상 API는 배열을 반환하므로 실제 배열 길이를 확인한다. item_count=$(jq 'length' response.json)

여기서 서비스가 사용하는 SQL을 직접 대신 실행하는 것은 아니다. 실제 HTTP 요청을 보내 백엔드의 조회·DTO 변환 경로를 실행한다. 고정된 테스트 데이터를 사용하고 측정 중에는 다른 요청이 들어오지 않도록 했다.

3. 호출 전후 DB 통계를 저장하고 차이 계산

통계 수집부는 PostgreSQL에서 테이블별 스캔 횟수를 읽어 스냅샷으로 저장한다. 핵심 SQL은 다음과 같다.

SELECT relname, coalesce(seq_scan, 0) + coalesce(idx_scan, 0) AS scans FROM pg_stat_user_tables;
준비·워밍업 → 배경 활동 확인 → 호출 전 통계 저장 → 대상 API 반복 호출 → 통계 반영 대기 → 호출 후 통계 저장 스캔 증가분 = 호출 후 scans - 호출 전 scans 요청당 스캔 수 = 스캔 증가분 / 요청 수 항목당 스캔 수 = 요청당 스캔 수 / 응답 항목 수

응답 항목 수가 0이면 항목당 값을 계산하지 않는다. 또한 PostgreSQL 누적 통계는 즉시 반영되지 않을 수 있어, 호출 직후의 값을 바로 사용하지 않고 반영을 기다렸다. 프론트엔드 폴링이나 백그라운드 작업도 같은 DB 통계에 포함되므로, 요청을 보내지 않는 구간의 활동을 함께 확인했다.

이 구조로 어느 API에서 어느 테이블의 접근이 많이 발생하는지 범위를 좁혔다. 이후 해당 테이블을 읽는 DTO 변환 코드와 Repository 호출을 살펴보고, SQL 실행 횟수는 별도로 측정했다.

스캔 횟수와 SQL 문 수는 다르다

스크립트를 해석할 때 특히 구분해야 할 부분이 있다. 수집하는 값은 pg_stat_user_tables의 seq_scan + idx_scan 증가분, 즉 테이블별 순차·인덱스 스캔 횟수다.

-- 수집하는 통계의 핵심 SELECT relname, coalesce(seq_scan, 0) + coalesce(idx_scan, 0) AS scans FROM pg_stat_user_tables;

이 값은 실행된 SQL 문 수와 일대일로 대응하지 않는다. SQL 한 문장도 여러 테이블을 조회하거나, nested loop 실행 계획에서 인덱스 스캔을 반복할 수 있다. 따라서 스캔이 많다는 사실만으로 N+1을 확정할 수는 없다. PostgreSQL 통계 문서

항목당 스캔 수는 추가 확인이 필요한 후보를 찾는 지표로 사용한다. 수치가 높다는 이유만으로 N+1을 확정하지 않고, 실제 조회 코드와 SQL을 함께 확인해야 한다. DB 통계에는 다른 트래픽이 섞일 수 있으므로 배경 활동과 통계 반영 대기도 고려한다.

후보를 찾은 뒤에는 실제 코드의 항목별 조회와 실행 SQL을 연결해서 확인해야 한다. SQL 문 수 자체는 SQL 로그나 별도 계측으로 확인할 수 있으며, PostgreSQL의 pg_stat_statements.calls는 SQL 실행 횟수를 나타내는 지표다. 이 프로브는 해당 지표를 수집하지 않는다. PostgreSQL SQL 실행 통계 문서

정리하면 부하 테스트에서 이상 징후 발견 → 코드에서 반복 조회 의심 → API를 분리해 DB 활동 조사 → 반복 조회 구조 확인 및 개선이라는 흐름이다. 아래에서는 그중 친구 목록과 모집 신청 목록의 코드 변경을 살펴본다.

아래 코드는 2026년 8월 18일의 개선 커밋과 그 직전 버전을 기준으로 한다. 설명에 필요한 부분만 발췌했으며, SQL 예시는 실행 로그 원문이 아니라 동작을 이해하기 위한 형태다.

사례 1. 친구 목록에서 사용자 정보가 필요해지는 순간

Friend는 친구 신청을 보낸 사용자와 받은 사용자를 각각 참조한다. 두 연관관계 모두 LAZY로 설정돼 있었다.

// Friend.java — 연관관계 발췌 @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "requester_id") private User requester; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "receiver_id") private User receiver;

기존 친구 목록 JPQL은 다음과 같았다. JPQL은 테이블 대신 엔티티와 필드를 대상으로 작성하는 쿼리다.

select f from Friend f where f.status = :status and (f.requester.id = :userId or f.receiver.id = :userId) order by f.acceptedAt desc

서비스에서는 조회 결과를 DTO로 변환했다. DTO는 클라이언트에 전달할 데이터를 담는 객체다.

@Transactional(readOnly = true) public List<FriendDto> friends(Long meId) { return friendRepository.findFriendsOf(meId) .stream().map(f -> FriendDto.from(f, meId)).toList(); }

FriendDto.from()은 현재 사용자를 기준으로 상대방을 골라 사용자 DTO를 만들었다.

public static FriendDto from(Friend f, Long meId) { User other = f.getRequester().getId().equals(meId) ? f.getReceiver() : f.getRequester(); return new FriendDto(f.getId(), f.getStatus().name(), f.getCreatedAt(), f.getAcceptedAt(), UserDto.from(other)); }

목록 조회가 끝났다고 해서 응답에 필요한 사용자 정보까지 준비된 것은 아니었다. UserDto.from(other)에서 사용자 필드에 접근하면, 아직 초기화되지 않은 사용자 프록시를 로딩하기 위한 SELECT가 추가될 수 있었다. 프록시는 실제 데이터의 로딩을 미루기 위해 사용하는 대리 객체다.

-- 개선 전의 조회 흐름 예시 select ... from friends where ...; select ... from users where id = ?; select ... from users where id = ?; -- 상대방 사용자 정보 조회가 반복됨

해결: 친구 관계와 사용자를 함께 가져오기

목록 응답에는 상대방 사용자 정보가 필요하다. 그래서 목록 조회 시점에 함께 가져오도록 fetch join을 추가했다.

select f from Friend f join fetch f.requester join fetch f.receiver where f.status = :status and (f.requester.id = :userId or f.receiver.id = :userId) order by f.acceptedAt desc

join fetch는 연관 엔티티도 함께 로딩하도록 지정한다. 일반 join을 조건이나 필터링에 사용했다고 해서 연관 객체까지 초기화되는 것은 아니다.

-- 개선 후의 조회 흐름 예시 select f..., requester..., receiver... from friends f join users requester on requester.id = f.requester_id join users receiver on receiver.id = f.receiver_id where ...;

서비스의 DTO 변환 코드는 그대로 두고, 필요한 정보를 가져오는 조회 방식을 바꿨다. 이제 해당 사용자 정보는 친구 목록과 함께 로딩된다.

받은·보낸 친구 신청 목록에는 @EntityGraph로 필요한 연관관계를 지정했다.

@EntityGraph(attributePaths = {"requester", "receiver"}) List<Friend> findByReceiverIdAndStatusOrderByCreatedAtDesc( Long receiverId, Friend.Status status); @EntityGraph(attributePaths = {"requester", "receiver"}) List<Friend> findByRequesterIdAndStatusOrderByCreatedAtDesc( Long requesterId, Friend.Status status);

EntityGraph는 조회할 때 필요한 연관 데이터를 선언하는 방법이다. 실제 SQL 형태는 사용하는 구현체와 매핑에 따라 확인해야 한다.

사례 2. 모집 신청마다 게시글과 채팅방 조회하기

모집 신청 목록은 DTO 변환 내부에 직접적인 Repository 호출도 있었다.

이 서비스에서는 모집 신청이 승인되면 해당 모집글의 파티 채팅방에 참가하는 흐름이다. 승인 처리 코드는 모집글의 채팅방을 찾고, 없다면 생성한 뒤 신청자를 멤버로 추가한다. 신청 목록 응답에도 클라이언트가 해당 방을 식별할 수 있도록 chatRoomId를 담는다.

public record ApplicationDto( Long id, String status, Instant createdAt, Long postId, String postTitle, UserDto applicant, Long chatRoomId ) {}

따라서 승인·확정 상태인 APPROVED와 CONFIRMED 신청에는 채팅방 ID가 필요하고, 대기 중인 신청에는 필요하지 않았다. 목록 조회가 채팅방을 생성하는 것은 아니다. 승인 과정에서 연결된 방의 ID를 찾아 응답에 넣는 작업이다.

// 개선 전: 신청 한 건을 DTO로 변환 private ApplicationDto toDto(Application a) { Long roomId = null; if (a.getStatus() == Application.Status.APPROVED || a.getStatus() == Application.Status.CONFIRMED) { roomId = chatRoomRepository.findByPostId(a.getPost().getId()) .map(ChatRoom::getId).orElse(null); } return new ApplicationDto(a.getId(), a.getStatus().name(), a.getCreatedAt(), a.getPost().getId(), a.getPost().getTitle(), UserDto.from(a.getApplicant()), roomId); }

목록에서 .map(this::toDto)를 실행하면 두 종류의 추가 조회가 생길 수 있다.

코드추가 조회가 발생하는 조건
a.getPost().getTitle()게시글이 아직 로딩되지 않았을 때
UserDto.from(a.getApplicant())DTO에 필요한 신청자 정보가 아직 로딩되지 않았을 때
chatRoomRepository.findByPostId(...)승인·확정 상태인 신청을 변환할 때마다

Application의 post와 applicant도 LAZY 연관관계였다. 다만 내 신청 목록에서는 신청자가 같은 사용자이므로, 신청자 SELECT가 반드시 신청 수만큼 반복되는 것은 아니다. 같은 엔티티의 재사용 여부와 신청 상태에 따라 실제 횟수는 달라진다.

해결: ID를 모아서 조회하고 Map에서 꺼내기

한 건씩 DTO를 만들며 조회하던 방식을 바꾸었다. 먼저 목록 전체에서 필요한 ID를 모으고, 게시글·신청자·채팅방을 각각 일괄 조회한다.

// 개선 후: 목록 전체를 받아 필요한 데이터를 먼저 조회 private List<ApplicationDto> toDtos(List<Application> applications) { if (applications.isEmpty()) return List.of(); Map<Long, String> titleByPostId = postRepository .findAllById(idsOf(applications, a -> a.getPost().getId())) .stream().collect(Collectors.toMap(Post::getId, Post::getTitle)); Map<Long, UserDto> applicantById = userRepository .findAllById(idsOf(applications, a -> a.getApplicant().getId())) .stream().collect(Collectors.toMap(User::getId, UserDto::from)); Set<Long> roomPostIds = idsOf( applications.stream().filter(ApplicationService::hasChatRoom).toList(), a -> a.getPost().getId()); Map<Long, Long> roomIdByPostId = roomPostIds.isEmpty() ? Map.of() : chatRoomRepository.findByPostIdIn(roomPostIds).stream() .collect(Collectors.toMap(r -> r.getPost().getId(), ChatRoom::getId)); return applications.stream() .map(a -> new ApplicationDto(a.getId(), a.getStatus().name(), a.getCreatedAt(), a.getPost().getId(), titleByPostId.get(a.getPost().getId()), applicantById.get(a.getApplicant().getId()), hasChatRoom(a) ? roomIdByPostId.get(a.getPost().getId()) : null)) .toList(); } private static Set<Long> idsOf(List<Application> applications, Function<Application, Long> id) { return applications.stream().map(id).collect(Collectors.toSet()); } private static boolean hasChatRoom(Application a) { return a.getStatus() == Application.Status.APPROVED || a.getStatus() == Application.Status.CONFIRMED; }

채팅방 Repository에는 다음 메서드를 추가했다.

List<ChatRoom> findByPostIdIn(Collection<Long> postIds);

Set으로 중복 ID를 제거하고, 조회 결과는 ID를 키로 하는 Map에 담는다. 마지막 DTO 변환에서는 DB 조회 대신 메모리에 준비한 값을 사용한다. 채팅방이 필요한 신청이 없으면 채팅방 조회도 생략한다.

이 코드는 연관 프록시의 식별자를 읽을 때 본문 로딩을 하지 않는 Hibernate 동작을 활용한다. ID 접근과 getTitle() 같은 일반 필드 접근은 구분해야 하며, 실제 매핑과 설정에서도 추가 SQL이 없는지 확인해야 한다.

-- 일괄 조회의 의도를 나타낸 SQL 예시 select ... from applications where applicant_id = ?; select ... from posts where id in (?, ?, ...); select ... from users where id in (?, ?, ...); select ... from chat_rooms where post_id in (?, ?, ...);

이 네 줄은 목록과 관련 데이터를 가져오는 흐름을 설명한 것이다. 인증 등 요청의 다른 구간이나 추가 연관 로딩까지 포함한 API 전체 SQL 수를 의미하지 않는다.

왜 모두 fetch join으로 바꾸지 않았을까?

친구 목록은 응답에 필요한 두 사용자 연관을 함께 가져오는 방식으로 해결했다. 신청 목록은 게시글·신청자 정보 외에도, 승인·확정된 신청에서 파티 채팅방 ID를 응답에 담아야 했다. 필요한 ID를 모으는 방식으로 이 조회들을 한꺼번에 정리할 수 있었다.

당시 커밋에는 조인 실행 계획에서 게시글 인덱스 접근이 반복되어 IN 조회를 선택했다는 기록도 있다. 다만 IN 조회가 항상 조인보다 빠르거나, 항목 수와 무관한 비용을 갖는다는 의미는 아니다. IN 조회도 DB 실행 계획에 따라 여러 번 인덱스를 탐색할 수 있다. 줄어든 SQL 호출 횟수와 DB 내부 작업량은 구분해야 한다.

개선 전후 쿼리는 얼마나 줄었나?

개선 효과는 같은 로컬 환경과 데이터에서 개선 전후의 SQL 실행 횟수를 비교하는 방식으로 확인했다. 두 버전을 각각 빌드하고 실제 HTTP API를 호출해, 인증부터 응답 생성까지 발생하는 쿼리를 측정했다.

로컬 부하 테스트 환경

당시 부하 테스트는 MacBook에서 로컬 Docker Compose로 진행했다. 이 사례는 서비스 분리 이전의 백엔드를 대상으로 한다. 기본 Compose에는 백엔드, 프론트엔드, RabbitMQ가 정의돼 있고, 로컬 설정에서 PostgreSQL을 추가한다. 관측용 Compose에는 Prometheus와 Grafana 등이 정의돼 있다. 아래는 개선 시점 이전 infra 리비전 8d44cc5의 설정을 기준으로 정리한 구성이다.

구성 요소infra Compose 설정
실행 장비MacBook, 로컬 Docker Compose
백엔드단일 Spring Boot 서비스, 컨테이너 메모리 제한 700m
JVM-Xmx200m -Xss512k -XX:MaxDirectMemorySize=64m
프론트엔드별도 프론트엔드 컨테이너
DB로컬 PostgreSQL postgres:16, 호스트 포트 15432
메시지 브로커RabbitMQ, STOMP 연동
관측 도구Prometheus v2.55.1, Grafana 11.3.0, cAdvisor, node-exporter, postgres-exporter

당시 백엔드의 최대 힙은 200MB로 제한돼 있었다. 데이터 양과 동시 요청 수에 따라 이 메모리 제한도 OOM 발생에 영향을 줄 수 있다. 이 글에서는 조사 과정에서 발견한 N+1과 개선 전후 SQL 실행 횟수에 집중한다.

위 표는 당시 로컬 부하 테스트 구성이다. 아래에 첨부한 SQL 결과는 별도 검증 실행에서 수집했으며, 실행 버전과 상세 조건은 원본 측정 자료에 보존했다.

SQL 측정 방법

항목조건
개선 전5345ee9d4d47b43d3f62ed31dc475537f94f754c
개선 후e24165689f276c515d082af3f7a647bd84ac1928
데이터목록 크기 1·10·30건, 서로 다른 친구·게시글, 신청은 모두 APPROVED
채팅방게시글 30개 중 10개에 생성하여 존재·부재 상황을 함께 확인
반복조합별 워밍업 후 3회, 총 36회 측정
캐시Open Session in View, 2차 캐시, 쿼리 캐시 비활성화
측정 범위인증 필터부터 DTO 응답까지의 SELECT·UPDATE 등 DML, 트랜잭션 제어문과 준비·계측 SQL 제외

두 버전을 순차 실행하며 동일한 테스트 데이터로 초기화했다. 대상 목록 요청은 모두 HTTP 200이었으며, 두 목록 API의 SQL 실행 횟수와 응답 내용이 유지되는지를 검증했다.

측정용 measure.mjs는 다음 순서로 동작한다.

  1. 측정 구간 밖에서 데이터를 준비하고 해당 API를 워밍업한다.
  2. 전용 DB의 pg_stat_statements 통계를 초기화한다.
  3. JWT를 포함한 HTTP 요청을 한 번 보낸다.
  4. 대상 SELECT·UPDATE 등의 calls를 합산한다. 계측 쿼리는 제외한다.
  5. 같은 구간의 Hibernate SQL 로그 수와 Session Metrics의 JDBC statement 준비 횟수 합계를 대조한다.

SQL 로그는 org.hibernate.SQL=DEBUG, Hibernate 통계는 hibernate.generate_statistics=true로 활성화했다. 세 계측값은 36회 모두 일치했다. JDBC statement 준비 횟수와 SQL 실행 횟수는 일반적으로 항상 같은 의미는 아니므로, DB의 실제 실행 횟수를 함께 확인했다.

# SQL과 Hibernate 세션 통계 관측 설정 logging: level: org.hibernate.SQL: DEBUG spring: jpa: properties: hibernate.generate_statistics: true hibernate.format_sql: false hibernate.cache.use_second_level_cache: false hibernate.cache.use_query_cache: false

SQL 로그에서는 실행된 SELECT와 UPDATE를 한 문장씩 세었다. Hibernate Session Metrics에서는 요청 중 생성된 세션들의 preparing ... JDBC statements 값을 합산했다. PostgreSQL에서는 pg_stat_statements 확장을 활성화하고, 동일한 요청 구간의 SQL별 calls를 합산했다.

예를 들어 게시글 조회 SQL 하나의 calls가 30이라면, 같은 형태의 SQL이 30번 실행됐다는 뜻이다. SQL 형태의 종류 수를 세는 것이 아니라 각 SQL의 실행 횟수를 합산해야 한다. 통계 초기화와 통계 조회 자체는 측정에서 제외했고, 다른 트래픽이 없는 전용 실험 DB에서만 통계를 초기화했다.

요청별 측정값과 30건 기준 PostgreSQL 정규화 SQL 및 calls는 측정 원본 데이터(JSON)에 남겼다. 테이블 스캔 프로브가 의심 대상을 찾는 데 쓰였다면, 이 측정은 실제 SQL 실행 횟수를 확인하는 데 사용했다.

결과: SELECT와 전체 SQL을 구분하기

아래 값은 각 조합에서 3회 모두 같았다. 응답 건수뿐 아니라 개선 전후 응답 JSON 전체도 일치했다.

API항목 수개선 전 SELECT개선 후 SELECT개선 전 전체 SQL개선 후 전체 SQL
친구 목록14354
친구 목록10133144
친구 목록30333344
내 신청 목록16677
내 신청 목록10246257
내 신청 목록30646657

여기서 전체 SQL은 이 요청에서 실행된 SELECT와 UPDATE의 합계다. 모든 요청에 사용자의 최근 접속 시각을 갱신하는 UPDATE 1회가 포함돼 있었다.

30건 기준 SELECT는 친구 목록에서 33 → 3, 신청 목록에서 64 → 6으로 줄었다. UPDATE까지 포함한 전체 SQL은 각각 34 → 4, 65 → 7이다. 조회 횟수와 전체 SQL 횟수를 구분해서 기록했다.

남은 쿼리는 어디서 발생했을까?

인증 필터는 사용자 findById() 후 최근 접속 시각을 바꾸고 save()한다. 이번 실행에서는 조회와 merge 과정에서 사용자 SELECT 2회, UPDATE 1회가 발생했다. 이 공통 비용 위에 각 목록의 조회가 더해진다.

친구 목록의 SELECT 개선 전: 인증 2 + 친구 목록 1 + 상대 사용자 N = N + 3 개선 후: 인증 2 + 친구·사용자 fetch join 1 = 3 신청 목록의 SELECT 개선 전: 인증 2 + 신청 목록 1 + 신청자 1 + 게시글 N + 채팅방 N = 2N + 4 개선 후: 인증 2 + 신청 목록 1 + 신청자 1 + 게시글 일괄 1 + 채팅방 일괄 1 = 6 모든 요청: 여기에 최근 접속 시각 UPDATE 1회 추가

신청 1건에서는 개선 전후 횟수가 같지만, 10건과 30건에서는 차이가 커졌다. 개선 전에는 항목 수에 따라 조회가 늘고, 개선 후에는 이번 데이터 범위에서 일정하게 유지되는 모습을 실제 수치로 확인했다.

30건 기준 SELECT 감소율은 친구 목록 약 90.9%, 신청 목록 약 90.6%다. UPDATE를 포함한 전체 SQL 감소율은 각각 약 88.2%, 89.2%다. 이를 응답 시간이나 메모리 사용량의 감소율로 해석하지는 않는다.

N+1인지 어떻게 알아차릴까?

1. SQL 로그에서 반복 패턴 확인하기

개발 환경에서 Hibernate SQL 로그를 활성화하면 실제 실행되는 쿼리를 볼 수 있다.

# 개발 환경의 application.yml 설정 예시 logging: level: org.hibernate.SQL: DEBUG

목록 API를 한 번 호출했을 때 where id = ?나 where post_id = ? 형태의 SELECT가 반복되는지 살펴본다. 그런 다음 DTO 변환과 연관 필드 접근, 반복적인 Repository 호출을 연결해서 원인을 찾는다.

SQL이 여러 개라는 이유만으로 N+1이라고 판단하지는 않는다. 서로 다른 목적의 고정된 조회 여러 번일 수도 있기 때문이다.

2. 목록 크기를 바꿔보기

항목을 1건, 10건, 30건으로 늘렸을 때 SQL 수도 함께 증가하는지 확인한다. 아래는 실제 측정값이 아닌, 단순한 N+1 구조의 계산 예시다.

목록 크기목록 1회 + 항목별 1회 조회한 번의 fetch join으로 해결한 해당 조회
1건2회1회
10건11회1회
30건31회1회

항목 1건만으로 확인하면 반복 조회의 문제가 잘 드러나지 않는다. 서로 다른 연관 데이터를 가진 항목 여러 개로 확인해야 한다.

3. 측정 조건 맞추기

개선 전후를 다시 검증한다면 다음 조건을 맞춘다.

  • 같은 DB 데이터, 같은 사용자, 같은 요청 조건으로 실행한다.
  • 데이터 준비를 위한 INSERT와 조회 구간의 SQL을 분리한다.
  • 서비스 메서드만 측정하는지, 인증부터 응답 변환까지 측정하는지 명시한다.
  • 테스트에서 데이터를 저장했다면 flush() 후 clear()로 영속성 컨텍스트를 비운 뒤 측정한다. 2차 캐시와 쿼리 캐시도 사용 여부를 맞춘다.
  • 게시글 ID의 중복 여부와 승인·확정 신청 수를 기록한다. 이 사례에서는 해당 조건이 추가 조회 수에 영향을 준다.
  • 쿼리 수뿐 아니라 응답 내용과 정렬이 유지되는지도 확인한다.

Hibernate Statistics나 데이터소스 계측 도구를 사용하면 SQL 문 수를 자동으로 확인하는 데 도움이 된다. 통계가 애플리케이션 전체에서 누적되는 경우에는 다른 요청이 섞이지 않는 환경에서 측정해야 한다.

N+1을 예방하려면 무엇을 신경 써야 할까?

가장 먼저 볼 것은 이 응답을 완성하기 위해 어떤 데이터가 필요한가다. Repository에서 목록을 가져오는 코드뿐 아니라, DTO 변환이 끝날 때까지의 데이터 접근을 함께 봐야 한다.

필요한 작업검토할 방법
목록과 함께 단일 연관 엔티티의 정보가 필요함fetch join, EntityGraph
여러 항목의 ID로 별도 데이터를 찾아야 함ID를 모은 IN 조회와 Map 변환
응답에 일부 필드만 필요함필요한 필드를 DTO로 직접 조회
연관 데이터를 묶어서 지연 로딩하고 싶음Hibernate 배치 페치 설정

배치 페치는 지연 로딩 대상을 묶어서 조회하는 방식이다. 이 글의 직접적인 ID 수집과 IN 조회와는 구현 방식이 다르며, 항상 모든 조회를 한 번으로 만드는 것도 아니다.

fetch join을 적용할 때는 연관관계의 형태도 확인해야 한다. 이 사례의 친구 관계는 사용자 한 명을 가리키는 ManyToOne이다. 반면 컬렉션을 fetch join하면 부모 한 건이 여러 SQL 결과 행으로 펼쳐질 수 있어 페이징에 주의해야 한다. 여러 컬렉션을 동시에 조인하면 결과 행이 크게 늘어날 수도 있다. Hibernate 조회 전략 문서

마치며

이번 작업의 출발점은 다른 팀원이 개발한 백엔드를 로컬에서 부하 테스트하던 중 반복적으로 발생한 OOM이었다. 가능한 원인을 나열하고 확인할 항목을 좁혀 가면서, N+1도 조사할 후보로 삼았다.

기존 코드의 조회와 DTO 변환 흐름을 따라가고, 의심되는 API를 따로 호출해 DB 활동을 확인하면서 실제로 반복 조회가 발생하는 부분을 찾았다. 발견한 문제 중 친구 목록과 모집 신청 목록의 개선을 맡아, 연관 사용자 정보를 함께 조회하고 항목별 조회를 일괄 조회로 바꿨다.

개선 전후를 같은 데이터로 비교한 결과, 30건 기준 SELECT는 친구 목록에서 33번에서 3번, 신청 목록에서 64번에서 6번으로 줄었다. 응답 내용은 유지됐고, 항목 수를 늘려도 SELECT 횟수가 일정하게 유지되는 것을 확인했다.

이 경험에서 배운 것은 이상 징후를 출발점으로 가설을 세우고, 기존 코드와 실제 동작을 확인하면서 원인을 좁혀 가는 과정이었다. OOM을 조사하면서 발견한 N+1에 대해, 담당한 두 API의 반복 조회를 제거하고 그 결과를 수치로 검증할 수 있었다.

All Posts

© 2026 DooYee. All rights reserved.