컨트롤러에 로직이 묻히고, 로그 한 줄 남기기 위해 수십 줄의 코드가 반복되던 레거시 로깅 시스템.
결국 주말을 반납하고 고군분투 끝에 AOP 기반으로 리팩토링했다.
이번 포스트에서는 이 리팩토링 과정을 정리 해보려고 한다.
✨ 리팩토링 배경
1. 기존 Logging 기능
기존 레거시 로그코드는 아래와 같은 기능과 모습을 가지고 있었다.
- 6하원칙 기반 로그 메시지: 누가, 언제, 어디서, 무엇을 했는지를 기반으로 성공/실패 여부와 메시지를 남긴다.
- 다국어 지원: 한국어 및 영어 메시지를 국제화 키로 관리
- WebFlux 대응: 비동기 환경에서도 동작 가능
2. 문제점
문제 항목 | 상세 설명 |
⚠️ 관심사 혼재 | 컨트롤러에 비즈니스 로직과 로깅 로직이 섞여 있어 가독성 저하(가장 문제가 되는 부분이었다.) |
⚠️ 반복 코드 | API마다 유사한 로깅 코드가 반복됨 |
⚠️ 메시지 분산 | 다국어 메시지 키가 코드 곳곳에 분산되어 유지보수 어려움 |
⚠️ WebFlux 특성 | RequestBody는 Flux로 구성돼 있어 한번 소비하면 재사용 불가 |
기존 레거시코드는 아래와 같은 구조를 가지고 있었으며 위와 같은 문제점을 가지고 있었다.
@Operation(summary = "Insert Secure Data", description = "Legacy Insert API with Logging")
@PostMapping("/insert")
public Mono<SecureDTO.Response> insert(
@Parameter(description = "SecureDTO.Request") @RequestBody SecureDTO.Request reqDto,
ServerHttpRequest request) {
return getCurrentUserId(request).switchIfEmpty(Mono.just("")).flatMap(userId -> {
String userIp = extractClientIp(request);
String uri = request.getPath().toString();
// Set common service metadata
// ...
AtomicReference<List<Map<String, Object>>> logs = new AtomicReference<>();
Mono<SecureEntity> insertFlow = secureService.insertData(reqDto)
.doOnSuccess(result -> {
// Success log 세팅
// ...
})
.onErrorResume(e -> {
// Error log 세팅
// ...
});
return logUtil.logWithData(insertFlow, logs);
});
}
🔗 리팩토링 목표 및 성과
내가 지향하는 이번 리팩토링의 목표는 아래와 같았다.
- 6하원칙에 의거한 자세한 로깅 Content 제공
기존에 비해 심플한 로깅 메시지를 남기되, RequestBody를 함께 저장함으로써 추적성을 올렸다. - 성공/실패에 대한 로그 및 에러코드 제공
기존과 동일하게 해당 요청의 성공/실패 여부를 남기고, 실패 했다면 실패 원인과 에러코들르 함께 남긴다. - multipart/form-data Content-Type 지원
제품 특성상 자주 쓰이는 multipart/form-data Content-Type을 지원해야 했다. 단, 이때 용량이 크고 보안상 문제가 될 수 있는 fileData는 filename만 로그에 남기는 식으로 처리하였다. - 다국어 기능 제공
기존과 동일하게 2개국어 지원이 되도록 한다. - 보안 데이터 마스킹
비밀번호와 같은 보안데이터는 로그데이터에 남기지 않도록 마스킹처리 하였다. - 로그 메시지 유지보수성 제고
기존에 분산화 되어 있던 로그메시지를 하나의 파일에서 직접 관리할 수 있도록 개선하였다. - WebFlux환경에서 Reactive하게 동작
이번에 리팩토링한 결과 아래처럼 간단히 @LoggingAPI만 붙여주면 로그가 모두 남게 되었다.
아래 실제 구현 코드를 보면서 어떻게 위 조건들을 충족시킬 수 있었는지 정리해보도록하자.
@LoggingAPI
@PostMapping("/insert")
public Mono<SecureDTO.Response> insert(@RequestBody SecureDTO.Request reqDto, ServerHttpRequest request) {
return getCurrentUserId(request).flatMap(userId -> {
//... data settings...
String userIp = extractClientIp(request);
return secureService.insertData(reqDto);
});
}
📄 구현 구조
이번 리팩토링에서는 세 가지 핵심 컴포넌트를 새롭게 설계하여 전체 로깅 시스템의 구조를 개선했다.
각 컴포넌트는 기존의 문제를 해결하기 위한 목적을 가지고 있으며, 어떻게 역할을 나누었는지 아래에 자세히 설명한다.
1. LogMessages - 로그 메시지 집중 관리
기존에는 로그 메시지에 사용되는 다국어 키들이 코드 전반에 흩어져 있었기 때문에, 하나의 메시지를 변경하려 해도 어디서 사용하는지 찾는 데 시간이 걸렸다. 이를 해결하기 위해 메시지 키를 Enum 클래스로 집중 관리하도록 설계했다.
@Getter
@RequiredArgsConstructor
public enum LogMessages {
LOGIN("/login", "System", "log.audit.login.success", "log.audit.login.fail"),
...
UNKNOWN("", "Unknown", "log.audit.etc.success", "log.audit.etc.fail");
public static LogMessages fromPath(String path) {
return Arrays.stream(values())
.filter(e -> path.equalsIgnoreCase(e.path))
.findFirst().orElse(UNKNOWN);
}
public String getSuccessMessage(List<String> params) {
return MessageUtil.getMessageService().getMessage(successMsgKey, params);
}
public String getFailMessage(List<String> params) {
return MessageUtil.getMessageService().getMessage(failMsgKey, params);
}
}
이 Enum을 사용하면 경로 기반으로 자동으로 메시지를 매핑할 수 있어서 가독성과 유지보수성을 크게 개선할 수 있었다.
2. CachingRBFilter - RequestBody 캐싱
WebFlux의 비동기 환경에서는 RequestBody가 스트리밍 방식으로 전달되기 때문에, 한번 읽고 나면 더 이상 데이터를 꺼내 쓸 수 없다. 이 문제를 해결하기 위해 RequestBody를 메모리에 저장해두고, 여러 번 재사용할 수 있도록 필터(WebFilter)를 도입했다.
@Component
//...
@Slf4j
public class CachingRequestBodyFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
if (!isCachingRequired(request)) {
log.debug("no need to cache request body");
return chain.filter(exchange);
}
return DataBufferUtils.join(request.getBody())
.defaultIfEmpty(exchange.getResponse().bufferFactory().wrap(new byte[0]))
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
String cachedBody = new String(bytes, StandardCharsets.UTF_8);
// 저장
exchange.getAttributes().put("cachedRequestBody", cachedBody);
// 복제해서 body 제공
ServerHttpRequestDecorator decoratedRequest = new ServerHttpRequestDecorator(request) {
@Override
public HttpHeaders getHeaders() {
return super.getHeaders();
}
@Override
public Flux<DataBuffer> getBody() {
return Flux.defer(() -> {
DataBufferFactory factory = exchange.getResponse().bufferFactory();
return Flux.just(factory.wrap(bytes));
});
}
};
return chain.filter(exchange.mutate().request(decoratedRequest).build());
});
}
}
특히 multipart/form-data 형식은 애초에 재사용이 가능할 뿐아니라 대용량 파일이 들어올 수 있기 때문에,
이런 요청은 필터에서 캐싱 대상에서 제외(isCachingRequired)해 성능에 주는 영향을 최소화 하였다.
3. LoggingAspect - 핵심 로깅 로직
로깅 처리는 LoggingAspect라는 클래스에서 수행되며, 핵심 흐름은 다음과 같다.
- @LoggingAPI 어노테이션이 붙은 API를 감지하여 AOP가 동작한다.
- 사용자 ID, IP, 요청 URI, Body 등을 추출한다.
- 요청 결과가 성공인지 실패인지에 따라 적절한 메시지를 설정한다.
- 이를 바탕으로 로그를 생성하고 DB에 저장한다.
아래는 그 예시코드이다.
@Aspect
@Component
@Slf4j
@Order(3)
@RequiredArgsConstructor
public class LoggingAspect {
private final ConfigProperties configProperties;
private final LoginService loginService;
private final ApiKeyService apiKeyService;
private final AuditLogRepository auditLogRepository;
private final int UNEXPECTED_ERROR_CODE = 9999;
@Around("@annotation([package-path].LoggingAPI)")
public Object logRequestResponse(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
ServerHttpRequest request = (ServerHttpRequest) args[args.length - 1];
return getUserId(request)
.flatMap(userId -> processLoging(joinPoint, request, args, userId))
.cast(Object.class);
}
/**
* 사용자 ID를 조회하는 메서드
* 1. 쿠키에 토큰이 있는 경우에는 해당 토큰으로 사용자 ID를 조회
* 2. 쿠키에 토큰이 없는 경우에는 API Key로 사용자 ID를 조회
* 3. fallback 처리
*
* @param request
* @return
*/
private Mono<String> getUserId(ServerHttpRequest request) {
//...
}
private Mono<?> processLoging(ProceedingJoinPoint joinPoint, ServerHttpRequest request, Object[] args, String userId) {
LocalDateTime requestTime = DateUtil.getUTCLocalDateTime();
String userIp = NetworkUtil.getClientIP(request);
String requestPath = request.getPath().toString();
String requestMethod = request.getMethod().toString();
String requestBody = extractRequestBody(request, args);
LogMessages lm = LogMessages.fromPath(requestPath);
String successMessage = lm.getSuccessMessage(null);
String failMessage = lm.getFailMessage(null);
AuditLogBuilder alb = AuditLog.builder()
//... 생략
.userId(userId)
.userIp(userIp)
.requestPath(requestPath)
.requestMethod(requestMethod)
.requestBody(requestBody)
.requestTime(requestTime);
try {
Object result = joinPoint.proceed();
return handleResult(result, alb, successMessage, failMessage)
.onErrorResume(e -> {
// some error 처리
return Mono.error(e);
});
} catch (Throwable e) {
return handleException(e, alb, failMessage);
}
}
/**
* Request Body를 추출하는 메서드
* content-type이 multipart/form-data인 경우에는
* FilePart를 포함한 Map<String, Object> 형태로 변환하여 데이터를 정리하여 json.stringify해서 리턴
* 그 이외의 경우에는 String 형태로 변환하여 리턴
*
* @param request
* @param args
* @return
*/
private String extractRequestBody(ServerHttpRequest request, Object[] args) {
// ... 생략
// 그 이외의 경우
Object cachedBody = request.getAttributes().get("cachedRequestBody");
return (cachedBody instanceof String) ? (String) cachedBody : ObjectUtil.convertObjectToJsonString(cachedBody);
}
/**
* multipart/form-data인 경우에 Request Body를 Map<String, Object> 형태로 변환하는 메서드
* FilePart인 경우에는 filename을 key로 하여 Map에 저장
* FilePart[]인 경우에는 filename을 ArrayList에 저장하여 Map에 저장
* jsonString인 경우에는 data를 키로 하여 그대로 Map에 저장
*
* @param args
* @return
*/
private Map<String, Object> getMultipartDataMap(Object[] args) {
Map<String, Object> result = new HashMap<>();
for (Object arg : args) {
if (arg instanceof FilePart) {
FilePart file = (FilePart) arg;
result.put("file", file.filename());
} else if (arg instanceof FilePart[]) {
FilePart[] files = (FilePart[]) arg;
ArrayList<String> filenames = new ArrayList<>();
for (FilePart file : files) {
filenames.add(file.filename());
}
result.put("files", filenames);
} else if (arg instanceof String) {
result.put("datas", arg);
}
}
return result;
}
/**
* 응답을 처리하는 메서드
*/
private Mono<?> handleResult(Object result, AuditLogBuilder alb, String successMessage, String failMessage) {
if (result instanceof Mono) {
return ((Mono<?>) result).flatMap(response -> {
AuditLog auditLog = alb.logMessage(successMessage)
.errorCode(0)
.build();
return saveAuditLog(auditLog).thenReturn(response);
})
.onErrorResume(e -> {
String errorDesc = e.getMessage();
int errorCode = UNEXPECTED_ERROR_CODE;
if (e instanceof CustomException) {
errorDesc = ((CustomException) e).getErrorMsg();
errorCode = ((CustomException) e).getErrorCode().getCode();
}
AuditLog auditLog = alb.logMessage(failMessage)
.errorCode(errorCode)
.errorDesc(errorDesc)
.build();
return saveAuditLog(auditLog).then(Mono.error(e));
});
} else {
// Mono가 아닌 응답 처리
// ...
}
}
}
💪 삽질 & 극복기
처음 이 리팩토링을 시작했을 때에는 그렇게 어려운 작업이 아닐거라고 생각했지만,
여느 다른 일들과 마찬가지로 막상 시작하고나니 여러가지 문제들이 나를 기다리고 있었다.
1. WebFlux와 RequestBody
WebFlux에서는 RequestBody가 Flux<DataBuffer> 형태로 들어오는데, 이게 한 번 읽고 나면 다시는 사용할 수 없다는 제한이 있다. 처음엔 단순히 로그를 남기려다 전체 API 동작이 꼬이는 문제가 생겼다. 그래서 이 문제를 해결하기 위해 CachingRBFilter를 만들었고, Request를 복제해서 다시 사용할 수 있도록 ServerHttpRequestDecorator를 적용해 해결할 수 있었다.
2. 성능 고려
RequestBody를 캐싱하면 메모리를 추가로 사용하게 되는데, 우리 시스템처럼 대용량 파일도 다뤄야 하는 상황에서는 큰 부담이 될 수 있었다. 그래서 multipart/form-data 요청은 캐싱 대상에서 제외했고, 나머지 요청에 대해서만 필요한 경우에 한해 캐싱이 동작하도록 설계했다. 이후 JMeter로 현재 제품 사용량을 고려한 실제 부하 테스트를 돌려본 결과, 성능상 문제가 없는 것을 확인했다.
3. 레거시 코드 제거
AOP 기반 로깅 시스템을 도입하면서 기존의 모든 API에서 레거시 로깅 코드를 걷어내야 했다. 단순히 삭제만 하면 되는 줄 알았지만, 실제로는 과거 코드 안에 숨어 있던 버그 3개를 발견하게 되었고, 이 기회에 함께 수정할 수 있었다. 이 작업이 정말 시간이 오래 걸렸다.
4. 동료 설득
가장 고민스러웠던 부분 중 하나는 기존 로그 시스템이 동료 선임분이 꽤 공들여 만들어 놓은 구조였다는 점이다. 자칫하면 기존 코드를 부정하는 모양새가 될 수 있었기에, 변경 전후 비교 예시와 개선 효과를 실제 코드와 함께 최대한 정리해서 설명드렸다. 실제 동작하는 모습까지 보여드리며 리뷰를 요청했고, 다행히 긍정적으로 검토해 주시고 피드백도 많이 주셔서 성공적으로 마칠 수 있었다.
🚀 마무리하며
단순히 코드를 리팩토링한 것이 아니라, 시스템의 유지보수성과 확장성을 높이는 경험이었다. 반복되는 코드, 분산된 메시지, WebFlux의 제약을 극복하면서 더욱 견고하고 재사용 가능한 로깅 시스템을 만들 수 있었다.
앞으로도 기술적 부채를 외면하지 않고, 직접 개선하는 개발자가 되고 싶다.
'java,springboot' 카테고리의 다른 글
[Java] Java에서 JSON 다루기(mapper, converter) (0) | 2024.04.04 |
---|---|
[성능개선]KMP알고리즘 활용하여 50% 성능개선 해보기 (1) | 2024.03.11 |
[Springboot] Reactive Redis 총 정리(config, generic, test) (1) | 2024.02.06 |
try-with-resources 사용법 및 주의점 (1) | 2023.12.05 |
브라우저에서 RTSP프로토콜 스트리밍 하기 (2) | 2023.10.30 |
컨트롤러에 로직이 묻히고, 로그 한 줄 남기기 위해 수십 줄의 코드가 반복되던 레거시 로깅 시스템.
결국 주말을 반납하고 고군분투 끝에 AOP 기반으로 리팩토링했다.
이번 포스트에서는 이 리팩토링 과정을 정리 해보려고 한다.
✨ 리팩토링 배경
1. 기존 Logging 기능
기존 레거시 로그코드는 아래와 같은 기능과 모습을 가지고 있었다.
- 6하원칙 기반 로그 메시지: 누가, 언제, 어디서, 무엇을 했는지를 기반으로 성공/실패 여부와 메시지를 남긴다.
- 다국어 지원: 한국어 및 영어 메시지를 국제화 키로 관리
- WebFlux 대응: 비동기 환경에서도 동작 가능
2. 문제점
문제 항목 | 상세 설명 |
⚠️ 관심사 혼재 | 컨트롤러에 비즈니스 로직과 로깅 로직이 섞여 있어 가독성 저하(가장 문제가 되는 부분이었다.) |
⚠️ 반복 코드 | API마다 유사한 로깅 코드가 반복됨 |
⚠️ 메시지 분산 | 다국어 메시지 키가 코드 곳곳에 분산되어 유지보수 어려움 |
⚠️ WebFlux 특성 | RequestBody는 Flux로 구성돼 있어 한번 소비하면 재사용 불가 |
기존 레거시코드는 아래와 같은 구조를 가지고 있었으며 위와 같은 문제점을 가지고 있었다.
@Operation(summary = "Insert Secure Data", description = "Legacy Insert API with Logging")
@PostMapping("/insert")
public Mono<SecureDTO.Response> insert(
@Parameter(description = "SecureDTO.Request") @RequestBody SecureDTO.Request reqDto,
ServerHttpRequest request) {
return getCurrentUserId(request).switchIfEmpty(Mono.just("")).flatMap(userId -> {
String userIp = extractClientIp(request);
String uri = request.getPath().toString();
// Set common service metadata
// ...
AtomicReference<List<Map<String, Object>>> logs = new AtomicReference<>();
Mono<SecureEntity> insertFlow = secureService.insertData(reqDto)
.doOnSuccess(result -> {
// Success log 세팅
// ...
})
.onErrorResume(e -> {
// Error log 세팅
// ...
});
return logUtil.logWithData(insertFlow, logs);
});
}
🔗 리팩토링 목표 및 성과
내가 지향하는 이번 리팩토링의 목표는 아래와 같았다.
- 6하원칙에 의거한 자세한 로깅 Content 제공
기존에 비해 심플한 로깅 메시지를 남기되, RequestBody를 함께 저장함으로써 추적성을 올렸다. - 성공/실패에 대한 로그 및 에러코드 제공
기존과 동일하게 해당 요청의 성공/실패 여부를 남기고, 실패 했다면 실패 원인과 에러코들르 함께 남긴다. - multipart/form-data Content-Type 지원
제품 특성상 자주 쓰이는 multipart/form-data Content-Type을 지원해야 했다. 단, 이때 용량이 크고 보안상 문제가 될 수 있는 fileData는 filename만 로그에 남기는 식으로 처리하였다. - 다국어 기능 제공
기존과 동일하게 2개국어 지원이 되도록 한다. - 보안 데이터 마스킹
비밀번호와 같은 보안데이터는 로그데이터에 남기지 않도록 마스킹처리 하였다. - 로그 메시지 유지보수성 제고
기존에 분산화 되어 있던 로그메시지를 하나의 파일에서 직접 관리할 수 있도록 개선하였다. - WebFlux환경에서 Reactive하게 동작
이번에 리팩토링한 결과 아래처럼 간단히 @LoggingAPI만 붙여주면 로그가 모두 남게 되었다.
아래 실제 구현 코드를 보면서 어떻게 위 조건들을 충족시킬 수 있었는지 정리해보도록하자.
@LoggingAPI
@PostMapping("/insert")
public Mono<SecureDTO.Response> insert(@RequestBody SecureDTO.Request reqDto, ServerHttpRequest request) {
return getCurrentUserId(request).flatMap(userId -> {
//... data settings...
String userIp = extractClientIp(request);
return secureService.insertData(reqDto);
});
}
📄 구현 구조
이번 리팩토링에서는 세 가지 핵심 컴포넌트를 새롭게 설계하여 전체 로깅 시스템의 구조를 개선했다.
각 컴포넌트는 기존의 문제를 해결하기 위한 목적을 가지고 있으며, 어떻게 역할을 나누었는지 아래에 자세히 설명한다.
1. LogMessages - 로그 메시지 집중 관리
기존에는 로그 메시지에 사용되는 다국어 키들이 코드 전반에 흩어져 있었기 때문에, 하나의 메시지를 변경하려 해도 어디서 사용하는지 찾는 데 시간이 걸렸다. 이를 해결하기 위해 메시지 키를 Enum 클래스로 집중 관리하도록 설계했다.
@Getter
@RequiredArgsConstructor
public enum LogMessages {
LOGIN("/login", "System", "log.audit.login.success", "log.audit.login.fail"),
...
UNKNOWN("", "Unknown", "log.audit.etc.success", "log.audit.etc.fail");
public static LogMessages fromPath(String path) {
return Arrays.stream(values())
.filter(e -> path.equalsIgnoreCase(e.path))
.findFirst().orElse(UNKNOWN);
}
public String getSuccessMessage(List<String> params) {
return MessageUtil.getMessageService().getMessage(successMsgKey, params);
}
public String getFailMessage(List<String> params) {
return MessageUtil.getMessageService().getMessage(failMsgKey, params);
}
}
이 Enum을 사용하면 경로 기반으로 자동으로 메시지를 매핑할 수 있어서 가독성과 유지보수성을 크게 개선할 수 있었다.
2. CachingRBFilter - RequestBody 캐싱
WebFlux의 비동기 환경에서는 RequestBody가 스트리밍 방식으로 전달되기 때문에, 한번 읽고 나면 더 이상 데이터를 꺼내 쓸 수 없다. 이 문제를 해결하기 위해 RequestBody를 메모리에 저장해두고, 여러 번 재사용할 수 있도록 필터(WebFilter)를 도입했다.
@Component
//...
@Slf4j
public class CachingRequestBodyFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
if (!isCachingRequired(request)) {
log.debug("no need to cache request body");
return chain.filter(exchange);
}
return DataBufferUtils.join(request.getBody())
.defaultIfEmpty(exchange.getResponse().bufferFactory().wrap(new byte[0]))
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
String cachedBody = new String(bytes, StandardCharsets.UTF_8);
// 저장
exchange.getAttributes().put("cachedRequestBody", cachedBody);
// 복제해서 body 제공
ServerHttpRequestDecorator decoratedRequest = new ServerHttpRequestDecorator(request) {
@Override
public HttpHeaders getHeaders() {
return super.getHeaders();
}
@Override
public Flux<DataBuffer> getBody() {
return Flux.defer(() -> {
DataBufferFactory factory = exchange.getResponse().bufferFactory();
return Flux.just(factory.wrap(bytes));
});
}
};
return chain.filter(exchange.mutate().request(decoratedRequest).build());
});
}
}
특히 multipart/form-data 형식은 애초에 재사용이 가능할 뿐아니라 대용량 파일이 들어올 수 있기 때문에,
이런 요청은 필터에서 캐싱 대상에서 제외(isCachingRequired)해 성능에 주는 영향을 최소화 하였다.
3. LoggingAspect - 핵심 로깅 로직
로깅 처리는 LoggingAspect라는 클래스에서 수행되며, 핵심 흐름은 다음과 같다.
- @LoggingAPI 어노테이션이 붙은 API를 감지하여 AOP가 동작한다.
- 사용자 ID, IP, 요청 URI, Body 등을 추출한다.
- 요청 결과가 성공인지 실패인지에 따라 적절한 메시지를 설정한다.
- 이를 바탕으로 로그를 생성하고 DB에 저장한다.
아래는 그 예시코드이다.
@Aspect
@Component
@Slf4j
@Order(3)
@RequiredArgsConstructor
public class LoggingAspect {
private final ConfigProperties configProperties;
private final LoginService loginService;
private final ApiKeyService apiKeyService;
private final AuditLogRepository auditLogRepository;
private final int UNEXPECTED_ERROR_CODE = 9999;
@Around("@annotation([package-path].LoggingAPI)")
public Object logRequestResponse(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
ServerHttpRequest request = (ServerHttpRequest) args[args.length - 1];
return getUserId(request)
.flatMap(userId -> processLoging(joinPoint, request, args, userId))
.cast(Object.class);
}
/**
* 사용자 ID를 조회하는 메서드
* 1. 쿠키에 토큰이 있는 경우에는 해당 토큰으로 사용자 ID를 조회
* 2. 쿠키에 토큰이 없는 경우에는 API Key로 사용자 ID를 조회
* 3. fallback 처리
*
* @param request
* @return
*/
private Mono<String> getUserId(ServerHttpRequest request) {
//...
}
private Mono<?> processLoging(ProceedingJoinPoint joinPoint, ServerHttpRequest request, Object[] args, String userId) {
LocalDateTime requestTime = DateUtil.getUTCLocalDateTime();
String userIp = NetworkUtil.getClientIP(request);
String requestPath = request.getPath().toString();
String requestMethod = request.getMethod().toString();
String requestBody = extractRequestBody(request, args);
LogMessages lm = LogMessages.fromPath(requestPath);
String successMessage = lm.getSuccessMessage(null);
String failMessage = lm.getFailMessage(null);
AuditLogBuilder alb = AuditLog.builder()
//... 생략
.userId(userId)
.userIp(userIp)
.requestPath(requestPath)
.requestMethod(requestMethod)
.requestBody(requestBody)
.requestTime(requestTime);
try {
Object result = joinPoint.proceed();
return handleResult(result, alb, successMessage, failMessage)
.onErrorResume(e -> {
// some error 처리
return Mono.error(e);
});
} catch (Throwable e) {
return handleException(e, alb, failMessage);
}
}
/**
* Request Body를 추출하는 메서드
* content-type이 multipart/form-data인 경우에는
* FilePart를 포함한 Map<String, Object> 형태로 변환하여 데이터를 정리하여 json.stringify해서 리턴
* 그 이외의 경우에는 String 형태로 변환하여 리턴
*
* @param request
* @param args
* @return
*/
private String extractRequestBody(ServerHttpRequest request, Object[] args) {
// ... 생략
// 그 이외의 경우
Object cachedBody = request.getAttributes().get("cachedRequestBody");
return (cachedBody instanceof String) ? (String) cachedBody : ObjectUtil.convertObjectToJsonString(cachedBody);
}
/**
* multipart/form-data인 경우에 Request Body를 Map<String, Object> 형태로 변환하는 메서드
* FilePart인 경우에는 filename을 key로 하여 Map에 저장
* FilePart[]인 경우에는 filename을 ArrayList에 저장하여 Map에 저장
* jsonString인 경우에는 data를 키로 하여 그대로 Map에 저장
*
* @param args
* @return
*/
private Map<String, Object> getMultipartDataMap(Object[] args) {
Map<String, Object> result = new HashMap<>();
for (Object arg : args) {
if (arg instanceof FilePart) {
FilePart file = (FilePart) arg;
result.put("file", file.filename());
} else if (arg instanceof FilePart[]) {
FilePart[] files = (FilePart[]) arg;
ArrayList<String> filenames = new ArrayList<>();
for (FilePart file : files) {
filenames.add(file.filename());
}
result.put("files", filenames);
} else if (arg instanceof String) {
result.put("datas", arg);
}
}
return result;
}
/**
* 응답을 처리하는 메서드
*/
private Mono<?> handleResult(Object result, AuditLogBuilder alb, String successMessage, String failMessage) {
if (result instanceof Mono) {
return ((Mono<?>) result).flatMap(response -> {
AuditLog auditLog = alb.logMessage(successMessage)
.errorCode(0)
.build();
return saveAuditLog(auditLog).thenReturn(response);
})
.onErrorResume(e -> {
String errorDesc = e.getMessage();
int errorCode = UNEXPECTED_ERROR_CODE;
if (e instanceof CustomException) {
errorDesc = ((CustomException) e).getErrorMsg();
errorCode = ((CustomException) e).getErrorCode().getCode();
}
AuditLog auditLog = alb.logMessage(failMessage)
.errorCode(errorCode)
.errorDesc(errorDesc)
.build();
return saveAuditLog(auditLog).then(Mono.error(e));
});
} else {
// Mono가 아닌 응답 처리
// ...
}
}
}
💪 삽질 & 극복기
처음 이 리팩토링을 시작했을 때에는 그렇게 어려운 작업이 아닐거라고 생각했지만,
여느 다른 일들과 마찬가지로 막상 시작하고나니 여러가지 문제들이 나를 기다리고 있었다.
1. WebFlux와 RequestBody
WebFlux에서는 RequestBody가 Flux<DataBuffer> 형태로 들어오는데, 이게 한 번 읽고 나면 다시는 사용할 수 없다는 제한이 있다. 처음엔 단순히 로그를 남기려다 전체 API 동작이 꼬이는 문제가 생겼다. 그래서 이 문제를 해결하기 위해 CachingRBFilter를 만들었고, Request를 복제해서 다시 사용할 수 있도록 ServerHttpRequestDecorator를 적용해 해결할 수 있었다.
2. 성능 고려
RequestBody를 캐싱하면 메모리를 추가로 사용하게 되는데, 우리 시스템처럼 대용량 파일도 다뤄야 하는 상황에서는 큰 부담이 될 수 있었다. 그래서 multipart/form-data 요청은 캐싱 대상에서 제외했고, 나머지 요청에 대해서만 필요한 경우에 한해 캐싱이 동작하도록 설계했다. 이후 JMeter로 현재 제품 사용량을 고려한 실제 부하 테스트를 돌려본 결과, 성능상 문제가 없는 것을 확인했다.
3. 레거시 코드 제거
AOP 기반 로깅 시스템을 도입하면서 기존의 모든 API에서 레거시 로깅 코드를 걷어내야 했다. 단순히 삭제만 하면 되는 줄 알았지만, 실제로는 과거 코드 안에 숨어 있던 버그 3개를 발견하게 되었고, 이 기회에 함께 수정할 수 있었다. 이 작업이 정말 시간이 오래 걸렸다.
4. 동료 설득
가장 고민스러웠던 부분 중 하나는 기존 로그 시스템이 동료 선임분이 꽤 공들여 만들어 놓은 구조였다는 점이다. 자칫하면 기존 코드를 부정하는 모양새가 될 수 있었기에, 변경 전후 비교 예시와 개선 효과를 실제 코드와 함께 최대한 정리해서 설명드렸다. 실제 동작하는 모습까지 보여드리며 리뷰를 요청했고, 다행히 긍정적으로 검토해 주시고 피드백도 많이 주셔서 성공적으로 마칠 수 있었다.
🚀 마무리하며
단순히 코드를 리팩토링한 것이 아니라, 시스템의 유지보수성과 확장성을 높이는 경험이었다. 반복되는 코드, 분산된 메시지, WebFlux의 제약을 극복하면서 더욱 견고하고 재사용 가능한 로깅 시스템을 만들 수 있었다.
앞으로도 기술적 부채를 외면하지 않고, 직접 개선하는 개발자가 되고 싶다.
'java,springboot' 카테고리의 다른 글
[Java] Java에서 JSON 다루기(mapper, converter) (0) | 2024.04.04 |
---|---|
[성능개선]KMP알고리즘 활용하여 50% 성능개선 해보기 (1) | 2024.03.11 |
[Springboot] Reactive Redis 총 정리(config, generic, test) (1) | 2024.02.06 |
try-with-resources 사용법 및 주의점 (1) | 2023.12.05 |
브라우저에서 RTSP프로토콜 스트리밍 하기 (2) | 2023.10.30 |