diff --git a/.gitignore b/.gitignore index 9f31018fe..638a9033a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ out/ ### env file ### *.env + +### log file ### +/logs/ diff --git a/build.gradle b/build.gradle index eb5c5d410..e765995fd 100644 --- a/build.gradle +++ b/build.gradle @@ -69,6 +69,7 @@ dependencies { // Monitoring implementation group: 'org.springframework.boot', name: 'spring-boot-starter-actuator', version: '3.3.3' implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'com.github.loki4j:loki-logback-appender:1.5.1' //elastic-search implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' @@ -116,6 +117,7 @@ def jacocoExcludePatterns = [ '**/*Application.class', '**/*Config*', '**/*Exception*', + '**/*Extractor*', '**/*Request*', '**/*Response*', '**/*Entity*', @@ -135,6 +137,7 @@ def jacocoExcludePatternsForVerify = [ '*.*Application*', '*.*Config*', '*.*Exception*', + '*.*Extractor*', '*.*Request*', '*.*Response*', '*.*Entity*', diff --git a/src/main/java/com/somemore/global/aspect/LoggingAspect.java b/src/main/java/com/somemore/global/aspect/LoggingAspect.java deleted file mode 100644 index ef27d996f..000000000 --- a/src/main/java/com/somemore/global/aspect/LoggingAspect.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.somemore.global.aspect; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.annotation.Pointcut; -import org.springframework.stereotype.Component; - -@Slf4j -@Aspect -@Component -public class LoggingAspect { - - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Pointcut("execution(* com.somemore.domains.*.controller..*.*(..))") - private void pointCut(){} - - @Around("pointCut()") - public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { - - String methodName = joinPoint.getSignature().toShortString(); - String args = convertArgsToJson(joinPoint.getArgs()); - log.info("엔드포인트 호출: {} \n- 파라미터: {}", methodName, args); - - long startTime = System.currentTimeMillis(); - - try { - Object result = joinPoint.proceed(); - long elapsedTime = System.currentTimeMillis() - startTime; - - log.debug("성공: {} \n- 응답: {} \n- 실행 시간: {}ms", - methodName, - convertResultToJson(result), - elapsedTime); - - return result; - } catch (Exception e) { - long elapsedTime = System.currentTimeMillis() - startTime; - - log.warn("에러 발생: {} \n- 에러 타입: {} \n- 에러 메세지: {} \n- 실행 시간: {}ms", - methodName, - e.getClass().getSimpleName(), - e.getMessage(), - elapsedTime); - - throw e; - } - } - - private String convertArgsToJson(Object[] args) { - try { - return objectMapper.writeValueAsString(args != null ? args : new Object[]{}); - } catch (Exception e) { - log.warn("파라미터 변환 실패", e); - return "[파라미터 변환 실패]"; - } - } - - private String convertResultToJson(Object result) { - try { - return objectMapper.writeValueAsString(result != null ? result : "null"); - } catch (Exception e) { - log.warn("응답 변환 실패", e); - return "[응답 변환 실패]"; - } - } -} diff --git a/src/main/java/com/somemore/global/aspect/log/LoggingAspect.java b/src/main/java/com/somemore/global/aspect/log/LoggingAspect.java new file mode 100644 index 000000000..95f4fed07 --- /dev/null +++ b/src/main/java/com/somemore/global/aspect/log/LoggingAspect.java @@ -0,0 +1,90 @@ +package com.somemore.global.aspect.log; + +import com.somemore.global.aspect.log.extractor.ParameterExtractor; +import com.somemore.global.aspect.log.extractor.RequestExtractor; +import com.somemore.global.aspect.log.extractor.ResponseExtractor; +import com.somemore.global.common.response.LoggedResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + + +import java.util.UUID; + +@RequiredArgsConstructor +@Slf4j +@Aspect +@Component +public class LoggingAspect { + + private final RequestExtractor requestExtractor; + private final ResponseExtractor responseExtractor; + private final ParameterExtractor parameterExtractor; + + @Pointcut("execution(* com.somemore.domains.*.controller..*.*(..))") + private void controllerPointCut() {} + + @Around("controllerPointCut()") + public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { + String requestId = UUID.randomUUID().toString(); + MDC.put("requestId", requestId); + + try { + return doLogAround(joinPoint); + } finally { + MDC.remove("requestId"); + } + } + + private Object doLogAround(ProceedingJoinPoint joinPoint) throws Throwable { + String methodName = joinPoint.getSignature().toShortString(); + HttpServletRequest request = requestExtractor.getCurrentRequest(); + + MDC.put("method", request.getMethod()); + MDC.put("uri", request.getRequestURI()); + + String params = parameterExtractor.extractParameters(joinPoint); + log.info("엔드포인트 호출: {} \n- URI: {} \n- Method: {} \n- 파라미터: {}", + methodName, + request.getRequestURI(), + request.getMethod(), + params); + + long startTime = System.currentTimeMillis(); + + try { + Object result = joinPoint.proceed(); + long elapsedTime = System.currentTimeMillis() - startTime; + + LoggedResponse loggedResponse = responseExtractor.extractResponse(result); + log.info("호출 성공: {} \n- 응답 코드: {} \n- 응답 값: {} \n- 실행 시간: {}ms", + methodName, + loggedResponse.getStatusCode(), + loggedResponse.getBody(), + elapsedTime); + + return result; + } catch (Exception e) { + long elapsedTime = System.currentTimeMillis() - startTime; + HttpStatus status = responseExtractor.extractExceptionStatus(e); + + log.warn("예외 발생: {} \n- 예외 코드: {} \n- 예외 타입: {} \n- 예외 메세지: {} \n- 실행 시간: {}ms", + methodName, + status, + e.getClass().getSimpleName(), + e.getMessage(), + elapsedTime); + + throw e; + } finally { + MDC.clear(); + } + } +} diff --git a/src/main/java/com/somemore/global/aspect/log/extractor/ParameterExtractor.java b/src/main/java/com/somemore/global/aspect/log/extractor/ParameterExtractor.java new file mode 100644 index 000000000..9c1e0cc7c --- /dev/null +++ b/src/main/java/com/somemore/global/aspect/log/extractor/ParameterExtractor.java @@ -0,0 +1,68 @@ +package com.somemore.global.aspect.log.extractor; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.somemore.global.aspect.log.utils.SensitiveDataMasker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Parameter; +import java.util.LinkedHashMap; +import java.util.Map; + +@RequiredArgsConstructor +@Slf4j +@Component +public class ParameterExtractor { + + private final ObjectMapper objectMapper; + private final SensitiveDataMasker sensitiveDataMasker; + + public String extractParameters(ProceedingJoinPoint joinPoint) { + try { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Parameter[] parameters = signature.getMethod().getParameters(); + Object[] args = joinPoint.getArgs(); + + if (parameters.length == 0) { + return "{}"; + } + + Map paramMap = new LinkedHashMap<>(); + for (int i = 0; i < parameters.length; i++) { + if (args[i] != null) { + addParameter(paramMap, parameters[i], args[i]); + } + } + + return objectMapper.writeValueAsString(paramMap); + } catch (Exception e) { + log.warn("파라미터 변환 실패: {}", e.getMessage()); + return "{}"; + } + } + + private void addParameter(Map paramMap, Parameter parameter, Object value) throws JsonProcessingException { + String paramName = extractParamName(parameter); + paramMap.put(paramName, sensitiveDataMasker.maskSensitiveData(paramName, value, objectMapper)); + } + + private String extractParamName(Parameter parameter) { + PathVariable pathVariable = parameter.getAnnotation(PathVariable.class); + if (pathVariable != null && !pathVariable.value().isEmpty()) { + return pathVariable.value(); + } + + RequestParam requestParam = parameter.getAnnotation(RequestParam.class); + if (requestParam != null && !requestParam.value().isEmpty()) { + return requestParam.value(); + } + + return parameter.getName(); + } +} diff --git a/src/main/java/com/somemore/global/aspect/log/extractor/RequestExtractor.java b/src/main/java/com/somemore/global/aspect/log/extractor/RequestExtractor.java new file mode 100644 index 000000000..50401b9a8 --- /dev/null +++ b/src/main/java/com/somemore/global/aspect/log/extractor/RequestExtractor.java @@ -0,0 +1,18 @@ +package com.somemore.global.aspect.log.extractor; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Component +public class RequestExtractor { + + public HttpServletRequest getCurrentRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + throw new IllegalStateException("요청을 찾을수 없습니다."); + } + return attributes.getRequest(); + } +} diff --git a/src/main/java/com/somemore/global/aspect/log/extractor/ResponseExtractor.java b/src/main/java/com/somemore/global/aspect/log/extractor/ResponseExtractor.java new file mode 100644 index 000000000..3433ffcba --- /dev/null +++ b/src/main/java/com/somemore/global/aspect/log/extractor/ResponseExtractor.java @@ -0,0 +1,52 @@ +package com.somemore.global.aspect.log.extractor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.somemore.global.common.response.LoggedResponse; +import com.somemore.global.exception.DuplicateException; +import com.somemore.global.exception.ImageUploadException; +import com.somemore.global.exception.BadRequestException; +import com.somemore.global.exception.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.MethodArgumentNotValidException; + +@RequiredArgsConstructor +@Slf4j +@Component +public class ResponseExtractor { + + private final ObjectMapper objectMapper; + + public LoggedResponse extractResponse(Object result) { + try { + if (result == null) { + return new LoggedResponse(HttpStatus.OK, "null"); + } + + if (result instanceof ResponseEntity responseEntity) { + String body = objectMapper.writeValueAsString(responseEntity.getBody()); + return new LoggedResponse(responseEntity.getStatusCode(), body); + } + + return new LoggedResponse(HttpStatus.OK, objectMapper.writeValueAsString(result)); + } catch (Exception e) { + log.warn("응답 변환 실패: {}", e.getMessage()); + return new LoggedResponse(HttpStatus.OK, "[응답 변환 실패]"); + } + } + + public HttpStatus extractExceptionStatus(Exception e) { + if (e instanceof BadRequestException || + e instanceof ImageUploadException || + e instanceof DuplicateException || + e instanceof MethodArgumentNotValidException) { + return HttpStatus.BAD_REQUEST; + } else if (e instanceof NoSuchElementException) { + return HttpStatus.NOT_FOUND; + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } +} diff --git a/src/main/java/com/somemore/global/aspect/log/utils/SensitiveDataMasker.java b/src/main/java/com/somemore/global/aspect/log/utils/SensitiveDataMasker.java new file mode 100644 index 000000000..6addf11fb --- /dev/null +++ b/src/main/java/com/somemore/global/aspect/log/utils/SensitiveDataMasker.java @@ -0,0 +1,70 @@ +package com.somemore.global.aspect.log.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Slf4j +@Component +public class SensitiveDataMasker { + + private final Set sensitiveFields = new HashSet<>(Arrays.asList( + "password", "token", "secret", "credential", "authorization", + "accessToken", "refreshToken" + )); + + public Object maskSensitiveData(String fieldName, Object value, ObjectMapper objectMapper) throws JsonProcessingException { + if (isSensitiveField(fieldName)) { + return "********"; + } else if (value instanceof Map) { + return maskSensitiveDataInMap((Map) value); + } else if (isComplexObject(value)) { + String json = objectMapper.writeValueAsString(value); + json = maskSensitiveDataInJson(json, objectMapper); + return objectMapper.readValue(json, Object.class); + } + return value; + } + + private boolean isSensitiveField(String fieldName) { + String lowercaseFieldName = fieldName.toLowerCase(); + return sensitiveFields.stream() + .anyMatch(sensitive -> lowercaseFieldName.contains(sensitive.toLowerCase())); + } + + private Map maskSensitiveDataInMap(Map map) { + Map maskedMap = new LinkedHashMap<>(); + + map.forEach((key, value) -> { + String keyStr = String.valueOf(key); + if (isSensitiveField(keyStr)) { + maskedMap.put(keyStr, "********"); + } else if (value instanceof Map) { + maskedMap.put(keyStr, maskSensitiveDataInMap((Map) value)); + } else { + maskedMap.put(keyStr, value); + } + }); + + return maskedMap; + } + + private String maskSensitiveDataInJson(String json, ObjectMapper objectMapper) { + try { + Map jsonMap = objectMapper.readValue(json, Map.class); + Map maskedMap = maskSensitiveDataInMap(jsonMap); + return objectMapper.writeValueAsString(maskedMap); + } catch (Exception e) { + log.warn("JSON 마스킹 처리 실패: {}", e.getMessage()); + return json; + } + } + + private boolean isComplexObject(Object value) { + return !(value instanceof String || value instanceof Number || + value instanceof Boolean || value instanceof Date); + } +} diff --git a/src/main/java/com/somemore/global/common/response/LoggedResponse.java b/src/main/java/com/somemore/global/common/response/LoggedResponse.java new file mode 100644 index 000000000..9d33a1819 --- /dev/null +++ b/src/main/java/com/somemore/global/common/response/LoggedResponse.java @@ -0,0 +1,15 @@ +package com.somemore.global.common.response; + +import lombok.Getter; +import org.springframework.http.HttpStatusCode; + +@Getter +public class LoggedResponse { + private final HttpStatusCode statusCode; + private final String body; + + public LoggedResponse(HttpStatusCode statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 63a0ef2c9..bb55d6c1c 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,38 +1,81 @@ - - - - - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n - UTF-8 - - - - - - 5000 - 0 - - - - ${LOG_DIR:-logs}/application.log - - ${LOG_DIR:-logs}/application.%d{yyyy-MM-dd}.log - 30 - - - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n - UTF-8 - - - - - - - - - - - - - + + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_PATH}/${LOG_FILE_NAME}.log + + ${LOG_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log + 30 + 3GB + + + ${LOG_PATTERN} + + + + + + + + http://localhost:3100/loki/api/v1/push + 30000 + 15000 + 3 + 1000 + + + 100 + 10000 + 1048576 + + + + + true + true + true + true + JACKSON + @timestamp + false + true + ${LOG_PATTERN} + yyyy-MM-dd HH:mm:ss.SSS + + yyyy-MM-dd'T'HH:mm:ss.SSS'Z' + + + Asia/Seoul + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/somemore/global/aspect/log/SensitiveDataMaskerTest.java b/src/test/java/com/somemore/global/aspect/log/SensitiveDataMaskerTest.java new file mode 100644 index 000000000..059c366d2 --- /dev/null +++ b/src/test/java/com/somemore/global/aspect/log/SensitiveDataMaskerTest.java @@ -0,0 +1,91 @@ +package com.somemore.global.aspect.log; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.somemore.global.aspect.log.utils.SensitiveDataMasker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SensitiveDataMaskerTest { + + private SensitiveDataMasker sensitiveDataMasker; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + sensitiveDataMasker = new SensitiveDataMasker(); + } + + @DisplayName("민감한 필드 이름이 주어지면 데이터를 마스킹할 때 마스킹된 값을 반환해야 한다.") + @Test + void givenSensitiveField_whenMaskSensitiveData_thenMasked() throws JsonProcessingException { + // Given + String fieldName = "password"; + String value = "mySecretPassword"; + + // When + Object result = sensitiveDataMasker.maskSensitiveData(fieldName, value, objectMapper); + + // Then + assertEquals("********", result); + } + + @DisplayName("민감한 데이터가 포함된 Map에서 민감한 필드는 마스킹되어야 한다.") + @Test + void givenMapWithSensitiveData_whenMaskSensitiveData_thenMasked() throws JsonProcessingException { + // Given + Map data = new HashMap<>(); + data.put("password", "123456"); + data.put("username", "user123"); + + // When + Object result = sensitiveDataMasker.maskSensitiveData("testField", data, objectMapper); + + // Then + assertEquals("********", ((Map) result).get("password")); + assertEquals("user123", ((Map) result).get("username")); + } + + @DisplayName("민감한 데이터를 포함하는 객체는 민감한 필드를 마스킹 해야한다.") + @Test + void givenComplexObject_whenMaskSensitiveData_thenMasked() throws JsonProcessingException { + // Given + Map nestedData = new HashMap<>(); + nestedData.put("token", "abcd1234"); + nestedData.put("email", "test@example.com"); + + Map data = new HashMap<>(); + data.put("user", nestedData); + data.put("username", "user123"); + + // When + Object result = sensitiveDataMasker.maskSensitiveData("testField", data, objectMapper); + + // Then + Map maskedUser = (Map) ((Map) result).get("user"); + assertEquals("********", maskedUser.get("token")); + assertEquals("test@example.com", maskedUser.get("email")); + assertEquals("user123", ((Map) result).get("username")); + } + + @DisplayName("일반 데이터는 마스킹되지 않아야 한다.") + @Test + void givenPrimitiveValue_whenMaskSensitiveData_thenUnchanged() throws JsonProcessingException { + // Given + int intValue = 12345; + boolean boolValue = true; + String textValue = "text"; + + // When & Then + assertEquals(intValue, sensitiveDataMasker.maskSensitiveData("field", intValue, objectMapper)); + assertEquals(boolValue, sensitiveDataMasker.maskSensitiveData("field", boolValue, objectMapper)); + assertEquals(textValue, sensitiveDataMasker.maskSensitiveData("field", textValue, objectMapper)); + } +}