[Spring] Spring AOP 이해하고 적용하기
AOP란?
Aspect-Oriented Programming (관점 지향 프로그래밍)
로깅, 트랜잭션, 보안 같은 공통 기능을 비즈니스 로직에서 분리하는 기법
AOP 없이 구현
1
2
3
4
5
6
7
8
9
10
public void placeBid(...) {
log.info("입찰 시작"); // 로깅
long start = System.currentTimeMillis(); // 성능측정
// 실제 비즈니스 로직
auction.placeBid(...);
long end = System.currentTimeMillis(); // 성능측정
log.info("입찰 완료, {}ms", end - start);
}
- 비즈니스 로직보다 부가 기능 코드가 더 많다.. 이런 코드가 모든 메서드에 반복된다면..?
끔찍
AOP 사용
1
2
3
4
5
@LogExecutionTime // <- Aspect가 자동으로 처리
public void placeBid(...) {
auction.placeBid(...); // 비즈니스 로직만!
}
- 코드가 훨씬 보기좋아진다.
AOP 핵심 용어
Aspect
공통 관심사를 담은 모듈(클래스 단위)로, 보통 @Aspect가 붙은 클래스를 의미
JoinPoint
부가 기능을 끼워 넣을 수 있는 실행 지점, Spring AOP에서는 메서드 실행 지점만 지원
PointCut
어떤 JoinPoint에 Aspect를 적용할지 선택하는 표현식 execution(* *.placeBid(…)) 이런 식으로 사용
Advice
실제로 실행되는 부가로직 으로 @Before, @After, @Around 등의 형태로 정의
로깅 Aspect 구현
DevBid 개인 경매 프로젝트 입찰 메서드에 로깅 적용
Aspect 클래스 생성
1
2
3
4
5
@Slf4j
@Component
@Aspect
public class AuctionLoggingAspect {
}
@Slf4j⇒ Lombok: log 변수 자동 생성@Component⇒ Spring Bean 으로 등록 (필수)@Aspect⇒ 이 클래스는 Aspect 라는걸 선언
PointCut 정의
1
2
@Pointcut("execution(* org.devbid.auction.application.AuctionFacade.placeBid(..))")
public void bidMethod() {}
- “어떤 메서드에 적용할까?” 를 정의 이 메서드는 이름표 역할이라 비어있음.
Before Advice 추가
1
2
3
4
5
6
7
@Before("bidMethod()")
public void logBefore(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("입찰 메서드 호출: {} - 파라미터 {}", methodName, args);
}
@Before⇒PlaceBid실행 전에 이 메서드가 먼저 실행JoinPoint⇒ 실제 호출된 메서드 정보를 담은 객체getSignature().getName();⇒ 메서드 이름 가져오기Object[] args = joinPoint.getArgs();⇒ 파라미터를 배열로 받음
AfterReturning Advice 추가
입찰 완료 후에도 로그를 찍어보자
1
2
3
4
@AfterReturning(pointcut = "bidMethod()", returning = "result")
public void logAfter(JoinPoint joinPoint, Object result) {
log.info("입찰 완료! 반환값: {}", result.toString());
}
실행 결과
1
2
o.d.a.aspect.AuctionLoggingAspect - 입찰 메서드 호출: placeBid - 파라미터 [8, 10000013, 43200]
o.d.a.aspect.AuctionLoggingAspect - 입찰 완료! 반환값: org.devbid.auction.dto.BidPlacedEvent@216a2ff5
실행 흐름
1
2
3
4
5
6
7
8
9
10
11
입찰 요청
↓
Spring이 AuctionApplicationService를 호출
↓
[AOP 프록시가 가로챔!]
↓
logBefore() 먼저 실행 (로그 찍음)
↓
실제 placeBid() 실행
↓
입찰 완료
AOP의 동작 원리 - 프록시 패턴
Spring AOP는 프록시 패턴을 사용
1
2
3
4
5
6
7
8
원래 기대:
Controller → AuctionApplicationService.placeBid()
실제 동작:
Controller → [프록시 객체] → AuctionApplicationService.placeBid()
↓
Aspect 실행!
프록시 2가지 방식
JDK Dynamic Proxy
- 인터페이스가 있을 때 사용
- 인터페이스 기반으로 프록시 생성
CGLIB Proxy
- 클래스 상속으로 프록시 생성
- Spring Boot 기본값
CGLIB 프록시 확인해보기
디버깅으로 주입된 객체를 확인
원래 예상했던 클래스명 ⇒ org.devbid.auction.application.AuctionApplicationService
실제로 주입된 객체 ⇒ org.devbid.auction.application.AuctionApplicationService$SpringCGLIB$0
$SpringCGLIB$0 ⇒ Spring이 만든 프록시 객체
CGLIB 프록시의 동작원리
Spring이 런타임에 이런 클래스를 자동으로 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. Spring이 자동으로 생성하는 프록시 클래스 (개념)
class AuctionApplicationService$SpringCGLIB$0 extends AuctionApplicationService {
private AuctionApplicationService target; // 진짜 객체
private List<Aspect> aspects; // Aspect들
@Override
public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal amount) {
// 1. Before Advice 실행
aspects.forEach(aspect -> aspect.logBefore(...));
// 2. 진짜 메서드 호출
BidPlacedEvent result = target.placeBid(auctionId, bidderId, amount);
// 3. AfterReturning Advice 실행
aspects.forEach(aspect -> aspect.logAfterReturning(...));
return result;
}
}
- 상속으로 프록시를 만들어서 메서드를 오버라이드하는 방식
내부 호출 문제 - AOP가 동작 안 하는 경우
Controller에서 테스트용 엔드포인트 생성
1
2
3
4
5
@GetMapping("/debug/internal-call")
public String testInternalCall() {
auctionApplicationService.testInternalCall();
return "redirect:/";
}
AuctionApplicationService
1
2
3
4
public void testInternalCall() {
log.info("테스트: 내부에서 placeBid 호출");
this.placeBid(8L, 10000013L, BigDecimal.valueOf(50000));
}
AOP 로그가 찍힐까?
1
o.d.a.a.AuctionApplicationService - 테스트: 내부에서 placeBid 호출
안찍힌다. this.placeBid 의 this는 프록시가 아니라 실제 객체
1
2
3
4
5
6
7
8
9
10
11
12
13
외부에서 호출 (Controller → Service):
Controller
↓
[프록시 객체] ← AOP가 여기서 작동
↓
실제 AuctionApplicationService.placeBid()
내부에서 호출 (Service 내부):
testInternalCall()
↓
this.placeBid() ← this = 실제 객체
↓
프록시를 거치지 않음 AOP 작동 안 함
실제 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 프록시 객체 (Spring이 자동 생성)
class AuctionApplicationService$CGLIB extends AuctionApplicationService {
@Override
public void testInternalCall() {
log.info("테스트: 내부에서 placeBid 호출");
// 여기서 this.placeBid()를 호출하면?
this.placeBid(...);
// ↑ this = 진짜 객체
// 프록시의 placeBid()를 거치지 않음!
// → Aspect 실행 안 됨!
}
@Override
public BidPlacedEvent placeBid(...) {
// Aspect 실행 (Before)
aspect.logBefore();
// 실제 메서드
super.placeBid(...);
// Aspect 실행 (After)
aspect.logAfter();
}
}
해결방법 3가지
self-injection (가장 많이 씀)
1
2
3
4
5
6
7
8
9
@Service
public class AuctionApplicationService {
@Autowired
private AuctionApplicationService self; // 자기 자신 주입 (프록시!)
public void testInternalCall() {
self.placeBid(...); // 프록시를 통해 호출!
}
}
메서드 분리
1
2
3
4
5
// 내부 호출을 다른 Service로 분리
@Service
class AuctionBidService {
public void placeBid(...) { }
}
AopContext 사용
1
((AuctionApplicationService) AopContext.currentProxy()).placeBid(...);
-
같은 클래스 내부에서
@Transactional메서드를 호출하면?트랜잭션이 적용되지 않는다. 내부 메서드 호출 시 this는 프록시가 아닌 실제 객체를 가리키기 때문에 프록시를 거치지 않아 트랜잭션 AOP가 작동하지 않음!
@Around - 가장 강력한 Advice
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Before
public void logBefore() {
// 실행 전
// 메서드 실행은 Spring이 자동으로
}
@Around // ← 차이점
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
// 실행 전
Object result = joinPoint.proceed(); // ← 직접 실행해야 함
// 실행 후
return result;
}
proceed()를 직접 호출해야 실제 메서드 실행 이 덕분에 메서드 실행을 완전히 제어할 수 있다
성능측정 Aspect 만들기
입찰 메서드 실행 시간 측정하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
@Component
@Aspect
public class PerformanceAspect {
@Around("execution(* org.devbid.auction.application.AuctionApplicationService.*(..))")
public Object measurePerformance(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
if(elapsedTime > 1000) {
log.warn("느린 메서드 감지: {} ms", elapsedTime);
}
log.info("입찰 실행 시간: {} ms", elapsedTime);
return result;
}
}
1
2
실행결과 =>
o.d.auction.aspect.PerformanceAspect - 실행 시간: 91 ms
- 실행시간과 종료시간 사이에 실제 메서드 실행을 해 성능측정 후 반환
Around의 다양한 사용법
예외 처리 및 재시도
1
2
3
4
5
6
7
8
9
10
11
@Around("...")
public Object handleException(ProceedingJoinPoint joinPoint) throws Throwable {
try {
return joinPoint.proceed();
} catch (Exception e) {
log.error("에러 발생! {}", e.getMessage());
// 재시도 로직 추가 가능
throw e;
}
}
메서드 실행 차단
1
2
3
4
5
6
7
8
9
@Around("...")
public Object preventExecution(ProceedingJoinPoint joinPoint) throws Throwable {
if (someCondition) {
log.warn("실행 차단!");
return null; // proceed() 안 부르면 메서드 실행 안 됨
}
return joinPoint.proceed();
}
반환값 조작
1
2
3
4
5
6
7
8
@Around("...")
public Object modifyResult(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
// 결과를 조작해서 반환
return someModifiedResult;
}
Advice 비교 정리
| Advice | 실행 시점 | 메서드 제어 | 반환값 접근 | 예외 처리 |
|---|---|---|---|---|
| @Before | 실행 전 | ❌ | ❌ | ❌ |
| @AfterReturning | 성공 후 | ❌ | ✅ (읽기만) | ❌ |
| @AfterThrowing | 예외 발생 후 | ❌ | ❌ | ✅ |
| @Around | 전 + 후 | ✅ | ✅ (수정 가능) | ✅ |
참고
@Transactional은@Around방식으로 동작 메서드 실행 전에 트랜잭션을 시작 =>proceed()로 실제 메서드를 실행 => try-catch로 예외 시 롤백, 성공 시 커밋
정리
Spring AOP는 관점 지향 프로그래밍으로, 로깅이나 트랜잭션 같은 공통 관심사를 비즈니스 로직에서 분리하는 기법. CGLIB 프록시를 통해 구현되며, 런타임에 대상 클래스를 상속한 프록시 객체를 생성해 메서드 실행 전후에 부가 기능을 삽입
내부 호출 시 AOP가 동작하지 않는 문제는 실무에서도 자주 발생하는 이슈라 생각된다. DevBid 개인 프로젝트에서 낙관적 락을 적용하며 같은 문제를 겪었고, 메서드 분리로 해결했었다. 프록시 동작 방식을 제대로 이해하는 것이 중요하다 느꼈다..
