커머스 코드 자산화 개발 일지-2 상품을 팔지 않고 오퍼를 판다

지난 이야기

지난 글에서는 개발 일지를 쓰게 된 배경을 설명했고 간단하게 상품 마이크로서비스를 구현해 보았다.

상품을 보다 유연하게 다루기

몇 년 전 함께 일했던 기획자가 상품(Product)을 분리하자는 제안을 했다. 상품에는 잘 변하지 않는 요소(바코드, 제조사, 원가 등)와 자주 변하는 요소(판매 조건 등)가 있는데 상품 하나로 다루다 보니 유연성이 떨어진다는 것이었다. 유연성이 떨어진다는 말은 무엇을 의미하는 것일까? '커머스 혹은 유통 도메인 설계에 대한 연작'에서 같은 고민의 흔적을 엿볼 수 있다.

사용자가 보통 상품(商品)이라고 부를 때 말이나 화면에 보이는 표기는 상품이지만, 정보는 층을 나눠 저장하자는 제안입니다. 판매자(커머스) 입장에서 Product란 개념은 생산하거나 판매하려고 구입한 실제 물건을 지칭하고, Product를 지칭하거나 참조하지만 판매 관련한 정보는 별도의 개념(Item)으로 구분하자는 것이죠. 몇 가지 이유가 있는데 대략 예를 들면 이렇습니다.

  • 제조사가 다루는 관리번호와 판매자가 부여하는 식별번호는 다르다.
  • 같은 상품이라도 프로모션이나 수수료 계약에 따란 다른 가격을 부여해 판매조건이 달라진다.
  • 같은 상품 여러 개 혹은 다른 상품을 묶어 파는 경우는 흔히 있다.
  • 일차 판매자가 팔고 남은 물건을 다른 판매사가 사다가 파는 형태(아웃렛 혹은 Off-price-retails)가 있다.
  • 파는 단위를 고정하지 않고 구매할 때 단위와 가격이 정해지기도 한다. (슈퍼마켓에서 저울에 달아 사는 과일처럼)
  • https://www.popit.kr/커머스-혹은-유통-도메인-설계에-대한-연작/

위에서 언급한 이유는 상품을 유연하게 다루어야 하는지를 잘 설명해준다.

상품을 유연하게 다루기 위해 상품을 판매할 때 변하는 정보와 변하지 않는 정보를 구분하고 변하는 정보를 다음 그림과 같이 별도의 개념(Item)을 만들어서 두 가지 구성요소로 정의하고 있다.

출처 : https://www.popit.kr/커머스-혹은-유통-도메인-설계에-대한-연작/

출처 : https://www.popit.kr/커머스-혹은-유통-도메인-설계에-대한-연작/

하지만 Item 개념은 좋으나 그대로 차용해서 쓰기에는 단어가 너무나도 보편적이고 일반적이다. 개인적인 경험으로 이런 단어를 사용했을 때 의사소통에 좋지 않았다. 왜냐하면 모두 자신이 생각하는 Item이 있기 때문이다. 대체할 수 있는 다른 단어는 없을까? 안영회 님과 대화 중에서 찾을 수 있었다. 바로 Offering(간단히 오퍼). 사전에서는 아래와 같이 정의한다.

(사람들이 사용하거나 즐기도록) 제공된[내놓은] 것 - 네이버 사전

좀 더 찾아보니 마케팅과 커머스 분야에서 정의와 그림을 발견할 수 있었다.

MARKETING, COMMERCE a product or service that is offered for sale. - https://dictionary.cambridge.org/ko/사전/영어/offering
출처 : https://i.pinimg.com/originals/89/9d/7f/899d7fe08717ad589e9855bd21b093d6.jpg

출처 : https://i.pinimg.com/originals/89/9d/7f/899d7fe08717ad589e9855bd21b093d6.jpg

너무 흔하게 쓰지 않는(한국에서) 단어이고 뜻이 딱이어서 Item 대신 Offering을 사용하기로 결정했다.

오퍼(Offering) 개념 설계

상품은 여러 개의 오퍼를 가질 수 있으며 오퍼는 여러 개의 혜택(Customer Benefit, Coupon)을 가진다. 그리고 고객은 상품이 아닌 오퍼를 구매한다.

image-20200223-050459

상품을 판매하기 위해 오퍼를 만든다. 오퍼를 만드는 행위를 판촉 활동이라고 부른다.

의도적으로 구현 대상과 범위 줄이기

오퍼의 대상과 범위를 Product로 의도적으로 한정하였다. 그 이유는 개발 주기를 일주일로 정해서(제약 조건) 진행하고 있기 때문이기도 하고 주기 안에서 작고 가치 있는 기능을 배포하는 것이 목표이기 때문이다.

이번 주기에는 상품에 오퍼를 생성할 수 있는 것이 목표이고 오퍼의 ‘Customer Benefit’의 극히 일부만 구현할 생각이다.

오퍼 생성 화면

Ant Design을 사용하여 오퍼 생성 화면을 간단하게 아래와 같이 만들었다.

image-20200224-011335

기간을 어떻게 입력받을 지 고민이었지만 DatePicker 컴포넌트 중 RangePicker 컴포넌트로 별다른 달력에 대한 의존성 없이 쉽게 구현할 수 있었다.

image-20200224-011835

화면에서 ‘Customer Benefit'은 여러 개를 추가할 수 있다. 이렇게 반복되는 요소를 Form 컴포넌트로부터 배열로 데이터를 받고 싶었는데 방법을 찾아보니 아래처럼 getFieldDecorator 첫 번째 인자에 배열식으로 선언하면 되었다.

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
// ...
import {Button, Form, Input, DatePicker, Divider, Icon, Select, message} from "antd";
class OfferingForm extends React.Component {
  // ...
  render() {
    const { getFieldDecorator } = this.props.form;
    return (
        {/* ... */}
        this.state.customerBenefits.map((customerBenefit, idx) => (
        {/* ... */}
        <Form.Item label="이름">
          {getFieldDecorator(`customerBenefits[${idx}].name`, {
            rules: [
              {
                required: true,
                message: '이름은 필수 입니다.',
              },
            ],
          })(<Input />)}
        </Form.Item>
        <Form.Item label="할인 기준 금액">
          {getFieldDecorator(`customerBenefits[${idx}].ruleset.discountThreshold`, {
            rules: [
              {
                required: true,
                type: 'number', transform: (value) => Number(value),
                message: '할인 기준 금액은 필수 입니다.',
              },
            ],
          })(<Input />)}
        </Form.Item>
        {/* ... */}
      </div>
      {/* ... */}
    )
  }
}
export default OfferingForm = Form.create()(OfferingForm);

오퍼 API

먼저 객체 모델은 아래와 같다. Offering 엔터티는 CustomerBenefit 값 객체ValueObject를 가지고 Product 엔터티를 참조한다.

image-20200224-013746

도메인 주도 설계Domain-Driven Desigin(이하 DDD)에서 엔터티는 매우 단순하게 설명하면 영속성Persistence을 가진 객체이다. 영속성이라고 하면 데이터베이스 같은 비휘발성 저장소에 저장해 관리하는 것을 의미하고 관계형 데이터베이스는 테이블과 연결할 수 있다.

데이터베이스 테이블에 저장할 객체를 대부분 엔터티로 모델링 하는 경우가 많다. 필자도 처음에는 CustomerBenefit을 엔터티로 설계했었다. 도메인 주도 설계 구현Implementing Domain-Driven Design에서는 엔터티의 남용으로 복잡해지는 도메인 모델을 언급하며 값 객체 사용을 노력해야 한다고 말한다.

가능한 위치에선 엔터티 대신 값 객체를 사용해 모델링하도록 노력해야 한다는 사실을 알게 되면 놀랄지도 모르겠다. 심지어 도메인 개념이 엔터티로 모델링돼야 할 때에도 엔터티의 설계는 자식 엔터티의 컨테이너보다는 값의 컨테이너로 동작하는 쪽으로 기울어야 한다. - 도메인 주도 설계 구현, 299쪽

쓰임새 관점에서 생각해보자. 오퍼는 따로 떼어서 사용하는가? 그렇다. 고객은 특정 오퍼를 구매한다. 그렇다면 CustomerBenefit은 따로 떼어서 사용하는가? 아니다. 현재로서는 오퍼를 구성하는 값Value에 불과하다. 따로 떼어서 사용하기 위해서는 식별성을 지녀야 한다. 그래서 엔터티의 중요한 특징 중 하나는 식별성이다.

ENTITY의 식별성을 관리하는 일은 매우 중요하지만 그 밖의 객체에 식별성을 추가하면서 시스템의 성능이 저하되고, 분석 작업이 별도로 필요하며, 모든 객체를 동일한 것으로 보이게 해서 모델이 혼란스러워질 수 있다. 소프트웨어 설계는 복잡성과의 끊임없는 전투다. 그러므로 우리는 특별하게 다뤄야 할 부분과 그렇지 않은 부분을 구분해야 한다. 하지만 이러한 범주에 속하는 객체를 단순히 식별성이 없는 것으로만 생각한다면 우리의 도구상자나 어휘에 추가할 게 그리 많지 않을 것이다. 사실 이 같은 객체는 자체적인 특징을 비롯해 모델에 중요한 의미를 지닌다. 이것들이 사물을 서술하는 객체다. 개념적 식별성을 갖지 않으면서 도메인의 서술적 측면을 나타내는 객체를 VALUE OBJECT라 한다. - 도메인 주도 설계,100 쪽

이제부터 동작하는 코드로 풀어보자.

Offering 엔터티에 합성Composition으로 OfferingCustomerBenefit 값 객체 포함시켰다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Offering struct {
	Id int64 `xorm:"id pk autoincr"`
	Name  string `xorm:"name notnull"`
	ProductId int64 `xorm:"product_id notnull"`
	OfferingPrice  float64 `xorm:"offering_price notnull"`
	ImageUrl  string `xorm:"image_url notnull"`
	StartDate time.Time `xorm:"start_date notnull"`
	EndDate time.Time `xorm:"end_date notnull"`
	OfferingCustomerBenefits []OfferingCustomerBenefit `xorm:"customer_benefits notnull"`
	CreatedAt time.Time `xorm:"created_at notnull"`
	UpdatedAt time.Time `xorm:"updated_at notnull"`
}
func (Offering) TableName() string {
	return "offerings"
}
type OfferingCustomerBenefit struct {
	Type string `json:"type"`
	Name  string `json:"name"`
	Ruleset map[string]interface{} `json:"ruleset"`
	CreatedAt time.Time `json:"createAt"`
	UpdatedAt time.Time `json:"updateAt"`
}

Offering을 저장하는 OfferingRepository는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var (
	o *offeringRepository
)
func OfferingRepository() *offeringRepository {
	if o == nil {
		o = &offeringRepository{
		}
	}
	return o
}
type offeringRepository struct {
}
func (offeringRepository) Create(ctx context.Context, offering entitiy.Offering) error {
	if _, err := common.GetDB(ctx).Insert(&offering); err != nil {
		return err
	}
	return nil
}

xORM은 기본적으로 Golang의 Struct를 관계형 데이터베이스에 JSON 형태로 저장해 준다.

image-20200224-020412

결과적으로 OfferingCustomerBenefit을 값 객체로 모델링함으로써 Offering 엔터티를 조회할 때 OfferingCustomerBenefit을 JOIN 할 필요가 없어지기 때문에 성능 측면에서 더 나아졌으며 모델을 다루는 것이 더 단순해졌다. 물론 단점도 있는데 관계형 데이터베이스에 친화적이지 않은 집합 데이터(OfferingCustomerBenefit)를 JSON으로 넣다 보니 SQL을 통해 customer_benefits 칼럼 내용으로 검색하기 쉽지 않고 또한 JSON 스키마 관리를 애플리케이션에서 잘 하지 않으면 JSON 문자열을 객체로 만드는 과정에서 오류가 날 수 있다.

현재까지는 Repository 코드가 매우 단순했다. 테이블 하나에 대한 연산을 했기 때문이다. 하지만 연관 관계를 가지는 테이블을 함께 연산해야 하는 경우가 많은데(차후에 DDD 애그리게잇Aggreate으로 다룰 예정) xORM은 단순히 객체-테이블 매퍼Mapper 역할만 하기 때문에 이를 지원하지 않는다. 따라서 직접 구현해 주어야 한다.

마지막으로 Controller 와 Service 코드는 아래와 같다.

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
type OfferingController struct {
}
func (oc OfferingController) Init(g *echo.Group) {
	g.POST("", oc.CreateOffering)
}
func (OfferingController) CreateOffering(ctx echo.Context) error {
	var offeringCreation dto.OfferingCreation
	if err := ctx.Bind(&offeringCreation); err != nil {
		return ctx.JSON(http.StatusBadRequest, dto.ApiError{
			Message: err.Error(),
		})
	}
	if err := offeringCreation.Validate(); err != nil {
		return ctx.JSON(http.StatusBadRequest, dto.ApiError{
			Message: err.Error(),
		})
	}
	if err := service.OfferingService().CreateOffering(ctx.Request().Context(), offeringCreation); err != nil {
		log.Errorf("Create Offering Error:  %s", err.Error())
		return ctx.JSON(http.StatusInternalServerError, dto.ApiError{
			Message: err.Error(),
		})
	}
	return ctx.JSON(http.StatusCreated, nil)
}
var (
	o *offeringService
)
func OfferingService() *offeringService {
	if o == nil {
		o = &offeringService{
		}
	}
	return o
}
type offeringService struct {
}
func (offeringService) CreateOffering(ctx context.Context, offeringCreation dto.OfferingCreation) error {
	offering := entitiy.NewOffering(offeringCreation)
	return repository.OfferingRepository().Create(ctx, offering)
}




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