Cosmic Python 제1부(2): Service Layer와 TDD 전략
컨트롤러에서 비즈니스 로직을 빼내 서비스 레이어로 분리하고, 테스트 전략을 도메인과 리팩터링에 맞게 전환하는 방법.
Chapter 4: Service Layer (서비스 레이어)
문제: Flask 엔드포인트가 너무 뚱뚱하다
웹 컨트롤러에 비즈니스 로직을 넣으면 어떻게 될까?
1
2
3
4
5
6
7
8
9
10
@app.route("/allocate", methods=["POST"])
def allocate_endpoint():
session = get_session()
batches = repository.SqlAlchemyRepository(session).list()
line = model.OrderLine(
request.json["orderid"],
request.json["sku"],
request.json["qty"],
)
# 검증 로직, 에러 처리, 도메인 로직, 커밋... 전부 여기에?
컨트롤러가 검증, 비즈니스 로직, 데이터 접근, 에러 처리를 전부 담당하게 된다. 테스트하려면 HTTP 요청을 보내야 하고, DB가 떠 있어야 한다.
해결: 서비스 레이어로 오케스트레이션 분리
서비스 레이어는 유스케이스의 오케스트레이션(조율)을 담당한다. 직접 비즈니스 로직을 수행하는 게 아니라, 도메인 모델을 호출하는 역할이다.
1
2
3
4
5
6
7
8
9
def allocate(orderid: str, sku: str, qty: int,
repo: AbstractRepository, session) -> str:
batches = repo.list()
if not is_valid_sku(sku, batches):
raise InvalidSku(f"Invalid sku {sku}")
line = OrderLine(orderid, sku, qty)
batchref = model.allocate(line, batches)
session.commit()
return batchref
이제 Flask 엔드포인트는 요청 파싱과 응답 반환만 한다.
1
2
3
4
5
6
7
8
9
10
11
12
@app.route("/allocate", methods=["POST"])
def allocate_endpoint():
try:
batchref = services.allocate(
request.json["orderid"],
request.json["sku"],
request.json["qty"],
repo, session,
)
except services.InvalidSku as e:
return {"message": str(e)}, 400
return {"batchref": batchref}, 201
두 종류의 “서비스”를 구분하자
| 구분 | 애플리케이션 서비스 (Service Layer) | 도메인 서비스 |
|---|---|---|
| 역할 | 외부 요청을 받아서 조율 | 비즈니스 로직 수행 |
| 예시 | services.allocate() | model.allocate() |
| 알고 있는 것 | Repository, UoW, 도메인 모델 | 순수 도메인 개념만 |
서비스 레이어는 “할 일을 조율”하고, 도메인 서비스는 “실제로 일을 수행”한다. 비즈니스 로직이 서비스 레이어로 새어나가지 않도록 주의해야 한다. (빈약한 도메인 모델 안티패턴)
Chapter 5: TDD의 고속 기어와 저속 기어
테스트 피라미드는 건강한가?
서비스 레이어를 도입한 후, 테스트 분포를 살펴보면 이렇다.
- 단위 테스트 15개 — 빠르고, 도메인 로직 검증
- 통합 테스트 8개 — DB 연동 확인
- E2E 테스트 2개 — 전체 시스템 동작 확인
건강한 테스트 피라미드 모양이다. 단위 테스트가 가장 많고, 느린 테스트는 적다.
하지만 여기서 한 가지 질문이 생긴다. 도메인 레이어 테스트를 서비스 레이어로 옮겨야 할까?
고속 기어 vs 저속 기어
자전거 기어에 비유하면 이해가 쉽다.
저속 기어: 도메인 레이어 TDD
1
2
3
4
5
6
7
8
9
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
도메인 객체를 직접 만들고, 직접 호출한다.
- 장점: 설계 피드백이 풍부하다. 도메인 모델의 API가 어색하면 즉시 느낀다
- 단점: 도메인 내부 구조에 강하게 결합된다. 리팩터링하면 테스트가 깨진다
고속 기어: 서비스 레이어 TDD
1
2
3
4
5
6
7
8
9
def test_prefers_warehouse_batches_to_shipments():
repo, session = FakeRepository([]), FakeSession()
services.add_batch("in-stock-batch", "RETRO-CLOCK", 100, None, repo, session)
services.add_batch("shipment-batch", "RETRO-CLOCK", 100, tomorrow, repo, session)
services.allocate("oref", "RETRO-CLOCK", 10, repo, session)
assert repo.get("in-stock-batch").available_quantity == 90
assert repo.get("shipment-batch").available_quantity == 100
서비스 함수를 통해서만 상호작용한다.
- 장점: 도메인 내부를 자유롭게 리팩터링할 수 있다
- 단점: 설계 피드백이 줄어든다
언제 어떤 기어를 쓸까?
| 상황 | 기어 | 이유 |
|---|---|---|
| 새 프로젝트, 도메인 탐색 중 | 저속 | 설계 피드백이 중요하다 |
| 도메인 모델이 안정된 후 | 고속 | 리팩터링 자유도가 중요하다 |
| 복잡한 도메인 규칙 추가 | 저속 | 세밀한 검증이 필요하다 |
| 일반적인 기능 추가 | 고속 | 빠른 개발이 중요하다 |
핵심은 고정된 하나의 전략이 아니라, 상황에 따라 기어를 바꾸는 것이다.
서비스 레이어 테스트에서 도메인 객체 완전 분리하기
고속 기어로 전환했더라도, 테스트에서 OrderLine이나 Batch를 직접 import하면 여전히 도메인에 결합된다.
1단계: 서비스 함수가 원시 타입을 받도록 변경
1
2
3
4
5
6
7
# Before: 도메인 객체를 받는다
def allocate(line: OrderLine, repo, session) -> str:
# After: 원시 타입(str, int)을 받는다
def allocate(orderid: str, sku: str, qty: int, repo, session) -> str:
line = OrderLine(orderid, sku, qty) # 내부에서 생성
...
2단계: 테스트 셋업용 서비스 함수 추가
테스트에서 Batch(...) 를 직접 만들 필요가 없도록, add_batch 서비스 함수를 추가한다.
1
2
3
4
def add_batch(ref: str, sku: str, qty: int, eta: Optional[date],
repo: AbstractRepository, session) -> None:
repo.add(model.Batch(ref, sku, qty, eta))
session.commit()
결과: 테스트가 완전히 서비스 API로만 표현된다
1
2
3
4
5
6
7
def test_allocate_returns_allocation():
repo, session = FakeRepository([]), FakeSession()
services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, repo, session)
result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, session)
assert result == "batch1"
도메인 객체 import가 사라졌다. 오직 문자열과 숫자만으로 테스트를 작성한다.
“서비스 레이어 테스트에서 도메인 객체를 직접 다뤄야 한다면, 서비스 레이어가 아직 불완전하다는 신호다.”
E2E 테스트도 같은 원칙으로
E2E 테스트에서도 SQL로 직접 데이터를 넣는 대신, HTTP API를 통해 셋업한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test_happy_path_returns_201_and_allocated_batch():
sku, othersku = random_sku(), random_sku("other")
earlybatch = random_batchref(1)
laterbatch = random_batchref(2)
otherbatch = random_batchref(3)
post_to_add_batch(laterbatch, sku, 100, "2011-01-02")
post_to_add_batch(earlybatch, sku, 100, "2011-01-01")
post_to_add_batch(otherbatch, othersku, 100, None)
data = {"orderid": random_orderid(), "sku": sku, "qty": 3}
url = config.get_api_url()
r = requests.post(f"{url}/allocate", json=data)
assert r.status_code == 201
assert r.json()["batchref"] == earlybatch
DB 스키마나 ORM에 결합되지 않는다. HTTP API만으로 셋업과 검증을 한다.
테스트 전략 Rules of Thumb
| 규칙 | 설명 |
|---|---|
| 기능당 E2E 테스트 1개 | 전체 연결이 동작하는지만 확인 |
| 테스트 대부분은 서비스 레이어에 | 엣지 케이스까지 포함한 비즈니스 로직 검증 |
| 도메인 테스트는 소수만 유지 | 복잡한 핵심 규칙의 설계 피드백용 |
| 서비스 레이어는 원시 타입으로 | 문자열, 숫자만 받아서 도메인과 분리 |
“모든 테스트 코드는 접착제다.” 시스템을 특정 모양으로 고정시킨다. 저수준 테스트가 많을수록 내부 변경이 어려워진다. 적절한 추상화 수준에서 테스트하자.
이전 글: 제1부(1): 도메인 모델링, Repository 패턴, 그리고 추상화
다음 글: 제1부(3): Unit of Work와 Aggregate 패턴에서 이어진다.
참고 자료
