사용자정의 예외 (Custom Exception)
사용자정의 예외 작성
기존의 정의된 예외 클래스 외에 필요에 따라 프로그래머가 새로운 예외 클래스를 정의할 수 있다. 보통 Exception 클래스 또는 RutimeException 클래스로부터 상속받아 클래스를 만든다. 그러나 필요에 따라 알맞은 예외 클래스를 선택하여 상속하면된다. 다만, 가능하면 새로운 예외 클래스를 만들기 보다 기존의 예외 클래스를 활용하는 것이 좋다.
Excetion 클래스로부터 상속받아서 MyException을 구현하였다. 상위 클래스의 생성자를 사용하여 사용자정의 예외(custom exception) 클래스에 메세지를 저장할수 있으며, 필요하다면 멤버 변수나 메서드를 추가하여 더 기능을 업그레이드 시킬수 있다.
public class MyException extends Exception {
private final int ERR_CODE; // 생성자를 통한 초기화 필요
MyException(String message, int errCode) {
super(message); // 예외 메서지 인스턴스에 저장
ERR_CODE = errCode;
}
MyException(String message) {
this(message, 100); // ERR_CODE 100으로 초기화
}
public int getErrCode() {
return ERR_CODE;
}
}
그리고 예외 메시지 외에도 에러 코드를 사용하면, 동일한 예외 클래스 내에서도 다양한 오류 상황을 구별도 가능하다. 이는 에러 코드에 따라 예외 처리를 다르게 처리할 수 있다. 에러 코드에 따라 다른 복구 절차를 수행을 한다든가, 에러 코드에 따라 사용자에게 다른 메시지를 노출할 수 있다. 이렇게 예외에 대한 추가 정보를 제공하여 예외를 더 세밀하게 처리하고 디버깅이 가능하다.
기존의 예외 클래스는 주로 Exception 클래스를 상속받아 Checked Exception으로 작성하는 경우가 많았다고 한다. 요즘은 예외처리를 선택적으로 할 수 있도록 RuntimeException을 상속받아서 Unchecked Exception 으로 작성하는 쪽이 많아진다고 한다.
Check Exception은 반드시 예외 처리를 해주어야되기 때문에 예외처리가 불필요한 경우에도 try-catch 문을 넣어서 코드가 복잡해지기 때문이다. 그러나 예외처리를 강제하도록 할려면 Checked Exception을 써야한다. 따라서 강제할 필요가 없는 경우에는, 런타임에 어떠한 코드로 인한 에러가 발생할지 모르기 때문에 Unchecked Exception으로 구현하여 선택적으로 예외 처리하도록 하는것이 좋을것이다.
사용자정의 예외의 활용
위의 내용에 대한 예시로, 은행 시스템의 거래에서 예금(deposit), 출금(withdraw) 메서드에서 발생하는 Checked Exception와 거래 상태를 조작하는 거래 시작(startTransaction)과 거래 종료(endTransaction) 메서드에서 발생하는 Unchecked Exception를 작성하였다.
예시의 각 주요 클래스는 다음과 같다.
- BankAccount: 계좌의 상태(잔액)를 관리하는 역할
- TransactionProgressor: 동시에 여러 스레드가 동일한 데이터(계좌 잔액)를 변경하는 것을 방지하기 위해 트랜잭션 진행 상태를 관리하는 역할
- Transactor: 입금과 출금 작업을 처리하는 역할
- TransactionService: TransactionProgressor 객체의 트랜잭션의 진행 상태에 따라 Transactor 객체를 통해 실제 금융 작업(입금 및 출금)을 수행하는 역할
public class BankAccount {
private double balance;
public BankAccount() {
this(0);
}
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public double getBalance() {
return balance;
}
public void updateBalance(double amount) {
this.balance += amount;
}
}
public class TransactionService {
private TransactionProgresser transactionProgresser;
private Transactor transactor;
public TransactionService(TransactionProgresser transactionProgresser, Transactor transactor) {
this.transactionProgresser = transactionProgresser;
this.transactor = transactor;
}
public void performDeposit(BankAccount bankAccount, double amount) throws InvalidUserInputException {
try (TransactionProgresser tp = transactionProgresser) {
tp.startTransaction();
transactor.deposit(bankAccount, amount);
} catch (InvalidTransactionStateException e) {
e.printStackTrace();
return; // 트랜잭션 시작 실패 시 예외를 처리하고 메서드를 종료
} catch (InvalidUserInputException e) { // 생략 가능
e.printStackTrace();
}
}
public void performWithdraw(BankAccount bankAccount, double amount) throws InvalidUserInputException, InsufficientFundsException {
try (TransactionProgresser tp = transactionProgresser) {
tp.startTransaction();
transactor.withdraw(bankAccount, amount);
} catch (InvalidTransactionStateException e) {
e.printStackTrace();
return; // 트랜잭션 시작 실패 시 예외를 처리하고 메서드를 종료
} catch (InvalidUserInputException | InsufficientFundsException e) { // 생략 가능
throw e;
}
}
}
public class TransactionProgresser implements AutoCloseable {
private boolean transactionInProgress;
{
this.transactionInProgress = false;
}
private boolean isTransactionInProgress() {
return transactionInProgress;
}
private void setTransactionInProgress() {
this.transactionInProgress = !transactionInProgress;
}
public void startTransaction() throws InvalidTransactionStateException {
if (isTransactionInProgress()) {
throw new InvalidTransactionStateException(BankSystemErrorCode.TRANSACTION_ALREADY_IN_PROGRESS);
}
setTransactionInProgress();
System.out.println("Transaction started.");
}
public void endTransaction() throws InvalidTransactionStateException {
if (!isTransactionInProgress()) {
throw new InvalidTransactionStateException(BankSystemErrorCode.NO_TRANSACTION_IN_PROGRESS);
}
setTransactionInProgress();
System.out.println("Transaction ended.");
}
@Override
public void close() throws InvalidTransactionStateException {
try {
endTransaction();
} catch (InvalidTransactionStateException e) {
e.printStackTrace();
}
}
}
public class Transactor {
public void deposit(BankAccount bankAccount, double amount) throws InvalidUserInputException {
if (amount <= 0) {
throw new InvalidUserInputException(BankSystemErrorCode.INVALID_DEPOSIT_AMOUNT);
}
bankAccount.updateBalance(amount);
System.out.println("Deposited " + amount);
}
public void withdraw(BankAccount bankAccount, double amount) throws InvalidUserInputException, InsufficientFundsException {
if (amount <= 0) {
throw new InvalidUserInputException(BankSystemErrorCode.INVALID_WITHDRAWAL_AMOUNT);
}
if (amount > bankAccount.getBalance()) {
throw new InsufficientFundsException(BankSystemErrorCode.INSUFFICIENT_FUNDS);
}
bankAccount.updateBalance(-amount);
System.out.println("Withdrew " + amount);
}
}
예금(deposit)시에는 사용자가 입금할 금액을 음수로 입력하는 예외(InvalidUserInputException)와 출금(withdraw)에는 사용자가 잔고보다 큰 금액을 출금하는 예외(InsufficientFundsException)와 음수를 입력하는 예외(InvalidUserInputException)가 있다. 이 에외들은 사용자들의 특정한 동작에 의해서 발생되는 Checked Exception이며, 예외 발생시 사용자에게 알려야한다. 해당 예외는 Exception 클래스를 상속받아 Checked Exception으로 작성하였다.
public class BankSystemException extends Exception {
private BankSystemErrorCode errorCode;
public BankSystemException(BankSystemErrorCode errorCode) {
this.errorCode = errorCode;
}
@Override
public void printStackTrace(PrintStream s) {
synchronized (s) {
super.printStackTrace(s);
s.println(" Error Code: " + errorCode.getCode());
s.println(" Error Message: " + errorCode.getMessage());
}
}
}
public class InsufficientFundsException extends BankSystemException {
public InsufficientFundsException(BankSystemErrorCode errorCode) {
super(errorCode);
}
}
public class InvalidUserInputException extends BankSystemException {
public InvalidUserInputException(BankSystemErrorCode errorCode) {
super(errorCode);
}
}
잔고(balance)의 동기화를 위해서, 거래(transaction) 중인 상태인데 다른 거래를 시작할려하거나, 거래가 끝났는데 거래를 끝나는 작업을 할려하거나 할때는, 프로그램 실행중에 발생하는 예외(InvalidTransactionStateException)를 RuntimeException을 상속받아서 Unchecked Exception 으로 작성하였다. 그리고 거래 시작(startTransaction)과 거래 종료(endTransaction) 메서드에서 예외가 발생하면 바로 아무작업도 수행하지 되지않기 종료 되기 때문에 Unchecked Exception를 던지는 것이 적절하였다.
public class InvalidTransactionStateException extends RuntimeException {
private BankSystemErrorCode errorCode;
public InvalidTransactionStateException(BankSystemErrorCode errorCode) {
this.errorCode = errorCode;
}
@Override
public void printStackTrace(PrintStream s) {
synchronized (s) {
super.printStackTrace(s);
s.println(" Error Code: " + errorCode.getCode());
s.println(" Error Message: " + errorCode.getMessage());
}
}
}
그리고 예외 메시지 외에도 에러 코드(BankSystemErrorCode)를 사용하여 동일한 예외 클래스 내에서도 다양한 오류 상황을 구별 하도록 하였다. 사용자가 출입금시 금액을 잘못된 입력을 의해서 발생되는 Checked Exception은 1000번대의 에러 코드로 지정하였고, 계좌 거래 시도할때 발생되는 Unchecked Exception은 6000번대의 에러 코드로 지정하였다.
public enum BankSystemErrorCode {
INVALID_DEPOSIT_AMOUNT(1001, "Please enter a positive amount for deposit."),
INVALID_WITHDRAWAL_AMOUNT(1002, "Please enter a positive amount for withdrawal."),
INSUFFICIENT_FUNDS(1003, "You do not have enough funds to complete this withdrawal."),
TRANSACTION_ALREADY_IN_PROGRESS(6001, "A transaction is already in progress. Please complete it before starting a new one."),
NO_TRANSACTION_IN_PROGRESS(6002, "No transaction is currently in progress to end."),
UNKNOWN_ERROR(9999, "An unknown error occurred.");
private final int code;
private final String message;
BankSystemErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
위에서 설정한 에러 코드로 5가지의 테스트를 해볼것이다.
- 유효하지 않은 입금 테스트:
- deposit 메서드에 음수 값을 전달하여 InvalidUserInputException을 던진다.
- printStackTrace 메서드에서 에러 코드 1001에 따라 메시지를 출력한다.
- 유효하지 않은 출금 테스트:
- withdraw 메서드에 음수 값을 전달하여 InvalidUserInputException을 던진다.
- printStackTrace 메서드에서 에러 코드 1002에 따라 메시지를 출력한다.
- 잔액 부족 출금 테스트:
- withdraw 메서드에 잔액보다 큰 값을 전달하여 InsufficientFundsException을 던진다.
- printStackTrace 메서드에서 에러 코드 1003에 따라 메시지를 출력한다.
- 거래 상태에 대한 시작 테스트:
- startTransaction 거래가 종료되지 않은 상태애서 거래를 시작하여 InvalidTransactionStateException을 던진다.
- printStackTrace 메서드에서 에러 코드 6001에 따라 메시지를 출력한다.
- 거래 상태에 대한 종료 테스트:
- endTransaction 거래가 시작되지 않은 상태애서 거래를 시작하여 InvalidTransactionStateException을 던진다.
- printStackTrace 메서드에서 에러 코드 6002에 따라 메시지를 출력한다.
다음은 Checked Exception을 발생시킨 결과이다. 음수 출금(withdraw), 음수 입금(deposit), 잔고(balance)가 100인데 200을 출금(withdraw)하여 각 3개의 예외를 발생시켰다.
public class BankSystemTest {
public static void main(String[] args) {
BankAccount bankAccount = new BankAccount(100);
Transactor transactor = new Transactor();
TransactionProgresser transactionProgresser = new TransactionProgresser();
TransactionService transactionService = new TransactionService(transactionProgresser, transactor);
// 음수 출금 예외
try {
transactionService.performWithdraw(bankAccount, -20);
} catch (InvalidUserInputException e) {
e.printStackTrace();
} catch (InsufficientFundsException e) {
e.printStackTrace();
}
// 음수 입금 예외
try {
transactionService.performDeposit(bankAccount, -50);
} catch (InvalidUserInputException e) {
e.printStackTrace();
}
// 잔고가 100인데 200을 출급하여 예외
try {
transactionService.performWithdraw(bankAccount, 200);
} catch (InvalidUserInputException e) {
e.printStackTrace();
} catch (InsufficientFundsException e) {
e.printStackTrace();
}
}
}
위의 코드 실행시. 예외가 발생한 콜 스택에 있었던 메서드의 정보와 예외 메세지를 한번에 출력하며, printStackTrace메서드를 overriding하여서 Error Code와 Error Message까지도 출력한 것을 볼 수 있다. 이러한 입출금시애 사용자의 잘못된 입력으로 인한 Exception 자손 예외는 1000번 에러 코드도 출력되었다.

다음은 여러 스레드가 동시에 performDeposit과 performWithdraw 메서드를 동시에 실행시키도록 하여서거래가 진행되고 있는 와중에 거래를 시도를 하여 Unchecked Exception인 RuntimeException을 발생시켰다.
public class BankSystemTest {
public static void main(String[] args) {
BankAccount bankAccount = new BankAccount(100);
Transactor transactor = new Transactor();
TransactionProgresser transactionProgresser = new TransactionProgresser();
TransactionService transactionService = new TransactionService(transactionProgresser, transactor);
// 스레드 풀 생성
int threadCount = 3;
Thread[] threads = new Thread[threadCount];
// 각 스레드에서 트랜잭션 수행
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
try {
transactionService.performDeposit(bankAccount, 50);
} catch (InvalidUserInputException e) {
e.printStackTrace();
}
try {
transactionService.performWithdraw(bankAccount, 30);
} catch (InvalidUserInputException e) {
e.printStackTrace();
} catch (InsufficientFundsException e) {
e.printStackTrace();
}
});
threads[i].start();
}
// 스레드 종료 대기
for (int i = 0; i < threadCount; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
위의 코드 실행시, 계좌 거래 시도할때 발생되는 Unchecked Exception은 6000번대의 에러 코드가 발생하는 것을 볼수 있다.

멀티스레딩시에 예외를 발생시키도록 작성한 performDeposit와 performWithdraw 메서드에 synchronized 키워드를 붙여주면 두 메서드가 동일한 객체에 대해 동기화된다. 이는 객체 수준에서 lock을 걸기 때문에, 서로 다른 스레드에서 동일한 synchronized method를 실행시키거나 서로 다른 스레드에서 서로 다른 synchronized method를 실행시칼수 없다.
예를 들어 아래의 코드와 같이, 하나의 스레드가 performDeposit를 실행할때 다른 스레드에서 performDeposit를 실행시킬수 없고, 하나의 스레드가 performDeposit 메서드를 실행하는 동안 다른 스레드가 performWithdraw 메서드를 실행할 수 없다. 따라서 아래의 코드로 변경 이후에는 멀티스레딩으로 인한 RuntimeException은 발생하지 되지 않는다.
public synchronized void performDeposit(BankAccount bankAccount, double amount) throws InvalidUserInputException {
try (TransactionProgresser tp = transactionProgresser) {
tp.startTransaction();
transactor.deposit(bankAccount, amount);
} catch (InvalidTransactionStateException e) {
e.printStackTrace();
return; // 트랜잭션 시작 실패 시 예외를 처리하고 메서드를 종료
} catch (InvalidUserInputException e) { // 생략 가능
throw e;
}
}
public synchronized void performWithdraw(BankAccount bankAccount, double amount) throws InvalidUserInputException, InsufficientFundsException {
try (TransactionProgresser tp = transactionProgresser) {
tp.startTransaction();
transactor.withdraw(bankAccount, amount);
} catch (InvalidTransactionStateException e) {
e.printStackTrace();
return; // 트랜잭션 시작 실패 시 예외를 처리하고 메서드를 종료
} catch (InvalidUserInputException | InsufficientFundsException e) { // 생략 가능
throw e;
}
}
연결된 예외(Chained Exception)
예외 A가 예외 B를 발생시켰다면, A를 B의 원인 예외(cause exception)이라고 한다. 다음은 InstallExcepion이 발생한 원인 예외로 SpaceExcepion와 MemoryException을 등록하는 것이다. install() 메서드에서 발생한 InstallExcepion 예외 객체의 initCase()메서드로 SpaceExcepion와 MemoryException 객체를 원인 예외로 할당하였다.
public static void main(String[] args) {
try {
install();
} catch (InstallException e) {
e.printStackTrace();
}
}
static void install() throws InstallException {
try {
startInstall();
copyFiles();
} catch (SpaceException se) {
InstallException ie = new InstallException("Exception occurred during installation");
ie.initCause(se);
throw ie;
} catch (MemoryException me) {
InstallException ie = new InstallException("Exception occurred during installation");
ie.initCause(me);
throw ie;
} finally {
deleteFiles();
}
}
static void startInstall() throws SpaceException, MemoryException {
if (!enoughSpace()) {
throw new SpaceException("Not enough space");
}
if (!enoughMemory()) {
throw new MemoryException("Not enough memory");
}
}
class InstallException extends Exception {
InstallException(String message) { super(message); }
}
class SpaceException extends Exception {
SpaceException(String message) { super(message); }
}
class MemoryException extends Exception {
MemoryException(String message) { super(message); }
}
위의 코드 실행시, InstallException 예외의 원인(Caused by)으로 MemoryException 예외가 출력된다.
exceptionhandle.chainedexception.InstallException: Exception occurred during installation
at exceptionhandle.chainedexception.ChainedException.install(ChainedException.java:51)
at exceptionhandle.chainedexception.ChainedException.main(ChainedException.java:18)
Caused by: exceptionhandle.chainedexception.MemoryException: Not engouth memory
at exceptionhandle.chainedexception.ChainedException.startInstall(ChainedException.java:65)
at exceptionhandle.chainedexception.ChainedException.install(ChainedException.java:44)
... 1 more
상속 없이 하나의 예외로 묶기
initCause()는 Exception 클래스의 부모인 Thrwoable 클래스에 정의되어 있어, 모든 예외에서 사용가능하다. 그리고 getCause()메서드를 통하여 원인 예외도 반환시킬 수 있다.
원인 예외를 등록하여, 다시 예외를 발생시키는 이유는, 여러 원인 예외 SpaceExcepion와 MemoryException을 호출한 메서드로 하나의 예외 InstallExcepion로 rethrowing 하기 위해서이다.
위의 소스 코드 로직은 소프트웨어를 설치를 시작하고 디스크 공간(이나 메모리 공간이 부족함에 따라 예외가 발생하는 로직이다. 따라서 설치시 문제가 발생했을 경우에 설치 예외를 발생시키는 것이 문맥에 맞으며, 설치 예외 원인으로 디스크 공간 부족 예외나 메모리 공간 부족 예외가 따라가는 것이 적합하다. 이럴 경우에 원인 예외를 쓰는 것이다. 원인 예외는 두 예외 클래스간에 상속 관계가 아니어도 무관하다.ㅠ 또한 원인 예외는 다른 말로 연결된 예외(chained exception)라고 한다. Throwable 클래스의 chained exception은 단일 연결 리스트(Singly Linked List)의 형태로 저장하여 예외 객체가 원인 객체를 참조하는 체인 형태로 관리한다. 이로써, 예외가 발생한 원인을 추적하고 디버깅할 수 있다.
Checked Exception를 Unchecked Exception으로 변환
Chained Exception을 사용하는 또 다른 이유는 checked 예외를 unchecked 예외로 바꿀 수 있도록 하기 위해서이다. check 예외로 예외처리를 강제한 이유는 프로그래밍 경험이 적은 사람도 보다 견고한 프로그램을 작성할 수 있도록 유도하기 위한것이였으며, 소형기기나 데스크탑에서 실행될 것이라고 생각하여 필수적으로 예외를 처리해야 될것 같았지만, 자바는 웹 서버, 모바일, GUI 등으로 쓰이면서 checked exception가 발생하여도 예외를 처리할 수 없는 상황이 늘어나게 되었다. 이럴때는 그저 의미 없는 try-catch 문을 추가하는 것 뿐인데, checked exception를 unchecked exception으로 바꾸면 선택적으로 예외를 처리할 수 있어서 억지로 예외 처리를 하지 않아도된다.
MemoryException는 Exception의 자식이므로 반드시 예외처리해야하지만, RuntimeException으로 wrapping하여 unchecked exception가 될수 있다.
static void startInstall() throws SpaceException, MemoryException {
if (!enoughSpace()) {
throw new SpaceException("Not enough space");
}
if (!enoughMemory()) {
// throw new MemoryException("Not enough memory");
throw new RuntimeException(new MemoryException("Not engouth memory"));
}
}
이전의 initCause메서드가 아닌 RuntimeException의 생성자를 사용하여 원인 예외로 할당할 수 있다.
public RuntimeException(Throwable cause) {
super(cause);
}
이렇게 RuntimeException의 생성자와 객체를 wrapping하면, 메서드 호출자에게 구체적인 예외 세부 사항을 숨기면서도 예외의 원인을 보존할 수 있게 된다.
챰고 자료
- 자바의 정석 (남궁성 지음)
'Java > Java Language' 카테고리의 다른 글
| [Java] Process와 Thread (2) | 2024.09.10 |
|---|---|
| [Java] CLI와 IntelliJ 소스코드 실행 비교 (0) | 2024.07.08 |
| [Java] Exception (re)throwing과 Method throws Declaration (0) | 2024.07.06 |
| [Java] 예외 처리 (Exception Handling) (0) | 2024.07.06 |
| [Java] 프로그램의 에러 (Exception, Error 클래스) (0) | 2024.07.06 |