Kotlin·WebFlux·코루틴에서 Java·MVC·가상 스레드로 전환하기
이 글의 목차
이번 전환의 범위는 Kotlin·WebFlux·코루틴에서 Java·Spring MVC·가상 스레드로 옮기는 것입니다. Kotlin에서 Java로 옮긴 이유는 빠른 일정에 대응하면서 기존 인원이 Kotlin을 익혀야 하는 러닝커브를 줄이기 위해서였습니다. 언어 선택에서는 납기와 팀의 학습 부담을 고려했고, 서버 실행 모델에서는 MVC와 가상 스레드를 선택했습니다.
언어 전환의 배경과 가상 스레드의 성능 효과는 별도로 설명해야 합니다. 아래 코드는 전환 형태를 설명하는 예시이며, 실제 서비스의 코드나 측정된 성과를 재현한 것은 아닙니다.
이 글은 Java 21 이상과 Spring Boot 3.2 이상을 대상으로 한 단계적 전환 가이드입니다. 처리량이나 지연이 개선된다는 보장은 다루지 않습니다. 실제 결과는 요청의 대기·CPU 비율, DB 연결 수, 하위 시스템 용량과 부하 시험으로 판단해야 합니다.
언어 선택과 실행 모델을 나눠 보기
WebFlux와 MVC는 웹 런타임 선택입니다. WebFlux는 Reactive Streams와 비동기 논블로킹 I/O를 중심으로 하고, MVC는 Servlet 요청 처리 모델을 사용합니다. 코루틴은 Kotlin의 비동기 프로그래밍 모델이므로 MVC를 택해도 suspend 함수를 계속 사용할 수 있습니다. Spring은 MVC와 WebFlux 양쪽의 애노테이션 컨트롤러에서 Kotlin 코루틴을 지원합니다. 코루틴 지원에는 kotlinx-coroutines-core와 kotlinx-coroutines-reactor가 필요합니다. Spring Framework의 코루틴 지원 문서
가상 스레드는 Java의 경량 스레드입니다. 블로킹 호출을 사용하는 thread-per-request 코드가 대기 중 플랫폼 스레드를 오래 점유하는 부담을 줄이는 데 도움이 될 수 있습니다. 이를 켜도 JDBC가 논블로킹으로 바뀌거나 DB 커넥션 풀이 늘지는 않습니다. Spring Boot 3.2 이상은 JDK 21 이상에서 spring.threads.virtual.enabled=true로 가상 스레드 기반 실행을 활성화할 수 있습니다. Boot 문서는 더 나은 경험을 위해 Java 24 이상을 권장하고, pinning·기존 스레드 풀 설정·daemon thread 동작을 확인하도록 안내합니다. Spring Boot 가상 스레드 설정과 주의점
이번 전환에서는 언어·웹 런타임·동시성 모델이 함께 바뀝니다. 검증할 때는 각 변경의 책임을 나눕니다. 저장소 전환은 아래 표처럼 별도 판단할 항목이며, 이 사례에서 R2DBC를 사용했다거나 JPA로 옮겼다는 의미는 아닙니다.
| 변경 | 바뀌는 것 | 별도 검증할 점 |
|---|---|---|
| Kotlin → Java | 구현 언어와 비동기 API 표현 | null 처리, 데이터 객체, 예외·취소 의미와 기존 API 계약 |
| WebFlux → MVC | 서버 요청 처리 모델 | 필터, 보안, 예외 변환, SSE·WebSocket 등 |
| R2DBC → JDBC/JPA (필요한 경우) | DB 접근 모델 | 트랜잭션 경계, 쿼리, 커넥션 상한과 타임아웃 |
| 코루틴 기반 처리 → 동기 호출·가상 스레드 | 작업 실행·대기 모델 | 라이브러리 호환성, ThreadLocal, pinning과 운영 지표 |
어느 경로를 옮길까?
일반적인 요청·응답에서 JPA 또는 JDBC를 사용하고, 처리 중 대부분을 DB나 외부 HTTP 응답 대기에 쓴다면 MVC와 가상 스레드는 검토할 만한 선택입니다. 반대로 높은 동시성 스트림, 서버 발행 이벤트(SSE), WebSocket, Reactive Streams의 수요 제어를 핵심 계약으로 쓰는 경로는 WebFlux를 유지하거나 전용 경로로 분리하는 편이 자연스러울 수 있습니다. Flow를 MVC 반환 타입으로 사용할 수 있다는 사실만으로 두 스택의 스트리밍·취소·압력 제어 동작이 모든 환경에서 같다고 가정하면 안 됩니다.
흐름도는 일반 요청 경로의 전환 순서를 보여줍니다. 스트리밍 요구가 남은 경로는 전환을 보류하고 별도로 유지·분리합니다. 배포 확대는 테스트와 관측 결과를 확인한 뒤 진행하며, 기준선을 벗어나면 이전 경로로 되돌릴 수 있어야 합니다.
Kotlin 코루틴 컨트롤러를 Java MVC로 바꾸기
다음은 단일 주문 조회 API의 전환 형태를 보여주는 발췌입니다. 전환 전 Kotlin 컨트롤러는 suspend 함수로 서비스 결과를 기다립니다.
@RestController
class OrderController(private val orders: OrderService) {
@GetMapping("/orders/{id}")
suspend fun get(@PathVariable id: Long): OrderDto = orders.get(id)
}
전환 후에는 Java MVC 컨트롤러가 동기식 서비스 메서드를 호출해 같은 응답 DTO를 반환합니다. orders.get(id)도 Java에서 호출할 수 있는 동기식 계약으로 바꿨다고 가정합니다. Kotlin의 suspend 메서드를 Java에서 그대로 호출하는 예시는 아닙니다.
@RestController
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable("id") Long id) {
return orders.get(id);
}
}
Spring 애노테이션은 org.springframework.web.bind.annotation 패키지를 사용하며, OrderService와 OrderDto는 애플리케이션의 서비스·응답 타입입니다. 이 발췌만으로 실행되는 완전한 애플리케이션은 아닙니다. 가상 스레드는 뒤의 Boot 설정으로 활성화하고 실제 HTTP 요청에서 실행 스레드를 확인합니다.
언어를 옮길 때는 Kotlin의 nullable 타입을 Java에서 어떻게 표현할지, data class의 값 비교와 직렬화 계약을 어떻게 보존할지 확인합니다. 코루틴의 async 병렬 호출을 순차 Java 호출로 바꾸면 외부 요청 순서와 응답 시간이 달라질 수 있으므로 병렬성·타임아웃·취소 의미를 별도로 검증합니다. runBlocking으로 기존 코루틴 호출을 감싸는 것만으로 Java 동기식 서비스 전환을 마쳤다고 보지 않습니다.
Spring MVC 자체는 Kotlin suspend와 Flow도 지원합니다. 따라서 코루틴 제거가 MVC의 필수 조건은 아닙니다. 이번 사례에서는 빠른 일정과 기존 인원의 Kotlin 학습 부담을 고려해 Java를 목표 언어로 선택했기 때문에 컨트롤러와 서비스 계약도 그 방향으로 옮깁니다.
저장소 전환은 별도 변경으로 검증한다
DB 접근 모델도 바꿔야 한다면, 다음은 Kotlin의 CoroutineCrudRepository·R2DBC에서 Java의 Spring Data JPA로 옮기는 별도 예시입니다. 목록을 전부 메모리에 모으지 않도록 결과 크기를 제한하고, 기존 API 계약의 정렬·페이지 크기·권한 필터도 보존해야 합니다.
interface OrderRepository : CoroutineCrudRepository<Order, Long> {
fun findByCustomerId(customerId: Long): Flow<Order>
}
동기식 JPA 저장소에서는 페이지 단위로 조회할 수 있습니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByCustomerId(Long customerId, Pageable pageable);
}
이 코드는 저장소 형태를 보여주는 발췌입니다. 실제 프로젝트에서는 엔티티 로딩 전략, 트랜잭션 범위, N+1 쿼리, 페이지 정렬 안정성과 기존 Flow의 취소·부분 소비 의미를 함께 점검해야 합니다. 한 번에 모두 바꾸기보다 한 유한 응답 API를 선택하고, 계약 테스트와 쿼리 관측을 먼저 붙이세요.
가상 스레드를 켜고 확인하기
Spring Boot 3.2 이상과 JDK 21 이상에서는 다음 설정을 적용할 수 있습니다.
spring:
threads:
virtual:
enabled: true
설정을 적용한 뒤 요청을 처리하는 스레드가 실제 가상 스레드인지 간단한 계측으로 확인할 수 있습니다. 예를 들어 임시 진단용 MVC 인터셉터나 컨트롤러에서 Thread.currentThread().isVirtual()을 기록합니다. 이 확인은 해당 요청 경로의 실행 스레드를 확인할 뿐, 전체 애플리케이션의 모든 작업·코루틴 dispatcher가 가상 스레드라는 뜻은 아닙니다.
Java 컨트롤러 경로를 임시 진단할 때는 다음 표현으로 현재 요청 스레드를 기록할 수 있습니다.
public class RequestThreadProbe {
public static boolean isVirtual() {
return Thread.currentThread().isVirtual();
}
}
Boot 문서는 가상 스레드 사용 시 스레드 풀 관련 설정이 기존과 다르게 작동할 수 있고, 일부 동기화·네이티브 호출 구간의 pinning이 처리량에 영향을 줄 수 있다고 안내합니다. 가상 스레드 개수를 늘리는 방식으로 DB 커넥션 풀이나 외부 API 동시 호출 한도를 풀지 마세요. DB 풀은 DB가 처리할 수 있는 연결 수에 맞게 제한하고, 연결 획득 대기·쿼리 timeout·외부 호출 timeout과 전체 요청 deadline을 함께 설정해야 합니다.
단계적 전환과 롤백 기준
- 기준선을 기록합니다. 대표 엔드포인트의 요청률, p50/p95/p99 지연, 오류율, CPU, 힙, DB 풀 대기와 외부 호출 지연을 같은 부하 조건에서 수집합니다.
- 리액티브 전용 경로를 표시합니다. SSE, WebSocket, 무제한 스트림, backpressure 또는 Reactive Streams 연산에 의존하는 경로를 목록화하고 전환 대상에서 분리합니다.
- 한 개의 유한 API를 옮깁니다. Kotlin 코드를 Java로 옮기면서 서버 런타임과 필요한 저장소 변경을 나눠 확인하고, 요청·응답·오류·취소·인증 계약을 테스트합니다.
- 가상 스레드 실행을 확인합니다. 배포 설정뿐 아니라 런타임 스레드와 JDBC 호출 경로를 확인합니다. 실행되지 않는 백그라운드 작업은 별도 executor 설정을 점검합니다.
- 같은 입력으로 비교합니다. 동일한 데이터 크기와 부하에서 지연 분포, 오류, CPU, DB 대기, 연결 사용량을 비교합니다. 가상 스레드는 CPU 집약 작업의 CPU 용량을 늘려 주지 않으므로 CPU 포화도 함께 봅니다.
- 작은 트래픽부터 확대합니다. 업무 오류, 지연, DB 풀 대기 또는 자원 사용이 사전에 정한 기준을 넘으면 확대를 멈추고 원인을 확인하거나 기존 경로로 롤백합니다. 임계값은 서비스 SLO와 기준선에 맞춰 팀이 정해야 합니다.
실제 처리량·지연 개선 수치는 환경에서 측정하지 않았다면 글이나 설계 검토에 넣지 마세요. 먼저 바꾼 경로와 같은 입력으로 측정하고, 결과가 개선되지 않으면 MVC 전환 자체를 목표로 삼지 않는 것이 맞습니다.
결정 기준
JDBC/JPA 기반의 일반 요청·응답이 중심이고 동시 블로킹 대기가 병목 후보라면 MVC와 가상 스레드를 작은 경로에서 시험할 수 있습니다. 이 사례의 Java 선택은 빠른 일정과 기존 인원의 Kotlin 러닝커브를 고려한 결정입니다. 언어 전환의 효과를 가상 스레드의 처리량 개선과 동일시하지 않습니다. 스트리밍·Reactive Streams 계약이 제품 요구사항이라면 WebFlux를 남기고, 스레드 모델만 바꾸려는 이유로 그 계약을 약화시키지 마세요. 어떤 선택이 유리한지는 동일 부하에서의 지연, 오류, DB 대기와 운영 복잡도를 비교해 결정합니다.
댓글