[김영한 스프링 review] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 (3)

조갱·2023년 12월 17일
0

스프링 강의

목록 보기
7/16

예외 처리와 오류 페이지

서블릿 예외 처리

서블릿이 예외 처리를 하는 상황은 2가지 케이스가 있다.

  • 예외가 발생하는 경우
  • response.sendError(HTTP 상태 코드, 오류 메시지) 를 호출하는 경우

예외가 발생하게 되면

WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(Exception 또는 response.sendError)

와 같은 플로우로 다시 WAS까지 예외가 전달된다.

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

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

위와 같은 설정을 구성하여 에러 페이지를 매핑할 수도 있다.
WAS에 예외가 전파되었을 때,
Http 404 -> /error-page/404
Http 500 -> /error-page/500
RuntimeException (를 상속받는 하위 클래스 포함) -> /error-page/500
으로 매핑된다. (물론 우측에 매핑된 uri도 컨트롤러에 등록되어야 한다.)

서블릿 예외 처리 - 오류 페이지 작동 원리

즉, 사용자 요청 -> 예외 발생 -> 에러페이지 노출 까지의 플로우를 정리해보면 아래와 같다.

WAS(최초 사용자 요청) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(예외발생)
-> 인터셉터 -> 서블릿 -> 필터 -> WAS(여기까지 전파, /error-page/500 다시 요청)
-> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라,
오류 정보를 request 의 attribute 에 추가해서 넘겨준다.

@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
	log.info("ERROR_EXCEPTION: ex=", request.getAttribute(ERROR_EXCEPTION));
	return "error-page/404";
}

에러 코드 상수는 RequestDispatcher 클래스에 상수로 정의되어있다.

  • ERROR_EXCEPTION (javax.servlet.error.exception
    예외
  • ERROR_EXCEPTION_TYPE (javax.servlet.error.exception_type
    예외 타입
  • ERROR_MESSAGE (javax.servlet.error.message)
    오류 메시지
  • ERROR_REQUEST_URI (javax.servlet.error.request_uri)
    클라이언트 요청 URI
  • ERROR_SERVLET_NAME (javax.servlet.error.servlet_name)
    오류가 발생한 서블릿 이름
  • ERROR_STATUS_CODE (javax.servlet.error.status_code)
    HTTP 상태 코드

서블릿 예외 처리 - 필터

위에서 예외처리 플로우를 보면, 불필요하게 필터를 호출하고 있다.
(단순히 예외페이지를 보여주기 위해 컨트롤러를 호출하는데, 필터를 호출할 필요는 없다.)

이러한 경우를 방지하기 위해, 필터는 dispatcherTypes 라는 옵션을 제공한다.

log.info("dispatchType={}", request.getDispatcherType())
public enum DispatcherType {
	FORWARD, // 서블릿에서 다른 서블릿이나 JSP를 호출할 때
	INCLUDE, // 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
	REQUEST, // 클라이언트 요청
	ASYNC, // 서블릿 비동기 호출
	ERROR // 오류 요청
}

필터 로직 doFIlter(...) 내에서 if문을 통해 DispatcherType에 따른 예외를 처리할 수도 있고, WebConfig 를 통해 필터를 등록할 때 DispatcherType의 예외를 등록할 수도 있다.

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

setDispatcherTypes 메소드를 통해 REQUEST, ERROR 를 등록했기 때문에,
logFilter는 REQUEST, ERROR 상황에서 실행된다.

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

인터셉터는 서블릿이 제공하는 필터와 무관하게, 스프링이 제공하는 기능이기 때문에 DispatcherType 에 상관 없이 항상 호출된다.
따라서 아래 2가지 방법으로 예외 처리가 가능하다.

  • 인터셉터 로직 내에 request.getDispatcherType() 를 통해 직접 예외처리
  • 오류 페이지 경로를 excludePathPatterns 에 등록하여 예외처리

API 예외 처리

HTML 페이지의 경우, 단순히 화면을 렌더링만 해주면 되는 반면,
API의 예외는 에러 인터페이스를 정의해두고 형식에 맞게 예외 데이터를 응답해야한다.

스프링 부트 기본 오류 처리

스프링부트에서는 API에 대한 예외 처리도 기본적으로 제공하고있다.
BasicErrorController 를 보면 동일하게 /error 경로를 HTML, JSON 형태로 렌더링하는 두개의 컨트롤러를 볼 수 있다.

* server.error.path 설정이 없으면, 기본적으로 /error 를 사용한다.
다른 경로를 사용하고싶다면 application.properties 를 수정한다.

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
	// request Header의 accept 가 text/html 인 경우 HTML로 view를 제공
	@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
	public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) { ... }

	// 그 외의 accept는 json 형태로 response 를 제공
	@RequestMapping
	public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { ... }
}

API 예외가 발생하면 다양한 오류 메시지가 노출되는데,
application.properties 에서 아래 설정을 통해 노출되는 메시지를 조절할 수 있다.
(당연하겠지만, 서버의 보안을 위해 최소한의 메시지만 노출하는 것이 좋다.)

server.error.include-binding-errors=always
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always

BasicErrorController 는 단순한 기능만을 제공하므로,
에러 응답을 좀 더 구체적으로 하고싶다면, ExceptionResolver를 사용해보자.

HandlerExceptionResolver

HandlerExceptionResolver를 줄여서 ExceptionResolver로 부르기도 한다.

동작 플로우

* 참고: ExceptionResolver 로 예외를 해결해도 postHandle() 은 호출되지 않는다.

인터페이스 구조

public interface HandlerExceptionResolver {
    ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object 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;
	}
}

IllegalArgumentException이 발생하면 response.sendError를 통해 BAD_REQUEST를 서블릿에 전달한다.

그 이후에 return new ModelAndView();를 통해 빈 ModelAndView를 반환함으로서 정상 동작처럼 보이게 하는데, 이는 일반적인 try-catch 처럼 예외를 해결시킨다.
ExceptionResolver는 말 그대로, 예외를 해결시키는 역할을 하기 때문에 Exception을 전파하는게 아니라 빈 ModelAndView를 반환하는 것이다.

참고로, 반환값에 따라 서블릿은 서로 다른 동작을 수행한다.

new ModelAndView(): 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
ModelAndView 지정: 뷰를 렌더링 한다.
null: 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

위 내용들을 통해, HandlerExcpetionResolver을 통해
1. Http 응답코드 변환 (response.sendError)
2. 뷰 템플릿 처리 (반환할 ModelAndView 지정)
3. API 응답 처리
response.getWriter().println("hello"); 와 같이 responseBody 설정 가능
와 같은 작업을 수행할 수 있다.

HandlerExceptionResolver 등록

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

WebMvcConfigurer 를 통해 등록하면 된다.

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

HandlerExceptionResolver 활용

이전에 다뤘던 예외 플로우를 다시 보자.

WAS(최초 사용자 요청) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(예외발생)
-> 인터셉터 -> 서블릿 -> 필터 -> WAS(여기까지 전파, /error-page/500 다시 요청)
-> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

처리되지 않은 예외 때문에, 다시 WAS로 돌아가서 에러 페이지를 렌더링 하는 것은 복잡하다.

WAS(최초 사용자 요청) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(예외발생)
-> 인터셉터 -> 서블릿(ExceptionResolve) -> 필터 -> WAS(정상처리)

이렇게 바꿔보자.

@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 RuntimeException) {
				log.info("RuntimeExcpetion 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;
    }
}

ExceptionResolver로 중간에 예외를 가로채서
response.setStatus 를 통해 http 응답코드를 400으로 바꿔주고

(accept requestHeader가 application/json인 경우)
response.getWriter().write(...) 를 통해 json 응답값을 내려준다.

그 외의 accept requestHeader이면 error/500 으로 렌더링한다.

ExceptionResolver 등록

WebConfigurer 에 ExceptionResolver를 등록해주자.

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

스프링이 제공하는 ExceptionResolver

ExceptionResolver를 직접 구현하고 등록하려니 꽤 많은 리소스가 든다.
스프링이 제공하는 ExceptionResolver를 사용해보자.

스프링 부트가 기본으로 제공하는 ExceptionResolver 는 다음과 같다. HandlerExceptionResolverComposite 에 다음 순서로 등록한다.
1 -> 3으로 갈수록 우선순위가 낮다.
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver

ExceptionHandlerExceptionResolver

@ExceptionHandler 을 처리한다.
이후에 ExceptionHandler와 함께 알아보자.

ResponseStatusExceptionResolver

예외에 따라서 HTTP 상태 코드를 지정해준다.
단, 개발자가 수정할 수 없는 코드 (라이브러리) 에는 사용할 수 없다.

아래 2가지 예외를 처리한다.

  • @ResponseStatus 가 달려있는 예외
  • ResponseStatusException 예외
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException { ... }

앞으로 로직에서 throw BadRequestException() 를 하게되면, http 응답 코드가 400으로 응답된다.

*메시지 기능 : reason에 메시지 기능을 사용할 수도 있다. (reason = "error.bad")

DefaultHandlerExceptionResolver

스프링 내부 기본 예외를 처리한다.

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 발생하는 TypeMismatchException이 있는데, 이는 클라이언트가 http 요청을 잘못한 케이스이다.
그리고, 잘못된 요청은 http status 400으로 내려가야 한다.

DefaultHandlerExceptionResolver 내부를 보면

protected ModelAndView handleTypeMismatch(TypeMismatchException ex, HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
	response.sendError(HttpServletResponse.SC_BAD_REQUEST);
	return new ModelAndView();
}

와 같이 400에러로 변환하는 것을 볼 수 있다.

@ExceptionHandler

스프링은 어노테이션을 통해 손쉬운 ExceptionHandler 기능을 제공한다.
ExceptionHandler 를 처리하는 ExceptionHandlerExceptionResolver는 스프링이 기본적으로 제공하는 ExceptionResolver 중 하나이며, 그중에서도 우선순위가 가장 높다.

에러 객체 정의

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

	@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("잘못된 사용자");
        } else if (id.equals("bad")) {
			throw new IllegalArgumentException("잘못된 입력 값");
        } else if (id.equals("user-ex")) {
			throw new UserException("사용자 오류");
        }
        
        return new MemberDto(id, "hello " + id);
    }
}

어노테이션에 속성을 통해 예외를 처리할 Exception을 지정할 수 있다.
또한, 지정한 Exception class와 그 하위까지 모두 잡을 수 있다.

물론, 부모와 자식 예외가 둘 다 정의되어있다면, 더 구체적인 (자식) 예외 처리 로직이 수행된다.

@ExceptionHandler(부모예외.class)
public String 부모예외처리(부모예외 e) { ... }

// 자식 예외가 발생하면 이 로직이 수행된다.
@ExceptionHandler(자식예외.class)
public String 자식예외처리(자식예외 e) { ... }

여러 예외 처리

@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
	log.info("exception e", e);
}

위와 같이 여러 예외를 한번에 처리할 수도 있다.

예외 생략

@ExceptionHandler
public ErrorResult exHandle(Exception e) {
	log.error("[exceptionHandle] ex", e);
    return new ErrorResult("EX", "내부 오류");
}

@ExceptionHandler의 속성에 예외 클래스를 생략할 수 있다.
예외 클래스를 생략하면, 메소드의 파라미터에 지정된 예외 클래스 (위 예제에서는 Exception 클래스) 가 지정된다.

즉, 위 코드는 아래와 같다.

@ExceptionHandler(Exception.class)
public ErrorResult exHandle(Exception e) {
	log.error("[exceptionHandle] ex", e);
    return new ErrorResult("EX", "내부 오류");
}

HTML 오류 화면

ModelAndView를 을 반환하여 오류 화면 (HTML)을 응답할 수도 있다.

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

@ControllerAdvice

@ExceptionHandler를 사용하여 기존보다 에러를 깔끔하게 처리할 수 있게 됐다.
하지만, @ExceptionHandler와 컨트롤러가 하나의 파일에 존재하는 것은 가독성을 떨어뜨린다.

@(Rest)ControllerAdvice 를 사용하여 ExceptionHandler와 컨트롤러를 분리하자.

(Rest)ControllerAdvice 클래스

@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", "내부 오류");
	}
}

Controller 클래스

@Slf4j
@RestController
public class ApiExceptionV2Controller {
	@GetMapping("/api2/members/{id}")
	public MemberDto getMember(@PathVariable("id") String id) {
		if (id.equals("ex")) {
			throw new RuntimeException("잘못된 사용자");
		} else if (id.equals("bad")) {
			throw new IllegalArgumentException("잘못된 입력 값");
        } else if (id.equals("user-ex")) {
			throw new UserException("사용자 오류");
		}
		
        return new MemberDto(id, "hello " + id);
	}
}

@ControllerAdvice

  • 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 한다.
  • @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.
    (글로벌 적용)
  • @RestControllerAdvice는 @ResponseBody 가 추가되어 있다.
    (@Controller , @RestController 의 차이와 같다.)

대상 컨트롤러 지정

// @RestController가 붙은 모든 컨트롤러
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// 특정 패키지 및 하위 패키지에 있는 모든 컨트롤러
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// 지정한 모든 컨트롤러
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}

// 모든 컨트롤러 (글로벌 설정)
@ControllerAdvice
public class ExampleAdvice4 {}

스프링 타입 컨버터

모든 http 요청, 응답 필드는 String 타입으로 전달된다.
하지만, 로직 내에서는 그 외(Int, Boolean, Object...)의 타입으로 사용될 수 있다.

개발자가 매번 Integer.valueOf(...) 로 형변환을 해줄수도 없는 노릇이다.
(더욱이, 객체로 변환한다면 더 복잡하고 귀찮다.)

스프링은 이러한 Request, Response 데이터에 대해 컨버터를 제공함으로서,
개발자가 형변환할 필요 없이 원하는 데이터로 Request를 받고,
원하는 데이터로 Output을 변환할 수 있다.

Converter를 적용하기 전에

먼저, 아무런 컨버터를 적용하지 않고 컨트롤러를 구현해보자.

RequestParam

@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
	System.out.println("data = " + data);
	return "ok";
}

ModelAttribute

@GetMapping("/hello-v2")
public String helloV2(@ModelAttribute UserData data) {
	System.out.println("data = " + data);
	return "ok";
}

class UserData {
	Integer data;
}

PathVariable

@GetMapping("/users/{userId}")
public String helloV2(@PathVariable("userId") Integer data) {
	System.out.println("data = " + data);
	return "ok";
}

위에서 모든 데이터는 String 타입으로 오간다고 했는데,,, 변환이 잘된다.
나는 거짓말쟁이인걸까,,?

정답은, 스프링의 기본 타입 컨버터를 탔기 때문이다.
스프링은 문자, 숫자, Boolean, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다.
IDE에서 Converter, ConverterFactory, GenericConverter 의 구현체를 찾아보면 수 많은 컨버터를 확인할 수 있다.

*참고 : https://docs.spring.io/spring-framework/reference/core/validation/convert.html

* 기존에는 타입을 변환할 때 PropertyEditor를 사용했으나, 동시성 이슈로 사용하지 않는다. Converter를 사용하자.

Type Converter

인터페이스

package org.springframework.core.convert.converter;
public interface Converter<S, T> {
	T convert(S source);
}

S: Source의 약자로, 변환의 원본이 되는 타입이다.
T: Target(혹은 To)의 약자로, 변환 결과가 될 타입니다.

TypeConverter를 사용하고 싶으면 Converter 인터페이스를 구현하면 된다.
위 속성을 보면 알 수 있듯, Converter는 범용적인 변환에서 사용된다.
웹 개발에서는 문자 <-> 객체(Object, Int, Boolean...) 간의 변환을 많이 사용하는데,
이에 특화된 Formatter 인터페이스도 존재한다. 이에 대해서는 아래에서 후술한다.

참고로, Converter는 굉장히 많은 패키지에 존재하므로 꼭 다음 패키지를 import할 수 있도록 한다 : org.springframework.core.convert.converter.Converter

StringToIntegerConverter: 문자열 -> 숫자 변환

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

String -> Integer 로 변환하기 때문에
Converter<S, T> 에서 Converter<String, Integer> 를 상속받는다.

이후에 converter 메소드는 S: String을 입력받아 T: Integer반환형으로 오버라이딩한다.

IntegerToStringConverter: 숫자 -> 문자열 변환

package hello.typeconverter.converter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;

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

IpPort 사용자 정의 객체 추가

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

StringToIpPortConverter: 문자열 -> 객체 변환

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

IpPortToStringConverter: 객체 -> 문자열 변환

public class IpPortToStringConverter implements Converter<IpPort, String> {
	@Override
	public String convert(IpPort source) {
		log.info("convert source={}", source);
		return source.getIp() + ":" + source.getPort();
	}
}

변환 테스트

@Test
void stringToInteger() {
	StringToIntegerConverter converter = new StringToIntegerConverter();
	Integer result = converter.convert("10");
	assertThat(result).isEqualTo(10);
}

@Test
void integerToString() {
	IntegerToStringConverter converter = new IntegerToStringConverter();
	String result = converter.convert(10);
	assertThat(result).isEqualTo("10");
}

@Test
void stringToIpPort() {
	StringToIpPortConverter converter = new StringToIpPortConverter();
	String source = "127.0.0.1:8080";
	IpPort result = converter.convert(source);
	assertThat(result).isEqualTo(new IpPort("127.0.0.1", 8080));
}

@Test
void ipPortToString() {
	IpPortToStringConverter converter = new IpPortToStringConverter();
	IpPort source = new IpPort("127.0.0.1", 8080);
	String result = converter.convert(source);
	assertThat(result).isEqualTo("127.0.0.1:8080");
}

대충 Converter는 만들었는데, 매번 Converter 객체를 생성해서 convert() 하는 것은
기존처럼 개발자가 직접 형변환 해주는거랑 다를게 없어보인다.
-> 여전히 불편하고 구찮다.

이를 스프링에 등록하고 사용해보자.

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

참고 :
제네릭에서 <?>는 와일드카드를 나타낸다.
와일드카드는 특정한 타입을 정확히 지정하지 않고, 더 범용적으로 다루기 위한 용도로 사용된다. 크게 2가지로 나눌 수 있는데, 제네릭 와일드카드(?)와 제한된 와일드카드(? extends T 또는 ? super T)로 나뉜다.

<T> T convert(...): 제네릭 메소드를 의미한다. 반환할 때마다 타입을 동적으로 결정하기 위해 사용한다.

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

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

클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙

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

  • ConversionService : 컨버터 사용에 초점
    -> 타입 컨버터를 전혀 몰라도 되고, 단지 convert(...)만 호출하면 된다.
  • ConverterRegistry : 컨버터 등록에 초점
    -> StringToIntegerConverter 같은 타입 컨버터를 명확하게 알아야 한다.

스프링에 Converter 적용하기

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

실행해보기

@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
	System.out.println("data = " + data);
	return "ok";
}

>>> StringToIntegerConverter : convert source=10
>>> data = 10
@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

처리 과정

@RequestParam은 @RequestParam을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다. 부모 클래스와 다양한 외부 클래스를 호출하는 등 복잡한 내부 과정을 거치기 때문에 대략 이렇게 처리되는 것으로 이해해도 충분하다. 만약 더 깊이있게 확인하고 싶으면 IpPortConverter 에 디버그 브레이크 포인트를 걸어서 확인해보자.

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

타임리프에서도 컨버터를 적용할 수 있다.

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

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

>>> ${number}: 10000
>>> ${{number}}: 10000
>>> ${ipPort}: hello.typeconverter.type.IpPort@59cb0946
>>> ${{ipPort}}: 127.0.0.1:8080

폼 객체에 th:field 속성을 사용하면 자동으로 ConverService를 이용할 수도 있다.

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

Formatter

위에서 소개한 Converter는 범용적인 변환에 사용된다.
Formatter는 웹 개발에서 주로 변환하는 문자열<->객체 (Object, Int, Boolean...) 에 특화된 인터페이스이다.

추가로, Locale도 사용할 수 있기 때문에 국제화에서 유용하게 사용할 수 있다.

즉, Formatter는 Converter의 특별한 버전(?) 으로 생각해도 된다.

인터페이스

public interface Printer<T> {
	String print(T object, Locale locale);
}
public interface Parser<T> {
	T parse(String text, Locale locale) throws ParseException;
}
public interface Formatter<T> extends Printer<T>, Parser<T> {  }

Formatter 인터페이스는 단순히 Printer, Parser 인터페이스를 상속받는 인터페이스이며
Printer는 String print(...): 객체 > 문자열로 변환
Parser는 T parse(...): 문자열 > 객체로 변환
만을 지원한다. 즉, Formatter 인터페이스를 구현하는 클래스는
print와 parse만 오버라이딩 하면 된다.

@Slf4j
public class MyNumberFormatter implements Formatter<Number> {
	@Override
	public Number parse(String text, Locale locale) throws ParseException {
		log.info("text={}, locale={}", text, locale);
		NumberFormat format = NumberFormat.getInstance(locale);
		return format.parse(text);
	}

	@Override
	public String print(Number object, Locale locale) {
		log.info("object={}, locale={}", object, locale);
		return NumberFormat.getInstance(locale).format(object);
	}
}

위 예제는 1000 이라는 숫자와 "1,000"이라는 문자열을 상호 변환하는 예제 코드이다.
Number는 모든 숫자 (Int, Long,,,)의 부모 클래스이며,
1000 -> "1,000" 과 같은 단순한 변환은 NumberFormat 라이브러리를 사용하여 단순하게 구현할 수 있다. (NumberFormat은 Locale을 사용하여 국가별로 다른 포맷으로 변환해줄 수도 있다.)

테스트

@Test
void parse() throws ParseException {
	Number result = formatter.parse("1,000", Locale.KOREA);
	assertThat(result).isEqualTo(1000L); //Long 타입 주의
}

@Test
void print() {
	String result = formatter.print(1000, Locale.KOREA);
	assertThat(result).isEqualTo("1,000");
}

스프링은 용도에 따라 다양한 방식의 포맷터를 제공한다.

Formatter: 포맷터
AnnotationFormatterFactory: 필드의 타입이나 애노테이션 정보를 활용할 수 있는 포맷터

자세한 내용은 공식 문서를 참고하자.
https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#format

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

Formatter는 Converter의 특수한 버전일 뿐인데, ConversionService에는 등록할 수 없다.
FormattingConversionService 를 사용하면 Converter와 Formatter를 동시에 등록하여 사용할 수 있다.
-> 내부에서 어댑터 패턴을 사용해서 Formatter 가 Converter 처럼 동작하도록 지원한다.

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

FormattingConversionServiceGenericConversionService 을 상속받기 때문에 Formatter, Converter 모두 등록할 수 있다.
사용할 때는 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());
	}
}

같은 타입을 변환하는 Converter와 Formatter가 동시에 등록되어 있으면 Converter가 우선적으로 동작한다. 따라서 기존에 작성했던
StringToIntegerConverter, IntegerToStringConverter를 주석처리해야 Formatter가 정상 작동된다.

스프링이 제공하는 기본 포맷터

@NumberFormat : 숫자 관련 형식 지정 포맷터 사용,
-> Formatter: NumberFormatAnnotationFormatterFactory

@DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용,
-> Formatter: Jsr310DateTimeFormatAnnotationFormatterFactory

사용하기

@NumberFormat(pattern = "###,###")
Integer number;

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime localDateTime;
profile
A fast learner.

0개의 댓글