김영한의 스프링 완전 정복 로드맵
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
섹션8~섹션11 정리입니다.
서블릿은 다음 2가지 방식으로 예외 처리를 지원
자바의 메인 메서드를 직접 실행하는 경우 main 이라는 이름의 쓰레드가 실행
실행 도중에 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드는 종료된다
웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행
애플리케이션에서 예외가 발생했는데, 어디선가 try ~ catch로 예외를 잡아서 처리하면 아무런 문제 없지만, 애플리케이션에서 예외를 잡지 못하면 서블릿 밖으로 까지 예외가 전달된다
(톰캣 같은 WAS 까지 예외 전달)
WAS(여기까지 전파) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러(예외발생)
오류가 발생했을 때 HttpServletResponse가 제공하는 sendError 라는 메서드 사용가능
이것을 호출한다고 당장 예외가 발생하는 것은 아니지만, 서블릿 컨테이너에게 오류가 발생했다는 점을 전달 할수 있고, HTTP 상태 코드와 오류 메시지도 추가할 수 있다
response.sendError(HTTP 상태 코드)
response.sendError(HTTP 상태 코드, 오류 메시지)
response.sendError() 를 호출하면 response 내부에는 오류가 발생했다는 상태를 저장
서블릿 컨테이너는 고객에게 응답 전에 response
에 sendError()
가 호출되었는지 확인
<web-app>
<error-page>
<error-code>404</error-code>
<location>/error-page/404.html</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/error-page/500.html</location>
</error-page>
<error-page>
<exception-type>java.lang.RuntimeException</exception-type>
<location>/error-page/500.html</location>
</error-page>
</web-app>
@Component
public class WebServerCustomizer implements
WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
}
}
오류 페이지는 예외를 다룰 때 해당 예외와 그 자식 타입의 오류를 함께 처리한다
오류가 발생했을 때 처리할 수 있는 컨트롤러가 필요하다
Slf4j
@Controller
public class ErrorPageController {
@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
return "error-page/404";
}
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
return "error-page/500";
}
}
WAS(여기까지 전파) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러(예외발생)
WAS(sendError 호출 기록 확인) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러 (response.sendError())
1. WAS(여기까지 전파) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 ➞ 필터 ➞ 서블릿 ➞ 인터셉터 ➞ 컨트롤러(/error-page/500) ➞ View
정리
1. 예외가 발생해서 WAS까지 전파된다.
2. WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 필터,
서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다
WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 request
의 attribute
에 추가해서 넘겨준다
필요하면 오류 페이지에서 이렇게 전달된 오류 정보를 사용할 수 있다
@Slf4j
@Controller
public class ErrorPageController {
//RequestDispatcher 상수로 정의되어 있음
public static final String ERROR_EXCEPTION = "javax.servlet.error.exception"; // 예외
public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type"; // 예외 타입
public static final String ERROR_MESSAGE = "javax.servlet.error.message"; // 오류 메시지
public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri"; // 클라이언트 요청 URI
public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name"; // 오류가 발생한 서블릿 이름
public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code" // HTTP 상태 코드
@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
printErrorInfo(request);
return "error-page/404";
}
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
printErrorInfo(request);
return "error-page/500";
}
private void printErrorInfo(HttpServletRequest request) {
log.info("ERROR_EXCEPTION: ex=", request.getAttribute(ERROR_EXCEPTION));
log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE)); // ex의 경우 NestedServletException 스프링이 한번 감싸서 반환
log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
log.info("dispatchType={}", request.getDispatcherType());
}
}
예외 발생과 오류 페이지 요청 흐름
1. WAS(여기까지 전파) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 ➞ 필터 ➞ 서블릿 ➞ 인터셉터 ➞ 컨트롤러(/error-page/500) ➞ View
오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 한번 호출이 발생하는데 이때 필터, 서블릿, 인터셉터도 모두 다시 호출된다
오류 페이지를 호출한다고 해서 해당 필터나 인터셉트가 한번 더 호출되는 것은 매우 비효율적
결국 클라이언트로 부터 발생한 정상 요청인지, 아니면 오류 페이지를 출력하기 위한 내부 요청인지 구분할 수 있어야 한다 서블릿은 이런 문제를 해결하기 위해 DispatcherType
이라는 추가 정보를 제공한다
필터는 이런 경우를 위해서 dispatcherTypes 라는 옵션을 제공,이전 강의의 마지막에 다음 로그를 추가했다
log.info("dispatchType={}", request.getDispatcherType())
그리고 출력해보면 오류 페이지에서 dispatchType=ERROR
로 나오는 것을 확인할 수 있다
고객이 처음 요청하면 dispatcherType=REQUEST
이다
이렇듯 서블릿 스펙은 실제 고객이 요청한 것인지, 서버가 내부에서 오류 페이지를 요청하는 것인지 DispatcherType
으로 구분할 수 있는 방법을 제공한다
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException
log.info("log filter init");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST [{}][{}][{}]", uuid,
request.getDispatcherType(), requestURI);
chain.doFilter(request, response);
} catch (Exception e) {
throw e;
} finally {
log.info("RESPONSE [{}][{}][{}]", uuid,
request.getDispatcherType(), requestURI);
}
}
@Override
public void destroy() {
log.info("log filter destroy");
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST,
DispatcherType.ERROR); // 두 가지를 모두 넣으면 클라이언트 요청, 오류 페이지 요청에서도 필터가 호출
return filterRegistrationBean;
}
}
인터셉터 중복 호출 제거
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse
response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
log.info("REQUEST [{}][{}][{}][{}]", uuid, request.getDispatcherType(), requestURI, handler);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse
response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("postHandle [{}]", modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse
response, Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String logId = (String)request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}][{}]", logId, request.getDispatcherType(), requestURI);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
앞서 필터의 경우에는 필터를 등록할 때 어떤 DispatcherType
인 경우에 필터를 적용할 지 선택할 수 있었다
그런데 인터셉터는 서블릿이 제공하는 기능이 아니라 스프링이 제공하는 기능이기 때문에DispatcherType
과 무관하게 항상 호출된다
대신에 인터셉터는 다음과 같이 요청 경로에 따라서 추가하거나 제외하기 쉽게 되어 있기 때문에, 이러한 설정을 사용해서 오류 페이지 경로를 excludePathPatterns
를 사용해서 빼주면 된다
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns(
"/css/**", "/*.ico"
, "/error", "/error-page/**" //오류 페이지 경로
);
}
//@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
return filterRegistrationBean;
}
}
인터셉터와 중복으로 처리되지 않기 위해 앞의logFilter()
의 @Bean
에 주석을 달아준다 여기에서 /error-page/**
를 제거하면 error-page/500
같은 내부 호출의 경우에도 인터셉터가 호출된다
/hello
정상 요청
WAS(/hello, dispatchType=REQUEST) ➞ 필터 ➞ 서블릿 ➞ 인터셉터 ➞ 컨트롤러 ➞ View
/error-ex
오류 요청
dispatchType=REQUEST
)excludePathPatterns("/error-page/**")
)1. WAS(/error-ex, dispatchType=REQUEST) ➞ 필터 ➞ 서블릿 ➞ 인터셉터 ➞ 컨트롤러
2. WAS(여기까지 전파) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러(예외발생)
3. WAS 오류 페이지 확인
4. WAS(/error-page/500, dispatchType=ERROR) ➞ 필터(x) ➞ 서블릿 ➞ 인터셉터(x) ➞
컨트롤러(/error-page/500) ➞ View
지금까지 예외 처리 페이지를 만들기 위해서 다음과 같은 복잡한 과정을 거침
WebServerCustomizer
를 만들고ErrorPage
를 추가하고ErrorPageController
를 만듬ErrorPage
를 자동으로 등록, 이때 /error
라는 경로로 기본 오류 페이지를 설정한다new ErrorPage("/error")
, 상태코드와 예외를 설정하지 않으면 기본 오류 페이지로 사용된다.response.sendError(...)
가 호출되면 모든 오류는 /error
를 호출BasicErrorController
라는 스프링 컨트롤러를 자동으로 등록ErrorPage
에서 등록한 /error
를 매핑해서 처리하는 컨트롤러스프링 부트가 제공하는 기본 오류 메커니즘을 사용하도록 WebServerCustomizer 에
@Component
주석 처리
이제 오류가 발생했을 때 오류 페이지로 /error
를 기본 요청
스프링 부트가 자동 등록한 BasicErrorController
는 이 경로를 기본으로 받는다
BasicErrorController
는 기본적인 로직이 모두 개발되어 있다
개발자는 오류 페이지 화면만 BasicErrorController
가 제공하는 룰과 우선순위에 따라서 등록하면 된다
정적 HTML이면 정적 리소스, 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶으면 뷰 템플릿 경로에 오류 페이지 파일을 만들어서 넣어두기만 하면 된다
뷰 템플릿
resources/templates/error/500.html
resources/templates/error/5xx.html
정적 리소스(static , public)
resources/static/error/400.html
resources/static/error/404.html
resources/static/error/4xx.html
적용 대상이 없을 때 뷰 이름(error)
resources/templates/error.html
BasicErrorController
컨트롤러는 다음 정보를 model
에 담아서 뷰에 전달
뷰 템플릿은 이 값을 활용해서 출력할 수 있다
* timestamp: Fri Feb 05 00:00:00 KST 2021
* status: 400
* error: Bad Request
* exception: org.springframework.validation.BindException
* trace: 예외 trace
* message: Validation failed for object='data'. Error count: 1
* errors: Errors(BindingResult)
* path: 클라이언트 요청 경로 (`/hello`)
오류 관련 내부 정보들을 고객에게 노출하는 것은 좋지 않다
고객이 해당 정보를 읽어도 혼란만 더해지고, 보안상 문제가 될 수도 있다
BasicErrorController
오류 컨트롤러에서 다음 오류 정보를 model
에 포함할지 여부 선택 가능
application.properties
application.properties
server.error.include-exception=true
server.error.include-message=on_param
server.error.include-stacktrace=on_param
server.error.include-binding-errors=on_param
기본 값이 never 인 부분은 다음 3가지 옵션을 사용가능
never
(사용하지 않음), always
(항상 사용), on_param
(파라미터가 있을 때 사용)
📌실무에서는 이것들을 노출하면 안된다! 사용자에게는 이쁜 오류 화면과 고객이 이해할 수 있는 간단한 오류 메시지를 보여주고 오류는 서버에 로그로 남겨서 로그로 확인해야 한다
API의 경우에는 생각할 내용이 더 많다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다
@Component
public class WebServerCustomizer implements
WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error- page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error\-page/500");
factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
}
}
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
단순히 회원을 조회하는 기능을 하나 만들었다
예외 테스트를 위해 URL에 전달된 id
의 값이 ex
이면 예외가 발생하도록 코드생성
HTTP Header에 Accept
가 application/json
인 것을 꼭 확인
API를 요청했는데, 정상의 경우 API로 JSON 형식으로 데이터가 정상 반환,
오류가 발생시 오류 페이지 HTML이 반환 한다
하지만 정상 요청이든, 오류 요청이든 JSON이 반환 되어야 한다
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {
log.info("API errorPage 500");
Map<String, Object> result = new HashMap<>();
Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
result.put("status", request.getAttribute(ERROR_STATUS_CODE));
result.put("message", ex.getMessage());
Integer statusCode = (Integer)
request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
return new ResponseEntity(result, HttpStatus.valueOf(statusCod
}
produces = MediaType.APPLICATION_JSON_VALUE
의 뜻은 클라이언트가 요청하는 HTTP Header의 Accept
의 값이application/json
일 때 해당 메서드가 호출된다는 것이다
결국 클라어인트가 받고 싶은 미디어타입이 json이면 이 컨트롤러의 메서드가 호출된다
응답 데이터를 위해서 Map
을 만들고 status
, message
키에 값을 할당했다
Jackson 라이브러리는 Map
을 JSON 구조로 변환할 수 있다
ResponseEntity
를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다
API 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
/error 동일한 경로를 처리하는 errorHtml()
, error()
두 메서드를 확인할 수 있다
errorHtml()
: produces = MediaType.TEXT_HTML_VALUE
: 클라이언트 요청의 Accept 해더 값이 text/html
인 경우에는errorHtml()
을 호출해서 view를 제공한다error()
: 그외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환한다.앞서 학습했듯이 스프링 부트의 기본 설정은 오류 발생시 /error
를 오류 페이지로 요청한다
BasicErrorController
는 이 경로를 기본으로 받는다. (server.error.path
로 수정 가능, 기본 경로 /error
)
BasicErrorController
를 확장하면 JSON
메시지도 변경할 수 있다
스프링 부트가 제공하는 BasicErrorController
는 HTML
페이지를 제공하는 경우에는 매우 편리하다
4xx
, 5xx
등등 모두 잘 처리해준다
그런데 API 오류 처리는 다른 차원의 이야기이다
API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다
예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있다
결과적으로 매우 세밀하고 복잡하다 따라서 이 방법은 HTML 화면을 처리할 때 사용하고, API 오류 처리는 뒤에서 설명할 @ExceptionHandler 를 사용하자
예를 들어서 IllegalArgumentException
을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태코드를 400으로 처리하고 싶다 어떻게 해야할까?
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
return new MemberDto(id, "hello " + id);
}
스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다
컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면HandlerExceptionResolver
를 사용하면 된다 줄여서 ExceptionResolver
라 한다
public interface HandlerExceptionResolver {
ModelAndView resolveException( HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
}
handler
: 핸들러(컨트롤러) 정보Exception ex
: 핸들러(컨트롤러)에서 발생한 발생한 예외@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
log.info("IllegalArgumentException resolver to 400");
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView();
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
ExceptionResolver
가 ModelAndView
를 반환하는 이유는 마치 try, catch를 하듯이, Exception
을 처리해서 정상 흐름 처럼 변경하는 것이 목적, 이름 그대로 Exception
을 Resolver
(해결)하는 것이 목적이다여기서는 IllegalArgumentException
이 발생하면 response.sendError(400)
를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView
를 반환한다
HandlerExceptionResolver
의 반환 값에 따른 DispatcherServlet
의 동작 방식은 다음과 같다
new ModelAndView()
처럼 빈 ModelAndView
를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴ModelAndView
에 View
, Model
등의 정보를 지정해서 반환하면 뷰를 렌더링 한다null
을 반환하면, 다음 ExceptionResolver
를 찾아서 실행ExceptionResolver
가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다response.sendError(xxx)
호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임/error
가 호출됨ModelAndView
에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공response.getWriter().println("hello");
처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능, 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다/**
* 기본 설정을 유지하면서 추가
*/
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
configureHandlerExceptionResolvers(..)
를 사용하면 스프링이 기본으로 등록하는
ExceptionResolver
가 제거되므로 주의, extendHandlerExceptionResolvers
를 사용하자
예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error 를
호출하는 과정은 너무 복잡하다
ExceptionResolver
를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다
사용자 정의 예외를 하나 추가하자
public class UserException extends RuntimeException {
public UserException() {
super();
}
public UserException(String message) {
super(message);
}
public UserException(String message, Throwable cause) {
super(message, cause);
}
public UserException(Throwable cause) {
super(cause);
}
protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
UserException
이 발생이 예외를 처리하는 것이 UserHandlerExceptionResolver
@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
if (ex instanceof UserException) {
log.info("UserException resolver to 400");
String acceptHeader = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
'
if ("application/json".equals(acceptHeader)) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
String result = objectMapper.writeValueAsString(errorResult);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(result);
return new ModelAndView();
} else {
//TEXT/HTML
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
HTTP 요청 해더의 ACCEPT
값이 `application/json
이면 JSON으로 오류를 내려주고, 그 외 경우에는 error/500에 있는 HTML 오류 페이지를 보여준다
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
resolvers.add(new UserHandlerExceptionResolver());
}
ExceptionResolver
를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver
에서 예외를 처리해버린다
예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이
나기 때문에 결과적으로 WAS 입장에서는 정상 처리가 된다
이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다
서블릿 컨테이너까지 예외가 올라가면 복잡하고 지저분하게 추가 프로세스가 실행된다
반면에ExceptionResolver
를 사용하면 예외처리가 상당히 깔끔해진다
하지만 직접ExceptionResolver
를 구현하려고 하니 상당히 복잡하다
지금부터 스프링이 제공하는 ExceptionResolver
들을 알아보자
스프링 부트가 기본으로 제공하는 ExceptionResolver
는 다음과 같다
HandlerExceptionResolverComposite 에 다음 순서로 등록
ExceptionHandlerExceptionResolver
: @ExceptionHandler
을 처리(예외처리 대부분 처리)ResponseStatusExceptionResolver
: HTTP 상태 코드를 지정해준다DefaultHandlerExceptionResolver
:스프링 내부 기본 예외를 처리 (우선 순위가 가장 낮음)ResponseStatusExceptionResolver는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 함
@ResponseStatus
가 달려있는 예외ResponseStatusException
예외@ResponseStatus
애노테이션을 적용하면 HTTP 상태 코드를 변경
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST
(400)으로 변경하고, 메시지도 담는다
ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason)
를 호출하는 것을 확인 할 수 있음
sendError(400)
를 호출했기 때문에 WAS에서 다시 오류 페이지( /error
)를 내부 요청한다
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
@ResponseStatus
는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다 (애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다X)
추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵기 때문에 이때는
ResponseStatusException
예외를 사용하면 된다
ApiExceptionController - 추가
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
DefaultHandlerExceptionResolver
는 스프링 내부에서 발생하는 스프링 예외를 해결
대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이
발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다
그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다 HTTP에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다
DefaultHandlerExceptionResolver
는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다]
스프링 내부 오류를 어떻게 처리할지 수 많은 내용이 정의되어 있다
웹 브라우저에 HTML 화면을 제공할 때는 오류가 발생하면 BasicErrorController
를 사용하는게 편하다
이때는 단순히 5xx, 4xx 관련된 오류 화면을 보여주면 된다. BasicErrorController 는 이런 메커니즘을 모두 구현해두었다
그런데 API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 모두 다르다. 예외 상황에 단순히 오류 화면을 보여주는 것이 아니라, 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있다
그리고 같은 예외라고 해도 어떤 컨트롤러에서 발생했는가에 따라서 다른 예외 응답을 내려주어야 할 수 있다
한마디로 매우 세밀한 제어가 필요하다.
앞서 이야기했지만, 예를 들어서 상품 API와 주문 API는 오류가 발생했을 때 응답의 모양이 완전히 다를 수 있다
결국 지금까지 살펴본 BasicErrorController
를 사용하거나 HandlerExceptionResolver
를 직접 구현하는 방식으로 API 예외를 다루기는 쉽지 않다
HandlerExceptionResolver
를 떠올려 보면 ModelAndView
를 반환해야 했다HttpServletResponse
에 직접 응답 데이터를 넣어주었다 이것은 매우 불편하다 스프링 컨트롤러에 비유하면 마치 과거 서블릿을 사용하던 시절로 돌아간 것 같음RuntimeException
예외와 상품을 관리하는 컨트롤러에서 발생하는 동일한RuntimeException
예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야할까?스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler
라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver
이다
스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는
ExceptionResolver
중에 우선순위도 가장 높다
실무에서 API 예외 처리는 대부분 이 기능을 사용한다
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
@ExceptionHandler
애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다
해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다
참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다
IllegalArgumentException
또는 그 하위 자식 클래스를 모두 처리 예제
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
스프링의 우선순위는 항상 자세한 것이 우선권을 가짐
예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다
다음과 같이 다양한 예외를 한번에 처리할 수 있다
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
log.info("exception e", e);
}
@ExceptionHandler 에 예외를 생략 가능 생략하면 메서드 파라미터의 예외가 지정된다
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}
@ExceptionHandler 에는 마치 스프링의 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정 가능
📌자세한 파라미터와 응답은 다음 공식 메뉴얼을 참고
https://docs.spring.io/springframework/docs/current/reference/html/web.html#mvc-ann-exceptionhandler-args
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler
에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용 여기서는 UserException 을 사용ResponseEntity
를 사용해서 HTTP 메시지 바디에 직접 응답한다ResponseEntity
를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경가능 하다@ResponseStatus
는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다x@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
throw new RuntimeException("잘못된 사용자")
이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException
이 던져진다RuntimeException
은 Exception
의 자식 클래스이다 따라서 이 메서드가 호출된다@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
로 HTTP 상태 코드를 500으로 응답한다음과 같이 ModelAndView
를 사용해서 오류 화면(HTML)을 응답하는데 사용 가능
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
log.info("exception e", e);
return new ModelAndView("error");
}
@ExceptionHandler
를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다
@ControllerAdvice
또는 @RestControllerAdvice
를 사용하면 둘을 분리할 수 있다
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
ApiExceptionV2Controller 코드에 있는 @ExceptionHandler 모두 제거
@ControllerAdvice
는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler
, @InitBinder
기능을 부여해주는 역할을 한다@ControllerAdvice
에 대상을 지정하지 않으면 모든 컨트롤러에 적용(글로벌 적용)@RestControllerAdvice
는 @ControllerAdvice
와 같고, @ResponseBody
가 추가되어 있다@Controller
, @RestController
의 차이와 같다// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class,
AbstractController.class})
public class ExampleAdvice3 {}
스프링 공식 문서 예제에서 보는 것 처럼 특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정 가능 하다
패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다
그리고 특정 클래스를 지정할 수도 있다 대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용된다
@ExceptionHandler
와 @ControllerAdvice
를 조합하면 예외를 깔끔하게 해결할 수 있다
HTTP 요청 파라미터는 모두 문자로 처리된다. 따라서 요청 파라미터를 자바에서 다른 타입으로 변환해서사용하고 싶으면 숫자 타입으로 변환하는 과정을 거쳐야 한다
@RestController
public class HelloController {
@GetMapping("/hello-v1")
public String helloV1(HttpServletRequest request) {
String data = request.getParameter("data"); //문자 타입 조회
Integer intValue = Integer.valueOf(data); //숫자 타입으로 변경
System.out.println("intValue = " + intValue);
return "ok";
}
}
HelloController - 추가
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
@RequestParam
을 사용하면 이 문자 10을 Integer 타입의 숫자 10으로 편리하게 받을 수 있다@ModelAttribute
, @PathVariable
에서도 확인할 수 있다@RequestParam
, @ModelAttribute
, @PathVariable
@Value
등으로 YML 정보 읽기스프링이 중간에 타입 변환기를 사용해서 타입을 String Integer 로 변환해주었기 때문에 개발자는 편리하게 해당 타입을 바로 받을 수 있다.
앞에서는 문자를 숫자로 변경하는 예시를 들었지만, 반대로 숫자를 문자로 변경하는 것도 가능하고, Boolean 타입을 숫자로 변경하는 것도 가능하다
타입 컨버터를 사용하려면 org.springframework.core.convert.converter.Converter
인터페이스를 구현하면 된다
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
log.info("convert source={}", source);
return Integer.valueOf(source);
}
}
@Slf4j
public class IntegerToStringConverter implements Converter<Integer, String> {
@Override
public String convert(Integer source) {
log.info("convert source={}", source);
return String.valueOf(source);
}
}
타입 컨버터 이해를 돕기 위해 조금 다른 컨버터를 준비해보았다
127.0.0.1:8080과 같은 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨버터를 만들어보자
@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private int port;
public IpPort(String ip, int port) {
this.ip = ip;
this.port = port;
}
}
@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
@Override
public IpPort convert(String source) {
log.info("convert source={}", source);
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
127.0.0.1:8080
같은 문자를 입력하면 IpPort
객체를 만들어 반환한다@Slf4j
public class IpPortToStringConverter implements Converter<IpPort, String> {
@Override
public String convert(IpPort source) {
log.info("convert source={}", source);
return source.getIp() + ":" + source.getPort();
}
}
IpPort
객체를 입력하면 127.0.0.1:8080
같은 문자를 반환한다📌참고
스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다Converter 기본 타입 컨버터
ConverterFactory 전체 클래스 계층 구조가 필요할 때
GenericConverter 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
ConditionalGenericConverter 특정 조건이 참인 경우에만 실행자세한 내용은 공식 문서 참고
https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#core-convert
📌참고
스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다
IDE에서 Converter , ConverterFactory , GenericConverter 의 구현체를 찾아보면 수 많은 컨버터를 확인할 수 있다
타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것은 매우 불편
그래서 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 이것이 바로 컨버전 서비스(ConversionService)이다.
package org.springframework.core.convert;
import org.springframework.lang.Nullable;
public interface ConversionService {
boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType);
boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
<T> T convert(@Nullable Object source, Class<T> targetType);
Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
}
컨버전 서비스 인터페이스는 1. 단순히 컨버팅이 가능한가? 확인하는 기능과, 2. 컨버팅 기능을 제공한다
@Test
void conversionService() {
//등록
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter());
conversionService.addConverter(new IntegerToStringConverter());
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
//사용
assertThat(conversionService.convert("10", Integer.class)).isEqualTo(10);
assertThat(conversionService.convert(10, String.class)).isEqualTo("10");
IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
String ipPortString = conversionService.convert(new IpPort("127.0.0.1", 8080), String.class);
assertThat(ipPortString).isEqualTo("127.0.0.1:8080");
}
}
DefaultConversionService 는 ConversionService 인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공한다
컨버터를 등록할 때는 StringToIntegerConverter
같은 타입 컨버터를 명확하게 알아야 한다
반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다. 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다
따라서 타입을 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다
물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다
Integer value = conversionService.convert("10", Integer.class)
인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다
DefaultConversionService 는 다음 두 인터페이스를 구현했다.
이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리 할 수 있다
특히 컨버터를 사용하는 클라이언트는 ConversionService
만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다
결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다.
이렇게 인터페이스를 분리하는 것을 ``ISP```라 한다
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
}
}
스프링은 내부에서 ConversionService
를 제공
우리는 WebMvcConfigurer
가 제공하는 addFormatters()
를 사용해서 추가하고 싶은 컨버터를 등록하면 된다
이렇게 하면 스프링은 내부에서 사용하는 ConversionService
에 컨버터를 추가해준다
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
StringToIntegerConverter : convert source=10
data = 10
?data=10
의 쿼리 파라미터는 문자이고 이것을 Integer data
로 변환하는 과정이 필요하다 실행해보면 직접 등록한 StringToIntegerConverter
가 작동하는 로그를 확인할 수 있음
@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
System.out.println("ipPort IP = " + ipPort.getIp());
System.out.println("ipPort PORT = " + ipPort.getPort());
return "ok";
}
StringToIpPortConverter : convert source=127.0.0.1:8080
ipPort IP = 127.0.0.1
ipPort PORT = 8080
?ipPort=127.0.0.1:8080
쿼리 스트링이 @RequestParam IpPort ipPort
에서 객체 타입으로 변환 된 것을 확인할 수 있음
@RequestParam
을 처리하는 ArgumentResolver 인RequestParamMethodArgumentResolver
에서 ConversionService
를 사용해서 타입을 변환한다.만약 더 깊이있게 확인하고 싶으면 IpPortConverter
에 디버그 브레이크 포인트를 걸어서 확인 추천
타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원한다
-이전까지는 문자를 객체로 변환했다면, 이번에는 그 반대로 객체를 문자로 변환하는 작업을 확인할 수 있다
@Controller
public class ConverterController {
@GetMapping("/converter-view")
public String converterView(Model model) {
model.addAttribute("number", 10000);
model.addAttribute("ipPort", new IpPort("127.0.0.1", 8080));
return "converter-view";
}
}
<ul>
<li>${number}: <span th:text="${number}" ></span></li>
<li>${{number}}: <span th:text="${{number}}" ></span></li>
<li>${ipPort}: <span th:text="${ipPort}" ></span></li>
<li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li>
</ul>
${{...}}
${...}
${{...}}
@Controller
public class ConverterController {
@GetMapping("/converter-view")
public String converterView(Model model) {
model.addAttribute("number", 10000);
model.addAttribute("ipPort", new IpPort("127.0.0.1", 8080));
return "converter-view";
}
@GetMapping("/converter/edit")
public String converterForm(Model model) {
IpPort ipPort = new IpPort("127.0.0.1", 8080);
Form form = new Form(ipPort);
model.addAttribute("form", form);
return "converter-form";
}
@PostMapping("/converter/edit")
public String converterEdit(@ModelAttribute Form form, Model model) {
IpPort ipPort = form.getIpPort();
model.addAttribute("ipPort", ipPort);
return "converter-view";
}
@Data
static class Form {
private IpPort ipPort;
public Form(IpPort ipPort) {
this.ipPort = ipPort;
}
}
}
Form
객체를 데이터를 전달하는 폼 객체로 사용한다.
GET /converter/edit
: IpPort
를 뷰 템플릿 폼에 출력한다POST /converter/edit
: 뷰 템플릿 폼의 IpPort
정보를 받아서 출력한다<form th:object="${form}" th:method="post">
th:field <input type="text" th:field="*{ipPort}"><br/>
th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/>
<input type="submit"/>
</form>
th:field
는 앞서 설명했듯이 id
,name
를 출력하는 등 다양한 기능이 있는데, 여기에 컨버전 서비스도 함께 적용된다GET /converter/edit
th:field
가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}}
처럼 적용이 되었다. 따라서 IpPort
➞ String
으로 변환된다POST /converter/edit
@ModelAttribute
를 사용해서 String
➞ IpPort
로 변환된다컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수 는 없다.
그런데 생각해보면 포맷터는 객체 문자, 문자 객체로 변환하는 특별한 컨버터일 뿐이다.
포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다
Formatter
가 Converter
처럼 동작하도록 지원FormattingConversionService
는 포맷터를 지원하는 컨버전 서비스이다.
DefaultFormattingConversionService
는 FormattingConversionService
에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다
public class FormattingConversionServiceTest {
@Test
void formattingConversionService() {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
//컨버터 등록
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
//포맷터 등록
conversionService.addFormatter(new MyNumberFormatter());
//컨버터 사용
IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
//포맷터 사용
assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
}
}
FormattingConversionService
는 ConversionService
관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다
그리고 사용할 때는 ConversionService
가 제공하는 convert
를 사용하면 된다
추가로 스프링 부트는 DefaultFormattingConversionService
를 상속 받은 WebConversionService
를 내부에서 사용한다
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
//주석처리 우선순위
//registry.addConverter(new StringToIntegerConverter());
//registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
//추가
registry.addFormatter(new MyNumberFormatter());
}
}
스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다
IDE에서 Formatter
인터페이스의 구현 클래스를 찾아보면 수 많은 날짜나 시간 관련 포맷터가 제공되는
것을 확인할 수 있다.
그런데 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.
스프링은 이런 문제를 해결하기 위해
@(애노테이션) 기반으로 원하는 형식을 지정해서 사용할 수 있는 매우 유용한 포맷터 두 가지를 기본으로 제공한다
@NumberFormat
: 숫자 관련 형식 지정 포맷터 사용,
NumberFormatAnnotationFormatterFactory
@DateTimeFormat
: 날짜 관련 형식 지정 포맷터 사용, Jsr310DateTimeFormatAnnotationFormatterFactory
@Controller
public class FormatterController {
@GetMapping("/formatter/edit")
public String formatterForm(Model model) {
Form form = new Form();
form.setNumber(10000);
form.setLocalDateTime(LocalDateTime.now());
model.addAttribute("form", form);
return "formatter-form";
}
@PostMapping("/formatter/edit")
public String formatterEdit(@ModelAttribute Form form) {
return "formatter-view";
}
@Data
static class Form {
@NumberFormat(pattern = "###,###")
private Integer number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
}
}
• ${form.number}: 10000
• ${{form.number}}: 10,000
• ${form.localDateTime}: 2021-01-01T00:00:00
• ${{form.localDateTime}}: 2021-01-01 00:00:00
컨버터를 사용하든, 포맷터를 사용하든 등록 방법은 다르지만, 사용할 때는 컨버전 서비스를 통해서 일관성 있게 사용할 수 있다
📌주의
메시지 컨버터(HttpMessageConverter)에는 컨버전
서비스가 적용되지 않는다특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 많이 오해하는데,
HttpMessageConverter
의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다
- JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 하기때문에 컨버전 서비스와 전혀 관계가 없다
컨버전 서비스는
@RequestParam
,@ModelAttribute
,@PathVariable
, 뷰 템플릿 등에서 사용할 수 있다
일반적으로 사용하는 HTML Form을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 다음 두 가지 방식의 차이를 이해해야 한다
application/x-www-form-urlencoded
multipart/form-data
Content-Type: application/x-www-form-urlencoded
username=kim&age=20
&
로 구분해서 전송 enctype="multipart/form-data"
를 지정해야 한다multipart/form-data
방식은 다른 종류의 여러 파일과 폼의 내용 함께 전송 가능 하다 multipart
이다Content-Disposition
username
, age
, file1
이 각각 분리되어 있고, 폼의 일반 데이터는 각 항목별로 문자가 전송되고, 파일의 경우 파일 이름과 Content-Type
이 추가되고 바이너리 데이터가 전송된다multipart/form-data
는 application/x-www-form-urlencoded
와 비교해서 매우 복잡하고 각각의 부분(Part)로 나누어져 있다
@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
log.info("request={}", request);
String itemName = request.getParameter("itemName");
log.info("itemName={}", itemName);
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
return "upload-form";
}
}
request.getParts()
: multipart/form-data
전송 방식에서 각각 나누어진 부분을 받아서 확인할 수 있다
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<form th:action method="post" enctype="multipart/form-data">
<ul>
<li>상품명 <input type="text" name="itemName"></li>
<li>파일<input type="file" name="file" ></li>
</ul>
<input type="submit"/>
</form>
</div> <!-- /container -->
</body>
</html>
enctype="multipart/form-data"
를 지정해야 한다테스트를 진행하기 전에
application.properties
에 다음 옵션들을 추가
logging.level.org.apache.coyote.http11=debug
이 옵션을 사용하면 HTTP 요청 메시지를 확인할 수 있다
실행해보면 logging.level.org.apache.coyote.http11 옵션을 통한 로그에서 multipart/form-data 방식으로 전송된 것을 확인할 수 있다
Content-Type: multipart/form-data; boundary=----xxxx
------xxxx
Content-Disposition: form-data; name="itemName"
Spring
------xxxx
Content-Disposition: form-data; name="file"; filename="test.data"
Content-Type: application/octet-stream
sdklajkljdf...
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
max-file-size
: 파일 하나의 최대 사이즈, 기본 1MBmax-request-size
: 멀티파트 요청 하나에 여러 파일을 업로드 할 수 있는데, 그 전체 합이다. 기본 10MB request=org.apache.catalina.connector.RequestFacade@xxx
itemName=null
parts=[]
request.getParameter("itemName")
, request.getParts()
의 결과가 비어있는 걸 확인할 수 있다request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest
itemName=Spring
parts=[ApplicationPart1, ApplicationPart2]
request.getParameter("itemName")
의 결과도 잘 출력되고, request.getParts()
에도 요청한 두 가지 멀티파트의 부분 데이터가 포함된 것을 확인할 수 있다로그를 보면 HttpServletRequest
객체가 RequestFacade
➞ StandardMultipartHttpServletRequest
로 변한 것을 확인할 수 있다
spring.servlet.multipart.enabled
옵션을 켜면 스프링의DispatcherServlet
에서 멀티파트 리졸버(MultipartResolver
)를 실행한다멀티파트 리졸버는 멀티파트 요청인 경우 서블릿 컨테이너가 전달하는 일반적인
HttpServletRequest
를MultipartHttpServletRequest
로 변환해서 반환한다
MultipartHttpServletRequest
는HttpServletRequest
의 자식 인터페이스이고, 멀티파트와 관련된 추가 기능을 제공한다
스프링이 제공하는 기본 멀티파트 리졸버는MultipartHttpServletRequest
인터페이스를 구현한StandardMultipartHttpServletRequest
를 반환한다
이제 컨트롤러에서 HttpServletRequest 대신에MultipartHttpServletRequest
를 주입받을 수 있는데, 이것을 사용하면 멀티파트와 관련된 여러가지 처리를 편리하게 할 수 있다그러나
MultipartFile
이라는 것을 사용하는 것이 더 편하기 때문에MultipartHttpServletRequest
를 잘 사용하지는 않는다
서블릿이 제공하는 Part에 대해 알아보고 실제 파일도 서버에 업로드하기 위해선 파일을 업로드를 하려면 실제 파일이 저장되는 경로가 필요하기 때문에 경로를 지정해 준다
file.dir=파일 업로드 경로 설정(예): /Users/kimyounghan/study/file/
@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
log.info("request={}", request);
String itemName = request.getParameter("itemName");
log.info("itemName={}", itemName);
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
for (Part part : parts) {
log.info("==== PART ====");
log.info("name={}", part.getName());
Collection<String> headerNames = part.getHeaderNames();
for (String headerName : headerNames) {
log.info("header {}: {}", headerName, part.getHeader(headerName));
}
//편의 메서드
//content-disposition; filename
log.info("submittedFileName={}", part.getSubmittedFileName());
log.info("size={}", part.getSize()); //part body size
//데이터 읽기
InputStream inputStream = part.getInputStream();
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("body={}", body);
//파일에 저장하기
if (StringUtils.hasText(part.getSubmittedFileName())) {
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath={}", fullPath);
part.write(fullPath);
}
}
return "upload-form";
}
}
part.getInputStream(): Part의 전송 데이터를 읽을 수 있다.
part.write(...): Part를 통해 전송된 데이터를 저장할 수 있다
@Value("${file.dir}")
private String fileDir;
Part
)으로 나누어 전송한다parts
에는 이렇게 나누어진 데이터가 각각 담긴다Part
는 멀티파트 형식을 편리하게 읽을 수 있는 다양한 메서드를 제공한다==== PART ====
name=itemName
header content-disposition: form-data; name="itemName"
submittedFileName=null
size=7
body=상품A
==== PART ====
name=file
header content-disposition: form-data; name="file"; filename="스크린샷.png"
header content-type: image/png
submittedFileName=스크린샷.png
size=112384
body=qwlkjek2ljlese...
파일 저장 fullPath=/Users/kimyounghan/study/file/스크린샷.png
파일 저장 경로에 가보면 실제 파일이 저장된 것을 확인할 수 있음
📌참고
큰 용량의 파일을 업로드를 테스트 할 때는 로그가 너무 많이 남아서 다음 옵션을 끄는 것이 좋음
logging.level.org.apache.coyote.http11=debug
다음 부분도 파일의 바이너리 데이터를 모두 출력하므로 끄는 것이 좋다
log.info("body={}", body);
서블릿이 제공하는 Part
는 편하기는 하지만, HttpServletRequest
를 사용해야 하고, 추가로 파일 부분만 구분하려면 여러가지 코드를 넣어야 한다
스프링은 MultipartFile
이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다
@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFile(@RequestParam String itemName,
@RequestParam MultipartFile file,
HttpServletRequest request) throws IOException {
log.info("request={}", request);
log.info("itemName={}", itemName);
log.info("multipartFile={}", file);
if (!file.isEmpty()) {
String fullPath = fileDir + file.getOriginalFilename();
log.info("파일 저장 fullPath={}", fullPath);
file.transferTo(new File(fullPath));
}
return "upload-form";
}
}
스프링 답게 딱 필요한 부분의 코드만 작성하면 된다
@RequestParam MultipartFile file
@RequestParam
을 적용하면 된다 @ModelAttribute
에서도 MultipartFile
을 동일하게 사용할 수 있다file.getOriginalFilename()
: 업로드 파일 명
file.transferTo(...)
: 파일 저장
request=org.springframework.web.multipart.support.StandardMultipartHttpServletR
equest@5c022dc6
itemName=상품A
multipartFile=org.springframework.web.multipart.support.StandardMultipartHttpSe
rvletRequest$StandardMultipartFile@274ba730
파일 저장 fullPath=/Users/kimyounghan/study/file/스크린샷.png
@Data
public class Item {
private Long id;
private String itemName;
private UploadFile attachFile;
private List<UploadFile> imageFiles;
}
@Repository
public class ItemRepository {
private final Map<Long, Item> store = new HashMap<>();
private long sequence = 0L;
public Item save(Item item) {
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id) {
return store.get(id);
}
}
@Data
public class UploadFile {
// 고객이 업로드한 파일명
private String uploadFileName;
// 서버 내부에서 관리하는 파일명 ➞ 파일명이 겹치지 않도록 내부에서 관리
private String storeFileName;
public UploadFile(String uploadFileName, String storeFileName) {
this.uploadFileName = uploadFileName;
this.storeFileName = storeFileName;
}
}
uploadFileName
: 고객이 업로드한 파일명
storeFileName
: 서버 내부에서 관리하는 파일명
storeFileName
@Component
public class FileStore {
@Value("${file.dir}")
private String fileDir;
public String getFullPath(String filename) {
return fileDir + filename;
}
public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
List<UploadFile> storeFileResult = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if (!multipartFile.isEmpty()) {
storeFileResult.add(storeFile(multipartFile));
}
}
return storeFileResult;
}
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
return null;
}
String originalFilename = multipartFile.getOriginalFilename();
String storeFileName = createStoreFileName(originalFilename);
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFilename, storeFileName);
}
// 서버 내부에서 관리하는 파일명 UUID 를 사용해서 충돌하지 않도록 함
private String createStoreFileName(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
// 확장자를 별도로 추출 ➞ 서버 내부에서 관리하는 파일명에도 붙여줌
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
}
멀티파트 파일을 서버에 저장하는 역할을 담당한다.
createStoreFileName()
: 서버 내부에서 관리하는 파일명은 유일한 이름을 생성하는 UUID 를 사용해서 충돌하지 않도록 한다extractExt()
: 확장자를 별도로 추출해서 서버 내부에서 관리하는 파일명에도 붙여준다a.png
라는 이름으로 업로드 하면 51041c62-86e4-4274-801d-614a7d994edb.png
와 같이 저장한다@Data
public class ItemForm {
private Long itemId;
private String itemName;
private List<MultipartFile> imageFiles;
private MultipartFile attachFile;
}
List<MultipartFile> imageFiles
: 이미지를 다중 업로드 하기 위해 MultipartFile
를 사용했다MultipartFile attachFile
: 멀티파트는 @ModelAttribute
에서 사용할 수 있다@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {
private final ItemRepository itemRepository;
private final FileStore fileStore;
@GetMapping("/items/new")
public String newItem(@ModelAttribute ItemForm form) {
return "item-form";
}
@PostMapping("/items/new")
public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());
//데이터베이스에 저장
Item item = new Item();
item.setItemName(form.getItemName());
item.setAttachFile(attachFile);
item.setImageFiles(storeImageFiles);
itemRepository.save(item);
redirectAttributes.addAttribute("itemId", item.getId());
return "redirect:/items/{itemId}";
}
@GetMapping("/items/{id}")
public String items(@PathVariable Long id, Model model) {
Item item = itemRepository.findById(id);
model.addAttribute("item", item);
return "item-view";
}
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
@GetMapping("/attach/{itemId}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
Item item = itemRepository.findById(itemId);
String storeFileName = item.getAttachFile().getStoreFileName();
String uploadFileName = item.getAttachFile().getUploadFileName();
UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
log.info("uploadFileName={}", uploadFileName);
String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
}
@GetMapping("/items/new")
: 등록 폼을 보여준다.@PostMapping("/items/new")
: 폼의 데이터를 저장하고 보여주는 화면으로 리다이렉트 한다.@GetMapping("/items/{id}")
: 상품을 보여준다.@GetMapping("/images/{filename}")
: <img>
태그로 이미지를 조회할 때 사용한다 UrlResource
로 이미지 파일을 읽어서 @ResponseBody
로 이미지 바이너리를 반환한다@GetMapping("/attach/{itemId}")
: 파일을 다운로드 할 때 실행한다Content-Disposition
해더에 attachment; filename="업로드 파일명"
값을 주면 된다 <form th:action method="post" enctype="multipart/form-data">
<ul>
<li>상품명 <input type="text" name="itemName"></li>
<li>첨부파일<input type="file" name="attachFile" ></li>
<li>이미지 파일들<input type="file" multiple="multiple" name="imageFiles" ></li>
</ul>
<input type="submit"/>
</form>
다중 파일 업로드를 하려면 multiple="multiple"
옵션을 주면 된다.
ItemForm
에서 여러 이미지 파일을 받을 수 있다.
private List<MultipartFile> imageFiles;
<div class="py-5 text-center">
<h2>상품 조회</h2>
</div>
상품명: <span th:text="${item.itemName}">상품명</span><br/>
첨부파일: <a th:if="${item.attachFile}" th:href="|/attach/${item.id}|"
th:text="${item.getAttachFile().getUploadFileName()}" /><br/>
<img th:each="imageFile : ${item.imageFiles}" th:src="|/images/${imageFile.getStoreFileName()}|"
width="300" height="300"/>
</div>
첨부 파일은 링크로 걸어두고, 이미지는 <img>
태그를 반복해서 출력한다