0. 들어가며
JVM 위에서 실행되는 Java는 메모리 구조를 명확히 이해해야 성능/안정성 문제를 잡기 쉽다.
특히 Memory Leak은 Java는 GC가 있어서 메모리 누수 안 생긴다는 오해를 깨야 할 중요한 주제이다.
이 글에서는 JVM의 메모리 구조를 먼저 살펴보고, 메모리 누수가 발생하는 원인과 예시를 함께 정리한다.
1. JVM 메모리 구조 개요
JVM은 실행 중 다음과 같은 메모리 영역을 사용한다

- Method Area (메서드 영역)
- Heap (힙)
- Stack (스택)
- PC Register
- Native Method Stack
하지만 일반적으로 개발자 입장에서 중요한 구조는 Heap, Stack, Method Area이다.
2. 메모리 영역 설명
각 영역은 서로 다른 책임을 가지고 있으며, 변수나 객체가 어디에 저장되느냐에 따라 생명 주기와 GC 대상 여부도 달라진다.
2.1 Method Area (메서드 영역)
클래스 단위로 관리되는 메모리 영역
- JVM이 .class 파일을 로드할 때, 해당 클래스의 메타 정보(이름, 메서드, static 변수, 상수 풀 등)가 이곳에 저장
- static 변수 역시 이 영역에 저장되며, 클래스가 처음 로드될 때 단 한 번 초기화
- 모든 스레드가 공유하는 공간이다.
- 메모리 누수가 발생할 가능성은 낮지만, 너무 많은 클래스를 동적으로 로드하거나 제거하지 않으면 PermGen(구버전) 또는 Metaspace(현재 JDK)가 가득 차는 문제가 생길 수 있다.
2.2 Heap (힙)
JVM에서 가장 큰 메모리 영역이며, 대부분의 메모리 문제가 발생하는 공간이다.
- 애플리케이션에서 생성한 모든 인스턴스 객체(new로 생성된 것)가 이 영역에 저장.
- GC(Garbage Collector)의 대상이며, GC는 이 영역만 정리한다.
- Strong Reference로 연결된 객체는 GC 대상이 되지 않기 때문에, 참조를 끊지 않으면 메모리 누수(Memory Leak)가 발생할 수 있다.
- Heap은 Young / Old 영역으로 나뉘며, GC 동작과 성능 튜닝 시 중요한 개념이다.
2.3 Stack (스택)
메서드 호출 시 생성되는 일시적인 메모리 영역이다.
- 각 스레드마다 독립적인 Stack 공간을 가진다.
- 메서드가 호출될 때 프레임(stack frame)이 생성되고, 그 안에 지역 변수, 매개변수, 리턴 주소 등이 저장.
- 메서드가 종료되면 해당 프레임은 자동으로 사라지며, GC가 개입하지 않아도 정리.
- 지역 변수는 초기화를 반드시 해야 하며, 초기화하지 않고 사용하려 하면 컴파일 에러가 발생한다.
- Stack 영역은 크기가 작기 때문에, 재귀 호출이 너무 깊거나 무한 루프가 있으면
StackOverflowError
가 발생할 수 있다.
2.4 PC Register
각 스레드마다 하나씩 존재하는 작은 메모리 공간
- 현재 실행 중인 명령어의 주소(바이트코드 라인 번호)를 저장한다.
- JVM이 어떤 명령어를 실행할 차례인지 추적하기 위해 사용.
- 대부분의 Java 개발자에게는 직접적으로 드러나지 않는 내부적인 영역이다.
2.5 Native Method Stack
자바가 아닌 C, C++ 등 네이티브 코드(JNI)를 호출할 때 사용하는 스택이다.
- 자바 애플리케이션에서 System.loadLibrary()를 통해 외부 라이브러리를 사용할 때 활용.
- 일반적인 Java 코드만 사용하는 경우에는 이 영역을 직접 다룰 일은 거의 없다.
3. Java에서 Memory Leak이란?
Java는 자동으로 메모리를 회수해주는 Garbage Collector(GC)가 있어도 Memory Leak(메모리 누수)가 얼마든지 발생할 수 있다.
📌 Memory Leak이란, 더 이상 사용하지 않지만 여전히 참조되고 있어 GC가 회수하지 못하는 객체가 Heap에 남아 있는 상태를 말한다.
🧐 그럼 왜 발생할까??
JVM의 Garbage Collector는 참조가 완전히 끊긴 객체만 회수한다.
따라서 실제로는 필요 없는 객체라도 참조가 남아 있으면 메모리에서 제거되지 않아 누수가 발생한다.
4. Java에서 Memory Leak이 발생하는 주요 예시
4.1 정적(static) 필드에서 객체 참조 유지
- 문제: static 필드가 객체를 참조하면, 프로그램이 종료될 때까지 해당 객체가 GC 대상이 되지 않음
- 원인: static 변수는 JVM의 Method Area에 남아 클래스가 살아 있는 한 계속 유지됨
- 해결 방법: 사용이 끝난 객체는
null
로 명시하거나, WeakReference
등 약한 참조로 관리
4.2 파일/소켓/DB 등 자원을 닫지 않음
- 문제: 파일, 네트워크 연결, DB 커넥션 등의 리소스를 닫지 않으면 메모리에 누적됨
- 원인: 자원 객체가 계속 참조되며 해제되지 않음 → Heap에 남아있게 됨
- 해결 방법:
try-with-resources
또는 finally
블록으로 명시적으로 자원 해제
4.3 equals() / hashCode() 잘못 구현
- 문제: 컬렉션에서 중복 제거나 객체 삭제가 제대로 되지 않음 → 객체가 계속 쌓임
- 원인:
equals()
와 hashCode()
가 일관성 없이 구현되어 객체 관리가 실패함
- 해결 방법: 두 메서드는 항상 함께 정확히 구현 (IDE 자동 생성 권장)
4.4 캐시 설정 부적절
- 문제: 캐시에 저장된 객체가 제거되지 않아 메모리 점유 지속
- 원인: 적절한 만료 조건 또는 제거 정책이 없는 경우
- 해결 방법: TTL(Time-To-Live), LRU 정책,
WeakHashMap
등 활용
4.5 리스너나 콜백 제거하지 않음
- 문제: 리스너/콜백이 계속 참조를 유지하면서 메모리에 남아 있음
- 원인: 등록한 후
removeListener()
등으로 제거하지 않음
- 해결 방법: 작업이 끝난 뒤 리스너 제거 코드 반드시 작성
5. 마무리
Java는 GC 덕분에 메모리를 자동으로 관리해주지만, 개발자가 참조를 명확히 끊지 않으면 메모리 누수는 언제든지 발생할 수 있다.
JVM의 메모리 구조를 이해하고 객체의 생명 주기를 신경 써야한다.