Spring으로 개발하다 보면 이런 상황을 마주칩니다. 모든 서비스 메소드의 실행 시간을 측정하고 싶은데, 메소드가 50개라면 50군데에 똑같은 코드를 넣어야 할까요? 로그인 체크를 모든 메소드에 넣어야 한다면? 이런 반복적인 코드를 어떻게 깔끔하게 처리할 수 있을지 알아보겠습니다.
문제 상황: 코드가 여기저기 중복된다
실행 시간을 측정하는 코드를 직접 넣어보겠습니다.
@Service
public class UserService {
public User findById(Long id) {
long start = System.currentTimeMillis(); // 측정 시작
// 실제 비즈니스 로직
User user = userRepository.findById(id);
long end = System.currentTimeMillis(); // 측정 끝
System.out.println("findById 실행 시간: " + (end - start) + "ms");
return user;
}
public List<User> findAll() {
long start = System.currentTimeMillis(); // 또 측정 시작
// 실제 비즈니스 로직
List<User> users = userRepository.findAll();
long end = System.currentTimeMillis(); // 또 측정 끝
System.out.println("findAll 실행 시간: " + (end - start) + "ms");
return users;
}
// 메소드가 10개라면... 10번 반복?
}
문제가 보이시나요?
- 똑같은 코드가 반복됩니다
- 메소드가 늘어날 때마다 복사-붙여넣기해야 합니다
- 측정 방식을 바꾸려면 모든 메소드를 수정해야 합니다
- 비즈니스 로직과 시간 측정 로직이 섞여 있어서 코드가 지저분합니다
이런 문제를 해결하기 위해 등장한 것이 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)입니다.
AOP가 뭔가요?
AOP는 "여러 곳에서 공통으로 사용되는 기능을 분리해서 관리하자"는 프로그래밍 기법입니다.
비유를 하나 들어보겠습니다.
아파트 단지를 생각해 보세요. 각 집마다 경비원을 따로 고용할 수도 있지만, 그건 비효율적입니다. 대신 단지 입구에 경비실을 하나 두고, 모든 방문객이 그곳을 거치게 합니다. 경비원은 "누가 들어오고 나가는지" 기록하고, 수상한 사람은 막습니다.
[방문객] → [경비실(공통 기능)] → [101동, 102동, 103동...]
AOP도 마찬가지입니다. 실행 시간 측정, 로깅, 트랜잭션 처리 같은 공통 기능을 한 곳에 모아두고, 필요한 메소드에 자동으로 적용합니다.
핵심 개념 정리
AOP에서 자주 나오는 용어들을 정리해 보겠습니다. 처음엔 낯설지만, 예제를 보면서 익히면 금방 익숙해집니다.
| 용어 | 설명 | 비유 |
|---|---|---|
| Aspect | 공통 관심사를 모듈화한 것 | 경비실 |
| Advice | 실제로 실행되는 공통 로직 | 경비원이 하는 일 (신분증 확인, 기록 등) |
| Join Point | Advice가 적용될 수 있는 지점 | 경비실을 거쳐가는 모든 사람 |
| Pointcut | 어떤 Join Point에 Advice를 적용할지 선택 | "택배 기사는 검사 안 함" 같은 규칙 |
| Target | Advice가 적용되는 대상 객체 | 아파트 주민 |
복잡해 보이지만, 결국 이런 뜻입니다:
"어떤 메소드들(Pointcut)이 실행될 때(Join Point), 이 로직(Advice)을 자동으로 끼워 넣어라"
Spring AOP 사용해보기
이제 Spring에서 AOP를 어떻게 사용하는지 코드로 살펴보겠습니다.
의존성 추가
Spring Boot를 사용한다면 아래 의존성을 추가합니다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-aop'
실행 시간을 측정하는 Aspect 만들기
@Aspect
@Component
public class ExecutionTimeAspect {
// service 패키지와 그 하위 패키지의 모든 메서드에 적용
@Around("execution(* com.example.service..*.*(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
// 실제 메소드 실행
Object result = joinPoint.proceed();
long end = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
System.out.println(methodName + " 실행 시간: " + (end - start) + "ms");
return result;
}
}
이게 전부입니다. 이제 com.example.service 패키지 아래의 모든 메소드에 자동으로 실행 시간 측정이 적용됩니다.
기존 Service 코드는 깔끔해집니다
@Service
public class UserService {
public User findById(Long id) {
// 비즈니스 로직만 남음
return userRepository.findById(id);
}
public List<User> findAll() {
// 비즈니스 로직만 남음
return userRepository.findAll();
}
}
시간 측정 코드가 사라졌습니다. 비즈니스 로직에만 집중할 수 있게 되었습니다.
Advice 종류 알아보기
Advice는 "언제 실행할지"에 따라 여러 종류가 있습니다.
@Before: 메소드 실행 전
@Before("execution(* com.example.service.*.*(..))")
public void beforeMethod(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
System.out.println("[Before] " + methodName + " 시작");
}
@After: 메소드 실행 후 (성공/실패 무관)
@After("execution(* com.example.service.*.*(..))")
public void afterMethod(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
System.out.println("[After] " + methodName + " 끝");
}
@AfterReturning: 메소드가 정상 반환된 후
@AfterReturning(pointcut = "execution(* com.example.service.*.*(..))",
returning = "result")
public void afterReturning(JoinPoint joinPoint, Object result) {
System.out.println("반환값: " + result);
}
@AfterThrowing: 예외가 발생한 후
@AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
throwing = "ex")
public void afterThrowing(JoinPoint joinPoint, Exception ex) {
System.out.println("예외 발생: " + ex.getMessage());
}
@Around: 메소드 실행 전후 모두
@Around("execution(* com.example.service.*.*(..))")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("실행 전");
Object result = joinPoint.proceed(); // 실제 메소드 호출
System.out.println("실행 후");
return result;
}
@Around가 가장 강력합니다. 메소드 실행 전후를 모두 제어할 수 있고, 심지어 메소드 실행 자체를 막을 수도 있습니다.
Pointcut 표현식 이해하기
execution(* com.example.service.*.*(..))이 뭘 의미하는지 뜯어보겠습니다.
execution(* com.example.service.*.*(..))
│ │ │ │ │
│ │ │ │ └─ 파라미터: (..) = 모든 파라미터
│ │ │ └─── 메소드명: * = 모든 메소드
│ │ └───── 클래스명: * = 모든 클래스
│ └────────────────────────── 패키지 경로
└───────────────────────────── 반환 타입: * = 모든 타입
몇 가지 예시를 더 보겠습니다.
// UserService의 모든 메소드
execution(* com.example.service.UserService.*(..))
// find로 시작하는 메소드만
execution(* com.example.service.*.find*(..))
// 파라미터가 Long 하나인 메소드만
execution(* com.example.service.*.*(Long))
// 하위 패키지까지 포함
execution(* com.example.service..*.*(..))
자주 쓰는 Pointcut 분리하기
같은 Pointcut을 여러 Advice에서 쓴다면 분리할 수 있습니다.
@Aspect
@Component
public class LoggingAspect {
// Pointcut 정의
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayer() {}
@Before("serviceLayer()")
public void beforeService(JoinPoint joinPoint) {
System.out.println("서비스 메소드 시작");
}
@After("serviceLayer()")
public void afterService(JoinPoint joinPoint) {
System.out.println("서비스 메소드 끝");
}
}
어노테이션 기반 Pointcut
특정 어노테이션이 붙은 메소드만 대상으로 할 수도 있습니다. 이 방식이 더 명확하고 실무에서 많이 쓰입니다.
커스텀 어노테이션 만들기
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {
}
어노테이션을 기준으로 Aspect 적용
@Aspect
@Component
public class ExecutionTimeAspect {
@Around("@annotation(LogExecutionTime)")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long end = System.currentTimeMillis();
System.out.println(joinPoint.getSignature().getName() +
" 실행 시간: " + (end - start) + "ms");
return result;
}
}
원하는 메소드에만 적용
@Service
public class UserService {
@LogExecutionTime // 이 메소드만 시간 측정
public User findById(Long id) {
return userRepository.findById(id);
}
// 이 메소드는 시간 측정 안 함
public List<User> findAll() {
return userRepository.findAll();
}
}
이 방식의 장점은 어떤 메소드에 AOP가 적용되는지 코드만 봐도 알 수 있다는 것입니다.
프록시 패턴: AOP는 어떻게 동작할까?
"메소드 앞뒤로 코드를 끼워 넣는다"고 했는데, 어떻게 가능한 걸까요? 답은 프록시(Proxy)입니다.
프록시란?
프록시는 "대리인"이라는 뜻입니다. 원래 객체를 감싸서, 호출을 가로채고, 추가 작업을 한 뒤 원래 객체를 호출합니다.
[클라이언트] → [프록시(가짜 UserService)] → [진짜 UserService]
↓
"실행 시간 측정"
Spring의 동작 방식
Spring은 AOP가 적용된 빈을 생성할 때, 원본 객체 대신 프록시 객체를 생성해서 빈으로 등록합니다.
// 개발자가 작성한 코드
@Autowired
private UserService userService;
// 실제로 주입되는 건 프록시 객체
// userService -> UserService$$EnhancerBySpringCGLIB$$abc123 (프록시)
그래서 userService.findById()를 호출하면:
- 프록시가 먼저 호출을 받음
- 프록시가 @Before 로직 실행
- 프록시가 실제 메소드 호출
- 프록시가 @After 로직 실행
- 결과 반환
주의: 같은 클래스 내부 호출은 AOP가 안 먹힌다
이건 많이들 헷갈려하는 부분입니다.
@Service
public class UserService {
public void methodA() {
// 같은 클래스의 methodB를 직접 호출
this.methodB(); // AOP가 적용 안 됨!
}
@LogExecutionTime
public void methodB() {
// ...
}
}
왜 그럴까요? this.methodB()는 프록시를 거치지 않고 직접 호출하기 때문입니다.
외부에서 호출: [프록시] → [methodB] (AOP 적용)
내부에서 호출: [this.methodB()] (프록시 안 거침, AOP 미적용)
해결 방법:
- 별도의 빈으로 분리하거나
- 자기 자신을 주입받아서 호출하거나 (권장하지 않음)
- 설계를 다시 생각해보기
실무에서 AOP를 쓰는 곳
1. 로깅
@Around("execution(* com.example.controller.*.*(..))")
public Object logRequest(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("요청: {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("응답: {}", result);
return result;
}
2. 트랜잭션 처리
Spring의 @Transactional이 바로 AOP로 구현되어 있습니다.
@Transactional // 내부적으로 AOP가 동작
public void transferMoney(Long from, Long to, int amount) {
// 이 메소드 전체가 하나의 트랜잭션으로 묶임
accountRepository.withdraw(from, amount);
accountRepository.deposit(to, amount);
}
3. 권한 체크
@Before("@annotation(RequireAdmin)")
public void checkAdmin(JoinPoint joinPoint) {
if (!currentUser.isAdmin()) {
throw new AccessDeniedException("관리자 권한이 필요합니다");
}
}
4. 캐싱
Spring의 @Cacheable도 AOP 기반입니다.
@Cacheable("users") // AOP가 캐시 로직을 처리
public User findById(Long id) {
return userRepository.findById(id);
}
정리
- AOP는 여러 곳에서 반복되는 공통 기능(로깅, 트랜잭션 등)을 분리해서 관리하는 기법이다
- Aspect에 공통 로직을 작성하고, Pointcut으로 어디에 적용할지 지정한다
- Advice는 실행 시점에 따라 @Before, @After, @Around 등이 있다
- Spring AOP는 프록시 패턴으로 동작한다. 원본 객체 대신 프록시가 빈으로 등록된다
- 같은 클래스 내부에서 호출하면 AOP가 적용되지 않는다 (프록시를 거치지 않기 때문)
@Transactional,@Cacheable같은 Spring의 편리한 기능들이 AOP로 구현되어 있다
'프레임워크 > 스프링' 카테고리의 다른 글
| 스프링의 Scope(스코프): 싱글톤(Singleton)과 프로토타입(Prototype)의 결정적 차이 (0) | 2025.12.23 |
|---|---|
| Filter vs Interceptor: 둘 다 거름망인데 도대체 뭐가 다를까? (0) | 2025.12.22 |
| DispatcherServlet의 역할: 요청이 들어와서 응답이 나갈 때까지의 여정 (1) | 2025.12.19 |
| Spring Bean의 생명주기(Lifecycle) - 객체 생성부터 소멸까지 (0) | 2025.12.18 |
| DI(의존성 주입)과 IoC(제어의 역전): 왜 우리가 직접 new를 안 쓸까? (0) | 2025.12.18 |