8장 - 경계

시스템에 들어가는 모든 소프트웨어를 직접 개발하는 경우는 드물다. 패키지를 사거나, 오픈 소스를 이용하거나, 사내 다른 팀이 제공하는 외부 코드를 우리 코드에 깔끔하게 통합해야만 한다.

외부 코드 사용하기

패키지, 라이브러리 등의 제공자와 사용자 사이엔 특유의 긴장이 존재한다. 제공자는 더 많은 환경에서 더 많은 고객의 요구에 맞추기 위해 적용성을 넓히려 애쓴다. 사용자는 자신의 요구에 집중하길 바란다. 이런 긴장 때문에 시스템 경계에서 문제가 생길 소지가 많다.

Map의 메서드

Map은 다양한 메서드를 제공한다. 프로그램에서 Map을 만들어 여기저기 넘긴다고 가정할 때, 넘기는 쪽에서는 아무도 Map의 내용을 삭제하지 않길 바라더라도, clear 메서드로 Map에 접근하는 누구나 내용을 지울 권한이 있다.

const map = new Map();
const sensor = map.get(sensorId) as Sensor;

특정한 자료형을 저장하기로 했을 때도 Map은 타입을 제한하지 않기에, Map에 접근할 수 있는 사용자는 모든 유형의 값을 추가할 수 있다.

특정한 타입을 갖는 Map을 제작하면, 위 예시와 같이 Map을 사용하는 클라이언트에게 올바른 타입으로 변환할 책임이 있다. 코드가 동작하긴 하지만, 깨끗한 코드라 보기 어렵다. 게다가 코드의 의도를 파악하기도 힘들다.

const map = new Map<string, Sensor>();
const sensor = map.get(sensorId);

위 예시와 같이 제네릭스Generics를 사용하면 코드 가독성을 크게 높일 순 있지만, 여전히 Map<string, Sensor>가 사용자에게 필요하지 않은 기능까지 제공하는 문제는 해결하지 못했다.

class Sensors {
    private sensors = new Map<string, Sensor>();

    getById(id: string): Sensor {
        return this.sensors.get(id) as Sensor;
    }
}

위 예제는 Map을 좀 더 깔끔하게 사용한 코드다. MapSensors 안에 숨겨 사용자는 제네릭스의 사용 여부를 신경 쓸 필요가 없고, Map 인터페이스가 변하더라도 나머지 프로그램에는 영향을 미치지 않는다.

또한, Sensors 클래스는 프로그램에 필요한 메서드만 제공하기에, 코드를 이해하긴 쉬워지고, 오용하긴 어려워졌다.

Map을 사용할 때마다 위와 같이 캡슐화하라는 소리가 아니다. 경계 인터페이스를 여기저기 넘기지 말라는 말이다.

경계 살피고 익히기

외부 코드를 사용하면 적은 시간에 더 많은 기능을 출시하기 쉬워진다. 외부에서 가져온 패키지를 사용할 때 외부 패키지 테스트가 우리 책임은 아니지만, 우리 자신을 위해 우리가 사용할 코드를 테스트하는 편이 바람직하다.

사용법이 분명치 않은 타사 라이브러리를 가져왔다고 가정하자. 하루나 이틀간 문서를 읽으며 사용법을 결정하고, 우리 쪽 코드를 작성해 라이브러리가 예상대로 동작하는지 확인한다. 때로는 버그가 작성한 코드에서 발생한 것인지, 라이브러리에서 발생한 것인지 찾아내느라 오랜 시간 골머리를 앓기도 한다.

외부 코드를 익히긴 어렵다. 외부 코드를 통합하기도 어렵다. 두 가지를 동시에 하는 건 배로 어렵다. 우리 쪽 코드를 작성해 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익히는 방식으로 다르게 접근하면 어떨까? 짐 뉴커크Jim Newkirk는 이를 학습 테스트라 부른다.

학습 테스트는 프로그램에서 사용하려는 방식대로 외부 API를 호출한다. 통제된 환경에서 API를 제대로 이해하는지 확인하는 셈이다. 학습 테스트는 API를 사용하려는 목적에 초점을 맞춘다.

log4j 익히기

아래 대체재들을 참고해보자.

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

어쨌든 API를 배워야 하기에, 학습 테스트에는 비용이 들지 않는다. 오히려 투자하는 노력보다 얻는 성과가 더 크고, 필요한 지식만 정확하게 확보하는 손쉬운 방법이다.

학습 테스트는 패키지가 예상대로 도는지 검증한다. 통합한 이후에도 패키지가 우리 코드와 호환되리란 보장은 없다. 패키지 코드 작성자가 코드를 변경해야 할 수도, 버그를 수정하고 기능을 추가할 수도 있다. 패키지의 새 버전이 나올 때마다 위험도 생긴다. 이때 학습 테스트가 새 버전이 우리 코드와 호환되는지 검증해준다.

학습 테스트를 이용한 학습이 필요하건 필요하지 않건, 실제 코드와 같은 방식으로 인터페이스를 사용하는 테스트 케이스가 필요하다. 이런 경계 테스트가 없다면 패키지의 새 버전으로 이전이 어려워지고, 낡은 버전을 필요 이상으로 오랫동안 사용하려는 유혹에 빠지기 쉽다.

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

경계와 관련해 또 다른 유형은 아는 코드와 모르는 코드를 분리하는 경계다. 때로는 우리 지식이 경계를 너머 미치지 못하는 코드 영역도 있다.

예를 들어 다른 팀이 API를 제작해주길 기다리는 상황이라 가정해보자. 이때 우리가 바라는 인터페이스를 구현하면 우리가 인터페이스를 전적으로 통제한다는 장점이 생긴다. 또한, 코드 가독성도 높아지고 코드 의도도 분명해진다. 다른 팀이 API를 정의한 후에는 ADAPTER 패턴1으로 API의 사용을 캡슐화해 API가 바뀔 때 수정할 코드를 한 곳으로 모을 수도 있다.

깨끗한 경계

경계에선 변경처럼 흥미로운 일이 많이 벌어진다. 소프트웨어 설계가 우수하다면 변경하는데 엄청난 시간과 노력과 재작업을 요구하지 않는다. 통제하지 못하는 코드를 사용할 때는 너무 많은 투자를 하거나 향후 변경 비용이 지나치게 커지지 않도록 각별히 주의해야 한다.

경계에 있는 코드는 깔끔히 분리하고, 기대치를 정의하는 테스트도 작성하자. 또한, 외부 패키지를 호출하는 코드를 가능한 한 줄여 경계를 관리하자. 상술한 것처럼 새로운 클래스로 경계를 감싸거나, ADAPTER 패턴을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환하자. 어느 방법이든 코드 가독성이 높아지며, 경계 인터페이스를 사용하는 일관성도 높아지며, 외부 패키지가 변했을 때 변경할 코드도 줄어든다.

Footnotes

  1. https://en.wikipedia.org/wiki/Adapter_pattern