넘치게 채우기

7장. 오류 처리 본문

개발/Clean Code

7장. 오류 처리

riveroverflow 2023. 8. 5. 16:16
728x90
반응형

오류 처리는 프로그램에 반드시 필요한 요소입니다.

 

상당수의 코드 기반은 전적으로 오류 처리 코드에 좌우됩니다.

여기서 좌우된다는 표현은 코드 기반이 오류만 처리한다는 의미가 아니라,

여기저기 흩어진 오류 처리 코드 때문에 실제 코드가 하는 일을 파악하기가 거의 불가능하다는 뜻입니다.

 

오류 처리로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드가 불리기 어렵습니다.

이 장에서는 오류를 처리하는 기법과 고려 사항 몇 가지를 소개합니다.

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

얼마 전까지만 해도 예외를 지원하지 않는 프로그래밍 언어가 많았습니다.

public class DeviceController {
	...
	public void sendShoutDown() {
		DeviceHandle handle = getHandle(DEV1);
		//디바이스 상태를 점검한다.
		if(handle != DeviceHandle.INVALID) {
			//레코드 필드에 디바이스 상태를 저장한다.
			retrieveDevideRecord(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);
	}
}

	public void tryToShutDown() throws DeviceShutDownError {
		...
	}

예외 처리 코드를 통해서 코드가 깔끔하게 정리됩니다.

 

무엇보다도, 오류 처리와 디바이스 종료의 기능을 분리하여서 각 개념을 독립적으로 볼 수 있게됩니다.

 

 

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

try-catch-finally문에서 try 블록에 들어가는 코드를 실행하면 어느 시점에서든 실행이 중단된 후 catch블록으로 넘어갈 수 있습니다.

 

try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 합니다.

try-catch-finally 문을 시작으로 코드를 짜면 호출자가 기대하는 상태를 정의하기 쉬워집니다.

 

TDD 방식으로 메소드 구현하기

  1. 단위 테스트 만들기
@Test(expected = StorageException.class)
 public void retrieveSectionShouldThrowOnInvalidFileName() {
     sectionStore.retrieveSection("invalid - file");
}
  1. 단위 테스트에 맞춰 코드 구현
public List<RecordedGrip> retrieveSection(String sectionName) {
     // 실제로 구현할 때까지 비어 있는 더미를 반환한다.
     return new ArrayList<RecordedGrip>();
}

예외가 발생하지 않기 때문에 단위 테스트에서 실패합니다.

  1. 파일 접근을 시도하도록 구현
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>();
}

테스트가 성공합니다.

  1. catch유형에서 예외 유형을 좁혀서 실제로 FileInputStream생성자가 던지는 FileNotFoundException을 잡습니다.
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>();
 }

 

미확인(unchecked)예외를 사용하라

checked 예외는 컴파일 단계에서 반드시 처리해야 하는 예외입니다.

(IOException, SQLException 등)

 

미확인 예외란, 런타임에서 확인되며, 명시적인 처리를 강제하지는 않는 예외입니다.

체크 예외는 API를 사용하면서 발생할 수 있는 예측 가능한 오류 상황을 나타내며, 'try-catch' 블록을 사용하여 처리해야 합니다. 반면에 체크되지 않은 예외는 프로그래밍 로직의 오류를 나타내며, 예외 처리를 요구하지 않습니다. 이들은 런타임에 발생하며, NullPointerException이나 ArrayIndexOutOfBoundsException과 같은 경우가 포함됩니다[1].

체크되지 않은 예외를 사용하는 원칙은 다음과 같습니다:

  1. 프로그래밍 오류를 나타내는 예외: 체크되지 않은 예외는 프로그래밍 오류를 나타내므로, 이러한 예외가 발생하는 코드를 수정하여 예외를 방지하는 것이 가장 좋습니다.
  1. 예외 처리를 강제하지 않음: 체크되지 않은 예외는 컴파일러에 의해 확인되지 않으므로, 'try-catch' 블록을 사용하여 처리하거나 'throws' 절을 함수 시그니처에 추가할 필요가 없습니다. 이로 인해 코드는 더 간결해지고, 핵심 비즈니스 로직에 집중할 수 있습니다[1].
  1. 예외 처리에 대한 선택적 접근: 체크되지 않은 예외는 처리를 필요로 하지 않지만, 필요에 따라 이들을 처리할 수도 있습니다. 예를 들어, NullPointerException과 같은 예외가 발생할 경우, 이를 적절히 처리하거나, 최소한 로깅하여 디버깅이 가능하게 하는 것이 좋습니다[2].
  1. 유지 관리성과 가독성 향상: 체크 예외는 예외 처리를 강제하므로, 비즈니스 로직을 복잡하게 만들 수 있습니다. 반면에, 체크되지 않은 예외를 사용하면 코드의 가독성과 유지 관리성이 향상됩니다[1].

이런 원칙을 따르면 체크되지 않은 예외의 활용을 통해 코드를 더욱 클린하게 유지하고 프로그래밍 오류를 방지하는 데 도움이 됩니다.

 

 

예외에 의미를 제공하라

오류가 발생한 원인과 위치를 찾기 쉽도록 호출 스택만으로는 부족한 정보를 덧붙여야 합니다.

실패한 연산 이름과 실패 유형 등의 정보 등을 언급할 수 있습니다

 

호출자를 고려해 예외 클래스를 제공하라

오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법 이어야 합니다.

 

아래는 오류를 형편없이 분류한 사례입니다. 외부 라이브러리를 호출하는 try-catch-finally문을 포함한 코드로, 외부 라이브러리가 던질 예외를 모두 잡아냅니다.

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", e);
} finally {
	...
}

 

외부 라이브러리를 감싸서 한 가지 예외 유형을 반환하게 하여 단순화가 가능합니다.

ACMEPort port = new ACMEPort(12);

try {
	port.open();
} catch (PortDeviceFailure e) {
	reportPortError(e);
	logger.log(e.getMessage(), e);
} finally {
	...
}

 

외부 API를 감싸면 아래와 같은 장점이 있습니다

  1. 예외 처리가 간결해집니다.
  1. 외부 라이브러리와 프로그램 사이의 의존성이 크게 줄어듭니다.
  1. 프로그램 테스트가 쉬워집니다.
  1. 외부 API 설계 방식에[ 의존하지 않아도 됩니다.

 

정상 흐름을 정의하라

때로는 예외 처리가 필요 하지 않을 수 있습니다.

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

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

직원이 청구한 식비를 청구에 총계에 더하는 코드인데, 청구하지 않았다면 기본식비를 더하는 식입니다.

이러한 경우는 오히려 예외 처리 코드보다 아래의 방식이 나을 수도 있습니다.

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

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

이를 특수 사례 패턴(Special Case Pattern)이라고 부릅니다. 클래스를 만들거나 객체를 조작해 특수 사례를 처리하는 방식입니다.

이런 경우는 예외처리를 할 필요가 없습니다.

 

null을 반환하지 마라

null을 반환시키는 습관은 좋지 않습니다.

호출자에게 null을 체크해야할 의무를 주고,

널포인터 예외의 발생 위험이 있고,

널 확인이 너무 많아집니다.

 

차라리 예외를 던지거나 특수 사례를 반환시키는게 좋습니다.

 

null을 전달하지 마라

null을 반환하는것도 나쁘지만, 전달하는 것은 더 나쁩니다.

정상적인 인수로 Null을 기대하는 API가 아니라면, 메서드로 Null을 전달하는 코드는 최대한 피합니다.

 

마무리

깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 합니다.

 

오류 처리를 프로그램 논리와 분리하면 독립적인 추론이 가능해지며 코드 유지보수성도 크게 높아집니다.

 

728x90
반응형

'개발 > Clean Code' 카테고리의 다른 글

9장. 단위 테스트  (0) 2023.08.08
8장. 경계  (0) 2023.08.07
6장. 객체와 자료 구조  (0) 2023.08.04
5장. 형식 맞추기  (0) 2023.08.03
4장. 주석  (0) 2023.08.01