4.1 Spring AOP

AOP(Aspect-Oriented Programming)는 횡단 관심사를 분리하는 프로그래밍 기법입니다.

4.1.1 횡단 관심사의 예

로깅을 직접 추가하는 경우:

@RestController
public class UserController {
	public User getUser(Long id) {
	
	log.info("Request: getUser({})", id); // 로깅 코드
	long start = System.currentTimeMillis();
	User user = userService.findById(id);
	long duration = System.currentTimeMillis() - start;
	log.info("Response: getUser -> {} ({}ms)", user, duration); // 로깅 코드
 
	return user;
}
 
public void createUser(UserRequest request) {
	log.info("Request: createUser({})", request); // 로깅 코드
	long start = System.currentTimeMillis();
	userService.create(request);
	long duration = System.currentTimeMillis() - start;
	log.info("Response: createUser ({}ms)", duration); // 로깅 코드
}
 
// 모든 메서드에 똑같은 로깅 코드가 반복됨...
}

AOP를 사용하는 경우:

@RestController
public class UserController {
	// 로깅 코드가 없어도 자동으로 로그 출력!
	public User getUser(Long id) {
		return userService.findById(id);
	}
	
	public void createUser(UserRequest request) {
		userService.create(request);
	}
}

4.2 MethodLoggingAspect 구조

파일: MethodLoggingAspect.java

@Aspect
@Slf4j
@RequiredArgsConstructor
public class MethodLoggingAspect {
	private final ObjectMapper objectMapper;
	
	@Value("${dependency.logger.stacktrace.max-lines:-1}")
	private int maxStackTraceLines;
 
	private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_INSTANT;
}

4.3 Pointcut 정의

4.3.1 Controller 로깅

@Around("within(@org.springframework.web.bind.annotation.RestController *) " +
"&& execution(public * *(..)) " + "&& !@annotation(a306.dependency_logger_starter.logging.annotation.NoLogging) " +
"&& !@within(a306.dependency_logger_starter.logging.annotation.NoLogging)")
public Object logControllerMethods(ProceedingJoinPoint joinPoint) throws Throwable {
	return logMethodExecution(joinPoint);
}

Pointcut 표현식 분석:

표현식의미
within(@RestController *)@RestController 클래스 안의
execution(public * *(..))모든 public 메서드
!@annotation(NoLogging)@NoLogging이 없는
!@within(NoLogging)클래스에 @NoLogging이 없는

4.3.2 Service 로깅

@Around("within(@org.springframework.stereotype.Service *) " + "&& execution(public * *(..)) " + "&& !@annotation(a306.dependency_logger_starter.logging.annotation.NoLogging) " +
"&& !@within(a306.dependency_logger_starter.logging.annotation.NoLogging)")
public Object logServiceMethods(ProceedingJoinPoint joinPoint) throws Throwable {
	return logMethodExecution(joinPoint);
}

4.3.3 Repository 로깅

Class-based Repository:

@Around("within(@org.springframework.stereotype.Repository *) " + "&& execution(public * *(..)) " + "&& !@annotation(a306.dependency_logger_starter.logging.annotation.NoLogging) " +
"&& !@within(a306.dependency_logger_starter.logging.annotation.NoLogging)")
public Object logRepositoryMethods(ProceedingJoinPoint joinPoint) throws Throwable {
	return logMethodExecution(joinPoint);
}

Interface-based Repository (JPA):

@Around("target(org.springframework.data.repository.Repository) " + "&& !@annotation(a306.dependency_logger_starter.logging.annotation.NoLogging) " +
"&& !@within(a306.dependency_logger_starter.logging.annotation.NoLogging)")
public Object logJpaRepositoryMethods(ProceedingJoinPoint joinPoint) throws Throwable {
	return logMethodExecution(joinPoint);
}

target() vs within():

  • within(): 클래스 선언 기준
  • target(): 런타임 객체 기준 (프록시 포함)

4.3.4 Component 로깅

@Around("within(@org.springframework.stereotype.Component *) " +
"&& execution(public * *(..)) " + "&& !@annotation(a306.dependency_logger_starter.logging.annotation.NoLogging) " +
"&& !@within(a306.dependency_logger_starter.logging.annotation.NoLogging)")
public Object logComponentMethods(ProceedingJoinPoint joinPoint) throws Throwable {
	return logMethodExecution(joinPoint);
}

4.3.5 명시적 로깅 어노테이션

@Around("@annotation(a306.dependency_logger_starter.logging.annotation.LogMethodExecution)")
public Object logAnnotatedMethod(ProceedingJoinPoint joinPoint) throws Throwable {
	return logMethodExecution(joinPoint);
}

사용 예시:

public class MyUtil { // 어노테이션 없는 일반 클래스
	@LogMethodExecution // 이 메서드만 로깅
	public void importantMethod() {
		// ...
	}
}

4.4 메서드 실행 로깅 프로세스

4.4.1 전체 흐름

private Object logMethodExecution(ProceedingJoinPoint joinPoint) throws Throwable {
	long startTime = System.currentTimeMillis();
	MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Class<?> targetClass = ClassUtils.getUserClass(joinPoint.getTarget().getClass());
	String methodName = signature.getMethod().getName();
	String packageName = targetClass.getName();
	String componentName = targetClass.getSimpleName();
	String layer = detectLayer(targetClass);
 
	// Repository 프록시 처리
	String actualRepositoryName = null;
	if ("REPOSITORY".equals(layer)) {
		actualRepositoryName = extractActualRepositoryName(joinPoint.getTarget());
		if (actualRepositoryName != null) {
					MDC.put("actual_repository_name", actualRepositoryName); 
					componentName = actualRepositoryName;
					packageName = extractRepositoryPackage(joinPoint.getTarget());
		}
	}
 
	HttpInfo httpInfo = extractHttpInfo();
	Map<String, Object> parameters = collectParameters(signature, joinPoint.getArgs());
 
	logRequest(packageName, componentName, layer, methodName, parameters, httpInfo);
 
	Object result = null;
	Throwable exception = null;
	
	try {
		result = joinPoint.proceed();
	} catch (Throwable e) {
		exception = e;
		throw e;
	} finally {
		long executionTime = System.currentTimeMillis() - startTime;
		Object responseData = (exception == null) ? collectResponse(result) : null;
		if (httpInfo != null) {
			httpInfo.updateStatusCode();
		}
		
		logResponse(packageName, componentName, layer, methodName,
		responseData, executionTime, exception, httpInfo);
	}
	
	return result;
}

실행 단계:

  1. 메서드 정보 수집 (시작 시간, 클래스명, 메서드명, 계층)
  2. HTTP 정보 추출 (Controller만 해당)
  3. 파라미터 수집 (민감 정보 마스킹 포함)
  4. Request 로그 출력
  5. 실제 메서드 실행 (joinPoint.proceed())
  6. 실행 시간 계산
  7. Response 로그 출력 (성공 시) 또는 Exception 로그 출력 (실패 시)

4.4.2 JPA Repository 프록시 처리

JPA Repository는 프록시 객체이므로 실제 인터페이스 이름을 추출해야 합니다.

private String extractActualRepositoryName(Object target) {
	if (target == null) {
		return null;
	}
 
	try {
		Class<?>[] interfaces = target.getClass().getInterfaces();
		for (Class<?> iface : interfaces) {
			String name = iface.getSimpleName();
	
			// Spring Data의 내부 인터페이스 제외
			if (name.endsWith("Repository") && !name.equals("Repository") && !name.equals("JpaRepository") && !name.equals("CrudRepository") && !name.equals("PagingAndSortingRepository") && !name.equals("QueryByExampleExecutor") && !name.equals("JpaSpecificationExecutor")) {
	
				log.debug("실제 Repository 발견: {}", name);
				return name;
			}
		}
	} catch (Exception e) {
		log.debug("Repository 이름 추출 실패: {}", e.getMessage());
	}
	return null;
}

예시:

// 사용자 코드
public interface UserRepository extends JpaRepository<User, Long> { }
 
// 런타임 객체
// class: com.sun.proxy.$Proxy123
// interfaces: [UserRepository, JpaRepository, Repository, ...]
 
// 추출 결과: "UserRepository"

4.4.3 HTTP 정보 추출

private HttpInfo extractHttpInfo() {
	try {
		ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
 
		if (attributes != null) {
			HttpServletRequest request = attributes.getRequest();
			return new HttpInfo(request.getMethod(), request.getRequestURI(),
					request.getQueryString(), attributes);
		}
	} catch (Exception e) {
		log.debug("HTTP 정보 추출 실패: {}", e.getMessage());
	}
	return null;
}
 
private static class HttpInfo {
	final String method;
	final String uri;
	final String queryString;
	Integer statusCode;
	final ServletRequestAttributes attributes;
 
	HttpInfo(String method, String uri, String queryString,
	ServletRequestAttributes attributes) {
		this.method = method;
		this.uri = uri;
		this.queryString = queryString;
		this.attributes = attributes;
		this.statusCode = null;
	}
	
	void updateStatusCode() {
		try {
			if (attributes != null && attributes.getResponse() != null) {
				this.statusCode = attributes.getResponse().getStatus();
			}
		} catch (Exception e) {
			// 무시
		}
	}
}

주의사항: RequestContextHolder는 HTTP 요청 컨텍스트가 있을 때만 작동합니다.

  • Controller: HTTP 정보 있음 ✅
  • Service: HTTP 정보 없음 (null) ❌
  • Repository: HTTP 정보 없음 (null) ❌

4.4.4 파라미터 수집 및 민감 정보 처리

private Map<String, Object> collectParameters(MethodSignature signature,
Object[] args) {
	Map<String, Object> parameters = new LinkedHashMap<>();
	
	if (args == null || args.length == 0) {
		return parameters;
	}
	
	String[] parameterNames = signature.getParameterNames();
	Class<?>[] parameterTypes = signature.getParameterTypes();
	Annotation[][] parameterAnnotations = signature.getMethod().getParameterAnnotations();
  
	for (int i = 0; i < args.length; i++) {
		String paramName = (parameterNames != null && i < parameterNames.length) ? parameterNames[i] : "arg" + i;
 
		// Spring 프레임워크 클래스는 제외
		if (TypeChecker.isFrameworkClass(parameterTypes[i])) {
			continue;
		}
 
		Annotation[] annotations = parameterAnnotations[i];
  
		// @ExcludeValue: 로그에서 완전히 제외
		if (hasAnnotation(annotations, ExcludeValue.class)) {
			parameters.put(paramName, ValueProcessor.getExcludedValue());
			continue;
		}
  
		// @Sensitive: 마스킹 처리
		if (hasAnnotation(annotations, Sensitive.class)) {
			parameters.put(paramName, ValueProcessor.getMaskedValue());
			continue;
		}
 
		parameters.put(paramName, ValueProcessor.processValue(args[i]));
	}
 
	return parameters;
}
 
private boolean hasAnnotation(Annotation[] annotations, Class<? extends Annotation> annotationClass) {
	for (Annotation annotation : annotations) {
		if (annotation.annotationType() == annotationClass) {
			return true;
		}
	}
	return false;
}

어노테이션별 처리:

어노테이션출력 결과사용 예시
없음실제 값userId: 123
@Sensitive"***"password: "***"
@ExcludeValue"[EXCLUDED]"fileData: "[EXCLUDED]"

사용 예시:

@RestController
public class AuthController {
	public TokenResponse login(String username, @Sensitive String password // ← 로그에 "***"로 출력) {
		// ...
	}
 
	public void uploadFile(Long userId, @ExcludeValue byte[] fileData // ← 로그에서 제외) {
		// ...
	}
}

4.5 로그 출력 형식

4.5.1 Request 로그

private void logRequest(String packageName, String componentName, String layer, String methodName, Map<String, Object> parameters, HttpInfo httpInfo) {
	try {
		Map<String, Object> logEntry = createBaseLogEntry(packageName, componentName, layer);
		
		logEntry.put("message", "Request received: " + methodName);
		logEntry.put("execution_time_ms", null);
		
		Map<String, Object> request = new LinkedHashMap<>();
		
		if (httpInfo != null) {
			request.put("http", createHttpInfoMap(httpInfo, false));
		}
 
		request.put("method", methodName);
		request.put("parameters", parameters);  
		logEntry.put("request", request);
		logEntry.put("response", null);
		logEntry.put("exception", null);
 
		log.info("{}", objectMapper.writeValueAsString(logEntry));
	} catch (Exception e) {
		log.error("REQUEST 로그 출력 실패", e);
	}
}

출력 예시:

{
	"@timestamp": "2025-01-25T10:30:45.123Z",
	"trace_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
	"client_ip": "192.168.1.100",
	"level": "INFO",
	"package": "com.example.controller.UserController",
	"component_name": "UserController",
	"layer": "CONTROLLER",
	"message": "Request received: getUser",
	"execution_time_ms": null,
	"request": {
		"http": {
			"method": "GET",
			"endpoint": "/api/users/123",
			"queryString": "includeDetails=true"
		},
		"method": "getUser",
		"parameters": {
			"userId": 123,
			"includeDetails": true
		}
	},
	"response": null,
	"exception": null
}

4.5.2 Response 로그 (성공)

private void logResponse(String packageName, String componentName, String layer, String methodName, Object responseData, Long executionTime, Throwable exception, HttpInfo httpInfo) {
	try {
		Map<String, Object> logEntry = createBaseLogEntry(packageName, componentName, layer);
		
		logEntry.put("execution_time_ms", executionTime);
		logEntry.put("request", null);
	
		if (exception != null) {
			// 예외 로그 (다음 섹션)
		} else {
			logEntry.put("level", "INFO");
			logEntry.put("message", "Response completed: " + methodName);
			
			Map<String, Object> response = new LinkedHashMap<>();
 
			if (httpInfo != null) {			
				response.put("http", createHttpInfoMap(httpInfo, true));
			}
			
			response.put("method", methodName);
			response.put("result", responseData);
			logEntry.put("response", response);
			logEntry.put("exception", null);
 
			log.info("{}", objectMapper.writeValueAsString(logEntry));		
		}
	} catch (Exception e) {
		log.error("RESPONSE 로그 출력 실패", e);
	}
}

출력 예시:

{
	"@timestamp": "2025-01-25T10:30:45.456Z",	
	"trace_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
	"client_ip": "192.168.1.100",
	"level": "INFO",
	"package": "com.example.controller.UserController",
	"component_name": "UserController",
	"layer": "CONTROLLER",
	"message": "Response completed: getUser",
	"execution_time_ms": 333,
	"request": null,
	"response": {
		"http": {
			"method": "GET",
			"endpoint": "/api/users/123",
			"statusCode": 200
		},
		"method": "getUser",
		"result": {
			"id": 123,
			"name": "John Doe",
			"email": "john@example.com"
		}
	},
	"exception": null
}

4.5.3 Exception 로그 (실패)

if (exception != null) {
	logEntry.put("level", "ERROR");
	logEntry.put("message", "Failed to execute " + methodName + ": " + exception.getMessage());
	logEntry.put("response", null);
	logEntry.put("exception", createExceptionInfo(exception));
 
	log.error("{}", objectMapper.writeValueAsString(logEntry));
}
 
private Map<String, Object> createExceptionInfo(Throwable exception) {
	Map<String, Object> exceptionInfo = new LinkedHashMap<>();
	
	exceptionInfo.put("type", exception.getClass().getName());
	exceptionInfo.put("message", exception.getMessage());
	exceptionInfo.put("stacktrace", getStackTrace(exception));
	
	return exceptionInfo;
}

출력 예시:

{
	"@timestamp": "2025-01-25T10:30:45.456Z",	
	"trace_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
	"client_ip": "192.168.1.100",
	"level": "ERROR",
	"package": "com.example.service.UserService",
	"component_name": "UserService",
	"layer": "SERVICE",
	"message": "Failed to execute findUser: User not found",
	"execution_time_ms": 50,
	"request": null,
	"response": null,
	"exception": {
		"type": "com.example.exception.UserNotFoundException",
		"message": "User not found",
		"stacktrace": "com.example.exception.UserNotFoundException: User not found\n\tat com.example.service.UserService.findUser(UserService.java:45)\n\tat com.example.controller.UserController.getUser(UserController.java:23)\n\t..."
	}
}

4.5.4 스택트레이스 제어

private String getStackTrace(Throwable e) {
	if (e == null) {
		return null;
	}
 
	StackTraceElement[] stackTrace = e.getStackTrace();
	if (stackTrace == null || stackTrace.length == 0) {
		return e.toString();
	}
	
	StringBuilder sb = new StringBuilder(e.toString()).append("\n");
 
	// maxStackTraceLines가 0이면 스택트레이스 출력 안함
	if (maxStackTraceLines == 0) {
		return sb.toString().trim();
	}
 
	// -1이면 전체 출력, 양수면 해당 줄 수만 출력
	int limit = (maxStackTraceLines == -1) ? stackTrace.length : Math.min(maxStackTraceLines, stackTrace.length);
 
	for (int i = 0; i < limit; i++) {
		sb.append("\tat ").append(stackTrace[i]).append("\n");
	}
 
	if (maxStackTraceLines != -1 && stackTrace.length > maxStackTraceLines) {
		sb.append("\t... ").append(stackTrace.length - maxStackTraceLines) .append(" more");
	}
 
	return sb.toString();
}

설정 예시 (application.yml):

dependency:
  logger:
    stacktrace:
      max-lines: 10 # 상위 10줄만 출력
     # max-lines: -1 # 전체 출력 (기본값)
     # max-lines: 0 # 출력 안함

4.6 Base 로그 엔트리 구조

private Map<String, Object> createBaseLogEntry(String packageName, String componentName, String layer) {
	Map<String, Object> logEntry = new LinkedHashMap<>();
	
	logEntry.put("@timestamp", LocalDateTime.now().atZone(ZoneOffset.UTC).format(ISO_FORMATTER));
	logEntry.put("trace_id", MDC.get("traceId"));
	logEntry.put("client_ip", MDC.get("client_ip"));
	logEntry.put("level", "INFO");
	logEntry.put("package", packageName);
	logEntry.put("component_name", componentName);
	logEntry.put("layer", layer);
	
	return logEntry;
}

필드 설명:

필드설명예시
@timestampISO 8601 형식 타임스탬프2025-01-25T10:30:45.123Z
trace_idMDC에서 가져온 TraceIDa1b2c3d4-...
client_ipMDC에서 가져온 클라이언트 IP192.168.1.100
level로그 레벨INFO or ERROR
package완전한 클래스 이름com.example.controller.UserController
component_name단순 클래스 이름UserController
layer계층CONTROLLER, SERVICE, REPOSITORY