클린코드 7장 - 오류 처리

7장

깨끗한 코드와 오류 처리는 확실히 연관성이 있다. 상당수 코드 기반은 전적으로 오류 처리 코드에 좌우된다. 이 말은 지저분한 오류 처리 코드 때문에 실제 코드가 무엇을 하는지 파악하기 어려워지기 때문이다. 이 장은 오류 처리하는 기법과 고려 사항 몇 가지를 소개한다.

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

public class DeviceController {
        ...
        public void sendShutDown() {
            DeviceHandle handle = getHandle(DEV1):
            if(handle != DeviceHandle.INVALID) {
                ...
                if(record.getStatus() != DEVICE_SUSPENDED) {
                    ...
                }
                else {
                    logger.log(...);
                }
            }
            else {
                logger.log(...);
            }
        }
    }

위와 같이 오류 코드를 사용하면 호출자의 코드가 복잡해진다. 함수를 호출한 즉시 오류를 확인해야하기 때문이다. 그리고 이 단계는 잊어버리기 쉽다. 그러므로 아래와 같이 예외 처리를 사용하는 편이 낫다.

 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) {
            throws new DeviceShutDownError("Invalid handle for: "  + id.toString());
        }
    }
    ...

위 코드로 개선함으로써 코드는 굉장히 깔끔해 졌다. 뒤섞였던 디바이스를 종료하는 알고리즘과 오류를 처리하는 알고리즘이 분리되었다. 이로써 각 개념을 독립적으로 살펴볼 수 있게 되었다.

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

try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 그러므로 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 낫다.

  • try 블록에서 무슨일이 생기든 호출자가 기대하는 상태를 정의하기 쉬워진다.

먼저 다음과 같이 파일이 없으면 예외를 던지는지 확인하는 테스트가 있다.

@Test(expected = StorageException.class)
public void retireveSectionShouldthrownOnInvalidFileName() {
	sectionStore.retrieveSection("invalid - file");
}

아래와 같이 retrieveSection() 는 처음엔 예외를 발생하지 않을 것이므로 테스트에 통과할 수 있도록 이 함수에 예외가 발생할 수 있는 코드를 작성하면서 try-catch문의 범위를 정의할 수 있다.

public List<RecordedGrip> retrieveSection(String sectionName) {
        try {
            ...
        } catch (FileNotFoundException e) {
            ...
        }
        return new ArrayList<RecordedGrip>();
    }

처음에는 Exception을 catch할 수 있도록 짤 수 있지만 리팩토링 과정에서 예외 유형을 FileNotFoundException으로 좁힐 수 있다.
위 과정은 TDD를 이용한 것이다. 자연스럽게 try블록의 트랜잭션 범위 부터 구현하게 되므로 범위 내에서 트랙잭션 본질을 유지하기 쉬워진다.

미확인 예외를 사용하라

확인된 예외는 과거 멋진 아이디어라고 생각했고, 실제로도 몇가지 장점을 제공하지만 현재 시점에서는 그다지 필요하지는 않다고한다. 많은 언어에서도 확인된 예외를 사용하지 않고 있다.

확인된 예외는 OCP를 위반한다.

확인된 예외를 던졌는데 catch 블록이 세 단계 위에 있다면 그 사이 메서드 모두가 선언부에 해당 예외를 정의해야 한다.
-> 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야한다.

최상위 함수에서 하위 함수를 호출하는 구조에서 최하위 함수가 새로운 예외를 throw한다면.
최상위 함수와 최하위 함수 사이의 모든 함수는 try-catch문을 작성하거나. 선언문에 throws절을 추가해야 된다는 말이다.

  • 즉 상위 함수들에게 변경이 일어난 것이다.
  • 하위 함수가 던지는 예외를 알아야 하므로 캡슐화가 깨진다.

예외에 의미를 제공하라

자바는 모든 예외에 호출 스택을 제공하지만 그것만으로는 부족하다. 오류 메시지에 정보를 담아 예외와 함께 던져야한다.

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

오류는 디바이스 실패, 네트워크 실패, 프로그래밍 오류 등으로 분류할 수 있지만. 애플리케이션에서 오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야한다.

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

위 코드는 중복이 일어난다. 하지만 오류를 처리하는 방식은 일정하다.
1. 오류를 기록하고
2. 프로그램을 계속 수행해도 좋은지 확인한다.

위 코드는 호출하는 라이브러리 API를 감싸는 방식으로 개선할 수 있다.

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 클래스 처럼 외부 API를 감싸는 Wrapper 클래스를 만들어 사용하는 것이 매우 유용하고 최선이라고 말한다.

  • 의존성이 크게 줄어든다.
  • 다른 라이브러릴를 갈아타도 비용이 적다.
  • Wrapper 클래스를 외부 API를 호출하는 대신 테스트 코드를 넣어 주는 방법으로 테스트하기도 쉽다.
  • 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다.

정상 흐름을 정의하라

이제는 코드 대부분이 깨끗하고 간결한 알고리즘으로 보이기 시작하지만 오류 감지가 뒷전이게 된다. 오류를 감지하고 중단된 계산을 처리하지만 때로는 중단이 적합하지 않은 경우도 있다.

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

위 코드는

  • 식비를 비용으로 청구하면 직원이 청구한 식비를 총계에 더한다.
  • 식비를 비용으로 청구하지 않았다면 일일 기본 식비를 총계에 더한다.

예외 처리가 논리따라감에 있어 장애물이 된다. 예외처리보다는 언제나 MealExpense를 반환하도록 수정하고 기본값으로 청구한 식비가 없다면 일일 기본 식비를 반환하는 MealExpense를 반환하도록 만들면 된다.

이를 "특수 사례 패턴"이라고 부른다. 그렇다면 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다.

null을 반환하지 마라

null을 반환하는 코드는 일거리를 늘리고 호출자에게 문제를 떠넘기는 것이다.
null 확인을 빼먹는다면 애플리케이션의 흐름에 치명적 문제가 발생할 수 있다.
null 확인을 빼먹었다는 것은 쉽게 식별할 수 없다.
상위 어디선가 NullPointerException을 처리 했는지 아닌지 알 수 없다.

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

위 코드에서 getEmployees() 함수가 null을 반환하지 않는다면 null 체크도 불필요하다
이럴땐 그냥 getEmployees() 함수를 Collection.EmptyList()를 반환하도록 변경하면 기존 동작과 같으면서 null 체크를 할 필요가 없어진다.

null을 전달하지 마라

메서드가 null을 반환하는 것도 나쁘지만 null을 전달하는 것은 더 나쁘다.

두 지점 사이의 거리를 계산하는 xProjection(Point p1, Point p2) 함수가 있다고 했을 때
인자에 null이 전달되면 문제가 발생할 수도 있다.

이럴땐 xProjection() 함수에서 null 체크를 해서 예외를 던지도록 수정해야한다.

또는 아래와 같이 assert를 사용할 수도 있다.


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;
}

대다수 프로그래밍 언어는 메소드로 null이 넘어오는 것을 제어하지 못한다. 그러므로 null을 넘기지 못하도록 금지하는 정책이 합리적이다.
이런 정책을 따른다면 부주의한 실수를 저지를 확률이 줄어들 것이다.

좋은 웹페이지 즐겨찾기