[파이썬으로 살펴보는 아키텍처 패턴] 챕터 3 막간: 결합과 추상화

2024. 3. 23. 23:24·책 & 스터디

결합 : B 컴포넌트가 깨지는게 두려워서 A 컴포넌트를 변경할 수 없을 경우 A와 B가 결합되어 있다고 한다 
응집 : 지역적인 결합으로 코드 전체가 맞물려서 잘 돌아가는 것은 결합된 요소들 사이에 응집이 있다고 한다 

전역적인 결합은 코드 변경과 유지/보수를 힘들게 하기 때문에 추상화를 통해 세부사항을 감춰 시스템 내 결합 정도를 줄일 수 있다

추상화(Abstraction) - 복잡한 시스템, 모델, 또는 실세계의 현상에서 핵심적인 개념 또는 기능만을 도출하여 단순화하는 과정 또는 그 결과

추상적인 상태는 테스트를 더 쉽게 해준다

예제로 두 파일 디렉터리를 동기화 하는 코드 작성. 두 파일 디렉터리를 원본과 사본이라고 칭함

  • 원본에 파일이 있지만, 그 파일과 동일하게 사본에 있지만 이름이 다른 경우 → 사본에 있는 이름을 원본처럼 바꿔야 함
  • 원본에 파일이 있지만, 사본에는 없는 경우 → 사본으로 복사해야 함
  • 원본에 파일이 없는 경우 → 사본도 삭제

1, 3 요구사항은 단순하지만 2번의 경우 해시를 이용해야함 

BLOCKSIZE = 65536


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

디렉토리 동기화 알고리즘(sync.py)을 작성하면 다음과 같다

import hashlib
import os
import shutil
from pathlib import Path


def sync(source, dest):
    # 원본 폴더 자식들을 순회하면서 파일 이름과 해시 사전을 만든다 
    source_hashes = {}
    for folder, _, files in os.walk(source):
        for fn in files:
            source_hashes[hash_file(Path(folder) / fn)] = fn

    seen = set()  # 사본폴더에서 찾은 파일을 추적한다 

    # 사본 폴더 자식들을 순회하면서 파일이름과 해시를 얻는다 
    for folder, _, files in os.walk(dest):
        for fn in files:
            dest_path = Path(folder) / fn
            dest_hash = hash_file(dest_path)
            seen.add(dest_hash)

            # 사본에는 있지만 원본에 없는 파일을 찾으면 삭제한다
            if dest_hash not in source_hashes:
                dest_path.remove()

            # 사본에 있는 파일이 원본과 다른 이름이라면 
            # 사본 이름을 올바른 이름(원본이륾)으로 바꾼다 
            elif dest_hash in source_hashes and fn != source_hashes[dest_hash]:
                shutil.move(dest_path, Path(folder) / source_hashes[dest_hash])

    # 원본에는 있지만 사본에 없는 모든 파일을 사본으로 복사한다 
    for source_hash, fn in source_hashes.items():
        if source_hash not in seen:
            shutil.copy(Path(source) / fn, Path(dest) / fn)

 여기에 테스트를 코드를 작성한다면 어떻게 될까?

def test_when_a_file_exists_in_the_source_but_not_the_destination():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "I am a very useful file"
        (Path(source) / "my-file").write_text(content)

        sync(source, dest)

        expected_path = Path(dest) / "my-file"
        assert expected_path.exists()
        assert expected_path.read_text() == content

    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)


def test_when_a_file_has_been_renamed_in_the_source():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "I am a file that was renamed"
        source_path = Path(source) / "source-filename"
        old_dest_path = Path(dest) / "dest-filename"
        expected_dest_path = Path(dest) / "source-filename"
        source_path.write_text(content)
        old_dest_path.write_text(content)

        sync(source, dest)

        assert old_dest_path.exists() is False
        assert expected_dest_path.read_text() == content

    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)
  • 위의 문제점은 두가지 간단한 경우를 테스트 하는데 너무 많은 준비과정이 필요함
  • sync 함수에 도메인 로직과 I/O 코드가 긴밀하게 결합되어 pathlib, shutil, hashlib 같은 모듈을 호출하지 않고 는 디렉터리 차이 판단 알고리즘을 실행 할 수 없음 

올바른 추상화 선택

코드를 읽어보면 세가지 서로 다른 책임으로 나눌 수 있음 

  1. 파일 시스템 정보를 얻고, 파일 경로로부터 파일의 해시를 결정할 수 있음(원본과 사본 디렉토리에서 동일하게 작동)
  2. 파일 비교(새 파일인지, 이름이 변경된 파일인지, 중복된 파일인지)
  3. 원본과 사본을 일치시키기 위해, 파일을 옮기거나 삭제함

이 세가지 책임에 대해 더 단순화한 추상화를 적용하면 지저분한 세부사항을 감추고 로직에만 초점을 맞출 수 있음

선택한 추상화 구현

시스템에서 트릭이 적용된 부분을 분리해서 격리하고, 실제 파일 시스템 없이도 테스트 할 수 있게 하는 것이 목표. 
외부 상태에 대해 아무 의존성이 없는 코드의 '핵'을 만들고 외부 세계를 표현하는 입력에 대해 핵이 어떻게 반응하는지 살펴본다 

세 부분으로 코드 분리 (sync.py)

def sync(source, dest):
    # 명령형 셸 1단계: 입력 수집
    source_hashes = read_paths_and_hashes(source)
    dest_hashes = read_paths_and_hashes(dest)

    # 명령형 셸 2단계: 함수형 핵 호출
    actions = determine_actions(source_hashes, dest_hashes, source, dest)

    # 명령형 셸 3단계: 출력 적용 
    for action, *paths in actions:
        if action == "COPY":
            shutil.copyfile(*paths)
        if action == "MOVE":
            shutil.move(*paths)
        if action == "DELETE":
            os.remove(paths[0])

애플리케이션의 I/O부분을 격리하는 read_paths_and_hashes 함수 

def read_paths_and_hashes(root):
    hashes = {}
    for folder, _, files in os.walk(root):
        for fn in files:
            hashes[hash_file(Path(folder) / fn)] = fn
    return hashes

비즈니스 로직만 수행하는 determin_actions 함수 

def determine_actions(source_hashes, dest_hashes, source_folder, dest_folder):
    for sha, filename in source_hashes.items():
        if sha not in dest_hashes:
            sourcepath = Path(source_folder) / filename
            destpath = Path(dest_folder) / filename
            yield "COPY", sourcepath, destpath

        elif dest_hashes[sha] != filename:
            olddestpath = Path(dest_folder) / dest_hashes[sha]
            newdestpath = Path(dest_folder) / filename
            yield "MOVE", olddestpath, newdestpath

    for sha, filename in dest_hashes.items():
        if sha not in source_hashes:
            yield "DELETE", dest_folder / filename
  • 비즈니스 핵심 로직과 저수준 I/O 세부 사항의 결합을 분리했기 때문에 쉽게 코드의 핵 부분을 테스트 할 수 있음.
  • 이제 고수준인 sync함수가 아닌 더 저수준의 determine_actions 함수를 테스트 할 수 있음 

의존성 주입과 가짜를 사용해 에지투에지 테스트

더 큰 덩어리를 한번에 테스트해야 할 때, 작성하기 어려운 엔드투엔드 테스트보다 가짜 I/O를 사용하는 에지투에지 테스트를 작성 할 수 있다 

def sync(source, dest, filesystem=FileSystem()):
    source_hashes = filesystem.read(source)
    dest_hashes = filesystem.read(dest)

    for sha, filename in source_hashes.items():
        if sha not in dest_hashes:
            sourcepath = Path(source) / filename
            destpath = Path(dest) / filename
            filesystem.copy(sourcepath, destpath)

        elif dest_hashes[sha] != filename:
            olddestpath = Path(dest) / dest_hashes[sha]
            newdestpath = Path(dest) / filename
            filesystem.move(olddestpath, newdestpath)

    for sha, filename in dest_hashes.items():
        if sha not in source_hashes:
            filesystem.delete(dest / filename)
  • 최상위 함수는 이제 reader와 filesystem이라는 두 가징 의존성을 노출
  • reader를 호출해 파일이 있는 사전 만듦
  • filesystem을 호출해 변경사항 적용 

DI를 사용하는 테스트 

class FakeFilesystem:
    def read(self, path):
        return self.path_hashes[path]

    def copy(self, src, dest):
        self.actions.append(('COPY', src, dest))

    def move(self, src, dest):
        self.actions.append(('MOVE', src, dest))

    def delete(self, dest):
        self.actions.append(('DELETE', dest))


def test_when_a_file_exists_in_the_source_but_not_the_destination():
    fakefs = FakeFilesystem({
        '/src': {"hash1": "fn1"},
        '/dst': {},
    })
    sync('/src', '/dst', filesystem=fakefs)
    assert fakefs.actions == [("COPY", Path("/src/fn1"), Path("/dst/fn1"))]


def test_when_a_file_has_been_renamed_in_the_source():
    fakefs = FakeFilesystem({
        '/src': {"hash1": "fn1"},
        '/dst': {"hash1": "fn2"},
    })
    sync('/src', '/dst', filesystem=fakefs)
    assert fakefs.actions == [("MOVE", Path("/dst/fn2"), Path("/dst/fn1"))]
  • 이 접근 방법은 테스트가 프로덕션 코드에 사용되는 함수와 완전히 같은 함수에 작용한다는 것이 장점 
  • 단점은 상태가 있는 요소들을 명시적으로 표현해 전달하면서 작업을 수행해야 한다는 점 

패치를 사용하지 않는 이유

이 책에서는 mock을 사용하지 않으며 대신 코드베이스의 책임을 명확히 식별하고, 각 책임을 테스트 더블로 대치하기 쉬운 작은 객체에 집중한다 

  • 사용 중인 의존성을 다른 코드로 패치하면 코드를 단위 테스트 할 수 있지만 설계를 개선하는데 아무 역할하지 못함 
  • mock을 사용한 테스트는 코드베이스의 구현 세부사항에 더 밀접히 결합, 코드와 테스트 사이에 이런 식의 결합이 일어나면 경험상 테스트가 더 깨지기 쉬워지는 경향이 있음 
  • mock을 과용하면 테스트 스위트가 너무 복잡해져서 테스트 코드를 보고 테스트 대상 코드의 동작을 알아내기가 어려워짐 
저작자표시 (새창열림)

'책 & 스터디' 카테고리의 다른 글

[파이썬으로 살펴보는 아키텍처 패턴] 챕터 5 높은 기어비와 낮은 기어비의 TDD  (2) 2024.03.31
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 4 첫번째 유스 케이스: 플라스크 API와 서비스 계층  (1) 2024.03.24
데이터 메시를 읽고  (1) 2024.02.25
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 2 저장소 패턴  (0) 2023.07.30
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 1 도메인 모델링  (0) 2023.07.30
'책 & 스터디' 카테고리의 다른 글
  • [파이썬으로 살펴보는 아키텍처 패턴] 챕터 5 높은 기어비와 낮은 기어비의 TDD
  • [파이썬으로 살펴보는 아키텍처 패턴] 챕터 4 첫번째 유스 케이스: 플라스크 API와 서비스 계층
  • 데이터 메시를 읽고
  • [파이썬으로 살펴보는 아키텍처 패턴] 챕터 2 저장소 패턴
haong_
haong_
블로그 이전합니다 >> www.hajinnote.me
  • haong_
    일단 시작하는 블로그
    haong_
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 데이터 엔지니어링
      • 이슈 해결 일지
      • 개발 환경 및 운영
        • Kubernetes
      • CS
        • 운영체제
        • 데이터베이스시스템
      • 프로그래밍
      • 알고리즘
      • 머신러닝
        • 통계학
        • 딥러닝
        • 데이터 분석
        • 논문
        • 프로젝트
      • 회고
      • 책 & 스터디
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    데이터메시
    data mesh
    이
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
haong_
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 3 막간: 결합과 추상화
상단으로

티스토리툴바