AOP(Aspect-Oriented Programming)란?
여러 클래스에 공통으로 필요한 로직(로깅, 트랜잭션, 실행시간 측정 등)을 비즈니스 로직과 분리해서 한 곳에 모아두는 방식
- 만약 모든 api의 실행 시간을 체크해야 한다면, 모든 api에 시간 측정 코드를 넣는게 아닌, 반복되는 부분을 별도 클래스로 빼서, "실행시켜야 할 대상"만 지정하면 자동으로 앞뒤에 끼어들게 만드는 방식
- 우리가 자주 쓰는 @Transactional도 AOP로 구현됨
API 전체 처리 경로
클라이언트 요청
│
▼
① Filter (JwtFilter 등) - 요청 방향
│ - request.setAttribute("userId", ...) 등 세팅
│ - chain.doFilter() 호출
▼
② DispatcherServlet
│ - HandlerMapping으로 어떤 Controller.method를 호출할지 결정
▼
③ Interceptor.preHandle (AdminInterceptor 등)
│ - 권한 체크 등
│ - false면 여기서 즉시 응답 방향으로 turn (Controller 호출 안 됨)
▼ (true인 경우만 진행)
④ ArgumentResolver (AuthUserArgumentResolver 등)
│ - @Auth AuthUser 같은 커스텀 파라미터 조립
│ - @Valid 검증도 이 시점에 함께 수행됨
▼
⑤ Controller
│ - Service 메서드 호출
▼
⑥ AOP (@Around 등, AdminRequestLoggingAop, TimeCheckAop)
│ - Service(또는 지정한 대상) 메서드 실행을 앞뒤로 감쌈
│ - joinPoint.proceed() 호출 시점에 실제 Service 로직 실행
▼
⑦ Service → Repository → DB
│
▼ (다시 위로 리턴하며 거슬러 올라감)
⑥' AOP 후처리
│ - 실행시간 로깅, 응답 데이터 로깅 등
▼
⑤' Controller가 응답 객체 리턴
▼
③' Interceptor.postHandle
│ - View 렌더링 전 (예외 없을 때만)
▼
(예외 발생 시) GlobalExceptionHandler
│ - @ExceptionHandler가 예외를 잡아서 응답으로 변환
▼
③'' Interceptor.afterCompletion
│ - 예외 발생 여부와 무관하게 항상 실행
│ - 로깅, 리소스 정리
▼
② DispatcherServlet이 최종 응답 작성 (JSON 직렬화 등)
▼
① Filter - 응답 방향
│ - chain.doFilter() 이후 코드 실행 (응답 헤더 조작 등)
▼
클라이언트 응답
예시 코드
전체 예시 코드
admin 권한이 필요한 모든 service 메서드에 대해서
처리 시간, 요청자 정보, request body, response body를 로깅하는 메서드
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class AdminAop {
private final TimeUtil timeUtil;
private final ObjectMapper objectMapper;
@Around("execution(* org.example.expert.domain..service..*AdminService.*(..))")
public Object logAdminRequest(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
// 시간
long startTime = System.currentTimeMillis();
// 데이터
Long userId = (Long) request.getAttribute("userId");
String requestUrl = request.getRequestURI();
String requestBody = extractRequestBody(joinPoint.getArgs());
String methodName = joinPoint.getSignature().toShortString();
log.info("[Admin API 요청] userId={}, time={}, url={}, method={}, requestBody={}",
userId, timeUtil.toReadableTime(startTime), requestUrl, methodName, requestBody);
// 실제 메서드 실행 -> Filter에서 doFilter 와 비슷함.
Object result = joinPoint.proceed();
String responseBody = toJson(result); // void면 null이 그대로 찍힘
log.info("[Admin API 응답] userId={}, url={}, responseBody={}",
userId, requestUrl, responseBody);
return result;
}
private String extractRequestBody(Object[] args) {
for (Object arg : args) {
if (arg != null && !isSimpleType(arg)) {
return toJson(arg); // DTO가 있으면 그대로
}
}
// DTO가 없으면(commentId처럼 단순 타입만 있는 경우) 파라미터 전체를 JSON으로 남김
return toJson(args);
}
private boolean isSimpleType(Object arg) {
return arg instanceof Long || arg instanceof Integer
|| arg instanceof String || arg instanceof Boolean;
}
private String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
log.warn("JSON 직렬화 실패: {}", e.getMessage());
return "직렬화 실패";
}
}
}
파트 별 설명
클래스 어노테이션
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class AdminAop {
- @Aspect: 이 클래스가 AOP의 공통 로직(Aspect)임을 선언
Advice - 언제, Pointcut - 무엇을
@Around("execution(* org.example.expert.domain..service..*AdminService.*(..))")
- @Around : Advice라고 불리며, 어느 메서드의 어느 타이밍에 적용될지 결정
- @Around : 실행 전후 모두
- @Before : 실행 전만
- @AfterReturning : 정상 리턴 후 → 응답 값만 로깅하고 싶을 때
- @AfterThrowing : 예외 발생 시 → 실패 케이스만 로깅하고 싶을 때
- execution(~) : Pointcut 표현식이라고 불리며, 어느 메서드에 적용될지 결정
- * (리턴 타입 자리) : 리턴 타입 무관
- org.example.expert.domain..service.. : domain 하위 어디든, service라는 경로가 들어간 곳
- *AdminService : 이름이 AdminService로 끝나는 모든 클래스 (UserAdminService, CommentAdminService 등)
- .*(..) : 그 클래스의 모든 메서드, 파라미터 무관
데이터 정의
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
long startTime = System.currentTimeMillis();
Long userId = (Long) request.getAttribute("userId");
String requestUrl = request.getRequestURI();
String requestBody = extractRequestBody(joinPoint.getArgs());
String methodName = joinPoint.getSignature().toShortString();
- RequestContextHolder.currentRequestAttributes()를 쓰는 이유 : AOP는 HTTP 요청 객체에 직접 접근할 방법이 없어서 현재 스레드에 바인딩된 요청 정보를 꺼내오는 우회 경로가 필요하기 때문
메서드 실행
Object result = joinPoint.proceed(); // 실제 메서드 실행 -> Filter에서 doFilter 와 비슷함.
응답 로깅
String responseBody = toJson(result); // void면 null이 그대로 찍힘
log.info("[Admin API 응답] userId={}, url={}, responseBody={}",
userId, requestUrl, responseBody);
결과
10:16:41.691 [INFO ] : [Admin API 요청] userId=2, time=2026-07-27 10:16:41.690, url=/admin/users/4, method=UserAdminService.changeUserRole(..), requestBody={"role":"ADMIN"}
10:16:41.711 [INFO ] : [Admin API 응답] userId=2, url=/admin/users/4, responseBody=null'Spring > 관련 지식' 카테고리의 다른 글
| 인터셉터(Interceptor)의 개념과 구현 (0) | 2026.07.25 |
|---|---|
| JWT(JSON Web Token) (0) | 2026.07.22 |
| JPA에서 Enum 활용 방법 (ORDINAL vs STRING 등) (0) | 2026.07.21 |
| N+1 문제의 개념과 해결 방법 (0) | 2026.07.09 |
| IoC/DI, 스프링 컨테이너와 Bean의 개념 (0) | 2026.07.07 |