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;
}실행 단계:
- 메서드 정보 수집 (시작 시간, 클래스명, 메서드명, 계층)
- HTTP 정보 추출 (Controller만 해당)
- 파라미터 수집 (민감 정보 마스킹 포함)
- Request 로그 출력
- 실제 메서드 실행 (
joinPoint.proceed()) - 실행 시간 계산
- 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;
}필드 설명:
| 필드 | 설명 | 예시 |
|---|---|---|
@timestamp | ISO 8601 형식 타임스탬프 | 2025-01-25T10:30:45.123Z |
trace_id | MDC에서 가져온 TraceID | a1b2c3d4-... |
client_ip | MDC에서 가져온 클라이언트 IP | 192.168.1.100 |
level | 로그 레벨 | INFO or ERROR |
package | 완전한 클래스 이름 | com.example.controller.UserController |
component_name | 단순 클래스 이름 | UserController |
layer | 계층 | CONTROLLER, SERVICE, REPOSITORY |