컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
클라이언트로부터 오는 입력값을 제대로 검증하지 않는다면, 고치기 힘든 버그가 생길 수 있기 때문이다. 그렇기 때문에 이번 내용은 개발에 있어서 아주 중요한 내용이라고 해도 과언이 아니다.
클라이언트 vs 서버 검증
결론부터 말하자면, 클라이언트 검증과 서버 검증은 모두 필수이다. 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다는 단점이 있지만, 즉각적인 고객 사용성을 올려주는 장점이 있다. 또한 서버 검증은 반대로 정확한 검증을 할 수 있지만, 고객 사용성이 떨어진다는 단점이 있다. 따라서 이 둘을 적절히 섞어서 사용하는 것이 좋다. 만약 API 방식을 사용한다면, API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.
Map으로 검증해보기
@PostMapping("/add")
public String addItem(@ModelAttribute User user, Model model, RedirectAttributes redirectAttributes) {
// 검증 오류 정보 보관
Map<String, String> errors = new HashMap<>();
if (!StringUtils.hasText(user.getUserName()))
errors.put("username", "이름 입력은 필수입니다.");
if (user.getUserId() == null || user.getUserId().length() < 8)
errors.put("userId", "아이디는 8자 이상으로 입력해야 합니다.");
if (user.getPassword() == null || user.getPassword().length() < 8)
errors.put("password", "비밀번호는 8자 이상으로 입력해야 합니다.");
if (user.getUserId() != null) {
if(userRepository.isDuplicateId(user.getUserId()))
errors.put("globalError", "중복 아이디 입니다.");
}
if(!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "/users/addForm";
}
User savedUser = userRepository.save(user);
redirectAttributes.addAttribute("userId", savedUser.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/users/{userId}";
}
위 코드는 가볍게 Map을 사용하여 검증하는 예시를 작성해 본것인데, 별다른 설명 없어도 이해할 수 있을 것이다.
이제 남은 것은 오류가 생긴 경우, 어떻게 템플릿을 클라이언트에게 뿌릴 것인지 고민해 보면 된다.
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
그냥 Model에서 attribute을 꺼내오는 거랑 똑같아서, 간단하다. 하지만 주의해야 할 점은 이런 회원가입 폼 자체에 "${errors.containsKey(...)}로 작성하면, 회원가입 페이지를 처음 방문할 때, errors라는 attribute은 모델에 추가해주지 않았으므로 NPE가 무조건 뜨게 되어 있다. 이를 해결하기 위해 SpringEL에서 제공하는 문법인 Safe Navigation Operator를 사용하면 된다. 방법은 코드와 같이 errors?.으로 참조 앞에 "?"를 추가해주면, errors가 null이라면 그대로 null이 반환된다. th:if 에선 null은 실패로 간주한다.
BindingResult1
하지만, 위 코드는 복잡성이 높고, 코드의 중복도 많다. 가장 큰 문제점은 타입 바인딩 에러의 처리가 이미 argumentResolver에서 예외가 터졌기 때문에 컨트롤러 호출조차 불가능하다는 것이다.
점진적으로 발전시켜서 타입 바인딩 에러도 해결해보자.
@PostMapping("/add")
public String addItem(@ModelAttribute User user
, BindingResult bindingResult
, RedirectAttributes redirectAttributes)
{
// 필드 에러
if (!StringUtils.hasText(user.getUserName()))
bindingResult.addError(new FieldError("user", "username", "이름 입력은 필수입니다."));
// global 에러
if (user.getUserId() != null) {
if(userRepository.isDuplicateId(user.getUserId()))
bindingResult.addError(new ObjectError("user","중복 아이디 입니다."));
}
if(bindingResult.hasErrors()) {
return "/users/addForm";
}
User savedUser = userRepository.save(user);
redirectAttributes.addAttribute("userId", savedUser.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/users/{userId}";
}
이번엔 비밀번호등을 검증하는 코드까지 포함하면 가독성이 떨어져서 제외시켰다.
이 코드에서의 핵심은 BindingResult라는 것인데, 이것에 대해 간략히 알아보자.
BindingResult는 검증 시에 발생한 예외들을 보관해 두는 객체이다. BindingResult가 있으면 타입 바인딩 에러 시에도, 컨트롤러가 정상 호출될 수 있고, 사용자가 잘못 입력한 값들이 지워지지 않고 계속 남아있을 수 있다는 장점이 있다.
- BindingResult bindingResult를 파라미터로 선언할 때 위치는 반드시! @ModelAttribute Item item 다음에 위치해야 한다.
- 필드 에러는 FieldError를, 글로벌 에러는 GlobalError 객체를 bindingResult 객체에 추가해 주면 된다.
타임리프 스프링 검증 오류 통합 기능
// 글로벌 에러
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
// 필드 에러
<div>
<label for="username" th:text="#{label.user.username}">유저명</label>
<input type="text" id="username" th:field="*{username}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{username}">
유저명 오류
</div>
</div>
타임리프는 스프링의 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
- #fields : #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
- th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전
- th:errorclass : th:field에서 지정한 필드에 오류가 있으면 CSS class 정보를 추가한다.
BindingResult2
BindingResult에 대해 더 자세히 알아보자.
만약 @ModelAttribute에 타입 바인딩 에러가 발생한다면?
- BindingResult가 오른쪽에 없다면 : 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
- BindingResult가 오른쪽에 있으면 : 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다.
BindingResult 검증 오류 적용법
- @ModelAttribute 객체 타입 오류 바인딩이 실패하는 경우, 스프링이 알아서 FieldError 객체를 생성해서 BindingResult에 넣음
- 반드시 @ModelAttribute 객체 바로 다음에 와야 한다. 또한 BindingResult는 자동으로 Model에 포함된다.
- 개발자가 직접 넣어준다.
- Validator 사용
BindingResult, Errors
BindingResult 인터페이스는 Errors 인터페이스를 상속받고 있다.
실제 파라미터로 넘어오는 객체는 BeanPropertyBindingResult라는 것이고, 둘 다 구현하므로 Errors를 사용해도 된다.
하지만, Errors는 단순 오류 저장과 조회 기능만을 제공하기 때문에, 추가적인 기능을 제공하는 BindingResult를 사용하는 경우가 더 많기 떄문에 그냥 BindingResult를 사용하면 된다.
FieldError, ObjectError 오류 발생시 값 유지
사용자의 입력 데이터가 컨트롤러의 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력값을 유지하기 어렵다. 왜냐면 이 경우는 int 타입에 "abc"와 같은 값을 넣었기 때문에 값을 넣는것이 아예 불가능하다. 따라서 사용자 입력 값을 보관하는 별도의 저장공간이 필요하고, 이렇게 저장한 사용자 입력 값을 검증 오류 발생 시, 화면에 다시 출력하면 된다. 이때 FieldError는 오류 발생 시 사용자 입력 값을 저장하는 기능을 제공한다.
if (!StringUtils.hasText(user.getUserName()))
bindingResult.addError(new FieldError("user", "username", user.getUsername(), false, null, null));
이렇게 FieldError의 생성자에 사용자가 입력한 값을 넣어주면 된다. 근데 여기에서 하나 또 중요한 것은 타임리프가 영리하게 상황에 따라 적절하게 값을 가져온다는 점이다.
타임리프의 사용자 입력 값 유지
th:field="*{username}"
타임리프의 th:field는 정상 상황에선 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.
FieldError 객체를 초기화 할 때, 생성자로 객체명, 필드명을 넣어주기 때문에 구분이 가능하다.
만약 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 최종적으로 BindingResult에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 오류가 발생해도 잘못된 입력값을 유지할 수 있디.
오류 코드와 메세지 처리 1
하지만, 이렇게 오류 코드를 설정하는 것은 뭔가 번잡하고 눈에 딱 들어오지 않는다. 이번엔 조금 더 자동화를 사용해서 간결한 코드로 바꿔보자.
생각해 보면, BindingResult 객체를 parameter에 넣을 때, 검증해야 할 객체의 바로 다음에 반드시 넣어야 한다고 했었는데, 이 말은 사실 BindingResult는 이미 자신이 검증해야 할 객체의 정보를 알고 있는 것이다. 다음 코드를 확인해 보자.
@PostMapping("/add")
public String addMemberV4(@ModelAttribute User user,
BindingResult bindingResult,
RedirectAttributes redirectAttributes)
{
log.info("object name : {}", bindingResult.getObjectName());
log.info("target : {}", bindingResult.getTarget());
생략 ....
}
// 출력 결과
2023-05-19 19:37:49.220 INFO 57887 --- [nio-8080-exec-1]
h.i.w.v.ValidationUserController : object name : user
2023-05-19 19:37:49.221 INFO 57887 --- [nio-8080-exec-1]
h.i.w.v.ValidationUserController : target : User(id=null, username=, userId=null, password=null)
이렇게, FieldError 객체를 넣기 전부터 bindingResult 객체는 이미 검증 객체의 정보들을 알고 있었다. 이를 이용하여 좀 더 메시지를 간결하게 해주는 메서드인 rejectValue(), reject()를 사용해 보자.
메시지를 다음과 같이 간단히 설정하고 나서
required.user.username=회원명 이름은 필수입니다.
min.user.userId=아이디는 최소 {0}자 이상 입력하셔야 합니다.
min.user.password=비밀번호는 최소 {0}자 이상 입력하셔야 합니다.
@PostMapping("/add")
public String addMemberV4(@ModelAttribute User user,
BindingResult bindingResult,
RedirectAttributes redirectAttributes)
{
log.info("object name : {}", bindingResult.getObjectName());
log.info("target : {}", bindingResult.getTarget());
if(!StringUtils.hasText(user.getItemName()))
bindingResult.rejectValue("username", "required");
if(user.getUserId() == null || user.getUserId().length() < 8)
bindingResult.rejectValue("userId", "min", new Integer[]{8}, null);
if(user.getPassword() == null || user.getPassword().length() < 8)
bindingResult.rejectValue("password","min", new Integer[]{8}, null);
if(bindingResult.hasErrors())
return "validation/users/addForm";
User saveUser = userRepository.save(user);
redirectAttributes.addAttribute("userId", saveUser.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/users/{userId}";
}
객체 이름은 미리 알고 있으니까 생략하고, (필드명, 에러코드, 인자배열, 기본 메시지)를 넣어줘서 이전보다 간결하게 에러메시지를 만들어 낼 수 있다.
rejectValue()의 실제 구현 코드
@Override
public void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs,
@Nullable String defaultMessage) {
if (!StringUtils.hasLength(getNestedPath()) && !StringUtils.hasLength(field)) {
// We're at the top of the nested object hierarchy,
// so the present level is not a field but rather the top object.
// The best we can do is register a global error here...
reject(errorCode, errorArgs, defaultMessage);
return;
}
String fixedField = fixedField(field);
Object newVal = getActualFieldValue(fixedField);
FieldError fe = new FieldError(getObjectName(), fixedField, newVal, false,
resolveMessageCodes(errorCode, field), errorArgs, defaultMessage);
addError(fe);
}
이 코드를 확인해보면 우리가 이전에 직접 FieldError를 생성해서 값을 하나하나 다 작성해줘야 했지만, rejectValue()에서 필요한 값만 전달해 주면, 내부에서 대신 FieldError 객체를 생성해서 에러를 추가해주고 있었다.
한마디로, 대신 FieldError를 생성해 준다고 보면 된다.
그런데 여기서 궁금할만한 것은 에러 코드로 "min", "required"처럼 에러 코드를 축약해서 넣어줬는데, 사실상 확인해 보면 제대로 에러코드를 다 찾아내서 출력이 된다. 이 부분을 이해하려면 MessageCodesResolver라는 것을 이해해야 한다.
중요! MessageCodesResolver
실제 에러 코드는 "min.user.userId"이지만, rejectValue() 메서드를 사용할 때 "min"만 넣어줘도 이것을 찾아낸다고 했는데 무언가 규칙이 있다는 것을 느낀 분들도 있을 것이다. 정답은 "그렇다"이다. 내가 바로 위에서 올린 rejectValue()의 실제 구현 코드를 확인해보면,
FieldError의 생성자 5번째에서 resolveMessageCodes를 호출하고 있는데, 저 위치는 에러 코드 문자열 배열을 넣는 곳이다.
아리쏭 하다면 FieldError의 생성자를 다시 확인해 보면 이해에 도움이 될 것이다.
여기까지 공부하면서 여러분은 ArgumentResolver, ReturnValueHandler, ViewResolver등에 대해서 배웠는데 모두가 동일하게 하는 역할은 "자동화"였다. 이번에도 역시 MessageCodesResolver는 이름 그대로 메세지 코드를 자동 생성해주는 메세지 코드 해결자이다.
이제 MessageCodesResolver가 objectName과 fieldName등으로 메세지 코드를 자동 생성해준다는 것을 알았는데, 어떤 규칙이 있고 만들어지는지 실제 코드로 확인해보자.
public class MessageCodesResolverTest {
private final MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();
// bindingResult 구현체
BeanPropertyBindingResult bp = new BeanPropertyBindingResult(User.class,"user");
@Test
@DisplayName("글로벌 에러 테스트")
void messageCodesResolverObject() {
String[] codes = messageCodesResolver.resolveMessageCodes("required", "user");
// 생성된 코드 출력
Arrays.stream(codes).forEach(System.out::println);
ObjectError oe = new ObjectError("user", codes, null, null);
bp.addError(oe);
// 글로벌 에러 출력
System.out.println(bp.getGlobalErrors());
}
[
// 출력 결과
//생성된 코드 출력
required.user
required
//글로벌 에러 출력
[Error in object 'user': codes [required.user,required]; arguments []; default message [null]]
]
@Test
@DisplayName("필드 에러 테스트")
void messageCodesResolverField() {
String[] codes = messageCodesResolver.resolveMessageCodes
("min", "user", "userId", String.class);
// 생성된 코드 출력
Arrays.stream(codes).forEach(System.out::println);
FieldError fe = new FieldError("user", "userId",
null, false, codes, null, null);
bp.addError(fe);
// 필드 에러 출력
System.out.println(bp.getFieldError("userId"));
}
[
// 출력 결과
//생성된 코드 출력 (우선 순위 순)
min.user.userId
min.userId
min.java.lang.String
min
// 필드 에러 출력
Field error in object 'user' on field 'userId':
rejected value [null];
codes [min.user.userId,min.userId,min.java.lang.String,min];
arguments []; default message [null]
]
}
테스트 코드와 출력 결과를 확인해보면 다음과 같은 것을 알 수 있었다.
- messageCodesResolver는 resolveMessageCodes() 메서드를 사용하여 일정 규칙의 코드들을 String 배열로 생성한다.
- 이후, 생성된 코드 배열을 사용하여, FieldError/ObjectError을 생성한다.
- bindingResult에 addError()을 사용하여 생성한 에러 객체를 담는다.
이렇게 이해하고 나면 분명 깨달은 것이 있을텐데, 내가 직접 공유했던 코드와 완전히 똑같은 것을 대신 해주고 있었다.
마무리로 이 링크를 클릭해서 다시 한번 rejectValue()의 코드를 확인해보면, 이해가 확실히 되지 않을까 싶다.
이제 남은 것은 messageCodesResolver가 어떤 패턴으로 오류 코드를 생성하는지 정리해보겠다
1. 글로벌 오류
1. Code + "." + Object Name
2. Code
ex) Error Code: required, Object Name: user
1. required.user
2. required
2. 필드 오류
1. Code + "." + Object Name + "." + Field
2. Code + "." + Field
3. Code + "." + Field Type
4. Code
ex) Error Code: typeMismatch, object name "user", field "userId", field type : string
1. "typeMismatch.user.userId"
2. "typeMismatch.user"
3. "typeMismatch.java.lang.String"
4. "typeMismatch"
동작 방식 다시 정리
- rejectValue(), reject()는 내부에서 MessageCodesResolver를 사용하여 메세지 코드들을 생성한다.
- FieldError를 생성할 때, 이전 과정에서 생성한 메세지 코드들을 전달한다.
- 마지막으로 bindingResult에 생성한 에러 객체를 담는다.
오류 메세지 출력
타임리프 화면을 랜더링 할 때, th:errors가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메세지 코드를 순서대로 돌아가면서 메세지를 찾는데, 없다면 default message를 출력한다.
오류 코드 관리 전략
핵심은 구체적인 것에서 덜 구체적인 것으로 하는 것.
위에서 보았다시피 MessageCodesResolver는 구체적인 것을 먼저 만들고, 덜 구체적인 것을 만들어 나간다.
모든 오류 코드에 대해서 메세지를 모두 다 구체적으로 정의하면 개발자 입장에서 오히려 관리하기가 더 어려워진다.
따라서 크게 중요하지 않은 메세지는 범용성 있는 "required" 같은 메세지로 끝내고, 정말 중요한 메세지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
타입 바인딩 에러 메세지 해결하기
BindingResult를 통해, 타입 바인딩 에러도 해결이 되었지만 실제로 클라이언트에 보여지는 메세지는 다음과 같은 매우 불친절한 메세지가 확인된다.
이 메세지는 타입 바인딩 에러 발생 시의 기본 메세지이므로, 이 메세지 코드를 "errors.properties"에 다음과 같이 적어두면 된다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=입력값 형식 오류입니다.
다시 확인해보면, 이제야 좀 괜찮은 메세지가 나오는 것을 확인할 수 있다.
'스프링 > 스프링 MVC' 카테고리의 다른 글
검증2 BeanValidation (0) | 2023.05.21 |
---|---|
스프링 MVC 기본 기능 (0) | 2023.05.16 |
스프링 MVC 구조 (0) | 2023.05.15 |
MVC 프레임워크 (0) | 2023.05.14 |
Servlet의 한계, 템플릿 엔진, MVC 패턴 (0) | 2023.05.13 |