Go My Way #1 - 웹 프레임워크

루비의 Ruby on Rails, 자바의 Spring, 파이썬의 Django, 노드의 Express. 대부분의 인기 있는 언어는 메인 프레임워크가 있다. 그래서 고민 없이 그 언어에 맞는 메인 프레임워크를 사용한다. 하지만 Go는 이런 게 없다. Go는 많은 기능을 하나의 프레임워크에 담아놓는 방식보다, 상황에 맞게 필요한 패키지를 조합한 마이크로 프레임워크를 만들어 사용하는 것을 권장한다. 익숙해지면 이것이 편하지만, Go를 처음 접하는 사람들에게 어떤 패키지를 사용해야 할지 선택하는 것은 여간 어려운 일이 아니다.

Go 언어를 접한 지 3년이 되었고, 지난 1년 동안은 아주 적극적으로 Go 언어를 사용했다. 처음에는 회사 내에서 혼자서만 Go를 사용하고 있었는데, 이제 주변을 쓱 둘러보면 vscode를 켜고 Go 코드를 작성하는 동료들이 꽤 많이 보인다. 우리 회사는 중국에서 서비스를 하고 있고, 현재 alicloud에서 5개의 리눅스 서버와 2개의 윈도우즈 서버로 서비스를 운영하고 있다. 총 50개 정도의 벡엔드 서비스가 있는데, 그중 Go로 작성된 서비스는 10개 정도이다.

이렇게 Go 언어를 사용해오면서, 나름대로 나만의 방식이 생겼다. 3편에 걸쳐서 내가 Go를 사용하는 방식을 소개하려고 한다. (너무 길면 읽기도 지루할뿐더러, 글을 쓰는 나도 힘들다. ㅋ)

첫 번째 주제는 웹 프레임워크이다. 아마 이 부분에 가장 관심을 많이 가질 것 같아서 첫 번째 주제로 선정했다.

하지만 실제 웹 어플리케이션을 작성해보면 웹 프레임워크 만으로는 힘들다. 데이터를 저장하려면 DB도 필요하고, 로그도 좀 잘 남기고 싶고, 웹 요청이 처리되는 트레이스 정보도 보고 싶고,,, 이런 걸 하나하나 풀어가려면 또다시 수많은 선택지 앞에서 먹먹해진다. 그래서 2편과 3편에서는 웹 요청을 받아들여서 로직을 처리하는 기능 이외에, DB, Logging, Tracing, Vendoring, Confugration, Test등과 같은 것들을 어떤 방식으로 구현하는지 소개한다.

마지막에는 번외로, gomobile에 대해 소개하겠다. 몇몇 컨퍼런스에서 소개가 되긴 했지만, 실제로 Go를 모바일 개발에 사용하는 것을 많이 꺼린다. 하지만 우리 회사에서 서비스하는 모바일 앱 안에는 Go 패키지가 들어가있다. gomobile에 제약사항이 있긴 하지만, 제약사항을 잘 고려해서 프로그램을 작성하면 꽤 쓸만하다. 마지막으로 모바일 앱에서 Go를 어떻게 사용했는지 소개할 것이다.

사족을 달자면, 앞으로 3편에 걸쳐서 소개할 내용은 웹 어플리케이션 개발에 한한 방법이다. 모든 상황에 딱 들어맞는 방식은 없고, Go는 더더욱 그런 방식을 권장하지 않는다. 우리 회사에서 서비스 하는 비즈니스 환경 안에서, 웹 어플리케이션을 작성할 때에 한해서 나름 최적화한 방식을 소개하는 것이다. 환경이 다르다면 이 방식이 맞지 않을 수 있다. 물론 우리 회사 내에서도 다른 형태로 어플리케이션을 구성하기도 한다.


앞으로 소개할 내용에 대한 토론, 충고, 지적, 논쟁, 질문, 물어뜯기,,, 어떤 형태로든 의견을 주신다면 감사히 받아들이겠습니다. 저의 생각은 항상 열려 있습니다. ^^


결론부터 얘기하면, 웹 어플리케이션을 작성할 때는 대부분 메인 프레임워크로 echo를 사용한다. 지금까지의 사용 패턴을 담아서 레퍼런스 프로젝트인 echosample(https://github.com/pangpanglabs/echosample)을 만들었고, 새로운 패턴이 나올때마다 echosample을 지속해서 업데이트한다. 새로운 서비스를 만들 때는 echosample을 참고해서 만든다.

echosample를 함께 만들어가고 싶은 분이 있다면 대환영이다!

메인 프레임워크 - echo

echo는 과하지도 않고 아쉬운 것도 없는, 필요한 것만 딱 들어있는 프레임워크다.

Go에는 많은 웹 프레임워크가 있는데, 그 기능은 대동소이하다. 대부분의 웹 프레임워크들은 이런 기능들을 제공한다.

  • 라우팅을 정의하고
  • Query String이나 Body를 struct로 쉽게 변환을 해 준다.
  • 하나의 웹 요청이 처리되는 동안 유지되어야 할 값들을 보관할 컨텍스트를 제공한다.
  • 실행 결과를 다양한 형태(JSON, XML, Template, etc.)로 렌더링하기 위한 공통 함수들도 있다.
  • 각 요청 전후에 로직을 추가하기 위한 미들웨어를 쉽게 작성할 수 있도록 해주고
  • 편의를 위해 Auth, Logging, Recover, CORS등을 위한 built-in 미들웨어를 제공한다.

물론 각각의 기능을 별도 패키지를 조합해서 사용하기도 한다. 예를 들면, negroni와 같은 미들웨어를 기반으로, gorilla mux 또는 httprouter로 라우팅을 정의하고, render와 같은 렌더링 패키지를 사용하여 큰 와꾸(?)를 만든다. 이런 방식에 대해서는 아랫부분에서 다시 이야기하겠다.

대부분의 웹 프레임워크들이 비슷한 기능을 제공하는데, 그중에서도 echo를 선택한 이유는?

우선 풀스택 프레임워크를 표방한 것(beego, revel)은 제외했다. 편리해 보이는 기능이 많이 있지만, 아무리 봐도 동작하는 방식이나 사용하는 방식이 Go스럽지 않았다(“Go스럽지 않다” 라는 것에 대해서는 언젠가 충분히 이야기를 나눌 자리가 있었으면 좋겠습니다. 언젠가…^^;;). revel은 작년에 출간한 Go 언어 웹 프로그래밍 철저 입문 책에서 한 챕터를 할애해 revel 프레임워크로 블로그를 작성하는 방법을 소개했었지만, 그런데도 정작 나는 revel을 사용하지 않는다. 초반에 인기를 아주 많이 끌었던 martiniGo의 사상과 맞지 않는다는 의견들이 많았고, iris는 지구에서 가장 빠른 웹프레임워크라고 자신을 소개하고 있지만, 도덕적이지 않은 일로 논란이 있었던 적이 있어서 꺼려졌었다.

그다음 후보로 ginecho가 있었다. github의 like수는 gin이 더 많았지만, 후발주자인 echo에 더 마음이 끌렸다. 사실 github의 like 수가 5000 이상이라면, like 수로 뭘 쓸지 결정하는 것은 무의미하다고 생각한다. 개발자들에게 그 정도의 호응을 받고 있다면 이미 검증이 되었다고 볼 수 있다. 그 상황에서 echo가 더 끌렸던 이유는, echo가 gin보다 훨씬 더 활동이 활발하다는 점이었다. echo를 실제 서비스에 사용해 보았었고, 아주 마음에 들었었다.

참고로, 웹 프레임워크를 선택할 때 성능은 고려하지 않았다. Go 패키지 중에 좋은 성능을 강조하는 패키지들이 있는데, 난 그 부분은 무시한다. iris에 소개된 벤치마크 자료를 보면, iris의 초당 요청 처리 수 그래프 선이 450,000 이상으로 올라가 있는 것을 볼 수 있다. 초당 450,000건을 처리할 만큼의 고성능 서비스가 우리에게 필요한가? 초당 처리하는 요청 수가 10,000건만 넘어도 트래픽이 아주 높은 굉장히 성공한 서비스이고, 대부분의 Go 프레임워크는 그 정도는 가볍게 처리한다. 즉, 엄청난 트래픽을 견뎌내야 하는 몇몇 소수의 글로벌 서비스가 아니라면 그 정도의 성능은 중요한 요소가 아니라는 것이다. (Go를 사용하는 것 자체가 성능의 이점은 상당 부분 먹고 들어간다고 생각한다)

패키지 구성

Go 대부분의 프레임워크는 디렉토리 구조를 강제화하지 않는다. 필요한 기능을 제공할 뿐, 그 외의 것들은 개발자들이 알아서 상황에 맞게 쓰라는 것이다.

그래서 다른 언어에서 사용하던 방식으로 구성해보기도 하고, github에 있는 유명한 Go 프로젝트들을 흉내 내 보기도 했었다. 그러던 중 괜찮은 포스트를 보게 되었는데, 바로 Package Oriented Design이었다. 다른 Go 프로젝트에서 많이 봐오던 방식이여서, 왠지 Go스럽다라는 생각에 아래와 같이 구성해 보았다.

$ tree
.
├── account/
│   ├── model.go
│   └── service.go
├── cart/
│   ├── model.go
│   └── service.go
├── catalog/
│   ├── model.go
│   └── service.go
├── cmd
|   └── server/
|       ├── config.production.yml
|       ├── config.staging.yml
|       ├── config.yml
|       ├── handler.go
|       ├── main.go
|       ├── main_test.go
|       └── swagger.yml
└── kit/
    ├── httpreq/
    ├── number/
    ├── scanner/
    └── transport/

각 모듈은 라이브러리 형태로 패키지를 만들고, 프로그램 실행을 위핸 패키지는 cmd 안에서 구성한다는 것이다. 이 방식으로 웹 어플리케이션을 작성해 보니, 처음 웹 요청을 받아서 각 모듈로 연결하는 핸들러 로직이 담긴 /cmd/server/handler.go 파일이 꽤 커졌고, handler 또한 모듈별로 분리해야 할 필요가 생겼다. controller 디렉토리와 model 디렉토리를 나누는 전통적인 MVC 프로젝트의 형태와 점점 유사해졌다.

Package Oriented Design에서 소개하는 방식, 그리고 github의 많은 Go 프로젝트에서 사용하고 있는 패키지 구조는 여러 프로젝트에서 사용하는 라이브러리 패키지에 적합하다는 생각이 들었다. 웹 어플리케이션에서는 이러한 구조가 불편하게 느껴졌다. 여러 서비스에서 사용하는 라이브러리 패키지가 아니라면, 이렇게 구성을 하지 않기로 했다. 결국 웹 어플리케이션에서는 controller/model 형태의 익숙한 구성을 채택했다.

$ tree
.
├── controllers/
├── filters/
├── models/
├── static/
│   ├── css/
│   └── js/
|── views/
|   ├── 404.html
|   ├── 500.html
|   ├── index.html
|   └── layout/
└── main.go

그 이전에는?

앞에서 소개한 방식은, 1~2년간 이것저것 시도해보다가 결국 종착한 곳이다. 그렇다면, 그 전에는 어떤 방식을 사용했었는지, 그리고 지금은 왜 그 방식을 사용하지 않는지를 소개하는 것도 의미가 있을 것 같다.

negroni + gorilla mux + render

negroni, gorilla mux 그리고 render의 조합은 내가 가장 선호하던 방식이었다. 그래서 Go 언어 웹 프로그래밍 철저 입문 책에서 다양한 패키지를 조합하여 마이크로 프레임워크 구성하기란 제목으로 소개도 했었다.

처음에 가볍게 웹프레임워크를 구성해서 사용하기는 좋았었는데, 하다보니 매번 똑같은 작업을 반복하고 있더라. JWT 인증 처리 미들웨어를 넣고, 세션/쿠키 사용을 위한 구성을 해 주고, 렌더러를 세팅하는 작업을 매번 해야 했다. 어차피 이렇게 다양한 패키지를 조합해서 써야 한다면, 그리고 그 방식이 매번 똑같다면, 굳이 이렇게 쓸 필요가 있을까? 적어도 이런 기본적인 것들은 내장된 프레임워크를 쓰는 것이 낫지 않을까? 점점 negroni 기반으로 프레임워크를 직접 구성하는 방식에 회의가 들기 시작했다. 이런 경험을 통해 echo로 넘어가서 그런지, echo와 같은 스타일이 참 마음에 들었다.

go-kit

다른 웹 프레임워크 못지않게 많은 호응을 받는 go-kit. 하지만 왜 이렇게 어색하게 느껴졌던지…

circuit breaker, metrics, tracing과 같은 개발자들이 좋아할 만한 요소도 많고, 마이크로서비스를 한다면 A toolkit for microservices라고 소개하고 있는 go-kit을 왠지 꼭 써야 할 것 같고, 그리고 go-kit의 영향을 받은 프레임워크, 패키지들도 많은 것 같고… 하지만 막상 쓰기엔 부담스러웠다. 뭘 어떻게 하라는 건지 정돈된 문서도 없고, 다양한 example만 제공되는데, example들의 소스도 눈에 잘 안 들어왔었다.

그러다, go-kit을 써야 할 명분을 만들었다 명분이 생겼다.

“Go My Way #3 - gomobile"에서 소개할 테지만 우리 회사는 모바일 앱 개발에도 Go를 사용한다. 중국은 네트워크 상황이 좋지 않은 곳도 있어서(건물 지하 푸드코트에서는 네트워크 신호가 아주 약함), 서버로 통신하는 데이터의 양을 최소화해야 했다. 그때 떠오른 것이 grpc. go-kit의 transport를 사용하면 쉽게 grpc로 전환이 가능하다. 상황에 따라 http 통신을 할 수도 있고, grpc 통신을 할 수도 있고. 이 얼마나 멋진 기능인가?

관련 프로젝트를 go-kit 기반으로 포팅하기 시작했다. 그래서

  • endpoint로 노출할 기능을 정의했고
  • endpoint 각각에 대해 request, response를 변환하는 코드를 넣었고
  • client와 server에 각각 transport layer를 만들었고
  • 모든 request, response 타입에 대해 decode/encode 함수를 만들었고
  • grpc 사용을 위해 모든 reqeust, response 타입에 대해 proto file을 만들었다.

그렇게 한 땀 한 땀 go-kit 기반으로 포팅을 했더니, 또 한가지 관문이 남았다.

우리는 NGINX가 맨 앞단에서 API Gateway 역할을 한다. 서비스의 무중단 배포를 위해 Blue-Green Deploy 방식으로 배포하고 있고, 유동적으로 변하는 서비스의 주소는 consul에 등록이 된다. NGINX는 consul을 watching하고 있다가 서비스 정보가 변경되면 NGINX 설정 파일을 변경한다. NGINX는 Reverse Proxy 기능을 통해 웹 요청을 실제 service로 연결을 한다. 즉, 우리는 실제 서비스를 직접 외부로 노출하지 않는다. 그런데 NGINX에서 grpc는 Reverse Proxy가 안된다. Go로 만들어진 웹서버인 Caddy에서는 grpc도 Reverse Proxy가 가능하다는 것을 확인하였고, grpc 서비스에 대해서는 Caddy가 API Gateway 역할을 하도록 했다.

go-kit + grpc를 사용하기 위해 이 많은 작업을 하였다. 이렇게까지 해야하나…?

그렇게 해서 확인한 결과.

기존에 http로 통신하던 것과 grpc로 통신하는 것에 큰 차이가 없었다. NGINX를 통해 내려오는 압축된 JSON 데이터의 사이즈와 grpc로 내려오는 데이터 사이즈는 큰 차이가 없었다. 그렇다면, grpc를 사용함으로써 얻을 수 있는 이득은 서버의 CPU를 절약하는 것인데, 아직 우리 서비스는 CPU를 아껴야 할 만큼 많은 트래픽을 감당하고 있지 않다. 그것을 위해 지금까지 장황하게 설명한 저 작업을 하기엔, 그 노력이 아까웠다. circuit breaker, metrics, tracing와 같은 기능도 아직은 우리 서비스에 필수 요소는 아니라 생각했고, 필요하다면 go-kit 없이 직접 구현하는 것도 어렵지 않다.

그래서 go-kit도 OUT!

기본 net/http 패키지 사용

상황에 따라 Go의 기본 패키지인 net/http를 직접 사용하기도 한다. 기능이 많지 않은 서비스는 net/http 만으로도 충분하다. 예를들면, CAPTCHA 기능만 따로 빼내서 별도의 서비스로 만들어야 한다면, 이러한 서비스는 많은 기능이 필요 없기 때문에 net/http를 바로 사용해도 큰 어려움은 없다.