서문
이 책은 누구를 위한 책인가?
- 대상 독자: 새로운 도구를 도구 벨트에 추가하고 싶은 경력 있는 소프트웨어 개발자
- 다음 중 하나에 해당하면 추천되는 책
- 타입과 함수만으로 도메인을 모델링/구현하는 방법이 궁금한 사람
- 도메인 주도 설계(DDD)를 객체지향 설계나 DB 우선 설계와 비교하며 쉽게 배우고 싶은 사람
- 이미 DDD 실무자이며, DDD가 함수형 프로그래밍과 왜 잘 맞는지 알고 싶은 사람
- 이론과 추상화에 지쳐 실용적인 함수형 프로그래밍 입문서를 원하는 사람
- F#과 함수형 프로그래밍이 실제 도메인에 어떻게 적용되는지 보고 싶은 사람
- 사전 지식: DDD나 함수형 프로그래밍 경험이 없어도 무방함 (입문서 성격이며 필요한 개념은 그때그때 설명)
이 책에는 무엇이 담겨 있는가?
- 책 전체는 3부 구성이며, 각 파트는 이전 파트를 기반으로 하므로 순서대로 읽는 것을 권장
- 파트 1: 도메인 이해하기 (Understanding the Domain)
- 파트 2: 도메인 모델링하기 (Modeling the Domain)
- 파트 3: 모델 구현하기 (Implementing the Model)
파트 1 (도메인 이해하기)
- DDD의 배경 개념과 도메인에 대한 공유된 이해의 중요성을 다룸
- Event Storming 같은 공유 이해 구축 기법을 간략히 살펴봄
- 큰 도메인을 독립적으로 구현/진화 가능한 작은 컴포넌트로 분해하는 방법을 다룸
- DDD 전체를 철저히 다루는 책은 아니며, 다음 두 가지를 강조
- (a) 도메인 전문가 및 비개발 팀원과의 소통 중요성
- (b) 실세계 개념에 기반한 공유 도메인 모델의 가치
파트 2 (도메인 모델링하기)
- 도메인에서 워크플로우 하나를 골라 함수형 방식으로 모델링
- 객체지향적 분해 방식과 함수적 분해 방식의 차이를 비교
- 타입으로 요구사항을 표현하는 방법을 학습
- 결과물: 도메인의 읽기 쉬운 문서 역할을 하면서, 동시에 컴파일 가능한 구현 프레임워크가 되는 코드
파트 3 (모델 구현하기)
- 같은 워크플로우를 실제로 구현
- 합성(composition), 부분 적용(partial application), “모나드(monad)” 같은 함수형 기법 학습
- 완전한 함수형 프로그래밍 가이드는 아니며, 도메인 구현에 필요한 만큼만 다룸
- 마지막 챕터에서는 요구사항 변화에 따라 설계가 어떻게 진화할 수 있는지 다룸
도메인 모델링에 대한 다른 접근법
- 이 책은 데이터 구조를 정의하고 그 위에서 동작하는 함수를 정의하는 “주류(mainstream)” 방식에 집중
- 다른 상황에 더 적합할 수 있는 접근법 2가지를 소개(깊이 다루지는 않음)
- 반구조적(semistructured) 데이터 중심 도메인 → 이 책에서 다루는 경직된 모델이 부적합할 수 있으며, 맵/딕셔너리 같은 유연한 구조가 더 나은 선택 (Clojure 커뮤니티가 이 부분에 강함)
- 원소를 조합해 다른 원소를 만드는 것이 핵심인 도메인 → 데이터보다 “조합 규칙(대수, algebra)“에 먼저 집중하는 것이 유용. 금융 계약, 그래픽 디자인 툴 등 “everywhere composition” 도메인은 함수형 접근에 특히 적합하지만, 지면 관계상 본문에서는 다루지 않음
파트 1 도메인 이해하기
- 이 파트에서는 DDD의 배경이 되는 아이디어와, 도메인에 대한 공유된 이해가 왜 중요한지를 다룸
- 이런 공유된 이해를 구축하는 데 도움이 되는 기법으로 Event Storming을 간략히 살펴봄
- 이후 큰 도메인을 독립적으로 구현/진화 가능한 더 작은 컴포넌트로 분해하는 방법을 살펴봄
챕터 1 도메인 주도 설계 소개
- 개발자의 역할에 대한 핵심 주장: 개발자의 일은 단순히 “코드를 작성하는 것”이 아니라 소프트웨어를 통해 문제를 해결하는 것이며, 코딩은 그 일부일 뿐임. 좋은 설계와 커뮤니케이션이 그만큼(혹은 그 이상) 중요함
- 소프트웨어 개발을 입력(요구사항)과 출력(최종 산출물)이 있는 파이프라인으로 비유하면 “Garbage in, garbage out” 원칙이 적용됨 → 입력(불분명한 요구사항, 나쁜 설계)이 나쁘면 아무리 좋은 코딩 실력으로도 좋은 결과물을 만들 수 없음
- 이 책 파트 1의 목표: 명확한 커뮤니케이션과 공유된 도메인 지식에 초점을 맞춘 설계 접근법인 **도메인 주도 설계(Domain-Driven Design, DDD)**를 통해 “garbage in”을 최소화하는 방법을 다룸
- DDD는 매우 큰 주제이므로 이 챕터에서 깊이 있게 다루지는 않음(자세한 내용은 dddcommunity.org 참고). 이 챕터를 마치면 DDD가 어떻게 작동하는지, 그리고 DB 주도 설계 및 객체지향 설계와 어떻게 다른지 감을 잡을 수 있는 것이 목표
- DDD가 모든 소프트웨어 개발에 적합한 것은 아님 (시스템 소프트웨어, 게임 등은 다른 접근이 더 나을 수 있음). 다만 개발자가 비개발 팀과 협업해야 하는 비즈니스/엔터프라이즈 소프트웨어에 특히 유용하며, 이것이 이 책의 초점
1.1 공유 모델의 중요성
- 문제를 풀기 전에 문제를 정확히 이해하는 것이 먼저다. 이해가 불완전하거나 왜곡되면 유용한 해결책을 제공할 수 없음
- 안타깝게도 실제 프로덕션에 반영되는 것은 도메인 전문가의 이해가 아니라 개발자의 이해임
- 그렇다면 개발자가 문제를 제대로 이해하고 있다는 것을 어떻게 보장할 수 있을까?
접근 방식 1: 문서(명세서) 기반 접근
- 일부 개발 프로세스는 문서화된 명세/요구사항 문서로 문제의 모든 세부사항을 포착하려 시도
- 문제는, 이 접근이 문제를 가장 잘 아는 사람(도메인 전문가)과 솔루션을 구현할 사람(개발팀: 개발자뿐 아니라 UX/UI 디자이너, 테스터 등 포함) 사이에 거리를 만든다는 것
flowchart LR
A["도메인 전문가"] --> B["비즈니스 분석가"]
B --> C["요구사항 문서"]
C --> D["아키텍트"]
D --> E["설계 문서"]
E --> F["개발팀"]
F --> G["코드"]
- 이 과정은 어린이 놀이 “전화(Telephone)” 게임에 비유됨: 메시지가 사람을 거칠수록 점점 왜곡됨
- 실제 개발 프로젝트에서는 전혀 재미있는 일이 아니며, 개발자의 이해와 도메인 전문가의 이해 사이의 불일치는 프로젝트 성공에 치명적일 수 있음
접근 방식 2: 반복적(애자일) 피드백 루프
- 더 나은 해결책은 중간 단계를 없애고, 도메인 전문가를 개발 프로세스에 긴밀히 참여시켜 개발팀과 도메인 전문가 사이에 피드백 루프를 만드는 것
- 개발팀이 도메인 전문가에게 정기적으로 무언가를 전달하면, 도메인 전문가는 다음 이터레이션을 위해 오해를 빠르게 바로잡을 수 있음
flowchart LR
A["도메인 전문가"] <--> B["개발팀"]
B --> C["코드"]
C --> D["산출물(deliverable)"]
D --> A
- 이런 반복적 프로세스가 “애자일” 개발 프로세스의 핵심
- 그러나 이 방식도 한계가 있음: 개발자가 도메인 전문가의 멘탈 모델을 코드로 옮기는 번역가 역할을 하게 되는데, 모든 번역이 그렇듯 왜곡과 미묘한 뉘앙스의 손실이 발생할 수 있음. 코드가 도메인 개념과 정확히 대응하지 않으면, 도메인 전문가의 입력 없이 코드베이스를 다루는 후속 개발자들이 오해하여 오류를 만들기 쉬움
접근 방식 3 (DDD): 공유 멘탈 모델
- 세 번째 접근법: 도메인 전문가, 개발팀, 다른 이해관계자, 그리고 (가장 중요하게) 소스 코드 자체가 모두 동일한 모델을 공유한다면 어떨까?
- 이 경우 도메인 전문가의 요구사항에서 코드로의 “번역”이 존재하지 않음. 코드는 공유된 멘탈 모델을 직접 반영하도록 설계됨
- 이것이 바로 도메인 주도 설계(DDD)의 목표
flowchart TD
M(("공유 멘탈 모델"))
A["도메인 전문가"] <--> M
B["개발팀"] <--> M
C["기타 이해관계자"] <--> M
D["코드"] <--> M
공유 모델 정렬의 이점
| 이점 | 설명 |
|---|---|
| 빠른 시장 출시 (Faster time to market) | 개발자와 코드베이스가 문제를 가진 사람과 동일한 모델을 공유하면 팀이 적절한 솔루션을 더 빨리 개발할 가능성이 높음 |
| 더 큰 비즈니스 가치 (More business value) | 문제와 정확히 정렬된 솔루션은 고객 만족도를 높이고 방향성을 잃을 가능성을 줄임 |
| 낭비 감소 (Less waste) | 명확한 요구사항은 오해와 재작업 시간을 줄이며, 어떤 컴포넌트가 고가치인지 드러나 개발 노력을 그곳에 집중할 수 있게 함 |
| 유지보수와 진화 용이성 (Easier maintenance and evolution) | 코드가 표현하는 모델이 도메인 전문가의 모델과 밀접하게 일치하면 코드 변경이 더 쉽고 오류가 적음. 신규 팀원의 온보딩 속도도 빨라짐 |
콜아웃: 인상적으로 효과적인 딜리버리 머신
Behavior-Driven Development의 창시자로 잘 알려진 Dan North는 자신의 강연 “Accelerating Agile”에서 소규모 트레이딩 회사 팀에서의 경험을 소개했음. 그는 그 팀을 자신이 경험한 것 중 가장 인상적으로 효과적인 딜리버리 머신이라고 묘사했으며, 그곳에서는 소수의 프로그래머가 몇 주 만에 최첨단 트레이딩 시스템을 만들어냈음. 이 팀 성공의 한 가지 이유는 개발자들이 실제 트레이더들과 함께 트레이더로 훈련받아 스스로 도메인 전문가가 되었다는 점임. 그 결과 공유된 멘탈 모델 덕분에 트레이더들과 매우 효과적으로 소통할 수 있었고, 도메인 전문가(트레이더)가 원하는 것을 정확히 구현할 수 있었음
- 공유 모델을 어떻게 만들 것인가? DDD 커뮤니티가 정리한 4가지 가이드라인이 이 챕터 나머지 부분의 뼈대가 됨
- 데이터 구조가 아니라 비즈니스 이벤트와 워크플로우에 집중한다
- 문제 도메인을 더 작은 서브도메인으로 분할한다
- 솔루션에서 각 서브도메인의 모델을 만든다
- 프로젝트에 관련된 모두가 공유하고 코드 전반에서 사용하는 공통 언어, 즉 **“유비쿼터스 언어”**를 개발한다
1.2 비즈니스 이벤트를 통해 도메인 이해하기
- DDD 접근법은 개발자와 도메인 전문가 사이의 공유된 이해를 구축하는 것을 강조함. 그렇다면 어디서부터 시작해야 하는가?
- 첫 번째 가이드라인은 데이터 구조가 아니라 비즈니스 이벤트에 집중하라는 것
- 이유: 비즈니스는 단순히 데이터를 가지고 있는 것이 아니라 데이터를 어떤 식으로든 변환함. 즉 전형적인 비즈니스 프로세스는 데이터/문서 변환의 연속으로 볼 수 있음. 비즈니스의 가치는 이 변환 과정에서 창출되므로, 변환이 어떻게 작동하고 서로 어떻게 관계 맺는지 이해하는 것이 매우 중요함
- 아무도 사용하지 않고 그냥 놓여 있는 정적 데이터는 아무 가치도 만들어내지 않음. 그렇다면 무엇이 직원(또는 자동화 프로세스)으로 하여금 그 데이터를 다루며 가치를 더하게 만드는가?
- 외부 트리거: 우편물 도착, 전화벨 등
- 시간 기반 트리거: 매일 오전 10시에 어떤 작업을 수행
- 관찰 기반 트리거: 수신함에 처리할 주문이 더 없으므로 다른 작업으로 전환
- 무엇이 되었든 이런 트리거를 설계에 포착하는 것이 중요함 → 이를 **도메인 이벤트(Domain Event)**라 부름
- 도메인 이벤트는 우리가 모델링하려는 거의 모든 비즈니스 프로세스의 출발점. 예: “주문서 접수됨”은 주문 접수 프로세스를 시작시키는 도메인 이벤트
- ==도메인 이벤트는 항상 과거형으로 작성됨== — 이미 일어난, 바꿀 수 없는 사실이기 때문
1.2.1 이벤트 스토밍으로 도메인 발견하기
- 도메인에서 이벤트를 발견하는 방법은 여러 가지지만, DDD 접근에 특히 적합한 것이 이벤트 스토밍(Event Storming) — 비즈니스 이벤트와 그에 연관된 워크플로우를 발견하기 위한 협업 프로세스
- 진행 방식
- 도메인의 서로 다른 부분을 이해하는 다양한 사람들을 모아 촉진자가 이끄는 워크숍을 진행
- 참가자는 개발자와 도메인 전문가뿐 아니라, 프로젝트 성공에 이해관계가 있는 다른 모든 사람(UX/UI 디자이너, 테스터 등)을 포함해야 함 — 이벤트 스토머들은 이를 “질문이 있는 사람과 답이 있는 사람 모두”라고 표현함
- 워크숍은 벽면이 넓은 방에서 진행하며, 참가자들이 스티키 노트를 붙이거나 그릴 수 있도록 벽을 종이나 화이트보드 재질로 덮어야 함
- 성공적인 세션이 끝나면 벽은 수백 장의 스티키 노트로 뒤덮임
- 참가자들은 스티키 노트에 비즈니스 이벤트를 적어 벽에 붙이고, 다른 사람들은 그 이벤트가 촉발하는 비즈니스 워크플로우를 요약한 노트로 응답함
- 이 워크플로우들은 다시 다른 비즈니스 이벤트를 만들어내는 경우가 많음
- 노트들은 종종 타임라인으로 정리되며 추가 논의를 촉발함
- 목표는 모든 참가자가 자신이 아는 것을 게시하고, 모르는 것을 질문하며 적극적으로 참여하게 하는 것
- 이벤트 스토밍에 대한 자세한 내용은 이 기법의 창시자인 Alberto Brandolini의 저서 “EventStorming”을 참고할 것
1.2.2 도메인 발견하기: 주문 접수 시스템
-
이 책 전체에서 사용할 예제 도메인: 소규모 제조업체 Widgets Inc의 주문 접수 워크플로우 자동화
-
배경 (매니저 Max의 설명 요지)
- 다른 회사에 부품(위젯, 기즈모 등)을 제조해 공급하는 작은 회사로, 빠르게 성장하고 있지만 기존 프로세스가 물량 증가를 따라가지 못함
- 현재 모든 프로세스가 종이 기반이며, 이를 전산화해 더 큰 물량을 처리하고 싶어함
- 특히 고객이 주문, 주문 상태 확인 등의 작업을 직접 처리할 수 있는 셀프서비스 웹사이트를 원함
-
이벤트 스토밍 세션 진행 과정 요약 (등장인물: Ollie=주문접수부서, Sam=배송부서, Blake=청구부서, Max=매니저)
- Ollie: 주문접수부서는 고객이 우편으로 보내오는 주문서/견적서를 주로 처리한다고 설명 → 이벤트 후보로 “주문서 접수됨”, “견적서 접수됨” 제안
- Sam: 배송부서는 승인 완료된 주문을 이행하며, 주문접수부서로부터 주문을 받을 때 작업이 시작된다고 설명. 처음에는 “주문 이용 가능”이라는 이벤트명을 제안했으나, Ollie가 완료되어 배송 준비된 주문을 “배치된 주문(Placed order)“이라 부른다고 하면서 팀 간 용어를 **“주문 배치됨(Order placed)”**으로 통일하자고 제안 → 이 짧은 상호작용은 이벤트 스토밍 과정에서 팀 간 용어가 어떻게 하나의 언어로 수렴해 가는지(유비쿼터스 언어의 씨앗)를 잘 보여줌
-
이렇게 발견된 이벤트 목록 예시
- 주문서 접수됨 (Order form received)
- 주문 배치됨 (Order placed)
- 주문 배송됨 (Order shipped)
- 주문 변경 요청됨 (Order change requested)
- 주문 취소 요청됨 (Order cancellation requested)
- 반품 요청됨 (Return requested)
- 견적서 접수됨 (Quote form received)
- 견적 제공됨 (Quote provided)
- 신규 고객 요청 접수됨 (New customer request received)
- 신규 고객 등록됨 (New customer registered)
-
일부 이벤트 옆에는 “Place Order(주문하기)”, “Ship Order(배송하기)” 같은 비즈니스 워크플로우가 함께 게시되며, 이벤트들이 더 큰 워크플로우로 어떻게 연결되는지가 서서히 드러남
-
세션 전체를 다 다룰 수는 없지만, 이벤트 스토밍이 요구사항 수집에서 촉진하는 측면들을 살펴보면 다음과 같음
-
비즈니스의 공유 모델
- 이벤트를 발견하는 것 외에도, 모든 참가자가 같은 벽을 바라보며 비즈니스에 대한 공유된 이해를 형성한다는 것이 핵심 이점
- DDD와 마찬가지로 이벤트 스토밍도 커뮤니케이션과 공유 모델, 그리고 “우리 vs 그들” 식의 사고를 피하는 것을 강조함
- 참가자들은 익숙하지 않은 도메인 영역을 배울 뿐 아니라, 다른 팀에 대한 자신의 가정이 틀렸음을 깨닫거나 비즈니스 개선에 도움이 되는 통찰을 얻기도 함
-
모든 팀에 대한 인식
- 자신이 관여하는 한 측면에만 집중하다 보면 다른 팀도 관여하고 있으며 자신이 만드는 데이터를 소비해야 할 수 있다는 사실을 잊기 쉬움
- 모든 이해관계자가 한 자리에 있으면, 소외될 수 있는 팀도 직접 목소리를 낼 수 있음
- 예: 청구부서의 Blake가 자신들도 완료된 주문에 대해 알아야 청구를 하고 회사를 위해 돈을 벌 수 있다고 지적하며, 청구부서를 위한 “주문 배치됨” 이벤트도 필요하다고 언급
-
요구사항의 공백 발견
- 이벤트를 벽에 타임라인으로 배치하면 누락된 요구사항이 매우 명확하게 드러나는 경우가 많음
- 예: 매니저 Max가 Ollie에게 주문 준비가 끝난 뒤 고객에게 알리는지 묻자, Ollie는 그제서야 주문이 성공적으로 배치되면 고객에게 접수 확인 및 곧 배송한다는 이메일을 보낸다는 사실을 떠올리고 “고객에게 확인 메일 발송됨” 이벤트를 추가함
- 명확한 답이 없는 질문은 질문 자체를 벽에 게시해 추가 논의의 트리거로 삼아야 함
- 논쟁이나 이견이 생기는 부분은 문제가 아니라 기회로 여겨야 함 — 그런 영역을 파고들면 많은 것을 배울 수 있음
- 프로젝트 초기에는 요구사항이 흐릿한 경우가 흔하므로, 이런 질문과 논쟁을 눈에 보이게 문서화하면 아직 할 일이 남아 있음이 명확해지고 개발 프로세스가 성급하게 시작되는 것을 막아줌
-
팀 간 연결
- 이벤트를 타임라인으로 그룹화하면 한 팀의 출력이 다른 팀의 입력이라는 사실이 명확해지는 경우가 많음
- 예: 주문접수팀이 주문 처리를 마치면 새 주문이 배치되었음을 알려야 하며, 이 “주문 배치됨” 이벤트는 배송팀과 청구팀 모두의 입력이 됨
flowchart LR
subgraph OT["주문접수팀 컨텍스트"]
A["주문서 접수됨"] --> W1[["주문하기 워크플로우"]]
W1 --> B["주문 배치됨"]
W1 --> C["고객에게 확인 메일 발송됨"]
end
subgraph SH["배송팀 컨텍스트"]
B --> W2[["배송하기 워크플로우"]]
W2 --> D["주문 배송됨"]
W2 --> E["고객에게 발송 메시지 전달됨"]
end
- 이 단계에서 팀들이 기술적으로 어떻게 연결되는지(메시지 큐 vs 데이터베이스 등)는 중요하지 않음. 도메인 자체에 집중하는 것이 목표
- 보고 요구사항에 대한 인식
- 프로세스와 트랜잭션에만 집중하다 보면 놓치기 쉽지만, 모든 비즈니스는 과거에 무슨 일이 있었는지 이해해야 함 — 보고(reporting)도 항상 도메인의 일부
- 이벤트 스토밍 세션에는 보고 및 다른 읽기 전용 모델(예: UI용 뷰 모델)도 반드시 포함해야 함
1.2.3 이벤트를 경계까지 확장하기
- 이벤트 체인을 시스템의 경계까지 최대한 따라가 보는 것이 유용함. 먼저 가장 왼쪽 이벤트보다 앞서 일어나는 이벤트가 있는지 물어볼 수 있음
- Ollie와의 대화 요지: “주문서 접수됨” 이벤트는 무엇이 촉발하는가? → 매일 아침 우편물을 열어 고객이 보낸 종이 주문서를 확인하고 주문/견적으로 분류한다는 답변 → 여기서 “우편물 접수됨” 이벤트와, 이를 주문/견적으로 분류하는 워크플로우가 추가로 필요함이 드러남
콜아웃: 워크플로우, 시나리오, 유스케이스
비즈니스 활동을 가리키는 여러 용어(“workflow”, “scenario”, “use case”, “process”)는 종종 혼용되지만, 이 책에서는 다음과 같이 조금 더 정밀하게 구분해서 사용함
| 용어 | 정의 | 초점 |
|---|---|---|
| 시나리오 (Scenario) | 고객(또는 다른 사용자)이 달성하고자 하는 목표(예: 주문하기). 애자일의 “스토리”와 유사 | 사용자 중심 |
| 유스케이스 (Use case) | 시나리오의 더 상세한 버전으로, 목표 달성을 위한 사용자 상호작용과 단계를 일반적인 용어로 서술 | 사용자 중심 |
| 비즈니스 프로세스 (Business process) | 개별 사용자가 아니라 비즈니스가 달성하고자 하는 목표. 시나리오와 유사하지만 비즈니스 중심 | 비즈니스 중심 |
| 워크플로우 (Workflow) | 비즈니스 프로세스의 일부에 대한 상세한 설명. 직원(또는 소프트웨어 컴포넌트)이 비즈니스 목표/서브목표를 달성하기 위해 수행해야 할 정확한 단계들의 나열. 한 사람이나 한 팀이 할 수 있는 범위로 한정하여, 여러 팀에 걸친 비즈니스 프로세스(예: 주문 처리)를 여러 개의 작은 워크플로우로 나누고 이를 조율함 | 팀/개인 단위 실행 |
- Sam과의 대화 요지: 주문을 고객에게 배송한 뒤에도 이벤트가 있는가? → 주문이 “서명 후 배송 완료” 상태가 되면 택배사로부터 알림을 받는다는 답변 → “고객이 배송물에 서명함” 이벤트가 추가됨
- 이렇게 양방향으로 이벤트를 확장하는 것은 누락된 요구사항을 찾는 좋은 방법이며, 이벤트 체인은 예상보다 훨씬 길어질 수 있음
flowchart LR
M["우편물 접수됨"] --> Q{"주문인가 견적인가?"}
Q --> A["주문서 접수됨"]
Q --> Q2["견적서 접수됨"]
subgraph OT["주문접수팀 컨텍스트"]
A --> W1[["주문하기"]]
W1 --> B["주문 배치됨"]
W1 --> C["확인 메일 발송됨"]
end
subgraph SH["배송팀 컨텍스트"]
B --> W2[["배송하기"]]
W2 --> D["주문 배송됨"]
W2 --> E["발송 메시지 전달됨"]
D --> F["고객 서명 후 배송 완료"]
end
- 여기서 도메인 전문가는 종이 양식과 인쇄된 우편물을 이야기하고 있음에 주목할 것. 앞으로 만들 시스템은 전산화되겠지만, 종이 기반 시스템을 워크플로우/우선순위/엣지 케이스 관점에서 생각해 보는 것만으로도 많은 것을 배울 수 있음
- 실제로 많은 비즈니스 프로세스에서 종이 vs 디지털의 구분은 무관함 — 도메인의 고수준 개념을 이해하는 것은 특정 구현 방식에 전혀 의존하지 않음 (예: 회계 도메인의 개념과 용어는 수백 년간 거의 변하지 않음)
- 종이 기반 시스템을 전산화할 때도 전체를 한 번에 전환할 필요는 없음. 시스템 전체를 조망한 뒤, 가장 큰 이득을 볼 수 있는 부분부터 전환하는 것이 바람직함
1.2.4 커맨드 문서화하기
- 벽에 이런 이벤트들을 붙이고 나면 “무엇이 이 도메인 이벤트를 일으켰는가?“를 물을 수 있음 — 누군가 또는 무언가가 어떤 활동이 일어나기를 원함 (예: 고객이 주문서 접수를 원함, 상사가 어떤 일을 해달라고 요청함)
- DDD 용어로 이런 요청을 **커맨드(Command)**라 부름(객체지향 프로그래밍의 Command 패턴과 혼동하지 말 것). ==커맨드는 항상 명령형으로 작성됨== (“이것을 해줘”)
- 모든 커맨드가 성공하는 것은 아님(주문서가 우편에서 분실될 수도, 더 급한 일 때문에 상사를 도울 여유가 없을 수도 있음). 하지만 커맨드가 성공하면 워크플로우가 시작되고, 그 결과로 대응하는 도메인 이벤트가 만들어짐
- 커맨드와 이벤트의 대응 예시
- 커맨드 “X가 일어나게 하라” → (성공 시) 이벤트 “X가 일어났다”
- 커맨드 “Widgets Inc에 주문서를 보내라” → 이벤트 “주문서 발송됨”
- 커맨드 “주문하기(Place an order)” → 이벤트 “주문 배치됨(Order placed)”
- 커맨드 “고객 ABC에게 배송물을 보내라” → 이벤트 “배송물 발송됨(Shipment sent)”
// 커맨드 이름: 항상 명령형
type PlaceOrder = PlaceOrder of OrderForm
// 도메인 이벤트 이름: 항상 과거형
type OrderPlaced = OrderPlaced of Order
// 커맨드 이름: 항상 명령형
interface PlaceOrder {
kind: "PlaceOrder";
orderForm: OrderForm;
}
// 도메인 이벤트 이름: 항상 과거형
interface OrderPlaced {
kind: "OrderPlaced";
order: Order;
}
- 이 책은 대부분의 비즈니스 프로세스를 다음 패턴으로 모델링함: 이벤트가 커맨드를 촉발 → 커맨드가 비즈니스 워크플로우를 시작 → 워크플로우의 출력은 (추가) 이벤트들의 목록 → 그 이벤트들이 다시 다른 커맨드를 촉발할 수 있음
flowchart LR
E["이벤트"] -- "triggers" --> C["커맨드"]
C --> W[["비즈니스 워크플로우"]]
W --> E1["이벤트"]
W --> E2["이벤트"]
W --> E3["이벤트"]
- 입력과 출력이 있는 파이프라인으로 비즈니스 프로세스를 생각하는 이 방식은 함수형 프로그래밍이 작동하는 방식과 매우 잘 맞음(이후 챕터들의 핵심 아이디어)
- 이 접근을 주문 접수 프로세스에 적용하면 다음과 같음: “주문서 접수됨” 이벤트가 “주문하기” 커맨드를 촉발하고, “주문 배치” 워크플로우(입력: 주문 배치에 필요한 데이터)가 실행되어, 배치된 주문으로부터 발생하는 이벤트 목록(배송용 “주문 배치됨”, 청구용 “주문 배치됨”)이 출력됨
flowchart LR
A["주문서 접수됨"] -- "triggers" --> B["주문하기 커맨드"]
B --> W[["주문 배치 워크플로우"]]
W --> C["주문 배치됨 - 배송용"]
W --> D["주문 배치됨 - 청구용"]
// 워크플로우를 "커맨드를 받아 이벤트 목록을 반환하는 함수"로 표현
type Workflow<'Command, 'Event> = 'Command -> 'Event list
type PlaceOrderWorkflow = PlaceOrder -> OrderPlaced list
// 워크플로우를 "커맨드를 받아 이벤트 목록을 반환하는 함수"로 표현
type Workflow<Command, Event> = (command: Command) => Event[];
type PlaceOrderWorkflow = (command: PlaceOrder) => OrderPlaced[];
- 지금 단계에서는 모든 커맨드가 성공하고 대응 이벤트가 발생한다고 가정함 (이후 챕터 10 “구현: 에러 다루기”에서 커맨드가 실패하는 경우를 어떻게 모델링하는지 다룸)
- 모든 이벤트가 커맨드와 연관되어야 하는 것은 아님 — 일부 이벤트는 스케줄러나 모니터링 시스템에 의해 촉발될 수 있음 (예: 회계 시스템의 월말 마감(MonthEndClose), 창고 시스템의 재고 소진(OutOfStock))
1.3 도메인을 서브도메인으로 분할하기
- 지금까지 이벤트와 커맨드 목록을 확보했고 다양한 비즈니스 프로세스에 대한 이해도 생겼지만, 전체 그림은 여전히 상당히 혼란스러움 — 코드를 작성하기 전에 이를 길들여야 함
- 두 번째 가이드라인: “문제 도메인을 더 작은 서브도메인으로 분할하라.” 큰 문제에 직면하면 별도로 다룰 수 있는 더 작은 컴포넌트로 나누는 것이 자연스러움
- 주문 접수 프로세스의 여러 측면(주문 접수, 배송, 청구 등)은 분리 가능함. 비즈니스에 이미 이런 영역에 대응하는 별도 부서가 존재한다는 사실은, 설계에서도 동일한 구분을 따를 수 있다는 강력한 힌트임 — 이런 각 영역을 **도메인(domain)**이라 부름
- “도메인”의 정의
- 사전적 정의: “일관된 지식의 영역(an area of coherent knowledge)” — 너무 모호해서 실용적이지 않음
- 실용적인 대안 정의: “도메인”이란 곧 “도메인 전문가”가 전문적으로 아는 것임. 예를 들어 “청구(billing)“란 청구부서 사람들(도메인 전문가들)이 하는 일이라고 말하는 것이 훨씬 편리함
- 개발자 스스로도 여러 도메인의 전문가일 수 있음 — 특정 프로그래밍 언어, 게임/과학 프로그래밍 같은 특정 분야, 보안/네트워킹/저수준 최적화 등 — 이런 것들도 모두 “도메인”임
- 도메인 안에는 구별되는 영역이 존재할 수 있으며, 이를 서브도메인(subdomain) — 자신만의 전문 지식을 가진, 더 큰 도메인의 작은 부분 — 이라 부름
- 예: “웹 프로그래밍”은 “일반 프로그래밍”의 서브도메인이고, “자바스크립트 프로그래밍”은 웹 프로그래밍의 서브도메인(적어도 예전에는 그랬음)
flowchart TD
subgraph GP["일반 프로그래밍"]
subgraph WP["웹 프로그래밍"]
JS["자바스크립트 프로그래밍"]
CSS["CSS"]
end
subgraph RP["루비 프로그래밍"]
RoR["Ruby on Rails"]
end
end
WD["웹 디자인"] --- CSS
- 도메인은 서로 겹칠 수 있음: 예를 들어 “CSS” 서브도메인은 “웹 프로그래밍” 도메인의 일부로도, “웹 디자인” 도메인의 일부로도 볼 수 있음(위 다이어그램에서 CSS가 두 영역에 걸쳐 있는 것으로 표현)
- 그러므로 도메인을 더 작은 부분으로 나눌 때는 주의해야 함: 명확하고 깔끔한 경계를 원하기 쉽지만, 실세계는 그보다 훨씬 애매함(fuzzy)
- 이 도메인 분할 접근법을 주문 접수 시스템에 적용하면 다음과 같음
flowchart LR
OT(("주문접수 도메인"))
SH(("배송 도메인"))
BI(("청구 도메인"))
OT --- SH
SH --- BI
BI --- OT
- 세 도메인은 약간씩 겹침: 주문접수자는 청구/배송 부서가 어떻게 일하는지 조금은 알아야 하고, 배송담당자는 주문접수/청구를 조금은 알아야 하는 식
- 효과적인 솔루션을 개발하려면 개발자 스스로도 어느 정도 도메인 전문가가 되어야 함 — 즉 위 도메인들을 지금까지보다 더 깊이 이해하려는 노력이 필요함. 다만 이는 잠시 미뤄두고 다음으로 솔루션을 만드는 가이드라인으로 넘어감
1.4 바운디드 컨텍스트로 솔루션 만들기
- 문제를 이해했다고 해서 솔루션을 만드는 것이 쉬운 일은 아님. 솔루션은 원본 도메인의 모든 정보를 표현할 수 없고, 그럴 필요도 없음 — 특정 문제 해결에 관련된 정보만 포착해야 하며 나머지는 무관함
- 그래서 **“문제 공간(problem space)”**과 **“솔루션 공간(solution space)”**을 구분해야 하며, 이 둘은 서로 다른 것으로 취급되어야 함
- 솔루션을 만들기 위해서는 문제 도메인의 **모델(model)**을 만듦 — 관련 있는 측면만 추출해 솔루션 공간에서 다시 만들어냄
flowchart LR
subgraph SPACE1["문제 공간 - 실세계"]
direction TB
D1(("주문접수 도메인"))
D2(("배송 도메인"))
D3(("청구 도메인"))
end
subgraph SPACE2["솔루션 공간 - 도메인 모델"]
direction TB
C1["주문접수 컨텍스트"]
C2["배송 컨텍스트"]
C3["청구 컨텍스트"]
end
SPACE1 -- "설계 프로세스" --> SPACE2
- 솔루션 공간에서 문제 공간의 도메인/서브도메인은 DDD 용어로 바운디드 컨텍스트(bounded context) — 구현에서의 일종의 서브시스템 — 로 매핑됨. 각 바운디드 컨텍스트는 그 자체로 하나의 미니 도메인 모델
- “서브시스템(subsystem)” 대신 “바운디드 컨텍스트”라는 표현을 쓰는 이유: 솔루션을 설계할 때 정말 중요한 것 — 컨텍스트에 대한 인식과 경계에 대한 인식 — 에 계속 초점을 맞추게 해주기 때문
- 왜 **“컨텍스트(context)”**인가: 각 컨텍스트는 솔루션 내에서 특화된 지식을 나타내기 때문. 컨텍스트 내부에서는 공통 언어를 공유하며 설계가 일관되고 통일됨. 다만 실세계와 마찬가지로, 맥락에서 벗어난 정보는 혼란스럽거나 쓸모없어질 수 있음
- 왜 **“경계가 있는(bounded)”**인가: 실세계에서는 도메인 경계가 애매하지만, 소프트웨어 세계에서는 별도 서브시스템들이 독립적으로 진화할 수 있도록 서로 간의 결합(coupling)을 줄이고자 함. 명시적 API를 두고 공유 코드 같은 의존성을 피하는 등 표준 소프트웨어 관행으로 이를 달성함. 그 결과 도메인 모델이 실세계만큼 풍부하지는 못하지만, 그 대가로 복잡성 감소와 유지보수 용이성을 얻음
- 문제 공간의 도메인과 솔루션 공간의 컨텍스트가 항상 1:1로 대응하는 것은 아님
- 때로는 하나의 도메인이 여러 바운디드 컨텍스트로 나뉘기도 함
- 더 흔하게는, 문제 공간의 여러 도메인이 솔루션 공간의 단 하나의 바운디드 컨텍스트로 모델링됨 — 특히 레거시 소프트웨어 시스템과 통합해야 할 때 흔함
- 예: 만약 Widgets Inc가 이미 주문 접수와 청구를 하나의 시스템에서 함께 처리하는 소프트웨어 패키지를 갖고 있었다면, 이 레거시 시스템과 통합할 때는 두 도메인을 아우르더라도 이를 단일 바운디드 컨텍스트로 취급해야 할 수 있음
- 어떻게 분할하든, 각 바운디드 컨텍스트는 명확한 책임을 가져야 함 — 모델을 구현할 때 바운디드 컨텍스트는 정확히 어떤 종류의 소프트웨어 컴포넌트에 대응하게 되기 때문. 그 컴포넌트는 별도의 DLL, 독립적인 서비스, 또는 단순한 네임스페이스로 구현될 수 있음 — 구현 세부사항은 지금 중요하지 않지만, 분할을 올바르게 하는 것은 중요함
1.4.1 컨텍스트를 올바르게 설정하기
- 바운디드 컨텍스트를 정의하는 것은 말로는 쉬워 보이지만 실제로는 까다로울 수 있음. 컨텍스트 경계를 올바르게 정하는 것은 DDD의 가장 중요한 도전 중 하나이며, 이는 과학이 아니라 **예술(art, not a science)**에 가까움. 도움이 되는 가이드라인은 다음과 같음
- 도메인 전문가의 말을 들어라. 모두가 같은 언어를 공유하고 같은 이슈에 집중한다면, 그들은 아마 같은 서브도메인(하나의 바운디드 컨텍스트에 대응)에서 일하고 있는 것
- 기존 팀/부서 경계에 주의를 기울여라. 이는 비즈니스가 무엇을 도메인/서브도메인으로 여기는지에 대한 강력한 단서. 다만 항상 참은 아님 — 같은 부서 사람들이 서로 다른 방향으로 일할 수도 있고, 반대로 다른 부서 사람들이 매우 긴밀히 협업하여 사실상 같은 도메인에서 일하고 있는 것일 수도 있음
- 바운디드 컨텍스트의 “경계가 있는(bounded)” 부분을 잊지 마라. 경계를 설정할 때 스코프 크리프(scope creep)를 경계해야 함. 요구사항이 계속 바뀌는 복잡한 프로젝트에서는 “경계가 있는” 부분을 지키는 데 단호해야 함. 너무 크거나 모호한 경계는 경계가 아닌 것과 마찬가지 — “좋은 담장이 좋은 이웃을 만든다”
- 자율성을 위해 설계하라. 두 그룹이 같은 바운디드 컨텍스트에 함께 기여하면, 설계가 진화하면서 서로 다른 방향으로 끌려갈 수 있음. 다리를 서로 묶은 2인3각 경주에 비유할 수 있음: 묶인 두 주자는 자유롭게 뛰는 두 주자보다 항상 느림. 도메인 모델도 마찬가지 — 모두를 만족시키려는 하나의 거대 컨텍스트보다, 독립적으로 진화할 수 있는 분리된 자율적 바운디드 컨텍스트를 갖는 것이 항상 더 나음
- 마찰 없는 비즈니스 워크플로우를 위해 설계하라. 어떤 워크플로우가 여러 바운디드 컨텍스트와 상호작용하면서 자주 막히거나 지연된다면, 설계가 “덜 예뻐지더라도(uglier)” 워크플로우를 매끄럽게 만들도록 컨텍스트를 리팩터링하는 것을 고려해야 함. 즉, “순수한(pure)” 설계보다 항상 비즈니스와 고객 가치를 우선해야 함
- 어떤 설계도 정적이지 않으며, 비즈니스 요구사항이 변함에 따라 모델도 진화해야 함(챕터 13 “설계를 진화시키고 깔끔하게 유지하기”에서 자세히 다룸)
1.4.2 컨텍스트 맵 만들기
- 컨텍스트들을 정의한 후에는, 설계 세부사항에 매몰되지 않으면서 그들 사이의 상호작용(큰 그림)을 전달할 방법이 필요함 — DDD 용어로 이 다이어그램을 **컨텍스트 맵(Context Map)**이라 부름
- 여행용 노선도(route map)에 비유할 수 있음: 노선도는 모든 세부사항을 보여주지 않고, 여정을 계획할 수 있도록 주요 노선만 보여줌
flowchart LR
Boston(("보스턴")) -- "United" --> London(("런던"))
London -- "United" --> Boston
Boston -- "Delta" --> Chicago(("시카고"))
NewYork(("뉴욕")) -- "Lufthansa" --> London
NewYork -- "United" --> Chicago
Paris(("파리")) -- "Air France" --> Chicago
- 이 다이어그램은 각 도시의 세부사항을 보여주지 않고, 도시 간 이용 가능한 노선만 보여줌. 지도의 유일한 목적은 항공편 계획을 돕는 것 — 뉴욕 시내에서 운전하는 것처럼 다른 무언가를 하고 싶다면 다른 지도(그리고 혈압약)가 필요할 것
- 같은 방식으로, 컨텍스트 맵은 다양한 바운디드 컨텍스트와 그들 사이의 관계를 고수준으로 보여줌. 모든 세부사항을 포착하는 것이 아니라 시스템 전체에 대한 관점을 제공하는 것이 목표
flowchart TD
Cust(("고객")) -- "주문/견적 접수" --> OTC["주문접수 컨텍스트"]
OTC -- "견적 반환" --> Cust
OTC -- "주문 배치됨" --> SHC["배송 컨텍스트"]
OTC -- "주문 배치됨" --> BIC["청구 컨텍스트"]
- 이 맵을 만들 때는 배송 컨텍스트의 내부 구조에는 관심이 없음 — 배송 컨텍스트가 주문접수 컨텍스트로부터 데이터를 받는다는 사실만 중요함. 이를 비공식적으로 배송 컨텍스트는 다운스트림(downstream), 주문접수 컨텍스트는 **업스트림(upstream)**이라고 표현함
- 두 컨텍스트는 교환하는 메시지에 대해 공유 포맷을 합의해야 함. 일반적으로 업스트림 컨텍스트가 포맷에 대해 더 많은 영향력을 갖지만, 때로는(레거시 시스템 작업 등) 다운스트림 컨텍스트가 유연하지 않을 수 있음 — 이 경우 업스트림 컨텍스트가 적응하거나, 중개자 역할을 하는 번역(translator) 컴포넌트가 필요할 수 있음(이는 이후 챕터의 “바운디드 컨텍스트 간의 계약”에서 더 자세히 다룸)
- 지금까지의 설계는 하나의 맵에 다 담을 수 있지만, 더 복잡한 설계에서는 특정 서브시스템에 초점을 맞춘 여러 개의 작은 맵을 만드는 것이 자연스러움
1.4.3 가장 중요한 바운디드 컨텍스트에 집중하기
- 지금까지 몇 개의 명백한 바운디드 컨텍스트를 확보했고, 도메인을 더 다루다 보면 더 많이 발견하게 될 것. 하지만 이들이 모두 동등하게 중요한가? 개발을 시작할 때 어디에 집중해야 하는가?
- 일반적으로 어떤 도메인은 다른 도메인보다 더 중요함
| 도메인 유형 | 설명 | Widgets Inc 예시 |
|---|---|---|
| 핵심 도메인 (Core domain) | 비즈니스 우위를 제공하고 수익을 창출하는 도메인 | 주문 접수, 배송 |
| 지원 도메인 (Supportive domain) | 필요하지만 핵심은 아닌 도메인 | 청구 |
| 일반 도메인 (Generic domain) | 비즈니스에 고유하지 않은 지원 도메인으로, 안전하게 아웃소싱할 수 있음 | 배송물의 실제 배달 |
- 다만 현실은 언제나 이렇게 단순하지 않음. 예를 들어 어떤 이커머스 비즈니스는 재고 보유와 즉시 배송 가능 여부가 고객 만족에 결정적이라고 판단해, 재고 관리를 사용하기 쉬운 웹사이트만큼이나 비즈니스 성공에 핵심적인 도메인으로 삼을 수 있음
- 때로는 어떤 도메인이 가장 중요한지에 대한 합의가 없을 수도 있음 — 각 부서는 자기 도메인이 가장 중요하다고 생각하는 경향이 있음. 그리고 때로는, 핵심 도메인은 단순히 클라이언트가 작업해달라고 요청하는 대상일 뿐일 수도 있음
- 어떤 경우든 우선순위를 정하고, 모든 바운디드 컨텍스트를 동시에 구현하려 시도하지 않는 것이 중요함 — 그런 시도는 실패로 이어지는 경우가 많음. 가장 큰 가치를 더하는 바운디드 컨텍스트에 먼저 집중한 다음, 거기서부터 확장해 나가야 함
1.5 유비쿼터스 언어 만들기
- 앞서 코드와 도메인 전문가가 동일한 모델을 공유해야 한다고 강조했음. 즉 설계 안의 요소들은 도메인 전문가의 멘탈 모델 속 실제 대상을 표현해야 함. 도메인 전문가가 무언가를 “주문(order)“이라 부른다면, 코드에도 이에 대응하고 동일하게 동작하는
Order가 있어야 함 - 반대로, 설계에는 도메인 전문가의 모델에 없는 요소가 있어서는 안 됨 — 즉
OrderFactory,OrderManager,OrderHelper같은 용어는 지양해야 함. 도메인 전문가는 이런 단어의 의미를 모름. 물론 일부 기술 용어는 코드베이스에 등장할 수밖에 없지만, 이를 설계의 일부로 노출하는 것은 피해야 함
// 지양: 도메인 전문가가 모르는 기술 용어를 설계에 노출
module OrderManager =
let processOrder (helper: OrderHelper) (order: Order) =
helper.Validate(order)
// 지향: 유비쿼터스 언어(도메인 전문가가 실제로 쓰는 말)를 그대로 반영
module OrderTaking =
let placeOrder (unvalidatedOrder: UnvalidatedOrder) : PlaceOrderEvents =
// ...
failwith "구현 예시"
// 지양: 도메인 전문가가 모르는 기술 용어를 설계에 노출
class OrderManager {
constructor(private helper: OrderHelper) {}
processOrder(order: Order) {
return this.helper.validate(order);
}
}
// 지향: 유비쿼터스 언어(도메인 전문가가 실제로 쓰는 말)를 그대로 반영
function placeOrder(unvalidatedOrder: UnvalidatedOrder): PlaceOrderEvents {
// ...
throw new Error("구현 예시");
}
- 팀 전체가 공유하는 개념과 어휘의 집합을 유비쿼터스 언어(Ubiquitous Language) — “어디에나 있는 언어” — 라 부름. 이는 비즈니스 도메인에 대한 공유된 멘탈 모델을 정의하는 언어이며, 이름 그대로 요구사항뿐 아니라 설계, 그리고 무엇보다 소스 코드에서 프로젝트 전반에 걸쳐 사용되어야 함
- 유비쿼터스 언어의 구축은 도메인 전문가가 일방적으로 지시하는 프로세스가 아니라 팀 전체의 협업임. 또한 이 언어가 정적일 것이라 기대해서는 안 됨 — 언제나 진행 중인 작업(work in progress)임. 설계가 진화함에 따라 새로운 용어와 개념을 발견할 준비가 되어 있어야 하고, 유비쿼터스 언어도 그에 맞게 함께 진화시켜야 함
- 중요한 점: 모든 도메인과 컨텍스트를 아우르는 단 하나의 유비쿼터스 언어를 가질 수 없는 경우가 많음. 각 컨텍스트는 유비쿼터스 언어의 **“방언(dialect)”**을 가지며, 같은 단어가 다른 방언에서는 다른 것을 의미할 수 있음
- 예: “class”는 객체지향 프로그래밍 도메인과 CSS 도메인에서 전혀 다른 것을 의미함
- “Customer”나 “Product” 같은 단어를 여러 컨텍스트에서 동일한 의미로 만들려는 시도는, 잘해야 복잡한 요구사항을, 최악의 경우 심각한 설계 오류를 낳을 수 있음
- 이벤트 스토밍 세션에서도 이 문제가 그대로 드러남: 모든 참가자가 “order”라는 단어를 사용했지만, 배송부서의 “order” 정의와 청구부서의 정의는 미묘하게 다를 수 있음. 배송부서는 재고 수준, 아이템 수량 등에 관심이 있고, 청구부서는 가격과 금액에 더 관심이 있음. 컨텍스트를 명시하지 않고 어디서나 “order”라는 같은 단어를 쓰면 고통스러운 오해를 겪을 수 있음
1.6 도메인 주도 설계 개념 요약하기
- 새로운 개념과 용어가 많이 등장했으므로, 다음 단계로 넘어가기 전에 한 곳에 요약함
| 용어 | 정의 |
|---|---|
| 도메인 (Domain) | 우리가 풀려는 문제와 연관된 지식 영역. 단순하게는 “도메인 전문가”가 전문적으로 아는 것 |
| 도메인 모델 (Domain Model) | 특정 문제와 관련된 도메인의 측면들을 표현하는 단순화의 집합. 도메인 모델은 솔루션 공간의 일부이며, 그것이 표현하는 도메인은 문제 공간의 일부 |
| 유비쿼터스 언어 (Ubiquitous Language) | 도메인과 연관되어 팀원과 소스 코드가 함께 공유하는 개념과 어휘의 집합 |
| 바운디드 컨텍스트 (Bounded Context) | 다른 서브시스템과 구별되는 명확한 경계를 가진, 솔루션 공간의 서브시스템. 문제 공간의 서브도메인에 대응하는 경우가 많음. 자신만의 개념/어휘, 즉 유비쿼터스 언어의 방언을 가짐 |
| 컨텍스트 맵 (Context Map) | 바운디드 컨텍스트들의 집합과 그들 사이의 관계를 보여주는 고수준 다이어그램 |
| 도메인 이벤트 (Domain Event) | 시스템에서 일어난 일의 기록. 항상 과거형으로 서술됨. 종종 추가적인 활동을 촉발함 |
| 커맨드 (Command) | 어떤 프로세스가 일어나기를 요청하는 것으로, 사람이나 다른 이벤트에 의해 촉발됨. 프로세스가 성공하면 시스템 상태가 변경되고 하나 이상의 도메인 이벤트가 기록됨 |
1.7 마무리하기
- 챕터 도입부에서 개발팀과 도메인 전문가 모두에게 동일한, 공유된 도메인/솔루션 모델을 만드는 것의 중요성을 강조했음
- 이를 위한 4가지 가이드라인을 논의함
- 데이터보다 이벤트와 프로세스에 집중한다
- 문제 도메인을 더 작은 서브도메인으로 분할한다
- 솔루션에서 각 서브도메인의 모델을 생성한다
- 관련된 모든 사람이 공유할 수 있는 “어디에나 있는 언어”를 개발한다
- 이를 주문 접수 도메인에 어떻게 적용했는지 정리하면 다음과 같음
1.7.1 이벤트와 프로세스
- 이벤트 스토밍 세션은 도메인의 주요 도메인 이벤트를 빠르게 드러냄
- 주문 접수 프로세스는 우편으로 주문서를 받는 것에서 촉발되며, 견적 처리, 신규 고객 등록 등을 위한 워크플로우가 존재한다는 것을 학습함
- 또한 주문접수팀이 주문 처리를 마치면 그 이벤트가 배송부서의 배송 프로세스와 청구부서의 청구 프로세스를 촉발한다는 것을 학습함
1.7.2 서브도메인과 바운디드 컨텍스트
- 지금까지 “주문 접수(Order Taking)”, “배송(Shipping)”, “청구(Billing)” 세 서브도메인을 발견한 것으로 보임
- “도메인이란 도메인 전문가가 전문적으로 아는 것”이라는 규칙으로 이를 확인함: Ollie에게 청구 프로세스를 아느냐고 물었을 때, Ollie는 조금은 알지만 자세한 내용은 청구팀에 직접 물어봐야 한다고 답함 → 청구가 별도의 도메인임이 확인됨
- 이 세 서브도메인에 대응하는 세 개의 바운디드 컨텍스트를 정의하고, 이들의 상호작용을 보여주는 컨텍스트 맵을 작성함
- 어느 것이 우리가 집중해야 할 핵심 도메인인지는 원래 매니저 Max와 상의해 자동화가 가장 큰 가치를 더할 곳을 결정해야 하지만, 지금은 주문 접수 도메인을 먼저 구현한다고 가정함. 필요하다면 이 도메인의 산출물을 종이 문서로 변환해, 다른 팀들이 기존 프로세스를 중단 없이 계속할 수 있게 할 수 있음
1.7.3 유비쿼터스 언어
- 지금까지 “주문서(order form)”, “견적(quote)”, “주문(order)” 같은 용어를 확보했으며, 설계를 더 파고들수록 더 많은 용어를 발견하게 될 것
- 공유된 이해를 유지하기 위해, 이런 용어와 정의를 나열한 살아있는 문서나 위키 페이지를 만드는 것이 좋은 방법임 — 팀 전체의 정렬을 유지하고 신규 팀원의 온보딩 속도를 높이는 데 도움이 됨
1.7.4 다음은 무엇인가?
- 문제에 대한 개요와 솔루션의 개략적인 윤곽은 확보했지만, 저수준 설계나 코딩을 시작하기 전에 답해야 할 질문이 아직 많이 남아 있음
- 예: 주문 처리 워크플로우에서 정확히 무슨 일이 일어나는가? 입력과 출력은 무엇인가? 이 워크플로우가 상호작용하는 다른 컨텍스트가 있는가? 배송팀의 “주문” 개념은 청구팀의 것과 어떻게 다른가?
- 다음 챕터에서는 주문 배치(order-placing) 워크플로우를 깊이 파고들어 이런 질문들에 답한다
챕터 2 도메인 이해하기
- 1장 요약: 도메인 전체 그림(overview)과 핵심 비즈니스 이벤트를 파악하고, 솔루션 공간을 여러 바운디드 컨텍스트(bounded context) 로 분할함. 도메인 주도 설계(DDD)와 공유 모델(shared model)의 중요성을 학습함
- 이 장의 목표: 하나의 특정 워크플로우(주문 접수)를 선택해 깊이 있게 이해하기
- 무엇이 이 워크플로우를 트리거하는가
- 어떤 데이터가 필요한가
- 다른 바운디드 컨텍스트와 어떻게 협업하는가
- 핵심 스킬: ==경청(careful listening)== — 자신의 정신 모델(mental model)을 도메인에 강요하지 않는 것이 중요함
2.1 도메인 전문가 인터뷰하기
- 깊은 이해를 얻기 위해 도메인 전문가와 심층 인터뷰를 진행함
- 인터뷰 대상: Ollie (주문 접수 부서 소속 도메인 전문가)
- 도메인 전문가는 대체로 바쁘고 개발자와 오래 시간을 보내기 어려움
- 커맨드/이벤트(commands/events) 접근법의 장점: 하루 종일 걸리는 회의 대신, 하나의 워크플로우에만 집중하는 짧은 인터뷰를 여러 번 진행할 수 있어 도메인 전문가가 시간을 내기 쉬움
- 인터뷰 전반부 전략: 높은 수준(high level)을 유지하며 워크플로우의 입력과 출력에만 집중
- 아직 관련 없는 세부 사항에 매몰되는 것을 방지하기 위함
인터뷰 시작
- 질문: 주문 접수(order-placing) 프로세스를 시작하려면 어떤 정보가 필요한가
- Ollie 답변 요약
- 고객이 작성해서 우편으로 보내는 종이 주문서(order form)에서 모든 것이 시작됨
- 전산화 버전에서는 고객이 이 양식을 온라인으로 작성하길 원함
주문서(Order Form) 구조
| 항목 | 내용 |
|---|---|
| 고객 이름 (Customer Name) | 입력란 |
| 청구 주소 (Billing Address) | 입력란 (여러 줄) |
| 배송 주소 (Shipping Address) | 입력란 (여러 줄) |
| 구분 체크박스 | Order / Quote / Express Delivery |
| 제품 코드 (Product Code) | 수량 (Quantity) | 비용 (Cost) |
|---|---|---|
| 소계 (Subtotal) | ||
| 배송비 (Shipping) | ||
| 합계 (Total) |
초기 가정과 정정
- 인터뷰어는 처음에 전형적인 이커머스 모델(제품 페이지 탐색 → 장바구니 담기 → 체크아웃)을 가정함
- Ollie가 정정
- 고객은 이미 원하는 상품을 정확히 알고 있음
- 제품 코드와 수량만 입력하는 단순한 양식을 원함
- 한 번에 200~300개 품목을 주문하기도 하므로, 제품 페이지를 일일이 클릭하는 방식은 지나치게 느림
핵심 교훈: 도메인을 배우는 입장이므로 (이 경우처럼) 고객이 시스템을 어떻게 사용할지 등 어떤 것에 대해서도 성급하게 결론을 내리려는 충동을 억제해야 함. 좋은 인터뷰란 곧 많이 듣는 것(listening)임. 도메인을 배우는 가장 좋은 방법은 인류학자(anthropologist)처럼 선입견 없이 접근하는 것. 이상적으로는 설계를 확정하기 전에 현장 관찰, 사용성 테스트 등 심층 리서치를 수행해야 하지만, 이 사례에서는 Ollie가 고객의 니즈를 충분히 대변할 수 있다고 신뢰하고 그 단계를 생략함
2.1.1 비기능적 요구사항 이해하기
- 워크플로우의 맥락(context)과 규모(scale)를 파악하는 단계
- 고객 특성
- B2B(기업 간 거래) 회사이며 고객은 다른 기업들임
- 고객사 수: 약 1,000곳
- 주문 빈도: 고객사당 보통 주 1회 주문
- 일 평균 주문량: 약 200건/영업일
- 연중 물량이 일정함 (연휴 등 특수 성수기에 급증하지 않음)
- → 설계 시 대규모 트래픽이나 스파이크성 트래픽에 대비할 필요가 없음
- 사용자(고객) 숙련도
- 고객은 구매 업무를 전업으로 하는 전문가(expert) 임
- 원하는 바를 정확히 알고 있으며, 효율적인 처리 방법만 필요로 함
- → 초보자용 시스템과 달리 전문가의 작업을 방해하는 장벽을 최소화해야 함
- 응답 속도(latency) 요구사항
- 영업일 종료 전까지 확인(acknowledgment)을 받아야 함
- 이 비즈니스에서는 속도보다 일관성(예측 가능성) 이 더 중요함
- 고객은 회사가 예측 가능한 방식으로 응답하고 배송할 것이라는 신뢰를 원함
- 시사점: 전형적인 B2B 애플리케이션 요구사항
- 예측 가능성(predictability)
- 견고한 데이터 처리(robust data handling)
- 분쟁 발생 시를 대비한 감사 추적(audit trail)
2.1.2 작업 흐름의 나머지 부분 이해하기
- Ollie가 각 주문서를 처리하는 절차
- 제품 코드가 올바른지 확인 (오타나 존재하지 않는 상품이 있을 수 있음)
- 제품 카탈로그(매월 발행되는 리플렛)에서 코드를 조회하여 존재 여부 확인
- 제품 카탈로그는 별도의 바운디드 컨텍스트일 가능성이 높음 → 나중에 자세히 다루기로 하고, 지금은 이 워크플로우가 그 컨텍스트로부터 필요로 하는 것(제품 목록과 가격)만 기록해 둠
- 품목별 비용을 합산해 Total 필드에 기입
- 사본 2부를 만들어 배송 부서(shipping)와 청구 부서(billing)에 각각 전달, 원본은 자체 보관
- 원본을 스캔하여 표준 확인서(acknowledgment letter)와 함께 이메일로 고객에게 발송 (이를 “주문 확인서”라고 부름)
- 주문서의 Order / Quote 체크박스
- Order 체크 → 주문(order)
- Quote 체크 → 견적(quote)
- Quote(견적): 고객이 가격 계산만 원할 뿐 실제 배송을 원하지 않는 경우
- 가격을 기입해 고객에게 회신만 함
- 배송/청구 부서에는 사본을 보내지 않음 (그들이 할 일이 없기 때문)
- 시사점: Order와 Quote는 같은 양식을 사용할 만큼 유사하지만, 이후의 워크플로우는 서로 다름
2.1.3 입력 생각해보기
- 지금까지 파악한 워크플로우의 입력/출력을 정리
- 입력: 주문서(order form) — 정확한 정의는 추후 구체화 필요
- 출력: 명확하지 않았던 부분
- “완료된 주문(completed order)”(검증되고 가격이 매겨진 상태)은 출력이 될 수 없음 — 그 자체로 직접 사용되는 대상이 아니기 때문
- “주문 확인서(order acknowledgment)” 역시 출력이 아님 — 확인서 발송은 워크플로우의 부수 효과(side effect) 일 뿐, 출력 자체가 아님
- ==핵심 원칙: 워크플로우의 출력은 항상 그 워크플로우가 생성하는 이벤트여야 함== — 다른 바운디드 컨텍스트에서 동작을 트리거하는 대상
- 이 사례의 출력:
OrderPlaced이벤트 → 배송(Shipping) 및 청구(Billing) 컨텍스트로 전달됨 - 이벤트가 실제로 각 부서에 어떻게 전달되는지는 나중에 논의할 사항이며, 지금 설계와는 무관함
- 이 사례의 출력:
Place Order 워크플로우의 입력/출력 다이어그램
flowchart LR
IN1["입력: 주문서"] --> WF["워크플로우: Place Order"]
IN2["추가 입력: 제품 카탈로그"] --> WF
WF --> OUT1["출력: OrderPlaced 이벤트<br/>(배송/청구 컨텍스트에 통지)"]
WF --> OUT2["부수 효과: 고객에게<br/>주문 확인서 전송"]
2.2 데이터베이스 중심 디자인 지양하기
- 대부분의 개발자는 이 시점에서 곧바로 저수준 설계/구현을 스케치하고 싶은 충동을 느낌
- 주문서를 보면 고객 정보, 여러 주소, 주문 라인 목록 등으로 구성되어 있음을 알 수 있음
- 데이터베이스 경험이 많은 개발자의 첫 본능: 테이블과 그 관계를 떠올림
Order테이블,OrderLine테이블,Customer/Address/Product테이블과 그 관계(FK)를 스케치
erDiagram
CUSTOMER ||--o{ ORDER_TABLE : "n..1"
ADDRESS ||--o{ ORDER_TABLE : "n..1 (배송주소)"
ADDRESS ||--o{ ORDER_TABLE : "n..1 (청구주소)"
ORDER_TABLE ||--o{ ORDER_LINE : "1..n"
PRODUCT ||--o{ ORDER_LINE : "n..1"
ORDER_TABLE {
int CustomerId
int ShippingAddressId
int BillingAddressId
bool IsQuote
}
ORDER_LINE {
int OrderId
int ProductId
int Quantity
}
- ==이렇게 하면 실수하는 것== — DDD에서는 데이터베이스 스키마가 아니라 도메인이 설계를 주도해야 함
- 저장소(storage) 구현과 무관하게 도메인으로부터 모델링을 시작하는 것이 바람직함
- 실제 종이 기반 시스템에는 “데이터베이스”라는 개념 자체가 없음
- “데이터베이스”는 유비쿼터스 언어(ubiquitous language)에 속하지 않음
- 사용자는 데이터가 어떻게 저장되는지 신경 쓰지 않음
- 이 원칙을 DDD 용어로 퍼시스턴스 무관성(persistence ignorance) 이라 부름
- 데이터베이스 표현을 걱정하지 않고 도메인을 정확히 모델링하는 데 집중하도록 강제하는 중요한 원칙
- 데이터베이스 관점으로 설계할 때의 문제: 데이터베이스 모델에 맞추기 위해 설계가 왜곡되는 경우가 많음
- 왜곡의 예시: 위 다이어그램에서는 Order와 Quote의 차이를
IsQuote플래그 하나로만 뭉뚱그림- 실제로는 비즈니스 규칙과 검증 규칙이 서로 다름 (예: Order는 반드시 청구 주소가 있어야 하지만 Quote는 없어도 될 수 있음)
- 이런 미묘한 차이는 외래 키(foreign key) 하나로 표현하기 어려움 — 같은 외래 키가 두 가지 다른 관계를 동시에 담당하면서 이 구분이 사라져 버림
- 나중에 영속성(persistence)을 다루는 장에서 관계형 데이터베이스에 도메인 모델을 저장하는 방법을 다시 살펴볼 예정이나, 지금은 선입견 없이 요구사항을 경청하는 데 집중해야 함
데이터베이스 스키마 관점 예시 (지양해야 할 설계)
// 지양해야 할 설계 예시 (데이터베이스 스키마 중심 사고의 함정)
interface OrderTableRow {
customerId: number;
shippingAddressId: number;
billingAddressId: number;
isQuote: boolean; // Order와 Quote의 차이를 단순 플래그로 뭉뚱그림 -> 비즈니스 규칙 표현의 한계
}
interface OrderLineTableRow {
orderId: number;
productId: number;
quantity: number;
}
2.3 클래스 중심 디자인 지양하기
- 객체지향(OO) 경험이 많은 개발자는 이미 데이터베이스 구현과 비즈니스 로직을 분리하는 데(예: 의존성 주입) 익숙할 수 있음
- 하지만 데이터베이스 대신 “객체” 관점으로 사고하면서 또 다른 편향을 설계에 끌어들일 수 있음
- 예시: Ollie와 대화하는 동안 머릿속으로 클래스를 구상하게 되는 경우
Order와Quote를 분리했지만, 실제 도메인에는 존재하지 않는 인위적인 기반 클래스OrderBase를 도입 → 도메인의 왜곡
classDiagram
class OrderBase {
Customer customer
}
class Order {
Address shippingAddress
Address billingAddress
}
class Quote
class OrderLine {
int quantity
}
class Customer
class Address
class Product
OrderBase <|-- Order
OrderBase <|-- Quote
OrderBase --> Customer : n..1
Order --> Address : 배송주소 n..1
Order --> Address : 청구주소 n..1
OrderBase "1" --> "1..n" OrderLine : 포함
OrderLine --> Product : n..1
- ==클래스가 설계를 주도하게 두는 것은 데이터베이스가 설계를 주도하게 두는 것만큼 위험함== — 이 역시 요구사항을 제대로 경청하지 않은 것
- 도메인 전문가에게 “OrderBase가 무엇인가요?“라고 직접 물어보면, 그런 개념은 실제 업무에 존재하지 않는다는 것을 알 수 있음
- 교훈: 요구사항을 수집하는 동안에는 마음을 열어두고, 자신의 기술적 아이디어를 도메인에 강요하지 말아야 함
클래스 중심 사고의 함정 예시 (지양해야 할 설계)
// 지양해야 할 설계 예시 (클래스 중심 사고의 함정)
// OrderBase는 실제 도메인 전문가의 언어에는 존재하지 않는 인위적인 개념임
abstract class OrderBase {
constructor(public customer: Customer) {}
}
class Order extends OrderBase {
constructor(
customer: Customer,
public shippingAddress: Address,
public billingAddress: Address,
public orderLines: OrderLine[]
) {
super(customer);
}
}
class Quote extends OrderBase {
constructor(
customer: Customer,
public shippingAddress: Address,
public billingAddress: Address
) {
super(customer);
}
}
class OrderLine {
constructor(public product: Product, public quantity: number) {}
}
2.4 도메인 문서화
- 질문: 기술적 편향(DB, 클래스)을 피하면서 요구사항을 어떻게 기록할 것인가
- 시각적 다이어그램(예: UML) 사용 가능하지만, 다루기 번거롭고 도메인의 미묘한 부분을 담기엔 세밀함이 부족한 경우가 많음
- 책의 후반부에서는 F# 타입 시스템으로 정확한 도메인 모델을 코드로 만드는 방법을 다룰 예정
- 지금 단계에서는 도메인 모델을 담아낼 간단한 텍스트 기반 미니 언어를 만들어 사용함
- 워크플로우: 입력과 출력을 문서화하고, 비즈니스 로직은 간단한 의사코드(pseudocode)로 표현
- 데이터 구조:
AND는 두 부분이 모두 필요함을 의미 (예:Name AND Address),OR는 둘 중 하나만 있으면 됨을 의미 (예:Email OR PhoneNumber)
Place Order 워크플로우 문서화 예시
Bounded context: Order-Taking
Workflow: "Place order"
triggered by:
"Order form received" event (when Quote is not checked)
primary input:
An order form
other input:
Product catalog
output events:
"Order Placed" event
side-effects:
An acknowledgment is sent to the customer,
along with the placed order
// bounded context: Order-Taking
/**
* workflow: "Place order"
* trigger: "OrderFormReceived" 이벤트 (Quote 체크박스가 선택되지 않았을 때)
* primary input: OrderForm
* other input: ProductCatalog
* output events: OrderPlaced
* side-effects: 고객에게 주문 확인서(acknowledgment)와 접수된 주문을 함께 전송
*/
interface PlaceOrderWorkflowSpec {
trigger: "OrderFormReceived";
primaryInput: OrderForm;
otherInput: ProductCatalog;
outputEvents: OrderPlaced;
sideEffects: "고객에게 주문 확인서 전송";
}
데이터 구조 문서화 예시
bounded context: Order-Taking
data Order =
CustomerInfo
AND ShippingAddress
AND BillingAddress
AND list of OrderLines
AND AmountToBill
data OrderLine =
Product
AND Quantity
AND Price
data CustomerInfo = ??? // don't know yet
data BillingAddress = ??? // don't know yet
// bounded context: Order-Taking
interface Order {
customerInfo: CustomerInfo;
shippingAddress: ShippingAddress;
billingAddress: BillingAddress;
orderLines: OrderLine[];
amountToBill: AmountToBill;
}
interface OrderLine {
product: Product;
quantity: Quantity;
price: Price;
}
// 아직 정의되지 않음 (원서에서는 ??? 로 표기)
type CustomerInfo = unknown;
type BillingAddress = unknown;
- Provide Quote 워크플로우와 관련 데이터 구조도 동일한 방식으로 문서화 가능 (본문에서는 상세 생략)
- 이 단계에서는 클래스 계층이나 데이터베이스 테이블을 전혀 만들지 않고, 도메인을 구조화된 방식으로만 담아냄
- 텍스트 기반 설계의 장점: 비개발자에게도 낯설지 않아 도메인 전문가에게 직접 보여주고 함께 작업할 수 있음
- 남은 질문: 실제 코드도 이만큼 단순하게 만들 수 있을까 → 이후 “타입을 이용한 도메인 모델링” 장에서 시도
2.5 주문 접수 작업 흐름을 깊이 파고들기
- 입력/출력 문서화가 끝났으니 이제 주문 접수 워크플로우를 상세히 이해할 차례
우편물 분류 및 우선순위
- 아침에 우편물이 도착하면 Ollie가 가장 먼저 분류 작업을 함
- 주문서는 한 더미(pile), 다른 서신은 다른 더미로 분류
- 각 양식에서 Quote 체크박스가 선택되어 있으면 “나중에 처리할 견적 더미”에 올려둠
- 이유: 주문(order)이 항상 더 중요함 — 회사는 주문으로 돈을 벌고, 견적으로는 돈을 벌지 않음
- ==핵심 통찰: 개발자는 기술적 관점에서 모든 요구사항을 동등하게 취급하는 경향이 있지만, 비즈니스는 그렇지 않음. 돈을 벌거나 절약하는 것이 거의 항상 개발 프로젝트의 핵심 동인이므로, 우선순위가 불분명할 때는 “돈을 따라가라(follow the money)”==
- 설계 시 (돈을 버는) 주문을 견적보다 우선 처리하도록 해야 함
주소 및 고객 정보 검증
- 주문서 처리의 첫 단계: 고객 이름, 이메일, 배송 주소, 청구 주소의 유효성 확인
- 주소 확인은 Ollie 컴퓨터의 전용 애플리케이션으로 수행
- 주소를 입력하면 존재 여부를 확인하고, 배송업체가 요구하는 표준 형식으로 정리해 줌
- → 워크플로우가 컨텍스트 외부의 서드파티 주소 확인 서비스와 통신해야 한다는 새로운 사실 발견
- 이 부분은 이전 이벤트 스토밍(Event Storming) 단계에서 놓쳤던 내용이므로 별도로 기록해 두어야 함
- 이름/주소가 유효하지 않으면 빨간 펜으로 문제를 표시하고 “유효하지 않은 양식” 더미에 올려둠 → 이후 고객에게 전화해 정보를 정정 요청
- 이 시점까지 파악된 세 가지 더미
- 새로 도착한 주문서 (우편함에서)
- 나중에 처리할 견적 더미
- 나중에 처리할 유효하지 않은 주문서 더미
- ==통찰: “종이 더미(pile)“는 대부분의 비즈니스 프로세스에서 매우 중요한 요소이며, 더미마다 중요도가 다르다는 점을 설계에 반드시 반영해야 함. 구현 단계에서는 “종이 더미”가 큐(queue)에 잘 대응되지만, 지금은 기술적 세부 사항에서 벗어나 있어야 함==
제품 코드 검증
- 제품 코드도 형식이 명백히 잘못된 경우가 있음
- Widget 코드:
W로 시작 + 숫자 4자리 - Gizmo 코드:
G로 시작 + 숫자 3자리 - 다른 유형의 제품 코드는 없으며, 앞으로도 바뀔 가능성이 낮음 (수년간 형식이 변경되지 않음)
- Widget 코드:
- 제품 코드 검증은 두 단계로 구성됨
- 형식(syntax) 검사:
W또는G로 시작하는지 등 순수하게 문법적인 검사 — 제품 카탈로그 조회가 필요 없음 - 존재(existence) 검사: 제품 카탈로그(Ollie에게는 책자, 소프트웨어라면 데이터베이스 조회)에서 실제로 존재하는 코드인지 확인
- 존재하지 않으면 오류를 표시하고 유효하지 않은 주문 더미에 올려둠
- 형식(syntax) 검사:
의존성 관리와 자율성
- 질문: 제품팀이 모든 문의에 즉시 응답할 수 있다면 Ollie는 여전히 자신만의 제품 카탈로그 사본이 필요할까
- Ollie의 답변 요지: 속도의 문제가 아니라 통제(control)의 문제
- 제품팀이 바쁘거나 전화가 안 될 수도 있으므로, 자신만의 카탈로그가 있으면 다른 부서의 가용성에 의존하지 않고 거의 모든 주문서를 처리할 수 있음
- ==시사점: 이는 성능이 아니라 의존성 관리(dependency management)의 문제이며, 앞서 다룬 바운디드 컨텍스트의 자율성(autonomy)과 연결됨. 도메인 모델에 직접 반영할지는 상황에 따라 다르지만, 각 부서가 독립적으로 일할 수 있어야 한다는 요구사항은 반드시 인지하고 있어야 함==
수량 검증과 유비쿼터스 언어
- 질문: 주문 수량은 정수(integer)인가 소수(float)인가
- Ollie는 “float”이라는 프로그래밍 용어를 이해하지 못함 (물처럼 뜨는 것이냐고 반문)
- ==교훈: 유비쿼터스 언어(ubiquitous language) — 도메인 전문가는 “float” 같은 프로그래밍 용어를 사용하지 않음. Ollie는 이를 “주문 수량(order quantity)“이라고 부름 →
OrderQuantity,ProductCode,AmountToBill등이 유비쿼터스 언어의 단어가 됨==
- 다시 질문: 주문 수량에 소수점이 있는가, 아니면 정수만 가능한가
- 답변: “상황에 따라 다름(it depends)”
- Widget: 개당(unit) 판매 → 정수만 가능 (예: 1.5개는 실수)
- Gizmo: 킬로그램 단위 판매 → 소수 허용
- 답변: “상황에 따라 다름(it depends)”
가격 책정 및 마무리 처리
- 제품 코드와 수량이 모두 유효하면
- 각 라인의 가격을 기입하고 합산하여 총 청구 금액(amount to bill) 계산
- 사본 2부 작성: 원본은 파일 보관, 사본 1부는 배송 부서 발신함, 사본 1부는 청구 부서 발신함
- 원본을 스캔하여 표준 확인서와 함께 이메일로 고객에게 발송
- 처리 단계 구분 방법 (양식이 섞이지 않도록)
- 검증이 끝난 양식은 모서리에 표시를 남김
- 가격 책정이 끝났는지는 “Total” 칸이 채워져 있는지로 구분 가능
- → 각 처리 단계마다 표시를 남겨 서로 다른 단계의 양식을 항상 구분할 수 있음
2.6 복잡미묘한 도메인 모델링하기
- 워크플로우를 깊이 파고들수록 도메인 모델이 더 복잡해짐 — 이는 좋은 신호임
- 코딩 중반에 복잡성을 마주하기보다, 지금 미리 이해해 두는 편이 훨씬 나음
- 흔한 격언(“계획에 한 시간을 쓰면 프로그래밍 몇 주를 절약한다”)을 역설적으로 비틀어, 지금 설계에 충분히 시간을 들이는 것의 가치를 강조함
- 현재까지 파악된 워크플로우 다이어그램
flowchart TD
MAIL["미분류 우편물"] --> UO["미검증 주문서"]
MAIL --> UQ["미검증 견적서"]
UQ --> OTHER["다른 워크플로우로"]
UO --> VAL["검증 Validation"]
ACS["주소 확인 서비스"] --> VAL
CAT["제품 카탈로그"] -- "제품 코드 확인" --> VAL
VAL --> VORDER["검증된 주문"]
VORDER --> PRICE["가격 책정 Pricing"]
CAT -- "가격 조회" --> PRICE
PRICE --> PORDER["가격이 책정된 주문"]
PORDER --> SHIP["배송/청구 부서로 전송"]
PORDER --> ACK["고객에게 주문 확인서 전송"]
- 하지만 이 다이어그램만으로는 지금까지 학습한 모든 내용을 담아낼 수 없음 → 텍스트 기반 미니 언어로 더 정교하게 표현해야 함
2.6.1 제약 사항 표현하기
- 가장 원시적인 값(제품 코드, 수량)부터 시작 — 단순 문자열/정수가 아니라 다양한 방식으로 제약되어 있음을 학습함
제품 코드 제약
context: Order-Taking
data WidgetCode = string starting with "W" then 4 digits
data GizmoCode = string starting with "G" then 3 digits
data ProductCode = WidgetCode OR GizmoCode
// context: Order-Taking
// TypeScript에는 F#처럼 패턴 제약을 표현하는 타입이 기본으로 없으므로
// 브랜디드 타입(branded type) + 파서 함수로 형식 제약을 표현함
type WidgetCode = string & { readonly __brand: "WidgetCode" }; // "W" + 숫자 4자리
type GizmoCode = string & { readonly __brand: "GizmoCode" }; // "G" + 숫자 3자리
type ProductCode = WidgetCode | GizmoCode;
function parseWidgetCode(code: string): WidgetCode {
if (!/^W\d{4}$/.test(code)) {
throw new Error(`유효하지 않은 WidgetCode: ${code}`);
}
return code as WidgetCode;
}
function parseGizmoCode(code: string): GizmoCode {
if (!/^G\d{3}$/.test(code)) {
throw new Error(`유효하지 않은 GizmoCode: ${code}`);
}
return code as GizmoCode;
}
- Ollie가 사용한 단어(
WidgetCode등)를 그대로 설계에 사용하여 유비쿼터스 언어의 일부로 취급함 - 두 코드 유형에 대한 제약을 문서화한 뒤, 이 둘의 선택(choice)으로
ProductCode를 정의함 - 의문: 너무 엄격한 설계가 아닌가? 새로운 제품 유형이 생기면 어떻게 하는가
- 흔한 딜레마: 너무 엄격하면 변경이 어렵고, 너무 자유로우면 설계 자체가 없는 것과 같음
- 정답은 상황에 따라 다르지만, 일반적으로 도메인 전문가의 관점을 최대한 반영하는 것이 중요함
- 코드 종류를 구분해 검사하는 것은 검증 프로세스의 중요한 부분이므로 도메인 설계에 자체 문서화(self-documenting) 형태로 반영하는 것이 바람직함 — 여기에 문서화하지 않으면 결국 다른 곳에 문서화해야 함
- 요구사항이 바뀌어도 이 모델은 변경이 쉬움 (새 제품 코드 유형 추가는 한 줄만 추가하면 됨)
- 설계가 엄격하다고 해서 구현까지 엄격해야 하는 것은 아님 — 예를 들어 자동화된 검증 로직은 이상한 코드를 무조건 거부하는 대신 사람의 검토를 위해 표시만 남길 수도 있음
주문 수량 제약
data OrderQuantity = UnitQuantity OR KilogramQuantity
data UnitQuantity = integer between 1 and ?
data KilogramQuantity = decimal between ? and ?
- 위 초안을 작성하며 상한선이 없다는 것을 깨달음 —
UnitQuantity가 수십억이 될 수는 없음 - 도메인 전문가에게 확인한 제약 사항
- 주문 가능한 최대 유닛 수: 1,000
- 최소 무게: 0.05kg, 최대 무게: 100kg
- ==이런 제약을 정확히 포착하는 것이 중요함 — 운영 환경에서 수량이 음수가 되거나 무게가 수백 톤이 되는 상황을 방지해야 함==
data UnitQuantity = integer between 1 and 1000
data KilogramQuantity = decimal between 0.05 and 100.00
type UnitQuantity = number & { readonly __brand: "UnitQuantity" }; // 1 ~ 1000 사이의 정수
type KilogramQuantity = number & { readonly __brand: "KilogramQuantity" }; // 0.05 ~ 100.00 사이의 소수
type OrderQuantity = UnitQuantity | KilogramQuantity;
function parseUnitQuantity(n: number): UnitQuantity {
if (!Number.isInteger(n) || n < 1 || n > 1000) {
throw new Error(`유효하지 않은 UnitQuantity: ${n}`);
}
return n as UnitQuantity;
}
function parseKilogramQuantity(n: number): KilogramQuantity {
if (n < 0.05 || n > 100.0) {
throw new Error(`유효하지 않은 KilogramQuantity: ${n}`);
}
return n as KilogramQuantity;
}
2.6.2 주문의 생애 주기 표현하기
- 이제
Order개념 자체를 재검토 - 초기 정의(너무 단순함)
data Order =
CustomerInfo
AND ShippingAddress
AND BillingAddress
AND list of OrderLines
AND AmountToBill
- 이 정의는 Ollie가 실제로 주문을 어떻게 사고하는지를 담아내지 못함
- Ollie의 정신 모델에서 주문은 ==생애 주기(life cycle)== 를 가짐: 미검증(unvalidated, 우편물 그대로) → 검증됨(validated) → 가격 책정됨(priced)
- 초반에는 가격이 없다가, 마지막에는 가격이 있음 — 위의 단순한
Order정의는 이 구분을 지워버림
- 초반에는 가격이 없다가, 마지막에는 가격이 있음 — 위의 단순한
- 종이 양식에서 Ollie는 각 단계가 끝날 때마다 표시를 남겨, 미검증/검증됨/가격 책정됨 양식을 한눈에 구분함
- 이 단계 구분을 도메인 모델에도 반영해야 함 — 단순한 문서화 목적뿐 아니라, (예를 들어) 가격이 매겨지지 않은 주문이 배송 부서로 전달되어서는 안 된다는 규칙을 명시적으로 드러내기 위함
- 접근법: 각 단계마다 새로운 타입 이름을 부여 (
UnvalidatedOrder,ValidatedOrder등)- 설계가 길어지고 작성이 번거로워지는 단점이 있으나, 모든 것이 명확해지는 장점이 있음
미검증 주문 (UnvalidatedOrder)
data UnvalidatedOrder =
UnvalidatedCustomerInfo
AND UnvalidatedShippingAddress
AND UnvalidatedBillingAddress
AND list of UnvalidatedOrderLine
data UnvalidatedOrderLine =
UnvalidatedProductCode
AND UnvalidatedOrderQuantity
- 워크플로우 시작 시점에는
CustomerInfo,ShippingAddress등이 아직 검증되지 않은 상태임을 명시적으로 드러냄
interface UnvalidatedOrder {
customerInfo: UnvalidatedCustomerInfo;
shippingAddress: UnvalidatedShippingAddress;
billingAddress: UnvalidatedBillingAddress;
orderLines: UnvalidatedOrderLine[];
}
interface UnvalidatedOrderLine {
productCode: UnvalidatedProductCode;
orderQuantity: UnvalidatedOrderQuantity;
}
검증된 주문 (ValidatedOrder)
data ValidatedOrder =
ValidatedCustomerInfo
AND ValidatedShippingAddress
AND ValidatedBillingAddress
AND list of ValidatedOrderLine
data ValidatedOrderLine =
ValidatedProductCode
AND ValidatedOrderQuantity
- 모든 구성 요소가 검증을 마쳤고 유효함을 보여줌
interface ValidatedOrder {
customerInfo: ValidatedCustomerInfo;
shippingAddress: ValidatedShippingAddress;
billingAddress: ValidatedBillingAddress;
orderLines: ValidatedOrderLine[];
}
interface ValidatedOrderLine {
productCode: ValidatedProductCode;
orderQuantity: ValidatedOrderQuantity;
}
가격이 책정된 주문 (PricedOrder)
ValidatedOrder와 거의 같지만 다음이 추가됨- 각 라인에 가격이 붙음:
PricedOrderLine=ValidatedOrderLine+LinePrice - 주문 전체에
AmountToBill이 붙음 (라인 가격들의 합계)
- 각 라인에 가격이 붙음:
data PricedOrder =
ValidatedCustomerInfo
AND ValidatedShippingAddress
AND ValidatedBillingAddress
AND list of PricedOrderLine // ValidatedOrderLine과 다름
AND AmountToBill // 새로 추가됨
data PricedOrderLine =
ValidatedOrderLine
AND LinePrice // 새로 추가됨
interface PricedOrder {
customerInfo: ValidatedCustomerInfo;
shippingAddress: ValidatedShippingAddress;
billingAddress: ValidatedBillingAddress;
orderLines: PricedOrderLine[]; // ValidatedOrderLine과 다름
amountToBill: AmountToBill; // 새로 추가됨
}
interface PricedOrderLine extends ValidatedOrderLine {
linePrice: LinePrice; // 새로 추가됨
}
주문 확인 (PlacedOrderAcknowledgment)
data PlacedOrderAcknowledgment =
PricedOrder
AND AcknowledgmentLetter
interface PlacedOrderAcknowledgment {
pricedOrder: PricedOrder;
acknowledgmentLetter: AcknowledgmentLetter;
}
- 이 설계에는 이미 비즈니스 로직이 상당히 반영되어 있음
- 규칙 1: 미검증 주문에는 가격이 없음
- 규칙 2: 검증된 주문의 모든 라인은 예외 없이 검증되어 있어야 함 (일부만 검증된 상태는 허용되지 않음)
- 모델이 처음 생각보다 훨씬 복잡해졌지만, 이는 실제 비즈니스가 동작하는 방식을 그대로 반영한 결과임 — 모델이 이 정도로 복잡하지 않았다면 오히려 요구사항을 제대로 담아내지 못했다는 뜻
- 이러한 구분을 코드에도 그대로 보존할 수 있다면, 진정한 의미의 “도메인 주도” 설계가 됨
2.6.3 작업 흐름의 단계 구체화하기
-
워크플로우는 검증, 가격 책정 등 더 작은 단계(step)로 나눌 수 있음 → 각 단계에도 동일한 입력/출력 접근법을 적용
-
전체 워크플로우의 출력이 처음 생각보다 복잡해짐
- 원래는 단순히 “Order placed” 이벤트 하나였지만, 이제는
- “Order placed” 이벤트를 배송/청구 부서로 보내거나, 또는
- 주문서를 유효하지 않은 주문 더미에 추가하고 나머지 단계를 건너뜀
- 원래는 단순히 “Order placed” 이벤트 하나였지만, 이제는
전체 워크플로우 의사코드
workflow "Place Order" =
input: OrderForm
output:
OrderPlaced event (put on a pile to send to other teams)
OR InvalidOrder (put on appropriate pile)
// step 1
do ValidateOrder
If order is invalid then:
add InvalidOrder to pile
stop
// step 2
do PriceOrder
// step 3
do SendAcknowledgmentToCustomer
// step 4
return OrderPlaced event (if no errors)
type PlaceOrderResult = OrderPlacedEvent | InvalidOrder;
function placeOrder(orderForm: OrderForm): PlaceOrderResult {
// step 1
const validationResult = validateOrder(orderForm);
if (validationResult.kind === "InvalidOrder") {
addToPile(validationResult); // 적합한 더미(pile)에 추가
return validationResult; // 여기서 중단
}
// step 2
const pricedOrder = priceOrder(validationResult.order);
// step 3
sendAcknowledgmentToCustomer(pricedOrder);
// step 4
return { kind: "OrderPlaced", order: pricedOrder };
}
ValidateOrder 서브스텝
- 입력:
UnvalidatedOrder - 출력:
ValidatedOrder또는ValidationError - 의존성:
CheckProductCodeExists,CheckAddressExists
substep "ValidateOrder" =
input: UnvalidatedOrder
output: ValidatedOrder OR ValidationError
dependencies: CheckProductCodeExists, CheckAddressExists
validate the customer name
check that the shipping and billing address exist
for each line:
check product code syntax
check that product code exists in ProductCatalog
if everything is OK, then:
return ValidatedOrder
else:
return ValidationError
type ValidateOrder = (
unvalidatedOrder: UnvalidatedOrder,
checkProductCodeExists: (code: ProductCode) => boolean,
checkAddressExists: (address: UnvalidatedAddress) => boolean
) => ValidatedOrder | ValidationError;
// 의사코드 형태의 구현 스케치 (상세 구현은 이후 도메인 모델링 장에서 다룸)
const validateOrder: ValidateOrder = (order, checkProductCodeExists, checkAddressExists) => {
// 1. 고객 이름 검증
// 2. 배송지/청구지 주소 존재 여부 확인 (checkAddressExists)
// 3. 각 라인에 대해 제품 코드 형식(syntax) 검증
// 4. 각 라인에 대해 제품 카탈로그 내 존재 여부 확인 (checkProductCodeExists)
// 5. 문제 없으면 ValidatedOrder 반환, 아니면 ValidationError 반환
throw new Error("상세 구현은 이후 장에서 다룸");
};
PriceOrder 서브스텝
- 입력:
ValidatedOrder - 출력:
PricedOrder - 의존성:
GetProductPrice
substep "PriceOrder" =
input: ValidatedOrder
output: PricedOrder
dependencies: GetProductPrice
for each line:
get the price for the product
set the price for the line
set the amount to bill ( = sum of the line prices)
type PriceOrder = (
validatedOrder: ValidatedOrder,
getProductPrice: (code: ProductCode) => Price
) => PricedOrder;
const priceOrder: PriceOrder = (order, getProductPrice) => {
// 각 라인마다 가격 조회 후 LinePrice 설정
// amountToBill = 모든 라인 가격의 합계
throw new Error("상세 구현은 이후 장에서 다룸");
};
SendAcknowledgmentToCustomer 서브스텝
- 입력:
PricedOrder - 출력: 없음 (None)
substep "SendAcknowledgmentToCustomer" =
input: PricedOrder
output: None
create acknowledgment letter and send it
and the priced order to the customer
type SendAcknowledgmentToCustomer = (pricedOrder: PricedOrder) => void;
const sendAcknowledgmentToCustomer: SendAcknowledgmentToCustomer = (pricedOrder) => {
// 주문 확인서(acknowledgment letter) 생성
// 확인서와 PricedOrder 정보를 고객에게 함께 발송
};
- 이 시점의 문서는 점점 코드에 가까워지지만, 여전히 도메인 전문가가 읽고 검토할 수 있는 수준을 유지함
2.7 마무리
- 이 장에서는 요구사항 수집을 여기서 마무리하고, 이후 2부(모델링 단계)에서 이 내용을 계속 활용함
- 이 장에서 배운 핵심 내용
- 설계 과정에서 구현 세부 사항에 바로 뛰어들지 않는 것이 중요함: DDD는 데이터베이스 중심도, 클래스 중심도 아니어야 함
- 대신 특정 코딩 방식을 전제하지 않고, 아무런 가정 없이 도메인 자체를 포착하는 데 집중해야 함
- 도메인 전문가의 말을 주의 깊게 경청하면, 비교적 단순해 보이는 시스템에서도 상당한 복잡성이 드러남
- 예: 처음에는 단일한 “Order” 하나만 있을 것이라 생각했지만, 실제로는 생애 주기 전반에 걸쳐 데이터와 동작이 조금씩 다른 여러 변형(Unvalidated/Validated/Priced 등)이 존재함이 드러남
2.7.1 다음 장 안내
- 다음 단계로는 F# 타입 시스템을 이용해 이 주문 접수 워크플로우를 모델링하는 방법을 살펴볼 예정
- 그 전에 한 걸음 물러나 전체 그림(big picture)을 다시 살펴보고, 완전한 시스템을 소프트웨어 아키텍처로 변환하는 방법을 논의함 — 이것이 다음 장의 주제
챕터 3 함수형 아키텍처
- 목표: 도메인에 대한 이해를 함수형 프로그래밍 원칙 기반의 소프트웨어 아키텍처로 변환하는 방법을 다룸
- 전제: 아직 도메인을 제대로 이해하지 못한 “무지의 정점(peak of ignorance)” 단계이므로, 이 시점에 아키텍처를 깊이 고민하는 것은 시간 낭비
- 지금은 이벤트 스토밍, 인터뷰 등 요구사항 파악에 시간을 쓰는 것이 최선
- 그럼에도 대략적인 구현 계획은 필요한 이유
- 빠른 개발 주기에서는 도메인 전체를 이해하기 전에 일부를 먼저 구현해야 하는 경우가 많음
- 아직 만들어지지 않은 컴포넌트들을 어떻게 맞출지 미리 계획이 있어야 함
- “워킹 스켈레톤(walking skeleton)”: 시스템 전체 동작 방식을 보여주는 조악한 프로토타입을 만드는 것이 유용
- 구체적인 구현에 대한 이른 피드백은 지식의 공백을 발견하는 좋은 방법
- 소프트웨어 아키텍처도 그 자체로 하나의 도메인 - 아키텍처를 논할 때도 “유비쿼터스 언어” 원칙을 따라야 함
- Simon Brown의 “C4” 접근법 용어를 채택 - 소프트웨어 아키텍처를 4단계 레벨로 설명
| 레벨 | 설명 |
|---|---|
| 시스템 콘텍스트 (System Context) | 최상위 레벨, 전체 시스템을 표현 |
| 컨테이너 (Container) | 배포 가능한 단위 - 웹사이트, 웹 서비스, 데이터베이스 등 |
| 컴포넌트 (Component) | 코드상의 주요 구조적 빌딩 블록 |
| 클래스 / 모듈 (Class / Module) | 저수준 메서드나 함수의 집합 - 함수형 아키텍처에서는 “모듈”이라 칭함 |
- 좋은 아키텍처의 목표: 컨테이너 - 컴포넌트 - 모듈 사이의 경계를 잘 정의하여, 새 요구사항이 생겼을 때 “변경 비용(cost of change)“을 최소화하는 것
3.1 경계 진 맥락을 자율적인 소프트웨어 컴포넌트로
- 경계 지어진 맥락(Bounded Context)의 전제 조건: 명확한 경계를 가진 자율적인 하위 시스템이어야 함
- 전체 시스템이 단일 모놀리식 배포 단위(C4 기준 하나의 컨테이너)인 경우, 경계 지어진 맥락을 구현하는 방식의 선택지
- 잘 정의된 인터페이스를 가진 단순한 모듈
- 더 명확한 경계를 가진 독립 컴포넌트 (예: .NET 어셈블리)
- 각 경계 지어진 맥락을 별도 컨테이너로 배포 - 전통적인 서비스 지향 아키텍처(SOA)
- 개별 워크플로 하나하나를 독립 배포 단위로 만드는 마이크로서비스 아키텍처
- 이 단계에서는 특정 방식에 미리 얽매일 필요 없음
- 논리적 설계를 배포 가능한 형태로 변환하는 방식 자체는 중요하지 않음
- 중요한 것은 경계 지어진 맥락들이 결합되지 않고(decoupled) 자율적(autonomous)으로 유지되는 것
- 프로젝트 초반에는 경계를 정확히 정하기 어려움 - 도메인을 더 알아갈수록 경계는 계속 변화한다고 예상해야 함
- 모놀리스가 리팩터링하기 훨씬 쉬움 → 처음에는 모놀리스로 구축하고, 필요할 때만 분리된 컨테이너로 리팩터링하는 것이 좋은 practice
- 처음부터 마이크로서비스로 뛰어들어 “마이크로서비스 프리미엄”(운영상의 추가 부담, Martin Fowler 개념)을 치를 필요 없음
- 이점이 단점을 명백히 능가한다는 확신이 있을 때만 채택
- 진정으로 결합되지 않은 마이크로서비스 아키텍처를 만드는 것은 까다로움
- ==마이크로서비스 하나를 껐을 때 다른 것이 깨진다면, 그것은 마이크로서비스 아키텍처가 아니라 “분산 모놀리스(distributed monolith)“에 불과함==
3.2 경계 진 맥락 간의 소통
- 질문: 경계 지어진 맥락들은 서로 어떻게 소통하는가?
- 예: 주문-접수(order-taking) 맥락이 처리를 마쳤을 때 배송(shipping) 맥락에게 어떻게 알리는가?
- 답: **이벤트(Event)**를 사용
예시 흐름
- 주문-접수 맥락의
Place-Order워크플로가OrderPlaced이벤트를 발행(emit) OrderPlaced이벤트가 큐(queue)에 게시(publish)됨- 배송 맥락이
OrderPlaced이벤트를 리스닝 - 이벤트 수신 시
ShipOrder커맨드가 생성됨 ShipOrder커맨드가Ship-Order워크플로를 시작시킴Ship-Order워크플로가 성공적으로 끝나면OrderShipped이벤트를 발행
flowchart LR
subgraph OT["Order-Taking"]
POW["Place-Order Workflow"]
end
subgraph SP["Shipping"]
SOW["Ship-Order Workflow"]
end
POW -- "OrderPlaced event" --> Q[("queue")]
Q -- "ShipOrder command" --> SOW
SOW -- "OrderShipped event" --> OUT(("다운스트림 맥락"))
- 이 설계는 완전히 결합되지 않은(decoupled) 구조
- 업스트림 컴포넌트(주문-접수)와 다운스트림 컴포넌트(배송)는 서로를 인지하지 못하고, 오직 이벤트를 통해서만 소통
- 진정으로 자율적인 컴포넌트가 되려면 이런 종류의 결합도 감소(decoupling)가 핵심
- 이벤트 전달 메커니즘은 선택한 아키텍처에 따라 달라짐
- 큐(Queue): 버퍼링된 비동기 통신에 적합 - 마이크로서비스나 에이전트 구현에서 우선 선택
- 모놀리식 시스템: 동일한 큐잉 방식을 내부적으로 쓰거나, 업스트림과 다운스트림 컴포넌트를 단순 함수 호출(direct linkage)로 연결 가능
- 지금 당장 정할 필요는 없음 - 컴포넌트들이 결합되지 않도록 설계하기만 하면 됨
- 이벤트를 커맨드로 변환하는 핸들러의 위치
- 다운스트림 맥락의 일부(맥락 경계에 위치)로 둘 수 있음
- 또는 별도의 라우터(router, Enterprise Integration Patterns의 Message Router 패턴)나 프로세스 매니저(process manager)로서 인프라의 일부로 실행할 수도 있음
- 이벤트와 커맨드 사이의 결합을 어디에 둘 것인지는 아키텍처 선택에 달림
개념을 코드로 표현하면 (참고용)
F#
type OrderPlaced = OrderPlaced of OrderDto
type ShipOrderCommand = ShipOrderCommand of OrderDto
TypeScript
interface OrderPlacedEvent {
type: "OrderPlaced";
order: OrderDto;
}
interface ShipOrderCommand {
type: "ShipOrder";
order: OrderDto;
}
3.2.1 경계 진 맥락 간 데이터 전송
- 맥락 간 통신에 쓰이는 이벤트는 단순 신호가 아니라, 다운스트림 컴포넌트가 이벤트를 처리하는 데 필요한 모든 데이터를 포함하는 것이 일반적
- 예:
OrderPlaced이벤트는 배치된 주문 전체 정보를 포함할 수 있음 → 배송 맥락이ShipOrder커맨드를 구성하는 데 필요한 모든 정보를 제공 - 데이터가 너무 크면, 공유 데이터 저장소에 대한 참조(reference)만 전달하는 방식도 가능
- 예:
- 맥락 간에 주고받는 데이터 객체는 경계 지어진 맥락 내부의 **도메인 객체(domain object)**와 겉보기엔 비슷하지만 동일한 것이 아님
- 이런 객체를 **DTO(Data Transfer Object)**라고 부름 (DDD 밖에서 유래한 용어를 조금 다르게 사용)
OrderPlaced이벤트에 담긴OrderDTO는Order도메인 객체와 대부분 같은 정보를 담지만, 목적에 맞게 다르게 구조화됨
- 업스트림 맥락 경계에서: 도메인 객체 → DTO로 변환 → JSON/XML 등으로 직렬화(Serialize)
flowchart LR
subgraph DB1["도메인 경계 (Domain Boundary)"]
A["Domain Type"] --> B["Domain Type to DTO"]
B --> C["DTO Type"]
end
C --> D["Serialize"]
D --> E["JSON / XML"]
E --> F(["다운스트림 맥락으로"])
- 다운스트림 맥락에서는 반대 과정이 반복됨: JSON/XML 역직렬화 → DTO → 도메인 객체로 변환
flowchart LR
A(["업스트림 맥락으로부터"]) --> B["JSON / XML"]
B --> C["Deserialize"]
C --> D["DTO Type"]
subgraph DB2["도메인 경계 (Domain Boundary)"]
D --> E["DTO to Domain Type"]
E --> F["Domain Type"]
end
- 실무에서 직렬화되는 최상위 DTO는 보통 이벤트 DTO이며, 그 안에 하위 DTO(예:
Order용 DTO)를 포함하고, 그 하위 DTO가 다시 추가 하위 DTO(예:OrderLines리스트)를 포함하는 중첩 구조를 가짐
DTO 예시 (개념 표현, 참고용)
F#
type OrderDto = {
OrderId: string
CustomerId: string
ShippingAddress: AddressDto
Lines: OrderLineDto list
}
TypeScript
interface OrderDto {
orderId: string;
customerId: string;
shippingAddress: AddressDto;
lines: OrderLineDto[];
}
3.2.2 신뢰 경계와 검증
- 경계 지어진 맥락의 경계(perimeter)는 “신뢰 경계(trust boundary)” 역할을 함
- 맥락 내부: 신뢰되고 유효한 것으로 간주
- 맥락 외부: 신뢰할 수 없고 무효할 수 있는 것으로 간주
- 따라서 워크플로의 시작과 끝에, 신뢰된 도메인과 신뢰할 수 없는 외부 세계를 중재하는 **“게이트(gate)”**를 둠
flowchart LR
U1["신뢰할 수 없는 외부 세계"] -.-> D1(["DTO"])
D1 --> IG(("입력 게이트<br/>input gate"))
subgraph TRUSTED["신뢰된, 깨끗한 도메인 모델"]
IG --> WF["Workflow"]
WF --> OG(("출력 게이트<br/>output gate"))
end
OG --> D2(["DTO"])
D2 -.-> U2["신뢰할 수 없는 외부 세계"]
- 입력 게이트(input gate): 입력이 도메인 모델의 제약 조건을 만족하는지 항상(always) 검증
- 예:
Order의 특정 속성이 null이 아니고 50자 미만이어야 한다는 제약이 있을 때, 수신된OrderDTO는 이런 제약이 없어 무엇이든 담길 수 있음. 입력 게이트에서 검증을 통과해야만Order도메인 객체가 유효함을 보장할 수 있음 - 검증 실패 시: 나머지 워크플로는 우회(bypass)되고 오류가 생성됨
- 예:
- 출력 게이트(output gate): 역할이 다름 - 비공개 정보가 경계 지어진 맥락 밖으로 유출되지 않도록 보장 (맥락 간 의도치 않은 결합 방지 + 보안 목적)
- 예: 배송 맥락은 주문 결제에 쓰인 신용카드 번호를 알 필요가 없음
- 출력 게이트는 도메인 객체를 DTO로 변환하는 과정에서 (카드번호 같은) 정보를 의도적으로 “잃어버림(lose)”
3.3 경계 진 맥락 간의 계약
- 맥락 간 결합을 최대한 줄이고 싶지만, 공유되는 통신 포맷은 필연적으로 어느 정도의 결합을 유발함
- 이벤트와 관련 DTO는 경계 지어진 맥락 간의 일종의 **“계약(contract)”**을 형성
- 두 맥락은 성공적인 소통을 위해 공통 포맷에 합의해야 함
- 누가 계약을 결정하는가? DDD 커뮤니티가 정립한 세 가지 대표적 관계 유형
| 관계 유형 | 설명 | 예시 (주문 도메인) |
|---|---|---|
| 공유 커널 (Shared Kernel) | 두 맥락이 공통 도메인 설계를 공유 - 관련 팀들이 반드시 협력해야 함. 이벤트/DTO 정의 변경 시 반드시 관련 맥락 소유자와 상의 필요 | 주문-접수와 배송 맥락이 배송 주소에 대해 동일한 설계를 공유 |
| 고객/공급자 (Customer/Supplier) 또는 소비자 주도 계약 (Consumer Driven Contract) | 다운스트림 맥락이 원하는 계약을 정의, 업스트림은 그 의무만 이행. 두 도메인은 독립적으로 진화 가능 | 청구(billing) 맥락이 “고객에게 청구하려면 이 정보가 필요”라는 계약을 정의하면, 주문-접수는 그 정보만 정확히 제공 |
| 순응자 (Conformist) | 소비자 주도의 반대 - 다운스트림이 업스트림이 제공하는 계약을 그대로 수용하고 자신의 도메인 모델을 맞춤 | 주문-접수 맥락이 상품 카탈로그가 정의한 계약을 그대로 받아들여 사용 |
3.3.1 부패 방지 계층
- 외부 시스템과 통신할 때, 사용 가능한 인터페이스가 우리 도메인 모델과 전혀 맞지 않는 경우가 흔함
- 이 경우 상호작용과 데이터를 경계 지어진 맥락 내부에서 쓰기 적합한 형태로 변환해야 함
- 그렇지 않으면 도메인 모델이 외부 시스템 모델에 적응하려다 **“부패(corrupted)”**됨
- 이러한 맥락 간 결합 완화 계층을 DDD 용어로 **부패 방지 계층(Anti-Corruption Layer, ACL)**이라 부름
- 3.2.2절의 다이어그램에서 “입력 게이트”가 종종 ACL 역할을 함께 수행
- 내부의 순수한 도메인 모델이 외부 세계에 대한 지식으로 “부패”되는 것을 방지
- ==ACL은 근본적으로 검증이나 데이터 손상 방지가 목적이 아니라, 서로 다른 두 언어(업스트림 맥락의 언어, 다운스트림 맥락의 언어) 사이를 번역하는 통역자 역할==
- 예: 주문-접수 예제에서 “주문-접수” 어휘를 “배송” 어휘로 번역하는 ACL을 두면, 각자의 어휘를 가진 두 맥락이 독립적으로 진화 가능
3.3.2 맥락 간 관계를 나타내는 맥락 지도
- 설계가 진행되어 맥락 간 관계가 다음과 같이 결정되었다고 가정
| 관계 | 유형 |
|---|---|
| 주문-접수 ↔ 배송 | 공유 커널 (Shared Kernel) - 통신 계약을 공동 소유 |
| 주문-접수 ↔ 청구 | 소비자 주도 계약 (Consumer-Driven Contract) - 청구 맥락이 계약을 결정, 주문-접수는 필요한 데이터만 정확히 제공 |
| 주문-접수 ↔ 상품 카탈로그 | 순응자 (Conformist) - 주문-접수가 상품 카탈로그와 동일한 모델 사용에 순응 |
| 주문-접수 ↔ 외부 주소 확인 서비스 | 도메인과 전혀 유사하지 않은 모델 → 명시적 부패 방지 계층(ACL) 삽입 |
- 서드파티 컴포넌트를 사용할 때 흔한 패턴 - 벤더 락인을 방지하고, 이후 다른 서비스로 교체 가능하게 함
flowchart TD
AC["Address Checking"] -- "ACL" --> OT["Order-taking"]
PC["Product Catalog"] -- "Conformist" --> OT
OT -- "Shared Kernel" --> SH["Shipping"]
OT -- "Consumer Driven" --> BI["Billing"]
- 이 맥락 지도는 이제 순수한 기술적 관계뿐 아니라, 맥락을 소유하는 팀 간의 관계와 그들이 어떻게 협업할 것으로 예상되는지(혹은 협업하지 않을지)도 함께 보여줌
- 도메인 간 상호작용 방식 결정은 기술적 과제만큼이나 조직적 과제
- 일부 팀은 **“역 콘웨이 전략(inverse Conway maneuver)”**을 사용하여 조직 구조를 아키텍처에 맞춤
3.4 경계 진 맥락의 작업 흐름
- 발견(discovery) 과정에서는 비즈니스 워크플로를 커맨드에 의해 시작되어 하나 이상의 도메인 이벤트를 생성하는 미니 프로세스로 취급
- 함수형 아키텍처에서는 각 워크플로를 단일 함수(single function)로 매핑
- 입력: 커맨드 객체
- 출력: 이벤트 객체의 리스트
개념을 코드로 표현하면 (참고용)
F#
type Workflow<'Command, 'Event> = 'Command -> 'Event list
TypeScript
type Workflow<TCommand, TEvent> = (command: TCommand) => TEvent[];
- 설계 다이어그램에서 워크플로는 입력과 출력이 있는 작은 파이프(pipe)로 표현
- 공개 워크플로(public workflow): 경계 지어진 맥락 외부에서 트리거되는 워크플로 - 경계 위로 약간 “튀어나온(sticking out)” 형태로 표시
- 내부 워크플로(internal workflow): 맥락 내부에서만 호출됨
flowchart TB
subgraph BC["Bounded Context"]
C1["Command"] --> PW1["Public Workflow"] --> E1["Events"]
C2["Command"] --> PW2["Public Workflow"] --> E2["Events"]
IW["Internal Workflow"]
end
- ==워크플로는 항상 단일 경계 지어진 맥락 내에 포함되며, 여러 맥락을 거쳐 “엔드투엔드(end-to-end)“로 시나리오를 구현하지 않음==
3.4.1 작업 흐름 입력 및 출력
- 워크플로의 입력은 항상 커맨드와 연관된 데이터, 출력은 항상 다른 맥락에 전달할 이벤트 집합
- 주문-배치(order-placing) 워크플로 예: 입력은
PlaceOrder커맨드 데이터, 출력은OrderPlaced이벤트 등 이벤트 집합
- 주문-배치(order-placing) 워크플로 예: 입력은
- 청구 맥락과 “고객/공급자” 관계가 정해졌으므로, 배송용 범용
OrderPlaced이벤트를 청구 맥락에 그대로 보내는 대신, 청구가 필요로 하는 정보만 정확히 보내야 함- 예: 청구 주소와 청구할 총액만 필요 - 배송 주소나 품목 목록은 불필요
- 따라서 워크플로에서 새로운 이벤트(
BillableOrderPlaced)를 발행해야 함
원서 예시 (원서의 경량 표기법)
data BillableOrderPlaced =
OrderId
AND BillingAddress
AND AmountToBill
실제 F# 레코드 타입 스타일로 표현하면
type BillableOrderPlaced = {
OrderId: OrderId
BillingAddress: Address
AmountToBill: BillingAmount
}
TypeScript로 표현하면
interface BillableOrderPlaced {
orderId: OrderId;
billingAddress: Address;
amountToBill: BillingAmount;
}
OrderAcknowledgmentSent이벤트도 함께 발행될 수 있음- 이런 변경으로 인해 이전(원서 29쪽)의 단순한 워크플로 다이어그램은 부정확해졌으므로 아래처럼 업데이트 필요
flowchart LR
IN["Input:<br/>Order form"] --> WF["Workflow:<br/>Place Order"]
WF --> EV1["OrderAcknowledgmentSent"]
WF --> EV2["OrderPlaced event<br/>(for Shipping)"]
WF --> EV3["BillableOrderPlaced event<br/>(for Billing)"]
WF -. "side effect" .-> SE["주문 확인서를<br/>고객에게 발송"]
- ==이 다이어그램에서 중요한 점: 워크플로 함수는 도메인 이벤트를 “발행(publish)“하지 않고 단순히 반환(return)할 뿐임. 이벤트가 실제로 어떻게 발행되는지는 별도의 관심사==
3.4.2 경계 진 맥락 내 도메인 이벤트 금지
- 객체지향 설계에서는 경계 지어진 맥락 내부에서 도메인 이벤트를 발생(raise)시키는 것이 일반적
- 워크플로 객체가
OrderPlaced이벤트를 발생시킴 - 핸들러가 이 이벤트를 리스닝해 주문 확인서를 발송
- 다른 핸들러가
BillableOrderPlaced이벤트를 생성 - …
- 워크플로 객체가
flowchart LR
IN["Input:<br/>Order form"] --> WF["Workflow:<br/>Place Order"]
WF --> OP["OrderPlaced event"]
OP -. "listen" .-> L1["Event listener:<br/>Acknowledge Order"]
L1 --> EV1["OrderAcknowledgmentSent"]
L1 -. "side effect" .-> SE["주문 확인서 발송"]
OP -. "listen" .-> L2["Event listener:<br/>Create BillableOrder"]
L2 --> EV2["BillableOrderPlaced"]
- 함수형 설계에서는 이 방식을 선호하지 않음 - **숨겨진 의존성(hidden dependency)**을 만들기 때문
- 대신 이벤트에 대한 “리스너”가 필요하면, 단순히 워크플로의 끝에 이어 붙임(append)
flowchart LR
IN["Input:<br/>Order form"] --> WF["Workflow:<br/>Place Order"]
WF --> AO["Acknowledge Order"]
AO --> CB["Create BillableOrder"]
AO --> EV1["OrderAcknowledgmentSent"]
AO -. "side effect" .-> SE["주문 확인서 발송"]
CB --> EV2["OrderPlaced event"]
CB --> EV3["BillableOrderPlaced"]
- ==이 접근법이 더 명시적(explicit) - 가변 상태(mutable state)를 가진 전역 이벤트 매니저가 없음 → 이해하고 유지보수하기 쉬움==
- 구현(Implementation) 장(원서 161쪽)과 설계 진화(Evolving A Design) 장(원서 265쪽)에서 실제 적용 사례를 다룸
두 접근법 비교
| 구분 | 객체지향(OO) 방식 | 함수형(FP) 방식 |
|---|---|---|
| 이벤트 처리 | 내부에서 이벤트를 발생(raise)시키고 리스너가 구독 | 워크플로 끝에 후속 단계를 명시적으로 연결(append) |
| 의존성 | 이벤트-리스너 간 숨겨진 의존성 존재 | 모든 의존성이 코드 흐름에 명시적으로 드러남 |
| 상태 | 전역 이벤트 매니저 등 가변 상태 필요 가능 | 가변 상태 없이 순수 함수 조합으로 구성 |
3.5 경계 진 맥락의 코드 구조
- 경계 지어진 맥락 내부의 코드가 어떻게 구조화되는지 살펴봄
- 전통적인 “계층형 접근(layered approach)”: 핵심 도메인/비즈니스 로직 계층, 데이터베이스 계층, 서비스 계층, API/사용자 인터페이스 계층(또는 변형)으로 코드를 분할
- 워크플로는 최상위 계층에서 시작해 데이터베이스 계층까지 내려갔다가 다시 최상위로 반환됨
flowchart TD
W1["Workflow"] --> API["API / Interface Layer"]
W2["Workflow"] --> API
API --> SVC["Services Layer"]
SVC --> DOM["Domain Layer"]
DOM --> DBL["Database Layer"]
DBL -. "반환" .-> DOM
DOM -. "반환" .-> SVC
SVC -. "반환" .-> API
- 이 접근법의 문제점
- ==**“함께 변경되는 코드는 함께 있어야 한다(code that changes together belongs together)”**는 중요한 설계 원칙을 위반==
- 계층이 “수평적으로(horizontally)” 조립되어 있어, 워크플로의 동작 방식이 바뀌면 모든 계층을 건드려야 함
- 더 나은 방법: “수직(vertical)” 슬라이스로 전환
- 각 워크플로가 자신에게 필요한 모든 코드를 포함
- 워크플로의 요구사항이 바뀌면 해당 수직 슬라이스의 코드만 변경하면 됨
flowchart TD
subgraph S1["워크플로 1 (수직 슬라이스)"]
A1["API/Interface"] --> B1["Services"] --> C1["Domain"] --> D1["Database"]
end
subgraph S2["워크플로 2 (수직 슬라이스)"]
A2["API/Interface"] --> B2["Services"] --> C2["Domain"] --> D2["Database"]
end
- 이것도 완벽하지 않음 - 워크플로를 수평 파이프로 늘려서 계층들을 다시 살펴보면, 계층이 뒤섞여(intermingled) 등장함
flowchart LR
S(("Workflow 시작")) --> N1["API/Interface"]
N1 --> N2["Domain"]
N2 --> N3["Services"]
N3 --> N4["Domain"]
N4 --> N5["Database"]
N5 --> N6["API/Interface"]
N6 --> E(("Workflow"))
- ==계층이 뒤섞여 있어(intermingled) 로직을 이해하고 테스트하기가 불필요하게 복잡해짐==
3.5.1 양파 아키텍처
- 대신 도메인 코드를 중심에 두고, 그 주위에 다른 관심사들을 조립
- 규칙: 각 계층은 오직 안쪽 계층에만 의존할 수 있고, 바깥쪽 계층에는 의존할 수 없음
- ==즉, 모든 의존성은 안쪽을 향해야(point inward) 함==
- 이를 **“양파 아키텍처(Onion Architecture)”**라고 부름 (Jeffrey Palermo)
flowchart LR
subgraph L1["Database / Infrastructure"]
subgraph L2["API / Interface"]
subgraph L3["Services"]
subgraph L4["Domain (핵심 도메인)"]
CORE(("비즈니스 로직"))
end
end
end
end
WF(("Workflow")) --> CORE
CORE --> WF
- 유사한 접근법
- 헥사고날 아키텍처(Hexagonal Architecture) (Alistair Cockburn)
- 클린 아키텍처(Clean Architecture) (“Uncle Bob” Robert C. Martin)
- 모든 의존성이 안쪽을 향하도록 하려면, 함수형 버전의 **의존성 주입(dependency injection)**이 필요
- “구현: 파이프라인 조합(Implementation: Composing a Pipeline)” 장에서 상세히 다룸
3.5.2 외부 입출력을 맥락 경계로 내몰기
- 함수형 프로그래밍의 주요 목표: 내부를 들여다보지 않고도 예측 가능하고 추론하기 쉬운 함수로 작업하는 것
- 이를 위한 실천 방법
- 가능한 한 **불변 데이터(immutable data)**를 사용
- 함수가 숨겨진 의존성 대신 명시적(explicit) 의존성을 갖도록 함
- ==가장 중요한 것: 함수 내 사이드 이펙트(side effect)를 피함 - 무작위성(randomness), 함수 외부 변수의 변경(mutation), 그리고 무엇보다 모든 종류의 I/O==
- 데이터베이스나 파일 시스템을 읽거나 쓰는 함수는 **“불순(impure)”**한 것으로 간주 → 핵심 도메인에서는 이런 함수를 피함
개념을 코드로 표현하면 (참고용)
F#
// 순수 함수 - 핵심 도메인 로직, 사이드 이펙트 없음
let validateOrder : UnvalidatedOrder -> Result<ValidatedOrder, ValidationError> =
fun order -> failwith "구현 생략"
// 불순 함수 - I/O 포함, 인프라스트럭처 관심사이므로 도메인 계층에서 배제
let saveOrder : ValidatedOrder -> Async<unit> =
fun order -> failwith "구현 생략"
TypeScript
// 순수 함수 - 핵심 도메인 로직, 사이드 이펙트 없음
type ValidateOrder = (
order: UnvalidatedOrder
) => Result<ValidatedOrder, ValidationError>;
// 불순 함수 - I/O 포함, 인프라스트럭처 관심사이므로 도메인 계층에서 배제
type SaveOrder = (order: ValidatedOrder) => Promise<void>;
- 그렇다면 데이터를 어떻게 읽고 쓰는가?
- 답: 모든 I/O를 양파의 가장자리(edge)로 밀어냄
- 예: 데이터베이스 접근은 워크플로의 시작이나 끝에서만 이루어지고, 워크플로 내부에서는 이루어지지 않음
- 부가적인 이점: 관심사 분리를 강제함
- 핵심 도메인 모델은 오직 비즈니스 로직에만 관심을 가짐
- 영속성(persistence)과 기타 I/O는 인프라스트럭처의 관심사가 됨
- 이는 앞서 2장에서 소개된 “영속성 무지(persistence ignorance)” 개념과 잘 맞물림
- ==워크플로 내부에서 데이터베이스에 접근조차 할 수 없다면, 애초에 데이터베이스를 사용해 도메인을 모델링할 수 없음==
- Persistence 장에서 상세히 다룸
3.6 마무리
- 이번 장에서 소개된 DDD 관련 개념과 용어 정리
| 용어 | 정의 |
|---|---|
| 도메인 객체 (Domain Object) | DTO와 달리, 맥락 경계 내부에서만 사용하도록 설계된 객체 |
| 데이터 전송 객체 (DTO) | 맥락 간 직렬화되어 공유되도록 설계된 객체 |
| 공유 커널 / 고객-공급자 / 순응자 | 경계 지어진 맥락 간의 서로 다른 관계 유형 |
| 부패 방지 계층 (ACL) | 결합도를 줄이고 도메인이 독립적으로 진화하도록, 한 도메인의 개념을 다른 도메인으로 번역하는 컴포넌트 |
| 영속성 무지 (Persistence Ignorance) | 도메인 모델이 오직 도메인 자체의 개념에만 기반해야 하며, 데이터베이스 등 영속화 메커니즘에 대한 인지를 포함하지 않아야 한다는 원칙 |
3.6.1 다음 장 안내
- 도메인에 대한 이해와, 솔루션을 설계하는 일반적인 접근법을 갖췄으므로, 다음은 개별 워크플로를 모델링하고 구현하는 과제로 이동
- 다음 몇 개 장에서는 F# 타입 시스템을 사용해 워크플로와 그것이 사용하는 데이터를 정의
- 목표: 도메인 전문가와 비개발자도 이해할 수 있는, 컴파일 가능한 코드를 작성
- 그에 앞서 필요한 선행 이해
- 함수형 프로그래머에게 **“타입(type)”**이 무엇을 의미하는지
- 그것이 객체지향 설계의 **“클래스(class)”**와 어떻게 다른지
- 이것이 다음 장(챕터 4)의 주제
파트 2 도메인 모델링하기
- 도메인의 워크플로 하나를 골라 함수형 방식으로 모델링하는 파트
- 함수형 도메인 분해가 객체지향 접근과 어떻게 다른지 확인
- 타입을 이용해 요구사항을 어떻게 포착하는지 학습
- 목표: 다음 두 가지 역할을 동시에 하는 간결한 코드를 작성하는 것
- 도메인에 대한 가독성 있는 문서
- 나머지 구현이 그 위에 쌓일 수 있는, 컴파일되는 프레임워크
챕터 4 타입 이해하기
- 2장에서 주문 처리 시스템의 한 워크플로에 대한 도메인 주도 요구사항을 이미 포착함
- 이번 장의 과제: 그 비정형 요구사항을 컴파일 가능한 코드로 변환하는 것
- 채택하는 접근법: F#의 “대수적 타입 시스템”(algebraic type system)으로 요구사항을 표현
- 이번 장에서 학습할 것
- 대수적 타입이 무엇인지
- 대수적 타입을 어떻게 정의하고 사용하는지
- 대수적 타입으로 도메인 모델을 어떻게 표현하는지
- 다음 장(5장)에서는 이번 장 내용을 바탕으로 주문 처리 워크플로를 정확히 모델링함
4.1 함수 이해하기
- 타입을 이해하기 전에 먼저 함수형 프로그래밍에서 가장 기초적인 개념인 함수(function)를 이해해야 함
- 고등학교 수학의 함수 개념: 입력과 출력이 있는 일종의 블랙박스
- 비유: 철로 위에 놓인 “변환의 터널”(Tunnel of Transformation)
- 무언가가 들어가서, 어떤 방식으로 변환되고, 반대편으로 나옴
- 예시: 사과를 바나나로 바꾸는 함수
- 함수는 입력과 출력을 화살표(
->)로 구분해서 표기
flowchart LR
Apple(["사과 Apple"]) --> Tunnel["변환의 터널<br/>Function: Apple → Banana"] --> Banana(["바나나 Banana"])
4.1.1 함수 이해하기
타입 시그니처(Type Signatures)
apple -> banana같은 표기를 타입 시그니처(= 함수 시그니처)라고 부름- 이번 예시는 단순하지만, 타입 시그니처는 매우 복잡해질 수 있음. F# 코딩에서 타입 시그니처를 읽고 쓰는 능력은 필수적
add1은 입력 하나에 1을 더하고,add는 두 입력x,y를 더함
let add1 x = x + 1 // signature is: int -> int
let add x y = x + y // signature is: int -> int -> int
// add1: (x: number) => number
const add1 = (x: number): number => x + 1;
// add: (x: number, y: number) => number
const add = (x: number, y: number): number => x + y;
- 함수 정의 문법 특징
let키워드로 함수를 정의- 파라미터는 괄호나 콤마 없이 공백으로 구분
- C#이나 Java와 달리
return키워드가 없음 - 함수 정의의 마지막 표현식이 곧 함수의 출력값
- F#은 입력과 출력의 타입을 신경 쓰지만, 대부분의 경우 컴파일러가 자동으로 타입을 추론하므로 명시적으로 선언할 필요가 거의 없음
add1:x의 추론된 타입(화살표 앞)은int, 출력의 추론된 타입(화살표 뒤)도int→ 시그니처는int -> intadd:x,y의 추론된 타입은int, 출력의 추론된 타입도int. 파라미터가 두 개이므로 각 파라미터가 화살표로 구분되어 시그니처는int -> int -> int
- Visual Studio 같은 IDE에서는 함수 정의에 마우스를 올리면 타입 시그니처가 표시됨. 책에서는 필요할 때 정의 위에 주석으로 타입 시그니처를 적어줌(컴파일러가 사용하는 것은 아니고 그냥 주석)
- 여러 줄로 이루어진 함수는 파이썬처럼 들여쓰기로 작성하며, 중괄호가 없음
// squarePlusOne : int -> int
let squarePlusOne x =
let square = x * x
square + 1
// squarePlusOne: (x: number) => number
function squarePlusOne(x: number): number {
const square = x * x;
return square + 1;
}
- 위 예시는 함수 내부에 서브함수(
let square = ...)를 정의할 수 있음을 보여주며, 여기서도 마지막 줄(square + 1)이 반환값
제네릭 타입을 갖는 함수(Functions with Generic Types)
- 함수가 어떤 타입에도 동작한다면, 컴파일러는 자동으로 제네릭 타입을 추론함(
areEqual예시)
// areEqual : 'a -> 'a -> bool
let areEqual x y =
(x = y)
// areEqual: <T>(x: T, y: T) => boolean
function areEqual<T>(x: T, y: T): boolean {
return x === y;
}
areEqual에서x,y의 추론된 타입은'a. 작은따옴표 + 문자는 F#에서 제네릭 타입을 나타내는 표기법x,y는 같은 타입이기만 하면 어떤 타입이든 될 수 있음- F#에서 동등 비교 연산자는
=이며, C 계열 언어의==가 아님
static bool AreEqual<T>(T x, T y)
{
return (x == y);
}
4.2 타입과 함수
- F#과 같은 프로그래밍 언어에서 타입(type)이 무엇을 의미하는지 정리
- ==타입은 객체지향의 클래스(class)와 다름. 훨씬 단순함==
- 타입이란 함수의 입력 또는 출력으로 사용될 수 있는, 가능한 값들의 집합에 붙인 이름일 뿐
- 타입 자체에는 그 이상의 특별한 의미나 동작이 없음
flowchart LR
subgraph IN["입력값의 집합 Set of valid inputs"]
end
subgraph OUT["출력값의 집합 Set of valid outputs"]
end
IN --> F["함수 Function<br/>input → output"] --> OUT
- 예: -32768부터 +32767까지의 정수 집합에
int16이라는 이름(label)을 붙임
flowchart LR
I["-32768, -32767, ...<br/>-2, -1, 0, 1, 2, ...<br/>32766, 32767<br/>(이 값들의 집합이 곧 'int16' 타입)"] --> F["함수 Function<br/>int16 → output"] --> O["출력값의 집합"]
int16 -> someOutputType
type Int16 = number; // 개념상 -32768 ~ 32767 범위로 제한된다고 가정
type Fn = (input: Int16) => SomeOutputType;
- 타입에 속한 값들이 반드시 원시(primitive) 값일 필요는 없음. 아래는 세 가지 예시
| 타입 이름 | 값의 집합 예시 |
|---|---|
string |
'abc', 'but', 'cobol', 'double', 'end', 'float' 등 가능한 모든 문자열 |
Person |
Donna Roy, Javier Mendoza, Nathan Logan, Shawna Ingram, Abel Ortiz, Lena Robbins 등 |
Fruit |
사과, 바나나, 체리, 레몬 등 |
someInputType -> string
type Fn = (input: SomeInputType) => string;
- 개념적으로 타입에 속한 것은 실제 사물이든 가상의 것이든 어떤 종류든 될 수 있음(Person, Fruit 예시)
- 함수 역시 하나의 “것”이므로, 함수들의 집합도 타입으로 사용 가능
- 예: 입력을 받아 “Fruit를 Fruit로 바꾸는 함수”를 출력하는 함수
flowchart LR
I2["입력값의 집합"] --> F2["함수 Function<br/>input → (Fruit → Fruit)"] --> O2["Fruit → Fruit 함수들의 집합<br/>(타입: 'Fruit → Fruit')"]
- 출력 집합의 각 원소는
Fruit -> Fruit함수이므로, 전체 함수의 시그니처는 다음과 같음
someInputType -> (Fruit -> Fruit)
type Fn = (input: SomeInputType) => (fruit: Fruit) => Fruit;
용어 정리: “값(Values)” vs “객체(Objects)” vs “변수(Variables)”
- 함수형 프로그래밍 언어에서는 대부분의 것을 “값(value)“이라 부르고, 객체지향 언어에서는 대부분 “객체(object)“라고 부름
- 값(value): 어떤 타입의 구성원. 함수의 입력 또는 출력으로 사용될 수 있는 것. 예:
1은int타입의 값,"abc"는string타입의 값- 함수도 값이 될 수 있음. 예를 들어
let add1 x = x + 1을 정의하면add1은int -> int타입의 (함수) 값- 값은 불변(immutable)임 — 그래서 “변수”라고 부르지 않음. 또한 값에는 어떤 동작(behavior)도 붙어있지 않고, 그저 데이터일 뿐
- 객체(object): 데이터 구조와 그에 결합된 동작(메서드)의 캡슐화. 일반적으로 객체는 상태(mutable)를 가지며, 내부 상태를 바꾸는 모든 연산은 반드시 객체 자신을 통해(점 표기법으로) 제공되어야 함
- 따라서 객체가 존재하지 않는 함수형 프로그래밍 세계에서는 “변수”나 “객체”보다 “값”이라는 용어를 사용해야 함
4.3 타입 합성
- 함수형 프로그래밍에서 “합성(composition)“이라는 단어는 매우 자주 등장하며, 함수형 설계의 근간이 됨
- 합성이란 레고 블록을 조립하듯 두 가지를 결합해 더 큰 것을 만드는 것
- 함수형 프로그래밍 세계에서는 합성을 이용해
- 작은 함수들로부터 새로운 함수를 만들고(함수 합성은 8장에서 다룸)
- 작은 타입들로부터 새로운 타입을 만듦(타입 합성은 지금 다룸)
- F#에서 새로운 타입을 만드는 두 가지 방법
AND로 결합하기OR로 결합하기
4.3.1 AND 타입
- 예: 과일 샐러드(FruitSalad)를 만들려면 사과 그리고(and) 바나나 그리고 체리가 필요함
flowchart LR
FS["FruitSalad"] -->|AND| A["Apple: AppleVariety"]
FS -->|AND| B["Banana: BananaVariety"]
FS -->|AND| C["Cherries: CherryVariety"]
- F#에서 이런 타입을 **레코드(record)**라고 부름
type FruitSalad = {
Apple: AppleVariety
Banana: BananaVariety
Cherries: CherryVariety
}
type FruitSalad = {
apple: AppleVariety;
banana: BananaVariety;
cherries: CherryVariety;
};
- 중괄호는 레코드 타입임을 나타내며,
Apple,Banana,Cherries세 개의 필드로 구성됨
4.3.2 OR 타입
- 다른 방법은
OR을 사용해 타입을 만드는 것 - 예: 과일 간식(FruitSnack)을 만들려면 사과 또는(or) 바나나 또는 체리 중 하나가 필요함
flowchart LR
FSN["FruitSnack"] -->|"OR (택1)"| A2["Apple of AppleVariety"]
FSN -->|"OR (택1)"| B2["Banana of BananaVariety"]
FSN -->|"OR (택1)"| C2["Cherries of CherryVariety"]
- 이런 “선택(choice)” 타입은 도메인 모델링에서 매우 유용하게 쓰임(책 전체에서 반복적으로 등장)
type FruitSnack =
| Apple of AppleVariety
| Banana of BananaVariety
| Cherries of CherryVariety
type FruitSnack =
| { kind: "Apple"; value: AppleVariety }
| { kind: "Banana"; value: BananaVariety }
| { kind: "Cherries"; value: CherryVariety };
- 이런 선택 타입을 F#에서는 **판별 유니온(discriminated union)**이라고 부름
- 읽는 법: FruitSnack은 (Apple 태그가 붙은) AppleVariety 이거나, (Banana 태그가 붙은) BananaVariety 이거나, (Cherries 태그가 붙은) CherryVariety이다
- 세로 막대(
|)로 각 선택지를 구분함 - 태그(Apple, Banana 등)가 필요한 이유: 두 개 이상의 선택지가 같은 타입을 가질 수 있으므로, 이를 구분하기 위한 표식이 필요하기 때문
- 과일의 품종(variety) 자체도
OR타입으로 정의되며, 이 경우는 다른 언어의 enum과 유사하게 사용됨
type AppleVariety =
| GoldenDelicious
| GrannySmith
| Fuji
type BananaVariety =
| Cavendish
| GrosMichel
| Manzano
type CherryVariety =
| Montmorency
| Bing
type AppleVariety = "GoldenDelicious" | "GrannySmith" | "Fuji";
type BananaVariety = "Cavendish" | "GrosMichel" | "Manzano";
type CherryVariety = "Montmorency" | "Bing";
- 읽는 법: AppleVariety는 GoldenDelicious 이거나 GrannySmith 이거나 Fuji이다 (이하 동일)
용어 정리: “곱 타입(Product Types)“과 “합 타입(Sum Types)”
AND로 만들어진 타입을 **곱 타입(product type)**이라고 부름OR로 만들어진 타입을 합 타입(sum type) 또는 태그드 유니온(tagged union), F# 용어로는 **판별 유니온(discriminated union)**이라고 부름. 이 책에서는 도메인 모델링에서의 역할을 가장 잘 설명한다는 이유로 이를 **선택 타입(choice type)**이라고 부름
4.3.3 단순 타입
- 선택지가 단 하나뿐인 선택 타입을 정의하는 경우가 자주 있음
type ProductCode =
| ProductCode of string
- 이 타입은 거의 항상 다음과 같이 한 줄로 단순화됨
type ProductCode = ProductCode of string
// 태그를 이용한 명목적(nominal) 래퍼 패턴
type ProductCode = { kind: "ProductCode"; value: string };
// 또는 브랜드(brand) 패턴으로 표현
type ProductCode = string & { readonly __brand: "ProductCode" };
- 왜 이런 타입을 만드는가?
string이나int같은 원시 타입을 내부 값으로 담는 “래퍼(wrapper)” 타입을 손쉽게 만들 수 있기 때문- 도메인 모델링을 할 때 이런 타입을 아주 많이 보게 됨
- 이 책에서는 레코드나 판별 유니온 같은 복합 타입과 구분해, 이런 단일 케이스 유니온을 **“단순 타입(simple type)”**이라고 표기함
4.3.4 대수적 타입 시스템
- 이제 “대수적 타입 시스템(algebraic type system)“이 무엇인지 정의할 수 있음
- 생각보다 무섭지 않은 개념: 모든 합성 타입이 더 작은 타입들을
AND또는OR로 결합해서 구성되는 타입 시스템을 말함 - F#은 대부분의 함수형 언어와 마찬가지로(그러나 OO 언어와 달리) 내장 대수적 타입 시스템을 가짐
- 타입을 만들 때 사용하는
AND와OR은, 앞선 장에서 도메인을 문서화할 때 사용했던AND,OR과 동일한 개념 — 익숙하게 느껴져야 함 - 대수적 타입 시스템은 도메인 모델링에 훌륭한 도구임을 곧 확인하게 됨
4.4 TypeScript 타입 다루기
- F#에서는 타입을 정의하는 방식과 타입의 값을 생성/해체하는 방식이 서로 매우 유사함(대칭적)
레코드 타입: 정의 / 생성 / 해체
- 레코드 타입 정의: 중괄호와
필드명: 타입목록 사용
type Person = {First:string; Last:string}
- 값 생성: 동일한 중괄호를 사용하되
=로 필드에 값을 대입
let aPerson = {First="Alex"; Last="Adams"}
- 패턴 매칭을 이용한 값 해체(deconstruct): 동일한 문법을 등호 왼쪽에 사용
let {First=first; Last=last} = aPerson
- 위 코드는
first,last값이 레코드의 해당 필드로 설정됨을 의미 - 레코드는 더 익숙한 점(
.) 표기법도 사용 가능 — 위 코드와 동등함
let first = aPerson.First
let last = aPerson.Last
type Person = { first: string; last: string };
// 값 생성
const aPerson: Person = { first: "Alex", last: "Adams" };
// 구조 분해 할당으로 해체
const { first, last } = aPerson;
// 점 표기법
const first2 = aPerson.first;
const last2 = aPerson.last;
판별 유니온: 정의 / 생성 / 해체 대칭성
- 정의: 세로 막대로 선택지를 구분하며, 각 선택지는
케이스라벨 of 타입형태
type OrderQuantity =
| UnitQuantity of int
| KilogramQuantity of decimal
- 생성: 케이스 라벨 중 하나를 생성자 함수로 사용하고, 연관된 데이터를 파라미터로 전달
let anOrderQtyInUnits = UnitQuantity 10
let anOrderQtyInKg = KilogramQuantity 2.5
- 케이스는 서브클래스가 아님.
UnitQuantity와KilogramQuantity는 그 자체로 타입이 아니라,OrderQuantity타입의 서로 다른 케이스일 뿐임. 위 예시에서 두 값은 모두 동일한 타입OrderQuantity를 가짐 - 해체: 판별 유니온을 해체하려면 반드시 패턴 매칭(
match .. with문법)을 사용해 각 케이스를 검사해야 함
let printQuantity aOrderQty =
match aOrderQty with
| UnitQuantity uQty ->
printfn "%i units" uQty
| KilogramQuantity kgQty ->
printfn "%g kg" kgQty
- 매칭 과정에서 특정 케이스에 연관된 데이터도 함께 사용 가능(
uQty값이UnitQuantity케이스에 매칭될 때 설정됨)
printQuantity anOrderQtyInUnits // "10 units"
printQuantity anOrderQtyInKg // "2.5 kg"
type OrderQuantity =
| { kind: "UnitQuantity"; value: number }
| { kind: "KilogramQuantity"; value: number };
// 생성
const anOrderQtyInUnits: OrderQuantity = { kind: "UnitQuantity", value: 10 };
const anOrderQtyInKg: OrderQuantity = { kind: "KilogramQuantity", value: 2.5 };
// 해체 (switch 문의 판별 필드로 타입이 좁혀짐 - narrowing)
function printQuantity(aOrderQty: OrderQuantity): string {
switch (aOrderQty.kind) {
case "UnitQuantity":
return `${aOrderQty.value} units`;
case "KilogramQuantity":
return `${aOrderQty.value} kg`;
}
}
console.log(printQuantity(anOrderQtyInUnits)); // "10 units"
console.log(printQuantity(anOrderQtyInKg)); // "2.5 kg"
- 참고: TypeScript에는 F#의
match같은 전용 패턴 매칭 문법은 없지만, 판별 필드(kind)를 갖는 유니온 타입에switch를 사용하면 컴파일러가 각case안에서 타입을 자동으로 좁혀주므로(narrowing) 유사한 안전성을 얻을 수 있음
4.5 타입으로 도메인 모델 만들기
- 합성 가능한 타입 시스템은 도메인 주도 설계에 큰 도움이 됨. 타입들을 다양한 조합으로 섞기만 해도 빠르게 복잡한 모델을 만들 수 있기 때문
- 예시 시나리오: 이커머스 사이트의 결제(payment)를 추적하고 싶다고 가정하고, 설계 세션에서 코드가 어떻게 스케치되는지 확인
1) 원시 타입의 래퍼(= 단순 타입)부터 시작
type CheckNumber = CheckNumber of int
type CardNumber = CardNumber of string
type CheckNumber = { kind: "CheckNumber"; value: number };
type CardNumber = { kind: "CardNumber"; value: string };
- 의미 있는 이름을 부여함으로써 이후 도메인을 훨씬 이해하기 쉽게 만들어줌
2) 저수준 타입 구성
CardType은OR타입(Visa 또는 Mastercard),CreditCardInfo는AND타입(레코드)
type CardType =
Visa | Mastercard // 'OR' type
type CreditCardInfo = { // 'AND' type (record)
CardType : CardType
CardNumber : CardNumber
}
type CardType = "Visa" | "Mastercard"; // OR 타입
type CreditCardInfo = { // AND 타입 (레코드)
cardType: CardType;
cardNumber: CardNumber;
};
3) PaymentMethod: Cash, Check, Card 중 하나를 선택하는 OR 타입
- 단순한 “enum”이 아님. 일부 선택지는 연관 데이터를 가짐 —
Check케이스는CheckNumber를,Card케이스는CreditCardInfo를 가짐
type PaymentMethod =
| Cash
| Check of CheckNumber
| Card of CreditCardInfo
type PaymentMethod =
| { kind: "Cash" }
| { kind: "Check"; value: CheckNumber }
| { kind: "Card"; value: CreditCardInfo };
4) 그 밖의 기본 타입
type PaymentAmount = PaymentAmount of decimal
type Currency = EUR | USD
type PaymentAmount = { kind: "PaymentAmount"; value: number };
type Currency = "EUR" | "USD";
5) 최상위 타입 Payment: PaymentAmount, Currency, PaymentMethod를 모두 가진 레코드
type Payment = {
Amount : PaymentAmount
Currency: Currency
Method: PaymentMethod
}
type Payment = {
amount: PaymentAmount;
currency: Currency;
method: PaymentMethod;
};
- 약 25줄의 코드만으로 꽤 쓸모 있는 타입 집합을 이미 정의한 것
graph TD
Payment --> Amount[PaymentAmount]
Payment --> Currency
Payment --> Method[PaymentMethod]
Method --> Cash
Method --> Check --> CheckNumber
Method --> Card --> CreditCardInfo
CreditCardInfo --> CardType
CreditCardInfo --> CardNumber
함수를 표현하는 타입으로 동작(behavior) 문서화하기
- 이 타입들에는 어떤 동작도 직접 결합되어 있지 않음. 함수형 모델이지 객체지향 모델이 아니기 때문
- 취할 수 있는 행동을 문서화하려면, 대신 함수를 나타내는 타입을 정의함
- 예:
Payment를 이용해 미결제 인보이스(UnpaidInvoice)를 결제하고, 최종적으로 결제 완료된 인보이스(PaidInvoice)를 만드는 방법이 있다는 것을 보여주고 싶다면
type PayInvoice =
UnpaidInvoice -> Payment -> PaidInvoice
type PayInvoice = (
unpaidInvoice: UnpaidInvoice,
payment: Payment
) => PaidInvoice;
- 의미: UnpaidInvoice와 Payment가 주어지면, PaidInvoice를 만들 수 있다
- 또 다른 예: 결제를 한 통화에서 다른 통화로 변환하는 경우
type ConvertPaymentCurrency =
Payment -> Currency -> Payment
type ConvertPaymentCurrency = (
payment: Payment,
currency: Currency
) => Payment;
- 첫 번째
Payment는 입력, 두 번째 파라미터(Currency)는 변환할 대상 통화, 두 번째Payment(출력)는 변환 후 결과
4.6 없어도 되는 값, 오류 및 컬렉션 모델링
- 지금까지 사용한 레코드와 선택 타입은 F#에서
null이 될 수 없음 → 도메인 모델에서 어떤 타입을 참조하든, 그것은 항상 필수값임 - 그렇다면 다음과 같은 상황은 F# 타입 시스템으로 어떻게 표현할 것인가
- 없어도 되는 값(선택적/누락 값)
- 오류
- 아무 값도 반환하지 않는 함수
- 컬렉션
4.6.1 없어도 되는 값 모델링
- 누락된 데이터가 의미하는 바를 생각해보면: 존재하거나(present) 부재하거나(absent) 둘 중 하나. 무언가 있거나, 아무것도 없거나
- 이를
Option이라는 선택 타입으로 모델링
type Option<'a> =
| Some of 'a
| None
flowchart TD
O["Option<'a>"] --> S["Some 'a<br/>(값이 있음)"]
O --> N["None<br/>(값이 없음)"]
Some케이스는 연관 값'a에 데이터가 저장되어 있음을 의미None케이스는 데이터가 없음을 의미'a의 작은따옴표는 F#에서 제네릭 타입을 나타내는 표기법 — 즉Option은 어떤 타입이든 감쌀 수 있음(C#/Java의Option<T>에 해당)Option타입은 F# 표준 라이브러리에 이미 정의되어 있으며, 함께 사용할 수 있는 다양한 헬퍼 함수도 풍부하게 제공됨- 도메인 모델에서 선택적 데이터를 표시하려면 C#이나 Java에서처럼 타입을
Option<..>으로 감싸면 됨. 예:PersonalName타입에서 이름(First)과 성(Last)은 필수지만 중간 이니셜(MiddleInitial)은 선택적인 경우
type PersonalName = {
FirstName : string
MiddleInitial: Option<string> // optional
LastName : string
}
- F#은 타입 뒤에
option라벨을 붙이는 방식도 지원하며, 더 읽기 쉽고 실무에서 더 흔히 사용됨
type PersonalName = {
FirstName : string
MiddleInitial: string option
LastName : string
}
// 일반적인 TypeScript 방식: 선택적 필드(undefined 허용)
type PersonalName = {
firstName: string;
middleInitial?: string; // optional
lastName: string;
};
// F#의 Option 스타일을 명시적으로 재현하고 싶다면
type Option<T> = { kind: "Some"; value: T } | { kind: "None" };
type PersonalNameExplicit = {
firstName: string;
middleInitial: Option<string>;
lastName: string;
};
4.6.2 오류 모델링
- 예시 상황: “결제가 성공적으로 이루어졌거나, 카드가 만료되어 실패했다.” 이를 어떻게 모델링할 것인가?
- F#은 예외(exception)를 던지는 것도 지원하지만, 실패할 수 있다는 사실을 타입 시그니처에 명시적으로 문서화하고 싶은 경우가 많음
- 이는 두 가지 케이스를 가진 선택 타입을 요구함 →
Result타입 정의
type Result<'Success,'Failure> =
| Ok of 'Success
| Error of 'Failure
flowchart TD
R["Result<'Success,'Failure>"] --> OK["Ok 'Success<br/>(성공)"]
R --> ER["Error 'Failure<br/>(실패)"]
Ok케이스는 함수가 성공했을 때의 값을 담고,Error케이스는 함수가 실패했을 때의 오류 데이터를 담음- 어떤 종류의 데이터든 담을 수 있어야 하므로 정의에 제네릭 타입을 사용
F# 4.1 이상(또는 Visual Studio 2017 이상)을 사용한다면
Result타입을 직접 정의할 필요가 없음 — 표준 F# 라이브러리에 이미 포함되어 있기 때문. 그 이전 버전의 F#을 사용한다면 몇 줄의 코드로 이 타입과 헬퍼 함수를 손쉽게 직접 정의할 수 있음
- 함수가 실패할 수 있음을 나타내려면 출력 타입을
Result로 감쌈. 예를 들어PayInvoice함수가 실패할 수 있다면
type PayInvoice =
UnpaidInvoice -> Payment -> Result<PaidInvoice,PaymentError>
Ok케이스에 연관된 타입은PaidInvoice,Error케이스에 연관된 타입은PaymentError.PaymentError는 가능한 각 오류에 대한 케이스를 가진 선택 타입으로 정의할 수 있음
type PaymentError =
| CardTypeNotRecognized
| PaymentRejected
| PaymentProviderOffline
type Result<TSuccess, TFailure> =
| { kind: "Ok"; value: TSuccess }
| { kind: "Error"; error: TFailure };
type PaymentError =
| "CardTypeNotRecognized"
| "PaymentRejected"
| "PaymentProviderOffline";
type PayInvoice = (
unpaidInvoice: UnpaidInvoice,
payment: Payment
) => Result<PaidInvoice, PaymentError>;
- 오류를 문서화하는 이 접근법은 10장(오류 다루기)에서 상세히 다룸
4.6.3 값 자체가 없음 모델링
- 대부분의 프로그래밍 언어에는 함수/메서드가 아무것도 반환하지 않을 때 쓰는
void개념이 있음 - F# 같은 함수형 언어에서는 모든 함수가 반드시 무언가를 반환해야 하므로
void를 사용할 수 없음. 대신unit이라는 특수한 내장 타입을 사용함 unit의 값은 오직 하나이며, 괄호 쌍()로 표기함- 예: 고객 레코드를 데이터베이스에 업데이트하는 함수 — 입력은 고객 레코드이지만 쓸모 있는 출력이 없는 경우, 출력 타입으로
unit을 사용
type SaveCustomer = Customer -> unit
- 반대로 입력은 없지만 쓸모 있는 값을 반환하는 함수(예: 난수를 생성하는 함수)도 “입력 없음”을
unit으로 나타냄
type NextRandom = unit -> int
type SaveCustomer = (customer: Customer) => void;
type NextRandom = () => number;
- 시그니처에서
unit타입을 보게 되면, 그것은 부수효과(side effect)가 존재한다는 강력한 신호임. 어딘가에서 상태가 변경되고 있지만 그것이 숨겨져 있다는 뜻 - 일반적으로 함수형 프로그래머는 부수효과를 피하거나, 최소한 코드의 제한된 영역으로 국한시키려고 노력함
- 참고: TypeScript/JavaScript에는 이미
void,undefined개념이 있어 F#만큼 “부수효과 신호”로서 엄격하게 구분되어 사용되지는 않음
4.6.4 리스트 및 컬렉션 모델링
- F#은 표준 라이브러리에서 다양한 컬렉션 타입을 지원함
| F# 타입 | 설명 | TypeScript 대응 |
|---|---|---|
list |
고정 크기의 불변 컬렉션(연결 리스트로 구현) | readonly T[] / ReadonlyArray<T> |
array |
고정 크기의 가변 컬렉션. 인덱스로 개별 요소 조회/할당 가능 | T[] (가변 배열) |
ResizeArray |
가변 크기 배열(항목 추가/삭제 가능). C#의 List<T>에 대한 F# 별칭 |
Array (push/splice 등으로 크기 변경) |
seq |
지연(lazy) 컬렉션. 각 요소가 필요할 때 반환됨. C#의 IEnumerable<T>에 대한 F# 별칭 |
Iterable<T> / 제너레이터 |
Map |
Dictionary와 유사. 도메인 모델에는 잘 쓰이지 않음 | Map<K, V> |
Set |
집합. 도메인 모델에는 잘 쓰이지 않음 | Set<T> |
- 도메인 모델링에서는 항상
list타입을 사용하는 것을 권장함.option과 마찬가지로 타입 뒤에 접미사로 붙일 수 있어 가독성이 좋음
type Order = {
OrderId : OrderId
Lines : OrderLine list // a collection
}
type Order = {
orderId: OrderId;
lines: OrderLine[]; // 컬렉션
};
- 리스트 생성: 대괄호와 세미콜론(콤마 아님!)으로 구분하는 리스트 리터럴 사용
let aList = [1; 2; 3]
- 또는
::(콘스, cons) 연산자로 기존 리스트 앞에 값을 붙여 새 리스트를 만들 수 있음
let aNewList = 0 :: aList // new list is [0;1;2;3]
const aList = [1, 2, 3];
const aNewList = [0, ...aList]; // [0, 1, 2, 3]
- 리스트 안의 요소에 접근하기 위해 리스트를 해체할 때도 유사한 패턴을 사용함
- 리스트 리터럴에 대한 패턴 매칭
let printList1 aList =
// matching against list literals
match aList with
| [] ->
printfn "list is empty"
| [x] ->
printfn "list has one element: %A" x
| [x;y] -> // match using list literal
printfn "list has two elements: %A and %A" x y
| longerList -> // match anything else
printfn "list has more than two elements"
::(cons) 연산자를 이용한 패턴 매칭
let printList2 aList =
// matching against "cons"
match aList with
| [] ->
printfn "list is empty"
| first::rest ->
printfn "list is non-empty with the first element being: %A" first
function printList1(aList: number[]): string {
if (aList.length === 0) return "list is empty";
if (aList.length === 1) return `list has one element: ${aList[0]}`;
if (aList.length === 2)
return `list has two elements: ${aList[0]} and ${aList[1]}`;
return "list has more than two elements";
}
function printList2(aList: number[]): string {
const [first, ...rest] = aList;
if (first === undefined) return "list is empty";
return `list is non-empty with the first element being: ${first}`;
}
- 참고: TypeScript에는 F#의
match처럼 리스트 구조 자체에 대한 전용 패턴 매칭 문법이 없으므로, 길이 검사나 구조 분해 할당(destructuring)으로 유사한 동작을 재현함
4.7 마무리
타입을 파일과 프로젝트에서 구성하기
- F#은 선언 순서에 엄격한 규칙을 둠
- 파일 위쪽에 있는 타입은 그보다 아래쪽에 있는 타입을 참조할 수 없음
- 컴파일 순서상 앞선 파일은 그보다 뒤에 있는 파일을 참조할 수 없음
- 즉, 타입을 코딩할 때 이들을 어떻게 조직할지 반드시 고려해야 함
- 표준적인 접근법
- 모든 도메인 타입을 하나의 파일(예:
Types.fs또는Domain.fs)에 모으고 - 그 타입에 의존하는 함수들은 컴파일 순서상 이후에 배치
- 타입이 많아 여러 파일로 나눠야 한다면, 공유 타입을 먼저, 하위 도메인별 타입을 그 다음 순서로 배치
- 모든 도메인 타입을 하나의 파일(예:
Common.Types.fs
Common.Functions.fs
OrderTaking.Types.fs
OrderTaking.Functions.fs
Shipping.Types.fs
Shipping.Functions.fs
flowchart TD
A[Common.Types.fs] --> B[Common.Functions.fs]
B --> C[OrderTaking.Types.fs]
C --> D[OrderTaking.Functions.fs]
D --> E[Shipping.Types.fs]
E --> F[Shipping.Functions.fs]
- 한 파일 안에서도, 이 규칙 때문에 단순한 타입은 파일 위쪽에, 그것에 의존하는 더 복잡한 타입은 그 아래쪽에 의존성 순서대로 배치해야 함
module Payments =
// simple types at the top of the file
type CheckNumber = CheckNumber of int
// domain types in the middle of the file
type PaymentMethod =
| Cash
| Check of CheckNumber // defined above
| Card of ...
// top-level types at the bottom of the file
type Payment = {
Amount: ...
Currency: ...
Method: PaymentMethod // defined above
}
- 모델을 위에서 아래로(top-down) 설계할 때는, 상위 타입 아래에 하위 타입을 쓰고 싶은 경우가 많아 이 제약이 불편할 수 있음
- F# 4.1부터는 모듈(또는 네임스페이스) 레벨에서
rec키워드를 사용해 이를 해결할 수 있음.rec키워드는 모듈 내부에서 타입들이 어느 위치에 있든 서로를 참조할 수 있게 해줌
module rec Payments =
type Payment = {
Amount: ...
Currency: ...
Method: PaymentMethod // defined BELOW
}
type PaymentMethod =
| Cash
| Check of CheckNumber // defined BELOW
| Card of ...
type CheckNumber = CheckNumber of int
- 이전 버전의 F#에서는
and키워드를 사용해, 타입 정의가 바로 아래에 있는 타입을 참조하도록 허용할 수 있음
type Payment = {
Amount: ...
Currency: ...
Method: PaymentMethod // defined BELOW
}
and PaymentMethod =
| Cash
| Check of CheckNumber // defined BELOW
| Card of ...
and CheckNumber = CheckNumber of int
- 이런 순서 무관 방식은 스케치(설계 초안) 단계에서는 유용하지만, 설계가 확정되어 프로덕션에 들어갈 준비가 되면 일반적으로 타입을 올바른 의존성 순서에 맞게 정리하는 편이 더 좋음. 다른 F# 코드와의 일관성을 유지할 수 있고, 다른 개발자가 읽기에도 더 쉬워지기 때문
- 실제 프로젝트에서 타입을 어떻게 조직하는지에 대한 실전 예시는 이 책의 코드 저장소에서 확인 가능(원문 기준)
챕터 정리(Wrapping Up)
- 이번 장에서 다룬 내용
- 타입(type)의 개념과, 그것이 함수형 프로그래밍과 어떻게 관련되는지
- F#의 대수적 타입 시스템을 이용해, 작은 타입을 합성함으로써 더 큰 타입을 만드는 방법
AND로 데이터를 결합해 만드는 레코드(record) 타입OR로 데이터를 결합해 만드는 선택(choice) 타입(= 판별 유니온)- 이런 기본 개념 위에 세워진
Option,Result같은 공통 타입들
- 이제 타입이 어떻게 동작하는지 이해했으므로, 앞서 정리했던 요구사항을 다시 살펴보고 지금까지 배운 내용을 이용해 그것을 문서화할 준비가 됨(→ 5장으로 이어짐)
핵심 개념 요약표
| 개념 | 설명 | F# 문법 | 비고 |
|---|---|---|---|
| 함수 | 입력을 출력으로 바꾸는 블랙박스 | let f x = ... |
타입 시그니처 input -> output으로 표현 |
| 타입 | 함수의 입력/출력으로 쓰일 수 있는 값들의 집합 | type X = ... |
클래스와 달리 상태/동작이 없음 |
| AND 타입(곱 타입) | 여러 필드를 모두 가짐 | type X = { A: ..; B: .. } |
레코드(record) |
| OR 타입(합 타입) | 여러 케이스 중 하나를 가짐 | type X = | A of .. | B of .. |
판별 유니온(선택 타입) |
| 단순 타입 | 원시값을 감싸는 단일 케이스 유니온 | type X = X of string |
의미 있는 이름의 래퍼 |
Option |
값의 있음/없음을 표현 | Some x / None |
null을 대신함 |
Result |
성공/실패를 표현 | Ok x / Error e |
예외 대신 명시적 오류 처리 |
unit |
값이 없음을 표현 | () |
void를 대신하며, 부수효과의 신호 |
list |
불변 컬렉션 | [1; 2; 3], x :: xs |
도메인 모델링에서 권장되는 컬렉션 타입 |
챕터 5 타입으로 도메인 모델링하기
이 챕터의 핵심 문제의식
- 1장에서 강조한 “공유된 멘탈 모델”을 소스 코드 자체가 반영해야 함
- 개발자가 도메인 모델과 소스 코드 사이에서 손실 있는 번역(lossy translation)을 하지 않아야 함
- 이상적으로는 소스 코드 자체가 문서 역할을 해서, 도메인 전문가나 비개발자도 코드를 리뷰하고 설계를 검토할 수 있어야 함
- 이 장의 목표: F# 타입 시스템으로 도메인 모델을 정확하게 담아내면서도, 비개발자가 읽고 이해할 수 있는 수준으로 표현하는 방법을 학습
- 핵심 인사이트: ==타입이 대부분의 문서를 대체할 수 있음== → 설계가 곧 코드이므로 구현이 설계와 어긋나는 일이 근본적으로 불가능해짐
5.1 도메인 모델 다시 보기
- 이전 장(원서 36페이지)에서 작성한 Order-Taking 컨텍스트의 도메인 모델을 다시 검토하는 절
- 이 모델은 AND/OR 표기법을 사용한 의사코드(pseudo-code) 형태
- 구성 요소
- 단순 타입:
ProductCode(WidgetCodeORGizmoCode),OrderQuantity(UnitQuantityORKilogramQuantity) - 주문 생명주기 상태: 미검증(
UnvalidatedOrder) → 검증됨(ValidatedOrder) → 가격산정됨(PricedOrder) - 출력 이벤트:
OrderAcknowledgmentSent,OrderPlaced,BillableOrderPlaced - 워크플로우:
"Place Order"— 입력/출력/에러가 명시된 비즈니스 프로세스
- 단순 타입:
context: Order-Taking
// 단순 타입
data ProductCode = WidgetCode OR GizmoCode
data WidgetCode = string starting with "W" then 4 digits
data GizmoCode = ...
data OrderQuantity = UnitQuantity OR KilogramQuantity
data UnitQuantity = ...
data KilogramQuantity = ...
// 주문 생명주기
// ----- 미검증 상태 -----
data UnvalidatedOrder =
UnvalidatedCustomerInfo
AND UnvalidatedShippingAddress
AND UnvalidatedBillingAddress
AND list of UnvalidatedOrderLine
data UnvalidatedOrderLine =
UnvalidatedProductCode
AND UnvalidatedOrderQuantity
// ----- 검증됨 상태 -----
data ValidatedOrder = ...
data ValidatedOrderLine = ...
// ----- 가격산정됨 상태 -----
data PricedOrder = ...
data PricedOrderLine = ...
// ----- 출력 이벤트 -----
data OrderAcknowledgmentSent = ...
data OrderPlaced = ...
data BillableOrderPlaced = ...
// 워크플로우
workflow "Place Order" =
input: UnvalidatedOrder
output (성공 시):
OrderAcknowledgmentSent
AND OrderPlaced (배송팀 전달용)
AND BillableOrderPlaced (청구팀 전달용)
output (실패 시):
InvalidOrder
- 이 장의 목표: 위 의사코드 모델을 실제 F# 코드로 옮기는 것
5.2 도메인 모델 속 패턴 찾기
- 도메인마다 세부 내용은 다르지만, 반복적으로 나타나는 패턴이 존재
- 4가지 패턴
- 단순값(Simple values):
string,int같은 원시 타입으로 표현되는 기본 빌딩 블록. 단, 도메인 전문가는int/string이 아니라OrderId,ProductCode같은 유비쿼터스 언어(ubiquitous language) 개념으로 사고함 - AND로 조합된 값: 밀접하게 연관된 데이터 그룹. 종이 기반 업무에서는 보통 문서나 하위 구성요소(이름, 주소, 주문 등)에 대응
- OR로 선택되는 값: 도메인에서 나타나는 선택지. 예: 주문(Order) 또는 견적(Quote), 개수 단위(UnitQuantity) 또는 킬로그램 단위(KilogramQuantity)
- 워크플로우: 입력과 출력이 있는 비즈니스 프로세스
- 단순값(Simple values):
flowchart TD
A[도메인 모델의 4가지 패턴] --> B[단순값]
A --> C["AND 조합"]
A --> D["OR 선택"]
A --> E[워크플로우]
B --> B1["OrderId, ProductCode 등<br/>기본 빌딩 블록"]
C --> C1["레코드 형태<br/>예: 이름, 주소, 주문"]
D --> D1["선택 가능한 대안<br/>예: Order vs Quote"]
E --> E1["입력-처리-출력<br/>비즈니스 프로세스"]
| 패턴 | 설명 | 도메인 예시 | F# 대응 타입 |
|---|---|---|---|
| 단순값 | 기본 빌딩 블록, 원시 타입을 감싼 개념 | OrderId, ProductCode |
단일 케이스 유니온 |
| AND 조합 | 밀접하게 연관된 데이터 묶음 | 이름, 주소, 주문 | 레코드 타입 |
| OR 선택 | 여러 대안 중 하나 | Order vs Quote |
선택 타입(구별된 유니온) |
| 워크플로우 | 입력과 출력이 있는 비즈니스 프로세스 | 주문 검증, 가격 계산 | 함수 타입 |
- 이후 절에서 F# 타입으로 이 4가지 패턴을 각각 어떻게 표현하는지 다룸
5.3 단순값 모델링
- 도메인의 가장 기본적인 빌딩 블록인 단순값부터 다룸
- 도메인 전문가는
int,string이 아니라OrderId,ProductCode같은 도메인 개념으로 사고함 OrderId와ProductCode는 둘 다 내부적으로int/string일 수 있지만, 서로 뒤섞이면 안 됨 → 구별되는 타입임을 명확히 하기 위해 “래퍼 타입(wrapper type)“을 만듦- F#에서 래퍼 타입을 만드는 가장 쉬운 방법: “단일 케이스 유니온(single-case union)” — 케이스가 하나뿐인 선택 타입
type CustomerId =
| CustomerId of int
- 케이스가 하나뿐이므로 보통 한 줄로 작성
type CustomerId = CustomerId of int
- 이런 래퍼 타입을 복합 타입(레코드)이나 원시 타입(
string,int)과 구분해 “단순 타입(simple type)“이라 부름 - 도메인의 단순 타입 예시
type WidgetCode = WidgetCode of string
type UnitQuantity = UnitQuantity of int
type KilogramQuantity = KilogramQuantity of decimal
- 단일 케이스 유니온 정의의 두 부분: 타입 이름 + 케이스 레이블
type CustomerId = CustomerId of int
// ^타입 이름 ^케이스 레이블
- 관례상 케이스 레이블은 타입 이름과 동일하게 짓는 경우가 많음 → 생성/해체 시 같은 이름을 재사용 가능
TypeScript 대응 (브랜드 타입 패턴)
- TS에는 F#의 구별된 유니온과 동일한 개념이 없으므로, 원시 타입에 “브랜드(brand)“를 붙여 명목적 타입(nominal type)처럼 흉내내는 방식을 사용함
// 브랜드를 이용해 number를 CustomerId라는 구별된 타입으로 취급
type CustomerId = number & { readonly __brand: "CustomerId" };
// 생성자 함수
const CustomerId = (value: number): CustomerId => value as CustomerId;
type WidgetCode = string & { readonly __brand: "WidgetCode" };
type UnitQuantity = number & { readonly __brand: "UnitQuantity" };
type KilogramQuantity = number & { readonly __brand: "KilogramQuantity" };
5.3.1 래퍼 타입 활용하기
- 단순 타입의 값을 생성할 때는 케이스 이름을 생성자 함수처럼 사용
type CustomerId = CustomerId of int
// ^ 이 케이스 이름이 생성자 함수가 됨
let customerId = CustomerId 42
// ^ int 하나를 매개변수로 받는 함수
- 이렇게 만든 단순 타입 덕분에 서로 다른 타입을 실수로 혼동하는 것을 방지할 수 있음.
CustomerId와OrderId를 비교하면 컴파일 에러 발생
type CustomerId = CustomerId of int
type OrderId = OrderId of int
let customerId = CustomerId 42
let orderId = OrderId 42
// 비교 시도 -- 컴파일 에러!
printfn "%b" (orderId = customerId)
// ^ 'OrderId' 타입이 기대되었으나 'CustomerId'가 사용됨
CustomerId를 입력으로 받는 함수에OrderId를 넘기는 것도 컴파일 에러
let processCustomerId (id: CustomerId) = ...
processCustomerId orderId
// ^ 컴파일 에러: 'CustomerId' 타입이 기대되었으나 'OrderId'가 사용됨
- 단일 케이스 유니온을 해체(deconstruct)/언래핑할 때는 케이스 레이블로 패턴 매칭
// 생성
let customerId = CustomerId 42
// 해체
let (CustomerId innerValue) = customerId
// ^ innerValue는 42
printfn "%i" innerValue // "42" 출력
- 함수 매개변수 자리에서 바로 해체하는 것이 흔한 관용구임 → 내부 값에 즉시 접근 가능 + 컴파일러가 타입을 자동 추론
let processCustomerId (CustomerId innerValue) =
printfn "innerValue is %i" innerValue
// 함수 시그니처: val processCustomerId: CustomerId -> unit
TypeScript 대응
// 생성
const customerId = CustomerId(42);
const orderId = OrderId(42);
// orderId === customerId;
// 컴파일 에러: Type 'OrderId' is not comparable to type 'CustomerId'
function processCustomerId(id: CustomerId): void {
console.log(`inner value: ${id}`);
}
// processCustomerId(orderId);
// 컴파일 에러: Argument of type 'OrderId' is not assignable to
// parameter of type 'CustomerId'
// 해체(언래핑): 브랜드 타입은 런타임에는 원시값 그대로이므로
// 별도 구문 없이 값을 그대로 쓰거나 명시적으로 캐스팅
function unwrapCustomerId(id: CustomerId): number {
return id as number;
}
// 함수 매개변수에서 바로 활용
function processCustomerId2(id: CustomerId): void {
const innerValue = id as number;
console.log(`innerValue is ${innerValue}`);
}
참고: F#의 래퍼 타입은 런타임에도 실제로 값을 감싸는 별도 객체이지만, TS 브랜드 타입은 컴파일 타임에만 존재하고 런타임에는 원시값 그대로임(오버헤드 없음) — 타입 안전성의 성격이 다소 다름
5.3.2 제약 있는 값
- 실무 도메인에서는 단순 타입에 거의 항상 제약(constraint)이 있음
- 예: 특정 범위 안에 있어야 함, 특정 패턴과 일치해야 함
- 실전 도메인에서 아무 제약 없는 정수/문자열은 매우 드묾
- 이러한 제약을 어떻게 강제할지는 다음 장(“단순 값의 무결성”, 원서 104페이지)에서 다룸 → 이 절 자체는 문제 제기만 하고 넘어감
5.3.3 단순 타입의 성능 문제 완화하기
- 원시 타입을 단순 타입으로 감싸는 것은 타입 안전성 확보에 매우 유용하지만, 메모리 사용량과 효율 측면에서 비용이 발생함
- 일반적인 업무 애플리케이션에서는 약간의 성능 저하가 문제되지 않지만, 과학 계산이나 실시간 처리처럼 고성능이 필요한 도메인에서는 주의가 필요함
- 예:
UnitQuantity배열을 순회하는 것은 원시int배열을 순회하는 것보다 느림 - 해결책 3가지
방법 1: 타입 별칭(type alias) 사용 — 오버헤드는 없지만 타입 안전성을 잃음
type UnitQuantity = int
방법 2: 값 타입(struct) 사용 — F# 4.1부터 가능, 배열에 저장 시 메모리가 연속적으로 배치되어 캐시 친화적
[<Struct>]
type UnitQuantity = UnitQuantity of int
방법 3: 원시값 컬렉션 전체를 하나의 타입으로 정의 — 대량 배열을 다룰 때 유리
type UnitQuantities = UnitQuantities of int[]
- 이 방법을 확장하면 데이터 지향 설계(data-oriented design)로 이어짐 (게임 개발에서 흔히 사용)
- 유비쿼터스 언어에 이런 컬렉션을 가리키는 단어(예: “DataSample”, “Measurements”)가 있다면 그 단어를 그대로 사용할 것
- 원칙: 성능은 복잡한 주제이므로, 우선 도메인을 가장 직관적인 방식으로 모델링하고 이후에 튜닝/최적화를 진행할 것
TypeScript 대응
// 방법 1: 타입 별칭 (런타임 오버헤드 없음, 타입 안전성 없음)
type UnitQuantityAlias = number;
// 방법 2: 브랜드 타입 (컴파일 타임 안전성, 런타임엔 원시값과 동일)
type UnitQuantity = number & { readonly __brand: "UnitQuantity" };
// 방법 3: 대량 컬렉션을 하나의 타입으로 묶기
type UnitQuantities = number[] & { readonly __brand: "UnitQuantities" };
| 방법 | 코드 예 | 타입 안전성 | 성능 특성 |
|---|---|---|---|
| 단순 타입(래퍼) | type UnitQuantity = UnitQuantity of int |
높음 | F#은 참조 오버헤드 있음 |
| 타입 별칭 | type UnitQuantity = int |
낮음(별칭일 뿐) | 오버헤드 없음 |
| 구조체(Struct) | [<Struct>] type UnitQuantity = UnitQuantity of int |
높음 | 배열에서 캐시 친화적 |
| 대량 컬렉션 타입 | type UnitQuantities = UnitQuantities of int[] |
높음(상위 레벨) | 원시 배열 그대로 효율적 |
참고: TS 브랜드 타입은 애초에 런타임 표현이 원시값과 동일하므로, F#과 달리 “성능 완화”라는 고민 자체가 크게 필요하지 않음
5.4 복잡한 데이터 모델링
- 앞서 도메인을 문서화할 때 AND/OR로 복잡한 모델을 표현함
- F#의 대수적 타입 시스템(algebraic type system) 역시 AND/OR로 복합 타입을 만듦
- 따라서 이 대수적 타입 시스템을 그대로 도메인 모델링에 적용할 수 있음
5.4.1 레코드 모델링
- 도메인의 많은 데이터 구조는 AND 관계로 이루어짐
- 예: 단순화한
Order
data Order =
CustomerInfo
AND ShippingAddress
AND BillingAddress
AND list of OrderLines
AND AmountToBill
- 이는 F# 레코드 구조로 바로 대응됨
type Order = {
CustomerInfo : CustomerInfo
ShippingAddress : ShippingAddress
BillingAddress : BillingAddress
OrderLines : OrderLine list
AmountToBill : ...
}
- 각 필드에 이름(“CustomerInfo”, “ShippingAddress”)과 타입(
CustomerInfo,ShippingAddress)을 부여함 - 이렇게 작성해보면 아직 풀리지 않은 질문들이 드러남
ShippingAddress와BillingAddress는 같은 타입인가?AmountToBill은 어떤 타입으로 표현해야 하는가?
- 이런 질문은 도메인 전문가와 함께 풀어야 함. 청구지 주소와 배송지 주소를 전문가가 서로 다른 개념으로 이야기한다면, 구조가 같더라도 논리적으로 분리해두는 것이 좋음(요구사항 변화에 따라 서로 다른 방향으로 진화할 수 있음)
TypeScript 대응
interface Order {
customerInfo: CustomerInfo;
shippingAddress: ShippingAddress;
billingAddress: BillingAddress;
orderLines: OrderLine[];
amountToBill: BillingAmount; // 타입 아직 미정
}
5.4.2 잘 모르는 타입 모델링
- 설계 초기 단계에서는 유비쿼터스 언어 덕분에 타입 이름은 알지만, 내부 구조는 아직 모를 때가 많음
- 문제 없음 — 최선의 추측으로 표현하거나, 아직 정의되지 않았음을 명시하는 “플레이스홀더” 타입으로 표현하면 됨
- F#에서는 예외 타입
exn을Undefined로 별칭 지정해서 사용
type Undefined = exn
- 이 별칭을 설계 모델 전체에 적용
type CustomerInfo = Undefined
type ShippingAddress = Undefined
type BillingAddress = Undefined
type OrderLine = Undefined
type BillingAmount = Undefined
type Order = {
CustomerInfo : CustomerInfo
ShippingAddress : ShippingAddress
BillingAddress : BillingAddress
OrderLines : OrderLine list
AmountToBill : BillingAmount
}
- 이 방식의 장점: 도메인을 타입으로 계속 모델링하면서 컴파일까지 가능함. 단, 실제로 해당 타입을 처리하는 함수를 작성하려는 순간
Undefined를 더 나은 것으로 교체하도록 강제됨
TypeScript 대응
type Undefined = unknown; // 아직 구조가 정해지지 않은 타입의 자리표시자
type CustomerInfo = Undefined;
type ShippingAddress = Undefined;
type BillingAddress = Undefined;
type OrderLine = Undefined;
type BillingAmount = Undefined;
interface Order {
customerInfo: CustomerInfo;
shippingAddress: ShippingAddress;
billingAddress: BillingAddress;
orderLines: OrderLine[];
amountToBill: BillingAmount;
}
5.4.3 선택 타입 모델링
- 도메인에는 다른 것들 사이의 “선택”인 경우도 많음
data ProductCode =
WidgetCode
OR GizmoCode
data OrderQuantity =
UnitQuantity
OR KilogramQuantity
- F#의 선택 타입(구별된 유니온)으로 이를 표현
type ProductCode =
| Widget of WidgetCode
| Gizmo of GizmoCode
type OrderQuantity =
| Unit of UnitQuantity
| Kilogram of KilogramQuantity
- 각 케이스는 “태그(케이스 레이블)“와 그 케이스에 연결된 데이터 타입, 두 부분으로 구성됨
- 케이스 레이블(예:
Widget)이 연결된 타입 이름(WidgetCode)과 반드시 같을 필요는 없음
TypeScript 대응 (구별된 유니온)
type ProductCode =
| { kind: "Widget"; value: WidgetCode }
| { kind: "Gizmo"; value: GizmoCode };
type OrderQuantity =
| { kind: "Unit"; value: UnitQuantity }
| { kind: "Kilogram"; value: KilogramQuantity };
5.5 함수로 작업 흐름 모델링하기
- 지금까지는 모든 데이터 구조, 즉 유비쿼터스 언어의 “명사”를 모델링하는 방법을 다룸
- 이제 “동사”, 즉 비즈니스 프로세스를 다룰 차례
- 이 책에서는 워크플로우와 기타 프로세스를 함수 타입으로 모델링함
- 예: 주문 양식을 검증하는 워크플로우 단계
type ValidateOrder = UnvalidatedOrder -> ValidatedOrder
- 이 코드만으로
ValidateOrder프로세스가 미검증 주문을 검증된 주문으로 변환한다는 것이 명확히 드러남
TypeScript 대응
type ValidateOrder = (order: UnvalidatedOrder) => ValidatedOrder;
5.5.1 복잡한 입력 및 출력 처리
- 모든 함수는 입력과 출력이 하나씩이지만, 실제 워크플로우는 입출력이 여러 개일 수 있음
출력이 여러 개(AND)인 경우 → 레코드 타입으로 묶음
- 주문 처리 워크플로우는 3개의 서로 다른 이벤트를 출력해야 하므로, 하나의 레코드로 묶어서 표현
type PlaceOrderEvents = {
AcknowledgmentSent : AcknowledgmentSent
OrderPlaced : OrderPlaced
BillableOrderPlaced : BillableOrderPlaced
}
type PlaceOrder = UnvalidatedOrder -> PlaceOrderEvents
출력이 여러 개(OR)인 경우 → 선택 타입으로 묶음
- 수신 메일을 견적/주문으로 분류하는 프로세스 예시
workflow "Categorize Inbound Mail" =
input: Envelope contents
output:
QuoteForm (해당 더미에 배치)
OR OrderForm (해당 더미에 배치)
OR ...
type EnvelopeContents = EnvelopeContents of string
type CategorizedMail =
| Quote of QuoteForm
| Order of OrderForm
// etc
type CategorizeInboundMail = EnvelopeContents -> CategorizedMail
입력이 여러 개(AND, 모두 필수)인 경우 → 두 가지 접근법
"Calculate Prices" =
input: OrderForm, ProductCatalog
output: PricedOrder
- 접근법 1: 각 입력을 별도의 매개변수로 전달(가장 단순함)
type CalculatePrices = OrderForm -> ProductCatalog -> PricedOrder
- 접근법 2: 새 레코드 타입으로 두 입력을 함께 묶음
type CalculatePricesInput = {
OrderForm : OrderForm
ProductCatalog : ProductCatalog
}
type CalculatePrices = CalculatePricesInput -> PricedOrder
- 어느 쪽이 더 나은가?
ProductCatalog처럼 “진짜 입력”이라기보다 의존성(dependency)에 가까운 경우 → 별도 매개변수 방식을 사용 (함수형 의존성 주입에 해당, 원서 180페이지에서 상세히 다룸)- 두 입력이 항상 함께 필요하고 서로 강하게 연결되어 있다면 → 레코드 타입을 사용해 이를 명확히 드러냄 (튜플도 대안이 될 수 있으나, 대개 이름이 있는 타입이 더 나음)
flowchart LR
In[UnvalidatedOrder] --> WF{{"PlaceOrder 워크플로우"}}
WF -->|성공| S1[OrderAcknowledgmentSent]
WF -->|성공| S2[OrderPlaced]
WF -->|성공| S3[BillableOrderPlaced]
WF -->|실패| Err[InvalidOrder]
TypeScript 대응
interface PlaceOrderEvents {
acknowledgmentSent: AcknowledgmentSent;
orderPlaced: OrderPlaced;
billableOrderPlaced: BillableOrderPlaced;
}
type PlaceOrder = (order: UnvalidatedOrder) => PlaceOrderEvents;
type EnvelopeContents = string;
type CategorizedMail =
| { kind: "Quote"; value: QuoteForm }
| { kind: "Order"; value: OrderForm };
type CategorizeInboundMail = (contents: EnvelopeContents) => CategorizedMail;
// 접근법 1: 별도 매개변수(커링 스타일)
type CalculatePrices = (orderForm: OrderForm) => (catalog: ProductCatalog) => PricedOrder;
// 접근법 2: 입력 레코드로 묶기
interface CalculatePricesInput {
orderForm: OrderForm;
productCatalog: ProductCatalog;
}
type CalculatePrices2 = (input: CalculatePricesInput) => PricedOrder;
| 접근법 | 코드 예 | 적합한 상황 |
|---|---|---|
| 별도 매개변수(커링) | OrderForm -> ProductCatalog -> PricedOrder |
입력 중 하나가 의존성에 가까울 때(함수형 의존성 주입) |
| 입력 레코드 | CalculatePricesInput -> PricedOrder |
두 입력이 항상 함께 필요하고 강하게 연관될 때 |
5.5.2 함수 시그니처에서 효과 문서화하기
- 앞선
ValidateOrder는 검증이 항상 성공한다고 가정했지만, 실제로는 그렇지 않음 - 실패 가능성을 표현하려면 함수 시그니처에
Result타입(원서 70페이지에서 소개)을 반환하도록 함
type ValidateOrder =
UnvalidatedOrder -> Result<ValidatedOrder, ValidationError list>
and ValidationError = {
FieldName : string
ErrorDescription : string
}
- 입력은
UnvalidatedOrder, 성공 시 출력은ValidatedOrder, 실패 시 출력은ValidationError목록(필드명 + 에러 설명 포함)임이 시그니처에서 드러남 - 함수형 프로그래밍 용어로, 주요 출력 외에 함수가 추가로 수행하는 것을 “효과(effects)“라고 부름 →
Result사용으로 “에러 효과”가 있음을 문서화한 것 - 함수가 비동기(asynchronous)임을 표현하려면
Async타입을 사용
type ValidateOrder =
UnvalidatedOrder -> Async<Result<ValidatedOrder, ValidationError list>>
- 이 시그니처는 (a) 반환값을 가져오려 할 때 즉시 반환되지 않음, (b) 반환되었을 때 결과가 에러일 수 있음, 두 가지를 모두 문서화함
- 모든 효과를 나열하면 시그니처가 복잡해지므로 보통 타입 별칭으로 깔끔하게 정리함
type ValidationResponse<'a> = Async<Result<'a, ValidationError list>>
type ValidateOrder =
UnvalidatedOrder -> ValidationResponse<ValidatedOrder>
flowchart TD
F1["UnvalidatedOrder -> ValidatedOrder<br/>(순수 함수, 효과 없음)"] --> F2["Result 타입 추가<br/>(에러 효과)"]
F2 --> F3["Async 타입 추가<br/>(비동기 효과)"]
TypeScript 대응
// Result 타입 정의 (F#의 Result<'T,'E>에 해당)
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
interface ValidationError {
fieldName: string;
errorDescription: string;
}
type ValidateOrder = (
order: UnvalidatedOrder
) => Result<ValidatedOrder, ValidationError[]>;
// 비동기 효과 추가 (F#의 Async에 대응하는 Promise 사용)
type ValidateOrderAsync = (
order: UnvalidatedOrder
) => Promise<Result<ValidatedOrder, ValidationError[]>>;
// 효과를 타입 별칭으로 정리
type ValidationResponse<A> = Promise<Result<A, ValidationError[]>>;
type ValidateOrderFinal = (order: UnvalidatedOrder) => ValidationResponse<ValidatedOrder>;
5.6 정체성에 관하여: 값 객체
- 도메인 타입을 모델링하는 기본기를 익혔으니, 이제 데이터가 “지속적인 정체성(persistent identity)“을 갖는지에 따라 타입을 분류하는 중요한 방법을 다룸
- DDD 용어
- 지속적인 정체성을 가진 객체 = 엔터티(Entity)
- 지속적인 정체성이 없는 객체 = 값 객체(Value Object)
- 값 객체부터 살펴봄: 많은 데이터 객체는 정체성이 없이 상호교환 가능함
- 예:
WidgetCode "W1234"인스턴스 하나와 다른 인스턴스는 서로 같은 것으로 취급됨(어느 것이 어느 것인지 추적할 필요 없음)
- 예:
let widgetCode1 = WidgetCode "W1234"
let widgetCode2 = WidgetCode "W1234"
printfn "%b" (widgetCode1 = widgetCode2) // "true" 출력
- 값 객체 개념은 복합 타입에서도 자주 나타남
- 예:
PersonalName레코드(FirstName, LastName 필드)도 두 값의 필드가 같으면 상호교환 가능한 값 객체임
let name1 = {FirstName="Alex"; LastName="Adams"}
let name2 = {FirstName="Alex"; LastName="Adams"}
printfn "%b" (name1 = name2) // "true" 출력
- 주소(address) 타입도 값 객체임 — 거리, 도시, 우편번호가 같으면 같은 주소로 취급
let address1 = {StreetAddress="123 Main St"; City="New York"; Zip="90001"}
let address2 = {StreetAddress="123 Main St"; City="New York"; Zip="90001"}
printfn "%b" (address1 = address2) // "true" 출력
- 자연어 표현으로 판별하는 팁: “Chris는 나와 이름이 같다”라고 말할 수 있다면 값 객체임 — Chris와 나는 다른 사람이지만 이름 자체는 같음(고유한 정체성이 없음). “Pat은 나와 우편주소가 같다”도 마찬가지
TypeScript 대응
const widgetCode1 = WidgetCode("W1234");
const widgetCode2 = WidgetCode("W1234");
// 브랜드 원시값이므로 참조가 아닌 값 비교가 그대로 동작함
console.log(widgetCode1 === widgetCode2); // true
interface PersonalName {
firstName: string;
lastName: string;
}
const name1: PersonalName = { firstName: "Alex", lastName: "Adams" };
const name2: PersonalName = { firstName: "Alex", lastName: "Adams" };
// 주의: 객체 리터럴은 참조 비교이므로 아래는 false
console.log(name1 === name2); // false
// 구조적 비교를 위한 헬퍼 필요
function shallowEqual<T extends object>(a: T, b: T): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
console.log(shallowEqual(name1, name2)); // true
5.6.1 값 객체의 같음
- F#의 대수적 타입 시스템으로 만든 타입은 기본적으로 필드 기반 동등성(equality) 검사를 자동으로 구현함 → 별도의 동등성 코드를 작성할 필요 없음
- 정확한 규칙(구조적 동등성, structural equality)
- 같은 타입의 두 레코드 값은 모든 필드가 같으면 같음
- 두 선택 타입 값은 같은 케이스이고 그 케이스에 연결된 데이터도 같으면 같음
참고: TS/JS는 객체·클래스에 대해 기본적으로 참조 동등성(
===)만 제공함 — F#처럼 필드 기반 구조적 동등성이 내장되어 있지 않으므로, 값 객체를 비교하려면 항상 별도의 비교 함수(예:JSON.stringify비교,lodash.isEqual등)가 필요함
5.7 정체성에 관하여: 엔터티
- 실제로는 구성요소가 바뀌어도 고유한 정체성을 유지해야 하는 것들을 모델링해야 할 때가 많음. 예: 내가 이름이나 주소를 바꿔도 나는 여전히 같은 사람임
- DDD 용어로 이런 것을 엔터티(Entity)라고 부름
- 비즈니스 맥락에서 엔터티는 대개 어떤 문서에 해당함: 주문, 견적, 청구서, 고객 프로필, 상품 시트 등 — 생명주기를 가지며 여러 비즈니스 프로세스에 의해 상태가 변함
- “값 객체” vs “엔터티” 구분은 맥락에 따라 달라짐
- 휴대폰 예시
- 제조 단계: 각 폰에 고유 시리얼 번호 부여 → 엔터티로 모델링
- 판매 단계: 시리얼 번호가 중요하지 않음, 같은 스펙의 폰은 상호교환 가능 → 값 객체로 모델링
- 특정 고객에게 판매된 이후: 정체성이 다시 중요해짐 → 엔터티로 모델링(화면·배터리를 교체해도 고객은 같은 폰으로 인식)
- 휴대폰 예시
flowchart LR
P1["제조 단계<br/>엔터티: 고유 시리얼번호"] --> P2["판매 단계<br/>값 객체: 스펙 동일하면 상호교환"]
P2 --> P3["구매 후 소유 단계<br/>엔터티: 동일 전화기로 인식"]
5.7.1 엔터티의 ID
- 엔터티는 변화에도 불구하고 안정적인 정체성을 가져야 하므로, 모델링 시 고유 식별자(ID/키)를 부여해야 함. 예: “Order ID”, “Customer ID”
- 예:
Contact타입은PhoneNumber나EmailAddress가 바뀌어도ContactId는 그대로 유지됨
type ContactId = ContactId of int
type Contact = {
ContactId : ContactId
PhoneNumber : ...
EmailAddress: ...
}
- 식별자의 출처
- 실제 도메인 자체에서 제공되는 경우: 종이 주문서/청구서에는 원래부터 참조번호가 적혀 있음
- 인위적으로 만들어야 하는 경우: UUID, 자동 증가 DB 테이블, ID 생성 서비스 등 (이 책에서는 식별자가 클라이언트로부터 이미 제공된다고 가정하고 더 깊이 다루지 않음)
TypeScript 대응
type ContactId = number & { readonly __brand: "ContactId" };
const ContactId = (value: number): ContactId => value as ContactId;
interface Contact {
contactId: ContactId;
phoneNumber: PhoneNumber;
emailAddress: EmailAddress;
}
5.7.2 데이터 정의에 ID 포함하기
- 어떤 도메인 객체를 엔터티로 판별했다면, 정의에 식별자를 어떻게 추가할까?
- 레코드 타입에 ID를 추가하는 것은 간단함(필드 하나 추가) — 문제는 선택 타입에 ID를 추가하는 방식
- 질문: ID를 선택 타입 “바깥”에 둘 것인가, 각 케이스 “안”에 둘 것인가?
- 예시:
Invoice(청구서)를 완납/미납 두 케이스로 모델링
바깥쪽(outside) 접근법 — InvoiceId를 최상위 레코드에 두고, 그 안에 선택 타입 InvoiceInfo를 둠
// 미납 케이스 정보 (id 없음)
type UnpaidInvoiceInfo = ...
// 완납 케이스 정보 (id 없음)
type PaidInvoiceInfo = ...
// 결합된 정보 (id 없음)
type InvoiceInfo =
| Unpaid of UnpaidInvoiceInfo
| Paid of PaidInvoiceInfo
// 청구서 id
type InvoiceId = ...
// 최상위 청구서 타입
type Invoice = {
InvoiceId : InvoiceId // 두 하위 케이스 "바깥"에 위치
InvoiceInfo : InvoiceInfo
}
- 이 접근법의 문제: 특정 케이스의 데이터를 다루려 할 때 데이터가 여러 컴포넌트에 흩어져 있어 다루기 불편함
안쪽(inside) 접근법 — 실무에서 더 흔히 사용됨, 각 케이스가 자신의 ID 사본을 가짐
type UnpaidInvoice = {
InvoiceId : InvoiceId // id를 "안쪽"에 저장
// 미납 관련 기타 정보
}
type PaidInvoice = {
InvoiceId : InvoiceId // id를 "안쪽"에 저장
// 완납 관련 기타 정보
}
// 최상위 청구서 타입
type Invoice =
| Unpaid of UnpaidInvoice
| Paid of PaidInvoice
- 장점: 패턴 매칭할 때 ID를 포함한 모든 데이터를 한 곳에서 바로 접근 가능
let invoice = Paid {InvoiceId = ...}
match invoice with
| Unpaid unpaidInvoice ->
printfn "The unpaid invoiceId is %A" unpaidInvoice.InvoiceId
| Paid paidInvoice ->
printfn "The paid invoiceId is %A" paidInvoice.InvoiceId
| 접근법 | 구조 | 장점 | 단점 |
|---|---|---|---|
| 바깥쪽(outside) | 최상위 레코드에 ID, 선택 타입은 케이스별 정보만 포함 | 구조가 단순해 보임 | 케이스 데이터를 다루려면 ID와 정보가 분리되어 불편 |
| 안쪽(inside) | 각 케이스가 자신의 ID를 포함 | 패턴 매칭 시 ID와 데이터를 한 번에 접근 가능 | 각 케이스마다 ID 필드가 중복됨 |
TypeScript 대응
// 바깥쪽 접근법
type UnpaidInvoiceInfo = Undefined;
type PaidInvoiceInfo = Undefined;
type InvoiceId = Undefined;
type InvoiceInfo =
| { kind: "Unpaid"; value: UnpaidInvoiceInfo }
| { kind: "Paid"; value: PaidInvoiceInfo };
interface InvoiceOutside {
invoiceId: InvoiceId; // 바깥쪽에 위치
invoiceInfo: InvoiceInfo;
}
// 안쪽 접근법
interface UnpaidInvoice {
invoiceId: InvoiceId; // 각 케이스 안쪽에 위치
}
interface PaidInvoice {
invoiceId: InvoiceId;
}
type Invoice =
| { kind: "Unpaid"; value: UnpaidInvoice }
| { kind: "Paid"; value: PaidInvoice };
// 패턴 매칭 (switch)
function describeInvoice(invoice: Invoice): string {
switch (invoice.kind) {
case "Unpaid":
return `미납 invoiceId: ${invoice.value.invoiceId}`;
case "Paid":
return `완납 invoiceId: ${invoice.value.invoiceId}`;
}
}
5.7.3 엔터티의 같음
- F#에서 기본 동등성은 레코드의 모든 필드를 비교함 — 그러나 엔터티를 비교할 때는 오직 식별자 한 필드만 사용해야 함 → 기본 동작을 변경해야 함
- 변경 절차
Equals메서드 오버라이드GetHashCode메서드 오버라이드CustomEquality,NoComparison속성을 타입에 추가하여 컴파일러에 기본 동작 변경을 알림
[<CustomEquality; NoComparison>]
type Contact = {
ContactId : ContactId
PhoneNumber : PhoneNumber
EmailAddress: EmailAddress
}
with
override this.Equals(obj) =
match obj with
| :? Contact as c -> this.ContactId = c.ContactId
| _ -> false
override this.GetHashCode() =
hash this.ContactId
참고: 위
with ... override문법은 F#의 객체지향 문법이며, 이 책에서는 동등성 오버라이드를 보여주기 위해서만 사용하고 이후에는 사용하지 않음
let contactId = ContactId 1
let contact1 = {
ContactId = contactId
PhoneNumber = PhoneNumber "123-456-7890"
EmailAddress = EmailAddress "bob@example.com"
}
// 같은 ContactId, 다른 이메일 주소
let contact2 = {
ContactId = contactId
PhoneNumber = PhoneNumber "123-456-7890"
EmailAddress = EmailAddress "robert@example.com"
}
// 이메일 주소가 다름에도 true
printfn "%b" (contact1 = contact2)
- 이 방식은 OOP에서 흔한 접근법이지만, 기본 동등성 동작을 조용히 바꾸는 것이라 종종 실수를 유발할 수 있음
- 대안(흔히 더 선호됨):
NoEquality속성으로 객체 수준의 동등성 비교 자체를 아예 금지
[<NoEquality; NoComparison>]
type Contact = {
ContactId : ContactId
PhoneNumber : PhoneNumber
EmailAddress: EmailAddress
}
- 이 상태에서
=비교를 시도하면 컴파일 에러 발생
// 컴파일 에러!
printfn "%b" (contact1 = contact2)
// ^ Contact 타입은 동등성을 지원하지 않음
- 물론
ContactId필드끼리는 직접 비교 가능
// 컴파일 에러 없음
printfn "%b" (contact1.ContactId = contact2.ContactId) // true
NoEquality방식의 장점: 객체 수준에서 동등성이 무엇을 의미하는지에 대한 모호함을 제거하고, 명시적 비교를 강제함- 여러 필드를 함께 식별자로 써야 하는 경우: 합성
Key프로퍼티를 노출
[<NoEquality;NoComparison>]
type OrderLine = {
OrderId : OrderId
ProductId : ProductId
Qty : int
}
with
member this.Key =
(this.OrderId, this.ProductId)
printfn "%b" (line1.Key = line2.Key)
| 전략 | 방법 | 특징 |
|---|---|---|
| 기본 구조적 동등성 | 아무 것도 하지 않음(F# 기본값) | 모든 필드가 같아야 같음 판정 - 엔터티에는 부적합 |
| CustomEquality | Equals/GetHashCode 오버라이드 + CustomEquality; NoComparison |
ID 필드만으로 비교하도록 재정의 |
| NoEquality | NoEquality; NoComparison |
= 사용 시 컴파일 에러, 명시적 필드 비교 강제 |
| Key 프로퍼티 | 여러 필드를 묶은 합성 Key 멤버 노출 |
복합 식별자 비교를 간단히 표현 |
TypeScript 대응
class Contact {
constructor(
public readonly contactId: ContactId,
public readonly phoneNumber: PhoneNumber,
public readonly emailAddress: EmailAddress
) {}
// ID만으로 비교하는 명시적 equals 메서드
equals(other: Contact): boolean {
return this.contactId === other.contactId;
}
}
const contactId = ContactId(1);
const contact1 = new Contact(contactId, PhoneNumber("123-456-7890"), EmailAddress("bob@example.com"));
const contact2 = new Contact(contactId, PhoneNumber("123-456-7890"), EmailAddress("robert@example.com"));
console.log(contact1.equals(contact2)); // true
// 필드 직접 비교
console.log(contact1.contactId === contact2.contactId); // true
// 합성 Key 방식
interface OrderLine {
orderId: OrderId;
productId: ProductId;
qty: number;
}
function orderLineKey(line: OrderLine): string {
return `${line.orderId}::${line.productId}`;
}
console.log(orderLineKey(line1) === orderLineKey(line2));
참고: TS/JS는 객체·클래스 비교(
===)가 기본적으로 참조 비교이므로, 사실상 F#의NoEquality상태와 유사한 출발점을 가짐 → 항상 명시적으로equals()나 키 비교 함수를 만들어야 함
5.7.4 불변성과 정체성
- F# 같은 함수형 언어의 값은 기본적으로 불변(immutable) — 초기화된 이후 어떤 객체도 변경될 수 없음
- 이것이 설계에 미치는 영향
- 값 객체: 불변성이 필수임 — 이름의 일부를 바꾸면 그것은 같은 이름이 아니라 새로운 별개의 이름으로 취급해야 함
- 엔터티: 시간이 지나며 데이터가 변하는 것이 당연함(그래서 고정된 식별자가 필요한 것) → 정체성은 유지한 채 변경된 데이터로 엔터티의 “복사본”을 만드는 방식으로 처리. 복사가 많아 보이지만 실제로는 큰 문제가 되지 않음(이 책 전반에서 불변 데이터를 사용)
- 예시:
with키워드로 일부 필드만 바꾼 복사본 생성
let initialPerson = {PersonId=PersonId 42; Name="Joseph"}
let updatedPerson = {initialPerson with Name="Joe"}
updatedPerson은Name만 다르고PersonId는initialPerson과 동일함- 불변 데이터 구조를 사용하면 모든 변경이 타입 시그니처에 명시적으로 드러남
- 나쁜 시그니처 예: 출력이 없음 → 아무것도 안 바뀌었거나(또는 부작용으로 person이 변경됨)을 암시
type UpdateName = Person -> Name -> unit
- 좋은 시그니처: 변경된
Person을 출력으로 명확히 반환
type UpdateName = Person -> Name -> Person
TypeScript 대응
interface Person {
personId: PersonId;
name: string;
}
const initialPerson: Person = { personId: PersonId(42), name: "Joseph" };
// 객체 스프레드로 일부 필드만 변경한 복사본 생성 (F#의 `with`에 대응)
const updatedPerson: Person = { ...initialPerson, name: "Joe" };
// 나쁜 시그니처: 반환값이 없어 부작용(mutation)을 암시
type UpdateNameBad = (person: Person, name: string) => void;
// 좋은 시그니처: 변경된 새 Person을 명확히 반환
type UpdateName = (person: Person, name: string) => Person;
5.8 집합체
- 설계에서 특히 중요한 두 데이터 타입을 자세히 살펴봄:
Order와OrderLine Order는 엔터티인가? → 명백히 그렇다. 주문 세부사항은 시간에 따라 바뀔 수 있지만 여전히 같은 주문임OrderLine은 어떤가? 특정 주문 라인의 수량을 바꾸면 여전히 같은 주문 라인인가? → 대부분의 설계에서는 그렇다고 봄(수량/가격이 바뀌어도 같은 라인). 따라서OrderLine도 자신만의 식별자를 가진 엔터티임- 질문: 주문 라인을 바꾸면 그 라인이 속한 주문도 바뀐 것인가? → 답은 “그렇다”
- 불변 데이터 구조를 사용하면 이는 피할 수 없는 결과임: 불변
Order가 불변OrderLine들을 포함할 때, 라인 하나만 복사해도Order가 자동으로 복사되는 것은 아니므로,OrderLine변경은 반드시Order레벨에서 이루어져야 함
- 불변 데이터 구조를 사용하면 이는 피할 수 없는 결과임: 불변
/// 세 개의 매개변수를 받음:
/// * 최상위 주문
/// * 변경할 주문 라인의 id
/// * 새 가격
let changeOrderLinePrice order orderLineId newPrice =
// 1. orderLineId로 변경할 라인을 찾음
let orderLine = order.OrderLines |> findOrderLine orderLineId
// 2. 새 가격을 반영한 새 OrderLine 생성
let newOrderLine = {orderLine with Price = newPrice}
// 3. 이전 라인을 새 라인으로 교체한 새 라인 목록 생성
let newOrderLines =
order.OrderLines |> replaceOrderLine orderLineId newOrderLine
// 4. 이전 라인들을 새 라인들로 교체한 전체 주문의 새 버전 생성
let newOrder = {order with OrderLines = newOrderLines}
// 5. 새 주문 반환
newOrder
- 함수의 최종 결과는 라인 중 하나가 새 가격을 가진, 새로운 라인 목록을 포함한 새
Order임 - 불변성은 데이터 구조 안에서 파급 효과(ripple effect)를 일으킴 — 하위 컴포넌트 하나의 변경이 상위 컴포넌트의 변경을 강제함
- 이런 상황은 매우 흔함: 각자 ID를 가진 엔터티들의 컬렉션과, 그것들을 포함하는 “최상위” 엔터티가 있는 구조
- DDD 용어
- 이런 엔터티 컬렉션 = 집합체(aggregate)
- 최상위 엔터티 = 집합체 루트(aggregate root)
- 위 사례에서 집합체는
Order와OrderLine컬렉션 전체이며, 집합체 루트는Order자신임
classDiagram
class Order {
OrderId Id
CustomerId CustomerId
ShippingAddress
BillingAddress
OrderLine[] OrderLines
BillingAmount AmountToBill
}
class OrderLine {
OrderLineId Id
OrderId OrderId
ProductCode
OrderQuantity
Price
}
class Customer {
CustomerId Id
}
Order "1" *-- "many" OrderLine : 집합체 루트가 소유
Order ..> Customer : CustomerId로만 참조
TypeScript 대응
function changeOrderLinePrice(
order: Order,
orderLineId: OrderLineId,
newPrice: Price
): Order {
// 1. 변경할 라인 찾기
const orderLine = findOrderLine(order.orderLines, orderLineId);
// 2. 새 가격을 반영한 새 OrderLine 생성
const newOrderLine = { ...orderLine, price: newPrice };
// 3. 라인 목록 교체
const newOrderLines = replaceOrderLine(order.orderLines, orderLineId, newOrderLine);
// 4. 새 Order 생성
const newOrder: Order = { ...order, orderLines: newOrderLines };
// 5. 반환
return newOrder;
}
5.8.1 일관성과 불변성을 책임지는 집합체
- 집합체는 데이터가 갱신될 때 중요한 역할을 함 — 집합체는 “일관성 경계(consistency boundary)“로 동작함: 집합체 일부가 갱신되면 다른 부분도 일관성을 유지하기 위해 함께 갱신되어야 할 수 있음
- 예: 최상위
Order에 “총 가격” 필드를 추가로 둔다면, 라인 가격이 바뀔 때 총액도 반드시 함께 갱신되어야 함 →changeOrderLinePrice함수 내부에서 처리 - 오직 최상위
Order(집합체 루트)만이 일관성을 어떻게 유지해야 하는지 “알고” 있음 → 라인 레벨이 아니라 주문 레벨에서 모든 갱신을 수행해야 하는 또 다른 이유 - 집합체는 불변성(invariant)이 강제되는 곳이기도 함
- 예: “모든 주문은 최소 1개의 주문 라인을 가져야 한다”는 규칙이 있다면, 라인을 여러 개 삭제하려 할 때 마지막 라인 하나가 남았을 시점에서 집합체가 에러를 발생시켜야 함
- 이 주제는 6장(“도메인의 무결성과 일관성”, 원서 103페이지)에서 더 자세히 다룸
5.8.2 집합체 참조
- 예:
Order에 고객 정보를 연결해야 한다면,Customer를Order의 필드로 직접 넣고 싶은 유혹이 생김
type Order = {
OrderId : OrderId
Customer : Customer // 연결된 고객에 대한 전체 정보
OrderLines : OrderLine list
// etc
}
- 그러나 불변성의 파급 효과를 생각해보면: 고객의 어떤 부분이라도 바뀌면 주문도 함께 바꿔야 함 → 이것이 정말 원하는 설계인가?
- 더 나은 설계: 고객 전체가 아니라 고객에 대한 “참조”만 저장 —
Order타입에는CustomerId만 저장
type Order = {
OrderId : OrderId
CustomerId : CustomerId // 연결된 고객에 대한 참조
OrderLines : OrderLine list
// etc
}
- 이 방식에서는 고객의 전체 정보가 필요할 때,
Order에서CustomerId를 얻은 뒤 별도로 데이터베이스에서 관련 고객 데이터를 로드함(주문의 일부로 함께 로드하지 않음) - 즉,
Customer와Order는 서로 별개이고 독립적인 집합체임 — 각자 자신의 내부 일관성을 책임지며, 둘 사이의 유일한 연결고리는 루트 객체의 식별자를 통한 참조뿐임 - 집합체의 또 다른 중요한 특징: 집합체는 영속성(persistence)의 기본 단위
- 객체를 DB에서 로드/저장할 때는 전체 집합체 단위로 로드/저장해야 함
- 각 DB 트랜잭션은 하나의 집합체만 다루어야 하며, 여러 집합체를 포함하거나 집합체 경계를 넘나들면 안 됨(원서 262페이지 “Transactions” 참고)
- 마찬가지로 객체를 직렬화해서 전송할 때도 항상 전체 집합체 단위로 전송해야 함, 부분적으로 전송하면 안 됨
- 주의: 집합체는 단순한 엔터티 모음이 아님 — 예를 들어
Customer목록은 엔터티들의 컬렉션이지만, 최상위 루트 엔터티가 없고 일관성 경계 역할을 하지 않으므로 DDD “집합체”가 아님
집합체 역할 요약
- 집합체는 여러 도메인 객체를 하나의 단위로 취급할 수 있게 해주며, 최상위 엔터티가 “루트” 역할을 함
- 집합체 내부 객체에 대한 모든 변경은 반드시 최상위 루트를 통해 이루어져야 하며, 집합체는 내부 데이터가 항상 올바르게 동시에 갱신되도록 보장하는 일관성 경계로 동작함
- 집합체는 영속성, 데이터베이스 트랜잭션, 데이터 전송의 원자적 단위임
DDD 용어 정리
| 용어 | 정의 | 예시 |
|---|---|---|
| 값 객체(Value Object) | 정체성이 없는 도메인 객체, 같은 데이터를 가지면 동일한 것으로 취급, 불변이어야 함 | 이름, 주소, 위치, 금액, 날짜 |
| 엔터티(Entity) | 속성이 변해도 유지되는 고유한 정체성을 가진 도메인 객체, 보통 ID/키 필드 보유 | 고객, 주문, 상품, 청구서 |
| 집합체(Aggregate) | 일관성 유지 및 데이터 트랜잭션의 원자적 단위로 취급되는 관련 객체들의 묶음, 루트의 식별자로만 외부에서 참조됨 | Order와 그에 속한 OrderLine들 |
TypeScript 대응
// 나쁜 설계: Customer 전체를 포함 (파급 효과 문제)
interface OrderBad {
orderId: OrderId;
customer: Customer;
orderLines: OrderLine[];
}
// 좋은 설계: CustomerId 참조만 저장
interface Order {
orderId: OrderId;
customerId: CustomerId; // 참조만 저장
orderLines: OrderLine[];
}
5.9 모두 한데 모으기
- 이 장에서 많은 타입을 만들었으니, 한 걸음 물러나 이들이 완전한 도메인 모델로서 어떻게 전체적으로 맞물리는지 확인함
- 먼저 모든 타입을
OrderTaking.Domain이라는 네임스페이스 안에 둠 → F#의 네임스페이스를 DDD의 바운디드 컨텍스트(bounded context)를 나타내는 용도로 사용
namespace OrderTaking.Domain
// 타입들이 이어짐
- 단순 타입 추가
// 상품 코드 관련
type WidgetCode = WidgetCode of string
// 제약: "W"로 시작 후 4자리 숫자
type GizmoCode = GizmoCode of string
// 제약: "G"로 시작 후 3자리 숫자
type ProductCode =
| Widget of WidgetCode
| Gizmo of GizmoCode
// 주문 수량 관련
type UnitQuantity = UnitQuantity of int
type KilogramQuantity = KilogramQuantity of decimal
type OrderQuantity =
| Unit of UnitQuantity
| Kilos of KilogramQuantity
- 이들은 모두 값 객체이며 식별자가 필요 없음
- 반면 주문은 변화 속에서도 유지되는 정체성을 가진 엔터티 → ID로 모델링해야 함. ID가 문자열인지 정수인지 GUID인지는 아직 모르므로 일단
Undefined사용(다른 식별자들도 동일하게 처리)
type OrderId = Undefined
type OrderLineId = Undefined
type CustomerId = Undefined
- 주문과 그 구성요소를 스케치
type CustomerInfo = Undefined
type ShippingAddress = Undefined
type BillingAddress = Undefined
type Price = Undefined
type BillingAmount = Undefined
type Order = {
Id : OrderId // 엔터티용 id
CustomerId : CustomerId // 고객 참조
ShippingAddress : ShippingAddress
BillingAddress : BillingAddress
OrderLines : OrderLine list
AmountToBill : BillingAmount
}
and OrderLine = {
Id : OrderLineId // 엔터티용 id
OrderId : OrderId
ProductCode : ProductCode
OrderQuantity : OrderQuantity
Price : Price
}
- 위에서
and키워드는 아직 선언되지 않은 타입에 대한 전방 참조(forward reference)를 허용하기 위해 사용됨(원서 73페이지 “Organizing Types in Files and Projects” 참고) - 이제 워크플로우 자체를 정리함. 워크플로우의 입력인
UnvalidatedOrder는 주문 양식 “그대로” 만들어지므로int,string같은 원시 타입만 포함함
type UnvalidatedOrder = {
OrderId : string
CustomerInfo : ...
ShippingAddress : ...
...
}
- 워크플로우의 출력으로는 두 가지 타입이 필요함
- 성공 시 이벤트 타입
type PlaceOrderEvents = {
AcknowledgmentSent : ...
OrderPlaced : ...
BillableOrderPlaced : ...
}
- 실패 시 에러 타입
type PlaceOrderError =
| ValidationError of ValidationError list
| ... // 그 외 에러들
and ValidationError = {
FieldName : string
ErrorDescription : string
}
- 마지막으로 주문 처리 워크플로우 전체를 나타내는 최상위 함수 타입을 정의
/// "주문 처리" 프로세스
type PlaceOrder =
UnvalidatedOrder -> Result<PlaceOrderEvents, PlaceOrderError>
- 아직 세부 사항이 많이 남아있지만, 앞으로 어떻게 채워나가야 할지 방향은 명확해짐
- 남은 질문: 주문의 여러 상태(검증됨, 가격산정됨 등)를 어떻게 모델링할 것인가는 아직 미해결
flowchart LR
UO[UnvalidatedOrder] --> PO{{"PlaceOrder 워크플로우"}}
PO -->|성공 Ok| EV[PlaceOrderEvents]
PO -->|실패 Error| ERR[PlaceOrderError]
EV --> E1[AcknowledgmentSent]
EV --> E2[OrderPlaced]
EV --> E3[BillableOrderPlaced]
ERR --> ER1["ValidationError 목록"]
ERR --> ER2[기타 에러]
TypeScript 대응 (통합 버전)
// namespace 대신 모듈로 OrderTaking.Domain 바운디드 컨텍스트 표현
namespace OrderTakingDomain {
// 상품 코드 관련
type WidgetCode = string & { readonly __brand: "WidgetCode" }; // "W"+4자리 숫자 제약
type GizmoCode = string & { readonly __brand: "GizmoCode" }; // "G"+3자리 숫자 제약
type ProductCode =
| { kind: "Widget"; value: WidgetCode }
| { kind: "Gizmo"; value: GizmoCode };
// 주문 수량 관련
type UnitQuantity = number & { readonly __brand: "UnitQuantity" };
type KilogramQuantity = number & { readonly __brand: "KilogramQuantity" };
type OrderQuantity =
| { kind: "Unit"; value: UnitQuantity }
| { kind: "Kilos"; value: KilogramQuantity };
// 식별자 (아직 구조 미정)
type OrderId = unknown;
type OrderLineId = unknown;
type CustomerId = unknown;
// 주문 관련 (아직 구조 미정)
type CustomerInfo = unknown;
type ShippingAddress = unknown;
type BillingAddress = unknown;
type Price = unknown;
type BillingAmount = unknown;
interface Order {
id: OrderId;
customerId: CustomerId;
shippingAddress: ShippingAddress;
billingAddress: BillingAddress;
orderLines: OrderLine[];
amountToBill: BillingAmount;
}
interface OrderLine {
id: OrderLineId;
orderId: OrderId;
productCode: ProductCode;
orderQuantity: OrderQuantity;
price: Price;
}
interface UnvalidatedOrder {
orderId: string;
customerInfo: unknown;
shippingAddress: unknown;
// ...
}
interface PlaceOrderEvents {
acknowledgmentSent: unknown;
orderPlaced: unknown;
billableOrderPlaced: unknown;
}
interface ValidationError {
fieldName: string;
errorDescription: string;
}
type PlaceOrderError =
| { kind: "ValidationError"; value: ValidationError[] };
// | 기타 에러 케이스
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type PlaceOrder = (order: UnvalidatedOrder) => Result<PlaceOrderEvents, PlaceOrderError>;
}
5.9.1 다시 생각해보는 도전 과제: 정말 타입으로 문서를 대체할 수 있을까?
- 이 장 도입부에서 던진 질문: 도메인 요구사항을 타입 시스템에 담아내면서도, 비개발자가 리뷰할 수 있는 수준으로 만들 수 있는가?
- 결과물을 보면 목표를 달성했다고 볼 수 있음 — F# 타입으로 문서화한 완전한 도메인 모델이, 앞서 AND/OR 표기법으로 작성했던 도메인 문서와 거의 동일한 모습을 하고 있음
- 비개발자가 이 코드를 문서로 이해하기 위해 배워야 할 것
- 단순 타입 문법(단일 케이스 유니온)
- AND 타입 문법(중괄호로 된 레코드)
- OR 타입 문법(세로 막대로 구분된 선택)
- “프로세스” 문법(입력, 출력, 화살표)
- 그 이상은 크게 필요 없음
- 결론: 이 방식은 C#이나 Java 같은 전통적인 프로그래밍 언어보다 확실히 더 읽기 쉬움
5.10 마무리
- 이 장에서 배운 것
- F# 타입 시스템으로 단순 타입, 레코드 타입, 선택 타입을 이용해 도메인을 모델링하는 방법
- 전체 과정에서
string,int같은 개발자 중심 단어 대신ProductCode,OrderQuantity같은 도메인의 유비쿼터스 언어를 사용함 - 단 한 번도
Manager나Handler같은 타입을 정의하지 않았음 - 정체성의 종류(값 객체 vs 엔터티)와 F#에서 이를 모델링하는 방법
- 일관성을 보장하기 위한 개념으로서의 “집합체(aggregate)”
- 이렇게 만들어진 타입 집합은 이 장 도입부의 텍스트 문서와 매우 비슷한 모습을 갖게 됨 — 결정적 차이는, 이 모든 타입 정의가 컴파일 가능한 코드이며 애플리케이션의 나머지 코드와 함께 포함될 수 있다는 점
- 이는 애플리케이션 코드가 도메인 정의와 항상 동기화된 상태를 유지한다는 것을 의미함 — 도메인 정의가 바뀌면 애플리케이션은 컴파일에 실패함
- ==설계를 코드와 별도로 동기화할 필요가 없음 — 설계가 곧 코드이기 때문==
- 타입을 문서로 활용하는 이 접근법은 매우 일반적이어서 다른 도메인에도 그대로 적용 가능함
- 구현이 아직 없는 단계이므로, 도메인 전문가와 협업하며 아이디어를 빠르게 시도해보기에 좋은 방법
- 순수한 텍스트이기 때문에 특별한 도구 없이도 도메인 전문가가 쉽게 리뷰할 수 있고, 직접 일부 타입을 작성해볼 수도 있음
- 아직 다루지 못한 부분
- 단순 타입의 제약을 항상 올바르게 강제하는 방법은?
- 집합체의 무결성을 어떻게 강제할 것인가?
- 주문의 여러 상태를 어떻게 모델링할 것인가?
- → 다음 장(“도메인의 무결성과 일관성”)에서 다룸
챕터 5 핵심 개념 총정리
| 개념 | 핵심 내용 |
|---|---|
| 단순 타입 | 단일 케이스 유니온으로 원시 타입을 도메인 개념으로 래핑 |
| 레코드 타입 | AND 관계를 표현, 필드들의 집합 |
| 선택 타입 | OR 관계를 표현, 여러 케이스 중 하나 |
| 함수 타입 | 워크플로우(입력 -> 출력)를 표현, Result/Async로 효과 문서화 |
| 값 객체 | 정체성 없음, 구조적 동등성, 불변이어야 함 |
| 엔터티 | 정체성 있음(ID), 생명주기 동안 데이터는 변경 가능 |
| 집합체 | 일관성 경계, 영속성/트랜잭션의 원자적 단위, 루트를 통해서만 접근 |
챕터 6 도메인의 무결성과 일관성
이 노트는 원서 6장 전체 내용을 다시 책을 펼치지 않아도 복기할 수 있도록 개조식으로 정리한 것입니다.
- 5장에서는 F# 타입 시스템으로 도메인을 모델링하는 기초를 다룸. 도메인을 잘 표현하면서도 컴파일 가능한 풍부한 타입 집합을 구축했음.
- 이번 장의 목표: 도메인 내부의 데이터가 항상 유효(valid)하고 일관(consistent)되도록 보장하는 방법을 다룸.
- 핵심 개념 - 경계 컨텍스트(Bounded Context): 신뢰할 수 있는 데이터만 담고 있는 경계. 바깥의 지저분하고 신뢰할 수 없는 세계와 명확히 구분됨.
- 모든 데이터 값이 항상 유효함을 보장할 수 있다면 구현부는 깨끗하게 유지되고, 방어적 코딩(defensive coding)이 필요 없어짐.
graph LR
subgraph L[지저분한 외부 세계]
E1((데이터 A))
E2((데이터 B))
end
subgraph BC[경계 컨텍스트]
subgraph DM[아름답고 깨끗한 도메인 모델]
D1((엔티티 1))
D2((엔티티 2))
D3((엔티티 3))
D1 --- D2
D2 --- D3
D1 --- D3
end
end
subgraph R[지저분한 외부 세계 2]
E3((데이터 C))
E4((데이터 D))
end
L -- 검증 필요 --> BC
R -- 검증 필요 --> BC
- 이번 장에서 다루는 신뢰할 수 있는 도메인의 두 가지 측면
- 무결성(Integrity)
- 일관성(Consistency)
| 구분 | 정의 | 예시 |
|---|---|---|
| 무결성 (Integrity, 유효성) | 데이터 한 조각이 올바른 비즈니스 규칙을 따르는 것 | UnitQuantity는 1~1000 사이여야 한다 |
| 일관성 (Consistency) | 도메인 모델의 서로 다른 부분들이 사실에 대해 서로 일치하는 것 | 주문 총액은 개별 라인의 합과 같아야 한다 |
- 무결성 예시
UnitQuantity는 1~1000 사이여야 한다. 이 검사를 코드 여러 곳에서 반복해야 하는가, 아니면 한 번 검증되면 항상 참이라고 믿어도 되는가?- 주문(order)은 반드시 하나 이상의 주문 라인(order line)을 가져야 한다.
- 주문은 배송 부서로 보내지기 전에 반드시 검증된 배송 주소(validated shipping address)를 가져야 한다.
- 일관성 예시
- 주문의 총 청구 금액은 개별 라인의 합계와 같아야 한다. 총액이 다르면 데이터는 불일치.
- 주문이 접수되면 그에 대응하는 인보이스(invoice)가 반드시 생성되어야 한다. 주문은 있는데 인보이스가 없다면 불일치.
- 할인 바우처 코드가 주문에 사용되었다면, 재사용을 막기 위해 바우처는 사용됨(used)으로 표시되어야 한다. 표시되지 않았다면 불일치.
- 타입 시스템에 더 많은 정보를 담아낼수록 문서화 필요성은 줄어들고, 코드가 올바르게 구현될 가능성은 높아짐. 이 원칙이 이번 장 전체를 관통함.
6.1 단순값의 무결성
참고 - 이 노트에서 F# 코드를 TypeScript로 옮길 때 사용하는 공통 전략
- F#의
Result<'T,'TError>는type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }형태로 대응- private 생성자를 가진 F# 유니온 케이스(nominal typing)는 TypeScript의 브랜드 타입(
T & { readonly __brand: string }) 또는 private 생성자를 가진 클래스로 대응- F#의 판별 유니온(discriminated union)은 TypeScript의 태그된 유니온(
{ kind: "A"; ... } | { kind: "B"; ... })으로 대응- F# 모듈 안의 함수 묶음은 TypeScript의
namespace또는 정적 함수 모음으로 대응
- 5장(79페이지)에서 단순 값은
string이나int가 아니라WidgetCode,UnitQuantity처럼 도메인에 초점을 맞춘 타입으로 표현해야 한다고 배움. - 여기서 한 걸음 더 나아가야 하는 이유: 실무 도메인에서 무제한 정수나 문자열은 거의 없음. 대부분 어떤 형태로든 제약(constrained)됨.
OrderQuantity는 부호 있는 정수로 표현될 수 있지만, 비즈니스에서 음수나 40억 같은 값을 원할 리는 없음.CustomerName은 문자열이지만 탭 문자나 줄바꿈 문자를 포함해선 안 됨.
- 도메인에서 이미 본 제약된 타입들(주석으로만 제약을 표현한 상태)
type WidgetCode = WidgetCode of string // starting with "W" then 4 digits
type UnitQuantity = UnitQuantity of int // between 1 and 1000
type KilogramQuantity = KilogramQuantity of decimal // between 0.05 and 100.00
type WidgetCode = string & { readonly __brand: "WidgetCode" }; // "W" 뒤에 숫자 4자리
type UnitQuantity = number & { readonly __brand: "UnitQuantity" }; // 1 ~ 1000
type KilogramQuantity = number & { readonly __brand: "KilogramQuantity" }; // 0.05 ~ 100.00
- 문제: 사용자가 주석을 읽지 않고도, 제약 조건을 만족하지 않는 값은 애초에 생성될 수 없도록 보장하고 싶음.
- 데이터가 불변(immutable)이므로, 생성 시점에 한 번 검증되면 이후로는 내부 값을 다시 검사할 필요가 없음.
WidgetCode나UnitQuantity는 어디서든 방어적 코드 없이 안심하고 사용 가능. - 제약을 강제하는 방법 - 스마트 생성자(Smart Constructor) 패턴
- 다른 언어에서도 흔히 쓰는 방식: 생성자를 private으로 만들고, 유효한 값만 생성하고 유효하지 않으면 에러를 반환하는 별도 함수를 둠.
- FP 커뮤니티에서는 이를 “스마트 생성자”라고 부름.
type UnitQuantity = private UnitQuantity of int
// ^ private constructor
class UnitQuantity {
private constructor(private readonly qty: number) {}
// 모듈(파일) 바깥에서는 new UnitQuantity(...)를 직접 호출할 수 없음
}
- private 키워드로 생성자를 감춤 -> 모듈 바깥에서는
UnitQuantity값을 직접 만들 수 없음. 단, 타입 정의를 포함한 같은 모듈 안의 코드에서는 생성자에 접근 가능. - 타입과 동일한 이름의 서브모듈(
UnitQuantity)을 만들고, 그 안에create함수를 정의.int를 받아Result<UnitQuantity, string>을 반환 (Result 타입은 5장 “오류 모델링” 참고)- 함수 시그니처
int -> Result<UnitQuantity,string>에 성공/실패 두 가능성이 명시적으로 드러남.
// define a module with the same name as the type
module UnitQuantity =
/// Define a "smart constructor" for UnitQuantity
/// int -> Result<UnitQuantity,string>
let create qty =
if qty < 1 then
// failure
Error "UnitQuantity can not be negative"
else if qty > 1000 then
// failure
Error "UnitQuantity can not be more than 1000"
else
// success -- construct the return value
Ok (UnitQuantity qty)
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type UnitQuantity = number & { readonly __brand: "UnitQuantity" };
namespace UnitQuantity {
// "스마트 생성자"
export function create(qty: number): Result<UnitQuantity, string> {
if (qty < 1) {
// 실패
return { ok: false, error: "UnitQuantity can not be negative" };
} else if (qty > 1000) {
// 실패
return { ok: false, error: "UnitQuantity can not be more than 1000" };
} else {
// 성공
return { ok: true, value: qty as UnitQuantity };
}
}
}
F# 구버전 호환성 노트 비제네릭 타입과 이름이 같은 모듈은 F# v4.1(VS2017) 이전 버전에서 오류가 발생함. 아래처럼
CompilationRepresentation속성으로 모듈 접미사를 붙여야 함.type UnitQuantity = ... [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] module UnitQuantity = ...
- private 생성자의 단점: 패턴 매칭으로 내부 데이터를 추출할 수 없게 됨.
- 해결책: 같은
UnitQuantity모듈 안에 값을 추출하는 별도의value함수를 정의.
- 해결책: 같은
/// Return the wrapped value
let value (UnitQuantity qty) = qty
namespace UnitQuantity {
// ...(위 create 함수에 이어서)
export function value(q: UnitQuantity): number {
return q;
}
}
- 실전 동작 확인
UnitQuantity를 직접 생성하려고 하면 컴파일 오류 발생(유니온 케이스가 접근 불가능하다는 오류).
let unitQty = UnitQuantity 1
// ^ The union cases of the type 'UnitQuantity'
// are not accessible
UnitQuantity.create함수를 사용하면 정상 동작하며Result를 돌려받아 매치 가능.
let unitQtyResult = UnitQuantity.create 1
match unitQtyResult with
| Error msg ->
printfn "Failure, Message is %s" msg
| Ok uQty ->
printfn "Success. Value is %A" uQty
let innerValue = UnitQuantity.value uQty
printfn "innerValue is %i" innerValue
const unitQtyResult = UnitQuantity.create(1);
if (!unitQtyResult.ok) {
console.log(`Failure, Message is ${unitQtyResult.error}`);
} else {
console.log(`Success. Value is ${unitQtyResult.value}`);
const innerValue = UnitQuantity.value(unitQtyResult.value);
console.log(`innerValue is ${innerValue}`);
}
- 팁: 제약된 타입이 많다면 생성자 관련 공통 코드를 헬퍼 모듈로 뽑아 중복을 줄일 수 있음(책 예제 코드의
Domain.SimpleTypes.fs참고). - private 외에도 생성자를 감추는 다른 기법(시그니처 파일 등)이 있으나 이 책에서는 다루지 않음.
6.2 측정 단위
- 숫자 값에 대한 요구사항을 문서화하면서 동시에 타입 안전성까지 보장하는 또 다른 방법: 측정 단위(Units of Measure).
- 숫자 값에 커스텀 “측정 단위(measure)“를 붙이는 방식.
[<Measure>]
type kg
[<Measure>]
type m
// TypeScript에는 측정 단위가 내장되어 있지 않으므로 브랜드 타입으로 흉내냄
type Kilogram = number & { readonly __unit: "kg" };
type Meter = number & { readonly __unit: "m" };
function kg(n: number): Kilogram {
return n as Kilogram;
}
function m(n: number): Meter {
return n as Meter;
}
- 측정 단위를 붙인 값 예시
let fiveKilos = 5.0<kg>
let fiveMeters = 5.0<m>
const fiveKilos = kg(5.0);
const fiveMeters = m(5.0);
모든 SI 단위에 대해 직접 측정 타입을 정의할 필요는 없음.
Microsoft.FSharp.Data.UnitSystems.SI네임스페이스에 이미 정의되어 있음.
- 측정 단위를 정의하면 컴파일러가 단위 간 호환성을 강제하며, 단위가 일치하지 않으면 오류를 냄.
// compiler error
fiveKilos = fiveMeters
// ^ Expecting a float<kg> but given a float<m>
let listOfWeights = [
fiveKilos
fiveMeters // <-- compiler error
// The unit of measure 'kg'
// does not match the unit of measure 'm'
]
// fiveKilos === fiveMeters; // 컴파일 오류: Kilogram 형식과 Meter 형식은 호환되지 않음
const listOfWeights: Kilogram[] = [
fiveKilos,
// fiveMeters, // <-- 컴파일 오류: Meter 형식은 Kilogram[]에 할당할 수 없음
];
- 도메인에 적용:
KilogramQuantity가 정말로 킬로그램 단위임을 강제하여 실수로 파운드 값을 넣는 것을 방지할 수 있음.
type KilogramQuantity = KilogramQuantity of decimal<kg>
type KilogramQuantity = Kilogram & { readonly __brand: "KilogramQuantity" };
- 이렇게 하면 두 가지 검사를 동시에 갖게 됨.
<kg>- 숫자가 올바른 단위를 가지는지KilogramQuantity- 최댓값/최솟값 제약을 만족하는지
- 이 예제 도메인에는 다소 과한 설계(overkill)일 수 있으나, 다른 상황에서는 유용할 수 있음.
- 측정 단위는 물리적 단위에만 쓰이지 않음.
- 타임아웃 값(초/밀리초 혼동 방지)
- 공간 차원(x축/y축 혼동 방지)
- 통화 단위 등
- 성능: 측정 단위는 F# 컴파일러만 사용하는 개념이며 런타임 오버헤드가 전혀 없음.
6.3 타입 시스템으로 불변성 강제하기
- **불변량(invariant)**의 정의: 다른 무슨 일이 일어나도 항상 참으로 유지되는 조건.
- 예: 이번 장 서두의 “
UnitQuantity는 항상 1~1000 사이여야 한다”가 불변량의 예.
- 예: 이번 장 서두의 “
- “주문에는 항상 하나 이상의 주문 라인이 있어야 한다”는 불변량은
UnitQuantity사례와 달리 타입 시스템으로 직접 표현 가능한 불변량임.- 리스트가 비어 있지 않음을 보장하려면
NonEmptyList타입을 정의하면 됨. F#에 내장되어 있지 않지만 직접 정의하기 쉬움.
- 리스트가 비어 있지 않음을 보장하려면
type NonEmptyList<'a> = {
First: 'a
Rest: 'a list
}
interface NonEmptyList<A> {
readonly first: A;
readonly rest: A[];
}
- 이 정의 자체가 “최소 하나의 원소가 항상 있어야 한다”는 조건을 강제함 ->
NonEmptyList는 절대 비어 있을 수 없음이 보장됨. add,remove같은 헬퍼 함수는 직접 정의하거나FSharpx.Collections같은 서드파티 라이브러리를 활용 가능.Order타입을NonEmptyList를 사용하도록 재작성
type Order = {
...
OrderLines : NonEmptyList<OrderLine>
...
}
interface Order {
// ...
orderLines: NonEmptyList<OrderLine>;
// ...
}
- 이 변경으로 “주문에는 항상 하나 이상의 주문 라인이 있어야 한다”는 제약이 자동으로 강제됨. 코드 자체가 문서 역할을 하며(self-documenting), 이 요구사항에 대한 단위 테스트를 작성할 필요가 없어짐.
6.4 타입 시스템에 비즈니스 규칙 녹이기
- 새로운 모델링 과제: 비즈니스 규칙 자체를 타입 시스템만으로 문서화할 수 있는가?
- 목표: 런타임 검사나 코드 주석에 의존하지 않고, F# 타입 시스템으로 유효/무효 상태를 표현해 컴파일러가 검사하도록 만드는 것.
- 실전 예시: Widgets Inc.는 고객 이메일 주소를 저장함.
- 이메일 주소는 두 가지 상태로 나뉨
- 검증됨(verified): 고객이 인증 메일을 받고 인증 링크를 클릭함
- 미검증(unverified): 유효성을 확신할 수 없음
- 이 차이에 기반한 비즈니스 규칙
- 인증 메일은 미검증 이메일 주소로만 보내야 함(기존 고객에게 스팸 방지)
- 비밀번호 재설정 메일은 검증된 이메일 주소로만 보내야 함(보안 침해 방지)
- 이메일 주소는 두 가지 상태로 나뉨
- 흔하지만 나쁜 접근: 플래그(flag)를 사용해 검증 여부를 표시
type CustomerEmail = {
EmailAddress : EmailAddress
IsVerified : bool
}
interface CustomerEmail {
emailAddress: EmailAddress;
isVerified: boolean;
}
- 이 방식의 문제점
IsVerified플래그를 언제/왜 설정해야 하는지 명확하지 않음. 예를 들어 고객이 이메일 주소를 변경하면 새 이메일은 아직 미검증 상태이므로 플래그를 다시false로 되돌려야 하지만, 설계상 이 규칙이 명시적으로 드러나지 않음. 개발자가 실수로 빠뜨리거나 규칙 자체를 모를 수 있음.- 보안 침해 가능성: 개발자가 실수로 미검증 이메일에도 플래그를
true로 설정하는 코드를 작성하면, 미검증 주소로 비밀번호 재설정 메일이 발송될 수 있음.
- 더 나은 모델링 방법: 도메인에 주의를 기울이기.
- 도메인 전문가가 “고객 이메일은 검증됨 또는 미검증 둘 중 하나다”라고 말한다면, 이를 두 타입 사이의 선택(choice)으로 모델링해야 함.
type CustomerEmail =
| Unverified of EmailAddress
| Verified of EmailAddress
type CustomerEmail =
| { kind: "Unverified"; value: EmailAddress }
| { kind: "Verified"; value: EmailAddress };
- 하지만 이것으로는 아직 부족함: 미검증
EmailAddress를 넘겨서 실수로Verified케이스를 만드는 것을 막지 못함.- 해결: 새로운 타입을 하나 더 만듦 -
VerifiedEmailAddress. 일반EmailAddress타입과는 다른 타입.
- 해결: 새로운 타입을 하나 더 만듦 -
type CustomerEmail =
| Unverified of EmailAddress
| Verified of VerifiedEmailAddress // different from normal EmailAddress
type VerifiedEmailAddress = EmailAddress & { readonly __brand: "VerifiedEmailAddress" };
type CustomerEmail =
| { kind: "Unverified"; value: EmailAddress }
| { kind: "Verified"; value: VerifiedEmailAddress };
- 핵심 트릭:
VerifiedEmailAddress에 private 생성자를 부여하여 일반 코드는 이 타입의 값을 생성할 수 없게 만들고, 오직 인증 서비스만 생성할 수 있도록 함.- 새 이메일 주소를 가지고 있다면
VerifiedEmailAddress가 없으므로 반드시Unverified케이스로CustomerEmail을 만들어야 함. Verified케이스를 만드는 유일한 방법은VerifiedEmailAddress를 갖고 있는 것뿐이고,VerifiedEmailAddress를 얻는 유일한 방법은 이메일 인증 서비스 그 자체뿐임.
- 새 이메일 주소를 가지고 있다면
graph TD
CE[CustomerEmail] -->|Unverified| U[EmailAddress]
CE -->|Verified| V[VerifiedEmailAddress]
V -.->|생성 권한| VS[이메일 인증 서비스만 생성 가능]
- 이것이 바로 중요한 설계 지침인 ==잘못된 상태를 표현 불가능하게 만들라(Make illegal states unrepresentable)==의 한 예.
- 비즈니스 규칙을 타입 시스템 안에 제대로 담아내면, 유효하지 않은 상황은 코드상에 아예 존재할 수 없게 되고 그에 대한 단위 테스트를 작성할 필요가 없어짐. 대신 “컴파일 타임 단위 테스트”를 갖게 됨.
- 부가적 이점: 도메인을 더 잘 문서화함. 두 역할을 동시에 수행하려던 단순한
EmailAddress대신, 서로 다른 규칙을 가진 두 개의 뚜렷한 타입을 갖게 됨. 이런 세분화된 타입을 만들고 나면 곧바로 다양한 활용처를 찾게 됨.- 예: 비밀번호 재설정 메일을 보내는 워크플로가 일반 이메일 주소가 아니라
VerifiedEmailAddress파라미터를 받아야 한다는 것을 명시적으로 문서화 가능.
- 예: 비밀번호 재설정 메일을 보내는 워크플로가 일반 이메일 주소가 아니라
type SendPasswordResetEmail = VerifiedEmailAddress -> ...
type SendPasswordResetEmail = (email: VerifiedEmailAddress) => void; // 실제로는 워크플로 반환 타입
- 이 정의 덕분에 누군가 실수로 일반
EmailAddress를 넘겨서 비즈니스 규칙을 깨뜨릴 걱정을 문서를 읽지 않아도 방지할 수 있음. - 두 번째 예시: “고객은 이메일 주소 또는 우편 주소 중 하나는 반드시 있어야 한다.”
- 첫 번째(잘못된) 접근:
Email과Address를 모두 필수 필드로 만든 레코드
type Contact = {
Name: Name
Email: EmailContactInfo
Address: PostalContactInfo
}
interface Contact {
name: Name;
email: EmailContactInfo;
address: PostalContactInfo;
}
- 문제: 둘 다 필수라는 뜻이 되어버림. 규칙과 다름. 그래서 옵션(optional)으로 만들어봄.
type Contact = {
Name: Name
Email: EmailContactInfo option
Address: PostalContactInfo option
}
interface Contact {
name: Name;
email?: EmailContactInfo;
address?: PostalContactInfo;
}
- 이것도 틀림:
Email과Address가 둘 다 없는 상태도 허용되어버려 비즈니스 규칙을 깰 수 있음. - 런타임 검증 로직을 추가할 수도 있지만, 타입 시스템으로 표현할 수 있는가? 가능함. 규칙을 자세히 들여다보면 세 가지 경우만 존재함.
- 이메일 주소만 있음
- 우편 주소만 있음
- 이메일 주소와 우편 주소 둘 다 있음
- 세 가지 경우 -> 선택 타입(choice type)으로 표현
type BothContactMethods = {
Email: EmailContactInfo
Address : PostalContactInfo
}
type ContactInfo =
| EmailOnly of EmailContactInfo
| AddrOnly of PostalContactInfo
| EmailAndAddr of BothContactMethods
interface BothContactMethods {
email: EmailContactInfo;
address: PostalContactInfo;
}
type ContactInfo =
| { kind: "EmailOnly"; value: EmailContactInfo }
| { kind: "AddrOnly"; value: PostalContactInfo }
| { kind: "EmailAndAddr"; value: BothContactMethods };
graph TD
CI[ContactInfo] --> EO[EmailOnly]
CI --> AO[AddrOnly]
CI --> EA[EmailAndAddr]
EO --> E1[EmailContactInfo]
AO --> A1[PostalContactInfo]
EA --> E2[Email]
EA --> A2[Address]
- 최종
Contact타입에 이 선택 타입을 적용
type Contact = {
Name: Name
ContactInfo : ContactInfo
}
interface Contact {
name: Name;
contactInfo: ContactInfo;
}
- 효과: 개발자 입장에서 실수로 연락처 정보가 하나도 없는 상태를 만들 수 없음(테스트 하나를 덜 작성해도 됨). 설계 관점에서도 오직 세 가지 경우만 가능하다는 것과 그 경우가 정확히 무엇인지 코드만 보고도 명확히 알 수 있음. 별도 문서를 찾아볼 필요가 없음.
6.4.1 잘못된 상태가 생길 수 없게 예시 도메인 수정하기
- 질문: 우리 설계 안에서 이 접근법을 실제로 적용할 수 있는 부분이 있는가?
- 이메일 검증 예시와 매우 유사한 부분: 검증 프로세스에서 이미 미검증 우편 주소(
UnvalidatedAddress)와 검증된 우편 주소(ValidatedAddress)를 구분해서 문서화했었음. - 두 케이스가 혼용되지 않도록, 그리고 검증 함수가 올바르게 사용되도록 보장하는 방법
- 두 개의 뚜렷한 타입 생성:
UnvalidatedAddress,ValidatedAddress ValidatedAddress에 private 생성자를 부여하여, 오직 주소 검증 서비스만 생성할 수 있도록 함
- 두 개의 뚜렷한 타입 생성:
type UnvalidatedAddress = ...
type ValidatedAddress = private ...
interface UnvalidatedAddress {
// ...
}
// private 생성자 패턴: 오직 주소 검증 서비스만 인스턴스를 생성할 수 있음
class ValidatedAddress {
private constructor(/* ... */) {}
}
- 검증 서비스는
UnvalidatedAddress를 받아 옵션 형태의ValidatedAddress를 반환(옵션인 이유: 검증이 실패할 수도 있으므로).
type AddressValidationService =
UnvalidatedAddress -> ValidatedAddress option
type AddressValidationService = (
address: UnvalidatedAddress
) => ValidatedAddress | undefined;
- “주문이 배송 부서로 보내지기 전 반드시 검증된 배송 주소를 가져야 한다”는 규칙을 강제하기 위해, 두 개의 뚜렷한 타입을 추가로 생성:
UnvalidatedOrder,ValidatedOrderValidatedOrder레코드는 배송 주소로 반드시ValidatedAddress타입을 갖도록 요구
type UnvalidatedOrder = {
...
ShippingAddress : UnvalidatedAddress
...
}
type ValidatedOrder = {
...
ShippingAddress : ValidatedAddress
...
}
interface UnvalidatedOrder {
// ...
shippingAddress: UnvalidatedAddress;
// ...
}
interface ValidatedOrder {
// ...
shippingAddress: ValidatedAddress;
// ...
}
flowchart LR
UA[UnvalidatedAddress] -->|AddressValidationService| VA[ValidatedAddress option]
UO[UnvalidatedOrder] -->|검증 통과| VO[ValidatedOrder]
VA --> VO
- 결과: 테스트를 전혀 작성하지 않아도,
ValidatedOrder안의 주소는 반드시 주소 검증 서비스를 거쳤음을 타입 시스템 차원에서 보장할 수 있음.
6.5 일관성
- 지금까지는 도메인 데이터의 무결성을 보장하는 방법을 살펴봤으니, 이제 연관 개념인 일관성(consistency)을 살펴봄.
- 이번 장 서두에서 본 일관성 요구사항 예시(재정리)
- 주문 총액은 개별 라인의 합이어야 함. 다르면 불일치.
- 주문이 생기면 대응하는 인보이스가 반드시 생성되어야 함. 주문은 있는데 인보이스가 없으면 불일치.
- 할인 바우처 코드가 사용되었다면 반드시 사용됨으로 표시되어야 함. 표시되지 않았다면 불일치.
- 일관성은 기술 용어가 아니라 비즈니스 용어이며, 그 의미는 항상 맥락에 따라 다름.
- 예: 제품 가격이 바뀌면 아직 배송되지 않은 주문들을 즉시 새 가격으로 갱신해야 하는가? 고객의 기본 주소가 바뀌면 미배송 주문들도 즉시 새 주소로 갱신해야 하는가? 정답은 없으며, 비즈니스가 무엇을 필요로 하는지에 달려 있음.
- 일관성은 설계에 큰 부담을 주고 비용이 클 수 있으므로, 가능하면 그 필요성 자체를 피하는 것이 좋음.
- 요구사항 수집 과정에서 제품 책임자가 바람직하지 않고 비현실적인 수준의 일관성을 요구하는 경우가 많음. 그러나 많은 경우 일관성에 대한 필요는 회피하거나 지연시킬 수 있음.
- 일관성과 영속성의 원자성(atomicity of persistence)은 서로 연결되어 있음.
- 예: 주문이 원자적으로(atomically) 영속화되지 않는다면 주문이 내부적으로 일관되도록 보장하는 것은 의미가 없음. 주문의 여러 부분이 따로따로 저장되다가 한 부분이 저장에 실패하면, 나중에 그 주문을 불러오는 사람은 내부적으로 일관되지 않은 주문을 보게 됨.
6.5.1 단일 집합체 내의 일관성
- (5장에서 소개한) 집합체(aggregate) 개념 복습: 집합체는 **일관성 경계(consistency boundary)**이자 **영속성 단위(unit of persistence)**로 동작함.
- 요구사항 예: 주문 총액은 개별 라인의 합이어야 함.
- 가장 쉬운 일관성 보장 방법: 데이터를 저장하지 않고 원본 데이터로부터 그때그때 계산하는 것. 즉, 총액이 필요할 때마다(메모리 상에서든 SQL 쿼리로든) 주문 라인들을 합산.
- 만약 추가 데이터(예: 최상위
Order에 저장되는AmountToBill)를 영속화해야 한다면, 이 값이 항상 동기화되도록 보장해야 함.- 라인 하나가 갱신되면 총액도 함께 갱신되어야 일관성이 유지됨.
- 일관성을 지킬 줄 아는 유일한 컴포넌트는 최상위
Order뿐임 -> 이것이 라인 레벨이 아니라 주문(Order) 레벨에서 모든 갱신을 수행해야 하는 이유.Order가 일관성 경계를 강제하는 집합체이기 때문.
/// We pass in three parameters:
/// * the top-level order
/// * the id of the order line we want to change
/// * the new price
let changeOrderLinePrice order orderLineId newPrice =
// find orderLine in order.OrderLines using orderLineId
let orderLine = order.OrderLines |> findOrderLine orderLineId
// make a new version of the OrderLine with new price
let newOrderLine = {orderLine with Price = newPrice}
// create new list of lines, replacing old line with new line
let newOrderLines =
order.OrderLines |> replaceOrderLine orderLineId newOrderLine
// make a new AmountToBill
let newAmountToBill = newOrderLines |> List.sumBy (fun line -> line.Price)
// make a new version of the order with the new lines
let newOrder = {
order with
OrderLines = newOrderLines
AmountToBill = newAmountToBill
}
// return the new order
newOrder
// 매개변수: 최상위 order, 변경할 orderLine의 id, 새 가격
function changeOrderLinePrice(
order: Order,
orderLineId: OrderLineId,
newPrice: Price
): Order {
// order.orderLines에서 orderLineId로 orderLine을 찾음
const orderLine = findOrderLine(order.orderLines, orderLineId);
// 새 가격을 가진 OrderLine의 새 버전을 만듦
const newOrderLine: OrderLine = { ...orderLine, price: newPrice };
// 기존 라인을 새 라인으로 교체한 새 리스트 생성
const newOrderLines = replaceOrderLine(
order.orderLines,
orderLineId,
newOrderLine
);
// 새 AmountToBill 계산
const newAmountToBill = newOrderLines.reduce(
(sum, line) => sum + line.price,
0
);
// 새 라인과 새 총액을 가진 order의 새 버전 생성
const newOrder: Order = {
...order,
orderLines: newOrderLines,
amountToBill: newAmountToBill,
};
// 새 order 반환
return newOrder;
}
- 집합체는 원자성의 단위이기도 함. 관계형 데이터베이스에 이 주문을 저장한다면, 주문 헤더와 주문 라인들이 모두 같은 트랜잭션 안에서 삽입/갱신되도록 보장해야 함.
6.5.2 다른 맥락 간의 일관성
- 서로 다른 컨텍스트 간 조율이 필요한 경우는 어떻게 해야 하는가?
- 예: 주문이 접수되면 대응하는 인보이스가 반드시 생성되어야 한다.
- 인보이싱은 빌링(billing) 도메인의 일부이지 주문 접수(order-taking) 도메인이 아님.
- 그렇다고 다른 도메인에 직접 침투해서 그 객체를 조작해야 하는가? 아님. 각 경계 컨텍스트는 격리되고 분리된 상태를 유지해야 함.
- 빌링 컨텍스트의 공개 API를 사용하는 방식은 어떤가(의사코드)
빌링 컨텍스트에 인보이스 생성을 요청
성공적으로 생성되었다면:
주문 접수 컨텍스트에 주문을 생성
- 이 접근법은 보이는 것보다 훨씬 까다로움. 둘 중 하나의 갱신이 실패하는 경우를 처리해야 하기 때문.
- 서로 다른 시스템 간 갱신을 제대로 동기화하는 방법(예: 2단계 커밋, two-phase commit)이 있지만, 실무에서 이것이 필요한 경우는 드묾.
- Gregor Hohpe의 글 “Starbucks Does Not Use Two-Phase Commit”의 통찰: 실세계 비즈니스는 일반적으로 모든 하위 시스템이 한 단계를 마칠 때까지 기다렸다가 다음 단계로 넘어가는 락스텝(lockstep) 방식으로 프로세스를 진행하도록 요구하지 않음. 대신 메시지를 통한 비동기적 조율이 이루어짐. 가끔 문제가 생기기도 하지만, 드문 오류를 처리하는 비용이 모든 것을 항상 동기화된 상태로 유지하는 비용보다 훨씬 저렴한 경우가 많음.
- 대안: 인보이스를 즉시 생성하도록 요구하는 대신, 빌링 도메인에 메시지(또는 이벤트)를 보내고 나머지 주문 처리를 계속 진행.
- 메시지가 유실되어 인보이스가 생성되지 않으면 어떻게 할 것인가? 세 가지 옵션.
| 옵션 | 설명 | 적합한 상황 |
|---|---|---|
| 아무 것도 하지 않기 | 유실된 메시지를 그대로 둠 | 오류가 드물고 비용이 작을 때(예: 커피숍에서 공짜로 제공되는 정도) |
| 재조정(reconciliation) | 두 데이터 집합을 비교하여 불일치를 찾아내고 수정 | 메시지 유실을 탐지하고 재전송해야 할 때 |
| 보상 조치(compensating action) | 이전 액션을 취소하거나 오류를 바로잡는 별도의 액션을 수행 | 주문 취소, 반품 요청, 환불 등 |
- 세 경우 모두 경계 컨텍스트 간의 엄격한 조율이 필요하지 않음.
- 일관성에 대한 요구사항이 있다면 두 번째 또는 세 번째 옵션을 구현해야 함. 다만 이런 종류의 일관성은 즉시 나타나지 않고, 시간이 어느 정도 지난 후 시스템이 일관된 상태가 되는 **결과적 일관성(eventual consistency)**의 형태를 띰.
- 결과적 일관성은 “선택적 일관성(optional consistency)“이 아님: 미래의 어느 시점에는 반드시 시스템이 일관되어야 한다는 점은 여전히 매우 중요함.
- 예시: 제품 가격이 변경되면, 아직 배송되지 않은 모든 주문의 가격을 갱신하고 싶다고 하자.
- 즉각적인 일관성이 필요하다면: 제품 레코드의 가격을 갱신할 때 영향받는 모든 주문도 같은 트랜잭션 안에서 갱신해야 함. 시간이 오래 걸릴 수 있음.
- 즉각적인 일관성이 필요 없다면:
PriceChanged이벤트를 생성하고, 이 이벤트가 일련의UpdateOrderWithChangedPrice커맨드들을 촉발하여 미배송 주문들을 갱신하도록 함. 이 커맨드들은 제품 가격이 바뀐 후 몇 초, 몇 시간 뒤에 처리될 수도 있음. 결국 주문들이 갱신되고 시스템은 일관된 상태가 됨.
sequenceDiagram
participant OT as 주문 접수 컨텍스트
participant MQ as 메시지 큐
participant BC as 빌링 컨텍스트
OT->>MQ: 주문 생성 이벤트 발행
MQ->>BC: 이벤트 전달
BC->>BC: 인보이스 생성
Note over OT,BC: 즉시 동기화 대신 결과적 일관성 확보
6.5.3 같은 맥락의 집합체들 간 일관성
- 같은 경계 컨텍스트 안에 있는 집합체들 사이의 일관성은 어떻게 보장할 것인가?
- 두 집합체가 서로 일관되어야 한다면, 같은 트랜잭션 안에서 함께 갱신해야 하는가, 아니면 결과적 일관성을 사용해 별도로 갱신해야 하는가?
- 일반적인 지침: ==트랜잭션 하나당 집합체 하나만 갱신하라==. 둘 이상의 집합체가 관련되어 있다면, 두 집합체가 같은 경계 컨텍스트 안에 있더라도 메시지와 결과적 일관성을 사용해야 함.
- 예외: 워크플로가 비즈니스 관점에서 단일 트랜잭션으로 간주된다면, 영향받는 모든 엔티티를 한 트랜잭션에 포함시키는 것이 나을 수도 있음.
- 고전적 예시: 두 계좌 사이의 송금. 한 계좌는 증가, 다른 계좌는 감소.
트랜잭션 시작
accountA에 X만큼 추가
accountB에서 X만큼 차감
트랜잭션 커밋
async function transferMoney(
accountA: AccountId,
accountB: AccountId,
amount: Money
): Promise<void> {
await db.transaction(async (tx) => {
await addAmount(tx, accountA, amount);
await removeAmount(tx, accountB, amount);
});
}
- 계좌가
Account집합체로 표현된다면, 하나의 트랜잭션 안에서 서로 다른 두 집합체를 갱신하는 셈. 이것이 반드시 문제는 아니지만, 리팩터링을 통해 도메인에 대한 더 깊은 통찰을 얻을 수 있다는 단서일 수 있음.- 이런 경우 트랜잭션 자체가 고유 식별자를 갖는 경우가 많음 -> 이는 그것이 그 자체로 DDD 엔티티(Entity)임을 암시함. 그렇다면 그렇게 모델링하지 않을 이유가 없음.
type MoneyTransfer = {
Id: MoneyTransferId
ToAccount : AccountId
FromAccount : AccountId
Amount: Money
}
interface MoneyTransfer {
id: MoneyTransferId;
toAccount: AccountId;
fromAccount: AccountId;
amount: Money;
}
graph LR
MT[MoneyTransfer 엔티티] -->|참조| A1[Account A]
MT -->|참조| A2[Account B]
A1 -. 잔액은 MoneyTransfer 합산으로 계산 .-> MT
A2 -. 잔액은 MoneyTransfer 합산으로 계산 .-> MT
- 이 변경 후
Account엔티티는 여전히 존재하지만, 더 이상 돈을 직접 더하거나 빼는 책임을 지지 않음. 대신Account의 현재 잔액은 그 계좌를 참조하는MoneyTransfer레코드들을 순회(iterate)하여 계산됨. - 이로써 설계를 리팩터링했을 뿐 아니라 도메인에 대해서도 새로운 것을 배우게 됨.
- 시사점: 집합체를 억지로 재사용할 필요는 없음. 하나의 유스케이스만을 위한 새로운 집합체가 필요하다면 그렇게 만들어도 됨.
6.5.4 동일한 데이터를 다루는 여러 집합체
- 앞서 집합체는 무결성 제약을 강제하는 역할을 한다고 강조했음. 그렇다면 같은 데이터를 다루는 여러 집합체가 있을 때 그 제약들이 일관되게 강제되도록 어떻게 보장할 것인가?
- 예:
Account집합체와MoneyTransfer집합체가 둘 다 계좌 잔액에 대해 작업하며, 둘 다 잔액이 음수가 되지 않도록 보장해야 함.
- 예:
- 많은 경우, 타입으로 모델링하면 여러 집합체 사이에서도 제약을 공유할 수 있음.
- 예: 계좌 잔액이 절대 0 미만이 되지 않아야 한다는 요구사항은
NonNegativeMoney타입으로 모델링할 수 있음.
- 예: 계좌 잔액이 절대 0 미만이 되지 않아야 한다는 요구사항은
// 참고용 예시: 앞서 다룬 스마트 생성자 패턴을 그대로 적용
type NonNegativeMoney = number & { readonly __brand: "NonNegativeMoney" };
namespace NonNegativeMoney {
export function create(amount: number): Result<NonNegativeMoney, string> {
if (amount < 0) {
return { ok: false, error: "Money amount can not be negative" };
}
return { ok: true, value: amount as NonNegativeMoney };
}
}
- 만약 타입으로 표현하는 것이 적용되지 않는다면, 공유된 검증 함수(validation function)를 사용할 수 있음.
- 이것이 함수형 모델이 객체지향 모델에 비해 갖는 장점 중 하나: 검증 함수는 특정 객체에 종속되지 않고 전역 상태에도 의존하지 않으므로, 서로 다른 워크플로에서 쉽게 재사용할 수 있음.
6.6 마무리
- 이번 장에서 배운 것: 도메인 내부의 데이터를 신뢰할 수 있도록 보장하는 방법.
- 핵심 요약
- 단순 타입에 대한 **스마트 생성자(smart constructors)**와, 더 복잡한 타입에 대한 **잘못된 상태를 표현 불가능하게 만들기(making illegal states unrepresentable)**를 조합하면, 타입 시스템 자체로 여러 종류의 무결성 규칙을 강제할 수 있음.
- 결과: 코드가 스스로 문서화되고(self-documenting), 단위 테스트의 필요성이 줄어듦.
- 하나의 경계 컨텍스트 내부, 그리고 경계 컨텍스트들 사이에서 일관된 데이터를 유지하는 방법도 살펴봄.
- 결론: 단일 집합체 내부에서 작업하는 경우가 아니라면, 즉각적 일관성(immediate consistency)보다는 **결과적 일관성(eventual consistency)**을 설계 목표로 삼아야 함.
- 단순 타입에 대한 **스마트 생성자(smart constructors)**와, 더 복잡한 타입에 대한 **잘못된 상태를 표현 불가능하게 만들기(making illegal states unrepresentable)**를 조합하면, 타입 시스템 자체로 여러 종류의 무결성 규칙을 강제할 수 있음.
- 다음 장 예고: 지금까지 배운 내용을 실제로 적용하여 주문 접수(order-placing) 워크플로를 모델링함.
챕터 7 파이프라인으로 작업 흐름 모델링하기
- 5, 6장에서 타입을 이용한 일반적인 도메인 모델링 방법을 학습 -> 이번 장에서는 이를 실제 주문하기(Place Order) 작업 흐름에 적용
- 목표는 항상 동일: 도메인 전문가가 읽을 수 있는(readable) 결과물을 만드는 것
- 5장(40페이지)에서 정리한 Place Order 워크플로 요약을 재확인
workflow "Place Order" =
input: UnvalidatedOrder
output (on success):
OrderAcknowledgmentSent
AND OrderPlaced (to send to shipping)
AND BillableOrderPlaced (to send to billing)
output (on error):
ValidationError
// step 1
do ValidateOrder
If order is invalid then:
return with ValidationError
// step 2
do PriceOrder
// step 3
do AcknowledgeOrder
// step 4
create and return the events
- 워크플로는
ValidateOrder,PriceOrder등 일련의 하위 단계(substep)로 구성됨 - 많은 비즈니스 프로세스는 문서 변환(document transformation)의 연속으로 볼 수 있음 -> 워크플로를 **파이프라인(pipeline)**으로 표현 가능
- 비즈니스 프로세스를 표현하는 큰 파이프라인은 여러 개의 작은 “파이프”로 구성되며, 각 파이프는 하나의 변환만 수행
- 이런 프로그래밍 스타일을 ==변환 지향 프로그래밍(transformation-oriented programming)==이라 부름
- 함수형 프로그래밍 원칙에 따라 파이프라인의 각 단계는 상태가 없고(stateless), 부수 효과가 없도록(without side effects) 설계 -> 각 단계를 독립적으로 테스트/이해 가능
- 파이프라인의 조각을 설계한 뒤에는 이를 구현하고 조립(assemble)하기만 하면 됨
flowchart LR
UO(["Unvalidated Order"]) --> V
subgraph "Place order workflow"
V["Validate"] --> P["Price"] --> A["Acknowledge"]
end
A --> SE[/"부수 효과: 주문 확인서 발송"/]
A --> SUCCESS(["성공: OrderAcknowledgmentSent, OrderPlaced, BillableOrderPlaced"])
V -. 실패 .-> FAIL{{"실패: error list"}}
7.1 작업 흐름 입력
- 작업 흐름의 입력은 항상 도메인 객체여야 함 (DTO에서 역직렬화가 이미 끝난 상태로 가정)
- Place Order 워크플로의 입력:
UnvalidatedOrder(앞 장에서 모델링한 타입 재사용)
type UnvalidatedOrder = {
OrderId : string
CustomerInfo : UnvalidatedCustomerInfo
ShippingAddress : UnvalidatedAddress
...
}
interface UnvalidatedOrder {
orderId: string;
customerInfo: UnvalidatedCustomerInfo;
shippingAddress: UnvalidatedAddress;
// ...
}
7.1.1 명령을 입력으로 사용하기
- 1장(13페이지)에서 이미 언급: 작업 흐름은 그 흐름을 개시하는 커맨드(command) 와 연관됨
- 즉 워크플로의 진짜 입력은 주문서(order form) 자체가 아니라 커맨드
- Place Order 워크플로의 커맨드 이름:
PlaceOrder - 커맨드는 워크플로 처리에 필요한 모든 것(
UnvalidatedOrder)을 담아야 하며, 로깅/감사(auditing)를 위해 커맨드 생성자(UserId)와 타임스탬프 등 메타데이터도 함께 추적
type PlaceOrder = {
OrderForm : UnvalidatedOrder
Timestamp: DateTime
UserId: string
// etc
}
interface PlaceOrder {
orderForm: UnvalidatedOrder;
timestamp: Date;
userId: string;
// etc
}
7.1.2 공통 구조 일반화하기
PlaceOrder는 유일한 커맨드가 아님 -> 각 커맨드는 자기 워크플로에 필요한 데이터를 갖지만,UserId,Timestamp같은 공통 필드도 공유- 매번 동일한 필드를 반복 구현하지 않고 공유하는 방법이 필요
- 객체지향이라면: 공통 필드를 담은 기반 클래스(base class)를 만들고 각 커맨드가 상속
- 함수형에서는: 제네릭(generics) 으로 동일한 목표 달성
- 공통 필드 + 커맨드별 데이터를 담을 슬롯(
Data)을 가진Command<'data>타입 정의
type Command<'data> = {
Data : 'data
Timestamp: DateTime
UserId: string
// etc
}
Data슬롯에 들어갈 타입만 지정하면 워크플로별 커맨드를 손쉽게 생성 가능
type PlaceOrder = Command<UnvalidatedOrder>
interface Command<TData> {
data: TData;
timestamp: Date;
userId: string;
// etc
}
type PlaceOrder = Command<UnvalidatedOrder>;
7.1.3 여러 명령을 단일 타입으로 묶기
- 하나의 바운디드 컨텍스트(bounded context)에 속한 모든 커맨드가 동일한 입력 채널(예: 메시지 큐)로 전달되는 경우가 많음 -> 이들을 하나의 데이터 구조로 통합해 직렬화할 방법이 필요
- 해결책: 모든 커맨드를 포함하는 선택 타입(choice type) 생성
type OrderTakingCommand =
| Place of PlaceOrder
| Change of ChangeOrder
| Cancel of CancelOrder
- 각 케이스는 해당 커맨드 타입과 연관됨.
ChangeOrder,CancelOrder도PlaceOrder와 동일한 방식으로 정의(커맨드 실행에 필요한 정보를 담음) - 이 선택 타입은 입력 채널에서 DTO로 매핑되어 직렬화/역직렬화됨
- 바운디드 컨텍스트 경계(온이언 아키텍처의 “인프라” 링)에 새로운 라우팅/디스패칭 입력 단계를 추가해야 함
type OrderTakingCommand =
| { type: "Place"; command: PlaceOrder }
| { type: "Change"; command: ChangeOrder }
| { type: "Cancel"; command: CancelOrder };
flowchart LR
OTC[["OrderTaking command"]] --> CH["Command handler"]
CH --> PC[["PlaceOrder command"]]
CH --> CC[["ChangeOrder command"]]
CH --> XC[["CancelOrder command"]]
subgraph "Order-taking context"
PC --> PW["PlaceOrder workflow"]
CC --> CW["ChangeOrder workflow"]
XC --> XW["CancelOrder workflow"]
end
7.2 상태 집합으로 주문 모델링하기
- 워크플로 파이프라인의 단계로 넘어가기 전에,
Order가 정적 문서가 아니라 일련의 서로 다른 상태(state) 를 거쳐 전이(transition)한다는 점을 파악해야 함
flowchart LR
UPF(("Unprocessed order form")) --> UO(("Unvalidated order"))
UPF -.-> UQ(("Unvalidated quote"))
UO --> VO(("Validated order"))
VO --> PO(("Priced order"))
VO --> IO(("Invalid Order"))
- 나쁜 접근: 단일 레코드 타입에 플래그(flag)로 모든 상태를 표현
type Order = {
OrderId : OrderId
...
IsValidated : bool // set when validated
IsPriced : bool // set when priced
AmountToBill : decimal option // also set when priced
}
interface Order {
orderId: OrderId;
// ...
isValidated: boolean; // 검증되면 설정
isPriced: boolean; // 가격 책정되면 설정
amountToBill?: number; // 가격 책정될 때도 함께 설정
}
-
플래그 방식의 문제점
- 시스템에는 분명 상태가 존재하지만(플래그로 표시) 암묵적(implicit) 이며, 다루려면 많은 조건문(conditional code)이 필요
- 일부 상태에서만 필요한 데이터가 있는데도 모두 한 레코드에 몰아넣다 보니,
AmountToBill처럼 항상 존재하지 않는 필드를 옵션(optional)으로 만들어야 함 - 어떤 필드가 어떤 플래그와 짝인지 불명확 ->
AmountToBill은IsPriced일 때만 유효해야 하지만 설계가 이를 강제하지 않고 주석에만 의존
-
더 나은 방법: 상태마다 새로운 타입을 만들어 암묵적 상태와 조건부 필드를 제거
-
타입은 이전에 작성한 도메인 문서로부터 직접 도출 가능
data ValidatedOrder =
ValidatedCustomerInfo
AND ValidatedShippingAddress
AND ValidatedBillingAddress
AND list of ValidatedOrderLine
- 위 도메인 문서를 그대로 옮긴 타입 정의(워크플로 전체에서 주문 식별자를 유지해야 하므로
OrderId추가)
type ValidatedOrder = {
OrderId : OrderId
CustomerInfo : CustomerInfo
ShippingAddress : Address
BillingAddress : Address
OrderLines : ValidatedOrderLine list
}
interface ValidatedOrder {
orderId: OrderId;
customerInfo: CustomerInfo;
shippingAddress: Address;
billingAddress: Address;
orderLines: ValidatedOrderLine[];
}
PricedOrder도 같은 방식으로 정의하되 가격 정보 필드를 추가
type PricedOrder = {
OrderId : ...
CustomerInfo : CustomerInfo
ShippingAddress : Address
BillingAddress : Address
// different from ValidatedOrder
OrderLines : PricedOrderLine list
AmountToBill : BillingAmount
}
interface PricedOrder {
orderId: OrderId;
customerInfo: CustomerInfo;
shippingAddress: Address;
billingAddress: Address;
// ValidatedOrder와 다른 부분
orderLines: PricedOrderLine[];
amountToBill: BillingAmount;
}
- 마지막으로, 모든 상태를 아우르는 최상위 선택 타입 생성 -> 이 타입이 주문의 생애 주기 어느 시점이든 표현하며, 저장소(storage)에 영속화하거나 다른 컨텍스트와 통신할 때 사용
type Order =
| Unvalidated of UnvalidatedOrder
| Validated of ValidatedOrder
| Priced of PricedOrder
// etc
type Order =
| { type: "Unvalidated"; order: UnvalidatedOrder }
| { type: "Validated"; order: ValidatedOrder }
| { type: "Priced"; order: PricedOrder };
// etc
Quote는 이 선택지에 포함하지 않음 ->Order가 도달할 수 있는 상태가 아니라 완전히 별개의 워크플로이기 때문
7.2.1 요구사항 변경에 따라 새 상태 타입 추가하기
- 상태마다 별도 타입을 쓰는 방식의 장점: 새 상태를 기존 코드를 깨뜨리지 않고 추가 가능
- 예: 환불(refund) 요구사항이 생기면 그 상태에 필요한 정보만 담은
RefundedOrder상태를 새로 추가하면 됨 - 다른 상태들은 서로 독립적으로 정의되어 있으므로, 이 변경이 기존 코드에 영향을 주지 않음
7.3 상태 기계
- 앞서 플래그가 있는 단일 타입을 개별 타입 집합으로 바꾼 것은 이번이 두 번째임 (6장 108페이지의
EmailAddress예제에서 “Unverified”/“Verified” 두 선택지로 바꾼 것이 첫 번째) - 이런 상황은 비즈니스 모델링에서 매우 흔함 -> 상태(state) 를 범용 도메인 모델링 도구로 짚고 넘어감
- 전형적인 모델: 문서/레코드가 하나 이상의 상태를 가지고, 상태 간 경로(전이, transition)는 어떤 종류의 커맨드에 의해 트리거됨 -> 이를 상태 기계(state machine) 라 부름
stateDiagram-v2
A: State A
B: State B
C: State C
A --> B: Transition from A to B
B --> A: Transition from B to A
B --> C: Transition from B to C
- 언어 파서, 정규 표현식 등에 쓰이는 수십~수백 개 상태를 가진 복잡한 상태 기계는 다루지 않음
- 여기서 다루는 상태 기계는 케이스가 몇 개 안 되고 전이도 적은 훨씬 단순한 것들
| 예시 | 상태 목록 | 주요 전이 |
|---|---|---|
| 이메일 주소 | Unverified, Verified | 확인 링크 클릭(Unverified -> Verified), 이메일 주소 변경(Verified -> Unverified) |
| 쇼핑카트 | Empty, Active, Paid | 아이템 추가(Empty -> Active), 아이템 추가/제거(Active -> Active), 결제(Active -> Paid) |
| 택배 배송 | Undelivered, Out for Delivery, Delivered | 트럭 적재(Undelivered -> Out for Delivery), 고객 부재중(Out for Delivery -> Undelivered), 고객 서명(Out for Delivery -> Delivered) |
- 이메일 주소:
Unverified상태에서 사용자가 확인 이메일의 링크를 클릭하면Verified상태로 전이
stateDiagram-v2
[*] --> Unverified
Unverified --> Verified: 확인 링크 클릭
Verified --> Unverified: 이메일 주소 변경
- 쇼핑카트:
Empty에서 아이템을 추가하면Active로,Active에서 결제하면Paid로 전이.Active상태에서는 아이템 추가/제거가 반복 가능하며, 모든 아이템을 제거하면 다시Empty로 돌아감
stateDiagram-v2
[*] --> EmptyCart
EmptyCart --> ActiveCart: Add item
ActiveCart --> ActiveCart: Add item
ActiveCart --> ActiveCart: Remove item
ActiveCart --> EmptyCart: Remove item
ActiveCart --> PaidCart: Pay
- 택배 배송:
Undelivered에서 트럭에 실으면Out for Delivery로, 고객이 부재중이면 다시Undelivered로, 고객이 서명하면Delivered로 전이
stateDiagram-v2
[*] --> Undelivered
Undelivered --> OutForDelivery: Put on truck
OutForDelivery --> Undelivered: Customer not home
OutForDelivery --> Delivered: Customer signs for package
7.3.1 왜 상태 기계를 사용할까요?
- 각 상태는 서로 다른 허용 동작(allowable behavior)을 가질 수 있음
- 쇼핑카트 예:
Active카트만 결제 가능,Paid카트에는 아이템 추가 불가 - 이메일 예:
Verified상태에만 비밀번호 재설정을 발송 가능 - 상태별로 별도 타입을 쓰면 이런 비즈니스 규칙을 함수 시그니처에서 컴파일러로 강제 가능
- 쇼핑카트 예:
- 모든 상태가 명시적으로 문서화됨
- 암묵적이지만 중요한 상태가 존재하기 쉬움 (예: “빈 카트”는 “활성 카트”와 동작이 다르지만 코드에 명시적으로 문서화되는 경우가 드묾)
- 발생 가능한 모든 경우의 수를 고려하도록 강제하는 설계 도구
- 설계 오류의 흔한 원인은 처리되지 않은 엣지 케이스
- 상태 기계는 다음과 같은 질문을 강제로 떠올리게 함
- 이미 검증된 이메일을 다시 검증하려 하면 어떻게 되는가?
- 빈 쇼핑카트에서 아이템을 제거하려 하면 어떻게 되는가?
- 이미 “Delivered” 상태인 패키지를 다시 배송하려 하면 어떻게 되는가?
7.3.2 TypeScript로 간단한 상태 기계를 구현하는 방법
- 언어 파서 등에 쓰이는 복잡한 상태 기계는 규칙 집합/문법으로부터 생성되며 구현이 꽤 복잡
- 그러나 위에서 다룬 것 같은 단순한 비즈니스 지향 상태 기계는 별도 도구나 라이브러리 없이 손으로 구현 가능
- 하지 말아야 할 것: 플래그, enum, 조건 로직 등으로 모든 상태를 하나의 공통 레코드에 합치는 것
- 좋은 접근: 상태마다 고유 타입을 부여(관련 데이터가 있으면 그 데이터를 저장) + 전체 상태 집합은 상태별 케이스를 가진 선택 타입(choice type) 으로 표현
원서 F# 예시(쇼핑카트 상태 기계):
type Item = ...
type ActiveCartData = { UnpaidItems: Item list }
type PaidCartData = { PaidItems: Item list; Payment: float }
type ShoppingCart =
| EmptyCart // no data
| ActiveCart of ActiveCartData
| PaidCart of PaidCartData
EmptyCart상태는 연관 데이터가 없으므로 별도 타입이 필요 없음- 커맨드 핸들러는 전체 상태 기계(선택 타입)를 받아 새 버전의 상태 기계를 반환하는 함수
아이템 추가(addItem):
let addItem cart item =
match cart with
| EmptyCart ->
// 아이템 하나를 가진 새 active cart 생성
ActiveCart {UnpaidItems=[item]}
| ActiveCart {UnpaidItems=existingItems} ->
// 아이템이 추가된 새 ActiveCart 생성
ActiveCart {UnpaidItems = item :: existingItems}
| PaidCart _ ->
// 무시
cart
결제(makePayment):
let makePayment cart payment =
match cart with
| EmptyCart ->
// 무시
cart
| ActiveCart {UnpaidItems=existingItems} ->
// 결제 정보를 담은 새 PaidCart 생성
PaidCart {PaidItems = existingItems; Payment=payment}
| PaidCart _ ->
// 무시
cart
- 결과는 상태가 바뀐 새로운
ShoppingCart일 수도, 그대로일 수도 있음(예: 이미Paid상태였다면 무시) - 호출자 입장에서는 전체 상태 집합이 하나(
ShoppingCart타입)로 취급되지만, 내부적으로 이벤트를 처리할 때는 각 상태가 개별적으로 처리됨
TypeScript 구현(discriminated union + switch):
interface Item {
// ...
}
interface ActiveCartData {
unpaidItems: Item[];
}
interface PaidCartData {
paidItems: Item[];
payment: number;
}
type ShoppingCart =
| { type: "EmptyCart" }
| { type: "ActiveCart"; data: ActiveCartData }
| { type: "PaidCart"; data: PaidCartData };
function addItem(cart: ShoppingCart, item: Item): ShoppingCart {
switch (cart.type) {
case "EmptyCart":
// 아이템 하나를 가진 새 active cart 생성
return { type: "ActiveCart", data: { unpaidItems: [item] } };
case "ActiveCart":
// 아이템이 추가된 새 ActiveCart 생성
return {
type: "ActiveCart",
data: { unpaidItems: [item, ...cart.data.unpaidItems] },
};
case "PaidCart":
// 무시
return cart;
}
}
function makePayment(cart: ShoppingCart, payment: number): ShoppingCart {
switch (cart.type) {
case "EmptyCart":
// 무시
return cart;
case "ActiveCart":
// 결제 정보를 담은 새 PaidCart 생성
return {
type: "PaidCart",
data: { paidItems: cart.data.unpaidItems, payment },
};
case "PaidCart":
// 무시
return cart;
}
}
7.4 타입으로 작업 흐름의 개별 단계 모델링하기
- 상태 기계 접근법은 주문하기 워크플로 모델링에 딱 맞음 -> 이를 바탕으로 각 하위 단계를 상세히 모델링
7.4.1 검증 단계
- 5장(40페이지)에서 정리한
ValidateOrder하위 단계 도메인 문서
substep "ValidateOrder" =
input: UnvalidatedOrder
output: ValidatedOrder OR ValidationError
dependencies: CheckProductCodeExists, CheckAddressExists
- 입력 외에 두 개의 의존성(dependency) 존재: 제품 코드 존재 확인, 주소 존재 확인
flowchart LR
CPE[["CheckProductCodeExists"]]
CAE[["CheckAddressExists"]]
UO(("Unvalidated Order")) --> VO["ValidateOrder"]
CPE -. dependency .-> VO
CAE -. dependency .-> VO
VO --> R(("ValidatedOrder or Invalid Order"))
- 의존성도 함수로 모델링 -> 함수의 타입 시그니처가 나중에 구현해야 할 “인터페이스”가 됨
- 제품 코드 존재 확인:
ProductCode를 받아 존재 여부(bool)를 반환
type CheckProductCodeExists =
ProductCode -> bool
// ^input ^output
type CheckProductCodeExists = (productCode: ProductCode) => boolean;
- 주소 확인 서비스:
UnvalidatedAddress를 받아 보정된 주소(성공) 또는 검증 오류(실패)를 반환해야 함 - “확인된 주소(checked address, 원격 주소 확인 서비스의 출력)“와
Address도메인 객체를 구분 -> 언젠가 둘 사이를 변환해야 함. 우선CheckedAddress를UnvalidatedAddress의 래핑 타입으로 정의
type CheckedAddress = CheckedAddress of UnvalidatedAddress
type CheckedAddress = { kind: "CheckedAddress"; value: UnvalidatedAddress };
- 서비스는
UnvalidatedAddress를 입력받아Result타입을 반환(성공:CheckedAddress, 실패:AddressValidationError)
type AddressValidationError = AddressValidationError of string
type CheckAddressExists =
UnvalidatedAddress -> Result<CheckedAddress,AddressValidationError>
// ^input ^output
type AddressValidationError = { kind: "AddressValidationError"; message: string };
type Result<TSuccess, TFailure> =
| { ok: true; value: TSuccess }
| { ok: false; error: TFailure };
type CheckAddressExists = (
address: UnvalidatedAddress
) => Result<CheckedAddress, AddressValidationError>;
- 의존성을 정의했으니
ValidateOrder단계 전체를 함수로 정의: 기본 입력(UnvalidatedOrder), 의존성 2개(CheckProductCodeExists,CheckAddressExists), 출력(ValidatedOrder또는 오류)
type ValidateOrder =
CheckProductCodeExists // dependency
-> CheckAddressExists // dependency
-> UnvalidatedOrder // input
-> Result<ValidatedOrder,ValidationError> // output
- 의존성 중 하나(
CheckAddressExists)가Result를 반환하므로, 함수의 전체 반환값도Result여야 함 Result가 사용되는 곳은 모두 “오염(contaminate)“시키며, 이 “result성”은 최상위 함수에서 처리될 때까지 계속 위로 전파되어야 함
팁: 의존성을 매개변수 목록의 맨 앞에, 입력 타입을 출력 타입 바로 앞(끝에서 두 번째)에 두는 이유는 부분 적용(partial application) 을 쉽게 하기 위함이며, 이는 함수형에서의 의존성 주입(dependency injection)과 같은 역할을 함. 실제 동작 방식은 8장(구현 장, 180페이지)에서 다룸
type ValidateOrder = (
checkProductCodeExists: CheckProductCodeExists
) => (
checkAddressExists: CheckAddressExists
) => (
unvalidatedOrder: UnvalidatedOrder
) => Result<ValidatedOrder, ValidationError>;
7.4.2 가격 책정 단계
PriceOrder하위 단계 도메인 문서
substep "PriceOrder" =
input: ValidatedOrder
output: PricedOrder
dependencies: GetProductPrice
- 의존성 1개: 제품 코드로 가격을 반환하는 함수
flowchart LR
GPP[["GetProductPrice"]]
VO(("Validated Order")) --> PR["PriceOrder"]
GPP -. dependency .-> PR
PR --> PO(("PricedOrder"))
type GetProductPrice =
ProductCode -> Price
type GetProductPrice = (productCode: ProductCode) => Price;
PriceOrder함수는 제품 카탈로그 정보가 필요하지만, 무거운IProductCatalog인터페이스 전체를 넘기는 대신 이 단계에서 정확히 필요한 것만 표현하는 함수(GetProductPrice) 하나만 전달 -> 제품 카탈로그의 존재 자체를 감추는 추상화 역할
type PriceOrder =
GetProductPrice // dependency
-> ValidatedOrder // input
-> PricedOrder // output
- 이 함수는 항상 성공한다고 가정 ->
Result를 반환할 필요 없음
type PriceOrder = (
getProductPrice: GetProductPrice
) => (validatedOrder: ValidatedOrder) => PricedOrder;
7.4.3 주문 확인 단계
- 다음 단계는 확인서(acknowledgment letter)를 만들어 고객에게 발송하는 것
- 확인서 모델링: 이메일로 보낼 HTML 문자열 타입 + 수신 이메일 주소와 편지를 담은 레코드
type HtmlString =
HtmlString of string
type OrderAcknowledgment = {
EmailAddress : EmailAddress
Letter : HtmlString
}
type HtmlString = { kind: "HtmlString"; value: string };
interface OrderAcknowledgment {
emailAddress: EmailAddress;
letter: HtmlString;
}
- 편지 내용은 템플릿에서 생성될 가능성이 높음 -> 이 로직을 워크플로에 넣지 않고 다른 서비스 함수에 위임(다른 누군가의 문제로 만듦)
type CreateOrderAcknowledgmentLetter =
PricedOrder -> HtmlString
type CreateOrderAcknowledgmentLetter = (pricedOrder: PricedOrder) => HtmlString;
- 이 타입의 함수를 이 단계의 의존성으로 사용
- 편지를 실제로 발송하는 방법(API 직접 호출? 메시지 큐?)은 지금 결정할 필요 없음 -> 구현은 미루고 필요한 인터페이스만 정의
type SendOrderAcknowledgment =
OrderAcknowledgment -> unit
unit은 “부수 효과는 있지만 신경 쓰지 않음, 반환값 없음”을 의미- 문제: 상위 워크플로에서
OrderAcknowledgmentSent이벤트를 반환하려면 발송 성공 여부를 알아야 하는데,unit으로는 알 수 없음 - 개선안 1:
bool반환
type SendOrderAcknowledgment =
OrderAcknowledgment -> bool
-
문제점:
bool은 정보성이 낮음(uninformative) -> 좋지 않은 설계 선택 -
개선안 2(채택):
Sent/NotSent선택 타입 사용
type SendResult = Sent | NotSent
type SendOrderAcknowledgment =
OrderAcknowledgment -> SendResult
- 개선안 3(검토 후 기각): 서비스가 직접
OrderAcknowledgmentSent이벤트를 (옵션으로) 반환
type SendOrderAcknowledgment =
OrderAcknowledgment -> OrderAcknowledgmentSent option
- 단점: 이벤트 타입을 통해 도메인과 서비스 사이에 결합(coupling) 이 생김
- 정답은 없으므로 일단
Sent/NotSent방식을 채택(나중에 변경 가능)
type OrderAcknowledgmentSent = {
OrderId : OrderId
EmailAddress : EmailAddress
}
type SendResult = "Sent" | "NotSent";
type SendOrderAcknowledgment = (
acknowledgment: OrderAcknowledgment
) => SendResult;
interface OrderAcknowledgmentSent {
orderId: OrderId;
emailAddress: EmailAddress;
}
- 이 단계(
AcknowledgeOrder) 전체 함수 타입: 확인서가 발송되지 않았을 수도 있으므로 반환값은 옵션 이벤트
type AcknowledgeOrder =
CreateOrderAcknowledgmentLetter // dependency
-> SendOrderAcknowledgment // dependency
-> PricedOrder // input
-> OrderAcknowledgmentSent option // output
type AcknowledgeOrder = (
createLetter: CreateOrderAcknowledgmentLetter
) => (
sendAcknowledgment: SendOrderAcknowledgment
) => (pricedOrder: PricedOrder) => OrderAcknowledgmentSent | undefined;
7.4.4 반환할 이벤트 생성
AcknowledgeOrder단계에서OrderAcknowledgmentSent이벤트는 만들어졌지만, 배송용OrderPlaced이벤트와 청구용BillableOrderPlaced이벤트는 아직 생성되지 않음OrderPlaced는PricedOrder의 별칭(alias)으로,BillableOrderPlaced는PricedOrder의 부분집합으로 정의 가능
type OrderPlaced = PricedOrder
type BillableOrderPlaced = {
OrderId : OrderId
BillingAddress: Address
AmountToBill : BillingAmount
}
type OrderPlaced = PricedOrder;
interface BillableOrderPlaced {
orderId: OrderId;
billingAddress: Address;
amountToBill: BillingAmount;
}
- 방법 1(기각): 이벤트를 반환하기 위한 전용 레코드 타입 생성
type PlaceOrderResult = {
OrderPlaced : OrderPlaced
BillableOrderPlaced : BillableOrderPlaced
OrderAcknowledgmentSent : OrderAcknowledgmentSent option
}
-
문제점: 시간이 지나며 새 이벤트가 추가될 가능성이 높은데, 이런 전용 레코드 타입은 변경을 어렵게 만듦
-
방법 2(채택): 워크플로가 이벤트의 리스트를 반환하도록 함. 이벤트는
OrderPlaced,BillableOrderPlaced,OrderAcknowledgmentSent중 하나인 선택 타입(PlaceOrderEvent)
type PlaceOrderEvent =
| OrderPlaced of OrderPlaced
| BillableOrderPlaced of BillableOrderPlaced
| AcknowledgmentSent of OrderAcknowledgmentSent
- 워크플로의 마지막 단계는 이 이벤트들의 리스트를 방출(emit)
type CreateEvents =
PricedOrder -> PlaceOrderEvent list
- 새 이벤트가 필요해지면 선택지에 추가만 하면 되고, 전체 워크플로를 깨뜨리지 않음
- 동일한 이벤트가 여러 워크플로에서 나타난다면, 한 단계 더 올라가 도메인 전체를 아우르는
OrderTakingDomainEvent같은 더 일반적인 선택 타입을 만들 수도 있음
type PlaceOrderEvent =
| { type: "OrderPlaced"; event: OrderPlaced }
| { type: "BillableOrderPlaced"; event: BillableOrderPlaced }
| { type: "AcknowledgmentSent"; event: OrderAcknowledgmentSent };
type CreateEvents = (pricedOrder: PricedOrder) => PlaceOrderEvent[];
7.5 효과 문서화하기
- 5장(87페이지)에서 타입 시그니처에 효과(effect) 를 문서화하는 방법을 논의: 이 함수는 오류를 반환할 수 있는가? I/O를 수행하는가?
- 이번 절에서는 모든 의존성을 다시 살펴보고, 이런 효과를 명시적으로 표현해야 하는지 재점검
7.5.1 검증 단계의 효과
ValidateOrder의 두 의존성:CheckProductCodeExists,CheckAddressExistsCheckProductCodeExists: 오류를 반환하는가? 원격 호출인가? -> 둘 다 아니라고 가정. 로컬에 캐시된 제품 카탈로그 사본이 있고 빠르게 접근 가능하다고 가정(6장에서 Ollie가 언급한 “자율성(autonomy)” 상기)
type CheckProductCodeExists = ProductCode -> bool
CheckAddressExists는 원격 서비스를 호출 ->Async효과와Result효과 모두 필요Async와Result는 함께 쓰이는 경우가 매우 흔함 ->AsyncResult별칭 타입으로 결합
type AsyncResult<'success,'failure> = Async<Result<'success,'failure>>
type CheckAddressExists =
UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>
- 이제 타입 시그니처만 보고도 이 함수가 I/O를 수행하고 실패할 수도 있음을 명확히 알 수 있음
- 바운디드 컨텍스트 자율성을 위해 로컬 버전을 만들어야 하는가? -> Ollie에 따르면 이 서비스는 가용성(availability)이 매우 높음
- 자율성을 원하는 주된 이유는 성능이 아니라 특정 수준의 가용성/서비스를 약속할 수 있는 능력. 서드파티에 의존한다면 그 서드파티를 신뢰해야 함(혹은 서비스 이슈를 우회할 방법을 마련해야 함)
Result와 마찬가지로Async도 전염성(contagious)이 있음 ->CheckAddressExists가AsyncResult를 반환하므로ValidateOrder전체도AsyncResult를 반환해야 함
type ValidateOrder =
CheckProductCodeExists // dependency
-> CheckAddressExists // AsyncResult dependency
-> UnvalidatedOrder // input
-> AsyncResult<ValidatedOrder,ValidationError list> // output
type AsyncResult<TSuccess, TFailure> = Promise<Result<TSuccess, TFailure>>;
type CheckAddressExists = (
address: UnvalidatedAddress
) => AsyncResult<CheckedAddress, AddressValidationError>;
type ValidateOrder = (
checkProductCodeExists: CheckProductCodeExists
) => (
checkAddressExists: CheckAddressExists
) => (
unvalidatedOrder: UnvalidatedOrder
) => AsyncResult<ValidatedOrder, ValidationError[]>;
7.5.2 가격 산정 단계의 효과
PriceOrder의 의존성은GetProductPrice하나뿐. 제품 카탈로그가 로컬(예: 메모리 캐시)이라 가정 ->Async효과 없음. 오류도 반환하지 않는다고 가정 -> 문서화할 효과 없음- 그러나
PriceOrder단계 자체는 오류를 반환할 수 있음 -> 예: 상품이 잘못 책정되어AmountToBill이 지나치게 크거나(혹은 음수)인 경우. 드문 엣지 케이스지만 실제로 여러 번 큰 사고를 일으킨 유형의 오류이므로 발생 즉시 잡아야 함 PricingError오류 타입 추가
type PricingError = PricingError of string
type PriceOrder =
GetProductPrice // dependency
-> ValidatedOrder // input
-> Result<PricedOrder,PricingError> // output
type PricingError = { kind: "PricingError"; message: string };
type PriceOrder = (
getProductPrice: GetProductPrice
) => (
validatedOrder: ValidatedOrder
) => Result<PricedOrder, PricingError>;
7.5.3 주문 확인 단계의 효과
AcknowledgeOrder의 두 의존성:CreateOrderAcknowledgmentLetter,SendOrderAcknowledgmentCreateOrderAcknowledgmentLetter는 오류를 반환할 가능성이 낮고, 캐시된 템플릿을 사용하는 로컬 함수로 가정 -> 문서화할 효과 없음SendOrderAcknowledgment는 I/O를 수행 ->Async효과 필요. 오류는? 오류의 세부 사항은 신경 쓰지 않고, 오류가 나도 무시한 채 해피 패스(happy path)를 계속 진행하기로 결정 ->Async는 있지만Result는 없음
type SendOrderAcknowledgment =
OrderAcknowledgment -> Async<SendResult>
Async효과는 상위 함수까지 그대로 전파됨
type AcknowledgeOrder =
CreateOrderAcknowledgmentLetter // dependency
-> SendOrderAcknowledgment // Async dependency
-> PricedOrder // input
-> Async<OrderAcknowledgmentSent option> // Async output
type SendOrderAcknowledgment = (
acknowledgment: OrderAcknowledgment
) => Promise<SendResult>;
type AcknowledgeOrder = (
createLetter: CreateOrderAcknowledgmentLetter
) => (
sendAcknowledgment: SendOrderAcknowledgment
) => (
pricedOrder: PricedOrder
) => Promise<OrderAcknowledgmentSent | undefined>;
7.6 개별 단계로부터 작업 흐름 합성하기
- 모든 단계의 정의를 갖추었으니, 각 단계 구현이 준비되면 한 단계의 출력을 다음 단계의 입력에 연결(wire)해 전체 워크플로를 구성할 수 있어야 함
- 그러나 그렇게 간단하지 않음. 의존성을 제거하고 입력/출력만 나열해 비교하면 문제가 드러남
| 단계 | 입력 | 출력 | 의존성 | 효과 |
|---|---|---|---|---|
ValidateOrder |
UnvalidatedOrder |
ValidatedOrder |
CheckProductCodeExists, CheckAddressExists |
AsyncResult (Async + Result) |
PriceOrder |
ValidatedOrder |
PricedOrder |
GetProductPrice |
Result |
AcknowledgeOrder |
PricedOrder |
OrderAcknowledgmentSent option |
CreateOrderAcknowledgmentLetter, SendOrderAcknowledgment |
Async |
CreateEvents |
PricedOrder |
PlaceOrderEvent list |
없음 | 없음 |
type ValidateOrder =
UnvalidatedOrder // input
-> AsyncResult<ValidatedOrder,ValidationError list> // output
type PriceOrder =
ValidatedOrder // input
-> Result<PricedOrder,PricingError> // output
type AcknowledgeOrder =
PricedOrder // input
-> Async<OrderAcknowledgmentSent option> // output
type CreateEvents =
PricedOrder // input
-> PlaceOrderEvent list // output
PriceOrder단계의 입력은ValidatedOrder가 필요하지만,ValidateOrder의 출력은AsyncResult<ValidatedOrder,...>라서 타입이 전혀 맞지 않음- 마찬가지로
PriceOrder의 출력(Result<PricedOrder,...>)도AcknowledgeOrder의 입력(PricedOrder)으로 바로 쓸 수 없음 - 이런 함수들을 합성(compose)하려면 입력/출력 타입이 서로 호환되도록 조정(juggle) 하는 작업이 필요
- 이는 타입 주도 설계(type-driven design)에서 흔한 과제이며, 구체적인 해결 방법은 이후 구현 장들에서 다룸
type ValidateOrder = (
input: UnvalidatedOrder
) => AsyncResult<ValidatedOrder, ValidationError[]>;
type PriceOrder = (
input: ValidatedOrder
) => Result<PricedOrder, PricingError>;
type AcknowledgeOrder = (
input: PricedOrder
) => Promise<OrderAcknowledgmentSent | undefined>;
type CreateEvents = (input: PricedOrder) => PlaceOrderEvent[];
// ValidateOrder의 출력(AsyncResult<ValidatedOrder, ...>)과
// PriceOrder의 입력(ValidatedOrder)이 그대로 연결되지 않음
// -> 합성 과정에서 타입을 맞추는 별도 작업이 필요
7.7 의존을 디자인에 포함시켜야 하나요?
- 지금까지 다른 컨텍스트로의 호출(
CheckProductCodeExists,ValidateAddress등)을 의존성으로 취급하고, 각 하위 단계 설계에 명시적 파라미터로 추가함
type ValidateOrder =
CheckProductCodeExists // explicit dependency
-> CheckAddressExists // explicit dependency
-> UnvalidatedOrder // input
-> AsyncResult<ValidatedOrder,ValidationError list> // output
type PriceOrder =
GetProductPrice // explicit dependency
-> ValidatedOrder // input
-> Result<PricedOrder,PricingError> // output
- 반대 관점: 어떤 시스템과 협력해서 목표를 달성하는지는 굳이 알 필요 없이 숨겨야 한다는 주장도 가능
- 이 관점을 취하면 프로세스 정의는 입력/출력만으로 단순화됨
type ValidateOrder =
UnvalidatedOrder // input
-> AsyncResult<ValidatedOrder,ValidationError list> // output
type PriceOrder =
ValidatedOrder // input
-> Result<PricedOrder,PricingError> // output
- 어느 쪽이 더 나은가? -> 설계에 정답은 없지만 다음 가이드라인을 따름
- 공개 API(public API)로 노출되는 함수: 호출자에게 의존성 정보를 숨김
- 내부적으로 사용되는 함수: 의존성을 명시적으로 드러냄
- 최상위
PlaceOrder워크플로 함수의 의존성은 호출자가 알 필요 없으므로 노출하지 않음 -> 시그니처는 입력/출력만 표시
type PlaceOrderWorkflow =
PlaceOrder // input
-> AsyncResult<PlaceOrderEvent list,PlaceOrderError> // output
- 그러나 워크플로 내부의 각 단계는 원래 설계처럼 의존성을 명시적으로 드러내야 함
- 각 단계가 실제로 무엇을 필요로 하는지 문서화하는 데 도움이 되며, 의존성이 바뀌면 해당 단계의 함수 정의를 변경 -> 이는 구현 변경을 강제함
type PlaceOrderWorkflow = (
input: PlaceOrder
) => AsyncResult<PlaceOrderEvent[], PlaceOrderError>;
7.8 완전한 파이프라인
- 설계의 첫 단계를 완료 -> 지금까지의 내용을 정리
- 공개 API용 타입들은 보통
DomainApi.fs같은 하나의 파일에 모아둠
입력 타입:
// ----------------------
// Input data
// ----------------------
type UnvalidatedOrder = {
OrderId : string
CustomerInfo : UnvalidatedCustomer
ShippingAddress : UnvalidatedAddress
}
and UnvalidatedCustomer = {
Name : string
Email : string
}
and UnvalidatedAddress = ...
// ----------------------
// Input Command
// ----------------------
type Command<'data> = {
Data : 'data
Timestamp: DateTime
UserId: string
// etc
}
type PlaceOrderCommand = Command<UnvalidatedOrder>
출력 및 워크플로 정의:
// ----------------------
// Public API
// ----------------------
/// Success output of PlaceOrder workflow
type OrderPlaced = ...
type BillableOrderPlaced = ...
type OrderAcknowledgmentSent = ...
type PlaceOrderEvent =
| OrderPlaced of OrderPlaced
| BillableOrderPlaced of BillableOrderPlaced
| AcknowledgmentSent of OrderAcknowledgmentSent
/// Failure output of PlaceOrder workflow
type PlaceOrderError = ...
type PlaceOrderWorkflow =
PlaceOrderCommand // input command
-> AsyncResult<PlaceOrderEvent list,PlaceOrderError> // output events
TypeScript 버전(domainApi.ts):
// ----------------------
// Input data
// ----------------------
interface UnvalidatedOrder {
orderId: string;
customerInfo: UnvalidatedCustomer;
shippingAddress: UnvalidatedAddress;
}
interface UnvalidatedCustomer {
name: string;
email: string;
}
interface UnvalidatedAddress {
// ...
}
// ----------------------
// Input Command
// ----------------------
interface Command<TData> {
data: TData;
timestamp: Date;
userId: string;
// etc
}
type PlaceOrderCommand = Command<UnvalidatedOrder>;
// ----------------------
// Public API
// ----------------------
type OrderPlaced = PricedOrder;
interface BillableOrderPlaced {
// ...
}
interface OrderAcknowledgmentSent {
// ...
}
type PlaceOrderEvent =
| { type: "OrderPlaced"; event: OrderPlaced }
| { type: "BillableOrderPlaced"; event: BillableOrderPlaced }
| { type: "AcknowledgmentSent"; event: OrderAcknowledgmentSent };
interface PlaceOrderError {
// ...
}
type PlaceOrderWorkflow = (
command: PlaceOrderCommand
) => AsyncResult<PlaceOrderEvent[], PlaceOrderError>;
7.8.1 내부 단계
- 내부 단계에서 사용하는 타입들은 별도의 구현 파일(예:
PlaceOrderWorkflow.fs)에 둠 -> 이후 이 파일 하단에 구현을 추가
주문 생명 주기(order life cycle):
// bring in the types from the domain API module
open DomainApi
// ----------------------
// Order life cycle
// ----------------------
// validated state
type ValidatedOrderLine = ...
type ValidatedOrder = {
OrderId : OrderId
CustomerInfo : CustomerInfo
ShippingAddress : Address
BillingAddress : Address
OrderLines : ValidatedOrderLine list
}
and OrderId = Undefined
and CustomerInfo = ...
and Address = ...
// priced state
type PricedOrderLine = ...
type PricedOrder = ...
// all states combined
type Order =
| Unvalidated of UnvalidatedOrder
| Validated of ValidatedOrder
| Priced of PricedOrder
// etc
내부 단계 정의:
// ----------------------
// Definitions of Internal Steps
// ----------------------
// ----- Validate order -----
// services used by ValidateOrder
type CheckProductCodeExists =
ProductCode -> bool
type AddressValidationError = ...
type CheckedAddress = ...
type CheckAddressExists =
UnvalidatedAddress
-> AsyncResult<CheckedAddress,AddressValidationError>
type ValidateOrder =
CheckProductCodeExists // dependency
-> CheckAddressExists // dependency
-> UnvalidatedOrder // input
-> AsyncResult<ValidatedOrder,ValidationError list> // output
and ValidationError = ...
// ----- Price order -----
// services used by PriceOrder
type GetProductPrice =
ProductCode -> Price
type PricingError = ...
type PriceOrder =
GetProductPrice // dependency
-> ValidatedOrder // input
-> Result<PricedOrder,PricingError> // output
// etc
- 이렇게 모든 타입이 한곳에 모여 이후 구현을 안내(guide)하는 역할을 함
TypeScript 버전(placeOrderWorkflow.ts):
// domainApi 모듈에서 타입 가져오기
import type { UnvalidatedOrder } from "./domainApi";
// ----------------------
// Order life cycle
// ----------------------
type OrderId = string; // 실제로는 브랜드 타입 등으로 더 엄격하게 표현 가능
interface CustomerInfo {
// ...
}
interface Address {
// ...
}
interface ValidatedOrderLine {
// ...
}
interface ValidatedOrder {
orderId: OrderId;
customerInfo: CustomerInfo;
shippingAddress: Address;
billingAddress: Address;
orderLines: ValidatedOrderLine[];
}
interface PricedOrderLine {
// ...
}
interface PricedOrder {
orderId: OrderId;
customerInfo: CustomerInfo;
shippingAddress: Address;
billingAddress: Address;
orderLines: PricedOrderLine[];
amountToBill: BillingAmount;
}
type Order =
| { type: "Unvalidated"; order: UnvalidatedOrder }
| { type: "Validated"; order: ValidatedOrder }
| { type: "Priced"; order: PricedOrder };
// etc
// ----------------------
// Definitions of Internal Steps
// ----------------------
// ----- Validate order -----
type CheckProductCodeExists = (productCode: ProductCode) => boolean;
interface AddressValidationError {
// ...
}
interface CheckedAddress {
// ...
}
type CheckAddressExists = (
address: UnvalidatedAddress
) => AsyncResult<CheckedAddress, AddressValidationError>;
interface ValidationError {
// ...
}
type ValidateOrder = (
checkProductCodeExists: CheckProductCodeExists
) => (
checkAddressExists: CheckAddressExists
) => (
unvalidatedOrder: UnvalidatedOrder
) => AsyncResult<ValidatedOrder, ValidationError[]>;
// ----- Price order -----
type GetProductPrice = (productCode: ProductCode) => Price;
interface PricingError {
// ...
}
type PriceOrder = (
getProductPrice: GetProductPrice
) => (validatedOrder: ValidatedOrder) => Result<PricedOrder, PricingError>;
// etc
7.9 오래 수행하는 작업 흐름
- 지금까지는 원격 시스템 호출이 있어도 파이프라인이 초 단위의 짧은 시간 안에 완료된다고 가정
- 만약 외부 서비스가 훨씬 오래 걸린다면? 예: 검증을 사람이 수행해서 하루 종일 걸린다면? 가격 책정을 다른 부서가 담당해서 오래 걸린다면? -> 설계에 어떤 영향을 주는가?
- 이 경우 원격 서비스를 호출하기 전에 상태를 저장소(storage) 에 저장하고, 서비스가 끝났다는 메시지를 기다렸다가, 상태를 다시 불러와(reload) 다음 단계로 진행해야 함
- 단계마다 상태를 영속화(persist)해야 하므로, 일반적인 비동기 호출보다 훨씬 무거운 방식
flowchart LR
Start(["Start"]) --> S1["Step 1"]
S1 -- save state --> ST1[("Storage")]
S1 -- call --> RS1[["Remote Service"]]
RS1 -- wait for response --> S2["Step 2"]
S2 -- restore state --> ST1
S2 -- call --> RS2[["Remote Service"]]
RS2 -- wait for response --> S3["Step 3"]
S3 -- restore state --> ST1
S3 --> Finish(["Finish"])
- 이렇게 하면 원래 워크플로를 더 작고 독립적인 조각(chunk)들로 나눈 셈 -> 각 조각은 이벤트에 의해 트리거됨
- 하나의 워크플로라기보다는 일련의 분리된 미니 워크플로(mini-workflow) 들의 모음으로 볼 수도 있음
- 이런 상황에서 상태 기계 모델이 시스템을 사고하는 유용한 프레임워크가 됨: 각 단계 전에 주문은 저장소에서 (그 상태 중 하나로 영속화된 채) 로드되고, 미니 워크플로가 원래 상태에서 새 상태로 전이시킨 뒤 새 상태를 다시 저장소에 저장
flowchart TB
St[("Storage")]
EH["Event Handler"]
VO["ValidateOrder"]
PO["PriceOrder"]
ETC["etc"]
AV[["Address Validation"]]
PS[["Pricing Service"]]
St -. restore state .-> EH
EH -. save state .-> St
EH --> VO
EH --> PO
EH --> ETC
VO -- call --> AV
AV -- wait for response --> EH
PO -- call --> PS
PS -- wait for response --> EH
- 이런 오래 수행하는 워크플로를 사가(Saga) 라고 부르기도 함
- 느린 사람(사람이 개입하는 프로세스)이 관련될 때 흔히 나타나며, 워크플로를 이벤트로 연결된 분리된(decoupled) 독립 조각들로 나누고 싶을 때도 사용(예: 마이크로서비스)
- 이 책의 예제에서는 워크플로가 매우 단순함. 이벤트/상태 수가 늘고 전이가 복잡해지면 프로세스 매니저(Process Manager) 라는 별도 컴포넌트가 필요할 수 있음
- 들어오는 메시지를 처리
- 현재 상태에 따라 어떤 동작을 취할지 결정
- 적절한 워크플로를 트리거
- 원서 각주에 사가(Saga)에 대한 외부 참고 자료가 소개되어 있음(vasters.com 아카이브 글)
7.10 마무리
- 이번 장에서 타입만으로 워크플로를 모델링하는 방법을 학습
- 워크플로 입력을 문서화하는 것으로 시작 -> 커맨드(command)를 모델링하는 방법
- 상태 기계를 이용해 문서와 기타 생명 주기를 가진 엔티티를 모델링하는 방법
- 상태에 대한 새로운 이해를 바탕으로 다시 워크플로로 돌아가, 각 하위 단계를 입력/출력 상태를 표현하는 타입으로 모델링
- 각 단계의 의존성(dependency)과 효과(effect)도 함께 문서화
- 그 과정에서 수백 개처럼 보이는 타입(실제로는 약 30개)을 만들었음 -> 정말 필요했는가?
- 목적은 실행 가능한 문서(executable documentation) 를 만드는 것 -> 코드 자체가 도메인을 전달(communicate)하게 함
- 이런 타입들을 만들지 않았다면, 검증된 주문과 가격 책정된 주문의 차이, 위젯 코드와 일반 문자열의 차이 등을 여전히 문서로 남겨야 했을 것 -> 그럴 바에 코드 자체가 문서가 되게 함
- 물론 균형이 필요함. 저자는 모든 것을 이런 식으로 문서화하면 어떤 모습인지 보여주기 위해 의도적으로 극단으로 감. 상황에 과하다고 느껴지면 필요에 맞게 줄여도 됨
- 항상 도메인에 도움이 되고 당면 과제에 가장 유용한 방식을 택할 것
핵심 개념 요약
| 핵심 개념 | 한 줄 요약 |
|---|---|
| 파이프라인 / 변환 지향 프로그래밍 | 워크플로를 상태가 없는 여러 변환 단계로 쪼개어 연결 |
| 커맨드(Command) | 워크플로의 실제 입력. 도메인 데이터 + 메타데이터(UserId, Timestamp) |
제네릭 Command<'data> |
공통 필드를 공유하는 함수형 방식(상속 대신 제네릭) |
| 상태별 타입 | 플래그 대신 상태마다 별도 타입 + 최상위 선택 타입으로 결합 |
| 상태 기계 | 상태 + 전이(커맨드로 트리거)로 도메인을 모델링하는 범용 도구 |
| 의존성을 함수로 표현 | 무거운 인터페이스 대신 필요한 기능만 담은 함수 타입 전달 |
| Result / AsyncResult | 오류·비동기 효과는 타입 시그니처에 전염(contaminate)되며 상위로 전파됨 |
| 이벤트 리스트 반환 | 전용 레코드 대신 선택 타입 리스트로 반환해 확장성 확보 |
| 공개 API vs 내부 단계 | 공개 API는 의존성 은닉, 내부 단계는 의존성 명시 |
| 사가(Saga) | 오래 걸리는 워크플로를 상태 영속화 + 이벤트 기반 미니 워크플로로 분해 |
7.10.1 다음 장 안내
- 지난 네 개 장 동안 모델링만 해왔고, 이제 실제 구현(implementation) 을 시작할 시점
- 이 책에서는 요구사항 수집, 모델링, 코딩을 별도 섹션으로 나누었지만, 이는 선형적인 “폭포수(waterfall)” 개발 모델을 권장하는 것이 아님
- 실제 프로젝트에서는 요구사항 수집, 모델링, 프로토타이핑을 계속 섞어가며 진행해 고객/도메인 전문가로부터 최대한 빨리 피드백을 얻어야 함
- 타입으로 모델링하는 것의 핵심은, 도메인 전문가가 모델을 직접 읽을 수 있기 때문에 요구사항 -> 모델링 -> 다시 요구사항으로 며칠이 아니라 몇 분 만에 왕복할 수 있다는 것
- 다음 장부터 구현 장 시작 -> 첫 단계로 함수가 어떻게 동작하는지, 함수로 애플리케이션을 어떻게 구성하는지 이해하는 것부터 시작
파트 3 모델 구현하기
- 파트 2에서 요구사항을 캡처하고 타입으로 모델링했던 주문 처리(order-taking) 워크플로우를 이번 파트에서 실제로 구현합니다.
- 구현 과정에서 다음과 같은 함수형 프로그래밍(FP) 기법을 배웁니다.
- 합성(composition)
- 부분 적용(partial application)
- 무섭게 들리는 이름의 “모나드(monad)”
챕터 8 함수 이해하기
- 지금까지 주문 처리 워크플로우의 요구사항을 캡처하고 타입으로 모델링했으며, 다음 과제는 이 설계를 FP 방식으로 구현하는 것임.
- 구현에 들어가기 전에 먼저 짚고 넘어갈 것
- 함수형 프로그래밍이 무엇인지 정확히 이해하기
- 구현에 필요한 도구와 전략 파악하기
- 이 장을 마치면 도메인 주도 설계뿐 아니라 어떤 프로그래밍에도 유용한 FP 핵심 개념을 갖추게 됨.
- 이 책이 다루는 범위(스코프)
- 함수란 무엇인지, 그리고 FP의 핵심 설계 원칙인 함수 합성을 어떻게 하는지에 집중함.
- 모나드(monad), 펑터(functor) 같은 개념은 지금 다루지 않으며, 필요성이 자연스럽게 생기는 시점에 다룰 예정임.
- F# 문법 전체를 다루지는 않으므로, 이해되지 않는 구문이 나오면 “F# cheat sheet”나 “F# syntax”로 검색해 참고할 것을 권장함.
8.1 함수, 함수 어디에나 함수
- 함수형 프로그래밍이 객체지향 프로그래밍(OOP)과 왜 이렇게 다른지부터 살펴봄.
- FP에 대한 정의는 다양하지만, 이 책은 다음과 같이 아주 단순하게 정의함.
- ==함수형 프로그래밍은 함수가 정말로 중요한 것처럼 프로그래밍하는 것임.==
- 현대 언어 대부분은 함수를 일급 객체(first-class object)로 다루지만, 함수(또는 람다)를 가끔 사용한다고 해서 FP를 적용하고 있다고 볼 수 없음.
- FP 패러다임의 핵심은 함수를 어디에나, 모든 것에 사용한다는 점임.
- OOP와 FP의 접근 방식을 상황별로 비교하면 다음과 같음.
| 상황 | OOP 접근 | FP 접근 |
|---|---|---|
| 큰 프로그램을 여러 작은 조각으로 구성 | 조각을 클래스와 객체로 만듦 | 조각을 함수로 만듦 |
| 프로그램의 특정 부분을 파라미터화하거나 컴포넌트 간 결합도를 낮추고 싶음 | 인터페이스와 의존성 주입(DI)을 사용 | 함수로 파라미터화 |
| “중복 배제(DRY)” 원칙을 지키며 컴포넌트 간 코드를 재사용하고 싶음 | 상속이나 데코레이터(Decorator) 패턴 같은 기법 사용 | 재사용 코드를 함수에 담고, 함수 합성(composition)으로 이어 붙임 |
- 따라서 FP는 단순한 문법적 취향 차이가 아니라, 프로그래밍을 바라보는 완전히 다른 사고방식임.
- FP가 처음이라면 “초보자의 마음(beginner’s mind)“으로 접근하는 것이 좋음.
- 즉 다른 패러다임의 질문(예: “컬렉션을 어떻게 반복하나요?”, “Strategy 패턴을 어떻게 구현하나요?”)을 그대로 던지기보다, 동일한 근본 문제를 FP식으로 다시 질문하는 편이 나음.
| 기존 패러다임 질문 | FP식으로 바꾼 질문 |
|---|---|
| 컬렉션을 어떻게 반복(loop)하나요? | 컬렉션의 각 원소에 대해 특정 동작을 수행하려면 어떻게 해야 하나요? |
| Strategy 패턴을 어떻게 구현하나요? | 동작(behavior)을 어떻게 파라미터화할 수 있나요? |
- 프로그래머가 마주하는 문제 자체는 OOP나 FP나 동일하지만, FP가 사용하는 해법은 OOP와는 완전히 다름.
8.2 함수가 주인공
- FP 패러다임에서 함수는 그 자체로 독립적인 **사물(thing)**임.
- 함수가 사물이라면 다음이 모두 가능함.
- 다른 함수의 입력으로 전달될 수 있음
- 함수의 출력으로 반환될 수 있음
- 함수의 동작을 제어하는 파라미터로 전달될 수 있음
flowchart LR
A1["함수<br/>apple → banana"] --> A2[Function] --> A3[Output]
flowchart LR
B1[Input] --> B2[Function] --> B3["함수<br/>apple → banana"]
flowchart TB
C1[Input] --> C2[Function] --> C3[Output]
C4["함수<br/>apple → banana"] -.파라미터로 전달.-> C2
- 함수를 사물로 취급한다는 이 단순한 원리만으로도 복잡한 시스템을 빠르게 쌓아 올릴 수 있음. 처음에는 낯설게 느껴질 수 있음.
용어 정리: 고차 함수(Higher-Order Function, HOF) 다른 함수를 입력으로 받거나 출력으로 내보내거나 파라미터로 받는 함수를 고차 함수, 줄여서 HOF라고 부름.
8.2.1 주인공인 함수
- F#에서 “함수가 사물”이라는 개념이 어떻게 동작하는지 4가지 함수 정의로 살펴봄.
let plus3 x = x + 3 // plus3 : x:int -> int
let times2 x = x * 2 // times2 : x:int -> int
let square = (fun x -> x * x) // square : x:int -> int
let addThree = plus3 // addThree : (int -> int)
const plus3 = (x: number): number => x + 3; // plus3: (x: number) => number
const times2 = (x: number): number => x * 2; // times2: (x: number) => number
const square = (x: number): number => x * x; // square: (x: number) => number
const addThree = plus3; // addThree: (x: number) => number
- 처음 두 정의는 익숙한 형태임.
- 세 번째 정의는
let키워드로 익명 함수(람다 표현식)에 이름square를 붙인 것임. - 네 번째 정의는 이미 정의된 함수(
plus3)에 새 이름addThree를 붙인 것임. - 네 함수 모두
int -> int타입으로, int를 입력받아 새 int를 출력함. - 함수가 사물이므로 리스트에 담을 수도 있음.
// listOfFunctions : (int -> int) list
let listOfFunctions =
[addThree; times2; square]
// listOfFunctions: Array<(x: number) => number>
const listOfFunctions: Array<(x: number) => number> = [addThree, times2, square];
참고: F#의 리스트 리터럴은 대괄호(
[ ])로 구분하며, 원소 구분에는 쉼표가 아니라 **세미콜론(;)**을 사용함.
- 리스트를 순회하며 각 함수를 평가할 수 있음.
for fn in listOfFunctions do
let result = fn 100 // call the function
printfn "If 100 is the input, the output is %i" result
// 결과 =>
// If 100 is the input, the output is 103
// If 100 is the input, the output is 200
// If 100 is the input, the output is 10000
for (const fn of listOfFunctions) {
const result = fn(100);
console.log(`If 100 is the input, the output is ${result}`);
}
// 결과 =>
// If 100 is the input, the output is 103
// If 100 is the input, the output is 200
// If 100 is the input, the output is 10000
let키워드는 함수 정의 전용이 아니라, 값에 이름을 붙이는 데도 일반적으로 사용됨.
// myString : string
let myString = "hello"
const myString: string = "hello";
- 함수 정의와 단순 값 할당에 동일한 키워드(
let)가 쓰이는 것은 우연이 아님. 다음 두 정의를 비교함.
// square : x:int -> int
let square x = x * x
// square : x:int -> int
let square = (fun x -> x * x)
- 첫 번째는 함수 정의 문법, 두 번째는 익명 함수에 이름을 할당하는 문법임. 질문: 여기서
let은 단순 값을 정의하는 것인지, 함수를 정의하는 것인지?- 답은 둘 다임. 함수도 하나의 사물이므로 이름을 붙일 수 있으며, 두 정의는 본질적으로 동일하고 상호 교환 가능함.
- TypeScript에서도
function square(x) {...}형태의 함수 선언과const square = (x) => x * x형태의 함수 표현식 할당이 결과적으로 동일한 함수를 만든다는 점에서 같은 원리가 적용됨.
8.2.2 입력으로서 함수
- “함수는 사물”이라는 말은 함수를 입력과 출력에 모두 쓸 수 있다는 뜻이므로, 실제로 어떻게 쓰이는지 살펴봄.
- 먼저 함수를 입력 파라미터로 사용하는 예시임.
evalWith5ThenAdd2는 함수fn을 받아 5로 호출한 뒤 결과에 2를 더함.
let evalWith5ThenAdd2 fn =
fn(5) + 2
// evalWith5ThenAdd2 : fn:(int -> int) -> int
function evalWith5ThenAdd2(fn: (x: number) => number): number {
return fn(5) + 2;
}
// evalWith5ThenAdd2: (fn: (x: number) => number) => number
- 타입 시그니처를 보면 컴파일러가
fn이(int -> int)함수여야 함을 자동으로 추론했음을 알 수 있음. add1이라는(int -> int)함수를 정의해 전달함.
let add1 x = x + 1 // an int -> int function
evalWith5ThenAdd2 add1 // fn(5) + 2 => add1(5) + 2
// 결과는 8
const add1 = (x: number): number => x + 1;
evalWith5ThenAdd2(add1); // 결과는 8
- 같은 시그니처(
int -> int)를 가진 다른 함수, 예를 들어square도 파라미터로 넘길 수 있음.
let square x = x * x // an int -> int function
evalWith5ThenAdd2 square // fn(5) + 2 => square(5) + 2
// 결과는 27
const square2 = (x: number): number => x * x;
evalWith5ThenAdd2(square2); // 결과는 27
8.2.3 출력으로서 함수
- 이번에는 함수를 출력(반환값)으로 사용하는 경우임. 왜 함수를 반환하고 싶은가?
- 가장 중요한 이유 중 하나는 특정 파라미터를 함수 안에 미리 구워 넣을(bake in) 수 있기 때문임.
- 다음처럼 정수를 더하는 세 함수가 있다고 가정함.
let add1 x = x + 1
let add2 x = x + 2
let add3 x = x + 3
const add1 = (x: number): number => x + 1;
const add2 = (x: number): number => x + 2;
const add3 = (x: number): number => x + 3;
- 이 코드에는 명백한 중복이 있음. 해법은 더할 숫자를 “구워 넣은”
add함수를 반환하는 **어더 제너레이터(adder generator)**를 만드는 것임.
let adderGenerator numberToAdd =
// 람다를 반환
fun x -> numberToAdd + x
// val adderGenerator :
// int -> (int -> int)
function adderGenerator(numberToAdd: number): (x: number) => number {
return (x: number) => numberToAdd + x;
}
// adderGenerator: (numberToAdd: number) => (x: number) => number
- 타입 시그니처를 보면 int를 입력받아
(int -> int)함수를 출력으로 내보낸다는 것이 명확히 드러남. - 익명 함수 대신 이름이 있는 내부(nested) 함수를 반환하는 방식으로도 동일하게 구현할 수 있음.
let adderGenerator numberToAdd =
// 중첩된 내부 함수 정의
let innerFn x =
numberToAdd + x
// 내부 함수를 반환
innerFn
function adderGenerator(numberToAdd: number): (x: number) => number {
const innerFn = (x: number): number => numberToAdd + x;
return innerFn;
}
- 앞서
square예시에서 본 것처럼, 두 구현은 결과적으로 동일함. 어느 쪽이 더 마음에 드는지는 취향의 문제임. - 실전에서
adderGenerator를 사용하는 모습임.
// 테스트
let add1 = adderGenerator 1
add1 2 // 결과 => 3
let add100 = adderGenerator 100
add100 2 // 결과 => 102
const add1b = adderGenerator(1);
add1b(2); // 결과 => 3
const add100 = adderGenerator(100);
add100(2); // 결과 => 102
8.2.4 커링
- 함수를 반환하는 이 기법을 활용하면, 다중 파라미터 함수를 일련의 단일 파라미터 함수로 변환할 수 있음. 이 방법을 **커링(currying)**이라고 부름.
- 예를 들어 파라미터가 2개인
add함수는 다음과 같음.
// int -> int -> int
let add x y = x + y
- 앞서 본 방식처럼 새 함수를 반환하도록 바꾸면 파라미터가 1개인 함수로 변환할 수 있음.
// int -> (int -> int)
let adderGenerator x = fun y -> x + y
- F#에서는 이 작업을 명시적으로 할 필요가 없음. 모든 함수는 이미 커링된 함수이기 때문임.
- 즉 시그니처가
'a -> 'b -> 'c인 2-파라미터 함수는,'a를 받아('b -> 'c)함수를 돌려주는 1-파라미터 함수로도 해석할 수 있으며, 파라미터가 더 많은 함수에도 동일한 원리가 적용됨.
- 즉 시그니처가
- TypeScript(및 JavaScript)는 F#처럼 함수가 자동으로 커링되지 않음. 커링된 형태로 쓰고 싶다면 중첩된 화살표 함수를 명시적으로 작성해야 함.
// add: (x: number) => (y: number) => number
const add = (x: number) => (y: number): number => x + y;
add(3)(4); // 7
8.2.5 부분 적용
- 모든 함수가 커링되어 있다면, 다중 파라미터 함수에 인자를 하나만 넘겨도 나머지 파라미터가 그대로 남아 있는 새 함수를 돌려받을 수 있음.
- 예를 들어 파라미터가 2개인
sayGreeting함수를 봄.
// sayGreeting: string -> string -> unit
let sayGreeting greeting name =
printfn "%s %s" greeting name
// sayGreeting: (greeting: string) => (name: string) => void
const sayGreeting = (greeting: string) => (name: string): void => {
console.log(`${greeting} ${name}`);
};
- 파라미터 하나만 넘겨서 인사말이 구워 넣어진 새 함수들을 만들 수 있음.
// sayHello: string -> unit
let sayHello = sayGreeting "Hello"
// sayGoodbye: string -> unit
let sayGoodbye = sayGreeting "Goodbye"
const sayHello = sayGreeting("Hello"); // (name: string) => void
const sayGoodbye = sayGreeting("Goodbye"); // (name: string) => void
- 이제 남은 파라미터(이름)만 채우면 최종 결과를 얻음.
sayHello "Alex"
// 출력: "Hello Alex"
sayGoodbye "Alex"
// 출력: "Goodbye Alex"
sayHello("Alex"); // 출력: "Hello Alex"
sayGoodbye("Alex"); // 출력: "Goodbye Alex"
- 이렇게 파라미터를 “구워 넣는” 방식을 **부분 적용(partial application)**이라 하며, 매우 중요한 함수형 패턴임.
- 이후 챕터 9(파이프라인 구성)에서 의존성 주입(dependency injection)을 구현할 때 이 기법이 사용됨.
8.3 완전 함수
- 수학적 함수는 가능한 모든 입력값을 하나의 출력값에 대응시킴.
- FP에서도 함수를 이와 같은 방식으로 설계하려고 함. 즉 모든 입력이 대응하는 출력을 갖도록 만듦. 이런 함수를 ==완전 함수(total function)==라고 부름.
- 왜 이렇게까지 신경 쓰는가? 모든 부작용(effect)을 타입 시그니처에 최대한 명시적으로 드러내고 싶기 때문임.
- 예시로, 입력을 12로 나눈 정수 나눗셈 결과를 돌려주는 다소 억지스러운 함수
twelveDividedBy를 봄. 케이스 테이블로 의사 코드를 작성하면 다음과 같음.
let twelveDividedBy n =
match n with
| 6 -> 2
| 5 -> 2
| 4 -> 3
| 3 -> 4
| 2 -> 6
| 1 -> 12
| 0 -> ???
- 입력이 0일 때 답은 무엇이어야 하는가? 12를 0으로 나누는 것은 정의되지 않음.
방법 1. 예외를 던진다
- 모든 입력에 대응하는 출력이 있어야 한다는 원칙을 무시한다면, 0인 경우 그냥 예외를 던질 수 있음.
let twelveDividedBy n =
match n with
| 6 -> 2
// ...
| 0 -> failwith "Can't divide by zero"
function twelveDividedBy(n: number): number {
switch (n) {
case 6: return 2;
case 5: return 2;
case 4: return 3;
case 3: return 4;
case 2: return 6;
case 1: return 12;
case 0: throw new Error("Can't divide by zero");
default: throw new Error("Invalid input");
}
}
- 이 함수의 시그니처를 보면
twelveDividedBy : int -> int임. 이 시그니처는 int를 넣으면 항상 int를 돌려받는다고 암시하지만, 이것은 거짓말임. 항상 int를 돌려받는 것이 아니라 때로는 예외가 발생하는데, 그 사실이 타입 시그니처에는 전혀 드러나지 않음.
방법 2. 입력을 제한한다
- 시그니처가 거짓말을 하지 않게 하려면, 함수의 모든 입력이 유효한 출력을 갖도록 만들면 됨.
- 한 가지 기법은 불법적인 값을 애초에 제거하도록 입력을 제한하는 것임. 이 예시에서는
NonZeroInteger라는 제약된 타입을 만들어 전달할 수 있음. 0은 입력 후보 집합에 아예 포함되지 않으므로 처리할 필요가 없음.
type NonZeroInteger =
// 0이 아닌 int로 제약됨
// 스마트 생성자 등을 추가
private NonZeroInteger of int
/// 제한된 입력을 사용
let twelveDividedBy (NonZeroInteger n) =
match n with
| 6 -> 2
// ...
// 0은 입력에 포함될 수 없으므로
// 처리할 필요가 없음
// TypeScript에는 F#과 같은 명목적(nominal) 타입이 없으므로
// 브랜드(brand) 기법으로 비슷하게 흉내 낼 수 있음.
type NonZeroInteger = number & { readonly __brand: "NonZeroInteger" };
function makeNonZeroInteger(n: number): NonZeroInteger | undefined {
return n !== 0 ? (n as NonZeroInteger) : undefined; // 스마트 생성자 역할
}
function twelveDividedBy(n: NonZeroInteger): number {
switch (n) {
case 6: return 2;
// ...
default: throw new Error("Unhandled case");
}
}
- 이 새 버전의 시그니처는
twelveDividedBy : NonZeroInteger -> int로, 이전보다 훨씬 나음. 문서를 읽거나 소스 코드를 들여다보지 않아도 입력에 대한 요구사항이 한눈에 드러남. 이 함수는 거짓말하지 않으며, 모든 것이 명시적임.
방법 3. 출력을 확장한다
- 다른 기법은 0을 입력으로 그대로 받아들이되, 출력을 유효한 int와 정의되지 않음(undefined) 사이의 선택지로 확장하는 것임. “무언가”와 “아무것도 없음” 사이의 선택을 표현하기 위해
Option타입을 사용함.
/// 확장된 출력을 사용
let twelveDividedBy n =
match n with
| 6 -> Some 2 // 유효
| 5 -> Some 2 // 유효
| 4 -> Some 3 // 유효
// ...
| 0 -> None // 정의되지 않음
function twelveDividedBy(n: number): number | undefined {
switch (n) {
case 6: return 2;
case 5: return 2;
case 4: return 3;
case 3: return 4;
case 2: return 6;
case 1: return 12;
case 0: return undefined; // 정의되지 않음
default: return undefined;
}
}
- 새 버전의 시그니처는
twelveDividedBy : int -> int option임. 이는 “int를 주면 입력이 허용 가능한 경우에 한해 int를 돌려줄 수도 있다”는 의미이며, 시그니처가 명시적이고 오해의 소지가 없음.
세 접근 방식 비교
| 접근 방식 | 시그니처 | 특징 |
|---|---|---|
| 예외 던지기 | int -> int |
실제로는 예외가 발생할 수 있음에도 시그니처가 이를 숨김 (거짓말하는 시그니처) |
| 입력 제한 | NonZeroInteger -> int |
0을 애초에 입력 후보에서 제외해, 요구사항이 시그니처에 그대로 드러남 |
| 출력 확장 | int -> int option |
모든 int를 입력으로 허용하되, 값이 없을 가능성을 출력 타입에 명시 |
- 이렇게 억지스러운 예시에서도 완전 함수(total function)의 이점을 확인할 수 있음. 두 변형 모두 함수 시그니처만으로 가능한 모든 입력과 출력을 명시적으로 드러냄.
- 이후 오류 처리를 다루는 챕터에서 함수 시그니처로 가능한 모든 출력을 문서화하는 실전 사례를 더 살펴볼 예정임.
8.4 함수 합성
- 앞서 타입의 맥락에서 “합성(composition)“을 다룸. 즉 다른 타입을 조합해 새 타입을 만드는 것이었음.
- 이제 함수 합성을 다룸. 첫 번째 함수의 출력을 두 번째 함수의 입력에 연결해서 함수를 결합하는 것임.
- 예를 들어 두 함수가 있다고 가정함. 첫 번째는 apple을 입력받아 banana를 출력하고, 두 번째는 banana를 입력받아 cherries를 출력함. 첫 번째의 출력 타입이 두 번째의 입력 타입과 같으므로 둘을 합성할 수 있음.
flowchart LR
A1["Function<br/>apple → banana"] --> A2["Function<br/>banana → cherries"]
- 합성하고 나면 새로운 함수 하나가 만들어짐.
flowchart LR
B1["새로운 합성 함수<br/>apple → cherries"]
- 이런 합성에서 중요한 부분은 ==정보 은닉(information hiding)==임. 이 새 함수가 더 작은 함수들로 구성되었다는 사실도, 그 작은 함수들이 무엇을 다뤘는지도 겉에서는 알 수 없음. banana는 어디로 갔는지? 합성된 최종 함수의 사용자는 애초에 banana라는 것이 존재했는지조차 알지 못함. 이렇게 정보가 성공적으로 숨겨짐.
8.4.1 TypeScript의 함수 합성
- F#에서 함수 합성은 어떻게 동작하는지 살펴봄.
- F#에서는 첫 번째 함수의 출력 타입이 두 번째 함수의 입력 타입과 같기만 하면 어떤 두 함수든 이어 붙일 수 있음. 이는 일반적으로 **파이핑(piping)**이라는 방식으로 이루어짐.
- F#의 파이핑은 유닉스(Unix)의 파이프와 매우 비슷함. 값 하나로 시작해서 첫 번째 함수에 흘려 넣고, 그 출력을 다음 함수에 흘려 넣는 식으로 이어가며, 마지막 함수의 출력이 파이프라인 전체의 출력이 됨.
- F#의 파이프 연산자는
|>임. 첫 예시를 파이프로 표현하면 다음과 같음.
let add1 x = x + 1 // an int -> int function
let square x = x * x // an int -> int function
let add1ThenSquare x =
x |> add1 |> square
// 테스트
add1ThenSquare 5 // 결과는 36
const add1 = (x: number): number => x + 1;
const square = (x: number): number => x * x;
// TypeScript에는 언어 차원의 파이프 연산자가 없으므로
// 아래와 같은 pipe 헬퍼로 동일한 흐름을 표현할 수 있음.
const pipe = <T>(value: T, ...fns: Array<(arg: T) => T>): T =>
fns.reduce((acc, fn) => fn(acc), value);
const add1ThenSquare = (x: number): number => pipe(x, add1, square);
// 테스트
add1ThenSquare(5); // 결과는 36
- 파라미터
x가add1ThenSquare에 정의되어 있고, 구현부에서 이 파라미터가 파이프라인의 첫 함수(add1)로 흘러 들어가면서 데이터 흐름이 시작됨.
flowchart LR
X[x] --> Add1[add1] --> Square[square] --> R[result]
- 다른 예시도 봄. 첫 함수는
int -> bool, 두 번째 함수는bool -> string이며, 합쳐진 함수는int -> string임.
let isEven x =
(x % 2) = 0 // an int -> bool function
let printBool x =
sprintf "value is %b" x // a bool -> string function
let isEvenThenPrint x =
x |> isEven |> printBool
// 테스트
isEvenThenPrint 2 // 결과는 "value is true"
const isEven = (x: number): boolean => x % 2 === 0;
const printBool = (x: boolean): string => `value is ${x}`;
const isEvenThenPrint = (x: number): string =>
printBool(isEven(x));
// 테스트
isEvenThenPrint(2); // 결과는 "value is true"
참고: 위 예시처럼 파이프 단계마다 입력 타입과 출력 타입이 달라지는 경우, TypeScript의 범용
pipe헬퍼는 제네릭이 하나로 고정되어 있어 그대로 쓰기 어려움. 이럴 때는 단계별 타입을 각각 받는 오버로드된pipe를 만들거나, 위 예시처럼 함수 호출을 직접 중첩해서 표현하는 편이 간단함.
8.4.2 전체 애플리케이션을 함수로 구성하기
- 이 합성의 원리는 애플리케이션 전체를 구성하는 데도 그대로 적용할 수 있음.
- 예를 들어 애플리케이션 최하단에는 기본적인 함수 하나가 있음.
flowchart LR
Low["Low-level operation"]
- 이런 저수준 연산들을 합성해서 서비스 함수를 만듦.
flowchart LR
O1["Low-level operation"] -->|합성| O2["Low-level operation"] -->|합성| O3["Low-level operation"]
O3 --> S["Service"]
- 이 서비스 함수들을 다시 이어 붙여서 완전한 워크플로우를 처리하는 함수를 만들 수 있음.
flowchart LR
S1["Service"] -->|합성| S2["Service"] -->|합성| S3["Service"]
S3 --> W["Workflow"]
- 마지막으로, 이런 워크플로우들을 병렬로 합성하고 입력에 따라 특정 워크플로우를 선택하는 컨트롤러(디스패처)를 만들면 애플리케이션 전체를 구성할 수 있음.
flowchart TB
In[Input] --> App["Application<br/>controller / dispatcher"]
App -->|워크플로우 선택 1| W1["Workflow"]
App -->|워크플로우 선택 2| W2["Workflow"]
App -->|워크플로우 선택 3| W3["Workflow"]
- 이것이 함수형 애플리케이션을 만드는 방법임. 각 계층은 입력과 출력을 가진 함수로 이루어져 있으며, 끝까지 함수로만 쌓아 올라감(functions all the way up).
- 챕터 9 “파이프라인 구성하기”에서 이 아이디어가 실제로 어떻게 동작하는지 살펴봄. 더 작은 함수들을 조립해서 주문 처리(order-placing) 워크플로우의 파이프라인을 구현하게 됨.
8.4.3 녹록지 않은 함수 합성
- 한 함수의 출력이 다른 함수의 입력과 정확히 일치하면 함수 합성은 쉽음. 하지만 함수들이 그렇게 딱 맞아떨어지지 않으면 어떻게 되는지 살펴봄.
- 흔히 발생하는 경우는, 근본이 되는 타입 자체는 맞는데 함수의 “모양(shape)“이 서로 다른 경우임.
- 예를 들어 한 함수는
int option을 출력하는데 다음 함수는 그냥int를 필요로 하거나, 반대로 한 함수는int를 출력하는데 다음 함수는int option을 필요로 하는 경우임.
flowchart LR
FA["Function A"] -->|"출력: int option"| M1["타입 불일치"]
M1 -->|"필요: int"| FB["Function B"]
flowchart LR
FA2["Function A"] -->|"출력: int"| M2["타입 불일치"]
M2 -->|"필요: int option"| FB2["Function B"]
- 비슷한 불일치 문제는 리스트(list), 성공/실패를 나타내는
Result타입,async등을 다룰 때도 똑같이 발생함. - 합성을 사용할 때 겪는 어려움 대부분은 함수들이 서로 이어질 수 있도록 입력과 출력을 조정하는 문제임. 널리 쓰이는 접근법은 양쪽을 같은 타입으로 맞추는 것으로, 말하자면 양쪽의 “최소공배수(lowest common multiple)“에 해당하는 타입으로 변환하는 방식임.
- 예를 들어 출력이
int이고 입력이int option이라면, 둘을 모두 포괄할 수 있는 가장 작은 타입, 즉 최소공배수는option임.functionA의 출력을Some으로 감싸option으로 변환하면, 조정된 값을functionB의 입력으로 그대로 사용할 수 있어 합성이 가능해짐.
flowchart LR
FA3["Function A"] -->|"int"| SOME["Some 변환"]
SOME -->|"int option"| FB3["Function B"]
- 실제 코드로 확인함.
// 출력이 int인 함수
let add1 x = x + 1
// 입력이 int option인 함수
let printOption x =
match x with
| Some i -> printfn "The int is %i" i
| None -> printfn "No value"
// 출력이 number인 함수
const add1 = (x: number): number => x + 1;
// 입력이 number | undefined(F#의 option에 대응)인 함수
function printOption(x: number | undefined): void {
if (x !== undefined) {
console.log(`The int is ${x}`);
} else {
console.log("No value");
}
}
- 둘을 연결하려면
add1의 출력을Some생성자로Option으로 변환한 뒤, 그 값을printOption의 입력으로 파이프에 흘려보냄.
5 |> add1 |> Some |> printOption
// F#의 Some에 해당하는 명시적 래핑이 필요하다면
printOption(add1(5) as number | undefined);
// 다만 TypeScript는 number를 number | undefined 유니언에
// 별도 래핑 없이도 대입할 수 있으므로, 실무에서는 아래처럼도 충분함.
printOption(add1(5));
참고: F#의
option은Some/None으로 명시적으로 감싸야 하는 별도의 타입이지만, TypeScript의number | undefined는 유니언 타입이라number값을 그대로 대입할 수 있음. 두 언어의 타입 시스템 차이로 인해 “모양을 맞추는” 작업의 체감 난이도가 다를 수 있다는 점에 유의함.
- 이는 타입 불일치 문제의 아주 단순한 예시일 뿐임. 주문 처리 워크플로우를 모델링하고 합성을 시도했을 때 이미 더 복잡한 사례를 마주한 적이 있음. 이어지는 두 구현 챕터(9장, 10장)에서는 함수들을 합성 가능하도록 일관된 모양으로 맞추는 작업에 상당한 분량을 할애함.
8.5 마무리
- 이번 장에서는 F#의 함수형 프로그래밍 기본 개념을 소개함.
- 함수를 어디에서나 빌딩 블록으로 사용하기
- 함수들이 서로 합성 가능하도록 설계하기
- 이 원칙들을 갖췄으니 이제 본격적인 코딩을 시작할 준비가 되었음. 다음 장(9장)에서는 이 개념들을 실전에 적용해, 주문 처리 워크플로우를 위한 파이프라인을 구성하는 작업부터 시작함.
챕터 9 구현: 파이프라인 조합하기
- 지금까지 도메인을 타입으로만 모델링, 실제 구현은 아직 없었음
- 이번 장부터 7장(Modeling Workflows as Pipelines)에서 설계한 워크플로우를 함수형 원칙으로 구현
- 워크플로우 = 일련의 문서 변환(파이프라인), 각 단계는 하나의 “파이프” 구간으로 설계됨
- 기술적 관점에서 파이프라인의 4단계
UnvalidatedOrder→ValidatedOrder변환 (검증 실패 시 에러 반환)- 검증 단계 출력(
ValidatedOrder)에 추가 정보를 더해PricedOrder로 변환 - 가격 계산 단계 출력을 이용해 승인 편지(acknowledgment letter) 생성 및 발송
- 무슨 일이 일어났는지 나타내는 이벤트 집합 생성 및 반환
- 목표: 원래 요구사항을 그대로 유지하며 기술적 세부사항에 얽매이지 않고 코드로 변환
파이핑 방식으로 표현한 목표 코드
let placeOrder unvalidatedOrder =
unvalidatedOrder
|> validateOrder
|> priceOrder
|> acknowledgeOrder
|> createEvents
// TypeScript에는 파이프 연산자(|>)가 없으므로 간단한 pipe 헬퍼로 표현
function pipe<A, B>(a: A, ab: (a: A) => B): B;
function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
function pipe<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D;
function pipe(value: any, ...fns: Array<(x: any) => any>): any {
return fns.reduce((acc, fn) => fn(acc), value);
}
const placeOrder = (unvalidatedOrder: UnvalidatedOrder) =>
pipe(
unvalidatedOrder,
validateOrder,
priceOrder,
acknowledgeOrder,
createEvents
);
- 위 코드는 비개발자도 이해할 수 있을 만큼 단순한 일련의 단계로 구성됨
- 구현은 2단계로 진행
- 파이프라인의 각 단계를 독립적인 함수로 구현 (무상태, 부작용 없음 → 테스트와 추론이 쉬움)
- 작은 함수들을 하나의 큰 함수로 조합
- 이론상 단순해 보이지만 실제로 시도하면 문제 발생: 각 단계 함수의 출력이 다음 단계 함수의 입력과 맞지 않음
- 조합이 안 되는 두 가지 이유
- 일부 함수는 파이프라인 데이터가 아닌 추가 매개변수(“의존성”, dependency)를 가짐
- 일부 함수는 시그니처에
Result같은 래퍼 타입으로 “효과(effect)”(에러 처리 등)를 명시적으로 표현 → 효과가 있는 함수의 출력과, 효과 없는 일반 데이터를 입력으로 받는 함수는 직접 연결 불가
- 이번 장에서는 첫 번째 문제(의존성 처리, 함수형 버전의 “의존성 주입”)만 다룸
- 효과(
Result,Async) 다루는 법은 다음 장에서 학습 - 이번 장에서는 효과 없이 모든 단계를 구현 → 조합의 기본기에 집중
9.1 단순 타입 다루기
- 워크플로우 단계 구현 전에
OrderId,ProductCode같은 “단순 타입(simple types)“부터 구현 필요 - 대부분의 타입이 제약(constraint)을 가지므로, 앞서 다룬 제약 타입 구현 가이드라인을 따름
- 각 단순 타입마다 최소 2개의 헬퍼 함수 필요
create함수:string,int같은 원시 타입으로부터 해당 타입을 생성, 형식이 잘못되면 에러 발생 (예:OrderId.create)value함수: 내부 원시 값을 추출
- 헬퍼 함수들은 보통 단순 타입과 같은 파일에, 타입과 동일한 이름의 모듈에 넣음
OrderId 타입과 헬퍼 함수 (Domain 모듈)
module Domain =
type OrderId = private OrderId of string
module OrderId =
/// "스마트 생성자"를 OrderId에 대해 정의
/// string -> OrderId
let create str =
if String.IsNullOrEmpty(str) then
// 지금은 Result 대신 예외 사용
failwith "OrderId must not be null or empty"
elif str.Length > 50 then
failwith "OrderId must not be more than 50 chars"
else
OrderId str
/// OrderId에서 내부 값을 추출
/// OrderId -> string
let value (OrderId str) = // 매개변수에서 바로 언랩!
str // 내부 값 반환
// Domain.ts
// TypeScript에는 private 유니온 케이스가 없으므로
// 브랜드(brand) 타입으로 불투명(opaque) 타입을 흉내냄
type OrderId = string & { readonly __brand: "OrderId" };
namespace OrderId {
/// "스마트 생성자": string -> OrderId
export function create(str: string): OrderId {
if (!str) {
// 지금은 Result 대신 예외 사용
throw new Error("OrderId must not be null or empty");
} else if (str.length > 50) {
throw new Error("OrderId must not be more than 50 chars");
} else {
return str as OrderId;
}
}
/// OrderId -> string
export function value(orderId: OrderId): string {
return orderId as string;
}
}
create함수는 가이드라인 버전과 비슷하지만, 지금은 효과를 배제하므로Result대신 예외(failwith)로 에러 표현value함수는 매개변수에서 바로 패턴 매칭 후 내부 값을 추출하는 F# 특유의 관용구 → TS에서는 브랜드 타입 캐스팅으로 동일한 효과를 흉내냄
9.2 함수 타입으로 구현 가이드하기
- 앞선 모델링 단계에서 워크플로우 각 단계를 나타내는 특수 함수 타입을 이미 정의함
- 구현 시점에 코드가 이 타입에 부합하는지 어떻게 보장할 것인가?
방법 1: 타입 참조 없이 일반적으로 함수 정의
let validateOrder
checkProductCodeExists // 의존성
checkAddressExists // 의존성
unvalidatedOrder = // 입력
...
- 대부분의 F# 코드에서 쓰는 표준 방식
- 나중에 이 함수를 사용할 때 타입이 틀리면 타입 체크 에러가 발생한다는 점을 신뢰
방법 2: 함수를 값으로 정의하고 함수 타입으로 명시적 어노테이션
// 함수 시그니처 정의
type MyFunctionSignature = Param1 -> Param2 -> Result
// 시그니처를 구현하는 함수 정의
let myFunc: MyFunctionSignature =
fun param1 param2 ->
...
// 함수 시그니처 정의 (커링 스타일 - 부분 적용을 지원하기 위함)
type MyFunctionSignature = (param1: Param1) => (param2: Param2) => Result;
// 시그니처를 구현하는 함수 정의
const myFunc: MyFunctionSignature = (param1) => (param2) => {
// ...
};
validateOrder에 적용
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
// ^의존성 ^의존성 ^입력
...
const validateOrder: ValidateOrder =
(checkProductCodeExists) => (checkAddressExists) => (unvalidatedOrder) => {
// ^의존성 ^의존성 ^입력
// ...
};
- 장점: 모든 매개변수와 반환값의 타입이 함수 타입에 의해 결정됨
- 구현에서 실수하면 나중이 아니라 함수 정의 바로 그 자리(로컬)에서 에러 발생
타입 체크 동작 예시 (정수를 실수로 전달한 경우)
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
if checkProductCodeExists 42 then
// 컴파일 에러 ^
// This expression was expected to have type ProductCode
// but here has type int
...
...
- 함수 타입이 없었다면 컴파일러가 타입 추론으로
checkProductCodeExists가 정수와 동작한다고 잘못 결론지을 수 있음 → 나중에 다른 곳에서 헷갈리는 에러 발생 가능
9.3 유효성 검증 단계 구현
- 유효성 검증 단계 목표: 모든 필드가 원시 타입인
UnvalidatedOrder를 완전히 검증된 도메인 객체로 변환
함수 타입 (효과 포함, 기존 모델링 결과)
type CheckAddressExists =
UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>
type ValidateOrder =
CheckProductCodeExists // 의존성
-> CheckAddressExists // AsyncResult 의존성
-> UnvalidatedOrder // 입력
-> AsyncResult<ValidatedOrder,ValidationError list> // 출력
이번 장에서는 효과 제거 → 단순화된 버전
type CheckAddressExists =
UnvalidatedAddress -> CheckedAddress
type ValidateOrder =
CheckProductCodeExists // 의존성
-> CheckAddressExists // 의존성
-> UnvalidatedOrder // 입력
-> ValidatedOrder // 출력
// 효과(Result/Async) 제거 버전
type CheckAddressExists = (unvalidatedAddress: UnvalidatedAddress) => CheckedAddress;
type ValidateOrder =
(checkProductCodeExists: CheckProductCodeExists) =>
(checkAddressExists: CheckAddressExists) =>
(unvalidatedOrder: UnvalidatedOrder) => ValidatedOrder;
UnvalidatedOrder → ValidatedOrder 변환 절차
- 비검증 주문의
OrderId문자열로부터OrderId도메인 타입 생성 - 비검증 주문의
UnvalidatedCustomerInfo필드로부터CustomerInfo도메인 타입 생성 - 비검증 주문의
ShippingAddress(UnvalidatedAddress)로부터Address도메인 타입 생성 BillingAddress와 나머지 속성들에도 동일하게 적용- 모든 구성 요소가 준비되면 일반적인 방식으로 레코드 생성
구현 코드
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId =
unvalidatedOrder.OrderId
|> OrderId.create
let customerInfo =
unvalidatedOrder.CustomerInfo
|> toCustomerInfo // 헬퍼 함수
let shippingAddress =
unvalidatedOrder.ShippingAddress
|> toAddress // 헬퍼 함수
// 이하 unvalidatedOrder의 나머지 속성도 동일하게 처리
// 모든 필드가 준비되면 새 "ValidatedOrder" 레코드 생성 및 반환
{
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = ...
Lines = ...
}
const validateOrder: ValidateOrder =
(checkProductCodeExists) => (checkAddressExists) => (unvalidatedOrder) => {
const orderId = OrderId.create(unvalidatedOrder.orderId);
const customerInfo = toCustomerInfo(unvalidatedOrder.customerInfo); // 헬퍼 함수
const shippingAddress = toAddress(checkAddressExists)(unvalidatedOrder.shippingAddress); // 헬퍼 함수
// 이하 unvalidatedOrder의 나머지 속성도 동일하게 처리
const validatedOrder: ValidatedOrder = {
orderId,
customerInfo,
shippingAddress,
billingAddress: /* ... */ null as any,
lines: /* ... */ [],
};
return validatedOrder;
};
toCustomerInfo,toAddress같은 헬퍼 함수는 아직 미정의 → 비검증 타입으로부터 도메인 타입을 생성하며, 원시 값이 제약 조건을 위반하면 에러 발생- 헬퍼 함수가 모두 준비되면 로직은 단순: 도메인 타입(
ValidatedOrder)의 각 필드에 대응하는 비도메인 타입(UnvalidatedOrder)의 필드를 찾아 헬퍼 함수로 변환
toCustomerInfo 구현 (UnvalidatedCustomerInfo → CustomerInfo)
let toCustomerInfo (customer:UnvalidatedCustomerInfo) : CustomerInfo =
// CustomerInfo의 각 속성 생성
// 유효하지 않으면 예외 발생
let firstName = customer.FirstName |> String50.create
let lastName = customer.LastName |> String50.create
let emailAddress = customer.EmailAddress |> EmailAddress.create
// PersonalName 생성
let name : PersonalName = {
FirstName = firstName
LastName = lastName
}
// CustomerInfo 생성
let customerInfo : CustomerInfo = {
Name = name
EmailAddress = emailAddress
}
// 반환
customerInfo
function toCustomerInfo(customer: UnvalidatedCustomerInfo): CustomerInfo {
// CustomerInfo의 각 속성 생성
// 유효하지 않으면 예외 발생
const firstName = String50.create(customer.firstName);
const lastName = String50.create(customer.lastName);
const emailAddress = EmailAddress.create(customer.emailAddress);
// PersonalName 생성
const name: PersonalName = { firstName, lastName };
// CustomerInfo 생성
const customerInfo: CustomerInfo = { name, emailAddress };
return customerInfo;
}
9.3.1 유효한 주소 생성
toAddress함수는 더 복잡함: 원시 타입 → 도메인 객체 변환뿐 아니라CheckAddressExists서비스로 주소 실존 여부까지 확인해야 함
전체 구현
let toAddress (checkAddressExists:CheckAddressExists) unvalidatedAddress =
// 원격 서비스 호출
let checkedAddress = checkAddressExists unvalidatedAddress
// 패턴 매칭으로 내부 값 추출
let (CheckedAddress checkedAddress) = checkedAddress
let addressLine1 =
checkedAddress.AddressLine1 |> String50.create
let addressLine2 =
checkedAddress.AddressLine2 |> String50.createOption
let addressLine3 =
checkedAddress.AddressLine3 |> String50.createOption
let addressLine4 =
checkedAddress.AddressLine4 |> String50.createOption
let city =
checkedAddress.City |> String50.create
let zipCode =
checkedAddress.ZipCode |> ZipCode.create
// 주소 생성
let address : Address = {
AddressLine1 = addressLine1
AddressLine2 = addressLine2
AddressLine3 = addressLine3
AddressLine4 = addressLine4
City = city
ZipCode = zipCode
}
// 반환
address
function toAddress(checkAddressExists: CheckAddressExists) {
return (unvalidatedAddress: UnvalidatedAddress): Address => {
// 원격 서비스 호출
const checkedAddress = checkAddressExists(unvalidatedAddress);
// 태그된 값에서 내부 값 추출
const raw = checkedAddress.value;
const addressLine1 = String50.create(raw.addressLine1);
const addressLine2 = String50.createOption(raw.addressLine2);
const addressLine3 = String50.createOption(raw.addressLine3);
const addressLine4 = String50.createOption(raw.addressLine4);
const city = String50.create(raw.city);
const zipCode = ZipCode.create(raw.zipCode);
const address: Address = {
addressLine1,
addressLine2,
addressLine3,
addressLine4,
city,
zipCode,
};
return address;
};
}
String50.createOption: 입력이null이나 빈 문자열이어도 허용, 그 경우None(TS에서는undefined) 반환toAddress는checkAddressExists에 의존 →validateOrder에서 이 의존성을 전달해야 함
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId = ...
let customerInfo = ...
let shippingAddress =
unvalidatedOrder.ShippingAddress
|> toAddress checkAddressExists // 새 매개변수
...
const validateOrder: ValidateOrder =
(checkProductCodeExists) => (checkAddressExists) => (unvalidatedOrder) => {
const orderId = /* ... */;
const customerInfo = /* ... */;
const shippingAddress = toAddress(checkAddressExists)(unvalidatedOrder.shippingAddress); // 새 매개변수
// ...
};
toAddress는 매개변수가 2개이지만 여기서는 1개만 넘김 → 두 번째 매개변수(배송 주소)는 파이핑 과정에서 제공됨 (부분 적용 기법)
9.3.2 주문 항목 생성
- 주문 라인 목록 생성은 더 복잡함
- 먼저 단일
UnvalidatedOrderLine을ValidatedOrderLine으로 변환하는 함수toValidatedOrderLine필요
let toValidatedOrderLine checkProductCodeExists
(unvalidatedOrderLine:UnvalidatedOrderLine) =
let orderLineId =
unvalidatedOrderLine.OrderLineId
|> OrderLineId.create
let productCode =
unvalidatedOrderLine.ProductCode
|> toProductCode checkProductCodeExists // 헬퍼 함수
let quantity =
unvalidatedOrderLine.Quantity
|> toOrderQuantity productCode // 헬퍼 함수
let validatedOrderLine = {
OrderLineId = orderLineId
ProductCode = productCode
Quantity = quantity
}
validatedOrderLine
function toValidatedOrderLine(checkProductCodeExists: CheckProductCodeExists) {
return (unvalidatedOrderLine: UnvalidatedOrderLine): ValidatedOrderLine => {
const orderLineId = OrderLineId.create(unvalidatedOrderLine.orderLineId);
const productCode = toProductCode(checkProductCodeExists)(unvalidatedOrderLine.productCode); // 헬퍼 함수
const quantity = toOrderQuantity(productCode)(unvalidatedOrderLine.quantity); // 헬퍼 함수
const validatedOrderLine: ValidatedOrderLine = {
orderLineId,
productCode,
quantity,
};
return validatedOrderLine;
};
}
toProductCode,toOrderQuantity라는 2개의 헬퍼 함수 사용 (뒤에서 설명)- 각 원소를 변환하는 방법이 준비되었으므로
List.map(C# LINQ의Select에 해당)으로 리스트 전체를 한번에 변환
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId = ...
let customerInfo = ...
let shippingAddress = ...
let orderLines =
unvalidatedOrder.Lines
// `toValidatedOrderLine`으로 각 라인 변환
|> List.map (toValidatedOrderLine checkProductCodeExists)
...
const validateOrder: ValidateOrder =
(checkProductCodeExists) => (checkAddressExists) => (unvalidatedOrder) => {
const orderId = /* ... */;
const customerInfo = /* ... */;
const shippingAddress = /* ... */;
const orderLines = unvalidatedOrder.lines.map(
toValidatedOrderLine(checkProductCodeExists) // 각 라인 변환
);
// ...
};
toOrderQuantity: 경계에서의 유효성 검증(validation at the boundary)의 좋은 예- 입력:
UnvalidatedOrderLine의 검증되지 않은decimal - 출력: 케이스별로 다른 검증 로직을 가진 선택 타입(choice type)
OrderQuantity
- 입력:
let toOrderQuantity productCode quantity =
match productCode with
| Widget _ ->
quantity
|> int // decimal을 int로 변환
|> UnitQuantity.create // UnitQuantity로
|> OrderQuantity.Unit // OrderQuantity 타입으로 승격
| Gizmo _ ->
quantity
|> KilogramQuantity.create // KilogramQuantity로
|> OrderQuantity.Kilogram // OrderQuantity 타입으로 승격
function toOrderQuantity(productCode: ProductCode) {
return (quantity: number): OrderQuantity => {
switch (productCode.kind) {
case "Widget":
return {
kind: "Unit",
value: UnitQuantity.create(Math.trunc(quantity)), // decimal을 int로 변환 후 생성
};
case "Gizmo":
return {
kind: "Kilogram",
value: KilogramQuantity.create(quantity),
};
}
};
}
ProductCode선택 타입의 케이스에 따라 생성자를 다르게 사용Widget이면 원시decimal을int로 캐스팅 후UnitQuantity생성Gizmo면KilogramQuantity생성
- 두 분기 결과를 그대로 반환하면 한쪽은
UnitQuantity, 다른 쪽은KilogramQuantity로 서로 다른 타입 → 컴파일 에러 - 두 분기를 모두
OrderQuantity선택 타입으로 감싸서 동일한 타입 반환 → 컴파일러 만족
문제가 있는 toProductCode 초안
let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
productCode
|> ProductCode.create
|> checkProductCodeExists
// bool을 반환함 :(
function toProductCode(checkProductCodeExists: CheckProductCodeExists) {
return (productCode: string) => {
return pipe(
productCode,
ProductCode.create,
checkProductCodeExists
// bool을 반환함 :(
);
};
}
- 문제:
toProductCode가ProductCode를 반환해야 하는데,checkProductCodeExists가bool을 반환하므로 파이프라인 전체가bool을 반환하게 됨 checkProductCodeExists가ProductCode를 반환하도록 만들어야 하나? 스펙(사양)을 바꿔야 하나? 아니오
9.3.3 함수 어댑터 생성
bool을 반환하는 함수는 있는데, 원래의ProductCode입력을 그대로 반환하는 함수가 필요함 (모든 게 잘 진행됐을 때)- 스펙을 바꾸는 대신 원본 함수를 입력받아 파이프라인에 맞는 “모양”의 새 함수를 만들어내는 “어댑터” 함수를 작성
flowchart TB
subgraph 원본함수["원본 함수"]
A1["Product Code"] --> B1["Bool"]
end
subgraph 어댑터함수["어댑터 함수"]
A2["Input"] --> B2["Output"]
end
subgraph 결합함수["결합된 함수 (Adapted Function)"]
A3["Product Code"] --> B3["Product Code"]
end
구현 (bool 반환 술어 → 통과(passthru) 함수)
let convertToPassthru checkProductCodeExists productCode =
if checkProductCodeExists productCode then
productCode
else
failwith "Invalid Product Code"
function convertToPassthru<T>(checkProductCodeExists: (x: T) => boolean, productCode: T): T {
if (checkProductCodeExists(productCode)) {
return productCode;
} else {
throw new Error("Invalid Product Code");
}
}
- 컴파일러가 이 구현이 완전히 제네릭하다는 것(특정 케이스에 국한되지 않음)을 알아냄 → 시그니처 어디에도
ProductCode가 언급되지 않음
val convertToPassthru :
checkProductCodeExists:('a -> bool) -> productCode:'a -> 'a
- 실수로 임의의 술어 함수를 파이프라인용 “통과” 함수로 바꿔주는 제네릭 어댑터를 만들어낸 것
- 매개변수 이름
checkProductCodeExists,productCode는 더 이상 어울리지 않음(무엇이든 될 수 있음) → 더 추상적인 이름(f,x)으로 재작성
let predicateToPassthru f x =
if f x then
x
else
failwith "Invalid Product Code"
- 하드코딩된 에러 메시지도 눈에 띔 → 매개변수화
let predicateToPassthru errorMsg f x =
if f x then
x
else
failwith errorMsg
function predicateToPassthru<T>(errorMsg: string) {
return (f: (x: T) => boolean) => {
return (x: T): T => {
if (f(x)) {
return x;
} else {
throw new Error(errorMsg);
}
};
};
}
- 에러 메시지를 매개변수 순서상 맨 앞에 두어 부분 적용으로 미리 “베이킹”할 수 있게 함
val predicateToPassthru : errorMsg:string -> f:('a -> bool) -> x:'a -> 'a
- 해석: “에러 메시지와
'a -> bool타입의 함수를 주면,'a -> 'a타입의 함수를 돌려준다” predicateToPassthru는 “함수 변환기(function transformer)”: 함수 하나를 넣으면 다른 함수로 변환해줌- 함수형 프로그래밍에서 매우 흔한 기법 → 패턴을 인식하는 것이 중요
List.map도 함수 변환기의 예:'a -> 'b타입의 “평범한” 함수를 리스트에서 동작하는 함수('a list -> 'b list)로 변환
predicateToPassthru를 이용한 toProductCode 최종 버전
let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
// 파이프라인에서 사용하기 적합한
// ProductCode -> ProductCode 로컬 함수 생성
let checkProduct productCode =
let errorMsg = sprintf "Invalid: %A" productCode
predicateToPassthru errorMsg checkProductCodeExists productCode
// 파이프라인 조립
productCode
|> ProductCode.create
|> checkProduct
function toProductCode(checkProductCodeExists: CheckProductCodeExists) {
return (rawProductCode: string): ProductCode => {
// 파이프라인에서 사용하기 적합한
// ProductCode -> ProductCode 로컬 함수 생성
const checkProduct = (productCode: ProductCode): ProductCode => {
const errorMsg = `Invalid: ${JSON.stringify(productCode)}`;
return predicateToPassthru<ProductCode>(errorMsg)(checkProductCodeExists)(productCode);
};
// 파이프라인 조립
return pipe(rawProductCode, ProductCode.create, checkProduct);
};
}
- 이로써
validateOrder구현의 기본 뼈대 완성 - “제품 코드는 W나 G로 시작해야 한다” 같은 저수준 검증 로직은 검증 함수에 명시적으로 구현되지 않고,
OrderId,ProductCode같은 제약된 단순 타입의 생성자에 내장됨 - 타입 활용의 이점:
UnvalidatedOrder로부터ValidatedOrder를 성공적으로 생성할 수 있다는 사실 자체가 검증이 완료되었음을 신뢰할 수 있게 해줌
9.4 나머지 단계 구현
validateOrder에서 사용한 기법을 나머지 파이프라인 함수에도 동일하게 적용
가격 계산 단계 원래 설계 (효과 포함)
type PriceOrder =
GetProductPrice // 의존성
-> ValidatedOrder // 입력
-> Result<PricedOrder, PlaceOrderError> // 출력
효과 제거된 설계
type GetProductPrice = ProductCode -> Price
type PriceOrder =
GetProductPrice // 의존성
-> ValidatedOrder // 입력
-> PricedOrder // 출력
type GetProductPrice = (productCode: ProductCode) => Price;
type PriceOrder =
(getProductPrice: GetProductPrice) =>
(validatedOrder: ValidatedOrder) => PricedOrder;
구현 개요: 각 주문 라인을 PricedOrderLine으로 변환 후 새 PricedOrder 조립
let priceOrder : PriceOrder =
fun getProductPrice validatedOrder ->
let lines =
validatedOrder.Lines
|> List.map (toPricedOrderLine getProductPrice)
let amountToBill =
lines
// 각 라인의 가격을 가져와서
|> List.map (fun line -> line.LinePrice)
// BillingAmount로 모두 합산
|> BillingAmount.sumPrices
let pricedOrder : PricedOrder = {
OrderId = validatedOrder.OrderId
CustomerInfo = validatedOrder.CustomerInfo
ShippingAddress = validatedOrder.ShippingAddress
BillingAddress = validatedOrder.BillingAddress
Lines = lines
AmountToBill = amountToBill
}
pricedOrder
const priceOrder: PriceOrder = (getProductPrice) => (validatedOrder) => {
const lines = validatedOrder.lines.map(toPricedOrderLine(getProductPrice));
const amountToBill = BillingAmount.sumPrices(
lines.map((line) => line.linePrice) // 각 라인의 가격을 가져와서
);
const pricedOrder: PricedOrder = {
orderId: validatedOrder.orderId,
customerInfo: validatedOrder.customerInfo,
shippingAddress: validatedOrder.shippingAddress,
billingAddress: validatedOrder.billingAddress,
lines,
amountToBill,
};
return pricedOrder;
};
- 여러 단계를 아직 구현하고 싶지 않다면(또는 방법을 모른다면) “not implemented” 메시지로 실패시켜 프로젝트가 항상 컴파일 가능한 상태를 유지 가능
let priceOrder : PriceOrder =
fun getProductPrice validatedOrder ->
failwith "not implemented"
const priceOrder: PriceOrder = (getProductPrice) => (validatedOrder) => {
throw new Error("not implemented");
};
- 구현을 스케치할 때 편리함: 함수 타입에 부합하는 더미 스테이지를 만들어 두고, 나중에 제대로 된 구현이 준비되기 전까지 다른 스테이지들과 함께 사용 가능
priceOrder에서 도입한 2개의 새 헬퍼 함수:toPricedOrderLine,BillingAmount.sumPricesBillingAmount.sumPrices:Price목록을 합산하여BillingAmount로 래핑 (공유BillingAmount모듈에create,value와 함께 추가)BillingAmount를 별도 타입으로 둔 이유:Price와 구분되며, 검증 규칙이 다를 수 있기 때문
/// 가격 목록을 합산해 청구 금액을 만듦
/// 합계가 범위를 벗어나면 예외 발생
let sumPrices prices =
let total = prices |> List.map Price.value |> List.sum
create total
namespace BillingAmount {
/// 가격 목록을 합산해 청구 금액을 만듦
/// 합계가 범위를 벗어나면 예외 발생
export function sumPrices(prices: Price[]): BillingAmount {
const total = prices.map(Price.value).reduce((a, b) => a + b, 0);
return create(total);
}
}
toPricedOrderLine: 단일 라인 변환
/// ValidatedOrderLine을 PricedOrderLine으로 변환
let toPricedOrderLine getProductPrice (line:ValidatedOrderLine) : PricedOrderLine =
let qty = line.Quantity |> OrderQuantity.value
let price = line.ProductCode |> getProductPrice
let linePrice = price |> Price.multiply qty
{
OrderLineId = line.OrderLineId
ProductCode = line.ProductCode
Quantity = line.Quantity
LinePrice = linePrice
}
/// ValidatedOrderLine을 PricedOrderLine으로 변환
function toPricedOrderLine(getProductPrice: GetProductPrice) {
return (line: ValidatedOrderLine): PricedOrderLine => {
const qty = OrderQuantity.value(line.quantity);
const price = getProductPrice(line.productCode);
const linePrice = Price.multiply(qty)(price);
return {
orderLineId: line.orderLineId,
productCode: line.productCode,
quantity: line.quantity,
linePrice,
};
};
}
Price.multiply: Price를 수량으로 곱하는 헬퍼 함수
/// Price를 decimal 수량으로 곱함
/// 새 가격이 범위를 벗어나면 예외 발생
let multiply qty (Price p) =
create (qty * p)
namespace Price {
/// Price를 수량으로 곱함
/// 새 가격이 범위를 벗어나면 예외 발생
export function multiply(qty: number) {
return (price: Price): Price => {
return create(qty * value(price));
};
}
}
- 가격 계산 단계 완료
9.4.1 승인 단계 구현
효과 제거된 설계
type HtmlString = HtmlString of string
type CreateOrderAcknowledgmentLetter =
PricedOrder -> HtmlString
type OrderAcknowledgment = {
EmailAddress : EmailAddress
Letter : HtmlString
}
type SendResult = Sent | NotSent
type SendOrderAcknowledgment =
OrderAcknowledgment -> SendResult
type AcknowledgeOrder =
CreateOrderAcknowledgmentLetter // 의존성
-> SendOrderAcknowledgment // 의존성
-> PricedOrder // 입력
-> OrderAcknowledgmentSent option // 출력
type HtmlString = string & { readonly __brand: "HtmlString" };
type CreateOrderAcknowledgmentLetter = (pricedOrder: PricedOrder) => HtmlString;
interface OrderAcknowledgment {
emailAddress: EmailAddress;
letter: HtmlString;
}
type SendResult = "Sent" | "NotSent";
type SendOrderAcknowledgment = (ack: OrderAcknowledgment) => SendResult;
type AcknowledgeOrder =
(createAcknowledgmentLetter: CreateOrderAcknowledgmentLetter) =>
(sendAcknowledgment: SendOrderAcknowledgment) =>
(pricedOrder: PricedOrder) => OrderAcknowledgmentSent | undefined;
구현
let acknowledgeOrder : AcknowledgeOrder =
fun createAcknowledgmentLetter sendAcknowledgment pricedOrder ->
let letter = createAcknowledgmentLetter pricedOrder
let acknowledgment = {
EmailAddress = pricedOrder.CustomerInfo.EmailAddress
Letter = letter
}
// 승인이 성공적으로 발송되면 해당 이벤트를 반환
// 그렇지 않으면 None 반환
match sendAcknowledgment acknowledgment with
| Sent ->
let event = {
OrderId = pricedOrder.OrderId
EmailAddress = pricedOrder.CustomerInfo.EmailAddress
}
Some event
| NotSent ->
None
const acknowledgeOrder: AcknowledgeOrder =
(createAcknowledgmentLetter) => (sendAcknowledgment) => (pricedOrder) => {
const letter = createAcknowledgmentLetter(pricedOrder);
const acknowledgment: OrderAcknowledgment = {
emailAddress: pricedOrder.customerInfo.emailAddress,
letter,
};
// 승인이 성공적으로 발송되면 해당 이벤트를 반환
// 그렇지 않으면 undefined 반환
const sendResult = sendAcknowledgment(acknowledgment);
if (sendResult === "Sent") {
const event: OrderAcknowledgmentSent = {
orderId: pricedOrder.orderId,
emailAddress: pricedOrder.customerInfo.emailAddress,
};
return event;
} else {
return undefined;
}
};
- 구현이 단순함: 별도 헬퍼 함수 불필요
sendAcknowledgment의존성은 언젠가 구체적인 구현을 결정해야 하지만, 지금은 미뤄둘 수 있음- 함수로 의존성을 매개변수화하는 장점: 대부분의 코드를 빌드/조립하면서도 “마지막까지 책임질 수 있는 순간(last responsible moment)“까지 결정을 미룰 수 있음
9.4.2 이벤트 생성
- 워크플로우가 반환할 이벤트 생성 단계
- 요구사항 추가: 청구(billing) 이벤트는 청구 금액(
AmountToBill)이 0보다 클 때만 생성
설계
/// 배송 컨텍스트로 보낼 이벤트
type OrderPlaced = PricedOrder
/// 청구 컨텍스트로 보낼 이벤트
/// AmountToBill이 0이 아닐 때만 생성됨
type BillableOrderPlaced = {
OrderId : OrderId
BillingAddress: Address
AmountToBill : BillingAmount
}
type PlaceOrderEvent =
| OrderPlaced of OrderPlaced
| BillableOrderPlaced of BillableOrderPlaced
| AcknowledgmentSent of OrderAcknowledgmentSent
type CreateEvents =
PricedOrder // 입력
-> OrderAcknowledgmentSent option // 입력 (이전 단계에서 온 이벤트)
-> PlaceOrderEvent list // 출력
/// 배송 컨텍스트로 보낼 이벤트 (PricedOrder와 동일한 구조)
type OrderPlaced = PricedOrder;
/// 청구 컨텍스트로 보낼 이벤트
/// amountToBill이 0이 아닐 때만 생성됨
interface BillableOrderPlaced {
orderId: OrderId;
billingAddress: Address;
amountToBill: BillingAmount;
}
type PlaceOrderEvent =
| { kind: "OrderPlaced"; value: OrderPlaced }
| { kind: "BillableOrderPlaced"; value: BillableOrderPlaced }
| { kind: "AcknowledgmentSent"; value: OrderAcknowledgmentSent };
type CreateEvents =
(pricedOrder: PricedOrder) =>
(acknowledgmentEventOpt: OrderAcknowledgmentSent | undefined) => PlaceOrderEvent[];
OrderPlaced는PricedOrder와 동일하므로 별도 생성 불필요OrderAcknowledgmentSent는 이전 단계에서 이미 생성됨 → 별도 생성 불필요BillableOrderPlaced는 새로 생성 필요 →createBillingEvent함수 작성- 청구 금액이 0보다 커야 하므로 옵션(optional) 이벤트 반환
// PricedOrder -> BillableOrderPlaced option
let createBillingEvent (placedOrder:PricedOrder) : BillableOrderPlaced option =
let billingAmount = placedOrder.AmountToBill |> BillingAmount.value
if billingAmount > 0M then
let order = {
OrderId = placedOrder.OrderId
BillingAddress = placedOrder.BillingAddress
AmountToBill = placedOrder.AmountToBill
}
Some order
else
None
// PricedOrder -> BillableOrderPlaced | undefined
function createBillingEvent(placedOrder: PricedOrder): BillableOrderPlaced | undefined {
const billingAmount = BillingAmount.value(placedOrder.amountToBill);
if (billingAmount > 0) {
return {
orderId: placedOrder.orderId,
billingAddress: placedOrder.billingAddress,
amountToBill: placedOrder.amountToBill,
};
} else {
return undefined;
}
}
- 정리하면
OrderPlaced이벤트, 옵션인OrderAcknowledgmentSent이벤트, 옵션인BillableOrderPlaced이벤트가 존재 - “최소 공통 배수(lowest common multiple)” 접근법으로 모두 공통 타입으로 변환
- 먼저 각 이벤트를 선택 타입(
PlaceOrderEvent)으로 변환OrderPlaced는PlaceOrderEvent.OrderPlaced생성자로 바로 변환OrderAcknowledgmentSent와BillableOrderPlaced는 옵션이므로Option.map사용
let createEvents : CreateEvents =
fun pricedOrder acknowledgmentEventOpt ->
let event1 =
pricedOrder
// 공통 선택 타입으로 변환
|> PlaceOrderEvent.OrderPlaced
let event2Opt =
acknowledgmentEventOpt
// 공통 선택 타입으로 변환
|> Option.map PlaceOrderEvent.AcknowledgmentSent
let event3Opt =
pricedOrder
|> createBillingEvent
// 공통 선택 타입으로 변환
|> Option.map PlaceOrderEvent.BillableOrderPlaced
// 이 모든 것을 어떻게 반환할까?
...
- 이제 모두 같은 타입이지만 일부는 옵션(optional) → 다시 같은 트릭으로 더 일반적인 타입(리스트)으로 변환
OrderPlaced는List.singleton으로, 옵션들은listOfOption헬퍼로 리스트화
/// Option을 List로 변환
let listOfOption opt =
match opt with
| Some x -> [x]
| None -> []
/// Option(undefined 가능)을 배열로 변환
function listOfOption<T>(opt: T | undefined): T[] {
return opt !== undefined ? [opt] : [];
}
최종 createEvents
let createEvents : CreateEvents =
fun pricedOrder acknowledgmentEventOpt ->
let events1 =
pricedOrder
// 공통 선택 타입으로 변환
|> PlaceOrderEvent.OrderPlaced
// 리스트로 변환
|> List.singleton
let events2 =
acknowledgmentEventOpt
// 공통 선택 타입으로 변환
|> Option.map PlaceOrderEvent.AcknowledgmentSent
// 리스트로 변환
|> listOfOption
let events3 =
pricedOrder
|> createBillingEvent
// 공통 선택 타입으로 변환
|> Option.map PlaceOrderEvent.BillableOrderPlaced
// 리스트로 변환
|> listOfOption
// 모든 이벤트 반환
[
yield! events1
yield! events2
yield! events3
]
const createEvents: CreateEvents = (pricedOrder) => (acknowledgmentEventOpt) => {
const events1: PlaceOrderEvent[] = [
// 공통 선택 타입으로 변환 후 리스트로
{ kind: "OrderPlaced", value: pricedOrder },
];
const events2: PlaceOrderEvent[] = listOfOption(acknowledgmentEventOpt).map(
// 공통 선택 타입으로 변환
(value): PlaceOrderEvent => ({ kind: "AcknowledgmentSent", value })
);
const events3: PlaceOrderEvent[] = listOfOption(createBillingEvent(pricedOrder)).map(
// 공통 선택 타입으로 변환
(value): PlaceOrderEvent => ({ kind: "BillableOrderPlaced", value })
);
// 모든 이벤트 반환
return [...events1, ...events2, ...events3];
};
- 서로 호환되지 않는 것들을 공유 타입으로 변환(“리프팅”, lifting)하는 이 방식은 조합 문제 해결의 핵심 기법
- 다음 장에서 서로 다른 종류의
Result타입 간 불일치를 다룰 때도 동일한 기법 사용 예정
9.5 파이프라인 단계들 모두 모으기
- 이제 단계들의 구현을 조합하여 워크플로우를 완성할 차례
- 목표 코드
let placeOrder : PlaceOrderWorkflow =
fun unvalidatedOrder ->
unvalidatedOrder
|> validateOrder
|> priceOrder
|> acknowledgeOrder
|> createEvents
- 문제:
validateOrder는UnvalidatedOrder외에 2개의 추가 입력(의존성)을 가짐 →PlaceOrder워크플로우의 입력을validateOrder에 바로 연결할 방법이 없음(입출력 모양 불일치)
flowchart LR
U["Unvalidated Order<br/>워크플로우 입력"] -->|연결 불가| V["Validate Order<br/>의존성 2개 + 입력 1개"]
V --> VA["Validated Order"]
priceOrder도 입력이 2개라서validateOrder의 출력과 바로 연결할 수 없음
flowchart LR
VA["Validated Order"] -->|연결 불가| P["Price Order<br/>의존성 1개 + 입력 1개"]
P --> PO["Priced Order"]
- 서로 다른 “모양”의 함수를 조합하는 것은 함수형 프로그래밍의 대표적인 과제 중 하나, 여러 해결 기법 존재
- 대부분의 해결책은 “모나드(monad)“를 다룸 → 지금은 아주 단순한 방법인 부분 적용(partial application) 사용
- 3개 매개변수 중 2개(의존성 2개)만
validateOrder에 적용 → 입력이 1개뿐인 새 함수 완성
flowchart LR
U["Unvalidated Order<br/>워크플로우 입력"] -->|연결 성공| V2["Validate Order<br/>의존성 베이킹됨"]
V2 --> VA["Validated Order"]
코드로 표현
let validateOrderWithDependenciesBakedIn =
validateOrder checkProductCodeExists checkAddressExists
// 부분 적용 후의 새 함수 시그니처:
// UnvalidatedOrder -> ValidatedOrder
const validateOrderWithDependenciesBakedIn = validateOrder(checkProductCodeExists)(checkAddressExists);
// 부분 적용 후의 새 함수 시그니처:
// (unvalidatedOrder: UnvalidatedOrder) => ValidatedOrder
- 이름이 별로 좋지 않음! F#에서는 새 함수에도 같은 이름(
validateOrder)을 그대로 쓸 수 있음 → “섀도잉(shadowing)”
let validateOrder =
validateOrder checkProductCodeExists checkAddressExists
- 또는 틱 마크(
')를 붙여 원본 함수의 변형임을 표시 가능
let validateOrder' =
validateOrder checkProductCodeExists checkAddressExists
TypeScript에는 F#의 섀도잉/틱 마크와 같은 관례가 없으므로, 보통
Baked와 같은 접미사를 붙인 별도 이름을 사용하거나 블록 스코프로 같은 이름을 가리는 방식을 씀
priceOrder,acknowledgeOrder도 동일한 방식으로 의존성을 베이킹해 1-매개변수 함수로 만듦
flowchart LR
VA["Validated Order"] -->|연결 성공| P2["Price Order<br/>의존성 베이킹됨"]
P2 --> PO["Priced Order"]
메인 워크플로우 함수 (파이프라인 스타일 시도)
let placeOrder : PlaceOrderWorkflow =
// 파이프라인 단계들의 로컬 버전 설정
// 부분 적용으로 의존성 베이킹
let validateOrder =
validateOrder checkProductCodeExists checkAddressExists
let priceOrder =
priceOrder getProductPrice
let acknowledgeOrder =
acknowledgeOrder createAcknowledgmentLetter sendAcknowledgment
// 워크플로우 함수 반환
fun unvalidatedOrder ->
// 새로운 1-매개변수 함수들로 파이프라인 조립
unvalidatedOrder
|> validateOrder
|> priceOrder
|> acknowledgeOrder
|> createEvents
const placeOrder: PlaceOrderWorkflow = (() => {
// 파이프라인 단계들의 로컬 버전 설정
// 부분 적용으로 의존성 베이킹
const validateOrderBaked = validateOrder(checkProductCodeExists)(checkAddressExists);
const priceOrderBaked = priceOrder(getProductPrice);
const acknowledgeOrderBaked = acknowledgeOrder(createAcknowledgmentLetter)(sendAcknowledgment);
// 워크플로우 함수 반환
return (unvalidatedOrder: UnvalidatedOrder) =>
pipe(
unvalidatedOrder,
validateOrderBaked,
priceOrderBaked,
acknowledgeOrderBaked,
createEvents // 아래에서 문제가 됨
);
})();
- 그러나 여전히 문제 존재:
acknowledgeOrder의 출력은 이벤트(옵션)일 뿐, 가격 계산된 주문(priced order)이 아님 →createEvents의 입력과 맞지 않음 - 해결책: 작은 어댑터를 작성하거나, 더 명령형(imperative) 스타일로 전환해 각 단계의 출력을 명시적으로 값에 할당
명령형 스타일 최종 버전
let placeOrder : PlaceOrderWorkflow =
// 워크플로우 함수 반환
fun unvalidatedOrder ->
let validatedOrder =
unvalidatedOrder
|> validateOrder checkProductCodeExists checkAddressExists
let pricedOrder =
validatedOrder
|> priceOrder getProductPrice
let acknowledgmentOption =
pricedOrder
|> acknowledgeOrder createAcknowledgmentLetter sendAcknowledgment
let events =
createEvents pricedOrder acknowledgmentOption
events
const placeOrder: PlaceOrderWorkflow = (unvalidatedOrder: UnvalidatedOrder) => {
const validatedOrder = validateOrder(checkProductCodeExists)(checkAddressExists)(unvalidatedOrder);
const pricedOrder = priceOrder(getProductPrice)(validatedOrder);
const acknowledgmentOption = acknowledgeOrder(createAcknowledgmentLetter)(sendAcknowledgment)(pricedOrder);
const events = createEvents(pricedOrder)(acknowledgmentOption);
return events;
};
- 파이프라인만큼 우아하지는 않지만 이해하고 유지보수하기에는 충분히 쉬움
- 다음 과제:
checkProductCodeExists,checkAddressExists,priceOrder등 의존성들은 어디서 오는가? → “의존성 주입(injecting dependencies)“으로 이어짐
9.6 의존 주입
toValidProductCode같은 저수준 헬퍼 함수들은 서비스를 나타내는 함수 매개변수를 가짐(설계 깊숙한 곳에 위치) → 최상위 수준부터 이 함수들까지 어떻게 의존성을 전달할 것인가?- 객체지향 프로그래밍이었다면 의존성 주입(DI)과 IoC 컨테이너 사용
- 함수형 프로그래밍에서는 의존성이 암묵적으로 되는 것을 원치 않음 → 항상 명시적 매개변수로 의존성을 전달
- “Reader Monad”, “Free Monad” 같은 기법도 있지만, 입문서인 만큼 가장 단순한 접근 사용
- 모든 의존성을 최상위 함수에 전달 → 그 함수가 내부 함수들에 전달 → 내부 함수가 다시 자신의 내부 함수들에 전달 (이하 반복)
저수준 헬퍼 함수 (명시적 의존성 매개변수)
// 저수준 헬퍼 함수
let toAddress checkAddressExists unvalidatedAddress =
...
let toProductCode checkProductCodeExists productCode =
...
- 둘 다 명시적인 의존성 매개변수를 가짐
한 단계 위: toValidatedOrderLine
// 헬퍼 함수
let toValidatedOrderLine checkProductExists unvalidatedOrderLine =
// ^ 아래 toProductCode에 필요
// 라인의 구성 요소 생성
let orderLineId = ...
let productCode =
unvalidatedOrderLine.ProductCode
|> toProductCode checkProductExists // 서비스 사용
...
// 헬퍼 함수
function toValidatedOrderLine(checkProductExists: CheckProductCodeExists) {
// ^ 아래 toProductCode에 필요
return (unvalidatedOrderLine: UnvalidatedOrderLine) => {
const orderLineId = /* ... */;
const productCode = toProductCode(checkProductExists)(unvalidatedOrderLine.productCode); // 서비스 사용
// ...
};
}
한 단계 더 위: validateOrder
let validateOrder : ValidateOrder =
fun checkProductExists // toValidatedOrderLine을 위한 의존성
checkAddressExists // toAddress를 위한 의존성
unvalidatedOrder ->
// 의존성을 사용해 검증된 주소 생성
let shippingAddress =
unvalidatedOrder.ShippingAddress
|> toAddress checkAddressExists
...
// 의존성을 사용해 검증된 주문 라인들 생성
let lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductExists)
...
const validateOrder: ValidateOrder =
(checkProductExists) => (checkAddressExists) => (unvalidatedOrder) => {
// 의존성을 사용해 검증된 주소 생성
const shippingAddress = toAddress(checkAddressExists)(unvalidatedOrder.shippingAddress);
// ...
// 의존성을 사용해 검증된 주문 라인들 생성
const lines = unvalidatedOrder.lines.map(toValidatedOrderLine(checkProductExists));
// ...
};
- 이런 식으로 체인을 따라 계속 위로 올라가 모든 서비스와 기타 의존성을 설정하는 최상위 함수까지 도달
- 객체지향 설계에서는 이 최상위 함수를 보통 “컴포지션 루트(composition root)“라 부름 → 여기서도 같은 용어 사용
placeOrder가 컴포지션 루트여야 하는가? 아니오
- 서비스를 설정하려면 보통 설정(configuration) 접근 등이 필요하기 때문
placeOrder워크플로우 자체는 필요한 서비스를 매개변수로 제공받는 편이 더 나음
let placeOrder
checkProductExists // 의존성
checkAddressExists // 의존성
getProductPrice // 의존성
createOrderAcknowledgmentLetter // 의존성
sendOrderAcknowledgment // 의존성
: PlaceOrderWorkflow = // 함수 정의
fun unvalidatedOrder ->
...
const placeOrder =
(checkProductExists: CheckProductCodeExists) => // 의존성
(checkAddressExists: CheckAddressExists) => // 의존성
(getProductPrice: GetProductPrice) => // 의존성
(createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter) => // 의존성
(sendOrderAcknowledgment: SendOrderAcknowledgment): PlaceOrderWorkflow => // 함수 정의
(unvalidatedOrder: UnvalidatedOrder) => {
// ...
};
- 이 방식의 추가적인 장점: 모든 의존성이 페이크(fake)로 대체 가능하므로 전체 워크플로우가 테스트하기 쉬워짐
- 실전에서는 컴포지션 루트 함수가 애플리케이션의 진입점(콘솔 앱의
main함수, 장기 실행 앱의OnStartup/Application_Start핸들러 등)에 최대한 가까워야 함
Suave 프레임워크 기반 웹 서비스의 컴포지션 루트 예시
let app : WebPart =
// 워크플로우가 사용할 서비스 설정
let checkProductExists = ...
let checkAddressExists = ...
let getProductPrice = ...
let createOrderAcknowledgmentLetter = ...
let sendOrderAcknowledgment = ...
let toHttpResponse = ...
// 서비스들을 부분 적용해 "placeOrder" 워크플로우 설정
let placeOrder =
placeOrder
checkProductExists
checkAddressExists
getProductPrice
createOrderAcknowledgmentLetter
sendOrderAcknowledgment
// 다른 워크플로우들도 설정
let changeOrder = ...
let cancelOrder = ...
// 라우팅 설정
choose
[ POST >=> choose
[ path "/placeOrder"
>=> deserializeOrder // JSON을 UnvalidatedOrder로 변환
>=> placeOrder // 워크플로우 실행
>=> postEvents // 이벤트를 큐에 게시
>=> toHttpResponse // 결과에 따라 200/400 등의 HTTP 응답 반환
path "/changeOrder"
>=> ...
path "/cancelOrder"
>=> ...
]
]
// Node.js/Express 스타일로 표현한 컴포지션 루트 예시
function createApp() {
// 워크플로우가 사용할 서비스 설정
const checkProductExists: CheckProductCodeExists = /* ... */ (() => true) as any;
const checkAddressExists: CheckAddressExists = /* ... */ null as any;
const getProductPrice: GetProductPrice = /* ... */ null as any;
const createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter = /* ... */ null as any;
const sendOrderAcknowledgment: SendOrderAcknowledgment = /* ... */ null as any;
const toHttpResponse = (res: any, events: PlaceOrderEvent[]) => {
/* ... */
};
// 서비스들을 부분 적용해 "placeOrder" 워크플로우 설정
const placeOrderWorkflow = placeOrder
(checkProductExists)
(checkAddressExists)
(getProductPrice)
(createOrderAcknowledgmentLetter)
(sendOrderAcknowledgment);
// 다른 워크플로우들도 설정
const changeOrderWorkflow = /* ... */ null;
const cancelOrderWorkflow = /* ... */ null;
const router = express.Router();
router.post("/placeOrder", (req, res) => {
const unvalidatedOrder = deserializeOrder(req.body); // JSON을 UnvalidatedOrder로 변환
const events = placeOrderWorkflow(unvalidatedOrder); // 워크플로우 실행
postEvents(events); // 이벤트를 큐에 게시
toHttpResponse(res, events); // 결과에 따라 200/400 등의 HTTP 응답 반환
});
router.post("/changeOrder", (req, res) => {
/* ... */
});
router.post("/cancelOrder", (req, res) => {
/* ... */
});
return router;
}
- 경로가
/placeOrder이면: 입력 역직렬화 → 메인placeOrder파이프라인 호출 → 이벤트 게시 → 출력을 HTTP 응답으로 변환 placeOrder이외의 함수(역직렬화 등)는 시리얼라이제이션 관련 장에서 별도로 다룸
9.6.1 넘쳐 나는 의존
validateOrder는 의존성이 2개뿐이지만, 4~5개 이상이 필요해지거나 다른 단계들도 많은 의존성을 필요로 하면 의존성이 폭발적으로 늘어날 수 있음 → 어떻게 대응할까?
대응 1: 함수가 너무 많은 일을 하고 있는 건 아닌지 점검
- 더 작은 조각으로 분리 가능한지 검토
대응 2: 의존성들을 하나의 레코드 구조체로 묶어 단일 매개변수로 전달
- 흔한 상황: 자식 함수의 의존성이 그 자체로 복잡한 경우
- 예:
checkAddressExists가 웹 서비스와 통신하며 URI 엔드포인트와 자격 증명(credentials)이 필요한 경우
let checkAddressExists endPoint credentials =
...
- 이 추가 매개변수 2개를
toAddress의 호출자에게까지 전달해야 하는가?
let toAddress checkAddressExists endPoint credentials unvalidatedAddress =
// checkAddressExists에만 필요 ^ ^
// 원격 서비스 호출
let checkedAddress = checkAddressExists endPoint credentials unvalidatedAddress
// 전달된 2개의 추가 매개변수 ^ ^
...
// 잘못된 방식: checkAddressExists의 세부 의존성이 toAddress의 시그니처까지 새어나감
function toAddressWrong(
checkAddressExists: (endPoint: string, credentials: Credentials, addr: UnvalidatedAddress) => CheckedAddress,
endPoint: string, // checkAddressExists에만 필요
credentials: Credentials, // checkAddressExists에만 필요
unvalidatedAddress: UnvalidatedAddress
): Address {
// 원격 서비스 호출
const checkedAddress = checkAddressExists(endPoint, credentials, unvalidatedAddress);
// 전달된 2개의 추가 매개변수
return {} as Address;
}
- 그렇다면
toAddress의 호출자에게도 이 추가 매개변수를 전달해야 하고, 결국 최상위까지 계속 전파됨
let validateOrder
checkProductExists
checkAddressExists
endPoint // checkAddressExists에만 필요
credentials // checkAddressExists에만 필요
unvalidatedOrder =
...
// 잘못된 방식: checkAddressExists의 세부 의존성이
// 중간 함수들에도 계속 새어나가고 있음
function validateOrderWrong(
checkProductExists: CheckProductCodeExists,
checkAddressExists: (endPoint: string, credentials: Credentials, addr: UnvalidatedAddress) => CheckedAddress,
endPoint: string, // checkAddressExists에만 필요
credentials: Credentials, // checkAddressExists에만 필요
unvalidatedOrder: UnvalidatedOrder
) {
// ...
}
- 당연히 이렇게 하면 안 됨 → 중간 함수들은
checkAddressExists의 의존성에 대해 전혀 알 필요가 없음 - 더 나은 접근: 저수준 함수들을 최상위 함수 바깥에서 미리 설정하고, 이미 모든 의존성이 베이킹된 자식 함수를 그대로 전달
let placeOrder : PlaceOrderWorkflow =
// 설정 정보 초기화 (예: 구성 파일에서)
let endPoint = ...
let credentials = ...
// 자격 증명이 베이킹된 새 버전의 checkAddressExists 생성
let checkAddressExists = checkAddressExists endPoint credentials
// 기타 등등
// 워크플로우 단계 설정
let validateOrder =
validateOrder checkProductCodeExists checkAddressExists
// 새로운 checkAddressExists는 ^
// 1-매개변수 함수임
// 기타 등등
// 워크플로우 함수 반환
fun unvalidatedOrder ->
// 파이프라인 조립
...
const placeOrder: PlaceOrderWorkflow = (() => {
// 설정 정보 초기화 (예: 구성 파일에서)
const endPoint = "https://address-service.example.com";
const credentials = getCredentialsFromConfig();
// 자격 증명이 베이킹된 새 버전의 checkAddressExists 생성
const checkAddressExistsBaked: CheckAddressExists = (addr) =>
checkAddressExistsRaw(endPoint, credentials, addr);
// 기타 등등
// 워크플로우 단계 설정
const validateOrderBaked = validateOrder(checkProductCodeExists)(checkAddressExistsBaked);
// 새로운 checkAddressExists는 1-매개변수 함수임
// 기타 등등
// 워크플로우 함수 반환
return (unvalidatedOrder: UnvalidatedOrder) => {
// 파이프라인 조립
// ...
};
})();
- “프리빌트(prebuilt)” 헬퍼 함수를 전달해 매개변수를 줄이는 이 기법은 복잡성을 숨기는 데 매우 유용
- 한 함수가 다른 함수에 전달될 때, 그 “인터페이스”(함수 타입)는 최대한 최소화하고 모든 의존성은 숨겨야 함
9.7 의존 테스트
- 의존성을 이런 식으로 전달하는 방식의 큰 장점: 특별한 모킹(mocking) 라이브러리 없이도 가짜(fake)지만 동작하는 의존성을 제공할 수 있어 핵심 함수를 매우 쉽게 테스트할 수 있음
- 예: 제품 코드 검증 로직 테스트
checkProductCodeExists가 성공하면 전체 검증이 성공해야 함checkProductCodeExists가 실패하면 전체 검증이 실패해야 함
F# 팁: 이중 백틱(``)으로 감싸면 식별자에 공백과 구두점을 사용할 수 있음. 일반 코드에는 권장되지 않지만, 테스트 출력 가독성을 크게 높여주므로 테스트 함수 이름에는 사용해도 좋음
성공 케이스 (Arrange/Act/Assert 패턴)
open NUnit.Framework
[<Test>]
let ``If product exists, validation succeeds``() =
// arrange: 서비스 의존성의 스텁 버전 설정
let checkAddressExists address =
CheckedAddress address // 성공
let checkProductCodeExists productCode =
true // 성공
// arrange: 입력 설정
let unvalidatedOrder = ...
// act: validateOrder 호출
let result = validateOrder checkProductCodeExists checkAddressExists ...
// assert: 결과가 에러가 아닌 ValidatedOrder인지 확인
...
import { describe, it, expect } from "vitest";
describe("validateOrder", () => {
it("제품 코드가 존재하면 검증이 성공한다", () => {
// arrange: 서비스 의존성의 스텁 버전 설정
const checkAddressExists: CheckAddressExists = (address) => ({
kind: "CheckedAddress",
value: address, // 성공
});
const checkProductCodeExists: CheckProductCodeExists = (_productCode) => true; // 성공
// arrange: 입력 설정
const unvalidatedOrder: UnvalidatedOrder = {
/* ... */
} as UnvalidatedOrder;
// act: validateOrder 호출
const result = validateOrder(checkProductCodeExists)(checkAddressExists)(unvalidatedOrder);
// assert: 결과가 ValidatedOrder인지 확인
expect(result).toBeDefined();
});
});
checkAddressExists,checkProductCodeExists의 스텁 버전은 테스트 안에서 그 자리에서 바로 작성 가능할 정도로 간단함
NUnit 프레임워크로 테스트를 설명했지만, 다른 .NET 테스트 프레임워크나 F#-친화적 라이브러리(FsUnit, Unquote, Expecto, FsCheck)도 사용 가능
실패 케이스
checkProductCodeExists가 모든 제품 코드에 대해 실패하도록 변경
let checkProductCodeExists productCode =
false // 실패
완전한 실패 테스트
[<Test>]
let ``If product doesn't exist, validation fails``() =
// arrange: 서비스 의존성의 스텁 버전 설정
let checkAddressExists address = ...
let checkProductCodeExists productCode =
false // 실패
// arrange: 입력 설정
let unvalidatedOrder = ...
// act: validateOrder 호출
let result = validateOrder checkProductCodeExists checkAddressExists ...
// assert: 결과가 실패인지 확인
...
describe("validateOrder", () => {
it("제품 코드가 존재하지 않으면 검증이 실패한다", () => {
// arrange: 서비스 의존성의 스텁 버전 설정
const checkAddressExists: CheckAddressExists = (address) => ({
kind: "CheckedAddress",
value: address,
});
const checkProductCodeExists: CheckProductCodeExists = (_productCode) => false; // 실패
// arrange: 입력 설정
const unvalidatedOrder: UnvalidatedOrder = {
/* ... */
} as UnvalidatedOrder;
// act & assert: validateOrder 호출 시 예외가 발생하는지 확인
expect(() => validateOrder(checkProductCodeExists)(checkAddressExists)(unvalidatedOrder)).toThrow();
});
});
- 이번 장에서는 서비스 실패를 예외로 표현했는데, 이는 피하고 싶은 방식 → 다음 장에서 수정 예정
- 작은 예시이지만, 함수형 프로그래밍 원칙을 사용한 테스트의 실질적 이점을 이미 확인 가능
validateOrder는 무상태(stateless): 아무것도 뮤테이션하지 않으며, 같은 입력이면 항상 같은 출력 → 테스트가 단순해짐- 모든 의존성이 명시적으로 전달됨 → 동작 방식을 이해하기 쉬움
- 모든 부작용이 함수 자체가 아니라 매개변수에 캡슐화됨 → 테스트가 단순해지고 부작용을 제어하기 쉬움
- 테스트는 큰 주제이지만 지면 관계상 깊이 다루지 않음. 참고할 만한 F#-친화적 테스트 도구는 아래와 같음
| 도구 | 설명 |
|---|---|
| FsUnit | NUnit/XUnit 등 표준 테스트 프레임워크를 F# 친화적 문법으로 래핑 |
| Unquote | 테스트 실패로 이어지는 모든 값을 보여줌 (“스택 언롤링”) |
| FsCheck | 예시 기반 테스트가 아닌 속성 기반(property-based) 테스트를 위한 F#의 대표 라이브러리 |
| Expecto | 특수 어트리뷰트([<Test>]) 없이 표준 함수를 테스트 픽스처로 사용하는 경량 F# 테스트 프레임워크 |
9.8 조립한 파이프라인
- 이번 장에 흩어져 있던 코드 조각들을 모아 완전한 파이프라인이 어떻게 조립되는지 정리
파일 구성 규칙
- 특정 워크플로우를 구현하는 모든 코드는 워크플로우 이름을 딴 동일한 모듈에 둠 (예:
PlaceOrderWorkflow.fs) - 파일 맨 위에 타입 정의를 둠
- 그다음 각 단계의 구현을 둠
- 맨 아래에 단계들을 조합한 메인 워크플로우 함수를 둠
- 지면 절약을 위해 파일 내용의 개요만 표시 (전체 코드는 책과 함께 제공되는 저장소에서 확인 가능)
- 호출자와의 계약에 해당하는 “공개(public)” 타입(예:
PlaceOrderWorkflow함수와 그 입력UnvalidatedOrder)은 API 모듈 등 다른 곳에 정의됨 → 이 파일에는 워크플로우 내부 단계를 나타내는 타입만 포함
타입 정의 부분
module PlaceOrderWorkflow =
// 공유 단순 타입(String50, ProductCode 등)을 사용 가능하게 함
open SimpleTypes
// 호출자에게 노출되는 공개 타입을 사용 가능하게 함
open API
// ==============================
// Part 1: Design
// ==============================
// 참고: 워크플로우의 공개 부분(API) -- `PlaceOrderWorkflow` 함수와
// 그 입력인 `UnvalidatedOrder` 등 -- 은 다른 곳에 정의됨.
// 아래 타입들은 워크플로우 구현에 비공개(private)임.
// ----- Validate Order -----
type CheckProductCodeExists =
ProductCode -> bool
type CheckedAddress =
CheckedAddress of UnvalidatedAddress
type CheckAddressExists =
UnvalidatedAddress -> CheckedAddress
type ValidateOrder =
CheckProductCodeExists // 의존성
-> CheckAddressExists // 의존성
-> UnvalidatedOrder // 입력
-> ValidatedOrder // 출력
// ----- Price order -----
type GetProductPrice = ...
type PriceOrder = ...
// 기타 등등
// PlaceOrderWorkflow.ts
// 공유 단순 타입(String50, ProductCode 등)을 임포트
import * as SimpleTypes from "./SimpleTypes";
// 호출자에게 노출되는 공개 타입을 임포트
import * as API from "./API";
// ==============================
// Part 1: Design
// ==============================
// 참고: 워크플로우의 공개 부분(API) -- PlaceOrderWorkflow 함수와
// 그 입력인 UnvalidatedOrder 등 -- 은 다른 곳에 정의됨.
// 아래 타입들은 워크플로우 구현에 비공개(private)임.
// ----- Validate Order -----
type CheckProductCodeExists = (productCode: ProductCode) => boolean;
interface CheckedAddress {
kind: "CheckedAddress";
value: UnvalidatedAddress;
}
type CheckAddressExists = (unvalidatedAddress: UnvalidatedAddress) => CheckedAddress;
type ValidateOrder =
(checkProductCodeExists: CheckProductCodeExists) =>
(checkAddressExists: CheckAddressExists) =>
(unvalidatedOrder: UnvalidatedOrder) => ValidatedOrder;
// ----- Price order -----
type GetProductPrice = /* ... */ any;
type PriceOrder = /* ... */ any;
// 기타 등등
구현 부분 (요약)
// ==============================
// Part 2: Implementation
// ==============================
// ------------------------------
// ValidateOrder implementation
// ------------------------------
let toCustomerInfo (unvalidatedCustomerInfo: UnvalidatedCustomerInfo) =
...
let toAddress (checkAddressExists:CheckAddressExists) unvalidatedAddress =
...
let predicateToPassthru = ...
let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
...
let toOrderQuantity productCode quantity =
...
let toValidatedOrderLine checkProductExists (unvalidatedOrderLine:UnvalidatedOrderLine) =
...
/// ValidateOrder 단계 구현
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId =
unvalidatedOrder.OrderId
|> OrderId.create
let customerInfo = ...
let shippingAddress = ...
let billingAddress = ...
let lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductCodeExists)
let validatedOrder : ValidatedOrder = {
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = billingAddress
Lines = lines
}
validatedOrder
// ==============================
// Part 2: Implementation
// ==============================
// ------------------------------
// ValidateOrder implementation
// ------------------------------
function toCustomerInfo(unvalidatedCustomerInfo: UnvalidatedCustomerInfo) {
/* ... */
}
function toAddress(checkAddressExists: CheckAddressExists) {
/* ... */
}
function predicateToPassthru<T>(errorMsg: string) {
/* ... */
}
function toProductCode(checkProductCodeExists: CheckProductCodeExists) {
/* ... */
}
function toOrderQuantity(productCode: ProductCode) {
/* ... */
}
function toValidatedOrderLine(checkProductExists: CheckProductCodeExists) {
/* ... */
}
/// ValidateOrder 단계 구현
const validateOrder: ValidateOrder =
(checkProductCodeExists) => (checkAddressExists) => (unvalidatedOrder) => {
const orderId = OrderId.create(unvalidatedOrder.orderId);
const customerInfo = /* ... */ null as any;
const shippingAddress = /* ... */ null as any;
const billingAddress = /* ... */ null as any;
const lines = unvalidatedOrder.lines.map(toValidatedOrderLine(checkProductCodeExists));
const validatedOrder: ValidatedOrder = {
orderId,
customerInfo,
shippingAddress,
billingAddress,
lines,
};
return validatedOrder;
};
- 나머지 단계들의 구현은 생략 (전체 코드는 저장소 참고)
- 파일 맨 아래, 최상위
PlaceOrder함수 구현
// ------------------------------
// The complete workflow
// ------------------------------
let placeOrder
checkProductExists // 의존성
checkAddressExists // 의존성
getProductPrice // 의존성
createOrderAcknowledgmentLetter // 의존성
sendOrderAcknowledgment // 의존성
: PlaceOrderWorkflow = // 함수 정의
fun unvalidatedOrder ->
let validatedOrder =
unvalidatedOrder
|> validateOrder checkProductExists checkAddressExists
let pricedOrder =
validatedOrder
|> priceOrder getProductPrice
let acknowledgmentOption =
pricedOrder
|> acknowledgeOrder createOrderAcknowledgmentLetter sendOrderAcknowledgment
let events =
createEvents pricedOrder acknowledgmentOption
events
// ------------------------------
// The complete workflow
// ------------------------------
const placeOrder =
(checkProductExists: CheckProductCodeExists) => // 의존성
(checkAddressExists: CheckAddressExists) => // 의존성
(getProductPrice: GetProductPrice) => // 의존성
(createOrderAcknowledgmentLetter: CreateOrderAcknowledgmentLetter) => // 의존성
(sendOrderAcknowledgment: SendOrderAcknowledgment): PlaceOrderWorkflow => // 함수 정의
(unvalidatedOrder: UnvalidatedOrder) => {
const validatedOrder = validateOrder(checkProductExists)(checkAddressExists)(unvalidatedOrder);
const pricedOrder = priceOrder(getProductPrice)(validatedOrder);
const acknowledgmentOption = acknowledgeOrder(createOrderAcknowledgmentLetter)(sendOrderAcknowledgment)(pricedOrder);
const events = createEvents(pricedOrder)(acknowledgmentOption);
return events;
};
9.9 마무리
- 이번 장에서는 파이프라인 단계 구현과 의존성 다루기에 집중
- 각 단계의 구현은 하나의 점진적인 변환에만 좁게 집중 → 독립적으로 추론하고 테스트하기 쉬움
- 단계들을 조합할 때 타입이 항상 맞아떨어지지 않아 3가지 중요한 함수형 프로그래밍 기법 도입
| 기법 | 설명 | 이번 장의 적용 예 |
|---|---|---|
| 어댑터 함수 | 한 함수를 다른 “모양”으로 변환 | checkProductCodeExists의 출력을 bool에서 ProductCode로 변경 (predicateToPassthru) |
| 리프팅(lifting) | 서로 다른 타입들을 공통 타입으로 승격 | 여러 이벤트를 공통 PlaceOrderEvent 타입(과 리스트)으로 변환 |
| 부분 적용 | 의존성을 함수에 미리 베이킹 | validateOrder, priceOrder, acknowledgeOrder에 의존성을 부분 적용해 조합을 쉽게 하고 구현 세부사항을 호출자로부터 숨김 |
- 이 세 기법은 책의 나머지 부분에서도 계속 재사용됨
- 아직 다루지 않은 영역: 효과(effects) 다루기
- 이번 장에서는 효과 대신 예외로 에러 처리 → 조합에는 편리했지만 문서화 측면에서는 좋지 않음 (함수 시그니처가 실제 동작을 숨기는 “기만적인” 형태가 됨)
- 다음 장에서 함수 타입에
Result타입을 다시 추가하고, 이를 다루는 방법 학습 예정
챕터 10 구현: 오류 처리하기
표기법 안내 원서(영문판) 본문은 F#의
Result<Success, Failure>타입(Ok/Error)을 기준으로 설명함. 이 노트에서는 목차 규칙에 맞춰Either명명법을 사용하며, 아래와 같이 대응시킴.
| F# (원서) | Either 표기 (본 노트) | 의미 |
|---|---|---|
Result<Success, Failure> |
Either<L, R> |
성공/실패를 표현하는 타입 |
Ok x |
Right x |
성공 값 |
Error e |
Left e |
실패 값 |
Result.bind |
flatMap |
스위치 함수 연결 어댑터 |
Result.map |
map |
1-트랙 함수 변환 어댑터 |
Result.mapError |
mapLeft |
실패 타입 변환 어댑터 |
- 상품 코드 오류, 고객명 길이 초과, 주소 검증 서비스 타임아웃 등 모든 시스템에는 오류가 존재함. 오류를 일관되고 투명하게 다루는 것이 프로덕션급 시스템의 핵심 조건임.
- 9장에서는 합성과 의존성에 집중하기 위해 파이프라인에서 오류 이펙트(Either)를 의도적으로 제거했었음. 10장에서는 이 Either를 다시 타입 시그니처에 복원하고, 다루는 기법을 학습함.
- 함수형 오류 처리 기법의 핵심: 지저분한 조건문·try/catch 없이도 오류를 우아하게 포착하는 것.
- 특정 오류는 “도메인 오류”로 취급하여 나머지 도메인 모델링과 동일한 수준으로 설계에 반영해야 함.
10.1 Either 타입으로 오류 드러내기
- 함수형 프로그래밍은 명시성(explicitness)을 추구함 → 오류 처리도 예외가 아님.
- 성공/실패 여부, 실패 시 오류 종류까지 시그니처에 명시하는 total function(154p 참고)을 만드는 것이 목표.
- 기존(9장) 방식: 예외로 오류를 표현 → 시그니처가 실제 동작을 감춤.
// 오류 가능성이 시그니처에 전혀 드러나지 않음
type CheckAddressExists =
UnvalidatedAddress -> CheckedAddress
// TS도 동일한 문제 - 실패 가능성이 타입에 없음
type CheckAddressExists = (address: UnvalidatedAddress) => CheckedAddress;
- Either(Result) 타입을 사용하면 성공/실패 각각의 출력 타입을 명시할 수 있음.
type CheckAddressExists =
UnvalidatedAddress -> Result<CheckedAddress, AddressValidationError>
and AddressValidationError =
| InvalidFormat of string
| AddressNotFound of string
type AddressValidationError =
| { kind: "InvalidFormat"; message: string }
| { kind: "AddressNotFound"; message: string };
type CheckAddressExists = (
address: UnvalidatedAddress
) => Either<AddressValidationError, CheckedAddress>;
- 이 시그니처 하나로 알 수 있는 것
- 입력은
UnvalidatedAddress. - 검증 성공 시 출력은 (다를 수 있는)
CheckedAddress. - 실패 시 원인은 형식 오류 또는 주소 미발견 두 가지뿐.
- 입력은
- 함수 시그니처 자체가 문서 역할을 함. 다른 개발자가 시그니처만 보고도 함수의 동작을 파악 가능.
10.2 도메인 오류 다루기
- 모든 오류를 타입으로 표현할 수도, 표현할 필요도 없음 → 먼저 오류를 분류하는 일관된 기준이 필요함.
| 구분 | 정의 | 예시 | 처리 방향 |
|---|---|---|---|
| 도메인 오류 | 비즈니스 프로세스의 일부로 예상되는 오류. 도메인 설계에 반드시 포함되어야 함 | 빌링에서 거부된 주문, 잘못된 상품 코드 | 비즈니스 프로세스가 이미 대응 절차를 갖고 있음. 코드가 이를 반영해야 함 |
| 패닉 | 시스템을 알 수 없는 상태로 만드는 오류 | 메모리 부족 같은 처리 불가능한 시스템 오류, 0으로 나누기·널 참조 같은 프로그래머 실수 | 최상위 레벨(main 함수 등)에서 예외로 잡아 워크플로우 자체를 포기 |
| 인프라 오류 | 아키텍처상 예상되지만 비즈니스 프로세스에는 포함되지 않는 오류 | 네트워크 타임아웃, 인증 실패 | 패닉/도메인 오류 두 방식 모두 가능. 아키텍처에 따라 선택 |
-
도메인 오류인지 애매하면 도메인 전문가에게 직접 확인해야 함. 저자는 로드밸런서 연결 중단 오류를 예로 들며, 담당자(Ollie)가 특별한 관심을 보이지 않자 이를 인프라 오류로 분류하고 “나중에 다시 시도하라”는 안내로 처리하기로 결정한 사례를 소개함 → 애매한 경계는 개발팀 단독 판단이 아니라 논의를 통해 정해야 함을 보여줌.
-
패닉 처리 예시 — 워크플로우 중간에서 예외를 던지고, 최상위 함수에서 한번에 잡음.
/// 잘못된 입력이 들어오면 패닉을 일으키는 워크플로우
let workflowPart2 input =
if input = 0 then
raise (DivideByZeroException())
// ...
/// 애플리케이션 최상위 함수
/// 모든 워크플로우의 예외를 여기서 잡음
let main () =
try
let result1 = workflowPart1 ()
let result2 = workflowPart2 result1
printfn "the result is %A" result2
with
| :? OutOfMemoryException -> printfn "exited with OutOfMemoryException"
| :? DivideByZeroException -> printfn "exited with DivideByZeroException"
| ex -> printfn "exited with %s" ex.Message
function workflowPart2(input: number): number {
if (input === 0) {
throw new RangeError("DivideByZeroException");
}
// ...
return input;
}
function main(): void {
try {
const result1 = workflowPart1();
const result2 = workflowPart2(result1);
console.log("the result is", result2);
} catch (ex) {
if (ex instanceof RangeError) {
console.error("exited with RangeError:", ex.message);
} else {
console.error("exited with", ex);
}
}
}
- 인프라 오류는 아키텍처에 따라 선택: 마이크로서비스처럼 작은 서비스가 많으면 예외가 더 깔끔할 수 있고, 모놀리식이면 명시적 처리가 나을 수 있음. 원격 주소 검증 서비스가 응답 불가할 때 고객에게 무엇을 안내할지 같은 문제는 개발팀만으로 결정할 수 없고 도메인 전문가·제품 책임자와 함께 정해야 함.
- 이후 챕터 내용은 도메인의 일부로 명시적으로 모델링하려는 오류에만 집중함. 패닉이나 모델링하지 않기로 한 오류는 예외를 던지고 최상위에서 잡는 방식을 그대로 사용.
10.2.1 타입으로 도메인 오류 모델링하기
- 도메인 모델링 시 원시 타입(string 등) 대신 도메인 특화 타입을 쓴 것처럼, 오류도 동일하게 대우해야 함.
- 도메인 논의 중 등장하는 오류는 선택 타입(choice type)으로 모델링. 각 오류 case가 별도로 관리해야 할 상황을 나타냄.
type PlaceOrderError =
| ValidationError of string
| ProductOutOfStock of ProductCode
| RemoteServiceError of RemoteServiceError
// ...
type PlaceOrderError =
| { kind: "ValidationError"; message: string }
| { kind: "ProductOutOfStock"; productCode: ProductCode }
| { kind: "RemoteServiceError"; error: RemoteServiceError };
ValidationError: 속성 검증(길이·형식 등) 오류.ProductOutOfStock: 재고 없는 상품을 주문하는 경우. 별도 비즈니스 프로세스가 있을 수 있음.RemoteServiceError: 인프라 오류를 도메인 오류처럼 다루는 예. 단순히 예외를 던지는 대신, 예를 들어 일정 횟수 재시도 후 포기하는 식으로 처리 가능.- 선택 타입의 장점
- 코드에서 발생 가능한 모든 상황을 명시적으로 문서화함.
- 오류에 딸린 추가 정보도 명시적으로 드러남.
- 요구사항 변화에 따라 case를 추가/삭제하기 쉬움.
- 컴파일러가 exhaustiveness(누락된 case)를 경고해줌 → 안전함. TypeScript에서도
never타입을 활용한 switch exhaustiveness 체크로 동일 효과를 낼 수 있음.
function handleError(err: PlaceOrderError): string {
switch (err.kind) {
case "ValidationError":
return err.message;
case "ProductOutOfStock":
return `품절: ${err.productCode}`;
case "RemoteServiceError":
return "일시적 오류, 나중에 다시 시도하세요";
default: {
// 새 case가 추가되면 여기서 컴파일 에러 발생
const _exhaustive: never = err;
return _exhaustive;
}
}
}
- 설계 초기 단계(7장)에서는 오류의 세부 종류를 굳이 정하지 않아도 됨. 오류 case는 보통 개발 과정에서 드러나며, 그때마다 도메인 오류로 볼지 판단해 선택 타입에 추가.
- 선택 타입에 새 case를 추가하면 기존 패턴 매칭 코드 곳곳에서 경고가 발생 → 이는 “이 case에서 정확히 무엇을 할지” 도메인 전문가/제품 책임자와 논의하도록 강제하는 안전장치가 됨.
10.2.2 코드를 어지럽히는 오류 처리
- 예외의 장점: “해피 패스” 코드가 깔끔하게 유지됨.
let validateOrder unvalidatedOrder =
let orderId = ... // 생성 실패 시 예외
let customerInfo = ... // 생성 실패 시 예외
let shippingAddress = ... // 생성 및 검증, 실패 시 예외
// 등등
- 각 단계마다 Either를 반환하도록 바꾸면 조건문과 try/catch가 코드 곳곳에 뒤섞이게 됨.
let validateOrder unvalidatedOrder =
let orderIdResult = ... // 실패 시 Error 반환
if orderIdResult is Error then
return
let customerInfoResult = ... // 실패 시 Error 반환
if customerInfoResult is Error then
return
try
let shippingAddressResult = ... // 실패 시 Error 반환
if shippingAddress is Error then
return
// ...
with
| ?: TimeoutException -> Error "service timed out"
| ?: AuthenticationException -> Error "bad credentials"
// 등등
- 문제점: 코드의 2/3가 오류 처리 로직으로 채워짐 → 원래의 단순하고 깔끔한 코드가 훼손됨.
- 던져진 질문: 파이프라인 모델의 우아함을 유지하면서도 제대로 된 오류 처리를 도입할 방법은 무엇인가? → 10.3절 이하에서 답을 찾음.
10.3 Either 타입을 출력하는 함수 연결하기
- 일반 함수는 하나의 철로(트랙)로 시각화할 수 있음.
flowchart LR
A[입력] --> B[일반 함수] --> C[출력]
- Either를 출력하는 함수는 철로가 두 갈래(성공/실패)로 갈라지는 모습으로 시각화 가능.
flowchart LR
A[입력] --> B{Either 반환 함수}
B -->|성공 Right| S[Success]
B -->|실패 Left| F[Failure]
- 이런 함수를 **스위치 함수(switch function)**라고 부름. FP 세계에서는 흔히 “모나딕(monadic) 함수”라고도 함.
- 두 스위치 함수를 연결할 때: 성공이면 다음 함수로 진행하고, 실패면 다음 함수를 우회해야 함.
flowchart LR
In[입력] --> S1{스위치 함수 1}
S1 -->|성공| S2{스위치 함수 2}
S1 -->|실패| Fail[Failure 트랙]
S2 -->|성공| Succ[Success 트랙]
S2 -->|실패| Fail
- 이렇게 모든 단계를 연결하면 “이중 선로(two-track)” 모델, 즉 **철도 지향 프로그래밍(railroad-oriented programming)**이 완성됨.
flowchart LR
In[입력] --> A{단계 1}
A -->|성공| B{단계 2}
A -->|실패| ERR[실패 트랙]
B -->|성공| C{단계 3}
B -->|실패| ERR
C -->|성공| OUT[최종 성공]
C -->|실패| ERR
ERR --> OUT2[최종 실패]
- 위쪽 트랙이 해피 패스, 아래쪽 트랙이 실패 패스. 성공하는 한 위 트랙에 머물고, 오류가 나면 아래 트랙으로 옮겨져 나머지 단계를 모두 건너뜀.
- 문제: 이렇게 좋아 보이지만, 스위치 함수를 그대로 연결할 수 없음 — 2-트랙 출력의 타입과 1-트랙 입력의 타입이 다르기 때문.
flowchart LR
A[1-트랙 입력] --> B{스위치 함수 - 2트랙 출력}
B -.실패.-> X1[호환 안됨]
B --> C{스위치 함수 2 - 1트랙 입력 필요}
X1 --- C
- 해법의 실마리: 두 번째 함수가 애초에 2-트랙 입력을 받는다면 문제없이 연결 가능.
flowchart LR
A[2-트랙 입력] --> B[2-트랙 함수]
B --> C[2-트랙 출력]
10.3.1 어댑터 블록 구현
- 필요한 것: 입력 1개·출력 2개인 “스위치” 함수를, 입력도 2트랙 출력도 2트랙인 함수로 바꿔주는 “어댑터 블록”.
flowchart LR
In[2-트랙 입력] --> D{Right or Left?}
D -->|Right| Slot[내부 슬롯: 스위치 함수 실행]
D -->|Left| Bypass[그대로 통과]
Slot --> Out[2-트랙 출력]
Bypass --> Out
- 이 어댑터가 바로 FP 툴킷에서 매우 중요한
bind(=flatMap)임.- 입력이
Right(성공)이면 스위치 함수를 실행 → 결과는 이미 2트랙이므로 그대로 반환. - 입력이
Left(실패)이면 스위치 함수를 건너뛰고 실패를 그대로 반환.
- 입력이
// 람다 버전
let bind switchFn =
fun twoTrackInput ->
match twoTrackInput with
| Ok success -> switchFn success
| Error failure -> Error failure
// 커링된, 더 흔히 쓰는 버전 (두 버전은 동치)
let bind switchFn twoTrackInput =
match twoTrackInput with
| Ok success -> switchFn success
| Error failure -> Error failure
type Either<L, R> =
| { _tag: "Left"; left: L }
| { _tag: "Right"; right: R };
const left = <L, R = never>(l: L): Either<L, R> => ({ _tag: "Left", left: l });
const right = <R, L = never>(r: R): Either<L, R> => ({ _tag: "Right", right: r });
function flatMap<L, R, R2>(
switchFn: (r: R) => Either<L, R2>
): (input: Either<L, R>) => Either<L, R2> {
return (input) => (input._tag === "Right" ? switchFn(input.right) : input);
}
- 또 하나의 유용한 어댑터: 1-트랙 함수를 2-트랙 함수로 바꾸는 것 → FP 세계에서 흔히
map이라 부름.
flowchart LR
In2[2-트랙 입력] --> D2{Right or Left?}
D2 -->|Right| Slot2[내부 슬롯: 1-트랙 함수 실행 후 Right로 감싸기]
D2 -->|Left| Bypass2[그대로 통과]
Slot2 --> Out2[2-트랙 출력]
Bypass2 --> Out2
- 입력이 성공이면 1트랙 함수를 실행하고 결과를 다시
Right로 감쌈(2-트랙이 되어야 하므로). 실패면 그대로 우회.
let map f aResult =
match aResult with
| Ok success -> Ok (f success)
| Error failure -> Error failure
function map<L, R, R2>(
f: (r: R) => R2
): (input: Either<L, R>) => Either<L, R2> {
return (input) => (input._tag === "Right" ? right(f(input.right)) : input);
}
bind,map과 몇몇 유사 함수만으로도 형태가 맞지 않는 함수들을 자유롭게 합성할 수 있는 강력한 툴킷이 완성됨.
10.3.2 Either 함수들 관리하기
- 이런 함수들은 타입과 동일한 이름의 모듈에 두는 것이 표준 관행. F#에서는
Result모듈.
/// Result 타입 정의
type Result<'Success,'Failure> =
| Ok of 'Success
| Error of 'Failure
/// Result와 함께 동작하는 함수들
module Result =
let bind f aResult = ...
let map f aResult = ...
Result(Either)와 관련 함수들은 도메인 전역에서 사용되므로, 보통 별도 유틸리티 모듈(Result.fs)로 만들어 프로젝트 구조상 도메인 타입들보다 앞에 배치함.
// either.ts — 도메인 타입보다 먼저 위치시키는 유틸리티 모듈
export type Either<L, R> =
| { _tag: "Left"; left: L }
| { _tag: "Right"; right: R };
export const Either = {
left: <L, R = never>(l: L): Either<L, R> => ({ _tag: "Left", left: l }),
right: <R, L = never>(r: R): Either<L, R> => ({ _tag: "Right", right: r }),
flatMap:
<L, R, R2>(switchFn: (r: R) => Either<L, R2>) =>
(input: Either<L, R>): Either<L, R2> =>
input._tag === "Right" ? switchFn(input.right) : input,
map:
<L, R, R2>(f: (r: R) => R2) =>
(input: Either<L, R>): Either<L, R2> =>
input._tag === "Right" ? Either.right(f(input.right)) : input,
mapLeft:
<L, L2, R>(f: (l: L) => L2) =>
(input: Either<L, R>): Either<L2, R> =>
input._tag === "Left" ? Either.left(f(input.left)) : input,
};
10.3.3 함수 합성과 타입 검사
- 성공 트랙에서는 각 단계의 출력 타입이 다음 단계의 입력 타입과 일치하는 한 타입이 바뀌어도 됨.
type FunctionA = Apple -> Result<Bananas,...>
type FunctionB = Bananas -> Result<Cherries,...>
type FunctionC = Cherries -> Result<Lemon,...>
let functionA : FunctionA = ...
let functionB : FunctionB = ...
let functionC : FunctionC = ...
let functionABC input =
input
|> functionA
|> Result.bind functionB
|> Result.bind functionC
type FunctionA = (a: Apple) => Either<AppleError, Bananas>;
type FunctionB = (b: Bananas) => Either<BananaError, Cherries>;
type FunctionC = (c: Cherries) => Either<CherryError, Lemon>;
const functionABC = (input: Apple) =>
pipe(
input,
functionA,
Either.flatMap(functionB),
Either.flatMap(functionC)
);
// pipe 유틸 (F#의 |> 연산자에 대응)
function pipe<A>(a: A, ...fns: Array<(x: any) => any>): any {
return fns.reduce((acc, fn) => fn(acc), a);
}
FunctionA와FunctionC는 타입이 다르므로bind로도 직접 연결 불가능 — Apple → Cherry 로 바로 연결할 수 없음.- 반면 실패 트랙은 파이프라인 전체에서 동일한 타입이어야 함. 즉 모든 단계의 오류 타입이 같아야 함.
10.3.4 공통 오류 타입으로 변환
- 대부분의 경우 오류 타입들을 서로 호환되도록 조정해야 함.
map과 유사하지만 실패 트랙 값에 작용하는 함수가 필요 →mapError.
let mapError f aResult =
match aResult with
| Ok success -> Ok success
| Error failure -> Error (f failure)
function mapLeft<L, L2, R>(
f: (l: L) => L2
): (input: Either<L, R>) => Either<L2, R> {
return (input) => (input._tag === "Left" ? left(f(input.left)) : input);
}
- 예시:
AppleError,BananaError를 쓰는 두 함수를 합성해야 하는 경우.
type FunctionA = Apple -> Result<Bananas,AppleError>
type FunctionB = Bananas -> Result<Cherries,BananaError>
- 두 오류 타입 그대로는 합성 불가 → 두 오류를 모두 담을 수 있는 새 선택 타입
FruitError를 만듦.
type FruitError =
| AppleErrorCase of AppleError
| BananaErrorCase of BananaError
let functionAWithFruitError input =
input
|> functionA
|> Result.mapError (fun appleError -> AppleErrorCase appleError)
// 단순화
let functionAWithFruitError input =
input |> functionA |> Result.mapError AppleErrorCase
flowchart LR
A["Either<AppleError, Bananas>"] -->|mapError AppleErrorCase| B["Either<FruitError, Bananas>"]
functionA와functionAWithFruitError의 시그니처를 비교하면 실패 타입만 바뀐 것을 알 수 있음.
// functionA의 타입
Apple -> Result<Bananas,AppleError>
// functionAWithFruitError의 타입
Apple -> Result<Bananas,FruitError>
functionB도 동일하게 변환한 뒤bind로 합성.
let functionA : FunctionA = ...
let functionB : FunctionB = ...
// FruitError를 쓰도록 functionA 변환
let functionAWithFruitError input =
input |> functionA |> Result.mapError AppleErrorCase
// FruitError를 쓰도록 functionB 변환
let functionBWithFruitError input =
input |> functionB |> Result.mapError BananaErrorCase
// 변환된 버전들을 bind로 합성
let functionAB input =
input
|> functionAWithFruitError
|> Result.bind functionBWithFruitError
// 최종 시그니처
// val functionAB : Apple -> Result<Cherries,FruitError>
type FruitError =
| { kind: "AppleErrorCase"; error: AppleError }
| { kind: "BananaErrorCase"; error: BananaError };
const functionAWithFruitError = (input: Apple) =>
pipe(input, functionA, mapLeft((e: AppleError): FruitError => ({ kind: "AppleErrorCase", error: e })));
const functionBWithFruitError = (input: Bananas) =>
pipe(input, functionB, mapLeft((e: BananaError): FruitError => ({ kind: "BananaErrorCase", error: e })));
const functionAB = (input: Apple): Either<FruitError, Cherries> =>
pipe(input, functionAWithFruitError, flatMap(functionBWithFruitError));
10.4 flatMap과 map으로 파이프라인 조립하기
- 지금까지 배운 개념을 실제 주문 파이프라인에 적용. Async 이펙트와 서비스 의존성은 잠시 제외하고 Either에만 집중.
// ValidateOrder: 입력 형식이 틀리면 실패하는 스위치 함수
type ValidateOrder =
UnvalidatedOrder -> Result<ValidatedOrder, ValidationError>
// PriceOrder: 다양한 이유로 실패 가능한 스위치 함수
type PriceOrder =
ValidatedOrder -> Result<PricedOrder, PricingError>
// AcknowledgeOrder / CreateEvents: 항상 성공하는 1-트랙 함수
type AcknowledgeOrder =
PricedOrder -> OrderAcknowledgmentSent option
type CreateEvents =
PricedOrder -> OrderAcknowledgmentSent option -> PlaceOrderEvent list
type ValidateOrder = (o: UnvalidatedOrder) => Either<ValidationError, ValidatedOrder>;
type PriceOrder = (o: ValidatedOrder) => Either<PricingError, PricedOrder>;
type AcknowledgeOrder = (o: PricedOrder) => OrderAcknowledgmentSent | undefined;
type CreateEvents = (o: PricedOrder, ack: OrderAcknowledgmentSent | undefined) => PlaceOrderEvent[];
ValidateOrder와PriceOrder의 실패 타입이 다름(ValidationErrorvsPricingError) → 그대로 합성 불가. 파이프라인 전체에서 쓸 공통 오류 타입PlaceOrderError가 필요.
type PlaceOrderError =
| Validation of ValidationError
| Pricing of PricingError
let validateOrderAdapted input =
input
|> validateOrder
|> Result.mapError PlaceOrderError.Validation
let priceOrderAdapted input =
input
|> priceOrder
|> Result.mapError PlaceOrderError.Pricing
let placeOrder unvalidatedOrder =
unvalidatedOrder
|> validateOrderAdapted
|> Result.bind priceOrderAdapted
- 맨 앞의
validateOrderAdapted는bind가 필요 없음(파이프라인 첫 단계이므로). acknowledgeOrder,createEvents는 오류가 없는 1-트랙 함수 →map으로 2-트랙 함수로 변환해 슬롯에 끼워 넣음.
let placeOrder unvalidatedOrder =
unvalidatedOrder
|> validateOrderAdapted
|> Result.bind priceOrderAdapted
|> Result.map acknowledgeOrder // map으로 2-트랙 변환
|> Result.map createEvents // map으로 2-트랙 변환
// 시그니처: UnvalidatedOrder -> Result<PlaceOrderEvent list,PlaceOrderError>
type PlaceOrderError =
| { kind: "Validation"; error: ValidationError }
| { kind: "Pricing"; error: PricingError };
const validateOrderAdapted = (input: UnvalidatedOrder) =>
pipe(input, validateOrder, mapLeft((e): PlaceOrderError => ({ kind: "Validation", error: e })));
const priceOrderAdapted = (input: ValidatedOrder) =>
pipe(input, priceOrder, mapLeft((e): PlaceOrderError => ({ kind: "Pricing", error: e })));
const placeOrder = (unvalidatedOrder: UnvalidatedOrder) =>
pipe(
unvalidatedOrder,
validateOrderAdapted,
flatMap(priceOrderAdapted),
map(acknowledgeOrder),
map(createEvents) // 실제로는 시그니처 불일치로 컴파일 안 됨 (아래 참고)
);
- 이 버전의 파이프라인 분석
- 각 함수가 낼 수 있는 오류가 시그니처에 그대로 드러남 → 조합 시 예상치 못한 동작 없이 각 함수를 독립적으로 테스트 가능.
- 함수들은 여전히 체이닝되어 있지만 이제 이중 선로 모델을 사용. 한 단계의 오류가 나머지 단계를 모두 건너뛰게 함.
- 최상위
placeOrder의 흐름은 여전히 깔끔함 — 특별한 조건문이나 try/catch가 없음.
- 주의: 위
placeOrder는 실제로는 컴파일되지 않음.acknowledgeOrder의 출력이createEvents의 입력과 형태가 맞지 않기 때문(출력은 이벤트뿐이고,createEvents는 가격 책정된 주문도 필요로 함). 이 문제는 이후(10.6절 계산식/Do 표기법 및 이후 장)에서 해결함.
10.5 다른 유형의 함수들 이중 선로 모델에 적응시키기
- 지금까지 다룬 형태: 1-트랙 함수, 스위치 함수. 이 외에도 흔히 마주치는 두 가지 유형이 있음.
- 예외를 던지는 함수
- 아무것도 반환하지 않는 “막다른 길(dead-end)” 함수
10.5.1 예외 처리
- 우리 코드는 예외를 피했지만, 우리가 제어하지 않는 라이브러리·서비스 코드는 예외를 던질 수 있음. 이런 예외를 도메인의 일부로 다루고 싶다면 예외 발생 함수를 Either 반환 함수로 바꿔주는 또 다른 어댑터 블록이 필요함.
flowchart LR
In[1-트랙 입력] --> Slot[내부 슬롯: 예외 던지는 함수 실행]
Slot -->|정상 반환| Ok3[Right 결과]
Slot -->|지정된 예외 발생| Err3[Left 오류]
- 원격 서비스의 타임아웃을
RemoteServiceError로 변환하는 예시. 먼저 오류를 일으킨 서비스를 추적할ServiceInfo타입을 정의.
type ServiceInfo = {
Name : string
Endpoint: Uri
}
type RemoteServiceError = {
Service : ServiceInfo
Exception : System.Exception
}
type ServiceInfo = { name: string; endpoint: string };
type RemoteServiceError = { service: ServiceInfo; exception: Error };
- 서비스 정보와 원본 서비스 함수를 어댑터 블록에 넣어, 일부 예외를 잡아 Either로 반환하도록 함(단일 매개변수
x인 경우).
/// 예외를 던지는 서비스를 Either를 반환하는 서비스로 변환하는 "어댑터 블록"
let serviceExceptionAdapter serviceInfo serviceFn x =
try
Ok (serviceFn x)
with
| :? TimeoutException as ex ->
Error {Service=serviceInfo; Exception=ex}
| :? AuthorizationException as ex ->
Error {Service=serviceInfo; Exception=ex}
function serviceExceptionAdapter<X, R>(
serviceInfo: ServiceInfo,
serviceFn: (x: X) => R
): (x: X) => Either<RemoteServiceError, R> {
return (x) => {
try {
return right(serviceFn(x));
} catch (ex) {
if (ex instanceof TimeoutError || ex instanceof AuthorizationError) {
return left({ service: serviceInfo, exception: ex });
}
throw ex; // 도메인과 무관한 예외는 그대로 던져 최상위에서 처리
}
};
}
- 주의: 발생 가능한 모든 예외가 아니라 도메인과 관련된 예외만 잡음.
- 서비스 함수가 매개변수를 2개 받는다면 별도의 어댑터(
serviceExceptionAdapter2)가 필요함. 데이터베이스 예외를 “레코드 없음”, “중복 키” 같은 도메인 친화적 케이스를 가진DatabaseError선택 타입으로 바꾸는 등 서비스 전용 커스텀 어댑터를 만드는 경우도 있음. - 실제 사용 예 — 주소 검증 서비스.
let serviceInfo = {
Name = "AddressCheckingService"
Endpoint = ...
}
// 예외를 던지는 서비스
let checkAddressExists address = ...
// Either를 반환하는 서비스
let checkAddressExistsR address =
let adaptedService =
serviceExceptionAdapter serviceInfo checkAddressExists
adaptedService address
const serviceInfo: ServiceInfo = {
name: "AddressCheckingService",
endpoint: "https://...",
};
// 예외를 던지는 서비스
declare function checkAddressExists(address: UnvalidatedAddress): CheckedAddress;
// Either를 반환하는 서비스
const checkAddressExistsR = (address: UnvalidatedAddress) => {
const adaptedService = serviceExceptionAdapter(serviceInfo, checkAddressExists);
return adaptedService(address);
};
- 원본 함수 시그니처(
UnvalidatedAddress -> CheckedAddress)는 실패 가능성을 숨기고 있었지만, 새 함수의 시그니처는 명시적임.
checkAddressExistsR :
UnvalidatedAddress -> Result<CheckedAddress,RemoteServiceError>
- 이 함수를 파이프라인에서 쓰려면
PlaceOrderError에 원격 오류 케이스를 추가하고,mapError로 승격해야 함.
type PlaceOrderError =
| Validation of ValidationError
| Pricing of PricingError
| RemoteService of RemoteServiceError // 신규!
let checkAddressExistsR address =
let adaptedService =
serviceExceptionAdapter serviceInfo checkAddressExists
address
|> adaptedService
|> Result.mapError RemoteService // PlaceOrderError로 승격
type PlaceOrderError =
| { kind: "Validation"; error: ValidationError }
| { kind: "Pricing"; error: PricingError }
| { kind: "RemoteService"; error: RemoteServiceError }; // 신규!
const checkAddressExistsR = (address: UnvalidatedAddress) =>
pipe(
address,
serviceExceptionAdapter(serviceInfo, checkAddressExists),
mapLeft((e): PlaceOrderError => ({ kind: "RemoteService", error: e }))
);
10.5.2 막다른 길 함수 처리
- 또 다른 흔한 함수 유형: “막다른 길(dead-end)” 혹은 “불(fire-and-forget)” 함수 — 입력은 받지만 출력이 없음.
- 대부분 I/O에 씀(DB 기록, 큐 게시 등). 예: 로깅 함수는 출력이 없음.
// string -> unit
let logError msg =
printfn "ERROR %s" msg
// (msg: string) => void
function logError(msg: string): void {
console.error(`ERROR ${msg}`);
}
- 이런 함수를 이중 선로 파이프라인에 넣으려면 또 다른 어댑터 블록이 필요. 먼저 부수 효과를 실행한 뒤 원래 입력을 그대로 반환하는 패스스루 함수
tee를 만듦.
flowchart LR
X[입력 x] --> F["dead-end 함수 f 실행 (부수효과)"]
X --> Pass[x 그대로 반환]
// ('a -> unit) -> ('a -> 'a)
let tee f x =
f x
x
function tee<A>(f: (a: A) => void): (a: A) => A {
return (a) => {
f(a);
return a;
};
}
tee의 출력을Result.map(=map)으로 2-트랙 함수로 변환.
// ('a -> unit) -> (Result<'a,'error> -> Result<'a,'error>)
let adaptDeadEnd f =
Result.map (tee f)
function adaptDeadEnd<A, L>(
f: (a: A) => void
): (input: Either<L, A>) => Either<L, A> {
return map(tee(f));
}
flowchart LR
In[2-트랙 입력] --> D{Right or Left?}
D -->|Right| T["tee(f) 실행: 부수효과 후 값 통과"]
D -->|Left| Pass2[그대로 통과]
T --> Out[2-트랙 출력, 메인 흐름 유지]
Pass2 --> Out
- 이제
logError같은 dead-end 함수를 파이프라인에 그대로 끼워 넣을 수 있는 2-트랙 함수로 변환 가능.
10.6 복잡한 파이프라인 다루기
-
지금까지는 단순한 오류 처리 로직만 다룸.
bind로 Either 반환 함수들을 체이닝하고, 어댑터로 다른 형태의 함수들을 맞춰 넣음. -
하지만 조건 분기, 반복문, 중첩된 Either 반환 함수가 얽히는 복잡한 워크플로우도 있음 → 언어마다
bind의 번거로움을 감춰주는 문법 설탕(computation expression, do-notation 등)을 제공. -
F#의 계산 표현식(computation expression):
bind와return두 함수만 있으면 커스텀 계산 표현식을 만들 수 있음. Either용으로result(소문자)라는 표현식을 정의.
type ResultBuilder() =
member this.Return(x) = Ok x
member this.Bind(x,f) = Result.bind f x
let result = ResultBuilder()
- 사용 예 — 기존
bind체인 버전.
let placeOrder unvalidatedOrder =
unvalidatedOrder
|> validateOrderAdapted
|> Result.bind priceOrderAdapted
|> Result.map acknowledgeOrder
|> Result.map createEvents
- 계산 표현식 버전 —
let!로 Either를 벗겨서 일반 값처럼 다룸.
let placeOrder unvalidatedOrder =
result {
let! validatedOrder =
validateOrder unvalidatedOrder
|> Result.mapError PlaceOrderError.Validation
let! pricedOrder =
priceOrder validatedOrder
|> Result.mapError PlaceOrderError.Pricing
let acknowledgmentOption =
acknowledgeOrder pricedOrder
let events =
createEvents pricedOrder acknowledgmentOption
return events
}
- 동작 원리
result { ... }블록으로 감쌈.let!은 겉보기엔let이지만 실제로는 Either를 “벗겨서” 내부 값을 꺼냄(=bind를 자동 적용).- 오류 타입은 블록 전체에서 동일해야 하므로 여전히
Result.mapError로 승격 필요. 오류가 표현식에 명시적으로 드러나진 않지만, 타입은 여전히 일치해야 함. - 마지막 줄의
return이 블록 전체의 값을 결정함. bind가 필요 없는 함수(acknowledgeOrder등)는 그냥 일반 문법으로 사용 —Result.map이 필요 없음.
- 계산 표현식은 합성 가능함.
validateOrder,priceOrder가 각각result { }로 정의돼 있다면 더 큰result표현식 안에서 일반 함수처럼 쓸 수 있음.
let validateOrder input = result {
let! validatedOrder = ...
return validatedOrder
}
let priceOrder input = result {
let! pricedOrder = ...
return pricedOrder
}
let placeOrder unvalidatedOrder = result {
let! validatedOrder = validateOrder unvalidatedOrder
let! pricedOrder = priceOrder validatedOrder
// ...
return ...
}
- TypeScript에는 F#의 계산 표현식 같은 언어 내장 문법 설탕이 없음 → 대신
pipe+flatMap체이닝을 쓰거나, 아래 10.6.1의 fp-tsDo표기법으로 유사한 가독성을 얻을 수 있음.
10.6.1 fp-ts의 do 표기법
- fp-ts 라이브러리는
Do표기법으로 F#의 계산 표현식과 유사한 가독성을 제공함.E.Do로 시작해E.bind로 이름 붙은 값을 하나씩 누적함.
import { pipe } from "fp-ts/function";
import * as E from "fp-ts/Either";
const validateOrderStep = pipe(
E.Do,
E.bind("orderId", () => OrderId.create(unvalidatedOrder.orderId)),
E.bind("customerInfo", () => toCustomerInfo(unvalidatedOrder.customerInfo)),
E.bind("shippingAddress", () =>
toAddress(checkAddressExists, unvalidatedOrder.shippingAddress)
),
E.bind("billingAddress", () =>
toAddress(checkAddressExists, unvalidatedOrder.billingAddress)
),
E.map(({ orderId, customerInfo, shippingAddress, billingAddress }) => ({
orderId,
customerInfo,
shippingAddress,
billingAddress,
}))
);
E.bind로 등록한 이름들은 누적된 객체의 프로퍼티로 이후 단계에서 계속 참조 가능 → F#의let!로 여러 값을 순서대로 꺼내 쓰는 것과 동일한 효과.E.Do/E.bind/E.map조합은 결국 내부적으로flatMap체인으로 컴파일되는 것과 동일한 결과 — 문법만 더 읽기 쉬워짐.
10.6.2 Either 타입으로 주문 검사하기
validateOrder를 Either 없이 구현했던 버전(복습).
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
let orderId =
unvalidatedOrder.OrderId
|> OrderId.create
let customerInfo =
unvalidatedOrder.CustomerInfo
|> toCustomerInfo
let shippingAddress =
unvalidatedOrder.ShippingAddress
|> toAddress checkAddressExists
let billingAddress = ...
let lines = ...
let validatedOrder : ValidatedOrder = {
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = billingAddress
Lines = lines
}
validatedOrder
- 헬퍼 함수들이 모두 Either를 반환하도록 바뀌면 위 코드는 그대로 동작하지 않음(
OrderId.create가Result<OrderId,string>을 반환하는 식).result { }와let!을 쓰면 일반 값처럼 접근 가능.
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
result {
let! orderId =
unvalidatedOrder.OrderId
|> OrderId.create
|> Result.mapError ValidationError
let! customerInfo =
unvalidatedOrder.CustomerInfo
|> toCustomerInfo
let! shippingAddress = ...
let! billingAddress = ...
let! lines = ...
let validatedOrder : ValidatedOrder = {
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = billingAddress
Lines = lines
}
return validatedOrder
}
OrderId.create는 실패 시 단순string을 반환하므로mapError로ValidationError로 승격해야 함.toCustomerInfo,toAddress등은 이미ValidationError를 쓴다고 가정해 별도 승격이 필요 없음.
import * as E from "fp-ts/Either";
import { pipe } from "fp-ts/function";
const validateOrder =
(checkProductCodeExists: CheckProductCodeExists, checkAddressExists: CheckAddressExists) =>
(unvalidatedOrder: UnvalidatedOrder): Either<ValidationError, ValidatedOrder> =>
pipe(
E.Do,
E.bind("orderId", () =>
pipe(
OrderId.create(unvalidatedOrder.orderId),
mapLeft((msg: string): ValidationError => ({ kind: "ValidationError", message: msg }))
)
),
E.bind("customerInfo", () => toCustomerInfo(unvalidatedOrder.customerInfo)),
E.bind("shippingAddress", () => /* ... */ ({} as Either<ValidationError, Address>)),
E.bind("billingAddress", () => /* ... */ ({} as Either<ValidationError, Address>)),
E.bind("lines", () => /* 10.6.5에서 다룸 */ ({} as Either<ValidationError, OrderLine[]>)),
E.map(({ orderId, customerInfo, shippingAddress, billingAddress, lines }) => ({
orderId,
customerInfo,
shippingAddress,
billingAddress,
lines,
}))
);
10.6.5 Either 리스트의 유효성 검사
- Either 도입 전에는 각 주문 라인을
List.map으로 그냥 변환할 수 있었음.
let validateOrder unvalidatedOrder =
...
let lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductCodeExists)
let validatedOrder : ValidatedOrder = {
...
Lines = lines
}
validatedOrder
toValidatedOrderLine이 Either를 반환하면,map이후 결과는 **“Either의 리스트”**가 됨. 하지만ValidatedOrder.Lines에 필요한 건 “리스트를 담은 Either” — 정반대 형태.
let validateOrder unvalidatedOrder =
...
let lines = // "Either 리스트"
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductCodeExists)
let validatedOrder : ValidatedOrder = {
...
Lines = lines // 컴파일 오류
// ^ "리스트를 담은 Either"가 필요함
}
...
- 문제:
Either리스트를Either<리스트>로 바꾸려면 어떻게 해야 하는가? - 해법:
prepend(cons) 함수를 먼저 만듦 — 원소 하나를 담은 Either와, 리스트를 담은 Either를 결합.- 둘 다
Right(성공)이면 내용물을 이어붙여 다시Right로 감쌈. - 어느 한쪽이라도
Left(실패)이면 그 실패를 반환.
- 둘 다
/// Result<item>을 Result<list> 앞에 붙임
let prepend firstR restR =
match firstR, restR with
| Ok first, Ok rest -> Ok (first::rest)
| Error err1, Ok _ -> Error err1
| Ok _, Error err2 -> Error err2
| Error err1, Error _ -> Error err1
function prepend<L, R>(
firstR: Either<L, R>,
restR: Either<L, R[]>
): Either<L, R[]> {
if (firstR._tag === "Right" && restR._tag === "Right") {
return right([firstR.right, ...restR.right]);
}
if (firstR._tag === "Left") return left(firstR.left);
return left((restR as { _tag: "Left"; left: L }).left);
}
prepend는 완전히 제네릭 —Either<'a>와Either<'a list>를 받아Either<'a list>를 만듦.- 이를 이용해 리스트 끝에서부터(
foldBack) 순회하며 각 원소를 누적된 리스트 앞에 붙이는sequence함수를 만듦.
let sequence aListOfResults =
let initialValue = Ok [] // Either 안의 빈 리스트
// 역순으로 순회하며 각 원소를 초깃값 앞에 붙임
List.foldBack prepend aListOfResults initialValue
function sequence<L, R>(list: Array<Either<L, R>>): Either<L, R[]> {
const initialValue: Either<L, R[]> = right([]);
return list.reduceRight((acc, curr) => prepend(curr, acc), initialValue);
}
- 테스트 — 성공 리스트.
type IntOrError = Result<int,string>
let listOfSuccesses : IntOrError list = [Ok 1; Ok 2]
let successResult =
Result.sequence listOfSuccesses // Ok [1; 2]
type IntOrError = Either<string, number>;
const listOfSuccesses: IntOrError[] = [right(1), right(2)];
const successResult = sequence(listOfSuccesses); // Right([1, 2])
- 테스트 — 실패 리스트.
let listOfErrors : IntOrError list = [ Error "bad"; Error "terrible" ]
let errorResult =
Result.sequence listOfErrors // Error "bad"
const listOfErrors: IntOrError[] = [left("bad"), left("terrible")];
const errorResult = sequence(listOfErrors); // Left("bad")
참고: 실패 예시에서는 첫 번째 오류만 반환됨. 검증처럼 모든 오류를 모으고 싶은 경우에는 FP의 애플리케이티브(applicative) 기법이 필요함(10.7.1절에서 간단히 소개, 상세 구현은 이 책에서 다루지 않음).
sequence를 이용해ValidatedOrder를 최종 구성.
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
result {
let! orderId = ...
let! customerInfo = ...
let! shippingAddress = ...
let! billingAddress = ...
let! lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductCodeExists)
|> Result.sequence // Either 리스트를 하나의 Either로 변환
let validatedOrder : ValidatedOrder = {
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = billingAddress
Lines = lines
}
return validatedOrder
}
- 성능이 중요하다면
List.map+Result.sequence를 하나로 합친traverse함수로 더 효율화 가능(본문에서는 다루지 않음).
const validateOrder =
(checkProductCodeExists: CheckProductCodeExists, checkAddressExists: CheckAddressExists) =>
(unvalidatedOrder: UnvalidatedOrder): Either<ValidationError, ValidatedOrder> =>
pipe(
E.Do,
E.bind("orderId", () => /* ... */ ({} as Either<ValidationError, OrderId>)),
E.bind("customerInfo", () => /* ... */ ({} as Either<ValidationError, CustomerInfo>)),
E.bind("shippingAddress", () => /* ... */ ({} as Either<ValidationError, Address>)),
E.bind("billingAddress", () => /* ... */ ({} as Either<ValidationError, Address>)),
E.bind("lines", () =>
sequence(
unvalidatedOrder.lines.map(toValidatedOrderLine(checkProductCodeExists))
)
),
E.map(({ orderId, customerInfo, shippingAddress, billingAddress, lines }) => ({
orderId,
customerInfo,
shippingAddress,
billingAddress,
lines,
}))
);
- 마지막으로,
validateOrder의 실패 타입(ValidationError)을 전체 파이프라인의PlaceOrderError로 통일.priceOrder의PricingError도 동일하게 처리.
let placeOrder : PlaceOrder =
fun unvalidatedOrder ->
result {
let! validatedOrder =
validateOrder checkProductExists checkAddressExists unvalidatedOrder
|> Result.mapError PlaceOrderError.Validation
let! pricedOrder =
priceOrder getProductPrice validatedOrder
|> Result.mapError PlaceOrderError.Pricing
let acknowledgmentOption = ...
let events = ...
return events
}
// 출력: Result<ValidatedOrder,PlaceOrderError>
const placeOrder =
(dependencies: PlaceOrderDependencies) =>
(unvalidatedOrder: UnvalidatedOrder): Either<PlaceOrderError, PlaceOrderEvent[]> =>
pipe(
E.Do,
E.bind("validatedOrder", () =>
pipe(
validateOrder(dependencies.checkProductExists, dependencies.checkAddressExists)(unvalidatedOrder),
mapLeft((e): PlaceOrderError => ({ kind: "Validation", error: e }))
)
),
E.bind("pricedOrder", ({ validatedOrder }) =>
pipe(
priceOrder(dependencies.getProductPrice)(validatedOrder),
mapLeft((e): PlaceOrderError => ({ kind: "Pricing", error: e }))
)
),
E.map(({ pricedOrder }) => {
const acknowledgmentOption = acknowledgeOrder(pricedOrder);
return createEvents(pricedOrder, acknowledgmentOption);
})
);
10.7 모나드와 기타 개념
- FP에서 자주 등장하는 용어 **모나드(monad)**를 정리. 이번 장에서 이미 모나드 하나를 만들고 사용했음.
- 모나딕 함수: “일반” 값을 받아 “강화된(enhanced)” 값을 반환하는 함수. 여기서 “강화된” 값이란 Either로 감싸진 값 — 즉 지금까지 다룬 Either 반환 스위치 함수가 바로 모나딕 함수임.
- 기술적으로 모나드는 세 가지 요소로 구성됨
- 데이터 구조 — 우리의 경우
Either(Result). - 관련 함수 —
return(=pure)과bind.return: 일반 값을 모나딕 타입으로 바꿈.Either에서는Right(F#의Ok) 생성자.bind(=flatMap): 모나딕 함수들을 체이닝할 수 있게 해줌.
- 규칙 — 모나드 법칙. 구현이 올바르고 이상하게 동작하지 않도록 보장하는 상식적인 가이드라인(본문에서 상세히 다루지 않음).
- 데이터 구조 — 우리의 경우
| 용어 | 의미 |
|---|---|
bind(flatMap) |
Either를 반환하는 함수를 2-트랙 함수로 변환. 함수를 **직렬(series)**로 연결할 때 사용. 모나드의 핵심 구성요소 |
map |
1-트랙 함수를 2-트랙 함수로 변환 |
| 모나딕(monadic) 합성 | bind를 사용해 함수를 직렬로 연결하는 방식 |
| 애플리케이티브(applicative) 합성 | 결과들을 **병렬(parallel)**로 결합하는 방식 |
10.7.1 애플리케이티브로 병렬 합성하기
- 애플리케이티브는 모나드와 비슷하지만, 모나딕 함수를 직렬로 체이닝하는 대신 모나딕 값들을 병렬로 결합함.
- 검증(validation)이 대표적인 사용처: 첫 오류에서 멈추는 대신 모든 오류를 모아서 보여주고 싶을 때 애플리케이티브 방식을 씀.
- 이 책에서는 지면 관계상 상세한 구현을 다루지 않음. 저자의 블로그 fsharpforfunandprofit.com에 더 자세한 내용이 있다고 소개함.
- “모나드”, “애플리케이티브”라는 용어를 이 책 전체에서 자주 쓰지는 않지만, 이제 마주쳤을 때 의미를 이해할 수 있음.
10.8 비동기 효과 추가하기
- 원래 파이프라인 설계는 오류 이펙트(Either)뿐 아니라 비동기(Async) 이펙트도 함께 사용했음. 두 이펙트를 조합하는 것은 일반적으로 까다롭지만, 이 둘이 자주 함께 등장하므로
asyncResult(AsyncEither) 계산 표현식을 별도로 정의해 사용함(구현 자체는 본문에서 생략, 코드 저장소 참고). asyncResult사용법은result와 동일함.
let validateOrder : ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
asyncResult {
let! orderId =
unvalidatedOrder.OrderId
|> OrderId.create
|> Result.mapError ValidationError
|> AsyncResult.ofResult // Result를 AsyncResult로 승격
let! customerInfo =
unvalidatedOrder.CustomerInfo
|> toCustomerInfo
|> AsyncResult.ofResult
let! checkedShippingAddress = // 검증된 주소 추출
unvalidatedOrder.ShippingAddress
|> toCheckedAddress checkAddressExists
let! shippingAddress = // 검증된 주소를 가공
checkedShippingAddress
|> toAddress
|> AsyncResult.ofResult
let! billingAddress = ...
let! lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkProductCodeExists)
|> Result.sequence
|> AsyncResult.ofResult
let validatedOrder : ValidatedOrder = {
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = billingAddress
Lines = lines
}
return validatedOrder
}
result를asyncResult로 바꾸는 것 외에도, 모든 값이 이제AsyncResult여야 함. 예를 들어OrderId.create의 출력은 단순Result이므로AsyncResult.ofResult로 승격 필요.- 주소 검증은 두 단계로 나눔. 효과를 모두 되돌리면
CheckAddressExists가AsyncResult를 반환하기 때문.
type CheckAddressExists =
UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>
- 이 타입은 우리 워크플로우와 오류 타입이 맞지 않으므로, 서비스 전용 오류(
AddressValidationError)를 우리ValidationError로 변환하는 헬퍼toCheckedAddress를 만듦.
/// checkAddressExists를 호출하고 오류를 ValidationError로 변환
let toCheckedAddress (checkAddress:CheckAddressExists) address =
address
|> checkAddress
|> AsyncResult.mapError (fun addrError ->
match addrError with
| AddressNotFound -> ValidationError "Address not found"
| InvalidFormat -> ValidationError "Address has bad format"
)
toCheckedAddress의 출력은 여전히CheckedAddress를 감싼AsyncResult이므로,let!로 풀어낸 뒤 이어지는 검증 단계(toAddress)로 넘김.placeOrder도 동일하게asyncResult로 변환.
let placeOrder : PlaceOrder =
fun unvalidatedOrder ->
asyncResult {
let! validatedOrder =
validateOrder checkProductExists checkAddressExists unvalidatedOrder
|> AsyncResult.mapError PlaceOrderError.Validation
let! pricedOrder =
priceOrder getProductPrice validatedOrder
|> AsyncResult.ofResult
|> AsyncResult.mapError PlaceOrderError.Pricing
let acknowledgmentOption = ...
let events = ...
return events
}
-
나머지 파이프라인도 같은 방식으로
asyncResult로 전환 가능(본문에서는 생략, 코드 저장소 참고). -
TypeScript 대응: fp-ts의
TaskEither<L, R>가 F#의AsyncResult(비동기 + Either)에 정확히 대응됨.
import * as TE from "fp-ts/TaskEither";
import { pipe } from "fp-ts/function";
const validateOrder =
(checkProductCodeExists: CheckProductCodeExists, checkAddressExists: CheckAddressExists) =>
(unvalidatedOrder: UnvalidatedOrder): TE.TaskEither<ValidationError, ValidatedOrder> =>
pipe(
TE.Do,
TE.bind("orderId", () =>
pipe(
OrderId.create(unvalidatedOrder.orderId),
E.mapLeft((msg: string): ValidationError => ({ kind: "ValidationError", message: msg })),
TE.fromEither // Either를 TaskEither로 승격
)
),
TE.bind("customerInfo", () =>
pipe(toCustomerInfo(unvalidatedOrder.customerInfo), TE.fromEither)
),
TE.bind("shippingAddress", () =>
pipe(
checkAddressExists(unvalidatedOrder.shippingAddress), // 이미 TaskEither 반환
TE.mapLeft(
(addrError): ValidationError =>
addrError.kind === "AddressNotFound"
? { kind: "ValidationError", message: "Address not found" }
: { kind: "ValidationError", message: "Address has bad format" }
)
)
),
TE.bind("lines", () =>
pipe(
unvalidatedOrder.lines.map(toValidatedOrderLine(checkProductCodeExists)),
E.sequenceArray, // 배열 안의 Either를 하나로 모음
TE.fromEither
)
),
TE.map(({ orderId, customerInfo, shippingAddress, lines }) => ({
orderId,
customerInfo,
shippingAddress,
lines,
}))
);
const placeOrder =
(deps: PlaceOrderDependencies) =>
(unvalidatedOrder: UnvalidatedOrder): TE.TaskEither<PlaceOrderError, PlaceOrderEvent[]> =>
pipe(
TE.Do,
TE.bind("validatedOrder", () =>
pipe(
validateOrder(deps.checkProductExists, deps.checkAddressExists)(unvalidatedOrder),
TE.mapLeft((e): PlaceOrderError => ({ kind: "Validation", error: e }))
)
),
TE.bind("pricedOrder", ({ validatedOrder }) =>
pipe(
priceOrder(deps.getProductPrice)(validatedOrder),
TE.mapLeft((e): PlaceOrderError => ({ kind: "Pricing", error: e }))
)
),
TE.map(({ pricedOrder }) => {
const acknowledgmentOption = acknowledgeOrder(pricedOrder);
return createEvents(pricedOrder, acknowledgmentOption);
})
);
10.9 마무리
- 파이프라인의 개정판 구현을 완료함 — 타입 안전한 오류 처리 + 비동기 효과가 모두 반영됨.
- 최상위
placeOrder구현은 여전히 명료함. 특별한 조건문이나 try/catch로 흐름이 어지럽혀지지 않음. - 타입을 맞추기 위한 다소 번거로운 변환 작업(
mapError/mapLeft, 어댑터 블록 등)이 필요했지만, 그 대가로 파이프라인의 모든 구성 요소가 문제없이 맞물려 동작한다는 확신을 얻을 수 있음. - 다음 장부터는 도메인과 외부 세계의 상호작용을 다룸 — 데이터의 직렬화/역직렬화, 상태를 데이터베이스에 영속화하는 방법.
부록: 핵심 함수 빠른 참고표
| 함수 | 역할 | 원본(F#) | 본 노트(Either/TS) |
|---|---|---|---|
bind |
스위치 함수를 2-트랙 함수로 변환, 직렬 연결 | Result.bind |
flatMap |
map |
1-트랙 함수를 2-트랙 함수로 변환 | Result.map |
map |
mapError |
실패 트랙의 값(오류 타입)을 변환 | Result.mapError |
mapLeft |
tee |
dead-end 함수 실행 후 원래 입력 반환(패스스루) | tee |
tee |
adaptDeadEnd |
dead-end 함수를 2-트랙 함수로 변환 | Result.map (tee f) |
map(tee(f)) |
serviceExceptionAdapter |
예외 던지는 함수를 Either 반환 함수로 변환 | serviceExceptionAdapter |
serviceExceptionAdapter |
prepend |
Either 원소를 Either 리스트 앞에 결합 | prepend |
prepend |
sequence |
Either 리스트를 리스트를 담은 Either로 변환 | Result.sequence |
sequence |
result/asyncResult 표현식 |
중첩 bind 호출을 감추는 문법 설탕 |
F# computation expression | fp-ts Do/bind, arrow-kt either{}/Raise |
TaskEither |
비동기 + Either 결합 타입 | AsyncResult |
fp-ts TaskEither |
챕터 11 직렬화
- 이 책의 작업 흐름(workflow)은 입력을 커맨드(Command), 출력을 이벤트(Event)로 하는 함수로 설계됨
- 커맨드는 어디서 오고, 이벤트는 어디로 가는가라는 질문에서 출발
- 경계 지어진 컨텍스트 바깥에 존재하는 인프라(메시지 큐, 웹 요청 등)에서 오고, 그리로 감
- 인프라는 도메인을 이해하지 못하므로, 도메인 모델의 타입을 인프라가 이해하는 형태(JSON, XML, protobuf 같은 바이너리 포맷 등)로 변환해야 함
- 작업 흐름에 필요한 내부 상태(예:
Order의 현재 상태)를 추적하려면 데이터베이스 같은 외부 서비스가 보통 필요함 - 인프라와 작업할 때 중요한 것은 도메인 모델의 타입을 손쉽게 직렬화/역직렬화할 수 있는 형태로 변환하는 능력
- 이번 챕터의 목표: 직렬화 가능한 타입을 설계하는 방법, 도메인 객체를 이 중간 타입으로 변환하는 방법 학습
11.1 영속화와 직렬화
- 영속화(Persistence): 상태를 만든 프로세스보다 더 오래 유지되는 상태
- 반드시 제대로 된 데이터베이스일 필요는 없음. 파일이나 큐에 저장되는 것도 영속화에 해당
- 영속화된 데이터의 수명에 대해 가정하면 안 됨
- 큐처럼 단 몇 초짜리일 수도 있음
- 데이터 웨어하우스처럼 수십 년짜리일 수도 있음
- 직렬화(Serialization): 도메인 특화 표현을 영속화하기 쉬운 표현(바이너리, JSON, XML 등)으로 변환하는 프로세스
- 예시: 주문 생성(order-placing) 작업 흐름
- “주문서 도착(order form arrived)” 이벤트가 발생할 때마다 인스턴스화되어 실행됨
- 코드 실행이 끝나도 출력 결과는 계속 남아있어야(“영속화”) 다른 비즈니스 파트에서 그 데이터를 사용할 수 있음
- 이번 챕터는 직렬화에 집중하고, 다음 챕터는 영속화를 다룸
11.2 직렬화를 위한 디자인
- 복잡한 도메인 타입은 선택(choice)과 제약(constraint)을 위한 특수 타입이 깊게 중첩된 구조라서, 직렬화기(serializer)가 다루기에 적합하지 않음
- 핵심 트릭: 도메인 객체를 직렬화 전용으로 설계된 타입인 DTO(Data Transfer Object) 로 변환한 뒤, DTO를 직렬화함
직렬화 흐름
flowchart LR
subgraph boundary1["도메인 경계"]
A["Domain Type"] --> B["Domain Type to DTO"]
B --> C["DTO Type"]
end
C --> D["Serialize"]
D --> E["Json / XML"]
E -.->|"하류(downstream) 컨텍스트로"| F["하류 컨텍스트"]
역직렬화 흐름
flowchart LR
G["상류 컨텍스트"] -.->|"상류(upstream) 컨텍스트로부터"| H["Json / XML"]
H --> I["Deserialize"]
subgraph boundary2["도메인 경계"]
I --> J["DTO Type"]
J --> K["DTO to Domain Type"]
K --> L["Domain Type"]
end
- 역직렬화 설계 원칙
- DTO로의 역직렬화는 데이터가 손상된 경우가 아니라면 ==항상 성공==해야 함
- 도메인 특화 유효성 검증(예:
OrderQty의 정수 범위 검증,ProductCode가 유효한지 확인)은 DTO -> 도메인 타입 변환 과정, 즉 경계 지어진 컨텍스트 내부에서 수행해야 함- 이유: 이 지점에서 오류 처리를 더 잘 제어할 수 있기 때문
11.3 작업 흐름에 직렬화 코드 연결하기
- 직렬화 프로세스는 작업 흐름 파이프라인에 추가되는 또 하나의 컴포넌트일 뿐임
- 역직렬화 단계는 작업 흐름의 맨 앞에 추가
- 직렬화 단계는 작업 흐름의 맨 뒤에 추가
기본 작업 흐름 타입
type MyInputType = ...
type MyOutputType = ...
type Workflow = MyInputType -> MyOutputType
type MyInputType = unknown; // 실제로는 구체적인 도메인 입력 타입
type MyOutputType = unknown; // 실제로는 구체적인 도메인 출력 타입
type Workflow = (input: MyInputType) => MyOutputType;
역직렬화 단계 타입
type JsonString = string
type MyInputDto = ...
type DeserializeInputDto = JsonString -> MyInputDto
type InputDtoToDomain = MyInputDto -> MyInputType
type JsonString = string;
type MyInputDto = unknown;
type DeserializeInputDto = (json: JsonString) => MyInputDto;
type InputDtoToDomain = (dto: MyInputDto) => MyInputType;
직렬화 단계 타입
type MyOutputDto = ...
type OutputDtoFromDomain = MyOutputType -> MyOutputDto
type SerializeOutputDto = MyOutputDto -> JsonString
type MyOutputDto = unknown;
type OutputDtoFromDomain = (output: MyOutputType) => MyOutputDto;
type SerializeOutputDto = (dto: MyOutputDto) => JsonString;
파이프라인으로 연결
let workflowWithSerialization jsonString =
jsonString
|> deserializeInputDto // JSON to DTO
|> inputDtoToDomain // DTO to domain object
|> workflow // the core workflow in the domain
|> outputDtoFromDomain // Domain object to DTO
|> serializeOutputDto // DTO to JSON
// final output is another JsonString
function workflowWithSerialization(jsonString: JsonString): JsonString {
const inputDto = deserializeInputDto(jsonString); // JSON -> DTO
const domainInput = inputDtoToDomain(inputDto); // DTO -> 도메인 객체
const domainOutput = workflow(domainInput); // 도메인 핵심 작업 흐름
const outputDto = outputDtoFromDomain(domainOutput); // 도메인 객체 -> DTO
return serializeOutputDto(outputDto); // DTO -> JSON
// 최종 출력도 JsonString
}
flowchart LR
A["JsonString"] --> B["deserializeInputDto"]
B --> C["inputDtoToDomain"]
C --> D["workflow"]
D --> E["outputDtoFromDomain"]
E --> F["serializeOutputDto"]
F --> G["JsonString"]
workflowWithSerialization함수가 인프라에 노출되는 함수가 됨- 입출력이 모두
JsonString같은 단순 타입이므로 인프라가 도메인으로부터 격리됨
- 입출력이 모두
- 실무에서는 에러 처리, 비동기 처리 등이 추가로 필요하지만, 위 코드는 기본 개념을 보여줌
11.3.1 경계 진 맥락 간 계약으로서 DTO
- 소비하는 커맨드는 다른 경계 지어진 컨텍스트의 출력에 의해 트리거됨
- 작업 흐름이 내보내는 이벤트는 다른 경계 지어진 컨텍스트의 입력이 됨
- 이 커맨드와 이벤트는 컨텍스트가 지원해야 하는 일종의 계약(contract) 을 형성함
- 컨텍스트 간 강한 결합(tight coupling)을 피하기 위해 느슨한 계약을 지향
- 그럼에도 이벤트와 커맨드의 직렬화 포맷(DTO)은 신중하게, 최소한으로만 변경해야 함
- ==직렬화 포맷에 대한 완전한 통제권을 항상 가져야 하며, 라이브러리가 자동으로(“auto-magically”) 처리하도록 방치해서는 안 됨==
11.4 완전한 직렬화 예제
- 도메인 타입
Person을 JSON으로 영속화하는 예제를 통해 직렬화/역직렬화를 실습
도메인 타입 정의
module Domain = // our domain-driven types
/// constrained to be not null and at most 50 chars
type String50 = String50 of string
/// constrained to be bigger than 1/1/1900 and less than today's date
type Birthdate = Birthdate of DateTime
/// Domain type
type Person = {
First: String50
Last: String50
Birthdate : Birthdate
}
namespace Domain {
// null이 아니고 50자 이하로 제약된 문자열
export type String50 = { kind: "String50"; value: string };
// 1900-01-01보다 크고 오늘 날짜보다 작게 제약된 날짜
export type Birthdate = { kind: "Birthdate"; value: Date };
export interface Person {
first: String50;
last: String50;
birthdate: Birthdate;
}
}
String50,Birthdate는 직접 직렬화가 불가능한 제약 타입 -> 모든 필드가 원시 타입인 대응 DTO 타입Dto.Person을 먼저 정의
DTO 타입 정의
module Dto =
type Person = {
First: string
Last: string
Birthdate : DateTime
}
namespace Dto {
export interface Person {
first: string;
last: string;
birthdate: string; // ISO 8601 문자열 등으로 표현
}
}
fromDomain,toDomain함수 쌍은 DTO 타입 쪽에 위치함 (도메인은 DTO의 존재를 몰라야 하므로)Dto모듈 하위의Person서브모듈에 위치
module Dto =
module Person =
let fromDomain (person:Domain.Person) :Dto.Person =
...
let toDomain (dto:Dto.Person) :Result<Domain.Person,string> =
...
F# 4.1 이전 버전에서는 모듈 이름이 타입 이름과 같을 때
CompilationRepresentation어트리뷰트가 필요함
fromDomain/toDomain쌍 패턴은 이 책 전체에서 일관되게 사용되는 패턴
fromDomain: 도메인 -> DTO (항상 성공)
let fromDomain (person:Domain.Person) :Dto.Person =
// get the primitive values from the domain object
let first = person.First |> String50.value
let last = person.Last |> String50.value
let birthdate = person.Birthdate |> Birthdate.value
// combine the components to create the DTO
{First = first; Last = last; Birthdate = birthdate}
function fromDomain(person: Domain.Person): Dto.Person {
// 도메인 객체에서 원시값 추출
const first = String50.value(person.first);
const last = String50.value(person.last);
const birthdate = Birthdate.value(person.birthdate);
// 컴포넌트들을 조합해 DTO 생성
return { first, last, birthdate: birthdate.toISOString() };
}
- 복잡한 도메인 타입은 항상 오류 없이 DTO로 변환 가능 ->
Result가 필요 없음
toDomain: DTO -> 도메인 (실패 가능, Result 반환)
let toDomain (dto:Dto.Person) :Result<Domain.Person,string> =
result {
// get each (validated) simple type from the DTO as a success or failure
let! first = dto.First |> String50.create "First"
let! last = dto.Last |> String50.create "Last"
let! birthdate = dto.Birthdate |> Birthdate.create
// combine the components to create the domain object
return {
First = first
Last = last
Birthdate = birthdate
}
}
function toDomain(dto: Dto.Person): Result<Domain.Person, string> {
const firstResult = String50.create("First", dto.first);
if (!firstResult.ok) return firstResult;
const lastResult = String50.create("Last", dto.last);
if (!lastResult.ok) return lastResult;
const birthdateResult = Birthdate.create(new Date(dto.birthdate));
if (!birthdateResult.ok) return birthdateResult;
return {
ok: true,
value: {
first: firstResult.value,
last: lastResult.value,
birthdate: birthdateResult.value,
},
};
}
- 각종 유효성 검증/제약이 실패할 수 있으므로
Person이 아닌Result<Person,string>반환 - F#의
result { ... }계산 표현식이 단순 타입들의create메서드가 반환하는Result의 에러 흐름을 처리- TypeScript에는 계산 표현식이 없으므로, 각 단계마다
if (!result.ok) return result;형태로 조기 반환(early return)하여 동일한 효과를 냄
- TypeScript에는 계산 표현식이 없으므로, 각 단계마다
String50.create 구현 예시
let create fieldName str : Result<String50,string> =
if String.IsNullOrEmpty(str) then
Error (fieldName + " must be non-empty")
elif str.Length > 50 then
Error (fieldName + " must be less that 50 chars")
else
Ok (String50 str)
function create(fieldName: string, str: string): Result<String50, string> {
if (!str) {
return { ok: false, error: `${fieldName} must be non-empty` };
} else if (str.length > 50) {
return { ok: false, error: `${fieldName} must be less than 50 chars` };
} else {
return { ok: true, value: { kind: "String50", value: str } };
}
}
- 필드 이름을 파라미터로 받아 유용한 에러 메시지를 생성
11.4.1 JSON 직렬화 라이브러리 래핑하기
- JSON/XML 직렬화 자체는 직접 구현하지 않고 서드파티 라이브러리(.NET의 경우
Newtonsoft.Json)를 사용 - 라이브러리 API가 함수형 친화적이지 않을 수 있으므로, 파이프라인에서 쓰기 좋고 예외를
Result로 변환하도록 래핑
module Json =
open Newtonsoft.Json
let serialize obj =
JsonConvert.SerializeObject obj
let deserialize<'a> str =
try
JsonConvert.DeserializeObject<'a> str
|> Result.Ok
with
// catch all exceptions and convert to Result
| ex -> Result.Error ex
namespace Json {
export function serialize<T>(obj: T): string {
return JSON.stringify(obj);
}
export function deserialize<T>(str: string): Result<T, Error> {
try {
const value = JSON.parse(str) as T;
return { ok: true, value };
} catch (ex) {
// 모든 예외를 잡아 Result로 변환
return { ok: false, error: ex as Error };
}
}
}
- 직접 만든
Json모듈에 어댑터 버전을 담아Json.serialize,Json.deserialize로 호출
다른 직렬화 라이브러리 사용 시 참고사항
Newtonsoft.Json대신 다른 직렬화기(DataContractSerializer(XML),DataContractJsonSerializer(JSON) 등)를 쓸 경우, DTO 타입에DataContractAttribute,DataMemberAttribute등의 어트리뷰트를 추가해야 할 수 있음
module Dto =
[<DataContract>]
type Person = {
[<field: DataMember>]
First: string
[<field: DataMember>]
Last: string
[<field: DataMember>]
Birthdate : DateTime
}
- 이런 방식은 도메인 타입을 이런 복잡한 어트리뷰트로 오염시키지 않고 DTO에만 부착할 수 있다는 점에서, 직렬화 타입을 도메인 타입과 분리하는 장점을 다시 보여줌
CLIMutableAttribute: 리플렉션 기반 직렬화기가 흔히 필요로 하는, 매개변수 없는(숨겨진) 생성자를 만들어줌- F# 전용 컴포넌트끼리만 통신한다면
FsPickler,Chiron같은 F# 특화 직렬화기 사용 가능- 단, 이 경우 모든 경계 지어진 컨텍스트가 동일 프로그래밍 언어를 사용해야 하는 결합이 생김
TypeScript/JavaScript에서는
JSON.stringify/JSON.parse가 평범한 객체·인터페이스에 바로 동작하므로 어트리뷰트 부착이 필요 없음. 런타임 스키마 검증이 필요하면zod,io-ts같은 라이브러리로 별도의 DTO 검증 계층을 둘 수 있음
11.4.2 완전한 직렬화 파이프라인
- DTO 변환기와 직렬화 함수가 준비되면, 도메인 타입
Person을 JSON 문자열까지 한 번에 변환 가능
/// Serialize a Person into a JSON string
let jsonFromDomain (person:Domain.Person) =
person
|> Dto.Person.fromDomain
|> Json.serialize
// Person을 JSON 문자열로 직렬화
function jsonFromDomain(person: Domain.Person): string {
const dto = Dto.Person.fromDomain(person);
return Json.serialize(dto);
}
테스트: 성공 케이스
// input to test with
let person : Domain.Person = {
First = String50 "Alex"
Last = String50 "Adams"
Birthdate = Birthdate (DateTime(1980,1,1))
}
// use the serialization pipeline
jsonFromDomain person
// The output is
// "{"First":"Alex","Last":"Adams","Birthdate":"1980-01-01T00:00:00"}"
const person: Domain.Person = {
first: { kind: "String50", value: "Alex" },
last: { kind: "String50", value: "Adams" },
birthdate: { kind: "Birthdate", value: new Date(1980, 0, 1) },
};
jsonFromDomain(person);
// 출력 예:
// '{"first":"Alex","last":"Adams","birthdate":"1980-01-01T00:00:00.000Z"}'
- 직렬화 파이프라인은 모든 단계가
Result가 필요 없어(Result-free) 구성이 단순함 - 역직렬화 파이프라인은
Json.deserialize와Dto.Person.toDomain둘 다Result를 반환하므로 더 까다로움- 해법:
Result.mapError로 각각의 실패를 공통 에러 타입으로 변환한 뒤,result표현식으로 처리
- 해법:
type DtoError =
| ValidationError of string
| DeserializationException of exn
/// Deserialize a JSON string into a Person
let jsonToDomain jsonString :Result<Domain.Person,DtoError> =
result {
let! deserializedValue =
jsonString
|> Json.deserialize
|> Result.mapError DeserializationException
let! domainValue =
deserializedValue
|> Dto.Person.toDomain
|> Result.mapError ValidationError
return domainValue
}
type DtoError =
| { kind: "ValidationError"; message: string }
| { kind: "DeserializationException"; error: Error };
// JSON 문자열을 Person으로 역직렬화
function jsonToDomain(jsonString: string): Result<Domain.Person, DtoError> {
const deserialized = Json.deserialize<Dto.Person>(jsonString);
if (!deserialized.ok) {
return {
ok: false,
error: { kind: "DeserializationException", error: deserialized.error },
};
}
const domainValue = Dto.Person.toDomain(deserialized.value);
if (!domainValue.ok) {
return {
ok: false,
error: { kind: "ValidationError", message: domainValue.error },
};
}
return domainValue;
}
테스트: 정상 JSON
// JSON string to test with
let jsonPerson = """{
"First": "Alex",
"Last": "Adams",
"Birthdate": "1980-01-01T00:00:00"
}"""
// call the deserialization pipeline
jsonToDomain jsonPerson |> printfn "%A"
// The output is:
// Ok {First = String50 "Alex";
// Last = String50 "Adams";
// Birthdate = Birthdate 01/01/1980 00:00:00;}
const jsonPerson = `{
"first": "Alex",
"last": "Adams",
"birthdate": "1980-01-01T00:00:00"
}`;
console.log(jsonToDomain(jsonPerson));
// 출력 예:
// { ok: true, value: { first: { kind: 'String50', value: 'Alex' }, ... } }
- 결과가
Ok이며Person도메인 객체가 정상 생성됨을 확인
테스트: 에러가 있는 JSON (빈 이름 + 잘못된 날짜)
let jsonPersonWithErrors = """{
"First": "",
"Last": "Adams",
"Birthdate": "1776-01-01T00:00:00"
}"""
// call the deserialization pipeline
jsonToDomain jsonPersonWithErrors |> printfn "%A"
// The output is:
// Error (ValidationError [
// "First must be non-empty"
// ])
const jsonPersonWithErrors = `{
"first": "",
"last": "Adams",
"birthdate": "1776-01-01T00:00:00"
}`;
console.log(jsonToDomain(jsonPersonWithErrors));
// 출력 예:
// { ok: false, error: { kind: 'ValidationError', message: 'First must be non-empty' } }
Error케이스와 유효성 검증 메시지 하나를 얻음 (이 구현은 첫 번째 에러만 반환. 모든 에러를 모으려면 애플리커티브를 이용한 병렬 조합 기법이 필요)- 실무에서는 이 에러를 로그로 남기거나 호출자에게 반환할 수 있음
- 대안적 접근: 역직렬화 에러 처리를 아예 하지 않고 예외를 그대로 던지게 둘 수도 있음
- 선택 기준: 역직렬화 오류를 “예상된 상황”으로 다룰지, 파이프라인 전체를 중단시키는 “패닉”으로 다룰지
- API의 공개 범위, 호출자에 대한 신뢰도, 호출자에게 제공하고 싶은 에러 정보의 양에 따라 결정
11.4.3 직렬화 타입의 여러 버전 관리하기
- 설계가 진화하면서 도메인 타입에 필드가 추가/삭제/이름변경될 수 있고, 이는 DTO 타입에도 영향을 줌
- DTO 타입은 경계 지어진 컨텍스트 간 계약이므로 이 계약을 깨뜨리지 않는 것이 중요
- 따라서 시간에 따라 DTO 타입의 여러 버전을 동시에 지원해야 할 수 있음
- 다양한 방법이 있으나 이 챕터에서는 지면상 자세히 다루지 않음
- 참고 자료: Greg Young, Versioning in an Event Sourced System
11.5 도메인 타입을 DTO로 변환하는 방법
- 도메인 타입은 복잡할 수 있지만, 대응 DTO 타입은 원시 타입만 포함하는 단순한 구조여야 함
- 특정 도메인 타입이 주어졌을 때 DTO를 설계하는 가이드라인 모음
11.5.1 단순 타입
- 단일 케이스 유니온(Single-Case Union) = 이 책에서 “단순 타입(simple type)“이라 부르는 것
- DTO에서는 내부의 원시 타입 그대로 표현 가능
type ProductCode = ProductCode of string
type ProductCode = { kind: "ProductCode"; value: string };
// 대응 DTO 타입은 그냥 string
type ProductCodeDto = string;
11.5.2 옵셔널값
- 옵션(Option)의
None케이스는 DTO에서null로 대체 가능- 참조 타입을 감싼 옵션이면
null이 이미 유효한 값이므로 별도 처리 불필요 int같은 값 타입을 감싼 옵션이면Nullable<int>같은 널러블 대응 타입이 필요
- 참조 타입을 감싼 옵션이면
// TypeScript는 값 타입/참조 타입 구분 없이 유니온으로 옵션을 표현
type OrderLineQty = { kind: "OrderLineQty"; value: number };
type OrderLineQtyOption = OrderLineQty | null; // 도메인에서는 undefined 대신 null 권장
// DTO에서도 값 타입에 별도 Nullable 래퍼가 필요 없이 number 또는 null로 충분
type QuantityDto = number | null;
11.5.3 레코드
- 레코드로 정의된 도메인 타입은 각 필드를 DTO 대응 타입으로 변환하기만 하면 DTO에서도 레코드로 유지 가능
/// Domain types
type OrderLineId = OrderLineId of int
type OrderLineQty = OrderLineQty of int
type OrderLine = {
OrderLineId : OrderLineId
ProductCode : ProductCode
Quantity : OrderLineQty option
Description : string option
}
/// Corresponding DTO type
type OrderLineDto = {
OrderLineId : int
ProductCode : string
Quantity : Nullable<int>
Description : string
}
// 도메인 타입
type OrderLineId = { kind: "OrderLineId"; value: number };
type OrderLineQty = { kind: "OrderLineQty"; value: number };
interface OrderLine {
orderLineId: OrderLineId;
productCode: ProductCode;
quantity: OrderLineQty | null;
description: string | null;
}
// 대응 DTO 타입
interface OrderLineDto {
orderLineId: number;
productCode: string;
quantity: number | null; // TS는 Nullable<int> 래퍼가 필요 없음
description: string | null;
}
11.5.4 컬렉션
- 리스트, 시퀀스, 셋은 모든 직렬화 포맷이 지원하는 배열로 변환
/// Domain type
type Order = {
...
Lines : OrderLine list
}
/// Corresponding DTO type
type OrderDto = {
...
Lines : OrderLineDto[]
}
interface Order {
// ...
lines: OrderLine[];
}
interface OrderDto {
// ...
lines: OrderLineDto[];
}
- 맵(Map)이나 다른 복잡한 컬렉션은 직렬화 포맷에 따라 접근 방식이 달라짐
- JSON 포맷에서는 맵을 JSON 객체로 바로 직렬화 가능 (JSON 객체 자체가 키-값 컬렉션이므로)
- 다른 포맷에서는 특별한 표현이 필요할 수 있음. 예: 맵을 키-값 쌍의 레코드 배열로 표현
/// Domain type
type Price = Price of decimal
type PriceLookup = Map<ProductCode,Price>
/// DTO type to represent a map
type PriceLookupPair = {
Key : string
Value : decimal
}
type PriceLookupDto = {
KVPairs : PriceLookupPair []
}
type Price = { kind: "Price"; value: number };
type PriceLookup = Map<ProductCode, Price>;
// 맵을 표현하기 위한 DTO 타입
interface PriceLookupPair {
key: string;
value: number;
}
interface PriceLookupDto {
kvPairs: PriceLookupPair[];
}
// 참고: JSON 포맷을 쓸 경우 아래처럼 평범한 객체로도 표현 가능
type PriceLookupDtoAsObject = Record<string, number>;
- 대안: 맵을 역직렬화 시점에 zip으로 합칠 수 있는 두 개의 병렬 배열로 표현
/// Alternative DTO type to represent a map
type PriceLookupDto = {
Keys : string []
Values : decimal []
}
interface PriceLookupDto {
keys: string[];
values: number[];
}
11.5.5 열거형
- 모든 케이스가 추가 데이터 없이 이름만 갖는 유니온(열거형처럼 쓰이는 판별 유니온)은 .NET enum으로 표현 가능
- enum은 직렬화 시 대개 정수로 표현됨
/// Domain type
type Color =
| Red
| Green
| Blue
/// Corresponding DTO type
type ColorDto =
| Red = 1
| Green = 2
| Blue = 3
// 도메인 타입: 문자열 리터럴 유니온으로 표현
type Color = "Red" | "Green" | "Blue";
// 대응 DTO 타입: TS enum으로 표현 (직렬화 시 정수)
enum ColorDto {
Red = 1,
Green = 2,
Blue = 3,
}
- 역직렬화 시 .NET enum 값이 열거된 값 중 하나가 아닌 경우를 반드시 처리해야 함
let toDomain dto : Result<Color,_> =
match dto with
| ColorDto.Red -> Ok Color.Red
| ColorDto.Green -> Ok Color.Green
| ColorDto.Blue -> Ok Color.Blue
| _ -> Error (sprintf "Color %O is not one of Red,Green,Blue" dto)
function toDomain(dto: ColorDto): Result<Color, string> {
switch (dto) {
case ColorDto.Red:
return { ok: true, value: "Red" };
case ColorDto.Green:
return { ok: true, value: "Green" };
case ColorDto.Blue:
return { ok: true, value: "Blue" };
default:
return { ok: false, error: `Color ${dto} is not one of Red,Green,Blue` };
}
}
- 대안: enum 스타일 유니온을 케이스 이름 문자열로 직렬화할 수도 있음. 다만 이름 변경(리네이밍) 이슈에 더 취약함
11.5.6 튜플
- 튜플은 도메인에 자주 등장하지 않지만, 등장할 경우 대부분의 직렬화 포맷이 튜플을 지원하지 않으므로 별도로 정의된 레코드로 표현해야 함
/// Components of tuple
type Suit = Heart | Spade | Diamond | Club
type Rank = Ace | Two | Queen | King // incomplete for clarity
// Tuple
type Card = Suit * Rank
/// Corresponding DTO types
type SuitDto = Heart = 1 | Spade = 2 | Diamond = 3 | Club = 4
type RankDto = Ace = 1 | Two = 2 | Queen = 12 | King = 13
type CardDto = {
Suit : SuitDto
Rank : RankDto
}
// 튜플 구성요소
type Suit = "Heart" | "Spade" | "Diamond" | "Club";
type Rank = "Ace" | "Two" | "Queen" | "King"; // 명확성을 위해 일부만 표기
// 튜플
type Card = [Suit, Rank];
// 대응 DTO 타입들
enum SuitDto {
Heart = 1,
Spade = 2,
Diamond = 3,
Club = 4,
}
enum RankDto {
Ace = 1,
Two = 2,
Queen = 12,
King = 13,
}
interface CardDto {
suit: SuitDto;
rank: RankDto;
}
11.5.7 선택 타입
- 선택 타입(Choice Type, 판별 유니온)은 “어떤 선택인지”를 나타내는 태그(tag) 필드와, 각 케이스별 데이터를 담는 필드들을 가진 레코드로 표현 가능
- 특정 케이스가 DTO로 변환될 때, 그 케이스의 필드에만 데이터가 채워지고 나머지 케이스의 필드는 모두
null(리스트라면 빈 값)이 됨
일부 직렬화기는 F# 판별 유니온 타입을 직접 다룰 수 있지만, 그 경우 포맷에 대한 통제권을 잃게 됨. 다른 직렬화기를 쓰는 다른 경계 지어진 컨텍스트가 그 포맷을 해석하지 못할 수 있으므로 문제가 될 수 있음. DTO는 계약의 일부이므로 포맷을 명시적으로 통제하는 편이 좋음
- 예제: 4가지 선택지를 가진 도메인 타입
Example- 빈 케이스, 태그
A - 정수, 태그
B - 문자열 리스트, 태그
C - 이름(
Name타입 사용), 태그D
- 빈 케이스, 태그
/// Domain types
type Name = {
First : String50
Last : String50
}
type Example =
| A
| B of int
| C of string list
| D of Name
interface Name {
first: String50;
last: String50;
}
type Example =
| { kind: "A" }
| { kind: "B"; value: number }
| { kind: "C"; value: string[] }
| { kind: "D"; value: Name };
- 대응 DTO 타입: 각 케이스의 타입을 직렬화 가능한 버전으로 치환 (
int->Nullable<int>,string list->string[],Name->NameDto)
/// Corresponding DTO types
type NameDto = {
First : string
Last : string
}
type ExampleDto = {
Tag : string // one of "A","B", "C", "D"
// no data for A case
BData : Nullable<int> // data for B case
CData : string[] // data for C case
DData : NameDto // data for D case
}
interface NameDto {
first: string;
last: string;
}
interface ExampleDto {
tag: "A" | "B" | "C" | "D";
// A 케이스는 데이터 없음
bData: number | null; // B 케이스 데이터
cData: string[] | null; // C 케이스 데이터
dData: NameDto | null; // D 케이스 데이터
}
- 직렬화: 선택된 케이스에 맞는 데이터만 채우고 나머지 케이스는 모두 null로 설정
let nameDtoFromDomain (name:Name) :NameDto =
let first = name.First |> String50.value
let last = name.Last |> String50.value
{First=first; Last=last}
function nameDtoFromDomain(name: Name): NameDto {
const first = String50.value(name.first);
const last = String50.value(name.last);
return { first, last };
}
let fromDomain (domainObj:Example) :ExampleDto =
let nullBData = Nullable()
let nullCData = null
let nullDData = Unchecked.defaultof<NameDto>
match domainObj with
| A ->
{Tag="A"; BData=nullBData; CData=nullCData; DData=nullDData}
| B i ->
let bdata = Nullable i
{Tag="B"; BData=bdata; CData=nullCData; DData=nullDData}
| C strList ->
let cdata = strList |> List.toArray
{Tag="C"; BData=nullBData; CData=cdata; DData=nullDData}
| D name ->
let ddata = name |> nameDtoFromDomain
{Tag="D"; BData=nullBData; CData=nullCData; DData=ddata}
function fromDomain(domainObj: Example): ExampleDto {
switch (domainObj.kind) {
case "A":
return { tag: "A", bData: null, cData: null, dData: null };
case "B":
return { tag: "B", bData: domainObj.value, cData: null, dData: null };
case "C":
return { tag: "C", bData: null, cData: domainObj.value, dData: null };
case "D":
return {
tag: "D",
bData: null,
cData: null,
dData: nameDtoFromDomain(domainObj.value),
};
}
}
-
위 코드에서 확인할 점
- 각 필드의 null 값을 함수 상단에서 미리 준비한 뒤, 해당 케이스와 무관한 필드에 할당
B케이스:Nullable<_>타입에는null을 직접 대입할 수 없어Nullable()함수를 사용 (TypeScript는null을 값 타입에도 그대로 대입 가능하므로 이 문제가 없음)C케이스: 배열은 .NET 클래스이므로null대입이 가능D케이스:NameDto같은 F# 레코드는null대입이 불가능하므로Unchecked.defaultof<_>라는 “백도어” 함수로 null 값을 만들어야 함. 이 함수는 상호운용/직렬화 목적 외에는 일반 코드에서 사용해서는 안 됨 (TypeScript는 인터페이스가 런타임 타입을 강제하지 않으므로 이런 백도어가 필요 없음)
-
역직렬화: 태그 필드로 매치한 뒤 각 케이스를 따로 처리. 역직렬화를 시도하기 전에 태그에 연계된 데이터가 null이 아닌지 항상 확인해야 함
let nameDtoToDomain (nameDto:NameDto) :Result<Name,string> =
result {
let! first = nameDto.First |> String50.create
let! last = nameDto.Last |> String50.create
return {First=first; Last=last}
}
let toDomain dto : Result<Example,string> =
match dto.Tag with
| "A" ->
Ok A
| "B" ->
if dto.BData.HasValue then
dto.BData.Value |> B |> Ok
else
Error "B data not expected to be null"
| "C" ->
match dto.CData with
| null ->
Error "C data not expected to be null"
| _ ->
dto.CData |> Array.toList |> C |> Ok
| "D" ->
match box dto.DData with
| null ->
Error "D data not expected to be null"
| _ ->
dto.DData
|> nameDtoToDomain // returns Result...
|> Result.map D // ...so must use "map"
| _ ->
// all other cases
let msg = sprintf "Tag '%s' not recognized" dto.Tag
Error msg
function nameDtoToDomain(nameDto: NameDto): Result<Name, string> {
const firstResult = String50.create("First", nameDto.first);
if (!firstResult.ok) return firstResult;
const lastResult = String50.create("Last", nameDto.last);
if (!lastResult.ok) return lastResult;
return {
ok: true,
value: { first: firstResult.value, last: lastResult.value },
};
}
function toDomain(dto: ExampleDto): Result<Example, string> {
switch (dto.tag) {
case "A":
return { ok: true, value: { kind: "A" } };
case "B":
if (dto.bData !== null) {
return { ok: true, value: { kind: "B", value: dto.bData } };
}
return { ok: false, error: "B data not expected to be null" };
case "C":
if (dto.cData === null) {
return { ok: false, error: "C data not expected to be null" };
}
return { ok: true, value: { kind: "C", value: dto.cData } };
case "D": {
if (dto.dData === null) {
return { ok: false, error: "D data not expected to be null" };
}
const nameResult = nameDtoToDomain(dto.dData);
if (!nameResult.ok) return nameResult;
return { ok: true, value: { kind: "D", value: nameResult.value } };
}
default:
return { ok: false, error: `Tag '${dto.tag}' not recognized` };
}
}
B,C케이스는 원시값에서 도메인값으로의 변환이 (null 아님을 확인한 후에는) 에러 없이 이뤄짐D케이스는NameDto->Name변환이 실패할 수 있으므로Result가 반환되고,Result.map으로D케이스 생성자를 매핑해야 함
11.5.8 맵으로 레코드 및 선택 타입 직렬화
- 복합 타입(레코드, 판별 유니온)을 직렬화하는 대안적 방법: 모든 것을 키-값 맵으로 직렬화
- .NET에서는
IDictionary<string,obj>타입으로 모든 DTO를 동일하게 구현 - JSON 포맷에서는 JSON 객체 모델과 잘 맞아 특히 유용
- .NET에서는
- 장점: DTO 구조에 내재된 “계약”이 없음. 키-값 맵은 무엇이든 담을 수 있으므로 매우 느슨하게 결합된 상호작용을 가능하게 함
- 단점: 계약이 아예 없다는 것 자체가 단점이기도 함. 프로듀서와 컨슈머 간 기대 불일치를 알아채기 어려움
- ==때로는 약간의 결합이 유용할 수 있음==
let nameDtoFromDomain (name:Name) :IDictionary<string,obj> =
let first = name.First |> String50.value :> obj
let last = name.Last |> String50.value :> obj
[
("First",first)
("Last",last)
] |> dict
function nameDtoFromDomain(name: Name): Record<string, unknown> {
const first = String50.value(name.first);
const last = String50.value(name.last);
return { First: first, Last: last };
}
-
키/값 쌍의 리스트를 만든 뒤 내장 함수
dict로IDictionary를 생성 -
이 딕셔너리가 JSON으로 직렬화되면, 별도의
NameDto타입을 만들어 직렬화한 것과 동일한 결과가 나옴 -
IDictionary는 값의 타입으로obj를 사용하므로, 레코드의 모든 값을 업캐스트 연산자:>로 명시적으로obj로 캐스팅해야 함 (TypeScript는unknown/구조적 타이핑을 쓰므로 이런 명시적 캐스팅이 필요 없음) -
선택 타입의 경우, 반환되는 딕셔너리는 정확히 하나의 엔트리를 가지며 키의 값은 선택된 케이스에 따라 달라짐
let fromDomain (domainObj:Example) :IDictionary<string,obj> =
match domainObj with
| A ->
[ ("A",null) ] |> dict
| B i ->
let bdata = Nullable i :> obj
[ ("B",bdata) ] |> dict
| C strList ->
let cdata = strList |> List.toArray :> obj
[ ("C",cdata) ] |> dict
| D name ->
let ddata = name |> nameDtoFromDomain :> obj
[ ("D",ddata) ] |> dict
function fromDomain(domainObj: Example): Record<string, unknown> {
switch (domainObj.kind) {
case "A":
return { A: null };
case "B":
return { B: domainObj.value };
case "C":
return { C: domainObj.value };
case "D":
return { D: nameDtoFromDomain(domainObj.value) };
}
}
-
각 케이스마다 데이터를 직렬화 가능한 형태로 변환한 뒤
obj로 캐스팅.D케이스에서는 데이터가Name이므로 직렬화 가능한 형태 역시 또 다른IDictionary가 됨 -
역직렬화는 조금 더 까다로움. 각 필드마다 (a) 딕셔너리에 해당 키가 있는지 확인하고, (b) 있으면 값을 꺼내 올바른 타입으로 캐스팅을 시도해야 함
-
이를 위한 헬퍼 함수
getValue
let getValue key (dict:IDictionary<string,obj>) :Result<'a,string> =
match dict.TryGetValue key with
| (true,value) -> // key found!
try
// downcast to the type 'a and return Ok
(value :?> 'a) |> Ok
with
| :? InvalidCastException ->
// the cast failed
let typeName = typeof<'a>.Name
let msg = sprintf "Value could not be cast to %s" typeName
Error msg
| (false,_) -> // key not found
let msg = sprintf "Key '%s' not found" key
Error msg
function getValue<T>(
key: string,
dict: Record<string, unknown>,
): Result<T, string> {
if (key in dict) {
// TypeScript는 런타임 캐스팅 예외가 없으므로 as로 단언함
// 운영 코드에서는 zod 등으로 런타임 검증을 추가하는 것이 안전함
return { ok: true, value: dict[key] as T };
}
return { ok: false, error: `Key '${key}' not found` };
}
Name역직렬화 예시:First키의 값을 가져온 뒤(실패 가능)String50.create호출(역시 실패 가능).Last키도 동일하게 처리
let nameDtoToDomain (nameDto:IDictionary<string,obj>) :Result<Name,string> =
result {
let! firstStr = nameDto |> getValue "First"
let! first = firstStr |> String50.create
let! lastStr = nameDto |> getValue "Last"
let! last = lastStr |> String50.create
return {First=first; Last=last}
}
function nameDtoToDomain(
nameDto: Record<string, unknown>,
): Result<Name, string> {
const firstStrResult = getValue<string>("First", nameDto);
if (!firstStrResult.ok) return firstStrResult;
const firstResult = String50.create("First", firstStrResult.value);
if (!firstResult.ok) return firstResult;
const lastStrResult = getValue<string>("Last", nameDto);
if (!lastStrResult.ok) return lastStrResult;
const lastResult = String50.create("Last", lastStrResult.value);
if (!lastResult.ok) return lastResult;
return {
ok: true,
value: { first: firstResult.value, last: lastResult.value },
};
}
- 선택 타입
Example의 역직렬화: 각 케이스에 대해 키가 존재하는지 검사하고, 존재하면 값을 꺼내 도메인 객체로 변환
let toDomain (dto:IDictionary<string,obj>) : Result<Example,string> =
if dto.ContainsKey "A" then
Ok A // no extra data needed
elif dto.ContainsKey "B" then
result {
let! bData = dto |> getValue "B" // might fail
return B bData
}
elif dto.ContainsKey "C" then
result {
let! cData = dto |> getValue "C" // might fail
return cData |> Array.toList |> C
}
elif dto.ContainsKey "D" then
result {
let! dData = dto |> getValue "D" // might fail
let! name = dData |> nameDtoToDomain // might also fail
return name |> D
}
else
// all other cases
let msg = sprintf "No union case recognized"
Error msg
function toDomain(dto: Record<string, unknown>): Result<Example, string> {
if ("A" in dto) {
return { ok: true, value: { kind: "A" } }; // 추가 데이터 불필요
} else if ("B" in dto) {
const bDataResult = getValue<number>("B", dto); // 실패 가능
if (!bDataResult.ok) return bDataResult;
return { ok: true, value: { kind: "B", value: bDataResult.value } };
} else if ("C" in dto) {
const cDataResult = getValue<string[]>("C", dto); // 실패 가능
if (!cDataResult.ok) return cDataResult;
return { ok: true, value: { kind: "C", value: cDataResult.value } };
} else if ("D" in dto) {
const dDataResult = getValue<Record<string, unknown>>("D", dto); // 실패 가능
if (!dDataResult.ok) return dDataResult;
const nameResult = nameDtoToDomain(dDataResult.value); // 이것도 실패 가능
if (!nameResult.ok) return nameResult;
return { ok: true, value: { kind: "D", value: nameResult.value } };
} else {
return { ok: false, error: "No union case recognized" };
}
}
11.5.9 제네릭
- 도메인 타입이 제네릭인 경우가 많음. 직렬화 라이브러리가 제네릭을 지원한다면 DTO도 제네릭으로 만들 수 있음
- 예: 제네릭
Result타입을 제네릭ResultDto로 변환
type ResultDto<'OkData,'ErrorData when 'OkData : null and 'ErrorData: null> = {
IsError : bool // replaces "Tag" field
OkData : 'OkData
ErrorData : 'ErrorData
}
interface ResultDto<OkData, ErrorData> {
isError: boolean; // "Tag" 필드를 대체
okData: OkData | null;
errorData: ErrorData | null;
}
- 제네릭 타입 파라미터
'OkData,'ErrorData는 연계된 JSON 객체에서 누락되거나 null일 수 있으므로 반드시 nullable로 제약해야 함- F#은
when 'OkData : null처럼 별도 제약 문법을 명시해야 함 - TypeScript는
OkData | null처럼 유니온에 이미 nullable을 내장하므로 별도 제약 문법이 필요 없음
- F#은
- 직렬화 라이브러리가 제네릭을 지원하지 않는다면 각 구체 케이스마다 별도 타입을 만들어야 함
- 번거로워 보이지만 실무에서는 직렬화가 필요한 제네릭 타입이 매우 적은 경우가 많음
- 예: 주문 생성(order-placing) 작업 흐름의
Result타입을 제네릭이 아닌 구체 타입의 DTO로 변환
type PlaceOrderResultDto = {
IsError : bool
OkData : PlaceOrderEventDto[]
ErrorData : PlaceOrderErrorDto
}
interface PlaceOrderResultDto {
isError: boolean;
okData: PlaceOrderEventDto[] | null;
errorData: PlaceOrderErrorDto | null;
}
11.6 마무리
- 이 챕터에서는 경계 지어진 컨텍스트와 깔끔한 도메인을 벗어나 인프라라는 지저분한 세계로 발을 들임
- 경계 지어진 컨텍스트와 바깥 세상 사이의 중개자 역할을 하는 직렬화 가능한 DTO를 설계하는 방법을 학습
- 직접 구현에 도움이 되는 다양한 가이드라인을 살펴봄
- 직렬화는 바깥 세상과의 상호작용 중 하나일 뿐, 유일한 상호작용은 아님
- 대부분의 애플리케이션은 어떤 형태로든 데이터베이스와 통신해야 함
- 다음 챕터에서는 영속화, 즉 도메인 모델을 관계형/NoSQL 데이터베이스와 함께 작동시키는 기법과 과제를 다룸
핵심 요약: 도메인 타입 -> DTO 변환 규칙
| 도메인 타입 패턴 | DTO 변환 규칙 | F# 대응 예시 | TypeScript 대응 예시 |
|---|---|---|---|
| 단일 케이스 유니온(단순 타입) | 내부 원시 타입 그대로 사용 | string |
string |
| 옵션(참조 타입) | null로 대체 | 그대로 유지 | T 또는 null |
| 옵션(값 타입) | Nullable 타입 사용 | Nullable<int> |
number 또는 null |
| 레코드 | 필드별 DTO 타입 변환, 구조 유지 | XDto 레코드 |
XDto 인터페이스 |
| 리스트/시퀀스/셋 | 배열로 변환 | T[] |
T[] |
| 맵 | 키-값 쌍 배열, 병렬 배열, 또는 JSON 객체 | PairDto[] |
Record<string, V> |
| 열거형 스타일 유니온 | 정수 enum 또는 문자열로 표현 | Red=1, Green=2 형태 enum |
enum ColorDto |
| 튜플 | 별도 레코드로 표현 | XDto 레코드 |
XDto 인터페이스 |
| 선택 타입(판별 유니온) | Tag 필드 및 케이스별 nullable 필드 | Tag: string 필드 |
tag: string 필드 |
| 제네릭 타입 | 라이브러리 지원 시 제네릭 DTO, 아니면 구체 타입별 DTO | ResultDto<'a,'b> |
ResultDto<A, B> |
12장 함수형 도메인 모델에서의 영속화(Persistence)
핵심 개념 한눈에 보기
| 개념 | 한 줄 요약 | 관련 절 |
|---|---|---|
| 함수형 코어 / 명령형 셸 | 도메인 로직은 순수 함수로, IO는 바깥으로 분리 | 12.1 |
| 리포지터리 재고 | 제네릭 인터페이스 대신 명시적 함수 의존성 주입 | 12.1.2 |
| CQS / CQRS | 명령과 조회 책임을 함수·모델 단위로 분리 | 12.2 |
| 이벤트 소싱 | 상태 대신 이벤트 이력을 저장, 상태는 재생으로 도출 | 12.2.3 |
| 컨텍스트별 DB 소유 | 다른 컨텍스트 DB에 직접 접근 금지 | 12.3 |
| 문서형 DB 매핑 | 애그리게잇을 문서 하나로 직렬화 | 12.4 |
| 관계형 DB 매핑 | 선택 타입/중첩 타입을 테이블 구조로 변환 | 12.5 |
| 트랜잭션 경계 | 보통 애그리게잇 경계와 일치 | 12.6 |
12.0 예시 도메인 소개
노트 전체에서 일관되게 사용할 예제 도메인: 도서관 도서 대출 시스템
BookId,MemberId: 원시값을 감싼 식별자 타입Book: 도서 정보(제목, 저자, 대출 가능 여부)Author: 저자 정보(중첩 타입 예시로 사용)LoanStatus: 대출중 / 반납완료 / 연체 중 하나를 표현하는 선택 타입(F#의 Discriminated Union)Loan: 대출 애그리게잇
type BookId = BookId of string
type MemberId = MemberId of string
type Author = {
Name: string
Country: string
}
type Book = {
BookId: BookId
Title: string
Author: Author
IsAvailable: bool
}
// 선택 타입: 세 가지 상태 중 하나만 가질 수 있음
type LoanStatus =
| Active
| Returned of returnedAt: System.DateTime
| Overdue of daysLate: int
type Loan = {
LoanId: string
BookId: BookId
MemberId: MemberId
Status: LoanStatus
}
type BookId = string & { readonly brand: unique symbol };
type MemberId = string & { readonly brand: unique symbol };
interface Author {
name: string;
country: string;
}
interface Book {
bookId: BookId;
title: string;
author: Author;
isAvailable: boolean;
}
// 선택 타입: 태그드 유니온으로 표현
type LoanStatus =
| { kind: "Active" }
| { kind: "Returned"; returnedAt: string }
| { kind: "Overdue"; daysLate: number };
interface Loan {
loanId: string;
bookId: BookId;
memberId: MemberId;
status: LoanStatus;
}
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
12.1 영속화 로직을 시스템 경계로 밀어내기
- 도메인 로직(순수 함수)과 부수효과(DB 접근, 파일 IO, 네트워크 호출)를 명확히 분리
- 흔히 ==함수형 코어, 명령형 셸(Functional Core, Imperative Shell)== 이라 부르는 구조
- 워크플로우 함수 자체는 DB를 모름, 이미 준비된 데이터를 입력으로 받아 판단만 수행
- 결과: 워크플로우 함수는 테스트가 쉬워지고, 부수효과는 셸 계층에 격리됨
graph LR
subgraph Shell["명령형 셸 (부수효과 담당)"]
A["DB에서 Book 조회"] --> B
D["DB에 Loan 저장"]
end
subgraph Core["함수형 코어 (순수 로직)"]
B["createLoan: 순수 함수"] --> C["성공/실패 결과"]
end
C --> D
12.1.1 입력 데이터만으로 판단하는 워크플로우 설계
- 워크플로우 실행 전, 필요한 모든 데이터를 미리 조회해서 함수의 인자로 전달
- 워크플로우 도중에 추가 조회를 하지 않도록 설계 원칙을 유지
- 순수 함수는 같은 입력에 항상 같은 출력을 반환하므로 단위 테스트에서 DB 없이 검증 가능
type CreateLoanCommand = {
BookId: BookId
MemberId: MemberId
}
// 순수 함수: DB 접근 없이 이미 조회된 Book을 인자로 받음
let createLoan (book: Book) (cmd: CreateLoanCommand) : Result<Loan, string> =
if not book.IsAvailable then
Error "대출 가능한 도서가 아닙니다"
else
Ok {
LoanId = System.Guid.NewGuid().ToString()
BookId = cmd.BookId
MemberId = cmd.MemberId
Status = Active
}
interface CreateLoanCommand {
bookId: BookId;
memberId: MemberId;
}
// 순수 함수: DB 접근 없이 이미 조회된 Book을 인자로 받음
function createLoan(book: Book, cmd: CreateLoanCommand): Result<Loan, string> {
if (!book.isAvailable) {
return { ok: false, error: "대출 가능한 도서가 아닙니다" };
}
return {
ok: true,
value: {
loanId: crypto.randomUUID(),
bookId: cmd.bookId,
memberId: cmd.memberId,
status: { kind: "Active" },
},
};
}
셸(Shell)에서 조회와 저장을 담당하는 예:
let createLoanWorkflow
(loadBook: BookId -> Book option)
(saveLoan: Loan -> unit)
(cmd: CreateLoanCommand) : Result<Loan, string> =
match loadBook cmd.BookId with
| None -> Error "도서를 찾을 수 없습니다"
| Some book ->
createLoan book cmd
|> Result.map (fun loan ->
saveLoan loan
loan)
function createLoanWorkflow(
loadBook: (id: BookId) => Book | undefined,
saveLoan: (loan: Loan) => void,
cmd: CreateLoanCommand
): Result<Loan, string> {
const book = loadBook(cmd.bookId);
if (!book) {
return { ok: false, error: "도서를 찾을 수 없습니다" };
}
const result = createLoan(book, cmd);
if (result.ok) {
saveLoan(result.value);
}
return result;
}
핵심 통찰: 워크플로우 함수의 시그니처만 보고도 “이 함수가 무엇을 필요로 하는지”가 드러남.
loadBook,saveLoan을 인자로 명시적으로 주입하기 때문에 숨은 의존성이 없음.
12.1.2 리포지터리 패턴을 다시 생각하기
- OOP에서 흔한 제네릭 리포지터리 인터페이스(
IRepository<T>에Add,Update,Delete,GetById등)를 그대로 함수형에 가져오면 문제 발생 - 문제점: 제네릭 인터페이스는 도메인마다 다른 저장 방식(부분 갱신, 배치 처리, 조건부 조회 등)을 표현하기 어려움
- 함수형 접근: “리포지터리”라는 객체 대신, 필요한 개별 함수 타입을 정의하고 워크플로우에 파라미터로 주입
- 즉, 추상화 계층 자체를 항상 만들 필요는 없으며 함수 조합으로 충분한 경우가 많음
// 제네릭 리포지터리 인터페이스 대신, 필요한 함수만 명시적으로 정의
type LoadBook = BookId -> Book option
type SaveLoan = Loan -> unit
type LoadLoan = string -> Loan option
type LoadBook = (id: BookId) => Book | undefined;
type SaveLoan = (loan: Loan) => void;
type LoadLoan = (loanId: string) => Loan | undefined;
12.2 명령(갱신)과 질의(조회) 분리하기
- CQS(Command-Query Separation, Bertrand Meyer 제안): 함수는 “명령”이거나 “질의” 중 하나여야 함
- 명령: 상태를 변경, 의미 있는 반환값 없음
- 질의: 상태 변경 없이 값만 반환
- 순수 함수는 태생적으로 질의에 가깝고, 부수효과가 있는 함수는 명령에 가까움 -> 함수형 스타일은 자연스럽게 CQS를 지향
12.2.1 CQS에서 CQRS로
- CQRS(Command Query Responsibility Segregation): CQS의 아키텍처 확장
- 쓰기 모델(Command Model): 도메인 불변식을 강제하며 상태를 변경
- 읽기 모델(Read Model): 특정 화면/보고서에 최적화된 비정규화 데이터 구조, 도메인 규칙 없이 단순 조회만 수행
- 하나의 애그리게잇 구조가 조회 요구사항에 그대로 맞지 않는 경우가 많음 -> 조회 전용 모델을 별도로 설계
// 쓰기 모델: 도메인 불변식이 담긴 애그리게잇
type Loan = {
LoanId: string
BookId: BookId
MemberId: MemberId
Status: LoanStatus
}
// 읽기 모델: 화면에 필요한 형태로 이미 비정규화됨
type LoanHistoryRow = {
LoanId: string
BookTitle: string
MemberName: string
StatusLabel: string
}
// 읽기 모델: 화면에 필요한 형태로 이미 비정규화됨
interface LoanHistoryRow {
loanId: string;
bookTitle: string;
memberName: string;
statusLabel: string;
}
12.2.2 CQRS와 데이터베이스 분리
- 쓰기 전용 DB(정규화된 트랜잭션 DB)와 읽기 전용 DB(비정규화된 조회 최적화 DB, 검색 인덱스, 캐시 등)를 물리적으로 분리 가능
- 쓰기 DB의 변경 사항을 읽기 DB로 전파하는 방법: 이벤트 발행 후 구독자가 읽기 모델 갱신, 또는 배치 동기화
- 완전 분리 시 결과적 일관성(Eventual Consistency)을 감수해야 함(읽기 모델이 아주 짧은 시간 지연될 수 있음)
flowchart LR
A["커맨드: CreateLoan"] --> B["쓰기 모델 / 도메인 로직"]
B --> C[("쓰기 전용 DB")]
B --> E["이벤트 발행: LoanCreated"]
E --> F["프로젝션 갱신기"]
F --> G[("읽기 전용 DB")]
H["질의: GetLoanHistory"] --> G
12.2.3 이벤트 소싱
- 현재 상태(스냅샷)를 저장하는 대신, 상태를 변화시킨 이벤트의 연속을 저장
- 현재 상태는 이벤트를 처음부터 재생(replay)하여 계산하는 파생 데이터
- 이벤트 소싱은 CQRS와 자연스럽게 결합됨: 쓰기 = 이벤트 스토어에 이벤트 추가, 읽기 = 이벤트 구독을 통한 프로젝션(뷰) 생성
- 장점: 완전한 이력 추적, 감사(audit) 용이, 특정 시점 상태 재구성 가능
- 단점: 조회 시 재생 비용, 이벤트 스키마 변경(버저닝) 관리 필요
type LoanEvent =
| LoanCreated of loanId: string * bookId: BookId * memberId: MemberId
| BookReturned of loanId: string * returnedAt: System.DateTime
// 현재 상태는 이벤트를 접어서(fold) 계산
let applyEvent (stateOpt: Loan option) (event: LoanEvent) : Loan option =
match event, stateOpt with
| LoanCreated (loanId, bookId, memberId), None ->
Some { LoanId = loanId; BookId = bookId; MemberId = memberId; Status = Active }
| BookReturned (_, returnedAt), Some state ->
Some { state with Status = Returned returnedAt }
| _ -> stateOpt
let replayEvents (events: LoanEvent list) : Loan option =
events |> List.fold applyEvent None
type LoanEvent =
| { type: "LoanCreated"; loanId: string; bookId: BookId; memberId: MemberId }
| { type: "BookReturned"; loanId: string; returnedAt: string };
function applyEvent(state: Loan | undefined, event: LoanEvent): Loan | undefined {
switch (event.type) {
case "LoanCreated":
return {
loanId: event.loanId,
bookId: event.bookId,
memberId: event.memberId,
status: { kind: "Active" },
};
case "BookReturned":
return state
? { ...state, status: { kind: "Returned", returnedAt: event.returnedAt } }
: state;
}
}
function replayEvents(events: LoanEvent[]): Loan | undefined {
return events.reduce(applyEvent, undefined as Loan | undefined);
}
12.3 경계 컨텍스트별로 데이터 저장소를 소유하기
- 각 바운디드 컨텍스트는 자신만의 데이터 저장소(스키마, 테이블, 컬렉션)를 소유
- 다른 컨텍스트의 DB에 직접 접근하는 것은 금지, 반드시 해당 컨텍스트가 공개하는 API 또는 이벤트를 통해서만 상호작용
- 이유: 컨텍스트 간 강결합 방지, 각 컨텍스트가 독립적으로 스키마를 바꿀 수 있어야 함
graph TB
subgraph BC1["대출 컨텍스트"]
DB1[("대출 DB")]
end
subgraph BC2["도서 카탈로그 컨텍스트"]
DB2[("카탈로그 DB")]
end
BC2 -- "BookCatalogUpdated 이벤트" --> BC1
BC1 -. "직접 접근 금지" .-> DB2
12.3.1 다른 도메인의 데이터가 필요할 때
- 필요한 참조 데이터(Reference Data)는 각 컨텍스트가 로컬 사본(캐시)으로 유지
- 다른 컨텍스트가 발행한 이벤트를 구독해 로컬 사본을 최신 상태로 갱신
- 안티코럽션 계층(Anti-Corruption Layer): 외부 모델을 내부 도메인 모델로 변환한 뒤 사용, 외부 모델 변경이 내부 도메인에 직접 전파되지 않도록 방지
// 카탈로그 컨텍스트에서 온 이벤트를 대출 컨텍스트의 로컬 모델로 변환
type CatalogBookUpdatedEvent = {
ExternalBookId: string
Title: string
IsAvailable: bool
}
// 안티코럽션 계층: 외부 이벤트를 내부 모델로 변환
let toLocalBook (evt: CatalogBookUpdatedEvent) : Book =
{
BookId = BookId evt.ExternalBookId
Title = evt.Title
Author = { Name = "미상"; Country = "미상" } // 대출 컨텍스트가 필요로 하지 않는 정보는 생략 가능
IsAvailable = evt.IsAvailable
}
interface CatalogBookUpdatedEvent {
externalBookId: string;
title: string;
isAvailable: boolean;
}
function toLocalBook(evt: CatalogBookUpdatedEvent): Book {
return {
bookId: evt.externalBookId as BookId,
title: evt.title,
author: { name: "미상", country: "미상" },
isAvailable: evt.isAvailable,
};
}
12.4 문서형 데이터베이스 활용하기
- 문서형 DB(예: MongoDB)는 도메인 타입(레코드, 선택 타입)을 JSON에 가까운 형태로 거의 그대로 저장 가능
- 장점: 애그리게잇 전체를 문서 하나로 저장, 관계형 DB의 JOIN 없이 한 번에 읽기/쓰기, 트랜잭션 경계와 애그리게잇 경계가 자연스럽게 일치
- 단점: 복잡한 질의(여러 애그리게잇 교차 조회)에 상대적으로 불리, 문서 스키마 버전 관리 필요
// F#의 선택 타입도 JSON 직렬화 라이브러리(예: Thoth.Json, System.Text.Json 커스텀 컨버터)를 통해
// 거의 그대로 문서로 저장 가능
type LoanDocument = {
LoanId: string
BookId: string
MemberId: string
Status: LoanStatus
}
// TypeScript/JSON은 태그드 유니온을 그대로 직렬화하는 데 특히 자연스러움
interface LoanDocument {
loanId: string;
bookId: string;
memberId: string;
status: LoanStatus;
}
async function saveLoanDocument(collection: any, doc: LoanDocument): Promise<void> {
await collection.replaceOne({ loanId: doc.loanId }, doc, { upsert: true });
}
async function loadLoanDocument(collection: any, loanId: string): Promise<LoanDocument | null> {
return collection.findOne({ loanId });
}
12.5 관계형 데이터베이스 활용하기
- 관계형 DB는 스키마가 고정되어 있어 선택 타입/중첩 타입을 그대로 저장할 수 없음 -> 매핑 전략 필요
- 원칙: 도메인 모델(불변식이 강제된 타입)과 DB 모델(평평한 DTO/Row)을 분리하고, 그 사이를 변환하는 전용 매핑 함수를 작성
flowchart LR
A["도메인 타입 (Loan)"] -- "toDto" --> B["DTO / Row"]
B -- "SQL INSERT/UPDATE" --> C[("관계형 DB")]
C -- "SQL SELECT" --> D["Row"]
D -- "fromDto (검증 포함)" --> E["도메인 타입 (Loan)"]
12.5.1 선택 타입을 테이블에 매핑하기
| 전략 | 설명 | 장점 | 단점 |
|---|---|---|---|
| 널 허용 컬럼 | 케이스별 컬럼을 두고 해당 없는 컬럼은 NULL | 구현이 단순, 테이블 하나로 처리 | 케이스가 늘수록 컬럼 폭증, NULL 조합만으로는 불변식 표현이 약함 |
| 구분자 컬럼 + 단일 테이블 | type 컬럼으로 케이스를 구분 |
조회가 단순, 테이블 하나 유지 | 케이스별 컬럼은 여전히 NULL 허용 필요 |
| 케이스별 별도 테이블 | 공통 테이블 + 케이스별 하위 테이블(FK) | 정규화 수준이 높고 불필요한 NULL이 없음 | JOIN 필요, 구현 복잡도 상승 |
flowchart TD
A["선택 타입을 어떻게 저장할까?"] --> B{"케이스 수가 적고 변경이 드문가?"}
B -->|예| C["널 허용 컬럼 전략"]
B -->|아니오| D{"케이스별 데이터 구조 차이가 큰가?"}
D -->|예| E["케이스별 별도 테이블 전략"]
D -->|아니오| F["구분자 컬럼 + 단일 테이블 전략"]
구분자 컬럼 + 단일 테이블 전략 예시:
CREATE TABLE loans (
loan_id UUID PRIMARY KEY,
book_id UUID NOT NULL,
member_id UUID NOT NULL,
status_type VARCHAR(20) NOT NULL, -- 'Active' | 'Returned' | 'Overdue'
returned_at TIMESTAMP NULL, -- Returned 케이스에서만 값 존재
days_late INT NULL -- Overdue 케이스에서만 값 존재
);
type LoanStatusDto = {
StatusType: string
ReturnedAt: System.DateTime option
DaysLate: int option
}
let toDto (status: LoanStatus) : LoanStatusDto =
match status with
| Active ->
{ StatusType = "Active"; ReturnedAt = None; DaysLate = None }
| Returned returnedAt ->
{ StatusType = "Returned"; ReturnedAt = Some returnedAt; DaysLate = None }
| Overdue daysLate ->
{ StatusType = "Overdue"; ReturnedAt = None; DaysLate = Some daysLate }
let fromDto (dto: LoanStatusDto) : Result<LoanStatus, string> =
match dto.StatusType with
| "Active" -> Ok Active
| "Returned" ->
match dto.ReturnedAt with
| Some d -> Ok (Returned d)
| None -> Error "Returned 상태에는 ReturnedAt 값이 필요합니다"
| "Overdue" ->
match dto.DaysLate with
| Some n -> Ok (Overdue n)
| None -> Error "Overdue 상태에는 DaysLate 값이 필요합니다"
| other -> Error (sprintf "알 수 없는 상태 타입: %s" other)
interface LoanStatusDto {
statusType: "Active" | "Returned" | "Overdue";
returnedAt: string | null;
daysLate: number | null;
}
function toDto(status: LoanStatus): LoanStatusDto {
switch (status.kind) {
case "Active":
return { statusType: "Active", returnedAt: null, daysLate: null };
case "Returned":
return { statusType: "Returned", returnedAt: status.returnedAt, daysLate: null };
case "Overdue":
return { statusType: "Overdue", returnedAt: null, daysLate: status.daysLate };
}
}
function fromDto(dto: LoanStatusDto): Result<LoanStatus, string> {
switch (dto.statusType) {
case "Active":
return { ok: true, value: { kind: "Active" } };
case "Returned":
return dto.returnedAt !== null
? { ok: true, value: { kind: "Returned", returnedAt: dto.returnedAt } }
: { ok: false, error: "Returned 상태에는 returnedAt 값이 필요합니다" };
case "Overdue":
return dto.daysLate !== null
? { ok: true, value: { kind: "Overdue", daysLate: dto.daysLate } }
: { ok: false, error: "Overdue 상태에는 daysLate 값이 필요합니다" };
}
}
케이스별 별도 테이블 전략 예시(참고):
CREATE TABLE loans (
loan_id UUID PRIMARY KEY,
book_id UUID NOT NULL,
member_id UUID NOT NULL,
status_type VARCHAR(20) NOT NULL
);
CREATE TABLE loan_returned (
loan_id UUID PRIMARY KEY REFERENCES loans(loan_id),
returned_at TIMESTAMP NOT NULL
);
CREATE TABLE loan_overdue (
loan_id UUID PRIMARY KEY REFERENCES loans(loan_id),
days_late INT NOT NULL
);
12.5.2 중첩 타입을 테이블에 매핑하기
- 전략 1: 평탄화(Flatten) - 중첩 레코드의 필드를 상위 테이블 컬럼으로 풀어냄, 1:1 관계에 적합
- 전략 2: 별도 테이블 + FK - 중첩 타입이 1:N 관계이거나 여러 곳에서 재사용될 때 적합
-- 전략 1: 평탄화
CREATE TABLE books_flat (
book_id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author_name VARCHAR(255) NOT NULL,
author_country VARCHAR(100) NOT NULL
);
-- 전략 2: 별도 테이블 + FK (저자가 여러 도서를 가질 수 있는 경우)
CREATE TABLE authors (
author_id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
country VARCHAR(100) NOT NULL
);
CREATE TABLE books (
book_id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author_id UUID NOT NULL REFERENCES authors(author_id)
);
// 전략 1: 평탄화 DTO
type BookRow = {
BookId: string
Title: string
AuthorName: string
AuthorCountry: string
}
let toBookRow (book: Book) : BookRow =
let (BookId bookId) = book.BookId
{ BookId = bookId
Title = book.Title
AuthorName = book.Author.Name
AuthorCountry = book.Author.Country }
let fromBookRow (row: BookRow) : Book =
{ BookId = BookId row.BookId
Title = row.Title
Author = { Name = row.AuthorName; Country = row.AuthorCountry }
IsAvailable = true }
interface BookRow {
bookId: string;
title: string;
authorName: string;
authorCountry: string;
}
function toBookRow(book: Book): BookRow {
return {
bookId: book.bookId,
title: book.title,
authorName: book.author.name,
authorCountry: book.author.country,
};
}
function fromBookRow(row: BookRow): Book {
return {
bookId: row.bookId as BookId,
title: row.title,
author: { name: row.authorName, country: row.authorCountry },
isAvailable: true,
};
}
12.5.3 관계형 데이터베이스에서 데이터 읽기
- 읽기 흐름: (1) SQL 조회로 원시 Row 획득 -> (2) Row를 도메인 타입으로 변환하며 검증(스마트 생성자 활용) -> (3) 검증 실패 시
Result/Option으로 오류 표현 - DB에 저장된 데이터라도 항상 도메인 불변식을 재검증하는 것이 안전(과거 마이그레이션, 수동 수정 등으로 데이터 불일치 가능)
type LoanRow = {
LoanId: string
BookId: string
MemberId: string
StatusType: string
ReturnedAt: System.DateTime option
DaysLate: int option
}
let loanFromRow (row: LoanRow) : Result<Loan, string> =
fromDto { StatusType = row.StatusType; ReturnedAt = row.ReturnedAt; DaysLate = row.DaysLate }
|> Result.map (fun status ->
{ LoanId = row.LoanId
BookId = BookId row.BookId
MemberId = MemberId row.MemberId
Status = status })
interface LoanRow {
loanId: string;
bookId: string;
memberId: string;
statusType: "Active" | "Returned" | "Overdue";
returnedAt: string | null;
daysLate: number | null;
}
function loanFromRow(row: LoanRow): Result<Loan, string> {
const statusResult = fromDto({
statusType: row.statusType,
returnedAt: row.returnedAt,
daysLate: row.daysLate,
});
if (!statusResult.ok) return statusResult;
return {
ok: true,
value: {
loanId: row.loanId,
bookId: row.bookId as BookId,
memberId: row.memberId as MemberId,
status: statusResult.value,
},
};
}
12.5.4 관계형 데이터베이스에서 선택 타입 읽기
- 12.5.1의
fromDto와 같은 순수 매핑 함수를 실제 SQL 조회와 결합하는 예 - Node.js/TypeScript 환경에서는
pg,mysql2같은 드라이버나 Prisma 등 ORM으로 Row를 얻고, 이를 매핑 함수로 변환
import { Pool } from "pg";
async function getLoanById(pool: Pool, loanId: string): Promise<Result<Loan, string>> {
const { rows } = await pool.query<LoanRow>(
`SELECT loan_id AS "loanId", book_id AS "bookId", member_id AS "memberId",
status_type AS "statusType", returned_at AS "returnedAt", days_late AS "daysLate"
FROM loans WHERE loan_id = $1`,
[loanId]
);
if (rows.length === 0) {
return { ok: false, error: "대출 정보를 찾을 수 없습니다" };
}
return loanFromRow(rows[0]);
}
open System.Data
let getLoanById (connection: IDbConnection) (loanId: string) : Async<Result<Loan, string>> =
async {
let sql = """
SELECT loan_id AS LoanId, book_id AS BookId, member_id AS MemberId,
status_type AS StatusType, returned_at AS ReturnedAt, days_late AS DaysLate
FROM loans WHERE loan_id = @LoanId
"""
let! rowOpt =
connection.QuerySingleOrDefaultAsync<LoanRow>(sql, {| LoanId = loanId |})
|> Async.AwaitTask
match box rowOpt with
| null -> return Error "대출 정보를 찾을 수 없습니다"
| _ -> return loanFromRow rowOpt
}
12.5.5 관계형 데이터베이스에 데이터 쓰기
- 쓰기 흐름: 도메인 타입 ->
toDto변환 -> SQL INSERT/UPDATE 실행 - 애그리게잇이 여러 테이블에 걸쳐 있다면 하나의 트랜잭션으로 묶어 원자성 보장(12.6절 참고)
async function saveLoan(pool: Pool, loan: Loan): Promise<void> {
const dto = toDto(loan.status);
await pool.query(
`INSERT INTO loans (loan_id, book_id, member_id, status_type, returned_at, days_late)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (loan_id) DO UPDATE
SET status_type = $4, returned_at = $5, days_late = $6`,
[loan.loanId, loan.bookId, loan.memberId, dto.statusType, dto.returnedAt, dto.daysLate]
);
}
let saveLoan (connection: IDbConnection) (loan: Loan) : Async<unit> =
async {
let dto = toDto loan.Status
let (BookId bookId) = loan.BookId
let (MemberId memberId) = loan.MemberId
let sql = """
INSERT INTO loans (loan_id, book_id, member_id, status_type, returned_at, days_late)
VALUES (@LoanId, @BookId, @MemberId, @StatusType, @ReturnedAt, @DaysLate)
ON CONFLICT (loan_id) DO UPDATE
SET status_type = @StatusType, returned_at = @ReturnedAt, days_late = @DaysLate
"""
do! connection.ExecuteAsync(sql, {|
LoanId = loan.LoanId
BookId = bookId
MemberId = memberId
StatusType = dto.StatusType
ReturnedAt = dto.ReturnedAt
DaysLate = dto.DaysLate
|}) |> Async.AwaitTask |> Async.Ignore
}
12.6 트랜잭션 경계 설계하기
- 트랜잭션 경계는 일반적으로 애그리게잇 경계와 일치시킴(하나의 애그리게잇 갱신 = 하나의 DB 트랜잭션)
- 여러 애그리게잇에 걸친 변경이 필요하다면, 단일 트랜잭션으로 묶기보다 이벤트를 통한 결과적 일관성으로 처리하는 편을 권장
- 동시성 제어가 필요한 경우
SELECT ... FOR UPDATE등으로 행 잠금 후 갱신
graph LR
subgraph Tx["DB 트랜잭션 경계"]
A["Loan 애그리게잇 조회 (FOR UPDATE)"] --> B["상태 전이 검증"]
B --> C["Loan 갱신"]
end
C --> D["COMMIT"]
B -->|검증 실패| E["ROLLBACK"]
async function returnBookTransactional(pool: Pool, loanId: string): Promise<Result<void, string>> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<LoanRow>(
`SELECT * FROM loans WHERE loan_id = $1 FOR UPDATE`,
[loanId]
);
if (rows.length === 0) {
await client.query("ROLLBACK");
return { ok: false, error: "대출 정보를 찾을 수 없습니다" };
}
await client.query(
`UPDATE loans SET status_type = 'Returned', returned_at = NOW() WHERE loan_id = $1`,
[loanId]
);
await client.query("COMMIT");
return { ok: true, value: undefined };
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
let returnBookTransactional (connection: IDbConnection) (loanId: string) : Async<Result<unit, string>> =
async {
use transaction = connection.BeginTransaction()
try
let! rowOpt =
connection.QuerySingleOrDefaultAsync<LoanRow>(
"SELECT * FROM loans WHERE loan_id = @LoanId FOR UPDATE",
{| LoanId = loanId |}, transaction)
|> Async.AwaitTask
match box rowOpt with
| null ->
transaction.Rollback()
return Error "대출 정보를 찾을 수 없습니다"
| _ ->
do! connection.ExecuteAsync(
"UPDATE loans SET status_type = 'Returned', returned_at = NOW() WHERE loan_id = @LoanId",
{| LoanId = loanId |}, transaction)
|> Async.AwaitTask |> Async.Ignore
transaction.Commit()
return Ok ()
with ex ->
transaction.Rollback()
return Error ex.Message
}
12.7 정리 및 핵심 인사이트
- 영속화 로직은 도메인 로직 바깥(셸)으로 밀어내고, 워크플로우는 순수 함수로 유지
- 제네릭 리포지터리 인터페이스를 무비판적으로 도입하기보다, 필요한 함수만 명시적으로 정의해 주입
- CQS/CQRS로 명령과 조회의 책임을 분리하면 각 모델을 목적에 맞게 최적화 가능
- 이벤트 소싱은 상태 대신 이벤트 이력을 저장, 감사·이력 추적에 강점
- 바운디드 컨텍스트는 자신의 데이터 저장소를 소유, 컨텍스트 간에는 API/이벤트로만 통신
- 문서형 DB는 애그리게잇을 통째로 저장하기 유리, 관계형 DB는 선택 타입/중첩 타입에 대해 명시적 매핑 전략(널 허용 컬럼, 구분자 컬럼, 별도 테이블) 필요
- DB에서 읽은 데이터도 도메인 스마트 생성자로 재검증하여 불변식 보장
- 트랜잭션 경계는 애그리게잇 경계에 맞추고, 여러 애그리게잇 간에는 결과적 일관성을 활용
| 상황 | 권장 접근 |
|---|---|
| 워크플로우가 부수효과 없이 판단만 하면 되는가 | 순수 함수 + 셸에서 IO 처리 |
| 저장소 접근 패턴이 도메인마다 다른가 | 제네릭 리포지터리보다 개별 함수 의존성 주입 |
| 읽기와 쓰기 요구사항이 크게 다른가 | CQRS로 모델 분리 |
| 완전한 이력 추적이 필요한가 | 이벤트 소싱 고려 |
| 애그리게잇을 통째로 읽고 쓰는가 | 문서형 DB 고려 |
| 복잡한 조인/집계 질의가 필요한가 | 관계형 DB + 읽기 전용 뷰 고려 |
챕터 13 깔끔하게 디자인 발전시키기
- 도메인 모델과 구현을 완성했지만, 이것이 끝이 아님
- 흔한 문제: 도메인 모델이 처음에는 깔끔하고 우아하게 시작하지만, 요구사항이 바뀌면서 모델이 지저분해지고 하위 시스템들이 서로 얽혀 테스트하기 어려워지는 경우가 많음
- 이번 장의 핵심 질문: 모델을 진흙 덩어리(big ball of mud)로 만들지 않으면서 발전시킬 수 있는가
- **도메인 주도 설계(DDD)**는 한 번으로 끝나는 정적 프로세스가 아니라, 개발자, 도메인 전문가, 이해관계자 간의 지속적인 협업 과정임
- 요구사항이 바뀌면 곧바로 구현을 수정하지 말고, 항상 도메인 모델을 먼저 재검토해야 함
- 타입을 적극적으로 활용한 설계 덕분에, 모델을 변경해도 코드가 실수로 깨지지 않았다는 확신을 높은 수준으로 가질 수 있음
- 이번 장에서 다루는 4가지 변경 유형
- 작업 흐름에 새로운 단계 추가하기
- 작업 흐름의 입력 변경하기
- 핵심 도메인 타입(주문)의 정의를 변경하고, 그 파급 효과를 시스템 전반에서 추적하기
- 비즈니스 규칙에 맞춰 작업 흐름 전체를 변환하기
13.1 첫 번째 변경: 배송비 추가하기
- 요구사항: 배송지에 따라 배송비를 차등 부과
- 미국 내 인근 주(로컬 스테이트) 배송: 5달러
- 미국 내 원거리 주(리모트 스테이트) 배송: 10달러
- 미국 외 국가(인터내셔널) 배송: 20달러
- 최초 구현안은 국가와 주(state)를 검사하는
if/match중첩 조건 분기 방식 - 문제점: 여러 조건이 뒤섞인 분기 로직은 이해하기 어렵고 유지보수가 힘듦
F#:
/// Calculate the shipping cost for an order
let calculateShippingCost validatedOrder =
let shippingAddress = validatedOrder.ShippingAddress
if shippingAddress.Country = "US" then
// shipping inside USA
match shippingAddress.State with
| "CA" | "OR" | "AZ" | "NV" ->
5.0 //local
| _ ->
10.0 //remote
else
// shipping outside USA
20.0
TypeScript:
type Address = {
country: string;
state: string;
};
// 주문의 배송비를 계산
function calculateShippingCost(validatedOrder: { shippingAddress: Address }): number {
const { shippingAddress } = validatedOrder;
if (shippingAddress.country === "US") {
// 미국 내 배송
switch (shippingAddress.state) {
case "CA":
case "OR":
case "AZ":
case "NV":
return 5.0; // 인근 지역
default:
return 10.0; // 원거리 지역
}
} else {
// 미국 외 배송
return 20.0;
}
}
13.1.1 관심사 분리로 비즈니스 로직 단순하게 만들기
- 해결책: 도메인 중심의 **분류(categorization)**와 실제 가격 책정 로직을 분리
- F#의 활성 패턴(active pattern) 활용
- 조건 로직을 마치 구별된 유니온(discriminated union) 타입처럼 이름 붙은 선택지 집합으로 전환
- 패턴 매칭 대상으로 사용 가능하며, 이런 종류의 분류 작업에 매우 적합한 기능
- 참고: F# 공식 문서의 활성 패턴 각주 참고(원서 각주 1)
- 활성 패턴 정의: 각 배송 카테고리에 대한 패턴 집합을 먼저 정의
F#:
let (|UsLocalState|UsRemoteState|International|) address =
if address.Country = "US" then
match address.State with
| "CA" | "OR" | "AZ" | "NV" ->
UsLocalState
| _ ->
UsRemoteState
else
International
- 가격 책정 함수에서는 이 카테고리에 대해 패턴 매칭만 수행
F#:
let calculateShippingCost validatedOrder =
match validatedOrder.ShippingAddress with
| UsLocalState -> 5.0
| UsRemoteState -> 10.0
| International -> 20.0
TypeScript(활성 패턴에 대응하는 방식: 분류를 구별된 유니온으로 반환하는 별도 함수로 관심사 분리):
type ShippingCategory =
| { kind: "UsLocalState" }
| { kind: "UsRemoteState" }
| { kind: "International" };
// 분류만 담당(비즈니스 로직 없음)
function classifyShippingAddress(address: Address): ShippingCategory {
if (address.country === "US") {
switch (address.state) {
case "CA":
case "OR":
case "AZ":
case "NV":
return { kind: "UsLocalState" };
default:
return { kind: "UsRemoteState" };
}
}
return { kind: "International" };
}
// 가격 책정 로직만 담당
function calculateShippingCost(validatedOrder: { shippingAddress: Address }): number {
const category = classifyShippingAddress(validatedOrder.shippingAddress);
switch (category.kind) {
case "UsLocalState":
return 5.0;
case "UsRemoteState":
return 10.0;
case "International":
return 20.0;
}
}
- 효과
- 분류 로직과 비즈니스 로직이 분리되어 코드가 훨씬 명확해짐
- 활성 패턴 케이스의 이름 자체가 일종의 문서 역할을 함
- 분류 로직이 바뀌어도(예:
UsLocalState에 포함되는 주가 달라짐) 활성 패턴(분류 함수)만 수정하면 되고, 가격 책정 함수는 그대로 유지 - 관심사가 깔끔하게 분리됨
- 참고: 활성 패턴 정의 자체는 여전히 복잡할 수 있지만, 그 코드는 오직 분류만 담당하고 비즈니스 로직은 포함하지 않음
13.1.2 작업 흐름에 새 단계 추가하기
- 계산한 배송비를 주문 처리(order-placing) 작업 흐름에 반영해야 함
- 선택지 1: 기존 가격 책정(
Price) 단계를 수정해 배송비 로직을 추가 - 이미 동작하는 코드를 건드려 더 복잡하게 만들고 버그를 유발할 가능성 있음 - 선택지 2(채택): 합성(composition)을 활용해 새로운 단계를 작업 흐름에 추가하고
PricedOrder를 갱신
F#:
type AddShippingInfoToOrder = PricedOrder -> PricedOrderWithShippingInfo
TypeScript:
type AddShippingInfoToOrder = (pricedOrder: PricedOrder) => PricedOrderWithShippingInfo;
- 새 단계는
PriceOrder단계와AcknowledgeOrder단계 사이에 배치 - 원서에서는 손그림 스타일 다이어그램으로 표현됨(여기서는 Mermaid로 재구성)
flowchart LR
subgraph Workflow["Place order workflow"]
direction LR
Validate --> Price --> Blank1[" "] --> Acknowledge
end
AddShippingInfo -.-> Blank1
- 배송 정보를 더 상세히 표현할 새로운 타입 필요
- 배송 방법(예: FedEx, UPS)과 가격을 함께 기록해야 함
F#:
type ShippingMethod =
| PostalService
| Fedex24
| Fedex48
| Ups48
type ShippingInfo = {
ShippingMethod : ShippingMethod
ShippingCost : Price
}
type PricedOrderWithShippingMethod = {
ShippingInfo : ShippingInfo
PricedOrder : PricedOrder
}
TypeScript:
type ShippingMethod = "PostalService" | "Fedex24" | "Fedex48" | "Ups48";
type ShippingInfo = {
shippingMethod: ShippingMethod;
shippingCost: Price;
};
type PricedOrderWithShippingMethod = {
shippingInfo: ShippingInfo;
pricedOrder: PricedOrder;
};
-
새로운 주문 타입(
PricedOrderWithShippingInfo)을 별도로 만든 이유- 기존
PricedOrder에 필드만 추가하는 대신 완전히 새로운 타입을 만드는 것이 유리 - 이유 1:
AcknowledgeOrder단계가PricedOrderWithShippingInfo를 입력으로 요구하도록 수정하면, 단계 순서를 잘못 배치하는 실수 자체가 불가능해짐(컴파일 오류로 방지) - 이유 2: 만약
ShippingInfo를PricedOrder의 필드로 추가한다면, 배송비 계산 전에는 그 필드를 무엇으로 초기화해야 하는지 애매함. 기본값으로 초기화하면 잠재적 버그의 원인이 됨
- 기존
-
남은 이슈: 배송비를 주문의 어디에 저장할 것인가
방법 A: 헤더의 필드로 저장
F#:
type PricedOrder = {
...
ShippingInfo : ShippingInfo
OrderTotal : Price
}
TypeScript:
type PricedOrder = {
// ...
shippingInfo: ShippingInfo;
orderTotal: Price;
};
방법 B: 새로운 종류의 주문 라인으로 저장
F#:
type PricedOrderLine =
| Product of PricedOrderProductLine
| ShippingInfo of ShippingInfo
TypeScript:
type PricedOrderLine =
| { kind: "Product"; value: PricedOrderProductLine }
| { kind: "ShippingInfo"; value: ShippingInfo };
-
두 방법 비교
- 라인 방식(B)의 장점: 주문 총액이 항상 라인들의 합으로 계산 가능하여 헤더 필드까지 포함하는 별도 로직 불필요
- 라인 방식(B)의 단점: 실수로
ShippingInfo라인을 두 개 만들 위험, 라인 출력 순서를 신경 써야 함 - 최종 선택: 헤더에 배송 정보를 저장하는 방식(A) 채택
-
AddShippingInfoToOrder단계 구현 요건AddShippingInfoToOrder함수 타입을 구현calculateShippingCost함수를 의존성으로 주입받음- 배송비를 계산해
PricedOrder에 추가하여PricedOrderWithShippingInfo를 생성
-
모든 요건이 타입으로 표현되어 있어, 잘못된 구현을 만들기가 오히려 어려움
F#:
let addShippingInfoToOrder calculateShippingCost : AddShippingInfoToOrder =
fun pricedOrder ->
// create the shipping info
let shippingInfo = {
ShippingMethod = ...
ShippingCost = calculateShippingCost pricedOrder
}
// add it to the order
{
OrderId = pricedOrder.OrderId
...
ShippingInfo = shippingInfo
}
TypeScript:
function addShippingInfoToOrder(
calculateShippingCost: (order: PricedOrder) => Price
): AddShippingInfoToOrder {
return (pricedOrder: PricedOrder): PricedOrderWithShippingInfo => {
// 배송 정보 생성
const shippingInfo: ShippingInfo = {
shippingMethod: /* ... */ "PostalService",
shippingCost: calculateShippingCost(pricedOrder),
};
// 주문에 배송 정보 추가
return {
orderId: pricedOrder.orderId,
// ...
shippingInfo,
};
};
}
- 최상위 작업 흐름에 새 단계 연결하기
- 부분 적용(partial application)으로 의존성을 미리 주입한 단항(1-parameter) 함수들을 준비
- 파이프라인 연산자로 함수 합성
F#:
// set up local versions of the pipeline stages
// using partial application to bake in the dependencies
let validateOrder unvalidatedOrder = ...
let priceOrder validatedOrder = ...
let addShippingInfo = addShippingInfoToOrder calculateShippingCost
// compose the pipeline from the new one-parameter functions
unvalidatedOrder
|> validateOrder
|> priceOrder
|> addShippingInfo
...
TypeScript:
// 파이프라인 단계의 지역 버전 준비
// 부분 적용으로 의존성을 미리 주입
const validateOrder = (unvalidatedOrder: UnvalidatedOrder) => {/* ... */};
const priceOrder = (validatedOrder: ValidatedOrder) => {/* ... */};
const addShippingInfo = addShippingInfoToOrder(calculateShippingCost);
// 단항 함수들을 연결해 파이프라인 구성
const result = addShippingInfo(priceOrder(validateOrder(unvalidatedOrder)));
13.1.3 파이프라인에 새 단계를 추가한 또 다른 이유
- 이번 예시는 요구사항 변경 때문에 파이프라인에 컴포넌트를 추가했지만, 이 방식은 어떤 기능이든 추가/제거하는 훌륭한 일반적 방법임
- 단계가 다른 단계로부터 독립적이고 요구되는 타입을 준수하는 한, 안전하게 추가하거나 제거할 수 있음
- 이 방식으로 할 수 있는 것들
- 운영 투명성(operational transparency)을 위한 단계 추가: 로깅, 성능 지표, 감사(auditing) 등을 손쉽게 추가 가능
- 인가(authorization) 검사를 수행하는 단계 추가: 검사에 실패하면 나머지 파이프라인을 건너뛰고 실패 경로로 이동
- 합성 루트(composition root)에서 설정이나 입력 컨텍스트에 따라 단계를 동적으로 추가/제거하는 것도 가능
13.2 두 번째 변경: VIP 고객 지원 추가하기
- 요구사항: VIP 고객에게 무료 배송 또는 당일 배송 업그레이드 같은 특별 대우 제공
- 하지 말아야 할 것: 비즈니스 규칙의 “결과”를 도메인에 모델링하는 것(예: 주문에 “무료 배송” 플래그를 직접 추가)
- 해야 할 것: 비즈니스 규칙의 “입력”(“고객이 VIP다”)을 저장하고, 그 입력에 대해 비즈니스 규칙이 작동하도록 함
- 이렇게 하면 비즈니스 규칙이 바뀌어도(반드시 바뀜) 도메인 모델을 변경할 필요가 없음
- 전제: 고객의 VIP 상태는 웹사이트 로그인과 연계되어 있다고 가정하며, 주문 처리 도메인에서 직접 판정할 필요 없음
- VIP 상태를 어떻게 모델링할 것인가에 대한 세 가지 대안
대안 1: CustomerInfo에 불리언 플래그 추가
F#:
type CustomerInfo = {
...
IsVip : bool
...
}
TypeScript:
type CustomerInfo = {
// ...
isVip: boolean;
};
대안 2: 고객 상태 집합의 하나로 모델링
F#:
type CustomerStatus =
| Normal of CustomerInfo
| Vip of CustomerInfo
type Order = {
...
CustomerStatus : CustomerStatus
...
}
TypeScript:
type CustomerStatus =
| { kind: "Normal"; value: CustomerInfo }
| { kind: "Vip"; value: CustomerInfo };
type Order = {
// ...
customerStatus: CustomerStatus;
};
- 단점: VIP 여부와 직교(orthogonal)하는 다른 고객 상태(신규/재구매 고객, 로열티 카드 보유 여부 등)가 있을 수 있음
대안 3(채택): 절충안. “VIP”라는 차원(dimension)만 표현하는 별도의 선택 타입 사용, 다른 고객 정보와 독립적으로 표현
F#:
type VipStatus =
| Normal
| Vip
type CustomerInfo = {
...
VipStatus : VipStatus
...
}
TypeScript:
type VipStatus = "Normal" | "Vip";
type CustomerInfo = {
// ...
vipStatus: VipStatus;
};
- 확장성: 다른 종류의 상태가 필요해지면 동일한 방식으로 쉽게 추가 가능
F#:
type LoyaltyCardId = ...
type LoyaltyCardStatus =
| None
| LoyaltyCard of LoyaltyCardId
type CustomerInfo = {
...
VipStatus : VipStatus
LoyaltyCardStatus : LoyaltyCardStatus
...
}
TypeScript:
type LoyaltyCardId = string;
type LoyaltyCardStatus =
| { kind: "None" }
| { kind: "LoyaltyCard"; value: LoyaltyCardId };
type CustomerInfo = {
// ...
vipStatus: VipStatus;
loyaltyCardStatus: LoyaltyCardStatus;
};
13.2.1 작업 흐름에 새로운 입력 추가하기
VipStatus필드를 실제로 도입하는 과정: 도메인 모델을 먼저 갱신하고, 그 결과를 따라가며 나머지를 수정
F#:
type VipStatus = ...
type CustomerInfo = {
...
VipStatus : VipStatus
}
- 이 변경만으로도
CustomerInfo를 생성하는 코드에서 곧바로 컴파일 오류 발생
No assignment given for field 'VipStatus' of type 'CustomerInfo'
- F# 레코드 타입의 장점: 생성 시 모든 필드를 반드시 제공해야 함. 새 필드가 추가되면 값을 제공할 때까지 컴파일 오류가 발생해 누락을 방지
- TypeScript에서도 유사한 안전장치 존재
- 객체 리터럴로 타입을 만족시킬 때, 타입에 정의된 필수 프로퍼티가 빠지면 컴파일 오류 발생(구조적 타이핑이지만 필수 필드 누락은 감지)
type CustomerInfo = {
// ...
vipStatus: VipStatus;
};
// vipStatus를 빠뜨리면 TS 컴파일 오류 발생
const info: CustomerInfo = {
// ...
// vipStatus 누락 -> Property 'vipStatus' is missing
};
VipStatus는 어디서 얻는가- 작업 흐름의 입력인
UnvalidatedCustomerInfo. 이는 사용자가 작성한 주문 양식(DTO)에서 옴 UnvalidatedCustomerInfo와 DTO 모두에 대응 필드 추가 필요- 두 곳 모두 단순 문자열로 표현하고, null로 값 없음을 표시
- 작업 흐름의 입력인
F#:
module Domain =
type UnvalidatedCustomerInfo = {
...
VipStatus : string
}
module Dto =
type CustomerInfo = {
...
VipStatus : string
}
TypeScript:
namespace Domain {
export type UnvalidatedCustomerInfo = {
// ...
vipStatus: string;
};
}
namespace Dto {
export type CustomerInfo = {
// ...
vipStatus: string;
};
}
- 마지막으로
UnvalidatedCustomerInfo의VipStatus필드로부터ValidatedCustomerInfo(CustomerInfo)를 생성
F#:
let validateCustomerInfo unvalidatedCustomerInfo =
result {
...
// new field
let! vipStatus =
VipStatus.create unvalidatedCustomerInfo.VipStatus
let customerInfo : CustomerInfo = {
...
VipStatus = vipStatus
}
return customerInfo
}
TypeScript:
type Result<TSuccess, TFailure> =
| { kind: "ok"; value: TSuccess }
| { kind: "error"; error: TFailure };
function validateCustomerInfo(
unvalidatedCustomerInfo: Domain.UnvalidatedCustomerInfo
): Result<CustomerInfo, ValidationError> {
// ...
// 신규 필드
const vipStatusResult = VipStatus.create(unvalidatedCustomerInfo.vipStatus);
if (vipStatusResult.kind === "error") {
return vipStatusResult;
}
const customerInfo: CustomerInfo = {
// ...
vipStatus: vipStatusResult.value,
};
return { kind: "ok", value: customerInfo };
}
13.2.2 작업 흐름에 무료 배송 규칙 추가하기
- 요구사항 중 하나: VIP에게 무료 배송 제공. 이 로직을 작업 흐름 어딘가에 추가해야 함
- 여기서도 안정적인 기존 코드를 수정하는 대신, 파이프라인에 새로운 세그먼트를 추가하는 방식 채택
flowchart LR
subgraph Workflow["Place order workflow"]
direction LR
Validate --> Price --> AddShippingInfo --> Blank2[" "] --> etc["etc"]
end
VipFreeShipping -.-> Blank2
- 새 세그먼트를 표현하는 타입 정의
F#:
type FreeVipShipping =
PricedOrderWithShippingMethod -> PricedOrderWithShippingMethod
TypeScript:
type FreeVipShipping = (
order: PricedOrderWithShippingMethod
) => PricedOrderWithShippingMethod;
- 이 타입을 구현하는 작업 흐름 세그먼트를 만들어 파이프라인에 삽입(구체 코드는 원서에서도 생략됨. 지금까지의 패턴과 동일한 방식으로 구현)
13.3 세 번째 변경: 프로모션 코드 지원 추가
- 시나리오: 영업팀이 프로모션을 진행하고자 함. 주문 시 프로모션 코드를 입력하면 할인된 가격 적용
- 영업팀과 논의 후 정리한 요구사항
- 주문 시 고객이 선택적으로(optional) 프로모션 코드를 입력 가능
- 코드가 존재하면 특정 상품에 다른(더 낮은) 가격 적용
- 주문서에 프로모션 할인이 적용되었음을 표시
- 앞의 두 요구사항은 비교적 쉬움. 마지막 요구사항(“표시”)은 단순해 보이지만 도메인 전반에 예상보다 큰 파급 효과를 만듦
13.3.1 도메인 모델에 프로모션 코드 추가하기
- 프로모션 코드용 타입 정의 후 주문의 선택적 필드로 추가
F#:
type PromotionCode = PromotionCode of string
type ValidatedOrder = {
...
PromotionCode : PromotionCode option
}
TypeScript:
type PromotionCode = { kind: "PromotionCode"; value: string };
type ValidatedOrder = {
// ...
promotionCode?: PromotionCode; // option 대신 선택적 프로퍼티 사용
};
PromotionCode에 특별한 검증 로직은 없지만, 단순 문자열 대신 전용 타입을 사용해 도메인 내 다른 문자열과 혼동되지 않도록 함VipStatus때와 마찬가지로 새 필드 추가 시 일련의 컴파일 오류 발생UnvalidatedOrder와 DTO에도 대응 필드 추가 필요ValidatedOrder의 필드는 명시적으로 option이지만, DTO에서는 null로 값 없음을 표시하는 단순 문자열 사용 가능
F#:
type OrderDto = {
...
PromotionCode : string
}
type UnvalidatedOrder = {
...
PromotionCode : string
}
TypeScript:
type OrderDto = {
// ...
promotionCode: string; // null 허용 문자열로 값 없음을 표현
};
type UnvalidatedOrder = {
// ...
promotionCode: string;
};
13.3.2 가격 책정 로직 변경하기
- 프로모션 코드 유무에 따라 서로 다른 가격 책정 계산을 수행해야 함
- 기존에는 가격 책정을 함수 타입으로 모델링
F#:
type GetProductPrice = ProductCode -> Price
TypeScript:
type ProductCode = string;
type Price = number;
type GetProductPrice = (productCode: ProductCode) => Price;
- 이제 프로모션 코드에 따라 다른
GetProductPrice함수를 제공해야 함- 프로모션 코드가 있으면 해당 코드에 연계된 가격을 반환하는
GetProductPrice제공 - 프로모션 코드가 없으면 기존
GetProductPrice제공
- 프로모션 코드가 있으면 해당 코드에 연계된 가격을 반환하는
- 필요한 것: 선택적 프로모션 코드를 받아 적절한
GetProductPrice함수를 반환하는 “팩토리” 함수
F#:
type GetPricingFunction = PromotionCode option -> GetProductPrice
TypeScript:
type GetPricingFunction = (promotionCode?: PromotionCode) => GetProductPrice;
- 옵션을 그대로 전달하는 방식은 의미가 불명확함. 더 자기 문서화(self-documenting)된 새로운 타입 도입
F#:
type PricingMethod =
| Standard
| Promotion of PromotionCode
TypeScript:
type PricingMethod =
| { kind: "Standard" }
| { kind: "Promotion"; value: PromotionCode };
- 논리적으로는 option과 동일하지만 도메인 모델에서 사용할 때 더 명확함
ValidatedOrder와GetPricingFunction갱신
F#:
type ValidatedOrder = {
... //as before
PricingMethod : PricingMethod
}
type GetPricingFunction = PricingMethod -> GetProductPrice
TypeScript:
type ValidatedOrder = {
// ... 기존과 동일
pricingMethod: PricingMethod;
};
type GetPricingFunction = (pricingMethod: PricingMethod) => GetProductPrice;
- 원래 설계에서는 가격 책정 단계에
GetProductPrice함수를 주입했지만, 이제는GetPricingFunction“팩토리” 함수를 대신 주입해야 함
F#:
type PriceOrder =
GetPricingFunction // new dependency
-> ValidatedOrder // input
-> PricedOrder // output
TypeScript:
type PriceOrder = (
getPricingFunction: GetPricingFunction
) => (validatedOrder: ValidatedOrder) => PricedOrder;
- 도메인 모델을 이렇게 바꾸고 나면 구현 코드에서 다시 한 무더기의 컴파일 오류 발생
- 이 컴파일 오류들은 “친구” 역할을 함. 구현을 무엇을 어떻게 고쳐야 하는지 안내해줌
- 지루하지만 단순한 작업이며, 완료 후 컴파일이 통과하면 구현이 다시 정상 동작한다는 높은 확신을 가질 수 있음
13.3.3 GetPricingFunction 구현하기
- 전제: 각 프로모션 코드는 (ProductCode, Price) 쌍의 딕셔너리와 연계됨
F#:
type GetStandardPriceTable =
// no input -> return standard prices
unit -> IDictionary<ProductCode,Price>
type GetPromotionPriceTable =
// promo input -> return prices for promo
PromotionCode -> IDictionary<ProductCode,Price>
let getPricingFunction
(standardPrices:GetStandardPriceTable)
(promoPrices:GetPromotionPriceTable)
: GetPricingFunction =
// the original pricing function
let getStandardPrice : GetProductPrice =
// cache the standard prices
let standardPrices = standardPrices()
// return the lookup function
fun productCode -> standardPrices.[productCode]
// the promotional pricing function
let getPromotionPrice promotionCode : GetProductPrice =
// cache the promotional prices
let promotionPrices = promoPrices promotionCode
// return the lookup function
fun productCode ->
match promotionPrices.TryGetValue productCode with
// found in promotional prices
| true,price -> price
// not found in promotional prices
// so use standard price
| false, _ -> getStandardPrice productCode
// return a function that conforms to GetPricingFunction
fun pricingMethod ->
match pricingMethod with
| Standard ->
getStandardPrice
| Promotion promotionCode ->
getPromotionPrice promotionCode
TypeScript:
type GetStandardPriceTable = () => Map<ProductCode, Price>;
type GetPromotionPriceTable = (
promotionCode: PromotionCode
) => Map<ProductCode, Price>;
function getPricingFunction(
standardPrices: GetStandardPriceTable,
promoPrices: GetPromotionPriceTable
): GetPricingFunction {
// 원래의(표준) 가격 책정 함수
// 표준 가격을 캐싱
const standardPriceTable = standardPrices();
const getStandardPrice: GetProductPrice = (productCode) => {
const price = standardPriceTable.get(productCode);
if (price === undefined) {
throw new Error(`가격 정보 없음: ${productCode}`);
}
return price;
};
// 프로모션 가격 책정 함수
const getPromotionPrice = (promotionCode: PromotionCode): GetProductPrice => {
// 프로모션 가격을 캐싱
const promotionPriceTable = promoPrices(promotionCode);
return (productCode) => {
const price = promotionPriceTable.get(productCode);
// 프로모션 가격에서 찾은 경우
if (price !== undefined) {
return price;
}
// 프로모션 가격에 없으면 표준 가격 사용
return getStandardPrice(productCode);
};
};
// GetPricingFunction 형태에 맞는 함수 반환
return (pricingMethod: PricingMethod): GetProductPrice => {
switch (pricingMethod.kind) {
case "Standard":
return getStandardPrice;
case "Promotion":
return getPromotionPrice(pricingMethod.value);
}
};
}
- 이 구현에서 사용된 함수형 프로그래밍 기법
- 타입을 통해 코드의 정확성을 보장(
GetProductPrice) - 타입을 통해 도메인 로직을 명확히 표현(
PricingMethod의 선택지) - 함수를 매개변수로 사용(
promoPrices:GetPromotionPriceTable) - 함수를 반환값으로 사용(
GetPricingFunction의 반환값)
- 타입을 통해 코드의 정확성을 보장(
13.3.4 주문 항목에 할인 문서화하기
- 요구사항: “주문서에 프로모션 할인이 적용되었음을 표시”
- 핵심 질문: 하위(downstream) 시스템이 프로모션에 대해 알아야 하는가
- 알 필요 없다면 가장 단순한 방법은 주문 라인 목록에 “코멘트 라인” 추가
- 코멘트에는 할인을 설명하는 텍스트만 있으면 충분, 특별한 세부 정보 불필요
- 이는 “주문 라인(order line)“의 정의를 바꾸는 변경
- 지금까지는 주문의 모든 라인이 특정 상품을 참조한다고 가정했음
- 이제 상품을 참조하지 않는 새로운 종류의 주문 라인이 필요함.
PricedOrderLine을 선택 타입(choice type)으로 변경
F#:
type CommentLine = CommentLine of string
type PricedOrderLine =
| Product of PricedOrderProductLine
| Comment of CommentLine
TypeScript:
type CommentLine = { kind: "CommentLine"; value: string };
type PricedOrderLine =
| { kind: "Product"; value: PricedOrderProductLine }
| { kind: "Comment"; value: CommentLine };
-
CommentLine에는 특별한 검증이 필요 없음(다만 글자 수 제한 정도는 고려 가능) -
대안: 코멘트 대신
DiscountApplied케이스를 만들어 할인 금액 등 데이터를 담을 수도 있음Comment방식의 장점: 배송(shipping) 및 청구(billing) 컨텍스트가 프로모션에 대해 전혀 알 필요가 없음. 프로모션 로직이 바뀌어도 두 컨텍스트는 영향받지 않음
-
PricedOrderLine이 선택 타입이 되었으므로, 상품 관련 상세(가격, 수량 등)를 담는PricedOrderProductLine타입도 별도로 필요 -
ValidatedOrderLine과PricedOrderLine의 설계가 서로 갈라졌음(divergence)을 확인- 도메인 모델링 단계에서 타입을 애초에 분리해둔 것이 좋은 선택이었음을 보여줌
- 만약 두 용도에 동일한 타입을 썼다면 모델을 깔끔하게 유지하지 못했을 것
-
코멘트 라인을 추가하기 위한
priceOrder함수 변경 절차GetPricingFunction“팩토리”에서 가격 책정 함수를 가져옴- 각 라인에 대해 그 가격 책정 함수로 가격을 설정
- 프로모션 코드가 사용되었다면 라인 목록에 특별한 코멘트 라인을 추가
F#:
let toPricedOrderLine orderLine = ...
let priceOrder : PriceOrder =
fun getPricingFunction validatedOrder ->
// get the pricing function from the getPricingFunction "factory"
let getProductPrice = getPricingFunction validatedOrder.PricingMethod
// set the price for each line
let productOrderLines =
validatedOrder.OrderLines
|> List.map (toPricedOrderLine getProductPrice)
// add the special comment line if needed
let orderLines =
match validatedOrder.PricingMethod with
| Standard ->
// unchanged
productOrderLines
| Promotion promotion ->
let promoCode = promotion|> PromotionCode.value
let commentLine =
sprintf "Applied promotion %s" promoCode
|> CommentLine.create
|> Comment // lift to PricedOrderLine
List.append productOrderLines [commentLine]
// return the new order
{
...
OrderLines = orderLines
}
TypeScript:
const toPricedOrderLine = (
getProductPrice: GetProductPrice
) => (orderLine: ValidatedOrderLine): PricedOrderProductLine => {
// ...
throw new Error("구현 생략");
};
const priceOrder: PriceOrder =
(getPricingFunction) => (validatedOrder) => {
// "팩토리"에서 가격 책정 함수 가져오기
const getProductPrice = getPricingFunction(validatedOrder.pricingMethod);
// 각 라인에 가격 설정
const productOrderLines: PricedOrderLine[] = validatedOrder.orderLines.map(
(line): PricedOrderLine => ({
kind: "Product",
value: toPricedOrderLine(getProductPrice)(line),
})
);
// 필요 시 특별 코멘트 라인 추가
let orderLines: PricedOrderLine[];
if (validatedOrder.pricingMethod.kind === "Standard") {
// 변경 없음
orderLines = productOrderLines;
} else {
const promoCode = validatedOrder.pricingMethod.value.value;
const commentLine: PricedOrderLine = {
kind: "Comment",
value: { kind: "CommentLine", value: `프로모션 ${promoCode} 적용됨` },
};
orderLines = [...productOrderLines, commentLine];
}
// 새 주문 반환
return {
// ...
orderLines,
} as PricedOrder;
};
13.3.5 더 복잡한 가격 체계
- 실무에서는 다중 프로모션, 바우처, 로열티 스킴 등이 결합되어 가격 체계가 훨씬 복잡해질 수 있음
- 이런 경우 “가격 책정(pricing)“을 별도의 **경계 지어진 맥락(bounded context)**으로 분리해야 한다는 신호일 수 있음
- 경계 지어진 맥락을 식별하는 단서(원서 앞부분 “경계 지어진 맥락 제대로 잡기” 내용 참조)
| 단서 | 설명 |
|---|---|
| 구별된 용어 | 예: “BOGOF”(Buy One Get One Free) 같은 전문 용어(원서 각주 2) |
| 전담 팀 | 가격을 관리하는 별도의 팀 존재 |
| 컨텍스트 전용 데이터 | 이전 구매 이력, 바우처 사용 이력 등 해당 컨텍스트에서만 쓰이는 데이터 |
| 자율성 | 다른 컨텍스트와 독립적으로 존재하고 진화할 수 있는 능력 |
- 가격 책정이 비즈니스에서 중요한 부분이라면, 주문 처리, 배송, 청구 도메인과 결합되지 않고 독립적으로 진화할 수 있어야 함
- 원서에서는 가격 책정 컨텍스트를 점선 박스로 표현하여 “주문 처리와 밀접하지만 논리적으로 분리된” 상태임을 나타냄
flowchart TB
subgraph OrderTaking["Order-taking context"]
Workflow["Place order workflow"]
end
Pricing["Pricing context"]
Shipping["Shipping context"]
Billing["Billing context"]
Workflow <--> Pricing
Workflow --> Shipping
Workflow --> Billing
13.3.6 경계 진 맥락 간 계약 수정하기
- 새로 도입한
CommentLine을 배송 시스템이 주문서를 올바르게 인쇄하려면 알아야 함 - 결과적으로 하위(downstream)로 전송되는
OrderPlaced이벤트도 변경이 필요해짐 - 문제: 이렇게 되면 주문 처리 컨텍스트와 배송 컨텍스트 사이의 **계약(contract)**이 깨짐
- 근본 질문: 지속 가능한 방식인가. 주문 처리 도메인에 새로운 개념을 추가할 때마다 매번 이벤트/DTO를 바꾸고 계약을 깨야 하는가
- 그렇지 않아야 함. 현재 상태는 경계 지어진 맥락 사이에 바람직하지 않은 결합(coupling)이 생긴 것
- 해결책: “소비자 주도(consumer-driven)” 계약 방식(원서 앞부분 “경계 지어진 맥락 간의 계약” 내용 참조)
- 하위(소비자) 컨텍스트가 자신에게 필요한 데이터가 무엇인지 결정
- 상위(생산자) 컨텍스트는 그 데이터만 정확히 제공(그 이상은 제공하지 않음)
- 배송 컨텍스트가 실제로 필요로 하는 것
- 가격 정보 불필요
- 배송비 불필요
- 할인 정보 불필요
- 필요한 것: 상품 목록, 각 상품의 수량, 배송 주소뿐
F#:
type ShippableOrderLine = {
ProductCode : ProductCode
Quantity : float
}
type ShippableOrderPlaced = {
OrderId : OrderId
ShippingAddress : Address
ShipmentLines : ShippableOrderLine list
}
TypeScript:
type ShippableOrderLine = {
productCode: ProductCode;
quantity: number;
};
type ShippableOrderPlaced = {
orderId: OrderId;
shippingAddress: Address;
shipmentLines: ShippableOrderLine[];
};
- 원래의
OrderPlaced이벤트 타입보다 훨씬 단순함. 데이터가 적을수록 주문 처리 도메인이 바뀌어도 영향을 받을 가능성이 낮아짐 - 이 새로운 이벤트 타입에 맞춰 작업 흐름의
PlaceOrderEvent출력 자체를 재설계AcknowledgmentSent: 로그 기록 및 고객 서비스 컨텍스트로 전송ShippableOrderPlaced: 배송 컨텍스트로 전송BillableOrderPlaced: 청구 컨텍스트로 전송
F#:
type PlaceOrderEvent =
| ShippableOrderPlaced of ShippableOrderPlaced
| BillableOrderPlaced of BillableOrderPlaced
| AcknowledgmentSent of OrderAcknowledgmentSent
TypeScript:
type PlaceOrderEvent =
| { kind: "ShippableOrderPlaced"; value: ShippableOrderPlaced }
| { kind: "BillableOrderPlaced"; value: BillableOrderPlaced }
| { kind: "AcknowledgmentSent"; value: OrderAcknowledgmentSent };
13.3.7 주문 인쇄하기
- 의문: 정보가 의도적으로 축소되었는데, 배송 부서가 어떻게 주문서를 인쇄할 수 있는가
- 핵심 통찰: 배송 부서는 “인쇄할 수 있는 무언가”만 필요할 뿐, 그 내용 자체에는 관심이 없음
- 해결책: 주문 처리 컨텍스트가 배송 부서에 PDF나 HTML 문서를 제공하고, 배송 부서는 그것을 인쇄하기만 하면 됨
- 구현 방식 두 가지
ShippableOrderPlaced타입 안에 바이너리 블롭(binary blob) 형태로 문서를 포함- 또는 PDF를 공유 저장소에 두고, 배송 컨텍스트가
OrderId를 통해 접근
13.4 네 번째 변경: 영업시간 제약 추가
- 지금까지는 새로운 데이터와 동작을 추가하는 변경이었음. 이번에는 작업 흐름의 “사용 방식”에 새로운 제약을 추가
- 새로운 비즈니스 규칙: 주문은 영업시간 동안에만 받을 수 있음
- 이유(예시): 새벽 4시에 사이트에 접속하는 사람은 진짜 고객이 아닐 가능성이 높다는 비즈니스 측의 가정
- 해결 기법: “어댑터(adapter)” 함수
- 임의의 함수를 입력으로 받아, 동일한 동작을 하되 영업시간 외 호출 시 오류를 발생시키는 “래퍼(wrapper)”/“프록시(proxy)” 함수를 출력하는 “영업시간 전용(business-hours-only)” 함수를 만듦
- 변환된 함수는 원본과 완전히 동일한 입력/출력을 가지므로, 원본이 쓰이던 모든 곳에 대체 투입 가능
flowchart LR
A["Workflow function"] --> T["Business-Hours-Only Transformer"] --> B["Transformed Workflow function"]
F#:
/// Determine the business hours
let isBusinessHour hour =
hour >= 9 && hour <= 17
/// tranformer
let businessHoursOnly getHour onError onSuccess =
let hour = getHour()
if isBusinessHour hour then
onSuccess()
else
onError()
TypeScript:
// 영업시간 여부 판별
function isBusinessHour(hour: number): boolean {
return hour >= 9 && hour <= 17;
}
// 트랜스포머
function businessHoursOnly<T>(
getHour: () => number,
onError: () => T,
onSuccess: () => T
): T {
const hour = getHour();
if (isBusinessHour(hour)) {
return onSuccess();
} else {
return onError();
}
}
- 이 코드는 완전히 범용적(generic)
onError: 영업시간 밖일 때 처리하는 매개변수onSuccess: 영업시간 안일 때 처리하는 매개변수- 하루 중 시간(
hour)은 하드코딩이 아니라getHour함수 매개변수로 결정. 단위 테스트 시 더미 함수 주입 가능
- 적용: 원래 작업 흐름은
UnvalidatedOrder를 받아PlaceOrderError를 오류 타입으로 하는Result를 반환onError도 동일한 타입의Result를 반환해야 함.PlaceOrderError에OutsideBusinessHours케이스 추가
F#:
type PlaceOrderError =
| Validation of ValidationError
...
| OutsideBusinessHours //new!
TypeScript:
type PlaceOrderError =
| { kind: "Validation"; value: ValidationError }
// ...
| { kind: "OutsideBusinessHours" }; // 신규!
- 원래의 주문 처리 작업 흐름을 변환
F#:
let placeOrder unvalidatedOrder =
...
let placeOrderInBusinessHours unvalidatedOrder =
let onError() =
Error OutsideBusinessHours
let onSuccess() =
placeOrder unvalidatedOrder
let getHour() = DateTime.Now.Hour
businessHoursOnly getHour onError onSuccess
TypeScript:
function placeOrder(
unvalidatedOrder: UnvalidatedOrder
): Result<PlaceOrderEvent[], PlaceOrderError> {
// ...
throw new Error("구현 생략");
}
function placeOrderInBusinessHours(
unvalidatedOrder: UnvalidatedOrder
): Result<PlaceOrderEvent[], PlaceOrderError> {
const onError = (): Result<PlaceOrderEvent[], PlaceOrderError> => ({
kind: "error",
error: { kind: "OutsideBusinessHours" },
});
const onSuccess = (): Result<PlaceOrderEvent[], PlaceOrderError> =>
placeOrder(unvalidatedOrder);
const getHour = (): number => new Date().getHours();
return businessHoursOnly(getHour, onError, onSuccess);
}
- 최종적으로 애플리케이션의 최상위(합성 루트, composition root)에서 기존
placeOrder함수를 새로운placeOrderInBusinessHours로 교체- 입력/출력이 완전히 동일하므로 완벽하게 호환됨
13.5 추가적인 요구사항 변경 처리하기
- 지금까지 다룬 것은 있을 수 있는 변경 유형의 극히 일부일 뿐임
- 추가로 고려해볼 만한 변경 사례들
VIP는 미국 내 배송에 대해서만 무료 배송을 받아야 함
freeVipShipping세그먼트의 코드만 수정하면 됨- 작은 세그먼트들로 나누어 둔 구조 덕분에 복잡도를 쉽게 통제할 수 있음이 드러남
고객이 하나의 주문을 여러 건의 배송으로 분할할 수 있어야 함
- 이를 처리하는 로직(파이프라인의 새 세그먼트) 필요
- 도메인 모델링 관점에서 유일한 변화: 작업 흐름의 출력이 단일 배송(shipment)이 아니라 배송 목록(list)을 배송 컨텍스트로 전달하는 것
고객이 주문 상태(배송 완료 여부, 결제 완료 여부 등)를 조회할 수 있어야 함
- 까다로운 사례임. 주문 상태에 대한 지식이 여러 컨텍스트(배송 컨텍스트는 배송 상태, 청구 컨텍스트는 결제 상태)에 흩어져 있기 때문
- 최선의 접근: **“고객 서비스(Customer Service)”**라는 새로운 컨텍스트를 만들어 이런 질문을 전담하도록 함
- 이 컨텍스트가 다른 컨텍스트들의 이벤트를 구독해 상태를 갱신
- 상태에 대한 모든 질의는 이 컨텍스트로 직접 전달
flowchart TB
subgraph OrderTaking["Order-taking context"]
Workflow["Place order workflow"]
end
Shipping["Shipping context"]
Billing["Billing context"]
CustomerService["Customer Service context"]
Workflow -- "Order placed" --> CustomerService
Workflow --> Shipping
Workflow --> Billing
Shipping -- "Order shipped" --> CustomerService
Billing -- "Order paid" --> CustomerService
13.6 마무리
- 이번 장에서 4가지 요구사항 변경을 통해 타입 기반 도메인 모델링과 함수 합성 기반 작업 흐름 구성 방식의 이점을 확인
| 변경 번호 | 변경 내용 | 작업 흐름에 미친 영향 | 핵심 기법 |
|---|---|---|---|
| 1 | 배송비 추가 | AddShippingInfo 단계 신설 |
활성 패턴을 통한 분류/로직 분리, 파이프라인에 단계 추가 |
| 2 | VIP 고객 지원 | 입력에 VipStatus 추가, VipFreeShipping 단계 신설 |
선택 타입으로 독립적 차원(dimension) 모델링 |
| 3 | 프로모션 코드 지원 | PricingMethod 도입, PricedOrderLine을 선택 타입으로 변경, 이벤트 계약 재설계 |
팩토리 함수, 소비자 주도 계약 |
| 4 | 영업시간 제약 | 작업 흐름 전체를 감싸는 어댑터 적용 | 함수를 함수로 변환하는 트랜스포머 |
- 타입 기반 설계의 이점
- 도메인 타입에 새 필드를 추가하면(예:
ValidatedOrder에VipStatus추가) 즉시 컴파일 오류 발생 - 컴파일 오류가 데이터의 출처를 명시하도록 강제함
- 이는 다시 다른 타입을 수정하도록 유도. 모든 컴파일 오류가 사라질 때까지 반복
- 도메인 타입에 새 필드를 추가하면(예:
- 의존성 변경 시에도 동일한 효과
- 프로모션 코드 예제에서
GetProductPrice를 더 복잡한GetPricingFunction으로 바꾸었을 때도 다수의 컴파일 오류 발생 - 오류를 모두 수정하고 컴파일이 통과하면, 구현이 다시 올바르게 동작한다는 확신을 높게 가질 수 있음
- 프로모션 코드 예제에서
- 함수 합성을 이용해 작업 흐름을 구성하는 방식의 이점
- 다른 세그먼트를 건드리지 않고 새로운 세그먼트를 쉽게 추가 가능
- 기존 코드를 변경하지 않는다는 것은 버그를 유발할 가능성이 낮아진다는 의미
- 영업시간 예제에서 확인한 이점
- “함수 타입을 인터페이스로 사용”하는 방식 덕분에, 기존 코드와의 플러그인 호환성(plug-in compatibility)을 유지하면서 함수 전체를 강력하게 변환 가능
13.7 책 마무리
- 이 책은 상위 수준의 추상화(경계 지어진 맥락 등)부터 직렬화 형식의 세부 사항까지 폭넓게 다룸
- 다루지 못한 중요한 주제들: 웹 서비스, 보안, 운영 투명성 등
- 저자의 바람: 이 책을 통해 어떤 설계 문제에도 적용할 수 있는 기법과 역량을 갖추게 되는 것
- 가장 중요한 실천 항목 정리
- 저수준 설계를 시작하기 전에 도메인에 대한 깊고 공유된 이해를 먼저 개발해야 함
- 이벤트 스토밍(Event Storming) 같은 발견 기법과 유비쿼터스 언어(Ubiquitous Language) 같은 소통 기법이 큰 도움이 됨
- 솔루션 공간을 독립적으로 진화 가능한, 결합도 낮은 경계 지어진 맥락들로 분할해야 함
- 각 작업 흐름은 명시적인 입력과 출력을 가진 독립형 파이프라인으로 표현되어야 함
- 코드를 작성하기 전에 타입 기반 표기법으로 요구사항(도메인의 명사와 동사)을 먼저 포착해야 함
- 명사는 대수적 타입 시스템(algebraic type system)으로, 동사는 함수로 거의 항상 표현 가능
- 가능한 한 중요한 제약과 비즈니스 규칙을 타입 시스템에 담아야 함
- 모토: ==잘못된 상태를 표현 불가능하게 만들어라(make illegal states unrepresentable)==
- 함수를 “순수(pure)“하고 “완전(total)“하게 설계해야 함
- 모든 입력에 명시적으로 문서화된 출력이 존재(예외 없음)
- 모든 동작이 완전히 예측 가능(숨겨진 의존성 없음)
- 저수준 설계를 시작하기 전에 도메인에 대한 깊고 공유된 이해를 먼저 개발해야 함
- 주문 처리 작업 흐름을 이 과정을 통해 진행한 결과, 구현을 안내하고 제약하는 상세한 타입 집합을 얻게 됨
- 구현 과정에서 반복적으로 사용한 함수형 프로그래밍 기법
- 더 작은 함수들의 합성만으로 전체 작업 흐름을 구축
- 의존성이 있거나 결정을 미루고 싶을 때마다 함수를 매개변수화
- 부분 적용(partial application)으로 의존성을 미리 주입하여 함수를 더 쉽게 합성하고 불필요한 구현 세부사항을 숨김
- 다른 함수들을 다양한 형태로 변환하는 특수 함수 제작. 특히 오류를 반환하는 함수를 손쉽게 합성 가능한 투 트랙(two-track) 함수로 변환하는 “어댑터 블록” 역할의
bind - 서로 다른 타입들을 공통 타입으로 “끌어올려(lifting)” 타입 불일치 문제 해결
- 저자의 결론: 함수형 프로그래밍과 도메인 모델링은 훌륭한 조합이며, 독자가 이를 자신의 애플리케이션에 적용할 자신감을 갖게 되길 희망