스프링 MVC 2편 - 섹션8~섹션11 후기

soso·2023년 6월 11일
0
post-thumbnail

김영한의 스프링 완전 정복 로드맵
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
섹션8~섹션11 정리입니다.

섹션8. 예외 처리와 오류 페이지

서블릿 예외 처리 - 시작

서블릿은 다음 2가지 방식으로 예외 처리를 지원

  • Exception (예외)
  • response.sendError(HTTP 상태 코드, 오류 메시지)

# Exception(예외)

# 자바 직접 실행

자바의 메인 메서드를 직접 실행하는 경우 main 이라는 이름의 쓰레드가 실행
실행 도중에 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드는 종료된다

# 웹 애플리케이션

웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행
애플리케이션에서 예외가 발생했는데, 어디선가 try ~ catch로 예외를 잡아서 처리하면 아무런 문제 없지만, 애플리케이션에서 예외를 잡지 못하면 서블릿 밖으로 까지 예외가 전달된다
(톰캣 같은 WAS 까지 예외 전달)

WAS(여기까지 전파) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러(예외발생)

  • Exception의 경우 서버 내부에서 처리할 수 없는 오류가 발생한 것으로 생각해서 HTTP 상태 코드 500을 반환

# response.sendError(HTTP 상태 코드, 오류 메시지)

  • 오류가 발생했을 때 HttpServletResponse가 제공하는 sendError 라는 메서드 사용가능

  • 이것을 호출한다고 당장 예외가 발생하는 것은 아니지만, 서블릿 컨테이너에게 오류가 발생했다는 점을 전달 할수 있고, HTTP 상태 코드와 오류 메시지도 추가할 수 있다

    • response.sendError(HTTP 상태 코드)
    • response.sendError(HTTP 상태 코드, 오류 메시지)
  • response.sendError() 를 호출하면 response 내부에는 오류가 발생했다는 상태를 저장

  • 서블릿 컨테이너는 고객에게 응답 전에 responsesendError()가 호출되었는지 확인

    • 호출되었다면 설정한 오류 코드에 맞추어 기본 오류 페이지를 보여준다

서블릿 예외 처리 - 오류 화면 제공

# 과거 xml 방식

<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>
  • 과거에는 web.xml 이라는 파일에 다음과 같이 오류 화면을 등록했다

# 서블릿 오류 페이지 등록

@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);
   }
 }
  • response.sendError(404) : errorPage404 호출
  • response.sendError(500) : errorPage500 호출
  • RuntimeException 또는 그 자식 타입의 예외: errorPageEx 호출

오류 페이지는 예외를 다룰 때 해당 예외와 그 자식 타입의 오류를 함께 처리한다
오류가 발생했을 때 처리할 수 있는 컨트롤러가 필요하다

# 오류 페이지 처리 Controller

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(여기까지 전파) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러(예외발생)

# sendError 흐름

WAS(sendError 호출 기록 확인) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러 (response.sendError())

# 예외 발생과 오류 페이지 요청 흐름

1. WAS(여기까지 전파) ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 ➞ 필터 ➞ 서블릿 ➞ 인터셉터 ➞ 컨트롤러(/error-page/500) ➞ View

정리
1. 예외가 발생해서 WAS까지 전파된다.
2. WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 필터,
서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다

#오류 정보 추가

WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 requestattribute에 추가해서 넘겨준다
필요하면 오류 페이지에서 이렇게 전달된 오류 정보를 사용할 수 있다

# ErrorPageController - 오류 출력

@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 이라는 추가 정보를 제공한다

# DispatcherType

필터는 이런 경우를 위해서 dispatcherTypes 라는 옵션을 제공,이전 강의의 마지막에 다음 로그를 추가했다
log.info("dispatchType={}", request.getDispatcherType())
그리고 출력해보면 오류 페이지에서 dispatchType=ERROR로 나오는 것을 확인할 수 있다

고객이 처음 요청하면 dispatcherType=REQUEST이다
이렇듯 서블릿 스펙은 실제 고객이 요청한 것인지, 서버가 내부에서 오류 페이지를 요청하는 것인지 DispatcherType으로 구분할 수 있는 방법을 제공한다

# LogFilter - 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");
    }
}
  • 로그를 출력하는 부분에 request.getDispatcherType() 을 추가했다

# WebConfig


@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;
 }
}

서블릿 예외 처리 - 인터셉터

인터셉터 중복 호출 제거

# LogInterceptor - DispatcherType 로그 추가

@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 으로 중복 호출 제거 (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

스프링 부트 - 오류 페이지1

지금까지 예외 처리 페이지를 만들기 위해서 다음과 같은 복잡한 과정을 거침

  • WebServerCustomizer를 만들고
  • 예외 종류에 따라서 ErrorPage를 추가하고
  • 예외 처리용 컨트롤러 ErrorPageController를 만듬

스프링 부트는 이런 과정을 모두 기본으로 제공

  • ErrorPage를 자동으로 등록, 이때 /error라는 경로로 기본 오류 페이지를 설정한다
    • new ErrorPage("/error"), 상태코드와 예외를 설정하지 않으면 기본 오류 페이지로 사용된다.
    • 서블릿 밖으로 예외가 발생하거나, response.sendError(...)가 호출되면 모든 오류는 /error를 호출
  • BasicErrorController라는 스프링 컨트롤러를 자동으로 등록
    • ErrorPage에서 등록한 /error를 매핑해서 처리하는 컨트롤러

스프링 부트가 제공하는 기본 오류 메커니즘을 사용하도록 WebServerCustomizer@Component주석 처리

이제 오류가 발생했을 때 오류 페이지로 /error를 기본 요청
스프링 부트가 자동 등록한 BasicErrorController는 이 경로를 기본으로 받는다

개발자는 오류 페이지만 등록

BasicErrorController는 기본적인 로직이 모두 개발되어 있다
개발자는 오류 페이지 화면만 BasicErrorController가 제공하는 룰과 우선순위에 따라서 등록하면 된다
정적 HTML이면 정적 리소스, 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶으면 뷰 템플릿 경로에 오류 페이지 파일을 만들어서 넣어두기만 하면 된다

# 뷰 선택 우선순위

  1. 뷰 템플릿
    resources/templates/error/500.html
    resources/templates/error/5xx.html

  2. 정적 리소스(static , public)
    resources/static/error/400.html
    resources/static/error/404.html
    resources/static/error/4xx.html

  3. 적용 대상이 없을 때 뷰 이름(error)
    resources/templates/error.html

스프링 부트 - 오류 페이지2

BasicErrorController가 제공하는 기본 정보들

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

  • server.error.include-exception=false : exception 포함 여부( true , false )
  • server.error.include-message=never : message 포함 여부
  • server.error.include-stacktrace=never : trace 포함 여부
  • server.error.include-binding-errors=never : errors 포함 여부

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(파라미터가 있을 때 사용)

📌실무에서는 이것들을 노출하면 안된다! 사용자에게는 이쁜 오류 화면과 고객이 이해할 수 있는 간단한 오류 메시지를 보여주고 오류는 서버에 로그로 남겨서 로그로 확인해야 한다


섹션9. API 예외 처리

API의 경우에는 생각할 내용이 더 많다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다

# WebServerCustomizer 다시 동작

@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);
 }
}

# ApiExceptionController - API 예외 컨트롤러

@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이면 예외가 발생하도록 코드생성

# Postman으로 테스트

HTTP Header에 Acceptapplication/json인 것을 꼭 확인

API를 요청했는데, 정상의 경우 API로 JSON 형식으로 데이터가 정상 반환,
오류가 발생시 오류 페이지 HTML이 반환 한다
하지만 정상 요청이든, 오류 요청이든 JSON이 반환 되어야 한다

# ErrorPageController - API 응답 추가

@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 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다

스프링 부트가 제공하는 BasicErrorController 코드

@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 )

#Html 페이지 vs API 오류

BasicErrorController를 확장하면 JSON 메시지도 변경할 수 있다
스프링 부트가 제공하는 BasicErrorControllerHTML 페이지를 제공하는 경우에는 매우 편리하다
4xx, 5xx 등등 모두 잘 처리해준다

그런데 API 오류 처리는 다른 차원의 이야기이다
API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다
예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있다

결과적으로 매우 세밀하고 복잡하다 따라서 이 방법은 HTML 화면을 처리할 때 사용하고, API 오류 처리는 뒤에서 설명할 @ExceptionHandler 를 사용하자

HandlerExceptionResolver

API 예외 처리 - HandlerExceptionResolver 시작

# 상태코드 변환

예를 들어서 IllegalArgumentException을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태코드를 400으로 처리하고 싶다 어떻게 해야할까?

텍스트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("잘못된 입력 값");
  }
  return new MemberDto(id, "hello " + id);
}

# HandlerExceptionResolver

스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다
컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면HandlerExceptionResolver를 사용하면 된다 줄여서 ExceptionResolver라 한다

HandlerExceptionResolver - 인터페이스

public interface HandlerExceptionResolver {
   ModelAndView resolveException( HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
}
  • handler : 핸들러(컨트롤러) 정보
  • Exception ex : 핸들러(컨트롤러)에서 발생한 발생한 예외

MyHandlerExceptionResolver

@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;
    }
}
  • ExceptionResolverModelAndView를 반환하는 이유는 마치 try, catch를 하듯이, Exception을 처리해서 정상 흐름 처럼 변경하는 것이 목적, 이름 그대로 ExceptionResolver(해결)하는 것이 목적이다

여기서는 IllegalArgumentException이 발생하면 response.sendError(400)를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView를 반환한다

반환 값에 따른 동작 방식

HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식은 다음과 같다

  • 빈 ModelAndView : new ModelAndView()처럼 빈 ModelAndView를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴
  • ModelAndView 지정 : ModelAndViewView , Model등의 정보를 지정해서 반환하면 뷰를 렌더링 한다
  • null : null을 반환하면, 다음 ExceptionResolver를 찾아서 실행
    만약 처리할 수 있는 `ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다

HandlerExceptionResolver 활용

  • 예외 상태 코드 변환
    • 예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
    • 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어서 스프링 부트가 기본으로 설정한 /error가 호출됨
  • 뷰 템플릿 처리
    • ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
  • API 응답 처리
    • response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능, 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다

WebConfig - 수정

/**
 * 기본 설정을 유지하면서 추가
 */
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
   resolvers.add(new MyHandlerExceptionResolver());
}

configureHandlerExceptionResolvers(..)를 사용하면 스프링이 기본으로 등록하는
ExceptionResolver가 제거되므로 주의, extendHandlerExceptionResolvers를 사용하자

API 예외 처리 - HandlerExceptionResolver 활용

예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error 를 호출하는 과정은 너무 복잡하다
ExceptionResolver를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다

사용자 정의 예외를 하나 추가하자

UserException

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);
   }
}

ApiExceptionController - 예외 추가

@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;
   }
}

이 예외를 처리하는 것이 UserHandlerExceptionResolver

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 오류 페이지를 보여준다

WebConfig에 UserHandlerExceptionResolver 추가

@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
   resolvers.add(new MyHandlerExceptionResolver());
   resolvers.add(new UserHandlerExceptionResolver());
}

정리

ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를 처리해버린다
예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이
나기 때문에 결과적으로 WAS 입장에서는 정상 처리가 된다
이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다

서블릿 컨테이너까지 예외가 올라가면 복잡하고 지저분하게 추가 프로세스가 실행된다
반면에ExceptionResolver를 사용하면 예외처리가 상당히 깔끔해진다

하지만 직접ExceptionResolver를 구현하려고 하니 상당히 복잡하다
지금부터 스프링이 제공하는 ExceptionResolver 들을 알아보자

스프링이 제공하는 ExceptionResolver1

스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다
HandlerExceptionResolverComposite 에 다음 순서로 등록

  1. ExceptionHandlerExceptionResolver : @ExceptionHandler을 처리(예외처리 대부분 처리)
  2. ResponseStatusExceptionResolver : HTTP 상태 코드를 지정해준다
    예) @ResponseStatus(value = HttpStatus.NOT_FOUND)
  3. DefaultHandlerExceptionResolver :스프링 내부 기본 예외를 처리 (우선 순위가 가장 낮음)

ResponseStatusExceptionResolver

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)를 내부 요청한다

ApiExceptionController - 추가

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
 	throw new BadRequestException();
}

ResponseStatusException

@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다 (애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다X)
추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵기 때문에 이때는
ResponseStatusException예외를 사용하면 된다
ApiExceptionController - 추가

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
   throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}

스프링이 제공하는 ExceptionResolver2

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결
대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다
그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다 HTTP에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다

DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다]

스프링 내부 오류를 어떻게 처리할지 수 많은 내용이 정의되어 있다

API 예외 처리 - @ExceptionHandler

# HTML 화면 오류 vs API 오류

웹 브라우저에 HTML 화면을 제공할 때는 오류가 발생하면 BasicErrorController를 사용하는게 편하다
이때는 단순히 5xx, 4xx 관련된 오류 화면을 보여주면 된다. BasicErrorController 는 이런 메커니즘을 모두 구현해두었다

그런데 API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 모두 다르다. 예외 상황에 단순히 오류 화면을 보여주는 것이 아니라, 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있다
그리고 같은 예외라고 해도 어떤 컨트롤러에서 발생했는가에 따라서 다른 예외 응답을 내려주어야 할 수 있다

한마디로 매우 세밀한 제어가 필요하다.
앞서 이야기했지만, 예를 들어서 상품 API와 주문 API는 오류가 발생했을 때 응답의 모양이 완전히 다를 수 있다
결국 지금까지 살펴본 BasicErrorController를 사용하거나 HandlerExceptionResolver를 직접 구현하는 방식으로 API 예외를 다루기는 쉽지 않다

# API 예외처리의 어려운 점

  • HandlerExceptionResolver를 떠올려 보면 ModelAndView를 반환해야 했다
    이것은 API 응답에는 필요하지 않다X
  • API 응답을 위해서 HttpServletResponse에 직접 응답 데이터를 넣어주었다 이것은 매우 불편하다 스프링 컨트롤러에 비유하면 마치 과거 서블릿을 사용하던 시절로 돌아간 것 같음
  • 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어려움
    예를 들어서 회원을 처리하는 컨트롤러에서 발생하는 RuntimeException예외와 상품을 관리하는 컨트롤러에서 발생하는 동일한RuntimeException 예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야할까?

# @ExceptionHandler

스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver이다

스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는
ExceptionResolver중에 우선순위도 가장 높다

실무에서 API 예외 처리는 대부분 이 기능을 사용한다

ErrorResult

@Data
@AllArgsConstructor
public class ErrorResult {
   private String code;
   private String message;
}

ApiExceptionV2Controller

@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 예외 처리 방법

@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

UserException 처리

@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 메시지 바디에 직접 응답한다
    물론 HTTP 컨버터가 사용된다 ResponseEntity를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경가능 하다
    앞서 살펴본 @ResponseStatus는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다x

Exception

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
   log.error("[exceptionHandle] ex", e);
   return new ErrorResult("EX", "내부 오류");
}
  • throw new RuntimeException("잘못된 사용자") 이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException이 던져진다
  • RuntimeExceptionException의 자식 클래스이다 따라서 이 메서드가 호출된다
  • @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)로 HTTP 상태 코드를 500으로 응답한

# 기타

HTML 오류 화면

다음과 같이 ModelAndView를 사용해서 오류 화면(HTML)을 응답하는데 사용 가능

@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
   log.info("exception e", e);
   return new ModelAndView("error");
}

@API 예외 처리 - @ControllerAdvice

@ExceptionHandler를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다
@ControllerAdvice 또는 @RestControllerAdvice를 사용하면 둘을 분리할 수 있다

ExControllerAdvice

@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

  • @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를 조합하면 예외를 깔끔하게 해결할 수 있다


섹션10. 스프링 타입 컨버터

스프링 타입 컨버터 소개

HTTP 요청 파라미터는 모두 문자로 처리된다. 따라서 요청 파라미터를 자바에서 다른 타입으로 변환해서사용하고 싶으면 숫자 타입으로 변환하는 과정을 거쳐야 한다

HelloController - 문자 타입을 숫자 타입으로 변경

@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";
   }
}

스프링 MVC가 제공하는 @RequestParam 사용

HelloController - 추가

@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
   System.out.println("data = " + data);
   return "ok";
}
  • HTTP 쿼리 스트링으로 전달하는 data=10 부분에서 10은 숫자 10이 아니라 문자10이다
  • 스프링이 제공하는 @RequestParam을 사용하면 이 문자 10을 Integer 타입의 숫자 10으로 편리하게 받을 수 있다
  • 이것은 스프링이 중간에서 타입을 변환해주었기 때문이다
  • 이러한 예는 @ModelAttribute , @PathVariable에서도 확인할 수 있다

# 스프링의 타입 변환 적용 예

  • 스프링 MVC 요청 파라미터
    • @RequestParam , @ModelAttribute , @PathVariable
  • @Value 등으로 YML 정보 읽기
  • XML에 넣은 스프링 빈 정보를 변환
  • 뷰를 렌더링 할 때

# 스프링과 타입 변환

스프링이 중간에 타입 변환기를 사용해서 타입을 String Integer 로 변환해주었기 때문에 개발자는 편리하게 해당 타입을 바로 받을 수 있다.
앞에서는 문자를 숫자로 변경하는 예시를 들었지만, 반대로 숫자를 문자로 변경하는 것도 가능하고, Boolean 타입을 숫자로 변경하는 것도 가능하다

타입 컨버터 - Converter

타입 컨버터를 사용하려면 org.springframework.core.convert.converter.Converter 인터페이스를 구현하면 된다

package org.springframework.core.convert.converter;

public interface Converter<S, T> {
   T convert(S source);
}

String ➞ Integer 문자를 숫자로 변환하는 타입 컨버터

@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
 
   @Override
   public Integer convert(String source) {
     log.info("convert source={}", source);
     return Integer.valueOf(source);
   }
}

Integer ➞ String 숫자를 문자로 변환하는 타입 컨버터

@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 객체로 변환하는 컨버터를 만들어보자

IpPort

@Getter
@EqualsAndHashCode
public class IpPort {
   private String ip;
   private int port;
 
   public IpPort(String ip, int port) {
     this.ip = ip;
     this.port = port;
   }
}

String ➞ IpPort 컨버터

@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 객체를 만들어 반환한다

IpPort ➞ String 컨버터

@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

타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것은 매우 불편
그래서 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 이것이 바로 컨버전 서비스(ConversionService)이다.

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. 컨버팅 기능을 제공한다

ConversionServiceTest - 컨버전 서비스 테스트 코드

@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)

# 인터페이스 분리 원칙 - ISP(Interface Segregation Principle)

인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다

DefaultConversionService 는 다음 두 인터페이스를 구현했다.

  • ConversionService : 컨버터 사용에 초점
  • ConverterRegistry : 컨버터 등록에 초점

이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리 할 수 있다
특히 컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다
결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다.
이렇게 인터페이스를 분리하는 것을 ``ISP```라 한다

스프링에 Converter 적용하기

WebConfig - 컨버터 등록

@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에 컨버터를 추가해준다

HelloController - 기존 코드

@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가 작동하는 로그를 확인할 수 있음

HelloController - 추가

@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 에 디버그 브레이크 포인트를 걸어서 확인 추천

뷰 템플릿에 컨버터 적용하기

타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원한다
-이전까지는 문자를 객체로 변환했다면, 이번에는 그 반대로 객체를 문자로 변환하는 작업을 확인할 수 있다

ConverterController

@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";
   }
}
  • Model에 숫자 10000와 ipPort 객체를 담아서 뷰 템플릿에 전달

converter-view.html

<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>
  • ${{...}}
    • 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력

💫주의

  • 변수 표현식 : ${...}
  • 컨버전 서비스 적용 : ${{...}}

ConverterController - 코드 추가

@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 정보를 받아서 출력한다

/converter-form.html

<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}} 처럼 적용이 되었다. 따라서 IpPortString 으로 변환된다
  • POST /converter/edit
    • @ModelAttribute를 사용해서 StringIpPort로 변환된다

포맷터 - Formatter

  • Converter는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다
  • 이렇게 객체를 특정한 포멧에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터(Formatter)이다
    • 포맷터는 컨버터의 특별한 버전으로 이해하면 된다

# Converter vs Formatter

  • 'Converter' 는 범용(객체 객체)
  • 'Formatter' 는 문자에 특화(객체 문자, 문자 객체) + 현지화(Locale) Converter의 특별한 버전

포맷터를 지원하는 컨버전 서비스

컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수 는 없다.
그런데 생각해보면 포맷터는 객체 문자, 문자 객체로 변환하는 특별한 컨버터일 뿐이다.

  • 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다

    • 내부에서 어댑터 패턴을 사용해서 FormatterConverter처럼 동작하도록 지원
  • FormattingConversionService는 포맷터를 지원하는 컨버전 서비스이다.

    • DefaultFormattingConversionServiceFormattingConversionService에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다

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);
 }
}

DefaultFormattingConversionService 상속 관계

FormattingConversionServiceConversionService 관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다
그리고 사용할 때는 ConversionService가 제공하는 convert 를 사용하면 된다

추가로 스프링 부트는 DefaultFormattingConversionService를 상속 받은 WebConversionService를 내부에서 사용한다

포맷터 적용하기

WebConfig - 수정

@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

FormatterController

@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 , 뷰 템플릿 등에서 사용할 수 있다


섹션11. 파일 업로드

파일 업로드 소개

일반적으로 사용하는 HTML Form을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 다음 두 가지 방식의 차이를 이해해야 한다

# HTML 폼 전송 방식

  • application/x-www-form-urlencoded
  • multipart/form-data

application/x-www-form-urlencoded 방식

  • HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법
  • Form 태그에 별도의 enctype 옵션이 없으면 웹 브라우저는 요청 HTTP 메시지의 헤더에 다음
    내용을 추가한다
    • Content-Type: application/x-www-form-urlencoded
  • 폼에 입력한 전송할 항목을 HTTP Body에 문자로 담아 전송
    • username=kim&age=20
    • & 로 구분해서 전송

⚡문제발생

  1. 파일을 업로드 하려면 파일은 문자가 아니라 바이너리 데이터를 전송
    문자를 전송하는 이방식으로 파일을 전송하기는 어렵다X
  2. 보통 폼을 전송할 때 파일만 전송하는 것이 아니라는 점이다

2번예시

  • 이름, 나이 ➞ 문자
  • 첨부파일 ➞ 바이너리
    • 💣문제 발생 문자와 바이너리를 동시에 전송해야 한다

multipart/form-data 방식

  • 이 방식을 사용하려면 Form 태그에 별도의 enctype="multipart/form-data"를 지정해야 한다
  • multipart/form-data 방식은 다른 종류의 여러 파일과 폼의 내용 함께 전송 가능 하다
    • 그래서 이름이 multipart이다
  • Content-Disposition
  • 폼의 입력 결과로 생성된 HTTP 메시지를 보면 각각의 전송 항목이 구분이 되어있다 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다
  • 예제에서는 username , age , file1 이 각각 분리되어 있고, 폼의 일반 데이터는 각 항목별로 문자가 전송되고, 파일의 경우 파일 이름과 Content-Type이 추가되고 바이너리 데이터가 전송된다

# Part

multipart/form-dataapplication/x-www-form-urlencoded 와 비교해서 매우 복잡하고 각각의 부분(Part)로 나누어져 있다

서블릿과 파일 업로드1


@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송 방식에서 각각 나누어진 부분을 받아서 확인할 수 있다

resources/templates/upload-form.html

<!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>
  • form 태그에 별도의 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
  • 큰 파일을 무제한 업로드하게 둘 수는 없으므로 업로드 사이즈를 제한할 수 있다
    • 사이즈를 넘으면 예외( SizeLimitExceededException )가 발생
  • max-file-size : 파일 하나의 최대 사이즈, 기본 1MB
  • max-request-size : 멀티파트 요청 하나에 여러 파일을 업로드 할 수 있는데, 그 전체 합이다. 기본 10MB

spring.servlet.multipart.enabled

  • 기본 true
  • 옵션을 끄면 서블릿 컨테이너는 멀티파트와 관련된 처리를 하지 않는다X

# spring.servlet.multipart.enabled =false 일 때

결과 로그

request=org.apache.catalina.connector.RequestFacade@xxx
itemName=null
parts=[]
  • 결과 로그를 보면 request.getParameter("itemName") , request.getParts() 의 결과가 비어있는 걸 확인할 수 있다

# spring.servlet.multipart.enabled=true 일 때

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)를 실행한다

멀티파트 리졸버는 멀티파트 요청인 경우 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequestMultipartHttpServletRequest로 변환해서 반환한다
MultipartHttpServletRequestHttpServletRequest의 자식 인터페이스이고, 멀티파트와 관련된 추가 기능을 제공한다
스프링이 제공하는 기본 멀티파트 리졸버는 MultipartHttpServletRequest 인터페이스를 구현한 StandardMultipartHttpServletRequest를 반환한다
이제 컨트롤러에서 HttpServletRequest 대신에 MultipartHttpServletRequest 를 주입받을 수 있는데, 이것을 사용하면 멀티파트와 관련된 여러가지 처리를 편리하게 할 수 있다

그러나 MultipartFile 이라는 것을 사용하는 것이 더 편하기 때문에 MultipartHttpServletRequest를 잘 사용하지는 않는다

서블릿과 파일 업로드2

서블릿이 제공하는 Part에 대해 알아보고 실제 파일도 서버에 업로드하기 위해선 파일을 업로드를 하려면 실제 파일이 저장되는 경로가 필요하기 때문에 경로를 지정해 준다

application.properties

file.dir=파일 업로드 경로 설정(예): /Users/kimyounghan/study/file/
  • application.properties 에서 설정할 때 마지막에 / (슬래시)가 포함된 것에 주의

@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 주요 메서드

part.getInputStream(): Part의 전송 데이터를 읽을 수 있다.
part.write(...): Part를 통해 전송된 데이터를 저장할 수 있다

@Value("${file.dir}")
private String fileDir;
  • application.properties 에서 설정한 file.dir 의 값을 주입
  • 멀티파트 형식은 전송 데이터를 하나하나 각각 부분(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
    • 업로드하는 HTML Form의 name에 맞추어 @RequestParam을 적용하면 된다
      - @ModelAttribute에서도 MultipartFile을 동일하게 사용할 수 있다

# 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

예제로 구현하는 파일 업로드, 다운로드

# 요구사항

  • 상품을 관리
    • 상품 이름
    • 첨부파일 하나
    • 이미지 파일 여러개
  • 첨부파일을 업로드 다운로드 할 수 있다
  • 업로드한 이미지를 웹 브라우저에서 확인할 수 있다

Item - 상품 도메인

@Data
public class Item {
   private Long id;
   private String itemName;
   private UploadFile attachFile;
   private List<UploadFile> imageFiles;
}

ItemRepository - 상품 리포지토리

@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);
  }
}

UploadFile - 업로드 파일 정보 보관

@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
    • 서로 다른 고객이 같은 파일이름을 업로드 하는 경우 기존 파일 이름과 충돌이 날 수 있다
    • 서버에서는 저장할 파일명이 겹치지 않도록 내부에서 관리하는 별도의 파일명이 필요하다

FileStore - 파일 저장과 관련된 업무 처리

@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와 같이 저장한다

ItemForm - 상품 저장용 폼이

@Data
public class ItemForm {
   private Long itemId;
   private String itemName;
   private List<MultipartFile> imageFiles;
   private MultipartFile attachFile;
}
  • List<MultipartFile> imageFiles : 이미지를 다중 업로드 하기 위해 MultipartFile를 사용했다
  • MultipartFile attachFile : 멀티파트는 @ModelAttribute에서 사용할 수 있다

ItemController

@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}") : 파일을 다운로드 할 때 실행한다
    • 예제를 더 단순화 할 수 있지만, 파일 다운로드 시 권한 체크같은 복잡한 상황까지 가정한다 생각하고 이미지 id 를 요청하도록 했다
  • 파일 다운로드시에는 고객이 업로드한 파일 이름으로 다운로드 하는게 좋다
    • 이때는 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> 태그를 반복해서 출력한다

profile
오늘의 기록

0개의 댓글