요즘 Go 언어의 주가가 지속적으로 오르면서 Go 언어에 대해서 들어보지 못한 사람은 없을 것이다.
Go 언어는 배우기 쉽고 간단함에도 불구하고 매우 좋은 성능을 자랑한다. (그 특유의 단순함으로 인해 개발자마다 극명하게 호불호가 갈리기도 한다.) 또한, 내부적으로 가지고 있는 기본 라이브러리도 풍부해서 생산성도 좋다. 여기에서는 따로 의존성없이 Go의 기본 라이브러리만 사용해서 간단히 웹서버를 개발해볼 것이다. Go 언어를 할 줄 몰라도 다른 프로그래밍 언어를 접해본 적이 있다면 코드를 대략적으로 이해하는데 어려움은 없을 것이다.
Getting Started
Hello World
늘 그렇듯이 Hello World 예제부터 살펴보자. 아래의 코드는 /hello 경로로 요청시 “Hello World!!”라는 문자열을 반환하는 웹서버의 Go 코드이다.
packagemainimport("fmt""log""net/http")funcmain(){log.Println("starting Web Server")// (1)http.HandleFunc("/hello",func(whttp.ResponseWriter,r*http.Request){// (2)fmt.Fprintln(w,"Hello World!!")})err:=http.ListenAndServe(":8080",nil)// (3)iferr!=nil{// (4)log.Fatalln(err)}}
여기서 핵심은 net/http 패키지이다. net/http 패키지는 Go에서 HTTP 서버 및 클라이언트와 관련된 기능을 제공하는 기본 내장 모듈이다. Go 역시 다른 언어들처럼 보다 간편하고 좋은 성능을 위해서 Gin, Echo 등의 웹 프레임워크가 존재하지만 간단한 웹서버의 경우 net/http 만으로도 충분할 것이다.
위 코드를 하나씩 살펴보자. (1)은 간단하게 서버시작을 알리는 로그이다. 역시 내장된 log 모듈을 사용하여 로그를 남기고 있다. (2)부터 중요한 부분이다. net/http 모듈을 사용하여 /hello 경로를 등록하고 있다. 두 번째 파라미터로 넘기는 값은 함수이다. Go 언어는 high-order function을 지원한다. /hello 경로가 호출되면 함께 등록된 함수가 실행될 것이다. 이때 등록된 함수의 첫 번째 파라미터(http.ResponseWriter)는 응답과 관련된 기능을, 두 번째 파라미터(r *http.Request)는 들어온 요청과 관련된 기능을 제공한다. 함수 내에서는 간단하게 Hello World를 응답으로 내보내고 있다. 집고 넘어가야할 것이, fmt.Println이 아니다. fmt.Fprintln이다. 이 함수는 첫 번째 파라미터로 주어지는 Writer에 해당 문자열을 출력하는 기능을 수행한다. C 언어를 접해본 사람은 조금 익숙한 방식의 함수일 것이다. Java 언어에 익숙한 사람은 Go의 Writer를 Java의 OutputStream이라고 생각하면 쉽게 이해될 것이다. (3)은 8080 포트로 바인딩해서 서버를 시작하고 요청을 기다린다. 이때 http.ListenAndServe 함수의 반환값은 error이다. 대부분의 Go 함수는 내부적으로 예상되는 에러가 있다면 마지막 반환값으로 에러를 반환한다.
만약 에러 반환값이 nil(다른 언어의 null과 동일하다)이 아니라면 함수 수행 중 에러가 발생한 것이라는 의미이다. 그래서 (4)에서 err가 nil이 아닌 경우 Fatal 로그를 남기고 있다. 그리고 추가적으로 := 연산자는 변수를 생성하고 바로 값을 할당하는 것을 의미한다.
그런데 함수의 마지막 반환값은 또 무슨 말인가 싶은 사람도 있을 것이다. 기본적으로 Go 언어의 함수는 2개 이상의 반환값을 가질 수 있다. 아래의 예제처럼 여러 개의 반환값을 여러 개의 변수로 한 번에 받아서 사용할 수 있다.
여기서 Go를 접해보지 못한 사람의 경우 *http.Request처럼 타입 앞에 붙은 *가 무엇을 의미하는지 궁금할 것이다. 이는 C 언어처럼 해당 타입의 포인터 임을 의미한다. Go 언어는 포인터를 지원한다. 걱정하지는 말자. Go 언어의 포인터는 훨씬 간단하며 일반 객체를 다루는 것과 크게 다르지 않다. 대부분 단순한 용도로만 사용된다. 그럼 왜 첫 번째 파라미터는 일반 타입이고 두 번째는 포인터인가하는 의문이 생길 수도 있는데, 이는 Go 언어에서 더 깊숙히 들어가야하는 것들이니 일단 넘어가도록 하자.
또 다른 궁금증은 함수명이나 타입명이 소문자로 시작하는 것도 있고 대문자로 시작하는 것도 있다는 것에 의문을 표할 것이다. 이는 절대 오타가 아니다. Go 언어는 개발자 간에 코딩 컨벤션을 통일하는 것을 선호한다. 그래서 일부 기능은 함수 또는 타입의 이름이 무엇이냐에 따라서 갈린다. 대문자로 시작하는 이름은 Exported로, Java로 치면 public 메소드 또는 클래스라고 생각하면 된다. 반대로 소문자로 시작한다면 같은 패키지 내에서만 사용할 수 있다.
Routing
앞에서 봤던 것처럼 net/http에서도 HandleFunc라는 함수를 이용하여 경로와 실행될 함수를 매핑할 수 있다.
함수를 넘겨주는 방법 외에도 직접 Handler를 구현하는 방법도 존재한다.
위 예제는 MyHandler라는 타입은 선언하고 MyHandler에 ServeHTTP를 정의하고 있다. Go 언어는 Duck Typing 언어이기 때문에 명시적으로 특정 인터페이스를 구현한다고 선언할 필요없이 해당 인터페이스가 정의하는 함수와 동일한 함수를 구현하고 있다면 해당 인터페이스 타입으로 취급한다. 이제 핸들러를 등록해보자.
Go 언어는 기본적으로 JSON을 다룰 수 있는 모듈을 제공한다. encode/json 패키지를 통해 해당 기능을 사용할 수 있다. 아래의 예제는 Content-Type이 application/json인 요청 body 값을 직접 정의한 SomeType이라는 구조체로 변환하는 코드이다.
SomeType 구조체의 필드 뒤에 붙은 json... 값은 해당 필드의 태그이다. Java의 어노테이션(@)이라고 SomeType의 객체를 생성하고 그 주소값(&)을 Decode 함수에 전달하다. 이제 Decode 함수 내에서 주어진 포인터를 사용하여 알맞은 값을 할당해줄 것이다.
Response
Text
http.ResponseWriter는 Go 언어의 io.Writer 인터페이스를 구현하고 있다. 때문에 앞에서 봤던 것처럼 fmt 패키지의 함수들과 함께 사용할 수 있다.
n,err:=fmt.Fprintf(w,"hello world: %d",arg)
JSON
Request Body를 JSON으로 바꾸던 것과 비슷한 방법을 통해서 JSON으로 응답을 할 수 있다.
아래의 코드를 통해서 요청자는 JSON 포맷의 데이터를 받게 될 것이다.
지금까지 웹서버에서 가장 필수적이라고 할 수 있는 API 라우팅 설정과 요청/응답을 다루는 방법을 net/http 패키지(약간의 net/httputil도…)를 통해서 간단히 알아보았다. 이외에도 더 많은 기능들을 제공하지만, 이정도만으로도 작은 웹서버를 만드는 것은 별로 어렵지 않을 것이다. 나중에 기회가 된다면 더 소개해보도록 하겠다.
이 만큼의 기능만으로도 유용하게 사용할 수 있겠지만, 앞에서도 언급했듯이 고급 기능과 더 간편한 사용을 원한다면 그땐 gin-gonic, echo, fastHTTP 등 잘 만들어진 프레임워크를 가져다 사용하는 것이 더 편할 것이다.
우리가 만드는 이 블로그는 Github와 jekyll으로 구성되어 있다.
Markdown 문서를 만들어 Github에 반영하면 글이 보인다. 이것을 jekyll을 통해 서비스를 하고 있다.
글을 쓰는 방법이 어렵고 쉽게 잊을 수 있어 정리하여 기록한다.
hyper-cube.io에 글을 쓰자
우리의 blog에 글을 쓰려면 서비스 하고 있는 hyper-cube-io계정의 ‘hyper-cube.io’ Repo에 Markdown 문서를 올리면 된다.
단, 우리는 jekyll로 서비스를 하고 있기때문에 jekyll에서 정의한 gh-pages라는 브랜치에 해당 작업이 진행되어야 한다.
우리들은 blog 관리를 위해 ‘hyper-cube.io’ Repo를 각자 Github 계정으로 Fork하여 글을 올린 후 Pull Request를 하도록 정하였다. 물론 'hyper-cube.io'를 로컬로 clone 후 gh-pages의 브랜치에 바로 push하여 글을 올리는 것도 가능하다.
hyper-cube-io에서 내 Github 계정으로 Repository Fork하기
글을 쓰기 위해서는 자신의 Github로 ‘hper-cube.io’ Repo를 Fork 할 필요가 있다.
내 Repo 로컬로 clone하여 문서 작성
일반적인 Git을 사용하듯이 Fork한 Repo를 로컬로 clone한다.
git clone <repo_url>
clone 후 실제 문서는 gh-pages 브랜치의 _posts에 넣어줘야 보인다. 우리도 gh-pages 브랜치로 이동하여 작성하도록 한다.
clone 후 실제 작성한 문서는 master 브랜치에서 작업하며 마스터 브랜치의 _posts에 넣어 줘서 작업한다. 이것은 ‘hyper-cube.io’ Repo의 gh-pages에 올라와 있는 다른 사람의 문서를 건드릴 수 없게 하기 위함이다.
폴더 구조는 jekyll문서에 잘 나와 있으니 참고 하면 된다. Directory Structure
git checkout master
현재 작성 중인 ‘_posts/<문서명>.md' 문서 자체는 Markdown 형식으로 작성하며 Google에 검색하면 많은 예제가 있으니 참고 하시길.. 문서명>
앞에서 말했듯이 실제 페이지의 서비스는 hyper-cube-io/hyper-cube.io의 gh-pages 브랜치에서 하고 있다.
따라서 내 Repo에 올린 내용을 hyper-cube-io/hyper-cube.io에 반영 해 줘야 한다.
내 Repo에 내용들을 실제 hyper-cube-io에 반영하기 위해서는 Pull Request라는 과정을 거쳐야 한다.
위 사진에서 보듯이 pull request를 생성할 때 base repository의 branch를 gh-pages로 head repository의 branch를 master로 설정 한다.
아래에는 내 repository에서 원본 repository로 반영할 commit 목록이 보인다.
이 과정은 내가 작업한 내용들이 실제 서비스에 반영해도 되는지 hyper-cube-io를 운영하는 권한 있는 사람들에게 승인을 받는다.
다른 수많은 소프트웨어 기술과 같이 Service Discovery도 이전부터 존재하던 개념이다. 그러나 최근 클라우드 및 마이크로서비스 아키텍처의 부흥과 함께 종종 언급되며 우리들의 눈에 띄는 것처럼 보인다. 한번 이번 기회에 Service Discovery에 대해 간단히 알아보자.
Service Discovery
위키피디아에서는 Service Discovery에 대한 문서가 빈약한 편이지만 간단하게 Service Discovery는 네트워크 상에서 제공되는 서비스 또는 장치의 자동 탐지라고 정의하고 있다. MSA의 입장에서 살펴보면 네트워크로 연결되어있는 여러 서비스들을 자동으로 탐지하여 그 정보들을 관리하는 것이다.
네트워크 상에 A 서비스와 B 서비스가 존재하며 각 서비스는 HTTP RESTful API를 제공한다. A가 B의 API를 사용하기 위해서는 B의 IP와 포트 같은 주소가 필요하다. 같은 물리 서버에 동작한다고해도 최소한 자신과 같은 서버에서 동작한다는 사실과 포트는 알고 있어야할 것이다. 이렇게 A가 B의 주소 정보를 구체적으로 알고 있어야 한다는 사실은 A와 B의 구조에 몇가지 제한을 만들게 된다.
첫 번째로 B를 scale-out 하는 경우를 예로 들 수 있다. B가 보단 많은 트레픽을 처리하기 위해서 scale-out을 한다고 해보자. A는 추가된 B 서비스의 서버 주소 정보를 추가적으로 알아야한다. 개발자는 직접 A에 B와 관련된 설정이 해주어야할 것이다. 만약, A 뿐만이 아니라 C, D 서비스도 B를 이용하는 있었다면 모두 일일이 새롭게 설정을 해주어야할 것이다. 설정이 별로 어렵지 않고 그정도는 직접 해줘도 괜찮다고 할 수도 있다. 그렇다면 auto scale-out은 어떻게 해야할까? 들어오는 트레픽에 비례해서 자동으로 B의 인스턴스가 추가된다고하면 A, C, D는 어떻게 할 것인가? B 서비스가 별도의 로드밸런서에 연동되어 자동으로 부하분산을 해주고 있다고해도 역시 개발자가 직접 로드밸런서에 새롭게 추가된 추가적인 B 인스턴스에 대해서 설정을 해주어야하는 번거로운 작업이 존재한다.
두 번째로 클라우드 환경에서는 IP 같은 주소 정보가 매우 동적이라는 것이다. 어찌보면 위에서 잠깐 언급한 auto scale-out와 이어지는 이야기일 수도 있다. 클라우드 환경에서는 서버 인스턴스가 얼마든지 실시간으로 간단하게 추가 및 제거될 수 있다. 이 과정에서 부여받는 주소 정보는 예측이 불가능하다.
Service Discovery는 클라이언트가 주소를 명시적으로 알고 있어야한다는 제한점으로부터 우리를 해방시켜준다. Service Discovery는 서비스들의 주소를 자동으로 탐지하고 관리를 해주기 때문이다. Service Discovery에 의해 주소 정보가 관리되기에 클라이언트는 더 이상 서버의 주소 및 포트에 대해서 알 필요가 없으며 어떤 서비스가 존재한다는 것만 알면 된다. 다른 서비스와의 통신에 좀 더 추상화된 레이어를 제공한다고 볼 수 있다. 또한, 서버 측도 서비스 주소를 자동으로 탐지하여 관리하기에 보다 물리적인 인프라에 제약을 받지않고 다이나믹하게 서비스를 운영할 수 있다.
Service Registry
Service Discovery에서 네트워크 내의 각종 서비스에 대한 주소 정보를 저장하고 관리하는 중앙 서버를 Service Registry라고 부른다. 다른 서비스들의 주소 정보를 제공하므로 고가용성이 요구되지만 최신 Service Discovery 플랫폼들은 만에 하나 Service Registry가 죽어버리더라도 문제없이 서비스들 간에 통신이 가능하도록 방법을 제공하기 때문에 부담은 덜하다.
Pattern
Service Discovery는 크게 두가지 패턴을 가지고 있다. 하나는 Client-Side Discovery Pattern이며 나머지 하나는 Server-Side Discovery이다.
Client-side Discovery
클라이언트가 직접 Service Registry로부터 특정 서비스의 주소 정보를 받아와서 통신을 하는 패턴.
클라이언트가 직접 주소를 가져와서 서비스와 직접 통신을 하기 때문에 불필요한 네트워크 부하를 줄일 수 있다. 또한, 클라이언트가 특정 서비스의 모든 인스턴스 주소를 알 수 있기 때문에 자신인 원하는대로 로드밸런싱을 할 수도 있다.
단 이점은 단점이 되기도 하는데, 자신이 서비스 주소를 가져오고 원하는대로 로드밸런싱을 할 수 있다는 말은 클라이언트가 Service Discovery를 위한 추가적인 기능을 개발해야된다는 말이기 때문이다.
Server-side Discovery
이 패턴은 클라이언트가 Service Registry에 대해서는 전혀 몰라도 되는 패턴이다. 서버와 클라이언트 사이에는 요청 유형에 따라(예를 들어, URL 경로 등) 적절한 서비스 인스턴스로 전달을 해주는 라우팅 서버가 존재한다. 이 라우팅 서버는 Service Registry를 참고하여 서비스에 대한 주소 정보를 얻고 이를 바탕으로하여 클라이언트로부터의 요청을 서비스 인스턴스에 전달한다. 클라이언트는 로드밸런서와만 통신을 하기 때문에 따로 Service Discovery와 관련된 특별한 작업을 신경 쓸 필요가 없다. 하지만 로드밸런서는 SPOF(Single Point Of Failure)가 될 수 있기 때문에 매우 중요한 요소로서 높은 고가용성이 요구될 것이다.
Next - Service Discovery 구현체
다음 포스트에서는 Service Discovery 구현체를 몇가지 알아볼 것이다. 첫 번째로 Java로 개발된 Netflix의 Eureka에 대해서 알아보고, 두 번째로 Go로 개발된 HashiCorp의 Consul 이렇게 두 가지를 알아볼 것이다.