본문 바로가기
Architecture & Engineering/Clean Software

[Clean Code] 4. 오류 처리

by 신숭이 2023. 4. 2.

[Clean Code] 오류 처리

 

오류 처리는 매우 중요한데, 이것으로 인해 코드 논리의 이해가 방해받으면 안 된다.
따라서 깔끔하게 오류처리를 하는 기법을 알아보는 [Chapter 7. 오류처리]이다.

 

[Chapter 7 오류 처리 요약]

  1. 오류 코드 보다는 예외를 사용하라 : 오류 코드는 Caller의 논리가 잘 안 보이게 만든다.
  2. try-catch 문 부터 사용하여 트랜잭션 처럼 활용하라 : try 문의 범위 정의
  3. 미확인 예외를 사용하라 : 추상화된 예외 클래스를 이용해 OCP를 준수하라
  4. 예외에 의미를 제공 : 전후 사정을 설명하는 Message
  5. 호출자를 고려한 예외 클래스를 정의 하라 : Wrapper Class를 이용할 것. 
  6. 정상흐름을 정의하라 : 특수 사례 패턴(Special Case Pattern)을 이용
  7. Null을 반환하지 마라 : 호출자에게 책임을 전가하지 말 것. 특수 사례 객체 이용.
  8. Null을 전달하지 마라 

 

 

1. 오류 코드보다는 예외를 사용할 것

 

오류 코드는 호출자 코드를 복잡하게 만든다. 함수는 오류 코드같은 Enum Type을 반환하지 말고 예외를 던지는(Throw) 게 낫다.
오류 코드는 논리와 오류 처리 코드를 뒤섞이게 만든다. 따라서 쓰지말자.

아래는 오류코드를 사용한 Bad Case

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());
		}
	}
...
}


예외를 던져 호출자인 sendShutDown() 메서드를 간결하게 바꾼 Case

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());
		...
	}
...
}

 

위와 같이 작성하면, 비즈니스로직과 오류처리 로직이 깔끔하게 try-catch로 분리된다.

 

 

2. Try-Catch-Finally 문부터 작성하라

 

Try-Catch문은 트랜잭션과 같다. 
Try 블록에서 무슨 일이 발생하건, Catch 블록에선 프로그램을 일관성 있게 유지해야 한다.

그러므로 예외가 발생할 것으로 예상되는 코드를 작성할 때는 먼저 Try-Catch문을 작성하여 Try 블록과 Catch 블록의 영역을 정의한다.
테스트 코드를 작성하여 강제 예외를 발생시키고 Exception 타입(추상화 레벨이 높은)으로 예외 케이스와 논리 영역을 나누고 이후에 명확한 Exception Type ( ex) FileNotFoundException..)으로 리팩터링 한다.

 

 

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

 

확인된 예외는 OCP를 완전히 위반하며 최초 예외를 발생시킨 함수에 이하 함수들이 예외 케이스에 대해 강한 의존성이 생긴다.
최상위 함수에서 확인된 예외를 변경하면 이하 모든 함수들에서 수정이 발생한다. 따라서 미확인 예외를 사용하자.
미확인 예외라는 것은 추상화된 예외 (Exception)을 사용하라는 것으로 보인다. 

확인된 예외가 유용할 때도 있지만, 일반적인 프로그램은 의존성이라는 비용이 이익보다 크다.

 

 

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

 

전후 상황을 충분히 전달할 메시지를 담아야 한다. Stack Trace로는 분명 부족하다.

 

 

5. 호출자를 고려해 예외 클래스를 정의하라
(Wrapper Class를 사용하라)

 

오류를 분류하는 방법은 다양하지만, 가장 중요한 관심사는 [오류를 잡아내는 방법]이 되어야 한다. 
즉 호출자가 오류를 편하게 잡아낼 수 있도록 작성해야 한다는 것. 예시를 통해 알아보자

다음은 형편없게 분류된 외부 라이브러리의 오류를 모두 잡아내어 처리하는 코드이다.

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 {
…
}

위의 예시에서 알 수 있는 것은 대다수의 상황에서 오류를 처리하는 방식은 비교적 일정하다.

  1. 오류를 기록한다
  2. 프로그램을 계속 수행해도 좋은 지 확인한다.

따라서 다음과 같이 LocalPort라는 Wrapper Class를 두어 Caller에서 오류 체크 로직을 줄일 수 있다. 
Wrapper Class의 장점은 이뿐 아니라 외부 라이브러리에 대한 의존성을 크게 줄이고, 테스트하기 쉬워지기에 적극 활용하자.

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 {
…
}

 

이 정도까지만 해도, 비즈니스 로직과 오류 처리 로직은 대부분 분리되어 코드가 한 층 더 깔끔해진다.

 

 

6. 정상흐름을 정의하라

 

늘 예외를 던져 중단시키는 게 적절하지 않을 때가 있다.
이럴 때는 예외 상황에도 적절히 작동하는 로직(정상 흐름)을 정의해야 하는데 다음과 같은 방식은 정상 흐름을 정의하고는 있지만 예외 처리로직이 논리의 이해를 어렵게 만든다. 이럴 때는 적절한 캡슐화와 Default 값을 이용해 Caller의 비즈니스 로직을 깔끔하게 만들자.

Bad Case

try {
	MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
	m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
	m_total += getMealPerDiem();
}

 

다음과 같이 캡슐화하여 Caller의 로직을 깔끔하게 변경

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



...

public class PerDiemMealExpenses implements MealExpenses {
	public int getTotal() {
		// return the per diem default
	}
}

 

이런 방식을 특수 사례 패턴(Special Case Pattern)이라 한다. 이 패턴은 클라이언트 코드가 예외적인 상황을 처리할 필요가 없도록 만든다.

 

 

7. Null을 반환하지 마라

 

Null을 반환하는 행위는 호출자에게 책임을 전가하는 행위이다. 이로 인해 어디선가 개발자의 실수로 NPE가 발생하곤 한다. 따라서 특수 사례 객체를 반환하는 식으로 하자.

다음은 Employee가 없을 때, Null 이 아니라 emptyList를 반환하는 예시

public List<Employee> getEmployees() {
	if( .. there are no employees .. )
		return Collections.emptyList();
}

 

 

 

8. Null을 전달하지 마라

 

호출자가 실수로 Null 을 파라미터로 전달했을 때 이를 처리하는 여러 가지 방법은 존재한다.

  1. 얼리 스테이지에서 null 체크를 하는 조건문을 두어 처리
  2. Assert로 처리

하지만 저자는 둘 다 깔끔하지 못한 방법이라고 한다. 애초에 null 전달을 금지하는 정책이 맞다고 한다.
최신 언어에서는 Nullable 객체가 도입되어 가는 추세라 Null이 불가능한 객체로 파라미터를 정의하면 되겠다.

 

 

 

댓글