Search

스프링 MVC 2편

메시지, 국제화

메시지
HTML에 하드코딩된 메시지들을 하나하나 고치는 것은 버거운 작업이다.
<!-- messages.properties --> item=상품 item.id=상품 ID item.itemName=상품명 item.price=가격 item.quantity=수량
HTML
복사
<!-- addForm.html --> <label for="itemName" th:text="#{item.itemName}"></label> <!-- editForm.html --> <label for="itemName" th:text="#{item.itemName}"></label>
HTML
복사
properties 파일에 key=value 형식으로 메시지를 설정해두고 HTML에서 불러와 사용할 수 있다.
국제화
메시지를 설정한 properties 파일을 각 나라별로 별도로 관리하면 요청에 따라 국제화를 할 수 있다.
<!-- messages_en_properties --> item=Item item.id=Item ID item.itemName=Item Name item.price=price item.quantity=quantity
HTML
복사
<!-- messages_ko_properties --> item=상품 item.id=상품 ID item.itemName=상품명 item.price=가격 item.quantity=수량
HTML
복사
HTTP의 accept-language 헤더값을 사용하거나 사용자가 언어를 선택하면 쿠키 등을 사용하여 요청에 따라 다르게 표시되도록 국제화를 할 수 있다.

스프링의 메시지, 국제화

스프링은 기본적인 메시지 관리 기능을 제공한다.
MessageSource를 빈으로 등록하면 되는데 스프링 부트가 자동으로 해준다.
@Bean public MessageSource messageSource() { ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); messageSource.setBasenames("messages", "errors"); messageSource.setDefaultEncoding("utf-8"); return messageSource; }
Java
복사
ResourceBundleMessageSource : MessageSource는 인터페이스여서 구현체를 스프링 빈으로 등록해야 한다.
basenames : 설정 파일의 이름을 지정한다.
messages로 지정하면 messages.properties를 읽어서 사용한다.
국제화 기능을 적용하려면 파일명 뒤에 언어 정보를 붙이면 된다. (messages_en.properties)
파일이 없다면 messages.properties가 기본적으로 사용된다.
defaultEncding : 인코딩 정보를 지정한다.
spring.messages.basename=messages,messages_en
Java
복사
//messages.properties hello=안녕 hello.name=안녕 {0} label.item=상품 label.item.id=상품 ID label.item.itemName=상품명 label.item.price=가격 label.item.quantity=수량 label.item.saved=저장 완료 page.items=상품 목록 page.item=상품 상세 page.addItem=상품 등록 page.updateItem=상품 수정 button.save=저장 button.cancle=취소
Java
복사
MessageSource를 빈으로 등록하지 않고 별도의 설정을 하지 않는다면 messages를 기본으로 등록한다.
국제화를 위해 메시지 소스 설정을 추가하려면 application.properties에 위처럼 추가해주면 된다.
@SpringBootTest public class MessageSourceTest { @Autowired MessageSource ms; @Test void helloMessage() { String result = ms.getMessage("hello", null, null); assertThat(result).isEqualTo("안녕"); } @Test void argumentMessage() { String message = ms.getMessage("hello.name", new Object[]{"Spring"}, null); assertThat(message).isEqualTo("안녕 Spring"); } @Test void enLang() { assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello"); } ...
Java
복사
properties로 설정한 메시지 소스는 MessageSource의 getMessage()를 통해 가져올 수 있다.
ms.getMessage(code, args, defaultMessage, locale)
만약 해당 인자가 없다면 basename에서 설정한 기본 파일에서 조회한다.
메시지에 매개변수를 전달하려면 properties에 {}로 설정해야 하고 getMessage엔 객체로 전달해야 한다.
<!-- 렌더링 전 --> <div th:text="#{label.item(${item.itemName})}"></div> <!-- 렌더링 후 --> <div>상품</div>
HTML
복사
타임리프에선 th:text 와 #{…}를 함께 사용한다. 표현식 사이엔 메시지 코드를 적어준다.
파라미터 사용 시 #{…(${…})} 처럼 사용한다.

LocaleResolver

Locale 선택 방식을 변경할 수 있는 인터페이스이다.
AcceptHeaderLocaleResolver : HTTP 헤더의 Accept-Language를 활용하는 구현체

검증 Validation

컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
클라이언트 검증, 서버 검증
클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
서버만으로 검증하면 즉각적인 고객 사용성이 부족해진다.
둘을 적절히 섞어서 사용하되 최종적으로 서버 검증은 필수이다.
API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.
정상 로직
사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면 서버에서는 검증 로직이 통과하고 상품을 저장하고 상품 상세화면으로 Redirect한다.
검증 실패 로직
고객이 상품 등록 폼에서 상품명을 입력하지 않거나 가격, 수량 등이 너무 적거나 커서 검증 범위를 넘어서면 서버 검증 로직이 실패해야 한다. 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.

필드 검증

@PostMapping("/add") public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) { Map<String, String> errors = new HashMap<>(); //검증 부분 if(!StringUtils.hasText(item.getItemName())) { errors.put("itemName", "상품 이름은 필수입니다."); } if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 10000000) { errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."); } if(item.getQuantity() == null || item.getQuantity() >= 9999) { errors.put("quantity", "수량은 최대 9,999까지 허용합니다."); } if(item.getPrice() != null && item.getQuantity() != null) { int resultPrice = item.getPrice() * item.getQuantity(); if(resultPrice < 10000) { errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice); } } //검증이 실패한 것이 있으면 에러를 모델에 담고 redirect if(!errors.isEmpty()) { model.addAttribute("errors", errors); return "validation/v1/addForm"; } //성공 로직 Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v1/items/{itemId}"; }
Java
복사
타임리프가 HTML에 정보를 보여줄 때 사용하기 위해 에러 정보를 model에 담아서 redirect한다.
<!-- addForm.html --> <form action="item.html" th:action th:object="${item}" method="post"> <div th:if="${errors?.containsKey('globalError')}"> <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p> </div> <div> <label for="itemName" th:text="#{label.item.itemName}">상품명</label> <input type="text" id="itemName" th:field="*{itemName}" th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" class="form-control" placeholder="이름을 입력하세요"> <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div> </div> <div> <label for="price" th:text="#{label.item.price}">가격</label> <input type="text" id="price" th:field="*{price}" th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" class="form-control" placeholder="가격을 입력하세요"> <div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">가격 오류</div> </div> <div> <label for="quantity" th:text="#{label.item.quantity}">수량</label> <input type="text" id="quantity" th:field="*{quantity}" th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" class="form-control" placeholder="수량을 입력하세요"> <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">수량 오류</div> </div>
HTML
복사
th:if 를 사용해 map에 해당 키를 포함하면 렌더링하도록 지정하였다.
이 때, errors?.containtsKey 의 ?. 는 NPE가 발생하면 null을 반환해준다.
th:class를 이용해 검증에 실패한 경우 클래스를 추가하여 css를 변경해준다.
th:classappend=”${errors?.containsKey(’itemName’)} ? ‘field-error’ : _” 로 사용해도 된다.
th:text=”${errors[’itemName’]}” 을 이용해 해당 키값의 value를 렌더링한다.
→ 뷰 템플릿에 중복 처리가 많다.
→ 타입 오류가 처리되지 않는다.
→ 타입 오류에도 고객이 입력한 내용을 보여줘야 하므로 별도로 관리해야 한다.

BindingResult

BindingResult는 인터페이스로 Error 인터페이스를 상속 받고 구현체는 BeanPropertyBindingResult
BindingResult 파라미터는 꼭 검증할 대상인 @ModelAttribute 바로 뒤에 와야한다.
addItemV2(@ModelAttribute Item item, BindingResult bindingResult, …)
@ModelAttribute 바인딩 시 타입 오류가 발생하면 컨트롤러를 호출해준다.
BindingResult가 없는 경우 → 400 오류 발생하며 컨트롤러 호출되지 않고 오류 페이지 이동
BindingResult가 있는 경우 → FieldError를 BindingResult에 담아서 컨트롤러를 호출
BindingResult에 검증 오류를 담는 방법
@ModelAttribute에 바인딩이 실패하는 경우 스프링이 FieldError를 생성해서 자동으로 담아준다.
이 경우 typeMismatch 의 오류 코드로 MessageCodesResolver를 통해 메시지가 생성되고
Failed to convert ~~의 기본 메시지가 지정된다. (properties를 통해 변경 가능)
개발자가 직접 담는다.
properties에 메시지 코드를 정의하고 rejectValue, reject를 사용해 오류 코드를 지정한다.
Validator를 사용해 담는다.
FieldError
if(!StringUtils.hasText(item.getItemName())) { bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다.")); }
Java
복사
필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.
FieldError(String objectName, String field, String defaultMessage)
objectName : 오류가 발생한 객체(@ModelAttribute) 이름
field : 오류가 발생한 필드 이름
defaultMessage : 오류 기본 메시지
ObjectError
if(item.getPrice() != null && item.getQuantity() != null) { int resultPrice = item.getPrice() * item.getQuantity(); if(resultPrice < 10000) { bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); } }
Java
복사
특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.
ObjectError(String objectName, String defaultMessage)
objectName : @ModelAttribute 이름
defaultMessage : 오류 기본 메시지
FieldError, ObjectError의 2번째 생성자
if(!StringUtils.hasText(item.getItemName())) { bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수 입니다.")); } if(resultPrice < 10000) { bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); }
Java
복사
이걸 사용해서 바인딩 시점에서 오류 발생해도 사용자 입력 값을 유지할 수 있다.
FieldError(String objectName, String field, Object rejectedValue, boolean bidingFailure, String[] codes, Object[] arguments, String defaultMessage)
rejectedValue : 사용자가 입력한 값 (거절된 값)
bidingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분
codes : 메시지 코드
arguments : 메시지에 사용하는 인자
타임리프 스프링 검증 오류 통합 기능
<!-- 글로벌 에러 --> <div th:if="${#fields.hasGlobalErrors()}"> <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> </div>
HTML
복사
th:if=”${#fields.hasGlobalErrors()}” : 글로벌 에러 처리
th:each="err : ${#fields.globalErrors()}" : 글로벌 에러 처리
<!-- 필드 에러 --> <div> <label for="itemName" th:text="#{label.item.itemName}">상품명</label> <input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요"> <div class="field-error" th:errors="*{itemName}">상품명 오류</div> </div>
HTML
복사
#fields : 타임리프가 스프링의 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
th:errors : 해당 필드에 오류가 있는 경우 태그를 출력한다. (th:if의 편의버전)
th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
오류 코드와 메시지 처리
FieldError, ObjectError의 생성자가 제공하는 errorCode, arguments를 이용해 properties에 지정해놓은 오류 코드로 메시지를 찾아서 사용할 수 있다.
required.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
HTML
복사
if(!StringUtils.hasText(item.getItemName())) { bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null)); } if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 10000000) { bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 10000000}, null)); } if(item.getQuantity() == null || item.getQuantity() >= 9999) { bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null)); } if(item.getPrice() != null && item.getQuantity() != null) { int resultPrice = item.getPrice() * item.getQuantity(); if(resultPrice < 10000) { bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null)); } }
Java
복사
errorCode는 String[] , arguments는 Object[]로 전달해야한다.
에러 코드를 못 찾을 시 순서대로 찾기 위함. (배열 내에서 찾지 못하면 defaultMessage 출력)
→ FieldError, ObjectError는 코드도 길고 번거롭다.

rejectValue(), reject()

BindingResult는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다.
그렇기에 BindingResult가 제공하는 rejectValue(), reject()를 사용하면 코드를 단순화할 수 있다.
if(!StringUtils.hasText(item.getItemName())) { bindingResult.rejectValue("itemName", "required"); } if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 10000000) { bindingResult.rejectValue("price", "range", new Object[]{1000, 10000000}, null); } if(item.getQuantity() == null || item.getQuantity() >= 9999) { bindingResult.rejectValue("quantity", "max", new Object[]{9999}, 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); } }
Java
복사
FieldError ← → rejectValue()
rejectValue(String field, String errorCode, Object[] errorArgs, String defaultMessage)
field : 오류 필드명
errorCode : 오류 코드 (properties에 등록된 코드가 아닌 messageResolver를 위한 코드)
errorArgs : 오류 메시지에서 {0}를 치환하기 위한 값
defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
ObjectError ← → reject()
reject(String errorCode, Object[] errorArgs, String defaultMessage)

MessageCodesResolver

주로 FieldError, ObjectError와 함께 사용하며 검증 오류 코드로 메시지 코드들을 생성한다.
메세지 코드를 생성 후 FieldError, ObjectError를 생성해서 메시지 코드들을 보관한다.
MessageCodesResolver는 인터페이스이고 DefaultMessageCodesResolver가 구현체이다.
메시지 생성 규칙
FieldError, rejectValue()
1.
[code].[object name].[field]
2.
[code].[field]
3.
[code].[field type]
4.
[code]
ObjectError, reject()
1.
[code].[object name]
2.
[code]
동작 방식
rejectValue(), reject()
내부에서 MessageCodesResolver를 사용하여 메시지 코드를 생성한다.
FieldError, ObjectError
MessageCodesResolver를 통해 생성된 메시지 코드들을 보관한다.

오류 코드 관리 전략

#required.item.itemName=상품 이름은 필수입니다. #range.item.price=가격은 {0} ~ {1} 까지 허용합니다. #max.item.quantity=수량은 최대 {0} 까지 허용합니다. #totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1} #==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} 까지 허용합니다.
HTML
복사
구체적인 것에서 덜 구체적인 것으로
오류 코드는 구체적인 것이 덜 구체적인 것보다 더 우선권을 갖는다.
MessageCodesResolver는 구체적인 것을 먼저 만들고 덜 구체적인 것을 나중에 만든다.
모든 오류 코드를 정의하면 관리하기 힘들기 때문에 중요한 메세지만 구체적으로 적어서 사용한다.
FieldError, ObjectError를 나누고 MessageCodesResolver가 만들어주는 것처럼 우선순위를 나눈다.
이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource에서 찾는다.

ValidationUtils

//기존 코드 if(!StringUtils.hasText(item.getItemName())) { bindingResult.rejectValue("itemName", "required"); } //ValidationUtils 사용 코드 ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
Java
복사
기존 if문을 사용했던 코드를 한 줄로 간편하게 사용할 수 있는 기능들을 제공한다.
Empty, 공백과 같은 단순한 기능만을 제공한다.

Validator

기존 코드는 Controller 내부에 검증 로직이 차지하는 부분이 매우 컸다.
Validator 인터페이스를 구현한 별도의 클래스로 분리하여 DI하면 코드가 명확해지고 재사용성이 좋아진다.
//ItemValidator @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; if(!StringUtils.hasText(item.getItemName())) { errors.rejectValue("itemName", "required"); } if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 10000000) { errors.rejectValue("price", "range", new Object[]{1000, 10000000}, null); } if(item.getQuantity() == null || 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[]{10000, resultPrice}, null); } } } }
Java
복사
supports() : isAssignableFrom()을 사용하여 검증하기 위한 객체거나 그 객체의 하위객체인지 확인
validate() : 검증 대상 객체와 BindingResult를 받아 검증 로직을 실행
//ValidationItemController private final ItemValidator itemValidator; ... @PostMapping("/add") public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { itemValidator.validate(item, bindingResult); if(bindingResult.hasErrors()) { return "validation/v2/addForm"; } Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v2/items/{itemId}"; }
Java
복사
코드가 굉장히 간결해진 것을 볼 수 있다.

WebDataBinder

WebDataBinder를 이용해 이전에 만들어놓은 Validator가 자동 검증기 역할을 하도록 할 수 있다.
@InitBinder public void init(WebDataBinder dataBinder) { dataBinder.addValidators(itemValidator); }
Java
복사
@InitBinder가 붙으면 Controller에 어떠한 요청이 들어와도 해당 메서드를 무조건 실행시킨다.
dataBinder.addValidators를 통해 Validator의 구현 객체를 여러 개 추가할 수 있다.
@PostMapping("/add") public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { if(bindingResult.hasErrors()) { return "validation/v2/addForm"; } Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v2/items/{itemId}"; }
Java
복사
이제 DI 받은 itemValidator를 직접 호출하는 부분을 지울 수 있게 됐다.
@Validated는 검증 대상 앞에 붙어 WebDataBinder 검증기를 실행하라는 어노테이션이다.
이 때, supports()가 사용되어 어떤 검증기를 사용해야 할지 구분하게 된다.
스프링 어노테이션인 @Validated와 자바 표준 어노테이션인 @Valid 둘 다 사용 가능하다.
@SpringBootApplication public class ItemServiceApplication implements WebMvcConfigurer { public static void main(String[] args) { SpringApplication.run(ItemServiceApplication.class, args); } @Override public Validator getValidator() { return new ItemValidator(); } }
Java
복사
WebDataBinder를 글로벌하게 사용하고 싶다면 @SpringBootApplication에다가
WebMvcConfigurer를 구현하고 getValidator()를 오버라이딩하면 된다.

Bean Validation

공백, 범위 체크 같은 단순 검증 로직 작성을 Bean Validation을 활용해 단순화 할 수 있다.
BeanValidation은 기술 표준으로 검증 어노테이션과 여러 인터페이스의 모음이다.
javax.validation : 표준 인터페이스
org.hibernate.validator : validator 구현체
하이버네이트 Validator를 사용하려면 build.gradle에 의존관계를 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'

Bean Validation - FieldError

//Item.java @Data public class Item { private Long id; @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 10000000) private Integer price; @NotNull @Max(9999) private Integer quantity; public Item() { } public Item(String itemName, Integer price, Integer quantity) { this.itemName = itemName; this.price = price; this.quantity = quantity; } }
Java
복사
@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull : null을 허용하지 않는다.
@Range(min = ??, max = ??) : 범위 안의 값이어야 한다.
@Max(??) : 최대 ??까지만 허용한다.

Bean Validation - Test

@Test void beanValidation() { ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); Validator validator = factory.getValidator(); Item item = new Item(); item.setItemName(" "); item.setPrice(0); item.setQuantity(10000); Set<ConstraintViolation<Item>> validations = validator.validate(item); for(ConstraintViolation<Item> validation : validations) { System.out.println("validation = " + validation); System.out.println("validation.getMessage() = " + validation.getMessage()); } }
Java
복사
검증기 생성
ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); Validator validator = factory.getValidator();
Java
복사
Validation.buildDefaultValidatorFactory()를 사용해 ValidatorFactory를 만들고
factory.getValidator()를 통해 Validator를 생성한다.
검증 실행
Set<ConstraintViolation<Item>> validations = validator.validate(item);
Java
복사
validate()를 통해 검증 대상을 전달해 결과를 Set으로 받는다.
ConstraintViolation 에는 검증 오류가 담겨있다.
실행 결과
validation = ConstraintViolationImpl{interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'} validation.getMessage() = 9999 이하여야 합니다 validation = ConstraintViolationImpl{interpolatedMessage='1000에서 10000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'} validation.getMessage() = 1000에서 10000000 사이여야 합니다 validation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'} validation.getMessage() = 공백일 수 없습니다
Java
복사
출력 결과로 오류 메시지, 오류가 발생한 필드, 객체, 메시지 정보 등을 확인할 수 있다.
오류 메시지는 자동으로 생성되므로 어노테이션 속성에 message=””를 이용해 변경할 수 있다.

Bean Validation - 스프링 통합

//private final ItemValidator itemValidator; @PostMapping("/add") public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { if(bindingResult.hasErrors()) { return "validation/v3/addForm"; } Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v3/items/{itemId}"; }
Java
복사
이제 우리는 Controller에서 Validator DI를 제거하고 검증 로직을 제거할 수 있다.
스프링 부트가 LocalValidatorFactoryBean을 자동으로 글로벌로 등록해준다.
이 Validator는 검증 어노테이션을 수행한다.
수동으로 Validator를 적용하게 되면 LocalValidatorFactoryBean은 등록되지 않는다.
검증하려는 대상 객체 앞에 @Validated 혹은 @Valid를 꼭 붙여야 한다.
검증 순서
1.
@ModelAttribute 가 각각의 필드에 타입 변환 시도
a.
성공하면 다음으로
b.
실패하면 typeMismatch로 FieldError 추가
2.
Validator 적용 (typeMismatch 된건 Validator 되지 않는다.)
에러 코드
@NotBlank NotBlank.item.itemName NotBlank.itemName NotBlank.java.lang.String NotBlank @Range Range.item.price Range.price Range.java.lang.Integer Range
Java
복사
Bean Validation은 어노테이션 이름을 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드를 만든다.
메시지 코드 형식을 맞춰 properties에 오류 메시지를 정해둘 수 있다.
BeanValidation이 메시지를 찾는 순서
1.
생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
2.
어노테이션의 message 속성 사용
3.
라이브러리가 제공하는 기본 값 사용 (없을 수도 있음)

Bean Validation - ObjectError

@Data @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000") public class Item {
Java
복사
@ScriptAssert를 이용해 ObjectError를 다룰 수 있다.
메시지 코드
ScriptAssert.item
ScriptAssert
검증 기능이 해당 객체의 범위를 넘어서는 경우가 종종 있으므로 그럴 땐 Controller 안에 검증 로직으로 해결한다.
동일한 모델 객체에 대한 다른 요청이 들어오면 ObjectError 검증 조건이 충돌하는 한계가 존재한다.
→ BeanValidation 의 groups 기능을 사용
→ Item을 ItemSaveForm, ItemUpdateForm 같은 폼 단위의 모델 객체로 나누어서 사용

Bean Validation - groups

등록 시의 검증 로직과 수정 시의 검증 로직을 각각 그룹으로 나누어 적용할 수 있다.
public interface SaveCheck { } public interface UpdateCheck { }
Java
복사
group 이름으로 인터페이스를 만든다.
//Item.java @Data @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000") 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}) @Range(min = 1000, max = 10000000, groups = {SaveCheck.class, UpdateCheck.class}) private Integer price; @NotNull(groups = {SaveCheck.class, UpdateCheck.class}) @Max(value = 9999, groups = {SaveCheck.class}) private Integer quantity; public Item() { } public Item(String itemName, Integer price, Integer quantity) { this.itemName = itemName; this.price = price; this.quantity = quantity; } }
Java
복사
데이터 객체의 Bean Validation 어노테이션이에 groups = “XXX.class”로 그룹을 지정한다.
@PostMapping("/add") public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { @PostMapping("/{itemId}/edit") public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
Java
복사
Controller에서 해당 요청의 @Validated 어노테이션에 value = “”로 그룹을 지정한다. (value 생략 가능)
→ 하지만 너무 복잡도가 상승해버리기 때문에 실무에선 잘 사용하지 않는다.

Form 전송 객체 분리

실무에선 회원 등록 시 회원 관련 데이터 외에도 많은 데이터를 폼으로 전달 받는다.
그래서 데이터가 도메인 객체와 딱 맞아 떨어지지 않기 때문에 폼을 전달 받는 전용 객체를 만들어 사용한다.
기존 방법
HTML Form → Item → Controller → Item → Repository
장점 : Item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달해 중간에 Item 만드는 과정이 없다.
단점 : 간단한 경우에만 적용할 수 있고 수정 시 검증이 중복될 수 있어 groups를 사용해야 한다.
Form 전송 객체 분리 방법
HTML Form → ItemSaveForm → Controller → Item 생성 → Repository
장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다.
보통 등록과 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성해서 Repository에 전달하는 과정이 추가된다.
→ 각 Form 마다 다루는 데이터와 검증 로직이 다르기 때문에 별도의 객체로 데이터를 전달 받는 것이 좋다.
//ItemSaveForm.java @Data public class ItemSaveForm { @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 1000000) private Integer price; @NotNull @Max(value = 9999) private Integer quantity; } //ItemUpdateForm.java @Data public class ItemUpdateForm { @NotNull private Long id; @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 1000000) private Integer price; private Integer quantity; }
Java
복사
기존의 Item 도메인 객체에 붙어있던 검증 어노테이션을 제거하고 각 Form 전송 객체에 맞게 붙여준다.
//ValidationItemController.java @PostMapping("/add") public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { if(bindingResult.hasErrors()) { return "validation/v4/addForm"; } Item item = new Item(); item.setItemName(form.getItemName()); item.setPrice(form.getPrice()); item.setQuantity(form.getQuantity()); Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v4/items/{itemId}"; } @PostMapping("/{itemId}/edit") public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) { if(form.getPrice() != null && form.getQuantity() != null) { int resultPrice = form.getPrice() * form.getQuantity(); if(resultPrice < 10000) { bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null); } } if(bindingResult.hasErrors()) { return "validation/v4/editForm"; } Item item = new Item(); item.setItemName(form.getItemName()); item.setPrice(form.getPrice()); item.setQuantity(form.getQuantity()); itemRepository.update(itemId, item); return "redirect:/validation/v4/items/{itemId}"; }
Java
복사
@Validated @ModelAttribute Item item
→ @Validated @ModelAttribute(”item”) ItemSaveForm form
@Validated @ModelAttribute Item item
→ @Validated @ModelAttribute(”item”) ItemUpdateForm form
각 Form 객체를 사용해 데이터를 바인딩하고 해당 Form 객체를 통해 Item을 생성해준다.
@ModelAttribute의 value로 Model에 key로 등록될 이름을 꼭 확인해줘야한다.
(이 이름은 뷰 템플릿에서 th:obejct로 접근하는 이름으로 지정하지 않으면 객체 타입의 첫 글자를 소문자로 한 이름으로 자동 설정된다.)

HTTP 메시지 컨버터

@Validated, @Valid는 HttpMessageConverter (@RequestBody)에도 적용할 수 있다.
@Slf4j @RestController @RequestMapping("/validation/api/items") public class ValidationItemApiController { @PostMapping("/add") public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) { log.info("API 컨트롤러 호출"); if(bindingResult.hasErrors()) { log.info("검증 오류 발생 errors={}", bindingResult); return bindingResult.getAllErrors(); } log.info("성공 로직 실행"); return form; } }
Java
복사
//성공 요청 POST http://localhost:8080/validation/api/items/add {"itemName":"hello", "price":1000, "quantity": 10} //결과 {"itemName":"hello", "price":1000, "quantity": 10}
JSON
복사
//실패 요청 POST http://localhost:8080/validation/api/items/add {"itemName":"hello", "price":"A", "quantity": 10} //결과 { "timestamp": "2021-04-20T00:00:00.000+00:00", "status": 400, "error": "Bad Request", "message": "", "path": "/validation/api/items/add" }
JSON
복사
@RequestBody를 통한 HttpMessageConverter가 요청 JSON을 ItemSaveForm 객체에 바인딩 하는 것을 실패했기 때문에 컨트롤러 자체가 호출되지 않았고 HttpMessageNotReadableException이 터졌다.
//검증 오류 요청 POST http://localhost:8080/validation/api/items/add {"itemName":"hello", "price":1000, "quantity": 10000} //결과 [ { "codes": [ "Max.itemSaveForm.quantity", "Max.quantity", "Max.java.lang.Integer", "Max" ], "arguments": [ { "codes": [ "itemSaveForm.quantity", "quantity" ], "arguments": null, "defaultMessage": "quantity", "code": "quantity" }, 9999 ], "defaultMessage": "9999 이하여야 합니다", "objectName": "itemSaveForm", "field": "quantity", "rejectedValue": 10000, "bindingFailure": false, "code": "Max" } ]
JSON
복사
검증 실패하면 Validator에 의해 BindingResult에 오류들이 담기고 getAllErrors()는 FieldError, ObjectError를 반환한다.
@RestController 때문에 스프링이 반환 값을 JSON으로 변환하는데 필요한 데이터만 뽑아 API 스펙을 정의하고 그에 맞는 객체를 만들어서 보내야 한다.

[@Validated @ModelAttribute] VS [@Validated @RequestBody]

@ModelAttribute
필드 단위로 정교하게 바인딩되기 때문에 특정 필드가 바인딩 실패해도 나머지 필드는 정상적으로 바인딩되고 Validator를 이용한 검증도 적용된다.
@RequestBody
전체 객체 단위로 적용되기 때문에 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 바인딩 할 객체도 생성하지 못하고 Validator를 이용한 검증도 적용되지 않는다.

로그인/로그아웃

<!-- loginForm.html --> <body> <div class="container"> <div class="py-5 text-center"> <h2>로그인</h2> </div> <form action="item.html" th:action th:object="${loginForm}" method="post"> <div th:if="${#fields.hasGlobalErrors()}"> <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> </div> <div> <label for="loginId">로그인 ID</label> <input type="text" id="loginId" th:field="*{loginId}" class="form-control" th:errorclass="field-error"> <div class="field-error" th:errors="*{loginId}" /> </div> <div> <label for="password">비밀번호</label> <input type="password" id="password" th:field="*{password}" class="form-control" th:errorclass="field-error"> <div class="field-error" th:errors="*{password}" /> </div> <hr class="my-4"> <div class="row"> <div class="col"> <button class="w-100 btn btn-primary btn-lg" type="submit">로그인</button> </div> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" th:onclick="|location.href='@{/}'|" type="button">취소</button> </div> </div> </form> </div> <!-- /container --> </body>
HTML
복사
<!-- home.html --> <body> <div class="container" style="max-width: 600px" th:if="${member} == null"> <div class="py-5 text-center"> <h2>홈 화면</h2> </div> <div class="row"> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" type="button" th:onclick="|location.href='@{/members/add}'|"> 회원 가입 </button> </div> <div class="col"> <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'" th:onclick="|location.href='@{/login}'|" type="button"> 로그인 </button> </div> </div> <hr class="my-4"> </div> <!-- /container --> <div class="container" style="max-width: 600px" th:if="${member} != null"> <div class="py-5 text-center"> <h2>홈 화면</h2> </div> <h4 class="mb-3" th:text="|로그인: ${member.name}|">로그인 사용자 이름</h4> <hr class="my-4"> <div class="row"> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" type="button" th:onclick="|location.href='@{/items}'|"> 상품 관리 </button> </div> <div class="col"> <form th:action="@{/logout}" method="post"> <button class="w-100 btn btn-dark btn-lg" type="submit"> 로그아웃 </button> </form> </div> </div> <hr class="my-4"> </div> </body>
HTML
복사
//LoginController.java @Slf4j @Controller @RequiredArgsConstructor public class LoginController { private final LoginService loginService; @GetMapping("/login") public String loginForm(@ModelAttribute("loginForm") LoginForm form) { return "login/loginForm"; } @PostMapping("/login") public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) { if(bindingResult.hasErrors()) { return "login/loginForm"; } Member loginMember = loginService.login(form.getLoginId(), form.getPassword()); if(loginMember == null) { bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다."); return "login/loginForm"; } Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId())); response.addCookie(idCookie); return "redirect:/"; } @PostMapping("/logout") public String logout(HttpServletResponse response) { expireCookie(response, "memberId"); return "redirect:/"; } private void expireCookie(HttpServletResponse response, String cookieName) { Cookie cookie = new Cookie(cookieName, null); cookie.setMaxAge(0); response.addCookie(cookie); } }
Java
복사
//LoginService.java @Service @RequiredArgsConstructor public class LoginService { private final MemberRepository memberRepository; public Member login(String loginId, String password) { return memberRepository.findByLoginId(loginId).filter(m -> m.getPassword().equals(password)).orElse(null); } }
Java
복사
//LoginForm.java @Data public class LoginForm { @NotEmpty private String loginId; @NotEmpty private String password; }
Java
복사

쿠키

로그인 상태를 유지하기 위해 쿼리 파라미터를 계속 유지하면서 보내는 것은 매우 번거로운 일이다.
로그인에 성공하면 서버에서 HTTP 응답으로 쿠키를 전달하고 이후에 쿠키를 통해 로그인을 유지할 수 있다.
영속 쿠키, 세션 쿠키
영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료 시까지만 유지
쿠키 생성 로직
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId())); response.addCookie(idCookie);
Java
복사
Controller에서 로그인 성공 시 Cookie를 생성해 response.addCookie()로 보내줄 수 있다.
@CookieValue
@GetMapping("/") public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) { if(memberId == null) { return "home"; } Member loginMember = memberRepository.findById(memberId); if(loginMember == null) { return "home"; } model.addAttribute("member", loginMember); return "home"; }
Java
복사
파라미터에 @CookieValue를 붙이고 value 속성에 쿠키 이름을 적어주면 해당 파라미터로 쿠키를 사용할 수 있다.
쿠키 만료 로직
@PostMapping("/logout") public String logout(HttpServletResponse response) { expireCookie(response, "memberId"); return "redirect:/"; } private void expireCookie(HttpServletResponse response, String cookieName) { Cookie cookie = new Cookie(cookieName, null); cookie.setMaxAge(0); response.addCookie(cookie); }
Java
복사
쿠키를 제거하려면 쿠키의 만료시점을 생성 이전 시점으로 돌려 제거할 수 있다. (로그아웃)
new Cookie로 기존 이름과 같은 쿠키를 만들어 setMaxAge()를 통해 만료시점을 0으로 한 뒤 response를 통해 다시 보내게 되면 쿠키를 제거할 수 있다.
쿠키 보안 문제
1.
쿠키 값은 임의로 변경될 수 있다.
a.
클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
2.
쿠키에 보관된 정보는 훔쳐갈 수 있다.
a.
나의 로컬 PC가 털릴 수도 있고 네트워크 전송 구간에서 털릴 수도 있다.
b.
해커가 쿠키를 한 번 훔쳐가면 만료 시점까지 악의적으로 사용할 수 있다.
대안
1.
쿠키에 중요한 값을 노출하지 않고 사용자 별로 임의의 토큰을 발급하고 서버에서 토큰과 사용자를 매핑해서 인식하며 토큰은 서버에서 관리한다.
2.
토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.
3.
해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지하고 해킹이 의심되는 경우 해당 토큰을 강제로 제거한다.

세션

쿠키의 보안 문제를 해결하기 위해선 중요한 정보를 모두 서버에 저장해야 하고 클라이언트와 서버는 임의의 식별자로 연결되어야 한다.
이 때 세션ID는 추정이 불가능한 UUID를 사용해 해당 세션이 보관할 값과 함께 서버에 보관한다.
클라이언트와 서버는 결국 쿠키로 연결되어야 하기 때문에 sessionID라는 이름으로 쿠키에 전달한다.
클라이언트는 쿠키 저장소에 sessionID를 저장하며 회원과 관련된 정보 없이 세션ID만을 전달한다.
//SessionManager.java @Component public class SessionManager { public static final String SESSION_COOKIE_NAME = "mySessionId"; private Map<String, Object> sessionStore = new ConcurrentHashMap<>(); //세션 생성 public void createSession(Object value, HttpServletResponse response) { String sessionId = UUID.randomUUID().toString(); sessionStore.put(sessionId, value); Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId); response.addCookie(mySessionCookie); } //세션 조회 public Object getSession(HttpServletRequest request) { Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME); if(sessionCookie == null) { return null; } return sessionStore.get(sessionCookie.getValue()); } public Cookie findCookie(HttpServletRequest request, String cookieName) { if(request.getCookies() == null) { return null; } return Arrays.stream(request.getCookies()) .filter(cookie -> cookie.getName().equals(cookieName)) .findAny() .orElse(null); } //세션 만료 public void expire(HttpServletRequest request) { Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME); if(sessionCookie != null) { sessionStore.remove(sessionCookie.getValue()); } } }
Java
복사
Test에서는 HttpServletRequest, Response를 사용할 수 없기 때문에 MockHttpServletRequest,Response를 사용한다.

서블릿 HTTP 세션

서블릿이 제공하는 HttpSession을 통해 세션을 더 편리하게 사용할 수 있다.
HttpSession을 생성하면 JSESSIONID라는 이름을 가지는 쿠키를 생성하게 된다.
public interface SessionConst { String LOGIN_MEMBER = "loginMember"; }
Java
복사
interface 또는 abstract class 를 사용하면 기본적으로 public static final이 붙어 상수로 사용할 수 있다.
세션 이름은 중복되어 자주 사용되기 때문에 상수화하여 사용하는 것이 편리하다.
@GetMapping("/") public String homeLoginV3(HttpServletRequest request, Model model) { HttpSession session = request.getSession(false); if(session == null) { return "home"; } Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER); if(loginMember == null) { return "home"; } model.addAttribute("member", loginMember); return "home"; } @PostMapping("/login") public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request) { if(bindingResult.hasErrors()) { return "login/loginForm"; } Member loginMember = loginService.login(form.getLoginId(), form.getPassword()); if(loginMember == null) { bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다."); return "login/loginForm"; } HttpSession session = request.getSession(); session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember); return "redirect:/"; } @PostMapping("/logout") public String logoutV3(HttpServletRequest request) { HttpSession session = request.getSession(false); if(session != null) { session.invalidate(); } return "redirect:/"; }
Java
복사
request.getSession(boolean create) 을 통해 세션을 생성할 수 있다.
create는 true가 기본값으로 세션이 있으면 기본 세션을 반환하고 없으면 생성해서 반환한다.
false는 세션이 있으면 기존 세션을 반환하고 없으면 null을 반환한다.
세션을 생성하게 되면 JSESSIONID을 생성한다.
session.setAttribute(세션이름, 객체)를 사용하면 지정한 세션이름으로 객체를 담을 수 있다.
session.getAttribute(세션이름)을 사용하면 해당 세션이름으로 담긴 객체를 가져올 수 있다.
session.invalidate()는 세션을 만료시킨다.

@SessionAttribute

@GetMapping("/") public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member member, Model model) { if(member == null) { return "home"; } model.addAttribute("member", member); return "home"; }
Java
복사
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member member
파라미터에 @SessionAttribute를 붙이면 name으로 담겨진 객체를 매핑해 사용할 수 있게 해준다.
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
Java
복사
로그인을 처음 시도하면 URL에 JSESSIONID가 포함된다.
웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해 세션을 유지하는 방법이다.
URL에 노출하기를 원하지 않는다면 application.properties에서 설정해야 한다.
server.servlet.session.tracking-modes=cookie

세션 정보

session.getAttributeNames().asIterator() .forEachRemaining(name -> log.info("session name = {}, value = {}", name, session.getAttribute(name))); log.info("sessionId = {}", session.getId()); log.info("getMaxInactiveInterval = {}", session.getMaxInactiveInterval()); log.info("creationTime = {}", session.getCreationTime()); log.info("lastAccessedTime = {}", session.getLastAccessedTime()); log.info("isNew = {}", session.isNew());
Java
복사
sessionId : JSESSIONID의 값이다.
maxInactiveInterval : 세션의 유효 시간 (최소 60초)
creationTime : 세션 생성일시
lastAccessedTime : 세션 최근 요청 시간 (요청할 때마다 갱신)
isNew : 제일 처음 세션이 생성될 땐 true 이후에 요청될 땐 false

세션 타임아웃

세션은 session.invalidate()가 호출되면 삭제된다. 하지만 사용자는 대부분 그냥 웹 브라우저를 종료한다.
하지만 HTTP는 비연결성이므로 서버는 사용자가 웹 브라우저를 종료한지 알 수 없다.
세션이 무제한으로 보관된다면 세션이 탈취 당했을 경우 위험하고 메모리에 계속 누적되어 생성된다.
//글로벌 설정 (application.properties) server.servlet.session.timeout=1800 //특정 세션 설정 session.setMaxInactiveInterval(1800);
Java
복사
타임아웃은 분 단위로 설정해야 하며 최소는 1분이다.
타임아웃은 lastAccessedTime 이후 시간으로 계산하며 요청이 들어올 때마다 갱신된다.
타임아웃이 지나게 되면 WAS에서 세션을 제거한다.
세션에 담는 데이터가 많거나 보관 시간이 길어진다면 메모리 사용량이 늘어나 적당한 시간으로 해야한다.

문제점

쿠키와 세션으로 로그인/로그아웃 기능을 만들었지만 로그인 없이도 URL을 직접 호출해 접근할 수 있다.
모든 URL 요청마다 인증 로직을 작성하면 되겠지만 쉽지 않은 일이다.
이렇게 여러 로직에서의 공통 관심사는 스프링 AOP, 서블릿 필터, 스프링 인터셉터를 사용해 처리할 수 있다.

서블릿 필터

필터 흐름
//일반적인 흐름 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //필터 제한 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 HTTP 요청 -> WAS -> 필터(적절하지 않은 요청으로 서블릿 호출 X) //비로그인 //필터 체인 HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
Java
복사
필터 인터페이스
public class myFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { Filter.super.init(filterConfig); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { } @Override public void destroy() { Filter.super.destroy(); } }
Java
복사
필터를 사용하려면 javax.servlet.Filter 인터페이스를 구현해야 한다.
init() : 필터 초기화 메서드로 서블릿 컨테이너가 생성될 때 호출된다.
doFilter() : 고객의 요청이 올 때마다 해당 메서드가 호출된다. (필터 로직 부분)
destroy() : 필터 종료 메서드로 서블릿 컨테이너가 종료될 때 호출된다.
init(), destroy()는 default로 필수 오버라이딩이 아니다.
doFilter의 ServletRequest, Response는 인터페이스로 HttpServletRequest, Response로 다운 캐스팅해서 사용해야 한다.
doFilter의 로직 마지막엔 chain.doFilter(request, response); 로 다음 필터를 호출해야 한다.
(다음 필터가 없다면 서블릿을 호출한다.)
필터 등록
@Configuration public class WebConfig { @Bean public FilterRegistrationBean logFilter() { FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>(); filterRegistrationBean.setFilter(new LogFilter()); filterRegistrationBean.setOrder(1); filterRegistrationBean.addUrlPatterns("/*"); return filterRegistrationBean; } }
Java
복사
FilterRegistrationBean을 사용하면 스프링 부트가 필터를 등록해준다.
setFilter() : 등록할 필터 객체를 new로 생성해 전달하여 지정한다.
setOrder(1) : 필터는 체인으로 동작하기 때문에 순서를 지정한다. (낮을 수록 먼저 동작)
addUrlPatterns(”/*”) : 필터를 적용할 URL 패턴을 지정한다. (여러 패턴 가능)
@ServletComponentScan 과 @WebFilter(fileName = “”, urlPatterns = “”)로도 등록 가능
(하지만 필터 순서 조절이 안된다.)
로그인 인증 필터
@Slf4j public class LoginCheckFilter implements Filter { private static final String[] whiteList = {"/", "/members/add", "/login", "/logout", "/css/*"}; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; String requestURI = httpRequest.getRequestURI(); try { log.info("인증 체크 필터 시작 {}", requestURI); if (isLoginCheckPath(requestURI)) { log.info("인증 체크 로직 실행 {}", requestURI); HttpSession session = httpRequest.getSession(false); if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) { log.info("미인증 사용자 요청 {}", requestURI); httpResponse.sendRedirect("/login?redirectURL=" + requestURI); return; } } chain.doFilter(request, response); } catch (Exception e) { throw e; } finally { log.info("인증 체크 필터 종료 {}", requestURI); } } private boolean isLoginCheckPath(String requestURI) { return !PatternMatchUtils.simpleMatch(whiteList, requestURI); } }
Java
복사
whiteList 상수를 통해 인증 없이 사용할 수 있는 URL을 지정할 수 있다.
미인증 사용자가 인증이 필요한 URL에 접근하면 login 페이지로 응답하는데
이 때 redirectURL을 파라미터로 전달해 로그인하면 기존 페이지로 돌아가게끔 할 수 있다.
(Controller에서 @RequestParam을 통해 파라미터를 매핑해 사용할 수 있다.)

스프링 인터셉터

서블릿 필터는 서블릿이 제공하는 기술이고 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다.
둘 다 웹과 관련된 공통 관심 사항을 처리하지만 적용되는 순서와 범위 그리고 사용 방법이 다르다.
스프링 인터셉터 흐름
//일반적인 흐름 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //스프링 인터셉터 제한 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청으로 컨트롤러 호출 X) //비로그인 //스프링 인터셉터 체인 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
Java
복사
스프링 인터셉터는 DispatcherServlet 과 Controller 사이에서 컨트롤러 호출 직전에 호출된다.
스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 때문에 스프링 MVC의 시작점인 DS 이후에 동작한다.
스프링 인터셉터에도 URL 패턴을 적용할 수 있는데 서블릿 필터의 URL 패턴보다 정밀하게 설정할 수 있다.
스프링 인터셉터 인터페이스
public class myInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return HandlerInterceptor.super.preHandle(request, response, handler); } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { HandlerInterceptor.super.postHandle(request, response, handler, modelAndView); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { HandlerInterceptor.super.afterCompletion(request, response, handler, ex); } }
Java
복사
preHandle() : 컨트롤러 호출 전에 호출된다. (핸들러 어댑터 호출 전에 호출)
반환값이 true면 다음으로 진행하고 false면 진행하지 않고 끝나게 된다.
postHandle() : 컨트롤러 호출 후에 호출된다. (핸들러 어댑터 호출 후에 호출)
컨트롤러에서 예외가 발생하면 postHandle()이 호출되지 않는다.
afterCompletion() : 뷰가 렌더링 된 이후 호출된다.
컨트롤러에서 예외가 발생해도 호출되며 ex 파라미터를 통해 예외 정보를 조회할 수 있다.
request, response만 전달 받았던 서블릿 필터와 달리 handler의 정보와 modelAndView까지 전달 받는다.
다 default 메서드로 필수 구현이 아니다.
핸들러 정보
if(handler instanceof HandlerMethod) { HandlerMethod hm = (HandlerMethod) handler; }
Java
복사
HandlerMethod
@Controller, @RequestMapping을 활용한 핸들러 매핑을 사용하는 경우 넘어오는 핸들러 정보
ResourceHttpRequestHandler
/resources/static 과 같은 정적 리소스가 호출되는 경우 넘어오는 핸들러 정보
handler는 Object로 넘어오기 때문에 타입에 맞게 캐스팅 후 사용해야 한다.
스프링 인터셉터 등록
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LogInterceptor()).order(1).addPathPatterns("/**").excludePathPatterns("/css/**", "*/*.ico", "/error"); registry.addInterceptor(new LoginCheckInterceptor()).order(2).addPathPatterns("/**").excludePathPatterns("/", "/members/add", "login", "/logout", "/css/**", "/*.ico", "/error"); } }
Java
복사
WebMvcConfigurer가 제공하는 addInterceptors()를 사용해서 인터셉터를 등록할 수 있다.
addInterceptors() : new로 생성한 인터셉터 객체를 전달해 인터셉터 등록
order(1) : 인터셉터의 호출 순서를 지정 (낮을 수록 먼저 호출)
addPathPatterns(”/**”) : 인터셉터를 적용할 URL 패턴을 지정
excludePathPatterns(”/css/**”) : 인터셉터에서 제외할 패턴을 지정
PathPattern 공식 문서
로그인 인증 인터셉터
@Slf4j public class LoginCheckInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); log.info("인증 체크 인터셉터 실행 = {}", requestURI); HttpSession session = request.getSession(); if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) { log.info("미인증 사용자 요청"); response.sendRedirect("/login?redirectURL=" + requestURI); return false; } return true; } }
Java
복사

ArgumentResolver

ArgumentResolver를 이용해 @Login 어노테이션이 붙으면 세션에 있는 로그인 회원을 찾아주도록 해보자
@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface Login { }
Java
복사
@Target(ElementType.PARAMETER) : 파라미터에만 사용하도록 지정
@Retention(RetentionPolicy.RUNTIME) : 유지정책을 런타임까지로 지정
@Slf4j public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { log.info("supportsparameter 실행"); boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class); boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType()); return hasLoginAnnotation && hasMemberType; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { log.info("resolveArgument 실행"); HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); HttpSession session = request.getSession(false); if(session == null) { return null; } return session.getAttribute(SessionConst.LOGIN_MEMBER); } }
Java
복사
@Login 을 인식하고 처리하기 위해선 HandlerMethodArgumentResolver를 구현해야 한다.
supportsParameter() : 해당 어노테이션과 타입이 맞는지 확인하고 맞다면 resolver 동작
resolveArgument() : 컨트롤러 호출 직전에 호출되어서 필요한 파라미터 정보를 생성
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new LoginMemberArgumentResolver()); } }
Java
복사
구현한 ArgumentResolver를 등록해야 사용할 수 있다.
WebMvcConfigurer가 제공하는 addArgumentResolvers()를 사용해 등록한다.

서블릿 예외 처리

서블릿은 다음 2가지 방식으로 예외 처리를 지원한다.
Exception
WAS (여기까지 전파) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러 (예외 발생)
웹 애플리케이션은 사용자 요청 별로 스레드가 할당되고 서블릿 컨테이너 안에서 실행된다.
실행 도중 try-catch로 처리를 하면 문제 없지만 예외를 처리하지 못하고
서블릿 밖으로 예외가 전달되면 WAS가 500 에러를 반환하게 된다.
response.sendError(HTTP 상태 코드, 오류 메시지)
WAS (sendError 호출 기록 확인) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러 (sendError)
sendError를 호출하면 response 객체 내부에 오류를 기록하고
서블릿 컨테이너가 요청에 응답하기 전에 response에서 sendError가 호출되었는지 확인한 후
호출되었다면 설정한 오류 코드에 맞추어 기본 오류 페이지를 보여준다.
서블릿 예외 처리 - 필터
오류 페이지를 출력하기 위해 다시 내부에서 호출이 발생할 때 필터나 인터셉트가 한 번 더 호출된다.
→ 이 방법은 비효율적이기 때문이므로 요청이 정상 요청인지 오류 페이지 요청인지 구분해야 한다.
public enum DispatcherType { FORWARD, //서블릿에서 다른 서블릿이나 JSP를 호출할 때 INCLUDE, //서블릿에서 다른 서블릿이나 JSP 결과를 포함할 때 REQUEST, //클라이언트가 처음 요청했을 때 ASYNC, //서블릿 비동기 호출 ERROR //오류 요청 }
Java
복사
서블릿은 DispatcherType을 제공하고 request.getDispatcherType()을 통해 조회할 수 있다.
@Configuration public class WebConfig implements WebMvcConfigurer { @Bean public FilterRegistrationBean logFilter() { ... filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR); ... } }
Java
복사
필터를 등록할 때 setDispatcherTypes()을 지정해주면 해당 타입의 경우에만 필터가 작동한다.
(기본값은 DispatcherType.REQUEST이고 생략할 수 있다.)
서블릿 예외 처리 - 인터셉터
@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/**"); } }
Java
복사
인터셉터를 사용할 땐 excludePathPatterns()을 통해 오류 페이지 요청에 대한 인터셉트를 제외 시킬 수 있다.
인터셉트의 preHandle(), postHandle(), afterCompletion() 중에서
오류가 발생하지 않았을 경우 preHandle() → postHandle() → afterCompletion()
오류가 발생 했을 경우 preHandle() → afterCompletion()
오류 페이지 요청의 경우 preHandle() → postHandle() → afterCompletion()

오류 페이지

서블릿의 오류 페이지
@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); } }
Java
복사
오류 페이지를 관리하는 WebServerFactoryCustomizer<ConfigurableWebServerFactory>를 구현
해당 객체를 빈으로 등록해주어야 한다.
오류 페이지마다 ErrorPage 객체를 만들어 factory.addErrorPages로 등록한다.
HttpStatus에 상수화되어 있는 상태 코드를 사용하거나 Exception 클래스를 직접 지정할 수 있다.
(Exception을 다룰 경우 지정한 예외의 자식도 함께 처리한다.)
오류 페이지를 다루기 위한 Controller 와 View 도 추가 해주어야 한다.
WAS는 예외를 처리하기 위해 해당 예외로 설정된 오류 페이지를 찾아서 다시 요청을 보낸다.
WAS /error-page/500 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> 뷰
WAS는 오류 페이지를 요청할 때 request 의 attribute에 오류 정보를 추가해서 전달한다.
오류 정보는 RequestDispatcher에 상수로 정의되어 있고 getAttribute()로 조회할 수 있다.

스프링 부트의 예외 처리와 오류 페이지

기존에는 WebServerCustomizer를 만들고 ErrorPage를 추가하고 ErrorPageController를 만듦
스프링 부트는 이 모든 과정을 기본으로 제공한다.
ErrorMvcAutoConfiguration이라는 클래스가 오류 페이지를 자동으로 등록한다.
ErrorPage는 자동으로 등록된다. (/error 라는 경로를 기본 오류 페이지로 설정한다.)
BasicErrorController라는 컨트롤러를 자동으로 등록해 자동으로 등록된 ErrorPage를 매핑한다.
→ 우리는 View 페이지만 추가하면 된다.
뷰 선택 우선순위 (BasicErrorController의 처리 순서)
1.
뷰 템플릿 (resources/templates/error/4xx.html)
2.
정적 리소스 (resources/static/error/400.html)
3.
적용 대상이 없을 때 (resources/templates/error.html)
→ 4xx은 400번대를 말하고 구체적인게 덜 구체적인 것보다 우선순위가 높다.
BasicErrorController가 model에 담아 제공하는 기본 정보들
timestamp: Fri Jul 01 04:35:59 KST 2022
path: /error-ex
status: 500
message: 예외 발생
error: Internal Server Error
exception: java.lang.RuntimeException
errors: null
trace: java.lang.RuntimeException: 예외 발생 at ~~~
스프링 부트 오류 관련 옵션 (application.properties)
server.error.whitelabel.enabled=true : 오류 처리 화면을 못 찾을 시 스프링 whitelabel 오류 페이지
server.error.path=/error : 스프링 부트라 자동으로 등록하는 글로벌 오류 페이지 경로
server.error.include-exception=false : 기본 정보에 exception 포함 여부
server.error.include-message=never : 기본 정보에 message 포함 여부
server.error.include-stacktrace=never : 기본 정보에 trace 포함 여부
server.error.include-binding-errors=never : 기본 정보에 errors 포함 여부
→ true | false 혹은 never | always | on_param 옵션을 적용할 수 있다.
on_param 옵션은 특정 파라미터가 있으면 정보를 노출해준다. (message=&errors=&trace=)
확장 포인트
오류 처리 컨트롤러를 변경하고 싶다면 ErrorController 인터페이스를 구현하거나 BasicErrorController를 상속 받아서 기능을 추가하면 된다.

API 예외 처리

@RestController에서 JSON을 내릴 때 예외가 발생하면 HTML로 정의해둔 오류 페이지가 반환된다.
→ 우리가 원하는건 에외가 발생해도 JSON이 반환되는 것이다.
@RequestMapping("/error-page/500") public String errorPage500(HttpServletRequest request, HttpServletResponse response) { log.info("errorPage 500"); return "error-page/500"; } @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(ERROR_STATUS_CODE); return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode)); }
Java
복사
@RequestMapping의 produces 속성으로 반환되는 미디어타입을 지정해준다. (Path 중복 지정 가능)
→ 클라이언트의 HTTP Header에 Accept에 따라 메서드가 호출된다.
request로부터 status와 message를 받아 Map에 저장하고 ResponseEntity로 메시지컨버터가 JSON으로 반환하게 함

스프링 부트의 API 예외 처리

기존의 서블릿 방식은 작성할 코드가 너무 길다.
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE) public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) { HttpStatus status = getStatus(request); Map<String, Object> model = Collections .unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML))); response.setStatus(status.value()); ModelAndView modelAndView = resolveErrorView(request, response, status, model); return (modelAndView != null) ? modelAndView : new ModelAndView("error", model); } @RequestMapping public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { HttpStatus status = getStatus(request); if (status == HttpStatus.NO_CONTENT) { return new ResponseEntity<>(status); } Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL)); return new ResponseEntity<>(body, status); }
Java
복사
스프링 부트에서 제공하는 BasicErrorController엔 errorHtml(), error() 두 가지 메서드가 있다.
errorHtml() : HTTP Header 의 Accept가 text/html인 경우 호출해 View를 제공
error() : 그 외 경우 호출해 ResponseEntity로 JSON을 반환
BasicErrorController를 확장하면 JSON 메시지를 변경할 수 있지만 편리하진 않다.
→ HTML 오류 페이지를 제공하는 경우엔 편리하기 때문에 이 경우에만 사용하자.

HandlerExceptionResolver

API마다 상태 코드, 오류 메시지, 형식 등을 다르게 처리하고 싶다면 어떻게 해야 할까?
차이점은 ExceptionResolver에 의해 ModelAndView를 반환하면서 정상 응답을 한다는 것이다.
//HandlerExceptionResolver.java public interface HandlerExceptionResolver { @Nullable ModelAndView resolveException( HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex); }
Java
복사
Object handler : 핸들러에 대한 정보
Exception ex : 핸들러에서 발생한 예외 정보
//MyHandlerExceptionResolver.java @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; } }
Java
복사
ModelAndView를 반환하는 이유는 try-catch와 같이 예외를 처리하고 정상 흐름으로 변경하는 것이다.
빈 ModelAndView : 뷰를 렌더링 하지 않고 정상 흐름으로 서블릿이 리턴된다.
지정 ModelAndView : 뷰를 렌더링한다.
null : 다음 ExceptionResolver를 찾아서 실행하고 예외가 처리되지 않으면 WAS까지 던진다.
ex instanceof ~~ 를 통해 해당 예외인지 확인한 후 원하는 상태 코드와 메시지를 sendError()로 담고 ModelAndView를 반환한다.
response.sendError() 호출로 예외를 변경해 상태 코드에 따른 오류를 처리하도록 위임
이후 WAS는 오류 페이지를 찾아서 내부 호출
ModelAndView 에 값을 채워서 예외에 따른 오류 페이지를 렌더링해서 제공
response.getWriter() 나 JSON을 통해 API 응답 처리
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) { resolvers.add(new MyHandlerExceptionResolver()); } }
Java
복사
WebMvcConfigurer 를 통해 우리가 정의한 예외 처리를 등록해야 한다.
→ 이 때 configureHandlerExceptionResolver를 오버라이딩하면 기본 ExceptionResolver가 제거된다.
extendHandlerExceptionResolver를 사용하도록 하자.

스프링 부트의 HandlerExceptionResolver

직접 ExceptionResolver를 구현하는 것은 상당히 복잡하다.
그렇기 때문에 스프링 부트가 HandlerExceptionResolverComposite에서 다음 순서로 제공한다.
1.
ExceptionHandlerExceptionResolver (@ExceptionHandler를 처리한다.)
2.
ResponseStatusExceptionResolver (HTTP 상태 코드를 지정해 처리한다.)
3.
DefaultHandlerExceptionResolver (스프링 내부 기본 예외를 처리한다.)
ResponseStatusExceptionResolver
//in ResponseStatusExceptionResolver.java protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response) throws IOException { if (!StringUtils.hasLength(reason)) { response.sendError(statusCode); } else { String resolvedReason = (this.messageSource != null ? this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) : reason); response.sendError(statusCode, resolvedReason); } return new ModelAndView(); }
Java
복사
결국 response.sendError(statusCode, resolverdReason)을 호출하는 것은 똑같다.
빈 ModelAndView를 반환해 정상 흐름으로 돌린 후 WAS에서 오류 페이지를 재요청한다.
@ResponseStatus가 달려있는 예외를 처리하는 경우
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad") public class BadRequestException extends RuntimeException { }
Java
복사
위의 BadRequestException 예외가 컨트롤러 밖으로 던져지면 ResponseStatusExceptionResolver가 어노테이션을 확인 후 오류 코드를 HttpStauts.BAD_REQUEST로 변경하고 reason을 메시지에 담는다.
//messages.properties error.bad=잘못된 요청 오류입니다. 메시지 사용
Java
복사
reason을 MessageSource에서 찾는 기능도 제공된다.
ResponseStatusException 예외를 처리하는 경우
@ResponseStauts는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다.
또한 어노테이션을 사용하므로 조건에 따라 동적으로 변경하는 것도 어렵다.
@GetMapping("/api/response-status-ex2") public String responseStatusEx2() { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException()); }
Java
복사
이 때 ResponseStatusException에 상태 코드, 메시지, 실제 예외를 담아서 던지게 되면
ResponseStatusExceptionResolver가 예외를 삼키고 @ResponseStatus의 경우처럼 처리한다.
DefaultHandlerExceptionResolver
//in DefaultHandlerExceptionResolver.java @Override @Nullable protected ModelAndView doResolveException( HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) { try { if (ex instanceof HttpRequestMethodNotSupportedException) { return handleHttpRequestMethodNotSupported( (HttpRequestMethodNotSupportedException) ex, request, response, handler); } else if (ex instanceof HttpMediaTypeNotSupportedException) { return handleHttpMediaTypeNotSupported( (HttpMediaTypeNotSupportedException) ex, request, response, handler); } ...
Java
복사
ExceptionResolver에 의해 DefaultHandlerExceptionResolver가 호출됐을 때
등록된 예외의 한에서 미리 정의된 handle~Exception()을 호출해 알아서 처리해준다.
protected ModelAndView handleHttpMessageNotReadable(HttpMessageNotReadableException ex, HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException { response.sendError(HttpServletResponse.SC_BAD_REQUEST); return new ModelAndView(); }
Java
복사
handleTypeMismatch의 코드를 확인해보면 결국 response.sendError()로 처리하는걸 확인할 수 있다.
@ExceptionHandler
ResponseStatusExceptionResolver, DafaultHandlerExceptionResolver는 ModelAndView를 반환
→ API에 잘 맞지 않는다.
@Data @AllArgsConstructor public class ErrorResult { private String code; private String message; }
Java
복사
우선 예외가 발생 했을 때 API 응답으로 사용할 객체를 정의한다.
ErrorResult 객체를 통해 반환하지 않더라도 다양한 방법으로 예외처리 후 응답을 할 수 있다.
String으로 viewPath를 반환해서 오류 페이지 재호출
@RestController가 붙고 ErrorResult 객체를 @ResponseStauts와 함께 반환해서 컨버터에 의해 JSON 변환
ResponseEntity를 통해 ErrorResult를 상태코드와 함께 반환해서 컨버터에 의해 JSON으로 HTTP 메시지 바디에 직접 응답
ModelAndView를 반환해서 오류 화면 렌더링
@ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResult illegalExhandler(IllegalArgumentException e) { log.error("[exceptionHandler] ex", e); return new ErrorResult("BAD", e.getMessage()); } @ExceptionHandler public ResponseEntity<ErrorResult> userHandler(UserException e) { log.error("[exceptionHandler] 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 exHandler(Exception e) { log.error("[exceptionHandler] ex", e); return new ErrorResult("ex", "내부 오류"); }
Java
복사
@ExceptionHandler 혹은 해당 메서드의 파라미터에 처리하고 싶은 예외를 지정 해줄 수 있다.
(중괄호{}와 콤마를 이용해 다양한 예외를 한 번에 처리할 수 있다.)
ExceptionHandlerExceptionResolver는 해당 예외를 처리할 수 있는 @ExceptionHandler를 찾는다.
(예외의 상속관계도 지원되므로 지정한 예외의 자식 예외까지 처리할 수 있고 자세할 수록 우선순위이다.)
@ResponseStatus나 ResponseEntity 통해 상태코드를 지정할 수 있다.

@ControllerAdvice

하나의 Controller 안에 정상 코드와 예외 처리 코드가 섞여 있기 때문에 복잡해졌다.
→ @ControllerAdvice 또는 @RestControllerAdvice를 사용하면 분리할 수 있다.
//ExControllerAdvice.java @Slf4j @RestControllerAdvice public class ExControllerAdvice { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResult illegalExhandler(IllegalArgumentException e) { log.error("[exceptionHandler] ex", e); return new ErrorResult("BAD", e.getMessage()); } @ExceptionHandler public ResponseEntity<ErrorResult> userHandler(UserException e) { log.error("[exceptionHandler] 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 exHandler(Exception e) { log.error("[exceptionHandler] ex", e); return new ErrorResult("ex", "내부 오류"); } }
Java
복사
@ControllerAdvice는 지정된 컨트롤러들에 @ExceptionHandler, @InitBinder 기능을 부여해준다.
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
// 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 {}
Java
복사
@ControllerAdvice의 대상을 지정할 수 있는 방법은 여러 가지가 있다.
특정 어노테이션이 붙은 모든 컨트롤러
특정 패키지에 있는 모든 컨트롤러
특정 클래스를 선택해서 해당 클래스와 하위 클래스에 있는 모든 컨트롤러
대상을 지정하지 않으면 글로벌

Converter

스프링은 어느정도 타입 변환을 지원해준다.
@RequestParam, @ModelAttribute, @PathVariable
@Value 등으로 YML 정보 읽기
XML에 넣은 스프링 빈 정보를 변환
뷰를 렌더링 할 때
새로운 타입을 만들어서 변환하고 싶으면 어떻게 할까?
@FunctionalInterface public interface Converter<S, T> { @Nullable T convert(S source); }
Java
복사
스프링은 확장 가능한 컨버터 인터페이스를 제공한다. org.springframework.core.convert.converter.Converter
모든 타입에 적용할 수 있으며 X → Y 컨버터 , Y → X 컨버터를 구현해 등록하면 된다.
//StringToIpPortConverter.java @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); } } //IpPortToString.java @Slf4j public class IpPortToStringConverter implements Converter<IpPort, String> { @Override public String convert(IpPort source) { log.info("convert source={}", source); return source.getIp() + ":" + source.getPort(); } }
Java
복사
X → Y 를 구현하려면 Converter<X, Y> 를 구현하고 convert()를 오버라이딩하면 된다.
뷰 템플릿에서 컨버터
<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
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>
HTML
복사
<form>에서 사용하는 경우 th:field 는 자동으로 컨버전 서비스를 적용하고 th:value는 하지않는다.

ConversionService

컨버터를 하나하나 찾아서 변환에 사용하는 것은 불편하기 때문에 스프링은 컨버터를 모아두고 묶어서 사용할 수 있는 기능을 제공한다.
public interface ConversionService { boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType); boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType); @Nullable <T> T convert(@Nullable Object source, Class<T> targetType); @Nullable Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType); }
Java
복사
단순히 컨버팅이 가능한지 확인하는 기능과 컨버팅 기능을 제공한다.
DefaultConversionService는 ConversionService의 구현 객체로 컨버터를 등록하는 기능을 제공한다.
DefaultConversionService conversionService = new DefaultConversionService(); conversionService.addConverter(new StringToIntegerConverter()); conversionService.addConverter(new IntegerToStringConverter()); conversionService.addConverter(new StringToIpPortConverter()); conversionService.addConverter(new IpPortToStringConverter());
Java
복사
이렇게 등록하면 컨버터를 등록하고 사용하는 관심사를 분리할 수 있다.
ConversionService : 컨버터 사용에 초점
ConverterRegistry : 컨버터 등록에 초점
스프링 통합
스프링은 내부에서 ConversionService를 제공한다.
@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()); } }
Java
복사
이제 컨버터가 필요할 경우 Resolver에 의해 자동으로 찾아서 사용된다.
(@RequestParam의 경우엔 ArgumentResolver 에 의해 호출돼서 사용된다.)

Formatter

객체를 문자로 , 문자를 객체로 변경하는 두 가지 기능을 모두 수행한다.
Converter는 범용적(객체 → 객체)이고 Formatter는 문자 + 현지화에 특화(객체 → 문자, 문자 → 객체)
//Formatter.java public interface Formatter<T> extends Printer<T>, Parser<T> { } //Printer.java @FunctionalInterface public interface Printer<T> { String print(T object, Locale locale); } //Parser.java @FunctionalInterface public interface Parser<T> { T parse(String text, Locale locale) throws ParseException; }
Java
복사
String print(T object, Locale locale) : 객체 → 문자
T parse(String text, Locale locale) : 문자 → 객체
Formatter의 사용
@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); } }
Java
복사
Formatter<변환할 객체> 를 구현해 parse, print를 오버라이딩하면 된다.
FormattingConversionService
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService(); conversionService.addConverter(new StringToIpPortConverter()); conversionService.addConverter(new IpPortToStringConverter()); conversionService.addFormatter(new MyNumberFormatter());
Java
복사
FormattingConversionService는 ConversionService를 상속 받아 컨버터, 포매터 등록 가능
DefaultFormattingConversionService는 FormattingConversionService의 구현체
(기본적인 통화, 숫자 관련 몇가지 기본 포매터를 추가해서 제공한다.)
스프링 통합
@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()); } }
Java
복사
컨버터를 주석한 이유는 컨버터가 포매터보다 우선순위가 높기 때문이다.
WebMvcConfigurer의 addFormatters()를 오버라이딩하면 컨버터, 포매터를 등록할 수 있다.
@Data static class Form { @NumberFormat(pattern = "###,###") private Integer number; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime localDateTime; }
Java
복사
스프링이 기본적으로 제공하는 포매터는 형식이 지정되어 있기 때문에 변경하기가 어렵다.
→ 이 문제를 해결하기 위해 어노테이션 형식의 포매터를 제공한다.
@NumberFormat(pattern = “”) : 숫자 관련 형식 지정 포매터
@DateTimeFormat(pattern = “”) : 날짜/시간 관련 형식 지정 포매터
pattern으로 지정된 형식으로 변환되어 출력된다.
HttpMessageConverter
메시지컨버터에는 컨버전 서비스가 적용되지 않는다.
메시지컨버터의 역할은 HTTP 메시지 바디 → 객체, 객체 → HTTP 메시지 바디 이다.

HTML form 전송 방식

application/x-www-form-urlencoded
HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법
<form>에 별도의 enctype 속성이 없으면 웹 브라우저는 HTTP 요청 헤더에 아래의 내용을 추가한다.
Content-Type: application/x-www-form-urlencoded
전송할 데이터는 HTTP Body에 키=값 형식으로 &으로 구분해서 전송한다.
multipart/form-data
이 방식을 사용하려면 <form>에 enctype=”multipart/form-data” 를 지정해야 한다.
폼의 내용과 다른 종류의 여러 파일을 함께 전송할 수 있다.
폼의 데이터는 항목별로 Content-Disposition 헤더가 추가되어 나누어져 있고
파일의 경우 Content-Type까지 추가되어 바이너리 데이터가 전송된다.

서블릿의 파일 업로드, 다운로드

multipart 인 경우 DispatcherServlet은 MultipartResolver를 호출하고
HttpServletRequest 대신 MultipartHttpServletRequest를 구현한 StandardMultipartHttpServletRequest를 호출
file.dir=파일 업로드 경로 설정(): /Users/kimyounghan/study/file/
Java
복사
파일을 저장하기 위해선 해당 경로의 폴더를 미리 만들어두고 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 IOException, ServletException { 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)); } log.info("submittedFilename={}", part.getSubmittedFileName()); log.info("size={}", part.getSize()); 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"; } }
Java
복사
@Value는 properties에서 설정한 값을 주입한다.
request.getParts()를 통해 multipart 부분을 받아올 수 있다.
part.getSubmittedFileName() : 클라이언트가 전달한 파일의 이름
part.getInputStream() : part 별 전송 데이터
part.write(url) : part를 통해 전송된 데이터를 저장

스프링의 파일 업로드, 다운로드

스프링은 MultipartFile이라는 인터페이스로 multipart 파일을 매우 편리하게 사용하도록 제공한다.
@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"; } }
Java
복사
@RequestParam 또는 @ModelAttribute MultipartFile file 로 파일을 받아와 사용할 수 있다.
file.getOriginalFilename() : 업로드 파일 명
file.transferTo(new File(url)) : 파일 저장
//파일 저장과 관련된 로직 //in ItemController.java @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}"; } //FileStore.java @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(!multipartFiles.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); } private String createStoreFileName(String originalFilename) { String uuid = UUID.randomUUID().toString(); String ext = extractExt(originalFilename); return uuid + "." + ext; } private String extractExt(String originalFilename) { int pos = originalFilename.lastIndexOf("."); return originalFilename.substring(pos + 1); } }
Java
복사
@ModelAttribute itemForm 에 MultipartFile을 매핑해 사용할 수 있다.
//파일 다운로드와 관련된 로직 //in ItemController.java @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 urlResource = 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(urlResource); }
Java
복사
UrlResource 을 통해 URL에 맞는 이미지 파일을 읽고 @ResponseBody를 통해 이미지 바이너리를 반환
그냥 ResponseEntity를 반환하게 되면 다운로드되지 않고 내용만 보이게 된다.
→ contentDisposition으로 Content-Disposition 헤더를 추가해서 보내면 다운로드가 된다.