diff --git a/content/books/cosmic-python/2023-04-11---pt01-ch01/index.md b/content/books/cosmic-python/2023-04-11---pt01-ch01/index.md new file mode 100644 index 00000000..36115d48 --- /dev/null +++ b/content/books/cosmic-python/2023-04-11---pt01-ch01/index.md @@ -0,0 +1,100 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (1)" +date: "2023-04-11T17:55:00.000Z" +template: "post" +draft: false +slug: "/books/docker/2023-04-11-pt01-ch01" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 1, 도메인 모델링에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +1장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt1/ch01) + +# 1장 도메인 모델링 + +이번 장에서는 + +- 엔티티 +- 값 객체(Value object) +- 도메인 서비스(Domain service) + +를 알아보도록 한다. + +## 1.1. 도메인 모델? + +비즈니스 로직 계층을 도메인 모델로 부른다. + +- 도메인: 해결하려는 문제. 회사마다 다르겠죠? 회사에서 고객에게 뭘 내어줘야 할 지가 곧 도메인. +- 모델: 유용한 특성을 포함하는 프로세스나 현상의 지도 + +객체를 통한 개념의 추상화, 의인화를 하면 눈과 손에 잡히니 생각을 더 확장하기 쉽다. + +이걸 도출하는 설계가 DDD(Domain-driven Design)라 할 수 있다. 그리고 아래 책들은 읽고 오면 다음 내용이 수월하게 읽힙니다. 안 읽고 독파하면 '왜 저게 저렇게 됨?' 이란 의문이 계속 생긴다고 생각한다. 위에서 아래 순으로 쭉 읽으면 좋다고 생각한다. + +- [객체지향의 사실과 오해](https://www.yes24.com/Product/Goods/18249021) +- [도메인 주도 설계 철저 입문](https://product.kyobobook.co.kr/detail/S000001766446) +- [오브젝트](https://product.kyobobook.co.kr/detail/S000001766367) +- [(반 버논) 도메인 주도 설계 핵심](https://www.yes24.com/Product/Goods/48577718) + +책을 보면, 책 대로 나온 "고객의 주문에 할당하는 프로세스를 조율"하기 위해 시스템을 배치한 다이어그램이 나온다. 이런 주요 모델부터 뽑아서 시작하면 될 것이다. + +## 1.2. 도메인 언어 탐구 + +이걸 하기 위해 유비쿼터스 언어, DSL 등의 각종 도구를 들고와서 포스트잇으로 나눈다. 각 파트 사람이 둘러앉아 뭐가 뭐다 하는 논의를 잘 거치면 된다는 뜻이다. + +## 1.3. 도메인 모델 단위 테스트 + +도메인을 만든다. 그냥 클래스로 만들고 불변객체에 대해서도 만든다. + +불변객체는 `@dataclass(frozen=True)` 로 만들 수 있다. + +코드를 작성하더라도 아래와 같이 접근한다. + +- 테스트 먼저, 개념 다음 +- 도메인 전문가들의 언어를 따르고, 그 예제를 코드로 옮긴 후 계속해서 나아간다(그래야 실제 요구사항을 반영하는 것이니). + +### 1.3.1. VO로 쓰기 좋은 `@dataclass` + +`dataclass` 는 VO로 쓰기 좋다. value equality(값 동등성)을 부여할 수 있다. 얘는 데이터는 있고 식별자(ID)가 없다. + +### 1.3.2 VO와 엔티티 + +엔티티는 정체성이 있다. 객체를 식별할 유일무이한 값이 있단 뜻이다[^1]. 파이썬에선 이를 위해 `__eq__(self, other)` 매직 메소드와 `__hash__(self)` 매직 메소드를 만든다. 이걸 통해서 엔티의 identity에 대한 동일성 점검을 구현할 수 있다. 책에선 [파이썬에서의 hashable](https://docs.python.org/3/glossary.html#term-hashable) 의 뜻을 설명한 공식문서 경로와 [이 링크](https://hynek.me/articles/hashes-and-equality/)를 통해 소개한다. + +## 1.4. 도메인 서비스 함수 + +모든걸 객체로 만들 필요는 없다. + +필요하면 파이썬은 그걸 객체화할 필요가 없다. 함수로 만들고 처리하면 되니까. → ‘동사’ 로 표현되는 부분을 표현하려면 그냥 함수로 풀어쓰면 된다. + +근데 이런 식으로 도메인 서비스를 죄다 만들면 객체가 아니라 도메인 서비스 함수에 힘이 더 실리게 된다. 그렇게 힘이 잔뜩 빠진 도메인 객체를 [anemic domain model](https://martinfowler.com/bliki/AnemicDomainModel.html) 이라 일컫는다. + +### 1.4.1 매직 메소드 쓸 때 pythonic idioms 를 써먹으려면? + +`next()`, `sorted()` 를 구현하려면 도메인 모델 내의 `__gt__` 가 구현되어야 한다. + +### 1.4.2 예외를 사용한 도메인 개념 표현 + +도메인 전문가하고 얘기하다보면 ‘이건 이렇게 할 수 없습니다’ 가 도출되는데, 이걸 예외로직으로 풀어내면 된다. + +그러고보니 도메인 개념 안에도 별도의 exception을 도출해서 써먹으면 좋을 것 같다... + +# 정리 + +- 이 방법론은 DDD로 설계 후 도메인 모델을 도출하고 작업하는걸 권장한다. 그렇게 시작한 프로젝트라면 무리없이 작업가능하다 +- 도메인 모델에 주요 개념들이 계속해서 들어갈거다. +- 엔티티 구현 시 + - 파이썬의 `__eq__(self, other)` 매직 메소드와 `__hash__(self)` 매직 메소드를 오버라이딩해서 객체간의 동일함을 점검할 수 있도록 하자 + - 객체를 파이썬스럽게(pythonic) 써먹으려면 매직메소드를 내 논리에 맞게 오버라이딩해서 써먹어보자 +- VO는 `@dataclass(frozen=True)` 을 사용하라 +- '상속 보다는 구성을 사용하라' 라는 유명한 말을 파이썬에서 써먹을 때, 저자는 [PEP 544 (프로토콜)](https://peps.python.org/pep-0544/)을 활용하라고 한다. + +[^1]: 자바로 치면 `hashCode()` , `equals()` 같은걸로 객체의 유일성 제공 및 동등함 부여를 체크한다 라는 말 diff --git a/content/books/cosmic-python/2023-04-11---pt01-ch01/media/universe.jpg b/content/books/cosmic-python/2023-04-11---pt01-ch01/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-04-11---pt01-ch01/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-04-13---pt01-ch02/index.md b/content/books/cosmic-python/2023-04-13---pt01-ch02/index.md new file mode 100644 index 00000000..872a6a00 --- /dev/null +++ b/content/books/cosmic-python/2023-04-13---pt01-ch02/index.md @@ -0,0 +1,243 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (2)" +date: "2023-04-13T20:44:23.000Z" +template: "post" +draft: false +slug: "/books/docker/2023-04-13-pt01-ch02" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 2, 저장소 패턴에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +2장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt1/ch02) + +# 2장 + +Repository Pattern: 데이터 저장소를 추상화 + +이득 + +- 모델 계층과 데이터 계층을 분리할 수 있다 +- 데이터베이스의 복잡성을 감춰서 시스템을 테스트하기 좋게 만든다 + +# 2.1. 도메인 모델 영속화 + +애자일하게 개발할 땐 뭐가 필요하냐? MVP가 빨리 나와야된다. 책에선 e2e 테스트부터 바로 들어간 후 웹 프레임워크에 기능을 넣고 외부로부터 내부 방향으로 테스트를 한다고 한다. + +어쨌거나 영속적 저장소가 필요한건 사실이다. + +# 2.2. pseudo-code로 뭐 필요한지 보자 + +```python +from fastapi import FastAPI + +from model import OrderLine, allocate + +app = FastAPI() + +@app.post( + "/allocate", + status_code=201, +) +def allocate_endpoint(): + # line = OrderLine(request.params, ...) + # + # batches = ... + # + # allocate(line, batches) + return {} +``` + +대충 이런 코드가 필요하다… + +# 2.3. 데이터 접근에 DIP 적용하기 + +계층을 분리하는게 핵심이다! + +그리고 도메인 모델은 **의존성을 단 하나도 두지 않도록** 한다. 사실 `pydantic` 을 쓰는 것도 편해서 그런거지 가능하면 dataclassse를 쓰는 형태로 갖고가는게 맞는 것 같다… + +의존성이 비즈니스 로직 즉, 도메인 모델로 들어오도록 하는 방법으로 가도록 한다. + +![Untitled](./media/001.png) + +다시말해, 높은 계층의 도메인이 하부 구조(표현계층이든 DB든)에 의존하면 안 된다 라는 것이다. + +# 2.4 책에서의 모델을 DB로 바꾸려면? + +“할당” 이라는 개념은 `OrderLine` 과 `Batch` 를 연결하는 개념이다. 이걸 DB화 해보자. + +## 2.4.1 일반적인 ORM: ORM에 의존하는 모델 + +모델 객체를 SQL문으로 매핑해주는 프레임워크를 ORM(Object Relational Mapping)이라 한다. 객체-도메인 모델-데이터베이스 를 이어주는 역할을 하기 때문이다. + +ORM은 영속성 무지(Persistence Ignorance)가 가장 중요한 역할을 한다. 데이터를 어떻게 쌓고 어떻게 영속화 하는지는 관심없도록 한다. + +Declarative 형식으로 짜면 모델이 ORM에 의존하게 된다고 한다. + +이걸 뒤집으려면 스키마를 따로 짜고, 스키마와 도메인 모델을 상호 변환하는 매퍼를 정의할 필요가 있다. 이걸 SQLAlchemy에선 imperative mapping(혹은 classical mapping) 이라고 한다. [참고링크](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#imperative-mapping) + +> 참고사항 + +SQLAlchemy 2.0 들어서선 Imperative mapping 방법이 좀 바뀌었다. [해당링크 참고](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#imperative-mapping) +`relationship` 기재의 경우 `properties` 라는 kwargs로 넣어주면 된다 +> + +## 2.4.2 의존성 역전: 모델에 의존하는 ORM으로 만들기 + +요는 명시적 매퍼를 정의해서 내가 만든 코드로 테이블을 의존시키는 것이다. + +진짜 핵심은 **“도메인”이 다른 하부 구조에 의존하게 않도록** 만드는 것이다. + +도메인 모델을 잘 가져가는 것이 객체지향 패러다임을 계속해서 가져갈 수 있도록 하는 힘이다. + +# 2.5 저장소 패턴 소개 + +저장소 패턴(Repository pattern)은 영속적 저장소를 추상화한 것이다. + +컴퓨팅 환경에서는 저장공간이 유한하기 때문에 어딘가에 데이터를 넣고 조회하는 것이 너무나 당연하다. 그리고 설령 모든 객체가 메모리 안에 있을 수 있다 하더라도 나중에 다시 찾을 수 있도록 어딘가에 보관해야한다. + +## 2.5.1 추상화한 저장소 + +- `add()`: 원소를 저장소에 저장한다. +- `get()`: 원소를 저장소에서 가져온다. + +도메인과 서비스 계층에서 데이터에 접근할 떄는 **저 함수들만을 사용**하도록 하면 결합을 끊을 수 있다. 이 때 가상 저장소는 `ABC`로 만들면 된다. + +ABC 대신 덕 타이핑, [PEP 544(프로토콜)](https://peps.python.org/pep-0544/) 도 방안이다. + +덕 타이핑은 add(thing), get(id) 메소드를 제공하는 **어떠한 객체**든 다 덕 타이핑을 할 수도 있다. + +파이썬 3.8 들어서는 PEP 544를 통해 필요한 값만 골라서 처리할 수 있는 프로토콜이 더 낫다고 생각한다. 자세한 사항은 Fluent Python 11장을 참고하자. [관련 링크1](https://junstar92.tistory.com/356), [관련 링크2](https://blog.doosikbae.com/entry/Fluent-Python-Chapter-11-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-%ED%94%84%EB%A1%9C%ED%86%A0%EC%BD%9C%EC%97%90%EC%84%9C-ABC%EA%B9%8C%EC%A7%80) + +## 2.5.2 트레이드오프? + +으레 사람들이 이런 말을 한다. 이걸로 얻는 이득과 손해가 뭐지? + +지역적으로는 좀더 복잡해지고 지속적으로 유지보수를 해야하지만, 전체적으로는 점차적으로 나아지는 길이다. + +DDD를 한번 시작했다면 돌이키기 힘들다. 하지만 각각의 계층을 보다 유지보수 하기 쉬워진다. + +저장소 패턴이 추가되면 어떤 그림이 되는지 살펴보자 + +![Untitled](./media/002.png) + +만들거면 테스트부터 만들어보자. + +# 2.6 테스트에 사용하는 가짜 저장소를 쉽게 만드는 방법 + +파이썬 원형을 만들고 테스트할 때 저걸로 대신 쓰면 된다. + +# 2.7 파이썬에서의 포트, 어댑터? + +포트와 어댑터는 객체지향에서 나온 단어다. 이 책에선 아래와같이 쓰인다 + +- 포트 + - 애플리케이션과 추상화하려는 대상 사이의 인터페이스 + - 추상 기반 클래스를 쓴다 + - `AbstractRepository` +- 어댑터 + - 이 인터페이스나 추상화가 뒤에있는 구현이라는 정의를 채택함 + - `SqlAlchemyRepository`, `FakeRepository` + +# 2.8 pros and cons + +| 장점 | 단점 | +| --- | --- | +| 영속적 저장소와 도메인 모델 사이의 인터페이스를 간단히 유지할 수 있다 | ORM이 어느정도 (모델과 저장소의) 결합을 완화시켜준다. (ORM을 쓰면) 외래키를 바꾸기는 어렵지만, 필요할 때 MySQL과 Postgres를 서로 바꾸기 쉽다 | +| 모델과 인프라 사이의 사항을 완전히 분리한다 → 단위 테스트에 대한 가짜 저장소 생성이 쉽고 저장소 해법을 변경하기도 쉽다 | ORM 매핑을 수동으로 하려면 작업과 코드가 더 필요하다 | +| 영속성 고려 전 도메인 모델을 작성하면 비즈니스 문제에 집중하기 쉽다. 접근 방식을 극적으로 바꾸고 싶을 때 외래키나 마이그레이션 등에 대해 고려하지 않고 모델이 이를 반영할 수 있다. | 간접 계층을 추가하면 유지보수 비용이 증가함을 의미한다. | +| 객체를 테이블에 매핑하는 과정을 원하는 대로 제어할 수 있어서 스키마를 단순히 할 수 있다 | | + +단순 CRUD만 가질거면 도메인 모델이나 저장소를 안써도 된다. 도메인이 복잡할 수록 인프라에 대해 신경쓰지 않을 정도로 투자하면 모델이나 코드를 보다 쉽게 변경할 수 있다. 그게 최고의 장점이다. + +# 정리하기 + +- ORM에 의존성 역전을 적용하자. ORM은 모델을 임포트 해야한다. 반대로 되면 안 된다. +- 저장소 패턴은 영속적 저장소에 대한 단순한 추상화다. + +# 번외 + +## `text` 사용 시... + +- [파라미터는 꼭 이렇게 보내자...](https://docs.sqlalchemy.org/en/20/tutorial/dbapi_transactions.html#sending-parameters) +- `Engine` 을 쓰건, `Session` 을 쓰건 이런 식이다. + + +## 매핑 + +`sqlalchemy.Table` 로 매핑하나 `sqlalchemy.orm.DeclarativeBase` 로 하나 똑같다 + +https://docs.sqlalchemy.org/en/20/tutorial/metadata.html#using-orm-declarative-forms-to-define-table-metadata + +1. 둘 다 충분히 테이블에 엮이지 않도록 한다. 다만 ORM Declarative로 가면 특정 RDBMS에 대한 지원을 보다 더 낫게 할 수도 있다 +2. `mypy` 를 이용한 타입 힌팅을 충분히 가져갈 수 있다 +3. persistance / object loading 작동에 필요한 테이블 메타데이터를 한번에 가져와서 쓸 수 있다 + 1. SQLAlchemy에 대한 이해도가 높아야겠지만, 쓴다면 당연히 높은 이해도를 제고해야 하는 것이 마땅하다. + +어차피 추상 리포지토리를 개별 리포지토리가 상속하고, 개별 리포지토리에서는 접근하고자 하는 RDBMS(SQLAlchemy 예시니까) 별로 다르게 코드가 나온다. +이게 길어지면 모듈로 분리하여 코드를 작성하면 될 일이다. +다른 어댑터나 포트는 다른 개별 리포지토리 형식으로 구현될텐데 말이지. + +근데 결국 아는만큼 쓴다고, `Table` 로 매핑하면서 쓰다가 필요할 때 ORM Declarative로 넘어가는게 나을 듯 싶다. + +## 모델 - 쿼리 엮기 + +### 배운 점 + +- 논리적인 흐름을 DB로 풀어낼 때.... + - VO가 그대로 테이블이 될 수 있다 + - 엔티티 내의 특정 값(`set`값인 `allocations`) 은 `OrderLine` 을 모아두는 논리적인 값이다 + - 이 값이 테이블이 될 수 있다 + - `allocate()` 함수를 통해 `OrderLine`을 `Batch`에 allocate 할 수도 있다. + - Java에선 이런 것 하나하나 인터페이스로 처리했어야 했었을 것을 함수 단위로 그냥 선언하고 땡 + - 다만 하나의 모듈(`model.py`) 안에 잘 두고 분리를 잘 해야할 것이다. + +### 복습 + +- 테이블 만들고 `mapper` 로 매핑하는 건 기존 프로젝트들에서 해왔던 것과 유사하다 + +## 리포지토리 + +- 안 엮인 쿼리를 짜면 greenlet 에러 뜬다. 쿼리를 엮으면서 동시에 `selectinload` 를 배웠음. +- 쿼리 1 예시 + - N+1 쿼리 방지를 위한 방안 소개 + - `selectinload`[^1] 를 통해 특정 테이블에 대해서만 내보낼 수 있는 SELECT 양식을 사용하여 쿼리함. + - 내부적으로는 두개의 쿼리를 하고 그 결과물을 처리한다고 보면 됨 + + ```python + query = ( + ( + await self.session.execute( + select(model.Batch) + # 이 라인 없으면 에러납니다! + # relationship으로 엮인 테이블을 참조할 방도가 없어요~~~ + .options(selectinload(model.Batch.allocations)) + .filter_by(reference=reference) + ) + ) + .scalar_one() + ) + ``` + +- 쿼리 2 예시 + - 상기 쿼리 1과 마찬가지의 예시임 + ```python + query = ( + ( + await self.session.scalars( + select(model.Batch) + .options(selectinload(model.Batch.allocations)) + ) + ) + .all() + ) + ``` + +[^1]: [참고 링크](https://soogoonsoogoonpythonists.github.io/sqlalchemy-for-pythonist/tutorial/7.%20ORM%20%EB%B0%A9%EC%8B%9D%EC%9C%BC%EB%A1%9C%20%EA%B4%80%EB%A0%A8%20%EA%B0%9C%EC%B2%B4%20%EC%9E%91%EC%97%85%ED%95%98%EA%B8%B0.html#select-in-loading-%E1%84%87%E1%85%A1%E1%86%BC%E1%84%89%E1%85%B5%E1%86%A8) diff --git a/content/books/cosmic-python/2023-04-13---pt01-ch02/media/001.png b/content/books/cosmic-python/2023-04-13---pt01-ch02/media/001.png new file mode 100644 index 00000000..2f8abc2c Binary files /dev/null and b/content/books/cosmic-python/2023-04-13---pt01-ch02/media/001.png differ diff --git a/content/books/cosmic-python/2023-04-13---pt01-ch02/media/002.png b/content/books/cosmic-python/2023-04-13---pt01-ch02/media/002.png new file mode 100644 index 00000000..9d8196ab Binary files /dev/null and b/content/books/cosmic-python/2023-04-13---pt01-ch02/media/002.png differ diff --git a/content/books/cosmic-python/2023-04-13---pt01-ch02/media/universe.jpg b/content/books/cosmic-python/2023-04-13---pt01-ch02/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-04-13---pt01-ch02/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-04-13---pt01-ch03/index.md b/content/books/cosmic-python/2023-04-13---pt01-ch03/index.md new file mode 100644 index 00000000..fece9dab --- /dev/null +++ b/content/books/cosmic-python/2023-04-13---pt01-ch03/index.md @@ -0,0 +1,164 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (3)" +date: "2023-04-13T22:51:20.000Z" +template: "post" +draft: false +slug: "/books/docker/2023-04-13-pt01-ch03" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 3, 결합과 추상화에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +3장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt1/ch03) + +# 3장 결합과 추상화 + +어떤 컴포넌트가 깨지는 것을 두려워해서 다른 컴포넌트롤 못건들이면, 두 컴포넌트가 결합되어 있다고 말한다. 결합은 엮여있음을 의미한다. + +그런데 지역적 결합은 ‘응집’이라고 표현한다. + +전역적 결합은 코드를 ‘진흙 공’ 처럼 서로 뭉치게 만든다. 앱이 커지면 커질 수록 결합을 훨씬 빠르게 하기 때문에 시스템은 사실상 고착화된다. + +따라서 추상화를 통해 세부사항을 감출 필요가 있다. + +# 3.1 추상적인 상태는 테스트를 더 쉽게 한다 + +따라가보자… + +## 요구사항 + +두 파일 디렉토리를 동기화하는 코드를 작성하고자 한다. 각 디렉토리를 **원본**, **사본**이라고 하자. + +## 해야할일 + +1. 원본에 파일이 있지만 사본에 없으면 파일위치를 원본 → 사본 으로 옮긴다 +2. 원본에 파일이 있지만 사본에 있는(내용이 같은)파일과 이름이 다르면 사본의 파일 이름을 원본 파일이름과 같게 변경한다 +3. 사본에 파일이 있지만 원본에 없다면 사본의 파일을 삭제한다 + +## 파일 해시코드 (핵심로직) + +```python +import hashlib + +BLOCK_SIZE = 65_536 + +def hash_file(path): + hasher = hashlib.sha1() + with path.open("rb") as file: + buf = file.read(BLOCK_SIZE) + with buf: + hasher.update(buf) + buf = file.read(BLOCK_SIZE) + return hasher.hexdigest() +``` + +## 3.1.1 1차 코드작성안 + +**처음부터 문제를 풀 때는 보통 간단한 구현을 짜고, 이걸 가지고 리팩토링 한다.** + +**가장 작은 부분부터 일단 만들면서 더 풍부하고, 더 좋은 해법의 설계를 가져가는 것을 반복한다.** + +바꿔말하면 **처음 코드는 보통 구지단 말이다. 처음부터 못해도 좋다. 빠르게 이터레이션을 가져가면서 좋은 코드로 바꾸는 연습을 하자.** + +### 문제점? + +- 두 디렉토리 차이점 알아내기 라는 도메인 로직이 I/O 코드와 긴밀하게 “결합” 되어있다 + - `pathlib`, `shutil`, `hashlib` 을 다 써야함 + - 비록 퓨어 파이썬 라이브러리라고 하지만…. +- 테스트가 충분하지 않다 + - 테스트케이스가 모자라다 → 커버리지가 낮다 + - `shutil.move()` 가 잘못 사용중이다(!) + - 버그를 찾으려면 테스트를 더 해야한다 +- 확장성이 없음. 아래 요구사항이 오면 코드를 싹 갈아야한다 + - 되는지 안 되는지만 알려주는 `--dry-run` 같은 기능을 추가하려면? + - 원격서버와 동기화 해야한다면? + - 클라우드 저장 장치와 동기화 해야한다면? + +# 3.2 올바른 추상화 선택 + +테스트하기 쉽게 짜려면 생각을 다시 해보자. + +1. 요구사항의 어떤 부분을 코드화할지 생각한다 +→ 파일 시스템의 어떤 기능을 코드에서 쓸지 생각해본다 +2. 코드 내에는 3가지 뚜렷한 서로 다른 일이 일어남을 캐치한다. 즉, 코드의 **책임**을 찾는다 + 1. `os.walk` 을 사용해 시스템 정보 및 파일해시를 구한다 (원본, 사본 모두에서 처리) + 2. 파일이 새 파일인지, 이름이 변경된 파일인지, 중복된 파일인지 정한다 + 3. 원본과 사본을 일치시키기 위해 파일 복사하거나, 옮기거나, 삭제한다. + +세 가지 책임에 대해 **단순화한 추상화(simplifying abstraction)** 을 찾으려는 과정이다. 마치 인터페이스를 만드는 것 처럼… 개선해보자! + +1. 시스템 정보 및 파일 해시를 구하는 딕셔너리를 만들 때, 원본 및 사본의 모든 파일해시를 다 구하고 연산한다면? +2. 두 번째, 세 번째 책임은 어떻게 해결할 것인가? +→ “**무엇**” 을 원하는가와 원하는 바를 “**어떻게**” 달성할 지를 분리하자. + 1. 프로그램이 아래와 비슷한 명령 목록을 출력하도록 하자 + + ```python + ("COPY", "sourcepath", "destpath"), + ("MOVE", "old", "new"), + ``` + + 2. 여기서 파일 시스템을 표현하는 두 딕셔너리를 입력받는 테스트 작성 가능 +3. … 그러면 아래와 같이 말을 바꿀 수 있다 + 1. (이전) 어떤 주어진 실제 파일 시스템에 대해 함수를 실행하면 어떤 일이 일어나는지 검사하자 + 2. (이후) 어떤 파일 시스템의 **추상화**에 대해 함수를 실행하면 어떤 **추상화된** 동작이 일어나는지 검사하자 + +# 3.3 선택한 추상화 구현 + +어려운 개발 도서들은 말은 좋지. 실제로 코드를 어떻게 짤까? 일단 목표를 다시 생각해보자. + +- 시스템에서 트릭이 적용된 부분을 분리해서 격리한다 +- 실제 파일 시스템 없이도 테스트가 되게 한다 + +외부 상태에 대해 의존성이 없는 코드의 ‘코어’를 만들고, 외부 세계를 표현하는 입력에 대해 이 코어가 어떻게 반응하는지 생각해보자[^1] + +step 1) 코드에서 로직과 상태가 있는 부분을 분리한다. + +1. 입력 수집 + 1. 이 코드를 나눠서 원본, 사본의 경로와 해시를 모두 구했다 +2. 함수형 코어 호출 + 1. if 구문이 여기서 갈릴 것이다 + 2. 개별 테스트로 빼기도 쉽다 +3. 출력 적용 + 1. 처음에 들어온 명령만 처리하면 된다. + +이러면 큰 로직과 저수준 I/O의 의존성을 함수단위로 풀었다. 쉽게 코드의 코어를 테스트할 수 있다(`determine_actions`)! + +전체를 테스트하려는 통합/인수테스트도 유지할 수도 있다만, 더 나아가 `sync()` 를 다듬어서 단위테스트를 겸해서 동시에 e2e 테스트까지 할 수도 있다. 이걸 책의 공동저자는 `edge-to-edge` 테스트라고 부른다. + +## 3.3.1 의존성 주입과 가짜를 사용한 edge-to-edge 테스트 + +새 시스템을 짤 때는 위에서 언급한 추상화를 통한 구현을 하자. 어느 시점이 되면 시스템의 더 큰 덩어리를 한번에 테스트하고자 할 것이다. + +저자는 이 때 한번에 테스트하되 가짜 I/O를 사용하는 류의 edge-to-edge 테스트를 추천한다. + +요컨대, 어느 파일 시스템에서(`filesystem`) 액션을 취할지를 테스트하는 방법을 DI를 통한 테스트 더블로 처리할 수 있다. + +## 3.3.2 `mock.patch` 를 쓰지 않는 이유 + +mock을 통한 monkey patching을 별로라고 하면서, 테스트 더블을 소개한다. 왜 안쓰는지를 설명하는 지에 대한 이유는 다음과 같다: + +1. 사용중인 의존성을 다른 코드로 패치하면, 테스트는 되지만 설계 개선에 도움되지 않는다. +2. mock을 쓴 테스트는 코드 베이스의 세부사항에 더 밀접하게 결합된다. 코드베이스가 뭘 하는지를 모킹하기 때문에, 이 또한 결합이다 라고 한단하는 것 같다. +3. 결국 코드 베이스를 알아야하니까 test suite을 보고 바로 이해하기 힘들어진다. + +여기 내용은 유닛 테스팅 책을 좀 보고 다시 이해해야겠다… 여전히 모르겠다. + +# 마무리 + +비즈니스 로직과 I/O 사이의 인터페이스를 단순화하는게 중요하다는 것을 배웠다. 올바른 추상화를 찾는 것은 어렵다. 아래는 올바른 추상화를 하기 위한 방법이다. + +1. 지저분한 시스템 상태를 표현할 수 있는 파이썬 객체가 있나? 있다면 이를 활용해 시스템의 상태를 반환하는 단일함수를 생각해보자. +2. 시스템의 구성요소 중 어떤 부분에 선을 그을 수 있을까? 이 각각의 추상화 사이의 [이음매(seams)](https://www.informit.com/articles/article.aspx?p=359417&seqNum=2)를 어떻게 만들 수 있을까? +3. 시스템의 여러 부분을 서로 다른 책임을 지니는 구성요소로 나누는 합리적인 방법은 무엇일까? 명시적으로 표현해야 하는 암시적인 개념은 무엇일까? +4. 어떤 의존관계가 존재하는가? 핵심 비즈니스 로직은 무엇인가? + +계속 연습하자… 계속… + +[^1]: Gary Bernhardt 가 말한 [Functional Core, Imperative Shell](https://github.com/kbilsted/Functional-core-imperative-shell/blob/master/README.md) 이라는 접근방법이다. 상세한건 링크 참고 diff --git a/content/books/cosmic-python/2023-04-13---pt01-ch03/media/universe.jpg b/content/books/cosmic-python/2023-04-13---pt01-ch03/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-04-13---pt01-ch03/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-04-15---pt01-ch04/index.md b/content/books/cosmic-python/2023-04-15---pt01-ch04/index.md new file mode 100644 index 00000000..c7b6f526 --- /dev/null +++ b/content/books/cosmic-python/2023-04-15---pt01-ch04/index.md @@ -0,0 +1,249 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (4)" +date: "2023-04-15T22:19:24.000Z" +template: "post" +draft: false +slug: "/books/docker/2023-04-15-pt01-ch04" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 4, 결합과 추상화에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +4장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt1/ch04) + +# 4장 API와 서비스 계층 + +이런 구조를 만들 것이다 + +![Untitled](https://www.cosmicpython.com/book/images/apwp_0401.png) + +이 장에서는 오케스트레이션 로직, 비즈니스 로직, 연결 코드 사이의 차이를 이해한다. 워크플로우 조정 및 시스템의 유스케이스를 정의하는 서비스 계층 패턴을 알아본다. + +테스트도 살펴본다. 서비스 계층과 데이터베이스에 대한 저장소 추상화를 조합할 것이다. 이를 통해 도메인 모델 뿐 아니라 유스케이스의 전체 워크플로우를 테스트할 것이다. + +테스트할 때 보면 프로덕션 코드는 `SqlAlchemyRepository` 를 쓰고, 테스트할 때는 `FakeRepository`를 쓰게 한다. + +# 4.1 애플리케이션을 실세계와 연결하기 + +이게 가장 빨라야한다! MVP니까… + +도메인에 필요한걸 만들고 주문할당(allocate)을 하는 도메인 서비스도 만들었고, 리포지토리 인터페이스도 만들었다… + +그러면 다음 할 일은 아래와 같다: + +1. 플라스크를 써서 `allocate` 도메인 서비스 앞에 API 엔드포인트를 둔다. DB 세션과 저장소를 연결한다. 이렇게 만든 시스템은 e2e 테스트와 빠르게 만든 SQL 문으로 테스트한다. +2. 서비스 계층을 리팩토링한다. 플라스크와 도메인 모델 사이에 유스케이스를 담는 추상화 계층을 만든다. 몇 가지 서비스 계층 테스트를 만들고 `FakeRepository` 를 써서 코드를 테스트한다. +3. 서비스 계층의 기능을 여러 유형의 파라미터로 실험한다. 원시 데이터 타입으로 서비스 계층의 클라이언트 (테스트와 API)를 모델에서 분리해본다. + +# 4.2 첫 E2E 테스트 + +실제 API 엔드포인트(HTTP)와 실제 DB를 사용하는 테스트를 한, 두개정도 짜고 리팩토링 또 한다. + +일단 처음엔 어쨌거나 만든다. 랜덤 문자열을 생성하고, DB에 row를 넣는 함수를 실제로 짠다. + +# 4.3 직접 구현하기 + +책에선 플라스크를, 나는 FastAPI를 통해서 짰다. 그런데 이 테스트의 한계는 DB커밋을 해야한다는 점이다. + +# 4.4 DB 검사가 필요한 오류조건 + +이런 케이스는 DB 측의 데이터 무결성 검사다. 도메인 서비스 호출 전에 캐치해야한다. + +- 도메인이 재고가 소진된 sku에 대해 예외가 발생하면? +- 존재하지 않는 sku에 대한 예외처리는? + +근데 이 방어로직을 API에 넣으면 E2E 테스트 갯수가 점점 많아지게되고 역피라미드형 테스트가 된다. 테스트코드도 꼬인다… + +따라서, API에 있던 일부 로직을 유스케이스로 빼고, 이를 테스트하기 위해 `FakeRepository`를 쓸 때가 왔다. + +# 4.5 서비스 계층 소개와 서비스 계층 테스트용 `FakeRepository` 사용 + +API는 가만보면 오케스트레이션이다. 저장소에서 뭐 갖고와서 DB 상태에 맞게 검증도 하고 오류 처리도 하고, 성공적이면 DB에 값도 커밋한다. 근데 이런 작업은 API하고는 관련이 없다. + +1. `FakeRepository` 를 이용해서 진짜 손쉽게 AAA 테스트코드를 구현했다 +2. `FakeSession` 을 이용해서 세션도 가짜로 만든다. 6장서 리팩토링할거다 +→ 당연하겠지만 커밋도 테스트 대상이다 + +## 4.5.1 서비스 함수 작성 + +이런 구성을 가져간다. + +1. 저장소에서 객체를 가져온다 +2. 애플리케이션이 아는 세계를 바탕으로 요청검사/검증(assertion) 한다 +3. 도메인 서비스를 호출한다 +4. 모두 정상실행했다면 변경된 상태를 저장/업데이트 한다 + +## 4.5.2 `deallocate` 을 만든다면? + +1. 제 1 사이클: 일단 짜자 + - [ ] deallocate하는 도메인 로직부터 짜고 테스트한다 + - [ ] 저장소 로직을 만든다 + - [ ] 유스케이스를 만든다 + - [ ] e2e를 만든다 +2. 제 2 사이클: 3장에서 본 내용을 적용해보자… + - [ ] 올바른 추상화를 하고있나? + - [ ] 바운더리가 어디어디 끊어지고, 이를 함수레벨로 나눌 수 있을까? + +UoW 하고나서 다시 할거다… + +# 4.6 왜 서비스라 부름? + +여기서 서비스라 부르는건 두 가지가 있다: + +1. 도메인 서비스 + 1. 도메인 모델에 속하지만, 엔티티/VO에 속하지 않는 로직을 부르는 이름이다. + 2. E.g., 쇼핑카트 애플리케이션을 만든다고 할 때 + 1. 도메인 서비스로 세금 관련 규칙을 구현한다 + 2. 모델에서는 중요하지만, 세금 관련만을 위한 영속적 엔티티를 빼지 않으려고 하는 것이다 + 3. 구현한다면 `TaxCalculator` 라는 클래스나 `calculate_tax` 같은 함수들로 처리하면 될 것이다 +2. 서비스 계층 + 1. 외부 세계로부터 오는 요청을 처리해 연산을 오케스트레이션 한다. + 2. 아래 단계를 수행하여 애플리케이션을 제어한다 + 1. DB에서 데이터를 얻는다 + 2. 도메인 모델을 업데이트한다 + 3. 바뀐 내용을 영속화한다 + 3. 비즈니스 로직과 떼어내서 프로그램을 깔끔하게 두자 + +## 4.6.1 처리과정 + +1. 또 리팩토링… +- [x] 개별 테스트도 메모리로 하면 되고, 값 준비해야되는걸 픽스처로 처리하자. +`yield` 전후로 setup, teardown으로 두자 +- [x] DB initialize 로직을 실제 앱 실행, 테스팅 두가지로 나누고 providers override로 처리하자 + - [x] 이러면 의존성 역전이 되는건지 살펴보자 + + > *This helps in testing. This also helps in overriding API clients with stubs for the development or staging environment.* + > + > [Provider overriding](https://python-dependency-injector.ets-labs.org/providers/overriding.html) , Dependency Injector + > + + 되는듯! + + - [x] 일단 너무 많은기능을 한번에 할려는 것 같으니 구획을 좀 나눠보자 + - 테스트 구동 프로시저 + - `AsyncClient`로 앱 구동함 → DB처리를 여기서도 함 + - DB처리 + 1. ‘엔진’ 생성 + 2. 메타데이터를 통한 테스트DB 생성 + 3. SQLAlchemy의 ‘세션’ 생성 + - 앱 구동 프로시저 (영 불안한 코드…) + - ORM 사용을 위한 SQLAlchemy의 ‘매핑’ 수행 + - 세션메이커 만들고 필요할 때마다 yield 해가게 세팅함 +- [x] 도메인 서비스 관련 처리과정 + - 테스트를 나눠서 성공했다 +1. 서비스 계층 처리과정 + - UoW 로 나누면서 해결할거다. 지금은 세션의 아래 문제 때문에 못한다 + - 처음 선언시 engine 주소 + `` + - 테스트 환경서... + override로 주입한 db의 engine 주소 `` + - 다시 로직에서... + init_session_factory 으로 세션을 가져오는 sqlalchemy engine 주소 `` + - 즉, 주소가 다르니까 거기서 갖고와봤자.... 이런걸로 진 빼지말고 개선하면서 해결하자 + - ❓이런 상황에서는 어떻게 시간분배를 해야할까? 이건 어디에 어떻게 물어보면 좋을까? + +# 4.7 디렉토리 구조를 잡자 + +여기서는 책에서 제시하는 구조를 **일단** 따른다. 주관은 지식이 생긴 후에 갖추는 것이 맞다고 생각한다. + +1. `domain` → 도메인 모델 + 1. 클래스마다 파일을 만든다 + 2. 엔티티 VO, Aggragate에 대한 부모 클래스도 여기에 + 3. exception이나 command, event도 여기 +2. `service_layer` → 서비스 계층 + 1. 서비스 계층 예외가 추가가능 + 2. uow를 여기… +3. `adapters` → ‘포트와 어댑터’ 용어에 사용된 **어댑터** + 1. 외부 I/O를 감싸는 추상화(redis_client.py)를 넣음 + 2. secondary adapter, driven adapter, inward-facing adapter 라고 일컫는다 +4. `entrypoints` → 애플리케이션 제어 시점, ‘포트와 어댑터의’ **어댑터** + 1. primary adapter, driving adapter, outward-facing adatper 라고 일컫는다 + +**포트**는? 어댑터가 구현하는 **추상 인터페이스**이다. 포트를 구현하는 어댑터와 같은 파일 안에 포트를 넣는다. + +# 4.8 마무리 + +- API 엔드포인트가 얇고 짜기 쉬워진다 → 웹 기능만 모아놨다 +- 도메인에 대한 API 정의를 했다. 논리적인 작업을 통으로 모아놓은 엔트리포인트다 +- 서비스 계층의 장점은 아래와 같다 + - 테스트를 ‘높은 기어비’로 작성할 수 있다 + - 도메인 모델을 적합한 형태로 리팩토리링 할 수 있다 + - 이를 활용하여 유스케이스를 제공할 수 있는 한 이미 존재하는 많은 테스트를 재작성하지 않고도 새 설계를 테스트할 수 있다 +- 테스트 피라미드도 (아직까지) 나쁘지 않다 + +## 4.8.1 DIP가 어떻게 돌아가는지…. + +서비스 계층이 어떻게 의존하는지 다시 살펴보자. + +서비스 계층은 도메인 모델, `AbstractRepository` 를 받는다. + +```python +async def allocate( + line: model.OrderLine, + repo: repository.AbstractRepository, + session, +) -> str: + """ batches를 line에 할당한다. + + FYI, + 의존성 역전 원칙이 여기 들어감에 유의! + 고수준 모듈인 서비스 계층은 저장소라는 추상화에 의존한다. + 구현의 세부내용은 어떤 영속 저장소를 선택했느냐에 따라 다르지만 + 같은 추상화에 의존한다. + + :param line: + :param repo: + :param session: + :return: + """ + batches = await repo.list() + if not is_valid_sku(line.sku, batches): + raise InvalidSku(f'Invalid sku {line.sku}') + batchref = model.allocate(line, batches) + await session.commit() + + return batchref +``` + +그죠? + +프로덕션 상에서는 `SqlAlchemyRepository`를 플라스크가 “제공” 하면 DIP가 이루어진다. + +여기까지의 트레이드오프를 살펴보자 + +| 장점 | 단점 | +| --- | --- | +| 애플리케이션의 모든 유스케이스를 넣을 유일한 위치가 생긴다 | 앱이 순수한 웹앱일 경우, 컨트롤러/뷰 함수는 모든 유스케이스를 넣을 유일한 위치가 된다 | +| 정교한 도메인로직을 API뒤로 숨긴다. 리팩토링이 쉬워진다 | 서비스 계층도 또다른 추상화 계층이다 | +| ‘HTTP와 말하는 기능’을 ‘할당을 말하는 기능’으로부터 말끔하게 분리했다 | 서비스 계층이 너무 커지면 anemic domain 이 된다. 컨트롤러에서 오케스트레이션 로직이 생길 때 서비스 계층을 만드는게 낫다 | +| 저장소 패턴 및 FakeRepository 와 조합하면 도메인 계층보다 더 높은 수준에서 테스트를 쓸 수 있다. 통합테스트 없이 개별 테스트가 가능해진다. +(5장에서 더 자세히 보자) | 풍부한 도메인 모델로 얻을 수 있는 이익 대부분은 단순히 컨트롤러에서 로직을 뽑아내 모델 계층으로 보내는 것 만으로 얻을 수 있다. 컨트롤러와 모델 계층 사이에 또다른 계층을 추가할 필요가 없다. +대부분의 경우 얇은 컨트롤러와 두꺼운 모델로 충분하기 때문이다. | + +개선해야 할 점도 있다 + +- 서비스 계층 API가 `OrderLine` 객체를 사용해 표현되므로, 서비스 계층이 여전히 도메인과 연관되어있다. 이 고리를 끊자. +- 서비스 계층은 세션 객체와 밀접하게 결합되어 있다. UoW로 풀어보자. + +# 끝으로 + +잘 되겠지… 하는 코드를 점차 없애자. 저기서 문제가 나면 어떻게 할 거야… + +SQLAlchemy 2.0 쿼리 방안을 좀 익혀두자 + +[SQLAlchemy 2.0 - Major Migration Guide + — + SQLAlchemy 2.0 Documentation](https://docs.sqlalchemy.org/en/20/changelog/migration_20.html#migration-orm-usage) + +## sqlite? + +SQLite는 딱히 TRUNCATE TABLE이 없다. 그래서 `DELETE FROM` 으로 다 날리면 된다 + +한편 postgres에서 DB 테스트하고 날릴거면 `nextval` 시퀀스 초기화라거나 그런 부분들도 생각해야한다. diff --git a/content/books/cosmic-python/2023-04-15---pt01-ch04/media/universe.jpg b/content/books/cosmic-python/2023-04-15---pt01-ch04/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-04-15---pt01-ch04/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-04-16---pt01-ch05/index.md b/content/books/cosmic-python/2023-04-16---pt01-ch05/index.md new file mode 100644 index 00000000..a1f30e77 --- /dev/null +++ b/content/books/cosmic-python/2023-04-16---pt01-ch05/index.md @@ -0,0 +1,294 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (5)" +date: "2023-04-16T03:26:01.000Z" +template: "post" +draft: false +slug: "/books/docker/2023-04-16-pt01-ch05" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 5, 높은 기어비와 낮은 기어비의 TDD에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +5장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt1/ch05) + +# 5장 **TDD in High Gear and Low Gear** + +기어비가 뭔소린가 했는데 걍 1단 2단 … 그거였음. 스틱 몰 때의 그것. + +4장까지 오면서 서비스계층으로 작동하는 애플리케이션에 필요한 오케스트레이션 책임을 좀 나눴다. 서비스 계층을 씀으로 인해 유스케이스와 워크플로우를 명확히 나눌 수 있었다. + +이를 통해 4.5.1에서 말한 아래 내용을 점검할 수 있다: + +1. 저장소에서 객체를 가져온다 +2. 애플리케이션이 아는 세계를 바탕으로 요청검사/검증(assertion) 한다 +3. 도메인 서비스를 호출한다 +4. 모두 정상실행했다면 변경된 상태를 저장/업데이트 한다 + +현재 단위테스트는 저수준에서 작동하며 모델에 직접 작용한다. 5장에서는 이런 테스트를 보다 상위 계층으로 끌어올려본다. 이때 해당하는 트레이드오프와 더 많은 일반적 테스트 지침을 살펴보자. + +# 5.1 테스트 피라미드는 어떻게 생겼나? + +```bash +(cosmic-python-py3.10) C:\cosmic_python\pt1\ch05>pytest --collect-only -qq +pt1/ch05/tests/e2e/test_app.py: 4 + +pt1/ch05/tests/integration/test_repository.py: 4 + +pt1/ch05/tests/unit/test_allocate.py: 4 +pt1/ch05/tests/unit/test_batches.py: 7 +pt1/ch05/tests/unit/test_services.py: 4 +``` + +그래도 피라미드처럼 생기긴 했구나… + +# 5.2 도메인 계층 테스트를 서비스 계층으로 옮겨야하나? + +한 단계 더 나아가면… + +서비스 계층에 대해 소프트웨어를 테스트하기 때문에 더이상 도메인 모델 테스트가 필요없다. 대신 1장에서 작성한 도메인 레벨의 테스트를 서비스 계층에 대한 테스트로 재작성한다. + +## 추상화를 한 단계 끌어올리자! + +### 이 코드를… + +```python +def test_prefers_current_stock_batches_to_shipments(): + in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None) + shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow) + line = OrderLine("oref", "RETRO-CLOCK", 10) + + allocate(line, [in_stock_batch, shipment_batch]) + + assert in_stock_batch.available_quantity == 90 + assert shipment_batch.available_quantity == 100 +``` + +### 대충 이런 식으로… + +```python +@pytest.mark.asyncio +async def test_prefers_current_stock_batches_to_shipments(): + in_stock_batch = model.Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None) + shipment_batch = model.Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow) + repo = FakeRepository([in_stock_batch, shipment_batch]) + + line = model.OrderLine("oref", "RETRO-CLOCK", 10) + await services.allocate(line, repo, FakeSession()) + + assert in_stock_batch.available_quantity == 90 + assert shipment_batch.available_quantity == 100 +``` + +## 이걸 왜 함? + +테스트로 시스템을 보다 쉽게 바꿀 수 있다는 것은 동의한다. 체감도 해봤으니까. + +하지만 저자는 도메인 모델에 의해 시간을 너무 허비하는 경우가 있을 수 있다고 한다. 코드베이스 하나 고치면 수십 수백개의 제반기능 테스트가 바뀔 수도 있으니까… + +테스트의 목적을 잘 생각해보자. + +- 변하면 안 되는 시스템의 특성을 강제로 유지하기 위해 사용한다 +- E.g., + - `200` 리턴이 계속 뜨는지? + - DB 세션이 커밋하고 있는지? + - 도메인 로직이 여전히 도는지? + +클린코드에서는 내게 [뭐라 말했는지](https://blog.s3ich4n.me/books/clean-code/2023-02-10-pt09#%EA%B9%A8%EB%81%97%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9C%A0%EC%A7%80%ED%95%98%EA%B8%B0) 떠올려보자 + +- 깨끗한 테스트코드? +- 테스트의 존재의의는 실제 코드 점검 +⇒ 이는 설계와 아키텍처를 깨끗하게 보존하는 열쇠! + - 이게 있어야 로직 고치기가 쉽다 + +잘 생각해보자. 프로그램을 바꾸면 테스트가 깨진다. 코드의 설계를 바꿀 때 코드에 의존하는 테스트가 실패한다는 뜻이다. + +책은 서비스 계층이 시스템을 다양한 방식으로 조정할 수 있는 API를 형성하게 된다는 점을 시사한다. API에 대해 테스트를 짜면 도메인 모델 리팩토링 시 변경해야하는 코드를 줄일 수 있다. 서비스 계층 테스트만 하도록 제한하고, 모델 객체의 ‘사적인’ 속성이나 메소드, 테스트가 직접 상호작용하지 못하게 하면 모델객체를 보다 자유롭게 리팩토링할 수 있다. + +> 테스트에 넣는 코드는 하나하나가 본드방울 같아서 시스템을 특정 모양으로 만든다. +테스트가 저수준이면 시스템 각 부분을 바꾸기가 어려워진다. +> + +# 5.3 어떤 종류의 테스트를 싸야할까? + +“그럼 죄다 다시 짜요?” 할 것이다. 이 질문에 답하기 위해선 결합과 설계 피드백 사이의 트레이드오프를 반드시 이해해야한다. + +![]([https://www.cosmicpython.com/book/images/apwp_0501.png](https://www.cosmicpython.com/book/images/apwp_0501.png)) + +익스트림 프로그래밍(XP)에서는 ‘코드에 귀기울여라(listen to the code)’ 라고 한다. (?) 테스트를 짤 때, 테스트 대상인 코드가 쓰기 어려운 코드인걸 발견하거나 코드 냄새를 맡을 수도 있다. 이러면 리팩토링하고 설계를 재점검 한다. + +하지만 대상 코드와 더 밀접하게 연관되어 작업할 때만 이런 피드백을 받을 수 있다. HTTP API에 대한 테스트는 훨씬 더 높은 수준의 추상화를 사용하므로 객체의 세부설계에 대한 피드백을 제공하지 않는다. + +전체 앱을 다시짜도 URL, 요청형식을 바꾸는게 아니면 앱은 HTTP 테스트를 계속 통과한다. 이러면 DB 스키마 변경 등의 대규모 변경 시에도 코드가 안망가지겠다 하는 자신감이 붙는다. + +이런 스펙트럼의 반대쪽에는 1장같은 테스트가 있다. 이런 테스트가 있으면 객체에 대한 이해증진에 크게 도움이 된다. 도메인 언어가 곧 테스트니까. + +이런 수준에서의 테스트는 새 행동양식을 ‘스케치’ 하고 코드가 어떻게 생겼는지를 살펴볼 수 있다. 하지만 이런 테스트는 특정 구현과 긴밀하게 연관되어있어서 코드 디자인을 개선하려면 이런 테스트를 다른 테스트로 대치하거나 바꿔야 한다. + +# 5.4 High and Low Gear + +새 기능을 추가하거나 버그를 수정할 때 도메인 모델을 크게 바꿀 필요가 없다. 도메인 모델을 바꿔야 하는 경우 더 낮은 결합과 더 높은 커버리지를 제공하므로 서비스에 대한 테스트를 작성하는 게 더 좋다. + +`add_stock`, `cancel_order` 같은 함수를 만드는 경우, 서비스 계층에 대한 테스트를 짜면 좀 더 빠르게 결합이 적은 테스트를 작성할 수 있다. + +새 플젝을 시작하거나 아주 어려운 특정 문제를 다뤄야 한다면 도메인 모델에 대한 테스트를 다시 짜서, 이를 통한 피드백을 얻고 의도를 더 명확하게 설명하는 “살아있는” 문서(테스트코드!)를 얻을 수 있다. + +이래서 필자는 저단기어, 고단기어라는 메타포(은유)를 사용했다. low gear로 빠르게 움직이기 시작하면 high gear로 바꿔서 더 빠르게 움직일 수 있다. 위험해서 속도를 낮춰야되면 기어비를 낮춰야된다. + +# 5.5 서비스 계층 테스트를 도메인으로부터 분리하기 + +서비스 테스트에는 도메인 모델에 대한 의존성이 있다. 테스트 데이터 설정 및 서비스 계층 함수 호출을 위해 도메인 객체를 쓰기 때문이다. + +이를 위해 원시타입만 사용하도록 다시 짜야한다. + +서비스 안의 `allocate()` 함수부터 시작하자. + +테스트가 함수를 호출하면서 원시타입을 쓰게 리팩토링 후… 5.5.1을 통해 헬퍼 함수나 픽스처로 도메인 모델을 내보내는 추상화를 한다. → 이러면 테스트의 의존성은 최대한 떨어뜨릴 수 있다. + +해당 서비스 로직에서는 모델을 쓰도록 한다! + +## 5.5.1 바꿔보자 (1) + +### 테스트가 이렇게 풀리고 + +```python +class FakeRepository(AbstractRepository): + ... + + @staticmethod + def for_batch(ref, sku, qty, eta=None): + return FakeRepository([ + model.Batch(ref, sku, qty, eta=None), + ]) + +# for_batch 같은 팩토리 함수를 만들어서 모든 도메인 의존성을 픽스처에 옮긴다! +@pytest.mark.asyncio +async def test_returns_allocation(): + repo = FakeRepository.for_batch("b1", "COMPLICATED-LAMP", 100, eta=None) + result = await services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession()) + assert result == "b1" +``` + +### 본 로직은 이렇게 풀린다 + +```python +async def allocate( + orderid: str, + sku: str, + qty: int, + repo: repository.AbstractRepository, + session, +) -> str: + """ batches를 line에 할당한다. + + FYI, + 의존성 역전 원칙이 여기 들어감에 유의! + 고수준 모듈인 서비스 계층은 저장소라는 추상화에 의존한다. + 구현의 세부내용은 어떤 영속 저장소를 선택했느냐에 따라 다르지만 + 같은 추상화에 의존한다. + + :param orderid: + :param sku: + :param qty: + :param repo: + :param session: + :return: + """ + line = model.OrderLine(orderid, sku, qty) + batches = await repo.list() + + if not is_valid_sku(line.sku, batches): + raise InvalidSku(f'Invalid sku {line.sku}') + + batchref = model.allocate(line, batches) + await session.commit() + + return batchref +``` + +## 5.5.2 추가해보자 (2) + +재고 추가 서비스(`add_batch`)를 만든다고 하자. 서비스 계층의 공식적인 유스케이스를 쓰는 서비스 계층 테스트 작성이 가능하다. 도메인에 대한 의존관계 또한 떼어낼 수 있다. + +> 저자의 팁 + +일반적으로 서비스 계층 테스트에서 도메인 계층에 있는 요소가 필요하다면 +이는 서비스 계층이 완전하지 않다는 사실을 보여주는 지표*일 수 있다*(*it may be an indication that your service layer is incomplete)*. +> + +### 테스트는 이렇게 + +```python +@pytest.mark.asyncio +async def test_add_batch(): + repo, session = FakeRepository([]), FakeSession() + await services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session) + + assert await repo.get("b1") is not None + assert session.committed +``` + +### 서비스 코드는 이렇게 + +```python +async def add_batch( + ref: str, + sku: str, + qty: int, + eta: Optional[date], + repo: repository.AbstractRepository, + session, +): + await repo.add(model.Batch(ref, sku, qty, eta)) + await session.commit() +``` + +진짜 최소한의 사용만을 했다. + +저자가 원하는 것은 **모든** 서비스 계층 테스트에 대해 의존성 없이 오직 서비스 자체와 원시타입만을 이용해서 짜는 것이다. + +> 저자는 `add_batch` 가 필요할 수도 있으니 만들어두고 테스트에서 의존성도 떼어냈다. 그래서 언제든지 리팩토링할 수 있는 것이다. +> + +# 5.6 E2E 테스트에 도달할 때 까지 계속 개선하기 + +`add_batch` 를 추가해서 서비스 계층 테스트를 모델에서 분리할 수 있었다. + +배치를 추가하는 API 엔드포인트를 추가하면 `add_stock` 같은 픽스처를 없앨 수도 있다. + +--- + +이건 좀 신박하네…. 어차피 필요한 기능이다 싶어서 과감하게 넣은건가? 이러면 테스트용 API 이런식인가? + +--- + +이거 정상적으로 돌리는건 UoW 되고나서 다시 할거다. + +하드코딩 SQL을 API콜로 바꾸면 API를 제외한 의존성을 분리완료했다는 의미가 된다(!). + +# 5.7 마치며 + +서비스 계층을 만들면 대부분의 테스트를 단위 테스트로 옮기고 건전한 테스트 피라미드(*a healthy test pyramid*)를 만들 수 있다. + +여러 유형의 테스트를 작성하는 간단한 규칙 + +1. ‘특성 당 E2E 테스트 하나를 만든다’ 라는 목표를 잡자 + 1. 이런 식의 테스트는 HTTP API를 쓸 가능성이 높다. 피처가 잘 작동하는 지 보고, 이에 따라 움직이는 모든 부품이 잘 연결되는지 살펴보는 것이다. +2. 테스트 대부분은 서비스 계층을 사용하여 만드는 것을 권한다. + 1. 이런 식의 테스트는 커버리지, 실행 시간, 효율 사이를 잘 절충하도록 도와준다. + 1. 각 테스트는 어떤 기능의 한 경로를 테스트하고 I/O에 가짜 객체(*fakes for I/O*)를 사용하는 경향이 있다. + 2. 이런 테스트는 모든 edge case를 다루고, 비즈니스 로직의 모든 입력/출력을 테스트해볼 수 있다. + 1. 8장을 보고나서 업데이트 할 것이다. 서로 협력하는 도메인 객체 사이의 저수준 단위 테스트를 제거함으로서 배워보자. +3. 도메인 모델을 사용하는 핵심 테스트를 적게 작성하고 유지하자 + 1. 이런 테스트는 커버리지가 작고(좁은 범위를 테스트), 더 깨지기 쉽다. 하지만 이 테스트가 제공하는 피드백이 가장 크다. + 2. 이런 테스트를 서비스 계층 기반으로 바꿀 수 있으면 바로바로 하는 것을 권한다. +4. 오류 처리도 특성으로 취급하자 + 1. **이상적인 경우** 앱은 모든 오류가 엔트리포인트까지(나는 FastAPI) 올라와서 처리된다.즉 테스트를 아래와 같이 유지하면 된다는 뜻이다: + 1. 모든 비정상경로를 테스트하는 E2E 테스트 한개 + 2. 각 기능의 정상경로만 테스트 diff --git a/content/books/cosmic-python/2023-04-16---pt01-ch05/media/universe.jpg b/content/books/cosmic-python/2023-04-16---pt01-ch05/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-04-16---pt01-ch05/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-04-16---pt01-ch06/index.md b/content/books/cosmic-python/2023-04-16---pt01-ch06/index.md new file mode 100644 index 00000000..9d6ebe45 --- /dev/null +++ b/content/books/cosmic-python/2023-04-16---pt01-ch06/index.md @@ -0,0 +1,386 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (6)" +date: "2023-04-16T19:09:45.000Z" +template: "post" +draft: false +slug: "/books/cosmic-python/2023-04-16-pt01-ch06" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 6, 작업단위 패턴에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +6장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt1/ch06) + +# 6장 Unit of Work(UoW) Pattern + +작업 단위 패턴(UoW Pattern) 은 저장소와 서비스 계층 패턴을 하나로 묶어주는 것을 의미한다. + +저장소 패턴이 영속적 저장소 개념에 대한 추상화라면 UoW 패턴은 원자적 연산(atomic operation) 개념에 대한 추상화를 의미한다. 이 패턴을 사용하면 서비스 계층과 데이터 계층의 분리가 가능하다. + +이게… + +![]([https://www.cosmicpython.com/book/images/apwp_0601.png](https://www.cosmicpython.com/book/images/apwp_0601.png)) + +이런 식의 UoW를 추가하여 DB의 상태를 관리하게 된다. + +![]([https://www.cosmicpython.com/book/images/apwp_0602.png](https://www.cosmicpython.com/book/images/apwp_0602.png)) + +목표는 다음과 같다 + +- API는 두 가지 일만 함 + - 작업 단위 초기화 + - 서비스 호출 + - 서비스는 UoW와 협력(저자는 UoW도 계층처럼 생각하는 편)한다 + - 서비스 함수 자체나 API는 DB와 직접 대화하지 않는다 +- 이 작업은 컨텍스트 매니저를 통해 수행한다(SQLAlchemy에서 이 철학을 쓴다) + +# 6.1 작업 단위는 저장소와 협력 + +이 패턴을 적용한 코드는 대충 이런 모습이다: + +```python +def allocate( + orderid: str, sku: str, qty: int, + uow: unit_of_work.AbstractUnitOfWork, +) -> str: + line = OrderLine(orderid, sku, qty) + with uow: #(1) + batches = uow.batches.list() #(2) + ... + batchref = model.allocate(line, batches) + uow.commit() #(3) +``` + +1. `contextmanager` 로 UoW 시작 +2. `uow.batches` 는 배치 저장소다. 즉 UoW는 영속적 저장소에 대한 접근을 제공한다. +3. 작업이 끝나면 커밋하거나 롤백한다 +(흠… uow 컨텍스트 매니저 끝에 try-except-finally 등으로 명시하는건 어떨까? +→ 라고 생각했다면 6.6장을 보십시오) + +UoW는 영속적 저장소에 대한 단일 진입점으로 작용한다. UoW는 어떤 객체가 메모리에 적재되었으며 어떤 객체가 최종 상태인지 기억한다[^1]. + +이 방식의 장점은 아래와 같다: + +1. 작업에 사용할 DB의 안정적인 스냅샷을 제공하고, 연산을 진행하는 과정에서 변경하지 않은 객체에 대한 스냅샷도 제공한다 +2. 변경 내용을 한번에 영속화할 방법을 제공한다. 어딘가 잘못되어도 일관성 없는 상태로 끝나지 않는다 +3. 영속성을 처리하기 위한 간단한 API와 저장소를 쉽게 얻을 수 있는 장소를 제공한다 + +# 6.2 테스트-통합 테스트로 UoW 조정하기 + +UoW의 통합 테스트는 아래와 같다: + +```python +def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory): + session = session_factory() + insert_batch(session, 'batch1', 'HIPSTER-WORKBENCH', 100, None) + session.commit() + + uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) # (1) + + with uow: + batch = uow.batches.get(reference='batch1') # (2) + line = model.OrderLine('o1', 'HIPSTER-WORKBENCH', 10) + batch.allocate(line) + uow.commit() + + batchref = get_allocated_batch_ref(session, 'o1', 'HIPSTER-WORKBENCH') + assert batchref == 'batch1' +``` + +UoW는 “뭘 해야할지”를 테스트한다. + +1. 커스텀 세션 팩토리로 세션을 받아온다 +2. `uow.batches` 를 통해서 배치 저장소에 대해 접근한다 +3. 작업이 끝나면 UoW에 대한 `commit()` 을 호출한다 + +`insert_batch` 나 `get_allocated_batch_ref` 는 헬퍼 함수다 + +# 6.3 작업 단위와 작업 단위의 `contextmanager` + +테스트 코드에서는 UoW의 인터페이스가 뭘 해야될지 기재했다. 그렇다면 추상 클래스를 통해 인터페이스를 제공하자. + +```python +import abc + +from pt1.ch06.adapters import repository + +class AbstractUnitOfWork(abc.ABC): + batches: repository.AbstractRepository # (1) + + def __aexit__(self, exc_type, exc_val, exc_tb): # (2) + self.rollback() # (4) + + @abc.abstractmethod + async def commit(self): # (3) + raise NotImplementedError + + @abc.abstractmethod + async def rollback(self): # (4) + raise NotImplementedError +``` + +1. 저장소에 접근할 수 있도록 설정한다. +2. 컨텍스트 매니저에 대한 매직 메소드. 이를 통해 `with` 구문을 쓸 수 있다. + 1. 추가로, `__aenter__` 나 `__aexit__` 은 비동기 처리를 위한 구문이다. +3. 커밋할 때가 되면 이 메소드로 커밋한다. +4. 문제가 생기면 예외를 발생시켜 컨텍스트 매니저를 빠져나가면 알아서 rollback 한다 + +## 6.3.1 SQLAlchemy 세션을 이용하는 실제 UoW + +```python +class SqlAlchemyUnitOfWork(AbstractUnitOfWork): + def __init__(self, session_factory): + self._session_factory = session_factory # (1) + + async def __aenter__(self): # (2) + self._session = self._session_factory() + self.batches = repository.SqlAlchemyRepository(self.session) + return await super().__aenter__() + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await super().__aexit__(exc_type, exc_val, exc_tb) + await self._session.close() # (3) + + async def commit(self): # (4) + await self._session.commit() + + async def rollback(self): # (4) + await self._session.rollback() +``` + +1. 세션 팩토리를 여기서 골라갈 수 있게 한다. 통합테스트에서는 오버라이드를 수행해서 SQLite를 쓰게 만들 것이다 +2. `__aenter__` 는 DB세션 시작 및 저장소를 인스턴스화한다 +3. 컨텍스트 관리자에서 나올 때 세션을 닫는다 +4. 세션에 사용할 `commit()` 과 `rollback()`을 제공한다 + +## 6.3.2 테스트를 위한 가짜 UoW + +```python +class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork): + def __init__(self): + self.batches = FakeRepository([]) # (1) + self.committed = False # (2) + + async def commit(self): + self.committed = True # (2) + + async def rollback(self): + pass + +... + +@pytest.mark.asyncio +async def test_add_batch(): + uow = FakeUnitOfWork() # (3) + await services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow) + + assert await uow.batches.get("b1") is not None + assert uow.committed + +@pytest.mark.asyncio +async def test_returns_allocation(): + uow = FakeUnitOfWork() # (3) + + repo = FakeRepository.for_batch("b1", "COMPLICATED-LAMP", 100, eta=None) + result = await services.allocate("o1", "COMPLICATED-LAMP", 10, uow) + assert result == "b1" +``` + +1. 이 둘은 마치 결합된(coupled) 것 처럼 밀접하게 연관되어있다. 그렇지만 이 둘은 서로 협력자니까 크게 문제되지 않는다 +2. 가짜 commit과 `FakeSession` 은 제 3자의 코드가 아니라 “내 코드” 를 가짜로 구현한 것이다. 이것은 큰 개선이다! ‘[당신이 만든 것이 아니면 모킹하지 마라](https://github.com/testdouble/contributing-tests/wiki/Don't-mock-what-you-don't-own)’ 하는 말에 부합하기 때문이다. +3. 테스트에서는 UoW를 인스턴스화 하고 서비스 계층에 저장소와 세션을 넘기는 대신 이거 하나로 퉁칠 수 있다. 훨씬 덜 번거롭다! + +### 당신이 만든 것이 아니면 모킹하지 마라(**Don't mock what you don't own)** + +세션보다 UoW를 모킹한게 편한 이유는 뭘까? 두가지 가짜(UoW, 세션)는 목적이 같다. 영속성 게층을 바꿔서 실제 DB를 안 쓰고 메모리 상에서 테스트할 수 있게 하는 것이다. 가짜 객체 두 개를 써서 얻을 수 있는 최종 설계에 차이가 있다. + +예를들어 SQLAlchemy 대신 목 객체를 만들어서 Session을 코드 전반에 쓰면, DB 접근 코드가 코드베이스 여기저기에 흩어진다. 이런 상황을 피하기 위해 영속적 계층에 대한 접근을 제한해서 필요한 것”만” 가지게 한다. + +코드를 Session 인터페이스와 결합하면 SQLAlchemy의 모든 복잡성과 결합하기로 하는 대신 더 간단한 추상화를 택하고 이를 통해 책임을 명확히 분리한다. + +이 문단이 시사하는 바는 복잡한 하위 시스템 위에 간단한 추상화를 만들도록 해주는 기본 규칙이다. 간단한 추상화를 하면 성능상으로는 동일하나 내 설계가 맞는 방안인지 보다 신중하게 생각하도록 해준다. + +# 6.4 UoW 를 서비스계층에 써먹기 + +이런식으로 리팩토링이 된다. + +```python +async def add_batch( + ref: str, + sku: str, + qty: int, + eta: Optional[date], + uow: unit_of_work.AbstractUnitOfWork # (1) +): + async with uow: + await uow.batches.add(model.Batch(ref, sku, qty, eta)) + await uow.commit() + +async def allocate( + orderid: str, + sku: str, + qty: int, + uow: unit_of_work.AbstractUnitOfWork # (1) +) -> str: + line = model.OrderLine(orderid, sku, qty) + + async with uow: + batches = await uow.batches.list() + if not is_valid_sku(line.sku, batches): + raise InvalidSku(f'Invalid sku {line.sku}') + + batchref = model.allocate(line, batches) + await uow.commit() + + return +``` + +1. 서비스 계층의 의존성은 UoW 추상화 하나 뿐이다 + +# 6.5 커밋/롤백에 대한 명시적 테스트 + +UoW를 구현해봤으니, 이러면 커밋/롤백을 테스트 하고싶어진다. + +```python +@pytest.mark.asyncio +async def test_rolls_back_uncommitted_work_by_default(session_factory): + uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) + async with uow: + insert_batch(uow._session, 'batch1', 'MEDIUM-PLINTH', 100, None) + + new_session = session_factory() + rows = list( + await new_session.execute(text('SELECT * FROM batches')) + ) + assert rows == [] + +@pytest.mark.asyncio +async def test_rolls_back_on_error(session_factory): + class MyException(Exception): + pass + + uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) + with pytest.raises(MyException): + async with uow: + insert_batch(uow._session, 'batch1', 'MEDIUM-PLINTH', 100, None) + raise MyException() + + new_session = session_factory() + rows = list( + await new_session.execute(text('SELECT * FROM batches')) + ) + assert rows == [] +``` + +아래 내용을 테스트한다! + +- 커밋 안 한 내용이 DB에 실제로 “없는지” 확인 +- 롤백으로 인해 DB에 실제로 “없는지” 확인 + +> Tip +트랜잭션 같은 ‘불확실한’ DB동작을 ‘실제’ DB엔진에 대해 테스트할 가치가 있다. +Postgres같은 RDBMS로 바꾸고 나서 테스트하면 훨씬 편리할 것이다. +> + +# 6.6 명시적 커밋과 암시적 커밋 + +디폴트로 결과를 커밋하고 예외 발생 시에만 롤백하는 (처음 내 생각대로) 의 UoW는 `__aexit__` 에서 `exn_type` 이 `None` 일 때 커밋하는 것이다. + +그런데 저자는 명시적 커밋이 낫다고 생각한다. 소프트웨어가 명령을 안 내리면 **************아무 것도 안 한다**************가 낫다라고 생각한다. 코드의 실행 상태를 추론하기도 보다 나아진다. 명시적이니까. + +그리고 롤백하면 걍 마지막 지점으로 돌아가니까 중간 변화를 모두 포기한다. 로직 파악이 수월하다는 장점이 있다. + +# 6.7 예제: UoW를 사용해 여러 연산을 원자적 단위로 묶기 + +UoW를 통한 코드 추론이 쉬워지는 것을 살펴보자! + +## 6.7.1 예제 1: 재할당 + +```python +def reallocate( + line: OrderLine, + uow: AbstractUnitOfWork, +) -> str: + with uow: + batch = uow.batches.get(sku=line.sku) + if batch is None: + raise InvalidSku(f"invalid sku {line.sku}") + batch.deallocate(line) # (1) + allocate(line) # (2) + uow.commit() +``` + +1. `deallocate()`이 실패하면 당연히 `allocate()`이 안 돌기를 바란다 +2. `allocate()`이 실패하면 `deallocate()` 한 결과만 커밋하고 싶지는 않을 것이다 + +둘 다 제대로 작동하기를 바란다는 뜻 + +## 6.7.2 예제 2: 배치 수량 변경 + +운송 중 문제가 생겨 제대로 배송이 안 되었다는 상황을 코드로 풀어보자 + +```python +def change_batch_quantity( + batchref: str, + new_qty: int, + uow: AbstractUnitOfWork, +): + with uow: + batch = uow.batches.get(reference=batchref) + batch.change_purchased_quantity(new_qty) + while batch.available_quantity < 0: + line = batch.deallocate_one() # (1) + uow.commit() +``` + +1. 원하는 만큼 할당 해제를 하려 하지만, 실패하면 그 어떤 사항도 적용되면 안 된다! 정합성을 유지해야한다! + +# 6.8 통합 테스트 정리하기 + +`integration` 디렉토리 안을 보면 테스트 관련 코드가 3개 있다. + +- `test_orm.py` + - SQLAlchemy 를 내 로직에 맞게 풀어낸 것을 테스트한다 +- `test_repository.py` + - 주요 리포지토리 로직을 테스트한다 +- `test_uow.py` + - 추상화 레벨을 올리면 좋을듯? + +# 6.9 마치며 + +UoW의 유용성과 `contextmanager` 를 통한 pythonic code 생성을 맛보았다. + +근데 이미 사실 SQLAlchemy 내부적으로 Session 객체가 UoW대로 구현되어있다. SQLAlchemy의 세션객체는 DB에서 새 엔티티를 읽을 때마다 엔티티의 변화를 추적하고, 세션 `flush` 를 수행할 때 모든 내용을 한꺼번에 영속화한다. + +근데 쓰는 이유가 있겠지? 여기까지의 트레이드오프를 살펴보자: + +| 장점 | 단점 | +| --- | --- | +| 원자적 연산을 표현하는 좋은 추상화 레벨을 가진다. contextmanager 를 사용해서 atomic하게 한 그룹으로 묶어야 하는 코드 블록을 시각적으로 쉽게 알아볼 수 있다. | ORM은 이미 원자성을 중심으로 좋은 추상화를 제공할 수도 있다. SQLAlchemy에는 이미 contextmanager를 제공한다. 세션을 주고받는 것 만으로도 많은 기능을 꽁으로 먹을 수 있다 | +| 트랜잭션 시작-끝 을 명시적으로 제어할 수 있고, 앱이 실패하면 롤백한다. 연산이 부분적으로 커밋되는 걱정을 덜어낼 수 있다 | 롤백, 다중스레딩, nested transactions등의 코드를 짤 때는 보다 더 신중하게 접근해야 한다. | +| 원자성은 트랜잭션 뿐 아니라 이벤트, 메시지 버스를 사용할 때도 도움이 된다. | | + +SQLAlchemy의 Session API는 풍부한 기능과 도메인에서 불필요한 연산을 제공한다. UoW는 세션을 단순화해 핵심부분만 쓸 수 있게 해준다. UoW를 시작하고, 커밋하거나 작업결과를 갖다버릴 수도 있다(*thrown away*). + +UoW를 써서 Repository 객체에 접근하는건 그냥 SQLAlchemy Session 만 써선 쓸 수 없는 장점을 가진다. + +## UoW 정리 + +1. UoW 패턴은 데이터 무결성 중심 추상화다 + 1. 연산 끝에 commit (이후 flush) 를 통해 도메인 모델의 일관성을 강화하고 성능향상(?)에 도움이 된다 +2. 저장소, 서비스 계층 패턴과 밀접하게 연관되어 작동한다 + 1. UoW는 원자적 업데이를 표현해 데이터 접근에 대한 추상화를 완성시켜준다. 서비스 계층의 유스케이스들은 블록단위로 성공하거나 실패하는 별도의 작업단위로 실행된다 +3. contextmanager를 쓰는 유스케이스 + 1. rollback을 파이썬스럽게 풀어냈다는 점에서 이미 매우 훌륭하다 +4. SQLAlchemy는 이미 UoW 패턴을 제공한다 + 1. SQLAlchemy의 Session 객체를 더 간단히 추상화해서 필요한 기능”만” 쓸 수 있게 노출시킨다. +5. UoW 도 추상화로 또 감싸서 의존성 역전 원칙을 활용한다 + +[^1]: 어떤 목표를 달성하기 위해 서로 협력하는 객체를 묘사하는 협력자(collaborator) 라는 단어가 있다. UoW와 Repository는 객체 모델링의 측면에서 아주 적절한 협력자의 예시라 할 수 있다. 책임 주도 설계에서 자신의 역할 안에서 협력하는 여러 객체를 이웃 객체(object neighborhood)라고 한다. diff --git a/content/books/cosmic-python/2023-04-16---pt01-ch06/media/universe.jpg b/content/books/cosmic-python/2023-04-16---pt01-ch06/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-04-16---pt01-ch06/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-04-17---pt01-ch07/index.md b/content/books/cosmic-python/2023-04-17---pt01-ch07/index.md new file mode 100644 index 00000000..aac4ecaf --- /dev/null +++ b/content/books/cosmic-python/2023-04-17---pt01-ch07/index.md @@ -0,0 +1,563 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (7)" +date: "2023-04-17T19:22:59.000Z" +template: "post" +draft: false +slug: "/books/cosmic-python/2023-04-17-pt01-ch07" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 7, 애그리게이트와 일관성 경계에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +7장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt1/ch07) + +# 7장 애그리게이트와 일관성 경계 + +도메인 모델을 보면서 불변조건, 제약에 대해 다시 살펴보고 도메인 모델 객체가 개념적으로나 영속적 저장소 안에서나 내부적인 일관성을 유지하는 방법을 살펴본다[^1]. + +일관성 경계(consistency boundary)를 설명하며, 이를 통해 어떻게 유지보수 편의를 해치지 않으면서 고성능 소프트웨어를 만들 수 있게 도와주는지 살펴보자. + +애그리게이트가 추가되면 도메인을 이런식으로 표현할 수 있게 된다 + +![https://www.cosmicpython.com/book/images/apwp_0701.png](https://www.cosmicpython.com/book/images/apwp_0701.png) + +# 7.1 모든 것을 스프레드시트에서 처리하지 않는 이유 + +도메인 모델의 요즘은 무엇일까? 이걸로 얻는 근본적인 이득이 뭘까? + +걍 스프레드시트로 다 하면 안되나? 어차피 사용자들은 하나로 다 되는거 엄청 좋아하는데. + +많은 수의 기업운영이 CSV over SMTP 수준의 아키텍처에서 걍 머물러있다. 역으로 말하면 딱 그정도의 회사들이 버글버글하단 이야기다. 일단 잘되고 좋은데 일관성이 없어서 확장이 안된다. + +예를 들어… + +- 특정 필드는 누구만 볼 수 있도록 설정되어있는가? + - 누가 그 ‘누구’를 변경할 수 있는가? +- 의자를 -350개 주문하거나 식탁을 10M개 주문할 수 있나? +- 직원의 월급이 음수가 될 수 있나? + +이런 건 일어나서도 안 된다. 도메인 로직은 이런 제약사항을 강제로 지키게 해서 시스템이 만족하는 불변조건을 유지하려는 목적으로 작성된다. 불변조건(*invariants)*은 어떤 연산을 끝낼 때 마다 항상 참이어야 하는 요소를 의미한다. + +# 7.2 불변조건, 제약, 일관성 + +- 제약(constraint): 모델이 취할 수 있는 상태의 수를 제한 +- 불변조건(invariants): 항상 참이어야 하는 조건 + +E.g., 호텔 예약 시스템을 작성한다면 중복 예약을 허용하지 않는 ***제약***이 있을 수 있다. 이 제약은 한 객실에 예약 한 개만 있을 수 있다는 ***불변조건***을 지원한다. + +경우에 따라 규칙을 일시적으로 완화(*bend*)해야할 수도 있다. VIP가 예약하면 VIP의 숙박 기간과 위치에 맞춰 주변의 방 예약을 섞어야 할 수도 있다. + +메모리상에서 예약을 섞는 동안 한 곳에 예약이 2개 이상 발생할 수도 있지만, 도메인 모델은 작업이 완료되면 불변성이 충족되는 최종 일관된 상태가 되도록 보장해야 한다. 모든 고객이 만족하는 방안을 못찾고 연산이 끝나면 안 되고 오류를 발생시켜야 한다. + +다시 예시로 돌아가보자. 이 요구사항부터 시작해보자. + +> 주문 라인은 한번에 한 배치에만 할당될 수 있다 +> + +이런 규칙은 불변조건을 만드는 비즈니스 규칙이다. 불변조건은 주문 라인이 `0`또는 `1`개의 배치에만 할당될 수 있고, `2` 개 이상의 배치에 할당될 수는 없다는 것이다. 코드가 실수로 같은 라인에 대해 `Batch.allocate()` 를 두 가지 다른 배치에 호출하는 일이 없도록 해야한다. 현재까지의 코드에선 그런 명시적인 코드는 없다. + +## 7.2.1 불변조건, 동시성, 락 + +비즈니스 로직의 다른 요구사항을 살펴보자 + +> 주문 라인 수량보다 더 작은 배치에 라인을 할당할 수는 없다 +> + +여기서의 제약조건은 배치에 있는 재고보다 많은 재고를 라인에 할당할 수 없다는 것이다. 이로 인해 두 고객에게 제품을 재고보다 더 많이 파는 일은 발생할 수 없다. 이 제약을 불변조건으로 바구면 가용 재고 수량이 `0`이상이어야 한다는 조건이 된다[^2]. 시스템 상태를 업데이트 할 때마다 코드는 이런 불변조건을 어기지 않는지 확인해야 한다. + +동시성(*concurrency*)를 도입하면 더 복잡해진다. 갑자기 재고를 여러 주문 라인에 동시에 할당할 수 있게 된다. 심지어 배치 변경과 동시에 주문라인을 할당할 수도 있다. + +보통은 DB 테이블에 lock을 걸어서 해결한다. 두 연산이 동시에 일어나는 것을 방지하기 위함이다. + +앱의 규모확장을 생각하면 모든 배치에 라인을 할당하는 모델은 규모를 키우기 어렵다는 사실을 깨닫는다(내 생각엔 이게 핵심 포인트다. 도메인을 알고 있다면/논의하다보면 이런 추론을 할 수 있어야 한다). 시간당 수만 건의 주문과 수십만 건의 주문 라인을 처리하려면 전체 테이블의 각 row에 lock을 거는 것 만으로는 안 된다. 이러면 데드락 상태에 빠질 수 있다. + +# 7.3 Aggregate 이란? + +주문 라인을 할당하고 싶을 때마다 DB에 lock을 걸 수 없다면 어떻게 해야할까? 시스템의 불변조건을 보호하면서 동시성을 최대한 살리고 싶다. 불변조건을 유지하려면 불가피하게 동시 쓰기를 막아야 한다. 여러 사용자가 `DEADLY-SPOON` 을 동시에 할당할 수 있다면 과할당이 이루어질 위험이 생긴다. + +반면, `DEADLY-SPOON` 과 `FLIMSY-DESK` 를 동시에 할당할 수 없는 이유는 없다. 두 제품에 동시에 적용되는 불변조건이 없기 때문이다. 즉 서로 다른 두 제품에 대한 할당 사이에 일관성이 있을 이유는 없다. + +**애그리게이트**(Aggregate) 패턴은 이런 긴장을 해소하기 위한 설계 패턴이다. 애그리게이트는 다른 도메인 객체를 **포함**하며 이 객체 컬렉션 전체를 한꺼번에 다룰 수 있게 해주는 도메인 객체다. + +애그리게이트에 있는 객체를 변경하는 유일한 방법은 애그리게이트와 그 안의 객체 전체를 불러와서 애그리게이트 자체에 대해 메소드를 호출하는 것이다. + +모델이 점점 복잡해지고 엔티티와 VO가 늘어나면서 각각에 대한 참조가 얽히고설킨 그래프가 된다. 따라서 누가 어떤 객체를 변경할 수 있는지 추적하기 어려워진다. 특히 모델안에 **컬렉션**이 있으면 어떤 엔티티를 선정해서 그 엔티티와 관련된 모든 객체를 변경할 수 있는 단일 진입점으로 삼으면 좋다[^3]. 이러면 시스템이 개념적으로 더 간단해지고 어떤 객체가 다른 객체의 일관성을 책임지게 하면 시스템에 대해 추론하기 쉬워진다. + +쇼핑몰 설계를 예로 들어보자. 장바구니(*cart*)는 좋은 애그리게이트가 된다. 장바구니는 한 단위로 다뤄야 하는 상품들로 이루어진 **컬렉션**이다. 중요한 점은 데이터 스토어에서 전체 장바구니를 단일 blob으로 읽어오고 싶다는 점이다. 동시에 장바구니 변경을 위해 요청을 두번 보내고 싶지도 않고 이상한 동시성 오류를 발생하게 하고싶지도 않다. 대신 장바구니에 대한 모든 변경을 단일 DB 트랜잭션으로 묶고싶다. + +하지만 여러 고객의 장바구니를 동시에 바꾸는 유스케이스는 없다. 여러 장바구니를 한 트랜잭션 안에서 바꾸고 싶진 않다. 따라서 각 장바구니는 자신만의 불변조건을 유지할 책임을 담당하는 한 **동시성 경계**다. + +> 애그리게이트는 데이터 변경이라는 목적을 위해 한 단위로 취급할 수 있는 연관된 객체의 묶음이다. + +Eric Evans, 도메인 주도 설계(위키북스, 2011) +> + +Evans에 따르면, 애그리게이트에는 원소에 대한 접근을 캡슐화한 루트 엔티티(장바구니) 가 있다. 원소마다 고유한 정체성이 있지만, 시스템의 나머지 부분은 장바구니를 나눌 수 없는 단일 객체처럼 참고해야 한다. + +# 7.4 애그리게이트 선택 + +그렇다면 시스템에 어떤 애그리게이트를 써야할까? 내 생각엔 좋은 설계를 위해선 이걸 잘 정해야 한다고 본다. 애그리게이트는 모든 연산이 일관성 있는 상태에서 끝난다는 점을 보장하는 경계가 되기 때문이다. 이러한 사실은 소프트웨어에 대해 추론하고 이상한 경합지점을 방지할 수 있게 해준다. 서로 일관성이 있어야 하는 소수의 객체 주변에 경계를 설정하고자 한다. 성능을 위해선 경계가 더 작을 수록 좋다. 이런 경계에는 좋은 이름을 부여해주는 것 또한 필요하다. + +애그리게이트 내부에서 다뤄야 하는 객체는 `Batch` 이다. 이 컬렉션을 뭐라고 부르는게 좋을까? 어떻게 시스템의 모든 배치를 내부에서 일관성이 보장되는 다른 섬들로 나눌 수 있을까? + +- shipment? + - 선적엔 여러 배치가 들어갈 수 있다 + - 모든 배치는 창고로 전달된다 +- warehouse? + - 각 창고에는 여러 배치가 들어있다 + - 모든 재고수량을 동시에 파악할 수도 있다 + +그런데 두 상품이 같은 창고에 있거나 같은 선적에 포함되어있어도 동시할당이 된다. 아까 위에서 이렇게 파악했다: + +> `DEADLY-SPOON` 과 `FLIMSY-DESK` 를 동시에 할당할 수 없는 이유는 없다. +> + +그런고로 상기 둘은 경계로 두긴 어렵다. + +주문 라인을 할당할 때는 주문 라인으로 같은 `SKU` 에 속하는 배치에만 관심이 있다. 글로벌한 SKU stock 같은 개념이 필요하다. `GlobalSkuStock` 으로 할까? 근데 저자는 저 이름이 너무 촌스러워서 `Product` 로 하기로 했다[^4]. + +기존에는, 주문 라인을 할당하고 싶으면 모든 `Batch` 객체를 살펴보고 이들을 `allocate()` 도메인 서비스에 전달했다. + +![도메인 서비스를 사용해 모든 배치를 할당](https://www.cosmicpython.com/book/images/apwp_0702.png) + +앞으로는 `Product` 객체한테 일임할 것이다. 이 객체는 주문 라인에서 특정 SKU를 표현한다. Product 객체는 **자신이 담당하는 SKU**에 대한 **모든 배치**를 담당한다. `allocate()` 메소드를 `Product`에 대해 호출하도록 열어둘 것이다. + +![`Product` 를 추가. 이 객체가 관리하는 배치를 할당해달라고 요청](https://www.cosmicpython.com/book/images/apwp_0703.png) + +코드를 보자. + +```python +class Product: + def __init__(self, sku: str, batches: List[Batch]): + self.sku = sku #(1) + self.batches = batches #(2) + + def allocate(self, line: OrderLine) -> str: #(3) + try: + batch = next(b for b in sorted(self.batches) if b.can_allocate(line)) + batch.allocate(line) + return batch.reference + except StopIteration: + raise OutOfStock(f"Out of stock for sku {line.sku}") +``` + +1. `Product`의 주요 식별자는 `sku` 다. +2. `Product`클래스는 sku에 해당하는 `batches` 컬렉션 참조를 유지한다 +3. `allocate()` 도메인 서비스를 이 애그리게이트가 제공한다. + +> `Product` 는 통상 우리가 생각하는 `Product` 하고 좀 다르다… 가격도 없고 설명도 없고 크기도 없고… + +하지만 현재 서비스에서는 그런 걸 고민할 필요가 없다. 이게 제한된 컨텍스트(이하 Bounded Context)의 강점이다. Bounded Context 상에서는 한 앱의 `Product` 개념이 다른 앱에서의 `Product` 와 완전히 다를 수 있다. +> + +## Aggregates, Bounded Contexts and Microservices + +### 개념 소개 및 전개 + +이 개념은 근본적으로 전체 비즈니스를 한 모델에 넣으려는 시도에 대한 반응이었다. “고객” 이라는 컨텍스트를 가지고 이해해보자. + +“고객” 이란 단어도 각 분야별 사람들에겐 각자 다른 의미를 가진다(판매상에서의 고객, CS에서의 고객, 배송에서의 고객, 지원에서의 고객 등). 그러니 각 “고객”의 속성이나 의미는 분야(컨텍스트)가 달라지면 완전히 다른게 되어버림을 알 수 있다. + +이런식으로 모든 유스케이스를 잡아내는 단일 모델(클래스, DB 등)을 만드는 대신 여러 모델을 만들고 각 컨텍스트 간의 경계(Bounded Context)를 잘 잡은 후 여러 컨텍스트를 왔다갔다 할 때 명시적인 변환을 처리하자는 아이디어가 도출되었다. + +그러니 자연스럽게 마이크로서비스가 대두된다. 각 마이크로서비스가 각자 자유롭게 “고객” 개념을 가지고, 자신이 통합해야 하는 다른 마이크로서비스의 개념으로 변환해 가져오거나 내보낼 수 있다. + +### (대충) 예시로 살펴보자 + +할당 서비스 에서는 `Product(sku, batches)` 가 있을 수 있을 것이고, 어떤 전자상거래의 `Product` 라면 `Product(sku, description, price, image_url, dimensions, ...`) 가 있을 수 있을 것이다. 내가 앞으로 작성해야 할 도메인 모델은 오직 내가 계산을 수행하기 위한 필요 데이터만을 포함해야 한다! + +마이크로서비스를 떠나서, 애그리게이터를 선택할 때는 어떤 제한된 컨텍스트 안에서 애그리게이트를 실행할지 선택해야 한다. 컨텍스트를 제약하면 애그리게이트의 숫자를 낮게 유지하고 그 크기를 관리하기 좋은 크기로 유지할 수 있다. + +나중에 아래 책들을 꼭 사서 보자...[^5] + +- [반 버논, <도메인 주도 설계 핵심> (에이콘출판사, 2017)](http://www.yes24.com/Product/Goods/48577718) +- 지금 이 책! +- [반 버논, <도메인 주도 설계 구현> (저자가 빨간 책이라 하는거) (에이콘출판사, 2016)](http://www.yes24.com/Product/Goods/25100510) +- [에릭 에반스, <도메인 주도 설계> (저자가 파란 책이라 하는거) (위키북스, 2022)](http://www.yes24.com/Product/Goods/116613006) + +# 7.5 **One Aggregate = One Repository** + +애그리게이트가 될 엔티티를 정의하고 나면 외부 세게에서 접근할 수 있는 유일한 엔티티가 되어야 한다는 규칙을 적용해야 한다. 허용되는 모든 저장소는 오직 애그리게이트만을 반환해야 한다. + +> 저장소가 애그리게이트만 반환해야 한다는 규칙은 애그리게이트가 **도메인 모델에 접근하는 유일한 통로**라는 관례를 지키도록 하는 핵심 규칙이다. 이를 어기지 말자! +> + +시작해보자! + +그러면 기존에 있던 `BatchRepository` 는 `ProductRepository` 가 될 것이다. 천천히 코드를 갈아보자… + +- UoW와 저장소 객체를 살펴보자 + +```python +class AbstractUnitOfWork(abc.ABC): + products: repository.AbstractProductRepository + ... + +class AbstractProductRepository(abc.ABC): + @abc.abstractmethod + async def get(self, reference) -> List['Product']: + raise NotImplementedError + + @abc.abstractmethod + async def add(self, product: 'Product'): + raise NotImplementedError +``` + +ORM계층을 조절해서 올바른 배치를 가져오게 한 후 Product 객체와 연관시켜야 한다. `Repository` 패턴을 쓰면 이를 어떻게 연관시킬지 아직 신경쓰지 않아도 된다. `FakeRepository` 를 쓰고 새 모델을 서비스 계층으로 전달해서 `Product` 가 엔트리포인트인 경우 서비스 계층이 어떤 모습일지 코드로 볼 수 있다. + +- 서비스 계층의 변화를 살펴보자 + - 의외로 쉽게 갈아끼워진다! 서비스 계층에서 서비스”만”을 바라보도록 코드를 작성한 도움인 것 같다. + +```python +async def add_batch( + ref: str, + sku: str, + qty: int, + eta: Optional[date], + uow: unit_of_work.AbstractUnitOfWork +): + async with uow: + product = uow.products.get(sku=sku) + + if product is None: + product = model.Product(sku=sku, batches=[]) + uow.products.add(product) + + await product.batches.add(model.Batch(ref, sku, qty, eta)) + await uow.commit() + +async def allocate( + orderid: str, + sku: str, + qty: int, + uow: unit_of_work.AbstractUnitOfWork +) -> str: + line = model.OrderLine(orderid, sku, qty) + + async with uow: + product = await uow.products.get(sku=line.sku) + if product is None: + raise InvalidSku(f'Invalid sku {line.sku}') + + batchref = product.allocate(line) + await uow.commit() + + return batchref + +async def deallocate( + orderid: str, + sku: str, + qty: int, + uow: unit_of_work.AbstractUnitOfWork, +): + line = model.OrderLine(orderid, sku, qty) + async with uow: + product = await uow.products.list() + + if product is None: + raise InvalidSku(f'Invalid sku {line.sku}') + + model.deallocate(line, product) + await uow.commit() +``` + +# 7.6 성능은 어떨까? + +저자가 성능좋은 소프트웨어를 원하기 때문에 애그리게이트로 모델링한다고 여러 번 말했다. 근데 배치 하나만 요청해도 **모든** 배치를 읽어온다. 그럼 안 좋은거 아닌가? 라고 생각했는데 근거가 있다: + +1. 현재는 의도적으로 DB질의를 한 번만 하고 변경된 부분을 한 번만 영속화하여 데이터를 모델링하는 기법을 사용중이다. 이런 방식은 소프트웨어가 진화하면 할 수록 여러번 다양한 질의를 던지는 프로그램보다 시스템 성능이 나은 경향이 있다. +2. 데이터 구조를 최소한으로 쓰며 한 row 당 최소한의 문자열과 정수만 만든다. 이러면 수백개의 배치를 메모리로 가져올 수 있다. +3. 시간이 지나도 가져오는 데이터의 양은 제어를 벗어나지 않는다. +예를 들어 어느 시점에서는 상품마다 20개 정도의 배치가 있을 것이라 “예상” 한다. 배치를 다 사용하고나면 이 배치를 계산에서 배제할 수 있다. +4. 만일 상품 당 몇천 개의 배치가 있다 예상된다면 배치의 로드방식을 lazy-loading(지연 읽기)으로 처리한다. SQLAlchemy는 이미 데이터를 페이지 단위로 읽어온다. 이렇게 하면 적은 수의 row를 가져오는 DB요청이 더 많아진다. 이렇게 점진적으로 행을 가져오는 방식도 잘 작동한다. + +코드를 짜보자. + +그리고, 다른 모든 방법이 실패하면 다른 애그리게이트를 살펴본다. + +- 어쩌면 배치를 지역/창고별로 나눠야할 수도 있다 +- 아니면 선적이라는 개념을 중심으로 데이터 접근전략을 재설계 해야할 수도 있다 + +애그리게이트 패턴은 일관성과 성능을 중심으로 여러 기술적 제약사항을 관리하는데 도움이 되도록 설계된 패턴이다. 올바른 애그리게이트가 **하나만 있는 것은 아니다**. 설정한 경계가 성능을 떨어뜨린다면 **언제든 설계를 다시** 할 준비를 하자. **바꿔도 좋다. 언제든 바꿀 수 있다고 생각하고, 또 이게 편하다고 느껴야 한다.** + +# 7.7 버전 번호와 낙관적 동시성 + +DB 수준에서 데이터 일관성을 강제할 수 있는 방법을 더 살펴보자 + +> 이번 절(*section*)에서는 구현을 다룬다. 또한 Postgres-specific 코드다. +여러 접근방법 중 하나일 뿐이다. + +실전에서는 요구사항 별로 다르게 접근해야할 수도 있다. +***코드를 절대 프로덕션에 복붙하지 마시오.*** +> + +전체 `batches` 테이블에 락걸고 싶지는 않고 특정 SKU에 해당하는 행에만 lock을 걸 수 있을까? + +한가지 답은 Product 모델 속성 하나를 사용해 전체 상태 변경이 완료되었는지 표시하고, 여러 동시성 작업자들이 이 속성을 획득하기 위해 경쟁하는 자원으로 활용하는 방법이다. 두 트랜잭션이 `batches` 에 대한 세계 상태를 동시에 읽고 둘 다 `allocation` 테이블을 업데이트 하려고 한다면, 각 트랜잭션이 `product_table` 에 있는 `version_number` 를 업데이트하도록 강제할 수 있다. 이러면 경쟁하는 트랜잭션 중 하나만 승리하고, 세계가 일관성 있게 남게 된다. + +![두 트랜잭션 예시: `Product` 에 동시 업데이트를 시도하는 시퀀스 다이어그램](https://www.cosmicpython.com/book/images/apwp_0704.png) + +- 둘 다 버전 `3`을 가져간다 +- 모델에 allocate을 하면 버전 `4`를 담고있는 Product 객체가 생긴다 + - 해당 객체를 먼저 커밋한 사항이 반영된다 + - 늦게 커밋한 사람은 버전이 안맞아서 못 한다. 혹은 다시 하거나 + +## 낙관적 동시성 제어와 재시도 + +> *어쨌거나 성능과 충돌 가능성을 측정 후 어떤 정책을 가져가야 할지 평가해야 한다.* +> +1. 낙관적/비관적 동시성 제어에 대해 + - 낙관적 동시성 제어(*Optimistic Concurrency Control*) + - 여러 사용자의 DB 변경 충돌이 *드물 것이다* 라고 생각한다 + - 일단 업데이트 하고 문제 시 통지받을 수 있는 방법이 있는지만 확실히 한다 + - 충돌 발생 시 어떻게 처리해야 할지 명시해야 한다 + - 비관적(*pessimistic*) 동시성 제어 + - 여러 사용자의 DB 변경 충돌이 *잦을 것이다* 라고 가정한다 + - 모든 충돌을 피하려 노력하고, 안전성을 위해 모든 대상을 lock을 사용해 잠근다 + - 실패 처리는 DB가 해줘서 고민할 필요는 없지만, deadlock을 고민해봐야 한다 + - E.g., + - `batches` 테이블 전체를 lock 걸거나, `SELECT FOR UPDATE` 를 사용한다. +2. 실패 처리에 대한 방안 + 1. 실패한 연산을 처음부터 다시 함 + 1. 두 트랜잭션 예시에서 실패한 쪽은 다시 요청해서 결과를 받아본다 + +## 7.7.1 버전 번호를 구현하는 방법 + +1. 도메인의 `version_number` 를 사용 + 1. 해당 값을 `Product` 생성자에 추가하고 `Product.allocate()` 가 버전 번호를 올리는 경우 +2. 서비스 계층이 수행 + 1. (근거) 버전 번호는 도메인의 관심사가 아니기 때문이다 + 2. 따라서 서비스 계층에서 `Product` 에 저장소를 통해 버전번호를 덧붙이고, `commit()` 전에 버전 번호를 증가한다고 가정할 수 있다 +3. 인프라와 측에서 사용(controversial) + 1. (근거) 버전 번호는 결국 인프라와 관련있다 + 2. 따라서 UoW와 저장소가 처리한다 + 1. 저장소는 자신이 읽어 온 상품의 모든 버전 번호에 접근 가능하다 + 2. UoW는 상품이 변경됐다는 가정 하에 자신이 아는 상품의 버전 번호를 증가할 수 있다 + +3번 방법은 “모든” 제품이 변경되었다고 가정하지 않고서는 구현할 방법이 없다. + +2번 방법은 상태 변경에 대한 책임이 서비스-도메인 계층 사이에 있어서 지저분하다(저자 曰) + +도메인 관심사와 무관하게 가장 나은 방안이 1번 방안이다(저자 曰) + +그럼 어디 둘까? 애그리게이트에 둔다. + +```python +class Product: + def __init__( + self, + sku: str, + batches: List[Batch], + version_number: int = 0, + ): + self.sku = sku + self.batches = batches + self.version_number = version_number + + def allocate( + self, + line: OrderLine, + ) -> str: + try: + batch = next(b for b in sorted(self.batches) if b.can_allocate(line)) + batch.allocate(line) + self.version_number += 1 # (1) + return batch.reference + except StopIteration: + raise OutOfStock(f"Out of stock for sku {line.sku}") + +``` + +1. 이 쯤 처리한다 + 1. 참고: 버전 번호에 대해 고심하고있다면 “번호” 란 말이 그다지 중요하지 않다는 사실을 깨달으면 좋을 듯 하다. + 2. 중요한 점은 `Product` 애그리게이트 변경 시 `Product` DB 컬럼이 변경된다는 사실이다. + 3. 매번 임의로 UUID를 생성하거나, Snowflake ID[^6] 같은 걸 쓰는건 어떨까 싶다. + +# 7.8 데이터 무결성 규칙 테스트 + +이 대로 했을 때 의도대로 잘 되는지 살펴보자! 동시에 트랜잭션을 시도하면 모두 버전 번호를 올릴 수는 없으니 둘 중 하나는 실패할 것이다. + +느린 트랜잭션[^7]을 하나 임의로 만들어보자: + +```python +def try_to_allocate(orderid, sku, exceptions): + line = model.OrderLine(orderid, sku, 10) + try: + with unit_of_work.SqlAlchemyUnitOfWork() as uow: + product = uow.products.get(sku=sku) + product.allocate(line) + time.sleep(0.2) + uow.commit() + except Exception as e: + print(traceback.format_exc()) + exceptions.append(e) +``` + +어떻게 테스트하는지 살펴보자: + +```python +def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory): + sku, batch = random_sku(), random_batchref() + session = postgres_session_factory() + insert_batch(session, batch, sku, 100, eta=None, product_version=1) + session.commit() + + order1, order2 = random_orderid(1), random_orderid(2) + exceptions = [] # type: List[Exception] + try_to_allocate_order1 = lambda: try_to_allocate(order1, sku, exceptions) + try_to_allocate_order2 = lambda: try_to_allocate(order2, sku, exceptions) + thread1 = threading.Thread(target=try_to_allocate_order1) #(1) + thread2 = threading.Thread(target=try_to_allocate_order2) #(1) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + [[version]] = session.execute( + "SELECT version_number FROM products WHERE sku=:sku", + dict(sku=sku), + ) + assert version == 2 #(2) + [exception] = exceptions + assert "could not serialize access due to concurrent update" in str(exception) #(3) + + orders = session.execute( + "SELECT orderid FROM allocations" + " JOIN batches ON allocations.batch_id = batches.id" + " JOIN order_lines ON allocations.orderline_id = order_lines.id" + " WHERE order_lines.sku=:sku", + dict(sku=sku), + ) + assert orders.rowcount == 1 #(4) + with unit_of_work.SqlAlchemyUnitOfWork() as uow: + uow.session.execute("select 1") +``` + +1. 원하는 동시성 행동 방식을 잘 재현할 수 있는 두 스레드를 실행시킨다. + 1. `read1`, `read2` 그리고 `write1`, `write2` +2. 버전 번호가 `1` 오른 것을 확인한다 +3. 필요하면 이런 식으로 확인한다 +4. `allocate()` 이 하나만 되었음을 검사한다 + +## 7.8.1 DB 트랜잭션 격리 수준을 사용하여 동시성 규칙을 강제 + +- (Postgres 에만 국한되는건 아니지만) 트랜잭션 격리 수준을 `REPEATABLE READ` 로 조절한다. 상세한 정보는 [Postgres 공식 문서](https://www.postgresql.org/docs/current/transaction-iso.html)를 읽자 + +```python +DEFAULT_SESSION_FACTORY = sessionmaker( + bind=create_engine( + config.get_postgres_uri(), + isolation_level="REPEATABLE READ", + ) +) +``` + +## 7.8.2 비관적 동시성 제어 예제: `SELECT FOR UPDATE` + +해당 방안은 비관적 동시성 제어 방안 중 하나이다. `SELECT FOR UPDATE` [^8]는 두 트랜잭션이 동시에 같은 row를 읽도록 허용하지 않는다. + +`SELECT FOR UPDATE` 는 lock으로 사용할 row를 선택하는 방안이다(업데이트 대상 row일 필요는 없다). 두 트랜잭션이 동시에 `SELECT FOR UPDATE` 를 수행하면 두 업데이트 중 하나만 승리하고 나머지는 상대방이 lock을 풀 때 까지 기다려야 한다. 이는 동시성 패턴을 아래와 같이 바꾼다. + +> AS-IS +`read1`, `read2` , `write1`, `write2(fail)` +> + +> TO-BE +`read1`, `write1`, `read2`, `write2(succeed)` +> + +이걸 “Read-Modify-Write” failure mode 라고 부르는 사람도 있다. 아래 게시글을 읽고 통찰을 얻자! + +["PostgreSQL Anti-Patterns: Read-Modify-Write Cycles"](https://oreil.ly/uXeZI) + +`REPEATABLE READ` 나 `SELECT FOR UPDATE` 어떤걸 하든 트레이드오프가 있다. 상기 테스트코드와 같은 접근을 하면 어떤 식으로 바뀌는지 알 수 있다. 물론 테스트코드를 더 좋게 보강해야겠지만… + +동시성 제어를 어떻게 할지는 비즈니스 환경, 저장소 기술에 따라 달라진다. + +# 7.9 마치며 + +이번 장은 애그리게이트의 개념을 살펴봤다. + +애그리게이트는… + +- 모델의 일부 부분집합에 대한 주 진입점 역할을 한다 +- 모든 모델 객체에 대한 비즈니스 규칙과 불변조건을 강제하는 역할을 담당하도록 객체를 명시적으로 모델링한다 + +올바른 애그리게이트를 잘 선택해야 한다! 시간이 지나고 요구사항 등등 재검토를 수행하다 보면 애그리게이트로 고른 객체가 달라질 수도 있다. + +[이 링크](https://www.dddcommunity.org/library/vernon_2011/)를 꼭 읽어보자. 존 버넌이 효과적인 애그리게이트 설계에 대해 쓴 글이다. + +그러면 이어서, 애그리게이트의 트레이드오프에 대해 살펴보자. + +| 장점 | 단점 | +| --- | --- | +| 애그리게이트는 도메인 모델 클래스 중 어떤 부분이 공개되어있고, 어떤 부분이 비공개인지 결정할 수 있다(멤버함수, 멤버변수에 _ 하나를 붙여서) | 엔티티, VO를 적당히 잘 감싼 객체가 또 하나 생긴다. 솔직히 이해하기 너무 어렵다…. | +| 연산 주변에 명시적인 Bounded Context를 모델링할 수 있으면 ORM 성능 문제 예방에 도움된다 | 한번에 한 가지 애그리게이트만 변경할 수 있다는 규칙을 엄격히 지키도록 해야하는데, 그것도 정말 어렵다… | +| 애그리게이트는 자신이 담당한 모델에 대한 상태변경만을 책임지도록 하면 시스템 추론 및 불변조건 제어가 쉬워진다 | 애그리게이트 사이의 최종 일관성을 처리하는 과정이 복잡해질 수 있다. 와 정말 너무 어렵다………… | + +## 7.9.1 애그리게이트와 일관성 경계 + +1. 애그리게이트는 도메인 모델에 대한 진입점이다 + 1. 도메인에 속한 것을 바꿀 수 있는 방식을 제한하면 시스템을 더 쉽게 추론할 수 있다 +2. 애그리게이트는 Bounded Context를 책임진다 + 1. 애그리게이트의 역할은 여러 객체로 이루어진 그룹에 적용할 불변조건에 대한 비즈니스 규칙을 관리하는 것이다 + 2. 자신이 담당하는 객체 사이와 객체와 비즈니스 규칙 사이의 일관성을 검사하고, 어떤 변경이 일관성을 해친다면 이를 거부하는 것도 애그리게이트의 할 일이다. +3. 애그리게이트와 동시성 문제는 공존한다 + 1. 동시성 검사 구현 방안을 고민하면 자연스럽게 트랜잭션과 lock까지 간다. 이는 성능과 직결된 이야기다 + 2. 애그리게이트를 제대로 고르는 것은 도메인을 개념적으로 잘 조직화하는 것 뿐 아니라 성능까지 살펴보는 것이다 + +# 7.10 1부 돌아보기 + +아무리 예제를 베끼고 내가 짜야할 것들을 짜고 했지만 이걸 만들다니…. + +![이겼다! 제 1부 끝!](https://www.cosmicpython.com/book/images/apwp_0705.png) + +뭘 만들어낸건지 리뷰해보자. + +1. 테스트 피라미드 → 검증된 도메인 모델 생성 + 1. 비즈니스 요구사항에 맞게 시스템이 어떻게 도는지를 코드로 작성했다 + 2. 비즈니스 요구사항이 바뀌면 테스트도 마찬가지로 변하면 된다 +2. API 핸들러, DB 등의 구조를 분리했다 + 1. 애플리케이션 외부에서 하부구조를 끼워넣을 수 있게 만들었다 + 2. 코드 베이스의 조직화가 이루어졌다 + → 코드 내부가 파악하지도 못하게 복잡해지는 것은 막했다 +3. DIP를 적용했다. 포트와 어댑터에서 영향받은 “저장소” 와 UoW를 사용했다 + 1. 저장소 수준의 테스트코드와 UoW 수준의 테스트코드를 분리했다 + 2. 시스템의 한쪽 끝부터 다른쪽 끝까지 테스트했다 +4. Bounded Context + 1. 변경이 필요할 때마다 전체 시스템을 잠그고 싶지않아서, 어떤 부분에 대해서만 일관성을 가지는지를 나눴다 + +2부에서는 모델을 넘어서는 일관성을 처리하기 위한 방안을 살펴볼 것이다. + +> **경고!** + +이런 패턴이 하나씩하나씩 붙을 때 마다 전부 비용이다. + +간접계층 하나하나가 모두 비용이다. +이 패턴을 모르는 사람에게 혼동을 야기할 수 있다. 이것도 큰 비용이다. +만약 만들 앱이 DB를 단순히 감싸는 CRUD wrapper 라면? 앞으로 이것 외의 일을 할 것 같지 않다면? + +***이런 복잡한걸 쓸 필요가 없다*.** + +[^1]: 왠지 [이 책의 3장](http://www.yes24.com/Product/Goods/114667254)을 다시 읽어봐야할 것 같은 느낌이다. 저기서 본 내용들의 일부가 여기도 나온다. + +[^2]: 할당 후 배치의 가용 재고 수량이 라인의 상품 수량만큼 감소하므로 비즈니스 제약 사항을 만족한다면 항상 가용 재고 수량은 `0`보다 크거나 같다. + +[^3]: 이 코드의 모델의 경우 배치가 컬렉션이다. + +[^4]: [A product is identified by a SKU, …](https://www.cosmicpython.com/book/chapter_01_domain_model.html#allocation_notes) 하면서 이미 풀어놨음 + +[^5]: [이 분의 블로그 게시글](https://haandol.github.io/2021/10/11/thoughts-for-ddd-starters.html#fn:1)을 보고 뽐뿌가 왔다… 꼭 지식을 머리속에 집어넣도록 하자 + +[^6]: [https://en.wikipedia.org/wiki/Snowflake_ID](https://en.wikipedia.org/wiki/Snowflake_ID) 를 의미한다 + +[^7]: 동시성 버그 재현을 위해 스레드 사이에서 세마포어나 비슷한 동기화 기능을 쓰는 편이 테스트 행동 방식을 보다 잘 보장할 수 있다. + +[^8]: [https://www.postgresql.org/docs/current/explicit-locking.html](https://www.postgresql.org/docs/current/explicit-locking.html) diff --git a/content/books/cosmic-python/2023-04-17---pt01-ch07/media/universe.jpg b/content/books/cosmic-python/2023-04-17---pt01-ch07/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-04-17---pt01-ch07/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-04-17---pt02-ch08/index.md b/content/books/cosmic-python/2023-04-17---pt02-ch08/index.md new file mode 100644 index 00000000..55a80d22 --- /dev/null +++ b/content/books/cosmic-python/2023-04-17---pt02-ch08/index.md @@ -0,0 +1,608 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (8)" +date: "2023-04-17T19:22:59.001Z" +template: "post" +draft: false +slug: "/books/cosmic-python/2023-04-17-pt02-ch08" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 8, 애그리게이트와 일관성 경계에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +8장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt2/ch08) + +# 8장 이벤트와 메시지 버스 + +걍 장고같은걸로 빠르게 서비스를 만들고 릴리즈할 수 있는 것 아니었나? 이게 진짜 이 정도로 가치있는 일인가? + +실세계에서는 코드베이스를 더럽히는게 능사가 아니다. 코드베이스를 건드는건 기름때(원문에선 *goop*)와 같은 것이다. + +여기서는 “통지관련” 요구사항을 처리한다. 아래와 같은 요구사항을 말한다: + +1. 플로우 + - (구매팀에게) 주문 할당이 부족합니다 라고 **통지** + - 구매팀이 처리하면 이쪽에 다시 알람을 줄 것 +2. 이메일 통지 만으로도 충분 + +평범한 요소에 뭔가 끼워넣어야 할 때, 아키텍처가 어떻게 유지되는지 살펴봅시다! + +1. 간단하고 빠른 방법은? → 이러면 “진흙 공을” 어떻게 만드는지 (안티패턴!) +2. 도메인 이벤트(Domain event pattern)를 사용하면? + 1. 위의 부작용을 해결할 수 있나? + 2. 이벤트에 따른 동작을 메시지 버스 패턴으로 수행하는 방안? +3. 도메인 이벤트 사용방안 + 1. 이벤트를 메시지 버스에 전달하는 방안 + 2. 도메인 이벤트와 메시지 버스를 연결하기 위한 uow 변경방안 + +모두 합치면 대충 이런 그림이 될 거다. + +![](./media/apwp_0801.png) + +# 8.1. 지저분해지지 않게 막기 + +재고가 없으면 구매팀에게 메일로 통지한다. 같은 요구사항은 핵심 도메인하고는 관련이없다. + +이런건 보통 웹 컨트롤러에 넣을 생각을 한다… + +## 8.1.1 웹 컨트롤러가 지저분해지는 일을 막자 + +한 번만 변경할거면 이렇게 해도 되지만, 좋은 코드라고 하기는 힘들다. + +이런 모양새가 나올 수도 있다는 뜻 + +```python +@app.post( + "/allocate", + status_code=status.HTTP_201_CREATED, +) +@inject +async def allocate_endpoint( + order_line: OrderLineRequest, +): + try: + batchref = await services.allocate( + orderid=order_line.orderid, + sku=order_line.sku, + qty=order_line.qty, + uow=unit_of_work.SqlAlchemyUnitOfWork(db.session_factory), + ) + + except (model.OutOfStock, services.InvalidSku) as e: + send_mail( + 'out of stock', + 'stock_admin@made.com', + f'{line.orderid} - {line.sku}', + ) + raise HTTPException( + detail=str(e), + status_code=status.HTTP_400_BAD_REQUEST, + ) from e + + else: + return {'batchref': batchref} +``` + +컨트롤러에 이것저것 넣으면 금방 전체가 더러워진다… + +1. 메일 보내는걸 HTTP 계층이 하는 일은 아니다 +2. 단위테스트도 하기 힘들다 + +## 8.1.2 모델이 지저분해지는 일을 막자 + +이러면 재고부족의 원인인 모델에 달아야되나? + +```python +def allocate( + self, + line: OrderLine, + ) -> str: + try: + batch = next(b for b in sorted(self.batches) if b.can_allocate(line)) + batch.allocate(line) + self.version_number += 1 + return batch.reference + except StopIteration: + email.send_mail('stock_admin@made.com', f'out of stock for {line.sku}') + raise OutOfStock(f"Out of stock for sku {line.sku}") +``` + +저자는 이게 더 구리다고 한다. 모델에 인프라구조에 의존하는 모양이기 때문이다. 도메인 모델은 단지 실제 할당할 수 있는 것 보다 더 많은 상품을 할당할 수는 없다’ 라는 규칙에만 집중해야하기 때문이다. 도메인과 연관없지만 이런 식으로 필요한 기능이 **아무데나** 붙는 것을 ‘코드의 기름때’와 같다고 말한다. + +도메인 모델은 재고가 부족한지만 알면 되고, 통지를 보내는 것은 다른 곳에서 하도록 해야한다. 이런 기능은 켜고끌 수도 있어야 하고, 도메인 모델의 규칙을 바꾸지 않고서도 이메일, 문자 등으로 통지를 보낼 수도 있어야 한다. + +## 8.1.3 …또는 서비스 계층이 지저분해지는 일을 막자 + +‘재고할당 시도 중 할당에 실패하면 메일을 보내야한다’ 는 워크플로우 오케스트레이션이다. 이 동작은 목표를 달성하기 위해 시스템을 따라야하는 단계다. + +그럼 서비스계층에 넣나? 저자는 그것도 아니라고 한다. + +```python +async def allocate( + orderid: str, + sku: str, + qty: int, + uow: unit_of_work.AbstractUnitOfWork, +) -> str: + line = model.OrderLine(orderid, sku, qty) + + async with uow: + product = await uow.products.get(sku=line.sku) + if product is None: + raise InvalidSku(f'Invalid sku {line.sku}') + + try: + batchref = product.allocate(line) + await uow.commit() + return batchref + except model.OutOfStock: + email.send_mail('stock_admin@made.com', f'out of stock for {line.sku}') + raise +``` + +예외를 잡아내고 또 발생하면 왠지 모르게 마음이 불편하다. 않이 왜이렇게 어려운거여 + +# 8.2 단일 책임 원칙 + +상기 내용들은 단일 책임 원칙(Single Responsibility Principle)에 위배된다[^1]. 여기서 처리하는 유스케이스는 할당이다. 엔드포인트인 서비스나 도메인이름은 `allocate` 이다. `allocate_and_send_mail_if_out_of_stock` 이 아니다. + +> `then`, `and` 이란 단어를 **쓰지 않고** 함수가 하는 일을 설명할 수 없다면 SRP를 위반하고 있을 가능성이 높다 +> + +SRP를 다른말로 하면 어떤 클래스를 수정해야 하는 이유가 단 하나만 존재해야한다 라고 설명할 수 있다. 따라서 이메일을 문자메시지로 변경할 때는 `allocate()` 를 바꿀 이유가 없다. 이메일을 문자메시지로 바꾸는데 `allocate()` 를 바꾼다는 말은 `allocate()` 가 상품할당 외에 다른 것도 한다는 뜻이다. + +이러려면 오케스트레이션을 여러 단계로 구분해서 각각의 관심사가 서로 얽히는 일이 없도록 해야 한다[^2]. 도메인은 도메인의 일만하고 그 외의 일은 다른 존재에게 부여해야 한다. + +세부구현으로부터 서비스계층을 분리한다. 서비스계층이 통지에 직접 의존하지 않고 추상화에 의존하도록 한다. + +# 8.3 메시지 버스에 타라! + +여기서 나오는 패턴은 “도메인 이벤트” 와 “메시지 버스” 다. 이는 구현방법이 여러가지다. 책의 구현 방식을 따라가기 전, 어떤 구현방식이 있는지 살펴보자 + +## 8.3.1 이벤트 기록 모델 + +모델은 이메일을 신경쓰지 않고 이벤트 기록을 담당한다. 이벤트는 발생한 일에 대한 사실을 뜻한다. 이벤트에 응답하지 않고 새 연산을 실행하기 위해 메시지 버스를 사용한다. + +## 8.3.2 이벤트는 간단한 데이터 클래스다 + +이벤트는 VO에 속한다. 이벤트는 순수 데이터 구조이므로 동작이 없다. 이벤트를 항상 도메인 언어로 이름붙여야 한다. 항상 이벤트를 도메인 모델의 일부로 간주하여야 한다. + +그런 고로 리팩토링을 수행한다. + +`domain/model.py` , `domain/events.py` 로 분리시키자. + +```python +from dataclasses import dataclass + +class Event: # 1) + pass + +@dataclass +class OutOfStock(Event): # 2) + sku: str +``` + +1. 이벤트 수가 늘어나면 공통 애트리뷰트를 담을 수 있는 부모 클래스가 유용하다. 타입힌팅도 적극도입할 수 있다. +2. `dataclasses` 는 이벤트의 경우에도 유용하다. + +## 8.3.3 모델은 이벤트를 발생한다 + +도메인 모델은 발생한 사실을 기록하기 위해 이벤트를 발생시킨다. + +외부에서 볼 땐 어떤식으로 보이는지 테스트코드를 짜서 스펙을 바꿔보자. `Product` 할당 요청 시 할당이 불가능하면 이벤트가 발생해야한다. + +이런 식으로 원하는 기능을 테스트코드로 틀을 잡고… + +```python +def test_records_out_of_stock_event_if_cannot_allocate(): + batch = Batch('batch1', 'SMALL-FORK', 10, eta=today) + product = Product(sku='SMALL-FORK', batches=[batch]) + product.allocate(OrderLine('order1', 'SMALL-FORK', 10)) + + allocation = product.allocate(OrderLine('order2', 'SMALL-FORK', 1)) + + assert product.events[-1] == events.OutOfStock(sku="SMALL-FORK") + assert allocation is None +``` + +이벤트를 담는 `events` 라는 리스트를 만들고, 여기에 append 하자. `OutOfStock` 예외는 사용하지 않는다. + +```python +class Product: + def __init__( + self, + sku: str, + batches: List[Batch], + version_number: int = 0, + ): + self.sku = sku + self.batches = batches + self.version_number = version_number + self.events = [] # type: List[events.Event] + + def allocate( + self, + line: OrderLine, + ) -> str: + try: + ... + except StopIteration: + self.events.append(events.OutOfStock(line.sku)) + # raise OutOfStock(f"Out of stock for sku {line.sku}") +``` + +> 흐름 제어를 위해 예외를 사용한 것을 빼기 위한 시도에 주목![^3] + +그리고 도메인 이벤트를 구현하고 있다면 도메인에서 동일한 개념을 표현하기 위해 예외발생을 피하는 것이 좋다. 추후 작업단위패턴에서 이벤트 처리 시 이벤트와 예외 동시사용의 문제점을 볼 수 있게 된다. (추론이 빡세짐) +> + +## 8.3.4 메시지 버스는 이벤트를 핸들러에 매핑한다 + +메시지 버스는 “이 이벤트가 발생하면 다음 핸들러 함수를 호출하시오” 라고 말한다. 간단한 pub-sub 시스템이다. 핸들러는 수신된 이벤트를 subscribe 한다. 수신되는 이벤트는 버스에 시스템이 publish 한 것이다. 이 책에서는 딕셔너리로 메시지 버스를 구현한다. + +```python +import asyncio + +from allocation.adapters import email +from allocation.domain import events + +async def handle(event: events.Event): + for handler in HANDLERS[type(event)]: + task = asyncio.create_task(handler(event)) + await task + +async def send_out_of_stock_notification(event: events.OutOfStock): + await email.send_mail( + "stock@made.com", + f"Out of stock for {event.sku}", + ) + +HANDLERS = { + events.OutOfStock: [send_out_of_stock_notification], +} # type: Dict[Type[events.Event], List[Callable]] +``` + +> 동시성 개념은 아래 Repository들을 적극 참조하여 코드를 작성한 것이다. + +1. +2. +> + +> Celery와 메시지 버스는 비슷한가? + +Celery는 그 자체로 완결적인 작업을 비동기 작업 큐에 넣고 처리하는 것이다. +책에서 말하는 메시지 버스는 아주 다르다. + +작업을 메인 스레드 밖으로 빼야한다는 요구사항이 있더라도 여전히 이벤트 기반의 메타포를 쓸 수 있다. 이를 위해서는 외부 이벤트(*external event*)를 쓰는 것이 권장된다. 중앙 집중 스토어에 이벤트를 영속화하는 방법을 구현하면, 다른 컨테이너나 마이크로서비스가 이 중앙 집중 이벤트 스토어를 subscribe 할 수 있다. +그 후에는 한 프로세스나 서비스 내에서 작업 단위별로 책임을 분산하기 위해 이벤트를 사용한다는 개념을 그대로 여러 프로세스에 걸친 이벤트에 적용할 수 있다. 이 때 각 프로세스는 같은 서비스 내 다른 컨테이너이거나 완전히 다른 마이크로서비스일 수도 있다. + +이런 접근방법에 따르면 작업을 분배하기 위한 API는 이벤트 클래스가 되거나 이벤트 클래스에 대한 JSON 표현이 될 수 있다. 이벤트 클래스나 JSON을 작업 분배용 API로 사용하면 작업을 위임할 대상을 폭넓게 고를 수 있다. +예를 들어 작업을 맡을 프로세스가 꼭 파이썬 서비스일 필요가 없다. 하지만 Celery 작업분배 API는 근본적으로 ‘함수 이름과 인수’ 로 이루어지며, 이는 좀 더 제한적이고 파이썬 안에서만 통하는 방식이다. +> + +# 8.4 첫 번째 선택지 + +서비스 계층이 모델에서 이벤트를 가져와 메시지 버스에 싣는 방안. + +도메인 모델이 이벤트를 발생시키고 메시지 버스는 이벤트가 발생하면 적절한 핸들러를 호출한다. 이 둘을 연결해야한다. 모델에서 이벤트를 찾고 메시지 버스에 실어주는 publishing 단계를 실행할 것을 넣어야 한다. + +첫 번째 방안은 서비스 계층에 코드를 약간 더 넣는 것이다. + +```python +async def allocate( + orderid: str, + sku: str, + qty: int, + uow: unit_of_work.AbstractUnitOfWork, +) -> str: + line = model.OrderLine(orderid, sku, qty) + + async with uow: + product = await uow.products.get(sku=line.sku) + if product is None: + raise InvalidSku(f'Invalid sku {line.sku}') + + try: # 1) + batchref = product.allocate(line) + await uow.commit() + + return batchref + finally: # 1) + messagebus.handle(product.events) # 2) +``` + +1. 몬생긴 try/finally 는 그대로고 `OutOfStock` **예외만** 뺐다. +2. 서비스 계층은 이메일 인프라에 직접 의존하지는 않고 모델에서 받은 이벤트를 직접 메시지 버스에 올리는 일만 담당한다. + +이 정도만 해도 바람직하지 않은 부분을 상당히 없앨 수는 있다! + +서비스 계층이 명시적으로 이벤트를 받아 통합한 다음 메시지 버스에 전달하는 여러 시스템을 가지게 된다. + +# 8.5 두 번째 선택지 + +서비스 계층은 자신만의 이벤트를 발생한다 + +서비스 계층이 도메인 모델에서 발생한 이벤트를 처리하기보다 직접 이벤트를 만들고 발생시키는 일을 책임지는 방안도 있다. + +```python +async def allocate( + orderid: str, + sku: str, + qty: int, + uow: unit_of_work.AbstractUnitOfWork, +) -> str: + line = model.OrderLine(orderid, sku, qty) + + async with uow: + product = await uow.products.get(sku=line.sku) + if product is None: + raise InvalidSku(f'Invalid sku {line.sku}') + + batchref = product.allocate(line) + await uow.commit() # 1) + + if batchref is None: + messagebus.handle(events.OutOfStock(line.sku)) + + return batchref +``` + +1. 할당에 실패해도 커밋은 한다. 이러면 코드가 더 단순해지고 코드 추론이 더 쉬워진다. '잘못되지 않으면 무조건 커밋된다' 라는 전제가 있으니 코드를 안전하고 깔끔하게 유지할 수 있다. + +프로젝트의 여러 요소 간 상충관계에 따라 더 나은 방안도 있을 수 있다. 세 번째 선택지는 저자의 생각에 가장 우아한 해법이다. + +# 8.6 세 번째 선택지 + +UoW가 메시지 버스에 이벤트를 publish + +## 8.6.1 원래 전채 본문 + +UoW에는 이미 `try/finally` 구문이 있다. UoW는 저장소에 대한 접근을 제공하므로 어떤 애그리게이트가 작업을 수행하는지도 알고있다. 그러므로 UoW는 이벤트를 찾아서 메시지 버스에 전달하기 좋은 곳이다. + +```python +class AbstractUnitOfWork(abc.ABC): + ... + + async def commit(self): + await self._commit() # 1) + await self.publish_events() # 2) + + async def publish_events(self): + for product in self.products.seen: # 3) + while product.events: + event = product.events.pop(0) + await messagebus.handle(event) + + @abc.abstractmethod + async def _commit(self): # 1) + raise NotImplementedError + + @abc.abstractmethod + async def rollback(self): + raise NotImplementedError +``` + +1. 커밋 메소드를 바꾼다. 하위 클래스가 제공하는 비공개 `_commit()` 을 호출한다 +2. 커밋 후 저장소에 전달된 모든 객체를 살펴보고 그 중 이벤트를 메시지 버스에 전달한다 +3. 2의 기능은 저장소가 새 어트리뷰트인 `.seen` 을 통해 로딩된 모든 애그리게이트를 추적하는 것에 의존한다. 이를 아래에서 자세히 살펴보자. + +> 핸들러 중 어느 하나가 실패하는 경우에 대한 예외처리는 10장에서 다시 보자. +> + +이어서 코드를 보자: + +```python +class AbstractRepository(abc.ABC): + def __init__(self): + self.seen = set() # type: Set[model.Product] (1) + + async def add(self, product: model.Product): # (2) + await self._add(product) + self.seen.add(product) + + async def get(self, sku) -> model.Product: # (3) + product = await self._get(sku) + if product: + self.seen.add(product) + return product + + @abc.abstractmethod + async def _add(self, product: model.Product): # (2) + raise NotImplementedError + + @abc.abstractmethod + async def _get(self, sku) -> model.Product: # (3) + raise NotImplementedError + +class SqlAlchemyRepository(AbstractRepository): + def __init__(self, session: AsyncSession): + super().__init__() + self.session = session + + async def _add(self, product: model.Product): # (2) + """ Batch 객체를 Persistent store에 저장한다. + + sqlalchemy의 add를 호출해서 그런가? + + """ + self.session.add(product) + + async def _get(self, sku: str) -> model.Product: # (3) + return ( + ( + await self.session.execute( + select(model.Product) + .options(selectinload(model.Product.batches)) + .filter(model.Product.sku == sku) + ) + ) + .scalars() + .one_or_none() + ) +``` + +1. UoW 가 새 이벤트를 publish하려면 저장소에 요청하여 이번 세션에 어떤 Product 객체를 쓴 것인지 알아야 한다. 여기서는 `.seen` 이라는 `Set`을 통해 사용한 Product 객체를 저장한다. 구현을 위해 `super().__init__` 을 호출해야한다는 뜻이다 +2. 부모의 `add()` 메소드는 `.seen` 에 객체를 저장한다. 하위 클래스는 `_add()` 를 구현해야 한다 +3. `get()` 도 마찬가지로 `_get()` 에 동작을 위임한다. 하위 클래스는 `_get()` 을 구현하여 자신이 살펴본 객체를 저장해야 한다. + +> underscore(`_`)로 시작하는 메소드와 하위 클래스를 쓰는 것 말고도 여러 방안이 있다. 책에서 소개하는 두 방안은 아래와 같다 + +1. 별도 클래스를 빼서 Wrapper 클래스로 구현하기 +2. [Protocol](https://peps.python.org/pep-0544/) 을 써서 *Composition over Inheritance* 를 구현하기[^4] +> + +3번 방안으로 구현하면 알아서 살아있는 객체를 추적하고, 그로부터 발생한 이벤트를 처리하도록 하면 서비스 계층은 이벤트 처리와 전혀 무관하게 된다. + +그러면 서비스 계층을 테스트할 때 쓰던 가짜객체도 손봐줘야 할 것이다. + +```python +class FakeRepository(repository.AbstractRepository): + def __init__( + self, + products, + ): + super().__init__() + self._products = set(products) + + async def _add(self, products): + self._products.add(products) + + async def _get(self, sku): + return next((b for b in self._products if b.sku == sku), None) + +class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork): + def __init__(self): + self.products = FakeRepository([]) + self.committed = False + + async def _commit(self): + self.committed = True +``` + +… 이런 식으로 `super().__init__` 및 `_add()` 사용, `_get()` 사용, `_commit()` 사용 등. + +### 8.6.2 연습문제 + +지금이야 코드도 짧고 예시에 가까운 것들이니 귀찮다 싶겠지만, 프로그램의 확장성을 공부한다는 차원에서 이 글도 보면 좋다. + +코드에는 `Protocol` 과 `TrackingRepository` 로 감싸는 두 접근법을 모두 취할 것이다. + +객체지향 관점에서 좋은 접근방안에 대해 소개한 글이 있다. + +- [The Composition Over Inheritance Principle](https://python-patterns.guide/gang-of-four/composition-over-inheritance/) +- [Inheritance and Composition: A Python OOP Guide](https://realpython.com/inheritance-composition-python/) + +그렇다면 composition over inheritance 구현 방안은 어떻게 짤 수 있을까? + +이런 식으로 테스트용 UoW를 처리한다: + +```python +class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork): + def __init__(self): + self.products = repository.TrackingRepository(FakeRepository()) + self.committed = False + + def _commit(self): + self.committed = True + + def _rollback(self): + pass +``` + +이런 식으로 감싼다: + +```python +class TrackingRepository: + seen = Set[model.Product] + + def __init__(self, repo: AbstractRepository): + self._repo = repo + self.seen = set() # type: Set[model.Product] + + def add(self, product: model.Product): + self._repo.add(product) + self.seen.add(product) + + def get(self, sku: model.Sku) -> model.Product: + product = self._repo.get(sku) + if product: + self.seen.add(product) + return product + + def list(self) -> List[model.Product]: + return self._repo.list() +``` + +그 다음 사용할 때는 이런 식으로 처리한다. 테스트를 돌려보면서 점검해보자! + +```python +class SqlAlchemyUnitOfWork(AbstractUnitOfWork): + def __init__(self, session_factory): + self.session_factory = session_factory + + async def __aenter__(self): + self.session: AsyncSession = self._session_factory() + self.products = repository.TrackingRepository( + repository.SqlAlchemyRepository(self.session) + ) + return await super().__enter__() + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await super().__aexit__(exc_type, exc_val, exc_tb) + await self.session.close() +``` + +# 8.7 마무리 + +## 8.7.1 본문 + +도메인 이벤트는 시스템에서 워크플로우를 다루는 또 다른 방안이다. X일 때 Y한다 라는 이벤트(아마 기억상 Policy 로 표현할 수 있는) 것을 코드로 풀어내는 방안이 이런 것이다 하는 것을 알게 되었다. 이벤트를 일급 시민인 요소로 다루면 코드를 보다 테스트하기 좋으면서도 관심사 분리에 도움되게 작성할 수 있다. + +이벤트에 대한 장단점을 알아보자! + +| 장점 | 단점 | +| --- | --- | +| 메시지 버스를 쓰면 어떤 요청에 대한 응답으로 여러 동작을 수행하는 경우에 대한 관심사 분리가 깔끔해진다 | UoW 내에서 알아서 처리되게 하는게 깔끔하긴 한데 명확하게 이해하기는 솔직히 어렵다. 비즈니스 로직에 따라서는 commit 의 명확한 시점 분리가 불분명하다 | +| 이벤트 핸들러는 ‘핵심’ 애플리케이션 로직과 완전히 분리될 수 있다. 추후 이벤트 핸들로 구현을 쉽게 변경할 수도 있다 | 감춰진 이벤트 처리 코드가 동기적으로 실행된다. 비동기 처리를 하면 그거대로 더 골아파진다. (본인이 참조해서 작성한 코드에서는 asyncio 의 태스크 gather로 풀던데…) | +| 실 세계를 모델링하기 아주 좋은 방법이다. 이를 비즈니스 언어의 일부분으로 쓸 수 있다 | 일반적으로는 이벤트 기반 워크플로우는 연속적으로 여러 핸들러로 분할된 후 시스템에서 요청을 어떻게 처리하는지 살펴볼 수 있는 단일지점이 없다. 이는 혼란을 야기할 수 있다. | +| | 더 나아가 이벤트 핸들러가 서로를 의존해서 무한루프가 생기면…? | + +애그리게이트 및 일관성 보장을 위한 bounded context가 필요함을 배웠다. 그렇다면 어떤 요청을 처리하기 위해 여러 애그리게이트를 변경해야 한다면 이벤트를 쓰면 될 것이다. + +트랜잭션으로 서로 격리된 두 요소가 있다면, 이벤트를 통해 최종 일관성(*eventually consistent*)을 갖추도록 할 수 있다. 어떤 주문이 취소되면, 이 주문에 할당된 상품을 찾고 할당을 없애는 식으로… + +## 8.7.2 도메인 이벤트와 메시지 버스 돌아보기 + +1. 이벤트는 단일 책임 원칙을 지키도록 돕는다 + 1. 한 곳에 여러 관심사를 처리하는 것을 피하자! + 2. 이벤트를 사용해서 메인 유스케이스와 서브 유스케이스를 분리시키자 + 3. 이벤트를 사용해 애그리게이트 끼리 통신하게 하자. 대해 잠기는 장기 실행 트랜잭션을 실행할 필요가 없도록 하자 +2. 메시지 버스는 메시지를 핸들러에게 연결한다 + 1. 메시지 버스를 이벤트와 이벤트 consumer를 연결하는 딕셔너리로 생각할 수 있다 + 2. 메시지 버스는 이벤트의 의미를 전혀 모른다. 단순한 메시지 전달용 인프라일 뿐이다 +3. 첫 번째 구현방안: 서비스 계층이 이벤트를 발생시키고 메시지 버스에 전달 + 1. 작업 단위 커밋 후 `bus.handle(신규이벤트)` 호출하기 +4. 두 번째 구현방안: 도메인 모델이 이벤트를 발생시키고, 서비스 계층이 메시지 버스에 이벤트를 전달 + 1. 도메인 모델에서 이벤트 발생하게 하기 + 2. 모델이 `commit` 후 핸들러가 이벤트를 찾아서 이벤트 버스에 싣도록 하기 +5. 세 번째 구현방안: UoW가 애그리게이트에서 이벤트를 수집 후 메시지 버스에 전달 + 1. `bus.handle(aggregate.events)` 를 모든 핸들러에 추가하는건 귀찮으므로, 메모리에 적재한 객체들이 발새시킨 이벤트를 UoW 가 발생하도록 시스템을 간결하게 풀기 + 2. 하고나면 코드가 간단해짐. 다만 ORM에 의존적이긴 함 + +--- + +[^1]: SOLID 원칙의 `S`를 의미한다. + +[^2]: 명령형에서 이벤트 기반 흐름 제어로 바꾸면 오케스트레이션이 안무로 바뀌게된다고 말한다. + +[^3]: 아래 두 링크를 보고, 파이썬이든 뭐든 `try/catch` 구문을 극혐하고 빼야한다라고 생각하는 근거를 읽어보자. +[링크 1](https://softwareengineering.stackexchange.com/questions/189222/are-exceptions-as-control-flow-considered-a-serious-antipattern-if-so-why), [링크 2](https://stackoverflow.com/questions/855759/what-is-the-intended-use-of-the-optional-else-clause-of-the-try-statement-in) + +[^4]: 아래 두 아티클이 굉장히 도움될 것이다 +[링크 1](https://python-patterns.guide/gang-of-four/composition-over-inheritance/), [링크 2](https://realpython.com/inheritance-composition-python/) \ No newline at end of file diff --git a/content/books/cosmic-python/2023-04-17---pt02-ch08/media/universe.jpg b/content/books/cosmic-python/2023-04-17---pt02-ch08/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-04-17---pt02-ch08/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-05-06---pt02-ch09/index.md b/content/books/cosmic-python/2023-05-06---pt02-ch09/index.md new file mode 100644 index 00000000..220afdb3 --- /dev/null +++ b/content/books/cosmic-python/2023-05-06---pt02-ch09/index.md @@ -0,0 +1,508 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (9)" +date: "2023-05-06T04:24:59.000Z" +template: "post" +draft: false +slug: "/books/cosmic-python/2023-05-06-pt02-ch09" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 9, 메시지 버스 톺아보기에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +9장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt2/ch09) + +# 9장 메시지 버스 톺아보기 + +> 왜 제목이 이렇냐? Going to town (on sth)가 원래 [이런 뜻](https://dictionary.cambridge.org/dictionary/english/go-to-town-on)이더라고… +우리말에 좀 딱 맞아 보이는게 저 표현이긴 한데, 글쎄… 처음 쓸 때나 키치했지 지금은 영…. +> + +이벤트를 보다 근본적인 요소로 사용해보자. + +![기존 레이어를 가진 구조에서](https://www.cosmicpython.com/book/images/apwp_0901.png) + +![메시지 버스가 메인이 되는 구조로 변경할 것이다!](https://www.cosmicpython.com/book/images/apwp_0902.png) + +# 9.1 새 아키텍처가 필요한 새로운 요구사항 + +Rich Hickey란 사람은 오랫동안 실행되며 실세계의 처리 과정을 관리하는 **상황에 따른 소프트웨어**에 대해 이야기했다. 이런 예시로는 창고 관리 시스템, 물류 스케줄러, 급여 시스템 등이 있다. + +이런 소프트웨어는 실세계에서의 예기치 못한 상황으로 인해 작성하기 어렵다. 예를들어 아래와 같은 케이스가 있을 것이다: + +- 재고조사를 하는 동안 몇몇 제품이 손상되었음을 확인했다 +- 몇몇 물품 배송 시 필요한 문서가 빠져서 몇 주간 세관에 머물러야 했다. 이후 안전검사에 실패하여 폐기처리 되었다 +- 원재료의 공급 부족으로 인해 특정 배치에 대한 생산이 불가능하게 되었다 + +이러한 유형의 상황을 통해 시스템에 있는 배치 수량을 변경해야 한다는 사실을 배웠다. 이벤트 스토밍을 통해 이런 사항을 모델링하면 아래와 같은 그림이 나온다: + +![배치 수량 변경 시 할당 해제 후 재할당을 해야하는 경우](https://www.cosmicpython.com/book/images/apwp_0903.png) + +`BatchQuantityChanged` 라는 이벤트 발생 시 배치의 수량을 바꿔야 한다. 이와 함께 **비즈니스 규칙**을 적용해야 한다는 뜻이다. 변경 후 수량이 이미 할당된 수량보다 적어지면, 이런 주문을 배치에서 할당 해제(*deallocate*) 해야한다. 이후 각각 새로 할당해야한다. 이를 `AllocationRequired` 라는 이벤트로 표현한다. + +이런 것을 구현할 때 내부 메시지 버스와 이벤트가 도움이 된다! 배치 수량을 조정하고 과도한 주문 라인을 할당 해제하는 `change_batch_quantity` 라는 서비스를 정의하고, 할당 해제가 일어날 때마다 `AllocationRequired` 이벤트를 발생시켜 기존 `allocate` 서비스에 별도의 트랜잭션으로 전달한다. 여기서도 메시지 버스를 사용하면 SRP를 강제할 수 있고, 트랜잭션과 데이터 통합에 관련된 선택을 할 수 있다. + +## 9.1.1 구조 변경을 상상해보기: 모든 것이 이벤트 핸들러다 + +그렇다면 어떤 식으로 수정될지 다시 한 번 살펴보자. 시스템에는 두 가지 종류의 흐름이 있다: + +1. 서비스 계층 함수에 의해 처리되는 API 콜 +2. 이벤트 + 1. 내부 이벤트: 서비스 계층 함수의 사이드 이펙트로 발생 가능 + 2. 그 이벤트에 대한 핸들러: 서비스 계층 함수를 호출 가능 + +그렇다면, 모든 것이 이벤트 핸들러라면? API 호출을 이벤트 캐치용으로 생각하면, 서비스 게층함수도 이벤트라고 생각할 수 있다(!) 그러면 내부/외부 를 분리할 필요가 없다. + +1. `services.allocate()` 는 `AllocationRequired` 이벤트의 핸들러이거나 `Allocate` 이벤트를 출력으로 내보낼 수도 있다. +2. `services.add_batch()` 도 `BatchCreated` 이벤트의 핸들러일 수도 있다[^1] + +그리고 새로운 요구사항도 같은 패턴에 부합한다. + +1. `BatchQuantityChanged` 이벤트는 `change_batch_quantity()` 핸들러를 호출할 수 있다 +2. 새로운 `AllocationRequired` 이벤트가 `services.allocate()` 를 호출하게 할 수 있다. 따라서 API에서 새 할당요청이 들어오는 것과 내부에서 할당 해제에 의해 발생하는 재할당은 개념상 구분되지 않는다(!) + +이 정도로 코드가 바뀌는건 매우 공격적이다! 그렇다면 점진적인 방법(*Preparatory Refactoring*)을 찾아보자[^2]. 이 방법은 “변경하기 쉽게 코드를 준비한다. 그 후 쉬워진 변경을 실제로 수행한다” 정도로 정리할 수 있다. 책에서는 아래와 같은 방안을 제시한다: + +1. 서비스 계층을 이벤트 핸들러로 리팩토링. 이벤트가 시스템에 대한 입력을 설명하는 방식이라는 개념에 익숙해질 수 있음. `services.allocate()` 는 `AllocationRequired` 이벤트의 핸들러행 +2. `BatchQuantityChanged` 이벤트를 시스템에 추가하고 `Allocated` 이벤트가 발생하는지 검사하는 e2e 테스트를 만들 것임 +3. 구현은 아래와 같이… + 1. `BatchQuantityChanged` 에 대한 새로운 핸들러를 만듬 + 2. 이 핸들러 구현은 `AllocationRequired` 이벤트를 발생 + 3. API에서 사용하는 할당 핸들러와 같은 핸들러가 이 `AllocationRequired` 이벤트를 처리함 + +이 과정에서 메시지 버스와 UoW를 약간 변경해서 새 이벤트를 메시지 버스에 넣는 책임을 버스 자체로 옮길 것임 + +# 9.2 서비스 함수를 메시지 핸들러로 리팩토링하기 + +이벤트부터 정의하자! + +```python +@dataclass +class BatchCreated(Event): + ref: str + sku: str + qty: int + eta: Optional[date] = None + +@dataclass +class AllocationRequired(Event): + orderid: str + sku: str + qty: int +``` + +그리고 `service.py` 를 `handler.py` 로 개명 후 기존 메시지 핸들러인 `send_out_of_stock_notification` 을 추가한다. 핵심은 모든 핸들러가 동일한 입력(UoW와 이벤트)를 갖도록 바꾸는 것이 핵심이다. + +이러면서 서비스계층의 API를 더 구조화하고 일관성있게 다듬을 수 있다. 원래는 원시타입 값이 여기저기 흩어져 있었지만, 이젠 잘 정의된 객체를 사용한다. + +## 도메인 객체에서 기본 타입에 대한 집착을 거쳐 인터페이스로 이벤트를 사용하기 까지 + +5.5절에서 서비스 계층 API가 도메인 객체에 대해 정의되었다가 갑자기 기본 타입을 썼던 것을 기억하고 있다. 근데 이젠 또 이벤트를 쓴다. 왤케 왔다갔다 하는 것이지? + +OO 사이클에서 사람들은 기본 타입에 대한 집착(*primitive obsession*)을 안티패턴으로 간주한다. 공개 API에서 기본타입을 피하고 커스텀 값 클래스로 기본타입 값을 감싸기를 이야기한다. + +파이썬 세계에서는 많은 사람들이 경험상 이에 대해 상당히 회의적이다. 무심코 적용하면 불필요한 복잡성을 초래할 수 있기 때문이다. 그래서 여기서 함수 파라미터를 도메인 객체서 기본 타입으로 바꿨다는 것 자체만으론 복잡도가 추가되는 건 아니다. + +그런 관점에서 파라미터를 도메인 객체가 아니라 기본 타입으로 바꾸면 그 연결을 끊을 수 있다. 도메인에 엮이지도 않고, 모델을 바꿔도 서비스 계층은 API를 바꾸지 않고 예전과 같이 그대로 제공할 수 있다. 반대로 API가 바뀌더라도 모델은 그대로 남겨둘 수 있다. + +그렇다면 이벤트를 도입하는건 맨 처음 염려대로 가는건가? 하지만 핵심 도메인 모델은 여전히 다른 계층과 관계없이 바뀔 수 있다. 이벤트 도입은 외부 세계와 이벤트 클래스를 연결할 뿐이다. 이벤트도 도메인의 일부일 뿐이지만 이벤트는 도메인에 대해 **훨씬 덜 자주 바뀔 것이다**라고 예측하면 어느정도 타당하다 할 수 있다. + +이벤트를 도입하면 어떤 이득이 있는지 살펴보자: + +- 애플리케이션의 유스케이스 호출 시 기본타입의 조합을 기억할 필요가 없다 → 애플리케이션 입력을 표현하는 단일 이벤트 클래스를 쓴다 +- 입력값 검증에 써먹기 아주 좋은 장소다! 아래엔 본인 생각의 단상을 좀 써보겠다: + - 그럼 pydantic으로 된걸 dataclass나 아니면 아예 attrs로 갈아타서 싹 갈아엎는게 나을려나? + - sqlalchemy하고 긴밀하게 쓸 수 있는 게 뭔지부터 살펴보는게 좋을 것 같다. 그러면서 동시에 sqlalchemy 2.0 하고는 뭐가 어울리는지도 확실히 해두자 + +## 9.2.1 메시지 버스는 이제 이벤트를 UoW로부터 수집한다 + +이벤트 핸들러는 이제 UoW가 필요하다. 추가로 애플리케이션에서 메시지 버스는 더 중심 위치를 차지하게 되었다. 메시지 버스가 명시적으로 새 이벤트를 수집하고 처리하도록 하는 것이 더 타당하다. 현재는 UoW와 메시지 버스 사이의 순환적 의존성이 있는데, 이를 단방향으로 떼내자! + +```python +async def handle( + event: events.Event, + uow: unit_of_work.AbstractUnitOfWork, # 1) +): + queue = deque(event) # 2) + while queue: + event = queue.pop(0) # 3) + for handler in HANDLERS[type(event)]: # 3) + task = asyncio.create_task(handler(event)) + await task # 4) + queue.extend(uow.collect_new_events()) # 5) +``` + +1. 메시지 버스 시작 시 UoW를 받음 +2. 첫 이벤트를 처리할 때 큐를 시작한다 +3. `큐.pop()` 후 적절한 핸들러에 값을 던진다. `HANDLERS` 딕셔너리는 안바뀌었으니, 알아서 태스크 생성하고 돌 것이다 +4. 메시지 버스는 UoW를 각 핸들러에 전달한다 +5. 핸들러가 끝나면 이벤트 수집 후 이 이벤트를 큐에 추가한다. ([.extend()가 뭐냐](https://docs.python.org/3/tutorial/datastructures.html#more-on-lists)?) + +> 맨 앞의 값 꺼내오는데 `[deque](https://docs.python.org/3/library/collections.html#deque-objects)`쓰면 O(1) 만에 나오지 않나? 싶기도 하고, thread-safe 하대서 `deque`로 바꿔써보기로 했다. +> + +그리고 `unit_of_work.py` 에 있는 `publish_events()` 를 `collect_new_events()` 로 바꾼다. + +```python +async def collect_new_events(self): + for product in self.products.seen: + while product.events: + yield product.events.popleft() # 1) +``` + +1. 커밋이 일어나면 `publish_event` 를 호출하지 않고 이 메시지 버스는 이벤트 대기열을 추적한다 +2. `deque`니까 popleft로 주면 됨! + +## 9.2.2 모든 테스트는 이벤트 기반으로 다시 쓸 수 있다 + +예시를 살펴보자. + +```python +class TestAddBatch: + @pytest.mark.asyncio + async def test_add_batch(self): + uow = FakeUnitOfWork() + + await messagebus.handle( + events.BatchCreated("b1", "CRUNCHY-ARMCHAIR", 100, eta=None), + uow, + ) + + assert await uow.products.get("CRUNCHY-ARMCHAIR") is not None + assert uow.committed + +class TestAllocate: + @pytest.mark.asyncio + async def test_returns_allocation(self): + uow = FakeUnitOfWork() + await messagebus.handle( + events.BatchCreated("batch1", "COMPLICATED-LAMP", 100, None), + uow, + ) + results = await messagebus.handle( + events.AllocationRequired("o1", "COMPLICATED-LAMP", 10), + uow, + ) + + assert results.popleft() == "batch1" +``` + +이런 식으로… 그런데 살펴볼 사항이 몇개 있다! + +1. 테스트를 핸들로 단위별로 클래스로 감싼다 +2. 서비스를 직접 부르는 것이 아니라, 메시지 버스에 이벤트를 전달하여 핸들러를 사용하도록 한다. 테스트 코드를 작성하며 이 스펙에 익숙해질 수 있다. + +## 9.2.3 보기 싫은 임시 땜빵: 결과를 반환해야 하는 메시지 버스 + +```python +async def handle( + event: events.Event, + uow: unit_of_work.AbstractUnitOfWork, +): + results = deque() + queue = deque([event]) + while queue: + event = queue.popleft() + for handler in HANDLERS[type(event)]: + task = asyncio.create_task(handler(event, uow=uow)) + results.append(await task) + queue.extend(uow.collect_new_events()) + + return results +``` + +이렇게 핸들에 결과가 나오는 이유는 읽기/쓰기 책임이 혼재되어서 그렇다. 12장에서 CQRS를 다루며 다시 살펴보자. + +## 9.2.4 이벤트로 작동하도록 API 바꾸기 + +서비스를 직접호출하지 않고 이벤트를 인스턴스 후 메시지 버스에 전달하는 방법으로 고친다 + +```python +@app.post( + "/allocate", + status_code=status.HTTP_201_CREATED, +) +@inject +async def allocate_endpoint( + order_line: OrderLineRequest, +): + try: + event = events.AllocationRequired( # 1) + orderid=order_line.orderid, + sku=order_line.sku, + qty=order_line.qty, + ) + batchref = await messagebus.handle( # 2) + event, uow=unit_of_work.SqlAlchemyUnitOfWork(db.session_factory), + ) + batchref = batchref.popleft() + + except (model.OutOfStock, handlers.InvalidSku) as e: + raise HTTPException( + detail=str(e), + status_code=status.HTTP_400_BAD_REQUEST, + ) from e + + else: + return {'batchref': batchref} # 3) +``` + +1. 이벤트를 인스턴스화 했다 +2. 메시지 버스에 이벤트를 전달했다 +3. 결과값을 리턴했다 + +여기까지 하면서 애플리케이션을 이벤트 기반으로 수정완료했다! + +1. 서비스 계층 함수를 모두 이벤트 핸들러로 변경했다 +2. 따라서 서비스 계층 함수 호출과 도메인 모델에서 발생한 내부 이벤트를 처리하기 위한 함수 호출이 동일해졌다 +3. 이벤트는 시스템 입력을 잡아내는 데이터구조로 사용한다. 동시에 내부 작업 덩어리를 전달하기 위한 데이터 구조로도 사용한다 +4. 이것으로 전체 앱은 메시지 처리기 혹은 이벤트 처리기가 되었다. 둘의 차이점은 10장에서 설명한다 + +# 9.3 새로운 요구사항 구현하기 + +리팩토링이 끝났으니, 코드가 ‘변경하기 쉽게’ 되었는지 살펴보자. 아래 그림에 맞는 요구사항을 구현해볼 것이다. `BatchQuantityChanged` 라는 신규 이벤트를 만든다. 이를 받아 핸들러에 넘기고, 이 핸들러는 다시 어떤 `AllocationRequired` 라는 이벤트를 발생시킨다. 이는 다시 기존 핸들러에 넘겨져서 재할당을 일으킬 수 있다. + +![어 근데..??? 트랜잭션이 2개 아닌가…?](https://www.cosmicpython.com/book/images/apwp_0904.png) + +> 사물을 두 단위의 UoW에 걸쳐 나누면 DB 트랜잭션이 두개 생긴다. 데이터 정합성 문제가 발생한다. 이는 첫 번째 트랜잭션은 끝났지만 두 번째 트랜잭션이 끝나지 않아서 생기는 문제다. + +이런 사항에 대해 어떻게 처리할지는 14장에서 살펴본다. +> + +## 9.3.1 새로운 이벤트 + +배치 수량의 변경을 알려주는 이벤트는 단순하다. 추가해보자! + +```python +@dataclass +class BatchQuantityChanged(Event): + ref: str + qty: int +``` + +# 9.4 새 핸들러 시범운영하기 + +4장에서 배운 교훈을 따르면, ‘high gear’ 를 사용해 일하면서 유닛 테스트를 가장 최상위 수준에서 짤 수 있다. + +이 코드도 마찬가지로 클래스 단위로 감싸자. + +```python +class TestChangeBatchQuantity: + @pytest.mark.asyncio + async def test_changes_available_quantity(self): + uow = FakeUnitOfWork() + + await messagebus.handle( + events.BatchCreated("batch1", "ADORABLE-SETTEE", 100, eta=None), + uow, + ) + + [batch] = (await uow.products.get(sku="ADORABLE-SETTEE")).batches + assert batch.available_quantity == 100 + + await messagebus.handle( + events.BatchQuantityChanged("batch1", 50), + uow, + ) + + assert batch.available_quantity == 50 # 1) + + @pytest.mark.asyncio + async def test_reallocates_if_necessary(self): + uow = FakeUnitOfWork() + event_history = [ + events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None), + events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, today), + events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20), + events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20), + ] + + for e in event_history: + await messagebus.handle(e, uow) + + [batch1, batch2] = (await uow.products.get(sku="INDIFFERENT-TABLE")).batches + + assert batch1.available_quantity == 10 + assert batch2.available_quantity == 50 + + await messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow) + + # order1 혹은 order2 가 할당 해제된다. 25-20이 수량이 된다. + assert batch1.available_quantity == 5 # 2) + # 다음 배치에서 20을 재할당한다 + assert batch2.available_quantity == 30 # 2) +``` + +1. 간단한 경우는 수량만 변경하면 된다 +2. 할당된 수량보다 더 작게 수량을 바꾸면 최소 주문 한 개를 할당 해제하고 새로운 배치에 이 주문할당해야 하는 것을 예측한 코드다 → 요구사항이 그렇다면, 테스트코드를 그렇게 짜고 구현하면 된다는 것을 보여주는 것으로 보인다 + +## 9.4.1 구현 + +그럼 핸들러를 추가하고, 핸들러 관리 딕셔너리에도 추가해주면 될 것이다. + +코드를 살펴보자: + +```python +async def change_batch_quantity( + event: events.BatchQuantityChanged, + uow: unit_of_work.AbstractUnitOfWork, +): + async with uow: + product = await uow.products.get_by_batchref(batchref=event.ref) + await product.change_batch_quantity(event.ref, event.qty) + await uow.commit() +``` + +Repository에 새 쿼리타입이 필요하니, 추가해보자. + +```python +class AbstractRepository(Protocol): # 1) + ... + + async def get_by_batchref(self, batchref) -> model.Product: + raise NotImplementedError + +class TrackingRepository: # 2) + ... + + async def get_by_batchref(self, batchref) -> model.Product: + product = await self._repo.get_by_batchref(batchref) + if product: + self.seen.add(product) + return product + +class SqlAlchemyRepository(AbstractRepository): # 3) + ... + + async def get_by_batchref(self, batchref) -> model.Product: + return ( + ( + await self.session.execute( + select(model.Product) + .join(model.Batch) + .filter(orm.batches.c.reference == batchref) + ) + ) + .scalars() + .one_or_none() + ) +``` + +1. `Protocol` 로 구현했다보니 필요한 원형만 기재한다. +2. `TrackingRepository` 로 한 번 감싸서 리포지토리 쿼리와 이벤트 관련 내용을 처리한다. +3. `SqlAlchenyRepository` 에는 실제 쿼리내용을 추가한다. + +테스트코드에서 쓰는 `FakeRepository` 도 마찬가지로 갈아준다. + +```python +class FakeRepository(repository.AbstractRepository): + ... + + async def get_by_batchref(self, batchref) -> model.Product: + return next(( + p for p in self._products for b in p.batches + if b.reference == batchref), + None + ) +``` + +> 이 유스케이스를 쉽게 구현하기 위해 리포지토리에 쿼리를 추가했다. + +쿼리가 단일 애그리게이트를 반환하면 문제없지만, 여러 저장소에 대해 복잡한 쿼리를 하면 다른 설계가 필요할 수 있다. 11장, 14장에서 그런 방안을 살펴볼 것이다. + +예를 들면 이런 쿼리가 될 수 있을 것이다… +`get_most_popular_products` , `find_products_by_order_id` 같은 것들… +> + +## 9.4.2 도메인 모델의 새 메소드 + +모델에 새 메소드를 추가한다. 이 메소드는 수량을 바꾸자마자 인라인으로 할당을 해제하고 새 이벤트를 publish한다. 기존 `allocate` 함수를 수정하여 이벤트를 publish 하도록 바꾼다: + +```python +class Product: + ... + + def change_batch_quantity(self, batch_ref: str, qty: int): + batch = self.get_allocation(batch_ref) + batch.purchased_quantity = qty + while batch.available_quantity < 0: + line = batch.deallocate_one() + self.events.append( + events.AllocationRequired( + line.orderid, + line.sku, + line.qty, + ) + ) +``` + +새 핸들러를 이벤트와 연결함으로 마무리한다: + +```python +HANDLERS = { + events.BatchCreated: [handlers.add_batch], + events.OutOfStock: [handlers.send_out_of_stock_notification], + events.AllocationRequired: [handlers.allocate], + events.DeallocationRequired: [handlers.deallocate], + events.BatchQuantityChanged: [handlers.change_batch_quantity], # 1) +} # type: Dict[Type[events.Event], List[Callable]] +``` + +1. 추가완료! + +# 9.5 선택: 가짜 메시지 버스와 독립적으로 이벤트 핸들러 단위 테스트 하기 + +reallocation 워크플로우 테스트는 e2e 테스트라 할 수 있다. 메시지 버스를 쓰고 전체 워크 플로우를 테스트한다. 이 테스트는 실제 메시지 버스를 사용하며, `BatchQuantityChanged` 이벤트 핸들러가 할당 해제를 트리거하고, 자체 핸들러가 처리하는 새로운 `AllocationRequired` 이벤트를 발생(*emit)*[^3]시키는 전체 플로우를 테스트한다. + +이벤트 체인이 복잡해짐에 따라 독립적으로 일부 핸들러를 테스트하고 싶을 때가 온다. 이 때는 ‘가짜’ 메시지 버스를 사용하면 이런 테스트를 할 수 있다. + +다름 예제에서 `FakeUnitOfWork` 의 `publish_events()` 메소드를 바꾸어서 실제 메시지 버스와 분리할 수 있다. 이 때는 메시지 버스에 넣는게 아니라 발생시킨 이벤트를 리스트(본 예제에서는 `deque`)에 저장한다. + +상세한 내용은 `pt2/ch09`의 코드를 참고하면 된다. + +# 9.6 마치며 + +시스템을 어떻게 바꿨는지 복습해보자. + +## 9.6.1 시스템을 어떻게 바꾸었나? + +이벤트는 시스템 안의 내부 메시지와 입력에 대한 데이터 구조를 정의하는 데이터 클래스다. + +이벤트는 종종 비즈니스 언어로 매우 잘 번역되기 때문에 DDD 관점에서 보면 매우 강력하다(이벤트 스토밍을 꼭 복습하자!). + +핸들러는 이벤트에 반응하는 방법이다. 핸들러는 모델을 호출하거나 외부 서비스를 호출할 수 있다. 원한다면 한 이벤트에 여러 핸들러를 정의할 수도 있다. 또 핸들러는 다른 이벤트를 만들 수도 있다. 이를 통해 핸들러가 수행하는 일의 크기를 세밀하게 조절하여 SRP를 유지할 수도 있다. + +## 9.6.2 왜 이렇게 바꾸었나? + +애플리케이션의 크기가 커지는 속도 보다 복잡도가 증가하는 속도를 느리게 하기 위함이다. + +메시지 버스에 실으면 아키텍처는 복잡해지지만 필요 작업을 수행하기 위해 주요 개념 혹은 아키텍처 추가로 인한 코드 변경이 필요없다. + +수량변경, 할당해제, 새 트랜잭션 시작, 재할당, 외부통지까지 한 번에 다 들어갔지만 아키텍처적으로 봤을 때는 복잡도가 늘어난 것은 아니다. 새 이벤트나 새 핸들러를 추가하고 외부 어댑터(메일전송)까지 추가하더라도 이벤트 기반의 아키텍처의 어디에 속하는지 파악할 수 있다. + +전체 애플리케이션이 메시지 버스인 경우의 트레이드오프를 살펴보자! + +| 장점 | 단점 | +| --- | --- | +| 핸들러와 서비스가 동일 물건이라서 더 단순하다 | 웹이라는 관점에서 메시지 버스를 보면 여전히 예측하기 어려운 처리방법이다 +작업이 언제 끝나는지 예측할 수 없다 | +| 시스템 입력을 처리하기 좋은 데이터 구조가 있다 | 모델 객체와 이벤트 사이에 필드와 구조 중복이 있고, 이에 대한 유지보수가 필요하다. 한쪽에 필드를 추가한다면 다른쪽에 속한 객체에 두 개 이상 필드를 추가해야 한다. | + +그리고 `BatchQuantityChanged` 같은 이벤트를 이해하기 위해, 이벤트와 커맨드의 차이부터 살펴볼 것이다. + +10장에서 봅시다. + +--- + +[^1]: 이런 이벤트 중 몇개는 커맨드같다? 맞다. 그런데 그건 12장에서 다시 살펴보는 걸로… + +[^2]: [https://martinfowler.com/articles/preparatory-refactoring-example.html](https://martinfowler.com/articles/preparatory-refactoring-example.html) + +[^3]: 왜 *emit* 이란 단어를 사용하는지는 해당 링크를 읽어보자. 다른 곳에서도 자주 쓰이는 듯 하니… +[https://stackoverflow.com/questions/31270657/what-does-emit-mean-in-general-computer-science-terms](https://stackoverflow.com/questions/31270657/what-does-emit-mean-in-general-computer-science-terms) diff --git a/content/books/cosmic-python/2023-05-06---pt02-ch09/media/universe.jpg b/content/books/cosmic-python/2023-05-06---pt02-ch09/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-05-06---pt02-ch09/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-05-07---pt02-ch10/index.md b/content/books/cosmic-python/2023-05-07---pt02-ch10/index.md new file mode 100644 index 00000000..fcdd8689 --- /dev/null +++ b/content/books/cosmic-python/2023-05-07---pt02-ch10/index.md @@ -0,0 +1,391 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (10)" +date: "2023-05-07T18:41:36.000Z" +template: "post" +draft: false +slug: "/books/cosmic-python/2023-05-07-pt02-ch10" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 8, 애그리게이트와 일관성 경계에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +10장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt2/ch10) + +# 10장 커맨드와 커맨드 핸들러 + +이전 장에서는 시스템 입력을 표현하기 위해 이벤트를 사용하는 방법을 익혔고, 애플리케이션을 메시지 처리 기계로 바꿨다. + +이를 위해 모든 유스케이스 함수를 이벤트 핸들러로 바꿨다. API는 새 배치 생성 호출을 받으면 `BatchCreated` 이벤트를 만들어서 내부 이벤트처럼 처리한다. 그런데 배치가 생성되지도 않았는데 API를 호출한다? 뭔가 이상하다(라고 하네요?) + +이벤트와 같은 메시지 버스를 다루지만 약간 다른 규칙으로 처리하는 커맨드를 도입하여 이런 사항을 수정한다. + +# 10.1 커맨드와 이벤트 + +이벤트와 마찬가지로 커맨드(*command*)도 메시지의 일종이다. 시스템의 한 부분에서 다른 부분으로 전달되는 명령이 커맨드다. 커맨드도 아무 메소드 없는 데이터 구조로 표현하고 이벤트처럼 처리하는데 **그 둘의 차이가 정말 중요하다!** + +커맨드는 한 액터[^1]로부터 다른 구체적인 액터에게 전달된다. 보내는 액터는 받는 액터가 커맨드를 받고 작업을 해주기를 바란다. API 핸들러에 폼을 전달하는 행동은 커맨드를 전달하는 행동과 같다. 그래서 커맨드 이름은 명령형 동사구(*imperative mood verb*)다. 예를 들면 아래와 같다: + +- Allocate stock +- Delay shipment + +커맨드는 의도(*intent*)를 잡아낸다. 커맨드는 시스템이 어떤 일을 수행하길 바라는 의도를 드러낸다. 그 결과로 커맨드를 보내는 액터는 커맨드 리시버(*reciever*)가 커맨드 처리에 실패했을 때 오류를 돌려받길 바란다. + +이벤트(*event*)는 액터가 관심있는 모든 리스너에게 보내는 메시지다. `BatchQuantityChanged` 라는 이벤트를 publish 해도 보내는 쪽(*sender*)은 누가 이걸 받는지 모른다. 아래 표는 커맨드와 이벤트의 차이다. + +| | 이벤트 | 커맨드 | +| --- | --- | --- | +| 이름 | 과거형 | 명령형 | +| 오류 처리 | (송신하는 쪽과) 독립적으로 실패함 | (송신하는 쪽에 오류를 돌려주며) 시끄럽게 실패함 | +| 누구에게 +보내나? | 모든 리스너 | 정해진 수신자 | + +현재 어떤 커맨드가 있는지 살펴보자. + +```python +from dataclasses import dataclass + +class Command: + pass + +@dataclass +class Allocate(Command): # 1) + order_id: str + sku: str + qty: int + +@dataclass +class CreateBatch(Command): # 2) + ref: str + sku: str + qty: int + eta: str + +@dataclass +class ChangeBatchQuantity(Command): # 3) + ref: str + qty: int +``` + +1. `AllocationRequired` 이벤트를 대신한다 +2. `BatchCreated` 이벤트를 대신한다 +3. `BatchQuantityChanged` 이벤트를 대신한다 + +# 10.2 예외 처리 방식의 차이점 + +이름, 동사를 바꾸는건 뭐 IDE한테 맡기면 그만이지만 로직을 어떻게 바꿔야하는지(메시지 버스 변경 등)을 살펴보자 + +커맨드, 이벤트를 통으로 처리하는 값을 메시지(`Message`)라 정의하고 이를 `Union` 으로 처리하자! + +```python +Message = Union[commands.Command, events.Event] # 1) + +async def handle( + message: Message, # 1) + uow: unit_of_work.AbstractUnitOfWork, +): + results = deque() + queue: deque[Message] = deque([message]) + while queue: + message = queue.popleft() + + if isinstance(message, events.Event): + await handle_event(message, queue, uow) # 2) + elif isinstance(message, commands.Command): + result = await handle_command(message, queue, uow) # 3) + results.append(result) + else: + raise Exception(f'{message} was not a Command or Event') + + return results +``` + +1. 메시지라는 값을 만들고, 커맨드/이벤트에 대해 받을 수 있게 해놨다 +2. 이벤트 핸들러에 대해 별도로 분리했다. 진입점도 분리되어있다. +3. 커맨드 핸들러에 대해 별도로 분리했다. 진입점도 분리되어있다. + +이벤트 처리 방안은 아래와 같다: + +```python +async def handle_event( + event: events.Event, + queue: deque[Message], + uow: unit_of_work.AbstractUnitOfWork, +): + for handler in MessageBus.EVENT_HANDLERS[type(event)]: # 1) + try: + logger.debug(f'Handling event {event} with {handler}') + await handler(event, uow) + queue.extend(uow.collect_new_events()) + except Exception as ex: + logger.exception(f'Exception handling {event}... detail: {ex}') + continue # 2) +``` + +1. 한 이벤트를 여러 핸들러가 처리하도록 위임할 수 있는 디스패처로 이벤트가 처리된다 +2. 오류가 생각하면 로그를 남기지만, 오류가 메시지 처리를 방해하지는 못하게 한다 + +커맨드 처리 방안은 아래와 같다: + +```python +async def handle_command( + command: commands.Command, + queue: deque[Message], + uow: unit_of_work.AbstractUnitOfWork, +): + logger.debug(f'Handling command {command}') + try: + handler = MessageBus.COMMAND_HANDLERS[type(command)] # 1) + result = await handler(command, uow) + queue.extend(uow.collect_new_events()) + return result # 3) + except Exception as ex: + logger.exception(f'Exception handling {command}... detail: {ex}') + raise # 2) +``` + +1. 커맨드 디스패처는 커맨드 하나에 핸들러 하나만을 허용한다(헷갈린다면 10.1 에서 내린 정의를 살펴보자!) +2. 오류가 발생하면 propagate 한다 +3. `return result` 구문은 임시방편이다(!) 9.2.3절에서 언급한 내용이다. 이 방법은 API가 사용하기 위한 배치 참조를 돌려주기 위한 값이다. 12장에서 수정할 것이다. + +메시지 버스를 살펴보자. 두 딕셔너리로 분리한 클래스로 관리한다[^2]. 앞서 살펴보았듯 각 이벤트에는 여러 핸들러가 있을 수 있다. 하지만 각 커맨드에는 핸들러가 하나밖에 없다. + +```python +class MessageBus: + EVENT_HANDLERS = { # 1) + events.OutOfStock: [handlers.send_out_of_stock_notification], + } # type: Dict[Type[events.Event], List[Callable]] + COMMAND_HANDLERS = { # 2) + commands.Allocate: handlers.allocate, + commands.Deallocate: handlers.deallocate, + commands.CreateBatch: handlers.add_batch, + commands.ChangeBatchQuantity: handlers.change_batch_quantity, + } # type: Dict[Type[commands.Command], Callable] +``` + +1. 이벤트 핸들러의 특징을 살펴보자 +2. 커맨드 핸들러의 특징도 마찬가지다 + +# 10.3 논의: 이벤트, 커맨드, 오류 처리 + +저자는 여기까지 왔을 때의 불편함이나 개선사항을 아래와 같이 말한다. + +- 만약 이벤트 처리에 실패하면 어떻게 처리할 것인가? +- 시스템이 일관성있는 상태를 유지한다고 어떻게 확신할 수 있나? +- 현 구조에서는 메시지 유실이 염려된다. 만일 `MessageBus.handle` 에서 이벤트를 절반만 처리하다가 OOM으로 프로세스가 죽으면 메시지가 사라질것이다. 이 경우는 어떻게 해결해야 할까? + +최악의 경우를 생각해보자. 이벤트 처리에 실패하고 시스템이 일관성을 잃었다. 이로 인해 어떤 오류가 생겼나 살펴보자. 연산 중 일부만 완료된 경우 시스템이 일관성 없는 상태가 될 수 있다. + +요컨대 `DESIRABLE_BEANBAG` [^3] 3개를 고객 주문에 할당했지만, 왠진 모르겠으나 현재 재고 감소에 실패했다고 치자. 겉보기엔 재고 세 개가 모두 할당되고 사용 가능한 상태가 되어버린다. 이러면 일관성이 무너진 것이다. 재고 파악을 옳게 못하다가 다른 고객에게 oversell 해버리면? + +다행히도 `Allocate` 서비스에서는 조치를 취해놨다. 애그리게이트를 일관성 경계로 동작하게 해놨고, 애그리게이트에 대한 업데이트 성공/실패를 atomic하게 처리하기 위해 UoW를 설계했다. + +예를 들자면, 주문에 재고를 할당할 때의 consistency boundary는 `Product` 애그리게이트다. 여기서 과할당을 방지할 수 있다. 특정 주문 라인이 제품에 할당하거나, 그렇지 않으면 아예 할당하지 않는다[^4]. + +그리고 프로젝트 정의에 따르면, 두 애그리게이트는 즉각적으로 일관성을 가질 필요가 없다. 어떤 이벤트를 처리하다 실패해서 애그리게이트 하나만 업데이트 된다고 해도, 시스템은 일관성을 갖춘다. 시스템의 제약 조건을 위반하면 안 된다. + +이런 예제를 통해 메시지를 커맨드와 이벤트로 분리하는 이유에 대해 다시금 생각할 수 있게 되었다. 사용자가 시스템이 어떤 일을 하길 원한다면 이 요청을 **커맨드**로 표현한다. 커맨드는 한 **애그리게이트**를 변경해야 하고, 전체적으로 성공하거나 모두 실패해야한다. 시스템이 수행하는 다른 재고처리나 후속조치는 **이벤트**로 발생시킨다. 커맨드가 성공하기 위해 이벤트 핸들러가 성공할 필요는 없다. + +커맨드가 성공하기 위해 이벤트가 성공하지 않아도 되는 이유를 아래의 다른 예시로 살펴보자. + +## 10.3.1 예제: 명품을 파는 전자상거래 사이트 설계 + +어느 사이트에서 많이 팔아주시는 VIP 고객님을 선정하는 기준을 아래와 같이 정리하고 처리하기로 했다 치자. + +1. 주문 이력이 2개있는 +2. 고객이 3번째 주문을 할 때 +3. 이 고객을 VIP로 선정한다 +4. 처음 VIP로 변경된 고객에게는 +5. 축하 메일을 보낸다 + +이걸 애그리게이트 단위로 풀어내면 된다는 뜻이다. 이 애그리게이트를 `History` 라고 하고 예시를 살펴보자. 이 애그리게이트는 주문을 기록하고, 규칙을 만족할 때 도메인 이벤트를 발생시킨다. + +```python +class History: + def __init__( + self, + customer_id: int, + ) -> None: + self.orders: Set[HistoryEntry] = set() + self.customer_id = customer_id + + def record_order( + self, + order_id: str, + order_amount: int, + ): # 1) + entry = HistoryEntry(order_id, order_amount) + + if entry in self.orders: + return + + self.orders.add(entry) + + if len(self.orders) == 3: + self.events.append( + CustomerBecameVIP(self.customer_id) + ) + +def create_order_from_basket( + uow, + cmd: CreateOrder, +): # 2) + with uow: + order = Order.from_basket(cmd.customer_id, cmd.basket_items) + uow.orders.add(order) + uow.commit() # raises OrderCreated + +def update_customer_history( + uow, + event: OrderCreated, +): # 3) + with uow: + history = uow.order_history.get(event.customer_id) + history.record_order(event.order_id, event.order_amount) + uow.commit() # raises CustomerBecameVIP + +def congratulate_vip_customer( + uow, + event: CustomerBecameVIP, +): # 4) + with uow: + customer = uow.customers.get(event.customer_id) + email.send( + customer.email_address, + f'Congratulations {customer.first_name}!' + ) +``` + +1. `History` 애그리게이트는 고객이 VIP가 되는 규칙을 체크한다. 애그리게이트는 이런 식으로 규칙을 처리하는 좋은 장소임을 캐치하자 +2. 첫 핸들러는 고객 주문을 생성하고 `OrderCreated` 라는 도메인 이벤트를 발생시킨다 +3. 두 번째 핸들러는 만들어진 주문을 기록하기 위해 `History` 를 업데이트한다 +4. 고객에게 VIP가 되었음을 알리는 메일을 전송한다 + +가만보면 이벤트 기반 시스템에서는 오류를 어떻게 처리하는지 볼 수 있다. + +- 애그리게이트는 상태를 DB에 **영속화한 이후** 이벤트를 발생시킨다 +- **영속화 이전**에 이벤트 발생 및 변화를 커밋하는건? 그 후에 “동시에” 모든 변화를 커밋하면? 이러면 모든 작업이 완료됐다 할 수 있을텐데 이게 더 낫지 않나? + +하지만 만일 메일 서버가 과부화라면? 모든 작업이 동시에 끝나야되면 '메일 전송’ 작업이 다른 작업의 발목이 될 수도 있다. + +`History` 애그리게이트에 버그가 있다면? 고객을 VIP로 인식하지 못했다고 해서 고객의 결제처리를 하면 안되는건가? + +이런 관심사를 분리하면 실패할 수 있는 요소들이 서로 격리되어 실패하게 할 수 있다. 이 코드에서 성공해야 하는 부분은 주문 생성 커맨드 핸들러 뿐이다. 이 것만이 고객이 신경쓰는 부분이고, 비즈니스 관계자들이 중요하게 생각하는 부분이다. + +트랜잭션 경계를 비즈니스 프로세스 시작과 종료에 맞추어 의도적으로 조정한 것인지 살펴보자. 코드에서 쓰는 이름은 비즈니스 관계자들이 쓰는 언어와 일치하며, 핸들러는 자연어로 작성한 판별 기준과 일치한다. 이름과 구조가 일치하면 시스템이 커지고 복잡해질 때, 시스템을 추론하는 과정에서 상당히 도움된다. + +# 10.4 동기적으로 오류 복구하기 + +위에서 살펴본 바와 같이, 이벤트는 이벤트를 발생시킨 커맨드와 독립적으로 실패해도 좋다. 불필요하게 오류가 발생한 경우 오류를 복구시킬 수 있다고 확신하려면 어떻게 해야할까? + +가장 먼저 해야할 것은 오류가 언제 일어났는지를 파악하는 것이다. 그것 때문에 오류 발생시점의 로그를 찍는 것이었다. `handle_event()` 로직을 다시 살펴보자. + +```python +async def handle_event( + event: events.Event, + queue: deque[Message], + uow: unit_of_work.AbstractUnitOfWork, +): + for handler in MessageBus.EVENT_HANDLERS[type(event)]: + try: + logger.debug(f'Handling event {event} with {handler}') + await handler(event, uow) + queue.extend(uow.collect_new_events()) + except Exception as ex: + logger.exception(f'Exception handling {event}... detail: {ex}') + continue +``` + +시스템에서 메시지 처리할 때 처음 하는 일은 로그를 찍는 것이다. 위의 `History` 예시를 통해서 다시 참고해보자. `CustomerBecameVIP` 라는 유스케이스의 경우 로그는 아래와 같다: + +``` +Handling event CustomerBecameVIP(customer_id=12345) with handler +``` + +`dataclasses` 를 써서 저런 식으로 데이터를 요약해서 볼 수 있다. 객체를 다시 만들기 위해 이 출력을 복사해 파이썬 shell에 복붙도 할 수 있다. + +오류가 발생하면 로그에 저장된 데이터를 사용해 문제를 유닛테스트로 재현하거나 시스템에서 메시지를 다시 실행할 수 있다. + +이벤트를 다시 처리하기 전에 버그를 수정해야 한다면 수동 재실행이 잘 작동한다. 하지만 시스템은 어쨌거나 백그라운드에서 **일시적인 실패가 일정 수준은 항상 존재**할 것이다. 이런 실패에는 네트워크의 일시적 문제, DB의 데드락, 배치로 인해 발생하는 일시적 서비스 중단 등이 그것이다. + +이런 경우에는 재시도를 하여 깔끔하게 복구할 수 있다. '깔끔하게' 라는 뜻은 '한 번 만에 안 되면 기하급수적으로 증가하는 백오프 기간(*exponentially increasing back-off period*) 후에 재시도 한다'를 의미한다. + +동기적으로 재시도하는 코드의 예시를 보자: + +```python +from tenacity import ( + Retrying, + RetryError, + stop_after_attempt, + wait_exponential, +) #(1) + +def handle_event( + event: events.Event, + queue: List[Message], + uow: unit_of_work.AbstractUnitOfWork, +): + for handler in EVENT_HANDLERS[type(event)]: + try: + for attempt in Retrying( #(2) + stop=stop_after_attempt(3), + wait=wait_exponential() + ): + + with attempt: + logger.debug("handling event %s with handler %s", event, handler) + handler(event, uow=uow) + queue.extend(uow.collect_new_events()) + except RetryError as retry_failure: + logger.error( + "Failed to handle event %s times, giving up!", + retry_failure.last_attempt.attempt_number + ) + continue +``` + +1. [Tenacity](https://github.com/jd/tenacity)는 파이썬에서 재시도와 관련한 패턴을 구현한 라이브러리다 +2. 여기서는 메시지버스가 `3`번을 기하급수적으로 증가하는 백오프 기간을 두고 재시도한다 + +실패할 수도 있는 연산을 재시도하는 것은 시스템의 회복 탄력성(*resilience*)을 향상시키는 최선의 방안일 것이다. 최소한 이렇게라도 해야 작업이 반쯤 끝난 상태로 남지 않게 한다. + +> ‼️ **CAUTION** ‼️ + +`tenacity` 를 쓰는 것과 별개로, 어느 시점에서는 메시지를 처리하려는 시도를 *포기* 해야한다. +분산 메시지를 사용해서 *반드시* 신뢰할 수 있는 시스템을 만드는 것은 매우 힘든 일이다. 그 부분은 에필로그에서 다시 볼 것이다 +> + +여기서는 `tenacity`를 사용한 비동기 코드의 작업방안에 대해 살펴본다: + +```python + +``` + +# 10.5 마치며 + +커맨드, 이벤트 개념을 알아봤다. 시스템이 응답할 수 있는 요청에 이름을 붙이고 자체적인 데이터 구조를 제공하여 명시하는 일에 대해 알게 되었다. 이번 장에서 살펴본 이벤트, 커맨드, 메시지 버스를 통한 처리를 커맨드 핸들러(*command handler*) 라고 부르기도 한다. + +아래 표는 커맨드, 이벤트 분리를 적용하기 전 살펴봐야 할 트레이드오프다: + +| 장점 | 단점 | +| --- | --- | +| 커맨드와 이벤트를 다른 방식으로 처리하면, 어떤 부분이 반드시 성공해야 하는지, 나중에 정리해도 되는지를 구별하는데 도움이 된다 | 커맨드와 이벤트의 의미적 차이가 사람마다 다를 수 있다. 둘 사이의 차이를 구성원 모두가 동의하는 데 시간을 써야할 수도 있다 | +| CreateBatch 는 BatchCreated 보다는 의도가 훨씬 명시적이다 | 실패를 명확히 구별한다. 프로그램이 깨질 수 있다는 사실을 알고, 실패를 더 작고 격리가능한 단위로 나누기로 결정한다. 이를 통해 시스템 추론이 더 어려워지고 모니터링의 중요성이 대두된다 | + +11장에서는 이벤트를 통합 패턴으로 쓰는 법에 대해 알아보자. + +--- + +[^1]: *actor* 라고 써져있다. 그런데 DDD에서 쓰는 그 Actor 같기도 해서, 음차해서 쓰는 편이 나을 것으로 판단했다. + +[^2]: 13장에서 개조할 클래스 형식의 메시지 버스에 대한 일부분이다. 13장 코드를 미리 살펴보면 아예 핸들용 클래스로 따로 떨어져있고, 시스템 구동 시의 부트스트랩에서 의존성을 모두 깔끔하게 처리한다. 지금 한큐에 이해하긴 힘들고, 한번에 느리게 하기보단 차라리 못생긴 모습을 들고가서 추후에 바꾸려고 한다. 이런 방안이야말로 어쨌거나 더 빠르게 문제를 해결하는 방법이니까… + +[^3]: 대충 이런 상품이 있다고 생각해주세요 + +[^4]: `Product.allocate()` 은 내부적으로 `Batch.can_allocate()` 로직을 거치기 때문 diff --git a/content/books/cosmic-python/2023-05-07---pt02-ch10/media/universe.jpg b/content/books/cosmic-python/2023-05-07---pt02-ch10/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-05-07---pt02-ch10/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-05-18---pt02-ch11/index.md b/content/books/cosmic-python/2023-05-18---pt02-ch11/index.md new file mode 100644 index 00000000..d2d68821 --- /dev/null +++ b/content/books/cosmic-python/2023-05-18---pt02-ch11/index.md @@ -0,0 +1,424 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (11)" +date: "2023-05-18T03:41:09.000Z" +template: "post" +draft: false +slug: "/books/cosmic-python/2023-05-18-pt02-ch10" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 11, 이벤트 기반 아키텍처: 이벤트를 사용한 마이크로서비스 통합에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +11장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt2/ch11) + +# 11장 이벤트 기반 아키텍처: 이벤트를 사용한 마이크로서비스 통합 + +제목 길다ㅋㅋ + +이전 장에서는 실제로 '배치 수량이 변경됨' 이라는 이벤트를 어떻게 받을 수 있는지, 재할당에 대해 외부 세계에 어떻게 통지할 수 있는지에 대해서는 논하지 않았다. + +현재까지 만든건 웹 API가 있는 마이크로서비스 한 개다. 다른 시스템과 이야기하는 다른 방법을 생각해보자. 선적이 지연되거나, 수량이 변경되거나 하는건 시스템이 어떻게 알 수 있을까? 시스템이 창고 시스템에게 주문이 할당되었고 다른 고객에게 운송되어야 한다고 어떻게 이야기할 수 있을까? + +이번 장에서는 이벤트 비유를 확장하여 시스템으로 들어오거나 시스템에서 나가는 메시지까지 포용하는 방안을 살펴본다. 여지껏 애플리케이션의 핵심은 메시지 처리기가 되도록 바꾸었다. 이제는 외부로도 이를 처리할 수 있도록 작업해보자. + +외부 이벤트가 들어오는 것은 외부 메시지 버스(이 책에서는 Redis의 pub/sub 대기열을 예제로 사용한다)를 통해 subscribe 하고, 출력은 이벤트 형태로 외부 메시지 버스에 publish 한다. + +![이제 애플리케이션은 메시지 처리기가 되었다](https://www.cosmicpython.com/book/images/apwp_1101.png) + +# 11.1 분산된 진흙 공, 명사로 생각하기 + +책의 저자는 마이크로서비스 아키텍처(이하 MSA)를 구축하는 사람과 자주 이야기하며 기존 앱을 마이그레이션하는 논의를 자주 한다고 한다. 이 때 본능적으로 하는 일은 시스템을 명사화 하는 것이다. + +현재까지의 시스템에 도입된 명사들을 생각해보자. 재고 배치, 주문, 상품, 고객 등이 있다. 이를 그림과 같이 나누었다. (참고: ‘할당’이라는 동작 대신, ‘배치’라는 명사를 기준으로 이름이 붙어졌음) + +![명사 기반 서비스의 컨텍스트 다이어그램](https://www.cosmicpython.com/book/images/apwp_1102.png) + +이 시스템의 ‘물건’ 마다 연관된 서비스가 있고, 그 서비스는 HTTP API를 노출한다. + +아래 command flow 1을 통해 정상경로(*happy path*) 를 진행해보자. + +1. 사용자가 웹 사이트에 방문하여 재고가 있는 상품을 선택한다 +2. 상품을 장바구니에 담고 재고를 예약한다 +3. 주문이 완료되면 예약을 확정하고 창고에 출고를 지시한다 +4. `3`번째 주문일 경우 고객 레코드를 변경하여 일반 고객을 VIP로 승격시킨다 + +![커맨드 플로우 1](https://www.cosmicpython.com/book/images/apwp_1103.png) + +각 단계를 이런 커맨드로 생각해볼 수 있겠다: + +1. `ReserveStock` +2. `ConfirmReservation` +3. `DispatchGoods` +4. `MakeCustomerVIP` + +이런 스타일의 아키텍처에서는 DB 테이블 단위로 마이크로서비스를 만들고, HTTP API를 빈약한(비즈니스 로직이 없는) 모델에 대한 CRUD 인터페이스로 취급하며, 서비스 중심의 설계를 처음 하는 사람들이 취하는 방식이다. + +간단하면 잘 돌겠지만, 금방 복잡해진다(!!!). 왜냐면 실패 케이스에 대한 고려가 없기 때문이다. 이런 케이스에 대해 살펴보자: + +1. 재고가 도착했는데 배송 중 물에의해 손상된 경우가 있다. 이걸 팔 수는 없으니 폐기하고 다시 재고요청을 해야한다 +2. 이 경우에는 재고 모델을 업데이트 해야할 수도 있고, 그로인해 고객의 주문을 재할당 해야할 수도 있다 + +이런 기능들을 어디에 넣어야할까? 대충 봤을 땐(!) 창고 시스템이 하면 될 것 같다. 아래와 같은 command flow 2가 나올 것이다. + +![커맨드 플로우 2: 창고 시스템이 이런 처리를 담당하는 경우](https://www.cosmicpython.com/book/images/apwp_1104.png) + +잘 돌아간다. 그렇지만 의존성 그래프가 지저분해진다. 왜인지 보자: + +1. 재고를 할당하려면 '주문 서비스'가 '배치 시스템'을 제어해야 한다 +2. '배치 시스템'은 다시 '창고 시스템'을 제어한다 +3. 창고에 생긴 문제를 해결하려면 '창고 시스템'은 '배치 시스템'을 제어하고, '배치 시스템'은 주문을 제어한다 + +이 경우 시스템이 제공해야 하는 다른 워크플로우의 숫자만큼 곱한다. 이래선 빠르게 결과를 만들어내는 것만 못한 시스템이 나온다..! + +# 11.2 분산 시스템에서 오류 처리하기 + +'모든 것은 망가진다(*Things break*)'는 소프트웨어 엔지니어링에서 일반적인 규칙이다. 어떤 요청이 실패하면 시스템에 어떤 일이 발생하는지 살펴보자. + +예를 들어, 사용자가 `MISBEGOTTEN-RUG` 에 대해 3개를 주문받고 네트워크 오류가 발생했다 가정하자: + +![오류가 발생한 명령 흐름](https://www.cosmicpython.com/book/images/apwp_1105.png) + +이에 대한 두 가지 처리방법이 있다. + +1. 주문은 넣지만 할당을 하지 않거나 할당을 보장할 수 없으므로, 최종적으로 주문을 거부한다. 이 실패를 위로 전달한다 → 주문 서비스의 신뢰성에 영향을 끼칠것이다! + - 두 가지를 함께 바꿔야 하는 경우를 결합되었다(*coupled*) 라고 한다 + - 이런 식의 연쇄적 실패는 시간적 결합(*temporal coupling*) 이라고 부른다 + - 시스템의 모든 부분이 동시에 제대로 작동할 때만 정상적으로 작동하는 경우를 시간적 결합이라고 한다 + - 시스템이 커지면 시스템 부품 중 일부의 성능이 나빠질 확률이 기하급수적으로 높아진다(*exponentially incresing probability*) + - 아래 동시생산 절을 살펴보자 +2. 11.3절에서 이에 대한 대안을 작성할 것이다 + +## 11.2.1 동시생산(*Connascence*)? + +본 책에서는 결합(*coupling*) 이란 말을 사용하나, 시스템 상 현재 예제와 같은 관계를 동시생산(*connascence*)[^1] 라고도 일컫는다. 이는 다른 유형의 결합을 묘사할 때 사용하는 용어다. + +동시생산은 나쁘지 않다. 그렇지만 어떤 동시생산 케이스는 다른 케이스보다 더 강하다. 보통은 두 클래스가 밀접하게 연관되어(*closely related*)있으면 강한 동시생산을 지역적으로만 한정시키고 그렇지 않으면 약한 생산으로 떼어놓고자 한다. + +위에서 살펴본 커맨드 플로우 2 예시에서는 [실행의 동시생산](https://connascence.io/execution.html)(*connascence of execution*)을 살펴볼 수 있다. 연산이 성공하려면 여러 구성요소의 **정확한** 작업 순서를 알고 있어야 한다. + +여기서는 오류가 발생하는 경우에서는 [타이밍의 동시생산](https://connascence.io/timing.html)(*connascence of timing*)을 살펴볼 수 있다. 한 가지 일이 **일어난 직후 바로 다음** 일이 일어나야 한다. + +~~RPC 이야기는 이해못해서 기재하지 않음~~ 이름의 동시생산(*connascence of name*)에 대해 언급한다. + +소프트웨어가 다른 소프트웨어와 통신하지 않는 경우를 제외하고는 **결합을 완전히 피할 수 없다. 다만 부적절한 결합만큼은 피해야 한다.** 동시생산은 서로 다른 아키텍처 스타일에 내재된 결합의 강도와 유형을 이해하기 위한 멘탈 모델(*mental model*)을 제공한다. + +# 11.3 대안: 비동기 메시징을 사용한 시간적 결합 + +적절한 결합을 얻기 위해선 명사가 아니라 동사로 생각해야 한다는 점을 살펴봤다. 도메인 모델은 비즈니스 프로세스를 모델링하기 위함이다. 도메인 모델은 어떤 물건에 대한 정적인 데이터 모델이 아닌 동사에 관한 모델이다. + +따라서 주문에 대한 시스템과 배치에 대한 시스템을 생각하는 것이 아니라, 주문 행위(*ordering*) 에 대한 시스템과 할당행위(*allocating*)에 대한 시스템을 생각한다. + +이런 식으로 사물을 구별하면 어떤 시스템이 어떤 일을 하는지에 대해 생각하기 쉽다. 주문 행위에 대해 생각해보면, **주문을 넣었을 때 주문이 들어간다는 무조건 되어야 한다**. 다른 모든 일은 언젠가 발생한다는 것만 보장할 수 있다면 **나중에** 발생할 수 있다. + +> 📒 **NOTE** 📒 + +애그리게이트와 커맨드 설계 시 수행했던 책임 분리가 바로 이것이다. +> + +마이크로서비스 또한 **일관성 경계**(*consistency boundaries*)여야 한다. 두 서비스에는 최종 일관성을 받아들일 수 있고, 이는 동기화된 호출에 의존하지 않아도 된다는 뜻이다. 각 서비스는 외부 세게에서 커맨드를 받고 결과를 저장하기 위해 이벤트를 발생시킨다. 이런 이벤트를 수신하는 다른 서비스는 워크플로우의 다음 단계를 트리거링한다. + +이런 식으로 *쉽게 복잡해지기 쉬운 구조*[^2]를 방지하기 위해 시간적으로 결합된(*temporally coupled*) 메시지가 업스트림 시스템으로부터 외부 메시지로 도착하길 바란다. 시스템은 이벤트를 리슨하는 다운스트림 시스템을 위해 `Allocated` 이벤트를 publish한다. + +왜 이런 구조가 더 나은지에 대한 근거는 아래와 같다: + +1. 각 부분이 서로 독립적으로 실패할 수 있다. 잘못된 동작이 발생했을 때 처리하기 더 쉽다. 어떤 시스템이 안되더라도 여전히 처리할 수 있기는 하다 +2. 시스템 사이의 결합 강도를 감소시킬 수 있다. 처리 연산순서를 바꾸거나 새 단계를 추가하더라도 이를 지역적으로 처리할 수 있다 + +# 11.4 Redis의 Pub/Sub Channel을 통합에 사용하기 + +그렇다면 이를 할 수 있는 메시지 버스가 필요하다. 이는 이벤트를 안팎으로 처리할 수 있는 인프라를 의미한다. 흔히들 **메시지 브로커**(*message broker*) 라고 부른다. 메시지 브로커의 역할은 publisher로부터 메시지를 받아서 subscriber에게 전달[^3](*deliver*)하는 것이다. + +made.com(진짜 영국의 가구서비스임)에서는 [Event Store](https://www.eventstore.com/) 라는 서비스를 쓴다. [Kafka](https://kafka.apache.org/)나 [RabbitMQ](https://www.rabbitmq.com/)도 좋은 대안이다. 책에서는 [Redis pub/sub channel](https://redis.io/docs/manual/pubsub/)를 사용할 것이다. + +> 📒 **NOTE** 📒 + +메시징 플랫폼을 선택하는 주요 방안으로는 주로 메시지 순서, 실패 처리, 멱등성(*idempotency*) 등이 있다. 이는 14.8절에서 다시 살펴보자! +> + +그렇다면 새로운 흐름에 대한 시퀀스 다이어그램을 살펴보자. Redis는 전체 프로세스를 시작하는 `BatchQuantityChanged` 를 제공하고, 마지막에는 `Allocated` 이벤트를 Redis에 publish 한다. + +![재할당 흐름의 시퀀스 다이어그램](https://www.cosmicpython.com/book/images/apwp_1106.png) + +# 11.5 엔드투엔드 테스트를 사용하여 모든 기능 시범운영하기 + +어떤 식으로 수행하는지 살펴보자. API를 사용하여 배치를 만들고, 인바운드-아웃바운드 메시지를 테스트할 것이다 + +> 여기서 잠깐! + +나는 현재 DB 커넥션도 Dependency Injector를 통해 구현했는데, Redis도 마찬가지일 것이다. [이런 예시](https://python-dependency-injector.ets-labs.org/examples/fastapi-redis.html)를 프로젝트에 맞게 구현할 것이다. 아래 방안을 작성하고자 한다: + +1. Redis 커넥션에 대해 깔끔하게 처리하는 방안 모색 +2. 그렇게 처리하면서 동시에 pub/sub을 쓸 수 있는지도 모색 +> + +이 로직을 테스트하기 위해선 아래 테스트코드가 필요하다: + +```python +@pytest.mark.asyncio +async def test_change_batch_quantity_leading_to_reallocation(client): + # 두 배치와 할당을 수행하여 한 쪽에 할당하는 주문으로 시작한다. + orderid, sku = random_orderid(), random_sku() + earlier_batch, later_batch = random_batchref("old"), random_batchref("newer") + await post_to_add_batch(client, earlier_batch, sku, qty=10, eta="2023-01-01") + await post_to_add_batch(client, later_batch, sku, qty=10, eta="2023-01-02") + response = await post_to_allocate(client, orderid, sku, 10) + assert response.json()["batchref"] == earlier_batch + + subscription = await redis_client.subscribe_to("line_allocated") # 1) + + await redis_client.publish_message( # 2) + "change_batch_quantity", + {"batchref": earlier_batch, "qty": 20}, + ) + + messages = [] + async with async_timeout.timeout(3): # 3) + message = await subscription.get_message(timeout=1) + if message: + messages.append(message) + print(messages) + + assert len(messages) == 1 + data = json.loads(messages[-1]["data"]) + assert data['order_id'] == orderid + assert data["batchref"] == later_batch +``` + +1. `line_allocated` 라는 채널을 listen 한다. +2. 외부 서비스가 `change_batch_quantity` 라는 채널에 아래 dict 값을 가진 이벤트를 전송함을 의미한다. +3. 책에서는 `tenacity` 를 사용해서 3번 정도를 더 수신하도록 기다린다 + +## 11.5.1 Redis는 메시지 버스를 감싸는 다른 얇은 어댑터 + +Redis pub/sub 리스너, 혹은 이벤트 소비자(*event consumer*)는 외부 서비스로부터 메시지를 받고 변환하여 이를 이벤트로 만든다. + +아래는 Redis메시지 리스너의 간단한 버전이다: + +```python +async def main(): + orm.start_mappers() + pubsub = r.pubsub(ignore_subscribe_messages=True) + await pubsub.subscribe("change_batch_quantity") # 1) + + async for m in pubsub.listen(): + await handle_change_batch_quantity(m) + +async def handle_change_batch_quantity(m): + logging.debug("handling %s", m) + data = json.loads(m["data"]) # 2) + cmd = commands.ChangeBatchQuantity(ref=data["batchref"], qty=data["qty"]) + await messagebus.handle(cmd, uow=unit_of_work.SqlAlchemyUnitOfWork()) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +1. 이 어댑터를 구동하며 `change_batch_quantity` 채널을 subscribe 한다 +2. 엔트리포인트에서는 JSON 역직렬화 후 커맨드로 변환하여 서비스 계층으로 메시지를 전달한다. API가 하는일과 동일하다! + +그렇다면 Redis 이미지 publisher 또한 살펴보자. + +```python +async def publish(channel, event: events.Event): # 1) + logging.debug("publishing: channel=%s, event=%s", channel, event) + await r.publish(channel, json.dumps(asdict(event))) +``` + +1. 여기선 하드코딩한 채널을 사용한다. 이벤트 클래스/이름과 적절한 채널을 매핑하는 정보를 저장할 수도 있다. 이러면 메시지 유형 중 일부에 대해 다른 채널을 사용할 수도 있다 + +## 11.5.2 외부로 나가는 새 이벤트 + +`Allocated` 라는 이벤트를 살펴보자 + +```python +@dataclass +class Allocated(Event): + orderid: str + sku: str + qty: int + batchref: str +``` + +이 이벤트로는 주문 라인 상세정보, 어떤 배치에 주문라인이 할당되었는지 등 할당에 대해 알아야 할 필요가 있는 모든 내용을 저장한다. + +이를 모델의 `allocate()` 메소드에 추가한다. 이를 위한 테스트를 함께 추가하자. + +```python +def test_product_allocate_should_emit_an_event(): + batch = Batch('batch1', 'SMALL-FORK', 10, eta=today) + product = Product(sku='SMALL-FORK', batches=[batch]) + allocation = product.allocate(OrderLine('order1', 'SMALL-FORK', 1)) + + expected_event = events.Allocated( + orderid="order1", + sku="SMALL-FORK", + qty=1, + batchref=batch.reference, + ) + + assert product.messages[-1] == expected_event + assert allocation is "batch1" +``` + +이런 류의 테스트가 있어야겠고, `Product` 애그리게이트에는 이벤트 emit하는 로직이 있어야 할 것이다. + +```python +class Product: + def __init__( + self, + sku: str, + batches: List[Batch], + version_number: int = 0, + ): + ... + def allocate( + self, + line: OrderLine, + ) -> str: + ... + self.version_number += 1 + self.messages.append( # 1) + events.Allocated( + orderid=line.orderid, + sku=line.sku, + qty=line.qty, + batchref=batch.reference, + ) + ) + return batch.reference + ... +``` + +1. 이런 식으로 `Allocated` 이벤트를 담아야한다. + +그 후에는 메시지 버스에도 관련 핸들러를 추가해주고, 이벤트 발행할 때는 레디스 wrapper가 제공하는 헬퍼함수를 쓰자. + +```python +class MessageBus: + EVENT_HANDLERS = { + events.Allocated: [handlers.publish_allocate_event], # 1) + events.OutOfStock: [handlers.send_out_of_stock_notification], + } # type: Dict[Type[events.Event], List[Callable]] + +async def publish_allocate_event( + event: events.Allocated, + uow: unit_of_work.AbstractUnitOfWork, + channel: redis.AsyncRedis, +): + await channel.publish("line_allocated", event) # 2) +``` + +1. 이런 식으로 핸들러를 추가하고 +2. wrapper함수는 이렇게 감싸준다 + +레디스 커넥션 처리는 Dependency Injector로 이렇게 했다 + +```python +class Container(containers.DeclarativeContainer): + __self__ = providers.Self() + + config = providers.Configuration() + config.from_pydantic(Settings()) + + wiring_config = containers.WiringConfiguration( # 1) + packages=[ + "pt2.ch11.src.allocation.entrypoints", + ] + ) + + redis_pool = providers.Resource( # 2) + redis.init_redis_pool, + redis_uri=config.broker.REDIS_URI, + ) + + redis = providers.Factory( # 3) + redis.AsyncRedis, + session=redis_pool, + ) + +``` + +1. 하기 팩토리를 사용할 Wiring 대상인 “패키지”의 경로를 기재했다. 아래 소스를 함께 살펴본다 +2. 리소스를 이렇게 만들어줄 수 있다. 이 리소스는 하기 팩토리에 사용된다 +3. 팩토리를 통해 `AsyncRedis` 라는 클래스의 의존성을 주입한다 + +```python +class AsyncRedis: + def __init__( + self, + session: redis.Redis, + ): + self._session = session # 1) + + async def publish( # 2) + self, + channel, + event: events.Event, + ): + await self._session.publish(channel, json.dumps(asdict(event))) + +from fastapi import Depends # 3) + +@app.post( + "/allocate", + status_code=status.HTTP_201_CREATED, +) +@inject +async def allocate_endpoint( + order_line: OrderLineRequest, + channel: redis.AsyncRedis = Depends(Provide[Container.redis]), # 3) +): + .... + batchref = await messagebus.handle( + ... + channel=channel, + ) + +@inject +async def signup_user( + channel: redis.AsyncRedis = Provide[Container.cache], # 4) +): + return jsonify({"status": await cache.ping()}) +``` + +1. 상기 세션값은 이런식으로 넣는다 +2. Wiring을 수행한 측에서는 `redis.publish(channel, event)` 형식으로 사용하면 된다. 이 때 Wiring을 위해 아래 3, 4와 같은 구문을 사용한다 +3. FastAPI에서는 `from fastapi import Depends` 를 해주고 의존성 주입을 한다 +4. Flask라면 이런 식으로 의존성 주입을 한다 + +## 11.6 내부 이벤트와 외부 이벤트 비교 + +내부 외부 이벤트의 구분이 명확할 필요가 있다. 일부 이벤트는 밖에서 들어오지만, 일부 이벤트는 승격되며 외부에 이벤트를 publish 할 수도 있다. 하지만 모든 이벤트가 외부에 이벤트를 emit하지는 않는다. [이벤트 소싱](https://io.made.com/blog/2018-04-28-eventsourcing-101.html)에 대해서는 저자가 작성한 글을 함께 읽어보자. + +> TIP + +외부로 나가는 이벤트는 검증을 적용하는 것이 중요한 부분에 속한다. Appendix E 도 함께 살펴보자. +> + +## 11.7 마치며 + +이벤트는 외부에서 들어올 수도, 외부로 emit할 수도 있다. `publish` 핸들러는 이벤트를 Redis 메시지 채널의 메시지로 변환한다. 이런즉 이벤트를 사용해 외부 세계와 이야기를 나누는 식의 시간적인 결합을 이용하자. 그렇다면 애플리케이션 통합 시 상당한 유연성을 얻을 수 있다. 하지만 흐름이 명시적이지 않고 디버깅이나 변경이 어려워질 수도 있다. 이 말을 누가했냐고? [마틴 파울러가 했다](https://martinfowler.com/articles/201701-event-driven.html). + +이벤트 기반 마이크로서비스 통합의 트레이드오프를 살펴보자: + +| 장점 | 단점 | +| --- | --- | +| 분산된 큰 진흙 공을 피할 수 있다 | 전체 정보 흐름을 알아보기 어렵다 | +| 서비스가 서로 결합되지 않는다. 개별 서비스 변경 및 새 서비스 추가가 쉽다 | 일관성은 처리할 필요가 있는 새로운 개념이다 +(이걸 해결하기 위해 SAGA 패턴이나 이벤트 소싱, CQRS가 있다고 하는데, 더 공부해보자) | +| | 메시지 신뢰성과 at-least-once(최소 한 번) versus at-most-once(최대 한 번)을 서로 생각해봐야 한다 | + +--- + +[^1]: [Connascence](https://connascence.io/)은 Meilir Page-Jones가 주장한 개념이다. + +[^2]: 원문에선 Distributed Ball of Mud antipattern 이라고 했다. 이런 설계가 안티패턴임을 시사하는 비꼬기 같은데 이걸 어케 번역함ㅋㅋ + +[^3]: 책에선 ‘배달’ 이란 용어를 쓰는데, 난 이게 더 익숙해서 이렇게 풀거다. 아니면 딜리버라고 바로 말하거나, 영단어를 바로 쓰거나… diff --git a/content/books/cosmic-python/2023-05-18---pt02-ch11/media/universe.jpg b/content/books/cosmic-python/2023-05-18---pt02-ch11/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-05-18---pt02-ch11/media/universe.jpg differ diff --git a/content/books/cosmic-python/2023-05-18---pt02-ch12/index.md b/content/books/cosmic-python/2023-05-18---pt02-ch12/index.md new file mode 100644 index 00000000..e798ee52 --- /dev/null +++ b/content/books/cosmic-python/2023-05-18---pt02-ch12/index.md @@ -0,0 +1,393 @@ +--- +title: "파이썬으로 살펴보는 아키텍처 패턴 (12)" +date: "2023-05-18T17:44:06.000Z" +template: "post" +draft: false +slug: "/books/cosmic-python/2023-05-18-pt02-ch12" +category: "books" +tags: + - "ddd" + - "books" + - "backend" + - "python" +description: "파이썬으로 살펴보는 아키텍처 패턴을 읽고 이해한 내용을 작성합니다. 챕터 11, 이벤트 기반 아키텍처: 이벤트를 사용한 마이크로서비스 통합에 대한 내용입니다." +socialImage: { "publicURL": "./media/universe.jpg" } +--- + +이 내용은 "파이썬으로 살펴보는 아키텍처 패턴" 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +12장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/cosmicpython-study/tree/main/pt2/ch12) + +# 12장 Command-Query Responsibility Segregation (CQRS) + +읽기-쓰기 가 다른 작업이라는 것은 누구나 알고있다. 그런 고로 이에 대한 책임을 분리할 필요가 있다. 굳이 이런 짓을 왜 하는지 살펴보자! + +![읽기와 쓰기 분리](https://www.cosmicpython.com/book/images/apwp_1201.png) + +# 12.1 쓰기 위해 존재하는 도메인 모델 + +도메인 규칙을 강화하는 소프트웨어를 만드는 방법에 대해 여태 학습해왔다. 이런 규칙이나 제약은 앱마다 다르고 시스템마다 다르다. + +책에서는 ‘현재 사용가능한 재고보다 더 많은 재고를 할당할 수 없다 (`can_allocate()`)’ 같은 명시적 제약을 만들거나, 각 주문 라인은 한 배치에 해당된다 같은 암시적 제약(`allocate` 메소드의 행위 자체를 통해)을 걸었다. + +이런걸 제대로 하려면 연산의 일관성(**UoW 패턴**)을 보장하며, 각 연산 자체는 객체 단위에서 수행하도록 해야했다(**애그리게이트 패턴**). + +그리고, 작업 덩어리 사이에서 변경된 내용을 통신하기 위해 도메인 이벤트 패턴을 도입하였다. 이를 통해 ‘재고 손상/분실 시, 배치의 사용가능수량을 조절하고 필요하다면 주문을 재할당하시오’ 같은 규칙을 정할 수 있었다(`change_batch_quantity` 로직과, 메시지 버스(`MessageBus`) 상의 `commands.ChangeBatchQuantity` 커맨드 같은 것을 의미함). + +이런 복잡도는 시스템 상태를 변경할 때 규칙적용을 강화하기 위해 존재한다. 즉 데이터를 유연하게 쓰기 위한 도구를 만든 것이다. + +그렇다면 읽기는? + +# 12.2 가구를 구매하지 않는 사용자 + +저자가 개발중인 시스템(책에선 [메이드닷컴](https://www4.next.co.uk/made)이라 나옴)에는 할당 서비스도 있을 것이다. **시간당 100건** 넘는 주문도 처리한다. 그렇지만 재고를 할당해주는 시스템이 존재한다. + +하지만 같은 날에 제품 뷰에 대한 건수는 **초당 100건**에 달할 수 있다. 재고가 있나 보거나, 배송이 얼마나 오래걸리는지 걸리기 위해 누군가 상품 목록 페이지나 상품페이지에서 하염없이 F5만 때리고 있을지도 모른다. + +**도메인**은 똑같다. 재고 배치, 배치의 도착일, 사용 가능한 수량에 대해 관심이 있다. 하지만 접근 패턴은 매우 다르다. 요컨대 고객은 쿼리가 몇 초 지난 상태인지 알 수 없다. 그렇지만 할당 서비스가 일관성이 없는건 고객 주문이 꼬여지는 것을 의미한다. 이런 차이를 십분 활용하여 읽기에 대해선 최종적으론 일관성있게(*eventually consistent*) 유지하여 성능을 향상시킬 수 있다. + +## 12.2.1 읽기 일관성을 *정말로* 달성할 수 있을까? + +일관성과 성능을 맞교환하는 사실은 받아들이기 어렵다. 당연히 갖춰야한다. RDBMS에 저장된 값이 다르다를 상상할 수 있을까? + +그런데 읽기 데이터에 대한 생각을 조금만 바꿔보면 **읽어온 데이터는 그 시점에만 최신**이다. 그리고 **분산 시스템은 완전한 일관성을 갖출 수 없다.** 시스템의 현재 상태를 계속해서 검사해야한다. 관점을 바꾸기 위한 예를 들어보자. + +1. A라는 고객이 ‘가’ 라는 상품이 “재고있음” 이란 것을 보고 잠깐 자리를 비운 새 B라는 고객이 먼저 구매해버렸다고 하자. A라는 고객이 *다시* 요청하면, 재고가 없어서 (1) 주문을 취소하거나 (2) 더 많은 재고를 요청하여 A 고객이 주문한 상품의 배송을 늦춘다. +2. 어찌어찌해서 완전한 일관성을 보장하는 웹앱이 생겼다고 하자. A라는 고객이 제품 구매를 하였으나, 배송중 제품이 박살나버렸다. 이러면 결국 (1) 환불처리를 하거나 (2) 더 많은 재고를 요청하여 A 고객이 주문한 상품의 배송을 늦춘다. + +이렇듯 현실은 소프트웨어 시스템과 일관성이 없으므로, 비즈니스 프로세스는 이런 경우를 처리할 수 있어야 한다. 다시말해 일관성이 없는 데이터는 근본적으로 피할 수 없으므로 읽기 측면에서 성능과 일관성을 어느정도 바꾸어도 좋다. + +## 12.2.2 읽기와 쓰기 비교 + +이런즉 시스템은 ‘읽기’와 ‘쓰기’ 두 시스템으로 분할이 가능하다. + +쓰기쪽에서 채택한 도메인 아키텍처 패턴은 읽기에 큰 도움이 되지 않는다. 그래서 배워야함ㅋㅋ + +| | 읽기 | 쓰기 | +| --- | --- | --- | +| 동작 | 간단한 읽기 | 복잡한 비즈니스 로직 | +| 캐시 가능성 | 높음 | 캐시 불가능 | +| 일관성 | 오래된 값 제공 가능 | 트랜잭션 일관성이 있어야 함 | + +# 12.3 POST/리디렉션/GET과 CQS + +웹 개발을 하는 사람에겐 POST/리디렉션/GET패턴이 익숙할 것이다[^1]. 이 기법에서 웹 엔드포인트는 `POST` 콜을 받고 처리결과를 보여주기 위해 리디렉션으로 응답한다. 책의 예시를 말해보자면 `POST /batches` 를 해서 배치를 만들면 `GET /batches/123` 으로 리디렉션해서 새 배치를 보여준다거나… 하는 것이다. 요는 연산의 쓰기와 읽기 단계를 분리하여 문제를 해결했다는 점이다. + +이 기법은 명령-쿼리 분리(Command-Query Separation, CQS) 의 예시이다. CQS에서는 한가지 간단한 규칙을 따른다. 함수는 상태변경 혹은 질의응답 둘 중 하나만 해야한다. 둘 다 해서는 안된다. 이러면 소프트웨어 추론이 쉬워진다. 전등을 껐다켰다 하지 않아도 전등이 켜져있나? 라는 질문에 답할 수 있어야 한다. + +> 📒 **NOTE** + +API 생성시에도 Location 헤더가 새로운 자원의 URI를 포함하면 201 Created 혹은 202 Accepted를 반환함으로써 같은 설계기법을 사용한다. + +여기서는 사용하는 상태코드의 값이 아니라 논리적으로 읽기-쓰기를 분리했다는 점을 캐치하자. +> + +기존코드의 CQS 위반부분을 먼저 해결하자. 오래 전에 주문을 받아 서비스 계층을 호출하여 재고를 할당하는 `allocate` 이란 엔드포인트를 바꾸자. 기존에는 호출 시 200 OK와 배치 ID를 반환했는데, 이러한 읽기-쓰기가 혼재된 부분을 해소해보자. 우선 테스트코드부터… + +```python +@pytest.mark.asyncio +@pytest.mark.integration +async def test_happy_path_returns_202_and_batch_is_allocated( + async_engine, + client, + clear, +): + orderid = random_orderid() + sku, othersku = random_sku(), random_sku('other') + ... + data = OrderLine(orderid=orderid, sku=sku, qty=3) + + res = await client.post("/allocate", json=asdict(data)) + assert res.status_code == status.HTTP_202_ACCEPTED # 1) + + res = await client.get(f"/allocations/{orderid}") + assert res.json() == [{'sku': sku, 'reference': earlybatch}] # 2) +``` + +1. `202 Accepted` 응답으로 오는지? +2. `GET /allocations/{order_id}` 로 호출했을 때 제대로 오는지? + +본 로직은 이에 대응하여 어떻게 바뀌어지는지 보자 + +```python +@app.post( + "/allocate", + status_code=status.HTTP_202_ACCEPTED, # 1) +) +@inject +async def allocate_endpoint( + ... + +@app.get( + "/allocations/{order_id}" +) +@inject +async def allocations_view_endpoint(order_id: str): # 2) + uow = unit_of_work.SqlAlchemyUnitOfWork(db) + result = views.allocations(order_id, uow) # 3) + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + ) + + return result +``` + +1. 기존 정상리턴 코드는 `202 Accepted` 로 바꿔준다 +2. 신규 API인 `GET /allocations/{order_id}` 에 대해 엔드포인트를 만들어주자 +3. “읽기 전용” 이라는 의미에서 `views.py` 로 파일명을 두자 + +# 12.4 잠깐만 있어보세요 + +repository 객체에서 값 목록을 리턴하는 메소드를 후딱 짜보자. + +```python +from sqlalchemy.sql import text + +async def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): + async with uow: + results = await uow.session.execute( + text( + """ + SELECT ol.sku, b.reference + FROM allocations AS a + JOIN batches as b ON a.batch_id = b.id + JOIN order_lines AS ol ON a.orderline_id = ol.id + WHERE ol.orderid = :orderid + """ + ), + dict(orderid=orderid), + ) + + return results.mappings().all() +``` + +예제에서는 놀랍게도 쌩 쿼리(*raw query*)를 넣어놨다. + +왜 이랬는지 보고, 실무에서는 보다 나은 트릭들을 써서 어떻게 하는지 보자 + +일단 뷰는 이런식으로 `views.py` 에 계속 보관하자 + +> 🍅 tips! + +CQRS를 안하더라도 상태를 변경하는 커맨드와 이벤트 핸들러에서 읽기 전용 뷰를 분리하는게 좋다. +> + +# 12.5 CQRS 뷰 테스트하기 + +뷰를 어떻게 가져가더라도 통합 테스트는 필요하다. + +```python +@pytest.mark.asyncio +async def test_allocations_view(sqlite_session_factory): + uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) + await messagebus.handle(commands.CreateBatch('sku1batch', 'sku1', 50, None), uow) + await messagebus.handle(commands.CreateBatch('sku2batch', 'sku2', 50, today), uow) + await messagebus.handle(commands.Allocate('order1', 'sku1', 20), uow) + await messagebus.handle(commands.Allocate('order1', 'sku2', 20), uow) + + # 제대로 데이터를 얻는지 보기 위해 여러 배치와 주문을 추가 + await messagebus.handle(commands.CreateBatch('sku1batch-l8r', 'sku1', 50, today), uow) + await messagebus.handle(commands.Allocate('otherorder', 'sku1', 30), uow) + await messagebus.handle(commands.Allocate('otherorder', 'sku2', 10), uow) + + assert await views.allocations('order1', uow) == [ + {'reference': 'sku1batch', 'sku': 'sku1'}, + {'reference': 'sku2batch', 'sku': 'sku2'}, + ] +``` + +# 12.6 대안 1: 기존 저장소 사용하기 + +헬퍼 메소드를 `products` 저장소에 추가하면? + +```python +from pt2.ch12.src.allocation.service_layer import unit_of_work +from sqlalchemy.sql import text + +async def allocations( + orderid: str, + uow: unit_of_work.SqlAlchemyUnitOfWork, +): + async with uow: + products = uow.products.for_order(orderid=orderid) # 1) + batches = [b for p in products for b in p.batches] # 2) + return [ + {'sku': b.sku, 'batchref': b.reference} + for b in batches + if orderid in b.orderids # 3) + ] + +``` + +1. 저장소는 `Product` 객체를 반환하며 주어진 주문에서 sku에 해당하는 모든 상품을 찾아야한다. 저장소에 `.for_order()` 라는 헬퍼 메소드를 만든다 +2. 이 시점에서는 상품이 있지만 실제로 원하는 값은 배치에 대한 참조다. 그러므로 모든 배치를 가져온다 +3. 원하는 주문에 대한 배치만을 찾기 위해 **다시** 배치를 걸러낸다. 이 과정은 다시 `Batch` 객체가 자신이 어떤 주문에 할당됐는지 알려준다는 사실에 의존한다 + +이 값은 `Batch` 객체에 `.orderid` 라는 프로퍼티를 구현하여 처리한다 + +이 방식은 기존 추상화를 재사용하는 장점이 있지만, 새 헬퍼 메소드를 저장소, 도메인 모델 클래스 둘 다에 추가하고 파이썬 수준에서 루프를 돌려야한다. DB를 쓰면 걍 쿼리로 하면 될 것인데 말이지. + +# 12.7 읽기 연산에 최적화되지 않은 도메인 모델 + +가만보면 도메인 모델을 만드는데 든 노력은 주로 쓰기연산을 위한 것임을 알 수 있다. 그러니까 읽기를 위해서는 기존 추상화에 덧붙이고 파이썬 레벨에서 루프를 돌렸다. + +그렇지만 이는 CQRS위해 골통을 짜맨 결과다. 도메인 모델은 앞서보았듯 데이터 모델이 아니다. 워크플로우, 상태 변경을 둘러싼 규칙, 메시지 교환 등 비즈니스의 규칙을 캐치하기 위한 요소였다. 이는 시스템이 외부 이벤트와 입력에 대해 어떻게 처리하는지에 대한 내용이다. **이 요소중 대부분은 읽기 전용 연산과는 관계가 없다.** + +> 🍅 tips! + +CQRS가 필요하다고 말하는 것은 도메인 모델 패턴이 필요하다고 하는 것과 연관있다. 단순 CRUD앱은 읽기/쓰기가 밀접하게 연관되어있기 때문에 도메인 모델이나 CQRS가 필요없지만, 도메인이 복잡해지면 도메인 모델과 CQRS 모두가 더 많이 필요해진다. +> + +접근 편의를 위해 도메인 클래스는 상태를 변경하는 메소드를 여럿 제공하지만, 읽기 전용 연산에서는 이런게 모두 필요하지 않다. + +게다가 도메인 모델의 복잡도가 더 커질 수록 모델을 구성하는 방법에 대한 선택의 폭이 넓어진다. 따라서 읽기 연산에 도메인 모델을 사용하는 것이 더 어려워진다. + +# 12.8 대안 2: ORM 쓰기 + +테스트코드가 살짝 바뀐다 + +```python +@pytest.mark.asyncio +async def test_allocations_view(sqlite_session_factory): + uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) + await messagebus.handle(commands.CreateBatch('sku1batch', 'sku1', 50, None), uow) + await messagebus.handle(commands.CreateBatch('sku2batch', 'sku2', 50, today), uow) + await messagebus.handle(commands.Allocate('order1', 'sku1', 20), uow) + await messagebus.handle(commands.Allocate('order1', 'sku2', 20), uow) + + # 제대로 데이터를 얻는지 보기 위해 여러 배치와 주문을 추가 + await messagebus.handle(commands.CreateBatch('sku1batch-l8r', 'sku1', 50, today), uow) + await messagebus.handle(commands.Allocate('otherorder', 'sku1', 30), uow) + await messagebus.handle(commands.Allocate('otherorder', 'sku2', 10), uow) + + view_result = await views.allocations('order1', uow) # 1) + expected = [ + {'batchref': 'sku1batch', 'sku': 'sku1'}, + {'batchref': 'sku2batch', 'sku': 'sku2'}, + ] # 2) + + for data in expected: + assert data in view_result # 3) +``` + +1. 쿼리하면 다른 배치, 주문 내용도 다 나와버린다 +2. 내가 생각하는게 있는지 전체 배치와 주문 중에서 살펴봐야한다 +3. 따로 빼서 보면 된다 + +그렇다면 ORM을 써서 쿼리해버리면? + +```python +async def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): + async with uow: + batches = ( + await uow.session.execute( + select(model.Batch) + .join(model.OrderLine, model.OrderLine.orderid == orderid) + ) + ).scalars() # 1) + + return [ + {'batchref': b.reference, 'sku': b.sku} + for b in batches + ] +``` + +1. 비동기 쿼리는 SQLAlchemy 2.0 스타일의 ORM 쿼리를 짜야한다! + 1. [참고용 공식문서](https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#synopsis-orm) + 2. 이를 알게 된 [스택오버플로우 게시글](https://stackoverflow.com/questions/68360687/sqlalchemy-asyncio-orm-how-to-query-the-database) → [공식문서의 출처](https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#synopsis-orm) + +SQLAlchemy 문서를 보고 쿼리를 짜는건 크게 문제가 없으나, 성능상의 이슈가 있다. + +# 12.9 SELECT N+1 과 다른 고려사항 + +SELECT N+1[^2]은 성능이슈가 생긴다. 객체 리스트를 가져올 때 ORM은 보통 필요한 모든 객체의 ID를 가져오는 쿼리를 먼저 수행한다. 그 후 각 객체의 어트리뷰트를 얻기 위한 개별 쿼리를 한다. 특히 객체에 외래키 관계가 많으면 더 자주 발생한다. + +> 📒 **NOTE** + +SQLAlchemy에서는 이를 피하기 위해 eager loading을 상황에 맞게 수행할 수 있다. +1.4 뿐 아니라 2.0에서도 문서를 꼭 읽어보자. + +[- SQLAlchemy 1.4에서의 관련 설명](https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#joined-eager-loading) +[- SQLAlchemy 2.0에서의 관련 설명](https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#joined-eager-loading) +> + +SELECT N+1 문제 외에도, 상태를 영속화하는 방법과 현재 상태 로드 방법을 분리해야하는 이유가 있다. 정규화된 테이블은 쓰기 연산이 데이터 오염을 발생시키는 것을 막는 방법이다. 그렇지만 읽어올 때 JOIN연산을 하면 읽기 연산이 느려질 수 있다. + +이를 위해 정규화되지 않는 뷰를 추가하거나, 읽기 전용 복사본을 만들거나, 캐시 계층을 추가할 수 있다. + +# 12.10 코드를 확 틀어보자[^3] + +12.4절에서 살펴본 코드를 정규화하지 않은 별도 뷰 모델에 값을 넣어버리고 그걸 바로 갖고오게 하자. + +```python +async def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): + async with uow: + results = await uow.session.execute( + text( # 1) + """ + SELECT sku, batchref + FROM allocations_view + WHERE orderid = :orderid + """ + ), + dict(orderid=orderid), + ) + return results.mappings().all() + +allocations_view = Table( # 2) + 'allocations_view', + metadata, + Column('orderid', String(255)), + Column('sku', String(255)), + Column('batchref', String(255)), +) +``` + +1. 전용 뷰에서 값을 가져오도록 하자 +2. 외래키 없이 그냥 바로 갖고올 수 있다. + +읽기에 최적화된, 데이터의 정규화되지 않은 복사본을 만드는 방법도 나쁘지 않다. 인덱스를 사용해 처리할 수 있는 일의 한계가 생긴다면 이런 복사본을 만드는 방법이 있다. (저자 왈)사실 잘 튜닝한 인덱스라도 조인을 위해 CPU 사용량이 많다. 그런걸 놓고 보면 `SELECT * FROM table WHERE key =: value` 가 가장 빠르긴 하다. + +이런 접근방식은 규모확장에도 장점을 가진다. RDBMS에 데이터를 쓸 때는 변경할 컬럼에 락을 걸어 일관성에 문제가 생기지 않도록 한다. 여러 클라이언트가 동시에 값을 변경하면 추적하기 힘든 race condition이 생긴다. 그렇지만 데이터를 읽을 때(*reading*)는 동시연산에 대한 제한이 없으므로 읽기 전용 저장소는 수평규모 확장이 가능하다. + +> 🍅 tips! + +읽기용 복사본이 일관성이 없을 수도 있어서, 사용할 수 있는 복사본 수에는 한계가 있다. 복잡한 데이터 저장소가 있는 시스템의 규모를 확장하는데 어려움이 있다면 더 간단한 읽기 모델을 만들 수 없는지 살펴보아야 한다. +> + +그렇긴 하지만 이 읽기 모델을 최신상태로 유지하는 것도 어렵다. 데이터베이스 뷰(meterialized 하거나 그렇지 않거나)나 트리거가 일반적 해법이다. 하지만 이런 해법은 DB에 따라 한계가 정해진다. 아래에서는 DB 기능 대신 아키텍처 재활용을 살펴보도록 한다. + +## 12.10.1 이벤트 핸들러를 사용한 읽기 모델 테이블 업데이트 + +`Allocated` 이벤트에 대해 두번째 핸들러를 넣는다. + +`Deallocated` 이벤트도 마찬가지다. + +읽기 모델의 시퀀스 다이어그램을 살펴보자 + +![읽기 모델의 시퀀스 다이어그램](https://www.cosmicpython.com/book/images/apwp_1202.png) + +POST/쓰기 연산의 두 트랜잭션을 볼 수 있다. + +1. 쓰기 모델 업데이트 +2. 읽기 모델 업데이트 +3. + +GET/읽기 연산은 이 읽기 모델을 사용한다. + +# 12.11 읽기 모델 구현을 변경하기 쉽다 + +저장소 엔진을 바꿔버리면 어떻게 되는지 살펴보자. + +1. Redis 읽기 모델을 업데이트 +2. 관련 헬퍼메소드 작업 +3. Redis에 맞게 view를 변경 + +--- + +[^1]: [이 내용](https://en.wikipedia.org/wiki/Post/Redirect/Get)을 의미한다. 예를 들어서 유저가 POST로 보낸 값을 새로고침함하여 POST 요청을 **또 보내는** 문제에 대한 대처법으로도 사용된다. + +[^2]: 쿼리 1번으로 N건을 가져왔으나, 관련 컬럼을 얻기 위해 쿼리를 N번 더 하는(!) 문제를 의미한다. 필히 알아둬야 할 내용이다. ([N+1 쿼리문제 관련 링크](https://zetawiki.com/wiki/N%2B1_%EC%BF%BC%EB%A6%AC_%EB%AC%B8%EC%A0%9C))([교재상의 참고링크](https://secure.phabricator.com/book/phabcontrib/article/n_plus_one/)) + +[^3]: *Time to Completely Jump the Shark*이 원제인데, jumping the shark은 잘 안풀려서 무리수를 뒀다 이런 뜻이라… ([링크를 보고 알아서 판단하시와요](https://www.urbandictionary.com/define.php?term=jump-the-shark)) diff --git a/content/books/cosmic-python/2023-05-18---pt02-ch12/media/universe.jpg b/content/books/cosmic-python/2023-05-18---pt02-ch12/media/universe.jpg new file mode 100644 index 00000000..f044921f Binary files /dev/null and b/content/books/cosmic-python/2023-05-18---pt02-ch12/media/universe.jpg differ diff --git a/content/books/unit-testing/2023-06-28---pt01-ch01/index.md b/content/books/unit-testing/2023-06-28---pt01-ch01/index.md new file mode 100644 index 00000000..afdde6ac --- /dev/null +++ b/content/books/unit-testing/2023-06-28---pt01-ch01/index.md @@ -0,0 +1,385 @@ +--- +title: "단위 테스트 (1)" +date: "2023-06-28T02:31:14.000Z" +template: "post" +draft: false +slug: "/books/unit-testing/2023-06-28-pt01-ch01" +category: "books" +tags: + - "book_review" + - "code_quality" +description: "단위 테스트 를 읽고 이해한 내용을 작성합니다. 챕터 1, 단위 테스트의 목표에 대한 내용입니다." +socialImage: { "publicURL": "./media/testcode.png" } +--- + +이 내용은 "단위 테스트" 를 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +1장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/unit-testing-101/tree/main/pt1/ch01) + +Chapter 1. 단위 테스트의 목표 + +> 커맨드로 테스트를 직접 실행하기 위해선 현재 디렉토리로 이동한다. +> `cd pt1/ch01` + +--- + +# 들어가며 + +단위 테스트(_Unit testing_)를 배우는 것은 테스트 프레임워크, 목 라이브러리(_Mockery library_) 등을 쓰는 것 이상의 개념이다. 테스트도 시간을 투자하는 것이니 이득을 봐야겠죠? 그렇다면 드는 시간을 최소화하고 이득을 많이 보는 방향을 생각해야함. + +그걸 이루려고 노력하는 오픈소스를 보는 것이 크게 도움된다. 어떤게 있는가 찾아보자. [여기서부터](https://github.com/tiangolo/fastapi/tree/master/tests) 하나씩 찬찬히 보는 것도 좋을 것이다. 가장 좋은건 필요에 따라 하나씩 보는거고... + +이 책에서는 어떤 단위테스트 기술이 좋고, 비용편익을 살펴본다. 안티패턴을 피하는방법도 살펴본다. + +# 1.1 단위 테스트 현황 + +엔터프라이즈 앱의 프로덕션 코드와 테스트 코드 비율은 많으면 1:1, 1:3, 많으면 1:10까지도 간다고 한다. + +좋은 테스트코드를 고려하는 것은 어떤 단위 테스트를 수행하는 것으로 최대 이득을 끌어내는지를 살펴보는 것이다. + +이 책에서는 "좋은 테스트"를 짜는 것을 종합적으로 논의한다. 짜니 못한 테스트코드는 분명 지양해야 하기 때문이다. 그와 동시에 노력 대비 최대의 이익을 끌어내는 테스트를 살펴볼 것이다. + +# 1.2 단위 테스트의 목표 + +코드 베이스에 대해 테스트를 짜면 더 나은 설계로 이어진다. 하지만 이는 테스트 코드의 사이드이펙트 중 하나고, 주 목표는 소프트웨어가 **지속가능한 성장**을 가능하게 하는 것이다. + +> 🍅 tips +> +> 단위 테스트와 코드 설계의 관계 +> +> - 코드를 단위 테스트하는 것은 충분히 좋은 방법이고 강결합(_tight coupling_) 된 코드에서 저품질이 나타나는 것은 잡을 수 있다! +> - 하지만 쉽게 단위테스트할 수 있는 코드가 "좋은 품질"의 코드를 의미하지는 않는다. + +테스트 코드를 추가하면 할 수록 소프트웨어 엔트로피[^1] 증가의 속도를 완화시킨다. 코드베이스의 변경은 엔트로피를 증가시킨다. 엔트로피가 겉잡을 수 없이 상승한다면 코드베이스마저 믿을 수 없게 된다. + +테스트는 이런 경향을 뒤집을 수 있다. 회귀(_regression_)에 대한 보험이라 할 수 있다. 다만 이런 이점을 얻기 위해서는 지속적으로, 확장할 수 있는 테스트를 작성해야한다. + +> 회귀(_regression_)? +> +> - 특정 사건(코드수정 등) 후 코드가 의도대로 작동하지 않는 경우를 의미한다 +> - 소프트웨어 버그라고 생각하면 된다 + +## 1.2.1 좋은 테스트와 좋지 않은 테스트를 가르는 요인 + +모든 테스트 코드를 짤 필요는 없다. 일부 테스트는 아주 중요하고 소프트웨어 품질에 기여를 한다. 그 밖의 테스트코드는 그렇지 않다. 잘못된 경고가 발생하고, 회귀 오류를 알아내는 데 도움되지 않는다. 유지보수가 어렵고 느리다. 테스트를 위한 테스트를 하게 된다. + +즉, 테스트의 가치와 유지 비용 요소를 생각해야 한다. 비용 요소는 다양한 활동에 필요한 시간에 따라 결정된다. + +- 기반 코드 테스트 시 테스트도 함께 리팩토링하라 +- 각 코드 변경 시 테스트를 실행하라 +- 테스트가 잘못된 경고를 발생시키면 처리하라 +- 기반 코드가 어떻게 도는지 이해하려 할 때는 테스트를 읽는데 시간을 들이라 + +잘못된 테스트코드는 오히려 독이 된다. 항상 가치있는 테스트를 고민하자. **좋은 테스트**에 집중하자! (4장에서 이어짐) + +# 1.3 테스트 스위트 품질 측정을 위한 커버리지 지표 + +테스트 스위트 품질 측정엔 크게 두가지 커버리지 지표가 있다. + +- 코드 커버리지(code coverage) +- 분기 커버리지(branch coverage) + +> 커버리지 지표? +> 테스트 스위트가 소스코드를 얼마나 실행하는지에 대한 백분율 + +커버리지 지표는 테스트가 충분한지에 대한 피드백을 제공하지만, 이 것이 100%에 가깝다고 해서 양질의 테스트 스위트를 보장하는 것은 아니다. + +## 1.3.1 코드 커버리지에 대한 이해 + +코드 커버리지는 하나 이상의 테스트로 실행된 코드 라인 수와 제품 코드 베이스의 전체 라인 수의 비율이다. 아래 수식으로 산출된다. + +$코드 커버리지(테스트 커버리지) = \dfrac{제품 코드 라인 수}{전체 라인 수} * 100$ + +요컨대 이런 코드가 있다고 치자: + +```python +def is_string_long(input_val: str): + if len(input_val) > 5: + return True + return False + + +def test_is_string_long(): + assert is_string_long("abc") is False +``` + +테스트는 아래 명령으로 구동한다: + +> `pytest test\test_01.py --cov ` + +```powershell +test\test_01.py . [100%] + +---------- coverage: platform win32, python 3.10.11-final-0 ---------- +Name Stmts Miss Cover +------------------------------------- +test\test_01.py 6 1 83% +------------------------------------- +TOTAL 6 1 83% + + +==================================================== 1 passed in 0.05s ==================================================== +``` + +이러면 커버리지가 `83%` 나온다. 코드를 이렇게 바꾸면? 커버리지를 `100%`로 달성시킬 수 있다. + +```python +def is_string_long(input_val: str): + return len(input_val) > 5 + + +def test_is_string_long(): + assert is_string_long("abc") is False + +``` + +테스트는 아래 명령으로 구동한다: + +> `pytest test\test_02.py --cov ` + +```powershell +test\test_02.py . [100%] + +---------- coverage: platform win32, python 3.10.11-final-0 ---------- +Name Stmts Miss Cover +------------------------------------- +test\test_02.py 4 0 100% +------------------------------------- +TOTAL 4 0 100% + + +==================================================== 1 passed in 0.08s ==================================================== +``` + +근데 이게 테스트 스위트를 개선한 건 아니다(저자는 이를 커버리지 수치로 '장난친다' 라고 표현했다). 코드가 작으면 커버리지 지표가 좋아지기 때문이다. 그리고 이렇다고 해도 테스트 스위트의 가치나, 코드베이스의 유지보수성이 변경되진 않는다. + +## 1.3.2 분기 커버리지 지표에 대한 이해 + +다른 지표는 분기 커버리지(branch coverage)이다. 이 지표는 `if` 문이나 `switch` 문같은 제어구조에 중점을 둔다. 테스트 스위트 내 하나 이상의 테스트가 통과하는 제어구조의 수를 나타낸다. + +$분기 커버리지 = \dfrac{통과 분기}{전체 분기 수}$ + +분기 커버리지 지표를 계산하려면 코드베이스에서 모든 가능한 분기를 합산하고 그 중 테스트가 얼마나 실행되는지를 확인한다. 분기 개수만 다루며, 해당 분기를 구현하는데 얼마나 코드가 필요한지 고려하진 않는다. + +```python +def is_string_long(input_val: str): + """ + if/else 구문이 기재되어있어야 아래의 Branch를 카운트한다. + """ + if len(input_val) > 5: + return True + return False + + +def test_is_string_long(): + assert is_string_long("abc") is False + +``` + +테스트는 해당 명령으로 구동한다: `pytest test\test_03_1.py --cov --cov-branch` + +```powershell +(unit-testing-py3.10) PS C:\unit_testing\pt1\ch01> pytest test\test_03_1.py --cov --cov-branch +=================================================== test session starts =================================================== +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 +rootdir: C:\unit_testing\pt1\ch01 +plugins: cov-4.1.0 +collected 1 item + +test\test_03_1.py . [100%] + +---------- coverage: platform win32, python 3.10.11-final-0 ---------- +Name Stmts Miss Branch BrPart Cover +--------------------------------------------------- +test\test_03.py 6 1 2 1 75% +--------------------------------------------------- +TOTAL 6 1 2 1 75% + + +==================================================== 1 passed in 0.07s ==================================================== +``` + +## 1.3.3 커버리지 지표에 관한 문제점 + +분기 커버리지로 코드 커버리지보다 나은 결과를 얻을 수 있지만, 테스트 스위트의 품질을 결정할 때 어떠한 커버리지 지표든 정답일 수는 없다. 이유는 다음과 같다: + +1. 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장하는 것은 아니다. +2. 외부 라이브러리의 코드 경로를 고려할 수 있는 커버리지 지표는 없다. + +### 가능한 모든 결과를 보장할 수 없음 + +단위 테스트는 적절한 검증이 있어야 한다. 테스트 대상 시스템이 낸 결과가 정확히 예상 가능한 결과인지 확인해야 한다. 게다가 결과가 여러개일 수도 있다. 즉 **모든 측정지표를 검증**해야 한다. + +그런데 테스트 케이스 하나만(`False` 조건만) 테스트 하니, 일부 실행만 보장한다. 이런 테스트는 검증을 안한다고 봐도 무방하다. 그러므로 쓸모가 없다. + +```python +# +# 일부 코드 생략 +# +def test_is_string_long(): + assert is_string_long("abc") is False + assert is_string_long("abcdef") is True +``` + +테스트는 해당 명령으로 구동한다: `pytest test\test_03_2.py --cov --cov-branch` + +```powershell +==================================================== 1 passed in 0.07s ==================================================== +(unit-testing-py3.10) PS C:\unit_testing\pt1\ch01> pytest test\test_03_2.py --cov --cov-branch +=================================================== test session starts =================================================== +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 +rootdir: C:\unit_testing\pt1\ch01 +plugins: cov-4.1.0 +collected 1 item + +test\test_03_2.py . [100%] + +---------- coverage: platform win32, python 3.10.11-final-0 ---------- +Name Stmts Miss Branch BrPart Cover +----------------------------------------------------- +test\test_03_2.py 7 0 2 0 100% +----------------------------------------------------- +TOTAL 7 0 2 0 100% + + +==================================================== 1 passed in 0.06s ==================================================== +``` + +이런 식의 접근으로 커버리지 수치를 높이는 것은 크게 의미없는 짓이다. + +### 외부 라이브러리의 경로를 고려할 수 없음 + +테스트 대상 시스템이 메소드 호출 시, 라이브러리의 모든 경로를 쫓아갈 수 없다. + +```python +import pytest + + +def parse(input_val: int): + return int(input_val) + + +def test_parse(): + assert parse("5") == 5 + + with pytest.raises(ValueError): + parse("bbb") + + with pytest.raises(ValueError): + parse("0b11010010") +``` + +테스트는 해당 명령으로 구동한다: `pytest test\test_04.py --cov` + +```powershell +========================================================== test session starts =========================================================== +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 +rootdir: C:\unit_testing\pt1\ch01 +plugins: cov-4.1.0 +collected 1 item + +test\test_04.py . [100%] + +---------- coverage: platform win32, python 3.10.11-final-0 ---------- +Name Stmts Miss Cover +------------------------------------- +test\test_04.py 9 0 100% +------------------------------------- +TOTAL 9 0 100% + + +=========================================================== 1 passed in 0.04s ============================================================ +``` + +성공이야 했다지만, 과연 `int()` Built-in 메소드의 *모든* 경우(_edge case_)를 테스트 한 것일까? 그렇지 않다. 이는 "모든" 결과를 검증하지 못했다[^2]. (E.g., `int()`의 `n`진법으로 변환 후 의도한 값이 나오는 기능 검증) + +지금 받아본 이 지표로는 단위 테스트가 좋은지 나쁜지 판단하기 어렵다. 다시말해, 커버리지 지표로 테스트가 철저한지, 테스트가 충분한지는 판단하기 어렵다. + +## 1.3.4 특정 커버리지 숫자를 목표로 하기 + +테스트 커버리지는 지표로 보아야 하지, 목표로 바라보면 안 된다. + +저자는 병원의 환자를 예시로 비유했다. 환자의 체온을 건강의 지표로 보기 때문에, 이를 낮추기 위해 에어컨을 빵빵하게 트는게 과연 '효율적' 인지로 역설한다. 이런 식의 접근은 의미없기 때문이다. + +중요한 시스템의 핵심부분을 잘 검증하는 테스트 코드가 중요하지, 커버리지 수치를 가지고 판단하는 것은 의미없다. 즉, 좋은 지표이자 나쁜 지표다. + +(하지만 나는 이를 적절한 베이스캠프라고 생각한다. 품질 테스트 스위트로 가는 첫 걸음을 떼기위한 "만족할 만한" 수치라고 본다.) + +# 1.4 무엇이 성공적인 테스트 스위트를 만드는가? + +하나씩 따로 평가하는 것이 낫지만, 그렇다고 해서 모든 테스트를 평가할 필요는 없다. 그리고 테스트 스위트가 얼마나 좋은지 자동으로 확인할 수는 없고, 리뷰를 수행해야 한다. + +어떻게 테스트 스위트를 성공할 수 있는지 살펴보자. 성공적인 테스트 스위트는 아래 특성을 가진다: (4장에서 이어짐) + +- 개발 주기에 통합되어있다 +- 코드베이스에서 가장 중요한 부분 **"만"** 을 대상으로 한다 +- 최소한의 유지비로 최대한의 가치를 끌어낸다 + +## 1.4.1 개발 주기에 통합되어있음 + +자동화된 테스트를 하는 방법은 끊임없이 하는 것 뿐이다. 모든 테스트는 개발 주기에 통합되어야 한다. + +## 1.4.2 코드베이스에서 가장 중요한 부분만을 대상으로 함 + +결국 테스트 코드를 짜는 것도 비용이므로, 최소 유지비로 최대 가치를 달성하도록 하는 것이 중요하다. 시스템의 가장 중요한 부분에 단위 테스트를 철저히 하고, 다른 부분은 간략하게, 간접적으로 검증하는 것이 좋다. + +일반적으로 애플리케이션에서는 도메인 모델이 가장 중요하다. 여기에 속한 비즈니스 로직을 1순위로 검증하는 것이 옳다. 모든 테스트의 가치는 다르기 때문이다. + +다른 부분은 세 가지 범주로 나눌 수 있다 + +- 인프라 코드 +- DB, 서드파티 시스템 등과 같은 외부서비스 및 종속성 +- 모든 것을 하나로 묶는 코드 + +이 중 일부는 단위 테스트를 철저히 해야할 수 있다. 예를들어 인프라 코드에 주요 로직이 존재할 수 있다. 이 경우는 테스트를 많이하는 것이 좋다. 일반적으로는 도메인 모델에 집중하는 것이 좋다. + +통합 테스트와 같은 일부 테스트는 도메인 모델 너머의 시스템 전반을 테스트할 수 있다. 그렇지만 도메인 모델을 중점으로 생각해야 한다. + +이 지침을 따르려면 도메인 모델을 코드베이스 중 중요한 부분/중요하지 않은 부분 으로 나누어야 한다. 도메인 모델을 다른 애플리케이션 문제와 분리해야 단위 테스트에 대한 노력을 도메인 모델에 집중할 수 있다. + +## 1.4.3 최소 유지비로 최대 가치를 끌어냄 + +가치가 유지비를 상회하는 테스트만을 테스트 스위트에 유지해야 한다. 이는 아래와 같다: + +- 가치있는 테스트(가치가 낮은 테스트 포함) 식별하기 +- 가치있는 테스트 작성하기 + +이를 위해서는 가치 높은 테스트를 식별하는 기준틀(_frame of reference_)이 있어야 하고, 그것을 위해서는 코드 설계기술이 있어야 한다. + +저자는 좋은 곡을 구별하는 것과 작곡을 하는 정도의 차이로 빗대었다. 음악 듣는 것보다 작곡은 훨씬 어렵다. 단위테스트도 마찬가지다. 맨땅에 테스트 없이 기반 코드를 설계해야 하기 때문이다. 그런 의미에서 이 책은 코드 설계를 같이 배운다고 할 수 있다. + +# 1.5 이 책을 통해 배우는 것들 + +- 테스트 코드를 설명하기 위한 기준틀 설명 → 리팩토링 대상 및 제거코드 파악 완료 +- 기존 테스트 기술과 실천법 및 better case에 대해 살펴봄 +- 제품코드와 관련 스위트를 리팩토링하는 방법 +- 단위 테스트의 다양한 스타일 적용법 +- 통합 테스트로 시스템 전체 동작 검증 +- 단위 테스트의 안티패턴 식별법, 예방법 + +# Summary + +- 코드는 가면 갈 수록 나빠진다. 코드베이스가 바뀌면 소프트웨어 엔트로피가 올라간다. 테스트로 이런 경향을 뒤집을 수 있다. +- 단위 테스트 작성은 중요하다. 좋은 단위 테스트를 짜는 것은 더 중요하다. +- 단위 테스트는 소프트웨어 프로젝트를 지속적으로 성장시키는 방법이다. 좋은 단위 테스트는 버그를 막을 수 있다. 요구사항을 만족하는 코드이므로 이를 바탕으로 얼마든지 코드 리팩토링 및 신기능 추가가 가능하다. +- 중요한 테스트를 우선하여 유지하라. +- 단위 테스트 코드는 두 가지를 시사한다 + - 단위 테스트를 할 수 없는 코드는 품질이 좋지 않다 + - 단위 테스트를 할 수 있다고 해서 품질을 보장하지는 않는다 +- 커버리지 지표 또한 두 가지를 시사한다 + - 커버리지가 낮은 것은 문제의 징후 + - 커버리지가 높다고 해서 테스트 스위트의 품질이 좋은 것을 보장하지 않음 +- 분기 커버리지로 테스트 스위트의 완전성에 대한 인사이트를 얻을 수 있지만, 테스트 스위트가 충분하다고 할 수는 없다 + - 검증문이 있는지 신경쓰지 않는다 + - 라이브러리의 모든 경우를 검증하는 것은 아니다 +- 커버리지 수치를 목표로 잡아선 안 된다 +- 성공적인 테스트 스위트는 다음 특성을 나타낸다 + - 개발 주기에 통합되어있다 + - 코드베이스에서 가장 중요한 부분 **"만"** 을 대상으로 한다 + - 최소한의 유지비로 최대한의 가치를 끌어낸다 +- 단위 테스트의 목표를 달성하기 위한 유일한 방법은 아래와 같다 + - 좋은 테스트, 좋지 않은 테스트를 구별한다 + - 테스트를 리팩토링한다 + +[^1]: 열역학 제 2법칙의 그 엔트로피에서 따왔다. 소프트웨어 엔트로피는 소프트웨어 시스템 내의 무질서도를 의미한다고 보면 될 것이다 +[^2]: https://docs.python.org/3/library/functions.html#int diff --git a/content/books/unit-testing/2023-06-28---pt01-ch01/media/testcode.png b/content/books/unit-testing/2023-06-28---pt01-ch01/media/testcode.png new file mode 100644 index 00000000..7807b5bc Binary files /dev/null and b/content/books/unit-testing/2023-06-28---pt01-ch01/media/testcode.png differ diff --git a/content/books/unit-testing/2023-06-30---pt01-ch02/index.md b/content/books/unit-testing/2023-06-30---pt01-ch02/index.md new file mode 100644 index 00000000..426af512 --- /dev/null +++ b/content/books/unit-testing/2023-06-30---pt01-ch02/index.md @@ -0,0 +1,516 @@ +--- +title: "단위 테스트 (2)" +date: "2023-06-30T20:36:32.000Z" +template: "post" +draft: false +slug: "/books/unit-testing/2023-06-30-pt01-ch02" +category: "books" +tags: + - "book_review" + - "code_quality" +description: "단위 테스트 를 읽고 이해한 내용을 작성합니다. 챕터 2, 에 대한 내용입니다." +socialImage: { "publicURL": "./media/testcode.png" } +--- + +이 내용은 "단위 테스트" 를 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +2장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/unit-testing-101/tree/main/pt1/ch02) + +Chapter 2. 단위 테스트란 무엇인가 + +> 커맨드로 테스트를 직접 실행하기 위해선 현재 디렉토리로 이동한다. +> `cd pt1/ch02` + +앞으로 꾸준히 나올 단어에 대해: + +* 테스트 대상 시스템(SUT, System under Test. 이하 SUT): 코드베이스, 즉 테스트 대상을 의미 + +--- + +# 들어가며 + +단위 테스트를 바라보는 관점은 생각 보다 중요하다. 접근 방법에 따라 고전파(_classical school_)과 런던파(_london school_)로 나뉜다(이걸 얘기하고자 하는 건 아니다. 이렇게 단도직입적으로 풀 문제도 아니다. 좀 더 살펴보면서 차이점을 비교해보자!). + +> 🍅 tips +> +> **단위 테스트의 고전파와 런던파** +> +> 고전파 +> - 단위 테스트와 테스트 주도 개발에 원론적으로 접근하는 방식으로 인해 고전(classic)이라고 부른다 +> +> 런던파? +> - 코드 격리에 대한 부분을 mocking으로 해결하고자 한다. 이런 기조가 런던의 프로그래밍 커뮤니티에서 시작된 탓에 런던파라고 부른다. + +# 2.1 '단위 테스트'에 대한 정의 + +단위 테스트의 중요한 세 가지 속성은 아래와 같다: + +- 작은 코드 조각(혹은 단위)을 검증한다 +- 빠르게 수행한다 +- 격리된 방식으로 처리하는 자동화된 테스트이다 + +앞서 살펴본 두가지는 논란의 여지가 없다. 작은 코드조각을 검증한다는 것은 말할 것도 없다. 빠르게 구동한다는 것은, 테스트 스위트의 실행시간이 충분하면 테스트는 충분히 빠르다로 이해할 수 있기 때문이다. + +'격리'를 어떻게 해석하는지에 따라 두 분파별로 관점이 달라진다( [2.3](#23-고전파와-런던파의-비교) 에서 설명) + +## 2.1.1 격리 문제에 대한 런던파의 접근 + +- 테스트 대상 시스템을 협력자(_collaborator_)로부터 분리하는 것을 의미한다 +- 하나의 클래스가 다른 클래스, 여러 클래스에 의존한다면 그 모든 의존성을 테스트 대역(_Test double_, 이하 테스트 더블)로 대체할 수 있어야 한다. +- 동작을 외부 영항과 분리해서 테스트 대상의 클래스에 집중한다. + +장점? + +- 테스트가 실패하면, 어느 부분에 문제가 있는지 파악하기 쉽다. +- 객체 그래프(_object graph_, 같은 문제를 해결하는 클래스의 통신망)를 분할할 수 있다 + - 클래스의 의존성, 심지어는 순환의존성 등을 빠르게 파악하고 나눌 수 있다 +- 의존성을 가진 코드베이스를 테스트하려면 실제 제품에 해당하는 의존성 외에 테스트 목적의 의존성들로 구성해서 대체한다 + - 직접 참조하는 의존성을 테스트 목적의 의존성으로 대체 + - 해당 의존성들이 테스트 목적의 의존성을 참조 + +### 고전파의 접근방식? + +고전파의 접근방식으로 테스트를 짜면 아래와 같다. 여기서는 `Store`, `Customer` 에 대한 내용은 생략했다. 상세한 내용은 `test/` 디렉토리 내의 파일을 참고하길 바란다. + +```python +# +# test\test_01_classical_way.py 의 일부분 +# +def test_purchase_succeeds_when_enough_inventory(): + # Arrange + store = Store(Product("Shampoo", 10)) + customer = Customer() + + # Act + success = customer.purchase(store, Product("Shampoo", 5)) + + # Assert + assert success is True + assert 5 == store.item.count + + +def test_purchase_fails_when_not_enough_money(): + # Arrange + store = Store(Product("Shampoo", 10)) + customer = Customer() + + # Act + success = customer.purchase(store, Product("Shampoo", 15)) + + # Assert + assert success is False + assert 10 == store.item.count +``` + +테스트는 아래 명령으로 구동한다: + +> `pytest test\test_01_classical_way.py -v` + +```powershell +=============================================== test session starts =============================================== +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 -- C:\REDACTED\python.exe +cachedir: .pytest_cache +rootdir: C:\unit_testing\pt1\ch02 +plugins: cov-4.1.0 +collected 2 items + +test/test_01_classical_way.py::test_purchase_succeeds_when_enough_inventory PASSED [ 50%] +test/test_01_classical_way.py::test_purchase_fails_when_not_enough_money PASSED [100%] + +================================================ 2 passed in 0.04s ================================================ +``` + +Arrange-Act-Assert 접근 방식은 5장에서 다시 살펴볼 예정이다. 쉽게 말해 아래 과정을 포함한다고 보면 된다. + +- 어떤 테스트를 할지 준비(_Arrange_)한다. SUT와 하나의 협력자를 준비한다. + - 여기서는 `Customer`(SUT), `Store`(협력자) 일 것이다. + - 협력자가 필요한 이유는 아래와 같다 + - SUT를 사용하려면 `Store` 인스턴스를 아규먼트로 쓰기 때문 + - 검증단계에서 `Customer.purchase()` 의 호출결과로 상점 제품 수량이 감소할 가능성이 있기 때문 +- 테스트를 수행한다(_Act_) +- 적절한 결과를 가정한다(_Assert_) + +> 🍅 tips +> +> 테스트 대상 메소드(MUT, Method under test)? +> +> 테스트 대상 메소드(MUT)는 테스트에서 호출한 SUT의 메소드를 의미한다. +> MUT는 흔히 메소드를, SUT는 클래스 전체를 가리킨다. + +이어서 고전파의 스타일대로 짠 코드를 설명한다. + +- 테스트는 협력자(`Store` 클래스)를 대체하지 않는다. 운영용 인스턴스를 사용한다. + - `Customer`, `Store` 둘 다 검증한다. + - 하지만, `Customer`가 정상작동 해도 `Store` 안에 버그가 있으면 테스트는 실패한다 → 테스트가 서로 격리되어있지 않다 + +### 런던파의 접근방식? + +런던파의 접근방식을 따라가보자. 동일한 테스트에서 `Store` 인스턴스를 테스트 더블(구체적으로는 목으로)로 교체한다. (상세한 내용은 5장으로) + +> 목(Mock) +> +> SUT와 협력자 간의 상호작용을 검사할 수 있는 특별한 테스트 더블이다. + +목은 테스트 더블의 부분집합이다. 테스트 더블에는 많은 접근방법이 있다. 다시말해 아래와 같다 + +- 테스트 더블은 실행과 관련없이 모든 종류의 가짜 의존성을 설명하는 포괄적인 용어다 +- 목은 그러한 의존성의 한 종류다 + +런던파의 접근방식으로 테스트를 짜면 아래와 같다: + +```python +# +# test\test_02_london_school_way.py 의 일부분 +# +def test_purchase_succeeds_when_enough_inventory(mocker): + # Arrange + mock_store = mocker.MagicMock(spec=Store) + mock_product = mocker.MagicMock(spec=Product) + mock_store.has_enough_inventory.return_value = True + customer = Customer() + + # Act + success = customer.purchase(mock_store, mock_product) + + # Assert + assert success is True + mock_store.sell.assert_called_once_with(mock_product) + + +def test_purchase_fails_when_not_enough_money(mocker): + # Arrange + mock_store = mocker.MagicMock(spec=Store) + mock_product = mocker.MagicMock(spec=Product) + mock_store.has_enough_inventory.return_value = False + customer = Customer() + + # Act + success = customer.purchase(mock_store, mock_product) + + # Assert + assert success is False + mock_store.sell.assert_not_called() + +``` + +테스트는 아래 명령으로 구동한다: + +> `pytest test\test_01_london_school_way.py -v` + +```powershell +======================================================= test session starts ======================================================== +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 -- C:\REDACTED\python.exe +cachedir: .pytest_cache +rootdir: C:\unit_testing\pt1\ch02 +plugins: cov-4.1.0, mock-3.11.1 +collected 2 items + +test/test_01_london_school_way.py::test_purchase_succeeds_when_enough_inventory PASSED [ 50%] +test/test_01_london_school_way.py::test_purchase_fails_when_not_enough_money PASSED [100%] + +======================================================== 2 passed in 0.02s ========================================================= +``` + +어떤 식으로 다른지 살펴보자: + +- 구현이 일부 변경되었다. 직접 상태를 수정하는 구현방식이 아니라 몇가지 더 추가되었다! + - `has_enough_inventory` 메소드 호출에 어떻게 응답할지 목에 직접 정의한다 + - 이 경우, `test_purchase_succeeds_when_enough_inventory` 테스트에서는 `Store`의 실제 상태와 관련없이 `True`를 리턴하도록 가정한다 + - (추가설명은 8장에) 협력자에서 격리된 테스트 대상 시스템에는 인터페이스가 필요하다 +- `Customer` 객체가 호출하였는지 확인하기 위해, `Store` 내의 특정 메소드가 호출되었는지(`assert_called_once_with`) 확인한다 + - `test_purchase_succeeds_when_enough_inventory` 테스트에서는 **한 번만 호출했는지** 살펴본다 + - `test_purchase_fails_when_not_enough_money` 테스트에서는 **한 번도 호출되지 않았음** 을 살펴본다 + +## 2.1.2 격리 문제에 대한 고전파의 접근 + +런던 스타일은 테스트 더블(여기서는 목)으로 테스트 대상 조각을 분리해서 격리 요구사항에 다가간다. 이 관점은 각 분파의 코드 조각(단위)에 대한 견해를 보여주기도 한다. + +단위 테스트의 속성을 다시 살펴보자: + +- 작은 코드 조각(혹은 단위)을 검증한다 +- 빠르게 수행한다 +- 격리된 방식으로 처리하는 자동화된 테스트이다 + +그렇다면, + +- 코드가 얼마나 *작아야* 되는가? + - 각각의 모든 클래스를 격리해야 한다면 SUT 코드는 단일 클래스 이거나 해당 클래스 내의 메소드여야 한다(격리 문제에 접근하는 방식 때문에 이보다 더 클 수는 없음) +- 최대한 '한 번에 한 클래스만' 지침을 따라야 한다. + +격리 특성을 해석하는 또 하나의 방법을 고전파의 방식으로 살펴보자: + +- 테스트는 서로 격리시켜서 실행해야 하는 것은 아니다. 그 대신 단위 테스트는 서로 격리해서 실행해야 한다. +- 그렇다면 테스트 실행순서가 어떻든 간에(_parallel, sequentially, etc._) 적합한 방식으로 실행할 수 있으며, 서로의 결과에 영향을 미치지 않는다. +- 여러 클래스가 모두 메모리에 올라가있고 공유 상태에 다다르지 않는 한, 여러 클래스를 한번에 테스트 해도 된다. 이를 통해 테스트가 서로 소통하고 실행 컨텍스트에 영향을 줄 수 있다. +- 공유 상태: DB, 파일 시스템, 프로세스 외부 의존성 등을 의미 +- 공유 상태의 예시? + - E.g., 테스트 준비 단계에서 DB 내에 고객 생성을 할 수도 있고, 테스트 실행 전에 다른 테스트의 준비단계에서 고객을 삭제하는 경우 + - 테스트를 병렬실행하면 다른 테스트에 의해 간섭받아 실패하는 케이스가 나오기도 한다 + +> 공유 의존성, 비공개 의존성, 프로세스 외부 의존성? +> +> 공유 의존성 +> - 테스트 간에 서로 공유되고 서로의 결과에 영향을 미칠 수 있는 수단을 제공하는 의존성 +> - E.g., 정적 가변 필드(_static mutable field_), 데이터베이스 +> +> 비공개 의존성 +> - 공유하지 않는 의존성 +> +> 프로세스 외부 의존성 +> - 애플리케이션 실행 프로세스 외부에서 실행되는 의존성 +> - 아직 메모리에 없는 데이터에 대한 프록시 +> - 프로세스 외부 의존성은 "대부분" 공유 의존성에 해당된다. +> - E.g., 데이터베이스(프로세스 외부 의존성 이자 공유 의존성) +> - 도커 컨테이너로 DB를 실행시키면 테스트가 더 이상 동일 인스턴스로 작동하지 않는다. + +격리 문제에 대한 견해는 테스트 더블 사용(목 포함) 그 이상의 견해가 뒤따른다. + +![단위 테스트를 서로 격리하는 것은 테스트 대상 클래스에서만 격리하는 것을 의미한다. 비공개 의존성은 그대로 두어도 된다](./media/001.png) + +- 공유 의존성은 단위 테스트 간에 공유한다(_테스트 대상 클래스가 아님_) + - 싱글턴 의존성은 각 테스트에서 새 인스턴스를 만들 수만 있으면 됨 + - 제품 코드에는 단 하나의 인스턴스만 있지만, 테스트에서는 달리 쓰인다 + - E.g., 설정 클래스를 사용한다고 했을 때? + - 실제 코드에서는 단 하나의 인스턴스를 사용한다 + - 테스트 코드 상에서는 그럴 필요는 없다 + - 그러나 새 파일 시스템이나 DB를 만들 수는 없으며, 테스트 간 공유되거나 테스트 더블로 대체되어야 한다 + +> 공유 의존성, 휘발성 의존성에 대해 +> +> 휘발성 의존성은 다음 속성 중 하나를 나타내는 의존성이다. +> 1. +> - 개발자 머신에 기본 설치된 환경 외에 런타임 환경의 설정, 구성을 요구함 +> - 추가설정이 필요하며, 시스템에 기본적으로 없음 +> - E.g., DB 혹은 API 서비스 +> 2. +> - 비결정적 동작(_non-deterministic behavior_)을 포함함 (때에 따라 다른 결과가 나오기 때문) +> - E.g., 난수 생성기, "현재" 날짜 및 시간을 리턴하는 클래스 +> +> 휘발성 의존성은 공유 의존성과 겹치는 부분이 있다. 아래 사항들의 의존성을 예시로 살펴보자: +> +>| 테스트 대상 |공유 의존성?|휘발성 의존성?|비고| +>|--------------|------------|--------------|----| +>| 데이터베이스 | O | O | . | +>| 파일 시스템 | O | X |모든 개발자 머신에 설치되고, 대부분 결정적으로 작동함| +>| 난수 생성기 | X | O |각 테스트에 별도의 인스턴스를 제공할 수 있음| + +공유 의존성을 대체하는 다른 이유: 테스트 실행속도 향상 +- 공유 의존성은 실행 프로세스 밖에 있음, 테스트 수행 시간이 길어짐 +- 테스트 코드의 속성 2번째―빨리 실행되어야 함을 충족해야함 +- 이런 것들이 필요한 테스트는 통합 테스트 영역에서 수행되어야 함 + +# 2.2 단위 테스트의 런던파와 고전파 + +두 분파는 격리 특성의 견해 차이로 인해 나누어졌다. + +- 런던파: 테스트 대상 시스템에서 협력자를 격리하는 방향을 채택 +- 고전파: 단위 테스트 끼리 격리하는 방향을 채택 + +종합하자면, 아래 세 가지 주요 주제에 대해 의견 차이가 있다. + +- 격리 요구사항 +- 테스트 대상 코드조각(단위)의 구성요소 +- 의존성 처리 + +표로 정리해보자: + +| \ |격리 주체|단위의 크기|테스트 더블 사용대상| +|-----|---|---|---| +| 런던파 |단위|단일 클래스|불변 의존성 외의 모든 의존성| +| 고전파 |단위 테스트|단일 클래스 또는 클래스 세트|공유 의존성| + +## 2.2.1 고전파와 런던파가 의존성을 다루는 방법 + +테스트 더블은 어디서든 쓰지만, 런던파는 테스트에서 일부 의존성을 그대로 쓸 수 있도록 하고있다. 불변객체(*immutable objects*)는 굳이 바꾸지 않아도 된다. 런던 스타일의 코드로 살펴보자. + +```python +# +# test\test_02_london_school_way.py 의 일부분 +# +def test_purchase_succeeds_when_enough_inventory(mocker): + # Arrange + mock_store = mocker.MagicMock(spec=Store) + mock_product = mocker.MagicMock(spec=Product("Shampoo", 5)) + mock_store.has_enough_inventory.return_value = True + customer = Customer() + + # Act + success = customer.purchase(mock_store, mock_product) + + # Assert + assert success is True + mock_store.sell.assert_called_once_with(mock_product) +``` + +`Customer`의 두 가지 의존성 중, `Store`만 시간에 따라 변할 수 있는 내부 상태를 포함하고 있고, `Product` 객체는 이뮤터블(여기서는 `namedtuple`)이다. 그래서 여기서는 Store 인스턴스만 바꿔주고, `Product`값은 VO(_Value objects_)로써 그대로 사용한 것이다. + +아래 그림은 의존성에 대한 종류를 나타내고, 동시에 단위테스트의 두 분파가 의존성을 어떤식으로 처리하는지 보여준다. +- 비공개 의존성은 변경 가능하거나 불변이다. 불변은 VO라고 부른다 +- E.g., + - DB: 공유의존성. 내부 상태(테스트 더블로 대체되지 않은 값)는 모든 테스트에서 공유함 + - `Store` 인스턴스: 변경 가능한 비공개 의존성 + - `Product` 인스턴스: 불변인 비공개 의존성(VO의 예시) +- 모든 공유 의존성은 변경 가능하지만, 변경 가능한 의존성을 공유하려면 여러 테스트에서 재사용 되어야 함 + +![의존성 계층의 도식](./media/002.png) + +- 고전파에서는 공유 의존성을 테스트 더블로 대체한다 +- 런던파에서는 변경 가능한 비공개 의존성도 테스트 더블로 교체할 수 있다 + +> 협력자(Collaborator)와 의존성 +> +> 협력자 +> - 공유하거나 변경 가능한 의존성이다 +> - E.g., +> - 데이터베이스 접근 권한을 제공하는 클래스 +> - `Store` 객체 + +모든 프로세스 외부 의존성이 공유 의존성의 범주에 속하지 않는다. +- 공유 의존성은 거의 항상 프로세스 외부에 있다. 그 반대는 그렇지 않다. +- 프로세스 외부 의존성을 공유하려면 단위 테스트가 서로 통신할 수 있는 수단이 있어야한다. +- 의존성 내부를 수정하면 통신이 이루어진다. +- 프로세스 외부의 불변 의존성은 그런 수단이 없다. +- 테스트는 내부의 어떤 것도 수정할 수없다. 서로의 실행 컨텍스트에 영향을 줄 수 없다. + +![공유 의존성과 프로세스 외부 의존성 간의 관계](./media/003.png) + +상기 사항에 대한 예시를 살펴보자. + +- E.g., 조직에서 판매하는 모든 제품에 대한 카탈로그를 반환하는 API + - API는 카탈로그를 변경하는 기능을 노출하지 않는 한 공유 의존성이 아님 + - 테스트가 반환하는 데이터에 영향을 미칠 수 없기 때문 (의존성은 휘발성이고 애플리케이션 경계를 벗어나긴 하지만) + - 이런 의존성을 테스트의 범주에 넣을 필요는 없음 + - 대부분 테스트 속도를 올리기 위해서는 테스트 더블로 교체해야함 + - 하지만 프로세스 외부 의존성이 충분히 빠르고 연결이 안정적이면 테스트에서 그대로 써도 됨 + +# 2.3 고전파와 런던파의 비교 + +고전파와 런던파의 차이는 단위테스트에서의 격리 문제를 처리하는 방안으로 갈린다. 이는 테스트 단위 처리와 의존성 취급에 대한 방법이라 말할 수 있다. + +저자는 목을 사용하는 테스트는 고전적 테스트보다 불안정한 경향이 있다 라는 표현을 사용했다. (5장에서 다시 볼 것) 런던파의 장점을 살펴보자. + +- 입자성(_granularity_)이 좋다. 테스트가 세밀해서 한 번에 한 클래스만 확인한다 +- 서로 연결된 클래스의 그래프가 커져도 테스트하기 쉽다. 모든 협력자를 테스트 더블로 처리하기 때문. +- 테스트가 실패하면 어떤 기능이 실패했는지 확실히 알 수 있다. + - 의존성이 거의 대부분 분리되어있기 때문 + +저자는 상기 세 장점이 가지는 맹점을 비판한다. + +## 2.3.1 한 번에 한 클래스만 테스트 + +런던파는 클래스를 단위로 간주하고, 이로 인해 클래스를 테스트에서 검증할 최소한의 단위로 취급한다. + +다만 이러한 관점은 코드의 입자성에 집중하게 되어, 문제 영역에 의미가 있는 것을 검증하는 것을 멀리할 수도 있다. + +> 🍅 tips +> +> 테스트는 코드의 단위를 검증하는 것이 아니라, 동작의 단위(문제영역)를 검증해야 한다. +> +> 비즈니스 담당자가 유용하다고 인식할 수 있는 것을 검증해야 한다. +> +> 동작의 단위는 아주 작은 메소드가 될 수도 있고, 한 클래스에만 있을 수도 있고, 심지어는 여러 클래스에 걸쳐있을 수도 있다. + +따라서 식별할 수 있는 동작의 주제를 살피고, 내부적인 세부 구현과 어떻게 구별짓는지 살펴본다(5장에서). + +## 2.3.2 상호 연결된 클래스의 큰 그래프를 단위 테스트 + +런던파는 실제 협력자 대신 목을 사용하고, 의존성 그래프가 복잡할 때 테스트 더블을 써서 전체 복잡한 객체 그래프에 대해 잘 대체하고 테스트할 수 있다고 한다. + +다만 저자는 클래스 그래프가 커진 것을 설계 문제의 결과로 판단한다. 클래스 그래프를 작게 가지도록 하는 것을 주문한다. + +따라서 코드 조각을 테스트 더블 없이 면밀하게 설계할 수 있도록 기본적인 코드 설계를 풀어보는 과정을 살펴본다(2부에서). + +## 2.3.3 버그 위치 정확히 찾아내기 + +런던파 철학을 따르는 테스트 위의 시스템에 버그가 생기면, SUT에 버그가 포함된 테스트만 실패한다. 고전적인 방식을 통해, 테스트할 때는 원하지 않았던 파급효과까지 테스트 할 수 있음을 시사한다. + +다만 저자는 다른 관점으로 본다. 테스트를 정기적으로 수행하여 마지막으로 바꾼 코드 변화가 어떤 테스트 실패를 초래하였는지 볼 수 있다고 한다. + +그리고 테스트 스위트 전체에 걸쳐 계단식으로 실패하는 것 또한 주목할 만한 지표로 판단한다. 고장낸 코드 조각(코드베이스의 본 로직)이 큰 가치가 있는 코드임을 알 수 있다. 이는 시스템이 그 코드에 의존한다는 것을 의미한다. + +## 2.3.4 고전파와 런던파 사이의 다른 차이점 + +고전파와 런던파 사이에는 아래 차이점이 더 존재한다 + +- TDD를 통한 시스템 설계방식 + - 추가기능에 대한 명세를 테스트로 작성(필히 실패함) + - 테스트가 통과하는 코드를 작성 + - 리팩토링 +- 과도한 명세(_over-specification_) 문제 + +> 🍅 tips +> +> TDD(_Test-driven Development_)란? +> +> 테스트에 의존하여 프로젝트 개발을 추진하는 소프트웨어 개발 프로세스. 아래 세 단계로 구성되며, 각 테스트케이스마다 이를 반복한다: +> +> 1. 추가할 기능과 작동에 대한 테스트 코드 작성 → 테스트 구동 시 빨간막대 생성 +> 2. 테스트를 통과하는 코드베이스를 작성 → 테스트 구동 시 초록막대로 변함 +> 3. 코드 리팩토링 → 테스트 구동 시 초록막대를 유지하도록 리팩토링 + +- TDD 관점차이 - 런던파 + - 하향식 TDD + - Mock을 사용해 예상 결과를 달성하고자 시스템이 통신해야하는 협력자를 지정 + - 모든 클래스를 구현할 때 까지 클래스 그래프를 다짐 +- TDD 관점차이 - 고전파 + - 상향식 TDD + - 도메인 모델을 시작으로 엔드유저가 소프트웨어를 사용할 수 있을 때 까지 계층을 그 위에 더 둔다 +- over-specification 차이? → 테스트가 SUT의 구현 세부사항에 결합되는 것을 의미 + - 런던파는 고전파보다 테스트가 구현에 더 자주 결합된다 + - 목 사용 남용이 주로 지적된다 + +# 2.4 두 분파의 통합 테스트 + +런던파는 실제 협력자 객체를 사용하는 모든 테스트를 통합 테스트로 간주한다. + +저자는 본 책에서 단위 테스트와 통합 테스트의 고전적 정의를 사용한다. 고전파의 관점에서 단위 테스트는 아래와 같다: + +- 단일 동작 범위를 검증한다 +- 빠르게 테스트한다 +- 다른 테스트와 별도로 처리한다 + +통합 테스트는 이런 기준 중 하나를 충족하지 않는 테스트다 + +E.g., DB 접근 테스트 → 다른 테스트와 분리하여 실행할 수 없다 +- 특정 테스트에서 DB 상태 변경을 시키면? 병렬 수행 시 동일 DB에 의존하는 모든 테스트 결과가 달라질 것이다 +- 이런 테스트는 수나적으로 실행해서 공유 의존성과 함께 작동하려고 기다릴 수 있다 + +E.g., 프로세스 외부 의존성이 있는 테스트 → 테스트가 느려진다 + +- DB 호출은 수백 밀리초가 걸릴 수 있다 +- 테스트 스위트가 커지면 커질 수록 체감된다! + +E.g., 둘 이상의 동작 범위를 검증할 때의 테스트 → 통합 테스트로 간주된다 + +E.g., 다른 모듈 이상을 둘 이상 검증할 때의 테스트 → 통합 테스트로 간주된다 + +종합하면, 통합 테스트는 소프트웨어 품질향상에 기여하는 주요 요소 중 하나다. (3부에서 다시 살펴볼 예정) + + +## 2.4.1 통합 테스트의 일부인 엔드투엔드 테스트 + +- 엔드 투 엔드 테스트(end-to-end test, aka. e2e test)는 통합 테스트의 일부이다 +- 코드가 프로세스 외부 종속성과 함께 어떻게 작동하는지 검증한다 +- 의존성을 더 많이 포함되는 특징이 있다(거의 대다수, 전부의 의존성) +- 가장 비용이 많이 들기 때문에, 모든 단위 테스트와 통합 테스트 통과 후 수행하는 것이 좋다 + +# Summary + +- 단위 테스트의 정의는 아래와 같다 + - 단일 동작 단위를 검증한다 + - 빠르게 수행한다 + - 다른 테스트와 별도로 처리한다 +- 단위 테스트는 격리 문제를 주로 논의한다. 이 논쟁으로 인해 고전파와 런던파의 두 분파로 나뉘어졌다. 상호 두 분파는 무엇이 단위 테스트인지, 테스트 대상 시스템의 의존성 처리 방식을 어떻게 정의하느냐에 대한 관점이 다르다. + - 런던파는 테스트 대상 단위를 서로 분리해야 한다고 한다. 테스트 대상 단위는 코드의 단위, 보통 단일 클래스다. 불변 의존성을 제외한 모든 의존성을 테스트 더블로 대체하길 바란다. + - 고전파는 단위 테스트를 서로 분리해야 한다고 한다. 테스트 대상 단위는 동작 단위다. 공유 의존성만 테스트 더블로 대체하길 바란다. 공유 의존성은 테스트가 서로 실행 흐름에 영향을 미치는 수단을 제공하는 의존성을 의미한다. +- 런던파는 아래 이점을 제공한다 + - 더 나은 입자성 + - 상호 연결된 큰 그래프에 대한 테스트 용이성 + - 테스트 실패 후 버그를 쉽게 찾을 수 있는 편의성 +- (저자 왈) 다만 런던파는 맹점을 가진다 + - 테스트 대상 클래스에 대한 관점: 테스트 코드 단위가 아닌 테스트 동작을 검증해야 + - 코드 조각을 테스트 할 수 없는 맹점: 코드 설계를 다시 검증해야... 테스트 더블을 통한 테스트는 이를 숨길 뿐 + - 테스트 실패 결과의 맹점: 지속적으로 테스트 하면 해결됨. 그렇다면 마지막에 수정한 코드가 버그의 원인임 +- 런던파 테스트의 가장 큰 맹점 → 과잉명세(_over-specification_). SUT 세부 구현에 결합된 테스트 +- 통합 테스트는 상기 단위 테스트의 요소 셋 중 하나 이상을 만족하지 못하는 테스트를 의미 +- 엔드 투 엔드 테스트는 애플리케이션과 함께 작동하는 프로세스 외부 의존성의 거의 대부분(혹은 전부)에 접근함 diff --git a/content/books/unit-testing/2023-06-30---pt01-ch02/media/001.png b/content/books/unit-testing/2023-06-30---pt01-ch02/media/001.png new file mode 100644 index 00000000..39825801 Binary files /dev/null and b/content/books/unit-testing/2023-06-30---pt01-ch02/media/001.png differ diff --git a/content/books/unit-testing/2023-06-30---pt01-ch02/media/002.png b/content/books/unit-testing/2023-06-30---pt01-ch02/media/002.png new file mode 100644 index 00000000..6721fe6e Binary files /dev/null and b/content/books/unit-testing/2023-06-30---pt01-ch02/media/002.png differ diff --git a/content/books/unit-testing/2023-06-30---pt01-ch02/media/003.png b/content/books/unit-testing/2023-06-30---pt01-ch02/media/003.png new file mode 100644 index 00000000..0ee333e0 Binary files /dev/null and b/content/books/unit-testing/2023-06-30---pt01-ch02/media/003.png differ diff --git a/content/books/unit-testing/2023-06-30---pt01-ch02/media/testcode.png b/content/books/unit-testing/2023-06-30---pt01-ch02/media/testcode.png new file mode 100644 index 00000000..7807b5bc Binary files /dev/null and b/content/books/unit-testing/2023-06-30---pt01-ch02/media/testcode.png differ diff --git a/content/books/unit-testing/2023-07-01---pt01-ch03/index.md b/content/books/unit-testing/2023-07-01---pt01-ch03/index.md new file mode 100644 index 00000000..d2fcd954 --- /dev/null +++ b/content/books/unit-testing/2023-07-01---pt01-ch03/index.md @@ -0,0 +1,689 @@ +--- +title: "단위 테스트 (3)" +date: "2023-07-01T00:44:02.000Z" +template: "post" +draft: false +slug: "/books/unit-testing/2023-07-01-pt01-ch03" +category: "books" +tags: + - "book_review" + - "code_quality" +description: "단위 테스트 를 읽고 이해한 내용을 작성합니다. 챕터 3, 단위 테스트 구조에 대한 내용입니다." +socialImage: { "publicURL": "./media/testcode.png" } +--- + +이 내용은 "단위 테스트" 를 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +3장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/unit-testing-101/tree/main/pt1/ch03) + +Chapter 3. 단위 테스트 구조 + +> 커맨드로 테스트를 직접 실행하기 위해선 현재 디렉토리로 이동한다. +> `cd pt1/ch03` + +--- + +# 들어가며 + +단위 테스트의 구조 살펴보기 +- Arrange +- Act +- Assert + +단위테스트 명명법 살펴보기 +- 관행 타파 방안 +- 더 나은 방안 제시 + +단위테스트 근소화에 도움되는 라이브러리의 특징 살펴보기 + +# 3.1 단위 테스트 구성하는 방법 + +## 3.1.1 Arrange-Act-Assert 패턴 사용 + +준비(_Arrange_), 실행(_Act_), 검증(_Assert_)의 세 가지 패턴을 사용하여 작성하는 것을 의미한다. 다음 클래스를 테스트한다고 생각해보자. + +```python +class Calculator: + def sum(first: double, second: double) -> double: + return first + second +``` + +그렇다면 테스트코드는 아래와 같이 이루어질 것이다: + +```python +def test_sum_of_two_numbers(): + # Arrange + first = 10 + second = 20 + calc = Calculator() + + # Act + result = calc.sum(first, second) + + # Assert + assert 30 == result +``` + +해당 패턴은 균일한 구조를 가지므로 일관성이 있다. 이것이 큰 장점이다. + +- Arrange: SUT 과 해당 의존성을 원하는 상태로 만든다 +- Act: SUT에서 메소드를 호출하고 준비된 의존성을 전달한다. 출력값이 있으면 이를 캡처한다 +- Assert: 결과를 검증한다. SUT와 협력자의 최종 상태, SUT가 협력자에 호출한 메소드 등으로 표시될 수 있다. + +> Given-When-Then 패턴? +> +> - Given: Arrange section과 유사 +> - When: Act section과 유사 +> - Then: Assert section과 유사 +> +> 두 패턴에 차이는 없으나, 비기술자들과 공유하는 테스트에 좀 더 적합하다. + +처음 테스트를 작성할 때, 이런 식으로 윤곽을 잡으면 좋다. +- 특정 동작이 무엇을 해야하는지에 대한 목표를 생각하며 시작한다 → assert 문으로 시작하는 사고를 해보자 +- 뭘 해야할지 설계되어있으면 Arrange 문부터 구상해보자 + +## 3.1.2 여러 개의 AAA sections 피하기 + +여러 동작단위를 테스트하지 말고 하나씩 하라. 하나 이상을 하면 통합 테스트다(2장 참고). 여러 동작단위가 있는 코드는 여러 단일 코드가 존재하는 코드로 리팩토링하라 + +실행이 하나면 아래 이점이 생긴다 +- 테스트를 단위 테스트의 범주에 있게 한다 +- 쉽고 빠르고 이해하기 쉽다 + +통합 테스트에선 여러 section이 있을 수 있지만 이를 빠르게 하려면 단일 테스트를 여러 개 모으는 방법이 있다. + +## 3.1.3 테스트 내 `if` 문 피하기 + +`if`문이 있는 테스트도 안티패턴이다. + +- if문은 테스트가 한 번에 너무 많은 것을 검증한다는 표시다 → 여러 테스트로 나누어야 한다 +- 이런 테스트는 차라리 여러 테스트로 나누는 것이 좋다(통합 테스트 포함) + +## 3.1.4 각 section은 얼마나 커야하나? + +### Arrange가 크면? + +별도의 private 메소드, 팩토리 클래스로 도출하는 편이 좋다. 이를 위해 Object mother 패턴과 Test Data Builder패턴을 고려할 수 있다. + +### Act section이 한 줄 이상인 경우를 경계하기 + +Act section은 보통 한 줄이다. 이 이상이면 SUT의 public API를 의심해야 한다. + +- good case: 깔끔하게 떨어짐 + +```python +def test_purchase_succeeds_when_enough_inventory(): + # Arrange + store = Store(Product("Shampoo", 10)) + customer = Customer() + + # Act + success = customer.purchase(store, Product("Shampoo", 5)) + + # Assert + assert success is True + assert 5 == store.item.count +``` + +- bad case + - 캡슐화를 깨면서까지 테스트하면 안 된다. + - 단일 작업을 수행하는 데 여러 메소드를 호출해야 한다는 점 + + → 불변 위반(_invariant violation_), 캡슐화가 깨짐. + + 캡슐화를 깨지 않도록 코드를 작성할 것! + +```python +def test_purchase_succeeds_when_enough_inventory(): + # Arrange + store = Store(Product("Shampoo", 10)) + customer = Customer() + + # Act + is_available = store.has_enough_inventory(Product("Shampoo", 5)) + success = customer.purchase(store, Product("Shampoo", 5)) + + # Assert + assert is_available is True + assert success is True + assert 5 == store.item.count +``` + +## 3.1.5 Assert section에는 얼마나 많은 `assert`가 있어야 하나? + +단위 테스트의 단위는 "동작"의 단위다. 동작은 여러 결과를 낼 수 있으므로, 그 결과를 하나의 테스트에서 검증하는 것은 문제없다. + +다만 너무 많은 assert 구문은 문제가 된다. 만약 이렇다면, 추상화가 제대로 안 되어있는지 생각해볼 수 있다. + +이를 해결하기 위해 동등 멤버(*equality member*)를 정의하는 것이 좋다. (파이썬이라면 `__eq__()` 매직 메소드를 객체별로 구현하는 뜻) + +## 3.1.6 종료 단계는? + +보통 그런 teardown은 별도 메소드로 표현하는 것이 좋다. + +다만 단위 테스트에서는 teardown을 보통 필요로 하지 않는다. + +## 3.1.7 테스트 대상 시스템 구별하기 + +SUT는 테스트에서 중요하다. 애플리케이션에서 호출하려는 지점에 대한 엔트리포인트이기 때문이다. "동작"은 여러 클래스에서 걸칠 수 있지만, 엔트리포인트는 단 하나일 수 밖에 없다. + +즉, SUT를 의존성과 구분하는 것이 좋다. SUT가 많으면 테스트 대상을 찾는데 시간을 너무 많이 들일 필요가 없다. 정 헷갈리면 Arrange 할 때, 이름을 그냥 `sut` 로 붙여버리면 된다. + +```python +def test_sum_of_two_numbers(): + # Arrange + first = 10 + second = 20 + sut = Calculator() # 이런 식으로! + + # Act + result = sut.sum(first, second) + + # Assert + assert 30 == result +``` +## 3.1.8 Arrange-Act-Assert 주석 떼어내기 + +테스트의 어떤 부분이 Arrange-Act-Assert 인지 구별을 쉽게 하는 것은 중요하다. + +이해하기 쉬운 테스트라면 굳이 주석을 달지 말고 개행으로 처리하라. +통합 테스트 등의 복잡한 테스트라면 Arrange-Act-Assert 주석을 달아주는 편이 좋다. + +```python +# +# 이해하기 쉬운 퀘스트면 개행으로만 구별! +# +def test_sum_of_two_numbers(): + first = 10 + second = 20 + sut = Calculator() + + result = sut.sum(first, second) + + assert 30 == result +``` + +# 3.2 xUnit 테스트 프레임워크 살펴보기 + +- `setUp()`, `tearDown()` 구성이 있고 테스트코드를 꾸리는게 xUnit 테스트 형식이라고 한다. 파이썬의 빌트인 테스팅 프레임워크 `unittest` 가 해당 구조를 따른다. +- `pytest` 는 fixture 기반으로 테스트의 `setUp`, `tearDown`을 구성할 수 있다. + - 예를 들면 이런 식으로... + ```python + @pytest.fixture + def fixture123(): + # yield 상단 구문은 setUp으로 구성가능 + yield "test data" + # yield 하단 구문은 tearDown으로 구성가능 + + def test_fixture(fixture123): + assert "test data" == fixture123 + ``` + - fixture의 반복을 피하기 위해 `conftest.py` 파일을 구조화할 수도 있고, fixture의 scope 또한 지정해줄 수 있다. + +# 3.3 테스트 간 테스트 픽스처 사용 + +테스트코드도 코드 재사용을 수행할 수 있다. 그를 위한 도구 중 하나가 픽스처이다. + +이 책에서 픽스처는 **테스트 실행 대상 객체**를 의미한다. 테스트 전에 원하는 고정적 상태를 유지하는 역할을 한다. + +픽스처를 재사용하는 첫 번째 방법은 아래와 같다. (아래 방안으로는 사용하지 말자) + +```python +class TestCustomer: + store = Store(Product("Shampoo", 10)) + sut = Customer() + + def test_purchase_succeeds_when_enough_inventory(self): + success = self.sut.purchase(self.store, Product("Shampoo", 5)) + + assert success is True + assert 5 == self.store.item.count + + def test_purchase_fails_when_not_enough_money(self): + success = self.sut.purchase(self.store, Product("Shampoo", 15)) + + assert success is False + assert 10 == self.store.item.count +``` + +테스트는 아래 명령으로 구동한다: + +> `pytest test\test_03_high_coupling.py` + +```shell +======================================================= test session starts ======================================================== +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 +rootdir: C:\unit_testing\pt1\ch03 +plugins: cov-4.1.0, mock-3.11.1 +collected 2 items + +test\test_03_high_coupling.py .F [100%] + +============================================================= FAILURES ============================================================= +______________________________________ TestCustomer.test_purchase_fails_when_not_enough_money ______________________________________ + +self = + + def test_purchase_fails_when_not_enough_money(self): + success = self.sut.purchase(self.store, Product("Shampoo", 15)) + + assert success is False +> assert 10 == self.store.item.count +E AssertionError: assert 10 == 5 +E + where 5 = Product(merch='Shampoo', count=5).count +E + where Product(merch='Shampoo', count=5) = .item +E + where = .store + +test\test_03_high_coupling.py:59: AssertionError +===================================================== short test summary info ====================================================== +FAILED test/test_03_high_coupling.py::TestCustomer::test_purchase_fails_when_not_enough_money - AssertionError: assert 10 == 5 +=================================================== 1 failed, 1 passed in 0.08s ==================================================== +``` + +다른 테스트 케이스에 간섭되어 문제가 발생했다! + +이런 류의 로직은 두 가지 단점이 있다! + +- 테스트 간 결합도가 높아짐 +- 테스트 가독성이 떨어짐 + +## 3.3.1 테스트 간의 높은 결합도는 안티패턴이다 + +테스트 간 결합도가 높으면, 다른 테스트에 원치않는 실패를 야기한다. 테스트는 서로 격리되어야 한다는 지침을 어기기 때문이다. + +테스트에 공유상태를 두는걸 끊어내야함 + +## 3.3.2 테스트 가독성을 떨어뜨리는 생성자 사용 + +준비코드를 생성자로 추출하면 테스트 가독성을 떨어뜨린다. 테스트 메소드가 무엇을 해야하는지 이해하려면 다른 클래스의 부분도 봐야한다. + +## 3.3.3 더 나은 테스트 픽스처 재사용법 + +생성자를 쓰는 것은 최선의 방법이라긴 힘들다. 두 번째 방법은 private 팩토리 메소드를 생성하는 것이다. + +pytest라면 scope을 좁게 둔 fixture를 테스트별로 주면 좋을 것 같다. 개별 테스트 별로 격리가 된다는 점에서 팩토리 메소드를 두는 것도 나쁘지 않은데, 픽스처로 해결하기 뭣한 부분들(예를 들어 여럿 걸친 conftest에 동시다발저긍로 쓰이는)에서 잘 분리하면 되지 않을까. + +대강 이런 코드를 생각해봤다. + +```python +@pytest.fixture( + scope="function", + name="data", +) +def create_store_with_inventory(): + """ Scope을 function으로 두어, 수행하는 테스트 케이스별로 돌 수 있도록... + """ + store = Store(Product("Shampoo", 10)) + sut = Customer() + + yield {"store": store, "sut": sut} + + +class TestCustomer: + store = Store(Product("Shampoo", 10)) + sut = Customer() + + def test_purchase_succeeds_when_enough_inventory(self, data): + store = data.get("store") + sut = data.get("sut") + + success = sut.purchase(store, Product("Shampoo", 5)) + + assert success is True + assert 5 == store.item.count + + def test_purchase_fails_when_not_enough_money(self, data): + store = data.get("store") + sut = data.get("sut") + + success = sut.purchase(store, Product("Shampoo", 15)) + + assert success is False + assert 10 == store.item.count +``` + +테스트는 아래 명령으로 구동한다: + +> `pytest test\test_04_using_fixture.py` + +```shell +========================================================= test session starts ========================================================= +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 -- C:\REDACTED\python.exe +cachedir: .pytest_cache +rootdir: C:\pt1\ch03 +plugins: cov-4.1.0, mock-3.11.1 +collected 2 items + +test/test_04_using_fixture.py::TestCustomer::test_purchase_succeeds_when_enough_inventory PASSED [ 50%] +test/test_04_using_fixture.py::TestCustomer::test_purchase_fails_when_not_enough_money PASSED [100%] + +========================================================== 2 passed in 0.02s ========================================================== +``` + +이러면 각 테스트코드 별로 맥락 유지, 결합 제거, 가독성 향상의 이점을 가진다. + +테스트 픽스처 재사용 규칙에는 예외가 있다. 모든 테스트에 사용되는 픽스처는 클래스 생성자로 빼는 편이 더 합리적이다. + +그런건 scope을 다르게 두면 된다고 생각한다. ([관련 링크](https://docs.pytest.org/en/7.3.x/how-to/fixtures.html#scope-sharing-fixtures-across-classes-modules-packages-or-session)) + +```python +@pytest.fixture(scope="session") +def smtp_connection(): + # the returned fixture value will be shared for + # all tests requesting it + ... +``` + +상기와 같이 `scope="session"` 으로 두면 모든 테스트에 대해 (정확히는 자신이 속한 모듈부터 모든 하위까지) 적용가능하다. + +# 3.4 단위 테스트 명명법 + +단위 테스트에 표현력이 있는 이름을 붙이는 것 또한 중요하다. 이름을 보고 뭐하는 테스트인지, 어떤 시스템 검증인지 한번에 이해할 수 있기 때문이다. + +저자는 일반적으로 쓰이는 명명법 관습을 비판한다. 아래를 보자: + +`[테스트 대상 메소드]_[시나리오]_[예상결과]` + +- 테스트 대상 메소드: 테스트 중인 메소드 명 +- 시나리오: 메소드 테스트 조건 +- 예상 결과: 현재 시나리오에서 테스트 대상 메소드에게 기대하는 것 + +이는 테스트코드의 동작 대신 구현 세부사항에 집중하도록 하므로 도움되지 않는다고 한다. 또한 괜히 복잡하게 이름을 작성하는 것은 테스트 파악에 도움되지 않는다고 비판한다. + +`test_sum_of_two_numbers` 와 같은 이름을 상기 명명법으로 바꾸면, `test_sum_twonumbers_returns_sum` 으로 두어야한다. + +- 테스트 대상 메소드: `sum` +- 시나리오: 두 개의 숫자 +- 예상결과: 두 수의 합 + +쓸데없이 복잡하게 두기보단 쉬운 말로 풀어야, 도메인 전문가나 프로그래머 모두에게 도움된다. 현실적인 도움이 되도록 쉽게 작성하자. + +## 3.4.1 단위 테스트 명명 지침 + +- 엄격한 명명정책보다 쉬운 이름으로. 복잡한 동작은 코드로 설명되도록. +- 비개발자에게 비즈니스 로직을 설명할 수 있도록 이름을 명명하기. +- 단어는 `_` 로 구별하기. + +## 3.4.2 지침에 따른 테스트이름 변경 + +이름 개선에 대한 예시를 작성해보자 + +```python +from datetime import ( + datetime, + timedelta, +) + + +class Delivery: + date_time: datetime + + def is_delivery_valid(self): + return self.date_time >= self.date_time + timedelta(days=1.99) + + +class TestDelivery: + def test_isdeliveryvalid_invaliddate_returnsfalse(self): + sut: Delivery = Delivery() + past_date: datetime = datetime.now() - timedelta(days=1) + sut.date_time = past_date + + is_valid = sut.is_delivery_valid() + + assert is_valid is False +``` + +테스트는 아래 명령으로 구동한다: + +> `pytest test\test_05_complex_name.py` + +```shell +========================================================= test session starts ========================================================= +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 -- C:\python.exe +cachedir: .pytest_cache +rootdir: C:\unit_testing\pt1\ch03 +plugins: cov-4.1.0, mock-3.11.1 +collected 1 item + +test/test_05_complex_name.py::TestDelivery::test_isdeliveryvalid_invaliddate_returnsfalse PASSED [100%] + +========================================================== 1 passed in 0.01s ========================================================== +``` + +테스트케이스의 이름을 고쳐보자... 이 정도를 첫 시도라고 할 수 있다! + +- `delivery_with_invalid_date_should_be_considered_invalid()` + +- 이름이 누구에게든 이해하기 쉽도록 바뀌었다 +- SUT의 메소드 이름은 더이상 테스트 이름에 속하지 않는다 + +> 테스트케이스 이름에 SUT의 메소드 이름을 넣지 마시오 +> +> 1. SUT의 메소드 이름이 바뀔지도 모른다 +> 2. 동작 대신 코드를 목표로 하면 해당 코드의 구현 세부사항과 테스트 간의 결합도가 높아진다 → 테스트 유지보수성이 떨어짐 (5장서 살펴봄) + +테스트케이스로 다시 돌아가보자. 이 테스트케이스의 "무효한 날짜"는 언제인가? 과거의 날짜다. 이는 테스트가 과거의 날짜면 실패함을 시사하도록 바꾸어야 한다. + +- `delivery_with_past_date_should_be_considered_invalid()` + +좀더 쉬운영어로 갈아보자! + +- `delivery_with_past_date_should_be_invalid()` + +_should be_ 구문은 안티패턴이다(!). 하나의 테스트는 동작 단위에 대한 단순하고 원자적 사실이기 때문이다. 사실을 기술할 땐 소망, 욕구가 없다. 그렇다면 아래와 같이 바뀐다: + +- `delivery_with_past_date_is_invalid()` + +기초적인 영문법은 지키자(!) + +- `delivery_with_a_past_date_is_invalid()` + +이 테스트 케이스는, 테스트 대상의 애플리케이션 동작의 관점 중 하나를 설명한다. "배송가능" 여부는 현재 이후의 날짜여야 한다는 점이다. + +# 3.5 매개변수화된 테스트 리팩토링하기 + +테스트 하나로는 동작을 완벽히 설명하기 힘들다. 각 구성요소는 자체 테스트로 캡처해야한다. 그런데, 상기 구문과 같은 로직을 검증하려면 복수개의 테스트코드가 많이 생겨야한다. 이 때 매개변수화된(_parametrized_) 테스트를 사용하여 반복을 줄일 수 있다. `pytest` 에서는 어떻게 쓸 수 있나 살펴보자. + +먼저, 상기 애플리케이션의 날짜 관련 동작은 여러가지 테스트 케이스를 포함하고 있다. 지난 배송일 확인 이외에도 오늘, 내일, 그 이후의 날짜에 대해서도 확인하는 테스트가 필요하다. 이는 아래와 같을 것이다: + +- `delivery_for_today_is_invalid()` +- `delivery_for_tomorrow_in_invalid()` +- `the_soonest_delivery_date_is_two_days_from_now()` + +이걸 일일이 만들면 길어진다. 그렇다면, 하나의 공통된 이름으로 묶고, 여러 파라미터를 한번에 넣고 테스트한다면 한결 나을 것이다. + +```python +class TestDelivery: + @pytest.mark.parametrize( + "from_now, expected", + [(-1, False), (0, False), (1, False), (2, True)] + ) + def test_can_detect_an_invalid_delivery_date(self, from_now, expected): + sut: Delivery = Delivery() + past_date: datetime = datetime.now() + timedelta(days=from_now) + sut.date_time = past_date + + is_valid = sut.is_delivery_valid() + + assert is_valid == expected +``` + +이런 식으로 parametrize를 수행해서, 여러 테스트에 대해 케이스 별로 수행해볼 수 있다. + +그리고, 매개변수화된 데이터를 별도로 뺄 수는 없을까? 너저분하게 코드가 나열되어있는 것은 보기 좀 그렇다. + +그럴 땐 테스트 데이터를 별도로 마련하고... + +```python +testdata = [ + (-1, False), + (0, False), + (1, False), + (2, True), +] +``` + +...이를 parametrize에 전달한다. + +```python +@pytest.mark.parametrize("from_now, expected", testdata) +def test_can_detect_an_invalid_delivery_date2(self, from_now, expected): + sut: Delivery = Delivery() + past_date: datetime = datetime.now() + timedelta(days=from_now) + sut.date_time = past_date + + is_valid = sut.is_delivery_valid() + + assert is_valid == expected +``` + +상기 테스트들은 아래 명령으로 구동한다: + +> `pytest test\test_06-1_parameterized_test.py` + +```shell +================================================ test session starts ================================================= +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 -- C:\REDACTED\python.exe +cachedir: .pytest_cache +rootdir: C:\pt1\ch03 +plugins: cov-4.1.0, mock-3.11.1 +collected 8 items + +test/test_06-1_parameterized_test.py::TestDelivery::test_can_detect_an_invalid_delivery_date[-1-False] PASSED [ 12%] +test/test_06-1_parameterized_test.py::TestDelivery::test_can_detect_an_invalid_delivery_date[0-False] PASSED [ 25%] +test/test_06-1_parameterized_test.py::TestDelivery::test_can_detect_an_invalid_delivery_date[1-False] PASSED [ 37%] +test/test_06-1_parameterized_test.py::TestDelivery::test_can_detect_an_invalid_delivery_date[2-True] PASSED [ 50%] +test/test_06-1_parameterized_test.py::TestDelivery::test_can_detect_an_invalid_delivery_date2[-1-False] PASSED [ 62%] +test/test_06-1_parameterized_test.py::TestDelivery::test_can_detect_an_invalid_delivery_date2[0-False] PASSED [ 75%] +test/test_06-1_parameterized_test.py::TestDelivery::test_can_detect_an_invalid_delivery_date2[1-False] PASSED [ 87%] +test/test_06-1_parameterized_test.py::TestDelivery::test_can_detect_an_invalid_delivery_date2[2-True] PASSED [100%] + +================================================= 8 passed in 0.09s ================================================== +``` + +### 주의! pytest의 parametrized 사용법에 대해... + +다만, `pytest`의 parametrized 기능을 두줄로 사용하면 `from_now`, `expected`로 나열가능한 모든 경우를 다 사용한다. ($4!$만큼의 테스트 케이스를 수행!) + +아래 코드는 아래와 같은 테스트 케이스의 에러가 난다! + +```python +class TestDelivery: + @pytest.mark.parametrize("from_now", [(-1), (0), (1), (2)]) + @pytest.mark.parametrize("expected", [(False), (False), (False), (True)]) + def test_can_detect_an_invalid_delivery_date(self, from_now, expected): + sut: Delivery = Delivery() + past_date: datetime = datetime.now() + timedelta(days=from_now) + sut.date_time = past_date + + is_valid = sut.is_delivery_valid() + + assert is_valid == expected +``` + +상기 테스트들은 아래 명령으로 구동한다: + +> `pytest test\test_06-2_parameterized_test_with_error.py` + +```shell +================================================ test session starts ================================================= +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 +rootdir: C:\unit_testing\pt1\ch03 +plugins: cov-4.1.0, mock-3.11.1 +collected 16 items + +test\test_06-2_parameterized_test_with_error.py ...F...F...FFFF. [100%] + +====================================================== FAILURES ====================================================== +__________________________ TestDelivery.test_can_detect_an_invalid_delivery_date[False0-2] ___________________________ + +self = , from_now = 2 +expected = False + +(중략) + +> assert is_valid == expected +E assert True == False + +test\test_06-2_parameterized_test_with_error.py:26: AssertionError +__________________________ TestDelivery.test_can_detect_an_invalid_delivery_date[False1-2] ___________________________ +( +self = , from_now = 2 +expected = False + +(중략) + +> assert is_valid == expected +E assert True == False + +test\test_06-2_parameterized_test_with_error.py:26: AssertionError +__________________________ TestDelivery.test_can_detect_an_invalid_delivery_date[False2-2] ___________________________ + +self = , from_now = 2 +expected = False + +(중략) + +> assert is_valid == expected +E assert True == False + +test\test_06-2_parameterized_test_with_error.py:26: AssertionError +___________________________ TestDelivery.test_can_detect_an_invalid_delivery_date[True--1] ___________________________ + +self = , from_now = -1 +expected = True + +(중략) + +> assert is_valid == expected +E assert False == True + +test\test_06-2_parameterized_test_with_error.py:26: AssertionError +___________________________ TestDelivery.test_can_detect_an_invalid_delivery_date[True-0] ____________________________ + +self = , from_now = 0 +expected = True + +(중략) + +> assert is_valid == expected +E assert False == True + +test\test_06-2_parameterized_test_with_error.py:26: AssertionError +___________________________ TestDelivery.test_can_detect_an_invalid_delivery_date[True-1] ____________________________ + +self = , from_now = 1 +expected = True + +(중략) + +> assert is_valid == expected +E assert False == True + +test\test_06-2_parameterized_test_with_error.py:26: AssertionError +============================================== short test summary info =============================================== +FAILED test/test_06-2_parameterized_test_with_error.py::TestDelivery::test_can_detect_an_invalid_delivery_date[False0-2] - assert True == False +FAILED test/test_06-2_parameterized_test_with_error.py::TestDelivery::test_can_detect_an_invalid_delivery_date[False1-2] - assert True == False +FAILED test/test_06-2_parameterized_test_with_error.py::TestDelivery::test_can_detect_an_invalid_delivery_date[False2-2] - assert True == False +FAILED test/test_06-2_parameterized_test_with_error.py::TestDelivery::test_can_detect_an_invalid_delivery_date[True--1] - assert False == True +FAILED test/test_06-2_parameterized_test_with_error.py::TestDelivery::test_can_detect_an_invalid_delivery_date[True-0] - assert False == True +FAILED test/test_06-2_parameterized_test_with_error.py::TestDelivery::test_can_detect_an_invalid_delivery_date[True-1] - assert False == True +============================================ 6 failed, 10 passed in 0.26s ============================================ +``` + +# 3.6 검증문 라이브러리를 사용한 테스트 가독성 향상 + +저자는 테스트 가독성 향상을 위해 [Fluent Assertions](https://github.com/fluentassertions/fluentassertions) 라는 라이브러리를 추천한다. + +[파이썬에도 있긴 하지만](https://github.com/csparpa/fluentcheck) 잘 모르겠다. + +# Summary + +- 모든 단위 테스트는 AAA 패턴을 따라야 한다. 테스트 내에 Arrange, Act, Assert section이 여러 줄이라면 여러 동작 단위를 한 번에 검증한다는 표시다. 이 경우 여러 테스트로 나누는 것이 좋다. +- Act section이 여러 줄이면 SUT의 API에 잠정적인 문제를 의심해야 한다 + - 클라이언트에서도 이런 작업을 항상 같이 수행해야하고, 잠재적으로 코드에 로직버그가 발생할 수 있다. 이는 불변 위반(_invariant violation_)이다. SUT의 캡슐화가 제대로 되어있는지 살펴보아야 한다 +- SUT의 이름은 `sut` 로 두고 테스트에서 구별하자. 각 section 별로 `Arrange`, `Act`, `Assert` 형식의 주석을 달거나 빈 줄을 추가하여 논리적으로 읽힐 수 있게 구별하자 +- 테스트 픽스처 초기화코드는, 팩토리 메소드 형식으로 사용하자. 테스트 간 결합도를 낮게 유지하기 위함이다 +- 테스트 케이스의 이름은 사내 구성원 모두가 이해할 수 있도록 쉽게쓰자 +- 매개변수화된(_parametrized_) 테스트 코드가 필요하다면 사용하자 +- 검증문 라이브러리를 쓰면 테스트 가독성 향상에 도움이 될 수 있다 diff --git a/content/books/unit-testing/2023-07-01---pt01-ch03/media/testcode.png b/content/books/unit-testing/2023-07-01---pt01-ch03/media/testcode.png new file mode 100644 index 00000000..7807b5bc Binary files /dev/null and b/content/books/unit-testing/2023-07-01---pt01-ch03/media/testcode.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/index.md b/content/books/unit-testing/2023-07-04---pt02-ch04/index.md new file mode 100644 index 00000000..67ca7d02 --- /dev/null +++ b/content/books/unit-testing/2023-07-04---pt02-ch04/index.md @@ -0,0 +1,527 @@ +--- +title: "단위 테스트 (4)" +date: "2023-07-04T19:08:04.000Z" +template: "post" +draft: false +slug: "/books/unit-testing/2023-07-04-pt02-ch04" +category: "books" +tags: + - "book_review" + - "code_quality" +description: "단위 테스트 를 읽고 이해한 내용을 작성합니다. 챕터 4, 좋은 테스트의 4대 요소에 대한 내용입니다." +socialImage: { "publicURL": "./media/testcode.png" } +--- + +이 내용은 "단위 테스트" 를 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다. + +4장은 해당 코드를 살펴봐주세요. [코드 링크](https://github.com/s3ich4n/unit-testing-101/tree/main/pt2/ch04) + +Chapter 4. 좋은 테스트의 4대 요소 + +> 커맨드로 테스트를 직접 실행하기 위해선 현재 디렉토리로 이동한다. +> `cd pt2/ch04` + +--- + +# 들어가며 + +1장에서 살펴본 좋은 단위 테스트 스위트의 특성을 상기시켜보자 + +- 개발 주기에 통합되어있다. 실제로 사용하는 테스트만 가치있다. +- 코드베이스의 가장 중요한 부분만을 테스트 대상으로 한다. 애플리케이션의 핵심(도메인 모델)을 다른 것과 구별하는 것이 중요하다 (7장 참고) +- 최소한의 유지비로 최대 가치를 끌어낸다. 이를 위해선 아래 두 가지를 할 수 있어야 한다 + - 가치 있는 테스트 _식별_(고가치, 저가치) + - 가치 있는 테스트 _작성_ + +이 장은 가치 있는 테스트를 _식별_ 하는 것 부터 알아본다. + +# 4.1 좋은 단위 테스트의 4대 요소 + +좋은 단위 테스트는 아래 네 가지 특성을 가진다: + +1. 회귀 방지 +2. 리팩토링 내성 +3. 빠른 피드백 +4. 유지 보수성 + +이 네 특성을 바탕으로 어떠한 자동화된 테스트(단위 테스트, 통합 테스트, e2e 테스트)든 분석 가능하다. + +## 4.1.1 회귀 방지(_Protection against regressions_) + +여기서 말하는 회귀는 '소프트웨어 버그'다. 코드 수정 후(신규기능 출시 후) 기능이 의도대로 작동하지 않는 경우다. + +버그는 코드베이스가 커지면 커질 수록 잠재적인 버그에 더 많이 노출된다. 따라서 코드보호를 통해 이를 방지해야 한다. + +회귀 방지 지표에 대한 테스트 점수가 얼마나 잘 나오는지 평가하려면 아래 사항을 고려한다: + +- 테스트 중에 실행되는 코드의 양 +- 코드 복잡도 +- 코드의 도메인 유의성(_significance_) + +실행되는 코드가 많을 수록 테스트에서 회귀가 나타날 가능성이 높다. 물론 이 테스트에 대한 일련의 검증이 있다고 가정할 때, 단순히 코드를 실행시키는 것 만으로는 충분하지 않다. 테스트 수행을 통한 코드베이스의 실행결과도 검증해야 한다. + +코드 양 뿐 아니라 복잡도와 도메인 유의성 또한 중요하다. 복잡한 비즈니스 로직이 보일러플레이트 등을 통한 자동생성코드 보다 훨씬 중요하다. 비즈니스에 중요한 기능버그가 가장 큰 피해를 일으키기 때문이다. + +반면에 단순한 코드를 검증하는 것은 가치가 거의 없다. attribute 하나를 검증하기 위한 로직은 실수할 여지가 그리 많지 않다. + +작성하지 않은 코드(E.g., 라이브러리, 프레임워크, 외부 시스템 등)도 중요하다. 최상의 보호를 위해선 이런 코드 또한 검증이 올바른지 확인한다. + +> 🍅 tips +> +> 회귀 방지 지표를 극대화하려면 테스트가 가능한 한 많은 코드를 실행하도록 목표해야한다. + +## 4.1.2 리팩토링 내성 + +좋은 단위 테스트의 두 번째 특성은 리팩토링 내성이다. 이는 '빨간막대'로 바꾸지 않고 기본 애플리케이션 코드를 리팩토링할 수 있는지에 대한 척도다. + +> ‼️ 정의 +> +> 리팩토링은 식별할 수 있는 동작을 수정하지 않고 기존 코드를 변경하는 것을 의미한다. +> +> 코드의 비기능적 특징을 개선하는 것으로 가독성을 높이고 복잡도를 낮추는 것이 주요 의도다. +> +> E.g., 메소드 명 변경, 코드 조각을 새 클래스로 추출. + +예를 들어 이런 상황이 발생했다고 생각해보자. + +새 기능 추가, 테스트코드 올패스 후 코드 리팩토링을 하려 한다. 리팩토링을 하니 테스트코드가 말썽이다. 기능은 의도대로 돌아가고 있다. 테스트만 '빨간막대' 로 변했다. 이런 상황을 거짓 양성(_false positive_, 긍정 오류. Type 1 에러라고도 함)이라고 한다. + +> 이하 **오탐** 으로 지칭하도록 하겠다. + +이는 허위 경보다. 기능은 의도대로 돌지만 테스트가 실패하는 경우다. 이러한 '오탐'은 코드 리팩토링 시 발생한다. 좋은 단위 테스트의 한 가지 특성으로 이름붙이자면 '리팩토링 내성'이라고 일컫는다. + +> Q: 거짓 양성, 거짓 음성이 헷갈려요 +> +> A: 아래 짤방을 보세요 + +![P는 작대기가 한개니까 Type I Error, N은 작대기가 2개니까 Type II Error](https://effectsizefaq.files.wordpress.com/2010/05/type-i-and-type-ii-errors.jpg) [^1] + +리팩토링 내성 지표에서 테스트 점수가 얼마나 잘 나오는지 평가하려면 테스트에서 얼마나 많은 '오탐'이 발생했는지 살펴봐야한다. 이는 적을 수록 좋다. + +'오탐'을 신경써야 하는 이유는 전체 테스트 스위트에 치명적인 영향을 줄 수 있기 때문이다. 단위 테스트의 목표는 프로젝트 성장을 지속가능하게 하는 것이다. 테스트가 지속 가능한 성장을 하기 위한 메커니즘은 회귀없이 주기적으로 리팩토링하고 새 기능을 추가할 수 있는 것이다. 이로 인한 장점은 아래와 같다: + +- 기존 기능이 고장났을 시 테스트로 조기경고를 받아볼 수 있다[^2] + - 결함 있는 코드를 배포 전 점검가능 + - 프로덕션 환경에서 문제 처리하려면 더 골치아팠을 것 +- 코드 변경이 회귀로 이어지지 않을 것이라고 확신할 수 있다 + - 좀더 공격적인 리팩토링을 할 수 있는 추진력이 된다 + - 코드베이스를 보다 깔끔하게 가져갈 수 있다 + +'오탐'은 두 이점을 모두 방해한다! 이유는 아래와 같다: + +- 테스트가 타당한 이유 없이 실패하면, 코드 대응능력과 의지가 희석된다 + - 실패에 익숙해지면 신경을 저절로 쓰지 않게 된다 + - **타당한 실패까지 무시** 하게 된다! 이는 프로덕션 환경에 버그가 살포됨을 의미한다 +- '오탐'이 빈번하면 테스트 스위트를 신뢰할 수 없게 된다 + - 믿을 만한 안전망으로 인식하지 않게 된다. _양치기 소년_ 이 되어버린다! + - 신뢰가 부족해지면 리팩토링을 시도하기 어려워진다 + +이에 대한 대응은 테스트 스위트를 다시 살펴보고, 안정성을 높이는 것이다. (7장에서 다시 볼 것) + +## 4.1.3 무엇이 '오탐'의 원인인가? + +테스트에서 발생하는 '오탐'의 수는 테스트 구성방식과 관련되어있다. **테스트와 SUT의 구현 세부 사항이 많이 결합할 수록** '오탐'이 많이 생긴다. 이를 위해선 테스트를 구현 세부 사항에서 분리해야한다. + +테스트는 SUT의 최종결과, 즉 동작을 검증하는 것이다. 동작하기 위한 과정을 테스트하는 것이 아니다. 테스트는 최종 사용자(end user)의 관점에서 SUT 검증하고, 최종 사용자에게 의미있는 결과가 나오는지 확인해야한다. (5장에서 다시 볼 것) + +테스트를 구성하기에 가장 좋은 방법은 문제 영역을 이야기하는 것이다. 테스트가 실패하면, 해당 애플리케이션 동작과 테스트 시나리오가 분리되는 것을 의미한다. 이런 구조라면 실패는 '시나리오 밖의 동작 시' 발생하며, 이는 문제 파악에 도움이 된다. + +예시를 통해 살펴보자. 예시코드를 구동가능한 파이썬 코드로 고치면 아래와 같다. (좋은 코드는 아닙니다!) + +```python +class Message: + header: str + body: str + footer: str + + +class Renderer(Protocol): + def render(self, msg: Message): + ... + + +class HeaderRenderer(Renderer): + def render(self, msg: Message): + return f"

{msg.header}

" + + +class BodyRenderer(Renderer): + def render(self, msg: Message): + return f"{msg.body}" + + +class FooterRenderer(Renderer): + def render(self, msg: Message): + return f"{msg.footer}" + + +class MessageRenderer(Renderer): + sub_renderers: List[Callable] = [ + HeaderRenderer(), + BodyRenderer(), + FooterRenderer(), + ] + + def render(self, msg: Message): + # 결과를 계속 받아서 지속적으로 하나의 string에 aggregate + output = (x.render(msg) for x in self.sub_renderers) + result = "".join(output) + + return result +``` + +테스트는 아래 명령으로 구동한다: + +> `pytest test\test_01_html_message.py -v` + +```powershell +=============================================== test session starts ================================================ +platform win32 -- Python 3.10.11, pytest-7.4.0, pluggy-1.2.0 -- C:\python.exe +cachedir: .pytest_cache +rootdir: C:\unit_testing\pt2\ch04 +plugins: cov-4.1.0, mock-3.11.1 +collected 2 items + +test/test_01_html_message.py::test_message_renderer_uses_correct_sub_renderers PASSED [ 50%] +test/test_01_html_message.py::test_rendering_a_message PASSED [100%] + +================================================ 2 passed in 0.04s ================================================= +``` + +- `test_message_renderer_uses_correct_sub_renderers` 테스트 케이스를 살펴보자 + +```python +def test_message_renderer_uses_correct_sub_renderers(): + sut = MessageRenderer() + + renderers = sut.sub_renderers + + assert 3 == len(renderers) + assert isinstance(renderers[0], HeaderRenderer) + assert isinstance(renderers[1], BodyRenderer) + assert isinstance(renderers[2], FooterRenderer) +``` + +`MessageRenderer` 안의 렌더러 갯수가 3개인 것을 확인하고, 헤더-바디-푸터를 파싱하고있다. 하지만, 이 테스트는 실제 `MessageRenderer`의 `render()` 의 **동작** 을 확인하지 않는다. 하위 렌더링 클래스의 구성을 변경해도 HTML 문서가 동일하게 유지될 수는 있으나, 이 테스트는 구현 세부사항과 매우 결합되어있다. 예를 들어, `BodyRenderer`를 `BoldRenderer`로 바꾼다거나, `MessageRenderer`에서 한번에 렌더링을 구현해버리거나 하면 테스트는 **깨진다**. + +이런 그림으로 테스트를 수행하고 있음을 알 수 있다. 아래 그림과 함께 정리해보자: + +![SUT 내의 알고리즘과 결합된 테스트. 리팩토링 시 테스트도 함께 깨진다](./media/001.png) + +- 리팩토링 과정은 애플리케이션의 식별동작에 영향을 주지 않으면서 구현을 변경하는 것이다 +- 상기 예시대로 코드를 바꾸면 이 테스트 케이스는 '빨간막대'로 변한다 + +즉, SUT의 구현 세부 사항과 결합된 테스트는 리팩토링 내성이 없다. 위에서 살펴본 모든 단점이 보인다: + +- 회귀 발생 시 조기 경고를 제공하지 않는다. 대부분 잘못된 것이므로 경고를 무시하게 된다 +- 리팩토링에 대한 능력과 의지를 방해한다. 테스트의 방향성을 알 수가 없으므로 버려진 테스트코드가 된다! + +깨지기 쉬운 테스트코드를 한번 더 살펴보자. + +```python +def test_message_renderer_is_implemented_correctly(whole_code): + import pathlib + cwd = pathlib.Path().cwd() / "pt2/ch04/test/test_01_html_message.py" + + with open(cwd, "r", encoding="utf-8") as source_code: + assert whole_code == source_code.read() +``` + +이 코드는 테스트코드의 **단 하나라도** 수정하면 실패한다! SUT의 식별동작이 아니라 특정 구현이 동일한지를 고집한다. 구현을 바꿀 때마다 '빨간막대'가 뜬다. + +## 4.1.4 구현 세부 사항 대신 최종 결과를 목표로 하기 + +리팩토링 내성을 높이려면 SUT의 구현 세부 사항과 테스트 간의 결합도를 낮추는 것이다. 이를 위해서 필요한 것이 어떤 것인지 살펴보자: + +- `MessageRender`에서 얻는 최종 결과는 메시지의 HTML 표현이다. 클래스에서 얻을 수 있는 관찰 가능한 결과이기 때문에 이를 확인하는 것 또한 마땅하다. 즉, `MessageRenderer`를 블랙박스로 취급하고 식별 가능한 동작에만 신경쓰기로 한 것이다. 관련 테스트 코드는 아래와 같다: + +```python +def test_rendering_a_message(): + sut = MessageRenderer() + message: Message = Message() + message.header = "h" + message.body = "b" + message.footer = "f" + + html = sut.render(message) + + assert "

h

bf" == html +``` + +이런 그림으로 테스트를 개선하였음을 알 수 있다. 상기 테스트로 아래 이점을 얻었다: + +![SUT의 식별 가능 동작과 결합된 테스트. 리팩토링 내성이 있어서, '오탐'발생률이 _거의_ 적다.](./media/002.png) + +- 최종 사용자에게 의미있는 유일한 결과를 검증했다 +- 고객에게 영향을 줄 수 있는 애플리케이션 동작을 점검한다 +- (책처럼 C# 혹은 Java, Kotlin을 쓴다할 때) 컴파일이 잘못된다거나 하면 테스트에 '오탐'이 발생할 수 있으나, 이는 훨씬 쉽게 캐치할 수 있는 문제다 + +# 4.2 첫 번째 특성과 두 번째 특성 간의 본질적인 관계 + +좋은 단위 테스트의 처음 두 요소(회귀 방지, 리팩토링 내성) 사이에는 본질적인 관계가 있다. 둘 다 정 반대의 관점에서도 테스트 스위트의 정확도에 기여한다. 이 두가지 특성은 시간이 흐르며 프로젝트에 영향을 다르게 미치는 경향이 있다. 프로젝트가 시작된 직후에는 회귀 방지를 훌륭히 갖추는 것이 중요하지만, 리팩토링 내성은 바로 필요하지 않다. + +이 절에는 아래 내용을 살펴볼 것이다 + +- 테스트 정확도 극대화 +- '오탐'과 거짓 음성(_false negative_, 부정 오류. Type 2 에러라고도 함. 이하 **미탐** 으로 지칭)의 중요성 + +## 4.2.1 테스트 정확도 + +테스트 결과를 다시 큰 그림으로 보고, 이를 분석해보자: + +![회귀 방지와 리팩토링 내성 간의 관계. 회귀 방지는 '미탐'을 예방하며, 리팩토링 내성은 '오탐'을 예방한다](./media/003.png) + +- 버그가 있어서 있다고 하거나(참 양성), 버그가 없어서 없다고 하는 건(참 음성) 지극히 정상이다. +- 테스트에서 의도한 오류가 발생하지 않으면 '미탐' 이다. 회귀 방지가 훌륭한 테스트는 '미탐'을 최소화하는데 도움이 된다. +- 기능은 올바르지만, 테스트에서 실패를 보고하면 '오탐' 이다. 리팩토링 내성이 훌륭한 테스트는 '오탐'을 최소화 하는데 도움이 된다. + +결국 테스트의 정확도는 좋은 단위 테스트의 처음 두 특성에 대한 것이다. 회귀 방지와 리팩토링 내성은 테스트 스위트의 정확도를 극대화 하는 것을 목표로 한다. 정확도 지표는 아래 두 가지 요소를 의미한다: + +- 테스트가 버그 있음을 얼마나 잘 나타내는가('미탐' 제외) +- 테스트가 버그 없음을 얼마나 잘 나타내는가('오탐' 제외) + +'오탐'과 '미탐'을 생각해보는 다른 방법은 소음 대비 신호 비율 측면에서 볼 수 있다. 하기 분모를 줄이거나, 분자를 늘리는 테스트로 개선하는 것을 의미한다. + +$테스트 정확도 = \dfrac{신호(발견된 버그 수)}{소음(허위 경보 발생 수)}$ + +## 4.2.2 '오탐'과 '미탐'의 중요성: 역학관계 + +단기적으로 '오탐'도 '미탐'만큼 나쁘지 않다. 초기 프로젝트에서는 버그를 프로덕션에 풀지 않는 것이 중요하기 때문이다. 하지만 프로젝트가 성장함에 따라 '오탐'은 테스트 스위트에 점차 큰 영향을 미친다. + +!['오탐'이 영향도 증가 추이와 '미탐'의 영향도 증가추이](./media/004.png) + +초기에는 '오탐'이 크게 중요하지는 않다. 리팩토링보다는 제품을 내어놓아야 하니까. 초반에는 코드에 대해 리팩토링을 빠르게 할 수 있다. 아직 머릿속에 있으니까. 그렇지만 가면 갈 수록 그 중요성이 점차 중요해진다. + +하지만 시간이 지날 수록 코드베이스는 나빠진다. 이 경향을 줄이려면 정기적으로 리팩토링을 해야한다. 그렇지 않으면 그 모든 것이 비용이다. + +리팩토링이 점차 필요함에 따라 테스트의 리팩토링 내성도 중요해진다. '오탐'을 말하는 테스트를 계속 둔다면 이른바 _양치기 소년_ 테스트가 된다. + +후반까지 가는 프로젝트라면 '오탐', '미탐' 이 두가지에 대해 특히나 신경써야 한다. + +# 4.3 세 번째 요소와 네 번째 요소: 빠른 피드백과 유지 보수성 + +아래 두 내용을 마저 살펴보자: + +- 빠른 피드백 +- 유지 보수성 + +빠른 피드백은 단위 테스트의 필수 속성이다. 테스트를 빠르고 자주 돌리면 코드 결함을 빠르게 캐치할 수 있다. 이를 피드백 루프라고 일컫는데, 이것이 줄어들면 버그 수정비용이 상당히 줄어든다. + +유지 보수성 지표는 유지비를 평가한다. 이 지표는 아래 두 가지 요소로 구성된다: + +- 테스트가 얼마나 이해하기 어려운가? + - 이 구성요소는 테스트의 크기와 관련있다. 테스트가 짧으면 더 읽기 쉽다. (쓸데없이 코드를 줄이는 경우를 말하는 것이 아니다!) + - 테스트 코드의 품질 또한 중요하다. 테스트 작성 시에도 절차를 생략하지 말자 +- 테스트가 얼마나 실행하기 어려운가? + - 테스트가 프로세스 외부 종속성으로 작동하면, 외부 의존성을 갖추는 시간 또한 들여야 한다 + +# 4.4 이상적인 테스트를 찾아서 + +다시, 좋은 단위 테스트의 4대 특성을 보자. + +1. 회귀 방지 +2. 리팩토링 내성 +3. 빠른 피드백 +4. 유지 보수성 + +이 네 특성을 _곱하면_ 테스트의 가치가 결정된다. 즉, 어떤 특성이라도 `0`이 되면 전체가 `0`이 되어버린다(!) + +`가치 추정치 = [0..1] * [0..1] * [0..1] * [0..1]` + +> 🍅 tips +> +> 가치가 있으려면 테스트는 네 가지 범주 모두에서 점수를 내야 한다. + +이 수치는 정확히 산정할 수 없다. 이런 것을 할 수 있는 도구 또한 없다. 다만 네 가지 특성과 관련하여 테스트가 어느정도 레벨인지는 비교할 수 있다. 이 평가를 통해 테스트의 가치평가가 가능하고, 테스트 스위트에서 계속 활용할지/말지 를 결정할 수 있다. + +테스트 코드를 포함한 모든 코드는 책임(_liability_) 이다. 최소 필수값에 대해 매우 높은 임계치를 정하고, 이 임계치를 충족하는 테스트만 남기자. 매우 가치있는 테스트를 기반으로 쌓아올리자. + +이상적인 테스트는 짤 수 있을까? + +## 4.4.1 이상적인 테스트를 만들 수 있는가? + +아쉽지만 그럴 수 없다. 회귀 방지, 리팩토링 내성, 빠른 피드백은 상호 배타적(_mutually exclusive_)이다. 셋 중 하나는 희생해야 한다. 그렇지만 어느 것 하나라도 `0`점이 되면 안 된다. 두 특성을 최대로 하는 것을 목표하되, 한가지 특성을 희생하는 케이스를 살펴보자. + +## 4.4.2 극단적 사례 1: 엔드 투 엔드(e2e) 테스트 + +e2e 테스트는 최종 사용자의 관점에서 시스템을 살펴본다. UI, DB, 외부 앱을 포함한 모든 시스템 구성을 거친다. + +이는 많은 코드를 테스트하므로 회귀 방지를 잘 수행한다. 많은 코드에는 직접 작성한 코드 뿐 아니라 외부 라이브러리, 프레임워크, 서드파티 앱 등 간접적으로 사용하는 모든 코드까지 테스트하게 된다. + +'오탐'에도 면역이다. 리팩토링 내성에도 우수하다. 리팩토링을 올바르게 했다면 식별할 수 있는 동작을 변경하지 않으므로 e2e 테스트에도 영향이 없다. 또한 기능이 되는지만을 보기 때문에 특정 구현에 구애받지 않는다. + +그러나 너무 느리다. **e2e 테스트에만** 의존하면 피드백이 너무 느릴 수 밖에 없다. + +![e2e 테스트: 회귀 방지, '오탐'에 대한 보호 가능, 피드백이 느림](./media/005.png) + +## 4.4.3 극단적 사례 2: 간단한 테스트 + +너무 간단한 테스트... getter/setter 를 테스트 하는 수준의 코드는 빨리돈다. 피드백도 빠르다. 리팩토링 내성도 우수하다. '오탐'이 생길 여지가 없기 때문이다. + +그러나 기반 코드에 실수할 여지가 많지 않기 때문에 간단한 테스트는 회귀를 나타내지 않을 것이다. + +이름만 다르고 동일한 테스트를 할 수도 있다. 이런 테스트는 항상 통과하거나 검증이 무의미하므로 제대로된 테스트라 하기 힘들다. + +![간단한 테스트: 리팩토링 내성 우수, 빠른 피드백 제공, 회귀 방지가 없음](./media/006.png) + +## 4.4.4 극단적 사례 3: 깨지기 쉬운 테스트(_brittle test_) + +실행이 빠르고 회귀를 잡을 가능성이 높지만, '오탐'이 많은 테스트도 있다. 이런 테스트를 깨지기 쉬운 테스트라고 한다. 리팩토링을 견디지 못하고, 해당 기능이 고장났는지 여부와 관계없이 '빨간막대'로 바뀐다. + +이런 테스트를 생각하면 된다. + +```python +class UserRepository: + last_query: str + + def get_by_id(self, user_id: int): + ... + + def get_query(self): + return self.last_query + + +def test_get_by_id_executes_correct_sql_code(): + sut: UserRepository = UserRepository() + + user = sut.get_by_id(5) + + assert sut.last_query == "SELECT * FROM dbo.User WHERE user_id = 5" +``` + +빠르게 실행될 수 있고 회귀를 잡을 수도 있겠지만, 리팩토링 내성이 없다. 코드를 잘못짜도 '오탐', ORM으로 만들어지는 쿼리가 조금이라도 다르면 '오탐', 등등. 내부 구현사항에 결합되었기 때문에 이런 결과가 나온다. + +![깨지기 쉬운 테스트: 빠른 피드백 제공, 회귀 방지, 리팩터링 내성이 없음](./media/007.png) + +## 4.4.5 이상적인 테스트를 찾아서: 결론 + +**모두 완벽한 테스트는 없다!** 상기 세 가지 특성은 상호 배타적이다. 그렇다면 어떻게 희생해야할지 살펴보자. + +![있을 수가 없는 경우](./media/008.png) + +유지보수성은 e2e 테스트를 제외하고는 처음 세 가지 특성과 크게 관련없다. + +리팩토링 내성은 최대한 많이 갖는 것을 목표로 해야한다. 이것은 양보하기 어려운 요소기 때문이다. 따라서 테스트가 얼마나 버그를 잘 찾는지(회귀방지)와 얼마나 빠른지(빠른 피드백) 사이의 선택으로 절충안을 정하게 된다. + +![리팩토링 내성을 가진 채로, 회귀방지와 빠른 피드백 사이를 절충한다](./media/009.png) + +리팩토링 내성은 포기하기 어렵다. 리팩토링 내성은 둘 중 하나를 선택해야한다. 내상이 있거나/없거나 이다. 그렇다면 가져갈 수 밖에 없다. + +회귀 방지와 빠른 피드백에 대한 지표는 조절이 가능하다. + +> 🍅 tips +> +> 테스트 스위트를 탄탄하게 하려면 테스트의 '오탐'을 제거하는 것이 최우선 과제다. + +> ❓ CAP Theorem +> +> 좋은 단위 테스트의 세 특성은 상호배타적이다. 이는 마치 DB의 CAP 이론과 유사하다. +> +> CAP 이론은 분산 데이터 저장소가 아래 세 보증 모두를 동시에 제공할 수 없음을 의미한다 +> +> - 일관성(Consistency): 모든 읽기가 가장 최근의 쓰기 또는 오류를 수신 +> - 가용성(Availability): 모든 요청(시스템 내 전체 노드 중단 제외)이 응답을 수신 +> - 분할 내성(Partition Toleration): 네트워크 분할(네트워크 노드 간 연결 끊어짐)에도 시스템이 계속 작동함 +> +> 두 가지 비슷한 점이 있다 +> +> - 셋 중 둘을 선택해야한다. +> - 대규모 시스템이더라도 분할 내성을 포기할 수는 없다. 대규모 서비스는 분할 내성을 수행하지 않으면 그 많은 데이터를 가지고 있을 수가 없다. +> +> 그렇다면 일관성과 가용성 둘 중 하나를 절충해야한다. +> - 실시간성이 필요하면 일관성을 선택 +> - 죽지않는 서비스여야 한다면 가용성을 선택 + +# 4.5 대중적인 테스트 자동화 개념 살펴보기 + +앞에서 살펴본 좋은 테스트의 특성은 기본적으로 깔고가야 하는 것이다. 기존에 알려진 테스트 자동화 개념은 하기 네 특성으로 거슬러 올라갈 수 있다. + +- 테스트 피라미드 +- 블랙박스 테스트 +- 화이트박스 테스트 + +## 4.5.1 테스트 피라미드 분해 + +테스트 피라미드는, 테스트 스위트에서 아래 테스트 유형 간의 일정한 비율을 일컫는 개념이다. + +- 단위 테스트 +- 통합 테스트 +- e2e 테스트 + +![Behold, the mighty test pyramid!](./media/010.png) + +피라미드의 특징은 아래와 같다: + +- 각 층의 너비: 테스트 스위트에서 해당 테스트가 얼마나 보편적인지 나타낸다. 넓을 수록 갯수가 많음을 말한다 +- 층의 높이: 최종 사용자의 동작을 얼마나 유사하게 흉내내는지를 말한다. e2e 테스트가 가장 위에 있고, 최종 사용자와 유사하게 행동하는지를 나타내는 척도다. +- 피라미드 내 테스트 유형에 따라 빠른 피드백↔회귀 방지 사이에서 선택한다. 상단 테스트는 회귀 방지에 유리하다. 하단은 실행속도를 강조한다. +- 테스트 갯수는 보통 피라미드 형태를 유지한다. e2e는 최소 필수값만, 단위 테스트는 가장 많이. +- 다만 어느 계층도 리팩토링 내성을 포기하지 않는다. 단위 테스트에서도 그래야한다. + +![피라미드 테스트는 빠른 피드백, 회귀 방지 사이에서 선택한다](./media/011.png) + +테스트 피라미드에도 예외가 있다. 비즈니스 규칙, 복잡도가 거의 없는 CRUD 작업이면 테스트 '피라미드'는 단위테스트, 통합테스트만 존재하고 e2e 테스트가 없는 직사각형처럼 될 수도 있다. + +단위 테스트는 알고리즘이나 비즈니스 복잡도가 없는 환경에서는 유용하지 않으므로 간단한 테스트까지 내려간다. 하지만 통합 테스트는 아무리 단순하더라도 다른 하위의 시스템과 '통합'하여 도는지 확인하는 것이 중요하다. 결국 단위 테스트는 더 적어지고, 통합 테스트가 더 많아질 수 있다[^3]. + +테스트 피라미드의 또 다른 예외는 프로젝트 외부 의존성(예시: DB) 하나만 연결하는 API다. 이런 경우는 e2e 케이스를 더 많이 두는 것이 이런 애플리케이션에 적합한 옵션일 수 있다. UI도 없으니 유지비도 크지 않다. 이런 환경은 e2e 테스트와 통합테스트가 테스트의 진입점 정도밖에 차이가 없다. + +## 4.5.2 블랙박스 테스트와 화이트박스 테스트 간의 선택 + +또 다른 잘 알려진 테스트는 블랙박스 테스트와 화이트박스 테스트가 있다. + +- 블랙박스 테스트 + - 시스템 구조를 몰라도 시스템 기능을 검사할 수 있는 소프트웨어 테스트 방법 + - 명세, 요구사항 중심으로 구축한다("무엇을" 해야하는지 중심으로) +- 화이트박스 테스트 + - 애플리케이션 내부 작업을 검증하는 테스트 방식 + - 소스코드 중심으로 파생된다 + +두 방법 모두 장단점이 있다. 화이트박스 테스트가 더 철저한 편이다. +- 소스코드를 집중분석하면, 외부명세(요구사항)에만 의존할 때 놓칠 수 있는 부분을 잡을 수 있다. 그렇지만 구현에 결합되어 있기 때문에 깨지기 쉽다. 다시말해 '오탐'을 많이 내고 리팩토링 내성 지표가 모자라다. +- 비즈니스 담당자에게 의미있는 동작으로 유추할 수 없다 + +블랙박스 테스트는 정반대의 장단점을 가진다. 아래는 표로 정리한 사항이다. + +| \ |회귀 방지|리팩토링 내성| +|-----------|---|---| +| 화이트박스 테스트 |좋음|나쁨| +| 블랙박스 테스트 |나쁨|좋음| + +그렇지만 리팩토링 내성은 타협할 수 없다. 기본적으로 블랙박스 테스트를 작성하되, 알고리즘 복잡도가 높은 유틸리티 코드를 다루거나 테스트를 분석할 때는 화이트박스 테스트 접근을 수행하라. + +# Summary + +- 좋은 단위 테스트에는 단위 테스트, 통합 테스트, e2e 테스트 등 자동화된 테스트를 분석하는 데 사용할 수 있는 네 가지 기본 특성이 있다. + 1. 회귀 방지 + 2. 리팩토링 내성 + 3. 빠른 피드백 + 4. 유지 보수성 +- 회귀 방지: 테스트가 얼마나 버그(회귀)의 존재를 잘 나타내는지에 대한 척도 +- 리팩토링 내성: 테스트가 거짓 양성('오탐')을 내지 않고 애플리케이션 코드 리팩토링을 유지할 수 있는 정도 +- 거짓 양성 → 오탐을 의미한다 + - 오탐에 익숙해지면... 테스트를 의미없는 것으로 본다(문제 대응의지력 하락, 테스트 비신뢰) +- 거짓 양성은 테스트 대상 시스템과 테스트 코드가 강하게 결합되어있음을 의미한다. 결합도를 낮추려면 SUT의 "단계"가 아니라 "결과"를 검증하라 +- 회귀 방지와 리팩토링 내성은 테스트 정확도에 기여한다. 테스트는...... + - 가능한 한(리팩토링 내성) + - 적은 소음('거짓 양성')으로 + - 강한 신호(버그 탐지... 회귀 방지 영역) + - ...를 발생시킨다 +- 거짓 양성은 프로젝트가 성장할 수록 점점 더 중요해진다 +- 빠른 피드백은 테스트가 얼마나 빨리 실행되는지에 대한 척도다 +- 유지 보수성은 두 가지 요소로 구성된다 + - 테스트 이해 난이도: 테스트가 작으면 읽기 쉬움 + - 테스트 실행 난이도: 테스트가 엮인 의존성(프로세스 외부 의존성)이 적으면 쉽게 운영할 수 있음 +- 테스트의 가치 추정치는 상기 네 가지 특성에서 얻은 점수의 곱이다. 모두 중요하다! (하나라도 `0`이 되면 가치없어짐) +- 회귀 방지, 리팩토링 내성, 빠른 피드백은 상호 배타적(_mutually exclusive_)이다 + - 리팩토링 내성은 양보할 수 없는 가치다 + - 회귀 방지와 빠른 피드백 사이에서 절충안을 찾는다 +- 테스트 피라미드는 단위 테스트, 통합 테스트, e2e 테스트의 적정 비율을 의미한다 + - 단위 테스트 > 통합 테스트 > e2e 테스트 순으로 갯수가 많다 + - e2e 테스트는 회귀 방지를 선호한다 + - 단위 테스트는 빠른 피드백을 선호한다 +- 테스트를 작성할 때는 블랙박스 테스트 방법을 사용한다. 테스트 분석 시에는 화이트박스 테스트 방법을 사용한다. + +[^1]: 출처: https://twitter.com/bikutoru/status/981977290430189569 +[^2]: 버그 없는 시스템은 없다. 최소한 이 의견에 동의한다. True Negative 영역 어딘가에 존재할 '수도 있다'. [관련 내용에 대한 원본 링크](https://johngrib.github.io/wiki/Lubarsky-s-Law-of-Cybernetic-Entomology/) +[^3]: 코스믹 파이썬에서 시사하는 [high gear, low gear](https://www.cosmicpython.com/book/chapter_05_high_gear_low_gear.html)가 연관있을지 살펴보자. diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/001.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/001.png new file mode 100644 index 00000000..199a14b5 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/001.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/002.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/002.png new file mode 100644 index 00000000..9a0702d0 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/002.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/003.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/003.png new file mode 100644 index 00000000..0b45becf Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/003.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/004.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/004.png new file mode 100644 index 00000000..445f4705 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/004.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/005.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/005.png new file mode 100644 index 00000000..c310a5b0 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/005.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/006.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/006.png new file mode 100644 index 00000000..68a934d7 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/006.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/007.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/007.png new file mode 100644 index 00000000..1f9144a4 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/007.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/008.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/008.png new file mode 100644 index 00000000..a4cdcbbd Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/008.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/009.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/009.png new file mode 100644 index 00000000..345dfdf7 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/009.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/010.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/010.png new file mode 100644 index 00000000..32ddc282 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/010.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/011.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/011.png new file mode 100644 index 00000000..1e847879 Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/011.png differ diff --git a/content/books/unit-testing/2023-07-04---pt02-ch04/media/testcode.png b/content/books/unit-testing/2023-07-04---pt02-ch04/media/testcode.png new file mode 100644 index 00000000..7807b5bc Binary files /dev/null and b/content/books/unit-testing/2023-07-04---pt02-ch04/media/testcode.png differ diff --git a/content/retrospect/2023-02-20---assignment-pt01/index.md b/content/retrospect/2023-02-20---assignment-pt01/index.md index ee3a122c..60663c84 100644 --- a/content/retrospect/2023-02-20---assignment-pt01/index.md +++ b/content/retrospect/2023-02-20---assignment-pt01/index.md @@ -43,7 +43,7 @@ socialImage: { "publicURL": "./media/under_construction.jpg" } 저는 과제에 필요한 도메인을 설계하고, 이를 그림으로 묶었습니다. 이후 설계방법에 대한 방향성을 잡고, 클린 아키텍처와 Hexagonal Architecture(aka. Ports and Adapters Architecture, 이하 헥사고널 아키텍처) 를 참고하였습니다. 그 키워드를 토대로 프로젝트를 구성하였습니다. [^3] -![이제야 어렴풋이 이해가 가는 육각형입니다]() +![이제야 어렴풋이 이해가 가는 육각형입니다](https://miro.medium.com/v2/resize:fit:1400/format:webp/1*aD3zDFzcF5Y2_27dvU213Q.png) 본 과제를 수행하며 참고한 프로젝트 5개를 소개드리고자 합니다. 모두 파이썬으로 구성되어 있습니다. 추후 DDD 및 클린아키텍처, 나아가 헥사고널 아키텍처를 연재하며 해당 프로젝트를 다시 살펴보도록 하겠습니다. diff --git a/package-lock.json b/package-lock.json index 00e2bbc3..86985552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,8 @@ "gatsby-transformer-sharp": "^4.25.0", "prismjs": "^1.29.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "sharp": "^0.33.2" }, "devDependencies": { "@alxshelepenok/eslint-config": "^1.0.126", @@ -2696,6 +2697,21 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emnapi/runtime": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", + "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "optional": true + }, "node_modules/@eslint/eslintrc": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", @@ -3380,6 +3396,437 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==" }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz", + "integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz", + "integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz", + "integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz", + "integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz", + "integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz", + "integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.1.tgz", + "integrity": "sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz", + "integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz", + "integrity": "sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz", + "integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz", + "integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz", + "integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz", + "integrity": "sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz", + "integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz", + "integrity": "sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz", + "integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^0.45.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz", + "integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz", + "integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -29783,6 +30230,83 @@ "resolved": "https://registry.npmjs.org/shallow-compare/-/shallow-compare-1.2.2.tgz", "integrity": "sha512-LUMFi+RppPlrHzbqmFnINTrazo0lPNwhcgzuAXVVcfy/mqPDrQmHAyz5bvV0gDAuRFrk804V0HpQ6u9sZ0tBeg==" }, + "node_modules/sharp": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.2.tgz", + "integrity": "sha512-WlYOPyyPDiiM07j/UO+E720ju6gtNtHjEGg5vovUk1Lgxyjm2LFO+37Nt/UI3MMh2l6hxTWQWi7qk3cXJTutcQ==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.1", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.2", + "@img/sharp-darwin-x64": "0.33.2", + "@img/sharp-libvips-darwin-arm64": "1.0.1", + "@img/sharp-libvips-darwin-x64": "1.0.1", + "@img/sharp-libvips-linux-arm": "1.0.1", + "@img/sharp-libvips-linux-arm64": "1.0.1", + "@img/sharp-libvips-linux-s390x": "1.0.1", + "@img/sharp-libvips-linux-x64": "1.0.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1", + "@img/sharp-libvips-linuxmusl-x64": "1.0.1", + "@img/sharp-linux-arm": "0.33.2", + "@img/sharp-linux-arm64": "0.33.2", + "@img/sharp-linux-s390x": "0.33.2", + "@img/sharp-linux-x64": "0.33.2", + "@img/sharp-linuxmusl-arm64": "0.33.2", + "@img/sharp-linuxmusl-x64": "0.33.2", + "@img/sharp-wasm32": "0.33.2", + "@img/sharp-win32-ia32": "0.33.2", + "@img/sharp-win32-x64": "0.33.2" + } + }, + "node_modules/sharp/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/sharp/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -35217,6 +35741,23 @@ "dev": true, "requires": {} }, + "@emnapi/runtime": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", + "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + }, + "dependencies": { + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "optional": true + } + } + }, "@eslint/eslintrc": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", @@ -35823,6 +36364,147 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==" }, + "@img/sharp-darwin-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz", + "integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==", + "optional": true, + "requires": { + "@img/sharp-libvips-darwin-arm64": "1.0.1" + } + }, + "@img/sharp-darwin-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz", + "integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==", + "optional": true, + "requires": { + "@img/sharp-libvips-darwin-x64": "1.0.1" + } + }, + "@img/sharp-libvips-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==", + "optional": true + }, + "@img/sharp-libvips-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz", + "integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==", + "optional": true + }, + "@img/sharp-libvips-linux-arm": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz", + "integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==", + "optional": true + }, + "@img/sharp-libvips-linux-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz", + "integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==", + "optional": true + }, + "@img/sharp-libvips-linux-s390x": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz", + "integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==", + "optional": true + }, + "@img/sharp-libvips-linux-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.1.tgz", + "integrity": "sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==", + "optional": true + }, + "@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz", + "integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==", + "optional": true + }, + "@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz", + "integrity": "sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==", + "optional": true + }, + "@img/sharp-linux-arm": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz", + "integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-arm": "1.0.1" + } + }, + "@img/sharp-linux-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz", + "integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-arm64": "1.0.1" + } + }, + "@img/sharp-linux-s390x": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz", + "integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-s390x": "1.0.1" + } + }, + "@img/sharp-linux-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz", + "integrity": "sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-x64": "1.0.1" + } + }, + "@img/sharp-linuxmusl-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz", + "integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==", + "optional": true, + "requires": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1" + } + }, + "@img/sharp-linuxmusl-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz", + "integrity": "sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==", + "optional": true, + "requires": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.1" + } + }, + "@img/sharp-wasm32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz", + "integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==", + "optional": true, + "requires": { + "@emnapi/runtime": "^0.45.0" + } + }, + "@img/sharp-win32-ia32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz", + "integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==", + "optional": true + }, + "@img/sharp-win32-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz", + "integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==", + "optional": true + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -55099,6 +55781,63 @@ "resolved": "https://registry.npmjs.org/shallow-compare/-/shallow-compare-1.2.2.tgz", "integrity": "sha512-LUMFi+RppPlrHzbqmFnINTrazo0lPNwhcgzuAXVVcfy/mqPDrQmHAyz5bvV0gDAuRFrk804V0HpQ6u9sZ0tBeg==" }, + "sharp": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.2.tgz", + "integrity": "sha512-WlYOPyyPDiiM07j/UO+E720ju6gtNtHjEGg5vovUk1Lgxyjm2LFO+37Nt/UI3MMh2l6hxTWQWi7qk3cXJTutcQ==", + "requires": { + "@img/sharp-darwin-arm64": "0.33.2", + "@img/sharp-darwin-x64": "0.33.2", + "@img/sharp-libvips-darwin-arm64": "1.0.1", + "@img/sharp-libvips-darwin-x64": "1.0.1", + "@img/sharp-libvips-linux-arm": "1.0.1", + "@img/sharp-libvips-linux-arm64": "1.0.1", + "@img/sharp-libvips-linux-s390x": "1.0.1", + "@img/sharp-libvips-linux-x64": "1.0.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1", + "@img/sharp-libvips-linuxmusl-x64": "1.0.1", + "@img/sharp-linux-arm": "0.33.2", + "@img/sharp-linux-arm64": "0.33.2", + "@img/sharp-linux-s390x": "0.33.2", + "@img/sharp-linux-x64": "0.33.2", + "@img/sharp-linuxmusl-arm64": "0.33.2", + "@img/sharp-linuxmusl-x64": "0.33.2", + "@img/sharp-wasm32": "0.33.2", + "@img/sharp-win32-ia32": "0.33.2", + "@img/sharp-win32-x64": "0.33.2", + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "dependencies": { + "detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 5c8f167e..21644eff 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "gatsby-transformer-sharp": "^4.25.0", "prismjs": "^1.29.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "sharp": "^0.33.2" }, "devDependencies": { "@alxshelepenok/eslint-config": "^1.0.126", @@ -149,4 +150,4 @@ "engines": { "node": ">=v18.12.1" } -} \ No newline at end of file +}