diff --git a/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java b/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java
index 149cd12..c5cbabc 100644
--- a/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java
+++ b/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java
@@ -1,23 +1,46 @@
package com.zioinfo.mall.common;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
+import java.sql.SQLException;
+
/**
* 전역 예외 처리.
*
- *
보안 불변 규칙: 스택트레이스를 응답에 절대 노출하지 않는다.
- * 에러 코드 + 요약 메시지만 반환한다.
+ *
보안 불변 규칙: 스택트레이스 · SQL문 · 테이블/컬럼명 · DB 드라이버 메시지를
+ * 응답에 절대 노출하지 않는다. 클라이언트에는 에러 코드 + 일반 요약 메시지만 반환하고,
+ * 실제 원인은 서버 로그(logger.error)에만 기록한다.
+ *
+ *
업무/검증 예외는 {@code ERR-XXX-NNN:} 형태의 비민감 메시지만 통과시킨다.
+ * 그 외(미분류 RuntimeException, DataAccessException, SQLException, 일반 Exception)는
+ * 메시지를 마스킹하고 일반 코드로만 응답한다.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
+ /**
+ * 안전한 업무/검증 에러 코드 패턴 (예: ERR-MALL-413, ERR-ORD-404, ERR-LOY-400 ...).
+ * 메시지가 이 코드로 시작하면 사전 정의된 비민감 메시지로 간주하여 그대로 노출한다.
+ * 그렇지 않은 메시지(SQL/드라이버/내부 원인 가능성)는 응답에서 마스킹한다.
+ */
+ private static final java.util.regex.Pattern SAFE_CODE =
+ java.util.regex.Pattern.compile("^ERR-[A-Z0-9]+-[A-Z0-9]+\\b.*");
+
+ /** 사전 정의된 안전 코드 메시지인지 검사. */
+ private static boolean isSafeBusinessMessage(String message) {
+ return message != null && SAFE_CODE.matcher(message).matches();
+ }
+
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public ApiResponse handleMaxSize(MaxUploadSizeExceededException e) {
@@ -28,16 +51,53 @@ public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse handleIllegalArg(IllegalArgumentException e) {
- log.warn("잘못된 요청: {}", e.getMessage());
- return ApiResponse.fail(e.getMessage());
+ // 사전 정의된 비민감 검증 메시지(ERR-XXX-NNN)만 노출, 그 외는 마스킹
+ if (isSafeBusinessMessage(e.getMessage())) {
+ log.warn("잘못된 요청: {}", e.getMessage());
+ return ApiResponse.fail(e.getMessage());
+ }
+ log.warn("잘못된 요청(메시지 마스킹)", e);
+ return ApiResponse.fail("ERR-MALL-400: 잘못된 요청입니다");
+ }
+
+ /**
+ * DB 접근 오류 — SQL문/테이블·컬럼명/드라이버 메시지가 e.getMessage()에 포함되므로
+ * 절대 응답에 노출하지 않는다. 원인은 서버 로그에만 기록.
+ */
+ @ExceptionHandler({DataAccessException.class, SQLException.class})
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public ApiResponse handleDataAccess(Exception e) {
+ log.error("데이터 접근 오류", e);
+ return ApiResponse.fail("ERR-MALL-DB: 요청 처리 중 오류가 발생했습니다");
+ }
+
+ /** 권한 부족(메서드 보안 @PreAuthorize 등) — 403 유지, 내부 메시지 미노출. */
+ @ExceptionHandler(AccessDeniedException.class)
+ @ResponseStatus(HttpStatus.FORBIDDEN)
+ public ApiResponse handleAccessDenied(AccessDeniedException e) {
+ log.warn("권한 부족: {}", e.getMessage());
+ return ApiResponse.fail("ERR-MALL-403: 접근 권한이 없습니다");
+ }
+
+ /** 인증 실패 — 401 유지, 내부 메시지 미노출. */
+ @ExceptionHandler(AuthenticationException.class)
+ @ResponseStatus(HttpStatus.UNAUTHORIZED)
+ public ApiResponse handleAuthentication(AuthenticationException e) {
+ log.warn("인증 실패: {}", e.getMessage());
+ return ApiResponse.fail("ERR-MALL-401: 인증이 필요합니다");
}
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse handleRuntime(RuntimeException e) {
- // 스택트레이스 미노출 — 에러 코드/요약만 반환
- log.warn("업무 오류: {}", e.getMessage());
- return ApiResponse.fail(e.getMessage());
+ // 사전 정의된 비민감 업무 메시지(ERR-XXX-NNN)만 노출.
+ // 미분류 런타임 예외는 SQL/드라이버/내부 원인이 섞일 수 있어 마스킹하고 원인은 로그에만 기록.
+ if (isSafeBusinessMessage(e.getMessage())) {
+ log.warn("업무 오류: {}", e.getMessage());
+ return ApiResponse.fail(e.getMessage());
+ }
+ log.error("미분류 런타임 오류(메시지 마스킹)", e);
+ return ApiResponse.fail("ERR-MALL-500: 요청 처리 중 오류가 발생했습니다");
}
/** 정적 리소스 미존재 — 500 으로 감싸지 않고 깔끔히 404 반환 (SPA 폴백 이후의 실제 미스). */