2주차 Unit 4.2 — G1 GC의 등장 배경

Psj·2026년 5월 15일

F-lab

목록 보기
65/230

Unit 4.2 — G1 GC의 등장 배경

F-LAB JAVA · 2주차 · Phase 4 · G1 GC 심화


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • 2000년대 → 2010년대 → 현재의 JVM 메모리 환경 변화는?
  • Serial / Parallel / CMS GC의 결정적 한계는?
  • 큰 힙(>4GB) 에서 기존 GC가 느린가?
  • G1 GC의 정지시간 예측 모델(Pause Prediction Model) 은 무엇인가?
  • -XX:MaxGCPauseMillis=200 옵션의 정확한 의미는?
  • G1이 어떻게 시간 약속을 지키는가?
  • ILIC 운영 서버에 어떤 GC를 선택해야 하나?

🎯 핵심 한 문장

G1 GC는 "큰 힙 시대의 응답"이다.
2GB 시절 GC가 그대로 32GB에 적용되면 STW가 분 단위로 폭발한다.
G1은 힙을 여러 작은 리전(Region) 으로 분할하고, 정지시간 한도 안에서 회수 효과가 큰 리전부터 처리하는 새 접근을 도입.
이로써 "M밀리초 안에 끝낼게" 라는 SLA를 약속할 수 있게 됐다.

비유 — 도서관의 청소 전략

시대비유
소형 도서관 (서가 1~2개)Serial GC — 혼자 한 번에 청소. 5분
중형 도서관 (서가 10개)Parallel GC — 여러 사람이 동시 청소. 1분
대형 도서관 (서가 1000개)CMS — 청소 중에도 손님이 책 빌릴 수 있게
거대 도서관 (서가 10000개)G1 — "오늘 30분 청소 가능. 가장 더러운 서가부터"

도서관이 작을 땐 단순한 전략 OK.
커질수록 "전부 다 청소"가 비현실적 → 우선순위 + 시간 약속.


🧭 9개 섹션 로드맵

1. 시대적 배경 — 메모리가 점점 커지고 있다
2. Serial GC — 1세대의 단순함과 한계
3. Parallel GC — 멀티코어의 활용
4. CMS GC — 동시 마킹의 시도
5. 큰 힙의 저주 — STW 시간 폭발
6. G1 GC의 등장 — 새로운 접근
7. 정지시간 예측 모델
8. ILIC 실무 — GC 선택 가이드
9. 면접 질문 + 자기 점검

1️⃣ 시대적 배경 — 메모리가 점점 커지고 있다

1.1 JVM 메모리 환경의 진화

1995년 (Java 1.0):
  - 단일 코어 CPU
  - RAM: 16~64 MB
  - JVM Heap: 수 MB
  → Serial GC로 충분

2000년대 초:
  - 2~4코어 CPU
  - RAM: 1~4 GB
  - JVM Heap: 256MB ~ 1GB
  → Parallel GC 등장

2010년대:
  - 8~16코어 CPU
  - RAM: 32~64 GB
  - JVM Heap: 4~16 GB
  → CMS, G1 필요

2020년대 (현재):
  - 32~128코어 CPU
  - RAM: 256GB+
  - JVM Heap: 32~256 GB
  → G1, ZGC, Shenandoah

하드웨어는 100배 커졌는데, GC가 그대로면? → 재앙.

1.2 ILIC 서버의 현실

ILIC 운영 서버 (가정):
  - Spring Boot 앱
  - JVM Heap: 4 GB
  - 평균 객체 수: 수백만 개
  - 응답 SLA: P99 < 500ms

만약 GC가 1초씩 STW 한다면:

  • 그 1초 동안 모든 요청 멈춤
  • P99 응답시간 1000ms+
  • SLA 위반
  • 사용자 경험 악화

GC 시간 통제가 운영 안정성의 핵심.

1.3 GC가 풀어야 할 5가지 과제

1. 처리량 (Throughput)
   - 단위 시간당 처리할 수 있는 요청 수
   - GC 시간이 적을수록 ↑

2. 응답 시간 (Latency)
   - 개별 요청의 응답 시간
   - STW 짧을수록 ↑

3. 메모리 효율 (Footprint)
   - 같은 작업에 쓰는 메모리
   - GC 자체의 메모리 오버헤드

4. 시간 예측 가능성 (Predictability)
   - "최악의 경우 얼마나 멈출까?"
   - SLA 보장

5. 큰 힙 처리 (Large Heap)
   - 32GB+ 힙에서도 동작

각 GC는 이 5가지를 다른 비율로 만족.


2️⃣ Serial GC — 1세대의 단순함과 한계

2.1 동작 방식

Serial GC:
  - 단일 스레드로 GC 수행
  - 모든 애플리케이션 스레드 정지 (STW)
  - 마킹 → 청소 → (압축)
  - 단순하지만 멀티코어 활용 못 함
java -XX:+UseSerialGC App

2.2 비유

1명의 청소부가 사무실 청소:
  모든 직원 퇴근 → 청소부 혼자 작업 → 다음 날 출근

사무실이 작으면 OK
사무실이 크면 청소 시간 길어짐 → 직원 못 출근

2.3 적합한 환경

  • 클라이언트 사이드 (데스크탑 앱)
  • 단일 CPU 환경
  • 작은 메모리 (수백 MB)
  • 임베디드, IoT 디바이스

2.4 ILIC 같은 서버에 부적합

ILIC 서버:
  - 16코어 CPU
  - 4GB Heap
  - 동시 처리 요청 다수

Serial GC 사용 시:
  - 1코어만 GC 사용 → 나머지 15코어 놀음
  - GC 시간 매우 김
  - 운영 안 됨

운영 서버에선 절대 사용 X.

2.5 그래도 알아야 하는 이유

면접 단골:
  "Serial GC와 Parallel GC 차이는?"
  
→ Serial은 단일 스레드, Parallel은 멀티 스레드 GC.
→ 둘 다 STW 발생.

기초 개념. 다른 GC를 이해하는 출발점.


3️⃣ Parallel GC — 멀티코어의 활용

3.1 동작 방식

Parallel GC (Throughput GC):
  - 여러 스레드로 GC 수행
  - 여전히 STW (모든 앱 스레드 정지)
  - GC 시간 = Serial / N (CPU 코어 수)
  - 처리량 우선
java -XX:+UseParallelGC App         # Java 8 기본
java -XX:+UseParallelOldGC App      # Old gen도 Parallel

3.2 비유

청소부 16명이 사무실 청소:
  여전히 모든 직원 퇴근해야 함
  하지만 청소 시간이 1/16로 줄어듦

3.3 처리량 최적화

처리량 = 애플리케이션 시간 / 전체 시간

만약 1시간 중:
  - GC: 1분 (1.7%)
  - 앱: 59분
처리량 = 98.3%

목표: 처리량 최대화
→ GC 시간을 줄이는 게 핵심

Parallel GC는 이 목표에 최적.

3.4 한계 — 여전히 STW

Heap 4GB:
  Parallel GC Full GC 시간: ~400ms (8코어 기준)

Heap 16GB:
  Full GC 시간: ~1.5초

Heap 32GB:
  Full GC 시간: ~3초+

힙 크기에 비례해서 STW 증가.

3.5 ILIC 같은 SLA 민감 서비스 부적합

ILIC 응답 SLA: P99 < 500ms

Parallel GC + 4GB Heap:
  보통: 20ms
  Full GC 시: 400ms ← 가끔 SLA 근접
  GC 폭발 시: 1초+ ← SLA 위반

처리량은 좋지만, 응답 시간 안정성 부족.


4️⃣ CMS GC — 동시 마킹의 시도

4.1 동작 방식

CMS (Concurrent Mark Sweep):
  - 4단계:
    1. Initial Mark (STW, 짧음)
    2. Concurrent Mark (앱과 동시)
    3. Remark (STW, 짧음)
    4. Concurrent Sweep (앱과 동시)
  - 대부분의 GC 작업을 앱과 동시 수행
  - STW 시간 단축
java -XX:+UseConcMarkSweepGC App

4.2 비유

청소부가 손님 받는 도중에 청소:
  - 출입구 입장 통제 (STW 1초)
  - 청소 (손님 받으면서)
  - 정리 정돈 (STW 30초)
  - 다시 청소 (손님 받으면서)

→ 도서관 닫는 시간 짧음
→ 하지만 청소가 비효율적

4.3 CMS의 장점

1. STW 시간 짧음 (Initial Mark + Remark만)
2. 응답 시간 안정
3. 인터랙티브 서비스에 적합

4.4 CMS의 한계 — Java 9 deprecated, Java 14 제거

CMS는 결국 Java 9에서 deprecated, Java 14에서 완전 제거됨.

이유:

한계 1: Concurrent Mode Failure

앱이 너무 빨리 객체 생성:
  Concurrent Sweep 중에 Old gen 가득 참
  → Full GC 발동 (Serial!)
  → 매우 긴 STW (10초+)
  → "Concurrent Mode Failure" 로그

한계 2: 단편화 (Fragmentation)

CMS는 Sweep만 함 (Compaction 안 함)
→ Old gen에 작은 빈 공간들 누적
→ 큰 객체 할당 시 공간 부족
→ Full GC 발동

한계 3: CPU 비용

Concurrent 작업이 앱 스레드와 동시 실행
→ 앱 CPU 사용량 감소 (10~20%)
→ 처리량 손해

한계 4: 시간 예측 불가

"이번 GC가 언제 끝날까?" 알 수 없음
→ SLA 약속 어려움

→ G1이 이 모든 문제를 해결하려고 등장.

4.5 CMS의 시대적 의의

CMS의 기여:
  - "Concurrent" 개념 도입
  - STW 시간 단축의 가능성 증명
  - G1 / ZGC / Shenandoah의 영감

CMS의 종말:
  - 단편화 문제 해결 못 함
  - 큰 힙에서 한계
  - G1으로 대체

5️⃣ 큰 힙의 저주 — STW 시간 폭발

5.1 문제의 본질

기존 GC들의 공통점:

GC 한 번 = 영역 전체를 한꺼번에 처리
  - Minor GC: Young Gen 전체
  - Major GC: Old Gen 전체
  - Full GC: 모든 영역

힙이 클수록:
  - 마킹할 객체 많음
  - 청소할 메모리 많음
  - 복사할 데이터 많음
  → STW 시간 비례 증가

5.2 데이터로 보기

가상 측정 (Parallel GC 기준):

Heap 크기Minor GC 평균Full GC 평균
1 GB20 ms200 ms
4 GB80 ms800 ms
16 GB300 ms3 초
32 GB600 ms6 초+
64 GB1.2 초12 초+

선형 증가. 32GB 힙 = 6초 STW.

5.3 SLA 관점

SLA P99 < 500ms 인 서비스:

Heap 1GB: ✓ Full GC 가끔이라면 OK
Heap 4GB: △ Full GC 시 SLA 위반
Heap 16GB: ❌ 매번 Full GC가 SLA 위반
Heap 32GB+: ❌❌ 실시간 서비스 불가능

5.4 큰 힙이 필요한 이유

그러면 작은 힙만 쓰면 되지 않나? 그게 안 되는 이유:

ILIC 같은 서비스:
  - 캐시: Redis 못 쓸 때 in-memory cache
  - 대용량 데이터 처리: 1만 건 화물 배치
  - 사용자 세션 보관
  - 풀링 (Connection, Thread)
  - 다양한 라이브러리들의 메모리 (수백 MB)

→ 4GB 이하로 운영 어려움
→ 8~16GB 권장

5.5 시대의 요구

2010년대:
  - 클라우드 시대
  - 마이크로서비스
  - 큰 힙 + 짧은 STW 동시에 필요
  - 기존 GC로 불가능

해결책:
  - GC 알고리즘의 근본적 변화 필요
  → G1 GC 등장

6️⃣ G1 GC의 등장 — 새로운 접근

6.1 G1의 기본 아이디어

기존 GC: "Young/Old 전체를 한 번에"
G1: "힙을 작은 리전으로 나누고, 우선순위로 일부만"

6.2 핵심 혁신 3가지

혁신 1: 리전(Region) 기반 분할

기존 GC:
  ┌─────── Young ────────┬───── Old ──────┐
  │ Eden | Surv | Surv  │                  │
  └─────────────────────┴──────────────────┘
  영역 크기와 위치 고정

G1 GC:
  ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
  │E│ │O│ │S│O│ │O│E│ │ │S│ │O│E│ │O│
  └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
  같은 크기의 리전들. 역할은 동적 결정.

Unit 4.3에서 정밀히. 이번엔 개요만.

혁신 2: 점진적 회수

기존 GC: Full GC = 전체 처리 = 긴 STW
G1: 매번 일부 리전만 처리 = 짧은 STW의 누적

비유:
  기존: 매년 한 번 대청소 (집 전체, 1주일)
  G1:   매주 일부 방 청소 (1방, 1시간)

혁신 3: 정지시간 예측 모델

"이번 GC는 200ms 안에 끝낼게"

G1이 어떻게 약속을 지키나?
  1. 각 리전의 회수 시간 학습 (이전 기록)
  2. 시간 한도 안에 처리 가능한 리전만 선택
  3. 시간 부족하면 일부 리전 다음 사이클로

6.3 G1 GC 옵션

# G1 활성화 (Java 9+ 기본)
java -XX:+UseG1GC App

# 정지시간 목표
java -XX:MaxGCPauseMillis=200 App   # 기본 200ms

# 리전 크기
java -XX:G1HeapRegionSize=16m App   # 기본 자동

# 힙 크기
java -Xms4g -Xmx4g App

6.4 Java 9 이후 기본 GC

Java 8:    Parallel GC 기본
Java 9+:   G1 GC 기본 ★
Java 17+:  G1 GC 기본, ZGC/Shenandoah 옵션
Java 21+:  Generational ZGC 추가

→ 박승제씨가 Java 17 환경이면 이미 G1 사용 중.

6.5 G1 = "Garbage First"

G1의 이름의 유래:
  Garbage First
  
의미:
  "쓰레기가 가장 많은 리전부터 회수"
  
즉:
  - 모든 리전을 다 회수하지 않음
  - 회수 효과(여유 공간 / 시간) 가장 큰 리전 선택
  - 시간 한도 안에서 최대 효과

Unit 4.5에서 상세. 우선순위 회수.


7️⃣ 정지시간 예측 모델

7.1 어떻게 약속을 지키나

사용자: "GC 200ms 안에 끝내줘"
G1: "알겠어. 가능한 만큼만 할게"

내부 동작:
  1. 과거 데이터: "리전 1개 처리에 평균 20ms 걸렸음"
  2. 시간 한도 200ms → 약 10개 리전 처리 가능
  3. 가장 효과 큰 10개 리전 선택
  4. 그것만 처리 → 약 200ms 완료
  5. 나머지 리전은 다음 사이클로

7.2 자기 점검 답변

정지시간 예측 모델은 어떻게 시간을 통제하는가?

:
1. 각 리전의 회수 비용을 과거 데이터로 추정
2. 시간 한도 안에 처리 가능한 리전 수 계산
3. 회수 효과 큰 리전을 우선 선택
4. 시간 부족하면 일부는 다음 사이클로 연기

시간을 약속할 수 있는 GC.

7.3 약속의 한계

G1도 항상 200ms 안에 끝내는 건 아님:

약속이 깨지는 경우:
  1. 너무 큰 객체 처리 (거대 리전, Unit 4.4)
  2. Old gen 가득 차서 Full GC 발동
  3. 메타데이터 부족 (Metaspace)
  4. 매우 큰 root set (수십만 스레드)

→ 200ms는 목표, 보장은 아님
→ 평소엔 거의 지킴 (90~95%)

7.4 ZGC, Shenandoah와의 비교

더 엄격한 SLA는 Java 11+의 ZGC, Shenandoah:

G1:        목표 < 200ms, 평균 50~100ms
ZGC:       목표 < 10ms (대부분 달성)
Shenandoah: 목표 < 10ms (대부분 달성)

ZGC/Shenandoah는:

  • 더 새로운 알고리즘
  • 더 적극적인 동시성
  • 큰 힙(수십 GB)에서도 ms 단위 STW
  • 하지만 처리량은 G1보다 약간 낮음

→ ILIC가 P99 50ms 같은 매우 엄격한 SLA면 ZGC 검토.
→ 일반적인 SLA(P99 500ms)는 G1으로 충분.

7.5 모니터링 — 약속이 지켜지나

# GC 로그 활성화 (Java 11+)
java -Xlog:gc*:file=gc.log -jar app.jar

# 로그 예:
# [0.123s][info][gc] GC(0) Pause Young (G1 Evacuation Pause) 50M->10M(256M) 15.234ms
#                                                                          ↑ 실제 STW 시간

# 분석 도구: GCViewer, GCEasy

운영에서 GC 로그 모니터링은 필수.
"평균 50ms, P99 150ms" 같은 통계 확인.


8️⃣ ILIC 실무 — GC 선택 가이드

8.1 의사결정 매트릭스

┌──────────────────────────────────────────────────┐
│ ILIC 같은 서비스의 GC 선택                          │
├──────────────────────────────────────────────────┤
│ Heap < 1GB, 단순 batch:                            │
│   → Serial GC                                     │
├──────────────────────────────────────────────────┤
│ Heap 1~4GB, 처리량 우선:                            │
│   → Parallel GC                                   │
├──────────────────────────────────────────────────┤
│ Heap 1~32GB, 응답시간 우선:                         │
│   → ★ G1 GC (가장 권장)                            │
├──────────────────────────────────────────────────┤
│ Heap 8GB+, 매우 엄격한 SLA (P99 < 50ms):           │
│   → ZGC 또는 Shenandoah                           │
├──────────────────────────────────────────────────┤
│ Heap 256GB+:                                       │
│   → ZGC (큰 힙 전문)                              │
└──────────────────────────────────────────────────┘

8.2 ILIC 운영 권장

ILIC Spring Boot 앱 (가정):
  - JVM: Java 17 LTS
  - Heap: 4GB
  - 응답 SLA: P99 < 500ms

권장 옵션:
  java -Xms4g -Xmx4g \
       -XX:+UseG1GC \
       -XX:MaxGCPauseMillis=200 \
       -Xlog:gc*:file=/var/log/gc.log:time:filecount=10,filesize=10M \
       -XX:+HeapDumpOnOutOfMemoryError \
       -XX:HeapDumpPath=/var/log/heap-dumps/ \
       -jar ilic-app.jar

옵션 해석:

  • -Xms4g -Xmx4g: 초기/최대 힙 4GB (동일하게 설정해 동적 조정 방지)
  • -XX:+UseG1GC: G1 명시 (Java 17 기본이지만 명시 권장)
  • -XX:MaxGCPauseMillis=200: 200ms 목표
  • -Xlog:gc*: GC 로그 (G1 모니터링)
  • -XX:+HeapDumpOnOutOfMemoryError: OOM 시 자동 dump

8.3 ZGC 검토 시점

ILIC가 다음 조건이면 ZGC 검토:

1. P99 < 100ms 이상 엄격한 SLA
2. Heap > 16GB
3. Java 17+ 사용
4. 처리량이 최우선 아님

옵션:
  -XX:+UseZGC
  -XX:+UseLargePages (선택)

8.4 GC 튜닝의 함정

❌ "성능이 떨어졌어 → GC 옵션 더 추가"

올바른 접근:
  1. GC 로그 분석으로 진짜 문제 식별
  2. 어디서 시간 소비되는지 측정
  3. 한 번에 하나씩 변경
  4. 부하 테스트로 효과 검증
  5. 문제없으면 옵션 추가 안 함

8.5 ILIC 운영 사고 시나리오

시나리오: 갑자기 P99 응답시간 폭발

1. GC 로그 확인:
   tail -100 /var/log/gc.log | grep "Pause"
   
   결과:
   GC(123) Pause Full (Allocation Failure) 1234.567ms ← Full GC 발동!

2. 원인 분석:
   - Old gen 가득 참?
   - 큰 객체 할당 시도?
   - 메모리 누수?

3. Heap dump 분석:
   jmap -dump:format=b,file=heap.hprof <PID>
   # → MAT 또는 jhat로 분석

4. 원인 식별 후 해결

8.6 운영 체크리스트

ILIC 운영 정기 점검:

☐ GC 로그 일일 모니터링
☐ 평균/P99/P999 GC STW 시간 추적
☐ Full GC 빈도 (이상적 0회/일)
☐ Old gen 사용률 추세
☐ Metaspace 사용률 추세
☐ 응답 시간 vs GC 시간 상관관계

9️⃣ 면접 질문 + 자기 점검

9.1 면접 단골 질문 매핑

Q핵심 답변
Serial vs Parallel GC 차이?단일 vs 멀티스레드. 둘 다 STW
CMS의 한계?Concurrent Mode Failure, 단편화, Java 14에서 제거
큰 힙에서 GC가 느린 이유?영역 전체 처리. 크기에 비례
G1의 핵심 아이디어?리전 분할 + 우선순위 회수 + 시간 예측
Pause Prediction Model?과거 데이터로 시간 추정. 한도 안에서 효과 큰 리전 선택
Java 8/9/11/17의 기본 GC?Parallel/G1/G1/G1
ZGC와 G1 차이?ZGC는 < 10ms 보장. 더 새로운 알고리즘
MaxGCPauseMillis 의미?GC 목표 시간. 보장은 아님
Full GC가 발생하는 이유?Old gen 가득. 큰 객체. Metaspace 부족
G1이 약속 못 지키는 경우?거대 리전, Full GC 발동, 매우 큰 root set

9.2 자기 점검 체크리스트

기본 이해

  • GC 진화 역사를 안다 (Serial → Parallel → CMS → G1 → ZGC)
  • 각 GC의 동작 방식 차이를 설명할 수 있다
  • 큰 힙에서 기존 GC가 느린 이유를 안다
  • G1의 3가지 혁신을 안다 (리전, 점진, 예측)
  • CMS가 Java 14에서 제거된 이유를 안다

실전 적용

  • Heap 크기에 따라 GC를 선택할 수 있다
  • G1 GC 옵션을 설정할 수 있다
  • GC 로그를 활성화하고 분석할 수 있다
  • Full GC 원인을 추적할 수 있다
  • ZGC 검토 시점을 판단할 수 있다

면접 대비 — 5분 답변

  • GC 진화의 시대적 배경
  • G1의 핵심 혁신과 등장 이유
  • 정지시간 예측 모델의 동작
  • 실무에서 GC 선택 기준
  • GC 튜닝의 우선순위

🎯 핵심 요약 — 3줄 정리

1. 기존 GC의 한계 = 큰 힙 시대의 부적합

  • Serial/Parallel: STW 시간 힙 크기에 비례 → 큰 힙 무리
  • CMS: 단편화 + Concurrent Mode Failure → Java 14 제거
  • 16GB+ 힙에서 Full GC 수 초 → SLA 보장 불가

2. G1 GC의 3가지 혁신

  • 리전 기반 분할 (Unit 4.3)
  • 점진적 회수 (Unit 4.5)
  • 정지시간 예측 모델 (이번 Unit)

3. ILIC 실무 가이드

  • Heap 1~32GB + SLA P99 500ms: G1 권장
  • -XX:+UseG1GC -XX:MaxGCPauseMillis=200
  • 엄격한 SLA (P99 < 50ms) 시 ZGC 검토
  • GC 로그 모니터링 + Heap dump 분석이 운영의 기본

📚 다음으로...

Unit 4.3 — G1 GC의 리전(Region) 기반 레이아웃

이번 Unit에서 G1이 왜 등장했는지 봤다면, 다음은 리전 모델의 정밀한 구조.

  • 리전의 크기와 개수 결정
  • Eden/Survivor/Old의 동적 할당
  • Remembered Set과 Card Table
  • Young Collection vs Mixed Collection
  • 운영 시 리전 크기 튜닝

2주차 진행 상황

✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
✅ Phase 3 — 바이트코드와 상수 풀 (3.1 ~ 3.4 완주, 정점)
🚀 Phase 4 — G1 GC 심화
  ✅ Unit 4.1 참조 카운팅의 한계
  ✅ Unit 4.2 G1 GC의 등장 배경 ← 여기
  ⏭ Unit 4.3 리전 기반 레이아웃
  ⏭ Unit 4.4 거대 리전
  ⏭ Unit 4.5 우선순위 기반 회수
⏭ Phase 5 — 컬렉션 내부 구조
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer
profile
Software Developer

0개의 댓글