웹 개발›Spring MVC

검증과 쿠키·세션

요청과 응답 편에 이어, 요청 데이터 검증(Bean Validation)과 상태 유지(쿠키·세션)를 다룬다.


8. Bean Validation

요청 데이터의 유효성을 어노테이션으로 선언적으로 검증한다.

의존성 추가

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

검증 어노테이션

어노테이션

의미

사용 예시

@NotNull

null 불가

@NotNull Integer age;

@NotEmpty

null·"" 불가 (문자열·컬렉션)

@NotEmpty List<String> tags;

@NotBlank

null·""·공백 불가 (문자열)

@NotBlank String name;

@Size(min, max)

길이·크기 범위 (문자열·컬렉션)

@Size(min = 2, max = 20)

@Range(min, max)

숫자 범위 (Hibernate Validator)

@Range(min = 1, max = 120)

@Min / @Max

최소 / 최대 숫자

@Min(1) · @Max(100)

@Positive / @Negative

양수 / 음수

@Positive int price;

@Pattern(regexp)

정규식 일치

@Pattern(regexp = "\\d{3}-\\d{4}")

@Past / @Future

과거 / 미래 날짜

@Past LocalDate birth;

@Email

이메일 형식

@Email String email;

@Valid

중첩 객체까지 검증 실행

@Valid Address address;

@Data
public class MemberForm {

    @NotBlank(message = "이름은 필수입니다.")     // message 속성으로 메시지 직접 지정
    @Size(min = 2, max = 20)
    private String name;

    @NotNull
    @Range(min = 1, max = 120)
    private Integer age;

    @Email(message = "이메일 형식이 올바르지 않습니다.")
    private String email;

    @Pattern(regexp = "\\d{3}-\\d{4}-\\d{4}",
             message = "전화번호는 010-0000-0000 형식이어야 합니다.")
    private String phone;
}
@PostMapping("/members")
public String save(@Valid @ModelAttribute MemberForm form,
                   BindingResult bindingResult) {     // 반드시 검증 대상 바로 뒤에
    if (bindingResult.hasErrors()) {
        return "members/addForm";                     // 오류 시 폼으로 복귀
    }
    ...
    return "redirect:/members";
}

@Valid vs @Validated

검증을 트리거하는 어노테이션은 두 가지다. 둘 다 검증 대상 파라미터 앞에 붙여 검증을 실행시키지만, 출처와 기능이 다르다.

구분

@Valid

@Validated

패키지

jakarta.validation.Valid (자바 표준)

org.springframework.validation.annotation.Validated (스프링 전용)

검증 그룹(groups)

지원 안 함

지원 — 상황별로 검증 항목을 골라 적용

중첩 객체 캐스케이딩

함 (필드에 붙이면 내부 객체까지 검증)

안 함 (중첩 필드엔 @Valid가 필요)

적용 위치

파라미터·필드·메서드 반환값

파라미터 + 클래스 레벨(메서드 검증 활성화)

결론부터 말하면, 일반적인 컨트롤러 폼 검증은 @Valid로 충분하다. 다음 두 경우에만 @Validated가 필요하다.

경우 ① 검증 그룹으로 상황별 검증

같은 폼이라도 등록할 때와 수정할 때 규칙이 다를 수 있다. 예를 들어 id는 수정 시에만 필수다. 이때 그룹을 정의해 상황별로 다른 검증을 적용한다.

public interface SaveCheck {}      // 등록용 그룹 (마커 인터페이스)
public interface UpdateCheck {}    // 수정용 그룹

@Data
public class MemberForm {

    @NotNull(groups = UpdateCheck.class)              // 수정 시에만 id 필수
    private Long id;

    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})   // 둘 다 적용
    private String name;
}
// 등록: SaveCheck 그룹만 검증 → id는 검사하지 않음
@PostMapping("/members")
public String save(@Validated(SaveCheck.class) @ModelAttribute MemberForm form,
                   BindingResult bindingResult) { ... }

// 수정: UpdateCheck 그룹만 검증 → id 필수
@PostMapping("/members/{id}/edit")
public String edit(@Validated(UpdateCheck.class) @ModelAttribute MemberForm form,
                   BindingResult bindingResult) { ... }

@Valid에는 그룹을 지정하는 속성이 없어 이 작업을 할 수 없다.

경우 ② 컨트롤러가 아닌 빈의 메서드 파라미터 검증

클래스에 @Validated를 붙이면 스프링 AOP가 메서드 호출을 가로채, 파라미터에 붙은 제약 어노테이션을 검증한다. 서비스 계층에서 단일 값(String, int 등)까지 검증하고 싶을 때 쓴다.

@Service
@Validated   // 클래스 레벨 — 이 빈의 메서드 파라미터 검증을 활성화
public class MemberService {

    public void register(@NotBlank String name, @Min(1) int age) { ... }
    // 위반 시 ConstraintViolationException 발생 (BindingResult로는 못 받음)
}

주의할 점

  • 중첩 객체는 @Valid만 캐스케이딩한다. 컨트롤러 파라미터에 @Validated를 쓰더라도, 내부 객체까지 검증하려면 그 필드에는 @Valid를 붙여야 한다.

  • @Validated 메서드 검증(경우 ②)은 실패를 BindingResult가 아니라 ConstraintViolationException 으로 던진다. 컨트롤러의 @Valid + BindingResult 흐름과 처리 방식이 다르다.

  • 검증 그룹은 강력하지만 남용하면 폼 클래스가 복잡해진다. 등록/수정처럼 규칙이 명확히 갈릴 때만 도입하고, 차이가 크면 아예 DTO를 분리하는 편이 단순할 수 있다.

검증 순서

  1. @ModelAttribute 각 필드에 대한 타입 변환을 시도한다.

  2. 성공하면 다음으로, 실패하면 typeMismatch로 FieldError를 추가한다.

  3. Validator를 적용한다.

타임리프 스프링 검증 통합

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

  • #fields : BindingResult가 제공하는 검증 오류에 접근

  • th:errors : 해당 필드에 오류가 있을 때 메시지 출력

  • th:errorclass : th:field로 지정한 필드에 오류가 있으면 class를 추가

<input type="text" th:field="*{name}" th:errorclass="field-error">
<div class="field-error" th:errors="*{name}">이름 오류</div>

에러 메시지 작성

검증 실패 시 보여줄 메시지를 지정하는 방법은 두 가지다.

방법 ① 어노테이션 message 속성

가장 간단하다. 어노테이션에 message를 직접 적는다. {min}, {max}, {validatedValue} 같은 속성 이름 플레이스홀더도 쓸 수 있다.

@Size(min = 2, max = 20, message = "이름은 {min}~{max}자로 입력하세요.")
private String name;

다만 메시지가 코드 곳곳에 흩어져, 다국어 처리나 일괄 변경이 어렵다.

방법 ② messages.properties (messageSource) — 권장

메시지를 프로퍼티 파일 한 곳에 모아 관리한다. 스프링 부트는 src/main/resources/messages.properties를 기본 메시지 소스로 인식한다.

# {0} = 필드명, 그 뒤는 애노테이션 속성을 이름 알파벳순으로 채운다
NotBlank={0}은(는) 필수입니다.
Size={0}은(는) {2}~{1}자로 입력하세요.       # {1}=max, {2}=min
Range={0}은(는) {2}~{1} 사이여야 합니다.
Email={0} 형식이 올바르지 않습니다.

# 더 구체적인 키일수록 우선한다 (특정 객체·필드 전용 메시지)
NotBlank.memberForm.name=이름을 입력해 주세요.

스프링은 검증이 실패하면 다음 우선순위(구체적 → 일반) 로 메시지 키를 찾는다.

1) 코드.객체명.필드명   →  NotBlank.memberForm.name
2) 코드.필드명          →  NotBlank.name
3) 코드.필드타입        →  NotBlank.java.lang.String
4) 코드                 →  NotBlank

플레이스홀더 규칙이 방법마다 다르다

  • 어노테이션 message: {min}, {max} 처럼 속성 이름으로 쓴다.

  • messages.properties: {0}은 필드명, {1}·{2} …는 애노테이션 속성을 이름 알파벳순으로 채운다. 그래서 @Size·@Range는 {1}=max, {2}=min이 된다.

메시지 탐색 순서

최종적으로 오류 메시지는 다음 순서로 결정된다.

  1. messageSource(messages.properties)에서 위 우선순위로 찾기

  2. (없으면) 어노테이션의 message 속성

  3. (없으면) 라이브러리가 제공하는 기본 메시지


9. 쿠키와 세션

HTTP는 무상태(stateless) 프로토콜이라 요청 간에 상태를 기억하지 못한다. 로그인 상태를 유지하려면 쿠키·세션이 필요하다.

  • 쿠키: 서버가 응답에 Set-Cookie를 담아 보내면, 브라우저가 저장했다가 이후 요청마다 보낸다. 클라이언트에 저장되므로 보안에 취약하다.

  • 세션: 중요한 정보는 서버에 보관하고, 브라우저에는 추정 불가능한 세션 ID만 쿠키로 전달한다. 로그인 유지에 주로 사용한다.

쿠키 — 생성과 조회

응답에 쿠키를 실어 보낼 때는 Cookie 객체를 만들어 response.addCookie(...) 한다. 받을 때는 @CookieValue로 간편하게 꺼낸다.

// 쿠키 생성 — 응답에 실어 보냄 (Set-Cookie)
@PostMapping("/login")
public String login(@RequestParam Long memberId, HttpServletResponse response) {
    Cookie cookie = new Cookie("memberId", String.valueOf(memberId));
    cookie.setMaxAge(60 * 30);   // 유효 시간 30분 (초 단위). 0이면 즉시 삭제
    cookie.setHttpOnly(true);    // JS(document.cookie) 접근 차단 → XSS 완화
    cookie.setPath("/");         // 쿠키가 전송될 경로
    response.addCookie(cookie);
    return "redirect:/";
}

// 쿠키 조회 — @CookieValue (required=false 면 없을 때 null 허용)
@GetMapping("/")
public String home(@CookieValue(name = "memberId", required = false) Long memberId,
                   Model model) {
    if (memberId == null) {
        return "home";           // 비로그인 화면
    }
    model.addAttribute("member", memberService.findOne(memberId));
    return "loginHome";
}

알아두기 — XSS가 뭐길래 HttpOnly를 쓸까

XSS(Cross-Site Scripting) 는 공격자가 웹 페이지에 악성 자바스크립트를 심어 다른 사용자의 브라우저에서 실행되게 하는 공격이다. 예를 들어 게시판 댓글에 <script>...</script>를 넣었는데 서버가 이를 걸러내지 않고 그대로 화면에 출력하면, 그 글을 본 사람의 브라우저에서 스크립트가 돈다.

가장 흔한 피해가 쿠키 탈취다. 스크립트가 document.cookie로 세션 쿠키를 읽어 공격자 서버로 보내면, 공격자는 그 쿠키로 다른 사람 계정에 로그인한 척할 수 있다.

  • cookie.setHttpOnly(true) — 쿠키를 자바스크립트(document.cookie)가 못 읽게 막는다. XSS가 발생해도 세션 쿠키 탈취를 완화한다. (근본 차단이 아니라 피해를 줄이는 것)

  • cookie.setSecure(true) — HTTPS 연결에서만 쿠키를 전송해 평문 노출을 막는다.

  • 근본 대책은 출력 이스케이프다. 타임리프의 th:text는 기본적으로 HTML 특수문자를 이스케이프하므로 XSS에 비교적 안전하다. 반대로 th:utext는 태그를 그대로 렌더링하니 사용자 입력에는 쓰지 않는다. (자세한 내용은 Spring Security — 사전 지식에서 다룬다.)

세션 — 저장과 조회

HttpSession에 객체를 담아 두면 세션 ID 쿠키로 사용자를 식별한다. 컨트롤러 파라미터로 HttpSession을 바로 받을 수도 있다.

// 세션에 로그인 회원 저장
@PostMapping("/login")
public String login(@ModelAttribute Member member, HttpServletRequest request) {
    HttpSession session = request.getSession();   // 없으면 새로 생성
    session.setAttribute("loginMember", member);
    return "redirect:/";
}

// 세션에서 조회 — @SessionAttribute (이미 생성된 세션만 조회, 새로 만들지 않음)
@GetMapping("/")
public String home(@SessionAttribute(name = "loginMember", required = false) Member loginMember,
                   Model model) {
    if (loginMember == null) {
        return "home";
    }
    model.addAttribute("member", loginMember);
    return "loginHome";
}

// 로그아웃 — 세션 제거
@PostMapping("/logout")
public String logout(HttpServletRequest request) {
    HttpSession session = request.getSession(false);   // getSession(false): 없으면 null
    if (session != null) {
        session.invalidate();                          // null 체크 후 무효화 (NPE 방지)
    }
    return "redirect:/";
}

쿠키 vs 세션, 무엇을 쓸까

민감한 값(회원 ID 등)을 쿠키에 그대로 담으면 위조·탈취 위험이 있다. 로그인 유지처럼 중요한 상태는 세션에 저장하고, 클라이언트에는 세션 ID만 오가게 한다. 실제 로그인 보안은 Spring Security 장에서 표준화된 방식으로 다룬다.

마지막 수정 2026년 10월 22일