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

조갱·2023년 11월 19일
0

스프링 강의

목록 보기
5/16

메시지, 국제화 소개

메시지

어플리케이션 코드를 작성할 때, String이나 숫자 등을 코드에 하드코딩 하는 것은 좋지 않은 코드이다.

위 코드보다 아래 코드가 가독성이 좋다.
PI 값의 재사용도 가능하다.

fun circleArea(r: Int) : Double {
	return r * r * 3.1415926
}
companion object {
    const val PI = 3.1415926
}

fun circleArea(r: Int) : Double {
	return r * r * PI
}

매직 넘버(영문 언어로 봐야함) 라고도 하는데,의미 있는 상수로 관리할 수 있는 설명되지 않은 의미 또는 여러 번 발생하는 고유한 값을 의미한다.

메시지는 이러한 매직 넘버를 특정한 코드를 부여하여 관리할 수 있는 기능을 제공한다.

국제화

이러한 메시지 기능을 message_en.properties과 같이 국가/언어별로 관리할 수 있는 기법이다. 글로벌 서비스를 제공하는 어플리케이션에서는 필수적으로 사용된다.

예시로는, 항공사 사이트를 가면 언어와 환율을 설정하여 원하는 언어로 페이지를 볼 수 있는데, 이것이 국제화가 적용된 예시이다.

스프링 메시지 소스

설정

대부분의 언어가 메시지 소스 기능을 제공하는데,
스프링 역시 기본적으로 메시지 소스 기능을 제공한다.

메세지 소스 기능을 사용하기 위해서는 여러 방법이 있는데, 아래에서 그 내용을 소개한다.

Spring Bean 등록

스프링이 제공하는 MessageSource 를 스프링 빈으로 등록하면 되는데, MessageSource 는 인터페이스이다.
따라서 구현체인 ResourceBundleMessageSource 를 스프링 빈으로 등록하면 된다.

@Bean
public MessageSource messageSource() {
	ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
	messageSource.setBasenames("messages", "errors");
	messageSource.setDefaultEncoding("utf-8");
	return messageSource;
}
  • setBasenames
    • 설정파일 명을 지정할 수 있다.
    • 위 예시에서는 messages.properties, errors.properties 를 읽어서 메시지 기능을 제공한다.
    • 국제화 기능을 사용하고싶다면, messages_en.properties와 같이 언더바(_) + 언어가 붙은 파일을 생성해주면 된다. (파일만 만들어두면 스프링이 알아서 찾아준다.)
    • 특정 언어에 대한 properties 파일을 찾지 못한다면, 기본적으로 setBasenames에 설정된 파일을 사용한다. (중국어인데 messages_cn.properties 가 없다면, messages.properties에서 찾아서 노출시킨다.)
    • 설정 파일은 /resources/파일명 에 위치시키면 된다.
  • setDefaultEncoding
    • 인코딩 정보를 설정한다. 설정하지 않으면 기본값은 ISO-8859-1이다.

스프링 설정 파일

스프링 설정 파일인 application.properties 에
spring.messages.basename=messages,config.i18n.messages
와 같이 설정 파일 이름이나 패키지를 지정할 수 있다. 위 예시에서는
/resources/messages.properties
/resources/config/i18n/messages.properties
경로를 탐색하게 된다. (국제화가 있을 경우 모두 포함)

static 객체로 관리

스프링 빈으로 등록하지 않고 직접 static 객체로 만들어서 관리하는 방법이다.
MessageSource 객체의 구현체인 ResourceBundleMessageSource를 static 객체로 관리한다.

class MyMessage {
    companion object {
        private val message: MessageSource = ResourceBundleMessageSource().apply {
            setBasenames(
                "messages",
                "config/i18n/messages",
            )
            setDefaultEncoding("utf-8")
        }
    }
    fun getMessageSource(): MessageSource {
      return message
    }
}
MyMessage.getMessageSource().getMessage(...)

기본값

스프링 설정(application.properties)에는 기본값이 아래와 같이 설정되어있다.
spring.messages.basename=messages

따라서, 별 다른 설정을 안했을 경우 /resources/messages.properties를 읽게된다.

사용

아래 테스트 케이스에서는 메시지를 아래와 같이 설정하고 진행한다.
테스트 코드에서 나올 ms 객체는 메시지 소스를 주입받은 객체이다.

/resources/messages.properties

hello=안녕
hello.name=안녕 {0}

/resources/messages_en.properties

hello=hello
hello.name=hello {0}

MessageSource 인터페이스

public interface MessageSource {
	@Nullable
	String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);

	String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;

	String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}

인터페이스를 보면 message를 얻을 수 있는 3가지 방법이 있다.
각 파라미터별로 살펴보자.

  • String Code
    메시지의 properties에서 관리되는 Key값이다.

  • Object[] args
    메시지에 넣을 수 있는 파라미터이다.

  • String defaultMessage
    Code에서 일치하는 properties를 찾지 못했을 때 기본 메시지이다.
    properties에서 찾지 못하고, 기본 메시지가 없다면 NoSuchMessageException 이 발생할 수 있다.

  • Locale locale
    국제화를 적용하기 위한 지역 값이다.
    null로 보내면 기본적으로 Locale.getOrDefault() 를 통해 기본 로케일이 설정된다.

    @Nullable
    protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
    	... // 중략
       if (locale == null) {
    	    locale = Locale.getDefault();
       }
    	... // 중략
    }
  • MessageSourceResolvable resolvable
    위에 Code, args, defaultMessage를 객체로 관리한다.

    public class DefaultMessageSourceResolvable implements MessageSourceResolvable, Serializable {
    	@Nullable
    	private final String[] codes;
    
    	@Nullable
    	private final Object[] arguments;
    
    	@Nullable
    	private final String defaultMessage;
       ...
    }

    특이한 점은, getMessage는 String을 반환하는데, codes를 배열로 관리한다는 점이다. 구현체인 AbstactMessageSource.java의 getMessage(...) 를 보면, code 배열의 앞쪽에서 먼저 찾은 메시지를 반환하는 것을 알 수 있다.

    @Override
    public final String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException {
       String[] codes = resolvable.getCodes();
       if (codes != null) {
           for (String code : codes) {
    		    String message = getMessageInternal(code, resolvable.getArguments(), locale);
    			if (message != null) {
    			    return message;
    			}
    		}
       }
    	... // 중략

정상 케이스

@Test
void helloMessage() {
    String result = ms.getMessage("hello", null, null);
    assertThat(result).isEqualTo("안녕");
}

메시지가 없는 경우, 기본 메시지

@Test
void notFoundMessageCode() {
    assertThatThrownBy(() -> ms.getMessage("no_code", null, null)).isInstanceOf(NoSuchMessageException.class);
}

@Test
void notFoundMessageCodeDefaultMessage() {
    String result = ms.getMessage("no_code", null, "기본 메시지", null);
    assertThat(result).isEqualTo("기본 메시지");
}

매개변수 사용

@Test
void argumentMessage() {
    String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
    assertThat(result).isEqualTo("안녕 Spring");
}

messages.properties에 정의된 hello.name 을 보면
안녕 {0}과 같이 {0} 으로 파라미터가 정의되어있다.

파라미터를 보면 Object[] 배열로 받는 것은, 파라미터를 여러개 넣을 수 있다는 의미기도 하다.
여러 파라미터를 받고자 한다면 {0} 안녕 {1} {2}...와 같이 {} 사이에 들어간 숫자를 올려가며 여러개를 할당할 수 있다. (그만큼 Object[] 배열도 채워주면 된다.)

국제화 파일 선택1

@Test
void defaultLang() {
    assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");
    assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");
}

messages_ko.properties 와 같이 한국어로 정의된 다국어가 없기 때문에
기본값인 messages.properties에서 "안녕" 을 찾았다.

국제화 파일 선택2

@Test
void enLang() {
    assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello");
}

messages_en.properties 를 찾아서 국제화를 적용했다.

국제화 파일 선택3

properties 를 하나 더 추가해보자.

/resources/messages_ko.properties
hello=안녕 한국!
hello.name=안녕 한국! {0}
@Test
void noLang() {
    assertThat(ms.getMessage("hello", null, Locale.CHINA)).isEqualTo("안녕 한국!");
}

위 테스트 결과를 보면 뭔가 이상하다.
messages_cn.properties 가 없기 때문에, 기본값인
messages.properties 에서 찾아서 안녕이 나와야 할것 같지만,
messages_ko.properties 에 정의된 안녕 한국! 이 출력된다.

위에서 기술했지만, getLocale() 의 기본값으로 시스템의 로케일이 들어간다.
따라서, 위 코드에서 메시지를 찾는 순서를 정해보자면 이렇게 된다.
messages_cn.properties > messages_ko.properties > messages.properties
(시스템의 기본 로케일에 따라 두번째에 messages_ko 는 달라질 수 있다.)

검증 - Validation

소개

웹 사이트에서 회원가입 폼(form)을 다 입력했는데, 비밀번호 규칙에 안맞아서 입력한게 다 날아가면 화가 난다. 잘 쓰던 사람도 다 나갈 판이다.

이렇게 검증에 실패한 경우, 사용자가 입력했던 값을 유지하면서 어떤 값이 잘못됐는지 표기해 주는게 친절한 웹 사이트이다.

직접 개발하기

검증 오류 저장하기

Map<String, String> errors = new HashMap<>();

발생한 에러를 모아두는 객체이다.
key : 필드명
value : 에러메시지

특정 필드 검증하기

if (!StringUtils.hasText(item.getItemName())) {
	errors.put("itemName", "상품 이름은 필수입니다.");
}

특정 필드에 국한되지 않는 복합적이거나 글로벌 검증하기

//특정 필드의 범위를 넘어서는 검증 로직
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
    }
}

errors 객체의 key로 특정 필드를 지정하기 어렵기 때문에
globalError라는 key를 사용했다.

에러가 발생하면, 에러 정보와 함께 다시 입력 폼으로 전달

if (!errors.isEmpty()) {
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}

html 에서 오류 메시지 처리

<div th:if="${errors?.containsKey('globalError')}">
	<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>

th:if 태그를 통해 에러가 존재하는 경우에만 <p> 태그가 노출되도록 한다.
참고로, errors?.containsKey 와 같이 ?가 붙은 내용을 볼 수 있는데,
이는 Safe Navigation Operator 라는 기능으로 errors 객체가 null인 경우 뒤에 containsKey를 호출하지 않고 그대로 null을 반환하는 역할을 한다.

페이지에 처음 접근하면 errors 객체가 없기 때문에 무조건 null인데, errors?를 사용하지 않는다면 NPE가 발생할 것이다.

th:if 는 null 이면 false처리를 하기 때문에, <p> 태그가 노출되지 않는다.

문제점

  1. 검증하는 필드가 많아진다면, html에서 th:if로 검증하는 중복 코드가 많아진다.
  2. 타입 오류 처리가 안된다. request받는 객체가 Int 타입인데, String 객체가 들어오면 Controller를 거치기 전부터 예외가 발생하여 400 에러 페이지로 이동될 것이다.
  3. 타입 오류 처리가 안되기 때문에, 사용자가 타입을 잘못 입력한 경우, 잘못 입력했던 값을 바인딩하지 못한다. 즉, 사용자가 값을 어떻게 잘못 입력했는지 알려줄 수 없다. -> 이를 해결하기 위해서는, 사용자가 입력한 값도 어딘가에 별도로 관리가 되어야 한다.

스프링 - BindingResult

이전에는 직접 validation을 구현했다. 이제는 Spring이 제공하는 검증 오류 보관 객체인 BindingResult를 사용해본다.

BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.

타입 오류 또한 잡을 수 있는데,

  • BindingResult 가 없으면
    400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
  • BindingResult 가 있으면
    오류 정보(FieldError)를 BindingResult 에 담아서 컨트롤러를 정상 호출한다.

BindingResult에 검증 오류를 적용하는 3가지 방법

  • @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 객체를 생성해서 BindingResult 에 넣어준다.
  • 개발자가 직접 넣어준다.
  • Validator 사용 (이것은 뒤에서 설명)

로직에 적용하기

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
    }

	//특정 필드 예외가 아닌 전체 예외
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
        	bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
		}
	}
    
    ... // 중략
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm";
	}
	... // 중략
}
FieldError

필드에 오류가 있는 경우 사용

public FieldError(String objectName, String field, String defaultMessage) { ... }
  • objectName
    @ModelAttribute 객체 변수명
  • field
    오류가 발생한 필드 이름
  • defaultMessage
    오류 기본 메시지
ObjectError

특정 필드에 국한되지 않는 복합적이거나 글로벌 에러 발생 시 사용

public ObjectError(String objectName, String defaultMessage) { ... }
  • objectName
    @ModelAttribute 객체 변수명
  • defaultMessage
    오류 기본 메시지

html

<div th:if="${#fields.hasGlobalErrors()}">
	<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}"> 글로벌 오류 메시지 </p>
</div>

<label for="quantity" th:text="#{label.item.quantity}">수량 </label>
<input type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:errors="*{quantity}"> 수량 오류 </div>
  • #fields
    BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors
    해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.
  • th:errorclass
    th:field 태그 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

FieldError, ObjectError

FieldError를 좀더 자세히 살펴보자.

FieldError는 두 가지 생성자를 제공한다.
ObjectError도 FieldError와 유사하게 두 가지 생성자를 제공하므로, 이곳에서는 FieldError에 대한 내용만 다룬다.

public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage);

첫 번째 생성자는 아까 사용해봤고, 두 번째 생성자를 자세히 살펴보자.

  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
  • defaultMessage : 기본 오류 메시지
... // 중략
if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}

//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
	int resultPrice = item.getPrice() * item.getQuantity();
	if (resultPrice < 10000) {
		bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
	}
}

FieldError와 ObjectError는 위와 같이 객체를 만들어서 bindingResult에 넘김으로서 사용할 수 있다. bindingError는 이전에 설명했듯 아래 3가지 방법으로 다룰 수 있다.
위 방식의 예제는 2번째, 개발자가 생성하는 경우이다.

  • Spring이 생성해주는 경우
    타입 에러가 발생하면 Spring이 알아서 FieldError 객체를 생성하여 BindingResult 객체에 담아준다. 따라서 바인딩에 실패한 입력값도 저장할 수 있다.
  • 개발자가 생성하는 경우
    위 코드와 같이 정책적으로 잘못된 값의 경우 rejectedValue 파라미터에 잘못된 값을 넘긴다. 타입 오류같은 bindingFailure 는 아니기 때문에, false를 넘겨준다.
  • validator 사용 (뒤에서 설명)

FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 여기서는 바인딩이 실패한 것은 아니기 때문에 false 를 사용한다.

타임리프와 BindingResult

th:field="*{price}"
타임리프는 정상 상황에서는 모델 객체의 값을 사용하지만,
에러가 발생한 상황에서는 FieldError에 저장된 값을 사용하기 때문에
위 코드만으로도 정상 상황과 에러 상황을 모두 대응할 수 있다.

오류 코드와 메시지 처리1

메시지 기능을 사용해보자.

errors.properties 정의

src/main/resources/errors.properties
src/main/resources/errors_en.properties > 국제화 가능

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

FieldError객체 수정

//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, new String[] {"range.item.price"}, new Object[]{1000, 1000000}

파라미터별 설명

  • codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다.
    메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데,
    순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.

  • arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

오류 코드와 메시지 처리2

FieldError, ObjectError 객체를 매번 생성하는 것은 번거롭다.
reject(), rejectValue() 메소드를 통해 에러를 추가해보자.

에러 필드 정의 로직 수정

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
	if (!StringUtils.hasText(item.getItemName())) {
		bindingResult.rejectValue("itemName", "required");
	}
    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
		bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
	}
	... // 중략
    
	//특정 필드 예외가 아닌 전체 예외
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
		}
    }
    ... // 중략

위 코드를 실행해보면, 에러 메시지가 잘 나온다.
에러메시지를 전달 하지도, errors.properties에 정의된 코드를 전달한 것도 아닌데 어떻게 에러메시지가 잘 나온걸까?

그 해답은 뒤쪽에 MessageCodesResolver에서 다뤄본다.

reject(), rejectValue() 파라미터

void rejectValue(@Nullable String field,
				 String errorCode,
                 @Nullable Object[] errorArgs,
                 @Nullable String defaultMessage);

void reject(String errorCode,
			@Nullable Object[] errorArgs,
            @Nullable String defaultMessage);
  • field: 오류 필드명
  • errorCode: 오류 코드
    (errors.properties에 정의된 코드가 아닌, 뒤에서 설명한 MessageCodesResolver를 위한 코드이다.)
  • errorArgs: 오류 메시지에서 {0} 을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

오류 코드와 메시지 처리3

MessageCodesResolver가 에러 코드를 탐색하는 방법에 대해 알아보자.

아래와 같은 상세한 에러코드는 자세한 내용을 정의하지만, 범용성이 떨어진다.
required.item.itemName : 상품 이름은 필수 입니다.
range.item.price : 상품의 가격 범위 오류 입니다.

아래와 같은 단순한 에러코드는 범용적으로 쓸 수 있지만, 에러 내용이 두루뭉술하다.
required : 필수 값 입니다.
range : 범위 오류 입니다.

스프링의 MessageCodesResolver는 상세한 에러메시지부터 모호한 에러메시지까지 순차적으로 찾는 기능을 제공한다.

#Level1
required.item.itemName: 상품 이름은 필수 입니다.

#Level2
required: 필수 값 입니다.

오류 코드와 메시지 처리4

MessageCodesResolver가 에러코드를 탐색하는 방법에 대해 조금 더 자세하게 알아보자.

MessageCodesResolver는 인터페이스이고, 별도의 설정이 없다면 기본적으로 DefaultMessageCodesResolver 구현체를 사용한다.

방금 소개한 reject(), rejectValue()의 파라미터에 있는 errorCode가 이곳에 쓰인다.

객체 오류

객체 오류의 경우 다음 순서로 2가지 생성한다.

  • {code}.{objectName}
  • {code}

reject("totalPriceMin"): ObjectError를 생성하여 bindingResult에 추가시키며 다음 2가지 오류 코드를 자동으로 생성한다.

  • totalPriceMin.item // 위 예제 코드에서 @ModelAttribute의 객체명이 item이다.
  • totalPriceMin

필드 오류

  • {code}.{objecName}.{field}
  • {code}.{field}
  • {code}.{field type}
  • {code}

rejectValue("itemName", "required"): FieldError를 생성하여 bindingResult에 추가시키며, 아래 4가지 오류 코드를 자동으로 생성한다.

  • required.item.itemName
  • required.itemName
  • required.java.lang.String
  • required

DefaultMessageCodesResolver가 만들어내는 객체 목록은 아래 코드를 통해서도 확인할 수 있다.

MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
String[] messageCodes2 = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);

오류 코드와 메시지 처리5

MessageCodesResolver 의 핵심은 구체적 -> 두루뭉술 순으로 메시지를 찾는 것이다.
모든 오류 케이스에 대해 구체적인 메시지를 정의하는 것은 시간이 많이 소요되므로
처음에는 두루뭉술한 케이스를 먼저 정의하고, 필요에 따라 구체적인 것을 정의하면 편리하다.

최종적으로 메시지는 아래와 같이 작성하면 구체적 ~ 두루뭉술 까지 대응이 가능하다.
errors.properties

#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level2
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}


#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.

#Level2 - 생략

#Level3
required.java.lang.String = 필수 문자입니다.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.

#Level4
required = 필수 값 입니다.
range= {0} ~ {1} 범위를 허용합니다.

오류 코드와 메시지 처리6

BindingResult에 오류를 적용하는 3가지 중, 스프링이 생성하는 에러에 대해서도 메시지 기능을 적용해보자.

DefaultMessageCodesResolver는 타입 오류의 경우 아래와 같이 4가지 에러메시지를 만들어낸다.
(DefaultMessageCodesResolver().resolveMessageCodes(...) 로 확인 가능)

  • typeMismatch.item.price
  • typeMismatch.price
  • typeMismatch.java.lang.Integer
  • typeMismatch

위 4가지 에러코드도 메시지에 정의해주면, 스프링이 생성하는 에러도 메시지 처리가 가능하다.

Validator 분리1

현재 코드를 보면 검증 로직이 Controller단에 들어가있다.
또한, End-Point가 여러개 생기면 그때마다 컨트롤러에 중복된 검증 로직을 넣는 것은 중복된 코드가 많아진다.
이를 분리해보자.

ItemValidator.java

@Component
public class ItemValidator implements Validator {
	@Override
	public boolean supports(Class<?> clazz) {
		return Item.class.isAssignableFrom(clazz);
    }

	@Override
	public void validate(Object target, Errors errors) {
		Item item = (Item) target;
		ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
		if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
			errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
		}

		if (item.getQuantity() == null || item.getQuantity() > 10000) {
			errors.rejectValue("quantity", "max", new Object[]{9999}, null);
		}

		//특정 필드 예외가 아닌 전체 예외
		if (item.getPrice() != null && item.getQuantity() != null) {
			int resultPrice = item.getPrice() * item.getQuantity();
			if (resultPrice < 10000) {
				errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
			}
		}
	}
}

스프링은 검증을 체계적으로 제공하기 위해 Validator 인터페이스를 제공한다.

public interface Validator {
	boolean supports(Class<?> clazz);
	void validate(Object target, Errors errors);
}

supports() : 해당 검증기를 지원하는 여부 확인(뒤에서 설명)
validate() : 검증 대상 객체와 BindingResult

이제 컨트롤러에서 ItemValidator()를 아래와 같이 명시적으로 호출할 수 있다.
비교적 Controller가 가벼워진 모습이다.

private final ItemValidator itemValidator;

@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
	itemValidator.validate(item, bindingResult);
	... // 중략
}

Validator 분리2

Controller에서 ItemValidator 를 호출하지 말고, 자동으로 호출되게 변경해보자. WebDataBinder를 사용한다.

Controller

private final ItemValidator itemValidator;

@InitBinder
public void init(WebDataBinder dataBinder) {
	dataBinder.addValidators(itemValidator);
}

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
	... // 중략
}

addItemV6(...) 의 Item 객체에 @Validated 를 추가하여 검증 대상 객체임을 명시해야 한다..

만약 @InitBinder 에 여러 검증기들이 등록돼있다면, Item 객체는 어떤 검증기를 통해 검증을 해야할까?
이것을 지원하는 것이 Validator 에 구현한 supports() 이다.
supports() 조건에 맞는 검증기에서 검증을 수행한다.

추가로, @InitBinder를 적용한 클래스에만 검증기가 작동한다.
@InitBinder없이 모든 클래스에 검증기를 적용하고 싶다면, 아래와 같이 스프링부트의 시작점에 검증기를 등록하면 된다.

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Override
	public Validator getValidator() {
    	return new ItemValidator();
	}
}

검증 - Bean Validation

Bean Validation은 특정한 구현체가 아니라 JSR-380이라는 자바 표준 기술이다.
어노테이션을 통해 검증 기능을 지원하며, 대표적인 구현체로는 Hibernate Validator가 있다.

아래와 같은 문법으로 사용된다.

public class Item {
	private Long id;

	@NotBlank // 공백을 지원하지 않음
	private String itemName;

	@NotNull // Null일 수 없음
	@Range(min = 1000, max = 1000000) // 범위 지정
	private Integer price;
    
    @NotNull
    @Max(9999) // 최댓값 지정
    private Integer quantity;
    
    //...
}

Bean Validation을 사용하기 위해서는 의존성을 추가해야 한다.

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'

중복 오류 검증

이전에 구현한 ItemValidator를 해제하지 않으면 오류 검증이 중복으로 일어나므로, 혹시 컨트롤러에 @InitBinder가 적용되어있다면 제거하자.

스프링은 자동으로 글로벌 Validator를 등록한다.

이전에, SpringBoot의 시작지점에 validator를 등록하면 검증기가 글로벌로 등록된다고 했는데, 글로벌로 ItemValidator 를 등록하면 Bean Validator가 자동으로 등록되지 않는다.

따라서 Bean Vaildator를 사용하고 싶다면, 스프링 부트 시작지점에 있는 글로벌 검증기 등록 로직을 제거하자.

@Validated

Bean Validation도 이전에 ItemValidator와 동일하게, 밸리데이션을 원하는 모델에 @Validated 어노테이션을 붙여야 한다.

@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {
    ... // 중략
    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
	if (bindingResult.hasErrors()) {
		... // 중략
    }
}

참고로, @Validated가 아닌 @Valid 어노테이션도 동작한다.
@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다.

에러 코드

Bean Validation의 에러코드는 DefaultMessageCodesResolver에서 아래와 같이 생성된다.

  • @NotBlank
    • NotBlank.item.itemName
    • NotBlank.itemName
    • NotBlank.java.lang.String
    • NotBlank
  • @Range
    • Range.item.price
    • Range.price
    • Range.java.lang.Integer
    • Range
      ...

위 형식에 맞춰 errors.properties에 알맞은 메시지를 추가해주자.

Bean Validation 메시지 찾는 순서

  1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
  2. 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.

오브젝트 오류

지금까지는 FieldError에 대해 알아봤다. ObjectError 는 @ScriptAssert 어노테이션을 통해 검증할 수 있다.

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
	//...
}

생성되는 오류 코드는 아래와 같다.

  • ScriptAssert.item
  • ScriptAssert

하지만 코드가 지저분해지고, 실무에서는 스크립트로도 대응하기 어려운 검증이 존재한다.
따라서, 오브젝트 검증은 기존처럼 자바 로직으로 해결하는게 낫다.

한계

동일한 Item을 사용하면서, 검증 요구사항이 다를 수 있다. 예를 들어

  • 상품 등록 시
    • id: Null 가능
    • quantity: 9999이하
  • 상품 수정 시
    • id: Not Null
    • quantity: 제한 없음

처럼 요구사항이 다를 수 있다.
등록과 수정에 동일한 Item객체를 사용한다면, Bean Validation에 제약이 생긴다.
이를 해결할 수 있는 방법 2가지(groups, 모델 분리)를 소개한다.

groups

저장용 groups 생성

public interface SaveCheck {} // 인터페이스 본문은 공백

수정용 groups 생성

public interface UpdateCheck {} // 인터페이스 본문은 공백

Item - groups 적용

@Data
public class Item {
	@NotNull(groups = UpdateCheck.class) //수정시에만 적용
    private Long id;
    
    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) // 공통으로 적용
    private String itemName;
    
    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
	@Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
    private Integer quantity;
    
    ...
}

Controller 에 그룹 적용

@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
	... // 중략
}

groups를 이용하는 방법은 매번 interface를 새로 생성하여 그룹을 분리해줘야하고,
오히려 코드가 지저분해지는 문제가 발생한다.

그리고 실무에서는 서로 다른 기능을 하는 API의 RequestModel에 동일한 객체를 사용하지 않는다. (완전히 동일한 필드를 사용한다면 재사용하기도 하지만, 서로 다른 필드를 필요로 하게되면 모델을 분리하는게 일반적이다.)

모델 분리

상품 등록용 모델

@Data
public class AddItemRequest {
    private Long id;
    
    @NotBlank
    private String itemName;
    
    @NotNull
	@Max(value = 9999)
    private Integer quantity;
    
    ...
}

상품 수정용 모델

@Data
public class ModifyItemRequest {
	@NotNull
    private Long id;
    
    @NotBlank
    private String itemName;
    
    @NotNull
    private Integer quantity;
    
    ...
}

Reference
https://en.wikipedia.org/wiki/Magic_number_(programming)

profile
A fast learner.

0개의 댓글