# Spring Boot 3.4 Virtual Threads: 면접 질문과 성능 벤치마크 > Spring Boot 3.4와 함께 Java 21 Virtual Threads를 마스터하세요. 15가지 면접 질문, 성능 벤치마크, 마이그레이션 패턴으로 기술 면접을 통과하세요. - Published: 2026-03-15 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring boot, virtual threads, java 21, project loom, performance - Reading time: 16 min --- Virtual Threads는 Java 21의 가장 중요한 진보 중 하나이며, Spring Boot 3.4는 이를 네이티브로 통합합니다. Project Loom의 이 기능은 백엔드 애플리케이션에서 동시성을 다루는 방식을 근본적으로 바꿉니다. 기술 면접에서는 내부 메커니즘에 대한 이해, 적절한 사용 사례, 흔히 빠지는 함정에 대한 인지가 평가 대상이 됩니다. > **준비 팁** > > 면접관은 Virtual Threads를 진정으로 이해하는 지원자와 무작정 사용하는 지원자를 구분합니다. 사용해서는 안 되는 시점을 아는 것이 장점을 아는 것만큼 중요합니다. ## Virtual Threads의 기초 ### 질문 1: Virtual Thread란 무엇이며 Platform Thread와 어떻게 다른가 Virtual Thread는 운영체제가 아닌 JVM이 관리하는 경량 스레드입니다. Platform Threads(전통적인 스레드)와 달리 Virtual Threads는 OS 스레드에 직접 매핑되지 않습니다. JVM은 매우 적은 메모리만으로 수백만 개의 Virtual Thread를 만들 수 있습니다. ```java // VirtualThreadDemo.java // Comparing thread creation approaches public class VirtualThreadDemo { public void demonstrateDifference() { // Platform Thread: ~1MB stack per thread // Practical limit: a few thousand on a standard JVM Thread platformThread = new Thread(() -> { performBlockingOperation(); }); // Virtual Thread: ~a few KB per thread // Can create millions without issues Thread virtualThread = Thread.ofVirtual().start(() -> { performBlockingOperation(); }); } // A Virtual Thread "mounts" onto a Platform Thread (carrier) // During I/O blocking, it releases the carrier for other Virtual Threads private void performBlockingOperation() { try { Thread.sleep(1000); // Virtual Thread detaches from carrier here } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } ``` 근본적인 차이는 블로킹 시의 동작에 있습니다. Platform Thread가 블로킹되면(I/O, sleep) OS 스레드에 그대로 묶여 있습니다. 반면 Virtual Thread는 캐리어 스레드에서 unmount되어 다른 Virtual Threads가 그 캐리어를 사용할 수 있게 합니다. ### 질문 2: Spring Boot 3.4에서 Virtual Threads를 어떻게 활성화하는가 Spring Boot 3.4는 Virtual Threads 활성화를 단일 설정 프로퍼티로 단순화합니다. 프레임워크 전체가 자동으로 적응하며 Tomcat, REST 컨트롤러, 블로킹 호출 모두가 즉시 이 최적화의 혜택을 받습니다. ```yaml # application.yml # Global Virtual Threads activation spring: threads: virtual: enabled: true # Optional Tomcat pool configuration server: tomcat: threads: max: 200 # Less critical with Virtual Threads min-spare: 10 ``` ```java // WebConfig.java // Programmatic activation if needed @Configuration public class WebConfig { @Bean public TomcatProtocolHandlerCustomizer virtualThreadCustomizer() { // Each HTTP request runs on a Virtual Thread return protocolHandler -> { protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); }; } } ``` 활성화는 Tomcat의 동작을 바꿉니다. 고정 크기 스레드 풀 대신 요청마다 자체 Virtual Thread를 받게 됩니다. 이 접근 방식은 전통적인 스레드 풀의 병목을 제거합니다. ### 질문 3: "mounting"과 "unmounting" 개념을 설명하십시오 Mounting은 Virtual Thread를 캐리어 스레드(Platform Thread)에 연결하는 동작을 의미합니다. Unmounting은 블로킹 작업 중 발생하며 캐리어를 다른 Virtual Threads에 풀어줍니다. 이 메커니즘은 CPU 자원을 최적으로 활용하게 해줍니다. ```java // MountingDemo.java // Illustrating the mounting/unmounting cycle public class MountingDemo { public void demonstrateMounting() { Thread.ofVirtual().start(() -> { // MOUNTED: Virtual Thread is using a carrier thread System.out.println("Carrier: " + getCurrentCarrier()); // UNMOUNTING: releases carrier during blocking performDatabaseQuery(); // Blocking JDBC call // REMOUNTED: may be on a different carrier System.out.println("New carrier: " + getCurrentCarrier()); }); } // Blocking operations trigger unmounting automatically private void performDatabaseQuery() { // JDBC connection, file read, network call... // Virtual Thread detaches during I/O wait } private String getCurrentCarrier() { // Gets the current carrier thread name return Thread.currentThread().toString(); } } ``` 이 메커니즘은 개발자에게는 투명합니다. 코드는 전통적인 명령형 스타일로 작성되지만 JVM이 자동으로 캐리어 사용을 최적화합니다. 적은 수의 캐리어로 구성된 풀이 수백만 개의 Virtual Threads를 처리할 수 있습니다. > **기술 노트** > > 캐리어 스레드 수는 기본적으로 CPU 코어 수와 일치합니다. JVM은 ForkJoinPool을 통해 이 풀을 동적으로 조정합니다. ## 사용 사례와 안티패턴 ### 질문 4: Virtual Threads는 언제 성능 향상을 가져다주는가 Virtual Threads는 외부 REST 호출, 데이터베이스 쿼리, 파일 읽기와 같은 I/O 바운드 작업에서 빛을 발합니다. 이러한 작업은 대부분의 시간을 대기에 사용하며 그 동안 Virtual Thread가 자신의 캐리어를 해제합니다. ```java // IOBoundService.java // Ideal case for Virtual Threads @Service public class IOBoundService { private final RestClient restClient; private final UserRepository userRepository; // Each call involves network and database waiting // Virtual Threads shine here public UserProfile enrichUserProfile(Long userId) { // DB call - VT detaches during SQL query User user = userRepository.findById(userId).orElseThrow(); // External REST call - VT detaches during HTTP wait ExternalData externalData = restClient .get() .uri("/api/external/{id}", userId) .retrieve() .body(ExternalData.class); // Data aggregation return new UserProfile(user, externalData); } } ``` 이점은 훨씬 더 많은 동시 요청을 처리할 수 있다는 데서 옵니다. Platform Threads 200개와 100ms 요청에서 최대 처리량은 2,000 req/s입니다. Virtual Threads로는 동일한 머신에서 50,000 req/s 이상에 도달할 수 있습니다. ### 질문 5: Virtual Threads에서 피해야 할 안티패턴은 무엇인가 Virtual Threads는 CPU 바운드 작업이나 "pinning"을 유발하는 상황에는 적합하지 않습니다. Pinning은 블로킹에도 불구하고 Virtual Thread가 캐리어에 묶여 있어 가상화의 이점을 잃게 만드는 현상입니다. ```java // AntiPatterns.java // Examples of cases to avoid @Service public class AntiPatterns { // ANTI-PATTERN 1: CPU-intensive computation // Virtual Threads provide no benefit here public BigInteger computeFactorial(int n) { // 100% CPU, no I/O, no unmounting possible BigInteger result = BigInteger.ONE; for (int i = 2; i <= n; i++) { result = result.multiply(BigInteger.valueOf(i)); } return result; // Carrier is monopolized throughout } // ANTI-PATTERN 2: Synchronized causes pinning private final Object lock = new Object(); public void pinnedOperation() { synchronized (lock) { // PINNING: VT stays on carrier performDatabaseQuery(); // Unmounting doesn't happen! } } // SOLUTION: Use ReentrantLock private final ReentrantLock reentrantLock = new ReentrantLock(); public void unpinnedOperation() { reentrantLock.lock(); try { performDatabaseQuery(); // Unmounting possible } finally { reentrantLock.unlock(); } } } ``` 자원 측면에서 pinning은 Virtual Thread를 사실상 Platform Thread로 만들어 버립니다. 주된 원인은 `synchronized` 블록과 JNI를 통한 네이티브 호출입니다. `ReentrantLock`으로 마이그레이션하면 첫 번째 경우를 해결할 수 있습니다. ### 질문 6: 애플리케이션에서 pinning을 어떻게 탐지하는가 JVM은 pinning 사례를 식별할 수 있는 진단 옵션을 제공합니다. 이 정보는 Virtual Threads로 마이그레이션할 때 매우 중요합니다. pinning은 성능을 개선하기는커녕 떨어뜨릴 수 있기 때문입니다. ```java // PinningDiagnostics.java // Configuration and pinning detection public class PinningDiagnostics { // JVM option to log pinning // -Djdk.tracePinnedThreads=full (detailed) // -Djdk.tracePinnedThreads=short (summary) // Example code causing pinning public void demonstratePinning() { Thread.ofVirtual().start(() -> { synchronized (this) { // This log will appear with tracePinnedThreads enabled try { Thread.sleep(100); // Pinned during sleep! } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); } } ``` ```bash # Typical output with -Djdk.tracePinnedThreads=full Thread[#23,VirtualThread[#1]/runnable@ForkJoinPool-1-worker-1,5,CarrierThreads] java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183) java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661) com.example.PinningDiagnostics.demonstratePinning(PinningDiagnostics.java:15) ``` pinning 로그를 분석하면 수정해야 할 핫스팟이 드러납니다. pinning이 빈번한 애플리케이션은 Virtual Threads의 장점을 충분히 살리지 못하며, 오히려 Platform Threads보다 느려질 수도 있습니다. ## 성능 벤치마크 ### 질문 7: Virtual Threads로 어느 정도의 성능 향상을 기대할 수 있는가 벤치마크에 따르면 일반적인 I/O 바운드 애플리케이션에서 상당한 개선이 나타납니다. 향상 폭은 I/O 시간 대 CPU 시간의 비율과 요구되는 동시성 수준에 달려 있습니다. ```java // BenchmarkController.java // Endpoint for measuring performance @RestController @RequestMapping("/api/benchmark") public class BenchmarkController { private final ExternalApiClient apiClient; // Simulation of a typical endpoint with external calls @GetMapping("/user/{id}") public ResponseEntity getUser(@PathVariable Long id) { // Three sequential I/O calls UserInfo info = apiClient.fetchUserInfo(id); // ~50ms List orders = apiClient.fetchOrders(id); // ~80ms CreditScore score = apiClient.fetchCreditScore(id); // ~100ms return ResponseEntity.ok(new UserData(info, orders, score)); } } ``` ```text # Benchmark results - 10,000 concurrent requests # Configuration: 8 cores, 16GB RAM, simulated latency 230ms/request Platform Threads (pool 200): - Throughput: 850 req/s - P99 latency: 1250ms - Heap memory: 2.1GB Virtual Threads: - Throughput: 4200 req/s - P99 latency: 280ms - Heap memory: 850MB Gain: 5x throughput, 4.5x P99 latency reduction ``` Virtual Threads는 메모리 사용량도 줄여줍니다. 각 스레드가 약 1MB가 아니라 수 KB만 차지하기 때문입니다. ### 질문 8: Virtual Threads에서 커넥션 풀은 어떻게 설정해야 하는가 커넥션 풀(HikariCP, Lettuce)은 Virtual Threads에서 새로운 병목이 됩니다. 10개 커넥션의 풀은 Virtual Thread가 수백만 개라도 동시 DB 쿼리를 10개로 제한합니다. ```yaml # application.yml # HikariCP configuration optimized for Virtual Threads spring: datasource: hikari: # More connections since Virtual Threads allow more concurrency maximum-pool-size: 50 minimum-idle: 10 # Shorter timeout due to more concurrent requests connection-timeout: 5000 # Fast validation validation-timeout: 3000 # Redis with Lettuce - already async-friendly data: redis: lettuce: pool: max-active: 50 max-idle: 20 ``` ```java // ConnectionPoolMonitor.java // Pool monitoring to avoid contention @Component public class ConnectionPoolMonitor { private final HikariDataSource dataSource; @Scheduled(fixedRate = 10000) public void logPoolStats() { HikariPoolMXBean pool = dataSource.getHikariPoolMXBean(); log.info("Pool stats - Active: {}, Idle: {}, Waiting: {}", pool.getActiveConnections(), pool.getIdleConnections(), pool.getThreadsAwaitingConnection()); // Alert if too many threads waiting if (pool.getThreadsAwaitingConnection() > 100) { log.warn("Connection pool contention detected!"); } } } ``` 풀 크기는 Virtual Thread 수가 아닌 데이터베이스의 처리 능력에 따라 결정됩니다. 표준 PostgreSQL은 약 100~200개의 활성 커넥션을 처리할 수 있습니다. ### 질문 9: Micrometer로 Virtual Threads의 영향을 어떻게 측정하는가 Micrometer와 Spring Boot Actuator는 Virtual Threads의 효과를 평가하기 위한 핵심 메트릭을 제공합니다. 이러한 메트릭을 통해 효과를 입증하고 잠재적 문제를 드러낼 수 있습니다. ```java // VirtualThreadMetrics.java // Custom metrics for Virtual Threads @Component public class VirtualThreadMetrics { private final MeterRegistry registry; private final AtomicLong activeVirtualThreads = new AtomicLong(0); @PostConstruct public void registerMetrics() { // Active Virtual Threads counter Gauge.builder("virtual.threads.active", activeVirtualThreads, AtomicLong::get) .description("Number of active virtual threads") .register(registry); // JVM metrics for carriers Gauge.builder("virtual.threads.carriers", this::getCarrierCount) .description("Number of carrier threads") .register(registry); } private double getCarrierCount() { // Gets the ForkJoinPool carrier count return ForkJoinPool.commonPool().getPoolSize(); } // Interceptor to trace requests public void trackVirtualThread(Runnable task) { activeVirtualThreads.incrementAndGet(); try { task.run(); } finally { activeVirtualThreads.decrementAndGet(); } } } ``` ```yaml # application.yml # Metrics exposure management: endpoints: web: exposure: include: health,metrics,prometheus metrics: tags: application: ${spring.application.name} ``` 메트릭 분석을 통해 Virtual Threads 대 Carriers의 비율을 확인하고 커넥션 풀에서 경합이 발생한 시점을 식별할 수 있습니다. ## 마이그레이션과 호환성 ### 질문 10: Virtual Threads와 호환되는 라이브러리는 무엇인가 호환성은 `synchronized` 블록 사용과 네이티브 호출에 좌우됩니다. Spring 생태계는 대체로 호환되지만 일부 라이브러리는 특정 버전을 요구합니다. ```java // CompatibilityCheck.java // Checking dependency compatibility @Configuration public class CompatibilityCheck { // Compatible libraries (recommended versions) // - Spring Boot 3.2+ (native support) // - HikariCP 5.1+ (ReentrantLock instead of synchronized) // - Lettuce 6.3+ (non-blocking I/O) // - Jackson 2.16+ (no synchronized) // Libraries requiring attention // - JDBC drivers: check version // - Some legacy HTTP clients @Bean public CommandLineRunner checkCompatibility() { return args -> { // Log versions for audit log.info("Java version: {}", System.getProperty("java.version")); log.info("Virtual threads available: {}", Thread.ofVirtual() != null); // Support verification if (Runtime.version().feature() < 21) { throw new IllegalStateException( "Java 21+ required for Virtual Threads"); } }; } } ``` 대다수의 모던 프레임워크는 이미 `ReentrantLock`으로 전환했습니다. 레거시 의존성은 `-Djdk.tracePinnedThreads=short`를 활성화한 부하 테스트로 pinning 문제를 드러낼 수 있습니다. ### 질문 11: Virtual Threads로 점진적으로 마이그레이션하는 방법 점진적 마이그레이션은 운영 환경에 위험을 주지 않으면서 효과를 검증하고 문제를 식별하게 해줍니다. 권장 전략은 Virtual Threads용 엔드포인트를 전통적 엔드포인트로부터 분리하는 것입니다. ```java // DualExecutorConfig.java // Configuration for progressive migration @Configuration public class DualExecutorConfig { // Traditional executor for legacy endpoints @Bean("platformExecutor") public ExecutorService platformExecutor() { return Executors.newFixedThreadPool(200); } // Virtual Threads executor for new endpoints @Bean("virtualExecutor") public ExecutorService virtualExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } } @RestController @RequestMapping("/api/v2") public class MigratedController { @Qualifier("virtualExecutor") private final ExecutorService executor; // Endpoint migrated to Virtual Threads @GetMapping("/users/{id}") public CompletableFuture getUser(@PathVariable Long id) { return CompletableFuture.supplyAsync(() -> { // Business code unchanged return userService.findById(id); }, executor); } } ``` ```yaml # application.yml # Feature flag for migration features: virtual-threads: enabled: true endpoints: - /api/v2/** - /api/reports/** # Disable for quick rollback # features.virtual-threads.enabled: false ``` > **주의** > > 모든 의존성을 검증하기 전에 Virtual Threads를 전역으로 활성화해서는 안 됩니다. pinning은 성능을 크게 떨어뜨릴 수 있습니다. ### 질문 12: 운영 배포 전에 어떤 테스트를 수행해야 하는가 Virtual Threads 검증에는 부하 테스트, pinning 테스트, 호환성 테스트가 필요합니다. 이러한 테스트는 현실적인 운영 조건을 모사해야 합니다. ```java // VirtualThreadLoadTest.java // Load test with JUnit and Virtual Threads @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class VirtualThreadLoadTest { @LocalServerPort private int port; @Test void shouldHandleHighConcurrency() throws Exception { int concurrentRequests = 5000; CountDownLatch latch = new CountDownLatch(concurrentRequests); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger errorCount = new AtomicInteger(0); HttpClient client = HttpClient.newHttpClient(); // Launch 5000 simultaneous requests try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < concurrentRequests; i++) { executor.submit(() -> { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://localhost:" + port + "/api/test")) .build(); HttpResponse response = client.send( request, BodyHandlers.ofString()); if (response.statusCode() == 200) { successCount.incrementAndGet(); } else { errorCount.incrementAndGet(); } } catch (Exception e) { errorCount.incrementAndGet(); } finally { latch.countDown(); } }); } } latch.await(60, TimeUnit.SECONDS); // Performance assertions assertThat(successCount.get()).isGreaterThan(4900); // >98% success assertThat(errorCount.get()).isLessThan(100); } } ``` 테스트는 경합 시나리오(커넥션 풀 포화), 타임아웃, 수 분간 지속되는 부하 등을 포괄해야 합니다. ## 심화 질문 ### 질문 13: Virtual Threads는 Structured Concurrency와 어떻게 상호작용하는가 Structured Concurrency(JEP 453)는 Virtual Threads를 보완하여 동시 작업이 동일한 라이프사이클을 공유하도록 보장합니다. 이 접근 방식은 오류 처리와 취소를 단순화합니다. ```java // StructuredConcurrencyExample.java // Combining Virtual Threads + Structured Concurrency public class StructuredConcurrencyExample { public UserDashboard fetchDashboard(Long userId) throws Exception { // StructuredTaskScope ensures all tasks complete together try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { // Three parallel tasks on Virtual Threads Subtask profileTask = scope.fork(() -> userService.getProfile(userId)); Subtask> notifTask = scope.fork(() -> notificationService.getRecent(userId)); Subtask balanceTask = scope.fork(() -> accountService.getBalance(userId)); // Wait for all tasks or fail on first error scope.join(); scope.throwIfFailed(); // All tasks succeeded - safe aggregation return new UserDashboard( profileTask.get(), notifTask.get(), balanceTask.get() ); } // If one task fails, others are automatically cancelled } } ``` Structured Concurrency는 스레드 누수를 방지하고 콜 스택이 코드의 논리 구조를 반영하기 때문에 디버깅을 단순하게 만듭니다. ### 질문 14: Virtual Threads와 리액티브 프로그래밍의 차이는 무엇인가 두 접근 방식 모두 동일한 문제(I/O 효율)를 다루지만 프로그래밍 모델이 다릅니다. Virtual Threads는 전통적인 명령형 코드를 허용하는 반면 리액티브 프로그래밍은 스트림 형태로 다시 작성해야 합니다. ```java // ComparisonService.java // Same logic: imperative vs reactive @Service public class ComparisonService { // VIRTUAL THREADS APPROACH: classic imperative code // Easy to read, debug, and maintain public UserData getUserDataImperative(Long id) { User user = userRepository.findById(id).orElseThrow(); List orders = orderRepository.findByUserId(id); PaymentInfo payment = paymentService.getInfo(id); return new UserData(user, orders, payment); } // REACTIVE APPROACH: streams and operators // More complex but native backpressure public Mono getUserDataReactive(Long id) { return userRepository.findById(id) .zipWith(orderRepository.findByUserId(id).collectList()) .zipWith(paymentService.getInfo(id)) .map(tuple -> new UserData( tuple.getT1().getT1(), tuple.getT1().getT2(), tuple.getT2() )); } } ``` | 기준 | Virtual Threads | 리액티브 | |------|-----------------|---------| | 학습 곡선 | 낮음 | 높음 | | 디버깅 | 전통적인 스택 트레이스 | 복잡 | | 백프레셔 | 수동 | 네이티브 | | 생태계 | 성장 중 | 성숙 | | 레거시 마이그레이션 | 단순 | 재작성 | 신규 애플리케이션과 마이그레이션에는 Virtual Threads가 권장됩니다. 정교한 백프레셔가 필요한 경우에는 리액티브가 여전히 유효합니다. ### 질문 15: Virtual Threads에서 ThreadLocal은 어떻게 다루는가 ThreadLocal은 Virtual Threads와 함께 사용할 수 있지만 인스턴스마다 메모리를 소비합니다. Scoped Values(JEP 446)는 컨텍스트 공유를 위한 더 효율적인 대안입니다. ```java // ThreadLocalVsScopedValue.java // Comparing context approaches public class ThreadLocalVsScopedValue { // CLASSIC APPROACH: ThreadLocal // Works but expensive with millions of VT private static final ThreadLocal requestContext = new ThreadLocal<>(); public void processWithThreadLocal(Request request) { requestContext.set(new RequestContext(request.getTraceId())); try { // Context accessible everywhere in the thread processRequest(); } finally { requestContext.remove(); // Important to prevent leaks } } // MODERN APPROACH: ScopedValue (Java 21+) // More efficient, immutable, explicit scope private static final ScopedValue CONTEXT = ScopedValue.newInstance(); public void processWithScopedValue(Request request) { ScopedValue.where(CONTEXT, new RequestContext(request.getTraceId())) .run(() -> { // Context accessible within this scope processRequest(); // No cleanup needed - automatic at scope end }); } private void processRequest() { // Context access String traceId = CONTEXT.isBound() ? CONTEXT.get().getTraceId() : "unknown"; log.info("Processing with trace: {}", traceId); } } ``` ScopedValues는 신규 개발에 권장됩니다. ThreadLocal을 사용하는 레거시 코드는 점진적인 마이그레이션이 바람직합니다. ## 결론 Virtual Threads는 단순하면서 고성능인 코드를 가능하게 하여 자바 백엔드 개발을 변화시킵니다. 핵심 요점: **기초:** - `spring.threads.virtual.enabled=true`로 활성화 - I/O 바운드 워크로드(REST, DB, 파일)에 이상적 - CPU 집약적인 계산에는 부적합 **성능:** - 커넥션 풀의 크기를 적절히 조정(새로운 병목) - `-Djdk.tracePinnedThreads`로 pinning 모니터링 - `synchronized`를 `ReentrantLock`으로 마이그레이션 **마이그레이션:** - 엔드포인트 그룹 단위로 점진적으로 테스트 - 의존성의 호환성 검증 - 병렬 작업에는 Structured Concurrency 활용 Virtual Threads에 대한 숙련도는 현대 애플리케이션의 성능 과제를 이해하는 지원자를 돋보이게 합니다. 이러한 개념은 이제 Spring Boot 기술 면접에서 빠질 수 없는 요소입니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/spring-boot/spring-boot-virtual-threads-interview