Spring Boot AOP는 반복되는 부가 로직을 비즈니스 코드에서 분리하기 위한 가장 강력한 도구입니다. 10년 가까이 Spring 기반 시스템을 운영하면서, 가장 큰 효과를 본 활용처는 API 입출력 공통 로깅과 외부 SSO 연동 시 계정 정보 주입이었습니다. 두 가지 모두 컨트롤러 코드를 건드리지 않고도 횡단 관심사(Cross-Cutting Concern)를 깔끔하게 해결할 수 있습니다.
이 글에서는 실무에서 바로 적용할 수 있는 형태로 어드바이스(Advice) 구조, 포인트컷(Pointcut) 표현식, 그리고 실제로 운영 환경에서 사용했던 코드 패턴을 정리합니다. 동작 흐름을 다이어그램으로 함께 설명하므로, AOP를 처음 도입하는 분도 이해할 수 있도록 구성했습니다.
Spring Boot AOP란 무엇인가
AOP(Aspect-Oriented Programming)는 객체지향 프로그래밍이 해결하지 못하는 횡단 관심사를 모듈화하기 위한 패러다임입니다. 로깅, 트랜잭션, 권한 검사, 캐싱, 성능 측정처럼 여러 클래스에 공통으로 등장하지만 비즈니스 로직과는 별개인 코드를 한 곳에 모아 관리할 수 있습니다.
Spring Boot에서는 spring-boot-starter-aop 의존성만 추가하면 별도 설정 없이 AspectJ 기반 어드바이스를 사용할 수 있습니다. 내부적으로는 CGLIB 또는 JDK Dynamic Proxy를 통해 동작하며, 빈(Bean)으로 등록된 대상 클래스에 한해 프록시 객체가 끼어들어 어드바이스를 실행합니다.
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
핵심 용어 정리
| 용어 | 설명 |
|---|---|
| Aspect | 횡단 관심사를 모듈화한 클래스 (@Aspect) |
| Join Point | 어드바이스가 적용될 수 있는 지점 (메서드 실행 시점) |
| Pointcut | 어떤 Join Point에 어드바이스를 적용할지 정의하는 표현식 |
| Advice | 실제로 수행되는 부가 로직 (@Before, @Around, @AfterReturning 등) |
| Weaving | 포인트컷에 맞는 지점에 어드바이스를 끼워 넣는 과정 |
실무 사례 1: API 입출력 공통 로깅
REST API를 운영하다 보면 “어떤 요청이 들어와서 어떤 응답이 나갔는지” 를 남기는 일은 모든 컨트롤러에서 반복됩니다. 처음에는 각 컨트롤러마다 log.info(...)를 찍지만, 메서드가 수십 개를 넘어가면 관리가 어려워지고 누락도 발생합니다. AOP를 사용하면 컨트롤러 한 줄도 건드리지 않고 모든 API 호출에 대해 동일한 포맷으로 로그를 남길 수 있습니다.
동작 흐름 다이어그램
다음은 클라이언트 요청이 컨트롤러에 도달하기까지 AOP가 어떻게 끼어드는지를 표현한 흐름입니다.
[Client]
│
▼
[DispatcherServlet]
│
▼
[Filter / Interceptor]
│
▼
[AOP Proxy] ──► @Around 시작 (요청 로깅: URI, Params, Body)
│
▼
[Controller 메서드 실행]
│
▼
[AOP Proxy] ──► @Around 종료 (응답 로깅: Status, ResponseBody, 소요시간)
│
▼
[HTTP Response]
이 구조의 장점은 컨트롤러 메서드가 정상 종료되든 예외가 발생하든 어드바이스에서 모두 잡아낼 수 있다는 점입니다. try-finally 블록 안에서 응답 데이터를 가공하기 때문에 누락 없이 모든 트래픽을 추적할 수 있습니다.
Aspect 구현 코드
아래는 운영 환경에서 사용 가능한 수준의 API 입출력 로깅 Aspect 예시입니다. @RestController가 붙은 모든 빈의 모든 메서드를 대상으로 동작합니다.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ApiLoggingAspect {
private final ObjectMapper objectMapper;
// @RestController가 붙은 모든 빈의 public 메서드를 대상으로 함
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
public void restControllerPointcut() {}
@Around("restControllerPointcut()")
public Object logApiInOut(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
String traceId = UUID.randomUUID().toString().substring(0, 8);
String httpMethod = request.getMethod();
String uri = request.getRequestURI();
String params = serialize(joinPoint.getArgs());
long start = System.currentTimeMillis();
log.info("[API-IN ] traceId={} {} {} params={}",
traceId, httpMethod, uri, params);
try {
Object result = joinPoint.proceed();
long elapsed = System.currentTimeMillis() - start;
log.info("[API-OUT] traceId={} {} {} elapsed={}ms response={}",
traceId, httpMethod, uri, elapsed, serialize(result));
return result;
} catch (Throwable ex) {
long elapsed = System.currentTimeMillis() - start;
log.error("[API-ERR] traceId={} {} {} elapsed={}ms message={}",
traceId, httpMethod, uri, elapsed, ex.getMessage(), ex);
throw ex;
}
}
private String serialize(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
return String.valueOf(obj);
}
}
}
운영에서 얻은 효과
실제 적용 후 얻은 효과는 다음과 같았습니다.
- 로그 포맷 일관성 확보: 모든 API가 동일한 traceId 기반으로 추적되어 장애 분석 시간이 크게 단축됩니다.
- 개발자 부담 감소: 신규 API를 추가할 때 로깅 코드를 작성하지 않아도 자동으로 포함됩니다.
- 민감정보 마스킹 일괄 처리:
serialize메서드에 마스킹 규칙을 한 번만 추가하면 전체 API에 즉시 반영됩니다.
다만 요청·응답 바디가 매우 큰 API(파일 업로드, 대용량 리스트 응답 등)는 별도 Pointcut으로 제외하거나 본문 길이를 잘라 출력하는 안전장치가 필요합니다.
실무 사례 2: SSO 토큰 기반 계정 정보 주입
외부 SSO(Single Sign-On)와 연동하는 시스템에서는 토큰의 페이로드에 사용자 ID 하나만 포함되어 있는 경우가 많습니다. 외부 SSO는 우리 시스템 내부 규칙(권한, 부서, 회사 코드 등)을 알지 못하므로 페이로드 확장을 요청할 수 없습니다. 그렇다고 매 컨트롤러마다 ID로 계정 정보를 조회하는 코드를 반복하는 것도 비효율입니다.
이때 컨트롤러 실행 직전에 AOP로 가로채어 계정 정보를 조회하고 컨텍스트에 주입하면, 컨트롤러는 ID 없이도 완성된 사용자 객체를 그대로 사용할 수 있습니다.
동작 흐름 다이어그램
[Request + Bearer Token]
│
▼
[JWT Filter] ──► 토큰 검증, userId 추출
│
▼
[AOP @Before] ──► userId로 계정 정보 조회
│ ├─ 캐시 조회 (Redis)
│ └─ 없으면 DB / 권한 API 호출
│
▼
[ThreadLocal에 UserContext 저장]
│
▼
[Controller] ──► UserContextHolder.get() 으로 즉시 사용
│
▼
[AOP @After] ──► ThreadLocal Clear (메모리 누수 방지)
핵심은 컨트롤러는 토큰의 존재조차 인지하지 않는다는 점입니다. 컨트롤러는 단지 UserContextHolder.get()을 통해 풍부한 사용자 정보(권한, 부서, 회사 코드 등)에 접근하면 됩니다.
Aspect 구현 코드
아래는 컨트롤러 메서드 진입 전에 Authorization 헤더를 분석하여 사용자 정보를 ThreadLocal에 주입하는 예시입니다.
// 1. 사용자 컨텍스트 보관소
public final class UserContextHolder {
private static final ThreadLocal<UserContext> HOLDER = new ThreadLocal<>();
public static void set(UserContext ctx) { HOLDER.set(ctx); }
public static UserContext get() { return HOLDER.get(); }
public static void clear() { HOLDER.remove(); }
}
// 2. 주입을 트리거할 마커 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireUserContext {}
// 3. 핵심 Aspect
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class UserContextAspect {
private final JwtTokenParser jwtTokenParser;
private final AccountService accountService;
@Around("@annotation(requireUserContext)")
public Object injectUserContext(ProceedingJoinPoint joinPoint,
RequireUserContext requireUserContext) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
if (bearer == null || !bearer.startsWith("Bearer ")) {
throw new UnauthorizedException("Authorization 헤더가 없습니다.");
}
String userId = jwtTokenParser.extractUserId(bearer.substring(7));
// 외부 SSO 토큰에는 ID만 있으므로, 시스템 내부 정보는 우리 DB/캐시에서 보강
UserContext context = accountService.loadUserContext(userId);
UserContextHolder.set(context);
try {
return joinPoint.proceed();
} finally {
// ThreadLocal 누수 방지 — 반드시 clear
UserContextHolder.clear();
}
}
}
// 4. 컨트롤러 사용 예시
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@RequireUserContext
@GetMapping
public List<OrderDto> getMyOrders() {
UserContext me = UserContextHolder.get();
// 토큰 파싱·DB 조회 코드 없음. 비즈니스 로직에만 집중
return orderService.findByCompanyAndUser(me.getCompanyCode(), me.getUserId());
}
}
왜 Interceptor가 아니라 AOP인가
비슷한 처리를 Spring Interceptor로도 구현할 수 있지만, AOP를 선택한 이유는 명확합니다.
- 메서드 단위 제어: 같은 컨트롤러 안에서도 토큰이 필요한 메서드와 그렇지 않은 메서드를 어노테이션으로 구분할 수 있습니다.
- 메서드 시그니처 활용: 컨트롤러 메서드의 파라미터, 반환값을 조작하거나 추가 검증이 가능합니다.
- 서비스 계층 재사용: Controller뿐 아니라 배치, 스케줄러 등 다른 진입점에도 동일한 Aspect를 일관되게 적용할 수 있습니다.
특히 외부 SSO처럼 우리가 페이로드를 통제할 수 없는 환경에서는, ID로부터 권한·조직 정보를 보강하는 작업을 한 곳에서 처리한다는 점이 운영 안정성에 큰 영향을 줍니다.
Pointcut 표현식 핵심 정리
실무에서 가장 자주 쓰는 Pointcut 표현식을 정리하면 다음과 같습니다.
| 표현식 | 의미 |
|---|---|
execution(public * com.example..*Controller.*(..)) | 특정 패키지의 컨트롤러 모든 public 메서드 |
within(@org.springframework.web.bind.annotation.RestController *) | @RestController가 붙은 클래스 전체 |
@annotation(com.example.RequireUserContext) | 특정 어노테이션이 붙은 메서드 |
@within(org.springframework.stereotype.Service) | @Service가 붙은 클래스 내 메서드 |
args(java.lang.Long, ..) | 첫 번째 파라미터 타입이 Long인 메서드 |
여러 표현식을 &&, ||, !로 조합할 수 있어, 운영 환경에서는 “컨트롤러이면서 특정 어노테이션이 없는 메서드” 와 같은 정밀한 타기팅이 가능합니다.
도입 시 자주 만나는 함정
AOP는 강력하지만, 잘못 사용하면 디버깅이 어려워지는 마법 코드가 됩니다. 10년간 만났던 대표적인 실수는 다음과 같습니다.
첫째, 같은 클래스 내부 메서드 호출은 프록시를 거치지 않습니다. Spring AOP는 프록시 기반이므로 this.someMethod()로 호출하면 어드바이스가 동작하지 않습니다. 외부에서 빈을 통해 호출해야 합니다.
둘째, private 메서드는 적용 대상이 아닙니다. Spring AOP는 public 메서드만 가로채므로 보안·로깅 어드바이스를 private에 걸어두면 누락됩니다.
셋째, ThreadLocal 사용 시 clear는 필수입니다. 톰캣 스레드는 풀로 재사용되므로 clear를 빠뜨리면 이전 요청의 컨텍스트가 다음 요청에 흘러 들어가 보안 사고로 이어집니다.
넷째, @Around 안의 proceed() 호출 누락은 컨트롤러 자체가 실행되지 않게 만듭니다. 코드 리뷰에서 반드시 확인해야 할 항목입니다.
자주 묻는 질문 (FAQ)
Q1. Spring AOP와 AspectJ의 차이는 무엇인가요?
Spring AOP는 런타임 프록시 기반으로 동작하여 Spring 빈으로 등록된 객체에만 적용됩니다. AspectJ는 컴파일 타임 또는 로드 타임 위빙(Weaving) 을 통해 모든 객체에 적용할 수 있어 더 강력하지만, 설정이 복잡합니다. 일반적인 웹 애플리케이션이라면 Spring AOP만으로 충분합니다.
Q2. AOP를 도입하면 성능 저하가 발생하나요?
프록시 호출이 추가되긴 하지만 일반적인 웹 요청 처리 비용에 비하면 무시할 수 있는 수준입니다. 다만 초당 수만 건 이상의 트랜잭션이 발생하는 핫스팟에서는 어드바이스 안의 로직(JSON 직렬화, DB 조회 등)을 최적화해야 합니다.
Q3. @Around와 @Before/@After는 언제 구분해서 쓰나요?
메서드 실행 전·후 단순한 부가 작업만 한다면 @Before, @After가 가독성이 좋습니다. 메서드 실행 시간 측정, 예외 가공, 반환값 변경처럼 메서드 실행 자체를 감싸야 한다면 @Around를 사용합니다.
Q4. AOP가 동작하지 않을 때 가장 먼저 확인할 것은 무엇인가요?
대상 클래스가 Spring 빈으로 등록되어 있는지, 호출이 다른 빈에서 들어오는지(자기 호출이 아닌지), 메서드가 public인지를 순서대로 확인합니다. 이 세 가지를 점검하면 대부분의 “AOP가 안 먹어요” 이슈가 해결됩니다.
마무리
Spring Boot AOP는 반복되는 부가 로직을 비즈니스 코드와 분리하기 위한 검증된 도구입니다. 이 글에서 소개한 두 가지 사례, API 입출력 공통 로깅과 SSO 토큰 기반 계정 정보 주입은 실무에서 즉시 효과를 볼 수 있는 패턴입니다. 컨트롤러 코드를 깨끗하게 유지하면서 횡단 관심사를 한 곳에서 통제할 수 있다는 점이 가장 큰 장점입니다.
도입할 때는 프록시 기반의 한계(자기 호출, public 메서드 제한)와 ThreadLocal 사용 시 clear 필수라는 점을 반드시 기억하시기 바랍니다. 작은 Aspect 하나로 시작해 점진적으로 확대 적용하는 방식을 권장합니다.
더 깊이 있는 학습이 필요하다면 Spring 공식 AOP 문서 — docs.spring.io를 함께 참고하시기 바랍니다.
핵심 요약
- API 입출력 로깅:
@Around+@RestController포인트컷으로 전 API에 traceId 기반 로그를 자동 적용합니다.- SSO 계정 주입: 외부 토큰에 ID만 있을 때, AOP가 Controller 진입 전에 계정 정보를 조회하여 ThreadLocal에 보강합니다.
- 함정 4가지: 자기 호출 미동작, private 메서드 제외, ThreadLocal clear 누락,
proceed()누락을 항상 점검합니다.