✅ 인증(Authentication)이 실패했음을 의미
→ "누군지 모르는 상태"
Client → [JWT 토큰 없음] → 서버 → 401 에러
✅ 인가는 되었지만 권한이 없음
→ "누군지는 아는데, 허용되지 않는 행동"
Client → [JWT 유효함] → 인증 OK → 권한 확인 실패 → 403 에러
사용자 UX 측면에서 "접근 불가" 메시지를 구분 없이 보여주고 싶을 수도 있기 때문
예를 들어:
AccessDeniedHandler
vs AuthenticationEntryPoint
JWT 없거나 유효하지 않음
→ JwtAuthenticationFilter
에서 예외 발생
→ AuthenticationEntryPoint
호출
→ 401 Unauthorized
응답
인증은 OK → 권한 부족
→ AccessDecisionManager
에서 예외 발생
→ AccessDeniedHandler
호출
→ 403 Forbidden
응답
! AuthenticationEntryPoint는 401 처리 담당
! AccessDeniedHandler는 403 처리 담당
http
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 401
.accessDeniedHandler(new CustomAccessDeniedHandler()); // 403
CustomAuthenticationEntryPoint와 CustomAccessDeniedHandler는 각각 AuthenticationEntryPoint, AccessDeniedHandler 인터페이스를 구현한 클래스
CustomAuthenticationEntryPoint.java
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\": \"인증이 필요합니다.\"}");
}
}
CustomAccessDeniedHandler.java
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\": \"권한이 없습니다.\"}");
}
}
409 Conflict
리소스의 상태가 충돌(conflict)할 때 사용
- 예시: 회원가입 시 닉네임이 이미 존재함
- 의미: 서버는 요청을 이해했지만, 현재 상태에서는 처리할 수 없음 (충돌)
HTTP/1.1 409 Conflict
{
"message": "이미 사용 중인 닉네임입니다."
}
401
은 인증이 안 된 사용자, 403
은 권한 부족 사용자에게만 써야 한다.
닉네임 중복은 인증이 됐건 안 됐건 입력값 자체가 문제인 거라서
인증/인가와 무관한 비즈니스 로직 에러니까 400
또는 409
를 써야 한다.
컨트롤러마다 try-catch를 넣는 대신, 예외를 한 곳에서 통합 처리
컨트롤러마다 try-catch 쓰는 건 비효율적임으로@ControllerAdvice
를 쓰면 애플리케이션 전체에서 발생하는 예외를 한 곳에서 처리할 수 있다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicate(DuplicateResourceException e) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("DUPLICATE_RESOURCE", e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 오류 발생"));
}
}
@RestControllerAdvice
= @ControllerAdvice + @ResponseBody
@ExceptionHandler(SomeException.class)
로 처리할 예외 지정 가능ErrorResponse
@Getter
@AllArgsConstructor
public class ErrorResponse {
private String code;
private String message;
}
RuntimeException을 상속한 커스텀 예외 클래스
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
}
@Valid 검증 실패 처리 (Spring Validation) Spring Validation(@Valid)에서 발생하는 유효성 검증 실패도 자주 처리한다.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
String errorMessage = e.getBindingResult().getFieldError().getDefaultMessage();
return ResponseEntity
.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", errorMessage));
}
최종 정리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicate(DuplicateResourceException e) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("DUPLICATE_RESOURCE", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
String errorMessage = e.getBindingResult().getFieldError().getDefaultMessage();
return ResponseEntity
.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", errorMessage));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 오류 발생"));
}
}
Spring에서 기본 제공하지 않는 도메인 예외는 직접 만들어야 함
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
}
RuntimeException
을 상속받아서 필요에 따라 다양한 예외 만들 수 있다EmailAlreadyUsedException
, InvalidTokenException
등클라이언트에게 일관된 에러 구조를 제공
public class ErrorResponse {
private String errorCode;
private String message;
public ErrorResponse(String errorCode, String message) {
this.errorCode = errorCode;
this.message = message;
}
// Getter, Setter 생략
}
[서비스 로직]
↓
조건 검사 실패
↓
throw new DuplicateResourceException("이미 사용 중인 닉네임입니다.")
↓
@RestControllerAdvice 가 해당 예외 잡음
↓
ResponseEntity<ErrorResponse> 생성
↓
클라이언트에게 JSON 응답 전송
{
"errorCode": "DUPLICATE_RESOURCE",
"message": "이미 사용 중인 닉네임입니다."
}