커머스 코드 자산화 개발일지 - 4 출시

소프트웨어를 배포할 때 비로소 가치는 생겨납니다. - The Nature of Software Development, 26 쪽

소프트웨어는 사용자를 만날 때 진정한 가치가 생긴다. 내가 만든 소프트웨어가 사용자를 만날 수 있도록 서버에 배포해보자.

지금까지 세 개의 모듈을 만들었다.

  • product-service : 상품 API를 제공하는 서비스
  • product-admin : 상품 관리자 서비스
  • mall : 사용자 쇼핑몰

모듈을 각기 다른 서버에 배포할 수도 있겠지만 내가 선택한 방법은 단일 서버에 모든 모듈을 배포하는 것이다.[1] 단일 서버에 여러 서비스를 배포하면 서비스 사이 간섭(예.라이브러리 버전 충돌 등)으로 문제가 일어날 수 있다. 그래서 도커로 각 서비스를 격리 시키고 서브도메인을 사용하여 외부로 노출하기로 했다.[2]

image-20200305-221528

소프트웨어를 출시Release한다는 것은 크게 배포할 대상 즉, 아티팩트Artifact를 만드는 과정인 빌드Build와 만들어진 아티팩트를 서버에 전달하여 실행하는 배포Deploy로 나눌 수 있다. 여기서는 아티팩트를 도커이미지, 도커 이미지를 만드는 것을 빌드, 서버에 도커이미지 전달하여 실행시키는 것을 배포라 지칭하겠다.

Golang 프로젝트 빌드

product-service와 mall 모듈은 Golang을 기반으로 만들었다. Golang의 장점은 코드 컴파일할 때 애플리케이션이 사용하는 의존성 라이브러리를 포함시켜 실행 가능한 하나의 바이너리 파일로 만들어 준다는 것이다. 따라서 애플리케이션 실행에 필요한 라이브러리나 웹 서버 같은 의존성 고민에서 벗어 날 수 있다.

1. 배포 환경Deployment environment에 따른 애플리케이션 설정 정보 처리

배포 환경이라고 하면 애플리케이션이 배포되어 실행되는 곳을 뜻하는데 보통 로컬, 개발, 테스트, 스테이징, 운영 등으로 부른다. 배포 환경에 따라 애플리케이션의 설정 정보(예. 데이터베이스 접속 정보, API URL 등등)는 달라지기 때문에 동적으로 처리해 주어야 한다.

먼저 배포 환경에 따라 달라지는 애플리케이션 설정을 파일로 만들었다.

1
2
3
4
.
├── config.dev.json
├── config.go
└── config.json

아래는 파일 예시이다.

1
2
3
4
5
6
7
8
{
  "HttpPort": "9000",
  "Database": {
    "Driver": "mysql",
    "User": "product_user",
    "Connection": "@tcp(127.0.0.1:3306)/product?charset=utf8&parseTime=True&loc=UTC"
  }
}

그리고 Configor로 애플리케이션 설정 파일을 읽어서 사용했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package config
import (
	"github.com/jinzhu/configor"
)
var Config = struct {
	HttpPort string
	Database struct {
		Driver     string
		User       string
		Connection string
	}
} {}
func InitConfig(cfg string) {
	configor.Load(&Config, cfg)
}
package main
func init() {
  config.InitConfig("config/config.json")
  // ...
}
func main() {
  //...
}

기본적으로 config.json을 사용하고 배포 환경에 따라 애플리케이션 실행할 때 CONFIGOR_ENV 환경 변수를 설정하여 사용한다.

1
CONFIGOR_ENV=dev

2. 고 모듈Go Modules로 의존성 라이브러리 버전닝

예를 들어 로컬에서는 Echo 1.0을 사용해서 개발하고 테스트했는데 원격 서버에서는 Echo 1.2을 사용한다면 어떻게 될까? 애플리케이션에서 사용하는 의존성 라이브러리 버전을 관리해 주지 않으면 예기치 않는 문제를 만날 수 있다. Golang에서는 고 모듈로 의존성 라이브러리 버전을 관리할 수 있다.[3]

먼저 프로젝트 최상위 디렉토리에서 아래 명령어로 고 모듈을 초기화한다.

1
go mod init

그러면 go.mod, go.sum 파일이 생긴다.

1
2
3
4
.
├── go.mod
├── go.sum // 체크섬 파일(https://github.com/golang/go/wiki/Modules#is-gosum-a-lock-file-why-does-gosum-include-information-for-module-versions-i-am-no-longer-using) 참조
└── main.go

그다음 아래 명령어를 입력하면 사용 중인 의존성 라이브러리 버전을 모두 go.mod, go.sum에 기록해 준다.

1
go mod tidy

아래는 만들어진 go.mod 파일 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module product-service
go 1.13
require (
	github.com/go-sql-driver/mysql v1.5.0
	github.com/go-xorm/xorm v0.7.9
	github.com/jinzhu/configor v1.1.1
	github.com/kr/pretty v0.2.0 // indirect
	github.com/labstack/echo v3.3.10+incompatible
	github.com/labstack/gommon v0.3.0 // indirect
	github.com/sirupsen/logrus v1.4.2
	github.com/valyala/fasttemplate v1.1.0 // indirect
	golang.org/x/net v0.0.0-20200301022130-244492dfa37a
	xorm.io/core v0.7.3
)

고 모듈에 대한 더 자세한 내용은 '고 모듈을 사용하여 패키지 구성 방법 개선하기'에서 확인할 수 있다.

3. 도커파일Dockerfile 만들기

도커 이미지를 만드는데 필요한 도커파일은 아래처럼 만들었다.

1
2
3
4
5
6
7
8
9
10
11
FROM golang:1.13
RUN mkdir /go/src/product-service
WORKDIR /go/src/product-service
# 소스 코드 복사
ADD . /go/src/product-service
# 의존성 라이브러리 다운로드
RUN go mod download
# 소스 코드 컴파일, 패키징
RUN go install 
EXPOSE 9000
CMD ["product-service"]

4. 도커이미지 크기 줄이기

위의 도커파일로 도커이미지를 만들면 보통 이미지 크기가 800MB가 넘는다.

image-20200311-010206

Docker Image 크기 문제 (중략) 이런 상황에서 하나의 Container의 Image 사이즈가 크면 불필요한 디스크 공간을 많이 차지하게 됩니다. 하나 당 1GB만 되어도 대략 50 ~ 100GB 정도가 필요합니다. 그리고 Image 사이즈가 크면 배포 시에도 레지스트리 등록, 이미지 pull 시 네트워크로 많은 양이 트래픽이 전송되어야 합니다(3).  이런 이유가 아니더라도 다양한 이유로 Docker 커뮤니티에서도 Image의 크기를 가능한 최적화해서 사용하는 것을 권장하고 있습니다(4). (중략) 이를 위해 Docker에서 제공하는 Multi-Staging Build 방법을 사용해서 프로젝트를 컴파일 하고 Image를 만들고 있습니다. Multi-Staging Build에 대한 방법은 다음 Docker 문서에도 잘 나와 있습니다. - 마이크로 서비스 프로젝트 300개 관리하기

Multi-Staging Build 방법으로 도커이미지 크기를 줄여보자. 도커파일을 아래같이 변경했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FROM golang:1.13 AS builder
WORKDIR /go/src/product-service
COPY . .
# disable cgo
ENV CGO_ENABLED=0
RUN go mod download
RUN go install
# make application docker image use alpine
FROM alpine:3.10
# using timezone
RUN apk add -U tzdata
WORKDIR /go/bin/
# copy config files to image
COPY --from=builder /go/src/product-service/config/*.json ./config/
# copy execute file to image
COPY --from=builder /go/bin/product-service .
EXPOSE 9000
CMD ["./product-service"]

변경한 도커파일로 도커이미지를 만들면 800MB가 넘던 도커이미지가 23MB 정도로 줄어든 것을 확인할 수 있다.

image-20200311-011118

리액트ReactJS 프로젝트 빌드

product-admin 모듈은 리액트를 기반으로 만들었다. 리액트는 Golang에 비해 상대적으로 도커이미지를 만드는 과정이 좀 더 복잡하다. Golang은 개발 환경과 실행 환경이 같기 때문에 컴파일 후 만들어진 바이너리 파일을 실행할 수 있다. 반면에 리액트는 노드NodeJS 환경에서 개발한 코드를 웹 브라우저에서 실행할 수 없기 때문에 트랜스파일을 거쳐 웹에서 실행할 수 있는 자바스크립트 파일을 만들어야 하고 또한 추가로 자바스크립트 실행 환경으로 웹 서버가 필요하다.

1.배포 환경에 따른 애플리케이션 설정 정보 처리

배포 환경 마다 달라지는 API 주소는 노드 환경 변수(process.env)로 아래처럼 처리했다.

1
2
3
4
5
6
7
8
9
10
11
export const Config = {
  API_SERVER: () => {
    switch (process.env.NODE_ENV) {
      case 'dev':
        return "http://dev-product-service.example.kr/api";
      case 'test':
        return "http://test-product-service.example.kr/api";         
      // ...            
    }
  }
};

 package.json에서 react-app-rewired build 명령어에 환경 변수를 추가했다.

1
2
3
4
5
6
7
8
9
{
  // ...
  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "build:dev": "react-app-rewired build --env=dev",
    // ...
  }
}

하지만 build:dev 실행하니 process.env에 --env로 지정한 환경변수(dev)가 전달되지 않는다. 문제를 해결하기 위해 검색을 하다가 Create React App에서 해결의 실마리를 찾을 수 있었는데 바로 env-cmd를 사용하는 것이다.

Customizing Environment Variables for Arbitrary Build Environments You can create an arbitrary build environment by creating a custom .env file and loading it using env-cmd. - https://create-react-app.dev/docs/deployment/#customizing-environment-variables-for-arbitrary-build-environments

리액트는 노드 환경에서 개발한다. 노드는 기본적으로 .env 파일이 존재하면 파일 내용을 환경 변수로 등록한다. env-cmd는 노드 환경변수 파일을 동적으로 변경할 수 있다.

먼저 배포 환경별로 .env 파일을 만들었다.

1
2
3
4
.
├── .env
├── .env.dev
└── package.json

.evn.dev 파일은 아래와 같다.

1
REACT_APP_API_BASE_URL="http://dev-product-service.example.kr/api"

그리고 package.json에 build:dev 스크립트를 아래 처럼 변경했다.

1
2
3
4
5
6
7
{
  // ...
  "scripts": {
    "build:dev": "env-cmd -f .env.dev react-app-rewired build",
    // ..
  },
}

마지막으로 API 주소를 읽어오는 부분을 변경했다.

1
2
3
4
5
export const Config = {
  API_SERVER: () => {
    return process.env.REACT_APP_API_BASE_URL;
  }
};

2. node-builder 도커 이미지 만들기

노드 환경에서 개발한 리액트 코드는 웹 브라우저에서는 실행할 수 없다. 따라서 웹 브라우저에서 실행 가능하도록 변환해 주어야 하는데 이를 트랜스파일Transpile이라고 부른다. 또한 애플리케이션에서 사용한 의존성 NPM 라이브러리(예. Ant Design 등등)을 함께 번들링해 주어야 한다.

앞서 언급한 것처럼 리액트는 개발 환경과 실행 환경이 다르다. 실행 환경에서는 노드가 필요 없다. 따라서 이를 분리해야 한다. 리액트 코드를 트랜스파일하고 번들링하는 역할로 node-builder 도커이미지를 따로 만들었다.

1
2
3
4
5
6
7
8
9
10
FROM node:12.6.0-alpine
RUN apk update && apk upgrade && \
    apk add --no-cache git openssh
RUN mkdir -p /usr/local/node && mkdir -p /etc/node-cache
RUN npm config set cache "/etc/node-cache"
RUN npm install -g yarn
COPY ./docker/run.sh /usr/local/node
RUN chmod +x /usr/local/node/run.sh
WORKDIR /usr/src/app
ENTRYPOINT ["/bin/sh","/usr/local/node/run.sh"]

위의 도커파일로 node-builder 도커이미지 만든다.

1
docker build -t node-builder -f .

아래 도커 명령어를 실행하면 리액트 코드가 웹 브라우저에서 실행할 수 있는 코드로 만들어진다.

1
2
3
4
docker run --rm \
     -v  `pwd`/node_modules:/etc/node-cache \
     -v `pwd`:/usr/src/app \
     node-builder "yarn" "build:dev"

4. 리액트 애플리케이션 도커파일 만들기

마지막으로 애플리케이션 도커파일을 아래와 같이 만들었다. 앞서 node-builder를 통해 만들어진 코드를 NGINX 웹 서버에 복사한다.

1
2
3
4
5
6
FROM nginx:alpine
ADD ./build /usr/share/nginx/html
RUN cp /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.orig && \
    sed -i 's/listen[[:space:]]*80;/listen 9001;/g' /etc/nginx/conf.d/default.conf
EXPOSE 9001
CMD ["nginx", "-g", "daemon off;"]

배포 및 서브 도메인 분기 처리

모듈 마다 도커파일은 만들었다. 문제는 도커이미지를 어떻게 만들고 어떻게 원격 서버에 전달해서 실행시킬것인지이다. 현재로서는 단일 서버에만 배포하기 때문에 서버에서 최신 소스 코드를 받아 도커 이미지를 만들고 바로 실행시켰으며 서브 도메인은 NGINX의 proxy-pass를 통해 처리했다. 자세한 내용은 '스타트업 개발자 혼자 빠르게 싸게 서버 구축하기 - 3편'에 기술해 두었다.

단일 서버가 아닌 운영 환경처럼 여러 서버에 배포하는 경우 도커는 외부 서버에 있는 컨테이너에 접속하기 어려운 문제가 있다. 이를 해결하기 위해서는 K8skubernetes와 같은 도커 오케스트레이션Orchestration 도구를 사용해야 하는데 이번 글의 주제에서 벗어나 설명하지 않는다.

주석

[1] 그 이유는 스타트업 개발자 혼자 빠르게 싸게 서버 구축하기 - 1편에 상세히 나와있다.

[2] 그 이유는 스타트업 개발자 혼자 빠르게 싸게 서버 구축하기 - 3편에 상세히 나와있다.

[3] Golang 1.11 버전에서 고 모듈이 옵션 기능으로 등장하였고 1.13 버전부터 본격적으로 지원한다.




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