티스토리 뷰

다른 시리즈 보기

 

 

드디어 Java의 실행원리 마지막 이야기인 Execute Engine에 관련된 이야기이다. 개발자가 작성한 Java 코드가 자바 컴파일러에 의해 바이트 코드로 바뀌고 클래스로더에 의해 Runtime Data Areas에 올려지면서 사용할 준비가 완료되었다. Execute Engine은 이 바이트코드를 어떻게 실행하는지 살펴보고 Garbage Collector의 원리도 살펴보도록 하자.

 

 

Execute Engine

그림1. Java의 실행원리 Execute Engine

 

Execute Engine은 클래스로더를 통해 JVM의 런타임 데이터 영역에 할당된 바이트 코드를 명령어 단위로 읽어와서 네이티브 코드(기계 코드, 바이너리 코드, 0과 1)로 바꿔서 실행한다. 실행 중인 애플리케이션의 각 스레드는 JVM Execute Engine의 개별 인스턴스이다.

 

그리고 실행 엔진은 이미 다른 언어로 작성된 라이브러리가 있어서 자바로 재작성 하고 싶지 않은 경우, 자바가 시스템 디바이스에 접근하거나 플랫폼 특정적인 작업을 원하는 경우, 자바로 구현하기에는 느리고 C/C++를 사용해서 성능 향상을 원하는 경우에 Host 운영체제에 있는 C/C++로 작성된 Native Method Libraries를 사용한다. 이때 네이티브 메서드 라이브러리를 호출할 수 있도록 도와주는 인터페이스가 바로 JNI(Java Native Interface)이다.

 

바이트 코드는 컴퓨터가 이해할 수 있는 언어가 아닌 JVM이 이해할 수 있는 언어라고 했었다. 그렇다면 실행 엔진은 어떻게 컴퓨터가 이해하는 언어인 네이티브 코드로 바꿀까?

 

 

Interpreter

Interpreter는 바이트 코드를 명령어 단위로 한 줄씩 읽어서 네이티브 코드로 해석(변환)하고 순차적으로 실행한다. 컴파일러와는 다르게 한 줄 한 줄 해석하고 실행하는 과정을 거치기 때문에 번역 속도 자체는 컴파일러보다 빠르지만 프로그램 전체적인 실행 속도는 컴파일러보다 느리다. 컴파일러는 영어로 작성된 파일을 통으로 해석하고 번역본을 만드는 행위이고 인터프리터는 실시간 통역의 개념으로 이해하자. 컴파일러와 인터프리터에 관한 내용은 [Computer Science]컴파일러와 인터프리터를 참고하자.

 

즉, 바이트 코드로 작성된 클래스 파일을 한 줄 한 줄 읽어서 네이티브 코드로 바꾼다는 건데 자주 실행되는 코드를 매번 Interpreter에 의해 변환한다는 것은 비효율적이다. 이러한 단점을 극복하기 나온 것이 JIT 컴파일러이다.

 

 

JIT Compiler (Just-In-Time Compiler)

JIT Compiler는 바이트 코드가 인터프리터 방식으로 실행되다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드로 변환하고 이후에는 해당 메서드를 더이상 인터프리터 방식으로 실행하지 않고 네이티브 코드로 직접 실행하는 방식이다. 즉, 적절한 시기에 바이트 코드를 캐싱하고 최적화함으로써 자주 호출되는 메서드가 인터프리터에 의해 여러 번 변환되는 작업의 수를 줄일 수 있다.

 

실행 속도가 가장 빠른 것부터 C언어, Java, Python이라는 이유가 이 때문이다. C언어는 완전한 컴파일러 언어이고 Java는 인터프리터와 JIT 컴파일러를 섞어서 쓰는 방식, Python은 인터프리터 언어이기 때문이다.

 

JIT 컴파일러가 바이트코드 전체를 컴파일하는 과정은 당연하게도 인터프리터가 매번 실행하는 과정보다는 오래 걸릴 것이다. 그래서 한 번만 실행되는 코드라면 컴파일하지 않고 인터프리팅 하는 것이 훨씬 유리하다. 그렇다면 인터프리팅 하다가 어느 시점부터 JIT 컴파일러로 컴파일 하는 것이 유리할까?

 

컴파일 임계치(Compile Threshold) = JVM 내에 있는 메서드가 호출된 횟수 + 메서드가 루프를 빠져나오기 전까지 돌아간 횟수를 참고한다. JVM이 호출되는 메서드 각각에 호출 횟수를 누적시키는데, 컴파일 임계치 수치를 넘어서는 시점부터 인터프리팅 방식보다 JIT 컴파일러 방식이 유리하다는 의미이다. 컴파일 임계치를 넘어서서 컴파일되는 것이 유리하다면, 해당하는 메서드는 컴파일되기 위해 큐에서 대기하다가 이후 컴파일 스레드에 의해 컴파일된다. 

 

// 코드 출처 https://www.slideshare.net/dougqh/jvm-mechanics-understanding-the-jits-tricks-93206227

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 500; ++i) {
            long startTime = System.nanoTime();
            for (int j = 0; j < 1000; ++j) {
                new Object();
            }

            long endTime = System.nanoTime();
            System.out.println("반복횟수 : " + i + ", 실행시간 측정 : " + (endTime - startTime));
        }
    }
}

 

그림2. 루프 메서드에 대한 나노초 단위 시간 측정

 

그림3.&nbsp;https://www.slideshare.net/dougqh/jvm-mechanics-understanding-the-jits-tricks-93206227

 

측정된 실행 시간을 보면 루프 횟수가 어느 부분에서 급격히 줄어드는 것을 확인할 수 있다. 1차적으로 한 번 줄어들었고 2차적으로 한 번 줄어든 것을 확인이 가능하다. 그림과 같이 나노초 단위의 실험 통계자료도 존재한다.

 

이렇게 컴파일 임계치를 넘어 JIT 컴파일러에 의해 컴파일된 코드가 생긴 이후에, JVM 스택 프레임에 올라가있던 인터프리팅 된 코드를 발견한다면 이를 컴파일된 코드로 교체하여 속도를 개선하는 작업도 진행한다. 이러한 작업을 On-Stack Replacement(OSR)이라고 한다.

 

그림4. [Java의 실행원리 1편]에서 나온 컴파일러 그림

 

[Java의 실행원리 1편] Compile-time 환경에서도 언급했지만, 바이트 코드를 네이티브 코드로 변환하는 과정에 있어서, JIT Compiler는 코드 최적화에 해당하는 부분이기에 최적화에 관하여 가장 중요하게 생각한다. 하지만, 실행 엔진이 어떻게 동작해야 하는지는 JVM 명세에 규정되지 않아서, 벤더마다 구현이 다를 수 있다. JVM 벤더들은 다양한 방법으로 실행 엔진을 구성하는데, 오라클 Hotspot JVM 기준으로 설명하자면 핫스팟 컴파일러라는 JIT 컴파일러를 사용한다.

 

그림5. JIT Compiler의 동작 과정

 

그림과 같이 JIT 컴파일러는 바이트코드를 중간 단계의 표현으로 변환하여 최적화를 수행하고 그를 기반으로 Code Generator에서 네이티브 코드를 생성한다. 오라클 Hotspot JVM에서 사용하는 Hotspot Compiler는 내부적으로 Profiler를 통한 프로파일링으로 가장 컴파일이 필요한 부분인 Hotspot을 찾아내고 이 부분을 네이티브로 컴파일하기 때문에 Hotspot 컴파일러라고 한다. 또, Hotspot Compiler는 한 번 컴파일된 바이트코드라도 해당 메서드가 더 이상 자주 불리지 않는다면 ( = 핫스팟이 아니게 된다면 ) 캐시에서 네이티브 코드를 덜어내고 다시 인터프리팅 방식으로 동작한다. 

 

C언어에 비해 마냥 느리기만 할거라는 Java는 이렇게 JIT 컴파일러 뿐만이 아닌 다양한 최적화 기법을 적용하며 JVM의 Execute Engine을 개선하고 속도를 향상시키고 있다. 

 

 

Garbage Collector

Garbage Collector는 메모리를 자동으로 관리하여 Heap 영역의 인스턴스 중 더이상 사용되지 않는 객체에 할당된 메모리를 자동으로 해제하는 역할을 수행한다. 이 덕분에 개발자는 개발에 조금 더 집중하게 되었고 Garbage Collector가 사용되지 않는 인스턴스에 할당된 메모리를 해제하기에 메모리 누수(Memory Leak)을 막을 수도 있게 되었다.

GC에 관련된 자세한 이야기는 네이버 d2 - Garbage Collection, 네이버 d2 - Reference와 GC, impala님의 GC 정리, 우테코 10분 테코톡 - 엘리의 GC 등 잘 정리된 내용이 너무 많아서 이를 참조하며 나중에 추가적으로 정리하기로 하고 본 게시글에서는 핵심만 간단히 살펴보도록 하자. 

 

GC(Garbage Collection) 개념이 도입이 가능했던 것은 weak generational hypothesis(약한 세대 가설) 덕분이라고 oracle docs에서는 소개한다. 해당 가설은 대부분의 객체는 금방 접근 불가능한 상태(unreachable)가 된다, 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다는 두 가지 이유를 든다. 예를 들어, 한 번 쓰이고 버려지는 객체들( = 금방 접근 불가능한 상태가 된 객체 )을 주기적으로 비움으로써 메모리를 효율적으로 사용한다는 것이 있다. 또, 어떤 값이나 상태를 저장하기 위해 생성된 POJO 객체는 다른 메서드나 클래스에 전달되고 난 이후에는 더이상 사용하지 않는 경우가 대부분인데 이러한 경우도 비우면 좋을 것이다.

 

그림6. Reachable과 Unreachable

 

Root에 해당하는 것은 JVM Stack에 있는 지역변수, 파라미터 혹은 static 변수, JNI에 의해 생성된 객체들이 해당된다. 어떤 객체에 Root로부터 유효한 참조가 존재한다고 하면 Reachable하다고 하고 Root로부터 유효한 참조가 존재하지 않는다면 Unreachable하다고 한다. 이 Unreachable한 객체들이 GC의 수거 대상이다.

 

그림7. Heap 메모리 구조

 

Heap 메모리 구조는 그림과 같은데, 두 영역에서는 Minor GC와 Major GC가 일어난다. 

 

New/Young Generation

  • Heap 영역에 객체가 생성되면 Eden 영역에 객체가 지정된다.
  • Eden 영역에 데이터가 가득차면 1차 GC인 Minor GC가 발생한다. 이 과정에서 살아남은 객체는 Survivor 0, Survivor 1 둘 중 하나의 영역으로 옮겨진다. Survivor 0과 1 둘 중에 우선순위는 없고 한 곳으로만 이동하면 된다.
  • 하나의 Survivor 영역이 가득차면 1차 GC인 Minor GC가 발생하고 살아남은 객체를 다른 Survivor 영역으로 이동한다. 그리고 기존에 가득찼던 Survivor 영역은 아무 데이터가 없이 비워둔 상태로 유지한다. 따라서 Survivor 영역 둘 중 하나는 항상 비워져있는 공간인채로 유지된다.
  • Minor GC는 기본적으로 Mark-Sweep-Compaction이라는 알고리즘을 사용하는데, 이는 먼저 살아있는 객체를 식별(Mark)하고 힙의 앞 부분부터 확인하여 살아있는 식별된 객체만 남기고 식별되지 않은 객체를 삭제하고(Sweep) 마지막에는 살아있는 객체들을 앞쪽으로 모아주는(Compact) 알고리즘이다.

 

Old Generation (Tenured Generation)

  • Survivor 0과 1을 왔다갔다 하는 과정에서 오랫동안 살아남은 객체들은 Old 영역으로 이동한다.
  • New/Young 영역에서 Survivor 영역을 거치지 않고 바로 Eden에서 Old로 넘어가는 객체도 존재한다. 이 경우 객체의 크기가 아주 큰 경우이다. 예를 들어, Survivor 영역의 크기가 16MB인데 객체의 크기가 20MB라면 바로 Old로 넘어간다.
  • 오랫동안 살아남는다는 것의 기준은 Minor GC에서 얼마나 살아남았는 지를 의미한다. Minor GC가 발생할 때마다 age bit 값이 1씩 증가하는데, MaxTenuringThreshold라는 임계치를 초과하는 경우 Old Generation으로 이동하는 것이다. 임계치 초과 이전이라도 Survivor 영역의 메모리가 부족하다면 Old Generation으로 이동할 수 있다.
  • Old 영역에서 데이터가 가득차면 2차 GC인 Major GC(Full GC)가 발생한다. 이때 Stop-The-World(STW) 상태가 발생한다. STW란, GC 하기 위하여 JVM이 애플리케이션 실행을 멈추는 것이다. STW가 발생하면 GC를 실행하는 스레드를 제외한 나머지 스레드는 모두 작업을 멈추고 GC 작업이 완료된 이후에야 중단했던 작업을 실행한다.
  • 어떠한 GC 알고리즘을 사용하더라도 Stop-The-World는 무조건 발생한다. 그렇기 때문에 적절한 GC 알고리즘 선택과 GC 튜닝을 통한 stop-the-world 시간을 최소한으로 줄이는 것이 성능 향상의 핵심이다.

 

Old 영역의 GC 알고리즘 중 대표적으로 Serial GC, Parallel GC, Parallel old GC, CMS(Concurrent Mark & Sweep) GC, G1(Garbage First) GC 5가지를 소개하려고 한다. 먼저 말할 내용은 Java 8에서 default로 사용되는 GC 알고리즘은 Parrel GC이고 Java 9부터는, 그러니까 Java 11에서 default로 사용되는 GC 알고리즘은 G1GC이다. Java8과 11의 차이에서 중요한 차이이니 기억하도록 하자.

 

 

Serial GC (-XX:+UseSerialGC)

예전 버전의 JDK에서 사용하던 방식으로 GC를 처리하는 스레드가 싱글스레드이다. 이러한 이유로 다른 GC 알고리즘에 비해 stop-the-world 시간이 길다는 단점이 있다. Serial GC에서 Young 영역과 Old 영역 모두 Mark-Sweep-Compaction 알고리즘을 사용하지만 싱글 스레드로 사용한다.

 

Serial GC는 적은 메모리와 CPU 코어 개수 1개일 때 적합하지만, 이 말은 현대 시대에 우리가 사용하는 컴퓨터에서 CPU 코어 개수가 1개인 경우는 찾기 힘드니까 Serial GC를 사용하면 애플리케이션의 성능이 많이 떨어지기 때문에 절대 사용하지 말라는 의미이다.

 

Parallel GC (-XX:+UseParrelGC) - Java 8의 default GC

Serial GC에서 mark-sweep-compaction 알고리즘을 사용하는 것처럼 기본적인 동작원리는 동일하지만, Parallel GC는 GC를 처리하는 스레드가 Young 영역에서 minor GC 하는 작업이 싱글스레드가 아닌 멀티스레드인 경우이다. 따라서 Serial GC보다 훨씬 빠르게 객체를 처리할 수 있었고 Java 8의 default GC였다. Parallel GC는 Throughput GC라고도 한다. 멀티 스레드로 처리하기 때문에 Stop-The-World 시간이 Serial GC 방식에 비해 훨씬 줄어든다.

 

그림8. Serial GC와 Parallel GC의 차이 (이미지 출처: "Java Performance", p.86)

 

Parallel Old GC(-XX:+UseParallelOldGC)

Parallel Old GC는 Parallel GC와 비교하여 Young 영역의 방식은 동일하고 Old 영역 또한 멀티 스레드이면서, Old 영역의 GC 알고리즘을 개선한 방식이다. Parallel GC는 Mark-Sweep-Compaction 알고리즘을 사용했지만 Parallel Old GC는 Mark-Summary-Compaction 알고리즘을 수행한다. Sweep은 단일 스레드가 Old 영역 전체를 훑어보고 살아있는 객체만 찾아내는 방식이지만 Summary는 멀티 스레드가 Old 영역을 분리하여 훑는 것이다. 또한 효율을 위해 앞선 GC에서 Compaction된 영역을 별도로 훑는다.

 

CMS GC (-XX:+UseConcMarkSweepGC)

그림9. Serial GC와 CMS GC (이미지 출처:https://www.oracle.com/java/technologies/)

CMS GC는 Stop-The-World 시간을 줄이기 위해 고안된 방식이다. Compaction 과정없이, 애플리케이션 중단 없이 Mark-and-Sweep이 가능한 방식이다. Initial Mark 단계와 Remark 단계에서는 짧게나마 Stop-The-World 현상이 발생한다.

 

  • Initial Mark 단계에서는 현재 살아남은 객체를 탐색하는데 GC Root에서 참조하는 객체들만 우선적으로 탐색하고 끝내기 때문에 Stop-The-World 발생 시간이 매우 짧다.
  • Concurrent Mark 단계에서는 방금 살아있다고 확인한 객체에서 참조하고 있는 모든 객체들을 추적하면서 GC의 대상인지 판별한다. Stop-The-World가 발생하지 않으며, 이 단계의 특징은 다른 스레드가 실행 중인 상태에서 동시에 진행된다는 것이다.
  • Remark 단계에서는 Concurrent Mark 단계에서 식별한 객체를 다시 추적하여 새로 추가되거나 참조가 끊긴 객체를 확인한다. 이 과정에서 Stop-The-World가 발생하지만 멀티스레드로 동작하기 때문에 매우 짧다.
  • Concurrent Sweep 단계에서는 Remark 단계까지 검증이 완료된 GC 대상 객체들을 삭제한다. 이 단계도 다른 스레드가 실행 중이 상태에서 동시에 진행한다.

 

이러한 이유로 CMS GC는 stop-the-world 시간이 매우 짧아서 모든 애플리케이션의 응답 속도가 중요한 경우 사용하면 좋은 GC 알고리즘이며 Low Latency GC라고도 부른다. 하지만, 시간이 짧다는 장점에 반해 다른 GC 방식보다 메모리와 CPU를 더 많이 사용한다는 것Compaction 단계가 기본적으로 제공되지 않아서 메모리 단편화 문제가 아쉽다는 단점이 존재한다.

 

따라서, CMS GC를 사용할 때는 신중히 검토해서 사용해야 한다. 만약 조각난 메모리가 많아서 연속적인 메모리 할당이 어려울 정도로 메모리 단편화가 심한 경우에 Compaction 작업을 실행한다면 다른 GC 방식의 stop-the-world 시간보다 더 긴 stop-the-world가 발생하기 때문이다. Compaction 작업이 얼마나 자주, 오랫동안 수행되는지 반드시 확인하고 사용하도록 하자.

 

G1 GC (-XX:+UseG1GC) - Java 9 이후의 default GC

G1GC는 하드웨어가 발전되면서 Java 애플리케이션이 사용할 수 있는 메모리의 크기가 점점 커져왔는데 이 큰 메모리에서 좋은 성능을 내는 것에 초점을 둔 GC 알고리즘이다. 메모리 단편화 문제를 해결하면서 CMS GC를 대체하기 위해 만들어졌다. G1GC를 이해하기 위해서는 지금까지 heap 영역을 New/Young Generation과 Old Generation으로 나누던 것을 잊는 것이 좋다.

 

Heap 영역을 고정된 크기로 물리적으로 New/Young 영역, Old 영역으로 나누는 것이 아닌 전체 heap을 체스판처럼 Region이라는 일정한 크기의 논리적인 단위로 나누고 Region에 특정한 역할을 부여한다. Heap은 동일한 크기의 영역으로 나뉘어 집합으로 분할되어 연속된 가상 메모리로 존재하게 된다. G1GC의 목표 또한 STW의 최소화이다. 이 말은 G1GC도 STW를 완전히 없애지는 못했다는 의미이다.

 

그림10. G1GC Heap Layout

 

G1GC 알고리즘은 이전 방식들과 동일하게 최초 객체가 생성이 되면 Eden에 할당하고 이후 Survivor 로 이동과 소멸 Old Region으로의 이동은 동일하다. 최초 객체 생성은 비어있는 영역에만 들어간다는 것이고 비어있는 영역이 Eden Region으로 될 것이다.

 

Humongous Region이라는 영역이 생겼는데 이는 한 영역보다 크기가 커서 여러 영역을 차지하는 커다란 객체이다. 한 영역의 절반 이상의 크기를 가진 객체를 의미하는데, 연속된 영역을 순차적으로 차지하도록 할당된다. 마지막 꼬리 영역에 남는 공간이 생길 수 있는데 그 잉여공간은 아깝지만 사용하지 않는다. 이 말은 Humongous Object가 회수되기 전까지 잉여공간을 사용할 수 없다는 의미이다.

 

그림11. G1GC의 Garbage Collection Cycle (그림출처 : Oracle docs)

 

Oracle docs에서는 G1GC는 2개의 페이즈 Young-only phase, Space Reclamation phase를 번갈아가면서 GC 작업을 한다고 소개한다. 

 

Young-only phase (Young-only 단계) : Old 영역의 점유율이 threshold 값을 넘어서면 Young-only phase로 전환된다. 이 단계에서는 Young Generation의 GC로 시작된다.

  1. Initial mark : Young GC를 수행하면서 Concurrent Marking 작업이 시작된다. Young GC 시점에 수행하니까 당연히 Stop-The-World가 발생한다.
  2. Concurrent Marking : Old Region을 GC하기 위해 현재 도달할 수 있는 live 객체(Object)를 결정한다. Concurrent Mark가 진행되는 도중에 Young GC가 동작할 수 있으며 이로 인해 방해받을 수 있다. Marking 작업은 Remark와 Cleanup 단계에서 Stop-The-World를 발생시킬 수 있다.
  3. Remark : Marking 작업 자체를 마무리하고 Reference 처리 및 클래스 언로딩을 수행하여 Empty Region을 회수하고 내부 데이터 구조를 정리한다. 즉, 마킹을 끝내고 쓰레기 영역을 해제한다는 의미이다. 이때, 짧은 Stop-The-World 현상이 발생할 수 있다. Remark 단계와 Cleanup 단계 사이에서 G1은 Old Region에서 여유 공간을 회수 할 수 있도록 정보를 계산하고 이 계산은 Cleanup 단계에서의 Stop-The-World에서 마무리 된다. 
  4. Cleanup : 이 단계는 Region 회수가 실제로 진행될 지 결정하는데, 만약 공간 재확보 단계가 온다면, Young-only 단계는 1회의 Mixed-GC만 진행하고 완료된다.

 

Space Reclamation phase (공간 재확보 단계) : Young / Old 영역을 가리지 않고 live 객체를 적절한 곳으로 대피시킨다. 이 단계는 G1이 더이상 Old Region을 효율적으로 줄일 수 없다고 판단되는 시점에 이 페이즈는 끝나고, 다시 Young-only phase로 넘어가게 된다.

 

G1GC는 위 두 개의 페이즈가 반복되는 방식으로 진행하다가 애플리케이션이 메모리가 부족하게 되어 OOM(Out Of Memory)로 가게 된다면 다른 GC들과 마찬가지로 Full GC가 발생한다.

 

 

 

자바의 실행 원리 마무리, 소감

비로소 자바 실행원리에 대한 시리즈가 끝났다. 사실 이렇게까지 할 필요는 없었지만, 지적 호기심을 채우기위해 열심히 찾아보았다. 공부하면서 느낌 소감은 JVM 명세에 따라 구현된 JVM의 세부적인 작동원리나 내부가 어떻게 되어있는지는 벤더마다 차이가 있고 런타임 데이터 영역에서 정확히 어느 위치에 저장되는지와 같은 부분이 애매모호하긴 했다. 예를 들어, static 변수는 클래스 변수니까 메서드 영역이라고 알고있었는데 공부를 하면서 heap 영역에 있다고 알게되어 혼란스러웠다.

 

이를 정확하게 메서드 영역이냐 Heap 영역이냐!는 결국 큰 의미는 없었다. 중요한 핵심은 스레드마다 생성되는 것이 아닌 스레드 들이 공유자원으로 이용한다는 점이 중요했다. static 변수가 heap 영역에 있다고 하여 인스턴스 여러 개 생성되는 원리도 아니고, heap 영역에 넣어서 최대한 GC되게 했다지만 사실상 거의 GC 될 일이 없었기 때문이다.

 

마지막으로, 아직 취업 준비하는 입장이기도 하고 갈 길이 멀기 때문에 이런 DFS식 공부는 접어두고 취업까지는 BFS식 공부로 쭉쭉 달리기로 다시 한 번 다짐한다. 여러분도 아직 취업을 준비하는 단계라면 이렇게까지 JVM에 대해서 파지말고 가볍게만 알고 넘어가는 것을 추천한다. :)

 

 

참고

 

 

※ 본 게시글은 공부하면서 작성했기에 틀린 부분이 있을 수 있습니다. 질문 사항이나 댓글로 알려주시면 감사하겠습니다.

댓글