오류 처리 - 예외를 설계하기
이 장에서 배우는 것
앞 장에서 주석이 설명해야 할 이유와 코드가 직접 드러내야 할 의도를 구분했다. 이번에는 실패의 의미를 코드에 드러낸다. 수강 신청 메서드가 null을 반환했을 때, 수강생이 없는 것인지 정원이 찬 것인지 알 수 없다면 호출자는 추측으로 분기하게 된다. 실패 이유를 알아도 잔액만 줄어든 상태가 남는다면 안내 문구를 고치는 것으로는 해결되지 않는다.
오류 처리는 예외를 던지는 문법보다 넓은 문제다. 값이 없는 경우를 어떻게 표현할지, 어느 경계에서 실패를 안내 문구로 바꿀지, 실패한 작업이 어떤 상태를 남겨야 할지를 함께 정해야 한다. 이 장에서는 오래된 수강 신청 코드를 고치되, 같은 입력에 대한 결과와 상태를 실행으로 비교한다.
- 조회 결과가 없다는 뜻을 Optional과 빈 컬렉션으로 표현한다.
- 호출자가 대응할 수 있는 의미를 예외 타입과 실패 사유에 담는다.
- 정상 처리와 실패 안내를 서로 다른 위치에 둔다.
- 검증과 상태 반영을 분리해 실패 후에도 일관된 상태를 유지한다.
- 수강 신청 전후의 결과와 상태를 작은 검사 함수로 확인한다.
문제 상황
학원 수강 관리 프로그램에는 수강생 한 명이 강좌 하나를 신청하는 기능이 있다. 신청에 성공하면 수강생의 잔액에서 수강료를 빼고, 수강생의 신청 목록과 강좌의 수강생 목록에 각각 항목을 추가한다. 예제의 잔액과 수강료는 동작 확인을 위한 가상 값이다.
오래된 구현은 수강생이나 강좌를 찾지 못하면 null을 돌려준다. 신청 메서드는 성공했을 때도 null을 반환하고, 실패했을 때는 문자열을 반환한다. 호출자는 조회 메서드의 null과 신청 메서드의 null을 정반대로 해석해야 한다. 성공을 나타내는 null을 그대로 출력하면 화면에는 아무 안내도 나타나지 않는다.
처음에는 문자열 몇 개로 충분해 보인다. 그러나 신청 기능을 다른 화면에서도 사용하면 문제가 커진다. 어떤 호출자는 반환 문자열을 검사하고, 다른 호출자는 반환값을 무시한다. 실패 사유를 구분하려고 문구를 비교하기 시작하면 띄어쓰기 수정도 프로그램의 분기에 영향을 준다.
더 큰 문제는 상태다. 잔액을 먼저 차감하고 나중에 정원을 검사하면 신청은 실패했는데 돈이 줄어들 수 있다. 이 장의 오래된 코드는 이미 모든 검증을 먼저 수행하도록 정리된 상태에서 출발한다. 그 안전장치는 유지하면서, null과 문자열에 숨어 있는 계약을 명시적인 표현으로 바꾼다. 리팩터링 중에 기존 오류까지 함께 고치면 결과가 달라진 이유를 구분하기 어려워지기 때문이다.
값이 없는 경우와 실패를 구분한다
먼저 메서드가 약속하는 결과를 구분해야 한다. 수강생 조회에서 대상이 없는 일은 조회 결과의 한 종류다. 반면 수강 신청은 지정한 수강생과 강좌가 있어야 수행할 수 있는 명령이다. 같은 ‘없음’도 조회에서는 정상적인 결과이고, 신청에서는 작업을 완료하지 못한 이유가 된다.
단일 대상을 조회하는 메서드에는 Optional을 사용할 수 있다. Optional은 값이 있을 수도, 없을 수도 있다는 사실을 반환 타입에 드러낸다. 호출자는 꺼낸 값을 사용하거나, 대체값을 선택하거나, 해당 작업에 맞는 예외로 바꾼다. 이 장에서는 조회 결과의 부재를 신청 서비스가 해석한다.
Optional<Student> findStudent(String id) {
return student.id().equals(id)
? Optional.of(student)
: Optional.empty();
}
Optional을 반환하고 내부에서 null을 반환하면 약속이 다시 흐려진다. 또한 값이 없을 수 있다는 사실을 표시해 놓고 곧바로 get()을 호출하면, 부재에 어떻게 대응할지 정하는 일을 뒤로 미룰 뿐이다. 신청 서비스에서는 orElseThrow로 작업에 맞는 실패를 지정한다.
여러 대상을 조회하는 경우에는 빈 컬렉션이 자연스럽다. 신청한 강좌가 없는 수강생의 목록은 항목 수가 0인 목록이다. 목록 자체가 없다는 별도 의미가 없다면 null을 사용할 이유가 적다. 호출자는 목록의 존재 여부를 확인하지 않고 반복하거나 크기를 읽을 수 있다.
| 상황 | 표현 | 호출자의 해석 |
|---|---|---|
| 수강생 한 명을 조회한다 | Optional<Student> | 찾았거나 찾지 못했다 |
| 신청한 강좌들을 조회한다 | List<String> | 0개 이상이다 |
| 수강 신청을 수행한다 | 성공 시 반환, 실패 시 예외 | 작업을 완료했거나 거절됐다 |
빈 목록으로 바꾸어서는 안 되는 경우도 있다. 목록을 읽는 작업 자체가 실패했는데 빈 목록을 반환하면, 호출자는 ‘조회할 수 없음’을 ‘조회했으나 0개임’으로 오해한다. 빈 컬렉션은 정상적으로 확인한 결과가 비어 있다는 뜻으로 사용해야 한다.
Optional 역시 모든 변수에 붙이는 장식이 아니다. 이 예제에서는 부재가 가능한 조회 반환값에 사용한다. 신청 상태를 담는 목록은 항상 존재하게 만들고, 이미 확보한 수강생과 강좌는 일반 참조로 다룬다. 부재를 처리한 뒤에는 정상 흐름을 읽는 데 집중할 수 있다.
예외는 실패의 의미와 처리 경계를 드러낸다
예외 타입은 어떤 종류의 실패인지 알려 주는 이름이다. 모든 실패를 RuntimeException으로 던지면 호출자는 신청 거절과 코드 결함을 구분하기 어렵다. 여기서는 수강 신청이 업무 조건 때문에 거절됐다는 뜻으로 EnrollmentException을 만든다.
다섯 가지 거절 사유는 Reason이라는 열거형으로 표현한다. 수강생 없음, 강좌 없음, 중복 신청, 정원 초과, 잔액 부족이다. 화면에서 모두 신청 거절로 다루고 사유에 따라 안내만 달라지므로, 예외 클래스 하나와 사유 값의 조합을 사용한다. 사유마다 복구 방법이 크게 달라진다면 서로 다른 예외 타입을 검토할 수 있다.
메시지와 사유 값의 역할도 다르다. 메시지는 사람이 상황을 파악하도록 돕는다. 사유 값은 프로그램이 실패를 구분하는 데 사용한다. 이 장의 실행 비교는 안내 문장 대신 사유 이름을 비교한다. 나중에 문구를 다듬어도 사유의 의미가 같다면 비교 기준을 유지할 수 있다.
완성 코드의 예외는 검사 예외다. 신청 메서드의 throws 선언과 호출 지점의 catch가 실패 계약을 드러내도록 선택했다. 검사 예외가 언제나 더 좋은 선택이라는 뜻은 아니다. 호출자가 해당 실패를 처리할 위치인지, 선언이 유용한 정보를 전달하는지를 기준으로 결정해야 한다.
신청 서비스는 실패를 안내 문자열로 바꾸지 않는다. 필요한 대상을 확보하고 조건을 검사한 뒤 상태를 반영한다. 실행 진입부에 있는 attempt 메서드가 EnrollmentException을 잡아서 비교 가능한 결과 문자열로 바꾼다. 실제 화면이 있다면 이 경계에서 사유를 사용자 안내로 바꿀 수 있다.
잡는 범위도 중요하다. 여기서는 예상한 신청 거절만 잡는다. 모든 Exception을 잡아 잔액 부족 같은 안내로 바꾸면, 잘못된 인덱스 접근이나 예상하지 못한 null까지 업무상 거절처럼 보일 수 있다. 처리 방법을 알고 있는 실패만 해당 경계에서 변환해야 원인도 보존된다.
정상 흐름은 ‘찾는다, 검증한다, 다음 상태를 만든다, 반영한다’로 이어진다. 실패 처리는 예외를 통해 호출 경계로 이동한다. 이 구조는 정상 처리의 중간마다 결과 문자열을 조립하고 반환하던 부담을 줄인다. 다만 예외를 사용했다는 사실만으로 상태가 안전해지는 것은 아니다.
실패해도 상태는 한 덩어리로 유지한다
수강 신청의 상태는 잔액, 신청 강좌 목록, 강좌 수강생 목록으로 구성된다. 이 셋은 함께 바뀌어야 한다. 성공했다면 잔액이 줄고 양쪽 목록에 신청이 기록되어야 한다. 거절됐다면 세 값이 모두 신청 전과 같아야 한다. 이것이 이번 예제가 확인할 일관성 조건이다.
완성 코드에서는 먼저 모든 업무 조건을 검사한다. 그다음 기존 목록을 복사해 새 항목을 넣고, 다음 상태 전체를 만든다. 마지막에 현재 상태를 가리키는 참조를 한 번 바꾼다. 목록 복사 중 문제가 생겨도 아직 현재 상태를 교체하지 않았으므로 기존 상태는 유지된다.
반대로 잔액 차감, 목록 추가, 정원 확인을 섞으면 실패 지점마다 되돌릴 항목을 계산해야 한다. 되돌리는 코드 자체가 실패할 가능성까지 생각하면 흐름이 복잡해진다. 가능한 검증을 앞에서 끝내고 변경할 내용을 준비한 뒤 반영하면, 실패 후 상태를 설명하기 쉬워진다.
이 예제는 한 실행 흐름이 메모리 안의 상태를 바꾸는 프로그램이다. 참조를 한 번 바꾼다고 여러 저장소에 걸친 작업까지 묶이는 것은 아니다. 여러 요청이 동시에 신청하거나 별도 저장소에 기록한다면, 검사한 상태가 반영할 때도 유효한지와 변경을 함께 확정하는 방법을 별도로 설계해야 한다.
또한 검증 순서도 관찰 가능한 동작이다. 이미 신청한 수강생이 잔액도 부족하다면 어느 사유를 먼저 반환할지 정해야 한다. 여기서는 수강생, 강좌, 중복, 정원, 잔액 순서를 유지한다. 새 구현에서 순서를 바꾸면 상태는 같아도 사용자에게 보이는 실패 이유가 달라질 수 있다.
완성 코드
다음 프로그램은 한 파일로 저장한다. OldEnrollment는 null과 문자열을 사용하는 이전 구현이고, NewEnrollment는 Optional과 의미 있는 예외를 사용하는 이후 구현이다. 두 구현은 같은 상태 준비 함수를 사용한다. 이번 변경의 초점을 실패 표현과 처리 경계에 두기 위해 상태 반영 방식은 동일하게 유지했다.
각 사례는 별도의 초기 상태에서 시작한다. 성공 사례 하나와 다섯 가지 실패 사례를 실행하고, 결과가 예상과 같은지, 전후 상태가 같은지, 최종 상태가 업무 조건에 맞는지 각각 검사한다. 마지막에는 빈 목록과 단일 조회의 부재 계약을 확인한다.
Main.java
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class Main {
record Student(String id) {}
record Course(String id, int fee, int capacity) {}
record State(int balance, List<String> courses,
List<String> roster) {
State {
courses = List.copyOf(courses);
roster = List.copyOf(roster);
}
}
enum Reason {
STUDENT_NOT_FOUND("수강생을 찾을 수 없다"),
COURSE_NOT_FOUND("강좌를 찾을 수 없다"),
ALREADY_ENROLLED("이미 신청한 강좌다"),
COURSE_FULL("강좌 정원이 찼다"),
INSUFFICIENT_BALANCE("잔액이 부족하다");
private final String message;
Reason(String message) {
this.message = message;
}
String message() {
return message;
}
}
static final class EnrollmentException extends Exception {
private static final long serialVersionUID = 1L;
private final Reason reason;
EnrollmentException(Reason reason) {
super(reason.message());
this.reason = reason;
}
Reason reason() {
return reason;
}
}
static final class Catalog {
private final Student student = new Student("S1");
private final Course course = new Course("C1", 100, 1);
Student oldStudent(String id) {
return student.id().equals(id) ? student : null;
}
Course oldCourse(String id) {
return course.id().equals(id) ? course : null;
}
Optional<Student> findStudent(String id) {
return Optional.ofNullable(oldStudent(id));
}
Optional<Course> findCourse(String id) {
return Optional.ofNullable(oldCourse(id));
}
}
static State prepareNext(State current, Student student,
Course course) {
List<String> courses = new ArrayList<>(current.courses());
List<String> roster = new ArrayList<>(current.roster());
courses.add(course.id());
roster.add(student.id());
return new State(
current.balance() - course.fee(), courses, roster);
}
static final class OldEnrollment {
private final Catalog catalog;
private State state;
OldEnrollment(Catalog catalog, State initial) {
this.catalog = catalog;
this.state = initial;
}
String enroll(String studentId, String courseId) {
Student student = catalog.oldStudent(studentId);
if (student == null) {
return "STUDENT_NOT_FOUND";
}
Course course = catalog.oldCourse(courseId);
if (course == null) {
return "COURSE_NOT_FOUND";
}
if (state.courses().contains(course.id())) {
return "ALREADY_ENROLLED";
}
if (state.roster().size() >= course.capacity()) {
return "COURSE_FULL";
}
if (state.balance() < course.fee()) {
return "INSUFFICIENT_BALANCE";
}
State next = prepareNext(state, student, course);
state = next;
return null;
}
State snapshot() {
return state;
}
}
static final class NewEnrollment {
private final Catalog catalog;
private State state;
NewEnrollment(Catalog catalog, State initial) {
this.catalog = catalog;
this.state = initial;
}
void enroll(String studentId, String courseId)
throws EnrollmentException {
Student student = catalog.findStudent(studentId)
.orElseThrow(() -> new EnrollmentException(
Reason.STUDENT_NOT_FOUND));
Course course = catalog.findCourse(courseId)
.orElseThrow(() -> new EnrollmentException(
Reason.COURSE_NOT_FOUND));
validate(course);
State next = prepareNext(state, student, course);
state = next;
}
private void validate(Course course)
throws EnrollmentException {
if (state.courses().contains(course.id())) {
throw new EnrollmentException(
Reason.ALREADY_ENROLLED);
}
if (state.roster().size() >= course.capacity()) {
throw new EnrollmentException(Reason.COURSE_FULL);
}
if (state.balance() < course.fee()) {
throw new EnrollmentException(
Reason.INSUFFICIENT_BALANCE);
}
}
List<String> enrolledCourses() {
return state.courses();
}
State snapshot() {
return state;
}
}
record Scenario(String name, String studentId, String courseId,
State initial, String expected) {}
static String attempt(NewEnrollment service, Scenario scenario) {
try {
service.enroll(scenario.studentId(), scenario.courseId());
return "SUCCESS";
} catch (EnrollmentException exception) {
return exception.reason().name();
}
}
static void check(boolean condition, String name) {
System.out.println((condition ? "PASS " : "FAIL ") + name);
if (!condition) {
throw new AssertionError(name);
}
}
public static void main(String[] args) {
Catalog catalog = new Catalog();
State available = new State(300, List.of(), List.of());
List<Scenario> scenarios = List.of(
new Scenario("성공", "S1", "C1", available,
"SUCCESS"),
new Scenario("수강생 없음", "S0", "C1", available,
"STUDENT_NOT_FOUND"),
new Scenario("강좌 없음", "S1", "C0", available,
"COURSE_NOT_FOUND"),
new Scenario("중복 신청", "S1", "C1",
new State(300, List.of("C1"), List.of("S1")),
"ALREADY_ENROLLED"),
new Scenario("정원 초과", "S1", "C1",
new State(300, List.of(), List.of("S9")),
"COURSE_FULL"),
new Scenario("잔액 부족", "S1", "C1",
new State(50, List.of(), List.of()),
"INSUFFICIENT_BALANCE"));
for (Scenario scenario : scenarios) {
OldEnrollment before =
new OldEnrollment(catalog, scenario.initial());
NewEnrollment after =
new NewEnrollment(catalog, scenario.initial());
String oldError = before.enroll(
scenario.studentId(), scenario.courseId());
String oldResult = oldError == null ? "SUCCESS" : oldError;
String newResult = attempt(after, scenario);
System.out.println(scenario.name()
+ " | 전=" + oldResult + " | 후=" + newResult);
check(oldResult.equals(scenario.expected())
&& newResult.equals(scenario.expected()),
scenario.name() + " 결과");
check(before.snapshot().equals(after.snapshot()),
scenario.name() + " 전후 상태");
State expectedState = scenario.expected().equals("SUCCESS")
? new State(200, List.of("C1"), List.of("S1"))
: scenario.initial();
check(before.snapshot().equals(expectedState)
&& after.snapshot().equals(expectedState),
scenario.name() + " 최종 상태");
}
NewEnrollment empty = new NewEnrollment(catalog, available);
check(empty.enrolledCourses().isEmpty(), "빈 신청 목록");
check(catalog.findStudent("S0").isEmpty(), "없는 단일 조회");
}
}
줄별 해설
Student와 Course 선언은 이번 실행에서 조회할 대상을 나타낸다. Catalog에는 수강생과 강좌가 각각 하나씩 있다. 예제의 검색은 식별자가 일치하는지만 확인한다. 저장소 구현을 늘리지 않아도 ‘찾음’과 ‘찾지 못함’의 계약을 비교할 수 있다.
State 생성자의 List.copyOf는 전달받은 목록의 내용을 복사해 수정할 수 없는 목록으로 보관한다. 호출자가 원래 목록을 바꾸어 보관된 상태를 흔드는 일을 막는다. 요소가 문자열이므로 여기서는 요소 내부의 변경까지 따로 고려할 필요가 없다. State의 잔액 역시 생성 후 바뀌지 않는다.
Reason 선언은 실패 사유와 설명 문장을 연결한다. 분기는 name()으로 얻는 사유 이름을 사용하고, 예외 메시지는 message()에서 가져온다. 이 예제에서 name()은 실행 비교용 표기다. 이를 외부에 오래 저장하는 식별자로 사용하려면 열거형 이름 변경의 영향까지 별도로 정해야 한다.
EnrollmentException 선언은 수강 신청 거절이라는 범위를 만든다. reason 필드는 호출자가 사유를 확인할 수 있게 한다. serialVersionUID는 예외가 상속받는 직렬화 특성에 대한 버전 식별자다. 여기서는 직렬화를 수행하지 않지만, 관련 컴파일 경고가 생기지 않도록 명시했다.
Catalog의 oldStudent와 findStudent는 같은 검색 결과를 서로 다른 계약으로 돌려준다. 새 조회는 기존 조회가 반환한 null을 Optional.empty()로 바꾼다. 실제 이전 과정에서도 기존 조회를 당장 제거하기 어렵다면 이런 경계를 둘 수 있다. 새 호출자는 null 검사 대신 부재에 대한 결정을 내린다.
prepareNext는 현재 상태를 직접 수정하지 않는다. 두 목록을 복사하고 항목을 추가한 뒤 새 State를 반환한다. 이 메서드는 검증을 통과한 대상에 대해서만 호출한다. 검증되지 않은 입력까지 안전하게 처리하는 공개 신청 메서드와 역할이 다르다.
OldEnrollment.enroll은 검증에 실패하면 문자열을 즉시 반환한다. 성공한 경우에는 null을 반환한다. 이전 구현도 상태 반영을 마지막에 수행하므로 실패 후 상태를 유지한다. 바꿀 대상은 그 동작 자체가 아니라, 호출자가 의미를 암기해야 하는 반환 계약이다.
NewEnrollment.enroll의 orElseThrow는 조회 부재를 신청 거절로 바꾸는 지점이다. 수강생과 강좌를 확보한 다음에는 null을 다시 검사하지 않는다. validate는 기존과 같은 순서로 중복, 정원, 잔액을 확인한다. 상태 준비와 교체는 모든 검증이 끝난 뒤 실행된다.
state = next는 준비한 상태를 현재 상태로 채택하는 지점이다. 그 앞에서는 기존 상태를 읽거나 다음 상태를 만들 뿐이다. 이 한 줄을 기준으로 준비 단계와 반영 단계를 구분할 수 있다. 반영 후에 거절 사유를 검사하는 코드를 추가하면 이 보장이 흐려진다.
attempt는 예외를 결과 표현으로 변환하는 호출 경계다. 서비스가 정상적으로 끝나면 SUCCESS를 반환하고, 신청 거절만 잡아 사유를 반환한다. 검증 함수 안에서 예외를 잡고 무시하지 않기 때문에 실패한 신청이 성공처럼 이어지지 않는다.
main의 세 검사는 서로 다른 질문을 한다. 첫째는 결과가 요구한 값인지, 둘째는 두 구현의 상태가 같은지, 셋째는 그 상태가 업무 조건에 맞는지다. 두 구현이 같은 잘못을 저지르면 전후 비교만으로는 발견하지 못한다. 그래서 성공의 예상 상태와 실패의 초기 상태도 따로 비교한다.
마지막 두 검사는 부재 표현의 계약을 확인한다. 신청 내역은 null 대신 빈 목록을 반환하고, 없는 수강생은 비어 있는 Optional을 반환한다. 여섯 사례를 통과했다는 사실이 모든 입력을 증명하지는 않지만, 이번에 바꾸는 계약과 대표적인 실패 경로를 실행 가능한 기준으로 남긴다.
실행 결과
Main.java를 저장한 디렉터리에서 다음 명령을 실행한다. 프로그램은 파일이나 네트워크를 사용하지 않으며, 시각과 난수에도 의존하지 않는다. 검사가 실패하면 FAIL을 출력하고 AssertionError로 실행을 중단한다.
javac -d out *.java
java -cp out Main
예상 출력은 다음과 같다. ‘최종 상태’가 통과했다는 것은 성공에서는 차감과 양쪽 목록 추가가 확인됐고, 실패에서는 초기 상태가 그대로 유지됐다는 뜻이다.
성공 | 전=SUCCESS | 후=SUCCESS
PASS 성공 결과
PASS 성공 전후 상태
PASS 성공 최종 상태
수강생 없음 | 전=STUDENT_NOT_FOUND | 후=STUDENT_NOT_FOUND
PASS 수강생 없음 결과
PASS 수강생 없음 전후 상태
PASS 수강생 없음 최종 상태
강좌 없음 | 전=COURSE_NOT_FOUND | 후=COURSE_NOT_FOUND
PASS 강좌 없음 결과
PASS 강좌 없음 전후 상태
PASS 강좌 없음 최종 상태
중복 신청 | 전=ALREADY_ENROLLED | 후=ALREADY_ENROLLED
PASS 중복 신청 결과
PASS 중복 신청 전후 상태
PASS 중복 신청 최종 상태
정원 초과 | 전=COURSE_FULL | 후=COURSE_FULL
PASS 정원 초과 결과
PASS 정원 초과 전후 상태
PASS 정원 초과 최종 상태
잔액 부족 | 전=INSUFFICIENT_BALANCE | 후=INSUFFICIENT_BALANCE
PASS 잔액 부족 결과
PASS 잔액 부족 전후 상태
PASS 잔액 부족 최종 상태
PASS 빈 신청 목록
PASS 없는 단일 조회
실무에서 자주 틀리는 것
Optional을 반환하고 곧바로 값을 꺼낸다
다음은 Catalog의 조회를 호출하는 코드 조각이다. 없는 수강생을 입력하면 신청 업무의 의미를 설명하지 못하는 예외가 발생한다. 반환 타입을 바꾸었어도 부재를 어떻게 처리할지 결정하지 않은 상태다.
Student student = catalog.findStudent(studentId).get();
신청에 필요한 수강생이 없다는 의미를 호출 지점에서 부여한다. 이후 코드는 확보한 수강생을 사용하면 된다.
Student student = catalog.findStudent(studentId)
.orElseThrow(() -> new EnrollmentException(
Reason.STUDENT_NOT_FOUND));
목록이 비어 있으면 null을 반환한다
다음과 같이 작성하면 신청 내역을 보여 주는 모든 호출자에 null 검사가 생긴다. ‘0개’라는 정보는 이미 목록의 크기로 표현할 수 있다.
List<String> enrolledCourses() {
return state.courses().isEmpty() ? null : state.courses();
}
상태가 항상 목록을 보관하도록 하고 그 목록을 반환한다. 완성 코드의 목록은 수정할 수 없으므로 반환받은 쪽에서 항목을 추가해 내부 상태를 바꿀 수도 없다.
List<String> enrolledCourses() {
return state.courses();
}
넓게 잡고 실패를 성공으로 바꾼다
다음은 신청 호출 경계의 잘못된 처리다. 실패를 잡은 뒤 SUCCESS를 반환하면 호출자는 재시도나 안내를 결정할 근거를 잃는다. 예상하지 못한 결함도 같은 결과에 묻힌다.
try {
service.enroll(studentId, courseId);
} catch (Exception exception) {
return "SUCCESS";
}
return "SUCCESS";
처리할 수 있는 신청 거절만 잡아 해당 사유를 반환한다. 예외의 메시지 문자열을 비교하지 않고 구조화된 사유를 사용한다.
try {
service.enroll(studentId, courseId);
return "SUCCESS";
} catch (EnrollmentException exception) {
return exception.reason().name();
}
상태를 바꾸고 나서 거절 조건을 검사한다
다음은 NewEnrollment 내부에 잘못 추가한 코드라고 가정한다. 정원이 찬 경우에도 잔액을 차감한 상태가 먼저 반영된다. 예외를 던졌다는 사실은 이미 일어난 변경을 되돌리지 않는다.
state = new State(
state.balance() - course.fee(),
state.courses(), state.roster());
validate(course);
검증을 먼저 끝내고 다음 상태 전체를 준비한다. 이 예제에서는 중간에 실패하면 현재 상태를 교체하는 줄에 도달하지 않는다.
validate(course);
State next = prepareNext(state, student, course);
state = next;
상태 검사가 이미 바뀐 잔액을 읽는 문제도 사라진다. 다만 외부 작업이 포함되면 이 순서만으로 충분한지 다시 판단해야 한다. 상태를 되돌리는 코드가 필요한 상황이라면 그 코드 역시 실패할 수 있음을 고려해야 한다.
한눈에 보기
| 점검 대상 | 선택 기준 | 이번 코드의 위치 |
|---|---|---|
| 단일 조회의 부재 | 부재를 반환 타입으로 알린다 | Catalog.findStudent |
| 복수 조회의 부재 | 정상 조회 결과가 0개면 빈 목록이다 | enrolledCourses |
| 작업 거절 | 예외 타입과 사유로 의미를 표현한다 | EnrollmentException, Reason |
| 실패 안내 | 처리할 수 있는 호출 경계에서 변환한다 | attempt |
| 상태 반영 | 검증과 준비가 끝난 뒤 교체한다 | state = next |
| 동작 확인 | 결과, 전후 상태, 예상 상태를 비교한다 | main의 check 호출 |
예외 설계에서 중요한 질문은 ‘어떤 예외를 던질까’ 하나가 아니다. 부재가 정상 결과인지, 실패를 누가 해석할지, 실패한 뒤 무엇이 남아야 하는지까지 연결해야 한다. 다음 장에서는 이 판단을 바탕으로 조회, 신청, 상태 보관의 책임을 어떤 클래스에 둘지 살펴본다.
연습 문제
- 잔액이 100인 상태와 99인 상태를 추가하라. 강좌와 정원은 기본 사례와 같게 두고, 각 결과와 최종 상태를 전후 구현에서 비교하라.
- 이미 신청한 강좌이고 잔액도 부족한 사례를 추가하라. 현재 코드가 반환할 사유를 먼저 적고 실행 결과와 비교하라. 잔액 검사를 중복 검사 앞으로 옮기면 무엇이 달라지는지도 설명하라.
- 수강생 식별자와 강좌 식별자가 모두 존재하지 않는 사례를 추가하라. 기대할 사유와 실패 후 상태를 정하고 검사하라.
- prepareNext에서 목록을 복사한 직후 의도적으로 IllegalStateException을 던진다고 가정하라. 현재 상태가 유지되는 이유와 attempt가 이 예외를 잡지 않는 이유를 설명하라.
정답과 해설
1. 잔액이 수강료와 같은 100이면 신청에 성공하고 잔액은 0이 된다. 99이면 INSUFFICIENT_BALANCE이며 잔액과 양쪽 목록이 초기 상태와 같아야 한다. 성공 상태를 항상 잔액 200으로 고정한 기존 검사는 새 성공 사례에 맞게 바꾸어야 한다. Scenario에 예상 State를 추가하면 사례별 기대를 명시할 수 있다.
record Scenario(String name, String studentId, String courseId,
State initial, String expected, State expectedState) {}
이렇게 확장한다면 기존 여섯 사례에도 예상 상태를 전달하고, main에서 계산하던 expectedState 대신 scenario.expectedState()를 사용한다. 두 구현의 상태가 같다는 검사만 추가하면 두 구현이 함께 잘못 차감하는 경우를 놓칠 수 있으므로, 잔액 0이라는 기대도 별도로 확인해야 한다.
2. 초기 상태를 잔액 50, 강좌 목록 [C1], 수강생 목록 [S1]로 두면 ALREADY_ENROLLED가 먼저 나온다. 중복 검사가 정원과 잔액 검사보다 앞에 있기 때문이다. 잔액 검사를 가장 앞의 업무 검사로 옮기면 INSUFFICIENT_BALANCE가 된다. 둘 다 거절이지만 관찰 가능한 결과가 달라지므로, 표현만 바꾸는 작업에서 검증 순서까지 함께 바꾸면 안 된다.
3. S0과 C0을 전달하면 STUDENT_NOT_FOUND가 나온다. 이전 구현은 수강생 조회 직후 반환하고, 이후 구현은 첫 orElseThrow에서 신청을 중단한다. 강좌 조회와 상태 준비에는 도달하지 않는다. 초기 상태로 available을 사용했다면 잔액 300과 두 빈 목록이 그대로 유지되어야 한다.
4. 목록 복사와 새 상태 준비는 현재 State의 내용을 변경하지 않는다. prepareNext가 값을 반환하지 못하면 호출자는 state = next를 실행하지 못하므로 기존 상태가 유지된다. attempt는 업무 조건에 따른 EnrollmentException만 처리하도록 작성됐다. 의도적으로 넣은 IllegalStateException까지 신청 거절로 바꾸면 코드 결함과 업무상 실패가 같은 결과로 보이므로 잡지 않는다. 이 동작을 검사하려면 별도의 검사 범위에서 해당 예외가 발생했는지 확인하고, 이어서 snapshot이 초기 상태와 같은지 확인하면 된다.