SpringBoot

[SpringBoot] AOP 기반 로깅 시스템 구축하기

감자꾸 2025. 9. 29. 18:50

Spring Boot로 프로젝트를 진행하다 보면, 로그 관리가 점점 중요해진다.
처음에는 @Slf4j 로 콘솔에만 로그를 찍어도 충분하지만,
실제 출시를 준비 중이라면, 다음과 같은 생각이 들게 될 것이다.

  • API 호출 이력을 DB에 남겨야 하지 않을까?
  • 요청/응답 값을 모두 로깅하려면 어떻게 할까?
  • 예외 발생 시 공통으로 처리할 수 없을까?

이런 문제를 해결하기 위해 AOP(관점 지향 프로그래밍) 를 활용해 로그 관리 구조를 구축하였다.

 

우선 AOP의 개념부터 잡아보자.

AOP란?

AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 핵심 비즈니스 로직과 횡단 관심사(cross-cutting concerns)를 분리하는 프로그래밍 기법이다.

  • 핵심 관심사 (Core Concern)
    → 서비스가 본질적으로 제공해야 하는 기능 (예: 주문 결제, 메모 생성, ToDo 관리)
  • 횡단 관심사 (Cross-cutting Concern)
    → 여러 기능 전반에 걸쳐 반복적으로 필요한 기능 (예: 로깅, 보안, 트랜잭션, 성능 모니터링)

주문 결제 API를 구현하다고 예시를 들어보면,,

  • 핵심 관심사 → “사용자가 어떤 상품을 얼마에 결제했는가?”
  • 횡단 관심사 → “결제 요청/응답, 예외 발생 여부를 반드시 기록해야 한다”
  1. AOP를 적용하지 않으면 모든 Controller/Service에 log.info(...) 같은 중복 코드가 들어간다.
  2. AOP를 적용하면 공통 로직(로깅, 예외 처리, 보안) 은 AOP로 분리하고, 핵심 로직만 깔끔하게 남길 수 있다.


2. AOP의 장점

  1. 중복 코드 제거 – 모든 API에 똑같은 로깅 코드를 적을 필요 없음
  2. 관심사 분리 – 핵심 로직과 공통 로직을 분리하여 가독성↑
  3. 유지보수성 향상 – 정책 변경 시 AOP 클래스 하나만 수정하면 됨
  4. 일관성 확보 – 모든 API에 동일한 방식으로 로그/예외 처리 적용

이건 이제 내가 이 프로젝트에서 request 값이 안들어와서 하나하나 로그를 찍어보느라 넣은 코드들인데,, 가독성이 매우 안좋아지기에, 확실히 깔끔하지 않다.

 

이를 개선하고자, AOP 로깅 시스템과 이 로그를 DB에 저장하는 것까지 해보려고 한다.

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-aop'

Log Entity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Log extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private char action;   // C: Controller Enter, R: Controller Return, S: Service, E: Error
    private Long time;     // 실행 시간 (ms)

    @Lob
    private String message; // 로그 요약 메시지

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "memberId", nullable = false)
    private Member member;

    @Builder
    public Log(char action, Long time, String message, Member member) {
        this.action = action;
        this.time = time;
        this.message = message;
        this.member = member;
    }

    public void updateMessage(String message) {
        this.message = message;
    }
}

 

action (char)
→ 로그 성격을 한 글자로 구분(C, R, S, E).
→ 이 부분을 enum으로 받을지 아니면 char로 받을지 고민하였는데, enum보다 저장 공간을 절약, 대량 데이터 처리에 유리하다고 판단하여, char로 선택했다. 비교표는 아래와 같이 정리하였다.

구분 char (1바이트) ENUM
저장 방식 CHAR(1) → 'C', 'R', 'S', 'E' @Enumerated → 문자열("CONTROLLER_ENTER") 또는 숫자(0,1,2)
공간 효율 항상 1바이트 → 대량 로그에 최적 문자열 저장 시 수십 바이트 소모
가독성 단순 코드 (주석/문서 참고 필요) 코드 레벨에서 의미 직관적
유지보수 enum보다 의미 파악 어려울 수 있음 ORDINAL은 순서 변경 시 무결성 깨짐, STRING은 안전하지만 공간 낭비
대량 데이터 처리 효율적 (로그/이벤트 테이블에 적합) 공간 부담 커서 장기 로그 관리 시 비효율

message (@Lob String)
→ 요청/응답/에러 메시지 등 저장.

 

LogService

@Service
@RequiredArgsConstructor
public class LogService {
    private final LogRepository logRepository;

    public void saveLog(Member member, char action, String message) {
        Log log = Log.builder()
                .action(action)
                .time(System.currentTimeMillis())
                .message(message)
                .member(member)
                .build();

        logRepository.save(log);
    }
}

 

이 부분은 단순히 로그를 DB에 저장하기 위한 서비스 로직이다.

 

LogAop

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class LogAop {

    private final LogService logService;

    // 컨트롤러 포인트컷
    @Pointcut("execution(* com.example.demo..controller.*.*(..))")
    public void controllerPointcut() {}

    // 서비스 포인트컷
    @Pointcut("execution(* com.example.demo..service.*.*(..)) && !within(com.offnal.shifterz..service.LogService)")
    public void servicePointcut() {}

    // ───────────── Controller 로그 ─────────────
    @Before("controllerPointcut()")
    public void logControllerEnter(JoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Member member = AuthService.getCurrentMember();

        String params = extractParams(joinPoint);
        String msg = "[Controller] Enter: " + method.getName() + " | Request: " + params;
        log.info(msg);
        logService.saveLog(member, 'C', msg);
    }

    @AfterReturning(value = "controllerPointcut()", returning = "returnObj")
    public void logControllerReturn(JoinPoint joinPoint, Object returnObj) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Member member = AuthService.getCurrentMember();

        String msg = "[Controller] Return: " + method.getName() + " | Response: " + returnObj;
        log.info(msg);
        logService.saveLog(member, 'R', msg);
    }

    @AfterThrowing(value = "controllerPointcut()", throwing = "ex")
    public void logControllerException(JoinPoint joinPoint, Exception ex) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Member member = AuthService.getCurrentMember();

        String msg = "[Controller] Error: " + method.getName() + " | " + ex.getMessage();
        log.error(msg, ex);
        logService.saveLog(member, 'E', msg);
    }

    // ───────────── Service 로그 ─────────────
    @Before("servicePointcut()")
    public void logServiceEnter(JoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        String params = extractParams(joinPoint);
        log.info("[Service] Enter: {} | Request: {}", method.getName(), params);
    }

    @AfterReturning(value = "servicePointcut()", returning = "returnObj")
    public void logServiceReturn(JoinPoint joinPoint, Object returnObj) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        log.info("[Service] Return: {} | Response: {}", method.getName(), returnObj);
    }

    @AfterThrowing(value = "servicePointcut()", throwing = "ex")
    public void logServiceException(JoinPoint joinPoint, Exception ex) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        log.error("[Service] Error: {} | {}", method.getName(), ex.getMessage(), ex);
    }

    // ───────────── 파라미터/응답 추출 ─────────────
    private String extractParams(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        if (args.length == 0) return "No params";

        StringBuilder sb = new StringBuilder();
        for (Object arg : args) {
            if (arg == null) continue;
            sb.append(arg.getClass().getSimpleName())
              .append(":")
              .append(arg.toString())
              .append("; ");
        }
        return sb.toString();
    }
}

 

@Slf4j : 로깅을 위한 Logger 자동 생성 (Lombok)

 

@Aspect : 이 클래스가 AOP 관점(Aspect)임을 선언

 

  • 역할: 클래스 안의 @Before, @AfterReturning, @AfterThrowing, @Around, @After 같은 어드바이스(Advice) 메서드와, @Pointcut 으로 정의한 포인트컷(Pointcut)하나의 단위(Aspect) 로 묶어준다.
  • 적용 범위: 스프링이 관리하는 빈(Bean)들에 대해, 포인트컷으로 지정한 메서드 실행 지점(Join point)에 프록시 기반으로 어드바이스를 넣는다.

 

스프링에서 어떻게 동작하나? (프록시 기반)

  1. 스프링 컨텍스트가 뜰 때, @Aspect 클래스가 스프링 빈으로 등록되어 있으면(보통 @Component 함께 사용) @Aspect가 붙은 클래스를 스캔한다.
  2. 포인트컷에 매칭되는 대상 빈들에 대해 스프링이 프록시 객체를 만든다.
    • 포인트컷에 걸린 대상 클래스(예: MemberService, MemoController)를 그냥 쓰는 게 아니라,
      프록시(Proxy) 객체라는 껍데기를 하나 더 감싸서 빈으로 등록한다.
    • 그래서 컨트롤러나 서비스 빈을 주입받으면 실제 객체가 아니라 프록시 객체가 들어온다.
    • 인터페이스가 있으면 JDK 동적 프록시, 없으면 보통 CGLIB 프록시 사용
  3. 클라이언트가 메서드를 호출하면 → 실제 대상 대신 프록시가 먼저 실행 → 어드바이스를 실행한 뒤 실제 메서드를 호출하거나, 예외/리턴 시점에 추가 동작을 한다.

※ 스프링 AOP는 기본적으로 런타임 프록시 방식이고, 메서드 실행(메서드 단위 Join point) 만 다룬다.
AspectJ의 컴파일/로드타임 위빙과 달리 바이트코드 자체를 바꾸지는 않는다.

자주 쓰는 구성 요소들

조인 포인트(Join point)

어드바이스가 적용될 수 있는 위치

추상적인 개념으로, AOP를 적용할 수 있는 모든 위치.
Spring AOP는 프록시 방식을 사용하므로, 항상 일반 메소드 실행 시점으로 제한된다.

  • @Pointcut: “어떤 메서드들에 적용할까?”를 정규식 같은 표현으로 지정
    • 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능
    • @Pointcut("execution(* com.example..controller.*.*(..))") void controllerPointcut() {}
  • 어드바이스(Advice): “언제/무엇을 할까?”
    • @Before : 대상 메서드 실행 전
    • @AfterReturning : 정상 리턴 후
    • @AfterThrowing : 예외 발생 후
    • @After : 정상/예외 상관없이 종료 후
    • @Around : 전/후를 모두 감싸 실행 시간 측정, 흐름 제어 등 가능
      (파라미터는 ProceedingJoinPoint 필요, proceed() 호출로 실제 실행)
  • JoinPoint / MethodSignature: 실행 중인 메서드의 정보(이름, 파라미터 등) 접근
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
  • 리턴/예외 바인딩:
@AfterReturning(pointcut = "controllerPointcut()", returning = "ret") public void afterReturn(Object ret) { ... } 
@AfterThrowing(pointcut = "controllerPointcut()", throwing = "ex") public void afterThrow(Exception ex) { ... }

 

실행결과

 

 

그럼 이제 기존 로직들에서 로그 코드들을 싹 다 빼주자~

 

 

✅주의할점 - DTO에 @ToString 붙이지 않으면, 아래와 같이 자바 기본 Object.toString() 응답이 온다. 

그래서 위와 같이 정확한 값을 확인하기 위해서 응답(SuccessApiResponse, ErrorApiResponse, API 별 응답)DTO에 @ToString을 붙여주자.. 

[Controller] Return: getTodos | Response: com.offnal.shifterz.global.response.SuccessResponse@4abc0388

 

 

 

참고 블로그

 

AOP의 개념과 @Aspect

그동안 다뤄왔던 프록시 개념을 바탕으로, AOP (Aspect-Oriented-Programming) 의 개념에 대해 알아봅시다.

velog.io