G1 GC 정말 깊게 파보기

Minuuu·2025년 4월 10일
0

Java

목록 보기
19/23
post-thumbnail

이 글의 목적은 g1 gc에 대해 깊이 파보고 작성한 글입니다.
최대한 자세하게 기록했지만 몰라도 되는 정보라면 과감하게 지웠습니다. 실제 예시로 글 내용 중 압축이 비싸다는 것에 대한 이유는 딱히 기록하지 않았습니다.
이러한 정보는 몰라도 G1 GC를 이해하는데 문제가 되지 않기 때문에 궁금하시면 직접 찾아보면 좋을 것 같습니다 :)

1. GC란

프로그램이 더 이상 사용하지 않는 메모리를 자동으로 찾아 해제하는 기술입니다.
즉, 메모리에서 사용하고 있는 데이터는 유지하고 사용하지 않는 데이터는 제거하는 기술입니다.

과거의 프로그램에선 메모리를 할당받고 개발자의 실수로 메모리를 해제하지 않는 실수가 많아 어플리케이션에서 메모리 누수가 많이 발생하였습니다. 이러한 실수를 방지하고자 GC가 등장하게 되었습니다.

오늘은 GC중에서 G1 GC를 중점으로 다뤄보겠습니다. 과거의 GC들은 기본적인 내용만 설명드리겠습니다.

2. G1 GC 이전의 가비지 컬렉터들


GC를 검색하면 위의 사진들을 가장 많이 확인할 수 있습니다. 이는 기본적인 G1 GC 이전의 GC를 사용할 때의 Heap 구조입니다.
Permanent는 주요 GC 대상이 아니기에 무시하셔도 됩니다.(이후에 MetaSpace로 이동해 Heap에서 제외됨)

기본적인 과거의 Minor GC 동작과정을 매우 간단히 설명해보겠습니다.

  1. Young의 EdenSpace에 생성한 객체들을 보관합니다.
  2. EdenSpace가 가득차면 GC가 실행됩니다.(young 메모리를 대상하는 GC를 Minor GC라고 칭합니다.)
  3. 루트부터 모든 객체를 탐지하며 살아있는 객체에 마킹을 남깁니다.
  4. 프로그램 어디서도 도달할 수 없는 객체(Unreachable)를 제거합니다.
  5. 살아있는 객체를 비어있는 survivor 영역으로 이동시킵니다. 객체 내부적으로 카운터를 가져 객체가 살아남은 생존횟수를 저장합니다.

4번에 오해할 소지가 있어서 글을 남깁니다.
객체를 제거한다는 것은 사실 일어날 수 없는 일입니다.
왜냐하면 GC의 객체도 Unreachable객체에겐 도달할 수 없기 때문입니다.
즉, GC의 본질은 살아있는 객체는 메모리 어딘가에 옮기고, 기존의 죽은 객체들은 이후에 다른 객체로 덮어쓰기를 하는 개념입니다.

위를 계속하여 반복하다가 객체 내부의 카운터가 특정 임계치를 넘어가면 Old GC로 승격되게됩니다.
5번의 비어있는 survivor 영역으로 이동시킨다는 구체적인 의미는 다음과 같습니다.

Survivor 영역은 보통 두 개(S0, S1)가 있으며, 번갈아가며 사용됩니다. 즉, Eden과 현재 사용 중인 Survivor에서 살아남은 객체들이 다른 비어있는 Survivor로 이동하는 방식(copy)입니다.
이렇게 번갈아가며 이동을 시키는 이유는 메모리 단편화를 방지하고 비싼 압축 작업을 피할 수 있습니다.
추후에 Old에도 가득찬다면 Old를 대상으로 GC하는 Major GC 또한 수행하게 됩니다.

Major GC는 Minor GC에 비해 GC가 빈번하지 않기 때문에 survivor 영역처럼 한 곳을 비워두는 것이 비효율적인 이유로 Major GC는 Copy 방식으로 단편화를 방지하는게 아닌 Compacting(압축) 방식으로 단편화를 방지합니다.

이러한 과정속에서 중요한 개념인 STW(Stop The World)가 등장합니다.

Stop the world

STW는 가비지 컬렉션 과정에서 JVM이 애플리케이션 실행을 일시적으로 멈추는 상황을 말합니다.

GC가 메모리를 정리하는 동안에는 객체의 참조 관계가 변경되지 않아야 정확한 작업이 가능합니다. 따라서 GC 작업 중에는 모든 애플리케이션 스레드가 일시 중지됩니다.
STW 시간은 GC의 종류와 힙 크기에 따라 달라집니다(Heap이 커지면 비용이 비싸집니다)

Minor GC: 일반적으로 짧은 STW 시간 (밀리초 단위) -> 크게 신경쓰지 않아도 된다.
Major GC: 더 긴 STW 시간 (경우에 따라 초 단위까지) -> 진짜 신경써야하는 부분

이러한 STW 시간은 애플리케이션의 응답성에 직접적인 영향을 미치기 때문에, 현대의 GC 알고리즘들은 이 시간을 최소화하는 방향으로 발전해왔습니다. 이는 추후에 조금 더 다뤄보겠습니다.

왜 Young과 Old 영역을 분리했는가

이는 GC를 설계할 때 2가지의 가설을 토대로 설계하였기 때문입니다.

  1. 대부분의 객체는 곧 도달하지 못한다. (Most Objects soon become unreachable)
  2. 오래 살아있는 객체가 젊은 객체를 참조하는건 적다 (Reference from "old" objects to "young objects" only exist in small numbers)

오래 살아남은 객체들은 대부분 다른 오래된 객체들과 연결되어 있고, 새로운 객체들을 많이 참조하지 않습니다.
이 가설 덕분에 Young 영역의 GC를 수행할 때 Old 영역 전체를 검사할 필요가 없어집니다.

// 이 객체들은 메서드 실행 후 곧 사라짐
Order order = new Order(request.getCustomerId());
OrderItem orderItem = new OrderItem(item.getProductId(), item.getQuantity());
PaymentResult payment = paymentGateway.process(...);

이러한 객체들은 메서드가 끝나면 사라지기 때문에 이 특성을 사용해 모든 메모리(heap)를 검사하는게 아니라, 새로 생성된 Young 영역만 자주 검사하는 것이 효율적입니다.
하지만 결국 2번 가설이 문제입니다. 참조하는게 적지만 참조가 필요할 경우가 존재합니다.
이 경우에는 모든 메모리를 검사하는게 아닌, CardTable이라는 자료구조를 통해서 Old가 young을 참조를 추적할 수 있습니다.

GC의 성능

  • 처리량(ThroughPut) : 시스템이 주어진 시간동안 처리할 수 있는 양
  • 지연 시간(Latency) : 어플리케이션에 요청한 정보가 얼마나 빨리 응답하는가?

GC 설계에서는 처리량과 지연 시간 사이에 명확한 트레이드오프가 존재합니다:
높은 처리량 중심 GC(parallel): 긴 STW 시간을 허용하고 GC 작업을 한 번에 완료하여 전체 처리량을 최대화합니다.
낮은 지연 시간 중심 GC(CMS): STW 시간을 최소화하여 응답성을 높이지만, 동시성 제어와 더 복잡한 알고리즘으로 인해 전체 처리량이 다소 감소할 수 있습니다. GC가 애플리케이션과 동시에 실행되면서 CPU와 메모리 사용에 추가 오버헤드가 발생합니다.

-> 만약 이해가 안된다면, 적은 STW에 애플리케이션(WAS)과 GC의 작업을 동시에 처리 vs 긴 STW에 GC 작업 집중으로 생각하시면 편할 것 같습니다.

긴 STW 시간동안 자원을 쏟아 한번에 작업을 처리해 처리량을 늘린다 (빈도는 적지만 GC는 길어 지연시간이 높음)
vs
짧은 STW를 여러번 실행해 지연 시간을 낮춘다. (빈도는 늘어나지만 GC를 짧게해 어플리케이션 지연시간이 낮음)


3줄 요약
1. Minor GC (Young 영역 검사)를 수행하는 것이 STW 관점에서 싸기에 Young/Old 구분 전략을 선택했다.
2. Major GC (Old)의 STW는 부담이 된다.
3. 좋은 GC를 위해선 높은 처리량, 낮은 지연시간이 요구된다.


3. G1 GC (Garbage First GC)

G1가 등장한 배경과 기존 GC의 한계점

기존 GC(CMS, Parallel)은 각각 특정 상황에서 강점을 보였지만 동시에 뚜렷한 한계점도 가지고 있었습니다.

  • Parallel GC: 높은 처리량(throughput)에 최적화되었지만, Stop-the-World 현상으로 인한 긴 GC 일시 정지 시간이 문제였습니다. 또한 힙이 커질 수록 STW 시간이 힙 크기에 비례하여 증가하게 되었습니다.

  • CMS(Concurrent Mark Sweep) GC: 짧은 일시 정지 시간에 초점을 맞춘 나머지 동시성을 문제로 압축을 사용하지 않습니다. 그로인해 메모리 단편화 문제가 있었습니다. 이 단편화 문제로 객체를 할당할 수 없을 때 Full GC가 발생해 Heap의 크기에 따라 느려지는 문제가 발생했습니다.

결국 Heap 크기에 따라 STW가 증가하게 되자, G1 GC는 전체 힙 크기와 상관없이 일정 수준만 처리하도록 설계되었습니다.
즉, Heap 크기 문제를 해결하면서도 높은 처리량과 낮은 지연 시간을 균형적으로 처리하도록 등장한게 G1 GC 입니다.

G1 GC의 힙 구조


전통적인 GC의 Young Generation과 Old Generation으로 분리한 개념을 그대로 가져왔습니다.
전통적인 GC와는 다르게, G1 GC는 Region으로 힙을 균등한 크기로 분할합니다.
이를 통해 전체 힙을 수집하지 않고 특정 Region만 선택적으로 수집이 가능합니다. (여담으로 마킹은 제외입니다.)

선택적으로 수집을 하여 STW시간을 조절할 수 있었습니다. 이전 GC는 STW를 조절할 수 없었지만, G1 GC는 어플리케이션 상황에 맞게 STW를 조절할 수 있습니다.

또한 기존에 없던 Humongous의 경우 하나의 리전으로 부족한 거대의 객체를 담는 곳입니다. Old 영역에 속하게 됩니다.

Thread Local Area Buffer
Java에서 TLAB 없이 객체 할당 시, 모든 스레드가 Eden 영역의 동일한 메모리 포인터에 동시 접근하여 경합이 발생하고 동기화(락)가 필요해 성능이 저하됩니다. TLAB을 사용하면 각 스레드가 Eden 내 자신만의 독점적 메모리 영역을 받아 락 없이 빠른 포인터 이동만으로 객체를 할당할 수 있어, 동기화 오버헤드가 크게 줄고 성능이 향상됩니다

Java의 객체 할당 매커니즘은 객체 크기에 따라 다른 전략을 사용합니다.

3가지 객체의 종류

  1. 객체가 Region의 50% 미만
    • 일반적인 객체로서, Eden영역이나 Survivor 경로에 할당합니다.
    • Thread Local Area Buffer (TLAB) 을 이용한 빠른 할당이 가능합니다.
  2. 객체가 Region의 50% 이상
    • Humongous 객체라고 불리며 특별한 방식으로 관리됩니다.
    • 리전 내에서 크기가 커 메모리 단편화가 발생할 수도 있습니다.
      TLAB을 크기에 따라 쓸 수도 있고 못 쓸 수도 있습니다.
  3. 객체가 Region 총 영역 초과
    • Humogous 객체라고 불리며 특별한 방식으로 관리됩니다. 여러 연속된 리전에 거쳐 할당됩니다.
    • 거대 객체는 TLAB의 크기를 초과하기에 힙 메모리에 직접할당 됩니다.

G1 GC의 객체 크기 모니터링과 수집 전략

  1. 객체의 크기 분포 - 작은 객체(리전의 50% 미만), 중간 객체(50% 이상), 대형 객체(리전 초과)
  2. 각 리전의 "쓰레기" 비율 - 어떤 리전에 가비지가 더 많이 쌓여있는지

쓰레기가 많은 곳부터 치우면 적은 노력으로 많은 공간을 확보할 수 있고, 객체 크기와 분포를 알면 GC 작업의 소요 시간을 예측할 수 있습니다. 또한 큰 객체는 다루기 어려워 특별 관리해 긴 STW를 방지할 수 있습니다.

G1 GC는 객체 크기와 쓰레기 분포를 지속적으로 모니터링하면서 가장 효율적으로 메모리를 확보할 수 있는 리전부터 수집하는 전략을 사용합니다. 이것이 G1 GC의 "Garbage First" 원칙의 실제 의미입니다.


G1 GC의 주요 개념

G1 GC의 동작 방식을 이해하기 위해 몇 가지 핵심 개념을 먼저 살펴보겠습니다.

Remember Set (RSET)

RSET은 각 리전별로 존재하는 자료구조로, 다른 리전에서 이 리전으로의 참조를 추적합니다.
기존 GC에서 CardTable이 Old에서 Young으로의 참조를 추적했다면, RSET은 모든 리전 간의 참조를 추적하여 각 리전이 독립적으로 GC될 수 있도록 합니다. 이를 통해 전체 힙을 스캔하지 않고도 특정 리전만 수집할 수 있습니다.

객체의 생존 여부, GC 과정에서 필요한 정보를 효율적으로 관리하는데 사용된다.

Collection Set(CSET)

CSET은 GC 사이클동안 수집될 리전들의 집합입니다. (정리될 메모리의 집합)
G1 GC는 가비지가 많은 리전을 우선적으로 CSET에 포함시켜 효율적으로 메모리를 확보합니다.
Young GC에서는 모든 Young 리전이 CSET에 포함되고, Space-Reclamation 단계에서는 Young + Old의 일부도
CSET에 포함됩니다. 이를 Mixed Collection이라고 합니다.
모든 Young + 가비지 우선 원칙에 따라 선택된 Old 영역들 = Mixed Collection

특정 GC 사이클에서 실제로 회수될 대상 영역들의 집합으로, Young GC에서는 Eden과 Survivor 영역을, Mixed GC에서는 여기에 가비지가 많은 Old 영역까지 포함하여 메모리 회수 작업을 효율적으로 수행하기 위해 사용된다

+------------------------------+
|           G1 Heap            |
+------------------------------+
|                              |
|   +--------+    +--------+   |
|   |Region 1|    |Region 2|   |
|   |        |<---|  RSET  |   |
|   |        |    |        |   |
|   +--------+    +--------+   |
|        ^                     |
|        |     +--------+      |
|        |     |Region 3|      |
|        +-----|  RSET  |      |
|              |        |      |
|              +--------+      |
|                              |
|   +--------+    +--------+   |
|   |Region 4|    |Region 5|   |
|   |  RSET  |--->|        |   |
|   |        |    |        |   |
|   +--------+    +--------+   |
|                              |
+------------------------------+

                  |
                  | GC 결정
                  v

+------------------------------+
|        Collection Set        |
+------------------------------+
|                              |
|   +--------+    +--------+   |
|   |Region 1|    |Region 3|   |
|   | (Eden) |    | (Eden) |   |
|   +--------+    +--------+   |
|                              |
|   +--------+                 |
|   |Region 5|                 |
|   | (Old)  |                 |
|   +--------+                 |
|                              |
+------------------------------+

RSET(Remember Set): 각 리전별로 가지고 있는 데이터 구조로, 다른 리전에서 이 리전으로의 참조를 기록
CSET(Collection Set): GC 사이클에서 수집될 리전들의 모음

Snapshot At The Beginning (SATB)

SATB는 G1 GC가 사용하는 동시 마킹 알고리즘으로, GC 시작 지점의 객체 그래프 스냅샷을 유지합니다.
이는 마킹 중에 객체 참조가 변경되더라도 일관된 객체 그래프를 유지할 수 있게 해줍니다.

SATB는 애플리케이션 스레드가 계속 실행되는 동안(즉, 동시에) 마킹 작업을 수행할 수 있게 해주는 메커니즘을 제공합니다. 이전엔 일관성을 위해서 마킹 단계에서 STW 방식을 사용했지만 SATB를 이용해 마킹 단계와 어플리케이션을 동시에 처리할 수 있도록 GC 성능을 크게 개선한 기술입니다.

점진적 압축 (Incremental Compaction)

G1 GC는 기존 GC들과 달리 점진적 압축(Incremental Compaction) 방식을 사용합니다. 이는 전체 힙을 한 번에 압축하는 대신, 선택된 리전들만 점진적으로 압축함으로써 STW 시간을 분산시키는 기법입니다.

G1 GC는 각 GC 사이클마다 Collection Set에 포함된 리전들의 살아있는 객체만 새로운 리전으로 이동(evacuation)시키고, 원래 리전은 완전히 비워 재사용합니다. 이 과정을 통해 단편화가 발생한 리전들을 점진적으로 정리하여, 전체 힙의 압축 없이도 메모리 단편화 문제를 효과적으로 관리할 수 있습니다.

이러한 점진적 압축 방식은 G1 GC가 큰 힙에서도 예측 가능한 일시 정지 시간을 유지할 수 있게 하는 핵심 메커니즘입니다.


G1 GC의 동작 단계

G1 gc는 두 가지의 순환적인 사이클로 동작합니다.
Young-Only 단계: 주로 새로 생성된 객체를 관리하는 과정으로, Eden과 Survivor 영역과 같은 영 제너레이션에서만 가비지 컬렉션을 수행하여 살아남은 객체를 올드 제너레이션으로 승격시키는 작업을 합니다.
Space-Reclamation 단계: Old에서 공간을 회수하는 과정으로, 영 제너레이션뿐만 아니라 선택된 올드 제너레이션 영역에서도 살아있는 객체를 대피시켜 메모리 공간을 확보하는 작업을 수행합니다.

쉽게 말해 young-only는 old를 정리할 세팅(모니터링)만 해두고 young gc만 수행하고 있다가,
특정 조건 발생 시, space Reclamation 단계로 나아가 old를 정리한다는 것이다.

G1 GC는 이 두 단계를 번갈아가며 실행하여 새로운 객체는 자주 정리하고 올드 제너레이션은 필요할 때만 처리함으로써 시스템 성능을 최적화합니다.

Young-Only 단계와 Young GC는 같은 것이 아닙니다. Young-Only 단계는 여러 번의 Young GC를 포함하는 더 큰 개념의 단계입니다. Young GC는 Young-Only 단계 내에서 반복적으로 발생하는 개별 가비지 컬렉션 작업입니다.

1. Young-only 단계

마킹에 대한 트리거는 Initial Heap Occupancy Percent(IHOP)일 때 마킹 사이클이 시작됩니다.
IHOP의 기본 값은 45%이며 Adaptive Ihop을 통해 자동으로 IHOP 값이 조정됩니다.
즉, Heap 영역이 45%이상이라면 Initial Marking이 시작합니다.

1. INITIAL MARKING + YOUNG GC
- 범위 : GC 루트(스레드 스택, JNI 참조 등)에서 직접 도달 가능한 객체들만 마킹합니다.
- Piggyback이라는 한 작업에 다른 작업이 편승하여 함께 실행합니다. 둘다 어차피 STW를 해야하기에 최소화.
- 마킹 시점에 SATB를 구성해 모든 살아있는 객체의 스냅샷을 생성합니다.

2. ROOT REGION SCANNING
- 1번의 단계에서 young gc가 일어나 루트 region은 survivor 영역을 의미합니다.
survivor 영역에서 참조하는 모든 객체들을 스캔하여 마킹하는 작업을 수행합니다.
이 작업은 STW를 발생시키지 않으며, 동시 처리가 가능하다 (지연시간이 낮은 요인)Young GC가 일어나서 갱신이 일어나지 못하도록 합니다.(young gc 대기)

3. CONCURRENT MARK
- 이전 단계들에서 마킹된 객체들이 참조하는 모든 객체들로 확장하여 마킹합니다.
- 애플리케이션과 동시에 실행 (STW x)
- SATB 알고리즘을 사용해 힙 전체를 순회하며 살아있는 객체를 마킹합니다.

4. REMARK
- STW로 실행되어, 모든 애플리케이션 스레드 정지합니다.
- 위와 같이 애플리케이션과 동시에 실행이 된다면, 특정 객체의 참조가 바뀔 수 있습니다.
- 3번 과정에서의 찾지 못한 살아있는 객체를 최종적으로 식별하고 마킹하게 됩니다.

쉽게 말해 G1 GC에서는 STW로 초기 스냅샷을 찍고, 중간에 병렬 작업을 수행한 후, 마지막에 다시 STW를 걸어 변경된 참조관계를 동기화해주는 방식입니다.

  1. CLEAN UP
    • 부분적 STW, 부분적 Concurrent로 실행
    • RSET에 있는 객체 정보가 죽은 객체가 된다면 이를 정리해야하기에 RSET 정리(STW)
    • 어디를 청소해야 할지 파악합니다 (빈 Region, 쓰레기가 많은 지역 식별)
    • Mixed GC에서 수집할 Old 영역 후보를 선정 (concurrent)

보면 마킹의 단계가 4단계로 쪼개어져 있는 것을 볼 수 있습니다. 이는 GC의 마킹 단계를 쪼개어 STW가 필요한 경우엔 STW를 실행하고, 필요하지 않으면 STW를 하지 않아 어플리케이션 지연시간을 적게 보장해줍니다. 또한 JVM은 객체의 참조 필드가 변경될 때마다 자동으로 실행되는 코드(Write Barrier)를 삽입하여 참조가 변경되면 동작되도록하여 SATB 큐에 저장하여 갱신된 객체를 파악하여 REMARK 단계에서 최종 일관성을 지킨 마킹이 가능합니다.

참조 변경 감지 동작 방식

초기 상태
A → B → C (A가 B를 참조하고, B가 C를 참조)

마킹 시작
루트에서 시작해서 A를 마킹함

동시 마킹 중 참조 변경 발생
애플리케이션이 B.field = D로 B의 참조를 C에서 D로 변경
변경 직전에 Write Barrier가 실행됨
Write Barrier는 원래 참조값 C를 SATB 큐에 저장 (이것이 핵심)
참조가 실제로 B → D로 변경됨

마킹 계속 진행
마킹은 계속해서 A → B → D 경로를 따라감
D도 마킹됨 (현재 참조가 D이므로)

Remark 단계
SATB 큐에서 C를 발견하고 C도 마킹
결과적으로 C와 D 모두 마킹됨


2. Space Reclamation

이 단계에서는 Mixed GC가 수행되어 메모리를 정리합니다.
이전에 CSET을 설명할 때 GC가 내부적으로 선정한 young + old가 mixed Collection이 되어 정리대상이 된다고 하였습니다.
즉, Clean UP 단계에서 결정된 Collection Set들을 대상으로 STW가 발생하며, 메모리 회수 작업이 시작하게 됩니다.


G1 GC의 Full GC

G1 GC의 주요 목표 중 하나는 전체 힙에 대한 Full GC를 피하는 것이지만, 다음과 같은 상황에서는 여전히 Full GC가 발생할 수 있습니다

  1. Evacuation Failure (대피 실패)

    • 살아있는 객체를 다른 리전으로 복사하려 할 때 충분한 공간이 없는 경우 발생합니다.
  2. 동시 모드 실패

    • 메모리 할당 속도가 동시 마킹 속도를 따라가지 못하는 경우 발생합니다.
    • Concurrent Mark 과정이 완료되기 전에 메모리가 고갈될 위험이 있을 때 발생합니다.
  3. 메타스페이스 부족

    • 클래스 메타데이터를 위한 메타스페이스가 부족할 경우 발생합니다.

위와 같은 경우엔 FULL GC가 발생할 수 있다. Java10부터 어느정도 개선이 되었지만 G1 GC는 Full GC를 피하는게 주 목적이기 때문에 만약 이러한 경우엔 적절한 GC 옵션을 수정해 해결해야합니다.

다 몰라도 이것만 기억하자

  1. GC란 살아있는 객체를 마킹해서, 객체를 안전한 장소로 이동하는 것. 나머지 공간을 비워서 새로운 객체를 위한 메모리로 재활용하는 것입니다.
  2. 결국엔 Stop the world를 최소화하며, 전체 heap을 탐색하지 않는 것이 목적이며 이를 통해 이전 GC는 STW를 조절할 수 없었지만, G1 GC는 어플리케이션 상황에 맞게 STW를 조절할 수 있습니다.
  3. 실제로 마킹 과정속에서 최대한 동시성을 확보해 지연시간을 낮추고 STW를 최대한 적게 사용합니다.
  4. 애플리케이션이랑 동시에 실행되니, 객체를 마지막에 동기화 시켜줍니다.
  5. 이렇게 마킹을 마치고 G1 GC 내부에서 비용을 계산해 높은 우선도의 객체를 해제합니다.

이렇게 글을 마무리하고자 합니다.
이 글을 쓰기 위해 정말 많은 공식문서, GC 제작자 블로그, 제작자 컨퍼런스 등 수많은 자료를 찾아보았습니다.
정말 최대한 이해를 돕고 싶지만, 너무나 어려운 내용임을 알기에 궁금한 점 댓글로 남겨주시면 최대한 도와드리겠습니다.
감사합니다 :) 좋은 하루 보내세요

profile
꾸준히 한걸음씩 나아가려고 하는 학부생입니다 😄

0개의 댓글