[Spring Boot] 11. Spring Boot Project (Blog v2 - jpa)_12.Validation

김미숙's avatar
Jul 22, 2025
[Spring Boot] 11. Spring Boot Project (Blog v2 - jpa)_12.Validation

예시

RegexTest
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 = "$86..ssa"; // 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 = "SAss"; // String value = "ssar"; boolean result = Pattern.matches("^[^a-zA-Z]+$", value); System.out.println("테스트 : " + result); } @Test public void 영어와숫자만된다_test() throws Exception { String value = "ssar2@"; // 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); } // 소문자, 대문자, 숫자, 특수문자가 포함되어야 하고, 최소 2자부터 20자 사이여야 한다 @Test public void password_test() { String password = "2#Ed "; boolean result = Pattern.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()\\-_=+\\[\\]{}|;:'\",.<>?/`~\\\\])[a-zA-Z0-9!@#$%^&*()\\-_=+\\[\\]{}|;:'\",.<>?/`~\\\\]{2,20}$", password); System.out.println("테스트 : " + result); } @Test public void user_username_test() throws Exception { String username = ""; // 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...sd@co.kr"; // 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 = "0102227777"; boolean result = Pattern.matches("^[0-9]{3}[0-9]{4}[0-9]{4}$", tel); System.out.println("테스트 : " + result); } }
 
UserController
package shop.mtcoding.blog.user; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; import shop.mtcoding.blog._core.error.ex.Exception400; import shop.mtcoding.blog._core.util.Resp; import java.util.Map; import java.util.regex.Pattern; @RequiredArgsConstructor @Controller public class UserController { private final UserService userService; private final HttpSession session; @GetMapping("/join-form") public String joinForm() { return "user/join-form"; } @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"; } @GetMapping("/api/check-username-available/{username}") public @ResponseBody Resp<?> checkUsernameAvailable(@PathVariable("username") String username) { Map<String, Object> dto = userService.유저네임중복체크(username); return Resp.ok(dto); } @GetMapping("/login-form") public String loginForm() { return "user/login-form"; } @PostMapping("/login") public String login(UserRequest.LoginDTO loginDTO, HttpServletResponse response) { 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); // cookie 하루동안 유지 response.addCookie(cookie); } return "redirect:/"; } @GetMapping("/logout") public String logout() { session.invalidate(); return "redirect:/login-form"; } @GetMapping("/user/update-form") public String updateForm() { // ViewResolver -> prefix = /templates/ suffix = .mustache return "user/update-form"; } @PostMapping("/user/update") public String update(UserRequest.UpdateDTO updateDTO) { User sessionUser = (User) session.getAttribute("sessionUser"); User user = userService.회원정보수정(updateDTO, sessionUser.getId()); // session 동기화 -> 동기화 안해주면 바꾸기 전 정보를 보게 된다 session.setAttribute("sessionUser", user); return "redirect:/"; } }

GlobalValidationHandler

package shop.mtcoding.blog._core.error; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.springframework.stereotype.Component; import org.springframework.validation.Errors; import org.springframework.validation.FieldError; import shop.mtcoding.blog._core.error.ex.Exception400; import java.util.List; // Aspect, PointCut, Advice @Aspect // 관점관리 @Component public class GlobalValidationHandler { // 관심사를 분리시킴 -> AOP // @Before: PostMapping,PutMapping 이 들어있는 메서드를 실행하기 직전에 Advice 를 호출하라 @Before("@annotation(org.springframework.web.bind.annotation.PostMapping) || @annotation(org.springframework.web.bind.annotation.PutMapping)") // pointcut public void badRequestAdvice(JoinPoint jp) { // 대리인 / jp는 실제 실행될 메서드의 모든 것을 투영하고 있다(리플렉션) Object[] args = jp.getArgs(); // 메서드의 매개변수들 (매개변수의 갯수와 상관없이 배열로 return) for (Object arg : args) { // 매개변수의 갯수만큼 반복 (annotation은 매개변수의 갯수에 포함되지 않는다) // Errors 타입이 매개변수에 존재하고, if (arg instanceof Errors) { // instanceof -> 타입검증 (다형성을 만족한다) System.out.println("error 400 처리 필요함"); Errors errors = (Errors) arg; // 실제 에러가 존재한다면 // 공통모듈 -> 관심사 분리 if (errors.hasErrors()) { List<FieldError> fErrors = errors.getFieldErrors(); for (FieldError fieldError : fErrors) { throw new Exception400(fieldError.getField() + ":" + fieldError.getDefaultMessage()); } } } } } }

UserRequest

validation annotation
✅ 주요 Validation Annotation 목록
어노테이션
설명
@NotNull
값이 null이면 안 됨 (빈 문자열은 허용, "" 불가, " " 가능)
@Null
null 값만 허용 (특정 상황에서 사용, 예: 업데이트에서는 금지)
@NotEmpty
null 또는 빈 문자열/컬렉션이면 안 됨
@NotBlank
문자열이 null, 빈 문자열, 공백만 있는 경우 허용 안 됨
@Email
이메일 형식인지 확인
@Size(min=, max=)
문자열, 배열, 컬렉션 등의 크기 제한
@Min(value)
숫자 최소값 제한
@Max(value)
숫자 최대값 제한
@Positive / @PositiveOrZero
양수 / 0 포함 양수만 허용
@Negative / @NegativeOrZero
음수 / 0 포함 음수만 허용
@Pattern(regexp="...")
정규표현식으로 문자열 패턴 검증
@AssertTrue / @AssertFalse
해당 값이 true / false여야 함
@Digits(integer=, fraction=)
정수 자리수와 소수점 자리수 제한
package shop.mtcoding.blog.user; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Data; public class UserRequest { //insert 용도의 dto에는 toEntity 메서드를 만든다 @Data public static class JoinDTO { @Pattern(regexp = "^[a-zA-Z0-9]{2,20}$", message = "유저네임은 2-20자이며, 특수문자,한글이 포함될 수 없습니다") private String username; @Size(min = 4, max = 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) private String password; private String rememberMe; // check되면 on 안되면 null } @Data public static class UpdateDTO { private String password; private String email; } }

UserController

package shop.mtcoding.blog.user; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; import shop.mtcoding.blog._core.util.Resp; import java.util.Map; @RequiredArgsConstructor @Controller public class UserController { private final UserService userService; private final HttpSession session; @GetMapping("/join-form") public String joinForm() { return "user/join-form"; } @PostMapping("/join") public String join(@Valid UserRequest.JoinDTO joinDTO, Errors errors) { // @Valid 가 붙으면 invoke 하기 전에 리플렉션 타고 DTO 내부의 어노테이션을 확인해서 터지면 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"; } @GetMapping("/api/check-username-available/{username}") public @ResponseBody Resp<?> checkUsernameAvailable(@PathVariable("username") String username) { Map<String, Object> dto = userService.유저네임중복체크(username); return Resp.ok(dto); } @GetMapping("/login-form") public String loginForm() { return "user/login-form"; } @PostMapping("/login") public String login(@Valid UserRequest.LoginDTO loginDTO, Errors errors, HttpServletResponse response) { // 어노테이션 붙은 매개변수 옆에 Errors가 있어야 DTO에서 터지면 errors에 넣어줌. 그 사이에 다른 매개변수가 있으면 그 매개변수를 확인하기 때문에 자리 잘 확인하기 // 부가로직(유효성 검사) - 공통모듈 / 공통모듈화가 되어야지 함수화해서 재활용 가능 // 핵심로직 -> seesionUser ~ cookie 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); // cookie 하루동안 유지 response.addCookie(cookie); } return "redirect:/"; } @GetMapping("/logout") public String logout() { session.invalidate(); return "redirect:/login-form"; } @GetMapping("/user/update-form") public String updateForm() { // ViewResolver -> prefix = /templates/ suffix = .mustache return "user/update-form"; } @PostMapping("/user/update") public String update(@Valid UserRequest.UpdateDTO updateDTO, Errors errors) { User sessionUser = (User) session.getAttribute("sessionUser"); User user = userService.회원정보수정(updateDTO, sessionUser.getId()); // session 동기화 -> 동기화 안해주면 바꾸기 전 정보를 보게 된다 session.setAttribute("sessionUser", user); return "redirect:/"; } }
notion image
@Valid가 붙으면 invoke 하기 전에 리플렉션 타고 DTO 내부의 Annotation 을 확인해서 터지면 errors에 넣어준다
notion image
Annotation 붙은 매개변수 옆에 Errors가 있어야 DTO에서 터지면 errors에 넣어줌
그 사이에 다른 매개변수가 있으면 그 매개변수를 확인하기 때문에 자리 잘 확인하기
 

핵심로직

UserController - Join
userService.회원가입(joinDTO); return "redirect:/login-form";
UserController - Login
// 핵심로직 -> seesionUser ~ cookie 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); // cookie 하루동안 유지 response.addCookie(cookie); } return "redirect:/";
 

부가로직

  • 공통 모듈 - 분리하려면 공통 모듈이 되어야 한다
  • 공통 모듈화가 되어야지 함수화해서 재활용 가능
UserController - Join
// 부가로직 (유효성 검사) - if (errors.hasErrors()) { List<FieldError> fErrors = errors.getFieldErrors(); for (FieldError fieldError : fErrors) { throw new Exception400(fieldError.getField() + ":" + fieldError.getDefaultMessage()); } }
UserController - Login
// 부가로직 (유효성 검사) - 공통모듈 / 공통모듈화가 되어야지 함수화해서 재활용 가능 if (errors.hasErrors()) { List<FieldError> fErrors = errors.getFieldErrors(); for (FieldError fieldError : fErrors) { throw new Exception400(fieldError.getField() + ":" + fieldError.getDefaultMessage()); } }
 

AOP

프로그래밍 패러다임의 하나로, 관심사를 분리하여 코드의 모듈화를 개선하기 위한 기법
코드의 가독성과 유지 보수성을 높일 수 있다.
리플렉션을 통해 우리는 깃발(어노테이션)을 원하는 위치에 설정(Pointcut)하고, 그 깃발이 있는 코드가 실행될 때 수행할 공통기능(Advice)을 적용할 수 있다
핵심로직을 실행하기 전에 부가로직을 호출하고 접근 → 프록시패턴 (Proxy Pattern)
프록시패턴 (ex. 대리인)
DS → Proxy → Controller
Proxy에서 validation check
관점지향 프로그램 → 목적은 같으나 관점이 다르다 , 목적의 행위에 따라 관점이 달라질 수 있음, 관점 따라 다른 실행
핵심로직 - 옷을 입는다 / 부가로직 - 어떻게 옷을 입나
핵심로직과 부가로직을 분리시켜야 하는데, 분리시키려면 부가로직을 공통모듈로 만들어야한다 → 리플렉션으로 구현하면 부가로직을 공통 모듈로 만드는 게 가능
공통모듈화가 되면 Advice로 뺄 수 있다
 

<AOP의 핵심개념>

  • Aspect → 관점
  • Advice → 공통모듈이 들어가있는 행위
  • Pointcut → 어디에서 실행할 지 결정 (Advice를 어디다가 꽂을건지?)
 
GlobalValidationHandler
package shop.mtcoding.blog._core.error; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.springframework.stereotype.Component; @Component @Aspect public class GlobalValidationHandler { // 직전 @Before("@annotation(shop.mtcoding.blog._core.error.anno.MyBefore)") public void beforeAdvice(JoinPoint jp) { // 리플렉션된 메서드의 정보가 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) { //before String name = jp.getSignature().getName(); System.out.println("Around Advice 직전 : " + name); try { //after Object result = jp.proceed(); // 컨트롤러 함수가 호출됨. 타입을 알수없기 때문에 Object 타입 System.out.println("Around Advice 직후 : " + name); System.out.println("result 값 : " + result); return result; } catch (Throwable e) { throw new RuntimeException(e); } } }
  • Pointcut → @Before("@annotation(shop.mtcoding.blog._core.error.anno.MyBefore)")
  • Advice → method 내부
  • Aspect → 관점 분리 (GlobalValidationHandler -> 관점에 따라 다르게 행동할 Object)
 
 
4.3 정규표현식을 사용해서 어노테이션 찾고 값 주입하기
import java.util.stream.IntStream; @Aspect @Component public class LoginAdvice { @Around("execution(* shop.mtcoding.aopstudy.controller.*.*(..))") public Object loginUserAdviceAround(ProceedingJoinPoint jp) throws Throwable { MethodSignature signature = (MethodSignature) jp.getSignature(); Method method = signature.getMethod(); Object[] args = jp.getArgs(); Parameter[] parameters = method.getParameters(); int loginUserAopIndex = IntStream.range(0, parameters.length) .filter(i -> parameters[i].isAnnotationPresent(LoginUserAop.class)) .findFirst() .orElse(-1); if (loginUserAopIndex >= 0) { HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); HttpSession session = req.getSession(); User principal = (User) session.getAttribute("loginUser"); args[loginUserAopIndex] = principal; // 해당 메서드 실행 Object result = jp.proceed(args); // 실행 후 결과 값을 가지고, 로그를 남길 수 있음. (실재 로그는 log4j 사용해야함) System.out.println(result); return result; } return jp.proceed(); } }
AOP에서 Before, After, Around는 각각 다른 시점에서 AOP 어드바이스를 실행하는 방법입니다.
  1. @Before: 메서드 실행 전에 어드바이스를 실행합니다. 이 시점에서 JoinPoint 객체를 통해 메서드의 인자, 클래스, 메서드 이름 등의 정보를 가져올 수 있습니다. @Before 어노테이션을 사용하여 선언합니다.
  1. @After : 메서드 실행 후에 어드바이스를 실행합니다. 이 시점에서 JoinPoint 객체를 사용할 수 있으며, 메서드의 실행 결과나 예외 정보 등을 가져올 수 있습니다. @After 어노테이션을 사용하여 선언합니 다.
  1. @Around : 메서드 실행 전후 모두 어드바이스를 실행합니다. 이 시점에서 ProceedingJoinPoint 객체를 사용하여 메서드 실행을 수행하고, 실행 결과를 반환할 수 있습니다. @Around 어노테이션을 사용하여 선언하며, 메서드 실행 전후에 추가적인 로직을 수행할 수 있습니다.
즉, @Around 어노테이션을 사용하여 @Before 는 메서드 실행 전에 추가적인 로직을 수행하는 어드바이스이고, @After 는 메서드 실행 후에 추가적인 로직을 수행하는 어드바이스입니다. @Around 는 메서드 실행 전후에 추가적인 로직을 수행할 수 있는 가장 범용적인 어드바이스입니다. AOP에서 메서드의 파라미터 값을 검사하거나 변경하는 작업을 수행할 때는 @Before 어노테이션을 사용하 고, 메서드 실행 시에 새로운 값을 주입하거나, 결과를 가공하는 작업을 수행할 때는 @Around 어노테이션을 사용하는 것이 일반적입니다.
 
Share article

parangdajavous