우리가 프로그램을 만들 때에 모든 라이브러리들을 직접 개발해 사용하는 경우는 드물다. 때로는 오픈소스를 이용하고 , 때로는 외부에 솔루션 패키지들을 사고, 때로는 사내 다른 팀이 제공하는 컴포넌트를 사용한다. 우리는 이 외부 코드를 어떤 식으로든 우리 코드에 깔끔하게 녹여내야 한다. 해당 장에서는 소프트웨어 간의 경계를 깔끔하게 처리하는 기법과 기교를 알려준다.
경계란?
우리가 만든 코드와 외부 패키지의 코드가 만나는 지점
ex) 외부 API를 호출한다면 해당 API를 호출하여 셋팅하고 사용하는 지점
외부 코드 사용하기
인터페이스 제공자와 사용자는 원하는 바가 다르다. 제공자는 최대한 많은 곳에 적용할 수 있기를 바라고 사용자는 자신이 원하는 요구대로 인터페이스가 집중되길 원한다. 서로 원하는 바가 다르기에 시스템 경계에서 문제가 생길 소지가 있다.
예로 java.util.Map을 살펴보자. 아래 코드와 같이 Map은 아주 다양한 인터페이스로 수많은 기능을 제공한다. Map이 제공하는 기능성과 유연성은 유용하지만 다음과 같은 위험성이 있다.
- Map은 객체 유형을 제한하지 않기에 어떠한 객체의 유형도 추가할 수 있다.
- Map을 여기저기 제공한다 가정했을 때 인터페이스가 변한다면 수정할 코드의 양이 많아진다.
// Map의 다양한 인터페이스들
- clear() void - Map
- containsKey(Object Key) boolean - Map
- containsValue(Object key) boolean - Map
- entrySet() Set - Map
- get(Object key) Object - Map
- isEmpty() boolean - Map
// 그 외 다양한 인터페이스들 .................
위에 나온 위험성들을 하나씩 해결하는 방법에 대해 알아보자.
첫 번째 어떠한 객체 유형도 추가할 수 있다.
// Sensor 객체를 담는 Map을 생성
Map sensors = new hashMap();
// Sensor 객체가 필요할 때 가져온다.
Sensor s = (Sensor) sensors.get(sensorId);
위와 같은 코드가 여러번 반복된다 생각해보자. Sensor객체를 올바른 유형으로 변환하는 책임은 클라이언트 즉, 사용자가 책임을 져야 한다. 또한 위 코드는 동작하는데 문제는 없지만 가독성이 낮아 사용 의도가 한눈에 드러나지 않는다. 위와 같은 코드 대신 제네릭스(Generics)를 사용하면 코드 가독성이 올라간다.
Map<String, Sensor> sensors = new HashMap<Sensor>();
...
Sensor s = sensor.get(sensorId);
제네릭을 사용함으로써 사용자가 변환을 책임지지 않아도 되게 되었으며, 코드의 가독성이 크게 높아졌다.
두 번째 Map은 너무나도 다양한 인터페이스를 제공한다.
제네릭을 사용하여 변환의 책임문제와 가독성 문제를 해결했지만 Map <String, Sensor>가 사용자에게 필요하지 않은 기능까지 제공한다는 문제는 해결하지 못했다. 여러 기능을 이용할 수 있다면 좋은 게 아닐까 생각할 수도 있다. 그러나 Map인터페이스가 변할 경우, 수정할 코드가 상당히 많아진다. 인터페이스가 변할 가능성이 거의 없다고 생각할 수 있지만 자바 5가 제네릭스를 지원하며 Map 인터페이스는 변했다.
한 예시로 데이터베이스에서 주민번호, 핸드폰번호 등을 PrimaryKey로 사용하지 않는다. 현실의 것은 얼마든지 변경의 가능성이 있기 때문이다. 예로 들어 주민번호를 PrimaryKey로 사용하고 있었는데 어떠한 정책으로 인해 주민번호를 수집하지 못하게 되었다고 한다면 눈앞이 깜깜 해지는 상황이 벌어질 것이다. 즉, 변경될 가능성이 있는것은 늘 대비해둬야 한다는 것이다. Map 인스턴스를 여기저기 제공하지 않아야 하는 이유도 동일하다.
그렇다면 이 문제를 어떤 방법으로 처리할지 알아보자.
public class Sensors {
private Map sensors = new HashMap();
public Sensor getById(String id) {
return (Sensor) sensors.get(id);
}
.....
}
경계 인터페이스인 Map을 Sensors 클래스 안으로 숨기는 방법이다. 따라서 Map 인터페이스가 변하더라도 나머지 프로그램에는 영향을 끼치지 않는다. Sensors 클래스 안에서 객체 유형을 관리하고 변환하기 때문이다. 또한 getById()처럼 프로그램에 필요한 인터페이스만 제공하도록 제한할 수 있다. 덕분에 코드는 이해하기 쉽지만 오용하기 어렵게 됐다.
항상 Map 클래스를 사용할때마다 캡슐화를 하라는 말은 아니다. Map(혹은 유사한 경계 인터페이스를)을 여기저기 넘기지 말라는 것이다. Map 인스턴스를 공개 API의 인수로 넘기거나 반환 값으로 사용하지 말자.
경계 살피고 익히기
우리는 외부에서 만들어진 패키지(API)를 사용한다면 더 많은 기능을 출시하기가 쉬워진다. 외부 패키지를 사용하고 싶다면 어디서부터 어떻게 사용하기 시작해야할까? 외부 패키지의 기능이 원하는 대로 작동하는지 테스트하는 것은 우리 책임이 아니다. 그러나 우리가 만드는 프로그램에 사용하기 위한 것이기 때문에 사용할 코드를 테스트하는 것이 바람직하다.
타사의 라이브러리를 가져왔으나 사용법이 분명하지 않다고 가정한다면, 보통은 어느정도의 기간 동안 문서를 읽으면서 사용법을 결정한다. 그리고 우리 쪽의 코드를 작성해 라이브러리가 예상대로 동작하는지 확인할 것이다. 만약 이때에 오류가 난다면 우리 쪽 코드의 오류인지 라이브러리의 버그인지 찾아내느라 골치 아플 것이다.
다들 알겠지만 외부 코드를 사용하기는 어렵다. 이럴 때에 행해야 하는 일이 바로 학습 테스트다. 우리 쪽 코드를 작성해 외부 코드를 바로 호출하기보다는 간단한 테스트 케이스를 작성해 외부 API를 호출할 환경과 동일하게 구성하고 외부 API를 호출하여 통제된 환경에서 라이브러리가 이해한 대로 동작하는지 확인 및 학습하는 것이다.
이 학습 테스트라는 것은 API를 사용하려는 목적에 초점을 맞춘다.
log4 j 익히기
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");
}
// 그외 필요한 기능에 대한 테스트들....
}
- 학습 테스트는 통제된 환경에서 내가 API를 제대로 이해하고 있는지를 확인할 수 있는 방법이다.
- 테스트 케이스를 통해 패키지의 기능들을 내가 사용할 기능들을 사용 환경에 맞춰 세팅하고 하나하나 테스트한다.
- 테스트가 끝난 모든 기능들을 독자적인 클래스로 캡슐화하여 사용한다.
테스트가 끝난 API를 독자적인 클래스로 캡슐화하는 순간 해당 패키지를 사용하는 나머지 프로그램들은 log4 j 경계 인터페이스를 몰라도 된다. log4j에 문제가 생긴다면 독자적인 클래스로 추출해둔 곳을 찾아가 디버깅하고 만들어둔 학습 테스트를 돌려보면 된다.
학습 테스트는 공짜 이상이다
학습 테스트에 드는 비용은 없다. 있다면 개발자의 시간일 것이다. 그러나 어찌 됐든 간에 패키지를 사용하려면 패키지의 문서를 읽어봐야 하고 학습하는 시간이 필요하다. 패키지의 이해도를 높여주면 높여줬지 절대 손해가 되는 시간이 아니다. 오히려 필요한 기능의 지식만을 확보하는 손쉬운 방법이다.
학습 테스트는 패키지를 잘 사용할 수 있게 해 줄 뿐만 아니라 새 버전으로 변경될 때 학습 테스트를 통해 차이점이 있는지 손쉽게 확인할 수 있다. 우리는 새 버전이 출시될 때에 적용하기 망설여질 때가 많다. 기존에 잘 돌아가던 것을 새 버전으로 변경했을 때에 수정사항에 따라 버그가 발생할지도 모르기 때문이다. 이때에 앞서 작성해둔 학습 테스트를 통해 우리의 코드와 패키지의 새 버전이 호환되는지를 확인하고 호환되지 않는 곳이 있다면 독자적인 클래스로 추출해둔 곳을 찾아가 수정하여 새 버전으로 좀 더 쉽게 이전할 수 있다.
학습 테스트를 작성하지 않는다면 새 버전으로 이전하기가 망설여질 것이고, 필요 이상으로 낡은 버전을 오랫동안 사용하려는 유혹에 빠지기 쉽다.
아직 존재하지 않는 코드를 사용하기
경계와 관련해 또 다른 유형은 아는 코드와 모르는 코드를 분리하는 경계다. 우리가 알지 못하는 영역, 아직은 알 수 없는 영역과 부딪혔을 때에 대처하는 방법이다.
책에서 소개해준 일화
CleanCode 글의 저자는 무선통신 시스템에 들어갈 소프트웨어 개발을 시작했다. 해당 소프트웨어에는 '송신기(Transmitter)'라는 하위 시스템이 있는데 해당 시스템에 대한 지식이 전무하였고, '송신기(Transmitter)' 시스템을 책임진 사람들은 인터페이스도 정의하지 못한 상태였다. 그러나 프로젝트의 지연을 원치 않았기에 '송신기' 하위 시스템과 먼 부분부터 작업을 시작했다.
책의 저자는 위와 같은 상황에 부딪혔다. 먼 부분부터 작업을 시작했음에도 불구하고 경계 너머를 알 수 없어 한 치 앞도 내다볼 수 없는 상황이었지만 경계와 자꾸 부딪히며 우리에게 필요한 경계 인터페이스가 무엇인지 알게 되었다고 한다. 원하는 요구사항은 다음과 같다.
"지정한 주파수를 이용해 이 스트림에서 들어오는 자료를 아날로그 신호로 전송하라"
'송신기(Transmitter)' 하위 시스템을 책임진 팀에서 API를 설계하기 전이므로 구체적인 방법은 모르나 우리 시스템에서 바라는 요구사항에 맞춰 자체적으로 인터페이스를 정의하는 방법이다. 아래 그림을 살펴보자
송신기 API가 정의된 상태였다면 CommunicationsController에서 송신기 API를 호출하여 사용했을 가능성도 있었을 것이다. 그러나 아직 정의되지 않은 상태였기에 CommunicationsController와 아직 정의되지 않은 송신기 API를 독자적인 클래스인 TrasmitterClass를 만들어 필요한 인터페이스인 trasmit(frequency, stream) 메서드를 정의함으로써 분리했다. 위의 그림을 정리해보자면 아래와 같다.
- 아직 정의되지 않은 송신기 API를 독자적인 클래스(Transmitter)로 추출해 우리 시스템에 필요한 인터페이스(trasmit(frequency, stream))를 정의한다.
- TransmitterAdapter를 구현해 Tranmitter API가 정의된 이후에 생길 수정사항들을 한 곳에서 처리해준다. (ADAPTER 패턴으로 API 사용을 캡슐화하여 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환해주는 곳이다.)
위와 같은 설계는 테스트도 간단하게 할 수 있다. 미리 필요한 인터페이스를 정의해뒀기 때문에 FakeTransmitter 클래스를 사용해 CommunicationsController 클래스를 테스트해줄 수 있고 또한 Transmitter API 인터페이스가 나온 다음 경계 테스트 즉, 학습 테스트를 작성하여 API를 올바르게 사용하는지 테스트하기도 좋다.
깨끗한 경계
경계에서는 많은 일이 벌어지는대 변경이 대표적이다. 소프트웨어의 설계가 우수하다면 변경하는데 많은 투자와 재작업이 필요하지 않다. 통제하지 못하는 코드를 사용할때면 너무 많은 투자를 하여 향후 변경 비용이 지나치게 커지지않도록 주의해야 한다.
경계에 위치하는 코드는 깔끔히 분리하는게 좋다. Map의 예시에서 봤듯이, 독자적인 클래스로 경계를 감싸거나 ADAPTER 패턴을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환해주는 방법을 이용하자. 어떠한 방법을 사용하던 학습 테스트를 작성하기 용이하고, 코드의 가독성이 높아지며, 경계 인터페이스를 다루는 일관성도 생긴다. 덤으로 외부 패키지가 변했을때에 변경해야 할 코드도 줄어든다.
마무리
제 8장 경계를 통해 외부 패키지들을 어떻게 대해야하는지 알게되었다. 또한 외부 API가 아직 불분명한 상태일때에 ADAPTER 패턴을 이용하여 처리할 수 있다는 것도 알게되었다. 또한 새 버전으로 올리는 것은 언제나 쉽지않다고 느껴왔다. 변경 사항을 정확히 파악하지 못한다면 이곳저곳에서 버그가 생기기 마련이기 때문이다. 그러나 독자적인 클래스로 감싸서 그곳에서 모든 변경사항을 처리해준다면 새 버전으로 버전업을 하기가 수월하다는 것도 배우게되었다. 클린코드는 정말 읽으면 읽을수록 많은것을 배우게된다.
정리
- 경계 인터페이스를 사용할때에는 독자적인 클래스로 감싸 필요한 인터페이스만 사용하자. 테스트 하기도 좋다.
- 학습 테스트를 통해 외부 패키지를 학습하자(경계 테스트와 버전업에 큰 도움이 된다.)
- 외부 API가 불분명 하다면 우리가 원하는 요구사항태로 인터페이스를 작성하고 테스트하고, 실제 API가 설계된 이후에는 ADAPTER 패턴을 사용하여 수정사항을 한곳에서 처리해주자
'Read Book > CleanCode' 카테고리의 다른 글
CleanCode 9장 단위 테스트 (0) | 2022.11.16 |
---|---|
CleanCode 7장 오류 처리 (0) | 2022.11.06 |
CleanCode 6장 객체와 자료구조 (0) | 2022.10.05 |
CleanCode 5장 형식 맞추기 (0) | 2022.09.24 |
CleanCode 4장 주석 (3) | 2022.09.19 |