예외와 오류 계약
75분 안팎
학습 목표
ControllerAdvice로 일관된 오류 코드를 반환합니다.
개념
오류도 클라이언트 계약입니다
화면이 실패를 안내하려면 HTTP 상태와 안정적인 기계 코드가 필요합니다. 어떤 엔드포인트는 문자열, 다른 곳은 HTML 오류 페이지를 반환하면 클라이언트가 요청마다 별도 파서를 만들어야 합니다. 이번 응답은 code와 message 두 필드로 고정합니다. INVALID_INPUT 같은 코드는 분기에 사용하고 한국어 message는 안내에 사용합니다. 문구가 조금 바뀌어도 클라이언트 동작이 변하지 않도록 기계 코드와 사람 문장을 구별합니다.
HTTP 상태의 이유를 설명합니다
400은 입력 형식이나 검증 실패, 404는 요청한 도서가 없음, 409는 현재 상태에서 실행 불가를 뜻합니다. 예상하지 못한 장애는 500입니다. 모든 예외를 200에 error 필드를 붙여 반환하면 성공 상태를 보고 처리하는 호출자가 실패를 놓칩니다. 모든 예외를 400으로 바꾸면 서버 장애를 사용자의 수정 문제로 오해합니다. 상태는 호출자가 다음 행동을 판단할 수 있도록 실패 원인 범주와 연결합니다.
한 곳에서 HTTP 표현을 만듭니다
ApiErrors에 @RestControllerAdvice를 붙이고 예외 유형별 @ExceptionHandler를 둡니다. 컨트롤러는 성공 응답에 집중하고 서비스는 업무 결과와 예외를 표현합니다. ErrorBody 레코드가 응답 모양을 고정합니다. 예외 객체 자체를 반환하지 않습니다. getMessage를 DTO에 그대로 넣으면 DB URL이나 SQL을 응답에 실을 수 있습니다. handler의 매개변수로 예외를 받는 것과 예외의 모든 정보를 공개하는 것은 다른 선택입니다.
입력 실패 경로를 묶습니다
MethodArgumentNotValidException은 DTO 값의 위반, HttpMessageNotReadableException은 본문 읽기 실패, MethodArgumentTypeMismatchException은 경로 인자 변환 실패입니다. 이들을 동일한 INVALID_INPUT으로 응답하지만 원인이 같은 예외라고 설명하지 않습니다. 사용자는 입력 형식을 확인하고 개발자는 실패한 테스트 경로를 봅니다. 여기서는 필드별 상세 배열을 추가하지 않았습니다. 나중에 상세를 확장할 때도 원문 입력이나 검증 대상의 비밀값을 그대로 복사하지 않습니다.
업무 예외의 메시지를 신뢰하지 않습니다
서비스가 던지는 ResponseStatusException은 상태를 보존하되 외부 코드와 문장은 화이트리스트로 매핑합니다. 404는 BOOK_NOT_FOUND, 409는 BOOK_CONFLICT입니다. 삭제 이력 충돌은 내부적으로 BOOK_HAS_HISTORY라는 이유를 가질 수 있지만 외부에서 SQL 제약 이름을 알 필요는 없습니다. 다른 상태가 실수로 이 경로에 오면 안전한 500으로 처리합니다. 예상한 업무 상태와 예상하지 못한 코드 경로를 분리해 검토합니다.
넓은 catch의 적용 위치를 정합니다
Exception 전체를 잡는 핸들러는 마지막 안전망으로 두며 500 INTERNAL_ERROR를 반환합니다. 저장소에서 모든 예외를 잡아 빈 목록으로 바꾸는 방식과 목적이 다릅니다. 응답은 실패라고 명확히 표시하고 내부 메시지를 노출하지 않습니다. 405와 415 같은 알려진 웹 예외는 별도 handler로 상태를 유지합니다. 이 구분을 생략하면 JSON 대신 텍스트를 보낸 정상적인 요청 오류가 일반 catch에 걸려 서버 장애처럼 500이 될 수 있습니다.
관측 정보와 공개 정보를 구별합니다
학습용 마지막 handler는 예외 클래스만 로그에 남깁니다. 원문 제목이나 JDBC URL, 예외 메시지는 출력하지 않습니다. 이는 운영 장애 추적 전체를 완성한 예제가 아닙니다. 후속 관측 모듈에서는 요청 식별자와 안전한 서버 기록 정책을 추가합니다. 사용자에게 스택 트레이스를 보여 주는 것과 운영자에게 접근 제한된 진단 기록을 남기는 것은 서로 다른 경계입니다. 현재 레슨은 공개 응답에 무엇이 들어가는지부터 고정합니다.
오류 계약은 정확한 JSON으로 검증합니다
internalFailureDoesNotLeak는 저장소가 비밀번호·SQL·경로가 들어 있는 예외를 던지도록 구성합니다. 실제 DB 장애를 만들 필요 없이 컨트롤러와 handler 경계에서 500 처리 정책을 검증합니다. strict JSON 비교로 code/message 두 필드만 있는지 확인하며 secret·SELECT·jdbc:·클래스 이름이 응답에 없는지도 검사합니다. 단순히 message가 null이 아닌지만 확인하면 새 stackTrace 필드가 추가되는 회귀를 잡지 못합니다.
서버 없는 HTTP 테스트의 범위를 압니다
MockMvc는 요청 라우팅·JSON 변환·응답 상태를 검사하지만 실제 TCP와 프록시를 통과하지 않습니다. standaloneSetup에서는 controllerAdvice를 명시적으로 등록합니다. @RestControllerAdvice를 클래스에 붙여 두기만 하고 standalone 테스트 빌더에 넣지 않으면 기본 오류 처리와 비교하게 됩니다. 애플리케이션은 컴포넌트 스캔으로 등록하므로 실행 경로별 등록 방식이 다릅니다. 테스트가 어느 경계를 실행하는지 설명할 수 있어야 결과를 바르게 해석합니다.
클라이언트가 취할 행동으로 리뷰합니다
400에서는 입력 수정, 404에서는 목록 갱신이나 자원 확인, 409에서는 현재 상태 확인을 안내합니다. 500에는 무조건 같은 POST를 반복하라고 안내하지 않습니다. 응답이 없더라도 저장됐을 가능성과 중복 요청 문제는 후속 모듈에서 다룹니다. starter는 마지막 handler가 예외 메시지를 그대로 공개하므로 보안 문자열 검사가 실패합니다. 공개 문장을 고정하고 테스트를 다시 실행한 뒤 기존 404·409와 405·415 상태가 유지되는지 누적 결과로 확인합니다.
오류 handler를 수정한 뒤 strict JSON 검사를 유지합니다. 응답 필드를 추가하려면 클라이언트 영향과 새 계약을 함께 검토합니다. 기존 테스트를 느슨하게 바꿔 노출된 정보를 통과시키지 않습니다.
사용한 API의 범위는 Spring 6.0 예외 처리 공식 설명에서 확인합니다. 실습 결과는 제공된 버전과 임시 DB에서 직접 검사한 범위입니다.
따라하기
안전한 마지막 응답을 만듭니다
실습 starter의 해당 TODO를 아래 코드와 주변 문맥을 보고 완성합니다. solution과 테스트는 먼저 접어 두고 각 인자의 의미를 설명합니다.
return error(500,"INTERNAL_ERROR","처리 중 오류가 발생했습니다");누적 테스트를 실행합니다
Advice 변경 뒤 notFoundAndPathErrors와 internalFailureDoesNotLeak를 확인합니다. 로그의 예외 클래스 정보와 사용자 응답의 고정 문구가 서로 다른 경계에 있음을 설명합니다.
./mvnw test > test.log 2>&1실행 보고서를 집계합니다
예외 공개 방지와 알려진 요청 오류의 상태를 함께 확인한 실제 보고서입니다. Errors는 테스트 중 처리하지 못한 예외이고 Failures는 기대 결과와의 불일치입니다.
python3 report.py실행 결과
Tests=95 Failures=0 Errors=0 Skipped=0
확인 문제
실습
ApiErrors.unexpected의 공개 메시지를 고정합니다. 비밀값을 포함한 내부 예외가 발생해도 응답은 code와 안전한 message만 포함해야 합니다. 테스트를 삭제하거나 비활성화하지 않습니다. 제공된 README와 API-SPEC의 제한을 설명하며 제출합니다.
실행 명령
./mvnw test
기대 결과
누적 테스트 95개, 실패·오류·건너뜀 0입니다.
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 도서 조회·추가 API의 메서드와 상태 코드를 설명합니다.