Day 21 — 예외 처리 ① 잡기 (try-catch-finally, 예외의 족보)
지난 시간 우리는 제네릭으로 "타입을 나중에 끼워 넣는 빈칸" 을 직접 만들었어요. 그러면서 두 번이나 같은 종류의 사고를 만났죠. ObjectBox 에서 String 을 Integer 로 잘못 꺼낼 때 터진 ClassCastException, 그리고 Repository.findById 가 없는 id 를 찾을 때 던진 IllegalArgumentException. 둘 다 "코드 짤 땐 멀쩡하다가 실행 중에 갑자기" 터지는 신호였어요.
그때 제가 약속했죠. "이 신호를 멋지게 받아내는 법은 다음 시간에 배운다" 고요. 바로 오늘이에요.
오늘 주인공은 예외 처리(exception handling) 예요. 예외(exception)는 "프로그램이 실행 도중 만난 예상 밖의 사건" 이에요. 지금까지는 예외가 터지면 프로그램이 그냥 멈춰버렸는데, 오늘은 그 예외를 try-catch 로 "잡아서" 프로그램이 멈추지 않고 계속 살아 있게 만들어요. 예외에도 족보(계층)가 있다는 것, 그리고 무슨 일이 있어도 실행되는 finally 까지 익혀요. 자, Day 21 시작해봐요!
🎯 학습 목표
- 예외(exception)가 "실행 중에 생긴 예상 밖의 사건" 이라는 걸 비유로 설명할 수 있어요.
- 지난 시간 만난
ClassCastException·IllegalArgumentException·IllegalStateException이 모두 "예외" 라는 한 식구임을 알 수 있어요. - 예외의 족보(
Throwable→Error/Exception→RuntimeException)를 그림으로 그릴 수 있어요. - try-catch 로 예외를 잡아 프로그램이 멈추지 않게 만들 수 있어요.
- catch 를 여러 개 둘 때 "구체적 → 일반적" 순서가 왜 중요한지 알 수 있어요.
- finally 가 무슨 일이 있어도 실행된다는 걸 이해하고, 뒷정리 코드를 안전하게 둘 수 있어요.
Step 1. 예외란 무엇인가 — 실행 중 갑자기 터지는 일
먼저 코드 없이 개념부터 잡고 갈게요. 예외를 한마디로 하면 "프로그램이 실행 도중 만난, 더는 정상적으로 진행할 수 없는 상황" 이에요.
자동차 계기판을 떠올려보세요. 잘 달리다가 엔진에 문제가 생기면 빨간 경고등이 번쩍 켜지면서 "이대로는 못 갑니다!" 하고 알려주죠. 예외가 딱 이 경고등이에요. 프로그램이 "이건 내가 처리할 수 없는 상황인데?" 를 만나면, 예외라는 신호를 띄우고 그 자리에서 멈춰요.
우리는 이미 이 경고등을 세 번이나 봤어요. 지난 시간 제네릭에서요.
지난 시간 만난 세 가지 경고등(예외)
ObjectBox 에서 String 을 Integer 로 꺼냄 → ClassCastException
빈 Repository 에서 없는 id 조회 → IllegalArgumentException
빈 Stack 에서 pop() (꺼내기) → IllegalStateException
셋 다 "지금 이 상황은 정상이 아니야!" 라는 같은 종류의 신호
이름은 다르지만 셋 다 "예외" 라는 한 식구예요. 코드로 다시 만나볼게요. 지난 시간에 만든 세 클래스를 그대로 불러와, 각각 예외가 터지는 메서드를 모아봤어요.
// com/instagram/javabasic/exceptionbasic/ExceptionExample.java
public class ExceptionExample {
// 만능 그릇 ObjectBox 에 String 을 넣고 Integer 로 꺼내면 형변환이 실패해요.
// 컴파일은 멀쩡히 지나가고, 실행하는 순간 ClassCastException 이 터져요.
public Integer triggerClassCast() {
ObjectBox box = new ObjectBox();
box.set("minji");
return (Integer) box.get();
}
// 텅 빈 저장소에 없는 번호표(999)로 조회하면 IllegalArgumentException 이 터져요.
public String triggerIllegalArgument() {
Repository<String> repository = new Repository<>();
return repository.findById(999L);
}
// 아무것도 쌓이지 않은 빈 스택에서 꺼내려 하면 IllegalStateException 이 터져요.
public String triggerIllegalState() {
Stack<String> stack = new Stack<>();
return stack.pop();
}
}
세 메서드 모두 지난 시간 코드를 그대로 빌려 썼어요. triggerClassCast 는 ObjectBox(만능 그릇)에 이름을 넣고 숫자로 꺼내려다 사고가 나고, triggerIllegalArgument 는 텅 빈 Repository(저장소)에서 없는 번호표를 찾다가, triggerIllegalState 는 빈 Stack(쌓기 그릇)에서 꺼낼 게 없어서 예외를 만나요.
이 메서드들을 그냥 부르면 어떻게 될까요? 예외가 터지는 그 줄에서 프로그램이 멈춰요.
예외가 나면 프로그램은 그 자리에서 멈춰요
정상이면: ── 한 줄 ── 다음 줄 ── 다음 줄 ── 끝까지 ✅
예외나면: ── 한 줄 ── 💥 (여기서 멈춤)
↑ 아래 코드는 영영 실행 못 함
그래서 세 가지를 한 번에 확인하려고, main 에서는 각 호출을 try-catch 로 살짝 감쌌어요. try-catch 가 정확히 무엇인지는 바로 다음 Step 3 에서 제대로 배워요. 지금은 "예외가 터지면 프로그램을 멈추는 대신 메시지를 한 줄 찍고 넘어가는 장치" 정도로만 봐주세요.
public static void main(String[] args) {
ExceptionExample example = new ExceptionExample();
try {
example.triggerClassCast();
} catch (ClassCastException e) {
System.out.println("ClassCastException 발생: 잘못된 형변환이에요.");
}
try {
example.triggerIllegalArgument();
} catch (IllegalArgumentException e) {
System.out.println("IllegalArgumentException 발생: " + e.getMessage());
}
try {
example.triggerIllegalState();
} catch (IllegalStateException e) {
System.out.println("IllegalStateException 발생: " + e.getMessage());
}
}
e.getMessage() 가 보이죠? 예외 객체 안에는 "무엇이 잘못됐는지" 를 설명하는 메시지가 들어 있어요. 지난 시간 Repository 와 Stack 에 우리가 직접 적어둔 그 문장("id 999 에 해당하는 항목이 없어요." 같은)이 여기서 다시 나와요. 예외는 그냥 터지기만 하는 게 아니라, 무엇이 왜 잘못됐는지 정보를 품고 있는 객체예요.
💡 튜터의 결론
예외는 "실행 도중 만난, 더는 정상 진행이 안 되는 상황" 을 알리는 경고등이에요. 지난 시간 만난
ClassCastException·IllegalArgumentException·IllegalStateException이 모두 같은 식구죠. 예외가 나면 프로그램은 그 자리에서 멈춰요. 이걸 잡아서 멈추지 않게 만드는 게 오늘의 목표예요.
Step 2. 예외의 족보 — Throwable 계층
예외를 제대로 다루려면, 예외에도 "가족 관계" 가 있다는 걸 알아야 해요. 잡을 때 "어떤 종류를 잡을지" 를 이 족보로 고르거든요.
자바의 모든 예외에는 맨 윗조상이 있어요. Throwable(스로어블, "던질 수 있는 것") 이에요. 그 아래로 크게 두 갈래로 갈라져요.
Throwable (맨 윗조상)
├── Error (시스템이 망가진 심각한 사고 — 우리가 손쓸 수 없음)
└── Exception (프로그램에서 다룰 수 있는 사건)
└── RuntimeException (실행 중에 터지는, 미리 처리를 강제하지 않음)
두 갈래의 성격이 완전히 달라요.
Error(에러)는 메모리가 바닥나거나(OutOfMemoryError) 자바 가상 머신 자체가 무너지는, "우리가 코드로 어떻게 해볼 수 없는" 심각한 사고예요. 이건 잡으려고 애쓰는 게 아니라, 애초에 안 나게 설계하는 영역이에요.
Exception(익셉션)은 반대로 "프로그램에서 다룰 수 있는 사건" 이에요. 없는 파일을 열려 하거나, 잘못된 입력이 들어오거나 하는, 우리가 잡아서 대응할 수 있는 것들이죠. 그리고 그 아래 RuntimeException(런타임 익셉션)이 있는데, 지난 시간 만난 세 예외가 전부 이 RuntimeException 자손이에요.
그래서 같은 ClassCastException 도 사실은 RuntimeException 이고, Exception 이고, Throwable 이에요. Day 11에서 배운 다형성 기억나시죠? 자식은 부모의 성격을 모두 물려받으니까요. 그래서 instanceof(어떤 타입인지 확인)로 "이 예외가 어느 갈래에 속하는지" 를 가려낼 수 있어요.
// com/instagram/javabasic/exceptionbasic/ThrowableHierarchy.java
public class ThrowableHierarchy {
// 예외 하나를 받아 족보의 어느 자리에 있는지 한국어 라벨로 알려줘요.
// 가장 구체적인(좁은) 타입부터 차례로 확인하는 게 핵심이에요.
public String classify(Throwable t) {
if (t instanceof RuntimeException) {
return "unchecked (RuntimeException 계열)";
} else if (t instanceof Exception) {
return "checked (Exception 계열)";
} else if (t instanceof Error) {
return "Error (복구 대상 아님)";
} else {
return "알 수 없는 Throwable";
}
}
// RuntimeException 계열이면 true 예요.
public boolean isUnchecked(Throwable t) {
return t instanceof RuntimeException;
}
}
classify 의 if 순서를 잘 봐주세요. RuntimeException 을 가장 먼저 확인해요. 왜냐하면 RuntimeException 은 Exception 의 자식이라서, 만약 Exception 을 먼저 확인하면 RuntimeException 까지 전부 "Exception 계열" 로 뭉뚱그려져 버리거든요. 그래서 좁은(구체적인) 것부터 확인해야 정확히 갈라낼 수 있어요. 이 "좁은 것 먼저" 규칙은 Step 4의 다중 catch 에서 똑같이 다시 나와요.
여기서 unchecked(언체크드)와 checked(체크드)라는 말이 나왔는데, 지금은 "RuntimeException 자손이면 unchecked" 정도만 알아두세요. 이 둘의 진짜 차이(컴파일러가 처리를 강제하느냐 마느냐)는 다음 시간에 본격적으로 다뤄요.
public static void main(String[] args) {
ThrowableHierarchy hierarchy = new ThrowableHierarchy();
// 지난 시간에 만난 세 예외는 모두 RuntimeException 계열이라 unchecked 로 나와요.
System.out.println(new ClassCastException() + " → " + hierarchy.classify(new ClassCastException()));
System.out.println(new IllegalArgumentException() + " → " + hierarchy.classify(new IllegalArgumentException()));
System.out.println(new IllegalStateException() + " → " + hierarchy.classify(new IllegalStateException()));
// 평범한 Exception 은 checked 계열이에요.
System.out.println(new Exception() + " → " + hierarchy.classify(new Exception()));
// Error 는 우리가 복구할 수 없는 심각한 사고예요.
System.out.println(new OutOfMemoryError() + " → " + hierarchy.classify(new OutOfMemoryError()));
}
지난 시간 세 예외는 전부 "unchecked (RuntimeException 계열)" 로 나오고, 평범한 Exception 은 "checked", OutOfMemoryError 는 "Error" 로 갈라져요. 같은 예외 하나가 동시에 여러 조상을 갖는다는 걸, instanceof 가 위에서부터 가려내는 모습이에요.
💡 튜터의 결론
예외에도 족보가 있어요. 맨 위가
Throwable, 그 아래Error(손쓸 수 없는 사고)와Exception(다룰 수 있는 사건)으로 갈라지고,Exception아래RuntimeException이 있어요. 지난 시간 세 예외는 전부RuntimeException자손이에요. 자식은 부모 성격을 다 물려받으니, 잡을 땐 "좁은 것부터" 가려내야 해요.
Step 3. try-catch 로 예외를 "잡기"
드디어 오늘의 핵심이에요. 예외가 터지면 프로그램이 멈춘다고 했죠? 그런데 우리가 미리 대비해두면, 멈추지 않고 "대신 이렇게 하자" 며 넘어갈 수 있어요. 그 도구가 try-catch 예요.
생김새는 이래요.
try {
위험할 수 있는 코드 ← 여기서 사고가 날 수 있어요
} catch (예외 e) {
대신 할 일 ← 사고가 나면 여기로 넘어와요
}
try(트라이, "시도해본다") 블록에는 예외가 날 수도 있는 위험한 코드를 넣어요. 그리고 catch(캐치, "잡는다") 블록에는 "만약 예외가 나면 대신 할 일" 을 적어요. 예외를 "잡는다" 는 건, 터진 예외를 받아서 프로그램을 멈추지 않고 계속 살려두는 거예요.
지난 시간 ObjectBox 로 직접 해볼게요. 그릇에서 이름을 안전하게 꺼내는 메서드예요.
// com/instagram/javabasic/exceptionbasic/BasicTryCatch.java
public class BasicTryCatch {
// box 에서 이름을 안전하게 꺼내요.
// 안에 String 이 들어 있으면 그대로 돌려주고,
// 엉뚱한 타입이 들어 있어 형변환이 실패하면 ClassCastException 을 잡아
// 프로그램을 멈추는 대신 기본값 "(알 수 없음)" 을 돌려줘요.
public String readUsernameSafely(ObjectBox box) {
try {
// 안에 String 이 아니면 이 줄에서 ClassCastException 이 터져요.
String username = (String) box.get();
return username;
} catch (ClassCastException e) {
// 예외를 잡았으니 프로그램은 멈추지 않아요. 안전한 기본값으로 대신해요.
return "(알 수 없음)";
}
}
}
try 안에 위험한 형변환((String) box.get())을 넣었어요. 만약 그릇 안에 String 이 들어 있으면 이 줄은 무사히 지나가서 그대로 이름을 돌려줘요. 그런데 엉뚱한 타입이 들어 있으면 이 줄에서 ClassCastException 이 터지고, 그 순간 곧장 catch 블록으로 점프해요. 거기서 기본값 "(알 수 없음)" 을 돌려주죠. 어느 쪽이든 프로그램은 멈추지 않아요.
두 경로를 그림으로 보면 이래요.
readUsernameSafely 의 두 갈래 길
정상 (String 들어 있음) 사고 (Integer 들어 있음)
try: (String) 형변환 성공 try: (String) 형변환 💥
│ │ 곧장 점프
return 실제 이름 ✅ catch: return "(알 수 없음)" ✅
둘 다 프로그램은 살아서 끝까지 감
main 으로 두 경우를 다 돌려볼게요.
public static void main(String[] args) {
BasicTryCatch reader = new BasicTryCatch();
// 정상 — String 이 들어 있어 실제 이름이 나와요.
ObjectBox good = new ObjectBox();
good.set("minji");
System.out.println("정상 box: " + reader.readUsernameSafely(good));
// 사고 — Integer 가 들어 있지만 예외를 잡아 기본값으로 복구돼요. 프로그램은 안 멈춰요.
ObjectBox wrong = new ObjectBox();
wrong.set(42);
System.out.println("잘못된 box: " + reader.readUsernameSafely(wrong));
System.out.println("프로그램이 멈추지 않고 끝까지 실행됐어요!");
}
맨 아래 줄을 잘 보세요. wrong 에서 형변환 사고가 났는데도 마지막 "프로그램이 멈추지 않고 끝까지 실행됐어요!" 가 찍혀요. 지난 시간 ObjectBox 였다면 그 사고에서 멈췄을 텐데, try-catch 로 잡았더니 프로그램이 끝까지 살아남았죠. 이게 예외 처리의 힘이에요.
🙋 학생 질문 — "튜터님, 그럼 모든 위험한 코드를 다 try-catch 로 감싸면 절대 안 멈추겠네요?"
감싸면 안 멈추는 건 맞아요. 하지만 "무조건 다 감싸는 게 좋은가" 는 또 다른 이야기예요. try-catch 는 "이 사고가 나면 이렇게 복구할 수 있다" 는 계획이 있을 때 쓰는 거예요.
예를 들어 readUsernameSafely 는 "이름을 못 읽으면 (알 수 없음)으로 대신한다" 는 분명한 복구 계획이 있죠. 반대로 아무 계획 없이 그냥 catch 로 잡아서 무시해버리면, 사고가 났는데도 아무 일 없는 척 넘어가서 나중에 더 큰 문제가 돼요. "잡을 거면 어떻게 복구할지" 까지 같이 정하는 게 핵심이에요. (이 고민은 오늘 생각해볼 주제에서 더 다뤄요.)
💡 튜터의 결론
try-catch 는 예외를 잡아 프로그램이 멈추지 않게 해줘요.
try에 위험한 코드를,catch에 "사고 나면 대신 할 일" 을 적어요. 예외가 나면 곧장catch로 점프해 복구하고 계속 진행해요. 단, 잡을 거면 "어떻게 복구할지" 까지 같이 정하는 게 중요해요.
Step 4. catch 여러 개 + 순서
한 try 블록에서 여러 종류의 예외가 날 수도 있어요. 그럴 땐 catch 를 여러 개 이어 붙여 "이 예외는 이렇게, 저 예외는 저렇게" 따로 처리할 수 있어요.
여기서 가장 중요한 게 순서예요. catch 는 위에서부터 차례로 "이거 내 거야?" 하고 확인하는데, 먼저 매치되는 catch 가 그 예외를 가져가요. 그래서 좁고 구체적인 예외를 위에, 넓고 일반적인 예외를 아래에 둬야 해요.
// com/instagram/javabasic/exceptionbasic/MultipleCatchBlocks.java
public class MultipleCatchBlocks {
// scenario 값에 따라 서로 다른 예외를 일부러 일으키고,
// 어느 catch 블록이 처리했는지 라벨로 돌려줘요.
public String handle(int scenario) {
try {
if (scenario == 1) {
// 더 구체적인 예외
throw new IllegalArgumentException("잘못된 인자예요.");
} else if (scenario == 2) {
// IllegalArgumentException 이 아닌 다른 런타임 예외
throw new IllegalStateException("지금 할 수 없는 상태예요.");
}
// 어떤 예외도 안 나면 여기까지 와요.
return "정상";
} catch (IllegalArgumentException e) {
// 구체적인 예외를 먼저 잡아요.
return "IllegalArgument 처리";
} catch (RuntimeException e) {
// 위에서 안 잡힌 나머지 런타임 예외를 여기서 받아요.
return "그 외 런타임 처리";
}
}
}
catch 가 두 개 이어졌죠. 위쪽은 IllegalArgumentException(구체적), 아래쪽은 RuntimeException(일반적)이에요. Step 2의 족보에서 봤듯이 IllegalArgumentException 은 RuntimeException 의 자식, 즉 더 좁은 타입이에요.
코드에 throw(예외를 직접 던지는 키워드)가 보이는데, 이건 다음 시간에 제대로 배워요. 오늘은 "일부러 예외를 만들어 상황을 연출하는 장치" 정도로만 봐주세요. 오늘의 초점은 던지는 게 아니라, 던져진 걸 여러 catch 가 순서대로 잡는 모습이에요.
catch 는 위에서부터 "이거 내 거야?" 하고 확인해요
IllegalArgumentException 발생
↓
catch (IllegalArgumentException) ← 매치! 여기서 잡고 끝 ✅
catch (RuntimeException) ← 도달 안 함
IllegalStateException 발생
↓
catch (IllegalArgumentException) ← 내 거 아님, 통과
catch (RuntimeException) ← 매치! (부모라 자식 다 받음) ✅
그런데 만약 순서를 뒤집어서 RuntimeException 을 위에 두면 어떻게 될까요? RuntimeException 은 부모라서 IllegalArgumentException 같은 자식까지 전부 먼저 잡아버려요. 그러면 아래 catch 는 영영 실행될 일이 없죠. 자바는 이걸 똑똑하게 알아채서, 아예 코드를 작성하는 단계에서 "이 아래 catch 는 도달할 수 없어요" 하고 빨간 줄을 그어 막아줘요. 그래서 순서를 잘못 두면 실행도 못 해요.
public static void main(String[] args) {
MultipleCatchBlocks demo = new MultipleCatchBlocks();
System.out.println("scenario 1 → " + demo.handle(1)); // IllegalArgument 처리
System.out.println("scenario 2 → " + demo.handle(2)); // 그 외 런타임 처리
System.out.println("scenario 0 → " + demo.handle(0)); // 정상
}
scenario 1 은 구체적 catch 가 잡아 "IllegalArgument 처리", scenario 2 는 위에서 안 잡혀 아래 RuntimeException catch 가 받아 "그 외 런타임 처리", 아무 예외도 안 난 scenario 0 은 그냥 "정상" 이 나와요. 좁은 것 먼저, 넓은 것 나중 — 이 순서가 다중 catch 의 전부예요.
💡 튜터의 결론
catch는 여러 개 이어 붙일 수 있고, 위에서부터 차례로 확인해요. 구체적인(좁은) 예외를 위에, 일반적인(넓은) 예외를 아래에 둬야 해요. 부모 예외를 위에 두면 자식까지 다 가로채서, 자바가 작성 단계에서 빨간 줄로 막아줘요. Step 2의 "좁은 것 먼저" 규칙이 그대로 이어져요.
Step 5. finally — 무조건 실행할 코드
try-catch 뒤에 finally(파이널리, "마지막으로") 블록을 하나 더 붙일 수 있어요. 여기에는 "무슨 일이 있어도 마지막에 꼭 실행할 코드" 를 적어요.
무슨 일이 있어도, 라는 게 핵심이에요. 세 가지 경우 모두에서 실행돼요.
try 가 정상으로 끝나도 → finally 실행
try 에서 예외가 나 catch 로 가도 → finally 실행
try 안에서 return 으로 빠져나가도 → finally 는 그래도 실행
왜 이런 게 필요할까요? "사고가 나든 안 나든 반드시 해야 하는 뒷정리" 가 있기 때문이에요. 예를 들어 파일이나 연결을 열었으면, 도중에 사고가 나더라도 반드시 닫아야 해요. 안 닫으면 자원이 줄줄 새거든요. 그 "반드시" 를 책임지는 게 finally 예요.
// com/instagram/javabasic/exceptionbasic/FinallyExample.java
public class FinallyExample {
// fail 이 true 면 try 안에서 예외를 일부러 일으켜요.
// 어느 경로든 finally 의 "정리" 기록이 빠지지 않는다는 걸 실행 순서로 보여줘요.
public String runWithCleanup(boolean fail) {
StringBuilder log = new StringBuilder();
try {
log.append("try");
if (fail) {
// 사고 발생! 여기서 바로 catch 로 점프해요.
throw new IllegalStateException("연결 도중 사고가 났어요.");
}
} catch (IllegalStateException e) {
// 예외가 났을 때만 들러요.
log.append(" → catch");
} finally {
// 정상이든 사고든, 이 줄은 무조건 실행돼요. 뒷정리를 책임지는 자리예요.
log.append(" → finally(정리)");
}
return log.toString();
}
}
StringBuilder(Day 17에서 배운 문자열 조립 도구) 에 실행 순서를 기록해뒀어요. try 에 들어가면 "try" 를 적고, 예외가 나서 catch 에 들르면 " → catch" 를 더하고, 마지막에 finally 에서 " → finally(정리)" 를 더해요. 이 기록을 보면 어느 줄이 실제로 실행됐는지 한눈에 보여요.
finally 는 두 경로 모두에서 실행돼요
정상 경로 (fail=false) 사고 경로 (fail=true)
try try
(예외 없음) 💥 예외 → catch
finally(정리) ✅ finally(정리) ✅
결과: "try → finally(정리)" 결과: "try → catch → finally(정리)"
main 으로 두 경로를 다 확인해볼게요.
public static void main(String[] args) {
FinallyExample example = new FinallyExample();
// 정상 경로 — 예외가 없어 catch 는 건너뛰지만, finally 는 그대로 실행돼요.
System.out.println("정상 경로: " + example.runWithCleanup(false));
// 예외 경로 — try 에서 사고가 나 catch 로 갔다가, finally 로 마무리해요.
System.out.println("예외 경로: " + example.runWithCleanup(true));
}
정상 경로는 "try → finally(정리)", 예외 경로는 "try → catch → finally(정리)" 가 나와요. 두 경우 모두 끝에 "finally(정리)" 가 빠지지 않고 찍히죠? 사고가 나든 안 나든 뒷정리는 반드시 실행된다는 게 눈으로 확인돼요.
참고로 자원(파일·연결 등)을 더 깔끔하게 자동으로 닫아주는 try-with-resources 라는 문법이 있는데, 그건 다음다음 시간(Day 23)에 배워요. 오늘은 "무슨 일이 있어도 실행되는 자리가 finally" 라는 것만 확실히 챙기면 충분해요.
💡 튜터의 결론
finally는 정상이든 예외든 return 이든, 무슨 일이 있어도 마지막에 실행돼요. "사고가 나도 반드시 해야 하는 뒷정리(자원 닫기 등)" 를 두는 자리예요. 정상 경로는 try → finally, 예외 경로는 try → catch → finally 로, 끝에 finally 가 빠지는 법이 없어요.
마무리 — 오늘 배운 것 압축 요약
- Step 1: 예외는 "실행 중 만난 예상 밖의 사건" 을 알리는 경고등. 지난 시간 세 예외(
ClassCastException·IllegalArgumentException·IllegalStateException)가 모두 한 식구. - Step 2: 예외의 족보 —
Throwable→Error(손쓸 수 없음)/Exception(다룰 수 있음) →RuntimeException. 자식은 부모 성격을 다 물려받아 "좁은 것부터" 가려냄. - Step 3: try-catch 로 예외를 잡아 프로그램이 안 멈추게.
try에 위험한 코드,catch에 복구 계획. - Step 4:
catch여러 개는 "구체적 → 일반적" 순서. 부모를 위에 두면 자식이 안 잡혀 컴파일 에러. - Step 5:
finally는 무슨 일이 있어도 실행되는 뒷정리 자리. 정상·예외 어느 경로든 마지막에 실행.
다음 시간엔 — 직접 던지고 전파하기
오늘 우리는 "남이 던진 예외를 잡는" 법을 배웠어요. ObjectBox·Repository·Stack 이 던진 예외를 try-catch 로 받아냈죠. 그런데 가만 보면, 지난 시간 Repository.findById 나 Stack.pop 은 우리가 직접 throw 로 예외를 "던지는" 코드를 적었어요. Step 4의 handle 에서도 throw 가 슬쩍 등장했고요.
다음 시간엔 이 throw(예외를 직접 던지기)와 throws(예외를 호출자에게 전파하기)를 정면으로 다뤄요. 그리고 오늘 잠깐 미뤄둔 checked 와 unchecked 의 진짜 차이, "이 사고는 내가 잡고, 저 사고는 위로 올려보내는" 판단까지 익혀요. 오늘은 받는 쪽이었다면, 다음 시간엔 던지는 쪽이 되는 거예요. 수고 많으셨어요!
과제
오늘 배운 예외 잡기(try-catch·다중 catch·finally)를 손에 익히는 과제예요. 모두 오늘까지 배운 문법(클래스·상속·인터페이스·Comparable·List/Set/Map·제네릭·예외 처리·향상된 for)만으로 풀 수 있어요. 람다·Stream은 아직 안 배웠으니 순회는 향상된 for로 해주세요. 직접 예외를 던지는 throw·throws 는 다음 시간 주제라, 오늘 과제는 "이미 던져진 예외를 잡아 다루는" 데 집중해요.
[기초] 과제 1 — 안전하게 게시물 꺼내기
해야 할 일
게시물 제목 리스트(List<String>)에서 "n번째 게시물 제목" 을 안전하게 꺼내는 메서드를 만들어보세요. 리스트 범위를 벗어난 번호를 넣으면 자바가 IndexOutOfBoundsException(범위를 벗어난 접근) 을 던지는데, 이걸 try-catch 로 잡아 프로그램이 멈추지 않게 해요.
요구사항
String getTitleSafely(List<String> titles, int index)메서드를 만들어요.try안에서titles.get(index)로 꺼내고, 정상이면 그 제목을 돌려줘요.- 범위를 벗어나
IndexOutOfBoundsException이 나면catch로 잡아"(없는 게시물)"을 돌려줘요. main에서 정상 번호와 범위 밖 번호(예: 100) 둘 다 호출해, 프로그램이 끝까지 실행되는지 확인해요.
힌트
List.get(index)는 인덱스가 크기를 벗어나면 예외를 던져요. 일부러titles.size()보다 큰 번호를 넣어 사고를 내보세요.catch (IndexOutOfBoundsException e)로 받으면 돼요. Step 3의readUsernameSafely구조를 그대로 따라가면 쉬워요.
[중] 과제 2 — 두 종류 사고를 구분해 처리하기
해야 할 일
지난 시간 만든 제네릭 Repository<T> 를 활용해, 회원을 조회하다 나는 두 종류의 사고를 다중 catch 로 구분 처리해보세요.
요구사항
Repository<Member>에 회원 몇 명을save해둬요.String describe(Repository<Member> repo, Long id)메서드를 만들어,try안에서repo.findById(id)로 회원을 꺼내 그 username 을 담은 문장을 돌려줘요.- 없는 id 면
findById가IllegalArgumentException을 던지니, 이걸 잡아"없는 회원이에요."를 돌려줘요. - 그 밖의
RuntimeException은 아래쪽 catch 로 받아"알 수 없는 오류예요."를 돌려줘요. (구체적 → 일반적 순서!) main에서 있는 id 와 없는 id 둘 다 호출해 결과를 확인해요.
힌트
catch (IllegalArgumentException e)를 먼저,catch (RuntimeException e)를 그 아래에 두세요. 순서를 뒤집으면 컴파일 에러가 나요(Step 4).- 메시지는
e.getMessage()로 꺼내 함께 보여줘도 좋아요.
[심화] 과제 3 — 여러 id 를 조회하며 사고는 건너뛰고 끝까지 가기
해야 할 일
회원 id 목록(List<Long>)을 하나씩 조회하는데, 중간에 없는 id 가 섞여 있어도 멈추지 말고 끝까지 진행하세요. 그리고 finally 로 "총 몇 번 시도했는지" 를 빠짐없이 세보세요.
요구사항
Repository<Member>에 회원 몇 명을 저장해두고, 조회할 id 목록에는 있는 id 와 없는 id 를 섞어요.- 향상된 for로 id 목록을 돌면서, 각 id 마다
try안에서findById로 조회해 성공하면 username 을 출력해요. - 없는 id 라
IllegalArgumentException이 나면catch로 잡아"id N: 없는 회원 — 건너뜀"을 출력하고, 멈추지 말고 다음 id 로 계속 가요. - 각 id 처리의
finally에서 시도 횟수 카운터를 1 올려, 마지막에 "총 N번 시도" 를 출력해요.
힌트
- try-catch 를 for 루프 "안" 에 넣는 게 핵심이에요. 그래야 한 id 에서 사고가 나도 루프 자체는 계속 돌아요.
- 카운터 증가를
finally에 두면, 성공하든 실패하든 빠짐없이 세져요. "무슨 일이 있어도 실행" 되는 finally 의 성격을 활용하는 거예요.
생각해볼 주제
오늘 배운 예외 잡기 뒤에는 "그래서 예외를 어떻게 다루는 게 좋은가?" 하는 질문이 숨어 있어요. 정답을 외우기보다 곰곰이 생각해보면 도구를 보는 눈이 깊어져요.
주제 1 — 예외를 잡고 아무것도 안 하면 어떻게 될까?
try-catch 로 예외를 잡으면 프로그램이 안 멈춰서 편해요. 그래서 가끔 catch 블록을 텅 비워두고 싶은 유혹이 생겨요. "일단 안 멈추게만 하자" 면서요. 그런데 예외를 잡아놓고 catch 에서 아무것도 안 하면(이걸 "예외를 삼킨다" 고 해요), 어떤 일이 벌어질까요? Step 3에서 readUsernameSafely 는 잡은 다음 "(알 수 없음)" 이라는 분명한 대안을 줬어요. 만약 그냥 비워뒀다면 무엇이 달라질지, 그리고 사고가 났는데도 조용히 넘어가는 게 왜 더 위험한지 생각해보세요.
주제 2 — catch (Exception e) 하나로 다 잡으면 편한데, 왜 구체적으로 잡을까?
Step 4에서 우리는 IllegalArgumentException 과 RuntimeException 을 나눠서 잡았어요. 그런데 생각해보면 맨 위 조상인 catch (Exception e) 하나만 두면 모든 예외가 다 걸려들어서 훨씬 편할 것 같아요. 코드도 짧아지고요. 그런데도 실무에서는 "잡을 예외를 구체적으로 적으라" 고 해요. 모든 예외를 뭉뚱그려 하나로 잡으면, 서로 다른 사고에 "똑같은 대응" 을 하게 되죠. 없는 회원과 형변환 실패에 같은 메시지를 주는 게 과연 괜찮을지, 구체적으로 나눠 잡을 때 무엇이 좋아지는지 따져보세요.
주제 3 — finally 없이 try 다음 줄에 정리 코드를 두면 안 될까?
finally 는 "무슨 일이 있어도 실행" 된다고 했어요. 그런데 의문이 들 수 있어요. "그냥 try-catch 다음 줄에 정리 코드를 적으면, 어차피 거기까지 흘러오니까 똑같지 않나요?" 얼핏 맞아 보이지만, 함정이 있어요. try 블록 안에서 return 으로 빠져나가거나, catch 가 못 잡는 예외가 올라가버리면 어떻게 될까요? 그 경우 "try-catch 다음 줄" 은 건너뛰어지지만 finally 는 실행돼요. 어떤 상황에서 이 차이가 진짜 사고로 이어질지(예: 열어둔 자원을 못 닫는 경우) 그려보세요.
✅ 예시 답안정답 보기
오늘 과제는 "이미 던져진 예외를 try-catch 로 잡아 프로그램이 멈추지 않게 한다" 가 핵심이에요. 예외를 직접 던지는 throw·throws 는 다음 시간 주제라 오늘 답안에는 안 나와요. 세 과제 모두 정답이 하나는 아니에요. 아래 예시와 다르게 풀었더라도 요구사항을 만족하고 try-catch·다중 catch·finally 를 제대로 썼다면 훌륭한 답이에요. 순회는 람다·Stream 없이 향상된 for로만 했어요.
과제 1 예시답안 — 안전하게 게시물 꺼내기
핵심 접근
이 과제의 주제는 "위험한 한 줄을 try 로 감싸 사고를 잡는다" 예요. List.get(index) 는 목록 크기를 벗어난 번호를 넣으면 IndexOutOfBoundsException(범위 벗어남 예외) 을 던져요. 이걸 catch 로 받아내면 프로그램이 그 자리에서 죽지 않고, 대신 안내 문구를 돌려주고 다음으로 넘어갈 수 있어요. Step 3의 readUsernameSafely 와 똑같은 구조예요 — 위험한 줄을 try 에, 복구 계획을 catch 에.
예시 구현
// com/instagram/javabasic/exceptionbasic/solution/day21/SafePostReader.java
public class SafePostReader {
// 안전하게 제목 꺼내기 — 정상 번호면 그 제목을, 범위 밖이면 안내 문구를 돌려줘요.
public String getTitleSafely(List<String> titles, int index) {
try {
return titles.get(index);
} catch (IndexOutOfBoundsException e) {
return "(없는 게시물)";
}
}
}
try 안에 위험한 titles.get(index) 한 줄을 넣었어요. 번호가 정상이면 그 제목이 그대로 return 되고, 목록 크기를 벗어나면 get 이 IndexOutOfBoundsException 을 던지는데 그 순간 곧장 catch 로 점프해 "(없는 게시물)" 을 돌려줘요. 예외가 메서드 밖으로 새어 나가지 않으니, 이 메서드를 부른 쪽은 멈추지 않고 계속 진행해요.
main 으로 확인해 볼게요.
// com/instagram/javabasic/exceptionbasic/solution/day21/SafePostReader.java
public static void main(String[] args) {
SafePostReader reader = new SafePostReader();
List<String> titles = new ArrayList<>();
titles.add("첫 게시물");
titles.add("점심 인증");
titles.add("저녁 노을");
// 정상 번호 — 그 자리의 제목이 그대로 나와요.
System.out.println("0번 제목: " + reader.getTitleSafely(titles, 0));
System.out.println("2번 제목: " + reader.getTitleSafely(titles, 2));
// 범위 밖 번호(100) — 예외가 잡혀서 안내 문구가 나오고, 프로그램은 죽지 않아요.
System.out.println("100번 제목: " + reader.getTitleSafely(titles, 100));
// 위에서 예외가 났어도 이 줄까지 무사히 실행돼요(복구의 증거).
System.out.println("끝까지 잘 실행됐어요!");
}
실행하면 이런 결과가 나와요.
0번 제목: 첫 게시물
2번 제목: 저녁 노을
100번 제목: (없는 게시물)
끝까지 잘 실행됐어요!
100번이라는 말도 안 되는 번호를 넣었는데도 프로그램이 죽지 않고 "(없는 게시물)" 을 돌려준 다음, 맨 아래 "끝까지 잘 실행됐어요!" 까지 찍혔죠? 예외를 잡았기 때문에 사고 이후로도 프로그램이 살아남은 거예요.
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| try-catch 구조 | 위험한 get 을 try 에, 복구를 catch 에 두었는가 |
상 |
| 정확한 예외 타입 | IndexOutOfBoundsException 을 잡는가 |
상 |
| 복구 값 반환 | 예외 시 "(없는 게시물)" 을 돌려주는가 |
중 |
| 멈추지 않음 확인 | main 에서 범위 밖 호출 뒤에도 다음 줄이 실행되는가 | 중 |
흔한 실수
- 예외 타입을 너무 넓게 잡기 →
catch (Exception e)로 다 잡아도 동작은 하지만, "범위를 벗어났다" 는 구체적 의미가 흐려져요. 무엇을 복구하는지 분명히 하려면IndexOutOfBoundsException으로 좁혀 잡는 게 좋아요. - catch 에서 아무것도 안 돌려주기 → catch 안을 비워두고 메서드가
null을 돌려주면, 부른 쪽에서 그null때문에 또 다른 사고가 나기 쉬워요. "잡았으면 무엇으로 대신할지" 까지 정하는 게 복구의 완성이에요.
실무 개선 포인트 (심화)
지금은 범위 밖 번호를 "예외로 잡아" 처리했어요. 그런데 사실 if (index >= 0 && index < titles.size()) 로 미리 검사해서 예외 자체가 안 나게 막을 수도 있어요. 둘 다 맞는 방법이지만, 보통 "정상적으로 자주 일어나는 상황" 은 if 로 미리 거르고, "정말 예상 밖일 때만" 예외로 다루는 걸 권해요. 예외를 발생시키고 잡는 건 if 한 줄보다 비용이 크거든요. "이건 흔한 일인가, 드문 사고인가" 를 기준으로 골라보세요.
과제 2 예시답안 — 두 종류 사고를 구분해 처리하기
핵심 접근
이번엔 catch 를 두 개 이어 붙여 사고를 종류별로 나눠 받는 거예요. 핵심은 순서 — 구체적인 IllegalArgumentException 을 위에, 넓은 RuntimeException 을 아래에 둬요. Step 2의 족보에서 봤듯이 RuntimeException 은 IllegalArgumentException 의 부모라, 부모를 위에 두면 자식이 영영 안 잡혀서 자바가 컴파일 단계에서 막아요. 지난 시간 Repository.findById 가 없는 id 에 던지는 예외를 여기서 받아내요.
예시 구현
// com/instagram/javabasic/exceptionbasic/solution/day21/MemberDescriber.java
public class MemberDescriber {
// 회원 소개 만들기 — 있는 id 면 "회원: 이름", 없으면 안내 문구를 돌려줘요.
public String describe(Repository<Member> repo, Long id) {
try {
Member member = repo.findById(id);
return "회원: " + member.getUsername();
} catch (IllegalArgumentException e) {
return "없는 회원이에요.";
} catch (RuntimeException e) {
return "알 수 없는 오류예요.";
}
}
}
try 안에서 repo.findById(id) 로 회원을 꺼내 username 문장을 만들어요. 없는 id 면 findById 가 IllegalArgumentException 을 던지고, 첫 번째 catch 가 받아 "없는 회원이에요." 를 돌려줘요. 그 밖에 예상 못 한 실행 중 오류는 두 번째 넓은 catch 가 받아 "알 수 없는 오류예요." 를 돌려주죠. 구체적 → 일반적 순서가 핵심이에요.
main 으로 확인해 볼게요.
// com/instagram/javabasic/exceptionbasic/solution/day21/MemberDescriber.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, 90));
MemberDescriber describer = new MemberDescriber();
// 있는 id — 이름을 담은 문장이 나와요.
System.out.println(describer.describe(repo, 1L));
System.out.println(describer.describe(repo, 2L));
// 없는 id — 예외가 잡혀서 안내 문구가 나오고, 프로그램은 멈추지 않아요.
System.out.println(describer.describe(repo, 999L));
System.out.println("끝까지 잘 실행됐어요!");
}
실행하면 이런 결과가 나와요.
회원: minji
회원: jaehoon
없는 회원이에요.
끝까지 잘 실행됐어요!
있는 id(1, 2)는 username 문장이, 없는 id(999)는 "없는 회원이에요." 가 나와요. 999 에서 예외가 났지만 catch 가 받아냈으니 그 다음 "끝까지 잘 실행됐어요!" 까지 무사히 찍혀요.
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| 다중 catch 순서 | 구체적(IllegalArgumentException) → 일반적(RuntimeException) 순인가 |
상 |
findById 예외 잡기 |
없는 id 의 IllegalArgumentException 을 첫 catch 로 받는가 |
상 |
| 정상 분기 | 있는 id 면 username 을 담은 문장을 돌려주는가 | 중 |
| 두 경우 검증 | main 에서 있는 id·없는 id 둘 다 호출했는가 | 하 |
흔한 실수
- 순서를 뒤집기 →
catch (RuntimeException e)를 위에 두면 자식인IllegalArgumentException이 영영 안 잡혀요. 자바가 "이 아래 catch 는 도달할 수 없어요" 하고 컴파일 단계에서 빨간 줄로 막아줘요. 좁은 것 먼저예요. - 두 사고에 같은 메시지 → 두 catch 가 똑같은 문구를 돌려주면 굳이 나눈 의미가 없어요. "없는 회원" 과 "알 수 없는 오류" 처럼 상황에 맞는 다른 안내를 주는 게 나눠 잡는 이유예요.
실무 개선 포인트 (심화)
지금은 catch 에서 안내 문구(String)를 돌려줬어요. 실무에서는 "회원을 못 찾았다" 같은 상황을 그 도메인에 딱 맞는 내 예외(예: MemberNotFoundException)로 다시 표현하는 경우가 많아요. 그러면 부른 쪽이 "아, 회원이 없는 거구나" 를 타입만 보고 바로 알 수 있죠. 이렇게 내 손으로 예외를 설계하고 던지는 법은 바로 다음 시간(throw)과 그 다음 시간(커스텀 예외)에서 이어서 배워요.
과제 3 예시답안 — 여러 id 를 조회하며 사고는 건너뛰고 끝까지 가기
핵심 접근
이 과제의 숨은 목표는 "한 건의 실패가 전체를 멈추지 못하게 한다" 와 "finally 로 빠짐없이 센다" 두 가지예요. try-catch 를 for 루프 "안" 에 넣는 게 핵심이에요. 그래야 한 id 에서 사고가 나도 그 한 바퀴만 catch 로 처리되고 루프는 계속 돌아요. 그리고 시도 횟수 카운터를 finally 에 두면 성공이든 실패든 무조건 세지니, 마지막 숫자가 정확히 id 개수와 맞아요.
예시 구현
// com/instagram/javabasic/exceptionbasic/solution/day21/BatchMemberLookup.java
public class BatchMemberLookup {
// 목록 전체 조회 — 각 id 를 차례로 찾아 성공/건너뜀을 한 줄씩 모으고,
// 마지막에 "총 N번 시도" 를 덧붙여 한 덩어리 문자열로 돌려줘요.
public String lookupAll(Repository<Member> repo, List<Long> ids) {
StringBuilder result = new StringBuilder();
int attempts = 0; // 시도 횟수 — finally 에서 한 건마다 +1
for (Long id : ids) {
try {
Member member = repo.findById(id);
result.append("회원: ").append(member.getUsername()).append("\n");
} catch (IllegalArgumentException e) {
// 없는 번호 — 멈추지 않고 건너뜀으로 표시한 뒤 다음 번호로 넘어가요.
result.append("건너뜀: id ").append(id).append("\n");
} finally {
// 성공이든 건너뜀이든 무조건 실행 — 그래서 시도 횟수가 빠짐없이 세져요.
attempts++;
}
}
result.append("총 ").append(attempts).append("번 시도");
return result.toString();
}
}
향상된 for로 id 를 하나씩 돌면서, 각 바퀴마다 try 안에서 findById 로 조회해요. 성공이면 username 을 기록하고, 없는 id 라 IllegalArgumentException 이 나면 catch 가 "건너뜀" 을 기록한 뒤 다음 바퀴로 자연스럽게 넘어가요. finally 의 attempts++ 는 성공·실패와 상관없이 매 바퀴 실행되니, 마지막 시도 횟수가 빠짐없이 세져요.
main 으로 확인해 볼게요.
// com/instagram/javabasic/exceptionbasic/solution/day21/BatchMemberLookup.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, 90));
// 있는 번호와 없는 번호를 섞은 목록 — 중간 실패에도 끝까지 진행돼요.
List<Long> ids = new ArrayList<>();
ids.add(1L);
ids.add(999L);
ids.add(2L);
BatchMemberLookup lookup = new BatchMemberLookup();
System.out.println(lookup.lookupAll(repo, ids));
}
실행하면 이런 결과가 나와요.
회원: minji
건너뜀: id 999
회원: jaehoon
총 3번 시도
중간의 999 에서 사고가 났지만 멈추지 않고 "건너뜀" 으로 처리한 뒤, 그 다음 2번(jaehoon)까지 끝까지 조회했죠? 그리고 "총 3번 시도" 가 정확히 목록 크기(3)와 맞아요 — 성공 2건과 실패 1건을 finally 가 빠짐없이 셌기 때문이에요.
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| 루프 안 try-catch | try-catch 를 for "안" 에 두어 한 건 실패가 전체를 안 멈추는가 | 상 |
| 계속 진행 | 없는 id 를 catch 한 뒤에도 다음 id 로 이어가는가 | 상 |
| finally 카운트 | 시도 횟수를 finally 에서 올려 성공·실패 모두 세는가 |
상 |
| 시도 횟수 정확성 | 최종 횟수가 ids.size() 와 일치하는가 |
중 |
흔한 실수
- try-catch 를 루프 밖에 두기 → 루프 전체를 하나의 try 로 감싸면, 첫 사고에서 catch 로 빠져나가 나머지 id 는 조회도 못 해요. "한 건씩 잡으려면" try-catch 가 루프 안에 있어야 해요.
- 카운터를 try 안에 두기 →
attempts++를 try 마지막 줄에 두면, 예외가 난 바퀴에서는 그 줄에 도달하기 전에 catch 로 점프해서 안 세져요. 그러면 시도 횟수가 실패 건수만큼 모자라요. "무조건 세려면" finally 가 정답이에요.
실무 개선 포인트 (심화)
지금은 실패한 id 를 "건너뜀" 으로 기록만 하고 넘어갔어요. 실무에서는 이렇게 일부만 실패하는 "부분 성공" 상황을 어떻게 처리할지가 중요한 설계 결정이에요. 실패 목록을 따로 모아 호출한 쪽에 함께 돌려주거나, 실패가 일정 개수를 넘으면 전체를 중단하는 식으로 정책을 정하죠. "한 건이라도 실패하면 전부 취소" 가 맞을 때도 있고 "되는 것만이라도 처리" 가 맞을 때도 있어요. 정답은 그 기능이 무엇을 보장해야 하느냐에 달려 있어요.
생각해볼 주제 1 예시답안 — 예외를 잡고 아무것도 안 하면 어떻게 될까?
[문제 상황 요약]
try-catch 로 예외를 잡으면 프로그램이 안 멈춰서 편해요. 그래서 catch 블록을 텅 비워두고 싶은 유혹이 생겨요. "일단 안 멈추게만 하자" 면서요. Step 3의 readUsernameSafely 는 잡은 다음 "(알 수 없음)" 이라는 분명한 대안을 줬는데, 만약 그냥 비워뒀다면 무엇이 달라질까요? 사고가 났는데도 조용히 넘어가는 게 왜 더 위험할까요?
[튜터의 가이드 및 해설]
핵심은 "예외를 잡는 것" 과 "예외를 해결하는 것" 은 완전히 다르다는 거예요. 텅 빈 catch 는 사고를 해결한 게 아니라, 사고가 났다는 사실 자체를 숨긴 거예요.
빈 catch vs 복구하는 catch
catch (Exception e) { } catch (ClassCastException e) {
── 사고를 "숨김" return "(알 수 없음)";
프로그램은 안 멈추지만 }
잘못된 상태로 계속 굴러감 ── 사고를 "복구"
→ 나중에 엉뚱한 데서 터짐 분명한 대안으로 정상 흐름 유지
빈 catch 의 진짜 무서움은 "사고가 났는데도 아무 일 없는 척" 한다는 거예요. 예를 들어 값을 못 읽었는데 그 자리에 아무것도 안 채우면, 그 빈 값(예: null)이 그대로 다음 코드로 흘러가요. 그러다 한참 뒤 전혀 상관없어 보이는 곳에서 갑자기 터지죠. 그러면 진짜 원인(여기서 못 읽은 것)을 찾기가 훨씬 어려워져요. 사고 지점과 발견 지점이 멀어질수록 디버깅 비용이 커지거든요.
반면 readUsernameSafely 처럼 "(알 수 없음)" 이라는 대안을 주면, 프로그램이 안 멈추는 동시에 "여긴 못 읽어서 기본값으로 대신했다" 는 정상적인 상태가 돼요. 다음 코드는 이 분명한 값을 받아 문제없이 이어가죠. 이게 "잡는다" 가 아니라 "복구한다" 의 의미예요.
그래서 catch 를 쓸 땐 최소한 셋 중 하나는 해야 해요. 대안 값으로 복구하거나, 어디서 무슨 사고가 났는지 기록(로그)을 남기거나, 여기서 못 다루면 다시 위로 알리거나. 셋 다 안 하고 비워두는 건, 경고등을 테이프로 가려버리는 것과 같아요.
🎯 면접관을 홀리는 핵심 멘트
"예외를 잡는 것과 해결하는 것은 다릅니다. 빈 catch 는 사고를 복구한 게 아니라 사고가 났다는 사실을 숨긴 것이라, 잘못된 상태가 그대로 흘러가 한참 뒤 엉뚱한 곳에서 터집니다. 그래서 catch 에서는 최소한 대안 값으로 복구하거나, 로그를 남기거나, 다시 위로 전파하거나 — 셋 중 하나는 반드시 해야 한다는 게 핵심입니다."
생각해볼 주제 2 예시답안 — catch (Exception e) 하나로 다 잡으면 편한데, 왜 구체적으로 잡을까?
[문제 상황 요약]
Step 4에서 우리는 IllegalArgumentException 과 RuntimeException 을 나눠서 잡았어요. 그런데 맨 위 조상인 catch (Exception e) 하나만 두면 모든 예외가 다 걸려들어 훨씬 편할 것 같아요. 코드도 짧아지고요. 그런데도 실무에서는 "잡을 예외를 구체적으로 적으라" 고 해요. 왜 그럴까요?
[튜터의 가이드 및 해설]
핵심은 "다른 사고에는 다른 대응이 필요하다" 예요. 모든 예외를 하나로 뭉뚱그려 잡으면, 서로 완전히 다른 사고에 똑같은 대응을 하게 돼요.
한 그물로 다 잡기 vs 종류별로 잡기
catch (Exception e) catch (IllegalArgumentException e) → "없는 회원"
── 없는 회원도, 형변환 실패도, catch (IllegalStateException e) → "지금 불가"
네트워크 오류도 전부 한 곳 catch (RuntimeException e) → "알 수 없음"
→ 다 똑같은 메시지/대응 ── 사고마다 알맞은 안내와 복구
→ 진짜 심각한 사고도 같이 삼킴
catch (Exception e) 하나의 문제는 두 가지예요. 첫째, 없는 회원과 형변환 실패는 원인도 대응도 다른데 같은 메시지를 주게 돼요. 사용자도 개발자도 "정확히 뭐가 문제인지" 를 알 수 없죠. 둘째, 더 위험한 건 "내가 예상한 사고" 와 "예상 못 한 심각한 사고" 까지 한꺼번에 삼킨다는 거예요. 정말 코드를 고쳐야 하는 버그성 예외마저 "알 수 없는 오류" 로 조용히 덮어버려요.
구체적으로 나눠 잡으면 사고마다 알맞게 대응할 수 있어요. 없는 회원이면 "그런 회원 없어요", 상태가 안 맞으면 "지금은 할 수 없어요" 처럼요. 그리고 내가 미처 예상 못 한 예외는 일부러 안 잡고 위로 올려보내, "이건 진짜 점검이 필요한 사고" 로 드러나게 둘 수도 있죠.
물론 catch (Exception e) 가 늘 나쁜 건 아니에요. 프로그램 가장 바깥(최상위)에서 "어떤 사고든 최후에 한 번은 받아 로그를 남기고 사용자에겐 정중한 안내를 주는" 안전망으로는 쓸모가 있어요. 핵심은 "구체적인 대응이 필요한 안쪽에서는 좁게, 최후의 안전망으로는 넓게" 라는 위치 감각이에요.
🎯 면접관을 홀리는 핵심 멘트
"예외를 구체적으로 잡는 이유는 서로 다른 사고에 서로 다른 대응을 하기 위해서입니다.
catch (Exception e)하나로 뭉뚱그리면 없는 회원과 심각한 버그가 같은 처리를 받고, 예상 못 한 예외까지 조용히 삼켜 버립니다. 그래서 비즈니스 로직 안쪽은 구체적 예외로 좁게 잡고,catch (Exception)같은 넓은 그물은 최상위 안전망으로만 제한한다는 게 핵심입니다."
생각해볼 주제 3 예시답안 — finally 없이 try 다음 줄에 정리 코드를 두면 안 될까?
[문제 상황 요약]
finally 는 "무슨 일이 있어도 실행" 된다고 했어요. 그런데 의문이 들 수 있어요. "그냥 try-catch 다음 줄에 정리 코드를 적으면, 어차피 거기까지 흘러오니까 똑같지 않나요?" 얼핏 맞아 보이지만, try 안에서 return 으로 빠져나가거나 catch 가 못 잡는 예외가 올라가면 어떻게 될까요?
[튜터의 가이드 및 해설]
핵심은 "try 다음 줄" 과 "finally" 가 흘러가는 길이 다르다는 거예요. 정상적으로 끝까지 흐를 때만 둘이 같아 보이고, 중간에 빠져나가는 순간 완전히 갈라져요.
정리 코드를 "try 다음 줄" 에 둘 때 vs "finally" 에 둘 때
① try 안에서 return ② catch 못 잡는 예외 올라감
try { ... return; } try { ... 💥 }
정리; ← 건너뜀 ❌ catch (다른예외) { } ← 매치 안 됨
정리; ← 건너뜀 ❌
finally { 정리; } → ①② 어느 경우든 실행됨 ✅
"try 다음 줄" 은 try-catch 가 정상적으로 끝나고 그 아래로 흘러올 때만 실행돼요. 그런데 try 안에서 return 으로 메서드를 바로 빠져나가면, 그 다음 줄은 건너뛰어져요. 또 catch 가 못 잡는 종류의 예외가 터지면, 그 예외는 메서드 밖으로 올라가 버리면서 역시 다음 줄을 건너뛰죠. 두 경우 모두 "정리 코드" 가 실행되지 못해요.
finally 는 바로 이 빈틈을 메워요. try 안에서 return 을 하든, catch 못 하는 예외가 올라가든, 무슨 일이 있어도 빠져나가기 직전에 finally 가 반드시 실행돼요. 그래서 "열었으면 반드시 닫아야 하는" 자원 정리를 finally 에 두는 거예요.
이 차이가 진짜 사고로 이어지는 대표적인 경우가 자원 누수예요. 파일이나 연결을 열어둔 채 중간에 예외가 올라가 닫는 코드를 건너뛰면, 그 자원이 닫히지 않고 계속 쌓여요. 이런 게 반복되면 결국 시스템이 자원을 다 써버려 멈추죠. "다음 줄에 두면 되지" 가 위험한 이유예요 — 정상일 때만 동작하고 정작 사고가 났을 때 안 돌거든요. (이 자원 정리를 더 안전하고 간결하게 해주는 try-with-resources 는 다음다음 시간에 배워요.)
🎯 면접관을 홀리는 핵심 멘트
"
finally와 'try 다음 줄' 은 정상 흐름에서만 같고, try 안 return 이나 catch 가 못 잡는 예외가 올라가는 순간 갈라집니다. 다음 줄은 건너뛰어지지만 finally 는 반드시 실행되죠. 그래서 자원 닫기처럼 '무슨 일이 있어도 해야 하는 뒷정리' 는 finally 에 둬야 하며, 안 그러면 사고 시 자원 누수로 이어진다는 점이 핵심입니다."