도메인 객체에 책임을 할당한다는 것
이번 주는 도메인 모델링과 아키텍처 관련 학습 및 프로젝트에 적용하는 작업을 했다.

빈 도메인 서비스를 만들었다
고객에게 상품 상세 정보를 응답하는 API를 만들다 고민하게 된 부분이 있다.
상품 정보의 응답을 만들기 위해 상품, 브랜드, 재고를 모아서 ProductInfo라는 DTO 하나로 내려주면 되는 거였다.
처음엔 별생각 없이 ProductInfo.from(product, stock, brand) 안에서 stock.isPurchasable()을 불렀다.
재고가 충분한지는 Stock이 판단하니까, 그 결과를 받아서 DTO에 담았다.
근데 좀 걸렸다. DTO가 ‘살 수 있나’를 직접 물어보고 있는 게. DTO는 결과만 담으면 되는 거 아닌가 싶었다. 판단을 부르는 건 DTO가 할 일은 아닌 것 같았다.
그래서 그 판단을 도메인 서비스로 빼보기로 했다. 파사드가 도메인 메서드를 직접 부르는 것도 좀 그랬고, 중간에 도메인 서비스 하나 거치는 게 맞다고 봤으니까.
근데 막상 빼서 만들어보니 그 안에 넣을 게 없었다. stock.isPurchasable() 한 번 더 부르는 게 다였다.
재고 판단은 이미 Stock이 다 하고 있었으니까, 도메인 서비스는 그냥 그걸 한 번 더 감싸서 넘기는 빈 껍데기였다.
넘길 것도 없는데 넘겨받을 자리부터 만든 거다. 사실 만들면서도 좀 이상하긴 했는데, 레이어 지켜야 한다는 생각에 그냥 밀고 나갔다. 다 만들고 나서야 내가 레이어 규칙에 너무 매여 있었구나 싶었다.
그래서 지웠다. 자리를 먼저 만들 게 아니라, 담을 게 생기면 그때 만들면 되는 거였다. 자리부터 만든다고 거기 채울 게 생기는 것도 아니고.
좋아요 수는 누가 늘리나
비슷한 고민을 좋아요 기능에서도 했다.
상품 좋아요 수를 어떻게 늘릴지 두 가지를 두고 고민했다.
- (A) 도메인 메서드 + 락:
product.increaseLikeCount() - (B) 원자적 SQL:
UPDATE product SET like_count = like_count + 1 WHERE id = ?
처음엔 (A)가 맞다고 봤다. 좋아요 수는 상품의 상태고, 그 상태를 바꾸는 책임은 상품한테 있어야 한다고 생각했으니까. 운영 문제 때문에 책임이 딴 데로 가면 안 된다고 봤다.
근데 좋아요는 워낙 자주 눌린다. 거기에 락을 걸면 같은 행 하나 두고 요청이 줄을 서고 DB가 느려진다.
그래서 결국 product.increaseLikeCount()를 지우고 원자적 SQL로 바꿨다.
운영 때문에 책임 안 바뀌어야 한다고 해놓고, 운영 때문에 책임을 옮긴 거다.
처음엔 그냥 성능 문제로 인해 원자적 SQL 로 바꾸는것을 정당화 했다. 그런데 다시 생각해보니 애초에 전제가 틀린 거 아닌가 싶었다.
근데 like_count가 진짜 상품의 상태이긴 한가?
이름이나 가격은 상품 그 자체 값 같은데, 좋아요 수는 사용자들이 누른 걸 세어둔 숫자다. 상품이 들고 있는 값이라기보단, 상품 바깥에서 일어난 일을 상품 행에 적어둔 거에 가깝다.
그럼 뭐가 상품의 ‘본질적인’ 상태인가. 이걸 따지다가 기준을 하나 세워봤다. 어떤 상태가 빠졌을 때 그게 상품이 아니게 되면, 그건 본질적인 상태다. 좋아요 수는 0이어도, 아예 없어도 상품은 그냥 상품이다. 그러니까 좋아요 수는 상품의 본질적인 상태는 아닌 것 같다.
그럼 가격은? 가격이 없는 건 상품이라고 할 수 있나? 여기서부턴 아직 답을 못 찾았다. 가격이 본질인지 아닌지도 잘 모르겠다. 근데 적어도 좋아요 수가 상품의 본질이 아니라는 건, 이 기준으로 보면 맞는 것 같다.
좋아요 수가 상품의 본질이 아니라면, 그걸 상품이 자기 상태로 들고 직접 바꾼다는 것도 당연한 게 아니었다. 원자적 SQL은 좋아요 수를 상품의 행동이 아니라 그냥 숫자 하나 올리는 걸로 다룬다. 어쩌면 이 값한텐 그게 더 맞는 거였다. 성능 때문에 변경한게 아니라, 애초에 상품한테 좋아요 수 증감 책임을 둔 게 잘못이었던 거다.
파사드 조율은 아직 모르겠다
애그리거트끼리 협력하는 건 파사드가 조율한다고 컨벤션을 정해놨는데, 그 조율을 어디까지로 봐야 하는지 모르겠다.
OrderFacade는 주문 저장하고 forEach로 재고를 깎고, LikeFacade는 if로 카운트를 올린다.
‘주문하면 재고 깎기’, ‘좋아요 누르면 카운트 올리기’는 도메인 정책에 가까운데, 이걸 파사드에서 forEach나 if로 처리하는 게 조율 안에 드는 건지, 도메인 이벤트나 도메인 서비스로 내려야 하는 건지 잘 모르겠다.
근데 앞에서 한 고민이 여기도 그대로 먹혔다. 자리를 만들지 말지는 담을 게 있냐로 정한다고 했으니까,
forEach나 if가 그냥 흐름 엮는 정도면 조율이 맞고, 거기 따로 뗄 만한 규칙이 생기기 시작하면 그때 내리면 된다.
지금은 흐름 엮는 정도라 파사드에 뒀다. 이게 맞는지는 더 짜봐야 알 것 같아서 일단 열어둔다.
그리고
이번 주에 만든 빈 도메인 서비스도, 상품한테 줬던 좋아요 수도, 따지고 보면 비슷했다. 하나는 담을 것도 없으면서 자리부터 만들었고, 하나는 이게 상품 건지 따져보지도 않고 상품한테 책임을 줘버렸다. 둘 다 ‘이게 더 깔끔하니까’, ‘원래 다들 그렇게 하니까’로 정한 거였다.
이번 주를 지나고 든 생각은, 어떤 아키텍처를 고르든(레이어드든 클린이든 헥사고날이든) 도메인에 책임을 위임하든 말든, 정답이 따로 있는 게 아니라는 거다. 다 상황마다 트레이드오프가 있다. 아키텍처는 특히 팀 규모나 러닝커브도 걸린다. 헥사고날 같은 건 팀원 전부가 그 구조를 알아야 굴러가니까. 그러니 중요한 건 ‘더 깔끔한 거’나 ‘원래 그렇게 하니까’가 아니라, 그 트레이드오프를 알고 고르는 거였다.
사실 예전부터 선택에는 근거가 제일 중요하다고 생각해왔는데, 이번 주엔 그 근거가 깔끔함이나 익숙함이면 안 된다는 걸 좀 더 분명히 알았다.
앞으로 책임을 어디 둘지, 추상화를 하나 넣을지 정할 때, ‘이게 더 깔끔한가’ 말고 ‘여기 담을 게 있나’, ‘이게 정말 여기 둘 게 맞나’, ‘뭘 내주고 뭘 얻는 선택인가’부터 보자.
댓글남기기