Language/Java

[Java 21] 가상 스레드(Virtual Threads) 사용기

계범 2025. 11. 25. 16:56

가상 스레드(Virtual Threads) 딱 알기 – Java 21 기준

가상 스레드는 Java 21에서 정식(GA, JEP 444)으로 도입된 초경량 스레드예요. 기존 “플랫폼 스레드(OS 커널 스레드 1:1)”와 달리, JVM이 사용자 모드에서 스케줄링하는 스레드라서 수십만~수백만 개까지도 가볍게 만들고(생성/전환 비용↓) 블로킹 코드 그대로 높은 동시성을 내게 해줍니다.


왜 필요한가?

  • 스레드당 요청 모델다시 단순하게: “요청 하나 = 스레드 하나 = 이해하기 쉬운 블로킹 코드”
  • 확장성: 연결 대기/IO 대기 시간이 길어도, 가상 스레드는 주차(park) 시 캐리어 OS 스레드를 즉시 반납 → 같은 하드웨어로 더 많은 동시 작업
  • 복잡성↓: 콜백/리액티브로 억지로 비동기화하지 않아도, 블로킹 스타일로 읽기 쉬운 코드를 유지 ( 기존 코드 스타일을 그대로 유지할 수 있다! )

어떻게 동작하나? (한 장 요약)

  • 가상 스레드는 JVM 스케줄러(ForkJoin 기반) 위에서 돌아가고, 실제 실행 시 소수의 OS 스레드(캐리어)mount됩니다.
  • 블로킹(park) 지점(예: 소켓 읽기, sleep, LockSupport.park)을 만나면 unmount → 캐리어 스레드는 즉시 다른 가상 스레드로 전환.
  • 다시 깨어날 때 다른 캐리어 스레드에 remount되어 이어서 실행.

즉, 블로킹을 두려워하지 않아도 되는 구조가 됩니다. (단, “핀(pin)” 되는 경우 주의!)


플랫폼 스레드 vs 가상 스레드

 
구분플랫폼 스레드(기존)가상 스레드(Loom)
매핑 1:1 (자바↔OS) N:소수 (수많은 가상↔적은 OS)
생성/전환 비용 비쌈 매우 저렴
동시성 규모 수천~수만 수십만~수백만
코드 스타일 블로킹 시 확장성 저하 블로킹 코드를 그대로 써도 확장성 유지
적합 작업 CPU 바운드 IO 바운드·대기 많은 작업

CPU 바운드 작업은 가상 스레드가 더 빠르지 않습니다.

오히려 더 많은 전환 등으로 더 느릴수도 있습니다.

사용하면 좋은 케이스

  1. 대기/IO가 많은 서버: DB, 외부 HTTP(LLM 요청 포함), 파일/네트워크 IO 등
  2. 스케줄/배치: 많은 소량 작업(fan-out) 처리
  3. “리액티브가 과한” 곳: 리액티브 유지보수 부담을 줄이고 싶은 서비스

 

가상 스레드 사용법

간단 사용

Thread.startVirtualThread(
			() -> {
				JobDescription jobDescription = jobDescriptionRepository.findByChatSn(chatSn)
					.orElseThrow(() -> new BusinessException(ErrorCode.JOB_DESCRIPTION_NOT_FOUND));
			}
		);

 

별도의 설정으로 빈 등록 후 사용하기

virtualThreadExecutor 설정

반환값을 가지게 하기 위해 CompletableFuture를 사용.

가상 스레드 내부적으로 계속 도는 것 또는 죽지 않고 계속 살아있는것을 방지하기 위해 시간초과 코드 추가.

 

@Slf4j
@AllArgsConstructor
public class VirtualThreadExecutor {
	private final Executor virtualThreadExecutor;

	private final long defaultTimeoutMillis = 180000; // 기본 타임아웃 시간 (밀리초 단위)
	private final TimeUnit defaultTimeoutUnit = TimeUnit.MILLISECONDS;

	public <T> CompletableFuture<T> runAsync(Supplier<T> task) {
		CompletableFuture<T> future = new CompletableFuture<>();

		virtualThreadExecutor.execute(() -> {
			try {
				T result = task.get();
				future.complete(result);
			} catch (Throwable ex) {
				future.completeExceptionally(ex);
			}
		});

		return future
			.orTimeout(defaultTimeoutMillis, defaultTimeoutUnit)
			.whenComplete((res, ex) -> {
				if (ex != null) {
					log.error("VirtualThreadExecutor.runAsync() - Task execution failed", ex);
				}
			});
	}

	public CompletableFuture<Void> runVoidAsync(String taskName, Runnable task) {
		CompletableFuture<Void> future = new CompletableFuture<>();

		virtualThreadExecutor.execute(() -> {
			try {
				task.run();
				future.complete(null);
			} catch (Throwable ex) {
				future.completeExceptionally(ex);
			}
		});

		return future
			.orTimeout(defaultTimeoutMillis, defaultTimeoutUnit)
			.whenComplete((res, ex) -> {
				if (ex != null) {
					log.error("VirtualThreadExecutor.runVoidAsync() - Task {} execution failed", taskName, ex);
				}
			});
	}
}

 

factory 설정

우리 서비스에선 스레드로컬을 별도로 커스텀하게 적용하여 사용중.

고로 가상 스레드를 생성할 때, 기존 스레드로컬 데이터를 복사해줄 필요가 있음.

관련 스레드 생성시점 로직을 오버라이드하여 복사 처리.

주의 사항은 주석 참조.

/**
 * ThreadLocal(TenantContext)을 가상 스레드에 전파하기 위한 ThreadFactory 구현입니다.
 * <p>
 * 가상 스레드는 매우 가볍고 대량으로 생성될 수 있으므로,
 * ThreadLocal에 큰 객체나 많은 데이터를 넣는 것은 메모리 누수 및 성능 저하를 유발할 수 있습니다.
 * <p>
 * 따라서 가능한 한 ThreadLocal에는 최소한의 필요한 값만 보관해야 하며,
 * 이 factory는 요청 시점의 TenantContext를 복사하여 새로운 가상 스레드에 설정합니다.
 */
public class ThreadLocalPropagatingThreadFactory implements ThreadFactory {
	private final ThreadFactory delegate;


	public ThreadLocalPropagatingThreadFactory(ThreadFactory delegate) {
		this.delegate = delegate;
	}

	@Override
	public Thread newThread(Runnable r) {

		TenantVo currentTenantVo = TenantContext.getTenantVo();

		return delegate.newThread(() -> {
			try {
				// 복사한 값 설정
				TenantContext.setTenantVo(currentTenantVo);
				r.run();
			} finally {
				// 사용 후 정리
				TenantContext.clear();
			}
		});
	}
}

 

실제 서비스 적용

// virtualThreadExecutor 정의
private final VirtualThreadExecutor virtualThreadExecutor;

// 실제 반환값 사용 가상스레드 적용
CompletableFuture<SolverChatGenerateResponseRs> solverApiResponseCompletableFuture = 
  virtualThreadExecutor.runAsync(
			() -> solverClient.generateChatResponse(rq)
		);

 

동시 실행 개수 제한 가상스레드

가상스레드를 막 사용하다보면, cpu와 메모리가 기하급수적으로 증가하는 현상을 목격할 수도 있다.

가상 스레드 내에서 db조작등의 작업을 하게 되면, connection 등 다운스트림 자원이 고갈될 수도 있기도 하다.

이럴 때 동시 실행 개수를 제한하고 싶다는 니즈가 생길 수 있으므로 이때 사용하는 가상스레드이다.

일단 위에서도 말했다시피, 가상스레드는 생성/전환 비용이 저렴하므로 풀로 관리하는건 비효율적이다.

대신 세마포어 등을 이용하여 동시성을 제한한다. (저는 세마포어 이용했슴다)

 

/**
 * 가상 스레드에서 동시 실행 개수를 제한하는 Executor입니다.
 * 이 Executor는 Semaphore를 사용하여 동시 실행 개수를 제한합니다.
 * <p>
 * 가상 스레드에서 동시 실행 개수를 제한하는 이유는,
 * 가상 스레드는 매우 가볍고 대량으로 생성될 수 있으므로,
 * 동시 실행 개수를 제한하지 않으면
 * 서버의 자원을 과도하게 소모할 수 있습니다.
 * <p>
 * ( 내부적으로 db 작업이 있다면, db 커넥션을 가상 스레드에서 다 소모할 수 있음. 외부 컨트롤러 요청이 딜레이 또는 막힘 )
 * ( cpu 바운드 작업이 있다면, cpu를 과도하게 소모할 수 있음. )
 * <p>
 * 세마포어 다 사용하면, 가상 스레드는 대기 상태가 되고,
 * 대기 상태가 되면, 가상 스레드는 자원을 소모하지 않으므로,
 * 서버의 자원을 절약할 수 있습니다.
 */
@Slf4j
@AllArgsConstructor
public class ConcurrencyLimiterExecutor {
	private final Semaphore semaphore;
	private final Executor virtualThreadExecutor;

	private final long defaultSemaphoreTimeoutMillis = 30000; // 세마포어 타임아웃 시간 (밀리초 단위)
	private final long defaultTimeoutMillis = 300000; // 기본 타임아웃 시간 (밀리초 단위)
	private final TimeUnit defaultTimeoutUnit = TimeUnit.MILLISECONDS;
    
    boolean acquire = false;

	public <T> CompletableFuture<T> runAsync(Supplier<T> task) {
		long currentTimeMillis = System.currentTimeMillis();
		try {
			log.info("ConcurrencyLimiterExecutor.runAsync() - semaphore available Permit {}", semaphore.availablePermits());
			acquire = semaphore.tryAcquire(defaultSemaphoreTimeoutMillis, defaultTimeoutUnit);

			if (!acquire) {
				log.warn("ConcurrencyLimiterExecutor.runAsync() - semaphore acquire timeout after {} ms", defaultTimeoutMillis);

				CompletableFuture<T> failed = new CompletableFuture<>();
				failed.completeExceptionally(
					new RejectedExecutionException("Task rejected due to concurrency limit")
				);
				return failed;
			}
			CompletableFuture<T> future = new CompletableFuture<>();

			virtualThreadExecutor.execute(() -> {
				try {
					T result = task.get();
					future.complete(result);
				} catch (Throwable ex) {
					future.completeExceptionally(ex);
				}
			});

			return future
				.orTimeout(defaultTimeoutMillis, defaultTimeoutUnit)
				.whenComplete((res, ex) -> {
					if (ex != null) {
						log.error("ConcurrencyLimiterExecutor.runAsync() - Task execution failed", ex);
					}
				});

		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			CompletableFuture<T> failed = new CompletableFuture<>();
			failed.completeExceptionally(
				new RejectedExecutionException("Interrupted while waiting for permit", e)
			);
			return failed;
		} finally {
			if (acquired) { // ✅ permit 얻은 경우에만 release
            semaphore.release();
        }
			log.info("ConcurrencyLimiterExecutor.runAsync() - lock acquired time : {}, semaphore available Permit : {}", System.currentTimeMillis() - currentTimeMillis, semaphore.availablePermits());
		}
	}

	public CompletableFuture<Void> runVoidAsync(Runnable task) {
		long currentTimeMillis = System.currentTimeMillis();
		try {
			log.info("ConcurrencyLimiterExecutor.runAsync() - semaphore available Permit {}", semaphore.availablePermits());

			boolean acquire = semaphore.tryAcquire(defaultSemaphoreTimeoutMillis, defaultTimeoutUnit);

			if (!acquire) {
				log.warn("ConcurrencyLimiterExecutor.runAsync() - semaphore acquire timeout after {} ms", defaultTimeoutMillis);

				CompletableFuture<Void> failed = new CompletableFuture<>();
				failed.completeExceptionally(
					new RejectedExecutionException("Task rejected due to concurrency limit")
				);
				return failed;
			}

			CompletableFuture<Void> future = new CompletableFuture<>();

			virtualThreadExecutor.execute(() -> {
				try {
					task.run();
					future.complete(null);
				} catch (Throwable ex) {
					future.completeExceptionally(ex);
				}
			});

			return future
				.orTimeout(defaultTimeoutMillis, defaultTimeoutUnit)
				.whenComplete((res, ex) -> {
					if (ex != null) {
						log.error("ConcurrencyLimiterExecutor.runVoidAsync() - Task execution failed", ex);
					}
				});

		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			CompletableFuture<Void> failed = new CompletableFuture<>();
			failed.completeExceptionally(
				new RejectedExecutionException("Interrupted while waiting for permit", e)
			);
			return failed;
		} finally {
			if (acquired) { // ✅ permit 얻은 경우에만 release
            semaphore.release();
        }
			log.info("ConcurrencyLimiterExecutor.runAsync() - lock acquired time : {}, semaphore available Permit : {}", System.currentTimeMillis() - currentTimeMillis, semaphore.availablePermits());
		}
	}
}

 

가상스레드 동작 전 세마포어 락을 획득 후 동작하며,
이후 어떤 과정을 겪든 최종적으로 락을 해제하게 처리하여 동시성을 제한했습니다.

동시성의 제한 수는 세마포어의 개수입니다.

저희는 동시성 제한이 대부분 db 작업과 맞물릴 거 같아서 db의 히카리 커넥션풀 사이즈와 유사하게 가져갔습니다.

(좀 더 크게..!)

사실 트랜잭션이 오래걸리지 않게 잘 설정한다면, 히카리 커넥션풀과 문제될 사항이 있을까 싶네요..

하지만 병렬 실행 및 속도 개선을 위해 빠르게 적용하면서 내부 코드 개선은 안했다보니 동시성을 제한해놨습니다.

위의 config 설정의 값 ( 동시성 실행 개수 제한을 세마포어 수로 둔다! )

 

@Bean
	public ConcurrencyLimiterExecutor concurrencyLimitExecutor(Executor executor) {
		return new ConcurrencyLimiterExecutor(new Semaphore(40), executor);
	}

 

서비스 내 사용법은 위에서 말한 가상스레드 사용법과 동일합니다 ㅎㅎ

 

가상 스레드 내 타임아웃 발생 시 내부 동작 인터럽트

문제 상황

가상 스레드로 비동기 작업을 실행할 때, 작업이 예상보다 오래 걸리면 타임아웃을 발생시켜야 합니다.

하지만 단순히 `CompletableFuture`를 실패 처리하는 것만으로는 부족합니다. 실제로 실행 중인 스레드는 계속 동작하여 리소스를 낭비하게 됩니다.

 

// ❌ 문제: CF만 실패 처리, 실제 작업은 계속 실행됨
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    // 이 작업은 타임아웃 후에도 계속 실행됨
    return longRunningTask();
}, virtualThreadExecutor);

cf.orTimeout(3, TimeUnit.SECONDS); // CF만 실패, 스레드는 계속 실행

 

해결 방법: Future.cancel(true)로 인터럽트


핵심은 `Future.cancel(true)`를 사용하여 실행 중인 스레드에 인터럽트를 걸어 작업을 중단시키는 것입니다.

@Slf4j
public class VirtualThreadExecutor {
    private final ExecutorService virtualThreadExecutor;
    private final long taskTimeoutMillis;

    public <T> CompletableFuture<T> runAsync(Supplier<T> task) {
        final long startNanos = System.nanoTime();
        
        // 1) 가상 스레드로 작업 제출 + Future 핸들 확보
        final Future<T> f = virtualThreadExecutor.submit(task::get);
        
        // 2) Future -> CompletableFuture 브릿지
        CompletableFuture<T> cf = new CompletableFuture<>();
        virtualThreadExecutor.execute(() -> {
            try {
                T result = f.get();
                cf.complete(result);
            } catch (CancellationException e) {
                cf.completeExceptionally(e);
            } catch (ExecutionException e) {
                cf.completeExceptionally(e.getCause());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                cf.completeExceptionally(e);
            }
        });
        
        // 3) ⭐ 타임아웃: CF를 실패로 끝내고 실행 스레드를 interrupt
        CompletableFuture.runAsync(() -> {
            if (cf.completeExceptionally(new TimeoutException("task timeout " + taskTimeoutMillis + "ms"))) {
                boolean cancelled = f.cancel(true); // ★ 핵심: 실행 스레드 interrupt
                log.warn("VirtualThreadExecutor - timeout: cancelled={}", cancelled);
            }
        }, CompletableFuture.delayedExecutor(taskTimeoutMillis, TimeUnit.MILLISECONDS));
        
        // 4) 완료 로깅
        return cf.whenComplete((r, ex) -> {
            long tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
            if (ex instanceof TimeoutException) {
                log.warn("VirtualThreadExecutor - timeout after {} ms", tookMs);
            } else if (ex != null) {
                log.error("VirtualThreadExecutor - failed after {} ms", tookMs, ex);
            }
        });
    }
}

 

핵심 포인트 설명

### 1. Future 핸들 확보
```java
final Future<T> f = virtualThreadExecutor.submit(task::get);
```
- `ExecutorService.submit()`은 `Future` 객체를 반환
- 이 `Future`를 통해 나중에 작업을 취소할 수 있음

### 2. CompletableFuture 브릿지
```java
CompletableFuture<T> cf = new CompletableFuture<>();
virtualThreadExecutor.execute(() -> {
    T result = f.get();
    cf.complete(result);
});
```
- `Future`는 조합이 어려우므로 `CompletableFuture`로 변환
- 별도의 가상 스레드에서 `Future.get()`을 대기

### 3. 타임아웃 처리 (핵심!)
```java
CompletableFuture.runAsync(() -> {
    if (cf.completeExceptionally(new TimeoutException(...))) {
        boolean cancelled = f.cancel(true); // ⭐ 인터럽트 발생
        log.warn("timeout: cancelled={}", cancelled);
    }
}, CompletableFuture.delayedExecutor(taskTimeoutMillis, TimeUnit.MILLISECONDS));
```

**동작 순서:**
1. `delayedExecutor`로 지정된 시간 후 실행
2. `cf.completeExceptionally()`로 CF를 실패 처리
   - 이미 완료된 경우 `false` 반환 (타임아웃 안 걸림)
   - 아직 실행 중이면 `true` 반환 (타임아웃 걸림)
3. `f.cancel(true)`로 **실행 중인 스레드에 인터럽트 전송**
   - `true` 파라미터: `mayInterruptIfRunning`
   - 스레드가 `InterruptedException`을 받아 중단됨

### 4. 인터럽트 처리
```java
try {
    T result = f.get();
    cf.complete(result);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // 인터럽트 상태 복원
    cf.completeExceptionally(e);
}
```
- `f.cancel(true)` 호출 시 `f.get()`에서 `InterruptedException` 발생
- 인터럽트 상태를 복원하여 상위 레이어에 전파

## 실제 사용 예시

```java
@Service
public class MatchingService {
    private final VirtualThreadExecutor virtualThreadExecutor;
    
    public CompletableFuture<MatchingResult> matchTalents(Long chatSn) {
        return virtualThreadExecutor.runAsync(() -> {
            // 매칭 알고리즘 실행 (시간이 오래 걸릴 수 있음)
            return performMatching(chatSn);
        });
    }
    
    private MatchingResult performMatching(Long chatSn) {
        // 복잡한 매칭 로직
        // 타임아웃 발생 시 InterruptedException으로 중단됨
        if (Thread.interrupted()) {
            throw new InterruptedException("Matching interrupted");
        }
        // ...
    }
}
```

## 가상 스레드에서 인터럽트가 중요한 이유

### 1. 리소스 효율성
- 타임아웃 후에도 작업이 계속 실행되면 CPU, 메모리, DB 커넥션 등 낭비
- 인터럽트로 즉시 중단하여 리소스 회수

### 2. 가상 스레드의 특성
- 가상 스레드는 매우 가볍지만 **무한정 생성할 수는 없음**
- 불필요한 작업을 빨리 정리해야 새 작업 수용 가능

테스트

실서비스에서 간단하게 k6 기반 부하테스트를 진행해봤습니다.

(로컬 실행, 로깅 최적화 x)

# 가상 스레드 성능 비교 리포트

## 테스트 환경

### 하드웨어 사양
- **CPU**: Apple M4 Pro (12 cores)
- **메모리**: 24 GB
- **OS**: macOS 15.3.2

### 소프트웨어 환경
- **Java**: 21
- **Spring Boot**: 3.x
- **서버**: localhost:8101
- **테스트 도구**: k6
- **테스트 일시**: 2025-11-25

### 테스트 시나리오 (steady)
- **VUs (Virtual Users)**: 10명의 동시 사용자
- **Duration**: 2분 지속
- **시나리오 내용**: 전체 매칭 플로우 (로그인 → 채팅 생성 → 직무설명서 → 인재상 → 채용조건 → 인재 추천)
- **총 13개 API 호출**: 각 반복마다 순차적으로 실행
- **예상 소요 시간**: 반복당 약 2분

## 테스트 시나리오 상세

### 전체 플로우 (13단계)

1. **로그인** - 인증 토큰 발급
2. **채팅 생성** - 새로운 매칭 세션 시작
3. **채팅 내용 조회** - 생성된 채팅 확인
4. **직무설명서 SSE 조회** - LLM 기반 직무설명서 생성 (13~15초)
5. **직무설명서 수정** - 생성된 내용 수정
6. **직무설명서 검증** - 추천 가능 여부 검증 (2~3초)
7. **인재상 초기 생성** - LLM 기반 이상적 인재상 생성 (5~7초)
8. **인재상 SSE 조회** - 생성된 인재상 확인
9. **채용조건 템플릿 요청** - 채용조건 템플릿 생성
10. **채용조건 조회(SSE)** - 생성된 조건 확인
11. **채용조건 저장** - 최종 채용조건 저장
12. **인재 추천 요청** - 매칭 알고리즘 실행
13. **인재 조회(SSE)** - 추천된 인재 목록 조회 (12~17초)

### 주요 특징
- **I/O 바운드 작업 중심**: DB, Redis, 외부 API(LLM) 호출 다수
- **긴 대기 시간**: SSE 스트리밍으로 인한 장시간 연결 유지
- **복잡한 비즈니스 로직**: 매칭 알고리즘, LLM 처리 등

## 테스트 결과

### 가상 스레드 ON (Virtual Thread)
**설정**: `Executors.newThreadPerTaskExecutor(Thread.ofVirtual().factory())` - 무제한 가상 스레드

| 메트릭 | 값 |
|--------|-----|
| 평균 응답시간 | 3,439ms (3.4초) |
| P95 응답시간 | 14,117ms (14.1초) |
| 처리량 | 0.983 req/s |
| 총 요청 수 | 130개 |
| 완료 반복 | 10회 |
| 실패율 | 0.0% |

### 가상 스레드 OFF (Platform Thread Pool)
**설정**: `Executors.newFixedThreadPool(200)` - 고정 크기 스레드 풀

| 메트릭 | 값 |
|--------|-----|
| 평균 응답시간 | 6,537ms (6.5초) |
| P95 응답시간 | 35,209ms (35.2초) |
| 처리량 | 0.833 req/s |
| 총 요청 수 | 125개 |
| 완료 반복 | 5회 |
| 실패율 | 4.0% (5건 타임아웃) |

## 성능 개선율

### ✅ 가상 스레드의 장점

| 항목 | 개선율 |
|------|--------|
| **평균 응답시간** | **47.4% 개선** (6.5초 → 3.4초) |
| **P95 응답시간** | **59.9% 개선** (35.2초 → 14.1초) |
| **처리량** | **18.0% 증가** (0.833 → 0.983 req/s) |
| **완료 반복** | **100% 증가** (5회 → 10회) |
| **안정성** | **타임아웃 0건** (일반: 5건) |

## 상세 분석

### 1. 응답 시간 (Response Time)
- 가상 스레드가 평균 **47.4% 빠른 응답시간**을 보임
- P95 기준으로는 **59.9% 개선**으로 더 큰 효과
- 고부하 상황에서 가상 스레드의 이점이 더 명확함

### 2. 처리량 (Throughput)
- 가상 스레드가 **18% 더 많은 요청** 처리
- 동일 시간 내 130개 vs 125개 요청 완료
- 더 많은 동시 연결을 효율적으로 처리

### 3. 안정성 (Reliability)
- **가상 스레드: 0% 실패율** (완벽한 성공)
- **일반 스레드: 4% 실패율** (5건 타임아웃)
- 가상 스레드가 더 안정적인 동작 보장

### 4. 완료율 (Completion Rate)
- 가상 스레드: 10회 완료 (100%)
- 일반 스레드: 5회 완료 (50%)
- **2배 더 많은 시나리오 완료**

## 구현 방법

### 가상 스레드 설정
```java
@Configuration
public class VirtualThreadConfig {
    @Bean(name = "vtExecutor", destroyMethod = "close")
    public ExecutorService vtExecutor() {
        return Executors.newThreadPerTaskExecutor(
            new ThreadLocalPropagatingThreadFactory(Thread.ofVirtual().factory())
        );
    }
}
```
- `Thread.ofVirtual().factory()`: 가상 스레드 팩토리
- `newThreadPerTaskExecutor`: 태스크당 새 스레드 생성
- 스레드 수 제한 없음

### 플랫폼 스레드 풀 설정 (비교용)
```java
@Configuration
public class VirtualThreadConfig {
    @Bean(name = "vtExecutor", destroyMethod = "shutdown")
    public ExecutorService vtExecutor() {
        return Executors.newFixedThreadPool(
            200, // 고정 크기
            new ThreadLocalPropagatingThreadFactory(Thread.ofPlatform().factory())
        );
    }
}
```
- `Thread.ofPlatform().factory()`: 일반 플랫폼 스레드
- `newFixedThreadPool(200)`: 200개 고정 크기
- 200개 초과 시 큐에서 대기

## 왜 가상 스레드가 더 빠른가?

### 1. 메모리 효율성
- **플랫폼 스레드**: 스레드당 ~1MB 메모리
- **가상 스레드**: 스레드당 ~1KB 메모리
- 1000배 적은 메모리로 더 많은 동시 작업 가능

### 2. 컨텍스트 스위칭 비용
- **플랫폼 스레드**: OS 레벨 스위칭 (비용 높음)
- **가상 스레드**: JVM 레벨 스위칭 (비용 낮음)
- 빠른 전환으로 대기 시간 최소화

### 3. I/O 대기 최적화
- 이 애플리케이션은 DB, Redis, 외부 API 호출 등 **I/O 바운드 작업**이 많음
- 가상 스레드는 I/O 대기 중 다른 작업 수행
- 플랫폼 스레드는 대기 중 블로킹되어 리소스 낭비

### 4. 스레드 풀 제한 없음
- **플랫폼 스레드**: 200개 고정 풀 (제한적)
  - `Executors.newFixedThreadPool(200)` 사용
  - 200개 초과 시 큐에서 대기
- **가상 스레드**: 필요한 만큼 생성 (무제한)
  - `Thread.ofVirtual().factory()` 사용
  - 수백만 개 생성 가능
- 동시 요청 증가 시 가상 스레드가 더 유리

## 실제 시나리오 분석

### 타임아웃 발생 원인 (일반 스레드)
```
인재상(초기) 생성 API에서 5건 타임아웃 발생
- 요청 시간: 60초 초과
- 원인: 200개 고정 스레드 풀 고갈로 인한 대기
  → 10개 VU가 동시에 여러 API 호출 시 스레드 부족
  → 큐에서 대기하다가 타임아웃
- 가상 스레드에서는 동일 API 정상 처리 (6~7초)
  → 필요한 만큼 스레드 생성하여 대기 없음
```

### 병목 지점
1. **직무설명서 SSE 조회**: 13~15초 (LLM 호출)
2. **인재 조회 SSE**: 12~17초 (매칭 알고리즘)
3. **인재상 초기 생성**: 5~7초 (LLM 호출)

→ 모든 병목 지점에서 가상 스레드가 더 안정적

## 결론

### 🎉 가상 스레드 적용 효과

1. **성능 향상**
   - 평균 응답시간 47.4% 개선
   - P95 응답시간 59.9% 개선
   - 처리량 18% 증가

2. **안정성 향상**
   - 타임아웃 0건 (일반: 5건)
   - 실패율 0% (일반: 4%)
   - 완료율 100% (일반: 50%)

3. **확장성 향상**
   - 더 많은 동시 연결 처리 가능
   - 스레드 풀 제한 없음
   - 메모리 효율적

### 💡 권장사항

1. **프로덕션 적용 권장**
   - 가상 스레드가 모든 지표에서 우수
   - 특히 I/O 바운드 작업에 효과적
   - 안정성과 성능 모두 개선

2. **모니터링 포인트**
   - 가상 스레드 생성 수
   - 메모리 사용량
   - 응답 시간 분포

3. **추가 최적화**
   - 로깅 최소화 (이미 적용)
   - 데이터베이스 커넥션 풀 조정
   - 캐시 전략 개선

## 참고 자료

- [Java Virtual Threads (JEP 444)](https://openjdk.org/jeps/444)
- [Spring Boot Virtual Threads](https://spring.io/blog/2022/10/11/embracing-virtual-threads)
- [k6 Load Testing](https://k6.io/docs/)

---