Spring AOP로 구현하는 활동 로그 시스템

dev.hyjang·2025년 9월 11일

지난 몇 년간 개발자로 일하면서 로그 보기는 개발의 시작이자 끝이였습니다.

  • "에러가 발생한 코드는 무엇인지?"
  • "어떤 에러가 난 것인지?"

개발뿐 아니라 유지보수 상황에서도 위 두가지 질문은 끊임없이 반복되었고, 이때마다 "로그"를 보고 문제를 해결하곤 했습니다.

로그는 시스템의 모든 중요한 활동, 특히 API 호출에 대한 상세한 기록을 남기는 것입니다. 이러한 기록이 시스템 유지보수, 개발, 확장에 가장 중요한 단서가 됩니다.


로그 남기기의 여러 방식

@PostMapping("/news/{newsId}/like")
public ResponseEntity<Map<String, Object>> toggleLike(...) {
    log.info("사용자 {}가 {}번 뉴스에 '좋아요'를 눌렀습니다.", userId, newsId);
    return ResponseEntity.ok(response);
}

위와 같은 방식은 실제 업무 중 프로젝트에서도 활용되는 아주 흔한 방식입니다. 로그가 필요한 중요한 코드 주변에 로그를 남기는 코드를 작성하는 방식입니다. 하지만 이 방식은 다음과 같은 명백한 단점을 가집니다.

  • 코드 중복: 모든 메소드에 거의 동일한 로그 기록 코드가 반복적으로 나타납니다.
  • 비즈니스 로직 오염: 컨트롤러는 요청을 받아 서비스를 호출하고 응답을 반환하는 핵심적인 역할에만 집중해야 합니다. 로그 기록과 같은 부가적인 코드가 섞여 들어가면 코드의 가독성과 유지보수성이 크게 저하됩니다.
  • 유지보수의 어려움: 로그 형식을 변경하거나 새로운 정보(예: 실행 시간)를 추가하고 싶을 때, 수십 개의 모든 메소드를 일일이 찾아 수정해야 합니다.

이번에는 프로젝트의 꽃! 로그를 기록하는 코드를 진행 중인 프로젝트에 추가하겠습니다. 객체지향적이면서도 간단하게 컨트롤러의 메소드마다 직접 로그를 기록하는 방법으로 진행해보겠습니다.


객체지향적 '로깅' 분리하기

AOP(Aspect-Oriented Programming)는 애플리케이션의 여러 부분에 공통적으로 나타나는 부가 기능(예: 로깅, 트랜잭션, 보안)을 공통 모듈로 분리하여 관리하는 프로그래밍 패러다임입니다. 저는 이번 프로젝트에서 AOP 관점을 활용하여 '활동 로그 기록'이라는 공통 관심사를 모든 컨트롤러의 비즈니스 로직으로부터 완벽하게 분리하기로 결정했습니다.

구현할 아키텍처는 아래와 같습니다.

  1. 커스텀 어노테이션 생성: 로그를 기록하고 싶은 컨트롤러 메소드에 붙여주기만 하면 되는 어노테이션을 직접 생성해 보겠습니다.

  2. 공통 로깅 클래스 구현: 로깅 어노테이션이 붙은 메소드의 실행을 '가로채서(Advice)', 로그 기록이라는 공통 로직을 실행 전후에 실행시키는 AOP 모듈을 만듭니다.

  3. 동작: 특정 API가 호출되면, Spring은 해당 컨트롤러 메소드에 직접 생성한 로깅 어노테이션이 붙어있는지 확인합니다. 만약 붙어있다면, 메소드를 직접 실행하기 전에 ActivityLogAspect를 먼저 실행하여 로그를 기록하고, 그 후에 원래의 메소드를 실행합니다.

이 방식을 통해 메소드의 핵심 로직을 건드리지 않고 로그를 남길 수 있으며, 추후 로깅 정책 개선 시 공통 로깅 클래스만 관리하면 되어 효율적입니다.


구현

1. AOP 의존성 추가

기본적으로 프로젝트에 AOP 관련 의존성을 추가합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-aop'
}

2. 로그용 커스텀 어노테이션 생성

로그를 적용할 대상을 지정하기 위한 어노테이션을 직접 생성합니다.

@Target(ElementType.METHOD) // 메소드에만 적용 가능
@Retention(RetentionPolicy.RUNTIME) // 런타임에도 정보 유지
public @interface LogActivity {
    String value() default ""; // 활동 설명을 위한 속성
}
  • 메소드에만 적용하겠다는 선언을 합니다.
  • 런타임에도 정보가 유지될 수 있도록 합니다.
  • 이루어지고 있는 활동에 대한 설명을 String 값으로 받도록 합니다.

3. AOP Aspect 구현

공통 로깅 클래스인 ActivityLogAspect 클래스를 구현합니다.

  • @Aspect, @Component: 이 클래스가 Spring Bean으로 관리되는 AOP Aspect임을 선언합니다.
  • @Around("@annotation(logActivity)"): @LogActivity가 붙은 모든 메소드를 타겟으로 지정하고, 해당 메소드의 실행 전후에 개입합니다.
package com.ccp.simple.aop;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Component
@Slf4j
public class ActivityLogAspect {

    @Around("@annotation(logActivity)")
    public Object logActivity(ProceedingJoinPoint joinPoint, LogActivity logActivity) throws Throwable {
        // 1. 요청 정보 수집
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String ip = request.getRemoteAddr();
        String method = request.getMethod();
        String url = request.getRequestURI();
        String activity = logActivity.value();

        // 2. 사용자 정보 수집
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String userId = (authentication != null && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal()))
                ? authentication.getName() : "GUEST";

        // 3. 로그 기록 (메소드 실행 전)
        log.info("[Activity Log] User: {}, IP: {}, Method: {}, URL: {}, Action: {}", userId, ip, method, url, activity);

        try {
            // 4. 원래의 타겟 메소드 실행
            Object result = joinPoint.proceed();
            
            // 5. 로그 기록 (메소드 성공 후)
            log.info("[Activity Log] Completed: {} - Success", activity);
            return result;
        } catch (Throwable throwable) {
            // 6. 로그 기록 (메소드 실패 시)
            log.error("[Activity Log] Failed: {} - Error: {}", activity, throwable.getMessage());
            throw throwable;
        }
    }
}

API 요청시 받을 수 있는 기본적이면서 중요한 정보를 모두 수집할 수 있도록 하였습니다. 또한 메소드 전, 후, 실패 시 모두 로그가 남을 수 있도록 설정하였습니다.

4. 메소드에 어노테이션 적용

    @LogActivity("뉴스 검색")
    @GetMapping("/news/search")
    public List<NewsResponseDto> searchNews(@RequestParam("q") String query) {
        return newsService.searchNews(query);
    }
  • 메소드에 직접 생성한 @LogActivity 을 달고 내용을 작성해줍니다.
  • 이렇게 간단하게 어노테이션을 달기만 해도 API 요청시 어노테이션에서 이루어지는 공통 로깅 클래스의 로직 실행 후 본 메소드의 작업이 이루어집니다.
  • 추후 로그에 해당 메소드가 동작할 때 내용을 확인할 수 있습니다.

결과

실제로 아래와 같이 로그 정보가 남는 것을 확인할 수 있었습니다. 해당 로그 정보만으로도 어떠한 메소드가 실행되었고, 어떠한 이유로 에러가 났는지 명확하게 확인할 수 있었습니다.

2025-09-11T13:58:38.126+09:00  INFO 26216 --- [nio-8080-exec-1] com.ccp.simple.aop.ActivityLogAspect     : [Activity Log] User: anonymousUser, IP: 0:0:0:0:0:0:0:1, Method: POST, URL: /api/login, Action: 사용자 로그인

2025-09-11T13:58:42.820+09:00  INFO 26216 --- [nio-8080-exec-3] com.ccp.simple.aop.ActivityLogAspect     : [Activity Log] Completed: 뉴스 목록 조회 - Success


2025-09-11T13:58:38.412+09:00 ERROR 26216 --- [nio-8080-exec-1] com.ccp.simple.aop.ActivityLogAspect     : [Activity Log] Failed: 사용자 로그인 - Error: 401 UNAUTHORIZED "Invalid credentials"

결론

AOP 방식으로 로그 남기기를 적용한 결과, 아래와 같은 두 가지 큰 성과를 얻었습니다.

  1. 향상된 코드 품질: 로그 기록 코드를 개별적으로 메소드 안에 작성하지 않아도 됩니다. 컨트롤러는 오직 자신의 핵심 책임에만 집중할 수 있게 되어, 코드의 가독성과 유지보수성이 비약적으로 향상되었습니다.

  2. 일관된 로그: 모든 API 호출에 대해 "누가, 어디서, 무엇을, 어떻게" 했는지에 대한 일관된 형식의 로그가 자동으로 기록됩니다. 이는 서비스 운영 중 문제 추적과 사용자 행동 분석에 매우 중요한 기록이 됩니다.

Spring AOP를 활용한 로깅 방식은 여러 모듈에 걸쳐 반복적으로 나타나는 작업을 따로 분리하여 관리합니다. 간단하지만 객체 지향적 프로그래밍을 확실하게 해볼 수 있는 기능 구현이였습니다.

profile
낭만감자

0개의 댓글