Skip to main content 집밥서선생

모놀리식 애플리케이션에 DDD 적용하기

Published: 2023-07-20

이전 포스트까지는 주로 DDD의 이론적인 부분에 대해 다루었다면, 이번 포스트부터는 실제 애플리케이션에 DDD를 적용하는 방법에 대해 다룰 것이다.



모놀리식 애플리케이션이란?


모놀리식 애플리케이션(Monolithic Application)은 시스템의 모든 컴포넌츠가 하나의 단위로 묶여있는 애플리케이션을 말한다. 가령 UI, 도메인, 인프라스트럭처 서비스가 동일한 배포 단위에 합쳐져 있다면, 그 애플리케이션은 모놀리식 애플리케이션이라 할 수 있다.

모놀리식 애플리케이션은 몇 가지 이유에서 인기가 높다.

  • 모든 코드와 우려 사항이 한 곳에 존재하므로 개발이 쉽다. 분산 시스템에서 RPC를 사용할 때 고려해야 하는 사항이 없다.
  • 배포할 항목이 하나이기 때문에 배포가 간단하다.
  • 서비스 간 통신이 인메모리에서 이루어지기 때문에 성능이 좋다.

하지만, 모놀리식 애플리케이션의 복잡도가 증가하면서, 다음과 같은 단점들이 드러나기 시작했다.

  • 애플리케이션의 스타트업 시간이 길어진다.
  • 애플리케이션의 확장(스케일링)이 어렵다. 위의 스타트업 시간이 길어진다는 점과 더해져, 애플리케이션의 인스턴스를 늘리는 것이 어렵다. 따라서 수직적 확장은 가능하지만, 수평적 확장에는 실질적 어려움이 있다.
  • 지속적 배포(Continious Deployment, CD)가 느려진다. 애플리케이션의 일부분만 수정하더라도 전체 애플리케이션을 모두 배포해야 하며, 이는 애플리케이션이 복잡해질수록 더욱 느려진다.
  • 특정 기술 스택에 귀속된다. 더 적합한 기술 스택이 나오거나 다른 기술 스택의 전문가이더라도 사용하던 기술 스택을 사용해야 한다. 만약 새로운 언어로 애플리케이션을 작성하고 싶다면, 애플리케이션 전체를 다시 작성해야 한다.
  • 변경 사항을 적용하기가 어렵고 모듈성이 떨어진다. 이는 DDD를 적용함으로써 해결할 수 있다.

본 포스트에서는 DDD를 통해 간단한 모놀리식 애플리케이션을 작성해볼 것이다.



애플리케이션의 요구사항

가상의 회사인 CoffeeCo는 국제 커피 체인점이다. CoffeeCo에 대한 비즈니스 도메인은 다음과 같다.

이 회사는 작년에만 50개의 매장을 새로 내는 등 급속한 성장을 이루었다. 각 매장은 커피 및 커피 관련 액세서리와 매장별 음료를 판매한다. 매장은 개별적인 가격을 가지고 있지만, 국가적인 마케팅 캠페인이 종종 운영되기도 하며, 이는 품목의 가격에 영향을 미친다.

CoffeeCo는 음료를 10회 구매할 때마다 1회의 무료 음료를 제공하는 CoffeeBux라는 로열티 프로그램을 시작하였다. 모든 매장에서 음료를 구매하거나 교환할 수 있다.

CoffeeCo는 온라인 매장을 출시하는 것을 고려하고 있으며, 구매자가 매월 무제한 커피를 받을 수 있는 월간 구독과 다른 음료에 대한 할인을 고려하고 있다.


시스템을 개발할 때 다음과 같은 유비쿼터스 언어와 정의를 따라야 한다.

  • Coffee Lovers: CoffeeCo의 고객
  • CoffeeBux: CoffeeCo의 멤버십 프로그램으로, Coffee Lovers가 음료 또는 악세서리를 구매할 때마다 CoffeeBux 포인트가 1점씩 쌓임.
  • Tiny, medium, and massive: 음료의 크기를 오름차순으로 나타냄. 일부 음료는 사이즈가 하나로 고정되어 있음.

도메인 모델링 과정에서, 다음과 같은 도메인 객체를 식별할 수 있다.

  • Store
  • Products
  • Membership(원문에서는 Loyalty)
  • Subscription

또한, 시스템의 MVP(Minimum Viable Product)는 다음과 같은 기능을 갖추어야 한다.

  • CoffeeBux를 이용해서 음료 또는 악세서리를 구매할 수 있어야 한다.
  • 신용카드 및 체크카드를 이용해서 음료 또는 악세서리를 구매할 수 있어야 한다.
  • 현금을 이용해서 음료 또는 악세서리를 구매할 수 있어야 한다.
  • 구매시마다 CoffeeBux 포인트가 쌓인다.
  • 매장별 할인 캠페인
  • 현재는 음료는 한 사이즈만 존재

비즈니스 도메인에 대한 사항을 모두 파악했으므로, 이제 DDD를 통해 애플리케이션을 작성해보자.



프로젝트 시작


먼저, 다음과 같은 패키지 구조를 갖는 프로젝트를 생성한다.

$ tree
.
├── go.mod
└── internal

Golang에서 internal 폴더는 특수한 의미를 가지며, 다른 프로젝트에서 임포트할 수 없다. 따라서 공개 API에 노출되지 않아야 하는 도메인 코드를 작성하기에 적합하며, 모든 도메인 코드는 internal 폴더에 작성할 것이다.

도메인 모델 작성

가장 먼저 작성해야 할 것은 Coffee Lover 모델이다. Coffee Lover는 가장 확실한 엔티티이며, 다른 도메인 객체와의 상호작용에서 중심적인 역할을 수행하기 때문이다.

internal 폴더에 coffeelover.go파일을 생성하여, Coffee Lover 모델을 작성한다.

package coffeeco

import "github.com/google/uuid"

type CoffeeLover struct {
	ID           uuid.UUID
	FirstName    string
	LastName     string
	EmailAddress string
}

CoffeeLover 구조체에 FirstName이나 EmailAddress와 같은 필드가 일부 추가되었는데, 이는 Coffee Lover에 대해 저장할 필요가 있는 추가적인 정보이다. 가령 도메인 전문가나 관계자와의 주기적인 회의를 통해 이러한 정보를 파악할 수 있다.


이번에는 매장 도메인을 작성해볼 것이다. internal 폴더 안에 store 폴더를 만들고, 그 아래 store.go 파일을 생성한다. store/store.go 파일에는 다음과 같은 코드를 작성한다.

package store

import "github.com/google/uuid"

type Store struct {
	ID       uuid.UUID
	Location string
}

이와 같이 매장은 ID를 가지는 엔티티가 된다.


각 매장에서는 커피, 커피에 관련된 악세사리, 매장별 음료를 팔고, 따라서 우리는 상품을 정의해야 한다.

여기까지는 비교적 간단한 모델링이었지만, 상품을 정의하는 것은 꽤 까다롭다. 상품은 엔티티일까, 아니면 밸류 오브젝트일까? 결론만 말하자면 상품은 밸류 오브젝트로 두는 것이 좋다. 왜냐하면,

  • 각 상품은 불변성을 가진다고 볼 수 있으며
  • 상품은 도매인 개념을 측정, 설명, 수량화하며,
  • 값만으로 동일한 타입의 다른 객체와 구분할 수 있으며,
  • 엔티티인지 밸류 오브젝트인지 애매한 것들은 일단 밸류 오브젝트로 처리하고 나중에 엔티티로 변경하는 것이 더 안전한 선택이기 때문이다.

internal 폴더 안에 product.go 파일을 생성하고, 다음과 같이 작성한다.

package coffeeco

type Money = int

type Product struct {
	ItemName  string
	BasePrice Money
}

이제 매장 도메인으로 다시 돌아가서, 매장에서 판매하는 상품을 정의해야 한다. store/store.go 파일을 다음과 같이 수정한다.

package store

import (
	"github.com/google/uuid"
	coffeeco "github.com/jhseoeo/Golang-DDD/chapter5/internal"
)

type Store struct {
	ID             uuid.UUID
	Location       string
	ProductForSale coffeeco.Product
}

사용자가 상품을 구매할 때 카드를 사용하는지 혹은 현금을 사용하는지, 카드를 사용했다면 어떤 카드인지에 대한 정보를 표현할 수 있어야 한다.

payment 도메인을 정의할 것이다. internal 폴더 안에 payment 폴더를 만들고, 그 아래 means.go 파일을 생성한다. payment/means.go 파일에는 다음과 같은 코드를 작성한다.

package payment

type Means string

const (
	MEANS_CARD      = "card"
	MEANS_CASH      = "cash"
	MEANS_COFFEEBUX = "coffeebux"
)

type CardDetails struct {
	cardToken string
}

지불 수단을 나타내기 위한 타입인 Means는 string에 대한 alias이다. 또한 카드에 대한 정보를 나타내는 CardDetails 타입을 정의하였다. 실제 카드 결제가 동작하는 방식과는 다소 거리가 있으나, 본 예제에서는 cardToken을 사용하여 결제를 처리한다고 가정한다.

또한 현금 및 CoffeeBux 결제에 관련된 상수를 정의하였는데, 이는 결제 수단을 나타내는 Means 타입의 값으로 사용된다.


다음으로는 상품의 구매에 관련된 도메인을 작성해야 한다. coffee lover가 상품을 구매할 때 필요한 정보에 대해 잘 이해하고 있는지, 그리고 알아야 할 추가적인 도메인 정보는 없는지에 대해 도메인 전문가와 대화해야 할 타이밍이 아마 이쯤이 될 것이다.

internal 폴더 안에 purchase 폴더를 만들고, 그 아래 purchase.go 파일을 생성한다. purchase/purchase.go 파일에는 다음과 같은 코드를 작성한다.

package purchase

import (
	"github.com/google/uuid"
	coffeeco "github.com/jhseoeo/Golang-DDD/chapter5/internal"
	"github.com/jhseoeo/Golang-DDD/chapter5/internal/payment"
	"github.com/jhseoeo/Golang-DDD/chapter5/internal/store"
	"time"
)

type Purchase struct {
	id                 uuid.UUID
	Store              store.Store
	ProductsToPurchase []coffeeco.Product
	total              coffeeco.Money
	PaymentMeans       payment.Means
	timeOfPurchase     time.Time
	cardToken          *string
}

Purchase는 ID를 가지는 엔티티여야 한다. 만약 사용자가 구매를 취소하고 싶을 때, ID를 통해 구매를 취소할 수 있어야 하기 때문이다.


이제 멤버십에 관한 도메인을 정의할 차례이다. internal 폴더 안에 membership 폴더를 만들고, 그 아래 coffeebux.go 파일을 생성한다. membership/coffeebux.go 파일에는 다음과 같은 코드를 작성한다.

package membership

import (
	"github.com/google/uuid"
	coffeeco "github.com/jhseoeo/Golang-DDD/chapter5/internal"
	"github.com/jhseoeo/Golang-DDD/chapter5/internal/store"
)

type CoffeeBux struct {
	ID                                    uuid.UUID
	store                                 store.Store
	coffeeLover                           coffeeco.CoffeeLover
	FreeDrinksAvailable                   int
	RemainingDrinkPurchasesUntilFreeDrink int
}

여기까지, 모든 도메인 모델이 정의되었다. 지금까지의 작업이 반영된 패키지 구조는 다음과 같다.

.
├── go.mod
├── go.sum
└── internal
    ├── coffeelover.go
    ├── membership
    │   └── coffeebux.go
    ├── payment
    │   └── means.go
    ├── product.go
    ├── purchase
    │   └── purchase.go
    └── store
        └── store.go


도메인 서비스 작성

이제 도메인 서비스를 작성할 차례이다. Purchase가 서비스 로직이 작성되기에 가장 적절하다고 볼 수 있는데, 이유는 다음과 같다.

  • 도매인 내의 중요한 비즈니스 로직이 수행될 것이며,
  • 일부 값을 계산해야 하며,
  • 레포지토리 레이어에 접근해야 하기 때문이다.

프로그램을 방어적으로 작성하려면 서비스를 얇게 유지하는 것이 좋고, 따라서 로직 코드를 최대한 도메인 객체에까지 내리는게 좋다. purchase/purchase.go를 열어, 다음과 같이 각 상품의 가격을 합하여 총 가격을 계산하고, 구매건에 대한 ID를 생성하는 메소드를 추가한다.

func (p *Purchase) validateAndEnrich() error {
	if len(p.ProductsToPurchase) == 0 {
		return errors.New("puchase must have at least one product")
	}

    p.total = 0
    for _, v := range p.ProductsToPurchase {
    	p.total += v.BasePrice
    }

    if p.total == 0 {
    	return errors.New("total price must be greater than 0")
    }

    p.id = uuid.New()
    p.timeOfPurchase = time.Now()
    return nil

}
Show⯆

이어서, purchase/purchase.go에 서비스를 계속 작성한다.

type CardChargeService interface {
	ChargeCard(ctx context.Context, amount coffeeco.Money, cardToken string) error
}

type Service struct {
	cardService  CardChargeService
	purchaseRepo Repository
}

func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase) error {
	err := purchase.validateAndEnrich()
	if err != nil {
		return err
	}

	switch purchase.PaymentMeans {
	case payment.MEANS_CARD:
		err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken)
		if err != nil {
			return errors.New("card charge is failed")
		}

	case payment.MEANS_CASH:
		// do nothing

	default:
		return errors.New("unknown payment means")
	}

	err = s.purchaseRepo.Store(ctx, *purchase)
	if err != nil {
		return errors.New("failed to store purchase")
	}

	return nil
}
Show⯆

이 서비스는 Purchase 객체에 필요한 값을 추가하기 위해 validateAndEnrich 메소드를 호출한다. 이후, 결제 수단에 따라 결제를 처리하고, 결제가 성공적으로 이루어지면 Purchase 객체를 저장한다.


purchase.validateAndEnrich()를 호출하고 나서 결제 수단에 따라 처리해야 할 몇 가지 로직이 있다. 카드 결제의 경우 CardService를 통해 카드 결제를 처리하므로, CardService 인터페이스를 정의할 것이다. 이렇게 인터페이스로 정의하면 개발자 혹은 개발팀이 나뉘어져 있을 때, 정해진 인터페이스를 통해 서로간의 의존성을 줄이면서도 개발 속도를 높이며 원활한 협업이 가능해진다.

다음으로, 레포지토리 인터페이스를 정의할 것이다. purchase 디렉토리에 repository.go 파일을 생성하고, 다음과 같이 작성한다.

package purchase

import "context"

type Repository interface {
	Store(ctx context.Context, purchase Purchase) error
}

이렇게 인터페이스를 정의하여 사용하는 것은 좋은 방법이다. 레포지토리의 구현체가 어떤 데이터베이스에 의존하든, 인터페이스만 충족시키면 되기 때문이다.

지금까지의 작업이 반영된 패키지 구조는 다음과 같다. 프로젝트의 대략적인 윤곽이 잡히고 있다!

.
├── go.mod
├── go.sum
└── internal
    ├── coffeelover.go
    ├── membership
    │   └── coffeebux.go
    ├── payment
    │   └── means.go
    ├── product.go
    ├── purchase
    │   ├── purchase.go
    │   └── repository.go
    └── store
        └── store.go


레포지토리 작성

MongoDB를 사용하여 레포지토리 계층을 구현할 것이다. 먼저, MongoDB Golang Driver를 설치한다.

go get go.mongodb.org/mongo-driver/mongo

그리고 Purchase 모델을 저장하기 위한 레포지토리를 작성할 것이므로, purchase/repository.go 파일에 다음과 같이 이어서 작성한다.

type MongoRepository struct {
	purchases *mongo.Collection
}

func NewMongoRepo(ctx context.Context, connectionString string) (*MongoRepository, error) {
	client, err := mongo.Connect(ctx, options.Client().ApplyURI(connectionString))
	if err != nil {
		return nil, fmt.Errorf("failed to create a mongo client: %w", err)
	}

	purchases := client.Database("coffeeco").Collection("purchases")

	return &MongoRepository{
		purchases: purchases,
	}, nil
}
Show⯆

다음으로 이전에 선언한 Repository 인터페이스를 충족시키기 위해 Store 메소드를 작성한다.

func (mr *MongoRepository) Store(ctx context.Context, purchase Purchase) error {
	mongoP := toMongoPurchase(purchase)
	_, err := mr.purchases.InsertOne(ctx, mongoP)
	if err != nil {
		return fmt.Errorf("failed to persist purchase: %w", err)
	}

	return nil
}

여기에서 toMongoPurchase 함수는 Purchase 객체를 MongoPurchase 객체로 변환하는 어댑터 함수이며, MongoPurchasePurchase 객체를 저장하기 위해 MongoDB에 저장되는 도큐먼트의 구조체 타입이다.

계속해서 purchase/repository.gomongoPurchasetoMongoPurchase를 구현한다.

type mongoPurchase struct {
	id                 uuid.UUID          `bson:"ID"`
	store              store.Store        `bson:"Store"`
	productsToPurchase []coffeeco.Product `bson:"product_purchased"`
	total              int64              `bson:"purchase_total"`
	paymentMeans       payment.Means      `bson:"payment_means"`
	timeOfPurchase     time.Time          `bson:"created_at"`
	cardToken          *string            `bson:"card_token"`
}

func toMongoPurchase(p Purchase) mongoPurchase {
	return mongoPurchase{
		id:                 p.id,
		store:              p.Store,
		productsToPurchase: p.ProductsToPurchase,
		total:              int64(p.total),
		paymentMeans:       p.PaymentMeans,
		timeOfPurchase:     p.timeOfPurchase,
		cardToken:          p.cardToken,
	}
}
Show⯆

이와 같이 MongoDB에 대한 의존성과 Purchase 애그리거트를 디커플링할 수 있다. 다른 도메인 모델도 마찬가지로 데이터베이스 모델과 디커플링해야 한다.

지금까지의 작업이 반영된 패키지 구조는 다음과 같다.

.
├── go.mod
├── go.sum
└── internal
    ├── coffeelover.go
    ├── membership
    │   └── coffeebux.go
    ├── payment
    │   └── means.go
    ├── product.go
    ├── purchase
    │   ├── purchase.go
    │   └── repository.go
    └── store
        └── store.go


인프라스트럭처 서비스 작성

결제 서비스를 위해 Stripe라는 것을 써볼 것이다. Mongo 레포지토리처럼 Stripe에 대한 의존성을 디커플링하기 위해 인터페이스를 정의할 것이다.

먼저, 다음 명령어로 Stripe Golang SDK를 설치한다.

go get github.com/stripe/stripe-go/v73

이거 뭔데 73버전까지 있는거지..?

아무튼 payment 폴더에 stripe.go 파일을 생성하고, 다음과 같이 작성한다.

package payment

import (
	"errors"
	"github.com/stripe/stripe-go/v73/client"
)

type StripeService struct {
	stripeClient *client.API
}

func NewStripeService(apiKey string) (*StripeService, error) {
	if apiKey == "" {
		return nil, errors.New("API key cannot be nil")
	}

	sc := &client.API{}
	sc.Init(apiKey, nil)
	return &StripeService{stripeClient: sc}, nil
}

그리고 CardChargeService 인터페이스를 충족시키기 위해 ChargeCard 메소드를 작성한다.

func (s StripeService) ChargeCard(ctx context.Context, amount coffeeco.Money, cardToken string) error {
	params := &stripe.ChargeParams{
		Amount:   stripe.Int64(int64(amount)),
		Currency: stripe.String(string(stripe.CurrencyKRW)),
		Source:   &stripe.PaymentSourceSourceParams{Token: stripe.String(cardToken)},
	}

	_, err := charge.New(params)
	if err != nil {
		return fmt.Errorf("failed to create a charge: %w", err)
	}

	return nil
}
Show⯆

이와 같이 외부 리소스인 Stripe를 사용하는 코드를 인프라스트럭처 레이어에 작성하였다.



기능 추가 구현하기


DDD의 장점 중 하나는 모듈성 덕분에 새로운 기능을 추가하기가 쉽다는 것이다. 아직 비즈니스 요구사항을 모두 충족시킨 것은 아니기 떄문에, 남은 요구사항을 충족시키기 위해 기능을 추가해보자.

멤버십 프로그램 구현

요구사항 중 10회 구매시 1회 무료 음료를 제공하는 멤버십 프로그램이 있다. 이를 구현하기 위해 membership/coffeebux.go 파일에 다음과 같은 메소드를 추가한다.

func (c *CoffeeBux) AddStamp() {
	if c.RemainingDrinkPurchasesUntilFreeDrink == 1 {
		c.RemainingDrinkPurchasesUntilFreeDrink = 10
		c.FreeDrinksAvailable += 1
	} else {
		c.RemainingDrinkPurchasesUntilFreeDrink--
	}
}

AddStamp 무료 음료를 제공하는 로직을 구현한 메소드이다. 이제 purchase/purchase.go 파일의 CompletePurchase 메소드에서 coffeebux 스탬프를 쌓는 로직을 추가한다.

func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase, coffeeBuxCard *membership.CoffeeBux) error {
	err := purchase.validateAndEnrich()
	if err != nil {
		return err
	}

	switch purchase.PaymentMeans {
	case payment.MEANS_CARD:
		err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken)
		if err != nil {
			return errors.New("card charge is failed")
		}

	case payment.MEANS_CASH:
		// do nothing

	default:
		return errors.New("unknown payment means")
	}

	err = s.purchaseRepo.Store(ctx, *purchase)
	if err != nil {
		return errors.New("failed to store purchase")
	}

	if coffeeBuxCard != nil {
		coffeeBuxCard.AddStamp()
	}

	return nil
}
Show⯆

CompletePurchase의 파라미터로 coffeeBuxCard를 추가하였는데, 고객이 멤버십을 가지고 있지 않을 수 있기 때문에 nil 여부를 검사해야 한다. 검사 이후에는 단지 AddStamp 메소드를 호출함으로써, 아주 쉽게 멤버십 프로그램을 구현할 수 있다.


이제 결제 수단으로 CoffeeBux를 사용할 수 있도록 구현해야 하는데, 이는 결제 도메인과 멤버십 도메인에 모두 속하기 때문에 어디에 구현해야 할지 고민이 될 수 있다. 이를 구현하는 데는 여러 가지 방법이 있을 수 있으며, 정답은 없다. 이번 예제에서는 결제 도메인에 구현할 것이다. purchase/purchase.go 파일을 열어 다음과 같이 Pay 메소드를 추가한다.

func (c *CoffeeBux) Pay(ctx context.Context, purchases []purchase.Purchase) error {
	lp := len(purchases)
	if lp == 0 {
		return errors.New("nothing to buy")
	}

	if c.FreeDrinksAvailable < lp {
		return fmt.Errorf("not enough free drinks available, %d requestsed, %d available", lp, c.FreeDrinksAvailable)
	}

	c.FreeDrinksAvailable -= lp
	return nil
}
Show⯆

이와 같이 사용할 수 있는 무료 음료의 수를 확인하고, 충분한 음료가 있다면 무료 음료의 수를 차감한다.


남은 것은 구매 서비스의 CompletePurchase 메소드에서 결제 수단으로 CoffeeBux를 사용할 수 있도록 구현하는 것이다. purchase/purchase.go 파일을 열어 다음과 같이 CompletePurchase 메소드를 수정한다.

func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase, coffeeBuxCard *membership.CoffeeBux) error {
	err := purchase.validateAndEnrich()
	if err != nil {
		return err
	}

	switch purchase.PaymentMeans {
	case payment.MEANS_CARD:
		err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken)
		if err != nil {
			return errors.New("card charge is failed")
		}

	case payment.MEANS_CASH:
	// do nothing

	case payment.MEANS_COFFEEBUX:
		err := coffeeBuxCard.Pay(ctx, purchase.ProductsToPurchase)
		if err != nil {
			return fmt.Errorf("failed to charge membership card: %w", err)
		}

	default:
		return errors.New("unknown payment means")
	}

	err = s.purchaseRepo.Store(ctx, *purchase)
	if err != nil {
		return errors.New("failed to store purchase")
	}

	if coffeeBuxCard != nil {
		coffeeBuxCard.AddStamp()
	}

	return nil
}
Show⯆

이와 같이 결제 수단으로 CoffeeBux를 사용할 수 있도록 구현하였다. 이 때 CoffeeBux를 사용하여도 AddStamp 메소드를 호출하여 멤버십 포인트가 쌓이도록 구현하였는데, 이러한 비즈니스 불변성은 도메인 전문가와의 대화를 통해 확인할 수 있다.



매장별 할인 캠페인 구현

매장별 할인 정보를 저장하기 위해서는 레포지토리 계층이 필요하다. store 폴더에 repository.go 파일을 생성하고, 다음과 같이 작성한다.

package store

import (
	"context"
	"errors"
	"fmt"
	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
)

var ErrNoDiscount = errors.New("no discount for store")

type Repository interface {
	GetStoreDiscount(ctx context.Context, storeId uuid.UUID) (int, error)
}

type MongoRepository struct {
	storeDiscounts *mongo.Collection
}

func NewMongoRepo(ctx context.Context, connectionString string) (*MongoRepository, error) {
	client, err := mongo.Connect(ctx, options.Client().ApplyURI(connectionString))
	if err != nil {
		return nil, fmt.Errorf("failed to create a mongo client: %w", err)
	}

	discounts := client.Database("coffeeco").Collection("store_discounts")
	return &MongoRepository{
		storeDiscounts: discounts,
	}, nil
}

func (m MongoRepository) GetStoreDiscount(ctx context.Context, storeId uuid.UUID) (float32, error) {
	var discountRate float32
	err := m.storeDiscounts.FindOne(ctx, bson.D{{"store_id", storeId.String()}}).Decode(&discountRate)
	if err != nil {
		if errors.Is(err, mongo.ErrNoDocuments) {
			return 0, ErrNoDiscount
		} else {
			return 0, fmt.Errorf("failed to get store discount: %w", err)
		}
	}

	return discountRate, nil
}
Show⯆

이 코드는 이전의 레포지토리 레이어와 비슷하다. 다만 현재 각각의 레포지토리 레이어에서 Mongo 연결 풀을 중복으로 생성하고 있는데, 이를 다른 패키지로 분리하여 공유하는 것이 향후 개선사항이 될 수 있다.

GetStoreDiscount 메소드를 사용할 때 에러 체크를 하는 것을 알 수있는데, 만약 ErrNoDocuments 에러가 발생하면 할인이 적용되지 않은 것이므로 ErrNoDiscount 에러를 반환하며, 이는 실제 에러라기보다는 할인이 적용되지 않았음을 명시적으로 알리는 것이다.


이렇게 구현된 매장별 할인을 구매 서비스에 추가할 것이다. purchase/purchase.go 파일을 열어 StoreService 인터페이스를 정의하고, 이를 구매 서비스 구조체에 추가한다.

type StoreService interface {
	GetStoreSpecificDiscount(ctx context.Context, storeId uuid.UUID) (float32, error)
}

type Service struct {
	cardService  CardChargeService
	purchaseRepo Repository
	storeService StoreService
}

이후 CompletePurchase 메소드에서 매장별 할인을 적용하는 로직을 추가한다.

func (s Service) CompletePurchase(ctx context.Context, storeId uuid.UUID, purchase *Purchase, coffeeBuxCard *membership.CoffeeBux) error {
	err := purchase.validateAndEnrich()
	if err != nil {
		return err
	}

	discount, err := s.storeService.GetStoreSpecificDiscount(ctx, storeId)
	if err != nil && !errors.Is(err, store.ErrNoDiscount) {
		return fmt.Errorf("failed to get discount: %w", err)
	}

	purchasePrice := purchase.total
	if discount > 0 {
		purchasePrice *= coffeeco.Money(100 - discount)
	}

	switch purchase.PaymentMeans {
	case payment.MEANS_CARD:
		err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken)
		if err != nil {
			return errors.New("card charge is failed")
		}

	case payment.MEANS_CASH:
	// do nothing

	case payment.MEANS_COFFEEBUX:
		err := coffeeBuxCard.Pay(ctx, purchase.ProductsToPurchase)
		if err != nil {
			return fmt.Errorf("failed to charge membership card: %w", err)
		}

	default:
		return errors.New("unknown payment means")
	}

	err = s.purchaseRepo.Store(ctx, *purchase)
	if err != nil {
		return errors.New("failed to store purchase")
	}

	if coffeeBuxCard != nil {
		coffeeBuxCard.AddStamp()
	}

	return nil
}
Show⯆

이렇게 이것저것 추가하고 나니 가독성도 떨어지고 도메인이 복잡해졌다. 리팩토링이 필요해 보인다.

func (s Service) CompletePurchase(ctx context.Context, storeId uuid.UUID, purchase *Purchase, coffeeBuxCard *membership.CoffeeBux) error {
	err := purchase.validateAndEnrich()
	if err != nil {
		return err
	}

	err = s.calculateStoreSpecificDiscount(ctx, storeId, purchase)
	if err != nil {
		return err
	}

	switch purchase.PaymentMeans {
	case payment.MEANS_CARD:
		err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken)
		if err != nil {
			return errors.New("card charge is failed")
		}

	case payment.MEANS_CASH:
	// do nothing

	case payment.MEANS_COFFEEBUX:
		err := coffeeBuxCard.Pay(ctx, purchase.ProductsToPurchase)
		if err != nil {
			return fmt.Errorf("failed to charge membership card: %w", err)
		}

	default:
		return errors.New("unknown payment type")
	}

	err = s.purchaseRepo.Store(ctx, *purchase)
	if err != nil {
		return errors.New("failed to store purchase")
	}

	if coffeeBuxCard != nil {
		coffeeBuxCard.AddStamp()
	}

	return nil
}

func (s *Service) calculateStoreSpecificDiscount(ctx context.Context, storeId uuid.UUID, purchase *Purchase) error {
	discount, err := s.storeService.GetStoreSpecificDiscount(ctx, storeId)
	if err != nil && !errors.Is(err, store.ErrNoDiscount) {
		return fmt.Errorf("failed to get discount: %w", err)
	}

	purchasePrice := purchase.total
	if discount > 0 {
		purchase.total = purchasePrice * coffeeco.Money(100-discount)
	}

	return nil
}
Show⯆

이와 같이 calculateStoreSpecificDiscount함수로 따로 분리하였고, 훨씬 더 보기 깔끔해진 만큼 도메인 전문가와 이야기하기 더 쉬울 것이다.


마지막으로 store/store.go를 열어 StoreService를 충족시키는 Service 구조체를 작성한다.

type Service struct {
	repo Repository
}

func (s Service) GetStoreSpecificDiscount(ctx context.Context, storeId uuid.UUID) (float32, error) {
	dis, err := s.repo.GetStoreDiscount(ctx, storeId)
	if err != nil {
		return 0, err
	}
	return float32(dis), nil
}

이로써 Domain Driven Design 기반의 전체 서비스가 완성되었다. 지금까지의 작업이 반영된 패키지 구조는 다음과 같다.

.
├── go.mod
├── go.sum
└── internal
    ├── coffeelover.go
    ├── membership
    │   └── coffeebux.go
    ├── payment
    │   ├── means.go
    │   └── stripe.go
    ├── product.go
    ├── purchase
    │   ├── purchase.go
    │   └── repository.go
    └── store
        ├── repository.go
        └── store.go


마치며


이렇게 모놀리식 아키텍처에 DDD를 적용해보았다. 현재로서는 서비스만 구현되어 있지만 REST API 등 인터페이스가 정의된다면 어떻게 구현해야 할지 고민해보는 것도 좋을 것 같다. 또한 유닛 테스트 또는 통합 테스트를 작성해보는 것도 좋을 것 같다.

DDD가 적용되지 않은 기존 코드에서, 이 포스트에서와 같이 레포지토리 패턴을 사용하고 도메인 객체를 사용하도록 리팩토링하는 것은 꽤 노력이 요구되는 일일 수 있다. 하지만 인프라스트럭처 레이어를 적용하는 것은 꽤 권장되는 방법이다. 비즈니스 로직과 인프라스트럭처를 분리함으로써, 비즈니스 로직을 테스트하기가 훨씬 쉬워지기 때문이다.



References


[

Domain-Driven Design with Golang Cover ](https://learning.oreilly.com/library/view/domain-driven-design-with/9781804613450/)
[Matthew Boyle, Domain-Driven Design with Golang』, O'Reilly Media, Inc.](https://learning.oreilly.com/library/view/domain-driven-design-with/9781804613450/)

© 2024 JHSeo. All right reserved.