시스템에 들어가는 모든 SW 를 직접 개발하는 경우는 드물다.
어떤 식으로든 이 외부 코드를 우리 코드에 깔끔하게 통합해야만 한다.
이 장에서는 SW 경계를 깔끔하게 처리하는 기법과 기교를 살펴본다.
패키지 제공자/프레임워크 제공자는 (더 많은 환경에서 돌아가야 많은 고객이 구매하니)
적용성을 최대한 넓히려 애쓴다.
반면, 사용자는 자신의 요구에 집중하는 인터페이스를 바란다.
예를 들어 java.util.Map 을 살펴보자.
다양한 인터페이스로 수많은 기능(clear, containsKey, equals, isEmpty ...)
을 제공하는 만큼, 기능성과 유연성은 유용하다.
하지만, 그만큼 위험도 크다.
예를 들어 설계 시 Map에 특정 객체 타입만 저장하기로 결정했다고 가정했다.
하지만 Map 은 객체 유형을 제한하지 않는다. 마음만 먹으면 사용자는 어떤 객체 유형도 추가할 수 있다.
아래 코드는 Map 이 반환하는 Object 를 올바른 타입으로 변환할 책임을 Map을 사용하는 클라이언트에게 부여한다.
이렇게 되면 동작은 하지만, 사용할 때마다 변환을 해주어야 하므로 깨끗한 코드라 보기 어렵다.
Map sensors = new HashMap();
Sensor s = (Sensor)sensors.get(sensorId);
경계 인터페이스인 Map 을 Sensor 클래스 안으로 숨긴다.
public class Sensors {
private Map sensors = new HashMap();
public Sensor getById(String id) {
return (Sensor)sensors.get(id);
}
}
외부 코드를 익히기는 어렵다
외부 코드를 (우리 코드와) 통합하기도 어렵다
-> 두 가지를 동시에 하는 것은 두 배나 어렵다.
우리쪽 코드를 작성해 외부 코드를 호출하는 대신,
먼저 간단한 테스트 케이스를 작성해 외부 코드를 익혀보면 어떨까?
이를 '학습 테스트' 라고 부른다.
통제된 환경에서 API 를 제대로 이해하는지를 확인하는 것으로,
API를 사용하려는 목적에 초점을 맞춘다.
API의 많은 기능 중 사용하려는 기능에 맞춰 테스트케이스를 작성해가며
사용할 기능들을 이해하가는 과정이라고 이해했습니다.
(로깅과 관련된 라이브러리)
화면에 "hello"를 출력하는 테스트 케이스이다.
@Test
public void testLogCreate() {
Logger logger = Logger.getLogger("MyLogger");
logger.info("hello");
}
돌렸더니 Appender 라는 뭔가가 필요하다는 오류가 발생한다.
문서를 읽어보고 ConsoleAppender 를 생성한 후 테스트케이스를 다시 돌린다.
@Test
public void testLogAddAppender() {
Logger logger = Logger.getLogger("MyLogger");
ConsoleAppender appender = new ConsoleAppender();
logger.addAppender(appender);
logger.info("hello");
}
이번에는 출력 스트림이 없다는 사실을 발견한다.
구글을 검색한 후 다음과 같이 시도하고 그제서야 제대로 돌아감을 확인한다.
@Test
public void testLogAddAppender() {
Logger logger = Logger.getLogger("MyLogger");
logger.removeAllAppenders();
logger.addAppender(new ConsoleAppender(
new PatternLayout("%p %t %m%n"),
ConsoleAppender.SYSTEM_OUT));
logger.info("hello");
}
이렇게 얻은 지식들을 아래와 같이 간단한 단위 테스트 케이스 몇 개로 표현한다.
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 의 경계 인터페이스를 몰라도 된다.
학습 테스트는 패키지가 예상대로 도는지 검증한다.
새 버전이 우리 코드와 호환되지 않으면 학습 테스트가 이 사실을 곧바로 밝혀낸다.
이런 경계 테스트가 있다면 패키지의 새 버전으로 이전하기 쉬워진다.
그렇지 않다면 낡은 버전을 필요 이상으로 오랫동안 사용하려는 유혹에 빠지기 쉽다.
필요로 하는 모듈이 완성되지 않아도 개발을 진행할 수 있어야 한다.
무선 통신 시스템에 들어갈 SW개발에 참여할 당시,
송신기(Transmitter) 라는 하위 시스템이 있었는데 인터페이스도 정의하지 못한 상태
프로젝트 지연을 원치 않았기에 송신기 하위 시스템과 아주 먼 부분부터 작업하기 시작
나중에 그 경계에 부딪히기 시작하자
(저쪽 팀이 아직 API를 설계하지 않았으므로) 구현을 나중으로 미루고 인터페이스부터 정의
현재 원하는 기능이 '지정한 주파수를 이용해 이 스트림에서 들어오는 자료를 아날로그 신호로 전송'이므로
Transmitter 인터페이스는 주파수와 자료 스트림을 입력으로 받게 함
(통제하지 못하고 정의되지도 않은) 송신기 API 에서 CommunicationsController 를 분리하여
CommunicationsController 는 깔끔하고 깨끗했다.
또한 저쪽 팀이 송신기 API 를 정의한 후에는
ADAPTER 패턴으로 API 사용을 캡슐화해 API가 바뀔 때 수정할 코드를 한 곳으로 모았다.
이와 같은 설계는
변경으로 인한 영향을 최소화할 수 있는 장점도 있다고 생각합니다.
경계에 위치하는 코드는 깔끔히 분리한다.
외부 패키지를 직접 호출하는 코드를 가능한 줄여 경계를 관리하자
어느 방법이든 코드의 가독성이 높아지고 경계 인터페이스를 사용하는 일관성도 높아지며, 외부 패키지가 변했을 때 영향도도 줄어든다.