kimyu0218
  • [spring] 웹 어플리케이션 기능 확장하기 (WebMvcConfigurer)
    2025년 01월 05일 18시 00분 29초에 업로드 된 글입니다.
    작성자: @kimyu0218

    스프링 부트 프레임워크는 프로젝트 설정 과정을 간소화하여 어플리케이션을 빠르게 개발하도록 돕는다. 하지만 프로젝트 목적에 따라 스프링 부트 기본 설정을 변경해야 하는 경우가 있다. 다행히 스프링 웹 MVC 프레임워크는 기존 기능을 확장하거나 교체하는 다양한 방법을 제공한다.
     
    웹 어플리케이션을 설정하는 방법은 크게 세 가지가 있다.

    1. `WebMvcConfigurer` 인터페이스를 사용하여 필요한 기능들을 추가하거나(add) 교체한다(configure)
    2. `@Primary` 어노테이션을 사용하여 기본으로 만들어지는 빈을 재설정한다
    3. 웹 MVC 프레임워크에서 정의한 스프링 빈 이름과 타입으로 빈을 직접 생성한다 (스프링은 이미 이름과 타입이 일치한 스프링 빈이 있다면 새롭게 만들지 않기 때문이다!)

    여기서는 `WebMvcConfigurer`를 구현한 방법만을 다룰 것이다. 지금부터 웹 어플리케이션을 확장하고 대체해보자.
     

    웹 어플리케이션 기본 설정

    웹 어플리케이션 기본 설정을 변경하기 전에 어떻게 기본 설정을 구성하는지 살펴보자. 다음은 프레임워크 별로 웹 MVC을 직접 설정하기 위한 방법이다.

    • 스프링 : 1) `spring-webmvc` 의존성을 추가하고, 2) `@EnableWebMvc`가 붙은 구성 클래스를 정의한다
    • 스프링 부트 : `spring-boot-starter-web` 의존성을 추가한다 (자동 설정 기능에 의해 어노테이션을 명시적으로 선언하지 않아도 동작한다!)

     
    먼저 스프링이 어떻게 웹 어플리케이션을 설정하는지 알아보자.

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @Documented
    @Import(DelegatingWebMvcConfiguration.class)
    public @interface EnableWebMvc {
    }

    위 코드는 `@EnableWebMvc` 어노테이션 코드로, `@Import`를 사용하여 `DelegatingWebMvcConfiguration` 클래스를 불러오고 있다.

    public class DelegatingWebMvcConfiguration extends WebMvcConfigurationSupport {
    
        private final WebMvcConfigurerComposite configurers = new WebMvcConfigurerComposite();
        ...
    }

    `DelegatingWebMvcConfiguration`은 `WebMvcConfigurationSupport` 클래스를 상속받으며, 멤버 변수로 `WebMvcConfigurerComposite`*를 가지고 있다.

    *`List<WebMvcConfigurer>`를 감싸는 합성 클래스
    • `WebMvcConfigurationSupport` : 웹 어플리케이션의 기본 기능을 설정한다
    • `WebMvcConfigurer` : 개발자가 웹 어플리케이션 설정 과정에 개입할 수 있는 방법을 제공한다

    즉, 스프링 프레임워크는 `WebMvcConfigurationSupport`를 통해 기본 설정을 구성하고, 개발자가 작성한 `WebMvcConfigurer` 구현체의 메서드를 호출하며 (콜백 메서드) 기본 설정을 덮어쓴다.
     
    스프링 부트는 `WebMvcAutoConfiguration`을 통해 웹 어플리케이션을 설정한다.

    ...
    @AutoConfiguration(after = { DispatcherServletAutoConfiguration.class, TaskExecutionAutoConfiguration.class, ValidationAutoConfiguration.class })
    @ConditionalOnWebApplication(type = Type.SERVLET)
    @ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
    public class WebMvcAutoConfiguration {
        
        public static class WebMvcAutoConfigurationAdapter implements WebMvcConfigurer, ServletContextAware { ... }
        ...
        public static class EnableWebMvcConfiguration extends DelegatingWebMvcConfiguration implements ResourceLoaderAware { ... }
        ...
    }

    `WebMvcAutoConfiguration` 내부에는 `WebMvcConfigurer`를 구현한 `WebMvcAutoConfigurationAdaptor`와 `DelegatingWebMvcConfiguration`을 상속 받은 `EnableWebMvcConfiguration`이라는 두 개의 주요 클래스가 있다. 설정 방법은 다르지만 내부 원리는 스프링 프레임워크와 같다.
     
    스프링과 스프링 부트 모두 스프링 웹 MVC를 직접 설정하려면 `WebMvcConfigurer` 구현체를 만들어야 한다. 1) 기존 설정을 변경하도록 필요한 메서드를 구현하고, 2) 구현 클래스에 `@Configuration` 어노테이션을 달아 구성 클래스로 만들면 된다.
     

    WebMvcConfigurer를 이용한 웹 어플리케이션 확장

    앞서 말했듯이 스프링 부트는 수많은 기능들을 자동으로 설정한다. 이는 기능을 곧바로 사용할 수 있다는 장점도 있지만 별도로 설정하지 않은 기능들은 이용할 수 없다.
     
    WebMvcConfigurer스프링 MVC 설정을 조작할 수 있도록 콜백 메서드를 제공한다. 콜백 메서드의 접두사는 add, configure, extend가 있다. add와 extend는 기본 설정을 확장하기 위해, configure는 기본 설정을 교체하기 위해 사용한다. 일부 콜백 메서드를 알아보자.
     

    configurePathMatch

    default void configurePathMatch(PathMatchConfigurer configurer) {}

    `configurePathMatch`는 사용자 요청 경로를 조작할 수 있도록 지원한다. 주로 요청 경로 앞에 접두사를 붙이거나 마지막에 붙은 슬래시를 제거하는 용도로 사용된다. 아래는 예시 코드다.

    @Configuration
    public WebMvcConfig implements WebMvcConfigurer { // spring-webmvc의 기본 설정을 확장/대체한다
        
        @Override
        public void configurePathMatch(PathMatchConfigurer configurer) {
            configurer
                .setUseTrailingSlashMatching(true) // 경로 끝의 슬래시가 있는지 없는지 구분하지 않는다 (/api/resource == /api/resource/)
                .addPathPrefix("/v1", HandlerTypePredicate.forAnnotation(RestController.class, Controller.class)) // @RestController와 @Controller가 붙은 클래스 경로 앞에 /v1을 추가한다
                .setPathMatcher(new AntPathMatcher()) // 요청 경로 매칭 전략을 설정한다 (AntPathMatcher : 와일드카드 및 패턴 매칭 지원)
                .setUrlPathHelper(new UrlPathHelper()); // 경로를 해석할 때 사용할 헬퍼를 설정한다
        }
    }
    💡 `setPathMatcher` 메서드는 `@RequestMapping`의 경로값과 사용자 요청 url을 매핑하는 역할을 한다. `setUrIPathHelper`는 `@PathVariable` 값을 처리하는 데 사용된다.

     

    configureContentNegotiation

    default void configureContentNegotiation(ContentNegotiationConfigurer configurer) {}

    `configureContentNegotiation`은 응답 콘텐츠 유형을 결정하는 과정을 사용자 정의할 수 있도록 지원하는 메서드다. 스프링 웹 MVC는 기본적으로 HTTP 헤더나 URL 경로를 기반으로 콘텐츠 유형을 결정한다.

    • 사용자가 `Accept` 헤더를 통해 요청하는 콘텐츠 유형을 명시하면 이를 기반으로 응답을 반환한다
    • URL에 확장자를 포함하면 해당 확장자에 따라 콘텐츠 유형을 결정한다

    이러한 기본 설정을 변경하고 싶다면 `configureContentNegotation`으로 콘텐츠 협상 규칙을 수정하면 된다.

    @Override
    public configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer
            .ignoreAcceptHeader(true) // Accept 헤더를 무시하고 다른 방법으로 콘텐츠 유형을 결정한다
            .defaultContentType(MediaType.APPLICATION_JSON) // 기본적으로 JSON을 반환한다
            .mediaType("json", MediaType.APPLICATION_JSON)
            .mediaType("xml", MediaType.APPLICATION_XML);
    }

     

    addResourceHandlers

    default void addResourceHandlers(ResourceHandlerRegistry registry) {}

    `addResourceHandlers`는 정적 리소스를 제공할 경로를 설정하는 데 사용된다. 스프링 부트는 기본적으로 `src/main/resources`에서 정적 파일을 찾는다. 만약 어플리케이션의 정적 파일 디렉토리 구조가 사용자가 요청하는 경로와 다르다면 이를 활용하여 변경할 수 있다.

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // /css 경로로 요청하는 모든 정적 파일은 코드 베이스의 /static/css 경로에서 찾는다
        registry.addResourceHandler("/css/**")
            .addResourceLocations("classpath:/static/css/");
        // /html 경로로 요청하는 모든 정적 파일은 코드 베이스의 /static/html 경로에서 찾는다 
        registry.addResourceHandler("/html/**")
            .addResourceLocations("classpath:/static/html/");
    }

     

    addCorsMappings

    default void addCorsMappings(CorsRegistry registry) {}

    `addCorsMappings`는 스프링 웹 MVC에서 CORS 정책을 설정한다. CORS는 클라이언트가 다른 출저에서 리소스를 요청할 때 발생하는 보안 정책으로, 브라우저는 기본적으로 SOP(Same Origin Policy)를 따른다. 다른 출처에서의 리소스 요청을 허용하고 싶다면 위 메서드로 규칙을 추가하면 된다.

    @Override
    public voic addCorsMappings(CorsRegistry registry) {
        registry
            .addMapping("/**") // 모든 리소스에 대해 CORS를 적용한다
            .allowedOrigins("https://example.com") // 허용하는 출처는 https://example.com이다
            .allowedMethods("GET", "POST") // 허용하는 메서드는 GET, POST다
            .allowedHeaders("*") // 모든 헤더를 허용한다
            .maxAge(60 * 60); // CORS 정책의 유효 시간은 1시간이다
    }

     

    addFormatters

    default void addFormatters(FormatterRegistry registry) {}

    스프링은 데이터 바인딩*을 확장할 수 있는 방법을 제공하는데, 그 중 하나가 `addFormatter`다. 바인딩을 확장하기 위해서는 1) 데이터를 변경하는 컨버터 클래스를 만들고, 2) 이를 프레임워크에 추가하면 된다. 스프링은 컨버터 구현체를 확장할 수 있도록 `Converter`, `Formatter` 등 여러 인터페이스를 제공한다.

    *바인딩 : 어떤 데이터를 변환하여 특정 대상 객체에 할당하는 것
    ex. `@PathVariable`, `@RequestParam`
    💡 `Converter`와 `Formatter`는 다국어 처리 지원 여부의 차이가 있다. `Formatter`는 `Locale` 객체를 인자로 받아 다국어를 지원한다. 

    다음은 예시코드다.

    @RequiredArgsConstructor
    public class HotelRoomNumber {
        
        private static final String DELIMITER = "-";
        
        private final String hotelCode;
        private final Long roomNumber;
        
        public static HotelRoomNumber parse(String hotelRoomNumberStr) {
            String[] tokens = hotelRoomNumberStr.split(DELIMITER);
            
            if (tokens.length != 2) { /* 예외 처리 */ }
            return new HotelRoomNumber(tokens[0], Long.parseLong(tokens[1]));
        }
        
        @Override
        public String toString() {
            return hotelCode + DELIMITER + roomNumber.toString();
        }
    }
    // 컨버터 구현체 정의 (String을 HotelRoomNumber로 변환한다)
    public class HotelRoomNumberConverter implements Converter<String, HotelRoomNumber> {
        
        @Override
        public HotelRoomNumber convert(String source) {
            return HotelRoomNumber.parse(source);
        }
    }
    
    // 스프링 어플리케이션에 컨버터 추가
    @Override 
    public void addFormatters(FormatterRegistry registry) {
        registry.addCoverter(new HotelRoomNumberConverter());
    }

     

    addArgumentResolvers

    `ArgumentResolver` : 인자(argument)를 변환(resolver)한다
    default void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {}

    `addArgumentResolvers`는 핸들러 메서드에 선언된 파라미터에 데이터를 바인딩하는 역할을 수행한다. 스프링 프레임워크는 핸들러 메서드 파라미터에 정의된 클래스 타입을 확인하고, 적절한 `ArgumentResolver` 구현체를 선택하여 데이터를 바인딩한다. 즉, 타입을 변환할 수 있는 `ArgumentResolver`가 있으면 변환한다.
     
    `ArgumentResolver` 구현체는 `HandlerMethodArgumentResolver` 인터페이스를 구현하여 만든다.

    public interface HandlerMethodArgumentResolver {
        
        // 핸들러 메서드의 파라미터가 해당 Resolver의 변환 대상인지 확인한다
        boolean supportsParameter(MethodParameter parameter);
        
        // 사용자 요청을 특정 객체(Object)로 변환한다
        @Nullable Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
    }

    아래 코드는 `ArgumentResolver`를 활용하여 JWT 인증 시스템에서 사용자 ID를 추출하고 핸들러 메서드로 전달한다.

    @RequiredArgsConstructor
    public class MemberIdResolver implements HandlerMethodArgumentResolver {
    
        private final JwtParser jwtParser;
        private final JwtProvider jwtProvider;
        
        // 핸들러 메서드에 @LoginMemberId가 붙은 파라미터가 있으면 데이터 변환을 수행할 수 있다
        @Override
        public boolean supportsParameter(MethodParameter parameter) {
            return parameter.hasParameterAnnotation(LoginMemberId.class);
        }
        
        // 요청에서 JWT를 추출하고, 사용자의 ID를 추출하고 파싱하여 핸들러 메서드 파라미터로 전달한다
        @Override
        public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
            HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
            
            String token = jwtParser.resolveToken(request);
            
            return Long.parseLong(jwtProvider.getSubject(token));
        }
    }
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(memberIdResolver);
    }

    참고자료

    댓글