Jhipster&Spring 예외처리 1편

Updated:

예외처리란?

소프트웨어를 개발하다보면 어떤 경우에서든 사용자가 프로그램을 사용하던중 마주칠 수도 있는 예외의 상황을 생각하게 됩니다.

예를 들면, 사용자가 로그인을 시도하는 경우 아이디가 틀렸다면 잘못된 아이디를 입력하셨습니다.라던지, 게시판 프로그램에서는 사용자가 입력할 수 있는 문자열 byte를 초과입력 하였다면 100글자 이내의 글을 입력해주세요와 같은 예외가 생길 수 있겠죠.

이때, 적절한 예외 메세지를 사용자에게 보여주고 예외의 상황에 따라 적절한 조치를 취하는 것이 중요합니다. 잘못된 아이디를 입력하였다고 해서 NullPointException와 같은 에러메세지를 던져버린다던지 프로그램이 먹통이 되는 일이 있어서는 안되기 때문입니다.

따라서 프로그래밍에서 예외처리는 정말 중요한 요소입니다.

하지만 예외처리는 너무 어려워요.

그렇지만 예외처리는 너무나 복잡하고 어렵습니다.

어디까지 어떤 방식으로 예외를 처리해야할지 고민해야할 요소가 많고, 부적절한 방식으로 예외를 처리하게 되면 오히려 더 코드가 복잡해지고 유지보수도 어려워지기 때문입니다.

Java에서 예외처리를 하는 방식은 다양한데, 대표적으로 “예외처리”라는 단어를 보면 바로 떠오르는 try-catch문이나 if-else의 경우 특정한 경우가 아니면 지양해야하는 예외처리 방식입니다.(물론, 필요하다면 사용해야하는 경우도 있습니다!)

왜냐하면 try-catch문의 경우 유지보수하기 어려울 뿐만 아니라, 비즈니스 로직 개발에 집중하기 어렵고 비즈니스 로직이 아닌 예외처리를 위한 코드만 무수히 많아지게 되기도합니다. if-else로 예외처리를 하게되면 코드의 가독성이 떨어져 try-catch문과 비슷&동일한 문제점이 발생하게 됩니다.

그렇다면 Spring에서는 이렇게 복잡하고 어려운 Exception처리를 위해 어떤 방식을 지원하고 있을까요?

Spring에서 예외처리를 하게 된다면?

Spring에서의 예외처리 방식은 아래와 같이 총 3가지로 구분됩니다.

  1. 전역 예외 처리 : @ControllerAdvice
  2. 컨트롤러단의 예외 처리 : @ExceptionHandler
  3. 메소드/서비스 단의 예외처리 : try-catch

이번 포스팅에서는 @ControllerAdvice와 @ExceptionHandler를 활용하여 예외처리를 하는 방법에 대해서 알아보도록 하겠습니다.

통일된 Error Message 형태

만약 각 에러마다 모두 다른 Error Message 객체로 클라이언트에게 응답한다면 어떤일이 벌어질까요? 아마 클라이언트에서 각각의 Error Message 객체마다 모두 다른 로직을 수행하게 될 것입니다. 그것은 바람직하지 않기 때문에, 클라이언트에게 에러 메세지를 넘겨줄 때에는 되도록 통일된 Format의 Error Message 객체를 전달하는 것이 좋습니다.

예제에서는 ApiErrorDetails라는 객체를 생성하여 Error Message를 만들 것 입니다. ApiErrorDetails객체는 web.rest아래 errors라는 패키지 아래에 생성하였습니다.

@Getter
@Setter
public class ApiErrorDetails {

    private String message;
    private int code;
    private LocalDateTime timeStamp;


}

최대한 심플하게 생성한 것으로, 클라이언트에게 전달할 Message, errorCode, 그리고 에러가 발생한 시간을 나타내는 timeStamp로 구성하였습니다.

*errorCode와 같은 경우 큰 프로젝트라면 Enum처리하여 errorCode를 지정하는 것이 바람직합니다. *

@ControllerAdvice & @ExceptionHandler 적용하기

@ControllerAdvice를 활용하면 모든 예외를 한 곳에서 처리 가능합니다. Jhipster 프로젝트에서는 web.rest아래 errors 패키지의 ExceptionTranslator클래스에서 확인 할 수 있습니다.

handleMethodArgumentNotValidException, handleBadRequestAlertException, handleConcurrencyFailure 등 기본적인 예외처리가 이루어져있고 이외에 발생할 수 있는 예외처리는 handleExeption과 같이 범용적인 에러 메소드를 선언하여 처리하기도합니다.


@ControllerAdvice
public class ExceptionTranslator implements ProblemHandling, SecurityAdviceTrait {

    ...(중략)...

    @Override
    public ResponseEntity<Problem> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, @Nonnull NativeWebRequest request) {
        BindingResult result = ex.getBindingResult();
        List<FieldErrorVM> fieldErrors = result.getFieldErrors().stream()
            .map(f -> new FieldErrorVM(f.getObjectName().replaceFirst("DTO$", ""), f.getField(), f.getCode()))
            .collect(Collectors.toList());

        Problem problem = Problem.builder()
            .withType(ErrorConstants.CONSTRAINT_VIOLATION_TYPE)
            .withTitle("Method argument not valid")
            .withStatus(defaultConstraintViolationStatus())
            .with(MESSAGE_KEY, ErrorConstants.ERR_VALIDATION)
            .with(FIELD_ERRORS_KEY, fieldErrors)
            .build();
        return create(ex, problem, request);
    }

    @ExceptionHandler
    public ResponseEntity<Problem> handleBadRequestAlertException(BadRequestAlertException ex, NativeWebRequest request) {
        return create(ex, request, HeaderUtil.createFailureAlert(applicationName, true, ex.getEntityName(), ex.getErrorKey(), ex.getMessage()));
    }

    @ExceptionHandler
    public ResponseEntity<Problem> handleConcurrencyFailure(ConcurrencyFailureException ex, NativeWebRequest request) {
        Problem problem = Problem.builder()
            .withStatus(Status.CONFLICT)
            .with(MESSAGE_KEY, ErrorConstants.ERR_CONCURRENCY_FAILURE)
            .build();
        return create(ex, problem, request);
    }
}

하지만 이 외에 비즈니스 로직에서 발생할 수 있는 예외의 경우 @ExceptionHandler로 추가해주고 위에서 생성한 ApiErrorDetails라는 공통적인 객체로 에러메세지를 생성하여 클라이언트에게 전달해주도록합니다.

비즈니스 로직에서 발생할 수 있는 예외를 처리해보자.

그렇다면 비즈니스 로직에서 발생할 수 있는 예외를 어떻게 처리할까요?

샘플 프로젝트는 간단한 도서 대여 프로그램입니다. 따라서, Rental이라는 도서를 대여하기 위해 필요한 사용자의 대여 내역과 도서 대여 가능 여부 등을 가지고 있는 Entity가 있습니다.

도서를 대여하기 위해서는 도서가 대여가능해야하며 사용자 또한 대여가 가능한 상태여야합니다.

그런데 만약 사용자가 도서 연체 때문에 도서를 대여할 수 없는 상황이라면 사용자에게 현재 도서를 대여할 수 없는 상태이며 연체 상태를 해제해야한다는 메세지를 보내야합니다. 따라서, 아래와 같이 도서대여불가 예외를 나타내는 클래스를 먼저 생성합니다.

public class RentUnavailableException extends RuntimeException{

    private static final long serialVersionUID = 1L;

    public RentUnavailableException(){

    }

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

그 다음, 사용자의 대여 가능여부를 체크하는 메소드에서 대여가 불가능한 경우 위에서 선언한 RentUnavailableException 예외를 던지도록 처리합니다.

먼저 클라이언트의 요청이 들어오는 컨트롤러부터 살펴보도록 할게요.

@RestController
@RequestMapping("/api")
public class RentalResource {
    
    ...(중략)...
    
    @PostMapping("/rentals/{userid}/RentedItem/{book}")
    public ResponseEntity<RentalDTO> rentBooks(@PathVariable("userid") Long userid, @PathVariable("book") Long bookId)
        throws InterruptedException, ExecutionException, JsonProcessingException, RentUnavailableException {
        ...(중략)...
        Rental rental= rentalService.rentBook(userid, bookInfoDTO);
        RentalDTO rentalDTO = rentalMapper.toDto(rental);
        return ResponseEntity.ok().body(rentalDTO);

    }

}

클라이언트로부터 대여 요청이 들어오면 rentalService.rentBook을 호출하여 결과를 받습니다.

@Service
@Transactional
public class RentalServiceImpl implements RentalService {

    ...(중략)...

    /**
     * 도서 대여하기
     *
     * @param userId
     * @param book
     * @return
     */
    @Transactional
    public Rental rentBook(Long userId, BookInfoDTO book) throws InterruptedException, ExecutionException, JsonProcessingException, RentUnavailableException {
    
        Rental rental = rentalRepository.findByUserId(userId).get();
        rental.checkRentalAvailable();

        rental = rental.rentBook(book.getId(), book.getTitle());
        rentalRepository.save(rental);

        ...(중략)...

        return rental;

    }
}

rentalServiceImpl은 rentalService.rentBook을 구현하는 클래스입니다. rentBook 메소드에서는 Rental Entity내부에 있는 rental.checkRentalAvailable을 호출하여 사용자의 도서대여가능 여부를 체크합니다.

@Entity
@Table(name = "rental")
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
@Data
public class Rental implements Serializable {

    ...(중략)...
    //대여 가능 여부 체크 //
    public void checkRentalAvailable() throws RentUnavailableException {
        if(this.rentalStatus.equals(RentalStatus.RENT_UNAVAILABLE ) || this.getLateFee()!=0) throw new RentUnavailableException("연체 상태입니다. 연체료를 정산 후, 도서를 대여하실 수 있습니다.");
        if(this.getRentedItems().size()>=5) throw new RentUnavailableException("대출 가능한 도서의 수는 "+( 5- this.getRentedItems().size())+"권 입니다.");

    }
}

그 다음, checkRentalAvailable 메소드에서 Rental의 도서대여가능여부를 확인합니다. 연체상태이거나, 연체료가 남았거나, 대여가능한 도서의 수를 초과하는 경우 위에서 선언한 RentUnavailableException을 던집니다. 이때, 경우에 따라 클라이언트에 전달하는 에러메세지가 달라집니다.

  • 연체 또는 연체료가 남은 경우 : “연체 상태입니다. 연체료를 정산 후, 도서를 대여하실 수 있습니다.”
  • 대여 가능한 도서의 수가 초과된 경우 : “대출 가능한 도서의 수는 ~~권 입니다.”

던져진 에러를 받아보자.

던져진 RentUnavailableException은 모든 예외가 처리되는 @ControllerAdvice가 선언된 ExceptionTranslator에 의해 처리됩니다.

@ControllerAdvice
public class ExceptionTranslator implements ProblemHandling, SecurityAdviceTrait {

    ...(중략)...

    @ExceptionHandler(RentUnavailableException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<ApiErrorDetails> handleRentUnavailableException(RentUnavailableException rue){
        ApiErrorDetails errorDetails = new ApiErrorDetails();
        errorDetails.setTimeStamp(LocalDateTime.now());
        errorDetails.setCode(1002);
        errorDetails.setMessage(rue.getMessage());
        return new ResponseEntity(errorDetails, HttpStatus.BAD_REQUEST);
    }
}

ExceptionTranslator 클래스 내에 위와 같이 @ExceptionHandler에 RentUnavailableException.class를 선언하여 RentUnavailbleException으로 던져진 모든 예외를 받도록합니다.

그리고 통일된 에러메세지 ApiErrorDetails를 생성하여 던져진 RentUnavailableException에 저장된 메세지와 timeStamp, 그리고 errorCode를 받아 클라이언트에 전달합니다.

에러메세지 결과

그럼 이제 구현한대로 사용자가 대여불가능인 상태일때 대여를 시도하면 대여불가 메세지가 설정한대로 잘 구현되는지 살펴봅시다.

  1. 사용자가 연체상태이기 때문에 사용자의 대여상태가 불가능인 것을 아래 이미지와 같이 확인할 수 있습니다. 따라서, 도서를 대여 신청하는 경우 사용자에게 연체를 해제한 후 대여하라는 메세지가 전달되어야합니다.

  2. 사용자가 도서를 대여 신청합니다.

  3. 사용자의 대여상태가 불가능하다는 에러메세지가 사용자에게 전달됩니다.

이로써 비즈니스 로직에서 예외가 발생하는 경우 어떻게 처리할 수 있는지, 어떻게 결과가 나오는 지 확인해보았습니다.

Jhipster에서는 위와 같이 ExceptionTranslator 라는 GlobalExceptionHandler 클래스를 제공합니다. 해당 클래스를 위 예제처럼 활용하면 더 나은 예외처리가 가능할 것으로 보입니다.

다음엔 비즈니스 로직 예외처리가 아닌, 외부 서비스에서 에러가 발생하는 경우 외부서비스에 요청하였던 클래스에서는 해당 에러를 어떻게 Decoding하여 처리하는지 확인해보도록 하겠습니다.

Spring Exception 처리 관련된 저의 다른 포스팅도 확인해보세요! : https://engineering-skcc.github.io/spring/exception-guide/