이번 프로젝트에서는 사용자가 입력한 데이터를 데이터베이스에 저장하기 전에 올바른 값인지 검증(Validation) 하는 방법을 학습하였다. 단순히 Controller에서 데이터를 받아 저장하는 것이 아니라, Bean Validation을 이용하여 입력값의 형식을 검사하고, 검증에 실패했을 경우 사용자에게 오류 메시지를 보여 주면서 다시 입력할 수 있도록 구현하였다.
또한 @Valid, @Validated, BindingResult, Validation Group, Thymeleaf의 오류 출력 기능을 함께 사용하면서 사용자가 잘못된 데이터를 입력했을 때 서버와 화면이 어떻게 동작하는지를 이해할 수 있었다.
실제 웹 서비스에서는 회원가입, 로그인, 상품 등록, 게시글 작성과 같이 거의 모든 입력 화면에서 Validation이 사용되므로 이번 프로젝트를 통해 Spring Validation의 전체 흐름을 익힐 수 있었다.
웹 애플리케이션에서는 사용자가 항상 올바른 값만 입력한다고 보장할 수 없다.
예를 들어 책 등록 화면이 있다고 가정해 보자.
사용자가 다음과 같이 입력할 수도 있다.
| 제목 | 저자 | 가격 |
|---|---|---|
| (공백) | 홍길동 | -10000 |
또는
| 제목 | 저자 | 가격 |
|---|---|---|
| 가나다라마바사아자차카타파하라마바사아자차카타파하 | 홍길동 | 999999999 |
이러한 값이 그대로 저장된다면
그래서 데이터를 저장하기 전에 반드시 검증을 수행해야 한다.
전체 흐름은 다음과 같다.
사용자 입력
↓
Controller
↓
Validation
↓
성공
↓
DB 저장
──────────────
실패
↓
오류 메시지 출력
↓
입력 화면 유지
이번 프로젝트에서는 이러한 과정을 Spring Validation이 자동으로 처리하도록 구현하였다.
데이터 검증은 DTO에서 Annotation을 이용하여 정의하였다.
@NotBlank
@Size(min = 1, max = 50, message = "{Size.bookForm.title2}")
private String title;
@NotNull
@PositiveOrZero
@Max(value = 1_000_000)
private Integer price;
Spring Validation은 DTO에 선언된 Annotation을 읽어서 자동으로 검증을 수행한다.
예를 들어
@NotBlank
private String title;
은
제목이
""
또는
" "
처럼 공백이면
검증에 실패한다.
문자열이
모두 허용하지 않는다.
예를 들어
| 입력값 | 결과 |
|---|---|
| Java | 성공 |
| "" | 실패 |
| " " | 실패 |
| null | 실패 |
회원가입의
등에 자주 사용된다.
@NotEmpty
private String author;
@NotEmpty는
빈 문자열은 허용하지 않지만
공백은 허용한다.
예를 들어
| 입력값 | 결과 |
|---|---|
| 홍길동 | 성공 |
| "" | 실패 |
| " " | 성공 |
따라서 일반적인 사용자 이름에는 @NotBlank가 더 적합한 경우가 많다.
@NotNull
private Integer price;
null 값만 허용하지 않는다.
예를 들어
| 입력값 | 결과 |
|---|---|
| 10000 | 성공 |
| 0 | 성공 |
| null | 실패 |
즉
숫자 자체는 허용하지만
값이 존재해야 한다.
@PositiveOrZero
0 이상만 허용한다.
예를 들어
| 가격 | 결과 |
|---|---|
| 10000 | 성공 |
| 0 | 성공 |
| -1000 | 실패 |
책 가격이 음수가 되는 상황을 방지할 수 있다.
@Max(1_000_000)
100만원 이하만 허용한다.
예를 들어
| 가격 | 결과 |
|---|---|
| 10000 | 성공 |
| 999999 | 성공 |
| 1500000 | 실패 |
비즈니스 규칙을 Annotation 하나로 표현할 수 있다는 점을 학습하였다.
Controller에서는
@PostMapping("/new")
public String createBook(
@Valid
@ModelAttribute("bookForm")
BookFormDTO bookFormDTO,
BindingResult bindingResult
)
사용자가 등록 버튼을 누르면
Spring은 먼저
Form 데이터
↓
BookFormDTO 생성
↓
@Valid
↓
Validation 수행
을 실행한다.
예를 들어
사용자가
제목
↓
(공백)
을 입력하면
@NotBlank가 실패한다.
그러면
@NotBlank 실패
↓
BindingResult 저장
↓
Controller 실행
이 된다.
즉
Controller가 직접
if(title == null)
처럼 검사하지 않아도
Spring Validation이 자동으로 검증을 수행한다.
Validation이 끝난 후
검증 결과는
BindingResult에 저장된다.
if(bindingResult.hasErrors()){
return "form";
}
BindingResult는
Validation 과정에서 발생한 오류들을 저장하는 객체이다.
예를 들어
사용자가
제목
↓
공백
을 입력하면
BindingResult 안에는
| Field | Error |
|---|---|
| title | 제목은 필수입니다 |
가 저장된다.
Controller에서는
bindingResult.hasErrors()
만 호출하면
오류가 있는지 쉽게 확인할 수 있다.
@Valid BookFormDTO dto,
BindingResult bindingResult
처럼
바로 뒤에 작성해야 한다.
만약
@Valid BookFormDTO dto,
Model model,
BindingResult bindingResult
처럼 작성하면
Spring은 Validation 결과를 BindingResult에 저장하지 못한다.
그래서 Validation 예외가 발생하여
400 오류가 발생할 수도 있다.
따라서 BindingResult는 검증 대상 객체 바로 다음에 위치해야 한다는 점을 이해하였다.
Validation 오류는
Thymeleaf에서 바로 출력할 수 있었다.
<input th:field="*{title}">
<p
th:if="${#fields.hasErrors('title')}"
th:errors="*{title}">
</p>
사용자가
제목
↓
공백
을 입력하면
Spring은 BindingResult에 오류를 저장한다.
Thymeleaf는
BindingResult
↓
#fields
↓
title Error
↓
th:errors
↓
화면 출력
순서로 오류를 출력한다.
예를 들어
화면에는
제목은 반드시 입력해야 합니다.
가 자동으로 출력된다.
즉
Controller가
model.addAttribute("error", ...)
를 작성하지 않아도
BindingResult와 Thymeleaf가 자동으로 연결되어 오류를 보여준다.
필드 하나가 아니라
객체 전체를 검사해야 하는 경우도 있다.
이번 프로젝트에서는
bindingResult.reject(
"author.noKimjava",
"김자바는 등록할 수 없습니다"
);
를 사용하였다.
예를 들어
제목
↓
김자바
또는
저자
↓
김자바
인 경우
필드 하나의 문제가 아니라
비즈니스 규칙 자체를 위반한 것이다.
그래서
BookFormDTO
↓
전체 검사
↓
Global Error
를 생성하였다.
Thymeleaf에서는
<div th:if="${#fields.hasGlobalErrors()}">
<p
th:each="err : ${#fields.globalErrors()}"
th:text="${err}">
</p>
</div>
를 통해
전역 오류를 화면 상단에 출력하였다.
프로젝트를 진행하면서 새롭게 학습한 기능 중 하나는 Validation Group이었다.
회원가입이나 상품 등록처럼 등록(Create) 과 수정(Update) 은 입력받아야 하는 값이 서로 다른 경우가 많다.
예를 들어 책 등록 화면에서는 판매 여부(isAvailable)를 입력받지 않아도 되지만, 수정 화면에서는 판매 여부를 변경할 수 있어야 한다.
이처럼 같은 DTO를 사용하면서도 상황에 따라 다른 검증 규칙을 적용하기 위해 Validation Group을 사용하였다.
public interface Update {
}
DTO에서는 Group을 지정하여 검증을 수행하였다.
@NotNull(groups = Update.class)
private Boolean isAvailable;
Validation Group은 검증을 여러 개의 그룹으로 나누는 기능이다.
예를 들어
등록 화면에서는
| 제목 | 저자 | 가격 | 판매여부 |
|---|---|---|---|
| 필수 | 필수 | 필수 | 입력 안 함 |
이지만
수정 화면에서는
| 제목 | 저자 | 가격 | 판매여부 |
|---|---|---|---|
| 필수 | 필수 | 필수 | 필수 |
가 될 수 있다.
이 경우 Validation Group이 없다면
등록 화면에서도 판매 여부를 입력해야 하는 문제가 발생한다.
Validation Group을 적용하면
등록 요청
↓
기본 Validation만 수행
──────────────
수정 요청
↓
Update Group Validation 수행
처럼 상황에 따라 필요한 검증만 실행된다.
이를 통해 하나의 DTO를 재사용하면서도 기능에 맞는 검증을 수행할 수 있다는 점을 학습하였다.
책을 등록한 후에는 바로 목록 화면으로 이동하였다.
이때 RedirectAttributes를 이용하여 메시지를 전달하였다.
redirectAttributes.addFlashAttribute(
"msg",
"책이 등록되었습니다."
);
return "redirect:/books";
사용자가 등록 버튼을 누르면
POST /books/new
요청이 발생한다.
만약 등록이 끝난 뒤
return "index";
를 반환한다면
브라우저는 여전히 POST 요청 상태를 유지한다.
이 상태에서 사용자가 새로고침(F5)을 누르면
POST
↓
새로고침
↓
POST 재실행
↓
데이터 중복 저장
문제가 발생한다.
이러한 문제를 해결하기 위해 PRG(Post-Redirect-Get) 패턴을 사용한다.
동작 과정은 다음과 같다.
사용자 등록
↓
POST /books/new
↓
DB 저장
↓
redirect:/books
↓
GET /books
↓
목록 화면
이렇게 하면 새로고침을 하더라도 GET 요청만 다시 실행되므로 중복 등록이 발생하지 않는다.
Redirect를 수행하면 일반적인 Model 데이터는 전달되지 않는다.
따라서
redirectAttributes.addFlashAttribute(
"msg",
"등록 완료"
);
를 사용한다.
Flash Attribute는
Redirect 이전
↓
Flash Map 저장
↓
Redirect
↓
한 번만 사용
↓
자동 삭제
순서로 동작한다.
목록 화면에서는
<section th:if="${msg != null}">
<p th:text="${msg}"></p>
</section>
을 통해 메시지를 출력하였다.
즉, "등록되었습니다."와 같은 알림을 한 번만 보여주고 자동으로 사라지는 기능을 구현할 수 있었다.
이번 프로젝트에서는 Spring Data JPA의 Query Method를 활용하여 검색 기능도 구현하였다.
Repository에는 다음과 같은 메서드가 정의되어 있었다.
findAllByTitleContaining(String keyword)
Spring Data JPA는 메서드 이름을 분석하여 SQL을 자동으로 생성한다.
예를 들어
findAllByTitleContaining("Java")
를 호출하면 내부적으로 다음과 같은 SQL과 유사한 쿼리가 실행된다.
SELECT *
FROM book
WHERE title LIKE '%Java%';
개발자가 직접 SQL을 작성하지 않아도 메서드 이름만으로 검색 기능을 구현할 수 있다는 점을 학습하였다.
목록 화면에서는 검색창을 제공하였다.
<form>
<label>
키워드 :
<input name="keyword">
</label>
<button>검색</button>
</form>
사용자가
Java
를 입력하면
브라우저
↓
GET /books?keyword=Java
↓
Controller
↓
Repository
↓
findAllByTitleContaining()
↓
DB 조회
↓
검색 결과 출력
순서로 동작한다.
이를 통해 검색 기능도 Spring MVC와 Repository가 자연스럽게 연결되어 동작한다는 점을 이해하였다.
이번 프로젝트에서는 등록과 수정 화면을 각각 만들지 않고 하나의 form.html을 재사용하였다.
th:action="@{${bookId == null ? '/books/new' : '/books/' + bookId}}"
또한 버튼의 글자도 변경하였다.
<button
th:text="${bookId == null ? '등록' : '수정'}">
</button>
등록 화면에서는
bookId = null
이므로
POST /books/new
으로 전송된다.
반대로 수정 화면에서는
bookId = 3
이라면
POST /books/3
으로 전송된다.
버튼 역시
등록
↓
bookId 없음
──────────────
수정
↓
bookId 존재
처럼 자동으로 변경된다.
하나의 HTML을 재사용함으로써 중복 코드를 줄이고 유지보수를 쉽게 하는 방법을 학습하였다.
이번 프로젝트에서는 Validation 오류 메시지를 코드에 직접 작성하지 않고 messages.properties 파일에서 관리하였다.
NotBlank.bookForm.title=책 제목을 꼭 입력해주세요
Size.bookForm.title=책 제목은 {2} 이상 {1} 이하로 입력해야합니다
Max.bookForm.price={0} 미만의 책 가격이어야 합니다
예를 들어 DTO에서
@NotBlank
private String title;
검증이 실패하면 Spring은
NotBlank.bookForm.title
이라는 키를 찾는다.
그리고
책 제목을 꼭 입력해주세요
를 읽어 사용자에게 출력한다.
또한
Size.bookForm.title2=책 제목은 {min} 이상 {max} 이하로 입력해야합니다
처럼 {min}, {max}와 같은 플레이스홀더를 사용할 수 있다.
예를 들어
@Size(min = 2, max = 30)
이라면 화면에는
책 제목은 2 이상 30 이하로 입력해야 합니다.
처럼 실제 값이 자동으로 치환되어 출력된다.
이러한 방식은 메시지를 한 곳에서 관리할 수 있기 때문에 유지보수가 쉽고, 국제화(i18n)와도 자연스럽게 연동할 수 있다는 장점이 있다.
| 학습 내용 | 세부 학습 내용 |
|---|---|
| Validation Group | 등록과 수정처럼 상황에 따라 서로 다른 검증 규칙을 적용하는 방법 |
| RedirectAttributes | Redirect 이후에도 사용자에게 한 번만 메시지를 전달하는 방법 |
| PRG 패턴 | POST 요청 이후 Redirect를 수행하여 중복 등록을 방지하는 방법 |
| Query Method | 메서드 이름만으로 검색 SQL을 자동 생성하는 방법 |
| Form 재사용 | 등록과 수정 화면을 하나의 HTML로 처리하여 중복을 줄이는 방법 |
| Validation Message | messages.properties를 이용하여 검증 메시지를 중앙에서 관리하는 방법 |
| Thymeleaf Validation | #fields.hasErrors(), th:errors를 이용하여 검증 오류를 화면에 출력하는 방법 |
이번 프로젝트를 통해 Validation은 단순히 입력값을 검사하는 기능이 아니라, 사용자가 올바른 데이터를 입력하도록 돕고 애플리케이션의 데이터 무결성을 보장하는 중요한 기능이라는 점을 이해할 수 있었다. 특히 @Valid와 BindingResult를 이용한 자동 검증, Validation Group을 통한 상황별 검증, RedirectAttributes와 PRG 패턴을 활용한 중복 요청 방지, 그리고 messages.properties를 이용한 검증 메시지 관리까지 구현하면서 실제 웹 애플리케이션에서 사용자 경험과 데이터의 신뢰성을 함께 고려하는 방법을 배울 수 있었다. 또한 하나의 form.html을 등록과 수정 화면에서 함께 사용하는 구조를 구현하며 중복 코드를 줄이고 유지보수성을 높이는 설계 방식도 함께 익힐 수 있었다.