오늘 하루에 집중하자
  • [BOOK - 클린코드] Chapter 8. 경계
    2025년 02월 28일 16시 53분 31초에 업로드 된 글입니다.
    작성자: nickhealthy

    시스템에 들어가는 모든 소프트웨어를 직접 개발하는 경우는 드물다. 때로는 패키지를 사고, 때로는 오픈 소스를 사용하거나 사내에 다른 팀이 제공하는 컴포넌트를 사용한다. 어떤 식으로든 이 외부 코드를 우리 코드에 깔끔하게 통합해야만 한다.

    이 장에서는 소프트웨어의 경계를 깔끔하게 처리하는 기법과 기교를 살펴본다.

     

    외부 코드 사용하기


    항상 인터페이스 제공자와 인터페이스 사용자 사이에는 특유의 긴장감이 존재한다. 패키지, 프레임워크 제공자는 적용성을 최대한 넓히려고 애를 쓴다. 그래야 더 많은 고객이 구매하고 사용하니까. 하지만 이런 긴장감으로 인해 시스템 경계에서 문제가 생길 소지가 많다.

    한 예로, java.util.Map을 살펴보면 맵(Map)은 굉장히 다양한 인터페이스로 수많은 기능을 제공해 유용하지만 그만큼 위험성도 크다. 다음과 같은 위험성이 존재한다.

    • Map을 여기저기에 넘기는 코드를 작성했을 때 Map의 데이터는 마음껏 핸들링이 가능하다. 예를 들어, Map에 데이터를 넣고 넘겼지만, Map을 사용하는 클라이언트는 아무런 제약 없이 clear() 메서드로 데이터를 모두 지워버릴 수도 있다.
    • 특정 객체 유형만 저장하기로 결정했지만 마음만 먹으면 어떤 객체 유형도 추가할 수 있다.
      • 제네릭스을 사용하면 코드 가독성이 크게 높아지지만, 사용자에게 필요하지 않은 기능까지 제공하는 문제가 발생한다. 만약 제네릭스를 사용한 Map 인터페이스를 많이 사용했는데 인터페이스가 바뀌어야 할 경우, 수정할 코드가 상당히 많아진다.
    • 즉, Map을 사용하는 클라이언트에 모든 책임이 있다.

     

    잘못된 방법) - 사용하는 클라이언트의 책임/너무 많은 기능을 제공

    // 인터페이스를 사용하는 클라이언트의 책임
    Map Sensors = new HashMap();
    Sensor s = (Sensor)sensors.get(sensorId); // Map이 반환하는 Obejct을 올바른 유형으로 변환할 책임은 클라이언트에 있다.
    
    // 제네릭스 사용 - 사용자에게 필요하지 않은 기능까지 제공
    Map<String, Sensor> sensors = new HashMap();
    Sensor s = sensors.get(sensorId);

     

    옳은 방법) - 클라이언트는 특정 유형을 변환할 책임도, 제네릭스 사용 여부를 고려할 필요도 없다.

    public class Sensors {
      private Map sensors = new HashMap();
    
      public Sensor getById(String id) {
        return (Sensor) sensors.get(id);
      }
    
      ...
    }

     

    경계 인터페이스인 Map을 Sensors 클래스 안으로 숨긴다. 따라서 Map 인터페이스가 변하더라도 나머지 프로그램에는 영향을 미치지 않는다. 제네릭스를 사용하든 하지 않든 상관 하지 않아도 된다. Sensors 클래스 안에서 객체 유형을 관리하고 변환하기 때문이다.

    이렇게 활용도가 높은 인터페이스를 사용할 때마다 캡슐화를 추천하는 것은 아니다. 경계할 인터페이스를 남용하여 여기저기 넘기지 말라는 뜻이다. 경계 인터페이스를 이용할 땐 이를 이용하는 클래스를 사용하여 밖으로 노출시키지 않도록 주의한다.

     

    경계 살피고 익히기


    외부 코드를 사용하면 적은 시간에 더 많은 기능을 출시하기 쉬워진다. 하지만 외부 코드를 익히기도 통합하기도 어렵다.
    외부 코드를 테스트하는 것은 우리 책임은 아니지만, 우리 자신을 위해 우리가 사용할 코드를 테스트 하는 것은 바람직하다.
    다음과 같은 가이드는 외부 코드를 조금 더 올바르게 사용할 수 있도록 돕는다.

    • 곧바로 우리쪽 코드를 작성해 외부 코드를 통합하는 대신, 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익힌다.
    • 이를 짐 뉴커크는 학습 테스트라 부른다.
    • 학습 테스트는 프로그램에서 사용하려는 방식대로 외부 API를 호출한다. 통제된 환경에서 API를 제대로 이해하는지를 확인하는 셈이다.

     

    log4j 익히기


    위에서 언급한 내용을 예제를 통해 조금 더 자세히 알아보자.
    로깅 기능을 직접 구현하는 대신 아파치의 log4j 패키지를 사용한다고 가정한다. 패키지를 내려 받고 소개 페이지를 연다.
    문서를 자세히 읽기 전 첫 째 테스트 케이스를 작성한다. hello를 출력해본다.

    @Test
    public void testLogCreate() {
      Logger logger = Logger.getLogger("MyLogger");
      logger.info("hello");
    }

    테스트 케이스를 돌렸더니 Appender라는 뭔가가 필요하다고 한다. 문서를 조금 더 읽고 ConsoleAppender라는 클래스를 발견하고 사용해본다.

     

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

    이번에는 Appender에 출력 스트림이 없다는 사실을 발견한다. 출력 스트림이 있어야 정상인데 뭔가 이상해서 다시 구글링을 해본다.

     

    @Test
    public void testLogAddAppender() {
      Logger logger = Logger.getLoger("MyLogger");
      logger.removeAllAppenders();
      logger.addAppender(new ConsoleAppender(new PatternLayout(
                      "%p %t %m%n"), ConsoleAppender.SYSTEM_OUT));
      logger.info("hello");
    }

    이번에는 정상적으로 출력된다. 하지만 ConsoleAppender에게 콘솔에 쓰라고 알려야 한다니 뭔가 수상하다. 조금 더 구글링과 문서를 읽어보고 테스트를 해보며 log4를 익혀본다.

     

    이제 테스트 케이스를 작성하는 과정에서 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");
        }
    }

    테스트 케이스를 통해 log4j가 어떻게 돌아가는지를 이해했다. 이제 여기서 얻은 것들을 독자적인 로거 클래스로 캡슐화한다.
    그러면 나머지 프로그램은 log4j 경계 인터페이스를 몰라도 된다.

     

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


    학습 테스트는 이해도를 높여주는 정확한 실험이다. 학습 테스트는 투자하는 노력보다 얻는 성과가 더 크기 때문에 공짜 이상이다.
    새 버전이 출시되었을 때 다음과 같은 가이드를 따른다.

    • 학습 테스트로 패키지가 예상대로 도는지 검증한다. 학습 테스트를 통해 호환되는지 곧바로 밝혀낼 수 있다.
    • 학습 테스트를 이용한 학습이 필요하든 그렇지 않든, 실제 코드와 동일한 방식으로 인터페이스를 사용하는 테스트 케이스가 필요하다.
      • 이런 경계 테스트가 있다면 패키지의 새 버전으로 이전하기 쉬워진다.

     

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


    경계와 관련해 또 다른 유형은 아는 코드와 모르는 코드를 분리하는 경계다. 우리 지식이 경계를 넘어 미치지 못하는 코드 영역도 존재하지만 프로젝트가 지연되도록 놔둘 수는 없다. 따라서 다음과 같은 가이드를 따른다.

    • 상호 작용할 API가 개발되어 있지 않다면 인터페이스를 정의해라.
      • 인터페이스를 전적으로 통제한다는 장점이 생긴다.
      • 코드 가독성도 높아지고 코드 의도도 분명해진다.
    • Adapter 패턴으로 API 사용을 캡슐화해라. 해당 패턴을 사용하면 API를 수정할 때 간극을 메꿀 수 있다.
    • 이와 같은 설계는 테스트도 편하다.

     

    Adapter 패턴


    Adapter 패턴은 소프트웨어 디자인 패턴 중 하나로, 기존의 인터페이스를 사용하기 어려운 상황에서, 그 인터페이스를 사용하는 데 필요한 형식으로 변환해 주는 역할을 한다. 주로 서로 호환되지 않는 인터페이스를 가진 클래스를 연결할 때 사용된다.

    간단히 말해, Adapter 패턴은 "호환되지 않는 인터페이스를 연결해주는 변환기" 역할을 한다.
    다음과 같은 특징이 있다.

    • 기존 코드 변경 없이 호환성 제공: 기존의 클래스를 수정하지 않고도 다른 인터페이스를 지원하게 할 수 있음
    • 클래스의 인터페이스를 변환: 주어진 인터페이스를 다른 인터페이스로 변환해서, 클라이언트가 새로운 인터페이스를 사용할 수 있도록 도움
    • 구현 예시: 자주 사용하는 예는 다른 형태의 데이터 포맷을 처리할 때, 예를 들어 JSON 포맷을 XML로 변환하는 경우

    예를 들어, 전기 콘세트를 생각하면 이해하기 쉬운데, 미국에서 110V 전원을 사용하는 기기를 사서 한국에서 사용하려면 220V 규격이 필요하다. 이럴 때 맞는 전원을 이용하기 위해선 어댑터를 사용해야 한다. Adapter 패턴은 이러한 역할을 한다.

    어댑터 패턴 예시

     

    예제를 통해 더 자세히 알아보자.

    [기존 클래스]

    // 미국 전자기기(기존 클래스)
    class USDevice {
        public void plugIn() {
            System.out.println("Plugged into 110V socket.");
        }
    }
    

    해당 클래스는 미국에서 산 전자기기로 110V가 필요하다.
    이제 한국에서 220V 전원을 사용하는 전자기기를 사용하려면, Adapter를 만들어야 한다.

     

    [Adapter 클래스]

    // 어댑터 클래스 (220V -> 110V 변환)
    class VoltageAdapter {
        private USDevice usDevice;
    
        public VoltageAdapter(USDevice usDevice) {
            this.usDevice = usDevice;
        }
    
        public void plugIn220V() {
            System.out.println("Converting 220V to 110V...");
            usDevice.plugIn(); // 220V를 110V로 변환하여 USDevice에 연결
        }
    }
    

     

    [클라이언트 코드]

    public class Main {
        public static void main(String[] args) {
            USDevice usDevice = new USDevice(); // 미국 전자기기
            VoltageAdapter adapter = new VoltageAdapter(usDevice); // 어댑터 사용
    
            // 한국의 220V 전원을 110V로 변환하여 기기 연결
            adapter.plugIn220V(); 
        }
    }
    
    // 실행 결과
    Converting 220V to 110V...
    Plugged into 110V socket.

    이 예시에서 Adapter 패턴은 미국 전자기기(USDevice)를 한국의 전원 시스템에 맞게 사용할 수 있도록 해주는 역할을 했다. 어댑터를 통해 기존 클래스를 변경하지 않고도 호환되도록 만든 것이 Adapter 패턴의 핵심이다.

    이와 같은 방식으로 다양한 시스템이 서로 호환되지 않을 때, Adapter 패턴을 사용하여 호환성 문제를 해결할 수 있다.

     

    깨끗한 경계


    경계할 코드나 패키지에는 변경사항에 주의해야 한다. 통제하지 못하는 외부 코드를 사용할 땐 각별히 주의해야 한다.
    다음과 같은 가이드가 있다.

    • 경계에 위치하는 코드는 깔끔히 분리해야 한다.
    • 기대치를 정의하는 테스트 케이스를 작성한다.
    • 통제가 불가능한 외부 패키지에 의존하는 대신 통제가 가능한 우리 코드에 의존하는 편이 훨씬 좋다.
    • 외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리하자.
    • Map에서 봤던 것처럼, 새로운 클래스로 경계를 감싸거나,
    • Adapter 패턴을 사용해 패키지가 제공하는 인터페이스를 우리가 원하는 인터페이스로 변환하자.

     

    댓글