커머스 코드 자산화 개발 일지-3 오퍼를 쇼핑몰에

지난 이야기

지난 글에서는 상품을 유연하게 다루기 위한 시도로 오퍼Offering 개념을 구현하였다.

개발 주기 목표

오퍼는 존재만으로는 가치가 없다. 고객이 보고 구매함으로써 비로소 가치가 생긴다. 그래서 이번 개발 주기의 목표는 고객이 방문할 수 있는 쇼핑몰(이하 Mall)을 만들고 오퍼를 노출하는 것으로 정했다. Mall은 별도로 존재하는 웹 애플리케이션으로 고객이 상품을 보고 장바구니에 담고 구매할 수 있는 온라인 쇼핑몰이다.

image-20200229-050917

Mall 웹 애플리케이션

Ant Design으로 관리자 화면을 만든 이유는 앞서 밝힌 것과 같이 디자이너 없이도 개발자 혼자 만들 수 있기 때문이었다.[1] 쇼핑몰 화면 역시도 Ant Design 같은 것이 필요했다. 운 좋게도 개인 위키에서 예전에 받아 두었던 부트스트랩Bootstrap 기반의 무료 커머스 화면 템플릿을 발견할 수 있었다.[2]

그림. 부트스트랩 기반 무료 커머스 화면 템플릿

그림. 부트스트랩 기반 무료 커머스 화면 템플릿

화면 템플릿 이외에도 API를 호출하여 반환받은 데이터로 HTML을 동적으로 만들 수 있는 기능이 필요했다. Golang Echo 웹 프레임워크는 HTML을 동적으로 만들 수 있는 Templates을 지원한다. 설정과 사용이 간단하며 Golang에 기본적으로 내장되어 있는 html/template 엔진을 사용할 수 있기 때문에 별도의 라이브러리 의존성도 필요 없다. 그래서 Echo 프레임워크와 커머스 화면 템플릿으로 웹 애플리케이션을 만들기로 했다.

Mall 프로젝트(IDE) 구조는 아래와 같다.

1
2
3
4
5
6
7
8
9
.
├── main.go
└── public
    ├── static
    │   ├── css
    │   ├── img
    │   └── js
    └── views
        └── product_list.gohtml

static 디렉토리에는 커머스 화면 템플릿이 사용하는 정적 리소스 파일이 존재한다. views 디렉토리에는 HTML을 동적으로 만드는 서버 사이드 템플릿 파일(.gohtml)이 존재한다.

Echo 서버를 실행하여 URL에 접속하니 화면은 제대로 나오지 않았는데 원인은 static 디렉토리 밑에 존재하는 정적 리소스 파일을 찾을 수 없는 오류(404 Not Found) 때문이었다.

image-20200225-051740

위 문제는 Echo의 Static Middleware로 해결할 수 있었다. 아래처럼 정적 리소스 파일이 존재하는 디렉토리를 명시해 준다.

1
2
3
4
5
func main() {
	e := echo.New()
	e.Use(middleware.Static("public/static"))
	// ...
}

image-20200229-063938

상품 목록 화면을 만들기 위해 필요한 데이터와 구조를 스텁Stub으로 만들어서 동적으로 화면을 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package controller
func ProductOfferingListController(c echo.Context) error {
  var stub = map[string]interface{}{}
  jsonString := `{
  	"result": [
  	  {
  	  	"id": 1,
  	  	"name": "Fur coat with very but very very long name",
  	  	"costPrice": 2000,
  	  	"offeringPrice": 2000,
  	  	"imageUrl": "http://localhost:7001/img/product1_2.jpg"
  	  },
  	  {
  	  	"id": 2,
  	  	"name": "White Blouse Armani",
  	  	"costPrice": 3000,
  	  	"offeringPrice": 1000,
  	  	"imageUrl": "http://localhost:7001/img/product2_2.jpg"
  	  }
  	],
  	"totalCount": 2 
  }`
  json.Unmarshal([]byte(jsonString), &stub)
  return c.Render(http.StatusOK, "product_offering_list", stub)
}

상품 마이크로서비스

Mall에서 필요한 API를 만들어 보자. API 스펙Specification은 이미 만들어졌다. 바로 Mall에서 스텁으로 처리했던 부분이다. 남은 작업은 상품 마이크로서비스에서 구체적으로 어떻게 API를 제공할 것인지이다.

일반적으로 함께 접근하는 객체를 한 덩어리로 모아 놓으면 유용하다. 이러한 덩어리를 '도메인 주도 설계'에서는 애그리게잇Aggregate이라고 정의한다.

모델 내의 참조에 대한 캡슐화를 추상화할 필요가 있다. AGGREGATE는 우리가 데이터 변경의 단위로 다루는 연관 객체의 묶음을 말한다. 각 AGGREGATE에는 루트(root)와 경계(boundary)가 있다. 경계는 AGGREGATE에 무엇이 포함되고 포함되지 않는지를 정의한다. 루트는 단 하나만 존재하며, AGGREGATE에 포함된 특정 ENTITIY를 가르킨다. 경계 안의 객체는 서로 참조할 수 있지만, 경계 바깥의 객체는 해당 AGGREGATE의 구성요소 가운데 루트만 참조할 수 있다. - 도메인 주도 설계, 131쪽

현재 상품 도메인 모델은 Offering과 Product 애그리게잇이 있다. 애그리게잇을 어떻게 만드냐(경계를 만드는 일)는 해답이 없다. 데이터를 조작하는 방식에 따라 달라진다. 위와 같이 만든 이유는 고객이 Offering을 구매한다는 쓰임새 때문이다.

상품 마이크로서비스 도메인 모델

상품 마이크로서비스 도메인 모델

애그리게잇은 밀접한 관계를 가진 객체를 묶어서 하나의 구조로 저장하고 이를 접근하는 단위이다. 일관성 관리의 단위가 된다.[3] 일관성을 유지하기 위해서 원자적 연산으로 애그리게잇을 데이터 저장소에 저장하고 수정한다. 그래서 리파지토리를 만들 때 모든 엔터티가 아니라 애그리게잇 루트 엔티티에 대해서만 리파지토리를 제공하라고 한다.[4]

애그리게잇에서 외부 애그리게잇의 참조는 객체 참조와 전역 식별자 참조로 구현할 수 있다.[5]

외부 애그리게잇보다는 참조를 사용하되, 객체 참조(포인터)를 직접 사용하지 말고 전역 고유 식별자를 이용하자. - 도메인 주도 설계 구현, 465 쪽

Offering 애그리게잇은 전역 식별자로 Prouct 애그리게잇을 참조한다.

1
2
3
4
5
6
type Offering struct {
  Id int64 `xorm:"id pk autoincr"`
  ProductId int64 `xorm:"product_id notnull"` // Product 애그리게잇 식별자
  OfferingCustomerBenefits []OfferingCustomerBenefit `xorm:"customer_benefits notnull"`
  // ...
}

Mall에서 필요한 데이터는 Offering과 Product을 합친 것이다. 이것은 ProductOffering이라고 정의했다.

1
2
3
4
5
6
7
type ProductOffering struct {
  Id int64 `json:"id"`
  Name  string `json:"name"`
  CostPrice  float64 `json:"costPrice"`
  OfferingPrice  float64 `json:"offeringPrice"`
  ImageUrl  string `json:"imageUrl"`
}

어딘가에서는 ProductOffering 만들기 위해 Offering을 조회하고 Offering의 ProductId로 Product를 조회하여 합쳐야 한다. '도메인 주도 설계 구현'에서는 두 가지 방법을 언급하고 있다.

ID를 통한 참조를 사용한다고 모델을 전혀 탐색할 수 없는 건 아니다. 조회를 위해선 애그리게잇의 내부에서 리파지토리를 사용하는 방법이 있다. 이는 단절된 도메인 모델이란 기법인데, 실제로 지연 로딩의 한 형태다. 애그리게잇의 행동을 호출하기에 앞서 리파지토리나 도메인 서비스를 통해 의존 관계에 있는 객체를 조회하는 방법도 추천할 만하다. 클라이언트 애플리케이션 서비스는 이를 제어하며 애그리게잇으로 디스패치할 수 있게 된다. -도메인 주도 설계 구현, 467 쪽

두 가지 방법 중 후자를 사용했다. 왜냐하면 여러 건을 조회해야 하기 때문에 성능 상 유리하기 때문이다. 아래는 ProductOfferingService에서 ProductOffering을 만드는 코드이다. OfferingRepository와 ProductRepository로 애그리게잇을 조회하고 이를 ProductOffering으로 조합한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
var (
  productOfferingServiceOnce sync.Once
  productOfferingServiceInstance *productOfferingService
)
func ProductOfferingService() *productOfferingService {
  productOfferingServiceOnce.Do(func() {
    productOfferingServiceInstance = &productOfferingService{}
  })
  return productOfferingServiceInstance
}
type productOfferingService struct {
}
func (productOfferingService) GetProductOfferings(ctx context.Context, filter map[string]interface{}, pageable dto.Pageable) (dto.PageResult, error) {
  if offerings, totalCount, err := repository.OfferingRepository().FindAll(ctx, filter, pageable); err != nil {
    return dto.PageResult{}, err
  } else {
    productIds := extractProductIdsInOfferings(offerings)
    products, err := repository.ProductRepository().FindAllByIds(ctx, productIds)
    if err != nil {
      return dto.PageResult{}, err
    }
    productOfferings := translateToProductOfferings(offerings, products)
    return  dto.PageResult {
      Result: productOfferings,
      TotalCount: totalCount,
    }, nil
  }
}
func extractProductIdsInOfferings(offerings []entitiy.Offering) []int64 {
  var productIds = make([]int64, 0)
  for _, offering := range offerings {
    productIds = append(productIds, offering.ProductId)
  }
  return productIds
}
func translateToProductOfferings(offerings []entitiy.Offering, products []entitiy.Product) []dto.ProductOffering {
  var productOfferings = make([]dto.ProductOffering, 0)
  for _, offering := range offerings {
    var findProduct entitiy.Product
    for _, product := range products {
      if product.Id == offering.ProductId {
        findProduct = product
        break
      }
    }
    imageURL := offering.ImageUrl
    if imageURL == "" {
      imageURL = findProduct.ImageUrl
    }
    productOfferings = append(productOfferings, dto.ProductOffering{
      Id:            offering.Id,
      Name:          offering.Name,
      CostPrice:     findProduct.CostPrice,
      OfferingPrice: offering.OfferingPrice,
      ImageUrl:      imageURL,
    })
  }
  return productOfferings
}

위와 같이 ProductOffering 같은 객체를 따로 만들지 않고 애그리게잇이나 엔터티를 통째로 반환하게 만들 수도 있을 것이다.(아래처럼)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[
  // ProductOffering
  {
    // Offering aggregate
    "id": 10,
    "name": "[3월 특가] 2020 스타벅스 텀블러 콜드컵 실버 골드 리유저블",
    // ...
    // product aggregate
    "product" : {
      "id": 5,
      "name": "2020 스타벅스 텀블러 콜드컵 실버 골드 리유저블"
      // ...
    }
  }
]

이것은 지난 글에서 언급했던 것과 같은 문제가 발생할 수 있으며 클라이언트 입장에서는 매우 불친절한 API이다.

전에 경험했던 프로젝트에서 엔터티(데이터베이스 테이블과 매핑되는 객체)를 REST API로 가감없이 노출해서 사용했던 적이 있다. 결과적으로 클라이언트는 API로 내려오는 엔터티를 해석해야 했으며 엔터티 변경에 매우 취약했고 엔터티에 안에 있어야 할 풍부한 비즈니스 로직은 없어지고 단순한 데이터 운반체와 데이터베이스 테이블에 매핑만 하는 객체로 전락했다. - https://www.popit.kr/커머스-코드-자산화-개발일지-1-시작/

마이크로서비스에서 ‘서비스Service’라는 말을 되새겨 보자. 엔티티를 통째로 주면 클라이언트보고 알아서 쓰라는 말이되는데 예를 들면 이것은 식당에서 재료를 고객에게 던져주고 알아서 먹으라는 것이다. 서비스는 고객에게 맞추는 것이다. 물론 Open API 같이 클라이언트가 특정되지 않는 경우가 있다. 하지만 여기서는 Open API를 만드는 것이 아니니 고려 대상이 아니다.

클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만을 공개하고 나머지는 꽁꽁 숨겨야 한다. (중략) 이를 구현 은닉(implementation hiding) 이라고 부른다. - 오브젝트, 45 쪽

구현 은닉(혹은 정보 은닉Information hiding)은 꼭 객체 지향 프로그래밍에서만 유효한 것이 아니다.

마무리

Mall에서 스텁 코드를 지우고 상품 마이크로서비스 API를 호출하는 것으로 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package controller
import (
  "encoding/json"
  "io/ioutil"
  "net/http"
  //...
)
func ProductOfferingListController(c echo.Context) error {
  response, err := http.Get("http://localhost:7000/api/product-offerings")
  if err != nil {
    log.Errorf("ProductListView error - The HTTP request failed with error %s\n", err)
    // 에러페이지 처리
  }
  var productOfferingList = map[string]interface{}{}
  if b, err := ioutil.ReadAll(response.Body); err != nil {
    log.Errorf("ProductListView Error: Read API Response Body: %s", err.Error())
    // 에러페이지 처리
  } else {
    err := json.Unmarshal(b, &productOfferingList)
    if err != nil {
      log.Errorf("ProductListView Error: Unmarshal: %s", err.Error())
      // 에러페이지 처리
    }
  }
  return c.Render(http.StatusOK, "product_offering_list", productOfferingList)
}

주석

[1] https://www.popit.kr/커머스-코드-자산화-개발일지-1-시작/ 참조

[2] 아쉽게도 오래전이라 출처를 찾을 수 없었다.

[3] NoSQL Distilled, 18쪽

도메인 주도 설계에서 집합은 단위로 다루고 싶은 관련된 객체의 무리를 뜻한다. 특히, 집합은 데이터 조작과 일관성 관리의 단위가 된다. 보통은 원자적 연산으로 집합을 업데이트하고 데이터 저장소와도 집합 단위로 통신한다.

[4] https://www.popit.kr/에그리게잇-하나에-리파지토리-하나/ 참조

[5] https://www.popit.kr/id로-다른-애그리게잇을-참조하라/ 참조




Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.