init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
# Exception Log 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 대표 실패만 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(ExternalAuthUnavailableException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleExternalAuthUnavailable(
|
||||
ExternalAuthUnavailableException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
log.error("Failed request. requestPath={} method={} errorCode={} actorId={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE.code(),
|
||||
request.getAttribute("actorId"),
|
||||
ex);
|
||||
|
||||
ErrorCode errorCode = ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE;
|
||||
|
||||
return ResponseEntity.status(errorCode.httpStatus())
|
||||
.body(ApiResult.fail(errorCode));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대표 ERROR 로그가 한 곳에 모인다
|
||||
- 메시지와 응답 코드가 분리된다
|
||||
- 운영 키와 stack trace가 함께 남는다
|
||||
|
||||
### 예시 2. 재시도 중간 실패는 WARN 또는 DEBUG로만 남긴다
|
||||
|
||||
```java
|
||||
try {
|
||||
return externalAuthClient.issueToken(command);
|
||||
} catch (SocketTimeoutException ex) {
|
||||
log.warn("External auth attempt failed. provider={} actorId={} attempt={}",
|
||||
"keycloak", command.actorId(), attemptNumber);
|
||||
throw ex;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 중간 실패를 곧바로 대표 장애처럼 기록하지 않는다
|
||||
- 재시도 맥락이 드러난다
|
||||
|
||||
### 예시 3. validation 실패는 ERROR로 과장하지 않는다
|
||||
|
||||
```java
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
log.info("Rejected invalid request. requestPath={} method={} actorId={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
request.getAttribute("actorId"));
|
||||
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
FieldError::getField,
|
||||
DefaultMessageSourceResolvable::getDefaultMessage,
|
||||
(first, second) -> first
|
||||
));
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResult.fail(ErrorCode.REQUEST_VALIDATION_FAILED, errors));
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 예상 가능한 4xx를 서버 장애처럼 기록하지 않는다
|
||||
- 필요한 요청 맥락은 남긴다
|
||||
|
||||
### 예시 4. 메시지는 사건 설명 중심으로 쓴다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} requestPath={} actorId={} durationMs={}",
|
||||
provider, requestPath, actorId, durationMs, ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 무엇이 실패했는지 바로 보인다
|
||||
- 예외 메시지 품질에 로그 제목이 종속되지 않는다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 같은 예외를 여러 레이어에서 반복 ERROR로 찍는다
|
||||
|
||||
```java
|
||||
log.error("Client failed", ex);
|
||||
log.error("Service failed", ex);
|
||||
log.error("Controller failed", ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 실패가 여러 번 기록된다
|
||||
- 검색/알림/집계 품질이 나빠진다
|
||||
|
||||
### 예시 2. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 사건 맥락이 없다
|
||||
- 민감정보가 메시지에 섞일 수 있다
|
||||
- 운영 키가 없다
|
||||
|
||||
### 예시 3. 요청 본문 전체를 예외 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.error("Failed create user request. requestBody={}", requestBody, ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PII/비밀번호/토큰이 유출될 수 있다
|
||||
- payload 전문 로그는 기본 금지다
|
||||
|
||||
### 예시 4. 예상 가능한 business rejection을 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
log.error("Duplicate email sign-up attempt. email={}", request.email());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 서버 장애처럼 과장된다
|
||||
- 개인식별정보 전체값이 그대로 남는다
|
||||
- 운영 신호를 오염시킨다
|
||||
@@ -0,0 +1,82 @@
|
||||
# Log Level 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 최종 실패만 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
try {
|
||||
externalAuthClient.issueToken(command);
|
||||
} catch (ExternalAuthException ex) {
|
||||
log.error("Failed to issue external auth token. provider={}, actorId={}", "auth-provider", command.actorId(), ex);
|
||||
throw ex;
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 최종 실패를 명확히 드러낸다
|
||||
- 운영자가 바로 봐야 할 사건이다
|
||||
|
||||
### 예시 2. 재시도 후 성공은 WARN으로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("External auth request succeeded after retry. provider={}, actorId={}, attempts={}",
|
||||
"auth-provider", actorId, attemptCount);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 즉시 실패는 아니지만 이상 징후다
|
||||
- 운영 추적 가치가 있다
|
||||
|
||||
### 예시 3. 상세 분기 정보는 DEBUG에 둔다
|
||||
|
||||
```java
|
||||
log.debug("Mapped external auth response to internal token result. provider={}, tokenType={}",
|
||||
"auth-provider", response.tokenType());
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 상세 흐름 파악용이다
|
||||
- 기본 운영 레벨에서는 숨겨진다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 예상 가능한 비즈니스 거절을 ERROR로 남긴다
|
||||
|
||||
```java
|
||||
log.error("Duplicate email sign-up attempt. email={}", request.email());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 서버 장애처럼 과장된다
|
||||
- 실제 운영 신호가 묻힌다
|
||||
|
||||
### 예시 2. 같은 예외를 여러 계층에서 모두 ERROR로 찍는다
|
||||
|
||||
```java
|
||||
log.error("Repository failed", ex);
|
||||
log.error("Service failed", ex);
|
||||
log.error("Controller failed", ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 실패가 세 번 기록된다
|
||||
- 검색/알림/분석 품질이 떨어진다
|
||||
|
||||
### 예시 3. production 상시 로그에 과도한 상세를 남긴다
|
||||
|
||||
```java
|
||||
log.info("Request payload={}", requestBody);
|
||||
log.info("Response payload={}", responseBody);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 노이즈가 많다
|
||||
- 민감정보 노출 위험이 크다
|
||||
- INFO 레벨 의미를 무너뜨린다
|
||||
@@ -0,0 +1,102 @@
|
||||
# Log Message Format 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 사건 설명 + key-value 필드를 함께 남긴다
|
||||
|
||||
```java
|
||||
log.info("Created user. actorId={} userId={} requestPath={}",
|
||||
actorId, userId, requestPath);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 사건이 짧게 드러난다
|
||||
- 검색 가능한 핵심 키가 있다
|
||||
- free text만으로 끝나지 않는다
|
||||
|
||||
### 예시 2. 실패 메시지에 운영 키를 먼저 담고 예외를 붙인다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} actorId={} durationMs={}",
|
||||
provider, actorId, durationMs, ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 메시지 자체만 봐도 무엇이 실패했는지 알 수 있다
|
||||
- stack trace는 추가 정보로 붙는다
|
||||
- 운영 키가 빠지지 않는다
|
||||
|
||||
### 예시 3. 재시도/대체 경로도 명시적 사건으로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Applied external auth fallback. provider={} actorId={} fallback={} durationMs={}",
|
||||
provider, actorId, "cached-public-key", durationMs);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fallback 발생 사실이 바로 드러난다
|
||||
- 이후 검색과 집계가 쉽다
|
||||
|
||||
### 예시 4. structured logging 전환을 고려한 키 이름을 쓴다
|
||||
|
||||
```java
|
||||
log.info("Completed session cleanup. job={} deletedCount={} durationMs={}",
|
||||
"expired-session-cleanup", deletedCount, durationMs);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 텍스트 로그에서도 구조가 보인다
|
||||
- JSON 로그로 전환해도 의미가 유지된다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 설명만 길고 검색 키가 없다
|
||||
|
||||
```java
|
||||
log.info("The user registration process was completed successfully after all checks had been passed");
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 누가, 어떤 요청에서, 어떤 리소스가 생성됐는지 알 수 없다
|
||||
- 검색/집계가 어렵다
|
||||
|
||||
### 예시 2. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 사건 맥락이 없다
|
||||
- 운영 키가 없다
|
||||
- 예외 메시지 품질에 로그 제목이 종속된다
|
||||
|
||||
### 예시 3. 민감정보를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.debug("Login request. email={} password={} accessToken={}",
|
||||
request.email(), request.password(), accessToken);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 민감정보가 원문으로 노출된다
|
||||
- 디버그 로그라도 허용되지 않는다
|
||||
|
||||
### 예시 4. 같은 의미를 제각각 다른 키 이름으로 쓴다
|
||||
|
||||
```java
|
||||
log.info("Created user. uid={} path={} timeMs={}", userId, requestPath, durationMs);
|
||||
log.info("Deleted user. userId={} requestUri={} duration={}", userId, requestPath, durationMs);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 의미의 키 이름이 섞인다
|
||||
- 검색/집계/알람 규칙이 복잡해진다
|
||||
@@ -0,0 +1,150 @@
|
||||
# Operation Indicator / Health Check 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 기본 actuator health와 probe 경로를 그대로 사용한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: 8081
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: 8081
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring Boot 기본 probe group을 그대로 사용한다
|
||||
- Kubernetes가 기대하는 liveness/readiness 의미와 맞다.
|
||||
|
||||
### 예시 2. management 포트 분리 시 main port에도 /livez, /readyz를 노출한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
server:
|
||||
port: 8081
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
add-additional-paths: true
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- actuator 전용 포트만 살아 있고 실제 애플리케이션 포트는 문제인 상황을 줄일 수 있다
|
||||
- Spring Boot도 이 구성을 좋은 아이디어로 안내한다.
|
||||
|
||||
### 예시 3. readiness에만 필수 내부 준비 상태를 추가한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: "readinessState,customCheck"
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- readiness에 필요한 추가 체크만 명시적으로 포함한다
|
||||
- liveness와 readiness를 구분해서 설계한다.
|
||||
|
||||
### 예시 4. startup이 긴 서비스에는 startup probe를 둔다
|
||||
|
||||
```yaml
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 느린 시작 중 liveness 오탐을 줄일 수 있다
|
||||
- startup probe는 성공 전까지 liveness/readiness 실행을 지연시킨다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. liveness에 DB 상태를 직접 넣는다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadDatabaseLivenessIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
return databaseClient.ping() ? Health.up().build() : Health.down().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 외부 DB 장애가 모든 인스턴스 재시작으로 이어질 수 있다
|
||||
- Spring Boot는 liveness를 외부 체크 기반으로 두지 말라고 권고한다.
|
||||
|
||||
### 예시 2. 모든 외부 시스템을 readiness에 무조건 포함한다
|
||||
|
||||
```yaml
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
group:
|
||||
readiness:
|
||||
include: "readinessState,db,redis,kafka,s3,externalApiA,externalApiB"
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공유 외부 시스템 장애 시 전체 인스턴스가 동시에 ready=false가 될 수 있다
|
||||
- fallback 가능한 비필수 시스템도 서비스 제외 원인이 된다.
|
||||
|
||||
### 예시 3. probe 용 controller를 별도로 만든다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
public class BadHealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
public Map<String, Object> health() {
|
||||
return Map.of("status", "UP");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- actuator가 이미 제공하는 운영 계약과 분리된다
|
||||
- liveness/readiness/group 정책과 연계되지 않는다
|
||||
- health semantics를 임의 JSON으로 약화시킨다.
|
||||
|
||||
### 예시 4. health indicator에서 무거운 쿼리를 수행한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class BadSlowHealthIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
analyticsRepository.runExpensiveAggregation();
|
||||
return Health.up().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- health endpoint 자체가 느려진다
|
||||
- Spring Boot도 느린 indicator를 warning 대상으로 본다.
|
||||
@@ -0,0 +1,122 @@
|
||||
# PII Masking 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 내부 식별자만 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.info("Completed password reset request. actorId={} requestPath={}",
|
||||
actorId, requestPath);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 누구의 요청인지는 추적 가능하다
|
||||
- 이메일/전화번호/비밀번호는 남기지 않는다
|
||||
|
||||
### 예시 2. 토큰은 일부만 식별 가능하게 남긴다
|
||||
|
||||
```java
|
||||
String maskedToken = TokenMasker.prefix(token);
|
||||
log.warn("Rejected external callback due to invalid token. provider={} tokenPrefix={}",
|
||||
provider, maskedToken);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 토큰 전체 원문을 남기지 않는다
|
||||
- 운영상 일부 식별은 가능하다
|
||||
|
||||
### 예시 3. 이메일은 부분 마스킹한다
|
||||
|
||||
```java
|
||||
String maskedEmail = EmailMasker.mask(request.email());
|
||||
log.info("Started email verification. actorId={} email={}",
|
||||
actorId, maskedEmail);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 메일 발송 대상 추적은 가능하다
|
||||
- 개인 식별정보 전체값을 남기지 않는다
|
||||
|
||||
### 예시 4. structured logging에도 안전한 필드만 넣는다
|
||||
|
||||
```java
|
||||
MDC.put("actorId", actorId);
|
||||
MDC.put("requestPath", request.getRequestURI());
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 운영 상관관계 필드는 남긴다
|
||||
- token/session/password 같은 값은 MDC에 올리지 않는다
|
||||
|
||||
### 예시 5. 외부 오류 메시지는 정제해서 남긴다
|
||||
|
||||
```java
|
||||
log.error("Failed external auth request. provider={} status={} errorCode={}",
|
||||
provider, status, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", ex);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 시스템 에러 본문 원문을 그대로 노출하지 않는다
|
||||
- 운영 키와 표준 에러 코드 중심으로 남긴다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. Authorization 헤더를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.debug("Incoming request. authorization={}", request.getHeader("Authorization"));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- access token 원문이 로그로 유출된다
|
||||
- 디버그 로그라도 허용되지 않는다
|
||||
|
||||
### 예시 2. request body 전체를 남긴다
|
||||
|
||||
```java
|
||||
log.info("Create user request body={}", requestBody);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 비밀번호, 이메일, 전화번호 등 민감값이 함께 들어갈 수 있다
|
||||
- 운영 로그 노이즈도 크다
|
||||
|
||||
### 예시 3. 세션 ID를 그대로 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Invalid session. sessionId={}", sessionId);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 세션 식별값 원문이 노출된다
|
||||
- OWASP도 세션 식별값은 직접 로그에 남기지 말라고 권고한다.
|
||||
|
||||
### 예시 4. 예외 메시지를 그대로 제목으로 쓴다
|
||||
|
||||
```java
|
||||
log.error(ex.getMessage(), ex);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 예외 메시지 안 민감정보가 그대로 노출될 수 있다
|
||||
- 사건 설명과 안전한 운영 키가 없다
|
||||
|
||||
### 예시 5. 외부 입력을 정제 없이 로그에 넣는다
|
||||
|
||||
```java
|
||||
log.warn("Rejected request. keyword={}", request.getParameter("keyword"));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 줄바꿈/제어문자/악성 문자열이 로그 형식을 깨뜨릴 수 있다
|
||||
- 민감 검색어가 그대로 남을 수 있다
|
||||
@@ -0,0 +1,161 @@
|
||||
# Trace / Principal / Path Recording 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. controller는 현재 사용자 식별자를 명시적으로 받는다
|
||||
|
||||
```java
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@AuthenticationPrincipal(expression = "userId")
|
||||
public @interface CurrentUserId {
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserQueryController {
|
||||
|
||||
private final UserQueryUseCase userQueryUseCase;
|
||||
|
||||
@GetMapping("/{userId}")
|
||||
public ApiResult<UserResponse> getUser(
|
||||
@CurrentUserId String actorId,
|
||||
@PathVariable String userId
|
||||
) {
|
||||
UserResult result = userQueryUseCase.getUser(actorId, userId);
|
||||
return ApiResult.success(new UserResponse(result.userId(), result.email()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- principal 접근이 controller 시그니처에서 드러난다
|
||||
- SecurityContextHolder 직접 접근이 없다
|
||||
- application에는 최소 actor 정보만 전달한다
|
||||
|
||||
### 예시 2. 공통 요청 로그는 한 곳에서 남긴다
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class RequestLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
long startNanos = System.nanoTime();
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
long durationMs = Duration.ofNanos(System.nanoTime() - startNanos).toMillis();
|
||||
|
||||
log.info("Completed request. requestPath={} method={} status={} durationMs={}",
|
||||
request.getRequestURI(),
|
||||
request.getMethod(),
|
||||
response.getStatus(),
|
||||
durationMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대표 요청 로그가 공통 위치에 있다
|
||||
- requestPath와 method/status/duration이 일관되게 남는다
|
||||
- controller마다 요청 로그를 복붙하지 않는다
|
||||
|
||||
### 예시 3. principal은 내부 식별자만 남긴다
|
||||
|
||||
```java
|
||||
log.warn("Rejected request. requestPath={} actorId={} errorCode={}",
|
||||
requestPath, actorId, "ACCESS_DENIED");
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 이메일/토큰 같은 민감정보 대신 내부 식별자를 남긴다
|
||||
- principal 검색 가능성과 개인정보 보호를 함께 고려한다
|
||||
|
||||
### 예시 4. route template를 별도 필드로 둘 수 있다
|
||||
|
||||
```java
|
||||
log.info("Completed request. requestPath={} route={} method={} status={} durationMs={}",
|
||||
"/api/v1/users/123",
|
||||
"/api/v1/users/{userId}",
|
||||
"GET",
|
||||
200,
|
||||
21);
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 요청과 집계용 route를 분리할 수 있다
|
||||
- 고카디널리티 문제를 운영에서 다루기 쉬워진다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 SecurityContextHolder를 직접 읽는다
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/me")
|
||||
public ApiResult<String> me() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
|
||||
|
||||
log.info("Current request. path={} principal={}", request.getRequestURI(), principal);
|
||||
return ApiResult.success(principal.getUserId());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- principal 접근과 로깅 규칙이 controller에 퍼진다
|
||||
- principal 전체 객체가 로그에 노출될 수 있다
|
||||
- current user 접근 방식이 일관되지 않다
|
||||
|
||||
### 예시 2. query string 전체를 기본 로그에 남긴다
|
||||
|
||||
```java
|
||||
log.info("Incoming request. requestPath={} query={}",
|
||||
request.getRequestURI(),
|
||||
request.getQueryString());
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 검색어, 토큰, 식별자 등 민감정보가 섞일 수 있다
|
||||
- 운영 로그에 노이즈가 많아진다
|
||||
|
||||
### 예시 3. 같은 의미를 여러 키 이름으로 섞는다
|
||||
|
||||
```java
|
||||
log.info("Completed request. uri={} userId={}", requestPath, actorId);
|
||||
log.info("Failed request. path={} principalId={}", requestPath, actorId);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- uri/path/requestPath, userId/actorId/principalId가 혼용된다
|
||||
- 검색/집계 규칙이 깨진다
|
||||
|
||||
### 예시 4. 요청 로그를 여러 레이어에서 반복한다
|
||||
|
||||
```java
|
||||
log.info("Controller request. requestPath={}", requestPath);
|
||||
log.info("Service request. requestPath={}", requestPath);
|
||||
log.info("Client request. requestPath={}", requestPath);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 대표 요청 로그가 중복된다
|
||||
- 실제 중요한 비즈니스/연동 로그가 묻힌다
|
||||
Reference in New Issue
Block a user