🌏 AOP (관점 지향 프로그래밍)
AOP는 핵심 비즈니스 로직에서 공통 관심사를 분리하기 위한 프로그래밍 패러다임입니다.
여러 모듈에서 반복되는 부가 기능(공통 관심사)을 따로 빼서 관리하고, 필요한 지점에 끼워 넣는 기법입니다.
예를 들자면,
- 핵심 관심사: 주문 생성, 결제 처리
- 공통 관심사: 로깅, 트랜잭션 관리, 보안 검사
✅ 필요성
- 중복 코드 제거: 로깅, 보안, 예외처리 같은 코드가 여러 클래스에 흩어져 있지 않음
- 관심사 분리(Separation of Concerns): 핵심 로직과 부가 로직을 분리해 가독성과 유지보수성을 높임
- 변경 용이성: 공통 기능을 한 곳에서만 수정하면 전체에 반영
✅ 핵심 용어 (Terms)
- Aspect (관점)
- 공통 관심사를 모듈화한 단위 (ex: 트랜잭션 관리, 로깅)
- 예: 모든 팀이 출퇴근 기록을 해야 한다고 하면, 각 팀 코드(영업, 개발, 회계)에 중복해서 쓰는 것이 아니라 ‘출퇴근 관리 부서’를 따로 둬서 처리하는 것과 같습니당
- Join Point (결합 지점)
- AOP가 적용될 수 있는 모든 지점 (메서드 호출, 예외 발생 등)
- 예: 직원이 회사에 출근할 때, 회의실 들어갈 때, 문서 결재할 때
- Pointcut (선택 지점)
- Join Point 중 실제로 Aspect를 적용할 지점을 표현 (조건식)
- 예:
execution(* com.example.service.*.*(..)) - 예: 모든 행동 중에서 출근할 때만 출퇴근 체크기를 찍는다
- Advice (실행 동작)
- 실제로 끼워 넣을 부가기능 (메서드 실행 전/후/예외 시 동작 등)
- 종류
@Before: 메서드 실행 전 (예: 출근 전 신분증 확인)@AfterReturning: 메서드 성공 후 (예: 퇴근 후 정상 기록)@AfterThrowing: 예외 발생 시 (예: 출근 중 사고 발생 시)@After: 메서드 종료 시(성공/실패 무관) (예: 퇴근 시간 기록은 무조건 남김)@Around: 메서드 실행 전후 전체 제어 (예: 출근부터 퇴근까지 전부 관리)
- 예: 지문 인식
- Weaving (위빙)
- 실제 코드에 Aspect를 엮어서 넣는 과정
- 시점: 컴파일 타임, 로드 타임, 런타임
- 스프링은 주로 런타임 위빙 (프록시 기반), 프로그램이 돌면서 프록시를 씌우는 방식
- Proxy (프록시 객체)
- 스프링 AOP는 내부적으로 대상 객체를 감싸는 프록시를 만들어서 Aspect를 적용
✔️ Join Point vs Pointcut?
- Join Point: AOP가 적용될 수 있는 모든 지점 (후보군)
- Pointcut: Join Point 중 실제로 AOP를 적용하겠다고 선택한 지점 (실행군)
실행은 항상 Pointcut에서 일어나지만, 그 바탕에는 “전체 후보군”을 정의한 Join Point 개념이 필요함
✅ 특징
- 프록시 기반 AOP: JDK 동적 프록시(인터페이스 있는 경우)나 CGLIB 프록시(인터페이스 없는 경우, 클래스 상속받은 가짜 자식을 만들어서 프록시 처리) 사용
- 런타임 위빙: 실행 시점에 프록시 객체를 만들어 Aspect 적용
- 메서드 실행 단위의 Join Point만 지원: Spring AOP는 메서드 호출에만 적용 가능 (AspectJ는 더 풍부한 Join Point 지원)
✅ Advice 실행 흐름
예를 들어 @Around 어드바이스가 붙은 경우, 대상 메서드의 실행 전, 후에 Advice가 실행됩니다. (API 성능 모니터링, 트랜잭션 관리 등에 활용됨)
@Around("execution(* com.example..*(..))")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 메서드 실행 전 로직
System.out.println("Before: " + joinPoint.getSignature());
// 2. 실제 대상 메서드 실행
Object result = joinPoint.proceed();
// 3. 메서드 실행 후 로직
System.out.println("After: " + result);
return result;
}
실행 흐름은 다음과 같습니다
Client → Proxy → [Before Advice] → Target Method → [After Advice] → Return
🌏 Spring AOP 내부 구현 원리
Spring AOP는 프록시 기반입니다. 즉, 원래 객체를 그대로 쓰지 않고 대리 객체(Proxy)를 앞에 세워서 부가기능을 끼워 넣습니다. 그리고 이 프록시 객체가 스프링 IoC 컨테이너 내부에서 관리됩니다.
✅ 프록시 객체 생성 과정
- BeanPostProcessor 등록
- 스프링 컨테이너 초기화 단계에서
AnnotationAwareAspectJAutoProxyCreator같은 특별한BeanPostProcessor가 등록됩니다. - 이 후처리기가 AOP 어드바이스가 필요한 빈을 감지하고, 프록시를 만들어줍니다.
- 스프링 컨테이너 초기화 단계에서
- 빈 생성 시점에 프록시로 대체
- 스프링이 빈을 만들 때, 해당 빈이 AOP 대상(Pointcut 조건과 매칭)이라면, 원본 객체 대신 프록시 객체를 컨테이너에 등록합니다.
- 이후 다른 빈에서
@Autowired로 주입받는 것은 원본이 아니라 프록시입니다.
- 메서드 호출 가로채기
- 프록시 객체가 호출을 가로채서, Advice 실행 → 원본 메서드 실행 → Advice 후처리 순서로 실행됩니다.
✅ 프록시 객체 종류: JDK Dynamic Proxy vs CGLIB
우리가 생성한 빈이 인터페이스를 구현한 식이냐 아니냐에 따라서 생성되는 프록시 객체의 종류는 달라집니다.
스프링은 기본적으로 인터페이스 있으면 JDK 프록시, 없으면 CGLIB을 사용합니다.
- JDK Dynamic Proxy
- 인터페이스가 있으면 인터페이스 기반으로 프록시 생성
- 원리:
java.lang.reflect.Proxy+InvocationHandler
- CGLIB Proxy
- 인터페이스가 없으면 클래스 상속 기반으로 프록시 생성 (빈 클라스 앞에 private 같은거 붙이면 상속이 안되기 때문에 스프링부트에서 에러를 발생시키는 이유입니다)
- 원리:
net.sf.cglib.proxy.Enhancer+MethodInterceptor - 최종 클래스(
final)나final메서드에는 적용 불가 - (Spring Boot 2.x+에서는 항상 CGLIB 활성화 옵션을 켜두는 경우가 많음)
✅ 프록시 객체는 원래 메서드를 감싸버려~
예를 들어 로깅을 하려고 AOP를 사용했다면, 스프링은 이 “프록시 클래스”를 자동으로 만들어주고, 컨테이너에 주입해줍니다.
public class MemberServiceProxy implements MemberService {
private final MemberService target;
public MemberServiceProxy(MemberService target) {
this.target = target;
}
@Override
public void join() {
System.out.println("로그 시작");
target.join(); // 실제 메서드 실행
System.out.println("로그 끝");
}
}
🌏 스프링에서 활용되는 AOP
AOP는 “내가 직접 Aspect를 만든다”는 것보다는 “스프링이 제공하는 기능들이 다 AOP 위에 돌아간다”는 걸 이해하는 게 중요합니다.
✅ 1. 트랜잭션 관리 (@Transactional)
- 데이터베이스 작업은 중간에 끊기면 안 되고, 전부 성공하거나 전부 실패해야 합니다.
- 예를 들어, 계좌 이체를 한다면 “A 계좌에서 돈 빼기”와 “B 계좌에 돈 넣기”가 반드시 함께 성공해야 합니다.
@Transactional을 메서드에 붙이면, 스프링이 프록시를 만들어서 메서드 실행 전후에 트랜잭션을 시작하고, 끝내고, 예외 시 롤백합니다.
예를 들어 아래와 같은 빈을 작성했다고 합시다.
@Service
public class AccountService {
@Transactional
public void transferMoney(String from, String to, int amount) {
withdraw(from, amount); // A 계좌 출금
deposit(to, amount); // B 계좌 입금
}
}
스프링은 위 클래스를 그대로 쓰지 않고, 프록시 클래스를 만들어서 컨테이너에 등록합니다. 프록시는 내부에 트랜잭션 처리 로직을 넣고, 원래 AccountService를 위임 호출합니다.
즉, 클라이언트는 accountService.transferMoney()를 호출하더라도 사실상 호출되는 건 AccountServiceProxy.transferMoney()가 됩니다.
// 프록시로 감싸진 버전 (실제 개발자가 보는 건 아님, 개념 설명용)
public class AccountServiceProxy extends AccountService {
private final AccountService target; // 실제 원본 객체
private final PlatformTransactionManager txManager;
public AccountServiceProxy(AccountService target, PlatformTransactionManager txManager) {
this.target = target;
this.txManager = txManager;
}
@Override
public void transferMoney(String from, String to, int amount) {
// 트랜잭션 시작
TransactionStatus status = txManager.getTransaction(new DefaultTransactionDefinition());
try {
// 실제 원본 객체의 메서드 실행
target.transferMoney(from, to, amount);
// 정상 → 커밋
txManager.commit(status);
} catch (RuntimeException e) {
// 예외 → 롤백
txManager.rollback(status);
throw e;
}
}
}
따라서 개발자는 그냥 비즈니스 로직만 작성하면 되고, 트랜잭션 시작/커밋/롤백은 AOP가 알아서 처리합니다.
- 프록시가 먼저 트랜잭션 매니저에게 “트랜잭션 시작” 요청
- 실제 비즈니스 메서드 실행
- 정상 종료 → 트랜잭션 커밋
- 예외 발생 → 트랜잭션 롤백
✅ 2. 보안 처리 (@Secured, @PreAuthorize)
- 특정 기능은 관리자만 실행할 수 있어야 하거나, 로그인한 사용자만 접근 가능해야 합니다.
- 권한 체크를 서비스 로직마다 직접 넣으면 코드가 지저분해집니다.
@Secured("ROLE_ADMIN")같은 어노테이션을 붙이면, 메서드 실행 전에 권한을 검사합니다.- 권한이 없으면 로직 자체가 실행되지 않고, 바로 예외 발생 → 접근 차단.
@Service
public class AdminService {
@Secured("ROLE_ADMIN")
public void deleteUser(Long userId) {
// 관리자만 가능한 작업
}
}
스프링은 AdminService를 빈으로 등록할 때, 바로 쓰지 않고 프록시 클래스를 만들어서 컨테이너에 등록합니다. (아래는 개념 설명용 의사코드임)
public class AdminServiceProxy extends AdminService {
private final AdminService target; // 원본 AdminService
private final SecurityInterceptor securityInterceptor;
public AdminServiceProxy(AdminService target, SecurityInterceptor securityInterceptor) {
this.target = target;
this.securityInterceptor = securityInterceptor;
}
@Override
public void deleteUser(Long userId) {
// 1. 권한 확인 (ROLE_ADMIN이 있는지 검사)
if (!securityInterceptor.hasRole("ROLE_ADMIN")) {
throw new AccessDeniedException("권한 없음");
}
// 2. 권한 통과 → 실제 메서드 실행
target.deleteUser(userId);
}
}
✅ 3. 캐싱 (@Cacheable)
- 조회 쿼리나 외부 API 호출처럼 자주 요청되지만 결과가 자주 바뀌지 않는 데이터가 있습니다.
- 매번 DB나 API를 호출하면 느려지고 비용이 큽니다.
@Cacheable을 붙이면, 메서드 실행 전에 캐시에 값이 있는지 확인합니다.- 있으면 DB를 건드리지 않고 캐시 값을 반환.
- 없으면 실제 메서드를 실행하고 결과를 캐시에 저장.
@Service
public class ProductService {
@Cacheable("products")
public Product getProduct(Long id) {
return productRepository.findById(id).get();
}
}
프록시로 감으면 이런 느낌이겠죠..?
public class ProductServiceProxy extends ProductService {
private final ProductService target; // 실제 ProductService
private final CacheManager cacheManager;
public ProductServiceProxy(ProductService target, CacheManager cacheManager) {
this.target = target;
this.cacheManager = cacheManager;
}
@Override
public Product getProduct(Long id) {
Cache cache = cacheManager.getCache("products");
// 1. 캐시에 값 있는지 확인
Product cached = cache.get(id, Product.class);
if (cached != null) {
System.out.println("캐시에서 조회: " + id);
return cached;
}
// 2. 없으면 실제 메서드 실행
Product result = target.getProduct(id);
// 3. 실행 결과 캐시에 저장
cache.put(id, result);
System.out.println("DB 조회 후 캐시 저장: " + id);
return result;
}
}
✅ 4. 로깅 / 모니터링 (실행 시간 측정)
- 실무에서는 “이 API가 몇 초 걸리는지”, “에러가 어디서 나는지” 같은 로깅/모니터링이 중요합니다.
- 하지만 모든 메서드마다
System.out.println()이나logger.info()를 넣는 건 비효율적입니다.
- 하지만 모든 메서드마다
@Around어드바이스를 사용하면, 메서드 실행 전후 시간을 측정하고 로그를 남길 수 있습니다.
@Aspect
@Component
public class LoggingAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed(); // 실제 메서드 실행
long end = System.currentTimeMillis();
System.out.println(joinPoint.getSignature() + " 실행 시간: " + (end - start) + "ms");
return result;
}
}
이렇게 구현을 하는데 만약에 @Around 어드바이스 안에서 proceed()를 안 부르면 원본 메서드가 실행되지 않기 때문에 주의해야 합니다(ㅋㅋ..)
🌏 Spring AOP 주의사항!!!
✅ 프록시 기반 한계 (Self-invocation 문제)
- 스프링 AOP는 프록시 객체를 통해 동작합니다.
- 그런데 자기 자신 안에서 자기 메서드를 호출하면 프록시를 거치지 않습니다.
@Service
public class OrderService {
@Transactional
public void outer() {
inner(); // 같은 클래스 메서드 호출 → 프록시 안 거침 → 트랜잭션 적용 안 됨
}
@Transactional
public void inner() {
// 트랜잭션 적용 안 됨
}
}
트랜잭션을 적용하고 싶다면 구조를 분리해서 별도 빈으로 나누거나, AopContext.currentProxy() 활용해야 하는데 이 경우는 복잡도가 증가하니 비추합니다.
별도 빈으로 나누는 편이 좋습니다.
✅ final 클래스 / final 메서드
프록시는 상속(CGLIB) 이나 인터페이스(JDK Dynamic Proxy) 기반으로 만들어지는데, final 클래스나 final 메서드는 오버라이드가 불가능해 AOP 적용이 안 됩니다.
final class PaymentService { ... } // 프록시 못 씌움
✅ private 메서드에는 적용 불가
스프링 AOP는 public 메서드에만 적용하는 것이 원칙이기 땜시 private 메서드는 프록시 호출 경로에서 가려지기 때문에 Advice를 걸 수 없습니다.
✅ 체크 예외 vs 언체크 예외 (@Transactional 롤백 규칙)
@Transactional 기본 동작의 경우에
RuntimeException(언체크) → 롤백Checked Exception→ 롤백 안 함
헷갈려서 잘못 설정하면 데이터 정합성 문제 발생 가능할 수 있기 때문에 필요시 rollbackFor 속성으로 명시해야 함.
✅ 프록시 객체와 원본 객체의 타입 혼동
프록시는 원본 객체를 감싸고 있지만 실제로는 다른 클래스입니다. 특히 JDK Dynamic Proxy는 인터페이스 타입으로만 캐스팅 가능합니다.
// JDK Dynamic Proxy 사용 시
OrderServiceImpl order = (OrderServiceImpl) context.getBean("orderService");
// ❌ ClassCastException 발생 (프록시는 인터페이스 기반이라 Impl로 캐스팅 불가)

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!