yesolje
Spring Validator를 이용한 전역 검증체계 구축 본문
배경
안녕하세요. 첫 티스토리 포스팅으로 저희 팀이 프로젝트를 진행하면서 겪었던 문제 사항 중 한가지와 이를 해결한 방법에 대해서 공유하려고 합니다.
우선 현재 저희 팀이 진행중인 CRM 프로젝트는 기존에 구축한 제품에서 발생하는 오류사항이나 문의사항에 대해 고객과 소통하며 문제를 해결하는 웹 서비스입니다. 고객은 사이트에 접속해 오류 제목, 내용, 희망 완료일자, 중요도를 직접 기입하는 방식으로 개발자들에게 요청을 등록할 수 있습니다.
또, 이 프로젝트에는 요청 등록 외에도 공지사항 게시판, 계약 내용 등록 등 고객사 및 개발자로부터 응답을 받아 처리를 해야 하는 로직이 존재합니다.
접속한 사용자가 응답 내용을 정확히 설계자의 의도대로 작성해준다면 제출된 값에 대한 검증도 필요치 않겠지만 입력 값의 타입, 길이 및 기타 비즈니스 로직 상의 이유로 인해 사용자에게 재작성을 요청해야 할 수도 있습니다. 이런 경우 프로젝트를 작업하는 개발자들은 각각에 상황에 맞는 검증 로직을 작성해야 합니다.
어떤 방식으로 검증 로직을 만들어야 여러명의 개발자들이 하나의 로직을 가져다 재사용할 수 있을지, 또 검증 이후 진행되는 비즈니스 로직과는 독립적일 수 있는지가 이번 작업의 포인트였습니다.

원인 분석
작성에 앞서 회사 내의 프로젝트를 분석했지만, 신규 프로젝트에서는 사용하기에 무리가 있다고 판단했습니다.
LoginApiController.java
//레거시 프로젝트 일부
public String XXXX(XXXX XXXX, HttpServletRequest request, HttpServletResponse response,
ModelMap model, SessionStatus status) throws Exception {
.....
if(sessionVO != null) {
.....
if (loginBlock) {
// 5번이상 비밀번호가 틀린 로그인 ID
JSONObject jsonObject = new JSONObject();
// 5번이상 비밀번호 불일치
jsonObject.put(XXXX.ERROR_CODE, XXXX.ERROR_CODE_WRONG_ID_OR_PWD);
jsonObject.put(XXXX.ERROR_MSG, "로그인 실패 5회 초과로 3분동안 로그인이 제한됩니다.");
resultJsonString = jsonObject.toString();
model.addAttribute("jsonObject", resultJsonString);
status.setComplete();
return "/data/jsonObject";
}
......
model.addAttribute("jsonObject", resultJsonString);
status.setComplete();
return "/data/jsonObject";
}
저희는 기존의 레거시 프로젝트 대로 유효성 검증을 수행할 시 세가지 문제점이 생길 것이라 판단했습니다.
첫번째로, 유효성을 검증하는 코드가 모듈마다 중복될 것이라는 점입니다. 그럴 경우, 오류 메시지를 변경해야 하는 등의 수정요청이 발생했을 시에는 모든 모듈을 돌면서 로직을 수정해야 하므로 유지보수가 어려워질 가능성이 있습니다.
두번째로, 검증 로직의 재사용성이 떨어질 것이라는 점이었습니다. 유효성 검증 코드가 주 기능을 수행하는 메소드 안에 종속되어 있기 때문에 작업자들마다 검증 로직을 다르게 구현할 것이고, 이는 시간적인 측면에서의 비효율성을 초래합니다.
마지막으로, 에러 핸들링의 방식의 일관성이 떨어질 것이라는 점입니다. 레거시 프로젝트는 에러 메시지의 처리를 json data 형식으로 돌려주어 클라이언트 단에 일임하고 있습니다. 이 과정에서 javascript 만으로 복잡한 에러 핸들링을 구현하는 것은 비효율적일 수 있습니다. 따라서 클라이언트 측에서는 단순히 메시지를 출력하는 데 집중하도록 개발 방향을 설정하여, 전체적인 에러 처리 방식의 일관성을 유지하고자 했습니다.
해결책 : Spring validation 의 도입
위에 서술했던 문제들을 해결하기 위해, 저희팀이 선택한 방법은 Spring validation 을 도입하는 것이었습니다.
Spring validation 은 클라이언트에서 서버로 값을 전송하고자 할 때, 전달되는 데이터에 대하여 유효성 검증을 수행하여 유효하지 않을 경우 에러를 발생하도록 처리하는 기능을 수행하는 라이브러리 입니다.
Spring validation 을 사용할 경우, 데이터 검증을 하는 로직의 흐름은 다음과 같이 진행됩니다.

설계 및 구현
저희팀이 검증 로직을 설계한 방법은 다음과 같습니다.
- HTTP 요청이 컨트롤러 메서드로 전달
- @ModelAttribute("systemBoard")에 의해 systemBoard라는 이름의 DTO 객체가 생성
- @InitBinder에 의해 systemBoard 객체에 대한 바인딩을 초기화하는 메서드가 호출
- init 메서드 내에서 systemBoardValidator가 WebDataBinder에 추가되어, systemBoard 객체 유효성 검사
- 유효성 검사 후, 만약 유효성에 오류가 있으면 BindingResult에 오류가 담겨서 컨트롤러 메서드로 반환
상세 구현은 다음과 같습니다.
SystemBoardController.java
@InitBinder("systemBoard")
public void init(WebDataBinder dataBinder) {
LOGGER.info("init binder {}", dataBinder);
dataBinder.addValidators(systemBoardValidator);
}
...
@PostMapping("/systemBoard")
public String postSystemBoard(@Validated @ModelAttribute("systemBoard") SystemBoardDto systemBoard,
BindingResult bindingResult,
HttpServletRequest request,
HttpServletResponse response,
Model model){
HttpSession session = request.getSession(false);
UserLoginDto loginUser = (UserLoginDto)session.getAttribute("loginUser");
//최초 인입된 dto 에 대해 validation 수행 후 반환
if (bindingResult.hasErrors()) {
LOGGER.info("validation error 발생={}",bindingResult);
List<CompanyOptionDto> companyOptions = commonService.getCompanyOption();
model.addAttribute("mode", "write");//글작성
model.addAttribute("companyOptions", companyOptions);
return "systemBoard";
}
@ModelAttribute: 요청에서 systemBoard라는 이름으로 전달된 데이터를 SystemBoardDto 객체로 바인딩합니다. 이 객체는 게시판 글을 작성할 때 사용되는 DTO(Data Transfer Object)입니다. DTO 에서는 유효성 검증을 조금 더 간편하게 하기 위해 객체 정의 시 @NotNull , @Size, @Email 과 같은 어노테이션을 붙여 사용할 수도 있지만, 저희 프로젝트에서는 데이터 형식이 완전히 정의되지는 않은 상태이기에 어노테이션을 통한 자동 검증이 아닌 customValidator 를 생성해 사용하였습니다.
@Validated: SystemBoardDto 객체에 정의된 유효성 검사를 수행합니다. 이를 통해 클라이언트가 보낸 데이터가 유효한지 확인하고, 검증 결과를 BindingResult에 저장합니다.
BindingResult BindingResult는 @Validated로 검증된 객체의 유효성 검사 결과를 담고 있습니다. 만약 유효성 검사를 통과하지 못한 필드가 있다면, 그에 대한 오류 정보를 포함하고 있습니다.
또, BindingResult는 Errors를 확장한 인터페이스로 컨트롤러에서 폼 데이터를 바인딩하고, 유효성 검사를 처리하는 데 사용됩니다.
@InitBinder initBinder는 폼 데이터를 객체로 변환할 때, 유효성 검사를 추가하거나 커스텀 바인딩 로직을 정의할 때 사용됩니다. 괄호에 모델을 명시적으로 지정하여(systemBoard) 이 모델에 대해서만 바인딩 작업을 수행합니다.
dataBinder.addValidators(systemBoardValidator); systemBoardValidator 에 유효성 검사를 위임합니다.
SystemBoardValidator.java
systemBoardValidator 는 validator 인터페이스의 구현체로, systemBoardDto 객체에서 수행할 상세 검증 내용을 담고 있습니다.
validate(Object target , Errors errors) 메서드는 실제 유효성 검사를 수행하는 메서드입니다. target 인자에는 SystemBoardDto 와 같은 유효성을 검사할 객체가 들어옵니다. Errors는 Spring의 유효성 검사와 바인딩 과정에서 발생하는 오류들을 저장하고 이를 검토하는 데 사용됩니다.
@Component
public class SystemBoardValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return SystemBoardDto.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
SystemBoardDto systemBoard = (SystemBoardDto) target;
//회사선택 없을 경우
if(systemBoard.getCompanyId() == null || systemBoard.getCompanyId().trim().isEmpty() || Integer.parseInt(systemBoard.getCompanyId()) == 0 ){
errors.rejectValue("companyId","error.required.systemBoard.companyId");
}
//제목 미기입시
ValidationUtils.rejectIfEmptyOrWhitespace(errors,"title","error.required.systemBoard.title");
//첨부파일 총 용량이 10MB 를 초과할 경우
final long MAX_TOTAL_FILE_SIZE = 10 * 1024 * 1024;
long totalFileSize = systemBoard.getAttachFiles().stream()
.filter(file -> !file.isEmpty()) // 빈 파일 제외
.mapToLong(MultipartFile::getSize) // 파일 크기 가져오기
.sum();
// 첨부파일 용량 초과 체크
if (totalFileSize > MAX_TOTAL_FILE_SIZE) {
errors.rejectValue("attachFiles", "error.fileMaxSize");
}
}
}
SystemBoardValidation 에서는 회사 선택이 공란일 경우, 제목을 미기입했을 경우, 첨부파일 용량이 10MB 를 초과하였을 경우 검증 실패를 띄우도록 설계하였습니다.
저희는 SystemBoardValidation, LoginValidation 과 같이 업무 모듈별로 다른 validation class 를 구현하여 각각의 Controller 에서 사용하였습니다. 이런 방법을 사용할 경우 각자의 업무 모듈별로 필요한 DTO 에 대해서만 검증을 수행할 수 있고, 같은 DTO 를 사용하더라도 다른 검증 로직을 구현할 수 있기 때문에 재사용성, 유지보수성이 좋습니다.
사용자의 응답이 검증 기준을 충족하지 못했다면, 어디에 어떤 메시지를 띄울 수 있을까요? 그 해답은 rejectValue( field , errorCode) 메소드에 있습니다.
field 는 DTO 의 각 객체값과 매핑됩니다. 조건을 충족하지 못한 제목, 또는 내용 과 같은 객체가 이에 해당됩니다.
errorCode 는 프로젝트에 선언한 messages.properties 에서 찾아오게 됩니다. 여기서 사용자는 띄워줄 에러 메시지의 깊이를 정할 수 있습니다.
Spring은 messages.properties 에 선언된 값에 따라 자동으로 에러 메시지를 매핑해주는데, 만약 error.required. systemboard.companyId 가 선언되어 있다면, 사용자는 errors.rejectValue("companyId","error.required"); 라고 작성해도 error.required.systemboard.companyId 의 메시지 값인 "회사는 공백일 수 없습니다" 를 리턴하게 됩니다.
#에러 메시지 Properties
#LEVEL 1
error.required = 이 값은 공백일 수 없습니다.
#LEVEL 2
error.required.user.userId = 아이디는 공백일 수 없습니다.
error.required.user.userPw = 비밀번호는 공백일 수 없습니다.
#사용법
#조금더 구체적인 것을 만들고 싶으면 아래와 같이 만들면 됨
#error.required.ITEM.ITEMNAME = 아이디 값은 공백일 수 없습니다.
#ITEM = Dto 로 오는 인스턴스. bindingResult 바로 앞에 오는 Dto 값
#ITEMNAME = Dto 안의 객체. 예를 들어서 UserLoginDto 안의 userId
#Controller 단에서 상세 에러코드가 있으면 그것을 우선적용하고, 없으면 범용코드를 사용
마지막으로, 해당 내용을 클라이언트로 전송하고, thymeleaf 를 이용해 error 메시지를 다음과 같이 띄워줍니다.
<tr>
<th>서버명</th>
<td colspan="3">
<input type="text" id="title" name="title" class="input--xs input01 input--full" th:errorclass="'input--error'" th:field="*{title}" th:readonly="${mode == 'read'}">
<span th:errors="*{title}" class="board_validation">error message area</span>
</td>
</tr>
마무리
현업에서 유지보수를 진행하면서 느낀 점은, 생각보다 다양한 부분에서 오류가 발생한다는 것입니다. 이런 오류를 예방하고 시스템의 안정성을 높이는 데 검증은 중요한 역할을 합니다. 저는 이번 작업을 통해 코드 깊숙한 곳에 숨어 있지만 필수적인 유효성 검증 로직을 체계적으로 구성하는데 집중하였고, 이를 통해 동료 작업자들의 수고를 덜어줄 수 있었습니다. 앞으로도 이런 작은 부분들이 프로젝트의 품질을 높이는 데 큰 차이를 만든다는 점을 기억하며, 독자분들도 기회가 되실 때 유효성 검증을 제대로 적용해 보셨으면 좋겠습니다😉
'기술' 카테고리의 다른 글
| Scapy를 활용한 실시간 TCP 패킷 수집과 Kafka‑DB 데이터 파이프라인 구축 (1) | 2025.07.24 |
|---|---|
| Spring의 기능 확장 - 상속과 템플릿 메소드 패턴 (0) | 2025.06.01 |
| 스프링 빈(Spring bean) 생명주기 (0) | 2025.05.08 |
| 캐시(Cache) 의 동작 원리 (0) | 2025.05.01 |
| 멀티모듈 구조로 Spring 프로젝트 리팩토링 (0) | 2025.04.16 |