Java와 Spring 예외 처리

NCOOKIE·2025년 3월 24일
0

TIL

목록 보기
16/20

오류 (Error)

오류(Error)에는 컴파일 타임 오류(Compile-Time Error)와 런타임 오류(Run-Time Error) 두 가지가 있다.
(여기서 말하는 Error는 후술할 Error 클래스와 이름만 같을 뿐 다른 개념이다. 헷갈리기 때문에 여기서는 에러가 아닌 오류로 지칭하겠다.)

구문 작성 규칙을 위반했을 때 발생하는 오류를 컴파일 타임 오류라고 한다. 이 컴파일러 오류는 코드를 컴파일하기 전에 수정해야 하는 것을 나타낸다. 이러한 모든 오류는 컴파일러에서 감지되므로 컴파일 타임 오류라고 한다.

성공적인 컴파일 후 프로그램 실행(런타임) 중에 발생하는 오류를 런타임 오류라고 한다. 가장 흔한 런타임 오류로 잘못된 배열 인덱스 참조, Division Error(0으로 나누기) 등이 있다.

자바의 Exception

(출처 : https://www.java4coding.com/contents/java/checked-exceptions-vs-unchecked-exceptions)

자바에서 런타임 오류는 ErrorException이 있다. 자바에서는 원시 타입(Primitive Type)을 제외한 모든 것이 객체(Object)이므로, 이들 또한 최상위 클래스인 Object를 상속받는 Throwable을 부모로 둔다.

Throwable 클래스는 getMessage(), printStackTrace()와 같은 예외 정보와 처리를 위한 기본적인 메서드가 구현되어 있다.

https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Throwable.html

Error

코드로 복구되지 않는 오류를 말한다. OOM(OutOfMemory Error, 실행 중 메모리 부족) 혹은 시스템 오류와 같이 애플리케이션 내 복구 불가능한 시스템 예외 등이 있다.

Error는 예외 처리 하려고 해도 별도로 처리할 수 있는 방법이 없다. (코드가 아닌 실행 환경의 문제이기 때문) 그래서 try-catch 구문을 사용할 때 Throwable 클래스를 catch 하는 것은 Error도 함께 잡히게 되므로 좋지 않은 안티 패턴이다.

Error와 아래 설명할 Runtime Exception을 함께 묶어 Unchecked Exception이라고 한다.

Exception

개발자가 직접 처리할 수 있는 오류다. 흔히 접하는 Exception으로 NPE(NullPointerException), IllegalArgumentException 등이 있다.

Exception은 다시 Compile Exception과 Runtime Exception으로 나누어진다.

Compile Exception

  • Checked Exception
  • 반드시 try-catch로 처리하거나 상위 메서드로 throws 해야 한다.
  • 따로 처리를 하지 않으면 컴파일 오류가 발생한다.

Runtime Exception

  • Uncheked Exception
  • 별도로 처리하지 않아도 컴파일 가능하고 프로그램도 정상적으로 실행이 된다.
  • throws를 생략할 수 있다.

왜 이렇게 나누었을까?

그럼 자바에서는 왜 이 둘을 이렇게 나누었을까?

만약 모든 Exception이 Checked Exception이라고 가정해보자. Checked Exception은 예외에 대해 반드시 어떻게 처리해야 할지 구현해야 한다.

public class FullyCheckedArrayPrinter {

    public static void main(String[] args) {
        String[] data = {"A", "B", "C"};

        try {
            printArray(data);
        } catch (Exception e) {
            System.err.println("예외 발생: " + e.getMessage());
        }
    }

    public static void printArray(String[] array) throws NullArrayException, ArrayAccessException {

        if (array == null) {
            throw new NullArrayException("배열이 null입니다.");
        }

        try {
            for (int i = 0; i <= array.length; i++) { // 일부러 <= 로 예외 유도 가능
                System.out.println("값: " + array[i]);
            }
        } catch (IndexOutOfBoundsException e) {
            throw new ArrayAccessException("배열 인덱스 접근 오류: " + e.getMessage(), e);
        } catch (Exception e) {
            throw new ArrayAccessException("알 수 없는 배열 접근 오류", e);
        }
        
    }
    
    // ...
}

그저 배열의 내용을 출력하는 기능을 구현했을 뿐인데, 코드가 굉장히 길고 난잡해졌다. 가독성도 가독성이지만 효율이 굉장히 나쁘다. 그렇다고 모든 Excpetion을 Unchecked Exception으로 둔다면 프로그램의 안정성이 떨어질 것이다.

장점단점
Checked Exception- 예외 처리 강제 → 코드 안정성 향상
- 예외 발생 가능성을 명확히 문서화
- 가독성이 떨어짐 → 코드 생산성 하락
- 불필요한 예외 처리로 코드 복잡성 증가
Unchecked Exception- 단순하고 가독성 높은 코드 작성 가능
- 필요할 때만 예외 처리
- 예외를 누락할 가능성이 있음
- 시스템의 신뢰도 하락

try-catch

try-catch는 발생하는 예외를 어떻게 처리할지에 대한 기술이다. catch 부분에서 별도의 Exception을 발생시킬 수도 있고, 오류 대신 정상적으로 처리할 수도 있다.

public Date checkedException() {
    // 날짜 관련 Checked Exception
    String dateStr = "2024/11/04"; // 형식이 맞지 않음
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

    Date date = null;
    try {
        date = sdf.parse(dateStr); // ParseException 발생
    } catch (ParseException e) {
        date = new Date(0L); // 예외 발생시 "January 1, 1970, 00:00:00 GMT"로 반환
    }

    return date;
}

만약 예외를 throw 했다면 상위 스택의 메서드에서 이를 처리해줘야 한다. 해주지 않는다면 예외는 콜스택을 쭉 타고 올라가 main()까지 가게 되고, main()에서도 처리되지 않는다면 에러를 뱉으며 프로그램이 종료된다.

Checked Exception

  • Exception 클래스를 상속받으면 Checked Exception이 된다.
  • Checked Exception은 발생한 예외를 개발자가 명시적으로 처리해야 한다.
    • catch 혹은 throws 로 처리한다.
    • throws 했다면 이후에 꼭 catch 해야한다.
    • 만약, 처리하지 않으면 컴파일 오류가 발생한다.
// Exception class를 상속받아 만든 Custom Exception
// Exception Class를 상속받으면 checked Exception이 된다.
public class TestCheckedException extends Exception {

	public TestCheckedException(String message) {
		super(message);
	}
	
}

Unchecked Exception

  • RuntimeException 상속받아 커스텀 가능
  • 컴파일러가 체크하지 않으므로 따로 처리하지 않아도 컴파일 오류가 발생하지 않는다.
  • throws 생략 가능
    • throws를 명시하여도 compile 시 checked 되진 않는다.
    • 다른 개발자가 인지할 수 있도록 명시하는것도 좋은 방법이다. 기본은 생략이다. (코드 컨벤션 중 하나)
  • 개발자가 실수로 예외 처리를 까먹을 수 있다. 컴파일러가 check 하지 않기 때문!
public class DateStringValidator implements ConstraintValidator<DateString, String> {
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        // modified_date 데이터는 required = false 속성의 파라미터이기 때문에 공백 문자열이 들어오면 통과시킨다.
        if (s.isBlank()) {
            return true;
        }

        try {
            LocalDate.parse(s, formatter);
            return true;
        } catch (DateTimeParseException e) {
            return false;
        }
    }
}

try-catch 구문 없이 LocalDate.parse(s, formatter)를 실행해도 컴파일 오류는 발생하지 않는다. DateTimeParseException은 RuntimeException이기 때문이다. 대신 LocalDate가 지원하지 않는 형식의 데이터가 들어오면 런타임 에러가 발생한다.

정리

  • CheckedException
    • Exception을 상속받은 Exception
    • 컴파일러가 체크한다.
    • throws, catch 를 생략할 수 없다.
    • 반드시 예외 처리를 해야한다.
  • UncheckedException
    • RuntimeException을 상속받은 Exception
    • 컴파일러가 체크하지 않는다.
    • throws, catch 를 생략할 수 있다.
    • 예외 처리를 까먹을 수 있다.

  • 개발할 때 임의로 예외 클래스를 만들어 사용하는 경우가 많을 것이다. 이 때 try-catch로 묶어줘야 할 때에만 Exception 클래스를 상속받는다.
  • 일반적으로 실행 시 예외를 처리할 수 있는 경우에는 RuntimeException 클래스를 확장해서 Unchcekd Exception으로 사용하는 것이 좋다.
/**
 * 비즈니스 로직에서 발생할 수 있는 예외들의 부모 클래스
 */
@Getter
public class BusinessException extends RuntimeException {

    // 커스텀 예외 클래스들이 자체적으로 Http 상태 코드를 가지도록 함
    private final HttpStatus httpStatus;

    public BusinessException(String message, HttpStatus httpStatus) {
        super(message);
        this.httpStatus = httpStatus;
    }

    public BusinessException(ResponseCode responseCode) {
        super(responseCode.getMessage());
        this.httpStatus = responseCode.getHttpStatus();
    }

}

Spring 예외 처리

스프링 부트에서 제공하는 형식이 아니라 원하는 모양으로 커스텀 응답을 보내고 싶다면 @ExceptionHandler 또는 @ControllerAdvice 어노테이션을 사용하여 에러를 핸들링 할 수 있다.

@ExceptionHandler, @RestControllerAdvice

@ExceptionHandler, @ControllerAdvice는 둘 다 예외를 핸들링하는 어노테이션이다. 일반적으로 이 둘을 함께 사용한다.

차이점은 다음과 같습니다.

  • @ExceptionHandler : 하나의 Controller 만 예외 핸들링
  • @RestControllerAdvice : 모든 Controller에서 발생하는 예외 핸들링

@ControllerAdvice와 @RestControllerAdvice의 차이는 @Controller와 @RestController의 차이와 같다. @RestControllerAdvice는 응답을 Response Body에 담아 전달할 수 있다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ControllerAdvice
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice

아래 예시에서는 공용 응답 클래스를 Response Body에 담아 전달하기 위해 @RestControllerAdvice를 사용했다.

@Slf4j
@ControllerAdvice
@RestControllerAdvice
public class GlobalControllerAdvice {

    // @ExceptionHandler는 더 구체적인 예외 타입이 우선적으로 매칭되므로, 여기에는 미처 핸들링하지 못한 예외들이 들어온다.
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<String>> handleUnhandledException(Exception e) {
        // 에러 메세지 + 스택 트레이스
        log.error("핸들링되지 않은 예외 발생!!! : [{}]{}", e.getClass().getSimpleName(), e.getMessage(), e);

        ApiResponse<String> body = ApiResponse.error(
                ResponseCode.INTERNAL_SERVER_ERROR,
                e.getMessage()
        );

        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(body);
    }

    // @Valid 검증 실패
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<String>> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.warn("@Valid 요청 파라미터 유효성 검사 실패: {}", e.getMessage());
        ApiResponse<String> body = ApiResponse.error(HttpStatus.BAD_REQUEST, "요청 파라미터 유효성 검사 실패", e.getMessage());

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(body);
    }
}

@ExceptionHandler는 각 컨트롤러에 작성해도 되지만 위와 같이 한 곳에서 일괄적으로 관리할 수 있다.

ControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해준다. 우리는 이러한 ControllerAdvice를 이용함으로써 다음과 같은 이점을 누릴 수 있다.

  • 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능함
  • 직접 정의한 에러 응답을 일관성있게 클라이언트에게 내려줄 수 있음
  • 별도의 try-catch문이 없어 코드의 가독성이 높아짐

데이터 검증 어노테이션

데이터를 검증하기 위한 어노테이션이다. 이를 활성화하기 위해서는 아래 의존성을 추가해야 한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

@Valid

@Valid는 JSR-380(Bean Validation)의 표준 어노테이션으로, 주로 @RequestBody, @ModelAttribute에 붙여서 객체 내부의 필드를 검증한다.

  • @Valid 사용 예시
public record ScheduleUpdateRequestDto(
        @NotNull Long userId,
        @NotBlank @Size(max = 200)  String task,
        @NotBlank @Size(max = 50) String author,
        @NotBlank @Size(max = 50) String password
) {
}

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping("/register")
    public ApiResponse<Object> register(@RequestBody @Valid UserRegisterRequestDto dto) {
        UserInfoResponseDto userInfoResponseDto = userService.registerUser(dto);
        return ApiResponse.success(HttpStatus.CREATED, userInfoResponseDto);
    }
}

@Validated + 커스텀 Valid

@Validated는 Spring 전용 어노테이션(@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER}))이다. 클래스 레벨에 붙여서 @RequestParam, @PathVariable 등의 메소드 파라미터 단위 검증을 활성화한다.

아래 예시에서는 쿼리 파라미터 중 modified_dateyyyy-MM-dd 형식으로 들어오는 데이터 외에는 예외 처리하도록 하려고 한다. (공백이나 null 제외) 이 때 스프링에서는 이와 관련해 지원하는 어노테이션이 없으므로 직접 만들어야 한다.

@Documented
@Constraint(validatedBy = DateStringValidator.class)
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface DateString {

    String message() default "올바르지 않은 날짜 형식입니다. (형식: yyyy-MM-dd)";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

}

실제 유효한 날짜인지 확인하는 커스텀 어노테이션이다. 검증 기능을 가진 유효성 검사자(Validator), 그러니까 이 값이 Valid한지 아닌지를 판별해줄 클래스를 DateStringValidator로 지정한다. 값이 유효하지 않을 때 날려줄 메세지도 설정한다.

public class DateStringValidator implements ConstraintValidator<DateString, String> {
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        // modified_date 데이터는 required = false 속성의 파라미터이기 때문에 공백 문자열이 들어오면 통과시킨다.
        if (s.isBlank()) {
            return true;
        }

        try {
            LocalDate.parse(s, formatter);
            return true;
        } catch (DateTimeParseException e) {
            return false;
        }
    }
}

지정한 날짜 포맷으로 입력이 들어왔다면 true를, 그렇지 않다면 false를 반환하는 메서드를 오버라이딩한다. 여기에서 입맛에 맞게 검증 방식을 구현할 수 있다.

@Validated  // @RequestParam, @PathVariable 등에도 @Valid 적용하기 위해서 사용
@RestController
@RequestMapping("/api/schedules")
@RequiredArgsConstructor
public class ScheduleController {

    private final ScheduleService scheduleService;

    @PostMapping
    public ApiResponse<ScheduleResponseDto> createSchedule(@RequestBody @Valid ScheduleRequestDto dto) {
        return ApiResponse.success(HttpStatus.CREATED, scheduleService.saveSchedule(dto));
    }

    @GetMapping
    public ApiResponse<SchedulePageResponseDto> findAllSchedules(
            @PageableDefault(size = 10, sort = "created_at", direction = Sort.Direction.DESC) Pageable pageable,
            @RequestParam(required = false, defaultValue = "") @DateString String modified_date,    // 날짜 형식 yyyy-MM-dd
            @RequestParam(required = false, defaultValue = "-1") Long userId
    ) {
        return ApiResponse.ok(scheduleService.findAllSchedules(pageable, modified_date, userId));
    }
}

유효성을 검증하려는 modified_date 필드에 @Valid와 @DateString을 붙이면 될 것 같지만, @Valid는 @RequestParam과 @PathVariable에는 적용이 되지 않는다. 때문에 클래스 위에 @Validated 어노테이션을 작성해주면 된다.

참고

profile
일단 해보자

0개의 댓글