All Articles

파이썬으로 살펴보는 아키텍처 패턴 (3)

이 내용은 “파이썬으로 살펴보는 아키텍처 패턴” 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다.

3장은 해당 코드를 살펴봐주세요. 코드 링크

3장 결합과 추상화

어떤 컴포넌트가 깨지는 것을 두려워해서 다른 컴포넌트롤 못건들이면, 두 컴포넌트가 결합되어 있다고 말한다. 결합은 엮여있음을 의미한다.

그런데 지역적 결합은 ‘응집’이라고 표현한다.

전역적 결합은 코드를 ‘진흙 공’ 처럼 서로 뭉치게 만든다. 앱이 커지면 커질 수록 결합을 훨씬 빠르게 하기 때문에 시스템은 사실상 고착화된다.

따라서 추상화를 통해 세부사항을 감출 필요가 있다.

3.1 추상적인 상태는 테스트를 더 쉽게 한다

따라가보자…

요구사항

두 파일 디렉토리를 동기화하는 코드를 작성하고자 한다. 각 디렉토리를 원본, 사본이라고 하자.

해야할일

  1. 원본에 파일이 있지만 사본에 없으면 파일위치를 원본 → 사본 으로 옮긴다
  2. 원본에 파일이 있지만 사본에 있는(내용이 같은)파일과 이름이 다르면 사본의 파일 이름을 원본 파일이름과 같게 변경한다
  3. 사본에 파일이 있지만 원본에 없다면 사본의 파일을 삭제한다

파일 해시코드 (핵심로직)

import hashlib

BLOCK_SIZE = 65_536

def hash_file(path):
    hasher = hashlib.sha1()
    with path.open("rb") as file:
        buf = file.read(BLOCK_SIZE)
        with buf:
            hasher.update(buf)
            buf = file.read(BLOCK_SIZE)
    return hasher.hexdigest()

3.1.1 1차 코드작성안

처음부터 문제를 풀 때는 보통 간단한 구현을 짜고, 이걸 가지고 리팩토링 한다.

가장 작은 부분부터 일단 만들면서 더 풍부하고, 더 좋은 해법의 설계를 가져가는 것을 반복한다.

바꿔말하면 처음 코드는 보통 구지단 말이다. 처음부터 못해도 좋다. 빠르게 이터레이션을 가져가면서 좋은 코드로 바꾸는 연습을 하자.

문제점?

  • 두 디렉토리 차이점 알아내기 라는 도메인 로직이 I/O 코드와 긴밀하게 “결합” 되어있다
    • pathlib, shutil, hashlib 을 다 써야함
    • 비록 퓨어 파이썬 라이브러리라고 하지만….
  • 테스트가 충분하지 않다
    • 테스트케이스가 모자라다 → 커버리지가 낮다
    • shutil.move() 가 잘못 사용중이다(!)
      • 버그를 찾으려면 테스트를 더 해야한다
  • 확장성이 없음. 아래 요구사항이 오면 코드를 싹 갈아야한다
    • 되는지 안 되는지만 알려주는 --dry-run 같은 기능을 추가하려면?
    • 원격서버와 동기화 해야한다면?
    • 클라우드 저장 장치와 동기화 해야한다면?

3.2 올바른 추상화 선택

테스트하기 쉽게 짜려면 생각을 다시 해보자.

  1. 요구사항의 어떤 부분을 코드화할지 생각한다

→ 파일 시스템의 어떤 기능을 코드에서 쓸지 생각해본다 2. 코드 내에는 3가지 뚜렷한 서로 다른 일이 일어남을 캐치한다. 즉, 코드의 책임을 찾는다 1. os.walk 을 사용해 시스템 정보 및 파일해시를 구한다 (원본, 사본 모두에서 처리) 2. 파일이 새 파일인지, 이름이 변경된 파일인지, 중복된 파일인지 정한다 3. 원본과 사본을 일치시키기 위해 파일 복사하거나, 옮기거나, 삭제한다.

세 가지 책임에 대해 단순화한 추상화(simplifying abstraction) 을 찾으려는 과정이다. 마치 인터페이스를 만드는 것 처럼… 개선해보자!

  1. 시스템 정보 및 파일 해시를 구하는 딕셔너리를 만들 때, 원본 및 사본의 모든 파일해시를 다 구하고 연산한다면?
  2. 두 번째, 세 번째 책임은 어떻게 해결할 것인가?

→ “무엇” 을 원하는가와 원하는 바를 “어떻게” 달성할 지를 분리하자. 1. 프로그램이 아래와 비슷한 명령 목록을 출력하도록 하자

    ```python
    ("COPY", "sourcepath", "destpath"),
    ("MOVE", "old", "new"),
    ```
    
2. 여기서 파일 시스템을 표현하는 두 딕셔너리를 입력받는 테스트 작성 가능
  1. … 그러면 아래와 같이 말을 바꿀 수 있다
    1. (이전) 어떤 주어진 실제 파일 시스템에 대해 함수를 실행하면 어떤 일이 일어나는지 검사하자
    2. (이후) 어떤 파일 시스템의 추상화에 대해 함수를 실행하면 어떤 추상화된 동작이 일어나는지 검사하자

3.3 선택한 추상화 구현

어려운 개발 도서들은 말은 좋지. 실제로 코드를 어떻게 짤까? 일단 목표를 다시 생각해보자.

  • 시스템에서 트릭이 적용된 부분을 분리해서 격리한다
  • 실제 파일 시스템 없이도 테스트가 되게 한다

외부 상태에 대해 의존성이 없는 코드의 ‘코어’를 만들고, 외부 세계를 표현하는 입력에 대해 이 코어가 어떻게 반응하는지 생각해보자1

step 1) 코드에서 로직과 상태가 있는 부분을 분리한다.

  1. 입력 수집
    1. 이 코드를 나눠서 원본, 사본의 경로와 해시를 모두 구했다
  2. 함수형 코어 호출
    1. if 구문이 여기서 갈릴 것이다
    2. 개별 테스트로 빼기도 쉽다
  3. 출력 적용
    1. 처음에 들어온 명령만 처리하면 된다.

이러면 큰 로직과 저수준 I/O의 의존성을 함수단위로 풀었다. 쉽게 코드의 코어를 테스트할 수 있다(determine_actions)!

전체를 테스트하려는 통합/인수테스트도 유지할 수도 있다만, 더 나아가 sync() 를 다듬어서 단위테스트를 겸해서 동시에 e2e 테스트까지 할 수도 있다. 이걸 책의 공동저자는 edge-to-edge 테스트라고 부른다.

3.3.1 의존성 주입과 가짜를 사용한 edge-to-edge 테스트

새 시스템을 짤 때는 위에서 언급한 추상화를 통한 구현을 하자. 어느 시점이 되면 시스템의 더 큰 덩어리를 한번에 테스트하고자 할 것이다.

저자는 이 때 한번에 테스트하되 가짜 I/O를 사용하는 류의 edge-to-edge 테스트를 추천한다.

요컨대, 어느 파일 시스템에서(filesystem) 액션을 취할지를 테스트하는 방법을 DI를 통한 테스트 더블로 처리할 수 있다.

3.3.2 mock.patch 를 쓰지 않는 이유

mock을 통한 monkey patching을 별로라고 하면서, 테스트 더블을 소개한다. 왜 안쓰는지를 설명하는 지에 대한 이유는 다음과 같다:

  1. 사용중인 의존성을 다른 코드로 패치하면, 테스트는 되지만 설계 개선에 도움되지 않는다.
  2. mock을 쓴 테스트는 코드 베이스의 세부사항에 더 밀접하게 결합된다. 코드베이스가 뭘 하는지를 모킹하기 때문에, 이 또한 결합이다 라고 한단하는 것 같다.
  3. 결국 코드 베이스를 알아야하니까 test suite을 보고 바로 이해하기 힘들어진다.

여기 내용은 유닛 테스팅 책을 좀 보고 다시 이해해야겠다… 여전히 모르겠다.

마무리

비즈니스 로직과 I/O 사이의 인터페이스를 단순화하는게 중요하다는 것을 배웠다. 올바른 추상화를 찾는 것은 어렵다. 아래는 올바른 추상화를 하기 위한 방법이다.

  1. 지저분한 시스템 상태를 표현할 수 있는 파이썬 객체가 있나? 있다면 이를 활용해 시스템의 상태를 반환하는 단일함수를 생각해보자.
  2. 시스템의 구성요소 중 어떤 부분에 선을 그을 수 있을까? 이 각각의 추상화 사이의 이음매(seams)를 어떻게 만들 수 있을까?
  3. 시스템의 여러 부분을 서로 다른 책임을 지니는 구성요소로 나누는 합리적인 방법은 무엇일까? 명시적으로 표현해야 하는 암시적인 개념은 무엇일까?
  4. 어떤 의존관계가 존재하는가? 핵심 비즈니스 로직은 무엇인가?

계속 연습하자… 계속…


  1. Gary Bernhardt 가 말한 Functional Core, Imperative Shell 이라는 접근방법이다. 상세한건 링크 참고

Published Apr 13, 2023

Non scholæ sed vitæ discimus.

his/him