no image
[Spring MVC] 세션을 이용한 로그인
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 세션을 이용한 로그인 처리 쿠키에 중요한 정보를 보관하는 방법은 여러가지 보안 이슈가 있다. 이 문제를 해결하려면 결국 중요한 정보를 모두 서버에 저장해야 한다. 그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다. 이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다. 사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다. 세션 ID를 생성하는데, 추정 불가능해야 한다. →UUID는 추정이 불가능하다. UUID ex) mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61 세션 저장소의 ..
2024.03.17
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
[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
[Spring DB] 예외 추상화, jdbcTemplate
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 스프링 데이터 접근 예외 계층 스프링은 데이터 접근과 관련된 예외를 추상화해서 제공한다. 스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리해서 일관된 예외 계층을 제공한다. 각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있다. 따라서 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다. 예를 들어서 JDBC 기술을 사용하든, JPA 기술을 사용하든 스프링이 제공하는 예외를 사용하면 된다. JDBC나 JPA를 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해주는 역할도 스프링이 제공한다. 예외의 최고 상위는 org.springframework.dao.DataAccessException ..
2024.03.05

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


세션을 이용한 로그인 처리

쿠키에 중요한 정보를 보관하는 방법은 여러가지 보안 이슈가 있다.
이 문제를 해결하려면 결국 중요한 정보를 모두 서버에 저장해야 한다. 그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.

이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.

 

사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.

 

세션 ID를 생성하는데, 추정 불가능해야 한다.
→UUID는 추정이 불가능하다.

UUID ex) mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61

세션 저장소의 세션 아이디에는 UUID를 저장하고, value에는 정보를 저장한다.

즉, 생성된 세션 ID(UUID)와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관한다.

 

서버는 클라이언트에 mySessionId 라는 이름으로 세션ID(UUID) 만 쿠키에 담아서 전달한다.

클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.

여기서 중요한 포인트는 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것이다.
오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.

 

클라이언트는 요청시 항상 mySessionId 쿠키를 전달한다.
서버에서는 클라이언트가 전달한 mySessionId 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.

 

세션을 사용해서 서버에서 중요한 정보를 관리하게 되었다.
따라서 아래와 같은 보안 문제들을 해결할 수 있다.

-쿠키 값을 변조 가능 → 예상 불가능한 복잡한 세션Id를 사용한다.
-쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다. → 세션Id가 털려도 여기에는 중요한 정보가 없다.

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

 

직접 세션 관리

세션 관리는 크게 아래와 같이 3가지 기능을 제공하면 된다.

  • 세션 생성

    →sessionId 생성 (임의의 추정 불가능한 랜덤 값)
    세션 저장소에 sessionId와 보관할 값 저장
    sessionId로 응답 쿠키를 생성해서 클라이언트에 전달

  • 세션 조회
    클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회

  • 세션 만료
    클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거

 

/**
 * 세션 관리
 */
@Component
public class SessionManager {
    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
    /**
     * 세션 생성
     */
    public void createSession(Object value, HttpServletResponse response) {
        //세션 id를 생성하고, 값을 세션에 저장
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);

        //쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }

    /**
     * 세션 조회
     */
    public Object getSession(HttpServletRequest request) {

        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie == null) {
            return null;
        }
        return sessionStore.get(sessionCookie.getValue());
    }

    /**
     * 세션 만료
     */
    public void expire(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie != null) {
            sessionStore.remove(sessionCookie.getValue());
        }
    }
    private Cookie findCookie(HttpServletRequest request, String cookieName) {

//        Cookie[] cookies = request.getCookies();
//        if(cookies == null){
//            return null;
//        }
//        for (Cookie cookie : cookies) {
//            if(cookie.getName().equals(SESSION_COOKIE_NAME)){
//                return sessionStore.get(cookie.getValue());
//            }
//        }
//        return null;
        
        if (request.getCookies() == null) {
            return null;
        }

        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }
}
Stream API에는 findFirst()와 findAny() 메소드가 있다.
이 메소드들은 스트림에서 요소를 찾을 때 사용되며, Optional<T> 타입의 결과를 반환한다. 이는 찾은 요소가 존재할 수도 있고, 존재하지 않을 수도 있다는 것을 의미한다.


stream.findFirst()
findFirst() 메소드는 스트림의 첫 번째 요소를 반환한다. 이 메소드는 순서가 중요한 스트림에서 특히 유용하다.
예를 들어, 리스트나 정렬된 컬렉션에서 생성된 스트림, 또는 정렬된 스트림에서 첫 번째 요소를 찾고 싶을 때 사용된다.


stream.findAny()
findAny() 메소드는 스트림의 "어떤" 요소라도 반환할 수 있다. 병렬 스트림에서 작업을 수행할 때 이 메소드를 사용하면 효율적이다.
순서가 중요하지 않거나, 단순히 스트림 내의 어떤 요소라도 반환받기를 원할 때 유용하다. findAny()는 첫 번째 요소가 아닌 다른 요소를 반환할 수도 있다는 점에서 findFirst()와 구별된다.

orElse()
Optional 객체가 값이 있으면 그 값을 반환하고, 값이 없을 경우에는 메소드에 전달된 인자를 기본값으로 반환

 

ConcurrentHashMap
java.util.concurrent 패키지에 있는 스레드 안전한 해시 테이블의 구현체이다.
이 클래스는 멀티 스레드 환경에서 고성능을 유지하면서도, 데이터의 일관성을 보장하기 위해 설계되었다. HashMap과 비교했을 때, ConcurrentHashMap은 동시성(concurrency)을 향상시키기 위한 여러 가지 전략을 채택하고 있다.

특징
스레드 안전: ConcurrentHashMap의 모든 연산은 스레드 안전하다. 여러 스레드가 동시에 맵을 수정하거나 조회해도, ConcurrentHashMap은 데이터의 일관성을 유지한다.

락 분할(Lock Stripping): ConcurrentHashMap은 내부적으로 여러 개의 락을 사용하여 데이터 구조의 다른 부분을 동시에 수정할 수 있도록 한다. 이로 인해, 동시에 여러 스레드가 맵에 접근하더라도 높은 동시성을 제공한다.

null 값 불허: ConcurrentHashMap은 키(key)나 값(value)으로 null을 허용하지 않는다. 이는 HashMap과의 주요 차이점 중 하나이다.

고성능: 락 분할 기법 덕분에, ConcurrentHashMap은 동시에 여러 스레드가 맵을 사용할 때 뛰어난 성능을 제공한다.

 

테스트 코드

class SessionManagerTest {
    SessionManager sessionManager = new SessionManager();
    @Test
    void sessionTest() {
        //세션 생성
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member, response);

        //요청에 응답 쿠키 저장
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies());

        //세션 조회
        Object result = sessionManager.getSession(request);
        assertThat(result).isEqualTo(member);

        //세션 만료
        sessionManager.expire(request);
        Object expired = sessionManager.getSession(request);
        assertThat(expired).isNull();
    }
}

MockHttpServletResponse와 MockHttpServletRequest

스프링 프레임워크에서 제공하는 목(mock) 객체로, 실제 HTTP 요청과 응답을 모의하는데 사용된다.
이를 통해 실제 네트워크 환경이나 웹 서버 없이 HTTP 통신을 시뮬레이션할 수 있다.

 

세션 생성: SessionManager의 createSession() 메소드를 사용하여 Member 객체를 세션에 저장한다. 이 과정에서 MockHttpServletResponse 객체를 사용하여 HTTP 응답에 세션 정보를 쿠키로 추가한다.

 

요청에 응답 쿠키 저장: 생성된 세션 쿠키를 MockHttpServletRequest 객체에 설정한다. 이는 클라이언트가 서버로 요청을 보낼 때 쿠키를 포함시키는 것을 모의한다.

 

세션 조회: SessionManager의 getSession() 메소드를 호출하여 요청 객체를 기반으로 세션 정보를 조회한다. 조회 결과가 원래 저장한 Member 객체와 동일한지 검증한다.

 

세션 만료: SessionManager의 expire() 메소드를 사용하여 세션을 만료시킨다. 이후 같은 요청 객체로 세션을 조회했을 때 null이 반환되는지 검증한다. 이는 세션이 성공적으로 만료되었음을 확인한다.

 

 

HttpSession

서블릿은 세션을 위해 HttpSession 이라는 기능을 제공하는데, 지금까지 나온 문제들을 해결해준다.

직접 구현한 세션의 개념이 이미 구현되어 있고, 더 잘 구현되어 있다.

서블릿이 제공하는 HttpSession 도 결국 직접 만든 SessionManager 와 같은 방식으로 동작한다.

서블릿을 통해 HttpSession 을 생성하면 다음과 같은 쿠키를 생성한다.

쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.

 

세션 생성: 사용자가 웹 사이트에 처음 방문하거나 세션이 만료된 후 새로운 요청을 할 때, 서버는 자동으로 새로운 HttpSession 객체를 생성한다.

 

세션 데이터 저장 및 검색: HttpSession 객체는 사용자별 정보를 저장할 수 있는 속성을 제공한다.

-setAttribute(String name, Object value) 메소드를 사용해 데이터를 세션에 저장하고

-getAttribute(String name) 메소드로 데이터를 검색할 수 있다.

 

세션 유효 시간 관리: HttpSession의 유효 시간은 기본적으로 웹 서버에서 설정되며, setMaxInactiveInterval(int interval) 메소드를 사용해 프로그래밍 방식으로 세션의 유효 시간을 조정할 수 있다. 사용자가 설정된 시간 동안 활동이 없으면 세션이 만료된다.

 

세션 종료: invalidate() 메소드를 호출하여 언제든지 세션을 종료시킬 수 있다. 이 메소드는 세션에 저장된 모든 데이터를 삭제하고 세션을 무효화한다.

 

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    // 세션 얻기
    HttpSession session = request.getSession();

    // 세션에 데이터 저장
    session.setAttribute("user", "John Doe");

    // 세션에서 데이터 검색
    String user = (String) session.getAttribute("user");
    System.out.println("User: " + user);

    // 세션 유효 시간 설정 (초 단위)
    session.setMaxInactiveInterval(300); // 5분

    // 세션 종료
    session.invalidate();
}

 

세션의 생성과 조회

세션을 생성하려면 request.getSession(true) 를 사용하면 된다.
public HttpSession getSession(boolean create);​


세션의 create 옵션
request.getSession(true) :  세션이 있으면 기존 세션을 반환한다. 세션이 없으면 새로운 세션을 생성해서 반환한다. (기본 값은 true)
request.getSession(false) :  세션이 있으면 기존 세션을 반환한다. 세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다. 


세션을 찾아서 사용하는 시점에는 create: false 옵션을 사용해서 세션을 생성하지 않아야 한다.

 

@SessionAttribute

스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute 을 지원한다.
이미 로그인 된 사용자를 찾을 때는 다음과 같이 사용하면 된다.

@SessionAttribute(name = "loginMember", required = false) Member loginMember

이 기능은 세션을 생성하지 않는다.

 

이 어노테이션을 사용하면, 세션에서 직접 속성을 가져오거나 세션에 속성을 추가하는 복잡한 작업 없이, 세션에 저장된 데이터를 쉽게 사용할 수 있다.

 

@SessionAttribute는 주로 컨트롤러 클래스 레벨에 선언되며, 이 어노테이션으로 지정된 속성은 해당 컨트롤러의 모든 핸들러 메소드에서 접근할 수 있게 된다. 또한, 메소드 파라미터 레벨에서 사용될 수도 있어, 특정 핸들러 메소드에서만 세션 속성을 사용하고 싶을 때 유용하다.

 

클래스 레벨에서의 사용 예

@Controller
@SessionAttributes("user")
public class MyController {

    @ModelAttribute("user")
    public User createUserModel() {
        return new User();
    }

    @GetMapping("/user/profile")
    public String userProfile(Model model) {
        // "user" 세션 속성 사용 가능
        User user = (User) model.getAttribute("user");
        return "userProfile";
    }
}

 

메소드 파라미터 레벨에서의 사용 예

@GetMapping("/user/profile")
public String getUserProfile(@SessionAttribute("user") User user, Model model) {
    // 메소드 파라미터로 "user" 세션 속성 직접 접근
    return "userProfile";
}

 

 @GetMapping("/")
public String homeLoginV3Spring(
        @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
        Member loginMember,
        Model model) {

        //세션에 회원 데이터가 없으면 home
        if (loginMember == null) {
        return "home";
    }

    //세션이 유지되면 로그인으로 이동
    model.addAttribute("member",loginMember);
    return"loginHome";
}

 

@SessionAttribute로 지정된 세션 속성은 컨트롤러 내부에서 모델에 자동으로 추가되므로, 뷰 템플릿에서 해당 데이터를 사용할 수 있다.

 

이 어노테이션은 주로 읽기 전용 시나리오에서 사용될 것을 권장한다. 

 

세션 데이터를 변경하고 싶다면, 직접 HttpSession을 사용하는 것이 좋다.

 

@SessionAttributes와 혼동하지 말아야 한다. @SessionAttributes는 모델 속성을 세션에 저장하기 위해 사용되며, 주로 폼 제출 과정에서 사용자의 입력 데이터를 임시로 저장하는 데 쓰인다. 

 

반면, @SessionAttribute는 이미 세션에 저장된 속성에 접근하는 데 사용된다.

 

세션에서 직접 관리해야 하는 데이터가 아니라면, 가능한 한 세션 사용을 최소화하는 것이 좋다. 세션 데이터는 서버의 메모리를 사용하기 때문에, 과도한 사용은 애플리케이션의 성능에 부정적인 영향을 줄 수 있다.

 

TrackingModes

세션을 이용해 로그인을 하게되면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.

-http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872

jsessionid가 노출되지 않도록 하기 위해서는 application.properties에 아래의 코드를 넣어주면 된다.

server.servlet.session.tracking-modes=cookie

 

세션 정보와 타임아웃 설정

세션 정보 확인

@Slf4j
@RestController
public class SessionInfoController {
    @GetMapping("/session-info")
    public String sessionInfo(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return "세션이 없습니다.";
        }

    //세션 데이터 출력
    session.getAttributeNames().asIterator()
            .forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));
        log.info("sessionId={}", session.getId()); //세션Id, JSESSIONID 의 값
        log.info("maxInactiveInterval={}", session.getMaxInactiveInterval()); //세션의 유효 시간
        log.info("creationTime={}", new Date(session.getCreationTime())); //세션 생성일시
        log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime())); //세션과 연결된 사용자가 최근에 서버에 접근한 시간
        log.info("isNew={}", session.isNew()); //새로 생성된 세션인지 아닌지
        return "세션 출력";
    }
}

 

  • sessionId: 세션Id, JSESSIONID의 값이다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1
  • maxInactiveInterval: 세션의 유효 시간, 예) 1800초, (30분)
  • creationTime: 세션 생성일시
  • lastAccessedTime: 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 sessionId (JSESSIONID)를 요청한 경우에 갱신된다.
  • isNew: 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId (JSESSIONID)를 요청해서 조회된 세션인지 여부

 

세션 타임아웃 설정

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제된다.

그런데 대부분 의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다.

문제는 HTTP가 비 연결성(ConnectionLess) 이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다.

따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.

 

이 경우 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.

  • 세션과 관련된 쿠키( JSESSIONID )를 탈취 당했을 경우 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
  • 세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.

 

세션의 종료 시점

세션 생성 시점으로부터 30분 정도로 잡는다면,  30분이 지나면 세션이 삭제되기 때문에 30분 마다 계속 로그인해야 하는 번거로움이 발생한다.

더 나은 대안은 세션 생성 시점이 아니라 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것이다. 이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다.

따라서 30 분 마다 로그인해야 하는 번거로움이 사라진다. HttpSession 은 이 방식을 사용한다.

 

세션 타임아웃 설정

application.properties 에서 타임아웃을 설정할 수 있다.

server.servlet.session.timeout=60

60초, 기본은 1800(30분) (글로벌 설정은 분 단위로 설정해야 한다. 60(1분), 120(2분), ...)

 

특정 세션 단위로 시간 설정

session.setMaxInactiveInterval(1800); //1800초

 

세션 타임아웃 발생

세션의 타임아웃 시간은 해당 세션과 관련된 JSESSIONID 를 전달하는 HTTP 요청이 있으면 현재 시간으로 다시 초기화 된다.

이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있다.

  • session.getLastAccessedTime() : 최근 세션 접근 시간

LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.

 

보관한 데이터 용량 사용자 수로 세션의 메모리 사용량 이 급격하게 늘어나서 장애로 이어질 수 있다.
추가로 세션의 시간을 너무 길게 가져가면 메모리 사용이 계속 누적 될 수 있으므로 적당한 시간을 선택하는 것이 필요하다.
기본이 30분이라는 것을 기준으로 고민하면 된다.

이 글은 인프런 김영한님의 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분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.

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

이 글은 인프런 김영한님의 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 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


스프링 데이터 접근 예외 계층

스프링은 데이터 접근과 관련된 예외를 추상화해서 제공한다.

그림을 단순화 하기 위해 일부 계층을 생략

스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리해서 일관된 예외 계층을 제공한다.

 

각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있다. 따라서 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다. 예를 들어서 JDBC 기술을 사용하든, JPA 기술을 사용하든 스프링이 제공하는 예외를 사용하면 된다.

 

JDBC나 JPA를 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해주는 역할도 스프링이 제공한다.

 

예외의 최고 상위는 org.springframework.dao.DataAccessException 이다. 그림에서 보는 것 처럼 런타임 예외를 상속 받았기 때문에 스프링이 제공하는 데이터 접근 계층의 모든 예외는 런타임 예외이다.

 

DataAccessException 은 크게 2가지로 구분하는데 NonTransient 예외와 Transient 예외이다.

  • Transient 는 일시적이라는 뜻이다. Transient 하위 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다. 예를 들어서 쿼리 타임아웃, 락과 관련된 오류들이다. 이런 오류들은 데이터베이스 상태가 좋아지거나, 락이 풀렸을 때 다시 시도하면 성공할 수도 있다.
  • NonTransient 는 일시적이지 않다는 뜻이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다. SQL 문법 오류, 데이터베이스 제약조건 위배 등이 있다.
스프링 메뉴얼에 모든 예외가 정리되어 있지는 않기 때문에 코드를 직접 열어서 확인해보는 것이 필요하다.

 

스프링이 제공하는 예외 변환기

@Test
void exceptionTranslator() {
    String sql = "select bad grammar";
    try {
        Connection con = dataSource.getConnection();
        PreparedStatement stmt = con.prepareStatement(sql);
        stmt.executeQuery();
    } catch (SQLException e) {
        //org.springframework.jdbc.support.sql-error-codes.xml
        SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
        //org.springframework.jdbc.BadSqlGrammarException
        DataAccessException resultEx = exTranslator.translate("select", sql, e);
        log.info("resultEx", resultEx);
        assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
    }
}

translate() 메서드의 첫번째 파라미터는 읽을 수 있는 설명이고(개발자가 알아보기 쉽게 설정), 두번째는 실행한 sql, 마지막은 발생된 SQLException 을 전달하면 된다. 이렇게 하면 적절한 스프링 데이터 접근 계층의 예외로 변환해서 반환해준다.

 

예제에서는 SQL 문법이 잘못되었으므로 BadSqlGrammarException 을 반환하는 것을 확인할 수 있다.
눈에 보이는 반환 타입은 최상위 타입인 DataAccessException 이지만 실제로는 BadSqlGrammarException 예외가 반환된다. 마지막에 assertThat() 부분을 확인하자.

참고로 BadSqlGrammarException 은 최상위 타입인 DataAccessException 를 상속 받아서 만들어진다.

 

스프링은 예외 변환기를 통해서 SQLException 의 ErrorCode 에 맞는 적절한 스프링 데이터 접근 예외로 변환해준다.
만약 서비스, 컨트롤러 계층에서 예외 처리가 필요하면 특정 기술에 종속적인 SQLException 같은 예외를 직접 사용하는 것이 아니라, 스프링이 제공하는 데이터 접근 예외를 사용하면 된다.

 

스프링 예외 추상화 덕분에 특정 기술에 종속적이지 않게 되었다.

 

JDBC에서 JPA같은 기술로 변경되어도 예외로 인한 변경을 최소화 할 수 있다. 향후 JDBC에서 JPA로 구현 기술을 변경하더라도, 스프링은 JPA 예외를 적절한 스프링 데이터 접근 예외로 변환해준다.

 

물론 스프링이 제공하는 예외를 사용하기 때문에 스프링에 대한 기술 종속성은 발생한다.

스프링에 대한 기술 종속성까지 완전히 제거하려면 예외를 모두 직접 정의하고 예외 변환도 직접 하면 되지만, 실용적인 방법은 아니다.

 

스프링 예외 추상화 적용

public class MemberRepositoryV4_2 implements MemberRepository{
    private final DataSource dataSource;
    private final SQLExceptionTranslator exTranslator;
    public MemberRepositoryV4_2(DataSource dataSource) {
        this.dataSource = dataSource;
        //SQLExceptionTranslator 인터페이스에 여러가지가 있지만 SQLErrorCodeSQLExceptionTranslator 사용
        this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); 
    }
    
    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";
        try {
            //데이터베이스 save(저장) 로직
        } catch (SQLException e) {
            throw exTranslator.translate("save", sql, e); //예외를 던짐
        } finally {
            close(con, pstmt, null);
        }
    }
    
    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";
        try {
            //데이터베이스에서 특정 데이터를 찾는 로직
        } catch (SQLException e) {
            throw exTranslator.translate("findById", sql, e); //예외를 던짐
        } finally {
            close(con, pstmt, rs);
        }
    }
    ...
}

스프링이 예외를 추상화해준 덕분에, 서비스 계층은 특정 리포지토리의 구현 기술과 예외에 종속적이지 않게 할 수 있다.

따라서 서비스 계층은 특정 구현 기술이 변경되어도 그대로 유지할 수 있다.  DI를 제대로 활용할 수 있게 된 것이다.

추가로 서비스 계층에서 예외를 잡아서 복구해야 하는 경우, 예외가 스프링이 제공하는 데이터 접근 예외로 변경되어서 서비스 계층에 넘어오기 때문에 필요한 경우 예외를 잡아서 복구하면 된다.

 

jdbcTemplate(JDBC 반복문제 해결)

JDBC 반복 문제

  • 커넥션 조회, 커넥션 동기화
  • PreparedStatement 생성 및 파라미터 바인딩 쿼리 실행
  • 결과 바인딩
  • 예외 발생시 스프링 예외 변환기 실행
  • 리소스 종료

 

데이터 접근 계층의 각각의 메서드에 jdbc 기술이 사용되면 상당히 많은 부분이 반복된다. 이런 반복을 효과적으로 처리하는 방법이 바로 템플릿 콜백 패턴이다.


스프링은 JDBC의 반복 문제를 해결하기 위해 JdbcTemplate 이라는 템플릿을 제공한다.

@Slf4j
public class MemberRepositoryV5 implements MemberRepository {
    private final JdbcTemplate template;
    public MemberRepositoryV5(DataSource dataSource) {
        template = new JdbcTemplate(dataSource);
    }
    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?, ?)";
        template.update(sql, member.getMemberId(), member.getMoney());
        //template.update()는 변경된 row 수를 리턴한다.
        return member;
    }

    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";
        return template.queryForObject(sql, memberRowMapper(), memberId); //주의
        //select 문으로 객체를 반환할 때, 콜백 패턴으로 따로 처리를 해줘야 한다.
    }

    @Override
    public void update(String memberId, int money) {
        String sql = "update member set money=? where member_id=?";
        template.update(sql, money, memberId);
    }

    @Override
    public void delete(String memberId) {
        String sql = "delete from member where member_id=?";
        template.update(sql, memberId);
    }

    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> { //콜백패턴
            Member member = new Member();
            member.setMemberId(rs.getString("member_id"));
            member.setMoney(rs.getInt("money"));
            return member;
        };
    }
}

 

template.queryForObject(sql, memberRowMapper(), memberId);

이 코드는 스프링 프레임워크의 JdbcTemplate 클래스의 인스턴스인 template을 사용하여 데이터베이스에서 단일 객체를 조회하는 것이다. 이 구문에서 사용되는 메서드와 매개변수의 역할은 다음과 같다.

  • template은 JdbcTemplate의 인스턴스이다. JdbcTemplate은 스프링이 제공하는 클래스로, JDBC를 통해 데이터베이스에 접근하여 작업을 수행할 때 반복되는 코드와 예외 처리를 간소화하는 역할을 한다. 
  • queryForObject 메서드는 SQL 쿼리를 실행하여 결과로 반환되는 단일 객체를 조회하는 메서드이다. 이 메서드는 쿼리 결과가 정확히 하나의 객체만을 반환해야 한다. 반환되는 객체의 수가 하나가 아닌 경우 IncorrectResultSizeDataAccessException 예외가 발생한다.
  • sql은 데이터베이스에서 실행할 SQL 쿼리 문자열이다.
  • memberRowMapper()는 결과 행을 객체로 매핑하는 역할을 하는 RowMapper 인터페이스의 구현체를 반환하는 메서드이다. RowMapper는 SQL 쿼리의 결과로 얻어진 ResultSet의 각 행을 객체로 변환하는 방법을 정의한다.
  • memberId는 SQL 쿼리에서 사용할 매개변수이다. 이 경우, memberId는 조회하고자 하는 멤버의 식별자로 사용된다.

 

private RowMapper<Member> memberRowMapper() {
    return (rs, rowNum) -> { //콜백패턴
        Member member = new Member();
        member.setMemberId(rs.getString("member_id"));
        member.setMoney(rs.getInt("money"));
        return member;
    };
}

위 코드는 데이터베이스로부터 Member 객체를 조회하기 위한 RowMapper<Member> 구현체를 제공하는 메서드이다. RowMapper 인터페이스는 JDBC ResultSet의 각 행을 객체로 매핑하는 역할을 한다. 이 코드는 콜백 패턴을 사용하여, SQL 쿼리의 결과로 얻어진 ResultSet에서 데이터를 읽어 Member 객체를 생성하고 반환하는 과정을 정의한다.

 

private RowMapper<Member> memberRowMapper() 메서드는 RowMapper<Member> 타입의 객체를 반환한다. 이 객체는 ResultSet에서 데이터를 읽어 Member 객체로 변환하는 방법을 정의한다.

  • (rs, rowNum) -> 람다 표현식을 사용하여 RowMapper의 mapRow 메서드를 구현한다. 여기서 rs는 쿼리 결과로 얻어진 ResultSet 객체이고, rowNum은 현재 행의 번호이다.
  • Member member = new Member(); 새로운 Member 객체를 생성한다.
  • member.setMemberId(rs.getString("member_id")); ResultSet에서 "member_id" 컬럼의 값을 읽어 Member 객체의 memberId 필드에 설정한다.
  • member.setMoney(rs.getInt("money")); ResultSet에서 "money" 컬럼의 값을 읽어 Member 객체의 money 필드에 설정한다.
  • return member; 매핑된 Member 객체를 반환한다.

 

JdbcTemplate 은 JDBC로 개발할 때 발생하는 반복을 대부분 해결해준다.

그 뿐만 아니라 트랜잭션을 위한 커넥션 동기화는 물론이고, 예외 발생시 스프링 예외 변환기도 자동으로 실행해준다.