- 깨끗한 코드와 오류 처리는 확실히 연관성이 존재한다.
- 상당수 코드 기반은 전적으로 오류 처리 코드에 좌우된다. 좌우된다는건 여기저기 흩어진 오류 처리 코드 때문에 실제 코드가 하는 일을 거의 불가능 하다는 의미이다.
- 오류 처리 코드는 중요하지만, 오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵다.
오류 코드보다 예외를 사용하라
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());
}
}
...
}
- 위와 같은 방법을 사용하면 호출자 코드가 복잡해진다. 함수를 호출한 즉시 오류를 확인해야 하기 떄문이다.
- 오류가 발생하면 예외를 던지는 편이 낫다. 그러면 호출자 코드가 더 깔끔해진다.
public class DeviceController {
...
public void sendShutDown() {
try {
tryToShutDown();
} catch (DeviceShutDownError e) {
logger.log(e);
}
}
private void tryToShutDown() throws DeviceShutDownError {
DeviceHandle handle = getHnadle(DEV1);
DeviceRecord record = retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
vloseDevice(handle);
}
private DeviceHandle getHandle(DeviceID id) {
...
throw new DeviceShutDownError("Invalid handle for: " + id.toString());
}
...
}
- 위와 같이 예외를 사용하면, 코드가 확실히 깨끗해지고 코드 품질도 나아진다. 이제는 각 개념을 독립적으로 살펴보고 이해할 수 있다.
Try-Catch-Finally 문부터 작성하라
- 어떤 면에서 try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다.
- 그러므로 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 낫다.
파일을 열어 직렬화된 객체 몇 개를 읽어들이는 코드이다.
다음은 파일이 없으면 예외를 던지는지 알아보는 단위 테스트이다.
@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
sectionStore.retrieveSection("invalid - file");
}
단위 테스트에 맞춰 다음 코드를 구현했다
public List<RecordedGrip> retrieveSection(String sectionName) {
// 실제로 구현할 때까지 비어 있는 더미를 반환한다.
return new ArrayList<RecordedGrip>();
}
하지만 예외를 던지지 않으므로 단위 테스트는 실패한다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
fileInputStream stream = new FileInpustStream(sectionName)
} catch (Exception e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}
코드가 예외를 던지므로 테스트가 성공하고, 이 시점에서 리팩터링이 가능하다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
fileInputStream stream = new FileInpustStream(sectionName)
stream.close();
} catch (FileNotFoundException e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}
- 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하는 방법을 권장한다.
미확인 예외를 사용하라
- 지금은 안정적인 소프트웨어를 제작하는 요소로 확인된 예외가 반드시 필요하지 않다는 사실이 분명해졌다.
- 확인된 예외를 던진다면 함수는 선언부에 throws 절을 추가해야 하고, 그러면 변경한 함수를 호출하는 함수 모두가 catch 블록에서 새로운 예외를 처리하거나, 선언부에 throw 절을 추개햐아 한다는 말이다.
- 결과적으로 throws 경로에 위치하는 모든 함수가 최하위 함수에서 던지는 예외를 알아야 하므로 캡슐화가 깨진다.
예외에 의미를 제공하라
- 예외를 던질 때는 전후 상황을 충분히 덧붙인다. 그러면 오류가 발생한 원인과 위치를 찾기가 쉬워진다.
- 오류 메시지에 정보를 담아 예외와 함께 던진다. 실패한 연산 이름과 실패 유형도 언급한다.
- 로깅 기능을 사용한다면 catch 블록에서 오류를 기록하도록 충분한 정보를 넘겨준다.
호출자를 고려해 예외 클래스를 정의하라
- 오류를 분류한 방법은 오류가 발생한 위치, 오류가 발생한 컴포넌트, 유형으로 분류할 수 있다. ex) 디바이스 실패, 네트워크 실패, 프로그래밍 오류
- 하지만 애플리케이션에서 오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야 한다.
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 {
...
}
- 위 코드는 중복이 심하다.
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를 설계한 방식에 발목을 잡히지 않는다.
정상 흐름을 정의하라
- 앞 절에서 출고한 지침을 충실히 따른다면, 비즈니스 논리와 오류 처리가 잘 분리된 코드가 나온다.
- 하지만 그러다 보면 오류 감지가 프로그램 언저리로 밀려난다.
- 외부 API를 감싸 독단적인 예외를 던지고, 코드 위에 처리기를 정의해 중단된 계산을 처리하는데 대개는 멋진 방식이지만, 때로는 중단이 적합하지 않은 때도 있다.
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expense.getTotal();
} catch (MealExpensesNotFound e) {
m_total += getMealPerDiem();
}
- 식비를 비용으로 청구했다면 직원이 청구한 식비에 총계를 더한다. 식비를 비용으로 청구하지 않았다면 일일 기본 식비를 총계에 더한다.
- 예외가 논리를 따라가가기 어렵게 만들어서 아래와 같은 코드로 간결하게 처리할 수 있다
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
- 위처럼 간결한 코드가 가능할까? ExpenseReportDAO를 고쳐 언제나 MealExpense 객체를 반환하게 하면 된다. 청구한 식비가 없다면 일일 기본 식비를 반환하는 MealExpense 객체를 반환한다.
public class PerDiemMealExpenses implements MealExpenses {
public int getTotal() {
// 기본값으로 일일 기본 식비를 반환한다.
}
}
- 이를 특수 사례 패턴이라 부른다. 클래스를 만들거나 객체를 조작해 특수 사례를 처리하는 방식이다.
- 그러면 클라이언트 코드가 에외적인 상황을 처리할 필요가 없어진다.
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);
}
}
}
}
- 위 코드는 나쁜 코드이다. null을 반환하는 코드는 일거리를 늘릴 뿐만 아니라, 호출자에게 문제를 떠넘긴다.
- 둘째 행에 null 확인이 빠졌는데, 만약 persistentStore가 null이면 NP를 호출한다.
- 위 코드는 null 확인이 누락된 문제라 말하기 쉽지만, 실상은 null 확인이 너무 많아서 문제다.
- 메서드에서 null을 반환하고 픈 유혹이 든다면, 그 대신 예외를 던지거나 특수 사례 객체를 반환한다.
- 많은 경우에 특수 사례 객체가 손쉬운 해결책이다.
List<Employee> employees = getEmployees();
if (employees != null) {
for(Employee e: employees) {
totalPay += e.getPay();
}
}
- getEmployees는 null을 반환하지만, 반드시 null을 반환할 필요가 있을까라는 의구심이 든다.
- getEmployees를 변경해 빈 리스트를 반환한다면 코드가 훨씬 깔끔해진다.
List<Employee> employees = getEmployees();
for(Employee e: employees) {
totalPay += e.getPay();
}
- 다행스럽게 자바에는 Collections.emptyList()가 있어 미리 정의된 읽기 전용 리스트를 반환한다
public List<Employee> getEmployees() {
if (.. 직원이 없다면 ..)
return Collections.emptyList();
}
- 이렇게 코드를 변경하면 코드도 깔끔해지고 NullPointerException이 발생할 가능성도 줄어든다.
null을 전달하지 마라
- 메서드에서 null을 반환하는 방식도 나쁘지만, null을 전달하는 방식은 더 나쁘다
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
return (p2.x - p1.x) * 1.5
}
...
}
- 누군가 인수로 null을 전달하면 어떤 일이 일어날까?
calculator.xProjection(null, new Point(12, 13)); -> NP 발생
- 다음과 같이 새로운 예외 유형을 만들어 던지는 방법도 있다.
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;
}
}
- 위 코드가 원래 코드보다는 낫지만, 위 코드는 InvalidArgumentException을 잡아내는 처리기가 필요하다.
- 다음은 또 다른 대안이다. assert 문을 사용하는 방법도 있다.
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";
}
}
- 문서화가 잘 되어 코드 읽기는 편하지만, 문제를 해결하지는 못한다. 누군가 null을 전달하면 여전히 실행 오류가 발생한다.
- 대다수 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절하게 처리하는 방법이 없다. 그래서 null을 넘기지 못하도록 금지하는 정책이 합리적이고, 인수로 null이 넘어오면 코드에 문제가 있다는 말이 된다.
결론
- 깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다.
- 이 둘은 상충하는 목표가 아니며, 오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려하면 튼튼하고 꺠끗한 코드를 작성할 수 있다.
참고 자료
- Clean Code 애자일 소프트웨어 장인 정신
'Book Review > Clean Code' 카테고리의 다른 글
클린코드 6장_객체와 자료구조 (0) | 2022.09.09 |
---|---|
클린코드 5장_형식 맞추기 (0) | 2022.09.06 |
클린코드 4장_주석 (0) | 2022.08.30 |
클린 코드 3장 정리 #1 (0) | 2022.07.30 |
클린 코드 2장 정리 #2 (0) | 2022.07.29 |