인터페이스 제공자(패키지 제공자, 프레임워크 제공자)는 더 많은 환경에서 돌아가야 더 많은 고객이 구매하므로 적용성을 최대한 넓히려 애쓴다. 반면, 사용자는 자신의 요구에 집중하는 인터페이스를 바란다. 이런 긴장으로 인해 시스템 경계에서 문제가 발생할 소지가 많다.
Map은 다양한 인터페이스로 수많은 기능을 제공한다. Map이 제공하는 기능성과 유연성은 확실히 유용하지만 그만큼 위험도 크다.
프로그램에서 Map을 만들어 여기저기 넘길 경우, Map 사용자라면 누구나 Map 내용을 지울 권한이 있으므로 Map 내용이 삭제될 수도 있으며 Map은 객체 유형을 제한하지 않으므로 마음만 먹으면 사용자는 어떤 객체 유형도 추가할 수 있다. 즉, Map이 반환하는 Object를 올바른 유형으로 변환할 책임은 Map을 사용하는 클라이언트에 있다.
대신 제네릭스를 사용하면 코드 가독성은 크게 증가하지만 사용자에게 필요하지 않은 기능은 여전히 제공하므로 Map 인스턴스를 여기저기로 넘긴다면, Map 인터페이스가 변할 경우, 수정할 코드가 상당히 많아진다.
그러므로 경계 인터페이스인 Map을 클래스 안으로 숨기면 Map 인터페이스가 변하더라도 클래스 안에서 객체 유형을 관리하고 변환하므로 나머지 프로그램에는 영향을 미치지 않는다. 또한 클래스는 프로그램에 필요한 인터페이스만 제공하므로 삭제 등의 오용하기 어려워지므로 설계 규칙과 비즈니스 규칙을 따르도록 강제할 수 있다.
즉, 경계 인터페이스를 여기저기 넘기지 말고 이를 이용하는 클래스나 클래스 계열 밖으로 노출되지 않도록 주의해야 한다.
// bad
Map sensors = new HashMap();
...
Sensor s = (Sensor)sensors.get(sensorId);
// bad
Map<String, Sensor> sensors = new HashMap<Sensor>();
...
Sensor s = (Sensor)sensors.get(sensorId);
// good
public class Sensors {
private Map sensors = new HashMap();
public Sensor getById(String id) {
return (Sensor) sensor.getId(id);
}
...
}
경계 살피고 익히기
외부 코드를 사용하면 적은 시간에 더 많은 기능을 출시하기 쉬워진다.
외부 패키지 테스트는 우리의 책임이 아니지만 우리 자신을 위해 우리가 사용할 코드를 테스트하는 편이 바람직하다.
문서를 읽으며 사용법을 결정한 후 우리쪽 코드를 작성해 라이브러리가 예상대로 동작하는지 확인할 경우 우리 버그인지 라이브러리 버그인지 찾아내느라 오랜 디버깅으로 골치를 앓는다.
곧바로 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익히는 학습 테스트를 한다. 학습 테스트는 프로그램에서 사용하려는 방식대로 외부 API를 호출한다. 즉, 통제된 환경에서 API를 제대로 이해하는지를 확인하는 셈이다.
log4j 익히기
로깅 기능을 직접 구현하는 대신 아파치의 log4j 패키지를 사용할 경우, 콘솔 로거를 초기화하는 방법을 익힌 후 모든 지식을 독자적인 로거 클래스로 캡슐화한다. 그러면 나머지 프로그램은 log4j 경계 인터페이스를 몰라도 된다.
// good
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("addAppenderWithStream");
}
}
학습 테스트는 공짜 이상이다
학습 테스트는 이해도를 높여주는 정확한 실험이다.
학습 테스트는 패키지가 예상대로 도는지 검증한다. 일단 통합한 이후라고 하더라고 패키지가 우리 코드와 호환되리라는 보장은 없다.
패키지 새 버전이 나올 때마다 새로운 위험이 생긴다. 새 버전이 우리 코드와 호환되지 않으면 학습 테스트가 이 사실을 곧바로 밝혀낸다. 이런 경계 테스트가 있다면 패키지의 새 버전으로 이전하기 쉬워진다.
아직 존재하지 않는 코드를 사용하기
아는 코드와 모르는 코드를 분리하는 경계 유형이 있다.
때로는 우리 지식이 경계를 너머 미치지 못하는 코드 영역이 있거나 알려고 해도 알 수 없는 영역이 있다.
이때 자체적인 인터페이스를 구현하면 우리가 인터페이스를 전적으로 통제한다는 장점이 생긴다. 또한 코드 가독성도 높아지고 코드 의도도 분명해진다.
이후 우리가 통제하지 못하며 정의되지도 않은 부분이 다른 곳에 의해 정의된다면 ADAPTER 패턴으로 API 사용을 캡슐화해 API가 바뀔 때마다 수정할 코드를 한곳에 모아 간극을 메우게 된다.
이와 같은 설계는 테스트도 아주 편하다.
깨끗한 경계
경계에서는 변경과 같은 흥미로운 일이 많이 벌어진다.
소프트웨어 설계가 우수하다면 변경하는데 많은 투자와 재작업이 필요하지 않다.
경계에 위치한 코드는 깔끔히 분리한다. 또한 기대치를 정의하는 테스트 케이스도 작성한다.
통제가 불가능한 외부 패키지에 의존하는 대신 통제가 가능한 우리 코드에 의존하는 편이 훨씬 좋다.
외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리하자.
새로운 클래스로 경계를 감싸거나 아니면 ADAPTER 패턴을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환하자.
어느 방법이든 코드 가독성이 높아지며, 경계 인터페이스를 사용하는 일관성도 높아지며, 외부 패키지가 변했을 때 변경할 코드도 줄어든다.