티스토리 뷰
스프링 부트 AOP(Aspect-Oriented Programming) 완벽 가이드: 개념, 프록시 동작 원리 및 실무 예제
J-Mandu 2026. 7. 3. 10:15
1. AOP(관점 지향 프로그래밍)의 등장 배경과 필요성
자바(Java) 웹 백엔드 개발을 위해 스프링(Spring) 프레임워크를 학습하다 보면 DI(의존성 주입)와 함께 가장 중요하게 다루어지는 개념이 바로 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)입니다.
객체 지향 프로그래밍(OOP)은 애플리케이션을 여러 개의 독립적인 객체로 나누고, 이들의 상호작용으로 로직을 구성합니다. 덕분에 비즈니스 로직의 모듈화와 재사용성이 크게 향상되었습니다. 하지만 애플리케이션의 규모가 커지면서 OOP만으로는 해결하기 어려운 문제에 직면하게 됩니다.
바로 흩어진 관심사(Cross-cutting Concerns)의 문제입니다. 게시판 서비스를 예로 들어보겠습니다. '게시글 작성', '게시글 수정', '회원 가입'이라는 핵심 비즈니스 로직(Core Concerns)이 있습니다. 그런데 이 모든 로직 전후에 실행 시간을 측정하는 로깅(Logging) 처리, 데이터베이스 트랜잭션(Transaction) 처리, 보안 인가(Security) 과정이 공통으로 필요하다면 어떻게 될까요?
모든 클래스의 메서드마다 동일한 로깅 코드와 트랜잭션 코드를 복사해서 붙여넣어야 합니다. 이는 코드의 중복을 낳고, 훗날 수정이 필요할 때 수백 개의 파일을 일일이 고쳐야 하는 유지보수의 지옥을 만들어냅니다.
AOP는 바로 이 지점에서 구원투수로 등장합니다. 핵심 비즈니스 로직에서 공통으로 사용되는 부가 기능(관심사)을 별도의 모듈(Aspect)로 분리해 내고, 원하는 곳에 동적으로 적용하는 기술입니다. 즉, AOP는 OOP를 배척하는 것이 아니라, OOP가 더욱 객체 지향적인 원칙(특히 단일 책임 원칙)을 잘 지킬 수 있도록 돕는 완벽한 파트너입니다.
2. AOP를 정복하기 위한 핵심 용어 (Terminology)
AOP 관련 공식 문서나 기술 블로그를 읽다 보면 난해한 용어들 때문에 좌절하기 쉽습니다. 실무에 AOP를 적용하기 위해 반드시 알아야 할 핵심 용어 6가지를 쉽게 풀어 설명해 드립니다.
2.1. Aspect (애스펙트)
- 정의: 공통으로 적용될 부가 기능(Advice)과 그 기능이 어디에 적용될지(Pointcut)를 하나로 묶은 모듈입니다.
- 비유: '로깅', '트랜잭션 관리' 등 흩어진 관심사를 하나의 클래스로 깔끔하게 모아둔 상자라고 생각하면 됩니다.
2.2. Target (타겟)
- 정의: Aspect가 적용되는 실제 비즈니스 로직을 가진 객체입니다.
- 설명: 우리가 작성한 UserService, BoardService 같은 클래스들이 타겟이 됩니다.
2.3. Advice (어드바이스)
- 정의: 타겟에 제공할 실질적인 부가 기능 구현체(코드)입니다.
- 종류: 언제 실행될 것인지에 따라 @Before(메서드 실행 전), @After(실행 후), @Around(실행 전후 모두, 가장 강력함), @AfterReturning(정상 반환 후), @AfterThrowing(예외 발생 시)으로 나뉩니다.
2.4. JoinPoint (조인포인트)
- 정의: Advice가 적용될 수 있는 애플리케이션 실행 흐름 상의 합류 지점입니다.
- 특징: 이론적으로는 생성자 호출 시, 필드 값 변경 시 등 다양하지만, 스프링 AOP는 오직 '메서드 실행 시점'만을 JoinPoint로 허용합니다.
2.5. Pointcut (포인트컷)
- 정의: 무수히 많은 JoinPoint 중에서, 실제로 Advice를 적용할 타겟 메서드를 선별하는 정규 표현식 또는 조건입니다.
- 비유: "com.example.service 패키지 밑에 있는 클래스 중 이름이 'get'으로 시작하는 메서드에만 적용해라!"라고 타겟팅을 하는 필터 역할입니다.
2.6. Weaving (위빙)
- 정의: Pointcut으로 결정된 타겟의 JoinPoint에 실제 Advice(부가 기능)를 끼워 넣는 과정입니다.
3. 스프링 AOP의 심장: 프록시(Proxy) 동작 원리
AOP를 구현하는 방법은 컴파일 타임에 코드를 삽입하거나, 클래스가 로드될 때 바이트코드를 조작하는 등 여러 방식(AspectJ)이 있습니다. 하지만 스프링 AOP는 '프록시(Proxy) 패턴'을 기반으로 동작하는 런타임 위빙(Runtime Weaving) 방식을 채택하고 있습니다. 이 동작 원리를 이해하는 것은 기술 면접에서도 매우 중요합니다.
스프링 프레임워크가 부팅되면서 빈(Bean)들을 생성할 때, 대상 빈이 AOP 적용 대상(Target)이라면 스프링은 실제 객체를 그대로 스프링 컨테이너에 올리지 않습니다. 대신, 실제 객체를 감싸고 있는 가짜 객체(Proxy 객체)를 동적으로 생성하여 빈으로 등록합니다.
클라이언트(컨트롤러 등)가 서비스의 메서드를 호출하면, 실제로는 다음의 흐름이 발생합니다.
- 클라이언트가 프록시 객체의 메서드를 호출합니다.
- 프록시 객체는 AOP에 정의된 부가 기능(Advice, 예: 로깅 시작)을 먼저 실행합니다.
- 프록시가 실제 객체(Target)의 비즈니스 로직을 대신 호출(위임)합니다.
- 실제 로직 실행이 끝나면, 프록시가 다시 나머지 부가 기능(예: 실행 시간 계산 후 로깅 끝)을 마무리하고 클라이언트에게 결과를 반환합니다.
이러한 프록시 기반 동작 덕분에 우리는 기존 비즈니스 로직 코드를 단 한 줄도 수정하지 않고 부가 기능을 마음껏 추가하고 뺄 수 있는 것입니다. (참고로 스프링 부트 환경에서는 기본적으로 CGLIB라는 라이브러리를 사용하여 클래스 기반의 프록시를 생성합니다.)
4. 실무 완벽 적용: 커스텀 어노테이션 기반 AOP 구현 예제
단순히 패키지 경로를 기준으로 AOP를 적용(Pointcut 표현식)하면, 원치 않는 메서드까지 적용되거나 패키지 구조가 변경되었을 때 에러가 발생하기 쉽습니다. 따라서 실무에서는 개발자가 직접 만든 커스텀 어노테이션을 부착한 메서드에만 AOP가 동작하도록 구현하는 방식을 널리 사용합니다.
대표적인 예제인 'API 실행 시간 측정 로직'을 만들어 보겠습니다.
4.1. 의존성 (Dependency) 추가
스프링 부트 프로젝트의 build.gradle에 AOP 스타터 의존성을 추가합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
4.2. 커스텀 어노테이션 생성 (@Timer)
시간 측정을 원하는 메서드에 붙일 용도로 활용할 어노테이션을 하나 생성합니다.
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 Timer {
// 내부 로직은 비워둡니다. 일종의 '마커(Marker)' 역할입니다.
}
4.3. Aspect 클래스 구현
실제 시간을 측정하고 로그를 남기는 부가 기능(Advice) 클래스를 작성합니다. @Aspect와 빈 등록을 위한 @Component가 필수입니다.
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
@Aspect
@Component
public class TimerAspect {
private static final Logger log = LoggerFactory.getLogger(TimerAspect.class);
// 우리가 만든 @Timer 어노테이션이 붙은 메서드만 Pointcut으로 지정합니다.
@Around("@annotation(com.example.demo.annotation.Timer)")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 1. Target의 실제 비즈니스 로직 실행 (핵심)
Object result = joinPoint.proceed();
stopWatch.stop();
// 2. 부가 기능 수행 (실행 시간 로깅)
String methodName = joinPoint.getSignature().getName();
log.info("[Timer AOP] {} 메서드 실행 시간: {} ms", methodName, stopWatch.getTotalTimeMillis());
return result;
}
}
여기서 핵심은 @Around 내부의 joinPoint.proceed() 입니다. 이 코드를 기점으로 위쪽이 '메서드 실행 전(Before)', 아래쪽이 '메서드 실행 후(After)'에 동작할 코드가 됩니다.
4.4. 서비스에 적용하기
이제 핵심 비즈니스 로직이 있는 서비스 계층에서 앞서 만든 @Timer 어노테이션만 붙여주면 끝입니다.
import com.example.demo.annotation.Timer;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Timer
public void createUser() throws InterruptedException {
// 복잡한 비즈니스 로직이 있다고 가정
System.out.println("회원 가입 로직 실행 중...");
Thread.sleep(1500); // 1.5초 소요 가정
}
}
이제 createUser() 메서드가 호출될 때마다 스프링 프록시가 개입하여 자동으로 실행 시간을 측정하고 콘솔에 로그를 남겨줍니다. 서비스 로직 안에는 로깅 코드가 전혀 섞이지 않은 깔끔한 상태를 유지하게 됩니다.
5. 스프링 AOP 사용 시 치명적인 주의사항: 내부 호출 (Self-Invocation)
스프링 AOP를 실무에 적용할 때 주니어 개발자들이 가장 많이 겪는 장애 포인트가 있습니다. 바로 '같은 클래스 내부에서의 메서드 호출 시에는 AOP가 동작하지 않는다'는 점입니다.
앞서 스프링 AOP는 '프록시(Proxy)' 객체를 통해 동작한다고 설명했습니다. 외부 컨트롤러에서 서비스 메서드를 호출할 때는 프록시를 거치기 때문에 AOP가 정상 작동합니다. 하지만, 서비스 클래스 내부에서 자신의 다른 메서드를 직접 호출할 때(this.method())는 프록시를 거치지 않고 실제 객체의 내부 코드를 직접 실행해버리기 때문에 AOP(부가 기능)가 발동하지 않습니다.
따라서 트랜잭션(@Transactional 역시 AOP 기반)이나 캐시 등을 적용한 메서드를 설계할 때는, 외부에서 직접 호출되도록 클래스를 분리하거나 구조를 재설계해야 한다는 점을 반드시 명심하시기 바랍니다.
6. 마치며
이번 포스팅에서는 자바 스프링의 핵심 철학인 AOP의 기본 개념부터 핵심 용어, 프록시 동작 원리, 그리고 실무에서 강력하게 활용되는 커스텀 어노테이션 기반의 AOP 적용법까지 심도 있게 알아보았습니다.
AOP는 무분별하게 남용하면 애플리케이션의 실행 흐름을 파악하기 힘들어지는 '유지보수의 적'이 될 수 있지만, 트랜잭션, 로깅, 권한 체크 등 명확한 '공통 인프라 로직'에 적절히 사용한다면 코드의 품질을 비약적으로 끌어올려 주는 최고의 무기입니다. 오늘 예제를 직접 타이핑해 보시면서 AOP의 매력을 꼭 느껴보시길 바랍니다.
'Spring' 카테고리의 다른 글
| 서블릿 필터 vs 스프링 인터셉터 (0) | 2023.03.26 |
|---|
- Total
- Today
- Yesterday
- java proxy pattern
- 클래스로더
- JRE와 JDK의 차이점
- Reflection
- 리플렉션
- java optional
- 코드 커버리지
- JVM 구조
- classloder
- java11 optional
- javaagent
- Java Reflection
- 자바
- Annotation Processor
- 실행 엔진
- 바이트 코드
- jvm
- java abstractprocessor
- javassist
- java
- 자바 프록시 패턴
- 람다표현식
- 애노테이션 프로세서
- 깃 기초
- Functional Interface
- optional api
- dromos
- 애노테이션
- 자바 리플렉션
- bytebuddy
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 28 | 29 | 30 | 31 |
