N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)

4 minute read

Published:

목차

  1. 들어가며..
  2. N+1은 단순히 쿼리 문제일까?🤔
  3. JVM 메모리 구조
  4. N+1 발생 시 실제 내부의 동작 과정
  5. N+1은 왜 JPA에서 발생할까?🤔
  6. 결론

N+1기

계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔
N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)
N+1은 왜 JPA에서 발생할까?🤔(JPQL)
N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)
영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)
Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔
Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔


들어가며..

스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.

개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)

Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.


N+1은 단순히 쿼리 문제일까?🤔

N+1이 그저 ‘쿼리가 많이 나가서 느리다’로 알려져 있다. 하지만 실제 운영 환경에서는 다음과 같은 문제 상황이 있다.

  • 객체가 너무 많이 생성된다
  • 영속성 컨텍스트가 비대해진다
  • GC가 자주 돈다

즉, DB 문제와 JVM 메모리 문제가 동시에 발생한다.

JVM 메모리 구조

JVM 메모리 구조

메서드 영역 (Method Area)

메서드 영역은 JVM이 시작될 때 생성되는 공간으로 바이트 코드(.class)를 처음 메모리 공간에 올릴 때 초기화되는 대상을 저장하기 위한 메모리 공간이다. 모든 쓰레드가 공유하는 메모리 영역이다. JVM이 동작하고 클래스가 로드될 때 적재되서 프로그램이 종료될 때까지 저장 된다. 메소드 영역은 클래스, 인터페이스, 메소드, 필드, Static 변수 등의 바이트 코드를 보관한다. 메서드 영역(Method Area) 은 Class Area 나 Static Area 로도 불리운다.

힙 영역 (Heap Area)

힙 영역은 메서드 영역와 함께 모든 쓰레드가 공유하며, JVM이 관리하는 프로그램 상에서 데이터를 저장하기 위해 런타임 시 동적으로 할당하여 사용하는 영역이다. 모든 쓰레드가 공유하며, new 키워드로 생성된 객체와 배열이 생성된다. 또한, 메소드 영역에 로드된 클래스만 생성이 가능하고 Garbage Collector가 참조되지 않는 메모리를 확인하고 제거하는 영역이다.

스택 영역 (Stack Area)

메서드 호출 시마다 각각의 스택 프레임(그 메서드만을 위한 공간)이 생성된다. 스택 영역은 int, long, boolean 등 기본 자료형을 생성할 때 저장하는 공간으로, 임시적으로 사용되는 변수나 정보들이 저장되는 영역이다.


N+1 발생 시 실제 내부의 동작 과정

다음의 코드를 실행한다고 가정하자.

List<Order> orders = orderRepository.findAll();

for (Order order : orders) {
    order.getMember().getName();
}

1. Order 조회

이미 여기서 객체 N개가 생성된다.

  1. Order 엔티티 N개 생성
  2. 전부 힙 메모리에 올라감
  3. 전부 영속성 컨텍스트(1차 캐시)에 저장됨

2. 지연 로딩 트리거

order.getMember() 호출 순간 Member도 N개 생성된다.

  1. 프록시 객체 초기화
  2. 실제 Member 엔티티 생성
  3. 영속성 컨텍스트에 추가
  4. 힙 메모리에 추가

결과적으로 생성되는 객체 수

대상개수
Order 엔티티N
Member 엔티티N
프록시 객체N
내부 Hibernate 관리 객체N 이상 (추가 메모리 사용 발생)

최소 2N ~ 4N개의 객체를 생성한다. 만약 N = 10,000이면이라면 20,000~40,000개의 객체가 생성된다.


N+1은 메모리에 어떤 영향을 미칠까?🤔

메모리 관점에서의 N+1 문제

힙 메모리에 객체가 계속 쌓인다

JPA는 DB에서 가져온 데이터를 엔티티 객체로 변환해서 힙에 올린다. 예를 들어 Order 100개 조회하면 각 Order마다 Member lazy loading 발생한다. 그러면 Order 객체 100개와 Member 객체 100개(혹은 그 이상)가 전부 힙에 올라간다.

특히 문제는 다음과 같은 경우이다.

  • 같은 Member를 여러 번 조회하면?
  • JPA 1차 캐시에 없다면?
  • 세션이 길다면?

영속성 컨텍스트(= 1차 캐시)도 힙이다

JPA의 영속성 컨텍스트는 다음과 같은 형태로 힙 메모리 안에 존재한다.

Map<엔티티ID, 엔티티객체>

N+1이 발생하면 다음과 같은 이유로 메모리 점유가 계속 유지된다.

  • 엔티티가 하나씩 조회될 때마다 영속성 컨텍스트에 등록됨
  • 트랜잭션이 끝날 때까지 안 지워짐​

GC 부담 증가

객체가 많이 생성되면 다음과 같은 상황이 발생한다. 이는 결국 GC 압박 문제로 이어질 수 있다.

  • Minor GC 자주 발생
  • Eden → Survivor → Old 영역 이동
  • Old 영역까지 가면 Full GC 위험

GC 관점에서의 N+1

객체가 과도하게 생성되면 다음과 같은 GC 흐름이 발생하며, 이는 트랜잭션 처리 시스템에 치명적인 성능 저하를 유발할 수 있다.

  1. Eden 영역 가득 참
  2. Minor GC 자주 발생
  3. Survivor 영역 이동
  4. 오래 살아남으면 Old 영역 승격
  5. Old 영역 차면 Full GC
  6. Full GC로 인한 STW(Stop The World)

Fetch Join이 메모리에도 좋은 이유

select o from Order o
join fetch o.member

객체 생성 패턴이 예측 가능해지면서 GC 압박이 완화된다.

  • 한 번의 SQL
  • 프록시 초기화 과정 줄어듦
  • 추가 쿼리 없음
  • 불필요한 지연 초기화 없음
  • 트랜잭션 내 객체 생성 흐름이 단순해짐

결론

N+1은 단순히 “쿼리가 많이 나간다”가 아니라 “객체 그래프를 제어하지 못해서 JVM 메모리를 비효율적으로 사용한다”는 문제이다. N+1은 힙에 객체를 대량으로 생성하므로 Fetch Join은 단순히 DB 최적화가 아니라 힙 메모리 최적화이기도 하다.

참고

Gemini
ChatGPT
Claude.ai
JVM의 메모리 구조
스프링 H2(+ Hibernate)
JVM 메모리 구조란? (JAVA)
JVM 내부 구조 & 메모리 영역 💯 총정리