7장 오류 처리를 읽으면서 내용에 대해 보충하거나, 개인적인 의견으로 반박하거나, 고민해 볼 부분에 대해 적어보려 한다.
오류 코드보다 예외를 사용하라
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());
}
}
// ...
}
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) {
// ...
throw new DeviceShutDownError("Invalid handle for: " + id.toString());
// ...
}
// ...
}
반환 코드를 사용할 경우, 해당 함수가 호출된 뒤 바로 그 오류를 처리하지 않으면 오류를 잊기 쉽다고 한다. 또한 예외 처리를 통해 코드의 가독성이 좋아진다고 하고 있다.
좀 더 정확하게 왜 가독성이 좋아지는지 해설하자면, 반환 코드를 검사하고 처리할 때는 각 반환 코드에 대한 의미를 정확히 이해하고 있어야 하며, 이전 함수 호출 결과가 성공했을 때 다음 동작을 수행시키는 방식이므로 들여쓰기 등의 흐름이 많아질 가능성이 높다. 하지만 예외 처리의 경우 성공이 아닌 경우를 모두 예외로 전달하므로, 문맥에 대한 이해를 단순하게 할 수 있다.
책에서 나온 예시처럼 들여쓰기가 많아지는 경우는 early return 식으로 예외를 먼저 처리해서 함수의 흐름을 종료시키는 식으로 작성하면 방지할 수 있으며, 들여쓰기 복잡도가 높아지는데는 try
-catch
도 한 몫을 하기 때문에 이 부분에 대해서는 주장하기 어려울 것 같다.
Try-Catch-Finally 문부터 작성하라
try
-catch
-finally
문을 사용하면 새로운 범위를 정의할 수 있다는 장점이 있다. try
문에서 코드를 수행할 때, 임의로 중간에서 중지되고 catch
에서 계속 이어나갈 수 있다.
이러한 특성을 봤을 때, try
문은 트랜잭션이라고 볼 수 있고, catch
는 try
에서 무슨 일이 일어났던 일관성을 유지할 수 있게 해 주는 일종의 롤백 과정이라 볼 수 있다. 이런 이유로 try
-catch
-finally
문을 먼저 작성할 것을 추천하고 있다.
미확인unchecked 예외를 사용하라
Java의 예외 처리 방식은 2가지로 구분할 수 있다. 확인(checked) 예외와, 미확인(unchecked) 예외다. checked 예외를 발생시키는 메서드의 경우 메서드 정의에 기술하거나, 내부에서 처리해야 한다.
public void openDataFile() throws FileNotFoundException {
FileInputStream stream = new FileInputStream("data.dat");
}
private FileInputStream stream;
public void openDataFile() throws FileNotFoundException {
this.stream = new FileInputStream("data.dat");
}
public int readData() throws IOException {
return stream.read();
}
public void getFromDataFile() throws FileNotFoundException, IOException {
// ...
openDataFile();
readData();
// ...
}
public int readData() {
int value;
try {
value = stream.read();
} catch (IOException e) {
value = 0;
}
return value;
}
두 번째 코드에서 보여준 것 처럼, 내부 메서드가 throws
로 checked 예외를 반환하게 되면 해당 메서드를 사용하는 상위 메서드도 throws
로 감싸줘야 한다. 만약 내부에서 호출하는 여러 메서드가 각각 다른 checked 예외를 발생시킨다면 각각 예외를 모두 작성해 줘야 한다. 여기에서는 FileNotFoundException
이 IOException
을 상속받으므로 간단하게 throws IOException
만 작성해도 되지만, 서로 상속 관계가 아닌 메서드였다면 예시처럼 모든 발생할 수 있는 예외 목록을 나열해야 한다.
이렇게 상위 메서드에서 하위 메서드의 예외 처리를 매번 기술해야 하는 것은 개방-폐쇄 원칙을 위반할 수 있다고 한다. 다른 객체의 메서드에서 발생시키큰 checked 예외가 그 메서드를 사용하는 객체에 영향을 주기 때문이다.
이외에도 트랜잭션의 rollback 관련 관점 문제도 있는데, 이 부분은 해당 글을 읽어보길 바란다.
예외에 의미를 제공하라
예외를 발생시킬때는 왜, 어디서 에러가 발생했는지 알려주는 것이 좋다. Java에서는 모든 예외에서 stack trace를 알아낼 수 있지만, stack trace만으로는 왜 해당 에러가 발생했는지 알기 쉽지 않다. 정확히 어디서 발생된 예외인지는 알 수 있어도, 직관적인 예외의 원인을 이해하기 힘들 가능성이 높다.
예외 발생 시 해당 정보를 알 수 있게 메시지를 작성해두면 catch
문에서 해당 에러들에 대한 로그를 찍을 수 있다.
호출자를 고려해 예외 클래스를 정의하라
책에서 보여주눈 예제는 API를 활용할 때, 그 API의 예외를 그대로 반환하기 보단 좀 더 문맥에 맞게 예외를 변환하여 발생시키는 방식을 보여주고 있다.
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 {
// ...
}
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 port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
// ...
}
정상 흐름을 정의하라
초반에 이야기했던 try
-catch
로 인해 들여쓰기 등의 복잡도가 높아지는 것을 방지하기 위한 방법에 대해 서술하고 있다. 정상적인 흐름에서 예외가 발생했을 때, 이 예외를 처리하느라 코드가 복잡해지는 것을 방지해야 한다는 것이다.
책에서 나온 코드 예시는 엔티티를 가져오는 과정에서 예외가 발생할 수 있는 상황을 보여주고, 예외를 처리하기 위한 흐름 대신 이 흐름을 묶어서 하나의 메서드로 제공하는 예시를 보여주고 있다.
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
} catch (MealExpensesNotFound e) {
m_total += getMealPerDiem();
}
public class PerDiemMealExpenses implements MealExpenses {
public int getTotal(){
// return the per diem default
}
}
null을 반환하지 마라
Java를 포함한 객체지향 언어는 대부분 각 객체를 new
등의 키워드로 생성하게 한다.
이런 방식은 일반적으로 모든 객체들을 heap 영역에 정의하게 하고, 실제 객체를 접근하는 변수는 포인터 형태로 주소를 가리키게 한다. 이 경우 객체가 실제 생성된 것인지를 구분하기 위해 null
개념을 도입했다.
이로 인해 대부분 함수에서는 함수의 인자가 null
인지 확인하는 코드를 작성해야 한다. (null
인 객체에서 메서드를 호출하면 NullPointerException
이 발생한다.) 이런 코드의 불편함을 제거하기 위해, Kotlin이나 Swift 등은 변수의 선언이나 할당 연산에서 다른 기호를 사용하여 null
여부를 확인하는 기능을 추가했다.
아래 코드를 확인해보자.
public void registerItem(Item item) {
if (item != null) {
ItemRegistry registry = peristentStore.getItemRegistry();
if (registry != null) {
Item existing = registry.getItem(item.getID());
if (existing.getBillingPeriod().hasRetailOwner()) {
existing.register(item);
}
}
}
}
public void registerItem(Item item) {
if (item == null) {
// return or throw exception
}
ItemRegistry registry = peristentStore.getItemRegistry();
if (registry != null) {
// return or throw exception
}
Item existing = registry.getItem(item.getID());
if (existing.getBillingPeriod().hasRetailOwner()) {
existing.register(item);
}
}
첫 예시부터 분석했을 때, 이미 null
검사를 하고 있는데, 중간에 사용된 다른 변수/함수의 반환 값에 대해 null
검사를 하고 있지 않는 것을 볼 수 있다. (중간 persistentStore
나, registry.getItem()
의 결과인 existing
에 대한 null
검사를 하지 않고 있다.)
이런 식으로 null
검사를 최소화하여 코드의 흐름을 단순화해야 하는 것을 주장하는 것으로 보이는데, 일단 이렇게 중첩 if
문은 early return 방식으로 들여쓰기의 깊이를 줄일 수 있다. (물론 매번 null
검사를 위해 if
문의 개수가 여전히 많다는 것이 문제라고 볼 수 있다.)
결국 과도한 null
검사를 최소화하기 위해선 평소에 null
을 반환하지 않도록 코드를 짜는 것이 중요하다는 것이다.
List<Employee> employees = getEmployees();
if (employees != null) {
for(Employee e : employees) {
totalPay += e.getPay();
}
}
public List<Employee> getEmployees() {
if(/* .. there are no employees .. */)
return Collections.emptyList();
}
// Code block without null checking
List<Employee> employees = getEmployees();
for(Employee e : employees) {
totalPay += e.getPay();
}
이전의 getEmployees()
에서 null
을 반환하지 않으면서 해당 함수의 결과가 없음을 알려줄 수 있어야 한다. Java의 경우는 Collections.emptyList()
를 사용할 수 있다.
null을 전달하지 마라
null
의 반환보다, null
을 함수의 인자로 사용하는 것이 더 문제라고 하고 있다.
이전의 언급처럼 null
을 사용하지 않도록 프로그래밍하지 않았다면, 당연히 NullPointerException
을 발생시킬 것이다. 외부 코드의 경우 수정하기 힘들기 때문에 null
의 사용을 지양하라고 하는 것 같다.
결론
깨끗한 코드는 가독성이 좋아야하지만, 또한 견고해야 한다. 두 목표는 서로 반대되지 않으므로 동시에 이룰 수 있다. 예외 처리는 코드를 견고하게 하기 위한 도구 중 하나다. 동작 방식이나 성능 상 내가 선호하는 방식은 아니지만, 생산성과 가독성 면에서는 예외 처리와 비교할 만한 다른 방법은 없을것이다.
책에서는 분명 오류 처리라고 이야기해놓고, 사실상 예외 처리만 이야기하고 있다. Java만을 기준으로 하기엔 Error에 대한 언급이 없고, 문맥 상의 오류 처리를 언급하려면 트랜잭션 롤백 등에 대한 이야기가 있어야 한다고 생각한다.
null의 존재에 대한 고찰
예외에 대한 이야기 중 null
관련된 이야기가 이렇게 중점적으로 다뤄질 것은 예상하지 못했다. Swift나 Kotlin같은 최근의 언어는 null
문제를 최소화하기 위해 몇가지 기호로 null
검사를 생략하는 것이 주요 특징으로 알고 있다. (자바의 경우 @NotNull
과 @Nullable
어노테이션으로 비슷한 효과를 기대할 수 있다.)
null
문제의 본질은 역할 상 0
이란 상태 값이 존재할 때, 0
은 아니지만 0
의 특성을 가지는 값을 표현하고자 한 것이라고 생각한다. 내 생각에는 초기 전산학에서는 null
이 예외 처리를 위한 기법 중 하나였는데, 이 null
이 존재하는 상태로 예외 처리 기법이 유행하기 시작하면서 불필요한 코드 중복이 일어나는 것 같다. (자바의 경우 Primitive data type과 Object type이 공존하는 것도 이렇게 과도기적인 언어에서 생긴 단점이라 생각한다.)
그리고 null
을 반환하지도 말고 입력도 하지 말라고 하는데, 책에서 주장하는 견고한 코드를 위해선 방어적 프로그래밍으로 모두 처리해주는 것이 더 좋은 접근법이 아닐까 생각한다.