Today I../Today I Read

[Clean code] Chapter 08. 경계

HJChung 2022. 2. 11. 00:26
Clean Code 클린 코드 - 로버트 C. 마틴 저
를 읽고, clean code 해설 강의를 통해 제가 이해한 바를 정리한 글입니다. 

 

 

여기서 말하는 경계라는 것은 우리 코드와 외부 코드의 구분이다. 

 

오픈소스, 라이브러리를 사용하지 않는 프로젝트는 없다. 또한 개발을 하다 보면 시스템에 들어가는 SW를 직접 개발 하기보다 외부 코드를 우리 코드에 깔끔하게 통합해야 하는 일이 생긴다. 

 

또한 외부 코드 사용시 해당 코드의 인터페이스 제공자와 사용자는 이런 경계에 있는 사람들이다. 

인터페이스 제공자는 자신의 코드의 적용성을 최대한 넓히고자 하는 반면 사용자는 자신의 요구에 집중하는 인터페이스를 원한다. 이런 입장 차이가 시스템 경계에서 문제를 야기시킬 수 있다. 

 

그래서 이 때 우리 코드와 외부 코드의 경계를 어떻게 잘 지어서 깔끔하게 통합시킬 것 인가에 대해서 Clean code에서 제안하는 바를 정리해보고자 한다. 

 

1. 외부 코드와 적절한 경계 짓기

캡슐화(Encapsulation)이란, 객체의 실제 구현을 외부로부터 감추는 방식이다. 이를 통해  우리 코드를 보호하면서 경계 짓기를 할 수 있다. 

우리 서비스의 객체에서 public 으로 구현된 것은 외부에서 호출할 수 있다. 그래서 우리 객체에서 외부에 노출해야 하는 부분만 public으로 구현하여 외부와 상호작용하게 하고, 나머지 중요한 비지니스 로직이나 데이터들은 private으로 감출 수 있는데 이렇게 캡슐화를 실현할 수 있다. 

예시

Sensor라는 값을 관리하고자 한다.  Sensor Id와 Sensor 객체로 저장되기때문에 Map 자료구조를 사용해야 한다. 그리고 이 Sensor 값은 외부에서 호출되어 사용될 수 있다. 

먼저 Map을 그대로 Sensor에 사용한 경우이다. 

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

이처럼 Map을 그대로 사용하게 되면 Map 에서 제공하는 모든 public method들도 Sensor가 그대로 제공하게 된다는 문제가 생긴다. 

 

이 경우 어떻게 캡슐화를 잘 활용해서 외부와 Sensor에 관련된 우리 코드의 경계를 잘 지을 수 있을까?

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

이렇게 외부에서 원하는 sensor를 sensorId로 가져오는 것만 getById라는 method로 노출하고, 거기서 Map의 get만 사용해서 원하는 기능만 외부에 공개할 수 있다. 

 

2. 외부 코드와 호환하는 방법

개발을 하다보면 확장성을 고려하여 우리 코드에서 필요한 외부 라이브러리(오픈 소스 등)를 사용 가능하도록 병합하는 일이 생긴다. 

이때 외부 코드를 호출할 때, 이미 구현된 우리 코드에 맞는 방식으로 사용할 수 있으면 좋을 것이다. 

이때 adapter를 사용해서 외부 코드를 호출할 때, 우리가 정의한 인터페이스 대로 호출할 수 있도록 하는 패턴이 Adapter 패턴이다. 

https://refactoring.guru/design-patterns/adapter

  1. The Client is a class that contains the existing business logic of the program.
  2. The Client Interface describes a protocol that other classes must follow to be able to collaborate with the client code.
  3. The Service is some useful class (usually 3rd-party or legacy). The client can’t use this class directly because it has an incompatible interface.
  4. The Adapter is a class that’s able to work with both the client and the service: it implements the client interface, while wrapping the service object. The adapter receives calls from the client via the adapter interface and translates them into calls to the wrapped service object in a format it can understand.
  5. The client code doesn’t get coupled to the concrete adapter class as long as it works with the adapter via the client interface. Thanks to this, you can introduce new types of adapters into the program without breaking the existing client code. This can be useful when the interface of the service class gets changed or replaced: you can just create a new adapter class without changing the client code.
  • Python
    •  main.py: Conceptual example
      class Target:
          """
          The Target defines the domain-specific interface used by the client code.
          """
      
          def request(self) -> str:
              return "Target: The default target's behavior."
      
      
      class Adaptee:
          """
          The Adaptee contains some useful behavior, but its interface is incompatible
          with the existing client code. The Adaptee needs some adaptation before the
          client code can use it.
          """
      
          def specific_request(self) -> str:
              return ".eetpadA eht fo roivaheb laicepS"
      
      
      class Adapter(Target):
          """
          The Adapter makes the Adaptee's interface compatible with the Target's
          interface via composition.
          """
      
          def __init__(self, adaptee: Adaptee) -> None:
              self.adaptee = adaptee
      
          def request(self) -> str:
              return f"Adapter: (TRANSLATED) {self.adaptee.specific_request()[::-1]}"
      
      
      def client_code(target: Target) -> None:
          """
          The client code supports all classes that follow the Target interface.
          """
      
          print(target.request(), end="")
      
      
      if __name__ == "__main__":
          print("Client: I can work just fine with the Target objects:")
          target = Target()
          client_code(target)
          print("\n")
      
          adaptee = Adaptee()
          print("Client: The Adaptee class has a weird interface. "
                "See, I don't understand it:")
          print(f"Adaptee: {adaptee.specific_request()}", end="\n\n")
      
          print("Client: But I can work with it via the Adapter:")
          adapter = Adapter(adaptee)
          client_code(adapter)
       
    • Output.txt: Execution result
      Client: I can work just fine with the Target objects:
      Target: The default target's behavior.
      
      Client: The Adaptee class has a weird interface. See, I don't understand it:
      Adaptee: .eetpadA eht fo roivaheb laicepS
      
      Client: But I can work with it via the Adapter:
      Adapter: (TRANSLATED) Special behavior of the Adaptee.

 

3. 외부 코드를 학습하는 방법

외부에서 가져온 라이브러리를 사용하고 싶다면 어떻게 시작하면 좋을까?

보통 해당 라이브러리의 문서를 읽으며 우리쪽 코드를 작성해서 예상대로 동작하는지 하나하나 print 해서 확인해가면서 시작하지 않을까?

(나는 그랬고, 그러고 있었다..)

그러다가 예상 밖의 결과가 나오거나 버그가 발생하면 내가 작성한 우리쪽 코드의 문제인지, 해당 라이브러리의 문제인지 디버깅을 해야 하는데 쉽지 않다. (나도 이런 상황도 겪고 있다.)

 

책에서는 이런 경우 곧바로 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성 하여 외부 코드를 익히는 방식인 Learning Test 방식을 추천한다. 

 

Learning Test는 한번도 해보질 않아서 잘 와닿진 않지만, 외부 코드를 사용하면서 기대하거나 우려하는 바, 그리고 문서를 통해 이해한 바를 test case로 작성해보면서 

  1. 작성하고 실행시켜 보면서 외부 코드의 이해가 생기고
  2. 해당 외부 라이브러리에 새 버전이 나온다면 이 테스트를 통해서 우리 코드와 여전히 잘 호환되는지, 어디에 차이가 있는지 확인
  3. 이 테스트를 통해 예상대로 동작하는지 검증

하는 것을 말하는게 아닐까라고 이해하였다.  

 


결론적으로 정리하면

경계에 위치하는 외부 코드는 우리 코드와 깔끔하게 분리하는 것이 좋다. 이 방법으로 캡슐화를 배웠다. 

그리고 기대치를 정의하는 테스트 케이스도 작성하는 것을 추천한다. 이 방법을 Learning Test라고 하는 것도 배웠다. 

 

실무에서 우리 코드와 외부 코드 인터페이스와 호환 가능하도록 wrapper를 구현해야 하는 일이 있었는데,  '외부 코드를 읽고 세세히 알아야 하는 게 아닌가?'라는 질문을 한 적이 있다. 그때  '그 보다는 해당 외부 코드의 인터페이스 output이 뭔지부터 쭉 파악하는 걸 추천한다.'라는 식의 답변을 들었다.

그때는 이해가 잘 되지 않았지만 하다 보니, 외부 코드를 다 읽는다고 해도(읽을 수 없을뿐더러) 그 코드에서 통제할 수 있는 건 없고, 

외부 코드의 인터페이스와 우리 코드의 간극을 어떻게 잘 호환가능하게 맞출 것인지 통제 가능한 우리 코드에 집중하는 게 맞았다. 

 

우리 코드에서 외부 패키지를 세세하게 알아야 할 필요가 없고, 대신 우리 코드에서 어떻게 외부 패키지를 잘 핸들링 할 수 있을 것인가를 고민하는 것이 훨씬 좋다
- Clean code 8장 경계. 152p

 

는 것을 글로 한 번 더 확인하니 앞으로 해당 일의 방향에 조금 더 자신감이 생긴다. 

 

 

Reference

https://refactoring.guru/design-patterns/adapter

Adapter in Python