[트러블슈팅] Spring Boot 어드민 API 로깅 시스템 구현 - 인터셉터와 AOP
들어가며
Spring Boot 프로젝트에서 어드민 API에 대한 상세한 로깅 시스템을 구현하면서 겪었던 다양한 트러블슈팅 경험을 공유합니다. 인터셉터와 AOP를 활용한 로깅 시스템 구현 과정에서 마주한 문제점들과 해결 과정을 단계별로 정리했습니다.
요구사항
어드민 사용자만 접근할 수 있는 특정 API에 대해 다음과 같은 로깅 시스템을 구현해야 했습니다:
인터셉터 요구사항:
- 어드민 권한 여부 확인
- 인증되지 않은 사용자 접근 차단 (예외 발생)
- 인증 성공 시 요청 시각과 URL 로깅
AOP 요구사항:
- 요청한 사용자의 ID
- API 요청 시각
- API 요청 URL
- 요청 본문(RequestBody) - JSON 형식
- 응답 본문(ResponseBody) - JSON 형식
초기 설계와 문제점
1차 시도: 복잡한 AOP 구현
처음에는 모든 기능을 AOP에 집중시켜 구현했습니다.
@Around("execution(* org.example.expert.domain.comment.controller.CommentAdminController.deleteComment(..)) || " +
"execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
public Object logAdminApi(ProceedingJoinPoint joinPoint) throws Throwable {
// 복잡한 로직들...
}
문제점:
- 하드코딩된 메서드 경로로 인한 확장성 부족
- 새로운 어드민 API 추가 시마다 AOP 코드 수정 필요
- 코드 가독성 저하
해결: @AdminAudit 어노테이션 기반 AOP
// 커스텀 어노테이션 생성
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminAudit {
String description() default "";
}
// AOP에서 어노테이션 기반 적용
@Around("@annotation(AdminAudit)")
public Object logAdminApi(ProceedingJoinPoint joinPoint) throws Throwable {
// 로깅 로직
}
// 컨트롤러에서 사용
@AdminAudit(description = "댓글 삭제")
@DeleteMapping("/admin/comments/{commentId}")
public void deleteComment(@PathVariable long commentId) {
// 비즈니스 로직
}
트러블슈팅 1: AOP 표현식 인식 오류
문제 상황
@Around("@annotation(AdminAudit)") // 심볼을 해결할 수 없음
에러 메시지: 'AdminAudit'을(를) 해결할 수 없습니다
해결 과정
원인 분석: AOP 표현식에서는 import와 상관없이 전체 패키지 경로가 필요함
해결 방법:
@Around("@annotation(org.example.expert.config.annotation.AdminAudit)")
AOP 표현식은 클래스의 import문과 독립적으로 동작하므로 항상 FQCN(Fully Qualified Class Name)을 사용해야 합니다.
트러블슈팅 2: 어노테이션 적용 오류
문제 상황
@AdminAudit(description = "댓글 삭제") // 메서드에 적용할 수 없음
@DeleteMapping("/admin/comments/{commentId}")
public void deleteComment(@PathVariable long commentId) {
}
에러 메시지: '@AdminAudit'을(를) 메서드에 적용할 수 없습니다
해결 과정
원인 분석: 어노테이션의 @Target 설정이 잘못됨
@Target(ElementType.TYPE) // ← 클래스에만 적용 가능
해결 방법:
@Target(ElementType.METHOD) // ← 메서드에 적용 가능
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminAudit {
String description() default "";
}
@Target과 @Retention 어노테이션의 정확한 이해가 필요하다고 느꼈습니다.
트러블슈팅 3: JPA Repository 메서드명 해석 오류
문제 상황
AOP 구현 후 애플리케이션 실행 시 다음 에러 발생:
No property 'withUser' found for type 'Long'; Traversed path: Todo.id
원인: TodoRepository의 메서드명을 Spring Data JPA가 잘못 해석
@EntityGraph(attributePaths = {"user"})
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
해결 과정
분석: Spring Data JPA가 findByIdWithUser를 findBy + Id + WithUser로 해석하여 WithUser라는 속성을 찾으려 시도
해결 방법: 메서드명 변경
@EntityGraph(attributePaths = {"user"})
Optional<Todo> findTodoById(Long todoId);
트러블슈팅 4: 과도한 복잡성 문제
문제 상황
초기 AOP 구현이 너무 복잡해졌습니다:
- 5개 메서드 (logAdminApi, safeLogBefore, safeLogAfter, extractRequestBody, extractResponseBody)
- 150줄 이상의 코드
- 과도한 예외 처리
해결 과정
리팩토링 원칙: "필요한 만큼만, 심플하게"
최종 간소화 버전:
@Around("@annotation(AdminAudit)")
public Object logAdminApi(ProceedingJoinPoint joinPoint) throws Throwable {
try {
HttpServletRequest request = getCurrentRequest();
// 요청 로깅
log.info("요청한 사용자의 ID: {}", request.getAttribute("userId"));
log.info("API 요청 시각: {}", getCurrentTime());
log.info("API 요청 URL: {} {}", request.getMethod(), request.getRequestURI());
log.info("요청 본문: {}", getRequestBodyJson(joinPoint.getArgs()));
// 메서드 실행
Object result = joinPoint.proceed();
// 응답 로깅
log.info("응답 본문: {}", getResponseBodyJson(result, joinPoint));
return result;
} catch (Exception e) {
log.warn("로깅 실패: {}", e.getMessage());
return joinPoint.proceed();
}
}
과도한 추상화보다는 심플하고 이해하기 쉬운 코드가 더 좋다고 생각합니다.
트러블슈팅 6: 성능 vs 기능성 고민
문제 상황
인터셉터 적용 범위에 대한 고민:
- 모든 요청에 적용하면 성능 오버헤드
- 복잡한 조건 체크 vs 경로 기반 필터링
해결 과정
성능 최적화:
// WebConfig에서 경로 기반 필터링
registry.addInterceptor(adminLoggingInterceptor)
.addPathPatterns("/admin/**"); // 어드민 API만 정확히 타겟팅
결과:
- 전체 트래픽의 1% 미만인 어드민 API에만 적용
- 99%의 오버헤드 제거
- 명확한 책임 분리
최종 아키텍처
역할 분리
- Interceptor: HTTP 레벨 권한 체크 + 기본 로깅
- AOP: 메서드 레벨 상세 요청/응답 로깅
실행 순서
HTTP 요청 → JwtFilter → AdminLoggingInterceptor → AOP → Controller
최종 코드 구조
src/main/java/org/example/expert/
├── config/
│ ├── annotation/
│ │ └── AdminAudit.java
│ ├── aop/
│ │ └── AdminApiLoggingAspect.java
│ ├── interceptor/
│ │ └── AdminLoggingInterceptor.java
│ └── WebConfig.java
결론
핵심
- AOP 표현식은 FQCN 사용 - import와 독립적으로 동작
- 어노테이션 설계 시 @Target과 @Retention 신중하게 선택
- Spring Data JPA 커스텀 메서드명 사용 시 @Query 명시
- void 메서드 처리는 메서드 시그니처로 판단
- 과도한 추상화보다는 심플함 추구
- 성능 최적화는 경로 기반 필터링으로
실무 적용 포인트
- 확장성: 어노테이션 기반으로 새로운 API 추가 시 수정 불필요
- 안전성: 로깅 실패가 비즈니스 로직에 영향을 주지 않음
- 관찰성: 요구사항에 맞는 상세한 로깅 제공
- 유지보수성: 명확한 역할 분리로 코드 이해도 향상
실무에서 자주 발생하는 다양한 트러블슈팅 경험을 통해 Spring Boot의 인터셉터와 AOP에 대한 깊은 이해를 얻을 수 있었습니다. 저만의 코드 선택 기준을 정하고 있는데 오늘 이 트러블슈팅을 통해 저만의 기준을 어느정도 잡은거같아 뿌듯합니다.