Java 21 Virtual Thread 완전 정리 — 메모리 모델, Pinning, Spring Boot 적용

"☕" 10 "min read"

"Virtual Thread 쓰면 그냥 빨라지는 거 아닌가요?"
맞기도 하고 틀리기도 합니다.
Virtual Thread는 올바르게 이해하고 적용하면 강력하지만,
잘못 사용하면 오히려 성능이 나빠지거나 디버깅하기 어려운 문제가 생깁니다.
이 글에서는 Virtual Thread가 메모리를 어떻게 관리하는지,
어떤 상황에서 막히는지(Pinning), Spring Boot 3.2에서 어떻게 적용하는지를 정리합니다.

👉 JDK Flight Recorder + JDK Mission Control 실전 — 운영 중 JVM 프로파일링

👉 JVM GC 로그 분석 실전 — GCViewer와 GCEasy로 튜닝 시작하기


1. 기존 스레드의 문제 — 왜 Virtual Thread가 나왔나요?

Virtual Thread를 이해하려면 먼저 기존 플랫폼 스레드(Platform Thread)의 한계를 알아야 합니다.

플랫폼 스레드의 비용

// 전통적인 방식: 요청 1개 = 스레드 1개
@RestController
public class TraditionalController {

    @GetMapping("/orders/{id}")
    public Order getOrder(@PathVariable Long id) {
        // 이 스레드는 DB 응답을 기다리는 동안 아무것도 못 하고 블로킹됨
        return orderRepository.findById(id);  // 예: DB 응답 50ms 대기
    }
}Code language: PHP (php)

위 코드에서 스레드가 findById() 결과를 기다리는 50ms 동안 무슨 일이 일어날까요?

플랫폼 스레드 라이프사이클 (DB I/O 50ms 대기 시):

[0ms]  스레드 A: 요청 처리 시작
[1ms]  스레드 A: DB 쿼리 전송 → 대기 시작 (BLOCKED)
       ↓↓↓ 49ms 동안 이 스레드는 아무 일도 하지 않음 ↓↓↓
       OS 커널: "스레드 A는 블로킹 중, 다른 스레드에 CPU 할당"
       (컨텍스트 스위칭 발생, 비용 발생)
[50ms] 스레드 A: DB 응답 수신 → 처리 재개
[52ms] 스레드 A: 응답 반환Code language: JavaScript (javascript)

플랫폼 스레드 1개가 차지하는 메모리는 기본 1MB (스택 메모리) 입니다.
동시 요청 1,000개를 처리하려면 최소 1GB의 스택 메모리가 필요합니다.

플랫폼 스레드의 한계 — 메모리와 CPU 활용플랫폼 스레드 1000개의 메모리 점유와 I/O 블로킹으로 인한 CPU 낭비를 보여주는 다이어그램메모리 사용 (1,000개 스레드)1,000 스레드 × 1MB= 1GB Native Stack MemoryGC 대상이 아님 (OS 관리)스레드 A의 시간 활용BLOCKED — DB 응답 대기 (49ms)RUN대기 (다른 스레드들)총 52ms 중:실제 CPU 실행: ~3ms (6%)I/O 블로킹 대기: ~49ms (94%)But 스택 메모리는 계속 점유 중컨텍스트 스위칭 비용도 지속 발생→ Virtual Thread가 해결하는 문제

2. Virtual Thread의 동작 원리

Virtual Thread는 JVM이 관리하는 경량 스레드입니다.
OS 스레드와 1:1로 매핑되지 않고, 소수의 OS 스레드(캐리어 스레드) 위에서 수백만 개의 Virtual Thread가 스케줄링됩니다.

Virtual Thread 구조 — mount/unmount 메커니즘수백만 개의 Virtual Thread가 JVM ForkJoinPool 스케줄러를 거쳐 소수의 OS 캐리어 스레드에서 실행되는 구조Virtual Threads (수백만 개 가능, Heap에 스택 저장)VT-1VT-2VT-3VT-4VT-5VT-6VT-7VT-8VT-9VT-…CPU 실행 중 (mounted)I/O 대기 중 (unmounted, Heap 보관)JVM 스케줄러 — ForkJoinPoolI/O 블로킹 감지 시 unmount, 실행 재개 시 mount캐리어 스레드 1OS Thread / CPU core캐리어 스레드 2OS Thread / CPU core캐리어 스레드 3OS Thread / CPU core캐리어 스레드 NCPU 수만큼만 존재

핵심은 mount / unmount 개념입니다.

  • mount: Virtual Thread가 캐리어 스레드에 올라가 실제로 CPU에서 실행되는 상태
  • unmount: I/O 블로킹이 발생하면 캐리어 스레드에서 분리되어 힙에 저장. 캐리어 스레드는 다른 Virtual Thread를 실행할 수 있게 됨

3. 메모리 모델 — 플랫폼 스레드와 무엇이 다른가요?

이 부분이 이 글의 핵심입니다.

스택 메모리 비교

플랫폼 스레드 vs Virtual Thread 스택 메모리 비교플랫폼 스레드의 1MB 고정 Native 스택과 Virtual Thread의 동적 Heap 스택을 비교하는 다이어그램플랫폼 스레드 (Platform Thread)OS Native Memory (GC 밖)Stack (1MB 고정)-Xss 옵션으로 크기 결정스레드 생성 시 즉시 예약GC 대상 아님종료 시 OS가 반환1,000 스레드 → 최소 1GB 예약Virtual ThreadJVM Heap (GC 대상)초기 Stack (~수백 바이트)필요시 확장Stack (동적 확장)재귀 깊이에 따라 자동 증가VT 종료 시 GC가 자동 회수100만 VT도 Heap 수십MB 수준

Virtual Thread의 스택은 JVM Heap에 저장됩니다.
이것이 플랫폼 스레드와 가장 큰 차이입니다.

// 실제로 확인해보기
public class MemoryComparison {

    public static void main(String[] args) throws Exception {
        int threadCount = 100_000;  // 10만 개

        // 플랫폼 스레드 10만 개 생성 시도
        // → 대부분의 서버에서 OutOfMemoryError 또는 수십 GB 메모리 필요
        // (실제로는 수천 개로 제한됨)

        // Virtual Thread 10만 개 생성
        // → 가능! 각 VT 초기 스택이 수백 바이트이므로 수십 MB 수준
        long start = System.currentTimeMillis();
        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < threadCount; i++) {
            Thread vt = Thread.ofVirtual().start(() -> {
                try {
                    Thread.sleep(1000);  // 1초 대기 (I/O 시뮬레이션)
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
            threads.add(vt);
        }

        // 모든 Virtual Thread 완료 대기
        for (Thread t : threads) {
            t.join();
        }

        System.out.println("소요 시간: " + (System.currentTimeMillis() - start) + "ms");
        // 플랫폼 스레드: 불가능 (OOM) 또는 매우 오래 걸림
        // Virtual Thread: ~1000ms (모두 병렬로 sleep)
    }
}Code language: PHP (php)

Heap에 스택이 있다는 것의 의미

Virtual Thread 스택이 Heap에 있기 때문에 생기는 변화가 있습니다.

변화 1: GC가 Virtual Thread 스택을 수거할 수 있음
  → Virtual Thread 종료 시 스택 메모리가 GC 대상이 됨
  → 메모리 누수 가능성 감소

변화 2: Heap 사용량 증가
  → Virtual Thread를 대량 사용하면 Heap 사용량이 늘어남
  → -Xmx 설정 시 VT 스택 공간도 고려 필요

변화 3: 스택 공간이 유연
  → 재귀 호출이 깊어지면 스택이 동적으로 증가
  → StackOverflowError 발생 임계값이 플랫폼 스레드와 다를 수 있음

4. Pinning — Virtual Thread의 가장 큰 주의사항

Pinning은 Virtual Thread가 블로킹 상태임에도 캐리어 스레드에서 분리(unmount)되지 못하고 고정되는 현상입니다.
Pinning이 발생하면 캐리어 스레드가 그 Virtual Thread에 묶여 다른 Virtual Thread를 처리하지 못합니다.
Virtual Thread를 쓰는 의미가 없어지는 상황입니다.

Pinning이 발생하는 두 가지 상황

Virtual Thread Pinning 원인 2가지와 해결책synchronized 블록 내 블로킹, native 메서드 호출은 Pinning을 유발하고, ReentrantLock은 정상 동작함을 보여주는 다이어그램❌ Pinning 원인 1synchronized 블록 내 I/OVirtual ThreadDB 쿼리 대기 중…캐리어 스레드에 고정(unmount 불가)다른 VT 실행 불가능❌ Pinning 원인 2native 메서드 호출 중 블로킹Virtual ThreadJNI / native 코드 실행캐리어 스레드에 고정(JVM 제어 밖)JFR VirtualThreadPinned 감지✅ 정상 동작ReentrantLock 사용Virtual ThreadDB 쿼리 대기 중…캐리어 스레드에서 분리(unmount 성공)다른 VT 캐리어에 mount

Pinning 발생 코드 vs 안전한 코드

// ❌ Pinning 발생: synchronized + 블로킹 I/O 조합
@Service
public class PinningProblemService {

    private final Object lock = new Object();

    public Order processOrder(Long orderId) {
        synchronized (lock) {                          // 여기서 캐리어 스레드에 고정
            Order order = orderRepository.findById(orderId);  // DB I/O — 블로킹!
            // → VThread가 unmount 되지 못함
            // → 캐리어 스레드가 DB 응답 올 때까지 점유됨
            // → 다른 VThread들이 실행 대기
            return processLogic(order);
        }
    }
}

// ✅ Pinning 없음: ReentrantLock 사용
@Service
public class PinningFixedService {

    private final ReentrantLock lock = new ReentrantLock();

    public Order processOrder(Long orderId) {
        lock.lock();
        try {
            Order order = orderRepository.findById(orderId);  // DB I/O — 블로킹 발생
            // → VThread unmount 가능!
            // → 캐리어 스레드는 다른 VThread 실행 가능
            // → DB 응답 오면 다시 mount되어 처리 재개
            return processLogic(order);
        } finally {
            lock.unlock();
        }
    }
}Code language: PHP (php)

⚠️ 주의: JDK의 일부 내부 API와 서드파티 라이브러리가 내부적으로 synchronized를 사용합니다. 예를 들어 Java 21 이전의 java.io.InputStream, JDBC 드라이버 일부 구현 등이 해당합니다. 이런 경우 JFR로 Pinning 발생 여부를 모니터링하는 것이 중요합니다.

JFR로 Pinning 감지하기

# Pinning 이벤트 포함해서 녹화
jcmd 12345 JFR.start \
  duration=60s \
  filename=/tmp/pinning_check.jfr \
  settings=profile

# jfr CLI로 Pinning 이벤트만 추출
jfr print --events jdk.VirtualThreadPinned /tmp/pinning_check.jfrCode language: PHP (php)
# 출력 예시 (Pinning 발생 시)
jdk.VirtualThreadPinned {
  startTime = 13:42:07.123
  duration = 52.3 ms                          ← Pinning 지속 시간
  carrierThread = "ForkJoinPool-1-worker-1"   ← 묶인 캐리어 스레드
  eventThread = "virtual-34"
  stackTrace = [
    com.example.service.PinningProblemService.processOrder(PinningProblemService.java:15)
    ...                                        ← Pinning 발생 위치 확인 가능
  ]
}Code language: PHP (php)

JVM 시작 시 아래 옵션을 추가하면 Pinning 발생 시 로그도 출력됩니다.

# Pinning 감지 로그 활성화
java -Djdk.tracePinnedThreads=full -jar app.jar

# 출력 예시:
# Thread[#34,ForkJoinPool-1-worker-1,5,CarrierThreads]
#     com.example.service.PinningProblemService.processOrder(PinningProblemService.java:15) <== monitors:1Code language: PHP (php)

5. Virtual Thread를 쓰면 안 되는 경우

Virtual Thread는 I/O 바운드 작업에 최적화되어 있습니다.
모든 상황에서 더 빠르지 않으며, 오히려 역효과가 날 수 있는 경우가 있습니다.

Virtual Thread가 유리한 경우 vs 불리한 경우I/O 바운드 작업에서 유리한 5가지 케이스와 CPU 바운드 등 불리한 5가지 케이스를 두 컬럼으로 비교✅ Virtual Thread가 유리한 경우 (I/O 바운드)❌ Virtual Thread가 불리한 경우DB 쿼리 처리 (JDBC, R2DBC)대기 중 unmount → 캐리어 스레드 재활용외부 HTTP API 호출네트워크 대기 시간 동안 다른 VT 실행파일 I/O (읽기/쓰기)I/O 완료 대기 중 CPU 반환Kafka/MQ 메시지 처리폴링 대기 시 unmount 가능대량 동시 요청 처리 (10만+ 연결)스레드당 수백 바이트 → 메모리 효율이미지/영상 처리, 압축unmount 기회 없음 → 캐리어 스레드 점유암호화/복호화 연산CPU 연속 사용 → ForkJoinPool 포화대규모 수치 계산 (ML 추론 등)고정 스레드풀이 CPU 활용에 더 적합Spring WebFlux + Reactor 조합이미 논블로킹 → VT 효과 미미synchronized + I/O 혼용 코드Pinning 미해결 시 오히려 성능 저하

CPU 바운드 작업에서의 잘못된 예

// ❌ CPU 바운드 작업에 Virtual Thread — 의미 없거나 오히려 느림
@Service
public class ImageProcessingService {

    public void processImages(List<byte[]> images) throws Exception {
        List<Thread> threads = new ArrayList<>();

        for (byte[] image : images) {
            Thread vt = Thread.ofVirtual().start(() -> {
                // 이미지 압축: 순수 CPU 작업, I/O 없음
                // → VThread가 unmount될 기회가 없음
                // → 캐리어 스레드 수만큼만 병렬 실행 (플랫폼 스레드와 동일)
                compressImage(image);
            });
            threads.add(vt);
        }

        for (Thread t : threads) t.join();
    }
}

// ✅ CPU 바운드 작업은 ForkJoinPool 또는 고정 스레드풀 사용
@Service
public class ImageProcessingService {

    // CPU 코어 수에 맞춘 고정 스레드풀
    private final ExecutorService cpuPool =
        Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public void processImages(List<byte[]> images) throws Exception {
        List<Future<?>> futures = images.stream()
            .map(image -> cpuPool.submit(() -> compressImage(image)))
            .toList();

        for (Future<?> f : futures) f.get();
    }
}Code language: PHP (php)

6. ThreadLocal과 Virtual Thread — 주의사항

플랫폼 스레드 환경에서 ThreadLocal은 스레드 수가 제한되어 있어 메모리 영향이 작았습니다.
Virtual Thread는 수백만 개가 생성될 수 있으므로 ThreadLocal 사용이 메모리 문제로 이어질 수 있습니다.

// ❌ Virtual Thread 환경에서 ThreadLocal 대량 데이터 저장 — 메모리 위험
public class RequestContextHolder {

    // 각 VThread마다 이 데이터가 복사되어 저장됨
    // VThread 100만 개 × 1KB = 1GB 이상 소비 가능
    private static final ThreadLocal<Map<String, Object>> context =
        ThreadLocal.withInitial(HashMap::new);

    public static void set(String key, Object value) {
        context.get().put(key, value);
    }
}

// ✅ Java 21 Scoped Values 사용 (Virtual Thread 시대의 ThreadLocal 대체)
// 불변(immutable), 상속 가능, 메모리 효율적
import java.lang.ScopedValue;

public class RequestContext {

    // ScopedValue: 특정 스코프 내에서만 유효, 스코프 종료 시 자동 해제
    public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
    public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    // 사용 예시
    public void handleRequest(String userId, String requestId) {
        ScopedValue.where(USER_ID, userId)
            .where(REQUEST_ID, requestId)
            .run(() -> {
                // 이 람다 내부에서만 USER_ID, REQUEST_ID 접근 가능
                processRequest();
            });
        // 람다 종료 후 자동으로 스코프 해제 — 메모리 누수 없음
    }

    private void processRequest() {
        String userId = USER_ID.get();      // 현재 스코프의 값 조회
        String requestId = REQUEST_ID.get();
        // ...
    }
}Code language: JavaScript (javascript)

7. Spring Boot 3.2에서 Virtual Thread 적용하기

Spring Boot 3.2부터 설정 한 줄로 전체 서버를 Virtual Thread 기반으로 전환할 수 있습니다.

기본 활성화

# application.yml
spring:
  threads:
    virtual:
      enabled: true   # 이 한 줄로 Tomcat/Jetty 스레드 → Virtual Thread 전환Code language: PHP (php)

내부적으로 아래와 같이 동작합니다.

// Spring Boot가 내부적으로 설정하는 내용 (직접 작성할 필요 없음)
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
    return protocolHandler ->
        protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}Code language: PHP (php)

세밀한 제어가 필요한 경우 직접 설정

@Configuration
public class ThreadConfig {

    /**
     * Virtual Thread 기반 ExecutorService
     * 요청 처리, 비동기 작업에 사용
     */
    @Bean("virtualThreadExecutor")
    public ExecutorService virtualThreadExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }

    /**
     * CPU 바운드 작업 전용 ExecutorService (기존 방식 유지)
     * 이미지 처리, 암호화 등에 사용
     */
    @Bean("cpuBoundExecutor")
    public ExecutorService cpuBoundExecutor() {
        return Executors.newFixedThreadPool(
            Runtime.getRuntime().availableProcessors()
        );
    }

    /**
     * @Async 기본 실행기를 Virtual Thread로 설정
     */
    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }
}

// 서비스에서 용도에 맞게 선택
@Service
public class OrderService {

    @Qualifier("virtualThreadExecutor")
    @Autowired
    private ExecutorService virtualExecutor;   // I/O 작업용

    @Qualifier("cpuBoundExecutor")
    @Autowired
    private ExecutorService cpuExecutor;       // CPU 작업용

    public CompletableFuture<Order> getOrderAsync(Long id) {
        // I/O 작업 → Virtual Thread
        return CompletableFuture.supplyAsync(
            () -> orderRepository.findById(id), virtualExecutor
        );
    }

    public CompletableFuture<byte[]> generatePdfAsync(Order order) {
        // CPU 작업 → 고정 스레드풀
        return CompletableFuture.supplyAsync(
            () -> pdfGenerator.generate(order), cpuExecutor
        );
    }
}Code language: PHP (php)

JDBC / JPA와 Virtual Thread — 커넥션 풀 설정 주의

Virtual Thread 환경에서 DB 커넥션 풀 설정을 잘못하면 성능이 오히려 나빠질 수 있습니다.

# application.yml — Virtual Thread 환경에서의 HikariCP 설정
spring:
  threads:
    virtual:
      enabled: true
  datasource:
    hikari:
      # ❌ 잘못된 설정: 커넥션 풀 크기를 무작정 크게
      # maximum-pool-size: 500  ← DB 서버 부하 급증

      # ✅ 올바른 설정
      # VThread가 많다고 커넥션도 많이 필요한 건 아님
      # DB 서버의 최대 동시 처리 능력에 맞게 설정 (보통 10~50)
      maximum-pool-size: 20
      minimum-idle: 5

      # Virtual Thread 환경에서는 타임아웃 설정이 더 중요해짐
      # 커넥션 대기 최대 시간 (VThread가 많을수록 경합 증가)
      connection-timeout: 3000Code language: PHP (php)

💡 VThread는 커넥션을 기다리는 동안 unmount되어 다른 작업을 처리할 수 있습니다. 따라서 커넥션 풀이 작더라도 VThread 환경에서는 덜 문제가 됩니다. 오히려 풀 크기를 너무 크게 늘리면 DB 서버에 부하를 줄 수 있습니다.


8. 전환 전 점검 체크리스트

Virtual Thread로 전환하기 전에 아래를 확인하세요.

Virtual Thread 전환 점검 체크리스트synchronized 교체 → I/O 바운드 확인 → ThreadLocal 점검 → 라이브러리 지원 확인 → DB 커넥션 풀 설정 → VT 활성화 → JFR 모니터링 순서의 체크리스트 플로차트Virtual Thread 전환 시작1. synchronized 블록 점검I/O 포함 시 ReentrantLock으로 교체2. I/O 바운드 작업 분리CPU 바운드는 별도 고정 스레드풀 사용3. ThreadLocal 점검대용량 데이터 → ScopedValue로 교체4. 라이브러리 VT 지원 확인JDBC 드라이버, 내부 synchronized 여부5. HikariCP 커넥션 풀 설정maximum-pool-size 무작정 늘리지 않기spring.threads.virtual.enabled=true 활성화 → JFR 모니터링

9. 결과 및 검증 — 적용 후 확인 방법

# 1. Virtual Thread 동작 확인 (JFR 활용)
jcmd 12345 JFR.start duration=30s filename=/tmp/vt_check.jfr settings=profile
jfr print --events jdk.VirtualThreadStart,jdk.VirtualThreadEnd /tmp/vt_check.jfr | head -30

# 2. Pinning 이벤트 없는지 확인
jfr print --events jdk.VirtualThreadPinned /tmp/vt_check.jfr
# 출력 없으면 Pinning 없음 ✅

# 3. 스레드 수 변화 확인 (JMX)
jcmd 12345 Thread.print | grep "virtual" | wc -l

# 4. Prometheus 메트릭으로 응답 시간 변화 비교
# 전환 전후 p99 레이턴시 비교
histogram_quantile(0.99,
  rate(http_server_requests_seconds_bucket[5m])
)Code language: PHP (php)

10. 추가 고려사항 💡

Reactive(WebFlux)와 Virtual Thread — 같이 쓸 필요 없음

Spring WebFlux + Project Reactor는 이미 Non-blocking I/O로 구현되어 있어
Virtual Thread의 혜택이 크지 않습니다. 오히려 Reactor의 스케줄러와 VT 스케줄러가 충돌할 수 있습니다.
Virtual Thread는 기존 Blocking MVC 코드를 Non-blocking처럼 동작하게 만드는 것이 주 목적입니다.

JDK 21 이후의 변화

Java 21부터 JDK 내부의 synchronized를 점진적으로 ReentrantLock으로 교체하고 있어
Pinning 발생 가능성이 줄어들고 있습니다. Java 24에서는 synchronized 블록 내 Pinning 문제도
상당 부분 해결될 예정입니다.

Virtual Thread 이름 설정 — 디버깅 편의성

// 디버깅 시 스레드를 구분하기 쉽게 이름 설정
Thread.ofVirtual()
    .name("order-processor-", 0)  // "order-processor-0", "order-processor-1" ...
    .start(() -> processOrder(orderId));Code language: JavaScript (javascript)

👉 JDK Flight Recorder + JDK Mission Control 실전 — 운영 중 JVM 프로파일링

👉 Spring Boot + Micrometer로 JVM 메모리 대시보드 구축하기


11. 마무리

이번 글을 세 줄로 정리합니다.

  1. Virtual Thread는 스택을 JVM Heap에 저장하는 경량 스레드로, I/O 바운드 작업에서 플랫폼 스레드 대비 훨씬 적은 메모리로 높은 동시성을 달성합니다.
  2. synchronized 블록 내 I/O 블로킹은 Pinning을 유발해 Virtual Thread의 장점을 무력화하므로, ReentrantLock으로 교체하고 JFR VirtualThreadPinned 이벤트로 모니터링하는 것이 필수입니다.
  3. Spring Boot 3.2에서는 spring.threads.virtual.enabled=true 한 줄로 전환 가능하지만, CPU 바운드 작업 분리, 커넥션 풀 크기 조정, ThreadLocal 사용 점검을 반드시 함께 진행해야 합니다.

다음 글에서는 이 시리즈의 마지막으로 Spring Boot + Micrometer로 JVM 메모리 대시보드를 구축하는 방법을 다룹니다.
지금까지 다룬 GC 로그, Heap Dump, JFR, Virtual Thread 지표를 Grafana 하나에서 한눈에 보는 파이프라인을 완성합니다.

Y

yshyuk

Java 백엔드 개발자 | Spring, AWS, DevOps
2020년부터 Java/Spring boot 서버 개발자로 일하면서, 인프라(AWS/NCP), DevOps 업무를 병행하였고, 현재는 OpenSource 기여에도 관심을 가지고 있습니다.

조회수: 0