[Spring Security] 401 Unauthorized와 403 Forbidden 차이와 처리 방법

dejeong·2025년 5월 4일
0

에러 해결 일지

목록 보기
4/5
post-thumbnail

🔐 401 Unauthorized

✅ 인증(Authentication)이 실패했음을 의미
→ "누군지 모르는 상태"

발생 상황 예시

  • JWT 토큰이 없거나 잘못됨
  • 토큰이 만료됨
  • 로그인하지 않고 보호된 API에 접근

동작 흐름

Client[JWT 토큰 없음] → 서버 → 401 에러

🚫 403 Forbidden

✅ 인가는 되었지만 권한이 없음
→ "누군지는 아는데, 허용되지 않는 행동"

발생 상황 예시

  • 로그인은 되었지만, 다른 사용자의 데이터를 삭제하려 함
  • 일반 사용자가 관리자만 가능한 작업을 시도함
Client[JWT 유효함] → 인증 OK → 권한 확인 실패 → 403 에러

🔄 왜 403대신 401로 응답할까?

사용자 UX 측면에서 "접근 불가" 메시지를 구분 없이 보여주고 싶을 수도 있기 때문

예를 들어:

  • "이 페이지에 접근할 수 없습니다." 라고만 보여주고 싶다면, 403, 401 모두 통일된 401 응답으로 처리할 수 있다.
  • 또는, 보안상의 이유로 403을 노출하지 않고 그냥 401로 처리하기도 한다.
    (누가 접근하려 했는지 감추기 위해)

🗝️ 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를 써야 한다.


💬 전역 예외 처리: @RestControllerAdvice

컨트롤러마다 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

🏞️ 에러 응답 객체 (DTO)

클라이언트에게 일관된 에러 구조를 제공

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": "이미 사용 중인 닉네임입니다."
}
profile
룰루

0개의 댓글