CleanCode 8장 경계 에 대해 정리한 포스트입니다.

Overview

시스템에 들어가는 소프트웨어를 직접 다 개발하는 경우는 드뭅니다.

  • 구매한 패키지, 오픈소스 등을 사용
  • 사내 다른 팀이 제공하는 컴퍼넌트를 사용

이 장에서는 소프트웨어 경계 를 깔끔하게 처리하는 기법과 기교를 설명합니다.

외부 코드 사용하기

인터페이스 제공자인터페이스 사용자 사이에는 입장차가 있습니다.

  • 제공자: 적용성을 최대한 넓히려 함
  • 사용자: 자신의 요구에 집중하는 인터페이스를 바람

이러한 차이로 인해 시스템 경계에서 문제가 생길 소지가 많습니다.

예시 - java.util.Map

  • clear() void - map
  • containsKey(Object key) boolean - Map
  • containsValue(Object value) boolean - Map
  • entrySet() set - Map
  • equals(Object o) boolean - Map
  • get(Object key)Object - Map
  • getClass() Class<? extends Object> - Object
  • hashCode() int - Map
  • isEmpty() boolean - Map
  • keySet() Set - Map
  • notify() void - Object
  • notifyAll() void - Object
  • put(Object key, Object value) Object - Map
  • putAll(Map t) void - Map
  • remove(Object key) Object - Map
  • size() int - Map
  • toString() String - Object
  • values() Collection - Map
  • wait() void - Object
  • wait(long timeout) void - Object
  • wait(long timeout, int nanos) void - Object

Map은 굉장히 다양한 인터페이스로 수많은 기능을 제공합니다. 이는 굉장히 유용하지만 그만큼 위험도 클 수 있습니다.

예를 들어:

  1. Map 을 만들어 인수나 반환으로 이리 저리 넘길 경우 clear() 메서드 사용으로 어디선가 예상치 않게 지울 수도 있다.
  2. Map 에 특정 객체 유형만 저장하기로 결정했을 때, Map(Generics 지원 이전의)은 유형을 제한하지 않기 때문에 사용자가 예상치 않은 객체를 추가할 위험도 있다.
    • 물론 반환 받아서 형번환을 하지 않을 가능성도 있다.

        Map sensors = new HashMap();
        Sensor s = (Sensor)sensors.get(sensorId);
      
    • 이런 경우는 Generic이 지원된 이후에 해결되었다.

        Map<String, Sensor> sensors = new HashMap<String, Sensor>();
        Sensor s = sensors.get(sensorId); 
      
  3. Map의 interface가 변경될 경우 수정할 코드가 너무 많아 질 수 있다.

    Generics 지원으로 인해 Map의 인터페이스가 변경되었다는 사실을 명심하자.

위의 문제들은 객체를 감싸는 방법으로 해결이 가능합니다.

  • Sensors 사용자는 외부 라이브러리를 어떤 식으로 사용하는지 신경쓰지 않아도 됨
  • 필요하지 않는 인터페이스(clear()와 같은)를 제공하지 않을 수 있음
public class Sensors {
	private Map sensors = new Hashmap();

	public Sensor getById(string id) {
		return (sensor) sensors.get(id);
	}
}

역시나 Map을(혹은 유사한 경계 인터페이스를) 여기저기 넘기지 않는 것이 가장 중요합니다.

  • 공개 API의 인수로 넘기거나 반환값으로 사용하지 않도록 주의, 즉 이러한 인터페이스를 이용하는 클래스 밖으로 노출되지 않도록 주의

하지만, 피치 못하게 노출될 경우에 캡슐화를 통해 설계 규칙을 지키도록 강제하는 것이 좋습니다.

경계 살피고 익히기

타사 라이브러리를 처음 가져왔을 때 사용법이 분명치 않다고 가정하면:

  1. 하루나 이틀(혹은 더더더) 문서를 읽으며 사용법을 결정
  2. 코드를 작성해 라이브러리가 예상대로 동작하는지 확인
  3. 디버깅(의 반복)

위와 다르게 학습 테스트 를 통해 외부 코드를 익히고 통합하는 어려운 일을 쉽게 할 수 있다.

  • 곧바로 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익힘
  • 학습 테스트 는 프로그램에서 사용하려는 방식대로 외부 API를 호출(API를 사용하려는 목적에 초점을 맞춘다.)

예시 - log4j 익히기

로깅 기능을 직접 구현하는 대신 아파치의 log4j 패키지를 사용하려 한다고 가정하자.

  1. 패키지를 내려 받아 소개 페이지를 연다.
  2. 문서를 자세히 읽기 전에 첫 번째 테스트 케이스를 작성한다.

     // 화면에 "hello"를 출력하는 테스트 케이스이다.
     @Test
     public void testLogCreate() {
         Logger logger = Logger.getLogger("MyLogger");
         logger.info("hello");
     }
    
  3. 테스트 케이스를 돌려본다.
    • Appender라는 뭔가가 필요하다는 오류가 발생한다.
  4. 문서를 읽어보니 ConsoleAppender 라는 클래스가 있다. 그래서 ConsoleAppender 를 생선한 후 테스트 케이스를 다시 돌린다.

     @Test
     public void testLogAddAppender() {
         Logger logger = Logger.getLogger("MyLogger");
         ConsoleAppender appender = new ConsoleAppender();
         logger.addAppender(appender);
         logger.info("hello");
     }
    

    Appender에 출력 스트림이 없다는 사실을 발견한다.

  5. 구글을 검색한 후 아래와 같이 시도한다.

     @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");
     }
    

    잘 돌아간다.

  6. 테스트 케이스를 짜는 과정에서 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");
         }
     }
    
  7. 모든 지식을 통해 Logger 클래스로 캡슐화한다.

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

학습 테스트 는 드는 비용은 없지만 필요한 지식만 확보할 수 있는 손쉬운 방법입니다.

추가적으로 아래의 이점도 있습니다.

  • 패키지가 예상대로 도는지 검증하고, 통합 이후에도 주기적으로 검증이 가능
    • 패키지의 새 버전이 나오면 학습 테스트만 돌려 차이가 있는지만 확인하면 동작을 보장할 수도 있음
    • 호환되지 않을 경우 코드를 수정하든 패키지를 수정하든 조치를 미리 취할 수 있음
  • 경계 테스트와 함께라면 버전 변경도 두렵지 않다.
    • 필요 이상으로 낡은 버전을 사용하려는 유혹에서 빠져나올 수 있음

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

지금 알지 못하는 코드 영역을 개발할 때도 경계는 유용하게 쓰일 수 있습니다.

  • 필요한 인터페이스를 정의/구현하면 전적으로 통제가 가능해짐
  • 테스트도 간편하게 진행할 수 있음

깨끗한 경계

변경이 이루어질 때 경계를 통해 통제하고 있었다면 향후 변경으로 인해 발생하는 비용이 줄어들 수 있습니다.

  • 경계에 위치하는 코드는 깔끔히 분리
  • 기대치를 정의하는 테스트 케이스 작성

통제가 불가능한 외부 패키지 에 의존하는 것보다는 통제가 가능한 우리 코드에 의존 하는 편이 좋습니다.

reference

Clean Code 클린코드 : 애자일 소프트웨어 장인 정신