grep

Frontend

Next.js SSR 서버를 위한 모니터링 시스템 구축 (SSR 지옥 탈출기 시리즈 2)

berry.straww카카오

2025년 9월 9일

원문에서 보기 ↗

Next.js SSR 지옥 탈출기 시리즈


1. 들어가며

안녕하세요. 메이커스FE개발 베리입니다. 이번 글에서는 카카오메이커스 Next.js 서버의 가시성을 확보하기 위해, 모니터링 시스템을 구축한 내용을 설명드리고자 합니다.

모니터링 시스템을 구축하게 된 배경은 Next.js SSR 전환 과정에서 발생한 서비스 장애와, 이를 ISR + Redis 캐싱으로 해결한 경험에서 비롯되었습니다. 자세한 내용은 1편 〈ISR 전환과 Redis 외부 캐싱〉의 첫 단락을 참고해 주세요.

서비스 장애 당시 저희 팀은 서버에서 무슨 일이 벌어지고 있는지 제대로 파악하지 못했습니다. 503 오류가 발생하고 있었지만, 모니터링 지표는 멀쩡했습니다. CPU나 메모리 사용량도 이상이 없었기 때문에, 리소스 부족 문제라는걸 바로 알아차리기 어려웠습니다.

1.1 프로세스 레벨 모니터링의 필요성

카카오에는 모피어스(Morpheus)라는 사내 모니터링 서비스가 있습니다. 전사 서버에는 모피어스 에이전트(Agent)가 설치되어, 이를 통해 지표를 수집하여 대시보드를 보여주고 알람을 설정할 수 있습니다.

그러나 프론트엔드 서버는 이것만으론 충분하지 않았습니다. 모피어스는 OS 레벨의 지표만 볼 수 있었을 뿐, 실제로 Node.js가 실행되는 프로세스의 상태는 알 수 없었기 때문입니다.

1.2 모니터링 목표

CPU나 메모리 등 서버 자원이 충분한데도 프로세스가 종료될 수 있을까요? 잘 아시는대로 Node.js 애플리케이션은 힙(Heap)을 메모리 영역으로 사용합니다. 그런데 V8 엔진은 이 힙 메모리 크기에 제한이 있습니다. 서버의 램 용량이 아무리 크더라도, 개별 Node.js 프로세스는 최대 2~4GB 정도의 힙 메모리 제한을 갖고 있습니다. 이렇게 할당된 힙 메모리를 가득 채우게 되면 Out Of Memory 에러를 발생시키며 프로세스가 종료되게 되죠.

또한, 무거운 동기 작업이 많아지면 이벤트 루프를 점유하여 지연(Event Loop Lag)이 발생할 수 있습니다. 프로세스는 살아있지만 요청은 처리하지 못하는 좀비 프로세스가 되어버립니다. 이러한 지연이 누적되면 504 Gateway Timeout 오류가 발생하거나, 커넥션이 고갈되어 서비스 전체가 마비될 수 있습니다.

때문에 저희는 OS 레벨의 모니터링을 넘어서, 힙 사용량, 이벤트 루프 지연 등 Node.js 프로세스 내부의 상태까지 확인할 수 있는 모니터링 시스템이 필요했습니다.


2. 모니터링 파이프라인 구축하기

카카오메이커스 프론트엔드는 VM 인프라 위에서 PM2로 Node.js 애플리케이션을 띄우고 있습니다. PM2의 클러스터 모드를 이용해 가용한 모든 CPU 코어를 활용합니다. 이는 단일 서버 내에서 기본적인 로드 밸런싱과 장애 복구 기능을 제공하는 첫 번째 방어선이기도 합니다.

클러스터 모드는 개별 프로세스 관리의 복잡성을 감춰주지만, 동시에 프로세스 단위의 가시성을 확보하기 어렵게 만들었습니다. 이 한계를 풀기 위해 아래와 같은 구조로 모니터링 파이프라인을 구성했습니다. pm2-metrics으로 지표를 노출하고, Grafana Alloy로 지표를 수집하며, TSCoke(시계열DB)에 데이터를 쌓고, Grafana를 통해 이를 시각화합니다. 자세한 내용은 아래에서 하나씩 짚어보겠습니다.

2.1 pm2-metrics

PM2 클러스터 모드로 인한 서버의 가시성 문제는 pm2-metrics라는 오픈소스를 이용해 쉽게 해결할 수 있었습니다. 이 툴은 PM2 데몬을 쿼리하여 CPU/메모리 사용량, 이벤트 루프 지연, 힙 사용량 등 프로세스 지표를 수집하고, Prometheus 포맷으로 보여줍니다. :9209/metrics 경로로 HTTP 엔드포인트를 열어주기 때문에 해당 페이지에 접속하는 것만으로 쉽게 지표 수집이 가능합니다.

대상 서버에서 아래 명령어로 pm2-metrics를 설치합니다.

pm2 install pm2-metrics

curl 또는 브라우저 접속을 통해 지표가 정상 노출되는지 확인합니다.

curl localhost:9209/metrics

2.2 TSCoke (TSDB)

메트릭 데이터를 적재하기 위한 데이터베이스도 필요합니다. 저희는 TSCoke를 활용하기로 했습니다. TSCoke는 카카오 사내에서 운영 중인 TSDB(시계열 데이터베이스) 서비스입니다.

시간에 따라 바뀌는 메트릭이라는 데이터 특성상 TSDB를 사용하기 적합했고, 또 프로메테우스와의 호환성을 제공하여 간편하게 연동할 수 있었습니다.

시계열 데이터베이스 (Time-Series Database, TSDB)란 시간의 흐름에 따라 기록된 데이터를 처리하기 위한 데이터베이스입니다. 모든 데이터에 타임스탬프를 함께 저장합니다. 시간이 지남에 따라 생성되는 대량의 데이터를 효율적으로 수집, 저장, 조회하는 데 특화되어 있습니다. 모니터링 지표를 비롯해 주가 정보나 온습도, 전력 사용량처럼 시간의 흐름에 따른 데이터를 처리하기에 적합합니다.

2.3 Grafana Alloy

Grafana Alloy는 메트릭 데이터를 수집하고 전송할 수 있는 도구입니다. 간단한 설정을 통해 타깃 호스트로부터 수집한 프로메테우스 형식의 메트릭 데이터를 원격 저장소로 쉽게 보낼 수 있습니다.

Alloy를 띄울 서버에서 아래와 같이 설정 파일을 생성합니다. pm2-metrics가 노출한 HTTP 엔드포인트를 일정한 간격으로 스크래핑하고, 라벨을 추가한 후, 그 결과를 TSCoke 데이터베이스로 전송하는 설정입니다.

// config.alloy

logging {
  level = "warn"
}

// PM2 메트릭 수집
prometheus.scrape "pm2" {
  targets = [
    { __address__ = "front01.your-domain.com", phase = "production", service = "service_name" },
    { __address__ = "front02.your-domain.com", phase = "production", service = "service_name" },
    { __address__ = "front03.your-domain.com", phase = "production", service = "service_name" },
    ...
  ]
  metrics_path = "/metrics"
  scrape_interval = "10s"
  forward_to = [prometheus.relabel.add_labels.receiver]
}

// 라벨 추가 (필요에 따라 설정)
prometheus.relabel "add_labels" {
  rule {
    action        = "replace"
    target_label  = "config_version"
    replacement   = "25072801"
  }

  forward_to = [prometheus.remote_write.default.receiver]
}

// remote_write 설정
prometheus.remote_write "default" {
  endpoint {
    url = ""
    basic_auth {
      username = "admin"
      password = "1234"
    }
  }
}

그리고 Docker를 이용해 Alloy를 실행합니다.

docker run -d -v ./config.alloy:/etc/alloy/config.alloy -p 12345:12345 grafana/alloy:latest run --server.http.listen-addr=0.0.0.0:12345 --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy

그럼 바로 메트릭 수집을 시작하게 됩니다.

Alloy는 UI 대시보드도 제공합니다. 12345번 포트로 접속하면 대시보드가 열립니다. 대시보드에서는 설정에서 정의한 컴포넌트들의 상태를 확인하고 디버깅할 수 있습니다.

또한 Graph 메뉴에서는 이처럼 데이터의 흐름을 시각화하여 보는 것도 가능합니다.

2.4 Grafana

Grafana는 시계열 데이터에 대한 대시보드를 제공해주는 시각화 툴입니다. 저희가 Alloy를 통해 DB에 적재한 메트릭 데이터를 이용해, 원하는 대로 대시보드를 꾸밀 수 있습니다.

Docker를 이용해 아래 명령어로 Grafana를 실행합니다.

sudo docker run -d --name=grafana -p 3000:3000 grafana/grafana-enterprise

이후 http://localhost:3000/ 경로로 접속하면 아래와 같은 화면이 뜨는 걸 볼 수 있습니다. 초기 아이디와 비밀번호는 admin/admin 입니다.

Connections > Data sources 메뉴에 접속하여 Time series databases - Prometheus를 선택합니다. 서버 경로와 인증 정보를 추가합니다.

데이터소스 연결이 완료되었으면 Dashboards 페이지에서 새로운 대시보드를 생성할 수 있습니다. 운영 환경에 맞춰 대시보드 세팅을 완료하면 완성입니다.

3. 시스템 고도화하기

3.1 Docker Compose 적용하기

Grafana Alloy와 Grafana는 Docker 명령어로 띄웠는데요, 이를 코드로 관리하기 위해 Docker Compose를 활용했습니다. Docker Compose는 IaC(Infrastructure as Code)의 일종으로, YAML 파일을 통해 컨테이너 실행 방식을 코드로 정의할 수 있습니다.

이를 통해 컨테이너 실행 환경을 비롯해 config.alloy 설정파일과 데이터소스(Data source) 연결 정보, 대시보드 설정까지 모두 파일로 관리할 수 있게 되었습니다.

# docker-compose.alloy.yml

version: '3.8'

services:
  alloy:
    image: grafana/alloy:latest
    container_name: grafana-alloy
    command:
      - run
      - --server.http.listen-addr=0.0.0.0:12345
      - --storage.path=/var/lib/alloy/data
      - /etc/alloy/config.alloy
    volumes:
      - ./configs/alloy/config.alloy:/etc/alloy/config.alloy
    ports:
      - "12345:12345"
    networks:
      - monitor-net
    env_file:
      - ./configs/env/alloy/.current.env
    restart: unless-stopped

networks:
  monitor-net:
    driver: bridge
    external: true 
# docker-compose.grafana.yml

version: '3.8'

services:
  grafana:
    image: grafana/grafana-enterprise
    container_name: grafana-dashboard
    ports:
      - "3000:3000"
    volumes:
      - ./grafana-provisioning:/etc/grafana/provisioning
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=password
    networks:
      - monitor-net

  nginx:
    image: nginx:latest
    container_name: nginx
    ports:
      - "80:80"
    volumes:
      - ./configs/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./configs/public:/usr/share/nginx/html
    depends_on:
      - grafana
    networks:
      - monitor-net

networks:
  monitor-net:
    driver: bridge
    external: false 

3.2 Grafana Alloy 고가용성(HA) 구성하기

모니터링 시스템을 구축 초기에는 위 다이어그램처럼 Grafana Alloy를 모니터링 대상 서버에 각각 설치하는 방안을 고민했습니다. 각 프론트엔드 서버에서 동작하는 Alloy가 로컬의 메트릭 정보를 수집하고, 이를 TSDB에 전송하고자 했습니다.

Alloy를 한 서버에만 두고 스크래핑할 경우 단일 장애 지점(SPOF)이 될 수 있다는 팀원 Aiden의 우려 때문이었습니다. Alloy가 중단되면 메트릭 수집이 중단되어 모니터링을 할 수 없게 되므로, 현실적으로 타당한 이슈였습니다.

그러나 이 접근법의 몇가지 문제점을 발견했습니다.

때문에 Alloy는 별도의 서버에 중앙 집중식 으로 구성하되, SPOF 문제를 해결하기 위해 Active-Standby 고가용성(HA) 구성을 적용했습니다.

두 개의 동일한 Alloy 인스턴스를 배포하여 하나는 Active 상태로 실제 수집 작업을 수행하고, 다른 하나는 Standby 상태로 대기하도록 구성했습니다. Active 인스턴스를 주기적으로 헬스 체크하며 장애(Failover)가 발생하면 Standby를 Active로 전환하는 쉘 스크립트를 작성해 크론잡(Cron Job)으로 등록했습니다. 이를 통해 Alloy를 중앙 집중식으로 효율적으로 운영하면서도, 고가용성을 간단하게 구성할 수 있었습니다.

#!/bin/bash

PRIMARY_HOST="primary-alloy.your-domain.com"
PRIMARY_PORT="12345"
HEALTHCHECK_URL="{PRIMARY_HOST}:${PRIMARY_PORT}/metrics"

BACKUP_ENV="/configs/env/alloy/event/backup.env"
PRIMARY_ENV="/configs/env/alloy/event/primary.env"
CURRENT_ENV="/configs/env/alloy/.current.env"

RESTART_SCRIPT="/scripts/alloy/event/restart.sh"
FAILOVER_FLAG="/tmp/alloy_promoted"
LOG="/var/log/alloy-failover.log"

# ensure .current.env exists at least once
if [ ! -f "$CURRENT_ENV" ]; then
  echo "$(date) - [INFO] .current.env not found. Initializing with backup env." >> "$LOG"
  cp "$BACKUP_ENV" "$CURRENT_ENV"
fi

# 1. health check
curl -s --max-time 2 "$HEALTHCHECK_URL" > /dev/null
if [ $? -ne 0 ]; then
  echo "$(date) - [FAILOVER] Primary unreachable. Promoting backup..." >> "$LOG"

  if [ -f "$FAILOVER_FLAG" ]; then
    echo "$(date) - Already promoted. Skipping." >> "$LOG"
    exit 0
  fi

  # 2. switch env to primary
  cp "$PRIMARY_ENV" "$CURRENT_ENV"

  # 3. restart
  $RESTART_SCRIPT >> "$LOG" 2>&1

  touch "$FAILOVER_FLAG"
  echo "$(date) - Backup promoted and restart script executed." >> "$LOG"
else
  echo "$(date) - [OK] Primary is healthy." >> "$LOG"
fi

4. 마무리

이번 글에서는 Next.js SSR 서버를 안정적으로 운영하기 위해 모니터링 시스템을 어떻게 구축하고 고도화했는지를 다루었습니다. 단순히 서버 자원의 사용량만 확인하는 수준에서 벗어나, 프로세스 단위의 상태와 지연, 장애 상황을 감지할 수 있는 체계를 마련할 수 있었습니다.

물론 아직도 개선해야 할 부분이 많습니다. 대시보드를 정교하게 다듬고, 메트릭 기반 알람을 설정하는 것도 필요합니다. 또한 Grafana Alloy의 HA 구성 역시 지금은 쉘 스크립트 + 크론잡으로 간단히 구현해 놓았는데요, 이를 Alloy Clustering Mode를 활용하는 것으로 전환하는 것도 고려해볼 수 있습니다.

사실 프론트엔드 개발자로서 평소에는 주로 JavaScript/TypeScript 코드만 다루다가 이런 인프라 작업들을 해보니 다소 낯설기도 했습니다. 하지만 이른바 ‘컴포넌트 공장’에서 벗어날 수 있었던 신선한 경험이었고, 새로운 것들을 탐구하면서 개발자로서 시야를 넓힐 수 있어 즐겁기도 했습니다.

긴 글 읽어주신 독자분들께 감사드리고, 이 글이 조금이나마 도움이 되셨기를 바라며 글을 마칩니다.