ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [스프링 부트] 예외처리(Exception) @ControllerAdvice @RestControllerAdvice @ExceptionHandler @ResponseStatus
    Spring Boot 2022. 2. 14. 14:16

    [스프링 부트] 예외처리(Exception)  

    @ControllerAdvice @RestControllerAdvice @ExceptionHandler @ResponseStatus


    안녕하세요? 장장스입니다!

    오늘은 스프링에서 '예외 처리를 어떻게 할 것인가'에 대한 포스팅을 하려고 합니다.

    코드를 작성하며 고민 되는 문제 중 하나가 어떻게 예외 처리를 해야 하는가 입니다.

     

     

    Exception In Java


    Java Exception Hierarchy

    예외 계층 구조에서 볼 수 있듯이 Java에서 발생하는 오류는 Error 클래스와 Exception 클래스로 나뉩니다. 모든 오류 및 예외는 Throwable 클래스를 상위 클래스로 가집니다. Throwable를 상속받은 하위 클래스의 인스턴스들은 JVM 또는 Java throw 문에 의해 throw됩니다.

     

    그렇다면 Error 클래스와 Exception 클래스의 차이는 무엇일까?

     

    오류 (Error)

    오류는 처리할 수 없는 심각한 오류를 나타내는 클래스입니다. 실행 중에 throw되었지만 catch되지 않은 오류에 대해 Error 또는 해당 하위 클래스에 대해 throw 절을 선언할 필요가 없습니다. 오류는 개발자가 처리 할 수 없는 경우입니다. 예를 들어, 프로그램 실행 도중 메모리 공간 부족으로 오류가 발생하는 경우 개발자는 이를 제어 할 수 없습니다. 오류 클래스의 종류로는 OutOfMemoryError,  AssertionError, StackOverflowError, IOError, NoClassDefFoundError 등이 있습니다.

     

    예외 (Exception)
    예외는 프로그램이 복구할 수 있고 응용 프로그램에서 개발자가 처리해야 하는 문제를 나타냅니다. 예외에는 두 가지 하위 유형이 있습니다.

     

    1. Checked Exception

    코드를 작성하다 보면 위 사진처럼 빨간색 밑줄이 생기는 경우를 경험한 적이 있을 것입니다. 컴파일 타임에 확인되는 이러한 예외를 Checked Exception이라고 합니다. 개발자는 이러한 예외에 대한 처리를 해야 합니다.

     

    IOException, SQLException, FileNotFoundException, ClassNotFoundException, NoSuchMethodException 등이 Checked Exception에 해당합니다.

     

     

    2.Unchecked Exception

    Checked Exception과 달리 Unchecked Exception은 컴파일 타임에 확인 할 수 없습니다. 이러한 예외는 런타임에 발생합니다. 때문에 RunTimeException이라고 부르기도 합니다.

     

    개발자는 Unchecked Exception을 발견하거나 예상된다면 해당 예외에 대한 처리를 해야 합니다.

     

    NullPointerException, ArithmeticException, IllegalArgumentException, IndexOutOfBoundException 등이 Unchecked Exception에 해당합니다.

     

     

     

     

    Unchecked Exception 처리


    그렇다면 이러한 예외를 어떻게 처리할 수 있을까요? 회원가입을 예시로 들어보겠습니다. 다음과 같은 Member Entity가 있습니다.

    import javax.persistence.*;
    
    @Entity
    public class Member {
    
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @Column(name = "member_name", unique = true)
        private String name;
    
        private int age;
    
        protected Member() {
        }
    
        public Member(String name, int age) {
            this.name = name;
            this.age = age;
        }
    }

    '장장스'라는 이름으로 회원가입을 신청했습니다. 성공했습니다!

     

    회원 가입을 요청하면 MemberService의 saveMember 메서드로 회원 가입을 시도합니다.

    @Service
    @Transactional(readOnly = true)
    public class MemberService {
        private final MemberRepository memberRepository;
    
        public MemberService(MemberRepository memberRepository) {
            this.memberRepository = memberRepository;
        }
    
        @Transactional
        public Long saveMember(MemberDto memberDto){
            duplicatedMemberByName(memberDto.getName());
            return memberRepository.save(memberDto.convertEntity());
        }
    }

     

    며칠 뒤 또 다른 사람이 '장장스'라는 이름으로 회원가입을 시도했으나 에러가 발생했습니다.

    회원 테이블을 확인해 보니 name 칼럼에 unique 제약 조건이 걸려 있습니다. 때문에 같은 이름으로 회원 가입을 시도하면 SQL관련된 Exception이 발생한 것입니다.

     

    이처럼 중복된 이름으로 가입을 하는 것은 런타임에 발생할 수 있는 예상되는 Unchecked Exception 입니다. 이처럼 예상되는 문제에 대해서는 개발자가 처리를 해야 합니다.

     

     

    Exception 클래스 생성하기


    ※ 자세한 코드는 Git을 참고해주세요 :)

     

    먼저 RuntimeException을 상속받은 새로운 DuplicatedMemberNameException 클래스를 생성합니다. 

    public class DuplicatedMemberNameException extends RuntimeException{
        public DuplicatedMemberNameException() {
            super("중복된 회원입니다");
        }
    }

     

    MemberService에 새로운 메서드를 추가합니다. findMemberByName 는 name을 기준으로 회원을 찾는 메서드 이고, DuplicatedMemberNameException 는 이름이 중복될 경우 DuplicatedMemberNameException 예외를 터뜨리는 메서드입니다.

    @Service
    @Transactional(readOnly = true)
    public class MemberService {
        
        ...생략
    
        public Optional<Member> findMemberByName(String name){
            return memberRepository.findByName(name);
        }
    
        private void duplicatedMemberByName(String name){
            if(findMemberByName(name).isPresent()){
                throw new DuplicatedMemberNameException();
            }
        }
    }

     

    이제 같은 이름으로 가입을 진행하면 SQL 관련 Exception이 아니라 DuplicatedMemberNameException 예외가 터지게 됩니다.

     

    간단한 테스트 코드를 작성해서 확인해 볼 수 있습니다!

    @SpringBootTest
    class MemberServiceTest {
    
        @Autowired
        private MemberService memberService;
    
    
        @Test
        @DisplayName("회원_중복_테스트")
        @Transactional
        void duplicatedUserTest(){
            // given
            String name = "홍길동";
            int age = 15;
            MemberDto memberDto = new MemberDto(name, age);
    
            // when
            memberService.saveMember(memberDto);
    
            // then - 홍킬동 중복 가입 시도
            DuplicatedMemberNameException e = assertThrows(DuplicatedMemberNameException.class,
                    () -> memberService.saveMember(memberDto));
    
            System.out.println("회원 중복 테스트" + e.getMessage());
        }
    }

    e.getMessage() 를 통해 DuplicatedMemberNameException에서 설정한 메시지를 확인 할 수 있게 되었네요.

     

    이렇게 예측되는 예외에 대해 새로운 코드를 작성했지만 하지만 결국 Exception이 발생하는 것은 똑같습니다. DuplicatedMemberNameException이 발생하면 문제가 되는 것은 동일하니까요. 사용자는 결국 에러 페이지를 만날 수 밖에 없습니다.

     

     

    @ControllerAdvice 사용하기


    @ExceptionHandler 어노테이션을 사용해서 컨트롤러에서 발생하는 예외를 처리 할 수 있다. 컨트롤러에서 발생하는 예외를 처리해주는 기능을 한다. @Controller, @RestController 모두 해당된다.

    @RestController
    public class MemberController {
    
    	...생략
    
        @PostMapping
        @ExceptionHandler(DuplicatedMemberNameException.class)
        public Object saveMember(@RequestBody MemberDto memberDto){
            return memberService.saveMember(memberDto);
        }
    }

     

    하지만 모든 컨트롤러마다 @ExceptionHandler 을 선언한다면 관리가 복잡해지게 된다. @ControllerAdvice 는 @Controller, @RestController가 적용된 Bean내에서 발생하는 예외를 처리할 수 있게 한다.

     

    다음은 @RestController 를 처리하기 위해 작성한 ExceptionLabRestControllerAdvice 클래스이다.

    @RestControllerAdvice
    public class ExceptionLabRestControllerAdvice {
    
        Logger logger = LoggerFactory.getLogger(ExceptionLabRestControllerAdvice.class);
    
    	..생략
    
        @ExceptionHandler(DuplicatedMemberNameException.class)
        @ResponseStatus(HttpStatus.CONFLICT)
        public String duplicatedMemberNameExceptionHandler(DuplicatedMemberNameException e){
            logger.error("DuplicatedMemberNameException: {}", e.getMessage());
            return e.getMessage();
        }
    }

     

    @RestControllerAdvice 을 통해 예외 처리의 타겟을 어노테이션으로 할지, 패키지나 클래스로 설정할 수 있다.

    @RestControllerAdvice(annotations = RestController.class)
    @RestControllerAdvice( basePackages = {"com.example.package1","com.example.package2"})
    @RestControllerAdvice( basePackageClasses = MemberController.class)

     


    @ExceptionHandler 은 어떤 Exception을 처리할지 설정 할 수 있다. 만약 value값이 없다면(Exception 클래스를 지정하지 않는경우) 메서드의 입력 파라미터의 Exception을 처리한다.

     

    추가로, 다음과 같이 여러개의 예외를 처리할 수 있습니다.

      @ExceptionHandler({Exception1.class, Exception2.class})

     

     

    @ResponseStatus 은 응답 Http 코드를 선언할 수 있다. 스프링에서 HttpStatus enum 클래스를 정의하고 있다. http 코드별 내용은 여기에서 확인 할 수 있습니다.

     

     

    PostMan 을 통해 확인하기


    첫 회원 가입 요청은 당연히 성공할 겁니다. 회원번호(PK)를 반환한 것을 확인 할 수 있습니다.

     

    두 번째 요청은 회원 가입이 되지 않고, DuplicatedMemberNameException 클래스에 선언한 텍스트가 반환됨을 확인 할 수 있다. 서버에서 log도 정상적으로 출력 되는 것을 확인 할 수 있습니다.

     

    ※ 자세한 코드는 Git을 참고해주세요 :)

     

    Post


    References


     

     


    잘못된 코드나 내용이 있다면 댓글을 남겨주세요. 즉시 수정하도록 하겠습니다! :)

     

     

    댓글