N+1은 GC를 왜 자주 발생시킬까?🤔
Published:
목차
- 들어가며..
- 자바 가상 머신(JVM)의 동작 방식
- 힙(Heap) 영역
- Garbage Collection(GC)
- 가비지 컬렉션 동작 과정
- N+1 상황에서 메모리에 벌어지는 일
- N+1은 GC를 왜 자주 발생시킬까?🤔
- JVM은 GC를 어떻게 제어할까?🤔
- 결론
N+1기
계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔
N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)
N+1은 왜 JPA에서 발생할까?🤔(JPQL)
N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)
영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)
Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔
Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔
N+1은 GC를 왜 자주 발생시킬까?🤔
들어가며..
스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.
개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)
Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.
자바 가상 머신(JVM)의 동작 방식
자바 가상 머신(Java Virtual Machine)인 JVM의 역할은 자바 애플리케이션을 클래스 로더를 통해 읽어 자바 API와 함께 실행하는 것이다. 다음은 자바 소스 파일을 어떤 동작으로 코드를 읽는지에 대한 간단한 요약 도식이다.

- 자바 프로그램을 실행하면 JVM은 OS로부터 메모리를 할당받는다.
- 자바 컴파일러(javac)가 자바 소스코드(.java)를 자바 바이트 코드(.class)로 컴파일 한다.
- Class Loader는 동적 로딩을 통해 필요한 클래스들을 로딩 및 링크 하여 Runtime Data Area(실질적인 메모리를 할당 받아 관리하는 영역)에 올린다.
- Runtime Data Area에 로딩 된 바이트 코드는 Execution Engine을 통해 해석된다.
- 이 과정에서 Execution Engine에 의해 Garbage Collector의 작동과 Thread 동기화가 이루어진다.
런타임 데이터 영역 (Runtime Data Area)

런타입 데이터 영역은 쉽게 말하면 JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다. 런타임 데이터 영역은 위 그림과 같이 크게 Method Area, Heap Area, Stack Area, PC Register, Native Method Stack로 나눌 수 있다.
힙(Heap) 영역
힙 영역은 메서드 영역와 함께 모든 쓰레드가 공유하며, JVM이 관리하는 프로그램 상에서 데이터를 저장하기 위해 런타임 시 동적으로 할당하여 사용하는 영역이다. 즉, new 연산자로 생성되는 클래스와 인스턴스 변수, 배열 타입 등 Reference Type이 저장되는 곳이다. 당연히 Method Area 영역에 저장된 클래스만이 생성이 되어 적재된다.
유의할점은 힙 영역에 생성된 객체와 배열은 Reference Type으로서, JVM 스택 영역의 변수나 다른 객체의 필드에서 참조된다는 점이다. 만일 참조하는 변수나 필드가 없다면 의미 없는 객체가 되기 때문에 이것을 쓰레기로 취급하고 JVM은 쓰레기 수집기인 Garbage Collector를 실행시켜 쓰레기 객체를 힙 영역에서 자동으로 제거된다. 이처럼 힙 영역은 가비지 컬렉션에 대상이 되는 공간이다. 그리고 효율적인 가비지 컬렉션을 수행하기 위해서 세부적으로 다음과 같이 5가지 영역으로 나뉘게 된다.

이렇게 다섯가지 영역(Eden, survivor 0, survivor 1, Old, Permanent)으로 나뉜 힙 영역은 다시 물리적으로 Young Generation 과 Old Generation 영역으로 구분되게 되는데 다음과 같다.
- Young Generation : 생명 주기가 짧은 객체를 GC 대상으로 하는 영역
- Eden : new를 통해 새로 생성된 객체가 위치. 정기적인 쓰레기 수집 후 살아남은 객체들은 Survivor로 이동
- Survivor 0 / Survivor 1 : 각 영역이 채워지게 되면, 살아남은 객체는 비워진 Survivor로 순차적으로 이동
- Old Generation : 생명 주기가 긴 객체를 GC 대상으로 하는 영역. Youn Generation에서 마지막까지 살아남은 객체가 이동
위에 사진을 보면 JVM 메모리는 부분적으로 나누어져 있습니다. 큰 부분에서 보면 JVM Heap 메모리는 물리적으로 Young 영역과 Old 영역 두부분으로 나누어져 있습니다.
Young Generation
Young 영역은 새로운 객체가 생성되는 곳이다. Young 영역이 가득차게 되면 GC가 동작한다. 이 동작을 Minor GC라고 한다. Young 영역은 3가지 부분으로 나눌 수 있는데, Eden 공간과 두개의 Survivor 공간이다.

Young Generation의 흐름
최근에 만들어진 객체는 Eden 영역에 위치한다.

Eden 영역이 가득차면, Minor GC가 발생하는데, 여기서 살아남은 객체들은 survivor 공간 중 하나로 이동한다.

Minor GC는 survivor 공간도 같이 검사를 해서 다른 survivor 공간으로 이동시킨다. 그러므로 survivor 공간 두개 중 하나는 항상 비어있다.

여러 번의 Minor GC를 많이 했음에도 불구하고 살아남은 객체들은 Old 영역으로 이동한다. Minor GC를 당했지만 살아남은 객체는 나이가 든다. 몇 살이 먹으면 Old 영역으로 가야하는지 설정 해 줄 수 있다.
Old Generation
Old 영역에는 여러 번의 Minor GC를 겪고 살아남은 객체들이 있다. Old 영역에서 메모리가 가득차게되면 Major GC가 발생한다. Major GC는 Minor GC에 비해 많은 시간이 걸린다.
Garbage Collection(GC)
가비지 컬렉션(Garbage Collection, 이하 GC)은 자바의 메모리 관리 방법 중의 하나로 JVM(자바 가상 머신)의 Heap 영역에서 동적으로 할당했던 메모리 중 필요 없게 된 메모리 객체(garbage)를 모아 주기적으로 제거하는 프로세스를 말한다.

C/C++ 언어에서는 이러한 가비지 컬렉션이 없어 프로그래머가 수동으로 메모리 할당과 해제를 일일이 해줘야 했었다. 반면 Java에서는 가비지 컬렉터가 메모리 관리를 대행해주기 때문에 Java 프로세스가 한정된 메모리를 효율적으로 사용할수 있게 하고, 개발자 입장에서 메모리 관리, 메모리 누수(Memory Leak) 문제에서 대해 관리하지 않아도 되어 오롯이 개발에만 집중할 수 있다는 장점이 있다.
그러나 이런 만능 같은 가비지 컬렉션에도 단점이 존재한다. 자동으로 처리해준다 해도 메모리가 언제 해제되는지 정확하게 알 수 없어 제어하기 힘들며, 가비지 컬렉션(GC)이 동작하는 동안에는 다른 동작을 멈추기 때문에 오버헤드가 발생되는 문제점이 있다. 이를 전문적인 용어로 Stop The World라 한다.
Stop the World
모든 GC는 “Stop the World” 라는 이벤트가 발생한다. GC가 동작하면 GC를 제외한 모든 스레드가 멈추기 때문이다. Young 영역의 Minor GC는 객체는 수명이 짧고 많은 객체를 검사하지 않기 때문에 속도가 매우 빨라 거의 애플리케이션에 영향을 주지 않는다.
그러나 Major GC는 살아 있는 모든 객체를 검사해야 하기 때문에 오랜 시간이 걸린다. Major GC가 일어나면 애플리케이션이 아무런 동작을 하지 않기 때문에 Major GC가 일어나는 횟수를 최소화 해야한다. 만약 애플리케이션이 Major GC가 일어나는 횟수가 많다면 timeout 에러를 발생시킬 확률이 높다. GC가 동작하는 시간은 GC의 전략에 따라 다르다. 그래서 Stop the World를 발생시키는 횟수를 줄이기 위해서는 GC를 모니터링 하여 적절하게 튜닝하는 습관이 필요하다.
가비지 컬렉션 동작 과정

위 그림에서, Old 영역이 Young 영역보다 크게 할당되는 이유는 Young 영역의 수명이 짧은 객체들은 큰 공간을 필요로 하지 않으며 큰 객체들은 Young 영역이 아니라 바로 Old 영역에 할당되기 때문이다.
heap 메모리의 구조
JVM의 힙(heap) 영역은 동적으로 레퍼런스 데이터가 저장되는 공간으로서, 가비지 컬렉션에 대상이 되는 공간이다. Heap영역은 처음 설계될 때 다음의 2가지를 전제 (Weak Generational Hypothesis)로 설계되었다.
- 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
- 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.
즉, 객체는 대부분 일회성되며, 메모리에 오랫동안 남아있는 경우는 드물다는 것이다. 이러한 특성을 이용해 JVM 개발자들은 보다 효율적인 메모리 관리를 위해, 객체의 생존 기간에 따라 물리적인 Heap 영역을 나누게 되었고 Young 과 Old 총 2가지 영역으로 설계하였다.

- Young 영역(Young Generation)
- 새롭게 생성된 객체가 할당(Allocation)되는 영역
- 대부분의 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다.
- Young 영역에 대한 가비지 컬렉션(Garbage Collection)을 Minor GC라고 부른다.
- Old 영역(Old Generation)
- Young영역에서 Reachable 상태를 유지하여 살아남은 객체가 복사되는 영역
- Young 영역보다 크게 할당되며, 영역의 크기가 큰 만큼 가비지는 적게 발생한다.
- Old 영역에 대한 가비지 컬렉션(Garbage Collection)을 Major GC 또는 Full GC라고 부른다.
위 그림에서, Old 영역이 Young 영역보다 크게 할당되는 이유는 Young 영역의 수명이 짧은 객체들은 큰 공간을 필요로 하지 않으며 큰 객체들은 Young 영역이 아니라 바로 Old 영역에 할당되기 때문이다.
Young Generation의 3가지 영역

- Eden
- new를 통해 새로 생성된 객체가 위치
- 정기적인 쓰레기 수집 후 살아남은 객체들은 Survivor 영역으로 보냄
- Survivor 0 / Survivor 1
- 최소 1번의 GC 이상 살아남은 객체가 존재하는 영역
- Survivor 0 또는 Survivor 1 둘 중 하나는 꼭 비어 있어야 함
이렇게 하나의 힙 영역을 세부적으로 쪼갬으로서 객체의 생존 기간을 면밀하게 제어하여 가비지 컬렉터(GC)를 보다 정확하게 불필요한 객체를 제거하는 프로세스를 실행하도록 한다.
Minor GC 과정

Young Generation 영역은 짧게 살아남는 메모리들이 존재하는 공간이다. 모든 객체는 처음에는 Young Generation에 생성된다. Young Generation의 공간은 Old Generation에 비해 상대적으로 작기 때문에 메모리 상의 객체를 찾아 제거하는데 적은 시간이 걸린다.(작은 공간에서 데이터를 찾으니까) 이 때문에 Young Generation 영역에서 발생되는 GC를 Minor GC라 불린다.
1. 처음 생성된 객체는 Young Generation 영역의 일부인 Eden 영역에 위치한다.

2. 객체가 계속 생성되어 Eden 영역이 꽉차게 되고 Minor GC가 실행한다.

3. Mark 동작을 통해 reachable 객체를 탐색한다.

4. Eden 영역에서 살아남은 객체는 1개의 Survivor 영역으로 이동한다.

5. Eden 영역에서 사용되지 않는 객체(unreachable)의 메모리를 해제(sweep)한다.

6. 살아남은 모든 객체들은 age값이 1씩 증가한다.

7. 또다시 Eden 영역에 신규 객체들로 가득 차게 되면 다시한번 minor GC 발생하고 mark 한다.

8. marking 한 객체들을 비어있는 Survival 1으로 이동하고 sweep한다.

9. 다시 살아남은 모든 객체들은 age가 1씩 증가한다.

10. 이러한 과정을 반복한다.

Major GC 과정

Old Generation은 길게 살아남는 메모리들이 존재하는 공간이다. Old Generation의 객체들은 거슬러 올라가면 처음에는 Young Generation에 의해 시작되었으나, GC 과정 중에 제거되지 않은 경우 age 임계값이 차게되어 이동된 녀석들이다. 그리고 Major GC는 객체들이 계속 Promotion되어 Old 영역의 메모리가 부족해지면 발생하게 된다.
1. 객체의 age가 임계값(여기선 8로 설정)에 도달하게 되면, 이 객체들은 Old Generation 으로 이동된다. 이를 promotion이라 부른다.

2. 위의 과정이 반복되어 Old Generation 영역의 공간(메모리)가 부족하게 되면 Major GC가 발생되게 된다.

Major GC는 Old 영역은 데이터가 가득 차면 GC를 실행하는 단순한 방식이다. Old 영역에 할당된 메모리가 허용치를 넘게 되면, Old 영역에 있는 모든 객체들을 검사하여 참조되지 않는 객체들을 한꺼번에 삭제하는 Major GC가 실행되게 된다. 하지만 Old Generation은 Young Generation에 비해 상대적으로 큰 공간을 가지고 있어, 이 공간에서 메모리 상의 객체 제거에 많은 시간이 걸리게 된다.
여기서 앞서 소개했던 Stop The World 문제가 발생한다. Major GC가 일어나면 Thread가 멈추고 Mark and Sweep 작업을 해야 해서 CPU에 부하를 주기 때문에 멈추거나 버벅이는 현상이 일어나기 때문이다.
Minor GC/Major GC 정리
| 구분 | Minor GC | Major GC |
|---|---|---|
| 대상 | Young Generation | Old Generation |
| 실행 시점 | Eden 영역이 꽉 찬 경우 | Old 영역이 꽉 찬 경우 |
| 실행 속도 | 빠르다 | 느리다 |
N+1 상황에서 메모리에 벌어지는 일
N+1 문제는 보통 1번의 부모 조회 쿼리 이후, 연관된 자식 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 현상이다. 이때 메모리에서는 다음과 같은 ‘객체 파티’가 열린다.
폭발적인 임시 객체 생성
하나의 Join 쿼리로 가져오면 DB 드라이버 수준에서 효율적으로 처리될 데이터가 N번의 개별 쿼리로 쪼개지면서 매번 다음과 같은 객체들이 생성된다.
- JDBC 관련 객체: ResultSet, PreparedStatement 등 통신을 위한 임시 객체
- Entity & Proxy 객체: Hibernate 같은 ORM이 상태 관리를 위해 만드는 수많은 프록시와 실제 엔티티 객체
- DTO 및 Collection: 데이터를 담기 위해 생성되는 리스트와 변환 객체들
Eden 영역의 급격한 포화
N이 커질수록(예: 1,000개의 게시글에 대한 댓글 조회) 순식간에 수만 개의 객체가 Eden 영역에 할당된다.
N+1은 GC를 왜 자주 발생시킬까?🤔
결론부터 말하면 메모리 할당 속도가 해제 속도보다 빨라지기 때문이다.
잦은 Minor GC 유발
Eden 영역은 크기가 제한적이다. N+1로 인해 단시간에 엄청난 양의 임시 객체가 쏟아져 들어오면 Eden이 순식간에 꽉 차버리고, JVM은 이를 비우기 위해 Minor GC를 매우 빈번하게 호출한다.
조기 승격(Premature Promotion) 문제
객체 생성 속도가 빨라 Eden이 너무 빨리 차버리면, 아직 참조가 끝나지 않은(쿼리 수행 중인) 객체들이 충분히 성숙하지 않았음에도 Old Generation으로 강제 이동한다. Old Gen이 꽉 차면 훨씬 파괴적인 Major GC(Full GC)가 발생하며 서버가 눈에 띄게 버벅거리게 된다.
Object Churn 발생
대부분의 객체는 짧게 살고 죽는다. N+1 상황에서 생성된 객체는 요청 처리 중에만 사용되고 이후 바로 GC 대상이 된다. 이런 객체를 Short-lived object라고 한다. 이 현상을 CS에서는 Object Churn라고 한다. 객체가 빠르게 생성되고 빠르게 사라지는 패턴 N+1은 대표적인 high churn 상황이다.
GC Pause 증가
아무리 짧은 Minor GC라도 발생 시 애플리케이션 스레드가 잠시 멈춥니다. N+1로 인해 GC 빈도가 초당 수십 번으로 늘어나면, 전체적인 처리량(Throughput)이 급감한다. 이 때, Stop-the-world 발생하며 애플리케이션이 멈춘다. 결국 응답 속도가 느려진다.(Latency 증가)
JVM은 GC를 어떻게 제어할까?🤔
현재의 JVM은 다양한 GC 알고리즘을 기반으로, 애플리케이션의 상태에 따라 가장 적절한 시점에 GC를 수행하도록 설계되어 있다. 그래서 일반적으로는 개발자가 System.gc()를 통해 GC를 직접 호출하는 방식은 지양되는 편이다.
그렇다면 GC를 제어해야 하는 상황에서는 어떻게 접근해야 할까 고민해보았는데, 직접 호출하기보다는 다음과 같이 간접적으로 영향을 주는 것이 현실적인 방법이라는 점을 알게 되었다.
- 객체 생성과 생명주기를 관리
- 불필요한 참조를 줄이기
- JVM 옵션을 통해 GC 동작을 튜닝
다만 이러한 튜닝은 단순히 코드 레벨에서 해결되는 문제가 아니라, 운영 환경과 모니터링이 함께 고려되어야 하기 때문에 실무에서는 더욱 신중하게 접근해야 할 영역이라는 생각도 들었다.
메모리가 충분한 시대에도 GC는 왜 필요할까?🤔
요즘은 하드웨어 사양이 크게 향상되어 과거처럼 메모리 부족을 걱정하지 않아도 되는 환경이 되었다. 그렇다면 GC에 무관심해도 되는 것은 아닐까?란 생각이 들었다.
하지만 찾아보니 오히려 메모리 용량이 커질수록 GC의 비용 또한 함께 증가할 수 있다는 점이 흥미로웠다. GC 과정에서 발생하는 STW(Stop-The-World)는 애플리케이션 실행을 일시적으로 멈추게 하는데, 관리해야 할 메모리 범위가 넓어질수록 한 번의 STW가 미치는 영향도 그만큼 커지기 때문이다.
이 흐름을 따라가다 보니 자연스럽게 “어떻게 하면 GC를 덜 발생시킬 수 있을까”라는 방향으로 고민이 이어졌다. GC를 완전히 없애는 것이 목표가 아니라, 불필요한 객체 생성을 줄이고 객체의 생명주기를 짧고 명확하게 관리하여 GC가 과도하게 발생하지 않도록 하는 것이 더 현실적인 목표라고 생각한다.
결국 이 고민의 끝은 메모리 최적화로 이어졌다. 낭비하는 메모리를 최대한 줄이는 것이 GC의 발생 빈도를 낮추고, 나아가 STW로 인한 성능 저하를 최소화하는 방향으로 귀결되었다.
결론
N+1이 왜 GC를 자주 발생시키는가에 대한 의문으로 시작한 고민이었지만, 결국 메모리 최적화로 수렴된다는 점이 흥미로웠다. N+1 문제를 해결하는 것 자체가 불필요한 객체 생성을 줄이는 일이고, 그것이 곧 GC 부담을 낮추는 메모리 최적화이기도 하다.
참고
Gemini
ChatGPT
Claude.ai
N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)
☕ JVM 내부 구조 & 메모리 영역 💯 총정리
자바의 JVM 구조와 Garbage Collection
☕ 가비지 컬렉션 동작 원리 & GC 종류 💯 총정리
[10분 테코톡] 🐥엘리의 GC
Garbage-First (G1) Garbage Collector
