개발자로 후회없는 삶 살기

spring PART.검증 본문

[백엔드]/[spring | 학습기록]

spring PART.검증

몽이장쥰 2023. 4. 20. 09:55

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com

 

본론

- 검증 요구사항

기존 프로젝트에 검증 로직을 추가해달라는 요구사항이 추가되었습니다. 타입 검증은 가격, 수량 같은 경우 숫자만 와야하는데 문자가 오는 지 검증하는 것이고, 필드 검증에서는 필드가 null이 안 들어오게 하고 가격의 범위, 수량의 범위 등 각 필드 당 검증 요구사항이 있습니다.

> 지금까지 만든 프로그램은 상품명을 입력 안해도 되고 가격, 수량에 문자를 적으면 시스템이 400 오류를 띄웁니다. 물론 오류 페이지를 예쁘게 띄우면 되긴 하지만 고객 입장에서는 본인이 입력한 게 다 사라집니다. 요즘 어플은 회원가입할 때 어떤게 잘못 됐는지 다 알려줍니다. 이렇게 오류가 떠버리면 고객은 그냥 가입을 안하고 빠져나가 버립니다. 빨간색으로 "잘못 입력하였습니다"를 띄워보겠습니다. 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터는 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려줘야합니다.

 

> 컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것입니다. 정상 로직보다 검증 로직을 잘 개발하는 것이 더 어렵습니다.

 

※ 참고

검증은 클라이언트 검증, 서버 검증이 있습니다. 클라이언트 검증은 js에서 하고 서버 검증은 http 요청이 서버에 넘어와서 컨트롤러 등에서 검증을 합니다. 근데 js 검증은 보안에 취약합니다. postman 열어서 넘기면 된다. 근데 서버 검증은 즉각적인 고객 사용성이 부족해집니다. 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수입니다.

 

- 검증 직접 처리

-> 성공하는 시나리오

상품 등록 폼을 요청하면 등록 폼을 html로 보여줍니다. 고객이 입력하면 컨트롤러에서 저장하고 상품 상세로 리다이렉트를 하도록 했습니다.

 

 

-> 실패 시나리오

등록 폼을 보여주는 건 똑같습니다. post로 add를 날렸는데 고객이 검증 요구사항을 이상하게 해서 이제는 컨트롤러에서 검증 로직을 돌려야합니다. 검증에 실패하면 상품 등록 form을 고객이 넣은 그대로 다시 보여줘야합니다. 값을 유지한 상태로 검증 실패한 부분이 잘못됐다고 메세지를 보여줘야합니다. 모델에 기존 데이터를 다 담고 검증이 실패한 정보도 담고 등록 폼을 다시 렌더링 해야합니다.

> 실패하면 다시 상품 등록 폼을 보여줘야해서 ★ 모델에 다시 잘못된 데이터를 담고 뭐가 잘 못 되었다는 정보도 다 담고 등록 폼에 다시 전달해야합니다. 기존 정보와 error 정보를 담아야합니다.

 

- 개발

post에서 잘못 정보가 오면 컨트롤러에서 뭔가 잘못된 것을 찾아내는 로직을 직접 개발해 볼 것입니다. PostMapping /add 컨트롤러에서 실제 저장이 일어납니다. 아이템에 대한 정보가 다 이 컨트롤러로 옵니다. 이 컨트롤러에서 검증 로직을 작성합니다.

 

> 먼저 오류가 뜨면 실패한 정보도 담는다고 했으니 무슨 오류가 떴나 담아주는 객체가 필요합니다. 검증 오류 결과를 보관하는 Map을 만듭니다. > 검증 로직을 만든다. 요구사항에 맞게 수량에 문자가 들어오면 안되고 null이 안 들어오게하고 범위를 맞추기만 하면 됩니다.

 

1. 상품명 필수

if(!StringUtils.hasText(item.getItemName())) {
    errors.put("itemName", "상품명은 필수로 들어가야 하는 값입니다.");
}

 

2. null, 범위

// 가격
if(item.getPrice() < 1000 || item.getPrice() > 1000000) {
    errors.put("price", "가격은 1000 ~ 1000000입니다");
}

null이거나 범위가 넘으면 errors에 price를 키로 정보를 담습니다.

 

 

3. 수량

// 수량
if(item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    errors.put("quantity", "수량은 최대 9999개입니다.");
}

quantity에 실패 정보를 담습니다.

 

4. 가격과 수량의 합

// 특정 필드의 범위를 넘어서는 검증
if(item.getQuantity() * item.getPrice() < 10000) {
    errors.put("global", "10000원은 넘어야 살 수 있습니다. 현재 값 : " + item.getQuantity() * item.getPrice());
}

너무 작은 값은 서비스에서 안 팔것이라는 건데 이것은 특정 필드가 아닌 복합 룰 검증입니다. 지금까지 key가 하나의 필드였는데 복합적입니다. 특정 필드가 아니니 global 오류라고 하자 이것을 뷰에서 렌더링할 때 errors에 값이 있으면 빨간색으로 실패 정보를 보여주면 됩니다.

 

-> 검증에 실패

// 실패하면 등록 form으로 이동
if(!errors.isEmpty()) {
    log.info("{}", errors);
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}

검증에 실패하면 다시 입력 폼으로 가는 로직을 만들어야합니다. errors가 empty가 아니면 오류 메세지가 하나라도 있으면 모델에 errors 정보를 담고 다시 입력 폼으로 갈 것이기에 그냥 뷰 템플릿으로 보내버릴 것입니다.

> 이것이 if로 return 해버리기 때문에 밑에 성공 로직을 안 타고 나가버립니다. 밑에는 성공 로직을 두면 됩니다.

 

-> 실행

실행해보면 아무 변화가 없습니다. 이게 상품 명을 안 넣어서 컨트롤러에서 그냥 상품 등록 폼으로 다시 보내버려서 그런 것입니다. (X) model에 error만 담았는데 redirect 하는 것이 아니라서 뒤로가기 개념이라서 사용자가 입력한 것이 그대로 남아있나 봅니다. 모델에 담은 것은 에러 메세지 뿐이라는 것을 명심해야합니다.

 

로그를 찍어보면 오류가 발생하고 이 오류를 고객에게 보여줘서 고객이 실패 정보를 알 수 있도록 바꿔야합니다. 지금 사실 addForm은 이상한 게 하나있습니다. 저장을 하면 컨트롤러를 거쳐서 오는 것인데 ★ 가격과 수량이 입력한 것이 그대로 남아있습니다. 그래서 errors만 넣고 뷰 렌더링하면 기존 데이터는 남아있는 줄 알았는데 아까 위에서 모델에는 실패 정보와 기존 정보 2개를 담는다고 했습니다. errors가 실패 정보인데 기존 정보는 어디있나 고민했습니다.

 

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes)

이게 왜 남아있냐면 add post를 하면 @ModelAttribute로 Item이 들어왔었는데 @ModelAttribute하면 모델 객체에 자동으로 model addttribute를 해준다고 했습니다. ( Model을 파라미터에서 없애도 자동으로 모델에 들어간다.) 이것이 이전에 add GetMapping에서 빈 Item을 넣어논 이유가 됩니다. 미리 넣어놨기 때문에 지금도 기존에 입력받은 데이터를 Item에 담아서 넘기는 이런 자연스러운 흐름이 됩니다.

 

이렇게 html에 field를 써서 생긴 value에 전달 받은 기존 값이 들어가는 것을 확인할 수 있습니다.

 

 

-> addForm html 수정

이제 뷰 템플릿을 꾸며보자 기존에 입력한 데이터가 뷰에 전달되는 것은 확인했습니다. 이제 어떤 오류가 발생했는지 오류 메시지를 보여주는 것이 중요합니다.

 

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

먼저 global 오류를 뿌려보자 if를 써서 global이라는 키가 있으면?(컨트롤러에서 put이 됐으면) th:text를 써서 value 값을 꺼냅니다. 예전에 멀티 체크박스할 때 Map에 담아서 뷰에 보낸 적이 있는데 그때는 each로 돌면서 Map의 요소를 전부 다 훑으니 ${regions.key}라고 한 건데 지금은 Map 중 요소 하나를 키로 찾아야하니 네츄럴 템플릿 언어를 이용해서 Map에 접근하는 문법인 errors[' ']로 접근합니다.

 

> 이 결과를 눈에 잘 보이게 하기 위해서 빨간색으로 보여주자 이렇게 하면 고객은 기존 값을 유지하며 오류 정보를 볼 수 있습니다.

 

// 기존
<input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">

// 변경
<input type="text" id="quantity" th:field="*{quantity}"
                   th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'from-control'"
                   class="form-control" placeholder="수량을 입력하세요">
                   
// 더욱 개선
<input type="text" id="quantity" th:field="*{quantity}"
                   th:classappend="${errors?.containsKey('quantity')} ? 'field-error' : '_'"
                   class="form-control" placeholder="수량을 입력하세요">

다음은 itemName을 입력하지 않은 에러, 가격의 범위 등 모든 필드 에러를 뷰에 보이면 됩니다. 박스도 빨간색을 넣어서 강조하고 싶으면 itemName input 태그에 th:class를 해서 오류가 있으면 class 속성이 form-control만 있던 것에서 field-error도 가지도록 하면 style 태그에 만들어 놓은 빨간색 css가 input 태그에도 적용됩니다.

 

결과적으로 오류가 있으면 오류 메시지와 강조를 보여줍니다.

 

-> 정리

만약 검증 오류가 발생하면 입력 폼을 다시 보여주는데 고객이 입력한 데이터는 유지되고 오류 정보가 나옵니다.

 

-> 남은 문제점

1) 뷰 템플릿에 중복이 많습니다. price를 몇 번이나 치는 지 모를 정도입니다.

 

2) 타입 오류는 잡지 않았습니다. 숫자 타입 입력에 문자를 넣으면 안 되는 검증 로직을 만들지 않았습니다.

 

- BindingResult1

이렇게 일일히 뷰에서 에러가 있으면 처리하고 없으면 처리 안하고 등등 하나하나 만드는게 불편합니다. 이제 v2에서 스프링이 제공하는 방법으로 개선해보겠습니다. 지금부터 스프링이 제공하는 검증 오류처리를 배울 것입니다.

 

post mapping, add 메서드를 고칠 것입니다. 이름이 바인딩 결과라는데 Item이 바인딩된 결과가 담긴다. 잘 담길 수 도 있지만 잘 안담겼을 경우에 bindingresult에 뭔가가 담깁니다. 이 bindingresult가 위에서 만든 errors의 역할을 합니다.

 

// 필드 검증
// 상품명
if(!StringUtils.hasText(item.getItemName())) {
    errors.put("itemName", "상품명은 필수로 들어가야 하는 값입니다.");
    bindingResult.addError(new FieldError("item","itemName", "상품명은 필수로 들어가야 하는 값입니다."));
}

// 가격
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
    bindingResult.addError(new FieldError("item","price", "가격은 1000 ~ 1000000입니다"));
}

// 수량
if(item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.addError(new FieldError("item","quantity", "수량은 최대 9999개입니다."));
}

// 특정 필드의 범위를 넘어서는 검증
if(item.getQuantity() * item.getPrice() < 10000) {
    errors.put("globalError", "10000원은 넘어야 살 수 있습니다. 현재 값 : " + item.getQuantity() * item.getPrice());
    bindingResult.addError(new ObjectError("item","상품명은 필수로 들어가야 하는 값입니다."));
}

errors map을 지우고 addError(new FieldError())로 필드 단위의 에러는 이렇게 처리합니다. 파라미터는 objName과 field, 메세지 3개를 넣어야하는데 obj는 modelAttiribute에 담기는 그 이름을 넣으면 되고 필드명 넣고 메세지를 넣으면 됩니다. 3개의 필드 오류는 fieldError로 합니다. bindingresult이 아까 만든 errors를 대신하는 그저 오류와 메세지를 담는 애일 뿐이고 필드에러는 fieldError로 하는 것입니다.

> 글로벌 오류도 bindingresult에 담을 것인데 특정 필드 오류가 아닙니다. new ObjError를 해서 objName은 item으로 하고 메세지를 넣으면 됩니다. 지금 해야하는게 타입 검증을 만들고 뷰의 검증 오류의 중복을 없애야하는 것인데 그것을 하는 과정에서 bindingresult을 만들었고 bindingresult은 그저 errors 역할을 하는 것으로 errors는 map으로 키, 벨류만 가지고 있었는데 br은 ObjName을 파라미터로 가지고 있는 것 외에 차이가 없습니다.

 

-> 검증 실패시

// 실패하면 등록 form으로 이동
if(bindingResult.hasErrors()) {
    log.info("{}", bindingResult);
    return "validation/v2/addForm";
}

bindingresult.hasErrors로 에러가 있으면 뷰로 리턴하게 합니다. 이전에 errors는 모델에 담아서 뷰에 뿌렸어야했는데 bindingresult은 안 담아도 됩니다. bindingresult은 자동으로 뷰로 넘어갑니다. 이렇게 하면 컨트롤러는 완성입니다. 뷰에 bindingresult을 적용해야합니다.

 

-> 코드 설명

★ bindingresult 파라미터의 위치는 ModelAttribute 바로 뒤에 와야만 합니다. 

 

1) 필드 오류는 똑같은데 bindingresult에 에러 정보를 담는 것입니다. 이름 그대로 바인딩이 된 결과인데 바인딩이 잘 안 됐으니깐 여기에 에러를 직접 담아주는 것입니다. errors는 바인딩이 잘 안됐을 때 put으로 errors에 담았던 것과 똑같은 목적입니다. 3개의 파라미터는 ModelAttribute에 담기는 객체이름, 오류가 발생한 필드이름, 오류 메세지입니다.

 

2) 글로벌 오류는 objError를 넣고 2개의 파라미터는 얘는 필드가 없고 "obj 자체에서 오류가 나버렸다고 해서" objName과 메세지만 넣으면 됩니다.

 

-> 뷰 수정

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

// 후
<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}"></p>
</div>

errors를 bindingresult로 바꿔야합니다. 문법이 있습니다. #fields로 has로 에러를 찾습니다. 먼저 globalError를 잡아보겠습니다. 이 글로벌 오류는 하나일 수도 있지만 여러개 일수도 있을 것입니다. 그 중 한개를 찾으려면 th:each로 글로벌 오류를 하나씩 훑어서 th:text에 담겨주면 됩니다. 그러면 이전에 글로벌 오류라고 넣어놓은 ObjError가 잡힐 것입니다.

 

+ 글로벌 오류가 여러 개가 뜨는 경우는 글로벌 오류는 필드 개념이 아니니깐 그냥 객체 자체에서 일어나는 오류로 봅니다. 근데 글로벌 오류들이 각각 일어나는 상황은 다를 것입니다. 다른 상황마다 if로 addError를 하게 되면 bindingresult에 모든 컨트롤러에 작성해 놓은 모든 글로벌 오류 중에서 if가 참이 되어(검증 오류가 발생하여) addError로 더해진 것들이 위에 each문으로 전부 다 메세지를 뿌릴 것입니다.

 

ObjError를 상품명에 null 넣을 때 참이 되는 if문에 넣고

 

실행을 해보면 th:each문으로 addError된 global 오류들이 다 나옵니다.

 

+ 문법을 외워보자면 "th:if에 has를 써서 오류를 가지고 있다면?"이고 th:text에 담기는 것은 그냥 오류 메세지일 것입니다. 이전에 errors를 쓸 때 Map이라서 키, 벨류가 있었는데 키는 그냥 에러 메세지를 찾기 위함이었고 화면에 보이는 것은 에러 메세지라는 것을 명심해야합니다.

 

// 전
<input type="text" id="itemName" th:field="*{itemName}"
       th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'from-control'"
       class="form-control" placeholder="이름을 입력하세요">
<div th:if="${errors?.containsKey('itemName')}">
    <p class="field-error" th:text="${errors['itemName']}"></p>
</div>

// 후
<input type="text" id="itemName" th:field="*{itemName}"
       th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}"></div>

색깔 바꾸는 것은 이전에는 에러가 있으면 class에 위에 만든 style class 속성을 붙이는 거 였는데 이를 한 번에 해결해주는 th:errorclass를 쓰면 끝입니다. 진짜 엄청 단순해집니다. th:field와 연결되는 것으로 th:field="*{itemName}"이면 이 itemName과 관련된 오류가 있으면 field-error를 class 속성에 추가해줍니다. 위에 *{}과 같은 개념으로 field=*{}에 있는 필드 이름으로 bindingresult에 오류가 있으면 class에 속성을 더해줍니다. 이 오류가 있는지를 다 bindingresult을 봐서 찾는습니다. bindingresult은 if문으로 에러가 있으면 addError 되었던 errors와 똑같은 거였으니 에러가 있으면 bindingresult에 에러가 담기는데 

> bindingresult에 오류가 있으면 이런 검증 처리를 해줍니다. 이 지저분한 로직을 다 하나로 바꿀 수 있습니다. 오류 조건 처리가 다 들어가 있는 것입니다. 여기서는 글로벌 오류는 has문법으로 했고 필드 오류처리는 *{}로 했습니다.

 

-> bindingresult 결과

price 관련 필드에러와 Obj 에러를 발생시키면

 

Field error in object item on field price로 필드에러가 났다고 알려주고, Error in object로 Obj 에러가 일어났다고 알려줍니다.

 

 

- bindingresult2

bindingresult은 스프링이 제공하는 검증 오류를 보관하는 객체로 검증 오류가 발생하면 여기에 보관이 됩니다. bindingresult이 있으면 @ModelAttribute에 데이터 바인딩시 오류가 발생해도 컨트롤러가 호출됩니다.

 

 

ex) ModelAttribute에 바인딩 시 타입 오류가 발생하면?

가격을 입력할 때 'qqq' 문자를 넣으면 이상한 문자가 나옵니다. bindingresult을 다 주석처리하고 돌리면 404 오류페이지로 가버립니다. bindingresult이 있을 때는 일단 컨트롤러가 호출이 됩니다. 어떤 게 문제가 있는지 bindingresult에 무조건 담깁니다. 

 

> bindingresult이라는 것은 이름 그대로 스프링이 item에 ModelAttribute로 값을 바인딩하고 있습니다. 근데 이것은 "정수 타입이라서 str이 안 들어가는데?" 싶으면 바인딩하는데 문제가 발생한 것이고(데이터 바인딩 시 오류 발생) 그러면 그 문제를 bindingresult에 담아줍니다. 담고 컨트롤러가 정상 호출이 됩니다. 근데 bindingresult이 없으면 item에 바인딩할 때 타입 에러로 오류 페이지를 띄어버립니다. 즉 bindingresult이 있으면 오류가 있어도 바인딩이 안되고 컨트롤러가 호출이 되는데 없으면 400대 오류가 뜨면서 컨트롤러가 호출이 되지 않고 오류 페이지로 이동합니다.

 

> 이런 것은 특정 필드 오류로 스프링이 똑같이 new FieldError를 만들고 그 오류를 bindingresult에 담습니다. 그래서 로그로 찍어보면 개발자가 담지 않은 스프링이 담은 오류가 나오는 것입니다.

 

※ 참고

if(bindingResult.hasErrors()) {
    log.info("타입 에러");
    return "validation/v2/addForm";
}

이 경우 price에 null이 들어가서 bindingresult에 스프링이 직접 만든 오류, if로 넣은 비즈니스 오류가 2개 들어갑니다. 그럴 경우 맨 위에 이것을 넣어서 타입 에러가 발생하면 바로 뷰로 돌아가게 합니다. 안 그러면 에러 정보가 뷰에 1개의 필드에 2개가 뜹니다.

 

-> bindingresult에 검증 오류를 적용하는 3가지 방법

1. 객체의 타입 오류로 바인딩이 실패하는 경우 스프링이 field Error를 자동으로 bindingresult에 담습니다. Objname은 타입 오류가 뜬 객체, 필드이름은 타입 오류가 뜬 필드, 오류 메세지는 뷰에 나온 빨간 defualt 메세지를 넣습니다.

 

2. 개발자가 직접 넣는 것으로 if문으로 addError를 개발자가 넣는 위에서 한 방법

 

3. validator 방법입니다. 크게 보면 2개로 2번이 비즈니스적 검증 오류이고 1, 3번이 타입 같은 문법 오류입니다.

 

- fieldError, ObjectError

그런데 오류가 발생한 경우 기존에 입력한 내용이 다 사라졌습니다. 원래는 postmapping에 item을 ModelAttribute로 자동으로 모델에 넣어주는데 지금 뭔가 데이터들이 유지가 안되고 있습니다.

 

-> 기존 입력값 유지하는 법

//이전
if(!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item","itemName", "상품명은 필수로 들어가야 하는 값입니다."));
}

//이후
if(!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item","itemName", item.getItemName(), false, null, null,"상품명은 필수로 들어가야 하는 값입니다."));
}

값을 보존하는 것을 만들어야합니다. 필드 에러를 보면 rejectedValue를 넣을 수 있습니다. 거절된 값으로 사용자가 입력한 틀린 값일 것입니다. 그리고 bindingFail을 넣어야하는데 데이터 자체가 넘어오는데 실패했나를 묻는것이기에 false를 넣습니다.

> ObjError는 기존 데이터를 보관할 필요가 없습니다. 글로벌 오류라서 필드 관련한 값이 넘어오는 것이 없습니다. 

> 실행해보면 값이 유지가 된다. 진짜 대단합니다.

 

-> 필드에러 생성자 파라미터 목록

객체이름, 필드명, 사용자가 입력한 값, 타입 오류로 바인딩 자체 실패인지는 스프링이 직접 bindingresult에 넣어준 타입오류는 타입 자체가 오류라서 True로 되어있을 테지만 우리 같은 경우는 비즈니스 로직 검증 오류라서 값은 넘어오므로 false입니다. 마지막으로 기본 오류 메세지가 있습니다.

 

-> 오류 발생시 사용자 입력 값 유지 방법

사용자 입력값을 필드 에러가 가지고 있는 것입니다. 사용자 입력값이 유지가 되는데 원래는 타입이 맞으면 item이 모델에 addAttribute가 됩니다. 하지만 지금은 price에 타입이 안 맞아서 price값은 null이다. price 값이 null이니 거절됐다는 것이고 거절된 값이 reject 값으로 들어왔습니다.

 

대신 스프링이 qqq가 들어오면 타입이 안 맞으면 bindingresult의 rejected value에 넣어주는 것입니다. bindingresult에 qqq가 담겨있으므로 뷰로 return해도 값이 남는 것입니다. >

 

타임리프에도 값이 남는 이유는 field 속성이 해주는 것인데 field는 정상상황에서는 *{}로 값을 그냥 가져다 씁니다. 근데 오류 상황에서는 fieldError에 보관한 값을 출력을 해줍니다.

 

결과를 보면 field가 value를 만들어줄 때 자동으로 rejectvalue 값을 넣어줍니다.

 

- 오류 코드와 메세지 처리1

지금까지 생성한 필드 에러를 보면 기본 메세지를 넣었습니다. 그런데 이런 메세지도 한 군데에서 일관성으로 관리하면 좋을 것입니다. 이런 오류 메세지도 messages.properties처럼 일관성있게 관리를 할 수 있습니다. 필드 에러의 생성자를 보면 codes와 args 파라미터가 있는데 이것을 활용해서 먼저 메세지를 찾고 없으면 기본 메세지를 출력하게 합니다.

 

-> errors 메세지 파일 생성

errors.properties 파일을 하나 만듭니다. 이것을 messageSource가 포함하게 만드려면 application properties에 등록을 해야합니다. spring.messages.basename=messages, errors를 넣으면 사용가능합니다.

 

errors 메세지 파일에 메세지를 key, value로 넣습니다. 메세지 파일에 키는 규칙이 있습니다. required.item.itemName은 오류코드.객체명.필드명입니다.

 

이렇게 메세지를 어딘가에 일관적으로 정의를 해놓고 이것을 필드 에러의 code와 args로 불러다 씁니다. 그러면 그냥 기본 메세지를 쓴 것과 똑같이 뷰에 나타납니다.

 

-> 코드 수정

if(!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item","itemName", item.getItemName(), false, new String[] {"required.item.itemNam", "range.item.price"}, null,"상품명은 필수로 들어가야 하는 값입니다."));
}

먼저 itemName이 입력되지 않았을 때 발생하는 필드 에러 메세지를 메세지 파일에서 가져다 써보겠습니다. 뷰에서는 #{} 이렇게 했었는데 컨트롤러에서는 String 배열로 넣습니다. 배열로 넣는 이유는 배열에 여러개를 넣어서 첫번째 메세지를 메세지 파일에서 찾아보고 없으면 두번째 메세지를 찾고 그런 것입니다.

 

if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
    bindingResult.addError(new FieldError("item","price", item.getPrice(),false, new String[] {"range.item.price"}, new Object[]{1000, 10000}, null));
}

args는 new Object 배열로 넣습니다. > 이렇게 모든 메세지를 다 메세지 파일에서 가져다 쓰게 바꿉니다.

 

실행해보면 메세지 파일에 적은 대로 잘 동작합니다.

 

 

- 오류코드와 메세지 처리 2

필드 에러와 obj 에러를 다루는 게 파라미터도 너무 많고 오류 코드도 자동화하고 싶습니다. 컨트롤러에서 bindingresult은 modelAttribute 바로 뒤에 와야한다고 했습니다. 그 뜻은 bindingresult은 본인이 이미 검증해야할 객체를 이미 알고 있다는 것입니다. 

로그를 찍어보면 bindingresult.addError에 new 필드에러() 파라미터로 objname을 넣어주기도 하지만 그 전에 이미 알고 있습니다. target이 실제 Item 객체가 들어있습니다. > 결과적으로는 bindingresult은 오류를 검증할 target 객체를 이미 알고 있다는 것입니다. 이것으로 코드를 더 줄일 수 있습니다.

 

-> rejectvalue(), reject()

bindingresult이 제공하는 rejectvalue(), reject()를 사용하면 필드 에러, obj 에러를 쓰지 않고 깔끔하게 검증 오류를 다룰 수 있습니다. 이제 이 코드들을 rejectvalue(), reject()를 쓰는 것으로 바꿉니다. rejectvalue는 필드 에러이고 reject는 obj에러입니다.   

 

-> 코드 개선

// 기존
if(!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item","itemName", item.getItemName(), false, new String[] {"required.item.itemName", "range.item.price"}, null, null));
}

// 이후
if(!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}

// 유틸리티
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "price", "range", new Object[]{1000, 1000000}, null);

rejectvalue 파라미터는 첫 번째로 필드가 나옵니다. 이유는 bindingresult이 이미 objectname을 알고 있기 때문입니다. 두번째로 code로 기존에는 Str 배열로 넣어줬는데 이제는 더 쉬워집니다. 기존에는 "required.item.itemName" 이렇게 했는데 이제는 "required"만하면 됩니다. 원래 앞 뒤로 있던 objname, rejectedvalue 이런게 싹 사라집니다. rejectedvalue도 내부에 가지고 있습니다.

 

 

// 이전
if (resultPrice < 10000) {
    bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
}

// 이후
if (resultPrice < 10000) {
    bindingResult.reject("totalPriceMin", new Object[]{1000, resultPrice}, null);
}

price, quantity 다 똑같이 쓰면 됩니다. global 에러는 reject로 합니다. 이때는 필드명도 안 넣었으니 메세지와 파라미터만 넣어주면 됩니다.

 

> 실행해보면 잘 나옵니다. 근데 잘 나오는게 신기합니다. itemName에 rejectvalue에 코드를 required만 적어줬지만 required.item.itemName를 가져옵니다. 이유는 에러 코드.obj명.필드명입니다. 근데 사실 messageCodeResolver라는 것이 추가됩니다. 결론적으로는 bindingresult가 objname를 이미 알고 있기에 addError 메서드를 reject 메서드로 바꾸면서 간편해졌습니다.

 

- 오류코드와 메시지 처리3

어떤 식으로 오류코드를 설계할 것인가를 배웁니다. 앞에 required만 간단하게 입력해도 오류 메세지를 잘 가져옵니다. 뭔가 규칙이 있는 것인데 오류 메세지 파일을 보면 되게 디테일하게 적혀있습니다. 근데 그냥 "required=상품 이름은 필수입니다." 이렇게 할 수도 있을 것입니다. (이러면 그냥 required와 사실 required.objname.필드명인데 required만 쓴 것과 어떻게 구분할까요?)

> 개발하다보면 간단하게 메세지를 해도 되고 사용자에게 자세하게 설명을 해야하면 디테일하게 설계를 해야합니다. 이게 장단점이 있습니다.

> 단순하게 쓰면 범용성이 좋습니다. 아무대서나 막 가져다 쓰면 됩니다. > 세밀하게하면 상품과 관련된 것에서만 쓸 수 있습니다. 그래서 가장 좋은 방법은 범용성으로 사용하다가 세밀하게 작성해야하는 경우 선택적으로 세밀한 것을 선택합니다. 이렇게 메세지에 단계를 두는게 좋습니다.

 

※ 참고

보면 위에서 말한 말이 틀렸습니다. required.item.itemName은 상품이 들어가는 디테일한 것으로 ="상품 이름은 필수입니다."이고 required는 ="필수 값 입니다."만 해서 상품을 빼고 범용적으로 쓰는 용도입니다.

 

ex) required 오류 코드를 사용 예제

다음과 같이 required 메세지만 있으면 이 메세지를 선택합니다. 근데 하다보니깐 상품은 사용자에게 나가는 중요한 메세지가 많으니 자세하게 하자고 결정하면 에러코드를 properties에 추가할 것입니다. 그러면 rejectvalue에도 required.item.itemName 이렇게 쓸 것입니다. 이렇게 해야 당연히 키, 벨류로 매칭이 됩니다. (required.item.itemName여도 required로 쓰는 것은 일단 생각하지 않습니다. )

 

근데 더 좋은 방법이 있습니다. rejectvalue에 코드는 그냥 properties에 1개만 있을 땐 required라고 씁니다. 그러다가 상품 이름은 자세했으면 좋을 때는 required.item.itemName을 추가하고 우선순위를 디테일한 애를 먼저 줍니다. 디테일한 게 있으면 디테일한 애로 하고 없으면 기본 범용적인 애를 찾습니다. new String[] {"required.item.itemName", "required"} 이것과 같은 개념입니다. 이렇게 하면 개발자는 bindingresult.reject()에는 개발을 한번만 "required"를 하면 되는 것이고 나중에 뭔가 메세지가 추가되어야하면 errors.properties만 추가하면 되고 디테일한 것이 먼저 선택되고 범용적인게 나중에 선택이 됩니다. 이러면 기획이 바뀔 때마다 빠르게 변화할 수 있는 좋은 개발이 됩니다.

 

- 오류코드와 메시지 처리4

정리해 보자면 세세한게 먼저 선택이 됩니다. 이것을 실제 스프링이 MessageCodesResolver로 다 구현을 해놓았습니다.

 

테스트에 패키지와 test 클래스를 만듭니다. MCR 인터페이스를 만듭니다. 얘는 에러코드를 하나 넣으면 여러개의 값들을 반환해줍니다. required를 넣으면 싹다 반환을 해줍니다. 

> mcr에 에러코드와 obj이름을 주면 String 배열에 2가지 메세지 코드를 뱉습니다. 이 메세지 코드를 new objError 생성자에 파라미터로 new String[] {"", ""}로 순서대로 넣습니다. 그래서 순서에 맞게 우선순위가 선택이 되는데 mcr의 반환 결과를 보면 자세한게 먼저, 범용적인게 나중에 나옵니다.

 

 

> mcr에 에러코드와 obj이름과 필드명을 주면 4가지를 뱉습니다. 그래서 컨트롤러에서 rejectValue에 필드이름과 에러코드를 넣어준 것입니다.

이렇게 하면 rejectvalue안에서 msr을 호출해서 에러코드가 4가지가 나오고 new 필드에러를 만들고 bindingresult이 objname은 알고 있다고 했으니 new FieldError("item", "itemName", codes, args, null,,,) 이거를 넘깁니다. 여기서 codes가 msr이 만든 에러코드들입니다. 이게 new String으로 순서대로 들어가서 순위가 선택이 되는 것입니다.

 

디테일한 순서를 보면 에러코드.obj명.필드명 > 에러코드.필드명 > 에러코드.타입 > 에러코드입니다. 그러니 에러 메세지 파일에 키를 4가지를 할 수 있겠습니다. 이 4가지의 순서를 잘 기억하고 에러 메세지를 파일에 작성해야겠습니다. 위는 필드오류고 객체 오류는 에러코드.obj명 > 에러코드입니다. 이 모든 것을 bindingresult이 가지고 있는 것이라서 bindingresult.reject 내부에서 되는 것입니다.

 

- 오류코드와 메시지 처리5

오류 코드 관리 전략은 구체적인 것에서 덜 구체적인 것입니다. 근데 이렇게 복잡하게 사용하는 이유는 보통 크게 중요하지 않은 범용성있는 메세지만으로 끝내고 정말 중요한 메세지는 구체적으로 적기 위함입니다.

 

-> 메세지 파일 고치기

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

#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}

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

#Level2 - 생략

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

#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.

이제 메세지 파일만 고치면 됩니다. > 수정한 파일을 보면 단계가 있습니다. objerror는 2가지를 만들었습니다. 필드에러는 레벨1은 진짜 디테일한 것, 3은 타입, 4에서 간단하게 썼습니다. rejectvalue에서 메세지를 파일에서 선택할 때 레벨1이 먼저 매칭이 되고 없으면 2 > 3 > 4가 매칭이 되는 원리입니다.

 

-> 결론

어플리케이션 코드는 변경없이 error 메세지 파일만 바꿔서 변경할 수 있습니다. 사실 bindingresult에는 오류코드가 수십개가 박혀있지만 말입니다.

 

- 오류코드와 메시지 처리6

 

바인딩 타입 에러가 났을 때 스프링이 직접 만든 오류메세지를 처리하는 것을 알아보겠습니다. 지금은 qqq를 넣으면 스프링이 직접 넣은 에러 메세지가 나옵니다.

 

스프링이 직접 bindingresult에 담은 오류도 필드 에러와 동일하게 에러 코드가 4가지가 나옵니다. 타입 에러도 사실 필드에러니깐 이렇게 뜹니다. rejectvalue에 typeMismatch라고 에러코드에 줘서 내부에 mcr이 4개 만든 것입니다. > 근데 이것에 대한 벨류인 메세지가 없습니다. 그러면 스프링이 내부 defualt message를 만들어서 보여주는 것입니다.

 

 

-> 변경

여기서도 똑같은 메커니즘을 적용하면 됩니다. error 메세지 파일에 이 4개를 넣습니다.

실행해보면 지금 코드를 하나도 변경하지 않고 에러 메세지 파일만 건드려서 결과를 바꾼 것입니다. 이 error.properties 파일을 설계하는 것으로 오류 메세지를 설계하는 인사이트를 얻었습니다.

 

- validator 분리1

검증 로직이 컨트롤러에 너무 많습니다. 진짜 로직이 어디에 있는지 모르겠습니다. 검증 로직을 모아두는 역할을 따로 만들어서 컨트롤러는 호출만 하고 검증은 validator가 하게 할 것입니다. 이렇게 하면 코드가 훨씬 깔끔해집니다.

 

-> 구현

@Slf4j
@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(bindingResult, "itemName", "required");

        if(!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }


        // 가격
        // ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "price", "range", new Object[]{1000, 1000000}, null);

        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() < 1 || item.getQuantity() > 9999) {
            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[]{1000, resultPrice}, null);
            }
        }
    }
}

validator 인터페이스를 구현합니다. Item을 검증하는 validator니깐 이 validator가 Item을 검증할 수 있는 것인지 서프트 메서드를 만듭니다. 예전에 RequestMapping을 지원하는 어댑터를 찾기 위해 서포트 메서드를 실행했었습니다.

> 그리고 검증 로직을 validate에 구현합니다. 파라미터로 target고 errors가 옵니다. target은 역시 Item라서 캐스팅을 하고 errors는 br의 부모입니다. 그래서 br대신에 errors를 쓰면 됩니다. > 컨트롤러에 작성했던 검증 로직을 다 여기에 넣습니다.

 

-> 컨트롤러 구현

public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

컨트롤러에는 검증기를 호출하는 메서드를 만들어야합니다. 검증기를 @Component를 붙여서 컴포넌트 스캔을 해서 스프링 빈에 등록하고 컨트롤러에서 주입합니다.

 

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

    if(bindingResult.hasErrors()) {
        log.info("타입 에러");
        return "validation/v2/addForm";
    }

    // 실패하면 등록 form으로 이동
    if(bindingResult.hasErrors()) {
        log.info("{}", bindingResult);
        return "validation/v2/addForm";
    }


    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

Post add 메서드에서 불러다 쓰면 br에 검증 메세지들이 다 담기고 똑같이 동작합니다.

 

- validator 분리2

// 컨트롤러
@InitBinder
public void init(WebDataBinder dataBinder) {
    log.info("init binder {}", dataBinder);
    dataBinder.addValidators(itemValidator);
}


// 맵핑 메서드
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

스프링의 validator 인터페이스를 만들면 추가적인 도움을 받을 수 있습니다. webDataBinder를 사용해야 하는데 이것을 사용하려면 @InitBinder를 쓰고 init 메서드에서 itemValidator를 사용합니다. 이렇게 되면 이 컨트롤러 맵핑 메서드가 호출이 될 때마다 항상 검증기가 불려져서 검증기를 적용할 수 있습니다.

> 이제 v6을 만듭니다. 한가지 @를 더 넣는데 @Validated를 넣으면 item에 대해서 자동으로 검증기가 수행이 됩니다. 이렇게 하면 검증 다 하고 그 결과가 br에 담겨있습니다. br에 담겨있으니 모델에 넣지 않아도 되고 뷰를 호출하면 오류 정보가 넘어갑니다. @하나로 검증기를 굉장히 편리하게 적용할 수 있습니다.

 

> @Validated를 붙이면 WebDataBinder에 등록한 검증기를 찾아서 검증기를 실행합니다. 그래서 여러개의 검증기를 구분할 게 필요한데 이때 validator의 서포트 메서드가 사용되어서 ModelAttribute로 넘어온 객체를 obj 객체로 쓰는 검증기가 선택이 됩니다. 이것은 @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();
	}
}

이렇게 하면 @validated만 넣고 컨트롤러에 @InitBinder가 붙은 메서드 없아도 검증기를 사용할 수 있습니다.

Comments