본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 30. · 38 Views
Spring AOP 관심사 분리 완벽 가이드
Spring AOP를 활용한 관심사 분리의 모든 것! 횡단 관심사를 깔끔하게 분리하여 코드의 가독성과 유지보수성을 높이는 방법을 실무 예제와 함께 배워보세요.
목차
- AOP와_관심사_분리
- Aspect와_Pointcut
- Around_Advice
- Before와_After_Advice
- AfterThrowing_Advice
- AfterReturning_Advice
- JoinPoint와_ProceedingJoinPoint
- Custom_Annotation과_AOP
- 실무_로깅_시스템
1. AOP와 관심사 분리
시작하며
여러분이 모든 서비스 메서드마다 실행 시간을 측정하는 코드를 추가해야 한다고 상상해보세요. 각 메서드의 시작과 끝에 시간 측정 코드를 넣다 보면 비즈니스 로직보다 부가 기능 코드가 더 많아지는 상황이 발생합니다.
이런 문제는 실제 개발 현장에서 매우 자주 발생합니다. 로깅, 보안 체크, 트랜잭션 관리, 성능 측정 같은 기능들은 여러 곳에서 반복적으로 필요하지만, 핵심 비즈니스 로직과는 직접적인 관련이 없습니다.
이렇게 코드가 섞이면 가독성이 떨어지고 유지보수가 어려워집니다. 바로 이럴 때 필요한 것이 AOP(Aspect-Oriented Programming)입니다.
핵심 비즈니스 로직에서 횡단 관심사를 깔끔하게 분리하여, 코드의 중복을 제거하고 관리를 단순화할 수 있습니다.
개요
간단히 말해서, AOP는 여러 곳에서 반복되는 부가 기능을 한 곳에 모아서 관리하는 프로그래밍 기법입니다. Spring에서는 프록시 패턴을 기반으로 AOP를 구현합니다.
실제 객체를 감싸는 프록시 객체가 생성되어, 메서드 호출 시 부가 기능을 자동으로 실행합니다. 예를 들어, 모든 컨트롤러 메서드의 실행 시간을 측정하거나, 특정 패키지의 모든 메서드에 로깅을 추가하는 경우에 매우 유용합니다.
기존에는 각 메서드마다 로깅 코드를 직접 작성했다면, 이제는 Aspect 클래스 하나로 모든 메서드에 로깅을 일괄 적용할 수 있습니다. AOP의 핵심 특징은 첫째, 코드의 중복을 제거하고 재사용성을 높입니다.
둘째, 비즈니스 로직과 부가 기능을 명확히 분리하여 가독성을 향상시킵니다. 셋째, 선언적 방식으로 간단하게 적용할 수 있습니다.
이러한 특징들이 대규모 엔터프라이즈 애플리케이션에서 특히 중요합니다.
코드 예제
// build.gradle에 AOP 의존성 추가
// implementation 'org.springframework.boot:spring-boot-starter-aop'
@Aspect
@Component
public class PerformanceAspect {
// service 패키지의 모든 메서드에 적용
@Around("execution(* com.example.service.*.*(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
// 시작 시간 기록
long startTime = System.currentTimeMillis();
// 실제 메서드 실행
Object result = joinPoint.proceed();
// 실행 시간 계산 및 로깅
long executionTime = System.currentTimeMillis() - startTime;
System.out.println(joinPoint.getSignature() + " 실행 시간: " + executionTime + "ms");
return result;
}
}
설명
이것이 하는 일: 이 코드는 service 패키지의 모든 메서드가 실행될 때마다 자동으로 실행 시간을 측정하고 로깅합니다. 첫 번째로, @Aspect 어노테이션이 이 클래스를 AOP 관점(Aspect)으로 선언합니다.
@Component를 함께 사용하여 Spring 컨테이너가 이 클래스를 빈으로 등록하도록 합니다. 이렇게 하면 Spring이 자동으로 이 Aspect를 감지하고 적용합니다.
두 번째로, @Around 어노테이션과 함께 정의된 Pointcut 표현식("execution(* com.example.service..(..))")이 실행됩니다. 이 표현식은 "com.example.service 패키지의 모든 클래스의 모든 메서드"를 의미합니다.
이 조건에 맞는 메서드가 호출되면 measureExecutionTime 메서드가 대신 실행됩니다. 세 번째로, ProceedingJoinPoint의 proceed() 메서드를 호출하여 실제 타겟 메서드를 실행합니다.
그 전후로 시간을 측정하여 실행 시간을 계산합니다. 이 방식으로 원본 메서드의 코드를 전혀 수정하지 않고도 성능 측정 기능을 추가할 수 있습니다.
마지막으로, 측정된 실행 시간을 로그에 출력하고 원본 메서드의 반환값을 그대로 반환합니다. 최종적으로 호출자는 AOP가 적용된 것을 전혀 인식하지 못한 채 원래의 결과를 받게 됩니다.
여러분이 이 코드를 사용하면 service 패키지의 수십, 수백 개 메서드에 단 한 줄의 코드 수정 없이 성능 측정 기능을 적용할 수 있습니다. 새로운 서비스 메서드를 추가해도 자동으로 적용되고, 성능 측정 로직을 변경하고 싶을 때도 이 Aspect 클래스만 수정하면 됩니다.
실전 팁
💡 @EnableAspectJAutoProxy를 설정 클래스에 추가하지 않아도 Spring Boot는 자동으로 AOP를 활성화합니다. 하지만 순수 Spring 사용 시에는 필수입니다.
💡 Aspect의 순서가 중요한 경우 @Order 어노테이션으로 우선순위를 지정하세요. 숫자가 낮을수록 먼저 실행됩니다.
💡 개발 환경에서만 성능 측정을 활성화하려면 @Profile("dev")를 Aspect 클래스에 추가하세요.
💡 프록시 방식의 한계로 인해 같은 클래스 내부의 메서드 호출에는 AOP가 적용되지 않습니다. 항상 외부에서 호출되는 public 메서드에만 적용됩니다.
💡 성능에 민감한 경우 Pointcut 표현식을 최대한 구체적으로 작성하여 불필요한 프록시 생성을 줄이세요.
2. Aspect와 Pointcut
시작하며
여러분이 특정 어노테이션이 붙은 메서드에만 특별한 처리를 하고 싶다면 어떻게 해야 할까요? 모든 메서드에 AOP를 적용하는 것은 비효율적이고, 수동으로 하나하나 지정하는 것도 번거롭습니다.
이런 문제를 해결하기 위해 AOP는 Pointcut이라는 강력한 표현식 시스템을 제공합니다. Pointcut은 "어디에" AOP를 적용할지 정의하는 선택자 역할을 합니다.
마치 CSS 선택자가 특정 HTML 요소를 선택하듯이, Pointcut은 특정 메서드들을 선택합니다. 바로 이것이 Aspect와 Pointcut의 핵심입니다.
Aspect는 "무엇을"할지 정의하고, Pointcut은 "어디에" 적용할지 정의합니다. 이 두 가지를 조합하면 매우 유연한 부가 기능 적용이 가능해집니다.
개요
간단히 말해서, Aspect는 횡단 관심사를 모듈화한 클래스이고, Pointcut은 Aspect를 적용할 위치를 지정하는 표현식입니다. Pointcut 표현식은 매우 다양한 조건을 지원합니다.
메서드 이름 패턴, 패키지 경로, 어노테이션 유무, 파라미터 타입 등을 조합하여 원하는 메서드들만 정확히 선택할 수 있습니다. 예를 들어, @Transactional이 붙은 모든 메서드나, Controller로 끝나는 클래스의 모든 public 메서드 같은 조건을 쉽게 표현할 수 있습니다.
기존에는 각 메서드마다 조건을 확인하는 if문을 작성했다면, 이제는 Pointcut 표현식으로 선언적으로 대상을 지정할 수 있습니다. Pointcut의 핵심 특징은 첫째, 재사용 가능한 포인트컷을 정의하여 여러 Advice에서 공유할 수 있습니다.
둘째, 표현식을 조합(&&, ||, !)하여 복잡한 조건을 만들 수 있습니다. 셋째, 런타임이 아닌 컴파일 타임에 대상을 결정하여 성능이 우수합니다.
이러한 특징들이 AOP를 실용적으로 만들어줍니다.
코드 예제
@Aspect
@Component
public class CommonPointcuts {
// 재사용 가능한 Pointcut 정의
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayer() {}
@Pointcut("execution(* com.example.controller.*.*(..))")
public void controllerLayer() {}
// 어노테이션 기반 Pointcut
@Pointcut("@annotation(com.example.annotation.LogExecutionTime)")
public void hasLogAnnotation() {}
// 조합 Pointcut: service 또는 controller 레이어
@Pointcut("serviceLayer() || controllerLayer()")
public void applicationLayer() {}
// 조합 Pointcut: service이면서 @LogExecutionTime이 있는 메서드
@Pointcut("serviceLayer() && hasLogAnnotation()")
public void serviceWithLogging() {}
}
설명
이것이 하는 일: 이 코드는 재사용 가능한 Pointcut들을 정의하고, 이를 조합하여 더 복잡한 선택 조건을 만듭니다. 첫 번째로, @Pointcut 어노테이션으로 serviceLayer()와 controllerLayer() 같은 기본 Pointcut들을 정의합니다.
이들은 메서드처럼 보이지만 실제로는 Pointcut 표현식의 이름입니다. execution은 가장 많이 사용되는 Pointcut 지시자로, 메서드 실행 시점을 가리킵니다.
"*"는 와일드카드로 "모든 것"을 의미합니다. 두 번째로, @annotation 지시자를 사용한 hasLogAnnotation() Pointcut이 정의됩니다.
이것은 특정 어노테이션이 붙은 메서드만 선택합니다. 어노테이션 기반 Pointcut은 매우 실용적입니다.
원하는 메서드에 어노테이션만 붙이면 자동으로 AOP가 적용되기 때문입니다. 세 번째로, 논리 연산자(||, &&)를 사용하여 기존 Pointcut들을 조합합니다.
applicationLayer()는 service 또는 controller 레이어의 모든 메서드를 선택하고, serviceWithLogging()은 service 레이어이면서 동시에 @LogExecutionTime 어노테이션이 있는 메서드만 선택합니다. 마지막으로, 다른 Aspect 클래스에서 이 Pointcut들을 참조하여 사용할 수 있습니다.
예를 들어 @Around("CommonPointcuts.serviceWithLogging()")처럼 사용하면 됩니다. 최종적으로 Pointcut을 중앙에서 관리하여 일관성을 유지하고 변경이 용이해집니다.
여러분이 이 코드를 사용하면 Pointcut 표현식을 한 곳에서 관리하여 중복을 제거하고, 표현식이 변경될 때 한 곳만 수정하면 됩니다. 또한 복잡한 조건을 가독성 좋게 표현할 수 있으며, 팀 전체가 동일한 Pointcut을 공유하여 일관성을 유지할 수 있습니다.
실전 팁
💡 execution 표현식의 전체 형식은 "execution(수정자 리턴타입 패키지.클래스.메서드(파라미터) throws 예외)"입니다. 필요한 부분만 사용하고 나머지는 생략 가능합니다.
💡 within 지시자는 특정 타입 내의 모든 메서드를 선택할 때 사용합니다. "within(com.example.service.*)"는 해당 패키지의 모든 메서드를 선택합니다.
💡 args 지시자로 파라미터 타입을 기준으로 선택할 수 있습니다. "args(String, ..)"는 첫 번째 파라미터가 String인 모든 메서드를 선택합니다.
💡 bean 지시자는 Spring 빈 이름으로 선택합니다. "bean(*Service)"는 이름이 Service로 끝나는 모든 빈의 메서드를 선택합니다.
💡 Pointcut 표현식이 복잡해지면 성능에 영향을 줄 수 있으므로, 가능한 한 구체적이고 단순하게 작성하세요.
3. Around Advice
시작하며
여러분이 메서드 실행 전에 인증을 체크하고, 실행 후에 결과를 캐싱하고 싶다면 어떻게 해야 할까요? 메서드의 전후를 모두 제어하면서 심지어 메서드 실행 자체를 취소하거나 반환값을 변경할 수 있어야 합니다.
이런 요구사항은 실무에서 매우 흔합니다. 캐싱, 트랜잭션 관리, 재시도 로직, 권한 체크 등 많은 경우에 메서드 실행 전후를 완전히 제어해야 합니다.
단순히 로그만 남기는 것이 아니라, 실행 흐름 자체를 제어할 수 있어야 합니다. 바로 이럴 때 필요한 것이 @Around Advice입니다.
가장 강력한 Advice 타입으로, 메서드 실행을 완전히 감싸서 제어할 수 있습니다. 실행 전후의 모든 것을 커스터마이징할 수 있는 만능 도구입니다.
개요
간단히 말해서, @Around Advice는 메서드 실행 전후를 완전히 제어할 수 있는 가장 강력한 Advice 타입입니다. ProceedingJoinPoint를 통해 타겟 메서드의 실행 시점을 직접 제어합니다.
proceed()를 호출하면 원본 메서드가 실행되고, 호출하지 않으면 메서드가 실행되지 않습니다. 심지어 proceed()를 여러 번 호출하여 재시도 로직을 구현하거나, 반환값을 가로채서 수정할 수도 있습니다.
예를 들어, 데이터베이스 조회 결과를 캐시에서 먼저 찾아보고, 없을 때만 실제 메서드를 실행하는 캐싱 로직을 쉽게 구현할 수 있습니다. 기존에는 데코레이터 패턴으로 여러 클래스를 만들어야 했다면, 이제는 @Around Advice 하나로 동일한 기능을 선언적으로 구현할 수 있습니다.
@Around Advice의 핵심 특징은 첫째, 메서드 실행 전후를 완전히 제어할 수 있습니다. 둘째, 반환값을 가로채거나 수정할 수 있습니다.
셋째, 예외를 캐치하고 처리할 수 있습니다. 넷째, 실행 여부를 동적으로 결정할 수 있습니다.
이러한 특징들이 복잡한 횡단 관심사를 간단하게 구현할 수 있게 해줍니다.
코드 예제
@Aspect
@Component
public class CachingAspect {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
@Around("@annotation(cacheable)")
public Object cacheResult(ProceedingJoinPoint joinPoint, Cacheable cacheable) throws Throwable {
// 캐시 키 생성 (메서드명 + 파라미터)
String cacheKey = joinPoint.getSignature().toShortString() +
Arrays.toString(joinPoint.getArgs());
// 캐시에 있으면 바로 반환
if (cache.containsKey(cacheKey)) {
System.out.println("캐시 히트: " + cacheKey);
return cache.get(cacheKey);
}
// 캐시에 없으면 실제 메서드 실행
System.out.println("캐시 미스: " + cacheKey);
Object result = joinPoint.proceed();
// 결과를 캐시에 저장
cache.put(cacheKey, result);
return result;
}
}
설명
이것이 하는 일: 이 코드는 @Cacheable 어노테이션이 붙은 메서드의 결과를 자동으로 캐싱하여, 같은 파라미터로 호출되면 실제 메서드를 실행하지 않고 캐시된 결과를 반환합니다. 첫 번째로, @Around 어노테이션의 파라미터로 Cacheable 어노테이션을 받아옵니다.
이렇게 하면 Pointcut에 매칭된 메서드에 붙은 어노테이션 정보에 접근할 수 있습니다. ConcurrentHashMap을 사용하여 스레드 안전한 캐시를 구현합니다.
두 번째로, joinPoint.getSignature()와 joinPoint.getArgs()를 조합하여 유니크한 캐시 키를 생성합니다. 메서드 이름과 파라미터가 같으면 같은 결과를 반환한다는 가정하에 캐싱합니다.
캐시에 해당 키가 있는지 확인하고, 있으면 즉시 반환하여 실제 메서드 실행을 건너뜁니다. 세 번째로, 캐시에 없는 경우에만 joinPoint.proceed()를 호출하여 실제 메서드를 실행합니다.
이때 원본 메서드가 실행되어 데이터베이스 조회나 복잡한 계산이 수행됩니다. proceed()의 반환값이 원본 메서드의 결과입니다.
마지막으로, 실행 결과를 캐시에 저장하고 호출자에게 반환합니다. 최종적으로 다음번에 같은 파라미터로 호출되면 캐시에서 바로 결과를 반환하여 성능을 크게 향상시킵니다.
여러분이 이 코드를 사용하면 메서드에 @Cacheable만 붙이면 자동으로 캐싱이 적용되어, 데이터베이스 부하를 줄이고 응답 속도를 높일 수 있습니다. 캐싱 로직과 비즈니스 로직이 완전히 분리되어 코드가 깔끔해지고, 캐싱 전략을 변경할 때도 Aspect만 수정하면 됩니다.
실전 팁
💡 proceed()를 호출하지 않으면 원본 메서드가 실행되지 않으므로, 권한 체크나 조건부 실행에 활용할 수 있습니다.
💡 try-catch로 proceed()를 감싸면 재시도 로직을 구현할 수 있습니다. 예외 발생 시 proceed()를 다시 호출하면 됩니다.
💡 Around Advice는 반드시 Object를 반환하고 Throwable을 던져야 합니다. 이는 모든 타입의 메서드를 처리하기 위함입니다.
💡 여러 Around Advice가 중첩되면 실행 순서가 중요합니다. @Order로 순서를 명확히 지정하세요.
💡 성능 측정, 트랜잭션, 캐싱처럼 전후 처리가 모두 필요한 경우에만 Around를 사용하고, 단순한 경우는 Before나 After를 사용하는 것이 더 효율적입니다.
4. Before와 After Advice
시작하며
여러분이 모든 API 호출에 대해 요청 로그를 남기고, 응답 후에는 성공 여부를 기록하고 싶다면 어떻게 해야 할까요? Around Advice를 사용할 수도 있지만, 단순히 전이나 후에만 동작하면 되는 경우에는 과도하게 복잡합니다.
이런 경우에는 더 간단한 해결책이 있습니다. 메서드 실행 흐름을 제어할 필요 없이 특정 시점에만 부가 기능을 실행하고 싶을 때가 많습니다.
로깅, 검증, 알림 같은 단방향 작업들이 대표적입니다. 바로 이럴 때 필요한 것이 @Before와 @After Advice입니다.
메서드 실행 전이나 후에 간단하게 동작을 추가할 수 있어, 코드가 더 명확하고 이해하기 쉬워집니다.
개요
간단히 말해서, @Before는 메서드 실행 전에, @After는 메서드 실행 후에 동작하는 단순한 형태의 Advice입니다. @Before Advice는 타겟 메서드가 실행되기 전에 먼저 실행됩니다.
파라미터 검증, 로깅, 권한 체크 같은 전처리 작업에 적합합니다. 단, 메서드 실행 자체를 막을 수는 없고 예외를 던져서 중단시킬 수는 있습니다.
@After Advice는 메서드가 정상 종료되든 예외가 발생하든 관계없이 항상 실행됩니다. 예를 들어, 데이터베이스 연결을 열고 닫거나, 통계를 수집하거나, 알림을 보내는 경우에 유용합니다.
기존에는 try-finally 블록으로 전후 처리를 구현했다면, 이제는 @Before와 @After로 선언적으로 분리할 수 있습니다. 이들의 핵심 특징은 첫째, Around보다 의도가 명확하고 간단합니다.
둘째, JoinPoint로 메서드 정보에 접근할 수 있습니다. 셋째, @After는 finally처럼 항상 실행되어 리소스 정리에 적합합니다.
이러한 특징들이 단순한 전후 처리를 더 깔끔하게 만들어줍니다.
코드 예제
@Aspect
@Component
@Slf4j
public class ApiLoggingAspect {
// 메서드 실행 전: 요청 로깅
@Before("execution(* com.example.controller.*.*(..))")
public void logBefore(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("API 호출 시작: {} - 파라미터: {}", methodName, Arrays.toString(args));
// 요청 시간을 ThreadLocal에 저장 (After에서 사용)
RequestContext.setStartTime(System.currentTimeMillis());
}
// 메서드 실행 후: 완료 로깅 (정상/예외 모두)
@After("execution(* com.example.controller.*.*(..))")
public void logAfter(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
long executionTime = System.currentTimeMillis() - RequestContext.getStartTime();
log.info("API 호출 완료: {} - 실행 시간: {}ms", methodName, executionTime);
// ThreadLocal 정리
RequestContext.clear();
}
}
설명
이것이 하는 일: 이 코드는 모든 컨트롤러 메서드 호출 전에 요청 정보를 로깅하고, 호출 후에는 실행 시간과 함께 완료 로그를 남깁니다. 첫 번째로, @Before Advice인 logBefore 메서드가 컨트롤러 메서드 실행 직전에 자동으로 호출됩니다.
JoinPoint를 통해 메서드 이름과 파라미터 정보를 가져와 로깅합니다. 이렇게 하면 어떤 API가 어떤 데이터로 호출되었는지 추적할 수 있습니다.
두 번째로, ThreadLocal을 활용한 RequestContext에 요청 시작 시간을 저장합니다. ThreadLocal은 각 스레드마다 독립적인 저장공간을 제공하므로, 멀티스레드 환경에서도 안전하게 데이터를 공유할 수 있습니다.
이렇게 저장한 시간은 @After Advice에서 사용됩니다. 세 번째로, @After Advice인 logAfter 메서드가 컨트롤러 메서드 실행 후에 자동으로 호출됩니다.
정상 종료든 예외 발생이든 관계없이 항상 실행되므로, finally 블록과 같은 역할을 합니다. RequestContext에서 시작 시간을 가져와 실행 시간을 계산합니다.
마지막으로, 완료 로그를 남기고 ThreadLocal을 정리합니다. ThreadLocal은 메모리 누수의 원인이 될 수 있으므로 사용 후 반드시 clear() 해야 합니다.
최종적으로 모든 API 호출이 자동으로 로깅되어 모니터링과 디버깅이 쉬워집니다. 여러분이 이 코드를 사용하면 컨트롤러 메서드에 로깅 코드를 전혀 작성하지 않아도 모든 API 호출이 자동으로 로깅됩니다.
요청과 응답의 흐름을 쉽게 추적할 수 있고, 성능 병목 지점을 빠르게 파악할 수 있습니다. 로깅 형식을 변경하고 싶을 때도 Aspect만 수정하면 모든 API에 일괄 적용됩니다.
실전 팁
💡 @Before에서 예외를 던지면 타겟 메서드가 실행되지 않습니다. 이를 활용하여 유효성 검증이나 권한 체크를 구현할 수 있습니다.
💡 @After는 finally처럼 항상 실행되므로 리소스 정리, 로깅, 통계 수집에 적합합니다.
💡 @AfterReturning은 정상 종료 시에만, @AfterThrowing은 예외 발생 시에만 실행됩니다. 상황에 따라 적절히 선택하세요.
💡 JoinPoint는 읽기 전용입니다. 파라미터를 수정하거나 실행을 제어하려면 Around Advice를 사용해야 합니다.
💡 ThreadLocal 사용 시 반드시 clear()를 호출하여 메모리 누수를 방지하세요. 특히 스레드 풀 환경에서는 필수입니다.
5. AfterThrowing Advice
시작하며
여러분의 애플리케이션에서 예외가 발생했을 때 자동으로 알림을 보내거나, 상세한 에러 로그를 남기고 싶다면 어떻게 해야 할까요? 모든 메서드에 try-catch를 추가하는 것은 비효율적이고 코드를 지저분하게 만듭니다.
이런 문제는 운영 환경에서 특히 중요합니다. 예외가 발생했을 때 빠르게 감지하고 대응하지 못하면 서비스 장애로 이어질 수 있습니다.
하지만 모든 메서드에 예외 처리 코드를 넣는 것은 코드 중복을 야기하고, 예외 처리 전략을 변경하기도 어렵습니다. 바로 이럴 때 필요한 것이 @AfterThrowing Advice입니다.
예외가 발생한 시점을 자동으로 감지하여 중앙 집중식 예외 처리를 구현할 수 있습니다. 모든 예외를 한 곳에서 처리하여 일관성을 유지하고 유지보수를 쉽게 만듭니다.
개요
간단히 말해서, @AfterThrowing Advice는 메서드 실행 중 예외가 발생했을 때만 실행되는 특별한 Advice입니다. 타겟 메서드에서 예외가 던져지면 @AfterThrowing Advice가 자동으로 실행됩니다.
발생한 예외 객체에 접근할 수 있어서 예외 타입에 따라 다른 처리를 할 수 있습니다. 예외를 가로채서 다른 예외로 변환하거나, 추가 정보를 로깅하거나, 외부 시스템에 알림을 보낼 수 있습니다.
예를 들어, 데이터베이스 예외가 발생하면 자동으로 관리자에게 이메일을 보내고, 상세한 스택 트레이스를 파일에 기록하는 로직을 쉽게 구현할 수 있습니다. 기존에는 각 메서드마다 try-catch로 예외를 처리했다면, 이제는 @AfterThrowing Advice 하나로 모든 예외를 중앙에서 처리할 수 있습니다.
@AfterThrowing의 핵심 특징은 첫째, 예외 발생 시에만 실행되어 효율적입니다. 둘째, 예외 객체에 직접 접근하여 상세 정보를 얻을 수 있습니다.
셋째, 예외를 변환하거나 추가 처리를 할 수 있습니다. 이러한 특징들이 중앙 집중식 예외 관리를 가능하게 합니다.
코드 예제
@Aspect
@Component
@Slf4j
public class ExceptionHandlingAspect {
@Autowired
private AlertService alertService;
// 모든 service 계층의 예외를 처리
@AfterThrowing(
pointcut = "execution(* com.example.service.*.*(..))",
throwing = "exception"
)
public void handleServiceException(JoinPoint joinPoint, Exception exception) {
String methodName = joinPoint.getSignature().toShortString();
Object[] args = joinPoint.getArgs();
// 예외 타입에 따라 다른 처리
if (exception instanceof DataAccessException) {
log.error("DB 오류 발생: {} - 파라미터: {}", methodName, args, exception);
alertService.sendAlert("데이터베이스 오류", methodName);
} else if (exception instanceof BusinessException) {
log.warn("비즈니스 로직 오류: {} - {}", methodName, exception.getMessage());
} else {
log.error("예상치 못한 오류: {} - {}", methodName, exception.getMessage(), exception);
alertService.sendAlert("시스템 오류", methodName);
}
}
}
설명
이것이 하는 일: 이 코드는 service 계층에서 발생하는 모든 예외를 자동으로 감지하여 예외 타입에 따라 적절한 로깅과 알림 처리를 수행합니다. 첫 번째로, @AfterThrowing 어노테이션의 throwing 속성으로 예외 객체를 받아옵니다.
throwing 속성의 값("exception")과 메서드 파라미터 이름이 일치해야 합니다. 이렇게 하면 실제 발생한 예외 객체에 접근하여 상세 정보를 얻을 수 있습니다.
두 번째로, JoinPoint를 통해 예외가 발생한 메서드의 정보를 가져옵니다. 메서드 이름, 클래스, 파라미터 등을 알 수 있어서 어디서 무엇 때문에 예외가 발생했는지 정확히 파악할 수 있습니다.
이 정보는 디버깅과 모니터링에 매우 중요합니다. 세 번째로, instanceof로 예외 타입을 확인하여 각각 다른 처리를 합니다.
DataAccessException이면 데이터베이스 오류로 판단하고 error 레벨로 로깅하며 즉시 알림을 보냅니다. BusinessException이면 예상된 비즈니스 오류이므로 warn 레벨로만 로깅합니다.
그 외의 예외는 예상치 못한 시스템 오류로 처리합니다. 마지막으로, AlertService를 통해 외부 알림 시스템에 알림을 보냅니다.
최종적으로 심각한 오류는 관리자에게 즉시 알림이 전송되어 빠른 대응이 가능해집니다. 원본 예외는 그대로 던져지므로 상위 레벨의 예외 처리기도 정상 동작합니다.
여러분이 이 코드를 사용하면 service 계층의 모든 메서드에서 발생하는 예외를 자동으로 감지하고 처리할 수 있습니다. 예외 처리 로직이 한 곳에 집중되어 일관성이 유지되고, 새로운 예외 타입이 추가되어도 Aspect만 수정하면 됩니다.
또한 실시간 알림을 통해 장애를 빠르게 인지하고 대응할 수 있습니다.
실전 팁
💡 @AfterThrowing은 예외를 잡아서 처리하는 것이 아니라 예외 발생을 감지만 합니다. 예외는 여전히 상위로 전파됩니다.
💡 throwing 속성을 사용하지 않으면 모든 예외에 반응하지만, Exception 파라미터를 추가하면 특정 타입의 예외만 처리할 수 있습니다.
💡 예외를 완전히 가로채서 처리하려면 @Around를 사용하고 try-catch로 감싸야 합니다.
💡 로깅 레벨(error, warn, info)을 적절히 사용하여 예외의 심각도를 구분하세요. 모든 예외를 error로 로깅하면 진짜 중요한 오류를 놓칠 수 있습니다.
💡 알림 시스템 호출은 비동기로 처리하는 것이 좋습니다. 알림 전송 실패로 인해 원본 예외 처리가 지연되거나 실패하면 안 됩니다.
6. AfterReturning Advice
시작하며
여러분이 모든 API 응답에 공통 헤더를 추가하거나, 민감한 정보를 자동으로 마스킹하고 싶다면 어떻게 해야 할까요? 메서드가 정상적으로 완료되었을 때만 반환값을 가공해야 하는 경우가 많습니다.
이런 요구사항은 보안, 규정 준수, 응답 표준화 등의 이유로 실무에서 자주 발생합니다. 예외가 발생한 경우는 제외하고, 정상적으로 값을 반환할 때만 후처리를 해야 합니다.
하지만 모든 메서드의 반환 직전에 코드를 추가하는 것은 비효율적입니다. 바로 이럴 때 필요한 것이 @AfterReturning Advice입니다.
메서드가 정상적으로 값을 반환할 때만 실행되며, 반환값에 접근하여 검증하거나 가공할 수 있습니다. 응답 데이터의 일관성을 보장하는 강력한 도구입니다.
개요
간단히 말해서, @AfterReturning Advice는 메서드가 정상적으로 값을 반환했을 때만 실행되며, 반환값에 접근할 수 있는 Advice입니다. 메서드가 예외 없이 성공적으로 완료되면 @AfterReturning Advice가 실행됩니다.
returning 속성을 통해 실제 반환값을 파라미터로 받을 수 있어, 반환값을 검증하거나 로깅하거나 변환할 수 있습니다. 다만 기본적으로는 반환값을 수정할 수 없고, 수정하려면 @Around를 사용해야 합니다.
예를 들어, 사용자 정보를 반환하는 모든 메서드에서 자동으로 비밀번호나 주민번호를 마스킹하거나, API 응답 형식을 통일하는 래퍼를 씌우는 작업에 유용합니다. 기존에는 각 메서드의 반환 직전에 후처리 코드를 작성했다면, 이제는 @AfterReturning Advice로 선언적으로 처리할 수 있습니다.
@AfterReturning의 핵심 특징은 첫째, 정상 완료 시에만 실행되어 성공 케이스만 처리합니다. 둘째, 반환값에 직접 접근하여 검증이나 로깅을 할 수 있습니다.
셋째, 반환값 타입으로 필터링하여 특정 타입만 처리할 수 있습니다. 이러한 특징들이 응답 데이터의 품질 관리를 쉽게 만들어줍니다.
코드 예제
@Aspect
@Component
@Slf4j
public class ResponseProcessingAspect {
// 모든 Controller 메서드의 반환값 처리
@AfterReturning(
pointcut = "execution(* com.example.controller.*.*(..))",
returning = "result"
)
public void processResponse(JoinPoint joinPoint, Object result) {
String methodName = joinPoint.getSignature().toShortString();
// 반환값이 null인 경우 경고
if (result == null) {
log.warn("Null 응답 반환: {}", methodName);
return;
}
// 민감정보 마스킹 (UserDTO인 경우)
if (result instanceof UserDTO) {
UserDTO user = (UserDTO) result;
maskSensitiveData(user);
log.info("사용자 정보 반환: {} - UserID: {}", methodName, user.getId());
}
// 응답 크기 로깅
log.info("응답 반환: {} - 타입: {}", methodName, result.getClass().getSimpleName());
}
private void maskSensitiveData(UserDTO user) {
// 주민번호, 비밀번호 등 마스킹
if (user.getSsn() != null) {
user.setSsn(user.getSsn().replaceAll("(\\d{6})(\\d{7})", "$1-*******"));
}
}
}
설명
이것이 하는 일: 이 코드는 모든 컨트롤러 메서드가 정상적으로 값을 반환할 때 자동으로 민감정보를 마스킹하고 응답 정보를 로깅합니다. 첫 번째로, @AfterReturning 어노테이션의 returning 속성으로 반환값을 받아옵니다.
returning 속성의 값("result")과 메서드 파라미터 이름이 일치해야 합니다. 이렇게 하면 실제 반환되는 객체에 접근하여 내용을 검사하거나 수정할 수 있습니다.
두 번째로, 반환값이 null인지 먼저 확인합니다. null 응답은 종종 버그나 잘못된 비즈니스 로직의 신호일 수 있으므로 경고 로그를 남깁니다.
이렇게 자동으로 감지하면 개발 단계에서 빠르게 문제를 발견할 수 있습니다. 세 번째로, instanceof로 반환값의 타입을 확인하여 UserDTO인 경우에만 민감정보 마스킹을 수행합니다.
주민번호의 뒷자리를 별표로 치환하여 보안을 강화합니다. 이 객체가 그대로 클라이언트에게 전달되므로, 여기서 마스킹하면 민감정보 유출을 방지할 수 있습니다.
마지막으로, 응답의 타입과 주요 정보를 로깅합니다. 최종적으로 모든 API 응답이 일관된 보안 정책에 따라 처리되고, 응답 패턴을 분석하여 모니터링할 수 있습니다.
여러분이 이 코드를 사용하면 컨트롤러 메서드마다 민감정보 마스킹 코드를 작성할 필요가 없습니다. 보안 정책이 변경되어도 Aspect만 수정하면 모든 API에 일괄 적용되며, 민감정보 유출 위험을 크게 줄일 수 있습니다.
또한 응답 패턴을 분석하여 API 사용 현황을 파악할 수 있습니다.
실전 팁
💡 @AfterReturning으로는 반환값을 직접 수정할 수 없습니다. 반환값을 변경하려면 @Around를 사용하세요.
💡 returning 속성에 타입을 지정하면 해당 타입의 반환값만 처리할 수 있습니다. 예: returning = "result", Object result 대신 returning = "user", UserDTO user
💡 void 메서드는 반환값이 없으므로 @AfterReturning에서 returning 속성을 사용하면 실행되지 않습니다.
💡 민감정보 마스킹은 반드시 응답 직렬화(JSON 변환) 전에 수행되어야 합니다. 그렇지 않으면 마스킹이 적용되지 않습니다.
💡 성능 모니터링을 위해 응답 크기나 처리 시간을 수집할 때 @AfterReturning이 유용합니다.
7. JoinPoint와 ProceedingJoinPoint
시작하며
여러분이 AOP에서 실제 메서드의 정보에 접근하고 싶다면 어떻게 해야 할까요? 메서드 이름, 파라미터, 대상 객체 등의 정보 없이는 제대로 된 로깅이나 검증을 할 수 없습니다.
이런 문제는 모든 AOP 구현에서 필수적으로 해결해야 합니다. 어떤 메서드가 호출되었는지, 어떤 파라미터로 호출되었는지, 어떤 객체에서 실행되는지 등의 컨텍스트 정보가 있어야 의미 있는 부가 기능을 구현할 수 있습니다.
바로 이럴 때 필요한 것이 JoinPoint와 ProceedingJoinPoint입니다. JoinPoint는 실행 지점의 모든 정보를 담고 있는 객체이고, ProceedingJoinPoint는 여기에 더해 실행 제어 기능까지 제공합니다.
이들을 활용하면 동적이고 유연한 AOP를 구현할 수 있습니다.
개요
간단히 말해서, JoinPoint는 AOP가 적용되는 실행 지점의 정보를 담은 객체이고, ProceedingJoinPoint는 실행 제어 기능이 추가된 확장 버전입니다. JoinPoint는 모든 Advice에서 첫 번째 파라미터로 받을 수 있으며, 메서드 시그니처, 대상 객체, 파라미터 배열 등을 제공합니다.
getSignature()로 메서드 정보를, getArgs()로 실제 파라미터를, getTarget()으로 대상 객체를 얻을 수 있습니다. ProceedingJoinPoint는 @Around에서만 사용 가능하며, proceed() 메서드로 타겟 메서드를 실행할 수 있습니다.
예를 들어, 특정 파라미터 값에 따라 실행 여부를 결정하거나, 파라미터를 동적으로 변경하거나, 결과를 조작하는 등의 고급 기능을 구현할 수 있습니다. 기존에는 리플렉션 API로 복잡하게 메서드 정보를 얻었다면, 이제는 JoinPoint로 간단하게 접근할 수 있습니다.
이들의 핵심 특징은 첫째, 실행 컨텍스트의 모든 정보에 접근할 수 있습니다. 둘째, ProceedingJoinPoint로 실행 흐름을 제어할 수 있습니다.
셋째, 타입 안전한 방식으로 정보를 얻을 수 있습니다. 이러한 특징들이 동적이고 강력한 AOP를 가능하게 합니다.
코드 예제
@Aspect
@Component
@Slf4j
public class DynamicCachingAspect {
private final CacheManager cacheManager;
@Around("@annotation(cacheable)")
public Object dynamicCache(ProceedingJoinPoint pjp, Cacheable cacheable) throws Throwable {
// 메서드 정보 추출
MethodSignature signature = (MethodSignature) pjp.getSignature();
String className = pjp.getTarget().getClass().getSimpleName();
String methodName = signature.getName();
// 파라미터로 캐시 키 생성
Object[] args = pjp.getArgs();
String cacheKey = generateCacheKey(className, methodName, args);
// 캐시 확인
Object cached = cacheManager.get(cacheKey);
if (cached != null) {
log.info("캐시 히트: {} - 키: {}", methodName, cacheKey);
return cached;
}
// 실제 메서드 실행 (파라미터 변경 가능)
log.info("캐시 미스: {} - 실행 중...", methodName);
Object result = pjp.proceed(args); // 원본 또는 수정된 파라미터로 실행
// 반환 타입 확인 후 캐싱
if (result != null && signature.getReturnType() != void.class) {
cacheManager.put(cacheKey, result, cacheable.ttl());
log.info("캐시 저장: {} - TTL: {}초", cacheKey, cacheable.ttl());
}
return result;
}
private String generateCacheKey(String className, String methodName, Object[] args) {
return className + ":" + methodName + ":" + Arrays.toString(args);
}
}
설명
이것이 하는 일: 이 코드는 ProceedingJoinPoint를 활용하여 메서드 정보를 동적으로 추출하고, 파라미터 기반의 캐시 키를 생성하며, 반환 타입을 확인하여 선택적으로 캐싱합니다. 첫 번째로, ProceedingJoinPoint의 getSignature()를 MethodSignature로 캐스팅하여 메서드의 상세 정보를 얻습니다.
MethodSignature는 메서드 이름, 반환 타입, 파라미터 타입 등의 정보를 제공합니다. getTarget()으로 실제 대상 객체의 클래스 이름도 얻어 캐시 키에 포함시킵니다.
두 번째로, getArgs()로 실제 메서드에 전달된 파라미터 배열을 얻습니다. 이 배열의 값들을 조합하여 유니크한 캐시 키를 생성합니다.
같은 메서드라도 파라미터가 다르면 다른 캐시 키를 갖게 되어 올바른 캐싱이 가능합니다. 세 번째로, proceed() 메서드에 파라미터 배열을 전달하여 타겟 메서드를 실행합니다.
파라미터 배열을 수정하면 변경된 값으로 메서드가 실행됩니다. 이 기능으로 입력값 검증이나 변환을 동적으로 수행할 수 있습니다.
마지막으로, MethodSignature의 getReturnType()으로 반환 타입을 확인합니다. void 메서드는 캐싱할 필요가 없으므로 제외하고, 반환값이 있는 경우에만 캐시에 저장합니다.
최종적으로 메서드 시그니처에 따라 동적으로 캐싱 전략을 조정할 수 있습니다. 여러분이 이 코드를 사용하면 메서드의 특성(이름, 파라미터, 반환 타입)에 따라 동적으로 동작을 변경할 수 있습니다.
파라미터를 분석하여 캐시 키를 생성하고, 반환 타입을 확인하여 캐싱 여부를 결정하는 등 매우 유연한 AOP를 구현할 수 있습니다. 또한 실행 정보를 상세히 로깅하여 디버깅도 쉬워집니다.
실전 팁
💡 MethodSignature로 캐스팅하면 getReturnType(), getParameterTypes() 등 메서드 전용 정보에 접근할 수 있습니다.
💡 proceed()에 수정된 파라미터 배열을 전달하면 원본과 다른 값으로 메서드를 실행할 수 있습니다. 입력값 검증이나 변환에 유용합니다.
💡 getThis()는 프록시 객체를, getTarget()은 실제 대상 객체를 반환합니다. 일반적으로 getTarget()을 사용하세요.
💡 JoinPoint는 불변 객체가 아닙니다. getArgs()로 얻은 배열을 수정하면 원본 파라미터가 변경될 수 있으므로 주의하세요.
💡 성능이 중요한 경우 getArgs()를 반복 호출하지 말고 한 번만 호출하여 변수에 저장해두세요.
8. Custom Annotation과 AOP
시작하며
여러분이 특정 메서드에만 AOP를 적용하고 싶은데, 패키지나 클래스 이름으로는 정확히 선택하기 어렵다면 어떻게 해야 할까요? execution 표현식만으로는 복잡한 조건을 표현하기 어렵고, 코드의 의도도 명확하지 않습니다.
이런 문제는 AOP를 실무에서 사용할 때 자주 발생합니다. "이 메서드는 로깅이 필요해", "이 메서드는 권한 체크가 필요해"처럼 메서드별로 다른 AOP를 선택적으로 적용하고 싶은 경우가 많습니다.
패턴 매칭으로는 이런 섬세한 제어가 어렵습니다. 바로 이럴 때 필요한 것이 Custom Annotation과 AOP의 조합입니다.
자신만의 어노테이션을 만들고, 그 어노테이션이 붙은 메서드에만 AOP를 적용하는 방식입니다. 코드의 의도가 명확해지고, 선택적 적용이 매우 쉬워집니다.
개요
간단히 말해서, Custom Annotation은 여러분이 직접 만드는 어노테이션이고, 이를 Pointcut의 선택 조건으로 사용하면 원하는 메서드에만 정확히 AOP를 적용할 수 있습니다. @Target과 @Retention으로 어노테이션의 적용 대상과 유지 기간을 지정하여 커스텀 어노테이션을 만듭니다.
그리고 @annotation Pointcut 지시자로 해당 어노테이션이 붙은 메서드를 선택합니다. 어노테이션에 속성을 추가하면 메타데이터를 전달할 수도 있습니다.
예를 들어, @PerformanceCheck(threshold=1000)처럼 임계값을 지정하거나, @RequireRole("ADMIN")처럼 권한을 지정하는 등 AOP의 동작을 커스터마이징할 수 있습니다. 기존에는 복잡한 execution 표현식으로 메서드를 선택했다면, 이제는 어노테이션만 붙이면 되므로 훨씬 직관적입니다.
Custom Annotation의 핵심 특징은 첫째, 선택적 적용이 매우 쉽고 명확합니다. 둘째, 어노테이션 속성으로 AOP의 동작을 메서드별로 커스터마이징할 수 있습니다.
셋째, 코드의 가독성과 유지보수성이 크게 향상됩니다. 이러한 특징들이 실무에서 가장 많이 사용되는 AOP 패턴을 만듭니다.
코드 예제
// Custom Annotation 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PerformanceCheck {
long threshold() default 1000; // 기본 임계값 1초
String description() default "";
}
// Aspect 구현
@Aspect
@Component
@Slf4j
public class PerformanceCheckAspect {
@Around("@annotation(performanceCheck)")
public Object checkPerformance(ProceedingJoinPoint pjp, PerformanceCheck performanceCheck) throws Throwable {
String methodName = pjp.getSignature().toShortString();
long threshold = performanceCheck.threshold();
// 실행 시간 측정
long startTime = System.currentTimeMillis();
Object result = pjp.proceed();
long executionTime = System.currentTimeMillis() - startTime;
// 임계값 초과 시 경고
if (executionTime > threshold) {
log.warn("성능 경고: {} - 실행 시간: {}ms (임계값: {}ms) - {}",
methodName, executionTime, threshold, performanceCheck.description());
} else {
log.info("정상: {} - 실행 시간: {}ms", methodName, executionTime);
}
return result;
}
}
// 사용 예시
@Service
public class UserService {
@PerformanceCheck(threshold = 500, description = "사용자 목록 조회")
public List<User> findAllUsers() {
// 비즈니스 로직
return userRepository.findAll();
}
@PerformanceCheck(threshold = 2000, description = "대용량 리포트 생성")
public Report generateReport() {
// 무거운 작업
return reportGenerator.generate();
}
}
설명
이것이 하는 일: 이 코드는 @PerformanceCheck 커스텀 어노테이션을 정의하고, 이 어노테이션이 붙은 메서드의 실행 시간을 측정하여 어노테이션에 지정된 임계값을 초과하면 경고를 발생시킵니다. 첫 번째로, @Target(ElementType.METHOD)로 이 어노테이션이 메서드에만 적용될 수 있도록 지정하고, @Retention(RetentionPolicy.RUNTIME)으로 런타임에 어노테이션 정보가 유지되도록 합니다.
RUNTIME이 아니면 리플렉션으로 어노테이션을 읽을 수 없으므로 AOP에서 사용할 수 없습니다. 두 번째로, 어노테이션에 threshold와 description 속성을 정의합니다.
threshold는 성능 임계값을, description은 메서드 설명을 담습니다. default 값을 지정하여 속성을 선택적으로 만들 수 있습니다.
이렇게 하면 메서드마다 다른 임계값을 지정할 수 있어 유연합니다. 세 번째로, @Around Advice의 파라미터로 PerformanceCheck 어노테이션을 받습니다.
이렇게 하면 실제 메서드에 붙은 어노테이션 인스턴스에 접근하여 속성 값을 읽을 수 있습니다. performanceCheck.threshold()로 해당 메서드에 지정된 임계값을 가져옵니다.
마지막으로, 실행 시간을 측정하여 임계값과 비교합니다. 초과하면 warn 레벨로, 정상이면 info 레벨로 로깅합니다.
최종적으로 각 메서드마다 다른 기준으로 성능을 모니터링할 수 있어, 메서드의 특성에 맞는 성능 관리가 가능해집니다. 여러분이 이 코드를 사용하면 메서드에 @PerformanceCheck만 붙이면 자동으로 성능 모니터링이 적용됩니다.
메서드의 중요도나 복잡도에 따라 다른 임계값을 지정할 수 있고, 새로운 메서드에도 어노테이션만 추가하면 됩니다. 코드를 보는 사람도 이 메서드가 성능 모니터링 대상임을 바로 알 수 있어 가독성이 향상됩니다.
실전 팁
💡 어노테이션 속성은 컴파일 타임 상수만 가능합니다. int, String, Enum, Class, 다른 어노테이션, 이들의 배열만 사용할 수 있습니다.
💡 @Inherited를 추가하면 어노테이션이 상속되어 자식 클래스에도 적용됩니다. 하지만 메서드 오버라이딩 시에는 상속되지 않습니다.
💡 여러 커스텀 어노테이션을 조합하여 사용할 수 있습니다. 예: @PerformanceCheck @RequireAuth @Cacheable
💡 Spring의 @Transactional, @Cacheable 등도 모두 이 패턴으로 구현되었습니다. 가장 실용적인 AOP 활용법입니다.
💡 어노테이션 이름은 동사보다는 형용사나 명사를 사용하는 것이 관례입니다. @CheckPerformance보다 @PerformanceCheck가 더 자연스럽습니다.
9. 실무 로깅 시스템
시작하며
여러분의 애플리케이션에 프로덕션 레벨의 로깅 시스템을 구축한다면 어떤 요구사항이 있을까요? 단순히 메서드 이름만 로깅하는 것이 아니라, 요청 ID 추적, 실행 시간 측정, 예외 상세 로깅, 민감정보 마스킹 등 복합적인 기능이 필요합니다.
이런 요구사항은 실제 운영 환경에서 필수적입니다. 장애 발생 시 빠르게 원인을 파악하려면 체계적인 로깅이 뒷받침되어야 합니다.
하지만 모든 메서드에 로깅 코드를 추가하면 비즈니스 로직보다 로깅 코드가 더 많아지는 문제가 발생합니다. 바로 이럴 때 필요한 것이 AOP 기반의 통합 로깅 시스템입니다.
여러 Advice를 조합하여 요청 추적, 성능 측정, 예외 처리 등을 자동화하고, 구조화된 로그를 생성하여 분석과 모니터링을 쉽게 만듭니다.
개요
간단히 말해서, 실무 로깅 시스템은 여러 AOP 기법을 조합하여 요청 추적, 성능 측정, 예외 로깅, 민감정보 보호를 자동화하는 통합 솔루션입니다. MDC(Mapped Diagnostic Context)를 활용하여 요청별 고유 ID를 생성하고, 모든 로그에 자동으로 포함시켜 분산 추적을 가능하게 합니다.
실행 시간을 측정하여 성능 병목을 파악하고, 예외 발생 시 스택 트레이스와 컨텍스트 정보를 상세히 기록합니다. 또한 민감정보는 자동으로 마스킹하여 보안을 유지합니다.
예를 들어, 사용자의 한 번의 요청이 여러 서비스를 거치더라도 동일한 요청 ID로 추적할 수 있어, 장애 발생 시 전체 흐름을 쉽게 파악할 수 있습니다. 기존에는 각 계층마다 로깅 코드를 따로 작성하고 수동으로 연결했다면, 이제는 AOP로 자동화하여 일관성 있고 누락 없는 로깅을 구현할 수 있습니다.
실무 로깅 시스템의 핵심 특징은 첫째, MDC로 요청 ID를 자동 전파하여 분산 추적이 가능합니다. 둘째, 구조화된 로그 형식으로 분석과 모니터링이 쉽습니다.
셋째, 민감정보 자동 마스킹으로 보안 규정을 준수합니다. 넷째, 성능 메트릭을 자동 수집하여 최적화 포인트를 찾을 수 있습니다.
이러한 특징들이 엔터프라이즈급 애플리케이션의 필수 인프라를 제공합니다.
코드 예제
@Aspect
@Component
@Slf4j
@Order(1) // 가장 먼저 실행
public class RequestLoggingAspect {
private static final String REQUEST_ID = "requestId";
private static final String START_TIME = "startTime";
@Before("execution(* com.example.controller.*.*(..))")
public void logRequest(JoinPoint joinPoint) {
// 요청 ID 생성 및 MDC에 저장
String requestId = UUID.randomUUID().toString().substring(0, 8);
MDC.put(REQUEST_ID, requestId);
MDC.put(START_TIME, String.valueOf(System.currentTimeMillis()));
// 요청 정보 로깅
String methodName = joinPoint.getSignature().toShortString();
Object[] args = maskSensitiveData(joinPoint.getArgs());
log.info("[{}] 요청 시작: {} - 파라미터: {}",
requestId, methodName, Arrays.toString(args));
}
@AfterReturning(pointcut = "execution(* com.example.controller.*.*(..))", returning = "result")
public void logResponse(JoinPoint joinPoint, Object result) {
String requestId = MDC.get(REQUEST_ID);
long startTime = Long.parseLong(MDC.get(START_TIME));
long duration = System.currentTimeMillis() - startTime;
log.info("[{}] 요청 성공: {} - 응답 타입: {} - 실행 시간: {}ms",
requestId, joinPoint.getSignature().toShortString(),
result != null ? result.getClass().getSimpleName() : "void", duration);
// MDC 정리
MDC.clear();
}
@AfterThrowing(pointcut = "execution(* com.example.controller.*.*(..))", throwing = "ex")
public void logException(JoinPoint joinPoint, Exception ex) {
String requestId = MDC.get(REQUEST_ID);
long startTime = Long.parseLong(MDC.get(START_TIME));
long duration = System.currentTimeMillis() - startTime;
log.error("[{}] 요청 실패: {} - 예외: {} - 실행 시간: {}ms",
requestId, joinPoint.getSignature().
댓글 (0)
함께 보면 좋은 카드 뉴스
관찰 가능한 마이크로서비스 완벽 가이드
마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.
Prometheus 메트릭 수집 완벽 가이드
Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.
스프링 관찰 가능성 완벽 가이드
Spring Boot 3.x의 Observation API를 활용한 애플리케이션 모니터링과 추적 방법을 초급 개발자 눈높이에서 쉽게 설명합니다. 실무에서 바로 적용할 수 있는 메트릭 수집과 분산 추적 기법을 다룹니다.
Zipkin으로 추적 시각화 완벽 가이드
마이크로서비스 환경에서 분산 추적을 시각화하는 Zipkin의 핵심 개념과 활용 방법을 초급자도 쉽게 이해할 수 있도록 실무 스토리로 풀어낸 가이드입니다. Docker 실행부터 UI 분석까지 단계별로 배웁니다.
Micrometer Tracing 완벽 가이드
분산 시스템에서 요청 흐름을 추적하는 Micrometer Tracing의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 실무 스토리와 비유로 풀어낸 완벽 가이드입니다.