no image
[Spring MVC] 쿠키를 이용한 로그인 처리
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. MemberRepository /** * 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려 */ @Slf4j @Repository public class MemberRepository { private static Map store = new HashMap(); //static 사용 private static long sequence = 0L; //static 사용 public Member save(Member member) { member.setId(++sequence); log.info("save: member={}", member); store...
2024.03.16
no image
[Java] Optional<T>
자바에서 Optional 클래스는 null이 될 수 있는 객체를 감싸는 래퍼 클래스이다. java.util.Optional는 자바 8에서 도입되었으며, NullPointerException을 방지하고, 명시적으로 변수가 null 값을 가질 수 있음을 표현할 수 있는 방법을 제공한다. 이를 통해 개발자는 보다 깔끔하고 의도가 명확한 코드를 작성할 수 있다. 기본 사용법 Optional 객체를 생성하는 기본적인 방법은 Optional.of(value), Optional.ofNullable(value), 그리고 Optional.empty() 세 가지가 있다. Optional.of(value): null이 아닌 명시적인 값을 가지는 Optional 객체를 반환한다. 만약 인자로 넘긴 값이 null이라면, 즉시 ..
2024.03.15
no image
[Spring MVC] 검증(Validation) - Bean Validation
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. Bean Validation Bean Validation은 자바 애플리케이션에서 객체의 속성이 정해진 제약 조건에 맞는지 검증하기 위한 표준이다. Bean Validation 1.0은 JSR 303으로 처음 도입되었으며, 이후 Bean Validation 2.0은 JSR 380으로 업데이트 되었다. 이 표준은 애플리케이션 전반에 걸쳐 일관된 데이터 검증 로직을 제공함으로써 개발자가 중복된 검증 코드를 작성하는 것을 방지하고, 유지 보수를 용이하게 한다. Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 쉽게 이야 기해서 검증 어노테이션..
2024.03.14
no image
[Spring MVC] 검증(Validation) - Validator 분리
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다면, 별도의 클래스로 역할을 분리하는 것이 좋다. 그리고 이렇게 분리한 검증 로직을 재사용 할 수도 있다. Validator 인터페이스 스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다. public interface Validator { boolean supports(Class clazz); void validate(Object target, Errors errors); } boolean supports(Class clazz) 이 메서드는 Validator가 주어진 클래스의 인스턴스를 검증할 수 있는지 여부를 판단한다. 검증하려는 객체의 클래..
2024.03.13
no image
[Spring MVC] 검증(Validation) - 오류 코드와 메시지 처리
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. FieldError 생성자 FieldError 는 두 가지 생성자를 제공한다. public FieldError(String objectName, String field, String defaultMessage); public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) objectName : 오류가 발생한 객체 이름 fie..
2024.03.12
no image
[Spring MVC] 검증(Validation) - FieldError, ObjectError, BindingResult
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. PRG 패턴의 상품 저장 검증에 관한 내용이다. 기본적인 검증 StringUtils.hasText() 스프링 프레임워크의 org.springframework.util.StringUtils 클래스에 포함된 유틸리티 메서드 중 하나이다. 이 메서드는 주어진 문자열이 실제로 텍스트를 포함하고 있는지 확인하는 데 사용된다. 구체적으로, 문자열이 null이 아니며, 길이가 0보다 크고, 하나 이상의 비공백 문자를 포함하고 있을 때 true를 반환한다. public static boolean hasText(@Nullable String str) 파라미터: str - 검사할 문자열 반환값: 문자열이 null이 아니고, 길이..
2024.03.11
no image
[Spring MVC] 메시지, 국제화
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 메시지 스프링 부트에서 메시지를 관리하는 기능은 애플리케이션의 국제화(i18n)를 지원하며, 애플리케이션에서 사용되는 문자열을 손쉽게 관리할 수 있게 해준다. 이 기능을 사용하면, 다양한 언어와 지역에 맞춰 동적으로 메시지를 변경할 수 있으며, 코드 내에 하드코딩된 문자열을 줄임으로써 유지보수성을 높일 수 있다. 스프링 부트에서 메시지를 사용하는 방법에 대해 자세히 알아보자. 1. 메시지 소스 파일 준비 메시지 관리의 첫 단계는 src/main/resources 디렉토리 아래에 프로퍼티 파일 형태로 메시지 소스 파일을 준비하는 것이다. 기본적으로 messages.properties 파일을 사용하지만, 다국어 지..
2024.03.10
no image
[Thymeleaf] 스프링 통합과 폼
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 타임리프 스프링 통합 타임리프 기본 메뉴얼 : https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html 스프링 통합 메뉴얼 : https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html 스프링 통합으로 추가되는 기능 스프링의 SpringEL 문법 ${@myBean.doSomething()} 처럼 스프링 빈 호출 지원 편리한 폼 관리를 위한 추가 속성 → th:object (기능 강화, 폼 커맨드 객체 선택) → th:field , th:errors , th:errorclass 폼 컴포넌트 기능 →c..
2024.03.09

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


MemberRepository

/**
 * 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
 */
@Slf4j
@Repository
public class MemberRepository {
    private static Map<Long, Member> store = new HashMap<>(); //static 사용
    private static long sequence = 0L; //static 사용
    public Member save(Member member) {
        member.setId(++sequence);
        log.info("save: member={}", member);
        store.put(member.getId(), member);
        return member;
    }
    public Member findById(Long id) {
        return store.get(id);
    }
    public Optional<Member> findByLoginId(String loginId) {
//        List<Member> all = findAll();
//        for (Member m : all){
//            if(m.getLoginId().equals(loginId)){
//                return m;
//            }
//        }
//        return m;

        return findAll().stream()
                .filter(m -> m.getLoginId().equals(loginId))
                .findFirst();
    }
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }
    public void clearStore() {
        store.clear();
    }
}

 

MemberController

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
    private final MemberRepository memberRepository;

    @GetMapping("/add")
    public String addForm(@ModelAttribute("member") Member member) {
        return "members/addMemberForm";
    }

    @PostMapping("/add")
    public String save(@Valid @ModelAttribute Member member, BindingResult result) {
        if (result.hasErrors()) {
            return "members/addMemberForm";
        }
        memberRepository.save(member);
        return "redirect:/";
    }
}

 

LoginService

@Service
@RequiredArgsConstructor
public class LoginService {
    private final MemberRepository memberRepository;

    /**
     * @return null이면 로그인 실패
     */
    public Member login(String loginId, String password) {

//        Optional<Member> findMemberOptional = memberRepository.findByLoginId(loginId);
//        Member member = findMemberOptional.get();
//        if(member.getPassword().equals(password)){
//            return member;
//        }else {
//            return null;
//        }

        return memberRepository.findByLoginId(loginId)
                .filter(m -> m.getPassword().equals(password))
                .orElse(null);
    }
}

 

LoginController

@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());
        log.info("login? {}", loginMember);
        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);
    }
}

 

로그인 컨트롤러는 로그인 서비스를 호출해서 로그인에 성공하면 홈 화면으로 이동하고, 로그인에 실패하면 bindingResult.reject() 를 사용해서 글로벌 오류( ObjectError )를 생성한다.

그리고 정보를 다시 입력하도록 로그인 폼을 뷰 템플릿으로 사용한다.

 

영속 쿠키와 세션 쿠키

쿠키에는 영속 쿠키와 세션 쿠키가 있다.

  • 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
  • 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

브라우저 종료시 로그아웃이 되길 기대하므로, 우리에게 필요한 것은 세션 쿠키이다.

 

쿠키 생성 로직

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

로그인에 성공하면 쿠키를 생성하고 HttpServletResponse 에 담는다.

쿠키 이름은 memberId 이고, 값은 회원의 id 를 담아둔다. 웹 브라우저는 종료 전까지 회원의 id 를 서버에 계속 보내줄 것이다.

 

로그아웃 기능

로그아웃 방법은 다음과 같다.

  • 세션 쿠키이므로 웹 브라우저 종료시
  • 서버에서 해당 쿠키의 종료 날짜를 0으로 지정
cookie.setMaxAge(0);

해당 쿠키의 유효시간을 0으로 만듦으로써 만료되게 만든다.

 

홈 화면 - 로그인 처리

@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;
    //@GetMapping("/")
    public String home() {
        return "home";
    }

    @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

@CookieValue는 주로 사용자 세션 관리, 광고 추적, 사용자 설정 저장 등의 목적으로 쿠키를 사용할 때 유용하다.

 

  • value (또는 name): 바인딩할 쿠키의 이름을 지정한다.
  • required: 이 속성이 true로 설정되어 있고 지정된 이름의 쿠키가 존재하지 않을 경우, 예외를 발생시킨다.
    기본값은 true이지만, false로 설정하면 쿠키가 없는 경우 메소드 파라미터를 null 또는 Optional.empty()로 처리할 수 있다.
    로그인 하지 않은 사용자도 홈에 접근할 수 있기 때문에 required = false 를 사용한다.

 

홈 화면에 화원 관련 정보도 출력해야 해서 member 데이터도 모델에 담아서 전달

 

쿠키와 보안문제

쿠키 값은 임의로 변경할 수 있다.

쿠키에 보관된 정보는 훔쳐갈 수 있다.

이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.

쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다. 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.

해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.

 

대안

쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.

토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.

해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.

보안상 중요한 데이터는 쿠키대신 세션을 이용하거나 쿠키와 세션을 적절히 섞어서 사용해야 한다.

자바에서 Optional 클래스는 null이 될 수 있는 객체를 감싸는 래퍼 클래스이다. java.util.Optional<T>는 자바 8에서 도입되었으며, NullPointerException을 방지하고, 명시적으로 변수가 null 값을 가질 수 있음을 표현할 수 있는 방법을 제공한다. 이를 통해 개발자는 보다 깔끔하고 의도가 명확한 코드를 작성할 수 있다.

 

기본 사용법

Optional 객체를 생성하는 기본적인 방법은 Optional.of(value), Optional.ofNullable(value), 그리고 Optional.empty() 세 가지가 있다.

 

  • Optional.of(value): null이 아닌 명시적인 값을 가지는 Optional 객체를 반환한다. 만약 인자로 넘긴 값이 null이라면, 즉시 NullPointerException을 발생시킨다.
  • Optional.ofNullable(value): 인자로 넘긴 값이 null일 수도 있는 경우 사용한다. 값이 null이면 빈 Optional 객체를 반환한다.
  • Optional.empty(): 빈 Optional 객체를 생성한다.
Optional<String> optional = Optional.of("hello");

// 값이 존재하는 경우에만 처리를 실행
optional.ifPresent(value -> System.out.println(value));

// 값이 존재하지 않는 경우 기본값을 제공
String valueOrDefault = optional.orElse("default");
System.out.println(valueOrDefault);

// 값이 존재하지 않을 때 예외를 던짐
String valueOrThrow = optional.orElseThrow(IllegalStateException::new);
System.out.println(valueOrThrow);

// 값이 존재할 때 변환을 수행
Optional<Integer> length = optional.map(String::length);
System.out.println(length.orElse(0));

 

아래의 예제에서는 간단한 사용자 정보를 나타내는 User 클래스를 만들고, 이를 Optional로 감싸서 처리하는 방법을 보여준다. 이를 통해 Optional의 기본적인 사용 방법과 Optional을 사용할 때의 장점을 이해할 수 있다.

public class User {
    private String name;
    private Integer age;

    public User(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public Integer getAge() {
        return age;
    }
}

 

Optional을 사용하여 객체를 감싸는 예제

이 예제에서는 Optional.ofNullable() 메소드를 사용하여 null일 수도 있는 User 객체를 Optional로 감싸고, ifPresent() 메소드를 사용하여 User 객체가 실제로 존재할 때만 특정 작업을 수행하도록 한다.

public class OptionalExample {
    public static void main(String[] args) {
        // User 객체가 존재하는 경우
        User user = new User("John Doe", 30);
        Optional<User> optionalUser = Optional.ofNullable(user);

        optionalUser.ifPresent(u -> System.out.println("이름: " + u.getName() + ", 나이: " + u.getAge()));

        // User 객체가 null인 경우
        User nullUser = null;
        Optional<User> emptyOptional = Optional.ofNullable(nullUser);

        // User 객체가 없을 때 기본값을 제공
        User defaultUser = emptyOptional.orElse(new User("기본 사용자", 0));
        System.out.println("이름: " + defaultUser.getName() + ", 나이: " + defaultUser.getAge());

        // User 객체가 없을 때 예외를 던지거나 특정 행동을 하기
        emptyOptional.orElseThrow(() -> new IllegalStateException("User 객체가 없습니다."));
    }
}

이 예제에서는 Optional을 사용하여 null 가능성이 있는 User 객체를 안전하게 처리한다.

ifPresent() 메소드를 사용하여 User 객체가 존재하는 경우에만 작업을 수행하고,

orElse() 메소드를 사용하여 User 객체가 없는 경우 기본값을 제공한다.

orElseThrow() 메소드를 사용하여 User 객체가 없을 때 예외를 던질 수도 있다.

ifPresent(Consumer<? super T> action) 메소드

Optional 객체가 값을 갖고 있을 경우, 즉 값이 비어 있지 않을 경우에 주어진 액션(람다 표현식 또는 메소드 참조)을 실행한다. 값이 없을 경우에는 아무런 동작도 하지 않는다. 이 메소드는 주로 Optional 객체가 값이 있을 때만 특정 작업을 실행하고 싶을 때 사용된다.

 

orElse(T other) 메소드

Optional 객체가 값이 있으면 그 값을 반환하고, 값이 없을 경우에는 메소드에 전달된 인자를 기본값으로 반환한다. 이 메소드는 Optional 객체가 비어 있을 때의 기본값을 제공하고자 할 때 유용하게 사용된다.

 

get() 메소드 대신 orElse() 또는 orElseGet()을 사용하는 이유

get() 메소드가 Optional 객체가 값을 가지고 있지 않을 때, 즉 Optional 객체가 비어 있을 때 NoSuchElementException을 발생시키기 때문이다. 이는 Optional을 사용하는 주된 목적 중 하나인 안전한 null 처리를 위배한다.

 

orElse() 메소드는 Optional 객체가 비어 있을 때 제공할 기본값을 반환한다. 이 메소드는 Optional 객체의 값이 있든 없든 항상 기본값을 평가하기 때문에, 기본값 생성 비용이 높은 경우에는 비효율적일 수 있다.

 

orElseGet() 메소드는 Optional 객체가 비어 있을 때만 제공할 기본값을 생성하기 위해 사용할 Supplier 함수형 인터페이스를 인자로 받는다. 이 방법은 Optional 객체가 비어 있을 때만 기본값을 생성하기 때문에, orElse()에 비해 기본값 생성 비용이 높은 경우에 효율적이다.

 

간단히 말해서, orElse()와 orElseGet()은 Optional 객체가 비어 있을 때 안전하게 기본값을 제공하며, orElseGet()은 필요할 때만 기본값을 생성하는 더 효율적인 방법을 제공한다. 따라서 get() 메소드의 사용은 값의 존재를 명시적으로 확인한 후에만 사용해야 하며, 그렇지 않은 경우 orElse() 또는 orElseGet() 같은 보다 안전한 대안을 사용하는 것이 좋다.

 

null이 아닌, 비어 있는 Optional 객체를 생성하는 방법

Optional을 사용할 때 null이 아닌, 비어 있는 Optional 객체를 생성하는 방법은 Optional.empty() 메소드를 사용하는 것이다. 

이 메소드는 값을 포함하지 않는, 즉 비어 있는 Optional 객체를 반환한다. 이 방법은 반환 값이 없음을 명시적으로 표현할 때 유용하며, 메소드의 시그니처를 통해 반환 값이 있을 수도 있고 없을 수도 있음을 명확하게 나타낼 수 있다.

 

Optional<String> emptyOptional = Optional.empty();

이 코드는 비어 있는 Optional 객체를 생성한다. 이 객체는 아무런 값도 포함하지 않으며, isPresent() 메소드를 호출했을 때 false를 반환한다.

Optional.empty()를 사용하는 상황의 예로는, 어떤 조건에 따라 값이 반환될 수도 있고 반환되지 않을 수도 있는 메소드에서, 조건에 맞지 않아 값이 반환되지 않는 경우에 사용할 수 있다.

public Optional<String> findUserNameById(Long userId) {
    // 사용자 ID로 사용자 이름을 조회하는 로직
    // 만약 해당 ID의 사용자가 없다면, 비어 있는 Optional을 반환
    return Optional.empty();
}

이렇게 Optional.empty()를 사용하면, 메소드의 반환 값이 항상 Optional 타입임을 보장할 수 있으며, 호출한 곳에서는 반환된 Optional 객체를 통해 값이 존재하는지 여부를 안전하게 확인하고 처리할 수 있다.

 

이 방식은 null 반환을 피하고, NullPointerException의 위험을 줄이며, 코드의 가독성과 안정성을 높이는 데 도움이 된다.

 

주의할 점

Optional은 반환 타입, 매개 변수, 필드 타입으로 사용할 수 있으나, 특히 API 설계 시에는 신중하게 사용해야 한다.

Optional을 매개 변수로 사용하는 것은 권장되지 않으며, 필드 타입으로 사용할 때도 주의가 필요하다.

또한, Optional은 Serializable 인터페이스를 구현하지 않기 때문에, 직렬화가 필요한 상황에서는 사용에 제약이 따른다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


Bean Validation

Bean Validation은 자바 애플리케이션에서 객체의 속성이 정해진 제약 조건에 맞는지 검증하기 위한 표준이다. Bean Validation 1.0은 JSR 303으로 처음 도입되었으며, 이후 Bean Validation 2.0은 JSR 380으로 업데이트 되었다. 이 표준은 애플리케이션 전반에 걸쳐 일관된 데이터 검증 로직을 제공함으로써 개발자가 중복된 검증 코드를 작성하는 것을 방지하고, 유지 보수를 용이하게 한다.

 

Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.

쉽게 이야 기해서 검증 어노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.
Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름에 하이버네이트가 붙었다고 해서 ORM과는 관련이 없다.

Jakarta Bean Validation

jakarta.validation-api : Bean Validation 인터페이스
hibernate-validator :  구현체

 

하이버네이트 Validator 관련 링크

공식 사이트: http://hibernate.org/validator/
공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
검증 애노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/ html_single/#validator-defineconstraints-spec

 

검증 어노테이션

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null 을 허용하지 않는다.
  • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용한다.

 

@Data
 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;
     
     public Item() {
     }
     public Item(String itemName, Integer price, Integer quantity) {
         this.itemName = itemName;
         this.price = price;
         this.quantity = quantity;
     }
}

 

public class BeanValidationTest {
    @Test
    void beanValidation() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();

        Item item = new Item();
        item.setItemName(" ");//공백
        item.setPrice(0);
        item.setQuantity(10000);

        //검증 대상(item)을 직접 검증기에 넣고 그 결과를 받는다. Set 에는 ConstraintViolation 이라는 검증 오류가 담긴다.
        //따라서 결과가 비어있으면 검증 오류가 없는 것이다.
        Set<ConstraintViolation<Item>> violations = validator.validate(item);

        for (ConstraintViolation<Item> violation : violations) {
            System.out.println("violation=" + violation);
            System.out.println("violation.message=" + violation.getMessage());
        }
    }
}

 

스프링부트에 적용

Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다.

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

스프링 부트는 자동으로 글로벌 Validator로 등록한다.

  • LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.
  • 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
  • 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다. 검증 오류가 발생하면, FieldError, ObjectError 를 생성해서BindingResult 에 담아준다.

 

검증 순서

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

 

바인딩에 성공한 필드만 Bean Validation 적용

BeanValidator는 바인딩에 실패한 필드에 대해서는 BeanValidation을 적용하지 않는다. 이는 타입 변환에 성공해서 바인딩에 성공한 필드에만 BeanValidation 적용이 의미가 있기 때문이다. 즉, 일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다는 것이다.

@ModelAttribute 각각의 필드 타입 변환 시도에서 변환에 성공한 필드만 BeanValidation 적용하는 예시

itemName에 문자 "A" 입력하고 타입 변환에 성공하면 itemName 필드에 BeanValidation을 적용한다.price에 문자 "A" 입력했을 때 "A"를 숫자 타입으로 변환 시도가 실패하면 typeMismatch로 FieldError를 추가하고, price 필드에는 BeanValidation을 적용하지 않는다.

 

 

에러 코드

Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶다면 두 가지 방법이 있다.

  • 검증 어노테이션에 message 속성을 지정하여 변경
  • 메시지 소스 파일 사용

 

검증 어노테이션에 message 속성을 지정하여 변경

검증 어노테이션에 message 속성을 지정하여 사용자 정의 메시지를 설정할 수 있다. 예를 들어, @NotNull 어노테이션에 대해 다음과 같이 사용자 정의 메시지를 지정할 수 있다.

public class User {
    
    @NotNull(message = "공백은 입력할 수 없습니다.")
    private String name;
}

 

메시지 소스파일 사용

Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면, 오류 코드가 어노테이션 이름으로 등록된다.

예를 들어 NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된다.

 

@NotBlank

  1. NotBlank.item.itemName
  2. NotBlank.itemName
  3. NotBlank.java.lang.String
  4. NotBlank

 

@Range

  1. Range.item.price
  2. Range.price
  3. Range.java.lang.Integer
  4. Range

 

따라서 properties 파일에 등록된 메시지를 적용할 수 있다.

errors.properties

#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1}
허용
Max={0}, 최대 {1}

{0} 은 필드명이고, {1} , {2} ...은 각 어노테이션 마다 다르다.

 

BeanValidation 메시지 찾는 순서

  1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
  2. 애노테이션의 message 속성 사용 → @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용
    ex) @NotBlank → 공백일 수 없습니다.

 

Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError )는 @ScriptAssert() 를 사용하면 된다.
@Data
 @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
 public class Item {
//...
}

하지만 실제 사용해보면 제약이 많고 복잡하다.
그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 같이 오브젝트  류 관련 부분만 직접 자바 코드로 작성하는 것이 좋다.

 

 

동일한 모델 객체를 등각각 다르게 검증

데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.

예를 들면 아래와 같다.

등록시 요구사항

타입 검증

  • 가격, 수량에 문자가 들어가면 검증 오류 처리

 

필드 검증

  • 상품명: 필수, 공백 없어야 함
  • 가격: 1000원 이상, 1백만원 이하
  • 수량: 최대 9999

 

특정 필드의 범위를 넘어서는 검증

  • 가격 * 수량의 합은 10,000원 이상

 

수정시 요구사항

  • 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.
  • 등록시에는 id 에 값이 없어도 되지만, 수정시에는 id 값이 필수이다.

 

동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법은 아래와 같이 두 가지가 있다.

  • BeanValidation의 groups 기능을 사용
  • 동일 객체를 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용

 

groups

이 기능을 사용하면 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.

 

저장용 groups 생성

 package hello.itemservice.domain.item;
 public interface SaveCheck {
 
 }



수정용 groups 생성

 package hello.itemservice.domain.item;
 public interface UpdateCheck {
 
 }

 

모델 객체에 groups 적용

@Data
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 = 1000000, 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;
    }
}

 

저장 로직에 SaveCheck Groups 적용

@PostMapping("/add")
    public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
  • @Validated(SaveCheck.class)
  • @Validated 어노테이션과 그룹 인터페이스(예: SaveCheck.class)를 함께 사용하는 방식은 특정 검증 그룹을 적용하여 메소드 레벨에서 Bean Validation을 수행할 수 있게 한다.
  • @Validated 어노테이션을 사용하여 컨트롤러 또는 서비스 메소드에 SaveCheck 그룹을 적용한다. 이렇게 하면 해당 메소드가 호출될 때 SaveCheck 그룹에 속하는 검증 규칙만 적용된다

 

 

수정 로직에 UpdateCheck Groups 적용

 @PostMapping("/{itemId}/edit")
    public String editFormV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
  • @Validated(UpdateCheck.class)
  • @Validated 어노테이션과 그룹 인터페이스(예: UpdateCheck.class)를 함께 사용하는 방식은 특정 검증 그룹을 적용하여 메소드 레벨에서 Bean Validation을 수행할 수 있게 한다.
  • @Validated 어노테이션을 사용하여 컨트롤러 또는 서비스 메소드에 SaveCheck 그룹을 적용한다. 이렇게 하면 해당 메소드가 호출될 때 SaveCheck 그룹에 속하는 검증 규칙만 적용된다

 

groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

 

Form 전송 객체 분리

실무에서는  (예 : Item) 하나의 모델 객체의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.
따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 드물다.

 

→보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.

예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.

 

Item 모델 객체

@Data
 public class Item {
     private Long id;
     private String itemName;
     private Integer price;
     private Integer quantity;
}

 

저장용 모델 객체(ItemSaveform)

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

@Data
 public class ItemUpdateForm {
     @NotNull
     private Long id;
     @NotBlank
     private String itemName;
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
	//수정에서는 수량은 자유롭게 변경할 수 있다.
	private Integer quantity;
}

 

 

저장 컨트롤러

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

    ...

    //성공 로직
    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}";
}
  • Item 대신에 ItemSaveform 을 전달 받는다.
  • @Validated 로 검증도 수행하고, BindingResult 로 검증 결과도 받는다.

 

수정 컨트롤러

@PostMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

    ...

	//성공 로직
    Item itemParam = new Item();
    itemParam.setItemName(form.getItemName());
    itemParam.setPrice(form.getPrice());
    itemParam.setQuantity(form.getQuantity());

    itemRepository.update(itemId, itemParam);
    return "redirect:/validation/v4/items/{itemId}";
}
  • Item 대신에 ItemUpdateform 을 전달 받는다.
  • @Validated 로 검증도 수행하고, BindingResult 로 검증 결과도 받는다.

 

주의

@ModelAttribute("item") 에 item 이름을 넣어준 부분을 주의
이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하 는 th:object 이름도 함께 변경해주어야 한다.

 

HTTP 메시지 컨버터

@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.

복습

@ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.

 

ApiController 생성

@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;
    }
}

 

API의 경우 3가지 경우를 나누어 생각해야 한다.

  • 성공 요청: 성공
  • 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
  • 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함

 

성공 요청

 

실패 요청

price 의 값에 숫자가 아닌 문자를 전달해서 실패하였다.

 

검증 오류 요청

수량( quantity )이 10000 이면 BeanValidation @Max(9999) 에서 걸린다.

return bindingResult.getAllErrors(); 는 ObjectError 와 FieldError 를 반환한다.

스프링이 이 객 체를 JSON으로 변환해서 클라이언트에 전달했다. 여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환 했다. 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.

 

@ModelAttribute vs @RequestBody

HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다. 

HttpMessageConverter는 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid, @Validated가 적용된다. 

 

  • @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다. 
  • @RequestBody는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다면,  별도의 클래스로 역할을 분리하는 것이 좋다. 그리고 이렇게 분리한 검증 로직을 재사용 할 수도 있다.

 

Validator 인터페이스

스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다. 

 public interface Validator {
     boolean supports(Class<?> clazz);
     void validate(Object target, Errors errors);
}

boolean supports(Class<?> clazz)

이 메서드는 Validator가 주어진 클래스의 인스턴스를 검증할 수 있는지 여부를 판단한다. 검증하려는 객체의 클래스가 이 메서드에서 true를 반환할 때만 validate 메서드를 통해 검증 로직이 실행된다.

예를 들어, UserForm 클래스의 인스턴스만 검증하려는 경우, 이 메서드는 UserForm.class에 대해 true를 반환하고, 다른 클래스 타입에 대해서는 false를 반환할 것이다.

 

void validate(Object target, Errors errors)

실제 객체를 검증하는 로직을 이 메서드에 구현한다. target 파라미터는 검증하려는 객체이며, errors 파라미터는 검증 과정에서 발견된 오류들을 저장하는 데 사용된다.

오류가 발견되면, Errors 객체에 오류 정보를 추가함으로써, 어플리케이션 나머지 부분에서 이를 처리할 수 있도록 한다.

 

예시

public class UserValidator implements Validator {
    
    @Override
    public boolean supports(Class<?> clazz) {
        return User.class.equals(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        User user = (User) target;
        
        if (StringUtils.hasText(user.getName()) == false) {
            errors.rejectValue("name", "required", "이름은 필수입니다.");
        }

        // 추가적인 검증 로직 구현...
    }
}

이 예제에서 UserValidator 클래스는 Validator 인터페이스를 구현하여 User 클래스의 인스턴스를 검증한다.

supports 메서드는 User 클래스의 인스턴스에 대해서만 검증을 지원한다고 선언하고, validate 메서드는 User 객체의 name 필드가 비어 있는지 확인하는 간단한 검증 로직을 포함한다.

 

검증기

@Component 를 이용하여 해당 클래스를 스프링 빈에 등록한다.

@Component
public class ItemValidator implements Validator {
    //supports() : 해당 검증기를 지원하는 여부 확인
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }

    //validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult
    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");


        //void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
        //field : 오류 필드명
        //errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드 아님. messageResolver를 위한 오류 코드)
        //errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
        //defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

        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);
        }

        //특정 필드 예외가 아닌 전체 예외
        //void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

        //errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드 아님. messageResolver를 위한 오류 코드)
        //errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
        //defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }
    }
}

 

컨트롤러

private final ItemValidator itemValidator;
 @PostMapping("/add")
 public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
 
 	itemValidator.validate(item, bindingResult);
     if (bindingResult.hasErrors()) {
         log.info("errors={}", bindingResult);
         return "validation/v2/addForm";
	}
    //성공 로직
    Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

 

WebDataBinder

스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서다. 그런데 앞 에서는 검증기를 직접 불러서 사용했고, 이렇게 사용해도 된다. 그런데 Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.

 

WebDataBinder는 스프링 프레임워크 내에서 HTTP 요청 데이터를 JavaBean 객체에 바인딩하는 역할을 하는 핵심 클래스이다. 사용자의 입력이나 쿼리 파라미터 같은 웹 요청 데이터를 자동으로 객체의 필드에 매핑하고, 필요한 타입 변환을 수행한다. 또한, Validator 인터페이스를 사용하여 바인딩된 객체의 데이터를 검증할 수 있는 기능도 제공한다.

WebDataBinder는 컨트롤러에서 @InitBinder 어노테이션과 함께 사용되어, 특정 요청을 처리하기 전에 요청 데이터를 객체에 바인딩하고 검증하는 과정을 커스터마이징할 수 있다. 예를 들어, 특정 포맷의 날짜 문자열을 Date 타입으로 변환하거나, 입력 값 검증을 위해 사용자 정의 검증기를 등록하는 것이 가능하다.

WebDataBinder의 사용은 데이터 바인딩과 검증 로직을 컨트롤러 밖으로 분리하여, 코드의 가독성과 유지보수성을 향상시키는 데 도움을 준다.

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

private final ItemValidator itemValidator;

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

     if (bindingResult.hasErrors()) {
         log.info("errors={}", bindingResult);
         return "validation/v2/addForm";
	}
    
    //성공 로직
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

이전 코드와 달라진 점은

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

 

위의 코드가 추가 되었고, validator를 직접 호출하는 부분이 사라지고, 대신에 검증 대상 앞에 @Validated 가 붙었다.

 

  • addValidators: DataBinder에 하나 이상의 Validator 인스턴스를 추가하는 메서드이다. 이 메서드를 통해 검증기가 등록되면, 바인딩된 객체에 대한 검증 과정에서 이 검증기가 사용된다.
  • itemValidator: 검증 로직을 구현하는 커스텀 검증기의 인스턴스이다. 이 검증기는 Validator 인터페이스를 구현해야 하며, supports() 메서드를 통해 검증 가능한 객체 타입을 지정하고, validate() 메서드에서 실제 검증 로직을 구현한다.
  • @InitBinder 는 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야한다.

 

@Validated는 검증기를 실행하라는 애노테이션이다. 이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다.

이때 Validator의 supports() 메서드가 사용된다. 여기서는 supports(Item.class)가 호출되고, 결과가 true이므로 ItemValidator의 validate() 메서드가 호출된다.

 

글로벌 설정 - 모든 컨트롤러에 다 적용

@SpringBootApplication
 public class ItemServiceApplication implements WebMvcConfigurer {
     public static void main(String[] args) {
         SpringApplication.run(ItemServiceApplication.class, args);
}
     @Override
     public Validator getValidator() {
         return new ItemValidator();
     }
}

이렇게 글로벌 설정을 추가할 수 있다. 기존 컨트롤러의 @InitBinder 를 제거해도 글로벌 설정으로 정상 동작하는 것을 확인할 수 있다.

글로벌 설정을 하면  BeanValidator가 자동 등록되지 않는다.
검증시 @Validated, @Valid 둘 다 사용 가능하다.
javax.validation.Valid를 사용하려면 build.gradle 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated는 스프링 전용 검증 어노테이션이고, @Valid는 자바 표준 검증 어노테이션이다.

@Valid는 자바 표준이므로 스프링 이외의 자바 어플리케이션에서도 널리 사용될 수 있다. @Validated는 스프링에 특화된 기능(예: 검증 그룹)을 제공하기 때문에, 스프링 기반 어플리케이션에서 더 복잡한 검증 로직이 필요할 때 유리하다. 사용 상황에 따라 두 애노테이션을 적절히 선택하여 사용하면, 어플리케이션의 데이터 검증 요구사항을 효과적으로 충족시킬 수 있다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


FieldError 생성자

FieldError 는 두 가지 생성자를 제공한다.

public FieldError(String objectName, String field, String defaultMessage);
 
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
  • defaultMessage : 기본 오류 메시지

 

FieldError , ObjectError 의 생성자는 codes , arguments 를 제공한다.

이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.

 

errors 메시지 파일 생성을 위해 messages.properties를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties라는 별도의 파일로 관리하는 방법이 있다.

 

먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다. 이렇게 하면 messages.properties, errors.properties 두 파일을 모두 인식한다. 생략하면 messages.properties를 기본으로 인식한다.

application.properties

spring.messages.basename=messages,errors

 

errors.properties 추가

src/main/resources/errors.properties

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

errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다. 

 

컨트롤러

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

    //public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)

    //objectName : @ModelAttribute 이름
    //field : 오류가 발생한 필드 이름
    //rejectedValue : 사용자가 입력한 값(거절된 값)
    //bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
    //codes : 메시지 코드
    //arguments : 메시지에서 사용하는 인자
    //defaultMessage : 오류 기본 메시지

    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));
    }

    //특정 필드 예외가 아닌 전체 예외
    //public ObjectError(String objectName, String defaultMessage)
    //objectName : @ModelAttribute 의 이름
    //defaultMessage : 오류 기본 메시지
    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));
        }
    }

    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "validation/v2/addForm";
    }

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

 

rejectValue(), reject()

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

rejectValue()

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field : 오류 필드명
  • errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아님 messageResolver를 위한 오류 코드)
  • errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

rejectValue() 메서드는 특정 필드에 대한 검증 실패를 나타내는 오류를 등록할 때 사용된다. 이 메서드는 필드 수준의 검증에서, 필드 값이 특정 조건을 만족하지 않을 경우 오류를 등록하는 데 적합하다.

 

reject()

void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

reject() 메서드는 객체 수준의 검증 실패를 나타내는 오류를 등록할 때 사용된다. 이는 특정 필드에 국한되지 않고, 전체 객체 또는 복합적인 조건에 대한 검증에서 사용된다.

 

BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다. 따라서 target(item)에 대한 정보는 없어도 된다.
rejectValue() 를 사용하고 부터는 오류 코드를 간단하게 입력할 수 있다.

이 부분을 이해하려면 MessageCodesResolver 를 이해해야 한다.

컨트롤러

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

        //public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)

        //objectName : @ModelAttribute 이름
        //field : 오류가 발생한 필드 이름
        //rejectedValue : 사용자가 입력한 값(거절된 값)
        //bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
        //codes : 메시지 코드
        //arguments : 메시지에서 사용하는 인자
        //defaultMessage : 오류 기본 메시지

        log.info("objectName={}", bindingResult.getObjectName());
        log.info("target={}", bindingResult.getTarget());

        //void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

        //field : 오류 필드명
        //errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드 아님. messageResolver를 위한 오류 코드)
        //errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
        //defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

        ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "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);
        }

        //특정 필드 예외가 아닌 전체 예외
        //void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

        //errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드 아님. messageResolver를 위한 오류 코드)
        //errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
        //defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            log.info("errors={}", bindingResult);
            return "validation/v2/addForm";
        }

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

 

ValidationUtils

ValidationUtils 사용전

if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}

 

ValidationUtils 사용후

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

특정 필드 값이 비어 있거나 공백으로만 구성되어 있는지를 검증하고, 그러한 경우에 오류를 BindingResult에 추가하는 역할을 한다.

이 메서드를 사용하면 복잡한 조건 없이 간단하게 필드의 빈 값 여부를 검증할 수 있으며, 검증 로직을 보다 깔끔하게 유지할 수 있다.

 

MessageCodesResolver

MessageCodesResolver는 스프링 프레임워크에서 검증 오류 메시지의 코드를 생성하는 전략을 정의하는 인터페이스이다. 이 인터페이스는 검증 과정에서 발생한 오류에 대해 메시지 코드를 해석하고 생성하는 방법을 결정한다. 주로 BindingResult의 구현체에서 내부적으로 사용되며, 오류 메시지를 국제화하거나 세분화하는 데 필요한 메시지 코드를 생성하는 역할을 한다.

 

동작 방식

MessageCodesResolver는 주로 rejectValue() 또는 reject() 메서드를 통해 검증 오류가 등록될 때 사용된다.

  • rejectValue(), reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성한다.
  • FieldError, ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. 
  • MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.

이러한 메서드는 오류를 등록하는 동안 MessageCodesResolver를 사용하여 오류 코드에 해당하는 메시지 코드를 생성한다. 생성된 메시지 코드는 다음과 같은 용도로 사용될 수 있다

  • 오류 메시지의 국제화: 다양한 언어로 오류 메시지를 제공할 수 있게 함으로써, 애플리케이션의 다국어 지원을 강화한다.
  • 세분화된 오류 메시지 제공: 상황에 따라 다른 오류 메시지를 표시함으로써 사용자에게 더 구체적인 피드백을 제공한다.

 

구현체

스프링 프레임워크는 DefaultMessageCodesResolver라는 기본 구현체를 제공한다.

이 구현체는 오류 코드, 객체 이름, 필드 이름 등을 기반으로 메시지 코드를 생성하는 전략을 정의한다. 예를 들어, 필드 검증 오류에 대한 메시지 코드는 다음과 같은 순서로 생성될 수 있다

 

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

객체 오류

객체 오류의 경우 다음 순서로 2가지 생성

  1. code + "." + object name
  2. code

예) 오류 코드: required, object name: item

  1. required.item
  2. required

ObjectError reject("totalPriceMin") → 다음 2가지 오류 코드를 자동으로 생성

  1. totalPriceMin.item
  2. totalPriceMin

 

필드 오류

필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성

  1. code + "." + object name + "." + field
  2. code + "." + field
  3. code + "." + field type
  4. code

예) 오류 코드: typeMismatch, object name "user", field "age", field type: int 

  1. "typeMismatch.user.age"
  2. "typeMismatch.age"
  3. "typeMismatch.int"
  4. "typeMismatch"

FieldError rejectValue("itemName", "required") → 다음 4가지 오류 코드를 자동으로 생성

  1. required.item.itemName
  2. required.itemName
  3. required.java.lang.String
  4. required

 

타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.

 

예제코드

package hello.itemservice.validation;

import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;

import static org.assertj.core.api.Assertions.assertThat;

public class MessageCodesResolverTest {
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();

    //String[] resolveMessageCodes(String errorCode, String objectName);

    //errorCode: 검증 과정에서 발견된 오류의 코드
    //objectName: 검증 대상 객체의 이름
    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        assertThat(messageCodes).containsExactly("required.item", "required");
    }

    //String[] resolveMessageCodes(String errorCode, String objectName, String field, Class<?> fieldType);
    
    //errorCode: 검증 과정에서 발견된 오류의 코드.
    //objectName: 검증 대상 객체의 이름.
    //field: 검증 대상 필드의 이름, 필드 수준의 오류 코드 생성에 사용된다.
    //fieldType: 검증 대상 필드의 타입, 필드 타입을 기반으로 메시지 코드를 생성하는 데 사용될 수 있다.
    @Test
    void messageCodesResolverField() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
        );
    }
}

 

스프링이 직접 만든 오류 메시지 처리(타입 오류)

검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.

  • 개발자가 직접 설정한 오류 코드 rejectValue() 를 직접 호출
  • 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)

 

예를 들어, itme 객체에 price 필드에 int 가 입력되야하는데 String 타입 자료형이 입력되었을 때, 아래와 같은 메시지 코드들이 생성된 것을 확인 할 수 있다.

  • typeMismatch.item.price
  • typeMismatch.price
  • typeMismatch.java.lang.Integer
  • typeMismatch

스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것이다.

 

errors.properties 에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.

error.properties 에 다음 내용을 추가하자

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

 

핵심은 구체적인 것에서! 덜 구체적인 것으로!
MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처 럼 덜 구체적인 것을 가장 나중에 만든다.
이렇게 하면 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.

왜 이렇게 복잡하게 사용하는가?
모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


PRG 패턴의 상품 저장 검증에 관한 내용이다.

 

 

기본적인 검증

StringUtils.hasText()

스프링 프레임워크의 org.springframework.util.StringUtils 클래스에 포함된 유틸리티 메서드 중 하나이다. 이 메서드는 주어진 문자열이 실제로 텍스트를 포함하고 있는지 확인하는 데 사용된다.

구체적으로, 문자열이 null이 아니며, 길이가 0보다 크고, 하나 이상의 비공백 문자를 포함하고 있을 때 true를 반환한다.

public static boolean hasText(@Nullable String str)
  • 파라미터: str - 검사할 문자열
  • 반환값: 문자열이 null이 아니고, 길이가 0보다 크며, 최소한 하나의 비공백 문자를 포함하고 있으면 true를 반환한다. 그렇지 않으면 false를 반환한다.

 

컨트롤러

@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() > 1000000) {
        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);
        }
    }

    //검증에 실패하면 다시 입력 폼으로
    if (!errors.isEmpty()) {
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}

 

타임리프(addForm)

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="utf-8">
        <link th:href="@{/css/bootstrap.min.css}"
              href="../css/bootstrap.min.css" rel="stylesheet">
        <style>
            .container {
                max-width: 560px;
            }

            /*에러 발생시 빨강색으로 랜더링*/
            .field-error {
                border-color: #dc3545;
                color: #dc3545;
            }
        </style>
    </head>

    <body>
        <div class="container">

            <div class="py-5 text-center">
                <h2 th:text="#{page.addItem}">상품 등록</h2>
            </div>

            <form action="item.html" th:action th:object="${item}" method="post">
                <!-- errors에 globalError 가 있으면 오류 메시지 출력 -->
                <!-- .? -> Safe Navigation Operator -->
                <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>

                    <!-- errors에 itemName가 있으면  class = form-control field-error, 없으면 form-control -->
                    <input type="text" id="itemName" th:field="*{itemName}"
                           th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
                           placeholder="이름을 입력하세요">

                    <!-- errors에 itemName가 있으면 오류 메시지 출력 -->
                    <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>

                    <!-- errors에 price 가 있으면 form-control field-error, 없으면 form-control -->
                    <input type="text" id="price" th:field="*{price}"
                           th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'"
                           placeholder="가격을 입력하세요">

                    <!-- errors에 price 가 있으면 오류 메시지 출력 -->
                    <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>

                    <!-- errors에 quantity 가 있으면 form-control field-error, 없으면 form-control -->
                    <input type="text" id="quantity" th:field="*{quantity}"
                           th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'"
                           placeholder="수량을 입력하세요">

                    <!-- errors에 quantity 가 있으면 오류 메시지 출력 -->
                    <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">
                        수량 오류
                    </div>
                </div>

                <hr class="my-4">
                <div class="row">
                    <div class="col">
                        <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
                    </div>
                    <div class="col">
                        <button class="w-100 btn btn-secondary btn-lg"
                                onclick="location.href='items.html'"
                                th:onclick="|location.href='@{/validation/v1/items}'|"
                                type="button" th:text="#{button.cancel}">취소</button>
                    </div>
                </div>

            </form>

        </div> <!-- /container -->
    </body>
</html>

 

Safe Navigation Operator

errors?. 은 errors 가 null 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법이다.
th:if 에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않는다.

 

BindingResult를 이용한 검증

컨트롤러

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

    //public FieldError(String objectName, String field, String defaultMessage)
    //objectName : @ModelAttribute 이름
    //field : 오류가 발생한 필드 이름
    //defaultMessage : 오류 기본 메시지

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

    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
    }

    if (item.getQuantity() == null || item.getQuantity() >= 10000) {
        bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
    }

    //특정 필드 예외가 아닌 전체 예외
    //public ObjectError(String objectName, String defaultMessage)
    //objectName : @ModelAttribute 의 이름
    //defaultMessage : 오류 기본 메시지
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
        }
    }

    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "validation/v2/addForm";
    }

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

 

BindingResult

컨트롤러에서 입력값 검증(validation) 후 그 결과를 저장하는 데 사용된다.

이 객체는 주로 폼 입력과 같은 사용자의 입력값에 대한 검증 결과를 담고 있으며, 검증 과정에서 발생한 오류들을 저장하고 관리한다. BindingResult는 검증 대상 객체 바로 뒤에 매개변수로 위치해야 한다.

 

예를 들어, 사용자로부터 어떤 입력을 받아 이를 검증해야 하는 경우, 컨트롤러 메소드에서 @Valid 또는 @Validated 애노테이션을 사용해 입력값에 대한 검증을 요청할 수 있다.

 

검증해야 할 객체 뒤에 BindingResult 매개변수를 추가함으로써, 검증 과정에서 발생한 오류들을 BindingResult 객체가 포착하게 된다. 이후 개발자는 BindingResult 객체를 사용하여 오류 여부를 확인하고, 필요한 경우 사용자에게 오류 메시지를 표시할 수 있다.

 

필드 오류 - FieldError

FieldError 생성자 요약

public FieldError(String objectName, String field, String defaultMessage)

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

 

글로벌 오류 - ObjectError

ObjectError 생성자 요약

public ObjectError(String objectName, String defaultMessage)

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute 의 이름
  • defaultMessage : 오류 기본 메시지

 

뷰(타임리프)

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="utf-8">
        <link th:href="@{/css/bootstrap.min.css}"
              href="../css/bootstrap.min.css" rel="stylesheet">
        <style>
            .container {
                max-width: 560px;
            }

            /*에러 발생시 빨강색으로 랜더링*/
            .field-error {
                border-color: #dc3545;
                color: #dc3545;
            }
        </style>
    </head>
    <body>

        <div class="container">

            <div class="py-5 text-center">
                <h2 th:text="#{page.addItem}">상품 등록</h2>
            </div>

            <form action="item.html" th:action th:object="${item}" method="post">
                <!-- bindingResult 에 ObjectError 의 전체 오류 메시지 출력 -->
                <div th:if="${#fields.hasGlobalErrors()}"> <!-- field 가 global 오류가 있다면 -->
                    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> <!-- 오류의 내용을 순차적으로 출력 -->
                </div>

                <div>
                    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>

                    <!-- text 박스 관련 -->
                    <!-- field 에 itemName 오류가 있으면 class = field-error -->
                    <input type="text" id="itemName" th:field="*{itemName}"
                           th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요"> <!-- itemname 에 해당하는 오류가 있다면 class에 field-error 추가 -->

                    <!-- 오류시 text 박스 아래 레이블 관련 -->
                    <!-- field 에 맞는 defaultMessage 가 출력됨 -->
                    <div class="field-error" th:errors="*{itemName}"> <!-- field 의 itemName 에 해당하는 오류가 있다면 오류 출력 -->
                        상품명 오류
                    </div>
                </div>

                <div>
                    <label for="price" th:text="#{label.item.price}">가격</label>

                    <!-- text 박스 관련 -->
                    <!-- field 에 price 오류가 있으면 class = field-error -->
                    <input type="text" id="price" th:field="*{price}"
                           th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요"> <!-- price 에 해당하는 오류가 있다면 class에 field-error 추가 -->

                    <!-- 오류시 text 박스 아래 레이블 관련 -->
                    <!-- field 에 맞는 defaultMessage 가 출력됨 -->
                    <div class="field-error" th:errors="*{price}"> <!-- field 의 price 에 해당하는 오류가 있다면 오류 출력 -->
                        가격 오류
                    </div>
                </div>

                <div>
                    <label for="quantity" th:text="#{label.item.quantity}">수량</label>

                    <!-- text 박스 관련 -->
                    <!-- field 에 price 오류가 있으면 class = field-error -->
                    <input type="text" id="quantity" th:field="*{quantity}"
                           th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요"> <!-- quantity 에 해당하는 오류가 있다면 class에 field-error 추가 -->

                    <!-- 오류시 text 박스 아래 레이블 관련 -->
                    <!-- field 에 맞는 defaultMessage 가 출력됨 -->
                    <div class="field-error" th:errors="*{quantity}"> <!-- quantity 의 price 에 해당하는 오류가 있다면 오류 출력 -->
                        수량 오류
                    </div>
                </div>

                <hr class="my-4">
                <div class="row">
                    <div class="col">
                        <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
                    </div>
                    <div class="col">
                        <button class="w-100 btn btn-secondary btn-lg"
                                onclick="location.href='items.html'"
                                th:onclick="|location.href='@{/validation/v2/items}'|"
                                type="button" th:text="#{button.cancel}">취소</button>
                    </div>
                </div>
            </form>
        </div> <!-- /container -->
    </body>
</html>

 

타임리프 스프링 검증 오류 통합 기능

타임리프는 스프링의 BindingResult 를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.

타임리프 템플릿에서 BindingResult의 오류 메시지를 표시하려면, 주로 th:errors 속성을 사용한다.

이 속성을 통해 특정 필드에 대한 검증 오류 메시지를 출력할 수 있다. 또한, th:if를 사용하여 오류 메시지가 존재하는 경우에만 오류 메시지를 표시하는 조건을 추가할 수 있다.

  • #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if 의 편의 버전이다.
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

 

검증과 오류 메시지 공식 메뉴얼

https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and- error-messages

 

BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.

예를 들어 @ModelAttribute에 바인딩 시 타입 오류가 발생하면, BindingResult가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.

BindingResult가 있으면 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다.

BindingResult에 검증 오류를 적용하는 방법

  1. @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다.
  2. Validator 사용

 

주의 사항

BindingResult는 검증할 대상 바로 다음에 와야한다. 예를 들어서 @ModelAttribute Item item, 바로 다음에 BindingResult가 와야 한다.
BindingResult는 Model에 자동으로 포함된다.

 

BindingResult와 Errors

-org.springframework.validation.Errors
-org.springframework.validation.BindingResult

BindingResult가 인터페이스이고, Errors 인터페이스를 상속받고 있다.
실제 넘어오는 구현체는 BeanPropertyBindingResult라는 것인데, 둘다 구현하고 있으므로 BindingResult 대신에 Errors를 사용해도 된다.
Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다. BindingResult는 여기에 더해서 추가적인 기능들을 제공한다. addError()도 BindingResult가 제공하므로  BindingResult를 사용하는 것이 좋다.
주로 관례상 BindingResult를 많이 사용한다.

 

FieldError, ObjectError

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

예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.

그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 그리고 이 렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.

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

 

FieldError

public FieldError(String objectName, String field, String defaultMessage)

 

  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

 

 

public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
  • objectName (String): 오류가 발생한 객체의 이름. 이는 해당 객체를 식별하는 데 사용되며, 보통 모델 객체의 이름이나 타입을 지칭한다.
  • field (String): 오류가 발생한 필드의 이름. 이 필드 이름은 객체 내에서 유일해야 하며, 입력 폼의 필드 이름과 일치해야 한다.
  • rejectedValue (Object): 오류가 발생했을 때 해당 필드에 입력되었던 값. 이 값은 사용자가 입력한 잘못된 값이나, 타입 불일치 등으로 인해 문제가 발생한 값을 나타낸다.
  • bindingFailure (boolean): 데이터 바인딩 실패 여부를 나타내는 플래그. 타입 불일치 등으로 인해 필드 값이 객체에 올바르게 바인딩되지 못한 경우 true가 된다.
  • codes (String[]): 오류 코드의 배열. 이 코드들은 메시지 소스 파일에서 해당 오류 메시지를 찾기 위해 사용된다. 계층적으로 구성될 수 있어, 보다 구체적인 오류 메시지부터 일반적인 오류 메시지 순으로 메시지를 정의하고 선택할 수 있다.
  • arguments (Object[]): 메시지 포맷에 사용될 인자의 배열. 오류 메시지 내에서 플레이스홀더를 대체하는 데 사용되는 값을 포함한다.
  • defaultMessage (String): 메시지 소스에서 적절한 오류 메시지 코드를 찾을 수 없을 때 사용될 기본 메시지. 이는 개발자가 사용자에게 보여줄 오류 메시지를 직접 지정하고 싶을 때 유용하다.

여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.

bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다.

 

타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.

그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

 

ObjectError

ObjectError는 주로 객체 수준의 검증 오류를 나타낼 때 사용되며, 다양한 생성자를 통해 유연하게 오류 정보를 정의할 수 있다. 

public ObjectError(String objectName, String defaultMessage)

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute 의 이름
  • defaultMessage : 오류 기본 메시지

 

ObjectError(String objectName, String[] codes, Object[] arguments, String defaultMessage)
ObjectError(String objectName, String code, Object[] arguments, String defaultMessage)
  • objectName (String)
    오류가 발생한 객체의 이름
  • codes (String[])
    오류 코드의 배열이다. 이 코드들은 메시지 소스 파일에서 해당 오류 메시지를 찾기 위해 사용된다. 오류 코드는 계층적으로 구성될 수 있으며, 스프링은 이 배열에 있는 코드를 순서대로 검색하여 첫 번째로 발견한 메시지를 사용한다. 이를 통해 보다 구체적인 오류 메시지부터 보다 일반적인 오류 메시지 순으로 메시지를 정의하고 선택할 수 있다.
  • arguments (Object[])
    메시지 포맷에 사용될 인자의 배열이다. 이는 오류 메시지 내에서 placeholder 를 대체하는 데 사용되는 값을 포함한다. 예를 들어, 메시지 소스에 "{0}는(은) 유효하지 않습니다."라는 메시지가 정의되어 있고, arguments로 new Object[]{"이메일"}이 전달되면, 최종 메시지는 "이메일은 유효하지 않습니다."가 된다.
  • defaultMessage (String)
    메시지 소스에서 적절한 오류 메시지 코드를 찾을 수 없을 때 사용될 기본 메시지이다. 이는 개발자가 사용자에게 보여줄 오류 메시지를 직접 지정하고 싶을 때 유용하다. defaultMessage는 메시지 코드나 인자가 없거나, 메시지 소스에서 해당 오류 코드에 대한 메시지를 찾지 못했을 때 표시된다.

 

타임리프의 사용자 입력 값 유지

  • th:field="*{price}"
  • 타임리프의 th:field 는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.

 

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

    //public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)

    //objectName : @ModelAttribute 이름
    //field : 오류가 발생한 필드 이름
    //rejectedValue : 사용자가 입력한 값(거절된 값)
    //bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
    //codes : 메시지 코드
    //arguments : 메시지에서 사용하는 인자
    //defaultMessage : 오류 기본 메시지

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

    if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
        bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
    }

    if (item.getQuantity() == null || item.getQuantity() >= 10000) {
        bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
    }

    //특정 필드 예외가 아닌 전체 예외
    //public ObjectError(String objectName, String defaultMessage)
    //objectName : @ModelAttribute 의 이름
    //defaultMessage : 오류 기본 메시지
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
        }
    }

    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "validation/v2/addForm";
    }

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

 

 

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


메시지

스프링 부트에서 메시지를 관리하는 기능은 애플리케이션의 국제화(i18n)를 지원하며, 애플리케이션에서 사용되는 문자열을 손쉽게 관리할 수 있게 해준다. 이 기능을 사용하면, 다양한 언어와 지역에 맞춰 동적으로 메시지를 변경할 수 있으며, 코드 내에 하드코딩된 문자열을 줄임으로써 유지보수성을 높일 수 있다. 스프링 부트에서 메시지를 사용하는 방법에 대해 자세히 알아보자.



1. 메시지 소스 파일 준비

메시지 관리의 첫 단계는 src/main/resources 디렉토리 아래에 프로퍼티 파일 형태로 메시지 소스 파일을 준비하는 것이다. 기본적으로 messages.properties 파일을 사용하지만, 다국어 지원을 위해 국가 코드나 언어 코드를 접미사로 추가한 파일들(messages_en.properties, messages_ko.properties 등)을 함께 준비할 수 있다.

 

예를 들어서 messages.properties 라는 메시지 관리용 파일을 만들고 

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

각 HTML들은 다음과 같이 해당 데이터를 key 값으로 불러서 사용하는 것이다.

<label for="itemName" th:text="#{item.itemName}"></label>
  • messages.properties :기본 값으로 사용(한글)

  • messages_en.properties : 영어 국제화 사용

 

/resources/messages.properties (기본, 한국어 설정)

messages.properties

hello=안녕
hello.name=안녕 {0}

{0} 이 부분에 파라미터를 전송하면 파라미터를 포함해서 출력된다.

<p th:text="#{hello.name(${item.itemName})}"></p>

 


/resources/messages_en.properties(영어 설정)

messages_en.properties

 hello=hello
 hello.name=hello {0}

{0} 이 부분에 파라미터를 전송하면 파라미터를 포함해서 출력된다.

<p th:text="#{hello.name(${item.itemName})}"></p>

 

2. application.properties에서 메시지 소스 설정

application.properties 또는 application.yml 파일에 메시지 소스를 위한 설정을 추가해야 한다. 메시지 파일의 기본 이름(basename)을 지정하여 스프링 부트가 메시지 소스를 올바르게 찾을 수 있게 한다.

spring.messages.basename=messages,config.i18n.messages

MessageSource 를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages 라는 이름으로 기본 등록된다. 따라서 messages_en.properties , messages_ko.properties , messages.properties 파일만 등록하면 자동으로 인식된다.

스프링 부트 메시지 소스 기본 값
spring.messages.basename=messages

 

3. MessageSource를 통한 메시지 접근

스프링의 MessageSource 인터페이스를 사용하여 코드에서 메시지를 조회할 수 있다. MessageSource는 @Autowired를 통해 자동으로 주입받을 수 있으며, getMessage 메서드를 사용해 메시지를 가져온다.

@Autowired
MessageSource ms;

 

@Test
void helloMessage() {
    String result = ms.getMessage("hello", null, null);
    assertThat(result).isEqualTo("안녕");
}

ms.getMessage("hello", null, null)

  • code: hello
  • args: null
  • locale: null

가장 단순한 테스트는 메시지 코드로 hello 를 입력하고 나머지 값은 null 을 입력했다.

locale 정보가 없으면 basename 에서 설정한 기본 이름 메시지 파일을 조회한다. basename 으로 messages 를 지정했으므로 messages.properties 파일에서 데이터 조회한다.

 

public String helloMessage(Locale locale) {
    return ms.getMessage("welcome.message", null, locale);
}
  • code: 메시지의 키로 사용될 문자열. 이 경우, "welcome.message"가 해당된다.
  • args: 메시지에서 사용될 가능한 매개변수들의 배열. 메시지 텍스트 내에서 플레이스홀더로 대체될 값들이다. 여기서는 null을 사용하여 추가적인 매개변수가 필요 없음을 나타낸다.
  • locale: 메시지를 조회할 로케일. 이 매개변수를 통해 MessageSource는 주어진 로케일에 맞는 메시지를 반환하려고 시도한다.

 

메시지가 없는 경우, 기본 메시지

@Test
void notFoundMessageCodeDefaultMessage() {
    String result = ms.getMessage("no_code", null, "기본 메시지", null);
    assertThat(result).isEqualTo("기본 메시지");
}

@Test
void argumentMessage() {
    String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
    assertThat(result).isEqualTo("안녕 Spring");
}

메시지가 없는 경우에는 NoSuchMessageException 이 발생한다.
메시지가 없어도 기본 메시지( defaultMessage )를 사용하면 기본 메시지가 반환된다.

 

매개변수 사용

 @Test
void argumentMessage() {
    String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
    assertThat(result).isEqualTo("안녕 Spring");
}

다음 메시지의 {0} 부분은 매개변수를 전달해서 치환할 수 있다.

hello.name=안녕 {0} → Spring 단어를 매개변수로 전달 → 안녕 Spring

 

4. 컨트롤러에서 메시지 사용하기

스프링 MVC 컨트롤러에서는 @RequestMapping 메서드의 매개변수로 Locale을 받아 해당 사용자의 언어에 맞는 메시지를 반환할 수 있다.

@GetMapping("/welcome")
public String welcome(Locale locale, Model model) {
    String welcomeMessage = ms.getMessage("welcome.message", null, locale);
    model.addAttribute("message", welcomeMessage);
    return "welcome";
}

 

 

국제화

스프링 부트에서 국제화(i18n) 기능을 구현하는 것은 애플리케이션을 다양한 언어 및 지역 설정에 맞춰 사용할 수 있게 해주며, 사용자 경험을 향상시키는 데 중요하다.

국제화를 통해 애플리케이션은 사용자의 언어와 지역 설정에 맞는 적절한 메시지, 날짜, 통화 등을 제공할 수 있다. 스프링 부트에서 국제화를 구현하는 기본 단계는 다음과 같다.

 

1. 메시지 소스 파일 생성

src/main/resources 디렉토리에 위치한 프로퍼티 파일들을 통해 다국어 메시지를 관리한다.
파일 이름은 messages.properties로 시작하며, 각 언어 및 지역별로 접미사를 추가한다.

예: messages_en.properties, messages_fr.properties, messages_ko.properties 등.

이 파일들에는 키-값 쌍으로 메시지를 정의한다.

 

예를 들어서 아래와 같이 2개의 파일을 만들어서 분류한다.

messages_en.properties

 item=Item
 item.id=Item ID
 item.itemName=Item Name
 item.price=price
 item.quantity=quantity

 

messages_ko.properties

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량


영어를 사용하는 사람이면 messages_en.properties 를 사용하고,
한국어를 사용하는 사람이면 messages_ko.properties 를 사용하게 개발하면 된다.

이렇게 하면 사이트를 국제화 할 수 있다.

한국에서 접근한 것인지 영어에서 접근한 것인지는 인식하는 방법은 HTTP accept-language 해더 값을 사용하거 나 사용자가 직접 언어를 선택하도록 하고, 쿠키 등을 사용해서 처리하면 된다.

메시지와 국제화 기능을 직접 구현할 수도 있겠지만, 스프링은 기본적인 메시지와 국제화 기능을 모두 제공한다.

그리고 타임리프도 스프링이 제공하는 메시지와 국제화 기능을 편리하게 통합해서 제공한다.

 

2. application.properties에서 국제화 설정

application.properties 파일에 국제화 설정을 추가한다.

spring.messages.basename 속성을 사용하여 메시지 소스 파일의 기본 이름을 지정한다. 여러 개의 파일을 쉼표로 구분하여 지정할 수 있다.

spring.messages.basename=messages,config.i18n.messages

MessageSource 를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages 라는 이름으로 기본 등록된다. 따라서 messages_en.properties , messages_ko.properties , messages.properties 파일만 등록하면 자동으로 인식된다.

스프링 부트 메시지 소스 기본 값
spring.messages.basename=messages

 

3. 컨트롤러에서 국제화 메시지 사용

국제화 파일 선택

locale 정보를 기반으로 국제화 파일을 선택한다.
Locale이 en_US 의 경우 messages_en_US → messages_en → messages 순서로 찾는다.Locale 에 맞추어 구체적인 것이 있으면 구체적인 것을 찾고, 없으면 디폴트를 찾는다고 이해하면 된다.

@Autowired를 사용하여 MessageSource를 주입받는다.

getMessage() 메서드를 사용하여 요청된 로케일에 맞는 메시지를 조회한다.

@Autowired
private MessageSource ms;

 

@Test
void defaultLang() {
	assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");
	assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");\
}
  • ms.getMessage("hello", null, null) : locale 정보가 없으므로 messages 를 사용
  • ms.getMessage("hello", null, Locale.KOREA) : locale 정보가 있지만, message_ko 가 없으므로 messages 를 사용

 

@Test
void enLang() {
     assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello");
}
  • ms.getMessage("hello", null, Locale.ENGLISH) : locale 정보가 Locale.ENGLISH 이므로 messages_en 을 찾아서 사용
Locale 정보가 없는 경우 Locale.getDefault() 을 호출해서 시스템의 기본 로케일을 사용한다.

예) locale = null 인 경우 시스템 기본 locale 이 ko_KR 이므로 messages_ko.properties 조회 시도 → 조회 실패 → messages.properties 조회

 

스프링의 국제화 메시지 선택

메시지 기능은 Locale 정보를 알아야 언어를 선택할 수 있다.

결국 스프링도 Locale 정보를 알아야 언어를 선택할 수 있는데, 스프링은 언어 선택시 기본으로 Accept-Language 헤더의 값을 사용한다.

LocaleResolver

스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver 라는 인터페이스를 제공하는데, 스프링 부트는 기본으로 Accept-Language 를 활용하는 AcceptHeaderLocaleResolver 를 사용한다.
 public interface LocaleResolver {
     Locale resolveLocale(HttpServletRequest request);
     void setLocale(HttpServletRequest request, @Nullable HttpServletResponse
 response, @Nullable Locale locale);
}​
LocaleResolver 변경

만약 Locale 선택 방식을 변경하려면 LocaleResolver 의 구현체를 변경해서 쿠키나 세션 기반의 Locale 선택 기능을 사용할 수 있다.
예를 들어서 고객이 직접 Locale 을 선택하도록 하는 등

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


타임리프 스프링 통합

타임리프 기본 메뉴얼 : https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html

스프링 통합 메뉴얼 : https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html

 

스프링 통합으로 추가되는 기능

  • 스프링의 SpringEL 문법
  • ${@myBean.doSomething()} 처럼 스프링 빈 호출 지원
  • 편리한 폼 관리를 위한 추가 속성
    → th:object (기능 강화, 폼 커맨드 객체 선택)
    → th:field , th:errors , th:errorclass
  • 폼 컴포넌트 기능
    →checkbox, radio button, List 등을 편리하게 사용할 수 있는 기능 지원
  • 스프링의 메시지, 국제화 기능의 편리한 통합
  • 스프링의 검증, 오류 처리 통합
  • 스프링의 변환 서비스 통합(ConversionService)

 

스프링 부트에서 build.gradle에 아래의 한줄을 넣어주면 Gradle은 타임리프와 관련 된 라이브러리를 다운로드 받고, 스프링 부트는 타임리프와 관련된 설정용 스프링 빈을 자동으로 등록해준다.

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 

 

입력 폼 처리

  • th:object : 커맨드 객체를 지정한다.
  • *{...} : 선택 변수 식이라고 한다. th:object 에서 선택한 객체에 접근한다.
  • th:field
    HTML 태그의 id , name , value 속성을 자동으로 처리해준다.

 

렌더링 전

<input type="text" th:field="*{itemName}" />

 

렌더링 후

<input type="text" id="itemName" name="itemName" th:value="*{itemName}" />

 

 

등록 폼

<form action="item.html" th:action th:object="${item}" method="post">
    <div>
        <label for="itemName">상품명</label>
        <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
    </div>
    <div>
        <label for="price">가격</label>
        <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
    </div>
    <div>
        <label for="quantity">수량</label>
        <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
    </div>
  • th:object="${item}" : <form> 에서 사용할 객체를 지정한다. 선택 변수 식( {...} )을 적용할 수 있다.
  • th:field="{itemName}"
    →*{itemName} 는 선택 변수 식을 사용했는데, ${item.itemName} 과 같다. 앞서 th:object 로 item 을 선택했기 때문에 선택 변수식을 적용할 수 있다.
  • th:field 는 id , name , value 속성을 모두 자동으로 만들어준다.
    → id : th:field 에서 지정한 변수 이름과 같다. id="itemName"
    → name : th:field 에서 지정한 변수 이름과 같다. name="itemName"
    → value : th:field 에서 지정한 변수의 값을 사용한다. value=""
  • 참고로  id 속성을 제거해도 th:field 가 자동으로 만들어준다.

 

렌더링 전

<input type="text" id="itemName" th:field="*{itemName}" class="form-control">

 

렌더링 후

<input type="text" id="itemName" class="form-control" name="itemName" value="itemA">

 

체크박스, 라디오 버튼, 셀렉트 박스

 

ItemType - 상품 종류

public enum ItemType {
    BOOK("도서"), FOOD("식품"), ETC("기타");
         private final String description;
         ItemType(String description) {
             this.description = description;
    }
         public String getDescription() {
             return description;
    }
}

 

DeliveryCode - 배송 방식

/**
* FAST: 빠른 배송
* NORMAL: 일반 배송 * SLOW: 느린 배송 */
 @Data
 @AllArgsConstructor
 public class DeliveryCode {
     private String code;
     private String displayName;
 }

 

Item - 상품

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    private Boolean open; //판매여부
    private List<String> regions; //등록지역
    private ItemType itemType; //상품 종류
    private String deliveryCode; //배송 방식
    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

타임리프 - 체크 박스 코드

<!-- single checkbox -->
<div>판매 여부</div> <div>
     <div class="form-check">
        <input type="checkbox" id="open" th:field="*{open}" class="form-check-input">
        <label for="open" class="form-check-label">판매 오픈</label>
     </div>
 </div>

 

타임리프를 사용하면 체크 박스의 히든 필드와 관련된 부분도 함께 해결해준다.

HTML 생성 결과를 보면 히든 필드 부분이 자동으로 생성되어 있다.

따라서 체크박스를 선택하지 않아도 null로 전송되는 것을 해결할 수 있다.

  • item.open=true //체크 박스를 선택하는 경우
  • item.open=false //체크 박스를 선택하지 않는 경우

 

체크 박스 - 멀티

컨트롤러

@ModelAttribute("regions")
 public Map<String, String> regions() {
    Map<String, String> regions = new LinkedHashMap<>(); regions.put("SEOUL", "서울");
    regions.put("BUSAN", "부산");
    regions.put("JEJU", "제주");
	return regions;
}

@ModelAttribute 를 컨트롤러 클래스에서 별도의 메서드에 붙이면 해당 컨트롤러를 요청할 때 regions 에서 반환한 값이 자동으로 모델에 담기게 된다.

 

타임리프

<!-- multi checkbox -->
<div>
    <div>등록 지역</div>
    <div th:each="region : ${regions}" class="form-check form-check-inline"> <!-- ModelAttribute에 담긴 region -->
        <input type="checkbox" th:field="${item.regions}" th:value="${region.key}" class="form-check-input" disabled> <!-- region.key: 서울 -> 부산 -> 제주 -->
        <label th:for="${#ids.prev('regions')}" th:text="${region.value}" class="form-check-label">서울</label>
    </div>
</div>

이 과정에서 중요한 것은 th:field를 사용하여 Model의 특정 필드와 체크박스를 연결하고, th:value를 사용하여 각 체크박스의 값을 지정하는 것이다.

  • th:each를 사용한 반복: regions 컬렉션에 대한 반복을 통해 각 지역에 대한 체크박스를 동적으로 생성한다. region 변수는 현재 반복 중인 지역을 나타낸다.
  • th:field의 사용: th:field="*{regions}"는 폼의 백엔드 모델에 있는 regions 필드와 체크박스 그룹을 바인딩한다. 이를 통해 체크박스의 선택 상태가 해당 모델 필드에 자동으로 반영된다.
  • th:value를 통한 값 지정: 각 체크박스에 th:value="${region.key}"를 사용하여 값을 지정한다. 이 값은 체크박스가 선택될 때 모델의 regions 필드에 저장될 식별 값이다.
  • th:text와 th:label을 사용한 레이블 설정: th:text를 사용하여 체크박스 옆에 표시될 지역 이름을 설정한다. th:label은 체크박스와 연결된 레이블을 지정하는데, th:for="${#ids.prev('regions')}"는 생성된 체크박스의 id를 레이블의 for 속성과 연결하여, 레이블 클릭 시 해당 체크박스가 선택되도록 한다.

 

th:for="${#ids.prev('regions')}"

→ 멀티 체크박스는 같은 이름의 여러 체크박스를 만들 수 있다. 그런데 문제는 이렇게 반복해서 HTML 태그를 생성할 때, 생성된 HTML 태그 속성에서 name 은 같아도 되지만, id 는 모두 달라야 한다. 따라서 타임리프는 체크박스를 each 루프 안에서 반복해서 만들 때 임의로 1 ,2 ,3 숫자를 뒤에 붙여준다.

 <input type="checkbox" value="SEOUL" class="form-check-input" id="regions1" name="regions">
 <input type="checkbox" value="BUSAN" class="form-check-input" id="regions2" name="regions">
 <input type="checkbox" value="JEJU" class="form-check-input" id="regions3" name="regions">

HTML의 id 가 타임리프에 의해 동적으로 만들어지기 때문에 <label for="id 값"> 으로 label 의 대상이 되는 id 값을 임의로 지정하는 것은 곤란하다. 타임리프는 ids.prev(...) , ids.next(...) 을 제공해서 동적으로 생성되는 id 값을 사용할 수 있도록 한다.

 

서울, 부산 선택

regions=SEOUL&_regions=on&regions=BUSAN&_regions=on&_regions=on

 

지역 선택X

_regions=on&_regions=on&_regions=on

웹 브라우저에서 체크를 하나도 하지 않았을 때, 클라이언트가 서버에 아무런 데 이터를 보내지 않는 것을 방지한다. _regions 조차 보내지 않으면 결과는 null 이 된다.
_regions 가 체크박스 숫자만큼 생성될 필요는 없지만, 타임리프가 생성되는 옵션 수 만큼 생성한다.

 

라디오 버튼

DeliveryCode

@Data
@AllArgsConstructor
public class DeliveryCode {
    private String code;
    private String displayName;
}

 

컨트롤러

@ModelAttribute("itemTypes")
 public ItemType[] itemTypes() {
     return ItemType.values();
 }
ItemType.values() 를 사용하면 해당 ENUM의 모든 정보를 배열로 반환한다. 예) [BOOK, FOOD, ETC]

 

타임리프

<!-- radio button -->
<div>
    <div>상품 종류</div>
    <div th:each="type : ${itemTypes}" class="form-check form-check-inline">
        <input type="radio" th:field="${item.itemType}" th:value="${type.name()}" class="form-check-input" disabled>
        <label th:for="${#ids.prev('itemType')}" th:text="${type.description}" class="form-check-label">BOOK</label>
    </div>
</div>
  • th:each를 사용한 반복: itemTypes 컬렉션에 대한 반복을 통해 각 상품 종류에 대한 라디오 버튼을 동적으로 생성한다. type 변수는 현재 반복 중인 상품 종류를 나타낸다.
  • th:field의 사용: th:field="${item.itemType}"는 폼의 백엔드 모델에 있는 itemType 필드와 라디오 버튼 그룹을 바인딩한다. 이를 통해 선택된 라디오 버튼의 값이 해당 모델 필드에 자동으로 반영된다.
  • th:value를 통한 값 지정: 각 라디오 버튼에 th:value="${type.name()}"를 사용하여 값을 지정한다. 이 값은 라디오 버튼이 선택될 때 모델의 itemType 필드에 저장될 값이다.
  • th:text와 th:label을 사용한 레이블 설정: th:text를 사용하여 라디오 버튼 옆에 표시될 상품 종류의 설명을 설정한다. th:label은 라디오 버튼과 연결된 레이블을 지정하는데, th:for="${#ids.prev('itemType')}"는 생성된 라디오 버튼의 id를 레이블의 for 속성과 연결하여, 레이블 클릭 시 해당 라디오 버튼이 선택되도록 한다.
  • disabled 속성의 사용: 라디오 버튼에 disabled 속성이 추가되어 있다. 이는 사용자가 해당 라디오 버튼을 선택하거나 변경할 수 없음을 의미한다. 특정 조건하에만 라디오 버튼을 활성화하려면, 서버 측 또는 클라이언트 측 스크립트를 사용하여 이 속성을 동적으로 제어할 수 있다.

 

실행 결과, 폼 전송

itemType=FOOD //음식 선택, 선택하지 않으면 아무 값도 넘어가지 않는다. 

 

로그 추가

log.info("item.itemType={}", item.getItemType());

 

실행 로그

  • item.itemType=FOOD: 값이 있을 때
  • item.itemType=null: 값이 없을 때

체크 박스는 수정시 체크를 해제하면 아무 값도 넘어가지 않기 때문에, 별도의 히든 필드로 이런 문제를 해결했다.

라디오 버튼은 이미 선택이 되어 있다면, 수정시에도 항상 하나를 선택하도록 되어 있으므로 체크 박스와 달리 별도의 히든 필드를 사용할 필요가 없다.

 

셀렉트 박스

컨트롤러

@ModelAttribute("deliveryCodes")
public List<DeliveryCode> deliveryCodes() {
    List<DeliveryCode> deliveryCodes = new ArrayList<>();
    deliveryCodes.add(new DeliveryCode("FAST", "빠른 배송"));
    deliveryCodes.add(new DeliveryCode("NORMAL", "일반 배송"));
    deliveryCodes.add(new DeliveryCode("SLOW", "느린 배송"));
    return deliveryCodes;
}

 

타임리프

<!-- SELECT -->
<div>
    <div>배송 방식</div>
    <select th:field="${item.deliveryCode}" class="form-select" disabled>
        <option value="">==배송 방식 선택==</option>
        <option th:each="deliveryCode : ${deliveryCodes}" th:value="${deliveryCode.code}" th:text="${deliveryCode.displayName}">FAST</option>
    </select>
</div>
  • th:field를 사용한 바인딩: th:field="${item.deliveryCode}"는 <select> 요소를 폼의 백엔드 모델에 있는 deliveryCode 필드와 바인딩한다. 이를 통해 사용자가 선택한 옵션의 값이 해당 모델 필드에 자동으로 반영된다.
  • 첫 번째 option 요소: 첫 번째 option 요소는 사용자에게 기본 안내 메시지를 제공한다. value=""는 실제 선택 가능한 옵션이 아님을 나타낸다.
  • th:each를 사용한 옵션의 동적 생성: th:each="deliveryCode : ${deliveryCodes}"를 사용하여 deliveryCodes 컬렉션의 각 항목에 대해 <option> 요소를 동적으로 생성한다. deliveryCode 변수는 현재 반복 중인 배송 코드 객체를 나타낸다.
  • th:value와 th:text를 통한 옵션 설정: th:value="${deliveryCode.code}"를 사용하여 각 <option>의 값을 설정한다. 이 값은 사용자가 해당 옵션을 선택했을 때 deliveryCode 필드에 저장될 식별 값이다. th:text="${deliveryCode.displayName}"를 사용하여 드롭다운 메뉴에 표시될 배송 방식의 이름을 설정한다.
  • disabled 속성: <select> 요소에 disabled 속성이 추가되어 있다는 것은 사용자가 드롭다운 메뉴에서 선택을 변경할 수 없음을 의미한다. 특정 조건에서만 이 드롭다운을 활성화하려면, 서버 측 또는 클라이언트 측 스크립트를 사용하여 이 속성을 동적으로 제어할 수 있다.