Day 23 — 예외 처리 ③: 나만의 예외를 설계하다
목차 25
지난 시간 회원 가입 검증기를 만들면서 살짝 불편한 게 있었죠. 빈 이름도, 너무 긴 이름도, 어린 나이도 전부 똑같은 IllegalArgumentException 으로 던지다 보니, 잡는 쪽에서는 메시지 글자를 읽어야만 무슨 사고인지 구분할 수 있었어요. 그리고 끝자락에 질문 하나도 남겨뒀어요. "예외를 잡아서 다른 예외로 바꿔 던지면, 원래 원인은 어디로 갈까?"
하나 더 있어요. Day 21 에서 자원을 정리할 때 finally 블록에 닫는 코드를 직접 적었잖아요. 깜빡하면 안 되고, 닫는 코드가 길어지면 본문보다 뒷정리가 더 커지는 부담도 있었죠.
오늘은 이 세 가지를 전부 해결해요. 이름만 봐도 무슨 사고인지 아는 커스텀 예외, 바꿔 던져도 원인을 잃지 않는 예외 체이닝, 그리고 닫는 일을 자바에게 맡기는 try-with-resources 까지. 예외 3부작의 마지막, "잘 설계된 예외" 를 만드는 날이에요. 시작해볼게요!
🎯 학습 목표
- 자바 기본 예외 대신, 이름이 사고를 설명하는 커스텀 예외 클래스를 직접 설계할 수 있어요.
- 예외를 잡아 다른 예외로 바꿔 던질 때, 예외 체이닝(원인 보존)으로 처음 사고의 흔적을 지킬 수 있어요.
- try-with-resources 로 자원을 자동으로 정리해서, finally 에 닫는 코드를 직접 적는 부담을 덜 수 있어요.
- 예외를 남용하는 안티패턴을 알아보고, 예외를 써야 할 곳과 쓰지 말아야 할 곳을 가릴 수 있어요.
오늘의 로드맵
- Step 1 — 표준 예외의 한계: 왜 커스텀 예외가 필요한지 잡는 쪽 입장에서 느껴봐요.
- Step 2 — 커스텀 예외 만들기:
extends RuntimeException한 줄로 나만의 예외를 설계해요. - Step 3 — 예외 체이닝: 잡아서 바꿔 던질 때 원인(cause)을 보존해요.
- Step 4 — 타입별 catch 실전: 지난 시간 검증기를 커스텀 예외로 업그레이드해요.
- Step 5 — 자원 정리의 불편: finally 로 직접 닫을 때 무엇이 번거로운지 확인해요.
- Step 6 — try-with-resources: 닫는 일을 자바에게 맡기는 문법을 배워요.
- Step 7 — 예외 남용 안티패턴: 예외 설계를 망가뜨리는 습관들을 가려내요.
- Step 8 — 종합: 회원 가입 흐름 하나에 오늘 배운 것을 모두 엮어요.
Step 1: 표준 예외의 한계 — 왜 커스텀 예외인가
지난 시간에 만든 회원 가입 검증기를 다시 꺼내볼게요. 세 가지 규칙을 throw 로 잘 막고 있었죠. 그런데 오늘은 이 코드를 "던지는 쪽" 이 아니라 "잡는 쪽" 의 눈으로 다시 봐요.
// com/instagram/javabasic/exceptionbasic/SignupValidator.java (Day 22)
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세 이상만 가입할 수 있어요.");
}
}
}
던지는 쪽 입장에서는 아무 문제가 없어요. 규칙을 어기면 막고, 통과하면 조용히 끝나니까요. 문제는 이 예외를 받아서 뭔가 해야 하는 쪽이에요.
상황을 하나 그려볼게요. 가입 화면을 만든다고 해봐요. 이름이 문제면 이름 입력칸을 빨갛게, 나이가 문제면 나이 입력칸을 빨갛게 표시하고 싶어요. 그런데 세 가지 사고가 전부 똑같은 IllegalArgumentException 이잖아요? 잡는 쪽 코드는 이렇게 쓸 수밖에 없어요.
// (시연용) 잡는 쪽 — 타입이 하나뿐이라, 메시지 글자로 사고를 구분해야 해요.
try {
validator.validate(username, age);
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("이름")) {
System.out.println("이름 입력칸을 빨갛게 표시해요.");
} else if (e.getMessage().contains("14세")) {
System.out.println("나이 입력칸을 빨갛게 표시해요.");
}
}
타입이 하나뿐이니 catch 도 하나뿐이고, 그 안에서 getMessage() 의 글자를 비교해서 사고를 구분하고 있어요. 일단 동작은 해요. 하지만 이건 꽤 아슬아슬한 코드예요.
왜 아슬아슬하냐면, 메시지는 사람에게 보여주려고 쓴 글이지 코드가 읽으라고 만든 약속이 아니거든요. 누군가 안내 문구를 "만 14세 이상만" 에서 "14살부터 가입 가능" 으로 살짝 다듬는 순간, contains("14세") 분기는 소리 없이 깨져요. 컴파일 에러도 안 나요. 그냥 조용히 엉뚱한 곳으로 흘러가는 거예요.
비유하자면 이래요. 병원에서 진단서를 받았는데 병명 없이 "몸이 안 좋음" 이라고만 적혀 있는 거예요. 다음 진료를 맡은 의사는 증상 설명을 처음부터 끝까지 다시 읽어야 무슨 병인지 짐작할 수 있어요. 진단서에 병명 한 줄만 있었어도 바로 처방으로 넘어갈 수 있는데 말이죠.
예외에서는 예외의 "이름(타입)" 이 곧 병명이에요. 지금 우리 검증기는 모든 환자에게 "몸이 안 좋음" 진단서를 떼주고 있는 셈이고요.
지금 — 한 타입으로 합쳐지는 깔때기 오늘 목표 — 이름이 사고를 말하는 예외
회원 없음 이메일 중복 캡션 위반 회원 없음 이메일 중복 캡션 위반
│ │ │ │ │ │
└──────────┼───────────┘ ▼ ▼ ▼
▼ MemberNotFound DuplicateEmail InvalidCaption
IllegalArgumentException Exception Exception Exception
│ │ │ │
▼ └───────────────┼───────────────┘
catch 가 1개뿐이라 메시지 ▼
글자를 읽어야만 구분돼요 catch 를 타입별로 나눠 다르게 대응해요
정리하면, 표준 예외만 쓰면 사고의 "종류" 가 타입에 담기지 않아요. 그래서 필요한 게 이름이 뜻을 말하는 예외, 이른바 커스텀 예외(custom exception, 우리 서비스 전용 예외)예요. MemberNotFoundException 이라는 이름이 보이는 순간, 메시지를 읽지 않아도 "아, 회원 조회가 실패했구나" 를 아는 거죠.
💡 튜터의 결론
표준 예외는 던지기엔 충분하지만, 잡는 쪽에서 사고를 "타입으로" 구분할 수 없어요. 메시지 글자 비교는 문구가 바뀌는 순간 조용히 깨지는 약한 고리예요. 사고의 종류를 타입에 담으려면, 이름이 상황을 설명하는 예외를 직접 만들어야 해요.
그럼 예외를 직접 만들려면 뭐가 필요할까요? 새 문법이 거창하게 나올 것 같지만, 사실 우리가 이미 아는 도구 하나면 충분해요. 다음 Step 에서 확인해볼게요.
Step 2: 커스텀 예외 만들기 — 예외도 결국 클래스다
오늘의 핵심 발견 하나로 시작할게요. 우리가 지금까지 수없이 던진 IllegalArgumentException 의 정체는 뭘까요? 사실 그냥 클래스예요. 자바를 만든 사람들이 미리 만들어둔, RuntimeException 을 상속한 평범한 클래스요.
그렇다면 답이 보이죠? 클래스를 상속하는 법은 우리가 이미 배웠잖아요. RuntimeException 을 상속하면, 우리도 예외를 만들 수 있어요. 진짜로 extends RuntimeException 한 줄이면 돼요. 인스타그램 서비스에서 가장 자주 일어날 사고, "회원을 찾지 못함" 부터 만들어볼게요.
// com/instagram/javabasic/exceptionbasic/MemberNotFoundException.java
// 회원을 id 나 이름으로 찾았는데 없을 때 던지는 우리 서비스 전용 예외예요.
// IllegalArgumentException 같은 자바 기본 예외 대신 이름에 상황을 그대로 담아서,
// 잡는 쪽이 "아, 회원 조회가 실패했구나" 를 예외 이름만 보고 바로 알 수 있어요.
// RuntimeException 을 상속했으니 throws 선언 없이 던질 수 있는 언체크 예외예요.
public class MemberNotFoundException extends RuntimeException {
// 메시지만 담아 만들어요 — "누구를 못 찾았는지" 를 글로 남겨요.
public MemberNotFoundException(String message) {
super(message);
}
// 메시지 + 원인(cause)을 함께 담아 만들어요 — 처음 사고의 흔적을 잃지 않고 보관해요.
public MemberNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
코드를 차근차근 봐요. 첫 줄 extends RuntimeException 이 전부예요. 이 한 줄로 MemberNotFoundException 은 예외 족보에 합류했고, throw 로 던질 수 있고, catch 로 잡을 수 있게 됐어요. 지난 시간 기준으로 보면 RuntimeException 자손이니 unchecked 예외라서, throws 선언 없이 자유롭게 던질 수 있고요.
그런데 클래스 본문이 좀 휑하죠? 필드도 없고, 새로운 메서드도 없고, 생성자 두 개가 전부예요. 이게 이상한 게 아니라 커스텀 예외의 정석이에요. 이 클래스의 가치는 기능이 아니라 이름 에 있거든요. 이름이 곧 진단서의 병명 역할을 하니까, 본문은 가벼울수록 좋아요.
생성자 두 개는 역할이 나뉘어요.
MemberNotFoundException(String message)— 메시지만 받아서super(message)로 부모에게 전달해요. 부모(RuntimeException)가 메시지를 보관해주니까, 잡는 쪽에서 늘 쓰던e.getMessage()가 그대로 동작해요.MemberNotFoundException(String message, Throwable cause)— 메시지에 더해 원인(cause, "이 사고를 일으킨 원래 예외")까지 함께 보관해요. 지금은 "처음 사고의 흔적을 담는 칸이 하나 더 있구나" 정도만 기억해두세요. 이 생성자는 다음 Step 예외 체이닝에서 진짜 힘을 발휘해요.
같은 틀로 두 개 더 만들어요. 이번엔 "이미 가입된 이메일" 사고예요.
// com/instagram/javabasic/exceptionbasic/DuplicateEmailException.java
// 이미 가입된 이메일로 다시 가입하려 할 때 던지는 우리 서비스 전용 예외예요.
// "회원이 없어요" 와 "이메일이 겹쳐요" 는 전혀 다른 사고인데, 둘 다
// IllegalArgumentException 으로 던지면 잡는 쪽이 구분할 수 없어요.
// 사고마다 예외 이름을 따로 두면 catch 를 타입별로 나눠 다르게 대응할 수 있어요.
public class DuplicateEmailException extends RuntimeException {
// 메시지만 담아 만들어요 — 어떤 이메일이 겹쳤는지를 글로 남겨요.
public DuplicateEmailException(String message) {
super(message);
}
// 메시지 + 원인(cause)을 함께 담아 만들어요 — 처음 사고의 흔적을 잃지 않고 보관해요.
public DuplicateEmailException(String message, Throwable cause) {
super(message, cause);
}
}
구조가 MemberNotFoundException 과 완전히 똑같죠? 클래스 이름과 주석만 다르고, 상속 한 줄 + 생성자 두 개라는 틀은 그대로예요. 캡션 규칙 위반용 InvalidCaptionException 도 같은 틀로 만들어뒀어요. 오늘 우리 서비스의 예외 삼총사를 표로 정리하면 이래요.
| 예외 이름 | 언제 던지나 |
|---|---|
MemberNotFoundException |
회원을 id 나 이름으로 찾았는데 없을 때 |
DuplicateEmailException |
이미 가입된 이메일로 다시 가입하려 할 때 |
InvalidCaptionException |
게시물 캡션이 규칙(글자 수 제한, 금지어 등)에 어긋날 때 |
이름 짓기 관례도 하나 챙겨가요. 예외 클래스 이름은 ~Exception 으로 끝나게 지어요. 그래야 코드 어디서 마주쳐도 "아, 예외구나" 를 바로 알 수 있거든요.
이제 던지고 잡아볼게요. 만드는 법이 클래스와 같았으니, 쓰는 법은 지난 시간 배운 throw·catch 그대로예요.
// 잡는 쪽 — 이제 메시지가 아니라 "타입" 으로 사고를 구분해요.
try {
throw new MemberNotFoundException("id 99 에 해당하는 회원이 없어요.");
} catch (MemberNotFoundException e) {
System.out.println("회원 조회 실패: " + e.getMessage());
} catch (DuplicateEmailException e) {
System.out.println("이메일 중복: " + e.getMessage());
}
지금은 시연이라 try 안에서 바로 던졌지만, 실전에서는 회원 조회나 가입 메서드가 던진 예외가 지난 시간 배운 전파를 타고 여기까지 올라와요. 중요한 변화는 catch 쪽이에요. Step 1 에서는 catch 가 하나뿐이라 메시지 글자를 비교했는데, 이제는 catch 를 타입별로 줄 세워서 사고마다 다르게 대응할 수 있어요. 메시지 문구를 아무리 다듬어도 이 분기는 깨지지 않아요.
이 세 예외가 약속대로 동작하는 건 코드베이스 CustomExceptionTest 에서 7가지 경우로 검증되어 있어요. 우리는 안심하고 가져다 쓰면 돼요.
마지막으로 족보에서 우리 예외들이 어디에 들어갔는지 확인해볼게요. Day 21 에서 그린 예외 계층도에 새 식구 셋이 늘었어요.
Throwable (맨 윗조상)
├── Error (손쓸 수 없는 심각한 사고)
└── Exception (checked — 처리 강제)
└── RuntimeException (unchecked — 강제 안 함)
├── IllegalArgumentException ◀ 자바가 미리 만들어둔 예외
├── MemberNotFoundException ◀ 오늘 우리가 만든 삼총사도
├── DuplicateEmailException ◀ extends RuntimeException 으로
└── InvalidCaptionException ◀ 같은 족보에 합류했어요
자바가 만든 예외와 우리가 만든 예외가 같은 족보에 나란히 있죠? 상속 덕분에 throw·catch·전파 같은 모든 예외 동작을 그대로 물려받은 거예요. 참고로 예외가 더 늘어나면 "우리 서비스 예외들의 공통 부모" 를 두고 한 번에 잡는 설계도 가능한데, 그 실전은 다음 시간(Day 24)에 해봐요.
🙋 학생 질문 — "튜터님, 왜 extends Exception 이 아니라 RuntimeException 인가요?"
지난 시간 checked vs unchecked 를 떠올려보세요. extends Exception 으로 만들면 checked 예외가 돼서, 이 예외를 던지는 메서드마다 throws 를 적어야 하고, 그 메서드를 부르는 쪽에도 throws 가 계속 번져요. 회원 조회처럼 서비스 곳곳에서 쓰이는 기능이라면 코드 전체가 throws 로 도배되겠죠.
게다가 "없는 회원 조회" 나 "중복 이메일 가입" 은 지난 시간 기준으로 보면 잘못된 입력·요청 계열이에요. IllegalArgumentException 이 unchecked 인 것과 같은 이유로, 우리 예외도 unchecked 가 자연스러워요. 그래서 실무에서도 서비스 전용 예외는 대부분 RuntimeException 을 상속해요.
물론 "바깥 사정으로 실패할 수 있는 일"(파일, 네트워크 등)을 표현하는 예외라면 checked 로 설계할 수도 있어요. 판단 기준은 지난 시간 배운 그대로예요.
💡 튜터의 결론
예외도 결국 클래스예요.
extends RuntimeException한 줄이면 우리 서비스 전용 예외가 생겨요. 본문이 비어 있어도 괜찮아요 — 이름 자체가 "무슨 사고인지" 를 말해주는 게 커스텀 예외의 가치니까요. 생성자는 메시지만 받는 것과 메시지 + 원인(cause)을 받는 것, 두 가지를 두는 게 기본형이에요.
그런데 아까부터 궁금하지 않았나요? 두 번째 생성자의 cause, 그러니까 "처음 사고의 흔적" 은 대체 언제 쓰는 걸까요? 예외를 잡아서 다른 예외로 바꿔 던지는 순간, cause 가 없으면 무슨 일이 벌어지는지부터 다음 Step 에서 직접 확인해볼게요.
Step 3: 예외 체이닝 — 잡아서 바꿔 던질 때 원인을 지키기
cause 가 언제 쓰이는지, 상황 하나로 풀어볼게요. Day 22 마지막에 만났던 회원 조회로 돌아가요. Day 20 에서 만든 Repository<Member> 는 없는 id 를 받으면 IllegalArgumentException 을 던졌죠.
그런데 이 저장소는 범용 부품이에요. Repository<Member> 로 쓰면 회원 저장소, Repository<Post> 로 쓰면 게시물 저장소가 되는 만능 틀이잖아요. 그러니 "회원을 못 찾았어요" 라고 말할 수 없고, "항목이 없어요" 라는 두루뭉술한 기술 언어로 말할 수밖에 없어요.
반면 회원 조회 서비스는 이제 MemberNotFoundException 이라는 분명한 언어를 갖고 있어요. 그래서 이런 흐름이 자연스러워요. 저장소의 예외를 서비스가 잡아서, 우리 서비스의 언어로 바꿔 다시 던지는 거예요. 통역과 비슷해요 — 기술 용어로 들어온 말을, 도메인의 말로 바꿔 전달하는 거죠.
그런데 바꿔 던지는 코드를 무심코 이렇게 쓰기 쉬워요.
// (시연용) cause 없이 바꿔 던지기 — 동작은 하지만, 처음 사고의 기록이 사라져요.
public Member findMember(Long id) {
try {
return repo.findById(id);
} catch (IllegalArgumentException e) {
throw new MemberNotFoundException("회원을 찾을 수 없어요: id " + id);
}
}
잡은 e 를 어디에도 쓰지 않고 새 예외만 만들어 던졌어요. 이 상태에서 아무도 예외를 안 잡으면(Day 22 에서 본 그 빨간 출력), 화면에는 이렇게 찍혀요.
Exception in thread "main" MemberNotFoundException: 회원을 찾을 수 없어요: id 99
at ExceptionChainingDemo.findMember(ExceptionChainingDemo.java:27)
at ExceptionChainingDemo.main(ExceptionChainingDemo.java:42)
(출력 끝 — 저장소 안에서 무슨 일이 있었는지는 어디에도 없어요)
여기서 끝이에요. 저장소 안에서 어떤 예외가 시작됐는지, 메시지가 뭐였는지, 몇 번째 줄이었는지 전부 사라졌어요. 새 예외를 만들면서 원래 예외 e 를 버렸으니까요.
지금이야 원인이 뻔해 보이지만, 몇 달 뒤 이 출력만 들고 디버깅하는 미래의 나를 상상해보세요. "회원을 찾을 수 없다는데… 저장소 문제야? id 계산이 잘못된 거야? 어디서부터 봐야 하지?" 사고 현장의 증거를 태워버린 셈이라, 추적을 처음부터 다시 시작해야 해요.
⚠️ 주의 — 예외를 잡아 다른 예외로 바꿔 던지는 것 자체는 좋은 설계예요. 하지만 잡은 예외를 버리고 새 예외만 던지면, 처음 사고의 기록이 통째로 사라져요. 디버깅 단서가 끊기는 거예요.
이 문제를 해결하는 게 Step 2 에서 만들어둔 두 번째 생성자예요. 새 예외를 만들 때 원래 예외 e 를 함께 넘기는 것, 이름하여 예외 체이닝(exception chaining, 예외 연결)이에요. 검증이 끝난 실제 코드를 볼게요.
// com/instagram/javabasic/exceptionbasic/ExceptionChainingDemo.java
// 예외 체이닝(연결) — 잡은 예외를 더 의미 있는 예외로 "재포장" 하되, 원래 예외를 버리지 않고
// cause(원인) 자리에 담아 넘기는 기술이에요.
// 저장소가 던진 IllegalArgumentException → 잡아서 → MemberNotFoundException(메시지, 원인) 으로 다시 던져요.
// 잡는 쪽은 의미가 분명한 예외 이름으로 상황을 알고, getCause() 로 처음 사고까지 추적할 수 있어요.
public class ExceptionChainingDemo {
// 회원 저장소 — 지난 시간처럼 외부에서 전달받아 보관해요.
private final Repository<Member> repo;
public ExceptionChainingDemo(Repository<Member> repo) {
this.repo = repo;
}
// 재포장하는 쪽 — 저장소의 기술적인 예외(IllegalArgumentException)를 잡아서,
// 우리 서비스의 언어로 말하는 예외(MemberNotFoundException)로 바꿔 던져요.
// 두 번째 인자 e 가 핵심이에요. 원래 예외를 cause 로 담아 흔적을 보존해요.
public Member findMember(Long id) {
try {
return repo.findById(id);
} catch (IllegalArgumentException e) {
throw new MemberNotFoundException("회원을 찾을 수 없어요: id " + id, e);
}
}
}
아까의 나쁜 버전과 비교하면 바뀐 건 끝부분 하나예요. new MemberNotFoundException("...", e) — 두 번째 인자로 잡은 예외 e 를 함께 넘겼어요. 이 작은 차이로 출력이 이렇게 달라져요.
Exception in thread "main" MemberNotFoundException: 회원을 찾을 수 없어요: id 99
at ExceptionChainingDemo.findMember(ExceptionChainingDemo.java:27)
at ExceptionChainingDemo.main(ExceptionChainingDemo.java:42)
Caused by: java.lang.IllegalArgumentException: id 99 에 해당하는 항목이 없어요.
at Repository.findById(Repository.java:31)
at ExceptionChainingDemo.findMember(ExceptionChainingDemo.java:25)
... 1 more
"Caused by:"(원인은 이것) 라는 줄이 생겼죠? 읽는 법은 이래요. 위쪽이 최종적으로 던져진 예외(우리 서비스의 언어), "Caused by:" 아래가 그 원인이 된 처음 예외(저장소의 언어)예요. 어떤 타입이, 어떤 메시지로, 어느 줄에서 시작됐는지까지 고스란히 남아요. 미래의 내가 이 출력을 보면 "아, 저장소 조회에서 시작된 사고구나" 를 바로 알 수 있는 거예요.
스택트레이스 말고 코드에서도 원인을 꺼낼 수 있어요. getCause()(원인 꺼내기) 메서드예요. main 에서 겉과 속을 둘 다 들여다볼게요.
public static void main(String[] args) {
Repository<Member> repo = new Repository<>();
repo.save(1L, new Member("minji", 8500, 150, 12, 400));
ExceptionChainingDemo demo = new ExceptionChainingDemo(repo);
// 있는 id — 평소처럼 회원이 나와요.
System.out.println("조회 성공: " + demo.findMember(1L).getUsername());
// 없는 id — 재포장된 예외를 잡아서, 겉(메시지)과 속(원인)을 둘 다 들여다봐요.
try {
demo.findMember(99L);
} catch (MemberNotFoundException e) {
System.out.println("잡은 예외 : " + e.getMessage());
System.out.println("원인(cause) : " + e.getCause());
}
}
실행하면 있는 id 1번은 "조회 성공: minji" 가 나오고, 없는 99번은 재포장된 예외가 잡혀요. "잡은 예외" 줄에는 우리 서비스의 메시지가, "원인(cause)" 줄에는 java.lang.IllegalArgumentException: id 99 에 해당하는 항목이 없어요. 가 그대로 찍혀요. 겉과 속이 둘 다 살아 있는 거예요.
예외 체이닝 = 새 봉투로 옮기되, 원래 편지를 동봉하기
┌─ MemberNotFoundException ───────────────────┐ ◀ 겉봉투: 우리 서비스의 언어
│ 메시지: "회원을 찾을 수 없어요: id 99" │ (잡는 쪽이 읽는 이름)
│ │
│ ┌─ cause: 동봉된 원래 편지 ───────────┐ │
│ │ IllegalArgumentException │ │ ◀ 처음 사고의 기록
│ │ "id 99 에 해당하는 항목이 없어요." │ │ getCause() 로 꺼내요
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
이 동작(재포장, 원인 보존, getCause() 추적)은 코드베이스 ExceptionChainingDemoTest 에서 4가지 경우로 확인되어 있어요.
🙋 학생 질문 — "튜터님, 그냥 처음부터 저장소가 MemberNotFoundException 을 던지면 안 되나요?"
체이닝이 번거롭게 느껴지면 그런 생각이 들죠. 그런데 Repository<T> 가 어떤 부품인지 떠올려보세요. Repository<Member> 로 쓰면 회원 저장소, Repository<Post> 로 쓰면 게시물 저장소가 되는 만능 틀이에요. 이 부품이 MemberNotFoundException 을 던지면, 게시물 저장소가 "회원을 찾을 수 없어요" 라고 외치는 이상한 일이 생겨요.
그래서 역할을 나눠요. 범용 부품(저장소)은 범용 언어("항목이 없어요")로 말하고, 그 부품을 쓰는 회원 서비스가 자기 언어("회원을 찾을 수 없어요")로 번역해요. 그 번역 과정에서 원래 말을 잃지 않게 해주는 게 바로 예외 체이닝이고요.
이 "부품은 범용 언어, 서비스는 도메인 언어" 라는 구분은 앞으로 더 큰 프로그램을 설계할 때 계속 만나게 될 기준이에요. 지금은 "범용 부품에 우리 서비스 전용 예외를 넣지 않는다" 정도로 기억해두면 충분해요.
💡 튜터의 결론
예외를 잡아 더 의미 있는 예외로 바꿔 던질 땐, 두 번째 인자로 잡은 예외를 함께 넘기세요.
throw new MemberNotFoundException("...", e)— 이e하나로 스택트레이스에 "Caused by:" 가 남고,getCause()로 처음 사고까지 추적할 수 있어요. 바꿔 던지되, 원인은 지킨다. 이게 예외 체이닝이에요.
이제 도구가 다 모였어요. 이름이 뜻을 말하는 예외 삼총사에, 원인을 지키는 체이닝까지. 이걸 들고 Step 1 에서 아슬아슬하다고 했던 지난 시간 검증기를 고치러 가요.
Step 4: 타입별 catch 실전 — 회원 가입 검증기 V2
Step 1 에서 봤던 잡는 쪽 코드, 기억나시죠? e.getMessage().contains("이름") 으로 메시지 글자를 비교하던 그 아슬아슬한 분기요. 문구가 바뀌면 소리 없이 깨진다고 했었어요. 이제 그걸 고칠 도구가 전부 모였어요.
계획은 단순해요. 검사 규칙은 하나도 안 건드리고, 던지는 예외만 위반 종류별 전용 타입으로 갈라내는 거예요. 이름 문제용과 나이 문제용 예외 두 개가 새로 필요한데, 둘 다 Step 2 에서 배운 틀(extends RuntimeException + 생성자 2개) 그대로라 코드는 생략할게요.
| 예외 이름 | 언제 던지나 |
|---|---|
InvalidUsernameException |
사용자 이름이 비었거나 30자를 넘을 때 |
UnderageMemberException |
나이가 가입 기준(만 14세)에 못 미칠 때 |
이 두 예외를 장착한 검증기 V2 예요.
// com/instagram/javabasic/exceptionbasic/SignupValidatorV2.java
// 지난 시간의 SignupValidator 를 커스텀 예외 버전으로 키운 두 번째 검증기예요.
// 그때는 모든 위반을 IllegalArgumentException 하나로 던져서, 잡는 쪽이
// "이름 문제인지 나이 문제인지" 를 메시지를 읽어 봐야만 알 수 있었어요.
// 이제 위반의 종류마다 예외 타입을 나눠 던지니, 잡는 쪽이 catch 블록을
// 타입별로 갈라 다르게 대응할 수 있어요. 검사 규칙 자체는 그대로예요.
public class SignupValidatorV2 {
// 회원 가입 정보를 검사해요. 규칙을 어기면 "그 규칙 전용 예외" 로 막고,
// 모두 통과하면 아무것도 돌려주지 않고 조용히 끝나요(검사 통과 = 무사 통과).
public void validate(String username, int age) {
if (username == null || username.isEmpty()) {
throw new InvalidUsernameException("사용자 이름을 입력해주세요.");
}
if (username.length() > 30) {
throw new InvalidUsernameException("사용자 이름은 30자까지예요.");
}
if (age < 14) {
throw new UnderageMemberException("만 14세 이상만 가입할 수 있어요.");
}
}
}
Step 1 의 검증기와 나란히 놓고 보세요. if 조건도, 안내 메시지도 글자 하나 안 바뀌었어요. 달라진 건 throw new 뒤의 타입뿐이에요. 이름 규칙 위반은 InvalidUsernameException, 나이 규칙 위반은 UnderageMemberException. 진단서의 증상 설명은 그대로 두고, 병명 칸만 제대로 채운 업그레이드인 거죠.
진짜 변화는 잡는 쪽에서 일어나요. main 을 볼게요.
public static void main(String[] args) {
SignupValidatorV2 validator = new SignupValidatorV2();
// 정상 — 모든 규칙을 통과하면 예외 없이 조용히 끝나요.
validator.validate("minji", 25);
System.out.println("minji 님 가입 정보가 통과했어요.");
// 사고 1 — 빈 이름. 이름 전용 예외가 날아오니 이름 안내 catch 가 받아요.
// 사고 2 — 나이 미달. 나이 전용 예외라 아래쪽 catch 가 받아요.
// 같은 try 인데도 예외 타입 덕분에 대응이 갈라지는 걸 확인해보세요.
try {
validator.validate("", 25);
} catch (InvalidUsernameException e) {
System.out.println("이름을 확인해주세요 → " + e.getMessage());
} catch (UnderageMemberException e) {
System.out.println("가입 연령을 확인해주세요 → " + e.getMessage());
}
try {
validator.validate("jaehoon", 13);
} catch (InvalidUsernameException e) {
System.out.println("이름을 확인해주세요 → " + e.getMessage());
} catch (UnderageMemberException e) {
System.out.println("가입 연령을 확인해주세요 → " + e.getMessage());
}
}
catch 가 타입별로 두 줄로 갈라져 있고, 그 안에 메시지 비교 if 는 한 줄도 없어요. Step 1 다이어그램의 오른쪽 그림이 그대로 코드가 된 거예요. 예외가 날아오면 자바가 타입을 보고 알맞은 catch 로 배달해주니, 우리는 각 catch 안에서 그 사고에 맞는 대응만 적으면 돼요.
실행하면 이렇게 나와요.
minji 님 가입 정보가 통과했어요.
이름을 확인해주세요 → 사용자 이름을 입력해주세요.
가입 연령을 확인해주세요 → 만 14세 이상만 가입할 수 있어요.
빈 이름은 이름 안내 catch 가, 13세는 연령 안내 catch 가 받았어요. 가입 화면이라면 첫 번째는 이름 입력칸을, 두 번째는 나이 입력칸을 빨갛게 표시하면 되겠죠. 이제 안내 문구를 어떻게 다듬어도 이 분기는 깨지지 않아요. 글자가 아니라 타입이 약속이니까요.
이 검증기의 동작은 코드베이스 SignupValidatorV2Test 에서 7가지 경우로 확인되어 있어요.
💡 튜터의 결론
같은 검사 규칙이라도 "무슨 예외를 던지느냐" 가 잡는 쪽의 코드 품질을 결정해요. 위반의 종류마다 전용 예외를 던지면, catch 를 타입별로 갈라 메시지 비교 없이 다르게 대응할 수 있어요. 예외 설계는 던지는 쪽의 친절이에요 — 잡는 쪽이 편해지거든요.
이로써 예외 설계의 앞부분이 마무리됐어요. 그런데 숙제가 하나 남아 있어요. Day 21 에서 finally 블록에 자원 닫는 코드를 직접 적으면서 "깜빡하면 어쩌지" 싶었던 그 불편이요. 다음 Step 에서 그 불편을 다시 꺼내, 정확히 어디가 번거로운지부터 확인해볼게요.
Step 5: 자원 정리의 불편 — finally 로 직접 닫기
Day 21 마지막에 배운 finally 를 떠올려볼게요. "무슨 일이 있어도 마지막에 꼭 실행할 코드" 를 두는 곳이었죠. 대표적인 쓰임이 자원 정리였어요. 파일이나 연결을 열었으면, 도중에 사고가 나도 반드시 닫아야 하니까요.
오늘은 그 자원을 인스타그램답게 하나 떠올려볼게요. 사진을 서버에 올리는 이미지 업로드 세션이에요. 업로드를 시작하려면 서버와 연결을 열고, 다 올렸으면 반드시 닫아야 해요. 안 닫으면 연결이 줄줄 새서 서버가 금방 지쳐요.
이 세션을 Day 21 방식으로 쓰면 이렇게 돼요.
// (시연용) finally 로 직접 닫기 — Day 21 에서 배운 뒷정리 방식이에요.
ImageUploadSession session = new ImageUploadSession("피드");
try {
session.upload("cat.jpg");
session.upload("coffee.jpg");
} finally {
session.close(); // 사고가 나도 이 줄은 반드시 실행돼 세션을 닫아요.
}
동작은 완벽해요. upload 도중 예외가 나도 finally 가 session.close() 를 책임지니, 연결이 새는 일은 없어요. 그런데 쓰다 보면 번거로운 점이 눈에 들어와요.
첫째, session 변수를 try 바깥에 미리 선언해야 해요. finally 에서도 보여야 하니까요. 둘째, 닫는 코드 session.close() 를 사람이 직접 적어야 해요. 셋째, 이게 제일 큰데, 깜빡 잊으면 아무도 안 알려줘요. 컴파일 에러도 없고, 평소엔 멀쩡히 돌아가다가 트래픽이 몰릴 때 연결이 바닥나서야 터져요.
자원이 둘 이상이면 더 복잡해져요. 세션을 두 개 열면 finally 안에서 둘 다 닫아야 하고, 닫다가 또 사고가 날까 봐 순서까지 신경 써야 하거든요.
finally 방식 — 닫기 책임이 사람에게 남아요
① 자원 열기 ──► ImageUploadSession session = ... (try 바깥에 선언)
② try { 사용 }
③ finally { session.close(); } ◀── 사람이 직접 적어야 함
깜빡하면? 컴파일 통과, 나중에 연결 누수로 터짐
자원 2개면 → finally 안에서 둘 다, 닫는 순서까지 직접 관리
⚠️ 주의 — finally 자체는 안전한 도구예요. 문제는 "닫아야 한다는 사실" 과 "닫는 코드를 적는 일" 이 온전히 사람의 기억에 맡겨져 있다는 거예요. 사람은 깜빡하고, 그 한 번의 깜빡임이 자원 누수로 이어져요.
여기서 자연스러운 바람이 생겨요. 자원을 열었으면, 블록이 끝날 때 자바가 알아서 닫아주면 안 될까? 바로 그걸 해주는 문법이 다음 Step 의 주인공이에요.
Step 6: try-with-resources — 닫는 일을 자바에게 맡기기
자바는 Step 5 의 바람을 정확히 들어주는 문법을 갖고 있어요. 이름은 try-with-resources(자원을 동반한 try)예요. 사용법은 try 옆에 괄호를 하나 더 여는 거예요. 그 괄호 안에서 자원을 선언하면, 블록이 끝날 때 자바가 자동으로 닫아줘요.
딱 한 가지 조건이 있어요. 그 자원이 AutoCloseable(자동으로 닫을 수 있는) 인터페이스를 구현해야 해요. 이 인터페이스는 정말 단순해서, close() 메서드 하나만 약속해요. 리모컨에 비유하면, "닫기 버튼 하나만 달려 있으면 우리 자동 정리 시스템에 태워줄게" 라는 약속이에요.
우리 이미지 업로드 세션을 그 약속에 맞춰 만들어볼게요.
// com/instagram/javabasic/exceptionbasic/ImageUploadSession.java
// try-with-resources 실습용 가상 자원 — 이미지 업로드 세션이에요.
// 서버와 연결을 열어 두고 사진을 올리는 동안만 살아 있다가, 다 쓰면 반드시 닫아야 하는 자원이에요.
// AutoCloseable 을 구현하면 try-with-resources 가 블록이 끝날 때(정상이든 예외든)
// close() 를 자동으로 불러 줘요. "닫는 일을 깜빡하는 사고" 를 문법이 막아 주는 거예요.
public class ImageUploadSession implements AutoCloseable {
// 세션 이름 — 어느 세션이 열리고 닫히는지 출력으로 구분하려고 붙여요.
private final String name;
// 세션이 열려 있는지 — 닫힌 세션에 업로드하는 사고를 막는 안전장치예요.
private boolean open = true;
public ImageUploadSession(String name) {
this.name = name;
System.out.println("[" + name + "] 업로드 세션을 열었어요.");
}
// 세션이 아직 열려 있는지 알려줘요.
public boolean isOpen() {
return open;
}
// 사진 한 장을 업로드해요. 닫힌 세션이면 IllegalStateException 으로 막아요.
public void upload(String filename) {
if (!open) {
throw new IllegalStateException("[" + name + "] 이미 닫힌 세션이에요. 업로드할 수 없어요.");
}
System.out.println("[" + name + "] " + filename + " 업로드 완료!");
}
// 자원 정리 — try-with-resources 가 블록 끝에서 자동으로 불러 줘요.
// AutoCloseable 의 close() 는 원래 Exception 을 던질 수 있다고 선언돼 있지만,
// 우리는 던질 일이 없으니 throws 없이 재정의해요(호출자가 잡을 필요가 없어져요).
@Override
public void close() {
open = false;
System.out.println("[" + name + "] 업로드 세션을 닫았어요.");
}
}
새 문법은 두 군데예요. 클래스 선언에 붙은 implements AutoCloseable 가 "나는 자동 정리 시스템에 태울 수 있는 자원이에요" 라는 표지예요. 그리고 그 약속을 지키려고 close() 메서드를 @Override 로 재정의했어요. 닫을 때 할 일(여기서는 open 을 false 로 바꾸고 안내를 출력)을 이 안에 적어두면, 자바가 알아서 불러줘요.
close() 위 주석에 적힌 대로, AutoCloseable 의 close() 는 원래 예외를 던질 수 있게 선언돼 있어요. 하지만 우리 세션은 닫을 때 사고 날 일이 없으니, throws 없이 재정의했어요. 그러면 이 세션을 쓰는 쪽이 close 때문에 try-catch 를 더 쓸 필요가 없어져요.
이제 진짜로 자바에게 닫기를 맡겨볼게요.
public static void main(String[] args) {
// ① 정상 경로 — 블록이 끝나면 close() 가 자동으로 불려요. 닫는 코드를 쓴 적이 없는데도요.
try (ImageUploadSession session = new ImageUploadSession("피드")) {
session.upload("cat.jpg");
session.upload("coffee.jpg");
}
// ② 예외 경로 — 업로드 도중 캡션 검증이 터져도 close() 는 자동으로 불려요.
// 출력 순서를 보세요. "닫았어요" 가 먼저 나오고, 그 다음에 catch 가 받아요.
try {
try (ImageUploadSession session = new ImageUploadSession("피드")) {
session.upload("sunset.jpg");
throw new InvalidCaptionException("캡션은 2200자까지만 쓸 수 있어요.");
}
} catch (InvalidCaptionException e) {
System.out.println("업로드 중단: " + e.getMessage());
}
// ③ 자원 2개 — 세미콜론으로 나란히 열면, 나중에 연 것부터 거꾸로 닫혀요.
// [스토리] 열기 → [피드] 열기 → ... → [피드] 닫기 → [스토리] 닫기 순서예요.
try (ImageUploadSession story = new ImageUploadSession("스토리");
ImageUploadSession feed = new ImageUploadSession("피드")) {
story.upload("daily.jpg");
feed.upload("brunch.jpg");
}
}
Step 5 의 finally 코드와 비교해보세요. finally { session.close(); } 가 통째로 사라졌죠? try (...) 괄호 안에 자원을 선언한 것 하나로, 닫기를 자바가 떠맡았어요. 세 가지 경우를 차례로 봐요.
①번 정상 경로는 블록이 끝나자마자 close() 가 자동으로 불려요. "닫았어요" 출력이 우리가 적지도 않았는데 찍히는 거예요.
②번 예외 경로가 핵심이에요. 업로드 도중 InvalidCaptionException 이 터지는데도, 출력 순서를 보면 "닫았어요" 가 먼저 나오고 그 다음에 catch 의 "업로드 중단" 이 나와요. 사고가 나도 닫기가 먼저 끝난다는 뜻이에요. ③번은 자원을 둘 열었더니, 나중에 연 [피드] 가 먼저 닫히고 [스토리] 가 나중에 닫혀요. 짐을 쌓은 역순으로 푸는 것과 같아요.
try-with-resources 실행 순서 — close() 는 자바가 자동으로
try (Session s = open()) { ① 자원 열기
s.upload(...) ② 본문 실행 (여기서 예외가 나도)
} ③ 블록을 벗어나는 순간 close() 자동 호출 ★
catch (...) { ... } ④ (예외였다면) 그제야 catch 가 받아요
★ 정상으로 끝나든 예외로 끝나든, ③ 의 close() 는 반드시 먼저 일어나요
이 세션이 정상·예외 경로 모두에서 빠짐없이 닫히고, 자원 2개가 역순으로 닫히는 동작은 코드베이스 ImageUploadSessionTest 에서 4가지 경우로 확인되어 있어요.
🙋 학생 질문 — "튜터님, AutoCloseable 의 close() 는 Exception 을 던진다던데 왜 우리는 throws 를 안 붙였나요?"
좋은 관찰이에요. AutoCloseable 인터페이스의 close() 는 throws Exception 으로 선언돼 있어요. "닫는 도중에도 사고가 날 수 있다" 는 가장 넓은 가정을 깔아둔 거예요. 파일이나 네트워크처럼 닫기조차 실패할 수 있는 자원도 있으니까요.
그런데 재정의할 때 부모의 throws 를 그대로 따를 필요는 없어요. 자식은 "부모보다 좁게" 던질 수 있거든요. 우리 세션은 open 값만 바꾸고 출력만 하니 닫다가 터질 일이 없어요. 그래서 throws 를 떼고 재정의했어요.
이게 쓰는 쪽에 주는 선물이 커요. 만약 close() 가 checked 예외를 던진다면, 이 세션을 쓰는 try-with-resources 마다 그 예외를 잡거나 넘겨야 했을 거예요. throws 를 떼니 그 부담이 사라져, main 의 ①번처럼 catch 없이 깔끔하게 쓸 수 있게 됐어요.
💡 튜터의 결론
try-with-resources 는
try (자원 선언)한 줄로 finally 의 닫기 책임을 자바에게 넘기는 문법이에요. 조건은 그 자원이AutoCloseable을 구현하는 것 하나예요. 정상이든 예외든 블록을 벗어나면close()가 자동으로 불리고, 자원이 여럿이면 연 역순으로 닫혀요. "깜빡하고 안 닫는 사고" 를 사람의 기억이 아니라 문법이 막아주는 거예요.
도구는 이제 다 익혔어요. 커스텀 예외, 체이닝, try-with-resources 까지. 그런데 도구가 좋아도 잘못 쓰면 오히려 독이 돼요. 다음 Step 에서는 예외를 망치는 대표적인 나쁜 습관들을 모아 가려내볼게요.
Step 7: 예외 남용 안티패턴 — 이렇게 쓰면 안 돼요
지금까지는 "예외를 잘 쓰는 법" 을 배웠어요. 이번엔 방향을 뒤집어, "예외를 망치는 습관" 다섯 가지를 모아볼게요. 안티패턴(anti-pattern)은 자주 보이지만 따라 하면 안 되는 나쁜 본보기예요. 나쁜 예를 알아두면, 내 코드에서도 같은 냄새를 맡고 피할 수 있어요.
① 빈 catch — 예외를 삼키고 시치미 떼기
// (안티패턴 ①) 잡아만 놓고 아무것도 안 해요.
try {
repo.findById(id);
} catch (Exception e) {
// 아무것도 안 함 — 사고가 조용히 사라져요.
}
가장 위험한 습관이에요. 예외를 잡았지만 catch 안이 비어 있어요. 사고가 났는데 아무 흔적도 없이 사라지고, 프로그램은 아무 일 없던 듯 다음 줄로 가요. 나중에 "분명 회원을 못 찾았는데 왜 에러가 안 떴지?" 하고 헤매게 돼요.
빈 catch — 사고를 삼키고 시치미 떼기
예외 발생 ──► catch (Exception e) { } ──► 아무 일 없던 척 다음 줄로
(조용히 사라짐)
결과: 프로그램은 계속 돌지만, 무엇이 왜 틀어졌는지 아무도 모름
올바르게 가려면, 최소한 로그라도 남기거나, 복구하거나, 못 하면 다시 던져 위층에 알려야 해요.
② catch (Exception e) — 너무 큰 그물
// (안티패턴 ②) 너무 큰 그물 — 잡을 생각 없던 예외까지 다 잡혀요.
try {
Member m = repo.findById(id);
System.out.println(m.getUsername());
} catch (Exception e) { // NullPointerException 같은 진짜 버그까지 삼켜요.
System.out.println("회원을 찾을 수 없어요");
}
Exception 으로 잡으면 그 아래 모든 예외가 다 걸려요. 회원을 못 찾는 사고만 처리하려던 건데, 코드 어딘가의 NullPointerException 같은 진짜 버그까지 "회원을 찾을 수 없어요" 로 뭉뚱그려져요. 버그가 엉뚱한 메시지 뒤에 숨는 거예요. 잡을 예외의 타입은 좁게, 처리할 줄 아는 것만 잡아요.
③ 예외로 흐름 제어 — 정상 흐름에 예외를 쓰기
// (안티패턴 ③) 예외로 반복 빠져나오기 — 정상 종료에 예외를 쓴 거예요.
try {
int i = 0;
while (true) {
process(list.get(i)); // 끝에 닿으면 IndexOutOfBounds 가 나길 기대
i++;
}
} catch (IndexOutOfBoundsException e) {
// 여기로 빠져나옴 — 사실은 i < list.size() 조건으로 끝냈어야 해요.
}
리스트 끝까지 돈 다음 일부러 예외를 내서 반복을 끝냈어요. 예외라는 단어 뜻 그대로 "예외적인 일" 에 써야 하는데, 여기서는 "리스트 끝" 이라는 지극히 정상적인 상황에 썼어요. 코드를 읽는 동료는 "여기서 왜 예외가 나지?" 하고 멈칫하게 돼요. 정상 종료는 반복문 조건으로 끝내요.
④ printStackTrace 만 찍고 방치
// (안티패턴 ④) 찍고 끝 — 잡았지만 대응도, 전파도 안 해요.
try {
signup(email);
} catch (DuplicateEmailException e) {
e.printStackTrace(); // 콘솔에 한 번 찍고, 프로그램은 그냥 진행
}
①번 빈 catch 보다는 낫지만, 비슷한 함정이에요. 콘솔에 스택트레이스 한 번 찍고는 아무 대응도 안 하고 진행해요. 가입이 실패했는데도 다음 코드는 "가입됐다" 고 믿고 굴러가요. 잡았으면 복구하든지, 못 하면 (체이닝해서) 다시 던져 위층이 알게 해야 해요.
⑤ 원인 버리기 — 체이닝 없이 바꿔 던지기
// (안티패턴 ⑤) 원인 버리기 — Step 3 에서 본 그 사고예요.
catch (IllegalArgumentException e) {
throw new MemberNotFoundException("회원을 찾을 수 없어요"); // e 를 안 넘김
}
Step 3 에서 이미 만났던 함정이에요. 예외를 바꿔 던지는 건 좋은데, 원래 예외 e 를 안 넘겨서 "Caused by:" 가 사라져요. 처음 사고의 흔적이 통째로 날아가는 거예요. 두 번째 인자로 e 를 넘겨 원인을 지켜요.
💡 튜터의 결론
다섯 가지 안티패턴을 관통하는 한 가지가 있어요. "예외가 전하려던 정보를 버리지 마라" 예요. 빈 catch 와 printStackTrace 방치는 정보를 무시하고, 너무 큰 그물은 정보를 뭉개고, 원인 버리기는 흔적을 지우고, 흐름 제어는 예외의 의미 자체를 흐려요. 예외는 "무언가 잘못됐다는 신호" 예요. 그 신호를 끝까지 살려 다루는 게 좋은 예외 처리예요.
이제 오늘 배운 도구 셋과, 피해야 할 습관까지 다 손에 넣었어요. 마지막으로 이걸 회원 조회라는 기능 하나에 전부 엮어, 층층이 협력하는 모습을 직접 확인해볼게요.
Step 8: 종합 — 회원 조회 흐름에 오늘 배운 걸 모두 엮기
Day 22 마지막에 만든 회원 조회 흐름을 기억하시나요? 저장소가 던진 IllegalArgumentException 이 findMember 를 거쳐 greet 까지 그대로 전파됐어요. 던지고·전파하고·받는 한 흐름이었죠. 오늘은 그 흐름을 한 단계 끌어올려, 중간에서 커스텀 예외로 재포장하고 원인까지 보존하는 버전으로 키워볼게요.
먼저 회원을 보관할 저장소부터 갖춰요. Day 22 에서는 만능 Repository<Member> 를 썼는데, 오늘은 회원 전용 저장소를 따로 만들어요. 다음 시간 인스타그램 서비스 계층의 씨앗이 될 부품이에요.
// com/instagram/javabasic/exceptionbasic/MemberRepository.java
// 회원 전용 인메모리 저장소예요. Map<Long, Member> 에 "번호표 → 회원" 짝으로 보관해요.
// 지난 시간의 만능 저장소(Repository<T>)와 약속이 같아요 — 없는 id 를 찾으면
// null 을 슬쩍 돌려주지 않고 IllegalArgumentException 을 던져 "없다" 를 분명히 알려요.
// 다음 시간에 이 저장소를 도메인별로 갖춰 진짜 서비스 모양으로 키워 볼 거예요.
public class MemberRepository {
// 번호표(id) 를 키로, 회원을 값으로 보관하는 사물함이에요.
private final Map<Long, Member> store = new HashMap<>();
// 저장 — id 번호표를 붙여 회원을 넣어요. 같은 id 면 덮어써요.
public void save(Long id, Member member) {
store.put(id, member);
}
// 조회 — id 로 회원을 찾아요. 없으면 예외를 던져 "그 번호는 없어요" 를 분명히 알려요.
public Member findById(Long id) {
Member found = store.get(id);
if (found == null) {
throw new IllegalArgumentException("id " + id + " 에 해당하는 회원이 없어요.");
}
return found;
}
}
저장소는 여전히 범용 언어로 말해요. 없으면 IllegalArgumentException 에 "항목이 없어요" 가 아니라 "회원이 없어요" 라고 적긴 했지만, 예외 타입 자체는 기술적인 표준 예외 그대로예요. 이걸 우리 서비스의 언어로 바꾸는 일은 위층의 몫이에요. 그 위층이 오늘의 종합편이에요.
// com/instagram/javabasic/exceptionbasic/MemberLookupFlowV2.java
// 오늘 배운 것을 모두 모은 종합편이에요. 지난 시간의 MemberLookupFlow 와 비교해보세요.
// 그때 : 저장소의 IllegalArgumentException 이 그대로 greet 까지 전파됐어요.
// 지금 : 중간(findMember)에서 우리 서비스 전용 예외로 재포장하고, 원인은 cause 로 보존해요.
// 층마다 역할이 나뉘어요 — 저장소는 기술적인 예외를, 서비스는 의미가 분명한 예외를,
// 마지막 받는 쪽(greet)은 메시지와 원인을 합쳐 사람이 읽을 안내 문구를 만들어요.
public class MemberLookupFlowV2 {
// 회원 전용 저장소 — 외부에서 전달받아 보관해요.
private final MemberRepository repo;
public MemberLookupFlowV2(MemberRepository repo) {
this.repo = repo;
}
// 재포장하는 쪽 — 저장소의 기본 예외를 잡아 MemberNotFoundException 으로 바꿔 던져요.
// 원래 예외 e 를 cause 로 함께 담아서, 처음 사고의 흔적이 사라지지 않아요.
public Member findMember(Long id) {
try {
return repo.findById(id);
} catch (IllegalArgumentException e) {
throw new MemberNotFoundException("회원을 찾을 수 없어요: id " + id, e);
}
}
// 최종 받는 쪽 — 예외의 겉(getMessage)과 속(getCause)을 둘 다 살려 안내 문구를 만들어요.
// 사용자에게는 우리 서비스의 말로, 괄호 안에는 저장소가 남긴 처음 기록까지 함께 보여줘요.
public String greet(Long id) {
try {
Member member = findMember(id);
return member.getUsername() + "님 환영합니다!";
} catch (MemberNotFoundException e) {
return e.getMessage() + " (저장소 기록: " + e.getCause().getMessage() + ")";
}
}
}
세 메서드가 각자 다른 층의 일을 맡아요. findMember 는 Step 3 에서 배운 체이닝 그대로예요. 저장소의 IllegalArgumentException 을 잡아 MemberNotFoundException 으로 바꿔 던지되, 원래 예외 e 를 두 번째 인자로 넘겨 원인을 지켜요.
greet 는 그 예외를 최종적으로 잡는 쪽이에요. 여기서 재미있는 건, 예외의 겉(getMessage)과 속(getCause)을 둘 다 꺼내 안내 문구 하나로 합친다는 거예요.
이제 흐름을 돌려볼게요.
public static void main(String[] args) {
// 회원 전용 저장소를 만들어 회원 두 명을 넣어요.
MemberRepository repo = new MemberRepository();
repo.save(1L, new Member("minji", 8500, 150, 12, 400));
repo.save(2L, new Member("jaehoon", 1240, 42, 5, 200));
MemberLookupFlowV2 flow = new MemberLookupFlowV2(repo);
// 있는 id — 환영 인사가 나와요.
System.out.println(flow.greet(1L));
System.out.println(flow.greet(2L));
// 없는 id — 저장소의 예외가 findMember 에서 MemberNotFoundException 으로 재포장되고,
// greet 가 잡아 메시지와 원인을 합친 안내 문구를 돌려줘요. 프로그램은 멈추지 않아요.
System.out.println(flow.greet(99L));
}
있는 1번·2번은 "minji님 환영합니다!", "jaehoon님 환영합니다!" 가 나와요. 없는 99번은 저장소가 던진 예외가 findMember 에서 MemberNotFoundException 으로 재포장되고, greet 가 그걸 잡아 겉과 속을 합친 한 줄을 돌려줘요.
"회원을 찾을 수 없어요: id 99 (저장소 기록: id 99 에 해당하는 회원이 없어요.)" 처럼요. 우리 서비스의 말과 저장소의 처음 기록이 한 문장에 같이 담긴 거예요.
3층 협력 — 층마다 자기 언어로 말하고, 위층이 번역해요
┌─ greet (받는 층) ───────────────────────────────────────┐
│ MemberNotFoundException 을 잡아 │
│ getMessage() + getCause() 를 합쳐 사람이 읽을 문구로 │ ◀ 사람의 언어
└───────────────────────▲─────────────────────────────────┘
│ MemberNotFoundException (cause 동봉)
┌─ findMember (서비스 층) ─────────────────────────────────┐
│ IllegalArgumentException 을 잡아 │
│ MemberNotFoundException(메시지, e) 로 재포장해 던짐 │ ◀ 도메인의 언어
└───────────────────────▲──────────────────────────────────┘
│ IllegalArgumentException
┌─ MemberRepository (저장소 층) ───────────────────────────┐
│ 없는 id 면 IllegalArgumentException 을 던짐 │ ◀ 기술의 언어
└──────────────────────────────────────────────────────────┘
저장소·서비스·받는 쪽이 각자 자기 층의 언어로 말하고, 위층이 아래층의 말을 번역하면서도 원래 말을 잃지 않아요. 오늘 배운 커스텀 예외(서비스의 언어)와 예외 체이닝(원인 보존)이 이 한 흐름 안에 모두 들어가 있는 거예요. 이 종합 흐름은 코드베이스 MemberRepositoryTest(3가지)와 MemberLookupFlowV2Test(5가지)에서 확인되어 있어요.
💡 튜터의 결론
잘 설계된 예외는 혼자 빛나지 않아요. 층마다 역할을 나눠 협력할 때 빛나요. 저장소는 범용 예외를 던지고, 서비스는 그걸 도메인 예외로 재포장하면서 원인을 cause 로 지키고, 받는 쪽은 겉과 속을 합쳐 사람이 읽을 안내를 만들어요. 오늘 배운 세 도구가 이 협력 구조 안에서 각자 제 몫을 하는 거예요.
마무리
- Step 1: 표준 예외만 쓰면 사고의 종류가 타입에 담기지 않아, 잡는 쪽이 메시지 글자를 비교해야 해요. 문구가 바뀌면 조용히 깨지는 약한 고리예요.
- Step 2: 예외도 결국 클래스예요.
extends RuntimeException한 줄이면 이름이 뜻을 말하는 커스텀 예외가 생겨요. 생성자는 메시지만 받는 것과 메시지+원인을 받는 것 두 가지가 기본형이에요. - Step 3: 예외를 바꿔 던질 땐 두 번째 인자로 잡은 예외를 함께 넘겨요(체이닝). 그러면 "Caused by:" 가 남고
getCause()로 처음 사고까지 추적할 수 있어요. - Step 4: 검사 규칙은 그대로 두고 던지는 예외만 타입별로 갈라내면, catch 를 타입별로 줄 세워 메시지 비교 없이 다르게 대응할 수 있어요.
- Step 5: finally 로 자원을 닫으면 동작은 하지만, 닫는 책임이 사람의 기억에 남아요. 깜빡하면 자원이 새요.
- Step 6: try-with-resources 는
AutoCloseable자원의close()를 블록 끝에서 자바가 자동으로 불러줘요. 정상·예외 모두 닫히고, 여럿이면 역순으로 닫혀요. - Step 7: 빈 catch·너무 큰 그물·흐름 제어·찍고 방치·원인 버리기. 다섯 안티패턴을 관통하는 원칙은 "예외가 전하려던 정보를 버리지 마라" 예요.
- Step 8: 저장소·서비스·받는 쪽이 층마다 자기 언어로 말하고, 위층이 번역하되 원인을 지켜요. 오늘 배운 도구 셋이 한 흐름에 모두 모였어요.
다음 시간엔 — 인스타그램 서비스 계층에 실전 투입
오늘 우리는 "잘 설계된 예외" 를 만드는 법을 배웠어요. 그런데 MemberNotFoundException·DuplicateEmailException·InvalidCaptionException 삼총사 중에, 실전에서 직접 던져본 건 회원 조회 하나뿐이었죠. 나머지 둘은 아직 정의만 해두고 무대에 못 올렸어요.
다음 시간엔 이 예외들이 진짜 일을 하러 나가요. 오늘 씨앗으로 만든 MemberRepository 를 본격적인 인메모리 저장소로 키우고, 회원가입·게시물 작성·팔로우 같은 서비스 기능을 붙여요. 그러면서 중복 이메일로 가입하면 DuplicateEmailException 이, 너무 긴 캡션으로 게시물을 올리면 InvalidCaptionException 이 던져지죠.
오늘 만든 예외 삼총사가 인스타그램 백엔드 곳곳에서 제 이름값을 하는 거예요. Phase 3 의 마지막 종합편이니, 기대해도 좋아요. 수고 많으셨어요!
과제
오늘 배운 커스텀 예외·예외 체이닝·try-with-resources 를 직접 다뤄보는 과제예요. 셋 다 오늘 새로 해제된 문법이니, 마음껏 써보세요. 단 람다·Stream 같은 아직 안 배운 문법은 쓰지 않아요. 오늘까지 배운 클래스·상속·인터페이스·제네릭·예외 처리 범위에서 풀면 돼요.
과제 1: [기초] 좋아요 수 검증용 커스텀 예외 만들기
해야 할 일
게시물 좋아요 수를 검증하는 데 쓸 커스텀 예외 InvalidLikeCountException 을 직접 설계하고 던져보세요. Day 22 의 throw 문법과 오늘 Step 2 의 커스텀 예외 설계를 합치는 과제예요.
요구사항
InvalidLikeCountException extends RuntimeException클래스를 만들어요.- 생성자는 두 개로 오버로딩해요:
(String message)와(String message, Throwable cause). 각각super(...)로 부모에 전달해요. int validateLikeCount(int likeCount)메서드를 만들어,likeCount가 0 보다 작으면throw new InvalidLikeCountException("좋아요 수는 0 이상이어야 해요: " + likeCount)로 막아요. 정상(0 이상)이면 그 값을 그대로 돌려줘요.main에서 정상 값(예: 100)과 음수 값(예: -5)을 둘 다 호출하고, 음수는 try-catch 로 받아 "InvalidLikeCountException 발생: " + 메시지를 출력해요.
힌트
- Step 2 의
MemberNotFoundException을 그대로 본떠 만들면 돼요. 클래스 이름과 주석만 바꾸면 틀은 똑같아요. - 검증 메서드는 Day 22 에서 만든
validate류와 구조가 같아요.if로 검사하고, 어기면 throw, 통과하면 return.
과제 2: [응용] 예외 체이닝으로 원인 보존하기
해야 할 일
저장소가 던진 표준 예외를 잡아, 더 의미 있는 커스텀 예외로 바꿔 던지면서 원인을 보존하세요. 오늘 Step 3 의 예외 체이닝을 직접 손으로 짜보는 과제예요.
요구사항
MemberNotFoundException(오늘 Step 2 에서 만든 것)을 그대로 써요. 새 예외를 또 만들 필요는 없어요.- 회원 저장소(
MemberRepository또는 Day 22 의Repository<Member>중 편한 것)를 감싸는 메서드Member getMemberSafely(Long id)를 만들어요.- try 에서 저장소의
findById(id)를 호출해요. - 없는 id 라
IllegalArgumentException이 나면, catch 에서throw new MemberNotFoundException("회원 id " + id + " 를 찾을 수 없어요", e)로 원래 예외e를 함께 넘겨 던져요. - 찾으면 그 회원을 그대로 돌려줘요.
- try 에서 저장소의
main에서 없는 id 로 호출하고, 던져진MemberNotFoundException을 try-catch 로 받아서 두 가지를 출력해요: 겉 메시지(e.getMessage())와 원인 메시지(e.getCause().getMessage()).
힌트
- 핵심은 catch 안의
throw new MemberNotFoundException(..., e)에서 두 번째 인자e를 빠뜨리지 않는 거예요. 그게 빠지면getCause()가 null 이 돼요. - 실행하면 스택트레이스에 "Caused by:" 가 층층이 찍히는 모습도 함께 확인해보세요.
과제 3: [심화] try-with-resources 로 자동 정리되는 자원 만들기
해야 할 일
AutoCloseable 을 구현하는 클래스를 직접 만들고, try-with-resources 로 정상·예외 경로 모두에서 자동으로 닫히는 걸 확인해보세요. 오늘 Step 6 의 ImageUploadSession 을 본떠, 다른 자원을 만들어보는 과제예요.
요구사항
- "연결" 을 표현하는 클래스
SimpleConnection implements AutoCloseable을 만들어요.- 필드
boolean open = true;로 연결이 열려 있는지 보관해요. void query(String sql):open이면 "쿼리 실행: " + sql 을 출력하고, 닫혀 있으면throw new IllegalStateException("연결이 닫혔어요")로 막아요.@Override public void close():open을 false 로 바꾸고 "연결을 닫습니다" 를 출력해요(throws없이 재정의).
- 필드
main에서 두 경로를 모두 확인해요.- 정상 경로:
try (SimpleConnection conn = new SimpleConnection()) { conn.query("SELECT ..."); }— 블록 끝에 close() 자동 호출. - 예외 경로: try-with-resources 블록 안에서
query호출 뒤throw new IllegalStateException("도중 사고!")를 일부러 던지고, 바깥 try-catch 로 받아요. 그래도 "연결을 닫습니다" 가 찍히는지 확인해요.
- 정상 경로:
- 두 경로 모두 "연결을 닫습니다" 가 출력되는지 눈으로 확인해요. 사고가 나도 close 가 빠지지 않는 게 핵심이에요.
힌트
- Step 6 의
ImageUploadSession이 거의 정답에 가까운 본보기예요. 필드 이름과 출력 문구만 바꾸면 돼요. - 예외 경로에서 던지는 예외는
IllegalStateException같은 unchecked 를 쓰세요. Step 7 에서 봤듯, 흐름을 막으려고 rawException을 던지는 건 피하는 게 좋아요.
생각해볼 주제
오늘 배운 예외 설계 뒤에는 "그래서 예외를 어떻게 설계하는 게 좋은가?" 하는 질문이 숨어 있어요. 정답을 외우기보다 곰곰이 생각해보면 도구를 보는 눈이 깊어져요.
1. 커스텀 예외는 어디까지 만들어야 할까?
오늘 우리는 회원 조회 실패에 MemberNotFoundException 을 만들었어요. 그런데 멈추지 않고 더 갈 수도 있어요. 이름 비었음, 이름 너무 김, 나이 미달, 이메일 형식 틀림... 사고마다 전용 예외를 하나하나 다 만들 수도 있죠. 반대로 표준 IllegalArgumentException 하나로 다 처리할 수도 있고요.
어디까지가 적당하고, 어디부터가 과할까요? 표준 예외로 충분한 상황과, 굳이 커스텀 예외를 만들 가치가 있는 상황의 경계를 생각해보세요.
2. 내가 만드는 예외는 checked 일까, unchecked 일까?
오늘 만든 커스텀 예외는 전부 extends RuntimeException, 즉 unchecked 였어요. 그런데 예외는 extends Exception 으로 만들면 checked 가 되기도 해요. Day 22 에서 배운 그 구분이죠. 그렇다면 내가 새 예외를 만들 때, 무엇을 기준으로 checked 와 unchecked 중 하나를 골라야 할까요?
"회원 중복" 같은 비즈니스 규칙 위반과, "파일이 없음" 같은 바깥 세계의 사정은 어느 쪽에 어울릴지, 부르는 쪽이 받을 부담까지 떠올리며 따져보세요.
3. 예외 체이닝은 정말 매번 필요할까?
Step 3 에서 우리는 예외를 바꿔 던질 때 원래 예외를 cause 로 넘겨 보존했어요. 그런데 이런 생각도 들 수 있어요. "어차피 새 예외 메시지에 상황을 다 적었는데, 굳이 원인까지 동봉할 필요가 있나? 그냥 처음부터 MemberNotFoundException 만 깔끔하게 던지면 안 되나?" 체이닝이 번거롭게 느껴지는 순간이죠.
예외 체이닝이 진짜로 빛나는 순간은 언제이고, 반대로 없어도 괜찮은 경우는 언제일지, 몇 달 뒤 그 로그만 보고 디버깅할 미래의 나를 떠올리며 생각해보세요.
✅ 예시 답안정답 보기
오늘 과제는 "이름이 뜻을 말하는 커스텀 예외를 직접 만든다", "예외를 바꿔 던질 때 원인을 보존한다", "try-with-resources 로 자원을 자동으로 닫는다" 세 가지가 핵심이에요. 셋 다 오늘 새로 해제된 문법이라 마음껏 써도 좋아요. 세 과제 모두 정답이 하나는 아니에요. 아래 예시와 다르게 풀었더라도 요구사항을 만족하고 커스텀 예외·체이닝·try-with-resources 를 제대로 썼다면 훌륭한 답이에요.
과제 예시답안
과제 1 예시답안 — 좋아요 수 검증용 커스텀 예외 만들기
핵심 접근
이 과제의 주제는 "표준 예외 대신 이름이 뜻을 말하는 커스텀 예외를 직접 만들어 던진다" 예요. 좋아요 수가 음수라는 건 데이터가 어딘가에서 잘못됐다는 신호죠. 지난 시간엔 이걸 IllegalArgumentException 으로 던졌는데, 이번엔 InvalidLikeCountException 이라는 전용 예외를 만들어 던져요. 검사 규칙은 똑같지만, 던지는 예외의 이름만으로 "좋아요 수가 잘못됐다" 를 알 수 있게 되는 거예요.
먼저 예외 클래스를 만들어요. Step 2 의 MemberNotFoundException 과 똑같은 틀이에요.
예시 구현
// com/instagram/javabasic/exceptionbasic/solution/day23/InvalidLikeCountException.java
public class InvalidLikeCountException extends RuntimeException {
// 메시지만 담아 만들어요 — 어떤 값이 잘못됐는지를 글로 남겨요.
public InvalidLikeCountException(String message) {
super(message);
}
// 메시지 + 원인(cause)을 함께 담아 만들어요 — 처음 사고의 흔적을 보존해요.
public InvalidLikeCountException(String message, Throwable cause) {
super(message, cause);
}
}
extends RuntimeException 한 줄로 예외 족보에 합류했고, 생성자 두 개(메시지만 / 메시지+원인)를 둬서 기본형을 갖췄어요. 본문이 비어 있어도 괜찮아요 — 이 클래스의 가치는 이름에 있으니까요.
이제 이 예외를 던지는 검증기예요.
// com/instagram/javabasic/exceptionbasic/solution/day23/LikeCountValidator.java
public class LikeCountValidator {
// 좋아요 수 검증 — 0 이상이면 그대로 돌려주고, 음수면 커스텀 예외를 던져요.
public int validateLikeCount(int likeCount) {
if (likeCount < 0) {
throw new InvalidLikeCountException("좋아요 수는 0 이상이어야 해요: " + likeCount);
}
return likeCount;
}
}
지난 시간 검증기와 비교하면 throw new 뒤의 타입만 바뀌었어요. IllegalArgumentException 이 InvalidLikeCountException 으로요. 검사 조건도 메시지도 그대로예요.
main 으로 확인해 볼게요.
// com/instagram/javabasic/exceptionbasic/solution/day23/LikeCountValidator.java
public static void main(String[] args) {
LikeCountValidator validator = new LikeCountValidator();
// 정상 값 — 검사를 통과해서 그 값이 그대로 나와요.
int valid = validator.validateLikeCount(100);
System.out.println("정상 좋아요 수: " + valid);
// 음수 값 — 커스텀 예외가 던져져요. 이제 catch 도 그 예외 타입으로 받아요.
try {
validator.validateLikeCount(-5);
} catch (InvalidLikeCountException e) {
System.out.println("InvalidLikeCountException 발생: " + e.getMessage());
}
// 위에서 예외를 잡았으니 프로그램은 죽지 않고 이 줄까지 실행돼요.
System.out.println("끝까지 잘 실행됐어요!");
}
catch 가 이제 InvalidLikeCountException 타입으로 받는 게 핵심이에요. 실행하면 이런 결과가 나와요.
정상 좋아요 수: 100
InvalidLikeCountException 발생: 좋아요 수는 0 이상이어야 해요: -5
끝까지 잘 실행됐어요!
정상 값 100 은 그대로 나오고, 음수 -5 는 커스텀 예외에 막혀 메시지가 출력돼요. 예외를 잡았으니 프로그램은 죽지 않고 마지막 줄까지 무사히 찍혀요.
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| 커스텀 예외 정의 | extends RuntimeException + 생성자 2종(메시지 / 메시지+원인)을 갖췄는가 |
상 |
| 전용 예외로 던지기 | 음수일 때 IllegalArgumentException 이 아니라 InvalidLikeCountException 을 던지는가 |
상 |
| 메시지에 값 포함 | 예외 메시지에 잘못된 값(-5)을 담았는가 |
중 |
| 타입으로 잡기 | main 의 catch 가 InvalidLikeCountException 타입으로 받는가 |
중 |
| 두 경우 검증 | 정상 값·음수 값 둘 다 호출했는가 | 하 |
흔한 실수
- 생성자를 하나만 만들기 → 메시지만 받는 생성자 하나로도 이 과제는 돌아가요. 하지만 다음 과제(체이닝)에서 곧 원인을 함께 넘기는 생성자가 필요해지니, 처음부터 두 개를 갖춰두는 습관을 들이는 게 좋아요.
extends Exception으로 만들기 → checked 예외가 돼서 던지는 메서드마다throws를 줄줄이 달아야 해요. "음수 좋아요 수" 는 잘못된 입력 계열이라 unchecked(RuntimeException)가 자연스러워요.- catch 를
IllegalArgumentException으로 받기 → 커스텀 예외를 던져놓고 부모 타입으로 잡으면 굳이 만든 의미가 줄어요. 던진 타입 그대로 잡아야 "타입으로 구분" 의 이점이 살아나요.
실무 개선 포인트 (심화)
지금은 예외 하나만 만들었지만, 서비스가 커지면 좋아요·댓글·팔로우마다 잘못된 값 예외가 늘어나요. 이때 "잘못된 입력" 류를 묶는 공통 부모 예외(예: InvalidInputException)를 하나 두고 그 자식으로 두면, 부른 쪽이 "구체적으로(좋아요만)" 또는 "대범하게(모든 잘못된 입력)" 골라 잡을 수 있어요. 이렇게 예외에도 계층을 설계하는 흐름은 다음 시간 인스타그램 서비스 계층에서 자연스럽게 만나게 돼요.
과제 2 예시답안 — 예외 체이닝으로 원인 보존하기
핵심 접근
이 과제의 주제는 "저장소의 기술적인 예외를 우리 서비스의 예외로 바꿔 던지되, 원래 원인을 잃지 않는다" 예요. 저장소(MemberRepository)는 범용 부품이라 없는 id 에 IllegalArgumentException 을 던져요. 회원 서비스는 이걸 잡아 MemberNotFoundException 으로 재포장하는데, 두 번째 인자로 원래 예외를 함께 넘기는 게 핵심이에요.
이게 Step 3 에서 배운 예외 체이닝이에요. MemberNotFoundException 은 오늘 수업에서 만든 걸 그대로 가져다 쓰면 돼요.
예시 구현
// com/instagram/javabasic/exceptionbasic/solution/day23/SafeMemberLookup.java
public class SafeMemberLookup {
private final MemberRepository repo;
public SafeMemberLookup(MemberRepository repo) {
this.repo = repo;
}
// 안전 조회 — 저장소의 표준 예외를 잡아 우리 서비스 예외로 재포장해 던져요.
// 두 번째 인자 e 를 빼먹으면 getCause() 가 null 이 되니 꼭 함께 넘겨요.
public Member getMemberSafely(Long id) {
try {
return repo.findById(id);
} catch (IllegalArgumentException e) {
throw new MemberNotFoundException("회원 id " + id + " 를 찾을 수 없어요", e);
}
}
}
catch 에서 잡은 e 를 throw new MemberNotFoundException("...", e) 의 두 번째 인자로 넘긴 게 전부예요. 이 작은 e 하나가 원인을 보존하는 열쇠예요. 이걸 빼먹으면 재포장은 되지만 원래 사고의 흔적이 사라져요.
main 으로 돌려볼게요.
// com/instagram/javabasic/exceptionbasic/solution/day23/SafeMemberLookup.java
public static void main(String[] args) {
MemberRepository repo = new MemberRepository();
repo.save(1L, new Member("minji", 8500, 150, 12, 400));
SafeMemberLookup lookup = new SafeMemberLookup(repo);
// 있는 id — 회원이 그대로 나와요.
System.out.println("조회 성공: " + lookup.getMemberSafely(1L).getUsername());
// 없는 id — 재포장된 예외를 잡아 겉(메시지)과 속(원인)을 둘 다 들여다봐요.
try {
lookup.getMemberSafely(99L);
} catch (MemberNotFoundException e) {
System.out.println("잡은 예외 : " + e.getMessage());
System.out.println("원인(cause) : " + e.getCause().getMessage());
}
}
실행하면 이런 결과가 나와요.
조회 성공: minji
잡은 예외 : 회원 id 99 를 찾을 수 없어요
원인(cause) : id 99 에 해당하는 회원이 없어요.
겉(우리 서비스의 메시지)과 속(저장소가 남긴 처음 기록)이 둘 다 살아 있죠? "잡은 예외" 는 회원 서비스의 언어로, "원인(cause)" 은 저장소의 언어로 사고를 설명해요. 두 층의 정보가 모두 보존된 거예요.
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| 원인 보존 | throw new MemberNotFoundException(..., e) 에서 두 번째 인자로 e 를 넘겼는가 |
상 |
| 재포장 | 저장소의 IllegalArgumentException 을 잡아 커스텀 예외로 바꿔 던지는가 |
상 |
| 원인 추적 | main 에서 getCause() 로 원래 예외 메시지를 꺼내 출력했는가 |
중 |
| 정상 분기 | 있는 id 는 회원을 그대로 돌려주는가 | 하 |
흔한 실수
- 두 번째 인자
e를 빠뜨리기 →throw new MemberNotFoundException("...")처럼 메시지만 넘기면, 재포장은 되지만getCause()가 null 이 돼요. main 에서e.getCause().getMessage()를 부르는 순간NullPointerException으로 터져요. 체이닝의 핵심은 바로 그e예요. - 저장소가 직접 커스텀 예외를 던지게 고치기 → "재포장이 번거로우니 저장소가 처음부터
MemberNotFoundException을 던지자" 는 유혹이 생겨요. 하지만 저장소는 회원·게시물 어디에나 쓰는 범용 부품이라, 회원 전용 예외를 넣으면 게시물 저장소가 "회원을 찾을 수 없어요" 라고 외치는 이상한 일이 생겨요. catch (Exception e)로 너무 넓게 잡기 → 저장소가 던지는 건IllegalArgumentException이에요.Exception으로 잡으면 엉뚱한 버그까지 같이 삼켜져요. 잡을 타입은 좁게 적어요.
실무 개선 포인트 (심화)
실무에서는 이 재포장을 한 곳에서 일관되게 하려고, 서비스 계층 전체에 "저장소 예외 → 도메인 예외" 변환 규칙을 둬요. 지금은 메서드 하나에서 직접 try-catch 로 바꿨지만, 회원 조회가 여러 곳에서 일어나면 같은 변환이 반복되죠.
이런 반복을 한 곳으로 모으는 설계(예: 조회 전용 헬퍼나 공통 처리 지점)는 나중에 프레임워크를 배우면 더 우아하게 풀려요. 지금은 "원인을 잃지 않고 바꿔 던진다" 는 원칙 하나만 확실히 익히면 충분해요.
과제 3 예시답안 — try-with-resources 로 자동 정리되는 자원 만들기
핵심 접근
이 과제의 주제는 "AutoCloseable 을 구현한 자원을 try-with-resources 에 태우면, 정상이든 예외든 close() 가 자동으로 불린다" 예요. Step 6 의 ImageUploadSession 과 똑같은 틀로, 이번엔 "연결" 자원을 만들어봐요. 핵심은 두 경로(정상·예외) 모두에서 "연결을 닫습니다" 가 빠짐없이 찍히는지 눈으로 확인하는 거예요.
예시 구현
// com/instagram/javabasic/exceptionbasic/solution/day23/SimpleConnection.java
public class SimpleConnection implements AutoCloseable {
// 연결이 열려 있는지 — 닫힌 연결에 query 하는 사고를 막는 안전장치예요.
private boolean open = true;
public boolean isOpen() {
return open;
}
// 쿼리 실행 — 열려 있으면 실행하고, 닫혀 있으면 IllegalStateException 으로 막아요.
public void query(String sql) {
if (!open) {
throw new IllegalStateException("연결이 닫혔어요");
}
System.out.println("쿼리 실행: " + sql);
}
// 자원 정리 — try-with-resources 가 블록 끝에서 자동으로 불러 줘요.
@Override
public void close() {
open = false;
System.out.println("연결을 닫습니다");
}
}
implements AutoCloseable 로 "나는 자동 정리 시스템에 태울 수 있는 자원" 이라고 선언하고, close() 를 재정의해 닫을 때 할 일을 적었어요. 닫다가 사고 날 일이 없으니 throws 없이 재정의한 점도 Step 6 그대로예요.
main 에서 두 경로를 모두 확인해요.
// com/instagram/javabasic/exceptionbasic/solution/day23/SimpleConnection.java
public static void main(String[] args) {
// 정상 경로 — 블록이 끝나면 close() 가 자동으로 불려요.
try (SimpleConnection conn = new SimpleConnection()) {
conn.query("SELECT * FROM member");
}
// 예외 경로 — 도중에 사고가 나도 close() 는 자동으로 불려요.
// "연결을 닫습니다" 가 먼저 나오고, 그 다음에 바깥 catch 가 받아요.
try {
try (SimpleConnection conn = new SimpleConnection()) {
conn.query("SELECT * FROM post");
throw new IllegalStateException("도중 사고!");
}
} catch (IllegalStateException e) {
System.out.println("사고 발생: " + e.getMessage());
}
}
실행하면 이런 결과가 나와요.
쿼리 실행: SELECT * FROM member
연결을 닫습니다
쿼리 실행: SELECT * FROM post
연결을 닫습니다
사고 발생: 도중 사고!
정상 경로는 쿼리 실행 뒤 "연결을 닫습니다" 가 자동으로 찍혀요. 우리가 close() 를 부른 적이 없는데도요. 예외 경로가 핵심인데, 도중에 사고를 던졌는데도 "연결을 닫습니다" 가 먼저 나오고 그 다음에 "사고 발생" 이 나와요. 사고가 나도 닫기가 먼저 끝난다는 증거예요.
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| AutoCloseable 구현 | implements AutoCloseable + close() 재정의를 했는가 |
상 |
| try-with-resources 사용 | try (SimpleConnection conn = ...) 형태로 자원을 선언했는가 |
상 |
| 예외 경로 자동 close | 블록 안에서 예외를 던져도 "연결을 닫습니다" 가 찍히는지 확인했는가 | 상 |
| 닫힘 안전장치 | 닫힌 연결에 query 하면 막는 처리가 있는가 | 중 |
| unchecked 예외 사용 | 도중 사고를 unchecked(IllegalStateException)로 던졌는가 |
하 |
흔한 실수
- finally 로 close() 를 직접 부르기 → 동작은 하지만 이 과제의 목적은 "자바에게 닫기를 맡기는" try-with-resources 예요.
finally { conn.close(); }를 쓰면 Step 5 의 옛 방식으로 되돌아간 거예요. AutoCloseable을 구현하지 않기 →implements AutoCloseable이 없으면 그 클래스는 try 괄호 안에 넣을 수 없어요. 컴파일 단계에서 "try-with-resource 자원이 아니다" 라고 거절당해요.- 예외 경로에서 raw
Exception던지기 →throw new Exception("도중 사고!")는 checked 라 main 에throws가 필요하고, Step 7 에서 본 안티패턴이기도 해요. 흐름을 막을 땐 unchecked 예외를 쓰는 게 깔끔해요.
실무 개선 포인트 (심화)
진짜 자원(파일·네트워크·데이터베이스 연결)은 닫을 때도 실패할 수 있어요. 그래서 AutoCloseable 의 close() 는 원래 예외를 던질 수 있게 선언돼 있죠. 우리 SimpleConnection 은 단순해서 throws 를 뗐지만, 실무에서 닫기조차 실패할 수 있는 자원이라면 close() 가 던지는 예외도 고려해야 해요.
try-with-resources 는 본문 예외와 close 예외가 겹칠 때 "본문 예외를 우선하고 close 예외는 따로 보관" 하는 영리한 처리까지 해주는데, 이런 깊은 규칙은 나중에 실제 파일·DB 를 다룰 때 다시 만나게 돼요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — 커스텀 예외는 어디까지 만들어야 할까?
[문제 상황 요약]
회원 조회 실패에 MemberNotFoundException 을 만들었어요. 그런데 멈추지 않고 더 갈 수도 있죠. 이름 비었음, 너무 김, 나이 미달, 이메일 형식 틀림... 사고마다 전용 예외를 다 만들 수도 있고, 반대로 표준 IllegalArgumentException 하나로 다 처리할 수도 있어요. 어디까지가 적당하고, 어디부터가 과할까요?
[튜터의 가이드 및 해설]
정답은 "호출자가 그 둘을 다르게 처리할 필요가 있느냐" 에 달려 있어요. 이 질문 하나로 거의 다 갈려요.
표준 예외로 충분한 경우는 이래요. 호출자가 두 사고를 똑같이 대응한다면, 굳이 타입을 나눌 이유가 없어요. 예를 들어 어떤 잘못된 값이든 그냥 "입력을 다시 확인하세요" 한 마디로 끝낼 거라면, IllegalArgumentException 하나로 충분해요. 타입을 다섯 개로 쪼개도 catch 가 다 똑같다면 그건 그냥 코드만 늘어난 거예요.
커스텀 예외가 필요한 경우는 반대예요. 호출자가 사고마다 다르게 반응해야 할 때죠. 회원 가입 화면에서 이름 문제는 이름 칸을, 나이 문제는 나이 칸을 빨갛게 표시해야 한다면, 두 사고를 타입으로 구분해야 해요. 또 하나는 "이름이 의미를 전달할 때" 예요. MemberNotFoundException 은 메시지를 안 읽어도 무슨 일인지 알 수 있죠. 로그에 타입 이름만 찍혀도 상황이 보이는 거예요.
그래서 실무에서는 이렇게 가늠해요. "일반적인 프로그래밍 실수(null 접근, 범위 초과)" 는 표준 예외로 두고, "내 도메인의 비즈니스 규칙 위반(회원 중복, 캡션 길이 초과)" 은 커스텀 예외로 표현해요. 처음부터 다 만들기보다, 호출자가 구분이 필요하다고 느껴지는 순간 그때 만들어도 늦지 않아요.
🎯 면접관을 홀리는 핵심 멘트
"커스텀 예외를 만드는 기준은 '호출자가 이 사고를 다르게 처리해야 하는가' 예요. 다르게 처리할 일이 없으면 표준 예외로 충분하고, 사고마다 다른 대응이 필요하거나 이름만으로 상황이 설명돼야 할 때 커스텀 예외를 만들죠. 표준 예외는 일반적인 프로그래밍 실수에, 커스텀 예외는 도메인 규칙 위반에 쓴다고 정리하면 깔끔합니다."
생각해볼 주제 2 예시답안 — 내가 만드는 예외는 checked 일까, unchecked 일까?
[문제 상황 요약]
오늘 만든 커스텀 예외는 전부 extends RuntimeException, 즉 unchecked 였어요. 그런데 extends Exception 으로 만들면 checked 가 되기도 하죠. 지난 시간 배운 그 구분이에요. 내가 새 예외를 만들 때, 무엇을 기준으로 checked 와 unchecked 중 하나를 골라야 할까요?
[튜터의 가이드 및 해설]
판단 기준은 지난 시간 배운 그대로예요. "내 코드를 고치면 안 나는 사고냐, 아니면 바깥 사정이라 내가 어쩔 수 없는 사고냐" 죠.
unchecked(RuntimeException 자손)는 "내 코드의 논리적 실수" 에 어울려요. 잘못된 입력, 없는 회원, 중복 이메일 같은 것들이요. 이런 사고는 "버그를 고치거나 입력을 바로잡으면" 안 나는 일이라, 호출자에게 매번 처리를 강제하지 않아도 돼요. 그래서 오늘 만든 예외들이 전부 unchecked 였던 거예요. 비즈니스 규칙 위반은 대부분 여기에 들어가요.
checked(Exception 자손)는 "바깥 세계의 예상 못 한 상황" 에 어울려요. 파일이 없거나, 네트워크가 끊기거나, 외부 시스템이 응답을 안 하는 경우요. 이런 건 내 코드가 멀쩡해도 실행할 때 실패할 수 있어서, 자바가 "이건 꼭 대비하라" 고 throws 나 try-catch 를 강제해요.
부르는 쪽 부담도 같이 봐야 해요. checked 로 만들면 그 예외를 던지는 메서드마다, 또 그 메서드를 부르는 쪽마다 throws 가 줄줄이 번져요. 회원 조회처럼 서비스 곳곳에서 쓰이는 기능이라면 코드 전체가 throws 로 도배되죠. 그래서 요즘 자바 커뮤니티는 비즈니스 예외를 unchecked 로 두는 쪽을 선호해요. 메모리 기반으로 도는 우리 인스타그램 같은 경우엔 더욱 그렇고요.
🎯 면접관을 홀리는 핵심 멘트
"checked 예외는 호출자에게 '이 상황을 꼭 처리하라' 고 강제하고, unchecked 는 '고치든 말든 당신의 선택' 이라는 거예요. 회원 중복 같은 비즈니스 규칙 위반은 코드나 입력을 바로잡으면 안 나는 일이라 unchecked 로, 파일 없음이나 네트워크 실패처럼 정말 피할 수 없는 바깥 사정은 checked 로 구분하는 게 원칙입니다. 실무에선 throws 전파 부담 때문에 비즈니스 예외를 unchecked 로 두는 흐름이 강하고요."
생각해볼 주제 3 예시답안 — 예외 체이닝은 정말 매번 필요할까?
[문제 상황 요약]
Step 3 에서 예외를 바꿔 던질 때 원래 예외를 cause 로 넘겨 보존했어요. 그런데 이런 생각도 들죠. "새 예외 메시지에 상황을 다 적었는데, 굳이 원인까지 동봉할 필요가 있나? 그냥 처음부터 MemberNotFoundException 만 깔끔하게 던지면 안 되나?" 체이닝이 빛나는 순간은 언제이고, 없어도 괜찮은 경우는 언제일까요?
[튜터의 가이드 및 해설]
체이닝의 가치는 "지금" 이 아니라 "몇 달 뒤" 에 드러나요. 사고를 만든 사람과 디버깅하는 사람이 시간으로 멀리 떨어져 있을 때 빛나죠.
원인을 버리면 어떻게 되는지 그려볼게요. 로그에 MemberNotFoundException: 회원을 찾을 수 없어요 만 남았다고 해봐요. 그런데 회원을 못 찾은 진짜 이유가 뭘까요? 저장소 조회가 실패한 건지, id 계산이 틀린 건지, 아니면 다른 사고였는지 알 수가 없어요. 단서가 끊긴 거예요.
체이닝이 있으면 그 아래 "Caused by: IllegalArgumentException: id 99 에 해당하는 항목이 없어요" 가 함께 남아요. 아, 저장소 조회 자체가 실패한 거구나, 하고 시작점을 바로 짚을 수 있죠. 스택트레이스가 사고 현장의 기록이 되어, 세 시간 걸릴 추적을 몇 분으로 줄여줘요.
그럼 없어도 괜찮은 경우는요? 잡은 예외가 정말 아무 정보가 없거나, 내가 직접 처음부터 던지는 예외라 원인 자체가 없을 때예요. 예를 들어 입력 검증에서 "이름이 비었다" 를 처음 발견해 던질 땐, 그 위에 다른 원인이 없으니 체이닝할 게 없어요. 체이닝은 "잡은 예외를 다른 예외로 바꿔 던질 때" 만 의미가 있어요. 그러니 규칙은 단순해요. 예외를 바꿔 던지는 순간이라면, 거의 항상 원인을 동봉하세요. 버리는 건 정보를 태우는 일이니까요.
🎯 면접관을 홀리는 핵심 멘트
"예외는 단순히 잡는 게 아니라 '원인을 추적 가능하게 던지는' 게 중요해요. 체이닝을 쓰면 스택트레이스의 'Caused by:' 가 사건 현장의 기록이 되어서, 몇 달 뒤 그 로그만 보고도 '아, 이 오류는 여기서 시작됐구나' 를 바로 알 수 있죠. 그래서 예외를 바꿔 던질 땐 거의 항상 원래 예외를 cause 로 넘기는 걸 원칙으로 삼습니다."
