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

    2024. 3. 23.

    by. haong_

    결합 : 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을 과용하면 테스트 스위트가 너무 복잡해져서 테스트 코드를 보고 테스트 대상 코드의 동작을 알아내기가 어려워짐 

    댓글