IT Book/클린 코드(Clean Code)

[Clean Code] 8. 경계 - 클린 코드 정독하기

잇트루 2023. 8. 17. 21:27
반응형

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

 

 

외부 코드 사용하기

패키지 제공자나 프레임워크 제공자는 더 많은 환경에서 돌아가도록 하기 위해 적용성을 최대한 넓히려 하는 반면에, 사용자는 자신의 요구에 집중하는 인터페이스를 바란다. 이로 인해 시스템 경계에서 문제가 생길 소지가 많다.

 

자바의 java.util.Map은 굉장히 다양한 인터페이스로 수많은 기능을 제공한다. Map이 제공하는 기능성과 유연성은 확실히 유용하지만 그만큼 위험도 크다.

예를 들어 프로그램에서 Map을 만들어 여기저기 넘긴다고 가정하자. Map에는 clear() 메서드가 있다. 누구나 Map 내용을 지울 권한이 있다는 뜻이다.

또 다른 예로, Map에 특정 객체 유형만 저장하기로 결정했다고 가정하자. 하지만 Map은 객체 유형을 제한하지 않아 마음만 먹으면 사용자는 어떤 객체 유형도 추가할 수 있다.

 

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

 

Sensor라는 객체를 담기 위해서는 다음과 같이 Map을 생성한다.

Map sensors = new HashMap();

 

Sensor 객체가 필요한 코드는 다음과 같이 Sensor 객체를 가져온다.

Sensor s = (Sensor)sensors.get(sensorId);

즉, Map이 반환하는 Object를 올바른 유형으로 벼노한할 책임은 Map을 사용하는 클라이언트에 있다.

 

위 코드를 다음과 같이 제네릭(Generics)을 사용하면 코드 가독성이 크게 높아진다.

Map<String, Sensor> sensors = new HashMap<Sensor>();
...
Sensor s = sensors.get(sensorId);

하지만 이 방법도 사용자에게 필요하지 않은 기능까지 제공한다는 문제는 해결하지 못한다.

 

프로그램에서 Map 인스턴스를 여기저기로 넘긴다면, Map 인터페이스가 변할 경우, 수정할 코드가 상당히 많아진다. 인터페이스가 변할 가능성이 거의 없다고 여길지도 모르지만, 자바 5가 제네릭을 지원하면서 Map 인터페이스가 변했다는 사실을 명심해야 한다.

 

다음은 Map을 좀 더 깔끔하게 사용한 코드다.

public class Sensors {
    private Map sensors = new HashMap();

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

    // 이하 생략
}

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

또한, Sensors 클래스는 프로그램에 필요한 인터페이스만 제공한다. Sensors 클래스는 설계 규칙과 비즈니스 규칙을 따르도록 강제할 수 있다.

Map 클래스를 사용할 때마다 위와 같이 캡슐화하라는 소리가 아닌 Map을 여기저기 넘기지 말라는 의미다. Map과 같은 경계 인터페이스를 이용할 때는 이를 이용하는 클래스나 클래스 계열 밖으로 노출되지 않도록 주의한다.

 

 

경계 살피고 익히기

외부 코드를 사용하면 적은 시간에 더 많은 기능을 출시하기 쉬워진다. 이런 외부 코드를 사용할 때 패키지 테스트를 할 책임이 있는 건 아니지만, 사용할 코드를 테스트하는 편이 바람직하다.

 

타사 라이브러리를 가져왔으나 사용법이 분명치 않다고 가정하자.

대개는 문서를 읽으며 사용법을 결정한다. 이후 코드를 작성해 라이브러리가 예상대로 동작하는지 확인한다. 이 과정에서 사용자 버그인지 라이브러리 버그인지 찾아내느라 오랜 디버깅으로 골치를 앓기도 한다.

이처럼 외부 코드를 익히고 통합하는 과정은 어렵다. 이를 다르게 접근하여 곧바로 우리 쪽 코드를 작성해 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익히는 방식이 있다. 이런 방식을 학습 테스트라 부른다.

학습 테스트는 프로그램에서 사용하려는 방식대로 외부 API를 호출한다. 통제된 환경에서 API를 제대로 이해하는지를 확인하는 셈이다. 학습 테스트는 API를 사용하려는 목적에 초점을 맞춘다.

 

 

log4j 익히기

log4j 학습 테스트 방식으로 익히기

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

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

위 테스트 케이스를 돌리면 Appender라는 뭔가가 필요하다는 오류가 발생한다.

 

log4j에는 ConsoleAppender라는 클래스가 있다. 따라서 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.removeAllAppenders();
    logger.addAppender(new ConsoleAppender(
            new PatternLayout("%p %t %m%n"),
            ConsoleAppender.SYSTEM_OUT));
    logger.info("hello");
}

이제야 “hello”가 들어간 로그 메시지가 콘솔에 찍히게 된다. 그런데 ConsoleAppender에게 콘솔에 쓰락 알려야 하는 점이 수상하다.

ConsoleAppender.SYSTEM.OUT 인수를 제거했더니 문제없이 콘솔에 “hello”가 찍힌다. 하지만 PatternLayOut을 제거했더니 또다시 출력 스트림이 없다는 오류가 뜬다.

문서를 자세히 읽어보니 기본 ConsoleAppender 생성자는 설정되지 않은(unconfigured) 상태라고 한다. 이는 당연하지도 유용하지도 않으며 log4j의 버그이거나 일관성 부족으로 여겨진다.

 

이와 같은 방식으로 문서를 읽어보고 테스트를 돌린 끝에 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 경계 인터페이스를 몰라도 된다.

 

 

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

학습 테스트에 드는 비용은 없다. 오히려 필요한 지식만 확보하는 손쉬운 방법이다. 학습 테스트는 이해도를 높여주는 정확한 실험이다. 패키지 새 버전이 나온다면 학습 테스트를 돌려 차이가 있는지 확인하여 패키지가 예상대로 도는지 검증한다. 학습 테스트를 이용한 학습이 필요하든 그렇지 않든, 실제 코드와 동일한 방식으로 인터페이스를 사용하는 테스트 케이스가 필요하다. 이런 경계 테스트가 있다면 패키지의 새 버전으로 이전하기 쉬워진다. 그렇지 않다면 낡은 버전을 필요 이상으로 오랫동안 사용하려는 유혹에 빠지기 쉽다.

 

 

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

저자의 무선통신 시스템에 들어갈 소프트웨어 개발에 참여한 사례다. 소프트웨어에는 송신기(Transmitter)라는 하위 시스템이 있었는데, 여기에 대한 지식이 거의 없었다. 송신기 시스템을 책임진 사람들은 인터페이스도 정의하지 못한 상태였다. 이때 프로젝트 지연을 원치 않았기에 송신기 하위 시스템과 아주 먼 부분부터 작업하기 시작했다.

송신기 모듈에게 원하는 기능은 지정한 주파수를 이용해 이 스트림에서 들어오는 자료를 아날로그 신호로 전송하는 것이었다. 송신기 시스템이 API를 설계하지 않아 구체적인 방법은 몰랐기 때문에 구현을 나중으로 미룰 수밖에 없지만 인터페이스는 먼저 정의할 수 있다. 인터페이스를 정의하면 간단한 클래스를 만들어 인터페이스를 구현할 수 있게 되며, 예측 가능한 기능을 구현할 수 있다.

public interface Transimitter {
    public void transmit(SomeType frequency, OtherType stream);
}

public class FakeTransmitter implements Transimitter {
    public void transmit(SomeType frequency, OtherType stream) {
        // 구현이 되기 전까지 사용할 테스트용 구현체
    }
}

// 외부 API
public class RealTransimitter {
    // 캡슐화된 구현
}

public class TransmitterAdapter extends RealTransimitter implements Transimitter {
    public void transmit(SomeType frequency, OtherType stream) {
        // Adapter 패턴으로 API 캡슐화
        // RealTransimitter(외부 API)를 사용해 실제 로직 구현
        // 외부 API 변경 시 수정할 코드를 한 곳으로 한정
    }
}

public class CommunicationController {
    public void fakeSomeMethod() { // 외부 API가 제공되기 전에 사용될 메서드
        Transmitter transmitter = new FakeTransmitter();
    	transmitter.transmit(someFrequency, someStream);
    }

    public void realSomeMethod() { // 외부 API가 제공된 후 사용될 메서드
        Transmitter transmitter = new TransmitterAdapter();
    	transmitter.transmit(someFrequency, someStream);
    }
}

위 코드는 Adapter 패턴으로 API 사용을 캡슐화하여 API가 바뀔 때 수정할 코드를 한 곳으로 모을 수 있다.

이와 같은 설계는 테스트도 아주 편하며 적절한 FakeTransmitter 클래스를 사용하면 CommunicationsController 클래스를 테스트할 수 있다. Transmitter API 인터페이스가 만들어지면 해당 API를 올바르게 사용하는지 테스트할 수도 있다.

 

 

깨끗한 경계

경계에서는 흥미로운 일이 많이 벌어진다. 변경이 대표적인 예다. 소프트웨어의 설계가 우수하다면 변경하는데 많은 비용이 필요하지 않다. 통제하지 못하는 코드를 사용할 때는 향후 변경 비용이 커지지 않도록 각별히 주의해야 한다.

경계에 위치하는 코드는 깔끔히 분리한다. 이에 대한 기대치를 정의하는 테스트 케이스도 작성한다. 통제가 불가능한 외부 패키지에 의존하는 대신 통제가 가능한 우리 코드에 의존하는 편이 훨씬 좋다.

외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리하자. Map과 같이 새로운 클래스로 경계를 감싸거나 Adapter 패턴을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환한다. 코드의 가독성, 일관성, 유지보수성이 용이해질 것이다.

 

https://link.coupang.com/a/65lvH

 

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

COUPANG

www.coupang.com

 

https://link.coupang.com/a/65lz4

 

클린 코드 + 클린 아키텍처 세트

COUPANG

www.coupang.com

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."

반응형