문서 읽는 데 62분 · day22

Day 22 — 예외 처리 ② 던지고 전파 (throw / throws, checked vs unchecked)

목차 18
전체 33강 중 22강 · 자바 기초
난이도 · 입문

지난 시간 우리는 예외를 try-catch 로 "잡는" 법을 배웠어요. ObjectBox·Repository·Stack 이 던진 예외를 우리가 받아냈죠. 그런데 가만 보면 이상한 점이 있었어요. Day 20 에서 만든 Repository.findById 는 없는 id 를 만나면 throw new IllegalArgumentException(...) 으로 예외를 직접 "던지고" 있었고, Stack.pop 도 빈 스택에서 throw new IllegalStateException(...) 을 던졌어요.

그러니까 우리는 이미 예외를 던지는 코드를 적어두고도, "잡는" 쪽만 배웠던 거예요. 오늘은 그 반대편, 예외를 직접 던지는(throw) 쪽으로 넘어가요.

오늘 주인공은 throw(예외를 직접 던지기)와 throws(예외를 호출자에게 전파하기) 두 가지예요. 지난 시간 잠깐 미뤄둔 checked 와 unchecked 의 진짜 차이도 정면으로 다뤄요. 그리고 "이 사고는 내가 잡고, 저 사고는 위로 올려보낸다" 는 판단까지 익혀요. 지난 시간이 받는 쪽이었다면, 오늘은 던지는 쪽이 되는 거예요. 자, Day 22 시작해봐요!

🎯 학습 목표

  • throw 로 내가 직접 예외를 던져서 잘못된 값을 막을 수 있어요.
  • checked 예외와 unchecked 예외의 차이(컴파일러가 처리를 강제하는지)를 구분할 수 있어요.
  • throws 로 예외를 호출자에게 전파할 수 있고, throwthrows 의 차이를 설명할 수 있어요.
  • 아무도 안 잡은 예외가 콜스택을 거슬러 올라가는 과정을 그림으로 그릴 수 있어요.
  • "여기서 잡을까, 위로 던질까" 를 상황에 따라 판단할 수 있어요.
  • 던지고(throw)·전파하고(throws)·받는(try-catch) 전체 흐름을 하나로 엮을 수 있어요.

Step 1: throw — 이제 내가 직접 던진다

지난 시간엔 예외를 "받는" 쪽이었어요. 오늘은 반대로, 내가 직접 예외를 "던지는" 쪽이에요. 그런데 사실 우리는 던지는 코드를 이미 본 적이 있어요.

Day 20 에서 만든 Repository.findById 를 다시 볼게요. 없는 id 를 찾으면 이렇게 했었죠.

Java
// com/instagram/javabasic/generic/Repository.java (Day 20)
public T findById(Long id) {
    T found = store.get(id);
    if (found == null) {
        throw new IllegalArgumentException("id " + id + " 에 해당하는 항목이 없어요.");
    }
    return found;
}

throw new IllegalArgumentException(...) — 이게 바로 예외를 직접 던지는 코드예요. 지난 시간엔 이 메서드를 try-catch 로 받기만 했는데, 오늘은 이 던지는 문장 자체를 우리가 써봐요.

throw(스로우, "던지다") 는 "여기서부터는 정상이 아니야!" 하고 예외를 직접 만들어 던지는 명령이에요. 문법은 간단해요.

텍스트
 throw new 예외종류("무엇이 잘못됐는지 메시지");
        └──┬──┘     └──────────┬──────────────┘
       예외 객체를         왜 잘못됐는지
       하나 새로 만들어     설명을 담아서

이 한 줄이 실행되면 두 가지 일이 한 번에 일어나요. 첫째, 메서드가 그 줄에서 즉시 멈춰요(뒤에 코드가 있어도 실행 안 됨). 둘째, 만든 예외가 이 메서드를 부른 쪽으로 튀어 올라가요.

이제 직접 던져볼게요. 인스타그램 캡션(게시물에 다는 글) 길이를 검사하는 코드예요. 인스타는 캡션을 2,200자까지만 허용해요. 한도를 넘으면 그냥 통과시키지 않고 throw 로 막을 거예요.

Java
// com/instagram/javabasic/exceptionbasic/ThrowStatement.java
public class ThrowStatement {

    // 인스타 캡션 최대 길이예요. 2200자까지만 쓸 수 있어요.
    public static final int MAX_CAPTION_LENGTH = 2200;

    // 캡션을 검사해서 통과하면 그대로 돌려주고, 규칙에 어긋나면 throw 로 막아요.
    // 빈 캡션이거나 한도를 넘으면 IllegalArgumentException 을 던져요.
    public String validateCaption(String caption) {
        if (caption == null || caption.isEmpty()) {
            throw new IllegalArgumentException("캡션이 비어 있어요.");
        }
        if (caption.length() > MAX_CAPTION_LENGTH) {
            throw new IllegalArgumentException("캡션은 2200자까지예요. 지금은 " + caption.length() + "자.");
        }
        return caption;
    }
}

흐름을 따라가볼게요. 캡션이 비어 있으면 첫 번째 if 에서 throw 가 실행돼 메서드가 즉시 멈춰요. 길이가 2,200자를 넘으면 두 번째 if 에서 막혀요. 두 검사를 다 통과해야 비로소 마지막 줄 return caption; 까지 도착해요.

텍스트
 validateCaption("...") 안에서 벌어지는 일

   caption 비었나?  ──예──▶ throw! 💥 (여기서 멈춤, 위로 튐)
        │ 아니오
        ▼
   2200자 넘나?    ──예──▶ throw! 💥 (여기서 멈춤, 위로 튐)
        │ 아니오
        ▼
   return caption  ✅ (검사 통과, 무사히 돌려줌)

이제 main 에서 두 경우를 다 확인해볼게요. 정상 캡션과 한도를 넘긴 캡션을 둘 다 넣어봐요. 던진 예외는 지난 시간 배운 try-catch 로 받아요.

Java
public static void main(String[] args) {
    ThrowStatement validator = new ThrowStatement();

    // 정상 — 짧은 캡션은 그대로 통과해요.
    String ok = validator.validateCaption("오늘 점심 인증 #맛집");
    System.out.println("통과한 캡션: " + ok);

    // 사고 — 한도를 넘는 캡션은 throw 로 막혀요. try-catch 로 받아 메시지를 보여줘요.
    StringBuilder tooLong = new StringBuilder();
    for (int i = 0; i < MAX_CAPTION_LENGTH + 1; i++) {
        tooLong.append("가");
    }
    try {
        validator.validateCaption(tooLong.toString());
    } catch (IllegalArgumentException e) {
        System.out.println("막혔어요: " + e.getMessage());
    }
}

실행하면 정상 캡션은 "통과한 캡션: ..." 이 찍히고, 2,201자짜리 긴 캡션은 throw 에 막혀 "막혔어요: 캡션은 2200자까지예요. 지금은 2201자." 가 나와요. 우리가 던진 예외를, 우리가 잡은 거예요.

💡 튜터의 결론

throw new 예외종류("메시지") 한 줄이면 내가 직접 예외를 던질 수 있어요. 그 줄에서 메서드는 즉시 멈추고, 예외는 부른 쪽으로 튀어 올라가요. Day 20 의 Repository.findById·Stack.pop 도 사실 이렇게 예외를 던지고 있었어요. 잘못된 값을 그냥 통과시키지 않고 "이건 안 돼요!" 하고 분명히 막는 게 throw 의 역할이에요.


Step 2: checked vs unchecked — 컴파일러가 강제하는 예외, 안 하는 예외

지난 시간 예외의 족보를 그릴 때 제가 한 가지를 미뤄뒀어요. 예외에는 사실 성격이 다른 두 종류가 있는데, 그 차이를 오늘 정리해요.

두 종류의 이름은 unchecked(언체크) 예외와 checked(체크) 예외예요. 이름이 어려워 보이지만 차이는 딱 하나예요. 컴파일러가 "이 예외 처리했어?" 하고 강제로 확인하느냐, 안 하느냐.

텍스트
                 Throwable (맨 윗조상)
                 ├── Error                 (손쓸 수 없는 심각한 사고)
                 └── Exception  ◀── 이 갈래가 기본은 checked (처리 강제)
                     └── RuntimeException ◀── 이 아래만 unchecked (강제 안 함)

지난 시간 만난 IllegalArgumentException·IllegalStateException·ClassCastException 은 모두 RuntimeException 자손이었죠? 그래서 전부 unchecked 예요. 반대로 Exception 바로 아래(RuntimeException 을 거치지 않는) 예외들은 checked 예요.

말로만 하면 안 와닿으니 두 종류를 나란히 만들어 차이를 직접 느껴볼게요.

Java
// com/instagram/javabasic/exceptionbasic/CheckedVsUnchecked.java
public class CheckedVsUnchecked {

    // unchecked 예외 — throws 선언이 없어요. 그래도 컴파일이 돼요.
    // 나이가 음수면 IllegalArgumentException 을 던져요.
    public void uncheckedExample(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("나이는 음수일 수 없어요: " + age);
        }
        System.out.println("나이 확인 완료: " + age);
    }

    // checked 예외 — 메서드 뒤에 throws Exception 을 꼭 적어야 컴파일돼요.
    // 컴파일러가 "이 예외는 호출하는 쪽이 반드시 처리해야 해" 라고 강제하는 거예요.
    public void checkedExample(boolean fail) throws Exception {
        if (fail) {
            throw new Exception("처리를 강제하는 checked 예외예요.");
        }
        System.out.println("checked 작업 성공!");
    }
}

두 메서드를 비교해보세요. uncheckedExampleIllegalArgumentException(unchecked)을 던지는데 메서드 뒤에 아무 선언이 없어요. 그래도 컴파일이 잘 돼요.

그런데 checkedExampleException(checked)을 던지는데, 메서드 이름 뒤에 throws Exception 이 붙어 있죠? 이게 없으면 컴파일러가 빨간 줄을 그으며 "이 checked 예외를 처리하든지, 넘기든지 둘 중 하나는 해!" 하고 막아요. 컴파일조차 안 되는 거예요.

텍스트
 같은 throw 인데 컴파일러 반응이 달라요

   unchecked (IllegalArgumentException)
     throws 선언 없음  →  컴파일 OK ✅  (강제 안 함)

   checked (Exception)
     throws 선언 없음  →  컴파일 에러 ❌  (처리하라고 강제!)
     throws Exception  →  컴파일 OK ✅

main 에서도 이 차이가 그대로 드러나요.

Java
// checkedExample 을 부르려면 main 에도 throws Exception 이 필요해요(전파).
public static void main(String[] args) throws Exception {
    CheckedVsUnchecked demo = new CheckedVsUnchecked();

    // unchecked — 정상 호출. throws 없이 그냥 부르면 돼요.
    demo.uncheckedExample(25);

    // checked — fail=false 면 성공해요. throws Exception 이 있어 그냥 부를 수 있어요.
    demo.checkedExample(false);
}

main 뒤에도 throws Exception 이 붙은 게 보이시죠? checked 예외를 던지는 checkedExample 을 부르니까, main 도 "나도 이 예외를 안 잡고 넘길래" 라고 선언해야 컴파일이 돼요. unchecked 인 uncheckedExample 은 그런 선언 없이 그냥 불러요.

🙋 학생 질문 — "튜터님, 그럼 checked 예외는 언제 쓰나요? 매번 throws 적는 게 귀찮은데요."

좋은 질문이에요. 보통 이렇게 나눠서 생각하면 편해요.

  • unchecked 는 "코드를 짠 사람의 실수" 로 생기는 예외에 써요. 음수 나이, 잘못된 형변환, 없는 id 조회 같은 것들요. 코드를 고치면 안 나는 사고라 컴파일러가 굳이 강제하지 않아요.
  • checked 는 "바깥 세계 사정으로 실패할 수 있는" 상황에 써요. 예를 들어 파일을 여는데 그 파일이 없거나, 네트워크가 끊기는 경우요. 내 코드가 멀쩡해도 실행할 때 실패할 수 있으니, 컴파일러가 "이건 꼭 대비해둬!" 하고 강제하는 거예요.

오늘은 표준 Exception 으로 checked 의 강제 성격만 체험했어요. 실무에서는 보통 의미가 분명한 이름의 예외를 직접 만들어 쓰는데, 그건 다음 시간(Day 23)에 배워요.

💡 튜터의 결론

예외는 두 종류예요. RuntimeException 자손은 unchecked — 컴파일러가 처리를 강제하지 않아요. 그 외 Exception 자손은 checked — 메서드에 throws 를 적거나 try-catch 로 잡지 않으면 컴파일조차 안 돼요. unchecked 는 "내 실수", checked 는 "바깥 사정으로 실패할 수 있는 일" 이라고 기억하면 편해요.


Step 3: throws — 안 잡고 호출자에게 넘기기

예외를 만난 메서드에는 두 가지 선택지가 있어요.

  1. 내가 직접 try-catch 로 잡는다 (지난 시간에 배운 것)
  2. 내가 안 잡고 "호출한 너가 처리해" 하고 넘긴다 (이번 시간!)

2번을 하려면 Step 2 에서 슬쩍 본 throws 를 써요. throws(스로우즈)는 메서드 이름 뒤에 붙여서 "이 메서드는 이런 예외를 넘길 수 있어요" 라고 알리는 표지판이에요.

텍스트
   void follow(String me, String target) throws Exception {
                                          └──────┬───────┘
                                    "나는 이 예외를 안 잡고
                                     부른 쪽에 넘길 수 있어"
                                     라는 표지판

표지판을 붙여두면, 이 메서드 안에서 예외를 잡지 않아도 돼요. 대신 이 메서드를 부른 쪽이 try-catch 로 받든지, 아니면 그쪽도 throws 로 또 넘기든지 해야 해요.

자기 자신을 팔로우하려는 경우를 막는 코드로 확인해볼게요. 인스타에서 내가 나를 팔로우할 수는 없잖아요? 그런데 이번엔 그 예외를 follow 메서드 안에서 잡지 않고, throws 로 호출자에게 넘겨요.

Java
// com/instagram/javabasic/exceptionbasic/ThrowsDeclaration.java
public class ThrowsDeclaration {

    // 팔로우 — 자기 자신은 팔로우할 수 없어요.
    // 여기서 잡지 않고 throws Exception 으로 호출한 쪽에 넘겨요.
    public void follow(String me, String target) throws Exception {
        if (me.equals(target)) {
            throw new Exception("자기 자신은 팔로우할 수 없어요.");
        }
        System.out.println(me + " 님이 " + target + " 님을 팔로우했어요.");
    }
}

follow 메서드는 자기 자신을 팔로우하려 하면 throw new Exception(...) 으로 던지기만 하고, try-catch 로 잡지 않아요. 대신 throws Exception 표지판을 붙였죠. "나는 이 예외를 안 잡으니, 부른 쪽에서 처리해" 라는 뜻이에요.

여기서 throwthrows 가 같이 등장하는데, 한 글자 차이라 정말 헷갈려요. 확실히 구분하고 가요.

텍스트
 throw  vs  throws  — 한 글자 차이, 완전히 다른 역할

   throw  (s 없음)  : 문장. 예외를 지금 "던지는" 동작.
                      → throw new Exception("...");

   throws (s 있음)  : 선언. 메서드가 예외를 "넘길 수 있다" 는 표지판.
                      → void follow(...) throws Exception { ... }

그러면 이 예외는 누가 처리할까요? follow 를 부른 main 이에요. follow 가 throws 로 넘겼으니, main 이 try-catch 로 받아야 해요.

Java
public static void main(String[] args) {
    ThrowsDeclaration service = new ThrowsDeclaration();

    // 정상 — 서로 다른 사람이라 팔로우가 성공해요.
    // follow 가 예외를 넘길 수 있으니, 부른 쪽인 여기서 try-catch 로 받아요.
    try {
        service.follow("minji", "jaehoon");
    } catch (Exception e) {
        System.out.println("실패: " + e.getMessage());
    }

    // 사고 — 자기 자신을 팔로우하면 follow 가 던진 예외가 여기까지 넘어와요.
    try {
        service.follow("minji", "minji");
    } catch (Exception e) {
        System.out.println("실패: " + e.getMessage());
    }
}

"minji" 가 "jaehoon" 을 팔로우하면 성공 메시지가 찍히고, "minji" 가 "minji" 를 팔로우하면 follow 가 던진 예외가 main 까지 넘어와 "실패: 자기 자신은 팔로우할 수 없어요." 가 나와요. 던지는 곳(follow)과 잡는 곳(main)이 분리된 거예요.

💡 튜터의 결론

예외를 만난 메서드는 직접 잡거나(try-catch), 안 잡고 넘기거나(throws) 둘 중 하나를 골라요. throws 는 메서드 뒤에 붙는 "이 예외를 넘길 수 있어요" 표지판이에요. throw(동작, s 없음)와 throws(선언, s 있음)는 한 글자 차이지만 역할이 완전히 달라요. 넘긴 예외는 결국 부른 쪽이 책임지고 처리해요.


Step 4: 예외가 콜스택을 거슬러 오른다 — 전파의 그림

Step 3 에서 follow 가 던진 예외가 main 까지 "넘어왔다" 고 했죠. 그런데 메서드가 여러 겹으로 깊게 호출되면, 예외는 정확히 어디로 갈까요? 이번엔 그 이동 경로를 그림으로 그려봐요.

먼저 메서드가 메서드를 부르는 순서를 콜스택(call stack, 호출 더미)이라고 해요. 접시를 쌓듯, 메서드를 부를 때마다 위로 한 칸씩 쌓여요.

텍스트
 콜스택 — 메서드를 부를 때마다 한 칸씩 쌓여요

   main 이 showPage 를 부르고
   showPage 가 renderProfile 을 부르고
   renderProfile 이 loadProfileImage 를 불러요

        ┌─────────────────────┐ ← 가장 깊은 곳 (마지막에 쌓임)
        │  loadProfileImage   │
        ├─────────────────────┤
        │  renderProfile      │
        ├─────────────────────┤
        │  showPage           │
        ├─────────────────────┤
        │  main               │ ← 맨 아래 (제일 먼저 쌓임)
        └─────────────────────┘

이렇게 깊이 들어간 상태에서, 가장 깊은 loadProfileImage 가 예외를 던지면 어떻게 될까요? 중간 메서드들이 잡지 않으면, 예외는 쌓인 순서를 거꾸로 거슬러 올라가요. 이걸 전파(propagation)라고 해요.

프로필 이미지를 불러오는 3단 호출로 확인해볼게요. 이번엔 unchecked 예외(IllegalStateException)를 써서, throws 선언 없이도 예외가 위로 올라가는 모습을 봐요.

Java
// com/instagram/javabasic/exceptionbasic/CallStackPropagation.java
public class CallStackPropagation {

    // 가장 깊은 메서드 — 실제로 예외가 시작되는 곳이에요.
    // 주소가 없으면 IllegalStateException 을 던져요(unchecked 라 throws 가 필요 없어요).
    public String loadProfileImage(String url) {
        if (url == null || url.isEmpty()) {
            throw new IllegalStateException("프로필 이미지 주소가 없어요.");
        }
        return url;
    }

    // 중간 메서드 — 예외를 잡지 않고 그대로 통과만 시켜요. 예외는 여기를 그냥 지나가요.
    public String renderProfile(String url) {
        return loadProfileImage(url);
    }

    // 또 다른 중간 메서드 — 역시 잡지 않고 통과만. 예외가 한 칸 더 위로 올라가요.
    public String showPage(String url) {
        return renderProfile(url);
    }
}

renderProfileshowPage 를 보세요. 둘 다 try-catch 가 없어요. 그냥 다음 메서드를 부르기만 해요. 그래서 가장 깊은 loadProfileImage 가 예외를 던지면, 두 중간 메서드는 그저 통과 통로가 되고 예외는 계속 위로 올라가요.

텍스트
 loadProfileImage 가 throw! 💥  예외가 거슬러 올라가요

   loadProfileImage  💥 throw  ──┐
   renderProfile     (안 잡음)   │ 예외가
   showPage          (안 잡음)   │ 위로
   main              🧤 catch! ◀─┘ 여기서 드디어 잡힘

main 에서 try-catch 로 받으면, 비로소 예외가 멈춰요.

Java
public static void main(String[] args) {
    CallStackPropagation page = new CallStackPropagation();

    // 정상 — 주소가 있으면 맨 깊은 곳에서 끝까지 잘 돌아와요.
    System.out.println("정상: " + page.showPage("profile.jpg"));

    // 사고 — null 을 넣으면 맨 깊은 loadProfileImage 에서 예외가 터져
    // renderProfile → showPage 를 거슬러 올라와 여기 main 의 catch 에서 잡혀요.
    try {
        page.showPage(null);
    } catch (IllegalStateException e) {
        System.out.println("맨 위(main)에서 잡았어요: " + e.getMessage());
    }
}

page.showPage(null) 을 부르면, 호출은 showPage → renderProfile → loadProfileImage 순서로 깊이 내려갔다가, 예외는 그 반대로 거슬러 올라와 main 에서 잡혀요. 만약 main 마저 안 잡았다면? 예외는 프로그램 밖까지 튀어나가 프로그램이 멈추고 빨간 에러 메시지가 출력돼요.

💡 튜터의 결론

예외를 아무도 안 잡으면, 호출한 순서를 거꾸로 거슬러 올라가요. 이게 전파(propagation)예요. 중간 메서드들이 잡지 않으면 그냥 통과 통로가 되고, 누군가 try-catch 로 잡을 때까지 계속 올라가요. 끝까지 아무도 안 잡으면 프로그램이 멈춰요. unchecked 예외는 throws 선언 없이도 이렇게 자동으로 전파돼요.


Step 5: 잡을 곳 vs 던질 곳 — 예외 처리 전략

이제 가장 중요한 판단을 배워요. 예외를 만났을 때 "여기서 잡을까, 아니면 위로 던질까?" 를 어떻게 정할까요?

기준은 의외로 단순해요. "내가 지금 이 문제를 제대로 해결할 수 있는가?" 예요.

  • 해결할 수 있으면(어떻게 복구할지 안다면) → 여기서 잡아요.
  • 해결할 수 없으면(무엇이 옳은 대응인지 모른다면) → 잡지 말고 위로 던져요. 더 잘 아는 쪽이 처리하게요.

같은 일을 두 방식으로 나란히 만들어 비교해볼게요. 사용자가 입력한 "보고 싶은 게시물 개수" 글자를 숫자로 바꾸는 상황이에요. 자바의 Integer.parseInt 는 숫자가 아닌 글자를 만나면 NumberFormatException 을 던져요.

Java
// com/instagram/javabasic/exceptionbasic/HandleOrThrow.java
public class HandleOrThrow {

    // 던지는 쪽 — 숫자로 못 바꾸면 NumberFormatException 이 그대로 위로 전파돼요.
    // 여기선 "무엇이 옳은 기본값인지" 를 모르니 굳이 잡지 않고 호출자에게 맡겨요.
    public int parseLimit(String raw) {
        return Integer.parseInt(raw);
    }

    // 잡는 쪽 — parseLimit 을 try-catch 로 감싸, 변환이 실패하면 기본값 30 으로 복구해요.
    // 인스타 해시태그 한도와 같은 30 을 안전한 기본값으로 써요.
    public int parseLimitOrDefault(String raw) {
        try {
            return parseLimit(raw);
        } catch (NumberFormatException e) {
            return 30;
        }
    }
}

두 메서드의 태도가 완전히 달라요.

parseLimit 은 "던지는 쪽" 이에요. 숫자 변환만 할 뿐, 변환이 실패했을 때 무엇이 옳은 값인지 몰라요. 그래서 굳이 잡지 않고 예외를 그대로 위로 흘려보내요. 판단은 더 잘 아는 쪽에 맡기는 거예요.

parseLimitOrDefault 는 "잡는 쪽" 이에요. 여기서는 "변환이 실패하면 기본값 30 을 쓰면 된다" 는 복구 방법을 알아요. 복구할 줄 아니까 try-catch 로 잡아서 30 을 돌려줘요. 화면이 깨지지 않게 안전하게 받쳐주는 거예요.

텍스트
 같은 예외, 다른 선택

   parseLimit          : 어떻게 복구할지 모름 → 안 잡고 던짐 (위로 전파)
   parseLimitOrDefault : 기본값 30 으로 복구 가능 → 여기서 잡음

main 으로 확인해볼게요.

Java
public static void main(String[] args) {
    HandleOrThrow parser = new HandleOrThrow();

    // 정상 — 숫자 글자는 그대로 변환돼요.
    System.out.println("정상 입력 \"50\" → " + parser.parseLimitOrDefault("50"));

    // 사고 — 숫자가 아닌 글자는 변환에 실패하지만, 잡는 쪽이 기본값 30 으로 복구해요.
    System.out.println("잘못된 입력 \"열개\" → " + parser.parseLimitOrDefault("열개"));
}

"50" 은 그대로 50 으로 변환되고, "열개" 는 변환에 실패하지만 잡는 쪽이 기본값 30 으로 받쳐줘서 프로그램이 멈추지 않아요.

한 가지 더 생각해볼 거리를 남겨둘게요. 만약 잡는 쪽에서 예외를 "다른 예외로 바꿔서" 다시 던지고 싶다면? 그때 원래 원인은 어떻게 보존할까요? 이 이야기는 다음 시간(Day 23) 예외 체이닝에서 다뤄요. 지금은 "잡을지 던질지" 를 판단하는 감각만 챙기면 충분해요.

💡 튜터의 결론

예외를 만나면 "내가 이걸 제대로 복구할 수 있나?" 를 물어보세요. 복구 방법을 알면 그 자리에서 잡고, 모르면 잡지 말고 위로 던져 더 잘 아는 쪽에 맡겨요. 무작정 다 잡는 것도, 무작정 다 던지는 것도 정답이 아니에요. "복구할 수 있는 곳에서 잡는다" 가 핵심이에요.


Step 6: 입력 검증 실전 — 회원 가입 검증기

지금까지 배운 throw 를 실전처럼 써볼게요. 실제 서비스의 회원 가입은 검사할 게 한둘이 아니에요. 이름이 비었는지, 너무 긴지, 나이가 가입 가능한지… 이런 여러 규칙을 throw 로 하나씩 막아봐요.

핵심은 Step 1 에서 본 throw 의 성격이에요. throw 가 실행되면 그 줄에서 메서드가 즉시 멈추니까, 위에서부터 규칙을 하나씩 검사하다가 하나라도 어기면 바로 막혀요. 모든 검사를 통과하면? 아무 일도 안 일어나고 조용히 끝나요. 검증기에서는 "아무 예외도 안 나는 것 = 통과" 예요.

Java
// com/instagram/javabasic/exceptionbasic/SignupValidator.java
public class SignupValidator {

    // 회원 가입 정보를 검사해요. 규칙을 어기면 IllegalArgumentException 으로 막고,
    // 모두 통과하면 아무것도 돌려주지 않고 조용히 끝나요(검사 통과 = 무사 통과).
    public void validate(String username, int age) {
        if (username == null || username.isEmpty()) {
            throw new IllegalArgumentException("사용자 이름을 입력해주세요.");
        }
        if (username.length() > 30) {
            throw new IllegalArgumentException("사용자 이름은 30자까지예요.");
        }
        if (age < 14) {
            throw new IllegalArgumentException("만 14세 이상만 가입할 수 있어요.");
        }
    }
}

규칙이 위에서 아래로 차례대로 검사돼요. 이름이 비면 첫 번째 if 에서 막혀 메서드가 즉시 끝나요(아래 검사는 아예 실행 안 됨). 이름이 너무 길면 두 번째, 나이가 모자라면 세 번째에서 막혀요.

텍스트
 validate 의 검사 통과 흐름

   이름 비었나?      ──예──▶ throw 💥 (즉시 멈춤)
        │ 아니오
        ▼
   이름 30자 넘나?   ──예──▶ throw 💥 (즉시 멈춤)
        │ 아니오
        ▼
   나이 14세 미만?   ──예──▶ throw 💥 (즉시 멈춤)
        │ 아니오
        ▼
   (끝) ✅ 모든 검사 통과 — 조용히 종료

main 에서 정상 가입과 위반 사례를 확인해볼게요.

Java
public static void main(String[] args) {
    SignupValidator validator = new SignupValidator();

    // 정상 — 모든 규칙을 통과하면 예외 없이 조용히 끝나요.
    validator.validate("minji", 25);
    System.out.println("minji 님 가입 정보가 통과했어요.");

    // 사고 1 — 빈 이름은 첫 검사에서 막혀요.
    try {
        validator.validate("", 25);
    } catch (IllegalArgumentException e) {
        System.out.println("막혔어요: " + e.getMessage());
    }

    // 사고 2 — 나이가 모자라면 마지막 검사에서 막혀요.
    try {
        validator.validate("jaehoon", 13);
    } catch (IllegalArgumentException e) {
        System.out.println("막혔어요: " + e.getMessage());
    }
}

"minji"(25세)는 모든 규칙을 통과해 조용히 끝나고 "통과했어요" 가 찍혀요. 빈 이름은 첫 검사에서, 13세는 마지막 검사에서 막혀 각각 안내 메시지가 나와요.

여기서 살짝 불편한 점이 보일 거예요. 세 가지 다른 사고(빈 이름·긴 이름·어린 나이)를 전부 똑같은 IllegalArgumentException 으로 던지고 있죠? 메시지로만 구분이 되는데, 만약 "이름 문제" 와 "나이 문제" 를 코드에서 다르게 처리하고 싶다면 메시지를 일일이 비교해야 해요. 차라리 이름이 뜻을 분명히 말하는 예외가 있다면 좋을 텐데요. 이 아쉬움이 바로 다음 시간(Day 23) 커스텀 예외로 이어져요.

💡 튜터의 결론

여러 규칙을 검사할 땐 위에서부터 하나씩 throw 로 막으면 돼요. throw 가 실행되면 즉시 멈추니, 어긴 규칙에서 바로 걸러져요. 모두 통과하면 조용히 끝나고요. 검증 메서드는 "예외가 안 나면 통과" 라는 약속으로 동작해요.


Step 7: 종합 — 회원 조회: 던지고 받는 한 흐름

마지막으로 오늘 배운 throw·throws·try-catch 를 한 흐름으로 묶어봐요. Day 20 에서 만든 Repository<Member>(만능 저장소)를 다시 꺼내 쓸 거예요.

기억하시죠? Repository.findById 는 없는 id 를 찾으면 IllegalArgumentException 을 던져요. 저장소가 만든 약속이에요. 이제 그 위에 두 메서드를 얹어요. 하나는 예외를 잡지 않고 전파하는 "던지는 쪽", 하나는 그 예외를 받아 안내 문구로 바꾸는 "받는 쪽" 이에요.

Java
// com/instagram/javabasic/exceptionbasic/MemberLookupFlow.java
public class MemberLookupFlow {

    // 회원을 보관하는 저장소예요. 직접 만들지 않고 밖에서 전달받아 보관해요.
    private final Repository<Member> repo;

    // 생성자 — 쓸 저장소를 외부에서 받아 필드에 담아요.
    public MemberLookupFlow(Repository<Member> repo) {
        this.repo = repo;
    }

    // 던지는 쪽 — 저장소에서 회원을 찾아요. 없으면 저장소가 던진 예외를
    // 여기서 잡지 않고 그대로 호출자에게 전파해요.
    public Member findMember(Long id) {
        return repo.findById(id);
    }

    // 받는 쪽 — findMember 를 try-catch 로 감싸 결과를 안내 문구로 바꿔줘요.
    // 찾으면 환영 인사를, 없으면 전파돼 온 예외를 잡아 안내 문구를 돌려줘요.
    public String greet(Long id) {
        try {
            Member member = findMember(id);
            return member.getUsername() + "님 환영합니다!";
        } catch (IllegalArgumentException e) {
            return "회원을 찾을 수 없어요: " + e.getMessage();
        }
    }
}

흐름이 깔끔하게 세 단계로 나뉘어요.

  1. 저장소(findById) — 없는 id 면 예외를 던져요. (Day 20 에서 만든 약속)
  2. 던지는 쪽(findMember) — 그 예외를 잡지 않고 그대로 통과시켜요. (전파)
  3. 받는 쪽(greet) — try-catch 로 받아 사용자에게 보여줄 안내 문구로 바꿔요.
텍스트
 없는 id 로 greet 를 부르면

   findById   💥 throw IllegalArgumentException
   findMember (안 잡음, 전파) ──┐
   greet      🧤 catch ◀────────┘ 안내 문구로 바꿔 돌려줌
              "회원을 찾을 수 없어요: ..."

main 으로 전체를 돌려볼게요. 저장소에 회원 두 명을 넣고, 있는 id 와 없는 id 를 둘 다 조회해요.

Java
public static void main(String[] args) {
    // 회원 저장소를 만들어 회원 두 명을 넣어요.
    Repository<Member> repo = new Repository<>();
    repo.save(1L, new Member("minji", 8500, 150, 12, 400));
    repo.save(2L, new Member("jaehoon", 1240, 42, 5, 200));

    MemberLookupFlow flow = new MemberLookupFlow(repo);

    // 있는 id — 환영 인사가 나와요.
    System.out.println(flow.greet(1L));
    System.out.println(flow.greet(2L));

    // 없는 id — 저장소가 던진 예외가 findMember 를 거쳐 greet 까지 전파되고,
    // greet 가 잡아 안내 문구로 바꿔 돌려줘요. 프로그램은 멈추지 않아요.
    System.out.println(flow.greet(99L));
}

id 1번과 2번은 "minji님 환영합니다!", "jaehoon님 환영합니다!" 가 나오고, 없는 99번은 저장소가 던진 예외가 findMember → greet 로 전파돼 "회원을 찾을 수 없어요: id 99 에 해당하는 항목이 없어요." 가 나와요. 던지고(저장소)·전파하고(findMember)·받는(greet) 흐름이 한 바퀴 도는 거예요.

💡 튜터의 결론

던지고·전파하고·받는 세 단계가 한 흐름으로 엮였어요. 저장소가 약속대로 예외를 던지면, 중간 메서드는 전파만 하고, 복구할 줄 아는 곳에서 잡아 안내 문구로 바꿔요. 오늘 배운 throw·throws·try-catch 가 실제 조회 기능 하나에 모두 들어간 거예요. 이게 바로 실무에서 예외를 다루는 기본 골격이에요.


마무리

  • Step 1: throw new 예외종류("메시지") 로 내가 직접 예외를 던져요. 그 줄에서 메서드는 즉시 멈추고 예외가 위로 튀어요. Day 20 Repository.findById 도 이렇게 던지고 있었어요.
  • Step 2: 예외는 두 종류 — RuntimeException 자손은 unchecked(강제 안 함), 그 외 Exception 자손은 checked(throws 나 try-catch 강제). unchecked 는 "내 실수", checked 는 "바깥 사정".
  • Step 3: throws 는 "이 예외를 안 잡고 넘긴다" 는 메서드 표지판. throw(동작, s 없음)와 throws(선언, s 있음)는 한 글자 차이지만 역할이 달라요.
  • Step 4: 아무도 안 잡은 예외는 콜스택을 거슬러 올라가요(전파). 끝까지 안 잡으면 프로그램이 멈춰요.
  • Step 5: "복구할 수 있으면 잡고, 모르면 던진다." 무작정 다 잡거나 다 던지는 게 아니라, 복구 가능한 곳에서 잡아요.
  • Step 6: 여러 규칙은 위에서부터 throw 로 하나씩 막아요. 모두 통과하면 조용히 끝나요.
  • Step 7: 던지고(저장소)·전파하고(findMember)·받는(greet) 전체 흐름을 한 기능으로 엮었어요.

다음 시간엔 — 나만의 예외를 만들기

오늘 우리는 검증할 때마다 IllegalArgumentException 을 던졌어요. 그런데 Step 6 에서 느꼈듯, 빈 이름·긴 이름·어린 나이를 전부 같은 예외로 던지니 메시지로만 구분해야 해서 조금 불편했죠. 만약 MemberNotFoundException(회원을 못 찾음) 처럼 이름만 봐도 무슨 사고인지 아는 예외가 있다면 어떨까요?

다음 시간엔 이렇게 나만의 예외 클래스를 직접 만드는 법(커스텀 예외)을 배워요. 그리고 예외를 잡아 다른 예외로 바꿔 던질 때 원래 원인을 보존하는 예외 체이닝, 자원을 자동으로 닫아주는 try-with-resources 도 익혀요. 오늘 던지는 법을 배웠으니, 다음엔 "잘 설계된 예외" 를 만드는 거예요. 수고 많으셨어요!


과제

오늘 배운 throw·throws·예외 처리 전략을 손에 익히는 과제예요. 모두 오늘까지 배운 문법(클래스·상속·인터페이스·Comparable·List/Set/Map·제네릭·예외 처리·향상된 for)만으로 풀 수 있어요. 커스텀 예외 클래스와 try-with-resources 는 다음 시간 주제라 오늘 과제에는 쓰지 않아요. 표준 예외(IllegalArgumentException·IllegalStateException 등)만 직접 던지면 돼요.

과제 1: [기초] 게시물 좋아요 수 검증기

해야 할 일

게시물의 좋아요 수를 검사하는 메서드를 만들어보세요. 좋아요 수는 0 보다 작을 수 없어요(음수면 데이터가 잘못된 거예요). 음수가 들어오면 throw 로 막아요.

요구사항

  • int validateLikeCount(int likeCount) 메서드를 만들어요.
  • likeCount 가 0 보다 작으면 throw new IllegalArgumentException("좋아요 수는 음수일 수 없어요: " + likeCount) 로 막아요.
  • 정상(0 이상)이면 그 값을 그대로 돌려줘요.
  • main 에서 정상 값(예: 100)과 음수 값(예: -5)을 둘 다 호출하고, 음수는 try-catch 로 받아 메시지를 출력해요.

힌트

  • Step 1 의 validateCaption 구조를 그대로 따라가면 쉬워요. if 로 검사하고 어기면 throw, 통과하면 return.
  • 던진 예외는 main 에서 catch (IllegalArgumentException e) 로 받아 e.getMessage() 를 출력하면 돼요.

과제 2: [응용] 던질까 잡을까: 두 메서드로 나누기

해야 할 일

Step 5 의 "던지는 쪽 / 잡는 쪽" 패턴을 직접 만들어보세요. 사용자가 입력한 나이 글자(String)를 숫자로 바꾸는 상황이에요.

요구사항

  • int parseAge(String raw) 메서드("던지는 쪽")를 만들어, Integer.parseInt(raw) 로 변환만 해요. 숫자가 아니면 NumberFormatException 이 그대로 전파되게 두고, 잡지 마세요.
  • int parseAgeOrDefault(String raw) 메서드("잡는 쪽")를 만들어, parseAge 를 try-catch 로 감싸요. NumberFormatException 이 나면 기본값 0 을 돌려줘요.
  • main 에서 정상 입력("20")과 잘못된 입력("스무살")을 둘 다 parseAgeOrDefault 로 호출해 결과를 확인해요.

힌트

  • parseAge 에는 try-catch 를 절대 넣지 마세요. "복구 방법을 모르니 그냥 던진다" 가 이 메서드의 역할이에요.
  • 복구(기본값 0)는 parseAgeOrDefault 에서만 해요. 같은 예외라도 "어디서 잡느냐" 가 전략이라는 걸 직접 느껴보세요.

과제 3: [심화] 전파 따라가기: 3단 호출 체인

해야 할 일

Step 4 의 콜스택 전파를 직접 만들어보세요. 메서드 3개가 서로를 부르는 체인을 만들고, 가장 깊은 곳에서 던진 예외가 맨 위까지 거슬러 올라오는 걸 확인해요.

요구사항

  • 가장 깊은 메서드 String readBio(String bio) 를 만들어, bio 가 null 이거나 빈 문자열이면 throw new IllegalStateException("자기소개가 비어 있어요."), 정상이면 bio 를 돌려줘요.
  • 중간 메서드 String buildProfileCard(String bio) 는 try-catch 없이 readBio(bio) 를 호출해 그 결과를 돌려줘요.
  • 맨 위 메서드 String renderProfilePage(String bio) 도 try-catch 없이 buildProfileCard(bio) 를 호출해 그 결과를 돌려줘요.
  • main 에서 renderProfilePage(null) 을 try-catch 로 감싸 호출하고, 맨 깊은 곳의 예외가 main 까지 전파돼 잡히는지 확인해요.

힌트

  • 중간 두 메서드(buildProfileCard·renderProfilePage)에는 try-catch 를 넣지 마세요. 그래야 예외가 통과해서 위로 올라가요.
  • IllegalStateException 은 unchecked 라 중간 메서드에 throws 선언도 필요 없어요. 그냥 호출만 하면 예외가 알아서 전파돼요.

생각해볼 주제

오늘 배운 "던지고 전파" 뒤에는 "그래서 예외를 어떻게 던지는 게 좋은가?" 하는 질문이 숨어 있어요. 정답을 외우기보다 곰곰이 생각해보면 도구를 보는 눈이 깊어져요.

1. throw 로 막을 일을 if 로 false 만 돌려주면 안 될까?

Step 6 의 회원 가입 검증기는 규칙을 어기면 throw 로 예외를 던졌어요. 그런데 이렇게 생각할 수도 있어요. "예외까지 던질 것 없이, 그냥 boolean 으로 false 를 돌려주면 안 되나요? 통과하면 true, 아니면 false 로요." 얼핏 더 간단해 보이죠. 하지만 false 만 돌려주면 "무엇이 왜 잘못됐는지" 정보가 사라지고, 부른 쪽이 그 false 를 깜빡 무시하고 다음 코드를 그냥 진행할 수도 있어요. 예외를 던질 때와 false 를 돌려줄 때, 각각 어떤 상황에서 더 안전한지 생각해보세요.

2. 모든 메서드에 throws Exception 을 붙여두면 편하지 않을까?

Step 3 에서 checked 예외를 넘기려면 throws Exception 을 붙여야 했어요. 그런데 매번 어떤 예외를 넘길지 고민하기 귀찮으니, 아예 모든 메서드에 throws Exception 을 기본으로 붙여두면 컴파일 에러도 안 나고 편할 것 같아요. 정말 그럴까요? 메서드 시그니처의 throws 는 "이 메서드를 부르면 이런 예외에 대비해야 해" 라는 약속이기도 해요. 모든 메서드가 throws Exception 이면 그 약속이 어떤 의미를 잃게 될지, 부르는 쪽 입장에서 따져보세요.

3. 예외는 정말 "예외적인" 상황에만 써야 할까?

오늘 우리는 잘못된 입력, 없는 회원처럼 "예상 밖의 사건" 에 예외를 썼어요. 그런데 예외는 편리해서, 평범한 흐름 제어에도 쓰고 싶은 유혹이 생겨요. 예를 들어 반복문을 빠져나오려고 일부러 예외를 던져 잡는다든지요. 이렇게 "정상적인 흐름" 에까지 예외를 쓰면 무엇이 문제일까요? 예외라는 단어 자체가 주는 의미("예외적인 일")와, 코드를 읽는 동료가 받을 혼란을 떠올리며, 예외를 써야 할 상황과 쓰지 말아야 할 상황의 경계를 그어보세요.

✅ 예시 답안정답 보기

오늘 과제는 "잘못된 값을 만나면 throw 로 직접 예외를 던진다", 그리고 "그 예외를 어디서 잡고 어디서 던질지 판단한다" 가 핵심이에요. 커스텀 예외 클래스와 try-with-resources 는 다음 시간 주제라 오늘 답안에는 안 나와요. 세 과제 모두 정답이 하나는 아니에요. 아래 예시와 다르게 풀었더라도 요구사항을 만족하고 throw·전파·try-catch 를 제대로 썼다면 훌륭한 답이에요.


과제 예시답안

과제 1 예시답안 — 게시물 좋아요 수 검증기

핵심 접근

이 과제의 주제는 "값이 들어오는 입구에서 미리 검사하고, 규칙에 어긋나면 그 자리에서 직접 예외를 던진다" 예요. 좋아요 수가 음수라는 건 데이터가 어딘가에서 잘못됐다는 신호예요. 그냥 두면 한참 뒤 엉뚱한 곳에서 이상한 결과가 나오니, 값을 받는 순간 throw 로 "여기서부터 잘못됐어요!" 하고 분명히 막는 게 더 안전해요. Step 1의 validateCaption 과 똑같은 구조예요 — if 로 검사하고, 어기면 throw, 통과하면 return.

예시 구현

Java
// com/instagram/javabasic/exceptionbasic/solution/day22/LikeCountValidator.java
public class LikeCountValidator {

    // 좋아요 수 검증 — 0 이상이면 그 값을 그대로 돌려주고, 음수면 예외를 던져요.
    public int validateLikeCount(int likeCount) {
        if (likeCount < 0) {
            throw new IllegalArgumentException("좋아요 수는 음수일 수 없어요: " + likeCount);
        }
        return likeCount;
    }
}

if (likeCount < 0) 로 음수인지 검사해요. 음수면 throw new IllegalArgumentException(...) 이 실행돼 그 자리에서 메서드가 즉시 멈추고, 예외가 부른 쪽으로 전파돼요. 0 이상이면 검사를 통과해 마지막 줄 return likeCount; 까지 도착해 그 값을 그대로 돌려주죠.

main 으로 확인해 볼게요.

Java
// com/instagram/javabasic/exceptionbasic/solution/day22/LikeCountValidator.java
public static void main(String[] args) {
    LikeCountValidator validator = new LikeCountValidator();

    // 정상 값 — 검사를 통과해서 그 값이 그대로 나와요.
    int valid = validator.validateLikeCount(100);
    System.out.println("정상 좋아요 수: " + valid);

    // 음수 값 — 검사에 걸려서 예외가 던져져요. try-catch 로 받아 메시지를 출력해요.
    try {
        validator.validateLikeCount(-5);
    } catch (IllegalArgumentException e) {
        System.out.println("검증 실패: " + e.getMessage());
    }

    // 위에서 예외를 잡았으니 프로그램은 죽지 않고 이 줄까지 실행돼요.
    System.out.println("끝까지 잘 실행됐어요!");
}

실행하면 이런 결과가 나와요.

텍스트
정상 좋아요 수: 100
검증 실패: 좋아요 수는 음수일 수 없어요: -5
끝까지 잘 실행됐어요!

정상 값 100 은 그대로 나오고, 음수 -5 는 throw 에 막혀 "검증 실패: ..." 가 나와요. 우리가 던진 예외를 main 의 try-catch 가 받아냈으니, 그 다음 "끝까지 잘 실행됐어요!" 까지 무사히 찍혀요.

채점 포인트

항목 배점 기준 가중
throw 로 막기 음수일 때 throw new IllegalArgumentException(...) 으로 직접 던지는가
메시지에 값 포함 예외 메시지에 잘못된 값(-5)을 담아 무엇이 문제인지 알리는가
정상 분기 0 이상이면 그 값을 그대로 돌려주는가
두 경우 검증 main 에서 정상 값·음수 값 둘 다 호출했는가

흔한 실수

  • 음수를 그냥 0으로 바꿔서 돌려주기if (likeCount < 0) return 0; 처럼 조용히 고쳐버리면, 잘못된 데이터가 어디서 왜 생겼는지 영영 모르게 돼요. "잘못된 값" 은 숨기지 말고 throw 로 분명히 드러내는 게 좋아요.
  • 검사 없이 그냥 받기 → 입구에서 안 막으면 음수 좋아요 수가 그대로 흘러가, 한참 뒤 화면이나 통계에서 이상하게 터져요. 사고 지점과 발견 지점이 멀어질수록 원인 찾기가 어려워져요.

실무 개선 포인트 (심화)

지금은 빈 이름·음수처럼 잘못된 값을 모두 IllegalArgumentException 으로 던졌어요. 그런데 실무에서는 "좋아요 수가 음수" 라는 상황을 그 도메인에 딱 맞는 내 예외(예: InvalidLikeCountException)로 표현하면, 부른 쪽이 타입만 보고 "아, 좋아요 수 문제구나" 를 바로 알 수 있어요. 이렇게 이름이 뜻을 말하는 예외를 직접 만드는 법은 다음 시간(Day 23) 커스텀 예외에서 배워요.


과제 2 예시답안 — 던질까 잡을까: 두 메서드로 나누기

핵심 접근

이 과제의 주제는 "같은 실패라도 누가 처리하는 게 맞느냐에 따라 잡을 곳을 고른다" 예요. Integer.parseInt 는 숫자가 아닌 글자를 만나면 NumberFormatException(숫자 형식 예외) 을 던져요. 이걸 두 메서드로 나눠서, 하나(parseAge)는 잡지 않고 그대로 던지고(전파), 다른 하나(parseAgeOrDefault)는 안에서 잡아 기본값으로 복구해요. Step 5의 HandleOrThrow 와 똑같은 패턴이에요.

예시 구현

Java
// com/instagram/javabasic/exceptionbasic/solution/day22/AgeParser.java
public class AgeParser {

    // 잡지 않고 던지는 쪽 — Integer.parseInt 가 숫자가 아닌 글자를 만나면
    // NumberFormatException 을 던지는데, 여기선 try-catch 로 감싸지 않아요.
    public int parseAge(String raw) {
        return Integer.parseInt(raw);
    }

    // 잡아서 기본값으로 막는 쪽 — parseAge 를 try 로 감싸 호출하고,
    // NumberFormatException 이 올라오면 catch 가 받아서 기본값 0 을 대신 돌려줘요.
    public int parseAgeOrDefault(String raw) {
        try {
            return parseAge(raw);
        } catch (NumberFormatException e) {
            return 0;
        }
    }
}

두 메서드의 태도가 완전히 달라요. parseAge 는 변환만 할 뿐, 실패했을 때 무엇이 옳은 값인지 몰라요. 그래서 try-catch 로 감싸지 않고 예외를 그대로 위로 흘려보내요. parseAgeOrDefault 는 "변환이 실패하면 0 을 쓰면 된다" 는 복구 방법을 아니까, try-catch 로 잡아 기본값 0 을 돌려주죠. 같은 예외라도 "어디서 잡느냐" 가 전략이에요.

main 으로 확인해 볼게요.

Java
// com/instagram/javabasic/exceptionbasic/solution/day22/AgeParser.java
public static void main(String[] args) {
    AgeParser parser = new AgeParser();

    // 정상 입력 — 숫자로 잘 변환돼요.
    System.out.println("\"20\" 변환 결과: " + parser.parseAgeOrDefault("20"));

    // 잘못된 입력 — 변환에 실패하지만, 안에서 잡아 기본값 0 이 나와요.
    System.out.println("\"스무살\" 변환 결과: " + parser.parseAgeOrDefault("스무살"));

    System.out.println("끝까지 잘 실행됐어요!");
}

실행하면 이런 결과가 나와요.

텍스트
"20" 변환 결과: 20
"스무살" 변환 결과: 0
끝까지 잘 실행됐어요!

"20" 은 그대로 20 으로 변환되고, "스무살" 은 변환에 실패하지만 parseAgeOrDefault 가 잡아서 기본값 0 을 돌려줘요. 예외가 밖으로 새어 나가지 않으니 프로그램이 멈추지 않고 끝까지 실행돼요.

채점 포인트

항목 배점 기준 가중
던지는 쪽 분리 parseAge 에 try-catch 없이 예외를 그대로 전파시키는가
잡는 쪽 복구 parseAgeOrDefaultNumberFormatException 을 잡아 기본값 0 을 돌려주는가
정확한 예외 타입 Integer.parseInt 가 던지는 NumberFormatException 을 잡는가
두 경우 검증 main 에서 정상 입력·잘못된 입력 둘 다 호출했는가

흔한 실수

  • parseAge 안에서 잡아버리기 → "던지는 쪽" 인데 try-catch 를 넣으면 두 메서드가 똑같아져 나눈 의미가 없어요. parseAge 는 일부러 안 잡고 전파해야, "복구는 더 잘 아는 쪽에 맡긴다" 는 전략이 드러나요.
  • 예외 타입을 너무 넓게 잡기catch (Exception e) 로 잡아도 동작은 하지만, "숫자 변환 실패" 라는 구체적 의미가 흐려져요. NumberFormatException 으로 좁혀 잡는 게 무엇을 복구하는지 분명해요.

실무 개선 포인트 (심화)

기본값으로 0 을 쓰는 게 늘 정답은 아니에요. 나이가 0 이라는 건 또 다른 이상한 값일 수 있거든요. 실무에서는 "변환 실패 시 기본값을 줄지, 아니면 잘못된 입력이라고 사용자에게 다시 알릴지" 를 그 기능의 목적에 따라 정해요. 검색 결과 개수처럼 기본값이 자연스러운 곳은 복구가 맞고, 나이·금액처럼 정확해야 하는 곳은 차라리 예외를 위로 던져 "다시 입력해주세요" 로 잇는 게 나아요. "이 실패를 조용히 메울까, 분명히 알릴까" 가 판단 기준이에요.


과제 3 예시답안 — 전파 따라가기: 3단 호출 체인

핵심 접근

이 과제의 주제는 "가장 깊은 곳에서 던진 예외가, 중간에 아무도 안 잡으면 맨 위까지 거슬러 올라간다" 를 눈으로 확인하는 거예요. 메서드 세 개가 사슬처럼 서로를 부르는데, 중간 두 메서드에는 try-catch 가 하나도 없어요. 그래서 맨 깊은 readBio 가 던진 예외가 사슬을 타고 거꾸로 올라가, 결국 맨 바깥 main 에서 잡혀요. Step 4의 CallStackPropagation 과 똑같은 전파 구조예요.

예시 구현

Java
// com/instagram/javabasic/exceptionbasic/solution/day22/ProfilePageRenderer.java
public class ProfilePageRenderer {

    // 가장 깊은 곳 — 자기소개가 비었으면 여기서 예외를 던져요.
    public String readBio(String bio) {
        if (bio == null || bio.isEmpty()) {
            throw new IllegalStateException("자기소개가 비어 있어요.");
        }
        return bio;
    }

    // 중간 단계 — try-catch 없이 readBio 를 그냥 부르기만 해요.
    public String buildProfileCard(String bio) {
        return readBio(bio);
    }

    // 가장 바깥 단계 — 역시 try-catch 없이 buildProfileCard 를 부르기만 해요.
    public String renderProfilePage(String bio) {
        return buildProfileCard(bio);
    }
}

buildProfileCardrenderProfilePage 를 보세요. 둘 다 try-catch 가 없고 그냥 다음 메서드를 부르기만 해요. 그래서 가장 깊은 readBioIllegalStateException 을 던지면, 두 중간 메서드는 통과 통로가 되고 예외는 계속 위로 올라가요. IllegalStateException 은 unchecked 라 중간 메서드에 throws 선언도 필요 없어요.

main 으로 확인해 볼게요.

Java
// com/instagram/javabasic/exceptionbasic/solution/day22/ProfilePageRenderer.java
public static void main(String[] args) {
    ProfilePageRenderer renderer = new ProfilePageRenderer();

    // 정상 자기소개 — 사슬을 끝까지 통과해서 그대로 나와요.
    System.out.println("프로필: " + renderer.renderProfilePage("자바 좋아요"));

    // 빈 자기소개(null) — readBio 에서 던진 예외가 buildProfileCard, renderProfilePage 를
    // 거쳐 여기 main 까지 전파돼서, 이 try-catch 가 마침내 받아내요.
    try {
        renderer.renderProfilePage(null);
    } catch (IllegalStateException e) {
        System.out.println("렌더링 실패: " + e.getMessage());
    }

    System.out.println("끝까지 잘 실행됐어요!");
}

실행하면 이런 결과가 나와요.

텍스트
프로필: 자바 좋아요
렌더링 실패: 자기소개가 비어 있어요.
끝까지 잘 실행됐어요!

정상 자기소개("자바 좋아요")는 사슬을 끝까지 통과해 그대로 나와요. 그런데 null 을 넣으면 호출은 renderProfilePage → buildProfileCard → readBio 로 깊이 내려갔다가, 예외는 그 반대로 거슬러 올라와 main 의 catch 에서 잡혀요. 중간 메서드들이 아무도 안 잡았기 때문에 예외가 맨 위까지 도달한 거예요.

채점 포인트

항목 배점 기준 가중
던지는 곳 가장 깊은 readBio 가 빈 값일 때 IllegalStateException 을 던지는가
중간 미포착 buildProfileCard·renderProfilePage 에 try-catch 를 넣지 않아 예외가 전파되는가
맨 위 포착 main 에서 전파돼 온 예외를 try-catch 로 받는가
정상 경로 정상 값이면 사슬을 통과해 그대로 돌려주는가

흔한 실수

  • 중간 메서드에 try-catch 넣기buildProfileCardrenderProfilePage 에서 예외를 잡아버리면, 예외가 main 까지 올라가지 못해 전파를 확인할 수 없어요. 중간은 통과 통로로 둬야 전파가 보여요.
  • unchecked 인데 throws 붙이기IllegalStateException 은 unchecked 라 throws 선언이 필요 없어요. 붙여도 틀린 건 아니지만, "unchecked 는 선언 없이도 자동 전파된다" 는 걸 기억하면 더 깔끔해요.

실무 개선 포인트 (심화)

예외가 깊은 곳에서 터져 맨 위까지 올라오는 건 편리하지만, 한 가지 주의할 점이 있어요. 맨 위에서 잡았을 때 "도대체 어느 깊은 곳에서 시작된 사고인지" 를 알아야 고칠 수 있거든요. 다행히 예외 객체에는 어떤 메서드들을 거쳐 올라왔는지 경로(스택 트레이스)가 담겨 있어요. 그리고 잡은 예외를 다른 예외로 바꿔 다시 던질 때 원래 원인을 잃지 않고 이어 붙이는 기법(예외 체이닝)이 있는데, 이건 다음 시간(Day 23)에 배워요.


생각해볼 주제 예시답안

생각해볼 주제 1 예시답안 — throw 로 막을 일을 if 로 false 만 돌려주면 안 될까?

[문제 상황 요약]

Step 6 의 회원 가입 검증기는 규칙을 어기면 throw 로 예외를 던졌어요. 그런데 "예외까지 던질 것 없이 그냥 boolean 으로 false 를 돌려주면 안 되나요? 통과하면 true, 아니면 false 로요." 하는 생각이 들 수 있어요. 얼핏 더 간단해 보이죠. 두 방식은 무엇이 다를까요?

[튜터의 가이드 및 해설]

핵심은 "false 는 무시할 수 있지만, 예외는 무시할 수 없다" 예요. 그리고 false 에는 "왜 실패했는지" 정보가 없어요.

텍스트
 false 를 돌려줄 때 vs throw 로 던질 때

   boolean validate(...)              void validate(...) { throw ... }
   ── 실패하면 false                  ── 실패하면 예외
   부른 쪽이 if 로 확인 안 하면        부른 쪽이 안 잡으면
   그냥 무시하고 다음 진행 😱          프로그램이 멈춰서 그냥 못 지나감 ✅
   "무엇이 왜 틀렸는지" 도 사라짐      메시지에 이유가 담겨 전달됨

false 의 첫 번째 문제는 "조용히 무시될 수 있다" 는 거예요. 검증 메서드가 false 를 돌려줘도, 부른 쪽이 그 결과를 if 로 확인하지 않으면 그냥 다음 코드로 넘어가버려요. 잘못된 회원 정보가 그대로 저장되는 거죠. 반면 throw 는 잡지 않으면 프로그램이 멈춰버리니, "실패를 못 본 척" 하고 지나갈 수가 없어요.

두 번째 문제는 false 에는 "왜" 가 없다는 거예요. 빈 이름인지, 너무 긴 이름인지, 나이가 모자란 건지 — false 하나로는 알 수 없어요. 예외는 메시지에 그 이유를 담아 전달하니, 사용자에게도 개발자에게도 무엇이 문제인지 알려줄 수 있어요.

물론 false 가 늘 나쁜 건 아니에요. "이 회원이 우수 회원인가?" 처럼 true/false 가 정상적인 두 가지 답일 때는 boolean 이 딱 맞아요. 핵심은 "이게 정상적인 갈림길인가, 아니면 일어나면 안 되는 사고인가" 예요. 사고라면 throw 로 분명히 막는 게 안전해요.

🎯 면접관을 홀리는 핵심 멘트

"false 와 throw 의 차이는 '무시 가능성' 과 '정보량' 입니다. false 는 부른 쪽이 확인 안 하면 조용히 무시되어 잘못된 값이 그대로 흘러가지만, 예외는 잡지 않으면 멈추기 때문에 못 본 척 지나갈 수 없습니다. 게다가 예외는 메시지에 실패 이유를 담아 전달하죠. true/false 가 정상적인 두 답일 땐 boolean 이 맞지만, '일어나면 안 되는 사고' 는 throw 로 막아야 한다는 게 핵심입니다."


생각해볼 주제 2 예시답안 — 모든 메서드에 throws Exception 을 붙여두면 편하지 않을까?

[문제 상황 요약]

Step 3 에서 checked 예외를 넘기려면 throws Exception 을 붙여야 했어요. 그런데 매번 어떤 예외를 넘길지 고민하기 귀찮으니, 아예 모든 메서드에 throws Exception 을 기본으로 붙여두면 컴파일 에러도 안 나고 편할 것 같아요. 정말 그럴까요?

[튜터의 가이드 및 해설]

핵심은 "throws 는 부르는 쪽과 맺는 약속" 이라는 거예요. 모든 메서드가 throws Exception 이면 그 약속이 아무 의미가 없어져요.

텍스트
 구체적인 throws vs 무조건 throws Exception

   void follow(...) throws SomeException     void doAnything(...) throws Exception
   ── "이 사고에 대비해" 라는               ── "무슨 예외든 날 수 있어" =
      구체적인 약속                            아무것도 안 알려준 것과 같음
   부른 쪽: 무엇을 잡을지 분명               부른 쪽: 뭘 잡아야 할지 모름
                                            → catch (Exception e) 로 다 뭉뚱그림

throws 는 "이 메서드를 부르면 이런 예외에 대비해야 해" 라는 표지판이에요. 그런데 모든 메서드가 throws Exception 이면, 이 표지판이 "뭔가 날 수도 있어요" 라는 의미 없는 말이 돼버려요. 부른 쪽은 정확히 무엇을 잡아야 할지 알 수 없어 결국 catch (Exception e) 로 다 뭉뚱그리게 되고, 이건 주제가 이어지는 "넓은 그물의 함정" 으로 빠져요.

또 하나, throws Exception 은 checked 예외라 전염돼요. A 가 throws Exception 이면 A 를 부르는 B 도 throws Exception 을 붙이거나 try-catch 해야 하고, B 를 부르는 C 도… 이렇게 코드 전체로 번져서, 정작 진짜 중요한 예외 한 개가 이 "의미 없는 throws" 들 사이에 묻혀버려요.

좋은 메서드는 자기가 넘길 수 있는 예외를 정확히 밝혀요. 그래야 부른 쪽이 "아, 이건 대비해야겠다" 를 분명히 알 수 있어요. 참고로 unchecked 예외(IllegalArgumentException 등)는 애초에 throws 가 강제되지 않으니, 이 고민의 상당 부분은 unchecked 를 적절히 쓰면 자연스럽게 줄어들어요.

🎯 면접관을 홀리는 핵심 멘트

"throws 는 부르는 쪽과 맺는 약속입니다. 모든 메서드에 throws Exception 을 붙이면 '무슨 예외든 날 수 있다' 는, 아무것도 안 알려준 것과 같은 말이 되어버려요. 부른 쪽은 무엇을 잡을지 몰라 catch (Exception) 으로 뭉뚱그리게 되고, checked 예외라 코드 전체로 전염되기까지 합니다. 메서드는 자기가 넘기는 예외를 정확히 밝혀야 부른 쪽이 제대로 대비할 수 있다는 게 핵심입니다."


생각해볼 주제 3 예시답안 — 예외는 정말 "예외적인" 상황에만 써야 할까?

[문제 상황 요약]

오늘 우리는 잘못된 입력, 없는 회원처럼 "예상 밖의 사건" 에 예외를 썼어요. 그런데 예외는 편리해서, 평범한 흐름 제어에도 쓰고 싶은 유혹이 생겨요. 예를 들어 반복문을 빠져나오려고 일부러 예외를 던져 잡는다든지요. 이렇게 "정상적인 흐름" 에까지 예외를 쓰면 무엇이 문제일까요?

[튜터의 가이드 및 해설]

핵심은 "예외는 이름 그대로 예외적인 일에 써야, 코드를 읽는 사람이 그 의미를 신뢰한다" 예요. 정상 흐름에 예외를 쓰면 의미가 망가져요.

텍스트
 예외로 흐름 제어 vs 정상 문법으로 흐름 제어

   for (...) {                        for (...) {
     if (found) throw new Stop();        if (found) break;   ← 의도가 한눈에
   }                                   }
   catch (Stop e) { }                  ── "찾으면 멈춤" 이 분명
   ── "사고? 정상? " 읽는 사람 혼란
   ── 느리기까지 함

첫 번째 문제는 "읽는 사람의 혼란" 이에요. 예외 코드를 본 동료는 "여기서 무슨 사고가 나나?" 하고 긴장해요. 그런데 알고 보니 그냥 반복문을 빠져나오려는 정상 로직이라면, 예외라는 단어가 주는 "예외적인 일" 이라는 신호가 거짓말이 돼버려요. 흐름 제어는 break·return·if 같은 정상 문법으로 하면 의도가 한눈에 보여요.

두 번째 문제는 성능이에요. 예외를 만들 때 자바는 "이 예외가 어디서 났는지" 경로(스택 트레이스)를 수집하는데, 이게 단순한 if 점프보다 비용이 훨씬 커요. 평범하게 자주 일어나는 일에 예외를 쓰면, 그 비용을 매번 치르게 돼요.

그래서 기준은 "이게 정상적으로 자주 일어나는 일인가, 아니면 정말 드문 사고인가" 예요. 자주 일어나는 정상 흐름은 if 로 다루고, 예외는 "이건 정말 예상 밖이야" 싶을 때만 아껴 써요. 그래야 예외라는 신호가 진짜 신호로 남아요. 지난 시간 과제에서도 "흔한 일은 if 로 미리 거르고, 드문 사고만 예외로" 라는 같은 결을 봤죠.

🎯 면접관을 홀리는 핵심 멘트

"예외는 이름 그대로 예외적인 상황에 써야 그 신호를 신뢰할 수 있습니다. 정상 흐름 제어에 예외를 쓰면 읽는 사람이 '여기서 무슨 사고가 나나' 오해하고, 스택 트레이스 수집 비용까지 매번 치르게 됩니다. 흐름 제어는 break·return·if 같은 정상 문법으로 하고, 예외는 '정말 드문 사고' 에만 아껴 쓴다 — 그래야 예외가 진짜 신호로 남는다는 게 핵심입니다."

전체 목록 자바 기초

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.