본문 바로가기

Book Review/Clean Code

클린코드 7장_오류 처리

  • 깨끗한 코드와 오류 처리는 확실히 연관성이 존재한다.
  • 상당수 코드 기반은 전적으로 오류 처리 코드에 좌우된다. 좌우된다는건 여기저기 흩어진 오류 처리 코드 때문에 실제 코드가 하는 일을 거의 불가능 하다는 의미이다.
  • 오류 처리 코드는 중요하지만, 오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵다.

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

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