... visits
작은 서비스를 만들 때는 일단 잘 돌아가게 하는 것이 중요하다고 생각했다. (일단 대충 돌아가게 하는 것과는 다르다)
그래야 나와 함께 손발을 맞추는 동료들이 너무 오래 기다리지 않고, 다양한 시도를 해볼 수 있으니 말이다.
그렇게 서비스가 커지고 사용자도 늘어나면서 나중을 상상해보곤 한다.
‘이 설계가 그때는 맞았지만 나중에도 맞을까?’, ‘갑자기 많은 사용자가 들어오면 괜찮을까?’
물론 아무런 준비도 안 되어 있는 건 아니지만, 가끔 드는 불안감은 어쩔 수 없는 것 같다.
그럴 때는 뭐라도 하는 게 맞으니까…
이 책을 읽어봐야겠다고 생각한 계기는 어떤 카카오톡 오픈 채팅에서 느꼈던 분위기 때문이다.
DDD에 대한 이야기가 오고 가는데 알아들을 수 있는 말이 많지 않았다.
예전에 사내에서 DDD 스터디를 정주행했던 적은 있지만, 깊이가 얕고 실천 계획이 동반되지 않아서 금방 잊혀졌다.
아무튼 그때 나랑 비슷한 느낌을 받았을 것 같은 누군가의 질문에 이 책을 권했던 사람이 있었다.
그날 바로 서점에 가서 샀는데 예상보다 얇아서 기뻤다(?)
2주 정도 출퇴근하는 지하철 안에서 읽었는데 겨우 Part1까지 읽고, (너무 기억 나는 게 없어서) 주말에 다시 복습하며 이 정리를 작성했다.
얇아서 출퇴근할 때 읽기 좋겠다고 생각했던 건 큰 오산이었다.
절대 한 번에 이해할 수 있는 내용이 아니고, 아직 TDD, DDD에 대한 기본적인 이해가 없다면 절대 먼저 보면 안 된다.
나는 우선 Part1까지만 읽고, 다른 DDD 관련 책이나 인터넷 자료 조사를 통해 감을 더 끌어 올릴 계획이다.
어쨌든 책은 정말 좋다. 특히 파이썬을 좋아하는 사람으로서 이 책의 예제 코드를 읽고, 이해하는 과정이 즐거웠다.
Name
이라는 클래스로 정의한다면 이름이 다른 Name
객체는 서로 같지 않다. 하지만 Name
객체를 갖는 사람(Person
) 클래스는 영속적인 정체성(persistent identity)을 갖고 있다.FooManager
, BarBuilder
, BazFactory
대신 manage_foo()
, build_bar()
, get_baz()
함수를 쓰는 편이 가독성이 더 좋고 표현력이 좋다.이 모델을 관계형 데이터베이스로 연결하려면 어떻게 해야할까?
이 모델이 데이터베이스에 대해 무지하다고 말할 수 있을까? 모델 프로퍼티가 직접 데이터베이스 열과 연관되어 있는데 어떻게 저장소와 관련된 관심사를 모델로부터 분리할 수 있을까?
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class Order(Base):
id = Column(Integer, primary_key=True)
class OrderLine(Base):
id = Column(Integer, primary_key=True)
sku = Column(String(250))
qty = Integer(String(250))
order_id = Column(Integer, ForeignKey('order.id'))
order = relationship(Order)
start_mapper()
함수를 호출하지 않으면 도메인 모델 클래스는 데이터베이스를 인식하지 못한다.
from sqlalchemy.orm import mapper, relationship
import model #(1)
metadata = MetaData()
order_lines = Table( #(2)
"order_lines",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("sku", String(255)),
Column("qty", Integer, nullable=False),
Column("orderid", String(255)),
)
...
def start_mappers():
lines_mapper = mapper(model.OrderLine, order_lines)
저장소 패턴 소개
add()
, get()
메서드 두 가지 밖에 없다. 이렇게 단순성을 강제로 유지하면 도메인 모델과 데이터베이스 사이의 결합을 끊을 수 있다.FakeRepository
를 쉽게 작성할 수 있다.아래 코드는 플라스크 함수에서 몇 가지 오류 처리를 추가하고 있다. 이렇게 할수록 점점 E2E 테스트 개수가 늘어나서 역 피라미드형 테스트가 된다.
def is_valid_sku(sku, batches):
return sku in {b.sku for b in batches}
@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"],
)
if not is_valid_sku(line.sku, batches):
return {"message": f"Invalid sku {line.sku}"}, 400
try:
batchref = model.allocate(line, batches)
except model.OutOfStock as e:
return {"message": str(e)}, 400
session.commit()
return {"batchref": batchref}, 201
# domain-layer test:
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
# service-layer test:
def test_prefers_warehouse_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)
repo = FakeRepository([in_stock_batch, shipment_batch])
session = FakeSession()
line = OrderLine('oref', "RETRO-CLOCK", 10)
services.allocate(line, repo, session)
assert in_stock_batch.available_quantity == 90
assert shipment_batch.available_quantity == 100
# tests/unit/test_services.py
def test_add_batch():
repo, session = FakeRepository([]), FakeSession()
services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session)
assert repo.get("b1") is not None
assert session.committed
# service_layer/services.py
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()
def allocate(
orderid: str, sku: str, qty: int,
repo: AbstractRepository, session
) -> str:
# 이전: allocate는 도메인 객체를 받는다 (service_layer/services.py)
def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:
pass
# 이후: allocate는 문자열과 정수를 받는다
def allocate(
orderid: str, sku: str, qty: int,
repo: AbstractRepository, session
) -> str:
pass
▼ UoW가 없는 경우: API는 서비스 계층, 저장소 계층, 데이터베이스와 직접 소통한다.
▼ UoW가 있는 경우 : UoW가 데이터베이스 상태를 관리한다.
# src/allocation/service_layer/unit_of_work.py
DEFAULT_SESSION_FACTORY = sessionmaker( #(1)
bind=create_engine(
config.get_postgres_uri(),
)
)
class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
self.session_factory = session_factory #(1)
def __enter__(self):
self.session = self.session_factory() # type: Session #(2)
self.batches = repository.SqlAlchemyRepository(self.session) #(2)
return super().__enter__()
def __exit__(self, *args):
super().__exit__(*args)
self.session.close() #(3)
def commit(self): #(4)
self.session.commit()
def rollback(self): #(4)
self.session.rollback()
# src/allocation/service_layer/services.py)
def add_batch(
ref: str, sku: str, qty: int, eta: Optional[date],
uow: unit_of_work.AbstractUnitOfWork, #(1)
):
with uow:
uow.batches.add(model.Batch(ref, sku, qty, eta))
uow.commit()
def allocate(
orderid: str, sku: str, qty: int,
uow: unit_of_work.AbstractUnitOfWork, #(1)
) -> str:
line = OrderLine(orderid, sku, qty)
with uow:
batches = uow.batches.list()
if not is_valid_sku(line.sku, batches):
raise InvalidSku(f"Invalid sku {line.sku}")
batchref = model.allocate(line, batches)
uow.commit()
return batchref