6 분 소요

스프링 복습 및 정리 10.1P

Checked Exception, Unchecked Exception

애플리케이션에서 발생하는 예외는 크게 체크 예외와 언체크 예외로 구분할 수 있다.

체크 예외는 발생한 예외를 잡아서(catch) 체크한 후에 해당 예외를 복구하던가 회피하던가 등의 어떤 구체적인 처리를 해야하는 예외이다.

Java에서 흔히 볼 수 있는 대표적인 체크 예외로는 ClassNotFoundException등을 들 수 있다.

언체크 예외는 예외를 잡아서(catch) 해당 예외에 대한 어떤 처리를 할 필요가 없는 예외를 의미한다.

대표적인 언체크 예외로는 NullPointerException, ArrayIndexOutOfBoundsException 등이 있다.

흔히 개발자가 코드를 잘못 작성해서 발생하는 이런 오류들은 모두 RuntimeException을 상속한 예외들이다.

그러나 Java나 Spring에서 수많은 RuntimeException을 지원해 주지만

이 RuntimeException을 이용해서 개발자가 직접 예외를 만들어야 할 경우도 있다.

개발자가 의도적으로 예외를 던질 수 있는 상황

백엔드 서버와 외부 시스템과의 연동에서 발생하는 에러 처리

암호 화폐 지갑과 연동하는 백엔드 서비스를 예시로 들어보겠다.

암호 화폐 지갑은 웹 지갑이 될 수도 있고, 안드로이드나 iOS 기반의 앱 지갑이 될 수도 있으며, 데스크톱 애플리케이션이 될 수도 있다.

일반적으로 암호 화폐 지갑은 블록체인과 직접적으로 API 통신을 거치는 경우가 많지만 백엔드 서버를 한 번 거쳐서 블록체인과 통신하는 경우도 있을 수 있다.

만일 A라는 사용자가 B라는 사용자에게 코인을 전송하기 위해 백엔드 서버가 블록체인과 API 통신을 하는 과정에서

블록체인으로부터 A 사용자의 코인 잔고가 부족하다는 메시지를 전달받고, 프로세스가 중단되었다.

백엔드 서버 쪽에서 이런 예외가 발생했다면 이 예외를 복구하려고 시도해 봤자 할 수 있는게 없다.

이럴 땐 잔고가 부족한 상황을 클라이언트 쪽에서 즉시 알려서 클라이언트가 지갑에 잔고를 채우는 게 최선의 방법이다.

이 경우, 백엔드 서버 쪽에서 예외를 의도적으로 던져서 클라이언트 쪽에 에러가 발생한 정보를 알려줄 수 있다.

시스템 내부에서 조회하려는 리소스(자원, Resource)가 없는 경우

커피 주문 애플리케이션을 예시로 들어보겠다.

회원 정보를 조회하려고 클라이언트 쪽에서 Controller의 getMember() 핸들러 메서드에 요청을 보냈다.

그런데 DB에 조회를 하니 해당하는 회원 정보가 없을 수 있다.

이런 경우 서비스 계층에서 해당 회원 정보가 없다는 예외를 의도적으로 전송해서 클라이언트 쪽에 알려줄 수 있다.

의도적인 예외 던지기/받기

Java에서는 throw 키워드를 사용해서 예외를 메서드 바깥으로 던질 수 있다.

던져진 예외는 메서드를 호출한 지점으로 던져지게 되는 것이다.

예를 들어 서비스 계층에서 예외를 던졌을때

서비스 계층의 메서드는 API 계층인 Controller의 핸들러 메서드가 이용하므로

서비스 계층에서 예외를 던지면 Controller의 핸들러 메서드 쪽에서 잡아서 처리할 수 있다.

그런데 이미(10p) Controller에서 발생하는 예외를 Exception Advice에서 처리하도록 공통화해두었으니 서비스 계층에서 던진 예외 역시 Exception Advice에서 처리하면 된다.

서비스 계층에서 예외를 던지는 예

@Service
public class MemberService {
    ...
    ...

    public Member findMember(long memberId) {
        // TODO should business logic
        
        // (1)
        throw new RuntimeException("Not found member");
    }

    ...
    ...
}

위 코드에서는 DB에서 회원 정보를 조회했는데 조회되는 회원이 없다고 가정하고,

(1)에서 throw 키워드를 사용하여 RuntimeException 객체에 적절한 예외 메시지를 포함한 후에 메서드 밖으로 던졌다.

GlobalExceptionAdvice 예외 잡기(catch)

이렇게 던져진 RuntimeException을 이전에 구현한 GlobalExceptionAdvice 클래스에서 받아보겠다.

@RestControllerAdvice
public class GlobalExceptionAdvice {
    ...
    ...
    
    // (1)
    @ExceptionHandler
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleResourceNotFoundException(RuntimeException e) {
        System.out.println(e.getMessage());

        return null;
    }
}

이전에 구현한 로직에 (1)과 같이 RuntimeException을 잡아서 처리하기 위한 handleResourceNotFoundException() 메서드를 추가했다.

Postman으로 MemberController의 getMember() 핸들러 메서드에 요청을 보내면 MemberService에서 RuntimeException을 던지고,

GlobalExceptionAdvice의 HandleResourceNotFoundException() 메서드가

이 RuntimeException을 잡아서 예외 메시지인 “Not found member”를 콘솔에 출력할 것이다.

handleResourceNotFoundException() 메서드의 문제점

회원 등록 시 이미 존재하는 회원일 경우,

로그인 패스워드 검증에서 패스워드가 일치하지 않는 경우 등

서비스 계층에서 의도적으로 던질 수 있는 예외 상황은 다양하게 존재할 수 있기 때문에

handleResourceNotFoundException() 의 메서드 이름은 적절하지 않다.

또한 추상적인 RuntimeException을 그대로 전달받는 것 역시 바람직하지 않다.

서비스 계층에서 RuntimeException을 그대로 던지고, Exception Advice에서 RuntimeException을 그대로 잡는 것은 예외의 의도가 명확하지 않으며, 구체적으로 어떤 예외가 발생했는지에 대한 예외 정보를 얻는 것이 어렵다.

사용자 정의 예외(Custom Exception) 사용

앞에서 백엔드 서버와 블록체인 간의 통신에서 블록체인 쪽에서 잔고 부족으로 인한 에러 메시지를 백엔드 서버 쪽에 전송한 경우를 다시 생각해 보겠다.

이 경우, 서버 쪽에서는 RuntimeException과 같은 추상적인 예외가 아닌 InsufficentBalanceException 같은 해당 예외를 조금 더 구체적으로 표현할 수 있는 Custom Exception을 만들어서 예외를 던질 수 있다.

이 Custom Exception을 샘플 애플리케이션에 적용해 보겠다.

예외 코드 정의

public enum ExceptionCode {
    MEMBER_NOT_FOUND(404, "Member Not Found");

    @Getter
    private int status;

    @Getter
    private String message;

    ExceptionCode(int status, String message) {
        this.status = status;
        this.message = message;
    }
}

먼저 위와 같이 서비스 계층에서 던질 Custom Exception에 사용할 ExceptionCode를 enum으로 정의한다.

이처럼 ExceptionCode를 enum으로 정의하면 비즈니스 로직에서 발생하는 다양한 유형의 예외를 enum에 추가해서 사용할 수 있다.

BusinessLogicException 구현

public class BusinessLogicException extends RuntimeException {
    @Getter
    private ExceptionCode exceptionCode;

    public BusinessLogicException(ExceptionCode exceptionCode) {
        super(exceptionCode.getMessage());
        this.exceptionCode = exceptionCode;
    }
}

서비스 계층에서 사용할 BusinessLogicException이라는 Custom Exception을 정의한다.

BusinessLogicException은 RuntimeException을 상속하고 있으며 ExceptionCode를 멤버 변수로 지정하여 생성자를 통해서 조금 더 구체적인 예외 정보들을 제공해 줄 수 있다.

그리고 상위 클래스인 RuntimeException의 생성자(super)로 예외 메시지를 전달해 준다.

BusinessLogicException은 서비스 계층에서 개발자가 의도적으로 예외를 던져야 하는 다양한 상황에서 ExceptionCode 정보만 바꿔가며 던질 수 있다.

서비스 계층에 BusinessLogicException 적용

@Service
public class MemberService {
    ...
    ...

    public Member findMember(long memberId) {
        // TODO should business logic

        // (1)
        throw new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND);
    }

    ...
    ...
}

위 코드에서 RuntimeException을 던지던 것을 BusinessLogicException에 구체적인 예외 정보를 던지도록 변경했다.

여기서는 회원 정보가 존재하지 않는다는 MEMBER_NOT_FOUND를 BusinessLogicException 생성자의 파라미터로 전달했다.

Exception Advice에서 BusinessLogicException 처리

이제 서비스 계층에서 던진 BusinessLogicException을 Exception Advice에서 처리하면 된다.

@RestControllerAdvice
public class GlobalExceptionAdvice {
    ...
    ...

    @ExceptionHandler
    public ResponseEntity handleBusinessLogicException(BusinessLogicException e) {
        System.out.println(e.getExceptionCode().getStatus());
        System.out.println(e.getMessage());

        return new ResponseEntity<>(HttpStatus.valueOf(e.getExceptionCode().getStatus()));
    }
}

위 코드에서 변경된 부분은 다음과 같다.

메서드 명 변경

메서드 명이 서비스 계층의 비즈니스 로직 처리에서 발생하는 예외를 처리하는 것을 목적으로 하기 때문에 메서드 명이 handleBusinessLogicException으로 변경되었다.

메서드 파라미터 변경

RuntimeException을 파라미터로 전달받던 것을 BusinessLogicException을 전달받는 것으로 변경되었다.

@ResponseStatus(HttpStatus.NOT_FOUND) 제거

@ResponseStatus 어노테이션은 고정된 HttpStatus를 지정하기 때문에 BusinessLogicException과 같이 다양한 Status를 동적으로 처리할 수 없으므로 ResponseEntity를 사용해서 HttpStatus를 동적으로 지정하도록 변경했다.

변경된 코드를 테스트하기 위해 Postman에서 MemberController의 getMember() 핸들러 메서드에 요청을 전송하면 다음과 같은 출력 결과를 확인할 수 있다.

404
Member Not Found

이처럼 BusinessLogicException 클래스를 통해 전달받은 구체적인 예외 정보는 ErrorResponse에 적절히 포함해서 클라이언트의 응답으로 전달하면 된다.

@RestControllerAdvice에서 @ResponseStatus, ResponseEntity 중 어느것을 사용할까?

한 가지 유형으로 고정된 예외를 처리할 경우에는 @ResponseStatus로 HttpStatus를 지정해서 사용하면 되고,

BusinessLogicException 처럼 다양한 유형의 Custom Exception을 처리하고자 할 경우에는 ResponseEntity를 사용하면 된다.

Spring Boot 핵심 정리 모음

1P - POJO, IoC, DI, AOP, PSA >

1.1P - DI / 의존성 주입은 왜 필요한가? >

2P - 빈(Bean) 생명주기와 범위 >

3P - 컴포넌트 스캔과 의존성 자동 주입, @Autowired >

4P - AOP (관심 지향 프로그래밍) >

5P - Spring MVC / MVC의 동작 방식과 구성 요소 >

6P - REST, REST API >

7P - HTTP Header, Rest Client >

8P - DTO, DTO 유효성 검증 >

9P - Service / Entity 클래스, Mapper / MapStruct >

10P - 예외 처리 (@ExceptionHandler, @RestControllerAdvice) >

10.1P - 비즈니스 로직에 대한 예외 처리 >

11P - JDBC 데이터 액세스 계층 >

11.1P - DDD, 도메인 엔티티 및 테이블 설계 >

11.2P - 데이터 액세스 계층 구현 (도메인 엔티티 클래스 정의) >

11.3P - 데이터 액세스 계층 구현 (서비스, 리포지토리 구현) >

12P - JPA(Java Persistence API) >

12.1P - JPA 엔티티 매핑 >

12.2P - JPA 엔티티 간의 연관 관계 매핑 >

12.3P - Spring Data JPA 데이터 액세스 계층 구현 >

13P - Spring MVC 트랜잭션 >

13.1P - 선언형 방식의 트랜잭션 적용 >

13.2P - JTA를 이용한 분산 트랜잭션 적용 >

14P - Spring MVC Testing 단위 테스트 >

14.1P - JUnit 단위 테스트 >

14.2P - Hamcrest Assertion >

14.3P - 슬라이스 테스트 (API, 데이터 액세스 계층) >

14.4P - Mockito >

14.5P - TDD (Test Driven Development) >

15P - API 문서화 (Documentation) >

15.1P - API 문서 생성, Asciidoc, Asciidoctor >

16P - 애플리케이션 빌드 / 실행 / 배포 >

17P - Spring Security >

17.1P - Spring Security 2 >

18P - JWT (JSON Web Token) >

댓글남기기