📕 목차
1. 오류 코드보다 예외를 사용하라
2. Try-Catch-Finally 문부터 작성하라
3. 미확인(unchecked) 예외를 사용하라
4. 예외에 의미를 제공하라
5. 호출자를 고려해 예외 클래스를 정의하라
6. 정상 흐름을 정의하라
7. null을 반환하지 마라
8. null을 전달하지 마라
1. 오류 코드보다 예외를 사용하라
📌 As-is. 오류 코드
public class DeviceController {
...
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// Check the state of the device
if (handle != DeviceHandle.INVALID) {
// Save the device status to the record field
retrieveDeviceRecord(handle);
// If not suspended, shut down
if (record.getStatus() != DEVICE_SUSPENDED) {
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
} else {
logger.log("Device suspended. Unable to shut down");
}
} else {
logger.log("Invalid handle for: " + DEV1.toString());
}
}
...
}
- 예외를 지원하지 않는 구시대 프로그래밍 언어를 위한 방식
- 오류 플래그를 설정하거나 호출자에게 오류 코드를 반환하는 방법이 전부다.
- 함수를 호출한 즉시 오류를 확인해야 하므로 코드가 복잡해진다.
📌 To-be. 예외
public class DeviceController {
...
public void sendShutDown() {
try {
tryToShutDown();
} catch (DeviceShutDownError e) {
logger.log(e);
}
}
private void tryToShutDown() throws DeviceShutDownError {
DeviceHandle handle = getHandle(DEV1);
DeviceRecord record = retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
private DeviceHandle getHandle(DeviceID id) throws DeviceShutDownError {
if (record.getStatus() != DEVICE_SUSPENDED)
throw new DeviceShutDownError("Invalid handle for: " + id.toString());
...
}
...
}
- 논리가 오류 처리 코드와 뒤섞이지 않으므로 깔끔해진다.
- 디바이스를 종료하는 알고리즘과 오류 처리 로직 분리되어 각 개념을 독립적으로 살펴보고 이해할 수 있다.
2. Try-Catch-Finally 문부터 작성하라
try 블록은 트랜잭션과 비슷하다.
try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다.
즉, try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.
@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
sectionStore.retrieveSection("invalid - file");
}
파일을 열어 직렬화된 객체 몇 개를 읽어 들이는 코드 작성을 위해, 테스트 케이스를 우선 작성하였다.
메서드 명으로 보아 retrieveSection()은 반드시 InvalidFileName 예외를 발생시켜야 할 듯하다.
public List<RecordedGrip> retrieveSection(String sectionName) {
// 실제 구현할 때까지 비어 있는 더미를 반환한다.
return new ArrayList<RecordedGrip>();
}
가장 간단한 더미 코드를 돌려주는 retrieveSection()을 구현했으나 예외를 던지지 않으므로 단위 테스트에 실패한다.
의도에 맞게 잘못된 파일에 접근하도록 구현해야 한다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
} catch (Exception e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}
코드가 반드시 예외를 던지므로 테스트가 성공할 것이다.
이제 리팩터링을 해보면, catch 블록에서 예외 유형을 보다 좁힐 수 있다.
바로 FileInputStream이 던지는 FileNotFoundException을 잡는 것이다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
} catch (FileNotFoundException e) {
throw new StorageException("retrieval error", e);
} catch (IOException e) {
throw new RuntimeException(e);
}
return new ArrayList<RecordedGrip>();
}
(책에서는 안 나오는데, stream.close() 때문에 IOException 안 해주면 컴파일 에러가 나온다.)
강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하는 방법을 권장한다.
그러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되어, 범위 내 트랜잭션 본질을 유지하기가 쉽다.
3. 미확인(unchecked) 예외를 사용하라
- 확인된 예외(Check Exception)
- RuntimeException 클래스를 상속받지 않은 예외 클래스
- 복구 가능성이 있는 예외이므로 반드시 예외 처리 코드를 함께 작성해야 한다.
- 예외를 처리하지 않으면 컴파일 에러가 발생한다.
- ex. IOException, SQLException, FileNotFoundException, ClassNotFoundException 등
- 미확인 예외(Uncheck Exception)
- RuntimeException 클래스를 상속받지 않는다.
- 복구 가능성이 없으므로 컴파일러가 예외처리를 강제하지 않는다.
- ex. NullPointerException, IllgalArgumentException, ClassCastException 등
- 추가로 Spring에서 제공하는 @Transactional 안에서 에러 발생 시 check exception은 롤백이 되지 않고, uncheck exception은 롤백이 된다. (순수 자바 관점에서는 UncehckedException 상황에서는 롤백을 수행하지 않아도 된다. 애초에 그런 식으로 강제하는 규칙이 없다.)
확인된 예외는 OCP(Open Closed Principle)을 위반한다.
가장 아래에서 호출된 함수에서 check exception를 던진다면, catch 블록이 있는 상위 함수까지의 모든 함수가 throws 절을 추가해야 한다.
- 변경한 함수를 호출하는 함수 모두가 catch 블록에서 새로운 예외를 처리하는 경우
- 모든 상위 함수가 선언부에 throw절을 추가해야 하는 경우
결국 throws 경로에 위치하는 모든 함수가 최하위에서 던지는 예외를 알아야 하므로 캡슐화가 깨진다.
가끔 아주 중요한 라이브러리를 작성할 때는 check exception도 유용하다.
하지만 일반적인 애플리케이션은 의존성이라는 비용이 이익보다 크다.
4. 예외에 의미를 제공하라
- 예외를 던질 때는 전후 상황을 충분히 덧붙여라. 그러면 오류가 발생한 원인과 위치를 찾기 편하다.
- 자바는 모든 예외에 호출 스택을 제공하지만 이것만으로는 부족하다.
- 오류 메시지에 정보를 담아 예외와 함께 던져라. (실패한 연산 이름, 실패 유형 등)
5. 호출자를 고려해 예외 클래스를 정의하라
💡 애플리케이션에서 오류를 정의할 때 프로그래머는 오류를 잡아내는 방법에 관심사를 두어야 한다.
오류를 분류하는 방법은 수없이 많다.
오류가 발생한 컴포넌트, 유형(디바이스 실패, 네트워크 실패, 프로그래밍 오류) 등으로 분류하기도 하지만, 오류를 잡아내는 방법에 초점을 두어야 한다.
우리가 오류를 처리하는 방식은 비교적 일정하다
- 오류를 기록한다.
- 프로그램을 계속 수행해도 좋은지 확인한다.
이런 경우에는 여러 개의 catch를 없애기 위해 호출하는 라이브러리 API를 감싸면서 예외 유형 하나를 반환하도록 만들면 된다.
// Bad
// 외부 라이브러리가 던질 예외를 모두 처리하고 있다.
ACMEPort port = new ACMEPort(12);
try {
port.open();
} catch (DeviceResponseException e) {
reportPortError(e);
logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
reportPortError(e);
logger.log("Unlock exception", e);
} catch (GMXError e) {
reportPortError(e);
logger.log("Device response exception");
} finally {
...
}
// Good
// ACME 클래스를 LocalPort 클래스로 래핑
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
...
}
...
public class LocalPort {
private ACMEPort innerPort;
public LocalPort(int portNumber) {
innerPort = new ACMEPort(portNumber);
}
public void open() {
try {
innerPort.open();
} catch (DeviceResponseException e) {
throw new PortDeviceFailure(e);
} catch (ATM1212UnlockedException e) {
throw new PortDeviceFailure(e);
} catch (GMXError e) {
throw new PortDeviceFailure(e);
}
}
...
}
- LocalPort는 ACMEPort 클래스가 던지는 예외를 잡아 변환하는 래퍼 클래스일 뿐이다.
- 외부 API를 사용할 때 최선의 방법이다.
- 의존성이 크게 줄어든다.
- 외부 API를 호출하는 대신 테스트 코드를 넣어주는 방법을 사용하기도 쉽다.
- 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다.
- 한 예외는 잡아내고 다른 예외는 무시해도 괜찮은 경우라면 여러 예외 클래스를 사용하면 된다.
6. 정상 흐름을 정의하라
// Bad
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
m_total += getMealPerDiem();
}
위 코드는 식비를 비용으로 청구하지 않은 직원들에 대해 일일 기본 식비를 총계에 더하는 로직이다.
문제는 예외가 논리를 따라가기가 힘들다.
특수 상황을 처리하지 않고, 기본값을 설정할 수 있으면 간결해질 것이다.
📌 특수 사례 패턴(Special Case Pattern)
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
...
public class PerDiemMealExpenses implements MealExpenses {
public int getTotal() {
// 기본값으로 일일 기본 식비 반환
}
}
ExpenseReportDAO.getMeals()가 언제나 MealExpense 객체를 반환하도록 한다.
청구한 식비가 없다면 일일 기본 식비를 반환하는 MealExpense 객체를 반환한다.
- 위에서 봤던 사례들과 달리, catch문에서 예외적인 상황을 처리해야 하는 경우에 사용한다.
- Default 값을 설정하는 방식
- 호출자는 예외적인 상황을 신경쓰지 않아도 된다.
- 예외 상황은 특수 사례 객체(Special Case Object) 내에 캡슐화 된다.
public class ExpenseReportDAO {
public MealExpenses getMeals(int employeeId) {
MealExpenses expenses = getMealExpensesFromSource(employeeId);
return (expenses != null) ? expenses
: new PerDiemMealExpenses();
}
private MealExpenses getMealExpensesFromSource(int employeeId) {
// 데이터베이스 또는 외부 소스에서 식비 정보를 조회하여 반환하는 로직..
return null;
}
}
굳이 예를 들자면 이런 로직이 되지 않을까.
7. null을 반환하지 마라
public void registerItem(Item item) {
if (item != null) {
ItemRegistry registry = persistentStore.getItemRegistry();
if (registry != null) {
Item existing = registry.getItem(item.getID());
if (existing.getBillingPeriod().hasRetailOwner()) {
existing.register(item);
}
}
}
}
- null을 반환하는 코드는 일거리를 늘리면서 호출자에게 문제를 떠넘긴다.
- null guard를 잊어먹으면 당장에 문제가 발생하지 않고 몇 년 후에나 오류가 발생할 수 있는데, 원인을 찾아내기가 굉장히 힘들어진다.
- 위 코드에서도 둘째 행의 persistentStore의 null check를 빠트리고 있다. (찾기 힘들다)
- 메서드에서 null을 반환하기보다, 예외를 던지거나 특수 사례 객체를 반환하라.
- 외부 API에서 null을 반환한다면, 래퍼 메서드를 구현하여 예외를 던지거나 특수 사례 객체를 반환하라.
📌 많은 경우에 특수 사례 객체가 손쉬운 해결책이다.
List<Employee> employees = getEmployees();
if (employees != null) {
for(Employee e : employees) {
totalPay += e.getPay();
}
}
getEmployees()가 null을 반환화면, 호출부에서 null-guard를 해주어야 한다.
// Good
List<Employee> employees = getEmployees();
for(Employee e : employees) {
totalPay += e.getPay();
}
public List<Employee> getEmployees() {
if( .. there are no employees .. )
return Collections.emptyList();
}
}
하지만 애초에 getEmployees()에서 빈 컬렉션을 반환해주었다면 훨씬 깔끔해질 수 있었다.
8. null을 전달하지 마라
null을 리턴하는 것보다 나쁘다.
정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 NULL을 전달하는 코드는 피하라.
// Bad
// calculator.xProjection(null, new Point(12, 13)); - NullPointerException 발생
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
return (p2.x – p1.x) * 1.5;
}
...
}
위 방식은 호출자가 null을 호출하면 NullPointerException이 발생한다.
따라서 아래와 같은 Null guard가 필요하다.
// Bad
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
if(p1 == null || p2 == null){
throw InvalidArgumentException("Invalid argument for MetricsCalculator.xProjection");
}
return (p2.x – p1.x) * 1.5;
}
}
NullPointerException은 피했으나, InvalidArgumentException을 잡아내는 예외가 필요하다.
(사실 instanceof를 쓰면 될 일이다. 책이 예전에 쓰여져서 오류가 있는 듯 하다.)
// Bad
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
assert p1 != null : "p1 should not be null";
assert p2 != null : "p2 should not be null";
return (p2.x – p1.x) * 1.5;
}
}
assert문을 사용하면 문서화가 잘 되어 코드를 읽기는 좋지만 근본적인 문제를 해결하지는 못한다.
대다수 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없다.
그렇다면 애초에 null을 넘기지 못하도록 금지하는 것이 합리적이며, null을 넘기는 코드에 문제가 있다는 말이다.
(아무튼 클라이언트 잘못임)