3. 결합과 추상화

hyuckhoon.ko·2021년 10월 22일
0

학습목표

  1. 좋은 추상화를 만드는 요소를 알 수 있다
  2. 추상화로 얻을 수 있는 것을 말할 수 있다
  3. 테스트와 추상화의 관계를 이해할 수 있다.


0️⃣ 서론

지저분한 세부 사항을 감추기 위해 추상화를 적용하자.

예를 들어, 참가자는 풋살 매치의 디테일한 경기 규칙,
골키퍼가 변경되는 규칙, 경기 시간, 휴식 시간 등을 알 필요가 없다.

           <경기> ---- <<매니저>> ---- <참가자>

매니저를 통해 지저분한 세부 사항을 감추는 것이다.


만약 아래와 같은 결합이면
참가자는 각 매치의 규칙을 항상 숙지해야하는 일이 생긴다.

            <경기> ---- <참가자>

객체지향의 사실과 오해라는 책에서도 비슷한 설명이 있다.

즉, 결합이 많다는 것은 하나의 객체가 하는 일이 많다는 것을 의미한다. 그래서 결합도가 높아진다. 만약 주고받는 메시지를 중심으로 객체들이 다양해진다면 자연스레 결합도가 낮춰진 구조가 될 것이라고 생각한다.

지역적인 결합은 좋다.

  "A컴포넌트를 수정하면 B컴포넌트가 깨질 것 같아"
  서로 역할과 책임이 명확히 분리되어 있어 나오는 결과다.
  따라서, 결합된 요소들 간 응집이 있다고 표현하기도 한다.
  
  

전역적인 결합은 좋지 않다.

  "서로 응집되지 않은 요소들끼리 응집이 되어 버리는 사태"
  
  
  

결론: 좋은 결합을 하기 위해서는 추상화가 잘 돼야 한다.


1️⃣ 추상화를 고려하지 않은 경우(3.1절)

  • a.k.a 마스터 객체

아래와 같은 프로젝트가 있다고 하자.

"두 디렉토리를 동기화해라."

fruit 디렉토리와 new 디렉토리를 동기화하려고 한다.


다음과 같이 new 디렉토리는 비어있다.

임의의 파일 내용을 해시하는 함수는 다음과 같다.

import hashlib
import os
import shutil
from pathlib import Path

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()


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)

            # 1) 사본에 있지만, 원본에 없는 파일은 삭제
            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)


sync("fruit", "new")


다음과 같이 new 디렉토리에 파일이 생성되었다.
(원본엔 있는 파일이지만, 사본엔 없는 파일이므로)


하지만 문제가 있다.
동기화 기능을 테스트하는데, 너무 많은 준비 과정이 필요하다.
특히, 도메인 로직은 동기화해라인데, 그 로직 안에
I/O 코드가 있다.
역할과 책임의 관점에서 보면,
한 객체가 하는 일이
1) "파일을 동기화해라"
2) "파일 경로를 입력받고 탐색해라" 인 것이다.
전혀 협력하고 있지 않다.
특이 이런 경우, 유닛테스트 관점에서 사실상 1), 2)가
하나의 유닛(Unit)이 되는 테스트가 된다.
1) 혹은 2)번 이 변경되면 그 테스트 전체가 실패하게 된다.


Q. --dry-run
"실탄 안쓰는 전투 훈련"

이해 안 된 문장: "실제 파일 시스템을 변경하지 않고 어떤 작업을 수행해야 할지만 표시해주는 --dry-run 플래그를 구현하다고 생각해보자.
원격 서버와 동기화하거나 클라우드 저장 장치와 동기화하려면 코드를 어떻게 변경해야 할까?"

개인적인 해석
"원격 서버와 동기화하는 코어 코드만 테스트하고 싶다. 아이디어를 내서 어떤 작업을 수행해야 할지만 알려주는 추상화를 적용해보자. 근데.... 코어 코드에 I/O 부분이 있어서, 테스트 코드에서 입출력 파일들과 같은 덜 중요하고 지저분한 부분의 코드가 필요하다"



2️⃣ 올바른 추상화 선택

[사고의 전환]
"실제 파일 시스템에 대한 함수를 실행하면 어떤 일이 일어날지 테스트하자"
대신
"실제 파일 시스템의 추상화에 대한 함수를 실행하면 어떤 추상화된 일이 일어날지 테스트하자"

# 원본 파일
source_files = {
	"hash1": "path1",
        "hash2": "path2",
}
#
dest_files = {
	"hash1": "path1",
        "hash2": "pathX",
}

큰 틀에서의 추상화는 이렇다.

1) 파일 순회(해시 사전 구축)
2) 판단(원본과 사본 비교)
3) 행동(복사, 삭제, 이동)

역할과 책임 관점에서 객체를 분리했다.

파일 순회를 위해 사전(dict)을 적용한다는 것 자체가 이미 추상화를 했다는 부분이 놀라웠다.

원본 디렉토리에 있는 파일 내용이 key에, 경로가 value에 저장된다.
추상화될 프로그램이 취할 행동은 다음과 같다.

("COPY", "sourcepath", "destpath"),
("MOVE", "old", "new")



3️⃣ 위의 추상화를 구현하기

목표를 다시 상기시키자.
시스템에서 트릭이 적용된 부분을 분리하고,
파일시스템 없이도 테스트할 수 있게 하는 것

특히, 외부 상태에 아무런 의존성이 없는 코드를 핵이라고 표현할거다.

❶ 엔드 투 엔드 테스트

추상화가 되지 않았던 맨 처음의 코드에서 상태와 로직을 분리하자.

# sync_method.py

import os
import shutil
from pathlib import Path


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

# 비즈니스 로직
def determine_actions(src_hashes, dst_hashes, src_folder, dst_folder):
    for sha, filename in src_hashes.items():
        if sha not in dst_hashes:
            sourcepath = Path(src_folder) / filename
            destpath = Path(dst_folder) / filename
            yield 'copy', sourcepath, destpath
        elif dst_hashes[sha] != filename:
            olddespath = Path(dst_folder) / dst_hashes[sha]
            newdespath = Path(dst_folder) / filename
            yield 'move', olddespath, newdespath

    for sha, filename in dst_hashes.items():
        if sha not in src_hashes:
            yield 'delete', dst_folder / filename


def sync(source, dest):
    # I/O 중 I
    source_hashes = read_paths_and_hashes(source)
    dest_hashes = read_paths_and_hashes(dest)

    # 핵(core) 호출
    actions = determine_actions(source_hashes, dest_hashes, source, dest)

    # I/O 중 O
    for action, *paths in actions:
        if action == 'copy':
            shutil.copyfile(*paths)
        if action == 'move':
            shutil.move(*paths)
        if action == 'delete':
            os.remove(paths[0])


sync('fruit', 'new')

위와 같이, core(고수준), Input(저수준), Output(저수준)으로 분리했다.

추상화된 테스트 진행은 다음과 같다.

# test_sync.py

from pathlib import Path

import sync_method


def test_when_a_file_exists_in_the_source_but_not_the_destination():
    src_hashes = {"hash1": "fn1"}
    dst_hashes = {}
    actions = sync_method.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": "fn10000"}
    actions = sync.determine_actions(
        src_hashes, dst_hashes, Path("/src"), Path("/dst"))

    assert list(actions) == [("move", Path("/dst/fn10000"), Path("dst/fn1"))]




엔드 투 엔드 테스트의 단점은 뭘까
개발자 관점에서 코어 로직이 더 중요하다지, 유저 관점에서는 어느 한 부분이라도 에러면 에러다. 즉, 배포될 sync 함수 전체를 테스트하는게 베스트다.
전체 시스템 관점에서의 테스트는 할 수 없다.

❷ 에지 투 에지 테스트

  • 가짜를 사용하자.

-의존성 주입(DI)과 가짜 사용하기

#test_sync_with_di.py

class FakeFileSystem(list):
    def copy(self, src, dest):
        self.append(("COPY", src, dest))

    def move(self, src, dest):
        self.append(("MOVE", src, dest))

    def delete(self, src, dest):
        self.append(("DELETE", src, dest))


def test_when_a_file_exists_in_the_source_but_not_the_destination():
    source = {"sha1": "my-file"}
    dest = {}
    file_system = FakeFileSystem()

    reader = {"/source": source, "/dest": dest}
    synchronise_dirs(reader.pop, file_system, "/source", "/dest")

    assert file_system == [("COPY", "/source/my-file", "/dest/my-file")]


def test_when_a_file_has_been_renamed_in_the_source():
    source = {"sha1": "renamed-file"}
    dest = {"sha1": "original-file"}
    file_system = FakeFileSystem()

    reader = {"/source": source, "/dest": dest}
    synchronise_dirs(reader.pop, file_system, "/source", "/dest")

    assert file_system == [
        ("DELETE", "/dest/original-file", "/dest/renamed-file")]

에지 투 에지 테스트의 장점은 실제 배포된 코드 자체를 테스트 한다는 점이다.
단지 가짜(여기서는 FakeSystem)를 정의하는 부분만 추가됐을다.

단점은 명시적인 상태를 직접 작성해줘야 한다는 거다.
source = {"sha1": "renamed-file"}
dest = {"sha1": "original-file"}
reader = {"/source": source, "/dest": dest}



의존성 주입(DI)

  • [우아한테크세미나] 190620 우아한객체지향 by 우아한형제들 개발실장 조영호님
    https://www.youtube.com/watch?v=dJ5C4qRqAgA&t=82s

  • 위키피디아

    "어떤 서비스를 호출하려는 클라이언트는 그 서비스가 어떻게 구성되었는지 알지 못해야 한다. (중략)

    이것은 "구성"의 책임으로부터 "사용"의 책임을 구분한다.
    의존성 주입은 프로그램 디자인이 결합도를 느슨하게 되도록하고 의존관계 역전 원칙과 단일 책임 원칙을 따르도록 클라이언트의 생성에 대한 의존성을 클라이언트의 행위로부터 분리하는 것이다. 이는 클라이언트가 의존성을 찾기 위해 그들이 사용하는 시스템에 대해 알도록 하는 서비스 로케이터 패턴과 정반대되는 것이다.

    의존성 주입의 기본 단위인 주입은 새롭거나 관습적인 메커니즘이 아니다. "매개변수 전달"과 동일하게 동작한다. 주입으로써 "파라미터 전달"은 클라이언트를 세부 사항과 분리하기 위해 수행되고 있는 부가적인 의미를 전달한다."


  • 객체지향의 사실과 오해
    인터페이스와 구현의 분리 원칙

(내 생각) 의존성 주입과 가짜를 사용하라라는 엣지 투 엣지 테스트의 개념은 우리에게 익숙한 개념이라고 생각한다. 역할과 책임을 기준으로 객체를 분류하면 자연스레 메시지 기반의 협력 관계가 형성된다. 각 객체는 다른 객체가 제공한 인터페이스만 알고 구성은 모른다.

0개의 댓글