007. (Clean Code) 7. 오류 처리 - Error Handling

7. 오류 처리 - Error Handling

오류 처리는 프로그램에 반드시 필요한 요소 중 하나이다.

사용자의 입력이 언제 실패할지, 디바이스가 언제 어떻게 될지 모르기때문이다.

다시 말해, 뭔가 잘못될 가능성은 항상 존재하기때문에 이에 대한 대응 방안이 필요하다.

상당수의 코드는 오류 처리 코드에 의해 좌우된다.

여기저기 흩어진 오류 처리 코드에 의해 실제 코드가 어떤 동작을하는지 파악하는 데 어려움이 따르기때문이다.

따라서 깨끗한 코드를 작성하기 위해서는 오류 처리도 중요한 요소 중 하나이다.

이번 포스팅에서는 깨끗한 코드를 작성하기 위해 우아하게 오류를 처리하는 몇 가지 기법에 대해서 알아보자.

7.1. 오류 코드보다 예외를 사용하라

예외를 지원하지 않는 프로그래밍 언어는 어떻게 예외를 처리할 수 있을까?

이 경우 오류를 의미하는 플래그를 설정하거나 호출자에게 약속된 오류 코드를 반환하는 방법이 전부였다.

아래 예제 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class DeviceController { 
// ...
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// 디바이스 상태를 점검한다.
if (handle != DeviceHandle.INVALID) {
// 레코드 필드에 디바이스 상태를 저장한다.
retrieveDeviceRecord(handle);
// 디바이스가 일시정지 상태가 아니라면 종료한다.
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());
}
}
// ...
}

예외가 없으면 위의 예제처럼 호출자 코드가 계속해서 복잡해질 수 밖에 없다.

이는 함수를 호출한 즉시 오류를 확인해야하기 때문이며, 누락하기 쉬운 영역이기도 하다.

예외를 지원한다면 오류 발생시 예외를 던지는 것이 낫다.

아래는 예외를 적용한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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());
// ...
}
// ...
}

호출자의 코드가 훨씬 깔끔해졌음을 알 수 있다.

이는 디바이스를 종료하는 알고리즘과 오류를 처리하는 알고리즘을 분리했기때문이다.

이처럼 뒤섞인 개념을 있을 경우 각각 분리해서 독립적으로 살필 수 있게 처리하는 것이 권장된다.

7.2. try-catch-finally 문부터 작성하라

예외를 처리할때 프로그램 내부에 범위를 작성할 수 있다.

try-catch-finally 문의 try 블록내에서 발생하는 모든 예외는 catch 블록으로 던질 수 있기 때문이다.

이때 try 블록에서 어떠한 예외가 발생하던간에 catch 블록은 프로그램의 상태를 일관성있게 유지할 수 있어야 한다.

아래 예제를 살펴보자.

이 예제는 파일이 없으면 예외를 던지는 지 알아보는 단위 테스트 코드이다.

1
2
3
4
@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
sectionStore.retrieveSection("invalid - file");
}

위 단위 테스트에 맞춰 아래와 같이 코드를 구현한다.

1
2
3
4
public List<RecordedGrip> retrieveSection(String sectionName) { 
// 실제로 구현할 때까지 비어있는 더미를 반환한다.
return new ArrayList<RecordedGrip>();
}

위 코드는 예외를 던지지 않으므로 단위 테스트는 실패한다.

이제 예외를 던지도록 코드를 아래와 같이 수정해보자.

1
2
3
4
5
6
7
8
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 블록에서 Exception이 아닌 FileNotFoundException을 잡도록 리팩토링한다.

1
2
3
4
5
6
7
8
9
public List<RecordedGrip> retrieveSection(String sectionName) { 
try {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
} catch (FileNotFoundException e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}

try-catch문을 이용해 범위를 지정했으므로 TDD에 의거하여 강제로 예외를 일으키는 테스트 케이스를 작성한 뒤,

이 테스트를 통과하도록 코드를 작성하는 방식으로 진행하는 것이 권장된다.

7.3. 미확인 예외(Unchecked Exception)를 사용하라

그동안 JVM 생태계의 개발자들은 확인된(checked) 예외의 장단점에 대해 논쟁을 벌여왔다.

초창기엔 Checked Exception이 우아한 방식이라고 인식되었지만, 최근엔 반드시 Checked Exception이 필요하다고 보지 않는다.

기본적으로 Checked Exception은 개방-폐쇄 원칙(OCP : Open-Closed Principle) 을 위반한다.

참고 016. (Objects) 9. 유연한 설계

만약 메서드에서 Checked Exception을 던졌을 때, catch 블록이 세 단계 위에 있다면 그 사이에 속한 메서드가 전부 해당 예외를 정의해야한다.

대규모 시스템의 최상위 함수가 하위의 함수들을 순차적으로 호출하는 경우, 최하위 함수가 예외를 추가할 때마다

모든 단계의 함수가 throw를 추가해야하는 연쇄 수정이 발생하여 캡슐화를 깨버린다.

7.4. 예외에 의미를 제공하라

예외를 던질 때는 전후 상황을 충분히 덧붙여서 오류가 발생한 원인과 위치를 찾기 쉽도록 한다.

java의 예외는 기본적으로 콜스택을 제공하지만, 콜스택으로는 충분하지않는 경우가 많다.

따라서 오류 메시지에 실패한 연산 이름과 실패 유형 등 정보를 담아 예외와 함께 던질 수 있도록 한다.

가능하다면 catch 블록에서 발생한 오류를 로깅하도록 하는 것도 좋다.

7.5. 호출자를 고려해 예외 클래스를 정의하라

오류를 분류하는 방법은 무수히 많다.

오류가 발생한 컴포넌트의 위치 혹은 유형으로도 분류도 가능하다.

하지만 오류를 정의할때 가장 중요한 것은 오류를 잡아내는 방법이다.

아래는 오류를 형펀없이 분류한 사례이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 {
// ...
}

외부 라이브러리를 호출하는 try-catch-finally 문으로 외부 라이브러리가 던질 수 있는 예외를 모두 잡으려고 3개의 catch 블록이 쓰였다.

대다수 상황에서 오류를 처리하는 방식은 오류를 기록하고, 프로그램을 계속 수행해도 좋은지 확인하는 방식이다.

위 예제는 예외의 종류와 상관없이 거의 동일하므로 아래와 같이 리팩토링하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 {
// ...
}

LocalPort 클래스를 이용해 ACMEPort 클래스를 랩핑하였고, 이를 통해 하나의 예외만 받아 처리할 수 있게 되었다.

이러한 감싸기 기법을 사용하면 특정 업체가 API를 설계한 방식과 상관없이 사용하기 편한대로 API를 정의할 수 있다.

참고 (Working Effectively with Legacy Code) 015. My Application Is All API Calls

7.6. 정상 흐름을 정의하라

깨끗한 코드는 비즈니스 로직과 오류 처리가 잘 분리되어 있다.

외부 API를 감싸서 독자적으로 정의한 예외를 던지고 이를 처리하는 방식은 대체로 우아하지만 적합하지 않은 케이스도 존재한다.

예제를 통해 알아보자.

아래는 비용 청구 애플리케이션이 총계를 계산하는 코드이다.

1
2
3
4
5
6
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
m_total += getMealPerDiem();
}

이 코드는 식비를 비용으로 청구했다면 청구한 식비를 총계에 더하고, 식비를 청구하지않았다면 일일 기본 식비를 총계에 더한다.

그런데 예외 자체가 논리를 따라가기 어렵게 만든다.

try문 내부 로직을 좀 더 간결하게 할 수 있을까?

1
2
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID()); 
m_total += expenses.getTotal();

아래와 같이 리팩토링을 통해 좀 더 간결하게 고쳐야 한다.

1
2
3
4
5
public class PerDiemMealExpenses implements MealExpenses { 
public int getTotal() {
// 기본값으로 일일 기본 식비를 반환한다.
}
}

이러한 사례를 특수 사례 패턴이라고 부른다.

7.7. null을 반환하지 마라

개발자가 가장 흔히 저지르는 오류 패턴 중 하나가 null을 반환하는 습관이다.

java를 경험해봤다면 라인마다 null 여부를 체크하는 코드가 가득한 경우를 수없이 보았을 것이다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
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);
}
}
}
}

null을 반환하는 코드는 일거리를 늘릴뿐 아니라 책임을 호출자에게 전가하는 구조를 가지게 되며,

null 여부 체크를 하나라도 누락하는 순간 오류가 발생할 가능성이 생긴다.

위 코드에서 만약 peristentStore가 null이라면 어떨까?

바로 NullPointerException이 발생하게 될것이다.

null 확인 누락된 게 문제일까?

아니다. null 확인이 너무 많아서 문제이다.

반환 객체를 도입하거나, 메서드를 감싸서 예외를 던지도록 처리하는 것이 옳다.

또 다른 사례를 살펴보자.

1
2
3
4
5
6
7
List<Employee> employees = getEmployees(); 

if (employees != null) {
for(Employee e : employees) {
totalPay += e.getPay();
}
}

getEmployees() 메서드는 null을 반환할 수도 있다.

하지만 굳이 null을 반환하지않고 비어있는 list를 반환해주면 null 여부 체크없이도 코드를 간결하게 작성할 수 있을 것이다.

1
2
3
4
5
List<Employee> employees = getEmployees(); 

for(Employee e : employees) {
totalPay += e.getPay();
}

7.8 null을 전달하지 마라

위에서 살펴봤듯 메서드에서 null을 반환하는 것도 문제이지만, 파라미터로 null을 전달하는 것은 더 큰 문제이다.

정상적으로 null을 기대하는 api가 아니라면 null을 전달하는 케이스는 최대한 피하도록 해야 한다.

아래 예제를 보자.

1
2
3
4
5
6
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
return (p2.x – p1.x) * 1.5;
}
// ...
}

파라미터로 null을 넘기는 순간 바로 NullPointerException이 발생할 것이다.

이 경우 어떻게 고치는 것이 좋을까?

1
2
3
4
5
6
7
8
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의 오류 처리를 강제하게 된다.

또 다른 대안으로 assert 키워드를 사용하는 방법이 있다.

1
2
3
4
5
6
7
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;
}
}

만약 조건이 거짓이면 AssertionError 예외가 발생하게 된다.

다양한 방법이 있지만 어느것도 만족스럽진 않다.

결국 제일 좋은 방법은 애초에 null을 넘기지 못하도록 금지하는 것이다.