Error - MessageSource w/ Validator and <Spring:Message>

2021. 1. 5. 00:16프로젝트/Salle(살래) 중고거래 웹

예상보다 시간이 남아 MessageSource가 출력되지 않는 오류를 잡아주기로 했습니다. 먼저 MessageSource 클래스란 properties 형식의 파일에 messageVariant(변수)와 messageContext(내용)을 작성해두면 JSP파일과 Validator의 Error 객체 등 Spring 내에서 messageVariant를 이용해 내용을 호출시킬 수 있어 편리합니다.

Messages_ko_KR.properties 코드

#category
digital = 디지털/가전
furniture = 가구/인테리어
kids = 유아동/유아도서
lifestyle = 생활/가공식품
sports = 스포츠/레저
womengoods = 여성잡화
womenclothes = 여성의류
menclothes = 남성패션/잡화
games = 게임/취미
beauty = 뷰티/미용
pets = 반려동물식품
books = 도서/티켓/음반
plants = 식물
etc = 기타중고물품

incorrectFormat.email = 올바른 이메일 형식으로 입력해주세요.
required.confirmPassword = 비밀번호를 한 번 더 입력해주세요.
required.password = 비밀번호를 입력해주세요.
required.name = 이름를 입력해주세요.
required.nickName = 닉네임을 입력해주세요.
required.phoneNum = 휴대전화 번호를 입력해주세요.
required.email = 이메일을 입력해주세요.
unmatch.confirmPassword = 비밀번호를 정확히 입력해주세요.
duplicate.email = 중복된 이메일입니다.
required = 테스트
password.required = 테스트

 

오류는 message properties에 지정된 message들이 출력되지 않는 것이었습니다. Spring Boot 기준 MessageSource를 사용하기 위해 Runtime Application 클래스에 Bean 등록을 해줘야 합니다. 오류의 원인은 ResourceBundleMessageSource setbasename() 메서드에 들어가는 message properties 경로(path) 양식었습니다. 그리고 실제 저장된 파일의 경로를 물리적으로 변경해 시도해보았습니다. 해결책은 아래와 같은 경로에 message properties 파일을 저장해주었을 때 MessageSource 설정입니다.

Spring docs를 뒤져봤지만 Messagesource setbasename에 대한 정확한 경로표기에 대해선 나와있지 않습니다. 구글링 했을 때 “classpath:label.message”, “label.message”, “label/message”으로 쓰여있었습니다. 제 케이스는 마지막 양식이 맞았고, 파일이 저장된 디렉토리를 옮겨서 시도해보는 것도 오류 해결에 도움이 될 수 있다고 생각됩니다.

디렉토리 구조

MessageSource 설정 코드  

ResourceBundleMessagSource는 messageSource 관련 설정을 담당하는 인터페이스 입니다. 영문으로 설명이 깔끔한 SpringFramework Doc 링크를 공유해드립니다.(링크) Locale.setDefault는 기본 언어를 지정해주는 것인데, 한글과 영문만 쓸 것이므로 KOREA로 설정해줬습니다.

	@Bean
	public MessageSource messageSource() {
		Locale.setDefault(Locale.KOREA);
		ResourceBundleMessageSource messageSource =
				new ResourceBundleMessageSource();
		messageSource.setBasename("label/messages");
		messageSource.setDefaultEncoding("UTF-8");
		return messageSource;
	}

    public static void main(String[] args) {
    	SpringApplication.run(DemoApplication.class, args);
    }

 

이렇게 설정된 message들을 View에서 클라이언트에 보여줄 수 있습니다. <Spring:message code="변수명">을 사용해서요.

message 사용 JSP 예시 코드

실제 클라이언트가 보는 화면에선 지정해둔 message가 출력됩니다.

			<strong>카테고리</strong>
			<ul>
				<li><a href="<c:url value="/category/digital"/>"><spring:message code="digital"/></a></li>
				<li><a href="<c:url value="/category/furniture"/>"><spring:message code="furniture"/></a></li>
				<li><a href="<c:url value="/category/kids"/>"><spring:message code="kids"/></a></li>
				<li><a href="<c:url value="/category/lifestyle"/>"><spring:message code="lifestyle"/></a></li>
				<li><a href="<c:url value="/category/sports"/>"><spring:message code="sports"/></a></li>
				<li><a href="<c:url value="/category/womengoods"/>"><spring:message code="womengoods"/></a></li>

 

두 번째 오류는 Validatior에서 errorcode가 register된 Error 인스턴스가 있음에도 MessageSource에 지정해둔 messageContext가 작동하지 않는 것이었습니다. 주된 원인은 JSP파일 <form:errors path=””> 태그 누락이었습니다.

<form: errors path=””>는 Spring Validator 클래스에 설정된  Error 객체의 인스턴스 error에 register된 “field” 중 path와 동일한 field가 있으면 해당 field의 error code를 View에 출력해줍니다. 이는 Validator 인터페이스와 MessageSource 인터페이스가 상호작용하고 Error 인터페이스와 MessageSource도 상호작용을 하기 때문에 가능한 것입니다. 참고하기 좋은 자료 링크를 첨부해드립니다. How MessageSource & Validator - Error interacts (링크)

또한 validate(Object target, Errors errors)target parameter에 들어갈 객체 Product를 View JSP파일에서 modelAttribute로 받아줘야하기 때문에 <form modelAttribute="Product">는 있어야합니다. 없다면 객체에 binding된 변수들이 없기 때문에 Error가 validate 할 수 없기 때문입니다.

자료를 읽어보시면 알수있듯이 Error 인스턴스에 register된 field.errorcode 아니면 errorcode의 조합으로 message properties의 messageVariant로 설정하면 MessageSource가 자동으로 인식해 JSP파일 <form:errors path="필드명">는 일치하는 Error field를 찾아 화면에 출력해줍니다.

<form: error path=""> 예시 JSP 코드

    <form:form action="done" modelAttribute="member" method="post">
    <p>
        <label>
        이메일:
        <form:input path="email"/>
        <form:errors path="email" value="error test" />
        </label>
    </p>

    <p>
        <label>
        비밀번호:
        <form:password path="password"/>
        <form:errors path="password"/>
        </label>
    </p>

 화면에는 이런 식으로 error message가 출력됩니다.

 

RegisterController 코드

Request GET mapping에서 Model에 비어있는 Member 객체를 미리 addAttribute 해준 이유는 클라이언트가 GET으로 호출된 화면에서 회원가입 정보를 입력하고 그 정보들은 Member 객체에 담겨 POST 방식으로 Controller에 Modelattribute로 전달해줄 것이기 때문입니다. 그리고 <form:input path=""> 태그를 name이 아닌 path으로 사용하면 JSP에서 Model에 저장된 객체(Member)로 입력받은 데이터들을 바인딩(binding) 해줍니다.

package com.example.demo.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import com.example.demo.domain.Member;
import com.example.demo.exception.AlreadyExistMember;
import com.example.demo.mapper.MemberMapper;
import com.example.demo.validation.RegisterValidation;


@Validated
@Controller
public class RegisterController {
	
	  @Autowired
	  MemberMapper memberService;
	  
	  
	    //회원가입 페이지 노출
	    @RequestMapping(value = "/register/main", method = RequestMethod.GET)
	    public String registerAttempt(Model model) {

	        model.addAttribute("member", new Member());
	        return "register/main";
	    }

	    @RequestMapping(value = "/register/done", method = RequestMethod.POST)
	    public String registerHandle(@ModelAttribute Member member,
	                                 Errors errors) {

	        new RegisterValidation().validate(member, errors);

	        if (errors.hasErrors()) {
	            return "register/main";
	        }
	        
		        try {
		            memberService.insertMember(member);
		            return "register/done";
		        } catch (AlreadyExistMember e) {
		            errors.rejectValue("email", "duplicate");
		            return "register/main";
		        }
		    }
	
}

Validator 코드

특수문자로 이루어진 양식은 올바른 이메일인지 검증하기 위한 패턴입니다. Validation 클래스 객체가 호출되면 자동으로 pattern이 초기화 되도록 생성자 내 메서드로 추가되어 있습니다.

Validator를 implements한 클래스이기 때문에 상속해야할 두 개의 @Override 메서드가 있습니다. 첫번째로 supports는 validate하려는 target과 내가 지정한 클래스의 관계성을 확인해 결과를 boolean으로 반환합니다. 다시말해 두번째 메서드인 validate에 들어가는 target 인스턴스가 supports의 aClass로 들어오구요. 그리고 Member.class와 관계성을 따져 target의 class  혹은 superclass가 맞는지 확인한 후 true/false를 return해줍니다. 제가 설명드리는 것은 모두 Spring Framework 참고문서에 나와있습니다. 링크 걸어드립니다.(링크)

그리고 오류 검증은 validate 메서드 내에서 이루어집니다. Errors 객체를 parameter로 받아 errors 인스턴스에 rejectValue(), reject...() 등의 메서드들을 실행시켜 reject 되면(== 에러가 발생하면) errors 인스턴스에 "error code"와 "error field"가 등록됩니다. reject로 시작하는 메서드에 String으로 들어간 parameter가 error code와 field 입니다.

package com.example.demo.validation;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;

import com.example.demo.domain.Member;
import com.example.demo.validation.RegisterValidation;

public class RegisterValidation implements Validator {
	
	   private static final String EMAIL_REG_EXP =
	            "^[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9]+)*@" +
	                    "[A-Za-z0-9]+(\\.[_A-Za-z0-9]+)*(\\.[A-Za-z]{2,})$";

	    private Pattern pattern;

	    public RegisterValidation() {
	        pattern = Pattern.compile(EMAIL_REG_EXP);
	    }

	    @Override
	    public boolean supports(Class<?> aClass) {
	        return Member.class.isAssignableFrom(aClass);
	    }

	    @Override
	    public void validate(Object target, Errors errors) {
	        //target : 검사 대상 객체 참조(커맨드 객체) = Member 객체
	        //errors : target 검사 후 에러 코드를 저장하는 객체(스프링이 만들어서 전달해 준 것)
	        Member member = (Member) target;

	        //이메일 없거나 잘못된 양식일 경우
	        if (member.getEmail() == null || member.getEmail().trim().isEmpty()) {
	            errors.rejectValue("email", "required");
	        } else {
	            Matcher matcher = pattern.matcher(member.getEmail());
	            if (!matcher.matches()) {
	                errors.rejectValue("email", "incorrectFormat");
	            }
	        }

	        //입력값이 비었을 때
	        ValidationUtils.rejectIfEmptyOrWhitespace(errors,"password","required");
	        ValidationUtils.rejectIfEmptyOrWhitespace(errors,"confirmPassword", "required");
	        ValidationUtils.rejectIfEmptyOrWhitespace(errors,"name", "required");
	        ValidationUtils.rejectIfEmptyOrWhitespace(errors,"nickName", "required");
	        ValidationUtils.rejectIfEmptyOrWhitespace(errors,"phoneNum", "required");

	        //비밀번호 확인
	        if (!member.getConfirmPassword().equals(member.getPassword())) {
	            errors.rejectValue("confirmPassword", "unmatch");
	        }
	        
	        if(errors.hasErrors()) {
	        	System.out.println("validator 에러발생");
	        }
	    }

}

 

210108_Validator Error에서 파생된 TIL(Today I Learned) 추가내용

저 날 Error를 해결한다고 코드를 수정해가면서 알게 된 추가적인 내용입니다.  

Redirect vs Forward

쉽게 말해 Redirect는 새로운 HTTP Request, Response를 전달해주는 전송방식이고 Forward는 사용자가 요청한 Request를 유지하면서 HTTP Response를 전송해준다는 차이가 있다. 따라서  validate 시 hasError == true일 때, redirect를 한다면 기존 사용자가 입력한 정보는 사라진다. 하지만 Forward 해준다면 정보를 어딘가(ex. Model)에 담아 살릴 수 있다.