처음 보는 사람도 순서대로 읽으면 이해되도록 구성한 AOP 완전 가이드
AOP는 한꺼번에 다 이해하려 하면 머리가 터진다. 순서가 있다. 이 순서대로만 읽으면 된다.
@Before, @After, @AfterReturning, @AfterThrowing → "이런 것도 있구나" 정도로만 읽고 넘어가면 된다.
Pointcut 표현식, JoinPoint API → 실제로 코드 짜면서 필요할 때 찾아보면 된다.
지금 단계에서 이것까지 다 외우려 하면 아무것도 안 남는다.
AOP를 이해하는 첫 번째 단계. "반복 코드를 한 곳에 모은다"는 게 무슨 말인지 코드로 직접 눈으로 확인하자.
AOP는 여러 클래스에 나뉜 책임을 Aspect(관점)라고 부르는 별도의 클래스에 캡슐화하는 접근 방식이다. 그리고 여러 클래스에 걸쳐 있는 이 책임을 횡단 관심사(Cross-cutting concern)라고 부른다.
public void 주문받기() { // 😩 반복 코드 로그인확인(); long s = now(); System.out.println("주문!"); 시간기록(s); } public void 피자만들기() { // 😩 또 반복 로그인확인(); long s = now(); System.out.println("제조!"); 시간기록(s); } public void 배달하기() { // 😩 또또 반복 로그인확인(); long s = now(); System.out.println("배달!"); 시간기록(s); }
로그인확인(), 시간기록() 코드가 메서드마다 반복된다. 메서드가 100개면 100번 복붙해야 하고, 나중에 수정하면 100군데를 다 찾아서 바꿔야 한다.
public void 주문받기() { // ✨ 핵심 코드만! System.out.println("주문!"); } public void 피자만들기() { System.out.println("제조!"); } public void 배달하기() { System.out.println("배달!"); }
@Aspect @Component public class PizzaAOP { @Around("execution(* PizzaService.*(..))") public Object 자동처리( ProceedingJoinPoint jp) throws Throwable { 로그인확인(); long s = now(); Object r = jp.proceed(); 시간기록(s); return r; } }
로그인확인(), 시간기록() 코드를 딱 한 곳에만 작성했다. 수정할 때도 AOP 파일 한 곳만 바꾸면 전체에 적용된다.
AOP를 처음 보면 "내가 안 쓴 코드가 어떻게 자동으로 실행되지?" 하는 의문이 생긴다. 이 원리를 이해해야 AOP 전체가 납득된다.
코드 파일에서 @Around가 맨 뒤에 선언되어 있어도 상관없다. 스프링이 서버 시작할 때 클래스 전체를 먼저 다 읽어서 등록해두고, 실제 메서드가 호출되는 순간에 등록된 것들을 실행 시점 순서에 맞게 꺼내서 실행하기 때문이다. 선언 순서와 실행 순서는 별개다.
내가 부르는 PizzaService는 사실 진짜가 아니다. 스프링이 몰래 껍데기(Proxy)를 만들어서 준다. 마치 연예인(진짜 PizzaService) 앞에 매니저(Proxy)를 세워둔 것처럼, 모든 요청은 매니저가 먼저 받아 처리한 뒤 연예인에게 전달한다.
AOP에는 5가지 Advice(끼어드는 방법)가 있다. 그런데 이걸 한꺼번에 다 외우려 하면 안 된다. @Around 하나만 먼저 완벽히 이해하면 나머지 4개는 저절로 따라온다.
@Around("execution(* PizzaService.*(..))") public Object 전부처리(ProceedingJoinPoint jp) throws Throwable { System.out.println("Before 역할"); // ← jp.proceed() 위 = @Before try { Object r = jp.proceed(); // ← 원래 메서드 실행 System.out.println("AfterReturning 역할"); // ← 성공시만 return r; } catch (Exception e) { System.out.println("AfterThrowing 역할"); // ← 에러시만 throw e; } finally { System.out.println("After 역할"); // ← 항상 실행 } }
@Around만 써도 되는데 나머지 4개가 있는 이유는 코드를 더 간결하고 명확하게 만들기 위해서다. "이 메서드는 실행 전에만 끼어들면 돼" 라는 의도가 명확하면 @Before를 쓰는 게 읽기 좋다. @Around는 강력하지만 코드가 길어질 수 있다.
@Around에서 가장 중요한 한 줄. 이걸 정확히 이해해야 @Around를 제대로 쓸 수 있다.
AOP는 어떤 메서드에도 범용으로 끼어들어야 한다. 반환 타입이 void일 수도, String일 수도, User 객체일 수도 있다. 그래서 어떤 타입이든 받을 수 있는 Object를 쓴다.
void 주문받기() → r = null // 반환값 없으면 null String 메뉴가져오기() → r = "페퍼로니" // String이면 String User 회원조회() → r = User{name="라희"} // 객체면 객체 int 재고확인() → r = 42 // 기본형은 박싱돼서 담김
원래 메서드가 아예 실행이 안 된다. 주문받기()를 호출했는데 피자가 안 만들어지는 상황이 된다. @Around 쓸 때 jp.proceed() 호출은 거의 필수다.
@Around("execution(* PizzaService.*(..))") public Object 실수(ProceedingJoinPoint jp) throws Throwable { System.out.println("실행 전"); // ❌ jp.proceed()를 깜빡했다! return null; // 결과: 주문받기() 호출했는데 피자가 영원히 안 만들어짐 😱 }
조금 더 풀면 @Around가 끼어드는 순간, 스프링이 jp 안에 이런 정보들을 담아서 줘요.
jp.getSignature().getName()() → "주문받기" (메서드 이름) jp.getArgs()() → ["페퍼로니", 2] (파라미터) jp.getTarget()() → 진짜 PizzaService 객체 jp.proceed() → 진짜 메서드 실행!
그래서 jp를 받으면 "지금 어떤 메서드가 실행됐는지" 다 꺼낼 수 있고, proceed()로 그 메서드를 직접 실행도 할 수 있는 거예요.
@Around를 이해했다면 나머지는 쉽다. 이 4가지는 @Around의 특정 시점만 담당하는 간소화 버전이다. "이런 게 있구나" 정도로만 읽어도 충분하다.
@Before("execution(* PizzaService.*(..))") public void 실행전에(JoinPoint jp) { System.out.println("로그인 확인!"); }
finally와 같은 개념. @Around에서 finally 블록 안에 코드 쓰는 것과 같다.@After("execution(* PizzaService.*(..))") public void 실행후에(JoinPoint jp) { System.out.println("성공이든 실패든 실행!"); }
returning 속성으로 반환값도 받을 수 있다.@AfterReturning( pointcut="execution(* PizzaService.*(..))", returning="result") public void 성공했을때(Object result) { System.out.println("성공! 결과: "+result); }
throwing 속성으로 예외 객체도 받을 수 있다.@AfterThrowing( pointcut="execution(* PizzaService.*(..))", throwing="error") public void 에러났을때(Exception error) { System.out.println("에러: "+error.getMessage()); }
AOP 문서나 강의에서 갑자기 튀어나오는 낯선 단어들. 여기서 한 번에 정리하자.
@Aspect가 붙은 클래스. "어디에(Pointcut) 무엇을(Advice) 끼울지"를 한 곳에 담는 그릇.pizzaService.주문받기() 를 호출하면, 사실 진짜 PizzaService가 아니라 스프링이 몰래 만든 껍데기(Proxy)가 먼저 받는다. 마치 사장님한테 전화했는데 비서가 받은 것처럼 — 비서(Proxy)가 먼저 처리하고 사장님(진짜 PizzaService)한테 넘겨주는 것.@Aspect // ← "나 AOP야!" @Component // ← "스프링아 나 좀 써줘!" — 이 둘은 항상 같이 붙임 public class MyAOP { // 여기에 끼어드는 코드 작성 } // Spring Boot는 아래 생략 가능 (자동 활성화됨) @EnableAspectJAutoProxy @Configuration public class AppConfig { }
execution만 있는 게 아니다. 상황에 따라 골라 쓰는 3가지 주요 표현식. 처음엔 execution만 알아도 충분하다.
| 종류 | 의미 | 예시 | 언제 씀 |
|---|---|---|---|
execution |
메서드 직접 지정 |
"execution(* PizzaService.*(..))"
"execution(void PizzaService.주문받기())"
"execution(* com.myapp..*(..))"
|
가장 많이 씀. 클래스/메서드/패키지 단위로 세밀하게 지정 가능 |
@annotation |
애너테이션으로 지정 | "@annotation(시간측정필요)" |
원하는 메서드에만 골라서 적용. execution 다음으로 많이 씀 |
within |
클래스/패키지 전체 | "within(com.myapp.service.*)" |
service 패키지 전체에 한 번에 적용할 때 |
&& || ! |
표현식 조합 |
"execution(..) && @annotation(..)"
"execution(..) && !execution(..재고확인(..))"
|
AND/OR/NOT으로 조합해 정밀하게 제어 |
@Around("execution(* PizzaService.*(..))") ''' 여기서 따옴표 안에 있는 이게 PointCut이에요. ''' execution(* PizzaService.*(..)) ↑ ↑ 모든 반환타입 모든 메서드, 모든 파라미터 ''' 쉽게 보면 ''' PizzaService 안에 있는 모든 메서드에 끼어들어줘!
// ① 내 커스텀 애너테이션 만들기 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface 시간측정필요 {} // ② 적용하고 싶은 메서드에만 붙이기 public class PizzaService { @시간측정필요 // ← AOP 적용 O public void 주문받기() { ... } public void 재고확인() { ... } // ← AOP 적용 X } // ③ AOP에서 @annotation으로 연결 @Around("@annotation(시간측정필요)") public Object 시간측정(ProceedingJoinPoint jp) throws Throwable { // @시간측정필요가 붙은 메서드에만 자동 적용! }
같은 표현식을 여러 Advice에서 반복해서 쓸 때, @Pointcut으로 이름을 붙여두면 재사용할 수 있다.
@Aspect @Component public class PizzaAOP { // 이름 붙이기 — 빈 메서드, 이름 역할만 함 @Pointcut("execution(* PizzaService.*(..))") public void 피자서비스전체() {} // 재사용 ① @After("피자서비스전체()") public void 호출로그(JoinPoint jp) { ... } // 재사용 ② @Around("피자서비스전체()") public Object 시간측정(ProceedingJoinPoint jp) throws Throwable { ... } }
AOP 코드 안에서 "지금 어떤 메서드가 실행됐는지" 다양한 정보를 꺼낼 수 있다. 주로 로그를 찍을 때 사용한다.
@Before("execution(* PizzaService.*(..))") public void 로그(JoinPoint jp) { String 메서드 = jp.getSignature().getName(); System.out.println("실행된 메서드: " + 메서드); // → 실행된 메서드: 주문받기 Object[] 파라미터 = jp.getArgs(); System.out.println("파라미터: " + Arrays.toString(파라미터)); // → 파라미터: [페퍼로니, 2판] String 클래스 = jp.getTarget().getClass().getSimpleName(); System.out.println("클래스: " + 클래스); // → 클래스: PizzaService }
지금까지 배운 모든 것을 하나의 코드로 합친 것. 처음엔 이해 안 돼도 괜찮다. Part 1~8을 다 이해하고 나서 다시 보면 전부 읽힌다.
@Aspect @Component public class PizzaAOP { /* ─── @Pointcut: 이름 붙여서 재사용 ─── */ @Pointcut("execution(* PizzaService.*(..))") public void 피자서비스전체() {} /* ─── ① 항상 남기는 호출 로그 (@After + JoinPoint 활용) ─── */ @After("피자서비스전체()") public void 호출로그(JoinPoint jp) { String 메서드 = jp.getSignature().getName(); Object[] 파라미터 = jp.getArgs(); System.out.println(메서드 + " 호출됨 / 파라미터: " + Arrays.toString(파라미터)); } /* ─── ② 에러나면 슬랙 알림 (@AfterThrowing) ─── */ @AfterThrowing(pointcut="피자서비스전체()", throwing="e") public void 에러알림(Exception e) { System.out.println("🚨 에러! 슬랙 전송: " + e.getMessage()); } /* ─── ③ 시간 측정 (@Around + jp.proceed()) ─── */ @Around("피자서비스전체()") public Object 시간측정(ProceedingJoinPoint jp) throws Throwable { long start = System.currentTimeMillis(); Object r = jp.proceed(); // 원래 메서드 실행, 결과를 r에 저장 System.out.println("⏱ " + (System.currentTimeMillis() - start) + "ms"); return r; // 원래 반환값 그대로 돌려줌 } } /* 실행 결과: ⏱ 2ms ← @Around 주문받기 호출됨 / 파라미터: [] ← @After + JoinPoint ⏱ 5ms ← @Around 피자만들기 호출됨 / 파라미터: [] ← @After + JoinPoint 🚨 에러! 슬랙 전송: ... ← @AfterThrowing (에러시만) */
헷갈릴 때 바로 꺼내 쓰는 레퍼런스.
| 개념 / 애너테이션 | 역할 | 핵심 키워드 | 실무 빈도 |
|---|---|---|---|
| @Aspect | AOP 클래스 선언 | 이 클래스가 AOP야! 라고 스프링에 알림 | 필수 |
| @Component | 스프링 빈 등록 | @Aspect와 항상 함께 붙임 | 필수 |
| @Pointcut | 적용 위치 이름 짓기 | 재사용 가능한 표현식에 이름 붙이기 | 권장 |
| @Before | 실행 전 | 로그인 확인, 파라미터 검증 | 보통 |
| @After | 실행 후 항상 | 자원 정리, 공통 로그 (finally와 동일) | 보통 |
| @AfterReturning | 성공시만 | 성공 로그, 반환값 후처리 | 가끔 |
| @AfterThrowing | 예외시만 | 에러 알림, 슬랙/이메일 발송 | 가끔 |
| @Around | 앞뒤 전부 — 나머지 4개 대체 가능 | 시간 측정, 트랜잭션, 캐싱 | ⭐ 가장 많음 |
jp.proceed() | 원래 메서드 실행 | @Around에서 필수. 안 부르면 원래 메서드 실행 안 됨 | @Around마다 |
jp.getSignature() | 실행된 메서드 정보 | .getName()으로 메서드 이름 꺼냄 | 로그 찍을 때 |
jp.getArgs() | 파라미터 목록 | 배열로 반환, Arrays.toString()으로 출력 | 로그 찍을 때 |
execution | 메서드 직접 지정 | 클래스명, 메서드명, 패키지로 세밀 제어 | ⭐ 가장 많음 |
@annotation | 애너테이션으로 지정 | 원하는 메서드에만 골라서 적용 | 많음 |
within | 패키지/클래스 전체 | service 패키지 전체 등 범위 지정 | 가끔 |