-
결합 : 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 같은 모듈을 호출하지 않고 는 디렉터리 차이 판단 알고리즘을 실행 할 수 없음
올바른 추상화 선택
코드를 읽어보면 세가지 서로 다른 책임으로 나눌 수 있음
- 파일 시스템 정보를 얻고, 파일 경로로부터 파일의 해시를 결정할 수 있음(원본과 사본 디렉토리에서 동일하게 작동)
- 파일 비교(새 파일인지, 이름이 변경된 파일인지, 중복된 파일인지)
- 원본과 사본을 일치시키기 위해, 파일을 옮기거나 삭제함
이 세가지 책임에 대해 더 단순화한 추상화를 적용하면 지저분한 세부사항을 감추고 로직에만 초점을 맞출 수 있음
선택한 추상화 구현
시스템에서 트릭이 적용된 부분을 분리해서 격리하고, 실제 파일 시스템 없이도 테스트 할 수 있게 하는 것이 목표.
외부 상태에 대해 아무 의존성이 없는 코드의 '핵'을 만들고 외부 세계를 표현하는 입력에 대해 핵이 어떻게 반응하는지 살펴본다세 부분으로 코드 분리 (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 (1) 2024.03.31 [파이썬으로 살펴보는 아키텍처 패턴] 챕터 4 첫번째 유스 케이스: 플라스크 API와 서비스 계층 (1) 2024.03.24 데이터 메시를 읽고 (1) 2024.02.25 [파이썬으로 살펴보는 아키텍처 패턴] 챕터 2 저장소 패턴 (0) 2023.07.30 [파이썬으로 살펴보는 아키텍처 패턴] 챕터 1 도메인 모델링 (0) 2023.07.30 댓글