삽질기록 #8 Optional, 스프링 예외처리

    (2022.03.15)

    스프링 == 김영한님 강의인 것처럼 많은 사람들이 영한님 강의를 보고 스프링 프로젝트를 만든다. 물론 나도 그렇다. 스프링 입문 강의를 열심히 따라가면서 이렇게 하는 거구나 하고 슥슥 만들다 보면, "이거 이렇게 짰어요?" 하는 리뷰어들의 질문이 들어온다. 짧게 심호흡 한 번 하고 진짜 공부는 이 때부터 시작.

     

    의미없는 옵셔널 + 리턴 널 + 주석에 TODO를 쓰는 삼신기... 리뷰어님 감사합니다

     

    그니까 Optional 왜 쓰는 거예요

    특정 ID 값에 해당하는 유저를 찾는다고 해 보자. 사실 지금 내가 만들고 있는 건 아주 기본적인 어플리케이션이라서 Repository에 findById()가 있고 Service에도 아이디로 유저를 찾는 메서드가 있다. 기본적으로 Controller에서 Service의 findById()를 호출하면 내부적으로는 Repository의 findById()를 호출해서 가져오는 방식이다. 근데 그냥 영한님이 Repository에서부터 반환값을 Optional<User>로 주시길래 나도 그렇게 만들었다. Optional로 주면 널을 조금 더 수월하게 다룰 수 있다고 한다. 근데 별로 Optional을 활용하는 것 없이 UserRepository도 Optional을 반환하고, 걔를 받는 UserService도 Optional을 반환하고... 결국 Userservice에서 get() 메서드를 통해서 옵셔널 안에 있는 값을 꺼내주도록 짰다.

     

    public class ArticleController {
    	@GetMapping("/{articleId}") // articleId를 PathVariable로 받아서 이 값에 맞는 아티클을 가져온다.
        public String viewArticle(@PathVariable("articleId") int articleId, Model model) {
            Article selectedArticle = articleService.findById(articleId).get();
            model.addAttribute("article", selectedArticle);
            return "/qna/show";
        }
    }
    
    public class ArticleService {
    	public Optional<Article> findById(int id) {
            return repository.findById(id); // repositry는 Repository 인스턴스
    }
    
    public class ArticleRepository {
    	public Optional<Article> findByid(int id) {
            Article foundArticle = null;
            if (id <= idSequence) {
                foundArticle = articles.get(id);
            }
            return Optional.ofNullable(foundArticle);
        }
    }

    이렇게 짜면 진짜 Optional이 별 의미가 없다. 그래서 리뷰를 받고 생각을 좀 해 봤다. 옵셔널은 왜 쓰는 걸까? 대부분 NullPointerException을 막기 위해서 쓴다고 이야기하는데, 그냥 생각하기에는 Exception이 나나 안 나나 차이지, 해당 객체가 null인지 체크하는 거나 해당 Optional에 값이 있는지 없는지 체크하는 거나 짜는 입장에서는 매한가지 아닌가? 싶었다. 기본적으로 빈 값을 참조하면 안 돌아가는 건 똑같잖아. 애초에 그걸 안 참조하도록 짜는 데 들어가는 프로그래머의 공수는 똑같지 않나? 하는 의문이었다.

     

    그래서 여기저기서 자료를 읽어 보니 'Optional 쓰면 NPE가 안 난다'가 포인트라기 보다는, Optional은 일단 널을 객체로 한 번 감싼 후에 여러 메서드를 이용해서 널을 '관리'하는 걸 더 직관적으로 만들어주는 기능이라고 이해하는 게 더 맞는 것 같다는 생각이 들었다. 예전에는 if OOO == null 해서 복잡하게 조건문으로 분기하고 해줘야 했던 널 관련 로직을 orElseGet()처럼 Optional이 제공하는 메서드를 쓰면, 해당 객체에 값이 없을 경우에는 다른 객체를 새로 만들어서 리턴해준다든지 하는 '관리'가 용이해진다. 즉, Optional을 잘 쓴다는 건, 이런 식으로 Null이 나는 상황을 더 유연하게 대처할 수 있느냐 하는 지점인 것 같다.

     

    옵셔널에 값 없으면 예외를 던지자!

    근데 지금 내 상황은 id에 맞는 게시글 뿌려주는 로직이라서, 해당하는 객체가 없다고 어디서 이상한 글을 새로 만들어서 뿌려줄 수는 없다. 그래서 대신 Service가 Repository에서 받은 Optional 안에 값이 있으면 Controller에게 객체로 넘겨주고, 값이 없으면 예외를 던져주는 데 써먹기로 했다. Optional에는 이럴 때 쓸 수 있는 orElseThrow()라는 좋은 메서드가 있다.

     

    // 컨트롤러
    @GetMapping("/{articleId}")
        public String viewArticle(@PathVariable("articleId") int articleId, Model model) {
            Article selectedArticle;
            try {
                selectedArticle = articleService.findById(articleId);
            } catch (IllegalStateException e) {
                return "redirect:/";
            }
            model.addAttribute("article", selectedArticle);
            return "/qna/show";
        }
        
    // 서비스
    public User findById(String id) throws IllegalStateException {
            return userRepository.findById(id).orElseThrow(()-> new IllegalStateException("유저를 찾을 수 없습니다."));
        }

    여기까지 하고 "뭔가 try-catch 쓰는 게 맘에 안 드는데...? 하고 그룹리뷰 시간에 이야기를 하니까, 역시 감사하게도 그룹원 선생님들께서 try-catch 안 써도 스프링이 이제 예외처리 다 해준다는 이야기를 해 주었다. 

     

    @ExceptionHandler와 @ControllerAdvice: 스프링이 다 해줍니다

    @ExceptionHandler 어노테이션을 붙이면 해당 Bean에서 발생하는 예외를 메서드로 분리해서 일괄적으로 처리해준다.

    // 컨트롤러
    @ExceptionHandler(IllegalStateException.class)
        public Object illegalStateHandler(Exception e) {
            System.err.println(e.getMessage());
            return "redirect:/";
        }
        
    @GetMapping("/{articleId}")
        public String viewArticle(@PathVariable("articleId") int articleId, Model model) {
            Article selectedArticle = articleService.findById(articleId); // findById()가 예외를 던질 가능성이 있다.
            model.addAttribute("article", selectedArticle);
            return "/qna/show";
        }

    이렇게만 해 놓으면 빈 안에서 @ExceptionHandler에 매개변수로 들어간 예외가 터지면, 따로 예외를 처리해주지 않아도 해당 코드를 자동으로 실행해준다. 이걸 써보고 진짜... 스프링 안 되는 게 없네... 하면서 감탄했다. try-catch는 일단 인덴트부터 지저분하니 굉장히 맘에 안 들게 생겼잖아요. 근데 그런 걸 아예 쓸 필요가 없다! 예외가 나는 지점마다 체크해줄 필요 없이 동일한 처리를 해줄 수 있다는 점에서 예외 가능성이 있는 곳이 여러 군데면 코드 중복을 많이 줄일 수 있다는 장점도 있다.

     

    @ExceptionHandler가 특정 컨트롤러 하나에서 터지는 예외를 관리한다면, @ControllerAdvice를 사용하면 모든 컨트롤러에서 발생하는 예외를 전역적으로 일괄 처리할 수 있다. 지금 단계에서는 article을 못 찾았을 때와 user를 못 찾았을 때의 리다이렉트 경로가 달라서 사용하지 않았는데, 추후 오류 페이지 리턴할 때는 @ControllerAdvice를 써서 그냥 다 일괄 관리하는 것도 좋은 방법일 것 같다. 자료를 찾아보니 추후에는  @ExceptionHandler와 함께 @ResponseStatus를 써서 status code도 지정해주는 식의 디테일도 보강할 필요가 있어 보인다.

     

    아무튼 스프링 짱 까도까도 양파같은 스프링


    참고자료

    - Optional이란? Optinal 개념 및 사용법

    - Java Optional 바르게 쓰기

    - @ControllerAdvice, @ExceptionHandler를 이용한 예외처리 분리, 통합하기

    댓글