Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import com.loopon.global.domain.ErrorCode;
import com.loopon.global.domain.dto.CommonResponse;
import com.loopon.global.domain.dto.CommonResponse.ValidationErrorDetail;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
Expand All @@ -25,36 +25,32 @@
public class GlobalExceptionAdvice {

@ExceptionHandler(BusinessException.class)
public ResponseEntity<CommonResponse<Void>> handleBusinessException(BusinessException ex) {
log.warn("Business Exception: [{}] {}", ex.getErrorCode().getCode(), ex.getMessage());
public ResponseEntity<CommonResponse<Void>> handleBusinessException(BusinessException ex, HttpServletRequest request) {
log.warn("[BUS] {} {} | {}: {}", request.getMethod(), request.getRequestURI(), ex.getErrorCode().getCode(), ex.getMessage());
return ResponseEntity
.status(ex.getErrorCode().getStatus())
.body(CommonResponse.onFailure(ex.getErrorCode()));
}

@ExceptionHandler({
BindException.class,
MethodArgumentNotValidException.class
})
public ResponseEntity<CommonResponse<List<ValidationErrorDetail>>> handleValidationException(BindException ex) {
log.warn("Validation Error: {}", ex.getBindingResult().getFieldError() != null
@ExceptionHandler({BindException.class, MethodArgumentNotValidException.class})
public ResponseEntity<CommonResponse<List<CommonResponse.ValidationErrorDetail>>> handleValidationException(BindException ex, HttpServletRequest request) {
String errorMessage = ex.getBindingResult().getFieldError() != null
? ex.getBindingResult().getFieldError().getDefaultMessage()
: "Unknown Validation Error");
: "Unknown Validation Error";

log.warn("[VAL] {} {} | Msg: {}", request.getMethod(), request.getRequestURI(), errorMessage);

return ResponseEntity
.status(ErrorCode.INVALID_INPUT_VALUE.getStatus())
.body(CommonResponse.onFailure(
ErrorCode.INVALID_INPUT_VALUE,
ex.getBindingResult()
));
.body(CommonResponse.onFailure(ErrorCode.INVALID_INPUT_VALUE, ex.getBindingResult()));
}

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<CommonResponse<List<ValidationErrorDetail>>> handleConstraintViolationException(ConstraintViolationException ex) {
log.warn("Constraint Violation: {}", ex.getMessage());
public ResponseEntity<CommonResponse<List<CommonResponse.ValidationErrorDetail>>> handleConstraintViolationException(ConstraintViolationException ex, HttpServletRequest request) {
log.warn("[VAL] {} {} | Msg: {}", request.getMethod(), request.getRequestURI(), ex.getMessage());

List<ValidationErrorDetail> details = ex.getConstraintViolations().stream()
.map(violation -> new ValidationErrorDetail(
List<CommonResponse.ValidationErrorDetail> details = ex.getConstraintViolations().stream()
.map(violation -> new CommonResponse.ValidationErrorDetail(
violation.getPropertyPath().toString(),
violation.getMessage()
))
Expand All @@ -72,8 +68,8 @@ public ResponseEntity<CommonResponse<List<ValidationErrorDetail>>> handleConstra
HttpRequestMethodNotSupportedException.class,
IllegalArgumentException.class
})
public ResponseEntity<CommonResponse<Void>> handleBadRequest(Exception ex) {
log.warn("Client Error: [{}] {}", ex.getClass().getSimpleName(), ex.getMessage());
public ResponseEntity<CommonResponse<Void>> handleBadRequest(Exception ex, HttpServletRequest request) {
log.warn("[BAD] {} {} | {}: {}", request.getMethod(), request.getRequestURI(), ex.getClass().getSimpleName(), ex.getMessage());

ErrorCode errorCode = switch (ex) {
case NoHandlerFoundException ignored -> ErrorCode.NOT_FOUND;
Expand All @@ -88,32 +84,25 @@ public ResponseEntity<CommonResponse<Void>> handleBadRequest(Exception ex) {
}

@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<CommonResponse<Void>> handleAuthenticationException(AuthenticationException ex) {
log.warn("Authentication Exception: {}", ex.getMessage());
public ResponseEntity<CommonResponse<Void>> handleAuthenticationException(AuthenticationException ex, HttpServletRequest request) {
log.warn("[AUTH] {} {} | {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
return ResponseEntity
.status(ErrorCode.UNAUTHORIZED.getStatus())
.body(CommonResponse.onFailure(ErrorCode.UNAUTHORIZED));
}

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<CommonResponse<Void>> handleAccessDeniedException(AccessDeniedException ex) {
log.warn("Access Denied Exception: {}", ex.getMessage());
public ResponseEntity<CommonResponse<Void>> handleAccessDeniedException(AccessDeniedException ex, HttpServletRequest request) {
log.warn("[DENY] {} {} | {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
return ResponseEntity
.status(ErrorCode.FORBIDDEN.getStatus())
.body(CommonResponse.onFailure(ErrorCode.FORBIDDEN));
}

@ExceptionHandler(AuthorizationException.class)
public ResponseEntity<CommonResponse<Void>> handleAuthorizationException(AuthorizationException ex) {
log.warn("Authorization Custom Exception: {}", ex.getMessage());
return ResponseEntity
.status(ex.getErrorCode().getStatus())
.body(CommonResponse.onFailure(ex.getErrorCode()));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<CommonResponse<Void>> handleException(Exception ex) {
log.error("Unhandled Exception: ", ex);
public ResponseEntity<CommonResponse<Void>> handleException(Exception ex, HttpServletRequest request) {
log.error("[ERR] {} {} | Exception: ", request.getMethod(), request.getRequestURI(), ex);

return ResponseEntity
.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus())
.body(CommonResponse.onFailure(ErrorCode.INTERNAL_SERVER_ERROR));
Comment on lines 102 to 108
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GlobalExceptionAdvice에서 AuthorizationException에 대한 핸들러가 제거되었지만, 이 예외는 여전히 코드베이스 전체에서 사용되고 있습니다.

확인된 사용 위치:

  • AuthService.java: reissueTokens 메서드에서 INVALID_REFRESH_TOKEN, REFRESH_TOKEN_NOT_FOUND 에러 시 발생
  • JwtTokenValidatorImpl.java: JWT 검증 실패 시 JWT_MALFORMED, JWT_EXPIRED, JWT_INVALID, JWT_MISSING, UNAUTHORIZED 에러로 발생
  • JwtAuthenticationFilter.java: catch 블록에서 처리

AuthorizationException이 발생하면 이제 일반 Exception 핸들러로 처리되어 500 INTERNAL_SERVER_ERROR로 응답됩니다. 이는 의도한 동작이 아닐 가능성이 높습니다.

AuthorizationException 핸들러를 다시 추가하거나, 이 예외를 다른 예외 타입으로 변경하는 작업이 필요합니다.

Copilot uses AI. Check for mistakes.
Expand Down
132 changes: 34 additions & 98 deletions src/main/java/com/loopon/global/log/LogAspect.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,134 +5,70 @@
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.CodeSignature;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;

import java.util.HashMap;
import java.util.Map;
import java.util.Arrays;

@Component
@Aspect
@Slf4j
public class LogAspect {

private static final String[] SENSITIVE_KEYWORDS = {
"password", "pw", "secret",
"token", "access", "refresh",
"auth", "cred",
"key", "pin", "card",
"ssn", "social"
};

@Pointcut("execution(* com.loopon..*Controller.*(..))")
public void controllerMethods() {
@Pointcut("execution(* com.loopon.*Controller.*(..))")
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pointcut 표현식이 변경되어 패키지 구조가 일치하지 않는 Controller들을 누락시킬 수 있습니다.

변경 전: "execution(* com.loopon..Controller.(..))" - 모든 하위 패키지의 Controller 포함
변경 후: "execution(* com.loopon.Controller.(..))" - 직접 하위 패키지의 Controller만 포함

실제 Controller들은 com.loopon.auth.presentation.AuthApiController, com.loopon.user.presentation.UserApiController 등 2단계 이상 깊은 패키지에 위치하므로, 현재 Pointcut은 어떤 Controller도 매칭하지 않습니다.

원래의 "..*Controller" 패턴으로 되돌려야 합니다.

Suggested change
@Pointcut("execution(* com.loopon.*Controller.*(..))")
@Pointcut("execution(* com.loopon..*Controller.*(..))")

Copilot uses AI. Check for mistakes.
public void controller() {
}

@Pointcut("execution(* com.loopon..*Service.*(..))")
public void serviceMethods() {
public void service() {
}

@Around("controllerMethods() || serviceMethods()")
@Around("controller() || service()")
public Object logExecution(ProceedingJoinPoint joinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
MethodInfo methodInfo = null;
long startTime = System.currentTimeMillis();
Object result = null;

try {
methodInfo = extractMethodInfo(joinPoint);

stopWatch.start();
Object result = joinPoint.proceed();
stopWatch.stop();

logSuccess(methodInfo, result, stopWatch);
result = joinPoint.proceed();
return result;

} catch (Throwable e) {
if (stopWatch.isRunning()) {
stopWatch.stop();
}

logFailure(methodInfo, e, stopWatch);
throw e;
} finally {
long totalTime = System.currentTimeMillis() - startTime;
MethodSignature signature = (MethodSignature) joinPoint.getSignature();

log.info("[{}] {}.{} | Args: {} | Ret: {} | {}ms",
getLayer(signature), // 1. 레이어 (API / SVC)
className(signature), // 2. 클래스명
methodName(signature), // 3. 메서드명
formatArgs(joinPoint.getArgs()), // 4. 파라미터 (단순화)
formatResult(result), // 5. 결과값 (길이 제한)
totalTime // 6. 소요시간
);
}
Comment on lines 27 to 46
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외 발생 시 로깅 기능이 완전히 제거되었습니다. 이전 코드에서는 try-catch 블록에서 예외를 캐치하여 logFailure 메서드로 상세한 에러 로그(클래스명, 메서드명, 파라미터, 예외 타입, 메시지, 소요시간)를 남겼습니다.

현재 코드는 finally 블록에서만 로깅하므로, 예외가 발생한 경우와 정상 실행된 경우를 구분할 수 없고, 예외 정보가 로그에 전혀 남지 않습니다.

GlobalExceptionAdvice에서 예외를 로깅하고 있지만, 이는 HTTP 요청 레벨의 예외만 처리하며, Service 계층에서 발생한 예외의 상세 컨텍스트(어떤 파라미터로 호출되었는지 등)를 추적하기 어렵습니다.

예외 발생 시 로그 레벨을 error로, 정상 실행 시 info로 구분하여 로깅하는 것을 권장합니다.

Copilot uses AI. Check for mistakes.
}

private void logSuccess(MethodInfo methodInfo, Object result, StopWatch stopWatch) {
log.info("{} | {}({}) -> {} [{}ms]",
methodInfo.className(),
methodInfo.methodName(),
methodInfo.params(),
formatResult(result),
stopWatch.getTotalTimeMillis());
}

private void logFailure(MethodInfo info, Throwable e, StopWatch stopWatch) {
String className = (info != null) ? info.className() : "UnknownClass";
String methodName = (info != null) ? info.methodName() : "UnknownMethod";
Map<String, Object> params = (info != null) ? info.params() : Map.of();

log.error("{} | {}({}) -> [EXCEPTION] {}: {} [{}ms]",
className,
methodName,
params,
e.getClass().getSimpleName(),
e.getMessage(),
stopWatch.getTotalTimeMillis()
);
}

private MethodInfo extractMethodInfo(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
private String getLayer(MethodSignature signature) {
String className = signature.getDeclaringType().getSimpleName();
String methodName = signature.getName();
Map<String, Object> params = extractParams(joinPoint);

return new MethodInfo(className, methodName, params);
if (className.endsWith("Controller")) return "API";
if (className.endsWith("Service")) return "SVC";
return "ETC";
}

private Map<String, Object> extractParams(ProceedingJoinPoint joinPoint) {
CodeSignature signature = (CodeSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
Map<String, Object> params = new HashMap<>();

if (paramNames == null) return params;

for (int i = 0; i < paramNames.length; i++) {
String key = paramNames[i];
Object value = args[i];
if (isSensitive(key)) {
params.put(key, "****");
} else {
params.put(key, value);
}
}
return params;
private String className(MethodSignature signature) {
return signature.getDeclaringType().getSimpleName();
}

private boolean isSensitive(String key) {
if (key == null) return false;

String lowerKey = key.toLowerCase();
private String methodName(MethodSignature signature) {
return signature.getName();
}

for (String keyword : SENSITIVE_KEYWORDS) {
if (lowerKey.contains(keyword)) {
return true;
}
}
return false;
private String formatArgs(Object[] args) {
if (args == null || args.length == 0) return "[]";
return Arrays.toString(args);
Comment on lines +65 to +66
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

민감한 정보(password, token 등)에 대한 마스킹 처리가 제거되었습니다. 이전 코드에서는 SENSITIVE_KEYWORDS 배열을 사용하여 password, token, secret 등의 민감한 파라미터를 "****"로 마스킹했습니다.

현재 코드는 Arrays.toString(args)를 직접 사용하여 모든 파라미터 값을 그대로 로그에 출력하므로, LoginRequest의 password 필드나 AuthService의 token 파라미터 등이 평문으로 로그에 기록됩니다.

이는 심각한 보안 취약점입니다. 로그 파일이 노출되거나 로그 모니터링 시스템에 접근 가능한 사람이 사용자의 비밀번호나 인증 토큰을 확인할 수 있습니다.

이전의 민감 정보 마스킹 로직을 다시 적용하거나, 파라미터 로깅 자체를 제거하는 것을 고려해야 합니다.

Suggested change
if (args == null || args.length == 0) return "[]";
return Arrays.toString(args);
if (args == null || args.length == 0) {
return "[]";
}
// 민감 정보 노출 방지를 위해 실제 파라미터 값은 로그에 남기지 않는다.
return "[MASKED]";

Copilot uses AI. Check for mistakes.
}

private String formatResult(Object result) {
if (result == null) return "void";
String resultStr = result.toString();
if (resultStr.length() > 100) {
return resultStr.substring(0, 100) + "...";
}
return resultStr;
String s = result.toString();
return s.length() > 100 ? s.substring(0, 100) + "..." : s;
Comment on lines +71 to +72
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

변수명 's'는 의미가 불명확합니다. 이전 코드에서는 'resultStr'을 사용하여 변수의 역할이 명확했습니다.

간단한 로직이지만 'resultString' 또는 'resultStr'과 같이 의미를 명확히 전달하는 이름을 사용하는 것이 좋습니다.

Suggested change
String s = result.toString();
return s.length() > 100 ? s.substring(0, 100) + "..." : s;
String resultStr = result.toString();
return resultStr.length() > 100 ? resultStr.substring(0, 100) + "..." : resultStr;

Copilot uses AI. Check for mistakes.
}

private record MethodInfo(String className, String methodName, Map<String, Object> params) {}
}
20 changes: 12 additions & 8 deletions src/main/java/com/loopon/global/log/MdcLoggingFilter.java
Original file line number Diff line number Diff line change
@@ -1,34 +1,38 @@
package com.loopon.global.log;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcLoggingFilter implements Filter {
public class MdcLoggingFilter extends OncePerRequestFilter {

private static final String REQUEST_ID = "request_id";

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String requestId = UUID.randomUUID().toString().substring(0, 8);
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestId = request.getHeader("X-Request-ID");
if (requestId == null || requestId.isEmpty()) {
requestId = UUID.randomUUID().toString().substring(0, 8);
}

MDC.put(REQUEST_ID, requestId);
response.setHeader("X-Request-ID", requestId);

try {
chain.doFilter(request, response);
filterChain.doFilter(request, response);
} finally {
MDC.clear();
}
Expand Down
Loading