카테고리 없음

CI/CD 스터디 3주차 - Jenkins(대신 GitLab) + ArgoCD

시스템엔지니어 2025. 10. 31. 15:39

가시다님 및 운영진분들께서 운영하시는 클라우드닷넷의 이번주차 스터디는 Jenkins + ArgoCD로 스터디에서는 다음 구성으로 스터디를 진행하였다.

 

5년전쯤 CI도구로 Jenkins를 사용했었는데 플러그인 관리에 상당히 애를 먹었던 기억이 있다. 그 이후로 GitLab CI를 주로 사용하고 있다. 따라서 이번 실습에서 나는 현재 사용중인 GitLab CI를 활용해서 실무 환경을 소개하며 스터디를 진행했고, 구성도는 다음과 같다.

 

 

 

GitLab 파이프라인의 핵심 구성 요소

Pipeline (파이프라인)

  • 전체 CI/CD 프로세스를 의미
  • 여러 개의 Stage로 구성

Stage (스테이지)

  • 파이프라인의 논리적 단계
  • 순차적으로 실행됨
  • 예: build → test → deploy

Job (잡)

  • 실제로 실행되는 작업 단위
  • 각 Job은 특정 Stage에 속함
  • 같은 Stage의 Job들은 병렬로 실행

Runner (러너)

  • Job을 실행하는 에이전트
  • Shared Runner 또는 Specific Runner 사용 가능
  • Docker, Shell, Kubernetes 등 다양한 Executor 지원
 

gitlab-ci.yml 파일 구조

프로젝트 루트에 .gitlab-ci.yml 파일을 생성하면 자동으로 CI/CD가 활성화된다.

기본 구조

# Stage 정의
stages:
  - build
  - test
  - deploy

# Job 정의
job_name:
  stage: stage_name
  script:
    - command1
    - command2

최소 구성 예제

stages:
  - build

build-job:
  stage: build
  script:
    - echo "Hello, GitLab CI!"

핵심 문법 요소

Stages (스테이지)

파이프라인의 실행 순서를 정의한다.

stages:
  - build
  - test
  - deploy
  • 정의한 순서대로 순차 실행
  • 한 Stage의 모든 Job이 성공해야 다음 Stage로 진행
  • 정의하지 않으면 기본값: build, test, deploy

Jobs (잡)

 
job_name:
  stage: test
  script:
    - npm install
    - npm test


Job 이름 규칙

  • 영문자, 숫자, 하이픈(-), 언더스코어(_) 사용 가능
  • .으로 시작하면 숨겨진 Job (템플릿용)

Script (스크립트)

실행할 명령어를 정의한다.

 
job_name:
  script:
    - echo "Starting build"
    - docker build -t myapp .
    - docker push myapp:latest


멀티라인 스크립트

job_name:
  script:
    - |
      if [ "$CI_COMMIT_BRANCH" == "main" ]; then
        echo "Deploying to production"
      else
        echo "Deploying to staging"
      fi

Image (이미지)

Docker 이미지를 지정한다.

# 전역 이미지
image: node:18

# Job별 이미지
build-job:
  image: node:18
  script:
    - npm install

test-job:
  image: python:3.9
  script:
    - pytest

Before Script / After Script

# 모든 Job 전후에 실행
before_script:
  - echo "Setting up environment"

after_script:
  - echo "Cleaning up"

# Job별 설정
test-job:
  before_script:
    - npm install
  script:
    - npm test
  after_script:
    - rm -rf node_modules

Variables (변수)

# 전역 변수
variables:
  DATABASE_URL: "postgres://localhost/db"
  DEPLOY_ENV: "staging"

# Job별 변수
deploy-job:
  variables:
    DEPLOY_ENV: "production"
  script:
    - echo "Deploying to $DEPLOY_ENV"

GitLab 제공 기본 변수

script:
  - echo "Branch: $CI_COMMIT_BRANCH"
  - echo "Commit SHA: $CI_COMMIT_SHA"
  - echo "Project Name: $CI_PROJECT_NAME"
  - echo "Pipeline ID: $CI_PIPELINE_ID"

Only / Except (실행 조건)

# 특정 브랜치에서만 실행
deploy-production:
  stage: deploy
  script:
    - ./deploy.sh
  only:
    - main

# 특정 브랜치 제외
test-job:
  stage: test
  script:
    - npm test
  except:
    - main

Rules (고급 조건)

only/except보다 강력한 조건 설정이다.

deploy-job:
  stage: deploy
  script:
    - ./deploy.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: always
    - if: '$CI_COMMIT_BRANCH == "develop"'
      when: manual
    - when: never

Artifacts (아티팩트)

Job 간 파일을 공유하거나 다운로드 가능하게 한다.

build-job:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

deploy-job:
  stage: deploy
  script:
    - ls dist/  # build-job의 artifacts 사용 가능

Artifacts 옵션

artifacts:
  paths:
    - build/
    - dist/
  exclude:
    - "*.log"
  expire_in: 30 days
  when: on_success  # on_success, on_failure, always

Cache (캐시)

의존성 등을 캐싱하여 빌드 속도를 개선한다.

# 전역 캐시
cache:
  paths:
    - node_modules/
  key: $CI_COMMIT_REF_SLUG

# Job별 캐시
test-job:
  cache:
    key: npm-cache
    paths:
      - node_modules/
  script:
    - npm install
    - npm test

Cache vs Artifacts

  • Cache: 빌드 속도 향상 목적, Job 간 공유 불보장
  • Artifacts: Job 간 파일 전달, 다운로드 가능

Dependencies (의존성)

특정 Job의 artifacts만 사용한다.

build-app:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/

build-docs:
  stage: build
  script:
    - npm run docs
  artifacts:
    paths:
      - docs/

deploy:
  stage: deploy
  dependencies:
    - build-app  # build-app의 artifacts만 다운로드
  script:
    - deploy dist/

Services (서비스)

데이터베이스 등의 보조 컨테이너를 실행한다.

test-job:
  image: node:18
  services:
    - postgres:14
    - redis:latest
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: user
    POSTGRES_PASSWORD: password
  script:
    - npm test

When (실행 시점)

deploy-manual:
  stage: deploy
  script:
    - ./deploy.sh
  when: manual  # 수동 실행

cleanup-on-failure:
  stage: cleanup
  script:
    - ./cleanup.sh
  when: on_failure  # 실패 시에만 실행

when 옵션

  • on_success: 이전 Stage 성공 시 (기본값)
  • on_failure: 이전 Stage 실패 시
  • always: 항상 실행
  • manual: 수동 트리거
  • delayed: 지연 실행

 

stage, job을 활용한 기본 스크립트는 다음과 같다.

stages:
  - prepare
  - build
  - test
  - deploy

# ==================== PREPARE STAGE ====================
setup-environment:              # Job 이름: setup-environment
  stage: prepare                # 이 Job은 prepare Stage에 속함
  script:
    - echo "환경 설정 중..."

# ==================== BUILD STAGE ====================
build-frontend:                 # Job 이름: build-frontend
  stage: build                  # 이 Job은 build Stage에 속함
  script:
    - echo "프론트엔드 빌드 중..."

build-backend:                  # Job 이름: build-backend
  stage: build                  # 이 Job은 build Stage에 속함
  script:
    - echo "백엔드 빌드 중..."

build-docker-image:             # Job 이름: build-docker-image
  stage: build                  # 이 Job은 build Stage에 속함
  script:
    - echo "Docker 이미지 빌드 중..."

# ==================== TEST STAGE ====================
unit-test:                      # Job 이름: unit-test
  stage: test                   # 이 Job은 test Stage에 속함
  script:
    - echo "단위 테스트 실행 중..."

integration-test:               # Job 이름: integration-test
  stage: test                   # 이 Job은 test Stage에 속함
  script:
    - echo "통합 테스트 실행 중..."

security-scan:                  # Job 이름: security-scan
  stage: test                   # 이 Job은 test Stage에 속함
  script:
    - echo "보안 스캔 중..."

# ==================== DEPLOY STAGE ====================
deploy-staging:                 # Job 이름: deploy-staging
  stage: deploy                 # 이 Job은 deploy Stage에 속함
  script:
    - echo "스테이징 배포 중..."
  only:
    - develop

deploy-production:              # Job 이름: deploy-production
  stage: deploy                 # 이 Job은 deploy Stage에 속함
  script:
    - echo "프로덕션 배포 중..."
  when: manual
  only:
    - main
태그를 안넣어서 stuck 상태

 

 

GitLab Runner 파드외에 추가로 파이프라인용 파드가 실행되는것을 볼 수 있다.

 

파이프라인의 해당 스테이지별로 파드가 실행됨

 

그렇다면 컨테이너 이미지 저장소 및 Manifest는 어떻게 지정할까 ?

 

위와 같이 파이프라인 스크립트 내에 변수로 지정할 수 있다. 다만 이렇게 설정할 경우 변수가 유출 될 수 있다. 특히 프라이빗 컨테이너 저장소 자격증명 토큰은 절대 파이프라인에 직접 저장하면 안된다.

이때는 GitLab의 그룹 변수, 또는 프로젝트 변수를 활용할 수 있다.

 

 

자, 파이프라인을 활용하여 빌드를 진행해보자.

include:
  - project: 'devops/gitlab-ci-template'
    ref: main
    file:
      - 'ecr-build.yml'
      - 'manifest-update.yml'
      - 'slack-notification.yml'

stages:
  - build
  - update-manifest

variables:
  # ECR 레지스트리 URL
  DEV_ECR_REGISTRY_URL: "178522123123.dkr.ecr.ap-northeast-2.amazonaws.com/test-sabo"
  PROD_ECR_REGISTRY_URL: "178522123123.dkr.ecr.ap-northeast-2.amazonaws.com/live-test-sabo"
  
  # 매니페스트 리포지토리 & 파일 경로
  MANIFEST_REPO_URL: "https://gitlab.santander.co.kr/devops/k8s-manifest.git"
  DEV_MANIFEST_FILE_PATH: "dev/test-sabo/2-ro-test-sabo.yaml"
  PROD_MANIFEST_FILE_PATH: "live/test-sabo/2-ro-test-sabo.yaml"
  
  # Slack Webhook URL
  SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/T0C0TQGTE/123DBQND2EM/123h84123jyW123123AJXN0Me"

# ----------------------------------------------------------
# Build: dev → DEV_ECR_REGISTRY_URL / main → PROD_ECR_REGISTRY_URL
# + Build 실패 시 Slack 알림
# ----------------------------------------------------------
build-image:
  extends: .ecr_build_template
  rules:
    - if: '$CI_COMMIT_BRANCH == "dev"'
      variables:
        ECR_REGISTRY_URL: $DEV_ECR_REGISTRY_URL
        ENVIRONMENT: "dev"
    - if: '$CI_COMMIT_BRANCH == "main"'
      variables:
        ECR_REGISTRY_URL: $PROD_ECR_REGISTRY_URL
        ENVIRONMENT: "prod"
  after_script:
    - !reference [.slack_notification_build_failed, after_script]

# ----------------------------------------------------------
# Manifest: dev → DEV_MANIFEST_FILE_PATH / main → PROD_MANIFEST_FILE_PATH
# + 성공/실패 Slack 알림
# ----------------------------------------------------------
update-manifest-image:
  extends: .manifest_update_template
  rules:
    - if: '$CI_COMMIT_BRANCH == "dev"'
      variables:
        ENV_NAME: "dev"
        TARGET_MANIFEST_FILE: "$DEV_MANIFEST_FILE_PATH"
    - if: '$CI_COMMIT_BRANCH == "main"'
      variables:
        ENV_NAME: "prod"
        TARGET_MANIFEST_FILE: "$PROD_MANIFEST_FILE_PATH"
  after_script:
    - !reference [.slack_notification_deploy_success, after_script]
    - !reference [.slack_notification_manifest_failed, after_script]

 

빌드 구간 스크립트는 위와 같다. 근데 실제 빌드 명령어는 어디에도 없다.

이는 include로 다른 파이프라인을 참조시키고, 이를 extends와 refrence 기능으로 연결해줬기 때문이다. 중복적으로 사용되는 스크립트를 각각의 레포지토리마다 적어줄 필요 없이 이와같이 사용하면 좋다. 

extend: 해당 스크립트 통으로 사용시

reference: 해당 스크립트 내 일부 스테이지 사용시

 

자 그럼 실제 파이프라인의 빌드(include중 ecr-build) 구간을 살펴보자.

# ECR Docker 빌드 템플릿
# 사용법: .gitlab-ci.yml에서 include하여 사용

.ecr_build_template:
  stage: build
  image: moby/buildkit:master
  tags: [devops]
  variables:
    DOCKER_HOST: ""
    AWS_DEFAULT_REGION: "ap-northeast-2"
  before_script:
    - apk add --no-cache aws-cli docker-cli tzdata curl jq git
    - |
      set -e
      : "${ECR_REGISTRY_URL:?ECR_REGISTRY_URL not set}"
      : "${ENVIRONMENT:?ENVIRONMENT not set}"
      ECR_REGISTRY_HOST="$(echo "$ECR_REGISTRY_URL" | cut -d/ -f1)"
      aws ecr get-login-password --region "$AWS_DEFAULT_REGION" \
        | docker login --username AWS --password-stdin "$ECR_REGISTRY_HOST"
  script:
    - |
      set -e
      IMAGE_TAG="${ENVIRONMENT}-$(TZ="Asia/Seoul" date +'%Y%m%d-%H%M%S')"
      FULL_IMAGE_NAME="${ECR_REGISTRY_URL}:${IMAGE_TAG}"

      buildctl-daemonless.sh build \
        --frontend dockerfile.v0 \
        --local context=. \
        --local dockerfile=. \
        --opt build-arg:SYSTEM=aws \
        --opt build-arg:ENVIRONMENT=${ENVIRONMENT} \
        --output type=image,name=${FULL_IMAGE_NAME},push=true

      echo "FULL_IMAGE_NAME=${FULL_IMAGE_NAME}" > build.env
      echo "ENVIRONMENT=${ENVIRONMENT}" >> build.env
  artifacts:
    reports:
      dotenv: build.env

# dev 브랜치용 빌드
.ecr_build_dev:
  extends: .ecr_build_template
  rules:
    - if: '$CI_COMMIT_BRANCH == "dev"'
      variables:
        ECR_REGISTRY_URL: $DEV_ECR_REGISTRY_URL
        ENVIRONMENT: "dev"

# main/prod 브랜치용 빌드
.ecr_build_prod:
  extends: .ecr_build_template
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      variables:
        ECR_REGISTRY_URL: $PROD_ECR_REGISTRY_URL
        ENVIRONMENT: "prod"

 

주요 동작:

  1. 베이스 템플릿 (.ecr_build_template): BuildKit을 사용해 Docker 이미지를 빌드하고 ECR에 푸시
  2. 환경 설정: AWS CLI로 ECR 로그인 후, 환경별(dev/prod) 이미지 태그 자동 생성 (예: dev-20241031-143022)
  3. 브랜치별 분기: dev 브랜치는 개발 ECR로, main 브랜치는 프로덕션 ECR로 자동 배포
  4. 아티팩트 전달: 빌드된 이미지 정보를 build.env 파일로 저장해 다음 Job에서 사용 가능

 여기서 이미지 태그는 시간으로만 지정 돼 있는 상태인데, 깃랩(GitLab) 파이프라인에서 기본 제공되는 커밋 SHA 변수는 도커 이미지 태그로 가장 널리 사용되는값으로 대표적으로 사용되는 변수는 다음과 같다.

- CI_COMMIT_SHA : 현재 파이프라인을 실행한 전체 커밋 SHA(길이 40)
- CI_COMMIT_SHORT_SHA : 전체 SHA의 앞 8자리 등, 짧은 형태

이 변수들은 도커 이미지의 태그로 바로 사용할 수 있어, 소스와 이미지의 트레이싱 및 일관성 관리에 적합하다.

 

예시

variables:
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker build -t my-app:${IMAGE_TAG} .
    - docker push my-app:${IMAGE_TAG}

 

여튼, 이런 방식으로 이미지를 빌드하고, 컨테이너 레지스트리로 이미지를 푸시한다. 

위 플로우에서 2, 3번 단계가 진행됐다고 보면 된다.

자, 이제 빌드가 끝났으니 다음으로 쿠버네티스에 적용될 manifest 업데이트 구간(플로우의 4번)을 살펴보자

 

테스트 레포지토리의 내용은 앞서 build 구간처럼 include, extends를 사용하여 별다른 내용이 없다.

실제 manifest-update 스크립트의 내용은 다음과 같다.

 

# Manifest 업데이트 템플릿
# 사용법: .gitlab-ci.yml에서 include하여 사용

.manifest_update_template:
  stage: update-manifest
  image: alpine:3.18
  tags: [devops]
  before_script:
    - apk add --no-cache git curl jq
    - git config --global user.email "gitlab-ci@test-sabo.co.kr"
    - git config --global user.name "GitLab CI/CD"
  script:
    - |
      set -e
      : "${FULL_IMAGE_NAME:?FULL_IMAGE_NAME not provided from build step}"
      : "${TARGET_MANIFEST_FILE:?TARGET_MANIFEST_FILE not set}"
      : "${MANIFEST_REPO_URL:?MANIFEST_REPO_URL not set}"

      git clone "https://oauth2:${GIT_ACCESS_TOKEN}@${MANIFEST_REPO_URL#https://}"
      MANIFEST_DIR="$(basename "${MANIFEST_REPO_URL}" .git)"
      cd "${MANIFEST_DIR}"

      # Busybox sed 호환: 첫 번째 image: 라인만 교체(들여쓰기 보존)
      sed -i -e "1,/^[[:space:]]*image:[[:space:]]*/s#^\([[:space:]]*image:[[:space:]]*\).*#\1${FULL_IMAGE_NAME}#" "$TARGET_MANIFEST_FILE"

      git add "$TARGET_MANIFEST_FILE"
      git commit -m "Deploy: Update image to ${FULL_IMAGE_NAME} for ${CI_PROJECT_PATH}" || echo "No changes to commit, skipping push."
      git push origin main

      echo "Manifest update successful. ArgoCD will now sync the changes..."

# dev 브랜치용 Manifest 업데이트
.manifest_update_dev:
  extends: .manifest_update_template
  rules:
    - if: '$CI_COMMIT_BRANCH == "dev"'
      variables:
        ENV_NAME: "dev"
        TARGET_MANIFEST_FILE: "$DEV_MANIFEST_FILE_PATH"

# main/prod 브랜치용 Manifest 업데이트
.manifest_update_prod:
  extends: .manifest_update_template
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      variables:
        ENV_NAME: "prod"
        TARGET_MANIFEST_FILE: "$PROD_MANIFEST_FILE_PATH"

 

주요 동작:
1. 이전 Job 연계: 빌드 단계에서 생성된 FULL_IMAGE_NAME (이미지 정보)을 받아서 사용
2. Manifest 저장소 클론: 별도의 Git 저장소에서 Kubernetes 배포 매니페스트를 가져옴
3. 이미지 태그 교체: sed 명령어로 YAML 파일 내 image: 필드를 새 이미지로 자동 변경
4. 변경사항 커밋 & 푸시: 업데이트된 매니페스트를 Git 저장소에 자동 커밋
5. ArgoCD 연동: Manifest가 업데이트되면 ArgoCD가 자동으로 감지해 Kubernetes 클러스터에 배포
ps. 브랜치별 동작: dev 브랜치는 개발 매니페스트, main 브랜치는 프로덕션 매니페스트 업데이트

 

보는것처럼 Manifest 모음 레포지토리가 있고, 이를 파이프라인을 실행하기 위해 생성된 파드에 git clone 후 해당 이미지 태그 부분만 sed로 교체하고 git push 하는 방식이다.

 

실제 수정되는 rollout manifest는 다음과 같다.

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: test-sabo
  namespace: test-sabo
  labels:
    app: test-sabo
    version: v1
spec:
  replicas: 1
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: test-sabo
  template:
    metadata:
      labels:
        app: test-sabo
        version: v1
    spec:
      containers:
      - name: test-sabo
        image: 178512310749.dkr.ecr.ap-northeast-2.amazonaws.com/dev-test-sabo:dev-20251028-180217
        imagePullPolicy: Always
        ports:
        - containerPort: 9080

        env:
        - name: TZ
          value: Asia/Seoul
        - name: DB_TYPE
          value: "mysql"
        - name: MYSQL_DB_HOST
          valueFrom:
            secretKeyRef:
              name: mysql-auth
              key: MYSQL_DB_HOST
        - name: MYSQL_DB_PORT
          valueFrom:
            secretKeyRef:
              name: mysql-auth
              key: MYSQL_DB_PORT
        - name: MYSQL_DB_USER
          valueFrom:
            secretKeyRef:
              name: mysql-auth
              key: MYSQL_DB_USER
        - name: MYSQL_DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: mysql-auth
              key: MYSQL_DB_PASSWORD
        - name: MYSQL_DB_NAME
          valueFrom:
            secretKeyRef:
              name: mysql-auth
              key: MYSQL_DB_NAME

        resources:
          limits:
            memory: 2048Mi
            #cpu: 500m
          requests:
            cpu: 300m
            memory: 1024Mi
      restartPolicy: Always
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: role
                    operator: In
                    values:
                      - service
      tolerations:
        - key: "role"
          operator: "Equal"
          value: "service"
          effect: "NoSchedule"
  strategy:
    blueGreen:
      activeService: test-sabo-svc
      previewService: test-sabo-preview-svc
      autoPromotionEnabled: true

 

manifest 업데이트 스테이지가 실행되면(아래 플로우의 4번)

manifest가 업데이트 되고, 설치돼있던 argocd에서 manifest git 저장소가 변경될 때 sync되어 신규 서비스가 배포되는 방식(현재 구성에서는 블루/그린 배포)이다.

 

자 그럼 이제 argocd가 manifest를 sync하여 실제 쿠버네티스에 배포 될 수 있도록 아르고시디에서 프로젝트, 애플리케이션을 등록해보자.

 

레포지토리 등록

 

 

argocd를 통해 배포된 리소스들을 확인할 수 있다.

쿠버네티스에서 확인된 실제 배포 상태.

 

 

자, 이제 그러면 처음부터 끝까지, 즉 코드 푸시부터 배포까지 진행해보자. 이번에 진행할때는 앞서 구축한 파이프라인 외에 이미지 태그를 디비로 관리하는것까지 추가로 설명한다.

 

1. 코드 푸시

 

 

2. 파이프라인 실행

 

2-1) 파이프라인 실행 상세 내용

a) 보는것처럼 .pre 스테이지에서 사전 검증을 진행하고 build / deploy를 진행한다. build/deploy 방식은 앞서 설명한것과 동일하다.

b)아르고시디의 실제 배포된 화면이다.

배포전 이미지 태그이다.

 

 

파이프라인이 실행되면서 신규 파드가 배포되는 상태이다.

 

파드가 정상적으로 종료되고 신규 파드만 남은 상태이다.

변경된 이미지 태그

 

자 그럼 이미지 태그 관리는 어떻게 할까 ?

현재 시스템에서는 go로 짜여진 파이프라인용 허브 서비스가 있고 파이프라인 트리거를 파이프라인 허브 서비스를 통해 진행되고 있다.

 

플로우

 

 

services/common/version_controller.go - 버전 컨트롤 로직
ControlUpdateVersion() 함수로 major.minor.feature 형식의 시맨틱 버저닝 관리
업데이트 타입에 따라 버전 자동 증가

 

routes/interact.go - 버전 생성 및 적용
Build: ControlUpdateVersion()으로 버전 증가
Rebuild: 기존 버전에 -rebuild-YYMMDD-HHMM 형식 추가

services/query/write_build_info.go - 버전 정보 DB 저장
WriteBuildUpdateHistory(): 일반 빌드 버전 저장
WriteRebuildUpdateHistory(): 리빌드 버전 저장

 

실제 저장되는 태그 디비는 다음과 같다.