GC, MySQL MVCC & PostgreSQL MVCC

Jihye Gim·2026년 6월 10일

Codeit SB11

목록 보기
21/22

1. Garbage Collection(GC)

GC (Garbage Collection)는 더이상 사용되지 않는 객체를 자동으로 메모리에서 해제하는 JVM의 메모리 관리 기능.

핵심 개념: Reachability

GC가 "이 객체를 지워도 돼?" 를 판단하는 기준이 도달 가능성(Reachability) 라고 할수 있다.


GC Root (스택 변수, static 변수, JNI 참조 등)

참조를 따라가서 닿을 수 있으면 → 살림 (Reachable)
닿을 수 없으면 → 수거 대상 (Unreachable)


왜 필요한가?

C/C++는 개발자가 직접 malloc / free 로 메모리를 관리해야 함. 까먹으면 메모리 누수, 잘못 해제하면 dangling pointer. Java는 GC가 이걸 대신 처리해줘서 개발자가 비즈니스 로직에 집중 할 수 있음.

GC 동작 2단계

  1. Mark - GC Root 에서 시작해 참조를 따라가며 살아있는 객체 표시
  2. Sweek - 표시 안 된 객체를 메모리에서 해제
    ( + Compact - 단편화 방지를 위해 남은 객체를 한쪽으로 모음)

Stop-the-World(STW)란?

GC가 실행되는 동안 Application 스레드가 전부 멈추는 현상. 이 멈춤이 길수록 응답 지연이 생기기 때문에 GC 알고리즘 발전의 핵심 목표가 STW를 얼마나 줄이냐 라고 할수 있음.

Serial -> Parallel -> CMS -> G1GC -> ZGC 순으로 STW가 줄어드는 방향으로 발전해온것.

핵심 개념 흐름

힙 구조 -> GC 알고리즘 -> Stop-the-World -> 튜닝 포인트

꼭 알아야 할 것들

  • 힙 구조 : Young(Eden + S0/S1) -> Old -> Metaspace
  • GC 종류 : Serial/Parallerl/CMS/G1GC/ZGC/Shenandoah
  • Minor GC vs Major GC vs Full GC 차이
  • Stop-the-World(STW) 가 왜 문제인지, G1/ZGC가 어떻게 줄이는지
  • Reachability : GC Root에서 도달 불가한 객체가 수거 대상

예상 면접 질문

  • "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가 자주 발생하면 어떻게 접근할지?"

  1. 로그 확인 - GC log에서 Full GC 빈도/소요시간 확인 (Xlog:gc*)

  2. 원인 분류

    • Old Gen 가득 참 -> 객체가 너무 빨리 승격되는 것 아닌지 (Survivor 크기, age threshold 확인)
    • Metaspace 가득 참 -> 클래스 동적 로딩이 과한 것 아닌지
    • System.gc() 명시 호출 -> 라이브러리/코드에서 강제 호출 여부
  3. 힙 덤프 분석 - jmap -dump, MAT(Memory Analyzer)로 leak 여부 확인

  4. GC 알고리즘 조정 - G1으로 교체 후 pause target 튜닝

  • "Weak Reference는 언제 쓰일지?"
    GC가 Strong Reference는 항상 보존하지만, Weak Reference는 메모리가 필요하면 수거 대상이 됨.
    대표 사용 사례: WeakHashMap - 캐시에서 key가 외부에서 참조되지 않으면 자동 삭제. 캐시가 메모리 누수의 원인이 되는 걸 방지할 때 유용.

GC 힙구조부터 시각적으로 잡고, 각 개념을 연결해서 보자!

힙 구조 - 각 영역 역할

Young Generation

객체가 처음 태어나는 곳, 세 영역으로 나눌수 있다.

영역역할
Eden(new) 키워드로 생성된 객체가 최초 할당되는 곳
Survivor 0/1Minor 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는 통째로 비워버리기 때문에 단편화가 없는게 장점임.

Old Generation (Tenured)

age가 threshold(기본 15)를 초과하면 Promotion 되어 Old로 이동. 여기서 GC가 발생하면 Major GC.

Metaspace

  • JDK 8부터 PermGen을 대체
  • 클래스 메타데이터, static 변수 등 저장
  • JVM Heap 외부 (Native 메모리) -> OOM 이슈 패턴이 다름

GC 종류 비교 - 흐름으로 이해하기


세대가 지날수록 STW를 줄이는 방향으로 발전해왔다.

  • Serial : 가장 단순. GC스레드 하나로 전부 처리. STW 동안 세상이 멈춤. 소형 앱 외엔 거의 안씀.
  • Parallel : Serial과 구조는 같은데 GC 스레드를 여러 개 써서 STW 시간을 줄인 버전. JDK 8 기본값. 처리량(throughput) 중심.
  • CMS : 처음으로 "GC 일부를 Application과 동시에 하자" 는 시도. STW가 크게 줄었지만 메모리 단편화 문제가 있었고 결국 JDK9에서 deprecated.
  • G1GC : 힙을 Region이라는 작은 단위로 쪼개서 관리. MaxGCPauseMillis 옵션으로 STW 목표시간을 설정 할 수 있다. JDK 9부터 기본값. 현재 가장 많이 쓰임.
  • ZGC/Shenandoah : STW를 ~1ms 수준으로 줄인 최신 GC. Application 실행 중에 GC 대부분의 작업을 동시에 처리. 대용량 힙(수십 ~ 수백 GB)에서도 안정적.
    요약하자면, "어떻게 하면 Application을 덜 멈추고 GC할 수 있을 까"의 고민이 Serial에서 ZGC까지 이어짐"

MVCC

MVCC란?

Multi-Version Concurrency Control, 다중 버전 동시성 제어.
"Lock 없이 읽기 일관성을 보장하기 위해, 데이터를 변경할 때 기존 버전을 보존하는 방식"

왜 필요한가?

Lock 기반으로만 동시성을 제어하면 이런 문제가 생김.
Reader가 읽는 동안 → Writer가 못 씀
Writer가 쓰는 동안 → Reader가 못 읽음

MVCC는 Reader와 Writer가 서로를 블로킹 하지 않음. 읽기는 스냅샷을 보고, 쓰기는 새 버전을 만들기 때문에.

핵심 동작 원리


트랜잭션 시작 → 스냅샷 생성 (그 시점의 데이터 버전 확정)

읽기 → 스냅샷 기준으로 읽음 (다른 트랜잭션 변경 영향 없음)
쓰기 → 기존 버전 보존 + 새 버전 생성

트랜잭션 종료 → 커밋이면 새 버전 확정, 롤백이면 새 버전 폐기


Isolation Level과 MVCC

MVCC가 어느 시점의 스냅샷을 보여주느냐가 Isolation Level임.

Isolation Level스냅샷 기준문제
Read Uncommitted스냅샷 안씀Dirty Read 발생
Read Committed쿼리 실행 시점 스냅샷Non-Repeatable Read
Repeatable Read트랜잭션 시작 시점 스냅샷Phantom Read 가능
Serializable완전 직렬화성능 저하

Isolation Level(격리수준)

여러 트랜잭션이 동시에 실행될 때 서로 얼마나 영향을 주고 받을지 정하는 설정.
식당으로 비유하면, 주문서(트랜잭션)가 여러 장 동시에 들어왔을 때 주방이 어떻게 처리하냐의 규칙같은 거임.

  • Read Uncommitted -> 남이 아직 완성 안한 요리도 서빙 (위험)
  • Read Committed -> 와성된 요리만 서빙 ( 쿼리 시점 기준)
  • Repeatable Read -> 내가 주문한 시점 메뉴 그대로 유지
  • Serializable -> 한 번에 한 테이ㅡㄹ씩만 처리 (완전 직렬)

Dirty Read/ NRR / Phantom Read 차이

현상설명예시
Dirty Read커밋 안 된 데이터를 읽음A가 rollback할 데이터를 B가 미리 읽음
Non-Repeatable Read같은 쿼리를 두번 날렸는데 결과가 다름중간에 다른 트랜잭션이 UPDATE + 커밋
Phantom Read같은 조건인데 행 개수가 달라짐중간에 다른 트랜잭션이 INSERT + 커밋

MySQL MVCC (InnoDB)

핵심구조 - 숨겨진 컬럼 3개

InnoDB는 모든 row에 눈에 안보이는 컬럼 3개를 자동으로 추가함.

숨겨진 컬럼역할
DB_TRX_ID이 row를 마지막으로 변경한 트랜잭션 ID
DB_ROLL_PTRUndo Log에서 이전 버전을 가리키는 포인터
DB_ROW_IDRK 없을 때 자동 생성되는 내부 ID

Undo Log (MySQL 용어)

UPDATE/DELETE 시 이전 데이터를 따로 저장해두는 공간
MySQL은 row를 직접 수정하고 이전 버전을 여기에 백업해둠. 롤백할 때나 오래된 스냅샷을 읽어야 할 때 이 Log를 타고 과거로 거슬러 올라감.
현재 row: name='Bob' (DB에 저장된 최신값)
Undo Log: name='Alice' ← 'Bob' 이전 버전
name='Tom' ← 'Alice' 이전 버전
PostgreSQL은 Undo Log가 없음. 대신 이전 버전 tuple을 테이블 안에 그냥 남겨둠.

동작 흐름

  1. UPDATE 발생 시 기존 row를 덮어쓰지 않는다. 대신 이전 값을 Undo Log에 저장하고, 현재 row의 DB_ROLL_PTR 이 그 이전 버전을 가리키게 함. 이렇게 버전 체인이 만들어지게 됨.
  2. 읽기 요청 시 트랜잭션이 시작되면 Read View를 생성함. 여기에는 "지금 활성화된 트랜잭션 ID 목록"이 담기게 됨.
  3. 버전 선택 DB_TRX_ID 를 Read View와 비교해서:
  • 내 트랜잭션보다 먼저 커밋됐으면 -> 읽음
  • 아직 활성 중이거나 내 이후면 -> Undo Log에서 이전 버전으로 이동
    이 과정을 보여줄 수 있는 버전을 찾을 때까지 체인을 타고 내려감.

그렇다면 긴 트랜잭션이 왜 위험한가?
트랜잭션이 오래 열려 있으면 Read View가 그 시점에 묶이게 됨. 그 사이 다른 트랜잭션들이 UPDATE를 계속하면 Undo Log 체인이 무한정 쌓이고 Purge Thread가 정리를 못하게 됨. 결국 Undo Log가 비대해져서 I/O와 메모리 성능이 저하됨.

Purge Thread (MySQL 용어)

Undo Log가 계속 쌓이면 디스크가 가득 참. Purge Thread가 백그라운드에서 주기적으로 필요 없어진 Undo Log를 청소해주는 역할을 함.
어떤 트랜잭션도 더이상 그 버전을 참조하지 않을 때 비로소 삭제할수 있음.

Purge Thread = MySQL의 청소부
VACUUM = PostgreSQL의 청소부 (같은 역할, 다른 이름)

Read View (MySQL 용어)

트랜잭션이 시작 될 때 "나는 어느 시점의 데이터를 봐야 하는가"를 기록한 스냅샷.
쉽게 말하자면, 사진을 한장 찍는다. 그 이후에 다른 사람이 데이터를 바꿔도 나는 내가 찍은 사진을 기준으로 데이터를 읽음.
트랜잭션 시작 → Read View 생성 (현재 활성 트랜잭션 ID 목록 기록)
읽기 요청 → Read View 기준으로 어느 버전 보여줄지 결정

Isolation Level에 따라 Read View를 언제 만드냐가 달라짐.
| Isolation Level | Read View 생성 시점 |
|:---|:---:|
| Read Committed | 쿼리 실행할 때마다 새로 생성 |
| Repeatable Read | 트랜잭션 시작할 때 한번만 생성 |

PostgreSQL MVCC

MySQL과 가장 다른 점

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 = ∞

tuple (PostgreSQL 용어)

그냥 테이블의 행(row) 하나임. PostgreSQL에서는 row 대신 tuple이라는 용어를 사용함.
특이한 점은 UPDATE를 해도 기존 tuple을 수정하지 않고 새 tuple을 추가 한다는 거임. 그래서 테이블 안에 같은 데이터의 여러 버전 tuple이 공존할 수 있음.
tuple 1: name='Alice' → 더 이상 유효하지 않음 (dead tuple)
tuple 2: name='Bob' → 현재 유효한 버전 (live tuple)

VACUUM이 왜 필요한가?

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를 정리 못해서 이 위험이 커짐.

VACUUM (PostgreSQL 용어)

PostgreSQL에서 dead tuple을 정리하는 청소 작업. MySQL의 Purge Thread에 대응하는 개념.
UPDATE/DELETE를 하면 old tuple이 테이블 안에 그냥 남는다(dead tuple). 이게 쌓이면 테이블이 점점 커지는 Table Bloat이 발생함. VACUUM이 이 dead tuple을 회수해서 공간을 재사용 가능하게 만듬.

autovacuum → 백그라운드 자동 실행
VACUUM FULL → 수동 실행, 공간까지 OS에 반환 (Lock 주의)

MySQL vs PostgreSQL MVCC 최종 비교

"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 vs MVCC 차이점

GCMVCC
목적메모리 확보동시성 제어(읽기 일관성)
대상JVM 힙의 객체DB 테이블의 데이터 버전
동작 레이어Application 런타임데이터베이스 엔진
정리 주체GC ThreadPurge Thread / VACCUM

둘이 완전 무관하지는 않음. MVCC에서 Purge Thread나 VACCUM이 dead tuple/Undo Log를 정리하는 방식이 GC의 Mark-and-Sweep과 개념적으로 닮은것으로 보임.

GC → Reachable 객체 표시 → 나머지 수거
VACUUM → 참조 중인 tuple 확인 → dead tuple 수거
"정리한다"는 메커니즘은 유사하지만, GC는 메모리관리, MVCC는 동시성 제어가 본질임.


연결 포인트 3가지

  1. "필요 없어진 버전을 정리한다" - 공통 메커니즘
    GC는 더이상 참조되지 않는 객체를 수거하고, MVCC의 청소(Purge Thread/VACCUM)는 더 이상 어떤 트랜잭션도 참조하지 않는 이전 버전 데이터를 수거함.

GC : GC Root에서 도달 불가 → 수거 대상
VACUUM : 활성 트랜잭션이 참조 안 함 → dead tuple 수거 대상

둘다 "아무도 참조하지 않을 때 비로소 정리할 수 있다"는 원칙이 동일함.

  1. "긴 참조가 정리를 막는다" - 공통 위험
    GC에서 Old Generation에 오래 살아남은 객체가 많으면 Major GC비용이 커지듯이, MVCC에서 오래 열린 트랜잭션이 있으면 그 시점 이후의 버전을 정리할 수 없음.

GC : 오래된 객체 참조 → Old Gen 가득 → Full GC 유발
MVCC : 긴 트랜잭션 → Undo Log 무한 증가 (MySQL)
→ dead tuple 누적, XID Wraparound (PostgreSQL)

"오래된 참조가 시스템 전체 성능을 저하시킨다"는 문제 구조가 동일함.

  1. "정리 타이밍이 성능을 결정한다" - 공통 트레이드 오프

GC는 STW를 줄이기 위해 백그라운드에서 점진적으로 수행하도록 발전해왔고(Serial -> G1GC -> ZGC), MVCC 청소도 마찮가지임.

GC : STW 최소화 → 백그라운드 concurrent GC (ZGC)
VACUUM : Lock 최소화 → 백그라운드 autovacuum

둘다 "Application을 멈추지 않으면서 얼마나 효율적으로 정리하느냐"가 핵심 과제임.

** 정리하자면, GC와 MVCC는 목적은 다르지만 동작 원리에서 공통점이 있다. 둘다 아무도 참조하지 않는 시점에 비로소 정리할 수 있다는 원칙을 따르고, 오래된 참조가 정리를 막아 성능을 저하시킨다는 위험 구조도 동일하다. 또한 둘다 Application을 멈추지 않으면서 백그라운드에서 점진적으로 정리하는 방향으로 발전해 왔다. GC는 JVM메모리를, MVCC 청소는 DB의 이전 버전 데이터를 대상으로 한다는 점만 다르다."

profile
Rookie

0개의 댓글