스택과 힙 관리
내가 만든 변수(데이터)를 어느 영역(Stack vs Heap)에 머물게 할 것인가, 그리고 얼마나 오래 머물게 할 것인가"를 통제하는 게 관리포인트
원칙 1. 설계: 스택(Stack)을 최대한 활용하기
스택은 '자동 청소 구역'입니다. 연산이 끝나면 즉시 사라지기 때문에 메모리 부담이 제로에 가깝습니다.
- 개발자의 역할: 변수의 **Scope(범위)**를 최소한으로 줄입니다.
- 효과: 필요한 동안만 스택에 머물다 바로 사라지게 하여, 힙(Heap) 메모리를 사용하지 않고도 연산을 끝냅니다. 이것이 가장 효율적인 메모리 관리입니다.
원칙 2. 최적화: 힙(Heap)의 수명 관리하기
힙은 '공용 저장소'이며 GC가 청소해줘야 하는 구역입니다.
- 개발자의 역할: * 불필요한 new(객체 생성)를 남발하지 않습니다.
- 다 쓴 객체는 **참조(Reference)**를 끊어줍니다. (예: 변수에 null을 대입하거나, 리스트에서 제거)
스택과 힙 특성
스택(Stack): 정해진 규격의 서랍장
스택은 컴파일 시점에 이미 "이 변수는 몇 바이트를 쓸 거야"라고 결정된 데이터들이 들어가는 곳입니다.
- 특징: 정해진 순서대로 쌓이고, 함수가 끝나면 통째로 날아갑니다.
- 저장 데이터: int, double, boolean 같은 기본형 타입들. 이들은 무조건 4바이트, 8바이트처럼 크기가 고정되어 있습니다.
- 효율성: 크기가 딱딱 정해져 있으니 컴퓨터가 고민할 필요 없이 아주 빠르게 데이터를 읽고 씁니다.
힙(Heap): 자유로운 광장 (동적 할당)
반면 힙은 말씀하신 대로 "데이터가 얼마나 커질지 모르는" 애들을 위해 비워둔 넓은 공간입니다.
- 특징: 실행 도중(new 키워드를 쓸 때)에 공간이 할당됩니다. 데이터가 늘어나거나 줄어드는 것에 유연하게 대응할 수 있습니다.
- 저장 데이터: String, List, 그리고 우리가 직접 만든 Object. 유동적인 데이터는 스택에 가둘 수 없습니다.
- 관리 방식: 공간을 자유롭게 쓰는 대신, 다 쓴 데이터를 치우는 **가비지 컬렉터(GC)**라는 환경 미화원이 필요합니다.
참조형(Integer 등)은 왜 스택에 직접 못 저장할까?
여기서 아까 배운 내용과 연결됩니다.
- int (4바이트): 크기가 딱 정해져 있습니다. 서랍장(Stack) 칸 크기가 일정해서 그냥 쏙 집어넣으면 됩니다.
- Object / Integer: 객체 안에는 데이터뿐만 아니라 메서드 정보, 상속 정보 등 크기가 제각각입니다. 서랍장에 넣기엔 너무 크거나 크기가 자꾸 변할 수 있죠.
- 그래서: 실제 덩어리는 넓은 마당(Heap)에 던져두고, 서랍장(Stack)에는 그 덩어리가 마당 어디에 있는지 적힌 **'작은 쪽지(주소값)'**만 넣어두는 겁니다.
왜 메모리 구조까지 알아야 하는가 (다시 강조!)
이걸 모르면 나중에 **'Casting(형변환)'**이나 **'Overflow(숫자가 범위를 넘치는 현상)'**를 이해할 수 없습니다.
- 예: int에 너무 큰 숫자를 넣어서 32비트가 꽉 차버리면, 맨 앞 비트가 넘어가면서 갑자기 숫자가 음수로 변해버리는 현상이 발생합니다. 메모리 구조를 모르는 사람은 "어? 자바 버그 아냐?"라고 하겠지만, 구조를 아는 사람은 "아, 비트가 밀렸구나!"라고 바로 알 수 있죠.
1. 기본형 (Primitive Type)
기본형은 실제 연산에 사용되는 값을 변수 안에 직접 저장합니다.
- 종류: int, double, boolean, char, byte, short, long, float (총 8개)
- 특징:
- 스택(Stack) 메모리에 값이 직접 저장됩니다.
- 비어있을 수 없으며(null 불가), 기본값이 존재합니다 (예: int는 0).
- 메모리 크기가 정해져 있어 속도가 매우 빠릅니다.
2. 참조형 (Reference Type)
기본형 8개를 제외한 모든 타입은 참조형입니다. 값이 저장된 메모리 주소를 저장합니다.
- 종류: String, Array, List, 그리고 우리가 직접 만든 모든 클래스(Object).
- 특징:
- 실제 데이터(객체)는 힙(Heap) 메모리에 저장됩니다.
- 변수(스택 메모리)에는 그 데이터가 어디 있는지 알려주는 **'주소값'**이 저장됩니다.
- 값이 없음을 의미하는 null을 가질 수 있습니다.
int 3 (기본형)
- 저장 방식: Stack 영역에 3이라는 이진수 값을 직접 때려 박습니다.
- 메모리: 딱 4바이트만 차지하고 끝납니다.
- 속도: 주소를 타고 갈 필요 없이 바로 꺼내 쓰기 때문에 엄청나게 빠릅니다.
💡 Integer 3 (참조형 / 래퍼 클래스)
- 저장 방식: 1. Heap 영역에 3을 가진 '객체(Object)'를 만듭니다. 2. Stack 영역에는 이 객체가 어디에 있는지 알려주는 주소값(예: 0x100)을 저장합니다.
- 메모리: 데이터 값(4바이트) + 객체 정보(헤더 등) + 주소값까지 포함해서 int보다 훨씬 많은 메모리를 씁니다.
- 속도: Stack에서 주소를 읽고 → Heap으로 찾아가서 → 값을 꺼내야 하므로 상대적으로 느립니다.
이걸 왜 알아야 하나요? (차이점의 실체)
단순히 속도 문제라면 요즘 컴퓨터가 좋아서 무시할 수도 있겠지만, 진짜 이유는 다른 곳에 있습니다.
① null을 넣을 수 있는가?
- int a = null; → 컴파일 에러! (기본형은 무조건 숫자가 있어야 함)
- Integer b = null; → 가능! (값이 없다는 상태를 표현할 수 있음)
- 실무 활용: DB에서 값이 비어있는 경우(Null)를 처리할 때는 Integer를 써야 합니다.
② 컬렉션(List, Map)에 넣을 수 있는가?
- ArrayList<int> → 불가능! (자바의 리스트는 객체만 담을 수 있음)
- ArrayList<Integer> → 가능!
- 결론: 우리가 리스트나 맵을 쓰려면 반드시 Integer 같은 참조형을 이해해야 합니다.
③ 성능과 박싱(Boxing)
자바는 int를 Integer로 자동으로 바꿔주기도 합니다(Auto-boxing). 하지만 100만 번 반복하는 루프 안에서 나도 모르게 이 변환이 일어나고 있다면, 프로그램은 수백만 개의 객체를 생성하며 메모리를 낭비하게 됩니다.
결론부터 말씀드리면, 대규모 서비스일수록 이 차이는 선택이 아닌 필수 고려 사항입니다. 단순히 "코드의 깔끔함" 문제가 아니라, 서버의 돈(비용)과 직결되는 성능 문제로 이어지기 때문입니다.
대규모 서비스에서 왜 이 구분이 치명적인지 3가지 관점에서 설명해 드릴게요.
1. 메모리 사용량 (Memory Footprint)
대규모 서비스는 수백만 명의 유저 데이터를 처리합니다.
- int: 4바이트(32비트)의 메모리만 차지합니다.
- Integer: 객체이기 때문에 8바이트의 객체 헤더(객체 정보) + 4바이트의 데이터 + 패딩(메모리 정렬용 빈 공간) 등을 합쳐 약 16~24바이트를 차지합니다.
예시: 1,000만 개의 숫자를 리스트에 담는다면?
- int[] 사용 시: 약 40MB
- ArrayList<Integer> 사용 시: 약 200MB ~ 300MB 이상
같은 데이터인데 메모리 사용량이 5~8배까지 차이 날 수 있습니다. 서버 메모리는 곧 돈입니다.
2. 오토박싱(Auto-boxing)의 함정
자바는 편의를 위해 int와 Integer를 자동으로 변환해 줍니다. 하지만 대규모 트래픽이 발생하는 루프 안에서 이 일이 벌어지면 대참사가 일어납니다.
Integer sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += i; // 여기서 매번 새로운 Integer 객체가 생성됨! (불필요한 객체 100만 개 생성)
}
위 코드는 실행될 때마다 100만 개의 쓰레기 객체를 만듭니다. 이는 곧 **GC(Garbage Collection)**의 부담으로 이어지고, 서버가 일시적으로 멈추는(Stop-the-world) 현상을 유발해 서비스 응답 속도가 느려집니다.
3. CPU 캐시 효율 (Locality)
현대 CPU는 데이터를 읽을 때 주변 데이터도 같이 읽어오는 특징이 있습니다.
- int 배열: 메모리에 숫자들이 다닥다닥 붙어 있어서 CPU가 한 번에 쓱 긁어오기 좋습니다. (매우 빠름)
- Integer 배열: 주소값이 담겨 있어, 실제 값을 찾으려면 메모리의 여기저기(Heap)를 점프하며 다녀야 합니다. 이를 **'캐시 미스'**라고 하며 연산 속도를 크게 떨어뜨립니다.
1. 힙이 느린 진짜 이유: '캐시 미스(Cache Miss)'
컴퓨터에는 메모리(RAM)보다 훨씬 빠른 **CPU 캐시(L1, L2, L3)**라는 공간이 있습니다. CPU는 데이터를 읽을 때 메모리에서 하나만 가져오는 게 아니라, 그 주변 데이터까지 뭉텅이로 캐시에 담아둡니다.
- 스택/배열(int[]): 데이터가 메모리에 다닥다닥 붙어 있습니다. CPU가 한 번 뭉텅이로 읽어오면 그다음 데이터도 이미 캐시에 들어있어 매우 빠릅니다. (캐시 히트)
- 힙/객체(List<Integer>): 객체들이 메모리 여기저기에 흩어져 있습니다. CPU가 다음 데이터를 읽으려는데 캐시에 없으면, 다시 먼 길을 떠나 RAM에서 데이터를 가져와야 합니다. (캐시 미스)
이 '먼 길'을 다녀오는 속도 차이가 CPU 입장에서는 수십 배에서 수백 배까지 납니다.
'개발기술 > JVM' 카테고리의 다른 글
| JVM 메모리 구조, 모니터링 (3) | 2025.07.29 |
|---|---|
| Java의 이해 ; JDK 구성, Compile 단계, Runtime 단계, JAVA 명령어 (0) | 2024.06.19 |