이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
@Before,@After,@AfterReturning,@AfterThrowing,@Around는 각각 어떤 책임을 가져야 하는가?- 왜 공식 문서는 요구사항을 만족하는 가장 약한 Advice를 쓰라고 하는가?
- 반환값 변경, 예외 처리, 실행 시간 측정, 인자 변경은 어떤 Advice에서 처리해야 하는가?
개요
Spring 공식 문서는 Advice를 특정 Join point에서 Aspect가 수행하는 동작으로 설명한다. Advice 종류에는 before, after returning, after throwing, after finally, around가 있다.
공식 문서의 중요한 권장사항은 요구사항을 만족하는 가장 약한 Advice를 사용하라는 것이다. @Around는 가장 강력하지만 proceed() 호출 누락, 반환값 처리 실수, 예외 흐름 왜곡 가능성이 있다. 단순 전처리에는 @Before, 정상 반환 관찰에는 @AfterReturning, 예외 관찰에는 @AfterThrowing을 쓰는 편이 안전하다.
Advice 선택 기준
Advice는 먼저 “언제 실행되는가”로 고른다.
| 필요 | 적합한 Advice |
|---|---|
| Target 실행 전에 검사하거나 로그를 남긴다 | @Before |
| 성공과 실패 모두에서 정리한다 | @After |
| 정상 반환 결과를 관찰한다 | @AfterReturning |
| 예외를 관찰하거나 실패 로그를 남긴다 | @AfterThrowing |
| 호출 여부, 인자, 반환값, 예외 흐름을 제어한다 | @Around |
선택 기준은 힘이 아니라 필요다. @Around로 모든 것을 할 수 있지만, 모든 것을 @Around로 하면 읽는 사람은 항상 Target이 실제로 호출되는지, 예외가 삼켜지는지, 반환값이 바뀌는지를 의심해야 한다.
@Before
@Before는 Target 메서드 실행 전에 동작한다. 사전 조건 검사, 입력값 검증, 호출 로그, MDC 설정 같은 전처리에 적합하다. 인증/인가 전반은 Spring Security 같은 표준 보안 인프라를 우선 검토하고, AOP는 메서드 경계의 보조 정책으로 제한하는 편이 안전하다.
@Aspect
@Component
public class PermissionAspect {
@Before("@annotation(requiresPermission)")
public void checkPermission(RequiresPermission requiresPermission) {
if (!SecurityContext.has(requiresPermission.value())) {
throw new AccessDeniedException("permission denied");
}
}
}@Before는 Target 실행을 직접 제어하지 않는다. 다만 예외를 던지면 Target 실행은 진행되지 않는다. 이 성질 때문에 검증과 차단에는 사용할 수 있지만, 반환값을 바꾸는 용도로는 맞지 않는다.
@After
@After는 Target 메서드가 정상 반환하든 예외로 종료하든 실행된다. Java의 finally와 비슷한 위치로 생각하면 된다.
@After("execution(* com.example..service..*(..))")
public void clearContext() {
RequestContextHolder.resetRequestAttributes();
}@After는 성공 결과나 예외 객체 자체를 정교하게 다루는 용도보다는 정리 작업에 적합하다. 성공 결과가 필요하면 @AfterReturning, 예외 객체가 필요하면 @AfterThrowing을 고려한다.
@AfterReturning
@AfterReturning은 Target 메서드가 정상적으로 반환된 뒤 실행된다. 반환값을 바인딩해서 관찰할 수 있다.
@AfterReturning(
pointcut = "@annotation(com.example.audit.Auditable)",
returning = "result"
)
public void auditSuccess(JoinPoint joinPoint, Object result) {
log.info("success method={}, result={}", joinPoint.getSignature(), result);
}중요한 함정은 반환값을 “받을 수 있다”와 “반환 객체 자체를 교체할 수 있다”를 구분하는 것이다. @AfterReturning에서 참조 객체의 내부 상태를 바꾸는 일은 기술적으로 가능할 수 있지만, 호출자에게는 변경 지점이 숨는다. 호출자에게 전달될 반환 객체를 완전히 다른 객체로 대체하는 책임은 @Around가 맡는 것이 맞다.
@AfterReturning은 캐시 갱신, 감사 로그, 결과 메트릭 기록처럼 성공 결과를 관찰하는 정책에 적합하다.
@AfterThrowing
@AfterThrowing은 Target 메서드가 예외를 던졌을 때 실행된다. 예외를 바인딩해 실패 로그, 알림, 장애 메트릭을 남길 수 있다.
@AfterThrowing(
pointcut = "execution(* com.example..service..*(..))",
throwing = "ex"
)
public void logFailure(JoinPoint joinPoint, RuntimeException ex) {
log.warn("failed method={}", joinPoint.getSignature(), ex);
}@AfterThrowing은 예외를 관찰하는 데 적합하다. 예외를 다른 예외로 감싸거나 복구 값을 반환하는 것처럼 흐름을 바꾸고 싶다면 @Around가 더 명확하다. 예외를 삼키는 정책은 호출자 계약을 바꾸므로 매우 제한적으로만 사용해야 한다.
@Around
@Around는 Target 호출 전체를 감싼다. ProceedingJoinPoint.proceed()를 호출해야 Target 메서드가 실제로 실행된다.
@Around("@annotation(com.example.monitor.Measured)")
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
return joinPoint.proceed();
} finally {
long elapsed = System.nanoTime() - start;
log.info("{} elapsed={}ns", joinPoint.getSignature().toShortString(), elapsed);
}
}@Around는 다음 일을 할 수 있다.
- Target 실행 전후에 상태를 공유한다.
- Target 호출을 생략하고 직접 반환한다.
proceed(args)로 인자를 바꿔 호출한다.- 반환값을 다른 값으로 대체한다.
- 예외를 잡아 다른 예외로 바꾸거나 다시 던진다.
강력하지만 위험도 크다. proceed()를 빼먹으면 Target이 실행되지 않는다. 예외를 무심코 삼키면 호출자는 실패를 모른다. 반환 타입이 맞지 않으면 런타임 오류가 난다. void 반환 타입으로 around advice를 선언하면 proceed() 결과가 호출자에게 전달되지 않는 문제도 생길 수 있다. 그래서 @Around는 “정말 호출 흐름 전체를 제어해야 하는가?”를 확인한 뒤 사용한다.
인자 변경
인자를 바꿔 Target을 호출해야 한다면 @Around가 필요하다.
@Around("@annotation(NormalizeName)")
public Object normalize(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
if (args.length > 0 && args[0] instanceof CreateMemberCommand command) {
args[0] = command.withName(command.name().trim());
}
return joinPoint.proceed(args);
}단, 인자 변경은 매우 신중해야 한다. 호출자가 넘긴 값과 Target이 받은 값이 달라지므로 디버깅 비용이 커진다. 가능하면 명시적인 서비스 코드나 DTO 변환 계층에서 처리하는 편이 더 읽기 쉽다.
반환값 대체
반환값을 완전히 바꾸려면 @Around가 적합하다.
@Around("@annotation(CacheableResult)")
public Object cache(ProceedingJoinPoint joinPoint) throws Throwable {
String key = keyGenerator.generate(joinPoint);
Object cached = cacheStore.get(key);
if (cached != null) {
return cached;
}
Object result = joinPoint.proceed();
cacheStore.put(key, result);
return result;
}이 코드는 캐시 히트 시 Target을 호출하지 않는다. 이런 흐름 제어는 @Before나 @AfterReturning의 책임이 아니다.
예외 변환
예외를 변환해야 한다면 @Around가 명확하다.
@Around("execution(* com.example..client..*(..))")
public Object translateClientException(ProceedingJoinPoint joinPoint) throws Throwable {
try {
return joinPoint.proceed();
} catch (ExternalClientException ex) {
throw new ExternalSystemUnavailableException("external system failed", ex);
}
}그러나 모든 예외를 공통 RuntimeException으로 감싸면 원인 분석이 어려워진다. 예외 변환은 경계가 명확한 외부 시스템, 인프라 계층, 어댑터 계층에서 제한적으로 적용하는 편이 좋다.
Advice 순서
여러 Advice가 한 Join point에 적용되면 순서가 중요할 수 있다. 예를 들어 보안 검사를 트랜잭션보다 먼저 할지, 트랜잭션 안에서 감사 로그를 남길지, 재시도가 트랜잭션 바깥에서 감싸야 하는지에 따라 결과가 달라진다.
순서를 명시해야 하는 경우 @Order를 사용할 수 있다.
@Aspect
@Order(1)
@Component
public class SecurityAspect {
}
@Aspect
@Order(2)
@Component
public class TransactionLikeAspect {
}순서가 많아질수록 문서화가 필요하다. Advice 순서가 시스템의 숨은 비즈니스 규칙이 되면 유지보수가 어려워진다.
실전 팁
- Advice 선택은
@Around에서 시작하지 말고@Before,@AfterReturning,@AfterThrowing,@After로 해결 가능한지 먼저 본다. @Around에서는try finally패턴을 습관화한다. 시간 측정, ThreadLocal 정리, MDC 정리처럼 종료 보장이 필요한 코드는 정상 반환과 예외 상황을 모두 고려해야 한다.@AfterReturning에서 반환 객체 내부를 바꾸는 코드는 팀 규칙으로 제한한다. 호출자 입장에서는 반환된 객체가 어디서 변경됐는지 추적하기 어렵다.@AfterThrowing에서 로그만 남기고 예외는 그대로 흘려보내는지, 예외를 변환하는지 정책을 분명히 한다.- Advice 메서드 이름은 시점보다 의도를 드러낸다.
before()보다checkPermission(),recordSuccessAudit(),measureElapsedTime()이 낫다.
확인 질문
확인 질문
- 단순 권한 검사를 Target 실행 전에 하고 싶다면 어떤 Advice가 적합한가?
@Before가 적합하며, 실패 시 예외를 던져 Target 실행을 막을 수 있다.- 정상 반환값을 읽어 감사 로그를 남기려면 어떤 Advice가 적합한가?
@AfterReturning이 적합하다.- Target 호출을 생략하고 캐시 값을 바로 반환하려면 어떤 Advice가 필요한가?
- 호출 여부와 반환값을 제어할 수 있는
@Around가 필요하다.- 공식 문서가 가장 약한 Advice를 권장하는 이유는 무엇인가?
- 더 구체적인 Advice가 프로그래밍 모델을 단순하게 만들고
proceed()누락 같은 오류 가능성을 줄이기 때문이다.