ECS 아티클에 이어 이번 글에서는 같은 서비스를 EKS로 구축해보도록 하겠습니다.

간단하게 구축하는 것은 eksctl을 쓰면 되지만, 내부 이해를 위해 여기서는 기본부터 구현하도록 하겠습니다.

VPC 셋업

VPC는 이전과 마찬가지로 두 개의 AZ에 퍼블릭 서브넷과 프리이빗 서브넷이 필요합니다. 다만 로드 밸런서가 정상적으로 배치되려며 특정한 태그를 서브넷에 부여해야합니다.

Sample VPC

서비스 컨테이너만 정의하는 ECS와 달리 쿠버네티스는 클러스터 내에서 로드 밸런서, 영구 볼륨등도 정의할 수 있습니다. 이때 정의는 쿠버네티스안에서 하지만 실제 만들어지는 것은 쿠버네티스와 무관한 AWS 리소스(ALB, EBS등)입니다. 쿠버네티스 자체는 AWS 구조와 독립적이고, 태그등 다른 방법을 통해 쿠버네티스 리소스가 위치할 AWS 리소스를 찾게 됩니다.

EKS에서 애플리케이션 로드 밸런싱 문서에 따라 다음 태그를 설정해줍니다.

  • kubernetes.io/cluster/<name>: shared
  • kubernetes.io/role/internal-elb: 프라이빗 서브넷에 대해 1로 설정합니다.
  • kubernetes.io/role/elb: 퍼블릭 서브넷에 대해 1로 설정합니다.
provider "aws" {
  region = "ap-northeast-2"
}

locals {
  vpc_name        = "simon-test"
  cidr            = "10.194.0.0/16"
  public_subnets  = ["10.194.0.0/24", "10.194.1.0/24"]
  private_subnets = ["10.194.100.0/24", "10.194.101.0/24"]
  azs             = ["ap-northeast-2a", "ap-northeast-2c"]
  cluster_name    = "simon-test"
}

## VPC를 생성합니다
resource "aws_vpc" "this" {
  cidr_block = local.cidr
  tags       = { Name = local.vpc_name }
}

## VPC 생성시 기본으로 생성되는 라우트 테이블에 이름을 붙입니다
## 이걸 서브넷에 연결해 써도 되지만, 여기서는 사용하지 않습니다
resource "aws_default_route_table" "this" {
  default_route_table_id = aws_vpc.this.default_route_table_id
  tags                   = { Name = "${local.vpc_name}-default" }
}

## VPC 생성시 기본으로 생성되는 보안 그룹에 이름을 붙입니다
resource "aws_default_security_group" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-default" }
}

## 퍼플릭 서브넷에 연결할 인터넷 게이트웨이를 정의합니다
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-igw" }
}

## 퍼플릭 서브넷에 적용할 라우팅 테이블
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-public" }
}

## 퍼플릭 서브넷에서 인터넷에 트래픽 요청시 앞서 정의한 인터넷 게이트웨이로 보냅니다
resource "aws_route" "public_worldwide" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.this.id
}

## 퍼플릭 서브넷을 정의합니다
resource "aws_subnet" "public" {
  count = length(local.public_subnets) # 여러개를 정의합니다

  vpc_id                  = aws_vpc.this.id
  cidr_block              = local.public_subnets[count.index]
  availability_zone       = local.azs[count.index]
  map_public_ip_on_launch = true # 퍼플릭 서브넷에 배치되는 서비스는 자동으로 공개 IP를 부여합니다
  tags = {
    Name = "${local.vpc_name}-public-${count.index + 1}",
    "kubernetes.io/cluster/${local.cluster_name}" = "shared", # 다른 부분
    "kubernetes.io/role/elb"                      = "1" # 다른 부분
  }
}

## 퍼플릭 서브넷을 라우팅 테이블에 연결합니다
resource "aws_route_table_association" "public" {
  count = length(local.public_subnets)

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

## NAT 게이트웨이는 고정 IP를 필요로 합니다
resource "aws_eip" "nat_gateway" {
  vpc  = true
  tags = { Name = "${local.vpc_name}-natgw" }
}

## 프라이빗 서브넷에서 인터넷 접속시 사용할 NAT 게이트웨이
resource "aws_nat_gateway" "this" {
  allocation_id = aws_eip.nat_gateway.id
  subnet_id     = aws_subnet.public[0].id # NAT 게이트웨이 자체는 퍼플릭 서브넷에 위치해야 합니다
  tags          = { Name = "${local.vpc_name}-natgw" }
}

## 프라이빗 서브넷에 적용할 라우팅 테이블
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-private" }
}

## 프라이빗 서브넷에서 인터넷에 트래픽 요청시 앞서 정의한 NAT 게이트웨이로 보냅니다
resource "aws_route" "private_worldwide" {
  route_table_id         = aws_route_table.private.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.this.id
}

## 프라이빗 서브넷을 정의합니다
resource "aws_subnet" "private" {
  count = length(local.private_subnets) # 여러개를 정의합니다

  vpc_id            = aws_vpc.this.id
  cidr_block        = local.private_subnets[count.index]
  availability_zone = local.azs[count.index]
  tags = {
    Name = "${local.vpc_name}-private-${count.index + 1}",
    "kubernetes.io/cluster/${local.cluster_name}" = "shared", # 다른 부분
    "kubernetes.io/role/internal-elb"             = "1" # 다른 부분
  }
}

## 프라이빗 서브넷을 라우팅 테이블에 연결합니다
resource "aws_route_table_association" "private" {
  count = length(local.private_subnets)

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

EKS 클러스터 생성

ECS가 하나의 리소스로 끝난 것에 비해 EKS는 조금 복잡합니다.

## 클러스터가 사용할 역할을 정의합니다.
## AmazonEKSClusterPolicy와 AmazonEKSVPCResourceController를 포함합니다.
resource "aws_iam_role" "cluster" {
  name = "${local.cluster_name}-eks-cluster-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect = "Allow",
      Principal = {
        Service = "eks.amazonaws.com"
      },
      Action = "sts:AssumeRole"
    }]
  })
}
resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSClusterPolicy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
  role       = aws_iam_role.cluster.name
}
resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSVPCResourceController" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController"
  role       = aws_iam_role.cluster.name
}

## EKS 클러스터를 정의합니다
resource "aws_eks_cluster" "this" {
  name     = local.cluster_name
  role_arn = aws_iam_role.cluster.arn
  vpc_config {
    subnet_ids = aws_subnet.private[*].id
  }
  depends_on = [ # see https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eks_cluster#example-usage
    aws_iam_role_policy_attachment.cluster_AmazonEKSClusterPolicy,
    aws_iam_role_policy_attachment.cluster_AmazonEKSVPCResourceController,
  ]
}

## Fargate에서 팟 배치시 사용하는 실행 역할을 정의합니다.
## AmazonEKSFargatePodExecutionRolePolicy를 포함합니다.
resource "aws_iam_role" "pod_execution" {
  name = "${local.cluster_name}-eks-pod-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect = "Allow",
      Principal = {
        Service = "eks-fargate-pods.amazonaws.com"
      },
      Action = "sts:AssumeRole"
    }]
  })
}
resource "aws_iam_role_policy_attachment" "pod_execution_AmazonEKSFargatePodExecutionRolePolicy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSFargatePodExecutionRolePolicy"
  role       = aws_iam_role.pod_execution.name
}

## EKS 클러스터에 노드를 Fargate로 공급합니다.
## default/kube-system 네임스페이스를 가진 팟에 대해 적용됩니다.
resource "aws_eks_fargate_profile" "default" {
  cluster_name           = aws_eks_cluster.this.name
  fargate_profile_name   = "fp-default"
  pod_execution_role_arn = aws_iam_role.pod_execution.arn
  subnet_ids             = aws_subnet.private[*].id # 프라이빗 서브넷만 줄 수 있습니다.
  selector {
    namespace = "default"
  }
  selector {
    namespace = "kube-system"
  }
}

쿠버네티스 클러스터에 접근할 수 있도록 kubeconfig를 생성합니다.

$ aws eks update-kubeconfig --region ap-northeast-2 --name simon-test --alias simon-test

이제 쿠버네티스 클러스터의 상태를 볼 수 있습니다.

$ kubectl get svc
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   172.20.0.1   <none>        443/TCP   23m

그런데 팟 목록을 보면 기본적으로 실행되어야 할 coredns 팟이 뜨지 않는 것이 보입니다.

$ kubectl get pods -n kube-system
NAME                       READY   STATUS    RESTARTS   AGE
coredns-6dbb778559-clx8r   0/1     Pending   0          13m
coredns-6dbb778559-zpx2h   0/1     Pending   0          13m

Fargate 노드만 사용하려면 CoreDNS 내용을 수정 해줘야 합니다.

$ kubectl patch deployment coredns \
    -n kube-system \
    --type json \
    -p='[{"op": "remove", "path": "/spec/template/metadata/annotations/eks.amazonaws.com~1compute-type"}]'
deployment.apps/coredns patched
$ kubectl get pods -n kube-system                          
NAME                       READY   STATUS    RESTARTS   AGE
coredns-6f99dbb876-hp667   1/1     Running   0          4m
coredns-6f99dbb876-skrgn   1/1     Running   0          4m

컨테이너에 IAM 권한을 부여하려면 OIDC 공급자 생성이 필요합니다

$ eksctl utils associate-iam-oidc-provider --cluster simon-test --approve

서비스 구동

이제 우리가 만든 어플리케이션을 EKS 클러스터에 올릴 차례입니다.

이전과 마찬가지로 ECR 저장소를 만들고 이미지를 올려둡니다.

locals {
  app_name = "simon-sample"
}

## simon-sample 앱을 위한 저장소를 만듭니다
resource "aws_ecr_repository" "simon_sample" {
  name = local.app_name
}

이 이미지를 띄우는 것은 쿠버네티스가 처리합니다.

쿠버네티스에 띄우는 가장 작은 단위는 파드입니다. 다만 파드로 띄우면 서버를 확장하는 것이 어렵습니다. 레플리카셋이라는 리소스를 사용하면 파드를 여러개 띄울 수 있습니다. 여기에 더해 디플로이먼트를 리소스를 사용하면 어플리케이션 갱신시 레플리카셋 교체를 해줍니다.

다음과 같이 쿠버네티스 리소스를 정의합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: simon-sample
spec:
  replicas: 1
  selector:
    matchLabels:
      app: simon-sample
  template:
    metadata:
      labels:
        app: simon-sample
    spec:
      containers:
        - name: app
          image: <aws-account-id>.dkr.ecr.ap-northeast-2.amazonaws.com/simon-sample:latest
          ports:
            - containerPort: 3000

다음 명령으로 적용합니다.

$ kubectl apply -f simon-sample.yaml
deployment.apps/simon-sample created

보통 5분 안에(빠르면 1분 안에) 다음과 같이 Running 상태로 바뀝니다.

$ kubectl get pods -o wide                 
NAME                           READY   STATUS    RESTARTS   AGE     IP              NODE                    NOMINATED NODE   READINESS GATES
simon-sample-dd88b97bc-svtd6   1/1     Running   0          3m45s   10.194.101.15   fargate-10.194.101.15   <none>           <none>

포트 포워딩으로 세팅하고, HTTP 요청을 하면 응답이 오는 것을 볼 수 있습니다.

$ kubectl port-forward simon-sample-dd88b97bc-svtd6 3000:3000
Forwarding from 127.0.0.1:3000 -> 3000
Handling connection for 3000

## from other terminal
$ curl localhost:3000 --data 'hello world'
hello world

서비스를 외부에 노출하기

ECS와 마찬가지로 실제 서비스가 되려면 외부에 노출된 로드 밸런서에 우리의 서비스를 붙여줘야 합니다. ECS와 달리 쿠버네티스에서는 로드 밸런서 정의도 쿠버네티스 리소스로 정의합니다. 하지만 실제로는 EKS가 알아서 AWS 로드 밸런서를 생성하게 됩니다.

어플리케이션 계층(L7)에서 동작하는 Application Load Balancer(ALB)를 생성하기 위한 리소스 타입은 Ingress 입니다.

우선 AWS Load Balancer Controller 추가 기능 설치가 필요합니다.

## ALB를 컨트롤 할 수 있는 정책을 생성합니다.
$ curl -o iam_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.4.0/docs/install/iam_policy.json
$ aws iam create-policy \
    --policy-name AWSLoadBalancerControllerIAMPolicy-simon-test \
    --policy-document file://iam_policy.json

## 위 정책을 포함한 IAM 역할을 생성합니다.
$ ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account)
$ OIDC_URL=$(aws eks describe-cluster --name simon-test --query "cluster.identity.oidc.issuer" --output text | sed "s|https://||")
$ cat > load-balancer-role-trust-policy.json << EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::${ACCOUNT_ID}:oidc-provider/$OIDC_URL"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "${OIDC_URL}:aud": "sts.amazonaws.com",
                    "${OIDC_URL}:sub": "system:serviceaccount:kube-system:aws-load-balancer-controller"
                }
            }
        }
    ]
}
EOF
$ aws iam create-role \
    --role-name AmazonEKSLoadBalancerControllerRole-simon-test \
    --assume-role-policy-document file://"load-balancer-role-trust-policy.json"
$ aws iam attach-role-policy \
    --policy-arn arn:aws:iam::${ACCOUNT_ID}:policy/AWSLoadBalancerControllerIAMPolicy-simon-test \
    --role-name AmazonEKSLoadBalancerControllerRole-simon-test

## IAM 역할에 연결된 쿠베네티스 서비스 계정을 생성합니다.
$ cat > aws-load-balancer-controller-service-account.yaml << EOF
apiVersion: v1
kind: ServiceAccount
metadata:
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/name: aws-load-balancer-controller
  name: aws-load-balancer-controller
  namespace: kube-system
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::${ACCOUNT_ID}:role/AmazonEKSLoadBalancerControllerRole-simon-test
EOF
$ kubectl apply -f aws-load-balancer-controller-service-account.yaml

## AWS Load Balancer Controller를 설치합니다
$ VPC_ID=$(aws ec2 describe-vpcs --filters Name=cidr,Values=10.194.0.0/16 | jq -r .Vpcs[0].VpcId)
$ helm repo add eks https://aws.github.io/eks-charts
$ helm repo update
$ helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
    -n kube-system \
    --set clusterName=simon-test \
    --set serviceAccount.create=false \
    --set serviceAccount.name=aws-load-balancer-controller \
    --set region=ap-northeast-2 \
    --set vpcId=$VPC_ID \
    --set image.repository=602401143452.dkr.ecr.ap-northeast-2.amazonaws.com/amazon/aws-load-balancer-controller
NAME: aws-load-balancer-controller
LAST DEPLOYED: Sat Mar 26 10:02:19 2022
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
AWS Load Balancer controller installed!
$ kubectl get deployment -n kube-system aws-load-balancer-controller
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
aws-load-balancer-controller   2/2     2            2           5m51s

이제 쿠버네티스를 통해 ALB를 생성하고, 아까 만든 앱을 등록합니다.

apiVersion: v1
kind: Service
metadata:
  name: simon-sample-service
spec:
  ports:
    - port: 3000
      targetPort: 3000
      protocol: TCP
  type: NodePort
  selector:
    app: simon-sample
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: simon-test-alb
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
spec:
  ingressClassName: alb
  rules:
    - http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: simon-sample-service
                port:
                  number: 3000

이제 로드 밸런서 주소로 요청을 보내면 응답을 보내옵니다.

## k8s-default-simontes-ea5ab2759b-771587743.ap-northeast-2.elb.amazonaws.com 같은 주소를 가집니다
$ LB_HOST=$(kubectl get ingress/simon-test-alb -ojson | jq -r .status.loadBalancer.ingress[0].hostname)
$ curl $LB_HOST --data 'hello world'
hello world

후기

확실히 EKS는 ECS에 비해 훨씬 복잡합니다. 그만큼 세세한 컨트롤이 가능하고, AWS에 특화된 개념이 아니라는 것은 장점이라고 봅니다.

이번 글에서는 수동으로 처리해주는 부분이 꽤 있는데 다음에는 더 자동화(Terraform) 해보도록 하겠습니다.

Appendix

카카오스타일에서는 한동한 ECS를 사용해서 어플리케이션을 서비스했습니다. 현재는 EKS로 전환하고 있지만, ECS가 상대적으로 단순하기 때문에 서비스 구축 개념을 익히는데 좋은 것 같습니다. (간단한 서비스는 굳이 쿠버네티스를 쓸 필요가 없다고 생각합니다) 그런 의미에서 이번 글에서는 ECS를 이용해 단순한 서비스를 오픈하는 과정을 단계별로 설명해보려고 합니다.

Docker 도입 과정

도커에 대한 얘기는 2014~2015년 무렵 들려오기 시작했던 것 같습니다. 혁신적인 솔루션이라는 얘기가 오가고 있었지만, 카카오스타일에서는 AWS Elastic Beanstalk를 거쳐, 독자적인 배포 시스템을 갖추고 있었기 때문에 도커의 이득에 대해서 크게 와 닿지 않아 도커로 넘어가는게 상대적으로 늦었던 것 같습니다.

그러던 와중에 처음 필요성을 느꼈던 것은 로컬 개발환경을 갖추는 부분이였던 것 같습니다. 유닛 테스트등을 위해서 로컬에 DB 프로세스 구동이 필요했습니다. 신규 직원에게 brew로 설치하는 것을 가이드하고 있었는데, Docker Compose로 구성해두었더니 도커에 익숙한 사람은 쉽게 개발 환경을 갖출 수 있었습니다.

2022-03-31-1-01.png

이후 지토가 하나의 마이크로서비스에 대해서 ECS 셋업을 했는데, 세팅된 것을 보고 나니 유용성이 느껴진 것 같습니다. 그래서 빠르게 이후에 빠르게 ECS 전환을 했습니다.

2022-03-31-1-02.png

ECS 전환전보다 배포 시간이 늘어나긴 했습니다. (이전에는 이미 존재하는 EC2 인스턴스에 새로운 소스를 전송한 후 프로세스만 새로 띄우면 됐습니다) 하지만 점점 마이크로서비스로 나눠지고, 트래픽이 늘어나는 상황에 대응하기에는 ECS가 훨씬 수월했습니다.

Docker로 서비스 만들기

도커로 구동할 Node.js 어플리케이션을 만들어봅시다. HTTP 요청에 보낸 온 데이터를 그대로 반환하는 간단한 서버입니다.

// echo.js
const http = require('http');
const server = http.createServer((req, res) => {
  req.pipe(res);
});
server.listen(3000);

이 서버를 도커 이미지로 만들기 위해 다음과 같이 Dockerfile을 만들면 됩니다.

FROM node:16
WORKDIR /opt/app
COPY echo.js .
EXPOSE 3000
CMD ["node", "echo.js"]

[docker build](https://docs.docker.com/engine/reference/commandline/build/)로 도커 이미지를 만들 수 있습니다.

$ docker build . -t simon-sample

Apple Silicon을 사용한 컴퓨터에서는 platform을 지정해 빌드해야 ECS에서 정상동작합니다. docker build . -t simon-sample --platform=linux/amd64

[docker run](https://docs.docker.com/engine/reference/commandline/run/)을 이용해 이미지로 부터 프로세스를 실행할 수 있습니다. 외부에서 서버에 접근하기 위해 3000번 포트를 열어야 하고, 데몬 모드로 실행합니다.

$ docker run -p 3000 -d simon-sample

curl을 사용해 잘 동작하는지 확인 가능합니다.

$ curl http://localhost:3000 --data 'hello world'    
hello world

이렇게 만들어진 이미지는 어떠한 환경에서든 동일하게 동작하고, 배포도 단순해집니다.

VPC 셋업

ECS 클러스터 생성에 앞서, 클러스터가 놓일 VPC를 만듭니다.

우리가 원하는 서비스 구조를 위해서는 VPC에 최소한 다음과 같은 것들이 필요합니다. (퍼블릭 및 프라이빗 서브넷이 있는 VPC(NAT) 문서에서 설명하는 구조와 같습니다.)

  • VPC
  • 인터넷에 노출되는 서버가 놓일 퍼플릭 서브넷 (최소 두개 이상의 AZ)
  • 안전하게 외부로 부터 접근을 차단된, 실제 서비스가 배치될 프라이빗 서브넷 (최소 두개 이상의 AZ)
  • 퍼플릭 서브넷에서 외부 통신을 할 때 사용하는 인터넷 게이트웨이
  • 프라이빗 서브넷에서 외부 통신이 필요할 때(아웃바운드 전용) 사용하는 NAT 게이트웨이
  • 퍼플릭 서브넷에 연결할 라우팅 테이블. 서브넷에서 다른 서브넷에 접근할 때 규칙과, 외부(0.0.0.0/0)에 접근할 때의 규칙(인터넷 게이트웨이를 거치게 함)을 정의합니다.
  • 프라이빗 서브넷에 연결할 라우팅 테이블. 퍼플릭 라우팅 테이블과 비슷한 규칙이지만, 외부에 접근시 NAT 게이트웨이를 거칩니다.

Sample VPC

카카오스타일은 인프라 정의시 Terraform을 사용합니다. 여기서는 세부 사항을 이해하기 위해 개별 리소스를 정의하고 있지만, terraform-aws-modules/vpc/aws 같은 모듈을 써서 편하게 정의하는 것도 가능합니다.

provider "aws" {
  region = "ap-northeast-2"
}

locals {
  vpc_name        = "simon-test"
  cidr            = "10.194.0.0/16"
  public_subnets  = ["10.194.0.0/24", "10.194.1.0/24"]
  private_subnets = ["10.194.100.0/24", "10.194.101.0/24"]
  azs             = ["ap-northeast-2a", "ap-northeast-2c"]
}

## VPC를 생성합니다
resource "aws_vpc" "this" {
  cidr_block = local.cidr
  tags       = { Name = local.vpc_name }
}

## VPC 생성시 기본으로 생성되는 라우트 테이블에 이름을 붙입니다
## 이걸 서브넷에 연결해 써도 되지만, 여기서는 사용하지 않습니다
resource "aws_default_route_table" "this" {
  default_route_table_id = aws_vpc.this.default_route_table_id
  tags                   = { Name = "${local.vpc_name}-default" }
}

## VPC 생성시 기본으로 생성되는 보안 그룹에 이름을 붙입니다
resource "aws_default_security_group" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-default" }
}

## 퍼플릭 서브넷에 연결할 인터넷 게이트웨이를 정의합니다
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-igw" }
}

## 퍼플릭 서브넷에 적용할 라우팅 테이블
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-public" }
}

## 퍼플릭 서브넷에서 인터넷에 트래픽 요청시 앞서 정의한 인터넷 게이트웨이로 보냅니다
resource "aws_route" "public_worldwide" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.this.id
}

## 퍼플릭 서브넷을 정의합니다
resource "aws_subnet" "public" {
  count = length(local.public_subnets) # 여러개를 정의합니다

  vpc_id                  = aws_vpc.this.id
  cidr_block              = local.public_subnets[count.index]
  availability_zone       = local.azs[count.index]
  map_public_ip_on_launch = true # 퍼플릭 서브넷에 배치되는 서비스는 자동으로 공개 IP를 부여합니다
  tags                    = { Name = "${local.vpc_name}-public-${count.index + 1}" }
}

## 퍼플릭 서브넷을 라우팅 테이블에 연결합니다
resource "aws_route_table_association" "public" {
  count = length(local.public_subnets)

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

## NAT 게이트웨이는 고정 IP를 필요로 합니다
resource "aws_eip" "nat_gateway" {
  vpc  = true
  tags = { Name = "${local.vpc_name}-natgw" }
}

## 프라이빗 서브넷에서 인터넷 접속시 사용할 NAT 게이트웨이
resource "aws_nat_gateway" "this" {
  allocation_id = aws_eip.nat_gateway.id
  subnet_id     = aws_subnet.public[0].id # NAT 게이트웨이 자체는 퍼플릭 서브넷에 위치해야 합니다
  tags          = { Name = "${local.vpc_name}-natgw" }
}

## 프라이빗 서브넷에 적용할 라우팅 테이블
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-private" }
}

## 프라이빗 서브넷에서 인터넷에 트래픽 요청시 앞서 정의한 NAT 게이트웨이로 보냅니다
resource "aws_route" "private_worldwide" {
  route_table_id         = aws_route_table.private.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.this.id
}

## 프라이빗 서브넷을 정의합니다
resource "aws_subnet" "private" {
  count = length(local.private_subnets) # 여러개를 정의합니다

  vpc_id            = aws_vpc.this.id
  cidr_block        = local.private_subnets[count.index]
  availability_zone = local.azs[count.index]
  tags              = { Name = "${local.vpc_name}-private-${count.index + 1}" }
}

## 프라이빗 서브넷을 라우팅 테이블에 연결합니다
resource "aws_route_table_association" "private" {
  count = length(local.private_subnets)

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

ECS 클러스터

우리 서비스가 동작할 ECS 클러스터가 필요합니다. 도커 컨테이너가 구동될 EC2 인스턴스를 직접 관리한다면 복잡하지만 Fargate를 쓴다면 크게 신경쓸게 없습니다.

## ECS 클러스터를 생성합니다
resource "aws_ecs_cluster" "this" {
  name = "simon-test"
}

서비스 구동

이제 우리가 만든 어플리케이션을 ECS 클러스터에 올릴 차례입니다.

우선 도커 이미지를 업로드할 저장소(ECR)를 정의합니다.

locals {
  app_name = "simon-sample"
}

## simon-sample 앱을 위한 저장소를 만듭니다
resource "aws_ecr_repository" "simon_sample" {
  name = local.app_name
}

첫단계에서 만든 도커 이미지를 다음과 같이 업로드할 수 있습니다.

## ECR 저장소에 로그인합니다
$ aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin <aws-account-id>.dkr.ecr.ap-northeast-2.amazonaws.com
## 앞서 만든 이미지에 ECR 이름을 붙여줍니다
$ docker tag simon-sample:latest <aws-account-id>.dkr.ecr.ap-northeast-2.amazonaws.com/simon-sample:latest
## 이미지를 올립니다
$ docker push <aws-account-id>.dkr.ecr.ap-northeast-2.amazonaws.com/simon-sample:latest

작업 정의를 다음과 같이 정의합니다.

## 태스크 정의시 AmazonECSTaskExecutionRolePolicy 정책을 포함한
## IAM 역할을 실행 역할(execution role)로 설정해줘야 합니다
resource "aws_iam_role" "execution" {
  name = "${local.app_name}-exec-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Action = "sts:AssumeRole",
      Principal = {
        Service = "ecs-tasks.amazonaws.com"
      },
      Effect = "Allow",
    }]
  })
}

resource "aws_iam_role_policy_attachment" "execution" {
  role       = aws_iam_role.execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

## ECS 위에서 띄울 태스크에 대한 정의입니다
resource "aws_ecs_task_definition" "this" {
  family                   = local.app_name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = 256 # CPU, 메모리는 가장 작은 값을 사용합니다
  memory                   = 512
  execution_role_arn       = aws_iam_role.execution.arn
  container_definitions = jsonencode([{
    name  = "app"
    image = "${aws_ecr_repository.simon_sample.repository_url}:latest", # ECR에 올라온 이미지를 사용합니다
    cpu   = 0
    portMappings = [{ # 3000번 포트를 외부에 엽니다
      hostPort      = 3000
      protocol      = "tcp"
      containerPort = 3000
    }]
  }])
}

이제 컨테이너를 띄울 준비가 끝났습니다. 서비스를 정의하면 설정된 것에 맞춰 작업(태스크)가 뜹니다

## ECR에서 데이터를 가져오려면 태스크에 인터넷에 접근할 수 있는 권한을 주어야 합니다
resource "aws_security_group" "this" {
  name   = "${local.app_name}-sg"
  vpc_id = aws_vpc.this.id
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

## ECS 서비스를 정의합니다
resource "aws_ecs_service" "this" {
  desired_count   = 1 # 태스크를 하나만 띄웁니다
  name            = local.app_name
  cluster         = aws_ecs_cluster.this.arn
  task_definition = aws_ecs_task_definition.this.arn
  launch_type     = "FARGATE"
  network_configuration {
    subnets         = aws_subnet.private[*].id # 프라이빗 서브넷에 배치합니다
    security_groups = [aws_security_group.this.id]
  }
}

서비스를 외부에 노출하기

이렇게 만들어진 서비스는 외부에서 접속이 불가능하기 때문에 사실 아무 쓸모가 없습니다. (로그도 없어서 잘 떴는지 확인하기도 어렵습니다.)

ECS 태스크를 직접 외부에 노출할 수도 있겠지만, 보통 여러개의 태스크를 실행하기 때문에 앞에 트래픽을 분산해주는 서버가 필요합니다. 이는 로드 밸런서를 이용해 달성할 수 있습니다. 이 로드 밸런서를 퍼블릭 서브넷에 배치함으로써 외부에서 트래픽도 받을 수 있습니다.

3000번 포트로 트래픽을 전송하는 로드밸런서 타겟 그룹을 만들어 로드 밸런서 80포트(HTTP)로 트래픽이 들어오면 전송하도록 설정합니다.

locals {
  lb_name = "simon-test-lb"
}

## 외부에 80번 포트를 여는 보안 그룹을 생성합니다
resource "aws_security_group" "lb" {
  name   = "${local.lb_name}-sg"
  vpc_id = aws_vpc.this.id
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

## HTTP 요청을 분산하는 어플리케이션 로드 밸런서를 정의합니다
resource "aws_lb" "this" {
  name               = local.lb_name
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.lb.id]
  subnets            = aws_subnet.public[*].id # 퍼블릭 서브넷에 배치합니다
}

## 로드 밸런서로 온 요청을 받아 처리할 목표 그룹을 정의합니다
resource "aws_lb_target_group" "this" {
  name        = local.app_name
  port        = 3000 # 우리가 만든 컨테이너는 3000번 포트에서 입력을 받습니다
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = aws_vpc.this.id
}

## HTTP(80)에 대한 리스너를 생성합니다.
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.this.arn
  port              = "80"
  protocol          = "HTTP"
  # 앞서 정의한 타겟 그룹으로 모든 트래픽을 보냅니다
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.this.arn
  }
}

이렇게 생성된 로드 밸런서는 simon-test-lb-135798642.ap-northeast-2.elb.amazonaws.com와 같은 주소를 할당받게 됩니다. 아직 이전에 만든 ECS 태스크가 타겟 그룹에 등록되지 않았기 때문에, 이 주소에 접속해보면 503 에러가 발생합니다. 두가지 추가 작업이 필요합니다.

우선 ECS 태스크가 3000번 포트에서의 입력을 처리할 수 있도록 해야 합니다.

## ECS는 로드 밸런서에서 3000번 포트로 온 요청을 받을 수 있습니다.
resource "aws_security_group_rule" "ecs_from_lb" {
  security_group_id        = aws_security_group.this.id
  type                     = "ingress"
  from_port                = 3000
  to_port                  = 3000
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.lb.id
}

또 로드 밸런서에서 ECS 태스크로 요청도 가능해야 합니다.

## 로드 밸런서에서 ECS의 3000번 포트로 요청을 보낼 수 있습니다.
resource "aws_security_group_rule" "lb_to_ecs" {
  security_group_id        = aws_security_group.lb.id
  type                     = "egress"
  from_port                = 3000
  to_port                  = 3000
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.this.id
}

다음으로는 ECS 서비스 정의를 변경해 태스크가 만들어지면 타겟 그룹에 등록하게 하게 하면 됩니다.

resource "aws_ecs_service" "this" {
  desired_count   = 1
  name            = local.app_name
  cluster         = aws_ecs_cluster.this.arn
  task_definition = aws_ecs_task_definition.this.arn
  launch_type     = "FARGATE"
  network_configuration {
    subnets         = aws_subnet.private[*].id
    security_groups = [aws_security_group.this.id]
  }
  # 다음을 추가합니다.
  load_balancer {
    target_group_arn = aws_lb_target_group.this.arn
    container_name   = "app"
    container_port   = 3000
  }
}

이제 로드 밸런서 주소로 요청을 보내면 로컬과 마찬가지로 응답을 보내옵니다.

$ curl simon-test-lb-135798642.ap-northeast-2.elb.amazonaws.com --data 'hello world'
hello world

남은 작업

실제 서비스에 더 가까우려면 추가로 해줘야 할 것들이 많이 있습니다. 다만 이번글에서는 따로 설명하지 않겠습니다.

  • 로드 밸런서 주소는 랜덤하게 생성됩니다. 이를 실제 서비스에서 쓰기는 어렵고 서비스만의 도메인을 설정해주는게 좋습니다. Route53에 별칭 A 레코드를 생성하면 됩니다.
  • 여기서는 HTTP 트래픽을 받고 있지만, 안전한 통신을 위해 HTTPS를 사용하면 좋습니다. AWS Certificate Manager로 인증서를 생성해 로드 밸런서에 설정하면 로드 밸런서에서 TLS 종료를 처리해줍니다. 그 뒷단에 있는 우리의 어플리케이션에서는 HTTP만 처리하면 됩니다.
  • 현재 트래픽을 받을 수 있는 서버가 하나 뿐입니다. 서버만 늘리면 로드 밸런서가 알아서 트래픽을 분산해줍니다. desired_count를 조절해 수동으로 서버를 늘릴 수도 있고, 오토 스케일링 정책을 설정해 트래픽에 따라 서버를 늘이고 줄일 수도 있습니다.
  • 현재는 컨테이너에서 발생한 로그를 볼 수 없습니다. CloudWatch Logs를 연결해 로그를 기록할 수 있습니다.
  • CloudFront를 연결하면 전세계에서 접근할 때 지연 시간을 줄이는 등 추가 이득을 얻을 수 있습니다.

Appendix

Jotai 레시피

13 Jan 2022

이번 글에서는 카카오스타일에서 Jotai를 어떤 식으로 사용하고 있는지 여러가지 패턴에 대해 설명하려고 합니다.

상태 정의하고 사용하기

Jotai의 기본 사용법은 간단합니다. useState로 정의하던 상태가 있으면, atom 메소드로 정의하고 useAtom을 써서 사용하면 됩니다.

import { atom, useAtom } from 'jotai';
import { FC } from 'react';

const count_atom = atom(0);

const App: FC = () => {
  const [count, setCount] = useAtom(count_atom);
  return (
    <div>
      <div>{count}</div>
      <button onClick={() => setCount(count + 1)}>Inc</button>
    </div>
  );
};

위 예제에서는 useState와 다른게 없지만, 하위 컴포넌트에서 해당 상태에 접근해야 할 경우 차이가 발생합니다.

import { atom } from 'jotai';
import { useAtomValue, useUpdateAtom } from 'jotai/utils';
import { FC } from 'react';

const count_atom = atom(0);

const Counter: FC = () => {
  const count = useAtomValue(count_atom);
  return <div>{count}</div>;
};

const IncButton: FC = () => {
  const setCount = useUpdateAtom(count_atom);
  return <button onClick={() => setCount((count) => count + 1)}>Inc</button>;
};

const App: FC = () => {
  return (
    <div>
      <Counter />
      <IncButton />
    </div>
  );
};

useAtomValueuseUpdateAtom은 읽기나 쓰기 중 하나만 필요할 때 사용가능한 유틸리티 함수입니다. 쓰기 함수는 useState의 setState 처럼 이전 상태를 받아 이용할 수 있습니다.

파생 atom

원시 atom의 값에서 파생된 atom을 정의할 수도 있습니다. MobX나 Vue의 computed 속성과 비슷합니다. useMemo와 같이 의존한 atom의 값이 바뀐 경우에만 재계산을 합니다.

const email_atom = atom('');
const password_atom = atom('');
const button_enabled_atom = atom((get) => {
  return get(email_atom).length > 0 && get(password_atom).length > 0;
});

같은 Provider가 제공한 atom의 값만 얻을 수 있습니다.

액션 atom

쓰기 전용 atom을 통해 비즈니스 로직을 정의할 수 있습니다.

// 전화번호 인증 과정 중 전화번호 입력 필드 값이 바뀔 때 호출한다
const updateMobileTelAtom = atom(null, (get, set, value: string) => {
  // 인증 과정이 완료된 경우 전화번호를 변경할 수 없다
  if (get(authenticated_atom)) {
    return;
  }
  // 전화번호 표시에서 -등 기타 문자는 제거한다
  set(mobile_tel_atom, value.replace(/[^0-9]/g, ''));
  // 인증 번호 입력 시간을 초기화한다
  set(remain_time_atom, 0);
});

대부분의 로직은 서버 API 호출을 필요로 하는 경우가 많습니다. 이 경우 응답을 기다리는, 즉 비동기 동작이 필요합니다. Redux에서는 thunk 같은 미들웨어가 필요하지만, Jotai에서는 자연스럽게 정의할 수 있습니다.

// 전화번호 인증 토큰을 발송한다
const sendAuthenticationTokenAtom = atom(null, async (get, set) => {
  // 중복 발송을 막는다
  if (get(submitting_atom)) {
    return;
  }
  try {
    set(submitting_atom, true);
    await fetch('/sendAuthenticationToken', { mobile_tel: get(mobile_tel_atom) });
    set(submitting_atom, false);
    // 이전 에러 메시지를 초기화한다
    set(authentication_error_atom, undefined);
    // 토큰 입력을 초기화한다
    set(token_atom, '');
    // 입력 시간을 2분으로 초기화한다
    set(remain_time_atom, 120);
  } catch (error: any) {
    set(submitting_atom, false);
    alert(error.message);
  }
});

디렉토리 구조

값과 액션이 사실 같은 atom이지만, 편의상 구분하고 있습니다. 이 둘을 묶은 용어를 고민하다가, Redux, MobX 등에서 쓰는 store라고 부르기로 했습니다. (ViewModel이라고 부를까도 고민했습니다.)

MobX에서 값과 액션을 한 클래스로 만들면 파일을 나누기가 어려운데 반해, Jotai는 atom의 모음이다 보니 나누기가 편리합니다. 값은 atoms 디렉토리, 액션은 actions 디렉토리에 둡니다. atoms는 연관성이 높은 것들을 한 파일로 묶고, 액션은 보통 길이가 길기 때문에 액션 별로 파일을 만들고 있습니다.

// src/components/model/detail/store/index.ts
export * from './atoms';
export * from './actions';
// src/components/model/detail/store/atoms/index.ts
import { atom } from 'jotai';

export * from './orderer';

export const submitting_atom = atom(false);
// src/components/model/detail/store/atoms/orderer.ts
import { atom } from 'jotai';

export const orderer_email_atom = atom('');
export const orderer_mobile_tel_atom = atom('');
export const orderer_name_atom = atom('');
// src/components/model/detail/store/actions/index.ts
export * from './purchase';
// src/components/model/detail/store/actions/purchase.ts
import { atom } from 'jotai';
import {
  orderer_email_atom,
  ...
} from '../atoms';

export const purchaseAtom = atom(null, async (get, set) => {
  const orderer_email = get(orderer_email_atom);
  ...
});

Provider와 초기값

atom의 값은 글로벌에 존재하는 atom에 저장되는 것이 아니라 Context와 비슷하게 컴포넌트 트리 상에 저장됩니다. Provider를 사용하면 atom이 저장될 Context를 제공할 수 있습니다. (즉 참조하는 Provider가 다르면 같은 atom을 use해도 값이 다릅니다) Provider를 지정하지 않으면 기본 저장소가 사용됩니다.

컴포넌트 구성 아티클에서 설명했듯이 페이지와 내부 구성 컴포넌트가 분리되어 있는데, 스토리북에서도 정상 동작하도록 내부 컴포넌트쪽에 Provider 선언을 두고 있습니다.

// Jotai 적용 전
const ProfileAddress: FC<{address1: string; address2: string}> = (props) => {
  const [address1, setAddress1] = useState(props.address1);
  const [address2, setAddress2] = useState(props.address2);
  return <div></div>;
};

export default ProfileAddress;
// Jotai 적용 후
const address1_atom = atom('');
const address2_atom = atom('');

const ProfileAddress: FC = () => {
  const [address1, setAddress1] = useAtom(address1_atom);
  const [address2, setAddress2] = useAtom(address2_atom);
  return <div></div>;
};

const ProfileAddressWithProvider: FC<{ address1: string; address2: string }> = (props) => {
  return (
    <Provider
      initialValues={[
        [address1_atom, props.address1],
        [address2_atom, props.address2],
      ] as Array<[Atom<unknown>, unknown]>}
    >
      <ProfileAddress />
    </Provider>
  );
};

export default ProfileAddressWithProvider;

위와 같이 외부에서 Props로 받아 온 값을 initialValues로 atom에 넣어주면 이후 Props 참조 없이, atom에서 값을 읽을 수 있습니다.

디렉토리 구조에서 설명했듯이 atom 정의는 하위 디렉토리에서 하고 있습니다. 이런 atom을 초기화하는 코드도 atom 정의와 같이 있는 것이 바람직하기 때문에, 초기화 내용을 분리해 store/atoms/index.ts 에 만들고 있습니다.

// store/atoms/index.ts
import { Atom, atom } from 'jotai';

export const address1_atom = atom('');
export const address2_atom = atom('');

export function getInitialValues(
  address1: string,
  address2: string,
): Array<[Atom<unknown>, unknown]> {
  return [
    [address1_atom, address1],
    [address2_atom, address2],
  ];
}

onMount과 localStorage

atom에 고정된 값 대신 처음 사용되는 시점에 값을 정해지도록 할 수 있습니다. 예를 들어 atom 값을 localStorage에서 가져와 초기화 시키고 싶을 수 있습니다. 이 경우 onMount를 사용하면 됩니다.

const toolip_seen_atom = atom<boolean>(true);
toolip_seen_atom.onMount = (set) => {
  set(window.localStorage.getItem('toolip_seen') === 'true');
};

그런데 위 코드로는 atom 값을 갱신했을 때 localStorage에 반영되지 않습니다. 쓰기시 커스텀 동작을 하면서, 저장도 하고 싶은 경우, 저장용 atom을 분리해야 합니다.

const toolip_seen_base_atom = atom<boolean>(true);
toolip_seen_base_atom.onMount = (set) => {
  set(window.localStorage.getItem('toolip_seen') === 'true');
};
const toolip_seen_atom = atom(
  (get) => get(toolip_seen_base_atom),
  (_, set, seen: boolean) => {
    set(toolip_seen_base_atom, seen);
    window.localStorage.setItem('toolip_seen', seen ? 'true' : 'false');
  },
);

같은 역할을 하는 것으로 atomWithStorage 유틸리티 함수가 있는데, 일부 환경에서 오동작을 하는 듯 해서 사용하지 못하고 있습니다. (Promise를 사용해 비동기적으로 동작하는 부분을 의심하고 있습니다.)

atom 조합 및 분리

개별 atom도 사용하지만, 그 값을 합친 것도 필요한 경우가 있습니다. 파생 atom으로 이를 구현할 수 있습니다.

import { atom } from 'jotai';

const orderer_email_atom = atom('');
const orderer_mobile_tel_atom = atom('');
const orderer_name_atom = atom('');
const orderer_atom = atom((get) => ({
  email: get(orderer_email_atom),
  mobile_tel: get(orderer_mobile_tel_atom),
  name: get(orderer_name_atom),
}));

반대로 atom이 전체 값을 가지고 있는데, 그 중 일부만 필요한 경우도 있습니다. selectAtom을 사용하면 됩니다. 원본 atom의 값을 바꿀 일이 없을 때 사용하면 편리합니다.

import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';

const order_atom = atom<Order>(...);
const ordered_product_atom = selectAtom<OrderProduct>(order_atom, (order) => order.product);

immer

atom이 복잡한 객체일 때, 일부 속성만 편하게 갱신하기 위해서 immer를 사용할 수 있습니다. 도움을 주는 유틸리티 모듈도 있습니다.

// immer를 사용하지 않는 경우
const selected_atom = atom<{ [key: string]: boolean }>({ apple: true, banana: false });
const setSelectedAtom = atom(null, (get, set, value: { fruit: string; checked: boolean }) => {
  const selected = get(selected_atom);
  set(selected_atom, { ...selected, ...{ [value.fruit]: value.checked } });
});

// immer를 사용하는 경우
import { produce } from 'immer';
const selected_atom = atom<{ [key: string]: boolean }>({ apple: true, banana: false });
const setSelectedAtom = atom(null, (get, set, value: { fruit: string; checked: boolean }) => {
  set(
    selected_atom,
    produce(get(selected_atom), (draft) => {
      draft[value.fruit] = value.checked;
    }),
  );
});

// immer 연동 모듈을 사용하는 경우
import { atomWithImmer } from 'jotai/immer';
const selected_atom = atomWithImmer<{ [key: string]: boolean }>({ apple: true, banana: false });
const setSelectedAtom = atom(null, (get, set, value: { fruit: string; checked: boolean }) => {
  set(selected_atom, (draft) => {
    draft[value.fruit] = value.checked;
  });
});

공용 컴포넌트

여러 페이지에서 사용하는 공통 컴포넌트가 있을 수 있습니다. 그리고 그 컴포넌트가 제공하는 상태를 atom으로 정의해 부모가 읽을 수 있도록 만들 수 있습니다. 이때 부모가 읽을 수 있도록 공용 컴포넌트에 Provider를 별도로 두지 않았습니다. 다만 Provider가 별도로 존재하지 않으므로 인스턴스를 여러개 만들 수는 없습니다. (Input 컴포넌트 같은 것은 불가능)

// common/EditReceiverView/index.tsx
const receiver_name_atom = atom('');
const receiver_mobile_tel_atom = atom('');
const receiver_postcode_atom = atom('');
const receiver_address1_atom = atom('');
const receiver_address2_atom = atom('');

export const receiver_atom = atom((get) => ({
  name: get(receiver_name_atom),
  mobile_tel: get(receiver_mobile_tel_atom),
  postcode: get(receiver_postcode_atom),
  address1: get(receiver_address1_atom),
  address2: get(receiver_address2_atom),
}));

export function getInitialValues(
  default_receiver: {
    name: string;
    mobile_tel: string;
    postcode: string;
    address1: string;
    address2: string | null;
  } | null,
): Array<[Atom<unknown>, unknown]> {
  return [
    [receiver_name_atom, default_receiver?.name ?? ''],
    [receiver_mobile_tel_atom, default_receiver?.mobile_tel ?? ''],
    [receiver_postcode_atom, default_receiver?.postcode ?? ''],
    [receiver_address1_atom, default_receiver?.address1 ?? ''],
    [receiver_address2_atom, default_receiver?.address2 ?? ''],
  ];
}

const EditReceiverView: FC = () => {
  const [receiver_name, setReceiverName] = useAtom(receiver_name_atom);
  ...

  return (
    <div>
      <Input
        label='받는 분'
        type='text'
        placeholder='이름을 입력해주세요'
        value={receiver_name}
        onChange={setReceiverName}
      />
      ...
    </div>
  );
};

export default EditReceiverView;
// order-sheet/index.tsx
import { receiver_atom, getInitialValues as EditReceiverView_getInitialValues } from 'common/EditReceiverView';

const order_sheet_atom = atom<OrderSheet>({});

function getInitialValues(
  order_sheet: OrderSheet,
  default_receiver: {
    name: string;
    mobile_tel: string;
    postcode: string;
    address1: string;
    address2: string | null;
  } | null,
): Array<[Atom<unknown>, unknown]> {
  return [
    [order_sheet_atom, order_sheet],
    ...EditReceiverView_getInitialValues(default_receiver),
  ];
}

const purchaseAtom = atom(null, async (get, set) => {
  const order_sheet = get(order_sheet_atom);
  const receiver = get(receiver_atom);
  ...
});

const OrderSheet: FC = () => {
  const purchase = useUpdateAtom(purchaseAtom);

  return (
    <div>
      ...
      <EditReceiverView />
      ...
      <button onClick={() => purchase()}>구매하기</button>
      ...
    </div>
  );
};

const OrderSheetWithProvider: FC<Props> = (props) => {
  return (
    <Provider
      initialValues={getInitialValues(
        props.order_sheet,
        props.default_receiver,
      )}
    >
      <OrderSheet />
    </Provider>
  );
};

export default OrderSheetWithProvider;

테스트

테스트는 중요하지만, 잘 하기는 쉽지 않습니다. 특히 수시로 변경되는 UI는 안정적이고 믿을 수 있는 테스트를 작성하기가 더 어렵습니다. 하지만 상태를 뷰와 분리하면 그나마 테스트가 좀 쉬워집니다. Jotai로 만든 store는 뷰와 무관하기 때문에 테스트 작성이 비교적 용이합니다.

다음과 같이 포인트 최대 적용이라는 액션을 만들었습니다.

import { atom } from 'jotai';

// 주문액
const payment_amount_atom = atom(0);

// 소유한 포인트 금액
const available_point_atom = atom(0);

// 최대 사용가능한 포인트 금액
const maximum_usable_point_atom = atom((get) => {
  const available_point = get(available_point_atom);
  if (available_point <= 0) {
    return 0;
  }
  const payment_amount = get(payment_amount_atom);
  return available_point <= payment_amount ? available_point : payment_amount;
});

// 사용할 포인트 금액
const point_to_use_atom = atom(0);

// 사용가능한 최대 포인트를 적용한다
const applyAllPointAtom = atom(null, (get, set) => {
  set(point_to_use_atom, get(maximum_usable_point_atom));
});

// Provider의 initialValues를 위한 도움 함수
function getInitialValues(
  payment_amount: number,
  available_point: number,
): Array<[Atom<unknown>, unknown]> {
  return [
    [payment_amount_atom, payment_amount],
    [available_point_atom, available_point],
  ];
}

다음은 이 액션에 대한 테스트 코드입니다.(action 파일과 같은 디렉토리에 위치시켰습니다) 현재 카카오스타일은 Jest 프레임워크를 사용중이고(서버는 Mocha를 사용하고 있습니다), Testing Library의 도움을 받고 있습니다.

import { renderHook, act } from '@testing-library/react-hooks';
import { Provider } from 'jotai';
import { useAtomValue, useUpdateAtom } from 'jotai/utils';
import { getInitialValues, maximum_usable_point_atom, point_to_use_atom } from '../atoms';
import { applyAllPointAtom } from './applyAllPoint';

describe('applyAllPoint', () => {
  it('포인트 사용액 기본값은 0이다', () => {
    const { result } = renderHook(() => ({
      point_to_use: useAtomValue(point_to_use_atom),
    }));

    expect(result.current.point_to_use).toBe(0);
  });

  it('사용 가능한 금액만큼 적용된다', () => {
    const initial_values = getInitialValues(
      92500,
      10000,
    );
    const { result } = renderHook(
      () => ({
        point_to_use: useAtomValue(point_to_use_atom),
        maximum_usable_point: useAtomValue(maximum_usable_point_atom),
        applyAllPoint: useUpdateAtom(applyAllPointAtom),
      }),
      {
        wrapper: ({ children }) => <Provider initialValues={initial_values}>{children}</Provider>,
      },
    );

    act(() => {
      result.current.applyAllPoint();
    });

    expect(result.current.maximum_usable_point).toBe(10000);
    expect(result.current.point_to_use).toBe(10000);
  });

  it('사용 가능한 금액이 주문 금액보다 많으면 주문 금액만큼 적용된다', () => {
    const initial_values = getInitialValues(
      92500,
      200000,
    );
    const { result } = renderHook(
      () => ({
        point_to_use: useAtomValue(point_to_use_atom),
        maximum_usable_point: useAtomValue(maximum_usable_point_atom),
        applyAllPoint: useUpdateAtom(applyAllPointAtom),
      }),
      {
        wrapper: ({ children }) => <Provider initialValues={initial_values}>{children}</Provider>,
      },
    );

    act(() => {
      result.current.applyAllPoint();
    });

    expect(result.current.maximum_usable_point).toBe(92500);
    expect(result.current.point_to_use).toBe(92500);
  });
});