[스프링부트] 25. 유효성 검사

문정준's avatar
Apr 15, 2025
[스프링부트] 25. 유효성 검사
 

1. 유효성 검사

  • 사용자에게 올바른 입력값을 받기 위한 사전 검사
  • Pattern.matches(regex, value) 사용
    • regex : 정규 표현식, 입력을 허용하는 규칙
    • value : 입력받을 값
 
  • 한글만 받고 싶을 때에 사용할 수 있는 Regular Expression Test
    • 자음, 모음, 한글을 전부 포함
    • ^ : 시작
    • $ : 끝
    • [ ] : 유효성 검사 패턴
      • - : 범위 (a-z, A-Z 등, a에서 z까지)
      • ^ : 제외 (!과 같은 의미, 대괄호 안에 있어야 작동)
    • { } : 글자 수 (정수 or 범위 (-))
    • + : 뒤에 추가 정규 표현식을 작성
@Test public void 한글만된다_test() { // 1. given String value = ""; // 2. when boolean result = Pattern.matches("^[ㄱ-ㅎㅏ-ㅣ가-힣]+$", value); // 3. eye System.out.println("Test : " + result); }
notion image
notion image
 
package shop.mtcoding.blog.temp; import org.junit.jupiter.api.Test; import java.util.regex.Pattern; // https://regex101.com public class RegexTest { @Test public void 한글만된다_test() { String value = "ㅏㅏㅑ"; boolean result = Pattern.matches("^[가-힣]+$", value); System.out.println("테스트 : " + result); } @Test public void 한글은안된다_test() throws Exception { String value = "saltedcaramel1234"; // String value = "$86..ssa"; boolean result = Pattern.matches("^[^가-힣ㄱ-ㅎㅏ-ㅣ]+$", value); System.out.println("테스트 : " + result); } @Test public void 영어만된다_test() throws Exception { String value = "ssar"; // String value = "ssar2"; boolean result = Pattern.matches("^[a-zA-Z]+$", value); System.out.println("테스트 : " + result); } @Test public void 영어는안된다_test() throws Exception { String value = "1한글$%^"; // String value = "ssar"; boolean result = Pattern.matches("^[^a-zA-Z]+$", value); System.out.println("테스트 : " + result); } @Test public void 영어와숫자만된다_test() throws Exception { String value = "ssar2+3"; // String value = "ssar2&"; // String value = "ssar한글"; boolean result = Pattern.matches("^[a-zA-Z0-9]+$", value); System.out.println("테스트 : " + result); } @Test public void 영어만되고_길이는최소2최대4이다_test() throws Exception { String value = "ssar"; // String value = "ssarm"; boolean result = Pattern.matches("^[a-zA-Z]{2,4}$", value); System.out.println("테스트 : " + result); } @Test public void user_username_test() throws Exception { String username = "ssar"; // String username = "ssa^"; boolean result = Pattern.matches("^[a-zA-Z0-9]{2,20}$", username); System.out.println("테스트 : " + result); } @Test public void user_email_test() throws Exception { String email = "s...s@fGf.ccm"; // String username = "@fGf.ccm"; // +를 *로 변경해보기 boolean result = Pattern.matches("^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$", email); System.out.println("테스트 : " + result); } @Test public void user_fullname_test() throws Exception { String fullname = "코스"; // String fullname = "코스ss1"; boolean result = Pattern.matches("^[a-zA-Z가-힣]{1,20}$", fullname); System.out.println("테스트 : " + result); } @Test public void account_gubun_test() throws Exception { String gubun = "TRANSFER"; // WITHDRAW(8), DEPOSIT(7), TRANSFER(8) boolean result = Pattern.matches("^(WITHDRAW|DEPOSIT|TRANSFER)$", gubun); System.out.println("테스트 : " + result); } @Test public void account_gubun_test2() throws Exception { String gubun = "TRANSFER"; // WITHDRAW(8), DEPOSIT(7), TRANSFER(8) boolean result = Pattern.matches("^(TRANSFER)$", gubun); System.out.println("테스트 : " + result); } @Test public void account_tel_test() throws Exception { String tel = "01022227777"; boolean result = Pattern.matches("^[0-9]{3}[0-9]{4}[0-9]{4}$", tel); System.out.println("테스트 : " + result); } }
 
💡

대괄호 ([ ]) 뒤에 바로 $를 붙이면 안될까?

  • 대괄호 뒤에 바로 $를 붙이면 문자 1개를 검사
    • A = true, Ab = false
  • +를 붙이면 이후에 이어지는 문자들도 추가로 검사
 

2. 모듈화

  • Controller에 부착하여 재사용하기 위해, 유효성 검사를 모듈화
    • DTO에서 값을 검사 : Controller의 책임 전가
    • Annotation 활용
 
  • 기존 코드 : Controller에서 입력받는 값에 따른 유효성 검사의 차이 - 입력값에 따라 달라짐
    • 모듈화 불가
@PostMapping("/join") public String join(UserRequest.JoinDTO joinDTO) { // 유효성 검사 boolean r1 = Pattern.matches("^[a-zA-Z0-9]{2,20}$", joinDTO.getUsername()); boolean r2 = Pattern.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()])[a-zA-Z\\d!@#$%^&*()]{6,20}$", joinDTO.getPassword()); boolean r3 = Pattern.matches("^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$", joinDTO.getEmail()); if (!r1) throw new Exception400("유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다"); if (!r2) throw new Exception400("패스워드는 4-20자이며, 특수문자,영어 대문자,소문자, 숫자가 포함되어야 하며, 공백이 있을 수 없습니다"); if (!r3) throw new Exception400("이메일 형식에 맞게 적어주세요"); userService.회원가입(joinDTO); return "redirect:/login-form"; }
 
  • 새 코드 : Annotation과 Errors를 활용하여 공통모듈을 생성 - 입력값이 달라도 코드 동일
    • 모듈화 가능
// @Valid : 매개변수 내 Validation (Pattern, Size 등) 검사 @PostMapping("/join") public String join(@Valid UserRequest.JoinDTO joinDTO, Errors errors) { /* // 유효성 검사 boolean r1 = Pattern.matches("^[a-zA-Z0-9]{2,20}$", joinDTO.getUsername()); boolean r2 = Pattern.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()])[a-zA-Z\\d!@#$%^&*()]{6,20}$", joinDTO.getPassword()); boolean r3 = Pattern.matches("^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$", joinDTO.getEmail()); if (!r1) throw new Exception400("유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다"); if (!r2) throw new Exception400("패스워드는 4-20자이며, 특수문자,영어 대문자,소문자, 숫자가 포함되어야 하며, 공백이 있을 수 없습니다"); if (!r3) throw new Exception400("이메일 형식에 맞게 적어주세요"); */ // pass if no error exists if (errors.hasErrors()) { // gather errors List<FieldError> fErrors = errors.getFieldErrors(); // alert users ( for (FieldError fieldError : fErrors) { throw new Exception400(fieldError.getField() + " : " + fieldError.getDefaultMessage()); } } userService.회원가입(joinDTO); return "redirect:/login-form"; }
 
notion image
 

3. Proxy Pattern

  • 공통 모듈을 프록시 패턴을 활용해서 분리
    • 코드의 재사용 용이
 
  • GlobalValidationHandler : 유효성 검사를 위한 Advice
    • Before, After, Around
    • Before : Target이 실행되기 전에 실행되는 함수
      • Target의 사전 동작을 관리 가능
    • After : Target이 실행되고 난 후 실행되는 함수
      • Target의 사후 동작을 관리 가능
    • Around : Target이 실행되기 전, 실행 후 둘 다 실행되는 함수
      • Target의 사전, 사후 동작을 전부 관리 가능
@Component @Aspect // Proxy public class GlobalValidationHandler { @Before("@annotation(shop.mtcoding.blog._core.error.anno.MyBefore)") public void beforeAdvice(JoinPoint jp) { String name = jp.getSignature().getName(); System.out.println("before advice : " + name); } @After("@annotation(shop.mtcoding.blog._core.error.anno.MyAfter)") public void afterAdvice(JoinPoint jp) { String name = jp.getSignature().getName(); System.out.println("after advice : " + name); } @Around("@annotation(shop.mtcoding.blog._core.error.anno.MyAround)") public Object aroundAdvice(ProceedingJoinPoint jp) { String name = jp.getSignature().getName(); System.out.println("around advice 직전 : " + name); try { Object result = jp.proceed(); // 컨트롤러 함수 호출 System.out.println("around advice 직후 : " + name); System.out.println("result : " + result); return result; } catch (Throwable e) { throw new RuntimeException(e); } } }
 
💡

@Around와 @Before, @After의 차이점

  • Before, After는 함수가 실행되기 전과 후를 나누어서 관리하므로, DS의 invoke 권한이 없음
    • 매개변수 또는 리턴 값을 분석할 수 있긴 하지만 invoke 타이밍을 조절할 수 없음
  • Around에서 사용하는 ProceedingJoinPoint는 invoke의 제어권을 가지고 있음
    • ProceedingJoinPoint.proceed()를 통해 invoke를 직접 수행 가능
    • 함수 전,후반의 매개변수, 리턴 값, invoke 타이밍을 전부 관리 가능
 
With ChatGPT : ✅ 보완된 요약: @Around, @Before, @After 차이점
🤖

@Around vs @Before / @After

  • @Before@After관심 대상 메서드의 실행 전후에만 개입할 수 있음
    • @Before: 메서드 실행 직전에 실행됨
    • @After: 메서드 실행 직후에 실행됨
    • 이들 어노테이션은 **핵심 로직(= invoke)**의 실행을 차단하거나 재정의할 수 없음
    • 단, JoinPoint를 통해 메서드 이름, 매개변수, 타겟 객체 정보 등은 읽을 수 있음
    • 예외 발생 여부와 무관하게 실행되는 특징이 있음 (@Afterfinally처럼 작동)
 
  • @Around핵심 메서드 실행 자체를 제어할 수 있는 유일한 어노테이션
    • ProceedingJoinPoint.proceed()를 호출해야 실제 메서드가 실행됨
    • 따라서, 실행 시점 지연, 실행 생략, 반복 실행, 예외 감싸기 등도 가능
    • @Before, @After, @AfterReturning, @AfterThrowing의 모든 기능을 포함 가능
    • 리턴값 변형이나 예외 핸들링을 포함한 전체 흐름 커스터마이징에 적합
 
  • 실무 팁
    • 단순히 로깅, 인증체크, 파라미터 확인 정도라면 @Before / @After로 충분
    • 트랜잭션 처리, 리턴 값 변조, 예외 래핑 등 전체 흐름이 필요한 경우@Around 사용
 
 

4. 적용

  • GlobalExceptionHandler의 Pointcut을 PostMapping, PutMapping에 적용
  • 매개 변수 내에 Errors를 찾음
  • 있으면, Errors 내에 항목이 있는지 검사 (Errors.hasErrors())
  • 있으면 유효성 검사
@Aspect @Component public class GlobalValidationHandler { // 관심사를 분리 // PostMapping 혹은 PutMapping이 붙어 있는 메서드를 실행하기 직전에 Advice 호출 @Before("@annotation(org.springframework.web.bind.annotation.PostMapping) || @annotation(org.springframework.web.bind.annotation.PutMapping)") public void badRequestAdvice(JoinPoint jp) { // 실행될 실제 메서드의 모든 것을 투영 (Reflect) Object[] args = jp.getArgs(); // 메서드의 매개변수들 (배열로 리턴) for (Object arg : args) { // 매개변수 갯수만큼 (Annotation 제외) // execute when method's args have Errors if (arg instanceof Errors) { System.out.println("Exception400 처리 필요"); Errors errors = (Errors) arg; // Downcasting // execute when Errors.size() > 0 -> errors exist if (errors.hasErrors()) { // gather errors List<FieldError> fErrors = errors.getFieldErrors(); // alert users ( for (FieldError fieldError : fErrors) { throw new Exception400(fieldError.getField() + " : " + fieldError.getDefaultMessage()); } } } } } }
 
  • Target : PostMapping + Body가 있는 메서드 (update, save)
  • BoardController
@PostMapping("/board/save") public String save(@Valid BoardRequest.SaveDTO saveDTO, Errors errors) { User sessionUser = (User) session.getAttribute("sessionUser"); boardService.글쓰기(saveDTO, sessionUser); return "redirect:/"; } @PostMapping("/board/{id}/update") public String update(@PathVariable("id") Integer id, @Valid BoardRequest.UpdateDTO updateDTO, Errors errors) { User sessionUser = (User) session.getAttribute("sessionUser"); boardService.글수정(updateDTO, sessionUser.getId(), id); return "redirect:/board/" + id; }
 
  • BoardRequest
@Data public static class SaveDTO { // title=제목1&content=내용1 -> isPublic은 null // title=제목1&content=내용1&isPublic -> isPublic은 공백 // title=제목1&content=내용1&isPublic= -> isPublic은 스페이스 @NotEmpty(message = "제목을 입력해주세요.") // null, 공백(""), 스페이스(" ") 안됨 private String title; @NotEmpty(message = "내용을 입력해주세요.") private String content; private String isPublic; public Board toEntity(User user) { return Board.builder() .title(title) .content(content) .isPublic(isPublic == null ? false : true) .user(user) // user객체 필요 .build(); } } @Data public static class UpdateDTO { @NotEmpty(message = "제목을 입력해주세요.") private String title; @NotEmpty(message = "내용을 입력해주세요.") private String content; private String isPublic; // "on" or null public Boolean isPublicChecked() { return "on".equals(isPublic); } }
 
  • UserController
@PostMapping("/user/update") public String update(@Valid UserRequest.UpdateDTO updateDTO, Errors errors) { User sessionUser = (User) session.getAttribute("sessionUser"); // update user_tb set password = ?, email = ? where id = ? User userPS = userService.회원정보수정(updateDTO, sessionUser.getId()); // 세션 동기화 session.setAttribute("sessionUser", userPS); return "redirect:/"; } @PostMapping("/join") public String join(@Valid UserRequest.JoinDTO joinDTO, Errors errors) { /* // 유효성 검사 boolean r1 = Pattern.matches("^[a-zA-Z0-9]{2,20}$", joinDTO.getUsername()); boolean r2 = Pattern.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()])[a-zA-Z\\d!@#$%^&*()]{6,20}$", joinDTO.getPassword()); boolean r3 = Pattern.matches("^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$", joinDTO.getEmail()); if (!r1) throw new Exception400("유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다"); if (!r2) throw new Exception400("패스워드는 4-20자이며, 특수문자,영어 대문자,소문자, 숫자가 포함되어야 하며, 공백이 있을 수 없습니다"); if (!r3) throw new Exception400("이메일 형식에 맞게 적어주세요"); */ userService.회원가입(joinDTO); return "redirect:/login-form"; } @PostMapping("/login") public String login(@Valid UserRequest.LoginDTO loginDTO, HttpServletResponse response, Errors errors) { User sessionUser = userService.로그인(loginDTO); session.setAttribute("sessionUser", sessionUser); if (loginDTO.getRememberMe() == null) { Cookie cookie = new Cookie("username", null); cookie.setMaxAge(0); // 즉시 만료 response.addCookie(cookie); } else { Cookie cookie = new Cookie("username", loginDTO.getUsername()); cookie.setMaxAge(60 * 60 * 24 * 7); response.addCookie(cookie); } return "redirect:/"; }
 
  • UserRequest
@Data public static class UpdateDTO { @Pattern(regexp = "^[a-zA-Z0-9]{2,20}$", message = "유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다") private String username; @Size(min = 4, max = 20, message = "크기가 4자에서 20자 사이여야 합니다.") private String password; @Pattern(regexp = "^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$", message = "이메일 형식으로 적어주세요") private String email; } // insert 용도의 dto에는 toEntity 메서드를 만든다. @Data public static class JoinDTO { // DTO 유효성 검사 annotation // 1. Pattern @Pattern(regexp = "^[a-zA-Z0-9]{2,20}$", message = "유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다") private String username; // 2. Size @Size(min = 4, max = 20, message = "크기가 4자에서 20자 사이여야 합니다.") private String password; @Pattern(regexp = "^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,3}$", message = "이메일 형식으로 적어주세요") private String email; public User toEntity() { return User.builder() .username(username) .password(password) .email(email) .build(); } } @Data public static class LoginDTO { @Pattern(regexp = "^[a-zA-Z0-9]{2,20}$", message = "유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다") private String username; @Size(min = 4, max = 20, message = "크기가 4자에서 20자 사이여야 합니다.") private String password; private String rememberMe; // check되면 on, 안되면 null }
 
  • LoveController
@PostMapping("/love") public Resp<?> saveLove(@Valid @RequestBody LoveRequest.SaveDTO reqDTO, Errors errors) { User sessionUser = (User) session.getAttribute("sessionUser"); LoveResponse.SaveDTO respDTO = loveService.좋아요(reqDTO, sessionUser.getId()); return Resp.ok(respDTO); }
 
  • LoveRequest : boardId는 Integer 타입 : NotBlank, NotEmpty 사용 불가 → NotNull
@Data public static class SaveDTO { @NotNull(message = "해당하는 글이 없습니다.") private Integer boardId; public Love toEntity(Integer sessionUserId) { return Love.builder() .board(Board.builder().id(boardId).build()) .user(User.builder().id(sessionUserId).build()) .build(); } }
 
  • ReplyController
@PostMapping("/reply/save") public String save(@Valid ReplyRequest.SaveDTO saveDTO, Errors errors) { User sessionUser = (User) session.getAttribute("sessionUser"); replyService.댓글저장(saveDTO, sessionUser); return "redirect:/board/" + saveDTO.getBoardId(); }
 
  • ReplyRequest
@Data public static class SaveDTO { @NotNull(message = "해당하는 글이 없습니다.") private Integer boardId; @NotEmpty(message = "내용을 입력해주세요.") private String content; public Reply toEntity(User sessionUser) { return Reply.builder() .content(content) .user(sessionUser) .board(Board.builder().id(boardId).build()) .build(); } }
Share article

sxias