[Java ] 개념노트 46 사용자 정의 예외 이해하기

[Java 개념노트 46] 사용자 정의 예외 이해하기

안녕하세요. Java 개념노트 시리즈 마흔여섯 번째 글입니다.

지난 글에서는 예외를 호출한 쪽으로 넘기는 throws와 예외를 직접 발생시키는 throw에 대해 정리했습니다. 이번 글에서는 자바에서 직접 예외 클래스를 만들어 사용하는 사용자 정의 예외에 대해 알아보겠습니다.

자바에는 IllegalArgumentException, NullPointerException, NumberFormatException처럼 이미 준비된 예외 클래스가 많습니다. 하지만 프로그램을 만들다 보면 내 서비스나 내 기능에 맞는 예외 이름이 필요할 때가 있습니다. 이럴 때 직접 예외 클래스를 만들 수 있습니다.


1. 사용자 정의 예외란?

사용자 정의 예외는 개발자가 직접 만든 예외 클래스입니다.

기본 예외 클래스만으로는 문제 상황을 명확하게 표현하기 어려울 때, 상황에 맞는 이름의 예외 클래스를 직접 만들어 사용할 수 있습니다.

쉽게 말하면
사용자 정의 예외는 내가 만든 프로그램의 문제 상황을 더 정확한 이름으로 표현하기 위해 직접 만드는 예외입니다.

2. 사용자 정의 예외가 필요한 이유

기본 예외만 사용해도 프로그램을 만들 수는 있습니다. 하지만 예외 이름이 구체적이지 않으면 어떤 문제가 발생했는지 파악하기 어려울 수 있습니다.

throw new IllegalArgumentException("잔액이 부족합니다.");

위 코드도 틀린 것은 아닙니다. 하지만 계좌 잔액 부족이라는 상황을 더 명확하게 표현하고 싶다면 다음처럼 직접 예외를 만들 수 있습니다.

throw new InsufficientBalanceException("잔액이 부족합니다.");

이렇게 하면 예외 이름만 봐도 “잔액 부족 문제구나”라고 이해하기 쉽습니다.

3. 사용자 정의 예외를 만드는 방법

사용자 정의 예외는 기존 예외 클래스를 상속받아서 만듭니다. 대표적으로 Exception 또는 RuntimeException을 상속받습니다.

class 예외클래스이름 extends Exception {
    public 예외클래스이름(String message) {
        super(message);
    }
}

또는 RuntimeException을 상속받아 만들 수도 있습니다.

class 예외클래스이름 extends RuntimeException {
    public 예외클래스이름(String message) {
        super(message);
    }
}

핵심 포인트
사용자 정의 예외는 Exception이나 RuntimeException 같은 기존 예외 클래스를 상속받아 만듭니다.

4. Exception을 상속받는 사용자 정의 예외

먼저 Exception을 상속받는 사용자 정의 예외를 만들어보겠습니다.

class InvalidScoreException extends Exception {
    public InvalidScoreException(String message) {
        super(message);
    }
}

InvalidScoreException은 잘못된 점수 상황을 표현하는 예외입니다. Exception을 상속받았기 때문에 checked exception 성격을 가집니다.

checked exception은 호출한 쪽에서 try-catch로 처리하거나 throws로 다시 넘겨야 할 수 있습니다.

5. Exception 상속 예외 사용하기

직접 만든 InvalidScoreException을 사용해보겠습니다.

public class Main {
    public static void main(String[] args) {
        try {
            validateScore(120);
        } catch (InvalidScoreException e) {
            System.out.println(e.getMessage());
        }
    }

    public static void validateScore(int score) throws InvalidScoreException {
        if (score < 0 || score > 100) {
            throw new InvalidScoreException("점수는 0부터 100 사이여야 합니다.");
        }

        System.out.println("점수: " + score);
    }
}

class InvalidScoreException extends Exception {
    public InvalidScoreException(String message) {
        super(message);
    }
}

실행 결과는 다음과 같습니다.

점수는 0부터 100 사이여야 합니다.

validateScore() 메서드에서 점수가 범위를 벗어나면 InvalidScoreException을 직접 발생시켰습니다. 이 예외는 throws InvalidScoreException으로 호출한 쪽에 전달되고, main()에서 catch로 처리했습니다.

6. RuntimeException을 상속받는 사용자 정의 예외

이번에는 RuntimeException을 상속받는 예외를 만들어보겠습니다.

class InvalidAgeException extends RuntimeException {
    public InvalidAgeException(String message) {
        super(message);
    }
}

RuntimeException을 상속받은 예외는 unchecked exception 성격을 가집니다. 따라서 메서드 선언부에 throws를 꼭 작성하지 않아도 됩니다.

7. RuntimeException 상속 예외 사용하기

나이가 음수이면 직접 만든 InvalidAgeException을 발생시키는 예제를 보겠습니다.

public class Main {
    public static void main(String[] args) {
        try {
            setAge(-1);
        } catch (InvalidAgeException e) {
            System.out.println(e.getMessage());
        }
    }

    public static void setAge(int age) {
        if (age < 0) {
            throw new InvalidAgeException("나이는 0 이상이어야 합니다.");
        }

        System.out.println("나이: " + age);
    }
}

class InvalidAgeException extends RuntimeException {
    public InvalidAgeException(String message) {
        super(message);
    }
}

실행 결과는 다음과 같습니다.

나이는 0 이상이어야 합니다.

setAge() 메서드에는 throws InvalidAgeException을 작성하지 않았습니다. InvalidAgeExceptionRuntimeException 계열이기 때문입니다.

8. Exception과 RuntimeException 선택 기준

사용자 정의 예외를 만들 때 Exception을 상속받을지, RuntimeException을 상속받을지 고민될 수 있습니다. 입문 단계에서는 아래처럼 이해하면 좋습니다.

상속 대상 성격 특징
Exception checked exception 호출한 쪽에서 처리하거나 throws 해야 할 수 있습니다.
RuntimeException unchecked exception throws를 강제하지 않습니다.

잘못된 값, 잘못된 상태, 메서드 사용 규칙 위반처럼 프로그램 내부 로직 문제를 표현할 때는 RuntimeException 계열을 자주 사용합니다. 반면 호출한 쪽에서 반드시 처리하도록 강제하고 싶다면 Exception 계열을 사용할 수 있습니다.

9. 사용자 정의 예외 생성자

사용자 정의 예외 클래스에는 보통 메시지를 받는 생성자를 작성합니다.

class InvalidInputException extends RuntimeException {
    public InvalidInputException(String message) {
        super(message);
    }
}

여기서 super(message)는 부모 예외 클래스의 생성자를 호출합니다. 이 메시지는 나중에 getMessage()로 꺼낼 수 있습니다.

try {
    throw new InvalidInputException("입력값이 올바르지 않습니다.");
} catch (InvalidInputException e) {
    System.out.println(e.getMessage());
}

실행 결과는 다음과 같습니다.

입력값이 올바르지 않습니다.

10. 기본 생성자도 만들 수 있다

필요하다면 메시지를 받지 않는 기본 생성자도 만들 수 있습니다.

class InvalidInputException extends RuntimeException {
    public InvalidInputException() {
        super("입력값이 올바르지 않습니다.");
    }

    public InvalidInputException(String message) {
        super(message);
    }
}

이렇게 작성하면 메시지를 직접 전달하지 않아도 기본 메시지를 사용할 수 있습니다.

throw new InvalidInputException();

11. 예외 클래스 이름 짓기

사용자 정의 예외 클래스 이름은 보통 끝에 Exception을 붙입니다. 문제 상황이 잘 드러나도록 이름을 짓는 것이 좋습니다.

예외 클래스 이름 의미
InvalidScoreException 잘못된 점수
InvalidAgeException 잘못된 나이
InsufficientBalanceException 잔액 부족
ProductNotFoundException 상품을 찾을 수 없음

기억하기
예외 이름은 문제가 무엇인지 드러나야 합니다. 이름만 봐도 어떤 상황인지 알 수 있으면 좋습니다.

12. 잔액 부족 예외 만들기

이번에는 계좌에서 출금할 때 잔액이 부족하면 직접 만든 예외를 발생시켜보겠습니다.

class InsufficientBalanceException extends RuntimeException {
    public InsufficientBalanceException(String message) {
        super(message);
    }
}

이 예외는 잔액 부족 상황을 표현합니다.

13. 잔액 부족 예외 사용 예제

계좌 클래스에서 잔액이 부족하면 InsufficientBalanceException을 발생시키겠습니다.

public class Main {
    public static void main(String[] args) {
        Account account = new Account(10000);

        try {
            account.withdraw(15000);
        } catch (InsufficientBalanceException e) {
            System.out.println(e.getMessage());
        }
    }
}

class Account {
    private int balance;

    public Account(int balance) {
        this.balance = balance;
    }

    public void withdraw(int amount) {
        if (amount > balance) {
            throw new InsufficientBalanceException("잔액이 부족합니다.");
        }

        balance -= amount;
        System.out.println("출금 후 잔액: " + balance);
    }
}

class InsufficientBalanceException extends RuntimeException {
    public InsufficientBalanceException(String message) {
        super(message);
    }
}

실행 결과는 다음과 같습니다.

잔액이 부족합니다.

출금하려는 금액이 잔액보다 크기 때문에 InsufficientBalanceException이 발생했습니다.

14. 사용자 정의 예외에 추가 정보 넣기

사용자 정의 예외 클래스에는 메시지뿐만 아니라 필요한 정보를 필드로 추가할 수도 있습니다.

class InsufficientBalanceException extends RuntimeException {
    private int balance;
    private int amount;

    public InsufficientBalanceException(String message, int balance, int amount) {
        super(message);
        this.balance = balance;
        this.amount = amount;
    }

    public int getBalance() {
        return balance;
    }

    public int getAmount() {
        return amount;
    }
}

이 예외는 현재 잔액과 요청 금액을 함께 보관합니다.

15. 추가 정보를 가진 예외 사용하기

추가 정보를 가진 사용자 정의 예외를 사용해보겠습니다.

public class Main {
    public static void main(String[] args) {
        Account account = new Account(10000);

        try {
            account.withdraw(15000);
        } catch (InsufficientBalanceException e) {
            System.out.println(e.getMessage());
            System.out.println("현재 잔액: " + e.getBalance());
            System.out.println("요청 금액: " + e.getAmount());
        }
    }
}

class Account {
    private int balance;

    public Account(int balance) {
        this.balance = balance;
    }

    public void withdraw(int amount) {
        if (amount > balance) {
            throw new InsufficientBalanceException("잔액이 부족합니다.", balance, amount);
        }

        balance -= amount;
    }
}

class InsufficientBalanceException extends RuntimeException {
    private int balance;
    private int amount;

    public InsufficientBalanceException(String message, int balance, int amount) {
        super(message);
        this.balance = balance;
        this.amount = amount;
    }

    public int getBalance() {
        return balance;
    }

    public int getAmount() {
        return amount;
    }
}

실행 결과는 다음과 같습니다.

잔액이 부족합니다.
현재 잔액: 10000
요청 금액: 15000

예외 객체 안에 현재 잔액과 요청 금액을 함께 담아두었기 때문에 catch에서 더 자세한 정보를 확인할 수 있습니다.

16. 사용자 정의 예외를 사용할 때 흐름

사용자 정의 예외를 사용하는 흐름은 다음과 같습니다.

1. 예외 클래스 만들기
        ↓
2. 문제가 되는 조건 확인하기
        ↓
3. throw new 사용자정의예외()로 예외 발생시키기
        ↓
4. try-catch에서 예외 처리하기

핵심은 예외 이름으로 문제 상황을 명확하게 드러내는 것입니다.

17. 사용자 정의 예외 전체 예제

이번에는 회원 가입 상황을 예로 들어 사용자 정의 예외를 사용해보겠습니다.

public class Main {
    public static void main(String[] args) {
        try {
            join("gw");
        } catch (InvalidUserIdException e) {
            System.out.println(e.getMessage());
        }
    }

    public static void join(String userId) {
        if (userId == null || userId.length() < 4) {
            throw new InvalidUserIdException("아이디는 4자 이상이어야 합니다.");
        }

        System.out.println("회원가입 완료: " + userId);
    }
}

class InvalidUserIdException extends RuntimeException {
    public InvalidUserIdException(String message) {
        super(message);
    }
}

실행 결과는 다음과 같습니다.

아이디는 4자 이상이어야 합니다.

아이디가 4자보다 짧기 때문에 InvalidUserIdException이 발생했습니다. 기본 예외보다 문제 상황이 더 명확하게 드러납니다.

18. 사용자 정의 예외 사용 시 자주 하는 실수

사용자 정의 예외를 처음 만들 때는 아래와 같은 실수를 자주 합니다.

실수 문제점 해결 방법
예외 클래스가 기존 예외를 상속받지 않음 예외로 사용할 수 없음 Exception 또는 RuntimeException 상속
예외 이름이 너무 모호함 문제 상황을 알기 어려움 InvalidScoreException처럼 구체적으로 작성
super(message) 누락 getMessage()로 메시지 확인이 어려움 생성자에서 super(message) 호출
무조건 사용자 정의 예외를 만듦 예외 클래스가 불필요하게 많아짐 기본 예외로 충분한지 먼저 판단
예외를 만들고 처리 흐름을 정하지 않음 프로그램이 중간에 종료될 수 있음 어디서 catch할지 흐름을 정리

19. 직접 연습해보기

아래 코드를 직접 작성하고 실행해보세요.

public class Main {
    public static void main(String[] args) {
        Product product = new Product("키보드", 50000);

        try {
            product.discount(60000);
        } catch (InvalidDiscountException e) {
            System.out.println(e.getMessage());
            System.out.println("상품 가격: " + e.getPrice());
            System.out.println("할인 금액: " + e.getDiscountAmount());
        }
    }
}

class Product {
    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public void discount(int discountAmount) {
        if (discountAmount < 0) {
            throw new InvalidDiscountException("할인 금액은 0 이상이어야 합니다.", price, discountAmount);
        }

        if (discountAmount > price) {
            throw new InvalidDiscountException("할인 금액은 상품 가격보다 클 수 없습니다.", price, discountAmount);
        }

        price -= discountAmount;
        System.out.println("할인 후 가격: " + price);
    }
}

class InvalidDiscountException extends RuntimeException {
    private int price;
    private int discountAmount;

    public InvalidDiscountException(String message, int price, int discountAmount) {
        super(message);
        this.price = price;
        this.discountAmount = discountAmount;
    }

    public int getPrice() {
        return price;
    }

    public int getDiscountAmount() {
        return discountAmount;
    }
}

실행 결과는 다음과 같습니다.

할인 금액은 상품 가격보다 클 수 없습니다.
상품 가격: 50000
할인 금액: 60000

20. 이번 글 정리

이번 글에서는 직접 예외 클래스를 만들어 사용하는 사용자 정의 예외에 대해 정리했습니다. 핵심 내용은 다음과 같습니다.

  • 사용자 정의 예외는 개발자가 직접 만든 예외 클래스이다.
  • 기본 예외만으로 문제 상황을 명확히 표현하기 어려울 때 사용할 수 있다.
  • 사용자 정의 예외는 Exception 또는 RuntimeException을 상속받아 만든다.
  • Exception을 상속받으면 checked exception 성격을 가진다.
  • RuntimeException을 상속받으면 unchecked exception 성격을 가진다.
  • 예외 클래스 이름은 보통 Exception으로 끝낸다.
  • 생성자에서 super(message)를 호출하면 getMessage()로 메시지를 확인할 수 있다.
  • throw new 사용자정의예외()로 직접 예외를 발생시킬 수 있다.
  • 사용자 정의 예외에는 필요한 추가 정보를 필드로 넣을 수도 있다.
  • 예외 이름은 문제 상황이 잘 드러나도록 구체적으로 짓는 것이 좋다.

한 줄 요약
사용자 정의 예외는 프로그램의 문제 상황을 더 명확한 이름과 정보로 표현하기 위해 직접 만드는 예외 클래스입니다.

다음 글 예고
다음 글에서는 [Java 개념노트 47] 컬렉션 프레임워크란 무엇인가?라는 주제로 여러 데이터를 더 편리하게 저장하고 관리하는 List, Set, Map의 기본 개념을 정리해보겠습니다.

GWDEVELBlog Java 개념노트 시리즈