시스템에 들어가는 모든 소프트웨어를 직접 개발하는 경우는 드물다. 어떤 식으로든 외부 코드를 우리 코드에 깔끔하게 통합해야 한다.
패키지 제공자나 프레임워크 제공자는 적용성을 최대한 넓히려고 하는 반면 사용자는 자신의 요구에 집중하는 인터페이스를 바란다. 이로 인해 시스템 경계에서 문제가 생길 소지가 많다.
한 예시로, java.util.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>();
처럼 제네릭스를 사용하면 코드 가독성이 크게 높아진다. 그러나 위 코드도 Map이 사용자에게 필요하지 않은 기능까지 제공한다는 문제는 해결하지 못한다.
또한 Map<String, Sensor>
인스턴스를 여기저기로 넘긴다면 Map 인터페이스가 변할 경우 수정해야 할 코드가 많아진다.
이 대신 경계 인터페이스인 Map을 Sensors
안으로 숨긴다면 Map
인터페이스가 변하더라도 나머지 프로그램에는 영향을 미치지 않는다. 또한 Sensors
클래스는 프로그램에 필요한 인터페이스만 제공한다.
Map을 (혹은 유사한 경계 인터페이스를) 여기저기 넘기지 말라. Map과 같은 경계 인터페이스를 이용할 때는 이를 이용하는 클래스나 클래스 계열 밖으로 노출되지 않도록 주의한다. Map 인스턴스를 공개 API의 인수로 넘기거나 반환값으로 사용하지 않는다.
우리 코드를 바로 작성해 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익히는 것을 학습 테스트
라고 한다.
학습 테스트는 프로그램에서 사용하려는 방식대로 외부 API를 호출한다. API를 사용하려는 목적에 초점을 맞추는 것이다.
학습 테스트에 드는 비용은 없으며, 오히려 필요한 지식만 확보하는 손쉬운 방법이다.
학습 테스트는 투자하는 노력보다 얻는 성과가 더 크다. 패키지 새 버전이 나온다면 학습 테스트를 돌려 차이가 있는지 확인할 수 있다.
또한 실제 코드와 동일한 방식으로 인터페이스를 사용하는 테스트 케이스가 필요하다. 이런 경계 테스트가 있다면 패키지의 새 버전으로 이전하기 쉽다.
경계와 관련한 또 다른 유형은 아는 코드와 모르는 코드를 분리하는 경계다. 때로는 우리 지식이 경계를 너머 미치지 못하는 코드 영역도 있다.
우리가 원하는 인터페이스를 구현하면 우리가 인터페이스를 전적으로 통제한다는 장점이 생긴다. 또한 코드 가독성도 높아지고 코드 의도도 분명해진다.
이와 같은 설계는 테스트도 편하다. 새 API 인터페이스가 나오면 경계 테스트 케이스를 사용해 우리가 API를 올바르게 사용하는지 테스트할 수도 있다.
경계에서 발생하는 흥미로운 일 중 하나는 변경
이다. 통제하지 못하는 코드를 사용할 때는 너무 많은 투자를 하거나 향후 변경 비용이 지나치게 커지지 않도록 주의해야 한다.
경계에 위치하는 코드는 깔끔하게 분리하고 기대치를 정의하는 테스트 케이스를 작성한다. 또한 외부 패키지를 호출하는 코드를 가능한 줄여서 경계를 관리하자.
통제가 불가능한 외부 패키지에 의존하는 대신 통제가 가능한 우리 코드에 의존하는 편이 훨씬 좋다. 자칫하면 오히려 외부 코드에 휘둘리고 만다.