커머스 코드 자산화 개발일지 - 5 장바구니(부제. 다중 저장소 지속성 입문)

지금까지 고객이 구매할 수 있는 오퍼Offering를 만들고[1] 쇼핑몰에 노출하였다.[2] 그다음으로 해야 할 일은 오퍼를 장바구니에 담는 것이다. ShoppingCartService는 마이크로서비스 모듈로서 장바구니를 책임진다. Mall은 ShoppingCartService의 API를 이용하여 오퍼를 장바구니에 담는다.

image-20200315-011701

불변성과 의존성

장바구니는 여러 개의 장바구니 아이템을 담을 수 있다. 여기서 오퍼라 하지 않고 ‘장바구니 아이템’이라고 부른 이유는 장바구니에는 오퍼뿐만 아니라 다른 것도 담을 수 있다는 추상적인 의미가 담겨 있다.

image-20200315-013628

장바구니 담기는 여러 가지 방식으로 구현할 수 있다.

먼저 Mall은 ShoppingCartService에 오퍼 ID만 전달하고 ShoppingCartService는 장바구니에 오퍼 ID만 저장한다. 그리고 Mall에서 장바구니 조회할 때 오퍼 ID로 ProductService API를 호출하여 오퍼 상세를 조회하여 고객에게 장바구니를 보여줄 수 있다.

image-20200315-060158

이 방식은 장바구니 조회할 때마다 오퍼 상세를 모두 조회해야 하기 때문에 성능 상 문제가 있다. 오퍼 상태가 계속 변한다면 최신 상태를 참조하는 것이 맞다. 하지만 오퍼가 불변Immutable하다면?

오퍼를 생성할 때 기간을 입력한다. 이 기간에만 오퍼는 유효하다. 오퍼를 변경하고 싶다면? 기존 오퍼는 만료시키고 새로운 오퍼를 만든다. 이렇게 하면 한번 생성한 오퍼는 변하지 않는다. 즉 불변하다. 변하지 않으니 얼마든지 복제해서 사용해도 상태 변경에 따른 부작용을 걱정할 필요 없다. 따라서 프로그래밍 모델 역시 매우 단순해진다.

오퍼가 불변하다면 아래처럼 오퍼 최신 상태를 매번 조회하는 것이 아니라 오퍼를 복제해서 장바구니에 담을 수 있을 것이다. Mall은 ShoppingCartService에 오퍼 ID만 전달하고 ShoppingCartService는 전달받은 오퍼 ID로 ProductService API를 호출하여 오퍼를 장바구니에 저장할 수 있을 것이다.

image-20200317-221342

하지만 이 방식은 ShoppingCartService와 ProductService 사이의 불필요한 의존성을 만든다. ShoppingCartService는 장바구니 아이템 내용에는 관심이 없다(적어도 지금은). 왜냐하면 장바구니에 장바구니 아이템을 담고 조회하여 사용 주체가 Mall이기 때문이다. 불필요한 의존성은 마이크로서비스 사이의 결합도를 높이고 결과적으로 변경에 취약한 설계를 만든다.

객체 지향 프로그래밍에서는 최소 지식의 원칙principle of least knowledge이라는 것이 있다. 디미터의 법칙Law of Demeter이라고도 하는데 마이크로서비스에도 유효한 말이다.

정말 친한 친구하고만 얘기하라. - 헤드 퍼스트 디자인 패턴

결론적으로 아래와 같은 방식으로 설계했다. Mall은 ProductService로부터 오퍼를 조회하여 장바구니에 저장한다. ShoppingCartService와 ProductService의 의존성을 끊을 수 있다.

image-20200317-221505

앞서 오퍼를 불변하게 다루는 것에 대한 이점을 언급했다. 하지만 비즈니스 환경이나 규모에 따라 오퍼에 대한 변경을 허용할 수도 있다. 이 경우 장바구니에 담은 오퍼(과거)와 변경한 오퍼(최신) 사이에 차이가 발생한다. 따라서 장바구니 오퍼를 최신 상태로 바꿔 주어야 한다. 이때 사용할 수 있는 방법 중 하나는 이벤트를 활용하는 것이다.

image-20200317-224637

상품 관리자가 오퍼를 변경하면 ProductService는 오퍼 변경 이벤트를 카프카Kafka래빗엠큐RabbitMQ 같은 메시지 큐Message Queue에 보낸다. ShoppingCartService는 이벤트를 수신하여 고객의 장바구니에 오퍼를 최신 상태로 변경한다.

이렇게 처리하는 경우 고객의 장바구니에 최신 오퍼를 반영하는 시차가 발생한다. 고객은 최신의 오퍼가 아닌 과거의 오퍼를 기준으로 결제/주문을 할 수 있다. 이 문제는 오퍼에 버전 스탬프version stamp를 추가하여 해결할 수 있다. Mall에서 장바구니에서 결제나 주문을 하기 전에 고객이 선택한 오퍼가 최신 버전인지 버전 스탬프로 확인하여 처리하는 것이다.

Mall 웹 애플리케이션

본격적으로 구현해보자. Mall 화면에서 ‘Add to cart’ 를 클릭하면 오퍼를 담는다.

해당 이미지 상품과 프로모션은 테스트를 위해서 필자가 임의로 만든 것임을 밝힙니다

해당 이미지 상품과 프로모션은 테스트를 위해서 필자가 임의로 만든 것임을 밝힙니다

장바구니 담기는 오퍼 ID로 오퍼 상세를 조회하여 ShoppingCartService API에 전달한다. 지금은 ShoppingCartService가 없기 때문에 HTTP 상태 코드 201(Created)을 바로 반환한다.

1
2
3
4
5
6
7
8
9
10
11
func (cc CartController) AddToCart(ctx echo.Context) error {
  productOfferingId := ctx.QueryParam("id")
  // 1. 오퍼 상세 조회 by ProductService API
  productOfferingDetails, err := cc.getProductOfferingDetails(productOfferingId)
  if err != nil {
  	log.Errorf("AddToCart error - getProductOfferingDetails %s\n", err.Error())
  	return ctx.JSON(http.StatusInternalServerError, nil)
  }
  // TODO : 2. 장바구니에 오퍼 담기 by ShoppingCartService API
  return ctx.JSON(http.StatusCreated, nil)
}

장바구니 화면을 만들기 위해 필요한 데이터는 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
func (cc CartController) GetCartItems(ctx echo.Context) error {
  // TODO: ShoppingCartService API 호출
  var shoppingCartItems []map[string]interface{}
  jsonString := `[
  	{
  		"id": 11,
  		"productId": 6,
  		"name": "[3월 특가] 2020 스타벅스 텀블러 콜드컵 실버 골드 리유저블",
  		// ...
  	},
  	{
  		"id": 12,
  		"productId": 7,
  		"name": "스텐 텀블러 대용량 900ml",
  		// ...
  	}
  ]`
  if err := json.Unmarshal([]byte(jsonString), &shoppingCartItems); err != nil {
  	log.Errorf("GetCartItems error - json %s\n", err)
  	return err
  } else {
  	return ctx.Render(http.StatusOK, "shopping_cart", shoppingCartItems)
  }
}
해당 이미지 상품과 프로모션은 테스트를 위해서 필자가 임의로 만든 것임을 밝힙니다

해당 이미지 상품과 프로모션은 테스트를 위해서 필자가 임의로 만든 것임을 밝힙니다

다중 저장소 지속성Polyglot Persistence

2006년 닐 포드는 문제마다 이를 해결하는 데 유리한 언어가 있으므로 이를 활용하기 위해 애플리케이션을 여러 언어로 작성해야 한다는 생각을 표현하려고 다중 언어 프로그래밍(polyglot programming)이란 용어를 만들었다. (중략) 전자상거래 시스템에서도 마찬가지이다. 장바구니 데이터를 저장할 데이터베이스는 고가용성과 확장성이 중요하다. 그러나 고객의 친구들이 구입한 제품을 찾는 것은 완전히 다른 문제이므로 장바구니와 동일한 데이터베이스 저장소를 사용하는 것은 도움이 되지 않는다. 여러 종류의 저장소를 사용하는 방법을 정의하기 위해 여기서는 다중 저장소 지속성(polyglot persistence)이란 용어를 사용한다. - NoSQL Distilled, 160쪽

장바구니에는 여러 가지 형태의 데이터를 담을 수 있어야 한다. 데이터 스키마가 고정적인 관계형 데이터베이스는 이런 요구 사항에 부응하기 어렵다. 아래와 같은 이유로 장바구니 저장소로 NoSQL 그중에서도 키-값 저장소가 타당하다고 한다.

고객이 주문을 확정하기 전 장바구니 데이터와 세션 데이터는 키-값 저장소에 저장할 수 있다. 따라서 RDBMS에는 이런 일시적 데이터를 저장하지 않게 된다. 장바구니는 보통 사용자 아이디로 접근하고, 사용자가 주문을 확정하여 결제하면 그 때 RDBMS에 저장할 수 있으므로 여기서 키-값 저장소를 사용하는 것은 충분히 타당하다. 세션 데이터 역시 세션 아이디를 키로 사용할 수 있으므로 마찬가지이다. - NoSQL Distilled, 161쪽

또한 Dynamo: Amazon’s Highly Available Key-value Store 에서도 키-값 저장소의 대표적인 쓰임새 중 하나로 장바구니를 언급한다.

There are many services on Amazon’s platform that only need primary-key access to a data store. For many services, such as those that provide best seller lists, shopping carts, customer preferences, session management, sales rank, and product catalog, the common pattern of using a relational database would lead to inefficiencies and limit scale and availability. Dynamo provides a simple primary-key only interface to meet the requirements of these applications. - Dynamo: Amazon’s Highly Available Key-value Store

다만 키-값 저장소에 키가 아닌 값으로 질의해야 하는 경우 데이터베이스가 아닌 애플리케이션에서 처리해야 한다.

장바구니 저장소로는 키-값 저장소인 Redis를 사용하기로 했다. 그 이유는 예전에 캐시 용도로 조금 써보았고 참고할 문서도 많기 때문이다.

image-20200315-011731

Redis 장바구니 PoCProof of Concept

장바구니 마이크로서비스를 구현하기 앞서 Redis가 장바구니 저장소로 적합한지 검증해보자.

먼저 도커로 Redis를 설치하고 실행시켰다.

1
2
docker pull redis:5.0.7
docker run --name essence-redis -d -p 6379:6379 redis:5.0.7 redis-server --appendonly yes

Redis 웹페이지에서는 Redis in Action 이라는 e-Book을 무료로 공개하고 있다. 이 문서에서 HASH(HSET, HGET)를 사용한 장바구니 예시가 있다.(2.2 Shopping carts in Redis)

Redis CLICommand Line Interface에서 검증해 보자. 아래 명령어를 실행하면 Redis CLI에 접속할 수 있다.

1
docker run -it --link essence-redis:redis --rm redis redis-cli -h redis -p 6379

1.장바구니 아이템 추가/수정

HSET 명령어의 key는 장바구니 식별자로 ‘사용자 ID’를 filed는 장바구니 아이템 식별자로 ‘오퍼 ID'를 value는 '오퍼 상세 정보’를 넣었다. key와 field 는 수정과 삭제의 식별자로도 쓰인다.

image-20200316-065821

자료 구조가 LIST가 아니라 HASH이기 때문에 장바구니 아이템을 수정할 때에도 HSET 명령어를 사용하며 key와 field가 같다면 새로운 값으로 대체한다.

image-20200316-070153

2.장바구니 아이템 삭제

HDEL 명령어를 사용하며 key와 field가 일치하는 것을 삭제한다.

image-20200316-075034

3.장바구니 조회

HGETALL 명령어를 사용하며 key에 해당하는 값을 모두 보여준다.

image-20200316-075150

정리하자면 Redis는 장바구니에는 여러 가지 형태의 데이터를 담을 수 있어야 한다는 요구 사항에 부합한다. 또한 HASH를 사용하기 때문에 장바구니에 중복해서 장바구니 아이템을 담는 문제도 없다.

장바구니 마이크로서비스

Redis CLI에서 검증된 부분을 동작하는 코드로 만들어보자.

먼저 Redis 클라이언트인 Go Redis 라이브러리를 사용하여 애플리케이션 구동할 때 Redis와 연결했다.

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
package main
import (
  "github.com/go-redis/redis/v7"
  "github.com/labstack/echo"
  "github.com/labstack/echo/middleware"
  //...
)
var (
  rdb *redis.Client
)
func init() {
  // ...
  rdb = redis.NewClient(&redis.Options{
  	Addr:         config.Config.Database.Connection,
  	DialTimeout:  10 * time.Second,
  	ReadTimeout:  30 * time.Second,
  	WriteTimeout: 30 * time.Second,
  	PoolSize:     10,
  	PoolTimeout:  30 * time.Second,
  })
  if _, err := rdb.Ping().Result(); err != nil {
  	panic(fmt.Errorf("Redis Database error: connection url: %s, error: %s \n", config.Config.Database.Connection, err))
  }
  // ...
}
func main() {
  defer rdb.Close()
  e := echo.New()
  e.Use(common.InitContextDB(rdb))
  // ...
}

API End-point 역할을 하는 ShoppingCartController는 아래와 같다. Mall에서는 cartId에 사용자 ID, cartItemId에는 오퍼ID를 넘길 것이다.

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
type ShoppingCartController struct {
}
func (scc ShoppingCartController) Init(g *echo.Group) {
  g.POST("/:cartId/items/:cartItemId", scc.AddItemToCart)
  g.GET("/:cartId/items", scc.GetCartItems)
  g.DELETE("/:cartId/items/:cartItemId", scc.DeleteCartItem)
}
func (ShoppingCartController) AddItemToCart(ctx echo.Context) error {
  cartItem := map[string]interface{}{}
  if err := ctx.Bind(&cartItem); err != nil {
  	return ctx.JSON(http.StatusBadRequest, dto.ApiError{
  		Message: err.Error(),
  	})
  }
  cartId := ctx.Param("cartId")
  cartItemId := ctx.Param("cartItemId")
  if err := service.ShoppingCartService().AddItemToCart(ctx.Request().Context(), cartId, cartItemId, cartItem); err != nil {
  	log.Errorf("AddItemToCart Error:  %s", err.Error())
  	return ctx.JSON(http.StatusInternalServerError, dto.ApiError{
  		Message: err.Error(),
  	})
  }
  return ctx.JSON(http.StatusCreated, nil)
}
// ...

ShoppingCartService는 처리를 ShoppingCartRepository로 위임한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var (
  shoppingCartServiceOnce sync.Once
  shoppingCartServiceInstance *shoppingCartService
)
func ShoppingCartService() *shoppingCartService {
  shoppingCartServiceOnce.Do(func() {
  	shoppingCartServiceInstance = &shoppingCartService{}
  })
  return shoppingCartServiceInstance
}
type shoppingCartService struct {
}
func (shoppingCartService) AddItemToCart(ctx context.Context, cartId string, cartItemId string, cartItem map[string]interface{}) error {
  if err := repository.ShoppingCartRepository().SaveCartItem(ctx, cartId, cartItemId, cartItem); err != nil {
  	return err
  }
  return nil
}

ShoppingCartRepository는 Redis 클라이언트의 HSet, HDel, HGetAll를 사용하여 데이터를 저장하고 조회한다.

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
var (
  shoppingCartRepositoryOnce sync.Once
  shoppingCartRepositoryInstance *shoppingCartRepository
)
func ShoppingCartRepository() *shoppingCartRepository {
  shoppingCartRepositoryOnce.Do(func() {
  	shoppingCartRepositoryInstance = &shoppingCartRepository{}
  })
  return shoppingCartRepositoryInstance
}
type shoppingCartRepository struct {
}
func (shoppingCartRepository) SaveCartItem(ctx context.Context, cartId string, cartItemId string, cartItem map[string]interface{}) error {
  marshal, err := json.Marshal(cartItem)
  if err != nil {
  	return err
  }
  rdb := common.GetDB(ctx)
  if err := rdb.HSet(fmt.Sprintf("shopping-cart:%s", cartId), cartItemId, string(marshal)).Err(); err != nil {
  	return err
  }
  return nil
}
func (shoppingCartRepository) FindItemsByCartId(ctx context.Context, cartId string) ([]map[string]interface{}, error) {
  rdb := common.GetDB(ctx)
  if m, err := rdb.HGetAll(fmt.Sprintf("shopping-cart:%s", cartId)).Result(); err != nil {
  	return nil, err
  } else {
  	var cartItems = make([]map[string]interface{}, 0)
  	for _, value := range m {
  		cartItem := map[string]interface{}{}
  		if err := json.Unmarshal([]byte(value), &cartItem); err != nil {
  			return nil, err
  		}
  		cartItems = append(cartItems, cartItem)
  	}
  	return cartItems, nil
  }
}
func (r shoppingCartRepository) DeleteCartItem(ctx context.Context, cartId string, cartItemId string) error {
  rdb := common.GetDB(ctx)
  if err := rdb.HDel(fmt.Sprintf("shopping-cart:%s", cartId), cartItemId).Err(); err != nil {
  	return err
  }
  return nil
}

여기서 주목해야 할 점은 상품 마이크로서비스(관계형 데이터베이스)와 장바구니 마이크로서비스(키-값 데이터베이스)가 서로 다른 데이터베이스를 사용함에도 불구하고 리파지토리Repository 코드를 빼고는 큰 차이가 없다는 것이다. 그 이유는 도메인 객체(장바구니)에 영속성을 부여하는 책임을 리파지토리가 담당하고 있기 때문이다.

도메인 객체에서 영속성을 부여하는 책임을 리파지토리로 분리함으로써, 도메인 객체가 자신의 비즈니스 로직에만 집중할 수 있게 할 뿐만 아니라 데이터베이스 변경에 유연하게 대응할 수 있다.

REPOSITORY에는 다음과 같은 이점이 있다. * REPOSITORY는 영속화된 객체를 획득하고 해당 객체의 생명주기를 관리하기 위한 단순한 모델을 클라이언트에게 제시한다. * REPOSITORY는 영속화 기술과 다수의 데이터베이스 전략, 또는 다수의 데이터 소스로부터 애플리케이션과 도메인 설계를 분리해 준다. * REPOSITORY는 객체 접근에 관한 설계 결정을 전해준다. * REPOSITORY를 이용하면 테스트에서 사용할 가짜 구현을 손쉽게 대체할 수 있다.(보통 메모리상의 컬렉션을 이용) - 도메인 주도 설계, 157 쪽

마무리

장바구니 마이크로서비스가 완성되었다. Mall에서 Stub 처리했던 부분을 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
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
60
61
62
63
64
65
66
67
68
func (cc CartController) AddToCart(ctx echo.Context) error {
  productOfferingId := ctx.QueryParam("id")
  // 1. 오퍼 상세 조회 by ProductService API
  productOfferingDetails, err := cc.getProductOfferingDetails(productOfferingId)
  if err != nil {
  	log.Errorf("AddToCart error - getProductOfferingDetails %s\n", err.Error())
  	return ctx.JSON(http.StatusInternalServerError, nil)
  }
  // 2. 장바구니 담기 by ShoppingCartService API
  if err := cc.addProductOfferingToCart(productOfferingId, productOfferingDetails); err != nil {
  	log.Errorf("AddToCart error - addProductOfferingToCart %s\n", err.Error())
  	return ctx.JSON(http.StatusInternalServerError, nil)
  } else {
  	return ctx.JSON(http.StatusCreated, nil)
  }
  return ctx.JSON(http.StatusCreated, nil)
}
func (cc CartController) getProductOfferingDetails(id string) (map[string]interface{}, error) {
  //...
  return productOfferingDetails, nil
}
func (cc CartController) addProductOfferingToCart(productOfferingId string, productOfferingDetails map[string]interface{}) error {
    apiBaseUrl, err := config.FindApiBaseURL(config.CartServiceAPI)
  if err != nil {
  	return err
  }
  body, err := json.Marshal(productOfferingDetails)
  if err != nil {
  	return err
  }
  response, err := http.Post(fmt.Sprintf(`%v/carts/%v/items/%v`,
  	apiBaseUrl, TestUserId, productOfferingId), "application/json", bytes.NewBuffer(body))
  if err != nil {
  	return err
  }
  defer response.Body.Close()
  if response.StatusCode == http.StatusCreated {
  	return nil
  } else {
  	log.Errorf("AddToCart error - The HTTP request failed with error %s\n", err)
  	return err
  }
}
func (cc CartController) GetCartItems(ctx echo.Context) error {
  apiBaseUrl, err := config.FindApiBaseURL(config.CartServiceAPI)
  if err != nil {
  	log.Errorf("GetCartItems error - FindApiBaseURL %s\n", err.Error())
  	return err
  }
  response, err := http.Get(fmt.Sprintf(`%v/carts/%v/items`, apiBaseUrl, TestUserId))
  defer response.Body.Close()
  if err != nil {
  	log.Errorf("GetCartItems error - Http Get %s\n", err.Error())
  	return err
  }
  var shoppingCartItems []map[string]interface{}
  if b, err := ioutil.ReadAll(response.Body); err != nil {
  	log.Errorf("GetCartItems error - Read Body %s\n", err.Error())
  	return err
  } else {
  	err := json.Unmarshal(b, &shoppingCartItems)
  	if err != nil {
  		log.Errorf("GetCartItems error - JSON Unmarshal %s\n", err.Error())
  		return err
  	}
  }
  return ctx.Render(http.StatusOK, "shopping_cart", shoppingCartItems)
}

글 검토를 받으며

Redis는 캐시 용도로 최적화되어 있어 고객이 직접 만든 장바구니 데이터(중요도가 높은)를 처리하는 데는 부적합하다는 의견을 받았다. 대안으로 MongoDB를 제안받았는데 향후 리펙토링의 과제로 기록해 둔다.

주석

[1] https://www.popit.kr/커머스-코드-자산화-개발-일지-2-상품을-팔지-않고-오퍼/

[2] https://www.popit.kr/커머스-코드-자산화-개발-일지-3-오퍼를-쇼핑몰에/




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