Spring Boot로 프로젝트를 진행하다 보면, 로그 관리가 점점 중요해진다.
처음에는 @Slf4j 로 콘솔에만 로그를 찍어도 충분하지만,
실제 출시를 준비 중이라면, 다음과 같은 생각이 들게 될 것이다.
- API 호출 이력을 DB에 남겨야 하지 않을까?
- 요청/응답 값을 모두 로깅하려면 어떻게 할까?
- 예외 발생 시 공통으로 처리할 수 없을까?
이런 문제를 해결하기 위해 AOP(관점 지향 프로그래밍) 를 활용해 로그 관리 구조를 구축하였다.
우선 AOP의 개념부터 잡아보자.
AOP란?
AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 핵심 비즈니스 로직과 횡단 관심사(cross-cutting concerns)를 분리하는 프로그래밍 기법이다.
- 핵심 관심사 (Core Concern)
→ 서비스가 본질적으로 제공해야 하는 기능 (예: 주문 결제, 메모 생성, ToDo 관리) - 횡단 관심사 (Cross-cutting Concern)
→ 여러 기능 전반에 걸쳐 반복적으로 필요한 기능 (예: 로깅, 보안, 트랜잭션, 성능 모니터링)
주문 결제 API를 구현하다고 예시를 들어보면,,
- 핵심 관심사 → “사용자가 어떤 상품을 얼마에 결제했는가?”
- 횡단 관심사 → “결제 요청/응답, 예외 발생 여부를 반드시 기록해야 한다”
- AOP를 적용하지 않으면 모든 Controller/Service에 log.info(...) 같은 중복 코드가 들어간다.
- AOP를 적용하면 공통 로직(로깅, 예외 처리, 보안) 은 AOP로 분리하고, 핵심 로직만 깔끔하게 남길 수 있다.
2. AOP의 장점
- 중복 코드 제거 – 모든 API에 똑같은 로깅 코드를 적을 필요 없음
- 관심사 분리 – 핵심 로직과 공통 로직을 분리하여 가독성↑
- 유지보수성 향상 – 정책 변경 시 AOP 클래스 하나만 수정하면 됨
- 일관성 확보 – 모든 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)에 프록시 기반으로 어드바이스를 넣는다.
스프링에서 어떻게 동작하나? (프록시 기반)
- 스프링 컨텍스트가 뜰 때, @Aspect 클래스가 스프링 빈으로 등록되어 있으면(보통 @Component 함께 사용) @Aspect가 붙은 클래스를 스캔한다.
- 포인트컷에 매칭되는 대상 빈들에 대해 스프링이 프록시 객체를 만든다.
- 포인트컷에 걸린 대상 클래스(예: MemberService, MemoController)를 그냥 쓰는 게 아니라,
프록시(Proxy) 객체라는 껍데기를 하나 더 감싸서 빈으로 등록한다. - 그래서 컨트롤러나 서비스 빈을 주입받으면 실제 객체가 아니라 프록시 객체가 들어온다.
- 인터페이스가 있으면 JDK 동적 프록시, 없으면 보통 CGLIB 프록시 사용
- 포인트컷에 걸린 대상 클래스(예: MemberService, MemoController)를 그냥 쓰는 게 아니라,
- 클라이언트가 메서드를 호출하면 → 실제 대상 대신 프록시가 먼저 실행 → 어드바이스를 실행한 뒤 실제 메서드를 호출하거나, 예외/리턴 시점에 추가 동작을 한다.
※ 스프링 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
'SpringBoot' 카테고리의 다른 글
| [SpringBoot] AOP 기반 로깅 시스템 구현하기 - 로그와 비즈니스 로직 트랜잭션은 분리되지만 예외는 공유된다. (0) | 2025.10.14 |
|---|---|
| [SpringBoot] JPA / Hibernate / Spring Data JPA (0) | 2025.10.13 |
| [SpringBoot] 금액권을 Toss 토스 결제 후, QR코드로 생성하기 (0) | 2025.09.01 |
| [SpringBoot] TOSS/토스 결제 API 연동하기 (5) | 2025.07.30 |
| [트러블 슈팅]FastAPI+Spring Boot EC2+Docker+Nginx (3) | 2025.07.21 |