앞서 난 Spring Cloud Gateway를 활용해 비동기 인증 시스템을 설계했고, Gateway는 WebFlux 기반으로 안정적인 요청 처리를 수행하고 있었다. 하지만 인증 서버는 여전히 Spring MVC 기반으로 구성되어 있었고, 이는 시스템 전반의 병목을 유발하는 주요 원인이었다.
특히 인증 요청이 증가함에 따라 Gateway → Auth 서버 간 통신에서 발생하는 블로킹 처리는 전체 서비스의 응답 속도 저하로 이어졌고, 이는 사용자 경험에도 영향을 미치기 시작했다. 이러한 문제를 해결하고자 인증 서버를 완전한 WebFlux 기반 비동기 구조로 리팩토링하게 되었다.
이번 글에서는 MVC → WebFlux로 전환하며 마주한 문제들과 그 해결 방식, 그리고 구조적으로 어떤 변화가 있었는지를 상세히 정리해본다.
기존에는 @Transactional
, JpaRepository
, WebMvcConfigurer
등 MVC 전용 기술 스택을 사용하고 있었다. 하지만 WebFlux 환경에서는 다음과 같은 제약이 존재했다:
@Transactional
은 동기 방식에서만 작동 → Reactor의 TransactionalOperator
등으로 대체 필요JpaRepository
는 동기 처리 방식 → ReactiveCrudRepository
로 대체WebMvcConfigurer
는 작동하지 않음 → WebFluxConfigurer
로 전환 필요이러한 구조적 제약들을 해결하며 모든 계층을 비동기로 재구성했다. 처음엔 마치 전체 코드를 다시 짜야 하는 듯한 막막함이 있었지만, 핵심적인 로직을 흐름 단위로 쪼개고 Mono
, Flux
로 구성하니 오히려 더 명확하게 구조가 정리되었다.
Kakao 로그인은 기존에 FeignClient
기반으로 구현되어 있었으나, 이는 blocking 방식이기 때문에 WebFlux 구조에 맞지 않았다. 따라서 WebClient
를 활용한 비동기 방식으로 전면 교체하였다.
public Mono<KakaoUserResponse> getUserInfo(String accessToken) {
return webClient.get()
.uri("/v2/user/me")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.bodyToMono(KakaoUserResponse.class);
}
@Controller
, @Service
, @Repository
전 계층에서 모든 리턴 타입을 Mono<T>
로 통일ResponseEntity<T>
를 Mono<ResponseEntity<T>>
로 변환public Mono<ResponseEntity<UserInfo>> getUserInfo(@CurrentUser UserInfo userInfo) {
return Mono.just(ResponseEntity.ok(userInfo));
}
이 과정에서 가장 신경 썼던 건 예외 처리였다. try-catch가 아닌 .onErrorResume()
이나 .switchIfEmpty()
를 활용해 흐름 안에서 에러를 유연하게 처리하는 방식은 리액티브 스타일에 익숙해지는 데 큰 도움이 되었다.
Spring MVC에서는 OncePerRequestFilter
를 활용했지만, WebFlux에서는 WebFilter
로 대체해야 한다.
JwtAuthenticationFilter
를 WebFilter
로 전면 재작성ReactiveSecurityContextHolder
활용)ReactiveAuthenticationManager
, ServerAuthenticationConverter
도 함께 구현public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String token = extractToken(exchange);
...
return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
}
리액티브 환경에서는 인증 컨텍스트를 명시적으로 contextWrite를 통해 넘겨줘야 한다는 점이 새로웠고, 초반엔 생소했지만 체계적으로 접근하니 이해할 수 있었다.
WebFlux에서는 기존 MVC의 HandlerMethodArgumentResolver
가 동작하지 않는다. 이를 해결하기 위해 WebFlux 전용 인터페이스로 전환했다:
HandlerMethodArgumentResolver
→ org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver
로 변경resolveArgument()
메서드를 Mono 기반으로 리팩토링@Override
public Mono<Object> resolveArgument(...) {
String userJson = exchange.getRequest().getHeaders().getFirst("X-Passport-User");
return Mono.just(objectMapper.readValue(userJson, UserInfo.class));
}
처음에는 WebFlux에서 이런 커스텀 어노테이션을 그대로 쓸 수 있을지 걱정이 많았지만, 다행히도 Spring에서의 확장성과 모듈화가 잘 되어 있어 이 방식으로 무리 없이 이식이 가능했다.
초기에는 API 응답이 예상한 형태가 아닌, 아래 이미지처럼 단순 JSON 포맷으로 내려오는 문제가 발생했다.
이는 내가 커스텀한 응답 구조가 아닌, WebFlux 기본 JSON 렌더링 방식이 활성화된 결과였다. 디버깅 과정에서 알게 된 핵심 원인은 application.yml
내 WebFlux 관련 설정이 누락되어 있었던 점이었다.
spring:
main:
web-application-type: reactive
이 설정을 추가한 후, 기대한 형태의 커스텀 응답 포맷이 정상적으로 동작하였고, 기존 필터 및 예외 처리 로직도 예상대로 반영되었다. 이 경험을 통해 WebFlux에서는 YML 설정 하나가 전체 응답 흐름에 큰 영향을 미친다는 사실을 다시 한번 체감할 수 있었다.
TokenRepository
는 기존 CrudRepository
로 유지 (단, 호출부는 Mono 흐름 내에서 wrapping 처리)springdoc-openapi-starter-webmvc-ui
→ springdoc-openapi-starter-webflux-ui
로 변경SecurityFilterChain
설정도 WebFlux 방식 (ServerHttpSecurity
)으로 전환AccessDeniedHandler
, AuthenticationEntryPoint
도 Reactive 방식으로 재작성Test tool: K6, 환경: AWS EC2(t2.micro), RDB, 테스트 API:
/api/v1/user/me
항목 | Spring MVC | WebFlux | 개선 여부 |
---|---|---|---|
총 요청 수 | 14,108 | 19,742 | +39.8% ✅ |
평균 응답 시간 | 2.32초 | 1.60초 | -31.4% ✅ |
p95 응답 시간 | 11.6초 | 5.4초 | -52.9% ✅ |
최대 응답 시간 | 평균 38.6초 | 평균 25.5초 | ✅ |
iteration 평균 시간 | 2.94초 | 2.11초 | ✅ |
경고 발생 | 다수 | 13회 이하 | ✅ |
이번 리팩토링은 단순히 MVC 코드를 Mono로 바꾸는 작업이 아니었다. 구조적인 전환, 기술 스택의 교체, API 응답 구조의 통일 등 전반적인 비동기 아키텍처로의 재설계였다.
리액티브 프로그래밍은 분명 러닝 커브가 존재했다. 오류 메시지 한 줄, 디버깅 한 단계에 몇 시간을 소비하기도 했다. 하지만 그 과정을 거치며 얻은 것은 단순한 코드 최적화가 아니라 서비스 구조 전체에 대한 깊은 고민이었다.
특히 MSA 구조에서 인증 서버는 병목을 유발하기 쉬운 중요한 지점이기 때문에, 이 구조적 전환은 시스템 전반의 확장성과 안정성을 확보하는 데 큰 의미가 있었다.