로그인에 대해 설명하기 앞서 도메인에 대해 잠깐 이야기해보겠다.

 

도메인은 시스템이 구현해야 하는 핵심 비즈니스 업무 영역으로 향후 웹 기술이 다른 기술로 바뀌어도 도메인은 그대로 유지되어야 하므로 웹이 도메인을 의존해도 되지만, 도메인은 웹에 의존해선 안된다.

 

로그인을 하면 로그인 상태를 유지해야하는데 HTTP는 비연결성이므로 이를 해결하기 위해 쿠키 라는 것을 사용한다.

 

쿠키에도 두가지 쿠키가 있다.

 

영속 쿠키 : 만료 날짜를 기입하여 해당 날짜까지만 유지하는 쿠키

세션 쿠키 : 만료 날짜를 생략하여 브라우저 종료시까지 유지

 

보통은 브라우저를 종료했을 때 로그아웃 되길 바라므로 세션 쿠키로 구현해보겠다.

 

Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);

로그인에 성공하면 쿠키(이름 : memberId, 값 : 회원의 id) 를 생성하고 response에 담는다.

 

@Controller
@RequiredArgsConstructor
public class HomeController {
 
 private final MemberRepository memberRepository;
 @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 "loginHome";
 }
}

@CookieValue를 사용하여 쿠키를 조회할 수 있다. 여기서 required 옵션은 로그인을 하지 않은 사용자도 메인 홈에는 접근할 수 있어야하기 때문에 false로 주었다.

 

이렇게 되면 쿠키 값이 null이면 home으로 보내고, 쿠키 값이 있어도 회원이 없으면 home으로 보낸다.

 

쿠키도 있고 회원 정보도 있으면 통과한다.

 

쿠키를 이용하여 로그인 기능을 구현했는데, 로그아웃 기능은 어떻게 구현해야 할까?

간단하게 쿠키의 종료 날짜를 0으로 지정하면 된다.

 

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
 Cookie cookie = new Cookie("memberId", null);
 cookie.setMaxAge(0);
 response.addCookie(cookie);

 return "redirect:/";
}

이렇게 쿠키의 setMaxAge 메서드를 이용하여 0으로 설정하면 쿠키가 즉시 만료된다.

 

이렇게 쿠키만 사용해서 구현하게 된다면 심각한 보안 문제가 발생한다.

 

그 이유는 쿠키 값은 임의로 변경이 가능하다. 임의로 변경이 가능하다는 것은 다른 사용자 행세를 할 수 있다는 말이 된다. 당장 개발자 도구를 키고 Cookie: memberId=1 값을 2로 변경하면 다른 사용자로 변경이 된다.

 

그리고 쿠키에 보관된 정보도 훔쳐갈 수 있으며, 도난당한 쿠키 정보를 통해 악의적인 요청을 계속해서 시도할 수 있다.

 

이러한 문제를 해결하기 위해 나온 방식이 세션 동작 방식이다.

 

세션 동작 방식을 사용하게 되면 로그인 순서는 다음과 같다

 

1. 먼저 POST로 ID 값과 비밀번호 값을 사용자가 전달하면 서버는 해당 사용자가 맞는지 확인한다.

 

2. 사용자가 맞다면 추정 불가능한 랜덤 값을 이용하여 세션 ID를 생성한다. 그리고 생성된 세션 ID와 세션에 보관할 값을 서버의 세션 저장소에 보관한다. -> sessionId: 랜덤한 값, value: 사용자

 

3. 서버는 클라이언트에게 쿠키 이름 : JSESSIONID, 값: 세션 ID  형태로 쿠키 저장소에 저장한다.

 

즉, 회원과 관련된 정보는 클라이언트가 일절 모른다는 것이다. 오로지 추정 불가능한(랜덤 값) 세션 ID만 쿠키의 값으로 전달한다.

 

이후 사용자가 클라이언트 요청시 JSESSIONID 쿠키를 전달하면 서버는 해당 쿠키의 값(세션ID)을 세션 저장소에서 조회하여 로그인시 보관한 세션 정보를 사용한다.

 

서블릿은 세션을 위해 HttpSession을 제공한다.

 

HttpSession session = request.getSession(); // 세션이 있으면 세션 반환, 없으면 세션 생성

session.setAttribute("loginMember", loginMember); // 세션에 로그인 정보 보관

로그인 로직이 성공하면 세션에 회원 정보를 담아준다.

 

여기서 request.getSession()에는 두가지 옵션이 있는데 기능은 다음과 같다.

 

request.getSession(true) : 세션이 있으면 기존 세션을 반환하고 없으면 새로 생성하여 반환

request.getSession(false) : 세션이 있으면 기존 세션을 반환하고 없으면 생성하지 않고 null을 반환

옵션을 주지 않으면 자동으로 true가 사용된다.

 

그리고 session.setAttribute()는 세션에 데이터를 저장한다.

 

로그인 로직을 보자

@GetMapping("/login")
public String LoginForm(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 "loginHome";
}

로그인을 살펴보면 세션정보가 없거나, 매핑되는 로그인 정보가 없다면 로그인 창으로 보내고 로그인에 성공하면 이후 로직이 출력된다.

 

로그인 정보는 session.getAttribute()로 불러오고 타입 캐스팅을 해주어서 검증한다.

 

HttpSession session = request.getSession(false);

if (session != null) {
 session.invalidate();
}

로그아웃 로직은 이와 같이 세션이 없다면 세션을 제거하는 로직으로 짜면 된다.

 

스프링에서는 이를 편리하게 사용할 수 있게 @SessionAttribute라는 어노테이션을 지원한다.

@GetMapping("/login")
public String LoginForm(
@SessionAttribute(name = "loginMember", required = false) Member loginMember,
Model model) {

 if (loginMember == null) {
 return "home";
 }

 model.addAttribute("member", loginMember);
 return "loginHome";
}

세션을 찾고 세션 정보를 통해 데이터를 얻는 과정을 편리하게 해결해준다.

 

이렇게 로그인을 처음 시도하게 되면

http://localhost:8080/;jsessionid=123456789ABCDEF

형태로 jsessionid 파라미터가 붙는다.

 

이것이 붙는 이유는 서버 입장에서는 웹 브라우저가 쿠키를 지원하는지를 모르고, 혹시라도 웹브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해 세션을 유지하기 위함이다.

 

URL 전달 방식을 이용하지 않고 쿠키를 통해서만 세션을 유지하려면(URL 뒤에 jsession 정보가 안붙게) 다음과 같이 입력한다.

 

application.properties

server.servlet.session.tracking-modes=cookie

 

세션의 타임아웃(세션 만료) 값 설정은 다음과 같다.

 

글로벌 설정 (분 단위로 설정해야 하고, 입력 값(1800) 은 초단위임)

server.servlet.session.timeout=1800

 

마지막 작업을 기준으로 세션 시간 설정

session.setMaxInactiveInterval(1800);

 

'Spring > SpringMVC' 카테고리의 다른 글

스프링 검증 - Bean Validation  (0) 2023.02.11
스프링 검증  (0) 2023.02.10
메시지, 국제화  (0) 2023.02.05
타임리프 - 스프링 통합과 폼  (0) 2023.02.05
타임리프 기본 기능 모음  (0) 2023.02.04

검증을 이전처럼 필드 하나하나 지정해서 오류를 출력하는 것은 정말 귀찮은 일이다.

 

다음 코드를 보자

 

public class Item {

 private Long id;
 
 @NotBlank
 private String itemName;
 
 @NotNull
 @Range(min = 1000, max = 1000000)
 private Integer price;
 
 @NotNull
 @Max(9999)
 private Integer quantity;
}

@NotBlank : 빈 값 혹은 공백일 경우 허용X

@NotNull : null을 허용하지 X

@Range(min = 1000, max = 1000000) : 해당 범위 안의 값이어야 함

@Max(9999) : 최대 9999까지만 허용

 

이렇게 간단하게 사용할 수 있는 것이 Bean Validation이다.

 

Bean Validation은 구현체가 아니라 검증 애노테이션과 인터페이스의 모음으로 Bean Validation 2.0(JSR-380) 기술표준이다.

 

스프링 부트에 spring-boot-starter-validation 라이브러리를 넣는 순간 Bean Validator를 인지하고 스프링에 통합하여 사용시 @Valid, @Validated (둘중 하나) 만 적용하면 검증이 적용된다.

 

검증 순서로는 @ModelAttribute 각각의 필드에 타입 변환을 시도하여 성공하면 Validator 적용하고 실패하면 typeMismatch로 FieldError를 추가한다.

 

즉, 바인딩에 성공한 필드만 검증을 적용한다.

 

Bean Validation이 제공하는 오류메시지를 변경하려면

 

errors.properties

NotBlank={0} 공백X 
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

이렇게 적용하면 되는데, {0}은 필드명이고 {1}, {2} 는 각각 애노테이션마다 다르다

 

BeanValidation이 메시지를 찾는 순서를 보면

1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기(바로 위의 코드)

2. 애노테이션의 message 속성 사용

@NotBlank(message = "공백입니다. {0}")

3. 라이브러리가 제공하는 기본 값 -> 공백일 수 없습니다.

 

Bean Validation에서 FieldError(필드 하나) 가 아닌 ObjectError(여러 필드)가 발생하면

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 
10000")

도메인 객체가 있는 곳에 @ScriptAssert를 이렇게 적용해주면 된다.

 

하지만 @ScriptAssert는 실제 사용시 제약이 많고 복잡한 부분이 있어 ObjectError 부분만 따로 자바코드로 작성하는 것을 권장한다.

 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);}}

 

예를 들어, 등록시에는 수량을 최대 9999까지 등록할 수 있고 id 값이 필요 없지만 수정시에는 수량을 무제한으로 변경할 수 있고 id값이 필수 값인 상황이라면 어떻게 적용해야할까?

 

해결 방법은 두가지가 있는데,

 

첫번째로는 Bean Validation의 groups 기능을 이용하면 해당 문제를 해결할 수 있다.

두번째로는 도메인 객체를 직접 사용하지 않고 ItemSaveForm, ItemUpdateForm과 같은 별도의 모델 객체를 생성하여 해결할 수 있다.

 

먼저 groups 기능먼저 살펴본다.

 

SaveCheck

public interface SaveCheck {
}

 

UpdateCheck

public interface UpdateCheck {
}

 

적용시

@NotNull(groups = UpdateCheck.class)
private Long id;
 
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class)
private Integer quantity;

id 값은 수정시에만 적용해야하고, 수량값은 등록시에만 9999개 제한을 걸어야 하므로 위와같이 groups로 적용시켜준다.

 

컨트롤러로 수정을 해주어야 하는데

 

등록 컨트롤러

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

 

수정 컨트롤러

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class)
@ModelAttribute Item item, BindingResult bindingResult) {
}

 

위의 코드처럼 @Validated에 적용할 것을 지정해주면 된다.

 

이렇게 groups 기능을 알아보았지만, groups 기능은 사실 실무에서 잘 사용되지 않는다.

 

그 이유는 실무에서는 회원 등록시 데이터만 전달받는것 뿐만 아니라 약관 정보와 같은 추가적인 정보도 받아야 해서 groups기능으로 하나하나 적용하기엔 너무 복잡해진다.

 

따라서 도메인 객체를 직접 전달받는 것이 아닌 별도의 폼 객체를 만들어 전달하는 것이 효율적이다.

 

등록 폼

@Data
public class ItemSaveForm {
 @NotNull
 @Max(value = 9999)
 private Integer quantity;
}

 

수정 폼

@Data
public class ItemUpdateForm {
 @NotNull
 private Long id;
 
 private Integer quantity;
}

 

등록 폼에는 수량의 제한이 있고, 수정폼에는 제한이 없어진 것을 볼 수 있고, Id 값은 수정 폼에만 존재하는 것을 확인할 수 있다.

 

컨트롤러도 설정해주자

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes)

먼저 @ModelAttribute를 도메인 객체가 아닌 폼을 통해 받는다.

 

if (form.getPrice() != null && form.getQuantity() != null) {
	int resultPrice = form.getPrice() * form.getQuantity();
	if (resultPrice < 10000) {
		bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);}}

폼을 이용하여 검증 로직을 구현하고

 

Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());

도메인 객체를 생성하여 폼의 내용들을 도메인 객체에 입력시킨다.

 

폼(DTO)를 이용한 방법을 사용하도록 하자

'Spring > SpringMVC' 카테고리의 다른 글

스프링 로그인 - 쿠키, 세션  (0) 2023.02.12
스프링 검증  (0) 2023.02.10
메시지, 국제화  (0) 2023.02.05
타임리프 - 스프링 통합과 폼  (0) 2023.02.05
타임리프 기본 기능 모음  (0) 2023.02.04

검증은 정말 중요하다. 예를들어, 웹페이지에서 아이디 비밀번호를 입력해서 로그인 해야하는데 입력하지 않고도 통과되면 안되듯이 꼭 검증을 해줘야 한다.

 

컨트롤러의 중요한 역할 중 하나인 검증은 클라이언트 검증과 서버 검증으로 나눌 수 있다.

 

클라이언트만 검증을 할 경우 버프스위트같은 프로그램 등으로 조작할 수 있으므로 보안에 매우 취약하다.

서버만 검증하게 된다면 즉각적인 고객 사용성이 부족해진다.

 

즉, 둘을 적절히 섞어 사용해야 하며 서버 검증은 필수적이다.

 

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

검증 오류 결과를 보관할 errors라는 Map을 만들었다.

 

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

만약 상품의 이름이 공백이라면 에러를 띄워야 하는데, 이 때 키값은 오류가 발생한 필드명으로 하였고 value값은 출력할 오류 문자열을 넣어주었다.

 

if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값= " + resultPrice);
 }
}

필드 하나만 다루지 않고 여러개의 필드를 같이 다뤄서 처리해야하는 경우는 필드 이름을 넣을 수 없으므로 globalError라는 키값을 사용했다.

 

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

검증 실패시에는 모델에 errors를 담고 입력 폼이 있는 뷰 템플릿으로 보낸다.

 

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

만약 errors에 globalError가 있다면 출력하고 없으면 출력을 안한다.

 

여기서 errors?. 부분을 보면 errors가 null일때 보통은 NullPointException을 출력하는데 이것을 대신 null으로 반환하는 타임리프 문법이다. 즉, 없다면 if=null이 되며 출력되지 않는다.

 

이제부터 타입 오류처리와 뷰 템플릿에서의 중복 제거를 해보고 고객이 입력한 문자를 화면에 남기는 것을 해본다.

 

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
    if (!StringUtils.hasText(item.getItemName())) {
	 bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
	}
    
    if (item.getPrice() != null && item.getQuantity() != null) {
 		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.addError(new ObjectError("item",
            "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
 		}
    }
}

BindingResult 파라미터 위치는 검증할 대상 바로 뒤에 위치해야 한다

 

필드 하나만 오류 처리할 경우 FieldError 객체를 생성하여 bindingResult에 담아주고,

여러 필드를 다룬다면 ObjectError 객체를 생성하여 bindingResult에 담아주면 된다.

 

파라미터 값은 첫번째로 @ModelAttribute 이름이 들어가고 두번째는 오류가 발생한 필드명 세번째는 오류 기본 메시지를 입력해주면 된다.

 

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

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

타임리프에서는 BindingResult에 대해 편리하게 검증 오류를 표현하는 기능을 제공한다.

 

여기서 #fields를 통해 BindingReuslt가 제공하는 검증 오류에 접근할 수 있고, th:errors로 필드에 오류가 있을 때만 출력하게 하며 th:errorclass로 th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 

이렇게 하면 검증은 다 완료되었지만, 검증 실패 시 데이터가 싹 다 날라가버린다.

 

이를 방지하기 위한 방법을 살펴본다.

 

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
 }

파라미터를 살펴보면

 

1. 오류가 발생한 객체 이름

2. 오류 발생 필드명

3. 사용자가 입력한 값(검증에 실패한 값)

4. 타입 오류인지 검증 실패인지 구분 해주는 값

5. 메시지 코드

6. 메시지에서 사용하는 인자

7. 기본 오류 메시지

 

순이다.

 

사용자의 입력한 값이 컨트롤러의 @ModelAttribute에 바인딩 되는 시점에 오류가 발생하게 되면 모델 객체에서 사용자가 입력한 값을 유지하기 힘들다.

 

예를 들어 문자열을 받아야하는데 숫자를 받아버리면 다른 타입이므로 보관할 방법이 없다. 이러한 이유 때문에 오류 발생시 사용자 입력 값을 별도로 보관해야한다. 그리고 보관한 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.

 

FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.

 

추가로 타임리프에서의 사용자 입력 값을 어떻게 유지하냐면

th:field="*{price}"

th:field로 지정해주면 오류 발생시 FieldError에서 보관한 값을 사용하여 출력한다.

 

타입오류로 바인딩에 실패하면 FieldError를 생성하여 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingReuslt에 담아 컨트롤러를 호출한다. 이렇게 된다면 타입 오류 발생시에도 오류 메시지를 정상 출력할 수 있다.

 

오류메시지에 대해 살펴보자

 

먼저 오류 메시지를 담아두는 errors.properties라는 파일을 만들고,

스프링 부트가 해당 메시지 파일을 인식할 수 있게

spring.messages.basename=messages,errors

 

errors.properties

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

 

errors를 추가해준다. 여기서 {0} {1}은 파라미터 값으로 이해하면 된다.

 

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

사용시 위의 방식처럼 5번째 파라미터 값에 new String[]{"error message 키 값"} 으로 메시지 코드를 지정해 줄 수 있다.

여기서 메시지 코드가 배열 형식으로 여러 값을 전달할 수 있는데 순서대로 매칭되어서 처음 매칭되는 메시지가 사용된다.

 

6번째 파라미터 값에는 파라미터값이 있다면 파라미터 값을 new Object[]{0번째 파라미터 값, 1번째 파라미터값} 형식으로 사용한다.

 

하지만 이렇게 사용할 경우 파라미터값이 너무 많아 사용하기에 불편하다.

 

BindingResult가 제공하는 rejectValue(), reject()를 사용하면 간단히 검증할 수 있다.

 

if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.rejectValue("itemName", "required");}
 
 if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
 bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);}

 if (item.getQuantity() == null || item.getQuantity() > 10000) {
 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);}}

해당 코드는 위의 코드와 같은 코드이고 매우 간소화된 것을 볼 수 있다.

 

rejectValue, reject()의 파라미터는 (둘은 필드에러냐 오브젝트에러냐의 차이)

1. 오류 필드명

2. 오류 코드

3. 오류 메시지에서의 파라미터 값 ex) {0} {1}

4. 기본 메시지

 

로 구성되어 있다.

 

여기서 왜 여기서는 오류가 발생한 객체 이름을 안묻는지 의문이 들 수 있다.

 

그 이유는 BindingResult는 무조건 검증할 @ModelAttribute 객체 뒤에 위치해야 하므로 BindingResult는 검증할 객체 대상을 이미 알고있으므로 생략 가능하다.

 

오류 코드는 자세하게 만들 수도 있고, 간단하게 만들 수도 있다.

 

예를 들어,

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

이렇게 자세하게 적용 객체와 필드를 지정해서 할 수도 있고,

required: 필수 값 입니다.

간단하게 적용할 수도 있는데, 우선순위는 구체적인 것이 더 높다.

 

우선순위를 자세히 알아보기 위해 MessageCodesResolver 인터페이스의 구현체인 DefaultMessageCodesResolver 구현체를 알아본다.

 

객체 오류시에

1. code + "." + 객체명 ex) required.item

2. code ex) required

 

필드 오류시에

1. code + "." + 객체 이름 + "." + 필드 ex) required.item.itemName

2. code + "." + 필드 ex) required.itemName

3. code + "." + 필드 타입 ex) required.String

4. code ex) required

 

순서이다. rejectValue()나 reject() 메서드는 내부에서 MessageCodesResolver를 사용하여 메시지 코드를 생성한다.

 

중요한 것은 구체적으로 작성한 것이 우선순위가 높고 추상적일수록 우선순위가 낮다.

 

즉, 이것들을 종합한 순서는 다음과 같다.

 

1. rejectValue() 호출

2. MessageCodesResolver를 사용해 검증 오류 코드로 메시지 코드들 생성

3. new FieldError()를 생성하며 메시지 코드들 보관

4. th:errors 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고 출력

 

그리고 타입 오류가 발생하면 스프링은 typeMismatch라는 오류코드를 출력하는데 이도 4가지의 메시지 코드가 출력되는데 몇가지만 고쳐서

typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

이런 형태로 메시지를 지정하여 출력할 수 있다.

 

이러한 검증 로직들이 모두 컨트롤러에 한꺼번에 들어가있으면 가독성도 안좋을 뿐더러 배보다 배꼽이 더 큰 꼴이 되어버린다. 그래서 별도의 클래스로 분리해주어야 한다.

 

ItemValidaotr.class

@Component
public class ItemValidator implements Validator {

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

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

 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 errors.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);}}}}

Validator라는 인터페이스를 이용하면 먼저 supports()로 검증기가 지원하는지 여부를 확인하고, validate()로 검증 객체와 BindingResult를 받아 검증하는 로직을 작성한다.

 

private final ItemValidator itemValidator;

itemValidator.validate(item, bindingResult);

사용시에는 의존관계 주입을 해주고, validate() 메서드로 검증해주면 된다.

 

여기서 추가적으로 스프링의 도움을 받아보자

 

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

@InitBinder는 해당 컨트롤러에만 영향을 주고,

 

WebDataBinder는 스프링의 파라미터 바인딩 역할을 해주고 검증 기능도 내부에 포함하므로 검증기를 자동으로 적용할 수 있다.

 

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult 
bindingResult, RedirectAttributes redirectAttributes)

이렇게 validator를 직접 호출하는 부분을 제거하고, 파라미터로 @Validated를 입력하니 기존과 동일하게 작동하는 것을 확인할 수 있다.

 

@Validator는 검증기를 실행하라는 애노테이션으로 WebDataBinder에 등록된 검증기를 찾아 실행한다. 만약 여러 검증기가 등록 되어있다면, 어떤 검증기가 실행되어야 하는지 support() 메서드로 구분하여 처리한다.

'Spring > SpringMVC' 카테고리의 다른 글

스프링 로그인 - 쿠키, 세션  (0) 2023.02.12
스프링 검증 - Bean Validation  (0) 2023.02.11
메시지, 국제화  (0) 2023.02.05
타임리프 - 스프링 통합과 폼  (0) 2023.02.05
타임리프 기본 기능 모음  (0) 2023.02.04

기존에 정해놓았던 문구가 있었는데, 업체가 문구를 바꿔달라고 요청한다면 해당 문구가 적혀있는 모든 코드들을 수정해야해서 유지보수 측면에서 본다면 하드코딩된 문구들이 많은 것은 좋지 않다. 그리고 해외에서 접속 시 한글이 아닌 영어로 페이지를 나타내고 싶다면 어떻게 해야할까?

 

이것을 해결해줄 메시지와 국제화에 대해 소개한다.

 

메시지

동적으로 문구를 출력하려면 먼저 message.properties 파일을 만들어주고 파일 내에 적용시킬 키워드와 값을 지정해준다.

 

message.properties

page.addItem=상품 등록
page.name=페이지 이름은 {0}

여기서 {0}은 파라미터 값으로 여기에 값을 지정해서 넣어줄 수 있다.

 

타임리프에서 활용시

<h2 th:text="#{page.addItem}">상품 등록</h2>
<p th:text="#{page.name(${item.itemName})}">페이지 이름은 상품</p>

타임리프로 적용시 #{적용시킬메시지} 해주면 message.properties 내의 문구만 수정한다면 동적으로 적용이 되고,

파라미터 값은 ()괄호 안에 지정해주면 파라미터 값이 출력된다.

 

 

국제화

해외는 보통 영어로 소통하니 영어로 된 메시지를 만들기 위해 message_en.properties 파일을 만들고 똑같이 메시지를 지정해준다.

 

message_en.properties

page.addItem=Item Add

이렇게하고 브라우저의 언어를 영어로 바꾸면 영어로 출력되는 것을 확인할 수 있다.

 

이 원리는 Accept-Language 헤더값에 따라 동적으로 출력되는 원리이다.

'Spring > SpringMVC' 카테고리의 다른 글

스프링 로그인 - 쿠키, 세션  (0) 2023.02.12
스프링 검증 - Bean Validation  (0) 2023.02.11
스프링 검증  (0) 2023.02.10
타임리프 - 스프링 통합과 폼  (0) 2023.02.05
타임리프 기본 기능 모음  (0) 2023.02.04

+ Recent posts