Go, gRPC 백엔드 + React 프론트엔드 + 쿠버네티스 사이드프로젝트 만들어보기 - Gomunkong
들어가면서
뭘 만들었는지 궁금하신 분들은 레포지터리 또는 데모를 먼저 구경하고 오셔도 좋습니다!
https://github.com/Indosaram/gomunkong-main
https://gomunkong-frontend-ingress-indosaram.cloud.okteto.net/
최근 유행하고 있는 쿠버네티스와 Go, gRPC를 모두 사용해 사이드 프로젝트를 해보고 싶어졌습니다. 그래서 코드를 예쁘게 만들어주는 온라인 코드 포매터를 만들었습니다. 각 스택에 대한 간략한 설명을 적어두었으니 확인해 보세요. 실제 백엔드/프론트엔드 구현이 궁금하신 분들은 바로 본문으로 가시면 됩니다 👀
gRPC 공부하는 데 1주일 정도 걸렸고, 실제 서비스 구현은 5일정도 걸렸네요. 전체 코드 양 자체가 많지 않아서 금방 할 수 있었던 것 같습니다.
쿠버네티스(Kubernetes, k8s)
최근 백엔드 개발자에게 요구되는 기술은 단순히 서버 또는 시스템을 어떻게 구성할 것인가가 아닌, 쿠버네티스 클러스터를 어떻게 설계하고 관리할 것인가라고 생각합니다. 물론 단일 서버에서 모든 작업을 처리하는 경우도 있겠지만, 대부분의 회사에서 마이크로서비스 아키텍처(MSA)를 도입하고 있기 때문에 쿠버네티스는 거의 필수적인 기술인 것 같습니다.
쿠버네티스에 대한 소개와 필요성에 대해서는 44bits의 서비큐라님께서 친절한 튜토리얼과 함께 작성해두신 글이 있어서 여기에 소개하는 것으로 대신합니다.
Golang
쿠버네티스에 더해 최근에는 Golang도 엄청나게 각광받는 추세입니다. gofmt
로 인해 강제되는 코딩 스타일 덕분에 읽기 쉽고 유지보수 하기도 쉬운 코드가 나오는데다, 언어 자체가 C처럼 빠르고, 비동기 연산을 간편하게 지원하기 때문에 백엔드 개발에 최적화 되어있습니다. 자세한 내용은 은 아래 포스팅을 참고하세요.
gRPC
HTTP2 레이어에서 작동하는 protobuf 기반의 RPC 프로토콜입니다. HTTP/1.X 기반의 REST API의 문제점으로는
- 메시지 크기 대비 큰 헤더
- 데이터는 JSON으로 직렬화(Serialize)해야 함 -> 사람이 읽기 편하지만(Human-readable) 실제 데이터 대비 비효율적인 문서 구조
- 클라이언트/서버단에서 발생하는 응답 대기로 인한 레이턴시
- 연속 스트림이 아닌 요청 기반 프로토콜이기 때문에 한 번에 여러 정보를 송수신할 수 없다는 점
등이 있는데요. gRPC는 컴팩트한 바이트 스트림인 protobuf를 이용한 Remote Procedure Call 방식의 통신으로 이를 해결했습니다. 따라서 내부 API와 같이 서버-서버 또는 서비스-서비스 간 빈번한 호출이 있는 경우 통신 리소스를 적게 사용하는 장점이 있습니다. 게다가 protobuf는 IDL(interface definition language)를 사용해 정의되기 때문에 별도 API 정의서가 없이도 인터페이스를 파악할 수 있는 장점이 있습니다. 최근 MSA를 사용하는 기업들에서 많이 도입중입니다. 자연스럽게 쿠버네티스랑 연결됩니다!
gRPC 튜토리얼은 정말 자세하게 설명돼있는 김도진님의 블로그를 참고하세요.
Go + gRPC 백엔드
gRPC와 k8s를 이번에 처음 사용해봤기 때문에 개발하는데 상당히 애를 먹었습니다. 특히 gRPC는 튜토리얼은 많은데 뭔가 실제적인 접근 방식에 대해서 설명된 곳이 별로 없더라구요. 비교적 최근에 올라온 GopherCon Israeld의 핸즈온 튜토리얼이 실제 서비스 구현에 도움이 많이 되었습니다.
백엔드 구성은 메인 포매터 서버가 코드를 입력받고, 각 언어 서버로 코드를 보낸 다음 포맷된 결과물을 받아보는 형식으로 구성했습니다. 포매터 서버는 심플하면서도 강력한 웹 프레임워크인 gin-gonic 을 사용했습니다. 간단한 예제를 보여드리면, API 서버를 다음과 같이 구성이 가능합니다.
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}
다음으로, gRPC를 구성하기 위한 protobuf를 정의해주어야 합니다. .proto
파일에 해당 내용을 적어주면 되는데요. option go_package
는 해당 protobuf로 컴파일되는 go 언어파일을 별도 패키지로 설정하기 위한 옵션입니다. 저는 grpc 관련 코드를 별도 패키지로 만들었는데, 실제 서비스를 사용할 패키지 폴더로 설정하셔도 됩니다.
syntax="proto3";
package lang_server;
option go_package = "github.com/Indosaram/gomunkong/gomunkong-backend/proto/lang_server";
service Lang {
rpc Formatter(Input) returns (FormattedCode);
}
message Input {
string lang = 1;
string formatter = 2;
string code = 3;
repeated string args = 4;
}
message FormattedCode {
string formatted_code = 1;
}
먼저 service
인 Lang
을 정의하고, 서비스 안의 rpc 입력과 출력인 Input
과 FormattedCode
메시지를 정의합니다. 메시지 안 속성에 할당되는 숫자들은 각 속성을 구분하기 위한 enumerate라고 생각하시면 됩니다. 저는 각 언어 서버가 같은 서비스 정의를 공유하기 때문에 하나만 작성했는데, 여러 서비스가 있다면 각 서비스마다 이런 .proto
파일을 정의해주면 됩니다.
그 다음은 정의한 proto
파일을 gRPC
구현에 사용할 Go언어 파일로 컴파일합니다. 그 전에 의존성을 미리 설치합니다.
go get -u google.golang.org/grpc
go get google.golang.org/protobuf/reflect/protoreflect@v1.26.0
go get google.golang.org/protobuf/runtime/protoimpl@v1.26.0
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
# Add GOBIN to PATH
export PATH="$PATH:$(go env GOPATH)/bin"
드디어 컴파일을 시작합니다. 명령어를 보면 --go_out
과 --go-grpc_out
2가지가 있는 걸 보실 수 있습니다. 각각 client와 grpc 서버에서 사용할 파일이 생성되는 것입니다.
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
lang_server/lang_server.proto
이렇게 생성된 파일을 실제 언어서버에서 어떻게 참조해서 gRPC 서버가 구동되는지를 확인하겠습니다.
package main
import (
...
langPb "github.com/Indosaram/gomunkong-backend/proto/lang_server"
"google.golang.org/grpc"
)
type golangServer struct {
langPb.LangServer
}
func (s *golangServer) Formatter(ctx context.Context, input *langPb.Input) (*langPb.FormattedCode, error) {
...
return &langPb.FormattedCode{
FormattedCode: string(data),
}, err
}
func main() {
...
lis, err := net.Listen("tcp", ":"+portNumber)
if err != nil {
fmt.Printf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
langPb.RegisterLangServer(grpcServer, &golangServer{})
fmt.Printf("start gRPC server on %s port", portNumber)
if err := grpcServer.Serve(lis); err != nil {
fmt.Printf("failed to serve: %s", err)
}
}
protobuf로 정의한 langServer
를 구조체 변수로 만들고, 이를 이용해 rpc로 호출될 Formatter
함수를 만들었습니다. 이 함수는 Context
와 *langPb.input
을 파라미터로 받고, *langPb.FormattedCode
와 에러를 리턴합니다. 입력과 출력이 모두 protobuf에서 정의된 Input
과 FormattedCode
라는 것 눈치채셨나요? 이제 이 함수는 main()
에서 실행되는 grpcServer
를 통해 호출되게 됩니다.
이렇게 각 언어마다 gRPC서버를 만들었는데요. gRPC는 아직 브라우저와는 호환이 되지 않습니다. 따라서 프론트엔드에서 백엔드로 호출을 보내려면 grpc-gateway
라는 걸 사용해야 하는데요. 저는 그냥 REST API호출을 대신 받는 proxy 서버를 구현해서 이 서버에서 gRPC 통신으로 각 언어 서버에 요청을 보내도록 했습니다.
React 프론트엔드
뭔가 있어보이는 백엔드와는 다르게 프론트엔드는 굉장히 단순합니다. 제게 디자인 재능이 전혀 없다는 것을 깨달은 이후로는 프론트는 기능 구현에만 집중하고 있어서요...ㅠㅠ
왼쪽 입력으로 원본 코드를 받고, 우측에 포맷된 코드를 보여줍니다. 각 언어를 선택할 때마다 기본 저장된 코드 샘플을 불러오고, 오른쪽 드롭다운 메뉴에서 각 언어에 해당하는 포맷터를 선택할 수 있습니다. FORMAT 버튼을 누르면 백엔드 서버로 API 요청이 날아갑니다.
각 컴포넌트는 Material-UI를 사용했습니다. 원래 입력창은 TextField 컴포넌트에 Syntax highlighter인 PrismJS 또는 CodeMirror를 사용하려 했으나, 이를 이용해 새로 컴포넌트를 정의하는 게 Reinventing the wheel인 것 같았습니다. 그래서 코드 에디터 라이브러리인 react-simple-code-editor 를 사용했습니다. 정말 잘한 일인 듯...🤟
k8s 클러스터 구축하고 배포까지
이제 nginx 기반의 프론트엔드를 포함한 각 서버를 도커 이미지로 빌드하고, 쿠버네티스에 배포할 차례입니다. 여기서 문제가 발생합니다. 바로 프론트엔드는 브라우저에서 작동하고, 엄밀히 따지면 k8s 클러스터 외부에 위치합니다. 따라서 브라우저에서 보내는 API 요청은 클러스터 외부에서 보내는 것과 동일합니다. 하지만 클러스터는 내부 클러스터 IP 기반으로 통신하기 때문에 서비스를 외부에 노출해주는 기능이 필요합니다. 이게 바로 쿠버네티스의 Ingress가 하는 일입니다.
Ingress
Ingress는 서비스를 클러스터 외부에서 접근할 수 있도록 하는 게이트웨이 역할과 동시에, 외부에서 들어오는 트래픽을 각 서비스에 분산시키는 로드 밸런서 역할을 수행할 수 있습니다. Ingress의 k8s yaml은 다음과 같이 정의했습니다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gomunkong-frontend-ingress
annotations:
dev.okteto.com/generate-host: "true"
spec:
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gomunkong-frontend-service
port:
number: 80
- path: /api
pathType: Prefix
backend:
service:
name: formatter-service
port:
number: 80
spec
아래 파라미터 중 path.pathType
은 Prefix로 되어있는데, 이러면 하위 경로로 들어오는 트래픽도 모두 해당 경로로 보내준다는 의미입니다. 예를 들면,
/ -> gomunkong-frontend-service
/api -> formatter-service
/api/get -> formatter-service
/api/python/black/import -> formatter-service
이렇게 트래픽이 보내집니다. 따라서 클러스터에는 보통 하나의 Ingress만 있어도 다른 서비스로 트래픽을 보내줄 수 있고, 여기서는 정의하지 않았지만 각 서비스의 호스트(http://api1.com
, https://api2.com
)가 다른 경우도 문제 없이 보낼 수 있습니다.
실제 배포 시에는 Ingress controller가 필요한데 로컬 개발 시에는 minikube의 Ingress addon을 사용하면 쉽게 구성이 가능합니다.
okteto
로컬에서 개발을 마친 클러스터를 이제 클라우드에 올려 실제 서비스를 배포해 보겠습니다. 유명한 클러스터 호스팅 플랫폼은 AWS, Google Cloud Platform이 있지만 오늘은 한정적이지만 무료로 배포가 가능한 okteto 클라우드에 배포해 보겠습니다.
방법은 매우 간단합니다. 깃허브 레포에 yaml 파일을 올려두면 okteto에서 알아서 파일을 가지고 클러스터에 배포를 시작합니다. Ingress controller도 알아서 붙여주고 엔드포인트 URL도 만들어줍니다. 개발자 입장에서 해야 하는 일은 Ingress 정의에 다음 annotation을 추가하는 것 뿐입니다.
metadata:
name: gomunkong-frontend-ingress
annotations:
dev.okteto.com/generate-host: "true"
배포가 완료된 화면입니다.
마치면서
도커, 쿠버네티스는 많은 분들이 중요성도 알고, 배우고는 싶어하지만 막상 기회가 없으면 시작하기 어려운 기술 중 하나인 것 같습니다. 왜냐하면 제가 그랬거든요. 도커와 쿠버네티스의 개념 자체가 어렵고 복잡하다 보니, 실제로 뭔가 해보려고 하면 굉장히 막막합니다. 그렇지만 꾹 참고 개발해보니 정말 좋았습니다. 개발 중에도 pod가 오류로 꺼져도 알아서 replicaset대로 복구해주고, 이미지 업데이트도 간편합니다. 아직 쿠버네티스에 대해 모르는 점이 많지만 gRPC와 함께 MSA를 더 많이 만들어보면서 실력을 키워야겠다고 다짐하게 되네요.