Skip to content

fix: 사용자 잘못을 서버 잘못으로 보고하지 않는다 (입력·권한 오류가 500) - #159

Open
RosieOh wants to merge 1 commit into
mainfrom
fix/client-errors-as-500
Open

RosieOh wants to merge 1 commit into
mainfrom
fix/client-errors-as-500

Conversation

@RosieOh

@RosieOh RosieOh commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Closes #158

무엇이 문제였나

입력 오류와 권한 거부가 500 Internal Server Error 로 나갔다.

CareServiceException 은 상태 코드를 문자열로 들고 다니고, 전역 핸들러가 그 문자열을 보고
상태를 정한다. 그런데 매핑이 네 가지뿐이다.

private ErrorCode resolveCareServiceErrorCode(String careServiceErrorCode) {
    if ("UNAUTHORIZED".equalsIgnoreCase(...))   return ErrorCode.UNAUTHORIZED;
    if ("FORBIDDEN".equalsIgnoreCase(...))      return ErrorCode.FORBIDDEN;
    if ("USER_NOT_FOUND".equalsIgnoreCase(...)) return ErrorCode.USER_NOT_FOUND;
    if (... startsWith("KAKAO"))                return ErrorCode.INVALID_INPUT;
    return ErrorCode.INTERNAL_SERVER_ERROR;   // <- 나머지 전부
}

세어 보니 throw new CareServiceException(...) 47곳 중 39곳이 코드를 주지 않는다. 코드 없이
던지면 CARE_SERVICE_ERROR 가 되고, 매핑에 없으므로 500 이다.

두 갈래로 나타났다

1. 정상적인 거절이 장애로 보고된다

상황 전 후
이미 취소한 예약을 또 취소 500 409
완료된 예약을 취소·수정 500 409
끝나는 시각이 시작보다 앞 500 400
과거 시간으로 예약 500 400
그 시간에 자리 없음 500 409

마지막 줄이 특히 나쁘다. 정원이 찬 인기 시설일수록 500 이 더 쌓인다 — 정상 동작이 장애
지표를 오염시킨다. 사용자가 두 번 눌렀을 뿐인데 운영에는 서버 장애로 보인다.

상태가 맞지 않아 거절하는 경우를 위해 BOOKING_STATE_CONFLICT(F005, 409)를 더했다. 요청 자체는
멀쩡하고 대상의 상태가 맞지 않은 것이라 400 이 아니다.

2. catch (Exception e) 가 안쪽의 403·404 를 삼킨다

try {
    Notification n = repo.findById(id).orElseThrow(() -> new IllegalArgumentException(...));
    assertNotificationOwnedByUser(n, actorUserId);   // BusinessException(FORBIDDEN)
    ...
} catch (Exception e) {                               // <- 403 도 여기로 들어온다
    throw new CareServiceException("알림 상세 조회 중 오류가 발생했습니다.", e);
}

소유권 검사가 403 을 던지는데 500 으로 나갔다. 클라이언트는 "권한이 없다" 와 "서버가
고장났다" 를 구분할 수 없고, 로그에는 서버 오류로 쌓여 권한 탐색 시도가 장애로 보인다.

CareCodeException 이 BusinessException·ResourceNotFoundException·UserNotFoundException 의
공통 조상이라, 요청 경로 24곳에 한 줄짜리 절을 앞세웠다.

} catch (CareCodeException | CareServiceException e) {
    throw e;
} catch (Exception e) {

삼키던 IllegalArgumentException 중 "찾을 수 없습니다" 는 ResourceNotFoundException 으로 바꿔
404 가 되게 했다.

건드리지 않은 곳 — 그리고 그 이유

  • 백그라운드 동기화(external/publicdata)는 감싸는 것이 맞다. HTTP 응답이 없으니 상태 코드가
    의미 없고, 한 건이 실패해도 동기화 전체는 이어져야 한다.
  • EmailVerificationService 의 발송 실패 catch 는 쿨다운 키를 지운다. 재던지기를 앞세우면
    그 정리를 건너뛰어 발송이 실패했는데 사용자가 1분간 재시도조차 못 하게 된다. 처음엔
    기계적으로 넣었다가 되돌렸다.
  • LegalDocumentService 는 클래스패스 파일만 읽어 도메인 예외가 나올 수 없다. 넣어 봐야
    죽은 코드다.

테스트

먼저 실패시켰다. 7개 모두 500 쪽으로 실패하는 것을 확인한 뒤 고쳤다.

NotificationErrorStatusTest          2개  (403 / 404)
CareFacilityBookingErrorStatusTest   5개  (409 x3 / 400 x2)

실패 당시 메시지:

Expecting actual throwable to be an instance of:
  com.carecode.core.exception.BusinessException
but was:
  com.carecode.core.exception.CareServiceException: 알림 상세 조회 중 오류가 발생했습니다.
    at NotificationService.getNotificationById(NotificationService.java:84)

기존 테스트 3개가 깨졌고, 그게 맞다

CareFacilityBookingServiceTest 의 세 개가 CareServiceException 을 기대하고 있었다. 그 테스트가
고정하던 것이 바로 이 버그였다
— 거부한다는 사실은 맞게 봤지만, 거부가 500 으로 나간다는 것까지
같이 굳혀 두고 있었다.

타입만 BusinessException 으로 바꾸지 않고 상태 코드까지 단언하도록 올렸다. 클래스가 아니라
계약을 지켜야 다음에 또 500 으로 돌아가지 않는다.

assertThatThrownBy(() -> bookingService.createBooking(...))
        .isInstanceOf(BusinessException.class)
        .hasMessageContaining("예약 가능한 자리가 없습니다")
        .satisfies(e -> assertThat(((BusinessException) e).getErrorCode())
                .isEqualTo(ErrorCode.BOOKING_ALREADY_EXISTS));

좋아지지 않은 것

  • CareServiceException 은 그대로 남아 있다. 이 PR 은 상태 코드가 틀린 곳만 고쳤고, 두 예외
    체계를 하나로 합치지 않았다([ 아키텍처 ] 같은 일을 여러 방식으로 해서 구조가 지저분하다 #132 2단계). 남은 CareServiceException 은 여전히 문자열 코드로
    상태를 정하고, 코드를 주지 않으면 500 이다 — 같은 함정이 남아 있다.
  • 합치려면 두 체계의 기본 매핑 차이를 먼저 테스트로 드러내야 한다. 한 번에 바꾸면 상태 코드가
    조용히 달라지고, 그걸 기대하는 계약 테스트가 있다.
  • 백그라운드 동기화의 catch (Exception e) 는 원인을 덮어쓰는 문제가 그대로다. HTTP 와 무관해
    이 PR 범위에서 뺐지만, 실패 원인이 로그에만 남고 동기화 상태에는 안 드러난다.

입력 오류와 권한 거부가 500 으로 나갔다.

CareServiceException 은 상태 코드를 문자열로 들고 다니고, 전역 핸들러가 그
문자열을 보고 상태를 정한다. 매핑은 네 가지(UNAUTHORIZED, FORBIDDEN,
USER_NOT_FOUND, KAKAO*)뿐이고 나머지는 전부 INTERNAL_SERVER_ERROR 다.
throw 47곳 중 39곳이 코드를 주지 않으므로 그대로 500 이 된다.

두 갈래로 나타났다.

1. 정상적인 거절이 장애로 보고된다

예약 8곳이 그랬다. 이미 취소한 예약을 또 취소(409), 완료된 예약 수정(409),
끝나는 시각이 시작보다 앞(400), 과거 시간(400), 자리 없음(409) 이 전부 500
이었다. 마지막이 특히 나쁘다 - 정원이 찬 인기 시설일수록 500 이 더 쌓여
정상 동작이 장애 지표를 오염시킨다.

상태가 맞지 않아 거절하는 경우를 위해 BOOKING_STATE_CONFLICT(F005, 409)를
더했다. 요청 자체는 멀쩡하고 대상의 상태가 맞지 않은 것이라 400 이 아니다.

2. catch (Exception e) 가 안쪽의 403·404 를 삼킨다

알림 상세 조회는 소유권 검사가 403 을 던지는데 같은 try 를 감싼 catch 가
받아 500 으로 바꿨다. 클라이언트는 "권한이 없다" 와 "서버가 고장났다" 를
구분할 수 없고, 로그에는 서버 오류로 쌓여 권한 탐색 시도가 장애로 보인다.

요청 경로의 24곳에 도메인 예외를 먼저 다시 던지는 절을 넣었다. 삼키던
IllegalArgumentException 중 "찾을 수 없습니다" 는 ResourceNotFoundException
으로 바꿔 404 가 되게 했다.

건드리지 않은 것:

- 백그라운드 동기화(external/publicdata)는 감싸는 것이 맞다. HTTP 응답이 없다.
- EmailVerificationService 의 발송 실패 catch 는 쿨다운 키를 지운다. 재던지기를
  넣으면 그 정리를 건너뛰어 사용자가 1분간 재시도조차 못 한다.
- LegalDocumentService 는 클래스패스 파일만 읽어 도메인 예외가 나올 수 없다.
  넣어 봐야 죽은 코드다.

먼저 실패 테스트로 현재 상태를 드러냈다 - 알림 403/404 두 개, 예약 409/400
다섯 개. 일곱 개 모두 500 쪽으로 실패하는 것을 확인한 뒤 고쳤다.

기존 CareFacilityBookingServiceTest 세 개가 깨졌고 그게 맞다. 그 테스트가
고정하던 것이 바로 이 버그였다 - 거부한다는 사실은 맞게 봤지만 거부가 500
으로 나간다는 것까지 같이 굳혀 두고 있었다. 타입만 바꾸지 않고 상태 코드까지
단언하도록 올렸다. 클래스가 아니라 계약을 지켜야 다시 돌아가지 않는다.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ Core ] 사용자 잘못이 서버 잘못으로 보고된다 - 입력·권한 오류가 500 으로 나간다

1 participant