결제서버 분리하기 - 3: msa 프로젝트 효율적으로 관리하기

1

지난 편들에서는 결제서버를 분리하기로 한 배경, 그 과정에서의 고민들을 적어보았는데요. 마지막으로 서비스가 하나 둘 분리되어가는 과정에서 어떻게하면 효율적으로 여러 프로젝트를 관리 할 수 있을지에 대한 고민을 적어보려고 합니다.

msa 환경에서 공통 코드 안전하게 관리하기

msa로 각 서비스가 분리되어가는 과정에서 가장 큰 고민은 “분리되는 서비스에 대한 유지보수” 일 것 입니다. 막상 분리를 시켰지만 msa의 장점을 경험하기도 전에 자칫 관리포인트만 늘어난 것으로 생각할 수 있습니다. 더군다나 스타트업의 경우에는 제한된 인원수로 여러 프로젝트들을 관리해야 하기 때문에 유지보수 비용은 msa도입을 고민하게 하는 포인트 중 하나입니다.

이미 팀에서는 두 개의 프로젝트에서 golang을 사용하고 있습니다. 이 때까지는 각 서비스에서 모든 로직이 포함되어 있었습니다. 하지만 팀 내에서 사용하는 모니터링 모듈이나 에러 모듈은 동일하다보니 거의 비슷한 코드가 중복으로 관리되고 있었습니다. 이번에 분리한 paygo 프로젝트도 마찬가지였습니다. 동일한 목적을 가진 코드가 분산되어 관리되다보니, 이후 코어한 정책이 변경된다면 모든 프로젝트에 변경 사항을 변경해야하는 번거로움과 의사결정마다 따르는 휴먼에러의 가능성을 배제할 수 없었습니다.

이런 유지보수의 비효율을 개선하기 위해, 여러 golang 프로젝트에서 사용할 수 있는 공통 모듈 프로젝트를 생성하기로 했습니다. 우선 모든 프로젝트에서 필수적으로 포함되어야하는 요소들을 정리해보니 아래와 같았습니다.

  • DB: RDB. NoSQL
  • Monitoring: Datadog
  • Error(Sentry) & Logging

하나의 예시로, 서로 다른 프로젝트에서 nosql 데이터베이스인 mongodb를 생성하는 로직을 살펴보면 아래와 같습니다.

// A project 

type MongoClient struct {
	client *mongo.Client
}

func NewMongoClientOption(config *config.MongoConfig) *options.ClientOptions {
	clientOptions := options.Client()
	clientOptions.ApplyURI(config.Url).SetTimeout(5 * time.Second)

	return clientOptions
}

func NewMongoClient(opts ...*options.ClientOptions) (*MongoClient, error) {
	client, err := mongo.Connect(context.Background(), opts...)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	err = client.Ping(context.Background(), nil)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	return &MongoClient{client: client}, nil
}
// B project 

type mongoConfig struct {
	url string
	db  string
}

func MongoNewClient() *mongo.Client {
	uri := ENV.MongoConfig.getMongo()
	clientOptions := options.Client().ApplyURI(uri)

	client, err := mongo.Connect(context.Background(), clientOptions)
	if err != nil {
		log.Fatal(err)
	}

	// 클라이언트와의 연결 확인
	err = client.Ping(context.Background(), nil)
	if err != nil {
		log.Fatal("Could not connect to MongoDB:", err)
	}

	return client
}

간단하지만 MongoDB 생성자 로직입니다. 로직상 차이는 있지만 동일하게, MongoClient를 생성하고, Ping을 통한 연결을 확인하고 있습니다. 만약 팀 내 몽고디비 생성 관련된 정책이 변경된다면 이 두 개의 프로젝트에서 변경 사항을 적용하여 배포해야합니다. 그 상황에서 새로운 정책을 반영하여 각각 다시 배포해고 만약 변경 사항이 프로젝트 전체와 디펜던시가 있을 경우 유지보수의 난이도가 급격히 올라가게 됩니다.

위와 같은 유지보수의 비효율을 개선하고자 공통 프로젝트에서 mongodb를 모듈화하여 아래와 같이 공통으로 사용하기로 했습니다. 또한 이전처럼 구조체가 아닌 인터페이스를 반환하게 하면서 다른 클라이언트 코드들과의 의존성을 낮출 수 있었고, 테스트를 위한 mocking의 이점을 챙길 수 있었습니다.

type IClient interface {
	...
}

var _ IClient = (*Client)(nil)

type Client struct {
	client *mongo.Client
}

func NewClient(opts ...*options.ClientOptions) (IClient, error) {
	client, err := mongo.Connect(context.Background(), opts...)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	err = client.Ping(context.Background(), nil)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	return &Client{client: client}, nil
}

func NewClientOption() *options.ClientOptions {
	return options.Client()
}

이제 모든 팀 내 golang 프로젝트에서는 go.mongodb.org/mongo-driver 모듈을 설치하는 것이 아닌 go mod에 github.com/***/go-common/mongo를 정의하고 설치해서 사용하게 됩니다. 실제 서비스를 컨테이너 기반으로 서빙할 때는 빌드 시점에 go get 명령어를 통해 해당 모듈 가져올 수 있습니다.

require (
							.
							.
	github.com/***/go-common v0.0.0-20240215041307-cb68b4e61e4b
							.
							.
)

아래와 같이 설치하면 default 브랜치를 참조하게되고, @develop 와 같이 특정 브랜치를 지정하여 가져 올 수 있습니다.

go get github.com/***/go-common

common module version control

위와 같이 운영을 하게되면 변경사항에 대한 이점을 챙길 수 있습니다. 하지만 변경사항을 각 프로젝트에서 매번 바로바로 적용하기는 쉽지 않고, 만약 빌드 시점에 다른 변경 사항을 반영하려고 했지만 가장 최신의 공통 모듈을 가져온다고 한다면 또다른 사이드 이펙트가 발생할 수 있습니다. 이러한 문제를 방지하기 위해 공통 모듈의 버저닝을 진행하기로 했습니다.

일반적으로 프로젝트의 version control은 git tag와 sementic versioning을 조합하여 사용합니다. 새로운 작업물이 release branch에 반영될 때마다 수동으로 git tag를 지정할 수 있지만 이러한 번거로움을 줄이기 위해 github action을 사용하여 version control을 자동화 했습니다.

먼저 모든 브랜치의 기준이 되는 브랜치(main)에 PR을 올리고 작업물이 merge가 되면, 자동으로 release 브랜치에 반영이 되는 액션 스크립트를 정의했습니다.

on:
  pull_request:
    types:
      - closed
    branches:
      - main

jobs:
  auto_merge:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
      - name: Sync ${{ github.ref }} to release
        uses: devmasx/merge-branch@master
        with:
          GITHUB_TOKEN: ${{ secrets.REPOSITORY_ACCESS_TOKEN }}
          type: now
          from_branch: ${{ github.ref }}
          target_branch: release

이 액션이 동작하게되면, 다음 자동으로 tagging과 release를 작성해주는 스크립트를 생성했습니다. 이 때 version control의 기준은 merge 커밋 메시지의 prefix입니다.

  • ex) perf: breaking change -> Major Release
  • ex) feat: add new feature -> Minor Release
  • ex) fix: fix Minor -> Patch Release
on:
  push:
    branches:
      [release]

jobs:
  auto_merge:
    runs-on: ubuntu-latest
    steps:
      - name: Bump version and push tag
        id: tag_version
        uses: mathieudutour/github-tag-action@v6.1
        with:
          github_token: ${{ secrets.REPOSITORY_ACCESS_TOKEN }}
          custom_release_rules: hotfix:patch:preminor

      - name: Create a GitHub release
        uses: ncipollo/release-action@v1
        with:
          tag: ${{ steps.tag_version.outputs.new_tag }}
          name: Release ${{ steps.tag_version.outputs.new_tag }}
          body: ${{ steps.tag_version.outputs.changelog }}

이렇게 버전을 관리하고, 실제 서비스에서 go.mod를 보게 되면 맨 처음 모듈을 설치했을 때와 같이 특정 커밋 포인트가 아닌 설치된 버전이 명시됩니다.

require (
							.
							.
	github.com/***/go-common v1.0.0
							.
							.
)

공통 모듈로 많은 부분을 이관하고나니 패키지 구조가 아래와 같이 단순해졌습니다.

// 공통 모듈 분리 전 패키지 구조
.
├── config
├── injector
├── nginx
├── scripts
├── protocol
└── server
		├── db
		├── monitor
    ├── error
    ├── handler
    └── logger

// 공통 모듈 분리 후 패키지 구조
.
├── config
├── injector
├── nginx
├── scripts
└── server
    ├── handler
    └── logger

프로젝트에서 데이터베이스, 에러, 모니터링 등을 걷어내니 조금 더 비즈니스 로직을 파악하는데 용이해졌고, 프로젝트 자체에서 신경써야할 부분들이 많이 줄어들었습니다.

Multi Stage Build

도커로 프로젝트를 빌드했더니 약 800mb 이미지가 생성되었습니다. 파일이 크다보니 저장소(물론 ECR과 github action을 사용해서 용량 걱정이 없긴 했지만..)의 용량의 비용이 신경쓰였고, 만약 github action을 self-hosting한다면 그 저장 용량도 항상 신경써줘야합니다. 만약 800mb 정도의 이미지가 생성된다면 10번만 빌드하더라도 약 8gb의 용량을 차지하게 됩니다. 또한 이렇게 큰 이미지의 경우 이미지를 다운 받거나 빌드 & 배포 할 때도 시간이 오래 걸리게 됩니다.

이렇게 큰 이미지가 생성되는 이유는 컨테이너 이미지에 생성에 필요한 여러 이미지와 파일이 함께 포함되기 때문입니다. 하지만 이것들은 실제 컨테이너를 실행시킬 때 필요하지 않은 것들입니다. 그러던 중 알게된 것이 multi stage build입니다. 요약하면 build stage를 두 단계 이상으로 나눠 실제 컨테이너 실행에 필요한 파일 및 디렉토리만 추출하여 사용하는 것을 의미합니다.

맨 처음 작성한 도커 파일은 아래와 같습니다. 일반적인 도커파일입니다.

FROM golang:1.21.4-alpine

ENV GOARCH=arm64\
    GOOS=linux

RUN apk update

WORKDIR /root

COPY . .

RUN go mod download && go mod tidy

EXPOSE 8000

RUN go build -o main .

ENTRYPOINT ["./main"]

multi stage build를 적용한 도커 파일입니다. 아래에 보면 FROM 구문을 기준으로 stage를 구분합니다. 또한 사용한 scratch는 여러 이미지들 가운데에서도 가장 경량화가 잘된 이미지입니다. super minimal image에 최적화되어 있습니다.

FROM golang:1.21.5-alpine as builder

ENV GOARCH=arm64\
    GOOS=linux\
    CGO_ENABLED=0\
    TZ=UTC

RUN apk update && apk add git ca-certificates tzdata

WORKDIR /root

COPY . .

RUN go mod download && go mod tidy

RUN go build -a -ldflags '-s' -o main .

FROM scratch

COPY --from=builder /root/main .
COPY --from=builder /root/.env .
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8000

ENTRYPOINT ["./main"]

이렇게 두 컨테이너 이미지 크기를 비교해보면, 아래와 같이 764mb → 18.52mb로 약 42배 가량 줄어든 것을 확인할 수 있었습니다.

나는 무엇을 얻었는가 !

현재 결제 서버 분리 프로젝트는 상용환경에서 우리 서비스의 결제 승인의 모든 처리를 진행하고 있습니다. 다행히 리소스적으로도 안정적으로 동작중이고, 현재까지 장애나 이상상황 없이 결제를 처리하고 있습니다.

이 프로젝트를 통해서 무엇을 얻었는가 ? 는 질문을 참 많이 받았습니다. 사실 서비스적으로도 이미 결제 도메인은 안정적으로 동작하고 있었고 자사 페이 서비스가 없다면 결제 서버 분리 자체가 가지는 이점도 크지는 않습니다. 그럼에도 모놀리식으로 운영되던 서비스를 작게나마 분리하면서 각 도메인간의 역할과 책임 분리의 이점을 챙겼다고 생각했고, 막연하게만 생각했던 msa에 한 발짝 다가갔다고 생각합니다. 분리하면서 팀 내에 gRPC라는 기술스택을 도입했다는 것과 작지만 msa를 통해 전체 서비스의 스케일업이 아닌 각 서비스별 스케일업을 할 수 있다는 msa의 장점을 느껴보기도 했습니다. 만약 기존의 구조를 유지했다면 쉽게 경험하지 못할 부분이었다고 생각합니다.

무엇보다 개인적으로 얻은 부분이 참 많습니다. 프로젝트의 오너로서 기획부터 설계, 구현, 모니터링, 유지보수의 과정을 거치면서 “어떻게 패키지 구조를 가져갈지”, “공통 코드는 어떻게 처리할지”, “유지보수의 효율을 높이기 위해서는” 과 같은 고민을 하고 스스로 답을 찾았다는 점에서 배운 것이 참 많습니다. 그렇게 차근 차근 채워나가면서 처음에는 아무것도 없었지만 지금은 어느덧 상용환경에서 우리 서비스의 모든 결제의 승인 처리를 담당하고 있으니 뿌듯하기도 합니다.

아직 모든 결제 도메인 로직이 넘어오지 않았습니다. 작게 작게 기능을 추가하면서 v0.4.1까지 왔는데, 모든 기능을 추가하여 빨리 v1.0.0 을 출시하고 싶네요 ! 긴 글 읽어주셔서 감사합니다 !

2개의 댓글

comment-user-thumbnail
2024년 4월 16일

유익한 글이었습니다! db 연결을 모듈화 하는 방법 정말 좋은 것 같아요
저는 테스트코드 만들때 매번 db연결 코드를 중복으로 썼었는데 저렇게 하면
중복을 확연히 줄일 수 있을 것 같아요
또, db가 다른 db로 변경된다고 해도 어려움 없이 적용 가능할 것 같네요

1개의 답글