#incomplete #study #25-1 #gdgoconsoganguniversity #csdeepdive
> [!warning]
> 이 페이지는 아직 미완성입니다.
> [!Introduction]
> 최근 스프링(Spring)과 스프링 부트(Spring Boot)를 배우기 시작했다. 이 둘은 백엔드 개발자라면 반드시 마주치게 된다고 할 수 있을 정도로 대중적인 웹 프레임워크인데, 배우면서도 이 방대하고도 무거운 녀석이 어떻게 돌아가는지 신기할 지경이다.
> 풀리지 않는 의문을 남겨둔 채로 기술을 공부하다보면 결국 머지 않아 막히기 마련이다. 스프링과 스프링 부트의 작동 방식을 이해하기 위한 큰 과정의 일환으로, 이번에는 스프링과 스프링 부트라는 웹 프레임워크의 기반이 되는 프로그래밍 언어 자바(Java)에서 메모리 관리를 어떻게 하는지 알아보았다.
## 자바가 메모리를 관리하는 방법
### 자바의 메모리 관리 특징
자바는 C나 C++과 달리 개발자가 직접 메모리를 할당하고 해제하지 않는다. 대신 JVM(Java Virtual Memory)이 메모리 할당과 가비지 컬렉션(Garbage Collection, GC)을 자동으로 해주는데, 이는 자바가 관리형 언어(managed language)로서 가지는 중요한 특징이다. JVM은 프로그램 실행 중 필요한 메모리를 할당하고, 더 이상 사용되지 않는 객체의 메모리를 자동으로 회수한다. 이러한 자동화된 메모리 관리는 개발자가 메모리 관리에 신경 쓰는 수고를 덜어주며, 메모리 누수나 댕글링 포인터(dangling pointer)와 같은 문제를 줄여 프로그램의 안정성을 높이고, 개발자는 복잡한 메모리 관리 로직 대신 비즈니스 로직에 집중할 수 있게 된다.
### 메모리 관리의 중요성
효율적인 메모리 관리는 자바 프로그램의 성능과 안정성에 직접적인 영향을 미친다. 효율적인 메모리 사용은 프로그램의 실행 속도를 향상시키며, 불필요한 메모리 할당을 줄이고, 메모리 관련 오류를 줄여 프로그램의 안정성을 높인다. 반면, 부적절한 메모리 관리는 성능 저하, 프로그램 실패, 비효율적인 자원 사용으로 이어질 수 있다. 따라서 자바 개발자는 JVM의 메모리 구조와 관리 방식을 이해하고, 이를 활용하여 최적화된 프로그램을 작성할 필요가 있다.
## JVM 메모리 구조
### 메모리 영역 개요
JVM의 메모리는 크게 네이티브 영역과 VM 영역으로 나뉜다. 네이티브 영역에는 텍스트, 데이터, 힙, 스택 등이 존재하며, 이는 일반적인 프로그램 실행에 필요한 메모리 영역이다. VM 영역은 자바 클래스를 구동하기 위한 전용 메모리 영역으로, 자바 애플리케이션의 실행에 필요한 다양한 데이터를 저장한다. JVM 메모리는 크게 메서드 영역(Method Area), 힙 영역(Heap Area), 스택 영역(Stack Area), PC 레지스터(Program Counter Register), 네이티브 메서드 스택(Native Method Stack)으로 이루어져 있고, 각 영역은 프로그램 실행에 있어 서로 다른 목적을 가지고 다른 유형의 데이터를 저장한다.
### 메서드 영역(Method Area)
메서드 영역은 클래스와 인터페이스의 구조적 정보, 메서드와 필드 정보, 상수 풀(Constant Pool), 정적 변수(static variables) 등이 저장되는 공간이다. 이 영역은 모든 스레드가 공유하는 영역으로, 클래스 로더에 의해 로드된 클래스의 메타데이터가 저장된다. 예를 들어, 다음 코드에서 `CONSTANT`와 `staticVariable`은 메서드 영역에 저장된다.
```java
public class JVMMemoryExample {
private static final String CONSTANT = "상수 풀";
private static int staticVariable = 0;
// ...
}
```
### 힙 영역(Heap Area)
힙 영역은 객체와 배열이 생성되는 공간으로, 가비지 컬렉션의 대상이 된다. 이 영역은 모든 스레드가 공유하며, 프로그램에서 `new` 연산자를 통해 생성된 모든 객체 인스턴스가 저장된다.
![[R1280x0.png]]
힙 영역은 크게 다음과 같은 세부 영역으로 구분된다:
1. Young Generation(영 제너레이션): 새롭게 생성된 객체가 할당되는 영역
- Eden 영역: 새로 생성된 객체가 처음 할당되는 공간
- Survivor 영역: 가비지 컬렉션 후 살아남은 객체가 이동하는 공간
2. Old Generation(올드 제너레이션): Young Generation에서 일정 시간 이상 살아남은 객체가 이동하는 영역
3. Permanent Generation(퍼머넌트 제너레이션): 클래스와 메서드의 메타데이터, 상수 풀 등이 저장되는 영역 (Java 8부터는 Metaspace로 대체됨)
힙 영역의 크기는 JVM 옵션을 통해 조정할 수 있으며, 이는 애플리케이션의 성능에 직접적인 영향을 미친다.
### 스택 영역(Stack Area)
스택 영역은 메서드 호출 시 생성되는 지역 변수, 매개변수, 메서드 호출 및 반환 정보, 리턴 값 등이 저장되는 공간이다. 각 스레드마다 독립적인 스택을 가지며, 메서드가 호출될 때마다 해당 메서드를 위한 스택 프레임(Stack Frame)이 생성된다. 스택 영역의 특징은 후입선출(LIFO: Last In, First Out) 구조로, 메서드 호출이 완료되면 해당 스택 프레임이 제거된다. 이는 메모리 관리가 매우 단순하고 효율적으로 이루어짐을 의미한다.
### PC 레지스터(Program Counter Register)
PC 레지스터는 현재 실행 중인 JVM 명령의 주소를 가리키는 영역으로, 각 스레드마다 독립적인 PC 레지스터를 가진다. PC 레지스터는 스레드가 어떤 명령을 실행해야 할지를 추적하는 역할을 하는데, JVM 명령어의 실행 순서를 제어하고, 현재 실행 중인 명령어의 위치를 기억하는 데 사용된다. 이는 자바의 멀티스레딩 기능을 지원하는 데 중요한 역할을 한다.
## 변수와 객체의 메모리 할당
### 변수 유형별 저장 위치
자바에서 변수는 그 유형에 따라 다른 메모리 영역에 저장된다:
1. 클래스 변수(static 변수): 클래스가 메모리에 로드될 때 생성되며, 클래스의 모든 인스턴스에 의해 공유됩니다. 이 변수들은 메서드 영역에 저장된다.
2. 인스턴스 변수: 객체가 생성될 때 힙에 할당되며, 각 객체 인스턴스마다 고유한 값을 가진다.
3. 지역 변수: 메서드 내에서 선언되며, 메서드 호출 시 스택에 저장된다. 메서드가 종료되면 이 변수들은 소멸한다.
4. 매개 변수: 메서드 호출과 함께 전달되며, 메서드의 스택 프레임 내에 위치한다.
다음은 이러한 변수들의 메모리 저장 위치를 보여주는 예제 코드다:
```java
public class MemoryExample {
// 이 변수는 메서드 영역에 저장되는 정적 변수입니다.
static int staticVar = 10;
// 이 변수는 실행시 힙 영역에 저장되는 인스턴스 변수입니다.
int instanceVar;
// 생성자
public MemoryExample(int var) {
// 생성자의 매개변수는 스택에 저장됩니다.
this.instanceVar = var;
}
// 메서드 호출시 각 매개변수는 스택 영역에 저장됩니다.
public void show(int a) {
// 로컬 변수는 메서드 호출 시 스택 영역에 저장됩니다.
int localVar = a;
System.out.println("Static Variable: " + staticVar);
System.out.println("Instance Variable: " + instanceVar);
System.out.println("Local Variable: " + localVar);
}
}
```
### 객체 생성과 메모리 할당 과정
자바에서 객체를 생성하면 다음과 같은 과정으로 메모리가 할당된다:
1. 클래스 로딩: 해당 클래스가 아직 로드되지 않았다면, 클래스 로더가 클래스를 로드하고 메서드 영역에 클래스 정보를 저장한다.
2. 힙 메모리 할당: `new` 연산자가 호출되면 JVM은 힙 영역에 객체를 위한 메모리를 할당한다.
3. 객체 초기화: 할당된 메모리에 객체의 인스턴스 변수를 위한 공간이 할당되고 기본값으로 초기화된다.
4. 생성자 호출: 객체의 생성자가 호출되어 인스턴스 변수를 초기한다.
5. 참조 반환: 생성된 객체의 참조(메모리 주소)가 반환되어 변수에 저장된다.
예를 들어, 다음 코드에서 `String` 객체는 힙 영역에 생성되고, 이 객체의 참조는 `objectReference` 변수에 저장된다:
```java
String objectReference = new String("힙 영역에 생성된 객체");
```
## 가비지 컬렉션(GC)
### 가비지 컬렉션 개념
가비지 컬렉션(GC)은 자바의 자동 메모리 관리 기능으로, 더 이상 사용되지 않는 객체의 메모리를 자동으로 회수하는 프로세스이다. 이는 자바 가상 머신의 힙 영역에서 동적으로 할당했던 메모리 중 필요 없게 된 메모리 객체를 모아 정리하는 작업이다.
가비지 컬렉션의 기본 원칙은 다음과 같다:
1. 객체가 더 이상 참조되지 않으면 가비지로 간주한다.
2. JVM은 주기적으로 가비지 컬렉션을 실행하여 이러한 가비지 객체를 정리한다.
3. 가비지 컬렉션은 자동으로 수행되며, 개발자는 이 프로세스를 직접 제어할 수 없다(`System.gc()`를 호출할 수 있지만, 실행이 보장되지는 않는다).
가비지 컬렉션은 개발자가 명시적으로 메모리를 해제하지 않아도 되기 때문에 메모리 누수를 방지하는 데 도움이 된다.
### 힙 영역의 세부 구조와 가비지 컬렉션
앞서 언급한 바와 같이, JVM의 힙은 크게 Young Generation, Old Generation, Permanent Generation(Java 8부터는 Metaspace)으로 구분된다. 이 구조는 가비지 컬렉션 프로세스를 효율적으로 수행하기 위한 것이다.
1. Young Generation:
- Eden 영역: 새로 생성된 객체가 처음 할당된다.
- Survivor 영역(S0, S1): Eden 영역에서 살아남은 객체가 이동하는 공간으로, 두 개의 영역이 번갈아가며 사용된다.
1. Old Generation: Young Generation에서 계속 살아남은 객체들이 이동하는 영역이다. 일반적으로 Young Generation보다 크기가 크며, 가비지 컬렉션의 빈도가 낮다.
2. Metaspace(Java 8 이후) / Permanent Generation(Java 7 이전): 클래스와 메서드의 메타데이터가 저장되는 영역이다.
### 가비지 컬렉션 프로세스
가비지 컬렉션은 기본적으로 두 단계의 프로세스로 나눌 수 있다:
1. Minor GC: Young Generation(주로 Eden 영역)에서 발생하는 가비지 컬렉션으로, 빈번하게 발생하지만 처리 시간이 짧다.
- Eden 영역이 가득 차면 Minor GC가 트리거된다.
- 살아남은 객체는 Survivor 영역으로 이동한다.
- Survivor 영역에서 일정 횟수 이상 살아남은 객체는 Old Generation으로 이동하는데, 이를 'Promotion'이라고 부른다.
2. Major GC(Full GC): Old Generation에서 발생하는 가비지 컬렉션으로, 발생 빈도는 낮지만 처리 시간이 길다.
- Old Generation이 가득 차면 Major GC가 트리거된다.
- 전체 힙에 대한 가비지 컬렉션이 수행되므로 애플리케이션의 일시적인 중단(Stop-the-world)이 발생할 수 있다.
가비지 컬렉션 알고리즘으로는 Mark-and-Sweep, Copying, Mark-and-Compact 등 다양한 방식이 사용됩니다. 최신 JVM에서는 이러한 알고리즘을 조합한 Generational Garbage Collection을 주로 사용하다.
## 메모리 관리 최적화
### JVM 메모리 구조를 알아야 하는 이유
JVM 메모리 구조를 이해하는 것은 다음과 같은 이유로 중요하다:
1. 가비지 컬렉션(GC) 최적화: 객체의 생명주기와 메모리 할당 패턴을 이해하면 불필요한 객체 생성을 피하고, 메모리 사용률을 최적화하여 GC 오버헤드를 줄일 수 있다. 이는 애플리케이션의 성능과 반응성을 직접적으로 향상시킨다.
2. 스레드 관리 및 동기화: JVM 내에서 스레드는 스택 메모리를 통해 각각의 실행 컨텍스트를 가진다. 스레드 사이의 올바른 동기화와 데이터 공유를 관리하기 위해서는 스택과 힙 메모리의 작동 방식을 이해해야 하며, 이는 멀티스레드 애플리케이션의 동시성 문제를 방지하고 데이터 일관성을 유지하는 데 중요하다.
3. 성능 문제 진단 및 튜닝: 메모리 사용에 대한 통찰력은 성능 관련 문제를 진단하고 해결하는 데 필수적이다. JVM의 메모리 구조를 이해하면 `OutOfMemoryError`와 같은 메모리 문제를 해결하고, 애플리케이션의 성능을 튜닝하기 위한 기법을 마련할 수 있다.
### 메모리 최적화 방법
효율적인 자바 메모리 관리를 위한 몇 가지 방법은 다음과 같다:
1. 객체 생성 최소화:
- 불필요한 객체 생성을 피한다.
- 문자열 연산에는 StringBuilder/StringBuffer를 사용한다.
- 객체 풀링(Object Pooling)을 고려한다.
2. 적절한 JVM 옵션 설정:
- 애플리케이션 특성에 맞게 힙 메모리 크기(-Xms, -Xmx)를 설정한다.
- 적절한 가비지 컬렉터를 선택한다(예: G1GC, ParallelGC).
- 세대 간 객체 크기 비율을 조정한다.
3. 메모리 누수 방지:
- 사용하지 않는 객체에 대한 참조를 명시적으로 제거한다(null 할당).
- 자원(파일, 네트워크 연결 등)을 사용 후 적절히 닫는다(try-with-resources 사용).
- 강한 참조 대신 약한 참조(WeakReference)나 소프트 참조(SoftReference)를 고려한다.
4. 코드 최적화:
- 지역 변수의 범위를 최소화한다.
- 순환 참조를 피한다.
- 불변(immutable) 객체를 활용한다.
5. 프로파일링 도구 활용:
- JVisualVM, JProfiler 등의 도구를 사용하여 메모리 사용량을 모니터링한다.
- 메모리 누수 및 핫스팟을 식별하고 최적화한다.