Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@

import lombok.Getter;

import java.util.Map;

@Getter
public class AppException extends RuntimeException {

private final BaseErrorCode errorCode;
private final Map<String, String> bind;

public AppException(BaseErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.bind = null;
}

public AppException(BaseErrorCode errorCode, Map<String, String> bind) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.bind = bind;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,25 @@
@AllArgsConstructor
public enum ErrorCode implements BaseErrorCode{

//400
BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.", "COMMON-001"),
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "요청 파라미터가 잘못되었습니다.", "COMMON-002"),
NOT_FOUND(HttpStatus.NOT_FOUND, "찾을 수 없습니다.", "COMMON-003"),
// 400
BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.", "COMMON_400_1"),
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "요청 파라미터가 잘못되었습니다.", "COMMON_400_2"),
JSON_PARSE_FAIL(HttpStatus.BAD_REQUEST, "JSON 파싱에 실패했습니다. Request body를 확인해주세요", "COMMON_400_3"),
PARAMETER_MISMATCH(HttpStatus.BAD_REQUEST, "쿼리 파라미터 타입이 맞지 않습니다.", "COMMON_400_4"),
NOT_FOUND_REQUEST_BODY(HttpStatus.BAD_REQUEST, "Request body가 없습니다.", "COMMON_400_5"),

//500
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부에서 에러가 발생하였습니다.", "COMMON-004"),
// 404
NOT_FOUND(HttpStatus.NOT_FOUND, "찾을 수 없습니다.", "COMMON_404_1"),
NOT_FOUND_URI(HttpStatus.NOT_FOUND, "존재하지 않는 URI입니다.", "COMMON_404_2"),

// 405
NOT_SUPPORT_HTTP_METHOD(HttpStatus.METHOD_NOT_ALLOWED, "잘못된 HTTP 메서드 입니다.", "COMMON_405_1"),

// 415
NOT_SUPPORT_CONTENT_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 Content-Type 입니다.", "COMMON_415_1"),

// 500
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부에서 에러가 발생하였습니다.", "COMMON_500_1"),
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,38 @@


import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;


@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

// NPE — 주로 DTO 검증 누락이나 비즈니스 로직에서 발생
@ExceptionHandler(NullPointerException.class)
public ResponseEntity<ErrorResponse> handleNullPointerException(NullPointerException e, HttpServletRequest request) {
log.error("NullPointerException 발생 — 파일: {}, 라인: {}",
e.getStackTrace()[0].getFileName(), e.getStackTrace()[0].getLineNumber());
Comment on lines +42 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

getStackTrace()[0] 접근이 핸들러 안에서 또 다른 예외를 던질 수 있어요.

JVM(HotSpot)은 같은 위치에서 NullPointerException 같은 예외가 반복 발생하면 -XX:+OmitStackTraceInFastThrow 최적화로 스택트레이스가 비어 있는 예외를 재사용합니다. 이때 e.getStackTrace()가 빈 배열이라 [0] 접근에서 ArrayIndexOutOfBoundsException이 터지고, 결국 예외 핸들러가 깔끔한 500 대신 또 다른 예외를 내뱉게 돼요.

🛠️ 제안 수정
-        log.error("NullPointerException 발생 — 파일: {}, 라인: {}",
-                e.getStackTrace()[0].getFileName(), e.getStackTrace()[0].getLineNumber());
+        StackTraceElement[] trace = e.getStackTrace();
+        if (trace.length > 0) {
+            log.error("NullPointerException 발생 — 파일: {}, 라인: {}",
+                    trace[0].getFileName(), trace[0].getLineNumber());
+        } else {
+            log.error("NullPointerException 발생 — 스택트레이스 없음", e);
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java`
around lines 42 - 43, The current log in GlobalExceptionHandler directly indexes
e.getStackTrace()[0], which can throw ArrayIndexOutOfBoundsException when the
JVM reuses exceptions with empty stack traces; change the handler to safely
access the top stack element by checking that e.getStackTrace() is non-null and
has length>0 before using [0], and if absent fall back to safe values (e.g.,
e.getClass().getName(), e.toString(), or "unknown file/line") so logging never
attempts an out-of-bounds access; update the NullPointerException
handling/logging logic in GlobalExceptionHandler accordingly.

log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

return ResponseEntity
.status(ErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus())
.body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, request));
}

// 처리되지 않은 모든 예외를 잡는 핸들러
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAllException(Exception e, HttpServletRequest request) {
Expand All @@ -44,7 +66,7 @@ public ResponseEntity<ErrorResponse> handleAllException(Exception e, HttpServlet
public ResponseEntity<ErrorResponse> handleAppCustomException(AppException e, HttpServletRequest request) {
log.error("AppException 발생: {}", e.getErrorCode().getMessage());
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());
ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode(), request);
ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode(), request, e.getBind());
return ResponseEntity
.status(e.getErrorCode().getHttpStatus())
.body(errorResponse);
Expand All @@ -53,17 +75,20 @@ public ResponseEntity<ErrorResponse> handleAppCustomException(AppException e, Ht
//@Valid 검사 실패(필수 파라미터가 null 또는 공백) 인 경우 예외를 잡는 핸들러
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) {
// 예시 결과: "email: 이메일 형식이 아닙니다, password: 비밀번호는 필수입니다"
String errorMessage = e.getBindingResult().getFieldErrors().stream()
.map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
.collect(Collectors.joining(", "));
Map<String, String> errors = e.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
fieldError -> fieldError.getField(),
fieldError -> fieldError.getDefaultMessage(),
(existing, duplicate) -> existing
));

log.error("MethodArgumentNotValidException 발생: {}", errorMessage);
log.error("MethodArgumentNotValidException 발생: {}", errors);
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.INVALID_PARAMETER,
request
request,
errors
);

return ResponseEntity
Expand Down Expand Up @@ -161,13 +186,181 @@ public ResponseEntity<Object> handleMethodArgumentTypeMismatch(MethodArgumentTyp
}

// Enum 변환 실패가 아닌 일반적인 타입 매스매치(예: Long 타입에 문자열 입력)인 경우
Map<String, String> errors = new HashMap<>();
errors.put(e.getName(), e.getValue() != null ? e.getValue().toString() : "null");

ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.PARAMETER_MISMATCH,
request,
errors
);

return ResponseEntity
.status(ErrorCode.PARAMETER_MISMATCH.getHttpStatus())
.body(errorResponse);
}

// @Valid 쿼리 파라미터 유효성 검사 실패
@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ErrorResponse> handleHandlerMethodValidationException(
HandlerMethodValidationException e,
HttpServletRequest request
) {

Map<String, String> errors = new HashMap<>();
e.getParameterValidationResults().forEach(result ->
errors.put(
result.getMethodParameter().getParameterName(),
result.getResolvableErrors().get(0).getDefaultMessage()
)
);
Comment on lines +211 to +216

@coderabbitai coderabbitai Bot Jun 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# -parameters 컴파일 플래그 사용 여부 확인
rg -nP -C3 '(\-parameters|parameters\s*=\s*true|compilerArgs)' --iglob '*.gradle*' --iglob 'pom.xml'

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 55


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Build files =="
find . -maxdepth 5 -type f \( -name "build.gradle" -o -name "build.gradle.kts" -o -name "pom.xml" \) | sed 's|^\./||'

echo
echo "== Search for -parameters / parameters=true / compilerArgs in build files =="
rg -n -i '(-parameters|\bparameters\s*=\s*true\b|compilerArgs)' --hidden --follow \
  --iglob '*/build.gradle*' --iglob '*/pom.xml' || true

echo
echo "== Show GlobalExceptionHandler.java around lines 180-250 =="
FILE="src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java"
if [ -f "$FILE" ]; then
  nl -ba "$FILE" | sed -n '180,250p'
else
  echo "File not found at expected path: $FILE"
  echo "== Candidate files =="
  fd -t f "GlobalExceptionHandler.java" .
fi

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 454


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Build files =="
mapfile -t BUILD_FILES < <(find . -type f \( -name "build.gradle" -o -name "build.gradle.kts" -o -name "pom.xml" \) -print | sed 's|^\./||')
printf "%s\n" "${BUILD_FILES[@]:-}"

echo
echo "== Search -parameters / parameters=true / compilerArgs in build files (explicit targets) =="
if [ "${`#BUILD_FILES`[@]}" -eq 0 ]; then
  echo "No build files found."
else
  rg -n -i '(-parameters|\bparameters\s*=\s*true\b|compilerArgs)' "${BUILD_FILES[@]}" || true
fi

echo
echo "== Show GlobalExceptionHandler.java around lines 180-250 (with line numbers) =="
FILE="src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java"
if [ -f "$FILE" ]; then
  awk 'NR>=180 && NR<=250 {printf "%d\t%s\n", NR, $0}' "$FILE"
else
  echo "File not found at: $FILE"
  echo "== Candidate files =="
  fd -t f "GlobalExceptionHandler.java" .
fi

echo
echo "== Fallback: repo-wide search for -parameters / getParameterName usage =="
rg -n --hidden --follow '\-parameters' . || true
rg -n --hidden --follow 'getParameterName\(\)' src/main/java || true
rg -n --hidden --follow 'getResolvableErrors\(\)' src/main/java || true

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Repo root listing =="
ls -la

echo
echo "== Build config candidates (build.gradle / build.gradle.kts / pom.xml) =="
find . -maxdepth 6 -type f \( -name "build.gradle" -o -name "build.gradle.kts" -o -name "pom.xml" \) -print | sed 's|^\./||' || true

echo
echo "== Search for -parameters / parameters=true / compilerArgs in build files =="
rg -n -i '(\-parameters|\bparameters\s*=\s*true\b|compilerArgs)' \
  -g 'build.gradle*' -g 'pom.xml' . || true

echo
echo "== Locate GlobalExceptionHandler.java =="
FILE="$(fd -t f 'GlobalExceptionHandler\.java$' . | head -n 1 || true)"
if [ -z "$FILE" ]; then
  echo "GlobalExceptionHandler.java not found."
  exit 0
fi
echo "Found: $FILE"

echo
echo "== Show code around the reported lines (180-250) =="
awk 'NR>=180 && NR<=250 {printf "%d\t%s\n", NR, $0}' "$FILE"

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 4202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== build.gradle: lines containing -parameters / compilerArgs / parameters=true =="
rg -n --hidden --follow '(-parameters|\bcompilerArgs\b|\bparameters\s*=\s*true\b)' build.gradle || true

echo
echo "== build.gradle: java/toolchain/compileJava related sections (best-effort) =="
rg -n --hidden --follow '(toolchain|compileJava|options\.compilerArgs|JavaCompile|tasks\.withType\(JavaCompile\)|jvmTarget)' build.gradle || true

echo
echo "== build.gradle: print entire compileJava-related blocks (up to ~200 lines around hits) =="
# Show a small context window around any compilerArgs hit; otherwise show near tasks.withType
if rg -n 'compilerArgs' build.gradle >/dev/null 2>&1; then
  rg -n -C15 'compilerArgs' build.gradle
elif rg -n 'compileJava' build.gradle >/dev/null 2>&1; then
  rg -n -C15 'compileJava' build.gradle
else
  echo "(no obvious compileJava/compilerArgs blocks found)"
fi

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 393


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== build.gradle: search broader for parameter-name / compilerArgs settings =="
rg -n --hidden --follow 'parameters\s*=\s*true|-\s*parameters|parameterName|parameter names|compilerArgs|options\.compilerArgs|compileJava|tasks\.withType\(JavaCompile\)|withType\(JavaCompile\)|all\s*JavaCompile|jvmTarget|options\s*\{' build.gradle || true

echo
echo "== build.gradle: show java/toolchain + compileJava/tasks blocks (context) =="
rg -n --hidden --follow -C5 'toolchain|compileJava|withType\(JavaCompile\)|tasks\.' build.gradle || true

echo
echo "== Locate ErrorResponse class =="
ERRFILE="$(fd -t f 'ErrorResponse\.java$' src | head -n 1 || true)"
echo "Found: ${ERRFILE:-none}"
if [ -n "${ERRFILE:-}" ]; then
  awk 'NR>=1 && NR<=220 {print NR "\t" $0}' "$ERRFILE" | sed -n '1,220p'
fi

echo
echo "== Locate ErrorResponse.of(...) usage/implementation if multiple constructors =="
rg -n --hidden --follow 'class ErrorResponse|static\s+ErrorResponse\s+of' src/main/java || true

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 2935


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== build.gradle: search for any JavaCompile/options.compilerArgs/compilerArgs.add patterns =="
rg -n --hidden --follow -i '(options\.compilerArgs|compilerArgs(\.add)?|java\.toolchain|tasks\.withType\(JavaCompile\)|withType\(JavaCompile\)|\-parameters|\bparameters\s*=\s*true\b|parameter\s*names)' build.gradle || true

echo
echo "== Inspect BaseResponse (to see how errors field is serialized) =="
BASEFILE="$(fd -t f 'BaseResponse\.java$' src/main/java | head -n 1 || true)"
echo "Found: ${BASEFILE:-none}"
if [ -n "${BASEFILE:-}" ]; then
  awk 'NR>=1 && NR<=220 {print NR "\t" $0}' "$BASEFILE"
fi

echo
echo "== Inspect any Jackson configuration affecting Map null keys =="
# Best-effort: search for ObjectMapper or JsonInclude settings
rg -n --hidden --follow -i '(ObjectMapper|JsonInclude|SerializationFeature|null\s*key|Map<|FAIL_ON|`@Json`' src/main/java || true

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 965


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for Jackson/ObjectMapper customizations =="
rg -n --hidden --follow -i 'ObjectMapper|JsonInclude|Jackson2ObjectMapperBuilderCustomizer|SerializationFeature|FAIL_ON|`@Json`|`@JsonInclude`' src/main/java || true

echo
echo "== Search where getResolvableErrors() is used =="
rg -n --hidden --follow 'getResolvableErrors\(\)' src/main/java || true

echo
echo "== Search for HandlerMethodValidationException usage/overrides =="
rg -n --hidden --follow 'HandlerMethodValidationException' src/main/java || true

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 16873


🌐 Web query:

Spring Boot 3 HandlerMethodValidationException getParameterValidationResults ParameterValidationResult getResolvableErrors empty behavior getParameterName null when -parameters not enabled

💡 Result:

In Spring Boot 3 (Spring Framework 6.x and 7.x), the HandlerMethodValidationException is thrown when method-level validation fails [1][2][3]. The behavior you described—null parameter names and potential issues with empty error lists—is tied to how Spring resolves method parameter names [3]. Parameter Names and the -parameters Flag Spring Framework relies on the Java reflection API to determine method parameter names. If the Java compiler is not invoked with the -parameters flag, these names are not stored in the compiled class files. Without this information, Spring may default to generic names like arg0, arg1, or return null, which causes getParameterName on a MethodParameter to return null [3][4][5]. To ensure correct parameter names are available at runtime for validation reporting, you must compile your code with the -parameters compiler flag [3]. Behavior of getResolvableErrors The getResolvableErrors method in ParameterValidationResult returns a list of MessageSourceResolvable objects [6][7]. This list will be empty if there are no validation errors for that specific parameter, but it should not be empty when a HandlerMethodValidationException is thrown, as the exception is only raised when errors exist [3]. If you are encountering an empty list in your exception handler, it may indicate you are iterating over parameters that did not cause the validation failure or that the structure of the result is being misinterpreted [3][4][5]. Recommended Handling Approach To correctly handle these exceptions and extract validation details: 1. Use getParameterValidationResults to access the results [1][8]. 2. For each ParameterValidationResult, check if it contains errors before accessing them [3]. 3. Ensure the -parameters flag is enabled in your build tool (e.g., Maven or Gradle) to avoid null parameter names [3]. Example snippet for your ExceptionHandler: @ExceptionHandler(HandlerMethodValidationException.class) public ResponseEntity handleMethodValidation(HandlerMethodValidationException ex) { List<Map<String, String>> errors = ex.getParameterValidationResults.stream.map(result -> { String name = result.getMethodParameter.getParameterName; // Requires -parameters flag String message = result.getResolvableErrors.stream.map(MessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(", ")); return Map.of("parameter", name!= null? name: "unknown", "error", message); }).collect(Collectors.toList); return ResponseEntity.badRequest.body(errors); }

Citations:


HandlerMethodValidationException 에러 추출 시 null/빈 값 방어 추가 필요 (211-216)

  • build.gradle-parameters(또는 compilerArgs) 설정이 없어 result.getMethodParameter().getParameterName()null일 수 있고, 이 값이 Map 키로 들어가 예외/직렬화 이슈 가능성이 있습니다.
  • result.getResolvableErrors()가 빈 리스트인 경우 get(0)에서 IndexOutOfBoundsException이 날 수 있으니 isEmpty() 가드를 추가하고, 키(parameterName)도 null이면 대체값("unknown" 등)으로 매핑하는 편이 안전합니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java`
around lines 211 - 216, When extracting validation errors from
e.getParameterValidationResults() in the GlobalExceptionHandler, guard against
null/empty values: check that result.getResolvableErrors() is not empty before
calling get(0) and obtain the default message safely, and derive the parameter
name via result.getMethodParameter() and
result.getMethodParameter().getParameterName() but substitute a safe fallback
(e.g. "unknown") if the methodParameter or parameterName is null; then put the
safe key and message into the errors Map instead of directly calling get(0) or
using a potentially null key.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

무슨 오류인지 설명해줘

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@kingmingyu 좋은 질문이에요! 😊 제가 지적한 부분에는 두 가지 잠재적 버그가 있어요. 하나씩 쉽게 설명해 드릴게요.


문제가 있는 코드 (Lines 211-216)

e.getParameterValidationResults().forEach(result ->
    errors.put(
        result.getMethodParameter().getParameterName(),  // ← 문제 1
        result.getResolvableErrors().get(0).getDefaultMessage()  // ← 문제 2
    )
);

🐛 문제 1: getParameterName()null을 반환할 수 있어요

Spring은 메서드 파라미터 이름을 알아내기 위해 컴파일 시 -parameters 옵션을 필요로 합니다. 이 옵션이 build.gradle에 없으면 파라미터 이름을 알 수 없어서 getParameterName()null을 반환합니다.

그러면 아래처럼 동작해요:

errors.put(null, "에러 메시지");
// → HashMap은 null 키를 허용하지만,
//   JSON 직렬화 시 예외 발생 또는 이상한 응답이 나올 수 있어요!

실제로 build.gradle에 아래 설정이 없다면 위험합니다:

tasks.withType(JavaCompile) {
    options.compilerArgs << '-parameters'
}

🐛 문제 2: getResolvableErrors().get(0) — 리스트가 비어 있으면 터져요

getResolvableErrors()가 반환하는 리스트가 비어 있을 때 .get(0)을 호출하면 IndexOutOfBoundsException이 발생합니다. 이게 터지면 오히려 500 에러가 나버려요! 😱

result.getResolvableErrors().get(0)  // 리스트가 비어있으면 바로 예외 발생!

✅ 안전하게 고치는 방법

e.getParameterValidationResults().forEach(result -> {
    String paramName = result.getMethodParameter().getParameterName();
    String key = (paramName != null) ? paramName : "unknown";  // null 방어

    List<MessageSourceResolvable> resolvableErrors = result.getResolvableErrors();
    if (!resolvableErrors.isEmpty()) {  // 빈 리스트 방어
        errors.put(key, resolvableErrors.get(0).getDefaultMessage());
    }
});

간단히 요약하면:

문제 발생 원인 결과
null -parameters 컴파일 옵션 누락 JSON 직렬화 오류 또는 null 키 응답
get(0) 에러 리스트가 비어 있는 경우 IndexOutOfBoundsException → 500 에러

( ・ᴗ・ )🐇 ← 이 부분만 조금 고치면 훨씬 안전해져요!


log.error("HandlerMethodValidationException 발생: {}", errors);
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.INVALID_PARAMETER,
request
request,
errors
);

return ResponseEntity
.status(ErrorCode.INVALID_PARAMETER.getHttpStatus())
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}

// 필수 @RequestParam 누락
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ErrorResponse> handleMissingServletRequestParameterException(
MissingServletRequestParameterException e,
HttpServletRequest request
) {

Map<String, String> errors = new HashMap<>();
errors.put(e.getParameterName(), "필수 파라미터입니다.");

log.error("MissingServletRequestParameterException 발생: 누락된 파라미터 '{}'", e.getParameterName());
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.INVALID_PARAMETER,
request,
errors
);

return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}

// @Validated 제약 조건 위반
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolationException(
ConstraintViolationException e,
HttpServletRequest request
) {
Map<String, String> errors = new HashMap<>();
e.getConstraintViolations().forEach(violation -> {
String propertyPath = violation.getPropertyPath().toString();
String fieldName = propertyPath.contains(".")
? propertyPath.substring(propertyPath.lastIndexOf(".") + 1)
: propertyPath;
errors.put(fieldName, violation.getMessage());
});

log.error("ConstraintViolationException 발생: {}", errors);
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.INVALID_PARAMETER,
request,
errors
);

return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}

// Request Body JSON 파싱 실패 또는 Body 누락
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleHttpMessageNotReadableException(HttpMessageNotReadableException e, HttpServletRequest request) {

// 특정 필드의 타입 변환 실패 (예: String 필드에 숫자 타입 등)
if (e.getCause() instanceof InvalidFormatException cause) {
Map<String, String> errors = new HashMap<>();
String fieldName = cause.getPath().get(0).getFieldName();

if (cause.getValue().toString().isEmpty()) {
errors.put(fieldName, "빈칸을 변환할 수 없습니다.");
} else {
errors.put(fieldName, "'" + cause.getValue() + "'을(를) 변환할 수 없습니다.");
}
Comment on lines +290 to +298

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

InvalidFormatException 파싱 시 경계 조건을 확인해 주세요.

cause.getPath().get(0)은 path가 비어 있는 경우(예: 루트 레벨 변환 실패) IndexOutOfBoundsException을, cause.getValue().toString()은 값이 null일 때 NullPointerException을 던질 수 있어요. 드물지만 핸들러 안에서 터지면 통일된 응답 대신 500이 나가니, 가드를 한 줄 넣어두면 안전합니다.

🛠️ 방어 코드 예시
-            String fieldName = cause.getPath().get(0).getFieldName();
-
-            if (cause.getValue().toString().isEmpty()) {
+            String fieldName = cause.getPath().isEmpty() ? "unknown" : cause.getPath().get(0).getFieldName();
+            Object rawValue = cause.getValue();
+
+            if (rawValue == null || rawValue.toString().isEmpty()) {
                 errors.put(fieldName, "빈칸을 변환할 수 없습니다.");
             } else {
-                errors.put(fieldName, "'" + cause.getValue() + "'을(를) 변환할 수 없습니다.");
+                errors.put(fieldName, "'" + rawValue + "'을(를) 변환할 수 없습니다.");
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java`
around lines 290 - 298, When handling InvalidFormatException in
GlobalExceptionHandler, add guards around cause.getPath() and cause.getValue():
check if cause.getPath() is empty before calling get(0) and choose a safe
default fieldName (e.g., "value" or "root") when empty; check if
cause.getValue() is null before calling toString() and branch the error message
accordingly (null -> treat as empty or use a "'null'을(를) 변환할 수 없습니다." message).
Update the block that computes fieldName and the errors.put(...) logic so it
never calls get(0) on an empty path or toString() on a null value, preserving
the existing messages but using the safe defaults.


log.error("InvalidFormatException 발생: {}", errors);
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

return ResponseEntity
.status(ErrorCode.JSON_PARSE_FAIL.getHttpStatus())
.body(ErrorResponse.of(ErrorCode.JSON_PARSE_FAIL, request, errors));
}

// JSON 문법 자체가 잘못된 경우 (중괄호 누락, 따옴표 오류 등)
if (e.getMessage() != null && e.getMessage().contains("JSON parse error")) {
log.error("HttpMessageNotReadableException 발생: JSON 파싱 실패 - {}", e.getMessage());
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

return ResponseEntity
.status(ErrorCode.JSON_PARSE_FAIL.getHttpStatus())
.body(ErrorResponse.of(ErrorCode.JSON_PARSE_FAIL, request));
}
Comment on lines +309 to +316

@coderabbitai coderabbitai Bot Jun 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Spring Boot 3.x HttpMessageNotReadableException JSON parse error cause JsonParseException

💡 Result:

In Spring Boot 3.x, HttpMessageNotReadableException is a wrapper exception thrown by the framework when a registered HttpMessageConverter—typically the Jackson-based converter—fails to read or deserialize the incoming request body [1]. When the root cause is a JsonParseException (or other Jackson exceptions like JsonMappingException), it indicates that the provided request body is not valid JSON or does not conform to the expected structure of the target Java DTO [2][3]. Root Cause Analysis: The exception chain often contains the underlying cause within the nested exception [3]. You can access this via: - ex.getCause: The direct cause (e.g., JsonMappingException or JsonParseException) [1][3]. - ex.getMostSpecificCause: A convenient method (from the base class NestedRuntimeException) to reach the root of the error, such as the specific parsing issue identified by Jackson [3][4]. Common Triggers: 1. Malformed JSON (syntax errors) triggering a JsonParseException [5][3]. 2. Schema mismatches, such as missing required fields or type mismatches (e.g., sending a string to an integer field), often triggering a JsonMappingException or InvalidFormatException [2][6][3]. 3. Missing default constructors or inaccessible properties on the target DTO [6]. Recommended Handling Strategy: The most robust approach to handling this exception in Spring Boot 3.x is to use a @RestControllerAdvice combined with ResponseEntityExceptionHandler [7]. By overriding the handleHttpMessageNotReadable method, you can provide a cleaner, consistent API error response instead of the default stack trace: @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @Override protected ResponseEntity handleHttpMessageNotReadable( HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { String errorMessage = "Malformed JSON request"; // Accessing the specific cause for a more helpful message Throwable mostSpecificCause = ex.getMostSpecificCause; if (mostSpecificCause!= null) { errorMessage = mostSpecificCause.getMessage; } // Return a custom error response DTO return handleExceptionInternal(ex, errorMessage, headers, status, request); } } This allows you to intercept the error before it propagates to the default error handling mechanism, ensuring that clients receive a structured 400 Bad Request response [5][8][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java"
echo "== Exists? =="; ls -l "$FILE"
echo
echo "== Lines 280-350 (context) =="
nl -ba "$FILE" | sed -n '280,350p'
echo
echo "== Search for JSON_PARSE_FAIL handling ==" 
rg -n "JSON_PARSE_FAIL|JSON parse error|HttpMessageNotReadableException" "$FILE"

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 314


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java"
echo "== Lines 280-350 (context) =="
awk 'NR>=280 && NR<=350 {printf "%d:%s\n", NR, $0}' "$FILE"

echo
echo "== Search for JSON_PARSE_FAIL / JSON parse error / HttpMessageNotReadableException =="
rg -n "JSON_PARSE_FAIL|JSON parse error|HttpMessageNotReadableException|getMessage\\(\\)" "$FILE"

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 5095


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java"

echo "== Imports (top 1-120) =="
awk 'NR>=1 && NR<=120 {print}' "$FILE"

echo
echo "== Check for JsonParseException usage =="
rg -n "JsonParseException|getMostSpecificCause|mostSpecificCause|JsonMappingException" "$FILE"

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 5649


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java"

echo "== Search for JsonParseException (anywhere in file) =="
rg -n "JsonParseException|getMostSpecificCause|getRootCause" "$FILE" || true

echo
echo "== Lines 200-360 (exception handler area) =="
awk 'NR>=200 && NR<=360 {print NR ":" $0}' "$FILE"

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 7736


HTTP JSON 파싱 실패 분기를 e.getMessage() 문자열이 아닌 예외 원인 타입으로 처리하기

  • e.getMessage().contains("JSON parse error")는 Spring/Jackson 버전(또는 메시지 포맷 변경)에서 조용히 빗나가고, 실제로는 JSON 문법 오류인데 “Request Body 없음” 분기로 떨어질 수 있어요.
  • Spring Boot 3.x에서 malformed JSON이면 HttpMessageNotReadableException의 nested cause(가장 구체적인 원인)가 com.fasterxml.jackson.core.JsonParseException으로 내려오는 편이라, 그 타입으로 분기하는 게 더 안전합니다. (이미 InvalidFormatException 분기처럼 원인 타입 기반으로 처리하고 있어요.)
🛠️ 제안
-        if (e.getMessage() != null && e.getMessage().contains("JSON parse error")) {
+        if (e.getMostSpecificCause() instanceof com.fasterxml.jackson.core.JsonParseException) {
             log.error("HttpMessageNotReadableException 발생: JSON 파싱 실패 - {}", e.getMessage());
             log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());
 
             return ResponseEntity
                     .status(ErrorCode.JSON_PARSE_FAIL.getHttpStatus())
                     .body(ErrorResponse.of(ErrorCode.JSON_PARSE_FAIL, request));
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/global/exception/GlobalExceptionHandler.java`
around lines 309 - 316, Replace the fragile string-match branch in
GlobalExceptionHandler's HttpMessageNotReadableException handling (the if that
checks e.getMessage().contains("JSON parse error")) with a check on the nested
cause type: traverse e.getCause() chain (or use the same root-cause helper used
for InvalidFormatException handling) and detect
com.fasterxml.jackson.core.JsonParseException (or its Jackson supertype) to
identify malformed JSON; when that cause is found, log the error and request
like the current block and return ErrorCode.JSON_PARSE_FAIL, keeping the
existing logging and response behavior but based on the cause type rather than
the message text.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

무슨 오류인지 설명해줘

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@kingmingyu 쉽게 설명해 드릴게요! 😊


🔍 문제가 된 코드 (현재)

if (e.getMessage() != null && e.getMessage().contains("JSON parse error")) {
    // JSON 문법 오류 처리
}

❓ 왜 문제인가?

e.getMessage()의 문자열에 의존하고 있기 때문이에요.

Spring/Jackson이 malformed JSON(문법적으로 잘못된 JSON)을 파싱하다 실패하면 HttpMessageNotReadableException을 던지는데, 이 예외의 메시지가 "JSON parse error: ..."라는 형태로 내려와요.

그런데 이 메시지 포맷은 공식 API가 아니에요. Spring Boot나 Jackson 버전이 업그레이드되면 메시지 형태가 달라질 수도 있어요.


💥 어떤 상황이 생기냐면?

예를 들어 프론트에서 이런 잘못된 JSON을 보냈다고 해봐요:

{ "name": "홍길동"   ← 닫는 괄호 없음!
  • 현재 코드: e.getMessage()"JSON parse error" 문자열이 없어지면 → 이 조건을 통과 못 하고 → 맨 아래 "Request Body 없음" 분기로 떨어짐
  • 결과: 실제로는 JSON 문법 오류인데, 잘못된 에러 코드(NOT_FOUND_REQUEST_BODY)로 응답하게 됨 ❌

✅ 더 안전한 방법 (제안)

예외의 타입으로 판단하는 거예요. 이미 InvalidFormatExceptioninstanceof로 체크하는 것처럼요:

// 변경 전 (문자열 의존 - 취약)
if (e.getMessage() != null && e.getMessage().contains("JSON parse error")) {

// 변경 후 (타입 기반 - 안전)
if (e.getMostSpecificCause() instanceof com.fasterxml.jackson.core.JsonParseException) {

JsonParseException은 Jackson이 JSON 문법 자체가 깨졌을 때 던지는 정식 예외 클래스라, 버전이 바뀌어도 신뢰할 수 있어요.


요약하면: 문자열("JSON parse error")은 언제든 바뀔 수 있는 불안정한 기준이고, 예외 타입(JsonParseException)은 훨씬 안정적인 기준이에요! 🐇


// Request Body 자체가 없는 경우
log.error("HttpMessageNotReadableException 발생: Request Body 없음");
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

return ResponseEntity
.status(ErrorCode.NOT_FOUND_REQUEST_BODY.getHttpStatus())
.body(ErrorResponse.of(ErrorCode.NOT_FOUND_REQUEST_BODY, request));
}

// 지원하지 않는 HTTP 메서드
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
log.error("HttpRequestMethodNotSupportedException 발생: 지원하지 않는 메서드 '{}'", e.getMethod());
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

Map<String, String[]> errors = new HashMap<>();
errors.put("지원하는 메서드", e.getSupportedMethods());

return ResponseEntity
.status(ErrorCode.NOT_SUPPORT_HTTP_METHOD.getHttpStatus())
.body(ErrorResponse.of(ErrorCode.NOT_SUPPORT_HTTP_METHOD, request, errors));
}

// 존재하지 않는 URI
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ErrorResponse> handleNoResourceFoundException(NoResourceFoundException e, HttpServletRequest request) {
log.error("NoResourceFoundException 발생: 존재하지 않는 URI '{}'", request.getRequestURI());

return ResponseEntity
.status(ErrorCode.NOT_FOUND_URI.getHttpStatus())
.body(ErrorResponse.of(ErrorCode.NOT_FOUND_URI, request));
}

// 지원하지 않는 Content-Type
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleHttpMediaTypeNotSupportedException(HttpMediaTypeNotSupportedException e, HttpServletRequest request) {
log.error("HttpMediaTypeNotSupportedException 발생: 지원하지 않는 Content-Type '{}'", e.getContentType());
log.error("에러가 발생한 지점 {}, {}", request.getMethod(), request.getRequestURI());

Map<String, String> errors = new HashMap<>();
if (e.getContentType() != null) {
errors.put("요청한 Content-Type", e.getContentType().toString());
}

return ResponseEntity
.status(ErrorCode.NOT_SUPPORT_CONTENT_TYPE.getHttpStatus())
.body(ErrorResponse.of(ErrorCode.NOT_SUPPORT_CONTENT_TYPE, request, errors));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ public class ErrorResponse extends BaseResponse {
private final String message;
private final String method;
private final String requestURI;
private final Object errors;

private ErrorResponse(String code, String message, String method, String requestURI, HttpStatus httpStatus) {
private ErrorResponse(String code, String message, String method, String requestURI, HttpStatus httpStatus, Object errors) {
super(httpStatus);
this.code = code;
this.message = message;
this.method = method;
this.requestURI = requestURI;
this.errors = errors;
}

public static ErrorResponse of(BaseErrorCode errorCode, HttpServletRequest request) {
Expand All @@ -28,7 +30,19 @@ public static ErrorResponse of(BaseErrorCode errorCode, HttpServletRequest reque
errorCode.getMessage(),
request.getMethod(),
request.getRequestURI(),
errorCode.getHttpStatus()
errorCode.getHttpStatus(),
null
);
}

public static ErrorResponse of(BaseErrorCode errorCode, HttpServletRequest request, Object errors) {
return new ErrorResponse(
errorCode.getCode(),
errorCode.getMessage(),
request.getMethod(),
request.getRequestURI(),
errorCode.getHttpStatus(),
errors
);
}
}
Loading