관심사 분리하려면 반드시 코드가 동일해야 함!
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) 동작 흐름
- 첫 번째 Validation 에러 메시지 추출
Exception400예외 발생
- 컨트롤러 메서드는 실행되지 않음
- 전역 예외 처리기(
@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