[JAVA] 예외 처리를 어떻게 해야할까? - (2)

kindof

·

2021. 6. 20. 17:30

0. 들어가면서

이전 포스팅에서 자바 예외처리 기본 개념과 예외처리를 어떻게 해야하는가에 대한 이야기를 했습니다.

 

 

[JAVA] 예외 처리를 어떻게 해야 할까

If문, try/catch문은 무엇이 다른가요? 0. 들어가면서 if 조건문을 쓰는 이유는 무엇인가요? try/catch문을 쓰는 이유는 무엇인가요? 이번 글에서는 if문과 try/catch문의 차이에 대해 간략히 알아보고, 예

studyandwrite.tistory.com

 

오늘은 자바의 예외 처리에 대한 몇 가지 내용을 더 공부해보려고 합니다.

 

1. throw와 throws

1-1) throw

아래 예시는 throw를 통해서 일부러 예외를 발생시킵니다.

 

개발자의 의도한 방향이 아닌 방향으로 사용자가 프로그램을 동작하면 이에 대해 일부러 Exception을 발생시킴으로써 잘못된 사용을 방지하려는 목적입니다.

import java.util.Scanner;

public class DanceLesson
{
    public static void main(String[] args)
    {
        Scanner sc = new Scanner(System.in);
        System.out.println("Enter number of participants: ");
        int participants = sc.nextdInt();

        try{
            if (parcipants <= 0)
                throw new Exception("Lesson is Canceled. No Participants.");
            System.out.println("There are " + participants + " people in class.");
        } catch (Exception e){
            String message = e.getMessage();
            System.out.println(message);
            System.exit(0);
        }

        System.out.println("Begin the lesson.");
    }
}

// 이 예시에서는 만약 수업에 참여하는 사람이 0명 이하일 때는 예외를 발생시킴으로써 프로그램이 종료되도록 하는 것이다.
// 실제 생활에서 위와 같은 예외처리는 별로 없을 것 같지만 throw를 어떻게 사용하는지만 확인하면 될 것 같다.

 

1-2) throws

throws는 예외가 발생한 곳에서 try-catch 블록으로 직접 처리하지 않고, 메서드를 호출한 곳으로 예외를 떠넘길 때 사용합니다. 예외 처리를 위임방은 메서드는 다시 예외 처리에 대한 책임이 있고, 자신이 직접 이 예외를 처리하거나 또 다시 다른 곳으로 떠넘길 수 있죠.

 

컨트롤러에서 서비스 로직을 호출하고, 서비스 로직에서 예외가 발생하면 컨트롤러 딴에서 예외를 처리해서 뷰로 넘기는 것을 생각해볼 수 있습니다.

 

하지만 만약 어떤 곳에서도 이 예외를 제대로 처리하지 않으면 프로그램은 비정상적으로 종료됩니다.

 

예외 계층

 

위 그림을 보면 Call Stack에 main -> method1 -> method2 순서로 쌓이게 되는데, method2에서 예외가 발생하면 method2는 이 예외를 자신을 호출한 method1에게 떠넘깁니다.

 

이 때, method1은 이 예외를 직접 처리하지 않고 다시 자신을 호출한 main에게 떠넘기고, main이 이를 처리해야 되는데 또 예외를 처리하지 않아서 결국 runtime exception문제가 생기죠.

 


 

2. 자바 예외 계층 구조(Exception Class)

자바 Exception Class는 크게 두 가지가 있습니다.

 

첫번째는 RuntimeException Class(Arithmetic, ClassCast, NullPointer, IndexOutOfBounds ...) = Unchecked Exception인데요.

 

이 예외는 우리가 가장 많이 접하는 예외로, 쉽게 말하자면 코딩을 잘못해서 발생하는 예외입니다. 이러한 예외에 대해서는 예외를 처리해놓지 않아도 컴파일 에러가 발생하지는 않습니다. 다만 런타임에 프로그램이 터지게 되죠.

 

두번째는 Other Exception Class(IOException, ClassNotFoundException ...) = Checked Exception입니다.

 

이 예외는 우리가 일부러 발생시키는 예외처럼 사용자가 잘못된 입력을 하는 상황 등에 대한 예외를 의미합니다. 이전에 봤던 것처럼 이런 예외를 제대로 처리하지 않으면 컴파일 에러가 발생하죠.

 

쉽게 생각해서 우리가 일부러 이런 저런 예외를 고려하면서 큰 그림을 그려놨는데, 정작 만들어놓은 그림에 대한 예외 처리가 되어있지 않으면 컴파일러가 이의를 제기하는 것입니다.

 

* Transaction의 롤백(Rollback)

RuntimeException 같은 경우에는 예외 발생 시 트랜잭션을 롤백하고, 그 외 Exception들은 트랜잭션을 롤백하지 않습니다.

 

따라서, 두 예외에서 rollback의 범위가 다르기 때문에 이를 인지하지 못하면 프로그램의 수행결과 등이 예상치 못하는 방향으로 나타나거나 비즈니스 로직이 깨질 수 있죠.

 

가령 우리가 어떤 사이트에서 회원가입을 하면 쿠폰이 발급되도록 하는 기능을 만든다고 해봅시다. 그런데 이 과정에서 어떤 문제가 발생해서 회원가입과 쿠폰 발급이 동시에 완료되지 못하면 어떻게 될까요?

// 다음과 같은 Service Component가 존재한다고 생각해보자.
@Service
public class UserService{
    @Autowired
    private CouponRepositoty couponRepository;

    @Transactional
    public void signUpUser1(UserDto UserDto) throws Exception{
        save(userDto);
        try{
            throw new Exception();
            couponRepository.issueCoupon(userDto.getId());
        } catch(Exception ex) 
            { // skip 
        } 
    }

    @Transactional
    public void signUpUser2(UserDto userDto) throws RuntimeException{
        couponRepository.issueCoupon(userDto.getId());
        save(userDto);
        throw new RuntimeException("RuntimeException for rollback");
    }
}

 

1) 만약 회원 가입은 허용하지만, 쿠폰 발급은 보류해도 된다고 하면?

  • 아래와 같이 Check Exception을 발생시켰지만, 회원 정보는 생성되고 쿠폰 발급은 문제없이 진행됩니다.
@Transactional
public void signUpUser1(UserDto UserDto) throws Exception{
    save(userDto);
    try{
        throw new Exception();
        couponRepository.issueCoupon(userDto.getId());
    } catch(Exception ex) 
        { // skip 
    } 
    // Rollback 없이 Transaction 완료
}
// 스프링에서는 @Transactional을 사용한 Checked Exception은 rollback되지 않는다.

 

2) 회원 가입과 쿠폰 발급이 동시에 완료되지 못한 경우에는 모두 Rollback 되어야 한다면?

  • RuntimeException이 발생하고 동시에 모두 Rollback됩니다.
public void signUpUser2(UserDto userDto) throws RuntimeException{
    couponRepository.issueCoupon(userDto.getId());
    save(userDto);
    throw new RuntimeException("RuntimeException for rollback");
}

이렇게 예외 클래스의 차이점은 트랜잭션의 롤백 여부에 영향을 미치게 됩니다.

 

아래 그림은 방금 살펴본 Exception Class의 구조입니다. 

Exception Class


 

3. 커스텀 예외 만들기

사용자 정의 예외(커스텀 예외)는 분명 필요하지만, 너무 많은 커스텀 예외를 일일이 만드는 것은 기존에 이미 있는 RuntimeException을 충분히 활용하지 못할 가능성을 만들게 됩니다.

 

인수로 부적절한 값이 들어올 때 던지는 IllegalArgumentException, 일을 수행하기에 적합하지 않은 상태의 객체인 경우 던지는 IllegalStateException 등 이미 표준적으로 사용되고 있는 예외들을 적절히 잘 사용하는 것이 우선적이죠.

 

하지만 그럼에도 불구하고 커스텀 예외는 다음과 같은 장점을 갖습니다.

  • 이름으로도 정보 전달이 가능합니다. NoSuchElementException, PostNotFoundException같은 예외는 이름만 봐도 무슨 예외가 발생했는지 쉽게 파악할 수 있죠.
  • 상세한 예외 정보를 제공할 수 있습니다. 예를들어 IndexOutOfBoundsException을 생각해보겠습니다. 이 예외가 발생하게 되면 우리는 전체 범위가 얼만큼인지, 어디에서 예외가 발생했는지 쉽게 파악하기 어렵습니다.

 

하지만 아래처럼 IndexOutOfBoundsException Class를 오버로딩하여 커스텀화한다면 정보를 전달할 수 있습니다.

public class IllegalIndexException extends IndexOutOfBoundsException {
    private static final String message = "범위를 벗어났습니다.";

    public IllegalIndexException(List<?> target, int index) {
        super(message + " size: "  + target.size() + " index: " + index);
    }
}

이 외에도 커스텀 예외를 만들어두면 예외에 필요한 메시지, 전달할 정보의 데이터, 데이터 가공 메소드등을 한 곳에 관리함으로써 객체의 책임이 분리된 코드를 만들 수 있습니다.

 

* 그렇다면 커스텀 예외는 어떤 식으로 만들까?

위에서 살펴본 것처럼 커스텀 예외는 기존에 존재하는 특정한 Exception Class를 오버로딩하여 구체적인 정보를 전달하는 식으로 생성할 수 있습니다.

 

또한 모든 예외들의 Parent인 Exception Class를 extends하여 그 곳에 멤버 변수 형태로 내가 원하는 예외를 정의할 수도 있죠.

 

예제 하나를 보겠습니다.

public class DivisionByZeroException extends Exception
{
    public DivisionByZeroException(){
        super("Division by Zero!");
    }

    public DivisionByZeroException(String message){
        super(message);
    }
}
-------------------------------------------------------
import java.util.Scanner;

public class Practice
{
    public static void main(String[] args){
        try{
            Scanner sc = new Scanner(System.in);

            System.out.println("Enter numerator:");
            int numerator = sc.nexdtInt();
            System.out.println("Enter denominator:");
            int denominator = sc.nexdtInt();

            if (denominator == 0)
                throw  new DivisionByZeroException();

            // 예외가 발생하지 않았을 때 statements...
        } catch (DivisionByZeroException e){
            System.out.println(e.getMessage());
        }
    }
    ...
}

 

 

4. 정리

이렇게 이번 포스팅에서는 자바 예외 처리의 기본적인 개념들에 대해 짚어봤습니다.

 

그리고 Checked, Unchecked Exception의 차이점에 대해서 공부해보고, 커스텀 예외를 정의해서 사용할 때의 장점과 주의할 점에 대해서도 정리해봤습니다.

 

감사합니다.