프로그래밍/java, spring

spring 예외처리 정리(exception기본, 중첩된 예외, 멀티서버간 예외처리)

브래드 킴 2023. 1. 11. 22:00
728x90

클라이언트에서 api서버에 데이터를 요청했을때 예외가 발생하면, 서버에서는 적절한 예외처리와 이와 관련된 http response를 클라이언트에 내려줘야 한다. 그래야지, 사용자가 어떤 요청을 잘못했는지에 대해 알수 있을것이고, 팝업을 띄우거나 관련된 에러화면을 보여줄 수 있을것이다.

그래서 오늘은 3가지 정도의 category로 예외처리에 대해 정리해보고자 한다.

먼저, 기본적인 예외처리, ControllerAdvice의 사용, ResponseEntity, JSON형태의 예외메시지 등 기본적인 1대의 API서버에서 발생할 수 있는 여러 상황에 대해서 알아보고자 한다.

두번째로는 예외 message중에 예외에 예외가 감싸져 wrapping되어 넘어오는 경우 또한 종종 있다. 예외에 예외가 wrapping되는 경우는 service에서도 예외처리를 하고, controller에서도 예외처리를 해야 하는 어떤 상황때문에, 최종적으로 예외가 넘어오는 controller의 예외response가 service의 예외값 까지 감싸고 있기 때문이다.

마지막으로는 단일서버로 endpoint가 끝나는 것이 아닌, 타(회사) 서비스의 api를 호출해서 가져와 response를 처리하는 경우에 발생할 수 있는 케이스에 대해서 알아보고자 한다. 최종 enpoint서버가 주는 예외를 catch해서 다시 정제한뒤에 클라이언트에 response를 줘야할 것이다. 이 경우 에도 몇몇 문제들이 발생한다. 이에 대해 알아보자.

*git코드참고) https://github.com/kimseonguk197/spring_exception_handling

일단, 단순한 단일서버에서 예외핸들링을 기본적으로 어떻게 처리하고, 어떤식으로 활용할 수 있는지 하나씩 살펴보자.

단일서버 예외 handling

컨트롤러로 게시글 목록을 조회를 요청했을때, list가 없으면, EntityNotFoundException Exception을 던져주고자 한다. 컨트롤러와 서비스를 봐보도록 하자. 현재 DB에 게시글 내용은 하나도 없는 상황이라, 무조건 예외가 발생하는 상황이다.

controller

@Slf4j
@RestController
public class PostRestController {
    @GetMapping("api/posts")
    public List<Post> postList(){
        return postService.findAll();
    }
}

service

@Service
@Transactional
public class PostService {
    public List<Post> findAll(){
        List<Post> result = repository.findAll();
        if(result.isEmpty()){
            throw new EntityNotFoundException("findAll EntityNotFoundException");
        }
        return result;
    }
}

postman으로 테스팅시 아래와 같은 결과값을 받게 된다. 일반적인 500에러가 발생한다.

ControllerAdvice와 ResponseEntity활용

좀 더 확실하게 404에러를 주고 싶다면 아래와 같이 ControllerAdvice를 활용하여 코딩하면 된다.

@Slf4j
@RestControllerAdvice
public class ExceptionHandlerAdvice {
    @ExceptionHandler(EntityNotFoundException.class)
    @ResponseStatus(value = HttpStatus.NOT_FOUND)
    public ResponseEntity<Object>  notFound() {
        return new ResponseEntity<Object>("no entity", new HttpHeaders(), HttpStatus.NOT_FOUND);
    }
}

ControllerAdvice를 통해 발생하는 예외를 catch하고, ResponseEntity를 통해 response를 만들어주면 된다. 위와 같이 코딩 후 다시 호출하면 아래와 같은 결과값을 받게 된다.

JSON형태 Return

JSON형태로 timestamp, status, error Message까지 깔끔하게 주고 싶다면, 먼저 ErrorResponse를 줄수 있는 객체를 하나 만들고,

@Getter
@AllArgsConstructor
public class ErrorResponse {
    private LocalDateTime timestamp;
    private HttpStatus status;
    private String errors;
}

아래와 같이 코딩하면 되겠다.

@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<Object>  notFound(EntityNotFoundException e, HttpRequest req) {
    final ErrorResponse response = new ErrorResponse(LocalDateTime.now(),HttpStatus.NOT_FOUND, e.getMessage());
    return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}

호출 결과는 아래와 같다.

Cutomizing 예외

java에 내장된 예외가 아닌 별도로 예외를 만들고 싶다면, 아래와 같이 Exception을 상속받아 클래스를 만들면 된다.

public class NoListException extends Exception {
    public NoListException() {super();}
    public NoListException(String message) {super(message);}
}

Wrapping 된 예외 Case

당연하겠지만 아래와 같이 controller에서도 예외를 처리하고, service에서도 예외를 처리하게 되면, 예외 message를 중복으로 return을 하게 될 수도 있다.

    @GetMapping("api/posts")
    public List<Post> postList(){
//        wrapping 되는 case
        try{
            List<Post> posts=  postService.findAll();
            return posts;
        }catch(EntityNotFoundException e){
            throw new EntityNotFoundException("postList EntityNotFoundException" + e.getMessage());
        }
//        wrapping되지 않는 case
//        return postService.findAll();
    }

포스트맨으로 확인해보면 아래처럼 service의 예외 message와 controller의 예외 message가 중복되어 찍히는 것이다.

사실 wrapping관련된 case는 위와 같이 단순한 케이스에는 별 의미가 없다. 여기서는 개념적 부분만 알고 가도록 하자.

현업에서는 service의 rollback처리를 위해 service에서 exception을 throw하고, service의 호출구조가 복잡해지고, 여러 서버간에 response를 주고받는 복잡한 상황이 중첩되면서, 에러 메시지가 wrapping되어 parsing이 어려워져서 곤란해지는 상황이 종종 발생하기도 한다.

Multi서버 예외 handling

만약에 클라이언트 -> 서버api -> 타회사 API호출 등의 과정을 거쳐서 데이터를 내려받아야 하는 상황이 있다고 가정하자. 타회사 API에서 예외가 발생했을때, 우리회사의 서버와 클라이언트 리소스에서는 어떤 이유로 에러가 발생했는지를 파악해야만 할 것이다. 어떤식으로 예외처리를 해야 하는지 하나씩 봐보자.

일단 타회사인 A회사의 API의 경우 어떤 요청값을 가지고 요청을 할 경우엔 400에러와 404가 종종발생하는 예외상황들이 있다고 가정하자. 해당 예외를 잘 catch해야만 어떤 이유에서 에러가 났는지 알수가 있고, 우리회사의 사버에서는 그에 맞는 요청값을 다시 response로 보낼줄수가 있다.

예외처리를 안했을때

아래는 acompay의 API를 호출하는 단순한 Controller이다.

@GetMapping("api/posts/acompany")
public Post acompanyApi() {
       return postService.test();
}

service에서는 RestTemplate을 사용하여, A회사의 api를 호출하고 있다. case는 단순화 하기 위해서, Post형태의 게시글 목록을 동일하게 내려준다고 가정했다. 그러나, 앞서 말한바와같이  A회사의 api는 400아니면 404에러만을 뱉는다고 가정하자.

public Post test(){
    RestTemplate restTemplate = new RestTemplate();
    ResponseEntity<Post> response = restTemplate.exchange(
            "http://localhost:8082/acompany",
            HttpMethod.GET,
            null,
            Post.class
    );
    return Objects.requireNonNull(response.getBody());
}

현재는 예외처리가 전혀 안되어 있는 상황이다. 이제 포스트맨에서 먼저 A회사의 api를 직접 호출해보자.

A회사의 API호출

400에러를 뱉고있다. 이번엔 우리회사의 API인 api/posts/acompany를 통해 A회사의 api를 호출해보자.

우리회사의 API

기본 서버에러인 Internal Server Error인 500 에러가 발생하고 있다. 이는 A회사의 response를 제대로 catch해서 가져오지 못하고 현재 서버에서 발생한 기본 에러만을 response에 담고 있는 것이다. A회사의 API호출해서 받은 a회사의 에러를 뱉어줘야 정상적인 상황일 것이다.

예외처리를 했을때

public Post test() throws Exception {
    RestTemplate restTemplate = new RestTemplate();
    try {
        ResponseEntity<Post> response = restTemplate.exchange(
                "http://localhost:8082/acompany",
                HttpMethod.GET,
                null,
                Post.class
        );
        return Objects.requireNonNull(response.getBody());
    } catch (Exception e) {
        String message = e.getMessage();
        //message를 분석해보니, 3번째 자리까지 400 또는 404등 status값이 온다.
        int status = Integer.parseInt(message.substring(0,3));
        //error내용이 무엇인지 문구를 JSONObject를 통해 Parsing하자.
        JSONObject jo = new JSONObject(message.substring(7));
        //JSONObject는 소모가 되는 성질이 있으니 아래와 같이 String에 담아 쓰자
        String errorMessage = (String) jo.get("errors");
        //상대방 서버가 주는 에러별로 이 서버에서도 같은 에러값을 return해주도록 한다.
        if(status == 400){
            throw new NoListException(errorMessage);
        }else if(status == 404){
            throw new EntityNotFoundException(errorMessage);
        }else{
            throw new Exception();
        }
    }
}

API를 호출하고, 예외가 발생했을때 상황별로 위와 같이 예외처리를 하게 되면 A회사가 주는 에러를 우리회사의 API에서도 동일하게 클라이언트에 내려줄 수 있게 된다.

포스트맨을 확인해 보면 아래와 같이 status뿐만 아니라, A회사로부터 넘어온 error문구 까지도 우리회사 API에서 처리할 수 있게 된다.

728x90