스프링부트를 사용해서 웹 애플리케이션을 만들다 보면, Spring MVC 구성을 나의 입맛에 맞게 더 구체적인 설정을 할 필요가 있다.
💋 WebConfig 파일이란?
WebConfig 파일은 웹 구성에 대해 지정하는 내용이 담겨있는 설정 파일이다.
스프링 부트에서는 WebMvcConfigurer
인터페이스를 구현하여 WebConfig
파일을 작성한다.
설정과 관련된 파일을 분리해서 관리하면, 다른 개발자가 이 파일만 들어가서 설정을 확인할 수 있기 때문에 유지보수하기 쉬워진다.
💋 WebMvcConfigurer 인터페이스 뜯어보기
- Spring MVC 구성을 사용자 정의하는 데 사용되는 인터페이스
- Handler Mapping 및 Handler Adapter, Interceptor, View Resolver, Message Converter, Resource Handler, Argument Resolver, Return Value Handler, Cors를 내 입맛에 맞게 구성할 수 있다.
자, 그러면 인터페이스가 어떻게 생겼는지부터 살펴보자!
엄청 긴데, 다 읽어볼 필요는 없이 내가 커스터마이징 할 수 있는 것들이 이렇게 많구나!
정도로 일단 메서드 이름 정도만 훑어보면서 넘어가면 될 것 같다.
public interface WebMvcConfigurer {
/**
* Help with configuring {@link HandlerMapping} path matching options such as
* whether to use parsed {@code PathPatterns} or String pattern matching
* with {@code PathMatcher}, whether to match trailing slashes, and more.
* @since 4.0.3
* @see PathMatchConfigurer
*/
default void configurePathMatch(PathMatchConfigurer configurer) {
}
/**
* Configure content negotiation options.
*/
default void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
}
/**
* Configure asynchronous request handling options.
*/
default void configureAsyncSupport(AsyncSupportConfigurer configurer) {
}
// 끝도 없음!!!!!!!
}
JAVA8부터 인터페이스에 default
메서드를 넣을 수 있게 되면서, 이 인터페이스는 현재의 모양을 갖추게 되었다.
따라서 이 많은 옵션 중에서 내가 커스텀하고 싶은 옵션에 대한 메서드만 @Override
를 통해서 재정의하면 되는 것이다.
자 그러면 오늘 WebConfig
파일을 사용해서 커스터마이징하면서 공부해볼 세 가지는 view controllers
, interceptors
, argument resolvers
이다.
시작!
💋 View Controllers 설정하기
✔ View Controllers란?
스프링의 View Controllers는 URL 매핑을 간단하게 처리할 수 있도록 도와주는 기능이다.
Controller 클래스를 작성하지 않고도 간단한 URL 매핑을 설정할 수 있어서 개발 시간을 단축할 수 있다!
View Controllers를 사용하면 요청 URL과 뷰 이름을 매핑시킬 수 있고, URL에 파라미터를 전달하거나 리다이렉트도 가능하다!
✔ View Controllers 설정 예시 코드
아래 예시는 URL / 에 대한 요청이 들어왔을 때, home이라는 이름의 view를 보여주는 코드이다.
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");
}
}
여기서 조금 낯선 어노테이션과 클래스(인터페이스)들이 등장하는데, 가볍게 알아보고 넘어가자!
여기 아래에 나오는 어노테이션들은 후에 나오는 Interceptors
, ArgumentResolvers
에 대한 예시에서도 등장하니 기억해두면 좋을 것 같다.
✔ @Configuration
- @Configuration 어노테이션이 붙어있는 클래스는 스프링 IoC 컨테이너가 초기화될 때 실행된다.
- 스프링 IoC 컨테이너는 애플리케이션 구동 시 가장 먼저 초기화되며, @Configuration 어노테이션이 붙은 클래스를 찾아서 Bean으로 등록한다.
- @Configuration 어노테이션이 붙은 클래스는 애플리케이션 시작 시점에 실행되므로, 미리 필요한 Bean을 생성하거나 설정할 수 있다.
✔ @EnableWebMvc
- 스프링 MVC를 사용할 때 필요한 설정을 자동으로 활성화해주는 어노테이션
- 예를 들어, @RequestMapping 어노테이션을 사용하여 URL 매핑을 처리하거나, @RequestBody 어노테이션을 사용하여 요청 바디를 처리하는 등의 설정이 가능하다!
✔ ViewControllerRegistry
- URL 경로와 뷰 이름 사이의 매핑을 등록하기 위해 사용되는 인터페이스
- 이전에 작성한 컨트롤러 클래스를 작성하지 않고도 URL 경로와 뷰를 직접 매핑할 수 있게 해준다.
이 인터페이스에 정의되어 있는 추상 메서드들은 아래와 같다.
- addViewController(): URL 경로와 뷰 이름을 등록
- setViewName():뷰 이름을 설정
- setStatusCode(): 응답 상태 코드를 설정
- setDefaultViewName(): 기본 뷰 이름을 설정
예시 코드도 첨부!
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// `/home` 경로에 대한 뷰 이름으로 `home` 설정
registry.addViewController("/home").setViewName("home");
// `/login` 경로에 대한 뷰 이름으로 `login`을 설정하고, 상태 코드로 `HttpStatus.OK`를 설정
registry.addViewController("/login").setViewName("login").setStatusCode(HttpStatus.OK);
// `/` 경로에 대한 뷰 이름으로 `index`를 설정
registry.addViewController("/").setViewName("index");
// 기본 뷰 이름으로 `default`를 설정
registry.setDefaultViewName("default");
}
}
💋 Interceptor 설정하기
✔ Interceptor란?
Interceptor는 클라이언트의 요청(request)과 서버의 응답(response) 사이, 혹은 그 전후에 위치하여 요청과 응답에 대한 처리를 수행하는 기능이다.
일반적으로 인터셉터는 요청/응답의 헤더(header) 정보를 수정하거나, 로그(log)를 남기는 등의 역할을 수행한다.
예를 들어서, 사용자가 로그인을 해야만 사용할 수 있는 기능이 5가지 있다고 생각해보자!
컨트롤러에서 그 5가지 기능을 처리하는 메서드가 5개 있다고 생각했을 때, 5가지의 메서드에서 각각 로그인이 되어 있는지
확인하는 방법도 있겠지만, Interceptor를 정의해서 이 기능들에 대한 요청이 들어왔을 때 요청을 먼저 가로채서 처리하는 로직을 공통적으로 처리할 수 있다면 더 좋을 것이다.
이처럼 Interceptor는 사용자의 인증(authentication)이 필요한 기능에 대한 요청이 들어왔을 때, 인터셉터를 활용하여 공통적으로 인증 과정을 처리할 수 있다.
✔ Interceptor 설정 예시 코드
이번에는 먼저 Interceptor를 만드는 것부터 해보자.
HandlerInterceptor
인터페이스를 구현하는 클래스 생성해서 Interceptor를 생성할 수 있다. preHandle()
, postHandle()
, afterCompletion()
메서드를 오버라이딩하여 인터셉터가 처리할 로직을 구현한다.
@Component
public class CustomInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 요청이 Controller에 도달하기 전에 실행되는 메서드
// true를 반환하면 요청이 계속 진행되고, false를 반환하면 요청을 중단합니다.
System.out.println("CustomInterceptor preHandle");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// Controller가 실행된 후, View가 렌더링되기 전에 실행되는 메서드
System.out.println("CustomInterceptor postHandle");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// View가 렌더링된 후에 실행되는 메서드
System.out.println("CustomInterceptor afterCompletion");
}
}
이제 만든 Interceptor를 내 애플리케이션에 설정해보자.
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Autowired
private CustomInterceptor customInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 인터셉터를 등록하는 메서드
// CustomInterceptor를 등록하고, 모든 URL에 대해 인터셉터를 적용하도록 설정
registry.addInterceptor(customInterceptor).addPathPatterns("/**");
}
}
💋 Interceptor를 통해 기본 200이 아닌 다른 상태코드로 응답하기
preHandle() 메서드에서 false를 반환하면 해당 요청에 대한 처리가 중단되고, 클라이언트에게 200 OK 응답이 반환된다.
따라서, 200 외에 다른 응답을 해야 한다면, HttpServletResponse를 조금 수정해야 한다!
두 가지 경우에 대해서 예시로 설명하겠다!
✔ 403 Forbidden 상태 코드로 응답 보내는 경우
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 사용자 인가에 실패한 경우
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
return false;
}
위 코드는 403 Forbidden 상태 코드와 함께 "Access Denied"라는 메시지를 클라이언트에게 반환한다.
✔ 401 Unauthorized 상태 코드로 응답을 보내는 경우
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 사용자 인증에 실패한 경우
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
💋 Argument Resolver 설정하기
✔ Argument Resolver란?
스프링 프레임워크에서 HTTP 요청 파라미터를 컨트롤러 메서드의 파라미터로 변환해주는 기능이다.
이해가 잘 가지 않는다면, 아래 예시 코드를 보면 오히려 이해가 쉬울 것이다.
Argument Resolver를 사용하면, 개발자는 복잡한 파라미터 변환 로직을 작성하지 않아도 되어서 좋다.
✔ Argument Resolver 설정 예시 코드
예를 들어 생각해보자!
public class CustomArgumentResolver implements HandlerMethodArgumentResolver {
@Override
// 해당 Argument Resolver가 지원할 파라미터 타입을 지정
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(User.class);
}
@Override
// 실제로 파라미터를 변환하는 로직을 구현
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
String name = request.getParameter("name");
String email = request.getParameter("email");
User user = new User(name, email);
return user;
}
}
- supportsParameter()
- 이 메서드가 true를 반환하는 경우에만 해당 인자를 ResolveArgument를 통해 바인딩한다.
- supportsParameter 메서드에서 false를 반환하는 경우에는 ResolveArgument도 실행되지 않는다.
- supportsParameter 메서드는 Controller 메서드의 인자가 Argument Resolver에서 처리 가능한지 판단하는 역할을 한다.
- MethodParameter
- Controller 메서드의 파라미터 정보를 담고 있는 객체
- MethodParameter 객체를 이용하여 해당 파라미터의 타입, 어노테이션 등의 정보를 가져와서 실제 값을 파라미터에 바인딩한다.
다른 예시를 통해서 위의 내용을 이해해보자!
먼저 아래처럼 커스텀 어노테이션을 만들어준다.
(커스텀 어노테이션 만들어주는 코드임)
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticatedMember {
}
나는 현재 Basic Authentication을 통해서 인증을 마친 사용자에 대해서만 CartController에서 정의된 요청을 처리하려고 한다.
@RestController
@RequestMapping("/carts")
public class CartController {
private final CartItemAddService addService;
private final CartItemFindService findService;
private final CartItemDeleteService deleteService;
public CartController(final CartItemAddService addService, final CartItemFindService findService, final CartItemDeleteService deleteService) {
this.addService = addService;
this.findService = findService;
this.deleteService = deleteService;
}
@GetMapping("/all")
public ResponseEntity<List<ProductResponse>> findItems(@AuthenticatedMember Member authenticatedMember) {
final List<ProductResponse> products = findService.findCartItems(authenticatedMember.getId()).stream()
.map(ProductResponse::from)
.collect(Collectors.toList());
return ResponseEntity.ok().body(products);
}
@PostMapping("/{productId}")
public ResponseEntity<CartItemResponse> addItem(@PathVariable("productId") long productId, @AuthenticatedMember Member authenticatedMember) {
final CartItem addedItem = addService.addItem(authenticatedMember.getId(), productId);
final CartItemResponse cartItem = CartItemResponse.from(addedItem);
return ResponseEntity.ok().body(cartItem);
}
@DeleteMapping("/{productId}")
public ResponseEntity<Void> deleteItem(@PathVariable("productId") long productId, @AuthenticatedMember Member authenticatedMember) {
deleteService.deleteItem(authenticatedMember.getId(), productId);
return ResponseEntity.noContent().build();
}
}
그리고 ArgumentResolver를 작성해보자!
@Component
public class AuthenticatedMemberArgumentResolver implements HandlerMethodArgumentResolver {
private static final String BASIC_TYPE_PREFIX = "Basic";
private static final String DELIMITER = ":";
private final MemberDao memberDao;
public AuthenticatedMemberArgumentResolver(final MemberDao memberDao) {
this.memberDao = memberDao;
}
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedMember.class)
&& Member.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory) throws Exception {
final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
final String authorization = request.getHeader("Authorization");
final boolean isBasicAuthentication = authorization != null && authorization.toLowerCase().startsWith(BASIC_TYPE_PREFIX.toLowerCase());
if (!isBasicAuthentication) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!");
}
final Member authenticatedMember = findMemberFromAuthentication(authorization);
if (authenticatedMember == null) {
throw new UnAuthenticatedMemberException("로그인 해 주세요!");
}
return authenticatedMember;
}
private Member findMemberFromAuthentication(final String authorization) {
// ...
}
}
여기서 먼저 supportsParameter() 메서드에 집중해보자!
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedMember.class)
&& Member.class.isAssignableFrom(parameter.getParameterType());
}
이 메서드 내의 hasParameterAnnotation과 isAssignableFrom은 controller의 addItem() 메서드의 파라미터 타입과 어노테이션을 체크하는 역할을 한다.
- parameter.hasParameterAnnotation(AuthenticatedMember.class)
- controller에서 파라미터에 @AuthenticatedMember 어노테이션이 붙어있는지 확인한다
- Member.class.isAssignableFrom(parameter.getParameterType())
- controller에서 파라미터의 타입(예시의 경우 Member)이 Member 클래스로 지정 가능한지 확인한다
아래의 컨트롤러 코드를 보면, 이 두 가지를 모두 충족해서 supportsParameter의 반환값이 true가 될 것이다!
@PostMapping("/{productId}")
public ResponseEntity<CartItemResponse> addItem(@PathVariable("productId") long productId, @AuthenticatedMember Member authenticatedMember) {
final CartItem addedItem = addService.addItem(authenticatedMember.getId(), productId);
final CartItemResponse cartItem = CartItemResponse.from(addedItem);
return ResponseEntity.ok().body(cartItem);
}
반환값이 true가 되면, resolveArgument()가 실행된다.
이제 이 Argument Resolver를 등록해줘야 한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
// Argument Resolver를 등록하는 메서드
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new CustomArgumentResolver());
}
}
만약 여러 개의 Argument Resolver를 등록하고 싶다면, resolvers.add()
메서드를 여러 번 호출하면 된다.
💋 ArgumentResolver VS @RequestBody
우리는 앞서서 @RequestBody를 통해서 값을 받아오는 방식도 공부했었다.
@RequestBody 역시 ArgumentResolver의 일종으로 볼 수 있다.
@RequestBody는 스프링에서 제공하는 Argument Resolver 중 하나로, HTTP 요청의 body에 담긴 데이터를 객체로 매핑해주는 역할을 한다.
@RequestBody 어노테이션을 사용하여 파라미터를 받아오면 HTTP 요청의 Body 부분을 직접 파싱하여 해당 객체로 변환한다.
주로 RESTful API를 구현할 때 사용되며, 주로 JSON 형식으로 요청을 보낼 때 사용한다.
ArgumentResolver는 스프링 프레임워크에서 제공하는 기본적인 방식뿐만 아니라, 개발자가 직접 커스터마이징하여 사용할 수 있다.
오늘 학습한 방법들을 사용해 내 코드에 적용해보는 내용에 대한 포스팅도 있으니 참고!
💋 정리
WebConfig
파일을 통해서 내 입맛에 맞게 스프링 프레임워크를 설정할 수 있다.- 이 설정 파일은
WebMvcConfigurer
인터페이스를 구현해서 만들 수 있는데, 설정할 수 있는 내용으로는 Handler Mapping 및 Handler Adapter, Interceptor, View Resolver, Message Converter, Resource Handler, Argument Resolver, Return Value Handler, Cors 등이 있다. - 오늘 설정해본 View Controllers, Interceptors, Argument Resolvers를 설정하는 방법을 기억해두자!