Skip to content

Latest commit

 

History

History
348 lines (320 loc) · 16.9 KB

220301.md

File metadata and controls

348 lines (320 loc) · 16.9 KB

7. 오류 처리

뭔가 잘못될 가능성은 늘 존재하고, 그것을 바로 잡을 책임은 우리에게 있다.
오류 처리는 중요하지만 오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵다.

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

예전에는 예외를 지원하지 않는 언어가 많아서 오류 플래그를 설정하거나 호출자에게 오류 코드를 반환하는 방법이 전부였다.
하지만 이 방법을 사용하면 함수를 호출한 즉시 오류를 확인해야 하기 때문에 호출자 코드가 복잡해지고 이 단계를 잊어버리기 쉽다.
그러므로 오류가 발생하면 예외를 던지는 편이 논리가 오류 처리 코드와 뒤섞이지 않아 호출자 코드가 더 깔끔해진다.

// 예전 방식
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());
    }
  }
  ...
}
// 예외 처리
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-Finally 문부터 작성하라

try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch문은 프로그램 상태를 일관성 있게 유지해야 한다.
그러므로 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 것이 좋다.
이러면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.
먼저 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 콛를 작성하는 방법이 권장된다.
그러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되어 범위 내에서 트랜잭션 본질을 유지하기 쉬워진다.

미확인 예외를 사용하라

확인된 오류가 치르는 비용에 상응하는 이익을 제공하는지 따져봐야한다.
확인된 예외는 OCP(Open Closed Principle)를 위반한다.
하위 단계에서 코드를 변경하면 상위 단계 메소드 선언부를 전부 고쳐야 한다는 말이다.
throws 경로에 위치하는 모든 함수가 최하위 함수에서 던지는 예외를 알아야 하므로 캡슐화가 깨진다.
아주 중요한 라이브러리를 작성한다면 모든 예외를 잡기 위해 확인된 예외를 사용해야 하지만 일반적으로는 의존성이라는 비용이 이익보다 크다.

예외에 의미를 제공하라

예외를 던질 때 전후 상황, 실패한 연산 이름과 실패 유형도 오류 메세지에 언급하면 오류가 발생한 원인과 위치를 찾기 쉬워진다.

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

오류를 정의할 때 가장 중요한 것은 오류를 잡아내는 방법이다.
아래의 코드는 오류를 형편없이 분류한 사례로 외부 라이브러리가 던질 예외를 모두 잡아낸다.

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

위 경우는 예외에 대응하는 방식이 예외 유형과 무관하게 거의 동일하여 호출하는 라이브러리 API를 감싸면서 예외 유형 하나를 반환하는 것으로 고칠 수 있다.

LocalPort port = new LocalPort(12);
try {
    port.open();
} catch (PortDeviceFailure e) {
    reportError(e);
    logger.log(e.getMessage(), e);
} finally {
    ...
}

여기서 LocalPort 클래스는 단순히 ACMEPort 클래스가 던지는 예외를 잡아 변환하는 감싸기 클래스일 뿐이다.

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

외부 API를 사용할 때는 감싸기 기법이 최선이다.
외부 API를 감싸면 외부 라이브러리와 프로그램 사이에서 의존성이 크게 줄어들어 나중에 다른 라이브러리로 갈아타도 비용이 적다.
감싸기 클래스에서 외부 API를 호출하는 대신 테스트 코드를 넣어주는 방법으로 프로그램을 테스트하기 쉬워진다.
특정 업체가 API를 설계한 방식에 발목 잡히지 않고 프로그램이 사용하기 편리한 API를 정의할 수 있다.
보통 예외 클래스가 하나만 있어도 충분한 경우가 많다.
한 예외는 잡아내고 다른 예외는 무시해도 괜찮은 경우라면 여러 예외 클래스를 사용한다.

정상 흐름을 정의하라

외부 API를 감싸 독자적인 예외를 던지고, 코드 위에 처리기를 정의해 중단된 계산을 처리하는 것은 일반적으로 유용한 방식이지만 때로는 중단이 적합하지 않은 때도 있다.

// 비용 청구 애플리케이션에서 총계를 계산하는 허술한 코드
try{
    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
    m_total += getMealPerDiem();
}

이 코드는 예외가 논리를 따라가기 어렵게 만든 경우이다.

// 특수 상황을 처리할 필요가 없게 만들기
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal

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

ExpenseReportDAO를 고쳐 언제나 MealExpense 객체를 반환한다.
그렇게 청구한 식비가 없다면 일일 기본 식비를 반환하는 MealExpense 객체를 반환하게 만든 것이다.

이를 특수 사례 패턴(Specail Case Pattern)이라 부른다.
클래스를 만들거나 객체를 조작해 특수 사례를 처리하는 방식이다.
클래스나 객체가 예외적인 상황을 캡슐화해서 처리하므로 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다.

null을 반환하지 마라

null을 반환하는 코드는 일거리를 늘릴 뿐만 아니라 호출자에게 문제를 떠넘긴다.
null을 반환하지 말고 예외를 던지거나 특수 사례 객체를 반환하는 방식을 선택한다.

// null을 반환하는 경우
List<Employee> employees = getEmployees();
if (employees != null) {
    for(Employee e : employees) {
        totalPay += e.getPay();
    }
}
// null 대신 빈 리스트를 반환하는 경우
List<Employee> employees = getEmployees();
for(Employee e : employees) {
    totalPay += e.getPay();
}

null을 전달하지 마라

null을 반환하는 것보다 메소드로 null을 전달하는 것이 더 나쁘다.
정상적인 인수로 null을 기대하는 API가 아니라면 메소드로 null을 전달하는 코드는 최대한 피한다.
대다수 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없다.
애초에 null을 넘기지 못하도록 금지하는 것이 합리적이다.

8. 경계

우리는 외부 코드를 사용해야 하는 상황이 오는데 어떤 식으로든 이 외부 코드를 우리 코드에 깔끔하게 통합해야 한다.

외부 코드 사용하기

인터페이스 제공자와 인터페이스 사용자 사이에는 특유의 긴장이 존재한다.
패키지 제공자나 프레임워크 제공자는 적용성을 최대한 넓히려 애쓰지만, 사용자는 자신의 요구에 집중하는 인터페이스를 바란다.
이런 긴장으로 인해 시스템 경계에서 문제가 생길 소지가 많다.

예시
java.utilMap에는 다양한 인터페이스로 수많은 기능을 제공한다.
설계 시 Map에 특정 객체 유형만 저장하려 했지만 Map은 객체 유형을 제한하지 않아 사용자는 마음만 먹으면 어떤 객체 유형도 추가할 수 있다. Sensor 라는 객체를 담는 Map을 만들려면 다음과 같이 Map을 생성한다.

Map sensors = new HashMap();

Sensor 객체가 필요한 코드는 다음과 같이 Sensor 객체를 가져온다.

Sensor s = (Sensor)sensors.get(sensorId);

이와 같은 코드가 여러 차례 나온다.
즉, Map이 반환하는 Object를 올바른 유형으로 변환할 책임은 Map을 사용하는 클라이언트에 있게되고, 코드는 의도도 분명히 드러나지 않는다.
이는 제너릭스를 사용하면 코드 가독성이 크게 높아진다.

Map<String, Sensor> sensors = new HashMap<Sensor>();
...
Sensor s = sensors.get(sensorID);

하지만 이 방법도 Map<String, Sensor>가 사용자에게 필요하지 않은 기능까지 제공한다는 문제는 해결하지 못한다.
프로그램에서 Map<String, Sensor> 인스턴스를 여기저기로 넘긴다면, Map 인터페이스가 변할 경우, 많은 부분이 수정되어야 한다.

public class Sensors {
    private Map sensors = new HashMap();

    public Sensor getById(String id) {
        return (Sensor) sensors.get(id);
    }
    ...
}

이 경우 경계 인터페이스인 Map을 Sensors 안으로 숨겨서 Map 인터페이스가 변해도 나머지 프로그램에 영향을 미치지 않는다.
Sensors 클래스 안에서 객체 유형을 관리하고 변환하기 때문에 제너릭스를 사용하든 하지 않든 문제가 되지 않는다.
Sensors 클래스는 프로그램에 필요한 인터페이스만 제공하여 이해하기 쉽지만 오용하기 어렵게 한다.
Map 클래스를 사용할 때마다 캡슐화하라는 것이 아니라, Map을 여기저기 넘기지 말라는 말이다.
Map과 같은 경계 인터페이스를 이용할 때는 이를 이용하는 클래스나 클래스 계열 밖으로 노출되지 않도록 주의하는 것이 필요하다.
또한 Map 인스턴스를 공개 API의 인수로 넘기거나 반환값으로 사용하지 않는다.

경계 살피고 익히기

외부 패키지를 사용할 때는 우리가 사용할 코드를 테스트하는 것이 좋다.
외부 코드를 익히는 것과 통합하는 것 둘 다 어려운 일이다.
그렇기에 곧바로 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익히는 것이 필요하다. 이를 짐 뉴커크는 학습 테스트라 한다.

log4j 익히기

경계 살피고 익히기에서 이어지는 내용이다.

로깅 기능을 직접 구현하는 대신 아파치의 log4j 패키지를 사용하려 한다고 가정한다.
우선 문서를 자세히 읽기 전에 첫 번째 테스트 케이스를 작성한다.

// 화면에 "hello"를 출력
@Test
public void testLogCreate() {
    Logger logger = Logger.getLogger("MyLogger");
    logger.info("hello");
}

테스트 케이스를 돌리니 Appender라는 것이 필요하다는 오류가 발생하여 문서를 더 읽어보니 ConsoleAppender라는 클래스가 있는 것을 확인해서 ConsolAppender를 생성한 후 다시 돌린다.

@Test
public void testLogCreate() {
    Logger logger = Logger.getLogger("MyLogger");
    ConsoleAppender appender = new ConsoleAppender();
    logger.addAppender(appender);
    logger.info("hello");
}

이번에는 Appender에 출력 스트림이 없다는 것을 발견하고 구글을 검색한 후 다시 시도한다.

@Test
public void testLogCreate() {
    Logger logger = Logger.getLogger("MyLogger");
    ConsoleAppender appender = new ConsoleAppender();
    logger.addAppender(new ConsoleAppender(
        new PatternLayout("%p %t %m%n"),
        ConsoleAppender.SYSTEM_OUT));
    logger.info("hello");
}

이제 "hello"가 들어간 로그 메세지가 콘솔에 찍히지만 ConsoleAppender에게 콘솔에 쓰라고 알려야 하는 것이 이상하다.
ConsoleAppender.SYSTEM_OUT 인수를 제거했더니 문제가 없지만 여전히 콘솔에 "hello"가 찍힌다.
하지만 PatternLayout을 제거하니 다시 출력 스트림이 없다는 오류가 뜬다.
문서를 더 읽어보니 기본 ConsoleAppender 생성자는 '설정되지 않은' 상태라고 한다.
이 상태가 일관성이 부족하다고 느껴진다.
좀 더 구글을 뒤지고, 문서를 읽어보고, 테스트를 돌린 끝에 다음과 같은 코드를 얻고, 그동안 log4j가 돌아가는 방식을 많이 이해했고, 얻은 지식을 간단한 단위 테스트 케이스 몇개로 표현했다.

public class LogTest {
    private Logger logger;
    
    @Before
    public void initialize() {
        logger = Logger.getLogger("logger");
        logger.removeAllAppenders();
        Logger.getRootLogger().removeAllAppenders();
    }
    
    @Test
    public void basicLogger() {
        BasicConfigurator.configure();
        logger.info("basicLogger");
    }
    
    @Test
    public void addAppenderWithStream() {
        logger.addAppender(new ConsoleAppender(
            new PatternLayout("%p %t %m%n"),
            ConsoleAppender.SYSTEM_OUT));
        logger.info("addAppenderWithStream");
    }
    
    @Test
    public void addAppenderWithoutStream() {
        logger.addAppender(new ConsoleAppender(
            new PatternLayout("%p %t %m%n")));
        logger.info("addAppenderWithoutStream");
    }
}

지금까지 간단한 콘솔 로거를 초기화하는 방법을 익혔으니, 모든 지식을 독자적인 로거 클래스로 캡슐화한다.
그러면 나머지 프로그램은 log4j 경계 인터페이스를 몰라도 된다.

학습 테스트는 공짜 이상이다.

학습 테스트에 드는 비용은 없고, 오히려 필요한 지식만 확보하는 손쉬운 방법이다.
학습 테스트는 투자하는 노력보다 얻는 성과가 더 크다.
패키지 새 버전이 나온다면 학습 테스트를 돌려 아직 우리 코드와 호환되는지 알 수 있다.
학습 테스트가 있다면 패키지의 새 버전으로 이전하기 쉬워진다.

아직 존재하지 않는 코드를 사용하기

아직 구현되지 않은 코드가 필요한데, 기능과 인터페이스도 구현이 되지 않은 경우에도 우리는 개발을 해야 할 때가 있다.

예시
저자가 무선통신 시스템에 들어갈 소프트웨어 개발에 참여했다.
이 소프트웨어에는 '송신기'라는 하위 시스템이 있었는데 팀원들이 여기에 대한 지식이 거의 없었다.
'송신기' 팀은 아직 API를 설계하지 않아 구체적인 방법을 알지 못해 저자의 팀은 자체적으로 인터페이스를 정의했다.
이렇게 팀원이 바라는 인터페이스를 구현하면 팀원이 인터페이스를 전적으로 통제하고, 코드 가독성도 높아지며 코드 의도도 분명해진다는 장점이 생긴다.

깨끗한 경계

경계에서는 변경 같은 흥미로운 일이 많이 벌어진다.
소프트웨어 설계가 우수하다면 변경하는데 많은 투자와 재작업이 필요하지 않다. 경계에 위치하는 코드는 깔끔히 분리한다.
기대치를 정의하는 테스트 케이스를 작성한다.
이쪽 코드에서 외부 패키지를 세세하게 알아야 할 필요가 없도록 한다.
통제가 불가능한 외부 패키지보다 통제가 가능한 우리 코드에 의존하는 편이 좋다.
외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리해야 한다.
Map에서 봤듯이, 새로운 클래스로 경계를 감싸거나 ADAPTER 패턴을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환한다.