Files
project-auth-server/docs/examples/web/error-code-http-status-separation.md

6.1 KiB

Error Code / HTTP Status Separation 예시

좋은 예시

예시 1. ErrorCode가 status와 외부 메시지를 함께 관리한다

public enum ErrorCode {
    REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed"),
    MALFORMED_JSON_REQUEST(HttpStatus.BAD_REQUEST, "MALFORMED_JSON_REQUEST", "Malformed request body"),
    DUPLICATE_EMAIL(HttpStatus.CONFLICT, "DUPLICATE_EMAIL", "Email already exists"),
    INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_ACCESS_TOKEN", "Invalid access token"),
    ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access denied"),
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "User not found"),
    UPSTREAM_AUTH_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "UPSTREAM_AUTH_SERVER_UNAVAILABLE", "Authentication server is temporarily unavailable"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error");

    private final HttpStatus httpStatus;
    private final String code;
    private final String message;

    ErrorCode(HttpStatus httpStatus, String code, String message) {
        this.httpStatus = httpStatus;
        this.code = code;
        this.message = message;
    }

    public HttpStatus httpStatus() {
        return httpStatus;
    }

    public String code() {
        return code;
    }

    public String message() {
        return message;
    }
}

좋은 이유:

  • HTTP status와 application code가 함께 정책화된다
  • 문자열 하드코딩이 흩어지지 않는다
  • 같은 code가 어디서든 같은 기본 status를 갖는다

예시 2. advice는 예외를 ErrorCode로 매핑하고, status와 body를 함께 만든다

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(DuplicateEmailException.class)
    public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
        ErrorCode errorCode = ErrorCode.DUPLICATE_EMAIL;

        return ResponseEntity.status(errorCode.httpStatus())
                .body(ApiResult.fail(errorCode));
    }

    @ExceptionHandler(InvalidAccessTokenException.class)
    public ResponseEntity<ApiResult<Void>> handleInvalidAccessToken() {
        ErrorCode errorCode = ErrorCode.INVALID_ACCESS_TOKEN;

        return ResponseEntity.status(errorCode.httpStatus())
                .body(ApiResult.fail(errorCode));
    }

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ApiResult<Void>> handleUserNotFound() {
        ErrorCode errorCode = ErrorCode.USER_NOT_FOUND;

        return ResponseEntity.status(errorCode.httpStatus())
                .body(ApiResult.fail(errorCode));
    }
}

좋은 이유:

  • status와 body code가 같은 정책 타입에서 나온다
  • controller가 실패 응답을 직접 만들지 않는다
  • ErrorCode와 HTTP status 역할이 모두 드러난다

예시 3. 같은 400 계열 아래 여러 세부 ErrorCode를 둔다

@RestControllerAdvice
public class RequestExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
            MethodArgumentNotValidException ex
    ) {
        Map<String, String> errors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .collect(Collectors.toUnmodifiableMap(
                        FieldError::getField,
                        DefaultMessageSourceResolvable::getDefaultMessage,
                        (first, second) -> first
                ));

        ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;

        return ResponseEntity.status(errorCode.httpStatus())
                .body(ApiResult.fail(errorCode, errors));
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ApiResult<Void>> handleMalformedJson() {
        ErrorCode errorCode = ErrorCode.MALFORMED_JSON_REQUEST;

        return ResponseEntity.status(errorCode.httpStatus())
                .body(ApiResult.fail(errorCode));
    }
}

좋은 이유:

  • 둘 다 400이지만 세부 원인은 ErrorCode로 구분된다
  • status는 넓은 범주, code는 세부 식별자라는 역할 분리가 분명하다

나쁜 예시

예시 1. 모든 실패를 200으로 응답한다

@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail() {
    return ResponseEntity.ok(ApiResult.fail(ErrorCode.DUPLICATE_EMAIL));
}

나쁜 이유:

  • body는 실패인데 HTTP status는 성공이다
  • HTTP semantics와 application semantics가 충돌한다

예시 2. custom 6xx status를 사용한다

@ExceptionHandler(UpstreamAuthServerUnavailableException.class)
public ResponseEntity<ApiResult<Void>> handleUpstreamFailure() {
    return ResponseEntity.status(601)
            .body(ApiResult.fail(ErrorCode.UPSTREAM_AUTH_SERVER_UNAVAILABLE));
}

나쁜 이유:

  • 601은 유효한 HTTP status가 아니다
  • 세부 원인 구분은 ErrorCode로 해야 한다

예시 3. @ResponseStatus(reason=...)를 REST API 기본 실패 전략으로 사용한다

@ResponseStatus(code = HttpStatus.CONFLICT, reason = "Email already exists")
public class DuplicateEmailException extends RuntimeException {
}

나쁜 이유:

  • HTTP status와 REST body 정책을 예외 클래스에 고정해 버린다
  • reason 기반 sendError는 REST API 응답 규약과 잘 맞지 않는다
  • body envelope 통일과 충돌하기 쉽다

예시 4. advice에서 문자열 코드와 예외 메시지를 직접 하드코딩한다

@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiResult<Void>> handleDuplicateEmail(DuplicateEmailException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ApiResult.fail("DUPLICATE_EMAIL", ex.getMessage()));
}

나쁜 이유:

  • status/code/message 정책이 중앙화되지 않는다
  • 외부 메시지와 내부 예외 메시지가 섞인다
  • 다른 파일에서도 같은 문자열이 반복되기 쉽다