Programming/Spring

Spring - 예외 처리

잇(IT) 2023. 9. 6. 17:38

- 기존의 Controller에서 발생하는 예외들은 @ControllerAdvice 어노테이션이 붙은 클래스에서 처리가 가능하다. 

- @ControllerAdvice는 전역 예외 처리가 가능하고 예외가 발생했을 때 처리할 수 있는 공통 로직을 제공한다.


- PostCreate.java

.....

@NotBlank(message = "title을 입력해주세요")
    private String title;

    @NotBlank(message = "content를 입력해주세요")
    private String content;
    
    .....

- Post 엔티티에 대한 DTO에 해당하는 PostCreate는 @NotBlank 어노테이션으로 인해 스프링이 제공하는 기본적인 검증이 이루어 진다.

- 하지만 기본적으로 제공하는 검증이기 때문에 특정한 검증(ex) title에 특정 단어가 포함되지 않게 검증해라) 에 대해선 검증 할 수 없다.

- 때문에 추가적인 검증을 위한 메서드 및 해당 검증에 대한 예외를 생성해주어야 한다.

 

- PostCreate.java

.....

public void validate() {
        if (title.contains("바보")) {
            throw new InvalidRequest("title", "제목에 바보를 포함할 수 없습니다.");
        }
    }

- PostCreate 클래스에 검증을 위한 메서드를 추가한다.

- 위 코드는 넘어온 title에 대해 "바보"라는 단어를 포함하게 되면 예외를 던지는 코드이다.

 

- PostController.java

@PostMapping("/posts")
    public Post post(@RequestBody @Valid PostCreate request) {

        request.validate();
        return postService.write(request);
    }

- 검증을 위해 validate() 메서드를 실행하는 코드를 추가한다.


- 여러 검증을 위한 예외 클래스를 생성하고 클래스의 수 만큼 @ControllerAdvice에 각 클래스에 대한 예외 처리 코드를 별도로 작성해 주어야 한다.

- 하지만 예외 클래스 마다 코드를 작성하는 것은 예외 클래스에 대한 에러 코드가 발생할 가능성이 크기 때문에 여러 예외 클래스에 대한 (개별적인)최상위 클래스를 생성하여 해당 클래스로 예외를 던지게 만드는 것이 좋다.

 

- IslogException.java ((개별적)최상위 예외 클래스)

public abstract class IslogException extends RuntimeException {

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

    public IslogException(String message, Throwable cause) {
        super(message, cause);
    }

    public abstract int getStatuscode();
}

- 위의 예외 클래스는 JAVA에서 기본적으로 제공하는 예외 클래스가 아니기 때문에 실제 예외를 상속 받아야 하며 예외를 실제 예외 클래스로 던져야 한다.

- 예외는 대체로 런타임, 언체크 예외인 RuntimeException으로 던지는 것이 좋다.

- getStatuscode()의 추상 메서드를 작성한 이유는 각 예외 클래스의 상태 코드가 다르기 때문에 각 예외로 부터 코드를 받아오는 것이 코드 중복이 발생하지 않아 좋기 때문이다.

 

- InvalidRequest.java

@Getter
public class InvalidRequest extends IslogException {

    private static final String MESSAGE = "잘못된 요청입니다.";

    public String fieldName;
    public String message;

    public InvalidRequest() {
        super(MESSAGE);
    }

    public InvalidRequest(String fieldName, String message) {
        super(MESSAGE);
        this.fieldName = fieldName;
        this.message = message;
    }

    @Override
    public int getStatuscode() {
        return 400;
    }
}

- 위 클래스는 예외로 사용하기 위해 생성한 클래스이다.

 

- ExceptionController.java

.....

@ResponseBody
    @ExceptionHandler(IslogException.class)
    public ResponseEntity<ErrorResponse> islogException(IslogException e) {
        int statusCode = e.getStatuscode();

        ErrorResponse body = ErrorResponse.builder()
                .code(String.valueOf(statusCode))//애초에 받아올 때 String으로 받아오던가 반환값을 뭐 알아서 변경하면 된다.
                .message(e.getMessage())
                .build();

        // 응답 json validation -> title : 제목에 바보를 포함할 수 없습니다.

        if (e instanceof InvalidRequest) {
            InvalidRequest invalidRequest = (InvalidRequest) e;
            String fieldName = invalidRequest.getFieldName();
            String message = invalidRequest.getMessage();
            body.addValidation(fieldName, message);
        }

        ResponseEntity<ErrorResponse> response = ResponseEntity.status(statusCode)
                .body(body);

        return response;
    }
    
.....

- 전역 예외를 처리하기 위한 @ControllerAdvice가 붙은 클래스이다.


- 예외 테스트

- Test 실행 -> Controller에서 InvalidException 발생 (파라미터로 "title", "제목에 바보를 포함할 수 없습니다"를 포함) -> ExceptionController.java의 @ExceptionHandler(IslogException.class)가 붙은 메서드 실행(IslogException.class는 추가 예외 클래스 중 최상위 클래스)

 

- PostControllerTest.java

@Test
    @DisplayName("게시글 작성시 제목에 '바보'는 포함될 수 없다.")
    void test11() throws Exception {

        PostCreate request = PostCreate.builder()
                .title("나는 바보입니다.")
                .content("내용입니다.")
                .build();

        String json = objectMapper.writeValueAsString(request);

        //when
        mockMvc.perform(MockMvcRequestBuilders.post("/posts")
                                .contentType(APPLICATION_JSON)
                                .content(json)
                )
                .andExpect(status().isBadRequest())
                .andDo(print());
    }

- MockMvc를 통해 임시로 요청을 생성하는데 전달하는 엔티티의 title 값에 "바보"가 포함되어 있고,

Controller에서 "바보"가 포함된 title의 경우 InvalidRequest이 발생하도록 코드를 작성하였기 때문에 예외가 발생하여 InvalidRequest로 예외를 던진다.

 

- InvalidRequest의 생성자에 Controller에서 넘어온 값이 들어가고 최상위 예외인 IslogException로 예외를 전달하기 위해 super() 메서드를 호출한다.

public abstract class IslogException extends RuntimeException {

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

    public IslogException(String message, Throwable cause) {
        super(message, cause);
    }

    public abstract int getStatuscode();
}

- 최종적으로 IslogException는 RuntimeException을 상속 받는다. 즉, 언체크, 런타임 예외로 처리된다.

- 예외가 발생했으므로 @ControllerAdvice가 붙은 클래스에서 처리 된다.

 

- ExceptionController.java

@ResponseBody
    @ExceptionHandler(IslogException.class)
    public ResponseEntity<ErrorResponse> islogException(IslogException e) {
        int statusCode = e.getStatuscode();

        ErrorResponse body = ErrorResponse.builder()
                .code(String.valueOf(statusCode))//애초에 받아올 때 String으로 받아오던가 반환값을 뭐 알아서 변경하면 된다.
                .message(e.getMessage())
                .build();

        // 응답 json validation -> title : 제목에 바보를 포함할 수 없습니다.

        if (e instanceof InvalidRequest) {
            InvalidRequest invalidRequest = (InvalidRequest) e;
            String fieldName = invalidRequest.getFieldName();
            String message = invalidRequest.getMessage();
            body.addValidation(fieldName, message);
        }

        ResponseEntity<ErrorResponse> response = ResponseEntity.status(statusCode)
                .body(body);

        return response;
    }

- @ExceptionHandler에 IslogException클래스에 대한 처리 메서드가 있기 때문에 해당 메서드에서 위에서 발생한 예외를 처리하게 된다. 

- 반환 타입을 ResponseEntity<ErrorResponse>로 하게되면 ErrorResponse를 통한 HTTP 응답 필드에 대한 설정이 가능하다.

- ErrorResponse 엔티티에 있는 필드들을 builder 패턴을 이용해 객체를 생성한다.

- 매개변수로 IslogException을 받으므로 IslogException의 하위 클래스들을 타입 캐스팅을 통해 사용할 수 있다.

- if문을 통해 InvalidRequest 예외가 넘어온 경우 ErrorResponse의 validation 필드에 값을 넣기 위한 fieldName과 message 필드를 생성하여 InvalidRequest로부터 넘어온 값들을 넣고, ErrorResponse의 validatoin 필드에 Map 형태로 값을 집어 넣는다.

- ResponseEntity<ErrorResponse>의 객체를 생성하고, 응답코드 및, Response body에 넣을 값을 지정하여 해당 객체를 메서드에 반환한다.

- Test 코드의 결과로 위와 같이 HttpResponse 필드에 각 데이터가 입력되어 보여지는 것을 확인할 수 있다.

 


- 코드 리팩토링

 

- ExceptionController.java

if (e instanceof InvalidRequest) {
            InvalidRequest invalidRequest = (InvalidRequest) e;
            String fieldName = invalidRequest.getFieldName();
            String message = invalidRequest.getMessage();
            body.addValidation(fieldName, message);
        }

- ExceptionController 클래스의 위 코드는 예외가 발생하는 field와 message를 전달하기 위해서 예외 클래스마다 if문을 작성해야 한다는 단점이 있다.

 

@ResponseBody
    @ExceptionHandler(IslogException.class)
    public ResponseEntity<ErrorResponse> islogException(IslogException e) {
        int statusCode = e.getStatuscode();

        ErrorResponse body = ErrorResponse.builder()
                .code(String.valueOf(statusCode))//애초에 받아올 때 String으로 받아오던가 반환값을 뭐 알아서 변경하면 된다.
                .message(e.getMessage())
                .validation(e.getValidation())
                .build();

        ResponseEntity<ErrorResponse> response = ResponseEntity.status(statusCode)
                .body(body);

        return response;
    }
}
.validation(e.getValidation())

- 위 코드와 같이 ErrorResponse 클래스에 예외로부터 값을 받아오게 되면 Controller에서 예외 클래스마다 코드를 작성해야 하는 번거로움이 사라진다.

 

- IslogException.java

@Getter
public abstract class IslogException extends RuntimeException {

    public final Map<String, String> validation = new HashMap<>();

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

    public IslogException(String message, Throwable cause) {
        super(message, cause);
    }

    public abstract int getStatuscode();

    public void addValidation(String fieldName, String message) {
        validation.put(fieldName, message);
    }
}

- ErrorResponse의 vallidation 필드에 값을 IslogException 필드로부터 받아오기 때문에 IslogException 클래스에 DTO처럼 사용하기 위한 validation 필드를 하나 생성해준다.

 

- InvalidRequest.java

@Getter
public class InvalidRequest extends IslogException {

    private static final String MESSAGE = "잘못된 요청입니다.";

//    public String fieldName;
//    public String message;
    // 필드를 직접적으로 설정하는 것은 좋지 않다...

    public InvalidRequest() {
        super(MESSAGE);
    }

    public InvalidRequest(String fieldName, String message) {
        super(MESSAGE);
//        this.fieldName = fieldName;
//        this.message = message;
        addValidation(fieldName, message);
    }

    @Override
    public int getStatuscode() {
        return 400;
    }
}

- InvalidRequest 클래스에 있던 기존 필드를 없애고 파라미터로 필드를 받아서 전달하는 방식으로 변경한다.

- InvalidRequest의 부모 클래스는 IslogException이기 때문에 자식 클래스에서 부모 클래스의 메서드를 사용할 수 있다.

- Controller에서 발생한 예외로 부터 넘어온 fieldName, message가 InvalidRequest를 통해 IslogException으로 넘어가고 IslogException에서 validation 필드에 해당 값들이 들어가고 해당 값은 최종적으로 ErrorResponse 필드에 주입되어 ExceptionController의 HttpResponse로 반환된다.

 

728x90