오류 처리는 프로그램에 반드시 필요한 요소 중 하나이다. 무언가가 잘못될 가능성은 항상 존재하기 때문이다.
깨끗한 코드와 오류 처리는 큰 연관성이 있으니 오류 처리도 깔끔하게 해야 한다.
예외 처리를 지원하지 않는 언어에서는 오류를 처리하는 방법이 꽤나 제한적이었다. 오류 플래그를 설정하거나 호출자에게 오류 코드를 전달하는 방법 밖에 존재하지 않았기 때문이다.
public class DeviceController {
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// 디바이스를 다룰 수 없는 경우를 오류 플래그를 통해 확인
if(handle != DeviceHandle.INVALID) {
retrieveDeviceRecord(handle);
if(record.getStatus() != DEVICE_SUSPEND) {
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice();
} else {
logger.log("Device suspended. Unable to shut down");
}
} else {
// 오류 처리 역시, 로그를 통해 알리는 방법으로 수행
logger.log("Invalid handle for : " + DEV1.toString());
}
}
}
이렇게 오류를 처리할 경우, 함수를 호출한 즉시 오류를 일일히 찾아야 하기 때문에 해당 함수를 호출한 호출자의 코드가 복잡해진다. 하지만 예외를 던진다면 논리와 오류 처리 코드가 뒤섞이지 않아 코드가 깔끔해진다.
public class DeviceControler {
public void sendShutDown() {
try {
tryToShutDown();
} catch (DeviceShutDownError e) {
logger.log(e);
}
}
private void tryToShutDown() throws DeviceShutDownError {
DeviceHandle handle = getHandle(DEV1);
DeviceRecord record = retrieveDeviceRecord(handle);
pauseDevide(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
private DeviceHandle getHandle(DeviceID id) {
...
throw new DeviceShutDownError("Invalid handle for : " + id.toString());
...
}
}
처음 코드에서는 디바이스를 종료하는 부분과 오류를 처리하는 부분이 뒤섞여 코드가 다소 지저분했었다. 하지만 예외를 던짐으로써 코드 품질이 크게 향상된 것을 알 수 있다.
try-catch-finally
문에서 try
블록 내 코드를 실행하면 어느 시점에서든 실행이 중단된 후 catch
블록으로 넘어갈 수 있다. catch
블록은 try
블록에서 어떤 일이 생기든지 일관성을 유지해야 하기 때문에, 예외가 발생할만한 코드를 작성할 때는 try-catch-finally
문으로 시작을 하는 것이 낫다.
// 파일이 없으면 예외를 던지는지 확인하는 단위 테스트
@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
sectionStroe.retrieveSection("invalid - file");
}
// 미구현된 테스트 메소드
public List<RecordedGrip> retrieveSection(String sectionName) {
return new ArrayList<RecordedGrip>;
}
코드의 원형은 작성이 되었으나 예외를 던지지 않기 때문에 테스트는 실패한다. 잘못된 파일에 접근을 하도록 구현을 해보자.
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>;
}
코드가 예외를 던지기 때문에 테스트가 성공하며, 나머지 논리를 try
블록에 작성하면 된다.
예외를 일으키는 테스트 케이스를 가장 먼저 작성한 뒤, 테스트를 통과하는 코드를 작성하는 것이 좋은 순서이다.
자바가 처음 나왔을때만 하더라도 checked
예외가 멋지게 여겨졌다. 메소드를 선언할 때는 메소드가 반환할 예외를 모두 작성했고, 메소드가 반환하는 예외는 메소드의 일부였기 때문에 코드가 메소드를 사용하는 방식이 선언과 일치하지 않으면 컴파일도 하지 못했다. 하지만 지금은 unchecked
예외를 사용하더라도 충분히 안정적인 프로그램을 만들 수 있다.
checked
예외의 단점은 OCP(Open Closed Participle)를 위반한다는 것이다. 메소드에서 예외를 던졌을 경우, catch
블록이 위치하는 메소드까지 모든 예외를 정의해야 한다. 이 말은 하위 단계에서 코드를 변경하면 모든 상위 메소드의 선언부에 throws
절을 추가해야 하며, 캡슐화가 깨진다는 것이다.
중요한 라이브러리를 작성할 경우에는 checked
예외도 유용하게 쓰일 수는 있지만, 의존성이라는 비용을 생각하고 사용하자.
예외를 던질 때는 그 상황을 충분히 덧붙여 오류가 발생한 원인과 위치를 찾기 쉽도록 만들어야 한다.
오류 메세지에 정보(실패한 연산 이름과 유형 등)을 담아 예외로 던진다.
어플리케이션에서 오류를 정의할 때 개발자가 가장 신경써야 할 부분은 오류를 잡아내는 방법이 되어야 한다.
// 외부 라이브러리를 호출하는 코드
// 외부 라이브러리가 던질 예외를 모두 catch로 잡아냄
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", e);
} finally {
...
}
한 눈에 봐도 코드가 지저분하다는 것을 느낄 수 있다. 이렇게 외부 라이브러리에 대한 예외 처리가 필요할 경우에는 호출하는 라이브러리를 감싸서 예외 유형을 하나로 줄이면 된다.
// 외부 라이브러리에 대한 예외 처리를 하나로 줄임
LocalPort port = new LocalPort(12);
try{
port.open();
} catch(PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
...
}
// 라이브러리를 감싸기 위해 만든 Wrapper 클래스
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);
}
}
}
이렇게 외부 라이브러리를 감쌀 경우 의존성이 크게 줄어들어 다른 라이브러리로 교체하더라도 교체 비용이 크지 않다. 또한 Wrapper 클래스에서 외부 API를 호출하는 대신 테스트 코드를 넣는 경우 테스트도 쉬워지며 특정 API 설계 방식에 영향을 받지 않게 된다.
예외 클래스가 하나도 있어도 충분한 코드가 많다. 이는 예외 클래스에 포함된 정보로만 오류를 구분해도 괜찮은 경우이며, 잡아내야 하는 예외와 무시해도 괜찮은 예외가 존재할 경우에는 여러 클래스를 사용해도 된다.
앞의 내용을 바탕으로 비즈니스 로직과 오류 처리를 구분하다보면 오류 감지가 프로그램의 바깥 쪽으로 밀려나는 경우가 있다. 외부 API를 감싸 새로운 예외를 던지고, 코드에서 중단된 계산을 처리하는 것이다. 하지만 항상 이 방식이 옳은 것은 아니다.
// 총 비용을 계산하는 코드
try {
// 직원이 식비를 청구한 경우, 총 금액에 해당 금액을 추가
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
// 식비를 청구하지 않은 경우 기본 식비를 더함
m_total += getMealPerDiem();
}
이 코드에서는 식비를 청구하지 않은 경우 예외를 던지는데, 굳이 예외를 던져야할까 하는 의문점이 생긴다. getMeals
메소드가 항상 MealExpenses 클래스를 반환한다면 try-catch
문을 사용할 필요가 없기 때문이다. 따라서 식비를 청구하지 않은 경우를 처리하기 위한 클래스를 새롭게 만들어 반환하면 문제가 해결된다.
// 기존 코드에서 try-catch를 제거
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
이렇게 문제를 해결하는 것을 특수 사례 패턴(SPECIAL CASE PATTERN)이라고 부르며, 클래스를 만들거나 객체를 조작해 특수한 사례를 처리하는 방식을 의미한다. 클래스나 객체가 예외적인 상황을 캡슐화해서 처리하기 때문에 클라이언트 코드는 예외 상황을 처리할 필요가 없어진다.
흔하게 일어나는 오류 유발 행휘가 바로 null
을 반환하는 습관이다. 한 줄마다 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
을 한 줄이라도 빼먹는다면 어플리케이션이 통제 불능에 빠질지도 모른다는 점 때문이다. 실제로도 위 코드에서 persistentStore가 null
인 경우는 확인을 하지 않았다.
이는 null
확인을 빼먹어서 생기는 문제가 아니라, 확인을 너무 많이 해야하는 것이 문제가 된다.
List<Employee> employees = getEmployees();
if(employees != null) {
for(Employee e : employees) {
totalPay += e.getPay();
}
}
여기서는 getEmployees 메소드가 null
을 반환할 수도 있기 때문에 확인을 먼저 한 뒤, for
문을 수행하였다. 하지만 getEmployees 메소드가 null
을 반환하지 않으면 어떨까? null
대신 비어있는 리스트를 반환한다면 코드가 훨씬 깔끔해질 것이다.
List<Employee> employees = getEmployees();
for(Employee e : employees) {
totalPay += e.getPay();
}
메소드에서 null
을 반환하는 것보다 나쁜 행동이 null
을 전달하는 것이다. 메소드의 인수로 null
을 전달할 경우 오류 처리를 작성하지 않은 코드에서는 반드시 NullPointerException
이 발생할 것이다.
null
을 검사하기 위해 새로운 예외를 만들거나 assert
문 등을 사용하는 것보다는 null
을 사용하지 않도록 정책을 세워 실수를 방지하자.
깨끗한 코드는 가독성 뿐만 아니라 안정성도 높아야 한다. 이 둘은 상충하는 목표가 아니며, 오류 처리를 프로그램 논리와 분리하여 독자적으로 고려한다면 깨끗한 코드를 작성할 수 있다.