GC (Garbage Collection)는 더이상 사용되지 않는 객체를 자동으로 메모리에서 해제하는 JVM의 메모리 관리 기능.
GC가 "이 객체를 지워도 돼?" 를 판단하는 기준이 도달 가능성(Reachability) 라고 할수 있다.
GC Root (스택 변수, static 변수, JNI 참조 등)
↓
참조를 따라가서 닿을 수 있으면 → 살림 (Reachable)
닿을 수 없으면 → 수거 대상 (Unreachable)
C/C++는 개발자가 직접 malloc / free 로 메모리를 관리해야 함. 까먹으면 메모리 누수, 잘못 해제하면 dangling pointer. Java는 GC가 이걸 대신 처리해줘서 개발자가 비즈니스 로직에 집중 할 수 있음.
GC가 실행되는 동안 Application 스레드가 전부 멈추는 현상. 이 멈춤이 길수록 응답 지연이 생기기 때문에 GC 알고리즘 발전의 핵심 목표가 STW를 얼마나 줄이냐 라고 할수 있음.
Serial -> Parallel -> CMS -> G1GC -> ZGC 순으로 STW가 줄어드는 방향으로 발전해온것.
힙 구조 -> GC 알고리즘 -> Stop-the-World -> 튜닝 포인트
꼭 알아야 할 것들
예상 면접 질문
"G1GC와 ZGC의 차이는?"
G1GC - 힙을 고정 크기 Region들로 나눠서 Eden/Old/Survivor 역할을 동적으로 부여. Young GC는 STW지만, Pause time target (XX:MaxGCPauseMillis=200)을 설정하면 그 안에 맞게 수집 범위를 조절. 수백ms 수준의 STW 발생.
ZGC - Colored pointer(객체 포인터에 상태 비트 인코딩)와 Load Barrier를 이용해 Mark/Relocate를 Application과 동시에 수행. STW가 root scanning 등 극히 짧은 구간에만 발생(~1ms). 다만 메모리 오버헤드가 있음.
요약하자면, G1은 "예측 가능한 STW", ZGC는 "거의 없는 STW"
"Full GC가 자주 발생하면 어떻게 접근할지?"
로그 확인 - GC log에서 Full GC 빈도/소요시간 확인 (Xlog:gc*)
원인 분류
힙 덤프 분석 - jmap -dump, MAT(Memory Analyzer)로 leak 여부 확인
GC 알고리즘 조정 - G1으로 교체 후 pause target 튜닝
WeakHashMap - 캐시에서 key가 외부에서 참조되지 않으면 자동 삭제. 캐시가 메모리 누수의 원인이 되는 걸 방지할 때 유용.GC 힙구조부터 시각적으로 잡고, 각 개념을 연결해서 보자!

힙 구조 - 각 영역 역할
객체가 처음 태어나는 곳, 세 영역으로 나눌수 있다.
| 영역 | 역할 |
|---|---|
| Eden | (new) 키워드로 생성된 객체가 최초 할당되는 곳 |
| Survivor 0/1 | Minor GC에서 살아남은 객체가 복사되는 곳. 항상 한쪽만 사용 |
Eden이 꽉 차면 Minor GC 발생. 살아남은 객체는 age가 1 증가하고 S0 -> S1 -> S0 을 오간다.
* 한가지 중요한 포인트는 S0, S1은 항상 둘 중 하나만 사용됨. 나머지 하나는 비어있는 상태.
흐름으로 보면:
new Object() → Eden 할당
↓ (Eden 꽉 참)
Minor GC 발생
↓
생존 객체 → S0 복사 (age +1)
↓ (다음 Minor GC)
S0 생존 객체 → S1 복사 (age +1)
↓ (age가 15 넘으면)
Old Generation으로 승격 (Promotion)
S0 -> S1 -> S0 -> S1.. 이렇게 번갈아가면서 살아남은 객체를 복사방식으로 관리함. 복사 후 이전 Survivor는 통째로 비워버리기 때문에 단편화가 없는게 장점임.
age가 threshold(기본 15)를 초과하면 Promotion 되어 Old로 이동. 여기서 GC가 발생하면 Major GC.

세대가 지날수록 STW를 줄이는 방향으로 발전해왔다.
MaxGCPauseMillis 옵션으로 STW 목표시간을 설정 할 수 있다. JDK 9부터 기본값. 현재 가장 많이 쓰임.Multi-Version Concurrency Control, 다중 버전 동시성 제어.
"Lock 없이 읽기 일관성을 보장하기 위해, 데이터를 변경할 때 기존 버전을 보존하는 방식"
Lock 기반으로만 동시성을 제어하면 이런 문제가 생김.
Reader가 읽는 동안 → Writer가 못 씀
Writer가 쓰는 동안 → Reader가 못 읽음
MVCC는 Reader와 Writer가 서로를 블로킹 하지 않음. 읽기는 스냅샷을 보고, 쓰기는 새 버전을 만들기 때문에.
트랜잭션 시작 → 스냅샷 생성 (그 시점의 데이터 버전 확정)
↓
읽기 → 스냅샷 기준으로 읽음 (다른 트랜잭션 변경 영향 없음)
쓰기 → 기존 버전 보존 + 새 버전 생성
↓
트랜잭션 종료 → 커밋이면 새 버전 확정, 롤백이면 새 버전 폐기
MVCC가 어느 시점의 스냅샷을 보여주느냐가 Isolation Level임.
| Isolation Level | 스냅샷 기준 | 문제 |
|---|---|---|
| Read Uncommitted | 스냅샷 안씀 | Dirty Read 발생 |
| Read Committed | 쿼리 실행 시점 스냅샷 | Non-Repeatable Read |
| Repeatable Read | 트랜잭션 시작 시점 스냅샷 | Phantom Read 가능 |
| Serializable | 완전 직렬화 | 성능 저하 |
여러 트랜잭션이 동시에 실행될 때 서로 얼마나 영향을 주고 받을지 정하는 설정.
식당으로 비유하면, 주문서(트랜잭션)가 여러 장 동시에 들어왔을 때 주방이 어떻게 처리하냐의 규칙같은 거임.
| 현상 | 설명 | 예시 |
|---|---|---|
| Dirty Read | 커밋 안 된 데이터를 읽음 | A가 rollback할 데이터를 B가 미리 읽음 |
| Non-Repeatable Read | 같은 쿼리를 두번 날렸는데 결과가 다름 | 중간에 다른 트랜잭션이 UPDATE + 커밋 |
| Phantom Read | 같은 조건인데 행 개수가 달라짐 | 중간에 다른 트랜잭션이 INSERT + 커밋 |
InnoDB는 모든 row에 눈에 안보이는 컬럼 3개를 자동으로 추가함.
| 숨겨진 컬럼 | 역할 |
|---|---|
DB_TRX_ID | 이 row를 마지막으로 변경한 트랜잭션 ID |
DB_ROLL_PTR | Undo Log에서 이전 버전을 가리키는 포인터 |
DB_ROW_ID | RK 없을 때 자동 생성되는 내부 ID |
UPDATE/DELETE 시 이전 데이터를 따로 저장해두는 공간
MySQL은 row를 직접 수정하고 이전 버전을 여기에 백업해둠. 롤백할 때나 오래된 스냅샷을 읽어야 할 때 이 Log를 타고 과거로 거슬러 올라감.
현재 row: name='Bob' (DB에 저장된 최신값)
Undo Log: name='Alice' ← 'Bob' 이전 버전
name='Tom' ← 'Alice' 이전 버전
PostgreSQL은 Undo Log가 없음. 대신 이전 버전 tuple을 테이블 안에 그냥 남겨둠.

DB_ROLL_PTR 이 그 이전 버전을 가리키게 함. 이렇게 버전 체인이 만들어지게 됨.DB_TRX_ID 를 Read View와 비교해서: 그렇다면 긴 트랜잭션이 왜 위험한가?
트랜잭션이 오래 열려 있으면 Read View가 그 시점에 묶이게 됨. 그 사이 다른 트랜잭션들이 UPDATE를 계속하면 Undo Log 체인이 무한정 쌓이고 Purge Thread가 정리를 못하게 됨. 결국 Undo Log가 비대해져서 I/O와 메모리 성능이 저하됨.
Undo Log가 계속 쌓이면 디스크가 가득 참. Purge Thread가 백그라운드에서 주기적으로 필요 없어진 Undo Log를 청소해주는 역할을 함.
어떤 트랜잭션도 더이상 그 버전을 참조하지 않을 때 비로소 삭제할수 있음.
Purge Thread = MySQL의 청소부
VACUUM = PostgreSQL의 청소부 (같은 역할, 다른 이름)
트랜잭션이 시작 될 때 "나는 어느 시점의 데이터를 봐야 하는가"를 기록한 스냅샷.
쉽게 말하자면, 사진을 한장 찍는다. 그 이후에 다른 사람이 데이터를 바꿔도 나는 내가 찍은 사진을 기준으로 데이터를 읽음.
트랜잭션 시작 → Read View 생성 (현재 활성 트랜잭션 ID 목록 기록)
읽기 요청 → Read View 기준으로 어느 버전 보여줄지 결정
Isolation Level에 따라 Read View를 언제 만드냐가 달라짐.
| Isolation Level | Read View 생성 시점 |
|:---|:---:|
| Read Committed | 쿼리 실행할 때마다 새로 생성 |
| Repeatable Read | 트랜잭션 시작할 때 한번만 생성 |

MySQL은 변경 시 이전 버전을 Undo Log(별도 공간)에 저장했는데, PostgreSQL은 힙 테이블 안에 새 tuple을 직접 삽입함. 기존 row를 건드리지 않음.
각 tuple에는 두개의 숨겨진 컬럼이 있음.
| 숨겨진 컬럼 | 의미 |
|---|---|
Xmin | 이 tuple을 만든 트랜잭션 ID |
Xmax | 이 tuple을 삭제/갱신한 트랜잭션 ID (없으면∞) |
UPDATE가 발생하면 두 가지 일이 동시에 일어남.
1. 기존 tuple의 xmax = 새 트랜잭션 ID -> dead tuple이 됨.
2. 새 tuple을 힙에 INSERT -> xmin = 새 트랜잭션 ID, xmax = ∞
그냥 테이블의 행(row) 하나임. PostgreSQL에서는 row 대신 tuple이라는 용어를 사용함.
특이한 점은 UPDATE를 해도 기존 tuple을 수정하지 않고 새 tuple을 추가 한다는 거임. 그래서 테이블 안에 같은 데이터의 여러 버전 tuple이 공존할 수 있음.
tuple 1: name='Alice' → 더 이상 유효하지 않음 (dead tuple)
tuple 2: name='Bob' → 현재 유효한 버전 (live tuple)
MySQL은 Undo Log를 별도 공간에 두고 Purge Thread가 정리하지만, PostgreSQL은 dead tuple이 힙 안에 그대로 남아 있음. 아무도 참조하지 않는 dead tuple이 쌓이면 Table Bloat이 발생하고 I/O가 낭비됨.
VACCUM이 dead tuple을 회수해서 공간을 재사용 가능하게 만듬.
- autovacuum — 백그라운드에서 자동 실행 (임계치 초과 시)
- VACUUM FULL — 테이블을 통째로 재작성, 디스크 공간까지 반환
(단, 테이블 전체 Lock 발생 → 운영 중엔 주의)
그렇다면 PostgreSQL에서 긴 트랜잭션의 위험은?
MySQL과 마찬가지로 긴 트랜잭션이 위험한데 이유가 다름.
PostgreSQL은 트랜잭션ID(xid)가 32비트 정수. 약 21억 개를 쓰면 순환(Wraparound)이 발생해서 과거 tuple이 미래 것으로 보일 수 있음. 이게 XID Wraparound문제고, 긴 트랜잭션이 열려 있으면 VACCUM이 오래된 xid를 정리 못해서 이 위험이 커짐.
PostgreSQL에서 dead tuple을 정리하는 청소 작업. MySQL의 Purge Thread에 대응하는 개념.
UPDATE/DELETE를 하면 old tuple이 테이블 안에 그냥 남는다(dead tuple). 이게 쌓이면 테이블이 점점 커지는 Table Bloat이 발생함. VACUUM이 이 dead tuple을 회수해서 공간을 재사용 가능하게 만듬.
autovacuum → 백그라운드 자동 실행
VACUUM FULL → 수동 실행, 공간까지 OS에 반환 (Lock 주의)

"MySQL과 PostgreSQL MVCC의 가장 큰차이는"
이전 버전 데이터를 어디에 보관하느냐이다. MySQL은 row를 직접 수정하고 이전 버전을 Undo Log라는 별도 공간에 저장한다. 반면 PostgreSQL은 row를 수정하지 않고 새 tuple을 힙 테이블 안에 직접 추가한다. 이 구조 차이 때문에 PostgreSQL은 dead tuple이 테이블 안에 쌓여 Table Bloat이 발생 할 수 있고, 이를 VACCUM이 정리한다.
공통
└─ MVCC: Lock 없이 읽기 일관성 보장
└─ 스냅샷 기반으로 트랜잭션마다 보여줄 버전 결정
MySQL
└─ 변경 → Undo Log에 이전 버전 저장
└─ 읽기 → Read View + Undo Log 체인 탐색
└─ 청소 → Purge Thread
PostgreSQL
└─ 변경 → 힙에 새 tuple 추가 (xmin/xmax로 가시성 관리)
└─ 읽기 → xmin/xmax와 스냅샷 비교
└─ 청소 → VACUUM
| GC | MVCC | |
|---|---|---|
| 목적 | 메모리 확보 | 동시성 제어(읽기 일관성) |
| 대상 | JVM 힙의 객체 | DB 테이블의 데이터 버전 |
| 동작 레이어 | Application 런타임 | 데이터베이스 엔진 |
| 정리 주체 | GC Thread | Purge Thread / VACCUM |
둘이 완전 무관하지는 않음. MVCC에서 Purge Thread나 VACCUM이 dead tuple/Undo Log를 정리하는 방식이 GC의 Mark-and-Sweep과 개념적으로 닮은것으로 보임.
GC → Reachable 객체 표시 → 나머지 수거
VACUUM → 참조 중인 tuple 확인 → dead tuple 수거
"정리한다"는 메커니즘은 유사하지만, GC는 메모리관리, MVCC는 동시성 제어가 본질임.
GC : GC Root에서 도달 불가 → 수거 대상
VACUUM : 활성 트랜잭션이 참조 안 함 → dead tuple 수거 대상
둘다 "아무도 참조하지 않을 때 비로소 정리할 수 있다"는 원칙이 동일함.
GC : 오래된 객체 참조 → Old Gen 가득 → Full GC 유발
MVCC : 긴 트랜잭션 → Undo Log 무한 증가 (MySQL)
→ dead tuple 누적, XID Wraparound (PostgreSQL)
"오래된 참조가 시스템 전체 성능을 저하시킨다"는 문제 구조가 동일함.
GC는 STW를 줄이기 위해 백그라운드에서 점진적으로 수행하도록 발전해왔고(Serial -> G1GC -> ZGC), MVCC 청소도 마찮가지임.
GC : STW 최소화 → 백그라운드 concurrent GC (ZGC)
VACUUM : Lock 최소화 → 백그라운드 autovacuum
둘다 "Application을 멈추지 않으면서 얼마나 효율적으로 정리하느냐"가 핵심 과제임.
** 정리하자면, GC와 MVCC는 목적은 다르지만 동작 원리에서 공통점이 있다. 둘다 아무도 참조하지 않는 시점에 비로소 정리할 수 있다는 원칙을 따르고, 오래된 참조가 정리를 막아 성능을 저하시킨다는 위험 구조도 동일하다. 또한 둘다 Application을 멈추지 않으면서 백그라운드에서 점진적으로 정리하는 방향으로 발전해 왔다. GC는 JVM메모리를, MVCC 청소는 DB의 이전 버전 데이터를 대상으로 한다는 점만 다르다."