커머스 코드 자산화 개발일지 - 1 시작

필자가 가지고 있는 자산 중 하나는 전자상거래(이하 커머스) 도메인에서의 개발 경험이다. 오픈소스가 세상을 바꾸어가는 모습을 지켜보면서 개인의 보이지 않는 경험이라는 무형의 자산으로 머무는 것이 아나라 구체화하여 코드라는 유형의 자산으로 만들고 싶은 욕구가 있었다.

Meme

‘페르시아 왕자’라는 책이 있다. 동명 게임의 개발 일지인데 어린 시절 이 게임에 푹 빠져 밤을 지새우며 했던 기억이 아직도 생생하다. 갑자기 웬 게임일까?

이미지 출처 : http://www.yes24.com/Product/Goods/11416409?scode=032&OzSrank=1

이미지 출처 : http://www.yes24.com/Product/Goods/11416409?scode=032&OzSrank=1

안영회 님의 글 '밈(meme) ... 쉽게 말해 모방하라'에서 말하는 것처럼 필자도 조던 메크너를 따라 커머스 경험이라는 무형의 자산을 코드라는 유형의 자산으로 만드는 과정 개발 일지로 써보기로 했다.

결론은 맘에 드는 뭔가가 있다면 따라하란 사실 책만 읽으면 무슨 소용인가? 자기 삶에서 소화해서 효과를 봐야지. 책 읽는 것 자체로 즐거움을 주는 만화나 소설이라면 이야기가 다르지만... 밈은 소설은 아니다. 배웠으면 실천하라. 당신이 개발자라면, 마음에 드는 코드를 보면 따라해보라. 동료가 멋진 툴을 쓰면 다운 받거나 사서 꼭 써보라. 멋지게 발표하는 동료가 있다면 연습해서 해보라. 동생 앞에서 시작하더라도... - https://www.popit.kr/개인문-밈meme-쉽게-말해-모방하라/

제약

코드를 만들 때 중요한 제약 조건은 마이크로서비스 아키텍처Microservice Architecture(이하 MSA)를 기반으로 만든다는 것이다. MSA를 제약 조건으로 선정한 이유는 하나다. 필자의 대부분의 커머스 경험이 MSA 기반이기도 하고 MSA 시행착오와 깨달음이 유형으로 만들어야 할 자산이라고 생각했기 때문이다.

구현 전략

먼저 대강 동작하는 코드를 만들고 계속 고치는 것(혹은 리펙토링)이 기본 전략이다.

대강 동작하는 코드를 만든다는 것의 일환으로 첫 번째 이정표를 구색具色 갖추기로 정했다. 여기서 구색이라 하면 전자 상거래에서 팔 상품 등록부터 시작하여 고객이 주문하는 것까지를 의미한다. 여기서 핵심은 처음부터 끝까지 돌아가는 코드 대강 빨리 만드는 것이다. 일단 동작하면 계속 보강하며 고칠 것이기 때문이다.

상품 마이크로서비스

일단 팔 상품을 시스템에 등록할 수 있어야 한다. 이를 책임지는 서비스를 상품 마이크로서비스(ProductService)로 결정했다.

ProductService는 서비스를 관리하는 관리자 화면(admin)과 서비스 API로 나누었다. 관리자는 Proudct Admin 화면으로 상품을 등록하거나 수정한다.

image-20200217-023533

상품 관리 화면을 만들자

관리자 화면은 Ant Design으로 만들기로 했다. Ant Design을 선택한 이유는 React를 기반으로 다양한 UI 컴포넌트를 제공하고 있기 때문에 별도의 디자이너 없이도 개발자 혼자 만들 수 있기 때문이다.[1]

먼저 Use in create-react-app를 참조하여 admin 프로젝트를 생성했다.

그다음으로 Layout 컴포넌트로 관리자 화면 Layout 을 잡았으며, Table 컴포넌트로 상품 목록 조회 화면을, Form 컴포넌트로 상품 등록 화면을 만들었다.

image-20200217-050729

image-20200217-051805

image-20200217-052023

커머스 도메인에 경험이 있다면 상품 등록 화면에 빈약함에 놀랐을 수도 있다. 실제로 상품을 팔기 위해서는 위 보다 훨씬 많은 정보(예. 상품 옵션, 배송비 등등)가 필요하다. 하지만 일단 동작하게 만드는 것이 첫 번째 목적이기 때문에 매우 단순화해서 만들었다. 계속 고쳐가며 개선할 생각이다.

API를 만들기 전에 화면부터 만들었는데 그 이유는 개인적으로는 API는 쓰임새 즉 클라이언트 주도로 만들어져야 한다고 생각하기 때문이다. 화면을 다 완성하면 자연스럽게 API 스펙(등록, 조회)이 정해진다. 문제는 구현된 API가 없어서 데이터를 받을 데가 없다는 점인데. 이 문제는 수많은 테스트 대역Test Double 도구들로 해결할 수 있다. 필자는 Mountebank을 사용했다.

상품 API를 만들자

API 프로젝트 기술 스택은 아래와 같이 정했다.

사실 필자는 자바와 스프링 프레임워크 그리고 하이버네이트를 주로 써왔지만 변절자가 된 까닭은 함께 일하는 동료들의 영향이 컸다. Echo나 xORM을 써보면서 느꼈던 것은 스프링이나 하이버네이트에 비해 기능이 너무나도 적다는 것이었다. 처음에는 없다는 것이 불편하게 느껴졌으나 마이크로한 서비스를 만든는 데에 마이크로한 프레임워크를 쓰는 것이 적합하다는 결론에 도달했다(물론 개취다). 적다는 것은 그만큼 학습할 것도 적다는 뜻이다. 그래서 빠르게 학습해서 빠르게 만들 수 있다.

또한 Golang이 매력적으로 보였던 점은 기본적으로 코드를 빌드 하면 실행 가능한 파일 하나로 만들어 준다는 것이다. 이 점은 배포할 때 라이브러리 의존성 고민을 덜어준다.

이제 기술 스택은 정했다. API 내부 설계는 어떻게 할까? 필자는 도메인 주도 설계Domain-Driven Design(이하 DDD)의 추종자로써 DDD의 빌딩 블록Building blocks으로 시작하기로 했다.

HTTP의 요청과 응답을 담당하는 객체를 ProductController로 만들었다. ProductController는 핵사고날 아키텍처Hexagonal architecture에서 말하는 어댑터Adapter의 일종으로 HTTP를 담당하며 주로 HTTP 요청을 DTOData Transfer Object로 변환하여 ProductService로 넘겨 처리를 위임하거나 ProductService로 조회한 엔터티를 API에 적합한 형태의 DTO로 변환하는 역할을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type ProductController struct {
}
func (pc ProductController) Init(g *echo.Group) {
	g.POST("", pc.CreateProduct)
}
func (ProductController) CreateProduct(ctx echo.Context) error {
	var productCreation dto.ProductCreation
	if err := ctx.Bind(&productCreation); err != nil {
		return ctx.JSON(http.StatusBadRequest, dto.ApiError{
			Message: err.Error(),
		})
	}
	if err := service.ProductService().CreateProduct(ctx.Request().Context(), productCreation); err != nil {
		log.Errorf("CreateProduct Error:  %s", err.Error())
		return ctx.JSON(http.StatusInternalServerError, dto.ApiError{
			Message: err.Error(),
		})
	}
	return ctx.JSON(http.StatusCreated, nil)
}

왜 DTO인가?

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

외부에 노출하는 객체를 DTO로 사용함으로써 내부의 변화가 외부로 전파되는 것을 막을 수 있다. 또한 클라이언트에 적합한 데이터 구조를 사용함으로써 클라이언트가 데이터를 해석해야 하는 부담을 덜 수 있다.

ProductService는 어댑터와 애플리케이션의 경계를 책임진다. ‘경계’라는 의미는 ‘보호’와 ‘교류’가 있다. 아래 그림에서 보이는 도메인 모델이 바로 ‘보호’의 대상이며 ‘교류’는 여러 어뎁터들과의 주고받음을 의미한다.

출처: 도메인 주도 설계 구현

출처: 도메인 주도 설계 구현

ProductService는 무상태 객체로 매번 생성하여 사용하지 않고 한 번만 생성해서 재사용하도록 했다. ProductCreation으로 부터 Product 엔터티를 생성하고 이를 ProductRepository로 처리를 위임한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var (
	p *productService
)
func ProductService() *productService {
	if p == nil {
		p = &productService{
		}
	}
	return p
}
type productService struct {
}
func (productService) CreateProduct(ctx context.Context, productCreation dto.ProductCreation) error {
	product := entitiy.NewProduct(productCreation)
	return repository.ProductRepository().Create(ctx, product)
}

ProductRepository는 Product 엔터티의 영속성을 책임진다. ProductService와 마찬가지로 무상태 객체로 매번 생성하여 사용하지 않고 한 번만 생성해서 재사용하도록 했다. context[2]에서 xORM 객체를 가져와 데이터베이스에 저장한다.

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

영속성을 지니는 Product 엔터티는 아래와 같은 모습이다. NewProduct 함수는 엔터티 팩토리이다. 현재는 비즈니스 로직이 없지만 차츰 풍부하게 만들 생각이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Product struct {
	Id int64 `xorm:"id pk autoincr"`
	Brand string `xorm:"brand notnull"`
	Name  string `xorm:"name notnull"`
	ImageUrl  string `xorm:"image_url notnull"`
	CostPrice  float64 `xorm:"cost_price notnull"`
	CreatedAt time.Time `xorm:"created_at notnull"`
	UpdatedAt time.Time `xorm:"updated_at notnull"`
}
func (Product) TableName() string {
	return "products"
}
func NewProduct(productCreation dto.ProductCreation) Product {
	now := time.Now()
	return Product{
		Brand:     productCreation.Brand,
		Name:      productCreation.Name,
		ImageUrl:  productCreation.ImageUrl,
		CostPrice: productCreation.CostPrice,
		CreatedAt: now,
		UpdatedAt: now,
	}
}

데이터베이스 트랜잭션 처리는 어떻게 할까?

Echo에서 미들웨어Middleware라는 것이 있다. HTTP 요청/응답에 연결되는 Function으로써 Echo의 Conetxt 객체에 접근이 가능하다. 그래서 모든 요청을 기록하거나 요청 수를 제한하는 등 특정 작업을 수행하는 데 사용한다. 미들웨어를 사용하여 HTTP 요청마다 xORM Session 객체를 생성하여 Echo의 Conetxt 객체에 할당하고 HTTP 응답에 따라 Commit 하거나 Rollback한다.

1
2
3
4
5
6
7
8
9
10
var (
	xormDb *xorm.Engine
)
func main() {
    e := echo.New()
    // ...
    // 미들웨어 추가
	e.Use(common.InitContextDB(xormDb))
	// ...
}
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
func InitContextDB(xormEngine *xorm.Engine) echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			req := c.Request()
			ctx := req.Context()
			session := NewSession(ctx, xormEngine)
			defer session.Close()
			ctx = context.WithValue(ctx, ContextDBKey, session)
			loggingSession := NewSession(ctx, xormEngine)
			defer loggingSession.Close()
			ctx = context.WithValue(ctx, ContextLoggingDBKey, loggingSession)
			c.SetRequest(req.WithContext(ctx))
			switch req.Method {
			case "POST", "PUT", "DELETE", "PATCH":
				if err := session.Begin(); err != nil {
					log.Println(err)
					return err
				}
				if err := next(c); err != nil {
					session.Rollback()
					return err
				}
				if c.Response().Status >= 500 {
					session.Rollback()
					return nil
				}
				if err := session.Commit(); err != nil {
					return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
				}
			default:
				return next(c)
			}
			return nil
		}
	}
}

주석

[1] 보다 자세한 내용은 앤트 디자인 - 어드민 개발을 위한 프레임웍 글에 잘 나와 있다.

[2] Go context 는 Go언어에서 Context 사용하기 글에 잘 나와 있다.




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