반응형
우리는 도메인 모델을 "persistence ignorance"로 설계했습니다.
즉, 데이터 저장 또는 다른 서비스와의 상호 작용과 관련된 구현 문제로 인해 설계가 왜곡되지 않도록 했습니다.
그러나 대부분의 애플리케이션에는 프로세스 또는 워크플로의 수명보다 더 오래 지속되어야 하는 상태가 필요한 시점이 있습니다.
이 시점에서 우리는 파일 시스템이나 데이터베이스와 같은 일종의 지속성 메커니즘으로 전환해야 합니다.
슬프게도 우리의 완벽한 도메인에서에서 인프라의 지저분한 세계로 이동할 때 거의 항상 불일치가 있습니다.
명령 쿼리 분리(CQRS)와 같은 몇 가지 높은 수준의 원칙에 대해 논의한 다음 낮은 수준의 구현으로 전환합니다.
NoSQL 문서 데이터베이스와 기존 SQL 데이터베이스의 두 가지 방식으로 도메인 모델을 유지하는 방법을 살펴보겠습니다.
이 장이 끝나면 데이터베이스 또는 기타 지속성 메커니즘을 애플리케이션에 통합하는 데 필요한 모든 도구를 갖게 됩니다.
하지만 지속성의 메커니즘에 대해 알아보기 전에 도메인 기반 디자인의 맥락에서 지속성을 사용하는 데 도움이 되는 몇 가지 일반적인 지침을 살펴보겠습니다.
- Push persistence to the edges. (영속성 문제를 가장자리로 밀어넣으세요)
- Separate commands (updates) from queries (reads). (조회와 커맨드의 관심사를 분리하세요)
- Bounded contexts must own their own data store. (바운디드 컨텍스트는 자신만의 데이터 저장소를 갖고 있어야 합니다.)
Pushing Persistence to the Edges
이상적으로는 모든 함수가 "순수"하여 추론하고 테스트하기가 더 쉽습니다.
외부 세계에서 읽거나 외부 세계에 쓰는 함수는 순수할 수 없으므로
워크플로를 설계할 때 워크플로 내에서 모든 종류의 I/O 또는 지속성 관련 논리를 피하고 싶습니다.
이는 일반적으로 워크플로를 두 부분으로 분리하는 것을 의미합니다.
- 비즈니스 로직을 담고 있는 도메인 중심적인 부분
- I/O 관련 코드를 포함하는 "에지" 부분
예를 들어 청구 지불 로직을 구현하는 워크플로가 있다고 가정해 보겠습니다.
도메인 논리와 I/O를 혼합한 모델에서 구현은 다음과 같이 설계될 수 있습니다.
- 데이터베이스에서 송장을 로드합니다.
- 결제를 적용합니다.
- 인보이스가 완납된 경우 데이터베이스에 인보이스를 완납한 것으로 표시하고 InvoicePaid 이벤트를 발행하.
- 인보이스가 전액 지불되지 않은 경우 데이터베이스에 부분 지불된 것으로 표시하고 이벤트를 발행하지 않습니다.
F# 코드는 다음과 같습니다.
// workflow mixes domain logic and I/O
let payInvoice invoiceId payment =
// load from DB
let invoice = loadInvoiceFromDatabase(invoiceId)
// apply payment
invoice.ApplyPayment(payment)
// handle different outcomes
if invoice.IsFullyPaid then
markAsFullyPaidInDb(invoiceId)
postInvoicePaidEvent(invoiceId)
else
markAsPartiallyPaidInDb(invoiceId)
문제는 함수가 순수하지 않고 테스트하기 어렵다는 것입니다.
데이터베이스를 건드리지 않고 대신 다음에 할 일에 대한 결정을 반환하는 applyPayment 함수로 순수한 비즈니스 로직을 추출해 보겠습니다.
이를 InvoicePaymentResult라고 부를 것입니다.
type InvoicePaymentResult =
| FullyPaid
| PartiallyPaid of ...
// domain workflow: pure function
let applyPayment unpaidInvoice payment :InvoicePaymentResult =
// apply payment
let updatedInvoice = unpaidInvoice |> applyPayment payment
// handle different outcomes
if isFullyPaid updatedInvoice then
FullyPaid
else
PartiallyPaid updatedInvoice
// return PartiallyPaid or FullyPaid
이 함수는 완전히 순수합니다. 데이터를 로드하지 않으며 필요한 모든 데이터가 매개변수로 전달됩니다.
그리고 어떤 데이터도 저장하지 않습니다. 결정을 내리지만 해당 결정을 즉시 실행하지 않고 초이스 타입으로 반환합니다.
결과적으로 이 함수의 논리가 작동하는지 테스트하기 쉽습니다.
이 함수는 다음과 같이 I/O가 허용되는 바운디드 컨텍스트의 경계에서 커맨드 핸들러의 일부로 사용할 것입니다.
type PayInvoiceCommand = {
InvoiceId : ...
Payment : ...
}
// command handler at the edge of the bounded context
let payInvoice payInvoiceCommand =
// load from DB
let invoiceId = payInvoiceCommand.InvoiceId
let unpaidInvoice =
loadInvoiceFromDatabase invoiceId // I/O
// call into pure domain
let payment =
payInvoiceCommand.Payment // pure
let paymentResult =
applyPayment unpaidInvoice payment // pure
// handle result
match paymentResult with
| FullyPaid ->
markAsFullyPaidInDb invoiceId // I/O
postInvoicePaidEvent invoiceId // I/O
| PartiallyPaid updatedInvoice ->
updateInvoiceInDb updatedInvoice // I/O
이 함수는 자체적으로 판단하지 않습니다.
내부 도메인 중심 함수에서 내린 결정을 처리할 뿐입니다.
결과적으로 이 함수는 단위 테스트할 필요가 없습니다.
지속성 로직이 일반적으로 간단하기 때문입니다.
물론 테스트하지 않아야 한다는 의미는 아니지만 종단 간 통합(E2E integration) 테스트의 일부로 테스트하는 것이 더 나을 수 있습니다.
이 합성 함수는 그림과 같이 순수한 중심이 있는 가장자리의 I/O인 샌드위치로 생각할 수 있습니다.
이 함수를 격리하여 테스트하려면 다음과 같이 호출하는 모든 I/O 작업을 나타내기 위해 추가 함수 매개변수를 추가하기만 하면 됩니다.
즉 일반적인 방식으로 매개변수에 대한 스텁을 제공하여 이 기능을 쉽게 테스트할 수 있습니다.
이와 같이 I/O를 사용하는 합성 함수는 물론 "컴포지션 루트(app 함수)" 또는 컨트롤러, 즉 응용 프로그램의 최상위 수준에 있어야 합니다.
// command handler at the edge of the bounded context
let payInvoice
loadUnpaidInvoiceFromDatabase // dependency
markAsFullyPaidInDb // dependency
updateInvoiceInDb // dependency
payInvoiceCommand =
// load from DB
let invoiceId = payInvoiceCommand.InvoiceId
let unpaidInvoice =
loadUnpaidInvoiceFromDatabase invoiceId
// call into pure domain
let payment =
payInvoiceCommand.Payment
let paymentResult =
applyPayment unpaidInvoice payment
// handle result
match paymentResult with
| FullyPaid ->
markAsFullyPaidInDb(invoiceId)
postInvoicePaidEvent(invoiceId)
| PartiallyPaid updatedInvoice ->
updateInvoiceInDb updatedInvoice
Making Decisions Based on Queries
위의 예에서는 모든 데이터가 도메인 함수 외부에서 로드된 다음 전달될 수 있다고 가정했습니다.
그러나 "순수한" 코드의 중간에서 데이터베이스에서 읽은 것을 기반으로 결정을 내려야 한다면 어떻게 될까요?
해결책은 순수 함수를 그대로 유지하되 순수하지 않은 I/O 함수 사이에 끼워 넣는 것입니다.
순수 함수는 비즈니스 로직을 포함하며, 비즈니스적인 결정을 내리고
I/O 함수는 데이터를 읽고 씁니다.
예를 들어 워크플로를 확장해야 한다고 가정해 보겠습니다.
결제 후 총 미납 금액을 계산하고 너무 큰 경우 고객에게 경고 메시지를 보냅니다.
추가 요구 사항이 있는 경우 파이프라인의 단계는 다음과 같습니다.
(주 : 외부에서 정의한 순수 함수를 워크플로 사이에 끼워넣음)
--- I/O---
Load invoice from DB
--- Pure ---
Do payment logic
--- I/O ---
Pattern match on output choice type:
if "FullyPaid" -> Mark invoice as paid in DB
if "PartiallyPaid" -> Save updated invoice to DB
--- I/O ---
Load all amounts from unpaid invoices in DB
--- Pure ---
Add the amounts up and decide if amount is too large
--- I/O ---
Pattern match on output choice type:
If "OverdueWarningNeeded" -> Send message to customer
If "NoActionNeeded" -> do nothing
I/O와 로직이 너무 많이 섞이면 단순한 "샌드위치"가 "레이어 케이크"가 될 수 있습니다.
이 경우 장기 실행 워크플로에서 설명한 대로 워크플로를 더 짧은 미니 워크플로로 나눌 수 있습니다. (saga - io를 나누어서 반복)
이렇게 하면 각 워크플로를 작고 간단한 샌드위치로 유지할 수 있습니다.
Where’s the Repository Pattern?
원래 Domain-Driven Design 책에는 리포지토리 패턴이라는 데이터베이스 액세스 패턴이 있습니다.
이 책에 익숙하다면 그 패턴이 함수형 접근 방식에 어떻게 부합하는지 궁금할 것입니다.
대답은 그렇지 않다는 것입니다.
리포지토리 패턴은 가변성에 의존하는 객체 지향 디자인에서 영속성을 숨기는 좋은 방법입니다.
그러나 모든 것을 함수로 모델링하고 지속성을 에지까지 밀어넣으면 리포지토리 패턴이 더 이상 필요하지 않습니다.
해당 워크플로에서 사용하지 않는 수십가지 메서드를 포함한 단일 인터페이스를 관리할 필요가 없어 유지보수에도 이점이 있습니다.
각 특정 I/O 액세스에 대해 고유한 함수를 정의하고 필요할 때만 사용합니다.
Command-Query Separation
다음으로 살펴볼 원칙은 명령 쿼리 분리(CQS)입니다.
도메인 모델링에 대한 함수형 접근 방식에서 모든 객체는 변경할 수 없도록 설계되었습니다.
스토리지 시스템도 일종의 불변 객체라고 생각합시다.
즉, 스토리지 시스템의 데이터를 변경할 때마다 자체적으로 새로운 버전으로 변환됩니다.
예를 들어, 함수형 방식으로 레코드 삽입을 모델링하려는 경우 삽입 함수에 두 개의 매개변수가 있는 것으로 생각할 수 있습니다.
삽입할 데이터와 데이터 저장소의 원래 상태입니다. 삽입이 완료된 후 함수의 출력은 데이터가 추가된 데이터 저장소의 새 버전입니다.
다음 타입 시그니처를 사용하여 모델링할 수 있습니다.
type InsertData = DataStoreState -> Data -> NewDataStoreState
데이터 저장소와 상호 작용하는 기본 방법에는 "Create"(또는 Insert"), "Read"(또는 "Query"), "Update" 및 "Delete"의 네 가지가 있습니다. 방금 "Insert"을 살펴보았습니다. 다른 것들도 도표화해 보겠습니다.
type InsertData = DataStoreState -> Data -> NewDataStoreState
type ReadData = DataStoreState -> Query -> Data
type UpdateData = DataStoreState -> Data -> NewDataStoreState
type DeleteData = DataStoreState -> Key -> NewDataStoreState
이들 중 하나는 다른 것과 같지 않습니다. 두 가지 다른 종류의 작업이 있음이 분명합니다.
- 삽입, 업데이트 및 삭제 작업은 데이터베이스의 상태를 변경하고 (일반적으로) 유용한 데이터를 반환하지 않습니다.
- 반면에 읽기 또는 쿼리 작업은 데이터베이스의 상태를 변경하지 않으며 유용한 결과를 반환하는 것은 네 가지 중 유일한 작업입니다.
명령 쿼리 분리(Command-query separation)는 이러한 구분을 기반으로 하는 설계 원칙입니다. 데이터를 반환하는 코드("쿼리")는 데이터를 업데이트하는 코드("명령")와 혼동되어서는 안 된다고 명시되어 있습니다. 또는 더 간단하게 말하면 질문을 한다고 해서 답이 바뀌지 않아야 합니다.
함수형 프로그래밍에 적용되는 CQS 원칙은 다음과 같이 제안합니다.
- 데이터를 반환하는 함수에는 부작용이 없어야 합니다.
- 부작용(상태 업데이트)이 있는 함수는 데이터를 반환하지 않아야 합니다. 즉, unit(void) 반환 함수여야 합니다.
이제 서명은 다음과 같이 표시됩니다.
type InsertData = DbConnection -> Data -> Unit
type ReadData = DbConnection -> Query -> Data
type UpdateData = DbConnection -> Data -> Unit
type DeleteData = DbConnection -> Key -> Unit
DbConnection 타입은 특정 데이터 저장소에 따라 다르므로 부분 적용 또는 유사한 기술(리더 모나드, 프리 모나드, tagless final)을 사용하여 호출자로부터 이 종속성을 숨기고자 합니다.
도메인 코드는 데이터베이스에 구애받지 않으며 다음과 같은 서명을 갖습니다.
type InsertData = Data -> Unit
type ReadData = Query -> Data
type UpdateData = Data -> Unit
type DeleteData = Key -> Unit
물론 우리는 I/O와 가능한 오류를 다루고 있기 때문에 실제 서명에는 몇 가지 효과가 포함되어야 합니다.
Result type과 Async도 포함하는 DataStoreResult 또는 DbResult와 같은 별칭을 만드는 것이 일반적입니다.
그러면 서명은 다음과 같이 표시됩니다.
type DbError = ...
type DbResult<'a> = AsyncResult<'a,DbError>
type InsertData = Data -> DbResult<Unit>
type ReadData = Query -> DbResult<Data>
type UpdateData = Data -> DbResult<Unit>
type DeleteData = Key -> DbResult<Unit>
Command-Query Responsibility Segregation
읽기와 쓰기에 동일한 객체를 재사용하려고 하는 경우가 많습니다.
예를 들어 고객 레코드가 있는 경우 데이터베이스에 저장하고 다음과 같은 부작용이 있는 함수를 사용하여 데이터베이스에서 로드할 수 있습니다.
type SaveCustomer = Customer -> DbResult<Unit>
type LoadCustomer = CustomerId -> DbResult<Customer>
그러나 여러 가지 이유로 읽기와 쓰기 모두에 동일한 타입을 재사용하는 것은 좋은 생각이 아닙니다.
첫째, 쿼리에서 반환된 데이터는 DB에 writing할 때 필요한 데이터와 다른 경우가 많습니다.
예를 들어 쿼리는 비정규화된 데이터나 계산된 값을 반환할 수 있지만 해당 값은 데이터를 쓸 때는 사용되지 않습니다.
또한 새 레코드를 생성할 때 생성된 ID 또는 버전과 같은 필드는 사용되지 않지만 쿼리에서 반환됩니다.
하나의 데이터 유형을 여러 용도로 사용하려고 하는 것보다 각 데이터 유형을 하나의 특정 용도로 설계하는 것이 좋습니다.
F#에서는 앞에서 본 것처럼 필요한 만큼 많은 데이터 타입을 쉽게 만들 수 있습니다.
둘째, 쿼리와 명령이 독립적으로 발전하는 경향이 있으므로 결합되어서는 안 된다는 것입니다.
예를 들어, 시간이 지남에 따라 동일한 데이터를 위한 하나의 업데이트 명령에 대해 3~4개의 서로 다른 쿼리가 필요해질 수 있습니다.
쿼리 유형과 명령 유형을 강제로 동일하게 하면 어색해집니다.
마지막으로 일부 쿼리는 성능상의 이유로 여러 엔터티를 한 번에 반환해야 할 수도 있습니다.
예를 들어 주문을 로드할 때 고객을 얻기 위해 데이터베이스를 두 번 방문하는 대신 해당 주문과 관련된 고객 데이터를 한 번에 로드할 수도 있습니다. 물론 주문을 DB에 저장할 때 전체 고객이 아닌 고객에 대한 참조(CustomerId)만 사용합니다.
이러한 관찰을 바탕으로 쿼리와 명령은 도메인 모델링 관점에서 거의 항상 다르기 때문에 서로 다른 유형으로 모델링해야 한다는 것이 분명합니다.
쿼리 유형과 명령 유형의 이러한 분리는 자연스럽게 서로 다른 모듈로 분리되어 진정으로 분리되고 독립적으로 발전할 수 있는 설계로 이어집니다.
한 모듈은 쿼리(읽기 모델이라고 함)를 담당하고 다른 모듈은 명령(쓰기 모델)을 담당하므로 명령 쿼리 책임 분리 또는 CQRS입니다.
예를 들어 Customer에 대해 별도의 읽기 및 쓰기 모델을 갖고 싶다면 WriteModel.Customer 유형과 ReadModel.Customer 유형을 정의할 수 있으며 데이터 액세스 함수는 다음과 같습니다.
type SaveCustomer = WriteModel.Customer -> DbResult<Unit>
type LoadCustomer = CustomerId -> DbResult<ReadModel.Customer>
CQRS and Database Segregation
CQRS 원칙은 데이터베이스에도 적용될 수 있습니다.
이 경우 쓰기에 최적화된 것(인덱스 없음, 트랜잭션 등)과 쿼리에 최적화된 것(비정규화, 과도하게 인덱싱된 등)의 두 가지 데이터 저장소가 있습니다.
이것은 물론 "논리적" 보기입니다.
두 개의 개별 물리적 데이터베이스가 필요하지 않습니다.
예를 들어 관계형 데이터베이스에서 "쓰기" 모델은 단순히 테이블이 될 수 있고 "읽기" 모델은 해당 테이블에 대한 미리 정의된 뷰가 될 수 있습니다.
물리적으로 별도의 데이터 저장소가 있는 경우 "쓰기 저장소"에서 "읽기 저장소"로 데이터를 복사하는 특수 프로세스를 구현해야 합니다. 이것은 추가 작업이므로 개별 데이터 저장소의 설계 이점이 가치가 있는지 여부를 결정해야 합니다.
더 중요한 것은 읽기 측의 데이터가 쓰기 측의 데이터에 비해 오래되었을 수 있다는 것입니다.
즉, 읽기 저장소가 즉시 일관성이 아니라 "최종적으로 일관성"이 있음을 의미합니다.
이것은 도메인에 따라 문제가 될 수도 있고 그렇지 않을 수도 있습니다.
그러나 일단 읽기와 쓰기를 분리하기로 결정하면 각각 특정 도메인에 최적화된 많은 고유한 읽기 저장소(read store)를 사용할 수 있는 유연성을 갖게 됩니다.
특히 보고서 또는 분석을 수행하는 데 매우 유용한, 여러 바운디드 컨텍스트에서 집계된 데이터를 포함하는 읽기 저장소를 가질 수 있습니다.
Event Sourcing
CQRS는 종종 이벤트 소싱과 관련이 있습니다.
이벤트 소싱 접근 방식에서 현재 상태는 단일 개체로 유지되지 않습니다.
대신 상태가 변경될 때마다 변경을 나타내는 이벤트(예: InvoicePaid)가 영속됩니다.
이러한 방식으로 이전 상태와 새 상태 사이의 각 차이점이 일종의 버전 제어 시스템처럼 캡처됩니다.
워크플로 시작 시 현재 상태를 복원하기 위해 모든 이전 이벤트가 재생됩니다.
이 접근 방식에는 많은 이점이 있습니다.
특히 모든 것이 감사되는 많은 도메인의 모델과 일치한다는 점입니다.
"회계사는 지우개를 사용하지 않는다"라는 말이 있듯이 말입니다.
Bounded Contexts Must Own Their Data Storage
지속성을 위한 또 다른 주요 지침은 각 바운디드 컨텍스트는 데이터 저장 측면에서 다른 컨텍스트와 격리되어야 한다는 것입니다.
이는 다음을 의미합니다.
- 바운디드 컨텍스트는 자체 데이터 저장소 및 관련 스키마를 소유해야 하며 다른 바운디드 컨텍스트와 조정할 필요 없이 언제든지 변경할 수 있습니다.
- 다른 시스템은 바운디드 컨텍스트가 소유한 데이터에 직접 액세스할 수 없습니다. 대신 클라이언트는 바운디드 컨텍스트의 공개 API를 사용하거나 일종의 데이터 저장소 복사본을 사용해야 합니다.
목표는 항상 그렇듯이 각 바운디드 컨텍스트가 분리된 상태를 유지하고 독립적으로 발전할 수 있도록 하는 것입니다.
시스템 A가 시스템 B의 데이터 저장소에 액세스하면 코드베이스가 완전히 독립적이더라도 공유 데이터로 인해 두 시스템은 실제로 여전히 결합되어 있습니다.
"격리"의 구현은 설계 요구 사항과 운영 팀의 요구 사항에 따라 달라질 수 있습니다. 한 가지 극단적인 경우 각 바운디드 컨텍스트에는 다른 바운디드 컨텍스트와 완전히 별도로 배포되는 물리적으로 고유한 데이터베이스 또는 데이터 저장소가 있을 수 있습니다. 다른 극단에서는 모든 컨텍스트의 모든 데이터를 하나의 물리적 데이터베이스에 저장할 수 있지만(배포가 더 쉬워짐) 각 컨텍스트에 대한 데이터를 논리적으로 분리된 상태로 유지하기 위해 일종의 네임스페이스 메커니즘을 사용할 수 있습니다.
Working with Data from Multiple Domains
보고 및 비즈니스 분석 시스템은 어떻습니까? 여러 컨텍스트의 데이터에 액세스해야 하지만 우리는 이것이 잘못된 생각이라고 말했습니다.
해결책은 "reporting", 즉 "비즈니스 인텔리전스"를 별도의 도메인으로 취급하고 다른 바운디드 컨텍스트가 소유한 데이터를 보고용으로 설계된 별도의 시스템으로 복사하는 것입니다.
이 접근 방식은 더 많은 작업이 필요하지만 source 시스템과 reporting 시스템이 독립적으로 발전하고 각자의 관심사에 맞게 최적화되도록 합니다. 물론 이 접근 방식은 새로운 것이 아닙니다.
OLTP와 OLAP 시스템 간의 구분은 수십 년 동안 존재해 왔습니다.
다른 바운디드 컨텍스트에서 비즈니스 인텔리전스 컨텍스트로 데이터를 가져오는 다양한 방법이 있습니다.
"순수한" 방법은 다른 시스템에서 발생하는 이벤트를 구독하도록 하는 것입니다.
예를 들어 주문이 생성될 때마다 이벤트가 트리거되고 비즈니스 인텔리전스(또는 "BI") 컨텍스트가 해당 이벤트를 수신하고 자체 데이터 저장소에 해당 레코드를 삽입할 수 있습니다.
이 접근 방식은 비즈니스 인텔리전스 컨텍스트가 또 다른 영역일 뿐이며 디자인에서 특별한 처리가 필요하지 않다는 장점이 있습니다.
또 다른 방법은 기존 ETL 프로세스를 사용하여 소스 시스템에서 BI 시스템으로 데이터를 복사하는 것입니다.
이것은 초기에 구현하기가 더 쉽다는 장점이 있지만 소스 시스템이 데이터베이스 스키마를 변경할 때 변경해야 하기 때문에 추가 유지 관리가 필요할 수 있습니다.
비즈니스 인텔리전스 도메인 내에서는 공식 도메인 모델이 거의 필요하지 않습니다.
임시(ad hoc) 쿼리와 다양한 액세스 경로를 효율적으로 지원하는 다차원 데이터베이스(구어적으로 "큐브"로 알려짐)를 개발하는 것이 더 중요할 것입니다.
유사한 접근 방식을 사용하여 운영에 필요한 데이터를 처리할 수 있습니다.
우리는 "운영 인텔리전스"를 별도의 도메인으로 취급한 다음 분석 및 보고를 위해 로깅, 메트릭 및 기타 종류의 데이터를 해당 도메인으로 보냅니다.
Working with Document Databases
우리는 지속성의 일반적인 원칙에 대해 이야기했습니다.
이제 기어를 완전히 바꾸고 JSON 또는 XML 형식으로 반구조화된 데이터를 저장하도록 설계된 소위 "문서 데이터베이스"부터 시작하여 몇 가지 구현 예를 살펴보겠습니다.
문서 데이터베이스를 영속하는 것은 쉽습니다.
우리는 이전 장(11장, 직렬화)에서 논의한 기술을 사용하여 도메인 개체를 DTO로 변환한 다음
JSON 문자열(또는 XML 문자열 등)로 변환한 다음 저장소의 API를 통해 저장하고 로드합니다.
예를 들어 Azure Blob Storage를 사용하여 PersonDto 개체를 저장하는 경우 다음과 같이 스토리지를 설정할 수 있습니다.
open Microsoft.WindowsAzure
open Microsoft.WindowsAzure.Storage
open Microsoft.WindowsAzure.Storage.Blob
let connString = "... Azure connection string ..."
let storageAccount = CloudStorageAccount.Parse(connString)
let blobClient = storageAccount.CreateCloudBlobClient()
let container = blobClient.GetContainerReference("Person");
container.CreateIfNotExists()
그런 다음 몇 줄의 코드로 DTO를 blob에 저장할 수 있습니다.
type PersonDto = {
PersonId : int
...
}
let savePersonDtoToBlob personDto =
let blobId = sprintf "Person%i" personDto.PersonId
let blob = container.GetBlockBlobReference(blobId)
let json = Json.serialize personDto
blob.UploadText(json)
이게 전부입니다. 같은 방식으로 이전 장의 역직렬화 기술을 사용하여 저장소에서 로드하는 코드를 만들 수 있습니다.
Working with Relational Databases
관계형 데이터베이스는 대부분의 코드와 매우 다른 모델을 가지고 있으며
전통적으로 이것은 개체와 데이터베이스 간의 소위 "임피던스 불일치"라는 많은 고통의 원인이었습니다.
함수형 프로그래밍 원칙을 사용하여 개발된 데이터 모델은
함수형 모델이 데이터와 메서드를 혼합하지 않으므로 레코드 저장 및 검색이 더 간단하기 때문에 관계형 데이터베이스와 더 호환되는 경향이 있습니다.
그럼에도 불구하고 우리는 여전히 몇 가지 문제를 해결해야 합니다. 관계형 데이터베이스 모델이 함수형 모델과 어떻게 비교되는지 살펴보겠습니다.
첫째, 좋은 소식은 관계형 데이터베이스의 테이블이 함수형 모델의 레코드 모음과 잘 일치한다는 것입니다.
그리고 데이터베이스의 집합 지향 연산(SELECT, WHERE)은 함수형 언어의 리스트 지향 연산(맵, 필터)과 유사합니다.
따라서 우리의 전략은 이전 장의 직렬화 기술을 사용하여 테이블에 직접 매핑할 수 있는 레코드 유형을 설계하는 것입니다.
예를 들어 다음과 같은 도메인 타입이 있다고 가정합니다.
type CustomerId = CustomerId of int
type String50 = String50 of string
type Birthdate = Birthdate of DateTime
type Customer = {
CustomerId : CustomerId
Name : String50
Birthdate : Birthdate option
}
그러면 해당 테이블 디자인이 간단합니다.
CREATE TABLE Customer (
CustomerId int NOT NULL,
Name NVARCHAR(50) NOT NULL,
Birthdate DATETIME NULL,
CONSTRAINT PK_Customer PRIMARY KEY (CustomerId)
)
나쁜 소식은 관계형 데이터베이스는 문자열이나 int와 같은 기본 요소만 저장한다는 것입니다.
즉, ProductCode 또는 OrderId와 같은 멋진 도메인 타입을 언래핑해야 합니다.
설상가상으로 관계형 테이블은 초이스 타입에 잘 매핑되지 않습니다. 더 자세히 살펴봐야 할 부분입니다.
Mapping Choice Types to Tables
관계형 데이터베이스에서 초이스 타입을 어떻게 모델링해야 합니까?
초이스 타입을 한 수준의 상속 계층으로 생각하면 개체 계층을 관계형 모델에 매핑하는 데 사용되는 몇 가지 접근 방식을 빌릴 수 있습니다.
초이스 타입 매핑에 가장 유용한 두 가지 접근 방식은 다음과 같습니다.
- 모든 케이스는 같은 테이블에 있습니다.
- 각 케이스에는 자체 테이블이 있습니다.
예를 들어 초이스 타입이 포함된 Contact(아래)와 같은 타입이 있고 이를 데이터베이스에 저장하려고 한다고 가정해 보겠습니다.
type Contact = {
ContactId : ContactId
Info : ContactInfo
}
and ContactInfo =
| Email of EmailAddress
| Phone of PhoneNumber
and EmailAddress = EmailAddress of string
and PhoneNumber = PhoneNumber of string
and ContactId = ContactId of int
첫 번째 접근 방식("하나의 테이블에 있는 모든 경우")은 논의된 접근 방식과 유사합니다.
우리는 단 하나의 테이블을 사용하여 모든 케이스의 모든 데이터를 저장할 것입니다.
이는 차례로
(a) 어떤 케이스가 사용되고 있는지 나타내는 플래그가 필요하고
(b) NULL을 허용해야 함을 의미합니다. 일부 경우에만 사용되는 열입니다.
CREATE TABLE ContactInfo (
-- shared data
ContactId int NOT NULL,
-- case flags
IsEmail bit NOT NULL,
IsPhone bit NOT NULL,
-- data for the "Email" case
EmailAddress NVARCHAR(100), -- Nullable
-- data for the "Phone" case
PhoneNumber NVARCHAR(25), -- Nullable
-- primary key constraint
CONSTRAINT PK_ContactInfo PRIMARY KEY (ContactId)
)
단일 Tag VARCHAR 필드 대신 각 경우에 비트 필드 플래그를 사용했는데, 이는 약간 더 간결하고 인덱싱하기 쉽기 때문입니다.
두 번째 접근 방식(각 케이스에 자체 테이블이 있는 경우)은 기본 테이블 외에 두 개의 하위 테이블을 생성한다는 의미입니다.
각 케이스에 대해 하나의 하위 테이블이 있습니다.
모든 테이블은 동일한 기본 키를 공유합니다.
기본 테이블은 ID와 어떤 케이스가 활성 상태인지 나타내는 일부 플래그를 저장하는 반면
하위 테이블은 각 케이스에 대한 데이터를 저장합니다.
더 복잡한 대신 데이터베이스에 더 나은 제약 조건이 있습니다(예: 자식 테이블의 NOT NULL 열).
-- Main table
CREATE TABLE ContactInfo (
-- shared data
ContactId int NOT NULL,
-- case flags
IsEmail bit NOT NULL,
IsPhone bit NOT NULL,
CONSTRAINT PK_ContactInfo PRIMARY KEY (ContactId)
)
-- Child table for "Email" case
CREATE TABLE ContactEmail (
ContactId int NOT NULL,
-- case-specific data
EmailAddress NVARCHAR(100) NOT NULL,
CONSTRAINT PK_ContactEmail PRIMARY KEY (ContactId)
)
-- Child table for "Phone" case
CREATE TABLE ContactPhone (
ContactId int NOT NULL,
-- case-specific data
PhoneNumber NVARCHAR(25) NOT NULL,
CONSTRAINT PK_ContactPhone PRIMARY KEY (ContactId)
)
이 "다중 테이블" 접근 방식은 케이스와 관련된 데이터가 매우 크고 공통점이 거의 없을 때 더 나을 수 있지만
그렇지 않으면 기본적으로 첫 번째 "하나의 테이블" 접근 방식을 사용합니다.
Mapping Nested Types to Tables
타입에 다른 타입이 포함되어 있으면 어떻게 됩니까? 이것들은 어떻게 처리해야 합니까? 일반적인 조언은 다음과 같습니다.
- 내부 타입이 고유한 ID를 가진 DDD Entity인 경우 별도의 테이블에 저장해야 합니다.
- 내부 타입이 고유한 ID가 없는 DDD value object인 경우 상위 데이터와 함께 "인라인"으로 저장해야 합니다.
예를 들어 주문 타입에는 OrderLine 값 리스트기 포함되어 있습니다.
OrderLine 타입은 Entity이므로 상위 개체에 대한 포인터(외래 키)와 함께 자체 테이블에 저장해야 합니다.
CREATE TABLE Order (
OrderId int NOT NULL,
-- and other columns
)
CREATE TABLE OrderLine (
OrderLineId int NOT NULL,
OrderId int NOT NULL,
-- and other columns
)
반면에 주문 타입에는 값 개체인 두 개의 주소 값이 포함됩니다.
따라서 해당 주문 테이블에는 모든 주소 열이 직접 포함되어야 합니다.
CREATE TABLE Order (
OrderId int NOT NULL,
-- inline the shipping address Value Object
ShippingAddress1 varchar(50)
ShippingAddress2 varchar(50)
ShippingAddressCity varchar(50)
-- and so on
-- inline the billing address Value Object
BillingAddress1 varchar(50)
BillingAddress2 varchar(50)
BillingAddressCity varchar(50)
-- and so on
-- other columns
)
Reading from a Relational Database
F#에서는 ORM(개체 관계형 매퍼)을 사용하지 않고 대신 raw SQL 명령으로 직접 작업하는 경향이 있습니다.
이를 수행하는 가장 편리한 방법은 F# SQL 형식 공급자를 사용하는 것입니다.
이 중 몇 가지를 사용할 수 있습니다. 이 예에서는 FSharp.Data.SqlClient 타입 공급자를 사용합니다.
일반적인 런타임 라이브러리가 아닌 타입 공급자를 사용할 때의 특별한 점은 SQL 타입 공급자가 컴파일 시간에 SQL 쿼리 또는 SQL 명령과 일치하는 타입을 생성한다는 것입니다. SQL 쿼리가 올바르지 않으면 런타임 오류가 아닌 컴파일 시간 오류가 발생합니다.
그리고 SQL이 정확하면 SQL 코드의 출력과 정확히 일치하는 F# 레코드 타입을 생성합니다.
CustomerId를 사용하여 단일 고객을 읽고 싶다고 가정해 보겠습니다. 타입 공급자를 사용하여 이 작업을 수행하는 방법은 다음과 같습니다.
먼저 일반적으로 로컬 데이터베이스를 참조하는 컴파일 타임에 사용할 연결 문자열을 정의합니다.
open FSharp.Data
[< Literal>]
let CompileTimeConnectionString =
@"Data Source=(localdb)\MsSqlLocalDb; Initial Catalog=DomainModelingExample;"
그런 다음 쿼리를 다음과 같이 ReadOneCustomer라는 타입으로 정의합니다.
type ReadOneCustomer = SqlCommandProvider<"""
SELECT CustomerId, Name, Birthdate
FROM Customer
WHERE CustomerId = @customerId
""", CompileTimeConnectionString>
컴파일 시간에 타입 공급자는 로컬 데이터베이스에서 이 쿼리를 실행하고 이를 나타내는 타입을 생성합니다.
이것은 별도의 파일이 생성되지 않는다는 점을 제외하고는 SqlMetal 또는 EdmGenerator 유틸리티와 유사합니다. 타입이 자동으로 생성됩니다. 나중에 이 유형을 사용할 때 컴파일 타임 연결과 다른 "프로덕션" 연결을 제공할 것입니다.
다음으로 이전 장의 직렬화 예제와 마찬가지로 toDomain 함수를 만들어야 합니다.
(데이터베이스의 필드를 검증한 다음 result 표현식을 사용하여 조합합니다.)
즉, 데이터베이스를 다른 데이터 소스와 마찬가지로 유효성을 검사해야 하는 신뢰할 수 없는 데이터 소스로 취급하므로 toDomain 함수는 일반 고객이 아닌 Result<Customer,_>를 반환해야 합니다. 코드는 다음과 같습니다.
let toDomain (dbRecord:ReadOneCustomer.Record) : Result<Customer,_> =
result {
let! customerId =
dbRecord.CustomerId
|> CustomerId.create
let! name =
dbRecord.Name
|> String50.create "Name"
let! birthdate =
dbRecord.Birthdate
|> Result.bindOption Birthdate.create
let customer = {
CustomerId = customerId
Name = name
Birthdate = birthdate
}
return customer
}
우리는 직렬화 장에서 이런 종류의 코드를 본 적이 있습니다.
그러나 하나의 새로운 추가 사항이 있습니다. 데이터베이스의 Birthdate 열은 null을 허용하므로 형식 공급자는 dbRecord.Birthdate 필드를 Option 형식으로 만듭니다.
그러나 Birthdate.create 함수는 옵션을 허용하지 않습니다.
이 문제를 해결하기 위해 "스위치" 기능이 옵션에서 작동하도록 하는 bindOption이라는 작은 도우미 함수를 만들 것입니다.
(Result(some))
let bindOption f xOpt =
match xOpt with
| Some x -> f x |> Result.map Some
| None -> Ok None
이와 같이 사용자 정의 toDomain 함수를 작성하고 모든것을 result type으로 작업하는 것은 약간 복잡하지만
일단 작성되면 처리되지 않은 오류가 절대 발생하지 않을 것이라고 확신할 수 있습니다.
반면에 데이터베이스에 나쁜 데이터가 포함되지 않을 것이라고 매우 확신하고 있으며,
데이터가 잘못될 경우 패닉에 빠질 의향이 있다면(시스템 에러로 처리) 잘못된 데이터에 대한 예외를 throw할 수 있습니다.
이 경우 코드를 변경하여 패닉 OnError 도우미 함수(오류 결과를 예외로 변환)를 사용할 수 있습니다.
이는 결과적으로 toDomain 함수의 출력이 Result로 래핑되지 않은 일반 Customer임을 의미합니다. 코드는 다음과 같습니다.
let toDomain (dbRecord:ReadOneCustomer.Record) : Customer =
let customerId =
dbRecord.CustomerId
|> CustomerId.create
|> panicOnError "CustomerId"
let name =
dbRecord.Name
|> String50.create "Name"
|> panicOnError "Name"
let birthdate =
dbRecord.Birthdate
|> Result.bindOption Birthdate.create
|> panicOnError "Birthdate"
// return the customer
{CustomerId = customerId; Name = name; Birthdate = birthdate}
여기서 panicOnError 도우미 함수는 다음과 같습니다.
exception DatabaseError of string
let panicOnError columnName result =
match result with
| Ok x -> x
| Error err ->
let msg = sprintf "%s: %A" columnName err
raise (DatabaseError msg)
어느 쪽이든 일단 toDomain 함수가 있으면 데이터베이스를 읽고 결과를 도메인 타입으로 반환하는 코드를 작성할 수 있습니다.
예를 들어 다음은 ReadOneCustomer 쿼리를 수행한 다음 이를 도메인 타입으로 변환하는 readOneCustomer 함수입니다.
type DbReadError =
| InvalidRecord of string
| MissingRecord of string
let readOneCustomer (productionConnection:SqlConnection) (CustomerId customerId) =
// create the command by instantiating the type we defined earlier
use cmd = new ReadOneCustomer(productionConnection)
// execute the command
let records = cmd.Execute(customerId = customerId) |> Seq.toList
// handle the possible cases
match records with
// none found
| [] ->
let msg = sprintf "Not found. CustomerId=%A" customerId
Error (MissingRecord msg) // return a Result
// exactly one found
| [dbCustomer] ->
dbCustomer
|> toDomain
|> Result.mapError InvalidRecord
// more than one found?
| _ ->
let msg = sprintf "Multiple records found for CustomerId=%A" customerId
raise (DatabaseError msg)
먼저 "프로덕션" 연결로 사용할 SqlConnection을 명시적으로 전달하고 있습니다.
다음으로 처리할 수 있는 세 가지 경우가 있습니다.
- 레코드를 찾을 수 없음.
- 정확히 하나의 레코드를 찾았음.
- 둘 이상의 레코드를 찾았음.
도메인의 일부로 처리해야 하는 경우와 발생하지 않아야 하는 경우(패닉 상태로 처리할 수 있음)를 결정해야 합니다.
이 경우, 우리는 누락된 레코드가 가능하고 Result.Error로 처리되는 반면,
둘 이상의 레코드는 패닉으로 처리된다고 말할 것입니다.
이러한 다양한 케이스를 처리하는 것은 많은 작업처럼 보이지만
모든 것이 작동한다고 가정한 다음 어딘가에 NullReferenceException이 발생하는 대신
가능한 오류에 대해 명시적으로 결정을 내릴 수 있다는 이점이 있습니다.
(파라미터 : tableName idValue records toDomain)
패턴 매칭 주의!
- 레코드를 찾을 수 없음.
- 정확히 하나의 레코드를 찾았음.
- 둘 이상의 레코드를 찾았음.
let convertSingleDbRecord tableName idValue records toDomain =
match records with
// none found
| [] ->
let msg = sprintf "Not found. Table=%s Id=%A" tableName idValue
Error msg // return a Result
// exactly one found
| [dbRecord] ->
dbRecord
|> toDomain
|> Ok // return a Result
// more than one found?
| _ ->
let msg = sprintf "Multiple records found. Table=%s Id=%A" tableName idValue
raise (DatabaseError msg)
이 일반 도우미 함수를 사용하면 코드를 몇 줄로 줄일 수 있습니다.
let readOneCustomer (productionConnection:SqlConnection) (CustomerId customerId) =
use cmd = new ReadOneCustomer(productionConnection)
let tableName = "Customer"
let records = cmd.Execute(customerId = customerId) |> Seq.toList
convertSingleDbRecord tableName customerId records toDomain
Reading Choice Types from a Relational Database
선택 유형을 같은 방식으로 읽을 수 있지만 조금 더 복잡합니다.
단일 테이블 접근 방식을 사용하여 ContactInfo 레코드를 저장하고 ContactId를 사용하여 단일 ContactInfo를 읽으려고 한다고 가정해 보겠습니다. 이전과 마찬가지로 쿼리를 다음과 같이 타입으로 정의합니다.
type ReadOneContact = SqlCommandProvider<"""
SELECT ContactId,IsEmail,IsPhone,EmailAddress,PhoneNumber
FROM ContactInfo
WHERE ContactId = @contactId
""", CompileTimeConnectionString>
다음으로 toDomain 함수를 만듭니다.
이것은 데이터베이스(IsEmail)의 플래그를 확인하여 생성할 ContactInfo의 케이스를 확인한 다음
하위 result 표현식을 사용하여 각 케이스에 대한 데이터를 어셈블합니다.
(Result를 계속 까서 최종 Result 하나만 만듬.)
//>RelationalDomain_Choice
type Contact = {
ContactId : ContactId
Info : ContactInfo
}
and ContactInfo =
| Email of EmailAddress
| Phone of PhoneNumber
and EmailAddress = EmailAddress of string
and PhoneNumber = PhoneNumber of string
and ContactId = ContactId of int
//<
// Email과 Phone은 option 가능.
let toDomain (dbRecord:ReadOneContact.Record) : Result<Contact,_> =
result {
let! contactId =
dbRecord.ContactId
|> ContactId.create
let! contactInfo =
if dbRecord.IsEmail then
result {
// get the primitive string which should not be NULL
let! emailAddressString =
dbRecord.EmailAddress
|> Result.ofOption "Email expected to be non null"
// create the EmailAddress simple type
let! emailAddress =
emailAddressString |> EmailAddress.create
// lift to the Email case of Contact Info
return (Email emailAddress)
}
else
result {
// get the primitive string which should not be NULL
let! phoneNumberString =
dbRecord.PhoneNumber
|> Result.ofOption "PhoneNumber expected to be non null"
// create the PhoneNumber simple type
let! phoneNumber =
phoneNumberString |> PhoneNumber.create
// lift to the PhoneNumber case of Contact Info
return (Phone phoneNumber)
}
let contact = {
ContactId = contactId
Info = contactInfo
}
return contact
}
예를 들어 Email의 경우 데이터베이스의 EmailAddress 열이 null을 허용하므로 타입 공급자가 생성한 dbRecord.EmailAddress가 Option 타입임을 알 수 있습니다.
따라서 먼저 Result.ofOption을 사용하여 Option을 Result로 변환해야 합니다(누락된 경우).
그런 다음 EmailAddress 타입을 만든 다음 이를 ContactInfo의 Email 케이스로 들어 올립니다.
이전의 Customer 예제보다 훨씬 더 복잡하지만 다시 말하지만 예상치 못한 오류가 발생하지 않을 것이라고 확신합니다.
Result.ofOption 함수의 코드가 어떻게 생겼는지 궁금하다면 다음과 같습니다.
module Result =
/// Convert an Option into a Result
let ofOption errorValue opt =
match opt with
| Some v -> Ok v
| None -> Error errorValue
이전과 마찬가지로 toDomain 함수가 있으면 이전에 만든 convertSingleDbRecord 도우미 함수와 함께 사용할 수 있습니다.
let readOneContact (productionConnection:SqlConnection) (ContactId contactId) =
use cmd = new ReadOneContact(productionConnection)
let tableName = "ContactInfo"
let records = cmd.Execute(contactId = contactId) |> Seq.toList
convertSingleDbRecord tableName contactId records toDomain
toDomain 함수를 만드는 것이 어려운 부분임을 알 수 있습니다. 완료되면 실제 데이터베이스 액세스 코드는 비교적 간단합니다.
다음과 같이 생각할 수 있습니다. 이 모든 것이 많은 작업이 아닌가요? 이 모든 매핑을 자동으로 수행하는 Entity Framework 또는 NHibernate와 같은 것을 사용하면 안되나요?
대답은 아니오입니다. 도메인의 무결성을 보장하려는 경우가 아니라면 사용하십시오.
언급된 것과 같은 ORM은 이메일 주소 및 주문 수량의 유효성을 검사하고 중첩된 선택 타입 등을 처리할 수 없습니다.
데이터베이스 코드를 작성하는 것은 지루하지만 프로세스는 기계적이고 간단하며 애플리케이션 작성에서 가장 어려운 부분이 아닙니다!
Writing to a Relational Database
관계형 데이터베이스에 쓰기는 읽기와 동일한 패턴을 따릅니다.
도메인 개체를 DTO로 변환한 다음 삽입 또는 업데이트 명령을 실행합니다.
데이터베이스 삽입을 수행하는 가장 간단한 방법은 SQL 타입 공급자가 테이블의 구조를 나타내는 변경 가능(mitable)한 타입을 생성하도록 한 다음 해당 유형의 필드를 설정하는 것입니다.
여기 데모가 있습니다. 먼저 타입 공급자를 사용하여 모든 테이블에 대한 타입을 설정합니다.
type Db = SqlProgrammabilityProvider<CompileTimeConnectionString>
이제 연락처를 가져와 데이터베이스에서 해당 연락처의 모든 필드를 설정하는 writeContact 함수를 정의할 수 있습니다.
let writeContact (productionConnection:SqlConnection) (contact:Contact) =
// extract the primitive data from the domain object
let contactId = contact.ContactId |> ContactId.value
let isEmail,isPhone,emailAddressOpt,phoneNumberOpt =
match contact.Info with
| Email emailAddress->
let emailAddressString = emailAddress |> EmailAddress.value
true,false,Some emailAddressString,None
| Phone phoneNumber ->
let phoneNumberString = phoneNumber |> PhoneNumber.value
false,true,None,Some phoneNumberString
// create a new row
let contactInfoTable = new Db.dbo.Tables.ContactInfo()
let newRow = contactInfoTable.NewRow()
newRow.ContactId <- contactId
newRow.IsEmail <- isEmail
newRow.IsPhone <- isPhone
// use optional types to map to NULL in the database
newRow.EmailAddress <- emailAddressOpt
newRow.PhoneNumber <- phoneNumberOpt
// add to table
contactInfoTable.Rows.Add newRow
// push changes to the database
let recordsAffected = contactInfoTable.Update(productionConnection)
recordsAffected
더 많은 제어가 가능한 다른 접근 방식은 손으로 쓴 SQL 문을 사용하는 것입니다. 예를 들어 새 연락처를 삽입하려면 먼저 SQL INSERT 문을 나타내는 유형을 정의합니다.
type InsertContact = SqlCommandProvider<"""
INSERT INTO ContactInfo
VALUES (@ContactId,@IsEmail,@IsPhone,@EmailAddress,@PhoneNumber)
""", CompileTimeConnectionString>
이제 연락처를 가져와 초이스 타입에서 primitive을 추출한 다음 명령을 실행하는 writeContact 함수를 정의할 수 있습니다.
let writeContact (productionConnection:SqlConnection) (contact:Contact) =
// extract the primitive data from the domain object
let contactId = contact.ContactId |> ContactId.value
let isEmail,isPhone,emailAddress,phoneNumber =
match contact.Info with
| Email emailAddress->
let emailAddressString = emailAddress |> EmailAddress.value
true,false,emailAddressString,null
| Phone phoneNumber ->
let phoneNumberString = phoneNumber |> PhoneNumber.value
false,true,null,phoneNumberString
// write to the DB
use cmd = new InsertContact(productionConnection)
cmd.Execute(contactId,isEmail,isPhone,emailAddress,phoneNumber)
Transactions
지금까지의 모든 코드는 "하나의 집계 = 하나의 트랜잭션" 형식이었습니다. 그러나 많은 상황에서 원자적으로 함께 저장해야 하는 여러 가지가 있습니다.
일부 데이터 저장소는 API의 일부로 트랜잭션을 지원합니다. 서비스에 대한 여러 호출은 다음과 같이 동일한 트랜잭션에 참여할 수 있습니다.
let connection = new SqlConnection()
let transaction = connection.BeginTransaction()
// do two separate calls to the database
// in the same transaction
markAsFullyPaid connection invoiceId
markPaymentCompleted connection paymentId
// completed
transaction.Commit()
일부 데이터 저장소는 모든 것이 단일 연결에서 수행되는 한 트랜잭션만 지원합니다.
실제로 이는 다음과 같이 단일 호출에서 여러 작업을 결합해야 함을 의미합니다.
let connection = new SqlConnection()
// do one call to service
markAsFullyPaidAndPaymentCompleted connection paymentId invoiceId
그러나 때로는 다른 서비스와 통신하고 있으며 cross-service transaction을 할 방법이 없습니다.
Gregor Hohpe의 기사 "Starbucks는 Two-Phase Commit을 사용하지 않습니다"에서 언급한 오버헤드와 조정 비용이 너무 무겁고 느리기 때문에 기업은 일반적으로 서로 다른 시스템에 걸친 트랜잭션이 필요하지 않다고 지적합니다. 대신, 대부분의 경우 일이 잘 된다고 가정하고 조정 프로세스를 사용하여 불일치를 감지하고 트랜잭션을 보정하여 오류를 수정합니다.
예를 들어, 다음은 데이터베이스 업데이트를 롤백하기 위한 보상 트랜잭션의 간단한 데모입니다.
// do first call
markAsFullyPaid connection invoiceId
// do second call
let result = markPaymentCompleted connection paymentId
// if second call fails, do compensating transaction
match result with
| Error err ->
// compensate for error
unmarkAsFullyPaid connection invoiceId
| Ok _ -> ...
반응형
'BackEnd' 카테고리의 다른 글
[Java, Spring] 쓰레드 로컬과 전략 패턴, 탬플릿 메서드 패턴 (0) | 2023.06.04 |
---|---|
설계를 깔끔하게 발전시키기 (0) | 2022.03.22 |
Serialization (0) | 2022.03.21 |
DDD 구현 : 모나드와 Async (0) | 2022.03.21 |
DDD 구현 : 파이프라인에 모나드 적용하기, computation expression (0) | 2022.03.20 |