들어가며
우리는 시스템에 들어가는 모든 소프트웨어를 직접 개발하는 경우는 드물다.
때로는 패키지를 구입하고, 오픈소스를 이용하고, 다른 팀이 제공하는 컴포넌트를 사용하기도 한다.
어떤 식으로든 이 외부 코드를 우리 코드에 깔끔하게 통합해야만 한다. 이 장에서는 소프트웨어 경계를 깔끔하게 처리하는 기법과 기교를 살펴보는 장이다.
외부 코드 사용하기
인터페이스 제공자와 인터페이스 사용자 사이에는 특유의 “긴장”이 존재한다.
패키지 제공자나 프레임워크 제공자는 적용성을 최대한 넓히려 애쓴다. 더 많은 환경에서 돌아가야 더 많은 고객이 구매하니까, 반면 사용자는 자신의 요구에 집중하는 인터페이스를 바라고 있다.
이런 긴장으로 인해 시스템 경계에서 문제가 생길 소지가 많다.
Java의 Map
Map은 굉장히 다양한 인터페이스로 수많은 기능을 제공하고 있다.
Map이 제공하는 기능성과 유연성은 확실히 유용하지만 그만큼 위험도 크다.
예를 들어, 프로그램에서 Map을 만들어 여기저기 넘긴다고 가정해보자.
- 넘기는 쪽에서는 아무도 맵의 내용을 삭제하지 않으리라 믿고 있을 수 있다. 그런데 아래 목록을 보면 첫번째가 clear() 메서드로 즉 Map 사용자라면 누구나 Map의 내용을 지울 권한이 있다는 뜻을 가지고 있다.
- 또 다른 예로 설계 시 맵에 특정 객체 유형만 저장하기로 결정했다고 가정했을 때 맵은 객체 유형을 제한하지 않는다. 마음만 먹으면 사용자는 어떤 객체 유형도 추가할 수 있다.
[예제코드]
아래는 Sensor라는 객체를 담는 Map의 사용 예시이다.
이 코드는 Map이 반환하든 Object를 올바른 유형으로(Sensor) 변환할 책임은 Map을 사용하는 클라이언트에게 있다.
코드는 동작하지만 깨끗한 코드라고 보기엔 어렵다.
의도가 제대로 드러나지 않는다.
Map sensors = new HashMap(); sensor s = (Sensor)sensors.get(sensorId);
제네릭을 사용하면 다음과 같다.
하지만 이 방법도 사용자에게 필요하지 않는 Map의 메소드를 제한할 수는 없다.
Map의 인터페이스가 변할 경우 수정할 코드가 상당히 많아진다.
Map<String, Sensor> sensors = new HashMap<Sensor>(); Sensor s = sensors.get(sensorId);
Map이 변경될리가 없다고 생각하지만 실제로 자바5가 제네릭을 도입하면서 인터페이스가 바뀌었다고 한다.
아래는 Map을 조금 더 깔끔하게 사용한 예시이다.
경계 인터페이스인 Map을 Sensor 안으로 숨겼다.
public class Sensors { private Map sensors = new HashMap(); public Sensor getById(String id) { return (Sensor) sensors.get(id); } }
Map의 인터페이스가 바뀌더라도 기존 코드에는 영향을 미치지 않는다. (Sensor만 수정하면 됨)
Sensors 클래스 안에서 객체 유형을 관리하고 변환하기 때문에 제네릭을 사용하든 사용하지 않든 문제가 되지 않는다.
Sensors 클래스는 프로그램에 필요한 인터페이스만 제공한다. 코드를 이해하기 쉽지만 오용하기 어렵게 만든다.
Sensors 클래스는 설계 규칙과 비즈니스 규칙을 따르도록 강제할 수 있다.
즉 Map을 사용할 때마다 매번 위와 같이 캡슐화 하라는 뜻은 아니다 본질은 Map을 여기저기 넘기지 말라는 말이다.
Map과 같은 경계 인터페이스를 이용할 때는 이를 이용하는 클래스나 클래스 계열 밖으로 노출되지 않도록 주의한다.
Map 인스턴스를 공개 API의 인수로 넘기거나 반환값으로 사용하지 말자.
경계 살피고 익히기
외부 코드를 사용하면 적은 시간에 더 많은 기능을 출시하기 쉬워진다.
외부 패키지 테스트는 우리의 책임은 아니지만 우리 자신을 위해 사용할 코드를 테스트를 하는 편이 더 바람직하다.
타사 라이브러리를 가져왔으나 사용법이 분명하지 않을 때, 대개는 시간을 투자해서 문서를 따로 읽으며 사용법을 알아본다. 그런 다음 우리쪽 코드를 작성해 라이브러리가 예상대로 동작하는지 확인한다.
때로는 원인 모를 버그로 골치를 앓을 수도 있으며 외부 코드를 익히거나 통합하기는 어렵다.
저자는 조금 다른방식을 제시하고 그 방법은 아래와 같다.
학습 테스트
바로 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 간단한 테스트 케이스를 작성해 외부 코드를 익힌다.
학습 테스트는 프로그램에서 사용하려는 방식대로 외부 API를 호출한다. 통제된 환경에서 API를 제대로 이해하는지 확인하는 것
API를 사용하려는 목적에 초점을 맞춤
log4j 익히기
로깅 기능을 직접 구현하는 대신 아파치 log4j를 사용하려는 상황을 가정한다. 아래와 같은 흐름으로 학습 테스트를 진행한다.
문서를 자세히 읽기 전에 첫번째 테스트 케이스를 작성한다
@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"); }
Appender에 출력 스트림이 없다는 부분을 확인하고 다음과 같이 수정한다.
@Test public void testLogAddAppender() { Logger logger = Logger.getLogger("MyLogger"); logger.removeAppAppenders(); logger.addAppender(new ConsoleAppender( new PatternLayout("%p %t %m%n"), ConsoleAppender.SYSTEM_OUT)); logger.info("hello"); }
“hello” 라는 로그가 잘 찍히지만 ConsoleAppender가 출력을 콘솔로 출력을 한다고 지정을 해주다니 이상하다. 지워보지만 여전히 잘 출력이 된다.
하지만 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"); } }
학습 테스트는 공짜 이상이다.
학습 테스트는 필요한 지식만 확보하는 손쉬운 방법이다.
학습 테스트는 이해도를 높여주는 정확한 실험이다.
학습 테스트는 공짜 이상이다. 투자하는 노력보다 얻는 성과가 더 크다.
패키지가 변경되더라도 곧장 테스트 코드를 실행해 당장 우리에게 영향이 있는 변경이 일어났는지 알 수 있다.
학습 테스트를 이용한 학습이 필요하든 그렇지 않든, 실제 코드와 동일한 방식으로 인터페이스를 사용하는 테스트 케이스는 필요하다.
아직 존재하지 않는 코드를 사용하기
저자는 무선통신 시스템 개발을 참여했을 때 얘기를 해준다.
'송신기' 하위 시스템 담당자가 있고, 저자는 송신기 시스템과 협력하는 또다른 어떤 하위 시스템을 만드는 상황인듯하다.
송신기 하위 시스템은 아직 인터페이스도 정의하지 못한 상태에 프로젝트 지연을 원하지 않았기 때문에 저자는 송신기 시스템과 아주 먼 부분부터 작업을 하기 시작했다.
대략 "지정한 주파수를 이용해 이 스트림에서 들어오는 자료를 아날로그 신호로 전송하라." 라는 추상적인 방향성만 갖고 작업을 하게 됐다.
송신시 시스템은 아직 API설계를 하지 않았고 저자쪽 코드를 진행하고자 자체적으로 인터페이스를 정의했다.

- Transmitter라는 클래스를 만든 후 transmit이라는 메서드를 추가했다.
- Transmit 인터페이스는 저자쪽이 원하는 방식(주파수와 자료 스트림을 입력으로 받기)으로 받았다.
- 송신시 API에서 CommuicationController를 분리 했다.
- 필요한 인터페이스만을 정의했으므로 코드는 깔끔했다.
- 송신기 팀이 API를 정의한 후에는 TransmitterAdapter를 구현해 간극을 메운다.
- Adapter 패턴으로 변경사항에 대한 수정할 코드를 한 곳에 모은다.
- 테스트도 편하다. FakeTransmitter 클래스를 사용하면 CommunicationsController 클래스를 테스트 할 수 있다.
- Transmitter API 인터페이스가 나온 다음 경계 테스트 케이스를 생성해 우리가 API를 올바로 사용하는지 테스트할 수 있다.
- 설계가 우수하다면 변경이 쉽다.
- 통제하지 못하는 코드를 사용할 때에는 주의해야한다.
- 너무 많은 투자를 하거나
- 향후 변경 비용이 지나치게 커지지 않도록 주의해야한다.
- 경계에 위치하는 코드는 깔끔하게 분리한다. 또한 기대치를 정의하는 테스트 케이스도 작성한다.
- 외부 패키지를 세세하게 알 필요 없다.
- 통제가 불가능한 외부 패키지에 의존하는 대신 통제가 간으한 우리 코드에 의존하는 편이 낫다.
- 외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리하자.(Map, Adpater)
깨끗한 경계
경계에선는 많은 흥미로운 일이 벌어진다. 변경이 대표적인 예.