<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://techblog.thingsflow.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://techblog.thingsflow.com/" rel="alternate" type="text/html" /><updated>2026-09-21T14:59:34+09:00</updated><id>https://techblog.thingsflow.com/feed.xml</id><title type="html">띵스플로우 기술블로그</title><subtitle>우리는 친구를 만듭니다</subtitle><entry><title type="html">Amazon EKS 구축과 CI/CD 파이프라인 구축 - 핸즈온</title><link href="https://techblog.thingsflow.com/tech/review/EKS_%EA%B5%AC%EC%B6%95%EA%B3%BC-CICD-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95-%ED%95%B8%EC%A6%88%EC%98%A8/" rel="alternate" type="text/html" title="Amazon EKS 구축과 CI/CD 파이프라인 구축 - 핸즈온" /><published>2022-12-20T00:00:00+09:00</published><updated>2022-12-20T00:00:00+09:00</updated><id>https://techblog.thingsflow.com/tech/review/EKS_%EA%B5%AC%EC%B6%95%EA%B3%BC%20CICD%20%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8%20%EA%B5%AC%EC%B6%95%20%ED%95%B8%EC%A6%88%EC%98%A8</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/review/EKS_%EA%B5%AC%EC%B6%95%EA%B3%BC-CICD-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95-%ED%95%B8%EC%A6%88%EC%98%A8/"><![CDATA[<h2 id="주제">주제</h2>
<p>이번에는 따라하면서 EKS와 CI/CD 파이프라인을 구축 해볼 수 있는 EKS CI/CD<br />
파이프라인 구축 핸즈온을 준비 해봤습니다.
기존에 포스팅했던 ‘ArgoCD를 활용한<br />
K8 CICD pipeline’은 핸즈온이라기 보다는 전체적인 개념과 구축 과정에 대한 정리였다고 하면 이번에는 순수하게 직접 따라하면서 해볼 수 있도록 준비했어요. 전체적인 개념을 먼저 파악하려면 아래 글을 먼저 읽을 것을 권장합니다.</p>
<ul>
  <li><a href="https://techblog.thingsflow.com/tech/review/ArgoCD%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-K8-CICD-pipeline-%EA%B5%AC%EC%B6%95-1/">ArgoCD를 활용한 K8 CICD pipeline-1</a></li>
  <li><a href="https://techblog.thingsflow.com/tech/review/ArgoCD%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-K8-CICD-pipeline-%EA%B5%AC%EC%B6%95-2/">ArgoCD를 활용한 K8 CICD pipeline-2</a></li>
</ul>

<h2 id="사전-요구-사항">사전 요구 사항</h2>

<p>이 글에서 다루게 될 기술과 사전 요구 사항이에요.</p>

<ul>
  <li>aws 테스트 계정과 aws access key</li>
  <li>awscli 설치 및 Access Key 등록
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  brew install awscli
  aws configure
  # 발급 받은 AWS Access Key를 입력합니다. 저는 편의상 Admin 권한이 있는 원타임 Key를 사용했어요.
</code></pre></div>    </div>
  </li>
  <li>terraform 설치
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  brew tap hashicorp/tap
  brew install hashicorp/tap/terraform
</code></pre></div>    </div>
  </li>
  <li>helm 설치
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  brew install helm
</code></pre></div>    </div>
  </li>
  <li>git 설치
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  brew install git
</code></pre></div>    </div>
  </li>
  <li>git Fork : 띵스플로우 인프라팀에서 공개하는 핸즈온 교육에 필요한 코드입니다. <a href="https://github.com/thingsflow/thingsflow-eks-cicd-hands-on">링크</a><br />
반드시 Fork 또는 clone 후 본인의 public repo로 먼저 push 하세요.
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  git clone https://github.com/thingsflow/thingsflow-eks-cicd-hands-on.git
  # 본 핸즈온은 public repo 기준으로 작성했습니다.
</code></pre></div>    </div>
  </li>
</ul>

<h2 id="terraform을-활용한-eks-구축">Terraform을 활용한 EKS 구축</h2>
<h3 id="eks-스펙">EKS 스펙</h3>
<p>terraform/_terraform.auto.tfvars 파일에 정의된 EKS 스펙은 간단하게 아래와 같습니다.</p>
<ul>
  <li>Cluster version : 1.22</li>
  <li>Public Access : True</li>
  <li>Node Groups
    <ul>
      <li>Builders : (각종 Controller 및 Grafana, Argo CD 배포)</li>
      <li>Workers : (대고객 서비스 POD배포)</li>
    </ul>
  </li>
</ul>

<h3 id="terraform-apply">Terraform Apply</h3>
<p>먼저 _terraform.auto.tfvars 파일에서 VPC 값을 수정 합니다. <strong>subnet_id 와 azs는<br />
반드시 두개 이상 넣어 주셔야 합니다.</strong> 핸즈온에서는 public을 subnet을 넣어 주세요.
입력된 VPC Subnet에 EKS가 Provisioning 됩니다.<br />
<img src="/assets/images/eks-hands-on/1.png" alt="image" /></p>

<p>아래와 같이 terraform 명령어를 실행하세요.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># terraform init 명령어를 사용해 terraform을 초기화 합니다.
cd terraform
terraform init

# 초기화가 완료되면 terraform apply 명령으로 aws 리소스를 생성합니다.
terraform apply

# 약 10분 정도 소요 됩니다.
</code></pre></div></div>

<h3 id="add-on-controller-설치">Add On Controller 설치</h3>
<p>Provisioning이 완료 되면 사진과 같은 메시지와 함께 Output 데이터가 출력됩니다.<br />
<img src="/assets/images/eks-hands-on/2.png" alt="image" /></p>

<p>terraform/sh/ 디렉터리 경로에 scripter.tf에 의해 shell이 자동으로 추가 되는데 이<br />
shell에는 EKS를 운영하기 위해 필요한 필수 Add On Controller 설치 명령어가 들어있고 내용은 아래와 같아요.</p>
<ul>
  <li>aws-load-balancer-controller : 외내부 통신을 위해 Ingress와 ELB를 통합 해주는 역할.</li>
  <li>cert-manager : k8s 내부에서 HTTPS 통신을 위한 인증서를 생성하고 관리 하는 역할</li>
  <li>metrics-server : k8s의 리소스 모니터링.</li>
  <li>cluster-autoscaler : 워크로드를 위한 자원이 부족 할때 worker 노드를 scale out 하는 역할</li>
  <li>secrets-store-csi-driver : AWS Secret Manager와 k8s를 통합 해주는 역할</li>
</ul>

<p>아래와 같이 add on 설치 Shell을 실행하세요.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cd sh
# eks에 연결합니다.
./00.update-kubeconfig.sh

# 연결이 잘 되었는지 kubectl 명령어를 실행해봅니다. 
# 혹시 제대로 실행이 안될 경우 내가 셋팅한 aws access key의 권한을 확인 하십시오.
kubectl get node -A

# cert-manager와 aws-load-balancer-controller를 설치 합니다.
./01.aws_load_balancer_controller.sh

# metric-server와 cluster_autoscaler를 설치 합니다.
# sudo 명령어가 있어서 비밀번호를 요구 할 수도 있습니다. 로컬 PC 또는 작업 서버의 비밀번호를 입력하세요.
./02.cluster_autoscaler.sh

# secrets-store-csi-driver를 설치 합니다. (본 핸즈온에서는 스킵 가능합니다.)
./03.secrets-store-csi-driver.sh
</code></pre></div></div>
<p>Add On Controller가 정상적으로 설치 되었을때 화면 입니다. EKS 구축이 완료되었습니다. 
<img src="/assets/images/eks-hands-on/3.png" alt="image" /></p>

<h2 id="k8s-cicd-파이프라인-구축">k8s CI/CD 파이프라인 구축</h2>
<p><img src="/assets/images/eks-hands-on/4.png" alt="image" /></p>

<p><strong>GitActions와 Argo CD를 사용해서 사진과 같은 CICD Workflow를 구현 할겁니다.</strong></p>
<ol>
  <li>Commit &amp; PUSH</li>
  <li>Github Action (CI)</li>
  <li>ECR PUSH</li>
  <li>Kustomize Image newTag 수정</li>
  <li>Argo CD가 변경 사항 확인</li>
  <li>변경 사항 업데이트</li>
  <li>최신 이미지 PULL</li>
</ol>

<h3 id="argo-cd-installation">Argo CD Installation</h3>
<p>먼저 k8-cicd-argocd/argo-cd/values-eks.yaml 파일에서 subnets를 수정 합니다.<br />
외부 통신을 위한 Ingress용 이기 때문에   Public Subnet이여야 합니다.(두개 이상) repositories에서 url을 본인의 git repo url로 수정합니다.
<img src="/assets/images/eks-hands-on/5.png" alt="image" /></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># ArgoCD를 설치 합니다. 미리 정의해 놓은 Makefile을 실행합니다.
make upgrade-eks

# kubectl 명령으로 argocd POD가 정상적으로 배포 됐는지 확인 합니다.
kubectl get pod -n argocd

# kubectl 명령으로 argocd Ingress가 정상적으로 배포 됐는지 확인 합니다.
# 결과값으로 나오는 ADDRESS 컬럼의 ALB 도메인을 저장합니다.
kubectl get ingress -n argocd

# 아래 명령어로 Argo CD 초기 패스워드를 확인 합니다.
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

ID : admin
Password : 위에 명령어 결과값 (마지막에 '%'가 있다면 '%'는 제외)
</code></pre></div></div>
<ul>
  <li>브라우저에서 ALB 도메인을 호출하면 Argo CD에 로그인 할 수 있습니다.</li>
  <li>EC2 Load balancer 메뉴에 가면 Argo CD용 ALB가 하나 생긴 것을 확인 할 수 있는데 처음에 설치한 aws-load-balancer-controller에 의해서 alb와 Ingress가 통합 된 것입니다.</li>
  <li>만약에 ALB 도메인으로 접속이 되지 않으면 EC2 Load Balancer 메뉴에서 ALB 상태를 확인하세요.</li>
</ul>

<h3 id="application-deployment">Application Deployment</h3>
<p>제일 먼저 AWS ECR Console로 들어가서 private repository 하나를 생성합니다.<br />
<img src="/assets/images/eks-hands-on/6.png" alt="image" /></p>

<p>그 다음 아래와 같이 Container를 빌드하여 PUSH 합니다. 참고로 사진과 같이 ECR에서 ‘푸쉬 명령 보기’ 버튼을 클릭 해서 
방법을 참고 하셔도 됩니다.<br />
<img src="/assets/images/eks-hands-on/10.png" alt="image" /></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># export AWS_ACCOUND_ID=본인의 AWS ACCOUNT ID를 넣어 주세요.
# ecr Login
aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${AWS_ACCOUND_ID}.dkr.ecr.ap-northeast-2.amazonaws.com
# git repo에 샘플 APP에서 Docker 빌드
cd sample-app
docker build -t thingsflos-hands-on .
docker tag thingsflos-hands-on:latest ${AWS_ACCOUND_ID}.dkr.ecr.ap-northeast-2.amazonaws.com/thingsflos-hands-on:latest
docker push ${AWS_ACCOUND_ID}.dkr.ecr.ap-northeast-2.amazonaws.com/thingsflos-hands-on:latest
</code></pre></div></div>

<p>위에 작업이 완료 되면 git repo에 있는 아래 두개의 파일을 사진과 같이 방금 생성한 repository URL로 수정하세요.</p>
<ul>
  <li>kustomize/overlays/deployment-patch.yaml</li>
  <li>kustomize/overlays/kustomization.yaml</li>
</ul>

<p><img src="/assets/images/eks-hands-on/7.png" alt="image" /></p>

<p><img src="/assets/images/eks-hands-on/8.png" alt="image" /></p>

<p>또 아래의 파일에서 Subnet ID를 본인의 VPC public SubnetID로 수정하세요.(두개이상)</p>
<ul>
  <li>kustomize/overlays/ingress-patch.yaml 
<img src="/assets/images/eks-hands-on/19.png" alt="image" /></li>
</ul>

<p>이제 어플리케이션을 배포 할건데요. 배포 하기전에 cicd 구조를 살펴 보면 POD에서 이미지를 AWS ECR(Private)에서 가지고 오는 것을 
확인 할 수 있어요. 그럼 POD에서 AWS ECR로 접근할 수 있는 권한이 필요합니다. 여러 방법이 있지만 핸즈온에서는 간단하게 k8s의 <strong>imagePullSecrets</strong>를 사용해 보려고 합니다. 먼저 아래 명령어를 실행해서 <strong>ecr-regcred</strong>라는 secret을 생성합니다.
생성한 secret은 kustomize/overlays/deployment-patch.yaml 파일 imagePullSecrets 라인에 미리 셋팅 해놨습니다.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># ${AWS_ACCOUND_ID}를 본인이 테스트 중인 AWS Account ID로 수정하세요.
# 중요한 것은 해당 명령어를 실행할때 입력한 Accound ID의 ECR 로그인 권한이 있는 AWS_ACCEESS_KEY가 등록된 서버 또는 로컬 PC에서 실행해야 합니다.
kubectl create secret docker-registry ecr-regcred \
--docker-server=${AWS_ACCOUND_ID}.dkr.ecr.ap-northeast-2.amazonaws.com \
--docker-username=AWS \
--docker-password=$(aws ecr get-login-password --region ap-northeast-2) \
--namespace=default

# 수정한 kustomize 파일을 git에 commit &amp; push 해줍니다.
git add kustomize/
git commit -m "fix: update"
git push origin master
</code></pre></div></div>

<p>imagePullSecrets 셋팅까지 완료 되었으면 이제 ArgoCD를 통해서 서비스를 배포 할 준비가 다 됐어요.
아래 경로의 파일은 Argo CD의 declarative-setup 파일 입니다. 수동으로 Application을 생성 할 수도 있지만 반복된 작업은 declarative 형식의 파일을 만들어서 사용도 가능 합니다. 아래 파일의 <strong>repoURL</strong> 부분을 본인의 repo URL로 변경하세요.</p>
<ul>
  <li>k8-cicd-argocd/argo-declarative-setup-yaml</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># 이제 Argo CD Application(k8s custom resource)을 EKS에 배포 합니다.
cd k8-cicd-argocd/argo-declarartive-setup-yaml
kubectl apply -f Application.yaml
</code></pre></div></div>
<p>Application.yaml을 Apply하고 Argo CD 화면으로 가면 사진과 같이 sample-app이 배포 되기 직전 상태가 됩니다.(OutOfSync)
Argo CD는 1분에 한번씩 Cluster의 리소스와 Argo CD가 바라보고 있는 git repo Manifest를 비교해서 서로 다른 경우에 OutOfSync 
상태를 표시 합니다. Sync 버튼을 클릭하고 -&gt; SYNCHRONIZE 버튼을 클릭해서 워크로드를 배포 합니다.<br />
<img src="/assets/images/eks-hands-on/9.png" alt="image" /></p>

<p>배포가 완료 됐지만 POD에서 에러가 발생 합니다. 에러가 발생하면 Argo CD에서 Degraded 상태를 보여주고 빨간색으로 
표시합니다. 여기서 ‘파드 클릭’ -&gt; ‘LOG 버튼 클릭’하면 사진과 같이 어플리케이션 에러 로그를 확인 할 수 있습니다.
아래 에러는 docker build를 Mac에서하고 배포를 linux에 했기 때문에 발생한 에러 입니다. 곧 gitaction을 태울것이기 때문에 일단
넘어 가겠습니다.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>exec /usr/local/bin/httpd-foreground: exec format error
</code></pre></div></div>
<p><img src="/assets/images/eks-hands-on/11.png" alt="image" /></p>

<p>일단 이렇게 CICD 중 CD 부분을 완성했습니다. 이 구조에서 포인트는 아래와 같아요.<br />
 중요하니 천천히 읽어 보세요.</p>
<ol>
  <li>ArgoCD의 ‘sample-yaml’ Application 은 k8-cicd-argocd/argo-declarative-setup-yaml/Application.yaml로 배포 했다.</li>
  <li>sample-yaml은 Application.yaml에 정의한 thingsflow-eks-cicd-hands-on.git/kustomize/overlays를 바라보고 있다.</li>
  <li>thingsflow-eks-cicd-hands-on.git/kustomize/overlays 에 변경 사항이 발견 되면 ArgoCD는 OutOfSync를 표시한다.</li>
  <li>Container의 버전은 곧 Tag 이다.</li>
  <li>k8s manifest 관리 도구인 Kustomize는 kustomization.yaml에 Container 버전을 정의한다.</li>
  <li>Container 신규 버전을 배포하는 것은 결국에는 새로 빌드된 컨터이너 태그를 kustomization.yaml에 업데이트 하는 것이다.</li>
</ol>

<h3 id="github-actions-ci">GitHub Actions (CI)</h3>
<p>위에서 말한것 처럼 이제 kustomization.yaml에 있는 태그만 변경하면 Argo CD가 변경<br />
사항을 확인하고 새로운 버전을 배포 합니다.<br />
github action의 역할은</p>
<ul>
  <li>새로운 버전을 빌드하고</li>
  <li>빌드한 것을 ecr로 push하고</li>
  <li>kustomization.yaml의 태그를 update 하는 것 입니다.</li>
</ul>

<p>먼저 Workflow에서 사용할 민감한 정보를 Actions Secret에 등록합니다.</p>

<p><strong>Github Secret 등록</strong><br />
git repo setting -&gt; Security -&gt; Secret -&gt; Actions -&gt; New Repository Secret</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># ACTION_TOKEN_K8S를 먼저 등록합니다. 
# ACTION_TOKEN_K8S는 kustomize 파일을 clone하고 container tag를 수정 후 push할때 사용합니다.
# https://github.com/settings/tokens 에서 생성 가능 합니다.
1. ACTION_TOKEN_K8S
# AWS_ACCESS_KEY는 ECR PUSH를 할때 사용 합니다.
2. AWS_ACCESS_KEY_ID
3. AWS_SECRET_ACCESS_KEY
</code></pre></div></div>
<p><img src="/assets/images/eks-hands-on/12.png" alt="image" /></p>

<p><strong>Workflow 파일 수정</strong>
.github/workflows/deploy.yaml 파일을 수정 합니다.<br />
<img src="/assets/images/eks-hands-on/13.png" alt="image" /></p>

<ol>
  <li>36 line : 본인이 생성한 ECR REPO 이름으로 수정</li>
  <li>52 line : 본인이 생성한 git repo 이름으로 수정(id/repo name)</li>
  <li>53,71 line : git 브런치 확인</li>
  <li>55 line : 본인이 생성한 repo 이름으로 수정</li>
  <li>62 line : git repo kustomize 경로로 수정</li>
  <li>67 line : 본인이 생성한 repo 이름으로 수정</li>
</ol>

<p><strong>commit &amp; push</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git add .
git commit -m "fix: workflow update"
git push origin master
</code></pre></div></div>

<p>아래 사진 처럼 workflow 실행이 완료 되면 우리는 <strong>kustomize/overlays/kustomization.yaml 파일 newTag 수정</strong>, <strong>OutOfSync</strong><br />
두가지를 확인 할 수 있어요.<br />
<img src="/assets/images/eks-hands-on/14.png" alt="image" /></p>

<p>ArgoCD에서 Deployment가 OutOfSync 상태가 된것을 확인 할 수 있고, 클릭하면 DIFF를 통해서 어떤것이 변경 되어 있는 확인 가능해요.<br />
이제 Sync 버튼을 클릭하면 새로운 버전의 컨테이너가 롤링 배포 됩니다.<br />
물론 sample-yaml을 클릭해서 수동 sync를 자동으로 변경 할 수도 있습니다.<br />
<img src="/assets/images/eks-hands-on/15.png" alt="image" /></p>

<p><strong>Service 수정 및 배포</strong><br />
이제 마지막으로 어플리케이션을 수정해서 배포 해보려고 합니다. 먼저 아래 명령어를 실행해서 Ingress와 연결된 ALB 주소를 확인 합니다.
그리고 접속해 보세요.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl get ingress -n default
</code></pre></div></div>
<p>아래 사진과 같은 화면이 나올건데요.
<img src="/assets/images/eks-hands-on/16.png" alt="image" /></p>

<p>sample-app/index.html 파일에서 아래 사진과 같이 수정 하고 git commit &amp; push 합니다. 그리고 workflow가 완료되면 Argo CD에서
Sync까지 해주세요.<br />
<img src="/assets/images/eks-hands-on/17.png" alt="image" /></p>

<p>그리고 다시 ALB 도메인을 호출 하면 아래 사진 처럼 배경색이 변경된 것을 확인 할 수 있어요. 제대로 배포 된 것입니다.<br />
<img src="/assets/images/eks-hands-on/18.png" alt="image" /></p>

<h2 id="삭제">삭제</h2>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cd terraform
terraform destroy
# EC2 Load balancer 메뉴로 가서 Ingress와 통합 됐던 ALB를 삭제하세요.
# 가끔 오랜 시간동안 "aws_security_group.this[0]: Still destroying..." 메시지가 계속 나오는 경우가 있는데
# 이런 경우에는 EC2 네트워크 인터페이스 메뉴로 가셔서 보안그룹과 연결된 네트워크 인터페이스를 수동으로 삭제 해주셔야합니다.
</code></pre></div></div>

<p>이렇게 Terraform을 활용해서 EKS를 구축하고 Argo CD와 Github Actions를 사용해서 CI/CD 파이프라인을 구축 해봤는데요.
사실 자료를 준비하면서 이 자료를 보고 완벽하게 따라 할 수 있을까 라는 걱정도 되긴 했습니다. 범위가 생각 보다 많습니다.
그래도 최대한 모든 과정을 담으려고 노력했기 때문에 아마 큰 어려운 없이 해보실수 있을 것이라고 생각합니다.</p>

<p>이번 핸즈온에서는 app repository(sample-app)와 k8s manifest repository를(kustomize) 하나로 묶어서 진행했지만
보통은 별도의 git repository로 나눠서 사용하여 운영 하고 있습니다. 핸즈온을 완료 하신 분들은
이부분도 직접 해보시면 좋을 것 같습니다.</p>

<p>감사합니다.</p>]]></content><author><name>dachan.choi</name></author><category term="tech" /><category term="review" /><category term="eks" /><category term="cicd" /><category term="terraform" /><category term="argocd" /><category term="GitOps" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">[데브인턴십 4기] 리팩터링을 할 때 생각해야 하는 것들</title><link href="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD-4%EA%B8%B0-%EB%A6%AC%ED%8C%A9%ED%84%B0%EB%A7%81%EC%9D%84-%ED%95%A0-%EB%95%8C-%EC%83%9D%EA%B0%81%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EA%B2%83%EB%93%A4/" rel="alternate" type="text/html" title="[데브인턴십 4기] 리팩터링을 할 때 생각해야 하는 것들" /><published>2022-08-25T00:00:00+09:00</published><updated>2022-08-26T08:21:00+09:00</updated><id>https://techblog.thingsflow.com/tech/%5B%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD%204%EA%B8%B0%5D%20%EB%A6%AC%ED%8C%A9%ED%84%B0%EB%A7%81%EC%9D%84%20%ED%95%A0%20%EB%95%8C%20%EC%83%9D%EA%B0%81%ED%95%B4%EC%95%BC%20%ED%95%98%EB%8A%94%20%EA%B2%83%EB%93%A4</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD-4%EA%B8%B0-%EB%A6%AC%ED%8C%A9%ED%84%B0%EB%A7%81%EC%9D%84-%ED%95%A0-%EB%95%8C-%EC%83%9D%EA%B0%81%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EA%B2%83%EB%93%A4/"><![CDATA[<h2 id="배경">배경</h2>
<p>헬로우봇 스튜디오 서버는 헬로우봇에서 챗봇을 만들기 위해서 사용하는 사내 서버입니다. 워낙 오래전에 만들어지기도 했고, 외부에 오픈되지 않는 서버라 크게 신경쓰지 않는 코드는 점점 레거시화 됐습니다.</p>

<p>그리고 사내 에디터분들이 늘어나 사용자 수가 늘어날수록, 점점 다음과 같은 문제점들이 드러나기 시작했습니다.</p>

<blockquote>
  <ol>
    <li>‘라이브 챗봇 반영’ 기능로 인한 서버 장애가 많이 발생합니다.
      <ul>
        <li>라챗반은 무결성 검사를 위해서인지 사용자가 제작한 챗봇의 데이터를 실시간으로 서버에 한번 더 반영합니다.</li>
        <li>다만 너무 과한 프로세스로 인해 서버에 장애를 많이 유발시킵니다.</li>
      </ul>
    </li>
    <li>헬로우봇 팀에는 숙련된 스프링 개발자가 없습니다.
      <ul>
        <li>반면 헬로우봇 스튜디오 서버는 Spring Framework 기반으로 작성됐기 때문에 유지보수에 어려움을 겪고 있습니다.</li>
        <li>또한, 새로운 기능을 추가할 시 서버 개발자들에게 부담이 커집니다.</li>
      </ul>
    </li>
  </ol>
</blockquote>

<p>때문에 헬로우봇 스튜디오 서버의 재개발이 요구됐고, 이에 따라 저는 멘토인 손용기님과 함께 헬로우봇 스튜디오의 재개발을 맡아서 진행하게 됐습니다. 처음 인턴 온보딩 과제로 카테고리 API부터 시작해 메뉴 API에 이르기까지 총 4개의 API를 담당했습니다. 그림은 아래와 같습니다.
<br />
<br />
<strong>[New Studio porting]</strong> <br />
| <br />
|— 카테고리 API 포팅 <br />
|— 이미지 API 포팅 <br />
|— 메뉴 API 포팅 <br />
|     |—— 태그 API 포팅 <br />
|     |—— 프리미엄 스킬 API 포팅 <br />
|     |—— 미리보기 이미지 API 포팅 <br />
|—— 검색 API 포팅 <br />
<br /></p>

<p>따라서 이 네 가지 API포팅을 진행하며 느꼈던, 생각했던 것들을 이번 포스트에 담아보고자 합니다. 사실 제가 온보딩 과제도 포팅이었고, 인턴 기간 동안 진행한 업무도 포팅이라 <a href="https://thingsflow.notion.site/5f6645c3a421439ebda40846eb2093e1">인턴 발표</a>에서 중복되지 않는 내용을 위주로 다루고자 합니다. 아마, 온보딩 결과 발표회에서 조금 더 보태고, 마지막에 추가로 진행했던 DB migration 툴에 대한 요약이 추가될 것 같습니다.</p>

<p>거두절미하고 주제를 말씀드리자면, <strong>‘코드를 작성할 때 고려해야되는 점’</strong>입니다.
<br />
<br />
<br /></p>

<h2 id="클린-코드"><a href="https://github.com/cyw320712/clean-code-java">클린 코드?</a></h2>
<p>사실 제가 띵스플로우로 인턴을 오기 직전, 저는 학교에서 연결해주는 코칭 프로그램에서 spring 관련 코칭을 받고 있었습니다. 시니어 개발자님께 직접 짠 <a href="https://github.com/cyw320712/WebtoonMoa">코드</a>를 리뷰 받고, 스프링과 관련된 기초 지식들, 책이나 강의에서 보는 내용과 현업에서 실제로 어떻게 사용하는지 차이점 등을 배워볼 수 있는 좋은 기회였습니다.</p>

<p>해당 프로그램을 들으며 제는 <strong><a href="https://github.com/cyw320712/clean-code-java">클린코드</a></strong>에 관심을 갖기 시작했습니다. <br />
(<a href="https://github.com/cyw320712/clean-code-java">위의 링크와 여기 링크는 제가 클린코드 책을 보며 정리한 내용들을 기록하는 레포입니다. 출퇴근길에 가볍게 보기 좋게 정리하고 있습니다.</a>)</p>

<p>마침 클린코드를 읽고 있던 제게 리팩토링은 실제 비즈니스 로직에 대한 고민보다는 클린코드를 공부하며 배웠던 내용들에 대한 고민을 집중할 수 있는 아주 효과적인 업무였습니다.
<br />
<br />
<br /></p>

<h2 id="리팩토링을-할-때-고려할-점">리팩토링을 할 때 고려할 점</h2>

<p>때문에 이번 글에서는 코드의 클린함을 생각하며 개발하는 기준에 대해서 다루고자 합니다. 즉, 스스로 책을 읽으며 얻었던 지식들을 실제로 적용하면서 체득한 내용에 대해서 다룹니다.</p>

<p>목차는 다음과 같습니다.</p>

<p><br /></p>

<ol>
  <li><a href="#1-하나의-함수에선-하나의-추상화">하나의 함수에선 하나의 추상화</a></li>
  <li><a href="#2-신뢰할-수-있는-코드">신뢰할 수 있는 코드</a></li>
  <li><a href="#3-재사용성을-고려한-일반화">재사용성을 고려한 일반화</a></li>
  <li><a href="#4-코드의-작성은-신문-기사처럼">코드의 작성은 신문 기사처럼</a></li>
</ol>

<p><br />
그럼 하나씩 살펴보겠습니다.
<br />
<br /></p>

<h2 id="1-하나의-함수에선-하나의-추상화">1. 하나의 함수에선 하나의 추상화</h2>

<p>하나의 함수에선 하나의 추상화 수준만 사용하는 것이 좋습니다. 잠깐, 여기서 추상화 수준이 뭘까요? 추상화 수준은 다음과 같이 정의할 수 있습니다.</p>

<blockquote>
  <h3 id="추상화-수준이란">추상화 수준이란?</h3>
  <p><strong><em>특정 표현이 추상적인 개념에 가까운지 세부적인 구현에 가까운지의 정도</em></strong> 를 말합니다. 실제로 사칙연산이나 할당, 검증을 실제로 하는 로직은 세부적인 구현에 가깝고, ‘검증해라’, ‘할당해라’, ‘연산해라’ 등의 명령형 함수들을 호출해서 사용하는 것은 개념에 가깝다고 할 수 있습니다.
코드로 예시로 살펴봅시다. <br /></p>

  <p><strong>낮은 추상화 수준</strong></p>

  <div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">directCount</span><span class="o">(</span><span class="nc">List</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span> <span class="n">inputList</span><span class="o">)</span> <span class="o">{</span>
    <span class="kt">int</span> <span class="n">result</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>

    <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">inputList</span><span class="o">.</span><span class="na">size</span><span class="o">();</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
		     <span class="n">result</span> <span class="o">+=</span> <span class="n">inputList</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">i</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="k">return</span> <span class="n">result</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div>  </div>
  <p><strong>높은 추상화 수준</strong></p>

  <div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">processInput</span><span class="o">(</span><span class="nc">UserReqDto</span> <span class="n">userRequest</span><span class="o">)</span> <span class="o">{</span>
	<span class="n">validateRequest</span><span class="o">(</span><span class="n">userRequest</span><span class="o">);</span>

	<span class="kt">int</span> <span class="n">result</span> <span class="o">=</span> <span class="n">directCount</span><span class="o">(</span><span class="n">userRequest</span><span class="o">.</span><span class="na">getInputList</span><span class="o">());</span>
	
	<span class="k">return</span> <span class="k">new</span> <span class="nf">UserResDto</span><span class="o">(</span><span class="n">result</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div>  </div>
</blockquote>

<p>위의 예제 코드에서 볼 수 있듯 어떤 함수는 다른 함수를 호출하기만하여 <strong>그 함수들의 실행 순서를 결정하는</strong> 역할을 맡게 될 것이고, 어떤 함수는 구체적으로 어떻게 그 로직을 어떻게 구현할지 결정하게 됩니다.</p>

<p>이렇게 하나의 함수의 추상화 수준을 하나로 맞추는 것은 상당히 번거로운 일입니다. 하지만, 그 번거로운 과정이 끝나게 된다면, 아래와 같이 해당 함수의 내부에서 사용되는 함수들의 메소드 이름만 읽고도 이 함수가 어떤 기능을 하는지 쉽게 파악할 수 있게 됩니다.</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/763e9eeb-2d9f-4e5e-b8b7-be845500d56f/image.png" alt="" /></p>

<p>위 함수는 검색 API의 검색 서비스 로직입니다. 사용자가 설정한 조건에 맞춰 검색 결과를 내리는 함수인데, 대충 읽어봐도 어떤 흐름을 통해서 어떤 변수가 어떻게 저장되고, 어떤 값이 반환될지 예상할 수 있습니다.</p>

<p>반면 다음 코드를 한 번 봅시다.</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/94660665-1641-4416-a795-4f99e587aae2/image.png" alt="" /></p>

<p>같은 동작을 하는 코드의 추상화 수준을 조금만 섞여다는 이유 하나만으로 이 함수가 어떤 역할을 하는 함수인지 파악하기 어려워졌습니다. 설령 자신은 한 눈에 파악할 수 있다고 하더라도, 자신과 협업하는 다른 프로그래머가 이해하기 힘들다면, 그건 좋은 코드라고 부르기 어려울 것 같습니다.</p>

<p>조금이라도 깨져있는 유리창을 깨는 것은, 그렇지 않은 유리창을 꺠는 것보다 죄책감이 덜하다고 합니다. 같은 이유로, 추상화 수준이 섞여있는, 한 번 지저분해진 코드는 다음 사람으로 하여금 더욱 코드를 더럽히기 쉽게 만듭니다.</p>

<p>따라서 코드를 작성하실 때는 최대한 추상화수준을 하나로 맞추시는게 좋습니다.
<br />
<br /></p>

<h2 id="2-신뢰할-수-있는-코드">2. 신뢰할 수 있는 코드</h2>

<p>신뢰할 수 있는 코드는 그 내부 로직을 깊게 들여다보지 않아도 그냥 잘 동작하겠거니 믿을 수 있는 코드를 의미합니다.</p>

<p>처음 보여드렸던 코드 내부에서 사용되는 메서드들은, 그 이름만 읽고도 어떤 식으로 동작할지 예상할 수 있습니다. 또한 <strong>실제로도 그렇게 동작합니다.</strong></p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/84906e33-ee0d-4330-8859-cf5e1a411ce8/image.png" alt="" /></p>

<p>위 코드는 이름에서와 같이 입력받은 BlockList에 포함된 각각의 block들을 가지고 맞는 값들을 추출하여 하나씩 SearchDto를 만들어 반환합니다. 다만 단지 아래 한 줄만 보고서도 이런 내용이 유추되고, 실제로도 그 유추한 절차대로 작동합니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">convertBlockToSearchDto</span><span class="p">(</span><span class="nx">blockList</span><span class="p">);</span>
</code></pre></div></div>

<p>다음은 같은 코드에서 사용되는 다른 메서드인 <code class="language-plaintext highlighter-rouge">paginate</code>를 가져왔습니다.</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/72ea461e-a3c9-4275-994d-bed122ff16b7/image.png" alt="" /></p>

<p>paginate라는 함수 또한, 그 이름에서 유추할 수 있듯 사용자의 paging 입력과 전체 목록을 받아 지정된 목록에서 해당 페이징이 가리키는 부분을 추출해 반환합니다. 한마디로 입력받은 List의 paginate된 결과를 반환합니다.</p>

<p>이번에도 역시 함수의 프로파일을 봐봅시다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">paginate</span><span class="p">(</span><span class="nx">inputList</span><span class="p">,</span> <span class="nx">pagingReq</span><span class="p">);</span>
</code></pre></div></div>

<p>단순히 프로파일을 보는 것만으로도 해당 메서드의 역할을 짐작할 수 있습니다.</p>

<p>이런식으로 <strong>함수의 이름만 보고도 그 함수가 어떤 역할을 하는지 예상을 할 수 있으며, 실제로 그 역할만을 하고, 다른 부가적인 영향을 만들어내지 않는, 즉 신뢰성이 높은 코드</strong>는 아주 중요합니다. 신뢰도가 높은 코드는 프로그래머로 하여금 코드를 빠르고 쉽게 재사용하도록 만들고, 이는 결국 높은 생산성으로까지 연결될 수 있기 때문입니다.</p>

<blockquote>
  <p><strong>불신</strong> <br />
<em>doAboutPagingToInputList…? 무슨 함수지..? 가져다 써도 될까…? 하.. 로직을 또 하나하나 봐야겠구나. or 그냥 새로 처음부터 짜자…</em></p>

  <p><strong>신뢰</strong> <br />
<em>Paginate? 페이징 함수구나! 우리 회사 사람이 짠 코드니까 신뢰해도 되겠지?</em> <br />
<em>바로 가져다씀 -&gt; 오류 없이 그대로 동작.</em> <br />
<em>와! 재사용을 통해 시간을 아꼈다! (약 30분, 모이면 30시간)</em></p>
</blockquote>

<p>코드를 작성시 메서드 명명법부터 시작해서 해당 메서드의 로직, 사이드이펙트 등을 충분히 생각해서 작성한다면, 그 코드는 신뢰할 수 있게 됩니다.</p>

<p>다음은 제가 생각하는 신뢰할 수 있는 코드를 작성하는 기준들입니다. 제 기준이 정답은 아니지만, 최소한 다른 사람은 이런 부분들을 고려하려 하는구나, 이런 것들도 고려할 수 있겠구나 정도로 귀엽게 봐주셨으면 합니다.
(이런 것도 생각하면 좋아요~의 의견 제시는 언제나 환영입니다!!)</p>

<blockquote>
  <ol>
    <li>이름을 동작과 일치시키기</li>
    <li>부가적인 동작 발생시키지 말기</li>
    <li>하나의 함수에서는 하나의 동작만</li>
    <li>함수로의 인자 전달 및 결과값 반환은 명시적으로 진행하기 (전역변수 사용 최소화)
      <ul>
        <li>BAD: <code class="language-plaintext highlighter-rouge">await this.exampleDoing();</code></li>
        <li>GOOD: <code class="language-plaintext highlighter-rouge">const exampleResult = await this.exampleDoing(exampleParams);</code></li>
      </ul>
    </li>
  </ol>
</blockquote>

<p>당장 생각나는 것은 여기까지입니다.
<br />
<br /></p>

<h2 id="3-재사용성을-고려한-일반화">3. 재사용성을 고려한 일반화</h2>

<p>재사용성을 고려한 일반화의 경우는 지난 온보딩 발표 때도 한 번 언급한 바 있습니다. 하지만, 일반화 자체는 생산성을 높이는 기준으로 일반화는 너무 중요한 것 같아서 다시 한 번 언급하게 됐습니다.</p>

<p>함수를 너무 일반화하는 것은 사용자로 하여금 이해하기 함수의 역할을 이해하기 힘들게 만들고 또 어떤 상황에서 사용할지 잘 파악하기 어렵게 만들기도 합니다. 하지만, 일반화되지 않은 함수는 특정된 하나의 목적만을 위해서 사용되어야 하고, 때문에 코드의 중복 및 생산성 하락을 야기하게 됩니다.</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/72ea461e-a3c9-4275-994d-bed122ff16b7/image.png" alt="" /></p>

<p>다시 가져온 페이지네이션 함수. 어떤 EntityList와 페이징 리퀘스트를 넣어도 반응할 수 있도록 만든, 일반화에 성공한 케이스라고 보시면 좋을 것 같습니다.
<br />
<br /></p>

<h2 id="4-코드의-작성은-신문-기사처럼">4. 코드의 작성은 신문 기사처럼</h2>

<p>코드는 신문 기사를 작성하듯 만들어야한다고 합니다. 가장 먼저 추상적인 내용들이 나오고, 그 추상적인 내용들에 대해 구체적인 내용이 뒤따릅니다. 예를 들면 아래와 같습니다. (정말 간단한 예시)</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/bc305934-9777-4fb5-9d1a-13bf27ce2de4/image.png" alt="" /></p>

<p>이렇게 추상화 수준이 높은 함수부터 차례로 낮아지는 함수를 작성한다면, 마치 신문 기사를 읽듯, 의식의 흐름대로 코드를 읽을 수 있게 됩니다. 예를 들어보자면 아래와 같은 과정을 밟을 수 있겠습니다.</p>

<blockquote>
  <ol>
    <li>아~ 사용자가 입력한 수에 대한 처리 결과를 가져오겠구나.</li>
    <li>아~ 사용자가 입력한 수가 0인지 아닌지 검증하는구나.</li>
    <li>아~ 사용자가 입력한 수는 이렇게 처리하는구나.</li>
  </ol>
</blockquote>

<p>사실 이렇게 추상화가 위에서부터 아래로 내려가는 것은 아주 기초적인 개념이긴합니다. 때문에 가볍게 언급할 겸 마지막으로 추가했습니다.
<br />
<br /></p>

<h2 id="첫-번째-후기">첫 번째 후기</h2>

<p>이쯤에서 발표의 흐름이 달라지기 때문에 미리 후기를 추가하려고 합니다.</p>

<p>가장 먼저 저는 마침 클린코드를 공부하고 있었기에 담당하게된 리팩토링 업무와 잘 연관지어 최대한 이론을 실전에 적용하려고 노력했습니다. 또한 제가 겪었던 프로젝트 설치 과정에서 겪었던 문제점들이나 어떻게 하는줄 몰라서 고생했던 부분들을 모두 문서로 남겨 다음 사람들에게는 같은 고생으로 인한 시간낭비를 줄인 것이 잘한 점이라고 생각합니다.</p>

<p>또한, 이번 업무를 진행하면서 이론으로만 배울 수 없었던 점들, 실제로 운영하고 있는 서비스를 이루는 코드들을 볼 수 있었기에 많은 것들을 배울 수 있었습니다. 하나의 간단한 서비스 로직에도 몇 중의 인증 과정을 거치는 것이 필요하다는 점 등은 혼자서 토이프로젝트를 진행하면서는 생각하기 힘든 부분이었습니다. 이외에도 프로젝트라는 배를 앞으로 나아가게 하기 위해서 필요한 여러 수고들과 과정 등까지도 많이 배울 수 있었습니다.</p>

<p>추가로 진행하고있는 DB Migration은 시간상의 문제로 아직 전부 완성하지는 못했지만, 앞으로 혼자 틈틈이 유지하면서 완성시켜보도록 하겠습니다.
<br />
<br />
<br />
<br /></p>

<h1 id="부가-발표-그래서-db-migration은-뭔데">부가 발표: 그래서 DB Migration은 뭔데?</h1>

<p>4개의 API에 대한 포팅을 완료했지만, 테스트코드를 작성하지 않아서인지, 혹은  너무 빠른 속도로 끝나버려서 인턴십 기간이 7일가량 남았습니다.</p>

<p>때문에 새로운 API 포팅을 맡기엔 부족한 시간이라, 남는 시간에 DB 마이그레이션 툴을 한 번 제작해보는 것은 어떻겠냐고 제안해주셔서, 해당 부분을 진행하게 됐습니다.</p>

<p><img width="70%" src="https://velog.velcdn.com/images/cyw320712/post/fc4e0014-3e68-427a-9fb1-30d56bd50498/image.png" /></p>

<p>현재 헬로우봇 스튜디오 서버의 DB는 NoSQL인 MongoDB를 사용하고 있습니다. Mongo에도 여러 장점이 있긴 하지만, 사용할수록 몇 가지 큰 단점들이 나타나고 있습니다. 간단하게 정리하자면 아래와 같습니다.</p>

<h2 id="단점">단점</h2>

<blockquote>
  <ol>
    <li>Join을 사용해 테이블 사이의 관계를 이용하는 것이 불가능합니다.</li>
    <li>Mongo 내에서 계층구조를 표현하기는 쉽지 않습니다.
      <ul>
        <li>이를 위해서는 관련된 collection을 모두 가져와 직접 비교해가면서 연관된 것을 추출한 뒤 표현해야 합니다.</li>
      </ul>
    </li>
    <li>데이터 구조의 변경이 용이하다는 장점은 외려 단점으로 다가오기도 합니다.
      <ul>
        <li>극단적으로, 한 API에서는 {userId, name}으로 추가되던 데이터가 다른 API에서는 {id, username}으로 저장 중일 수도 있습니다.</li>
      </ul>
    </li>
  </ol>
</blockquote>

<p>애초에 MongoDB는 검색을 빠르게 하는 용도로 사용됐어야 하지만, 저희 헬로우봇 스튜디오에서는 이를 RDB처럼 사용하고 있었습니다. 때문에 데이터가 쌓일수록 더욱 부각될 문제이며, 언젠간 해야할 마이그레이션이라면 뉴스튜디오 포팅이 진행중인 지금이 적기라고 생각돼 시작됐습니다.</p>

<p>최대한 빨리 진행해야했기 때문에 멀티스레딩을 사용할 생각을 했고, 데이터를 처리하는 과정은 ORM과 ODM을 사용하는 것이 가장 확실하다고 생각했습니다. 즉, <strong>멀티 스레딩 인터페이스가 잘 갖춰져있고, 신뢰할 수 있는 튼튼하 ORM이 존재하는 언어로 진행하고자 했습니다.</strong> 쉽게 떠올릴 수 있듯 이는 Java였습니다.</p>

<p>Java에서도 특히 Spring을 사용해서 진행했는데 이유는 아래와 같습니다.</p>

<blockquote>
  <ol>
    <li>Spring은 Spring-data-mongo와 spring-data-jpa등의 신뢰할 수 있는 ORM, ODM을 지원합니다.</li>
    <li>기존 스튜디오 코드가 Spring으로 작성됐기 때문에 Entity를 추가하는 과정에서 발생하는 오버헤드를 최소화할 수 있습니다.</li>
  </ol>
</blockquote>

<p>사실 두 번째 이유가 좀 컸는데, 발표를 제외하고 주어진 시간이 단 4일이었기 때문에 빠르게 만들었어야 합니다. (특히 스프링에 두 개의 다른 DB.. 심지어 NoSQL과 RDB..를 연결한 소스가 거의 전무했기 때문에… 저는 주말 이틀동안 거의 스프링 세팅에 사용했습니다..)
<br />
<br /></p>

<h2 id="설계">설계</h2>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/b2a27f39-36f0-4c25-b21b-57779bf56fc4/image.png" alt="" /></p>

<p>프로젝트 설계는 다음과 같습니다. 최대한 제 생각을 제한하지 않도록 생각할 시간을 주신 멘토 분에 처음 이렇게 멀티 스레드를 사용해야되는 프로그램을 설계해봤습니다. (처음 설계한 것이므로 오류가 있을 수 있습니다.. 지적해주시면 감사하겠습니다.)</p>

<p>전체 프로세스를 간략하게 묘사하자면, ODM이 Mongo의 데이터를 읽어오고, Jpa Entity인 MigrationEntity로 변환합니다. 이후 ORM인 JPA가 해당 데이터를 저장합니다.</p>

<p>주의 깊게 보실 부분은 <strong>3. 멀티스레딩으로 진행하는 방법</strong>입니다. 결국 결정적인 프로세스를 담당하는 worker의 설계입니다. 크게 2개의 설계를 생각해봤습니다.</p>
<ol>
  <li>하나의 스레드가 entity를 읽고, 변환해서 저장하는 과정까지 한 번에 진행하는 <code class="language-plaintext highlighter-rouge">All rounder</code> 모델</li>
  <li>reader는 읽어오기만 하고, writer는 변환해서 저장하기만 하는 <code class="language-plaintext highlighter-rouge">Reader/Writer</code> 모델</li>
</ol>

<p>각각에 대해서 떠올릴 수 있는 장단점을 정리하고, 용기님께 공유했습니다.</p>

<p><img width="70%" src="https://velog.velcdn.com/images/cyw320712/post/e9af3ef0-1872-4e86-b4e3-c08f2d0fb23c/image.png" /></p>

<p>원래라면 reader/writer 모델로 작성하고, 동시성 문제를 최소화하도록 탄탄히 설계하는 것이 더 좋았을 것 같지만 (혼자 개발을 해보면서 이쪽으로도 개발해보려고 합니다.) 당장 지금은 거기까지 고민할 시간이 없으니 <code class="language-plaintext highlighter-rouge">All Rounder</code> 모델로 개발을 진행했습니다.</p>

<p>이건 마이그레이션 용도로 만들고 있는 마이그레이션 툴의 레포입니다. <br />
https://github.com/cyw320712/DBMigrator
<br /></p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/a9d0d577-e523-4c20-907e-658083f2f3b5/image.png" alt="" /></p>

<p>간단하게 구조를 먼저 그려보겠습니다.</p>

<p><strong>[프로젝트 폴더]</strong></p>
<ul>
  <li><strong>common</strong>
    <ul>
      <li>공통으로 사용되는 유틸들이 있는 폴더입니다.</li>
      <li>특히 Entity scanning과정에서 최상위 추상화 Entity들을 제외하기 위해서 해당 폴더로 대피시켰습니다.</li>
    </ul>
  </li>
  <li><strong>config</strong>
    <ul>
      <li>NoSQL과 RDB를 동시에 연동하기 위해선 Application.yml만으론 충분하지 않고, CustomDBConfig를 추가해줘야 합니다.</li>
      <li>이외에도 지금 막힌 부분인 <strong>동적 레포지토리 빈 추가</strong>를 위한 설정 파일이 추가될 수도 있습니다.</li>
    </ul>
  </li>
  <li><strong>controller</strong>
    <ul>
      <li>스프링에서 서비스를 실행시키는 Trigger를 만들기 위해서 간단하게 root url로의 get 요청만 파싱해뒀습니다.</li>
      <li>추후에 해당 migrator를 일반화에 성공한다면, 사용자가 엔티티 특성을 입력하고, 어떤 DB에서 어떤 DB로 옮길지, 어떤 converting 과정을 거칠지까지 웹에서 입력할 수 있습니다.</li>
    </ul>
  </li>
  <li><strong>domain</strong>
    <ul>
      <li>실제로 파싱할 Entity 파일들이 들어가있습니다. (Legacy -&gt; Migration)</li>
      <li>thingsflow의 Entity들은 커밋하지 않도록 제외해뒀습니다.</li>
    </ul>
  </li>
  <li><strong>service</strong>
    <ul>
      <li>메인 마이그레이션 로직이 포함된 서비스입니다.</li>
      <li>현재는 사용자가 생성한 MongoRepository, JpaRepository를 읽어와 등록하고 하나씩 처리하는 방식입니다.</li>
      <li>지금 개선하는 부분은 MigratorService는 사용자가 저장한 Entity 목록만 읽어서 레포지토리를 동적으로 등록하도록 구현하려고 하고 있습니다.</li>
    </ul>
  </li>
</ul>

<p>프로젝트는 이런 구조로 만들어져있습니다. 메인 로직을 살펴보겠습니다.</p>

<h3 id="준비과정">준비과정</h3>
<p><img src="https://velog.velcdn.com/images/cyw320712/post/980b036d-9272-4c06-b174-1a76da332a2c/image.png" alt="" /></p>

<p>가장 먼저 마이그레이션을 준비하는 메소드입니다. <strong>extractSpecifiedEntities 함수는 지정한 단어가 포함된 엔티티 목록을 Domain 폴더에서 가져옵니다.</strong> 이후 추출한 Entity 목록을 통해 Repository를 동적으로 스프링 빈에 등록하고, 마지막으로 등록된 Repository들을 가져와서 taskQueue에 담아주면 준비가 완료됩니다.</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/b8e526a2-6afb-405a-b8a3-174f13160ba7/image.png" alt="" /></p>

<p>하지만 보실 수 있듯 JpaRepository에 등록하기 위해서는 명시적인 ClassType이 필요합니다… 또한 JpaRepository가 추상 인터페이스라 인스턴스화 할 수 없다는 벽 또한 마주하고 있습니다. 인턴십이 종료되면 JpaRepository나 MongoRepository가 어떤 과정을 거쳐서 스프링 내부에서 인스턴스화 되는지 파악하고, 해당 과정을 동일하게 거쳐서 등록하려고 합니다.</p>

<p>사실 그냥 사용자에게 Repository 코드까지 작성하도록 구현해도 괜찮습니다. 하지만, 해당 프로젝트에서 사용하는 JpaRepository와 MongoRepository의 method는 findAll과 save로 한정됩니다. 때문에 해당 레포지토리 코드를 들어가면 텅 빈 코드를 마주하게 됩니다.</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/80061388-2609-4f6c-ac24-b826673f725f/image.png" alt="" /></p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/a22d7cd2-6e3d-4c91-8709-76be04281d13/image.png" alt="" /></p>

<p>이런 코드가 중복되는 것은 그냥 수긍하실 수도 있습니다. 하지만 사용자가 마이그레이션 하려는 Entity마다 모두 저런 코드를 생성해줘야하는 것은 분명한 단점으로 다가옵니다.</p>

<p>당장 저희 헬로우봇 스튜디오에서 마이그레이션하고자 하는 Entity(Table) 개수만해도 57개였습니다. legacy와 migration 양쪽 모두에 해당 코드를 생성해줘야 하므로 총 114개의 사실상 비어있는 파일을 생성해줘야하는 번거로움이 있습니다.</p>

<p>어쨋든 지금은 저렇게 레포지토리를 직접 생성해주면 동작하도록 구현중입니다. 메모리나 각 테이블별 워크로드를 조사해 각각의 스레드에 할당할 작업을 분배하는 것도 추후에 작업으로 예정되어 있습니다.</p>

<h3 id="마이그레이션">마이그레이션</h3>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/1f64f72d-b91b-4498-a345-518d198344ed/image.png" alt="" /></p>

<p>사용자가 localhost:8080/으로 get요청을 보내면 간단히 마이그레이션 서비스의 이 migrate함수를 호출합니다. 그럼 해당 함수는 readyMigration을 호출하고, 현재 프로세서에서 생성가능한만큼의 스레드를 생성합니다.</p>

<p>이후 추가로 읽기 -&gt; convert -&gt; 저장하기 과정을 진행하는 <code class="language-plaintext highlighter-rouge">Migrator</code> 클래스를 쓰레드별로 할당시키고, 완료되면 완료됐다고 Complete 메세지를 띄워줍니다. (사실 저 과정도 그냥 10초 뒤에 완료됐다고 뜨고 백그라운드에서 작업은 계속 진행하던데… 이부분에는 디버깅이 필요합니다.)</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/070573a2-cc05-4c64-be5f-1eae6983b91f/image.png" alt="" /></p>

<p>간단한 Migrator Class입니다. Task가 담긴 queue를 넣으며 클래스를 선언하고, 각각의 thread는 task에서 각각의 레포지토리를 가져와 읽고 convert하고 저장합니다. 이렇게 메인 코드가 단순화될 수 있는 배경에는 <strong>추상화</strong>가 있습니다.</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/d5328e10-7e42-4643-b0e6-076f54224b5b/image.png" alt="" /></p>

<p>간단한 추상화 인터페이스를 생성하고, 각 Legacy Entity는 이 interface의 구현체로써 존재합니다.</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/e8bb53f9-a570-429c-a434-c93b7e655769/image.png" alt="" /></p>

<p>이후 각 Entity에서는 위 인터페이스에 대한 Convert 함수를 자신의 데이터에 맞게 구현합니다. 이렇게 각 Entity에 구현된 convert 함수를 사용하기만 하면, 특정 Entity에 특성화된 migrate 함수를 작성할 필요 없이 일반화된 함수로 구현할 수 있게 되는겁니다!</p>

<p><img src="https://velog.velcdn.com/images/cyw320712/post/956b56a5-a37c-4e7d-9fe3-d640dd141151/image.png" alt="" /></p>

<p>위 코드를 보시면 task에서 가져온 임의의 레포지토리를 통해서 데이터를 findAll로 가져오고, save할 뿐 Entity에 맞춰서 어떤 작업을 추가로 수행하지는 않습니다. 그렇게 Save까지 완료되면 모든 과정이 완료됩니다.</p>

<p>아쉬운 점이라면, 당장 시간이 부족해 메모리가 어느 정도까지 버틸 수 있는지 파악하지 못했고, 때문에 한 번에 가져와 한 개씩 save하고 있다는 점입니다. 현재 더미 데이터로 테스트해본 결과 1만개의 간단한 User Entity에 대해서 30초 정도의 시간이 소요됩니다. 만약 한 1000개씩만이라도 bulk insert하도록 변경한다면 획기적인 시간 단축을 구현할 수 있을 것이라 생각됩니다.</p>

<p>또한 현재 레포지토리를 동적으로 생성해서 빈에 등록하는 과정에서 막혀있는데 워낙 소스가 없어서 이게 가능한건지조차 모르고 있습니다. 우선은 스크립트로 레포지토리를 생성하고, 추후에 코드 없이도 동적으로 등록할 수 있도록 수정하려고 합니다.
<br /></p>

<h2 id="두-번째-후기">두 번째 후기</h2>

<p>DB 마이그레이션은 사실 aws 등에서 지원하는 20만원 상당의 서비스가 이미 존재하기 때문에 그걸 이용하면 되긴 합니다. 그럼에도 불구하고 직접 멀티스레딩을 사용하는 프로그램을 제작하고, 수백만 건의 데이터에 대해서 직접 사용해볼 일이 많지는 않았기에 정말 소중한 경험을 얻을 수 있었습니다.</p>

<p>아직 남아있는 구현 요구사항들은 아래와 같습니다.</p>

<blockquote>
  <ol>
    <li>메모리가 터지지 않도록 한 번에 가져오고, 변환해서 저장하는 데이터의 양을 찾고 그 안에서 프로세스가 동작하도록 구현합니다.</li>
    <li>레포지토리 및 엔티티를 사용자의 입력에 맞춰 동적으로 생성해 스프링 빈에 등록하고, 사용할 수 있도록 구현합니다.</li>
    <li>DB connection의 개수에 맞춰 스레드를 생성할 수 있도록 수정합니다.</li>
    <li>모니터링 스레드 등을 추가하여 데이터의 무결성에 문제가 발생하는 일이 없도록 수정합니다.</li>
    <li>사용자가 지정한 DBMS에 맞게 알아서 레포지토리를 수정해 추가합니다.</li>
    <li>사용자가 지정한 convert 로직에 맞춰 데이터를 수정할 수 있도록 합니다.</li>
  </ol>
</blockquote>

<p>… 등의 다양한 추가 기능을 구현할 수 있습니다. 비록 이제는 남은 인턴 기간이 없기 때문에 더이상 진행할 수 없지만, 퇴사 이후로도 홀로 유지보수해 쓸만한 툴이 될 수 있도록 만들어보겠습니다.</p>]]></content><author><name>yongwoo.choi</name></author><category term="tech" /><category term="cleancode" /><category term="hellobot-studio" /><category term="refactoring" /><category term="db migration" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">[데브인턴십 4기] Between의 Zookeeper 활용</title><link href="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD-4%EA%B8%B0-Between%EC%9D%98_Zookeeper_%ED%99%9C%EC%9A%A9/" rel="alternate" type="text/html" title="[데브인턴십 4기] Between의 Zookeeper 활용" /><published>2022-08-25T00:00:00+09:00</published><updated>2022-08-25T00:00:00+09:00</updated><id>https://techblog.thingsflow.com/tech/%5B%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD%204%EA%B8%B0%5DBetween%EC%9D%98_Zookeeper_%ED%99%9C%EC%9A%A9</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD-4%EA%B8%B0-Between%EC%9D%98_Zookeeper_%ED%99%9C%EC%9A%A9/"><![CDATA[<h2 id="zookeeper-란">Zookeeper 란?</h2>

<hr />

<h3 id="주키퍼의-기능">주키퍼의 기능</h3>

<p>지금까지 어떤 프로젝트를 수행해 왔는지 설명했고 지금부터 세부 내용에 대해 설명드리겠습니다. 먼저 ‘zookeeper가 무엇이고 어떻게 작동하는지’입니다.</p>

<blockquote>
  <p><strong>주키퍼의 핵심 기능</strong>
개발자가 사용해야 하는 기능: Tree 구조, Watcher
주키퍼가 보장해주는 기능: 에러 확장 방지, 일관성, 원자성</p>
</blockquote>

<p>주키퍼가 제공하는 핵심 기능을 위와 같이 5개로 나타냈습니다. 그리고 이 특징을 ‘개발자가 사용해야 하는 기능’, ‘주키퍼가 보장해주는 기능’ 으로 분류했습니다.</p>

<p>먼저 “개발자가 사용해야 하는 기능”을 보겠습니다.</p>

<blockquote>
  <p>Tree 구조, Watcher</p>
</blockquote>

<p>주키퍼의 data 저장 방식은 tree 자료구조를 사용합니다.</p>

<p><img src="https://velog.velcdn.com/images/www8989/post/56571b85-dbc0-4add-9c9a-8fa64e104b12/image.jpg" alt="" /></p>

<p>다음은 Between 주키퍼의 구조를 간략하게 나타낸 것입니다. 주키퍼는 node 하나를 znode라고 부르고 있습니다. 개발자들은 server에서 특정 path의 znode에다가 watcher를 등록할 수 있습니다. 해당 znode의 data가 변경,삭제되면 watcher를 통해 서버에서 이 사실을 알 수 있습니다. 그러면 서버는 znode의 data를 다시 읽어와 해당 서버의 data를 최신화 시킵니다. 즉, distribution은 watcher + server의 data 업데이트 logic으로 이루어 진다고 볼 수 있습니다. watcher를 등록하고 watcher에 의해 event가 감지됐을 때 server에서 data를 다시 읽게 하는 callback function을 만드는 것이 개발자가 할 일입니다.</p>

<p>다음으로 “주키퍼가 보장해주는 기능” 을 보겠습니다.</p>

<blockquote>
  <p>에러 확장 방지, 일관성, 원자성</p>
</blockquote>

<p><img src="https://velog.velcdn.com/images/www8989/post/3dc7cb14-5590-4b42-93ff-4ed10157919a/image.png" alt="" />
출처: https://ssup2.github.io/theory_analysis/ZooKeeper/</p>

<p>다음 그림에서 나타내는 것이 주키퍼 서버 클러스터입니다. 여러 Client가 주키퍼 클러스터에 연결되어 있는것을 보실 수 있습니다. Client는 주키퍼를 통해 config distribute되는 비트윈 서버들이겠죠. 보시다시피 주키퍼 서버는 여러 서버들과 연결되어 있기 때문에 주키퍼 서버에서 에러가 생기면 그 에러는 여러 서버로 퍼져나가게 됩니다.따라서 주키퍼는 주키퍼 서버의 에러를 서버전체로 퍼뜨리지 않기 위한 기능을 제공합니다.</p>

<p>여기서 클러스터의 존재 이유가 나옵니다. 다음과 같이 주키퍼는 주키퍼 서버를 여러개두고 이를 클러스터로 관리합니다. 클러스터내에 과반수 이상의 주키퍼 서버가 살아 있고 같은 data를 반환한다면 해당 data를  통해 일관성을 유지하고 서비스를 지속합니다. 만약에 5개의 서버가 클러스터를 이루고 있을 때 2개의 서버에서 에러가 터져도 주키퍼 시스템은 그대로 유지된다는 것입니다. 이러한 과반수 시스템이 주는 특징중 하나가 클러스터내의 주키퍼 서버를 홀수개로 유지하는게 좋다는 것입니다.</p>

<p>상황을 들어봅시다.</p>

<p><img src="https://velog.velcdn.com/images/www8989/post/2fe037db-1f03-434c-be8a-2ae8ce91915f/image.png" alt="" /></p>

<p>5개의 서버가 있을 때 2개의 서버가 다운되는 것은 문제가 되지 않고 3개의 서버가 다운되는 것은 문제가 됩니다. 과반수이상의 서버가 살아있어야 하니까요. 그런데 6개의 서버가 있을 때에도 2개의 서버가 다운되는 것은 문제가 안되지만 3개의 서버가 다운되는 것은 문제가 됩니다. 따라서 안정성이 똑같으면서 더 적은 resource로 관리가 가능한 것은 홀수개의 서버라는 것입니다. 이 때문에 주키퍼에서도 홀수개의 서버로 클러스터를 구성할 것을 권장하고 있습니다.</p>

<h3 id="주키퍼는-분산처리를-어떻게-하는가">주키퍼는 분산처리를 어떻게 하는가?</h3>

<p>다음으로 분산 처리가 어떻게 이루어 지는지 설명드리겠습니다.</p>

<p><img src="https://velog.velcdn.com/images/www8989/post/3dc7cb14-5590-4b42-93ff-4ed10157919a/image.png" alt="" />
출처: https://ssup2.github.io/theory_analysis/ZooKeeper/</p>

<blockquote>
  <ol>
    <li>client의 update 요청</li>
    <li>팔로우 서버는 update 요청을 리더 서버로 넘김.</li>
    <li>리더 서버에서 data update가 일어남. (아직 db에 commit은 안함)</li>
    <li>리더 서버에서 팔로우 서버들에게 update 요청을 broadcast 함.</li>
    <li>팔로우 서버는 update를 수행하고 리더 서버에게 알림.</li>
    <li>리더서버는 과반수 이상의 서버에서 update가 일어날경우 commit 요청을 보낸다. (이때 commit)</li>
    <li>각 주키퍼 서버의 znode가 update되고 watcher를 등록해놓은 서버들은 event를 수신하여 data를 최신화한다.</li>
    <li>update 요청을 날린 client는 성공 응답을 받는다.</li>
  </ol>
</blockquote>

<p>그림에서 볼 수 있듯이 주키퍼 cluster는 한개의 리더 서버와 여러개의 팔로워 서버로 이루어집니다. 그림에서 보면 각 client들은 리더,팔로우에 상관없이 아무 zookeeper server에 연결되어 있습니다. 따라서 update요청을 받는 서버는 리더 서버일 수도 있고 팔로우 서버일 수도 있습니다.</p>

<p>update명령은 무조건 리더 서버에서 수행됩니다. 따라서 팔로우 서버에서 zab명령을 통해 리더서버로 update 명령을 보냅니다. 리더서버의 znode가 수정됩니다. 이때 실제 db에는 반영이 안된상태입니다.</p>

<p>리더는 팔로우 서버들에게 업데이트 내용을 broadcast하고 팔로우 서버에서 update가 일어납니다. 그 후 팔로우들은 update 완료를 리더 서버에게 알리는데 이때 과반수 이상의 서버에서 업데이트가 일어나면 메모리 db에 해당 결과가 commit 됩니다. 이때 zab과정과 메모리 db접근과정은 모두 transaction 안에서 일어나므로 주키퍼는 원자성을 제공합니다.</p>

<p>각 주키퍼 서버의 노드가 업데이트 되었고 각 서버에 watcher를 달아놓은 client들은 update event가 발생했음을 알고 해당 data들을 최신화 시킴으로써 distribution이 마무리됩니다.</p>

<p>그리고 update 요청을 한 client는 성공 응답을 받습니다.</p>

<h3 id="생길-수-있는-의문점">생길 수 있는 의문점</h3>

<p>지금까지 주키퍼의 전체적인 그림을 살펴보았는데 다음과 같은 질문이 있을 수 있습니다.</p>

<blockquote>
  <p>주키퍼 서버가 죽으면 해당 서버를 watch하고 있던 client는 어떻게 되나?</p>
</blockquote>

<p>-&gt; 특정 서버에 문제가 생기면 주키퍼 내부적으로 다른 주키퍼 서버로 연결을 시도하고 이전 서버와 옮길 서버의 state가 같아질 때 까지 클라이언트는 요청을 할 수 없다. 따라서 client는 항상 같은 결과를 받을 수 있다.</p>

<blockquote>
  <p>그럼 리더서버가 죽으면 어떻게 되나?</p>
</blockquote>

<p>-&gt; 리더 선출 알고리즘을 사용해 리더를 다시 뽑는다. 이 과정은 0.5밀리초밖에 걸리지 않기 때문에 큰 문제가 되지 않는다.</p>

<p><br /></p>]]></content><author><name>jungjae.choi</name></author><category term="tech" /><category term="cleancode" /><category term="hellobot-studio" /><category term="refactoring" /><category term="db migration" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">[데브인턴십 4기] Configuration Distribution Admin Page 만들기</title><link href="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD-4%EA%B8%B0-Configuration_Distribution_Admin_Page_%EB%A7%8C%EB%93%A4%EA%B8%B0/" rel="alternate" type="text/html" title="[데브인턴십 4기] Configuration Distribution Admin Page 만들기" /><published>2022-08-25T00:00:00+09:00</published><updated>2022-08-25T00:00:00+09:00</updated><id>https://techblog.thingsflow.com/tech/%5B%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD%204%EA%B8%B0%5DConfiguration_Distribution_Admin_Page_%EB%A7%8C%EB%93%A4%EA%B8%B0</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD-4%EA%B8%B0-Configuration_Distribution_Admin_Page_%EB%A7%8C%EB%93%A4%EA%B8%B0/"><![CDATA[<h2 id="문제-상황">문제 상황</h2>

<hr />

<blockquote>
  <p>1.현재는 운영팀에서 비트윈 서버의 설정을 바꿀 수 없어서 항상 개발팀에 요청을 해야합니다.</p>
</blockquote>

<p>Between api 서버는 minimum 3개의 instance가 운영되고 있습니다. 이 서버들의 setting값을 설정해주는 config 파일을 동기화 시키기 위해 주키퍼를 사용하여 configuration distribution 시스템을 운영하고 있습니다. 현재는 cli 창을 통해 Bastion 서버에 접속하여 linux 명령어를 통해 cli창에서 distribution을 진행하고 있습니다. 이 때문에 운영팀에서는 setting값을 변경할 수 없고 필요시에 개발팀에 요청을 하게되어 업무효율이 떨어지는 상황입니다. Admin server를 구축하여 운영팀에서도 setting값을 변경할 수 있게 하는 것이 프로젝트의 가장 큰 목적입니다.</p>

<blockquote>
  <p>2.configuration distribution이 비효율적인 구조로 이루어져 있습니다.</p>
</blockquote>

<p>추가로 현재는 Bastion 서버에서 api 서버를 거쳐 distribution을 수행하는데 이를 개선하여 Admin server에서 한번에 zookeeper에 update 요청을 거는 식으로 infra 구조를 변경하려 합니다.</p>

<p><br /></p>

<h2 id="사용-기술">사용 기술</h2>

<hr />

<p>이번 프로젝트에 사용된 기술입니다.</p>

<ol>
  <li>
    <p>zookeeper</p>
  </li>
  <li>
    <p>docker</p>
  </li>
  <li>
    <p>aws</p>
  </li>
</ol>

<ul>
  <li>
    <p>ec2</p>
  </li>
  <li>
    <p>java aws sdk</p>
  </li>
</ul>

<p><br /></p>

<h2 id="기대-효과">기대 효과</h2>

<hr />

<h4 id="feature-flag">Feature Flag</h4>

<p>특정 기능을 특정 인원에게만 접근할 수 있게 하여 운영환경에서 테스트 할 수 있게 하는 방법. 운영팀에서 원할 때 개발팀의 도움없이 해당 기능에 접근 할 수 있게하여 업무 효율성 증가.</p>

<h4 id="infra-구조-개선-및-기능-분리">Infra 구조 개선 및 기능 분리</h4>

<p>config distribution 관련 기능을 Bastion 서버와 api 서버에서 분리하여 admin server에서 모두 처리하게 하여 각 서버는 core logic에 집중할 수 있게됨. 또한 config distribution 과정도 간단해짐.</p>

<p><br /></p>

<h2 id="infra-구조">Infra 구조</h2>

<hr />

<h3 id="기존의-infra-구조">기존의 Infra 구조</h3>

<p><img src="https://velog.velcdn.com/images/www8989/post/0514ccc5-ebb4-4203-9f95-6eb1d2a12881/image.png" alt="" /></p>

<p>먼저, 기존 Between 서버에서 config distribution을 위한 infra 구조입니다. 개발자가 Bastion에 접속하여 python 코드를 실행시키면 주키퍼에서 global path의 data를 가져와서 Bastion local에 json파일로 저장합니다. 그리고 vi 명령어를 통해 json파일을 수정하고 수정된 파일을 api 서버 에 복사합니다. 그리고 api 서버에서 globalConfigurationUpdater.java를 실행시켜서 api 서버에 저장된 json파일로 주키퍼에 update 요청을 보내고 주키퍼에 등록된 watcher에 의해 distribution이 진행됩니다.</p>

<p>globalConfigurationUpdater.java 코드만 Bastion환경에서 실행시킬 수 있으면 api 서버를 중간에 거쳐서 distribution을 수행할 필요가 없어집니다. 다음은 프로젝트 수행 후 바뀐 infra 구조입니다.</p>

<h3 id="바뀐-infra-구조">바뀐 Infra 구조</h3>

<p><img src="https://velog.velcdn.com/images/www8989/post/d1ebb2c5-8eaa-464e-94ba-fe03a72a448b/image.png" alt="" /></p>

<p>api 서버를 거칠 필요가 없어져서 기존의 Bastion역할을 하는 Admin server에서 바로 주키퍼에 update요청을 보내는 것을 볼 수 있습니다.</p>

<p><br /></p>

<h2 id="결과물-demo">결과물 (Demo)</h2>
<hr />

<p><img src="https://velog.velcdn.com/images/www8989/post/758d013f-f918-403f-b7a1-d6cc7c7b6077/image.png" alt="" /></p>

<p>admin page에 접속하면 /between path 하위에 있는 path들을 주키퍼로 부터 받아옵니다. 각 path에서 수정할 수 있는 기능은 하위 경로를 선택하거나 ‘현재 경로 선택’을 하여 해당 경로의 data를 수정하거나 현재 경로에 새로운 경로를 추가하여 주키퍼의 해당 path에 새로운 znode를 생성하는 것입니다. 이번 프로젝트에서 수정할 /global을 선택하겠습니다.</p>

<p><img src="https://velog.velcdn.com/images/www8989/post/61c0b4d8-7c64-4428-8340-00092865f11e/image.png" alt="" /></p>

<p>/between/global path에 있는 znode를 수정해보겠습니다.</p>

<p><img src="https://velog.velcdn.com/images/www8989/post/69c7cef2-d4b5-4c59-bab1-48208f6f3954/image.png" alt="" /></p>

<p>위의 사진은 json 데이터를 웹 페이지에서 수정하고 ‘비교’버튼을 누른 사진입니다. 왼쪽이 수정전이고 오른쪽이 수정 후의 json data입니다. +, - 로 이전과 달라진 data를 한번 사용자가 한번 check 할 수 있게 하고 여기서 ‘수정’버튼을 누르면 주키퍼에 update 요청이 보내지고 distribution이 일어납니다. 그러면 결과적으로 admin page에서 api 서버의 모든 config data를 수정할 수 있게 되는 것입니다.</p>

<p><br /></p>

<h2 id="zookeeper-update-요청-코드-리뷰">zookeeper update 요청 코드 리뷰</h2>

<hr />

<blockquote>
  <p>띵스플로우 노션의 <strong>“핵심테이블 / 문서/ Pigeon Globalconfiguration zookeeper 동작”</strong>으로 가시면 코드 리뷰가 자세히 되어있습니다. 여기에는 코드를 올릴 수 없는 관계로 간단히만 적겠습니다.</p>
</blockquote>

<p>코드의 목적은 안전하게 zookeeper update를 수행하는 것입니다.</p>

<p>우선 configuration class가 중요한데 json파일의 class형태라고 보시면 됩니다. 이 class를 수정한 json파일과 매핑시켜 validation을 진행합니다. 따라서 json파일을 수정하기 위해서는 configuration.java 파일도 수정해 주어야 합니다. 번거롭지만 configuration을 잘못수정하면 치명적이므로 개발자의 실수를 최대한 줄이기위한 안전 장치입니다.</p>

<p>이외에는 발생할 수 있는 상황에 대한 예외처리입니다. zookeeper를 update 한 후에 zookeeper의 data를 다시 한번 읽어와 json파일과 비교함으로써 update가 제대로 수행되었는지 check하고 안되었으면 예외를 발생시킵니다. 또한 update를 수행할 때 해당 path에 node가 없으면 node를 생성합니다.</p>

<p>이 모든 zookeeper와 관련된 처리는 curator라는 주키퍼에서 제공하는 라이브러리를 통해 수행됩니다. curator객체를 생성해서 zookeeper와 연결하면 zookeeper와 관련된 다양한 메소드를 사용할 수 있습니다. 대표적으로 create / delete / checkExist / getData / setData / getChilderen 등이 있습니다. getChildren은 해당 path의 하위 path들을 불러오는 메소드 입니다.</p>

<p>이외에도 watcher를 등록하는 메소드도 제공합니다. watcher를 등록하면서 node가 추가/제거/수정 되거나 reconnection이 발생하면 수행될 callback 함수를 정의해줘야 합니다. 그런데 사실 zookeeper에 update요청을 하는 로직에 watcher는 필요가 없습니다. update 요청은 메소드로 처리되기 때문에 watcher가 작동하려면 돌아가는 api서버에서 curator 객체를 생성하여 watcher를 등록해야 합니다.</p>

<p>api 서버의 메인함수 injector호출 class에 global path에 watcher를 등록하는 코드가 있었습니다. 여기서 의문점이 하나 생겼는데 zookeeper에 update 요청을 날릴 때는 curator객체에 굳이 watcher를 등록할 필요가 없는데 왜 그랬는지 입니다. “어차피 메소드가 완료되면 객체들도 다 삭제되기 때문에 watcher를 다는 코드가 큰 오버헤드를 발생시키지 않아 불필요하게 메소드를 추가 생성하지 않은 것 같다”가 제 결론입니다.</p>

<p><br /></p>

<h2 id="정리">정리</h2>

<hr />

<p><img src="https://velog.velcdn.com/images/www8989/post/dfbf2833-0515-4fe0-ab43-47895d78b7fe/image.png" alt="" /></p>

<p><a href="http://channy.creation.net/blog/1620">http://channy.creation.net/blog/1620 참조</a></p>

<p>먼저 못한점으로 zookeeper watcher에 대한 부족한 이해를 뽑았습니다. zookeeper curator의 watcher 등록부분에 대한 자료가 부족하여 코드를 뜯어보았지만 충분한 이해가 되지 않았습니다. 이후에 기존 between코드를 크게 수정하려면 해당 logic을 이해해야 하므로 아쉬운 점이었습니다.</p>

<p>잘한점은 api 서버 환경에서 돌아가던 코드를 admin server로 옮긴 것입니다. 그 과정에서 코드의 이해가 필요했고 라이브러리 버젼을 맞추는데 많은 시간을 소요했습니다. 결과적으로 api server를 거치지않고 distribution이 가능해졌습니다.</p>]]></content><author><name>jungjae.choi</name></author><category term="tech" /><category term="cleancode" /><category term="hellobot-studio" /><category term="refactoring" /><category term="db migration" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">[데브인턴십]헬로우봇 프론트앤드 개발 인턴십 후기</title><link href="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C-%EC%9D%B8%ED%84%B4%EC%8B%AD-%ED%97%AC%EB%A1%9C%EC%9A%B0%EB%B4%87-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%95%A4%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%B8%ED%84%B4%EC%8B%AD-%ED%9B%84%EA%B8%B0/" rel="alternate" type="text/html" title="[데브인턴십]헬로우봇 프론트앤드 개발 인턴십 후기" /><published>2022-08-23T00:00:00+09:00</published><updated>2022-08-24T08:09:00+09:00</updated><id>https://techblog.thingsflow.com/tech/%5B%EB%8D%B0%EB%B8%8C%20%EC%9D%B8%ED%84%B4%EC%8B%AD%5D%ED%97%AC%EB%A1%9C%EC%9A%B0%EB%B4%87%20%ED%94%84%EB%A1%A0%ED%8A%B8%EC%95%A4%EB%93%9C%20%EA%B0%9C%EB%B0%9C%20%EC%9D%B8%ED%84%B4%EC%8B%AD%20%ED%9B%84%EA%B8%B0</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C-%EC%9D%B8%ED%84%B4%EC%8B%AD-%ED%97%AC%EB%A1%9C%EC%9A%B0%EB%B4%87-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%95%A4%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%B8%ED%84%B4%EC%8B%AD-%ED%9B%84%EA%B8%B0/"><![CDATA[<p>7, 8월 2달간의 인턴이 끝났습니다. 인턴기간동안 많은 것을 배웠고 개발하였습니다. 저는 인턴기간동안의 겪은 일들을 헬로우봇이란 프로젝트의 구조, 제가 작업한 스프린트, 그리고 별도로 진행했던 리펙토링의 3가지 파트로 이야기 해보려합니다.</p>

<h1 id="헬로우봇-프로젝트의-구조">헬로우봇 프로젝트의 구조</h1>
<h2 id="스킬스토어와-스튜디오">스킬스토어와 스튜디오</h2>

<p>헬로우봇 프로젝트는 2개의 웹서비스를 가지고 있는데 <strong>헬로우봇 스킬스토어</strong>(이하 스킬스토어)와 <strong>헬로우봇 스튜디오</strong>(이하 스튜디오)입니다. 스킬스토어는 헬로우봇의 주력 상품인 <strong>스킬</strong>을 판매하는 웹 스토어이고 스튜디오는 그러한 <strong>스킬</strong>을 개발할 수 있는 GUI 툴 서비스입니다.</p>

<p>두 프로젝트의 프론트는 모두 Angular로 개발되어있고 travisCI와 AWS를 통해 배포되고 있습니다. 다만 스튜디오는 특정 에디터를 대상으로 한 만큼 범용성 고려 중요도가 낮아 SPA로 일반 배포되어있지만 스킬스토어는 일반 유저들을 대상으로 하므로 검색(SEO), 접근성등을 고려한 ssr로 배포되어 있고 GA(google analytics), hackle(A/B test tool)와 같은 분석도구와도 연결되어 있습니다.</p>

<h2 id="두-프로젝트의-코드-구조">두 프로젝트의 코드 구조</h2>

<p>먼저 프론트개발자일지라도 생소할 Angular를 간단히 설명하겠습니다. Angular는 typescript class 문법을 기반으로 작성된 통합 프레임워크입니다. 통합 프레임워크란 react, vue와 달리 http통신에 대한 모듈 등 외부라이브러리에 의존해야하던 부분을 내부에 포함시킨 프레임워크란 의미입니다. Angular의 요소들은 앵귤러모듈(NgModule)로 묶여서 외부로 제공됩니다. 이는 기존 js의 module패턴과 동일한 형태를 띕니다.</p>

<p>코드 구조는 Angular를 기반으로 한 만큼 Angular의 골조를 따릅니다. 큰 디렉토리 구성은 shared, module, core 입니다.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├── app
│   ├── app.module.ts
│   ├── app.component.ts
│   ├── app.component.html
│   ├── core
│   ├── modules
│   └── shared
├── assets -&gt; 정적파일
├── environment -&gt; 배포환경에 따른 설정 파일
├── main.ts -&gt; build 진입점
├── index.html -&gt; 진입 템플릿
├── ...
</code></pre></div></div>

<p><strong>shared</strong>: 프로젝트에서 공통적으로 사용되는 요소를 가지는 앵귤러 모듈(NgModule)입니다. <code class="language-plaintext highlighter-rouge">directive</code>, <code class="language-plaintext highlighter-rouge">pipe</code>와 같이 템플레이트에 적용될 요소와 공통적으로 쓰이는 <code class="language-plaintext highlighter-rouge">컴포넌트</code>(ex&gt; 경고창)들로 구성되어 있습니다.</p>

<p><strong>module</strong>: 프로젝트에서 특정 기능을 가지는, 혹은 특정 페이지를 구성하는 컴포넌트와 서비스를 하나의 모듈로 묶어둔 앵귤러 모듈(NgModule)입니다. 예를 들면 대부분의 페이지에서 쓰이는 skill과 관련된 모듈은 <code class="language-plaintext highlighter-rouge">skill.module</code>에서 통합적으로 제공됩니다.</p>

<p><strong>core</strong>: module이 페이지의 구성을 담당한다면 core는 로직의 구성을 담당하는 요소들로 구성되어 있습니다. 예를 들면 http통신의 앞뒤에 붙어 http통신을 제어하는 <code class="language-plaintext highlighter-rouge">interceptor</code>라던가, 페이지 접근을 제어하는 <code class="language-plaintext highlighter-rouge">guard</code>나 모든 페이지에서 공통으로 쓰이는 loading화면을 제어하는 <code class="language-plaintext highlighter-rouge">서비스</code>등을 포함합니다.</p>

<h2 id="서비스-로직의-흐름">서비스 로직의 흐름</h2>

<p>웹 서비스인 만큼 routing과 api로직이 핵심적인 요소로 그 흐름을 간단히 살펴보려합니다.</p>

<p><img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/guard.png" alt="img" /></p>

<p>Angular는 내장된 routing 시스템과 라우팅에대한 권한관리를 할 수 있는 Guard를 함께 제공해줍니다. Angular Router는 브라우저의 url을 읽어 랜더링되는 요소를 제어하게끔 도와주고 Guard는 canActivate에 설정해둔 조건에 따라 페이지 접근을 제어할 수 있습니다. 아래 코드를 살펴보죠.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nx">SomeGuard</span> <span class="k">implements</span> <span class="nx">CanActivate</span><span class="p">,</span> <span class="nx">CanActivateChild</span><span class="p">,</span> <span class="nx">Resolve</span><span class="o">&lt;</span><span class="nx">Admin</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">constructor</span><span class="p">(</span>
    <span class="na">authService</span><span class="p">:</span> <span class="nx">AuthService</span>
  <span class="p">)</span> <span class="p">{}</span>

  <span class="nx">canActivate</span><span class="p">(</span><span class="na">next</span><span class="p">:</span> <span class="nx">ActivatedRouteSnapshot</span><span class="p">,</span> <span class="na">state</span><span class="p">:</span> <span class="nx">RouterStateSnapshot</span><span class="p">):</span> <span class="nx">Observable</span><span class="o">&lt;</span><span class="nx">boolean</span><span class="o">&gt;</span> <span class="o">|</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">boolean</span><span class="o">&gt;</span> <span class="o">|</span> <span class="nx">boolean</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">checkLogin</span><span class="p">();</span>
  <span class="p">}</span>

  <span class="k">private</span> <span class="nx">checkLogin</span><span class="p">():</span> <span class="nx">boolean</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">authService</span><span class="p">.</span><span class="nx">isLogin</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">router</span><span class="p">.</span><span class="nx">navigate</span><span class="p">([</span><span class="dl">'</span><span class="s1">/login</span><span class="dl">'</span><span class="p">]);</span>
    <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>canActivate는 라우팅 서비스에 등록되어 해당 패스로 이동할때 동작하여 return값(true, false)에 따라 페이지의 접속 여부를 판단합니다.</p>

<p>다음은 api로직입니다. angular는 Http 통신을 지원하는 httpClient를 제공하고 HttpInterceptor를 통해 http request의 진입점과 response의 진입점에 특정 로직을 집어넣을 수 있습니다. 컴포넌트는 rxjs패턴을 통해 HttpClient의 동작을 구독하고 데이터를 비동기적으로 처리합니다. 흐름은 아래와 같습니다.</p>

<p><img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/interceptor.png" alt="img" /></p>

<p>interceptor의 역할은 다양하지만 주로 500번 error에 따른 리트라이, 권한(token)과 관련된 처리, os나 브라우저와 관련되어 달리 처리해야하는 부분을 관리합니다.</p>

<p>컴포넌트는 아래 코드와 같이 httpClient의 동작을 구독하여 데이터를 비동기 처리할 수 있습니다</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">@</span><span class="nd">Component</span><span class="p">({</span>
  <span class="na">selector</span><span class="p">:</span> <span class="dl">'</span><span class="s1">some-component</span><span class="dl">'</span><span class="p">,</span>
  <span class="c1">// 비동기적으로 data$의 값을 받음</span>
  <span class="na">template</span><span class="p">:</span> <span class="s2">`
    &lt;ng-container *ngIf="data$ | async"&gt;
        ...
    &lt;/ng-container&gt;
  `</span><span class="p">,</span>
  <span class="na">styleUrls</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">./friend-detail.component.scss</span><span class="dl">'</span><span class="p">]</span>
<span class="p">})</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nx">SomeComponent</span> <span class="k">implements</span> <span class="nx">OnInit</span> <span class="p">{</span>
  <span class="k">public</span> <span class="nx">data$</span><span class="p">:</span> <span class="nx">Observable</span><span class="o">&lt;</span><span class="nx">Data</span><span class="o">&gt;</span><span class="p">;</span>

  <span class="kd">constructor</span><span class="p">(</span>
    <span class="k">private</span> <span class="nx">httpClient</span><span class="p">:</span> <span class="nx">HttpClient</span><span class="p">,</span>
  <span class="p">)</span> <span class="p">{</span> <span class="p">}</span>
  
  <span class="nx">ngOnInit</span><span class="p">():</span> <span class="k">void</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">data$</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">HttpClient</span><span class="p">.</span><span class="kd">get</span><span class="o">&lt;</span><span class="nx">Data</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">url</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
      <span class="nx">map</span><span class="p">(</span><span class="nx">process</span><span class="p">...)</span>
    <span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이외에도 form에대한 처리방식등 다양한 로직 흐름이 존재하지만 크게 저 2가지 패턴으로 유저의 동작을 제어하고 있습니다.</p>

<h1 id="작업했던-스프린트">작업했던 스프린트</h1>

<p>2개월동안 2개의 스프린트가 진행되었고 하나는 스튜디오와, 하나는 스킬스토어 작업이었습니다. 앞서말했듯이 두개의 구조는 유사하면서도 다르고, 작업또한 스튜디오는 기존의 피쳐를 수정하는 작업이었고 스킬스토어는 새로운 피쳐를 만드는 작업이었기에 각각의 작업에서 느낀점이 달랐습니다.</p>

<h2 id="스프린트-1-헬로우봇-스튜디오-기존-페이지-변경">스프린트 1. 헬로우봇 스튜디오 기존 페이지 변경</h2>

<h3 id="배경">배경</h3>

<p>스튜디오는 원래 에디터분들을 대상으로 개발된 UI툴입니다. 따라서 기존 권한은 2가지만 동작하였고 권한 관리가 크게 필요하지 않았습니다.</p>

<p>하지만 스튜디오가 일반 사용자 대상 공개 목적으로 스프린트가 진행되었고 유저들의 권한을 세분화함(슈퍼에디터, 3단계 에디터 -&gt; 슈퍼에디터, 띵스플로우 에디터, 일반에디터)에따라 권한에 따른 기능 노출을 제한할 필요가 생겼습니다. 이에따라 기존에 어드민(관리 페이지)가 노출관리로 변경되고 권한에 따른 테이블 필드노출과 수정페이지의 수정가능한 필드가 변경되어야하는 작업을 요하게 되었습니다.</p>

<h3 id="작업">작업</h3>

<p><strong>1.새로운 가드의 구현</strong></p>

<p>권한이 분리되었고 권한에 따른 페이지의 접속 권한이 달라졌으니 새로운 가드를 작성할 필요가 생겼습니다. 기존의 노출관리에선 총 (TODO)개의 탭이 존재하나 변경된 사항에선 띵스플로우 에디터는 아예 해당 탭에 접근이 불가하고 일반 에디터는 3개의 탭만이 접근 가능하게 변경되었습니다.</p>

<p><img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/editor.png" alt="image" /></p>

<p>따라서 새로운 가드의 구축이 요구되었고 <code class="language-plaintext highlighter-rouge">admin-editor.guard</code>를 통해 띵스플로우 에디터가 노출관리에 접속할 수 없도록 하였고 <code class="language-plaintext highlighter-rouge">admin-menu.guard</code>를 통해 일반에디터가 노출관리에서 접근할 수 있는 탭을 제한하였습니다.</p>

<p><strong>2.서버에서 내려주는 데이터에 따른 업무 관리</strong></p>

<p>언제라도 바뀔 수 있는 노출 필드의 제어를 아예 프론트단이 아닌 서버에서 내려주는 데이터를 통해 관리하게 되었습니다. 유저 정보에 <code class="language-plaintext highlighter-rouge">adminMenu</code> 필드가 추가되었고 <code class="language-plaintext highlighter-rouge">editableFeilds</code>가 추가되어 이 정보를 토대로 노출되는 테이블의 컬럼이 변경되는 로직을 구축하고 템플릿에 추가하였습니다.</p>

<p>아래 adminMenu Guard는 2개의 로직이 적용된 코드입니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nx">AdminMenuGuard</span> <span class="k">implements</span> <span class="nx">CanActivate</span><span class="p">,</span> <span class="nx">CanActivateChild</span> <span class="p">{</span>
  <span class="p">...</span>
  <span class="nx">canActivate</span><span class="p">(</span>
    <span class="nx">next</span><span class="p">:</span> <span class="nx">ActivatedRouteSnapshot</span><span class="p">,</span>
    <span class="nx">state</span><span class="p">:</span> <span class="nx">RouterStateSnapshot</span><span class="p">):</span> <span class="nx">Observable</span><span class="o">&lt;</span><span class="nx">boolean</span> <span class="o">|</span> <span class="nx">UrlTree</span><span class="o">&gt;</span> <span class="o">|</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">boolean</span> <span class="o">|</span> <span class="nx">UrlTree</span><span class="o">&gt;</span> <span class="o">|</span> <span class="nx">boolean</span> <span class="o">|</span> <span class="nx">UrlTree</span> <span class="p">{</span>
    <span class="p">...</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">checkType</span><span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">url</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="p">...</span>
  <span class="k">private</span> <span class="nx">checkType</span><span class="p">(</span><span class="nx">url</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nx">Observable</span><span class="o">&lt;</span><span class="nx">boolean</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="c1">//auth 서비스는 서버로부터 내려오는 유저의 정보를 담고 있습니다.</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">authService</span><span class="p">.</span><span class="nx">loggedInEditor$</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span>
      <span class="p">...</span>
      <span class="c1">// 서버로부터 내려온 정도를 받아 현재 접속하려 하는 url이 허용되는 필드인지 확인합니다.</span>
      <span class="nx">switchMap</span><span class="p">(</span><span class="nx">userInfo</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">return</span> <span class="k">of</span><span class="p">(</span>
          <span class="c1">// `check user info access in url`</span>
        <span class="p">);</span>
      <span class="p">}),</span>
    <span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이후 코드리뷰, QA, 운영배포를 통해 작업이 마무리되었습니다.</p>

<h3 id="작업-후기">작업 후기</h3>

<p>첫 작업을 진행하면서 다양한 부분을 깨닫게 되었습니다. 특히 이 과정에는 리펙토링을 포함한 작업을 고민하며 진행하면서 기존 작업된 코드를 변경하는 작업이 어떻게 어려운가, 리펙토링이 왜 어려운가를 직접적으로 깨닿게 되었습니다.</p>

<p>기존 코드가 방대하고 Angular라는 프레임워크, bootstrap style의 스타일링, 생소한 분야 사이에서 제 코드는 방향성을 잃기 일쑤였습니다. 특히 리펙토링에 대해 생각하고 작업한 부분이 오히려 기존의 작업을 방해하고 스타일을 깨는 일을 초래하였죠. 너무 성급하게 작업을 들어갔고 좀 더 상세한 부분을 생각하고 오랜 기획 후 작업하면 어떠하였을까라는 아쉬움이 남는 작업이었습니다.</p>

<h2 id="스킬스토어-마이봇-프로필-페이지-개발">스킬스토어 마이봇 프로필 페이지 개발</h2>

<h3 id="배경-1">배경</h3>

<p>기존에 헬로우봇 앱을 플랫폼화 진행하면서 웹 서비스인 스킬스토어에 링크로 <strong>마이봇</strong>을 접속하게 하는 기능이 기획에 추가됨에 따라 해당 페이지를 직접 개발해야하는 업무가 추가되었습니다. 스튜디오와 달리 아예 새로운 페이지를 개발하는 작업이었습니다.</p>

<h3 id="작업-1">작업</h3>
<p><strong>1.신규 페이지 구현</strong></p>

<p>마이봇화면의 구성은 상단의 프로필 화면, 링크의 리스트, 스킬의 리스트, 그리고 링크리스트로만 구현된 별도의 화면이 존재하였고, 앱 연결을 위한 팝업창으로 구성되었습니다.</p>

<p>스킬스토어는 design system을 부트스트랩(bootstrap)과 동일한 시스템을 사용해 css 클래스를 통해 디자인을 구축하였고 design guide에 기록된 만큼 구성을 어렵지 않게 진행할 수 있었습니다.</p>

<p><img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/linklist.png" alt="image" /></p>

<p><strong>2.딥링크</strong></p>

<p>모바일 환경에 놓인 유저가 웹사이트 혹은 다른 앱에서 URL을 클릭했을 때, 앱을 실행시키고 특정 페이지로 이동하도록 돕는 기술입니다. 스킬스토어의 딥링크의 동작은 urlScheme과 airbridge란 외부 서비스로 이루어져 있으며 앱으로 연결할 버튼의 id를 이동할 딥링크와 함께 airbridge에 등록하여 동작합니다.</p>

<p><strong>3.Google Analytics</strong></p>

<p>Google Analytics(이하 GA)란 웹사이트 방문자의 데이터를 수집해서 분석함으로써 온라인 비즈니스의 성과를 측정하고 개선하는 데 사용하는 웹로그분석 도구입니다. 웹사이트에서 사용자의 등록된 동작들은 Google Tag manager를 통해 이벤트를 수집해 GA로 이벤트를 쏴주는 방식으로 진행됩니다.</p>

<p>스킬스토어에는 analytic.service에 GA에 특정 이벤트를 날릴 수 있는 함수가 개발되어 있습니다.따라서 유저의 동작에 따라 기획된 이벤트를 GA로 날릴 수 있도록 eventListener를 등록하는 작업을 진행하였습니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 마이봇 화면의 link를 클릭했을 시 GA로 이벤트를 보내는 코드</span>
<span class="nx">triggerTouchAccessLink</span><span class="p">(...</span><span class="nx">eventFlag</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="k">typeof</span> <span class="nx">gtag</span> <span class="o">!==</span> <span class="dl">'</span><span class="s1">undefined</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">gaEvent</span><span class="p">(</span><span class="dl">'</span><span class="s1">이벤트 플래그</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
      <span class="p">...</span><span class="nx">eventFlag</span>
    <span class="p">});</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이후 테스트 서버에 배포, QA를 진행하고 작업이 마무리되었습니다.</p>

<h3 id="작업-후기-1">작업 후기</h3>

<p>GA, deeplink등 기존에 경험해보지 못한 코드 외적 시스템을 직접 활용해보는 작업은 흥미로우면서도 막막한 작업이었습니다. 아예 새로운 화면을 개발하는 만큼, 커뮤니케이션이 많이 필요하였고, 업무 프로세스를 잘 이해하지 못한 시점에서 개발 보다는 개발 외적인 프로세스에서 힘들었던 작업이었습니다.</p>

<p>좀더 어떻게 일을 진행하면 좋을지 구체화 시킨 후 작업을 들어가면 좋았을 것 같습니다.</p>

<h1 id="리펙토링">리펙토링</h1>

<p>처음에 스튜디오, 스킬스토어의 코드를 보면서 느낀점은 “왜 이렇게 되어있을까?” 였습니다. 제가 프론트엔드 작업에 대해 배운 점과 많이 달랐던 부분들이 존재해서 였지요. 재사용성, 공통모듈화, 합성 컴포넌트, props를 통한 컴포넌트의 상태관리, 버저닝 문제 등등, 개선할 수 있어보이는 부분들이 손쉽게 보였죠. 따라서 스튜디오의 리펙토링을 목표로 잡고 작업 기획을 해보았습니다.</p>

<h2 id="작업-과정">작업 과정</h2>

<p>먼저 리펙토링의 목적은 <strong>개발을 효율적으로 할 수 있게끔</strong> 하는 것이었습니다. 그에 대한 세부 목표로 <strong>폼 입력 컴포넌트 공용 모듈화</strong>를 우선적으로 내세웠습니다. 테크스펙을 작성하고 예시 작업을 진행해보았죠.</p>

<h3 id="입력-컴포넌트-공용-모듈화">입력 컴포넌트 공용 모듈화</h3>

<p>스튜디오의 대부분의 페이지는 데이터를 보여주는 <strong>테이블</strong>과 수정이나 생성을 <strong>폼</strong>으로 구성되어 있습니다. 테이블은 <code class="language-plaintext highlighter-rouge">table.component.ts</code>에공통 컴포넌트로 구성되어 있지만 폼은 그러하지 못하였습니다. 예를 들면 시간을 입력하는 폼은 아래와 같이 몇몇 패턴을 가지고 존재하였고 이를 공통화 한 컴포넌트를 개발/적용해보았습니다.</p>

<figure class="half">
<img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/time1.png" />
<img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/time2.png" />
</figure>
<p><img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/time3.png" /></p>

<p>시간 입력 폼은</p>

<p><strong>1.기간을 선택하거나 2.단일시간만 선택하거나 로 나뉘고</strong></p>

<p><strong>1.시간만 선택하거나 2.날짜를 포함해서 선택하거나로 나뉘며</strong></p>

<p><strong>1.팁이 하단에 적히는가 2.팁이 하단에 적히지 않는가로 나뉘며</strong></p>

<p><strong>1.특정요소의 선택 가능여부로 나뉘고</strong></p>

<p><strong>1.기간 선택을 돕는 버튼이 있는가 없는가로</strong></p>

<p>나뉘었습니다. 이를 type과 해당 타입에 해당하는 property를 받아 실행하는 범용 컴포넌트를 개발해보았고 이에따라 아래와 같은 코드 절감효과를 얻었습니다.</p>

<figure class="half">
<img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/time1.png" />
<img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/effect2.png" />
</figure>

<figure class="half">
<img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/time2.png" />
<img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/effect3.png" />
</figure>

<figure class="half">
<img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/time3.png" />
<img src="/assets/images/2022-08-23-헬로우봇_프론트앤드_개발_인턴십_후기/effect1.png" />
</figure>

<p>이외에 텍스트 인풋 폼, 라디오 버튼 인풋 폼 등을 개발해서 적용 예정이었습니다.</p>

<h3 id="리펙토링의-문제">리펙토링의 문제</h3>

<p>결국 이 프로젝트는 실패하였습니다. 여러가지 원인이 있겠지만 주 원인은 <strong>“현재 진행중인 작업(스프린트)과 어우러질 수 있는가”</strong> 와 <strong>“이 부분이 생산성 향상에 도움이 되는가?”</strong>라고 생각합니다. 헬로우봇 팀은 프로젝트 스프린트를 빠르게 쳐내고 있고 계속해서 새로운 스프린트가 들어옵니다. 그 과정에서 리펙토링이 작업과 어우러질 수 있을만큼 충분히 고민했는가에 대해 그러지 못했다고 생각합니다.</p>

<p>또한 두 스프린트를 진행하면서 많은 부분이 바뀌었고 이에 대응하는 개발이 요구되었는데, 이런 부분이 리펙토링에서 기획한 컴포넌트시스템에서 가능한가에 대해 ‘NO’라고 답할 수 있었습니다. 제가 개발한 공통 컴포넌트는 그런 부분에 쉽게 대응하지 못하였고 생산성의 향상은 커녕 오히려 기존의 개발 가이드를 해치는 결과를 가져오게 되었죠.</p>

<p>그외에도 앵귤러 버전 업, 정적 코드 별도 파일화 등 몇몇 기획이 더 있었지만 모두 적용되기 어렵다는 결론에 다다랐습니다. 결국 이 리펙토링이 저의 경험에서 시작하여 “이렇게 하면 더 낫지 않을까?”라는 생각에서 구체화되지 못하고 현재 프로젝트와 어우러지도록 하지 못한 부분이 가장 아쉬운 점이었습니다.</p>

<h3 id="작업-후기-2">작업 후기</h3>

<p>앞선 2개의 스프린트도 좋은 경험이었지만 리펙토링을 고안해보고 실패한 경험은 단순히 “개발”이 아닌 “상황에 맞는 개발”이 얼마나 중요한지 알게끔 하였고 리펙토링이라는 작업이 책에서 보던것 만큼 단순한 작업이 아님을 알게하였습니다. 언젠가 리펙토링이라는 작업을 꼭 다시 해보고 싶을 정도로요.</p>

<h1 id="인턴십을-마치며">인턴십을 마치며</h1>

<p>두달간의 작업이 언제 지나간지 모를만큼 빠르게 지나갔습니다. 기간동안 좋았던 부분도 아쉬운 부분도 있지만 어느쪽이든 개발자로써 한단계 성장하는 계기가 되었습니다. 두서없는 글이지만 제가 경험한 부분이 잘 전달되었으면 좋겠습니다.</p>]]></content><author><name>seongyeon.lim</name></author><category term="tech" /><category term="frontend" /><category term="angular" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">[데브인턴십 - 몰입캠프 2022 겨울학기]스플 안드로이드 앱 개발 인턴십 후기</title><link href="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD_%EC%8A%A4%ED%94%8C_%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C_%EC%95%B1_%EA%B0%9C%EB%B0%9C_%EC%9D%B8%ED%84%B4/" rel="alternate" type="text/html" title="[데브인턴십 - 몰입캠프 2022 겨울학기]스플 안드로이드 앱 개발 인턴십 후기" /><published>2022-08-04T00:00:00+09:00</published><updated>2022-08-05T08:09:00+09:00</updated><id>https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD_%EC%8A%A4%ED%94%8C_%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C_%EC%95%B1_%EA%B0%9C%EB%B0%9C_%EC%9D%B8%ED%84%B4</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/%EB%8D%B0%EB%B8%8C%EC%9D%B8%ED%84%B4%EC%8B%AD_%EC%8A%A4%ED%94%8C_%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C_%EC%95%B1_%EA%B0%9C%EB%B0%9C_%EC%9D%B8%ED%84%B4/"><![CDATA[<h3 id="system-스플-초보-개발자-업적-획득-성공">[system] 스플 초보 개발자 업적 획득 성공!</h3>

<p>띵스플로우 입사 후 본격적으로 실제 앱에 기여하기 전, 3주간 스플에서 사용하는 안드로이드 앱의 구조와 기술 스택을 익히는 시간을 가졌습니다. 스플은 Single Activity 기반으로 MVVM 패턴을 지향합니다. 처음에는 그런 구조나 디자인 패턴 같은 기본적인 내용을 이해할 수 있는 온보딩 과제가 주어졌고, 과제를 진행하면서 DI, MVVM 패턴, viewBinding 등 여러 스택을 익힐 수 있었습니다. 이후 스플 코드를 리팩토링하는 간단한 과제를 진행하면서 스플 안드로이드 코드를 이해하는 과정을 거쳤습니다.</p>

<hr />

<h2 id="첫-번째-스프린트-220627">첫 번째 스프린트 (220627)</h2>

<h3 id="채팅-내-하드웨어-볼륨-키-미디어-볼륨-조절">채팅 내 하드웨어 볼륨 키 미디어 볼륨 조절</h3>

<p><img src="https://user-images.githubusercontent.com/65072995/182788405-55120dde-c4cc-4918-98f2-75732010ef7c.png" alt="1" /></p>

<p>앱 내에서 하드웨어 볼륨 키를 작동할 때, 기존에는 소리가 나오는 경우에만 미디어 볼륨 조절이 가능했습니다. 하지만 채팅 진행 중 효과음처럼 짧은 소리가 나올 경우 볼륨을 조절하기 위해 하드웨어 볼륨 키를 작동해도 이미 효과음 출력이 끝나 기기 볼륨 조절로만 작동되는 불편함이 있었습니다. 그래서 채팅 내부에서 하드웨어 볼륨 키를 조작했을 때는 항상 미디어 볼륨이 조절되도록 하였습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">override</span> <span class="k">fun</span> <span class="nf">onViewCreated</span><span class="p">(</span><span class="n">view</span><span class="p">:</span> <span class="nc">View</span><span class="p">,</span> <span class="n">savedInstanceState</span><span class="p">:</span> <span class="nc">Bundle</span><span class="p">?)</span> <span class="p">{</span>
    <span class="k">super</span><span class="p">.</span><span class="nf">onViewCreated</span><span class="p">(</span><span class="n">view</span><span class="p">,</span> <span class="n">savedInstanceState</span><span class="p">)</span>
    <span class="o">..</span><span class="p">.</span>
    <span class="n">activity</span><span class="o">?.</span><span class="n">volumeControlStream</span> <span class="p">=</span> <span class="nc">AudioManager</span><span class="p">.</span><span class="nc">STREAM_MUSIC</span>
<span class="p">}</span>

<span class="k">override</span> <span class="k">fun</span> <span class="nf">onDestroyView</span><span class="p">()</span> <span class="p">{</span>
    <span class="o">..</span><span class="p">.</span>
    <span class="n">activity</span><span class="o">?.</span><span class="n">volumeControlStream</span> <span class="p">=</span> <span class="nc">AudioManager</span><span class="p">.</span><span class="nc">STREAM_RING</span>
    <span class="k">super</span><span class="p">.</span><span class="nf">onDestroyView</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>처음에는 하드웨어 키 이벤트를 인식하게 하고, 그 이벤트에 대한 리스너를 만드는 방법을 도입할까 싶었지만 불필요한 코드 추가가 많아보여서 찾아보다가 알게 된 것이 AudioManager의 volumeControlStream 기능이었습니다. 우선 ChatFragment로 진입해서 view가 create 될 때 무조건 기본 볼륨 조절은 미디어 볼륨(STREAM_MUSIC)을 조절하도록 설정을 해주었습니다. 다만 이 기능은 fragment에서는 단독으로 작동하지 않았기 때문에 activity를 참조해주었습니다.</p>

<p>해당 피쳐 진행 중 간과했던 것은 요구조건을 정확히 파악하는 것이었습니다. 하드웨어 볼륨 키를 작동했을 때 채팅창 내부에서만 기본값이 미디어 볼륨 조절이 되도록 하고, 채팅창을 벗어났을 때는 다시 원 상태로 돌아와야 한다는 것이 요구사항이었습니다. 그래서 뷰가 destroy될 때 하드웨어 볼륨 키로 다시 기기 볼륨을 조절할 수 있도록 설정해주었습니다.</p>

<h3 id="아이템-창-기능-추가-구현">아이템 창 기능 추가 구현</h3>

<p>아이템 창과 관련된 기능을 몇 가지 추가하는 작업을 진행했습니다.</p>

<ul>
  <li><strong>아이템 클릭 시 설명 다이얼로그</strong></li>
</ul>

<p><img src="https://user-images.githubusercontent.com/65072995/182788410-3590ad9e-9693-438f-a074-129506e45ef1.png" alt="2" /></p>

<p>채팅 진행 중 아이템을 획득했을 때는 아이템명과 해당 아이템에 대한 설명이 함께 팝업창으로 뜹니다. 하지만 획득한 후 ‘내 아이템’ 창에서는 아이템 목록만 제공될 뿐 아이템에 대한 설명은 다시 볼 수 없었습니다. 그래서 ‘내 아이템’ 창에서 아이템을 클릭했을 때 해당 아이템에 대한 설명 다이얼로그가 다시 뜨도록 하는 기능을 구현하였습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">fun</span> <span class="nf">renderUi</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">StoryItem</span><span class="p">)</span> <span class="p">=</span> <span class="nf">with</span><span class="p">(</span><span class="n">binding</span><span class="p">)</span> <span class="p">{</span>
    <span class="o">..</span><span class="p">.</span>
    <span class="n">root</span><span class="p">.</span><span class="nf">setOnClickListener</span> <span class="p">{</span>
        <span class="n">event</span><span class="p">.</span><span class="nf">onClickItem</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">interface</span> <span class="nc">Event</span> <span class="p">:</span> <span class="nc">HolderEvent</span> <span class="p">{</span>
    <span class="k">fun</span> <span class="nf">onClickItem</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">StoryItem</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>먼저 아이템 목록을 띄워주던 리사이클러뷰에 클릭 이벤트를 추가해주기 위해 Holder에 이벤트와 리스너를 추가해주었습니다. 그리고 이 Holder의 이벤트를 활용하여 새로운 아이템 이벤트 클래스를 생성한 후 이를 Dialog 창에서 이벤트로 받을 수 있도록 했습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="kd">val</span> <span class="py">_showItemEvent</span> <span class="p">=</span> <span class="nc">MutableLiveData</span><span class="p">&lt;</span><span class="nc">Event</span><span class="p">&lt;</span><span class="nc">StoryItem</span><span class="p">&gt;&gt;()</span>
<span class="kd">val</span> <span class="py">showItemEvent</span><span class="p">:</span> <span class="nc">LiveData</span><span class="p">&lt;</span><span class="nc">Event</span><span class="p">&lt;</span><span class="nc">StoryItem</span><span class="p">&gt;&gt;</span> <span class="p">=</span> <span class="n">_showItemEvent</span>

<span class="k">fun</span> <span class="nf">onClickItem</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">StoryItem</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">_showItemEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>ItemDialog 창에서 클릭을 인지하고 팝업 창을 띄울 수 있도록 하기 위해 먼저 viewModel에 LiveData를 추가하고, 클릭이 됐을 때 그 값에 변화를 주는 함수를 추가하였습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">protected</span> <span class="kd">val</span> <span class="py">adapter</span> <span class="k">by</span> <span class="nf">lazy</span> <span class="p">{</span>
	<span class="o">..</span><span class="p">.</span>
  <span class="nf">event</span><span class="p">(</span><span class="n">showItemEvent</span><span class="p">)</span> <span class="p">{</span>
      <span class="nf">showItem</span><span class="p">(</span><span class="n">it</span><span class="p">)</span>
  <span class="p">}</span>
  <span class="o">..</span><span class="p">.</span>
<span class="p">}</span>
<span class="o">..</span><span class="p">.</span>
<span class="k">private</span> <span class="k">fun</span> <span class="nf">showItem</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">StoryItem</span><span class="p">)</span> <span class="p">{</span>
    <span class="nc">ItemPopupDialog</span><span class="p">().</span><span class="nf">apply</span> <span class="p">{</span>
        <span class="c1">// 아이템 정보</span>
        <span class="n">dialogType</span> <span class="p">=</span> <span class="nc">ItemPopupDialog</span><span class="p">.</span><span class="nc">DialogType</span><span class="p">.</span><span class="nc">SHOW</span>
    <span class="p">}.</span><span class="nf">show</span><span class="p">(</span><span class="n">childFragmentManager</span><span class="p">,</span> <span class="nc">ItemPopupDialog</span><span class="p">.</span><span class="nc">TAG</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이후 ItemDialog 창에서 이를 옵저빙하고, 값이 바뀔 때는 아이템 설명 팝업창을 띄우도록 했습니다. 처음에는 새로 아이템을 획득할 때와 같은 형태의 팝업창을 띄우기 때문에 ui를 함께 쓰도록 적용했지만, 사실 새로 아이템을 획득할 때와 획득한 아이템을 조회할 때는 팝업 창의 제목이나 버튼의 텍스트가 달라야 했습니다. 그래서 띄워야 할 다이얼로그 창을 dialogType 변수로 구분할 수 있도록 하고, 새로 획득은 NEW, 이미 획득한 것을 조회할 때는 SHOW를 띄우도록 했습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">override</span> <span class="k">fun</span> <span class="nf">setupComposeUi</span><span class="p">():</span> <span class="nc">View</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nc">ComposeView</span><span class="p">(</span><span class="nf">requireContext</span><span class="p">()).</span><span class="nf">apply</span> <span class="p">{</span>
        <span class="nf">setContent</span> <span class="p">{</span>
            <span class="nc">LayoutObtainDialog</span><span class="p">(</span>
                <span class="k">if</span> <span class="p">(</span><span class="n">dialogType</span> <span class="p">==</span> <span class="nc">DialogType</span><span class="p">.</span><span class="nc">NEW</span><span class="p">)</span> <span class="nf">stringResource</span><span class="p">(</span><span class="nc">R</span><span class="p">.</span><span class="n">string</span><span class="p">.</span><span class="n">item_gain</span><span class="p">)</span>
                <span class="k">else</span> <span class="nf">stringResource</span><span class="p">(</span><span class="nc">R</span><span class="p">.</span><span class="n">string</span><span class="p">.</span><span class="n">item_collection</span><span class="p">),</span>
                <span class="o">..</span><span class="p">.</span>
                <span class="k">if</span> <span class="p">(</span><span class="n">dialogType</span> <span class="p">==</span> <span class="nc">DialogType</span><span class="p">.</span><span class="nc">NEW</span><span class="p">)</span> <span class="nf">stringResource</span><span class="p">(</span><span class="nc">R</span><span class="p">.</span><span class="n">string</span><span class="p">.</span><span class="n">obtain</span><span class="p">)</span>
                <span class="k">else</span> <span class="nf">stringResource</span><span class="p">(</span><span class="nc">R</span><span class="p">.</span><span class="n">string</span><span class="p">.</span><span class="n">item_confirm</span><span class="p">)</span>
            <span class="p">)</span> <span class="p">{</span>
                <span class="nf">dismiss</span><span class="p">()</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
<span class="o">..</span><span class="p">.</span>
<span class="k">enum</span> <span class="kd">class</span> <span class="nc">DialogType</span> <span class="p">{</span>
    <span class="nc">NEW</span><span class="p">,</span> <span class="nc">SHOW</span>
<span class="p">}</span>
</code></pre></div></div>

<p>그래서 위와 같이 compose를 활용해서 ui를 구성할 때 조건에 따라 다르게 구성할 수 있도록 나누어 주었습니다. 동시에 채팅 창에서 아이템 팝업 다이얼로그를 apply할 때는 dialogType을 NEW로 설정했고, 아이템 창에서 apply할 때는 SHOW로 설정해서 팝업 창으로 진입할 수 있도록 설정했습니다.</p>

<ul>
  <li><strong>아이템 개수 표시</strong></li>
</ul>

<p><img src="https://user-images.githubusercontent.com/65072995/182788414-478514dc-606e-4540-a241-883469e03ec6.png" alt="3" /></p>

<p>기존에는 아이템 창에서 같은 아이템이 몇 개가 있든 그 개수를 알 수 없었습니다. 그래서 동일 아이템이 2개 이상 있을 때 개수를 함께 띄우는 기능을 추가했습니다. 변경된 디자인도 동시에 반영하였습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">override</span> <span class="k">fun</span> <span class="nf">onDraw</span><span class="p">(</span><span class="n">canvas</span><span class="p">:</span> <span class="nc">Canvas</span><span class="p">)</span> <span class="p">{</span>
    <span class="o">..</span><span class="p">.</span>
    <span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="k">in</span> <span class="mi">0</span> <span class="n">until</span> <span class="n">layout</span><span class="p">.</span><span class="n">lineCount</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">val</span> <span class="py">left</span><span class="p">:</span> <span class="nc">Float</span> <span class="p">=</span> <span class="n">layout</span><span class="p">.</span><span class="nf">getLineLeft</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>
        <span class="kd">val</span> <span class="py">baseLine</span><span class="p">:</span> <span class="nc">Int</span> <span class="p">=</span> <span class="n">layout</span><span class="p">.</span><span class="nf">getLineBaseline</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>

        <span class="n">paint</span><span class="p">.</span><span class="n">style</span> <span class="p">=</span> <span class="nc">Paint</span><span class="p">.</span><span class="nc">Style</span><span class="p">.</span><span class="nc">STROKE</span>
        <span class="n">paint</span><span class="p">.</span><span class="n">color</span> <span class="p">=</span> <span class="n">strokeColor</span>
        <span class="n">canvas</span><span class="p">.</span><span class="nf">drawText</span><span class="p">(</span>
            <span class="n">text</span><span class="p">,</span>
            <span class="n">left</span> <span class="p">+</span> <span class="n">totalPaddingLeft</span><span class="p">,</span>
            <span class="n">baseLine</span><span class="p">.</span><span class="nf">toFloat</span><span class="p">(),</span>
            <span class="n">paint</span>
        <span class="p">)</span>

        <span class="n">paint</span><span class="p">.</span><span class="n">style</span> <span class="p">=</span> <span class="nc">Paint</span><span class="p">.</span><span class="nc">Style</span><span class="p">.</span><span class="nc">FILL</span>
        <span class="n">paint</span><span class="p">.</span><span class="n">color</span> <span class="p">=</span> <span class="n">currentTextColor</span>
        <span class="n">canvas</span><span class="p">.</span><span class="nf">drawText</span><span class="p">(</span>
            <span class="n">text</span><span class="p">,</span>
            <span class="n">left</span> <span class="p">+</span> <span class="n">totalPaddingLeft</span><span class="p">,</span>
            <span class="n">baseLine</span><span class="p">.</span><span class="nf">toFloat</span><span class="p">(),</span>
            <span class="n">paint</span>
        <span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>숫자 표기 방식이 진한 색상의 stroke+밝은 안쪽 색상이었기 때문에 처음에는 textView의 onDraw를 활용해서 stroke를 설정하고 그려넣어주었습니다. 하지만 계속해서 숫자 양 옆 잘림 문제가 있었습니다. Padding의 문제도 아니었기 때문에 이 문제를 해결하기 위해 고민하다가 기본 onDraw를 사용하지 말고 직접 커스텀해서 drawText를 하기로 결정했습니다. 그래서 숫자를 표기할 커스텀한 TextView 클래스에서 onDraw를 override 해서, stroke와 fill을 그려주면서 동시에 padding을 넣은 위치로 그려주도록 설정했고, 잘림 문제는 해결되었습니다.</p>

<ul>
  <li><strong>아이템 이미지 full &amp; round 표시</strong></li>
</ul>

<p><img src="https://user-images.githubusercontent.com/65072995/182788417-8afbbf31-c4f4-4163-8851-27b68dcbd96c.png" alt="4" /></p>

<p>스프린트 종료 전 간단하게 아이템창의 디자인을 변경하는 문제를 해결했습니다. 디자인이 바뀌면서 테두리가 생겼는데, 처음에는 안드로이드와 iOS가 이미지를 처리하는 방법이 달랐습니다. 안드로이드는 어떤 이미지가 들어오든 패딩이 항상 존재했는데, 디자인적 요소로 어떤 것이 더 나을지 따져보았을 때 iOS처럼 이미지를 테두리 내부에 full로 차도록 처리하는 것이 좋다고 결정이 났습니다. 하지만 이미지를 그냥 딱 맞게 받아오면 테두리를 가리는 문제가 있었기 때문에 테두리 두께만큼 패딩을 주고 이미지를 모서리에 round를 줘서 자른 후 가져와서 문제를 해결하였습니다.</p>

<p>첫 번째 스프린트를 진행하며 가장 크게 느꼈던 것은 컴케의 중요성과 코드리뷰의 장점이었습니다. 같은 직무가 아닌 타 직무의 팀원분들과 서로 이해한 내용이 같은지 주기적으로 확인하고, 더 정확한 구현을 위해 하는 컴케는 보다 좋은 결과를 가져온다는 것을 알게 되었습니다. 그리고 코드리뷰를 받으면서 제 코드의 좋지 않은 부분은 수정하고 더 깔끔하게 코드를 작성하는 방법을 배우게 됐습니다.</p>

<hr />

<h2 id="두-번째-스프린트-220719">두 번째 스프린트 (220719)</h2>

<h3 id="아이템-획득소모-시-토스트에-개수-추가">아이템 획득/소모 시 토스트에 개수 추가</h3>

<p><img src="https://user-images.githubusercontent.com/65072995/182788420-18e98e71-a9d9-4992-8d32-8ab367c98236.png" alt="5" /></p>

<p>아이템의 개수가 드러나면서 아이템을 획득하거나 소비할 때도 “몇 개”를 하는지를 함께 토스트에 띄우는 기능을 추가해야했습니다.</p>

<p>해당 피쳐를 진행하면서 간단한 문제임에도 꽤 많은 고민을 했습니다.</p>

<ol>
  <li>
    <p>새로운 LiveData 변수를 만들어서 뷰모델에서 값을 변경하고 ChatFragment에서 그 값을 사용하는 방법</p>

    <p>→ MVVM 구조를 해치지 않기 위해 최대한 뷰모델의 값을 직접 활용하는 것은 지양 필요</p>
  </li>
  <li>
    <p>뷰모델에 새 변수를 만들고 ChatFragment에서 옵저빙</p>

    <p>→ 토스트는 획득 다이얼로그가 dismiss 된 이후에 떠야하므로 타이밍을 맞출 수 없음</p>
  </li>
  <li>
    <p>StoryItem에 아이템 증감을 나타내는 변수 추가</p>

    <p>→ 처음 데이터 클래스를 만들 때 담겼던 의도를 해칠 수도 있음</p>
  </li>
  <li>
    <p>새로운 업데이트용 StoryItem 데이터 클래스 생성 후 활용</p>

    <p>→ StoryItem을 활용하고 있던 모든 코드에 많은 변화가 필요시 될 것 같아 보류</p>
  </li>
</ol>

<p>다행히 이런 제 고민들을 여쭤보고 의견을 나눈 결과 데이터 클래스의 의도를 심하게 해치지 않는다는 판단이 들어 3번 방안으로 간단하게 해결할 수 있었습니다. (간단한 문제임에도 여러가지 요소를 고려할 수도 있다는 것을 알려준 피쳐였습니다.)</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">data class</span> <span class="nc">StoryItem</span><span class="p">(</span>
    <span class="kd">val</span> <span class="py">itemName</span><span class="p">:</span> <span class="nc">String</span><span class="p">,</span>
    <span class="kd">val</span> <span class="py">itemCount</span><span class="p">:</span> <span class="nc">Int</span><span class="p">,</span>
    <span class="kd">val</span> <span class="py">itemDescription</span><span class="p">:</span> <span class="nc">String</span><span class="p">,</span>
    <span class="kd">val</span> <span class="py">itemImageUrl</span><span class="p">:</span> <span class="nc">String</span><span class="p">?,</span>
    <span class="kd">val</span> <span class="py">whenChoice</span><span class="p">:</span> <span class="nc">Boolean</span> <span class="p">=</span> <span class="k">false</span><span class="p">,</span>
    <span class="kd">val</span> <span class="py">itemIncrease</span><span class="p">:</span> <span class="nc">Int</span> <span class="p">=</span> <span class="mi">0</span>
<span class="p">)</span>
</code></pre></div></div>

<p>가장 먼저 아이템에 대한 정보가 담겨있는 데이터 클래스에 아이템의 증감을 나타내는 변수를 추가했습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="n">itemEntity</span><span class="p">.</span><span class="n">increase</span> <span class="p">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">-&gt;</span> <span class="p">{</span> <span class="c1">// 증가할 때(획득)</span>
    <span class="n">_itemEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span>
        <span class="nc">StoryItem</span><span class="p">(</span>
            <span class="o">..</span><span class="p">.</span>
            <span class="n">itemIncrease</span> <span class="p">=</span> <span class="n">itemEntity</span><span class="p">.</span><span class="n">increase</span>
        <span class="p">)</span>
    <span class="p">)</span>
    <span class="o">..</span><span class="p">.</span>
<span class="p">}</span>
<span class="p">(</span><span class="n">itemEntity</span><span class="p">.</span><span class="n">increase</span> <span class="p">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">-&gt;</span> <span class="p">{</span> <span class="c1">// 감소할 때(소비)</span>
    <span class="n">_itemToastEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span>
        <span class="nc">StoryItem</span><span class="p">(</span>
            <span class="o">..</span><span class="p">.</span>
            <span class="n">itemIncrease</span> <span class="p">=</span> <span class="n">itemEntity</span><span class="p">.</span><span class="n">increase</span>
        <span class="p">)</span>
    <span class="p">)</span>
    <span class="o">..</span><span class="p">.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이후 정보를 받아오면서 아이템의 개수가 증가하거나 감소할 때 해당 변수에 그 값을 담아주었고, 토스트를 발생시킬 때 그 값을 함께 넘겨주어 같이 표현할 수 있도록 했습니다.</p>

<h3 id="등장인물-프로필-이미지-변경-기능-추가">등장인물 프로필 이미지 변경 기능 추가</h3>

<p>등장인물 프로필 이미지를 변경할 수 있도록 기능을 추가하는 것이 두 번째 스프린트에서 제가 맡은 가장 중요한 업무였습니다. 먼저 기존 온보딩 팝업의 UI를 수정했고, 그 후 프로필 이미지들을 띄울 프로필 리스트 화면과 사진을 변경할 때 띄울 바텀시트를 추가했습니다. 마지막으로 서버와 사진을 문제없이 주고받을 수 있도록 기존 온보딩 팝업의 API를 수정했습니다.</p>

<ul>
  <li><strong>기존 온보딩 팝업 UI 수정</strong></li>
</ul>

<p><img src="https://user-images.githubusercontent.com/65072995/182788422-565400df-282b-4f66-912e-e71746228d18.png" alt="6" /></p>

<p>프로필 이미지 변경이 가능한 온보딩 팝업에서는 편집 아이콘이 나타나고, 변경이 불가능한 팝업에서는 편집 아이콘이 사라져야 했습니다. 그래서 서버에서 내려준 canUploadProfileImage를 받아올 수 있도록 graphql 파일을 수정하고, 그 값이 true인지 false인지에 따라 참(업로드 가능)이면 아이콘 view를 visible하게, 거짓(업로드 불가능)이면 아이콘 view를 gone으로 처리해주었습니다.</p>

<div class="language-graphql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fragment</span><span class="w"> </span><span class="n">OnBoardingPopupsDto</span><span class="w"> </span><span class="k">on</span><span class="w"> </span><span class="n">Chapter</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">onboardingPopups</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="p">...</span><span class="w">
        </span><span class="n">canUploadProfileImage</span><span class="w">
        </span><span class="p">...</span><span class="w">
        </span><span class="n">popupProfileImages</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="n">popupId</span><span class="w">
            </span><span class="n">profileImageFileId</span><span class="w">
            </span><span class="n">profileImageFile</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="n">link</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">popup</span><span class="p">.</span><span class="n">canUpload</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">binding</span><span class="p">.</span><span class="n">btnImageChoiceProfile</span><span class="p">.</span><span class="n">visibility</span> <span class="p">=</span> <span class="nc">View</span><span class="p">.</span><span class="nc">VISIBLE</span>
    <span class="c1">// 뷰모델의 팝업 or 바텀시트 apply 함수 콜</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="n">binding</span><span class="p">.</span><span class="n">btnImageChoiceProfile</span><span class="p">.</span><span class="n">visibility</span> <span class="p">=</span> <span class="nc">View</span><span class="p">.</span><span class="nc">GONE</span>
<span class="p">}</span>
</code></pre></div></div>

<p>또한 등록이 되어있는 기본 이미지 리스트들을 받아올 수 있도록 popupProfileImages를 graphql 파일에 함께 추가를 해서 link를 받아왔습니다.</p>

<ul>
  <li><strong>프로필 리스트 화면 추가</strong></li>
</ul>

<p><img src="https://user-images.githubusercontent.com/65072995/182788425-cc3beb4f-b032-4c7d-8323-a7a2e357ba86.png" alt="7" /></p>

<p>다음으로는 프로필 이미지 변경 가능 온보딩 팝업이면서 기본 프로필 이미지 리스트의 size가 1 이상이면 해당 프로필 이미지들을 띄워줄 프로필 리스트 화면을 구현했습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fun</span> <span class="nf">editProfileImage</span><span class="p">(</span><span class="n">popupProfileImages</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileImage</span><span class="p">&gt;)</span> <span class="p">{</span>
  <span class="k">when</span> <span class="p">{</span>
      <span class="n">popupProfileImages</span><span class="p">.</span><span class="nf">isNotEmpty</span><span class="p">()</span> <span class="p">-&gt;</span> <span class="p">{</span>
          <span class="n">_showProfileListDialogEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span><span class="n">popupProfileImages</span><span class="p">)</span>
      <span class="p">}</span>
      <span class="k">else</span> <span class="p">-&gt;</span> <span class="p">{</span>
          <span class="c1">// 기본 리스트 없을 때(바텀시트 바로 띄우기)</span>
      <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이전에 받아왔던 프로필 이미지들이 리스트로 들어왔을 때, 그 길이를 뷰모델에서 체크하게 합니다. 리스트가 비어있을 때는 바텀시트를 바로 띄우고 하나라도 이미지가 있을 경우 프로필 리스트 화면을 띄워줘야 했기 때문에 조건에 따라 나누어서 각기 다른 이벤트를 실행, 온보딩 다이얼로그에서 해당 이벤트를 각각 옵저빙하도록 했습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">init</span> <span class="p">{</span>
        <span class="n">lifecycleScope</span><span class="p">.</span><span class="nf">launchWhenCreated</span> <span class="p">{</span>
            <span class="n">selectProfileImageManager</span> <span class="p">=</span> <span class="nc">SelectProfileImageManager</span><span class="p">(</span>
                <span class="k">this</span><span class="nd">@ChatOnBoardingDialog</span><span class="p">,</span>
                <span class="kd">object</span> <span class="err">: </span><span class="nc">SelectProfileImageManager</span><span class="p">.</span><span class="nc">OnSelectedImageListener</span> <span class="p">{</span>
                    <span class="k">override</span> <span class="k">fun</span> <span class="nf">onSelected</span><span class="p">(</span><span class="n">uri</span><span class="p">:</span> <span class="nc">Uri</span><span class="p">)</span> <span class="p">{</span>
                        <span class="c1">// 사용자 커스텀 이미지</span>
                    <span class="p">}</span>

                    <span class="k">override</span> <span class="k">fun</span> <span class="nf">onSelected</span><span class="p">(</span><span class="n">defaultImage</span><span class="p">:</span> <span class="nc">String</span><span class="p">)</span> <span class="p">{</span>
                        <span class="c1">// 기본 이미지로 변경</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>

<span class="k">private</span> <span class="k">fun</span> <span class="nf">observeUi</span><span class="p">()</span> <span class="p">=</span> <span class="nf">with</span><span class="p">(</span><span class="n">viewModel</span><span class="p">)</span> <span class="p">{</span>
    <span class="o">..</span><span class="p">.</span>
    <span class="nf">event</span><span class="p">(</span><span class="n">showProfileListDialogEvent</span><span class="p">)</span> <span class="p">{</span>
        <span class="nc">ProfileImageListDialog</span><span class="p">().</span><span class="nf">apply</span> <span class="p">{</span>
            <span class="o">..</span><span class="p">.</span>
            <span class="n">data</span> <span class="p">=</span> <span class="n">it</span>
            <span class="n">onResult</span> <span class="p">=</span> <span class="p">{</span> <span class="n">selected</span><span class="p">,</span> <span class="n">custom</span> <span class="p">-&gt;</span>
                <span class="n">selected</span><span class="o">?.</span><span class="nf">let</span> <span class="p">{</span> <span class="p">(</span><span class="n">imageId</span><span class="p">,</span> <span class="n">image</span><span class="p">)</span> <span class="p">-&gt;</span>
                    <span class="n">viewModel</span><span class="p">.</span><span class="nf">selectedProfileImage</span><span class="p">(</span><span class="n">imageId</span><span class="p">,</span> <span class="n">image</span><span class="p">)</span>
                <span class="p">}</span> <span class="o">?:</span> <span class="nf">run</span> <span class="p">{</span>
                    <span class="n">custom</span><span class="o">?.</span><span class="nf">let</span> <span class="p">{</span> <span class="n">customImage</span> <span class="p">-&gt;</span>
                        <span class="n">viewModel</span><span class="p">.</span><span class="nf">setCustomProfileImage</span><span class="p">(</span><span class="n">customImage</span><span class="p">)</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}.</span><span class="nf">show</span><span class="p">(</span><span class="nf">requireActivity</span><span class="p">().</span><span class="n">supportFragmentManager</span><span class="p">,</span> <span class="nc">ProfileImageListDialog</span><span class="p">.</span><span class="nc">TAG</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="o">..</span><span class="p">.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>프로필 리스트 화면(다이얼로그)을 띄워야 할 때 선택된 사진이 이미지 선택 모듈(selectProfileImageManger)을 통해서 결과로 넘어옵니다. 받아온 이미지를 가지고 커스텀된 이미지일 경우 사용자의 이미지를 서버로 업로드하는 뷰모델의 함수를 실행시키고, 기존 이미지일 경우 선택된 이미지의 아이디를 서버로 넘기는 함수를 호출합니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">sealed</span> <span class="kd">class</span> <span class="nc">OnBoardingInfo</span><span class="p">(</span>
    <span class="o">..</span><span class="p">.</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="kd">data class</span> <span class="nc">Name</span><span class="p">(</span>
        <span class="o">..</span><span class="p">.</span>
        <span class="kd">val</span> <span class="py">profileImages</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileImage</span><span class="p">&gt;,</span>
        <span class="o">..</span><span class="p">.</span>
    <span class="p">)</span> <span class="p">:</span> <span class="nc">OnBoardingInfo</span><span class="p">(</span><span class="n">popupId</span><span class="p">,</span> <span class="n">content</span><span class="p">,</span> <span class="n">isSelected</span><span class="p">,</span> <span class="n">index</span><span class="p">,</span> <span class="n">hasNext</span><span class="p">,</span> <span class="n">visibleCondition</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이미지를 받아올 때는 온보딩 데이터 클래스에 프로필 이미지 리스트를 받아오는 변수를 추가하여 받아오도록 했습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">sealed</span> <span class="kd">class</span> <span class="nc">OnBoardingPopupListEntity</span><span class="p">(</span>
    <span class="o">..</span><span class="p">.</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="o">..</span><span class="p">.</span>
    <span class="kd">data class</span> <span class="nc">Name</span><span class="p">(</span>
        <span class="o">..</span><span class="p">.</span>
        <span class="kd">val</span> <span class="py">profileUrl</span><span class="p">:</span> <span class="nc">String</span><span class="p">,</span>
        <span class="kd">val</span> <span class="py">profileImages</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Any</span><span class="p">&gt;,</span>
        <span class="o">..</span><span class="p">.</span>
    <span class="p">)</span> <span class="p">:</span> <span class="nc">OnBoardingPopupListEntity</span><span class="p">(</span><span class="n">popupId</span><span class="p">,</span> <span class="n">visibleConditions</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">OnBoardingPopupListEntityMapper</span> <span class="nd">@Inject</span> <span class="k">constructor</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">fun</span> <span class="nf">map</span><span class="p">(</span>
      <span class="o">..</span><span class="p">.</span>
  <span class="p">):</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">OnBoardingPopupListEntity</span><span class="p">&gt;</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="n">from</span><span class="p">.</span><span class="nf">isEmpty</span><span class="p">())</span> <span class="p">{</span>
          <span class="k">return</span> <span class="nf">listOf</span><span class="p">(</span>
              <span class="nc">OnBoardingPopupListEntity</span><span class="p">.</span><span class="nc">Name</span><span class="p">(</span>
                  <span class="o">..</span><span class="p">.</span>
                  <span class="n">profileImages</span> <span class="p">=</span> <span class="nf">listOf</span><span class="p">()</span>
              <span class="p">)</span>
          <span class="p">)</span>
      <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
          <span class="k">return</span> <span class="n">from</span><span class="p">.</span><span class="nf">map</span> <span class="p">{</span> <span class="n">popup</span> <span class="p">-&gt;</span>
              <span class="k">when</span> <span class="p">(</span><span class="n">popup</span><span class="p">.</span><span class="n">type</span><span class="p">)</span> <span class="p">{</span>
                  <span class="o">..</span><span class="p">.</span>
                  <span class="nc">ONBOARDING_POPUP_TYPE</span><span class="p">.</span><span class="nc">NAME</span><span class="p">,</span> <span class="nc">ONBOARDING_POPUP_TYPE</span><span class="p">.</span><span class="nc">OTHERNAME</span> <span class="p">-&gt;</span> <span class="p">{</span>
                      <span class="nc">OnBoardingPopupListEntity</span><span class="p">.</span><span class="nc">Name</span><span class="p">(</span>
                          <span class="o">..</span><span class="p">.</span>
                          <span class="n">profileImages</span> <span class="p">=</span> <span class="n">popup</span><span class="p">.</span><span class="n">popupProfileImages</span><span class="o">!!</span><span class="p">.</span><span class="nf">map</span> <span class="p">{</span> <span class="n">image</span> <span class="p">-&gt;</span>
                              <span class="nc">ProfileImageListEntity</span><span class="p">(</span>
                                  <span class="n">popupId</span> <span class="p">=</span> <span class="n">image</span><span class="p">.</span><span class="n">popupId</span><span class="p">,</span>
                                  <span class="n">profileImageFileId</span> <span class="p">=</span> <span class="n">image</span><span class="p">.</span><span class="n">profileImageFileId</span><span class="p">,</span>
                                  <span class="n">profileImageFileLink</span> <span class="p">=</span> <span class="n">image</span><span class="p">.</span><span class="n">profileImageFile</span><span class="o">?.</span><span class="n">link</span> <span class="o">?:</span> <span class="s">""</span>
                              <span class="p">)</span>
                          <span class="p">},</span>
                          <span class="o">..</span><span class="p">.</span>
                      <span class="p">)</span>
                  <span class="p">}</span>
                  <span class="o">..</span><span class="p">.</span>
              <span class="p">}</span>
          <span class="p">}</span>
      <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>또한 이미지를 서버에서 받아오면서 기존에는 존재하지 않던 이미지 리스트를 받아오기 때문에 그 과정에서 map을 해주는 mapper의 수정이 꼭 필요했기 때문에 OnBoardingPopupListEntityMapper와 OnBoardingPopupMapper도 일부 수정을 해야 했습니다.</p>

<p>그 외에 프로필 이미지 리스트 화면을 구현할 때 제가 신경썼던 요소들은 다음과 같습니다.</p>

<ol>
  <li>
    <p>사진 추가 버튼과 이미지 함께 리사이클러뷰 추가 → Sealed class 활용</p>

    <p>프로필 리스트 화면에서 하단 리사이클러뷰에 사진 추가 버튼과 이미지를 함께 추가해야하는 것이 문제였습니다. 같은 ‘Profile’ 데이터 클래스로 구현하자니 사진 추가 버튼을 클릭했을 때와 이미지를 클릭했을 때 각각 발생시켜야 하는 이벤트가 달랐기 때문입니다. 이때 온보딩 과제에서 처음 사용해본 sealed class를 사용해서 프로필 이미지와 사진 추가 버튼을 두 타입으로 나누어서 다루었습니다.</p>

    <div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="k">sealed</span> <span class="kd">class</span> <span class="nc">Profile</span> <span class="p">{</span>
     <span class="kd">data class</span> <span class="nc">ProfileImage</span><span class="p">(</span>
         <span class="kd">val</span> <span class="py">popupId</span><span class="p">:</span> <span class="nc">Int</span><span class="p">,</span>
         <span class="kd">val</span> <span class="py">profileImageId</span><span class="p">:</span> <span class="nc">Int</span><span class="p">?,</span>
         <span class="kd">val</span> <span class="py">link</span><span class="p">:</span> <span class="nc">String</span><span class="p">,</span>
         <span class="kd">val</span> <span class="py">localImage</span><span class="p">:</span> <span class="nc">Uri</span><span class="p">?,</span>
         <span class="kd">val</span> <span class="py">selected</span><span class="p">:</span> <span class="nc">Boolean</span>
     <span class="p">)</span> <span class="p">:</span> <span class="nc">Profile</span><span class="p">()</span>
    
     <span class="kd">object</span> <span class="nc">ProfileAdd</span> <span class="p">:</span> <span class="nc">Profile</span><span class="p">()</span>
 <span class="p">}</span>
</code></pre></div>    </div>

    <p>데이터 타입을 두 가지로 나눴기 때문에 이후 프로필 리스트 뷰모델에서 각 아이템을 클릭했을 때 발생하는 함수도 따로 나누어 처리할 수 있었습니다. 각 함수에서는 각기 다른 이벤트를 발생시켜 프로필 리스트 화면에서 기능해야할 것들도 나눌 수 있었습니다.</p>

    <div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="k">fun</span> <span class="nf">onClickItem</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileImage</span><span class="p">)</span> <span class="p">{</span>
 	<span class="c1">// 이미지 클릭 시 이벤트 발생</span>
     <span class="n">_showProfileImageEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
     <span class="o">..</span><span class="p">.</span>
 <span class="p">}</span>
    
 <span class="k">fun</span> <span class="nf">onClickAdd</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileAdd</span><span class="p">)</span> <span class="p">{</span>
     <span class="c1">// 사진 추가 버튼 누른 이벤트 발생</span>
 	<span class="n">_showImageGalleryEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
 	<span class="o">..</span><span class="p">.</span>
 <span class="p">}</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p>선택 사진 UI 변경 → selected 변수로 판단, viewModel에서</p>

    <p>하단 리사이클러뷰에서 사용자가 이미지를 선택했을 때 사진처럼 이미지의 테두리와 이미지를 덮고 있는 뷰의 색상이 변하도록 설정해야 했습니다. 저는 처음에 리사이클러뷰에 이미지를 넣으며 Holder에서 그 설정을 처리해줘야 한다고 생각했습니다. 하지만 position에 대한 정보 등은 얻을 수 있어도 UI에 변화를 주는 것이 불가능 했습니다.</p>

    <p>이때 코드리뷰를 바탕으로 select된 이미지인지 아닌지를 나타내주는 변수를 하나 데이터 클래스에 추가한 후 Holder가 아닌 뷰모델에서 그 아이템의 정보를 변경시키는 것이 좋다는 것을 알았습니다. 실제로 Holder는 매번 초기화되기 때문에 그 순간은 선택된 것을 알았더라도 다시 리사이클러뷰가 로드되면 그 정보는 사라지고 reset되기 때문입니다.</p>

    <div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="k">fun</span> <span class="nf">onClickItem</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileImage</span><span class="p">)</span> <span class="p">{</span>
     <span class="n">_showProfileImageEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
     <span class="p">(</span><span class="n">profileImageListState</span><span class="p">.</span><span class="n">value</span> <span class="k">as</span><span class="p">?</span> <span class="nc">ProfileImageListState</span><span class="p">.</span><span class="nc">Data</span><span class="p">)</span><span class="o">?.</span><span class="n">data</span><span class="o">?.</span><span class="nf">let</span> <span class="p">{</span>
         <span class="kd">val</span> <span class="py">mutableList</span> <span class="p">=</span> <span class="n">mutableListOf</span><span class="p">&lt;</span><span class="nc">Profile</span><span class="p">&gt;()</span>
         <span class="k">for</span> <span class="p">(</span><span class="n">profile</span> <span class="k">in</span> <span class="n">it</span><span class="p">)</span> <span class="p">{</span>
             <span class="k">when</span> <span class="p">(</span><span class="n">profile</span><span class="p">)</span> <span class="p">{</span>
                 <span class="k">is</span> <span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileImage</span> <span class="p">-&gt;</span> <span class="p">{</span>
 			<span class="c1">// 선택한 이미지와 같은 이미지를 리스트에서 골라 selected 됐다고 설정해준다.</span>
                     <span class="k">if</span> <span class="p">(</span><span class="n">profile</span><span class="p">.</span><span class="n">profileImageId</span> <span class="p">==</span> <span class="n">item</span><span class="p">.</span><span class="n">profileImageId</span><span class="p">)</span> <span class="p">{</span>
                         <span class="n">mutableList</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">profile</span><span class="p">.</span><span class="nf">copy</span><span class="p">(</span><span class="n">selected</span> <span class="p">=</span> <span class="k">true</span><span class="p">))</span>
                     <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                         <span class="n">mutableList</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">profile</span><span class="p">.</span><span class="nf">copy</span><span class="p">(</span><span class="n">selected</span> <span class="p">=</span> <span class="k">false</span><span class="p">))</span>
                     <span class="p">}</span>
                 <span class="p">}</span>
                 <span class="k">is</span> <span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileAdd</span> <span class="p">-&gt;</span> <span class="p">{</span>
                     <span class="n">mutableList</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">profile</span><span class="p">)</span>
                 <span class="p">}</span>
             <span class="p">}</span>
         <span class="p">}</span>
         <span class="n">_profileImageListState</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">ProfileImageListState</span><span class="p">.</span><span class="nc">Data</span><span class="p">(</span><span class="n">data</span> <span class="p">=</span> <span class="n">mutableList</span><span class="p">)</span>
     <span class="p">}</span>
 <span class="p">}</span>
    
 <span class="k">fun</span> <span class="nf">onClickAdd</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileAdd</span><span class="p">)</span> <span class="p">{</span>
     <span class="c1">// 사진 추가 버튼 누른 이벤트 발생</span>
 <span class="p">}</span>
</code></pre></div>    </div>

    <p>따라서 뷰모델에 이미지를 클릭했을 때 실행되는 함수에 리사이클러뷰에 넣어줄 모든 이미지들을 한번씩 순회하며 선택된 이미지와 같은 이미지의 경우 해당 이미지 selected 값을 true로 변경시켜주었습니다.</p>
  </li>
</ol>

<ul>
  <li><strong>사진 변경 바텀시트 추가</strong></li>
</ul>

<p><img src="https://user-images.githubusercontent.com/65072995/182788426-33988bb4-d473-434c-b91a-1b68e92e9341.png" alt="8" /></p>

<p>바텀시트가 뜨는 경우는 다음과 같습니다.</p>

<ol>
  <li>기본 프로필 이미지 리스트가 없을 때, 온보딩 팝업 편집 아이콘 클릭 시 바로</li>
  <li>프로필 리스트 화면에서 사진 추가 버튼을 눌렀을 때</li>
</ol>

<p>바텀시트의 종류도 두 가지입니다.</p>

<ol>
  <li>기본 이미지로 변경이 있는 경우</li>
  <li>기본 이미지로 변경 없이 두 가지 선택지만 있는 경우</li>
</ol>

<p>첫 번째 바텀시트가 뜨는 경우는 딱 한가지 입니다. 기본 프로필 이미지 리스트가 없어 온보딩 팝업에서 바로 사진 편집을 할 때, 초기 상태가 아닌 이미 n번 사용자가 사진을 변경한 경우입니다. 두 번째 바텀시트는 그 외 모든 경우에 해당합니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fun</span> <span class="nf">editProfileImage</span><span class="p">(</span><span class="n">popupProfileImages</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Profile</span><span class="p">.</span><span class="nc">ProfileImage</span><span class="p">&gt;)</span> <span class="p">{</span>
    <span class="k">when</span> <span class="p">{</span>
        <span class="n">popupProfileImages</span><span class="p">.</span><span class="nf">isNotEmpty</span><span class="p">()</span> <span class="p">-&gt;</span> <span class="p">{</span>
            <span class="o">..</span><span class="p">.</span>
        <span class="p">}</span>
        <span class="k">else</span> <span class="p">-&gt;</span> <span class="p">{</span>
            <span class="p">(</span><span class="n">onBoardingInfo</span><span class="p">.</span><span class="n">value</span> <span class="k">as</span><span class="p">?</span> <span class="nc">OnBoardingInfo</span><span class="p">.</span><span class="nc">Name</span><span class="p">)</span><span class="o">?.</span><span class="nf">let</span> <span class="p">{</span> <span class="n">currentBoardingInfo</span> <span class="p">-&gt;</span>
                <span class="kd">val</span> <span class="py">origin</span> <span class="p">=</span>
                    <span class="n">onBoardingLists</span><span class="p">.</span><span class="n">value</span><span class="o">?.</span><span class="nf">find</span> <span class="p">{</span> <span class="n">it</span><span class="p">.</span><span class="n">popupId</span> <span class="p">==</span> <span class="n">currentBoardingInfo</span><span class="p">.</span><span class="n">popupId</span> <span class="p">}</span> <span class="k">as</span><span class="p">?</span> <span class="nc">OnBoardingInfo</span><span class="p">.</span><span class="nc">Name</span>
		<span class="c1">// 현재 이미지가 기본 이미지일 때(기본 이미지로 변경 띄우지 않음)</span>
                <span class="k">if</span> <span class="p">(</span><span class="n">currentBoardingInfo</span><span class="p">.</span><span class="n">selectedProfileImageState</span> <span class="p">==</span> <span class="nc">OnBoardingInfo</span><span class="p">.</span><span class="nc">ProfileImageState</span><span class="p">.</span><span class="nc">DEFAULT</span><span class="p">)</span> <span class="p">{</span>
                    <span class="n">_showBottomSheetEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span><span class="n">origin</span><span class="o">?.</span><span class="n">profileUrl</span><span class="p">)</span>
                <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="c1">// 현재 이미지가 변경된 이미지일 때(기본 이미지로 변경 띄움)</span>
                    <span class="n">_showDefaultBottomSheetEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span><span class="n">origin</span><span class="o">?.</span><span class="n">profileUrl</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span> <span class="o">?:</span> <span class="nf">run</span> <span class="p">{</span>
                <span class="n">_showBottomSheetEvent</span><span class="p">.</span><span class="n">value</span> <span class="p">=</span> <span class="nc">Event</span><span class="p">(</span><span class="k">null</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
</code></pre></div></div>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">fun</span> <span class="nf">observeUi</span><span class="p">()</span> <span class="p">=</span> <span class="nf">with</span><span class="p">(</span><span class="n">viewModel</span><span class="p">)</span> <span class="p">{</span>
    <span class="o">..</span><span class="p">.</span>
    <span class="nf">event</span><span class="p">(</span><span class="n">showBottomSheetEvent</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">selectProfileImageManager</span><span class="p">.</span><span class="nf">showTextListBottomSheet</span><span class="p">(</span><span class="k">null</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="nf">event</span><span class="p">(</span><span class="n">showDefaultBottomSheetEvent</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">selectProfileImageManager</span><span class="p">.</span><span class="nf">showTextListBottomSheet</span><span class="p">(</span><span class="n">it</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="o">..</span><span class="p">.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>그리고 각 이벤트에 따라서 selectProfileImageManager의 함수를 각기 다르게 호출합니다. null값이 바텀시트를 보여주는 해당 함수로 넘어가게 되면 기본 이미지로 변경하는 항목은 보이지 않게 설정되어 나오고, 디폴트 이미지가 해당 함수로 넘어가면 기본 이미지로 변경 항목이 나오면서 클릭하는 경우 함수로 넘겨진 기본 이미지로 변경됩니다.</p>

<p>그 밖에 기본 이미지가 없는 경우 안드로이드의 팝업 구현도 변경하였습니다. 기존에 안드로이드는 기본 이미지가 없을 때 아예 원형의 이미지 뷰 자리를 남겨두지 않았습니다. 하지만 기본 이미지가 없더라도 이미지 변경 버튼을 제 위치에 삽입해야 했기 때문에 안드로이드도 iOS와 마찬가지로 빈 둥근 화면을 표시하기로 했습니다.</p>

<ul>
  <li><strong>기존 온보딩 팝업 API 수정</strong></li>
</ul>

<p>프로필 이미지 변경 작업을 하면서 가장 어려웠던 작업이었습니다.</p>

<p>서버와 성공적으로 이미지를 주고받기 위해서는 다음과 같은 방법으로 진행이 되어야 했습니다.</p>

<ol>
  <li>온보딩 팝업 정보에 담긴 boolean 정보로 프로필 이미지 업로드 가능 여부를 판단</li>
  <li>기본 프로필 이미지가 있는 경우 해당 챕터 온보딩 팝업들 정보를 담고 있는 곳의 프로필 이미지 리스트 정보를 받아와서 링크를 가져와 사용</li>
  <li>사용자의 이미지를 업로드 할 경우 s3에 먼저 업로드한 후 이미지의 정보를 서버에 넘겨줘야 한다.
    <ol>
      <li>getPresignedUrls로 파일을 업로드 할 링크를 서버에서 받아온다. hashId, fileName등 파일에 대한 저장 정보가 리턴된다.</li>
      <li>리턴된 정보를 가지고 있다가 온보딩 팝업이 끝난 후 startStory로 채팅이 시작될 때 customProfileFileInfo에 해당 정보를 담아 넘겨줘야 한다.</li>
    </ol>
  </li>
  <li>기본 프로필 이미지를 넘기는 경우 그냥 변경된 이미지의 profileImageFileId를 다시 넘겨주면 된다.</li>
</ol>

<p>하지만 여기서 생각지못한 문제가 있었는데, 이미지가 s3에 잘 업로드 되었음에도 해당 정보를 넘겼을 때 서버에서는 그 이미지가 내려오지 않아 Api에 CustomProfileFileInfo를 추가하니 아예 채팅 진입에 오류가 뜨길래 고전하고 있었는데 문제는 storageType 때문이었습니다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fun</span> <span class="nf">startStory</span><span class="p">(</span>
    <span class="n">param</span><span class="p">:</span> <span class="nc">StartStoryParams</span>
<span class="p">):</span> <span class="nc">Observable</span><span class="p">&lt;</span><span class="nc">PlayChapterInfoDto</span><span class="p">&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">apolloClient</span><span class="p">.</span><span class="nf">mutate</span><span class="p">(</span>
        <span class="nc">StartStoryMutation</span><span class="p">(</span>
            <span class="o">..</span><span class="p">.</span>
            <span class="nc">Input</span><span class="p">.</span><span class="nf">optional</span><span class="p">(</span>
                <span class="n">param</span><span class="p">.</span><span class="n">otherCharacterInfoList</span><span class="p">.</span><span class="nf">map</span> <span class="p">{</span> <span class="n">info</span> <span class="p">-&gt;</span>
                    <span class="k">lateinit</span> <span class="kd">var</span> <span class="py">result</span><span class="p">:</span> <span class="nc">OtherCharacterInfo</span>
                    <span class="k">lateinit</span> <span class="kd">var</span> <span class="py">storageType</span><span class="p">:</span> <span class="nc">Storages</span>
                    <span class="c1">// storageType이 Unknown으로 인지되던 문제</span>
                    <span class="n">info</span><span class="p">.</span><span class="n">customProfileFileInfo</span><span class="o">?.</span><span class="nf">let</span> <span class="p">{</span>
                        <span class="k">when</span> <span class="p">(</span><span class="n">it</span><span class="p">.</span><span class="n">storageType</span><span class="p">)</span> <span class="p">{</span>
                            <span class="s">"STORYFILES"</span> <span class="p">-&gt;</span> <span class="p">{</span>
                                <span class="n">storageType</span> <span class="p">=</span> <span class="nc">Storages</span><span class="p">.</span><span class="nc">STORYFILES</span>
                            <span class="p">}</span>
                            <span class="s">"IMAGEFILE"</span> <span class="p">-&gt;</span> <span class="p">{</span>
                                <span class="n">storageType</span> <span class="p">=</span> <span class="nc">Storages</span><span class="p">.</span><span class="nc">IMAGEFILE</span>
                            <span class="p">}</span>
                            <span class="s">"SFXFILE"</span> <span class="p">-&gt;</span> <span class="p">{</span>
                                <span class="n">storageType</span> <span class="p">=</span> <span class="nc">Storages</span><span class="p">.</span><span class="nc">SFXFILE</span>
                            <span class="p">}</span>
                            <span class="s">"STUDIOFILE"</span> <span class="p">-&gt;</span> <span class="p">{</span>
                                <span class="n">storageType</span> <span class="p">=</span> <span class="nc">Storages</span><span class="p">.</span><span class="nc">STUDIOFILE</span>
                            <span class="p">}</span>
                            <span class="k">else</span> <span class="p">-&gt;</span> <span class="p">{</span>
                                <span class="n">storageType</span> <span class="p">=</span> <span class="nc">Storages</span><span class="p">.</span><span class="nc">UNKNOWN__</span>
                            <span class="p">}</span>
                        <span class="p">}</span>
                        <span class="n">result</span> <span class="p">=</span> <span class="nc">OtherCharacterInfo</span><span class="p">(</span>
                            <span class="o">..</span><span class="p">.</span>
                            <span class="nc">Input</span><span class="p">.</span><span class="nf">optional</span><span class="p">(</span>
                                <span class="nc">CustomProfileFileInfo</span><span class="p">(</span>
                                    <span class="n">hashId</span> <span class="p">=</span> <span class="n">it</span><span class="p">.</span><span class="n">hashId</span><span class="p">,</span>
                                    <span class="n">isTemp</span> <span class="p">=</span> <span class="n">it</span><span class="p">.</span><span class="n">isTemp</span><span class="p">,</span>
                                    <span class="n">fileName</span> <span class="p">=</span> <span class="n">it</span><span class="p">.</span><span class="n">fileName</span><span class="p">,</span>
                                    <span class="n">storageType</span> <span class="p">=</span> <span class="n">storageType</span>
                                <span class="p">)</span>
                            <span class="p">)</span>
                        <span class="p">)</span>
                    <span class="p">}</span> <span class="o">?:</span> <span class="nf">run</span> <span class="p">{</span>
                        <span class="n">result</span> <span class="p">=</span> <span class="nc">OtherCharacterInfo</span><span class="p">(</span>
                            <span class="o">..</span><span class="p">.</span>
                        <span class="p">)</span>
                    <span class="p">}</span>
                    <span class="n">result</span>
                <span class="p">}</span>
            <span class="p">),</span>
            <span class="o">..</span><span class="p">.</span>
        <span class="p">)</span>
    <span class="p">)</span>
    <span class="o">..</span><span class="p">.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>s3에 이미지를 저장하고 나면 storageType이라는 것이 리턴됩니다. 해당 정보도 다시 서버로 넘겨줘야 했는데, ApiService에서 Storages 타입의 데이터가 잘 넘어가지 않아 오류가 발생했습니다. 왜 unknown으로 분류가 되는지 살펴보기 위해 storageType이 어떻게 넘어오는지 확인해보았는데, Storages에서는 타입을 구분하는 raw value로 “StoryFiles”를 활용하고 있었고, 서버에서 넘어와서 여기서 제가 넣어줬던 값은 “STORYFILES” 라서 대소문자가 달라 같은 값으로 취급이 되지 않아 Unknown이 떴던 것이었습니다. 이 부분을 아예 조건으로 분류하여 따로 처리를 해주었고, 그러자 문제가 해결되었습니다.</p>

<hr />

<h3 id="띵스플로우-인턴을-마치며">띵스플로우 인턴을 마치며</h3>

<p>띵스플로우 인턴이 된지 벌써 두 달이 흘러 인턴이 끝날 시간이 되었다는 게 실감도 안나고 아쉽습니다.</p>

<p>그동안 스플 안드로이드 인턴으로 일하며 정말 많은 것을 배우고 갑니다.</p>

<p>아무것도 모르고 코틀린도 어색해했던 제가 실제로 서비스되는 앱에 조금이나마 기여할 수 있었던 건 많은 분들이 도와주신 덕분인 것 같습니다. 혼자였으면 고칠 수 없었을 부분도 코드리뷰를 통해 개선할 수 있었고 다양한 직무에서 일하는 분들과도 소통하며 저 혼자만의 생각보다 더 나은 방법으로 구현할 수 있었습니다. 덕분에 안드로이드 앱 개발이 더 재밌어졌어요!</p>

<p>사실 이때까지 혼자 혹은 친구들과 함께 앱 개발 프로젝트를 몇 번 진행해봤음에도 실제로 스플을 개발하면서 느낀점은 꽤 색달랐습니다. 앱을 설계할 때 무작정 설계하는 것이 아니라, 디자인 패턴을 적용해서 설계할 수 있다는 것. 같은 기능을 구현해도 어떤 기술 스택을 적용했을 때 훨씬 효율적으로 구현할 수 있다는 것. 다른 팀원들과 협업했을 때 그 시너지 효과는 엄청나다는 것. 어려운 부분이 있을 때 서로 공유하고 도움을 줄 수 있다는 것. 그 밖에도 스플 인턴으로 일하며 느낀 모든 것이 저에게는 엄청난 영향을 미쳤습니다.</p>

<p>또한 인턴으로서 띵플에서 함께했던 두 달간의 시간은 학교에서 배운 것 뿐만 아니라 현직 개발자들은 어떻게 일하는지를 알 수 있었고, 제가 항상 궁금했던 스타트업의 생태도 체험할 수 있었던 소중한 시간이자 흔치않은 기회였습니다.</p>

<p>제 첫 인턴 생활이자 직장 생활을 띵스플로우, 그리고 스토리플레이 팀에서 할 수 있어서 너무 행복하고 즐거웠습니다. 띵스플로우, 스플 모두 감사합니다!</p>]]></content><author><name>hyunjeong.lee</name></author><category term="tech" /><category term="storyplay" /><category term="android" /><category term="kotlin" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">ArgoCD를 활용한 k8 CI/CD pipeline 구축 #1</title><link href="https://techblog.thingsflow.com/tech/review/ArgoCD%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-K8-CICD-pipeline-%EA%B5%AC%EC%B6%95-1/" rel="alternate" type="text/html" title="ArgoCD를 활용한 k8 CI/CD pipeline 구축 #1" /><published>2022-07-22T00:00:00+09:00</published><updated>2022-07-22T00:00:00+09:00</updated><id>https://techblog.thingsflow.com/tech/review/ArgoCD%EB%A5%BC%20%ED%99%9C%EC%9A%A9%ED%95%9C%20K8%20CICD%20pipeline%20%EA%B5%AC%EC%B6%95#1</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/review/ArgoCD%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-K8-CICD-pipeline-%EA%B5%AC%EC%B6%95-1/"><![CDATA[<h2 id="이야기에-앞서">이야기에 앞서</h2>

<p>띵스플로우 인프라팀 SRE/DevOps 조직은 현재 소수의 인원으로 스토리플레이, 헬로우봇, 비트윈 3개 서비스를 대상으로 장애 관리, 클라우드 인프라 운영을하고 있고 어플리케이션<br />
배포 및 모니터링을 자동화하여 개발자분들이 최대한 개발에만 집중 할 수 있도록 노력하고 있습니다.</p>

<p>또, 최신 기술을 POC하고 서비스 성능 최적화를 통한 인프라 비용 최적화 작업을 꾸준히<br />
진행하고 있어요. 띵스플로우에는 node.js로 개발되어 있는 서비스들이 많은데, 싱글 스레드로 동작하는 node.js를 운영하면서 서버 인스턴스들의 CPU 사용률 비효율과 급격하게<br />
트래픽이 증가하면서 상대적으로 느린 EC2 AutoScaling 속도에 한계를 경험하여<br />
스토리플레이 EKS 마이그레이션을 진행했고 컨테이너 배포 자동화를 위해 ArgoCD &amp; GitHub Actions을 활용하여 CI/CD pipeline을 구축했습니다.</p>

<p>이 글에서 EKS 구축 자동화, ArgoCD 구축, 배포 자동화 과정에 대해 소개하려 합니다.</p>

<h2 id="사용-기술">사용 기술</h2>
<p>이 글에서 다루게 될 기술과 주제에요.</p>
<ol>
  <li><strong>Container Orchestration</strong>
    <ul>
      <li>Why EKS ?</li>
      <li>Terraform을 활용한 EKS 구축</li>
      <li>EKS 주요 addOn 설치 자동화 및 서비스 통합</li>
    </ul>
  </li>
  <li><strong>어플리케이션 배포 자동화와 GitOps (CI/CD pipeline)</strong>
    <ul>
      <li>What’s GitOps</li>
      <li>GitHub Actions (CI Tool)</li>
      <li>ArgoCD (CD Tool)</li>
    </ul>
  </li>
</ol>

<h2 id="container-orchestration">Container Orchestration</h2>
<p>이 글에서 가장 큰 범위의 주제는 Container Orchestration 구축과<br />
Container Orchestration의 CICD pipeline 구축이에요. 아래 그래프는 Container<br />
Orchestration Market Share 인데요. 우리가 알고 있는 것보다 많은 플랫폼이 존재해요.<br />
<img src="https://mblogthumb-phinf.pstatic.net/MjAyMDAzMTBfMTM2/MDAxNTgzODM3MTk0MjAw.XrfpuHXy8bP4FNTeVKzNyIuuZotpaXeWoG3M4MfDuwgg.88cpjop7jDQ0YxkHWeEbMu7WSxOEbuNkZoaBAvdZAO4g.PNG.shakey7/image.png?type=w800" alt="" /><br />
특징적인 것은 Docker Swarm, Nomad, Cloud Foundry 같은 몇몇 플랫폼을 제외하면<br />
모두 오픈소스인 Kubernetes를 기반으로 각각의 Cloud Provider에서 자기 환경에 맞게 Customizing 된 것들인 것을 알 수 있습니다. 사용자는 각각 자신에 환경에 맞는 Container Orchestration를 선택하여 사용할 수 있어요.</p>

<h3 id="why-eks-">Why EKS ?</h3>
<p>EKS는 AWS에서 제공하는 완전관리형 쿠버네티스(Kubernetes) 서비스에요.
띵스플로우에서는 AWS를 메인으로 사용하고 있기 때문에 AWS EKS가 가장 적합한 Container Orchestration 이라고 생각합니다.
왜냐면 AWS에 있는 서비스들과의 연동과 다양한<br />
사용자 편의 기능을 제공하고 있어서 AWS를 사용중이라면 사용하지 않을 이유가 없죠.</p>

<p>아래 내용은 EKS에서 제공하는 핵심 기능입니다.</p>
<ol>
  <li><strong>완전관리형</strong> : Control Plane(Master)에 대한 설치와 유지관리, 버전관리를 알아서<br />
해주고 고가용성을 확보 해줍니다.
<img src="https://static.wixstatic.com/media/044ed3_713d2f8900a54cd88382aef58664d677~mv2.png/v1/fill/w_1136,h_859,al_c,q_90/044ed3_713d2f8900a54cd88382aef58664d677~mv2.webp" alt="" /></li>
  <li><strong>VPC CNI</strong> : 일반적인 k8에서는 파드의 네트워크가 워커 노드의 네트워크와 다른 자체적인 네트워크 대역을 사용하여 외부에서 파드와 통신하는 것이 불가능 하지만 VPC CNI를 사용하면 파드에서 VPC 내부 네트워크 대역을 사용할 수 있어요.
<img src="https://d2908q01vomqb2.cloudfront.net/ca3512f4dfa95a03169c5a670a4c91a19b3077b4/2018/02/27/cni.jpg" alt="Exploring the Networking Foundation for EKS: amazon-vpc-cni-k8s + Calico |  AWS Open Source Blog" /></li>
  <li><strong>AWS ELB 통합</strong> : AWS Load balancer Controller를 통한 k8 ingress와 ELB의<br />
통합을 지원합니다.
<img src="https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.4/assets/images/controller-design.png" alt="controller-design" /></li>
  <li>그 밖에 IAM를 통한 인증, AutoScaler를 통한 Worker Node 무한 확장, EBS CSI, Secret Manager CSI 와 같은 다양한 서비스 통합 기능들을 제공합니다.</li>
</ol>

<hr />

<h3 id="terraform을-활용한-eks-구축">Terraform을 활용한 EKS 구축</h3>
<p>사실 .. Terraform이 익숙하지 않다면, 그리고 참고하기 좋은 샘플 코드가 없다면 EKS를 Terraform 으로 구축 하지 않는 것을 추천해요. 왜냐면 eksctl 이라는 강력한 API를<br />
AWS에서 제공하기 때문이에요. eksctl을 사용하면 간단하게 EKS를 구축이 가능합니다.</p>

<p>굳이 Terraform을 사용한 이유는 Terraform 코드로 리소스를 관리하는게 익숙하고 좋은<br />
샘플 코드가 있었기 때문이에요. 또, Terraform을 사용 했을때 받을 수 있는 OUTPUT을<br />
이용해 EKS addOn 설치 자동화도 하고 싶었어요.</p>

<p><strong>사전 준비 사항</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>## terraform 설치 (https://learn.hashicorp.com/tutorials/terraform/install-cli)
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
sudo yum -y install terraform

## awscli &amp; eksctl 설치 및 configuration
sudo yum install -y awscli
aws configure
curl --silent --location "https://github.com/weaveworks/eksctl/releases/latest/download/eksctl_$(uname -s)_amd64.tar.gz" | tar xz -C /tmp
sudo mv /tmp/eksctl /usr/local/bin
eksctl version

## helm 설치
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod 700 get_helm.sh
./get_helm.sh

## kubectl 설치
curl -o kubectl https://s3.us-west-2.amazonaws.com/amazon-eks/1.22.6/2022-03-09/bin/linux/amd64/kubectl
chmod +x ./kubectl
mkdir -p $HOME/bin &amp;&amp; cp ./kubectl $HOME/bin/kubectl &amp;&amp; export PATH=$PATH:$HOME/bin
echo 'export PATH=$PATH:$HOME/bin' &gt;&gt; ~/.bashrc
kubectl version --short --client

git 설치 및 clone
sudo yum install -y git
git clone https://github.com/thingsflow/infra-terraform.git
</code></pre></div></div>

<p><strong>VPC 구축</strong>
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/1.png" alt="image" /></p>
<ul>
  <li>테스트를 위한 VPC가 있다면 이 작업을 SKIP 합니다.</li>
  <li>terraform.auto.tfvars를 수정하여 VPC에 대한 세부 설정을 합니다.</li>
  <li>VPC workspace는 backend로 s3를 사용하고 있습니다. (_backend.tf)</li>
  <li><strong>terraform apply</strong></li>
</ul>

<p><strong>EKS 구축</strong>
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/2.png" alt="image" /></p>
<ul>
  <li>Terraform으로 EKS를 구축 합니다.</li>
  <li>terraform.auto.tfvars에서 EKS에 대한 환경 설정을 할 수 있습니다.</li>
  <li>eks workspace도 VPC와 마찬가지로 s3 backend를 사용하고 VPC의 backend<br />
데이터를 가지고와 위에서 생성한 VPC 위에 EKS를 구축하도록 구현 되어 있습니다.(_remote.tf, _local.tf, _backend.tf)</li>
  <li>기본 설정에서는 coredns, vpccni, kube-proxy, ebs-csi-driver 4개의 addOn이 설치됩니다.</li>
  <li><strong>terraform apply</strong></li>
</ul>

<p><strong>검증</strong></p>
<ul>
  <li>VPC<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/3.png" alt="image" /></li>
  <li>subnet 생성<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/4.png" alt="image" /></li>
  <li>EKS Cluster &amp; Node Group
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/5.png" alt="image" /></li>
  <li>EKS AddOn<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/6.png" alt="image" /></li>
  <li>EKS Node ASG<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/7.png" alt="image" /></li>
</ul>

<hr />

<h3 id="aws-주요-addon-설치-자동화-및-서비스-통합">AWS 주요 addOn 설치 자동화 및 서비스 통합</h3>
<ol>
  <li>eks_addon_irsa.tf 에서 addon IRSA를 자동으로 생성해줍니다.</li>
  <li>terraform 실행이 완료되면 IRSA ARN을 받아와 scripter.tf가 ./sh 디렉토리에 필수 AddOn shell 생성합니다.<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/8.png" alt="image" /></li>
  <li>스크립트 실행
    <ul>
      <li>./00.update-kubeconfig.sh</li>
      <li>./01.aws_load_balancer_controller.sh</li>
      <li>./02.cluster_autoscaler.sh</li>
      <li>./03.secrets-store-csi-driver.sh</li>
    </ul>
  </li>
</ol>

<p><strong>검증</strong></p>
<ul>
  <li>kubectl get pod -n kube-system</li>
  <li>명령어로 모든 addOn이 잘 설치 되어 있는지 확인 합니다.<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/9.png" alt="image" /></li>
</ul>

<h2 id="마치며">마치며</h2>
<p>대부분의 Container Orchestration은 k8 엔진 기반으로 만들어졌기 때문에 대동소이<br />
하다고 볼 수 있어 운영 환경에 맞는 플랫폼을 선택하여 구축하는 것이 좋다고 생각합니다.</p>

<p>구축 방법도 여러가지가 있는데 사실 Terraform은 자주, 단순, 반복 작업을 하는 서비스에<br />
적용하는 것이 가장 효과적일 수도 있습니다.
하지만 자주 실행하지 않더라도 굉장히 복잡한 작업이라 사람이 실수 할 수 있는   포인트가 많은 서비스에 적용할 때도 자동화를 해두면 좋은<br />
효과를 보기도 합니다.</p>

<p>EKS에서 제공하는 AddOn들은 수동으로 설치해야 하고 설치 방법도 생각보다 간단하지<br />
않아 쉽게 잊어 버리고 별거 아닌 일에 시간을 빼앗기는 일도 생길 수 있어요.</p>

<p>이번 글에서는 Terraform을 사용해 EKS 구축 해봤는데요.
다음 글에서는 구축한 EKS에<br />
ArgoCD를 배포하고 ArgoCD를 통해 CI/CD pipeline을 구현하는 내용을 다루겠습니다.</p>]]></content><author><name>dachan.choi</name></author><category term="tech" /><category term="review" /><category term="eks" /><category term="cicd" /><category term="terraform" /><category term="argocd" /><category term="GitOps" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">ArgoCD를 활용한 k8 CI/CD pipeline 구축 #2</title><link href="https://techblog.thingsflow.com/tech/review/ArgoCD%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-K8-CICD-pipeline-%EA%B5%AC%EC%B6%95-2/" rel="alternate" type="text/html" title="ArgoCD를 활용한 k8 CI/CD pipeline 구축 #2" /><published>2022-07-22T00:00:00+09:00</published><updated>2022-07-22T00:00:00+09:00</updated><id>https://techblog.thingsflow.com/tech/review/ArgoCD%EB%A5%BC%20%ED%99%9C%EC%9A%A9%ED%95%9C%20K8%20CICD%20pipeline%20%EA%B5%AC%EC%B6%95#2</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/review/ArgoCD%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-K8-CICD-pipeline-%EA%B5%AC%EC%B6%95-2/"><![CDATA[<h2 id="이야기에-앞서">이야기에 앞서</h2>
<p>이번 글에서 다룰 CICD 전체 구성도 입니다.<br />
특이한 점은 git repo를 두가지를 사용한다는점 입니다.
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/29.png" alt="image" /></p>

<ol>
  <li>어플리케이션 코드가 올라가 있는 <a href="https://github.com/iamchoiz/demo-app-gitaction-argocd">App repository</a>
    <ul>
      <li>Github Actions CI workflow 있음</li>
      <li>어플리케이션 코드</li>
      <li>빌드를 위한 DockerFile</li>
      <li>코드를 컨테이너로 빌드하여 ECR로 PUSH</li>
    </ul>
  </li>
  <li>어플리케이션 Manifest(k8 POD를 만들기 위한)가 존재하는 <a href="https://github.com/iamchoiz/k8-cicd-argocd">Config repository</a>
    <ul>
      <li>어플리케이션 Manifest : k8 Resource 배포를 위한 Kustomize 형식의 yaml 파일들입니다.</li>
      <li>app-of-apps</li>
      <li>argo-declarartive-setup-yaml (배포랑은 상관 없음)</li>
    </ul>
  </li>
</ol>

<h2 id="사용-기술">사용 기술</h2>
<p>이 글에서 다루게 될 기술과 주제에요.</p>
<ol>
  <li><del><strong>Container Orchestration</strong></del>
    <ul>
      <li><del>Why EKS ?</del></li>
      <li><del>Terraform을 활용한 EKS 구축</del></li>
      <li><del>EKS 주요 addOn 설치 자동화 및 서비스 통합</del></li>
    </ul>
  </li>
  <li><strong>어플리케이션 배포 자동화와 GitOps (CI/CD pipeline)</strong>
    <ul>
      <li>What’s GitOps</li>
      <li>GitHub Actions (CI)</li>
      <li>ArgoCD (CD)</li>
    </ul>
  </li>
</ol>

<h2 id="whats-gitops">What’s GitOps</h2>
<p>GitOps란 무엇일까요 ? 다들 DevOps는 많이 들어보셨을거에요. DevOps는 개발자와<br />
운영자의 협업, 통합, 자동화를 강조하는 개념입니다. GitOps는 DevOps를
실천하기 위한 방법 중 하나 입니다.</p>

<p>간단하게 설명 드리면 모든 <strong>선언적 코드</strong>로 되어 있는 파일을 git 으로 관리하고 이것을 pipeline을 통해 IT 운영에 활용하는 것을 의미 합니다. 
여기서 핵심은 <strong>선언적 코드</strong> 입니다. 선언적 코드로 되어 있어야 자동화가 가능하기 때문이에요. GitOps의 목적에서 자동화는<br />
빠질수 없는 요소 입니다.
오늘 다룰 ArgoCD는 이 GitOps와 아주 밀접한 관계가 있어요.</p>

<p><img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/10.png" alt="image" /></p>

<h3 id="github-actions-ci">GitHub Actions (CI)</h3>
<p>GitHub Actions는 GitHub에서 제공하는 CI(Continuous Integration, 지속 통합)와 CD(Continuous Deployment, 지속 배포) 서비스입니다.<br />
아래는 구성도와 사용 방법입니다.<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/28.png" alt="image" /></p>

<p><strong>Github Secret 등록</strong><br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/11.png" alt="image" /><br />
AWS ECR을 사용할거라 AWS ACCESS KEY가 필요해요. <a href="https://github.com/iamchoiz/demo-app-gitaction-argocd/blob/master/.github/workflows/k8_cicd%20copy.yml_back">실습 예제 링크</a></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">CI/CD</span>
<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span> <span class="nv">master</span> <span class="pi">]</span>
    <span class="na">paths-ignore</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.gitignore'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.dockerignore'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">README.md'</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span> <span class="nv">master</span> <span class="pi">]</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">ci</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">outputs</span><span class="pi">:</span>
      <span class="na">IMAGE_TAG</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v2</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">fetch-depth</span><span class="pi">:</span> <span class="m">1</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Configure AWS credentials</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">aws-actions/configure-aws-credentials@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">aws-access-key-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">aws-secret-access-key</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">aws-region</span><span class="pi">:</span> <span class="s">ap-northeast-2</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Login to Amazon ECR</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">login-ecr</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">aws-actions/amazon-ecr-login@v1</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set var</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">set-var</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">echo ::set-output name=ECR_REGISTRY::$</span>
          <span class="s">echo ::set-output name=ECR_REPOSITORY::demo</span>
          <span class="s">echo ::set-output name=IMAGE_TAG::$(cat VERSION)</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Docker image Build</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">build-image</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">docker build \</span>
            <span class="s">-f Dockerfile \</span>
            <span class="s">-t $/$:$ .</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Docker image Push</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">push-image</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">docker push $/$:$</span>
</code></pre></div></div>

<p><strong>CI Workflow</strong></p>
<ol>
  <li>개발자가 수정된 앱 git push</li>
  <li>Github Action (CI)
    <ul>
      <li>Configure AWS credentials (Secret에 미리 등록 된 AWS KEY 사용)</li>
      <li>ECR Login</li>
      <li>Set Value : ECR_REGISTRY, ECR_REPOSITORY, IMAGE_TAG(cat VERSION file)</li>
      <li>docker image build &amp; push
<br />
<br /></li>
    </ul>
  </li>
</ol>

<p><strong>검증</strong><br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/12.png" alt="image" /></p>

<hr />

<h3 id="argocd-cd">ArgoCD (CD)</h3>
<p>ArgoCD는 k8 전용 Continuous Deployment/Delivery Tool 입니다. ArgoCD는<br />
k8 Resource 상태를 정의하기 위한 정보를 Git 저장소로 사용 하는 GitOps 패턴을<br />
따르고 있어요. ArgoCD 공식 문서에도 나와 있듯이 GitOps 패턴을 사용하는 ArgoCD<br />
역시 반드시 선언적 언어를 사용하도록 되어 있습니다.</p>

<p>k8에서 매니페스트를 관리하는 방법은 여러 가지가 있는데 띵스플로우에서는 Kustomize를 사용 하고 있어요. 물론 ArgoCD에서 Kustomize를 지원합니다.
지금부터는 어플리케이션 배포 자동화를 위한 ArgoCD 구축 및 CD pipeline 구현 방법을 알아 보겠습니다.</p>

<p><strong>ArgoCD Architecture</strong><br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/15.png" alt="image" /></p>
<ol>
  <li>User Interface : Web, Command</li>
  <li>Server
    <ul>
      <li>argocd-server : 메인 API 서버</li>
      <li>repo-server : git repo 관리 (로컬 캐시, connection 등)</li>
      <li>app-controller : 배포된 APP 모니터링 및 Sync 확인</li>
      <li>redis</li>
      <li>dex-server : 보안, SSO등 Argo 설정 관리</li>
    </ul>
  </li>
  <li>ArgoCD Managed App</li>
</ol>

<p><br /></p>

<p><strong>ArgoCD 설치</strong><br />
ArgoCD는 Helm Chart를 사용해 설치할거에요. 편의상 MakeFile을 만들어 놨는데 Helm 실행 명령어가 들어 있습니다. local과 eks의 차이는 Ingress 생성 유무 입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/iamchoiz/k8-cicd-argocd.git
cd k8-cicd-argocd/argo-cd
make template-eks
make upgrade-eks

# URL : alb-endpoint
# ID : admin
# Pwd: kubectl get secret -n argocd argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
</code></pre></div></div>
<p><br />
<strong>로그인</strong><br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/13.png" alt="image" /><br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/14.png" alt="image" />
values-eks.yaml에서 Ingress 설치를 True로 하고, EKS를 구축 할때 aws_loadbalancer_controller를 설치 했기 때문에 자동으로 ALB가 생성됩니다. 이제 ALB의 엔드포인트 URL로 ArgoCD에 접속 할 수 있어요.</p>

<p><strong>SSO 설정 (GithHub)</strong><br />
dev-server를 활용해서 SSO 설정을 할 수도 있어요.<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/16.png" alt="image" /><br />
<br /></p>
<ul>
  <li>먼저 Github OAuth Application을 생성 합니다.<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/17.png" alt="image" /><br />
<br /></li>
  <li>Client Secrets 생성<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/18.png" alt="image" />
<br /></li>
  <li>helm chart value-eks.yaml 수정<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/19.png" alt="image" /><br />
Client Secrets 같은 경우 민감한 정보이기 때문에 실제 적용시에는 Secret으로 생성하는 것을 권장해요. ex) clientSecret:$생성한_SECRET_NAME:DATA_NAME<br />
helm upgrade argocd -n argocd -f values-eks.yaml .<br />
<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/20.png" alt="image" /><br />
정상적으로 적용되면 사진과 같이 <strong>‘LOG IN VIA GITHUB’</strong>이라는 버튼이 활성화<br />
됩니다. 추가로 Argocd 같은 경우 RBAC을 지원하기 때문 rbacConfig 설정을 통해
모든 계정에 대한 세부적인 권한 관리를 할 수 있어요.</li>
</ul>

<h3 id="argocd-declarative-setup">ArgoCD Declarative-Setup</h3>
<p>ArgoCD에서는 Web U/I와 CLI를 지원하고 있지만 이외에 Declarative-Setup를 통해서 리소스를 생성 할 수 있도록 지원하고 있어요. 아래 예제를 보시면 금방 이해가 될거에요.</p>

<ol>
  <li>git repository 등록 방법
    <ul>
      <li>Web UI
 <img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/21.png" alt="image" /><br />
 로그인 -&gt; 설정 -&gt; Repositories</li>
      <li>Helm Chart (values-eks.yaml)
 <img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/22.png" alt="image" /><br />
 helm upgrade argocd -n argocd -f values-eks.yaml .</li>
    </ul>
  </li>
  <li>Project 생성
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> cd argo-declarartive-setup-yaml
 # AppProject.yaml 수정 (Default : dev Project)
 kubectl apply -f AppProject.yaml
</code></pre></div>    </div>
    <p><img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/23.png" alt="image" /></p>
  </li>
  <li>Application 생성(argo-declarartive-setup-yaml/Application-helm.yaml)<br />
Application은 실제 배포 자동화 대상 Application입니다. 이부분은 조금 자세하게<br />
설명하고 넘어갈게요. 아래 사진은 Application 생성을 위한 코드 Manifest 입니다.<br />
핵심은 <strong>repoURL</strong>과 <strong>path</strong>인데, repoURL의 path에 있는 app Manifest를 읽어 ArgoCD Application을 생성한다 라고 이해하시면 좋을 것 같아요.
 <img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/24.png" alt="image" />
    <ul>
      <li>metadata.<strong>name</strong> : argocd의 Application Display name</li>
      <li>metadata.<strong>namespace</strong> : argocd가 설치된 Namespace 이름</li>
      <li>spec.destination.<strong>namespace</strong> : app이 배포될 Namespace 이름</li>
      <li>spec.destination.<strong>server</strong> : app이 배포될 k8 context URL</li>
      <li>spec.<strong>project</strong> : argocd project (이전에 생성한 Project 이름)</li>
      <li>spec.source.<strong>repoURL</strong> : app이 있는 git repo URL</li>
      <li>spec.source.<strong>path</strong> : app이 있는 git repo path</li>
      <li>spec.source.<strong>helm</strong> : Manifest가 Helm일 경우 values 이름 지정</li>
      <li>spec.syncPolicy.<strong>automated</strong> : Auto Sync 여부
        <blockquote>
          <p>💡 <strong>Continuous Delivery vs Continuous Deployment</strong><br />
 갑자기 둘의 차이를 언급한 것은 둘의 차이가 Auto Sync와 관계가 있기 때문이에요. CI/CD에서 CD는 Continuous Delivery <strong>또는</strong> Continuous Deployment를 의미 합니다. 둘의 차이는 사실 간단해요.
 모든 배포 과정을 자동으로 하면 Continuous Deployment이고 마지막 배포 과정만 수동으로 하게 되면 Continuous Delivery라고 합니다.<br />
 아래 사진이 이를 잘 설명해 줍니다.
  <img src="https://blog.crisp.se/wp-content/uploads/2013/02/continuous-delivery-deployment-sm.jpg" alt="image" /> <a href="https://blog.crisp.se/2013/02/05/yassalsundman/continuous-delivery-vs-continuous-deployment">Image source</a></p>
        </blockquote>
      </li>
    </ul>
  </li>
</ol>

<p>ArgoCD Application Controller는 git repo에 있는 Manifest 상태와 실제 k8에서 실행 중인 리소스 상태를 지속적으로 Sync가맞는지
비교 확인합니다. 이때 Auto Sync가<br />
활성화 되어 있으면 변화가 생겼을때 바로 Sync를 맞추는 작업을 진행하고 비활성화 되어<br />
있으면 OutOfSync 상태가 되고 수동으로 Sync를 맞출수 있게 해줍니다.</p>

<p><strong>Auto Sync</strong>
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/AUTO-SYNC.gif" alt="test-result" /></p>

<p><strong>Manual Sync</strong>
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/OUTOFSYNC.gif" alt="test-result" /></p>

<p>띵스플로우 같은 경우 현재 대부분의 서비스가 Continuous Delivery(Maunal Sync) 배포 전략을 사용하고 있고 일부 서비스만 Continuous Deployment(Auto Sync)를 사용하고 있어요.</p>

<p><img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/25.png" alt="image" /><br />
Declarative-Setup을 사용해서 간단하게 Application을 배포 했습니다. 만약에 수동으로 설정한다면 ArgoCD Web UI에서 한땀한땀 모든 항목을 입력해야 할 겁니다.<br />
그런데 요즘 MSA 아키택처가 유행하면서 굉장히 많은 서비스가 존재 할 수 있고 서비스 수가 계속 늘어날수가 있습니다. 그때마다 각각의 서비스들의 Manifest 파일을 관리하는 것도<br />
쉽지 않을거에요.<br />
그래서 ArgoCD에서는 App of Apps pattern을 통한 서비스 그룹 통합 관리 기능을 지원 합니다.</p>

<p><strong>App Of Apps</strong><br />
말그대로 App들의 App입니다. App 하나를 생성해서 전체 App들을 관리 할 수 있게 해주는 기능입니다. 실습 예제로 확인 해볼게요.</p>

<p>먼저 App of Apps Mainfest 부터 볼게요. app-of-apps.yaml 파일은 특별할게 없습니다.<br />
방금전에 사용한 Application-helm.yaml 파일과 동일한 형식으로 되어 있어요.<br />
path가 app-of-apps 폴더로 되어 있는데 app-of-apps 폴더가 핵심입니다.<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/26.png" alt="image" /></p>

<p>app-of-apps 폴더는 helm chart로 되어 있는데 단순히 repoURL과 app의 path만<br />
넣어주면 됩니다. helm template은 단순하게 입력된 value에 대한 loop에요. 궁금하신<br />
분들은 한번 보시면 좋을것 같습니다. 아까 실행했던 kustomize-app을 삭제하고 app-of-apps로 다시 배포 해보겠습니다.</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app-of-apps/values-eks.yaml</span>
<span class="na">repoURL</span><span class="pi">:</span> <span class="s1">'</span><span class="s">https://github.com/iamchoiz/k8-cicd-argocd.git'</span>
<span class="na">apps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">kustomize-app</span>
    <span class="na">project</span><span class="pi">:</span> <span class="s">default</span>
    <span class="na">namespace</span><span class="pi">:</span> <span class="s">default</span>
    <span class="na">source</span><span class="pi">:</span>
      <span class="na">path</span><span class="pi">:</span> <span class="s">kustomize_app</span>
      <span class="c1"># customValues: true</span>
      <span class="c1"># values:</span>
      <span class="c1">#   - values-eks.yaml</span>

<span class="s">$ k delete -f argo-declarartive-setup-yaml/Application-helm.yaml</span>
<span class="s">$ k apply -f argo-declarartive-setup-yaml/app-of-apps.yaml</span>
</code></pre></div></div>
<p><strong>검증</strong><br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/27.png" alt="image" /></p>

<p>app-of-apps 아래 kustomize-app이 생성된 걸 확인 할 수 있어요.<br />
아직 감이 안오시나요 ? 아래 영상을 보시면 이해가 쉬울거에요. 영상 시나리오는<br />
app-of-apps 최초 배포 이후에 서비스를 추가 배포하는 시나리오에요.</p>

<p><img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/appofapps.gif" alt="image" /></p>

<p>자 .. 이제 준비가 끝났습니다. 현재 두개의 git repo가 준비되었어요.</p>
<ol>
  <li>어플리케이션 코드가 올라가 있는 <a href="https://github.com/iamchoiz/demo-app-gitaction-argocd">App repository</a>
    <ul>
      <li>Github Actions CI workflow 있음</li>
      <li>어플리케이션 코드</li>
      <li>빌드를 위한 DockerFile</li>
      <li>코드를 컨테이너로 빌드하여 ECR로 PUSH</li>
    </ul>
  </li>
  <li>어플리케이션 Manifest(k8 POD를 만들기 위한)가 존재하는 <a href="https://github.com/iamchoiz/k8-cicd-argocd">Config repository</a>
    <ul>
      <li>어플리케이션 Manifest : k8 Resource 배포를 위한 Kustomize 형식의 yaml 파일들입니다.</li>
      <li>app-of-apps</li>
      <li>argo-declarartive-setup-yaml (배포랑은 상관 없음)</li>
    </ul>
  </li>
</ol>

<p>이제 git repo A의 GitHub Actions에서 빌드 후 git repo B의 컨테이너 이미지 버전을<br />
변경 해주는 로직이 들어가면 CI/CD pipeline 완성 됩니다.</p>

<p>아래와 같은 구성도가 완성 됩니다.<br />
<img src="/assets/images/2022-07-22-ArgoCD를_활용한_K8_CICD_pipeline_구축/29.png" alt="image" /> 
<strong>CI/CD Workflow</strong></p>
<ol>
  <li>개발자가 수정된 앱을 App repo에 push</li>
  <li>Github Action (CI)
    <ul>
      <li>Configure AWS credentials</li>
      <li>ECR Login</li>
      <li>docker image build &amp; ECR push</li>
      <li>app-of-apps가 바라보고 있는 Config repo의 k8 Manifest 수정</li>
    </ul>
  </li>
  <li>ECR PUSH</li>
  <li>Config repo의 k8 Manifest (Image Tag) 수정</li>
  <li>ArgoCD가 변경 사항 확인</li>
  <li>변경 사항 업데이트</li>
  <li>최신 이미지 PULL</li>
</ol>

<p><strong>GitHub Actions 추가 사항</strong><br />
마지막으로 구성도에 있는 4번 항목인 Config repository 수정을 위한 GitHub Actions<br />
수정이 필요해요. 간단하게 설명 드리면 Config repo를 clon해서 이미지 태그를 수정하고 수정된 사항을 Commit 하는 흐름 입니다.</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="na">cd</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">ci</span><span class="pi">]</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout Target Repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v2</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">repository</span><span class="pi">:</span> <span class="s">iamchoiz/k8-cicd-argocd</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">deploy-k8s</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Replace image tag in helm values (LOCAL)</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">mikefarah/yq@master</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">IMAGE_TAG</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">cmd</span><span class="pi">:</span> <span class="s">yq eval -i '.image.tag = env(IMAGE_TAG)' 'deploy-k8s/demo/values.yaml'</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Replace image tag in helm values (EKS)</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">mikefarah/yq@master</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">IMAGE_TAG</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">cmd</span><span class="pi">:</span> <span class="s">yq eval -i '.image.tag = env(IMAGE_TAG)' 'deploy-k8s/demo/values-eks.yaml'</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Push helm repo</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">token</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">IMAGE_TAG</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">cd deploy-k8s</span>
          <span class="s">git config --global user.email "dachan1207@gmail.com"</span>
          <span class="s">git config --global user.name "iamchoiz"</span>

          <span class="s">git add demo/values.yaml demo/values-eks.yaml;</span>
          <span class="s">git commit --message "Update demo image tag to $IMAGE_TAG";</span>
          <span class="s">git config -l | grep 'http\..*\.extraheader' | cut -d= -f1 | xargs -L1 git config --unset-all</span>
          <span class="s">git push --prune https://token:$token@github.com/iamchoiz/k8-cicd-argocd.git</span>
</code></pre></div></div>

<h2 id="마치며">마치며</h2>
<p>요즘은 고객 요구사항을 신속하게 파악해 서비스에 반영하거나 버그가 발견되면 해결 후 신속하게 서비스에 배포 하는 것이 굉장히 중요해졌는데요. 오늘 설명한 CI/CD 개념이 이에 대한 솔루션이라고 생각합니다. CI/CD pipeline을 통해서 운영 중 무중단 배포가 가능해지면서<br />
정기점검 이라는 문구를 보기 힘들어진 것 같아요.</p>

<p>저희가 EKS 마이그레이션 이후 우여곡절이 있었지만 현재는 안정화가 진행 되어서 좋은 효과를 보고 있고 신규로 오픈하는 서비스는 모두 EKS 환경에서 동작하도록 구현하고 있습니다. 사실 문제가 있는 서비스든 잘 동작하는 서비스든 인프라 환경 자체를 변경하는 것은 쉽지<br />
않는 결정이긴 하지만 제대로 검토해서 서비스에 득이 있다고 판단하면 도전 해보는 것이<br />
미래를 위한 좋은 방향이라고 생각합니다.</p>]]></content><author><name>dachan.choi</name></author><category term="tech" /><category term="review" /><category term="eks" /><category term="cicd" /><category term="terraform" /><category term="argocd" /><category term="GitOps" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">스플 웹 FE를 소개합니다.</title><link href="https://techblog.thingsflow.com/tech/%EC%8A%A4%ED%94%8C-%EC%9B%B9-FE%EB%A5%BC-%EC%86%8C%EA%B0%9C%ED%95%A9%EB%8B%88%EB%8B%A4/" rel="alternate" type="text/html" title="스플 웹 FE를 소개합니다." /><published>2022-07-04T00:00:00+09:00</published><updated>2022-07-04T12:00:00+09:00</updated><id>https://techblog.thingsflow.com/tech/%EC%8A%A4%ED%94%8C%20%EC%9B%B9%20FE%EB%A5%BC%20%EC%86%8C%EA%B0%9C%ED%95%A9%EB%8B%88%EB%8B%A4</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/%EC%8A%A4%ED%94%8C-%EC%9B%B9-FE%EB%A5%BC-%EC%86%8C%EA%B0%9C%ED%95%A9%EB%8B%88%EB%8B%A4/"><![CDATA[<p>스플 웹 서비스는 앱에 비해 쉬운 사용성으로 신규유저 진입의 허들을 낮추고, 웹의 손쉬운 배포를 통해 신규 서비스를 우선 런칭 하여 유저 반응을 분석하는데 사용하고 있습니다. 이 글에서는 스플 웹에서 사용하는기술, 배포 과정 등을 소개합니다.</p>

<h1 id="스플-웹-서비스는-어떤것이-있나요"><strong>스플 웹 서비스는 어떤것이 있나요?</strong></h1>
<p>스플 웹은 총 4개의 서비스가 있습니다.</p>

<h2 id="스플-웹-httpsstoryplaycom">스플 웹 (https://storyplay.com/)</h2>
<ul>
  <li>스토리플레이 웹 서비스</li>
  <li>현재(2022-07) 1.5 버전 배포 완료</li>
</ul>

<h2 id="스플-landing-httpsaboutstoryplaycom">스플 Landing (https://about.storyplay.com/)</h2>
<ul>
  <li>스플 소개 페이지</li>
  <li>현재(2022-07) 스플 도메인 변경 작업으로 사용불가 (재 배포 예정)</li>
</ul>

<h2 id="스플-관리자-페이지">스플 관리자 페이지</h2>
<ul>
  <li>스플 서비스 관리자 페이지</li>
</ul>

<h2 id="스플-스튜디오">스플 스튜디오</h2>
<ul>
  <li>스플 작품 작성을 위한 웹 서비스</li>
</ul>

<h1 id="스플-프론트앤드는-이런-기술로-만들어졌어요"><strong>스플 프론트앤드는 이런 기술로 만들어졌어요.</strong></h1>

<h3 id="react"><strong>React</strong></h3>
<ul>
  <li>react는 facebook에서 제공하는 자바스크립트 기반의 라이브러리, component 기반으로 쉽게 UI를 만들 수 있습니다. 웹 FE 프레임워크 사용율 에서도 큰 차이로 1위를 차지하고 있습니다.</li>
</ul>

<p><img src="/assets/images/2022-07-04-스플_웹_FE를_소개합니다/npm-trends.png" alt="image" />
* npm trands</p>

<h3 id="nextjs"><strong>Next.js</strong></h3>
<ul>
  <li>nextjs는 React로 만드는 SSR(서버사이드 렌더링) 프레임워크 입니다. SSR을 통해 스플 사용자에게 더 빠르게 웹을 보여주고, SPA의 SEO문제를 해결하여 검색엔진이 스플을 좀 더 쉽게 찾을수 있도록 합니다.</li>
</ul>

<h3 id="typescript"><strong>TypeScript</strong></h3>
<ul>
  <li>스플은 TypeScript를 사용하여 개발자의 실수를 줄이고 빌드 시간에 오류를 찾아내어 안정적이고 효율적으로 작업합니다. (JavaScript의 superset으로 JS와 100% 호환됩니다.)</li>
</ul>

<h3 id="emotioncss"><strong>Emotion(CSS)</strong></h3>
<ul>
  <li>스플은 Emotion을 사용하여 CSS-in-JS로 CSS를 작성합니다. CSS-in-JS는 class 명명이 편하고 css가 컴포넌트 단위로 추상화 되어 모듈간 의존성을 고려할 필요가 없고,  컴포넌트 상태값에 따라 CSS를 구성하기 편합니다. styled-component보다 상대적으로 파일 사이즈가 작고 SSR적용시 서버에 따로 작업이 필요없다는 장점도 있습니다.</li>
  <li>CSS Composition 방식을 기본으로 작성합니다.</li>
</ul>

<h3 id="apollo"><strong>Apollo</strong></h3>
<ul>
  <li>스플 서버는 GraphQL로 작성되어 있어 클라이언트가 선언한 데이터 구조를 그대로 서버에 요청해서 받아 사용할 수 있습니다. GraphQL 데이터 관리를 위해 GraphQL 기반 플랫폼인 Apollo를 도입했습니다. Apollo-client에서 지원하는 글로벌 저장소, 캐시 기능을 활용하여 서버 상태(state)를 쉽게 관리할 수 있습니다. 캐싱된 데이터를 클라이언트에서 바로 사용할 수 있어 클라이언트 상태관리 코드를 최소화 할 수 있습니다.</li>
  <li>Apollo-Client는 React, angular, vue.js, Android, iOS 등 다양한 환경에서 사용이 가능합니다.</li>
</ul>

<h3 id="redux-toolkit"><strong>Redux Toolkit</strong></h3>
<ul>
  <li>서버 데이터를 클라이언트에서 관리가 필요한 경우 RTK를 사용하여 관리합니다.
Redux의 단점(보일러플레이트 코드(action, reducer,…), 패키지 의존성(redux-thunk, immer,…))을 극복할 수 있고,  Slice를 통해 action, reducer 구조를 쉽게 작성할 수 있습니다.</li>
  <li>state를 공유하는 컴포넌트가 많지 않거나 state가 단순할 경우 Context를 사용하기도 합니다.</li>
</ul>

<h3 id="코드-컨벤션"><strong>코드 컨벤션</strong></h3>
<ul>
  <li>ThingsFlow JS 코드 컨벤션을 사용합니다.</li>
</ul>

<h3 id="ci--cd"><strong>CI / CD</strong></h3>
<ul>
  <li><strong>Github Action</strong></li>
  <li>Github에서 제공하는 workflow 자동화 tool로 스플 브랜치별 CI/CD 세팅을 하여 빌드 확인과 AWS배포 등 작업을 자동화하고, 결과를 Slack을 통해 전달받을 수 있도록 합니다.</li>
</ul>

<h3 id="serverless-nextjs"><strong>serverless next.js</strong></h3>
<ul>
  <li>Web 서비스 AWS 배포시 Serverless next,js(<a href="https://www.serverless.com/plugins/serverless-nextjs-plugin">link</a>) 를 사용하여 쉽고 빠르게 AWS 배포를 수행합니다.</li>
  <li>AWS 관련 정보는 인프라팀 담당자에게 전달받아 사용합니다.</li>
</ul>

<h1 id="git-flow"><strong>Git Flow</strong></h1>
<p>깃 전략 수립을 통해 개발, 배포, QA 상태를 확인하고 개발 상황을 쉽게 인식할 수 있습니다.</p>

<p>*스플 웹FE 개발 파트는 아래의 규칙에 따라 작업을 진행 합니다.</p>
<ol>
  <li>스프린트 계획 회의를 통해 jira에 스프린트를 생성합니다.</li>
  <li>생성된 스프린트에 작업할 Story를 생성하고, Story 구현을 위한 개발 Task를 생성합니다.</li>
  <li>feature branch는 “task-id + 작업이름” 의 형식으로 작성하여 jira 티켓과 github가 연동되도록 하고, 작업 상태는 jira에서 임의로 바꾸지 않고 github branch 상태에 맞춰 자동으로 jira에 업데이트 되도록 합니다.</li>
  <li>작업 완료된 feature는 pull request 요청 합니다. 모든 리뷰어의 ‘approval’ comment가 등록완료 되면 작업자가 직접 merge합니다.</li>
  <li>merge가 완료된 feature branch는 바로 삭제 합니다.</li>
</ol>

<p>Branch에는 항상 유지되는 (main, develop, contents develop, platform develop, dev deploy, prod deploy) branch와 작업 기간에만 유지되는 feature, hotfix, QA branch가 있습니다.</p>

<p><img src="/assets/images/2022-07-04-스플_웹_FE를_소개합니다/git-flow.png" alt="image" /></p>

<ul>
  <li><strong>main</strong> : 운영 서비스로 출시되는 branch, 검증이 완료된 코드만 merge되며 배포시 tag, release 정보를 상세하게 기록한다.</li>
  <li><strong>develop</strong> : 개발을 위한 branch로 contents develop, platform develop 각 팀에서 개발 완료된 코드가 merge 된다. 배포 테스트가 필요한 경우 dev deploy로 머지 하여 배포한다.</li>
  <li><strong>contents, platform develop</strong> : 컨텐츠, 플랫폼 팀 간의 작업 의존성을 줄이기 위해 각 팀별로 개발 브랜치를 운영한다. 다른팀 작업 완료된 사항을 develop 브랜치에서 pull받아 최신 개발 상태를 유지한다.</li>
  <li><strong>dev deploy</strong> : 개발 배포를 위한 브랜치, 코드 머지 시 개발 서버에 배포된다.</li>
  <li><strong>prod deploy</strong>: 운영 배포를 위한 브랜치, 코드 머지 시 운영 서버에 배포된다.</li>
  <li><strong>feature</strong> : Task 별 개발 브랜치, 개발 완료 후 contents, platform develop 으로 머지 후 브랜치 삭제</li>
  <li><strong>QA</strong> : 스프린트 개발 완료 후 QA 진행할때 생성, QA중 발생한 이슈 브랜치 생성 후 작업 완료되면 QA 브랜치에 머지한다. QA 종료 시 develop에 코드 머지하고 브랜치 삭제</li>
  <li><strong>hotfix</strong> : 운영 서비스에서 발생한 이슈에 대응하는 브랜치로 main 바로 작업 진행한다.</li>
</ul>

<h1 id="어떤걸-더-해볼수-있을까">어떤걸 더 해볼수 있을까?</h1>
<h3 id="ssr">SSR</h3>
<ul>
  <li>현재 앱과 동일 수준으로 개발이 완료되지 않았고 홈 리뉴얼 작업이 필요해 SSR이 모든 페이지에 구현되지는 않았습니다. 웹TF 종료 후 유저분들께 빠른 화면을 보여드릴 수 있도록 SSR 확대 적용을 검토하고 있습니다.</li>
</ul>

<h3 id="tdd">TDD</h3>
<ul>
  <li>보다 안정적인 스플 웹을 만들수 있도록 jest 를 사용하여 TDD도입을 고려하고 있습니다. TDD를 통해 개발단계에서 버그발견, 유지보수, 리팩터링 할 수 있어 개발에서 서비스 배포까지의 시간을 절약할 수 있고 안정적인 서비스를 운영할수 있을거라 예상합니다.</li>
</ul>

<h3 id="typescript-react-코드-컨벤션">Typescript, React 코드 컨벤션</h3>
<ul>
  <li>JS 기반으로 작성되어있는 코드 컨벤션을 TS, React 까지 확장해서 띵스플로우 웹 FE의 공통 컨벤션을 웹FE 개발자 분들과 함께 만들어 나가면 좋겠습니다.</li>
</ul>

<h1 id="마치며">마치며</h1>
<p>이상 스플웹 FE에 대해 간략하게 소개드렸습니다. 스플 웹 기술선정에 있어 안정성과 성능, 확장성을 우선으로 고려했고 선정한 기술은 웹FE 직무미팅을 통해 의견을 모아 확정했습니다. 스플 개발, 배포 과정에서 의도치 못한오류들을 만났지만 띵플러분들의 도움으로 무사히 개발완료 할 수 있었습니다. 현재 스플 유입 유저의 절반가까운 분들이 스플 웹으로 진입하고 있을 정도로 스플 웹이 좋은 결과를 보여주고 있습니다. 앞으로 스플 웹이 스플 서비스의 성공과 띵스플로우 웹FE 기술 발전에 큰 역할을 할 수 있기를 바라며 지금까지 긴 글 읽어주셔서 감사합니다.</p>]]></content><author><name>geonhee.lee</name></author><category term="tech" /><category term="react" /><category term="next.js" /><category term="tech spec" /><summary type="html"><![CDATA[---]]></summary></entry><entry><title type="html">리소스 청크 스플릿이 적용된 CRA 리액트 앱의 무중단 배포 방법</title><link href="https://techblog.thingsflow.com/tech/%EC%B2%AD%ED%81%AC%EC%8A%A4%ED%94%8C%EB%A6%BF%EC%9D%B4-%EC%A0%81%EC%9A%A9%EB%90%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-CSR%EC%95%B1%EC%9D%98-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EB%B0%A9%EB%B2%95/" rel="alternate" type="text/html" title="리소스 청크 스플릿이 적용된 CRA 리액트 앱의 무중단 배포 방법" /><published>2022-05-29T00:00:00+09:00</published><updated>2022-05-29T19:57:00+09:00</updated><id>https://techblog.thingsflow.com/tech/%EC%B2%AD%ED%81%AC%EC%8A%A4%ED%94%8C%EB%A6%BF%EC%9D%B4%20%EC%A0%81%EC%9A%A9%EB%90%9C%20%EB%A6%AC%EC%95%A1%ED%8A%B8%20CSR%EC%95%B1%EC%9D%98%20%EB%AC%B4%EC%A4%91%EB%8B%A8%20%EB%B0%B0%ED%8F%AC%20%EB%B0%A9%EB%B2%95</id><content type="html" xml:base="https://techblog.thingsflow.com/tech/%EC%B2%AD%ED%81%AC%EC%8A%A4%ED%94%8C%EB%A6%BF%EC%9D%B4-%EC%A0%81%EC%9A%A9%EB%90%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-CSR%EC%95%B1%EC%9D%98-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EB%B0%A9%EB%B2%95/"><![CDATA[<p>오늘은 클라이언트 사이드 리액트 앱의 효율적인 배포 방법에 대해서 다뤄보겠습니다.</p>

<h1 id="리액트-앱의-배포">리액트 앱의 배포</h1>
<p>클라이언트에서 렌더링되는 리액트 앱의 배포는 정말 단순합니다. 리액트 앱을 빌드하면 폴더 하나에 들어있는 정적 파일들로 바뀌며, 이 폴더의 내용물을 서버의 정적 서빙 폴더에 배치하고 원하는 경로에서 접근할 수 있도록 서버설정을 해 두면 배포는 끝납니다. 이런 과정은 사용하는 서버에 따라서 조금씩 다르고 복잡해질 수도 있지만 크게 어렵지는 않습니다.</p>

<h2 id="흔한-두-가지-배포-시나리오">흔한 두 가지 배포 시나리오</h2>
<ul>
  <li>특정 도메인의 일부 URL에 대해서만 리액트 앱이 동작</li>
  <li>도메인 전체에서 리액트 앱이 동작</li>
</ul>

<h2 id="cf와-s3의-활용">CF와 S3의 활용</h2>
<p>만약 특정 도메인 전체가 오직 CSR 리액트 앱만을 서빙해도 괜찮다면, 우리는 S3에 정적 파일을 올려두고 이 S3를 CF가 바라보게 함으로써 간단하게 리액트로 동작하는 웹사이트를 구성할 수 있습니다.</p>

<h2 id="cfs3로-정적-호스팅하는-리액트-앱의-장점과-구조">CF+S3로 정적 호스팅하는 리액트 앱의 장점과 구조</h2>
<p>CF와 S3로 리액트 앱을 배포하면 우리는 깃허브 액션 등으로 S3에 파일을 업로드하고 CF의 캐시무효화를 해서 새로 접근하는 사용자에게 새 앱이 즉시 전달되도록 할 수 있습니다. 이렇게 하면 새 앱을 배포하는 과정에서 서비스의 중단이 일어나지 않으므로 장점이 있다고 할 수 있습니다. 가장 단순하게 빌드된 CRA 리액트 앱을 S3에 넣는다면 S3의 루트 디렉토리에는 이런 파일들이 들어갑니다.</p>
<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/
├font/...
├locales/...
├static/
│├js/
││├main.f9ej4fi.chunk.js
││├787.a955b512.chunk.js
││├542.e92bde55.chunk.js
││└...
│├css/...
│└media/...
├assets-manifest.json
├favicon.ico
├index.html
├logo192.png
├logo512.png
├robots.txt
└manifast.json
</code></pre></div></div>
<p>/static/js 폴더에는 가장 중요한 js파일들이 들어가며, main.[hash].chunk.js는 index.html에 의해서 로드되며, 나머지는 webpack의 코드스플릿 기능으로 생성된 청크입니다.</p>

<h1 id="코드-스플릿이-적용된-앱의-배포">코드 스플릿이 적용된 앱의 배포</h1>
<p>그러나 코드 스플릿이 적용되어 있다면 서비스의 중단이 일어납니다. 여러분이 <a href="https://create-react-app.dev/">CRA</a>나 webpack같은 도구를 사용한다면 코드 스플릿을 적용하는 것은 어렵지 않을 것입니다. 코드 스플릿이 어떻게 배포 시에 서비스 중단을 일으킬 수 있는지 아래와 같은 예시를 통해서 알아봅시다.</p>

<h2 id="코드스플릿이-적용된-웹사이트의-서비스-중단-시나리오">코드스플릿이 적용된 웹사이트의 서비스 중단 시나리오</h2>
<p>웹사이트가 CF와 S3의 정적 호스팅으로 구성되어 모든 접근에 대해서 index.html파일을 돌려주고 해당 파일이 클라이언트 측에서 path에 따라 자바스크립트를 로드해서 페이지를 구성한다고 가정해봅시다. 코드스플릿은 path별로 이루어져 있다고 생각해봅시다. 아래와 같은 동작이 가능합니다.</p>
<ol>
  <li>사용자가 my-website.com/path1에 접근해서 해당 경로의 코드스플릿이 적용된 js와 정적 리소스를 불러와서 페이지를 봅니다.</li>
  <li>새 버전의 서비스가 배포되어 기존의 코드스플릿 기능이 불러올 파일들이 서비스의 S3와 CF에서 사라집니다.</li>
  <li>사용자가 웹페이지 내부의 링크를 눌러서 my-website.com/path2에 접근하려 하고, 이것은 페이지를 처음부터 불러오는 것이 아니기에 path2에 대한 구버전 서비스의 js chunk를 불러오는 시도로 이어집니다.</li>
  <li>파일이 없어서 브라우저 콘솔창에 에러가 뜨고 사용자 웹페이지도 망가집니다.</li>
</ol>

<h2 id="문제의-실험">문제의 실험</h2>
<p>CRA앱에서 이런 문제가 일어날 것이라고 생각하고 실제로 실험을 위해서 위의 예시처럼 구성된 코드를 S3에 배포하고 시험해 보아도 서비스 중단은 발생하지 않습니다.</p>

<p>이것은 코드스플릿으로 생성되는 js파일에 파일 내용을 기반으로 한 해시가 붙기 때문입니다. 이 파일이름이 충돌을 피하게 해주기 때문에 새 서비스가 배포되어도 기존의 js청크들이 사라지지 않는 것입니다.</p>

<p>그러나 이렇게 되어도 여전히 문제는 있습니다. static폴더에 들어있지 않는 다른 파일들은 public파일들이고 이것들은 웹팩 번들링의 대상이 아니어서 해시 등이 전혀 붙지 않기 때문에 서비스가 새로 배포될 때 여전히 덮어씌워집니다. 만약 구 서비스에 특정 이미지가 필요한데 이 이미지가 새 서비스에서 덮어씌워진다면 서비스의 내용이 달라질 수 있습니다.</p>

<h1 id="코드스플릿으로-인한-중단-배포의-해결책">코드스플릿으로 인한 중단 배포의 해결책</h1>
<p>새로운 서비스를 배포할 때 발생하는 기존 서비스의 중단 문제를 해결하기 위해서는 아래와 같은 일이 가능해야 합니다.</p>
<ol>
  <li>한 번 배포된 서비스는 고유한 경로를 가져서 절대로 자신의 파일을 잃어버리지 않습니다.</li>
  <li>유저가 접근할 index.html은 반드시 바로 갱신되어야 합니다.</li>
</ol>

<h2 id="cra의-public_path-속성의-활용">CRA의 PUBLIC_PATH 속성의 활용</h2>
<p>CRA의 PUBLIC_PATH 환경변수는 모든 정적파일이 서버의 특정 경로 아래에 있을 것이라고 명시하는 역할을 합니다. 또 서비스 배포의 파일 경로를 고유하게 하기 위해서 깃 커밋아이디를 사용해보도록 합시다. 이렇게 하기 위해서 Github Action을 아래와 같이 구성해 볼 수 있습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>name: release build

on:
  push:
    branches: [ release ]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3

    - name: Setup Yarn
      uses: actions/setup-node@v3
      with:
        cache: 'yarn'
    
    - name: Setup AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.RELEASE_AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.RELEASE_AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ secrets.RELEASE_AWS_REGION }}

    - name: Build
      env:
        CI: false
      run: |
        echo "PUBLIC_URL=/static/${{github.sha}}" &gt;&gt; .env.production.local
        yarn install
        yarn build
        
    - name: S3 Sync
      run: |
        aws s3 sync ./build/ s3://my-bucket/static/${{github.sha}}
        aws s3 cp ./build/index.html s3://my-bucket/index.html
      
    - name: CloudFront Cache Invalidation
      run: aws cloudfront create-invalidation --distribution-id ${{ secrets.RELEASE_DISTRIBUTION_ID }} --paths "/index.html"

</code></pre></div></div>

<p>위의 코드를 보면 CRA의 .env파일의 기능을 잘 활용하는 것을 볼 수 있습니다. bash 스크립트로 깃허브 액션이 실행되는 동안에만 쓰일 설정 파일을 생성하고, 생성 경로는 /static/{깃커밋아이디}가 됩니다. 이 최상단 /static폴더 안의 {깃커밋아이디} 폴더 안에는 다시 static폴더가 있을 수 있지만 이것은 CRA의 빌드 과정에서 웹팩 번들링이 작동하는 내용물이 담기는 폴더이므로 둘을 혼동하면 안 됩니다.</p>

<p>다음으로는 그렇게 생성한 ./build폴더의 내용물을 s3의 동일한 경로에 업로드하게 됩니다.</p>

<p>새로 만들어진 index.html은 새로 접속할 유저들이 즉시 새 서비스를 바라보게 하기 위해서 버킷의 최상단에 다시 복사합니다. 이렇게 하는 이유는 CF의 CLI API로는 버킷의 기본 인덱스파일의 위치를 바꿀 수가 없기 때문입니다.</p>

<p>마지막으로 CF의 캐시 무효화를 해 주는데, 기존 서비스의 파일들은 전혀 캐시를 무효화할 이유가 없기에 오직 최상위의 index.html만 날리도록 합니다.</p>

<h1 id="다른-문제들">다른 문제들</h1>
<p>위처럼 간단한 환경변수 수정을 Github Action에 적용하고 S3의 파일복사를 적절히 해 주는 것으로 완전한 서비스 무중단 배포가 가능해지지만 실제 서비스에서는 다른 문제가 생길 수 있습니다. 위의 디렉토리 예제를 보면 locales폴더가 있는 것을 알 수 있는데, 이것은 예제로 든 앱이 i18next-http-backend을 사용하는 react-i18next 기능을 구성했기 때문입니다. i18next-http-backend는 기본적으로 서버의 /locales경로에서 번역파일을 찾게 됩니다. 그래서 위에서 제시한 배포위치 수정을 적용하고 나면 더 이상 /경로에 /locales가 없기 때문에 번역이 적용되지 않는 문제가 생깁니다. 이 문제를 수정하기 위해 해당 패키지의 설정 기능을 참고해서 코드를 이렇게 구성합니다.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="nx">i18n</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">i18next</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">initReactI18next</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-i18next</span><span class="dl">'</span><span class="p">;</span>

<span class="k">import</span> <span class="nx">Backend</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">i18next-http-backend</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">LanguageDetector</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">i18next-browser-languagedetector</span><span class="dl">'</span><span class="p">;</span>

<span class="nx">i18n</span>
  <span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">Backend</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">LanguageDetector</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">initReactI18next</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">init</span><span class="p">({</span>
    <span class="na">fallbackLng</span><span class="p">:</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">debug</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">development</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">interpolation</span><span class="p">:</span> <span class="p">{</span>
      <span class="na">escapeValue</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
    <span class="p">},</span>
    <span class="na">backend</span><span class="p">:{</span>
      <span class="na">loadPath</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">PUBLIC_URL</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">/locales/{{lng}}/{{ns}}.json</span><span class="dl">'</span><span class="p">,</span>
    <span class="p">}</span>
  <span class="p">});</span>

<span class="k">export</span> <span class="k">default</span> <span class="nx">i18n</span><span class="p">;</span>
</code></pre></div></div>

<p>이렇게 하면 번역 파일도 이상없이 불러오게 됩니다. 핵심은 CRA의 PUBLIC_URL환경변수에 영향받지 않는 다른 기능들은 직접 조율할 필요가 있다는 것입니다.</p>

<h1 id="마치며">마치며</h1>
<p>지금까지 CRA를 사용하는 프로젝트에서 코드 스플릿 기능을 사용했을 때에도 문제없이 작동하는 무중단 배포 방법을 알아봤습니다. 사용하는 개발 환경에 따라서 작업방식은 달라질 수 있으나 핵심적인 아이디어는 동일하게 적용할 수 있을 것입니다.</p>

<p>이 글에서는 구버전 서비스의 무중단을 주목적으로 삼기에 이미 구 버전 서비스를 이용중인 사용자에게 신 버전의 사용을 권하거나 강제하는 기법에 대해서는 다루고 있지 않습니다. 사용자가 서비스의 최신버전을 자주 로드하는 대부분의 시나리오에서 그런 기능은 불필요할 것입니다. 그러나 필요하다면 정기적으로 버전 정보와 연관된 파일 혹은 데이터를 내려받아 내용을 확인하고 적절한 방식으로 페이지 재로드를 유발하는 코드 등을 작성할 수 있을 것입니다.</p>

<p>사용자에게 안정적인 서비스를 제공할 방법을 고민하는 분들에게 이 글이 도움이 되길 바라며 마칩니다.</p>]]></content><author><name>minsuk.jung</name></author><category term="tech" /><category term="deploy" /><category term="create-react-app" /><category term="react" /><summary type="html"><![CDATA[---]]></summary></entry></feed>