클린코드 8장 - 경계

박진형·2022년 4월 23일
0

CleanCode

목록 보기
5/8

8장

우리는 때로는 오픈 소스를 이용하거나, 사내에서 다른 팀이 제공하는 컴포넌트를 이용하기도 한다.
우리는 어떤 식으로든 이 외부 코드를 깔끔하게 통합을 해야한다. 이 장에서는 소프트웨어 경계를 깔끔하게 처리하는 기법과 기교를 설명하고 있다.

외부 코드 사용하기

  • 패키지 제공자나 프레임워크 제공자는 더 많은 사용자들이 구매(사용)하길 원하니 적용성을 최대한 넓히려고 애쓴다.
  • 사용자는 자신의 요구에 집중하는 인터페이스를 바란다.

위 두 가지의 간극으로 인해 시스템 경계에서 문제가 생길 소지가 많다.

Map

자바에서의 Map은 다양한 인터페이스로 수많은 기능을 제공한다. 기능성, 유연성 모두 뛰어나지만 그 위험도 크다.

예를 들어 Map 인스턴스를 생성해 이곳 저곳에 넘기는 상황이라고 가정을하면. 어느 곳에서도 Map인스턴스가 clear()함수를 사용하는 것을 막을 수 없다. Map 사용자라면 누구나 Map 내용을 지울 권한이 있다는 말이다.

또 다른 예로, 설계시 Map에 특정 객체 유형만 저장하기로 결정했다고 쳤을 때, Map은 기본적으로 Object를 받기 때문에 유형을 제한하지 않는다.

그저 실수없이 올바르게 코드를 짰다고 기도할 수 밖에 없는 것이다.

예제 코드

아래는 Sensor라는 객체를 담는 Map의 사용 예다.

  • 이 코드는 Map이 반환하는 Object를 올바른 유형으로(Sensor) 변환할 책임은 Map을 사용하는 클라이언트에게 있다.
  • 코드는 동작하지만 깨끗한 코드라고 보기 어렵다.
  • 의도가 제대로 드러나지 않는다.
Map sensors = new HashMap();

sensor s = (Sesnsor)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가 출력을 콘솔로 출력을 한다고 지정을 해주다니 (SYSTEM_OUT) 이상하다. 지워보지만 여전히 잘 출력된다.
하지만 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");
	}
}

지금 까지 콘솔 로거를 초기화 하는 방법을 익혔다. 이제 모든 지식을 로거 클래스로 캡슐화한다. 그러면 나머지 프로그램은 log4j 경계 인터페이스를 몰라도 된다.

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

  • 학습 테스트는 필요한 지식만 확보하는 손쉬운 방법이다.
  • 학습 테스트는 이해도를 높여주는 정확한 실험이다.
  • 학습 테스트는 공짜 이상이다. 투자하는 노력보다 얻는 성과가 더 크다.
    • 패키지가 변경되더라도 곧장 테스트 코드를 실행해 당장 우리에게 영향이 있는 변경이 일어났는지 알 수 있다.

학습 테스트를 이용한 학습이 필요하든 그렇지 않든, 실제 코드와 동일한 방식으로 인터페이스를 사용하는 테스트 케이스는 필요하다.

  • 패키지의 새 버전으로 이전하기 쉬워진다.

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

저자는 무선통신 시스템 개발을 참여했을 때 얘기를 해준다.
'송신기' 하위 시스템 담당자가 있고, 저자는 송신기 시스템과 협력하는 또다른 어떤 하위 시스템을 만드는 상황인듯하다.
송신기 하위 시스템은 아직 인터페이스도 정의하지 못한 상태에 프로젝트 지연을 원하지 않았기 때문에 저자는 송신기 시스템과 아주 먼 부분부터 작업을 하기 시작했다.

대략 "지정한 주파수를 이용해 이 스트림에서 들어오는 자료를 아날로그 신호로 전송하라." 라는 추상적인 방향성만 갖고 작업을 하게 됐다.

송신시 시스템은 아직 API설계를 하지 않았고 저자쪽 코드를 진행하고자 자체적으로 인터페이스를 정의했다.

  • Transmitter라는 클래스를 만든 후 transmit이라는 메서드를 추가했다.

    • Transmit 인터페이스는 저자쪽이 원하는 방식(주파수와 자료 스트림을 입력으로 받기)으로 받았다.
  • 송신시 API에서 CommuicationController를 분리 했다.

    • 필요한 인터페이스만을 정의했으므로 코드는 깔끔했다.
  • 송신기 팀이 API를 정의한 후에는 TransmitterAdapter를 구현해 간극을 메운다.

    • Adapter 패턴으로 변경사항에 대한 수정할 코드를 한 곳에 모은다.
  • 테스트도 편하다. FakeTransmitter 클래스를 사용하면 CommunicationsController 클래스를 테스트 할 수 있다.

    • Transmitter API 인터페이스가 나온 다음 경계 테스트 케이스를 생성해 우리가 API를 올바로 사용하는지 테스트할 수 있다.

깨끗한 경계

경계에선는 많은 흥미로운 일이 벌어진다. 변경이 대표적인 예.

  • 설계가 우수하다면 변경이 쉽다.
  • 통제하지 못하는 코드를 사용할 때에는 주의해야한다.
    • 너무 많은 투자를 하거나
    • 향후 변경 비용이 지나치게 커지지 않도록 주의해야한다.
  • 경계에 위치하는 코드는 깔끔하게 분리한다. 또한 기대치를 정의하는 테스트 케이스도 작성한다.
  • 외부 패키지를 세세하게 알 필요 없다.
  • 통제가 불가능한 외부 패키지에 의존하는 대신 통제가 간으한 우리 코드에 의존하는 편이 낫다.
  • 외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리하자.(Map, Adpater)

0개의 댓글