
AOP란?
AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 공통 관심사(cross-cutting concerns)를 모듈화하여 핵심 비즈니스 로직과 분리하는 프로그래밍 패러다임입니다.
예를 들어, 로깅, 트랜잭션 처리, 보안 검사 같은 기능은 여러 비즈니스 로직에 공통적으로 들어가야 하지만, 이들을 매번 코드에 직접 작성하면 코드가 복잡하고 중복됩니다. AOP는 이런 공통 기능을 하나의 모듈(Aspect)로 분리하고, 원하는 지점(메서드 실행 전후 등)에 끼워 넣을 수 있게 해줍니다.
핵심 개념
- 핵심 관심사(Core Concern): 비즈니스 로직처럼 해당 객체나 클래스가 원래 수행해야 하는 본연의 기능.
- 공통 관심사(Cross-cutting Concern): 여러 객체나 계층에서 반복적으로 필요하지만, 핵심 로직과는 별개인 부가 기능(로깅, 보안 등).
스프링 AOP의 동작 원리
- 프록시 패턴 기반
스프링 AOP는 프록시 객체를 사용해 동작합니다.
- 적용 범위
스프링 AOP는 스프링 컨테이너에 등록된 Bean에만 적용되며, 메서드 실행 지점에만 Advice를 적용할 수 있습니다
주요 용어 및 핵심 개념
용어 | 설명 |
Aspect | 공통 관심사를 모듈화한 단위(예: 트랜잭션 관리, 로깅 등) |
Advice | 실제로 수행되는 부가 기능 코드(언제, 무엇을 할지 정의) |
Join Point | Advice가 적용될 수 있는 지점(스프링 AOP는 메서드 실행 지점)(메서드 실행 그순간) |
Pointcut | Advice를 적용할 Join Point를 선별하는 규칙(표현식)(어노테이션) |
Target | Advice를 적용받는 실제 객체(핵심 비즈니스 로직을 가진 객체) |
Weaving | Aspect와 Target을 결합하는 과정 |
Proxy | 실제 객체를 감싸 부가 기능을 제공하는 대리 객체(스프링 AOP는 프록시 기반) |
Advice의 종류
- Before Advice: 메서드 실행 전에 동작
- After Returning Advice: 메서드가 정상적으로 실행된 후 동작
- After Throwing Advice: 메서드에서 예외가 발생한 후 동작
- After(Finally) Advice: 메서드 실행이 끝난 후(성공/실패 상관없이) 동작
- Around Advice: 메서드 실행 전후 모두 동작(가장 강력, 직접 메서드 실행 제어 가능)
🧩 AOP의 주요 개념
개념 | 설명 |
Aspect | 공통 기능을 정의한 모듈 (ex: 로깅, 보안) |
Join Point | Advice가 적용될 수 있는 지점 (ex: 실제로 어떤 메서드가 실행되는 그 순간) |
Advice | 언제(언제 실행할지)와 무엇(무슨 작업을 할지)을 정의 (ex: Before, After, Around 등) |
Pointcut | Advice를 적용할 Join Point를 선택하는 표현식(어노테이션) |
Weaving | Advice를 실제 코드에 끼워 넣는 작업 (런타임에 이루어짐) |
✅ AOP를 사용하는 이유
- 관심사의 분리: 핵심 로직과 공통 기능을 분리해 코드가 깔끔해짐
- 재사용성 증가: 공통 로직을 여러 곳에 쉽게 적용 가능
- 유지보수 용이: 공통 기능을 한 곳에서 관리 가능
적용 방법
14. DTO 유효성 검사(AOP)
- 테스트 파일을 하나 만들어 각각의 테스트를 실행해 봐야 한다
- 정규표현식은 GPT가 잘 짜준다
- 유효성 검사는 Controller에서 처리한다
☕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 = "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";
// 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);
}
// 소문자, 대문자, 숫자, 특수문자가 포함되어야 하고 최소 6자부터 최대 20자 사이여야 한다
@Test
public void user_password_test() throws Exception {
String password = "Aa!3fffff";
// String password = "ssa^";
boolean result = Pattern.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()])[a-zA-Z\\d!@#$%^&*()]{6,20}$", password);
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 = "00s...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);
}
}
☕UserController
@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("패스워드는 6-20자이며, 특수문자,영어 대문자,소문자, 숫자가 포함되어야 하며, 공백이 있을 수 없습니다");
if (!r3) throw new Exception400("이메일 형식에 맞게 적어주세요");
userService.회원가입(joinDTO);
return "redirect:/login-form";
}
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-mustache'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
추가해야함
Controller에서 처리한 유효성 검사를 DTO에서 처리하는 방법
☕UserRequest
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
@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;
}
DTO에서 어노테이션으로 유효성 검사를 한다
☕UserController
@PostMapping("/join")
public String join(@Valid UserRequest.JoinDTO joinDTO, Errors errors) { // @Valid <- 어노테이션이 있어야 DTO 오브젝트 내부의 @검증 어노테이션이 실행됨, 검증 문제가 생기면 Errors 로 넘겨준다. 이제 DTO의 책임은 유효성 검사다. @Valid 어노테이션 바로 뒤에 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("패스워드는 6-20자이며, 특수문자,영어 대문자,소문자, 숫자가 포함되어야 하며, 공백이 있을 수 없습니다");
if (!r3) throw new Exception400("이메일 형식에 맞게 적어주세요");
*/
if (errors.hasErrors()) {
List<FieldError> fErrors = errors.getFieldErrors();
for (FieldError fError : fErrors) {
throw new Exception400(fError.getField() + ":" + fError.getDefaultMessage());
}
}
userService.회원가입(joinDTO);
return "redirect:/login-form";
}
@Valid UserRequest.JoinDTO joinDTO, Errors errors
처럼 붙여서 작성해야 함Proxy 패턴으로 유효성 검사를 처리하는 방법 + AOP(관점 지향 프로그래밍)

☕MyAfter
package shop.mtcoding.blog._core.error.anno;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAfter {
}
@Retention(RetentionPolicy.RUNTIME)
→ 어노테이션이 언제까지 살아있는지를 결정하는 설정이에요. 핵심은 "어노테이션을 JVM이 언제까지 유지할까?" 라는 질문에 대한 답
- 커스텀 어노테이션 생성
☕MyAround
package shop.mtcoding.blog._core.error.anno;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAfter {
}
커스텀 어노테이션 생성
☕MyBefore
package shop.mtcoding.blog._core.error.anno;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAfter {
}
커스텀 어노테이션 생성
☕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 // Ioc에 추가
@Aspect // 이 어노테이션이 붙어야 proxy로 동작함
public class GlobalValidationHandler {
@Before("@annotation(shop.mtcoding.blog._core.error.anno.MyBefore)") // 풀 패키지 이름을 넣어야 한다 어노테이션에 대한
// 인터셉터는 invoke 밖에 있어서 req, resp 에만 접근 가능하지만. 이 어노테이션은 Component이기 때문에 리플렉션이 작동함
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) {
String name = jp.getSignature().getName();
System.out.println("Around Advice 직전 : " + name);
try {
Object result = jp.proceed(); // 컨트롤러 함수가 호출됨(@Controller)
System.out.println("Around Advice 직후 : " + name);
System.out.println("result : " + result);
return result; // Dispatcher에게 전달
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
}
- 인터셉터와의 차이점
- Dispatcher 앞 : 인터셉터
- Dispatcher 뒤 : @Aspect
- 리플렉션 사용 유무 : 인터셉터 X
- 리플렉션 사용 유무 : @Aspect O
public void afterAdvice(JoinPoint jp) { String name = jp.getSignature().getName(); System.out.println("After Advice : " + name); }
advice → 부가 로직, 조언 or 충고 해줄깨- 공통 모듈이 들어있는 행위
JoinPoint
→ 리플렉션으로 분석한 내용이 jp안에 다 들어감. 예)join()
에 작성했다면join()
의 모든 정보가 들어감. 이름, 파라미터, 리턴타입 등등
@Before("@annotation(shop.mtcoding.blog._core.error.anno.MyBefore)")
pointcut → 어떤놈에 대해서 aop를 실행할지 선택. 이 어노테이션이 붙은 놈을 실행하겠다. 조건@annotation()
→ 이 어노테이션에는 실행시키고 싶은 어노테이션의 풀 패키지 이름을 넣어야 한다
@Aspect
→ 이 클래스가 관점을 가지고 어떻게 실행할지 정하는 클래스- Dispatcher ↔ Controller 사이에 존재
☕UserController
@MyAround
@GetMapping("/v2/around")
public @ResponseBody String around() {
return "around";
}
@MyBefore
@GetMapping("/join-form")
public String joinForm() {
System.out.println("join-form 호출됨");
return "user/join-form";
}
@MyAfter
@PostMapping("/join")
public String join(@Valid UserRequest.JoinDTO joinDTO, Errors errors) {
}
관점을 적용하고 싶은 메서드 위에 어노테이션을 붙여서 사용한다
유효성 검사 AOP로 처리
☕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
@Component
@Aspect // 관점 관리
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) { // jp -> 실행될 메서드의 모든 것을 투영하고 있다
Object[] args = jp.getArgs(); // 메서드의 매개변수들
for (Object arg : args) { // 매개변수 개수만큼 반복 (@어노테이션은 제외)
// Errors 타입이 매개변수에 존재하고
if (arg instanceof Errors) {
System.out.println("에러 400 처리 필요함");
Errors errors = (Errors) arg;
// 에러가 존재한다면
if (errors.hasErrors()) {
List<FieldError> fErrors = errors.getFieldErrors();
for (FieldError fError : fErrors) {
throw new Exception400(fError.getField() + ":" + fError.getDefaultMessage());
}
}
}
}
}
}
☕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;
@Controller
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final HttpSession session;
@PostMapping("/join")
public String join(@Valid UserRequest.JoinDTO joinDTO, Errors errors) { // @Valid <- 어노테이션이 있어야 DTO 오브젝트 내부의 @검증 어노테이션이 실행됨, 검증 문제가 생기면 Errors 로 넘겨준다. 이제 DTO의 책임은 유효성 검사다. @Valid 어노테이션 바로 뒤에 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("패스워드는 6-20자이며, 특수문자,영어 대문자,소문자, 숫자가 포함되어야 하며, 공백이 있을 수 없습니다");
if (!r3) throw new Exception400("이메일 형식에 맞게 적어주세요");
*/
userService.회원가입(joinDTO);
return "redirect:/login-form";
}
@PostMapping("/login")
public String login(@Valid UserRequest.LoginDTO loginDTO, Errors errors, HttpServletResponse response) {
User sessionUser = userService.로그인(loginDTO);
session.setAttribute("sessionUser", sessionUser);
if (loginDTO.getRememberMe() == null) {
Cookie cookie = new Cookie("username", null);
cookie.setMaxAge(0); // 브라우저가 MaxAge가 0인 쿠키는 자동 삭제함
response.addCookie(cookie);
} else {
Cookie cookie = new Cookie("username", loginDTO.getUsername());
cookie.setMaxAge(24 * 60 * 60 * 7); // MaxAge에 값을 넣으면 브라우저를 새로 켜도 유지됨
response.addCookie(cookie);
}
return "redirect:/";
}
@PostMapping("/user/update")
public String update(@Valid UserRequest.UpdateDTO updateDTO, Errors errors) {
User sessionUser = (User) session.getAttribute("sessionUser");
User userPS = userService.회원정보수정(updateDTO, sessionUser.getId());
// 세션 동기화
session.setAttribute("sessionUser", userPS);
return "redirect:/";
}
}
@Valid
를 검증하고 싶은 오브젝트에 붙이고 바로 뒤에 Errors
를 넣는다☕UserRequest
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;
// dto에 있는 데이터를 바로 Entity 객체로 변환 하는 메서드 insert에 필요함
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;
}
@Data
public static class UpdateDTO {
@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;
}
}
@Pattern
→ 정규표현식을 사용한 패턴 검증
@Size
→ 길이 검증
☕BoardRequest
package shop.mtcoding.blog.board;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import shop.mtcoding.blog.user.User;
public class 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;
}
}
@NotEmpty
→ null, “ “, “” 이 값들을 에러 처리한다Share article