본문으로 건너뛰기

8.3 사용자 정의 예외 (Custom Exceptions)

자바가 기본적으로 제공하는 수많은 예외 클래스(NullPointerException, FileNotFoundException 등)만으로도 대부분의 오류 상황을 처리할 수 있습니다.

하지만, 실제 실무 웹 서비스나 앱을 개발하다 보면 "잔고 부족 예외", "입력값이 금지어 예외", "회원 등급 미달 예외" 등 우리 비즈니스 로직에만 존재하는 특수한 에러 상황 을 표현해야 할 때가 생깁니다. 이때 개발자가 직접 클래스를 만들어 사용하는 것을 사용자 정의 예외(Custom Exception) 라고 합니다.

1. 사용자 정의 예외 만들기

사용자 정의 예외를 만드는 방법은 아주 간단합니다. 자바가 제공하는 Exception 클래스나 RuntimeException 클래스를 상속(extends) 받기만 하면 됩니다.

상속 대상종류특징사용 권장 시점
ExceptionChecked Exception반드시 try-catch 처리 강제복구 가능한 외부 자원 오류
RuntimeExceptionUnchecked Exception처리 선택 사항 (컴파일러 강제 없음)비즈니스 로직 오류, 프로그래머 실수
실무 권장 사항

최근 실무에서는 RuntimeException을 상속받는 Unchecked Exception 방식을 더 선호합니다. Spring Framework, JPA 등 현대 자바 프레임워크들도 대부분 Unchecked Exception 기반으로 설계되어 있습니다. Checked Exception은 코드에 불필요한 throws 선언을 강제하여 코드를 지저분하게 만들 수 있습니다.

기본 커스텀 예외 작성

// Unchecked 예외: 잔고 부족 예외
public class InsufficientBalanceException extends RuntimeException {

// 1. 기본 생성자
public InsufficientBalanceException() {
super("잔고가 부족합니다.");
}

// 2. 메시지를 직접 받는 생성자
public InsufficientBalanceException(String message) {
super(message);
}

// 3. 메시지 + 원인 예외를 받는 생성자 (예외 체이닝용)
public InsufficientBalanceException(String message, Throwable cause) {
super(message, cause);
}
}

2. throw 키워드: 예외를 의도적으로 발생시키기

우리가 만든 예외를 사용하려면, 에러 조건이 발생했을 때 throw 키워드를 사용해 예외 객체를 생성하고 던집니다.

// throw vs throws 정리
// - throw : 예외를 실제로 발생시키는 키워드 (동사)
// - throws : 메서드가 예외를 위로 전달한다고 선언하는 키워드 (명사형)

public class Account {
private String owner;
private long balance;

public Account(String owner, long balance) {
this.owner = owner;
this.balance = balance;
}

public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입금액은 0보다 커야 합니다: " + amount);
}
this.balance += amount;
System.out.printf("[%s] %,d원 입금. 잔고: %,d원%n", owner, amount, balance);
}

public void withdraw(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("출금액은 0보다 커야 합니다: " + amount);
}
if (this.balance < amount) {
// throw 키워드로 커스텀 예외를 던집니다
long shortfall = amount - this.balance;
throw new InsufficientBalanceException(
String.format("잔고 부족: %,d원이 더 필요합니다. 현재 잔고: %,d원", shortfall, balance)
);
}
this.balance -= amount;
System.out.printf("[%s] %,d원 출금. 잔고: %,d원%n", owner, amount, balance);
}

public long getBalance() { return balance; }
public String getOwner() { return owner; }
}

3. 에러 코드 필드가 있는 커스텀 예외

실무에서는 예외에 에러 코드를 함께 포함시키면 클라이언트(프론트엔드 등)가 에러를 구분하여 처리하기 쉬워집니다.

// 에러 코드 열거형
public enum ErrorCode {
INSUFFICIENT_BALANCE("E001", "잔고 부족"),
ACCOUNT_NOT_FOUND("E002", "계좌를 찾을 수 없음"),
INVALID_AMOUNT("E003", "유효하지 않은 금액"),
ACCOUNT_LOCKED("E004", "계좌가 잠겨있음");

private final String code;
private final String description;

ErrorCode(String code, String description) {
this.code = code;
this.description = description;
}

public String getCode() { return code; }
public String getDescription() { return description; }
}

// 에러 코드를 포함한 비즈니스 예외 기반 클래스
public class BankException extends RuntimeException {
private final ErrorCode errorCode;

public BankException(ErrorCode errorCode) {
super(errorCode.getDescription());
this.errorCode = errorCode;
}

public BankException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}

public BankException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}

public ErrorCode getErrorCode() { return errorCode; }

@Override
public String toString() {
return String.format("[%s] %s", errorCode.getCode(), getMessage());
}
}

4. 비즈니스 예외 계층 구조 설계

실무 프로젝트에서는 비즈니스 도메인에 맞는 예외 계층 구조를 설계합니다.

// 최상위 비즈니스 예외
public class BankBusinessException extends RuntimeException {
public BankBusinessException(String message) {
super(message);
}
public BankBusinessException(String message, Throwable cause) {
super(message, cause);
}
}

// 계좌 관련 예외 (BankBusinessException 상속)
public class AccountException extends BankBusinessException {
public AccountException(String message) { super(message); }
}

// 잔고 부족 예외 (AccountException 상속)
public class InsufficientFundsException extends AccountException {
private final long shortage;

public InsufficientFundsException(long shortage) {
super(String.format("잔고 부족: %,d원이 부족합니다", shortage));
this.shortage = shortage;
}

public long getShortage() { return shortage; }
}

// 계좌 잠금 예외 (AccountException 상속)
public class AccountLockedException extends AccountException {
public AccountLockedException(String accountId) {
super("계좌가 잠겨있습니다: " + accountId);
}
}

// 계좌 미존재 예외 (AccountException 상속)
public class AccountNotFoundException extends AccountException {
public AccountNotFoundException(String accountId) {
super("계좌를 찾을 수 없습니다: " + accountId);
}
}

5. 예외 체이닝(Exception Chaining)

낮은 레벨의 예외(예: SQLException)를 잡아서 더 의미 있는 상위 레벨 예외로 감싸는 패턴입니다. getCause()로 원래 예외를 추적할 수 있어 디버깅에 유리합니다.

import java.sql.SQLException;

public class ExceptionChainingExample {

// 낮은 레벨 메서드: 데이터베이스 접근
static long queryBalance(String accountId) throws SQLException {
// 실제로는 DB 쿼리를 수행하지만, 여기서는 시뮬레이션
if (accountId.equals("ERROR")) {
throw new SQLException("DB 연결 오류: Connection refused");
}
return 50000L;
}

// 높은 레벨 메서드: 비즈니스 로직
static long getAccountBalance(String accountId) {
try {
return queryBalance(accountId);
} catch (SQLException e) {
// 낮은 레벨 예외(SQLException)를 높은 레벨 예외로 체이닝
throw new BankBusinessException(
"계좌 잔고 조회 실패: " + accountId, e // e가 cause(원인)
);
}
}

public static void main(String[] args) {
try {
long balance = getAccountBalance("ERROR");
} catch (BankBusinessException e) {
System.out.println("비즈니스 오류: " + e.getMessage());
System.out.println("근본 원인: " + e.getCause().getMessage());
}
}
}

실행 결과:

비즈니스 오류: 계좌 잔고 조회 실패: ERROR
근본 원인: DB 연결 오류: Connection refused

6. 실전 예제: 은행 계좌 시스템 완성

위에서 만든 모든 요소를 통합한 실전 예제입니다.

import java.util.HashMap;
import java.util.Map;

public class BankSystem {

// 계좌 저장소 (accountId → Account)
private Map<String, Account> accounts = new HashMap<>();

// 계좌 개설
public void createAccount(String accountId, String owner, long initialBalance) {
if (accounts.containsKey(accountId)) {
throw new BankBusinessException("이미 존재하는 계좌번호입니다: " + accountId);
}
accounts.put(accountId, new Account(owner, initialBalance));
System.out.printf("계좌 개설 완료: %s (예금주: %s, 초기 잔고: %,d원)%n",
accountId, owner, initialBalance);
}

// 계좌 조회
private Account findAccount(String accountId) {
Account account = accounts.get(accountId);
if (account == null) {
throw new AccountNotFoundException(accountId);
}
return account;
}

// 입금
public void deposit(String accountId, long amount) {
Account account = findAccount(accountId);
account.deposit(amount);
}

// 출금
public void withdraw(String accountId, long amount) {
Account account = findAccount(accountId);
account.withdraw(amount);
}

// 이체
public void transfer(String fromId, String toId, long amount) {
Account from = findAccount(fromId);
Account to = findAccount(toId);

// 출금과 입금을 하나의 트랜잭션처럼 처리
try {
from.withdraw(amount);
to.deposit(amount);
System.out.printf("[이체] %s → %s: %,d원 성공%n", fromId, toId, amount);
} catch (InsufficientBalanceException e) {
System.out.println("[이체 실패] " + e.getMessage());
throw e; // 예외를 다시 위로 전파
}
}

// 잔고 조회
public void printBalance(String accountId) {
Account account = findAccount(accountId);
System.out.printf("[%s] %s님의 잔고: %,d원%n",
accountId, account.getOwner(), account.getBalance());
}

public static void main(String[] args) {
BankSystem bank = new BankSystem();

// 계좌 개설
bank.createAccount("ACC001", "김철수", 100000);
bank.createAccount("ACC002", "이영희", 50000);
System.out.println();

// 정상 거래
bank.deposit("ACC001", 30000);
bank.withdraw("ACC001", 20000);
bank.transfer("ACC001", "ACC002", 50000);
System.out.println();

// 잔고 조회
bank.printBalance("ACC001");
bank.printBalance("ACC002");
System.out.println();

// 예외 케이스 처리
System.out.println("=== 예외 케이스 테스트 ===");

// 1. 잔고 부족
try {
bank.withdraw("ACC001", 999999);
} catch (InsufficientBalanceException e) {
System.out.println("출금 실패: " + e.getMessage());
}

// 2. 존재하지 않는 계좌
try {
bank.deposit("ACC999", 10000);
} catch (AccountNotFoundException e) {
System.out.println("계좌 오류: " + e.getMessage());
}

// 3. 잘못된 금액
try {
bank.deposit("ACC001", -5000);
} catch (IllegalArgumentException e) {
System.out.println("입력 오류: " + e.getMessage());
}

// 4. 상위 예외 타입으로 포괄 처리
try {
bank.transfer("ACC001", "ACC999", 10000); // 계좌 없음
} catch (BankBusinessException e) {
// AccountNotFoundException은 BankBusinessException의 자손이므로 잡힙니다
System.out.println("이체 실패: " + e.getMessage());
}

System.out.println("\n최종 잔고:");
bank.printBalance("ACC001");
bank.printBalance("ACC002");
}
}

실행 결과:

계좌 개설 완료: ACC001 (예금주: 김철수, 초기 잔고: 100,000원)
계좌 개설 완료: ACC002 (예금주: 이영희, 초기 잔고: 50,000원)

[김철수] 30,000원 입금. 잔고: 130,000원
[김철수] 20,000원 출금. 잔고: 110,000원
[김철수] 50,000원 출금. 잔고: 60,000원
[이영희] 50,000원 입금. 잔고: 100,000원
[이체] ACC001 → ACC002: 50,000원 성공

[ACC001] 김철수님의 잔고: 60,000원
[ACC002] 이영희님의 잔고: 100,000원

=== 예외 케이스 테스트 ===
출금 실패: 잔고 부족: 939,999원이 더 필요합니다. 현재 잔고: 60,000원
계좌 오류: 계좌를 찾을 수 없습니다: ACC999
입력 오류: 입금액은 0보다 커야 합니다: -5000
이체 실패: 계좌를 찾을 수 없습니다: ACC999

최종 잔고:
[ACC001] 김철수님의 잔고: 60,000원
[ACC002] 이영희님의 잔고: 100,000원

7. 사용자 정의 예외를 사용하는 이유

이유설명
가독성throw new InsufficientFundsException()은 이름 자체로 "잔고 부족"을 명확히 표현
세밀한 제어비즈니스 에러를 각각 다른 catch 블록으로 처리 가능
유지보수성에러 종류가 클래스로 구분되어 있어 코드 변경이 쉬움
계층 처리catch (AccountException e)로 계좌 관련 예외를 한 번에 처리 가능
추가 정보에러 코드, 부족 금액 등 커스텀 필드로 부가 정보 전달 가능
고수 팁: 예외 설계 원칙
  1. 예외 클래스 이름은 명확하고 구체적 으로 작성합니다 (DataException 보다 InvalidUserEmailException이 좋습니다).
  2. 최소 3개의 생성자 를 제공합니다: 기본 생성자, 메시지 생성자, 메시지+원인 생성자.
  3. 계층 구조 를 설계해 상위 예외로 포괄 처리와 하위 예외로 세밀한 처리 모두 지원합니다.
  4. Checked Exception은 복구 가능한 경우 에만 사용합니다. 대부분의 비즈니스 예외는 RuntimeException이 적합합니다.

지금까지 프로그램의 붕괴를 막고 우아하게 복구하는 자바 예외 처리 의 기초부터 사용자 정의 예외까지 마스터하셨습니다! 다음 챕터부터는 자바 언어의 핵심 코어 패키지인 java.lang 패키지에 대해 다뤄보겠습니다.