12-3. (실습) 스프링 V3 (유효성검사 V2 - AOP로 자동화)

박은서's avatar
Feb 24, 2026
12-3. (실습) 스프링 V3 (유효성검사 V2 - AOP로 자동화)
💡
관심사 분리하려면 반드시 코드가 동일해야 함!

2. 유효성 검사 - AOP로 자동화

1) ValidationHandler.java

C:\workspace\spring_lab\boardv1\src\main\java\com\example\boardv1\_core\aop\ValidationHandler.java
package com.example.boardv1._core.aop; 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 com.example.boardv1._core.errors.ex.Exception400; @Aspect @Component public class ValidationHandler { // @Before : 컨트롤러 메서드 실행 전에 가로채기 @Before("@annotation(org.springframework.web.bind.annotation.PostMapping)") // Controller에 import된 PostMapping의 패키지명 복사해서 붙여넣기 public void validationCheck(JoinPoint jp) { // 메서드의 모든 파라미터를 순회 for (Object arg : jp.getArgs()) { // Errors 타입 파라미터를 찾으면 if (arg instanceof Errors errors) { // 에러가 있으면 Exception400 throw if (errors.hasErrors()) { throw new Exception400(errors.getAllErrors().get(0).getDefaultMessage()); } } } } }

2) BoardController.java

package com.example.boardv1.board; import java.util.List; 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 com.example.boardv1._core.errors.ex.Exception400; import com.example.boardv1._core.errors.ex.Exception401; import com.example.boardv1._core.errors.ex.Exception500; import com.example.boardv1.user.User; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor // final이 적혀있는 애로 생성자 만들어줌 @Controller // @Controller 적어야 리턴값이 파일이 됨 (외부진입점) public class BoardController { private final BoardService boardService; private final HttpSession session; // title : title=title7&content=content7 (x-www-form) @PostMapping("/boards/save") public String save(@Valid BoardRequest.SaveOrUpdateDTO reqDTO, Errors errors) { // new해서 넣어줌! 필드가 많을 때 상태만 추가하면 되기 때문에 매우 편함! 재사용 가능!(필드명 잘 적어야 함) // 유효성 검사 -> AOP가 자동 처리 // 인증(v) 권한(x) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증되지 않았습니다."); boardService.게시글쓰기(reqDTO.getTitle(), reqDTO.getContent(), sessionUser.getId()); return "redirect:/"; } // body : title=제목&content=내용 @PostMapping("/boards/{id}/update") public String update(@PathVariable("id") int id, @Valid BoardRequest.SaveOrUpdateDTO reqDTO, Errors errors) { // 유효성 검사 -> AOP가 자동 처리 // 인증(v) 권한(v) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증되지 않았습니다."); boardService.게시글수정(id, reqDTO.getTitle(), reqDTO.getContent(), sessionUser.getId()); return "redirect:/boards/" + id; } @GetMapping("/") public String index(HttpServletRequest req) { List<Board> list = boardService.게시글목록(); req.setAttribute("models", list); return "index"; } @GetMapping("/boards/save-form") public String saveForm() { // 인증(v) 권한(x) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증되지 않았습니다."); return "board/save-form"; } @GetMapping("/boards/{id}/update-form") public String updateForm(@PathVariable("id") int id, HttpServletRequest req) { // 인증(v) 권한(v) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증되지 않았습니다."); Board board = boardService.수정폼게시글정보(id, sessionUser.getId()); req.setAttribute("model", board); return "board/update-form"; } @GetMapping("/boards/{id}") public String detail(@PathVariable("id") int id, HttpServletRequest req) { User sessionUser = (User) session.getAttribute("sessionUser"); // Object타입으로 반환하기 때문에 다운캐스팅해야 함 Integer sessionUserId = sessionUser == null ? null : sessionUser.getId(); BoardResponse.DetailDTO dto = boardService.상세보기(id, sessionUserId); req.setAttribute("model", dto); return "board/detail"; // mustache 파일의 경로 } @PostMapping("/boards/{id}/delete") public String delete(@PathVariable("id") int id) { // 인증(v) 권한(v) // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new Exception401("인증되지 않았습니다."); try { boardService.게시글삭제(id, sessionUser.getId()); } catch (Exception e) { throw new Exception500("댓글이 있는 게시글을 삭제할 수 없습니다"); } return "redirect:/"; } @GetMapping("/api/boards/{id}") public @ResponseBody BoardResponse.DetailDTO apiDetail(@PathVariable("id") int id) { User sessionUser = (User) session.getAttribute("sessionUser"); Integer sessionUserId = sessionUser == null ? null : sessionUser.getId(); BoardResponse.DetailDTO dto = boardService.상세보기(id, sessionUserId); return dto; } }

3) UserController.java

package com.example.boardv1.user; import org.springframework.stereotype.Controller; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import com.example.boardv1._core.errors.ex.Exception400; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Controller public class UserController { private final UserService userService; private final HttpSession session; @GetMapping("/logout") public String logout(){ session.invalidate(); // sessionKey 삭제 return "redirect:/"; } // 조회인데, 예외로 post 요청 @PostMapping("/login") public String login(@Valid UserRequest.LoginDTO reqDTO, Errors errors, HttpServletResponse resp) { // 유효성 검사 -> AOP가 자동 처리 // HttpSession session = req.getSession(); User sessionUser = userService.로그인(reqDTO.getUsername(), reqDTO.getPassword()); session.setAttribute("sessionUser", sessionUser); // http Response header에 Set-Cookie: sessionKey 저장되서 응답됨. Cookie cookie = new Cookie("username", sessionUser.getUsername()); cookie.setHttpOnly(false); resp.addCookie(cookie); return "redirect:/"; } @PostMapping("/join") public String join(@Valid UserRequest.JoinDTO reqDTO, Errors errors){ // 유효성 검사 -> AOP가 자동 처리 userService.회원가입(reqDTO.getUsername(), reqDTO.getPassword(), reqDTO.getEmail()); return "redirect:/login-form"; } @GetMapping("/login-form") public String loginForm(){ return "user/login-form"; } @GetMapping("/join-form") public String joinForm(){ return "user/join-form"; } }

4) ReplyController.java

package com.example.boardv1.reply; import org.springframework.stereotype.Controller; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import com.example.boardv1._core.errors.ex.Exception400; import com.example.boardv1.user.User; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Controller public class ReplyController { private final ReplyService replyService; private final HttpSession session; @PostMapping("/replies/save") public String save(@Valid ReplyRequest.SaveDTO reqDTO, Errors errors){ // 유효성 검사 -> AOP가 자동 처리 // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); int sessionUserId = sessionUser.getId(); replyService.댓글등록(sessionUserId, reqDTO.getBoardId(), reqDTO.getComment()); return "redirect:/boards/" + reqDTO.getBoardId(); } // /replies/5/delete?boardId=2 @PostMapping("/replies/{id}/delete") public String delete(@PathVariable("id") int id, @RequestParam("boardId") int boardId){ // 인증 User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) throw new RuntimeException("인증되지 않았습니다."); replyService.댓글삭제(id, sessionUser.getId()); return "redirect:/boards/" + boardId; } }

💠 ValidationHandler 코드 이해하기

1️⃣ 클래스 레벨 어노테이션

@Aspect@ComponentpublicclassValidationHandler {

1) @Aspect

  • 이 클래스가 AOP의 관심사(Aspect) 임을 선언
  • 즉, 비즈니스 로직과 분리된 공통 관심사라는 의미
  • 여기서는 “유효성 검사 실패 처리”가 관심사
스프링에게 “이 클래스는 특정 시점에 끼어들어 동작할 코드다”라고 알려줌

2) @Component

  • Spring Bean으로 등록
  • AOP는 Spring Bean만 대상으로 동작 가능
➡️ 이 클래스가 컨테이너에 관리되어야 컨트롤러 메서드를 가로챌 수 있음

2️⃣ 메서드 레벨 어노테이션 (핵심)

@Before("@annotation(org.springframework.web.bind.annotation.PostMapping)")

1) @Before

  • 조인포인트(JoinPoint) 가 실행되기 직전에 실행
    • [AOP 코드 실행] ↓ 컨트롤러 메서드 실행
      ➡️ 여기서 조인포인트는 컨트롤러 메서드

2) 포인트컷 표현식

@annotation(org.springframework.web.bind.annotation.PostMapping)
  • 의미
    • @PostMapping이 붙은 메서드만 가로챈다
  • 결과적으로
    • @PostMapping("/join") public String join(@Valid UserReq.JoinDTO dto, Errors errors) { ... }
      이런 메서드 모두 자동 적용
  • 중요 포인트
    • 컨트롤러 코드에 ValidationHandler를 직접 호출하지 않음
    • AOP가 자동으로 끼어듦

3️⃣ 메서드 시그니처

public void validationCheck(JoinPoint jp)

1) JoinPoint

  • 가로챈 메서드 실행 정보
  • 포함 정보
    • 메서드 이름
    • 전달된 파라미터들
    • 실행 대상 객체 등
여기서는 파라미터 목록만 사용

4️⃣ 파라미터 검사 로직

for (Object arg : jp.getArgs()) {

1) jp.getArgs()

  • 컨트롤러 메서드에 전달된 모든 인자 배열
    • public String join(UserReq.JoinDTO dto, Errors errors)
      dto, errors 둘 다 순회됨

5️⃣ Errors 타입 찾기

if (arg instanceof Errors errors) {

1) Errors

  • Spring Validation 결과를 담는 객체
  • @Valid 또는 @Validated가 실패하면 여기에 에러가 저장됨
➡️ 이 코드는:
  • 파라미터 중 Errors 타입이 있으면
  • 그걸 errors 변수로 바로 캐스팅
(Java 16 패턴 매칭 문법 👍)

6️⃣ 유효성 검사 실패 여부 확인

if (errors.hasErrors()) {
  • Validation 에러가 하나라도 있으면 true
  • DTO에 정의한 제약 조건 위반 시 발생
    • @NotBlank private String username;

7️⃣ 예외 발생

throw new Exception400( errors.getAllErrors().get(0).getDefaultMessage() );

1) 동작 흐름

  1. 첫 번째 Validation 에러 메시지 추출
  1. Exception400 예외 발생
  1. 컨트롤러 메서드는 실행되지 않음
  1. 전역 예외 처리기(@ControllerAdvice)가 응답 생성

2) 결과

  • 컨트롤러 코드에서
    • if (errors.hasErrors()) { ... }
      매번 쓸 필요가 없어짐

8️⃣ 전체 실행 흐름 요약

POST 요청 ↓ @PostMapping 컨트롤러 메서드 감지 ↓ ValidationHandler.validationCheck() 실행 ↓ Errors 존재 & 에러 있음? ├─ YES → Exception400 발생 → 즉시 종료 └─ NO → 컨트롤러 메서드 정상 실행

9️⃣ 이 구조의 핵심 장점

1) 관심사 분리 (Separation of Concerns)

  • 컨트롤러: 요청 처리
  • AOP: 유효성 실패 처리

2) 중복 제거

  • 모든 POST 컨트롤러에서
    • if (errors.hasErrors())
      코드 제거

3) 유지보수 용이

  • Validation 정책 변경 시 AOP 하나만 수정
Share article