본문 바로가기

카테고리 없음

[Springboot] spring Web tomcat 서버로 구현된 프로젝트 spring WebFlux Netty 서버 사용으로 바꾸기 - 2

 

1. 의존성 수정

 

1) 기존

spring-boot-starter-web과 spring-boot-starter-webflux가 모두 의존성에 포함되면 Tomcat이 기본 웹 서버로 설정됩니다.

이는 spring-boot-starter-web이 spring-boot-starter-webflux보다 우선하기 때문입니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

 

 

2) 수정

  • 의존성 변경: build.gradle에서 spring-boot-starter-web을 제거하고 spring-boot-starter-webflux만 남겨둡니다.
  • 서버 실행 확인: 프로젝트를 실행한 후 로그를 확인하면 Netty 서버가 실행되었는지 확인할 수 있습니다. 다음과 같은 로그가 보일 것입니다.
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

 

 

 

2. 수정 후 발생한 에러

 

1) AOP 로깅 구현 부분에서 에러 발생

기존코드

LoggingAspect

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Component
@Slf4j
public class LoggingAspect {

    /*
        * @Around: 메서드 실행 전과 후에 실행되는 Advice
        * 지정된 pointcut 에서 메서드 실행 전과 후에 호출 됨
        * execution() 패턴을 사용하여 컨트롤러 패키지 내의 모든 메서드에 Advice 적용
        * ProceedingJoinPoint: Around Advice에서만 사용되는 파라미터로, 메서드 실행을 직접 제어할 수 있는 기능을 제공
     */
    @Around("execution(* com.project.dailyworkreportbackend.controller..*.*(..))")
    public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {

        String requestId = MDC.get("request_id");

        // HttpServletRequest 객체를 가져오기
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        // 호출된 메서드명 확인
        String methodName = proceedingJoinPoint.getSignature().getName();
        String className = proceedingJoinPoint.getTarget().getClass().getSimpleName();

        // 메서드 실행 전 로깅
        log.info("[IP: {}] Request Start - Method: {}.{}", requestId, className, methodName);

        // 메서드 실행
        Object result = proceedingJoinPoint.proceed();

        // 메서드 실행 후 로깅
        log.info("[IP: {}] Request End - Method: {}.{}", requestId, className, methodName);

        return result;
    }

    /*
        * @AfterThrowing: 예외가 발생했을 때 실행되는 Advice
        * 지정된 pointcut 에서 메서드 실행 중 예외 발생 시 호출 됨
        * execution() 패턴을 사용하여 컨트롤러 패키지 내의 모든 메서드에 Advice 적용
        * throwing: 예외를 받아들일 파라미터 이름
     */
    @AfterThrowing(pointcut = "execution(* com.project.dailyworkreportbackend.controller..*.*(..))", throwing = "exception")
    public void handleException(Exception exception) {
        String requestId = MDC.get("request_id");

        // '{}' 플레이스 홀더는 로깅 메시지에 변수나 값을 삽입할 때 사용되는 특별한 기호로 해당 위치에 인자를 동적으로 삽입함
        log.error("[IP: {}] Error Msg: {}", requestId, exception.getMessage());
    }
}

 

import jakarta.servlet.*;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.io.IOException;

/*
    @Component: 스프링 컨텍스트에서 클래스를 빈으로 등록, Filter를 구현한 이 클래스를 스프링 빈으로 관리
    @Order: 빈의 우선순위 설정
    Ordered.HIGHEST_PRECEDENCE: 가장 높은 우선 순위
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MDCRequestLoggingFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        // 필요에 따라 초기화 코드 작성
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        try {
            // 클라이언트의 IP 주소를 추출하여 request_id로 설정
            String clientIP = request.getRemoteAddr();
            MDC.put("request_id", clientIP);

            // 다음 필터로 제어 전달, 실제 요청이 로직이 실행되는 지점
            chain.doFilter(request, response);
        } finally {
            // 실제 요청이 완료되면 MDC 저장소를 초기화
            MDC.clear();
        }
    }

    @Override
    public void destroy() {
        // 필요에 따라 소멸 코드 작성
    }
}

 

관련 게시글

https://k-sky.tistory.com/827

 

[Spring boot] MDC(Mapped Diagnostic Context) 클라이언트 요청IP 넣기

기존 구현한 코드 import jakarta.servlet.*; import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.io.IOException; import java.util

k-sky.tistory.com

 

 

WebFlux 환경에서 AOP를 이용하여 요청 전후에 로그를 남기는 방법은 WebFlux는 비동기, 논블로킹 방식이므로 AOP에서의 로그 작업도 이를 고려하여 작성해야 합니다. 특정 요청 전후에 로그를 남기기 위해서는 Aspect를 사용하여 요청에 대해 적절한 시점에 로그를 기록할 수 있습니다.

 

ApiCallController의 메서드 호출 전후에 IP와 요청 데이터를 로그로 남기는 Aspect 예시

@Aspect
@Component
@Slf4j
public class LoggingAspect {

    @Before("execution(* com.project.dailyworkreportbackend.controller.ApiCallController.*(..)) && args(headers, dataRequestDto, ..)")
    public void logBefore(JoinPoint joinPoint, Map<String, String> headers, DataRequestDto dataRequestDto) {
        String clientIp = headers.getOrDefault("Host", "UNKNOWN"); // 실제 클라이언트 IP가 헤더에 존재할 경우
        log.info("IP: {} - 요청 시작: {}", clientIp, dataRequestDto);
    }

    @AfterReturning(pointcut = "execution(* com.project.dailyworkreportbackend.controller.ApiCallController.*(..))", returning = "result")
    public void logAfterReturning(JoinPoint joinPoint, Object result) {
        log.info("요청 성공 - 응답 데이터: {}", result);
    }

    @AfterThrowing(pointcut = "execution(* com.project.dailyworkreportbackend.controller.ApiCallController.*(..))", throwing = "error")
    public void logAfterThrowing(JoinPoint joinPoint, Throwable error) {
        log.error("요청 실패 - 에러: {}", error.getMessage());
    }
}

 

  • @Before: 컨트롤러 메서드 호출 전에 실행되며, 요청 헤더에서 클라이언트의 IP 정보를 가져와 로그를 기록합니다.
  • @AfterReturning: 메서드가 정상적으로 완료된 후 반환된 데이터를 로그로 남깁니다.
  • @AfterThrowing: 예외가 발생했을 때 로그로 해당 에러 메시지를 기록합니다.

 

 

@Around 어드바이스를 사용하지 않은 이유

@Around 어드바이스를 사용하지 않은 이유는 주로 WebFlux의 비동기, 논블로킹 특성과 연관이 있습니다. @Around 어드바이스는 Spring AOP에서 가장 강력한 어드바이스로, 메서드 호출 전후에 실행되며 메서드의 결과를 조작할 수 있는 기능을 제공합니다. 하지만 WebFlux에서는 Mono와 Flux와 같은 리액티브 타입이 사용되기 때문에, 비동기적으로 동작하는 리액티브 스트림을 적절히 처리하기 위해서는 몇 가지 주의가 필요합니다.

WebFlux와 @Around 사용 시 문제점

  1. 리액티브 타입의 반환: WebFlux에서는 메서드가 Mono 또는 Flux와 같은 리액티브 타입을 반환합니다. 그러나 @Around 어드바이스에서 리액티브 타입을 처리할 때, ProceedingJoinPoint.proceed() 메서드는 즉시 결과를 반환하지 않고, 비동기적으로 처리되기 때문에 처리 완료 시점을 다루기가 어렵습니다.
  2. @Around는 기본적으로 동기적인 방식으로 메서드 호출 전후의 처리를 다루도록 설계되었기 때문에 WebFlux의 비동기 논블로킹 흐름과 맞지 않을 수 있습니다. 즉, @Around에서 리턴된 Mono나 Flux를 적절히 핸들링하지 않으면 비동기적으로 반환되는 데이터를 누락하거나, 제대로 로깅할 수 없게 됩니다.
  3. 부가적인 복잡성: @Around 어드바이스를 사용하는 경우, 리액티브 스트림을 직접 조작할 필요가 있습니다. 예를 들어 Mono나 Flux를 반환하는 메서드를 감쌀 때, proceed()로 받은 객체가 Mono인지 Flux인지 확인한 후, 이를 다시 비동기적으로 처리하는 방식으로 코드를 작성해야 합니다. 이는 코드를 복잡하게 만들고, 단순한 로깅 목적에는 부적합할 수 있습니다.

 

왜 @Around를 피하는지 요약:

  • @Around는 동기적인 흐름에서 매우 유용하지만, WebFlux의 비동기 논블로킹 처리와의 궁합이 맞지 않는 경우가 많습니다.
  • 리액티브 타입을 직접 처리하는 데 복잡함이 따르며, 단순한 로깅을 위해서는 @Before, @AfterReturning, @AfterThrowing 등의 어드바이스가 더 간단하고 직관적입니다.
  • Mono나 Flux를 적절히 처리하지 않으면 로깅이나 데이터 처리에서 문제가 생길 수 있습니다.

결론

@Around 어드바이스를 사용하는 것이 가능하지만, WebFlux의 리액티브 타입을 처리할 때 추가적인 복잡성이 발생하기 때문에, 단순한 로깅 목적이라면 @Before, @AfterReturning, @AfterThrowing을 사용하는 것이 더 적합하고, 코드가 간결합니다.

 

 

WebFlux에서 AOP의 한계

WebFlux는 비동기 논블로킹 방식이기 때문에 전통적인 방식으로는 요청 전후의 비동기적인 결과나 반환 값을 처리하기 어렵습니다. 하지만 위와 같은 AOP 적용으로 대부분의 경우에서 로깅이 가능합니다. 특히, 논블로킹 응답이 있는 경우에는 @AfterReturning과 같은 방식으로 결과값을 바로 로그로 남기지 않고, 반환된 Mono/Flux에서 값을 처리하는 방법도 있습니다.

 

Mono의 값을 이용한 추가 로그 처리

로그를 남기기 위해서는 Mono나 Flux에서 .doOnNext(), .doOnError() 등을 활용할 수 있습니다.

@PostMapping("/getData")
public Mono<ResponseDto> getData(@RequestHeader Map<String, String> headers,
                                 @Valid @RequestBody DataRequestDto dataRequestDto) throws ParseException {
    log.info("[/getData] 요청 시작: {}", dataRequestDto);
    return apiCallService.getData(headers, dataRequestDto)
        .doOnNext(response -> log.info("응답 성공: {}", response))
        .doOnError(error -> log.error("응답 실패: {}", error.getMessage()));
}

 

  • webFlux 환경에서도 AOP를 사용한 로깅은 가능합니다.
  • 비동기 논블로킹 방식의 특성상 결과 값의 처리는 Mono나 Flux 내에서 적절히 처리해야 합니다.
  • 위 예시처럼 AOP와 WebFlux의 기능을 적절히 조합하여 로그를 남기면 됩니다.

 

 

AOP를 사용하지 않고 처리하는 방법 

WebFlux에서 AOP를 사용하는 것은 기본적으로 동기적인 AOP 방식과는 다르게 비동기 흐름을 처리하는 것이 필요합니다.

하지만 AOP는 원래 비동기 흐름에 완전히 최적화되어 있지 않기 때문에, 비동기적인 메서드의 흐름에서 기대한 대로 작동하지 않을 수 있습니다. 이런 문제를 해결하려면, WebFlux에 더 적합한 방식으로 비동기 로깅을 처리하는 방법을 찾아야 합니다.

대안: WebFilter를 사용한 전역 로깅 처리

AOP 대신, WebFilter를 사용하여 비동기 요청의 흐름에 따라 로깅을 관리하는 방법이 더 적합할 수 있습니다. WebFilter는 요청이 들어올 때와 나갈 때를 정확하게 제어할 수 있고, WebFlux의 비동기 처리 흐름과도 자연스럽게 어울립니다.

 

import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

import java.net.InetSocketAddress;
import java.util.Optional;

/*
    @Component: 스프링 컨텍스트에서 클래스를 빈으로 등록, WebFilter를 구현한 이 클래스를 스프링 빈으로 관리
    @Order: 빈의 우선순위 설정
    Ordered.HIGHEST_PRECEDENCE: 가장 높은 우선 순위
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
public class MDCRequestLoggingFilter implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String clientIP = Optional.ofNullable(exchange.getRequest().getRemoteAddress())
                .map(InetSocketAddress::getHostString)
                .orElse("Unknown");

        String methodName = exchange.getRequest().getMethod().name();
        String path = exchange.getRequest().getPath().toString();

        // 로그 기록
        log.info("[IP: {}] Request Start - Method: {}, Path: {}", clientIP, methodName, path);

        // 요청 처리 후 응답 시 로그 기록
        return chain.filter(exchange)
                .doOnSuccess(aVoid -> log.info("[IP: {}] Request End - Method: {}, Path: {}", clientIP, methodName, path))
                .doOnError(throwable -> log.error("[IP: {}] Request Error - Method: {}, Path: {}, Error: {}", clientIP, methodName, path, throwable.getMessage()))
                .doFinally(signalType -> {
                    MDC.clear(); // 요청이 완료되면 MDC 초기화
                });
    }
}



이 코드의 장점:

  1. WebFlux에 최적화: WebFilter는 WebFlux의 비동기 흐름을 자연스럽게 처리합니다. 비동기 로깅을 처리하기에 적합합니다.
  2. 전역 로깅: 모든 요청에 대해 전후 로그를 남길 수 있으며, doOnSuccess와 doOnError를 통해 성공 및 오류 상황 모두를 처리할 수 있습니다.
  3. 비동기 흐름에 맞춘 로깅: 비동기 요청 흐름을 추적하면서 요청의 시작과 끝을 정확히 로깅할 수 있습니다.

개선 포인트:

  • 이 방식은 AOP와 달리 전역적으로 모든 요청을 가로채기 때문에 메서드별로 세부적인 로깅을 하고 싶다면, 추가적인 로직을 통해 특정 메서드 또는 경로에 대한 필터를 적용할 수 있습니다.

 

 

 

 

2) WebClient 관련 에러

요청에 헤더에 필요한 값이 없어서 비즈니스 로직에서 에러 발생

 

비즈니스 로직에서 헤더에서 token, hashKey를 가져와야 하는 부분

private WebClient webClient(Map<String, String> headers) {
    return webClientBuilder.baseUrl(baseUrl)
        .defaultHeaders(header -> {
            header.set("token", headers.get("token"));
            header.set("hashKey", headers.get("hashkey"));
        })
        .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) // 10MB로 증가
        .build();
}

 

postman을 사용해서 요청을 보낼 때 헤더에 hashKey 라고 요청을 보내고 있음

기존 tomcat 서버를 사용했을 때, hashkey라고 소문자 k로 들어오는 것을 확인

headers: {
token=vwf1wZLlm5rnv43znMsOtE3WfYYad2, 
hashkey=79548068197525183397345794638165675620375735, 
schseq=3425451, 
content-type=application/json, 
user-agent=PostmanRuntime/7.41.2, 
accept=*/*, 
cache-control=no-cache, 
postman-token=2df5a43c-057f-4495-8aad-06f8b898dfc7, 
host=127.0.0.1:8081, 
accept-encoding=gzip, 
deflate, 
br, 
connection=keep-alive, 
content-length=508
}

 

netty 서버 사용했을 때, hashKey라고 대문자 K로 들어오는 것을 확인

headers: {
token=vwf1wZLlm5rnv43znMsOtE3WfYYad2, 
hashKey=79548068197525183397345794638165675620375735, 
schSeq=3425451, 
Content-Type=application/json, 
User-Agent=PostmanRuntime/7.41.2, 
Accept=*/*, 
Cache-Control=no-cache, 
Postman-Token=d216e277-371e-4ac9-babf-8741757deecd, 
Host=127.0.0.1:8081, 
Accept-Encoding=gzip, 
deflate, 
br, 
Connection=keep-alive, 
Content-Length=508
}

 

Tomcat과 Netty 간의 헤더 처리 방식 차이는 서버의 HTTP 표준 처리에 따른 것입니다.

1. Tomcat (Servlet 기반):

  • Tomcat은 HTTP/1.1 명세를 따릅니다. HTTP/1.1 명세에서는 헤더 이름이 대소문자를 구분하지 않도록 정의되어 있습니다. 그래서 클라이언트가 hashKey를 대문자나 소문자로 보내도, Tomcat에서는 대소문자를 구분하지 않고 동일하게 처리합니다. 이로 인해 headers.get("hashkey")와 같이 소문자로 조회할 수 있습니다.

2. Netty (비동기, 비블로킹 기반):

  • Netty는 고성능 비동기 서버로 HTTP/2 명세를 많이 따릅니다. HTTP/2에서는 헤더 이름이 소문자로 표준화되어 있습니다. 이 때문에 Netty 기반의 Spring WebFlux에서는 대소문자를 구분하여 헤더를 처리하며, 클라이언트가 보낸 hashKey를 소문자로 처리합니다. 그래서 headers.get("HashKey")가 아닌 headers.get("hashkey")로 조회해야 합니다​.

정리:

서버 간의 차이는 주로 HTTP 명세에 따라 발생하며, Tomcat은 HTTP/1.1의 대소문자 구분을 하지 않는 방식, Netty는 HTTP/2의 소문자 표준화 방식을 따르기 때문에 발생하는 차이입니다. Netty의 특성에 맞게 코드를 수정해야 하며, Spring WebFlux(Netty) 환경에서는 헤더 이름을 소문자로 통일하여 사용해야 할 것입니다.

 

 

하지만 나한테 발생한 상황 찾아봤을 때 설명과 반대인 현상

 

이 현상은 클라이언트가 보낸 요청의 HTTP 명세나 라이브러리의 차이에 의해 발생할 가능성이 큽니다.

이유:

  1. 클라이언트의 차이: Tomcat과 Netty는 기본적으로 HTTP 헤더 처리 방식이 다르긴 하지만, 클라이언트의 헤더 전송 방식이 큰 영향을 미칠 수 있습니다. Postman이나 다른 HTTP 클라이언트에서 헤더를 전송할 때, 대소문자를 구분하는 방식이 다를 수 있습니다.
    • Tomcat 서버의 경우, 클라이언트가 보낸 대소문자 구분을 무시하고 헤더를 소문자로 변환하는 내부 로직이 있을 수 있습니다.
    • 반면 Netty는 헤더의 대소문자를 그대로 유지하는 방식으로 처리할 수 있습니다.
  2. 서버 내부 구현 차이:
    • Tomcat은 HTTP/1.1 명세에 따라 대소문자를 구분하지 않고, 내부적으로 소문자로 변환해서 처리하는 경향이 있습니다.
    • Netty는 HTTP/2 명세를 따르며, 기본적으로 소문자 표준화를 강제하는 대신, 클라이언트가 보낸 대로 대소문자를 처리할 수 있습니다. 그러나 Netty 설정에 따라 헤더 처리 방식이 달라질 수 있습니다.

결론:

서버 간의 차이는 주로 클라이언트가 어떻게 헤더를 보내는지서버의 내부 처리 로직에 따라 다르게 보일 수 있습니다. Netty와 Tomcat 모두 HTTP 명세에 맞게 동작하지만, 각 서버의 기본 동작이 다르고 클라이언트의 전송 방식에 따라 결과가 달라질 수 있습니다.

이 차이는 정확한 설정이나 클라이언트의 헤더 전송 방식에 대한 추가 분석이 필요할 수 있습니다. Postman이나 다른 클라이언트의 설정을 확인해 보시는 것도 방법입니다.Postman을 사용해 hashKey라는 헤더를 대문자로 보냈는데, Tomcat에서는 소문자로 처리되고 Netty에서는 대문자로 처리되는 차이점은 다음과 같은 이유에서 발생할 수 있습니다:

1. Tomcat과 Netty의 기본 헤더 처리 방식 차이

  • Tomcat: Tomcat은 기본적으로 HTTP 헤더를 대소문자를 구분하지 않는 방식으로 처리하며, 내부적으로 소문자로 변환하여 관리할 수 있습니다. 이는 HTTP/1.1 명세에 기반한 처리 방식으로, Tomcat에서 헤더 이름을 일관되게 소문자로 변환하는 경향이 있습니다.
  • Netty: Netty는 HTTP/2 명세를 따르며, 기본적으로 헤더 이름을 클라이언트가 보낸 대로 처리할 수 있습니다. 즉, Netty는 대소문자를 그대로 유지하며, HTTP/2에서는 헤더가 소문자로 표준화되는 경우가 있지만, HTTP/1.1의 경우에는 대소문자를 구분하지 않을 수 있습니다.

2. HTTP 표준과 헤더 처리 방식

  • HTTP/1.1: HTTP/1.1 표준에서는 헤더 이름이 대소문자를 구분하지 않도록 명시되어 있습니다. 따라서, 서버는 클라이언트가 보낸 헤더의 대소문자를 구분하지 않고 처리해야 합니다.
  • HTTP/2: HTTP/2는 헤더 이름을 소문자로 통일하는 것을 권장합니다. 하지만 Netty는 HTTP/1.1도 지원하므로, 클라이언트가 보낸 대로 헤더를 대소문자 구분 없이 처리할 수 있습니다.

3. 클라이언트와 서버 간의 상호작용

  • Postman에서 hashKey라는 헤더를 대문자로 보냈을 때, Tomcat은 이를 소문자로 변환해서 처리하는 반면, Netty는 원본 그대로 대문자를 유지할 수 있습니다. 이는 서버가 HTTP 명세를 어떻게 구현했는지에 따른 차이입니다.

해결 방안:

  • Tomcat과 Netty 간 일관성을 유지하려면, 코드에서 헤더를 처리할 때 대소문자를 구분하지 않도록 주의해야 합니다. 즉, headers.get("hashkey")나 headers.get("HashKey") 모두 작동하도록 코드를 수정하는 것이 좋습니다.

이 차이는 클라이언트(Postman)가 대문자로 보낸 경우에도 서버에서 일관되게 처리하도록 설계되어 있기 때문에, Netty와 Tomcat의 기본적인 차이에서 비롯된 것으로 보입니다.