Search

[파이썬 아키텍처 패턴] 3장

결합과 추상화

저장소 패턴은 저장소에 대한 추상화에 대한 내용입니다.
잠깐 결합에 대해서 생각을 해봅시다.
B 컴포넌트를 수정하면서 A 컴포넌트가 손상될 수 있다는 우려 때문에 A 컴포넌트를 변경하지 못하는 상황
⇒ 두 컴포넌트가 서로 결합되어 있다
지역적인 결합은 바람직
→ 코드가 서로 상호 작용하고, 한 컴포넌트가 다른 컴포넌트를 지원해, 시계 부품처럼 서로 잘 맞물려 돌아갑니다.
그러나 전역적인 결합은 문제가 될 수 있습니다. 코드를 변경하는 데 필요한 비용을 증가시키며, 결합이 많아지면 결국 코드를 전혀 변경할 수 없게 됩니다.
세부 사항을 감추는 추상화를 통해 시스템 내 결합도를 줄일 수 있습니다.
추상화는 대개 단순화하는 과정이므로, 시스템 사이에 추상화를 적용하면 의존성의 종류가 줄어듭니다.
예시
원본(source)과 사본(destination)으로 구성된 두 파일 디렉터리를 동기화하는 코드
다음 세 가지 조건을 만족하는 코드를 작성
1.
원본에 파일이 있지만 사본에 없는 경우, 원본에서 사본으로 파일을 복사합니다.
2.
원본에 파일이 있지만 사본의 같은 내용의 파일과 이름이 다른 경우, 사본의 파일 이름을 원본의 파일 이름과 같게 변경합니다.
a.
2번 조건이 상대적으로 복잡합니다.
b.
파일의 내용을 확인하고, 동일한 경우에 이름을 비교하고 수정해야 하기 때문입니다.
3.
사본에 파일이 있지만 원본에 없는 경우, 사본의 파일을 삭제합니다.
2번의 경우, 해당 파일을 읽고 나서 hashlib 모듈의 md5 또는 sha1 해시 함수를 사용하여 내용을 비교할 수 있습니다.
sha1 해시를 생성하는 코드
import hashlib import os import shutil from pathlib import Path BLOCKSIZE = 65536 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: os.remove(dest_path) # 사본에 있는 파일이 원본과 다른 이름이면 원본 이름으로 바꾼다. elif dest_hash in source_hashes and fn != source_hashes[dest_hash]: shutil.move(dest_path, Path(folder) / source_hashes[dest_hash]) # 원본에는 있지만 사본에는 없는 모든 파일들을 사본으로 복사한다. for src_hash, fn in source_hashes.items(): if src_hash not in seen: shutil.copy(Path(source) / fn, Path(dest) / fn) def hash_file(path): hasher = hashlib.sha1() with open(path, "rb") as file: buf = file.read(BLOCKSIZE) while buf: hasher.update(buf) buf = file.read(BLOCKSIZE) return hasher.hexdigest() sync('source', 'destination')
Python
복사
1.
파일 동기화를 위해 주요 로직이 수행되는 sync 함수
a.
원본 폴더의 모든 파일들을 순회하면서 해시 값 생성 및 저장하는 부분
b.
사본 폴더의 모든 자식들을 순회하면서 해시 값 생성 및 저장하는 부분
c.
각각 조건을 비교하여 그에 대한 행위를 수행하는 부분(삭제, 이름수정, 복사)
2.
해당 파일의 해시 값을 생성해주는 hash_file 함수
… 길다..
시나리오를 요약하면 현재 상황은 프로그램의 비지니스 로직과 저수준 I/O 세부 사항이 얽혀 있는 상태
딱 봐도 시나리오가 너무 많아서 테스트하기 어렵다
위 코드를 테스트하기 쉽도록 다시 작성
3가지 책임 → 단순화된 추상화
1.
os.walk 함수를 통해 파일 시스템 정보를 얻고, 파일의 해시 값을 생성한다. 이는 원본/사본 디렉터리에서 모두 비슷하게 수행.
a.
원본/사본과 각각의 해시 값 추상화
i.
딕셔너리를 사용하여 추상화
source_files = {'hash1': 'path1', 'hash2': 'path2'} dest_files = {'hash1': 'path1', 'hash2': 'path2'}
JavaScript
복사
2.
파일이 새 파일인지, 이름이 변경된 파일인지, 중복된 파일인지 결정
3.
원본과 사본을 일치시키기 위해 파일을 복사하거나 옮기거나 삭제
a.
무엇을 원하는가?
b.
원하는 바를 어떻게 달성할지?
("COPY", "sourcepath", "destpath") ("MOVE", "old", "new)
JavaScript
복사
어떠한 주어진 실제 파일 시스템에 대해 함수를 실행하면 어떤 일이 일어나는지 검사 → 어떤 주어진 파일 시스템의 추상화에 대해 함수를 실행하면 어떤 추상화된 동작이 일어나는지 검사
def test_when_a_file_exists_in_the_source_but_not_the_destination(): src_hashes = {'hash1': 'fn1'} dst_hashes = {} actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst')) assert list(actions) == [('copy', Path('/src/fn1'), Path('/dst/fn1'))] def test_when_a_file_has_been_renamed_in_the_source(): src_hashes = {'hash1': 'fn1'} dst_hashes = {'hash1': 'fn2'} actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst')) assert list(actions) == [('move', Path('/dst/fn2'), Path('/dst/fn1'))]
Python
복사
추상화된 함수와 그에 따르는 추상화된 예상 동작을 비교하기만 하면 됨
위의 외부 동작과 상태에 의존성을 가지지 않는 코드의 핵을 만들자
code
프로그램의 비지니스 로직과 저수준 I/O 세부 사항 사이의 복잡성을 해결했기 때문에, 코드의 핵심 부분을 쉽게 테스트할 수 있게 됨.
테스트 코드는 주요 진입 함수인 sync() 대신 더 하위 수준의 함수인 determine_actions()를 테스트하게 됨
sync()가 매우 단순하기 때문에, 이러한 형태의 엣지 투 엣지 테스트로 충분하다고 판단
sync 함수를 갈아끼우는 것도 가능해짐
그렇다면, 본인은 레포지토리, 서비스, 컨트롤러 등으로 레이어를 나눠서 추상화를 했는데 왜 테스트하기 힘들었던 경험이 있나?
그렇다면 잘못을 인정하는 편이 부인하는 것보다 빠르다.
추상화를 잘못 수행했고, 테스트 대상을 엔드 투 엔드하게 잡았을 확률이 높기 때문.

참고

mockmonkey patching을 사용하면 외부 의존성을 격리할 수 있지만, 테스트가 불필요하게 복잡해질 수 있습니다.
Pytest 환경에서는 mocker.patch가 코드 스멜로 간주될 수 있습니다
1.
단위 테스트는 수행할 수 있지만 설계를 개선하는 데는 도움이 되지 못한다. mock.patch를 사용하면 코드가 --dry-run 플래그에 대해 동작하지 않고, FTP 서버에 접속해 동작하지 못한다.
2.
mock 을 사용한 테스트는 코드베이스의 구현 세부 사항에 더 밀접히 결합된다. mock 이 shutil.copy 에 올바른 인수를 넘겼는가 등 여러 요소 사이의 상호검증을 하기 때문이다.
3.
mock 을 과용하면 테스트 코드가 복잡해져서 테스트 코드를 보고 동작을 알아내기가 어려워진다.
Pytest에서 대안
Fixture 사용: Pytest의 강력한 기능인 Fixture를 활용하여 테스트 데이터를 재사용하고 의존성을 관리합니다.
테스트 더블(Test Doubles): mock 대신 더 간단한 테스트 더블을 사용하여 의존성을 줄이고 테스트 코드를 간소화합니다.
통합 테스트: 단위 테스트뿐만 아니라 전체 시스템이 올바르게 동작하는지 확인하는 통합 테스트도 병행합니다.

질문

엣지 투 엣지 테스트, 과연 어떤 부분을 추상화해야할까