semtax의 개발 일지

스프링부트로 게시판 만들기 3 : 스프링 예외처리 본문

개발/Java

스프링부트로 게시판 만들기 3 : 스프링 예외처리

semtax 2020. 1. 23. 20:12
반응형

개요

 

이번 포스팅에서는 스프링 부트를 이용해서 게시판을 작성하면서 발생하는 예외들을 처리하는 법에 대해서 학습을 하도록 하겠다.

 

 

 

입력값 검증 및 예외 처리

 

소프트웨어를 개발하면서 피할 수 없는 것들이 3가지가 있다. 바로 소프트웨어 버그, 입력값 검증, 그리고 장애 대응이다.

 

어떠한 프로그램이나 서비스를 외부에 공개한다는 것은, 전장터 한복판에 던져지는 것과 매우 흡사하다.

 

엄청나게 많은 트래픽, 수많은 사용자들의 이상한 값 들(id 필드에다 부동소수점이나 특수문자들을 넣는 거라던가..), 엄청나게 쌓이는 파일로 인한 용량초과, 수 많은 데이터베이스 커넥션 생성으로인한 DB커넥션 에러, 엄청나게 많은 입력값들과 그에 따른 버그, 예외사항들이 발생하게 된다.

 

따라서, 저러한 수 많은 예외들을 처리하지 않으면 그로 인해, 시스템이 다운되거나 비정상적으로 동작을 하게 되고 이는 시스템의 불안정성으로 이어져 결국 사람들이 서비스를 이용하지 못하고 떠나게 된다.

 

따라서 이러한 입력값 검증, 예외처리가 매우 중요한 역할이라는 것을 알 수 있다.

 

그렇다면 예외 처리를 어떻게 할것인가?

 

일단 가장 단순하게 떠올릴 수 있는 방법은 아래와 같은 방법이다.

 

package com.semtax.application.controller;

import com.semtax.application.entity.Post;
import com.semtax.application.repository.PostRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

@RestController
public class PostController {

    @Autowired
    PostRepository postRepository;

    @GetMapping("/")
    public String hello(){
        return "Main Page!";
    }


    @GetMapping("/post")
    public List<Post> getAllPost(){
        return postRepository.findAll();
    }


    @GetMapping("/post/{id}")
    public Post getPost(@PathVariable String id){

        try {
            Long postID = Long.parseLong(id);

            Optional<Post> post = postRepository.findById(postID);

            if (post.isPresent()) {
                return post.get();
            } else {
                return null;
            }
        }catch(Exception e){
            return null;
        }
    }


    @PostMapping("/post/{id}")
    public Post updatePost(@PathVariable String id, @RequestBody Post newPost){
        try {
            Long postID = Long.parseLong(id);
            Optional<Post> post = postRepository.findById(postID);

            if(post.isPresent()){
                post.get().setTitle(newPost.getTitle());
                post.get().setContent(newPost.getContent());
                postRepository.save(post.get());
                return post.get();
            }else{
                return null;
            }
        }catch(Exception e){
            return null;
        }
    }


    @PutMapping("/post")
    public Post createPost(@RequestBody Post post){
        Post newPost = postRepository.save(post);

        return newPost;
    }


    @DeleteMapping("/post/{id}")
    public String deletePost(@PathVariable String id){

        try {
            Long postID = Long.parseLong(id);
            postRepository.deleteById(postID);
        }catch(Exception e){
            return "Delete Failed!";
        }

        return "Delete Success!";
    }
}

 

 

하지만, 이런방식으로 처리하니 뭔가 코드가 지저분하다. 게다가 예외 처리 로직과 비즈니스 로직이 섞여서 알아보기도 힘들다.

 

또한, 나중에 어떤에러가 추가적으로 발생하면 그에 따른 로직을 추가해야한다. 결국 예외처리 때문에 아래와 같은 코드가 자의반 타의 반으로 작성되게 된다.

 

 

double val;

try { val = calc1(); }
catch (Calc1Exception e1)
{ 
    try { val = calc2(); }
    catch (Calc2Exception e2)
    {
        try { val = calc3(); }
        catch (Calc3Exception e3)
        {
            throw new NoCalcsWorkedException();
        }
    }
}

 

 

이러한 코드 때문에, 결국 유지보수와 기능추가가 힘들어지고 약간의 코드를 수정하는데에도 엄청나게 긴 시간을 잡아먹게 된다.

 

더 문제인것은, 실질적으로 try-catch를 선언한 부분에서 예외를 완벽하게 처리할 방법이 없는경우도 생긴다는 것이다.

 

이쯤되면 무언가 다른 방법이 필요하다는 것을 느끼게 된다.

 

이러한 문제를 해결할 방법은 과연 없는걸까?

 

 

예외 처리의 책임 분리

 

결국 이러한 문제가 발생하는 이유는, 에러 처리 책임의 대부분을 호출되는 모듈이 가지고 있는게 아닌 ,

모듈을 호출하는 사람이 가지고 있다는 것이다. 즉, 호출 되는 모듈에서 에러 처리의 책임을 분리해야 한다.

 

다시 말하면 에러 처리의 책임을 호출한 사람에게 떠넘겨야 한다는 것이다. 이러한 개념을 보통 위임(Delegation) 이라고 한다.

다행히도 자바에는 throw 라는 키워드를 이용해서 예외 처리의 책임을 떠넘길 수 있다.

 

하지만 이러한 예외를 처리할때에도 주의 사항이 있다.

일단, 주의사항을 먼저 언급하기 전에 잠시 예외의 종류부터 잠시 언급하도록 하겠다.

 

 

예외 종류 설명
Checked Exception 예외를 처리 할 수 있는 경우로, 다른 핸들러에서 예외 처리를 제대로 할 수 있게 해당 에러에 걸맞는 예외로 변환해서 예외를 상위 핸들러에 던져 준다. 이러한 방식을 보통 예외 전환(Converting exception)이라고 한다.
Unchecked Exception 예외 처리가 불가능한 경우(Runtime Exception) 이때는, 보통 로그를 남기고 죽는 선택지를 많이 고른다.

 


위와 표와 같이 처리가 가능한 예외는
해당 에러에 걸맞는 예외로 변환해서 던지거나(Checked Exception) 예외 처리가 불가능한 경우에는 로그를 남기고 죽는다(Unchecked Exception)

 

보통 위와 같이 처리할수 없을때 까지 다음 사람이 처리해주도록 계속 예외를 떠넘기다가, 결국 예외를 처리하지 못하는 경우(즉 RuntimeException이 발생하는 경우) 로그를 남기고 프로그램을 종료시키는 패턴을 사용한다.

 

이러한 패턴을 보고 연쇄 책임 패턴(chain of responsibility) 라고 한다. (사실 chain of responsibility는 예외 처리 할때 에만 쓰는 패턴은 아니다. 하지만 주로 사용하는 예시 중 하나가 바로 이러한 예외처리이다.)

 

스프링 프레임워크와 스프링 부트에서는 이러한 예외처리를 간편하게 해줄 몇가지 매커니즘을 제공하고 있다. 이제 아래 예시에서 해당 매커니즘을 사용해서 예외를 처리하는법을 알아보도록 하자.

 

 

예시

 

먼저 아래와 같이 컨트롤러 코드에 예외코드를 추가해서 작성해보도록 하자.

 

package com.semtax.application.controller;

import com.semtax.application.entity.Post;
import com.semtax.application.repository.PostRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;

@RestController
public class PostController {

    @Autowired
    PostRepository postRepository;

    @GetMapping("/")
    public String hello(){
        return "Main Page!";
    }


    @GetMapping("/post")
    public List<Post> getAllPost(){
        return postRepository.findAll();
    }


    @GetMapping("/post/{id}")
    public Post getPost(@PathVariable String id){
        Long postID = Long.parseLong(id);

        Optional<Post> post = postRepository.findById(postID);

        return post.get();
    }


    @PostMapping("/post/{id}")
    public Post updatePost(@PathVariable String id, @RequestBody Post newPost){
        Long postID = Long.parseLong(id);

        Optional<Post> post = postRepository.findById(postID);

        post.get().setTitle(newPost.getTitle());
        post.get().setContent(newPost.getContent());

        postRepository.save(post.get());

        return post.get();
    }


    @PutMapping("/post")
    public Post createPost(@RequestBody Post post){
        Post newPost = postRepository.save(post);

        return newPost;
    }


    @DeleteMapping("/post/{id}")
    public String deletePost(@PathVariable String id){
        Long postID = Long.parseLong(id);
        postRepository.deleteById(postID);

        return "Delete Success!";
    }

    @ExceptionHandler(NoSuchElementException.class)
    public @ResponseBody NoItemError noItemErrorHandler(NoSuchElementException e){
        NoItemError noItem = new NoItemError();
        noItem.setMessage("Item Not Exists!");
        return noItem;
    }

    @ExceptionHandler(NumberFormatException.class)
    public @ResponseBody PathFormatError noItemErrorHandler(NumberFormatException e){
        PathFormatError formatError = new PathFormatError();
        formatError.setMessage("Invalid Path Variable!");
        return formatError;
    }
}

 

스프링 프레임워크에 있는 @ExceptionHandler 어노테이션을 통해서, 잘못된 id값이나,

데이터베이스에 없는 값들을 조회/삭제/수정 했을때에 대한 예외처리 핸들러들을 지정해주었다.

이제 예외가 발생할 상황이 되면 throw를 통해서 ExceptionHandler로 예외를 넘겨주면 된다.

 

이 때, 포스트 id 값이 틀린경우에는 NumberFormatException 예외가 발생하고, 아이템이 존재하지 않는 경우에는 NoSuchElementException 예외가 발생하게 된다.

 

사실 이번 프로젝트에서는 발생하는 에러의 의미가 우리가 의도한 바와 비슷하므로 , 굳이 예외전환을 하지않아도 되서 예외 전환은 굳이 하지않았다.

 

이제 맨 위에서 언급한 try-catch의 지옥을 겪지 않아도 적절하게 예외가 처리된다. 모든 예외 처리 로직이 @ExceptionHandler 어노테이션을 붙인 함수로 집중되었다.

 

하지만, PostController 클래스에 예외처리 책임과 실제 비즈니스 로직이 둘 다 들어있는 것이 조금 더 안타깝다.

 

뭔가 조금 더 노력해서 예외 처리 책임을 별도로 분리 할 수 있을것 같다.

 

다행히 스프링 프레임워크 에서는 @ControllerAdvice라는 어노테이션을 통해 특정 클래스에 예외를 모아둘수 있게 해두었다.

 

이제, 컨트롤러 패키지 안에 PostExceptionController 를 생성한다.

 

그리고 나서 아래와 같이 코드를 작성해보자.

 

예외처리를 하는 코드들이 모두 한 클래스에 모여있는것을 알 수 있다.

 

package com.semtax.application.controller;

import com.semtax.application.controller.exception.NoItemError;
import com.semtax.application.controller.exception.PathFormatError;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.NoSuchElementException;

@ControllerAdvice
public class PostExceptionController {

    @ExceptionHandler(NoSuchElementException.class)
    public @ResponseBody NoItemError noItemErrorHandler(NoSuchElementException e){
        NoItemError noItem = new NoItemError();
        noItem.setMessage("Item Not Exists!");
        return noItem;
    }

    @ExceptionHandler(NumberFormatException.class)
    public @ResponseBody PathFormatError noItemErrorHandler(NumberFormatException e){
        PathFormatError formatError = new PathFormatError();
        formatError.setMessage("Invalid Path Variable!");
        return formatError;
    }
}

 

실행해보면 역시나, 정상적으로 예외를 처리할 수 있다는 것을 알 수 있다.

 

사실, 스프링부트에는 기본적으로 BasicErrorController가 구현이 되어있어서 위와 같은 에러가 발생하여도 기본적인 대처는 할 수 있다

(하지만, 5xx에러 발생시 서버정보들이 누출될수 있으므로, 실제 서비스를 만들때에는 BasicErrorController를 상속하여 서비스에 맞는 에러 처리 핸들러 또는 에러 페이지 를 꼭 구현해주어야 한다.)

 

여담으로, resources/static/error 폴더 안에(존재하지 않는 경우에는 폴더를 생성해서)

404.html 과 5xx.html 페이지를 생성하면, HTTP Response 번호에 맞는 에러 페이지를 보여주게 된다.

 

스프링 부트에서 에러 핸들링을 하고싶은 경우에는, ErrorViewResolver를 구현해주면 된다.

아래는 Postman으로 위에서 본 예외가 발생할 만한 값을 넣고 테스트 한 결과이다.

 

반응형
Comments