OVER THE CODE

Go + Terraform + AWS Lambda + API gateway 튜토리얼

May 21, 2020

이 글에서 Go로 간단한 API 서비스를 만들어볼 것이다. AWS lambda에 서비스를 올린 뒤 API-gateway에 커스텀 도메인 HTTPS를 적용해 요청을 처리하고자 한다. 그리고 이 모든 것들을 손쉽게 배포하기 위해 AWS웹 콘솔로 직접 인프라를 구성하지 않고 Terraform을 사용할 것이다. 만들고자 하는 것은 간단한 노트 서비스 백엔드이다. 제목과 내용을 가진 문서에 대한 CRUD및 리스트 기능을 제공한다. 사용자 인증은 다루지 않는다.

Note: 이 글은 마이크로서비스 아키텍처 설계에 관한 글이 아니다. AWS-lambda를 사용하고 DynamoDB로 데이터를 관리하지만 API 엔드포인트마다 람다 핸들러를 분리하지 않는다. 대신 aws-lambda-go-api-proxy 라이브러리를 사용해서 stand-alone 서버를 람다에 올릴 것이다. 이는 마이크로서비스라 부를 수 없다.

준비물

본 튜토리얼을 시작하기 위해 다음과 같은 준비물이 필요하다. 사전 지식은 따로 필요하지 않다. Hands-on을 잘 따라오면 누구나 동일한 결과를 얻을 수 있도록 작성되었다. 각 챕터별 중간 결과물은 go-note-api 깃허브에서 확인할 수 있으며 참고사항들은 챕터 마지막에 적혀있다.

  • Go 개발 환경 (version 1.13이상)
  • AWS 계정, git 계정
  • Route53에 등록되어있는 도메인
  • Terrafrom (version 0.12.x 이상), aws-cli
  • 터미널 환경 (bash, zsh …etc)

1. 프로젝트 세팅

터미널을 열고 적당한 위치에 가서 폴더를 만들고 Go를 초기화해주자. 프로젝트 이름은 간단하게 go-note-api라고 하겠다. 패키지 이름은 그렇게 중요하진 않다. 우리는 모듈을 따로 분리할게 아니라 main 패키지에서만 작업할 것이기 때문이다. 하지만 이 프로젝트를 확장해서 실제 서비스로 만들고 싶다면 정확한 git URL을 적어주도록 하자.

$ mkdir go-note-api && cd $_
$ go mod init github.com/your-git-account/go-note-api

스크립트 재사용을 위한 Makefile과 프로그램 시작점이 될 main.go를 생성하자.

# Makefile
# Go parameters
GOCMD=go
GOBUILD=$(GOCMD) build
GOCLEAN=$(GOCMD) clean
GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get
BINARY_NAME=main

all: test build
build:
	$(GOBUILD) -o $(BINARY_NAME) -v
test:
	$(GOTEST) -v ./...
clean:
	$(GOCLEAN)
	rm -f $(BINARY_NAME)
// main.go
package main

import "fmt"

func main() {
	fmt.Println("go-note-api")
}

잘 저장했으면, 다음 커맨드를 입력해서 실행이 잘 되는지, 컴파일이 잘 되는지 확인하자.

$ go run .
go-note-api

$ make build
$ ./main
go-note-api

Note:

  • 프로젝트 구조는 go-note-api/tree/v0.1.0 에서 확인할 수 있다.
  • Makefile은 들여쓰기에 space가 아닌 tab을 사용한다. 혹시 에러가 난다면 확인해보자.
  • GOPATH가 가리키는 폴더 내부에서는 go module을 사용할 수 없다. GOPATH 바깥에 프로젝트를 만들자.
  • 본인의 도메인에 API를 연결하고 싶으면 미리미리 인증서를 받아두자. 인증서를 발급받는데 시간이 좀 걸리는 편이다. 방법은 A1에서 확인할 수 있다.

2. Terraform 세팅

테라폼은 AWS, Azure, GDP등 클라우드 인프라 설정을 코드로 관리할 수 있도록 도와주는 도구이다. terraform.io/download 에서 다운받을 수 있다. 테라폼 코드는 Go 코드와 분리할 것이다. 필요한 폴더와 파일을 만들어주자.

$ mkdir terraform && cd $_
$ touch data.tf providers.tf terraform.tf

테라폼을 초기화하기 전에 테라폼의 상태파일 terraform.fstate을 저장하기 위한 S3 버킷이 하나 필요하다. 추후 이 버킷에는 람다에 올라갈 바이너리도 저장될 것이다. 적당한 이름의 버킷을 aws-cli로 만들어주자. 여기서는 go-note-api 라는 이름의 버킷을 만들었다.

$ aws s3 mb s3://go-note-api

테라폼을 초기화해주기 위한 코드를 작성해주자. 아래에서 나오는 go-note-api 라는 키워드는 본인이 정한 프로젝트 이름으로 바꿔주면 된다. 버킷 이름에는 방금 생성한 이름을 넣어주자.

// terraform.tf
terraform {
  required_version = ">= 0.12"
}

terraform {
  backend "s3" {
    bucket         = "go-note-api"
    key            = "terraform.tfstate"
    region         = "ap-northeast-2"
    encrypt        = true
    acl            = "private"
  }
}

// data.tf
data "aws_caller_identity" "current" {}

data "aws_s3_bucket" "go-note-api" {
  bucket = "simple-lambda-go"
}

output "account_id" {
  value = data.aws_caller_identity.current.account_id
}

output "s3_bucket" {
  value = data.aws_s3_bucket.simple-lambda-go.bucket
}

// providers.tf
provider "aws" {
  version = "~> 2.7"
  region  = "ap-northeast-2"
}

코드를 저장했다면 다음 커맨드를 입력해 테라폼을 초기화해보자. 초록 글씨와 함께 성공했다는 출력이 나오면 된다. terrafor refresh 커맨드를 입력하면 s3버킷과 계정 아이디가 출력될 것이다.

$ terraform init
Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
...

$ terraform refresh
...
Outputs:

account_id = Some-digits
s3_bucket = go-note-api

Note:

  • 프로젝트 구조는 go-note-api/tree/v0.2.0 에서 확인할 수 있다.
  • aws cli가 설치되어있어야 하며 aws confing 커맨드로 프로필을 설정해주어야 한다.
  • 테라폼은 DynamoDB로 상태변경 잠금을 걸어주는 것이 좋다. State Lock with DynamoDB를 보면 된다.
  • Data.tf의 output은 불필요한 코드이지만, 제대로 테라폼이 초기화되었는지 확인하기 위해 넣어주었다.

3. Hello World 람다

간단하게 람다에서 실행될 수 있는 Go 코드를 작성할 것이다. 그 전에 Makefile에 다음과 같은 코드를 추가해주자

# Makefile
...
deps:
	$(GOGET) github.com/aws/aws-sdk-go
	$(GOGET) github.com/labstack/echo
	$(GOGET) github.com/aws/aws-lambda-go
	$(GOGET) github.com/awslabs/aws-lambda-go-api-proxy

# Cross compilation
build-linux:
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME) -v

# Package
package: build-linux
	zip lambda.zip main

make deps 커맨드로 필요한 패키지를 설치할 수 있다.

$ make deps

모든 코딩의 시작은 Hello World이다. main.go를 수정해서 람다 핸들러 안에서 동작하는 Hello World 코드를 작성해주자.

// main.go
package main

import (
	"context"

	"github.com/aws/aws-lambda-go/lambda"
)

func HandleRequest(ctx context.Context) (string, error) {
	return "Hello World!", nil
}

func main() {
	lambda.Start(HandleRequest)
}

우리는 이제 이 코드를 람다에 올리기 위해 테라폼으로 람다를 만들어줄 것이다. 그 전에 코드를 빌드하고 바이너리를 압축해주자.

$ make build-linux
$ zip lambda.zip ./main

terraform 폴더에 lambda.tf 파일을 하나 만들어 람다 리소스와 필요한 정책 및 클라우드워치 로그 그룹을 정의하는 코드를 작성해주자

// terraform/lambda.tf
resource "aws_iam_role" "go-note-api" {
  name = "LambdaRole_GoNoteAPI"  # 고유한 이름이어야 한다.

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {"Service": "lambda.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "go-note-api" {
  role       = aws_iam_role.go-note-api.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_lambda_function" "go-note-api" {
  function_name = "go-note-api"
  role          = aws_iam_role.go-note-api.arn
  filename      = "../lambda.zip"  # 우선 로컬에 있는 파일을 직접 업로드한다.
  handler       = "main"
  runtime       = "go1.x"
  memory_size   = 1024
  timeout       = 300

  environment {
    variables = {
      APP_ENV = "production"
    }
  }
}

resource "aws_cloudwatch_log_group" "go-note-api" {
  name = "/aws/lambda/go-note-api"
}

terraform apply 커맨드로 변경사항을 실제 인프라에 적용할 수 있다.

$ terraform apply
data.aws_caller_identity.current: Refreshing state...
data.aws_s3_bucket.go-note-api: Refreshing state...

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_cloudwatch_log_group.go-note-api will be created
  + resource "aws_cloudwatch_log_group" "go-note-api" {
      + arn               = (known after apply)
      + id                = (known after apply)
...

테라폼으로 리소스들이 생성되었다면 aws cli를 사용해 람다가 제대로 작동하는지 확인할 수 있다.

$ aws lambda invoke --function-name go-note-api /dev/stdout
"Hello World!"

앞으로 여러번 람다를 배포할 것인데 이를 자동화 하기 위해 Makefile에 스크립트를 추가해주자

# Makefile
...
S3_BUCKET=go-note-api
LAMBDA_NAME=go-note-api

...

# Package
package:
	zip lambda.zip main

# Upload compiled
deploy: package
	aws s3 cp ./lambda.zip s3://${S3_BUCKET}/${LAMBDA_NAME}/
	aws lambda update-function-code \
		--region ap-northeast-2 \
		--function-name ${LAMBDA_NAME} \
		--s3-bucket ${S3_BUCKET} \
		--s3-key ${LAMBDA_NAME}/lambda.zip --publish

스크립트가 제대로 동작하는지 확인할 수 있도록 main.go에서 Hello WorldHello YourName으로 수정한 후 다시 배포해보자.

$ make build && make deploy
$ aws lambda invoke --function-name go-note-api /dev/stdout
"Hello overthecode!"

Note:

  • 프로젝트 구조는 go-note-api/tree/v0.3.0 에서 확인할 수 있다.
  • make커맨드는 프로젝트 루트에서, terraform 커맨드는 테라폼 폴더에서 실행해야 한다.

4. Echo와 API-gateway

echo 는 Go의 웹 프레임워크중 하나이다. 가벼우면서 강력한 기능들로 인기가 있다. server.go 파일을 만들어 다음과 같이 작성해주자. /note로 요청을 보내면 200 status를 돌려주는 간단한 코드이다. 확장성을 위해 기본 패스를 /note로 만들어주었다.

// server.go
package main

import (
	"net/http"

	"github.com/labstack/echo"
	"github.com/labstack/echo/middleware"
)

func createServer() *echo.Echo {
	e := echo.New()
	e.Use(middleware.CORS())  // 추후에 웹 페이지에서 요청을 날릴 수 있도록 CORS 헤더 추가
	e.GET("/note", func(c echo.Context) error {
		return c.NoContent(http.StatusOK)
	})

	return e
}

그리고 main.go 를 다음과 같이 수정해주자.

package main

import (
	"context"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	echoadapter "github.com/awslabs/aws-lambda-go-api-proxy/echo"
	"github.com/labstack/echo"
)

func getHandler(server *echo.Echo) func(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	echoLambda := echoadapter.New(server)
	return func(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
		// If no name is provided in the HTTP request body, throw an error
		return echoLambda.Proxy(req)
	}
}

func main() {
	server := createServer()

	env := os.Getenv("APP_ENV")
	if env == "production" {
		lambda.Start(getHandler(server))
	} else {
		server.Logger.Fatal(server.Start(":3201"))
	}
}

APP_ENV환경변수가 production일 때는 람다 핸들러가 실행되고 그렇지 않은 경우 로컬에서 일반 서버처럼 3201 포트로 요청을 수신한다. go run . 커맨드로 서버를 실행하고 curl로 요청을 보내 확인해보자.

$ curl -I -X GET http://localhost:3201/note
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Vary: Origin
Date: Wed, 20 May 2020 01:50:12 GMT
Content-Length: 0

이제 make build-linux && make deploy 커맨드로 코드를 배포한 뒤 람다에 HTTP요청을 보낼 수 있는 API gateway를 만들 것이다. api-gateway-api.tf파일과 api-gateway-method.tf파일을 다음과 같이 작성해주자.

// api-gateway-api.tf
resource "aws_api_gateway_rest_api" "go-note-api" {
  name        = "go-note-api"
}

// api-gateway-method.tf
resource "aws_api_gateway_resource" "note" {
  rest_api_id = aws_api_gateway_rest_api.go-note-api.id
  parent_id   = aws_api_gateway_rest_api.go-note-api.root_resource_id
  path_part   = "note"
}

resource "aws_api_gateway_method" "note" {
  rest_api_id   = aws_api_gateway_rest_api.go-note-api.id
  resource_id   = aws_api_gateway_resource.note.id
  http_method   = "ANY"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "note" {
  rest_api_id             = aws_api_gateway_rest_api.go-note-api.id
  resource_id             = aws_api_gateway_resource.note.id
  http_method             = aws_api_gateway_method.note.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = "arn:aws:apigateway:ap-northeast-2:lambda:path/2015-03-31/functions/${aws_lambda_function.go-note-api.arn}/invocations"
}

resource "aws_api_gateway_resource" "note-proxy" {
  rest_api_id = aws_api_gateway_rest_api.go-note-api.id
  parent_id   = aws_api_gateway_resource.note.id
  path_part   = "{proxy+}"
}

resource "aws_api_gateway_method" "note-proxy" {
  rest_api_id   = aws_api_gateway_rest_api.go-note-api.id
  resource_id   = aws_api_gateway_resource.note-proxy.id
  http_method   = "ANY"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "note-proxy" {
  rest_api_id             = aws_api_gateway_rest_api.go-note-api.id
  resource_id             = aws_api_gateway_resource.note-proxy.id
  http_method             = aws_api_gateway_method.note-proxy.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = "arn:aws:apigateway:ap-northeast-2:lambda:path/2015-03-31/functions/${aws_lambda_function.go-note-api.arn}/invocations"
}

위 코드는 go-note-api라는 이름의 api-gateway를 만들고 그 아래 note라는 이름의 리소스(패스)와 그 아래 요청을 프록시해주는 코드이다. 그리고 api-gateway로 들어오는 요청은 go-note-api람다로 보내진다.

마지막으로 lambda.tf에 api-gateway가 람다로 요청을 보낼 수 있는 권한을 부여하는 코드를 추가한 뒤 terraform apply커맨드로 변경사항을 적용시키자.

// lambda.tf
...
resource "aws_lambda_permission" "go-note-api" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.go-note-api.arn
  principal     = "apigateway.amazonaws.com"
  source_arn    = "arn:aws:execute-api:ap-northeast-2:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.go-note-api.id}/*/*"
}

브라우저를 열고 AWS console에 로그인 한 뒤 API Gateway > go-note-api > 리소스 > ANY-메서드 테스트로 가서 GET으로 요청을 보내보자. 200코드로 응답한다면 성공한 것이다.

api-gateway
api-gateway

Note:

  • 프로젝트 구조는 go-note-api/tree/v0.4.0 에서 확인할 수 있다.
  • Echo의 최신 버전은 v4이다. 하지만 글을 작성하는 시점에선 aws-lambda-go-api-proxy 가 echo/v3까지만 지원하고 있다. 이 튜토리얼에서 사용하는 echo API는 v4와 호환되기 때문에 추후 업데이트에 맞춰 echo 버전도 올려주면 된다.

5. Put Item

아무런 가치가 없는 앱을 쓸모있게 바꿀 것이다. 우리가 만들 서비스는 note CRUD서비스이다. note는 title과 content를 가지고 있는 레코드이며, 유저와 아이디가 복합 키를 구성한다. 데이터는 DynamoDB에 저장될 것이다. DynamoDB 테이블을 하나 만들자. terraform apply는 잊으면 안된다.

// dynamodb.tf
resource "aws_dynamodb_table" "go-note-api" {
  name           = "go-note-api"
  billing_mode   = "PAY_PER_REQUEST"

  hash_key       = "user"
  range_key      = "id"

  attribute {
    name = "user"
    type = "S"
  }

  attribute {
    name = "id"
    type = "S"
  }
}

table.go 파일에 다음 코드를 작성해주자. 실제 레코드의 구조와 무관하게 DynamoDB PutItem 요청을 보낼 수 있는 코드이다. DynamoDB 세션은 다른 코드에서 초기화해줄 것이다.

// table.go
package main

import (
	"fmt"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

type Table struct {
	name string
	db   *dynamodb.DynamoDB
}

// Create session.
func (t *Table) Init(name string) {
	db := dynamodb.New(session.New(), &aws.Config{Region: aws.String("ap-northeast-2")})
	t.name = name
	t.db = db
}

// Put item into dynamodb table
func (t *Table) PutItem(item interface{}) error {

	av, err := dynamodbattribute.MarshalMap(item)
	if err != nil {
		fmt.Println("Got error marshalling attribute item:")
		fmt.Println(err.Error())
		return err
	}

	input := &dynamodb.PutItemInput{
		Item:      av,
		TableName: aws.String(t.name),
	}

	_, err = t.db.PutItem(input)
	if err != nil {
		fmt.Println("Got error PutItem:")
		fmt.Println(err.Error())
		return err
	}
	return nil
}

note.go 는 실제로 note 구조체와 상호작용할 수 있는 코드로 구성되어있다. DynamoDB는 rangeKey(여기서는 note.Id)로 레코드를 정렬하므로 miilisecond 정확도의 유닉스 타임으로 id를 만들어준다. 이 디자인은 1ms 내에 복수개의 요청이 들어오면 오작동할 것이다.

// note.go
package main

import (
	"strconv"
	"time"
)

var noteTable *Table

func init() {
	noteTable = &Table{}
	noteTable.Init("go-note-api")  // 앞서 만들어준 table이름을 넣어준다.
}

type note struct {
	User      string    `json:"user"`
	Id        string    `json:"id"`
	Title     string    `json:"title"`
	Content   string    `json:"content"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

type NoteKey struct {
	Id   string `json:"id"`
	User string `json:"user"`
}

func createNote(user string, m note) (*note, error) {

	_m := note{
		User:      user,
		Content:   m.Content,
		Id:        strconv.FormatInt(time.Now().UTC().UnixNano()/1000, 10),
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
		Title:     m.Title,
	}

	err := noteTable.PutItem(_m)
	if err != nil {
		return nil, err
	}
	return &_m, nil
}

그리고 server.go에 POST요청을 보낼 수 있는 라우터를 추가한다.

// server.go
...
func createServer() *echo.Echo {

  ...

	e.POST("/note/:user", func(c echo.Context) error {
		user := c.Param("user")
		body := &note{}
		if err := c.Bind(body); err != nil {
			return err
		}

		note, err := createNote(user, *body)
		if err != nil {
			c.Error(err)
		}

		return c.JSON(http.StatusCreated, note)
	})

	return e
}

go run .커맨드로 서버를 실행한 후 curl로 커맨드를 테스트해보자. 테스트 커맨드는 다음과 같이 입력하면 된다.

$ curl -X POST -H "Content-Type: application/json" -d \
  '{ "title": "untitled", "content": "cat is awesome" }' \
  http://localhost:3201/note/user1

Id와 timestamp를 포함한 응답이 올 것이다. DynamoDB console을 열어 항목이 제대로 추가됐는지 확인해보자.

dynamodb
dynamodb

챕터를 마무리하기에 앞서 한가지 잊은 것이 있다. 바로 람다에 DynamoDB 접근 권한을 주는 것이다. lambda.tf 파일에 다음과 같은 코드를 추가하고 테라폼 커맨드로 적용시키자. 최소 권한을 부여하는 것이 원칙이지만 확장가능한 범위 내에서 충분히 안전한 권한을 부여했다.

// lambda.tf
...
resource "aws_iam_policy" "go-note-api-dynamodb" {
  name = "LambdaPolicy_GoNoteAPI"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:PutItem",
        "dynamodb:DeleteItem",
        "dynamodb:Scan",
        "dynamodb:Query",
        "dynamodb:UpdateItem",
        "dynamodb:ListTable",
        "dynamodb:DescribeTable",
        "dynamodb:GetItem",
        "dynamodb:DescribeLimits",
        "dynamodb:GetRecords"
      ],
      "Resource": "arn:aws:dynamodb:ap-northeast-2:${data.aws_caller_identity.current.account_id}:table/go-note-api"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "go-note-api-dynamodb" {
  role       = aws_iam_role.go-note-api.name
  policy_arn = aws_iam_policy.go-note-api-dynamodb.arn
}

Note:

6. Custom Domain and Deployment

서비스의 CRUD를 마무리 하기 전에 커스텀 도메인으로 요청을 받을 수 있도록 설정하고 HTTPS를 적용할 것이다. 아직까지 인증서를 받지 못했다면 A1로 가서 인증서를 발급받도록 하자. 이 챕터를 진행하기 위해서 필요하다.

인증서를 발급받았으면 해당 인증서와 도메인을 테라폼의 data source로 사용할 수 있다. data.tf를 열고 다음 코드를 추가하자. 그리고 output은 더이상 필요없으니 지워주도록 하겠다.

// data.tf
...
- output "account_id" {
-   value = data.aws_caller_identity.current.account_id
- }

- output "s3_bucket" {
-   value = data.aws_s3_bucket.go-note-api.bucket
- }

data "aws_route53_zone" "go-note-api" {
  name = "overthecode.io."  // 본 서비스에 적용할 루트 도메인.
}

data "aws_acm_certificate" "go-note-api" {
  domain      = "overthecode.io"
  types       = ["AMAZON_ISSUED"]
  most_recent = true
}

terraform refresh로 Route53 호스팅존과 인증서를 접근할 수 있는지 알 수 있다.

지금까지 만든 API를 stage에 올려 배포해야 한다. api-gateway-api.tf를 열어 다음 코드를 추가하자. stage_name"v1"로 정했다. deployment에서 stage_name을 적어주지 않는 이유는 deployment가 처음 생성되면서 stage가 동시에 생성되는데 이 때 아래 정의해준 stage 리소스와 충돌이 일어난다. 잘 알려진 이슈지만 아직 해결이 안되고 있다.

// api-gateway-api.tf
...
resource "aws_api_gateway_deployment" "go-note-api" {
  rest_api_id = aws_api_gateway_rest_api.go-note-api.id
  stage_name  = ""
}

resource "aws_api_gateway_stage" "go-note-api" {
  depends_on = [aws_api_gateway_deployment.go-note-api]

  stage_name           = "v1"
  rest_api_id          = aws_api_gateway_rest_api.go-note-api.id
  deployment_id        = aws_api_gateway_deployment.go-note-api.id
}

새 파일 api-gateway-domain.tf를 만들어 API-gateway에 붙을 커스텀 도메인을 정의해주자.

// api-gateway-domain.tf
resource "aws_api_gateway_domain_name" "go-note-api" {
  domain_name              = "api.overthecode.io"  // api 엔드포인트로 사용할 서브도메인
  regional_certificate_arn = data.aws_acm_certificate.go-note-api.arn

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

resource "aws_route53_record" "go-note-api" {
  name    = aws_api_gateway_domain_name.go-note-api.domain_name
  type    = "A"
  zone_id = data.aws_route53_zone.go-note-api.id

  alias {
    evaluate_target_health = true
    name                   = aws_api_gateway_domain_name.go-note-api.regional_domain_name
    zone_id                = aws_api_gateway_domain_name.go-note-api.regional_zone_id
  }
}

resource "aws_api_gateway_base_path_mapping" "go-note-api" {
  api_id      = aws_api_gateway_rest_api.go-note-api.id
  stage_name  = "v1"  // stage 이름을 v1로 정했었다.
  domain_name = aws_api_gateway_domain_name.go-note-api.domain_name
}

테라폼을 적용시키고 테스트를 해보자. 그 전에 지금까지 만들어두었던 코드를 make build-liux && make deploy로 람다에 올리는 것을 잊으면 안된다.

$ curl -X POST -H "Content-Type: application/json" -d \
  '{ "title": "hello", "content": "world!" }' \
  https://api.overthecode.io/note/user1

응답이 제대로 온다면 DynamoDB 콘솔에서 새로운 레코드가 생긴 것을 확인할 수 있다.

Note:

7. Read, Update, Delete

벌써 마지막 챕터이다. 다들 잘 따라왔으면 좋겠지만 그렇지 못한 경우도 많을 것이다. 이 튜토리얼에 관한 질문은 답글이나 메일로 남겨준다면 빠른 시일 내에 답장하고 해당 질문을 부록에 추가할 것이다.

지금 우리가 작성한 코드는 CRUD에서 Create밖에 하지 못한다. 나머지 RUD를 완성할 것이다. 정확히는 List(getAll)와 Read(getOne)이 따로 있어야 하겠지만, 한가지 레코드만 읽어 오는 것은 당장 꼭 필요한 요소가 아니므로 제외하였다. DynamoDB는 파티션과 레인지 복합 키로 작동하는 DBMS이며 Query요청에 최대 1MB의 데이터를 읽어올 수 있다. 즉 데이터 양이 많아지면 한번에 모든 레코드를 가져올 수 없기 때문에 페이지네이션을 해야하고 이에 관한 코드도 함께 보도록 하자.

table.go를 열어 ListItem 함수를 추가하자.

// table.go
...

// Query table with hash key and pagination.
func (t *Table) ListItem(hkName string, hkValue string, paginated bool, from interface{}) (*dynamodb.QueryOutput, error) {
	hash := expression.Key(hkName).Equal(expression.Value(hkValue))
	expr, err := expression.NewBuilder().WithKeyCondition(hash).Build()

	if err != nil {
		fmt.Println("Got error building expression:")
		fmt.Println(err.Error())
		return nil, err
	}

	query := dynamodb.QueryInput{
		KeyConditionExpression:    expr.KeyCondition(),
		ExpressionAttributeValues: expr.Values(),
		ExpressionAttributeNames:  expr.Names(),
		TableName:                 aws.String(t.name),
		Limit:                     aws.Int64(5),  // 한번에 다섯개 까지 읽어올 수 있다.
	}                                           // 페이지네이션 테스트를 위해 값을 작게 설정했다.

	if paginated {
		exkey, err := dynamodbattribute.MarshalMap(from)

		if err != nil {
			fmt.Println("Got error building ExclusiveStartKey:")
			fmt.Println(err.Error())
			return nil, err
		} else {
			query.ExclusiveStartKey = exkey
		}
	}

	result, err := t.db.Query(&query)

	if err != nil {
		fmt.Println("Got error while query:")
		fmt.Println(err.Error())
		return nil, err
	}

	return result, nil
}

해쉬(파티션) 키 이름과 값을 받고 페이지네이션 여부, 쿼리 시작 지점의 레인지(소트) 키를 받을 수 있도록 추상화되어있다. 이 함수는 복합키를 가진 임의의 테이블에서 잘 작동할 것이다.

note.go 파일에 noteQueryResult 구조체와 getNotes 함수를 추가하자.

// note.go
...

type noteQueryResult struct {
	Notes            []note  `json:"notes"`
	Count            int64   `json:"count"`
	ScannedCount     int64   `json:"scannedCount"`
	LastEvaluatedKey NoteKey `json:"lastEvaluatedKey"`
}

func getNotes(user string, from string) (*noteQueryResult, error) {
	result, err := noteTable.ListItem("user", user, from != "", NoteKey{User: user, Id: from})
	if err != nil {
		return nil, err
	}

	notes := make([]note, len(result.Items))
	for i, v := range result.Items {
		dynamodbattribute.UnmarshalMap(v, &notes[i])
	}

	lastEvaluatedKey := NoteKey{}

	if result.LastEvaluatedKey != nil {
		dynamodbattribute.UnmarshalMap(result.LastEvaluatedKey, &lastEvaluatedKey)
	}

	noteQueryResult := noteQueryResult{
		Notes:            notes,
		Count:            *result.Count,
		ScannedCount:     *result.ScannedCount,
		LastEvaluatedKey: lastEvaluatedKey,
	}

	return &noteQueryResult, nil
}

server.gocreateServer 함수 아래에 라우트를 추가해주면 완성이다.

// server.go
...
func createServer() *echo.Echo {
  ...
  e.GET("/note/:user", func(c echo.Context) error {
    user := c.Param("user")
	  id := c.QueryParam("from")
	  notes, err := getNotes(user, id)

    if err != nil {
		 	c.Error(err)
	  }
    return c.JSON(http.StatusOK, notes)
  })

  return e
}

우선 로컬에서 테스트를 해본 뒤, make build-linux && make deploy로 서버에 올려보자. user1에 대한 노트 리스트가 JSON 포맷으로 출력될 것이다.

$ curl -X GET http://localhost:3201/note/user1
...
$ make build-linux && make deploy
$ curl -X GET https://api.overthecode.io/note/user1
...

출력된 JSON에 lastEvaluatedKey가 있는데 값이 비어있을 것이다. 그리고 scannedCount 값이 우리가 limit으로 설정한 5보다 작은데 이는 레코드 숫자가 적어 한번에 모든 레코드를 가져왔다는 의미이다. (count값은 filterExpression이 적용된 이후의 레코드 숫자이기 때문에 여기서는 의미가 없다) 페이지네이션 테스트를 위해 몇개 더 레코드를 추가한 다음에 테스트해보자. 레코드가 최소한 여섯개 이상이 되어야 한다.

$ curl -X POST -H "Content-Type: application/json" -d \
  '{ "title": "hello2", "content": "world2" }' \
  https://api.overthecode.io/note/user1
$ curl -X POST -H "Content-Type: application/json" -d \
  '{ "title": "hello3", "content": "world3" }' \
  https://api.overthecode.io/note/user1
...

다시 GET 요청을 보내 레코드를 받아오면 이번엔 lastEvaluatedKey에 값이 들어가 있는 것을 확인할 수 있다.

lastEvaluatedKey.id 값을 GET요청의 from 쿼리 파라미터에 추가해주면 해당 id 이후 부터 레코드를 탐색한다.

$ curl -X GET https://api.overthecode.io/note/user1\?from\=1590012352455034

마지막으로 Update와 Delete를 구현해보자. table.go , note.go , server.go 에 각각 코드를 추가한다.

// table.go
...
// Update item in dynamodb table
func (t *Table) UpdateItem(key interface{}, expr expression.Expression) (*dynamodb.UpdateItemOutput, error) {

	k, err := dynamodbattribute.MarshalMap(key)
	if err != nil {
		fmt.Println("Got error marshalling key item:")
		fmt.Println(err.Error())
		return nil, err
	}

	input := &dynamodb.UpdateItemInput{
		Key:                       k,
		ExpressionAttributeNames:  expr.Names(),
		ExpressionAttributeValues: expr.Values(),
		TableName:                 aws.String(t.name),
		ReturnValues:              aws.String("ALL_NEW"),
		UpdateExpression:          expr.Update(),
	}

	result, err := t.db.UpdateItem(input)
	if err != nil {
		fmt.Println("Got error UpdateItem:")
		fmt.Println(err.Error())
		return nil, err
	}

	return result, nil
}

// Delete item in dynamodb table
func (t *Table) DeleteItem(key interface{}) error {  // 에러가 없으면 잘 삭제된 것이다.

	k, err := dynamodbattribute.MarshalMap(key)
	if err != nil {
		fmt.Println("Got error marshalling key item:")
		fmt.Println(err.Error())
		return err
	}

	input := &dynamodb.DeleteItemInput{
		Key:       k,
		TableName: aws.String(t.name),
	}

	_, err = t.db.DeleteItem(input)
	if err != nil {
		fmt.Println("Got error DeleteItem:")
		fmt.Println(err.Error())
		return err
	}

	return nil
}
// note.go
...
func updateNote(user string, id string, m note) (*note, error) {

	update := expression.UpdateBuilder{}
	update = update.Set(expression.Name("updatedAt"), expression.Value(time.Now()))
	if m.Title != "" {
		update = update.Set(expression.Name("title"), expression.Value(m.Title))
	}
	if m.Content != "" {
		update = update.Set(expression.Name("content"), expression.Value(m.Content))
	}

	expr, err := expression.NewBuilder().WithUpdate(update).Build()

	if err != nil {
		fmt.Println("Got error building expression:")
		fmt.Println(err.Error())
		return nil, err
	}

	result, err := noteTable.UpdateItem(NoteKey{Id: id, User: user}, expr)

	if err != nil {
		return nil, err
	}

	updatedNote := note{}
	dynamodbattribute.UnmarshalMap(result.Attributes, &updatedNote)

	return &updatedNote, nil
}

func deleteNote(user string, id string) error {
	if err := noteTable.DeleteItem(NoteKey{Id: id, User: user}); err != nil {
		return err
	}
	return nil
}
// server.go
...
func createServer() *echo.Echo {
  ...
	e.PUT("/note/:user/:id", func(c echo.Context) error {
		user := c.Param("user")
		id := c.Param("id")
		body := &note{}
		if err := c.Bind(body); err != nil {
			return err
		}

		note, err := updateNote(user, id, *body)
		if err != nil {
			c.Error(err)
		}

		return c.JSON(http.StatusCreated, note)
	})

	e.DELETE("/note/:user/:id", func(c echo.Context) error {
		user := c.Param("user")
		id := c.Param("id")
		if err := deleteNote(user, id); err != nil {
			c.Error(err)
		}
		return c.NoContent(http.StatusAccepted)
	})

	return e
}

로컬에서 테스트해도 괜찮고 배포한 다음 테스트를 할 수도 있다.

# 노트를 수정한다.
$ curl -X PUT -H "Content-Type: application/json" -d \
  '{ "title": "this is", "content": "updated" }' \
  http://localhost:3201/note/user1/1590012463321602

# 노트를 삭제한다.
$ curl -X DELETE https://api.overthecode.io/note/user1/1590012463321602

추가로 CORS가 잘 적용되었는지 확인하기 위해 브라우저의 개발자 도구를 열고 자바스크립트의 fetch API로 요청을 보내보자. (사실 CORS 헤더가 잘 붙어있기 때문에 큰 의미는 없다)

fetch('https://api.overthecode.io/note/user1')
  .then(response => response.json())
  .then(data => console.log(data));

이 튜토리얼을 끝까지 잘 따라왔다면 모든 것이 잘 작동하는 것을 확인할 수 있다. 일어나서 가슴을 펴고 고개를 돌려가며 스트레칭을 한번 해주는 것으로 마무리하겠다.

Note:

A1. ACM certificate 생성

AWS 웹 콘솔의 Certificate Manager 서비스로 이동한 다음 새 인증서를 요청하자. 이 때 AWS의 리전은 API-gateway region과 동일해야 한다. 이 튜토리얼은 ap-northeast-2(서울) 리전에서 진행하였다. 도메인이름에 루트 도메인이름(overthecode.io)과 모든 서브도메인(*.overthecode.io)를 추가하면 된다.

DNS검증 방식으로 도메인 소유권을 검증할 수 있는데, 웹 콘솔에서 Route53에 검증 레코드를 추가하는 버튼을 누를 수 있을 것이다. 레코드가 추가된 뒤 약 30분 이내에 인증서가 발급된다.


© Karl Saehun Chung