결합 : 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 (2) | 2024.03.31 |
---|---|
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 4 첫번째 유스 케이스: 플라스크 API와 서비스 계층 (1) | 2024.03.24 |
데이터 메시를 읽고 (1) | 2024.02.25 |
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 2 저장소 패턴 (0) | 2023.07.30 |
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 1 도메인 모델링 (0) | 2023.07.30 |