[Tech Blog] Go 서버 개발하기 - Buzzvil
[BuzzScreen Newsletter] January 2018
1월 30, 2018
[BuzzScreen Newsletter] February 2018
2월 27, 2018

[Tech Blog] Go 서버 개발하기

Go 서버 개발을 시작하며

 

특정 API만 다른 언어로 구현해서 최대의 성능을 내보자!

저희 서버는 대부분 Django framework 위에서 구현된 광고 할당 / 컨텐츠 할당 / 허니스크린 앱 서비스 이렇게 나눌 수 있는데 Python 이라는 언어 특성상 높은 성능을 기대하기가 어려웠습니다. 하지만 세가지 서비스에서 락스크린에서 어떤 컨텐츠나 광고를 보여줄지 결정하는 Allocation(할당) API 가 가장 많이 호출되고 있었는데 빈도로 보면 80% 정도로 높은 비중을 차지하고 있어서 이 Allocation API 들을 성능이 좋은 다른 언어로 구현하면 어떨까 하는 팀내 의견이 있었습니다.

Why Go?

저는 예전부터 Java,  C# 등의 컴파일 언어에 익숙해서 기존 Java 와 C, 그리고 Go 라는 최근에 새로 나온 언어 중에서 아래 블로그글과 같이 여러 reference 들을 통해 성능이 좋다는 Go 로 이 API 들을 포팅하는 작업을 시작하게 되었습니다. Go 에 대한 첫 인상은 Java, C계열 언어보다 덜 verbose 보였고 python 보다는 strongly-typed, encapsulated 하다보니 자유도를 제한해서 코드를 보기 쉽게 하는 것을 선호하는 저의 성격과도 잘 맞는 언어였습니다.

서버 개발 환경

 

Server design

How to import libraries

  • GVT (https://github.com/FiloSottile/gvt) – Go 는 vendering tool 을 통해 dependency 를 관리할 수 있습니다. GVT 의 경우 처음 도입했을 때 별로 유명하지 않았는데 사용법이 간단해서 도입하게 되었습니다. 아래와 같이 참조하고 있는 revision 을 관리해주며 update 통해서 최신 소스를 받아 올수 있습니다.
{
	"version": 0,
	"dependencies": [
		{
			"importpath": "github.com/Buzzvil/go-env",
			"repository": "https://github.com/Buzzvil/go-env",
			"vcs": "git",
			"revision": "2d8489d40184a12c4d09d09ce1ff717e5dbb0745",
			"branch": "master",
			"notests": true
		},
....

Design pattern

Go 언어에서는 package level cycling dependency 를 허용하지 않아서 좀더 명확한 구조를 만들기 좋았습니다. 예를들어 Service 에서는 Controller 를 참조할수 없고 Model 에서는 Controller / Service / DTO 등을 참조할수 없도록 강제했습니다.

모든 API 요청은 Route 를 통해 Controller 에게 전달되고 이 때 생성된 DTO (Data transfer object) 들을 Controller 가 직접 혹은 Service layer 에서 처리하도록 하였고 DB 에 접근할 때는 모델을 통해 혹은 직접 접근하도록 했지만 추후 구조가 복잡해지면 DB 쿼리 등을 담당하는 DAO (Data access object) 를 도입할 계획입니다

Libraries

요소 이름 선택 이유
Network Gin Web 서버이다 보니 네트워크 성능을 최우선으로 고려, 벤치마크 표를 보고 이 라이브러리를 선택
Redis & cache go-redis 역시 성능을 가장 중요한 지표로 보고 이 라이브러리 선택
Mysql Gorm ORM 없이는 개발하기 힘든 시대이죠. 여러 Database를 지원하고 ORM 중에서도 method chaining 을 사용하는 Gorm 을 선택
Dynamo guregu dynamo AWS에서 제공하는 Dynamo 패키지를 그대로 사용하면 코드 양이 너무 많아지고 역시 method chaining 을 지원해서 선택
Environment variables caarlos0 env Go 에서는 tag 를 이용하면 좀더 코드를 간결하고 읽기 쉽게 사용할수 있는데 이 라이브러리가 환경변수를 읽어오기 쉽도록 해줌

Redis cache

func SetCache(key string, obj interface{}, expiration time.Duration) error {
	err := getCodec().Set(&cache.Item{
		Key:        key,
		Object:     obj,
		Expiration: expiration,
	})
	return err
}

func GetCache(key string, obj interface{}) error {
	return getCodec().Get(key, obj)
}

Mysql

var config model.DeviceContentConfig
env.GetDatabase().Where(&model.DeviceContentConfig{DeviceId: deviceId}).FirstOrInit(&config)

Dynamo

if err := env.GetDynamoDb().Table(env.Config.DynamoTableProfile).Get(keyId, deviceId).All(&profiles); err == nil && len(profiles) > 0 {
	...
}

Environment variables


var (
	Config     = ServerConfigStruct{}
	onceConfig sync.Once
)

type (
	ServerConfigStruct struct {
		ServerEnv  string `env:"SERVER_ENV"`
		LogLevel   string
....
	}
)

func LoadServerConfig(configDir string) {
	onceConfig.Do(func() {//최초 한번반 호출되도록
		env.Parse(&Config)
	}
}

Unit test

 

환경 구성

Test 환경에는 Redis / Mysql / Elastic search 등에 대한 independent / isolated 된 환경이 필요해서 이를 위해 docker 환경을 따로 구성하였습니다.

Test case 작성은 아래와 같이 package 를 분리해서 작성했습니다.

package buzzscreen_test

var ts *httptest.Server

func TestMain(m *testing.M) {
	ts = tests.GetTestServer(m)
	// 환경 시작
	tearDownElasticSearch := tests.SetupElasticSearch()
	tearDownDatabase := tests.SetupDatabase()

	code := m.Run()		// 여기서 작성한 TestCase 들 실행
	// 환경 종료
	tearDownDatabase()
	tearDownElasticSearch()
	ts.Close()

	os.Exit(code)
}

Mock server는 은 http.RoundTripper interface 를 구현해서 http.Client 의 Transport 멤버로 설정해서 구현했습니다. 아래는 Test case 작성 예제입니다.

	httpClient := network.DefaultHttpClient
	mockServer := mock.NewTargetServer(network.GetHost(MockServerUrl))
	.AddResponseHandler(&mock.ResponseHandler{
		WriteToBody: func() []byte {
			return []byte(mockRes)
		},
		Path:   "/path",
		Method: http.MethodGet,
	})
	clientPatcher := mock.PatchClient(httpClient, mockServer)
	defer clientPatcher.RemovePatch()

Unit test 관련해서는 내용이 방대해서 추후 다른 포스트를 통해 자세히 소개하도록 하겠습니다.

Infra

API 요청 분할

AWS Application load balancer

여러 API 중에서 할당 API 를 제외한 요청은 기존의 Django 서버로 요청을 보내고 할당요청에 대해서만 Go서버로 요청을 보내도록 구현하기 위해 먼저 시도 했던 것은 AWS Application load balancer (이후 ALB) 였습니다. ALB 의 특징이 path 로 요청을 구별해서 처리할수 있었기 때문에 Allocation API 만 Go 서버 로 요청이 가도록 구현했습니다.

하지만 이렇게 오랫동안 서비스 하지 못했는데 그 이유는 서버 구성이 하나 더 늘어나고 앞단에 ALB 까지 추가되다 보니 이를 관리하는데 추가 리소스가 들어가게 되어서 어떻게 하면 이러한 비용을 줄일수 있을까 고민하게 되었습니다.

 

Using docker & nginx

Go로 작성된 서버가 독립적인 Micro service 냐 아니면 Django 서버에서 특정 API 를 독립시켜 성능을 강화한 모듈이냐 의 정체성을 두고 생각해봤을때 후자가 조금더 적합하다보니 Go / Django 서버는 한 묶음으로 관리하는 것이 명확했습니다. Docker 를 도입하면서 nginx container 가 proxy 역할을 하고 path를 보고 Go container / Django container 로 요청을 보내는 구성을 가지게 되었습니다.

글을 마치며

 

시작은 미약하였으나 끝은 창대하리라

하나의 API를 이전했음에도 불구하고 Allocation API 에 대해서는 약 1/3, 서버 Instance 비용은 1/2.5 수준으로 감소했습니다.

설명: 기존 4개의 Django 인스턴스의 CPU 사용률이 모두 13% 정도 감소, Go 인스턴스의 CPU 사용율은 17% 정도   17 / (13 * 4)  ≒ 1 / 3

충분히 만족할만한 성과가 나와서 그 뒤로 몇가지 API도 Go 로 옮겼고 새로 작성하는 API 는 Go 환경 안에서 직접 구현하는 중입니다.

처음에는 호출이 많은 하나의 API 를 다른 언어로 포팅하기 위해 시작한 작업이었는데 Container 기술을 도입하는 등 서버 Infra 까지 변경하면서 상당히 큰 작업이 뒤따르게 되었습니다. 하지만 이 작업을 하면서 많은 동료들의 도움과 조언이 있었고 결국 완성할수 있었습니다. 이렇게 실험적인 도전을 성공 할수 있는 환경에 여러분을 초대하고 싶습니다! Go언어에 대한 문의나 좋은 의견도 환영합니다.