웹 애플리케이션 서버(예: Spring Boot, NestJS)가 HTTP 요청 하나를 받아서 DB에서 데이터를 조회하고 응답을 돌려주는 과정에서, CPU는 대부분의 시간 동안 "무엇을 하고 있는가"?
그리고 이 사실이 Java(Spring)의 "thread-per-request" 모델과 Node.js의 "single-thread event loop" 모델이 각각 탄생하게 된 근본적인 이유와 어떻게 연결되는지, 당신의 논리로 설명해 보세요.
힌트: "CPU가 바쁜가, 아니면 놀고 있는가?"부터 시작하면 됩니다. 틀려도 괜찮습니다. 논리의 흐름이 중요합니다.
내 답변:
CPU는 CPU 작업이 없는 이상 I/O 작업 시에 대부분의 시간 동안 유휴하게 된다 ..
그러면 불필요하게 CPU 코어에 올라간 스레드가 놀게 되고 자원을 효율적으로 쓰지 못하게 될 수도 있다
그래서 Node.js는 이벤트 루프라는 것을 통해 매 요청마다 Node 프로그램에 할당된 스레드가 이벤트를 처리한다
그리고 실제 처리를 위임하는 스레드는 별도의 OS 스레드를 통해 CPU 작업을 하든 I/O 작업을 하든 하게 된다
Thread-per-request 모델(Java/Spring)의 실제 비용 구조:
스레드가 I/O를 기다리는 동안 CPU 코어를 계속 점유하는 것은 아닙니다. OS 스케줄러가 해당 스레드를 WAITING/BLOCKED 상태로 전환하고 CPU 코어에서 내려놓습니다. 그래서 CPU 자체는 다른 스레드를 실행할 수 있습니다. 진짜 비용은 CPU 점유가 아니라 다음 세 가지입니다:
첫째, 메모리 비용 — Platform Thread 하나당 기본 스택 크기가 ~1MB입니다. 동시 요청 10,000개면 스택만 ~10GB. 이것이 물리적 한계를 만듭니다.
둘째, 컨텍스트 스위칭 비용 — 스레드 수가 늘어나면 OS 스케줄러가 스레드 간 전환을 해야 하고, 이때 레지스터 저장/복원, TLB 플러시, 캐시 미스가 발생합니다. 스레드 수천 개 수준에서 이 오버헤드가 유의미해집니다.
셋째, OS 자원 한계 — 커널의 task_struct, 파일 디스크립터 등 OS 레벨 자원이 스레드 수에 비례하여 소모됩니다.
오 ? 알 듯 말 듯 했던 내용이네 .. 그리고 실제로 학습했던 내용도 있고 .. CS 지식을 통해서 답변하자면, 너무도 맞는 말이네 OS 스케줄러가 해당 스레드를 내려 놓고 다른 스레드에게 자원을 할당하고 작업을 수행할 수 있도록 해 주겠지 .. Spring 프로그램에서 스레드 풀 튜닝이나 그런 것들을 통해 미리 스레드를 new Thread()로 만들어 두면 그게 전부 메모리에 올라가 있을 테고 이것들 하나하나가 전부 자원 소모인데 유휴 스레드가 너무 많아지면 자원을 낭비하게 되는 거였네 .. 그리고 여러 스레드마다 CPU 코어에 올라갔다가 내려갔다가 하게 되면 컨텍스트 스위치 비용도 들 것이고 ThreadLocal에 사용되는 메모리 또한 비용 소모로 이어질 테고 ..
사실상 프로세스 안에서 스레드 == 스택 이렇게 느꼈어 실제로 스택 외에는 하나의 프로세스 안에서 여러 스레드가 다른 부분을 진짜 공유하고 있는 게 맞으니까 코드나 데이터 힙 영역들 말이야
그리고 요즘에는 실제 진짜 물리적인 CPU 코어가 정말 1개여서 모든 프로그램에 할당할 수 있는 CPU 코어가 1개인 경우는 없지 ? 그러면 진짜 물리적으로 싱글 스레드인 거잖아 그래서 CPU 코어가 1개이면 vCPU라는 기술을 통해서 그걸 절반 나눈 다음 2개의 스레드로 쓸 수 있게 코어를 분리하는 기술을 사용하지 않나 ?
Java(1995)가 thread-per-request를 택한 이유는, 당시 동시 접속이 수십~수백 수준이었고, 스레드 모델이 프로그래머에게 가장 직관적인 순차 코드 작성을 허용했기 때문입니다. "요청 하나 = 스레드 하나 = 위에서 아래로 읽히는 코드"는 강력한 추상화입니다.
Node.js(2009)가 등장한 시점에는 C10K 문제(동시 접속 10,000개)가 현실이 되었고, 스레드 만 개의 메모리/스위칭 비용이 병목으로 드러났습니다. Ryan Dahl이 "I/O 대기에 스레드를 묶어두는 것은 낭비"라는 관찰에서 이벤트 루프 모델을 선택한 것입니다.
그리고 Java 진영도 이 문제를 인식하여 Project Loom(Virtual Thread)으로 "thread-per-request의 직관성은 유지하되, 메모리/스위칭 비용은 이벤트 루프 수준으로 낮추자"는 해법을 내놓았습니다. 이것은 나중에 깊이 다룹니다.
그리고 CPU에 할당할 수 있는 실제 물리 코어는 제한되어 있으니 CPU 효율적인 설계 공식을 통해서 대략 (CPU 코어 * 2) + 1 같은 수준으로 맞춰 두는 거 아냐 ? 커넥션 풀도 그렇게 맞추던데 대략적으로
근데 CPU 코어가 10개일 때보다 200개일 때 실제로 처리량이 더 좋았던 적도 있어 .. 이건 왜일까 ?
[Phase 1] JVM Memory Area — 첫 번째 질문
JVM이 프로그램을 실행할 때, OS로부터 할당받은 메모리를 여러 영역으로 나눠 사용합니다. 이 "나눔"에는 물리적 이유가 있습니다.
질문:
JVM이 메모리를 "Heap"과 "Stack"으로 분리하는 이유는 무엇인가?
단순히 "객체는 힙, 지역변수는 스택"이라는 규칙이 아니라, 왜 이 두 가지가 물리적으로 다른 할당/해제 전략이 필요한지를 설명해 보세요.
힌트: 데이터의 **생명주기(lifetime)**와 크기의 예측 가능성이라는 두 축으로 생각해 보세요.
JVM의 Stack 영역은 크기의 예측 가능성이 더 높겠네 ? 고정으로 할당되는 메모리 영역이니까 그리고 JVM의 시작부터 끝까지 함께 살아 있어야 할 데이터라서 생명주기 자체가 JVM이랑 같은 시간으로 흐르게 되니 계속 생성하고 해제하는 것보다 Stack 영역에 올려 두는 것이 더 효율적일 수 있겠고 메모리 생성/해제에는 비용이 들어가니까, 정적으로 할당해 두는 거지
메모리 할당/해제는 할당하는 경우에 비어 있는 메모리를 찾는 것, 그리고 할당한 뒤에 GC 같은 게 돌 때 다시 해제하고 메모리 파편화 방지를 위해 주소 정렬 같은 걸 수행할 때 메모리 크기도 찾아야 되고 재정렬할 주소도 찾아야 되니 여러 모로 효용성이 줄어들 수 있겠구나 ?
그리고 Heap 영역에는 Stack과 반대되는 친구들이 있기 때문에 그런 것일 테고 ?? 그런 친구들에는 이제 User 클래스 같은 친구들이 어떤 사용자가 회원가입을 하려고 하면 그 사용자에 대한 정보를 저장할 클래스를 통해서 객체를 인스턴스화하고 거기에 사용자 정보를 메모리에 올린 다음 실제로 Repository 레이어를 통해서 DB에 쓰기 작업을 한 다음에는 그 User 정보는 필요가 없어지니 메모리에서 해제하는 것이 나을 테고, 왜냐하면 이미 DB에 정상적으로 썼다는 응답이 왔으니 메모리에 계속 들고 있을 필요가 없을 것이고 ..
평가: 질문별로 짚겠습니다
1. CPU 코어, vCPU, Hyper-Threading — 개념 혼동 교정
당신이 말한 내용에 두 가지 다른 기술이 섞여 있습니다.
Hyper-Threading (SMT, Simultaneous Multi-Threading): Intel이 만든 하드웨어 기술입니다. 물리 코어 1개 안에 레지스터 세트와 파이프라인 상태를 2벌 두어서, OS에게 "논리 코어 2개"로 보이게 합니다. 핵심은 실행 유닛(ALU, FPU 등)은 공유한다는 것입니다. 그래서 성능이 2배가 아니라 약 15~30% 향상입니다. 한 논리 코어가 메모리 대기로 파이프라인이 비는 틈에 다른 논리 코어의 명령을 실행하는 구조입니다.
vCPU (Virtual CPU): 이건 하드웨어가 아니라 가상화 소프트웨어(하이퍼바이저: KVM, VMware 등)가 만드는 추상화입니다. 물리 코어를 "나누는" 게 아니라, VM에게 "너는 CPU가 N개야"라고 스케줄링해주는 것입니다. AWS EC2에서 vCPU 2개라고 하면, 보통 Hyper-Threading이 켜진 물리 코어 1개의 논리 코어 2개를 의미합니다.
정리하면: Hyper-Threading은 하드웨어가 파이프라인 빈 틈을 채우는 기술, vCPU는 가상화 레이어의 스케줄링 추상화입니다. "코어를 절반 나눈다"는 표현은 부정확합니다.
2. 스레드 풀 공식과 "200개가 10개보다 빨랐던" 사례
CPU-bound 작업의 공식: 코어 수 + 1이 기본입니다. +1은 한 스레드가 페이지 폴트 등으로 잠깐 멈출 때 코어가 놀지 않게 하는 버퍼입니다.
I/O-bound 작업의 공식: Brian Goetz의 공식으로, 스레드 수 = 코어 수 × (1 + I/O 대기 시간 / CPU 처리 시간)입니다. 만약 요청 하나가 CPU 5ms, DB 대기 95ms라면 대기/처리 비율이 19가 됩니다. 코어 10개면 10 × 20 = 200개가 이론적 최적입니다.
그래서 200개가 10개보다 빨랐던 이유: 워크로드가 I/O-bound였기 때문입니다. 10개 스레드로는 10개가 전부 DB 응답을 기다리는 순간 CPU 코어 10개가 전부 놀게 됩니다. 200개 스레드면 10개가 기다리는 동안 나머지 190개 중 CPU 작업이 준비된 스레드가 코어를 채울 수 있습니다. 핵심은 "스레드가 많다 = 컨텍스트 스위칭 비용 증가"이지만, I/O 대기 비율이 높으면 그 비용보다 CPU 유휴 시간 감소의 이득이 더 크다는 것입니다.
HikariCP 커넥션 풀은 다른 공식을 쓰는 이유: 커넥션 풀은 코어 수 × 2 + 1 수준으로 작게 잡습니다. 이건 DB 서버 측의 병목 때문입니다. DB는 커넥션 하나당 서버 프로세스/스레드를 할당하므로, 커넥션이 많다고 DB가 빨라지지 않습니다. 오히려 DB 내부의 락 경합이 심해집니다. 스레드 풀과 커넥션 풀의 최적 크기가 다른 이유가 여기에 있습니다.
3. Stack/Heap 분리 — 여기서 중요한 오류를 교정합니다
당신이 틀린 부분: "Stack 영역은 JVM의 시작부터 끝까지 함께 살아 있어야 할 데이터"
이것은 틀렸습니다. Stack은 JVM과 생명주기가 같은 영역이 아닙니다. 그건 **Method Area(Metaspace)**입니다. Stack의 생명주기를 정확히 말하겠습니다.
Stack의 실제 동작:
Stack은 스레드마다 하나씩 생성되고, 메서드 호출마다 "스택 프레임"이 push되고 메서드 반환 시 pop됩니다.
Thread-1의 Stack:
┌─────────────────────┐
│ processPayment() │ ← 현재 실행 중 (top)
│ - amount: 50000 │ 지역변수: 컴파일 타임에 크기 확정
│ - userId: ref→Heap │ 참조 변수: 8 bytes (64bit JVM)
├─────────────────────┤
│ handleRequest() │ ← 호출자
│ - request: ref→Heap│
├─────────────────────┤
│ main() │ ← 진입점
└─────────────────────┘
메서드가 끝나면 해당 프레임은 즉시 사라집니다. GC가 개입할 필요 없이, 스택 포인터(SP)를 한 칸 내리면 끝입니다. 이것이 Stack이 빠른 핵심 이유입니다.
그러면 왜 Stack과 Heap이 분리되어야 하는가 — 두 축으로 정리:
기준 Stack Heap
| 생명주기 예측 | 완벽히 예측 가능. 메서드 시작 = 생성, 메서드 종료 = 소멸. LIFO(후입선출) 구조와 정확히 일치 | 예측 불가. 객체를 만든 메서드가 끝나도 다른 곳에서 참조하면 살아 있어야 함 |
| 크기 예측 | 컴파일 타임에 확정. int = 4B, long = 8B, 참조 = 8B. 프레임 크기가 고정 | 런타임에 결정. new User()의 필드 수, String 길이 등은 실행해봐야 앎 |
| 할당/해제 비용 | SP 이동 한 번 (~1 CPU 명령). O(1) | 빈 공간 탐색 + 메타데이터 기록 + 나중에 GC가 회수. O(n) 수준의 탐색 필요 |
| 공유 범위 | 해당 스레드만 접근 (thread-local). 동기화 불필요 | 모든 스레드가 접근 가능. 동기화 필요 |
당신이 맞은 부분: Heap에 대한 설명은 정확합니다. User 객체를 생성해서 DB에 쓰고, 응답이 왔으면 더 이상 참조가 없으니 GC가 회수하면 된다 — 이 생명주기의 불예측성이 Heap과 GC가 존재하는 이유입니다.
당신이 놓친 핵심: Stack이 빠른 이유는 "정적 할당이라서"가 아니라, LIFO 구조와 메서드 호출의 구조가 물리적으로 동형(isomorphic)이기 때문입니다. 메서드 A가 B를 호출하면 B가 먼저 끝나야 A가 끝납니다. 이것은 정확히 LIFO입니다. 그래서 포인터 하나만 움직이면 할당/해제가 끝납니다. Heap에는 이런 순서 보장이 없으므로, 복잡한 할당기(allocator)와 GC가 필요합니다.
JVM 메모리 전체 구조 — 정확한 지도
다음 질문으로 넘어가기 전에, 전체 구조를 한번 잡겠습니다.
JVM Process Memory
├── Heap (모든 스레드 공유)
│ ├── Young Generation (Eden + Survivor 0 + Survivor 1)
│ └── Old Generation
├── Metaspace (JDK 8+, 네이티브 메모리)
│ ├── 클래스 메타데이터 (바이트코드, 필드/메서드 정보)
│ └── 상수 풀의 일부
├── Thread별 영역 (스레드마다 독립)
│ ├── JVM Stack (스택 프레임들)
│ ├── PC Register (현재 실행 중인 바이트코드 주소)
│ └── Native Method Stack (JNI 호출 시)
└── 기타
├── Code Cache (JIT 컴파일된 네이티브 코드)
├── Direct ByteBuffer (off-heap, NIO)
└── Thread의 TLAB (Heap 내 스레드별 할당 버퍼)
다음 질문: Heap 내부 구조의 물리적 근거
질문: JVM Heap은 왜 Young Generation과 Old Generation으로 나뉘는가?
"새 객체는 Young, 오래된 객체는 Old"라는 규칙이 아니라, 이 분리가 GC 성능에 어떤 물리적 이점을 주는지 설명해 보세요.
힌트: "Weak Generational Hypothesis" — 대부분의 객체는 생성 직후 죽는다는 경험적 관찰이 있습니다. 이 관찰이 참일 때, 메모리를 세대별로 나누면 GC가 탐색해야 할 메모리 범위에 어떤 영향을 주는지 생각해 보세요.
내 답변:
새 객체, 오래된 객체를 나누는 기준이 얼마나 많이 쓰였냐를 기준으로 나누는데(여기서 '얼마나'의 기준은 잘 모름)
오래된 객체는 계속해서 쓰인다는 의미인데 이 객체에 대한 메모리를 할당/해제 반복할 경우
CPU 자원 소모가 계속해서 발생하기 때문에 불필요하게 낭비하게 되고,
GC가 해당 작업을 수행할 때 생각보다 오래 걸리게 될 수도 있고
이 경우 전체 서비스 중단에 대한 지연이 발생할 수도 있음.
그리고 GC가 너무 많이 작업하다 보면 타임아웃 걸려서 GC 작업을 못 끝내게 될 수도 있음.
더욱이 필요한 부분들에 대해서만 GC 작업을 나눠서 수행할 수 있게 되므로 조금 더 GC 예상 시간 가늠 가능해지고,
서비스 에러에 대한 불확실성을 줄일 수 있게 됨.
그런데 이제 막 새 객체가 오래된 객체로 되려던 찰나에 GC가 동작해서 회수된 건 .. 어떻게 해야 하지 ?
1. 스레드 풀/커넥션 풀 — "별도로 설정하는 것이 의미가 있는가?"
매우 좋은 질문입니다. 답은 **"의미가 있다, 하지만 당신이 짚은 상호 영향도 실재한다"**입니다.
핵심을 먼저 말하면: 세 공식은 같은 물리 자원(CPU 코어, 메모리, OS 스레드)을 서로 다른 관점에서 분배하는 것이고, 실제로는 한 프로세스 안에서 공존합니다.
Spring Boot 하나의 프로세스 안에서 실제로 존재하는 스레드 풀들을 보면:
Spring Boot Process
├── Tomcat Worker Thread Pool (기본 200개) ← HTTP 요청 처리
│ 이 스레드가 Controller → Service → Repository 전체를 실행
├── HikariCP Connection Pool (기본 10개) ← DB 커넥션
│ Tomcat 스레드가 DB 호출 시 여기서 커넥션을 "빌려감"
├── @Async / TaskExecutor Pool ← 비동기 작업
├── Scheduler Pool ← @Scheduled 작업
└── Kafka Consumer Thread 등 ← 메시징
왜 별도로 설정하는 것이 의미 있는가 — 병목 지점이 다르기 때문:
Tomcat 스레드 200개가 동시에 DB를 호출한다고 가정합시다. HikariCP가 10개면, 200개 중 10개만 실제로 DB 쿼리를 실행하고 나머지 190개는 getConnection()에서 대기합니다. 여기서 두 풀의 역할이 분리됩니다.
Tomcat 스레드 풀은 "동시에 처리할 수 있는 HTTP 요청 수"를 제어합니다. 이것은 I/O-bound 공식에 가깝습니다. 요청 처리 중 DB 대기, 외부 API 대기 시간이 길기 때문에 스레드가 많아야 CPU를 놀리지 않습니다.
HikariCP 풀은 "DB 서버에 동시에 보낼 쿼리 수"를 제어합니다. 이것은 DB 서버 측 병목에 맞춰야 합니다. DB의 CPU 코어, 락 경합, 디스크 I/O가 제한 요소입니다.
당신이 짚은 상호 영향 — 실제로 발생하는 문제:
Tomcat 200개, HikariCP 10개일 때, 190개 스레드가 커넥션을 기다립니다. 이때 HikariCP의 connectionTimeout(기본 30초)을 넘기면 SQLTransientConnectionException이 터집니다. 이것이 바로 당신이 운영에서 겪었던 HikariCP 데드락 문제의 근본 구조입니다. @Async가 Tomcat 스레드와 같은 커넥션 풀을 공유하면서 서로 커넥션을 기다리는 교착 상태가 된 것이죠.
그래서 실무에서의 원칙은: 스레드 풀은 역할별로 격리하되, 공유 자원(커넥션 풀, 외부 API)의 한계를 기준으로 상한을 맞춘다입니다. Tomcat 200개인데 HikariCP 10개면, 실질적 동시 DB 처리량은 10개로 제한됩니다. 나머지 190개는 대기열입니다.
2. "물리적으로 동형(Isomorphic)" — 상세 설명
수학에서 동형(isomorphism)은 "두 구조가 형태가 같다"는 뜻입니다. 여기서 제가 말한 것은:
메서드 호출의 순서 구조:
main() 호출 → handleRequest() 호출 → processPayment() 호출
processPayment() 반환 → handleRequest() 반환 → main() 반환
이것은 반드시 "마지막에 들어간 것이 먼저 나온다"는 순서를 따릅니다. processPayment()가 끝나기 전에 handleRequest()가 끝나는 것은 불가능합니다. 이것이 호출 구조의 LIFO 성질입니다.
스택 자료구조의 물리적 동작:
push main → SP 이동 ↑
push handleReq → SP 이동 ↑
push processPay → SP 이동 ↑
pop processPay → SP 이동 ↓
pop handleReq → SP 이동 ↓
pop main → SP 이동 ↓
두 구조의 순서가 정확히 1:1로 대응합니다. 이것이 동형입니다. 그래서 메서드 호출/반환을 스택 포인터 이동 한 번으로 구현할 수 있는 것입니다. 만약 메서드 호출이 LIFO가 아니었다면(예: 코루틴처럼 중간에 suspend하고 다른 메서드가 먼저 끝날 수 있다면), 스택으로는 구현이 불가능하고 힙에 별도로 상태를 저장해야 합니다. 이것이 정확히 Virtual Thread의 Continuation이 힙을 사용하는 이유이고, 나중에 다룰 내용입니다.
3. Young/Old Generation — 답변 평가
틀린 부분 3가지를 짚겠습니다.
첫째, "얼마나 많이 쓰였냐"가 기준이 아닙니다.
기준은 **"Minor GC에서 몇 번 살아남았는가"**입니다. 사용 빈도가 아니라 생존 횟수입니다. JVM에는 age라는 카운터가 있고, Minor GC를 한 번 살아남을 때마다 age가 1 증가합니다. 특정 임계값(-XX:MaxTenuringThreshold, G1 기본 15)에 도달하면 Old로 승격(promotion)됩니다. "많이 쓰이는 객체"가 아니라 "오래 살아남은 객체"입니다. 아무도 참조하지 않아도 GC가 안 돌면 age가 안 올라가고, 참조가 많아도 GC 때 살아남으면 age가 올라갑니다.
둘째, "오래된 객체의 할당/해제를 반복하면 낭비"라는 논리가 부정확합니다.
Old Generation에 있는 객체는 할당/해제를 반복하지 않습니다. 이미 오래 살아남았으므로 대부분 계속 살아 있습니다. 세대 분리의 진짜 이점은 반대 방향입니다. 죽는 객체가 몰려 있는 영역(Young)만 자주 청소하면 된다는 것입니다.
셋째, "새 객체가 오래된 객체로 되려던 찰나에 GC가 회수"는 발생하지 않습니다.
GC가 회수하는 기준은 "참조가 있는가/없는가"입니다. 참조가 살아 있으면 age가 올라가고 결국 Old로 승격됩니다. 참조가 없으면 Young에서 바로 회수됩니다. "승격되려던 찰나에 회수"되려면 그 순간에 참조가 사라져야 하는데, 그러면 회수되는 것이 정확한 동작입니다.
당신이 놓친 핵심 — Weak Generational Hypothesis의 물리적 의미
경험적 관찰에 따르면 객체의 약 80~95%가 생성 직후 매우 짧은 시간 안에 죽습니다. HTTP 요청 하나를 처리할 때 만들어지는 DTO, 임시 String, Stream의 중간 객체들 전부가 요청 끝나면 참조가 사라집니다.
이 관찰이 참일 때 세대 분리가 주는 물리적 이점은 GC의 탐색 범위 축소입니다.
만약 세대를 나누지 않으면:
Heap 전체 = 4GB
GC가 4GB 전체를 스캔해야 함
살아있는 객체 비율: 대부분 살아있음 (오래된 것 + 새 것 혼재)
매번 전체 탐색 → STW 시간이 김
세대를 나누면:
Young = 256MB, Old = 3.75GB
Minor GC: Young 256MB만 스캔
- 95%가 죽어있음 → 살아있는 5%만 복사하면 끝
- STW: 수 ms
Major/Full GC: Old 포함 전체 스캔
- 빈도가 훨씬 낮음 (Young에서 대부분 죽으므로)
- STW: 수십~수백 ms이지만 드물게 발생
DB와의 Cross-Domain 연결: 이것은 InnoDB의 Buffer Pool과 유사한 전략입니다. Buffer Pool에서도 Young/Old 리스트로 페이지를 나눕니다. 한 번만 읽히는 풀 스캔 페이지가 자주 쓰이는 핫 페이지를 밀어내지 않도록, 최근 접근된 페이지를 Young 리스트에 두고, 일정 시간 이상 접근이 유지되면 Old 리스트로 승격합니다. "자주 쓰이는 것은 오래 유지하고, 일회성은 빨리 퇴출한다"는 원리가 동일합니다. GC의 Young/Old와 방향이 반대(GC에서는 Old가 오래 사는 쪽, Buffer Pool에서도 Old가 오래 사는 쪽)지만, 핵심 전략인 **"접근 패턴의 편향(skew)을 이용해 관리 비용을 줄인다"**는 동일합니다.
Young Generation 내부 — Eden과 Survivor의 물리적 구조
다음 질문으로 넘어가기 전에 Young 내부를 정확히 잡겠습니다.
Young Generation
┌────────────────────────────────────────┐
│ Eden (80%) │ ← new 객체는 여기에 할당
│ obj1 obj2 obj3 ... (대부분 여기서 죽음) │
├──────────────┬─────────────────────────┤
│ Survivor 0 │ Survivor 1 │ ← 둘 중 하나는 항상 비어있음
│ (10%) │ (10%) │
│ age=1,2,3... │ (비어있음) │
└──────────────┴─────────────────────────┘
Minor GC가 발생하면: Eden + 현재 사용 중인 Survivor에서 살아있는 객체만 비어있는 Survivor로 복사합니다. 복사 후 Eden과 이전 Survivor는 통째로 비웁니다. 이 "복사 후 통째로 비우기"가 핵심입니다. 개별 객체를 하나씩 해제하는 것이 아니라, 영역 전체를 한 번에 초기화합니다. 95%가 죽는 상황에서, 살아있는 5%만 복사하는 비용이 죽은 95%를 개별 해제하는 비용보다 훨씬 적습니다.
이것이 왜 "복사(Copy) 알고리즘"을 Young에서 쓰는 이유입니다. Old에서는 살아있는 비율이 높으므로 복사 비용이 너무 크고, 대신 Mark-Sweep-Compact 같은 다른 전략을 씁니다.
다음 질문: GC의 물리적 불가피성
질문: Stop-the-World(STW)는 GC가 동작할 때 애플리케이션 스레드를 전부 멈추는 것입니다. 이것은 "설계의 한계"가 아니라 물리적으로 불가피한 순간이 있습니다.
GC가 "이 객체가 살아있는가?"를 판단하려면 GC Root(스택의 지역변수, static 필드, JNI 참조 등)에서 시작하여 참조 그래프를 탐색합니다. 이 탐색 도중에 애플리케이션 스레드가 계속 실행되면 정확히 어떤 문제가 발생하는지 설명해 보세요.
힌트: GC가 객체 A→B→C 참조 체인을 따라가고 있는데, 애플리케이션 스레드가 그 순간에 B→C 참조를 끊고 D→C 참조를 새로 만들면 어떻게 됩니까?
내 답변:
중간에 애플리케이션 스레드가 멈추지 않으면 정확하게 어떤 객체를 메모리에서 해제해야 하는지
판단하기 어려워지기 때문에 메모리 할당/해제에 차질이 생길 수 있고,
메모리를 할당/해제한 뒤에는 메모리 컴팩트 작업을 통해서
중간중간 비어 있는 상태의 파편화를 줄여서 메모리를 효율적으로 사용해야 되는데 이때에도 계속해서
메모리에 할당/해제되면 어디에 어떻게 컴팩트 작업을 할지 모르게 될 것 같아서 문제일 것 같아