1달러로 AWS기반의 웹서비스 배포한 배포기에 대해 이야기합니다.

이번에 사용하게 된 AWS 서비스는 위와 같다. 1달러 중에서 대부분의 비용이 Route 53과 Secrets Manager에 치중이 되어있다. Secrets Manager를 사용하지 않으면, 코드에 키를 하드코딩하고 GitHub같은 곳에는 코드를 푸시할 수 없기때문에 상당히 귀찮아 진다. Vault같은 옵션도 있지만 결국 서버가 필요하게 되니 비용적인 측면에서 Secrets Manager를 결정하게 되었는데 아쉽게도 0.39달러나 나왔다.

도메인은 15000원에 가비아에서 구입을 했다. 도메인 구입비용은 제외를 했고, AWS 서비스 사용비용만 이야기했다. 가비아에서 구매한 도메인을 Amplify에 연결해주기 위해서 Route 53을 활용했고 현재까지는 제일 큰 비용이 들어가는 서비스 중 하나다.

Frontend

프론트엔드는 Next를 사용하고 싶었다. 사용해본 프론트엔드 라이브러리 중에서는 React만한게 없다는 판단이었고, 커뮤니티도 크고 쉽고 간편하기 때문이다. 처음에는 Vercel과 고민을 많이 했었다. Vercel도 비용이 많이 들지는 않는다고 판단했기 때문이다.

결국 Amplify를 선택하게 된 큰 이유는 Cognito다. 서비스에는 인증이 들어가야 하는데, Amplify와 Cognito를 같이 사용하게 되면 인증을 간편하게 셋업할 수 있고, 유저도 유저풀에서 관리할 수 있다.

또한 Cognito를 사용하면 이점은 백엔드에서 API Gateway를 사용하기로 결정했고, API콜 인가에 대해서 Cognito 인증을 활용할 수 있는 점이었다.

Amplify

Amplify의 소스 레포지토리는 GitHub를 사용했다. CodeCommit을 사용할수도 있었지만 GitHub를 더 좋아하기 때문에 GitHub로 세팅했다.

resource "aws_amplify_app" "mzz" {
  name       = var.service_name
  repository = var.mzz_secret["GITHUB_REPO_URL"]

  # GitHub personal access token
  access_token = var.mzz_secret["GITHUB_ACCESS_TOKEN"]
  platform     = "WEB_COMPUTE"

  # The default build_spec added by the Amplify Console for React.
  build_spec = <<-EOT
version: 1
frontend:
    phases:
        preBuild:
            commands:
                - npm ci
        build:
            commands:
                - npm run build
    artifacts:
        baseDirectory: .next
        files:
            - '**/*'
    cache:
        paths:
            - node_modules/**/*
EOT

}

위와 같이 app을 작성해주고

resource "aws_amplify_branch" "main" {
  app_id      = aws_amplify_app.mzz.id
  branch_name = "main"

  framework         = "Next.js - SSR"
  stage             = "PRODUCTION"
  enable_auto_build = true

  # next app environment_variables
  environment_variables = {
    NEXT_PUBLIC_MZZ_ENV                 = "prod"
    NEXT_PUBLIC_MZZ_BACKEND_ENDPOINT    = var.mzz_secret_prod["NEXT_PUBLIC_BACKEND_ENDPOINT"]
    NEXT_PUBLIC_MZZ_USER_POOL_ID        = var.mzz_secret_prod["NEXT_PUBLIC_MZZ_USER_POOL_ID"]
    NEXT_PUBLIC_MZZ_USER_POOL_CLIENT_ID = var.mzz_secret_prod["NEXT_PUBLIC_MZZ_USER_POOL_CLIENT_ID"]
  }
}

resource "aws_amplify_branch" "dev" {
  app_id      = aws_amplify_app.mzz.id
  branch_name = "dev"

  framework         = "Next.js - SSR"
  stage             = "DEVELOPMENT"
  enable_auto_build = true

  # next app environment_variables
  environment_variables = {
    NEXT_PUBLIC_MZZ_ENV                 = "dev"
    NEXT_PUBLIC_MZZ_BACKEND_ENDPOINT    = var.mzz_secret["NEXT_PUBLIC_BACKEND_ENDPOINT"]
    NEXT_PUBLIC_MZZ_USER_POOL_ID        = var.mzz_secret["NEXT_PUBLIC_MZZ_USER_POOL_ID"]
    NEXT_PUBLIC_MZZ_USER_POOL_CLIENT_ID = var.mzz_secret["NEXT_PUBLIC_MZZ_USER_POOL_CLIENT_ID"]
  }
}

resource "aws_amplify_domain_association" "root_domain" {
  app_id      = aws_amplify_app.mzz.id
  domain_name = var.domain_name

  sub_domain {
    branch_name = aws_amplify_branch.main.branch_name
    prefix      = ""
  }

  sub_domain {
    branch_name = aws_amplify_branch.main.branch_name
    prefix      = "www"
  }
}

위와 같이 작성하여 SSR이 가능한 Next.js 앱을 배포해주는 브랜치와 도메인 리소스를 작성해주었다. 각 브랜치에 들어가는 환경변수에 대해서는 Secret Manager를 활용해 입력해주도록 했다.

개발환경과 상용환경을 나누는 것이 언제나 고민이게 된다. Amplify는 브랜치로 나누어서 dev 브랜치는 개발환경으로 배포되고 main브랜치는 상용환경으로 배포될 수 있도록 설정할 수 있어서 편했다. 도메인은 상용환경에만 달아주었다.

Cognito

인증은 간편하게 구현하고 싶어서 Cognito를 선택했다. 만약 유저 관리를 DB에서 따로 진행한다면 Cognito는 아마 좋은 옵션이 아닐 수 있다. Cognito를 선택했다면 유저풀을 활용하는 것이 좋고, 제공해주는 API를 사용하면 좋다.

resource "aws_cognito_user_pool" "pool" {
  name = "${var.service_name}_${var.environment}"

  auto_verified_attributes = ["email"]
  tags                     = var.aws_common_tags
}

resource "aws_cognito_user_pool_client" "client" {
  name = "${var.service_name}_${var.environment}_client"

  user_pool_id    = aws_cognito_user_pool.pool.id
  generate_secret = false
}

위와 같이 유저풀과 클라이언트를 만들어서 활용해주었다. 위 리소스를 다음과 같이 모듈화를 하여 개발과 사용에서 사용할 유저풀을 분리해주었다.

module "cognito" {
  source = "./cognito"

  service_name    = local.service_name
  aws_common_tags = local.aws_common_tags
  environment     = "dev"
}

module "cognito-prod" {
  source = "./cognito"

  service_name    = local.service_name
  aws_common_tags = local.aws_common_tags
  environment     = "prod"
}

Backend

백엔드에서 가장 중요한 두 가지는 DBAPI서버다. API를 서버로 올리게되면 서버비용이 만만치 않게 들어간다. AWS EC2만 올린다고 하더라도 매달 나가는 비용이 만만치 않다. 그래서 서버리스로 API를 작업하는 것을 택했다.

DB도 마찬가지로 결국 서버가 필요하다. 그리고 DB는 사이즈가 커질수록 큰 스펙을 요구하기 때문에 비용을 많이 잡아먹는 서버 중에 하나다. DB서버를 사용하게 되면 만만치 않은 비용을 감당해야할 수 밖에 없다. 그래서 Notion Database를 메인 DB로 사용하는 방안을 택했다.

Notion DB

노션 DB를 선택하기까지 고민이 좀 되었던 것 같다. DB라고 하면 SQL을 활용해 데이터를 쿼리하는 것에 익숙해져있는 상태였기 때문이다. 하지만 직접 사용해보니 생각보다 나쁘지 않은 옵션이었다.

비용과 관련해서는 1초에 3번까지 API콜을 할 수 있다는 단점만 빼고서는 괜찮았던 것 같다. 노션 DB는 1초에 3번까지만 API를 날릴 수 있기 때문에 프론트엔드, 백엔드에서 API를 호출하는 횟수를 줄이기 위해 최적화 하고 캐싱하는 습관이 생겨서 오히려 좋았던 것 같다.

서비스에서 게이트웨이 함수로 Golang기반의 람다 함수를 사용했기 때문에 Golang기반으로 노션을 다음과 같이 활용해주게 되었다.

package main

import (
	"context"
	"encoding/json"
	"os"

	"post_community_comment/pkg/service"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/jomei/notionapi"

	log "github.com/sirupsen/logrus"
)

type PostCommunityComment struct {
	Title           string `json:"title"`
	UserId          string `json:"userId"`
	Nickname        string `json:"nickname"`
	CommunityPageId string `json:"communityPageId"`
}

func main() {
	if os.Getenv("ENV") == "DEBUG" {
		log.SetLevel(log.DebugLevel)

		s := string(`{"title": "test2", "nickname": "testestset", "userId": "test2", "communityPageId": "exampl_key_asdfasdfasfsd"}`)
		data := PostCommunityComment{}
		json.Unmarshal([]byte(s), &data)

		LocalProperties := notionapi.Properties{
			"Title": notionapi.TitleProperty{
				Title: []notionapi.RichText{
					{
						Text: &notionapi.Text{Content: data.Title},
					},
				},
			},
			"Nickname": notionapi.RichTextProperty{
				RichText: []notionapi.RichText{
					{
						Text: &notionapi.Text{Content: data.Nickname},
					},
				},
			},
			"UserId": notionapi.RichTextProperty{
				RichText: []notionapi.RichText{
					{
						Text: &notionapi.Text{Content: data.UserId},
					},
				},
			},
			"Post": notionapi.RelationProperty{
				Relation: []notionapi.Relation{
					{
						ID: notionapi.PageID(data.CommunityPageId),
					},
				},
			},
		}

		resp, err := service.PostCommunityComment(&LocalProperties)
		if err != nil {
			log.Println(err)
		}
		test := string(resp)
		log.Println(test)

	} else {
		lambda.Start(handler)
	}
}

func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	s := req.Body
	data := PostCommunityComment{}
	json.Unmarshal([]byte(s), &data)

	LambdaProperties := notionapi.Properties{
		"Title": notionapi.TitleProperty{
			Title: []notionapi.RichText{
				{
					Text: &notionapi.Text{Content: data.Title},
				},
			},
		},
		"Nickname": notionapi.RichTextProperty{
			RichText: []notionapi.RichText{
				{
					Text: &notionapi.Text{Content: data.Nickname},
				},
			},
		},
		"UserId": notionapi.RichTextProperty{
			RichText: []notionapi.RichText{
				{
					Text: &notionapi.Text{Content: data.UserId},
				},
			},
		},
		"Post": notionapi.RelationProperty{
			Relation: []notionapi.Relation{
				{
					ID: notionapi.PageID(data.CommunityPageId),
				},
			},
		},
	}

	resp, err := service.PostCommunityComment(&LambdaProperties)
	if err != nil {
		return events.APIGatewayProxyResponse{Body: err.Error(), StatusCode: 500,
			Headers: map[string]string{
				"Access-Control-Allow-Headers": "Content-Type",
				"Access-Control-Allow-Origin":  "*",
				"Access-Control-Allow-Methods": "OPTIONS,POST,GET",
			},
		}, nil
	} else {
		return events.APIGatewayProxyResponse{Body: string(resp), StatusCode: 200,
			Headers: map[string]string{
				"Access-Control-Allow-Headers": "Content-Type",
				"Access-Control-Allow-Origin":  "*",
				"Access-Control-Allow-Methods": "OPTIONS,POST,GET",
			},
		}, nil
	}
}
package service

import (
	"context"
	"encoding/json"
	"fmt"
	"os"

	"github.com/jomei/notionapi"
	log "github.com/sirupsen/logrus"
)

func PostCommunityComment(properties *notionapi.Properties) ([]byte, error) {
	token := notionapi.Token(os.Getenv("NOTION_INTEGRATION_TOKEN"))
	commentDbId := notionapi.DatabaseID(os.Getenv("NOTION_COMMENT_ID"))
	client := notionapi.NewClient(token)

	// create req
	req := &notionapi.PageCreateRequest{
		Parent: notionapi.Parent{
			Type:       notionapi.ParentTypeDatabaseID,
			DatabaseID: commentDbId,
		},
		Properties: *properties,
	}

	page, err := client.Page.Create(context.TODO(), req)
	if err != nil {
		log.Println(err)
		return nil, err
	}

	fmt.Println(page)
	b, err := json.Marshal(page)

	// if json parsing fail
	if err != nil {
		log.Println(err)
		return nil, err
	}
	// else
	log.Println(string(b))
	return b, err
}

[github.com/jomei/notionapi](http://github.com/jomei/notionapi) 라는 고랭 패키지 덕분에 고랭에서도 손쉽게 노션 API를 활용할 수 있었다. JavaScript에서는 라이브러리가 조금 더 편하게 되어있는 것 같다. 위 코드는 댓글을 작성하는 API에 활용되는 람다함수 코드다.

commentDbId 라는 DB에 새로운 Page를 만들어주는 코드다. 나의 서비스에서는 Comment DB가 댓글을 작성하면 댓글이라는 Page를 자식으로 가지는 형태였기 때문에 위와같이 작성했다.

Headers: map[string]string{
				"Access-Control-Allow-Headers": "Content-Type",
				"Access-Control-Allow-Origin":  "*",
				"Access-Control-Allow-Methods": "OPTIONS,POST,GET",
			}

위와 같은 헤더설정은 CORS 처리를 위한 헤더옵션이다. 원래는 Origin을 특정해서 작성을 하는 것이 맞으나 localhost에서도 테스트할 수 있도록 만들기 위해 임시로 위와같이 모두 오픈해주었다.

CORS 관련해서 서버리스 환경에서 처리하는 부분은 아래 포스트를 참고해주면 좋을 것 같다.

API Gateway

AWS에서 제공하는 서버리스 API서비스다. 이 서비스를 사용하면 API호출에 따라서 비용이 나오기 때문에 상당히 합리적인 옵션이 될 수 있다. 서버리스 API를 배포하는 방법은 다음 포스트에서 설명하고 있다.

API Gateway를 사용하면서 가장 불편했던 점 중 하나는 환경의 분리였다. 개발환경과 상용환경을 분리해주어야 했는데, Gateway에서는 Stage라는 리소스가 있다. 처음에는 이 Stage라는 리소스를 활용하여 개발환경을 분리해주고 싶었다. 그렇게 되면 Gateway는 하나만 배포를 하면 되고 동일한 Gateway 내부에서 여러 Stage로 환경을 나눌 수 있다고 생각했다.

하지만 여러 실험을 해본 결과 Stage라는 리소스를 활용해 Gateway 구조에 대한 스냅샷은 따로 분리해서 만들 수 있지만 결국 그 스냅샷에서 동일한 람다함수를 바라보고 있다면 람다함수가 수정됨과 동시에 개발환경과 상용환경이 모두 API기능이 달라지게 되어 환경분리가 불가능했다.

그래서 어쩔 수 없이 다음과 같이 Lambda와 Gateway모듈을 만들어 개발환경 및 상용환경 각각 생성해줄 수 있도록 작업을 하게 되었다.

locals {
  aws_api_gateway_resource_id = var.path_part != null ? aws_api_gateway_resource._[0].id : var.aws_api_gateway_resource_root_id
}

module "_" {
  count   = var.path_part != null ? 1 : 0
  source  = "squidfunk/api-gateway-enable-cors/aws"
  version = "0.3.3"

  api_id          = var.aws_api_gateway_rest_api_id
  api_resource_id = local.aws_api_gateway_resource_id
}

resource "aws_api_gateway_resource" "_" {
  count       = var.path_part != null ? 1 : 0
  rest_api_id = var.aws_api_gateway_rest_api_id
  parent_id   = var.aws_api_gateway_resource_root_id
  path_part   = var.path_part
}

resource "aws_api_gateway_method" "_" {
  rest_api_id   = var.aws_api_gateway_rest_api_id
  resource_id   = local.aws_api_gateway_resource_id
  http_method   = var.http_method
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "_" {
  rest_api_id = var.aws_api_gateway_rest_api_id
  resource_id = aws_api_gateway_method._.resource_id
  http_method = aws_api_gateway_method._.http_method

  connection_type         = "INTERNET"
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = var.aws_lambda_function_invoke_arn
}

resource "aws_api_gateway_method_response" "_" {
  rest_api_id = var.aws_api_gateway_rest_api_id
  resource_id = aws_api_gateway_method._.resource_id
  http_method = aws_api_gateway_method._.http_method
  status_code = "200"
  response_parameters = {
    "method.response.header.Access-Control-Allow-Origin" = true
  }

  depends_on = [aws_api_gateway_method._, aws_api_gateway_integration._]
}

resource "aws_api_gateway_integration_response" "_" {
  rest_api_id = var.aws_api_gateway_rest_api_id
  resource_id = aws_api_gateway_method._.resource_id
  http_method = aws_api_gateway_method._.http_method
  status_code = "200"

  depends_on = [aws_api_gateway_method._, aws_api_gateway_integration._]
}
data "archive_file" "_" {
  type        = "zip"
  source_file = "${path.module}/../../go/${var.lambda_path}/build/bin/app"
  output_path = "${path.module}/../../go/${var.lambda_path}/build/bin/${var.file_name}.zip"
}

resource "aws_lambda_permission" "_" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function._.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "${var.api_gateway_execution_arn}/*/*"

  depends_on = [
    aws_lambda_function._
  ]
}

resource "aws_lambda_function" "_" {
  function_name    = "${var.service_name}_${var.environment}_${var.file_name}"
  filename         = data.archive_file._.output_path
  source_code_hash = filebase64sha256(data.archive_file._.output_path)

  handler = "app"
  runtime = "go1.x"
  timeout = 10

  role = var.lambda_role_arn

  tags = var.aws_common_tags

  environment {
    variables = {
      NOTION_INTEGRATION_TOKEN = var.mzz_secret["NOTION_INTEGRATION_TOKEN"]
      NOTION_COMMUNITY_ID      = var.mzz_secret["NOTION_COMMUNITY_ID"]
      NOTION_COMMENT_ID        = var.mzz_secret["NOTION_COMMENT_ID"]
    }
  }
}

resource "aws_cloudwatch_log_group" "_" {
  name              = "/aws/lambda/${var.service_name}/${var.environment}/${var.file_name}"
  retention_in_days = 3

  tags = var.aws_common_tags
}

output "aws_lambda_function_invoke_arn" {
  value = aws_lambda_function._.invoke_arn
}
# post community/comment
module "lambda_post_community_comment" {
  source = "../lambda"

  service_name              = var.service_name
  aws_common_tags           = var.aws_common_tags
  mzz_secret                = var.mzz_secret
  environment               = var.environment
  lambda_path               = "post/community_comment"
  file_name                 = "post_community_comment"
  lambda_role_arn           = aws_iam_role.lambda_role.arn
  api_gateway_execution_arn = aws_api_gateway_rest_api.api.execution_arn
}

module "api_resource_post_community_comment" {
  source = "../api-gateway-resource"

  aws_api_gateway_rest_api_id      = aws_api_gateway_rest_api.api.id
  aws_api_gateway_resource_root_id = aws_api_gateway_resource.community_root.id
  aws_lambda_function_invoke_arn   = module.lambda_post_community_comment.aws_lambda_function_invoke_arn
  http_method                      = "POST"
  path_part                        = "comment"
}
module "api-gateway-dev" {
  source = "./api-gateway"

  service_name    = local.service_name
  aws_common_tags = local.aws_common_tags
  mzz_secret      = module.secret.mzz_secret
  environment     = "dev"
}

module "api-gateway-prod" {
  source = "./api-gateway"

  service_name    = local.service_name
  aws_common_tags = local.aws_common_tags
  mzz_secret      = module.secret-prod.mzz_secret
  environment     = "prod"
}

api-gateway라는 모듈을 먼저 생성했고, 모듈을 main에서 개발과 상용으로 분리해주었다. 그리고 모듈 내부에서 api-gateway-resource라는 모듈과 lambda 모듈을 활용해 각 API를 담당할 수 있도록 작업했다.

고찰

작업하면서 생각보다 재미있었다. 물론 성능적인 부분에서는 결국 Amplify는 CloudFront로 배포가 되고, API Gateway는 람다를 호출하고 그에 따라서 람다가 트리거에 의해 실행되는 시간이 걸리기 때문에 그리 만족스럽지는 못한 것 같다.

람다속도를 최적화 하는 시간이나 Amplify의 리소스 전달 시간을 최소화하는 방법이 물론 찾아보면 있을 것 이라고 생각하지만 현재는 만족할만한 수준의 속도이기 때문에 필요성을 느끼게 되면 작업을 하려한다.

노션 DB에 대한 API호출 횟수도 제한이 있기 때문에 조금 불편한 점이 있지만 정보전달 목적의 서비스를 배포하는데는 위와같이 작업하면 아무런 문제가 없을 것으로 생각이 된다.

다음에는 GCP와 같은 다른 프로바이더를 활용해 저렴한 서버를 한 번 구축해보아야겠다.

profile
복세편살

0개의 댓글