Goa - Go에서 사용가능한 DSL

Goa는 Go 언어로 된 일종의 도메인 특화 언어(DSL, domain specific language)다. Go 언어로 마이크로서비스 설계(디자인)를 보다 쉽게 할 수 있도록 한다.

Goa를 사용하면 API의 endpoint와 param, service의 method의 input/output 정도만 정의하면 http, grpc로 코드를 생성해주며, 문서(swagger 등)도 자동 생성해준다.

service의 method도 boilerplate 코드는 다 작성되어 있고, 개발자는 method 내부의 비즈니스 로직만 구현하면 된다. (비즈니스 로직을 구현하고 return 값을 구현하면 된다)

Goa의 장점

  • 설계(디자인)에 집중하면 된다.
  • DSL로 된 설계 코드를 보면 서비스를 일목요연하게 이해할 수 있어 보다 쉽게 코드를 이해하고 리뷰할 수 있다.
  • DSL 문서 기반으로 swagger 등 문서화가 자동으로 관리되어 항상 문서가 최신 상태이며, 개발자의 수동적인 수정 작업이 필요하지 않다.
  • 트랜스포트 계층을 고려하지 않고 설계 후 비즈니스 로직 부분만 구현하면 된다.

따라서 목적에 맞게 HTTP든 gRPC든 선택적으로 사용 가능하다. (물론 커스터마이징은 가능하다.)

Goa의 단점

  • swagger를 자동으로 엔드포인트로 제공해주진 않는다. docker container로 따로 띄우거나 추가적인 작업이 필요함.
  • gorma라는 ORM이 존재하나 유지보수가 안되어서 최신 Goa 버전에서는 지원하지 않는다.
  • 결국 design 내에 설계 코드를 자세하게 작성한 만큼 swagger도 자세해 지는 구조이므로, 사실상 주석 기반의 스웨거 문서와 크게 다르지도 않다. (들어가는 공수가 다른 것 같지 않음)
  • Goa를 사용함에 따라 규격화되고 제한되는 것들에 비해 Goa로 얻을 수 있는 것이 제한적이다 (gRPC 정도..)

결론

  1. 빠르게 간단한 프로젝트를 개발한다면 사용할만 하다.
  2. gRPC도 함께 지원해야 한다면 사용할만 하다.
  3. HTTP만 사용한다면, 그냥 딴거 써라. 그게 삶이 편하다.

사용해보기

이 부분은 Goa에서 제공하는 튜토리얼을 그대로 가져왔다. 원문이 궁금하다면 원문을 읽자.

https://goa.design/

사실 말로 설명해서는 이해하기 어려운 부분이 있다. 직접 사용해보면 이해가 쉽다. 여기서는 goa에서 제공하는 튜토리얼을 가져와 소개한다.

목표

  • GET /multiply/{A}/{B} : 정수 A,B에 대해 A * B 결과를 반환.

만약 직접 구현한다면?

  1. HTTP 서버 관련 코드를 직접 구현해야 한다. net/http 혹은 echo같은 라이브러리를 가져오고, 서버 생성 코드를 짜고, 포트번호를 지정해주고, 엔드포인트를 만드는 등의 작업을 해야 한다.
  2. gRPC를 쓰고 싶다면 별도로 코드 작업을 해야 한다.
  3. openapi 등 문서화는 직접 챙겨야 한다.
  4. 디렉토리 구조를 직접 설계해야 한다.

Goa 사용하기

아래 5단계만 거치면 바로 동작하는 서버가 완성된다.

3,4번은 CLI 커맨드로 자동으로 진행하는 것이므로 개발자가 할 일은 1,2번과 5번이다.

  1. API 설계 (DSL)
  2. Service 설계 (DSL)
  3. goa gen으로 보일러플레이트 코드 생성 (CLI)
  4. goa example로 service 구현을 위한 코드 생성 (CLI)
  5. service 안에 비즈니스 로직 구현

먼저 go module을 만들어준다.

go mod init calc

다음으로 디자인 코드를 작성한다.

calc/design/design.go

package design

import . "goa.design/goa/v3/dsl"

// API 서버 생성 관련 로직
var _ = API("calc", func() {
	Title("Calculator Service")
	Description("Service for multiplying numbers")
	Server("calc", func() {
		Host("localhost", func() {
			URI("http://localhost:8081")
			URI("grpc://localhost:18081")
		})
	})
})

// service 메서드와 HTTP, gRPC, 엔드포인트 생성 관련 로직
var _ = Service("calc", func() {
	Description("The calc service performs operations on numbers")

	Method("Multiply", func() {
		Payload(func() {
			Field(1, "a", Int, "Left operand")
			Field(2, "b", Int, "Right operand")
			Required("a", "b")
		})

		Result(Int)

		HTTP(func() {
			GET("/multiply/{a}/{b}")
		})
		GRPC(func() {})
	})

	Files("/openapi.json", "./gen/http/openapi.json")
})

이렇게 작성한 후 커멘드라인에서 goa gen 명령을 실행한다.

goa gen calc/design

명령을 수행하면 자동으로 코드가 생성되는데, tree명령어로 구조를 살펴보면 아래와 비슷할 것이다.

.
├── design
│   └── design.go
├── gen
│   ├── calc
│   │   ├── client.go
│   │   ├── endpoints.go
│   │   └── service.go
│   ├── grpc
│   │   ├── calc
│   │   │   ├── client
│   │   │   │   ├── cli.go
│   │   │   │   ├── client.go
│   │   │   │   ├── encode_decode.go
│   │   │   │   └── types.go
│   │   │   ├── pb
│   │   │   │   ├── goadesign_goagen_calc.pb.go
│   │   │   │   ├── goadesign_goagen_calc.proto
│   │   │   │   └── goadesign_goagen_calc_grpc.pb.go
│   │   │   └── server
│   │   │       ├── encode_decode.go
│   │   │       ├── server.go
│   │   │       └── types.go
│   │   └── cli
│   │       └── calc
│   │           └── cli.go
│   └── http
│       ├── calc
│       │   ├── client
│       │   │   ├── cli.go
│       │   │   ├── client.go
│       │   │   ├── encode_decode.go
│       │   │   ├── paths.go
│       │   │   └── types.go
│       │   └── server
│       │       ├── encode_decode.go
│       │       ├── paths.go
│       │       ├── server.go
│       │       └── types.go
│       ├── cli
│       │   └── calc
│       │       └── cli.go
│       ├── openapi.json
│       ├── openapi.yaml
│       ├── openapi3.json
│       └── openapi3.yaml
├── go.mod
└── go.sum

이 많은 파일들을 직접 생성해준 것을 알 수 있다.

디렉토리 별 내부 파일

  • calc: 전송(transport)과 무관한 서비스코드가 담기는 디렉토리다.
  • grpc: protocol buffer 파일과 gRPC 서버, 클라이언트 코드가 담긴다.
  • http: http 전송과 관련된 서버, 클라이언트 코드가 담긴다. 더불어 Open API 2.0 코드도 담긴다.

gen 디렉토리는 goa gen 명령어 실행시마다 새롭게 생성된다. 따라서 gen 디렉토리 내부 파일은 수정하지 않는 것이 좋다. 수정 내용이 보존되지 않기 때문이다.

다음으로 goa example로 service 코드를 구현하기 위한 보일러플레이트 코드를 생성해보자.

goa example calc/design

명령 실행시 추가적으로 생성된 파일들은 아래와 같다.

├── calc.go
├── cmd
│   ├── calc
│   │   ├── grpc.go
│   │   ├── http.go
│   │   └── main.go
│   └── calc-cli
│       ├── grpc.go
│       ├── http.go
│       └── main.go

goa example로 생성된 파일들은 필요에 따라 수정해 사용하는 파일들이다.

calc.go는 service의 메서드가 위치하는 곳으로, 비즈니스 로직을 담는 곳이다.

calc.go 파일을 열고 아래 코드와 같이 Multiply 메서드의 return 문을 수정해준다.

// Multiply implements Multiply.
func (s *calcsrvc) Multiply(ctx context.Context, p *calc.MultiplyPayload) (res int, err error) {
	s.logger.Print("calc.Multiply")
	return p.A * p.B, nil // 이 부분 수정
}

param으로 A, B 값을 받으면 A*B 값을 반환하게 했다.

이제 서버를 돌려보자.

go build ./cmd/calc && go build ./cmd/calc-cli
./calc

GET /multiply/10/15를 해보면 150이 표시되는 것을 확인할 수 있다.