요즘 보는 파이썬으로 살펴보는 아키텍처 패턴이 재밌어서 포스팅을 이어나가겠다.
B컴포넌트가 깨지는 게 두려워서 A 컴포넌트를 변경할 수 없는 경우를 이 두 컴포넌트가 서로 결합되어 있다고 한다.
지역적인 결합은 좋은 것이다. 결합은 코드가 서로 함께 작동하고, 한 컴포넌트가 다른 컴포넌트를 지원하며 시계 나사처럼 각 컴포넌트들이 서로 맞물려 돌아간다는 사실이 드러나는 신호다. 결합된 요소들 사이에 응집이 있다는 용어로 이런 바람직한 경우를 표현한다.
전역적인 결합은 성가신 존재다. 전역적인 결합은 코드를 변경하는 데 드는 비용을 증가시키며 결합이 커지다 보면 코드를 변경할 수 없는 지경에 이른다. 추상화를 통해 시스템 내 결합 정도를 줄일 수 있다.
추상적인 상태는 테스트를 더 쉽게 해 준다.
두 파일 디렉터리를 동기화하는 코드를 작성하고 싶다. 두 파일 디렉터리를 원본과 사본이라고 부르자.
동기화하는 경우는 다음과 같다.
- 원본에 파일이 있지만 사본에 없으면 파일을 원본에서 사본으로 복사
- 원본에 파일이 있지만 사본에 있는 (내용이 같은) 파일과 이름이 다르면 사본의 파일 이름을 원본 파일 이름과 같게 변
- 사본에 파일이 있지만 원본에는 없다면 사본의 파일을 삭제
첫 번째와 세 번째는 두 디렉터리에 있는 파일의 목록만 비교하면 된다. 하지만 두 번째의 경우에는 이름이 바뀐 것을 알아내려면 파일의 내용을 살펴야 한다. 이를 위해 MD5나 SHA-1 등의 해시 함수를 사용한다. 코드는 다음과 같다.
# 파일 해시하기(sync.py)
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()
이제는 무엇을 해야 할지 결정을 내리는 비즈니스 로직을 작성해야 한다.
처음부터 문제를 해결해야 하는 경우라면 보통 간단한 구현을 작성한 다음 이 구현을 리팩터링 해서 더 나은 설계로 개선하자.
첫 번째로 만든 해법은 다음과 같다.
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)
이제 작성한 코드를 테스트해보자.
import tempfile
from pathlib import Path
import shutil
from sync import sync
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)
간단한 두 가지 경우를 테스트하는 데 너무 많은 준비가 필요하다. 문제는 '두 디렉터리의 차이 알아내기'라는 도메인 로직이 I/O 코드와 긴밀히 결합되어 있다는 점이다. pathlib.shutil, hashlib 모듈을 호출하지 않고는 디렉터리 차이 판단 알고리즘을 실행할 수 없다.
테스트를 작성하는 게 번거롭다면 머지않아 테스트를 작성하는 일이 더 고통스러워진다.
또한 이 코드는 확장성이 좋지 않다. 실제 파일 시스템을 변경하지 않고 어떤 작업을 수행해야 할지만 표시해 주는 --dry-run플래그를 구현한다고 생각해 보자. 원격 서버와 동기화하거나 클라우드 저장 장치와 동기화하려면 코드를 어떻게 변경해야 하는가.
올바른 추상화 선택
테스트하기 쉽도록 코드를 다시 작성하려면 어떻게 해야 할까?
우선 파일 시스템의 어떤 기능을 코드에서 사용할지에 대해 생각해봐야 한다. 코드의 세 가지 서로 다른 책임을 생각해보자
- os.walk를 사용해 파일 시스템 정보를 얻고, 얻은 여러 파일 경로로부터 파일 내용의 해시를 결정할 수 있다.
- 파일이 새 파일인지, 이름이 변경된 파일인지, 중복된 파일인지 결정한다.
- 원본과 사본을 일치시키기 위해 파일을 복사하거나 옮기거나 삭제한다.
이 세 가지 책임에 대해 더 단순화한 추상화를 찾고 싶다는 점을 기억하라. 추상화를 하면 지저분한 세부 사항을 감추고 로직에만 초점을 맞출 수 있다.
세 가지 책임 중 첫 번째와 두 번째에서 이미 파일 경로와 해시를 엮는 dict라는 추상화를 직관적으로 사용했다. 코드를 보면 왜 사본 폴더에 대해서는 dict 자료형을 쓰지 않았을까 라는 의문이 든다.
source_files = {"hash1": "path1"}
dest_hashes = {"hash1": "path1"}
dict를 이렇게 표현하면 두 번째 책임이나 세 번째 책임은 어떻게 추상화할 수 있을까?
무엇을 원하는가와 원하는 바를 어떻게 달성할지를 분리한다. 프로그램이 다음과 비슷한 명령 목록을 출력하도록 구현한다.
("COPY", "sourcepath", "destpath")
("MOVE", "old", "new")
이제 파일 시스템을 표현하는 두 dict를 입력받는 테스트를 작성할 수 있다. 이때 수행할 동작을 표현하는 문자열 튜플로 이룽저니 리스트를 예상하는 출력으로 지정할 수 있다.
"어떤 주어진 실제 파일 시스템에 대해 함수를 실행하면 어떤 일이 일어나는지 검사해 보자"라고 말하는 대신 "어떤 주어진 파일 시스템의 추상화에 대해 함수를 실행하면 어떤 추상화된 동작이 일어날까?"라고 말할 수 있다.
def test_when_a_file_exists_in_the_source_but_not_the_destination():
source_hashes = {"hash1": "fn1"}
dest_hashes = {}
expected_actions = [('COPY', '/src/fn1', '/dst/fn1')]
...
def test_when_a_file_has_been_renamed_in_the_source():
source_hashes = {"hash1": "fn1"}
dest_hashes = {"hash1": "fn2"}
expected_actions = [('MOVE', '/dst/fn2', '/dst/fn1')]
...