Back

[TIL] FE 인프라 가이드

2026. 03. 05.Yeji Kim
Platform

1. Docker - 왜 컨테이너?

"내 맥에선 되는데 서버에선 안 돼요." Node 버전이 다르거나, 시스템 라이브러리가 빠져있거나, .env가 다르거나. Docker는 이 문제를 컨테이너로 해결한다. 앱이 돌아가는 데 필요한 모든 것(코드, 런타임, 라이브러리, 환경변수)을 하나의 이미지로 패키징해서 어디서든 똑같이 실행한다.

컨테이너 격리가 뭐야?

가상머신(VM)은 OS 전체를 복제한다. Windows 위에 Ubuntu VM을 돌리면 커널부터 전부 새로 띄운다. 무겁다.

컨테이너는 호스트 OS의 커널을 공유하면서, 파일시스템·네트워크·프로세스만 격리한다. VM보다 훨씬 가볍고 빠르게 뜬다.

text
VM:        [App] → [Guest OS] → [Hypervisor] → [Host OS]
Container: [App] → [Container Runtime]        → [Host OS]

컨테이너는 초 단위로 뜨고, 이미지 크기도 수십 MB 수준이라 CI/CD에서 빠르게 빌드·배포할 수 있다.

Dockerfile - Next.js 앱 기준

dockerfile
# 1단계: 빌드 환경
FROM node:20-alpine AS builder
 
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
 
# 2단계: 실행 환경
FROM node:20-alpine AS runner
 
WORKDIR /app
ENV NODE_ENV=production
 
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
 
EXPOSE 3000
CMD ["node", "server.js"]

이게 바로 Multi-stage build다. 빌드에 필요한 dev dependencies(250MB+)는 builder 스테이지에만 존재하고, 최종 이미지에는 런타임에 필요한 것만 복사한다.

아래 시각화에서 Single-stage vs Multi-stage의 차이를 직접 확인해보자.

Base Image
180 MB
Dependencies
250 MB
Source Code
15 MB
Build Output
45 MB
Dev Dependencies
200 MB
Final Image Size~690 MB

* 레이어에 마우스를 올려 Dockerfile 명령어와 캐시 상태를 확인하세요

Single-stage는 빌드 도구, dev dependencies, 소스 코드가 전부 최종 이미지에 남아서 ~690MB다. Multi-stage로 바꾸면 builder 스테이지의 레이어는 버려지고(discarded) 런타임에 필요한 것만 남아서 ~175MB로 줄어든다. Rebuild 버튼을 눌러보면 cached 레이어는 그대로 재사용되고, 소스 코드처럼 변경된 레이어만 다시 빌드되는 것도 확인할 수 있다.

이미지 레이어와 캐시

Docker 이미지는 레이어(layer) 로 구성된다. FROM, COPY, RUN 같은 명령어 하나하나가 레이어를 만들고, 변경되지 않은 레이어는 캐시에서 재사용한다.

그래서 package.json을 먼저 복사하고 npm ci를 실행한 뒤, 소스 코드를 복사하는 순서가 중요하다. 소스 코드만 바뀌면 dependencies 레이어는 캐시를 쓰니까.

dockerfile
# (GOOD!) dependencies가 캐시됨
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
 
# (BAD!) 소스 바뀔 때마다 npm ci 다시 실행
COPY . .
RUN npm ci
포트 바인딩 (-p 3000:3000)

컨테이너는 자체 네트워크를 갖고 있어서, 내부 포트가 자동으로 외부에 노출되지 않는다.

docker run -p 3000:3000 my-app에서 -p 플래그가 하는 일:

  • 왼쪽 3000: 호스트(내 맥)의 포트
  • 오른쪽 3000: 컨테이너 내부 포트

-p 8080:3000이면 내 브라우저에서 localhost:8080으로 접속하면 컨테이너의 3000번 포트로 연결된다.

컨테이너 레지스트리

Docker 이미지를 저장하고 공유하는 저장소다. npm에 패키지를 올리듯, 이미지를 레지스트리에 push하고 서버에서 pull한다.

  • Docker Hub: 가장 유명한 공개 레지스트리. node:20-alpine 같은 공식 이미지가 여기 있다.
  • GitHub Container Registry (ghcr.io): GitHub 계정으로 이미지 관리. GitHub Actions와 연동이 편함.
  • AWS ECR / GCP Artifact Registry: 클라우드 제공자의 프라이빗 레지스트리.
bash
# 이미지 빌드 → 태그 → 푸시
docker build -t ghcr.io/my-org/my-app:v1.0 .
docker push ghcr.io/my-org/my-app:v1.0

Docker Compose로 로컬 개발환경

프론트엔드 + API + DB를 한번에 띄워야 할 때 Docker Compose가 유용하다. Docker Compose는 여러 컨테이너를 하나의 YAML 파일에 정의하고, 한 명령어로 일괄 실행·중지할 수 있게 해주는 도구다. 컨테이너를 하나하나 docker run으로 띄우는 대신, docker-compose.yml에 전체 구성을 적어두면 된다.

docker-compose.yml
yaml
services:
  frontend:
    build: .                        # 현재 디렉토리의 Dockerfile로 이미지 빌드
    ports:
      - "3000:3000"
    environment:
      - API_URL=http://api:4000     # 같은 Compose 네트워크 안에서 서비스 이름으로 접근
    depends_on:
      - api                         # api 서비스가 먼저 시작된 후 실행
 
  api:
    image: my-org/api:latest
    ports:
      - "4000:4000"
    environment:
      - DATABASE_URL=postgres://db:5432/mydb
    depends_on:
      - db
 
  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=mydb
      - POSTGRES_PASSWORD=localdev
    volumes:
      - db-data:/var/lib/postgresql/data   # 컨테이너가 삭제되어도 DB 데이터 유지
 
volumes:
  db-data:

docker compose up을 실행하면 Compose가 이 파일을 읽고, 정의된 서비스(frontend, api, db)의 컨테이너를 의존 순서에 맞춰 한번에 생성하고 시작한다. 내부적으로 전용 네트워크도 자동 생성해서 서비스 이름(api, db)만으로 컨테이너 간 통신이 가능하다. 같은 docker-compose.yml을 쓰는 팀원 전원이 동일한 환경에서 개발할 수 있다.


2. Kubernetes - Docker만으로 왜 안 되나?

Docker로 컨테이너를 만들었다. 그런데 프로덕션에선 이런 문제들이 생긴다.

  • 레플리카: 트래픽이 몰리면 컨테이너를 여러 개 띄워야 한다. docker run을 3번 치고 앞에 로드밸런서(트래픽을 여러 서버에 분산해주는 장치)를 수동으로 붙여야?
  • 자동 복구: 컨테이너가 죽으면 누가 다시 띄워주지?
  • 롤링 업데이트: 새 버전 배포할 때 한꺼번에 내리면 서비스 중단. 하나씩 교체해야 한다.
  • 설정 관리: 환경변수, 시크릿, 볼륨을 서버마다 수동 관리?

이걸 사람이 하면 실수가 나고, 스크립트로 하면 복잡해진다. Kubernetes(K8s) 는 이 모든 걸 자동화하는 컨테이너 오케스트레이션 플랫폼이다.

선언적(Declarative) vs 명령적(Imperative)

쿠버네티스의 핵심 철학은 선언적 관리다.

명령적: "컨테이너 3개 띄워. 하나 죽으면 다시 띄워. 새 버전 나오면 하나씩 교체해."

선언적: "항상 컨테이너 3개가 떠 있어야 해. 이미지는 v1.2.0."

선언적으로 원하는 상태를 YAML에 적으면, K8s가 현재 상태와 비교해서 알아서 맞춰준다. 컨테이너가 죽어도 자동 복구, 이미지를 바꾸면 자동 롤링 업데이트한다.

React와 비슷하게 느껴질 수도 있겠다. 우리가 setState({ count: 3 })을 하면 React가 DOM을 알아서 업데이트하듯, K8s도 원하는 상태만 선언하면 클러스터를 알아서 조율한다.

핵심 리소스 - Pod, Deployment, Service, Namespace

K8s에서 리소스(Resource) 란 클러스터 안에서 관리되는 객체를 말한다. Pod, Deployment, Service 같은 것들이 전부 리소스다. YAML 파일로 원하는 리소스의 상태를 선언하면 K8s가 그 상태를 만들고 유지해준다.

아래 인터랙티브 다이어그램에서 각 리소스를 클릭해 보자. 중첩 구조가 핵심이다.

Namespace
Deployment
ReplicaSet
Pod
Pod
Pod
Service
selector: app=frontendPod × 3

* 리소스를 클릭하면 YAML 스니펫과 설명을 볼 수 있어요

파드 네트워킹

쿠버네티스의 네트워크 모델은 두 가지를 보장한다.

  1. 모든 Pod은 고유 IP를 갖는다. 컨테이너가 아니라 Pod 단위로 IP가 할당된다.
  2. 플랫 네트워크: 모든 Pod은 NAT(네트워크 주소 변환) 없이 서로의 IP로 직접 통신할 수 있다.

비유하자면: Pod IP는 직원번호, Service IP는 회사 대표번호다. 직원(Pod)이 바뀌어도 대표번호(Service)는 그대로다. (세부적인 차이는 넘어가는 걸로..)

서비스 디스커버리

Pod은 죽고 다시 생길 때마다 IP가 바뀐다. 그럼 다른 서비스가 이 Pod에 접근하려면 어떻게 IP를 알지?

Service가 해결한다. frontend-svc라는 Service를 만들면:

  • ClusterIP: 클러스터 내부에서만 접근 가능한 고정 IP가 부여된다.
  • DNS: frontend-svc.my-app.svc.cluster.local이라는 DNS 이름이 자동 생성된다. 같은 네임스페이스면 frontend-svc만으로도 접근 가능하다.
bash
# 같은 네임스페이스에서
curl http://frontend-svc:80
 
# 다른 네임스페이스에서
curl http://frontend-svc.my-app:80
라벨과 셀렉터

K8s에서 리소스를 연결하는 핵심 메커니즘이다.

라벨(Label): 리소스에 붙이는 키-값 태그. app: frontend, env: prod 같은 것.

셀렉터(Selector): 라벨을 기준으로 리소스를 찾는다.

Service가 어떤 Pod으로 트래픽을 보낼지 결정할 때 셀렉터를 쓴다.

yaml
# Service의 selector
selector:
  app: frontend    # "app=frontend" 라벨이 붙은 Pod을 찾아라
 
# Pod의 labels
metadata:
  labels:
    app: frontend  # 이 Pod이 선택됨

CSS 셀렉터가 DOM 요소를 선택하듯, K8s 셀렉터가 리소스를 선택한다.

살펴보자 YAML - Deployment + Service

k8s/deployment.yaml
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  namespace: my-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      containers:
        - name: frontend
          image: ghcr.io/my-org/my-app:v1.2.0
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: frontend-svc
  namespace: my-app
spec:
  type: ClusterIP
  selector:
    app: frontend
  ports:
    - port: 80
      targetPort: 3000
resources를 꼭 설정하자

resources.requestslimits를 안 걸면, Pod 하나가 노드의 리소스를 전부 먹을 수 있다. 다른 Pod까지 영향 받는다. 프론트엔드 Next.js 앱 기준 cpu: 100m~500m, memory: 128Mi~512Mi 정도가 적당한 시작점이다.

HPA(HorizontalPodAutoscaler) - Auto Scaling

트래픽에 따라 Pod 수를 자동으로 조절하는 리소스다.

k8s/hpa.yaml
yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: frontend-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: frontend
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

CPU 사용률이 70%를 넘으면 Pod를 늘리고, 내려가면 줄인다. "항상 3개"가 아니라, "2~10개, CPU 70% 기준으로 알아서"가 되는 것이다.


3. ArgoCD + GitOps

GitOps란?

Git 저장소를 Single Source of Truth(SSOT) 로 쓰는 배포 전략이다. 클러스터의 모든 설정이 Git에 있고, 배포는 Git에 push하는 것만으로 이루어진다.

핵심 원칙

  1. Git = 진실의 원천: 클러스터의 원하는 상태가 Git에 선언되어 있다.
  2. Pull 기반 배포: CI에서 kubectl apply로 직접 배포하는 게 아니라, ArgoCD가 Git을 감시하다가 변경을 감지하면 자동으로 싱크한다.
  3. 감사 추적: 모든 배포 이력이 Git 커밋 히스토리에 남는다. 누가, 언제, 뭘 바꿨는지 투명하다.
git push
GitHub Actions
📦
Docker Build
Registry Push
🔍
ArgoCD Detect
K8s Sync

* Deploy 버튼을 눌러 git push → 프로덕션 배포 과정을 확인하세요

ArgoCD Sync 루프

ArgoCD는 기본 3분 주기로 Git 저장소를 확인한다.

  1. Compare: Git에 있는 원하는 상태 vs 클러스터의 현재 상태를 비교
  2. OutOfSync 감지: 차이가 있으면 OutOfSync 상태로 표시
  3. Sync: 자동 또는 수동으로 클러스터를 Git 상태에 맞춤
  4. Health Check: 새로 배포된 리소스가 정상인지 확인
argocd/application.yaml
yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: frontend
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/my-org/k8s-manifests
    targetRevision: main
    path: apps/frontend
  destination:
    server: https://kubernetes.default.svc
    namespace: my-app
  syncPolicy:
    automated:
      prune: true       # Git에서 삭제한 리소스는 클러스터에서도 삭제
      selfHeal: true     # 수동 변경을 Git 상태로 되돌림
    syncOptions:
      - CreateNamespace=true

App of Apps - 대규모 클러스터 관리

애플리케이션이 10개, 20개로 늘어나면 ArgoCD Application도 그만큼 만들어야 한다. App of Apps 패턴은 이 문제를 해결한다. Application을 관리하는 Application을 만드는 것이다.

text
root-app (Application)
├── frontend (Application)
├── api (Application)
├── monitoring (Application)
└── infra (Application)
    ├── envoy-gateway
    ├── cert-manager
    └── argocd
Sync Wave 내부 동작

App of Apps에서 순서가 중요하다. cert-manager가 먼저 설치되어야 TLS 인증서를 발급할 수 있고, 인증서가 있어야 Gateway가 HTTPS를 서빙할 수 있다.

Sync Wave는 이 순서를 제어한다.

yaml
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "1"  # 숫자가 낮을수록 먼저 실행

Wave 순서 예시:

  • Wave 0: Namespace, CRD(Custom Resource Definition, 사용자 정의 리소스) 정의
  • Wave 1: cert-manager, external-secrets
  • Wave 2: envoy-gateway, monitoring
  • Wave 3: 앱 배포 (frontend, api)

각 Wave가 Healthy 상태가 되어야 다음 Wave가 시작된다.

Envoy Gateway로의 마이그레이션에 대한 자세한 내용은 Ingress Nginx → Envoy Gateway, 왜 그리고 어떻게를 참고하자.


4. GitHub Actions + Self-Hosted Runner

GitHub Actions 기초

GitHub Actions는 GitHub에 내장된 CI/CD 플랫폼이다. 코드를 push하면 자동으로 테스트하고 빌드하고 배포한다.

세 단위로 구성된다.

  • Workflow: .github/workflows/*.yml 파일. 전체 자동화 흐름
  • Job: Workflow 안의 실행 단위. 병렬 또는 순차 실행 가능
  • Step: Job 안의 개별 명령어
.github/workflows/ci.yml
yaml
name: CI/CD
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
 
      - run: npm ci
      - run: npm run lint
      - run: npm run test
 
  build-and-push:
    needs: lint-and-test    # lint-and-test가 성공해야 실행
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Self-Hosted Runner: 왜, 언제 쓰나

GitHub이 제공하는 runner(ubuntu-latest 등)는 편하지만 한계가 있다.

  • 비용: Private repo에서 무료 시간을 초과하면 분당 과금
  • 성능: 2 vCPU, 7GB RAM이 기본. 큰 프로젝트 빌드가 느림
  • 네트워크: 사내 클러스터나 프라이빗 서비스에 접근 불가

Self-hosted runner는 직접 관리하는 머신에서 Actions를 실행한다.

GitHub-hosted vs Self-hosted
GitHub-hostedSelf-hosted
관리GitHub이 관리직접 관리
비용분당 과금 (Public은 무료)인프라 비용만
성능2 vCPU / 7 GB원하는 만큼
네트워크공인 인터넷만사내망 접근 가능
보안격리된 VM직접 보안 설정 필요
캐시Actions Cache (10 GB)로컬 디스크 (빠름)

개인 프로젝트는 GitHub-hosted로 충분하고, 회사 프로젝트에서 빌드가 느리거나 사내망 접근이 필요하면 Self-hosted를 고려할 만하다.

ARC - Actions Runner Controller on K8s

이미 K8s 클러스터가 있다면, runner도 K8s 위에서 돌리는 게 자연스럽다. ARC(Actions Runner Controller) 는 GitHub이 공식 관리하는 컨트롤러로, Actions Job이 들어오면 Pod를 띄우고, 끝나면 정리한다.

arc/runner-scale-set.yaml
yaml
apiVersion: actions.github.com/v1alpha1
kind: AutoscalingRunnerSet
metadata:
  name: frontend-runners
spec:
  githubConfigUrl: "https://github.com/my-org/my-app"
  minRunners: 1
  maxRunners: 5
  template:
    spec:
      containers:
        - name: runner
          image: ghcr.io/actions/actions-runner:latest
          resources:
            requests:
              cpu: 500m
              memory: 1Gi

Workflow에서 runs-on: frontend-runners로 지정하면 이 runner set에서 실행된다.

캐싱 전략

빌드 속도를 올리는 가장 효과적인 방법이다.

1. npm 캐시

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm          # package-lock.json 기반 자동 캐시

2. Docker 레이어 캐시

yaml
- uses: docker/build-push-action@v5
  with:
    cache-from: type=gha       # GitHub Actions 캐시에서 레이어 로드
    cache-to: type=gha,mode=max  # 모든 레이어를 캐시에 저장

3. Next.js 빌드 캐시

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
    restore-keys: |
      nextjs-${{ hashFiles('**/package-lock.json') }}-
캐시가 깨지는 흔한 실수
  • package-lock.json이 아니라 package.json으로 캐시 키를 만들면, devDependencies 버전이 바뀌어도 캐시가 안 갱신된다.
  • Docker 빌드에서 .dockerignore.next/cache를 넣으면 빌드 캐시가 날아간다.
  • npm cinode_modules를 전부 지우고 다시 설치한다. 로컬에서 npm install을 쓰던 습관으로 CI에서도 그러면 캐시 이점이 줄어든다.

5. Monitoring - Prometheus + Grafana

왜 모니터링?

장애를 사용자보다 먼저 알아야 한다.

프론트엔드 개발자가 만든 앱이 프로덕션에서 느려지고 있다면? 사용자가 Slack에 "사이트가 안 돼요"라고 쓰기 전에 우리가 먼저 알아야 한다. 모니터링은 그 먼저를 가능하게 한다.

메트릭 vs 로그 vs 트레이스

관측성(Observability)의 세 기둥이 있다.

메트릭(Metric): 숫자로 된 측정값이다. 현재 CPU 사용률 72%, 초당 요청 수 150 같은 것. 시계열(time-series) 데이터로 저장되어 추이를 본다.

로그(Log): 이벤트의 텍스트 기록이다. 2026-03-05 10:23:45 ERROR: DB connection timeout 같은 것. 구체적인 디버깅에 쓴다.

트레이스(Trace): 하나의 요청이 여러 서비스를 거치는 경로를 추적한다. API 호출이 프론트→API→DB를 거치면서 어디서 500ms가 걸렸는지 파악할 때 쓴다.

프론트엔드 기준 예시를 들면

  • 메트릭: API 응답 시간이 평균 200ms → 1s로 느려졌다 → 대시보드에서 발견
  • 로그: 왜 느려졌지? → 로그에서 DB timeout 에러 발견
  • 트레이스: 어느 구간이 병목이지? → API→DB 구간에서 800ms 소모 확인
Pull vs Push 모델

메트릭 수집에는 두 가지 방식이 있다.

Push 모델: 애플리케이션이 메트릭을 모니터링 서버로 보낸다. DataDog, New Relic이 이 방식.

Pull 모델: 모니터링 서버가 주기적으로 애플리케이션에서 메트릭을 가져간다. Prometheus가 이 방식.

Prometheus가 Pull을 쓰는 이유는 명확하다.

  • 모니터링 대상이 죽으면 메트릭이 안 들어온다는 것 자체가 장애 신호
  • 중앙에서 수집 주기를 제어할 수 있음
  • 서비스가 자기 메트릭을 /metrics 엔드포인트로 노출하면 끝

Prometheus 아키텍처

text
[App /metrics] ←── scrape ──── [Prometheus Server]
[Node Exporter] ←── scrape ──┘        │
[kube-state-metrics] ←── scrape ──┘   │
                                       ├── PromQL 쿼리
                                       ├── AlertManager → Slack/PagerDuty
                                       └── Grafana 대시보드
  • Exporter: 메트릭을 Prometheus 형식으로 노출하는 컴포넌트
  • AlertManager: 조건에 맞으면 알림을 보냄
  • Grafana: 메트릭을 시각화하는 대시보드 도구

PromQL 기초 - 실전 쿼리 3개

1. API 응답 시간 p95

promql
histogram_quantile(0.95,
  rate(http_request_duration_seconds_bucket{service="frontend"}[5m])
)

지난 5분간 frontend 서비스의 95번째 백분위수 응답 시간 - 100명 중 95명은 이 시간 안에 응답을 받았다는 뜻이다.

2. 에러율

promql
sum(rate(http_requests_total{service="frontend", status=~"5.."}[5m]))
/
sum(rate(http_requests_total{service="frontend"}[5m]))
* 100

5분간 전체 요청 중 5xx 에러의 비율(%) - 이 값이 1%를 넘으면 알림을 거는 게 일반적이다.

3. 파드 메모리 사용률

promql
container_memory_working_set_bytes{namespace="my-app", container="frontend"}
/
container_spec_memory_limit_bytes{namespace="my-app", container="frontend"}
* 100

메모리 limit 대비 실제 사용률이다. 80%를 넘으면 OOM Kill(Out Of Memory Kill, 메모리 초과 시 커널이 프로세스를 강제 종료하는 것) 위험이 있다.

프론트엔드에서 봐야 할 메트릭

메트릭왜 중요한가임계값 예시
응답 시간 (p95)사용자 체감 성능> 1s → warning, > 3s → critical
에러율 (5xx)서비스 안정성> 1% → alert
Pod CPU스케일링 기준> 70% → HPA scale up
Pod MemoryOOM Kill 방지> 80% → warning
Pod Restart Count앱 크래시 감지> 3/hour → alert
카디널리티(Cardinality) 주의

카디널리티란 라벨 조합으로 만들어지는 고유한 시계열(time series)의 수다. Prometheus는 라벨 조합 하나하나를 별도의 시계열로 저장하기 때문에, 라벨 값의 종류가 많아지면 시계열 수가 폭발적으로 늘어난다.

promql
# 카디널리티 낮음 — status는 200, 404, 500 등 수십 개 수준
http_requests_total{service="frontend", status="200"}
 
# 카디널리티 높음 — userId가 수만 명이면 시계열도 수만 개
http_requests_total{service="frontend", userId="12345"}

시계열이 많아지면 Prometheus의 메모리 사용량과 쿼리 응답 시간이 급격히 늘어난다. 라벨에 사용자 ID, 요청 URL 전체 경로, 세션 토큰처럼 값의 종류가 무한히 늘어날 수 있는 것을 넣으면 안 된다. 이런 데이터는 메트릭이 아니라 로그나 트레이스로 남기는 게 맞다.

Grafana 대시보드

Prometheus가 데이터를 수집하면, Grafana로 시각화한다. Grafana는 PromQL 쿼리 결과를 그래프, 게이지, 테이블 등으로 보여주는 도구다.

만들어 볼 만한 대시보드 구성은 이렇다.

  1. 서비스 Overview: 요청 수, 응답 시간, 에러율을 한눈에
  2. 리소스 Usage: CPU, 메모리, 네트워크 I/O
  3. Deployment Tracker: 배포 시점을 그래프에 마커로 표시 → 배포 후 지표 변화 추적

6. 로깅

kubectl logs만으로 왜 안 되나

bash
kubectl logs frontend-7d9f8b6c5-abc12

이 명령어는 특정 Pod 하나의 로그만 본다. 프로덕션에선

  • Pod이 3개, 10개일 때 어디서 에러 났는지 모른다
  • Pod이 재시작되면 이전 로그가 사라진다
  • 여러 서비스의 로그를 교차 검색할 수 없다

그래서 로그를 중앙에 수집해야 한다.

로그 레벨

text
DEBUG  → 개발 시 상세 정보 (API 요청/응답 body 등)
INFO   → 정상 동작 기록 (서버 시작, 요청 처리 완료)
WARN   → 잠재적 문제 (deprecated API 사용, 재시도 발생)
ERROR  → 실제 오류 (DB 연결 실패, 외부 API 타임아웃)

프로덕션에서는 보통 INFO 이상만 수집하고, 디버깅할 때 일시적으로 DEBUG로 내린다.

구조화된 로깅 (JSON)

javascript
// 파싱하기 어려움
console.log("User 123 failed to login - invalid password");
 
// 검색·필터링 가능
logger.warn({
  event: "login_failed",
  userId: 123,
  reason: "invalid_password",
  ip: "192.168.1.1",
  timestamp: "2026-03-05T10:23:45Z",
});

JSON 형태의 구조화된 로그를 쓰면:

  • event: "login_failed"로 필터링 가능
  • userId: 123으로 특정 사용자의 모든 활동 추적 가능
  • 대시보드에서 로그인 실패 횟수 같은 메트릭도 추출 가능

로그 수집 스택

Loki + Grafana (가벼운 선택지)

  • Loki: Prometheus처럼 라벨 기반 로그 저장소. 인덱스를 최소화해서 가볍다.
  • Grafana에서 메트릭과 로그를 같은 화면에서 볼 수 있다.

EFK Stack (전통적인 선택지)

  • Elasticsearch: 전문 검색 엔진 기반 로그 저장소
  • Fluentd/Fluent Bit: 로그 수집기. 각 노드에 DaemonSet(클러스터의 모든 노드에 하나씩 Pod을 배치하는 K8s 리소스)으로 배포한다.
  • Kibana: 시각화 대시보드
text
[Pod stdout/stderr]
    → [Fluent Bit (DaemonSet)]
    → [Loki or Elasticsearch]
    → [Grafana or Kibana]

로그 → 알림 연결

로그에서 특정 패턴이 감지되면 알림을 보낼 수 있다.

loki/rules.yaml
yaml
groups:
  - name: log-alerts
    rules:
      - alert: HighErrorLogRate
        expr: |
          sum(rate({namespace="my-app"} |= "ERROR" [5m])) > 10
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "my-app에서 5분간 ERROR 로그가 분당 10건 이상"

위 규칙은 Loki Ruler가 평가하는 LogQL 기반 알림이다. Prometheus의 PromQL 알림과 문법 구조는 비슷하지만, 로그 스트림을 대상으로 동작한다는 차이가 있다.

로깅 비용 주의

로그는 쌓이면 스토리지 비용이 급증한다. 특히 DEBUG 레벨 로그를 프로덕션에서 켜두면 하루에 수십 GB가 쌓일 수 있다. 반드시 보존 기간(retention) 을 설정하고, 프로덕션에서는 INFO 이상만 수집하자.


7. 전체 그림

지금까지 다룬 모든 것이 하나의 파이프라인으로 연결된다.

text
코드 작성
  → git push
  → GitHub Actions (lint, test, build)
  → Docker 이미지 빌드 → Registry Push
  → ArgoCD가 Git 변경 감지
  → Kubernetes에 롤링 업데이트
  → Prometheus가 메트릭 수집
  → Grafana 대시보드에서 모니터링
  → 이상 감지 시 Slack 등 온콜 알림
  → 로그에서 원인 분석

각 도구가 담당하는 영역을 정리해보면 다음과 같다.

영역도구역할
패키징Docker앱 + 환경을 이미지로
오케스트레이션Kubernetes컨테이너 배포·스케일링·복구
배포 전략ArgoCD + GitOpsGit 기반 선언적 배포
CI/CDGitHub Actions자동 테스트·빌드·이미지 푸시
메트릭Prometheus + Grafana수치 기반 모니터링
로깅Loki / EFK텍스트 로그 수집·검색

이 흐름을 이해하고 있으면, 배포가 실패했을 때 어디를 봐야 하는지, 성능이 느려졌을 때 어떤 대시보드를 확인해야 하는지, 인프라팀과 어떤 언어로 대화해야 하는지 알 수 있다.

인프라는 프론트엔드 코드가 사용자에게 닿기까지의 길이다. 운영 업무를 수월하게 볼 수 있는 그 날까지!