코드스테이츠 BE 44일차 - Section 3 - Spring MVC - 예외처리

coding infant·2022년 8월 24일

코드스테이츠BE

목록 보기
41/48

[Mapper 사용 시 우선 순위]

@Builder 패턴이 최우선

@AllArgsConstructor (@NoArgsConstructor이면 매핑이 정상적으로 안됨(주의!))

@Setter 가장 마지막

[학습 목표]

@ExceptionHandler 애너테이션을 사용해서 예외를 처리할 수 있다.

@RestControllerAdvice 애너테이션을 사용해서 예외를 처리할 수 있다.

예외 발생 시, 클라이언트 쪽에 적절한 예외 메시지를 제공해 줄 수 있다.

[예외 처리]

@ExceptionHandler을 이용한 예외 처리 : 클라이언트에게 전송하는 불편한 에러 메세지의 개선. 최대한 친절한 메세지를 전송하자

@RestControllerAdvice 애너테이션 사용한 공통 예외 처리 : Error 전용 Response의 할 일이 많아진다. Request body의 유효성 검증시 발생한 예외 처리.요청 URI 유효성 검증시 발생한 예외처리

[예외 종류]

클라이언트 요청 데이터에 대한 유효성 검증에서 발생하는 예외

서비스 계층의 비지니스 로직에서 던져지는 의도된 예외

웹 애플리케이션 실행 중에 발생하는 예외

[@ExceptionHandler 이용한 예외 처리]

DTO 유효성 검증 실패시 응답 데이터 : 400 Bad Request. 이것만으로는 어떤 항목이 문제 있는 지 알 수 없음 -> 에러메세지를 바꿔줘야!


package com.codestates.member.controller;

import com.codestates.member.dto.MemberPatchDto;
import com.codestates.member.dto.MemberPostDto;
import com.codestates.member.dto.MemberResponseDto;
import com.codestates.member.entity.Member;
import com.codestates.member.mapper.MemberMapper;
import com.codestates.member.service.MemberService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;


import javax.validation.Valid;
import javax.validation.constraints.Positive;
import java.util.List;
import java.util.stream.Collectors;


/**
 * - DI 적용
 * - Mapstruct Mapper 적용
 */
@RestController
@RequestMapping("/v6/members")
@Validated
@Slf4j
public class MemberController {
    private final MemberService memberService;
    private final MemberMapper mapper;

    public MemberController(MemberService memberService, MemberMapper mapper) {
        this.memberService = memberService;
        this.mapper = mapper;
    }

    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
        Member member = mapper.memberPostDtoToMember(memberDto);

        Member response = memberService.createMember(member);

        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
                HttpStatus.CREATED);
    }

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(
            @PathVariable("member-id") @Positive long memberId,
            @Valid @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);

        Member response =
                memberService.updateMember(mapper.memberPatchDtoToMember(memberPatchDto));

        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
                HttpStatus.OK);
    }

    @GetMapping("/{member-id}")
    public ResponseEntity getMember(
            @PathVariable("member-id") @Positive long memberId) {
        Member response = memberService.findMember(memberId);
        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response), HttpStatus.OK);
    }

    @GetMapping
    public ResponseEntity getMembers() {
        List<Member> members = memberService.findMembers();
        List<MemberResponseDto> response = mapper.membersToMemberResponseDtos(members);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(
            @PathVariable("member-id") @Positive long memberId) {
        System.out.println("# delete member");
        memberService.deleteMember(memberId);

        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
    
    @ExceptionHandler   // @ExceptionaHandler 애너테이션 이용해서 예외 처리하도록 handleException() 메서드 추가
    public ResponseEntity handleException(MethodArgumentNotValidException e) {
        // (1)
        final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        // (2)
        return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
    }
}

[Request Body 유효성 검증 실패시 예외 처리 과정]

클라이언트에서 MemberController의 postMember() 핸들러 메서드에 요청 전송

RequestBody에 유효하지 않은 요청 데이터 포함되어 유효성 검증 실패, MethodArgumentNotValidException 발생

MemberController에는 @ExceptionHandler 애너테이션이 추가된 handleException()이 있기 떄문에 유효성 검증 과정에서 내부적으로 던져진 MethodArgumentNotValidException을 handleException() 메서드가 전달 받음

(1)과 같이 MethodArguementNotValidException 객체에서 getBindingResult().getFieldErrors() 통해 발생한 에러 정보 확인

(1) 에서 얻은 에러 정보를 (2)에서 ResponseEntity 통해 Response Body로 전달

MemberController의 handleException() 메서드에서 에러 메세지를 구체적으로 전송해주기 때문에 어느 곳에 문제 있는지 알 수 있음

필요한 에러 메세지만 받아서 전달 위해 Error Response 클래스 생성

[Error Response 클래스 생성]


package com.codestates.response;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.validation.FieldError;

import java.util.List;

@Getter
@AllArgsConstructor

public class ErrorResponse {
    private List<FieldError> fieldErrors;

    @Getter
    @AllArgsConstructor
    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String reason;
    }
}

JSON 응답 객체 : 배열() - 검증해야 하는 멤버 변수 중에서 유효성 검증에 실패하는 멤버 변수가 하나 이상. 유효성 검증 실패 에러 역시 하나 이상.

[ErrorResponse 사용하도록 MemberController의 handleException() 메서드 수정]


package com.codestates.member.controller;

import com.codestates.ErrorResponse;
import com.codestates.member.dto.MemberPatchDto;
import com.codestates.member.dto.MemberPostDto;
import com.codestates.member.dto.MemberResponseDto;
import com.codestates.member.entity.Member;
import com.codestates.member.mapper.MemberMapper;
import com.codestates.member.service.MemberService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;


import javax.validation.Valid;
import javax.validation.constraints.Positive;
import java.util.List;
import java.util.stream.Collectors;


/**
 * - DI 적용
 * - Mapstruct Mapper 적용
 */
@RestController
@RequestMapping("/v6/members")
@Validated
@Slf4j
public class MemberController {
    private final MemberService memberService;
    private final MemberMapper mapper;

    public MemberController(MemberService memberService, MemberMapper mapper) {
        this.memberService = memberService;
        this.mapper = mapper;
    }

    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
        Member member = mapper.memberPostDtoToMember(memberDto);

        Member response = memberService.createMember(member);

        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
                HttpStatus.CREATED);
    }

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(
            @PathVariable("member-id") @Positive long memberId,
            @Valid @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);

        Member response =
                memberService.updateMember(mapper.memberPatchDtoToMember(memberPatchDto));

        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
                HttpStatus.OK);
    }

    @GetMapping("/{member-id}")
    public ResponseEntity getMember(
            @PathVariable("member-id") @Positive long memberId) {
        Member response = memberService.findMember(memberId);
        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response), HttpStatus.OK);
    }

    @GetMapping
    public ResponseEntity getMembers() {
        List<Member> members = memberService.findMembers();
        List<MemberResponseDto> response = mapper.membersToMemberResponseDtos(members);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(
            @PathVariable("member-id") @Positive long memberId) {
        System.out.println("# delete member");
        memberService.deleteMember(memberId);

        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }

    @ExceptionHandler   // @ExceptionaHandler 애너테이션 이용해서 예외 처리하도록 handleException() 메서드 추가
    public ResponseEntity handleException(MethodArgumentNotValidException e) {
        // (1)
        final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        // (2)
        List<ErrorResponse.FieldError> errors = 
                fieldErrors.stream()
                        .map(error -> new ErrorResponse.FieldError(
                                error.getField(),
                                error.getRejectedValue(),
                                error.getDefaultMessage()))
                        .collect(Collectors.toList());
                        
        return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
    }
}

(1)의 List를 통째로 ResponseEntity 클래스에 실어서 전달

(2)에서는 필요한 정보들만 선택적으로 골라서 ErrorResponse.FieldError 클래스에 담아서 List로 변환 후 List<ErrorResponse.FieldError>를 ResponseEntity 클래스에 전달 중

[@ExceptionHandler의 단점]

각 Controller 클래스마다 코드 중복 발생

하나의 Controller 클래스 내에서 @ExceptionHandler 추가한 에러 처리 핸들러 메서드 늘어남

package com.codestates.member.controller;

import com.codestates.ErrorResponse;
import com.codestates.member.dto.MemberPatchDto;
import com.codestates.member.dto.MemberPostDto;
import com.codestates.member.dto.MemberResponseDto;
import com.codestates.member.entity.Member;
import com.codestates.member.mapper.MemberMapper;
import com.codestates.member.service.MemberService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;


import javax.validation.ConstraintViolationException;
import javax.validation.Valid;
import javax.validation.constraints.Positive;
import java.util.List;
import java.util.stream.Collectors;


/**
 * - DI 적용
 * - Mapstruct Mapper 적용
 */
@RestController
@RequestMapping("/v6/members")
@Validated
@Slf4j
public class MemberController {
    private final MemberService memberService;
    private final MemberMapper mapper;

    public MemberController(MemberService memberService, MemberMapper mapper) {
        this.memberService = memberService;
        this.mapper = mapper;
    }

    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
        Member member = mapper.memberPostDtoToMember(memberDto);

        Member response = memberService.createMember(member);

        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
                HttpStatus.CREATED);
    }

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(
            @PathVariable("member-id") @Positive long memberId,
            @Valid @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);

        Member response =
                memberService.updateMember(mapper.memberPatchDtoToMember(memberPatchDto));

        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
                HttpStatus.OK);
    }

    @GetMapping("/{member-id}")
    public ResponseEntity getMember(
            @PathVariable("member-id") @Positive long memberId) {
        Member response = memberService.findMember(memberId);
        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response), HttpStatus.OK);
    }

    @GetMapping
    public ResponseEntity getMembers() {
        List<Member> members = memberService.findMembers();
        List<MemberResponseDto> response = mapper.membersToMemberResponseDtos(members);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(
            @PathVariable("member-id") @Positive long memberId) {
        System.out.println("# delete member");
        memberService.deleteMember(memberId);

        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }

    @ExceptionHandler   // @ExceptionaHandler 애너테이션 이용해서 예외 처리하도록 handleException() 메서드 추가
    public ResponseEntity handleException(MethodArgumentNotValidException e) {
        // (1)
        final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        // (2)
        List<ErrorResponse.FieldError> errors =
                fieldErrors.stream()
                        .map(error -> new ErrorResponse.FieldError(
                                error.getField(),
                                error.getRejectedValue(),
                                error.getDefaultMessage()))
                        .collect(Collectors.toList());

        return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
    }
    
    @ExceptionHandler
    public ResponseEntity handleException(ConstraintViolationException e) {
        /*
        - ConstraintViolationException 클래스는 
        getBindingResult().getFieldErrors()와 같이 에러 정보를 얻을 수 없다
        - MethodArgumentNotValidException과 다르게 또 다른 방식으로 처리 필요
         */
    }
}

patchMember() 핸들러 메서드의 URI 변수인 "/{member-id}" 에 0이 넘어 올 경우 ConstraintViolationException이 발생.

COnstraintViolationException을 처리할 @ExceptionHandler를 추가한 메서드를 하나 더 추가 -> 예외 처리 개선 필요

[핵심 포인트]

Controller 클래스 레벨에서 @ExceptionHandler 애너테이션을 사용하면 해당 Controller에서 발생하는 예외를 처리할 수 있다.

필요한 Error 정보만 담을 수 있는 Error 전용 Response 객체를 사용하면 클라이언트에게 조금 더 친절한 에러 정보를 제공할 수 있다.

@ExceptionHandler 애너테이션 방식은 Controller마다 동일하게 발생하는 예외 처리에 대한 중복 코드가 발생할 수 있다.

@ExceptionHandler 애너테이션 방식은 다양한 유형의 예외를 처리하기에는 적절하지 않은 방식이다.

[@ExceptionHandler를 이용한 Controller 레벨에서의 예외처리]

[MemberController 클래스에서 @ExceptionHandelr 로직 제거]

각 Controller 클래스에서 발생하는 예외를 GlobalExceptionAdvice 클래스에서 공통으로 처리할 예정.

MemberController에 구현된 @ExceptionHandler가 추가된 메서드는 모두 제거

[ExceptionAdvice 클래스 정의]


package com.codestates.advice;

import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionAdvice {

}

[Exception 핸들러 메서드 구현]


papackage com.codestates.advice;

import com.codestates.response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.validation.ConstraintViolationException;
import java.util.List;
import java.util.stream.Collectors;


@RestControllerAdvice
public class GlobalExceptionAdvice {
    // (1)
    @ExceptionHandler
    public ResponseEntity handleMethodArgumentNotValidException(
            MethodArgumentNotValidException e) {
        final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();

        List<ErrorResponse.FieldError> errors =
                fieldErrors.stream()
                        .map(error -> new ErrorResponse.FieldError(
                                error.getField(),
                                error.getRejectedValue(),
                                error.getDefaultMessage()))
                        .collect(Collectors.toList());

        return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
    }

    // (2)
    @ExceptionHandler
    public ResponseEntity handleConstraintViolationException(
            ConstraintViolationException e) {
        // TODO should implement for validation

        return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
    }
}

(1), (2) 모두 MemberController에 추가했던 Exception 핸들러 메서드의 로직 그대로 추가

[ErrorResponse 수정]


package com.codestates.response

import lombok.Getter;
import org.springframework.validation.BindingResult;

import javax.validation.ConstraintViolation;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;


@Getter
public class ErrorResponse {
    private List<FieldError> fieldErrors; // (1)
    private List<ConstraintViolationError> violationErrors;  // (2)

    // (3)
    private ErrorResponse(final List<FieldError> fieldErrors,
                          final List<ConstraintViolationError> violationErrors) {
        this.fieldErrors = fieldErrors;
        this.violationErrors = violationErrors;
    }

    // (4) BindingResult에 대한 ErrorResponse 객체 생성
    public static ErrorResponse of(BindingResult bindingResult) {
        return new ErrorResponse(FieldError.of(bindingResult), null);
    }

    // (5) Set<ConstraintViolation<?>> 객체에 대한 ErrorResponse 객체 생성
    public static ErrorResponse of(Set<ConstraintViolation<?>> violations) {
        return new ErrorResponse(null, ConstraintViolationError.of(violations));
    }

    // (6) Field Error 가공
    @Getter
    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String reason;

        private FieldError(String field, Object rejectedValue, String reason) {
            this.field = field;
            this.rejectedValue = rejectedValue;
            this.reason = reason;
        }

        public static List<FieldError> of(BindingResult bindingResult) {
            final List<org.springframework.validation.FieldError> fieldErrors =
                    bindingResult.getFieldErrors();
            return fieldErrors.stream()
                    .map(error -> new FieldError(
                            error.getField(),
                            error.getRejectedValue() == null ?
                                    "" : error.getRejectedValue().toString(),
                            error.getDefaultMessage()))
                    .collect(Collectors.toList());
        }
    }

    // (7) ConstraintViolation Error 가공
    @Getter
    public static class ConstraintViolationError {
        private String propertyPath;
        private Object rejectedValue;
        private String reason;

        private ConstraintViolationError(String propertyPath, Object rejectedValue,
                                         String reason) {
            this.propertyPath = propertyPath;
            this.rejectedValue = rejectedValue;
            this.reason = reason;
        }

        public static List<ConstraintViolationError> of(
                Set<ConstraintViolation<?>> constraintViolations) {
            return constraintViolations.stream()
                    .map(constraintViolation -> new ConstraintViolationError(
                            constraintViolation.getPropertyPath().toString(),
                            constraintViolation.getInvalidValue().toString(),
                            constraintViolation.getMessage()
                    )).collect(Collectors.toList());
        }
    }
}

(1)은 MethodArgumentNotValidException으로 발생하는 에러 정보를 담는 멤버 변수.

DTO 멤버 변수 필드의 유효성 검증 실패로 발생한 에러 정보를 담는 멤버 변수

(2)는 ConstraintViolationException으로부터 발생하는 에러 정보를 담는 멤버 변수.

URI 변수 값의 유효성 검증에 실패한 에러 정보를 담는 멤버 변수

(3)은 ErrorResponse 클래스의 생성자. 생성자 앞에 private 접근 제한자(Access Modifier) 지정

new ErrorResponse(-,-) 방식으로 객체 생성 불가.

대신 (4)와 (5)처럼 of() 메서드 사용하여 ErrorResponse 객체 생성 가능

-> ErrorResponse 객체 생성함과 동시에 ErrorResponse 역할을 명확하게 해줌

(4)는 MethodArgumentNotValidException에 대한 ErrorResponse 객체 생성.

MethodArgumentNotValidException에서 에러 정보를 얻기 위해 필요한 BindingResult 객체를 of() 메서드를 호출하는 쪽에서 BindingResult 객체를 파라미터로 넘겨줌. ErrorResponse 클래스의 static 멤버 클래스인 FieldError 클래스에 위임중

(5)는 ConstraintViolationException에 대한 ErrorResponse 객체 생성

ConstraintViolationException에서 에러 정보 얻기 위해 Set<ConstraintViolation<?>> 객체 필요.

of() 메서드를 호출하는 쪽에서 Set<ConstraintViolation<?>> 객체를 파라미터로 넘겨줌

Set<ConstraintViolation<?>> 객체 가지고 에러 정보 추출하고 가공한느 일은 ErrorResponse 클래스의 static 멤버 클래스인 ContstraintViolationError 클래스에게 위임

(6) 필드(DTO 클래스의 멤버 변수)의 유효성 검증에서 발생하는 에러 정보를 생성

(7) URI 변수 값에 대한 에러 정보를 생성

에러 유형에 대한 에러 정보 생성 역할을 분리함으로서 ErrorResponse 사용하기 더 편해짐

of() 메서드 : 네이밍 컨벤션(Naming Convention). 어떤 값의 객체를 생성한다

[Exception 핸들러 메서드 수정]

papackage com.codestates.advice;

import com.codestates.response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolationException;

import java.util.List;
import java.util.stream.Collectors;


@RestControllerAdvice
public class GlobalExceptionAdvice {
    // (1)
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity handleMethodArgumentNotValidException(
            MethodArgumentNotValidException e) {
        final ErrorResponse response = ErrorResponse.of(e.getBindingResult());
        return response;
    }
//        final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
//
//        List<ErrorResponse.FieldError> errors =
//                fieldErrors.stream()
//                        .map(error -> new ErrorResponse.FieldError(
//                                error.getField(),
//                                error.getRejectedValue(),
//                                error.getDefaultMessage()))
//                        .collect(Collectors.toList());
//
//        return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
//    }

    // (2)
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleConstraintViolationException(
            ConstraintViolationException e) {
        final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());
        return response;
    }
}

@ResponseStatus 애너테이션 사용해서 HTTP Status를 HTTP Response에 포함중

[@RestControllerAdvice vs @ControllerAdvice]

@RestControllerAdvice = @ControllerAdvice + @ResponseBody

[핵심 포인트]

@RestControllerAdvice 애너테이션을 추가한 클래스를 이용하면 예외 처리를 공통화 할 수 있다.

@RestControllerAdvice 애너테이션을 사용하면 JSON 형식의 데이터를 Response Body로 전송하기 위해 ResponseEntity로 래핑할 필요가 없다.

@ResponseStatus 애너테이션으로 HTTP Status를 대신 표현할 수 있다.

0개의 댓글