Java 31

예외 처리, 방향을 찾다: throw, throws, 사용자 정의 RuntimeException

try-catch로 예외를 잡는 것과 throw로 예외를 던지는 것, throws 선언, 사용자 정의 예외가 서로 다른 방향처럼 보여 헷갈린다.

근거 · 교안 p229-p234

예외 처리, 방향을 찾다: throw, throws, 사용자 정의 RuntimeException 대표 이미지

1장: 던지다: throw와 try-catch의 연결고리

1. 던지다: throw와 try-catch의 연결고리

솔라의 시선이 노트북 화면의 한 문장에 머물러 있었다. 강의 노트에 정리해 둔 짧은 문장이었다.

throw는 예외를 직접 발생시키고...

간결하고 명확한 정의처럼 보였지만, 솔라는 어딘가 개운치 않은 기분을 떨칠 수 없었다. 마치 서로 다른 두 개의 퍼즐 조각을 억지로 붙여놓은 듯한 이질감. 코드를 읽을 때마다 느꼈던 혼란이 이 문장 하나에 응축된 것 같았다.

“언니.”

맞은편에서 조용히 책을 읽던 루나가 고개를 들었다.

throwtry-catch 말이야. 꼭 서로 다른 세계 이야기 같아. 한쪽에서는 throw로 일부러 문제를 만들고, 저 멀리 다른 쪽에서는 try-catch로 그걸 수습하고. 둘 사이에 연결된 느낌이 전혀 안 들어. 그냥 각자 자기 할 일만 하는 것 같달까.”

솔라의 말에는 진심 어린 답답함이 묻어 있었다. 예외를 ‘발생시키는’ 행위와 그것을 ‘처리하는’ 행위가 하나의 유기적인 흐름으로 느껴지지 않는다는 것이 문제의 핵심이었다.

루나는 솔라의 말을 잠자코 듣다가, 책상 위에 놓여 있던 펜을 조용히 집어 들었다. 그리고는 별다른 설명 없이 솔라를 향해 가볍게 휙 던졌다.

반사적으로 손을 뻗은 솔라가 가볍게 펜을 받아 들었다. 어리둥절한 표정으로 언니를 쳐다보자, 루나가 차분한 목소리로 물었다.

“방금 내가 뭘 했지?” “펜을 던졌잖아.” “그럼 넌 뭘 했고?” “잡았지.”

솔라는 지극히 당연한 사실을 확인하는 이 대화가 어디로 향하는지 가늠하려 애썼다. 루나가 결정적인 질문을 던졌다.

“내가 펜을 ‘만들었어’, 아니면 ‘던졌어’?”

그제야 솔라는 질문의 의도를 어렴풋이 짐작했다. “…던졌지.”

“맞아.” 루나가 고개를 끄덕였다. “펜이라는 객체는 원래 여기 있었고, 나는 그걸 너라는 다른 공간으로 ‘던졌어’. 그리고 너는 그걸 ‘잡았지’. throw는 예외 객체를 뜬금없이 ‘만들어내는’ 마법이 아니야. 이미 존재하거나, 특정 조건에서 새로 생성된 예외 객체를 코드의 정상적인 흐름 바깥으로 ‘던지는’ 구체적인 행위야.”

루나는 자신의 노트북을 솔라 쪽으로 돌려 짧은 코드 예제를 보여주었다.

class BankAccount {
    private int balance;

    public BankAccount(int initialBalance) {
        this.balance = initialBalance;
    }

    void withdraw(int amount) {
        if (balance < amount) {
            // 문제가 되는 상황을 감지하고, 예외 객체를 생성해서 '던진다'.
            throw new IllegalArgumentException("잔액이 부족합니다.");
        }
        this.balance -= amount;
        System.out.println(amount + "원이 출금되었습니다. 남은 잔액: " + balance);
    }
}

// --- 다른 어딘가, 이 메서드를 호출하는 곳 ---

public class BankService {
    public static void main(String[] args) {
        BankAccount myAccount = new BankAccount(10000);

        try {
            // 출금을 시도한다.
            myAccount.withdraw(15000);
        } catch (IllegalArgumentException e) {
            // withdraw 메서드에서 '던져진' 예외를 여기서 '잡는다'.
            System.out.println("처리 중 문제가 발생했습니다: " + e.getMessage());
        }
    }
}

솔라는 코드를 눈으로 따라가며 방금 전의 펜 던지기를 떠올렸다. withdraw 메서드 안의 if문이 ‘잔액이 부족하다’는 잘못된 상태를 감지하는 순간, IllegalArgumentException이라는 이름의 펜을 만들어 throw라는 팔로 던져버린다. 그렇게 던져진 펜은 메서드 호출이라는 공간을 날아, main 메서드의 try 영역을 벗어나 catch라는 손에 붙잡히는 것이다.

“아!”

솔라의 입에서 작은 탄성이 터져 나왔다.

withdraw 메서드 안에서 throw가 예외 객체를 ‘던지니까’, 그게 메서드 바깥의 catch 블록으로 날아와서 잡히는 거구나! 던지는 행위가 있어야 잡는 행위도 의미가 있네. 이건… 이건 그냥 하나의 흐름이었어!”

마치 단절된 것처럼 보였던 두 키워드가 ‘던지고 잡는다’는 하나의 동작으로 선명하게 이어졌다. throw는 흐름의 시작이었고, try-catch는 그 흐름의 끝에서 기다리는 약속이었다.

솔라는 아까 자신을 답답하게 했던 문장을 다시 떠올렸다. ‘throw는 예외를 직접 발생시키고’.

“이제 알겠다. ‘발생시킨다’는 말이 틀린 건 아니지만, 그건 그냥 출발 신호 같은 거였어. 진짜 중요한 건 ‘던져서’ 호출한 쪽으로 문제를 알리고, catch가 그걸 받아서 처리할 기회를 주는 이 흐름 자체였네.”

퍼즐 조각이 제자리를 찾은 듯 시야가 환해졌다. 하지만 그와 동시에 새로운 그림자가 드리워졌다. 솔라가 고개를 갸웃하며 새로운 의문을 꺼냈다.

“잠깐만. throw가 예외 객체를 ‘던지는’ 실제 액션이라면, 이름이 비슷한 throws는 도대체 뭐야? 그것도 던진다는 뜻이잖아. 이건 또 어떻게 다른 거지?”

2장: 선언하다: throws와 예외 위임의 약속

솔라의 질문이 방 안의 고요함을 가르자, 루나는 대답 대신 노트북을 다시 자신 쪽으로 돌렸다. 이전의 은행 계좌 예제는 그대로 둔 채, 새로운 코드 파일을 하나 열었다. 간결하지만 낯선 메서드 하나가 화면을 채웠다. 솔라는 언니의 손가락이 키보드 위에서 움직이는 것을 조용히 지켜보았다.

import java.io.File;
import java.io.IOException;

class FileReader {
    // 파일을 읽는 시늉만 하는 메서드
    public void readFile(String filePath) /* 여기에 무언가 빠졌다 */ {
        System.out.println(filePath + " 파일을 읽기 시작합니다.");

        // 파일이 실제로 존재하지 않는다고 가정하고, 예외를 발생시킨다.
        throw new IOException("파일을 찾을 수 없습니다.");
    }
}

코드가 완성되자마자, throw new IOException(...) 부분에 선명한 빨간 밑줄이 그어졌다. IDE가 보내는 명백한 경고 신호였다.

“어, 컴파일 에러네.”

솔라가 무심코 중얼거렸다.

IOException… 파일 입출력할 때 나는 예외잖아. throw로 예외를 던졌는데, 왜 에러가 나는 거지? 아까 IllegalArgumentException은 괜찮았는데.”

솔라는 자신이 겪었던 두 상황의 차이점을 포착했다. 이전 장의 은행 계좌 예제에서는 throw를 사용해도 아무런 문제가 없었다. 하지만 지금은 달랐다. 컴파일러가 단호하게 코드 실행을 막아서고 있었다.

readFile 메서드 안에서 try-catch로 감싸면 해결되겠지. 하지만…”

솔라가 해결책을 떠올리다 말고 말을 멈췄다. 그건 throw를 쓴 의미를 없애는 행위였다. 파일을 읽다가 생긴 문제를 이 메서드 안에서 조용히 덮어버리는 꼴이 되기 때문이다. 문제를 호출한 쪽에 알리고 싶다는 본래 목적과 어긋났다.

루나가 솔라의 고민을 읽은 듯, 말없이 readFile 메서드 선언부의 주석 처리된 공간을 클릭했다. 그리고 키보드를 몇 번 두드렸다.

// public void readFile(String filePath) throws IOException { // 이렇게 바꾸면?

솔라가 고개를 끄덕이자, 루나는 주석을 지우고 코드를 완성했다.

public void readFile(String filePath) throws IOException {
    // ...
    throw new IOException("파일을 찾을 수 없습니다.");
}

마법처럼 throw 구문의 빨간 밑줄이 사라졌다. 하지만 변화는 거기서 끝나지 않았다. 이 readFile 메서드를 호출하는 다른 코드 영역에 새로운 빨간 밑줄이 나타났다. 마치 에러가 이사라도 간 듯했다.

“아!”

솔라는 그제야 모든 조각이 맞춰지는 것을 느꼈다.

throws는 ‘나는 이 예외를 직접 처리하지 않을 테니, 나를 호출한 네가 책임져!’라고 알리는 거구나! 이건 예외를 던지는 행위가 아니라, 책임을 넘기겠다는 ‘선언’이고 ‘약속’이었어.”

throw가 펜을 던지는 실제 동작이라면, throws는 ‘이 구역에서는 펜이 날아다닐 수 있으니 주의하시오’라고 입구에 붙여놓은 경고문과 같았다. IOException처럼 반드시 확인하고 넘어가야 하는 ‘체크 예외(Checked Exception)’의 경우, 이 경고문은 선택이 아닌 필수였다. 컴파일러는 이 약속이 지켜지는지 철저하게 감시하고 있었던 것이다.

“그럼 아까 IllegalArgumentException은 왜 경고문이 필요 없었어?”

솔라의 예리한 질문에, 루나는 다시 코드를 수정했다. IOExceptionRuntimeException의 일종인 IllegalArgumentException으로 바꾸고, 메서드 시그니처의 throws 구문을 다시 지웠다.

public void readFile(String filePath) {
    // ...
    throw new IllegalArgumentException("유효하지 않은 파일 경로입니다.");
}

놀랍게도, 이번에는 어디에도 빨간 밑줄이 나타나지 않았다. 컴파일러는 침묵했다.

IllegalArgumentException 같은 ‘언체크 예외(Unchecked Exception)’는 개발자의 논리적인 실수일 가능성이 높은 예외들이야. 예를 들면, 0으로 나누려 하거나, 없는 배열 인덱스에 접근하는 경우지.”

루나가 차분하게 설명했다.

“이런 예외들은 프로그램 곳곳에서 터져 나올 수 있어. 이걸 전부 throws로 선언하게 강제한다면, 코드는 온통 throws 선언으로 뒤덮여 버릴 거야. 그래서 자바는 ‘네가 원하면 잡을 수 있지만, 굳이 경고문(throws)을 붙이라고 강요하진 않을게’라는 정책을 쓰는 거지.”

이제 솔라는 throwthrows의 관계를 명확하게 구분할 수 있었다. 둘 다 예외를 다루지만, 역할의 차원은 완전히 달랐다.

  • throw: 예외 객체를 던지는 실행문. 흐름을 중단시키는 액션.
  • throws: 예외를 떠넘길 수 있다고 알리는 선언문. 메서드와 호출자 사이의 계약.

“던지는 건 throw의 역할이고, throws는 그럴 수 있다고 알려주는 안내판이었네. 이제 throws는 메서드가 예외를 던질 수 있음을 선언한다는 문장이 완전히 다르게 읽혀.”

혼란을 유발했던 문장은 이제 당연하고 명료한 사실이 되었다. 하지만 솔라의 머릿속에는 또 다른 호기심이 고개를 들었다.

“그런데 언니, 아까 은행 계좌 예제에서도 그렇고, 방금 예제에서도 자바가 미리 만들어 놓은 예외들을 썼잖아. IllegalArgumentException처럼. 근데 만약 ‘잔액 부족’이라는 상황을 좀 더 명확하게 표현하고 싶으면 어떡해? 그냥 IllegalArgumentException이라고 하면 의미가 모호하잖아. ‘회원 탈퇴 실패’나 ‘중복된 아이디’ 같은 우리만의 예외를 만들어서 쓰고 싶은데. 그럴 땐 어떻게 해야 하지?”

3장: 의미를 입히다: 사용자 정의 RuntimeException의 가치

솔라의 질문은 더 이상 막연한 호기심이 아니었다. ‘잔액 부족’, ‘중복된 아이디’. 이런 구체적인 실패 상황들은 자바가 미리 만들어 놓은 예외 이름만으로는 충분히 담아낼 수 없는, 그들만의 비즈니스 맥락을 품고 있었다. 그 의미를 코드에 어떻게 녹여낼 수 있을까?

루나는 이전 장에서 함께 보았던 BankAccount 코드를 화면에 다시 띄웠다. 하지만 이번에는 무언가 달라져 있었다. withdraw 메서드가 두 개로 나란히 놓여 있었다. 루나는 솔라가 차이점을 발견할 때까지 조용히 기다렸다.

// Version A: 기존 방식
void withdraw_A(int amount) {
    if (balance < amount) {
        throw new IllegalArgumentException("잔액이 부족합니다.");
    }
    this.balance -= amount;
    System.out.println(amount + "원이 출금되었습니다.");
}

// Version B: 의미를 담으려는 시도
void withdraw_B(int amount) {
    if (balance < amount) {
        // '잔액 부족'이라는 상황을 더 명확하게 표현하고 싶다면?
        throw new IllegalArgumentException("ERROR_CODE_101: INSUFFICIENT_BALANCE");
    }
    this.balance -= amount;
    System.out.println(amount + "원이 출금되었습니다.");
}

솔라는 두 번째 메서드인 withdraw_B를 유심히 살폈다. “아, 메시지를 바꿨구나. 에러 코드처럼 만들어서 어떤 종류의 실패인지 구별하려는 거네. 이렇게 하면 catch 블록에서 메시지 문자열을 분석해서 ‘아, 잔액 부족 에러구나’ 하고 알 수 있겠지.”

그럴듯한 해결책처럼 보였다. 하지만 솔라는 곧 미간을 찌푸렸다.

“근데… 좀 이상해. catch 블록에서 문자열을 확인해야 하잖아. 만약 다른 개발자가 메시지를 INSUFFICIENT_FUNDS라고 바꾸면 코드가 망가지겠네. 너무 불안정한 방법 같아.”

솔라는 문제의 핵심을 정확히 짚어냈다. 예외 메시지는 사람을 위한 설명이지, 프로그램의 로직이 의존해야 할 약속이 아니었다.

루나는 솔라의 말에 동의하며, 완전히 새로운 클래스 파일을 하나 만들었다. 파일 내용은 놀랍도록 간결했다.

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

“이게 다야?” 솔라가 물었다. “그냥 RuntimeException을 상속받은 텅 빈 클래스잖아. 이걸로 뭘 할 수 있는데?”

솔라의 눈에는 여전히 이것이 불필요한 추가 작업처럼 보였다. 새로운 이름을 가진 클래스를 만드는 것 이상의 의미를 찾기 어려웠다. 루나는 대답 대신, withdraw_B 메서드를 다시 수정하여 이 새로운 클래스를 사용하도록 바꾸었다.

// Version C: 새로운 타입을 사용한 방식
void withdraw_C(int amount) {
    if (balance < amount) {
        throw new InsufficientBalanceException("잔액이 부족합니다.");
    }
    this.balance -= amount;
    System.out.println(amount + "원이 출금되었습니다.");
}

그리고 이 메서드를 호출하는 쪽의 코드를 나란히 비교해서 보여주었다. 솔라가 불안정하다고 지적했던 ‘메시지 분석 방식’과 새로운 ‘타입 활용 방식’의 차이가 극명하게 드러났다.

방법 1: IllegalArgumentException의 메시지에 의존하는 경우

try {
    myAccount.withdraw_B(15000);
} catch (IllegalArgumentException e) {
    if (e.getMessage().contains("INSUFFICIENT_BALANCE")) {
        System.out.println("계좌 잔액을 확인해주세요. 특정 복구 로직을 수행합니다.");
    } else {
        System.out.println("알 수 없는 입력값 오류입니다.");
    }
}

방법 2: InsufficientBalanceException 타입을 사용하는 경우

try {
    myAccount.withdraw_C(15000);
} catch (InsufficientBalanceException e) {
    System.out.println("계좌 잔액을 확인해주세요. 특정 복구 로직을 수행합니다.");
} catch (SomeOtherBusinessException e) {
    // 또 다른 비즈니스 예외 처리
}

비교 코드를 본 순간, 솔라의 입에서 탄성이 터져 나왔다.

“아! 알겠다! 이건 그냥 이름만 바꾼 게 아니었어. 완전히 새로운 **종류(type)**의 예외를 정의한 거구나!”

그제야 모든 것이 명확해졌다. IllegalArgumentException은 ‘인자가 유효하지 않다’는 기술적인 사실만을 전달한다. 그 안에서 ‘잔액 부족’ 때문인지, ‘음수 금액 입력’ 때문인지 알려면 불안정한 문자열에 기댈 수밖에 없었다.

하지만 InsufficientBalanceException은 그 존재 자체가 ‘잔액이 부족하다’는 비즈니스 규칙이 깨졌음을 의미했다. catch 블록은 더 이상 메시지를 파싱할 필요 없이, 예외의 ‘타입’을 확인하는 것만으로 어떤 문제가 발생했는지 100% 확신하고 그에 맞는 일을 할 수 있었다. 이는 마치 다른 사람에게 ‘경고등’을 주는 것과 ‘빨간색 경고등’을 주는 것의 차이와 같았다. 후자는 그 색깔만으로도 ‘위험’이라는 의미를 즉시 전달한다.

“그러니까 throw는 예외를 던지는 실행문, throws는 책임을 위임하는 선언문, 그리고 우리가 직접 만든 RuntimeException은 그 던져지는 객체에 명확한 비즈니스 의미를 부여하는 거였어. 이 세 가지가 합쳐져서 하나의 유기적인 소통 시스템을 만드는 거네.”

솔라는 이제 예외 처리가 단순히 오류를 막는 기술이 아니라, 애플리케이션의 여러 부분이 서로 소통하는 정교한 언어라는 것을 깨달았다. 특히 비즈니스 로직의 실패를 명확하게 표현하는 데 사용자 정의 예외가 얼마나 강력한 도구인지 실감했다.

문득 처음 자신을 혼란스럽게 했던 문장이 떠올랐다. ‘throw는 예외를 직접 발생시키고, throws는 메서드가 예외를 던질 수 있음을 선언한다.’ 이제 이 문장은 너무도 당연한 기초처럼 느껴졌다. 그 문장이 설명하는 것은 문법이라는 뼈대였고, 오늘 깨달은 사용자 정의 예외는 그 뼈대에 살과 의미를 붙여 살아있는 코드를 만드는 핵심이었다.

스스로의 이해를 확인해보고 싶어진 솔라는 작은 노트를 꺼내 새로운 시나리오를 설계하기 시작했다. ‘회원가입 시 중복된 아이디가 있을 경우’에 대한 예외 처리 흐름이었다.

[비즈니스 예외 처리 흐름 설계 초안 - 중복 아이디]

  1. 예외 정의: UsernameAlreadyExistsException 클래스를 만든다. RuntimeException을 상속한다.

    class UsernameAlreadyExistsException extends RuntimeException { ... }
  2. 예외 발생(throw): registerUser 메서드 내에서, DB 조회 후 아이디가 이미 존재하면 throw new UsernameAlreadyExistsException(username); 코드를 실행한다.

  3. 예외 처리(catch): registerUser를 호출한 컨트롤러에서 try-catch로 감싼다.

    catch (UsernameAlreadyExistsException e) {
        // 사용자에게 '이미 사용 중인 아이디입니다' 라는 메시지를 보여준다.
    }

자신이 설계한 간결한 흐름을 보며 솔라는 미소 지었다. throw, throws, 그리고 의미를 담은 사용자 정의 예외. 흩어져 있던 점들이 마침내 하나의 선으로 이어져, 문제 상황을 명확하게 알리고 대처하는 견고한 길을 만들어내고 있었다.