🟠 코틀린↗ 🗄️ DB↗ 🟣 C#↗ 🐍 파이썬↗ ☕ 자바↗ 🟨 JS·TS↗ 🔵 C++↗ 🔌 C · 임베디드↗ 🦀 Rust↗ 🎓 CS 기술↗
👋 서버를 처음 만지시나요? 왼쪽 🧭 시작 · 개념부터 순서대로 읽으면 됩니다 · 위 주제 메뉴 ▾(또는 키보드 1~9·0)로 이동 · 설정 상자에 마우스를 올리면 복사 버튼이 나옵니다
WEB SERVER · 설치부터 · 서버를 처음 만져도 됩니다

웹서버 —
요청은
어떻게 흘러가나

이 가이드는 서버를 한 번도 만져 본 적 없는 사람을 기준으로 시작합니다. 설치 → 첫 페이지 → 리버스 프록시 → HTTPS → 언어별 배포까지 막히지 않게 명령을 그대로 적어 두었고, 이후 성능 튜닝·보안 운영·내부 구조로 이어집니다. Nginx · Apache · Tomcat · Caddy 를 각각 전용 탭으로 다룹니다.

10탭
138섹션
4서버 전용 탭
0사전 지식
SECTION 01

웹서버란 무엇인가

브라우저 주소창에 무언가를 치면, 어딘가의 컴퓨터가 그 요청을 받아 답을 돌려줍니다. 그 "받아서 돌려주는 프로그램"이 웹서버입니다.

정적과 동적 — 이 구분이 전부의 출발점

정적 (Static)
브라우저: /about.html 주세요
웹서버 : (디스크에서 파일을 읽어) 여기 있습니다

· HTML · CSS · JS · 이미지
· 누가 요청해도 같은 내용
· 웹서버 혼자서 처리 가능
· 매우 빠름
동적 (Dynamic)
브라우저: /orders/1234 주세요
웹서버 : (앱에게 물어봄) → 앱 → DB 조회
앱     : 이 사람의 주문 정보입니다
웹서버 : 여기 있습니다

· 사람마다 · 시점마다 내용이 다름
· 웹서버 혼자서는 못 만듦 → 앱이 필요
웹서버 · WAS · 앱 — 이름 정리
· 웹서버 — 요청을 받고 정적 파일을 돌려주며, 동적 요청은 뒤로 넘깁니다 (Nginx · Apache)
· WAS (웹 애플리케이션 서버) — 코드를 실행해 결과를 만듭니다 (Tomcat · Gunicorn · Node)
· 앱 — 우리가 작성한 프로그램 (Spring · Django · Express)
실무에서는 웹서버(앞) + WAS/앱(뒤) 조합이 표준입니다.

그냥 앱만 띄우면 안 되나요

됩니다. Node·Spring Boot·Django 모두 자체 서버 기능이 있어 혼자서도 요청을 받습니다. 실제로 개발 중에는 그렇게 씁니다. 그런데 실서비스에서는 거의 항상 앞에 웹서버를 둡니다. 이유는 이렇습니다.

앞단 웹서버가 하는 일안 두면 생기는 문제
정적 파일 서빙이미지·CSS 요청까지 앱이 처리하느라 정작 로직 처리가 밀립니다
HTTPS 처리앱마다 인증서를 관리해야 하고, 언어별로 방식이 제각각입니다
여러 앱 통합한 도메인에서 /api 는 자바, /admin 은 파이썬 같은 구성이 불가능해집니다
부하 분산앱 서버를 여러 대로 늘릴 수 없습니다
느린 클라이언트 흡수모바일 3G 사용자 한 명이 앱 워커를 수 초간 붙잡습니다
보안 · 접근 제어앱이 인터넷에 직접 노출됩니다
압축 · 캐시같은 응답을 매번 새로 만듭니다
한 문장 요약 — 웹서버는 앱이 로직에만 집중하게 해 주는 문지기입니다. 앱이 잘하는 일과 웹서버가 잘하는 일을 나누는 것이 좋은 구성의 시작입니다.
웹서버가 하는 일은 둘뿐입니다 브라우저 요청 GET /images/logo.png 웹서버 경로를 보고 판단합니다 ① 파일을 그대로 — 정적 이미지 · CSS · JS — 디스크에서 바로 ② 앱에 넘김 — 동적 로그인 · 주문 — 매번 결과가 다릅니다 그래서 앞에 두는 것이 기본 구성입니다 정적 파일은 웹서버가 처리해 앱의 부담을 덜고, 동적 요청만 뒤로 넘깁니다 HTTPS · 압축 · 접근 제한 · 로깅도 여기서 한 번에 처리합니다 앱 서버만으로도 웹은 돌아갑니다 — 다만 정적 처리와 보안 설정을 전부 앱이 떠안게 됩니다 "웹서버" 와 "앱 서버" 는 역할 이름입니다 — 한 프로그램이 둘 다 할 수도 있습니다
웹서버를 앞에 두는 이유는 속도만이 아니라 공통 관심사를 한곳에 모으는 것입니다.
SECTION 02

요청이 흐르는 길

주소를 치고 화면이 뜨기까지 무슨 일이 일어나는지 — 이 길을 알아야 어디서 막혔는지 찾을 수 있습니다.

1 · DNS
도메인을 IP 주소로 바꿉니다 example.com → 93.184.x.x. 여기서 실패하면 "사이트를 찾을 수 없음".
▼
2 · TCP
그 IP의 포트로 연결을 맺습니다 HTTP는 80, HTTPS는 443. 방화벽에 막히면 여기서 무응답(timeout)이 됩니다.
▼
3 · TLS
암호화 통로를 만듭니다 (HTTPS일 때) 인증서를 확인하고 키를 교환합니다. 인증서 문제는 이 단계에서 드러납니다.
▼
4 · HTTP
요청을 보냅니다 GET /orders/1234 HTTP/1.1 + 헤더들. 이제야 "무엇을 원하는지" 전달됩니다.
▼
5 · 웹서버
정적 파일이면 직접, 아니면 뒤로 넘깁니다 이 판단이 location 설정입니다. 여기서 잘못되면 404.
▼
6 · 앱
코드가 실행되어 결과를 만듭니다 앱이 안 떠 있으면 502, 너무 느리면 504.
▼
7 · DB
데이터를 읽고 씁니다 대부분의 지연은 사실 여기서 발생합니다 → 🗄️ 데이터베이스 가이드
이 순서가 진단의 지도입니다.
· 사이트를 못 찾음 → 1 DNS
· 계속 로딩만 됨(무응답) → 2 방화벽·포트
· 인증서 경고 → 3 TLS
· 404 → 5 웹서버 경로 설정
· 502 · 504 → 6 앱
· 느림 → 6~7 (로그의 응답 시간으로 구분)

직접 확인해 보기

# 1. DNS 가 어디를 가리키나
dig +short example.com
nslookup example.com

# 2. 포트가 열려 있나
nc -zv example.com 443
telnet example.com 443

# 3. 인증서가 정상인가
openssl s_client -connect example.com:443 -servername example.com < /dev/null

# 4. 실제 응답 (헤더 포함)
curl -I https://example.com
curl -v https://example.com

# 5. 각 단계에 걸린 시간 ← 어디가 느린지 바로 보입니다
curl -w "dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} \
first_byte:%{time_starttransfer} total:%{time_total}\n" -o /dev/null -s https://example.com
dns:0.004 connect:0.031 tls:0.087 first_byte:0.142 total:0.145
이 숫자를 읽는 법 — time_starttransfer(첫 바이트까지)가 크면 서버·앱·DB 처리가 느린 것이고, total 과의 차이가 크면 응답 본문 전송이 느린 것입니다(용량·네트워크). time_appconnect 가 크면 TLS 협상이 오래 걸린 것입니다.
요청이 흐르는 길 — 어디서 막혔는지 가르는 지도 1 DNS — 이름을 IP 로 여기서 막히면 "서버를 찾을 수 없음". 확인 — dig · nslookup 2 TCP 연결 — 포트로 문 두드리기 막히면 연결 거부 또는 시간 초과. 확인 — 방화벽 · 서버가 떠 있는가 · 포트가 맞는가 3 TLS 협상 — 인증서 확인과 열쇠 만들기 막히면 인증서 경고. 확인 — 만료 · 도메인 불일치 · 중간 인증서 누락 4 HTTP 요청 → 웹서버가 판단 정적이면 바로 파일, 동적이면 뒤쪽 앱으로. 404 · 403 이 여기서 나옵니다 5 앱 처리 → 응답 502 는 앱이 안 떠 있는 것, 504 는 앱이 늦는 것입니다 — 원인이 완전히 다릅니다 진단의 시작은 늘 같습니다 — 이 다섯 중 어디까지 갔는지 먼저 확인하세요
에러 화면을 보기 전에 몇 번째 단계인지부터 정하면 범위가 확 줄어듭니다.
SECTION 03

4대 웹서버 비교

아래 버튼을 눌러 서버를 바꿔 보세요. 성격 · 강점 · 함정 · 대표 설정이 함께 바뀝니다.

서버 선택

한 장 비교표

항목NginxApacheTomcatCaddy
분류웹서버 · 프록시웹서버WAS (자바)웹서버 · 프록시
처리 방식이벤트 기반MPM 선택 (prefork·worker·event)스레드 기반고루틴 기반
동시 접속 효율매우 높음보통 (event MPM 이면 개선)보통높음
자동 HTTPS✗ (직접 설정)✗✗✓ 기본 제공
동적 언어 직접 실행✗모듈로 가능 (mod_php 등)✓ 자바✗
설정 난이도보통장황함XML · 보통매우 쉬움
주요 설정 파일nginx.confhttpd.conf·apache2.confserver.xmlCaddyfile
기본 포트80 · 44380 · 4438080 · 844380 · 443
이 가이드 탭🟩 Nginx🦅 Apache🐱 Tomcat🔒 Caddy

고르는 기준

🟢 처음 배운다면
  • Nginx로 시작하세요 — 자료가 가장 많습니다
  • 개인 프로젝트에 HTTPS만 빨리 붙이고 싶으면 Caddy
  • 자바를 배운다면 Tomcat은 어차피 만나게 됩니다
🔵 실무라면
  • 회사가 이미 쓰는 것을 그대로 (대개 Nginx)
  • 레거시 시스템·워드프레스면 Apache일 확률이 높습니다
  • 자바 앱은 Nginx + Tomcat 또는 Nginx + Spring Boot
🟣 규모가 커지면
  • 앞단에 CDN·클라우드 LB가 추가됩니다
  • 쿠버네티스면 Ingress Controller(대부분 Nginx 기반)
  • 서버 종류보다 캐시·분산·관측 설계가 중요해집니다
IIS 는요? — 마이크로소프트의 웹서버로 Windows Server + ASP.NET 환경에서 씁니다. 이 가이드는 리눅스 기반 구성을 다루므로 별도로 설명하지 않지만, 개념(가상 호스트·리버스 프록시·인증서)은 그대로 통합니다.
넷은 경쟁 관계가 아닙니다 — 자리가 다릅니다 Nginx — 리버스 프록시의 기본값 적은 자원으로 많은 연결을 처리합니다. 설정이 간결하고 자료가 많습니다 Apache — 오래되고 두꺼운 생태계 .htaccess 로 디렉터리별 설정이 되고 모듈이 풍부합니다. 공유 호스팅과 레거시에 많습니다 Caddy — 자동 HTTPS 인증서를 알아서 발급하고 갱신합니다. 설정이 몇 줄이면 끝나 개인 프로젝트에 잘 맞습니다 Tomcat — 성격이 다릅니다 (앱 서버) 자바 애플리케이션을 실행하는 쪽입니다 — 보통 앞에 Nginx 를 두고 함께 씁니다 처음이라면 Nginx 로 시작하세요 — 어디서든 통하고 배운 것이 그대로 남습니다 개인 프로젝트에서 HTTPS 를 빨리 붙이고 싶다면 Caddy 가 가장 빠릅니다
고르는 기준은 성능 순위가 아니라 지금 무엇을 앞에 둘 것인가입니다.
SECTION 04

설치 ① Linux

명령 한 줄이면 설치됩니다. 중요한 것은 설치 후 서비스를 켜고 자동 시작을 등록하는 것입니다.

# ── Ubuntu · Debian ─────────────────────────────
sudo apt update
sudo apt install -y nginx
sudo systemctl enable --now nginx        # 지금 시작 + 부팅 시 자동 시작

# Apache 는 패키지 이름이 apache2
sudo apt install -y apache2
sudo systemctl enable --now apache2

# ── RHEL · Rocky · Alma ─────────────────────────
sudo dnf install -y nginx
sudo systemctl enable --now nginx

sudo dnf install -y httpd                # Apache 는 httpd
sudo systemctl enable --now httpd

# ── 확인 ────────────────────────────────────────
systemctl status nginx                   # active (running) 이어야 합니다
nginx -v
curl -I http://localhost                 # HTTP/1.1 200 OK
배포판마다 이름이 다릅니다. Apache 는 Ubuntu 계열에서 apache2, RHEL 계열에서 httpd 입니다. 설정 파일 경로도 /etc/apache2/ 와 /etc/httpd/ 로 다릅니다. 인터넷 문서를 볼 때 어느 계열 기준인지 먼저 확인하세요.

Tomcat — 자바가 먼저 필요합니다

# 자바 설치 (톰캣 11 은 자바 17 이상 필요)
sudo apt install -y openjdk-21-jdk
java -version

# 톰캣은 압축 파일을 받아 푸는 방식이 일반적입니다
cd /opt
sudo curl -LO https://dlcdn.apache.org/tomcat/tomcat-11/v11.0.x/bin/apache-tomcat-11.0.x.tar.gz
sudo tar xzf apache-tomcat-11.0.x.tar.gz
sudo mv apache-tomcat-11.0.x tomcat

# 전용 계정으로 실행 (root 로 돌리지 마세요)
sudo useradd -r -s /bin/false tomcat
sudo chown -R tomcat:tomcat /opt/tomcat

sudo -u tomcat /opt/tomcat/bin/startup.sh
curl -I http://localhost:8080

Caddy

# 공식 저장소 등록 후 설치 (Ubuntu)
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

sudo systemctl enable --now caddy

서비스 관리 명령 — 이 다섯 개면 됩니다

명령하는 일
systemctl status nginx지금 상태 · 최근 로그 몇 줄
systemctl start · stop시작 · 중지
systemctl restart nginx완전 재시작 — 순간적으로 접속이 끊깁니다
systemctl reload nginx★ 설정만 다시 읽기 — 접속이 끊기지 않습니다
journalctl -u nginx -f서비스 로그 실시간 보기
SECTION 05

설치 ② Windows · Docker

학습·개발 환경이라면 도커가 가장 편합니다. PC를 더럽히지 않고 버전을 바꿔 가며 실험할 수 있습니다.

Docker — 한 줄로 띄우기

# Nginx (기본 페이지 확인용)
docker run -d --name web -p 8080:80 nginx:1.28

# 내 HTML 폴더를 연결
docker run -d --name web -p 8080:80 \
  -v "$PWD/html:/usr/share/nginx/html:ro" \
  nginx:1.28

# 설정 파일까지 연결
docker run -d --name web -p 8080:80 \
  -v "$PWD/html:/usr/share/nginx/html:ro" \
  -v "$PWD/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
  nginx:1.28

# Apache · Tomcat · Caddy
docker run -d --name ap -p 8081:80 httpd:2.4
docker run -d --name tc -p 8082:8080 tomcat:11-jdk21
docker run -d --name cd -p 8083:80 caddy:2.10

# 확인 · 로그 · 정리
docker ps
docker logs -f web
docker exec -it web bash
docker rm -f web

docker compose — 앞단 + 앱 한 번에

# compose.yaml
services:
  web:
    image: nginx:1.28
    ports: ["8080:80"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./html:/usr/share/nginx/html:ro
    depends_on: [app]

  app:
    build: ./app                     # 우리 애플리케이션
    expose: ["3000"]                 # 외부에 열지 않고 web 만 접근

# docker compose up -d
# docker compose logs -f web
# nginx.conf — 컨테이너 안에서는 서비스 이름이 곧 호스트명입니다
server {
    listen 80;

    location / {
        root /usr/share/nginx/html;
        index index.html;
    }

    location /api/ {
        proxy_pass http://app:3000;      # ← 'app' 이 컨테이너 이름
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
도커 컴포즈에서는 컨테이너 이름이 DNS 이름이 됩니다. proxy_pass http://app:3000 처럼 서비스 이름을 그대로 쓸 수 있어 IP 를 하드코딩할 필요가 없습니다. 앱 컨테이너는 expose 만 하고 ports 로 외부에 열지 마세요 — 프록시를 우회해 직접 접근하는 경로가 생깁니다.

Windows에 직접 설치

  • WSL2 + Ubuntu — 가장 권장합니다. 리눅스 명령이 그대로 통해 운영 환경과 같은 방식으로 연습할 수 있습니다
  • Nginx for Windows — 공식 배포판이 있지만 성능·기능에 제약이 있습니다(이벤트 모델이 다름). 학습용으로만
  • XAMPP — Apache + PHP + MySQL 묶음. PHP 학습에는 편하지만 운영 환경과 구성이 달라 실무 감각을 익히기엔 부족합니다
  • IIS — Windows Server 기본 웹서버. .NET 환경이라면 자연스러운 선택입니다
도커에서 자주 겪는 문제
· 포트 충돌 — bind: address already in use → 다른 포트로 매핑하세요
· 파일이 안 보임 — 볼륨 경로 오타. 윈도우는 절대 경로 표기가 다릅니다
· 설정이 반영 안 됨 — 컨테이너 재시작 필요: docker exec web nginx -s reload
· 권한 오류 — 컨테이너 안 사용자와 호스트 파일 소유자가 다를 수 있습니다
SECTION 06

첫 페이지 띄우기

내가 만든 HTML을 실제 서버로 서비스해 봅니다. 배포의 첫 성공 경험입니다.

# 1) 파일을 둘 폴더를 만들고
sudo mkdir -p /var/www/mysite
sudo chown -R $USER:$USER /var/www/mysite

# 2) HTML 을 하나 만듭니다
cat > /var/www/mysite/index.html <<'EOF'
<!DOCTYPE html>
<html lang="ko">
<head><meta charset="utf-8"><title>첫 페이지</title></head>
<body><h1>서버가 살아 있습니다 🎉</h1></body>
</html>
EOF
# 3) /etc/nginx/sites-available/mysite  (RHEL 계열은 /etc/nginx/conf.d/mysite.conf)
server {
    listen 80;
    server_name mysite.local;          # 도메인이 없으면 _ 를 써도 됩니다

    root /var/www/mysite;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
# 4) 활성화 (Ubuntu 계열)
sudo ln -s /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/

# 5) ★ 문법 검사 — 이걸 빠뜨리면 서버가 안 뜹니다
sudo nginx -t
# nginx: configuration file /etc/nginx/nginx.conf test is successful

# 6) 반영 (접속을 끊지 않고)
sudo systemctl reload nginx

# 7) 확인
curl -H "Host: mysite.local" http://localhost
도메인이 없어도 테스트할 수 있습니다. 내 PC의 hosts 파일에 한 줄을 넣으면 브라우저에서도 열립니다.
· Linux · macOS — /etc/hosts
· Windows — C:\Windows\System32\drivers\etc\hosts (관리자 권한)
127.0.0.1 mysite.local

Apache · Caddy 로 같은 일 하기

Apache
# /etc/apache2/sites-available/mysite.conf
<VirtualHost *:80>
    ServerName mysite.local
    DocumentRoot /var/www/mysite

    <Directory /var/www/mysite>
        Require all granted
    </Directory>
</VirtualHost>

# sudo a2ensite mysite
# sudo apachectl configtest
# sudo systemctl reload apache2
Caddy
# /etc/caddy/Caddyfile
mysite.local {
    root * /var/www/mysite
    file_server
}

# sudo systemctl reload caddy
# 실제 도메인이면 HTTPS 까지 자동입니다
403 Forbidden 이 뜬다면 — 파일은 있는데 웹서버가 읽을 권한이 없는 것입니다.
① 폴더에 실행(x) 권한이 있어야 들어갈 수 있습니다: chmod 755 /var/www/mysite
② 파일은 읽기 권한: chmod 644 index.html
③ 상위 폴더 전부에 접근 권한이 있어야 합니다 (홈 디렉터리 아래에 두면 자주 막힙니다)
④ SELinux 가 켜진 RHEL 계열이면: sudo restorecon -Rv /var/www/mysite
SECTION 07

설정 파일 구조 읽는 법

남이 써 놓은 설정 파일을 열었을 때 당황하지 않으려면 구조의 규칙만 알면 됩니다.

Nginx — 블록의 중첩

# /etc/nginx/nginx.conf  (전역)
user  www-data;                      # 워커가 실행될 계정
worker_processes  auto;              # CPU 코어 수만큼

events {                             # 연결 처리 설정
    worker_connections  1024;
}

http {                               # HTTP 관련 전역 설정
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    sendfile      on;
    keepalive_timeout  65;

    include /etc/nginx/conf.d/*.conf;        # 여기서 개별 사이트 설정을 읽어 옵니다
    include /etc/nginx/sites-enabled/*;

    server {                         # 사이트 하나
        listen 80;
        server_name example.com;

        location / {                 # 경로별 설정
            root /var/www/html;
        }
    }
}
규칙내용
지시어이름 값; — 세미콜론으로 끝납니다. 빠뜨리면 문법 오류
블록이름 { … } — 안쪽이 바깥쪽 설정을 물려받습니다
덮어쓰기안쪽에서 같은 지시어를 다시 쓰면 안쪽이 이깁니다
주석# 로 시작
변수$host · $remote_addr 처럼 $ 로 시작
include다른 파일을 그 자리에 끼워 넣습니다 — 설정을 나눠 관리할 때

파일이 어디에 있나

서버주 설정 파일사이트별 설정
Nginx (Ubuntu)/etc/nginx/nginx.confsites-available/ → sites-enabled/ 심볼릭 링크
Nginx (RHEL)/etc/nginx/nginx.conf/etc/nginx/conf.d/*.conf
Apache (Ubuntu)/etc/apache2/apache2.confsites-available/ + a2ensite
Apache (RHEL)/etc/httpd/conf/httpd.conf/etc/httpd/conf.d/*.conf
Tomcat$CATALINA_HOME/conf/server.xmlconf/Catalina/localhost/*.xml
Caddy/etc/caddy/Caddyfile같은 파일 안에 사이트 블록으로
# 실제로 어떤 설정이 적용되고 있는지 확인
nginx -T                    # include 를 전부 펼친 최종 설정을 출력 ← 아주 유용합니다
apachectl -S                # 가상 호스트 목록과 우선순위
apachectl -M                # 로드된 모듈 목록
caddy adapt --config /etc/caddy/Caddyfile   # Caddyfile 을 JSON 으로 변환해 확인
nginx -T 는 문제 해결의 핵심 도구입니다. include 가 여러 겹이면 어느 파일의 설정이 실제로 적용되는지 헷갈립니다. 이 명령은 최종 결과를 한 번에 보여 줍니다.
설정 파일은 대개 이 세 겹입니다 전역 — 워커 수 · 로그 위치 · 포함할 파일 사이트 블록 — 도메인 하나 (server · VirtualHost) 어떤 도메인과 포트를 받을지, 문서 루트는 어디인지 경로 블록 — location · Directory /api 는 프록시로, /static 은 파일로, /admin 은 IP 제한 안쪽은 바깥을 물려받고, 같은 항목을 다시 적으면 안쪽이 이깁니다 설정이 안 먹을 때 — 더 안쪽에서 덮어썼거나, 더 구체적인 블록이 먼저 걸린 것입니다 고치기 전에 문법 검사부터 — nginx -t · apachectl configtest · caddy validate include 로 나눠 두면 사이트가 늘어도 파일이 커지지 않습니다 — 처음부터 그렇게 두세요
설정을 읽는 요령은 문법이 아니라 지금 어느 겹을 보고 있는지 아는 것입니다.
SECTION 08

포트 · 방화벽 · 권한

서버가 안 뜨거나 접속이 안 될 때, 원인의 대부분이 이 셋 중 하나입니다.

포트

# 지금 누가 쓰고 있나
sudo ss -lntp | grep -E ':80|:443|:8080'
sudo lsof -i :80

# 포트를 이미 쓰고 있으면 이런 오류가 납니다
# nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)

# 범인을 찾아 정리하거나 다른 포트를 쓰세요
sudo systemctl stop apache2       # 아파치가 80 을 잡고 있는 경우가 흔합니다
1024 미만 포트는 관리자 권한이 필요합니다. 80·443 이 여기에 해당하므로, 일반 사용자로 띄우면 Permission denied 가 납니다.
· 웹서버는 root 로 시작해 포트를 잡은 뒤 워커는 일반 계정으로 낮춥니다 (기본 동작)
· 앱은 8000·3000 같은 높은 포트를 쓰고 웹서버가 프록시하는 것이 정석입니다
· 꼭 필요하면: sudo setcap 'cap_net_bind_service=+ep' /path/to/app

방화벽

# Ubuntu (ufw)
sudo ufw status
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 'Nginx Full'          # 프로파일로 한 번에

# RHEL 계열 (firewalld)
sudo firewall-cmd --list-all
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

# 클라우드라면 보안 그룹·네트워크 ACL 도 확인해야 합니다
# (서버 안 방화벽만 열어도 클라우드 쪽에서 막혀 있으면 안 됩니다)
연결 거부(refused)와 무응답(timeout)의 차이
· Connection refused — 서버까지는 닿았는데 그 포트에 아무도 없음. 웹서버가 안 떴거나 포트가 다릅니다
· 무응답 · 타임아웃 — 패킷이 중간에서 버려짐. 방화벽·보안 그룹 문제입니다
이 구분만으로 진단 시간이 크게 줄어듭니다.

권한

# 웹서버가 어떤 계정으로 도는지
ps aux | grep nginx
# root      … nginx: master process     ← 포트를 잡는 프로세스
# www-data  … nginx: worker process     ← 실제로 파일을 읽는 프로세스

# 그 계정이 파일을 읽을 수 있어야 합니다
sudo -u www-data cat /var/www/mysite/index.html    # 이게 되면 권한은 정상

# 표준 권한
sudo chown -R www-data:www-data /var/www/mysite
sudo find /var/www/mysite -type d -exec chmod 755 {} \;
sudo find /var/www/mysite -type f -exec chmod 644 {} \;
SELinux (RHEL·Rocky 계열) — 권한이 다 맞는데도 403 이 난다면 이것입니다.
sudo ausearch -m avc -ts recent 로 차단 로그를 확인하고, sudo restorecon -Rv /var/www/mysite 로 컨텍스트를 복구하세요. 프록시를 쓸 때는 sudo setsebool -P httpd_can_network_connect 1 도 필요합니다. SELinux 를 끄는 것은 마지막 수단이며 권장되지 않습니다.
포트가 열렸다는 말은 네 곳이 모두 허락했다는 뜻입니다 브라우저 :443 요청 ① 클라우드 보안그룹 AWS SG · 방화벽 규칙 ② 서버 방화벽 ufw · firewalld ③ listen 웹서버가 대기 ①·②에서 막히면 연결이 한참 기다리다 실패합니다 타임아웃 — 거절 메시지가 없습니다 ③에서 막히면 연결 거부가 즉시 돌아옵니다 듣는 프로세스가 없다는 뜻입니다 ④ 1024 미만 포트 80·443 은 관리자 권한이 필요합니다 "Permission denied" 로 안 뜹니다 확인 순서는 언제나 안쪽부터입니다 서버에서 curl localhost → 되면 방화벽 문제 · 안 되면 웹서버 문제. 이 한 번으로 범위가 절반으로 줄어듭니다
타임아웃이냐 즉시 거부냐만 봐도 어느 관문에서 막혔는지 갈립니다.
포트가 열렸다는 말은 네 곳이 모두 허락했다는 뜻입니다 브라우저 :443 요청 ① 클라우드 보안그룹 AWS SG · 방화벽 규칙 ② 서버 방화벽 ufw · firewalld ③ listen 웹서버가 대기 ①·②에서 막히면 연결이 한참 기다리다 실패합니다 타임아웃 — 거절 메시지가 없습니다 ③에서 막히면 연결 거부가 즉시 돌아옵니다 듣는 프로세스가 없다는 뜻입니다 ④ 1024 미만 포트 80·443 은 관리자 권한이 필요합니다 "Permission denied" 로 안 뜹니다 확인 순서는 언제나 안쪽부터입니다 서버에서 curl localhost → 되면 방화벽 문제 · 안 되면 웹서버 문제. 이 한 번으로 범위가 절반으로 줄어듭니다
타임아웃이냐 즉시 거부냐만 봐도 어느 관문에서 막혔는지 갈립니다.
SECTION 09

로그 읽기

문제가 생기면 로그부터 보세요. 추측으로 설정을 고치는 것보다 훨씬 빠릅니다.

로그내용위치 (Ubuntu 기준)
access log누가 무엇을 요청했고 어떻게 응답했나/var/log/nginx/access.log
error log왜 실패했나 — 문제 해결의 1순위/var/log/nginx/error.log
Apache같은 개념/var/log/apache2/{access,error}.log
Tomcat앱 표준출력 + 접근 로그$CATALINA_HOME/logs/catalina.out
systemd서비스 기동 실패 원인journalctl -u nginx -n 50

access 로그 한 줄 해부

192.0.2.10 - - [05/Aug/2026:14:32:11 +0900] "GET /api/orders?page=2 HTTP/1.1" 200 1543 "https://example.com/" "Mozilla/5.0 …"
└─ 접속 IP ─┘                └─ 시각 ─┘        └── 요청 ──┘  └상태┘└크기┘ └─ 어디서 왔나 ─┘ └─ 브라우저 ─┘
# 실시간으로 보기
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log

# 상태 코드별 집계 ← 가장 자주 쓰는 분석
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

# 500 대 오류만
awk '$9 ~ /^5/' /var/log/nginx/access.log | tail -50

# 가장 많이 요청된 경로 상위 20
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# 특정 IP 의 요청만
grep '^192.0.2.10' /var/log/nginx/access.log

# 요청이 많은 IP 상위 10 (어뷰징 탐지)
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

error 로그의 심각도

레벨의미
emerg · alert서버가 못 뜨는 수준. 기동 실패는 여기
crit · error요청 처리 실패. 502·504 의 실제 원인이 여기 적힙니다
warn동작은 하지만 주의가 필요
notice · info · debug상세 정보. 평소에는 끄고 문제 추적 시에만
# 자주 보게 되는 error 로그와 그 뜻
connect() failed (111: Connection refused) while connecting to upstream
  → 뒤쪽 앱이 안 떠 있습니다 (502 의 원인)

upstream timed out (110: Connection timed out) while reading response header
  → 앱이 너무 느립니다 (504 의 원인)

open() "/var/www/html/x.png" failed (13: Permission denied)
  → 파일 권한 문제 (403)

open() "/var/www/html/x.png" failed (2: No such file or directory)
  → 경로가 틀렸습니다 (404)

client intended to send too large body
  → 업로드 용량 제한 (413) — client_max_body_size
error 로그의 메시지를 그대로 검색하세요. Nginx·Apache 의 오류 메시지는 정형화돼 있어 메시지 한 줄이면 원인과 해결책이 바로 나옵니다. 추측으로 설정을 바꾸기 전에 이 습관부터 들이세요.
SECTION 10

리버스 프록시란

웹서버가 요청을 받아 뒤쪽 앱에 대신 물어보고 결과를 돌려주는 구조입니다. 현대 웹 구성의 기본형입니다.

정방향 프록시 (Forward Proxy)
[사내 PC] → [프록시] → 인터넷

· 클라이언트를 대신해 나감
· 회사 인터넷 통제 · VPN
· 서버는 프록시의 IP 를 봄
리버스 프록시 (Reverse Proxy)
인터넷 → [Nginx] → [앱 서버들]

· 서버를 대신해 받음
· 클라이언트는 뒤에 뭐가 있는지 모름
· 이 가이드가 다루는 것
# 가장 단순한 리버스 프록시
server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;         # 뒤쪽 앱

        # 앱이 원래 요청 정보를 알 수 있게 헤더로 넘겨줍니다
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

리버스 프록시가 해 주는 일

역할설명
앱 은닉앱의 실제 포트·주소가 외부에 노출되지 않습니다
TLS 종료HTTPS 복호화를 앞단에서 처리 — 앱은 평문 HTTP 만 다루면 됩니다
부하 분산앱 서버 여러 대에 요청을 나눠 줍니다
정적 파일 분리이미지·CSS 는 웹서버가 직접 — 앱 부담이 줄어듭니다
버퍼링느린 클라이언트가 앱 워커를 붙잡지 못하게 막습니다
경로별 라우팅/api 는 자바, /admin 은 파이썬처럼 한 도메인에 여러 앱
공통 처리압축·캐시·보안 헤더·rate limit 을 한 곳에서

전형적인 구성

                     ┌─────────────────────────────────────┐
사용자 ──HTTPS──▶  │ Nginx (443)                          │
                     │  · TLS 종료                          │
                     │  · /static/  → 파일 직접 서빙        │
                     │  · /api/     → 앱으로 프록시         │
                     │  · 압축 · 캐시 · 보안 헤더 · 로그    │
                     └────────┬────────────────────────────┘
                              │ HTTP (사설망)
                     ┌────────▼────────┐  ┌────────────────┐
                     │ 앱 서버 1 (3000)│  │ 앱 서버 2 (3000)│
                     └────────┬────────┘  └───────┬────────┘
                              └──────┬────────────┘
                                ┌────▼─────┐
                                │    DB     │
                                └───────────┘
앱은 127.0.0.1 에만 바인드하세요. 앱이 0.0.0.0:3000 으로 떠 있으면 프록시를 우회해 직접 접속할 수 있어 보안 설정이 전부 무력화됩니다. 같은 서버에 있다면 127.0.0.1, 다른 서버면 사설망 IP + 방화벽으로 제한하세요.
리버스 프록시 — 대신 받아서 뒤로 넘깁니다 브라우저 한 주소만 압니다 리버스 프록시 HTTPS · 압축 · 캐시 · 제한 앱 서버 1 앱 서버 2 정적 파일 · 다른 기술의 서비스 뒤쪽을 늘리거나 교체해도 밖에서는 변화가 없습니다 무중단 배포 · 언어 전환 · 서비스 분리를 밖에 알리지 않고 할 수 있습니다 대신 실제 클라이언트 IP 가 가려집니다 — X-Forwarded-For 로 전달해 줘야 합니다 포워드 프록시는 반대입니다 — 클라이언트를 대신해 밖으로 나가는 쪽(사내 프록시)입니다
현대 웹 구성의 거의 모든 그림에 이 중간 층이 있습니다.
SECTION 11

오류 사전

증상이나 오류 코드로 검색하세요. 원인과 해결책이 바로 나옵니다.

증상으로 찾기
증상원인과 해결
502 Bad Gateway 뒤쪽 앱이 응답하지 않습니다. ① 앱이 떠 있나(ss -lntp) ② 포트가 맞나 ③ proxy_pass 주소 확인 ④ error 로그의 connect() failed 확인 ⑤ SELinux 라면 httpd_can_network_connect
504 Gateway Timeout 앱이 제 시간에 응답하지 못했습니다. ① 앱 처리 시간을 먼저 확인(대개 DB 쿼리) ② 임시로 proxy_read_timeout 을 늘릴 수 있지만 근본 해결은 앱 최적화
403 Forbidden 권한 문제. ① 파일·폴더 권한(755/644) ② 상위 폴더까지 접근 가능한지 ③ 웹서버 실행 계정 확인 ④ SELinux 컨텍스트 ⑤ Apache 라면 Require all granted
404 Not Found 경로가 틀렸습니다. ① root 와 실제 파일 위치 확인 ② root 와 alias 를 혼동하지 않았는지 ③ SPA 라면 try_files $uri /index.html 필요
413 Request Entity Too Large 업로드 용량 제한. Nginx 기본값이 1MB 입니다. client_max_body_size 50m; 을 추가하고 앱 쪽 제한도 함께 올리세요
400 Bad Request 헤더·쿠키가 너무 크거나 형식이 잘못됨. large_client_header_buffers 4 16k; 로 완화
upstream sent too big header 앱이 보낸 응답 헤더가 버퍼보다 큼(세션·쿠키가 많을 때). proxy_buffer_size 16k; proxy_busy_buffers_size 24k;
Address already in use 그 포트를 다른 프로세스가 쓰고 있습니다. sudo ss -lntp | grep :80 으로 찾아 정리하거나 포트를 바꾸세요
서버가 기동되지 않음 nginx -t · apachectl configtest 로 문법을 먼저 검사하고, journalctl -xeu nginx 로 상세 원인을 확인하세요
브라우저 인증서 경고 ① 만료 확인 ② 도메인 불일치 ③ 중간 인증서 누락(PC 는 되는데 모바일만 오류) → 🔐 HTTPS 탭 s08
한글이 깨짐 charset utf-8; 추가. HTML 의 <meta charset> 도 확인
로그에 프록시 IP 만 찍힘 X-Forwarded-For 를 전달하고 앱·웹서버에서 신뢰 설정 → 🟩 Nginx n08 · 🧬 전문가 z10
WebSocket 연결이 끊김 Upgrade·Connection 헤더 전달과 proxy_read_timeout 연장 필요 → 🟩 Nginx n17
리다이렉트 무한 반복 앞단에서 HTTPS 를 끝냈는데 앱이 그것을 모르는 경우. X-Forwarded-Proto 전달 + 앱 설정 → 🐱 Tomcat t13
전체적으로 느림 로그에 응답 시간을 추가해 웹서버·앱·DB 중 어디인지 먼저 특정 → ⚡ 성능 탭 p13
too many open files 동시 연결이 한계를 넘었습니다. worker_rlimit_nofile 과 systemd LimitNOFILE → ⚡ 성능 p10
진단의 기본 순서 — ① error 로그를 봅니다 ② curl -v 로 실제 응답을 확인합니다 ③ 앱에 직접 요청해 봅니다(curl localhost:3000) — 프록시 문제인지 앱 문제인지 갈립니다 ④ 설정을 최소 상태로 되돌려 하나씩 다시 붙입니다
오류 코드는 '어디를 보라'는 안내입니다 브라우저 웹서버 (Nginx) 앱 (WAS) DB 웹서버 구간에서 나는 것 403 권한·경로 — 파일 퍼미션과 root 경로 확인 404 그 위치에 파일이 없음 — try_files·alias 확인 뒤쪽 앱 구간에서 나는 것 502 앱이 죽었거나 포트가 다름 — 앱부터 확인 504 앱이 응답을 못 함 — 느린 쿼리·타임아웃 500 은 조금 다릅니다 앱이 살아 있고 응답도 했는데 그 안에서 예외가 난 것 웹서버 로그가 아니라 앱 로그를 봐야 합니다 먼저 볼 로그 403·404 → 웹서버 error.log · 502·504 → 둘 다 500 → 앱 로그. 순서만 지켜도 시간이 크게 줄어듭니다
숫자를 외우는 것이 아니라 "앞이냐 뒤냐"만 가르면 됩니다.
오류 코드는 '어디를 보라'는 안내입니다 브라우저 웹서버 (Nginx) 앱 (WAS) DB 웹서버 구간에서 나는 것 403 권한·경로 — 파일 퍼미션과 root 경로 확인 404 그 위치에 파일이 없음 — try_files·alias 확인 뒤쪽 앱 구간에서 나는 것 502 앱이 죽었거나 포트가 다름 — 앱부터 확인 504 앱이 응답을 못 함 — 느린 쿼리·타임아웃 500 은 조금 다릅니다 앱이 살아 있고 응답도 했는데 그 안에서 예외가 난 것 웹서버 로그가 아니라 앱 로그를 봐야 합니다 먼저 볼 로그 403·404 → 웹서버 error.log · 502·504 → 둘 다 500 → 앱 로그. 순서만 지켜도 시간이 크게 줄어듭니다
숫자를 외우는 것이 아니라 "앞이냐 뒤냐"만 가르면 됩니다.
SECTION 12

학습 로드맵

목적에 따라 필요한 탭이 다릅니다. 최단 경로를 정리했습니다.

목적순서
개인 프로젝트 배포 🧭 시작 → 🔒 Caddy (또는 🟩 Nginx n01~n07) → 🔐 HTTPS → 🔗 언어별 연동
백엔드 개발자 🧭 시작 → 🟩 Nginx 전부 → 🔐 HTTPS → 🔗 언어별 연동 → ⚡ 성능 → 🛡️ 운영
자바 개발자 🧭 시작 → 🐱 Tomcat 전부 → 🟩 Nginx n07·n08·n09 → 🔐 HTTPS → ⚡ 성능
인프라 · DevOps 🟩 Nginx → ⚡ 성능 전부 → 🛡️ 보안·운영 전부 → 🧬 전문가
레거시 유지보수 🦅 Apache 전부 → 🧭 시작 w09·w11 → 🔐 HTTPS
면접 준비 🧭 w02·w10 → 🟩 n01·n05·n07·n09 → 🧬 z02·z05·z11 → 🧬 z14

4주 학습 계획 (하루 1시간 기준)

1주차
기본기🧭 시작 탭 전부. 실제로 서버를 띄우고 내 HTML 을 서비스해 보는 것이 핵심입니다.
2주차
Nginx 와 프록시🟩 n01~n09. 앱을 뒤에 두고 프록시하는 구성을 손으로 만들어 보세요.
3주차
HTTPS 와 배포🔐 HTTPS 전부 + 🔗 내가 쓰는 언어. 도메인을 하나 사서 실제로 붙여 보면 가장 좋습니다.
4주차
성능과 운영⚡ 성능 + 🛡️ 보안·운영. 부하 테스트로 한계를 재 보고 보안 헤더를 적용합니다.
가장 좋은 학습 방법 — 도메인 하나를 사서(연 1~2만원) 실제로 배포해 보세요. 로컬에서만 하면 DNS·인증서·방화벽·실제 트래픽을 경험할 수 없습니다. 저렴한 클라우드 인스턴스 하나면 이 가이드의 거의 모든 내용을 실습할 수 있습니다.
같이 보면 좋은 가이드 — 앱이 쓰는 데이터베이스는 🗄️ 데이터베이스 가이드에서 설치부터 튜닝까지 다룹니다. 언어별 심화는 🐍 파이썬 · ☕ 자바 · 🟨 JS · TS 가이드를 보세요.
NGINX 1.28 · 리버스 프록시 · 로드밸런싱 · 캐시

Nginx —
앞단의
표준

오늘날 웹 인프라의 앞단은 대부분 Nginx 입니다. 정적 파일 · 리버스 프록시 · HTTPS · 로드밸런싱 · 캐시 · 접근 제어를 한 프로그램이 처리합니다. 이 탭은 설정 문법부터 시작해 실무에서 실제로 쓰는 구성을 순서대로 쌓아 올립니다.

18SECTIONS
1.28기준 버전
복붙바로 적용
SECTION 01

마스터 · 워커 구조

Nginx가 적은 메모리로 수만 연결을 감당하는 이유가 여기 있습니다. 연결마다 스레드를 만들지 않습니다.

master
지휘자 — root 로 실행 80·443 포트를 잡고, 설정을 읽고, 워커를 만들고 관리합니다. 요청을 직접 처리하지는 않습니다.
▼ fork
worker × N
일꾼 — 일반 계정(www-data)으로 실행 실제 요청을 처리합니다. 보통 CPU 코어 수만큼 만들고, 워커 하나가 수천 개 연결을 동시에 다룹니다.
ps aux | grep nginx
# root     1234  nginx: master process /usr/sbin/nginx
# www-data 1235  nginx: worker process
# www-data 1236  nginx: worker process
# www-data 1237  nginx: cache manager process

왜 이 구조가 빠른가

전통적 방식 (연결당 스레드)
연결 10,000개 = 스레드 10,000개

· 스레드마다 메모리(수백 KB~수 MB)
· 컨텍스트 스위칭 비용 폭증
· 대부분의 스레드는 그냥 '기다리는 중'
Nginx (이벤트 기반)
연결 10,000개 = 워커 4개

· 각 워커가 epoll 로 "준비된 연결"만 처리
· 기다리는 연결은 자원을 거의 안 씀
· 메모리 사용량이 연결 수에 거의 비례하지 않음
대가도 있습니다 — 워커가 블로킹 작업(느린 디스크 읽기 등)을 만나면 그 워커가 담당하던 다른 수천 개 연결이 함께 멈춥니다. 그래서 Nginx 는 무거운 작업을 절대 직접 하지 않고 뒤로 넘기는 설계를 택합니다. (디스크 IO 는 aio·thread_pool 로 분리할 수 있습니다)
# /etc/nginx/nginx.conf
user  www-data;
worker_processes  auto;            # CPU 코어 수만큼 자동 (권장)
worker_rlimit_nofile  65535;       # 워커당 열 수 있는 파일·소켓 수

events {
    worker_connections  4096;      # 워커 하나가 다룰 최대 연결
    multi_accept  on;              # 한 번에 여러 연결을 받아들임
    use epoll;                     # 리눅스 기본값 (명시하지 않아도 됨)
}

# 이론상 최대 동시 연결 = worker_processes × worker_connections
# 단, 프록시는 클라이언트 1 + 업스트림 1 = 연결 2개를 씁니다
마스터 하나 · 워커 여럿 — 역할이 다릅니다 마스터 프로세스 설정 읽기 · 포트 열기 · 워커 관리 워커 1 연결 수천 개를 혼자 이벤트 방식으로 워커 2 요청은 여기서 처리 마스터는 관여 안 함 워커 3 보통 코어 수만큼 더 늘려도 이득 없음 설정을 다시 읽으면 새 워커를 띄우고 옛 워커는 처리 중인 요청을 마친 뒤 사라집니다 그래서 nginx -s reload 는 끊김이 없습니다 — restart 와 다릅니다 동시 연결 한도는 워커 수 × worker_connections 입니다 — 파일 디스크립터 한계와 함께 보세요 워커가 이벤트 방식이라 요청마다 스레드를 만들지 않습니다 — 적은 메모리로 많이 받는 이유입니다
"Nginx 가 가볍다" 는 말의 실제 내용이 이 구조입니다.
SECTION 02

설치와 디렉터리

파일이 어디에 있는지만 알면 절반은 끝납니다.

경로내용
/etc/nginx/nginx.conf주 설정 파일 (전역)
/etc/nginx/conf.d/*.conf추가 설정 — RHEL 계열은 여기에 사이트 설정
/etc/nginx/sites-available/Ubuntu 계열 — 사이트 설정 원본
/etc/nginx/sites-enabled/활성화된 사이트 (위 폴더로의 심볼릭 링크)
/var/www/html기본 문서 루트
/var/log/nginx/access.log · error.log
/etc/nginx/mime.types확장자 ↔ Content-Type 매핑
# 사이트 활성화 · 비활성화 (Ubuntu 계열)
sudo ln -s /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default          # 기본 사이트 제거

# 검증 → 반영 (이 순서를 반드시 지키세요)
sudo nginx -t
sudo systemctl reload nginx

# 지금 적용 중인 전체 설정 보기 (include 를 전부 펼침)
sudo nginx -T | less

# 버전과 컴파일 옵션
nginx -V
공식 저장소를 쓰면 최신 버전을 받을 수 있습니다. 배포판 기본 저장소의 Nginx 는 버전이 낮은 경우가 많습니다(HTTP/3 미지원 등). nginx.org 의 공식 저장소를 등록하면 mainline 또는 stable 최신 버전을 쓸 수 있습니다. 다만 배포판 패키지와 경로 구조가 조금 다르므로 문서를 확인하세요.
Nginx 가 요청을 처리하는 구조 — 왜 연결이 많아도 가벼운가 마스터 프로세스 root 로 실행 · 설정 읽기 80·443 포트 열기 워커 1 — 연결 수천 개 워커 2 — 연결 수천 개 워커 N (보통 CPU 코어 수) 워커 하나 안에서 이벤트 루프가 준비된 소켓만 골라 처리 연결 하나에 스레드 하나를 쓰지 않습니다 reload 를 하면 — 무중단의 원리 마스터가 새 설정을 검증 틀리면 아무 일도 안 일어남 새 워커를 띄워 새 요청 받기 여기부터 새 설정 적용 옛 워커는 남은 요청만 마치고 스스로 종료 그래서 reload 는 연결을 하나도 끊지 않습니다 — restart 와 달리 다운타임이 0 입니다. 설정이 틀리면 새 워커가 안 떠서 옛 워커가 그대로 서비스합니다. nginx -t 를 먼저 치는 이유가 여기 있습니다. "워커를 늘리면 빨라진다"는 오해입니다 — 코어 수를 넘기면 문맥 전환만 늘어납니다(auto 가 정답).
Apache 의 prefork 가 연결 하나에 프로세스 하나였던 것과 대비됩니다 — 이것이 같은 하드웨어에서 동시 연결 수가 다른 이유입니다.

막혔을 때 먼저 칠 명령

알고 싶은 것명령
설정 문법이 맞나sudo nginx -t
최종 설정이 어떻게 되나sudo nginx -T — include 를 전부 펼칩니다
어느 파일 몇 번째 줄인가nginx -t 의 오류 메시지에 그대로 나옵니다
이 모듈이 있나nginx -V 2>&1 | tr ' ' '\n' | grep module
왜 404 인가tail -f /var/log/nginx/error.log — 찾으려던 실제 경로가 찍힙니다
어느 server 블록이 처리했나로그 포맷에 $server_name 을 추가
업스트림이 얼마나 걸렸나로그에 $upstream_response_time
지금 연결 수curl localhost/nginx_status (stub_status 필요)
포트를 누가 잡고 있나sudo ss -tlnp | grep :80
# 진단에 필요한 값을 로그에 미리 넣어 두세요 — 사고 뒤에 넣으면 늦습니다
log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                  '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                  'rt=$request_time urt=$upstream_response_time '        # ★ 어디가 느린가
                  'us=$upstream_status ua=$upstream_addr '               # ★ 어느 백엔드
                  'srv=$server_name host=$host';                          # ★ 어느 블록

access_log /var/log/nginx/access.log main;

# 상태 페이지 — 로컬에서만
server {
    listen 127.0.0.1:8080;
    location /nginx_status {
        stub_status;
        access_log off;
    }
}
$request_time 과 $upstream_response_time 을 같이 남기면 범인을 즉시 가릅니다. 둘이 비슷하면 백엔드가 느린 것이고, $request_time 만 크면 클라이언트 쪽 네트워크나 큰 응답 전송입니다. 이 두 값이 없으면 "Nginx 가 느린가 앱이 느린가"를 놓고 몇 시간을 허비하게 됩니다.
SECTION 03

설정 문법

문법은 단순합니다 — 지시어와 블록 둘뿐입니다.

http {                                  # ── 컨텍스트(블록)
    gzip on;                            # ── 지시어 (세미콜론 필수)
    keepalive_timeout 65;

    server {                            # 안쪽 블록은 바깥 설정을 물려받습니다
        listen 80;
        server_name example.com;
        gzip off;                       # 여기서 다시 쓰면 이 server 안에서는 off

        location /api/ {
            proxy_pass http://app:3000;
        }
    }
}
컨텍스트담는 것
main (최상위)user · worker_processes · pid · error_log
events연결 처리 방식 — worker_connections
httpHTTP 전역 — 압축·로그 포맷·타임아웃·업스트림
server사이트 하나 — 포트·도메인·인증서
locationURL 경로별 처리
upstream백엔드 서버 그룹 (http 안에)
streamTCP·UDP 프록시 (HTTP 가 아닌 것 — DB·SMTP 등)

자주 쓰는 내장 변수

변수값
$host요청의 Host 헤더 (없으면 server_name)
$remote_addr접속한 클라이언트 IP
$request_uri원본 URI 전체 (쿼리스트링 포함)
$uri정규화된 URI (rewrite 로 바뀔 수 있음)
$args쿼리스트링
$schemehttp 또는 https
$request_time요청 전체 처리 시간 — 로그에 꼭 넣으세요
$upstream_response_time뒤쪽 앱이 쓴 시간 — 병목 판별의 핵심
$status응답 상태 코드
$http_user_agent임의 요청 헤더 — $http_<헤더명> 형식
가장 흔한 문법 실수
· 세미콜론 누락 — nginx: [emerg] directive "x" is not terminated by ";"
· 중괄호 짝이 안 맞음 — unexpected end of file
· 같은 지시어를 한 블록에 두 번 — directive is duplicate
· 경로 끝 슬래시 유무 — 특히 proxy_pass·alias 에서 동작이 완전히 달라집니다
고칠 때마다 nginx -t 를 습관으로 만드세요.
SECTION 04

server 블록 · 가상 호스트

서버 한 대에서 도메인 여러 개를 서비스합니다. 비용을 크게 줄이는 구성입니다.

# 사이트 A
server {
    listen 80;
    server_name a.example.com;
    root /var/www/a;
}

# 사이트 B
server {
    listen 80;
    server_name b.example.com www.b.example.com;    # 여러 개 가능
    root /var/www/b;
}

# 어디에도 안 걸리는 요청 처리 (직접 IP 접속·봇 등)
server {
    listen 80 default_server;
    server_name _;
    return 444;               # 아무 응답 없이 연결을 끊습니다 (Nginx 전용 코드)
}

server_name 매칭 우선순위

순위형태예
1정확히 일치example.com
2* 로 시작하는 와일드카드*.example.com
3* 로 끝나는 와일드카드www.example.*
4정규식 (설정 파일에 나온 순서)~^(?<sub>.+)\.example\.com$
5아무것도 안 걸리면default_server 로 지정된 블록
default_server 를 반드시 두세요. 지정하지 않으면 설정 파일에서 첫 번째로 나온 server 블록이 기본이 됩니다. 그래서 IP로 직접 접속하거나 알 수 없는 Host 헤더로 오는 요청이 엉뚱한 사이트로 연결됩니다. 봇 스캔에서 실제로 자주 일어납니다.

공통 설정을 파일로 빼기

# /etc/nginx/snippets/proxy-common.conf
proxy_set_header Host              $host;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_read_timeout 60s;

# 사용하는 쪽
location /api/ {
    include /etc/nginx/snippets/proxy-common.conf;
    proxy_pass http://app:3000;
}
같은 설정을 여러 사이트에 복붙하지 마세요. snippets/ 로 빼 두면 한 곳만 고쳐도 전부 반영됩니다. 보안 헤더·프록시 헤더·SSL 설정이 대표적인 대상입니다.
요청 하나가 어느 설정에 걸리는가 — 세 단계로 좁혀집니다 ① listen IP · 포트로 후보 추림 ② server_name Host 헤더와 대조 → server ③ location — 우선순위로 하나 선택 파일에 적힌 순서가 아닙니다 location 이 골라지는 실제 순서 1위 location = /path 정확히 일치 — 찾으면 즉시 확정, 더 안 봅니다 2위 location ^~ /path 가장 긴 접두사가 이것이면 정규식을 건너뜁니다 3위 location ~ /re 정규식 — 여기서만 "파일에 적힌 순서"가 의미를 가집니다 (첫 일치 채택) 4위 location /path 일반 접두사 — 정규식이 하나도 안 맞으면 가장 긴 것이 채택됩니다 "위에 썼는데 아래 블록이 먹는다"는 혼란은 전부 이 표를 몰라서 생깁니다 — 접두사는 길이로, 정규식만 순서로 결정됩니다.
^~ 는 "여기서 멈춰라" 라는 뜻입니다 — 정적 파일 경로에 붙여 두면 아래의 정규식 location 이 가로채는 것을 막습니다.
server {
    listen 443 ssl default_server;
    http2 on;
    server_name example.com;
    root /var/www/example;

    # ① 정확히 일치 — 가장 빠릅니다. 호출이 잦은 경로에
    location = / {
        try_files /index.html =404;
    }
    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    # ② ^~ — 정적 자원을 정규식이 가로채지 못하게 막습니다
    location ^~ /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # ③ 정규식 — 여기서만 파일 순서가 중요합니다
    location ~* \.(?:jpg|jpeg|png|gif|webp|svg|woff2)$ {
        expires 30d;
        add_header Cache-Control "public";
    }

    # 숨김 파일 차단 (.git · .env) — 반드시 넣으세요
    location ~ /\.(?!well-known) {
        deny all;
        access_log off;
    }

    # ④ 일반 접두사 — 가장 긴 것이 이깁니다
    location /api/ {
        include /etc/nginx/snippets/proxy-common.conf;
        proxy_pass http://app:3000;          # 끝 슬래시 주의 (아래 warn)
    }

    location / {
        try_files $uri $uri/ /index.html;    # SPA 폴백
    }
}
proxy_pass 의 끝 슬래시가 경로를 바꿉니다.
location /api/ { proxy_pass http://app:3000/; } → /api/users 가 /users 로 (접두사 제거)
location /api/ { proxy_pass http://app:3000; } → /api/users 가 /api/users 로 (그대로)
백엔드가 404 를 뱉는데 원인을 못 찾겠다면 대부분 이 슬래시 하나입니다. $upstream_addr 과 백엔드 접근 로그를 함께 보면 즉시 확인됩니다.
try_files $uri $uri/ /index.html; 을 API 가 있는 서버에 그대로 쓰면 위험합니다. 존재하지 않는 API 경로가 404 대신 index.html(200) 을 돌려주게 되어, 클라이언트가 JSON 을 기대하다 HTML 을 파싱하며 이상한 에러를 냅니다. /api/ 는 별도 location 으로 먼저 잡아 프록시로 넘기세요 — 위 예시가 그 구조입니다.
SECTION 05

location 매칭 규칙

Nginx 설정에서 가장 많이 헷갈리는 부분입니다. 순서대로 위에서 아래로 찾는 것이 아닙니다 — 우선순위 규칙이 따로 있습니다.

server {
    location = /images/logo.png { … }      # ① 정확히 일치 (최우선 · 즉시 확정)
    location ^~ /static/       { … }       # ② 접두사 + 정규식 건너뛰기
    location ~ \.(jpg|png)$    { … }       # ④ 정규식 (대소문자 구분)
    location ~* \.(jpg|png)$   { … }       # ④ 정규식 (대소문자 무시)
    location /api/             { … }       # ③ 일반 접두사
    location /                 { … }       # ③ 가장 짧은 접두사 = 최후의 보루
}
경로를 눌러 어떤 location 이 걸리는지 확인

요청 경로

선택된 location

설정된 location 목록

①= /images/logo.png
②^~ /static/
④~ \.(jpg|png)$
④~* \.(jpg|png)$
③/api/
③/

규칙을 말로 풀면

  1. = 로 정확히 일치하는 것이 있으면 거기서 끝입니다
  2. 없으면 가장 길게 일치하는 접두사를 기억해 둡니다
  3. 그 접두사가 ^~ 였다면 정규식을 아예 보지 않고 확정합니다
  4. 아니면 정규식들을 설정 파일에 적힌 순서대로 검사해 처음 일치하는 것을 씁니다
  5. 정규식에 하나도 안 걸리면 2에서 기억해 둔 접두사 매칭을 씁니다
실무 요령
· 정적 파일 경로에는 ^~ 를 붙이세요 — 불필요한 정규식 검사를 건너뜁니다
· = 는 아주 자주 호출되는 한 경로에 (예: = /health, = /favicon.ico)
· 정규식은 최소한으로 — 개수가 많아지면 매 요청마다 순회 비용이 듭니다
· location / 는 반드시 하나 두세요 (없으면 404)
흔한 함정 — location /api(슬래시 없음)는 /apitest 도 걸립니다. 의도한 것이 /api/ 하위라면 끝에 슬래시를 붙이거나 location ^~ /api/ 로 명확히 하세요.
location 은 적힌 순서가 아니라 우선순위로 고릅니다 1순위 — location = /path (정확히 일치) 맞으면 즉시 결정됩니다. 자주 불리는 단일 경로에 쓰면 검사 비용도 줄어듭니다 2순위 — location ^~ /path (접두 일치, 정규식보다 우선) 정규식 블록에 뺏기고 싶지 않은 정적 경로에 씁니다 (예: /static/) 3순위 — location ~ · ~* (정규식, 위에서부터) 여기서만 순서가 중요합니다 — 먼저 맞는 것이 이깁니다 4순위 — location /path (가장 길게 일치한 것) 위에서 아무것도 안 걸렸을 때 쓰입니다 자주 겪는 일 — 확장자 정규식 블록이 /static/ 보다 먼저 걸려 프록시로 가 버리는 경우 어떤 블록이 걸렸는지는 로그 포맷에 변수를 넣어 확인하는 것이 가장 빠릅니다
"설정은 맞는데 왜 안 되지" 의 상당수가 다른 블록이 먼저 걸린 것입니다.
SECTION 06

정적 파일 · try_files

Nginx가 가장 잘하는 일입니다. SPA 새로고침 404 문제도 여기서 해결합니다.

root 와 alias — 가장 헷갈리는 한 쌍

root — 경로를 이어 붙입니다
location /static/ {
    root /var/www;
}

# 요청: /static/app.css
# 실제: /var/www + /static/app.css
#     = /var/www/static/app.css
alias — 경로를 바꿔치기합니다
location /static/ {
    alias /var/www/assets/;      # ★ 끝 슬래시 필수
}

# 요청: /static/app.css
# 실제: /var/www/assets/app.css
#     (location 부분이 alias 로 치환)
alias 는 끝 슬래시를 반드시 맞춰야 합니다. location /static/ 에는 alias /path/; (양쪽 다 슬래시). 한쪽만 있으면 경로가 어긋나 404가 납니다. 헷갈리면 root 를 쓰고 폴더 구조를 맞추는 편이 안전합니다.

try_files — 순서대로 찾기

# 파일 → 폴더 → 404
location / {
    root /var/www/html;
    try_files $uri $uri/ =404;
}

# ★ SPA (React·Vue) — 없는 경로는 전부 index.html 로
location / {
    root /var/www/spa;
    try_files $uri $uri/ /index.html;
}

# 파일이 없으면 앱으로 넘기기 (하이브리드)
location / {
    root /var/www/html;
    try_files $uri $uri/ @app;
}
location @app {
    proxy_pass http://127.0.0.1:3000;
}
SPA 배포의 정석 — React·Vue 로 만든 앱은 /about 같은 경로에 실제 파일이 없습니다(라우팅이 브라우저에서 일어남). 그래서 새로고침하면 404 가 납니다. try_files $uri $uri/ /index.html; 한 줄이 이 문제를 해결합니다.

정적 파일 캐시 설정

# 해시가 붙은 빌드 산출물 — 아주 길게 캐시
location ~* \.(?:css|js|woff2?)$ {
    root /var/www/spa;
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;                       # 정적 파일 로그는 꺼서 IO 절약
}

# 이미지
location ~* \.(?:png|jpe?g|gif|svg|ico|webp|avif)$ {
    root /var/www/spa;
    expires 30d;
    add_header Cache-Control "public";
}

# index.html 은 절대 캐시하면 안 됩니다 (새 배포가 반영되지 않습니다)
location = /index.html {
    root /var/www/spa;
    add_header Cache-Control "no-store, must-revalidate";
}
배포했는데 옛 화면이 보이는 이유가 대부분 이것입니다. index.html 이 캐시되면 새로 배포한 JS 파일을 가리키는 링크 자체가 갱신되지 않습니다.
· 해시가 붙은 파일(app.a1b2c3.js) → 영구 캐시
· index.html → 캐시 금지
이 조합이 프런트엔드 배포의 표준입니다.
SECTION 07

proxy_pass

요청을 뒤쪽 앱으로 넘깁니다. 주소 끝의 슬래시 하나가 동작을 완전히 바꿉니다.

슬래시의 차이 — 반드시 알아야 합니다

슬래시 없음 — 경로가 그대로 붙음
location /api/ {
    proxy_pass http://app:3000;
}

# 요청  : /api/users
# 전달  : http://app:3000/api/users
#         (location 경로가 그대로 유지)
슬래시 있음 — location 부분이 잘림
location /api/ {
    proxy_pass http://app:3000/;
}

# 요청  : /api/users
# 전달  : http://app:3000/users
#         (/api/ 가 / 로 치환)
외우는 법 — proxy_pass 뒤에 경로(슬래시 포함)가 있으면 치환, 호스트만 있으면 그대로 전달입니다. 앱이 /api 접두사를 모른다면 슬래시를 붙이고, 앱도 /api 로 라우팅한다면 붙이지 마세요.

실무에서 쓰는 완전한 형태

location /api/ {
    proxy_pass http://127.0.0.1:3000;

    # ── 원래 요청 정보를 앱에 전달 (n08 에서 자세히)
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;

    # ── HTTP/1.1 + keepalive (기본은 1.0 이라 매번 새 연결을 맺습니다)
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    # ── 타임아웃 (기본 60초)
    proxy_connect_timeout 5s;        # 연결 수립 — 짧게
    proxy_send_timeout   60s;
    proxy_read_timeout   60s;        # 앱 응답 대기 — 504 의 기준

    # ── 버퍼링 (기본 on)
    proxy_buffering on;
    proxy_buffer_size 8k;
    proxy_buffers 8 8k;
}

경로별로 다른 앱에 보내기

server {
    listen 80;
    server_name example.com;

    # 프런트엔드 정적 파일
    location / {
        root /var/www/spa;
        try_files $uri $uri/ /index.html;
    }

    # 자바 API
    location /api/ {
        proxy_pass http://127.0.0.1:8080;
        include /etc/nginx/snippets/proxy-common.conf;
    }

    # 파이썬 관리자
    location /admin/ {
        proxy_pass http://127.0.0.1:8000;
        include /etc/nginx/snippets/proxy-common.conf;
    }

    # 업로드된 파일 (앱을 거치지 않고 직접)
    location /uploads/ {
        alias /var/data/uploads/;
        add_header Content-Disposition "attachment";     # 브라우저에서 실행되지 않게
    }
}
주의할 점 두 가지
① 업스트림 주소에 변수를 쓰면 Nginx 가 시작 시점에 DNS 를 확인하지 않고 요청마다 해석합니다. resolver 지시어가 필요하고, 없으면 오류가 납니다.
② 도커 컴포즈 환경에서 앱 컨테이너가 재시작되면 IP 가 바뀝니다. Nginx 는 시작 시점의 IP 를 캐시하므로 502 가 계속 날 수 있습니다 — resolver 127.0.0.11 valid=10s; 와 변수 사용으로 해결합니다.
proxy_pass 끝의 / 한 글자가 경로를 바꿉니다 들어온 요청: /api/users/7 ① 대상에 경로가 없을 때 — 그대로 이어 붙습니다 location /api/ { proxy_pass http://app:8080; } 뒤쪽이 받는 경로 /api/users/7 ② 대상 끝에 / 가 있을 때 — location 부분이 잘립니다 location /api/ { proxy_pass http://app:8080/; } 뒤쪽이 받는 경로 /users/7 ← /api/ 가 사라졌습니다 앱이 /api 를 포함한 경로를 기대하는지 아닌지에 맞춰 고르면 됩니다 — 404 가 나면 이것부터 의심하세요
Nginx 설정에서 가장 많이 틀리는 한 글자입니다 — 뒤쪽 로그의 경로를 보면 바로 확인됩니다.
proxy_pass 끝의 / 한 글자가 경로를 바꿉니다 들어온 요청: /api/users/7 ① 대상에 경로가 없을 때 — 그대로 이어 붙습니다 location /api/ { proxy_pass http://app:8080; } 뒤쪽이 받는 경로 /api/users/7 ② 대상 끝에 / 가 있을 때 — location 부분이 잘립니다 location /api/ { proxy_pass http://app:8080/; } 뒤쪽이 받는 경로 /users/7 ← /api/ 가 사라졌습니다 앱이 /api 를 포함한 경로를 기대하는지 아닌지에 맞춰 고르면 됩니다 — 404 가 나면 이것부터 의심하세요
Nginx 설정에서 가장 많이 틀리는 한 글자입니다 — 뒤쪽 로그의 경로를 보면 바로 확인됩니다.
SECTION 08

X-Forwarded 헤더

프록시를 거치면 앱은 Nginx의 IP만 보게 됩니다. 진짜 방문자 정보를 알려면 헤더로 넘겨야 합니다.

헤더전달하는 정보
Host원래 요청한 도메인 — 앱이 URL 을 만들 때 필요합니다
X-Real-IP클라이언트 IP (바로 앞 단계)
X-Forwarded-For거쳐 온 IP 목록 — 클라이언트, 프록시1, 프록시2
X-Forwarded-Proto원래 http 였나 https 였나 — 리다이렉트 루프의 원인
X-Forwarded-Host원래 Host (프록시가 Host 를 바꾼 경우)
X-Forwarded-Port원래 포트
proxy_set_header Host              $host;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# $proxy_add_x_forwarded_for 는
#   기존 X-Forwarded-For 값이 있으면 ", $remote_addr" 를 덧붙이고
#   없으면 $remote_addr 만 넣습니다
X-Forwarded-Proto 를 안 넘기면 리다이렉트 무한 루프가 납니다.
① 브라우저가 HTTPS 로 접속 → ② Nginx 가 HTTP 로 앱에 전달 → ③ 앱이 "HTTP 네? HTTPS 로 가세요" 라며 리다이렉트 → ④ ①로 되돌아감
Spring Boot·Django·Rails 모두 이 헤더를 보고 판단합니다.

앱 쪽 설정도 필요합니다

Spring Boot
server.forward-headers-strategy=native
# 또는 = framework
# 프록시 뒤에 있을 때 반드시 설정하세요
Django
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
ALLOWED_HOSTS = ['example.com']
Express (Node)
app.set('trust proxy', 1);   // 앞단 프록시 1개를 신뢰
// req.ip · req.protocol 이 올바르게 동작합니다
FastAPI · Uvicorn
uvicorn main:app \
  --proxy-headers \
  --forwarded-allow-ips='127.0.0.1'
보안 — 헤더는 위조할 수 있습니다. 클라이언트가 직접 X-Forwarded-For: 1.2.3.4 를 보내면 앱이 그것을 진짜 IP 로 믿을 수 있습니다.
· Nginx 는 클라이언트가 보낸 값 뒤에 실제 IP 를 덧붙이므로, 앱은 맨 오른쪽부터 신뢰할 수 있는 프록시를 걷어내며 읽어야 합니다
· 또는 Nginx 에서 proxy_set_header X-Forwarded-For $remote_addr; 로 덮어써서 위조를 차단할 수 있습니다(프록시가 하나일 때)
자세한 내용은 🧬 전문가 z10 참고.
# 앞단에 CDN·LB 가 또 있을 때 : 신뢰할 대역을 지정해 실제 IP 를 복원
set_real_ip_from 10.0.0.0/8;          # 사내 LB 대역
set_real_ip_from 173.245.48.0/20;     # CDN 대역 (예시)
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# 이제 $remote_addr 자체가 진짜 클라이언트 IP 가 됩니다 (로그·rate limit 에 그대로 적용)
SECTION 09

로드 밸런싱 upstream

앱 서버를 여러 대로 늘려 트래픽을 나누고, 한 대가 죽어도 서비스를 유지합니다.

upstream backend {
    # 기본은 라운드 로빈 (순서대로 돌아가며)
    server 10.0.1.11:3000 weight=3;              # 3배 더 많이 받음
    server 10.0.1.12:3000;
    server 10.0.1.13:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.14:3000 backup;                # 다른 서버가 전부 죽으면 투입

    keepalive 32;                                # ★ 업스트림 연결 재사용
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";          # ★ keepalive 를 쓰려면 필수
        include /etc/nginx/snippets/proxy-common.conf;
    }
}

분산 방식

방식동작쓰는 곳
라운드 로빈 (기본)순서대로 돌아가며무상태 앱 — 대부분의 경우
least_conn연결 수가 가장 적은 서버로요청 처리 시간이 들쭉날쭉할 때
ip_hash클라이언트 IP 로 서버를 고정세션이 서버에 저장될 때 (권장하지 않음)
hash $key지정한 값으로 해시캐시 적중률을 높일 때
random two둘을 무작위로 뽑아 덜 바쁜 쪽서버가 아주 많을 때
ip_hash 로 세션을 유지하는 것은 임시방편입니다.
· 모바일은 IP 가 자주 바뀌어 로그인이 풀립니다
· NAT 뒤의 수천 명이 한 IP로 잡혀 분산이 안 됩니다
· 서버를 늘리거나 줄이면 해시가 재배치돼 전부 로그아웃됩니다
세션은 Redis 같은 외부 저장소에 두고 앱을 무상태로 만드는 것이 정답입니다.

장애 감지

upstream backend {
    server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
    # 30초 안에 3번 실패하면 30초간 그 서버를 제외합니다 (수동적 헬스체크)
}

server {
    location / {
        proxy_pass http://backend;

        # 어떤 상황에서 다음 서버로 재시도할지
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 10s;
    }
}
재시도 대상에 http_500 을 넣지 마세요. 500 은 앱 코드의 오류이므로 다른 서버에서도 똑같이 실패합니다. 재시도는 부하만 늘립니다. 또 POST 같은 변경 요청을 재시도하면 중복 처리가 될 수 있으니 non_idempotent 를 함부로 넣지 마세요.
능동적 헬스체크(주기적으로 /health 를 찔러 보는 방식)는 상용판 Nginx Plus 기능입니다. 오픈소스에서는 ① 수동적 감지(max_fails)를 쓰거나 ② nginx_upstream_check_module 을 넣어 빌드하거나 ③ 쿠버네티스·클라우드 LB 의 헬스체크에 맡깁니다.
로드밸런싱을 본격적으로 하려면 ⚖️ 로드밸런싱 탭으로. 세션 문제(로그인 풀림)·HAProxy·무중단 배포·LB 이중화까지 확장의 전 과정을 따로 정리해 뒀습니다.
SECTION 10

HTTPS · HTTP/2 · HTTP/3

인증서 파일 두 개를 지정하면 끝입니다. 발급 방법은 🔐 HTTPS 탭에서 다룹니다.

# HTTP → HTTPS 리다이렉트 전용 서버
server {
    listen 80;
    server_name example.com www.example.com;

    # 인증서 갱신 요청은 통과시켜야 합니다
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# 실제 서비스
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;                                    # 1.25.1+ 문법
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;               # TLS1.3 에서는 클라이언트 우선이 권장
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;

    ssl_session_cache   shared:SSL:10m;          # 재접속 시 협상 생략 → 지연 감소
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # OCSP 스테이플링 — ※ Let's Encrypt 는 2025년에 OCSP 를 종료해
    #   LE 인증서에는 OCSP 주소가 없습니다 (켜 두면 경고만 남고 무시됨)
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    root /var/www/html;
}
listen 443 ssl http2; 는 옛 문법입니다. Nginx 1.25.1 부터 http2 on; 을 별도 지시어로 씁니다. 옛 문법도 아직 동작하지만 경고가 나오며 향후 제거될 수 있습니다.

HTTP/3 (QUIC) 1.25+

server {
    listen 443 ssl;
    listen 443 quic reuseport;          # ★ UDP 443 — 방화벽도 열어야 합니다
    http2 on;
    http3 on;

    ssl_protocols TLSv1.3;              # HTTP/3 는 TLS1.3 필수

    # 브라우저에게 "HTTP/3 로도 접속할 수 있다"고 알림
    add_header Alt-Svc 'h3=":443"; ma=86400';
}
# UDP 443 을 여는 것을 잊지 마세요
sudo ufw allow 443/udp

# 지원 여부 확인
nginx -V 2>&1 | grep -o with-http_v3_module
HTTP/2·3 은 언제 효과가 있나
· HTTP/2 — 작은 파일이 많은 페이지에서 효과가 큽니다(요청을 한 연결에 다중화). 이미 번들링된 SPA 라면 체감 차이가 작을 수 있습니다
· HTTP/3 — 불안정한 모바일 네트워크에서 특히 유리합니다(패킷 손실에 강함). 유선 환경에서는 차이가 크지 않습니다
· 둘 다 HTTPS 가 전제입니다
자세한 원리는 🧬 전문가 z05 참고.
SECTION 11

rewrite · 리다이렉트

주소를 바꿔 보내거나 내부에서 다시 처리합니다.

# ── return : 가장 단순하고 빠릅니다 (가능하면 이것을 쓰세요)
server {
    listen 80;
    server_name old.example.com;
    return 301 https://new.example.com$request_uri;      # 영구 이동
}

# www 를 붙이거나 떼기
server {
    server_name www.example.com;
    return 301 https://example.com$request_uri;
}

# ── rewrite : 정규식으로 경로를 바꿉니다
location / {
    rewrite ^/old/(.*)$ /new/$1 permanent;      # 301 리다이렉트 (브라우저 주소가 바뀜)
    rewrite ^/api/v1/(.*)$ /v1/$1 last;         # 내부 재처리 (주소는 그대로)
}
플래그동작
last재작성 후 location 매칭을 다시 수행 (내부 처리)
break재작성 후 현재 location 안에서 계속 진행
redirect302 임시 리다이렉트 (브라우저에 응답)
permanent301 영구 리다이렉트
301 과 302 를 정확히 구분하세요.
· 301 (영구) — 브라우저와 검색엔진이 캐시합니다. 한 번 잘못 설정하면 사용자 브라우저에서 지우기가 매우 어렵습니다
· 302 (임시) — 캐시되지 않습니다. 로그인 후 이동 등에 씁니다
도메인 이전은 301(SEO 를 위해), 테스트 중에는 302 로 시작하세요.

조건부 처리 — if 는 조심해서

# Nginx 의 if 는 location 안에서 예상 밖으로 동작할 수 있습니다
# (공식 위키에도 "If is Evil" 문서가 있습니다)

# ✗ 피할 것
location / {
    if ($http_user_agent ~* "bot") {
        return 403;
    }
    proxy_pass http://app;      # if 안에서 다른 지시어와 섞이면 문제가 생깁니다
}

# ✓ map 을 쓰세요 (http 컨텍스트에 정의)
map $http_user_agent $is_bot {
    default        0;
    "~*bot|crawl"  1;
}
server {
    location / {
        if ($is_bot) { return 403; }        # return 만 쓰는 if 는 안전합니다
        proxy_pass http://app;
    }
}
안전하게 쓸 수 있는 if — return 과 rewrite … last 뿐입니다. 그 외 지시어(proxy_pass·add_header 등)를 if 안에 넣으면 예상과 다르게 동작하거나 조용히 무시될 수 있습니다. 조건 분기는 map 이나 별도 location 으로 푸는 것이 정석입니다.
SECTION 12

gzip · brotli 압축

텍스트 응답을 압축하면 전송량이 1/3 이하로 줄어듭니다. 가장 값싼 최적화입니다.

# http 컨텍스트에
gzip on;
gzip_vary on;                    # ★ Vary: Accept-Encoding — 캐시가 압축본과 원본을 구분
gzip_comp_level 5;               # 1~9 · 5~6 이 적정 (9는 CPU 낭비)
gzip_min_length 1024;            # 1KB 미만은 압축해도 이득이 없습니다
gzip_proxied any;                # 프록시를 거친 요청도 압축
gzip_types
    text/plain text/css text/xml text/javascript
    application/json application/javascript application/xml+rss
    application/atom+xml image/svg+xml
    application/vnd.ms-fontobject font/ttf font/otf;
# text/html 은 항상 압축되므로 목록에 넣지 않습니다
이미 압축된 것을 또 압축하지 마세요. JPG·PNG·MP4·ZIP·woff2 는 이미 압축된 형식이라 다시 압축하면 CPU 만 쓰고 크기는 오히려 늘어날 수 있습니다. gzip_types 에 이미지·동영상을 넣지 마세요.

brotli — gzip 보다 15~20% 더 작게

# 모듈이 필요합니다 (nginx.org 공식 패키지 또는 직접 빌드)
brotli on;
brotli_comp_level 5;
brotli_min_length 1024;
brotli_types text/plain text/css application/json application/javascript image/svg+xml;

# gzip 과 함께 두면 브라우저가 지원하는 쪽을 자동으로 씁니다
# 모듈 설치 (nginx.org 저장소 기준)
sudo apt install -y nginx-module-brotli

# nginx.conf 최상단에
# load_module modules/ngx_http_brotli_filter_module.so;
# load_module modules/ngx_http_brotli_static_module.so;

미리 압축해 두기 (정적 파일)

# 빌드 시점에 app.js.gz · app.js.br 을 함께 만들어 두면
# 요청마다 압축하지 않고 그 파일을 그대로 보냅니다 (CPU 0)
gzip_static on;
brotli_static on;
# 빌드 후
find dist -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' \) \
  -exec gzip -9 -k {} \; -exec brotli -9 -k {} \;
# 압축이 실제로 적용되는지 확인
curl -H "Accept-Encoding: gzip" -I https://example.com/app.js | grep -i content-encoding
# content-encoding: gzip
압축 레벨은 5~6이 최적입니다. 9로 올려도 크기는 1~2% 줄어드는데 CPU 는 몇 배를 씁니다. 트래픽이 많은 서버에서는 압축이 CPU 병목이 될 수 있으니 정적 파일은 미리 압축해 두는 방식을 권합니다.
SECTION 13

proxy_cache

Nginx가 응답을 기억해 뒀다가 그대로 돌려줍니다. 백엔드 부하를 수십 분의 일로 줄일 수 있습니다.

# http 컨텍스트에 캐시 저장소 정의
proxy_cache_path /var/cache/nginx
                 levels=1:2
                 keys_zone=app_cache:100m      # 키 저장용 공유 메모리 (100MB ≈ 80만 개)
                 max_size=10g                  # 디스크 최대 사용량
                 inactive=60m                  # 60분간 접근 없으면 삭제
                 use_temp_path=off;

server {
    location /api/products {
        proxy_cache app_cache;
        proxy_cache_key "$scheme$request_method$host$request_uri";

        proxy_cache_valid 200 302  10m;        # 정상 응답은 10분
        proxy_cache_valid 404       1m;
        proxy_cache_valid any       30s;

        # 백엔드가 죽어도 옛 캐시로 응답 (아주 중요한 장치)
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_background_update on;      # 만료된 캐시를 주면서 뒤에서 갱신
        proxy_cache_lock on;                   # 동시 요청 중 하나만 백엔드로 (스탬피드 방지)

        add_header X-Cache-Status $upstream_cache_status;   # HIT · MISS · EXPIRED · STALE

        proxy_pass http://backend;
    }
}
# 캐시가 먹는지 확인
curl -I https://example.com/api/products | grep -i x-cache-status
# X-Cache-Status: MISS   ← 첫 요청
# X-Cache-Status: HIT    ← 두 번째부터

캐시하면 안 되는 것

# 로그인 사용자별 응답은 절대 캐시하면 안 됩니다
# (다른 사람의 개인정보가 노출되는 심각한 사고가 됩니다)

proxy_cache_bypass $http_authorization $cookie_sessionid;   # 이 값이 있으면 캐시를 건너뜀
proxy_no_cache     $http_authorization $cookie_sessionid;   # 저장도 하지 않음

# 또는 캐시 키에 사용자 구분을 넣습니다 (적중률은 떨어집니다)
proxy_cache_key "$scheme$request_method$host$request_uri$cookie_sessionid";
가장 위험한 사고 유형 — 로그인한 A 사용자의 응답이 캐시되어 B 사용자에게 그대로 전달되는 것입니다. 실제로 대형 서비스에서 여러 번 발생한 사고입니다.
원칙 — 개인화된 응답은 캐시 대상에서 명시적으로 제외하고, 캐시 대상은 화이트리스트로 관리하세요.

캐시 무효화

# 오픈소스 Nginx 에는 선택적 삭제(purge) 기능이 없습니다
# ① 파일을 직접 지우기 (가장 단순)
sudo find /var/cache/nginx -type f -delete
sudo systemctl reload nginx

# ② 캐시 키에 버전을 넣어 통째로 갈아치우기
# proxy_cache_key "v2:$scheme$request_method$host$request_uri";

# ③ ngx_cache_purge 모듈을 넣어 빌드 (서드파티)
# ④ 짧은 TTL + stale-while-revalidate 로 운영
캐시 설계의 기본 원칙 — 무효화가 어려우므로 TTL 을 짧게 잡는 편이 안전합니다. "10분 지난 데이터를 보여도 괜찮은가"를 기능별로 판단하세요. 상품 목록은 5분, 재고 수량은 캐시 금지 같은 식입니다.
캐시는 '키가 같으면 뒤에 묻지 않는다'가 전부입니다 요청 GET /news 캐시 키로 조회 $scheme$host$request_uri HIT — 뒤에 묻지 않고 바로 응답 MISS — 뒤에서 받아 저장한 뒤 응답 무엇을 캐시할지 정합니다 proxy_cache_valid 200 10m · 404 1m 로그인 사용자별 응답은 캐시하면 안 됩니다 — 쿠키가 있으면 우회 몰릴 때 뒤를 지키는 두 줄 proxy_cache_lock on — 같은 키는 하나만 뒤로 use_stale updating — 갱신 중엔 옛 응답을 줍니다 뒤쪽이 죽어도 서비스가 이어집니다 — proxy_cache_use_stale error timeout 을 켜면 만료된 응답이라도 계속 내보냅니다 확인은 응답 헤더로 — add_header X-Cache-Status $upstream_cache_status 를 붙이면 HIT·MISS 가 눈에 보입니다
캐시 한 겹이 뒤쪽 부하를 수분의 일로 줄입니다 — 다만 무엇을 담을지가 전부입니다.
캐시는 '키가 같으면 뒤에 묻지 않는다'가 전부입니다 요청 GET /news 캐시 키로 조회 $scheme$host$request_uri HIT — 뒤에 묻지 않고 바로 응답 MISS — 뒤에서 받아 저장한 뒤 응답 무엇을 캐시할지 정합니다 proxy_cache_valid 200 10m · 404 1m 로그인 사용자별 응답은 캐시하면 안 됩니다 — 쿠키가 있으면 우회 몰릴 때 뒤를 지키는 두 줄 proxy_cache_lock on — 같은 키는 하나만 뒤로 use_stale updating — 갱신 중엔 옛 응답을 줍니다 뒤쪽이 죽어도 서비스가 이어집니다 — proxy_cache_use_stale error timeout 을 켜면 만료된 응답이라도 계속 내보냅니다 확인은 응답 헤더로 — add_header X-Cache-Status $upstream_cache_status 를 붙이면 HIT·MISS 가 눈에 보입니다
캐시 한 겹이 뒤쪽 부하를 수분의 일로 줄입니다 — 다만 무엇을 담을지가 전부입니다.
SECTION 14

업로드 용량 · 타임아웃

파일 업로드가 실패하는 원인은 거의 정해져 있습니다.

server {
    # 업로드 최대 크기 (기본값 1m — 매우 작습니다)
    client_max_body_size 50m;

    # 요청 본문을 읽는 타임아웃
    client_body_timeout 120s;
    client_header_timeout 30s;

    # 본문 버퍼 (넘으면 임시 파일에 씁니다)
    client_body_buffer_size 128k;
    client_body_temp_path /var/cache/nginx/client_temp;

    location /upload {
        proxy_pass http://app:3000;

        # 프록시 타임아웃도 함께 늘려야 합니다
        proxy_send_timeout 300s;
        proxy_read_timeout 300s;

        # 큰 업로드는 버퍼링을 끄고 스트리밍으로 넘기는 편이 나을 수 있습니다
        proxy_request_buffering off;
    }
}
증상확인할 곳
413 Request Entity Too Large client_max_body_size — Nginx 에서 막힌 것입니다
업로드 중 끊김 client_body_timeout · proxy_read_timeout
Nginx 는 통과했는데 실패 앱 쪽 제한 — Spring max-file-size · PHP upload_max_filesize · Django DATA_UPLOAD_MAX_MEMORY_SIZE
디스크가 가득 참 임시 파일 경로 확인 — 큰 업로드는 디스크에 먼저 씁니다
메모리 부족 proxy_request_buffering off 로 스트리밍 전달
Nginx 와 앱 양쪽 제한을 함께 올려야 합니다. Nginx 만 올리면 앱에서 다시 막히고, 앱만 올리면 Nginx 에서 413 이 납니다. 둘 다 확인하는 것이 원칙입니다.

대용량 파일은 우회하는 것이 정답

수백 MB~GB 파일을 웹서버와 앱을 거쳐 올리는 것은 메모리·디스크·타임아웃 모두에 부담입니다. 실무에서는 클라이언트가 스토리지에 직접 업로드하는 방식을 씁니다.

① 브라우저 → 앱 : "파일 올릴 건데 업로드 주소 좀"
② 앱 → 브라우저 : 서명된 임시 업로드 URL (presigned URL)
③ 브라우저 → S3 등 스토리지 : 파일을 직접 전송  ← 서버를 거치지 않습니다
④ 브라우저 → 앱 : "다 올렸어요, 이 키로 저장됐습니다"

· 서버 부하 0 · 타임아웃 문제 없음 · 확장에 유리
SECTION 15

로그 포맷 커스터마이징

기본 로그에는 응답 시간이 없습니다. 성능 분석을 하려면 반드시 추가해야 합니다.

# http 컨텍스트에
log_format main_ext '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    'rt=$request_time uct=$upstream_connect_time '
                    'uht=$upstream_header_time urt=$upstream_response_time '
                    'cache=$upstream_cache_status host=$host';

access_log /var/log/nginx/access.log main_ext;
변수의미
$request_time요청 전체 시간 — 클라이언트가 체감한 시간
$upstream_connect_time백엔드 연결 수립 시간
$upstream_header_time백엔드가 첫 응답 헤더를 줄 때까지 — 앱 처리 시간
$upstream_response_time백엔드 응답 완료까지
$upstream_cache_statusHIT · MISS · BYPASS · EXPIRED
$upstream_addr실제로 어느 백엔드로 갔나 (LB 디버깅에 필수)
$request_time 과 $upstream_response_time 의 차이가 핵심입니다.
· 둘 다 크면 → 앱이 느린 것
· request_time 만 크면 → 클라이언트 네트워크가 느린 것(응답 전송이 오래 걸림)
이 구분만으로 "서버가 느리다"는 민원의 절반이 정리됩니다.

JSON 로그 — 수집 도구와 연동할 때

log_format json_log escape=json
  '{'
    '"time":"$time_iso8601",'
    '"remote_addr":"$remote_addr",'
    '"method":"$request_method",'
    '"uri":"$request_uri",'
    '"status":$status,'
    '"bytes":$body_bytes_sent,'
    '"referer":"$http_referer",'
    '"ua":"$http_user_agent",'
    '"request_time":$request_time,'
    '"upstream_time":"$upstream_response_time",'
    '"host":"$host",'
    '"request_id":"$request_id"'
  '}';

access_log /var/log/nginx/access.json json_log;
escape=json 을 반드시 넣으세요. 없으면 User-Agent 에 큰따옴표가 들어올 때 JSON 이 깨져 로그 수집기가 통째로 파싱에 실패합니다.

로그를 줄이는 방법

# 정적 파일·헬스체크는 기록하지 않기
location = /health { access_log off; return 200 "ok\n"; }
location ~* \.(css|js|png|jpg|svg|woff2)$ { access_log off; expires 1y; }

# 특정 조건만 기록 (map 으로 판단)
map $status $loggable {
    ~^[23]  0;      # 2xx · 3xx 는 기록 안 함
    default 1;
}
access_log /var/log/nginx/errors.log main_ext if=$loggable;

# 버퍼링으로 디스크 IO 줄이기 (트래픽이 아주 많을 때)
access_log /var/log/nginx/access.log main_ext buffer=32k flush=5s;
SECTION 16

rate limit

같은 IP가 폭주하면 자동으로 속도를 제한합니다. 로그인·API 보호의 기본입니다.

# http 컨텍스트에 존을 정의합니다
limit_req_zone  $binary_remote_addr zone=general:10m rate=30r/s;   # 일반 API
limit_req_zone  $binary_remote_addr zone=login:10m   rate=5r/m;    # 로그인 (분당 5회)
limit_conn_zone $binary_remote_addr zone=conn:10m;                 # 동시 연결 수

limit_req_status  429;                    # 기본은 503 — 429 가 의미상 정확합니다
limit_conn_status 429;

server {
    # 전체에 완만한 제한
    limit_req  zone=general burst=60 nodelay;
    limit_conn conn 20;

    # 로그인은 강하게
    location = /api/login {
        limit_req zone=login burst=3 nodelay;
        proxy_pass http://app;
    }

    # 헬스체크는 제외 — limit_req 에는 off 가 없습니다.
    # return 은 rewrite 단계에서 끝나므로 limit_req(preaccess 단계)까지 가지 않습니다
    location = /health {
        return 200 "ok\n";
    }
}
옵션의미
rate=30r/s초당 30건 — 내부적으로는 33ms 에 1건 꼴로 균등하게 봅니다
burst=60순간적으로 60건까지는 큐에 넣어 허용
nodelay버스트 분을 지연 없이 즉시 처리 (없으면 순차 지연)
delay=2020건까지는 즉시, 그 이후 버스트는 지연 (절충안)
$binary_remote_addrIP 를 이진으로 저장 — 문자열보다 메모리를 적게 씁니다
프록시 뒤에 있다면 $binary_remote_addr 가 프록시 IP 입니다. 그러면 모든 사용자가 하나로 묶여 정상 트래픽까지 차단됩니다. set_real_ip_from 으로 실제 IP 를 복원하거나(n08), $http_x_forwarded_for 를 키로 쓰되 위조 가능성을 감안하세요.

그 밖의 방어 수단

# 대역폭 제한 (다운로드 서버)
location /downloads/ {
    limit_rate_after 10m;        # 처음 10MB 는 최고 속도로
    limit_rate 500k;             # 이후 500KB/s 로 제한
}

# 특정 User-Agent 차단
map $http_user_agent $blocked_ua {
    default              0;
    "~*(scrapy|curl|wget|python-requests)"  1;
}
server {
    if ($blocked_ua) { return 403; }
}

# 지역·IP 차단
location /admin/ {
    allow 10.0.0.0/8;
    allow 203.0.113.5;
    deny  all;
}
rate limit 은 만능이 아닙니다. 분산된 대규모 공격(DDoS)은 서버에 도달하기 전에 막아야 합니다 — CDN·클라우드 방화벽(WAF)·전용 서비스의 영역입니다. Nginx 의 rate limit 은 단일 IP 의 어뷰징과 크롤러 폭주를 막는 데 효과적입니다.
SECTION 17

WebSocket · SSE

실시간 통신을 프록시하려면 일반 HTTP와 다른 설정이 필요합니다. 기본 설정으로는 연결이 끊깁니다.

# http 컨텍스트 — Upgrade 헤더를 조건부로 만듭니다
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /ws/ {
        proxy_pass http://app:3000;

        # ★ WebSocket 핸드셰이크에 필수
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host       $host;

        # ★ 유휴 연결이 끊기지 않게 (기본 60초면 1분마다 끊깁니다)
        proxy_read_timeout  3600s;
        proxy_send_timeout  3600s;

        # 버퍼링을 끄지 않으면 메시지가 모였다가 한꺼번에 갑니다
        proxy_buffering off;
    }
}
WebSocket 이 자꾸 끊긴다면
① proxy_read_timeout 이 기본값(60초)이라 유휴 연결이 잘립니다
② 앱에서 주기적으로 ping·pong 을 보내 연결을 살려 두세요 (권장)
③ 앞단에 클라우드 LB 가 있으면 거기에도 타임아웃 설정이 있습니다
④ proxy_buffering off 를 빠뜨리면 메시지 전달이 지연됩니다

SSE (Server-Sent Events)

location /events {
    proxy_pass http://app:3000;
    proxy_http_version 1.1;
    proxy_set_header Connection '';       # keepalive 유지

    proxy_buffering off;                  # ★ 필수 — 없으면 이벤트가 안 나갑니다
    proxy_cache off;
    proxy_read_timeout 3600s;

    chunked_transfer_encoding off;
    # add_header X-Accel-Buffering no;  ← 효과 없음: 브라우저에게 보낼 뿐입니다.
    #   이 헤더는 앱(업스트림)이 응답에 실어 보내야 Nginx 가 읽고 버퍼링을 끕니다
}
X-Accel-Buffering: no — 앱이 이 응답 헤더를 보내면 그 응답만 Nginx 버퍼링이 꺼집니다. location 전체를 바꾸지 않고 스트리밍 엔드포인트만 예외 처리할 때 유용합니다.

gRPC 프록시

server {
    listen 443 ssl;
    http2 on;                             # gRPC 는 HTTP/2 필수

    location / {
        grpc_pass grpc://app:50051;       # 평문
        # grpc_pass grpcs://app:50051;    # TLS
        grpc_read_timeout 3600s;
    }
}
WebSocket 이 프록시에서 자꾸 실패하는 이유 — 전환이 안 일어납니다 브라우저 GET /ws Upgrade: websocket Nginx (기본 설정) HTTP/1.0 으로 프록시하며 홉바이홉 헤더를 버립니다 백엔드 Upgrade 를 못 봄 → 그냥 200 · 404 결과: 연결 실패 또는 즉시 끊김 세 줄을 넣으면 — 같은 TCP 연결이 양방향 통로로 바뀝니다 proxy_http_version 1.1 Upgrade · Connection 전달 101 Switching Protocols 여기서 HTTP 가 끝납니다 양방향 프레임 통신 연결이 유지되는 동안 계속 101 이후에는 요청·응답이 없습니다 — 그래서 proxy_read_timeout 기본 60초에 유휴 연결이 잘립니다. 앱에서 ping·pong 을 주기적으로 보내는 것이 근본 해결입니다. 타임아웃만 늘리면 죽은 연결이 쌓입니다.
Caddy 가 이 설정 없이 동작하는 것은 기본값이 다르기 때문입니다 — 원리는 같습니다.

실시간 세 방식 — 무엇을 언제 쓰나

방식방향프록시 설정맞는 곳
WebSocket양방향Upgrade·Connection·http_version 1.1채팅 · 협업 편집 · 게임 로비
SSE서버 → 클라proxy_buffering off알림 · 진행률 · 실시간 대시보드 — 훨씬 단순합니다
롱 폴링서버 → 클라(흉내)proxy_read_timeout 만 길게구형 환경 호환
gRPC 스트림양방향grpc_pass + HTTP/2서비스 간 통신
서버가 일방적으로 밀어 주기만 하면 되는 경우, SSE 가 WebSocket 보다 낫습니다. 일반 HTTP 응답이라 프록시·CDN·방화벽을 그대로 통과하고, 브라우저가 자동으로 재연결하며(Last-Event-ID 로 이어받기까지), 프록시 설정도 proxy_buffering off 한 줄이면 끝납니다. 클라이언트가 서버에 자주 보내야 할 때만 WebSocket 을 고르세요.

실시간 연결이 끊길 때 — 확인 순서

증상원인과 확인
연결 자체가 안 됨 (101 이 안 옴)Upgrade 헤더 3종 누락 — curl -i -H "Upgrade: websocket" ...
정확히 60초마다 끊김proxy_read_timeout 기본값
정확히 여러 분마다 끊김앞단 클라우드 LB 의 유휴 타임아웃(ALB 기본 60초 등)
메시지가 몰려서 한꺼번에 옴proxy_buffering 이 켜져 있음
일부 사용자만 실패기업 프록시가 Upgrade 를 막음 → SSE 로 폴백 준비
서버 재시작 후 전원 재접속 폭주클라이언트에 지수 백오프 + 지터를 넣으세요
연결 수가 계속 늘어남죽은 연결 미정리 — 앱에서 ping 응답 없으면 끊기
# 연결이 많아지면 워커 한계도 함께 올려야 합니다
events {
    worker_connections 10240;      # 워커당 동시 연결 (기본 512~1024)
    use epoll;
}
worker_processes  auto;            # 코어 수
worker_rlimit_nofile 65535;        # ★ 이것을 안 올리면 위 값이 의미 없습니다

# 총 동시 연결 ≈ worker_processes × worker_connections
# 단, 프록시는 연결 하나당 소켓 2개(클라·업스트림)를 씁니다 → 실제로는 절반

http {
    # 업스트림 keepalive — 백엔드로의 연결을 재사용합니다
    upstream app {
        server 127.0.0.1:3000;
        keepalive 64;              # ★ 이것만으로 지연이 눈에 띄게 줄어듭니다
        keepalive_timeout 60s;
    }
}
SECTION 18

무중단 재시작 · 검증

설정을 바꿀 때 접속을 끊지 않는 것이 기본입니다. 그리고 검증 없이 반영하지 마세요.

명령동작
nginx -t★ 문법 검사 — 반영 전 반드시
nginx -T최종 설정 전체 출력 (include 펼침)
nginx -s reload
systemctl reload nginx
★ 무중단 반영 — 새 워커를 띄우고 옛 워커는 처리 중인 요청을 마친 뒤 종료
systemctl restart nginx완전 재시작 — 순간 접속이 끊깁니다
nginx -s quit정상 종료 (처리 중인 요청을 마치고)
nginx -s stop즉시 종료 (진행 중 요청도 끊김)
nginx -s reopen로그 파일 다시 열기 (로테이션 후)

reload 는 어떻게 무중단이 되나

1
마스터가 새 설정을 읽습니다문법 오류가 있으면 여기서 멈추고 기존 상태를 유지합니다.
▼
2
새 설정으로 새 워커를 만듭니다새로 들어오는 요청은 새 워커가 받습니다.
▼
3
옛 워커는 새 요청을 받지 않고처리 중인 요청을 끝까지 마친 뒤 스스로 종료합니다.
reload 로 반영되지 않는 것도 있습니다. user·worker_processes 같은 일부 main 컨텍스트 설정과 새로 로드하는 모듈은 restart 가 필요합니다. 바꿨는데 반영이 안 되면 이것을 의심하세요.

배포 전 검증 절차

#!/usr/bin/env bash
set -euo pipefail

# 1) 백업 — ★ 설정을 고치기 '전에' 따로 떠 둡니다
#    (고친 뒤에 백업하면 되돌릴 대상도 새 설정이 됩니다)
#    sudo cp -a /etc/nginx /etc/nginx.bak.$(date +%F-%H%M)

# 2) 문법 검사
sudo nginx -t

# 3) 반영
sudo systemctl reload nginx

# 4) 실제 응답 확인
sleep 1
curl -fsS -o /dev/null -w "%{http_code}\n" https://example.com/health

# 실패하면 되돌리기
# sudo rm -rf /etc/nginx && sudo mv /etc/nginx.bak.XXXX /etc/nginx && sudo systemctl reload nginx
nginx -t 가 통과해도 안심할 수는 없습니다. 문법 검사는 실제 동작·논리적 오류까지 잡지 못합니다.
· 인증서 파일은 읽히지만 만료·도메인 불일치 → 브라우저 경고
· proxy_pass 주소 오타 → 502
· location 우선순위 착각 → 엉뚱한 응답
반영 후 실제 요청으로 확인하는 단계를 반드시 넣으세요.
설정은 git 으로 관리하세요. /etc/nginx 를 저장소로 두면 누가 언제 무엇을 바꿨는지 남고 되돌리기도 한 명령입니다. 더 나아가 Ansible·Terraform 으로 관리하면 서버가 통째로 날아가도 재현할 수 있습니다 → 🛡️ 운영 탭 o10.
APACHE HTTP SERVER 2.4 · MPM · mod_rewrite · .htaccess

Apache —
가장 오래된
웹서버

1995년부터 웹을 떠받쳐 온 서버입니다. 신규 구축에서는 Nginx 에 자리를 내줬지만, 기존 시스템·워드프레스·공유 호스팅에는 여전히 압도적으로 많습니다. 실무에서 Apache 를 만나는 이유는 대부분 유지보수이므로, 이 탭은 남이 만든 설정을 읽고 고치는 것에 초점을 둡니다.

14SECTIONS
2.4기준 버전
변환표→ Nginx
SECTION 01

MPM — 세 가지 처리 방식

Apache 는 요청을 어떻게 나눠 처리할지 골라 쓸 수 있습니다. 이 선택이 메모리 사용량과 동시 처리 능력을 결정합니다.

MPM구조메모리동시성쓰는 곳
prefork요청당 프로세스 1개많음낮음 스레드 안전하지 않은 모듈(옛 mod_php)이 있을 때
worker프로세스 × 스레드중간중간 prefork 와 event 의 중간
eventworker + 유휴 연결 분리적음높음 ★ 2.4 기본값 — 특별한 이유가 없으면 이것
event MPM 이 개선한 것 — prefork·worker 는 keepalive 로 놀고 있는 연결도 프로세스·스레드를 붙잡고 있었습니다. event MPM 은 유휴 연결을 전담 스레드에 넘겨 작업 스레드를 풀어 줍니다. Nginx 의 이벤트 모델에 한 걸음 다가간 구조입니다.
다만 PHP 를 모듈(mod_php)로 쓰면 prefork 가 강제되므로, PHP-FPM 으로 분리해야 event 를 쓸 수 있습니다.
# 현재 MPM 확인
apachectl -V | grep MPM
# Server MPM:     event

# Ubuntu 계열에서 전환
sudo a2dismod mpm_prefork
sudo a2enmod mpm_event
sudo systemctl restart apache2

# ※ mod_php 가 켜져 있으면 event 로 전환되지 않습니다
sudo a2dismod php8.3
sudo a2enmod proxy_fcgi setenvif
sudo a2enconf php8.3-fpm
# mpm_event.conf — 동시 처리 설정
<IfModule mpm_event_module>
    StartServers             4
    MinSpareThreads         25
    MaxSpareThreads         75
    ThreadsPerChild         25
    # ★ 동시에 처리할 최대 요청 수
    MaxRequestWorkers      400
    # 0 = 무제한 (메모리 누수가 있으면 10000 등으로)
    MaxConnectionsPerChild   0
</IfModule>
MaxRequestWorkers 를 무작정 올리지 마세요. 각 워커가 메모리를 쓰므로 (워커 수 × 워커당 메모리) 가 물리 메모리를 넘으면 스왑이 발생해 서버가 통째로 느려집니다.
계산법 — ps 로 워커 하나의 실제 메모리를 재고, (가용 메모리 × 0.8) ÷ 워커당 메모리 를 상한으로 잡으세요.
MPM — 요청을 어떻게 나눠 처리할 것인가 prefork — 요청마다 프로세스 하나 서로 완전히 격리되어 안전하지만 메모리를 많이 씁니다 동시 처리 수가 적습니다 — PHP 를 모듈로 쓸 때 어쩔 수 없이 고르는 경우가 있습니다 worker — 프로세스 안에 스레드 여러 개 메모리를 훨씬 아낍니다 — 대신 모듈이 스레드 안전해야 합니다 연결을 유지하는 동안에도 스레드를 붙잡고 있습니다 event — 연결 유지를 별도로 관리 (요즘 기본값) keepalive 로 열려 있기만 한 연결은 스레드를 붙잡지 않습니다 동시 연결이 많아도 견딥니다 — Nginx 와의 격차가 크게 줄어든 지점입니다 확인은 apachectl -V 로 — 지금 어떤 MPM 으로 도는지 한 줄로 나옵니다 PHP 는 모듈 대신 PHP-FPM 으로 분리하세요 — event MPM 을 그대로 쓸 수 있습니다
아파치가 무겁다는 인식의 상당 부분은 prefork 시절의 기억입니다.
SECTION 02

설치와 디렉터리

배포판마다 이름과 경로가 다릅니다. 이것만 정리하면 문서를 보고 헤매지 않습니다.

항목Ubuntu · DebianRHEL · Rocky
패키지·서비스명apache2httpd
주 설정 파일/etc/apache2/apache2.conf/etc/httpd/conf/httpd.conf
사이트 설정sites-available/ + a2ensite/etc/httpd/conf.d/*.conf
모듈 설정mods-available/ + a2enmod/etc/httpd/conf.modules.d/
실행 계정www-dataapache
문서 루트/var/www/html/var/www/html
로그/var/log/apache2//var/log/httpd/
제어 명령apache2ctl · apachectlapachectl
# 설치
sudo apt install -y apache2          # Ubuntu
sudo dnf install -y httpd            # RHEL

sudo systemctl enable --now apache2  # 또는 httpd

# 상태 확인
apachectl -v                         # 버전
apachectl -V                         # 컴파일 정보 · MPM
apachectl -M                         # 로드된 모듈
apachectl -S                         # 가상 호스트 목록 ← 아주 유용합니다
apachectl configtest                 # 문법 검사

sudo systemctl reload apache2        # 무중단 반영
sudo apachectl graceful              # 같은 동작
apachectl -S 를 먼저 실행하세요. 가상 호스트가 여러 개일 때 어떤 요청이 어느 블록으로 가는지, 기본(default) 이 무엇인지 한눈에 보여 줍니다. "설정을 고쳤는데 반영이 안 된다"는 문제의 절반이 다른 VirtualHost 가 먼저 잡고 있어서입니다.
설정이 "어디에 있는지" 모르겠을 때 — 읽히는 순서가 곧 구조입니다 apache2.conf 주 설정 — 여기서 시작 mods-enabled 모듈 로드 conf-enabled 전역 공통 설정 sites-enabled/*.conf VirtualHost — 대개 여기 <Directory> · <Location> 경로별 규칙 .htaccess (요청마다 읽음) AllowOverride 가 허용할 때만 enabled 디렉터리는 available 디렉터리를 가리키는 심볼릭 링크입니다 — a2ensite·a2enmod 가 그 링크를 만듭니다. "고쳤는데 반영이 안 돼요" 의 대부분은 available 만 고치고 enable 을 안 했거나, 뒤에 읽힌 파일이 덮어쓴 경우입니다.
Nginx 의 sites-enabled 와 구조는 같지만, Apache 는 .htaccess 라는 한 층이 더 있습니다 — 편리한 만큼 느립니다.

막혔을 때 먼저 칠 명령

알고 싶은 것명령보이는 것
어느 VirtualHost 가 처리하나apachectl -S포트별 VirtualHost 목록 · 기본이 무엇인지
이 모듈이 켜져 있나apachectl -M | grep 이름로드된 모듈 전부
지금 MPM 이 무엇인가apachectl -V | grep MPMprefork · worker · event
설정이 최종적으로 어떻게 되나apachectl -t -D DUMP_CONFIGinclude 를 전부 펼친 결과
문법이 맞나apachectl configtest오류 파일명과 줄 번호
왜 403 이 나나tail -f /var/log/apache2/error.log거부 이유가 그대로 찍힙니다
지금 무엇을 처리 중인가apachectl status
(mod_status 필요)
워커별 상태 · 요청 URL
포트를 누가 잡고 있나sudo ss -tlnp | grep :80충돌하는 프로세스
"reload 했는데 안 바뀐다"의 원인 세 가지
① a2ensite 를 안 했다 — sites-available 만 고쳤습니다
② 다른 VirtualHost 가 먼저 매칭됐다 — apachectl -S 로 순서 확인
③ 브라우저가 301 리다이렉트를 캐시했다 — 시크릿 창이나 curl -I 로 확인
세 번째가 특히 자주 사람을 붙잡습니다. 301 은 브라우저가 영구 저장하므로, 테스트 중에는 302 로 두고 확정된 뒤에 301 로 바꾸는 편이 안전합니다.
SECTION 03

설정 구조 · 모듈

Apache 의 기능은 대부분 모듈로 되어 있습니다. 필요한 것만 켜세요.

# apache2.conf 의 뼈대
ServerRoot "/etc/apache2"
Listen 80

IncludeOptional mods-enabled/*.load
IncludeOptional mods-enabled/*.conf
IncludeOptional conf-enabled/*.conf
# ← 사이트 설정을 여기서 읽습니다
IncludeOptional sites-enabled/*.conf

User  ${APACHE_RUN_USER}
Group ${APACHE_RUN_GROUP}

# 응답에 버전을 노출하지 않음
ServerTokens Prod
ServerSignature Off
Timeout 60
KeepAlive On
MaxKeepAliveRequests 100
KeepAliveTimeout 5

<Directory />
    Options FollowSymLinks
    AllowOverride None
    # 기본은 전부 차단 (안전한 기본값)
    Require all denied
</Directory>

<Directory /var/www/>
    Options Indexes FollowSymLinks
    AllowOverride None
    Require all granted
</Directory>

자주 쓰는 모듈

모듈하는 일
mod_rewriteURL 재작성 — 거의 항상 켭니다
mod_proxy · mod_proxy_http리버스 프록시
mod_sslHTTPS
mod_headers응답 헤더 조작 (보안 헤더)
mod_deflategzip 압축
mod_expires캐시 헤더
mod_remoteip프록시 뒤에서 실제 IP 복원
mod_proxy_fcgiPHP-FPM 연동
mod_status서버 상태 페이지
mod_autoindex디렉터리 목록 표시 — 운영에서는 끄세요
# Ubuntu 계열
sudo a2enmod rewrite proxy proxy_http headers deflate expires ssl remoteip
sudo a2dismod autoindex status
sudo systemctl restart apache2

# RHEL 계열 — /etc/httpd/conf.modules.d/*.conf 에서 LoadModule 을 주석 처리·해제
# LoadModule rewrite_module modules/mod_rewrite.so
안 쓰는 모듈은 끄세요. 모듈마다 메모리를 차지하고, 공격 표면이 늘어납니다. 특히 mod_autoindex(디렉터리 목록 노출)와 mod_info·mod_status(설정·상태 노출)는 인터넷에 열려 있으면 심각한 정보 유출이 됩니다.
SECTION 04

VirtualHost

Nginx 의 server 블록과 같은 역할입니다.

# /etc/apache2/sites-available/example.com.conf
<VirtualHost *:80>
    ServerName  example.com
    ServerAlias www.example.com
    DocumentRoot /var/www/example

    <Directory /var/www/example>
        Options -Indexes +FollowSymLinks
        AllowOverride None
        Require all granted
    </Directory>

    ErrorLog  ${APACHE_LOG_DIR}/example-error.log
    CustomLog ${APACHE_LOG_DIR}/example-access.log combined

    # HTTPS 로 리다이렉트
    RewriteEngine On
    RewriteCond %{HTTPS} off
    RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>

<VirtualHost *:443>
    ServerName example.com
    DocumentRoot /var/www/example

    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem

    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</VirtualHost>
sudo a2ensite example.com          # 활성화
sudo a2dissite 000-default         # 기본 사이트 비활성화
sudo apachectl configtest
sudo systemctl reload apache2

sudo apachectl -S                  # 어떤 VirtualHost 가 어떤 순서인지 확인
매칭 규칙 — 요청의 Host 헤더와 ServerName·ServerAlias 를 비교하고, 어디에도 안 맞으면 첫 번째 VirtualHost 가 처리합니다. Nginx 의 default_server 같은 명시적 지정이 없으므로, 파일 이름 순서(000-default)로 기본을 정하는 관례가 있습니다.
알 수 없는 Host 를 거부하려면 맨 앞 순서에 오는 VirtualHost 를 하나 두고 Redirect 404 / 또는 빈 응답을 주도록 설정하세요. 봇 스캔이 엉뚱한 사이트에 접근하는 것을 막습니다.

컨테이너 블록 — 무엇을 언제 쓰나

블록기준쓸 곳주의
<Directory>파일 시스템 경로문서 루트 · 업로드 폴더 권한가장 먼저 평가됩니다
<Location>URL 경로프록시 대상 · /admin 인증실제 파일이 없어도 적용
<Files>파일 이름index.html 캐시 · .env 차단경로 무관하게 이름만 봅니다
<FilesMatch>파일 이름 정규식해시 붙은 자원—
<LocationMatch>URL 정규식복잡한 경로 규칙느립니다 — 꼭 필요할 때만
<If>표현식헤더·조건별 분기2.4 이상
적용 순서는 Directory → DirectoryMatch → Files → Location 입니다 (.htaccess 는 Directory 뒤). 같은 것을 여러 곳에 쓰면 나중에 적용된 것이 이깁니다 — Location 에 쓴 규칙이 Directory 의 규칙을 덮어씁니다. 프록시 대상에는 Directory 가 안 먹힙니다(파일이 없으니까요) — 반드시 Location 을 쓰세요.

실전 VirtualHost — 리버스 프록시 · SPA

# sudo a2enmod proxy proxy_http headers rewrite ssl http2
<VirtualHost *:443>
    ServerName example.com
    Protocols h2 http/1.1

    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem

    # ── 1) 정적 SPA
    DocumentRoot /var/www/spa
    <Directory /var/www/spa>
        Options -Indexes +FollowSymLinks
        # ★ .htaccess 를 끄면 눈에 띄게 빨라집니다
        AllowOverride None
        Require all granted

        # 클라이언트 라우팅 폴백
        RewriteEngine On
        RewriteCond %{REQUEST_FILENAME} !-f
        RewriteCond %{REQUEST_FILENAME} !-d
        RewriteRule ^ /index.html [L]
    </Directory>

    # ── 2) API 프록시 (/api 접두사를 떼고 넘김)
    # 백엔드에 원래 Host 를 전달
    ProxyPreserveHost On
    ProxyPass        /api/ http://127.0.0.1:8080/
    ProxyPassReverse /api/ http://127.0.0.1:8080/

    <Location /api/>
        RequestHeader set X-Forwarded-Proto "https"
        RequestHeader set X-Forwarded-Port  "443"
    </Location>

    # ── 3) WebSocket (Apache 는 명시적으로 잡아 줘야 합니다)
    # sudo a2enmod proxy_wstunnel
    RewriteEngine On
    RewriteCond %{HTTP:Upgrade} =websocket [NC]
    RewriteRule ^/ws/(.*) ws://127.0.0.1:8080/ws/$1 [P,L]

    # ── 4) 프록시 타임아웃 — 기본은 Timeout 값(2.4 기본 60초)을 따릅니다. 백엔드에 맞게 명시하세요
    ProxyTimeout 60

    ErrorLog  ${APACHE_LOG_DIR}/example-error.log
    CustomLog ${APACHE_LOG_DIR}/example-access.log combined
</VirtualHost>
ProxyPass 의 끝 슬래시가 동작을 완전히 바꿉니다.
ProxyPass /api/ http://backend/ → /api/users 가 /users 로
ProxyPass /api http://backend → /api/users 가 /api/users 로
양쪽 다 있거나 양쪽 다 없어야 합니다. 한쪽만 붙이면 경로가 이상하게 합쳐집니다. 그리고 ProxyPassReverse 를 빠뜨리면 백엔드가 보낸 리다이렉트가 내부 주소를 그대로 노출합니다.
AllowOverride None 이 성능에 생각보다 크게 작용합니다. 허용해 두면 Apache 가 요청마다 경로의 모든 상위 디렉터리에서 .htaccess 를 찾습니다 — 파일이 없어도 stat() 호출이 반복됩니다. 설정 파일을 직접 고칠 수 있는 환경이라면 끄는 것이 정답이고, 공유 호스팅처럼 어쩔 수 없는 경우에만 켜세요.
VirtualHost — Host 헤더로 갈라집니다 요청 도착 Host: shop.example.com ServerName 비교 ServerAlias 도 함께 맞는 블록이 처리 없으면 — 첫 번째 블록 기본값으로 쓰입니다 여기서 생기는 사고 등록 안 한 도메인이나 IP 로 직접 들어온 요청이 엉뚱한 사이트를 보여 줍니다 막는 법 — 아무것도 처리하지 않고 444 나 403 을 주는 기본 블록을 맨 앞에 하나 두세요 HTTPS 는 블록마다 인증서가 다르므로 SNI 로 도메인을 먼저 확인해 인증서를 고릅니다 80 과 443 은 별도 블록입니다 — 80 은 리다이렉트만 두는 구성이 흔합니다 지금 어떤 블록들이 잡혀 있는지는 apachectl -S 로 한 번에 볼 수 있습니다
한 서버에 사이트가 둘 이상이면 기본 블록을 먼저 정의하세요.
SECTION 05

Directory · Location · Files

셋 다 "어디에 적용할지" 정하지만 기준이 다릅니다. 이 구분이 Apache 설정의 핵심입니다.

지시어기준예
<Directory>파일 시스템 경로<Directory /var/www/app>
<Location>URL 경로<Location /admin>
<Files>파일 이름<Files ".env">
<FilesMatch>파일 이름 (정규식)<FilesMatch "\.(env|log)$">
<LocationMatch>URL (정규식)<LocationMatch "^/api/v[0-9]+">
# 파일 시스템 기준 — 실제 폴더에 적용
<Directory /var/www/example>
    # 디렉터리 목록 금지
    Options -Indexes +FollowSymLinks
    # .htaccess 무시 (성능·보안)
    AllowOverride None
    Require all granted
</Directory>

# URL 기준 — 프록시 경로처럼 실제 파일이 없는 곳에도 적용됩니다
<Location /admin>
    Require ip 10.0.0.0/8
    Require ip 203.0.113.5
</Location>

# 민감한 파일 접근 차단 (반드시 넣으세요)
<FilesMatch "^\.|\.(env|git|sql|bak|log|ini|conf)$">
    Require all denied
</FilesMatch>

# 업로드 폴더에서 스크립트 실행 금지 (치명적 취약점 방지)
<Directory /var/www/example/uploads>
    php_admin_flag engine off
    Options -ExecCGI
    AddType text/plain .php .phtml .php3 .php5 .pl .py .cgi
</Directory>
적용 순서 — <Directory>(경로가 짧은 것부터) → <Files> → <Location> 순서로 적용되며, 뒤에 적용된 것이 앞을 덮어씁니다. <Location> 이 가장 마지막이라 가장 강력합니다.

Options 지시어

옵션의미
Indexesindex 파일이 없으면 디렉터리 목록을 보여 줍니다 — 운영에서는 반드시 -Indexes
FollowSymLinks심볼릭 링크를 따라감 (성능상 권장)
SymLinksIfOwnerMatch소유자가 같을 때만 따라감 (더 안전하지만 느림)
ExecCGICGI 실행 허용 — 필요한 곳에만
MultiViews확장자 없이 접근 시 추측 — 예상 밖 동작의 원인이 되므로 끄는 편이 낫습니다
무엇을 기준으로 범위를 정하느냐가 다릅니다 <Directory> 기준 — 디스크의 실제 경로 /var/www/html/admin 파일 권한·인덱싱 설정이 여기 <Location> 기준 — 요청 주소(URL) /admin 프록시처럼 파일이 없어도 적용 <Files> 기준 — 파일 이름 "*.env" · ".htaccess" 경로와 무관하게 이름으로 겹칠 때 적용되는 순서 — 뒤가 앞을 덮어씁니다 Directory .htaccess Files Location 그래서 Location 에서 허용한 것이 Directory 의 차단을 이깁니다 — 접근 제어가 왜 안 먹히는지 헷갈릴 때 이 순서를 떠올리세요
파일이 실제로 있는 경로면 Directory, 프록시처럼 주소만 있으면 Location 입니다.
무엇을 기준으로 범위를 정하느냐가 다릅니다 <Directory> 기준 — 디스크의 실제 경로 /var/www/html/admin 파일 권한·인덱싱 설정이 여기 <Location> 기준 — 요청 주소(URL) /admin 프록시처럼 파일이 없어도 적용 <Files> 기준 — 파일 이름 "*.env" · ".htaccess" 경로와 무관하게 이름으로 겹칠 때 적용되는 순서 — 뒤가 앞을 덮어씁니다 Directory .htaccess Files Location 그래서 Location 에서 허용한 것이 Directory 의 차단을 이깁니다 — 접근 제어가 왜 안 먹히는지 헷갈릴 때 이 순서를 떠올리세요
파일이 실제로 있는 경로면 Directory, 프록시처럼 주소만 있으면 Location 입니다.
SECTION 06

.htaccess — 쓸 것인가

Apache 만의 특징입니다. 폴더마다 두는 설정 파일로, 서버 설정을 못 건드리는 환경에서 유용합니다.

장점
· 서버 재시작 없이 즉시 반영
· 서버 설정 접근 권한 없이 사용 가능
  (공유 호스팅의 핵심)
· 애플리케이션과 함께 배포 가능
  (워드프레스·라라벨이 기본 제공)
· 폴더 단위로 권한 위임
단점
· ★ 요청마다 파일을 읽습니다
  (상위 폴더 전부를 거슬러 올라가며!)
· 설정이 여러 곳에 흩어져 추적이 어려움
· 문법 오류 시 500 오류 (검증 단계 없음)
· 사용자가 보안 설정을 무력화할 수 있음
성능 비용이 생각보다 큽니다. /var/www/app/a/b/c/file.php 요청이 오면 Apache 는 / → /var → /var/www → … → /c 까지 모든 폴더에서 .htaccess 를 찾습니다. 요청 하나에 디스크 접근이 수 회 발생합니다.
서버 설정을 직접 고칠 수 있다면 AllowOverride None 으로 끄고 VirtualHost 에 직접 쓰세요.
# 완전히 끄기 (권장 · 성능 향상)
<Directory /var/www/>
    AllowOverride None
</Directory>

# 필요한 것만 허용
<Directory /var/www/app/>
    AllowOverride FileInfo Options=Indexes
</Directory>

전형적인 .htaccess (워드프레스·라라벨)

# 프런트 컨트롤러 패턴 — 실제 파일이 없으면 index.php 로
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteBase /

    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^ index.php [L]
</IfModule>

# 민감 파일 차단
<FilesMatch "^\.|composer\.(json|lock)$">
    Require all denied
</FilesMatch>

# 디렉터리 목록 금지
Options -Indexes
이 설정을 VirtualHost 로 옮기는 법 — .htaccess 의 내용을 <Directory /var/www/app> 블록 안에 그대로 복사하고 AllowOverride None 으로 바꾸면 됩니다. RewriteBase 만 상황에 맞게 조정하세요. 성능이 개선되고 설정이 한 곳에 모입니다.
.htaccess — 편한 만큼 매 요청 비용을 냅니다 .htaccess 를 켜 두면 요청마다 /var/www/a/b/c 와 그 위의 모든 디렉터리에서 이 파일을 찾습니다 없어도 찾는 비용은 그대로 듭니다 — 경로가 깊을수록 늘어납니다 대신 서버를 다시 읽지 않아도 바로 반영됩니다 서버 설정에 직접 넣으면 시작할 때 한 번만 읽습니다 — 요청마다 드는 비용이 없습니다 대신 고칠 때마다 설정을 다시 읽어야 합니다 권한이 있다면 서버 설정에 · 공유 호스팅처럼 권한이 없을 때만 .htaccess 안 쓸 거라면 AllowOverride None 으로 꺼 두세요 — 찾는 비용까지 사라집니다 여기 적힌 규칙은 웹으로 노출될 수 있는 경로에 있습니다 — 비밀값을 적지 마세요
.htaccess 는 나쁜 기능이 아니라 권한이 없을 때의 대안입니다.
SECTION 07

mod_rewrite

Apache 의 가장 강력한 기능이자 가장 어려운 부분입니다. 레거시 시스템 유지보수에서 반드시 만나게 됩니다.

RewriteEngine On

# ── 기본 형태
# RewriteCond  조건 (여러 개 가능 · AND 로 묶임)
# RewriteRule  패턴  대체  [플래그]

# HTTPS 강제
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

# www 제거
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1/$1 [R=301,L]

# 실제 파일이 없으면 index.php 로 (프런트 컨트롤러)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]

# 옛 URL 을 새 URL 로
RewriteRule ^article/([0-9]+)$ /posts/$1 [R=301,L]

# 내부 재작성 (주소는 그대로, 처리만 다른 곳에서)
RewriteRule ^api/(.*)$ /backend/api.php?path=$1 [QSA,L]
플래그의미
LLast — 여기서 규칙 처리를 멈춥니다 (거의 항상 붙입니다)
R=301브라우저에 리다이렉트 응답 (301 영구 · 302 임시)
QSAQuery String Append — 원래 쿼리스트링을 유지
NC대소문자 무시
P프록시로 전달 (mod_proxy 필요)
F403 Forbidden 응답
E=VAR:val환경 변수 설정

디버깅 — 규칙이 왜 안 먹는지

# 2.4 부터는 LogLevel 로 rewrite 추적을 켭니다
LogLevel warn rewrite:trace3

# error.log 에 각 단계가 기록됩니다
# trace1(간단) ~ trace8(매우 상세) · 운영에서는 반드시 끄세요 (로그 폭증)
[rewrite:trace3] applying pattern '^(.*)$' to uri '/article/123' [rewrite:trace2] rewrite '/article/123' -> '/posts/123' [rewrite:trace1] escaping /posts/123 for redirect
mod_rewrite 의 흔한 함정
· 무한 루프 — 재작성 결과가 다시 같은 규칙에 걸립니다. RewriteCond 로 탈출 조건을 넣으세요
· .htaccess 에서는 경로 앞의 / 가 제거됩니다 — VirtualHost 와 패턴이 달라집니다
· L 플래그가 .htaccess 에서는 완전히 끝내지 않습니다 — 재작성 후 다시 처음부터 돕니다. 확실히 끝내려면 END 플래그(2.3.9+)를 쓰세요
· 조건이 여러 개면 AND 입니다. OR 를 원하면 [OR] 플래그를 붙이세요
단순한 리다이렉트에는 mod_rewrite 를 쓰지 마세요. Redirect 301 /old /new 나 RedirectMatch 가 훨씬 빠르고 읽기 쉽습니다. mod_rewrite 는 조건이 필요한 복잡한 경우에만 씁니다.
규칙은 위에서 아래로 · 바뀌면 처음부터 다시 요청 /old/page RewriteCond 검사 RewriteRule 로 주소 변경 플래그가 없으면 → 처음부터 다시 검사합니다 자주 쓰는 플래그 넷 [L] 여기서 끝 · [R=301] 브라우저에게 이동 지시 [QSA] 원래 쿼리스트링 유지 · [NC] 대소문자 무시 [L] 을 빠뜨리면 규칙이 계속 겹쳐 적용됩니다 무한 반복 — 500 의 단골 원인 바꾼 결과가 같은 규칙에 또 걸리면 끝나지 않습니다 아파치는 10회를 넘기면 500 을 냅니다 → 조건을 좁히고 [L] 을 반드시 붙이세요 디버깅은 로그로 — LogLevel alert rewrite:trace3 을 켜면 규칙이 어떻게 적용됐는지 한 줄씩 남습니다 단, 로그가 매우 많아지므로 확인이 끝나면 반드시 되돌리세요
rewrite 는 반복문입니다 — 종료 조건을 안 적으면 도는 것이 당연합니다.
규칙은 위에서 아래로 · 바뀌면 처음부터 다시 요청 /old/page RewriteCond 검사 RewriteRule 로 주소 변경 플래그가 없으면 → 처음부터 다시 검사합니다 자주 쓰는 플래그 넷 [L] 여기서 끝 · [R=301] 브라우저에게 이동 지시 [QSA] 원래 쿼리스트링 유지 · [NC] 대소문자 무시 [L] 을 빠뜨리면 규칙이 계속 겹쳐 적용됩니다 무한 반복 — 500 의 단골 원인 바꾼 결과가 같은 규칙에 또 걸리면 끝나지 않습니다 아파치는 10회를 넘기면 500 을 냅니다 → 조건을 좁히고 [L] 을 반드시 붙이세요 디버깅은 로그로 — LogLevel alert rewrite:trace3 을 켜면 규칙이 어떻게 적용됐는지 한 줄씩 남습니다 단, 로그가 매우 많아지므로 확인이 끝나면 반드시 되돌리세요
rewrite 는 반복문입니다 — 종료 조건을 안 적으면 도는 것이 당연합니다.
SECTION 08

리버스 프록시 mod_proxy

Apache 도 프록시가 됩니다. Tomcat 앞단으로 쓰는 구성이 특히 흔합니다.

# 모듈 활성화
# sudo a2enmod proxy proxy_http proxy_balancer lbmethod_byrequests headers

<VirtualHost *:80>
    ServerName example.com

    # 원래 Host 헤더를 유지
    ProxyPreserveHost On
    # ★ 정방향 프록시 금지 (켜면 오픈 프록시가 됩니다)
    ProxyRequests Off

    ProxyPass        /api/ http://127.0.0.1:8080/api/
    ProxyPassReverse /api/ http://127.0.0.1:8080/api/

    # 실제 IP 전달
    RequestHeader set X-Forwarded-Proto "http"
    RequestHeader set X-Real-IP "expr=%{REMOTE_ADDR}"

    # 정적 파일은 프록시하지 않음
    ProxyPass /static/ !
    Alias /static/ /var/www/static/

    ProxyTimeout 60
</VirtualHost>
ProxyRequests Off 를 반드시 확인하세요. 이것이 On 이면 정방향 프록시가 되어 누구나 우리 서버를 거쳐 아무 사이트에나 접속할 수 있습니다. 스팸·불법 트래픽의 경유지가 되어 IP 가 차단됩니다. (기본값은 Off 이지만 잘못 켜 두는 사례가 있습니다)

로드 밸런싱

<Proxy balancer://mycluster>
    BalancerMember http://10.0.1.11:8080 loadfactor=3
    BalancerMember http://10.0.1.12:8080
    # H = 대기(핫 스탠바이)
    BalancerMember http://10.0.1.13:8080 status=+H

    # bytraffic · bybusyness 도 있음
    ProxySet lbmethod=byrequests
</Proxy>

ProxyPass        / balancer://mycluster/
ProxyPassReverse / balancer://mycluster/

# 관리 화면 (내부에서만 접근 가능하게)
<Location /balancer-manager>
    SetHandler balancer-manager
    Require ip 10.0.0.0/8
</Location>

AJP — Tomcat 전용 프로토콜

# sudo a2enmod proxy_ajp
ProxyPass        /app/ ajp://127.0.0.1:8009/app/
ProxyPassReverse /app/ ajp://127.0.0.1:8009/app/
AJP 는 신중하게 쓰세요. 2020년 Ghostcat(CVE-2020-1938) 취약점 이후 Tomcat 은 AJP 커넥터를 기본 비활성화했고, 활성화하더라도 secretRequired 와 address="127.0.0.1" 이 필요합니다. 특별한 이유가 없으면 그냥 HTTP 프록시를 쓰세요 — 성능 차이는 미미합니다.
SECTION 09

HTTPS 설정

개념은 Nginx 와 같고 지시어 이름만 다릅니다.

# SSLStaplingCache 는 VirtualHost 안에 둘 수 없습니다 — 서버 전역(바깥)에 한 번
# ※ Let's Encrypt 는 2025년에 OCSP 를 종료해 LE 인증서에는 스테이플링이 동작하지 않습니다
SSLStaplingCache "shmcb:logs/ssl_stapling(32768)"

<VirtualHost *:443>
    ServerName example.com
    DocumentRoot /var/www/example

    SSLEngine on
    SSLCertificateFile      /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile   /etc/letsencrypt/live/example.com/privkey.pem
    # 2.4.8 미만에서는 체인을 따로 지정했습니다
    # SSLCertificateChainFile /etc/letsencrypt/live/example.com/chain.pem

    SSLProtocol             -all +TLSv1.2 +TLSv1.3
    SSLHonorCipherOrder     off
    SSLCipherSuite          ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384

    SSLUseStapling on

    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</VirtualHost>

# HTTP → HTTPS
<VirtualHost *:80>
    ServerName example.com
    Redirect permanent / https://example.com/
</VirtualHost>
# certbot 이 Apache 설정까지 자동으로 해 줍니다
sudo apt install -y certbot python3-certbot-apache
sudo certbot --apache -d example.com -d www.example.com

# 인증서만 받고 설정은 직접
sudo certbot certonly --apache -d example.com
HTTP/2 를 쓰려면 — sudo a2enmod http2 후 VirtualHost 에 Protocols h2 http/1.1 을 추가합니다. 다만 prefork MPM 에서는 HTTP/2 가 동작하지 않습니다 — event MPM 이 필요합니다.
인증서 발급·갱신·진단은 🔐 HTTPS 탭에서 자세히 다룹니다.

Nginx 와 지시어 대응 — 이름만 다릅니다

하는 일NginxApache
인증서ssl_certificateSSLCertificateFile
개인 키ssl_certificate_keySSLCertificateKeyFile
프로토콜 버전ssl_protocols TLSv1.2 TLSv1.3;SSLProtocol -all +TLSv1.2 +TLSv1.3
암호 스위트ssl_ciphersSSLCipherSuite
서버 우선순위ssl_prefer_server_ciphersSSLHonorCipherOrder
OCSP 스테이플링ssl_stapling on;SSLUseStapling on
세션 캐시ssl_session_cacheSSLSessionCache
HTTP/2listen 443 ssl; http2 on;Protocols h2 http/1.1
암호 스위트를 직접 고르려 하지 마세요. Mozilla SSL Configuration Generator 에서 서버 종류·버전·호환 수준을 고르면 검증된 설정을 그대로 받을 수 있습니다. 직접 나열한 목록은 시간이 지나면 취약해지고, 빠뜨린 항목 때문에 구형 클라이언트가 못 붙는 일이 생깁니다. TLS 1.3 만 쓰는 경우 암호 스위트 지정 자체가 거의 불필요합니다.

HTTPS 가 안 될 때 — 증상별

증상원인확인
브라우저만 인증서 오류
(curl 은 정상)
중간 인증서 누락openssl s_client -connect ... 로 체인 확인
443 연결 자체가 안 됨a2enmod ssl 안 함 · 방화벽apachectl -M | grep ssl
다른 사이트 인증서가 나옴SNI 미매칭 — ServerName 확인apachectl -S
리다이렉트 무한 루프백엔드가 X-Forwarded-Proto 를 못 봄RequestHeader set X-Forwarded-Proto
갱신 후에도 옛 인증서reload 안 함certbot 훅에 systemctl reload
HTTP/2 가 안 붙음prefork MPMapachectl -V | grep MPM
일부 사용자만 접속 실패구형 기기 · TLS 1.0/1.1 차단SSL Labs 로 호환성 확인
# 인증서 체인을 밖에서 확인 — 가장 확실한 방법
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
  | grep -E "subject=|issuer=|Verify return code"

# 만료일
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates

# 자동 갱신이 실제로 도는지 (실행하지 않고 예행)
sudo certbot renew --dry-run

# 갱신 후 자동 reload — 이 훅이 없으면 인증서는 새것인데 서비스는 옛것을 씁니다
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh <<'EOF'
#!/bin/sh
systemctl reload apache2
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh
"인증서를 갱신했는데 만료 오류가 계속 난다" — 거의 항상 reload 를 안 한 것입니다. Apache 는 시작할 때 인증서를 읽어 메모리에 들고 있어서, 파일이 바뀌어도 재적용하지 않으면 옛 인증서를 계속 사용합니다. certbot 의 deploy 훅에 reload 를 넣어 두는 것이 유일한 예방책입니다.
SECTION 10

인증 · 접근 제어

Apache 는 이 부분이 특히 강합니다. 관리자 페이지 보호에 그대로 씁니다.

# 비밀번호 파일 만들기 (문서 루트 밖에 두세요)
sudo htpasswd -c /etc/apache2/.htpasswd admin      # -c 는 최초 1회만
sudo htpasswd /etc/apache2/.htpasswd user2         # 추가
<Location /admin>
    AuthType Basic
    AuthName "Restricted Area"
    AuthUserFile /etc/apache2/.htpasswd
    Require valid-user
</Location>

# IP 제한
<Location /internal>
    Require ip 10.0.0.0/8
    Require ip 203.0.113.5
</Location>

# IP 또는 인증 (둘 중 하나만 통과하면 됨)
<Location /admin>
    <RequireAny>
        Require ip 10.0.0.0/8
        Require valid-user
    </RequireAny>
</Location>

# IP 이면서 인증 (둘 다 필요)
<Location /danger>
    <RequireAll>
        Require ip 10.0.0.0/8
        Require valid-user
    </RequireAll>
</Location>
Basic 인증은 HTTPS 에서만 쓰세요. 비밀번호가 Base64 인코딩될 뿐 암호화되지 않습니다. HTTP 로 쓰면 네트워크에서 그대로 읽힙니다.
또 2.2 문법(Order·Allow from)은 2.4 에서 폐기됐습니다. 옛 설정을 옮길 때는 Require 문법으로 바꿔야 합니다.

2.2 → 2.4 접근 제어 문법 변환

2.2 (폐기)2.4
Order allow,deny
Allow from all
Require all granted
Order deny,allow
Deny from all
Require all denied
Allow from 10.0.0.0/8Require ip 10.0.0.0/8
Allow from example.comRequire host example.com

Require 지시어 — 쓸 수 있는 조건들

조건뜻예
Require all granted / denied전부 허용 / 거부기본 정책
Require ipIP · 대역Require ip 10.0.0.0/8 203.0.113.5
Require valid-user인증된 사용자 아무나Basic·Digest 인증 뒤
Require user특정 계정만Require user alice bob
Require group그룹AuthGroupFile 필요
Require expr표현식 — 가장 유연Require expr %{HTTP_HOST} == 'admin.example.com'
Require local루프백에서만서버 상태 페이지
# 실전 조합 — 사내 IP 는 그냥, 밖에서는 인증을 요구
<Location /admin>
    AuthType Basic
    AuthName "Admin"
    AuthUserFile /etc/apache2/.htpasswd
    <RequireAny>
        Require ip 10.0.0.0/8
        Require valid-user
    </RequireAny>
</Location>

# 프록시 뒤에 있으면 remote_addr 이 LB IP 입니다 — 실제 IP 를 복원해야 합니다
# sudo a2enmod remoteip
RemoteIPHeader X-Forwarded-For
# ★ 신뢰할 프록시만 지정
RemoteIPTrustedProxy 10.0.0.0/8
# %a 가 실제 IP
LogFormat "%a %l %u %t \"%r\" %>s %O" combined_realip

# 민감 파일 차단 — 정적 사이트에 항상 넣으세요
<FilesMatch "^\.|\.(bak|sql|env|log|swp)$">
    Require all denied
</FilesMatch>
<DirectoryMatch "/\.git">
    Require all denied
</DirectoryMatch>

# 서버 상태 페이지 — 로컬에서만 (진단에 매우 유용합니다)
# sudo a2enmod status
<Location /server-status>
    SetHandler server-status
    Require local
</Location>
ExtendedStatus On
프록시 뒤에서 IP 제한이 통째로 무력화되는 사고가 흔합니다. 로드밸런서를 앞에 두면 Apache 가 보는 IP 는 전부 LB 주소입니다. Require ip 10.0.0.0/8 이 모두 통과하거나 모두 차단되는 두 가지 결과 중 하나가 되는데, 앞쪽이면 관리자 페이지가 인터넷에 열립니다. mod_remoteip 를 설정하고 RemoteIPTrustedProxy 를 반드시 명시하세요 — 안 그러면 헤더를 위조해 IP 제한을 우회할 수 있습니다.
Basic 인증은 임시 보호용입니다. 비밀번호 회전·감사 로그·다중 인증이 없고, 사용자가 늘면 관리가 안 됩니다. 사내 도구라면 OIDC/SAML 인증 프록시(oauth2-proxy 등)를 앞에 두는 편이 장기적으로 훨씬 낫습니다. Basic 은 "오늘 당장 막아야 할 때" 쓰세요.
SECTION 11

압축 · 캐시 헤더

모듈을 켜고 설정하면 정적 자원 성능이 크게 달라집니다.

# sudo a2enmod deflate expires headers

# ── 압축
<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
    AddOutputFilterByType DEFLATE application/javascript application/json
    AddOutputFilterByType DEFLATE application/xml image/svg+xml

    DeflateCompressionLevel 5

    # 이미 압축된 것은 제외
    SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|zip|gz|mp4)$ no-gzip dont-vary
</IfModule>

# ── 캐시 헤더
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType image/jpeg      "access plus 1 month"
    ExpiresByType image/png       "access plus 1 month"
    ExpiresByType image/svg+xml   "access plus 1 month"
    ExpiresByType text/css        "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    ExpiresByType text/html       "access plus 0 seconds"
</IfModule>

# 해시가 붙은 파일은 영구 캐시
<FilesMatch "\.[0-9a-f]{8,}\.(js|css)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

# index.html 은 매번 재검증 (no-cache — 아래 설명 참고)
<Files "index.html">
    Header set Cache-Control "no-cache"
</Files>

보안 헤더도 여기서

<IfModule mod_headers.c>
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

    # 서버 정보 제거
    Header unset X-Powered-By
    # Server 헤더는 mod_headers 로 지울 수 없습니다 — ServerTokens Prod 로 "Apache" 만 남깁니다
</IfModule>
always 키워드를 붙이세요. 없으면 2xx·3xx 응답에만 헤더가 붙고 4xx·5xx 오류 응답에는 빠집니다. 보안 헤더는 오류 페이지에도 적용돼야 의미가 있습니다.

brotli 를 함께 쓰기 — deflate 보다 20~25% 더 줄어듭니다

# sudo a2enmod brotli
<IfModule mod_brotli.c>
    AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css
    AddOutputFilterByType BROTLI_COMPRESS application/javascript application/json
    AddOutputFilterByType BROTLI_COMPRESS image/svg+xml

    # 5 가 속도·압축률 균형점 (mod_brotli 기본값도 5 · 11 은 실시간으로는 너무 느립니다)
    BrotliCompressionQuality 5
    BrotliCompressionWindow  18
</IfModule>

# ★ 사전 압축 파일을 쓰면 CPU 를 아예 안 씁니다 (빌드 때 만들어 두세요)
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{HTTP:Accept-Encoding} br
    RewriteCond %{REQUEST_FILENAME}.br -f
    RewriteRule ^(.*)$ $1.br [L]

    <FilesMatch "\.js\.br$">
        Header set Content-Encoding br
        Header append Vary Accept-Encoding
        ForceType application/javascript
    </FilesMatch>
    <FilesMatch "\.css\.br$">
        Header set Content-Encoding br
        Header append Vary Accept-Encoding
        ForceType text/css
    </FilesMatch>
</IfModule>
Vary: Accept-Encoding 을 빠뜨리면 캐시가 오염됩니다. CDN 이나 중간 프록시가 압축된 응답을 압축 미지원 클라이언트에 그대로 내주거나 그 반대가 됩니다. 증상은 "일부 사용자만 페이지가 깨져 보임" 으로 나타나고, 재현이 안 돼서 원인을 찾기가 매우 어렵습니다. mod_deflate 는 자동으로 붙이지만, 수동으로 압축 파일을 서빙할 때는 직접 넣어야 합니다.

캐시 헤더 — 정확히 어떤 값을 줄 것인가

대상헤더이유
app.a1b2c3d4.jsmax-age=31536000, immutable내용이 바뀌면 이름이 바뀝니다
index.htmlno-cache새 파일명을 알려 주는 문서
이미지(고정 이름)max-age=2592000 (30일)바뀔 수는 있으나 드묾
API 응답no-store사용자별 데이터
로그인 뒤 페이지no-store, private공용 PC 뒤로 가기 노출 방지
robots.txt·faviconmax-age=86400—
no-cache 와 no-store 는 다릅니다.
no-cache — 저장은 하되 쓰기 전에 서버에 물어봅니다(ETag 로 304 응답 가능 → 빠름)
no-store — 아예 저장하지 않습니다(민감 정보에만)
index.html 에는 no-cache 가 맞습니다 — 바뀌지 않았으면 304 로 끝나 실제 전송이 없습니다. no-store 를 쓰면 매번 전체를 다시 받습니다.
# 실제로 어떤 헤더가 나가는지 확인 — 설정만 보고 판단하지 마세요
curl -sI https://example.com/assets/app.a1b2c3d4.js \
  | grep -iE "cache-control|content-encoding|vary|etag"

# 압축이 실제로 적용됐는지 (크기 비교)
curl -s -o /dev/null -w "raw  %{size_download}\n" https://example.com/app.js
curl -s -o /dev/null -w "gzip %{size_download}\n" -H "Accept-Encoding: gzip" https://example.com/app.js
curl -s -o /dev/null -w "br   %{size_download}\n" -H "Accept-Encoding: br"   https://example.com/app.js

# 오류 응답에도 보안 헤더가 붙는지 (always 확인)
curl -sI https://example.com/없는경로 | grep -i "x-frame\|strict-transport"
SECTION 12

로그와 진단

Apache 의 error 로그는 정보량이 많습니다. 레벨만 조절할 줄 알면 됩니다.

# 로그 포맷 정의
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %D" combined_time
# %D = 처리 시간(마이크로초) ← 성능 분석에 필수

CustomLog ${APACHE_LOG_DIR}/access.log combined_time
ErrorLog  ${APACHE_LOG_DIR}/error.log

# 평상시
LogLevel warn
# LogLevel info rewrite:trace3    # rewrite 디버깅 시 (운영에서는 끄세요)
# LogLevel debug proxy:trace5     # 프록시 디버깅

# 특정 요청은 로그에서 제외
SetEnvIf Request_URI "^/health$" dontlog
SetEnvIf Request_URI "\.(css|js|png|jpg|svg)$" dontlog
CustomLog ${APACHE_LOG_DIR}/access.log combined_time env=!dontlog
기호의미
%h클라이언트 IP
%t시각
%r요청 첫 줄 (GET /path HTTP/1.1)
%>s최종 상태 코드
%b응답 크기
%D처리 시간 (마이크로초)
%T처리 시간 (초)
%{X-Forwarded-For}i임의 요청 헤더
# 상태 코드 집계
awk '{print $9}' /var/log/apache2/access.log | sort | uniq -c | sort -rn

# 가장 느린 요청 (마지막 필드가 %D 일 때)
awk '{print $NF, $7}' /var/log/apache2/access.log | sort -rn | head -20

# 실시간 오류만
sudo tail -f /var/log/apache2/error.log | grep -E '\[[a-z0-9_]*:(error|crit|alert|emerg)\]'   # 2.4 형식: [모듈:레벨]

서버 상태 페이지

# sudo a2enmod status
ExtendedStatus On
<Location /server-status>
    SetHandler server-status
    Require ip 127.0.0.1
    # ★ 반드시 제한하세요
    Require ip 10.0.0.0/8
</Location>

# curl http://localhost/server-status?auto
/server-status 와 /server-info 를 인터넷에 열지 마세요. 현재 처리 중인 URL·클라이언트 IP·서버 설정이 그대로 노출됩니다. 실제로 검색엔진에 노출된 사례가 많습니다. 반드시 Require ip 로 제한하세요.
SECTION 13

Nginx vs Apache

감정적인 논쟁이 많은 주제입니다. 사실만 정리합니다.

항목NginxApache
동시 접속 효율우수 (이벤트)보통 (event MPM 이면 개선)
정적 파일빠름보통
동적 언어 직접 실행불가 (항상 프록시)모듈로 가능
디렉터리별 설정없음.htaccess
설정 문법간결장황함
URL 재작성충분표현력이 더 큼
모듈 생태계보통 (동적 모듈)방대함
메모리 사용적음많음
자료·사례최신 자료가 많음오래된 자료가 많음
공유 호스팅부적합사실상 표준

언제 무엇을

🟢 Nginx 를 쓰세요
  • 신규 구축
  • 리버스 프록시·로드밸런서
  • 정적 파일·SPA 서빙
  • 동시 접속이 많은 서비스
  • 컨테이너·쿠버네티스 환경
🔵 Apache 가 나은 경우
  • 이미 Apache 로 돌아가는 시스템
  • .htaccess 가 필요한 공유 호스팅
  • 특정 Apache 모듈에 의존
  • 워드프레스 등 기본 지원이 Apache 인 솔루션
  • 팀이 Apache 에 익숙할 때
🟣 둘 다 쓰는 구성
  • Nginx(앞) + Apache(뒤)
  • Nginx 가 정적·TLS·캐시를 처리
  • Apache 가 .htaccess 와 PHP 를 처리
  • 레거시를 유지하며 점진적 이관
솔직한 결론 — 순수 성능 차이는 예전만큼 크지 않습니다. event MPM + PHP-FPM 조합의 Apache 는 상당히 좋은 성능을 냅니다.
실무에서 Nginx 를 고르는 진짜 이유는 설정의 간결함·최신 자료의 양· 컨테이너 생태계와의 궁합입니다. 이미 잘 돌아가는 Apache 를 바꿀 이유는 대개 없습니다.
둘의 차이 — 처리 모델과 설정 방식 Nginx 이벤트 방식 — 적은 메모리로 많은 연결 설정이 중앙에 모여 있습니다 정적 파일과 리버스 프록시에 강합니다 동적 처리는 뒤로 넘깁니다 reload 가 끊김 없이 됩니다 앞단 표준에 가깝습니다 Apache MPM 을 골라 씁니다 (요즘은 event) 디렉터리별 설정(.htaccess)이 됩니다 모듈이 풍부하고 자료가 많습니다 PHP 를 모듈로 품을 수 있습니다 공유 호스팅과 레거시에 많습니다 설정이 여러 곳에 흩어지기 쉽습니다 새로 시작한다면 Nginx · 이미 Apache 로 잘 돌고 있다면 굳이 바꿀 이유는 없습니다 둘을 함께 쓰는 구성도 흔합니다 — 앞에 Nginx, 뒤에 Apache + PHP 성능 비교표보다 중요한 것은 팀이 어느 쪽 설정을 읽고 고칠 수 있는가입니다
둘 다 충분히 빠릅니다 — 갈리는 것은 대개 운영 방식입니다.
SECTION 14

설정 변환 대조표

Apache 설정을 Nginx 로 옮길 때 하나씩 대응시키세요.

목적ApacheNginx
사이트 정의<VirtualHost *:80>server { listen 80; }
도메인ServerName · ServerAliasserver_name a.com b.com;
문서 루트DocumentRoot /pathroot /path;
기본 파일DirectoryIndex index.htmlindex index.html;
경로별 설정<Location /api>location /api { }
디렉터리 목록 금지Options -Indexesautoindex off; (기본값)
접근 허용Require all granted(기본 허용)
IP 제한Require ip 10.0.0.0/8allow 10.0.0.0/8; deny all;
Basic 인증AuthType Basic …auth_basic "…"; auth_basic_user_file …;
리다이렉트Redirect 301 /old /newreturn 301 /new;
URL 재작성RewriteRule ^a/(.*)$ /b/$1 [L]rewrite ^/a/(.*)$ /b/$1 last;
프런트 컨트롤러RewriteCond !-f + RewriteRule ^ index.php try_files $uri $uri/ /index.php?$args;
리버스 프록시ProxyPass /api/ http://…proxy_pass http://…;
로드 밸런싱<Proxy balancer://…>upstream name { server …; }
HTTPS 인증서SSLCertificateFilessl_certificate
압축AddOutputFilterByType DEFLATEgzip on; gzip_types …;
캐시 헤더ExpiresByTypeexpires 1y;
응답 헤더 추가Header always set X "y"add_header X "y" always;
실제 IP 복원RemoteIPHeader X-Forwarded-Forreal_ip_header X-Forwarded-For;
로그 포맷LogFormat "…" namelog_format name '…';
문법 검사apachectl configtestnginx -t
무중단 반영apachectl gracefulnginx -s reload
변환 시 반드시 확인할 것
① .htaccess 를 쓰고 있었다면 그 내용을 전부 Nginx 설정으로 옮겨야 합니다 (Nginx 에는 대응 기능이 없습니다)
② mod_php 를 쓰고 있었다면 PHP-FPM 으로 전환해야 합니다
③ 복잡한 RewriteRule 은 동작이 미묘하게 다를 수 있으니 하나씩 테스트하세요
④ MultiViews·ContentNegotiation 같은 Apache 고유 기능은 대응 방법이 다릅니다
안전한 이관 절차 — ① Nginx 를 다른 포트(8080)에 띄워 설정을 옮깁니다 ② curl 로 주요 URL 을 양쪽에 요청해 응답을 비교합니다 ③ 정적 파일·리다이렉트·프록시를 각각 확인합니다 ④ 문제가 없으면 포트를 바꿔 전환하고, Apache 는 며칠간 남겨 둡니다
APACHE TOMCAT 11 · 서블릿 컨테이너 · SPRING BOOT

Tomcat —
자바 앱을
실행하는 엔진

Tomcat 은 웹서버가 아니라 WAS(웹 애플리케이션 서버)입니다. 자바 서블릿·JSP 를 실행하며, 실무에서는 거의 항상 Nginx 뒤에 둡니다. Spring Boot 를 쓴다면 이미 톰캣이 앱 안에 들어 있습니다 — 그래서 톰캣을 아는 것이 곧 자바 웹 운영을 아는 것입니다.

16SECTIONS
11기준 버전
Boot내장 톰캣 포함
SECTION 01

Tomcat은 무엇인가

"Nginx 가 있는데 왜 Tomcat 이 또 필요한가요?" — 역할이 다르기 때문입니다.

웹서버 (Nginx · Apache)
· 요청을 받고 응답을 보냄
· 정적 파일을 디스크에서 읽어 전달
· HTTPS · 압축 · 캐시 · 로드밸런싱
· 자바 코드는 실행하지 못함
WAS (Tomcat)
· 자바 서블릿·JSP 를 실행
· 요청마다 스레드를 배정해 코드 실행
· 세션 · 트랜잭션 · 커넥션 풀 관리
· 정적 파일도 줄 수는 있지만 비효율
브라우저
https://example.com/orders/1HTTPS 요청
▼
Nginx :443
TLS 종료 · 정적 파일 처리 · 프록시 /static/ 은 직접 응답, 나머지는 톰캣으로 넘깁니다.
▼ HTTP
Tomcat :8080
서블릿 실행 스레드 풀에서 스레드 하나를 꺼내 우리 코드(Controller)를 실행합니다.
▼ JDBC
DB
데이터 조회·저장커넥션 풀에서 커넥션을 빌려 씁니다.

서블릿 컨테이너란

자바에는 서블릿(Servlet)이라는 표준 규격이 있습니다. "HTTP 요청을 받으면 이런 형태의 객체로 전달하고, 이런 형태로 응답을 돌려준다"는 약속입니다. Tomcat 은 이 규격을 구현한 프로그램이고, Spring 은 그 위에서 도는 프레임워크입니다.

구현체특징
Tomcat가장 널리 쓰임 · Spring Boot 기본값 · 자료가 압도적
Jetty가볍고 임베디드에 적합 · 비동기 처리에 강점
Undertow논블로킹 · 낮은 메모리 · WildFly 기반
WebLogic · WebSphere · JEUS상용 WAS — 대기업·금융 기간계. 톰캣보다 많은 기능(EJB·JTA 등)
Jakarta EE 이름 변경 — 오라클이 Java EE 를 재단에 넘기면서 패키지 이름이 javax.servlet → jakarta.servlet 으로 바뀌었습니다.
· Tomcat 9 이하 — javax.*
· Tomcat 10 이상 — jakarta.*
이 때문에 WAR 를 톰캣 10 이상으로 그냥 옮기면 동작하지 않습니다. Spring Boot 3 이상은 jakarta.* 기준입니다.
톰캣은 웹서버가 아니라 서블릿 컨테이너입니다 HTTP 요청 Connector 가 받습니다 자바 객체로 변환 HttpServletRequest 서블릿 실행 내 애플리케이션 코드 응답 다시 HTTP 로 요청마다 스레드 하나 스레드 풀에서 하나 꺼내 처리하고 끝나면 돌려줍니다 — 동시 처리 수는 풀 크기가 정합니다 그래서 느린 요청이 스레드를 오래 붙잡으면 다른 요청이 대기합니다 스프링 부트는 이 톰캣을 라이브러리처럼 안에 품고 실행합니다 — 별도 설치가 필요 없습니다 정적 파일도 다룰 수 있지만 앞에 Nginx 를 두는 편이 일반적입니다 — 압축과 캐시가 함께 해결됩니다 이름이 비슷한 것들 — Jetty · Undertow 도 같은 자리의 서블릿 컨테이너입니다
톰캣을 이해하는 한 문장 — HTTP 를 자바 객체로 바꿔 주는 층입니다.
SECTION 02

설치와 디렉터리

압축을 푸는 것이 곧 설치입니다. 폴더 네 개만 알면 됩니다.

# 자바 먼저 (Tomcat 11 은 Java 17 이상)
sudo apt install -y openjdk-21-jdk
java -version

# 톰캣 내려받기
cd /opt
sudo curl -LO https://dlcdn.apache.org/tomcat/tomcat-11/v11.0.x/bin/apache-tomcat-11.0.x.tar.gz
sudo tar xzf apache-tomcat-11.0.x.tar.gz
sudo mv apache-tomcat-11.0.x tomcat

# 전용 계정 (★ root 로 실행하지 마세요)
sudo useradd -r -s /bin/false tomcat
sudo chown -R tomcat:tomcat /opt/tomcat

# 실행
sudo -u tomcat /opt/tomcat/bin/startup.sh
curl -I http://localhost:8080
sudo -u tomcat /opt/tomcat/bin/shutdown.sh
폴더내용
bin/실행 스크립트 — startup.sh · shutdown.sh · catalina.sh
conf/설정 — server.xml · context.xml · web.xml · tomcat-users.xml
webapps/애플리케이션 — WAR 를 여기 넣으면 자동 배포
logs/로그 — catalina.out · localhost_access_log
lib/공용 라이브러리 (JDBC 드라이버 등)
temp/ · work/임시 파일 · JSP 컴파일 결과 (문제 시 지워도 됩니다)

systemd 서비스로 등록

# /etc/systemd/system/tomcat.service
[Unit]
Description=Apache Tomcat 11
After=network.target

[Service]
Type=forking
User=tomcat
Group=tomcat

Environment="JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64"
Environment="CATALINA_HOME=/opt/tomcat"
Environment="CATALINA_BASE=/opt/tomcat"
Environment="CATALINA_PID=/opt/tomcat/temp/tomcat.pid"
Environment="CATALINA_OPTS=-Xms512m -Xmx1024m -XX:+UseG1GC"

ExecStart=/opt/tomcat/bin/startup.sh
ExecStop=/opt/tomcat/bin/shutdown.sh

Restart=on-failure
RestartSec=10
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now tomcat
sudo journalctl -u tomcat -f
CATALINA_HOME 과 CATALINA_BASE — 한 톰캣 설치본(HOME)으로 여러 인스턴스(BASE)를 돌릴 수 있습니다. BASE 에 conf·webapps·logs 만 두면 업그레이드 시 HOME 만 교체하면 됩니다. 한 서버에 여러 앱을 올리는 환경에서 유용합니다.
SECTION 03

server.xml 해부

톰캣의 심장입니다. 구조만 알면 어렵지 않습니다.

<Server port="8005" shutdown="SHUTDOWN">          <!-- 종료 명령을 받는 포트 -->

  <Service name="Catalina">

    <!-- 요청을 받는 창구 -->
    <Connector port="8080"
               protocol="org.apache.coyote.http11.Http11NioProtocol"
               connectionTimeout="20000"
               maxThreads="200"
               minSpareThreads="10"
               acceptCount="100"
               maxConnections="8192"
               URIEncoding="UTF-8"
               compression="off"
               redirectPort="8443" />

    <Engine name="Catalina" defaultHost="localhost">

      <Host name="localhost" appBase="webapps"
            unpackWARs="true" autoDeploy="true">

        <!-- 접근 로그 -->
        <Valve className="org.apache.catalina.valves.AccessLogValve"
               directory="logs" prefix="localhost_access_log"
               suffix=".txt"
               pattern="%h %l %u %t &quot;%r&quot; %s %b %D" />

        <!-- 프록시 뒤에서 실제 IP 복원 -->
        <Valve className="org.apache.catalina.valves.RemoteIpValve"
               remoteIpHeader="X-Forwarded-For"
               protocolHeader="X-Forwarded-Proto"
               internalProxies="127\.0\.0\.1|10\.\d+\.\d+\.\d+" />

      </Host>
    </Engine>
  </Service>
</Server>
요소역할
Server톰캣 전체. port="8005" 는 종료 명령용
Service커넥터들과 엔진을 묶는 단위
Connector요청을 받는 창구 — 포트·프로토콜·스레드 (t04)
Engine요청을 처리할 호스트를 고름
Host가상 호스트 — appBase 가 앱이 놓이는 폴더
Context앱 하나 — 보통 자동 인식되므로 직접 쓸 일은 적습니다
Valve요청 처리 중간에 끼어드는 필터 (로그·IP 복원·접근 제어)
종료 포트(8005)를 반드시 막으세요. 이 포트에 SHUTDOWN 문자열을 보내면 누구나 톰캣을 종료시킬 수 있습니다.
· port="-1" 로 아예 비활성화하거나
· shutdown 문자열을 추측 불가능한 값으로 바꾸고
· 방화벽에서 외부 접근을 차단하세요
SECTION 04

Connector — HTTP · AJP

동시 처리 능력이 여기서 결정됩니다.

속성의미기본값
maxThreads동시에 처리할 최대 요청 수200
minSpareThreads미리 만들어 두는 스레드10
acceptCount스레드가 다 찼을 때 대기열 길이 — 넘치면 연결 거부100
maxConnections동시에 유지할 연결 수 (NIO 기준)8192
connectionTimeout연결 후 요청이 오기까지 대기 (ms)60000
keepAliveTimeout유휴 연결 유지 시간connectionTimeout 과 동일
maxHttpHeaderSize헤더 최대 크기 — 쿠키가 크면 늘려야 합니다8192
URIEncodingURL 인코딩UTF-8 (8.0+)

프로토콜 구현체

구현설명
NIO (Http11NioProtocol)기본값. 논블로킹 IO — 대부분 이것을 씁니다
NIO2비동기 IO. 특정 워크로드에서 이점이 있으나 차이가 크지 않습니다
APR/nativeOpenSSL 기반 네이티브. 9.0·10.0 에서 폐기 예고 → 10.1 에서 커넥터가 제거됐습니다 (11 에도 없음)
AJPApache 전용 이진 프로토콜 — 보안 이슈로 기본 비활성화
<!-- 프록시 뒤에 있을 때의 권장 설정
     (XML 은 태그 안에 주석을 넣을 수 없으므로 설명은 여기에 모읍니다)
     · address="127.0.0.1"   ★ 외부에서 직접 접근 차단
     · keepAliveTimeout      ★ Nginx 업스트림 keepalive_timeout(기본 60s) 보다 길게
     · compression="off"     압축은 Nginx 가 담당
     · enableLookups="false" ★ DNS 역조회 끄기 (성능)
     · server="server"       서버 정보 숨김
     · proxyName·proxyPort   앞단 도메인 -->
<Connector port="8080"
           protocol="org.apache.coyote.http11.Http11NioProtocol"
           address="127.0.0.1"
           maxThreads="200"
           acceptCount="200"
           connectionTimeout="20000"
           keepAliveTimeout="65000"
           maxHttpHeaderSize="16384"
           URIEncoding="UTF-8"
           compression="off"
           enableLookups="false"
           server="server"
           proxyName="example.com"
           proxyPort="443"
           scheme="https"
           secure="true" />
address="127.0.0.1" 을 반드시 설정하세요. 기본값은 모든 인터페이스에 바인드되어 누구나 서버IP:8080 으로 프록시를 우회해 접속할 수 있습니다. Nginx 의 보안 설정·rate limit·접근 제어가 전부 무력화됩니다.
enableLookups="false" 는 꼭 확인하세요. true 면 request.getRemoteHost() 호출 시 DNS 역조회를 수행해 요청당 수십~수백 ms 가 추가될 수 있습니다. 기본값은 false 이지만 옛 설정에서 true 인 경우가 있습니다.
요청이 통과하는 네 관문 — 가장 좁은 곳이 실제 처리량입니다 ① 연결 수락 maxConnections 기본 8192 ② 처리 스레드 maxThreads 기본 200 ← 좁아짐 ③ 대기열 acceptCount 넘치면 연결 거부 ④ DB 커넥션 풀 HikariCP 기본 10 ← 실제로는 여기가 병목 maxThreads 500 · DB 풀 20 이면 → 480개 스레드는 커넥션을 기다리며 놀고 있습니다 메모리(스레드당 1MB)와 문맥 전환 비용만 늘고 처리량은 그대로입니다 "maxThreads 를 올렸는데 안 빨라져요" — 정상입니다. 병목이 ②가 아니라 ④에 있다는 신호입니다. "Connection refused" 가 나면 ③까지 가득 찬 것입니다. 늘려야 할 것은 대기열이 아니라 처리 속도입니다. 대기열을 늘리면 거부 대신 "오래 기다리다 타임아웃"이 됩니다 — 사용자 경험은 오히려 나빠집니다. 진단은 항상 스레드 덤프부터입니다. 스레드들이 어디서 멈춰 있는지가 곧 병목의 위치입니다.
튜닝의 첫 질문은 "어느 관문이 가장 좁은가" 입니다 — 그 밖의 숫자를 만지는 것은 대부분 헛수고입니다.

프록시 뒤에 둘 때 꼭 맞춰야 하는 값

설정안 맞추면 생기는 일
address="127.0.0.1"8080 포트가 인터넷에 노출 — 프록시의 보안 설정이 전부 우회됩니다
keepAliveTimeoutNginx 값보다 짧으면 간헐적 502 — 재사용하려는 순간 톰캣이 끊습니다
proxyName·proxyPort·scheme앱이 만든 리다이렉트 URL 이 http://127.0.0.1:8080 으로 나갑니다
RemoteIpValve접근 로그와 앱의 IP 가 전부 프록시 주소로 찍힙니다
maxHttpHeaderSize쿠키·JWT 가 크면 431 Request Header Fields Too Large
compression="off"Nginx 와 이중 압축 — CPU 만 쓰고 이득이 없습니다
<!-- server.xml 의 <Host> 안 — 실제 클라이언트 IP 를 복원합니다 -->
<Valve className="org.apache.catalina.valves.RemoteIpValve"
       remoteIpHeader="X-Forwarded-For"
       protocolHeader="X-Forwarded-Proto"
       internalProxies="127\.0\.0\.1|10\.\d+\.\d+\.\d+" />
<!-- ★ internalProxies 를 반드시 지정하세요.
     안 하면 클라이언트가 X-Forwarded-For 를 위조해
     IP 기반 접근 제어를 헤더 한 줄로 우회할 수 있습니다. -->
# Spring Boot 내장 톰캣이면 설정 파일 한 곳에서 끝납니다
server.address=127.0.0.1
server.port=8080
server.tomcat.threads.max=200
server.tomcat.accept-count=200
server.tomcat.max-connections=8192
server.tomcat.connection-timeout=20s
# ★ Nginx keepalive_timeout(60s) 보다 길게
server.tomcat.keep-alive-timeout=65s
server.tomcat.max-http-form-post-size=10MB
server.max-http-request-header-size=16KB

# 프록시 헤더 신뢰 — 이 한 줄이면 RemoteIpValve 가 자동 등록됩니다
server.forward-headers-strategy=native
server.tomcat.remoteip.internal-proxies=127\\.0\\.0\\.1|10\\.\\d+\\.\\d+\\.\\d+

# 접근 로그 (기본 꺼져 있습니다 — 켜 두세요)
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.pattern=%h %l %u %t "%r" %s %b %D
#   %D = 처리 시간(Tomcat 10+ 는 마이크로초 · 9 이하는 ms) ★ 이게 없으면 "어느 요청이 느린가"를 못 봅니다
keepAliveTimeout 을 Nginx 보다 짧게 두면 간헐적 502 가 납니다. Nginx 가 유휴 연결을 재사용하려는 바로 그 순간 톰캣이 연결을 닫으면, 요청이 허공에 나가 502 로 끝납니다. 재현이 안 되고 부하가 낮을 때 오히려 자주 나는 것이 특징입니다 (유휴 시간이 길어야 생기므로). 백엔드 쪽을 항상 더 길게 잡는 것이 원칙입니다.
SECTION 05

WAR 배포 · 컨텍스트

WAR 파일을 webapps/ 에 넣으면 자동으로 풀려서 배포됩니다.

# 배포
sudo cp myapp.war /opt/tomcat/webapps/
# → /opt/tomcat/webapps/myapp/ 로 자동 압축 해제
# → 접속 주소: http://localhost:8080/myapp

# 루트 경로(/)로 배포하려면 파일 이름을 ROOT.war 로
sudo cp myapp.war /opt/tomcat/webapps/ROOT.war

# 제거 — WAR 를 지우면 폴더도 함께 삭제됩니다
sudo rm /opt/tomcat/webapps/myapp.war
WAR 파일명접속 경로
ROOT.war/
myapp.war/myapp
myapp##1.2.0.war/myapp — 버전 표기(병렬 배포용)
api#v1.war/api/v1 — # 이 / 로

경로를 직접 지정하기 — context 파일

<!-- conf/Catalina/localhost/myapp.xml -->
<!-- 파일 이름이 곧 컨텍스트 경로가 됩니다 -->
<Context docBase="/opt/apps/myapp" reloadable="false">

  <!-- 앱에서 쓸 DB 커넥션 풀 -->
  <Resource name="jdbc/mydb"
            auth="Container"
            type="javax.sql.DataSource"
            driverClassName="org.postgresql.Driver"
            url="jdbc:postgresql://db:5432/shop"
            username="app"
            password="${DB_PASSWORD}"
            maxTotal="20" maxIdle="10" maxWaitMillis="3000"
            validationQuery="SELECT 1" testOnBorrow="true" />
</Context>
reloadable="true" 는 운영에서 쓰지 마세요. 클래스 파일 변경을 주기적으로 감시하느라 자원을 쓰고, 재로딩 시 메모리 누수(PermGen·Metaspace)가 쌓입니다. 개발 환경에서만 쓰세요.

배포 방식 비교

방식설명
파일 복사가장 단순. CI 에서 scp 후 재시작
Manager 앱웹 UI·API 로 배포. 보안 위험이 크므로 운영에서는 제거 권장
병렬 배포##버전 표기로 신구 버전을 동시에 두고 세션이 끝나면 옛 버전 제거
컨테이너 교체★ 요즘 표준 — 이미지를 새로 빌드해 컨테이너를 갈아 끼웁니다
webapps 에 놓은 파일 이름이 곧 주소가 됩니다 webapps 안의 파일 접속 주소 shop.war http://서버:8080/shop/ ROOT.war http://서버:8080/ ← 앞부분 없이 최상위 api#v2.war http://서버:8080/api/v2/ — # 이 / 가 됩니다 배포는 파일을 두는 것만으로 끝납니다 autoDeploy 가 켜져 있으면 war 를 복사하는 순간 풀리고 뜹니다 지울 때는 war 와 풀린 폴더를 둘 다 지워야 합니다 폴더 밖에 두고 싶다면 conf/Catalina/localhost/shop.xml 에 <Context docBase="/opt/apps/shop" /> 로 지정합니다
"주소에 /shop 이 왜 붙지"의 답은 파일 이름입니다 — 최상위로 쓰려면 ROOT.war 입니다.
webapps 에 놓은 파일 이름이 곧 주소가 됩니다 webapps 안의 파일 접속 주소 shop.war http://서버:8080/shop/ ROOT.war http://서버:8080/ ← 앞부분 없이 최상위 api#v2.war http://서버:8080/api/v2/ — # 이 / 가 됩니다 배포는 파일을 두는 것만으로 끝납니다 autoDeploy 가 켜져 있으면 war 를 복사하는 순간 풀리고 뜹니다 지울 때는 war 와 풀린 폴더를 둘 다 지워야 합니다 폴더 밖에 두고 싶다면 conf/Catalina/localhost/shop.xml 에 <Context docBase="/opt/apps/shop" /> 로 지정합니다
"주소에 /shop 이 왜 붙지"의 답은 파일 이름입니다 — 최상위로 쓰려면 ROOT.war 입니다.
SECTION 06

Spring Boot 내장 톰캣

Spring Boot 는 톰캣을 jar 안에 품고 있습니다. 그래서 java -jar 하나로 서버가 뜹니다.

# application.properties — 내장 톰캣 설정
server.port=8080
# ★ 프록시 뒤라면 로컬만
server.address=127.0.0.1

# 스레드 풀
server.tomcat.threads.max=200
server.tomcat.threads.min-spare=20
server.tomcat.accept-count=200
server.tomcat.max-connections=8192

# 타임아웃
server.tomcat.connection-timeout=20s
# Nginx 업스트림 keepalive_timeout(기본 60s) 보다 길게 — 짧으면 간헐적 502 (t04)
server.tomcat.keep-alive-timeout=65s

# 프록시 뒤에서 실제 IP·프로토콜 인식 (★ 매우 중요)
server.forward-headers-strategy=native
server.tomcat.remoteip.remote-ip-header=X-Forwarded-For
server.tomcat.remoteip.protocol-header=X-Forwarded-Proto
server.tomcat.remoteip.internal-proxies=127\\.0\\.0\\.1|10\\.\\d+\\.\\d+\\.\\d+

# 접근 로그
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.directory=/var/log/myapp
server.tomcat.accesslog.pattern=%h %l %u %t "%r" %s %b %D

# 업로드 크기
spring.servlet.multipart.max-file-size=50MB
spring.servlet.multipart.max-request-size=50MB

# 우아한 종료 (무중단 배포에 필수)
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

# 오류 응답에서 정보 노출 줄이기
server.error.include-stacktrace=never
server.error.include-message=never

실행 방식

# 기본
java -jar myapp.jar

# JVM 옵션과 함께 (운영 권장)
#   -Xmx 와 -XX:MaxRAMPercentage 를 같이 주면 -Xmx 가 이깁니다 — 컨테이너라면 비율 하나만 (t10)
java -Xms512m -Xmx1g \
     -XX:+UseG1GC \
     -Dspring.profiles.active=prod \
     -Dfile.encoding=UTF-8 \
     -jar myapp.jar

# 환경 변수로 설정 주입 (설정 파일에 비밀번호를 넣지 마세요)
export SPRING_DATASOURCE_PASSWORD='...'
java -jar myapp.jar
# /etc/systemd/system/myapp.service
[Unit]
Description=My Spring Boot App
After=network.target

[Service]
User=appuser
WorkingDirectory=/opt/myapp
Environment="JAVA_OPTS=-Xms512m -Xmx1g -XX:+UseG1GC"
# 비밀은 이 파일에 (권한 600)
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/java $JAVA_OPTS -jar /opt/myapp/myapp.jar
# SIGTERM 종료를 정상으로 취급
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
WAR 대신 jar 를 쓰세요. Spring Boot 3 이후로는 실행 가능한 jar 가 표준입니다.
· 톰캣을 따로 설치·관리할 필요가 없습니다
· 버전이 앱에 고정되어 환경 차이가 사라집니다
· 컨테이너 이미지로 만들기 쉽습니다
WAR 가 필요한 경우는 기존 WAS 에 배포해야 하는 조직 정책 정도입니다.
SECTION 07

정적 자원 분리

이미지·CSS·JS 를 톰캣이 처리하면 귀중한 스레드가 파일 전송에 묶입니다. Nginx 에 맡기세요.

✗ 전부 톰캣이 처리
페이지 하나 = HTML 1 + CSS 2 + JS 3 + 이미지 20
            = 요청 26개

톰캣 스레드 200개로
동시 사용자 8명이면 이미 포화

느린 모바일 사용자 한 명이
스레드를 수 초간 붙잡습니다
✓ 정적은 Nginx 가
Nginx  : 이미지·CSS·JS 25개 처리 (매우 빠름)
Tomcat : HTML(API) 1개만 처리

톰캣 스레드 200개로
동시 사용자 200명 처리 가능

+ 캐시 헤더로 재요청 자체를 없앰
server {
    listen 443 ssl;
    server_name example.com;

    # 정적 자원 — Nginx 가 직접
    location ^~ /static/ {
        alias /opt/myapp/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location ^~ /uploads/ {
        alias /var/data/uploads/;
        expires 30d;
        add_header Content-Disposition "attachment";   # 브라우저 실행 방지
    }

    location = /favicon.ico { alias /opt/myapp/static/favicon.ico; access_log off; }

    # 나머지는 톰캣으로
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}
Spring Boot 의 정적 자원은 jar 안에 있습니다. src/main/resources/static/ 의 파일은 jar 에 포함되므로 Nginx 가 직접 읽을 수 없습니다.
· 방법 ① — 빌드 시 정적 파일을 별도 폴더로 복사해 배포
· 방법 ② — 프런트엔드를 완전히 분리해 별도 빌드·배포 (권장)
· 방법 ③ — CDN·오브젝트 스토리지에 업로드
업로드 파일은 절대 앱을 거쳐 내려주지 마세요. 수 MB 파일 하나가 스레드를 수 초간 점유합니다. Nginx 의 X-Accel-Redirect(내부 리다이렉트)를 쓰면 권한 검사는 앱이 하고 파일 전송은 Nginx 가 하는 구성이 가능합니다.

X-Accel-Redirect — 권한은 앱이, 전송은 Nginx 가

// 앱은 권한만 확인하고 "이 파일을 보내라"고 지시만 합니다 — 전송은 안 합니다
@GetMapping("/files/{id}")
public ResponseEntity<Void> download(@PathVariable Long id, @LoginUser Member me) {

    Attachment att = attachmentService.findAccessible(id, me);   // 권한 검사

    return ResponseEntity.ok()
        // ★ Nginx 만 이해하는 내부 경로 — 브라우저에는 안 나갑니다
        .header("X-Accel-Redirect", "/protected/" + att.storedName())
        .header(HttpHeaders.CONTENT_DISPOSITION,
                ContentDisposition.attachment()
                    .filename(att.originalName(), StandardCharsets.UTF_8).build().toString())
        .header(HttpHeaders.CONTENT_TYPE, att.contentType())
        .build();   // ★ 본문 없음 — 스레드가 즉시 반납됩니다
}
location /protected/ {
    internal;                       # ★ 외부에서 직접 접근 불가 — 앱을 거쳐야만
    alias /var/data/uploads/;
    sendfile on;
    sendfile_max_chunk 512k;        # 큰 파일이 워커를 독점하지 않게
}
100MB 파일 1000명 동시 다운로드결과
앱이 직접 스트리밍스레드 1000개 필요 — maxThreads 200 이면 즉시 포화
X-Accel-Redirect앱 스레드는 수 ms 만 사용 — 전송은 Nginx 가 이벤트 루프로
Apache 에도 같은 기능이 있습니다 — X-Sendfile(mod_xsendfile). S3 같은 오브젝트 스토리지를 쓴다면 서명된 URL(presigned URL)이 같은 역할을 합니다 — 앱이 권한을 확인하고 짧은 수명의 URL 만 발급하면 파일은 스토리지가 직접 내보냅니다. 어떤 방식이든 원칙은 하나입니다 — 앱 스레드로 바이트를 나르지 마세요.

Spring Boot 정적 자원을 밖으로 빼는 실전

# 방법 ① 빌드 산출물에서 정적 파일만 뽑아 배포
./gradlew bootJar
mkdir -p /opt/myapp/static
unzip -o build/libs/app.jar 'BOOT-INF/classes/static/*' -d /tmp/x
cp -r /tmp/x/BOOT-INF/classes/static/* /opt/myapp/static/
#   → Nginx 의 location ^~ /static/ 이 이 폴더를 직접 서빙합니다

# 방법 ② 프런트엔드를 완전히 분리 (권장)
#   프런트: Vite·Next 빌드 → dist/ → Nginx 또는 CDN
#   백엔드: API 만 담당 → 톰캣은 JSON 만 만듭니다
#   장점: 배포 주기 분리 · 캐시 전략 분리 · 톰캣 부하 최소
# 톰캣이 정적 파일을 아예 안 다루게 하기 (API 전용 서버라면)
spring.web.resources.add-mappings=false
#   → 없는 경로가 404 로 명확히 떨어집니다.
#     기본값 true 면 정적 리소스를 찾느라 불필요한 파일 시스템 접근이 생기고,
#     API 오타가 404 대신 index.html(200) 로 응답되는 혼란도 사라집니다.
SECTION 08

세션 · 클러스터링

서버가 여러 대가 되는 순간 로그인 정보를 어떻게 공유할지 정해야 합니다.

방식설명평가
Sticky Session LB 가 같은 사용자를 항상 같은 서버로 보냅니다 간단하지만 서버가 죽으면 로그아웃 · 부하 불균형
세션 복제 톰캣끼리 세션을 서로 복사합니다 서버가 늘수록 네트워크 비용이 급증 — 소규모에만
외부 저장소 세션을 Redis 등에 저장합니다 ★ 실무 표준 — 서버는 무상태가 됩니다
JWT · 무상태 서버에 세션을 두지 않고 토큰으로 확장에 가장 유리 · 무효화가 어렵다는 단점
<!-- 톰캣 세션 복제 (참고용 — 실무에서는 잘 안 씁니다) -->
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
         channelSendOptions="8">
  <Manager className="org.apache.catalina.ha.session.DeltaManager"
           expireSessionsOnShutdown="false"
           notifyListenersOnReplication="true"/>
  ...
</Cluster>
<!-- web.xml 에 <distributable/> 도 필요하고,
     세션에 담는 객체가 전부 Serializable 이어야 합니다 -->

권장 — Spring Session + Redis

# 의존성: spring-session-data-redis — 의존성만 있으면 자동 구성됩니다
# (spring.session.store-type 속성은 Spring Boot 3.0 에서 삭제됐습니다)
spring.session.timeout=30m
spring.session.redis.namespace=myapp:session

spring.data.redis.host=redis.internal
spring.data.redis.port=6379
spring.data.redis.password=${REDIS_PASSWORD}
이 설정만으로 앱이 무상태가 됩니다.
· 서버를 몇 대든 자유롭게 늘리고 줄일 수 있습니다
· 배포 중 서버가 재시작돼도 로그인이 유지됩니다
· LB 에 sticky session 설정이 필요 없습니다
Redis 는 🗄️ DB 가이드에서 다루는 캐시 계층과 함께 운영하면 됩니다.
# 그래도 sticky session 이 필요하다면 (Nginx)
upstream tomcat {
    ip_hash;                      # IP 기반 고정 (모바일에서 불안정)
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
}

# 또는 쿠키 기반 (Nginx Plus 또는 서드파티 모듈 필요)
# sticky cookie srv_id expires=1h;
SECTION 09

스레드 풀 튜닝

maxThreads 를 무작정 올리면 오히려 느려집니다. 근거를 갖고 정해야 합니다.

왜 무작정 늘리면 안 되나 — 스레드는 각각 스택 메모리(기본 1MB)를 차지하고, 개수가 많아지면 컨텍스트 스위칭 비용이 커집니다. 또 뒤쪽 DB 커넥션 풀이 20개인데 톰캣 스레드가 500개면 480개는 커넥션을 기다리며 대기할 뿐입니다. 병목은 스레드가 아니라 그 뒤에 있는 경우가 대부분입니다.

산정하는 법

필요한 스레드 수 ≈ 목표 RPS × 평균 응답 시간(초)

예) 목표 500 RPS · 평균 응답 0.2초
    → 500 × 0.2 = 100 스레드

여기에 여유 1.5~2배 → maxThreads = 150~200

※ 응답 시간이 대부분 'DB 대기'라면
   스레드를 늘려도 DB 커넥션이 병목이라 효과가 없습니다
   → DB 커넥션 풀과 함께 조정해야 합니다
# Spring Boot
server.tomcat.threads.max=200
server.tomcat.threads.min-spare=20
# 스레드가 다 차면 여기에 대기
server.tomcat.accept-count=200
server.tomcat.max-connections=8192

# DB 커넥션 풀과 균형을 맞추세요
spring.datasource.hikari.maximum-pool-size=20
증상원인과 조치
응답이 갑자기 느려짐 스레드 포화. 스레드 덤프로 어디서 막혔는지 확인 (대개 DB·외부 API 대기)
연결이 거부됨 acceptCount 대기열까지 가득 참. 근본 원인은 처리 속도
스레드를 늘려도 안 빨라짐 정상입니다. 병목이 다른 곳에 있다는 신호
OOM · 메모리 부족 스레드가 너무 많음. 스레드당 스택 1MB × 개수를 계산해 보세요

가상 스레드 Java 21+

# Spring Boot 3.2+ · Java 21+
spring.threads.virtual.enabled=true

# 요청마다 가상 스레드를 만들어 처리합니다
# · 블로킹 IO 대기 중에는 OS 스레드를 반납 → 수만 동시 요청 가능
# · maxThreads 제약에서 사실상 자유로워집니다
가상 스레드가 만능은 아닙니다.
· synchronized 블록 안에서 블로킹하면 캐리어 스레드가 고정(pinning)됩니다
· DB 커넥션 풀이 여전히 병목입니다 — 스레드가 늘어도 커넥션은 20개
· ThreadLocal 을 많이 쓰면 메모리 사용이 늘 수 있습니다
자세한 내용은 ☕ 자바 가이드의 고급·동시성 탭을 보세요.

스레드 풀과 DB 풀을 함께 맞추기

서비스 성격maxThreadsDB 풀 · 근거
DB 위주 CRUD100~200풀 10~20 — 요청 대부분이 DB 를 씁니다. 풀보다 스레드를 크게 잡아도 대기만 늘어납니다
외부 API 호출 위주200~400풀 10 — DB 는 거의 안 쓰고 네트워크 대기가 깁니다
계산 위주코어 수 × 2풀 5 — 더 늘리면 문맥 전환만 늘어납니다
정적 · 프록시—애초에 톰캣까지 오면 안 됩니다(t07)
가상 스레드
Java 21+
제약 없음풀이 유일한 상한이 됩니다 — 풀 크기를 반드시 재검토
DB 커넥션 풀은 크게 잡을수록 좋은 것이 아닙니다. HikariCP 문서의 권장 공식은 커넥션 수 = (코어 수 × 2) + 디스크 수 정도입니다 — 4코어면 10 안팎이 출발점입니다. DB 도 결국 코어 수만큼만 동시에 일할 수 있어서, 커넥션을 100개 열면 DB 내부에서 경합이 생겨 오히려 전체가 느려집니다. 앱 서버 대수 × 풀 크기가 DB 의 max_connections 를 넘지 않는지도 함께 확인하세요.
# ① 지금 스레드가 몇 개 쓰이고 있나 (JMX)
jcmd <pid> ManagementAgent.status
# Spring Boot Actuator 를 쓰면 훨씬 간단합니다
curl -s localhost:8081/actuator/metrics/tomcat.threads.busy
curl -s localhost:8081/actuator/metrics/tomcat.threads.config.max
curl -s localhost:8081/actuator/metrics/hikaricp.connections.pending   # ★ 이게 핵심

# ② 스레드가 어디서 막혀 있나 — 튜닝 전에 반드시
jcmd <pid> Thread.print > dump.txt
grep -c 'java.lang.Thread.State: BLOCKED'       dump.txt   # 락 경합
grep -c 'java.lang.Thread.State: WAITING'       dump.txt   # 외부 대기
grep -A3 '"http-nio-8080-exec' dump.txt | grep -o 'at [a-z.]*' | sort | uniq -c | sort -rn | head
#   → 톰캣 워커 스레드들이 공통으로 멈춰 있는 지점이 곧 병목입니다

# ③ 30초 간격으로 3번 떠서 비교하면 "계속 같은 곳"인지 알 수 있습니다
hikaricp.connections.pending 이 0 이 아니면 스레드를 늘려도 소용없습니다. 이 값은 커넥션을 못 받아 대기 중인 스레드 수입니다. 여기에 숫자가 쌓여 있다는 것은 병목이 DB 풀이라는 확정적인 증거입니다. 이때 할 일은 maxThreads 상향이 아니라 느린 쿼리 개선 · N+1 제거 · 캐시 · 풀 크기 재조정 순입니다. 지표를 안 보고 스레드 수부터 올리는 것이 가장 흔한 잘못된 대응입니다.
가상 스레드를 켜면 병목의 위치가 옮겨 갑니다. 톰캣 스레드 제약이 사라지는 대신, DB 풀·외부 API 커넥션 풀·메모리가 새로운 상한이 됩니다. 켜기 전에 maximum-pool-size 를 다시 검토하고, 속도 제한(rate limit)을 함께 두세요 — 수만 요청이 한꺼번에 들어와 DB 를 밀어붙이는 구조가 될 수 있습니다.
스레드 풀 — 늘리기 전에 왜 오래 잡는지부터 요청 도착 대기열에 들어갑니다 스레드 배정 maxThreads 만큼만 동시에 처리 끝나야 반납 느리면 그동안 계속 점유 무작정 늘리면 생기는 일 스레드마다 스택 메모리가 붙고 컨텍스트 전환이 늘어 전체가 느려집니다 뒤쪽 DB 커넥션 풀보다 크면 결국 거기서 대기하게 됩니다 — 함께 계산해야 합니다 먼저 볼 것 — 요청 하나가 왜 오래 걸리는가 대개 느린 쿼리나 외부 API 대기입니다. 그쪽을 고치면 지금 스레드 수로도 충분해집니다 지표를 보세요 — 사용 중인 스레드 수와 대기열 길이는 Actuator 나 JMX 로 확인할 수 있습니다 가상 스레드를 쓰면 이 제약이 크게 완화됩니다 — 기다리는 일이 대부분인 서비스에서 특히
스레드를 늘려 해결되는 문제는 드뭅니다 — 대개 붙잡는 시간이 문제입니다.
SECTION 10

JVM 메모리와 GC

자바는 메모리를 미리 잡아 씁니다. 컨테이너에서는 반드시 명시해야 합니다.

# 기본 옵션
#   -Xms1g -Xmx1g                  초기·최대 힙 (같게 잡으면 리사이징 비용 없음)
#   -XX:+UseG1GC                   Java 9+ 기본
#   -XX:+HeapDumpOnOutOfMemoryError ★ OOM 시 힙 덤프 저장
#   (줄 끝 \ 뒤에는 주석을 붙일 수 없어 설명을 위로 모았습니다)
java -Xms1g -Xmx1g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/myapp/ \
     -Xlog:gc*:file=/var/log/myapp/gc.log:time,uptime:filecount=5,filesize=10M \
     -jar myapp.jar
컨테이너에서는 -Xmx 대신 비율을 쓰세요.
-XX:MaxRAMPercentage=75
컨테이너 메모리 제한이 바뀌어도 자동으로 따라갑니다. 고정값을 쓰면 제한을 올렸는데 힙은 그대로이거나, 제한을 내렸는데 힙이 커서 OOMKilled 가 됩니다.

컨테이너 메모리 배분

컨테이너 메모리 2GB 일 때

  힙 (Xmx)              1.4GB   (70%)
  메타스페이스           ~200MB   (클래스 정보)
  스레드 스택            ~200MB   (200 스레드 × 1MB)
  다이렉트 버퍼·코드 캐시 ~150MB
  여유                   ~50MB
  ────────────────────────────
  합계                   2GB

★ 힙만 보고 2GB 를 다 주면 OOMKilled 가 납니다
  (컨테이너는 힙 밖 메모리도 함께 셉니다)
GC특징쓰는 곳
G1GC정지 시간 예측 가능 · 균형형★ 기본값 — 대부분 이것
ZGC정지 시간 1ms 이하 · 큰 힙에 적합저지연이 중요한 서비스
Parallel처리량 최대 · 정지 시간 김배치 작업
Serial단일 스레드 · 매우 작은 힙컨테이너 1 CPU 미만
# 실제 힙 사용량 확인
jcmd <pid> GC.heap_info
jstat -gc <pid> 1000 10        # 1초 간격 10회

# 힙 덤프 (OOM 분석용)
jcmd <pid> GC.heap_dump /tmp/heap.hprof
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>

# JVM 이 인식한 컨테이너 자원 확인
java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|ActiveProcessorCount'
메모리 문제의 진단 순서 — ① GC 로그에서 Full GC 빈도를 봅니다 (잦으면 힙 부족 또는 누수) ② 힙 덤프를 떠서 어떤 객체가 많은지 확인합니다 (MAT·VisualVM) ③ 힙은 여유 있는데 컨테이너가 죽으면 힙 밖 메모리를 의심합니다 (다이렉트 버퍼·네이티브 라이브러리·스레드 수)
힙은 두 구역 — 어디를 수집하느냐로 멈춤 시간이 갈립니다 Young (신생 영역) Eden S0 S1 Minor GC — 수십 ms · 매우 잦음 살아남으면 Old (오래된 영역) 오래 사는 객체 · 캐시 · 세션 Major GC — 수백 ms ~ 수 초 · 이때 앱이 멈춥니다 위험 신호 — 계속 GC 만 도는 상태 Old 가 가득 차 수집해도 안 비면, GC 가 끝없이 돌며 CPU 만 쓰고 요청은 처리되지 않습니다 응답 없음 · 504 로 나타납니다 — 대개 누수입니다 먼저 잡을 설정 -Xms 와 -Xmx 를 같은 값으로 (리사이징 멈춤 제거) 컨테이너면 -XX:MaxRAMPercentage=75 로 잡습니다 GC 로그를 켜 두세요 — 사고 후에는 켤 수 없습니다 튜닝의 순서 — GC 로그로 무엇이 문제인지 본 뒤 힙 크기 → GC 종류 순으로. 옵션부터 바꾸면 원인을 못 찾습니다 힙 덤프(-XX:+HeapDumpOnOutOfMemoryError)를 미리 켜 두면 OOM 이 났을 때 원인을 볼 수 있습니다
"가끔 몇 초씩 멈춰요"의 대부분은 Old 영역 수집입니다.
힙은 두 구역 — 어디를 수집하느냐로 멈춤 시간이 갈립니다 Young (신생 영역) Eden S0 S1 Minor GC — 수십 ms · 매우 잦음 살아남으면 Old (오래된 영역) 오래 사는 객체 · 캐시 · 세션 Major GC — 수백 ms ~ 수 초 · 이때 앱이 멈춥니다 위험 신호 — 계속 GC 만 도는 상태 Old 가 가득 차 수집해도 안 비면, GC 가 끝없이 돌며 CPU 만 쓰고 요청은 처리되지 않습니다 응답 없음 · 504 로 나타납니다 — 대개 누수입니다 먼저 잡을 설정 -Xms 와 -Xmx 를 같은 값으로 (리사이징 멈춤 제거) 컨테이너면 -XX:MaxRAMPercentage=75 로 잡습니다 GC 로그를 켜 두세요 — 사고 후에는 켤 수 없습니다 튜닝의 순서 — GC 로그로 무엇이 문제인지 본 뒤 힙 크기 → GC 종류 순으로. 옵션부터 바꾸면 원인을 못 찾습니다 힙 덤프(-XX:+HeapDumpOnOutOfMemoryError)를 미리 켜 두면 OOM 이 났을 때 원인을 볼 수 있습니다
"가끔 몇 초씩 멈춰요"의 대부분은 Old 영역 수집입니다.
SECTION 11

로그 설정

catalina.out 은 계속 커지기만 하는 파일입니다. 방치하면 디스크가 찹니다.

로그내용
catalina.out표준 출력·에러 전부. 로테이션이 안 됩니다
catalina.YYYY-MM-DD.log톰캣 자체 로그 (날짜별로 나뉨)
localhost_access_log.*.txt접근 로그 (AccessLogValve)
localhost.YYYY-MM-DD.log애플리케이션 오류
# /etc/logrotate.d/tomcat — catalina.out 로테이션
/opt/tomcat/logs/catalina.out {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    # ★ 톰캣을 재시작하지 않고 잘라냅니다
    copytruncate
    su tomcat tomcat
}
copytruncate 를 쓰는 이유 — 톰캣은 파일 핸들을 계속 열고 있어서, 파일을 이름만 바꾸면 계속 옛 파일에 씁니다(디스크는 그대로 차오릅니다). copytruncate 는 내용을 복사한 뒤 원본을 0바이트로 잘라 이 문제를 피합니다. (아주 짧은 순간의 로그 유실 가능성은 있습니다)

접근 로그

<Valve className="org.apache.catalina.valves.AccessLogValve"
       directory="logs"
       prefix="access" suffix=".log"
       pattern="%h %l %u %t &quot;%r&quot; %s %b %D &quot;%{User-Agent}i&quot; %{X-Request-Id}i"
       rotatable="true"
       maxDays="30" />
<!-- %D = 처리 시간(Tomcat 10+ 는 마이크로초) · %{헤더}i = 요청 헤더 -->

Spring Boot 라면 로그백으로

<!-- logback-spring.xml -->
<configuration>
  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>/var/log/myapp/app.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
      <fileNamePattern>/var/log/myapp/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
      <maxFileSize>100MB</maxFileSize>
      <maxHistory>30</maxHistory>
      <totalSizeCap>5GB</totalSizeCap>
    </rollingPolicy>
    <encoder>
      <pattern>%d{ISO8601} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n</pattern>
    </encoder>
  </appender>
  <root level="INFO"><appender-ref ref="FILE"/></root>
</configuration>
컨테이너 환경에서는 파일이 아니라 표준 출력으로 로그를 내보내세요. 도커·쿠버네티스가 수집·로테이션을 알아서 처리합니다. 파일에 쓰면 컨테이너가 사라질 때 로그도 함께 사라집니다.
SECTION 12

Nginx + Tomcat 연동

실무 표준 구성입니다. 처음부터 끝까지 만들어 봅니다.

# /etc/nginx/sites-available/myapp
upstream tomcat {
    server 127.0.0.1:8080;
    keepalive 32;                     # ★ 연결 재사용
}

server {
    listen 80;
    server_name example.com;
    location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; }
    location / { return 301 https://$host$request_uri; }
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    # 보안 헤더
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;

    # 업로드 크기
    client_max_body_size 50m;

    # 정적 자원
    location ^~ /static/ {
        alias /opt/myapp/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # 헬스체크 (로그 제외)
    location = /actuator/health {
        proxy_pass http://tomcat;
        access_log off;
    }

    # 관리 엔드포인트는 내부에서만
    location ^~ /actuator/ {
        allow 10.0.0.0/8;
        deny all;
        proxy_pass http://tomcat;
    }

    # 애플리케이션
    location / {
        proxy_pass http://tomcat;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port  $server_port;

        proxy_http_version 1.1;
        proxy_set_header Connection "";

        proxy_connect_timeout 5s;
        proxy_read_timeout   60s;
        proxy_send_timeout   60s;
    }
}
# 톰캣(Spring Boot) 쪽 — 프록시를 인식하게
server.address=127.0.0.1
server.port=8080
server.forward-headers-strategy=native

# Actuator 는 별도 포트로 빼는 것도 좋은 방법입니다
# ※ 이렇게 빼면 위 Nginx 의 /actuator/ location 은 http://127.0.0.1:8081 로 보내야 합니다
#   (8080 의 tomcat 업스트림으로 보내면 404)
management.server.port=8081
management.server.address=127.0.0.1
management.endpoints.web.exposure.include=health,info,metrics,prometheus
확인 절차
① curl -I http://127.0.0.1:8080/ — 톰캣이 직접 응답하나
② sudo nginx -t && sudo systemctl reload nginx
③ curl -I https://example.com/ — 전체 경로가 동작하나
④ 앱 로그에 실제 클라이언트 IP 가 찍히나
⑤ 정적 파일이 Nginx 로그에만 남고 톰캣 로그에는 없는지
Nginx + Tomcat — 각자 잘하는 일을 맡깁니다 브라우저 HTTPS 로 접속 Nginx TLS 종료 · 정적 파일 · 압축 · 제한 Tomcat 동적 요청만 · 평문 HTTP 로 받음 반드시 함께 해야 하는 설정 Nginx 는 X-Forwarded-For · X-Forwarded-Proto · Host 를 전달합니다 톰캣은 그 헤더를 신뢰하도록 켜야 합니다 (RemoteIpValve · server.forward-headers-strategy) 안 하면 로그에 프록시 주소만 남고, 리다이렉트가 http 로 만들어져 무한 루프가 나기도 합니다 톰캣 포트는 외부에 열지 마세요 — 프록시를 우회해 직접 들어오는 길이 생깁니다 AJP 는 요즘 잘 쓰지 않습니다 — 평문 HTTP 로 넘기고 방화벽으로 막는 구성이 일반적입니다 타임아웃은 앞뒤를 함께 맞추세요 — 앞이 짧으면 504, 뒤가 짧으면 502 가 납니다
이 구성의 사고는 거의 전부 헤더 전달 설정에서 납니다.
SECTION 13

HTTPS 처리 위치

암호화를 어디서 풀지 결정해야 합니다. 대부분 앞단에서 끝냅니다.

구성설명평가
Nginx 에서 종료
(TLS Termination)
Nginx 가 복호화하고 톰캣에는 평문 HTTP ★ 가장 흔함 — 인증서 관리가 한 곳
재암호화
(Re-encryption)
Nginx 가 복호화 후 톰캣에 다시 HTTPS 망 구간까지 암호화가 필요할 때 (금융·공공)
통과
(TLS Passthrough)
Nginx 가 그대로 흘려보내고 톰캣이 복호화 Nginx 가 내용을 못 보므로 라우팅·캐시 불가
앞단 종료 방식에서 가장 흔한 사고 — 리다이렉트 무한 루프
① 브라우저가 HTTPS 로 접속 → ② Nginx 가 HTTP 로 톰캣에 전달 → ③ Spring Security 가 "HTTP 네? HTTPS 로 가세요" 리다이렉트 → ④ 반복
해결 — X-Forwarded-Proto 헤더 전달 + server.forward-headers-strategy=native
# Spring Boot — 프록시 뒤에 있을 때 반드시
server.forward-headers-strategy=native

# 쿠키에 Secure 플래그 (HTTPS 에서만 전송)
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.same-site=lax
<!-- 순수 톰캣이라면 RemoteIpValve -->
<Valve className="org.apache.catalina.valves.RemoteIpValve"
       remoteIpHeader="X-Forwarded-For"
       protocolHeader="X-Forwarded-Proto"
       protocolHeaderHttpsValue="https"
       internalProxies="127\.0\.0\.1|10\.\d+\.\d+\.\d+|172\.1[6-9]\.\d+\.\d+" />
<!-- 이 밸브가 request.isSecure() 와 getRemoteAddr() 을 올바르게 만들어 줍니다 -->

톰캣에 직접 HTTPS 를 붙여야 한다면

# Let's Encrypt 인증서를 자바 키스토어(PKCS12)로 변환
sudo openssl pkcs12 -export \
  -in  /etc/letsencrypt/live/example.com/fullchain.pem \
  -inkey /etc/letsencrypt/live/example.com/privkey.pem \
  -out /opt/tomcat/conf/keystore.p12 \
  -name tomcat -passout pass:changeit
# Spring Boot
server.port=8443
server.ssl.key-store=/opt/myapp/keystore.p12
server.ssl.key-store-type=PKCS12
server.ssl.key-store-password=${KEYSTORE_PASSWORD}
server.ssl.key-alias=tomcat
server.ssl.enabled-protocols=TLSv1.2,TLSv1.3
갱신이 번거롭습니다. Let's Encrypt 는 90일마다 갱신되는데, 갱신할 때마다 키스토어를 다시 만들고 톰캣을 재시작해야 합니다. 이것이 앞단에서 TLS 를 끝내는 구성을 선호하는 실질적인 이유입니다.
HTTPS 를 어디서 끝낼 것인가 ① 앞단에서 끝내고 뒤는 평문 — 가장 흔합니다 인증서 관리가 한 곳으로 모이고 뒤쪽 서버의 부담이 줄어듭니다 앞단과 뒤쪽 사이가 신뢰할 수 있는 망일 때 씁니다 ② 앞단에서 끝내고 뒤로 다시 암호화 사내망 구간까지 암호화가 요구될 때 (규제 · 공용 네트워크) 인증서가 두 벌이 되고 성능 비용이 조금 듭니다 ③ 톰캣에서 직접 끝내기 앞단이 없는 단순한 구성에서만 씁니다 — 인증서 갱신을 앱 배포와 함께 다뤄야 합니다 서버가 여러 대가 되면 관리가 급격히 번거로워집니다 어느 쪽이든 앱은 원래 프로토콜을 알아야 합니다 — X-Forwarded-Proto 를 신뢰하도록 설정하세요 HSTS 는 앞단에서 한 번만 붙이세요 — 여러 층에서 중복으로 붙일 필요가 없습니다
종료 위치를 정하는 기준은 성능이 아니라 어디까지 신뢰할 수 있는 망인가입니다.
SECTION 14

무중단 배포

배포 중에도 처리 중인 요청이 끊기지 않게 만듭니다.

① 우아한 종료 (Graceful Shutdown)

# Spring Boot 2.3+
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

# 동작
# 1) SIGTERM 을 받으면 새 요청 수신을 중단
# 2) 처리 중인 요청을 최대 30초까지 기다림
# 3) 그 후 종료

② LB 에서 먼저 빼기 (드레인)

#!/usr/bin/env bash
set -euo pipefail

# 1) 헬스체크를 실패시켜 LB 가 이 서버를 제외하게 합니다
curl -fsS -X POST http://127.0.0.1:8081/actuator/shutdown-prepare || true
#    (또는 파일 플래그를 두고 헬스 엔드포인트가 그것을 보게 합니다)

# 2) LB 가 인지할 시간을 줍니다 (헬스체크 주기 × 실패 임계값)
sleep 15

# 3) 이제 종료해도 새 요청이 오지 않습니다
sudo systemctl stop myapp

# 4) 새 버전 배포 후 기동
sudo cp myapp-new.jar /opt/myapp/myapp.jar
sudo systemctl start myapp

# 5) 준비될 때까지 기다린 뒤 LB 에 복귀
until curl -fsS http://127.0.0.1:8081/actuator/health | grep -q '"UP"'; do sleep 2; done

③ 롤링 배포 (서버가 여러 대일 때)

# Nginx : 한 대씩 빼면서 배포합니다
upstream tomcat {
    server 10.0.1.11:8080 max_fails=2 fail_timeout=10s;
    server 10.0.1.12:8080 max_fails=2 fail_timeout=10s;
    server 10.0.1.13:8080 max_fails=2 fail_timeout=10s;
}

# 특정 서버를 잠시 제외하려면 down 을 붙이고 reload
#   server 10.0.1.11:8080 down;
# sudo nginx -s reload   (무중단)
무중단 배포에서 놓치기 쉬운 것
· DB 스키마 변경 — 신구 버전이 동시에 도는 동안 양쪽 모두 동작해야 합니다 (확장-축소 패턴 → 🗄️ DB 가이드 a09)
· 세션 — 서버에 세션이 있으면 그 서버를 내리는 순간 로그아웃됩니다 (t08)
· 진행 중인 배치·스케줄러 — 종료 시 중단되면 데이터가 반만 처리될 수 있습니다
· 캐시 워밍업 — 새 인스턴스는 캐시가 비어 있어 초기에 느립니다
쿠버네티스라면 이 과정을 플랫폼이 해 줍니다. readinessProbe(트래픽 받을 준비) · preStop 훅(드레인 대기) · terminationGracePeriodSeconds(종료 유예)를 설정하면 롤링 업데이트가 자동으로 무중단이 됩니다 → 🛡️ 운영 탭 o11.
SECTION 15

보안 설정

기본 상태의 톰캣은 관리자 화면과 버전 정보가 노출돼 있습니다. 반드시 정리하세요.

① 기본 앱 제거 — 가장 먼저

cd /opt/tomcat/webapps
sudo rm -rf docs examples manager host-manager ROOT
# manager 는 원격 배포 기능이라 침해 시 서버를 통째로 넘겨줍니다
# examples 는 알려진 취약점이 여러 개 있습니다

② 종료 포트 차단

<!-- server.xml -->
<Server port="-1" shutdown="SHUTDOWN">
<!-- 또는 추측 불가능한 문자열 + 방화벽 차단 -->

③ 서버 정보 숨기기

<!-- server = Server 헤더 값 변경 (태그 안에는 주석을 넣을 수 없습니다) -->
<Connector port="8080" ...
           server="server"
           xpoweredBy="false" />
# 오류 페이지의 버전 표기까지 없애려면 ServerInfo.properties 를 덮어씁니다
cd /opt/tomcat/lib
mkdir -p org/apache/catalina/util
cat > org/apache/catalina/util/ServerInfo.properties <<'EOF'
server.info=Server
server.number=0.0.0.0
server.built=
EOF
jar uf catalina.jar org/apache/catalina/util/ServerInfo.properties

④ 오류 페이지 커스터마이징

<!-- conf/web.xml 또는 앱의 web.xml -->
<error-page>
    <error-code>404</error-code>
    <location>/error/404.html</location>
</error-page>
<error-page>
    <exception-type>java.lang.Throwable</exception-type>
    <location>/error/500.html</location>
</error-page>
<!-- 기본 오류 페이지는 스택 트레이스와 톰캣 버전을 그대로 노출합니다 -->

⑤ 그 밖의 점검 항목

항목조치
실행 계정전용 계정(tomcat)으로 · 절대 root 금지
디렉터리 목록conf/web.xml 의 listings 를 false(기본값)로
AJP 커넥터쓰지 않으면 주석 처리. 쓰면 address="127.0.0.1" + secretRequired
파일 권한conf/ 는 600·700 — 비밀번호가 들어 있습니다
세션 쿠키HttpOnly · Secure · SameSite
버전 업데이트Tomcat CVE 는 자주 나옵니다 — 보안 공지를 구독하세요
외부 노출 차단8080 을 방화벽에서 막고 프록시만 접근하게
SECTION 16

문제 진단

응답이 없거나 느릴 때 무엇을 봐야 하는지 순서대로 정리합니다.

스레드 덤프 — 어디서 멈췄나

# 프로세스 ID 찾기
jps -l
# 또는 ps aux | grep java

# 스레드 덤프 (3~5초 간격으로 3회 뜨는 것이 정석)
jstack <pid> > /tmp/dump1.txt
sleep 5; jstack <pid> > /tmp/dump2.txt
sleep 5; jstack <pid> > /tmp/dump3.txt

# 또는
jcmd <pid> Thread.print > /tmp/dump.txt

# 상태별 개수 세기
grep 'java.lang.Thread.State' /tmp/dump1.txt | sort | uniq -c
142 java.lang.Thread.State: WAITING (parking) 45 java.lang.Thread.State: BLOCKED (on object monitor) ← 락 경합! 13 java.lang.Thread.State: RUNNABLE
상태의미
RUNNABLE실행 중 또는 IO 대기
BLOCKED락을 기다림 — 많으면 동기화 병목
WAITING다른 스레드를 무한 대기 (커넥션 풀 대기 등)
TIMED_WAITING시간 제한 대기 (sleep·poll)
3회를 뜨는 이유 — 같은 스레드가 세 번 모두 같은 위치에 있으면 진짜 막힌 것이고, 위치가 계속 바뀌면 정상적으로 일하는 중입니다. 덤프 하나만 보고 판단하면 틀립니다.

증상별 진단표

증상확인 순서
응답 없음 (행) ① 스레드 덤프 3회 → ② BLOCKED·WAITING 이 몰린 지점 확인 → ③ 대개 DB 커넥션 풀 고갈이나 외부 API 대기
OutOfMemoryError ① 힙 덤프 분석(MAT) → ② 어떤 객체가 많은지 → ③ 캐시·컬렉션 무한 증가 의심
Metaspace OOM 이면 클래스 로딩 누수(핫 리로드 반복)
CPU 100% ① top -H -p <pid> 로 바쁜 스레드 ID 확인 → ② 16진수로 변환 → ③ 덤프에서 nid=0x… 로 검색
흔한 원인: 무한 루프 · 잦은 Full GC · 정규식 폭주
점점 느려짐 GC 로그에서 Full GC 빈도와 회수량 확인 — 회수가 안 되면 메모리 누수
기동 실패 catalina.out 의 맨 앞 예외를 보세요. 뒤의 것은 연쇄 오류입니다
# CPU 를 많이 쓰는 스레드 찾기
top -H -p <pid>                       # TIME 이 큰 스레드의 PID 확인
printf '%x\n' <스레드PID>              # 16진수로 변환 → nid 값
grep -A 20 'nid=0x<16진수>' /tmp/dump.txt

# JFR 로 상세 프로파일링 (운영에서도 부하가 낮습니다)
jcmd <pid> JFR.start duration=60s filename=/tmp/rec.jfr
# JDK Mission Control 로 열어 봅니다

# 실시간 지표
jcmd <pid> VM.native_memory summary    # -XX:NativeMemoryTracking=summary 필요
jstat -gcutil <pid> 1000
더 깊은 JVM 진단은 ☕ 자바 가이드의 🔬 전문가 · JVM 내부 탭에서 다룹니다 — GC 내부·JFR·힙 덤프 분석·네이티브 메모리 추적까지 정리돼 있습니다.
CADDY 2 · 자동 HTTPS · 단일 바이너리 · HTTP/3

Caddy —
인증서를
알아서 해 주는

Caddy 의 존재 이유는 하나입니다 — HTTPS 를 설정 없이 자동으로. 도메인만 적으면 인증서를 발급받고 갱신까지 알아서 합니다. Go 로 만들어 바이너리 하나로 동작하고, 설정 문법이 압도적으로 간단합니다.

10SECTIONS
0인증서 설정
3줄사이트 하나
SECTION 01

자동 HTTPS 의 서버

Nginx 설정 30줄이 Caddy 에서는 3줄입니다. 비교해 보면 차이가 분명합니다.

Nginx — HTTPS 정적 사이트
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}
server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    root /var/www/html;
    index index.html;
}
# + certbot 설치 · 발급 · 갱신 타이머 설정
Caddy — 같은 결과
example.com {
    root * /var/www/html
    file_server
}

# 끝입니다.
# · 인증서 자동 발급
# · 자동 갱신
# · HTTP → HTTPS 리다이렉트 자동
# · HTTP/2 · HTTP/3 자동
# · 보안 기본값 적용

기본으로 켜져 있는 것들

  • 자동 HTTPS — Let's Encrypt(또는 ZeroSSL)에서 발급·갱신
  • HTTP → HTTPS 리다이렉트 — 별도 설정 없이
  • HTTP/2 · HTTP/3 — 기본 활성화
  • 안전한 TLS 기본값 — 취약한 프로토콜·암호가 처음부터 비활성
  • OCSP 스테이플링, 세션 재개
왜 이게 중요한가 — 인증서 만료로 서비스가 멈추는 사고는 정말 자주 일어납니다. 갱신 스크립트가 조용히 실패해도 90일 뒤에야 알게 되기 때문입니다. Caddy 는 이 문제를 구조적으로 없앱니다.

그럼에도 Nginx 가 여전히 주류인 이유

  • 자료와 사례의 양 — 문제가 생겼을 때 검색 결과가 압도적으로 다릅니다
  • 조직의 익숙함 — 운영팀이 아는 도구를 쓰는 것이 안전합니다
  • 대규모 운영 사례 — 초대형 트래픽 환경의 검증 사례가 Nginx 쪽이 훨씬 많습니다
  • 플러그인 방식 — Caddy 는 기능을 추가하려면 재빌드가 필요합니다
  • 세밀한 제어 — Nginx 가 제공하는 저수준 튜닝 옵션이 더 많습니다
현실적인 선택 — 개인 프로젝트·사내 도구·소규모 서비스라면 Caddy 가 시간을 크게 아껴 줍니다. 팀이 운영하는 서비스라면 조직이 익숙한 것을 쓰는 편이 낫습니다.
Caddy 의 핵심은 기능이 아니라 자동 HTTPS 입니다 보통의 서버에서 해야 하는 일 ① 발급 도구 설치 (certbot) ② 발급받고 경로를 설정에 적기 ③ 갱신 일정 걸고 갱신 후 reload 한 단계라도 빠지면 만료 장애 Caddy 에서 해야 하는 일 example.com { reverse_proxy localhost:8080 } 이게 전부입니다 — 발급과 갱신은 알아서 전제 조건 — 그 도메인이 이 서버를 가리키고 있어야 하고 80 · 443 이 열려 있어야 합니다 인증 기관이 실제로 접속해 확인하기 때문입니다 — 사내망이라면 DNS 방식을 씁니다 치르는 것 — 세밀한 튜닝 항목과 참고 자료의 양은 Nginx 쪽이 여전히 많습니다 개인 프로젝트와 사내 도구에서는 이 자동화 하나로 고르는 이유가 충분합니다
인증서 만료 장애를 겪어 본 사람에게 가장 설득력 있는 기능입니다.
SECTION 02

설치와 실행

바이너리 하나면 됩니다. 의존성이 없습니다.

# ── Ubuntu · Debian (공식 저장소)
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

# ── 바이너리 직접 (어떤 리눅스에서든)
curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=amd64" -o caddy
chmod +x caddy && sudo mv caddy /usr/local/bin/

# ── macOS
brew install caddy

# ── Docker
docker run -d -p 80:80 -p 443:443 -p 443:443/udp \
  -v caddy_data:/data -v caddy_config:/config \
  -v $PWD/Caddyfile:/etc/caddy/Caddyfile:ro \
  caddy:2.10
# 즉석 실행 (개발용)
caddy file-server --root ./public --listen :8080
caddy reverse-proxy --from :8080 --to 127.0.0.1:3000

# 설정 파일로 실행
caddy run --config /etc/caddy/Caddyfile
caddy start                          # 백그라운드
caddy stop

# 서비스로 (apt 설치 시 자동 등록됨)
sudo systemctl enable --now caddy
sudo systemctl reload caddy          # 무중단 반영

# 설정 검증·정리
caddy validate --config /etc/caddy/Caddyfile
caddy fmt --overwrite /etc/caddy/Caddyfile
중요한 경로
· 설정 — /etc/caddy/Caddyfile
· 인증서·상태 — /var/lib/caddy/.local/share/caddy/ (도커에서는 /data)
· 로그 — journalctl -u caddy
인증서 폴더는 반드시 보존하세요. 지우면 전부 재발급받게 되고, Let's Encrypt 발급 한도에 걸릴 수 있습니다.
Caddyfile 에 도메인 한 줄을 쓰면 벌어지는 일 — 자동 HTTPS caddy 시작 Caddyfile 읽기 저장된 인증서? /data 폴더 확인 ACME 발급 80 번 포트로 소유 증명 HTTPS 서비스 HTTP/3 포함 만료 30일 전 — 백그라운드 자동 갱신 certbot · cron · 재시작이 전부 필요 없습니다 "인증서 만료로 서비스가 멈췄다"는 사고가 구조적으로 사라지는 것이 Caddy 의 가장 큰 실익입니다. 전제 조건 두 가지 — 도메인 A 레코드가 이 서버를 가리킬 것, 80·443 포트가 외부에서 열려 있을 것. 둘 중 하나만 어긋나도 발급이 실패합니다. 로그에 이유가 정확히 찍히니 반드시 확인하세요. 그래서 /data 볼륨이 중요합니다 — 발급 결과가 여기 저장되고, 사라지면 매번 처음부터 다시 받습니다.
Nginx·Apache 에서 certbot 설치 → 발급 → cron 갱신 → 리로드 로 하던 일이 도메인을 적는 것 하나로 끝납니다.

발급이 안 될 때 — 원인 순서대로

로그에 나오는 것원인확인 명령
no valid A/AAAA recordsDNS 가 이 서버를 안 가리킴dig +short example.com
connection refused (80)방화벽 · 보안 그룹sudo ss -tlnp | grep :80
timeout during connect클라우드 보안 그룹이 80 을 막음외부에서 curl -I http://도메인
too many certificates발급 한도 초과(주 5회)일주일 대기 · 스테이징 CA 로 테스트
unauthorized다른 서버가 그 도메인을 응답CDN·프록시가 앞에 있는지
내부망 도메인공인 CA 가 검증 불가tls internal 또는 DNS 챌린지
# 개발·테스트 중에는 스테이징 CA 로 — 발급 한도가 사실상 없습니다
{
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
    email admin@example.com
}

# 내부망·사설 도메인 — Caddy 내부 CA 로 자체 서명
intranet.local {
    tls internal
    reverse_proxy 127.0.0.1:3000
}

# 80 번 포트를 열 수 없는 환경 → DNS 챌린지 (와일드카드도 이 방식만 가능)
# ※ DNS 제공자 플러그인이 포함된 빌드가 필요합니다
#   xcaddy build --with github.com/caddy-dns/cloudflare
*.example.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    reverse_proxy 127.0.0.1:3000
}
운영 도메인으로 시행착오를 하지 마세요. Let's Encrypt 는 같은 도메인 조합에 주 5회 제한이 있고, 한 번 걸리면 일주일간 인증서를 못 받습니다. 설정을 시험할 때는 반드시 스테이징 CA 로 먼저 확인하고, 통과한 뒤에 그 줄을 지우세요.
SECTION 03

Caddyfile 문법

세미콜론도 중괄호도 거의 없습니다. 들여쓰기로 구조를 만듭니다.

# 전역 옵션 (파일 맨 위에 한 번)
{
    email admin@example.com          # 인증서 만료 알림을 받을 주소
    admin off                        # 관리 API 끄기 — 단, caddy reload·systemctl reload 도 안 됩니다(재시작으로 반영)
    servers {
        protocols h1 h2 h3
    }
}

# 사이트 블록
example.com {
    root * /var/www/html
    file_server
    encode gzip zstd

    handle /api/* {
        reverse_proxy 127.0.0.1:3000
    }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options nosniff
        -Server                      # 헤더 제거는 앞에 -
    }

    log {
        output file /var/log/caddy/example.log
        format json
    }
}

# 여러 도메인
a.example.com, b.example.com {
    respond "Hello"
}

# HTTP 만 (인증서 발급 안 함)
http://internal.local {
    reverse_proxy 127.0.0.1:8080
}
지시어하는 일
root * /path문서 루트 (* 는 모든 경로에)
file_server정적 파일 서빙
reverse_proxy프록시
encode gzip zstd압축
header응답 헤더 조작
handle /path/*경로별 처리 (배타적 — 하나만 실행)
handle_path /api/*경로별 처리 + 접두사 제거
route { }지시어 실행 순서를 직접 지정
redir리다이렉트
respond즉시 응답 (헬스체크 등)
rewrite내부 경로 재작성
try_files순서대로 찾기 (SPA)
basic_auth기본 인증 (2.8 이전 이름 basicauth)
Caddy 는 지시어 순서를 자동으로 정합니다. Nginx 처럼 "위에서부터"가 아니라 미리 정해진 우선순위에 따라 실행됩니다. 그래서 파일에 적은 순서와 실제 실행 순서가 다를 수 있습니다. 순서를 직접 통제하려면 route { } 로 감싸세요.
# SPA 배포 — 이 세 줄이면 됩니다
example.com {
    root * /var/www/spa
    try_files {path} /index.html
    file_server
}
주소 한 줄 + 중괄호 — 이것이 전부입니다 example.com, www.example.com { encode gzip header X-Frame-Options DENY handle /api/* { reverse_proxy app:8080 } file_server 주소를 콤마로 묶으면 같은 설정을 함께 적용 이름을 적는 순간 HTTPS 가 자동으로 붙습니다 handle 로 경로별 분기 먼저 맞는 handle 하나만 실행됩니다 적은 순서대로 실행되지 않습니다 Caddy 는 지시어마다 정해진 실행 순서가 있습니다. 순서를 직접 정해야 하면 route { } 로 감싸세요 — Nginx 습관대로 쓰면 여기서 헷갈립니다
설정이 짧은 이유는 기본값이 이미 실무 기준이기 때문입니다.
주소 한 줄 + 중괄호 — 이것이 전부입니다 example.com, www.example.com { encode gzip header X-Frame-Options DENY handle /api/* { reverse_proxy app:8080 } file_server 주소를 콤마로 묶으면 같은 설정을 함께 적용 이름을 적는 순간 HTTPS 가 자동으로 붙습니다 handle 로 경로별 분기 먼저 맞는 handle 하나만 실행됩니다 적은 순서대로 실행되지 않습니다 Caddy 는 지시어마다 정해진 실행 순서가 있습니다. 순서를 직접 정해야 하면 route { } 로 감싸세요 — Nginx 습관대로 쓰면 여기서 헷갈립니다
설정이 짧은 이유는 기본값이 이미 실무 기준이기 때문입니다.
SECTION 04

리버스 프록시 · 로드 밸런싱

한 줄이면 프록시가 되고, 서버를 더 적으면 자동으로 분산됩니다.

example.com {
    # 가장 단순한 형태 — X-Forwarded-* 헤더가 자동으로 붙습니다
    reverse_proxy 127.0.0.1:3000
}

# 여러 서버 = 자동 로드 밸런싱
example.com {
    reverse_proxy 10.0.1.11:3000 10.0.1.12:3000 10.0.1.13:3000 {
        lb_policy least_conn          # random(기본) · round_robin · ip_hash · first

        health_uri      /health       # ★ 능동적 헬스체크가 기본 기능입니다
        health_interval 10s
        health_timeout  3s
        health_status   2xx

        fail_duration   30s           # 실패한 서버를 제외할 시간
        max_fails       3

        transport http {
            keepalive 30s
            keepalive_idle_conns 32
            dial_timeout 5s
            read_timeout 60s
        }
    }
}
능동적 헬스체크가 오픈소스에 기본 포함되어 있습니다. Nginx 에서는 상용판(Plus) 기능이라, 이 점이 Caddy 의 실질적 장점입니다.

경로별 라우팅

example.com {
    # 정적 프런트엔드
    handle /static/* {
        root * /var/www
        file_server
        header Cache-Control "public, max-age=31536000, immutable"
    }

    # API — 접두사를 떼고 넘김 (/api/users → /users)
    handle_path /api/* {
        reverse_proxy 127.0.0.1:8080
    }

    # 웹소켓 (별도 설정이 필요 없습니다 — 자동 처리)
    handle /ws/* {
        reverse_proxy 127.0.0.1:3000
    }

    # 나머지는 SPA
    handle {
        root * /var/www/spa
        try_files {path} /index.html
        file_server
    }
}
WebSocket 은 별도 설정이 필요 없습니다. Caddy 의 reverse_proxy 는 Upgrade 헤더를 자동으로 처리합니다. Nginx 에서 map $http_upgrade 를 쓰던 것과 대조적입니다.
# 프록시 세부 조정
reverse_proxy 127.0.0.1:3000 {
    header_up   X-Real-IP {remote_host}
    header_up   Host {upstream_hostport}
    header_down -Server                    # 백엔드가 보낸 Server 헤더 제거

    flush_interval -1                      # SSE·스트리밍 (버퍼링 끄기)
}

Nginx 설정을 Caddy 로 — 대응표

하고 싶은 것NginxCaddy
프록시proxy_pass http://127.0.0.1:3000;
+ proxy_set_header 4줄
reverse_proxy 127.0.0.1:3000
WebSocketmap $http_upgrade + Upgrade/Connection 헤더자동(설정 없음)
경로 접두사 제거location /api/ { proxy_pass .../; }
끝 슬래시 유무가 동작을 바꿉니다
handle_path /api/*
SPA 폴백try_files $uri $uri/ /index.html;try_files {path} /index.html
HTTPScertbot + ssl_certificate + cron도메인만 적으면 끝
HTTP → HTTPSreturn 301 https://... 서버 블록 추가자동
헬스체크 기반 LBNginx Plus(유료)health_uri — 오픈소스
압축gzip on; + 타입 목록encode gzip zstd
HTTP/3별도 빌드·설정기본 활성(443/udp 만 열면 됨)
설정 재적용nginx -s reloadcaddy reload · API 로도 가능
가장 큰 차이는 "기본값"입니다. Caddy 는 안전하고 현대적인 설정이 기본이고 필요한 것만 덧붙입니다. Nginx 는 비어 있는 상태에서 하나씩 채우는 방식이라, X-Forwarded-Proto 를 빠뜨려 백엔드가 리다이렉트 루프에 빠지는 식의 "안 적어서 생기는 사고"가 잦습니다. 반대로 세밀한 제어가 필요하면 Nginx 가 여전히 유리합니다.

실전에서 자주 쓰는 프록시 설정

example.com {
    # ── 1) 큰 파일 업로드 (기본 제한 없음 — 오히려 제한을 걸어야 합니다)
    request_body {
        max_size 50MB
    }

    # ── 2) 속도 제한이 필요하면 (플러그인)
    #   xcaddy build --with github.com/mholt/caddy-ratelimit

    # ── 3) 특정 경로만 인증 걸기
    @admin path /admin/*
    basic_auth @admin {
        # caddy hash-password 로 만든 bcrypt 해시
        alice $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG
    }

    # ── 4) 백엔드가 죽었을 때 보여 줄 페이지
    handle_errors {
        @502 expression {err.status_code} == 502
        rewrite @502 /maintenance.html
        root * /var/www/static
        file_server
    }

    # ── 5) 요청이 실제로 어디로 갔는지 디버깅
    #   Caddyfile 맨 위 전역 블록에
    #   { debug }
    #   → 매처 판정과 라우팅 결정이 로그에 전부 찍힙니다

    reverse_proxy 127.0.0.1:3000 {
        # 백엔드가 느릴 때 — 기본은 무제한이라 반드시 잡아 주세요
        transport http {
            dial_timeout     5s
            response_header_timeout 30s
            read_timeout     300s      # SSE·스트리밍이면 길게
        }

        # 실패 시 다음 서버로 재시도
        lb_try_duration 5s
        lb_try_interval 250ms
    }
}
타임아웃이 기본적으로 무제한에 가깝습니다. 백엔드가 응답을 안 주면 연결이 계속 쌓입니다. dial_timeout 과 response_header_timeout 은 반드시 명시하세요 — 이것만으로 백엔드 장애가 프록시까지 무너뜨리는 것을 막을 수 있습니다.
리버스 프록시 — 한 줄이면 시작됩니다 기본형 reverse_proxy localhost:8080 X-Forwarded-For · X-Forwarded-Proto · Host 를 기본으로 붙여 줍니다 여러 대를 적으면 그대로 로드밸런싱 reverse_proxy app1:8080 app2:8080 app3:8080 분산 방식과 헬스체크는 블록 안에 몇 줄로 지정합니다 웹소켓도 별도 설정 없이 통과합니다 다른 서버에서 필요한 업그레이드 헤더 설정이 필요 없습니다 경로별로 다른 뒤쪽으로 보내려면 handle 블록을 나눠 적으세요 — 순서대로 검사합니다 정적 파일과 프록시를 함께 두려면 file_server 와 reverse_proxy 를 경로별로 나눕니다
Caddy 설정이 짧은 이유는 기본값이 실무 기본값이기 때문입니다.
SECTION 05

자동 인증서 발급 원리

어떻게 자동으로 되는지 알아야 실패했을 때 진단할 수 있습니다.

1
도메인이 이 서버를 가리키는지 확인 DNS A·AAAA 레코드가 이 서버 IP 여야 합니다.
▼
2
ACME 서버에 인증서를 요청 Let's Encrypt(기본) 또는 ZeroSSL 에 "이 도메인 인증서를 주세요"라고 요청합니다.
▼
3
도메인 소유를 증명 (챌린지) HTTP-01 — 80 포트로 특정 경로에 파일을 두고 확인받거나, TLS-ALPN-01 — 443 포트로 특별한 TLS 핸드셰이크를 수행합니다.
▼
4
인증서를 받아 저장하고 자동 적용 /var/lib/caddy/.local/share/caddy/ 에 저장되고, 만료 30일 전에 자동 갱신합니다.
자동 발급이 실패하는 이유 — 거의 이 넷입니다
① DNS 가 이 서버를 안 가리킴 — dig +short example.com 으로 확인
② 80·443 포트가 막힘 — 방화벽·보안 그룹·다른 프로세스 점유
③ 발급 한도 초과 — Let's Encrypt 는 도메인당 주간 발급 제한이 있습니다 (테스트 중 반복 재시작하면 걸립니다)
④ 인증서 폴더가 초기화됨 — 도커에서 볼륨을 안 붙인 경우
# 로그에서 발급 과정을 확인
sudo journalctl -u caddy -f | grep -i -E 'certificate|acme|challenge'

# 테스트 중이라면 스테이징 서버를 쓰세요 (발급 한도가 훨씬 넉넉합니다)
# Caddyfile 전역 옵션에
# {
#     acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
# }
# ※ 스테이징 인증서는 브라우저에서 신뢰되지 않습니다 (경고가 뜹니다)

DNS 챌린지 — 와일드카드·내부 서버용

# *.example.com 같은 와일드카드는 DNS 챌린지만 가능합니다
# DNS 제공자 플러그인이 포함된 빌드가 필요합니다
# xcaddy build --with github.com/caddy-dns/cloudflare

*.example.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    reverse_proxy 127.0.0.1:3000
}
DNS 챌린지의 장점 — 80·443 포트를 열지 않아도 인증서를 받을 수 있습니다. 인터넷에 노출되지 않은 내부 서버에도 공인 인증서를 붙일 수 있어 유용합니다.
# 직접 준비한 인증서를 쓰려면
example.com {
    tls /etc/ssl/certs/example.crt /etc/ssl/private/example.key
}

# 내부 CA (개발·사내용 자체 서명)
internal.local {
    tls internal
}

# 인증서 관리를 아예 끄기 — Caddy 2 에는 tls off 가 없습니다
# 주소 앞에 http:// 를 붙이면 그 사이트는 HTTP 로만 서비스합니다
# (전부 끄려면 전역 옵션 { auto_https off })
http://example.com {
    respond "HTTP only"
}
ACME — 도메인이 내 것임을 기계적으로 증명합니다 1. 요청 이 도메인 인증서 주세요 2. 도전 과제 이 값을 여기에 올려 두세요 3. 확인 기관이 직접 접속 4. 발급 90일짜리 HTTP 방식 80 포트로 접속해 파일을 확인합니다 밖에서 접속 가능해야 합니다 DNS 방식 DNS 에 TXT 레코드를 넣어 증명합니다 사내망 · 와일드카드 인증서에 필요합니다 유효기간이 짧습니다 — 자동 갱신이 도는지 반드시 확인하세요 발급 시도에는 횟수 제한이 있습니다 — 설정을 시험할 때는 스테이징 환경을 쓰세요 certbot 을 쓰든 Caddy 를 쓰든 절차는 같습니다 — 자동화 수준만 다릅니다
인증서가 공짜가 된 이유는 가격 정책이 아니라 이 절차의 자동화입니다.
SECTION 06

정적 사이트 · 파일 서버

개발 중 임시 서버로도, 실제 배포용으로도 씁니다.

# 지금 폴더를 즉시 서빙 (개발용)
caddy file-server --listen :8080 --browse

# --browse 를 주면 디렉터리 목록도 보여 줍니다 (운영에서는 쓰지 마세요)
example.com {
    root * /var/www/site
    file_server
    encode gzip zstd

    # 정적 자원 캐시
    @static {
        path *.css *.js *.woff2 *.png *.jpg *.svg *.webp
    }
    header @static Cache-Control "public, max-age=31536000, immutable"

    # index.html 은 매번 재검증 (no-cache — 아래 SPA 설명 참고)
    @html path /index.html /
    header @html Cache-Control "no-cache"

    # 커스텀 오류 페이지
    handle_errors {
        rewrite * /{err.status_code}.html
        file_server
    }
}
@이름 은 매처(matcher)입니다. 조건에 이름을 붙여 두고 여러 지시어에서 재사용할 수 있습니다. Nginx 의 location 보다 유연합니다 — 경로·헤더·메서드·쿼리·표현식을 조합할 수 있습니다.
# 매처 예시
@admin {
    path /admin/*
    not remote_ip 10.0.0.0/8
}
respond @admin "Forbidden" 403

@post method POST
@api header Content-Type application/json
@mobile header User-Agent *Mobile*

# 여러 조건은 AND · 하나라도 만족하려면 별도 매처를 여러 개

사전 압축 파일 사용

example.com {
    root * /var/www/site
    file_server {
        precompressed br gzip        # app.js.br · app.js.gz 가 있으면 그것을 전송
    }
}
사전 압축은 빌드 때 한 번만 하면 됩니다. 요청마다 압축하는 것과 결과는 같지만 CPU 를 쓰지 않고, brotli 최고 압축률(-11)을 쓸 수 있습니다 — 실시간 압축으로는 너무 느려서 못 쓰는 수준입니다. find dist -type f \( -name '*.js' -o -name '*.css' \) -exec brotli -k -q 11 {} \; 한 줄을 배포 스크립트에 넣으세요.

SPA 를 제대로 서빙하기 — 캐시 전략이 핵심입니다

example.com {
    root * /var/www/spa
    encode zstd gzip

    # ① 해시가 붙은 자원 — 영원히 캐시 (파일명이 바뀌면 새로 받습니다)
    @hashed path_regexp \.[a-f0-9]{8,}\.(js|css|woff2|png|jpg|svg|webp)$
    header @hashed Cache-Control "public, max-age=31536000, immutable"

    # ② index.html — 절대 캐시 금지
    #    이것을 캐시하면 배포해도 사용자가 옛 버전을 계속 봅니다
    @index path / /index.html
    header @index Cache-Control "no-cache, must-revalidate"

    # ③ 그 외 정적 파일 — 짧게
    @asset path /assets/*
    header @asset Cache-Control "public, max-age=3600"

    # ④ API 는 프록시로
    handle_path /api/* {
        reverse_proxy 127.0.0.1:8080
    }

    # ⑤ 나머지 경로는 전부 index.html (클라이언트 라우팅)
    handle {
        try_files {path} /index.html
        file_server
    }
}
파일Cache-Control이유
app.a1b2c3d4.jsmax-age=31536000, immutable내용이 바뀌면 이름이 바뀝니다 — 무한 캐시가 안전
index.htmlno-cache새 파일명을 알려 주는 유일한 문서
이미지(해시 없음)max-age=3600바뀔 수 있으나 자주는 아님
API 응답no-store 또는 짧게사용자별 데이터
manifest.json·sw.jsno-cache캐시되면 업데이트가 영원히 안 옵니다
"배포했는데 사용자에게 반영이 안 돼요"의 원인은 거의 항상 이것입니다. index.html 이 캐시되면 브라우저가 옛 파일명을 계속 요청합니다. 반대로 해시 붙은 자원에 캐시를 안 걸면 매번 다시 받아 느려집니다. 둘을 정확히 반대로 설정하는 것이 정답입니다 — no-cache 는 "캐시하지 마라"가 아니라 "쓰기 전에 서버에 물어봐라" 라는 뜻이라는 점도 기억해 두세요.

파일 서버에서 조심할 것

설정위험운영에서
--browse · file_server browse디렉터리 목록 노출끄세요
root * /시스템 파일까지 노출전용 디렉터리로 한정
백업 파일(.env.bak·.git)비밀 유출매처로 차단(아래)
심볼릭 링크바깥 파일 접근배포 디렉터리에 링크를 두지 말 것
# 숨김 파일·민감 경로 차단 — 정적 서버에 항상 넣으세요
@hidden {
    path /.git/* /.env* /.htaccess *.bak *.swp *.sql
}
respond @hidden 404

# 또는 file_server 의 hide 옵션
file_server {
    hide .git .env *.bak
}
SECTION 07

헤더 · 압축 · 캐시

보안 헤더와 성능 설정을 간결하게 적용합니다.

example.com {
    header {
        # 보안 헤더
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options    "nosniff"
        X-Frame-Options           "SAMEORIGIN"
        Referrer-Policy           "strict-origin-when-cross-origin"
        Permissions-Policy        "geolocation=(), microphone=(), camera=()"
        Content-Security-Policy   "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'"

        # 서버 정보 제거
        -Server
        -X-Powered-By
    }

    encode {
        zstd
        gzip 5
        minimum_length 1024
        match {
            header Content-Type text/*
            header Content-Type application/json*
            header Content-Type application/javascript*
            header Content-Type image/svg+xml*
        }
    }
}
재사용할 설정은 스니펫으로 빼세요. 여러 사이트에 같은 보안 헤더를 적용할 때 편리합니다.
(security) {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        -Server
    }
}

(compress) {
    encode gzip zstd
}

a.example.com {
    import security
    import compress
    reverse_proxy 127.0.0.1:3000
}

b.example.com {
    import security
    import compress
    root * /var/www/b
    file_server
}

로그

example.com {
    log {
        output file /var/log/caddy/access.log {
            roll_size     100MiB
            roll_keep     10
            roll_keep_for 720h
        }
        format json
        level  INFO
    }

    # 특정 경로는 로그에서 제외
    @health path /health
    log_skip @health
}
Caddy 의 로그는 기본이 JSON 입니다. 로그 수집 도구(Loki·Elasticsearch)와 바로 연동되고, 로테이션도 내장되어 logrotate 를 따로 설정할 필요가 없습니다.

보안 헤더 — 무엇이 무엇을 막나

헤더막는 공격깨질 수 있는 것
Strict-Transport-Security중간자가 HTTP 로 끌어내리기되돌리기 어렵습니다 — 아래 warn
X-Content-Type-Options: nosniff업로드 파일을 스크립트로 실행Content-Type 이 틀린 파일
X-Frame-Options / frame-ancestors클릭재킹(투명 iframe)정상적인 iframe 임베드
Content-Security-PolicyXSS — 가장 강력합니다인라인 스크립트 · 외부 CDN
Referrer-PolicyURL 에 담긴 정보 유출유입 경로 분석
Permissions-Policyiframe 이 카메라·위치 사용지도·화상 기능
HSTS 는 되돌리기가 어렵습니다. 브라우저가 max-age 동안 그 도메인을 HTTPS 로만 접속하도록 기억합니다. 인증서가 잘못되면 사용자가 "안전하지 않음"을 무시하고 들어갈 방법조차 없어집니다. 처음에는 max-age=300 으로 시작해 문제가 없는 것을 확인한 뒤 늘리세요. preload 는 브라우저에 하드코딩되므로 제거에 몇 개월이 걸립니다 — 확신이 있을 때만 붙이세요. 서브도메인 전부가 HTTPS 여야 한다는 점도 확인이 필요합니다.
# CSP 를 실제로 적용하는 순서 — 한 번에 켜면 사이트가 깨집니다
# ① 먼저 리포트 전용으로 (차단하지 않고 위반만 수집)
header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report"

# ② 며칠간 리포트를 보고 필요한 출처를 추가
# ③ 위반이 사라지면 Report-Only 를 떼고 실제 적용
header Content-Security-Policy "
    default-src 'self';
    script-src  'self' https://cdn.example.com;
    style-src   'self' 'unsafe-inline';
    img-src     'self' data: https:;
    connect-src 'self' https://api.example.com wss://api.example.com;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self'
"

압축 — 무엇을 압축하고 무엇을 하지 말아야 하나

종류압축이유
HTML · CSS · JS · JSON · SVG반드시70~85% 줄어듭니다
JPEG · PNG · WebP · MP4하지 말 것이미 압축됨 — CPU 만 쓰고 커지기도 합니다
woff2 폰트하지 말 것내부적으로 이미 압축
1KB 미만 응답하지 말 것헤더 오버헤드가 더 큽니다
스트리밍 · SSE주의버퍼링되어 실시간성이 깨집니다
zstd 를 gzip 보다 먼저 쓰세요. Caddy 는 클라이언트가 지원하는 것 중 먼저 적힌 것을 고릅니다. zstd 는 같은 압축률에서 gzip 보다 훨씬 빠르고, 지원 브라우저도 충분히 퍼졌습니다. encode zstd gzip 처럼 순서를 지켜 적으면 지원하는 브라우저는 zstd, 나머지는 gzip 으로 자동 분기합니다.
응답이 나가기 전에 붙는 것들 뒤쪽 응답 HTML · JS · 이미지 header 보안 헤더 부착 Cache-Control 보관 기간 지정 encode gzip · zstd 압축 캐시 수명을 정하는 기준 app.a1b2c3.js 처럼 이름에 해시가 있는 파일 → 1년 index.html → no-cache (매번 확인만 시킵니다) 이름이 그대로인데 길게 잡으면 배포해도 안 바뀝니다 압축하지 말아야 할 것 jpg · png · mp4 · zip — 이미 압축된 형식입니다 다시 압축하면 CPU 만 쓰고 크기는 그대로거나 커집니다 Caddy 의 encode 는 기본적으로 알아서 걸러 냅니다 최소로 붙일 보안 헤더 — nosniff · X-Frame-Options DENY · Referrer-Policy strict-origin-when-cross-origin HTTPS 가 자동이므로 HSTS 도 기본으로 붙습니다 — 다만 되돌리기 어려우니 도메인 구성이 확정된 뒤에 기간을 늘리세요
순서가 정해져 있어 적는 위치를 고민할 필요가 없습니다.
응답이 나가기 전에 붙는 것들 뒤쪽 응답 HTML · JS · 이미지 header 보안 헤더 부착 Cache-Control 보관 기간 지정 encode gzip · zstd 압축 캐시 수명을 정하는 기준 app.a1b2c3.js 처럼 이름에 해시가 있는 파일 → 1년 index.html → no-cache (매번 확인만 시킵니다) 이름이 그대로인데 길게 잡으면 배포해도 안 바뀝니다 압축하지 말아야 할 것 jpg · png · mp4 · zip — 이미 압축된 형식입니다 다시 압축하면 CPU 만 쓰고 크기는 그대로거나 커집니다 Caddy 의 encode 는 기본적으로 알아서 걸러 냅니다 최소로 붙일 보안 헤더 — X-Content-Type-Options nosniff · X-Frame-Options DENY · Referrer-Policy strict-origin-when-cross-origin HTTPS 가 자동이므로 HSTS 도 기본으로 붙습니다 — 다만 되돌리기 어려우니 도메인 구성이 확정된 뒤에 기간을 늘리세요
순서가 정해져 있어 적는 위치를 고민할 필요가 없습니다.
SECTION 08

Docker · Compose

컨테이너로 쓸 때 인증서 볼륨을 빠뜨리면 재시작마다 재발급을 시도하다 한도에 걸립니다.

# compose.yaml
services:
  caddy:
    image: caddy:2.10
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"          # ★ HTTP/3 용
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./site:/srv:ro
      - caddy_data:/data       # ★★ 인증서 — 반드시 볼륨으로
      - caddy_config:/config
    depends_on:
      - app

  app:
    build: ./app
    restart: unless-stopped
    expose:
      - "3000"                 # 외부에 열지 않고 caddy 만 접근

volumes:
  caddy_data:
  caddy_config:
# Caddyfile — 컨테이너 이름이 곧 호스트명
example.com {
    handle_path /api/* {
        reverse_proxy app:3000
    }
    handle {
        root * /srv
        try_files {path} /index.html
        file_server
    }
}
caddy_data 볼륨을 빠뜨리면 벌어지는 일
컨테이너를 재시작할 때마다 인증서가 사라져 매번 새로 발급을 시도합니다. Let's Encrypt 는 도메인당 주 5회 발급 제한이 있어 며칠간 인증서를 못 받는 상태가 됩니다. 개발 중 재시작을 반복하면 순식간에 걸립니다.
# 무중단 설정 반영
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

# 설정 검증
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile

# 로그
docker compose logs -f caddy

# 발급된 인증서 확인
docker compose exec caddy ls -R /data/caddy/certificates
로컬 개발에서는 localhost 를 쓰세요. Caddy 는 localhost 나 .localhost 도메인에 내부 CA 로 자체 서명 인증서를 만들어 줍니다. Let's Encrypt 를 건드리지 않으므로 발급 한도 걱정이 없습니다.

컨테이너에서 자주 막히는 것

증상원인해결
재시작마다 인증서 재발급/data 볼륨 없음caddy_data:/data
HTTP/3 이 안 붙음UDP 443 미개방"443:443/udp"
백엔드 연결 실패127.0.0.1 을 씀서비스 이름으로 (app:3000)
클라이언트 IP 가 전부 같음앞단에 LB·CDNtrusted_proxies 설정(아래)
설정을 고쳐도 반영 안 됨:ro 마운트 + 재시작 안 함caddy reload
권한 오류볼륨 소유자 불일치named volume 사용(bind mount 대신)
80 포트 바인딩 실패호스트에 다른 웹서버ss -tlnp | grep :80
# 앞에 CDN·로드밸런서가 있을 때 — 실제 클라이언트 IP 를 살립니다
{
    servers {
        trusted_proxies static 10.0.0.0/8 172.16.0.0/12   # LB 대역만 신뢰
        client_ip_headers X-Forwarded-For
    }
}

example.com {
    # 이제 {client_ip} 가 실제 사용자 IP 입니다
    log {
        format json
    }
    reverse_proxy app:3000 {
        header_up X-Real-IP {client_ip}
    }
}
trusted_proxies 없이 X-Forwarded-For 를 믿으면 위험합니다. 누구나 그 헤더를 임의로 채워 보낼 수 있어, IP 기반 접근 제어나 속도 제한을 헤더 한 줄로 우회당합니다. 신뢰할 프록시 대역을 명시했을 때만 그 헤더를 읽도록 하세요.

운영에 넣어 두면 좋은 것

services:
  caddy:
    image: caddy:2.10
    restart: unless-stopped
    ports: ["80:80", "443:443", "443:443/udp"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
      - ./logs:/var/log/caddy          # 로그를 호스트로 빼 두면 조사에 편합니다
    healthcheck:                        # ★ 프록시 자신도 헬스체크 대상입니다
      test: ["CMD", "wget", "-qO-", "http://localhost:2019/config/"]
      interval: 30s
      timeout: 5s
      retries: 3
    logging:                            # 컨테이너 로그가 디스크를 채우지 않게
      driver: json-file
      options: { max-size: "20m", max-file: "5" }
    ulimits:
      nofile: { soft: 65535, hard: 65535 }   # 동시 연결이 많으면 필수

volumes:
  caddy_data:
  caddy_config:
# 관리 API — 재시작 없이 설정을 읽고 바꿀 수 있습니다 (기본 2019 포트, 로컬만)
curl -s localhost:2019/config/ | jq          # 현재 설정 전체
curl -s localhost:2019/reverse_proxy/upstreams | jq   # 업스트림 상태·헬스

# 인증서 파일의 갱신 시각 — 자동 갱신이 잘 돌고 있는지 가끔 봐 두세요
docker compose exec caddy sh -c \
  'ls -l /data/caddy/certificates/*/*/*.crt' 2>/dev/null

# 밖에서 실제로 확인하는 것이 가장 확실합니다
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates -issuer
관리 API(2019 포트)를 외부에 열지 마세요. 인증 없이 설정 전체를 바꿀 수 있습니다 — 프록시 대상을 공격자 서버로 돌리는 것이 curl 한 번이면 됩니다. 컨테이너에서 ports 로 2019 를 노출하지 말고, 필요하면 admin 지시어로 바인딩 주소를 명시하거나 아예 끄세요.
SECTION 09

Nginx 대비 장단점

감정 빼고 사실만 정리합니다.

항목CaddyNginx
HTTPS 설정자동 (설정 0)certbot + 설정 필요
설정 분량매우 적음보통
HTTP/3기본 활성빌드·설정 필요
능동 헬스체크기본 제공상용판(Plus) 또는 모듈
성능 (정적)좋음약간 우세
메모리 사용보통 (Go 런타임)적음
자료·사례적음압도적으로 많음
플러그인재빌드 필요 (xcaddy)동적 모듈
설정 APIREST API 내장파일 기반
운영 인력 풀적음많음
🟢 Caddy 가 유리
  • 개인·사이드 프로젝트
  • 사내 도구·내부 서비스
  • 도메인이 많고 인증서 관리가 부담
  • 설정 시간을 아끼고 싶을 때
  • 컨테이너 기반 간단한 배포
🔵 Nginx 가 유리
  • 팀·회사가 이미 쓰고 있을 때
  • 대규모 트래픽 운영
  • 세밀한 튜닝이 필요할 때
  • 기존 설정 자산이 많을 때
  • 문제 해결 자료가 중요할 때
🟣 함께 쓰기
  • 앞단 Caddy(TLS 자동) + 뒤 Nginx
  • 내부망 서비스만 Caddy
  • 개발 환경만 Caddy, 운영은 Nginx
  • 다만 두 개를 관리하는 비용도 고려하세요
솔직한 조언 — 이력서·실무 관점에서는 Nginx 를 먼저 익히는 것이 확실히 유리합니다. 채용 공고와 운영 사례가 압도적으로 많습니다. Caddy 는 "내 프로젝트를 빨리 띄우는 도구"로 알아 두면 충분합니다.
Caddy 와 Nginx — 성능보다 운영 방식으로 갈립니다 Caddy 가 나을 때 HTTPS 를 빨리 안전하게 붙이고 싶을 때 설정 파일을 짧게 유지하고 싶을 때 인증서 갱신을 신경 쓰기 싫을 때 개인 프로젝트 · 사내 도구 · 데모 기본값이 이미 실무 기본값입니다 Nginx 가 나을 때 캐시 · 재작성 · 세밀한 제어가 필요할 때 대규모 트래픽 튜닝이 필요할 때 팀이 이미 익숙하고 자료가 많을 때 클라우드 · 사내 표준이 정해져 있을 때 문제를 검색하면 답이 나옵니다 일반적인 트래픽 범위에서 성능은 결정적인 차이가 아닙니다 둘 다 충분히 빠릅니다 — 갈리는 것은 설정을 누가 얼마나 오래 유지하느냐입니다 함께 쓰는 구성도 가능합니다 — 앞에 Caddy 로 인증서만 맡기고 뒤에 기존 구성을 두는 식으로
도구를 고를 때 마지막 질문은 늘 같습니다 — 누가 유지할 것인가.
SECTION 10

API · 플러그인 · 운영

Caddy 의 실제 설정 형식은 JSON 이고, Caddyfile 은 그것을 쉽게 쓰는 문법입니다.

# Caddyfile 이 실제로 어떤 JSON 이 되는지 확인
caddy adapt --config /etc/caddy/Caddyfile --pretty

# 관리 API (기본 localhost:2019)
curl localhost:2019/config/ | jq            # 현재 설정 조회
curl localhost:2019/config/apps/http/servers/srv0/routes | jq

# 설정을 API 로 교체 (무중단)
curl -X POST localhost:2019/load \
     -H "Content-Type: application/json" \
     -d @caddy.json
운영에서는 관리 API 를 끄거나 제한하세요. 기본적으로 localhost:2019 에만 열리지만, 컨테이너 안에서는 다른 컨테이너가 접근할 수 있습니다. 설정을 통째로 바꿀 수 있으므로 매우 위험합니다.
Caddyfile 전역 옵션에 admin off 를 넣으세요. (단, 그러면 caddy reload 로 무중단 반영을 할 수 없어 설정 변경은 재시작으로 해야 합니다)

플러그인 — xcaddy 로 재빌드

# xcaddy 설치 (Go 필요)
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# 필요한 플러그인을 넣어 빌드
xcaddy build \
  --with github.com/caddy-dns/cloudflare \
  --with github.com/greenpau/caddy-security \
  --with github.com/mholt/caddy-ratelimit

sudo mv caddy /usr/local/bin/caddy
caddy list-modules | grep -i cloudflare
플러그인하는 일
caddy-dns/*DNS 챌린지 (Cloudflare·Route53 등) — 와일드카드 인증서에 필수
caddy-ratelimit요청 속도 제한 (기본 기능에 없습니다)
caddy-security인증·인가 (OAuth·JWT)
caddy-docker-proxy도커 라벨로 설정 자동 생성
replace-response응답 본문 치환
플러그인 방식의 트레이드오프 — Nginx 는 동적 모듈을 로드하지만 Caddy 는 바이너리를 다시 빌드해야 합니다.
· 장점 — 단일 바이너리 유지, 의존성 없음, 배포가 단순
· 단점 — 플러그인 추가 시 재빌드·재배포 필요, 업데이트 시마다 다시 빌드
도커를 쓴다면 caddy:builder 이미지로 멀티스테이지 빌드를 구성하면 편합니다.
# Dockerfile — 플러그인 포함 이미지 만들기
FROM caddy:2.10-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare

FROM caddy:2.10
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
HTTPS · TLS 1.3 · LET'S ENCRYPT · 인증서 운영

HTTPS —
발급부터
갱신까지

HTTPS 는 이제 선택이 아닙니다. 브라우저가 HTTP 사이트에 "안전하지 않음"을 표시하고, HTTP/2·3 과 여러 브라우저 API 가 HTTPS 를 요구합니다. 다행히 Let's Encrypt 덕분에 무료이고 자동화도 어렵지 않습니다. 인증서 만료로 서비스가 멈추는 사고만 피하면 됩니다.

13SECTIONS
0원인증서 비용
90일갱신 주기
SECTION 01

HTTPS는 무엇을 지키나

자물쇠 아이콘이 보장하는 것은 세 가지입니다.

보장없으면 생기는 일
기밀성
(암호화)
같은 와이파이의 누구나 비밀번호·쿠키·개인정보를 그대로 볼 수 있습니다
무결성
(변조 방지)
중간에서 내용을 바꿔치기할 수 있습니다 — 광고 삽입·악성 스크립트 주입이 실제로 있었습니다
인증
(신원 확인)
가짜 사이트에 접속해도 알 수 없습니다 (피싱)
HTTPS 가 보장하지 않는 것도 알아야 합니다.
· 사이트가 안전하다는 뜻이 아닙니다 — 피싱 사이트도 HTTPS 를 씁니다
· 서버가 해킹되지 않는다는 뜻이 아닙니다 — 전송 구간만 보호합니다
· 어느 사이트에 접속했는지는 숨겨지지 않습니다 — 도메인은 노출됩니다(SNI)
· 데이터는 서버에 도착한 뒤에는 평문입니다

HTTPS 가 필수인 실질적 이유

  • 브라우저 경고 — HTTP 폼에 입력하면 "안전하지 않음" 경고가 뜹니다
  • HTTP/2 · HTTP/3 — 사실상 HTTPS 에서만 동작합니다
  • 브라우저 API 제한 — 위치·카메라·서비스워커·클립보드 등이 보안 컨텍스트에서만 동작합니다
  • 검색 순위 — 검색엔진이 HTTPS 를 순위 요소로 반영합니다
  • 쿠키 보안 속성 — Secure·SameSite=None 이 HTTPS 를 요구합니다
"내부 서버라 HTTP 로 충분하다"는 판단은 재검토하세요. 사내망 침해 후 내부 트래픽을 엿보는 공격이 일반적입니다. 내부 서버도 사설 CA 나 DNS 챌린지로 HTTPS 를 적용할 수 있습니다(s11 · 🔒 Caddy c05).
HTTP 로 보내면 — 이 경로의 누구나 읽고 바꿀 수 있습니다 브라우저 비밀번호 입력 카페 와이파이 그대로 보임 통신사 · ISP 기록 가능 중간 장비 내용 변조 가능 서버 example.com HTTPS 로 보내면 — 구간 전체가 암호화됩니다 브라우저 여기서 암호화 중간 구간 — 알아볼 수 없는 바이트 보이는 것: 목적지 도메인(SNI) · 데이터 크기 · 접속 시각 서버 여기서 복호화 서버에 도착한 뒤에는 평문입니다 — 로그·DB·백업에 무엇을 남기는가는 완전히 별개의 문제입니다. "어느 사이트에 갔는지"도 숨겨지지 않습니다. 도메인 이름은 SNI 로 평문 노출됩니다(ECH 가 이를 바꾸는 중). 그래서 HTTPS 는 "안전한 사이트"의 증거가 아니라 "전송 구간이 보호된다"는 사실만 말해 줍니다.
자물쇠 아이콘의 정확한 뜻은 "내용이 중간에서 새거나 바뀌지 않았다" 입니다 — 상대가 믿을 만한지는 말해 주지 않습니다.

HTTP 로 두면 실제로 무엇이 막히나

기능HTTP 에서
서비스 워커 · PWA등록 자체가 안 됩니다(localhost 예외)
위치 · 카메라 · 마이크권한 요청이 차단됩니다
클립보드 API동작하지 않습니다
HTTP/2 · HTTP/3브라우저가 HTTPS 에서만 씁니다 → 느립니다
Secure 쿠키설정해도 전송되지 않습니다
SameSite=NoneSecure 가 함께 필요 → 크로스 사이트 인증 불가
비밀번호 입력란주소창에 "안전하지 않음" 경고
혼합 콘텐츠HTTPS 페이지에서 HTTP 자원은 차단됩니다
"우리 사이트는 로그인이 없어서 HTTP 로 충분하다"는 이제 성립하지 않습니다. 현대 웹 기능의 상당수가 보안 컨텍스트를 전제로 설계돼 있어서, HTTP 로 두면 기능이 안 되는 쪽이 문제가 됩니다. 비용도 사실상 0 입니다 — Let's Encrypt 는 무료이고, Caddy 는 도메인만 적으면 끝나며, certbot 도 명령 한 줄입니다. 안 할 이유를 찾기가 더 어렵습니다.
SECTION 02

TLS 핸드셰이크

접속할 때 무슨 대화가 오가는지 알면 인증서 오류와 지연을 진단할 수 있습니다.

1
ClientHello 브라우저가 "지원하는 TLS 버전·암호 목록"과 접속하려는 도메인(SNI)을 보냅니다. SNI 는 아직 암호화되지 않아 어느 사이트인지 노출됩니다.
▼
2
ServerHello + 인증서 서버가 암호 방식을 고르고 인증서(서버+중간)를 보냅니다. 여기서 중간 인증서가 빠지면 일부 클라이언트에서 오류가 납니다(s08).
▼
3
인증서 검증 브라우저가 ① 신뢰하는 CA 가 서명했나 ② 도메인이 맞나 ③ 만료되지 않았나 ④ 폐기되지 않았나 를 확인합니다.
▼
4
키 교환 → 대칭키 확정 이후 실제 데이터는 빠른 대칭키 암호로 주고받습니다. 공개키 암호는 이 키를 안전하게 나누는 데만 씁니다.
TLS 1.3 이 빠른 이유 — 1.2 는 핸드셰이크에 왕복 2회(2-RTT)가 필요했지만 1.3 은 1회(1-RTT) 로 줄였고, 재접속 시에는 0-RTT 도 가능합니다. 지연이 큰 모바일 환경에서 체감 차이가 큽니다.
다만 0-RTT 는 재전송 공격 위험이 있어 GET 같은 안전한 요청에만 쓰도록 제한됩니다.
# 실제 핸드셰이크 관찰
openssl s_client -connect example.com:443 -servername example.com < /dev/null

# 협상된 프로토콜·암호만 확인
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | grep -E 'Protocol|Cipher'

# TLS 단계별 소요 시간
curl -w "tls:%{time_appconnect}s connect:%{time_connect}s total:%{time_total}s\n" \
     -o /dev/null -s https://example.com
인증서 검증 — 브라우저는 사슬을 타고 올라가 루트에 닿아야 믿습니다 정상 — 서버가 중간 인증서를 함께 보냈을 때 서버 인증서 example.com 서버가 보냄 중간 인증서 R11 등 서버가 함께 보냄 루트 인증서 ISRG Root X1 OS·브라우저에 내장 → 신뢰함 자물쇠 표시 문제 — 중간 인증서를 빠뜨렸을 때 (fullchain 대신 cert 만 지정) 서버 인증서 이것만 보냄 중간 인증서 없음 사슬이 끊김 루트에 못 닿음 ✕ → 기기마다 다름 (아래 설명) 이것이 "내 PC 는 되는데 안드로이드에서만 오류"의 정체입니다 — 일부 브라우저는 캐시된 중간 인증서로 스스로 메웁니다. 그래서 재현이 안 되고, curl 도 통과하는데 사용자만 못 들어오는 상황이 생깁니다. 해결은 fullchain 을 쓰는 것뿐입니다.
Nginx 는 fullchain.pem, Apache 2.4.8+ 도 fullchain.pem — 서버 인증서 하나만 지정하는 실수가 가장 흔합니다.

핸드셰이크에서 나는 오류 — 증상별

증상원인 · 확인
일부 기기에서만 인증서 오류중간 인증서 누락 — fullchain.pem 을 쓰고 있는지
NET::ERR_CERT_COMMON_NAME_INVALID인증서의 도메인과 접속 도메인이 다름 — www 포함 여부
ERR_CERT_DATE_INVALID만료 · 서버 시각이 틀림(NTP 확인)
다른 사이트 인증서가 나옴SNI 미매칭 — server_name·ServerName 확인
구형 기기만 접속 실패TLS 1.0/1.1 차단 · 최신 암호만 허용
첫 접속만 유난히 느림OCSP 스테이플링 미설정 → 브라우저가 CA 에 직접 문의
갱신했는데 옛 인증서reload 안 함 — 가장 흔합니다
사내망에서만 오류SSL 검사 장비가 인증서를 바꿔치기 — 사내 루트 CA 신뢰 필요
# ① 체인이 완전한지 — 가장 먼저 확인할 것
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
  | grep -A20 "Certificate chain"
# 서버 인증서 → 중간 인증서까지 나와야 정상입니다 (루트는 안 보내도 됩니다)

# ② 검증 결과
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
  | grep "Verify return code"
# "0 (ok)" 가 아니면 문제입니다

# ③ 중간 인증서 없이도 되는지 시험 (엄격 검증)
openssl s_client -connect example.com:443 -servername example.com \
  -verify_return_error -CApath /etc/ssl/certs < /dev/null

# ④ 협상 결과 — 프로토콜과 암호
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | grep -E "Protocol|Cipher|Server Temp Key"

# ⑤ 어느 단계가 느린지 숫자로
curl -w "dns:%{time_namelookup} tcp:%{time_connect} tls:%{time_appconnect} \
ttfb:%{time_starttransfer} total:%{time_total}\n" -o /dev/null -s https://example.com
# tls - tcp 가 핸드셰이크 시간입니다. 100ms 를 크게 넘으면 OCSP 를 의심하세요.
OCSP 스테이플링을 켜면 첫 접속이 눈에 띄게 빨라집니다. 켜지 않으면 브라우저가 인증서가 폐기됐는지 CA 서버에 직접 물어봅니다 — 해외에 있는 CA 라면 수백 ms 가 그대로 더해지고, CA 가 느리면 우리 사이트가 느려 보입니다. 스테이플링은 서버가 그 답변을 미리 받아 인증서와 함께 건네주는 방식입니다. Nginx ssl_stapling on;, Apache SSLUseStapling on 한 줄입니다.
※ 단, Let's Encrypt 는 2025년에 OCSP 서비스를 종료해 LE 인증서에는 OCSP 주소가 없습니다 — LE 인증서라면 스테이플링이 동작하지 않으며(경고만 남음), 폐기 확인은 CRL 로 넘어갔습니다. 유료 CA 인증서처럼 OCSP 주소가 있는 경우에만 의미가 있습니다.
SECTION 03

인증서의 종류와 구성

파일이 여러 개인데 각각 무엇인지 헷갈립니다. 여기서 정리합니다.

파일내용
privkey.pem개인키 — 절대 유출되면 안 됩니다. 권한 600
cert.pem서버 인증서 (내 도메인용)
chain.pem중간 인증서 — CA 와 내 인증서를 잇는 다리
fullchain.pem★ cert + chain — Nginx·Apache 에는 이것을 지정합니다
*.p12 · *.jks자바용 키스토어 (톰캣)
Nginx·Apache 에는 fullchain.pem 을 쓰세요. cert.pem 만 지정하면 중간 인증서가 빠져 PC 브라우저에서는 되는데 모바일·구형 클라이언트에서 오류가 납니다(s08).

신뢰 체인

루트 CA (브라우저·OS에 미리 내장)
      │ 서명
      ▼
중간 CA (Let's Encrypt R11 등)   ← chain.pem
      │ 서명
      ▼
내 서버 인증서 (example.com)      ← cert.pem

브라우저는 이 체인을 위로 따라가
'내가 아는 루트'에 닿아야 신뢰합니다.
중간 인증서가 빠지면 체인이 끊깁니다.

인증서 종류

구분내용
DV (도메인 검증)도메인 소유만 확인. Let's Encrypt 가 이것 · 무료 · 자동
OV (조직 검증)기업 실체를 확인. 유료
EV (확장 검증)엄격한 심사. 유료. 브라우저 주소창의 회사명 표시는 대부분 사라졌습니다
단일 도메인example.com 하나
SAN (멀티 도메인)여러 도메인을 한 인증서에 (Let's Encrypt 는 최대 100개)
와일드카드*.example.com — DNS 챌린지가 필요합니다
암호화 강도는 DV·OV·EV 가 모두 같습니다. 차이는 발급 전 신원 확인 절차뿐입니다. EV 인증서가 브라우저에 회사명을 표시하던 기능은 주요 브라우저에서 제거됐으므로, 대부분의 서비스에는 무료 DV 로 충분합니다. (금융·공공 등 규정상 OV·EV 가 요구되는 경우는 예외)
# 인증서 내용 확인
openssl x509 -in fullchain.pem -noout -text | head -30
openssl x509 -in fullchain.pem -noout -subject -issuer -dates
openssl x509 -in fullchain.pem -noout -ext subjectAltName    # 포함된 도메인 목록

# 개인키와 인증서가 짝이 맞는지 (해시가 같아야 합니다)
#   공개키를 비교하므로 RSA·ECDSA 모두 됩니다 (certbot 2.0+ 기본 키는 ECDSA 라 -modulus 는 실패)
openssl x509 -noout -pubkey -in fullchain.pem | openssl md5
openssl pkey -pubout        -in privkey.pem   | openssl md5
인증서 — 검증 수준과 적용 범위 두 축 검증 수준 — 대부분 DV 로 충분합니다 DV(도메인 검증) — 도메인 소유만 확인. 무료이고 자동 발급됩니다 OV · EV(조직 검증) — 회사 실체까지 확인. 브라우저에서 보이는 차이는 이제 거의 없습니다 적용 범위 단일 — example.com 하나만 멀티 도메인(SAN) — 여러 이름을 한 인증서에 담습니다 와일드카드 — *.example.com. DNS 방식으로만 발급되고 한 단계 아래만 덮습니다 *.example.com 은 a.b.example.com 을 덮지 않습니다 — 자주 걸리는 함정입니다 example.com 과 www.example.com 은 다른 이름입니다 — 둘 다 넣어야 합니다 인증서 안에는 공개키와 도메인 목록과 유효기간, 그리고 발급 기관의 서명이 들어 있습니다
고민이 길어지면 DV + 필요한 이름 전부면 대부분 끝납니다.
SECTION 04

Let's Encrypt · certbot

무료 인증서를 발급받습니다. 명령 한 번이면 됩니다.

전제 조건
① 도메인이 이 서버 IP 를 가리켜야 합니다 (dig +short example.com)
② 80 포트가 인터넷에서 접근 가능해야 합니다 (HTTP-01 챌린지)
③ 와일드카드(*.example.com)는 DNS 챌린지가 필요합니다
# 설치
sudo apt install -y certbot python3-certbot-nginx      # Nginx 플러그인
sudo apt install -y python3-certbot-apache             # Apache 플러그인

# ── 방법 ① 웹서버 설정까지 자동으로 (가장 쉬움)
sudo certbot --nginx -d example.com -d www.example.com

# ── 방법 ② 인증서만 받고 설정은 직접
sudo certbot certonly --nginx -d example.com

# ── 방법 ③ 웹서버를 잠시 멈추고 (standalone)
sudo certbot certonly --standalone -d example.com

# ── 방법 ④ 실행 중인 서버의 문서 루트를 이용 (무중단)
sudo certbot certonly --webroot -w /var/www/html -d example.com

# ── 방법 ⑤ DNS 챌린지 (와일드카드·포트를 못 여는 경우)
sudo certbot certonly --manual --preferred-challenges dns -d '*.example.com'
# 안내에 따라 DNS 에 TXT 레코드를 추가해야 합니다
# webroot 방식을 쓰려면 이 location 이 열려 있어야 합니다
server {
    listen 80;
    server_name example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
        allow all;                        # ★ 인증 없이 접근 가능해야 합니다
    }

    location / {
        return 301 https://$host$request_uri;
    }
}
발급 한도에 주의하세요. Let's Encrypt 는 등록 도메인당 주 50건, 동일한 도메인 조합은 주 5건의 제한이 있습니다. 테스트하다 반복 실패하면 일주일간 발급이 막힙니다.
테스트에는 반드시 --dry-run 또는 스테이징 서버를 쓰세요: certbot certonly --dry-run --nginx -d example.com
# 발급된 인증서 확인
sudo certbot certificates
sudo ls -l /etc/letsencrypt/live/example.com/

# 도메인 추가·변경 (기존 인증서를 확장)
sudo certbot --nginx -d example.com -d www.example.com -d api.example.com --expand

# 인증서 폐기·삭제
sudo certbot revoke --cert-path /etc/letsencrypt/live/example.com/cert.pem
sudo certbot delete --cert-name example.com
SECTION 05

따라하기 — 발급부터 적용까지

도메인 하나를 HTTP 상태에서 HTTPS 완성까지 한 번에 갑니다. 단계마다 확인 명령이 있으니 그대로 순서대로 실행하면 됩니다. (Nginx 기준 · 소요 약 15분)

STEP 0
준비물 점검 도메인 · 서버(공인 IP) · Nginx 설치 완료 · 80/443 포트 개방. 하나라도 없으면 아래 0단계에서 먼저 해결합니다.
▼
STEP 1
DNS 를 서버로 연결 도메인의 A 레코드가 이 서버 IP 를 가리키게 합니다. 전파 확인 전에는 다음 단계로 가지 마세요.
▼
STEP 2
HTTP(80) 서버 블록 준비 인증 요청이 들어올 자리를 만듭니다. 이 시점에는 아직 HTTP 사이트입니다.
▼
STEP 3
certbot 으로 발급 + 자동 설정 인증서를 받으면서 443 블록·리다이렉트까지 certbot 이 만들어 줍니다.
▼
STEP 4
적용 확인 브라우저 자물쇠 · 체인 · 리다이렉트 · 등급을 확인합니다.
▼
STEP 5
자동 갱신 확인 여기까지 마쳐야 90일 뒤에도 서비스가 살아 있습니다.

STEP 0 — 준비물 점검

# Nginx 가 떠 있나
systemctl status nginx --no-pager | head -3

# 80·443 포트가 열려 있나 (ufw 기준)
sudo ufw status | grep -E '80|443'
sudo ufw allow 'Nginx Full'                 # 안 열려 있으면

# 클라우드(AWS·GCP 등)라면 보안 그룹에서도 80·443 인바운드를 열어야 합니다
# 내 서버의 공인 IP 확인
curl -4 -s ifconfig.me; echo

STEP 1 — DNS 연결과 전파 확인

도메인 관리 콘솔(가비아·후이즈·Cloudflare·Route 53 등)에서 A 레코드를 추가합니다: example.com → 서버 공인 IP, 필요하면 www 도 함께.

# 도메인이 내 서버 IP 를 가리키는지 (STEP 0 의 IP 와 같아야 합니다)
dig +short example.com
dig +short www.example.com

# 아직 예전 값이 나오면 TTL 만큼 기다립니다 (보통 수분~1시간)
# 여러 지역에서의 전파 상태는 dnschecker.org 에서 확인할 수 있습니다
DNS 가 전파되기 전에 certbot 을 실행하면 실패합니다. Let's Encrypt 서버가 도메인으로 접속해 확인하는 방식이기 때문입니다. 실패를 반복하면 발급 한도(s04)에 걸려 일주일간 막힐 수 있으니 dig 결과가 맞을 때까지 기다렸다가 진행하세요.

STEP 2 — HTTP 서버 블록 준비

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com www.example.com;   # ★ 발급받을 도메인과 정확히 일치

    root /var/www/example.com;
    index index.html;
}
sudo mkdir -p /var/www/example.com
echo '<h1>It works</h1>' | sudo tee /var/www/example.com/index.html

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

# 확인 — HTTP 로 접속되는지 (여기까지가 HTTPS 이전의 마지막 모습입니다)
curl -I http://example.com

STEP 3 — 발급 + 자동 설정 (핵심 한 줄)

sudo apt install -y certbot python3-certbot-nginx

# 이 한 줄이 ①발급 ②443 블록 생성 ③인증서 지정 ④HTTP→HTTPS 리다이렉트까지 합니다
sudo certbot --nginx -d example.com -d www.example.com

# 처음 실행하면 이메일(만료 경고 수신용)과 약관 동의를 묻습니다
# "Congratulations!" 가 나오면 성공입니다
certbot 이 내 설정을 어떻게 바꿨는지 봐 두세요. sites-available/example.com 을 다시 열면 443 server 블록과 ssl_certificate 두 줄, 그리고 80 블록의 301 리다이렉트가 추가돼 있습니다. 자동 수정이 싫다면 certbot certonly 로 인증서만 받고 s07 의 서버별 설정을 직접 쓰면 됩니다.

STEP 4 — 적용 확인

# ① HTTPS 응답 확인
curl -I https://example.com

# ② HTTP → HTTPS 리다이렉트 확인 (301 + Location: https:// 가 나와야 합니다)
curl -I http://example.com

# ③ 체인이 완전한지 (0 과 1 두 개가 보여야 정상 — s08)
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | grep -A 5 'Certificate chain'

# ④ 모바일 실기기 브라우저에서도 한 번 열어 보세요 (PC 는 체인 문제를 감춰 줍니다)
# ⑤ ssllabs.com/ssltest 에서 등급 확인 — A 이상이면 합격

STEP 5 — 자동 갱신 확인 (빠뜨리면 90일 뒤 장애)

# 갱신 타이머가 걸려 있나
systemctl list-timers | grep certbot

# 갱신 리허설 — 전 과정을 실제 발급 없이 검증합니다
sudo certbot renew --dry-run
# "Congratulations, all simulated renewals succeeded" 가 나오면 완료입니다
여기까지 하면 HTTPS 적용은 끝입니다. 더 깊게 가려면 —
· 갱신 후 reload 훅과 타이머 구조 → s06
· Apache · Tomcat · Caddy 에 적용 → s07 (Caddy 는 이 전 과정이 자동입니다 · 🔒 Caddy c05)
· 보안 등급 올리기(TLS 버전 · HSTS) → s09 · s10
· 만료 감시 알람 → s12
자주 나는 실패 3가지
① DNS problem: NXDOMAIN — DNS 전파 전에 실행 (STEP 1 로)
② Connection refused — 80 포트가 닫혀 있음 (STEP 0 방화벽·보안 그룹)
③ unauthorized — 도메인이 다른 서버를 가리키고 있음 (dig 로 IP 재확인)
SECTION 06

갱신 자동화

Let's Encrypt 인증서는 90일짜리입니다. 자동 갱신이 조용히 실패하면 어느 날 갑자기 서비스가 멈춥니다.

# 패키지로 설치하면 타이머가 이미 등록돼 있습니다
systemctl list-timers | grep certbot
sudo systemctl status certbot.timer

# 갱신 테스트 (실제로 갱신하지 않고 전 과정을 검증)
sudo certbot renew --dry-run

# 수동 갱신 (만료 30일 이내인 것만 실제로 갱신됩니다)
sudo certbot renew

# 강제 갱신 (테스트 외에는 쓰지 마세요 — 발급 한도 소모)
sudo certbot renew --force-renewal
갱신은 됐는데 적용이 안 되는 경우가 가장 흔합니다. 파일은 새로 받았지만 웹서버가 옛 인증서를 메모리에 들고 있어 만료된 인증서를 계속 내보냅니다. 갱신 후 반드시 reload 를 걸어야 합니다.
# 갱신 성공 시 자동으로 실행될 훅
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-web.sh <<'EOF'
#!/usr/bin/env bash
set -e
systemctl reload nginx    || true
systemctl reload apache2  || true
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-web.sh
훅 종류실행 시점
renewal-hooks/pre/갱신 시도 전 (예: standalone 을 위해 웹서버 중지)
renewal-hooks/deploy/★ 갱신 성공 시에만 — reload 는 여기에
renewal-hooks/post/갱신 시도 후 (성공·실패 무관)

직접 타이머를 만든다면

# /etc/systemd/system/certbot-renew.service
[Unit]
Description=Certbot renewal
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"

# /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Run certbot twice daily
[Timer]
OnCalendar=*-*-* 03,15:00:00
# ★ 서버가 많으면 분산 (CA 부하 방지)
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
하루 두 번 실행하는 이유 — 갱신은 만료 30일 이내일 때만 실제로 수행되므로 자주 돌려도 무해합니다. 한 번 실패해도 다음 시도가 곧 있어 여유가 생깁니다. 랜덤 지연은 전 세계 서버가 정각에 몰리는 것을 막기 위한 배려입니다.
갱신 자동화 — 세 부분이 다 돌아야 완성입니다 ① 주기적으로 갱신 시도 타이머 · cron — 하루 두 번 ② 성공하면 reload 서버가 새 파일을 다시 읽어야 ③ 실패하면 알림 조용한 실패가 가장 위험 가장 흔한 사고 — 갱신은 됐는데 서버가 옛 인증서를 계속 씀 파일은 새것인데 프로세스는 시작할 때 읽은 것을 메모리에 들고 있습니다 그래서 만료 당일에 갑자기 장애가 납니다 — deploy-hook 으로 reload 를 반드시 거세요 확인은 실제 접속으로 — 파일 날짜가 아니라 서버가 실제로 주는 인증서의 만료일을 보세요 만료 30일 · 7일 전에 알림이 오도록 감시를 따로 두세요 — 자동화의 실패까지 감시해야 합니다 시험할 때는 스테이징 환경을 쓰세요 — 발급 시도 횟수에는 제한이 있습니다 컨테이너라면 인증서를 볼륨에 두세요 — 컨테이너를 새로 만들 때마다 다시 발급받게 됩니다
인증서 장애는 예고가 있습니다 — 다만 보고 있어야 보입니다.
SECTION 07

서버별 설치 방법

같은 인증서라도 서버마다 지정 방식이 다릅니다.

Nginx
server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
}
# sudo nginx -t && sudo systemctl reload nginx
Apache
<VirtualHost *:443>
    ServerName example.com
    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
    SSLProtocol -all +TLSv1.2 +TLSv1.3
</VirtualHost>
# sudo apachectl configtest && sudo systemctl reload apache2
Tomcat · Spring Boot
# PEM → PKCS12 변환이 필요합니다
openssl pkcs12 -export \
  -in  /etc/letsencrypt/live/example.com/fullchain.pem \
  -inkey /etc/letsencrypt/live/example.com/privkey.pem \
  -out keystore.p12 -name tomcat \
  -passout pass:changeit

# application.properties
# server.ssl.key-store=/opt/app/keystore.p12
# server.ssl.key-store-type=PKCS12
# server.ssl.key-alias=tomcat
Caddy
example.com {
    # 아무것도 안 써도 자동 발급·갱신됩니다

    # 직접 준비한 인증서를 쓰려면
    # tls /path/cert.pem /path/key.pem
}
파일 권한을 확인하세요. privkey.pem 은 600(소유자만 읽기)이어야 합니다. Nginx·Apache 는 root 로 시작한 마스터 프로세스가 인증서를 읽으므로 워커 계정에 권한을 줄 필요가 없습니다.
반면 톰캣·Spring Boot 는 앱 계정으로 읽어야 하므로 키스토어 파일 소유자를 앱 계정으로 바꿔야 합니다.

여러 도메인 · SNI

# 한 IP 에서 여러 도메인에 각각 다른 인증서
server {
    listen 443 ssl;
    server_name a.example.com;
    ssl_certificate     /etc/letsencrypt/live/a.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/a.example.com/privkey.pem;
}
server {
    listen 443 ssl;
    server_name b.example.com;
    ssl_certificate     /etc/letsencrypt/live/b.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/b.example.com/privkey.pem;
}
# SNI 덕분에 가능합니다 — 클라이언트가 핸드셰이크에서 도메인을 알려 주기 때문입니다
SNI 를 지원하지 않는 아주 오래된 클라이언트는 첫 번째 인증서를 받게 되어 이름 불일치 오류가 납니다. Windows XP·Android 2.x 수준이므로 현실적으로는 고려하지 않아도 됩니다.
SECTION 08

체인 · 중간 인증서

"PC 에서는 되는데 모바일에서만 오류" — 이 증상의 원인은 거의 항상 이것입니다.

✗ 중간 인증서 누락
ssl_certificate /etc/letsencrypt/live/x/cert.pem;

# 서버가 보내는 것: [내 인증서] 만
# 브라우저: "이걸 서명한 중간 CA 를 모르겠는데?"
#
# PC 브라우저는 캐시나 AIA fetch 로
# 중간 인증서를 알아서 구해 오는 경우가 있어
# 문제를 못 느낍니다 (그래서 발견이 늦습니다)
✓ fullchain 사용
ssl_certificate /etc/letsencrypt/live/x/fullchain.pem;

# 서버가 보내는 것:
#   [내 인증서] + [중간 CA 인증서]
#
# 모든 클라이언트가 체인을 완성할 수 있습니다
# 체인이 제대로 나가는지 확인
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | grep -A 5 'Certificate chain'
Certificate chain 0 s:CN = example.com i:C = US, O = Let's Encrypt, CN = R11 ← 내 인증서 1 s:C = US, O = Let's Encrypt, CN = R11 i:C = US, O = Internet Security Research Group, CN = ISRG Root X1 ← 중간 인증서 ✓
0 만 있고 1 이 없으면 체인이 빠진 것입니다. cert.pem 대신 fullchain.pem 을 지정하세요.
Apache 2.4.8 미만에서는 SSLCertificateChainFile 로 따로 지정했지만, 2.4.8 이상은 SSLCertificateFile 에 fullchain 을 넣으면 됩니다.

순서도 중요합니다

fullchain.pem 의 올바른 순서

-----BEGIN CERTIFICATE-----   ← ① 서버 인증서 (내 도메인)
...
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----   ← ② 중간 CA
...
-----END CERTIFICATE-----

★ 순서가 뒤바뀌면 일부 클라이언트가 거부합니다
★ 루트 CA 는 넣지 않아도 됩니다 (클라이언트가 이미 갖고 있음)
  넣어도 동작하지만 전송량만 늘어납니다
진단 도구
· SSL Labs(ssllabs.com/ssltest) — 체인 문제를 "Chain issues: Incomplete" 로 명확히 알려 줍니다
· openssl s_client -showcerts — 실제로 나가는 인증서 전부를 봅니다
· 모바일에서 직접 테스트 — PC 브라우저는 문제를 감춰 줄 수 있습니다
체인 — 뿌리까지 이어져야 신뢰가 성립합니다 내 인증서 example.com 발급자 중간 인증서 함께 보내야 합니다 뿌리 인증서 브라우저가 이미 갖고 있습니다 중간 인증서를 빠뜨리면 브라우저가 사슬을 잇지 못해 경고를 띄웁니다 — 인증서 자체는 멀쩡한데도 그렇습니다 일부 브라우저는 알아서 찾아 주기도 해서 "어떤 환경에서만 안 된다" 는 형태로 나타납니다 모바일 앱이나 서버 간 호출에서 먼저 터지는 경우가 많습니다 해결 — 인증서와 중간 인증서를 이어 붙인 파일(fullchain)을 서버에 설정하세요 확인 — openssl s_client 나 SSL 검사 사이트로 사슬이 완성됐는지 볼 수 있습니다 뿌리 인증서는 서버가 보낼 필요가 없습니다 — 이미 상대가 갖고 있습니다
"내 브라우저에서는 되는데" 라는 HTTPS 문제의 단골 원인입니다.
SECTION 09

TLS 버전 · 암호 스위트

보안 점검에서 등급이 낮게 나오는 이유는 대부분 여기입니다.

버전상태
SSL 2.0 · 3.0폐기 — 절대 쓰면 안 됩니다
TLS 1.0 · 1.12020년 주요 브라우저에서 지원 종료 — 비활성화하세요
TLS 1.2현재 표준 — 호환성을 위해 켜 둡니다
TLS 1.3★ 최신 · 더 빠르고 안전 — 반드시 켜세요
# Nginx — 권장 설정 (현대적 호환성)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;         # TLS1.3 에서는 클라이언트 우선이 권장됩니다
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;

# 세션 재개 (재접속 지연 감소)
ssl_session_cache   shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;               # 티켓 키 관리가 어렵다면 끄는 편이 안전

# OCSP 스테이플링 (인증서 폐기 확인을 서버가 대신)
# ※ OCSP 주소가 있는 인증서에만 해당 — Let's Encrypt 는 2025년 OCSP 종료 (s02)
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
암호 스위트를 직접 고르지 마세요. Mozilla SSL Configuration Generator(ssl-config.mozilla.org)에서 서버 종류와 버전을 고르면 검증된 설정을 만들어 줍니다.
· Modern — TLS 1.3 만. 최신 클라이언트만 지원
· Intermediate — 대부분의 서비스에 권장
· Old — 아주 오래된 클라이언트 지원 (보안 약화)

알아 둘 개념

  • 전방 비밀성 (Forward Secrecy) — 개인키가 나중에 유출돼도 과거 트래픽을 복호화할 수 없게 하는 성질. ECDHE 로 시작하는 암호가 이를 제공합니다
  • AEAD — 암호화와 무결성 검증을 함께 하는 방식(GCM·CHACHA20-POLY1305). TLS 1.3 은 AEAD 만 씁니다
  • ECDSA vs RSA — ECDSA 인증서가 더 작고 빠릅니다. 둘 다 발급해 클라이언트에 맞게 제공할 수도 있습니다
# 우리 서버가 어떤 프로토콜·암호를 지원하는지 점검
nmap --script ssl-enum-ciphers -p 443 example.com

# 특정 버전만 시도해 보기
openssl s_client -connect example.com:443 -tls1_2 < /dev/null
openssl s_client -connect example.com:443 -tls1_1 < /dev/null   # 실패해야 정상
SECTION 10

HSTS · 혼합 콘텐츠

HTTPS 를 켜는 것만으로는 부족합니다. HTTP 로 새는 경로를 막아야 합니다.

HSTS — 브라우저에게 HTTPS 를 강제

# Nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

# preload 목록에 등록하려면 (아래 경고를 반드시 읽으세요)
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
HSTS 가 막는 것 — 사용자가 example.com 을 입력하면 브라우저는 먼저 HTTP 로 접속했다가 리다이렉트를 받습니다. 그 첫 요청이 가로채기(SSL 스트리핑)에 노출됩니다. HSTS 헤더를 한 번 받으면 브라우저는 그다음부터 HTTP 를 아예 시도하지 않습니다.
preload 는 신중하게. 브라우저에 내장되어 배포되므로 되돌리는 데 수개월이 걸립니다.
· 모든 서브도메인이 HTTPS 를 지원하는지 확인하고
· max-age 를 짧게(300초) 시작해 점진적으로 늘린 뒤
· 확신이 섰을 때만 preload 를 신청하세요
내부용 서브도메인 하나가 HTTP 라면 그것도 접속이 막힙니다.

혼합 콘텐츠 (Mixed Content)

HTTPS 페이지 안에서 HTTP 리소스를 불러오면 브라우저가 차단하거나 경고합니다. 이미지·스크립트·CSS·폰트가 모두 대상입니다.

<!-- ✗ 차단됨 -->
<script src="http://cdn.example.com/lib.js"></script>

<!-- ✓ 명시적 HTTPS -->
<script src="https://cdn.example.com/lib.js"></script>

<!-- ✓ 프로토콜 상대 경로 (현재는 그냥 https 를 쓰는 편이 낫습니다) -->
<script src="//cdn.example.com/lib.js"></script>
# 임시 대응 — 브라우저에게 HTTP 요청을 HTTPS 로 바꿔 달라고 요청
add_header Content-Security-Policy "upgrade-insecure-requests" always;
# 근본 해결은 코드에서 URL 을 고치는 것입니다
혼합 콘텐츠를 찾는 법 — 브라우저 개발자 도구 콘솔에 경고가 출력됩니다. 또는 CSP 의 report-uri 를 설정해 실제 사용자 환경에서 수집할 수도 있습니다.
앱이 HTTPS 를 인식하지 못해 HTTP URL 을 생성하는 경우도 흔합니다 — X-Forwarded-Proto 설정을 확인하세요(🟩 Nginx n08).
자물쇠가 사라졌다면 — 페이지 안에 http:// 가 남아 있습니다 페이지 자체 https://shop.com — 안전 <script src="http://cdn…"> <img src="http://img…"> 아예 차단 경고 · 자물쇠 해제 고치는 순서 ① 개발자 도구 콘솔에 어떤 주소가 문제인지 다 나옵니다 ② 그 주소를 https 로 바꿉니다 — 상대 경로면 더 좋습니다 ③ 대상이 https 를 지원하지 않으면 우리가 프록시해 줍니다 HSTS — 다음부터 http 로 가지도 않게 Strict-Transport-Security: max-age=31536000 브라우저가 그 기간 동안 무조건 https 로만 접속합니다 한 번 걸면 그 기간 동안 되돌릴 수 없습니다 HSTS 를 켜기 전 확인 — 서브도메인까지 전부 HTTPS 가 되는지 includeSubDomains 를 붙였는데 사내용 서브도메인이 http 라면 그날부터 접속이 안 됩니다. max-age 를 짧게 시작해 늘려 가세요
HTTPS 적용은 서버 설정 절반, 페이지 안의 주소 절반입니다.
자물쇠가 사라졌다면 — 페이지 안에 http:// 가 남아 있습니다 페이지 자체 https://shop.com — 안전 <script src="http://cdn…"> <img src="http://img…"> 아예 차단 경고 · 자물쇠 해제 고치는 순서 ① 개발자 도구 콘솔에 어떤 주소가 문제인지 다 나옵니다 ② 그 주소를 https 로 바꿉니다 — 상대 경로면 더 좋습니다 ③ 대상이 https 를 지원하지 않으면 우리가 프록시해 줍니다 HSTS — 다음부터 http 로 가지도 않게 Strict-Transport-Security: max-age=31536000 브라우저가 그 기간 동안 무조건 https 로만 접속합니다 한 번 걸면 그 기간 동안 되돌릴 수 없습니다 HSTS 를 켜기 전 확인 — 서브도메인까지 전부 HTTPS 가 되는지 includeSubDomains 를 붙였는데 사내용 서브도메인이 http 라면 그날부터 접속이 안 됩니다. max-age 를 짧게 시작해 늘려 가세요
HTTPS 적용은 서버 설정 절반, 페이지 안의 주소 절반입니다.
SECTION 11

mTLS 상호 인증

서버뿐 아니라 클라이언트도 인증서로 신원을 증명합니다.

# Nginx — 클라이언트 인증서 요구
server {
    listen 443 ssl;
    server_name api-internal.example.com;

    ssl_certificate     /etc/ssl/server.crt;
    ssl_certificate_key /etc/ssl/server.key;

    # 클라이언트 인증서 검증
    ssl_client_certificate /etc/ssl/ca.crt;      # 클라이언트를 서명한 CA
    ssl_verify_client on;                        # on · optional · off
    ssl_verify_depth 2;

    location / {
        # 검증 결과를 앱에 전달
        proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
        proxy_set_header X-SSL-Client-DN     $ssl_client_s_dn;
        proxy_set_header X-SSL-Client-Serial $ssl_client_serial;

        proxy_pass http://backend;
    }
}
# 사설 CA 와 클라이언트 인증서 만들기
# 1) CA 키·인증서
openssl req -x509 -newkey rsa:4096 -days 3650 -nodes \
  -keyout ca.key -out ca.crt -subj "/CN=Internal CA"

# 2) 클라이언트 키·CSR
openssl req -newkey rsa:2048 -nodes \
  -keyout client.key -out client.csr -subj "/CN=service-a"

# 3) CA 로 서명
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out client.crt -days 365

# 4) 사용
curl --cert client.crt --key client.key https://api-internal.example.com/
쓰는 곳설명
서비스 간 통신마이크로서비스끼리 서로를 확인 — 서비스 메시의 기본
파트너 API특정 기업만 접근하게 (IP 제한보다 확실)
금융·공공규정상 요구되는 경우가 많습니다
관리자 접근VPN 대신 인증서로 접근 통제
IoT 기기기기마다 인증서를 심어 신원 확인
mTLS 의 실질적 비용은 인증서 관리입니다.
· 클라이언트가 100개면 인증서도 100개 — 발급·배포·갱신·폐기 체계가 필요합니다
· 만료 관리가 서버 인증서보다 훨씬 어렵습니다
· 폐기 목록(CRL·OCSP) 운영도 필요합니다
일반 서비스라면 API 키·OAuth 토큰이 운영 부담이 훨씬 적습니다. 쿠버네티스 환경이라면 서비스 메시(Istio·Linkerd) 가 이를 자동화해 줍니다.

일반 TLS 와 무엇이 다른가

항목일반 TLSmTLS
누구를 확인클라이언트가 서버를 확인서로 확인
클라이언트 신원토큰·쿠키 (앱 계층)인증서 (전송 계층)
탈취되면토큰만 있으면 아무 데서나 사용 가능인증서 + 개인 키가 함께 있어야 함
폐기토큰 무효화 (즉시)CRL·OCSP 갱신 필요 (느립니다)
갱신자동 (리프레시 토큰)수동 또는 별도 자동화
운영 난이도낮음높음 — 인증서 수명 관리가 전부입니다
mTLS 의 실질적 강점은 "네트워크에 붙는 것만으로는 아무것도 못 한다" 는 점입니다. 사내망이 뚫려도 유효한 클라이언트 인증서와 개인 키가 없으면 API 를 못 부릅니다. IP 허용 목록은 같은 대역에 발을 들이면 무력화되지만, mTLS 는 그렇지 않습니다. 반대로 키가 유출되면 IP 제한보다 나을 것이 없어지므로, 키를 어디에 어떻게 두는지가 이 방식의 전부입니다.
# 경로별로 다르게 — API 만 mTLS, 문서는 공개
server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate     /etc/ssl/server.crt;
    ssl_certificate_key /etc/ssl/server.key;

    ssl_client_certificate /etc/ssl/ca.crt;
    ssl_verify_client optional;              # ★ 요구하되 거부하지는 않음

    # 공개 문서 — 인증서 없어도 통과
    location /docs/ {
        root /var/www;
    }

    # API — 검증 성공한 경우만
    location /v1/ {
        if ($ssl_client_verify != SUCCESS) { return 403; }

        # Nginx 에는 CN 만 주는 변수가 없습니다 — DN 전체를 넘기고 앱에서 CN 을 꺼내세요
        proxy_set_header X-Client-DN     $ssl_client_s_dn;
        proxy_set_header X-Client-Serial $ssl_client_serial;
        proxy_set_header X-Client-Verify $ssl_client_verify;
        proxy_pass http://backend;
    }
}
# 폐기 목록(CRL) — 인증서를 무효화하는 유일한 수단입니다
openssl ca -config ca.cnf -revoke client.crt          # 폐기 기록
openssl ca -config ca.cnf -gencrl -out crl.pem        # 목록 재생성

# Nginx 에 적용 (재생성할 때마다 reload 필요)
#   ssl_crl /etc/ssl/crl.pem;

# 클라이언트 인증서 정보 확인
openssl x509 -in client.crt -noout -subject -dates -serial

# 실제로 되는지 시험
curl --cert client.crt --key client.key https://api.example.com/v1/ping
curl https://api.example.com/v1/ping        # ← 403 이 나와야 정상 (optional 블록)
인증서 수명을 짧게 잡고 자동 갱신을 먼저 만드세요. 1년짜리 인증서 100장을 수동으로 관리하겠다는 계획은 반드시 실패합니다 — 갱신을 잊은 날 서비스가 멈추고, 그것도 대개 연휴에 일어납니다.
클라이언트가 몇 개 안 되고 갱신 자동화를 만들 여력이 없다면, mTLS 대신 API 키 + IP 제한 + 짧은 수명 토큰이 현실적으로 더 안전합니다. 운영할 수 없는 보안 장치는 보안이 아닙니다.
쿠버네티스라면 직접 만들지 마세요. cert-manager 가 발급·갱신·배포를 자동화하고, Istio·Linkerd 는 파드 간 통신에 mTLS 를 코드 변경 없이 자동 적용합니다 (사이드카가 대신 처리합니다). 직접 CA 를 운영하는 것은 그 자동화가 안 되는 환경에서만 선택하세요.
mTLS — 서버뿐 아니라 클라이언트도 증명합니다 ① 일반 HTTPS — 한쪽만 확인 클라이언트 접속 서버 인증서 제시 서버 누구나 접속할 수 있습니다 — 인증은 로그인으로 따로 ② mTLS — 양쪽 모두 확인 클라이언트 클라이언트 인증서 보유 인증서 제시 서버 인증서 제시 서버 CA 로 검증 후 허용 인증서가 없으면 연결 자체가 끊깁니다 애플리케이션까지 요청이 닿지 않습니다 쓰는 자리 — 서버 간 내부 통신 · 관리자 전용 화면 · 결제 기관 연동 · 사물인터넷 기기 비용은 인증서 배포와 폐기 관리입니다. 기기가 늘어날수록 이 운영이 본체가 되므로, 범위를 좁혀 적용하세요
"비밀번호 없이 기기만으로 막는" 방법입니다 — 대신 인증서 운영이 따라옵니다.
mTLS — 서버뿐 아니라 클라이언트도 증명합니다 ① 일반 HTTPS — 한쪽만 확인 클라이언트 접속 서버 인증서 제시 서버 누구나 접속할 수 있습니다 — 인증은 로그인으로 따로 ② mTLS — 양쪽 모두 확인 클라이언트 클라이언트 인증서 보유 인증서 제시 서버 인증서 제시 서버 CA 로 검증 후 허용 인증서가 없으면 연결 자체가 끊깁니다 애플리케이션까지 요청이 닿지 않습니다 쓰는 자리 — 서버 간 내부 통신 · 관리자 전용 화면 · 결제 기관 연동 · 사물인터넷 기기 비용은 인증서 배포와 폐기 관리입니다. 기기가 늘어날수록 이 운영이 본체가 되므로, 범위를 좁혀 적용하세요
"비밀번호 없이 기기만으로 막는" 방법입니다 — 대신 인증서 운영이 따라옵니다.
SECTION 12

만료 감시

인증서 만료로 서비스가 멈추는 사고는 대기업에서도 매년 일어납니다. 감시가 유일한 예방책입니다.

# 남은 일수 확인 (한 줄)
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -enddate

# 파일에서 확인
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate

# 30일 이내면 실패로 처리 (모니터링·CI 에 붙이기 좋습니다)
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | openssl x509 -noout -checkend $((30*86400)) \
  && echo "OK: 30일 이상 남음" || echo "WARNING: 30일 이내 만료"
#!/usr/bin/env bash
# 여러 도메인을 한 번에 점검하는 스크립트
DOMAINS=(example.com api.example.com admin.example.com)
WARN_DAYS=21

for d in "${DOMAINS[@]}"; do
  end=$(echo | openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
  if [ -z "$end" ]; then
    echo "ERROR  $d : 인증서를 가져오지 못했습니다"
    continue
  fi
  left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
  if   [ "$left" -lt 0  ];          then echo "EXPIRED $d : ${left}일"
  elif [ "$left" -lt $WARN_DAYS ];  then echo "WARN    $d : ${left}일 남음"
  else                                   echo "OK      $d : ${left}일 남음"
  fi
done
방법설명
Prometheus + blackbox_exporter ★ probe_ssl_earliest_cert_expiry 지표로 알람 설정
Uptime 모니터링 서비스대부분 인증서 만료 알림을 기본 제공합니다
cron 스크립트 + 알림위 스크립트를 매일 돌려 슬랙·메일로
Let's Encrypt 이메일등록한 주소로 만료 경고가 오지만 이것만 믿지 마세요
# Prometheus 알람 규칙
groups:
  - name: tls
    rules:
      - alert: TLSCertExpiringSoon
        expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 21
        for: 1h
        labels: { severity: warning }
        annotations:
          summary: "{{ $labels.instance }} 인증서가 {{ $value | printf \"%.0f\" }}일 뒤 만료"

      - alert: TLSCertExpiringCritical
        expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 7
        for: 10m
        labels: { severity: critical }
감시해야 할 대상을 빠뜨리지 마세요.
· 서브도메인 전부 — 메인만 보고 있다가 API 도메인이 만료되는 사고
· 내부 서비스 — 외부 모니터링에 안 잡힙니다
· 클라이언트 인증서(mTLS) — 서버 것보다 놓치기 쉽습니다
· 중간 CA 인증서 — 드물지만 만료됩니다
SECTION 13

진단 도구

문제가 생겼을 때 확인하는 명령들입니다.

# ── 기본 확인
openssl s_client -connect example.com:443 -servername example.com < /dev/null

# 인증서 정보만
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

# 체인 전체
openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null

# 협상 결과
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | grep -E 'Protocol|Cipher|Verify return code'

# ── 특정 버전으로 시도
openssl s_client -connect example.com:443 -tls1_3 < /dev/null
openssl s_client -connect example.com:443 -tls1_1 < /dev/null   # 실패해야 정상

# ── curl 로
curl -vI https://example.com 2>&1 | grep -E 'SSL|subject|issuer|expire'
curl --resolve example.com:443:1.2.3.4 -I https://example.com   # DNS 전환 전 테스트

# ── 지원 암호 전수 조사
nmap --script ssl-enum-ciphers -p 443 example.com
도구용도
SSL Labs (ssllabs.com/ssltest) ★ 종합 점검 — 등급·체인·프로토콜·취약점을 한 번에. 배포 후 한 번은 돌려 보세요
testssl.sh같은 검사를 내부망에서 실행할 수 있는 스크립트
Mozilla ObservatoryTLS + 보안 헤더까지 종합 평가
crt.sh인증서 투명성 로그 조회 — 내 도메인으로 발급된 인증서 전부를 확인
HardenizeDNS·메일·TLS 종합 점검

자주 만나는 오류

메시지원인
unable to get local issuer certificate 중간 인증서 누락 → fullchain 사용 (s08)
certificate has expired 만료. 갱신 후 reload 를 했는지 확인
Hostname mismatch 인증서의 도메인과 접속 도메인이 다름 — SAN 목록 확인
self signed certificate 자체 서명 인증서 — 신뢰된 CA 인증서로 교체
tlsv1 alert protocol version 클라이언트가 지원하지 않는 TLS 버전만 서버가 허용
ERR_CERT_AUTHORITY_INVALID 브라우저가 CA 를 모름 — 사설 CA 라면 클라이언트에 CA 를 설치해야 합니다
갱신은 됐는데 옛 인증서가 나감 reload 누락 — 가장 흔한 실수입니다
배포 전 체크리스트
① openssl s_client 로 체인이 완전한지
② SSL Labs 등급이 A 이상인지
③ 모바일 실기기에서 접속되는지
④ HTTP 접속이 HTTPS 로 리다이렉트되는지
⑤ 혼합 콘텐츠 경고가 없는지 (개발자 도구 콘솔)
⑥ 갱신 자동화가 동작하는지 (certbot renew --dry-run)
⑦ 만료 감시 알람이 걸려 있는지
JAVA · PYTHON · NODE · PHP · 프런트엔드 배포

언어별 연동 —
내 앱을
서버에 올리기

개발이 끝난 앱을 실제 서버에서 서비스하는 과정입니다. 언어마다 관례가 다르지만 구조는 같습니다 — 앱을 프로세스로 띄우고 · 죽으면 되살리고 · 앞에 웹서버를 둔다. 각 언어의 표준 방식을 처음부터 끝까지 정리합니다.

14SECTIONS
5언어 · 런타임
복붙바로 적용
SECTION 01

웹서버와 앱은 어떻게 대화하나

방식은 세 가지뿐입니다. 언어마다 관례가 다를 뿐입니다.

방식설명쓰는 곳
HTTP 프록시 웹서버가 앱에 HTTP 요청을 그대로 다시 보냅니다 ★ Node · Spring Boot · Go · Python(Gunicorn) — 가장 흔합니다
FastCGI 이진 프로토콜로 앱 프로세스에 전달 PHP-FPM
유닉스 소켓 TCP 대신 파일 형태의 소켓으로 (같은 서버 안에서만) Gunicorn · PHP-FPM — 약간 더 빠르고 안전
(AJP) Apache ↔ Tomcat 전용 이진 프로토콜 보안 이슈로 요즘은 잘 안 씁니다
공통 구조
어떤 언어든 결국 같습니다 ① 앱을 프로세스로 띄운다 → ② 프로세스 관리자가 죽으면 되살린다 → ③ 앞에 웹서버를 두고 프록시한다 → ④ 정적 파일은 웹서버가 처리한다
언어앱 실행프로세스 관리웹서버 연결
Javajava -jar · WARsystemd · 컨테이너HTTP 프록시
PythonGunicorn · Uvicornsystemd · 컨테이너HTTP 프록시 · 유닉스 소켓
Node.jsnode app.jsPM2 · systemd · 컨테이너HTTP 프록시
PHPPHP-FPMFPM 자체가 관리FastCGI
Go · Rust바이너리 직접systemd · 컨테이너HTTP 프록시
정적(React 등)없음 — 파일뿐없음웹서버가 직접 서빙
이 구조를 이해하면 어떤 언어든 배포할 수 있습니다. 새로운 언어를 만나도 "이 언어는 앱을 어떻게 띄우지?"만 찾으면 나머지(웹서버 설정·systemd·정적 파일 분리)는 거의 동일합니다.
언어가 무엇이든 이 구조는 같습니다 — 바뀌는 건 가운데 상자 하나 브라우저 요청 웹서버 Nginx · Apache · Caddy TLS · 압축 · 라우팅 정적 파일 → 여기서 끝 나머지 → 앱으로 프록시 127.0.0.1:8080 앱 프로세스 언어마다 다른 것은 이 실행 명령뿐 systemd · 컨테이너 죽으면 되살림 정적 파일을 앱이 처리하지 않게 하는 것이 첫 번째 최적화입니다 — 웹서버가 수십 배 빠릅니다. 앱은 127.0.0.1 이나 유닉스 소켓에만 바인딩하세요. 0.0.0.0 이면 웹서버를 건너뛰고 직접 접근됩니다. 프로세스 관리자가 없으면 앱이 한 번 죽고 끝납니다 — 재부팅 후 자동 시작도 여기서 결정됩니다. 이 네 조각(웹서버 · 프록시 · 앱 · 프로세스 관리)만 갖추면 언어는 갈아 끼우기만 하면 됩니다.
"파이썬 배포"·"Node 배포"를 따로 배울 필요가 없는 이유입니다 — 다른 것은 실행 명령 한 줄뿐입니다.

어떤 언어든 쓰는 systemd 템플릿

# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target
# 의존 서비스가 있으면
# Requires=postgresql.service
# 60초 안에 5회 실패하면 포기(무한 재시작 방지) — ★ [Service] 가 아니라 [Unit] 에 둡니다
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=simple
# ★ 전용 계정 — root 로 돌리지 마세요
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp

# ── 이 줄만 언어별로 바뀝니다 ─────────────────────
# Java
ExecStart=/usr/bin/java -jar app.jar
# ExecStart=/opt/myapp/venv/bin/gunicorn -c gunicorn.conf.py app:app   # Python
# ExecStart=/usr/bin/node server.js                # Node
# ExecStart=/opt/myapp/bin/myapp                   # Go · Rust
# ────────────────────────────────────────────────

Environment=NODE_ENV=production
# 비밀은 파일로 (권한 600)
EnvironmentFile=-/etc/myapp/env

# 죽으면 되살림
Restart=always
RestartSec=3

# 그레이스풀 종료 — 진행 중 요청을 마치게
KillSignal=SIGTERM
TimeoutStopSec=30

# 리소스 한계
LimitNOFILE=65535
MemoryMax=2G

# 보안 강화 — 넣어 두면 침해 시 피해가 줄어듭니다
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/data /var/log/myapp

StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
sudo journalctl -u myapp -f           # 로그 실시간
sudo journalctl -u myapp --since "10 min ago" -p err   # 오류만

# 무중단 재시작이 되는지 (앱이 SIGTERM 을 제대로 처리하는지)
while true; do curl -s -o /dev/null -w "%{http_code} " localhost:8080/health; sleep 0.2; done &
sudo systemctl restart myapp
#  → 200 사이에 000·502 가 섞이면 그레이스풀 종료가 안 되고 있는 것입니다
앱을 0.0.0.0 에 바인딩하면 웹서버를 우회할 수 있습니다. 방화벽으로 막았다고 생각해도, 클라우드 보안 그룹 설정 하나가 어긋나면 8080 포트가 인터넷에 그대로 열립니다 — TLS 도 없고 웹서버의 접근 제어도 안 거칩니다. 127.0.0.1:8080 또는 유닉스 소켓에만 바인딩하는 것이 원칙이고, ss -tlnp 로 실제로 그런지 확인하세요.
Restart=always 만 넣어도 운영 안정성이 크게 올라갑니다. 다만 StartLimitBurst 를 함께 넣으세요 — 설정 오류로 즉시 죽는 앱을 무한히 재시작하면 로그가 초당 수백 줄씩 쌓여 디스크를 채웁니다. 5회 실패 후 멈추게 해 두면, 감시 도구가 "죽어 있음"을 정확히 알려 줍니다.
SECTION 02

Java ① WAR 배포

전통적인 방식입니다. 기존 시스템에서 여전히 많이 쓰입니다.

# 빌드
./gradlew clean bootWar        # 또는 mvn clean package

# 배포
sudo systemctl stop tomcat
sudo cp build/libs/myapp.war /opt/tomcat/webapps/ROOT.war
sudo rm -rf /opt/tomcat/webapps/ROOT      # 옛 압축 해제 폴더 제거
sudo systemctl start tomcat

# 로그로 기동 확인
sudo tail -f /opt/tomcat/logs/catalina.out
// Spring Boot 를 WAR 로 만들려면
@SpringBootApplication
public class MyApplication extends SpringBootServletInitializer {

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder b) {
        return b.sources(MyApplication.class);
    }

    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}
# build.gradle
plugins { id 'war' }

dependencies {
    // 내장 톰캣은 provided 로 (외부 톰캣이 제공하므로)
    providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
}
Tomcat 10 이상으로 옮길 때 주의 — 패키지가 javax.* → jakarta.* 로 바뀌었습니다. Spring Boot 2.x 로 만든 WAR 는 Tomcat 10+ 에서 동작하지 않습니다. Spring Boot 3 이상으로 올려야 합니다.
새로 만든다면 WAR 가 아니라 jar 를 쓰세요. 톰캣을 따로 설치·관리할 필요가 없고, 컨테이너 이미지로 만들기도 쉽습니다 → 다음 섹션. WAR 를 유지하는 이유는 기존 WAS 인프라·조직 정책 정도입니다.

WAR vs 실행 가능 jar — 실제 차이

항목WAR (외부 톰캣)실행 jar (내장)
배포 단위WAR 파일 + 톰캣 설치jar 하나
버전 관리앱과 톰캣을 따로앱에 포함 — 함께 올라갑니다
로컬 실행톰캣 설치 필요java -jar 한 줄
컨테이너화번거로움기본 이미지 + jar 복사
한 서버에 여러 앱webapps 에 여러 개포트를 나눠 여러 프로세스
무중단 배포톰캣 재시작 필요(또는 병렬 배포)인스턴스 단위 롤링
설정 위치server.xml·context.xmlapplication.yml
메모리여러 앱이 JVM 하나를 공유앱마다 JVM — 더 씁니다
# 실행 jar 방식 — 이 정도면 배포 전체입니다
./gradlew clean bootJar
scp build/libs/app.jar deploy@server:/opt/myapp/app.jar.new

ssh deploy@server '
  sudo mv /opt/myapp/app.jar /opt/myapp/app.jar.prev   # 롤백용으로 남기기
  sudo mv /opt/myapp/app.jar.new /opt/myapp/app.jar
  sudo systemctl restart myapp
  sleep 5
  curl -fsS localhost:8080/actuator/health || {
      echo "기동 실패 — 되돌립니다"
      sudo mv /opt/myapp/app.jar.prev /opt/myapp/app.jar
      sudo systemctl restart myapp
      exit 1
  }
'
# JVM 옵션 — 컨테이너·systemd 어느 쪽이든 이 정도는 챙기세요
#   MaxRAMPercentage         ★ -Xmx 대신 비율로 (컨테이너 메모리 인식)
#   HeapDumpOnOutOfMemoryError ★ OOM 시 덤프 — 원인 추적의 유일한 단서
#   ExitOnOutOfMemoryError   OOM 이면 죽고 재시작 (반쯤 살아있는 상태 방지)
#   (systemd 는 줄 끝 주석을 지원하지 않아 설명을 위로 모았습니다)
Environment=JAVA_OPTS=" \
  -XX:MaxRAMPercentage=75.0 \
  -XX:+UseG1GC \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/myapp/ \
  -XX:+ExitOnOutOfMemoryError \
  -Djava.security.egd=file:/dev/./urandom \
  -Duser.timezone=Asia/Seoul \
  -Dfile.encoding=UTF-8"
컨테이너에서 -Xmx 를 고정값으로 주지 마세요. 메모리 한도를 바꿔도 JVM 이 옛 값을 그대로 써서, 한도보다 힙이 커지면 컨테이너가 통째로 강제 종료(OOMKilled)됩니다. -XX:MaxRAMPercentage 를 쓰면 JVM 이 cgroup 한도를 읽어 알아서 맞춥니다. "잘 돌다가 갑자기 재시작되는데 로그에 아무것도 없다"면 대부분 이것입니다 — dmesg | grep -i kill 이나 파드 이벤트에서 확인할 수 있습니다.
WAR 를 유지해야 한다면 병렬 배포(parallel deployment)를 알아 두세요. ROOT##002.war 처럼 ##버전 을 붙여 넣으면 톰캣이 기존 세션은 옛 버전에서 마치게 하고 새 요청만 새 버전으로 보냅니다. 재시작 없이 전환되므로, 외부 톰캣 환경에서 가장 현실적인 무중단 수단입니다.
SECTION 03

Java ② Spring Boot + Nginx

요즘 자바 배포의 표준입니다. jar 하나 + systemd + Nginx.

# 1) 빌드
./gradlew clean bootJar
# → build/libs/myapp-1.0.0.jar

# 2) 서버로 전송
scp build/libs/myapp-1.0.0.jar deploy@server:/opt/myapp/myapp.jar

# 3) 전용 계정 (한 번만)
sudo useradd -r -s /bin/false appuser
sudo mkdir -p /opt/myapp /var/log/myapp
sudo chown -R appuser:appuser /opt/myapp /var/log/myapp
# /etc/systemd/system/myapp.service
[Unit]
Description=My Spring Boot Application
After=network.target

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp

# 비밀은 파일로 (권한 600) — 명령줄 인자에 넣으면 ps 로 다 보입니다
EnvironmentFile=/etc/myapp/env
# -Xmx 와 MaxRAMPercentage 를 같이 주면 -Xmx 가 이깁니다 — 둘 중 하나만 (컨테이너면 비율)
Environment="JAVA_OPTS=-Xms512m -Xmx1g -XX:+UseG1GC"

ExecStart=/usr/bin/java $JAVA_OPTS -jar /opt/myapp/myapp.jar

# SIGTERM 종료를 정상으로 취급
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
LimitNOFILE=65535

# 로그는 journald 로
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target
# /etc/myapp/env  (chmod 600)
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=8080
SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/shop
SPRING_DATASOURCE_USERNAME=app
SPRING_DATASOURCE_PASSWORD=...
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
sudo journalctl -u myapp -f
# Nginx
upstream myapp { server 127.0.0.1:8080; keepalive 32; }

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    client_max_body_size 20m;

    location / {
        proxy_pass http://myapp;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
반드시 확인할 세 가지
① server.address=127.0.0.1 — 앱이 외부에 직접 노출되지 않게
② server.forward-headers-strategy=native — 프록시 인식
③ server.shutdown=graceful — 배포 시 요청 유실 방지
SECTION 04

Python ① WSGI vs ASGI

파이썬 웹 배포에서 가장 먼저 이해해야 할 개념입니다. 내 프레임워크가 어느 쪽인지에 따라 실행 방법이 달라집니다.

WSGI — 동기
# 요청 하나를 워커 하나가 끝까지 처리
def application(environ, start_response):
    start_response('200 OK', [('Content-Type','text/plain')])
    return [b'Hello']

# 프레임워크 : Django · Flask
# 서버      : Gunicorn · uWSGI
# 특징      : 단순 · 안정 · 라이브러리 호환성 최고
ASGI — 비동기
async def app(scope, receive, send):
    await send({'type':'http.response.start','status':200})
    await send({'type':'http.response.body','body':b'Hello'})

# 프레임워크 : FastAPI · Starlette · Django(비동기 뷰)
# 서버      : Uvicorn · Hypercorn · Granian
# 특징      : WebSocket 지원 · IO 대기 중 다른 요청 처리
상황선택
Django 웹 애플리케이션WSGI + Gunicorn (비동기 뷰를 쓴다면 ASGI 도 가능)
Flask APIWSGI + Gunicorn
FastAPIASGI + Uvicorn
WebSocket 이 필요ASGI 필수
외부 API 호출이 많은 서비스ASGI 가 유리 (IO 대기 중 다른 요청 처리)
CPU 연산이 많은 서비스둘 다 GIL 제약 — 프로세스를 늘리는 것이 핵심
ASGI 를 쓴다고 자동으로 빨라지지 않습니다. 비동기 프레임워크에서 동기 라이브러리를 그대로 호출하면 이벤트 루프가 통째로 멈춰 오히려 더 느려집니다. (예: FastAPI 에서 requests · 동기 DB 드라이버 사용)
비동기 전용 라이브러리(httpx·asyncpg)를 쓰거나 run_in_executor 로 분리해야 합니다. 자세한 내용은 🐍 파이썬 가이드의 대규모 트래픽 탭에 있습니다.

워커 수를 정하는 법 — 가장 자주 틀리는 설정

# gunicorn.conf.py — WSGI (Django · Flask)
import multiprocessing

bind = "unix:/run/myapp/gunicorn.sock"   # 유닉스 소켓이 TCP 보다 조금 빠릅니다
workers = multiprocessing.cpu_count() * 2 + 1     # ★ CPU 바운드 기준 출발점
worker_class = "gthread"                 # IO 대기가 많으면 스레드를 함께
threads = 4                              # 워커당 스레드

timeout = 30                             # 이 시간을 넘으면 워커를 죽이고 재시작
graceful_timeout = 30                    # 종료 신호 후 마무리 시간
keepalive = 65                           # ★ Nginx 보다 길게 (502 방지)

max_requests = 1000                      # ★ 메모리 누수 대비 — 주기적으로 워커 교체
max_requests_jitter = 100                #   동시에 교체되지 않게 흔들기

preload_app = True                       # 메모리 절약(fork 전에 로드)
accesslog = "-"
errorlog  = "-"
# ASGI (FastAPI) — uvicorn 워커를 gunicorn 이 관리하는 구성이 안정적입니다
gunicorn app:app \
  -k uvicorn.workers.UvicornWorker \
  -w 4 \
  -b unix:/run/myapp/uvicorn.sock \
  --timeout 30 --graceful-timeout 30 --keep-alive 65

# 또는 uvicorn 단독 (컨테이너에서는 이쪽이 단순합니다)
uvicorn app:app --host 127.0.0.1 --port 8000 --workers 4 --timeout-keep-alive 65
서비스 성격워커 설정
CPU 연산 중심workers = 코어 수 — 더 늘려도 GIL 때문에 이득이 없습니다
DB·API 대기 중심 (WSGI)workers = 코어×2+1 + gthread·threads 4~8
DB·API 대기 중심 (ASGI)workers = 코어 수 — 대기는 이벤트 루프가 흡수합니다
메모리를 많이 쓰는 앱워커 수 × 워커당 메모리가 서버 메모리를 넘지 않게
컨테이너(코어 제한)cpu_count() 가 호스트 코어를 반환할 수 있습니다 — 값을 직접 지정
워커를 늘릴수록 좋다는 생각이 가장 흔한 실수입니다. 워커 하나가 앱과 라이브러리를 통째로 메모리에 올립니다(Django 는 보통 100~300MB). 2GB 서버에 워커 16개를 띄우면 메모리가 터지거나 스왑으로 빠져 오히려 훨씬 느려집니다. 그리고 워커 수만큼 DB 커넥션이 늘어나 DB 쪽이 먼저 무너지는 경우도 많습니다 — 워커 수 × 커넥션 풀 크기가 DB 의 최대 접속 수를 넘지 않는지 반드시 확인하세요.
max_requests 는 파이썬 배포에서 특히 유용합니다. 라이브러리의 미묘한 누수로 메모리가 며칠에 걸쳐 서서히 오르는 일이 흔한데, 워커를 1000요청마다 교체하면 원인을 못 찾아도 운영은 안정됩니다. 근본 해결은 아니지만, 새벽에 OOM 으로 깨는 것보다는 훨씬 낫습니다. jitter 를 함께 주어 워커가 동시에 교체되지 않게 하는 것을 잊지 마세요.
WSGI 와 ASGI — 앱과 서버가 대화하는 규약 WSGI — 동기 규약 요청 하나 = 함수 한 번 호출. 처리하는 동안 워커 하나를 붙잡습니다 동시 처리 수는 워커 수가 정합니다 — Gunicorn 워커 수를 늘려 대응합니다 웹소켓처럼 오래 유지되는 연결은 다룰 수 없습니다 ASGI — 비동기 전제 기다리는 동안 다른 요청을 처리합니다 — 외부 호출이 많은 서비스에서 유리합니다 웹소켓 · SSE 같은 오래 유지되는 연결을 다룰 수 있습니다 Uvicorn 이 대표적인 실행기이며 FastAPI 가 이 위에 있습니다 Django 도 ASGI 로 돌 수 있습니다 — 다만 안에 동기 코드가 섞이면 이득이 크지 않습니다 async 함수 안에서 동기 라이브러리를 부르면 이벤트 루프가 막힙니다 — 가장 흔한 사고입니다 어느 쪽이든 앞에 Nginx 를 두는 구성은 같습니다 — 정적 파일과 HTTPS 는 거기서 처리합니다
고르는 기준은 유행이 아니라 오래 유지되는 연결이 필요한가입니다.
SECTION 05

Python ② Gunicorn 설정

워커 수와 워커 종류만 제대로 정하면 됩니다.

# 설치
pip install gunicorn

# 기본 실행
gunicorn myapp.wsgi:application --bind 127.0.0.1:8000 --workers 4

# 유닉스 소켓 (같은 서버의 Nginx 와 연결할 때 권장)
gunicorn myapp.wsgi:application --bind unix:/run/myapp/gunicorn.sock --workers 4
# gunicorn.conf.py — 설정을 파일로 관리하는 편이 낫습니다
import multiprocessing

bind = "unix:/run/myapp/gunicorn.sock"
# bind = "127.0.0.1:8000"

# ★ 워커 수 : (CPU 코어 × 2) + 1 이 출발점입니다
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"              # sync · gthread · uvicorn.workers.UvicornWorker
threads = 1                        # gthread 일 때만 의미가 있습니다

timeout = 30                       # 이 시간을 넘으면 워커를 죽이고 재시작
graceful_timeout = 30
keepalive = 5

# 메모리 누수 대비 — 일정 요청 수마다 워커를 재시작
max_requests = 1000
max_requests_jitter = 100          # 동시에 재시작하지 않게 분산

accesslog = "-"                    # 표준 출력 (systemd·컨테이너 로그로)
errorlog  = "-"
loglevel  = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(L)s'

preload_app = True                 # 메모리 절약 (fork 전에 앱 로드)
# ※ preload 를 켜면 DB 커넥션을 fork 전에 만들면 안 됩니다
worker_class특징
sync기본값. 프로세스가 요청 하나씩 처리 — 가장 안정적
gthread프로세스 안에서 스레드로 — IO 대기가 많을 때 유리
uvicorn.workers.UvicornWorkerASGI 앱을 Gunicorn 으로 관리 (FastAPI)
gevent · eventlet코루틴 기반. 몽키패치가 필요해 예상 밖 문제가 생길 수 있습니다
# /etc/systemd/system/myapp.service
[Unit]
Description=Gunicorn for myapp
After=network.target

[Service]
Type=notify
User=appuser
# ★ Nginx 가 소켓을 읽을 수 있게
Group=www-data
WorkingDirectory=/opt/myapp
Environment="PATH=/opt/myapp/venv/bin"
EnvironmentFile=/etc/myapp/env

# /run/myapp 을 자동 생성·정리
RuntimeDirectory=myapp
RuntimeDirectoryMode=0755
# 소켓 권한
UMask=0007

ExecStart=/opt/myapp/venv/bin/gunicorn -c /opt/myapp/gunicorn.conf.py myapp.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID

Restart=on-failure
RestartSec=5
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
워커 수를 늘려도 안 빨라진다면
· DB 커넥션이 병목일 수 있습니다 — 워커 수 × 커넥션 수가 DB 한계를 넘지 않는지 확인
· 메모리가 부족할 수 있습니다 — 워커마다 앱 전체를 메모리에 올립니다
· CPU 코어를 넘어서면 컨텍스트 스위칭만 늘어납니다
· timeout 이 자주 걸리면 워커가 계속 재시작되며 성능이 무너집니다
SECTION 06

Python ③ Uvicorn · FastAPI

비동기 앱은 Uvicorn 으로 띄웁니다.

pip install "uvicorn[standard]" fastapi

# 개발
uvicorn main:app --reload --host 127.0.0.1 --port 8000

# 운영 — 워커를 여러 개
uvicorn main:app \
  --host 127.0.0.1 --port 8000 \
  --workers 4 \
  --proxy-headers \
  --forwarded-allow-ips='127.0.0.1' \
  --access-log \
  --timeout-keep-alive 5
--proxy-headers 와 --forwarded-allow-ips 를 빠뜨리지 마세요. 없으면 앱이 클라이언트 IP 를 프록시 IP 로 인식하고, HTTPS 인데 request.url 이 http:// 로 나옵니다.
--forwarded-allow-ips 는 신뢰할 프록시 IP 를 지정합니다 — '*' 로 두면 누구나 헤더를 위조할 수 있으니 위험합니다.

Gunicorn + Uvicorn 조합

# Gunicorn 이 프로세스 관리를, Uvicorn 이 ASGI 처리를 담당합니다
gunicorn main:app \
  -k uvicorn.workers.UvicornWorker \
  -w 4 \
  -b 127.0.0.1:8000 \
  --timeout 60 \
  --max-requests 1000 --max-requests-jitter 100
어느 쪽을 쓸까 —
· Uvicorn 단독 --workers — 단순하고 충분합니다
· Gunicorn + UvicornWorker — max_requests(메모리 누수 대비)· 더 세밀한 프로세스 관리가 필요할 때
· 컨테이너 환경 — 워커 1개씩 여러 컨테이너로 띄우고 오케스트레이터가 관리하는 방식도 흔합니다
# /etc/systemd/system/fastapi.service
[Unit]
Description=FastAPI (Uvicorn)
After=network.target

[Service]
User=appuser
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/opt/myapp/venv/bin/uvicorn main:app \
          --host 127.0.0.1 --port 8000 --workers 4 \
          --proxy-headers --forwarded-allow-ips='127.0.0.1'
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
# Nginx — WebSocket 도 함께 쓰는 경우
location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;   # map 필요 (n17)
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

# SSE·스트리밍 응답이 있다면
location /stream {
    proxy_pass http://127.0.0.1:8000;
    proxy_buffering off;
    proxy_read_timeout 3600s;
}
SECTION 07

Python ④ Django 배포 전 과정

Django 배포에서 가장 많이 막히는 것은 정적 파일(CSS 가 안 먹는 문제)입니다.

# settings.py — 운영 설정
import os

DEBUG = False                                    # ★ 반드시 False
ALLOWED_HOSTS = ['example.com', 'www.example.com']
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']     # 코드에 넣지 마세요

# 프록시 뒤에 있을 때
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True

# 보안
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ['https://example.com']
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True

# ★ 정적 파일
STATIC_URL = '/static/'
STATIC_ROOT = '/var/www/myapp/static'            # collectstatic 이 모을 곳
MEDIA_URL = '/media/'
MEDIA_ROOT = '/var/www/myapp/media'              # 사용자 업로드
# 배포 절차
cd /opt/myapp
source venv/bin/activate

pip install -r requirements.txt

python manage.py check --deploy          # ★ 운영 설정 점검 (경고를 꼭 읽으세요)
python manage.py migrate                 # DB 마이그레이션
python manage.py collectstatic --noinput # ★ 정적 파일을 STATIC_ROOT 로 모읍니다

sudo systemctl restart myapp
DEBUG = True 로 운영하면 안 되는 이유
· 오류 페이지에 소스 코드·설정값·환경 변수가 그대로 노출됩니다
· SQL 쿼리가 메모리에 계속 쌓여 메모리 누수가 발생합니다
· ALLOWED_HOSTS 검사가 느슨해집니다
실제 유출 사고로 이어진 사례가 많습니다.
# Nginx — 정적·미디어를 직접 서빙
server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    client_max_body_size 20m;

    # ★ 정적 파일 — Django 를 거치지 않습니다
    location /static/ {
        alias /var/www/myapp/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location /media/ {
        alias /var/www/myapp/media/;
        expires 30d;
        add_header Content-Disposition "attachment";   # 업로드 파일 실행 방지
    }

    location / {
        proxy_pass http://unix:/run/myapp/gunicorn.sock;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
"CSS 가 안 먹어요" 체크리스트
① collectstatic 을 실행했나
② STATIC_ROOT 와 Nginx alias 경로가 일치하나
③ Nginx 실행 계정이 그 폴더를 읽을 수 있나
④ 브라우저 개발자 도구 Network 탭에서 404 인지 403 인지 확인
⑤ STATIC_URL 과 location 경로가 맞나
SECTION 08

Node ① PM2 프로세스 관리

Node 앱은 죽으면 아무도 안 살려 줍니다. PM2 가 그 일을 해 줍니다.

npm install -g pm2

# 실행
pm2 start app.js --name myapp

# ★ 클러스터 모드 — CPU 코어 수만큼 프로세스를 띄웁니다
pm2 start app.js --name myapp -i max

# 관리
pm2 list
pm2 logs myapp
pm2 monit                      # 실시간 대시보드
pm2 restart myapp
pm2 reload myapp               # ★ 무중단 재시작 (클러스터 모드에서)
pm2 stop myapp
pm2 delete myapp

# 서버 재부팅 후에도 자동 시작
pm2 startup                    # 출력되는 명령을 그대로 실행
pm2 save
// ecosystem.config.js — 설정을 파일로
module.exports = {
  apps: [{
    name: 'myapp',
    script: './dist/main.js',
    instances: 'max',              // CPU 코어 수만큼
    exec_mode: 'cluster',          // ★ 클러스터 모드 (무중단 reload 가능)

    env: { NODE_ENV: 'development' },
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },

    max_memory_restart: '500M',    // 메모리가 넘으면 재시작 (누수 대비)
    autorestart: true,
    max_restarts: 10,
    min_uptime: '10s',             // 이 시간 안에 죽으면 실패로 간주

    error_file:  '/var/log/myapp/error.log',
    out_file:    '/var/log/myapp/out.log',
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
    merge_logs: true,

    kill_timeout: 10000,           // 종료 유예 (진행 중 요청 처리)
    listen_timeout: 5000,
    wait_ready: true,              // process.send('ready') 를 기다림
  }]
}

// pm2 start ecosystem.config.js --env production
클러스터 모드의 이점 — Node 는 기본적으로 CPU 코어 하나만 씁니다. 클러스터 모드는 프로세스를 여러 개 띄워 코어를 전부 활용하고, pm2 reload 로 하나씩 교체해 무중단 배포가 됩니다.
다만 앱이 무상태여야 합니다 — 메모리에 세션·캐시를 두면 프로세스마다 달라집니다.

systemd 로 관리해도 됩니다

# PM2 없이 systemd 만으로
[Unit]
Description=Node App
After=network.target

[Service]
User=appuser
WorkingDirectory=/opt/myapp
Environment="NODE_ENV=production" "PORT=3000"
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node /opt/myapp/dist/main.js
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
PM2 vs systemd vs 컨테이너
· PM2 — 클러스터 모드·모니터링이 편리. Node 전용
· systemd — 다른 언어와 관리 방식이 통일됩니다. 클러스터는 앱에서 직접 구현
· 컨테이너 — 요즘 표준. 오케스트레이터가 재시작·확장을 담당하므로 PM2 가 불필요합니다
PM2 — 프로세스를 여러 개 띄우고 지켜봅니다 해 주는 일 네 가지 코어 수만큼 프로세스를 띄워 요청을 나눠 받습니다 (Node 하나는 코어 하나만 씁니다) 죽으면 다시 살립니다 · 서버가 재부팅돼도 자동으로 시작합니다 새 버전 배포 시 프로세스를 하나씩 교체해 끊김 없이 넘어갑니다 앞에 Nginx 를 두는 이유는 그대로입니다 HTTPS 종료 · 정적 파일 · 압축 · 요청 제한은 Nginx 가 처리합니다 PM2 는 프로세스 관리만 맡습니다 — 역할이 겹치지 않습니다 컨테이너 환경이라면 겹쳐 쓰지 마세요 — 오케스트레이터가 같은 일을 합니다 메모리 상한을 걸어 두면 누수가 있어도 자동으로 재시작해 버팁니다 — 임시 방편이지만 유용합니다 로그는 파일로 쌓입니다 — 로테이션을 반드시 설정하세요. 디스크가 차면 서버가 멈춥니다
Node 배포의 첫 질문 — 지금 코어를 몇 개 쓰고 있는가.
SECTION 09

Node ② Nginx 앞단 구성

Node 앞에 Nginx 를 두면 HTTPS·정적파일·압축을 전부 위임할 수 있습니다.

upstream node_app {
    server 127.0.0.1:3000;
    keepalive 64;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    gzip on;
    gzip_types text/plain text/css application/json application/javascript;

    client_max_body_size 20m;

    # Next.js 등의 빌드 산출물
    location ^~ /_next/static/ {
        alias /opt/myapp/.next/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location ^~ /public/ {
        alias /opt/myapp/public/;
        expires 30d;
    }

    location / {
        proxy_pass http://node_app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;      # WebSocket 대비
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }
}
// Express — 프록시 뒤에 있을 때 필수 설정
const express = require('express');
const app = express();

app.set('trust proxy', 1);          // ★ 앞단 프록시 1개를 신뢰
// req.ip · req.protocol · req.secure 가 올바르게 동작합니다

app.get('/health', (req, res) => res.json({ status: 'ok' }));

// 앱은 로컬에만 바인드
app.listen(3000, '127.0.0.1', () => console.log('listening'));
trust proxy 를 true 로 두지 마세요. 모든 프록시를 신뢰한다는 뜻이라 클라이언트가 IP 를 위조할 수 있습니다. 앞단 프록시 개수(1) 또는 신뢰할 IP 대역을 명시하세요. rate limit·어뷰징 차단이 이 값에 의존합니다.

우아한 종료

const server = app.listen(3000, '127.0.0.1');

function shutdown(signal) {
  console.log(`${signal} 수신 — 종료를 시작합니다`);
  server.close(() => {                    // 새 연결을 받지 않고
    console.log('열린 연결 정리 완료');    // 진행 중 요청을 마친 뒤
    // DB 커넥션 풀 등 정리
    process.exit(0);
  });
  // 강제 종료 안전핀
  setTimeout(() => process.exit(1), 15000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT',  () => shutdown('SIGINT'));
SECTION 10

PHP — PHP-FPM

PHP 는 웹서버가 직접 실행하지 않고 별도 프로세스(FPM)에 넘깁니다.

sudo apt install -y php8.3-fpm php8.3-mysql php8.3-mbstring php8.3-xml php8.3-curl
sudo systemctl enable --now php8.3-fpm

# 소켓 위치 확인
ls -l /run/php/php8.3-fpm.sock
# Nginx + PHP-FPM
server {
    listen 443 ssl;
    server_name example.com;
    root /var/www/example/public;
    index index.php index.html;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    client_max_body_size 20m;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_read_timeout 60s;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 8 16k;
    }

    # ★ 업로드 폴더에서 PHP 실행 금지 (매우 중요)
    location ^~ /uploads/ {
        location ~ \.php$ { deny all; }
    }

    # 숨김 파일 차단
    location ~ /\. { deny all; }
}
업로드 폴더의 PHP 실행 차단은 필수입니다. 공격자가 shell.php 를 업로드하고 그것을 실행할 수 있으면 서버가 통째로 넘어갑니다. 워드프레스 침해의 전형적인 경로입니다.
또 location ~ \.php$ 는 image.php.jpg 같은 이중 확장자도 조심해야 합니다 — try_files $uri =404; 를 함께 쓰세요.
; /etc/php/8.3/fpm/pool.d/www.conf — 프로세스 풀  (주석은 ; — PHP 7 부터 # 주석 불가)
[www]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic                    ; dynamic · static · ondemand
pm.max_children = 50            ; ★ 최대 동시 처리 (메모리로 산정)
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500           ; 메모리 누수 대비

; 느린 요청 로깅
slowlog = /var/log/php8.3-fpm-slow.log
request_slowlog_timeout = 5s
request_terminate_timeout = 60s

; 상태 페이지 (내부에서만 접근하게 Nginx 에서 제한)
pm.status_path = /fpm-status
pm.max_children 산정 — (가용 메모리 × 0.8) ÷ PHP 프로세스 하나의 평균 메모리
프로세스 메모리는 ps --sort -rss -eo rss,comm | grep php-fpm 로 확인합니다.
너무 크게 잡으면 스왑이 발생해 서버 전체가 느려집니다.
; php.ini 운영 설정
display_errors = Off              ; ★ 오류를 화면에 표시하지 않음
log_errors = On
error_log = /var/log/php_errors.log
expose_php = Off                  ; X-Powered-By 헤더 제거

memory_limit = 256M
max_execution_time = 30
upload_max_filesize = 20M
post_max_size = 25M               ; upload 보다 크게

session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax

opcache.enable = 1                ; ★ 성능에 큰 영향
opcache.memory_consumption = 128
opcache.validate_timestamps = 0   ; 운영에서는 0 (배포 시 FPM 재시작 필요)
PHP-FPM — 웹서버와 PHP 를 분리합니다 브라우저 /index.php 웹서버 정적은 직접 · PHP 는 넘김 PHP-FPM FastCGI 로 대화 · 워커 풀로 처리 나눠서 얻는 것 웹서버는 가벼운 이벤트 방식을 그대로 씁니다 — 아파치도 event MPM 을 쓸 수 있습니다 PHP 워커 수와 메모리를 따로 조절합니다 — 웹서버 설정과 독립적입니다 워커 수 × PHP 메모리 상한이 서버 메모리를 넘지 않는지 계산하세요 넘으면 스왑이 시작되고 전체가 느려집니다 — 워커가 모자라면 요청이 대기열에 쌓입니다 유닉스 소켓으로 연결하면 같은 서버 안에서는 TCP 보다 조금 빠릅니다 — 권한 설정에 주의하세요 opcache 를 켜세요 — 매 요청마다 PHP 를 다시 해석하지 않아 체감 차이가 큽니다
"PHP 는 느리다" 는 인식의 상당 부분은 모듈 방식 시절의 이야기입니다.
SECTION 11

React · Vue 정적 배포

빌드하면 그냥 HTML·JS 파일 덩어리입니다. 앱 서버가 필요 없습니다.

# 빌드
npm run build
# → dist/ 또는 build/ 폴더에 정적 파일이 생성됩니다

# 서버로 전송
rsync -avz --delete dist/ deploy@server:/var/www/spa/
server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    root /var/www/spa;
    index index.html;

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;

    # ★ 해시가 붙은 빌드 산출물 — 영구 캐시
    #   정규식에 { } 가 있으면 따옴표로 감싸야 합니다 (안 그러면 nginx -t 실패)
    location ~* "\.[0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|svg|webp)$" {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # ★ index.html 은 캐시 금지 (새 배포가 즉시 반영되게)
    location = /index.html {
        add_header Cache-Control "no-store, must-revalidate";
    }

    # ★ SPA 라우팅 — 없는 경로는 index.html 로
    location / {
        try_files $uri $uri/ /index.html;
    }

    # API 는 백엔드로
    location /api/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
배포했는데 옛 화면이 보이는 이유 — index.html 이 캐시되면 새 JS 파일을 가리키는 링크 자체가 갱신되지 않습니다.
· 해시 붙은 파일(app.a1b2c3d4.js) → 영구 캐시 (내용이 바뀌면 이름이 바뀌므로 안전)
· index.html → 캐시 금지
이 조합이 프런트엔드 배포의 표준입니다.

Next.js 처럼 서버가 필요한 경우

# 완전 정적으로 내보낼 수 있으면 (SSG)
#   next.config 에 output: 'export' 를 두고 빌드 (next export 명령은 Next 14 에서 제거됨)
next build                     # → out/ 폴더를 그대로 서빙

# SSR·API 라우트를 쓴다면 Node 프로세스가 필요합니다
pm2 start npm --name web -- start
# → Nginx 가 3000 포트로 프록시 (l09 참고)
더 나은 선택 — CDN·정적 호스팅
순수 정적 SPA 라면 Cloudflare Pages · Netlify · Vercel · S3+CloudFront 같은 정적 호스팅이 더 빠르고 저렴합니다. 서버를 운영할 필요 자체가 없어집니다.
API 만 별도 서버에 두고 CORS 를 설정하는 구성이 요즘 흔합니다.
"새로고침하면 404" — SPA 배포의 단골 문제 ① 앱 안에서 이동할 때 — 서버에 묻지 않습니다 / 에서 시작 index.html 로드됨 /orders/7 로 이동 주소만 바뀜 · 요청 없음 라우터가 화면만 바꿉니다 ② 그 상태에서 새로고침하면 — 서버가 파일을 찾습니다 GET /orders/7 이번엔 진짜 요청 /orders/7 파일이 없음 404 그런 파일은 애초에 없습니다 Nginx 해결 — 한 줄 try_files $uri $uri/ /index.html; 함께 챙길 것 해시가 붙은 정적 파일은 캐시 1년 · index.html 은 no-cache
서버가 "모르는 경로는 전부 index.html" 이라고 답하면 라우터가 알아서 처리합니다.
"새로고침하면 404" — SPA 배포의 단골 문제 ① 앱 안에서 이동할 때 — 서버에 묻지 않습니다 / 에서 시작 index.html 로드됨 /orders/7 로 이동 주소만 바뀜 · 요청 없음 라우터가 화면만 바꿉니다 ② 그 상태에서 새로고침하면 — 서버가 파일을 찾습니다 GET /orders/7 이번엔 진짜 요청 /orders/7 파일이 없음 404 그런 파일은 애초에 없습니다 Nginx 해결 — 한 줄 try_files $uri $uri/ /index.html; 함께 챙길 것 해시가 붙은 정적 파일은 캐시 1년 · index.html 은 no-cache
서버가 "모르는 경로는 전부 index.html" 이라고 답하면 라우터가 알아서 처리합니다.
SECTION 12

systemd 서비스 등록

터미널을 닫아도 앱이 계속 돌게 하고, 서버가 재부팅돼도 자동으로 뜨게 만듭니다.

# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
Documentation=https://github.com/me/myapp
After=network-online.target
Wants=network-online.target
# DB 가 같은 서버에 있다면
# After=postgresql.service
# 60초에 5번 실패하면 포기 (무한 재시작 방지) — ★ [Unit] 에 둬야 적용됩니다
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
# simple · forking · notify · oneshot
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp

Environment="NODE_ENV=production"
# 비밀은 여기에 (권한 600)
EnvironmentFile=/etc/myapp/env

ExecStartPre=/usr/bin/test -f /opt/myapp/app.js
ExecStart=/usr/bin/node /opt/myapp/app.js
# ※ 앱이 SIGHUP 을 직접 처리하도록 짰을 때만 — Node 는 기본적으로 SIGHUP 을 받으면 종료합니다
ExecReload=/bin/kill -HUP $MAINPID

Restart=on-failure
RestartSec=5

# 종료 유예
TimeoutStopSec=30
KillSignal=SIGTERM

# 파일 디스크립터 한계
LimitNOFILE=65535

# 보안 강화 (선택이지만 권장)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/myapp /var/data/myapp

StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload        # ★ 유닛 파일을 고칠 때마다
sudo systemctl enable --now myapp
sudo systemctl status myapp

sudo journalctl -u myapp -f          # 실시간 로그
sudo journalctl -u myapp --since "1 hour ago"
sudo journalctl -u myapp -p err      # 오류만
sudo journalctl -u myapp --no-pager -n 100

sudo systemctl restart myapp
sudo systemctl reload myapp          # ExecReload 가 정의돼 있어야 동작
Type언제
simpleExecStart 가 포그라운드로 계속 실행될 때 — 대부분 이것
forking프로세스가 백그라운드로 빠질 때 (톰캣 startup.sh)
notify앱이 준비 완료를 알려 줄 때 (Gunicorn)
oneshot한 번 실행하고 끝나는 작업
흔한 실수
· daemon-reload 를 안 함 — 유닛 파일을 고쳤는데 반영이 안 됩니다
· 상대 경로 사용 — ExecStart 는 절대 경로여야 합니다
· 환경 변수 부재 — 로그인 셸의 PATH·환경이 없습니다. 명시적으로 지정하세요
· 가상환경 미지정 — 파이썬은 venv/bin/ 안의 실행 파일을 절대 경로로
· 비밀번호를 ExecStart 인자로 — ps 로 누구나 볼 수 있습니다
유닛 파일 한 장이 세 가지를 대신합니다 [Unit] After=network.target [Service] User=appuser WorkingDirectory=/opt/app ExecStart=/usr/bin/java -jar app.jar Restart=always [Install] WantedBy=multi-user.target ① 부팅하면 자동 시작 enable 한 번이면 재부팅 뒤에도 뜹니다 ② 죽으면 자동 재시작 Restart=always · RestartSec=3 — 감시 스크립트가 필요 없습니다 ③ 로그가 저널로 모입니다 journalctl -u app -f — 표준 출력만 찍어도 됩니다 등록 절차 — /etc/systemd/system/app.service 작성 → daemon-reload → enable --now app 전용 계정(User=)으로 돌리세요 — root 로 띄우면 앱이 뚫렸을 때 서버 전체가 넘어갑니다. 1024 미만 포트는 AmbientCapabilities 로
nohup 과 & 로 띄우던 습관을 유닛 파일 한 장으로 바꾸면 운영이 조용해집니다.
유닛 파일 한 장이 세 가지를 대신합니다 [Unit] After=network.target [Service] User=appuser WorkingDirectory=/opt/app ExecStart=/usr/bin/java -jar app.jar Restart=always [Install] WantedBy=multi-user.target ① 부팅하면 자동 시작 enable 한 번이면 재부팅 뒤에도 뜹니다 ② 죽으면 자동 재시작 Restart=always · RestartSec=3 — 감시 스크립트가 필요 없습니다 ③ 로그가 저널로 모입니다 journalctl -u app -f — 표준 출력만 찍어도 됩니다 등록 절차 — /etc/systemd/system/app.service 작성 → daemon-reload → enable --now app 전용 계정(User=)으로 돌리세요. root 로 띄우면 앱이 뚫렸을 때 서버 전체가 넘어갑니다 — 1024 미만 포트가 필요하면 AmbientCapabilities 로 권한만 줍니다
nohup 과 & 로 띄우던 습관을 유닛 파일 한 장으로 바꾸면 운영이 조용해집니다.
SECTION 13

소켓 vs 포트 · 권한

같은 서버 안이라면 유닉스 소켓이 조금 더 빠르고 안전합니다.

TCP 포트
proxy_pass http://127.0.0.1:8000;

장점
· 설정이 단순
· 다른 서버로 옮기기 쉬움
· 디버깅이 쉬움 (curl 로 직접 호출)

단점
· TCP 스택을 거침 (약간의 오버헤드)
· 0.0.0.0 에 바인드하면 외부 노출 위험
유닉스 소켓
proxy_pass http://unix:/run/app/app.sock;

장점
· TCP 스택을 건너뜀 (약간 빠름)
· 네트워크로 노출될 수 없음 (구조적 안전)
· 파일 권한으로 접근 통제

단점
· 같은 서버 안에서만
· 권한 설정이 필요 (자주 막힙니다)
# 권한 문제 진단
ls -l /run/myapp/gunicorn.sock
# srw-rw---- 1 appuser www-data 0 Aug  5 14:00 gunicorn.sock
#             └ 소유자    └ 그룹 (Nginx 계정이 여기 있어야 합니다)

# Nginx 실행 계정 확인
ps aux | grep nginx | head -2

# Nginx 가 소켓에 접속할 수 있는지 직접 테스트
# (소켓에 접속하려면 읽기가 아니라 쓰기 권한이 필요합니다)
sudo -u www-data test -w /run/myapp/gunicorn.sock && echo OK || echo "권한 없음"
502 Bad Gateway + Permission denied 조합은 소켓 권한 문제입니다.
· 앱 프로세스의 그룹을 www-data 로 하거나
· UMask=0007 로 그룹 쓰기 권한을 주거나
· Gunicorn 에 --umask 007 옵션을 주세요
또 상위 폴더(/run/myapp)에도 접근 권한이 필요합니다.
# systemd 로 소켓 폴더를 안전하게 관리
[Service]
User=appuser
# ★ Nginx 그룹
Group=www-data
# /run/myapp 을 자동 생성 (서비스 종료 시 정리)
RuntimeDirectory=myapp
RuntimeDirectoryMode=0750
# 소켓이 660 으로 생성됩니다
UMask=0007

SELinux 환경 (RHEL 계열)

# 권한이 다 맞는데도 502 라면 SELinux 를 의심하세요
sudo ausearch -m avc -ts recent | tail -20

# Nginx 가 네트워크로 프록시할 수 있게
sudo setsebool -P httpd_can_network_connect 1

# 유닉스 소켓 사용 허용
sudo setsebool -P httpd_can_network_relay 1

# 소켓 파일 컨텍스트
sudo semanage fcontext -a -t httpd_var_run_t "/run/myapp(/.*)?"
sudo restorecon -Rv /run/myapp
어느 쪽을 쓸까 — 같은 서버 + 성능이 중요하면 유닉스 소켓, 나중에 앱 서버를 분리할 가능성이 있거나 디버깅 편의가 중요하면 TCP. 성능 차이는 대부분의 서비스에서 체감되지 않으므로, 운영 편의로 결정해도 무방합니다.
포트냐 유닉스 소켓이냐 — 그리고 권한 TCP 포트 (127.0.0.1:8000) 설정이 단순하고 익숙합니다 다른 서버로 옮기기 쉽습니다 외부에 열리지 않게 주소를 묶으세요 유닉스 소켓 (/run/app.sock) 같은 서버 안에서는 조금 더 빠릅니다 네트워크로 노출될 일이 없습니다 파일 권한 설정이 필요합니다 권한에서 걸리는 것들 소켓 파일에 웹서버 계정이 접근할 수 있어야 합니다 — 권한 거부가 502 로 나타납니다 앱을 루트로 실행하지 마세요 — 전용 계정을 만들고 필요한 경로만 열어 주세요 80 · 443 처럼 1024 미만 포트를 쓰려면 별도 권한 부여가 필요합니다 (setcap · systemd) /tmp 대신 /run 아래에 두세요 — 재부팅 시 정리되고 권한 관리가 명확합니다 systemd 소켓 활성화를 쓰면 소켓 생성과 권한을 systemd 가 맡아 줍니다
502 의 상당수는 앱이 죽어서가 아니라 연결 지점의 권한 때문입니다.
SECTION 14

배포 체크리스트

언어와 무관하게 빠뜨리기 쉬운 항목을 모았습니다.

분류확인 항목
보안 앱이 127.0.0.1 에만 바인드돼 있나 (외부 직접 접근 차단)
비밀번호·키가 코드·명령줄이 아니라 환경 변수·파일에 있나 (권한 600)
디버그 모드가 꺼져 있나 (DEBUG=False · NODE_ENV=production)
오류 페이지에 스택 트레이스가 노출되지 않나
프록시 X-Forwarded-Proto 를 전달하고 앱이 인식하나
앱 로그에 실제 클라이언트 IP 가 찍히나
업로드 용량 제한을 웹서버와 앱 양쪽에 설정했나
정적 파일 웹서버가 직접 서빙하나 (앱을 거치지 않나)
해시 붙은 파일은 영구 캐시, index.html 은 캐시 금지인가
업로드 폴더에서 스크립트 실행이 차단돼 있나
프로세스 죽으면 자동 재시작되나 (systemd·PM2·컨테이너)
서버 재부팅 후 자동 시작되나 (systemctl enable)
우아한 종료가 설정돼 있나 (배포 시 요청 유실 방지)
로그 로그가 어디에 쌓이는지 알고 있나
로테이션이 설정돼 있나 (디스크 가득 참 방지)
응답 시간이 로그에 포함돼 있나
운영 헬스체크 엔드포인트가 있나 (/health)
인증서 자동 갱신과 만료 감시가 걸려 있나
설정 파일이 버전 관리되고 있나

배포 후 확인 명령

# 1) 서비스가 떴나
systemctl status myapp nginx

# 2) 포트가 열렸나 · 어디에 바인드됐나
sudo ss -lntp | grep -E ':80|:443|:3000|:8080'

# 3) 앱에 직접 요청 (프록시 문제인지 앱 문제인지 구분)
curl -sS http://127.0.0.1:3000/health

# 4) 전체 경로로 요청
curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" https://example.com/health

# 5) 로그에 오류가 없나
sudo journalctl -u myapp --since "5 min ago" -p warning
sudo tail -50 /var/log/nginx/error.log

# 6) 인증서
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates
배포 스크립트로 만들어 두세요. 수동 배포는 매번 다르게 하게 되고, 실수하면 재현이 어렵습니다. 간단한 셸 스크립트라도 절차를 고정하면 사고가 크게 줄어듭니다. 나아가 CI/CD 파이프라인으로 옮기면 누가 언제 무엇을 배포했는지까지 남습니다.
PERFORMANCE · KEEPALIVE · CACHE · KERNEL · LOAD TEST

성능 —
측정하고
고칩니다

"느리다"를 숫자로 바꾸는 것이 시작입니다. 그다음 어디가 병목인지 지목하고, 효과가 큰 것부터 고칩니다. 설정부터 만지는 것은 거의 항상 잘못된 순서입니다 — 측정 → 병목 지목 → 개선 → 재측정의 순환을 익힙니다.

14SECTIONS
측정먼저
p99기준
SECTION 01

성능 지표 읽기

평균은 거짓말을 합니다. 백분위(percentile)로 말해야 합니다.

지표의미
RPS · TPS초당 처리한 요청 수 — 처리량
지연 (latency)요청 하나에 걸린 시간 — 체감 속도
p50 (중앙값)절반의 사용자가 이보다 빠름
p95 · p99★ 상위 5%·1% 느린 요청 — 실제 불만이 여기서 나옵니다
동시 접속같은 시점에 열려 있는 연결 수
오류율5xx 비율 — 성능 문제는 오류로 나타나는 경우가 많습니다
포화도CPU·메모리·커넥션 풀의 사용률 — 한계에 얼마나 가까운가
왜 평균이 아니라 p99 인가 — 응답 시간 100건 중 99건이 50ms, 1건이 5초라면 평균은 99.5ms 로 훌륭해 보입니다. 그런데 그 1건을 겪은 사용자는 "사이트가 멈췄다"고 느낍니다.
페이지 하나가 요청 50개를 보낸다면, p99 가 5초일 때 사용자의 40%가 최소 한 번은 그 5초를 경험합니다. 꼬리 지연(tail latency)이 실제 체감을 지배합니다.
# Nginx 로그에서 백분위 계산 (log_format 에 $request_time 이 있어야 합니다)
awk '{print $NF}' /var/log/nginx/access.log | sort -n | awk '
{ a[NR] = $1 }
END {
  printf "count=%d\n", NR
  printf "p50=%.3f\n", a[int(NR*0.50)]
  printf "p95=%.3f\n", a[int(NR*0.95)]
  printf "p99=%.3f\n", a[int(NR*0.99)]
  printf "max=%.3f\n", a[NR]
}'
count=48213 p50=0.042 p95=0.310 p99=1.850 ← 여기를 봐야 합니다 max=30.002

목표를 먼저 정하세요

나쁜 목표 : "빠르게 만들자"
좋은 목표 : "상품 목록 API 를 p95 200ms 이내로, 초당 300건 처리"

이렇게 정하면
· 달성했는지 판단할 수 있고
· 어디까지 최적화할지 멈출 지점이 생기고
· 부하 테스트 시나리오가 저절로 나옵니다
용량 산정의 기본 공식
필요 동시 처리 수 ≈ 목표 RPS × 평균 응답 시간(초)
목표 300 RPS · 평균 0.2초 → 동시 60건을 처리할 수 있어야 합니다. 여기에 여유 2배를 두고 워커·스레드·커넥션 풀을 설계합니다.
성능 지표 넷 — 함께 봐야 의미가 생깁니다 처리량 (RPS) 초당 몇 건을 처리하는가 — 용량의 척도 이 숫자만 올리면 응답 시간이 나빠질 수 있습니다 — 함께 봐야 합니다 응답 시간 (p50 · p95 · p99) 한 건이 얼마나 걸리는가 — 체감의 척도 평균은 보지 마세요 — 평균 80ms 라도 백 명 중 한 명은 4초를 기다릴 수 있습니다 동시 연결 수 지금 몇 개가 붙어 있는가 — 자원의 척도 파일 디스크립터 한계와 워커 설정이 여기서 걸립니다 오류율 몇 퍼센트가 실패하는가 — 신뢰의 척도 처리량이 아무리 높아도 오류율이 오르면 실제 처리량은 0 에 가깝습니다 부하 테스트에서는 넷을 함께 기록하세요 — 하나만 보면 반드시 잘못된 결론이 나옵니다 목표를 먼저 정하세요 — "p95 200ms 이하에서 초당 500건" 처럼 숫자로 적어야 판단이 됩니다
"빠르다" 는 말은 어느 지표에서라는 단서 없이는 의미가 없습니다.
SECTION 02

worker 수와 커넥션

Nginx 의 가장 기본적인 용량 설정입니다.

worker_processes auto;             # CPU 코어 수만큼 (권장)
worker_rlimit_nofile 65535;        # 워커당 열 수 있는 파일·소켓 수

events {
    worker_connections 4096;       # 워커 하나가 다룰 최대 연결
    multi_accept on;
    use epoll;                     # 리눅스 기본 (명시 불필요)
}
동시 처리 가능 연결 수
worker_processes × worker_connections
다만 프록시는 연결을 2개 씁니다(클라이언트 1 + 업스트림 1). 그래서 실제 동시 사용자는 절반 정도로 봐야 합니다.
8코어 × 4096 = 32,768 → 프록시 기준 약 16,000 동시 요청
설정지침
worker_processes auto 로 두세요. 코어보다 많이 만들면 오히려 손해입니다
worker_connections Nginx 기본값은 512, 배포판 설정 파일은 보통 768~1024 → 4096~8192 로. worker_rlimit_nofile 보다 작아야 합니다
worker_rlimit_nofile 시스템 ulimit -n 을 넘을 수 없습니다 — systemd 설정도 함께(p10)
worker_cpu_affinity 워커를 특정 코어에 고정. 대부분 필요 없습니다
# 현재 상태 확인
nproc                                    # CPU 코어 수
ulimit -n                                # 열 수 있는 파일 수
cat /proc/$(pgrep -f 'nginx: master')/limits | grep 'open files'

# 실제 연결 수 (stub_status 활성화 필요 — o08 참고)
curl -s http://127.0.0.1/nginx_status
Active connections: 291 server accepts handled requests 16630948 16630948 31070465 Reading: 6 Writing: 179 Waiting: 106
읽는 법 — Active 가 worker_processes × worker_connections 에 가까워지면 한계입니다. Waiting 이 크면 keepalive 연결이 많다는 뜻으로 정상입니다. accepts 와 handled 가 다르면 연결을 거부한 것이므로 즉시 조치가 필요합니다.
동시 연결 1만 개를 감당하는 세 가지 방식 — 자원 소모가 완전히 다릅니다 ① 연결마다 프로세스 Apache prefork 연결 1개 = 프로세스 1개 개당 수~수십 MB → 수백이 한계 ② 연결마다 스레드 Apache worker · Tomcat 기본 연결 1개 = 스레드 1개 개당 ~1MB 스택 → 수천이 한계 ③ 이벤트 루프 Nginx · Apache event · Node 워커 1개가 전부 처리 연결 1개 = 소켓 + 작은 상태 개당 수 KB → 수만도 가능 ③이 빠른 이유는 "연결을 기다리는 데 자원을 안 쓰기" 때문입니다 — 대부분의 연결은 대부분의 시간을 그냥 기다립니다. 커널에게 "이 1만 개 중 준비된 것만 알려 줘"(epoll) → 준비된 것만 순서대로 처리 → 다시 대기 주의: 이벤트 루프에서 CPU 를 오래 쓰는 작업을 하면 그 워커에 붙은 모든 연결이 함께 멈춥니다. Nginx 가 정적 파일·프록시에 강하고, 앱 로직을 직접 돌리지 않는 이유가 여기 있습니다.
같은 하드웨어에서 동시 연결 수가 수십 배 차이 나는 이유는 튜닝이 아니라 이 구조입니다.

서버별 동시성 모델 — 무엇을 쓰고 있는지 확인하세요

서버모델대략적 한계주의
Nginx이벤트 루프 (워커 = 코어 수)수만 연결블로킹 모듈을 넣으면 무너집니다
Caddy고루틴 (경량 스레드)수만 연결Go 런타임이 스케줄링
Apache event혼합 — 대기는 이벤트, 처리는 스레드수천~1만HTTP/2 는 이것이 필요
Apache worker프로세스 × 스레드수천—
Apache prefork연결당 프로세스수백mod_php 때문에 아직 쓰입니다
Tomcat NIO스레드 풀 + 셀렉터수천maxThreads 가 실질 상한
Node · Go이벤트 루프 / 고루틴수만Node 는 CPU 작업에 취약
# 지금 무엇을 쓰고 있는지
apachectl -V | grep -i mpm            # Apache — prefork 면 재검토 대상
nginx -V 2>&1 | head -1               # Nginx 버전

# Apache 를 event MPM 으로 바꾸기 (PHP 는 php-fpm 으로 분리해야 합니다)
sudo a2dismod php8.3 mpm_prefork
sudo a2enmod mpm_event proxy_fcgi setenvif
sudo a2enconf php8.3-fpm
sudo systemctl restart apache2

# 실제 동시 연결 수 세기
ss -s                                  # 전체 요약
ss -tan state established | wc -l      # 확립된 TCP 연결
ss -tan state time-wait | wc -l        # TIME_WAIT 이 많으면 커넥션 재사용 부족
Apache 가 prefork 로 돌고 있다면 그것부터 확인하세요. mod_php 를 쓰면 prefork 를 강제당하는데, 이때 동시 연결 수백 개에서 메모리가 바닥납니다 — 워커 하나가 PHP 인터프리터를 통째로 안고 있기 때문입니다. php-fpm 으로 분리하고 event MPM 으로 바꾸면 같은 서버에서 처리량이 몇 배가 되고 HTTP/2 도 쓸 수 있게 됩니다. 설정 몇 줄로 끝나는 작업 중 효과가 가장 큰 편입니다.
SECTION 03

keepalive 이해하기

연결을 끊지 않고 재사용하는 것입니다. 연결을 새로 맺는 비용이 생각보다 큽니다.

keepalive 없음
요청마다
  TCP 3-way 핸드셰이크 (1 RTT)
+ TLS 핸드셰이크        (1~2 RTT)
+ 실제 요청·응답        (1 RTT)
= 3~4 RTT

지연 50ms 환경이면 요청당 150~200ms 가
'연결 맺는 데만' 소모됩니다
keepalive 사용
첫 요청만 핸드셰이크
이후 요청은 실제 요청·응답만 (1 RTT)

페이지 하나에 요청 50개라면
차이가 압도적입니다
# ── 클라이언트 ↔ Nginx
keepalive_timeout 65s;             # 유휴 연결 유지 시간
keepalive_requests 1000;           # 한 연결로 처리할 최대 요청 수

# ── Nginx ↔ 백엔드 (★ 기본이 꺼져 있어 매번 새로 연결합니다)
upstream backend {
    server 127.0.0.1:8080;
    keepalive 64;                  # 워커당 유지할 유휴 연결 수
    keepalive_timeout 60s;
    keepalive_requests 1000;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;         # ★ 1.0 은 keepalive 미지원
        proxy_set_header Connection "";  # ★ 기본 'close' 헤더를 지웁니다
    }
}
업스트림 keepalive 는 세 가지를 모두 해야 동작합니다.
① upstream 블록에 keepalive N;
② proxy_http_version 1.1;
③ proxy_set_header Connection "";
하나라도 빠지면 매 요청마다 새 TCP 연결을 맺습니다. 이것만 고쳐도 지연이 눈에 띄게 줄어드는 경우가 많습니다.

트레이드오프

값을 늘리면효과
keepalive_timeout ↑ 재사용률 상승 · 유휴 연결이 자원을 점유
keepalive_requests ↑ 핸드셰이크 감소 · 한 연결에 편중될 수 있음
upstream keepalive ↑ 백엔드 연결 비용 감소 · 백엔드가 유지할 연결이 늘어남
백엔드 쪽 타임아웃과 맞추세요. 백엔드의 유휴 타임아웃이 Nginx 의 keepalive_timeout 보다 짧으면, Nginx 가 이미 끊긴 연결로 요청을 보내다 502 가 간헐적으로 발생합니다.
Nginx 쪽을 백엔드보다 짧게 잡는 것이 안전합니다.
keepalive — 연결을 다시 쓰는 것만으로 크게 달라집니다 매번 새로 맺으면 요청마다 TCP 왕복 + TLS 협상 — 실제 데이터보다 준비 과정이 더 오래 걸리기도 합니다 닫힌 연결이 잠시 남아 포트를 붙잡습니다 — 트래픽이 많으면 포트가 모자랍니다 재사용하면 준비 비용이 사라지고 지연이 눈에 띄게 줄어듭니다 대신 유지하는 동안 자원을 붙잡습니다 — 유지 시간과 최대 요청 수를 잡아 줘야 합니다 자주 놓치는 곳 — 프록시와 뒤쪽 서버 사이 upstream 에 keepalive 를 안 두면 프록시가 요청마다 새 연결을 맺습니다 브라우저 쪽 유지 시간은 너무 길게 두지 마세요 — 연결만 붙잡고 있는 상태가 쌓입니다 HTTP/2 는 한 연결에 여러 요청을 함께 실으므로 이 효과가 더 커집니다
설정 두 줄로 얻는 개선 중 가장 효율이 좋은 편에 속합니다.
SECTION 04

정적 파일 최적화

가장 값싸고 효과가 큰 최적화입니다. 요청 자체를 없앱니다.

http {
    sendfile on;                   # ★ 커널이 직접 전송 (유저 공간 복사 생략)
    tcp_nopush on;                 # 헤더와 본문을 모아서 전송
    tcp_nodelay on;                # keepalive 연결에서는 즉시 전송

    # 파일 메타데이터 캐시 (자주 요청되는 정적 파일에 효과적)
    open_file_cache          max=10000 inactive=60s;
    open_file_cache_valid    60s;
    open_file_cache_min_uses 2;
    open_file_cache_errors   on;
}

server {
    # 해시가 붙은 빌드 산출물 — 영구 캐시
    #   정규식에 { } 가 있으면 따옴표로 감싸야 합니다 (안 그러면 nginx -t 실패)
    location ~* "\.[0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|svg|webp|avif)$" {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # 일반 이미지
    location ~* \.(png|jpe?g|gif|svg|ico|webp|avif)$ {
        expires 30d;
        add_header Cache-Control "public";
        access_log off;
    }

    # HTML 은 캐시하지 않음
    location ~* \.html$ {
        add_header Cache-Control "no-store, must-revalidate";
    }
}
캐시 헤더동작
max-age=315360001년간 재요청하지 않음
immutable새로고침해도 재검증하지 않음 — 해시 붙은 파일에 필수
publicCDN·프록시도 캐시 가능
private브라우저만 캐시 (사용자별 응답)
no-store어디에도 저장 금지
must-revalidate만료되면 반드시 서버에 확인
stale-while-revalidate=60만료된 것을 주면서 뒤에서 갱신 (체감 속도 개선)
가장 빠른 요청은 보내지 않는 요청입니다. 캐시 헤더 한 줄로 재방문자의 요청 수십 개가 아예 발생하지 않습니다. 서버 부하도 줄고 사용자 체감도 개선되는, 드물게 양쪽 모두에 좋은 최적화입니다.

ETag 와 조건부 요청

etag on;                          # 기본값 (파일 크기+수정시각 기반)

# 브라우저가 If-None-Match 로 물으면 Nginx 가 304 Not Modified 로 응답
# → 본문 전송 없이 "안 바뀌었다"만 알려 줍니다

# 여러 서버가 같은 파일을 서빙할 때 ETag 가 달라지면 캐시 효율이 떨어집니다
# 이 경우 파일 해시 기반 파일명 + immutable 조합이 더 확실합니다
# 캐시가 실제로 동작하는지 확인
curl -I https://example.com/app.a1b2c3.js
# cache-control: public, immutable
# expires: ...

# 조건부 요청 테스트
#   먼저 curl -I 로 받은 etag 값을 그대로 넣어야 합니다 (아무 값이면 200)
curl -I -H 'If-None-Match: "65f1a2b3-1a2b"' https://example.com/style.css
# HTTP/2 304   ← 본문을 안 보냄
SECTION 05

압축 전략

CPU 를 써서 전송량을 줄이는 거래입니다. 균형점이 있습니다.

gzip on;
gzip_vary on;                      # ★ Vary: Accept-Encoding
gzip_comp_level 5;                 # 1~9 · 5~6 이 최적
gzip_min_length 1024;              # 작은 파일은 압축 이득이 없습니다
gzip_proxied any;
gzip_types
    text/plain text/css text/xml
    application/json application/javascript application/xml+rss
    image/svg+xml font/ttf font/otf;

# 미리 압축된 파일이 있으면 그것을 사용 (CPU 0)
gzip_static on;

# brotli (모듈 필요) — gzip 보다 15~20% 더 작습니다
brotli on;
brotli_comp_level 5;
brotli_types text/plain text/css application/json application/javascript image/svg+xml;
brotli_static on;
레벨크기 감소CPU
gzip 1보통매우 낮음
gzip 5~6거의 최대치적정
gzip 96 대비 1~2% 추가몇 배 증가
brotli 5gzip 대비 15~20% 더 작음적정
brotli 11최대매우 높음 — 사전 압축 전용
이미 압축된 것을 또 압축하지 마세요. JPG·PNG·WebP·MP4·ZIP·woff2 는 이미 압축된 형식입니다. 다시 압축하면 CPU 만 쓰고 크기는 오히려 커질 수 있습니다. gzip_types 에 이미지·동영상을 넣지 마세요.

사전 압축 — 가장 좋은 방법

# 빌드 시점에 최고 레벨로 압축해 둡니다
find dist -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' \) \
  -exec gzip -9 -k {} \; \
  -exec brotli -q 11 -k {} \;

# Nginx 는 요청 시 app.js.br · app.js.gz 를 찾아 그대로 전송합니다
# → 압축 CPU 0 · 압축률 최대
정적 파일은 사전 압축, 동적 응답은 실시간 압축(레벨 5). 이 조합이 CPU 와 전송량의 균형을 가장 잘 맞춥니다.
트래픽이 아주 많은 서버에서는 압축이 CPU 병목이 될 수 있으니 top 으로 Nginx 워커의 CPU 사용률을 확인하세요.
# 압축이 적용되는지 확인
curl -H "Accept-Encoding: gzip, br" -I https://example.com/app.js | grep -i content-encoding

# 크기 비교
curl -s https://example.com/app.js | wc -c                          # 원본
curl -s -H "Accept-Encoding: gzip" https://example.com/app.js | wc -c  # 압축
SECTION 06

프록시 버퍼링

느린 클라이언트가 앱 워커를 붙잡지 못하게 막는 중요한 장치입니다.

버퍼링 없음
앱 → (느린 3G 사용자에게 직접 전송)

응답 1MB 를 보내는 데 10초가 걸리면
그동안 앱 워커가 계속 묶여 있습니다

워커 200개인데 느린 사용자 200명이면
서비스 전체가 멈춥니다
버퍼링 사용 (기본값)
앱 → Nginx 가 응답 전체를 즉시 받아 저장
     (앱 워커는 바로 풀려납니다)
Nginx → 느린 사용자에게 천천히 전송

앱은 빠른 Nginx 만 상대하면 됩니다
느린 클라이언트는 Nginx 가 흡수합니다
location / {
    proxy_pass http://backend;

    proxy_buffering on;              # 기본값 · 켜 두세요
    proxy_buffer_size 8k;            # 응답 헤더용 버퍼
    proxy_buffers 16 8k;             # 본문 버퍼 개수 × 크기 (= 128k)
    proxy_busy_buffers_size 16k;

    # 버퍼를 넘으면 임시 파일에 씁니다
    proxy_max_temp_file_size 1024m;
    proxy_temp_file_write_size 16k;
    proxy_temp_path /var/cache/nginx/proxy_temp;
}

버퍼링을 꺼야 하는 경우

# SSE · 실시간 스트리밍 — 버퍼링하면 이벤트가 모였다가 한꺼번에 갑니다
location /events {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;
    # add_header X-Accel-Buffering no; 는 효과 없음 — 이 헤더는 앱이 보내야 Nginx 가 읽습니다(아래)
}

# 대용량 업로드 — 요청 본문 버퍼링을 끄고 스트리밍
location /upload {
    proxy_pass http://backend;
    proxy_request_buffering off;
    client_max_body_size 500m;
}
앱에서 응답별로 끄는 방법 — 응답 헤더에 X-Accel-Buffering: no 를 넣으면 그 응답만 버퍼링이 꺼집니다. location 전체를 바꾸지 않아도 되어 편리합니다.
upstream sent too big header 오류가 나면 proxy_buffer_size 를 늘려야 합니다. 세션 쿠키가 많거나 헤더가 큰 응답에서 발생합니다.
proxy_buffer_size 16k; proxy_busy_buffers_size 24k;
# 임시 파일이 많이 생기면 버퍼가 부족하다는 신호입니다
ls -l /var/cache/nginx/proxy_temp/ | head
watch -n1 'ls /var/cache/nginx/proxy_temp/ | wc -l'

# 디스크 IO 가 부담되면 proxy_buffers 를 늘려 메모리에서 처리하세요
버퍼링은 '느린 사용자로부터 앱을 지키는' 장치입니다 ① 버퍼링 켬 (기본값) — 앱이 빨리 풀려납니다 앱 10ms 만에 응답 완료 웹서버가 통째로 받아 둠 앱 연결은 여기서 종료 느린 3G 사용자에게 30초에 걸쳐 전송 그동안 앱은 다른 요청을 처리합니다 ② 버퍼링 끔 — 앱이 끝까지 붙잡힙니다 앱 30초 내내 점유 사용자가 받는 속도에 앱이 끌려갑니다 동시 접속이 몰리면 앱 스레드가 고갈됩니다 이것이 504 로 나타나는 흔한 경로입니다 그래도 꺼야 하는 경로 SSE · 스트리밍 · 실시간 로그 — 그 location 에서만 off 로 버퍼가 모자라면 디스크로 넘어갑니다 응답이 크면 proxy_buffers 를 늘리세요 — 임시 파일은 느립니다
기본값(켬)이 옳습니다 — 끄는 것은 스트리밍 경로에 한정하세요.
버퍼링은 '느린 사용자로부터 앱을 지키는' 장치입니다 ① 버퍼링 켬 (기본값) — 앱이 빨리 풀려납니다 앱 10ms 만에 응답 완료 웹서버가 통째로 받아 둠 앱 연결은 여기서 종료 느린 3G 사용자에게 30초에 걸쳐 전송 그동안 앱은 다른 요청을 처리합니다 ② 버퍼링 끔 — 앱이 끝까지 붙잡힙니다 앱 30초 내내 점유 사용자가 받는 속도에 앱이 끌려갑니다 동시 접속이 몰리면 앱 스레드가 고갈됩니다 이것이 504 로 나타나는 흔한 경로입니다 그래도 꺼야 하는 경로 SSE · 스트리밍 · 실시간 로그 — 그 location 에서만 off 로 버퍼가 모자라면 디스크로 넘어갑니다 응답이 크면 proxy_buffers 를 늘리세요 — 디스크 임시 파일은 느립니다
기본값(켬)이 옳습니다 — 끄는 것은 스트리밍 경로에 한정하세요.
SECTION 07

업스트림 커넥션 재사용

p03 의 keepalive 를 백엔드 쪽에 적용하는 이야기입니다. 효과가 큽니다.

upstream backend {
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;

    keepalive 64;                  # ★ 워커당 유지할 유휴 연결 수
    keepalive_timeout 60s;
    keepalive_requests 1000;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}
keepalive 값 정하기 — 워커당 값이므로 실제 유지 연결은 worker_processes × keepalive 입니다. 8코어에 keepalive 64 면 백엔드에 최대 512개 유휴 연결이 유지됩니다.
백엔드가 감당할 수 있는지 확인하세요 — 톰캣 maxConnections · Node 의 파일 디스크립터 한계에 걸릴 수 있습니다.

HTTPS 업스트림이라면

upstream backend_ssl {
    server backend.internal:443;
    keepalive 32;
}

location / {
    proxy_pass https://backend_ssl;
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    proxy_ssl_session_reuse on;         # ★ TLS 세션 재사용 (핸드셰이크 생략)
    proxy_ssl_server_name on;           # SNI 전송
    proxy_ssl_name backend.internal;
    proxy_ssl_verify on;
    proxy_ssl_trusted_certificate /etc/ssl/certs/ca-bundle.crt;
}
업스트림이 HTTPS 면 매 연결마다 TLS 핸드셰이크가 추가됩니다. keepalive 와 proxy_ssl_session_reuse 를 함께 쓰지 않으면 지연이 크게 늘어납니다.
같은 사설망 안이라면 평문 HTTP 로 두고 망 자체를 보호하는 구성도 흔합니다 (다만 보안 요구사항에 따라 다릅니다).
# 재사용이 되고 있는지 확인 — 백엔드의 연결 수를 보면 알 수 있습니다
ss -tn state established '( dport = :8080 )' | wc -l

# keepalive 설정 전후로 이 값을 비교해 보세요
# 설정 후에는 요청 수와 무관하게 연결 수가 안정적으로 유지됩니다
업스트림 keepalive 를 켜고 끄고의 차이 — 요청 하나당 없을 때 — 요청마다 연결을 새로 TCP 3-way 요청 보내기 응답 받기 연결 종료 TIME_WAIT 소켓이 한동안 남음 있을 때 — 연결을 빌려 쓰고 돌려줍니다 풀에서 유휴 연결 꺼내기 요청 보내기 응답 받기 연결을 풀에 반납 — 다음 요청이 재사용 같은 데이터센터 안이라도 3-way 핸드셰이크는 왕복이 필요합니다 — 요청이 짧을수록 이 비율이 커집니다. HTTPS 업스트림이면 TLS 핸드셰이크까지 더해져, keepalive 유무의 차이가 몇 배로 벌어집니다.
설정 세 줄로 지연이 눈에 띄게 줄고 TIME_WAIT 이 사라집니다 — 성능 튜닝 중 투입 대비 효과가 가장 좋은 축입니다.
keepalive 만 넣고 두 줄을 빠뜨리면 아무 효과가 없습니다.
proxy_http_version 1.1; — HTTP/1.0 은 연결 유지를 못 합니다
proxy_set_header Connection ""; — 안 지우면 close 가 전달돼 매번 끊깁니다
이 두 줄이 없으면 keepalive 64 를 적어 두고도 실제로는 매 요청 새 연결을 맺습니다. 설정을 넣은 뒤에는 반드시 ss -tan state time-wait | wc -l 로 실제로 줄었는지 확인하세요.

keepalive 값을 정하는 법

설정지침
keepalive워커당 유휴 연결 수. 실제 총합 = worker_processes × keepalive.
16~64 사이에서 시작해 백엔드 연결 수를 보며 조정하세요
keepalive_timeout백엔드의 유휴 타임아웃보다 짧게. 길면 백엔드가 먼저 끊어 502 가 납니다
keepalive_requests한 연결로 처리할 최대 요청 수(기본 1000). 그대로 두면 됩니다
백엔드 쪽Node server.keepAliveTimeout · Tomcat keepAliveTimeout ·
Kestrel KeepAliveTimeout 을 Nginx 값보다 크게
"간헐적 502 가 나는데 재현이 안 된다"의 대표적 원인이 이 타임아웃 불일치입니다. Nginx 가 유휴 연결을 재사용하려는 순간 백엔드가 방금 그 연결을 닫았다면, 요청이 허공에 나가 502 가 됩니다. Node 의 기본 keepAliveTimeout 은 5초라 Nginx 기본값과 어긋납니다 — 백엔드를 65초 정도로 올리는 것이 정석적인 해결입니다. proxy_next_upstream error timeout; 로 한 번 재시도를 열어 두면 남은 확률까지 흡수됩니다.
SECTION 08

캐시 계층 설계

앞쪽에서 막을수록 좋습니다. 네 개의 층이 있습니다.

1 · 브라우저
가장 빠릅니다 — 네트워크를 아예 안 씁니다 Cache-Control 헤더로 제어. 정적 자원은 여기서 끝내는 것이 최선입니다.
▼ 없으면
2 · CDN
사용자와 가까운 엣지에서 응답 전 세계 지연이 크게 줄고 오리진 부하가 사라집니다.
▼ 없으면
3 · 웹서버
proxy_cache — 앱을 거치지 않고 응답 동적 응답도 캐시할 수 있습니다(🟩 Nginx n13).
▼ 없으면
4 · 앱 · Redis
계산 결과·DB 조회 결과를 캐시 여기까지 오면 이미 비용이 발생했습니다.
▼ 없으면
5 · DB
가장 비쌉니다 DB 내부 캐시(버퍼 풀)도 있지만 여기까지 오는 요청을 줄이는 것이 핵심입니다.

무엇을 어디에 캐시할까

대상위치와 TTL
해시 붙은 JS·CSS브라우저 + CDN · 1년 (immutable)
이미지브라우저 + CDN · 30일
index.html캐시 금지 (또는 매우 짧게)
공개 API 목록 (상품 목록 등)웹서버 proxy_cache · 1~10분
사용자별 응답공유 캐시 금지 — private 또는 캐시 안 함
DB 조회 결과앱 · Redis · 수 분
실시간 데이터 (재고·잔액)캐시하지 않음
가장 위험한 사고 — 개인화된 응답의 공유 캐시
로그인한 A 의 응답이 캐시되어 B 에게 그대로 전달되는 사고입니다. 실제로 대형 서비스에서 여러 번 발생했습니다.
기본을 "캐시하지 않음"으로 두고, 캐시할 것만 명시적으로 지정하세요. Authorization 헤더나 세션 쿠키가 있으면 무조건 우회하도록 설정합니다.

캐시 무효화 전략

전략설명
URL 에 버전·해시 ★ 가장 확실합니다. 내용이 바뀌면 URL 이 바뀌므로 무효화가 필요 없습니다
짧은 TTL 무효화 대신 금방 만료되게. 단순하고 안전합니다
stale-while-revalidate 만료된 것을 주면서 뒤에서 갱신 — 체감 속도가 좋습니다
명시적 퍼지 CDN API 로 삭제. Nginx 오픈소스는 선택 삭제 기능이 없습니다
캐시 설계의 원칙 — 무효화는 어렵습니다. 그래서 "무효화가 필요 없는 구조"(URL 버저닝)를 우선하고, 그다음이 "짧은 TTL", 마지막이 "명시적 퍼지"입니다.
"이 데이터가 5분 늦어도 괜찮은가?"를 기능별로 판단하세요.
SECTION 09

커널 파라미터 튜닝

동시 접속이 아주 많아지면 OS 설정이 먼저 한계에 닿습니다.

# /etc/sysctl.d/99-webserver.conf

# ── 연결 대기열
# accept 대기열 (기본 4096)
net.core.somaxconn = 65535
# SYN 대기열
net.ipv4.tcp_max_syn_backlog = 65535
# NIC 수신 대기열
net.core.netdev_max_backlog = 65535

# ── 포트·연결 재사용
net.ipv4.ip_local_port_range = 10240 65535
# TIME_WAIT 소켓 재사용 (클라이언트 방향)
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15

# ── keepalive
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3

# ── 파일 디스크립터
fs.file-max = 2097152

# ── 메모리 (대량 연결 시)
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# ── SYN flood 완화
net.ipv4.tcp_syncookies = 1

# 적용
# sudo sysctl --system
# sysctl -a | grep somaxconn   로 확인
net.ipv4.tcp_tw_recycle 을 쓰지 마세요. NAT 뒤의 여러 클라이언트가 연결에 실패하는 문제를 일으키며, 리눅스 커널 4.12 에서 제거됐습니다. 옛 튜닝 글에 자주 등장하니 주의하세요. tcp_tw_reuse 는 안전합니다.
# Nginx 쪽에서도 백로그를 맞춰야 합니다
server {
    listen 80 backlog=65535 reuseport;
    # backlog 는 somaxconn 을 넘을 수 없습니다
    # reuseport 는 커널이 워커별로 연결을 분배 (로드 불균형 완화)
}
증상확인할 파라미터
연결이 거부됨 (피크 시)somaxconn · tcp_max_syn_backlog · Nginx backlog
TIME_WAIT 이 수만 개tcp_tw_reuse · 업스트림 keepalive(근본 해결)
포트 고갈ip_local_port_range · keepalive 로 연결 재사용
too many open filesfs.file-max · ulimit · systemd LimitNOFILE (p10)
# 현재 상태 확인
ss -s                                    # 소켓 상태 요약
ss -tan state time-wait | wc -l          # TIME_WAIT 개수
netstat -s | grep -i -E 'listen|overflow'  # 대기열 넘침 여부
cat /proc/net/netstat | grep -i listen
커널 튜닝은 마지막 카드입니다. 초당 수천 연결 이상을 다루는 서버가 아니라면 기본값으로도 충분합니다. 먼저 애플리케이션·쿼리·캐시를 개선하세요 — 효과가 훨씬 큽니다.
SECTION 10

파일 디스크립터 한계

too many open files — 트래픽이 늘면 반드시 만나는 오류입니다.

리눅스에서는 소켓도 파일입니다. 연결 하나가 파일 디스크립터 하나를 씁니다. 프록시는 연결당 2개(클라이언트 + 업스트림)를 쓰므로, 동시 1만 연결이면 2만 개 이상이 필요합니다.
# 현재 한계 확인
ulimit -n                                        # 셸의 한계
cat /proc/$(pgrep -f 'nginx: master')/limits | grep 'open files'

# 실제 사용량
ls /proc/$(pgrep -f 'nginx: worker' | head -1)/fd | wc -l
lsof -p <pid> | wc -l

# 시스템 전체
cat /proc/sys/fs/file-nr    # 사용중 / 미사용 / 최대

세 곳을 모두 올려야 합니다

# ① 커널 전체
# /etc/sysctl.d/99-limits.conf
fs.file-max = 2097152

# ② 사용자 한계
# /etc/security/limits.conf
*       soft  nofile  65535
*       hard  nofile  65535
nginx   soft  nofile  65535
nginx   hard  nofile  65535
# ③ systemd 서비스 (★ 가장 자주 놓칩니다)
# limits.conf 는 systemd 서비스에 적용되지 않습니다!
# /etc/systemd/system/nginx.service.d/override.conf
[Service]
LimitNOFILE=65535

# sudo systemctl daemon-reload && sudo systemctl restart nginx
# ④ Nginx 설정에도
worker_rlimit_nofile 65535;
events { worker_connections 16384; }
# worker_connections 는 worker_rlimit_nofile 보다 작아야 합니다
/etc/security/limits.conf 만 고치면 안 됩니다. 이 파일은 PAM 을 거치는 로그인 세션에만 적용됩니다. systemd 로 실행되는 서비스는 유닛 파일의 LimitNOFILE 을 따릅니다. "설정했는데 안 올라간다"는 문제의 대부분이 이것입니다.
반드시 /proc/<pid>/limits 로 실제 프로세스의 값을 확인하세요.
# 확인 (설정 후)
cat /proc/$(pgrep -f 'nginx: master')/limits | grep -i 'open files'
# Max open files            65535                65535                files
컨테이너에서는 도커·쿠버네티스 설정에서 지정합니다.
· Docker — --ulimit nofile=65535:65535
· Compose — ulimits: nofile: {soft: 65535, hard: 65535}
· 쿠버네티스 — 노드의 kubelet·컨테이너 런타임 설정을 따릅니다
한계는 세 겹 — 가장 낮은 값이 이깁니다 ① 시스템 전체 — fs.file-max (수십만~수백만) ② 프로세스별 — ulimit -n · systemd 의 LimitNOFILE=65535 ③ 웹서버 설정 — worker_rlimit_nofile · worker_connections 실제 한계 = 이 셋 중 가장 작은 값 계산 — 커넥션 하나에 두 개를 씁니다 앞 1 + 뒤 1 · 로그도 별도 → 동시접속 × 2 + 여유 증상 — Too many open files 이 줄이 로그에 보이면 접속이 무작위로 실패하기 시작합니다
systemd 로 띄운 서비스는 쉘의 ulimit 이 적용되지 않습니다 — 유닛 파일에 적어야 합니다.
한계는 세 겹 — 가장 낮은 값이 이깁니다 ① 시스템 전체 — fs.file-max (수십만~수백만) ② 프로세스별 — ulimit -n · systemd 의 LimitNOFILE=65535 ③ 웹서버 설정 — worker_rlimit_nofile · worker_connections 실제 한계 = 이 셋 중 가장 작은 값 계산 — 커넥션 하나에 두 개를 씁니다 앞(브라우저) 1 + 뒤(앱) 1 · 로그 파일도 별도 → 동시접속 × 2 + 여유 증상 — Too many open files 이 줄이 로그에 보이면 접속이 무작위로 실패하기 시작합니다
systemd 로 띄운 서비스는 쉘의 ulimit 이 적용되지 않습니다 — 유닛 파일에 적어야 합니다.
SECTION 11

CDN 앞단 구성

전 세계 어디서든 가까운 곳에서 파일을 받게 합니다. 오리진 부하도 크게 줍니다.

사용자
가장 가까운 엣지 서버로 연결DNS 나 애니캐스트로 자동 라우팅됩니다.
▼ 캐시에 있으면 즉시 응답
CDN 엣지
캐시된 응답을 반환오리진까지 가지 않습니다 — 지연이 수십 ms 로 줄어듭니다.
▼ 없으면 (캐시 미스)
오리진
우리 Nginx · 앱응답을 만들어 주고, CDN 이 그것을 캐시합니다.
# 오리진(Nginx) 쪽 설정 — CDN 이 캐시할 수 있게 헤더를 정확히 보냅니다
location ~* "\.[0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|svg|webp)$" {     # { } 가 든 정규식은 따옴표로
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Vary "Accept-Encoding";
}

# 개인화된 응답은 CDN 이 캐시하지 않게
location /api/me {
    add_header Cache-Control "private, no-store";
    proxy_pass http://backend;
}

# ★ CDN 뒤에서 실제 클라이언트 IP 복원
set_real_ip_from 173.245.48.0/20;      # CDN 대역 (제공자 문서에서 확인)
set_real_ip_from 103.21.244.0/22;
real_ip_header CF-Connecting-IP;        # 또는 X-Forwarded-For
real_ip_recursive on;
CDN 을 붙이면 실제 IP 가 가려집니다. set_real_ip_from 으로 CDN 대역을 신뢰하도록 설정하지 않으면
· 로그에 CDN IP 만 남고
· rate limit 이 전 사용자를 하나로 묶어 정상 트래픽까지 차단합니다
· 지역·IP 기반 로직이 전부 오동작합니다
CDN 제공자의 공식 IP 대역 목록을 주기적으로 갱신하세요.
확인 항목내용
오리진 보호 오리진 IP 가 노출되면 CDN 을 우회한 직접 공격이 가능합니다. 방화벽에서 CDN 대역만 허용하세요
캐시 키 쿼리스트링·쿠키·헤더 중 무엇을 키에 포함할지 — 잘못 설정하면 적중률 급락 또는 개인정보 유출
Vary 헤더 Accept-Encoding 은 필요하지만, User-Agent 를 넣으면 캐시가 사실상 무력화됩니다
퍼지 절차 배포 시 무효화가 필요한 경로와 방법을 미리 정해 두세요
HTTPS 구간 CDN ↔ 오리진 구간도 HTTPS 로 (Full Strict 모드)
CDN 없이도 할 수 있는 것 — 정적 자원에 긴 캐시 헤더를 붙이는 것만으로도 재방문자의 요청이 대부분 사라집니다. CDN 은 첫 방문자와 지리적 거리 문제를 해결합니다. 국내 사용자만 대상이라면 효과가 제한적일 수 있으니 측정 후 판단하세요.
SECTION 12

부하 테스트

성능은 측정하지 않으면 알 수 없습니다. "몇 명까지 버티냐"에 숫자로 답합니다.

도구특징
k6★ JS 로 시나리오 작성 · 지표가 풍부 · CI 연동이 쉽습니다
wrk가볍고 빠름 · 단순 부하에 적합
ab (ApacheBench)가장 단순 · 빠른 확인용 (keepalive 처리에 한계)
JMeterGUI · 복잡한 시나리오 · 무겁습니다
Locust파이썬으로 시나리오 작성
GatlingScala 기반 · 리포트가 훌륭합니다
# 빠른 확인
ab -n 10000 -c 100 -k https://example.com/
wrk -t4 -c200 -d30s --latency https://example.com/
// k6 시나리오 — load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '1m',  target: 50  },    // 워밍업
    { duration: '3m',  target: 200 },    // 부하 상승
    { duration: '5m',  target: 200 },    // 유지
    { duration: '1m',  target: 0   },    // 감소
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1500'],   // ★ 목표를 여기에
    http_req_failed:   ['rate<0.01'],                  // 오류율 1% 미만
  },
};

export default function () {
  const res = http.get('https://example.com/api/products');
  check(res, {
    'status 200': (r) => r.status === 200,
    'body ok':    (r) => r.body.length > 0,
  });
  sleep(1);
}

// k6 run load-test.js
부하 테스트에서 흔한 실수
· 운영 환경에 그대로 실행 — 실제 사용자에게 장애를 일으킵니다
· 워밍업 없이 측정 — JIT·캐시·커넥션 풀이 준비되기 전 수치는 의미가 없습니다
· 테스트 도구가 병목 — 부하 생성기의 CPU·네트워크를 함께 확인하세요
· 같은 데이터만 반복 요청 — 캐시에 다 걸려 실제보다 좋게 나옵니다
· 평균만 보기 — p95·p99 를 봐야 합니다

해석하는 법

동시 사용자를 늘려 가며 관찰합니다

    RPS
     │        ┌──────  ← 한계점(saturation): 여기서 더 늘려도 처리량이 안 늘어남
     │      ╱
     │    ╱                     이 지점의 동시 사용자 수가 '용량'입니다
     │  ╱
     └────────────── 동시 사용자

    p99
     │              ╱   ← 한계를 넘으면 지연이 급격히 치솟습니다
     │         ╱
     │ ────────
     └────────────── 동시 사용자

★ 처리량이 평평해지는 지점 = 한계
★ 그 지점 이후에는 지연만 늘고 처리량은 그대로입니다
SECTION 13

병목 찾기 절차

감으로 고치지 마세요. 순서가 있습니다.

1
어디가 느린지 구간을 나눕니다 로그의 $request_time 과 $upstream_response_time 을 비교합니다. 둘 다 크면 앱, request_time 만 크면 네트워크·전송입니다.
▼
2
서버 자원을 봅니다 CPU · 메모리 · 디스크 IO · 네트워크 중 무엇이 포화인지 확인합니다.
▼
3
앱 안에서 다시 나눕니다 앱이 느리다면 DB 쿼리 · 외부 API · 로직 중 어디인지. APM·로그로 확인합니다.
▼
4
하나씩 바꾸고 재측정 여러 개를 동시에 바꾸면 무엇이 효과였는지 알 수 없습니다.
# ── 구간 나누기 (Nginx 로그에 request_time · upstream_response_time 필요)
awk '{print $(NF-1), $NF}' /var/log/nginx/access.log | tail -100
# 예: rt=1.234 urt=1.200  → 앱이 느림
#     rt=1.234 urt=0.020  → 전송이 느림 (큰 응답 · 느린 클라이언트)

# ── 자원 확인
top -o %CPU                              # CPU
free -h                                  # 메모리
iostat -x 1 5                            # 디스크 (%util 이 100 에 가까우면 포화)
vmstat 1 5                               # 종합 (si·so 가 있으면 스왑 발생 = 심각)
ss -s                                    # 소켓

# ── 어느 URL 이 느린가
awk '{print $NF, $7}' /var/log/nginx/access.log | sort -rn | head -20

# ── 어느 URL 이 많이 호출되는가 (총 부하 = 횟수 × 시간)
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
증상가능성이 높은 원인
CPU 100%압축 레벨 과다 · TLS 핸드셰이크 폭주 · 앱 로직 · 정규식
CPU 는 여유, 응답은 느림대기가 원인 — DB · 외부 API · 락 · IO
메모리 부족 · 스왑워커·프로세스 수 과다 · 앱 메모리 누수
디스크 IO 포화로그 과다 · 프록시 임시 파일 · 캐시 쓰기
간헐적 502업스트림 keepalive 불일치 · 앱 재시작 · 커넥션 한계
피크에만 느림커넥션 풀·스레드 풀 포화 · 대기열 적체
가장 흔한 진실 — 웹서버 튜닝으로 해결되는 문제는 생각보다 적습니다. 대부분의 병목은 앱과 DB에 있습니다.
느린 쿼리 하나가 전체 지연을 지배하는 경우가 압도적으로 많으니, 🗄️ DB 가이드의 튜닝 탭을 먼저 확인하는 것이 실제로는 더 빠른 길인 경우가 많습니다.
병목 찾기 — 순서를 지키면 절반은 끝납니다 1 밖에서 재기 — 어디까지 느린가 curl 로 DNS · 연결 · TLS · 첫 바이트 시간을 나눠 재면 구간이 바로 보입니다 2 서버 자원 확인 CPU · 메모리 · 디스크 · 네트워크. 여기가 한계면 튜닝이 아니라 증설이 답입니다 3 웹서버 지표 — 대기가 있는가 활성 연결 · 대기 중인 연결 · 파일 디스크립터. 로그에 처리 시간을 남겨 두세요 4 앱 지표 — 무엇을 기다리는가 DB 쿼리 · 외부 API · 커넥션 풀 대기. 대부분의 병목이 이 셋 안에 있습니다 5 하나만 바꾸고 다시 재기 여러 개를 동시에 바꾸면 무엇이 효과였는지 영영 알 수 없습니다 CPU 가 놀고 있는데 느리다면 거의 항상 어딘가에서 기다리고 있는 것입니다
순서를 건너뛰면 엉뚱한 곳을 튜닝하게 됩니다.
SECTION 14

성능 체크리스트

배포 전에 한 번씩 훑어보세요.

분류확인 항목
기본 worker_processes auto · worker_connections 4096 이상
sendfile on · tcp_nopush on
keepalive_timeout 설정 (클라이언트 쪽)
업스트림 keepalive + HTTP/1.1 + Connection ""
정적 웹서버가 직접 서빙 (앱을 거치지 않음)
해시 파일 1년 immutable · HTML 캐시 금지
압축 활성 (레벨 5~6) · 이미지·동영상은 제외
프록시 버퍼링 켜짐 (스트리밍 엔드포인트만 예외)
타임아웃이 앱 처리 시간에 맞게 설정됨
필요하면 proxy_cache — 개인화 응답은 제외
OS LimitNOFILE 이 systemd 유닛에 설정됨
somaxconn 과 Nginx backlog 가 맞음
스왑이 발생하지 않음
관측 로그에 $request_time·$upstream_response_time 포함
p95·p99 를 대시보드에서 볼 수 있음
부하 테스트로 한계 용량을 숫자로 알고 있음

효과가 큰 순서

🟢 즉시 · 큰 효과
  • 정적 파일 캐시 헤더
  • 압축 활성화
  • 업스트림 keepalive
  • 정적 파일을 앱에서 분리
  • 느린 DB 쿼리 개선
🔵 설계가 필요
  • 응답 캐시(proxy_cache)
  • CDN 도입
  • 앱 서버 수평 확장
  • 이미지 최적화(WebP·AVIF)
  • 커넥션 풀 재조정
🟣 마지막에
  • 커널 파라미터 튜닝
  • HTTP/3 도입
  • 하드웨어 증설
  • 아키텍처 변경
  • ※ 앞의 것을 다 하고 나서
최적화의 원칙 — 측정하지 않은 최적화는 최적화가 아닙니다. 개선 전후를 같은 방법으로 측정해 숫자로 증명하세요. 효과가 없다면 되돌리는 것도 개선입니다 — 불필요한 복잡도가 줄어드니까요.
LOAD BALANCING · NGINX · HAPROXY · 무중단 배포 · 이중화

로드밸런싱 —
한 대에서
여러 대로

서버 한 대로 버티던 서비스가 커지면 여러 대에 트래픽을 나누는 순간이 옵니다. 로드밸런서는 트래픽을 나누는 것뿐 아니라 죽은 서버를 숨기고, 배포 중에도 서비스를 살려 두는 핵심 장비입니다. 개념부터 Nginx·HAProxy 실전 구성, 이중화까지 순서대로 갑니다.

12SECTIONS
2+서버면 시작
0초배포 중단 목표
SECTION 01

왜 필요한가

서버 한 대의 한계는 반드시 옵니다. 문제는 어떻게 늘리느냐입니다.

수직 확장 (Scale-Up) — 더 좋은 한 대
CPU 4코어 → 32코어, RAM 8GB → 256GB

장점  코드·구조 변경이 전혀 없다
한계  ① 가격이 선형이 아니라 폭증한다
      ② 아무리 커도 상한이 있다
      ③ 그 한 대가 죽으면 → 전체 장애
      ④ 업그레이드하려면 → 내려야 한다
수평 확장 (Scale-Out) — 보통 여러 대
서버 1대 → 같은 앱을 3대, 5대, 10대로

장점  ① 값싼 서버를 필요한 만큼 추가
      ② 한 대가 죽어도 나머지가 받는다
      ③ 한 대씩 빼서 배포 → 무중단
전제  ★ 트래픽을 나눠 줄 무언가가 필요
      → 그것이 로드밸런서입니다

로드밸런서가 실제로 해 주는 세 가지

역할의미
① 분산 요청을 여러 서버에 나눠 한 대의 처리량 한계를 넘습니다
② 장애 숨김 죽은 서버를 자동으로 빼서 사용자는 장애를 모르게 합니다 (헬스체크 · b06)
③ 무중단 배포 한 대씩 빼서 새 버전을 올립니다 — 배포 중에도 서비스가 살아 있습니다 (b09)
이미 절반은 배웠습니다. 리버스 프록시(🧭 시작 w10)가 뒤쪽 서버를 여러 대로 늘린 것이 로드밸런서입니다. Nginx 를 쓰고 있다면 설정 몇 줄로 로드밸런서가 됩니다(b03).
수평 확장에는 숙제가 하나 따라옵니다 — 상태(state). 로그인 세션·업로드 파일이 서버 한 대의 메모리·디스크에 있으면, 다음 요청이 다른 서버로 가는 순간 사라진 것처럼 보입니다. 이 문제와 해법은 b05에서 자세히 다룹니다.
같은 요청 8개를 3대에 나눌 때 — 알고리즘이 만드는 차이 라운드 로빈 — 순서대로 A: 1,4,7 B: 2,5,8 C: 3,6 A 에 무거운 요청이 겹치면 A 만 밀립니다 최소 연결 — 지금 한가한 곳으로 처리 중 개수를 보고 배정 무거운 요청이 어디로 가든 편차를 스스로 흡수합니다 IP 해시 — 같은 사람은 같은 서버 세션·캐시가 서버에 있을 때 대신 서버 대수가 바뀌면 배정이 뒤섞입니다 IP 해시의 함정 — 서버를 3대에서 4대로 늘렸을 때 단순 해시(id % 서버수): 사용자의 약 75% 가 다른 서버로 이동 → 세션 대량 소실 · 캐시 전부 미스 일관된 해싱(consistent hash): 약 25% 만 이동 → Nginx 의 hash ... consistent 옵션 기본은 최소 연결(least_conn)입니다 — 요청 시간이 제각각인 실제 웹에서 가장 무난하게 동작합니다. 해시 계열은 "세션을 서버에 두고 있어서" 쓰는 경우가 대부분인데, 그 상태를 밖으로 빼는 편이 근본 해결입니다.
알고리즘 선택으로 상태 문제를 우회하려 하면 서버를 늘리거나 줄일 때마다 대가를 치릅니다 — 무상태가 먼저입니다.

알고리즘 고르기 — 판단표

알고리즘언제주의
round_robin
(기본)
요청 처리 시간이 대체로 균일느린 요청이 섞이면 기울어집니다
least_conn대부분의 웹·API — 기본 선택서버 성능이 다르면 weight 를 함께
ip_hash세션이 서버에 있을 때(임시방편)NAT 뒤 사용자가 한 서버로 몰립니다
hash ... consistent캐시 서버 · 샤딩된 백엔드키 선택이 중요합니다
random two서버가 아주 많을 때(수십 대+)둘을 뽑아 덜 바쁜 쪽 — 쏠림이 적습니다
least_time
(Plus·OpenResty)
응답 시간 편차가 클 때오픈소스 Nginx 에는 없습니다
upstream backend {
    least_conn;                          # ★ 고민되면 이것

    server 10.0.1.11:8080 weight=2;      # 사양이 두 배면 가중치도 두 배
    server 10.0.1.12:8080;
    server 10.0.1.13:8080;

    server 10.0.1.14:8080 backup;        # 위가 전부 죽었을 때만
    # server 10.0.1.15:8080 down;        # 점검 중 — 설정에서 지우지 말고 표시

    keepalive 64;                        # 백엔드 연결 재사용
                                         # (location 에 proxy_http_version 1.1 + Connection "" 필요 · b12)
}

# 일관된 해싱 — HTTP 캐시 서버(Varnish 등) 앞이라면
upstream cache_nodes {
    hash $request_uri consistent;        # 같은 URL 은 항상 같은 노드로
    server 10.0.2.11:6081;
    server 10.0.2.12:6081;
    server 10.0.2.13:6081;
}
ip_hash 로 세션 문제를 덮는 것은 부채를 미루는 것입니다. ① 모바일 사용자는 IP 가 수시로 바뀌어 로그아웃됩니다 ② 회사·통신사 NAT 뒤의 수백 명이 한 서버에 몰립니다 ③ 서버를 늘리거나 빼면 세션이 대량으로 사라집니다 ④ 무중단 배포에서 드레인이 제 역할을 못 합니다
세션을 Redis 로 옮기는 작업은 대개 반나절이면 끝납니다(b05) — 그 뒤로는 이 네 가지가 전부 사라집니다.
SECTION 02

큰 그림 — L4 와 L7

로드밸런서가 어디에 서고, 무엇을 보고 나누는지가 종류를 가릅니다.

사용자
  │  example.com
  ▼
[ DNS ]  ─ 도메인 → 로드밸런서의 IP
  │
  ▼
┌─────────────────────┐
│    로드밸런서        │   ★ 유일한 공개 진입점
│  (Nginx·HAProxy·ALB) │      TLS 종료 · 헬스체크 · 분산
└──────┬──────────────┘
       │  어느 서버로 보낼까? (알고리즘 · b04)
   ┌───┼───────┐
   ▼   ▼       ▼
 [앱1] [앱2]  [앱3]     ← 같은 앱 여러 대 (사설망 · 직접 노출 안 함)
   └───┴───┬───┘
           ▼
        [ DB · Redis ]  ← 상태는 공유 저장소에 (b05)

L4 vs L7 — 무엇을 보고 나누나

택배에 비유하면 — L4 는 소포의 겉면(주소·포트)만 보고 어느 창구로 보낼지 정하고, L7 은 소포를 열어 내용물(HTTP 경로·헤더·쿠키)까지 보고 정합니다.

항목L4 (전송 계층)L7 (애플리케이션 계층)
보는 것IP · 포트 (TCP/UDP)HTTP 경로 · 호스트 · 헤더 · 쿠키
할 수 있는 일연결 단위 분산/api 는 A 그룹, /static 은 B 그룹처럼 내용 기반 라우팅
TLS통과시킴 (내용을 못 봄)종료 가능 → 헤더 조작·캐시·압축 가능 (b07)
속도더 빠름 (패킷만 넘김)파싱 비용이 있지만 충분히 빠름
대표 제품NLB · LVS · HAProxy(tcp 모드)Nginx · HAProxy(http 모드) · ALB · Caddy
웹 서비스는 대부분 L7 로 시작하면 됩니다. 경로별 라우팅·헤더 추가·쿠키 기반 세션 유지가 전부 L7 기능입니다. L4 는 HTTP 가 아닌 트래픽(DB·게임 서버·MQTT)이나 극한의 성능이 필요할 때 선택합니다. 둘을 겹쳐 쓰기도 합니다 — 앞에 L4(NLB), 뒤에 L7(Nginx).

"DNS 라운드로빈으로 되지 않나요?"

# 한 도메인에 A 레코드를 여러 개 등록하는 방법
example.com  →  10.0.0.11
example.com  →  10.0.0.12
example.com  →  10.0.0.13
DNS 라운드로빈만으로는 부족합니다.
① 죽은 서버를 모릅니다 — 10.0.0.12 가 죽어도 1/3 의 사용자는 계속 그리 갑니다
② 캐시를 통제할 수 없습니다 — 통신사·브라우저가 제각각 캐시합니다
③ 분배 비율을 클라이언트가 결정합니다 — 균등을 보장하지 못합니다
그래서 DNS 라운드로빈은 로드밸런서 자체를 여러 개 두는 최상단(b10)에서 보조 수단으로 쓰고, 실제 분산은 로드밸런서가 맡는 것이 표준입니다.
L4 와 L7 — 어느 계층에서 판단하는가 L4 — IP 와 포트만 봅니다 내용을 해석하지 않고 그대로 넘깁니다 아주 빠르고 자원을 적게 씁니다 HTTP 가 아니어도 됩니다 (DB · 게임 · MQTT) 경로나 헤더로 나눌 수는 없습니다 HTTPS 는 뒤쪽에서 끝냅니다 L7 — HTTP 내용을 읽습니다 경로별로 다른 서버에 보낼 수 있습니다 헤더 추가 · 재작성 · 캐시가 됩니다 HTTPS 를 여기서 끝낼 수 있습니다 해석 비용이 조금 듭니다 Nginx · HAProxy · ALB 가 여기 함께 쓰는 구성도 흔합니다 — 앞에 L4 로 받아 뒤의 L7 여러 대에 나눠 줍니다 L4 는 아주 큰 트래픽을 받고, L7 은 세밀한 라우팅을 맡습니다 웹 서비스라면 대개 L7 로 시작하면 됩니다 — 경로별 분기와 HTTPS 종료가 바로 필요해집니다
고르는 기준은 성능이 아니라 내용을 보고 판단할 일이 있는가입니다.
SECTION 03

첫 로드밸런싱 — 10분 따라하기

서버 한 대 안에서 앱 2개 + Nginx 로 로드밸런싱을 직접 눈으로 확인합니다.

① 응답이 서로 다른 앱 두 개 띄우기

# 실습용 — 어느 서버가 응답했는지 보이는 초간단 앱 2개
mkdir -p /tmp/app1 /tmp/app2
echo 'server-1' > /tmp/app1/index.html
echo 'server-2' > /tmp/app2/index.html

python3 -m http.server 8001 --directory /tmp/app1 &
python3 -m http.server 8002 --directory /tmp/app2 &

# 각각 따로 확인
curl -s http://127.0.0.1:8001/   # server-1
curl -s http://127.0.0.1:8002/   # server-2

② upstream — 서버 그룹 정의

# /etc/nginx/sites-available/lb-test
upstream demo_pool {                # ★ 서버 그룹에 이름을 붙입니다
    server 127.0.0.1:8001;
    server 127.0.0.1:8002;
    # 실전에서는 사설 IP — server 10.0.0.11:8080; 처럼 씁니다
}

server {
    listen 8080;

    location / {
        proxy_pass http://demo_pool;          # ★ 그룹 이름으로 프록시
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
sudo ln -s /etc/nginx/sites-available/lb-test /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

③ 분산 확인 — 번갈아 나오면 성공

for i in $(seq 1 6); do curl -s http://127.0.0.1:8080/; done
server-1 server-2 server-1 server-2 server-1 server-2 ← 기본 알고리즘(라운드로빈)이 정확히 번갈아 보냅니다

④ 한 대를 죽여 보기 — 장애 숨김 체험

# server-1 을 강제 종료
kill %1

for i in $(seq 1 4); do curl -s http://127.0.0.1:8080/; done
server-2 server-2 server-2 server-2 ← 죽은 서버를 빼고 모두 server-2 로. 사용자는 장애를 모릅니다
지금 일어난 일 — 첫 요청이 8001 에서 실패하자 Nginx 가 즉시 다음 서버로 재시도(기본 동작)하고, 8001 을 일정 시간 제외했습니다. 이 판정 규칙(max_fails·fail_timeout)은 b06 에서 정밀하게 다룹니다. 실험이 끝나면 kill %2 로 나머지 앱도 정리하세요.
SECTION 04

분산 알고리즘 고르기

아래 시뮬레이터에서 알고리즘을 바꿔 가며 요청을 보내 보세요. 분배가 어떻게 달라지는지 보입니다.

분산 시뮬레이터 — 서버 3대 (③번은 처리가 느린 서버)
알고리즘동작 · 언제 쓰나
라운드로빈 (기본) 순서대로 한 번씩. 서버 성능이 같고 요청 처리 시간이 비슷하면 이것으로 충분합니다
weight (가중치) 성능이 다른 서버 혼용 시 — weight=3 인 서버가 3배 받습니다
least_conn ★ 현재 연결이 가장 적은 서버로. 파일 업로드·리포트 생성처럼 요청마다 처리 시간이 들쭉날쭉하면 이것으로 바꾸세요
ip_hash 같은 IP 는 항상 같은 서버로. 세션 유지용이지만 임시방편입니다 (b05)
hash $키 임의 키(URL·헤더) 기준 고정. consistent 를 붙이면 서버 증감 시 재배치를 최소화 — 캐시 서버 분산에 씁니다 (🧬 전문가 z11)
random 무작위. random two least_conn; — 둘을 뽑아 연결 적은 쪽으로 (대규모에서 유용)
upstream backend {
    least_conn;                          # 알고리즘은 첫 줄에 (기본은 라운드로빈)

    server 10.0.0.11:8080 weight=3;      # 성능 좋은 새 서버 — 3배 받기
    server 10.0.0.12:8080 weight=2;
    server 10.0.0.13:8080;               # weight 생략 = 1
    server 10.0.0.14:8080 backup;        # ★ 평소엔 안 쓰고, 전원 장애 시에만
    server 10.0.0.15:8080 down;          # 점검 중 — 목록엔 두고 제외
}
ip_hash 의 함정 두 가지
① IPv4 는 앞 세 옥텟만 해시합니다 — 같은 회사·통신사 대역이 몽땅 한 서버로 몰릴 수 있습니다
② 회사 방화벽·모바일 통신사 뒤의 수천 명이 같은 공인 IP 로 나옵니다 — 분산이 심하게 기웁니다
세션 때문에 ip_hash 를 고려한다면 먼저 b05 를 읽어 보세요.
실무 요약 — 라운드로빈으로 시작 → 응답 시간 편차가 크면 least_conn → 서버 사양이 다르면 weight 병용. 분배가 잘 되는지는 접속 로그의 $upstream_addr(🟩 Nginx n17)로 확인합니다.
SECTION 05

세션 문제 — "로그인이 풀려요"

서버를 2대로 늘린 날부터 생기는 가장 흔한 사고입니다. 원인은 단순합니다.

1. 사용자가 로그인 → 요청이 [앱1] 로 → 세션이 앱1 메모리에 저장
2. 다음 요청이 [앱2] 로 분산됨
3. 앱2: "세션? 그런 거 없는데요" → 로그인 화면으로 튕김 ✗

문제의 본질: 상태(세션)가 서버 한 대에 갇혀 있다
임시방편 — 세션 고정 (sticky)
# 같은 사용자를 같은 서버로 계속 보낸다
upstream backend {
    ip_hash;                # IP 기준 고정
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

# 한계
# · 그 서버가 죽으면 → 그 사용자들 세션 전멸
# · 분산이 기운다 (b04 의 ip_hash 함정)
# · 서버를 빼는 순간 세션 소실 → 무중단 배포 방해
# · 쿠키 기반 sticky 는 Nginx Plus·HAProxy 기능
✓ 근본 해결 — 세션을 밖으로
# 세션을 모든 서버가 공유하는 저장소로
[앱1] [앱2] [앱3]
   \    |    /
    ▼   ▼   ▼
   [ Redis ]  ← 세션은 여기에

· 어느 서버로 가든 같은 세션을 본다
· 서버 추가·제거가 자유로워진다
· 앱 재시작에도 로그인이 유지된다

Spring   spring-session-data-redis
Django   SESSION_ENGINE = "...cached_db"
Express  connect-redis
PHP      session.save_handler = redis

아예 세션을 없애는 선택 — 토큰 (JWT)

방식특징
서버 세션 + Redis ★ 전통적·안전. 즉시 강제 로그아웃 가능. Redis 운영이 추가됩니다
JWT (무상태 토큰) 상태 저장이 아예 없어 수평 확장이 가장 쉽습니다. 대신 발급한 토큰을 즉시 무효화하기 어렵다는 대가가 있습니다 (만료를 짧게 + 갱신 토큰으로 보완)
세션 말고도 서버에 갇히는 상태가 있습니다.
· 업로드 파일 — 로컬 디스크에 저장하면 다른 서버에서 안 보입니다 → S3 같은 오브젝트 스토리지 또는 공유 볼륨으로
· 로컬 캐시·인메모리 큐 — 서버마다 내용이 달라집니다 → Redis 로
서버를 늘리기 전에 "이 앱이 로컬에 쌓는 것이 무엇인가"를 먼저 점검하세요.
순서 제안 — ① 세션을 Redis 로 옮기고 ② 업로드를 오브젝트 스토리지로 빼면 앱이 무상태(stateless)가 됩니다. 그다음부터 서버 수는 그냥 숫자일 뿐입니다. 쿠버네티스·오토스케일링도 이 전제 위에서 동작합니다.
"로그인이 자꾸 풀려요" — 세션이 한 대에만 있어서입니다 무슨 일이 벌어지나 서버 A 에 로그인해 세션이 A 의 메모리에 생겼는데, 다음 요청이 서버 B 로 갑니다 B 는 그 세션을 모르므로 로그인하지 않은 사용자로 봅니다 ① 같은 서버로 고정 IP 나 쿠키로 붙여 둡니다 가장 빨리 해결됩니다 그 서버가 죽으면 다 풀립니다 ② 공용 저장소에 두기 Redis 에 세션을 저장합니다 어느 서버로 가도 됩니다 가장 흔한 선택입니다 ③ 서버가 안 들기 토큰에 담아 보냅니다 (JWT) 저장소가 필요 없습니다 강제 로그아웃이 어렵습니다 서버 고정은 임시 처방입니다 — 배포로 서버를 교체할 때마다 사용자가 로그아웃됩니다 세션 말고도 서버 메모리에 둔 것이 있는지 확인하세요 — 캐시 · 업로드 임시 파일도 같은 문제입니다 한 대일 때는 안 보이던 문제가 두 대가 되는 순간 전부 드러납니다
수평 확장의 전제 조건은 대수가 아니라 서버가 상태를 들지 않는 것입니다.
SECTION 06

헬스체크 — 죽은 서버 빼기

로드밸런서의 진짜 가치는 분산보다 장애를 숨기는 것에 있습니다.

방식동작
수동적 (passive) 실제 사용자 요청이 실패하는 것을 보고 제외 — Nginx 오픈소스의 기본 방식. 공짜지만 최소 한 명은 실패를 겪습니다 (재시도로 가려 줌)
능동적 (active) ★ 주기적으로 /health 를 미리 찔러보고 제외 — 사용자가 실패를 만나기 전에 뺍니다. HAProxy·Caddy·클라우드 LB 기본, Nginx 는 Plus 기능

Nginx 수동적 헬스체크 — 정확히 이해하기

upstream backend {
    # 10초(fail_timeout) 안에 3번(max_fails) 실패하면
    # → 그 서버를 10초간(같은 fail_timeout) 제외
    server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
}

server {
    location / {
        proxy_pass http://backend;

        # '실패'로 칠 조건과 다음 서버 재시도 규칙
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;         # 최대 2대까지 시도
        proxy_next_upstream_timeout 10s;     # 재시도에 쓸 총 시간

        proxy_connect_timeout 3s;            # 연결 자체는 빨리 포기
    }
}
재시도가 항상 안전한 것은 아닙니다. proxy_next_upstream 은 기본적으로 POST 같은 비멱등 요청을 timeout 시 재시도하지 않습니다 — 결제가 두 번 나가는 사고를 막기 위해서입니다. non_idempotent 옵션으로 풀 수 있지만, 그 API 가 두 번 실행돼도 안전한지(멱등 키 등) 확인한 뒤에만 쓰세요.

능동적 헬스체크 — HAProxy · Caddy

HAProxy
backend app
    option httpchk GET /health
    http-check expect status 200
    default-server inter 3s fall 3 rise 2
    #  inter 3s : 3초마다 검사
    #  fall 3   : 3연속 실패 → 제외
    #  rise 2   : 2연속 성공 → 복귀
    server app1 10.0.0.11:8080 check
    server app2 10.0.0.12:8080 check
Caddy
reverse_proxy 10.0.0.11:8080 10.0.0.12:8080 {
    health_uri      /health
    health_interval 3s
    health_timeout  2s
    health_status   200
    # 능동 헬스체크가 오픈소스에 기본 내장
}

/health 엔드포인트는 어떻게 만드나

수준내용
liveness (살았나) ★ 프로세스가 응답하는지만 — DB 를 건드리지 않는 가벼운 200 응답. LB 헬스체크는 보통 이것이면 충분합니다
readiness (받을 수 있나) DB·캐시 연결까지 확인. 무겁고, DB 순단 시 전 서버가 동시에 제외되는 위험이 있어 신중하게 설계합니다 (🧬 전문가 z12)
헬스체크 요청은 로그에서 빼세요. 3초마다 × 서버 수만큼 쌓여 로그를 오염시킵니다. Nginx 라면 location /health { access_log off; return 200; } 처럼 앱까지 가지 않고 끊는 방법도 있습니다 — 단, 이건 Nginx 의 생존만 보장할 뿐이니 LB 가 앱을 검사할 때는 앱의 /health 를 쓰는 것이 맞습니다.
헬스체크 — 무엇을 확인할지가 전부입니다 로드밸런서 주기적으로 확인 정상 — 목록에 유지 실패 — 목록에서 제외 회복하면 다시 넣음 연속 N회 성공 기준 너무 얕으면 프로세스만 떠 있으면 정상이라 답합니다 DB 가 끊겨도 트래픽을 계속 받습니다 사용자만 오류를 봅니다 너무 깊으면 검사 자체가 부하가 됩니다 DB 가 잠깐 느려지면 모든 서버가 한꺼번에 빠집니다 권장 — 가벼운 자체 점검(캐시된 상태 확인)으로 하고, 실패 판정은 연속 실패 횟수로 완충하세요 기동 중 검사와 살아있음 검사를 나누면 배포 중 잠깐의 지연으로 서버가 빠지는 일을 막습니다
헬스체크는 감시 장치이자 스스로를 무너뜨릴 수 있는 장치이기도 합니다.
SECTION 07

HTTPS 는 어디서 끝내나

서버가 여러 대가 되면 인증서를 어디에 둘지 결정해야 합니다.

표준 구성 — TLS 종료(termination)를 로드밸런서에서

사용자 ══ HTTPS ══▶ [ 로드밸런서 ] ── HTTP ──▶ [앱1·앱2·앱3]
                    인증서는 여기 한 곳에만!

· 인증서 발급·갱신을 LB 한 곳에서만 관리 (🔐 HTTPS 탭)
· 앱 서버는 TLS 부담이 없어 가볍다
· LB 가 내용을 보므로 L7 라우팅·캐시·압축 가능
대신 두 가지를 반드시 챙겨야 합니다.
① 앱은 자신이 HTTP 로 받았으므로 "지금 HTTPS 접속인가"를 모릅니다 → X-Forwarded-Proto 헤더로 알려 줘야 리다이렉트 루프·secure 쿠키 문제가 없습니다
② LB→앱 구간이 평문입니다 — 반드시 사설망이어야 하고, 규정상 내부도 암호화해야 하면 재암호화(아래)를 씁니다
# Nginx 를 LB 로 쓸 때 — 앱에 원본 정보를 전달
server {
    listen 443 ssl;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;    # ★ https 임을 전달
    }
}

세 가지 방식 비교

방식설명
TLS 종료 (LB 에서 끝) ★ 표준. 관리 한 곳 · 앱 가벼움. LB→앱은 사설망 전제
재암호화 (종료 후 다시 TLS) 사용자→LB, LB→앱 둘 다 암호화. 내부 암호화 규정(금융·의료)이 있을 때. 내부용 인증서는 사설 CA 로 (🔐 HTTPS s11)
통과 (passthrough) LB 가 TLS 를 풀지 않고 TCP 로 흘려보냄(L4). 앱까지 종단간 암호화되지만 L7 기능(경로 라우팅·헤더)을 전부 포기합니다
TLS 종료 위치별 성능·구조 심화는 🧬 전문가 z06, 다단 프록시에서 실제 방문자 IP 를 지키는 법은 🧬 전문가 z10 에서 이어집니다.
HTTPS 를 어디서 끝낼 것인가 — 세 가지 구성 ① LB 에서 끝내고 뒤는 평문 — 가장 흔합니다 인증서가 한 곳에 모이고, 내용을 볼 수 있어 경로별 분기와 캐시가 됩니다 LB 와 뒤쪽 사이가 신뢰할 수 있는 망일 때 씁니다 ② 그대로 통과 (패스스루) LB 는 내용을 못 봅니다 — IP 와 포트로만 분산합니다 (L4) 인증서를 뒤쪽 서버마다 관리해야 하고 경로별 분기는 못 합니다 ③ 끝내고 다시 암호화 구간 전체 암호화가 요구될 때 — 규제 · 공용 네트워크를 지날 때 인증서가 두 벌이 되고 성능 비용이 조금 듭니다 어느 쪽이든 뒤쪽 앱은 원래 프로토콜과 클라이언트 IP 를 헤더로 받아야 합니다 L4 통과 구성에서는 프록시 프로토콜로 원래 IP 를 전달합니다 — 양쪽 다 켜야 합니다
종료 위치가 정해지면 인증서 관리와 라우팅 능력이 함께 정해집니다.
SECTION 08

HAProxy — 전용 로드밸런서

Nginx 로 시작해도 되지만, 로드밸런싱만큼은 HAProxy 가 더 깊습니다. 능동 헬스체크·정밀한 통계·드레인이 오픈소스에 다 들어 있습니다.

sudo apt install -y haproxy
haproxy -v                                   # 설치 확인
sudo systemctl enable --now haproxy

haproxy.cfg 해부 — 구조는 넷뿐입니다

# /etc/haproxy/haproxy.cfg
global                                # ① 프로세스 전역 (로그·보안·한계치)
    log /dev/log local0
    maxconn 20000
    user haproxy
    group haproxy

defaults                              # ② 아래 모든 블록의 기본값
    mode http                         #    L7 모드 (tcp 로 바꾸면 L4)
    log global
    option httplog
    timeout connect 5s                #    백엔드 연결 수립 제한
    timeout client  30s               #    클라이언트 무응답 제한
    timeout server  30s               #    백엔드 무응답 제한

frontend web_in                       # ③ 받는 곳 — 어디로 듣고 어디로 넘길까
    bind *:80
    # 경로 기반 라우팅 (L7 의 힘)
    acl is_api path_beg /api
    use_backend api_pool if is_api
    default_backend web_pool

backend web_pool                      # ④ 보내는 곳 — 서버 그룹 + 헬스체크
    balance roundrobin                #    leastconn · source 등
    option httpchk GET /health
    http-check expect status 200
    default-server inter 3s fall 3 rise 2
    server web1 10.0.0.11:8080 check
    server web2 10.0.0.12:8080 check

backend api_pool
    balance leastconn
    option httpchk GET /health
    server api1 10.0.0.21:8080 check
    server api2 10.0.0.22:8080 check
# 문법 검사 → 무중단 반영 (nginx -t && reload 와 같은 습관)
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy

stats 페이지 — 상태가 눈에 보인다

listen stats
    bind *:8404
    stats enable
    stats uri /stats
    stats refresh 5s
    stats auth admin:비밀번호변경     # ★ 반드시 변경 + 내부망·IP 제한
# → http://LB주소:8404/stats 에서
#   서버별 상태(UP/DOWN)·연결 수·응답 시간·에러가 실시간으로 보입니다
항목Nginx (오픈소스)HAProxy
능동 헬스체크✗ (Plus 전용)✓ 기본
쿠키 기반 sticky✗ (Plus 전용)✓ 기본 (cookie SRV insert)
상태 대시보드stub_status (숫자 몇 개)stats 페이지 — 서버별 상세
드레인 (서서히 빼기)down + reload 로 대응런타임 API 로 재시작 없이
정적 파일 · 캐시✓ 강함✗ (프록시 전용)
역할 요약웹서버 + 준수한 LBLB 특화 — 그 외엔 관심 없음
고르는 법 — 이미 Nginx 를 쓰고 규모가 작다면 그대로 Nginx 로 충분합니다. 서버가 늘고 능동 헬스체크·정밀 드레인·상태 가시성이 아쉬워지는 시점에 HAProxy 를 앞단에 세우세요. 실무에서는 HAProxy(LB) → Nginx(웹서버) → 앱 처럼 겹쳐 쓰는 구성도 흔합니다.
SECTION 09

무중단 배포 — LB 로 하는 법

로드밸런서가 있으면 배포 중에도 503 이 안 나는 구성이 가능해집니다.

롤링 배포 — 한 대씩 빼서 올린다

1
앱1 을 분리 (drain) 새 요청은 안 보내고, 처리 중인 요청은 끝나게 둡니다.
▼
2
앱1 에 새 버전 배포 → 자체 확인 LB 를 거치지 않고 curl http://10.0.0.11:8080/health 로 직접 확인합니다.
▼
3
앱1 복귀 → 앱2 로 반복 전체가 끝날 때까지 사용자는 구버전 또는 신버전 중 하나를 항상 받습니다.
Nginx — down + reload 로 드레인
upstream backend {
    server 10.0.0.11:8080 down;   # ← 배포할 서버에 down
    server 10.0.0.12:8080;
}
# sudo nginx -t && sudo systemctl reload nginx
#
# reload 는 기존 연결을 끊지 않으므로(n18)
# '새 요청 차단 + 진행 중 요청 완료' = 드레인이 됩니다
# 배포 후 down 을 지우고 다시 reload
HAProxy — 재시작 없이 런타임 API
# global 에 stats socket 을 열어 두면
#   stats socket /run/haproxy/admin.sock mode 660 level admin
echo "set server web_pool/web1 state drain" \
  | sudo socat stdio /run/haproxy/admin.sock

# 배포 후 복귀
echo "set server web_pool/web1 state ready" \
  | sudo socat stdio /run/haproxy/admin.sock

블루-그린 배포 — 전체를 통째로 전환

# 구버전(blue)과 신버전(green)을 나란히 띄워 두고 한 번에 전환
upstream blue  { server 10.0.0.11:8080; server 10.0.0.12:8080; }
upstream green { server 10.0.0.21:8080; server 10.0.0.22:8080; }

server {
    location / {
        proxy_pass http://blue;      # ← 이 한 단어를 green 으로 바꾸고 reload
    }
}
# 장점: 전환·롤백이 즉시 (다시 blue 로 reload)
# 대가: 서버가 2배로 필요 (전환하는 동안만)
전략특징
롤링★ 추가 서버 불필요. 배포 중 구·신버전이 공존합니다 — DB 스키마·API 는 하위 호환이어야 안전합니다
블루-그린전환·롤백 즉시. 서버 2배. 공존 시간이 짧아 호환성 부담이 적습니다
카나리신버전에 5% 만 먼저 (weight=1 vs weight=19) → 지표 확인 후 확대. 대규모 서비스의 표준
배포 자동화 전 필수 점검
① 드레인 없이 그냥 껐다면 — 처리 중이던 요청이 그대로 502 가 됩니다
② 새 버전이 뜨자마자 트래픽을 받으면 — 워밍업 전이라 첫 요청들이 느립니다 (헬스체크 통과 후 복귀시키세요)
③ 세션이 서버에 있다면(b05) — 빼는 순간 그 서버 사용자들이 로그아웃됩니다
reload 신호의 내부 동작은 🟩 Nginx n18, 톰캣의 graceful 종료는 🐱 Tomcat t14 참고.
한 대씩 빼고 · 비우고 · 바꾸고 · 넣습니다 로드밸런서 ① 분배에서 제외 서버 A 새 요청이 안 들어옴 ② 드레인 대기 처리 중인 요청만 끝날 때까지 기다림 ③ 교체 · 헬스체크 새 버전 배포 정상 응답 확인까지 ④ 복귀 다시 분배 대상 다음 서버로 ②를 건너뛰면 사용자가 에러를 봅니다 처리 중이던 요청이 중간에 끊깁니다 — 결제 같은 곳이면 문의로 이어집니다 앱이 종료 신호를 받고 정리할 시간을 주세요(graceful) ③의 헬스체크가 진짜여야 합니다 "프로세스가 떴다"가 아니라 "DB 까지 정상"을 보는 경로여야 합니다 — 안 그러면 죽은 서버로 트래픽이 갑니다 한 대가 끝난 뒤 잠깐 지켜보고 다음으로 넘어가세요
무중단의 핵심은 배포 도구가 아니라 드레인과 헬스체크 두 가지입니다.
한 대씩 빼고 · 비우고 · 바꾸고 · 넣습니다 로드밸런서 ① 분배에서 제외 서버 A 새 요청이 안 들어옴 ② 드레인 대기 처리 중인 요청만 끝날 때까지 기다림 ③ 교체 · 헬스체크 새 버전 배포 정상 응답 확인까지 ④ 복귀 다시 분배 대상 다음 서버로 ②를 건너뛰면 사용자가 에러를 봅니다 처리 중이던 요청이 중간에 끊깁니다 — 결제 같은 곳이면 문의로 이어집니다 앱이 종료 신호를 받고 정리할 시간을 주세요(graceful) ③의 헬스체크가 진짜여야 합니다 "프로세스가 떴다"가 아니라 "DB 까지 정상"을 보는 경로여야 합니다 — 안 그러면 죽은 서버로 트래픽이 갑니다 한 대가 끝난 뒤 잠깐 지켜보고 다음으로 넘어가세요
무중단의 핵심은 배포 도구가 아니라 드레인과 헬스체크 두 가지입니다.
SECTION 10

LB 자체가 죽으면? — 이중화

서버를 아무리 늘려도 로드밸런서가 한 대면 거기가 단일 장애점(SPOF)입니다.

keepalived (VRRP) — 가상 IP 로 LB 를 2대로

            사용자는 이 IP 만 안다
                  │
            VIP 10.0.0.100        ← 가상 IP (떠다니는 IP)
             ┌────┴────┐
        ┌────▼───┐ ┌───▼────┐
        │  LB-1  │ │  LB-2  │
        │ MASTER │ │ BACKUP │   ← 평소엔 MASTER 가 VIP 를 소유
        └────────┘ └────────┘
             │  1초마다 "나 살아있어" (VRRP 광고)
             ▼
        MASTER 가 죽으면 BACKUP 이 수 초 안에 VIP 를 가져간다
        → 사용자 입장에선 IP 가 그대로라 아무 일도 없던 것처럼 보인다
# /etc/keepalived/keepalived.conf  (LB-1 · MASTER 쪽)
vrrp_script chk_haproxy {
    script "/usr/bin/killall -0 haproxy"    # 프로세스 생존 확인
    interval 2
    weight -20                              # 죽으면 우선순위를 깎아 강등
}

vrrp_instance VI_1 {
    state MASTER                 # LB-2 는 BACKUP
    interface eth0
    virtual_router_id 51         # ★ 두 대가 반드시 같은 값
    priority 100                 # LB-2 는 90 (낮게)
    advert_int 1
    authentication {
        auth_type PASS
        auth_pass 비밀문자열
    }
    virtual_ipaddress {
        10.0.0.100/24            # ★ 이 VIP 를 DNS 에 등록합니다
    }
    track_script { chk_haproxy }
}
핵심 그림 — DNS 는 VIP 하나만 가리키고, VIP 는 살아 있는 LB 를 따라다닙니다. HAProxy 가 죽어도(track_script), 머신이 통째로 죽어도(광고 중단) 자동 전환됩니다. 같은 L2 네트워크(같은 서브넷)에서 동작하는 방식이라 온프레미스·IDC 구성에서 씁니다.
방법설명
keepalived (VRRP) ★ 온프레미스 표준. VIP 자동 전환 · 수 초 내 복구
DNS 다중 A + 헬스체크 Route 53 등 헬스체크 연동 DNS 로 죽은 LB 를 응답에서 제외 — 지역 간(멀티 리전) 분산에 적합. TTL 만큼 전환이 느립니다
관리형 LB (클라우드) ★ ALB·NLB 는 이중화가 내장돼 있습니다 — 클라우드라면 고민 자체가 사라집니다 (b11)
이중화를 만들었으면 반드시 죽여 보세요. MASTER 의 haproxy 를 systemctl stop haproxy 로 내리고 VIP 가 BACKUP 으로 넘어가는지, 서비스가 몇 초 만에 복구되는지 실측해야 진짜 이중화입니다. 테스트해 본 적 없는 페일오버는 없는 것과 같습니다.
서버를 늘려도 앞단이 한 대면 거기가 급소입니다 ① LB 한 대 — 단일 장애점 LB (죽음) 앱 A 앱 B 앱이 멀쩡해도 아무도 닿지 못합니다 ② 두 대 + 가상 IP LB 1 (주) VIP 보유 LB 2 (대기) 상태만 지켜봄 주가 죽으면 대기가 VIP 를 넘겨받습니다 — 수 초 안에 keepalived(VRRP) 가 이 역할을 합니다 확인해야 진짜입니다 이중화는 평소에 확인할 수 없는 기능입니다 분기에 한 번 주 LB 를 일부러 내려 보세요 넘어가지 않는 설정 오류를 그때 발견하게 됩니다 클라우드라면 대개 필요 없습니다 ALB · NLB 는 그 자체가 여러 대로 운영됩니다 대신 가용 영역을 둘 이상 지정했는지 확인하세요 한 영역에만 대상 그룹을 두면 이중화가 아닙니다
"서버를 늘렸으니 안전하다"가 아니라 가장 앞의 한 점을 보세요.
서버를 늘려도 앞단이 한 대면 거기가 급소입니다 ① LB 한 대 — 단일 장애점 LB (죽음) 앱 A 앱 B 앱이 멀쩡해도 아무도 닿지 못합니다 ② 두 대 + 가상 IP LB 1 (주) VIP 보유 LB 2 (대기) 상태만 지켜봄 주가 죽으면 대기가 VIP 를 넘겨받습니다 — 수 초 안에 keepalived(VRRP) 가 이 역할을 합니다 확인해야 진짜입니다 이중화는 평소에 확인할 수 없는 기능입니다 분기에 한 번 주 LB 를 일부러 내려 보세요 넘어가지 않는 설정 오류를 그때 발견하게 됩니다 클라우드라면 대개 필요 없습니다 ALB · NLB 는 그 자체가 여러 대로 운영됩니다 대신 가용 영역을 둘 이상 지정했는지 확인하세요 한 영역에만 대상 그룹을 두면 이중화가 아닙니다
"서버를 늘렸으니 안전하다"가 아니라 가장 앞의 한 점을 보세요.
SECTION 11

클라우드 로드밸런서

개념은 지금까지 배운 그대로입니다. 이름만 바뀝니다.

이 가이드의 개념AWSGCP · Azure
L7 로드밸런서ALB (Application LB)Application LB · App Gateway
L4 로드밸런서NLB (Network LB)Network LB · Azure LB
upstream (서버 그룹)Target GroupBackend Service · Backend Pool
능동 헬스체크Target Group 의 Health CheckHealth Check (동일 개념)
드레인 (b09)Deregistration delayConnection Draining
TLS 종료 (b07)ACM 인증서를 ALB 에 부착관리형 인증서 부착
세션 고정 (b05)Stickiness (LB 쿠키)Session Affinity

ALB vs NLB — 무엇을 고르나

상황선택
일반 웹 · API★ ALB — 경로·호스트 라우팅, WebSocket, HTTP/2, WAF 연동
고정 IP 가 필요NLB — ALB 는 IP 가 바뀝니다 (NLB 는 EIP 부착 가능)
HTTP 가 아닌 TCP·UDPNLB — 게임 서버 · MQTT · DB 프록시
초저지연 · 초대량NLB — L4 라 오버헤드가 최소
관리형 LB 가 대신 해 주는 것 — 이중화(b10)·스케일링·패치가 전부 내장입니다. 직접 운영할 것은 헬스체크 경로·드레인 시간·타임아웃 값 같은 설정뿐입니다. 반대로 비용이 트래픽에 비례하고, 세부 튜닝(알고리즘 선택 폭 등)은 직접 운영보다 좁습니다.
쿠버네티스라면 — Service(L4)와 Ingress(L7)가 이 탭의 개념을 그대로 구현합니다. Ingress Controller 의 실체가 바로 Nginx·HAProxy 인 경우가 많아, 여기서 배운 것이 그대로 통합니다 (🛡️ 운영 o11).

관리형 LB 에서 처음에 반드시 확인할 값

설정기본값왜 봐야 하나
유휴 타임아웃ALB 60초WebSocket·SSE 가 정확히 60초마다 끊깁니다
드레인 지연300초배포가 느려집니다 — 30~60초로 줄이세요
헬스체크 주기30초죽은 서버를 빼기까지 주기 × 실패 횟수가 걸립니다
정상 판정 횟수5회배포한 서버가 트래픽을 받기까지의 시간
헬스체크 경로// 는 무겁습니다 — 가벼운 /health 로
슬로 스타트꺼짐새 인스턴스에 트래픽을 서서히 — JIT·캐시 예열
교차 영역 분산ALB 켜짐 / NLB 꺼짐NLB 는 AZ 별 인스턴스 수가 다르면 쏠립니다
액세스 로그꺼짐사고 조사에 필수 — S3 로 켜 두세요
기본 유휴 타임아웃이 실시간 기능을 조용히 망가뜨립니다. "Nginx 에서 proxy_read_timeout 을 3600초로 늘렸는데도 60초마다 끊긴다" — 앞단 ALB 가 자르고 있는 것입니다. 경로가 여러 겹이면 가장 짧은 타임아웃이 이깁니다. 클라이언트 → CDN → LB → Nginx → 앱, 이 다섯 곳의 값을 전부 확인하세요.
드레인 지연은 "가장 긴 요청 처리 시간 + 여유" 로 잡으면 됩니다. 대부분의 웹 API 는 30초면 충분합니다. 기본값 300초를 그대로 두면 10대를 배포하는 데 50분이 걸리고, 그러면 팀이 드레인을 꺼 버리게 되어 무중단 배포 자체가 사라집니다. 파일 업로드나 리포트 생성처럼 긴 요청이 있다면 그 경로만 별도 타깃 그룹으로 분리하는 편이 낫습니다.

직접 운영 vs 관리형 — 실제 비교

항목Nginx·HAProxy 직접관리형(ALB 등)
비용인스턴스 비용만시간 + 처리량 과금 — 트래픽이 크면 무시 못 합니다
이중화직접 구성(keepalived VIP)내장
고정 IP가능ALB 는 불가(NLB 는 가능)
세밀한 제어매우 넓음제공하는 옵션 안에서만
설정 반영즉시(reload)수십 초~수 분
패치·업그레이드직접자동
디버깅로그·설정을 직접 봄블랙박스 — 액세스 로그에 의존
WAF·인증 연동모듈·별도 구축클릭 몇 번
둘을 겹쳐 쓰는 구성이 실무에서 가장 흔합니다. 앞에는 관리형 LB(이중화·TLS·WAF를 맡기고), 뒤에는 Nginx(경로 라우팅·캐시·헤더 조작)를 두는 방식입니다. 이때 X-Forwarded-For 가 두 번 거쳐 오므로 set_real_ip_from 에 LB 대역을 반드시 등록해야 앱 로그에 실제 방문자 IP 가 남습니다.
SECTION 12

규모별 구성 레시피

지금 단계에 맞는 구성을 골라 그대로 쓰세요. 과한 구성은 그 자체가 비용입니다.

🟢 1단계 — 서버 1대 (아직 LB 불필요)
  • Nginx(리버스 프록시) + 앱 + HTTPS
  • 지금 할 일: 앱을 무상태로 만들어 두기 (b05)
  • 그래야 2단계 전환이 설정 몇 줄로 끝납니다
🟡 2단계 — 앱 2~5대 (이 탭의 핵심)
  • Nginx upstream + least_conn (b03·b04)
  • 세션 → Redis · 업로드 → 오브젝트 스토리지 (b05)
  • 헬스체크 + 드레인 배포 (b06·b09)
  • TLS 는 LB 에서 종료 (b07)
🔴 3단계 — 그 이상 (고가용성)
  • HAProxy 승격 또는 관리형 LB (b08·b11)
  • LB 이중화 — keepalived VIP (b10)
  • 카나리 배포 + 지표 기반 롤백 (b09)
  • 내부 구조 심화 → 🧬 전문가 z11·z12

배포 전 체크리스트

서버를 늘리기 전에
① 세션·업로드·캐시가 서버 밖으로 나갔는가 (b05)
② /health 엔드포인트가 있는가 (b06)
③ 앱 로그에 실제 방문자 IP 가 남는가 — X-Forwarded-For (b07 · 🟩 n08)
LB 를 세운 뒤에
④ 서버 한 대를 죽여도 서비스가 사는가 (b03 실험을 실전에서)
⑤ 드레인 배포가 실제로 무중단인가 — 배포 중 curl 루프로 확인 (b09)
⑥ 분배가 기울지 않는가 — 접속 로그의 $upstream_addr 집계
⑦ LB 자체의 장애 대책이 있는가 (b10)
다음 갈 곳 — 알고리즘·일관된 해싱의 내부 원리는 🧬 전문가 z11, 서킷 브레이킹·장애 전파 차단은 🧬 전문가 z12, 성능 관점의 커널·커넥션 튜닝은 ⚡ 성능 탭에서 이어집니다.

2단계 구성 — 복사해서 쓰는 완성 설정

# /etc/nginx/nginx.conf — 앱 3대를 받는 최소 완성형
upstream app {
    least_conn;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=10s;
    server 10.0.1.13:8080 max_fails=3 fail_timeout=10s;
    keepalive 64;
}

server {
    listen 443 ssl default_server;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # LB 자신의 헬스체크 (감시 도구용)
    location = /lb-health { return 200 "ok\n"; access_log off; }

    location / {
        proxy_pass http://app;

        proxy_http_version 1.1;
        proxy_set_header Connection "";              # ★ keepalive 유지
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 한 서버가 실패하면 다음 서버로 — 무중단의 핵심입니다
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 5s;

        proxy_connect_timeout 3s;
        proxy_read_timeout   60s;
        proxy_send_timeout   60s;
    }
}

server {
    listen 80 default_server;
    return 301 https://$host$request_uri;
}
proxy_next_upstream 에 non_idempotent 를 함부로 넣지 마세요. 기본적으로 Nginx 는 POST·PATCH 를 재시도하지 않습니다 — 옳은 동작입니다. 이 옵션을 켜면 결제나 주문이 두 번 처리될 수 있습니다. "가끔 주문이 두 건 들어온다"는 사고의 원인이 여기인 경우가 실제로 있습니다. 재시도가 필요하면 앱에 멱등 키를 붙이는 것이 정답입니다.

확인 절차 — 정말 되는지 직접 봅니다

# ① 분배가 실제로 되는가 — 백엔드별 카운트
for i in $(seq 1 60); do curl -s https://example.com/whoami; done | sort | uniq -c

# ② 한 대를 죽여도 서비스가 사는가 (가장 중요한 시험)
ssh app-11 'sudo systemctl stop myapp'
while true; do curl -s -o /dev/null -w "%{http_code} " https://example.com/; sleep 0.3; done
#   → 200 이 계속 나와야 정상. 502 가 몇 번 섞이면 max_fails·헬스체크를 조정하세요.

# ③ 배포가 무중단인가 — 배포하면서 이 루프를 돌려 두세요
while true; do
  curl -s -o /dev/null -w "%{http_code} %{time_total}\n" https://example.com/
  sleep 0.2
done | tee deploy-check.log
#   끝난 뒤: grep -v "^200" deploy-check.log   ← 비어 있어야 무중단입니다

# ④ 분배가 기울지 않는가 — 접근 로그 집계
awk '{print $NF}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
#   (로그 포맷에 $upstream_addr 을 넣어 두어야 합니다)

# ⑤ 실제 방문자 IP 가 앱까지 가는가
curl -s https://example.com/debug/ip     # 앱이 X-Forwarded-For 를 읽어 반환하게
②번 시험을 실제로 해 본 팀과 안 해 본 팀의 차이가 큽니다. "로드밸런서를 세웠으니 한 대가 죽어도 괜찮겠지"는 가정이고, systemctl stop 을 눌러 보는 것은 확인입니다. 헬스체크 경로가 틀렸거나, max_fails 가 너무 커서 몇 분간 502 가 나가는 구성이 의외로 많습니다. 운영에 올리기 전에 스테이징에서 한 번, 올린 뒤에 한가한 시간에 한 번 해 보세요.
SECURITY HEADERS · WAF · 로그 · 모니터링 · 무중단 배포

보안과 운영 —
돌아가게
유지하기

서버를 띄우는 것보다 계속 안전하게 돌아가게 하는 것이 어렵습니다. 설정 몇 줄로 막을 수 있는 공격이 많고, 로그·모니터링 없이는 장애가 나도 원인을 못 찾습니다. 실무에서 실제로 필요한 것만 정리합니다.

14SECTIONS
7보안 헤더
체크리스트 포함
SECTION 01

보안 헤더 7종

브라우저에게 "이렇게 지켜 줘"라고 부탁하는 한 줄들입니다. 설정만으로 여러 공격의 성공률을 크게 낮춥니다.

# Nginx — server 블록에 (또는 snippets 로 빼서 include)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options    "nosniff" always;
add_header X-Frame-Options           "SAMEORIGIN" always;
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
add_header Permissions-Policy        "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy   "default-src 'self'; img-src 'self' data: https:; frame-ancestors 'self'" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;

# 서버 정보 숨기기
server_tokens off;
헤더막는 것
Strict-Transport-Security HTTP 로 접속하려는 시도 자체를 브라우저가 차단 (SSL 스트리핑)
X-Content-Type-Options 브라우저의 MIME 타입 추측 — 업로드된 텍스트가 스크립트로 실행되는 것을 막습니다
X-Frame-Options 클릭재킹 — 우리 사이트를 남의 iframe 에 넣어 클릭을 가로채는 공격
Referrer-Policy 외부 사이트로 이동할 때 우리 URL(토큰이 포함될 수 있음)이 새는 것
Permissions-Policy 위치·카메라·마이크 등 브라우저 기능 접근 제한
Content-Security-Policy XSS 방어의 핵심 — 어디서 온 스크립트를 실행할지 통제
Cross-Origin-Opener-Policy 다른 출처 창과의 격리 (일부 사이드채널 공격 방어)
always 를 빠뜨리지 마세요. 없으면 2xx·3xx 응답에만 헤더가 붙고 4xx·5xx 오류 페이지에는 빠집니다. 오류 페이지도 공격 대상이 될 수 있으므로 모든 응답에 적용돼야 합니다.
또 Nginx 의 add_header 는 하위 블록에서 하나라도 쓰면 상위의 것이 전부 무효가 됩니다 — location 안에서 헤더를 추가할 때는 상위 헤더를 다시 써 주거나 include 로 관리하세요.

CSP — 가장 강력하지만 가장 까다롭습니다

# ① 먼저 리포트 전용으로 시작 (차단하지 않고 위반만 수집)
add_header Content-Security-Policy-Report-Only
  "default-src 'self'; report-uri /csp-report" always;

# ② 수집된 위반을 보고 정책을 다듬은 뒤
#    (읽기 쉽게 줄을 나눴지만, 값 안의 줄바꿈은 헤더에 그대로 들어가므로 실제로는 한 줄로)
add_header Content-Security-Policy
  "default-src 'self';
   script-src 'self' https://cdn.example.com;
   style-src  'self' 'unsafe-inline';
   img-src    'self' data: https:;
   font-src   'self' data:;
   connect-src 'self' https://api.example.com;
   frame-ancestors 'self';
   base-uri 'self';
   form-action 'self'" always;
CSP 를 처음부터 강하게 걸면 사이트가 깨집니다.
① Report-Only 로 시작해 실제 위반을 수집하고
② 필요한 출처를 하나씩 허용한 뒤
③ 확신이 섰을 때 실제 정책으로 전환하세요
'unsafe-inline'·'unsafe-eval' 은 CSP 의 효과를 크게 떨어뜨리므로 가능하면 nonce·hash 방식으로 옮기세요.
# Apache 는 mod_headers 로 같은 일을
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header unset Server
ServerTokens Prod
ServerSignature Off
보안 헤더 — 앞단에서 한 번에 붙이면 됩니다 Strict-Transport-Security 앞으로 항상 HTTPS 로만 접속하게 합니다 Content-Security-Policy 어떤 출처의 스크립트를 실행할지 제한 — XSS 피해를 줄입니다 X-Frame-Options · frame-ancestors 다른 사이트가 내 페이지를 겹쳐 놓는 것을 막습니다 X-Content-Type-Options: nosniff 파일 형식을 브라우저가 마음대로 추측하지 않게 합니다 Referrer-Policy · Permissions-Policy 어디서 왔는지 얼마나 알릴지 · 카메라 · 위치 권한 제한 CSP 는 처음부터 강하게 걸면 사이트가 깨집니다 — 보고 전용 모드로 시작하세요 HSTS 는 되돌리기 어렵습니다 — HTTPS 가 확실히 자리 잡은 뒤에 기간을 늘리세요 지금 어떤 헤더가 붙고 있는지는 브라우저 개발자 도구나 온라인 검사 도구로 확인할 수 있습니다
설정 몇 줄로 웹 취약점의 상당 부분을 어렵게 만들 수 있습니다.
SECTION 02

정보 노출 줄이기

공격자에게 정보를 주지 마세요. 정찰 단계를 어렵게 만듭니다.

# 지금 무엇이 노출되고 있는지 확인
curl -I https://example.com
HTTP/2 200 server: nginx/1.24.0 ← 버전 노출 (알려진 취약점을 바로 찾을 수 있습니다) x-powered-by: Express ← 프레임워크 노출 x-aspnet-version: 4.0.30319 ← 런타임 버전까지
# Nginx
server_tokens off;                          # "nginx/1.24.0" → "nginx"
proxy_hide_header X-Powered-By;             # 백엔드가 보낸 헤더 제거
proxy_hide_header X-AspNet-Version;
proxy_hide_header Server;

# 디렉터리 목록 금지 (기본값이지만 확인)
autoindex off;

# 숨김 파일·백업 파일 차단
location ~ /\.(?!well-known) { deny all; }
location ~* \.(bak|old|sql|env|log|conf|ini|swp)$ { deny all; }
location ~* /(\.git|\.svn|\.env|composer\.(json|lock)|package-lock\.json)$ { deny all; }
실제로 자주 유출되는 것들
· /.git/ — 소스 코드 전체와 커밋 기록이 복원됩니다. 스캐너가 가장 먼저 찾습니다
· /.env — DB 비밀번호·API 키
· /backup.sql · /dump.sql — DB 전체
· /phpinfo.php — 서버 설정 전부
· /server-status · /nginx_status — 실시간 요청 정보
· 소스맵(.js.map) — 원본 코드
# 우리 서버가 노출하고 있는지 직접 확인
for p in /.git/config /.env /backup.sql /phpinfo.php /server-status /.svn/entries; do
  code=$(curl -s -o /dev/null -w "%{http_code}" "https://example.com$p")
  echo "$code  $p"
done
# 전부 403 또는 404 여야 합니다

오류 페이지

# 기본 오류 페이지는 서버 종류·버전을 노출합니다
error_page 400 401 403 404 /40x.html;
error_page 500 502 503 504 /50x.html;

location = /40x.html { root /var/www/error; internal; }
location = /50x.html { root /var/www/error; internal; }

# 앱의 스택 트레이스가 사용자에게 보이지 않게 하는 것도 중요합니다
# (Django DEBUG=False · Spring server.error.include-stacktrace=never 등)
정보 은폐는 방어의 전부가 아닙니다. 버전을 숨겨도 취약한 버전이면 결국 뚫립니다. 패치를 제때 적용하는 것이 근본이고, 정보 은폐는 자동화된 스캔의 표적이 되는 것을 줄이는 보조 수단입니다.
SECTION 03

접근 제어 · IP 제한

관리자 경로는 인터넷 전체에 열어 둘 이유가 없습니다.

# IP 제한
location ^~ /admin/ {
    allow 10.0.0.0/8;              # 사내망
    allow 203.0.113.5;             # 사무실 고정 IP
    deny  all;

    proxy_pass http://backend;
}

# Basic 인증 추가 (이중 방어) — 위 블록 대신 씁니다 (같은 location 두 개는 nginx -t 오류)
location ^~ /admin/ {
    allow 10.0.0.0/8;
    deny  all;

    auth_basic "Restricted";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://backend;
}

# 특정 메서드만 허용
location /api/public/ {
    limit_except GET HEAD { deny all; }
    proxy_pass http://backend;
}
# 비밀번호 파일 만들기
sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd admin
sudo chmod 640 /etc/nginx/.htpasswd
sudo chown root:www-data /etc/nginx/.htpasswd
프록시 뒤에서는 allow·deny 가 오작동합니다. $remote_addr 가 앞단 프록시의 IP 이기 때문입니다. CDN·LB 를 쓴다면 set_real_ip_from 으로 실제 IP 를 복원한 뒤에 접근 제어를 적용해야 합니다(🟩 Nginx n08).
이것을 놓치면 모든 요청이 차단되거나 모두 통과합니다.

더 나은 방법들

방법설명
VPN · 사설망★ 가장 확실합니다. 관리자 경로를 아예 인터넷에 노출하지 않습니다
SSH 터널 · 배스천점프 서버를 거쳐야만 접근 가능하게
IP 허용 목록고정 IP 가 있을 때. 재택근무면 관리가 번거롭습니다
mTLS클라이언트 인증서가 있어야 접근 (🔐 HTTPS s11)
제로트러스트 프록시Cloudflare Access 등 — SSO 인증 후 접근
공개 서비스에서도 제한할 것이 있습니다
· 관리자 페이지 · 모니터링 엔드포인트 (/actuator·/metrics)
· 개발용 문서 (/swagger·/graphql 플레이그라운드)
· 파일 업로드 경로의 실행 권한
· 내부 API (앞단에서만 호출되는 것)
SECTION 04

rate limit · DDoS 완화

서버에서 막을 수 있는 것과 막을 수 없는 것을 구분해야 합니다.

# http 컨텍스트
limit_req_zone  $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone  $binary_remote_addr zone=login:10m   rate=5r/m;
limit_conn_zone $binary_remote_addr zone=perip:10m;

limit_req_status  429;
limit_conn_status 429;

server {
    limit_req  zone=general burst=60 nodelay;
    limit_conn perip 20;

    # 로그인·인증은 강하게
    location = /api/login {
        limit_req zone=login burst=3 nodelay;
        proxy_pass http://backend;
    }

    # 헬스체크는 제외 — return 은 limit_req 보다 앞 단계에서 끝나 제한을 받지 않습니다
    # (limit_req 에는 off 값이 없습니다 — 쓰면 nginx -t 오류)
    location = /health { return 200 "ok\n"; }
}
공격 유형Nginx 로 막을 수 있나
단일 IP 폭주✓ limit_req
무차별 대입 (로그인)✓ rate limit + fail2ban
크롤러 폭주✓ User-Agent 필터 + rate limit
Slowloris (느린 연결 다수)✓ client_body_timeout·limit_conn
분산 DDoS (수만 IP)✗ 서버에 도달하기 전에 막아야 합니다 — CDN·클라우드 방화벽
대역폭 소진 공격✗ 회선이 먼저 막힙니다 — ISP·CDN 영역
대규모 DDoS 는 서버 설정으로 막을 수 없습니다. 수만 대에서 오는 트래픽이 회선을 가득 채우면 Nginx 가 아무리 빨라도 소용이 없습니다.
CDN·클라우드 DDoS 방어 서비스를 앞에 두는 것이 유일한 실효적 대책입니다. Nginx 의 rate limit 은 어뷰징과 소규모 공격에 효과적입니다.

fail2ban — 반복 실패 IP 차단

# /etc/fail2ban/jail.local
[nginx-limit-req]
enabled  = true
filter   = nginx-limit-req
logpath  = /var/log/nginx/error.log
maxretry = 10
findtime = 60
bantime  = 3600
action   = iptables-multiport[name=ReqLimit, port="http,https"]

[nginx-badbots]
enabled  = true
filter   = apache-badbots     # nginx 전용 필터는 없어 이것을 씁니다(jail.conf 기본값과 같음)
logpath  = /var/log/nginx/access.log
maxretry = 2
bantime  = 86400
sudo systemctl enable --now fail2ban
sudo fail2ban-client status
sudo fail2ban-client status nginx-limit-req
sudo fail2ban-client set nginx-limit-req unbanip 1.2.3.4    # 오탐 해제

Slowloris 방어

# 연결을 열어 두고 아주 천천히 보내는 공격
client_body_timeout   10s;      # 본문 수신 타임아웃
client_header_timeout 10s;      # 헤더 수신 타임아웃
send_timeout          10s;      # 응답 전송 타임아웃
limit_conn perip 20;            # IP 당 동시 연결 제한

# Nginx 는 이벤트 기반이라 구조적으로 Slowloris 에 강하지만
# 위 설정으로 자원 점유를 더 확실히 제한할 수 있습니다
요청 제한 — 층으로 나눠 걸어야 합니다 ① 전체 — IP 단위 초당 요청 수 평소 트래픽의 몇 배로 잡되 순간적으로 몰리는 것은 버퍼로 흡수하게 두세요 ② 민감한 경로 — 더 엄하게 로그인 · 인증번호 발송 · 결제. 무차별 시도를 막는 가장 값싼 방법입니다 ③ 동시 연결 수와 본문 크기 이것이 없으면 큰 파일을 계속 올리는 것만으로 서버가 막힙니다 IP 단위만으로는 부족합니다 — 사무실이나 학교는 여러 사람이 같은 IP 를 씁니다 계정 단위 제한과 함께 쓰고, 거절할 때는 429 와 Retry-After 를 주세요 프록시 뒤라면 실제 IP 를 신뢰할 헤더로 받아야 합니다 — 안 그러면 모두가 한 IP 로 보입니다 대규모 공격은 서버 앞에서 막아야 합니다 — 회선이 먼저 차면 서버 설정은 소용이 없습니다
제한을 거는 이유는 막기 위해서가 아니라 나머지가 계속 쓸 수 있게 하기 위해서입니다.
SECTION 05

WAF (ModSecurity)

알려진 공격 패턴을 요청 단계에서 걸러 냅니다.

# Nginx + ModSecurity (동적 모듈)
sudo apt install -y libmodsecurity3 nginx-module-modsecurity   # 배포판에 따라 다름

# OWASP Core Rule Set (CRS) — 사실상 표준 규칙 모음
cd /etc/nginx
sudo git clone https://github.com/coreruleset/coreruleset.git modsec-crs
sudo cp modsec-crs/crs-setup.conf.example modsec-crs/crs-setup.conf
# nginx.conf 최상단
load_module modules/ngx_http_modsecurity_module.so;

server {
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf;
}

# /etc/nginx/modsec/main.conf
# Include /etc/nginx/modsec/modsecurity.conf
# Include /etc/nginx/modsec-crs/crs-setup.conf
# Include /etc/nginx/modsec-crs/rules/*.conf
# modsecurity.conf 의 핵심 설정
# (주석은 줄 끝에 붙이지 말고 따로 한 줄로 — 지시어 인자로 읽혀 오류가 납니다)
# ★ 처음에는 탐지만 (차단하지 않음)
SecRuleEngine DetectionOnly
# 검증 후 차단 모드로
# SecRuleEngine On

SecAuditEngine RelevantOnly
SecAuditLog /var/log/nginx/modsec_audit.log
# 업로드 크기와 맞추세요
SecRequestBodyLimit 13107200
WAF 의 가장 큰 문제는 오탐(false positive)입니다. 정상 요청이 차단되어 사용자가 서비스를 못 쓰는 사고가 흔합니다.
· 반드시 DetectionOnly 로 시작해 감사 로그를 수집하고
· 오탐 규칙을 하나씩 예외 처리한 뒤
· 충분히 검증되면 차단 모드로 전환하세요
파일 업로드·리치 텍스트 에디터·JSON API 에서 오탐이 특히 잦습니다.
# 오탐 예외 처리
# 특정 경로에서 특정 규칙 끄기
SecRule REQUEST_URI "@beginsWith /api/upload" \
    "id:1000,phase:1,pass,nolog,ctl:ruleRemoveById=942100"

# 특정 파라미터를 검사에서 제외
SecRule REQUEST_URI "@beginsWith /admin/post" \
    "id:1001,phase:2,pass,nolog,ctl:ruleRemoveTargetById=941100;ARGS:content"
WAF 를 도입하기 전에 — 애플리케이션 레벨 방어가 먼저입니다. 파라미터 바인딩(SQL 인젝션) · 출력 이스케이프(XSS) · CSRF 토큰이 제대로 돼 있다면 WAF 는 추가 방어층일 뿐입니다.
WAF 는 알려지지 않은 취약점의 시간을 벌어 주는 역할이고, 운영 비용(오탐 조정·성능)이 만만치 않습니다.
부담이 크다면 클라우드 WAF(관리형)를 쓰는 것이 현실적입니다.
SECTION 06

파일 업로드 보안

업로드 폴더에서 스크립트가 실행되면 서버가 통째로 넘어갑니다. 침해 사고의 대표적인 경로입니다.

# ★ 업로드 폴더에서 실행 차단 (Nginx)
location ^~ /uploads/ {
    alias /var/data/uploads/;

    # PHP·CGI 실행 금지
    location ~ \.(php|phtml|php[0-9]|pl|py|cgi|sh|jsp|asp)$ { deny all; }

    # 브라우저에서 실행되지 않고 다운로드되게
    add_header Content-Disposition "attachment" always;
    add_header X-Content-Type-Options "nosniff" always;

    # HTML·SVG 도 위험합니다 (XSS)
    types { }
    default_type application/octet-stream;
}
SVG 는 이미지가 아니라 실행 가능한 문서입니다. SVG 안에 <script> 를 넣을 수 있어 같은 도메인에서 XSS 가 발생합니다.
· 사용자 업로드 SVG 는 별도 도메인에서 서빙하거나
· Content-Disposition: attachment 로 강제 다운로드하거나
· 서버에서 SVG 를 정제(sanitize) 하세요

업로드 보안 체크리스트

항목내용
저장 위치 ★ 문서 루트 밖에 저장하고 앱을 통해 제공하거나, 별도 스토리지(S3)를 쓰는 것이 가장 안전합니다
파일명 사용자 입력을 그대로 쓰지 말고 UUID 로 재생성. 경로 순회(../)와 널 바이트를 차단
확장자 검증 허용 목록으로. 차단 목록은 우회됩니다(.pHp·.php5·이중 확장자)
내용 검증 확장자만 믿지 말고 매직 넘버로 실제 형식을 확인
크기 제한 웹서버·앱 양쪽에 설정 (디스크 소진 방지)
실행 권한 저장된 파일은 chmod 644 · 폴더에 실행 비트 제거
이미지 재처리 이미지는 서버에서 다시 인코딩하면 내장된 악성 코드가 제거됩니다
바이러스 검사 파일 공유 서비스라면 ClamAV 등으로 검사
# 앱 레벨 검증 예 (Python)
import uuid, pathlib

ALLOWED = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
MAX_SIZE = 10 * 1024 * 1024
# imghdr 는 Python 3.13 에서 제거됐습니다 — 파일 앞부분의 시그니처(매직 바이트)로 직접 확인
MAGIC = (b'\xff\xd8\xff', b'\x89PNG\r\n\x1a\n', b'GIF87a', b'GIF89a')

def is_image(head):
    return head.startswith(MAGIC) or (head[:4] == b'RIFF' and head[8:12] == b'WEBP')

def save_upload(file, dest_dir):
    ext = pathlib.Path(file.filename).suffix.lower()
    if ext not in ALLOWED:
        raise ValueError("허용되지 않은 확장자")
    if file.size > MAX_SIZE:
        raise ValueError("파일이 너무 큽니다")

    # ★ 실제 내용이 이미지인지 확인 (확장자만 믿지 않습니다)
    head = file.read(512); file.seek(0)
    if not is_image(head):
        raise ValueError("이미지 파일이 아닙니다")

    # ★ 파일명을 새로 생성 (경로 순회·덮어쓰기 방지)
    name = f"{uuid.uuid4().hex}{ext}"
    path = pathlib.Path(dest_dir) / name
    path.write_bytes(file.read())
    path.chmod(0o644)
    return name
SECTION 07

로그 관리 · 로테이션

로그를 방치하면 디스크가 가득 차서 서비스가 멈춥니다.

# /etc/logrotate.d/nginx (보통 패키지에 포함돼 있습니다)
/var/log/nginx/*.log {
    daily
    # 30일 보관 (주석은 지시어 뒤가 아니라 따로 한 줄로)
    rotate 30
    missingok
    notifempty
    compress
    delaycompress
    create 0640 www-data adm
    sharedscripts
    postrotate
        # ★ Nginx 에게 로그 파일을 다시 열라고 알립니다
        [ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
    endscript
}
로테이션 후 시그널을 보내지 않으면 Nginx 가 이름이 바뀐 옛 파일에 계속 씁니다. 새 파일은 0바이트로 남고 디스크는 계속 차오릅니다.
· Nginx — kill -USR1 (또는 nginx -s reopen)
· 톰캣 catalina.out — copytruncate 사용
# 테스트 (실제로 돌려 보기)
sudo logrotate -d /etc/logrotate.d/nginx        # 시뮬레이션
sudo logrotate -f /etc/logrotate.d/nginx        # 강제 실행

# 디스크 사용량 확인
du -sh /var/log/*
df -h

# 가장 큰 로그 파일 찾기
sudo find /var/log -type f -size +100M -exec ls -lh {} \;

로그 양 줄이기

# 정적 파일·헬스체크는 기록하지 않기
location = /health { access_log off; return 200 "ok\n"; }
location ~* \.(css|js|png|jpg|svg|woff2|ico)$ { access_log off; expires 1y; }

# 특정 조건만 기록
map $status $loggable {
    ~^[23]  0;                 # 2xx·3xx 는 제외
    default 1;
}
access_log /var/log/nginx/errors.log main if=$loggable;

# 버퍼링 (디스크 IO 감소)
access_log /var/log/nginx/access.log main buffer=32k flush=5s;
중앙 로그 수집을 검토하세요. 서버가 여러 대면 각 서버에 SSH 로 들어가 로그를 뒤지는 것은 현실적이지 않습니다. Loki·Elasticsearch·CloudWatch 같은 중앙 저장소로 보내면 여러 서버의 로그를 한 번에 검색할 수 있습니다.
이때 JSON 로그 포맷(🟩 Nginx n15)이 훨씬 다루기 편합니다.
로그에 개인정보가 들어가지 않게 주의하세요. URL 쿼리스트링에 토큰·비밀번호·주민번호가 들어가면 로그에 평문으로 영구 기록됩니다.
· 민감 정보는 POST 본문으로 (URL 에 넣지 않기)
· 필요하면 로그 수집 단계에서 마스킹
· 보관 기간을 정하고 기간이 지나면 삭제
돌려 쓰지 않으면 로그가 디스크를 채웁니다 access.log 지금 쓰는 파일 access.log.1 어제치로 이름 변경 access.log.7.gz 압축 보관 삭제 보관 기간 종료 가장 흔한 사고 — 이름만 바뀌고 계속 씀 리눅스는 열려 있는 파일의 이름을 바꿔도 그대로 씁니다 웹서버는 access.log.1 에 계속 기록하고, 새 파일은 빈 채 → postrotate 에서 kill -USR1 로 새 파일을 열게 합니다 정할 것은 셋뿐입니다 주기(daily) · 보관 개수(rotate 14) · 압축(compress) 용량 기준(size 100M)도 함께 걸어 두면 폭주에 안전합니다 감사 로그는 보존 기간이 규정으로 정해진 경우가 많습니다 확인은 logrotate -d /etc/logrotate.d/nginx — 실제로 돌리지 않고 무엇을 할지 보여 줍니다 디스크가 찬 뒤 지우면 이미 늦습니다 — 사용률 80% 알람을 함께 걸어 두세요
디스크 풀 장애의 원인 1위가 로그입니다 — 설정 한 파일로 끝납니다.
돌려 쓰지 않으면 로그가 디스크를 채웁니다 access.log 지금 쓰는 파일 access.log.1 어제치로 이름 변경 access.log.7.gz 압축 보관 삭제 보관 기간 종료 가장 흔한 사고 — 이름만 바뀌고 계속 씀 리눅스는 열려 있는 파일의 이름을 바꿔도 그대로 씁니다 웹서버는 access.log.1 에 계속 기록하고, 새 파일은 빈 채 → postrotate 에서 kill -USR1 로 새 파일을 열게 합니다 정할 것은 셋뿐입니다 주기(daily) · 보관 개수(rotate 14) · 압축(compress) 용량 기준(size 100M)도 함께 걸어 두면 폭주에 안전합니다 감사 로그는 보존 기간이 규정으로 정해진 경우가 많습니다 확인은 logrotate -d /etc/logrotate.d/nginx — 실제로 돌리지 않고 무엇을 할지 보여 줍니다 디스크가 찬 뒤 지우면 이미 늦습니다 — 사용률 80% 알람을 함께 걸어 두세요
디스크 풀 장애의 원인 1위가 로그입니다 — 설정 한 파일로 끝납니다.
SECTION 08

모니터링 · 상태 페이지

서버 상태를 숫자로 보이게 만들어 두면 장애를 미리 알 수 있습니다.

# Nginx 기본 상태 페이지
server {
    listen 127.0.0.1:8080;          # ★ 로컬만

    location = /nginx_status {
        stub_status;
        access_log off;
        allow 127.0.0.1;
        deny all;
    }
}
curl -s http://127.0.0.1:8080/nginx_status
Active connections: 291 server accepts handled requests 16630948 16630948 31070465 Reading: 6 Writing: 179 Waiting: 106
지표의미
Active connections현재 열린 연결 수 (Waiting 포함)
accepts / handled두 값이 다르면 연결을 거부한 것 — 즉시 조치
requests누적 요청 수 — 연결당 요청 수를 계산하면 keepalive 효율을 알 수 있습니다
Reading요청을 읽는 중
Writing응답을 보내는 중
Waiting유휴 keepalive 연결 (많아도 정상)

Prometheus 로 수집하기

# nginx-prometheus-exporter
docker run -d --name nginx-exporter --network host \
  nginx/nginx-prometheus-exporter:latest \
  --nginx.scrape-uri=http://127.0.0.1:8080/nginx_status

curl -s localhost:9113/metrics | head -20
# prometheus.yml
scrape_configs:
  - job_name: nginx
    static_configs:
      - targets: ['localhost:9113']

  - job_name: node
    static_configs:
      - targets: ['localhost:9100']    # node_exporter (CPU·메모리·디스크)

  - job_name: blackbox
    metrics_path: /probe
    params: { module: [http_2xx] }
    static_configs:
      - targets: ['https://example.com']     # 외부에서 본 응답·인증서 만료
    relabel_configs:                         # ★ 대상 URL 을 ?target= 으로 넘기고
      - source_labels: [__address__]         #   실제 수집은 blackbox_exporter 에게
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: localhost:9115          # blackbox_exporter 주소

무엇을 감시할까

지표알람 기준 예시
5xx 비율★ 1% 초과가 5분 지속
p95 응답 시간목표치의 2배 초과
연결 거부accepts ≠ handled
디스크 사용률80% 초과 — 로그가 원인인 경우가 많습니다
CPU · 메모리80% 초과가 10분 지속
인증서 만료★ 21일 이내 (🔐 HTTPS s12)
프로세스 다운즉시
외부 접속 실패외부에서 본 가용성 (내부 지표만으로는 못 잡습니다)
외부 모니터링을 반드시 함께 두세요. 서버 내부 지표만 보면 DNS 장애·방화벽 오설정·인증서 만료를 못 잡습니다. 외부에서 실제 URL 을 주기적으로 호출하는 감시가 필요합니다 (blackbox_exporter · UptimeRobot 등).
보는 것은 넷이면 충분합니다 ① 요청량 RPS · 분당 요청 평소 대비 급변을 먼저 봅니다 ② 오류율 5xx 비율 사용자가 즉시 아파하는 지표 ③ 지연 p95 · p99 평균은 거짓말을 합니다 ④ 자원 CPU · 메모리 · 디스크 원인을 찾을 때 보는 보조 지표입니다 알람은 ②③에만 겁니다 "CPU 80%" 알람은 밤마다 울리고 아무도 안 봅니다 "5분간 5xx 가 1% 초과" · "p99 가 2초 초과" 처럼 사용자가 느끼는 것을 기준으로 최소 구성 Nginx stub_status → exporter → Prometheus → Grafana 여기에 외부에서 도는 상태 확인을 하나 더 두세요 서버가 통째로 죽으면 서버 안의 감시도 같이 죽습니다 대시보드를 늘리기 전에 — "장애가 났을 때 어느 화면을 볼 것인가" 한 장을 먼저 만드세요. 나머지는 그 뒤에 붙여도 늦지 않습니다
지표를 늘리는 것보다 울릴 알람을 줄이는 것이 어렵고 중요합니다.
보는 것은 넷이면 충분합니다 ① 요청량 RPS · 분당 요청 평소 대비 급변을 먼저 봅니다 ② 오류율 5xx 비율 사용자가 즉시 아파하는 지표 ③ 지연 p95 · p99 평균은 거짓말을 합니다 ④ 자원 CPU · 메모리 · 디스크 원인을 찾을 때 보는 보조 지표입니다 알람은 ②③에만 겁니다 "CPU 80%" 알람은 밤마다 울리고 아무도 안 봅니다 "5분간 5xx 가 1% 초과" · "p99 가 2초 초과" 처럼 사용자가 느끼는 것을 기준으로 최소 구성 Nginx stub_status → exporter → Prometheus → Grafana 여기에 외부에서 도는 상태 확인을 하나 더 두세요 서버가 통째로 죽으면 서버 안의 감시도 같이 죽습니다 대시보드를 늘리기 전에 — "장애가 났을 때 어느 화면을 볼 것인가" 한 장을 먼저 만드세요. 나머지는 그 뒤에 붙여도 늦지 않습니다
지표를 늘리는 것보다 울릴 알람을 줄이는 것이 어렵고 중요합니다.
SECTION 09

무중단 배포 전략

배포 중에도 502 가 나지 않게 만듭니다.

전략설명비용
롤링서버를 한 대씩 교체낮음 — 가장 흔합니다
블루-그린새 환경을 통째로 띄우고 트래픽을 전환서버 2배 필요 · 롤백이 즉시
카나리일부 트래픽만 새 버전으로 보내 관찰구성이 복잡 · 위험이 가장 낮음

롤링 배포 — Nginx 로

#!/usr/bin/env bash
set -euo pipefail
SERVERS=(10.0.1.11 10.0.1.12 10.0.1.13)

for s in "${SERVERS[@]}"; do
  echo "==== $s 배포 시작"

  # 1) LB 에서 제외 (해당 server 줄에 down 을 붙이고 reload)
  sudo sed -i "s|server $s:8080;|server $s:8080 down;|" /etc/nginx/conf.d/upstream.conf
  sudo nginx -t && sudo nginx -s reload

  # 2) 진행 중 요청이 끝나기를 기다림
  sleep 10

  # 3) 배포
  ssh "deploy@$s" 'sudo systemctl stop myapp && \
                   sudo cp /tmp/myapp.jar /opt/myapp/myapp.jar && \
                   sudo systemctl start myapp'

  # 4) 준비될 때까지 대기
  for i in $(seq 1 30); do
    curl -fsS "http://$s:8080/health" >/dev/null && break
    sleep 2
  done
  # 끝내 준비되지 않으면 LB 에 되돌리지 말고 중단 (다음 서버로 번지지 않게)
  curl -fsS "http://$s:8080/health" >/dev/null || { echo "==== $s 헬스체크 실패 — 중단"; exit 1; }

  # 5) LB 에 복귀
  sudo sed -i "s|server $s:8080 down;|server $s:8080;|" /etc/nginx/conf.d/upstream.conf
  sudo nginx -t && sudo nginx -s reload

  echo "==== $s 완료"
  sleep 5
done

블루-그린 — 포트 전환

# /etc/nginx/conf.d/active.conf 를 심볼릭 링크로 관리
upstream app { server 127.0.0.1:8080; }     # blue
# upstream app { server 127.0.0.1:8081; }   # green

# 전환
# ln -sf /etc/nginx/upstreams/green.conf /etc/nginx/conf.d/active.conf
# nginx -t && nginx -s reload
# → 롤백은 링크를 되돌리고 reload 하면 즉시
무중단 배포에서 놓치기 쉬운 것
· DB 스키마 — 신구 버전이 동시에 도는 동안 양쪽 모두 동작해야 합니다 (확장-축소 패턴 → 🗄️ DB 가이드)
· 세션 — 서버에 세션이 있으면 그 서버를 내릴 때 로그아웃됩니다
· 정적 파일 버전 불일치 — 옛 HTML 이 새 JS 를 찾거나 그 반대
· 진행 중인 배치 — 종료 시 중단되면 데이터가 반만 처리될 수 있습니다
· 캐시 워밍업 — 새 인스턴스는 초기에 느립니다
쿠버네티스를 쓴다면 이 과정을 플랫폼이 해 줍니다. readinessProbe·preStop·maxUnavailable 설정만 제대로 하면 롤링 업데이트가 자동으로 무중단이 됩니다 → 다음 섹션.
무중단 배포 세 방식 — 자원과 되돌리기 속도의 맞바꿈 롤링 — 하나씩 교체 추가 자원이 적게 듭니다 · 두 버전이 함께 도는 구간이 깁니다 되돌리려면 다시 하나씩 되돌려야 해서 시간이 걸립니다 블루그린 — 새 환경을 통째로 띄우고 한 번에 전환 되돌리기가 즉시 됩니다 (트래픽만 되돌리면 끝) 대신 잠시 자원이 두 배로 듭니다 — 클라우드에서 쓰기 좋습니다 카나리 — 일부만 먼저 보내고 지켜보기 5% → 25% → 100% 로 올립니다 · 문제를 작게 발견합니다 버전별로 나눠 볼 수 있는 지표가 있어야 의미가 있습니다 공통 전제 — 두 버전이 함께 도는 순간이 반드시 있습니다. DB 변경은 나눠서 배포하세요 그리고 종료 신호를 받으면 처리 중인 요청을 마치고 내려가야 합니다 — 없으면 무중단이 아닙니다
배포 방식보다 먼저 갖춰야 할 것은 즉시 되돌릴 수 있는 상태입니다.
SECTION 10

설정 관리 · 버전 관리

서버에서 직접 고친 설정은 다음 사람이 알 수 없습니다. 서버가 날아가면 복구도 못 합니다.

# 가장 단순한 방법 — /etc/nginx 를 git 저장소로
cd /etc/nginx
sudo git init
sudo tee .gitignore <<'EOF'
*.pem
*.key
.htpasswd
EOF
sudo git add -A
sudo git commit -m "초기 설정"

# 변경할 때마다
sudo git diff
sudo git commit -am "API 경로에 rate limit 추가"

# 되돌리기
sudo git log --oneline
sudo git checkout HEAD~1 -- sites-available/myapp
sudo nginx -t && sudo systemctl reload nginx
인증서 개인키와 비밀번호 파일은 절대 커밋하지 마세요. .gitignore 로 제외하고, 실수로 커밋했다면 파일을 지우는 것만으로는 부족합니다 — 기록에 영원히 남으므로 키를 재발급해야 합니다.

한 단계 위 — 코드로 관리

# Ansible 예시 — 여러 서버에 같은 설정을 재현 가능하게
- name: Nginx 설정 배포
  hosts: web
  become: yes
  tasks:
    - name: 설정 파일 템플릿
      template:
        src: nginx-site.conf.j2
        dest: /etc/nginx/sites-available/myapp
        owner: root
        mode: '0644'
      notify: reload nginx

    - name: 사이트 활성화
      file:
        src: /etc/nginx/sites-available/myapp
        dest: /etc/nginx/sites-enabled/myapp
        state: link
      notify: reload nginx

  handlers:
    - name: reload nginx
      # ★ 문법 검사를 먼저
      shell: nginx -t && systemctl reload nginx
단계방법
1. 최소설정 폴더를 git 으로 — 지금 당장 할 수 있습니다
2. 자동화Ansible·Chef·Puppet 으로 여러 서버에 배포
3. 이미지화Docker 이미지에 설정을 포함 — 서버가 상태를 갖지 않게
4. 선언형쿠버네티스 매니페스트 · Terraform — 인프라 전체를 코드로
왜 중요한가
· 서버가 날아가도 복구할 수 있습니다
· 누가 언제 왜 바꿨는지 남습니다
· 리뷰를 거칠 수 있습니다 (설정 실수로 인한 장애 감소)
· 스테이징과 운영이 같은 설정임을 보장할 수 있습니다
· 새 서버를 동일하게 만들 수 있습니다
SECTION 11

컨테이너 · 쿠버네티스

쿠버네티스에서는 Ingress 가 Nginx 역할을 합니다. 개념은 그대로입니다.

# Ingress — Nginx 의 server 블록에 해당합니다
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
  annotations:
    # Nginx 설정을 애노테이션으로 지정합니다
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/limit-rps: "30"
    cert-manager.io/cluster-issuer: letsencrypt-prod    # 인증서 자동 발급
spec:
  ingressClassName: nginx
  tls:
    - hosts: [example.com]
      secretName: example-tls
  rules:
    - host: example.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service: { name: api-service, port: { number: 8080 } }
          - path: /
            pathType: Prefix
            backend:
              service: { name: web-service, port: { number: 80 } }
전통적 구성쿠버네티스
server 블록Ingress 리소스
upstreamService (엔드포인트 자동 관리)
locationIngress 의 path
certbotcert-manager (자동 발급·갱신)
systemd 재시작Deployment 롤링 업데이트
헬스체크 스크립트liveness · readiness Probe
Nginx 설정 파일Ingress Controller 의 애노테이션·ConfigMap
# 무중단 배포에 필요한 설정 — 이 셋이 핵심입니다
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }   # 항상 최소 대수 유지
  template:
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: app
          readinessProbe:              # ★ 준비되기 전에는 트래픽을 안 보냄
            httpGet: { path: /health/ready, port: 8080 }
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:               # 죽었으면 재시작
            httpGet: { path: /health/live, port: 8080 }
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                # ★ LB 에서 빠질 시간을 벌어 줍니다
                command: ["sh", "-c", "sleep 10"]
preStop 의 sleep 이 필요한 이유 — 파드가 종료될 때 엔드포인트 목록에서 빠지는 것과 SIGTERM 을 받는 것이 동시에 일어납니다. 그래서 이미 라우팅된 요청이 종료 중인 파드에 도달해 502 가 납니다.
preStop 으로 몇 초를 벌면 그동안 LB 갱신이 완료됩니다.
기본기는 그대로입니다. 쿠버네티스를 쓰더라도 프록시·타임아웃·헤더·캐시·TLS 의 개념은 같습니다. Ingress Controller 대부분이 내부적으로 Nginx 를 씁니다. 이 가이드의 내용이 그대로 적용되며, 설정 방법만 애노테이션으로 바뀝니다.
SECTION 12

장애 대응 시나리오

자주 나는 유형과 대응 순서입니다. 당황하지 않는 것이 절반입니다.

증상확인 순서 → 조치
502 급증 ① error 로그에서 connect() failed 확인 → ② 앱 프로세스 상태(systemctl status) → ③ 앱에 직접 요청(curl localhost:8080) → ④ 앱 로그에서 OOM·예외 확인 → ⑤ 필요하면 앱 재시작
504 급증 ① 앱 응답 시간 확인 → ② 대개 DB 쿼리·외부 API 지연 → ③ DB 슬로우 쿼리·락 확인 → ④ 문제 기능을 일시 차단하는 것도 방법
전면 무응답 ① Nginx 프로세스 생존 확인 → ② ss -s 로 연결 상태 → ③ 디스크 여유(df -h) → ④ 파일 디스크립터 한계 → ⑤ 로드 애버리지·스왑
디스크 가득 참 ① du -sh /var/log/* /var/cache/* → ② 큰 로그를 지우지 말고 truncate(: > file) → ③ 로테이션 설정 점검 → ④ 근본 원인 제거
인증서 만료 ① certbot renew --force-renewal → ② reload → ③ 갱신 자동화가 왜 실패했는지 확인
특정 IP 폭주 ① access 로그에서 상위 IP 확인 → ② 임시 차단(deny + reload) → ③ rate limit 조정 → ④ fail2ban 규칙 추가
배포 후 오류 ① 먼저 롤백 → ② 그다음 원인 분석
원인을 찾느라 장애를 길게 끌지 마세요
# 장애 시 첫 5분에 실행할 명령 모음
systemctl status nginx myapp --no-pager
sudo tail -100 /var/log/nginx/error.log
sudo journalctl -u myapp --since "10 min ago" -p err --no-pager
df -h; free -h; uptime
ss -s
curl -sS -o /dev/null -w "%{http_code} %{time_total}\n" http://127.0.0.1:8080/health

# 상태 코드 분포 (최근 1000건)
tail -1000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c | sort -rn

# 응답이 느린 요청
tail -5000 /var/log/nginx/access.log | awk '$NF > 1 {print $NF, $7}' | sort -rn | head -20
장애 중에 하지 말아야 할 것
· 여러 조치를 동시에 — 무엇이 효과였는지 알 수 없게 됩니다
· 상태 수집 없이 재시작 — 원인이 사라지고 다시 같은 장애가 납니다
· 혼자 판단하고 되돌릴 수 없는 조치 — 데이터 삭제·강제 복구는 반드시 공유 후에
· 원인 분석을 먼저 — 서비스 복구가 우선입니다. 분석은 그다음
사후에 반드시 할 것 — 비난하지 않는 회고(blameless postmortem)를 씁니다. "왜 이 상황이 가능했는가", "왜 더 일찍 알지 못했는가", "다음에 같은 일이 생기면 자동으로 막히는가". 사람을 탓하면 진짜 원인이 숨겨집니다.
장애 대응 순서 — 원인보다 회복이 먼저입니다 1 최근 변경을 되돌린다 방금 배포했다면 그것부터 되돌리세요 — 원인 분석은 회복한 뒤에 해도 됩니다 2 범위를 확인한다 전체인가 일부 경로인가 특정 사용자인가 — 범위가 곧 원인의 힌트입니다 3 어느 층에서 끊겼는지 좁힌다 DNS · 연결 · TLS · 웹서버 · 앱 · DB — 502 와 504 는 원인이 다릅니다 4 임시로라도 회복시킨다 서버 추가 · 기능 일부 끄기 · 캐시로 버티기 — 완전한 해결을 기다리지 마세요 5 끝난 뒤 기록으로 남긴다 시간대별 경위 · 원인 · 다음에 막을 방법. 사람을 탓하는 기록이 되지 않게 쓰세요 평소에 준비할 것 — 되돌리기 명령 한 줄 · 연락 체계 · 어디를 볼지 적어 둔 문서
장애 대응 실력은 지식이 아니라 순서를 지킬 수 있는가에서 갈립니다.
SECTION 13

정기 점검 체크리스트

주기별로 무엇을 봐야 하는지 정리합니다.

매일 (자동화하세요)

  • 5xx 비율·응답 시간 대시보드 확인
  • 디스크 사용률
  • error 로그에 새로운 유형이 있는지
  • 백업이 실제로 생성됐는지 (성공 알림이 아니라 파일 크기와 시각)

매주

  • 가장 느린 URL 상위 20개 — 새로 등장한 것이 있는지
  • 가장 많이 호출되는 URL — 예상과 다른 것이 있는지
  • 4xx 급증 여부 (스캔·어뷰징 신호)
  • 로그·캐시 디스크 증가 추이

매월

  • 보안 패치 적용 — apt list --upgradable
  • 인증서 만료일 확인 (자동 갱신이 돌고 있어도 눈으로 한 번)
  • 불필요한 열린 포트 점검 (ss -lntp)
  • 접근 권한 검토 — SSH 키·계정·sudo 권한
  • 설정 파일이 git 과 일치하는지 (git status)

분기

  • 백업에서 실제로 복구해 보기 — 이것을 안 하면 백업은 없는 것과 같습니다
  • 부하 테스트로 현재 용량 재측정
  • SSL Labs 등급 재확인
  • 노출된 경로 점검 (/.git·/.env 등)
  • 장애 대응 절차 문서 갱신
#!/usr/bin/env bash
# 매일 돌리는 점검 스크립트 예시
echo "===== $(date) 점검 ====="

echo "-- 디스크"
df -h | awk '$5+0 > 80 {print "  경고: " $6 " " $5}'

echo "-- 서비스"
for s in nginx myapp; do
  systemctl is-active --quiet $s && echo "  $s: 정상" || echo "  $s: 중지됨 ★"
done

echo "-- 인증서"
for d in example.com api.example.com; do
  end=$(echo | openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null \
        | openssl x509 -noout -enddate | cut -d= -f2)
  left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
  [ "$left" -lt 21 ] && echo "  경고: $d ${left}일 남음 ★" || echo "  $d: ${left}일"
done

echo "-- 최근 1시간 5xx"
awk -v d="$(date -d '1 hour ago' '+%d/%b/%Y:%H')" '$4 ~ d && $9 ~ /^5/' \
  /var/log/nginx/access.log | wc -l
SECTION 14

취약점 대응 · 패치

"잘 돌아가니까 건드리지 말자"가 가장 위험한 태도입니다.

# 현재 버전 확인
nginx -v
apachectl -v
java -version
node -v

# 업데이트 가능 목록
apt list --upgradable 2>/dev/null | grep -E 'nginx|apache|openssl|openjdk'

# 보안 업데이트만 적용 (Ubuntu)
sudo unattended-upgrade --dry-run -d
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

# 재시작이 필요한 서비스 확인
sudo apt install -y needrestart
sudo needrestart -r l
정보원내용
배포판 보안 공지Ubuntu USN · RHEL RHSA — 패키지 단위로 알려 줍니다
Nginx 보안 공지nginx.org/en/security_advisories.html
Apache 보안 공지httpd.apache.org/security_report.html
Tomcat 보안 공지tomcat.apache.org/security.html — CVE 가 자주 나옵니다
KISA · CERT국내 보안 공지
이미지 스캔Trivy · Grype — 컨테이너 이미지의 취약점 검사
# 컨테이너 이미지 취약점 스캔
trivy image nginx:1.28
trivy image --severity HIGH,CRITICAL myapp:latest

# CI 에 넣어 취약한 이미지가 배포되지 않게
trivy image --exit-code 1 --severity CRITICAL myapp:latest

패치 절차

1
영향 여부 확인 CVE 가 우리 버전·우리가 쓰는 기능에 해당하는지. 모든 CVE 가 우리에게 위험한 것은 아닙니다.
▼
2
스테이징에서 먼저 패치가 기존 동작을 깨뜨리지 않는지 확인합니다.
▼
3
롤백 계획을 준비하고 적용 이전 버전 패키지를 보관하거나 스냅숏을 떠 둡니다.
▼
4
재시작이 필요한지 확인 OpenSSL 같은 라이브러리는 이를 쓰는 모든 서비스를 재시작해야 반영됩니다.
라이브러리 패치의 함정 — libssl 을 업데이트해도 이미 실행 중인 프로세스는 옛 라이브러리를 메모리에 들고 있습니다. needrestart 나 lsof | grep DEL 로 재시작이 필요한 서비스를 찾아 반드시 재시작하세요.
"패치는 했는데 여전히 취약한" 상태가 실제로 자주 발생합니다.
현실적인 정책
· 보안 업데이트는 자동 적용(unattended-upgrades) — 대부분의 경우 안전합니다
· 메이저 업그레이드는 계획적으로 — 분기 단위로
· EOL(지원 종료) 버전은 쓰지 않기 — 패치 자체가 나오지 않습니다
· 컨테이너라면 베이스 이미지를 주기적으로 새로 빌드하세요
OPS 15

리버스 터널 — 포트를 안 열고 사내 서버 노출하기

공인 IP 도 없고, 방화벽에 구멍도 못 뚫는데 밖에서 접속은 돼야 할 때. 해법은 방향을 뒤집는 것입니다 — 안에서 밖으로 먼저 연결을 걸어 두고, 그 통로로 바깥 요청을 되받습니다.

외부 사용자 app.example.com 운영자 SSH · 관리 화면 중계 서버 (공인 IP) Cloudflare · frps · VPS 방화벽 — 인바운드 전면 차단 사내 서버 127.0.0.1:8080 ↑ 아웃바운드로 먼저 연결해 둔다
화살표가 안에서 밖으로 향한다는 점이 전부입니다 — 방화벽은 나가는 연결은 대부분 막지 않기 때문에 포트를 하나도 열지 않고 통로가 생깁니다.

언제 쓰나 — 실제로 쓰이는 자리

  • 사내망 서비스를 외부에 공개 — 공인 IP·고정 IP 없이 (지사·공장·매장 서버)
  • 고객사 장비 원격 관리 — 고객 방화벽을 건드리지 않고 지원 채널 확보
  • 개발 중인 로컬 서버를 팀·외부에 시연 — 결제사 웹훅 테스트가 대표적
  • CGNAT·모바일 회선 — 통신사가 공인 IP 를 안 줄 때 유일한 방법
  • IoT · 키오스크 원격 접속 — 수백 대가 각자 밖으로 붙어 옵니다
같은 원리를 이미 쓰고 계실 수도 있습니다. GitHub Actions 러너, Jenkins 에이전트, 모니터링 에이전트, 원격 데스크톱 도구는 전부 에이전트가 밖으로 붙는 구조입니다. “서버가 클라이언트에게 연결하는 게 아니라, 클라이언트가 먼저 붙어 대기한다”가 공통점입니다.
사내 규정을 먼저 확인하세요. 리버스 터널은 방화벽 정책을 사실상 우회합니다. 보안팀 승인 없이 몰래 뚫으면 징계 사유가 되는 조직이 많습니다. “포트를 안 열었으니 괜찮다”는 기술적으로도 틀린 말입니다 — 통로는 실제로 생깁니다.

네 가지 선택지 비교

방식설치HTTPS적합한 곳
SSH -R없음
서버에 이미 있음
직접 구성임시 · 운영자 접속
가장 빠르게 뚫을 때
Cloudflare Tunnelcloudflared 자동상시 서비스 공개
무료 · 도메인 필요
frpfrps + frpc 직접 구성내가 다 통제해야 할 때
TCP·UDP 도 됨
ngrokngrok 자동개발 중 임시 공개
웹훅 테스트
고르는 기준 한 줄 — 상시 서비스면 Cloudflare Tunnel, 내 인프라 안에서 다 끝내야 하면 frp, 지금 5분만 필요하면 SSH -R, 웹훅 테스트면 ngrok.

① SSH 리버스 터널 — 설치 없이 지금

# 사내 서버에서 실행한다 (안 → 밖 방향)
# 중계서버의 8080 포트로 들어온 요청 → 내 PC 의 127.0.0.1:3000 으로
ssh -N -R 8080:127.0.0.1:3000 relay@relay.example.com

#  -N  원격 명령 실행 없이 터널만
#  -R  Remote forward (반대 방향은 -L)

# 중계 서버 밖에서도 접속받게 하려면 sshd 설정이 필요하다
# /etc/ssh/sshd_config
GatewayPorts yes          # 없으면 127.0.0.1 에만 바인딩된다
ClientAliveInterval 30    # 죽은 터널을 빨리 정리
ClientAliveCountMax 3
# ── 끊기면 자동 복구: systemd + autossh ──────────────
# /etc/systemd/system/tunnel.service
[Unit]
Description=Reverse tunnel to relay
After=network-online.target
Wants=network-online.target

[Service]
User=tunnel
Environment=AUTOSSH_GATETIME=0
ExecStart=/usr/bin/autossh -M 0 -N -q \
  -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" \
  -o "ExitOnForwardFailure yes" -o "StrictHostKeyChecking yes" \
  -i /home/tunnel/.ssh/id_ed25519 \
  -R 8080:127.0.0.1:3000 relay@relay.example.com
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
ExitOnForwardFailure yes 를 빼면 조용히 망가집니다. 중계 서버에 포트가 이미 잡혀 있으면 SSH 는 포워딩만 실패하고 연결은 유지합니다 — 프로세스는 살아 있는데 터널만 죽은 상태라 모니터링에도 안 잡힙니다.

중계 서버 쪽 — 계정을 가둬 두기

# 터널 전용 계정: 셸도 없고, 포워딩만 가능
sudo useradd -m -s /usr/sbin/nologin tunnel

# ~tunnel/.ssh/authorized_keys — 키 앞에 제약을 붙인다
restrict,port-forwarding,permitlisten="8080" ssh-ed25519 AAAA... 사내서버A
  • restrict — 모든 기능을 끄고 필요한 것만 다시 켭니다
  • permitlisten — 이 키는 8080 만 열 수 있습니다
  • 셸이 nologin 이라 터널이 뚫려도 명령 실행은 불가
  • 키는 장비마다 다르게 — 한 대가 털려도 나머지는 무사합니다
# 중계 서버의 Nginx — 터널 포트를 도메인에 붙인다
server {
  listen 443 ssl;
  http2 on;
  server_name app.example.com;

  ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:8080;   # ← 터널이 물려 있는 포트
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    # 터널이 끊겼을 때 60초씩 기다리지 않게
    proxy_connect_timeout 3s;
    proxy_next_upstream error timeout http_502;
  }
}
HTTPS 는 중계 서버에서 끝내는 것이 정석입니다. 인증서는 공인 도메인을 가진 중계 서버가 갖고, 터널 안쪽(중계 → 사내)은 이미 SSH 로 암호화되어 있습니다. 사내 서버에 인증서를 넣으려 애쓰지 마세요.

② Cloudflare Tunnel — 중계 서버도 필요 없다

# 사내 서버에서 (중계 서버를 직접 운영하지 않아도 된다)
cloudflared tunnel login
cloudflared tunnel create office-app
cloudflared tunnel route dns office-app app.example.com

# ~/.cloudflared/config.yml
tunnel: office-app
credentials-file: /home/svc/.cloudflared/<UUID>.json
ingress:
  - hostname: app.example.com
    service: http://127.0.0.1:3000
  - hostname: admin.example.com
    service: http://127.0.0.1:9000
    originRequest:
      noTLSVerify: false
  - service: http_status:404          # 마지막 규칙은 필수

# 서비스로 등록 (부팅 시 자동 시작)
sudo cloudflared service install
  • 인증서 자동 — Let's Encrypt 갱신을 신경 쓸 필요가 없습니다
  • DDoS·WAF 가 앞단에 붙습니다 — 사내 서버가 직접 두들겨 맞지 않습니다
  • Access 정책으로 사내 계정(SSO)·이메일 인증을 앞에 세울 수 있습니다
  • 단점 — Cloudflare 에 의존하고, 도메인을 Cloudflare DNS 로 옮겨야 합니다
사내 관리 화면을 공개할 때는 Access 를 꼭 앞에 두세요. 터널만 뚫으면 인터넷 전체에 열린 것과 같습니다. “주소를 아무도 모른다”는 방어가 아닙니다 — 스캐너는 인증서 투명성 로그로 도메인을 찾아냅니다.

③ frp — 전부 내 손 안에 두고 싶을 때

# ── 중계 서버(공인 IP): frps.toml ──────────────────
bindPort = 7000
auth.method = "token"
auth.token = "긴-랜덤-문자열"          # 반드시 바꿀 것

vhostHTTPPort  = 8080
webServer.addr = "127.0.0.1"          # 대시보드는 외부에 열지 않는다
webServer.port = 7500
webServer.user = "admin"
webServer.password = "..."

# 아무나 붙어서 아무 포트나 열지 못하게
allowPorts = [{ start = 20000, end = 20100 }]
# ── 사내 서버: frpc.toml ───────────────────────────
serverAddr = "relay.example.com"
serverPort = 7000
auth.token = "긴-랜덤-문자열"
transport.tls.enable = true           # frp 구간도 TLS 로 감싼다

[[proxies]]
name = "office-web"
type = "http"
localIP = "127.0.0.1"
localPort = 3000
customDomains = ["app.example.com"]
# 터널 자체에 인증을 하나 더 (공개하면 안 되는 화면이라면)
httpUser = "team"
httpPassword = "..."

[[proxies]]
name = "office-db"                    # TCP 도 그대로 넘긴다
type = "tcp"
localIP = "127.0.0.1"
localPort = 5432
remotePort = 20055
DB 포트를 터널로 여는 것은 최후의 수단입니다. 열더라도 중계 서버의 127.0.0.1 에만 바인딩하고, 접속자는 SSH 로 중계 서버에 들어온 뒤 로컬 포트로 붙게 하세요. 인터넷에 5432 를 노출하면 몇 분 안에 스캔이 들어옵니다.

운영 — 끊기는 것을 전제로 만든다

  • 죽은 터널 감지 — 프로세스 생존이 아니라 실제 응답을 확인해야 합니다
  • 지수 백오프 재연결 — 중계 서버가 재부팅될 때 수백 대가 동시에 들이닥칩니다
  • keepalive 30초 — NAT·방화벽이 유휴 연결을 조용히 끊습니다
  • 장비별 개별 키·토큰 — 한 대 분실 시 그 키만 폐기
  • 중계 서버 로그를 남기세요 — 누가 언제 붙었는지가 감사 자료가 됩니다
# 터널 헬스체크 — 중계 서버에서 1분마다
#!/usr/bin/env bash
if ! curl -fsS --max-time 5 http://127.0.0.1:8080/healthz >/dev/null; then
  logger -t tunnel "DOWN: office-app"
  curl -s -X POST "$SLACK_WEBHOOK" \
       -d '{"text":"⚠️ 사내 터널(office-app) 응답 없음"}'
fi
사내 서버 쪽에 /healthz 를 꼭 두세요. 터널은 “연결은 살아 있는데 뒤가 죽은” 상태가 가장 흔합니다. TCP 연결만 확인하면 이 상태를 정상으로 오판합니다.

보안 — 터널은 방화벽에 낸 구멍입니다

이렇게 하면 사고가 납니다
  • 루트 계정으로 터널을 띄운다
  • 키 하나를 모든 장비에 복사해 쓴다
  • 관리자 화면을 인증 없이 그대로 노출
  • 0.0.0.0 바인딩 + 인증 없는 frps
  • “임시로” 열어 두고 1년째 그대로
최소한 이만큼은
  • 전용 계정 + nologin 셸
  • 장비별 키 · permitlisten 제한
  • 터널 앞단에 인증(SSO·Basic·mTLS)
  • 노출 대상은 한 서비스·한 포트로 최소화
  • 만료일을 정하고 캘린더에 회수 일정 등록
공격자도 리버스 터널을 씁니다. 침해된 서버에서 밖으로 나가는 연결을 만들어 지속 접근 통로를 확보하는 것은 전형적인 수법입니다. 그래서 내 서버에서 나가는 연결을 감시하는 것이 보안 운영의 기본이고, 우리가 만든 정상 터널도 문서로 남겨 둬야 나중에 “이건 우리 것”이라고 증명할 수 있습니다.
더 나은 대안이 있는지 먼저 보세요. 상시 접속이 목적이라면 VPN(WireGuard) 이나 제로트러스트 네트워크(Tailscale·Cloudflare Access)가 감사·권한 관리 면에서 앞섭니다. 리버스 터널은 “그것들을 쓸 수 없을 때”의 해법입니다.
EXPERT · SOCKET · EPOLL · HTTP/2·3 · LOAD BALANCING

전문가 —
서버 안에서
무슨 일이

여기서부터는 "왜 그렇게 동작하는가"입니다. 워커 4개가 어떻게 수만 연결을 다루는지, HTTP/2 가 정말 빠른지, 프록시를 거치면 왜 IP 가 사라지는지 — 설정으로 안 풀리는 문제는 이 층위에서 원인을 찾습니다.

14SECTIONS
커널수준까지
설계분산 · 관측
SECTION 01

소켓부터 — accept 와 커넥션

연결은 웹서버가 받기 전에 커널의 대기줄에 먼저 쌓입니다. 이 줄이 넘치면 접속이 거부됩니다.

1
SYN 도착 → SYN 큐 3-way 핸드셰이크가 진행 중인 연결이 여기 쌓입니다. 크기는 tcp_max_syn_backlog.
▼ 핸드셰이크 완료
2
accept 큐 (완성 큐) 연결은 이미 성립됐지만 애플리케이션이 아직 accept() 하지 않은 상태. 크기는 min(somaxconn, listen backlog).
▼ nginx 가 accept()
3
워커가 처리 이제부터 epoll 이 관리하는 연결이 됩니다.
accept 큐가 넘치면 커널이 연결을 조용히 버리거나 RST 를 보냅니다. 클라이언트는 타임아웃 또는 connection reset 을 보게 됩니다.
이때 웹서버 로그에는 아무것도 안 남습니다 — 요청이 도달하지 못했으니까요. "서버는 멀쩡한데 접속이 안 된다"는 상황의 원인입니다.
# 큐 넘침 확인
netstat -s | grep -i -E 'listen|overflow'
# 12345 times the listen queue of a socket overflowed    ← 이 값이 증가하면 문제
# 12345 SYNs to LISTEN sockets dropped

# 현재 큐 상태 (Send-Q = 큐 최대 크기, Recv-Q = 대기 중인 연결 수)
ss -lnt
# State   Recv-Q  Send-Q  Local Address:Port
# LISTEN  0       511     0.0.0.0:80              ← Send-Q 가 backlog

# 커널 설정
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog
# 세 곳을 맞춰야 합니다
# ① 커널
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535

# ② Nginx listen 지시어
listen 80 backlog=65535;
# ★ backlog 는 somaxconn 을 넘을 수 없습니다 (조용히 잘립니다)

# ③ 앱 서버 (톰캣 acceptCount · Node backlog 등)
Nginx 의 기본 backlog 는 511 입니다(리눅스). 피크 트래픽이 크다면 이 값이 병목이 될 수 있습니다. 다만 큐를 키우는 것은 근본 해결이 아닙니다 — 처리 속도가 따라오지 않으면 대기 시간만 길어질 뿐입니다. 큐 넘침이 계속되면 워커·서버를 늘리는 것이 답입니다.
accept — 커널이 만들어 둔 줄에서 꺼내는 것입니다 socket · bind 주소와 포트에 묶기 listen 커널이 대기 줄 두 개를 만듭니다 연결 완료 3-way 핸드셰이크 accept 하나씩 꺼냄 서버가 accept 를 못 따라가면 완료 대기 줄이 차고 새 연결이 거부되거나 조용히 버려집니다 이 줄의 길이가 backlog 입니다 — 애플리케이션 설정과 커널 설정 둘 다 걸립니다 증상은 "가끔 연결이 안 된다" 로 나타나 원인을 찾기 어렵습니다 accept 이후는 별개의 소켓입니다 — 듣는 소켓은 계속 듣고, 통신은 새 소켓으로 합니다 연결 하나가 파일 디스크립터 하나입니다 — 동시 연결 한계는 결국 이 숫자입니다 확인 — ss -lnt 로 지금 줄에 얼마나 쌓여 있는지 볼 수 있습니다
"연결이 가끔 안 된다" 는 신고의 단골 원인이 이 줄의 길이입니다.
SECTION 02

이벤트 루프 · epoll

워커 하나가 수천 연결을 다루는 비결입니다. "준비된 것만 알려 줘"가 핵심입니다.

select · poll — 옛 방식
"이 1000개 소켓 중 준비된 게 있나?"
→ 커널이 1000개를 전부 검사
→ 매 호출마다 O(n)

연결이 10,000개면 매번 10,000개 검사
select 는 1024개 제한도 있습니다
epoll — 리눅스
epoll_create()  감시 목록을 커널에 만들고
epoll_ctl()     관심 소켓을 등록해 두면
epoll_wait()    준비된 것만 돌려받습니다
                → O(준비된 개수)

연결이 10,000개여도
실제로 데이터가 온 10개만 처리
# Nginx 워커의 개념적 루프
while (true) {
    events = epoll_wait(epfd, ...)          // 준비된 이벤트만 받음 (블로킹)

    for (ev in events) {
        if (ev is 새 연결)      accept() 후 epoll 에 등록
        else if (ev is 읽기 가능) 요청을 읽고 파싱
        else if (ev is 쓰기 가능) 응답을 조금 보냄
    }

    타이머 처리 (타임아웃된 연결 정리)
}
이 구조의 치명적 약점 — 블로킹
루프 안에서 블로킹 작업(느린 디스크 읽기·DNS 조회·동기 연산)이 일어나면 그 워커가 담당하는 수천 개 연결이 전부 멈춥니다.
그래서 Nginx 는 무거운 작업을 절대 직접 하지 않고 뒤로 넘깁니다. Node.js 의 이벤트 루프도 같은 원리이며, 동기 함수 하나가 서버 전체를 멈추는 이유가 이것입니다.
# 디스크 IO 를 별도 스레드 풀로 분리 (파일이 크고 캐시 미스가 잦을 때)
thread_pool default threads=32 max_queue=65536;

location /downloads/ {
    aio threads=default;        # 비동기 파일 읽기
    directio 4m;                # 4MB 넘는 파일은 페이지 캐시를 우회
    sendfile off;               # aio 와 함께 쓸 때
}
다른 OS 의 대응 기술 — BSD·macOS 는 kqueue, Solaris 는 event ports, Windows 는 IOCP. Nginx 는 이를 자동으로 선택합니다. 최신 리눅스에는 io_uring 이라는 더 발전된 방식도 있습니다.
연결 1만 개, 그중 10개에만 데이터가 왔을 때 — 두 방식의 차이 select · poll — 매번 전부 넘기고 전부 검사 소켓 1만 개 목록을 커널에 복사 커널이 1만 개를 전부 순회 검사 1만 개 결과를 되돌려 복사 앱이 다시 1만 개 순회 epoll — 한 번 등록하고, 준비된 것만 받습니다 epoll_ctl — 최초 한 번만 등록 이후 목록 복사 없음 커널이 준비된 것만 모아 둠 인터럽트 시점에 기록 epoll_wait — 10개만 반환 앱은 10개만 처리 비용이 "전체 연결 수"가 아니라 "실제로 일이 생긴 개수"에 비례합니다 — 이것이 C10K 문제의 해답이었습니다.
select 의 1024개 제한도 사라집니다 — 연결 수와 처리 비용이 분리되는 것이 이 설계의 전부입니다.

이벤트 루프를 멈추게 하는 것들

블로킹 원인영향과 대응
디스크 읽기 (캐시 미스)워커가 통째로 멈춤 → aio threads 로 분리
DNS 조회proxy_pass 에 도메인을 쓰면 발생 → resolver 설정 · IP 직접 지정
gzip 고압축CPU 점유 → 압축 레벨 5 이하 · 사전 압축
정규식 백트래킹복잡한 location ~ · map → 접두사 매칭 우선
Lua·third-party 모듈동기 호출이 섞이면 치명적 → 비동기 API 만 사용
로그 디스크 지연디스크가 느려지면 응답도 느려짐 → access_log ... buffer=64k flush=5s
# 워커가 실제로 어디서 시간을 쓰는지 — 블로킹 지점을 직접 찾습니다
sudo strace -c -f -p $(pgrep -f 'nginx: worker' | head -1) & sleep 10; kill %1
#   → 호출별 누적 시간이 나옵니다. read/openat 이 크면 디스크 블로킹입니다.

# 워커가 멈춰 있는 순간을 잡기
sudo perf top -p $(pgrep -f 'nginx: worker' | head -1)

# 응답 지연의 분포 (평균이 아니라 꼬리를 보세요)
awk '{print $(NF)}' /var/log/nginx/access.log | sort -n | \
  awk '{a[NR]=$1} END {print "p50:"a[int(NR*0.5)], "p95:"a[int(NR*0.95)], "p99:"a[int(NR*0.99)]}'
#   (로그 포맷에 $request_time 이 마지막 필드로 있다고 가정)
로그를 버퍼링하면 디스크 지연이 응답에 새는 것을 막습니다. access_log /var/log/nginx/access.log main buffer=64k flush=5s; 한 줄이면 됩니다. 기본 설정은 요청마다 디스크에 쓰기라, 디스크가 잠깐 느려지는 순간 모든 응답이 함께 느려집니다 — 네트워크 스토리지를 쓰거나 로그가 많은 서버에서 실제로 겪는 문제입니다. 대신 서버가 갑자기 죽으면 마지막 몇 초의 로그를 잃습니다.
SECTION 03

Nginx 워커 모델 내부

워커가 여러 개일 때 연결을 어떻게 나눠 갖는지가 성능에 영향을 줍니다.

방식동작
accept_mutex 워커들이 순서대로 돌아가며 accept. 예전 기본값 — 지연이 생길 수 있습니다
모든 워커가 대기 연결 하나에 모든 워커가 깨어나는 문제(thundering herd). 현대 커널은 대부분 완화돼 있습니다
SO_REUSEPORT ★ 커널이 직접 분배 — 워커마다 독립된 리슨 소켓. 경합이 없고 분배가 고릅니다
events {
    accept_mutex off;               # 1.11.3+ 기본값 (off)
}

server {
    listen 80 reuseport;            # ★ SO_REUSEPORT 활성화
    # 워커마다 커널이 별도 큐를 만들어 연결을 나눠 줍니다
}
reuseport 는 같은 주소·포트에 한 번만 써야 합니다. 여러 server 블록에서 같은 listen 80 을 쓸 때 첫 블록에만 붙이세요. 여러 곳에 쓰면 설정 오류가 납니다.
또 reload 시 짧은 순간 연결이 유실될 수 있다는 보고가 있으므로, 무중단이 극도로 중요한 환경에서는 검증 후 도입하세요.

워커 간 불균형

# 워커별 연결 수 확인
for pid in $(pgrep -f 'nginx: worker'); do
  echo "$pid : $(ls /proc/$pid/fd 2>/dev/null | wc -l) fds"
done
워커마다 연결 수가 크게 다르면 분배가 고르지 않은 것입니다. keepalive 연결이 특정 워커에 몰리면 그 워커만 바빠집니다. reuseport 가 이 문제를 상당히 개선합니다.

공유 메모리 존

# 워커는 별도 프로세스라 메모리를 공유하지 않습니다
# rate limit · 캐시 키 · SSL 세션은 '공유 메모리 존'에 둡니다

limit_req_zone $binary_remote_addr zone=req:10m rate=30r/s;   # 모든 워커가 공유
ssl_session_cache shared:SSL:10m;                              # shared: 접두사
proxy_cache_path /var/cache/nginx keys_zone=cache:100m ...;

# shared 가 아닌 builtin 캐시를 쓰면 워커마다 따로 관리되어
# 실제 제한이 '워커 수 × 설정값' 이 되어 버립니다
존 크기 계산 — 10m 은 대략 16만 개의 IP 상태를 담습니다 ($binary_remote_addr 기준 항목당 약 64바이트). 존이 가득 차면 오래된 항목부터 제거되며, 심하면 limit_req 가 제대로 동작하지 않습니다.
SECTION 04

Apache MPM 내부 비교

Apache 가 메모리를 더 쓰는 이유를 구조로 설명합니다.

MPM연결 1000개일 때메모리 추정
prefork프로세스 1000개프로세스당 20~50MB → 수십 GB
worker프로세스 40개 × 스레드 25개스레드 스택 + 프로세스 → 수 GB
eventworker 와 같지만 유휴 연결은 전담 스레드가 관리worker 보다 크게 절약
(Nginx)워커 4개수십~수백 MB
event MPM 이 개선한 지점 — HTTP keepalive 로 연결은 열려 있지만 아무 요청도 없는 상태가 대부분입니다. prefork·worker 는 그동안에도 프로세스·스레드를 붙잡고 있었습니다. event MPM 은 이 유휴 연결을 전담 리스너 스레드에 넘겨 작업 스레드를 즉시 반납합니다.
# 실제 메모리 사용 측정
ps -ylC apache2 --sort:rss | awk 'NR>1 {sum+=$8; n++} END {print "평균 " sum/n/1024 " MB · 프로세스 " n "개"}'
#   (NR>1 — 첫 줄은 헤더라 빼야 개수·평균이 맞습니다)

# MaxRequestWorkers 계산
# (가용 메모리 MB × 0.7) ÷ 프로세스당 평균 MB
free -m | awk '/^Mem:/ {print "가용: " $7 " MB"}'
PHP 를 쓰면 event MPM 을 못 쓰는 경우가 있습니다. mod_php 는 스레드 안전하지 않아 prefork 를 강제합니다.
해결 — mod_php 를 끄고 PHP-FPM + proxy_fcgi 로 전환하면 event MPM 을 쓸 수 있고 메모리 사용이 크게 줄어듭니다. 이것이 현대 Apache + PHP 구성의 표준입니다.
# 왜 Nginx 가 메모리를 적게 쓰나

Apache prefork : 연결 = 프로세스
  · 각 프로세스가 PHP 인터프리터·모듈 전체를 메모리에 적재
  · 연결이 늘면 메모리가 선형으로 증가

Nginx : 연결 = 자료구조 (수 KB)
  · 워커 프로세스 수는 고정 (CPU 코어 수)
  · 연결이 늘어도 메모리는 완만하게 증가
  · 대신 동적 언어를 직접 실행하지 못합니다 (항상 프록시)

★ 트레이드오프입니다. Apache 는 '한 프로세스가 전부 처리',
  Nginx 는 '나는 전달만 하고 실행은 남에게'

MaxRequestWorkers 를 실제로 계산하기

# ① 프로세스 하나가 실제로 쓰는 메모리 (RSS 는 공유분이 중복 계산됩니다)
sudo smem -t -P apache2 -k | tail -1        # PSS 기준 합계 — 프로세스 수로 나누면 1개당 값 (더 정확)
# smem 이 없으면 근사치로
ps -ylC apache2 --sort:rss | awk 'NR>1 {s+=$8; n++} END {printf "평균 %.0f MB · %d개\n", s/n/1024, n}'

# ② 앱과 캐시를 뺀 가용 메모리
free -m | awk '/^Mem:/ {printf "가용 %d MB\n", $7}'

# ③ MaxRequestWorkers = (가용 × 0.7) ÷ 프로세스당 MB
#    예) 8000MB × 0.7 = 5600, 프로세스당 40MB → 140
# prefork — 메모리가 상한을 정합니다
<IfModule mpm_prefork_module>
    StartServers             8
    MinSpareServers          8
    MaxSpareServers         20
    # ★ 위에서 계산한 값
    MaxRequestWorkers      140
    # 누수 대비 주기적 교체 (0 = 무제한)
    MaxConnectionsPerChild 10000
</IfModule>

# event — 스레드가 상한을 정합니다
# (Apache 는 지시어 줄 끝 주석을 허용하지 않습니다 — 주석은 따로 한 줄로)
<IfModule mpm_event_module>
    StartServers             4
    MinSpareThreads         50
    MaxSpareThreads        150
    # 프로세스당 스레드
    ThreadsPerChild         25
    # = ServerLimit × ThreadsPerChild
    MaxRequestWorkers      400
    # ★ MaxRequestWorkers ÷ ThreadsPerChild
    ServerLimit             16
    # 유휴 연결을 몇 배까지 받을지
    AsyncRequestWorkerFactor 2
    MaxConnectionsPerChild   0
</IfModule>
MaxRequestWorkers 를 크게 잡는 것이 오히려 서버를 죽입니다. 메모리를 넘기면 스왑으로 빠지고, 스왑에 들어간 순간 응답 시간이 수십 배가 됩니다. 그러면 요청이 더 쌓이고, 워커가 더 늘고, 더 스왑되는 악순환이 시작됩니다.
메모리가 부족하면 워커를 줄이고 대기시키는 편이 낫습니다 — 일부 요청이 조금 기다리는 것과 서버 전체가 죽는 것은 다른 차원의 문제입니다. 스왑을 아예 끄고(vm.swappiness=1) 한계를 명확히 하는 구성도 흔합니다.

Apache 를 고를 이유가 남아 있는 곳

상황이유
.htaccess 가 필요공유 호스팅 · 앱이 배포 시 규칙을 생성 — Nginx 에는 대응물이 없습니다
정교한 접근 제어Require expr · 인증 모듈 생태계가 훨씬 넓습니다
LDAP·Kerberos 인증모듈이 성숙 — 사내 시스템 연동에 유리
레거시 모듈 의존수십 년 축적된 모듈이 그대로 동작합니다
동적 콘텐츠를 직접 실행mod_php·mod_wsgi — 다만 권장되지 않습니다
대량 동시 연결Nginx·Caddy 가 낫습니다 — 구조적 차이입니다
실무에서 가장 흔한 답은 "둘 다"입니다. 앞에 Nginx 를 두어 정적 파일·TLS·연결 관리를 맡기고, 뒤에 Apache 를 두어 .htaccess 와 기존 모듈을 그대로 쓰는 구성입니다. Nginx 가 동시 연결을 흡수해 주므로 Apache 는 실제 처리 중인 요청만 상대하면 됩니다 — prefork 라도 워커 수를 훨씬 적게 잡을 수 있게 됩니다.
SECTION 05

HTTP/1.1 · 2 · 3

"HTTP/2 를 켜면 빨라진다"는 상황에 따라 다릅니다. 왜 그런지 봅니다.

항목HTTP/1.1HTTP/2HTTP/3
전송 계층TCPTCPQUIC (UDP)
동시 요청연결당 1개 (브라우저가 6개 연결)한 연결에 다중화한 연결에 다중화
헤더텍스트 · 매번 전체HPACK 압축QPACK 압축
HOL 블로킹애플리케이션 레벨TCP 레벨에 남음해결
연결 수립TCP + TLS (2~3 RTT)TCP + TLS1 RTT · 재접속 0-RTT
네트워크 전환연결 재수립연결 재수립연결 유지 (Connection ID)
서버 푸시없음있었으나 사실상 폐기없음
HOL (Head-of-Line) 블로킹 — HTTP/2 는 한 TCP 연결에 여러 스트림을 실어 보내는데, 패킷 하나가 유실되면 TCP 가 재전송을 기다리며 그 연결의 모든 스트림이 멈춥니다.
HTTP/3 은 QUIC(UDP 기반)으로 스트림마다 독립적으로 처리해 이 문제를 해결했습니다. 패킷 손실이 잦은 모바일에서 차이가 큽니다.

언제 효과가 있나

🟢 HTTP/2 효과 큼
  • 작은 파일이 많은 페이지
  • 지연이 큰 네트워크
  • 번들링하지 않은 사이트
  • 이미지가 많은 페이지
🔵 효과 작음
  • 이미 번들링된 SPA (파일 2~3개)
  • 대용량 파일 하나를 받는 경우
  • 빠른 유선 네트워크
  • CDN 이 이미 앞에 있는 경우
🟣 HTTP/3 이 유리
  • 모바일 · 불안정한 네트워크
  • 패킷 손실이 있는 환경
  • 네트워크 전환이 잦은 사용자
  • 전 세계 대상 서비스
HTTP/2 를 켠 뒤 오히려 느려지는 경우
· 도메인 샤딩(정적 파일을 여러 도메인에 분산)을 그대로 두면 HTTP/2 의 다중화 이점이 사라집니다 — 한 도메인으로 합치는 것이 맞습니다
· 스프라이트 이미지·인라인 CSS 같은 HTTP/1.1 시대 최적화도 HTTP/2 에서는 불필요하거나 역효과일 수 있습니다
· 서버 푸시는 브라우저에서 제거됐으므로 쓰지 마세요
HTTP 1.1 · 2 · 3 — 무엇이 막히는가로 발전했습니다 HTTP/1.1 — 한 연결에 요청 하나씩 앞의 요청이 느리면 뒤가 기다립니다 — 그래서 브라우저가 연결을 여러 개 엽니다 이미지를 여러 도메인에 나누던 옛 최적화가 이 제약 때문이었습니다 HTTP/2 — 한 연결에 여러 요청을 동시에 헤더도 압축해 보냅니다 — 연결 하나로 충분해집니다 다만 TCP 패킷 하나가 유실되면 그 연결의 모든 요청이 함께 기다립니다 HTTP/3 — UDP 기반 QUIC 위에서 유실이 한 스트림에만 영향을 줍니다 · 연결 수립도 더 빠릅니다 모바일에서 네트워크가 바뀌어도 연결이 유지됩니다 이득은 지연이 크거나 손실이 있는 환경에서 두드러집니다 — 사내망에서는 차이가 작습니다 켜기 전에 확인 — UDP 443 이 열려 있어야 하고, 지원하지 않는 클라이언트는 자동으로 낮은 버전을 씁니다
버전마다 어디가 막혀 있었는지를 알면 왜 바뀌었는지가 보입니다.
SECTION 06

TLS 종료 위치와 성능

암호화를 어디서 푸느냐가 성능과 구조를 좌우합니다.

위치장단점
CDN·LB 에서 사용자와 가장 가까워 핸드셰이크 지연이 최소. 인증서 관리가 한 곳.
뒤쪽 구간이 평문이면 내부망 보안에 의존
웹서버(Nginx)에서 가장 흔한 구성. 앱은 평문만 다루면 됩니다
앱에서 인증서 관리가 앱마다 필요. 언어별 방식이 달라 번거롭습니다
재암호화 구간 전체 암호화. CPU 비용이 두 배, 지연도 늘어납니다

TLS 비용은 어디서 발생하나

① 핸드셰이크 (연결당 1회)
   · 비대칭키 연산 (RSA·ECDSA 서명·검증) ← 비쌉니다
   · RSA 2048 서명 1회 ≈ ECDSA P-256 의 여러 배
   · 세션 재개(session resumption)로 생략 가능 ★

② 대칭키 암복호화 (모든 데이터)
   · AES-GCM 은 현대 CPU 의 AES-NI 명령으로 매우 빠름
   · 실질적 부담은 크지 않습니다

★ 결론 : TLS 비용의 대부분은 '핸드셰이크'입니다
  → keepalive 와 세션 재개가 가장 효과적인 최적화입니다
# 핸드셰이크 비용 줄이기
ssl_session_cache shared:SSL:50m;      # 서버 측 세션 캐시 (약 20만 세션)
ssl_session_timeout 1d;
ssl_session_tickets off;               # 티켓 키를 안전하게 순환할 수 없다면 끄세요

keepalive_timeout 65s;                 # 연결 재사용 = 핸드셰이크 자체를 줄임

ssl_stapling on;                       # OCSP 응답을 서버가 미리 받아 전달
ssl_stapling_verify on;                # (클라이언트의 별도 검증 왕복을 없앰)
resolver 1.1.1.1 8.8.8.8 valid=300s;
# ※ Let's Encrypt 는 2025년에 OCSP 를 종료했습니다 — LE 인증서에는 스테이플링할 OCSP 주소가 없습니다

# ECDSA 인증서가 RSA 보다 가볍습니다 (둘 다 발급해 병행 제공도 가능)
# ssl_certificate     ecdsa.crt;  ssl_certificate_key ecdsa.key;
# ssl_certificate     rsa.crt;    ssl_certificate_key rsa.key;
세션 티켓의 트레이드오프 — ssl_session_tickets on 은 서버가 상태를 저장하지 않아도 되어 편리하지만, 티켓 키가 유출되면 과거 세션이 복호화될 수 있습니다(전방 비밀성 약화).
키를 주기적으로 순환할 수 없다면 끄는 편이 안전합니다. 여러 서버가 있다면 세션 캐시 공유가 어려우므로 티켓 + 키 순환이 현실적입니다.
측정 방법 — curl -w "%{time_appconnect}" 로 TLS 구간 시간을 재고, 같은 연결로 여러 요청을 보냈을 때 이 값이 0에 가까워지는지 확인하세요. 세션 재개가 동작하면 두 번째 접속부터 크게 줄어듭니다.
SECTION 07

제로카피 · sendfile

정적 파일 전송이 왜 그렇게 빠른지, 커널 수준에서 봅니다.

일반적인 파일 전송
read(file, buf)   디스크 → 커널 버퍼 → 유저 버퍼
write(sock, buf)  유저 버퍼 → 커널 소켓 버퍼 → NIC

복사 4회 · 컨텍스트 스위치 4회
1MB 파일이면 그만큼의 메모리 대역폭 소모
sendfile — 제로카피
sendfile(sock, file)
  디스크 → 커널 버퍼 → (직접) NIC

유저 공간을 거치지 않습니다
복사 2회 · 컨텍스트 스위치 2회
CPU 와 메모리 대역폭을 크게 절약
sendfile on;
tcp_nopush on;        # sendfile 과 함께 — 헤더와 파일 시작을 한 패킷에 모아 전송
tcp_nodelay on;       # keepalive 연결에서는 지연 없이 즉시 전송

# 큰 파일은 청크로 나눠 보내 다른 연결을 굶기지 않게
sendfile_max_chunk 512k;

# 매우 큰 파일 — 페이지 캐시를 오염시키지 않게 우회
directio 8m;
output_buffers 2 512k;
sendfile 이 동작하지 않는 경우
· gzip 압축을 하는 경우 — 내용을 변형해야 하므로 유저 공간을 거칩니다
· TLS 를 쓰는 경우 — 암호화가 필요하므로 마찬가지 (커널 TLS 인 kTLS 를 쓰면 가능하지만 설정이 까다롭습니다)
· 프록시 응답 — 파일이 아니므로 해당 없음
즉 HTTPS + 압축이 기본인 요즘에는 sendfile 의 이득이 예전만큼 크지 않습니다. 사전 압축 파일을 서빙할 때는 여전히 유효합니다.
# 실제로 sendfile 이 호출되는지 확인
sudo strace -f -e trace=sendfile,write -p $(pgrep -f 'nginx: worker' | head -1) 2>&1 | head -20

# kTLS 지원 여부 (1.21.4+ · OpenSSL 3.0+ · 커널 4.13+)
nginx -V 2>&1 | grep -o 'built with OpenSSL [0-9.]*'   # 3.0 이상인지
lsmod | grep -w tls                                       # 커널 tls 모듈 (없으면 sudo modprobe tls)
# ssl_conf_command Options KTLS;  로 활성화
1MB 파일을 보낼 때 메모리를 몇 번 지나가는가 read + write — 유저 공간을 왕복합니다 디스크 커널 페이지 캐시 유저 버퍼 (앱) 소켓 버퍼 NIC sendfile — 유저 공간을 건너뜁니다 디스크 커널 페이지 캐시 NIC — 커널 안에서 바로 (DMA) 복사 4회 → 2회, 컨텍스트 전환 4회 → 2회. 파일이 클수록 차이가 커집니다. 단, 내용을 손대야 하면(압축·암호화) 유저 공간을 거칠 수밖에 없어 이 이득이 사라집니다.
HTTPS 가 기본인 지금은 kTLS 를 켜야 이 이득이 살아납니다 — 아니면 사전 압축 파일을 쓰는 편이 실질적입니다.

언제 켜고 언제 무의미한가

상황sendfile 효과
정적 파일 · 평문 HTTP큼 — 그대로 커널이 보냅니다
정적 파일 + 사전 압축(.br·.gz)큼 — 이미 만들어진 파일을 그대로
정적 파일 + 실시간 gzip없음 — 내용을 만들어야 합니다
HTTPS (kTLS 없음)없음 — 암호화가 유저 공간에서
HTTPS + kTLS큼 — 커널이 암호화까지
프록시 응답해당 없음 — 파일이 아닙니다
대용량 다운로드큼 — sendfile_max_chunk 를 함께
sendfile_max_chunk 를 안 주면 큰 파일 하나가 워커를 독점합니다. 이벤트 루프는 한 번에 하나씩 처리하므로, 1GB 파일을 한 번에 밀어 넣으면 그 워커의 다른 연결이 전부 기다립니다. sendfile_max_chunk 512k; 를 주면 조금씩 나눠 보내며 사이사이 다른 연결을 처리합니다. 다운로드 서버에서 "큰 파일을 받는 사람이 있으면 사이트가 느려진다"는 현상의 원인이 대개 이것입니다.
정적 파일 성능의 대부분은 sendfile 이 아니라 "페이지 캐시에 있느냐"로 결정됩니다. 자주 요청되는 파일은 커널이 메모리에 들고 있어서 디스크를 안 건드립니다 — 이것이 sendfile 보다 훨씬 큰 요인입니다. free -m 의 buff/cache 가 충분한지, vmstat 1 의 bi(블록 입력) 가 0 에 가까운지 확인하세요. 디스크를 계속 읽고 있다면 메모리를 늘리는 것이 어떤 설정보다 효과적입니다.
SECTION 08

백프레셔와 버퍼

보내는 쪽이 빠르고 받는 쪽이 느릴 때 어떻게 되는지 — 버퍼가 그 완충 장치입니다.

백엔드
1MB 응답을 즉시 생성로컬 네트워크라 순식간에 전송할 수 있습니다.
▼
Nginx 버퍼
응답을 통째로 받아 보관 백엔드는 여기서 즉시 해방됩니다. 버퍼를 넘으면 임시 파일에 씁니다.
▼ 느리게
느린 클라이언트
3G 로 10초에 걸쳐 수신 그동안 붙잡히는 것은 Nginx 의 가벼운 연결 자료구조뿐입니다.
이것이 리버스 프록시의 숨은 가치입니다. 앱 워커(스레드·프로세스)는 비쌉니다(메모리 수십 MB). Nginx 의 연결은 쌉니다(수 KB). 비싼 자원을 빨리 해방시키고 싼 자원이 기다리게 하는 것이 프록시 버퍼링의 본질입니다.

Slowloris — 이 구조를 역이용하는 공격

공격자가 연결을 수천 개 열고
헤더를 아주 천천히(1분에 한 글자씩) 보냅니다

Apache prefork : 연결마다 프로세스 → 수천 프로세스 → 자원 고갈
Nginx          : 연결마다 수 KB → 구조적으로 훨씬 강합니다

그래도 방어 설정은 필요합니다:
  client_header_timeout 10s;
  client_body_timeout   10s;
  limit_conn perip 20;

스트리밍에서는 버퍼링이 방해가 됩니다

# SSE · 실시간 로그 · 대용량 다운로드 스트리밍
location /events {
    proxy_pass http://backend;
    proxy_buffering off;              # 오는 대로 즉시 전달
    proxy_cache off;
    chunked_transfer_encoding off;
    proxy_read_timeout 3600s;
}

# 앱에서 응답별로 제어
# 응답 헤더에 X-Accel-Buffering: no 를 넣으면 그 응답만 버퍼링이 꺼집니다
버퍼링을 끄면 느린 클라이언트가 앱을 붙잡습니다. 스트리밍이 꼭 필요한 엔드포인트에만 적용하고, 동시 스트리밍 연결 수에 제한을 두세요. 수천 개의 SSE 연결이 앱 워커를 하나씩 붙잡으면 서비스가 멈춥니다. 이 때문에 실시간 기능은 비동기 프레임워크(ASGI·Node·WebFlux)로 만드는 것이 좋습니다.
빠른 쪽과 느린 쪽 사이에 버퍼가 있습니다 앱 (빠름) 초당 100MB 생성 중간 버퍼 (커널 · 웹서버) 사용자 (느림) 초당 2MB 수신 버퍼가 차면 → 앞쪽에 "잠깐 멈춰"라고 알립니다 (백프레셔) 백프레셔가 없으면 차이가 전부 메모리에 쌓입니다 — 느린 사용자 1000명이면 그만큼의 버퍼가 서버 메모리를 먹습니다 결국 OOM 또는 스왑으로 전체가 느려집니다 설정으로 조절하는 자리 proxy_buffers · proxy_buffer_size — 웹서버가 들고 있을 양 넘치면 디스크 임시 파일로 넘어갑니다(느려짐) 스트리밍은 버퍼를 끄고 흘려보내는 편이 맞습니다(p06)
버퍼는 속도 차를 흡수하는 통입니다 — 무한한 통은 메모리 사고가 됩니다.
빠른 쪽과 느린 쪽 사이에 버퍼가 있습니다 앱 (빠름) 초당 100MB 생성 중간 버퍼 (커널 · 웹서버) 사용자 (느림) 초당 2MB 수신 버퍼가 차면 → 앞쪽에 "잠깐 멈춰"라고 알립니다 (백프레셔) 백프레셔가 없으면 차이가 전부 메모리에 쌓입니다 — 느린 사용자 1000명이면 그만큼의 버퍼가 서버 메모리를 먹습니다 결국 OOM 또는 스왑으로 전체가 느려집니다 설정으로 조절하는 자리 proxy_buffers · proxy_buffer_size — 웹서버가 들고 있을 양 넘치면 디스크 임시 파일로 넘어갑니다(느려짐) 스트리밍은 버퍼를 끄고 흘려보내는 편이 맞습니다(p06)
버퍼는 속도 차를 흡수하는 통입니다 — 무한한 통은 메모리 사고가 됩니다.
SECTION 09

커넥션과 파일 디스크립터

연결을 오래 유지하면 빠르지만 자원을 계속 차지합니다.

프록시 구성에서 요청 하나가 쓰는 파일 디스크립터

  클라이언트 ↔ Nginx      : 1개
  Nginx      ↔ 백엔드     : 1개
  (+ 로그 파일 · 캐시 파일 등)

동시 10,000 요청 = 최소 20,000개
여기에 keepalive 유휴 연결까지 더해집니다
설정영향
keepalive_timeout ↑ 재사용률 상승 ↔ 유휴 연결이 FD 를 점유
upstream keepalive ↑ 백엔드 연결 비용 감소 ↔ 백엔드도 그만큼 FD 사용
worker_connections ↑ 동시 처리 증가 ↔ FD 한계에 걸릴 수 있음
# 실시간 FD 사용량
for pid in $(pgrep -f 'nginx: worker'); do
  echo "worker $pid : $(ls /proc/$pid/fd 2>/dev/null | wc -l)"
done

# 한계 확인
cat /proc/$(pgrep -f 'nginx: master')/limits | grep 'open files'

# 시스템 전체
cat /proc/sys/fs/file-nr       # 사용중  미사용  최대

# 소켓 상태 분포
ss -s
ss -tan state established | wc -l
ss -tan state time-wait  | wc -l
TIME_WAIT 이 수만 개 쌓인다면 연결을 계속 새로 맺고 끊고 있다는 신호입니다.
· 근본 해결 — 업스트림 keepalive 활성화 (연결 재사용)
· 완화 — net.ipv4.tcp_tw_reuse = 1
· tcp_tw_recycle 은 쓰지 마세요 — NAT 환경에서 연결 실패를 일으키며 커널에서 제거됐습니다
포트 고갈 — Nginx 가 백엔드로 연결할 때 로컬 포트를 하나씩 씁니다. 기본 범위가 약 28,000개이므로 초당 수천 연결을 새로 맺으면 TIME_WAIT 과 겹쳐 포트가 고갈됩니다.
net.ipv4.ip_local_port_range = 10240 65535 로 넓히고, keepalive 로 연결 자체를 줄이는 것이 정답입니다.

FD 한계는 네 곳에 있습니다 — 하나만 올리면 소용없습니다

계층설정확인
① 시스템 전체fs.file-maxsysctl fs.file-max
② 프로세스(systemd)LimitNOFILEsystemctl show nginx -p LimitNOFILE
③ Nginx 워커worker_rlimit_nofilecat /proc/PID/limits
④ 연결 상한worker_connections③보다 작아야 합니다
# 네 곳을 한꺼번에 맞추기
# ① 커널
sudo tee /etc/sysctl.d/99-nginx.conf <<'EOF'
fs.file-max = 2097152
net.ipv4.ip_local_port_range = 10240 65535
net.ipv4.tcp_tw_reuse = 1
# accept 대기 큐 (기본 4096) — sysctl 파일은 줄 끝 주석을 값으로 읽으니 주석은 따로 한 줄로
net.core.somaxconn = 8192
net.ipv4.tcp_max_syn_backlog = 8192
net.core.netdev_max_backlog = 16384
net.ipv4.tcp_fin_timeout = 15
EOF
sudo sysctl --system

# ② systemd
sudo mkdir -p /etc/systemd/system/nginx.service.d
sudo tee /etc/systemd/system/nginx.service.d/limits.conf <<'EOF'
[Service]
LimitNOFILE=65535
EOF
sudo systemctl daemon-reload

# ③④ nginx.conf
#   worker_rlimit_nofile 65535;
#   events { worker_connections 16384; }

sudo systemctl restart nginx

# ★ 실제로 반영됐는지 반드시 확인 — 설정만 보고 믿지 마세요
cat /proc/$(pgrep -f 'nginx: master')/limits | grep 'open files'
ulimit -n 을 셸에서 올려도 systemd 서비스에는 적용되지 않습니다. /etc/security/limits.conf 는 로그인 세션에만 작용하고, systemd 로 뜬 프로세스는 LimitNOFILE 을 따릅니다. "설정을 다 올렸는데 여전히 Too many open files 가 난다"면 거의 항상 이 지점입니다 — /proc/PID/limits 로 실제 값을 확인하세요.

FD 관련 오류 — 로그에 이렇게 나옵니다

error.log 메시지원인
Too many open filesFD 한계 — 위 네 곳 확인
worker_connections are not enoughworker_connections 상향
Cannot assign requested address로컬 포트 고갈 — keepalive · 포트 범위
upstream timed out백엔드 지연 — $upstream_response_time 확인
no live upstreams전부 실패 판정 — max_fails·헬스체크
connection reset by peer백엔드가 먼저 끊음 — keepalive 타임아웃 불일치
SSL_do_handshake() failed프로토콜·암호 불일치 — 구형 클라이언트
연결 수를 늘리기 전에 "왜 이렇게 많은가"를 먼저 보세요. ss -tan state established | wc -l 이 예상보다 훨씬 크다면, 클라이언트가 연결을 안 닫고 있거나(모바일 앱의 흔한 버그), 업스트림 keepalive 가 없어 연결이 계속 새로 생기거나, 죽은 연결이 정리되지 않는 것입니다. 한계를 올리는 것은 시간을 버는 조치일 뿐, 원인을 그대로 두면 더 큰 숫자에서 같은 일이 반복됩니다.
SECTION 10

프록시 프로토콜 · 실제 IP

프록시를 여러 번 거치면 진짜 방문자 IP 를 잃어버립니다. 게다가 위조도 가능합니다.

사용자(1.2.3.4) → CDN(5.6.7.8) → LB(10.0.0.1) → Nginx → 앱

앱이 보는 것 : 10.0.0.1  (바로 앞 프록시)
X-Forwarded-For: 1.2.3.4, 5.6.7.8, 10.0.0.1
                 └ 진짜 ┘  └── 거쳐 온 프록시들 ──┘

★ 문제 : 클라이언트가 직접 X-Forwarded-For 를 넣어 보낼 수 있습니다
  "X-Forwarded-For: 8.8.8.8" 로 보내면
  최종 헤더는 "8.8.8.8, 1.2.3.4, 5.6.7.8, 10.0.0.1"
  → 맨 앞을 그대로 믿으면 위조된 IP 를 진짜로 인식합니다
맨 앞 값을 그대로 믿으면 안 됩니다. 오른쪽부터 신뢰할 수 있는 프록시를 하나씩 걷어내고 처음 만나는 신뢰하지 않는 값이 실제 클라이언트 IP 입니다.
이것을 틀리면 IP 기반 차단·rate limit·지역 판별이 전부 우회됩니다.
# Nginx 가 이 계산을 대신해 줍니다
set_real_ip_from 10.0.0.0/8;          # 우리 LB 대역
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 173.245.48.0/20;     # CDN 대역 (제공자 문서에서)
real_ip_header X-Forwarded-For;
real_ip_recursive on;                 # ★ 신뢰 대역을 재귀적으로 걷어냄

# 이제 $remote_addr 자체가 실제 클라이언트 IP 가 됩니다
# → 로그 · rate limit · allow/deny 가 전부 올바르게 동작합니다

PROXY protocol — 더 확실한 방법

# L4 로드밸런서(TCP 레벨)는 HTTP 헤더를 넣을 수 없습니다
# 대신 연결을 맺자마자 '누가 접속했는지'를 먼저 알려 주는 방식

server {
    listen 443 ssl proxy_protocol;        # ★ PROXY protocol 수신

    set_real_ip_from 10.0.0.0/8;
    real_ip_header proxy_protocol;

    location / {
        proxy_pass http://backend;
        proxy_set_header X-Real-IP $proxy_protocol_addr;
    }
}
proxy_protocol 을 켜면 일반 연결은 전부 실패합니다. 앞단이 반드시 PROXY protocol 을 보내야 하며, 직접 접속하면 broken header 오류가 납니다.
따라서 앞단 LB 설정과 반드시 짝을 맞춰 적용하고, 전환 시 순간적인 장애가 없도록 별도 포트로 먼저 검증하세요.
보안 원칙 — 신뢰 대역을 명시적으로 지정하지 않은 헤더는 믿지 마세요. set_real_ip_from 없이 real_ip_header 만 쓰면 누구나 IP 를 위조할 수 있습니다. 앱 쪽에서도 마찬가지입니다 — Express 의 trust proxy: true, Uvicorn 의 --forwarded-allow-ips '*' 는 모두 위험한 설정입니다.
실제 IP 를 뒤로 전달하는 두 가지 방법 ① X-Forwarded-For — HTTP 헤더 프록시가 원래 주소를 헤더에 적어 넘깁니다. 여러 단을 거치면 쉼표로 이어집니다 클라이언트가 마음대로 붙일 수 있습니다 — 신뢰할 프록시를 정하고 그 뒤 값만 믿으세요 그냥 맨 앞 값을 쓰면 IP 제한과 요청 제한을 우회당합니다 ② 프록시 프로토콜 — 연결 앞머리에 실어 보내기 TLS 를 그대로 통과시키는 L4 구성에서는 헤더를 넣을 수 없습니다 연결이 열린 직후 원래 주소를 한 줄 보내는 방식입니다 보내는 쪽과 받는 쪽 양쪽 다 켜야 합니다 — 한쪽만 켜면 요청이 깨집니다 받는 쪽에서 신뢰할 프록시 주소를 지정하세요 — 아무 데서나 오는 값을 믿으면 안 됩니다 로그에 실제 IP 가 안 남는다면 이 설정이 빠진 것입니다 — 차단과 통계가 전부 어긋납니다 CDN 을 앞에 두면 단이 하나 더 늘어납니다 — 그 CDN 의 주소 대역도 신뢰 목록에 넣어야 합니다
IP 기반 제한은 실제 IP 가 정확할 때만 의미가 있습니다.
SECTION 11

로드밸런싱 알고리즘

상황마다 정답이 다릅니다. 각각의 성질을 알아야 고를 수 있습니다. (기초 개념·구성 실습은 ⚖️ 로드밸런싱 탭에서 — 여기서는 내부 성질을 파고듭니다)

알고리즘동작과 성질
라운드 로빈 순서대로. 요청 처리 시간이 비슷할 때 가장 고르게 분산됩니다
가중 라운드 로빈 서버 성능이 다를 때 weight 로 비율 조정
least_conn 연결 수가 적은 곳으로. 처리 시간이 들쭉날쭉할 때 유리합니다
ip_hash IP 로 서버 고정. 서버 수가 바뀌면 대부분 재배치됩니다
hash $key consistent 일관된 해싱 — 서버가 바뀌어도 재배치가 최소화됩니다
random two 둘을 무작위로 뽑아 덜 바쁜 쪽. 서버가 아주 많을 때 유리합니다

일관된 해싱이 중요한 이유

일반 해시 : server = hash(key) % N

  서버 3대 → 4대가 되면 N 이 바뀌어
  거의 모든 키의 배정이 달라집니다
  → 캐시 서버라면 캐시 전체가 무효화됩니다 ★

일관된 해싱 : 원형 공간에 서버와 키를 배치

  서버 하나를 추가하면
  그 구간의 키만 이동합니다 (약 1/N)
  → 캐시 적중률이 유지됩니다
# 캐시 서버 계층에 유용합니다
upstream cache_servers {
    hash $request_uri consistent;      # 같은 URL 은 항상 같은 서버로
    server cache1:8080;
    server cache2:8080;
    server cache3:8080;
}

# 사용자별 고정이 필요하면 (세션이 서버에 있을 때)
upstream app {
    hash $cookie_sessionid consistent;
    server app1:8080;
    server app2:8080;
}
세션 고정(sticky)은 근본 해결이 아닙니다.
· 서버가 죽으면 그 서버에 붙어 있던 사용자는 로그아웃됩니다
· 부하가 고르게 분산되지 않습니다
· 배포·확장이 어려워집니다
세션을 Redis 등 외부에 두고 앱을 무상태로 만드는 것이 정답입니다.
실무 기본값 — 앱이 무상태라면 라운드 로빈으로 시작하세요. 응답 시간 편차가 크면(파일 업로드·리포트 생성 등) least_conn 으로 바꿉니다. 캐시 계층에는 일관된 해싱을 씁니다.
SECTION 12

헬스체크 · 서킷 브레이킹

죽은 서버에 요청을 계속 보내지 않게 하고, 장애 전파를 막습니다.

방식설명
수동적 (passive) 실제 요청이 실패하면 그 서버를 잠시 제외. Nginx 오픈소스의 방식
능동적 (active) 주기적으로 /health 를 호출해 미리 판단. Nginx Plus · Caddy · 클라우드 LB 가 제공
# Nginx 오픈소스 — 수동적 헬스체크
upstream backend {
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
}

location / {
    proxy_pass http://backend;

    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 10s;

    proxy_connect_timeout 3s;      # ★ 짧게 — 죽은 서버를 빨리 감지
    proxy_read_timeout   30s;
}
재시도 대상에 http_500 을 넣지 마세요. 500 은 앱 코드의 오류라 다른 서버에서도 똑같이 실패합니다. 재시도는 부하만 3배로 만듭니다.
또 POST·PUT 같은 변경 요청의 재시도는 중복 처리를 일으킬 수 있으니 non_idempotent 를 함부로 넣지 마세요.

헬스체크 엔드포인트 설계

구분해서 만드세요

/health/live   — "프로세스가 살아 있나"
  · 단순히 200 만 반환
  · 실패하면 → 재시작

/health/ready  — "트래픽을 받을 준비가 됐나"
  · DB 연결 · 캐시 · 필수 외부 의존성 확인
  · 실패하면 → LB 에서 제외 (재시작하지 않음)

★ 이 둘을 구분하지 않으면
  DB 가 잠시 느릴 때 앱이 계속 재시작되는 재앙이 벌어집니다
# 헬스체크는 로그에서 제외하고 rate limit 도 면제
location = /health/live {
    access_log off;
    limit_req_dry_run on;     # 제한을 걸지 않음 (limit_req 에는 off 값이 없어 이렇게 · 1.17.1+)
    proxy_pass http://backend;
}

서킷 브레이커 — 장애 격리

개념 — 특정 백엔드가 계속 실패하면 일정 시간 아예 호출하지 않습니다.
· 닫힘(Closed) — 정상. 요청을 통과시킵니다
· 열림(Open) — 실패가 임계치를 넘으면 즉시 실패 응답(빠른 실패)
· 반열림(Half-Open) — 일정 시간 후 일부 요청만 시험적으로 통과
왜 필요한가 — 죽은 서비스를 계속 호출하면 타임아웃을 기다리느라 우리 스레드까지 고갈되어 장애가 전파됩니다.
Nginx 의 max_fails 는 단순한 형태의 서킷 브레이커이고, 본격적인 구현은 애플리케이션(Resilience4j 등)이나 서비스 메시에서 합니다. 자세한 패턴은 ☕ 자바 가이드의 대규모 트래픽 탭에 있습니다.
죽은 곳을 빼는 것과, 아예 부르지 않는 것 ① 헬스체크 — 분배 대상에서 뺍니다 앱 A — 정상 앱 B — 정상 앱 C — 실패 3회 제외 — 회복되면 자동 복귀 ② 서킷 브레이커 — 상태가 셋입니다 CLOSED (닫힘) 평소처럼 호출합니다 실패율 초과 OPEN (열림) 부르지 않고 즉시 실패 시간 경과 HALF-OPEN (반열림) 한 건만 시험해 봅니다 성공하면 다시 닫힘으로 왜 필요한가 — 죽은 대상을 계속 부르면 타임아웃을 기다리느라 우리 쪽 스레드가 다 묶입니다. 그 장애가 우리 서비스로 번지는 것을 끊는 장치입니다
헬스체크는 누구에게 보낼지, 서킷 브레이커는 보낼지 말지를 정합니다.
죽은 곳을 빼는 것과, 아예 부르지 않는 것 ① 헬스체크 — 분배 대상에서 뺍니다 앱 A — 정상 앱 B — 정상 앱 C — 실패 3회 제외 — 회복되면 자동 복귀 ② 서킷 브레이커 — 상태가 셋입니다 CLOSED (닫힘) 평소처럼 호출합니다 실패율 초과 OPEN (열림) 부르지 않고 즉시 실패 시간 경과 HALF-OPEN (반열림) 한 건만 시험해 봅니다 성공하면 다시 닫힘으로 왜 필요한가 — 죽은 대상을 계속 부르면 타임아웃을 기다리느라 우리 쪽 스레드가 다 묶입니다. 그 장애가 우리 서비스로 번지는 것을 끊는 장치입니다
헬스체크는 누구에게 보낼지, 서킷 브레이커는 보낼지 말지를 정합니다.
SECTION 13

관측 가능성 · 추적 ID

서버가 여러 대면 로그가 흩어집니다. 요청 하나를 끝까지 따라갈 방법이 필요합니다.

# Nginx 가 요청마다 고유 ID 를 만들어 전달합니다
log_format traced '$remote_addr - [$time_local] "$request" $status '
                  'rt=$request_time urt=$upstream_response_time '
                  'rid=$request_id';

server {
    access_log /var/log/nginx/access.log traced;

    location / {
        proxy_set_header X-Request-Id $request_id;     # ★ 앱에 전달
        add_header       X-Request-Id $request_id;     # 응답에도 (사용자 문의 대응)
        proxy_pass http://backend;
    }
}
# 이제 로그가 이렇게 이어집니다

Nginx : rid=a1b2c3d4 "GET /api/orders/123" 200 rt=1.234
앱    : [a1b2c3d4] OrderController.get(123) 시작
앱    : [a1b2c3d4] OrderService.find(123)
DB    : [a1b2c3d4] SELECT * FROM orders WHERE id=123  (1.1s) ← 범인
앱    : [a1b2c3d4] 응답 완료 1.2s

★ 사용자가 "느려요"라고 하면
  응답 헤더의 X-Request-Id 만 받아 이 전체를 재구성할 수 있습니다
Spring Boot (MDC)
@Component
public class RequestIdFilter extends OncePerRequestFilter {
  protected void doFilterInternal(HttpServletRequest req,
      HttpServletResponse res, FilterChain chain)
      throws ServletException, IOException {   // throws Exception 은 오버라이드 불가(컴파일 오류)
    String id = req.getHeader("X-Request-Id");
    MDC.put("requestId", id != null ? id : UUID.randomUUID().toString());
    try { chain.doFilter(req, res); }
    finally { MDC.clear(); }
  }
}
// logback 패턴에 %X{requestId} 추가
Python (contextvars)
import contextvars, logging
request_id = contextvars.ContextVar("request_id", default="-")

class RequestIdFilter(logging.Filter):
    def filter(self, record):
        record.request_id = request_id.get()
        return True

# 미들웨어에서
# request_id.set(request.headers.get("X-Request-Id", uuid4().hex))
# 포맷: "%(asctime)s [%(request_id)s] %(message)s"

한 단계 위 — 분산 추적

OpenTelemetry 는 요청 ID 를 넘어 구간별 소요 시간까지 추적합니다.
· Trace — 요청 하나의 전체 여정
· Span — 그 안의 각 구간(HTTP 처리 · DB 쿼리 · 외부 API)
· W3C Trace Context — traceparent 헤더로 서비스 간 전파
Jaeger·Tempo·Zipkin 같은 도구로 어느 구간이 느린지 그림으로 볼 수 있습니다. 마이크로서비스 환경에서는 사실상 필수입니다.
# 외부에서 온 추적 헤더가 있으면 그것을 이어받고, 없으면 새로 만듭니다
map $http_x_request_id $req_id {
    default   $http_x_request_id;
    ""        $request_id;
}

location / {
    proxy_set_header X-Request-Id  $req_id;
    proxy_set_header traceparent   $http_traceparent;   # W3C 표준 전파
    proxy_pass http://backend;
}
SECTION 14

아키텍처 정리

전체 그림을 한 장으로. 설계 회의와 면접에서 그대로 쓸 수 있습니다.

                          [ 사용자 ]
                               │ HTTPS
                    ┌──────────▼──────────┐
                    │  DNS · CDN           │  정적 캐시 · DDoS 방어 · 지리 분산
                    └──────────┬──────────┘
                    ┌──────────▼──────────┐
                    │  L4/L7 로드밸런서     │  TLS 종료 · 헬스체크 · 트래픽 분산
                    └──────────┬──────────┘
              ┌────────────────┼────────────────┐
     ┌────────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐
     │ Nginx (웹서버)  │ │ Nginx        │ │ Nginx        │
     │ · 정적 파일     │ │              │ │              │
     │ · 압축 · 캐시   │ │              │ │              │
     │ · rate limit    │ │              │ │              │
     │ · 보안 헤더     │ │              │ │              │
     └────────┬───────┘ └──────┬───────┘ └──────┬───────┘
              └────────────────┼────────────────┘
                    ┌──────────▼──────────┐
                    │  앱 서버 (WAS)       │  비즈니스 로직
                    │  Tomcat·Gunicorn·Node│  무상태(stateless)
                    └──────────┬──────────┘
              ┌────────────────┼────────────────┐
     ┌────────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐
     │  DB (주)        │ │ 캐시 (Redis) │ │ 큐 · 오브젝트 │
     │  + 읽기 복제본  │ │ 세션 · 캐시  │ │ 스토리지      │
     └────────────────┘ └──────────────┘ └──────────────┘

관측  : 로그 수집 · 메트릭(Prometheus) · 추적(OpenTelemetry) · 알람

각 계층이 담당하는 것

계층책임
CDN정적 캐시 · 지리적 분산 · DDoS 1차 방어
LB트래픽 분산 · 헬스체크 · TLS 종료 · 무중단 배포 지원
웹서버정적 파일 · 압축 · 캐시 · 보안 헤더 · rate limit · 라우팅
앱 서버비즈니스 로직만 — 무상태로 만들어 자유롭게 확장
캐시세션 · 반복 조회 결과 · 분산 락
DB정합성이 필요한 데이터 (읽기는 복제본으로 분산)
관측모든 계층의 로그·지표·추적을 한 곳에서

규모별 현실적인 구성

🟢 개인 · 소규모
  • 서버 1대
  • Caddy 또는 Nginx + 앱 + DB
  • 인증서 자동 갱신
  • 정기 백업
  • 이것으로 상당한 트래픽을 감당합니다
🔵 성장기
  • 웹서버 · 앱 · DB 분리
  • 앱 서버 2대 이상 + LB
  • Redis 세션 · 캐시
  • DB 읽기 복제본
  • 모니터링 · 알람 체계
🟣 대규모
  • CDN + 다중 리전
  • 쿠버네티스 · 오토스케일
  • 분산 추적 · 중앙 로그
  • DB 샤딩 · 큐 기반 비동기
  • 카나리 배포 · 서비스 메시
가장 흔한 실수 — 필요하지 않은 복잡도
사용자 1000명 서비스에 쿠버네티스·마이크로서비스·서비스 메시를 도입하면 운영 부담만 폭증합니다.
서버 한 대로 감당되는 규모가 생각보다 큽니다. 병목이 실제로 생겼을 때 그 지점만 확장하는 것이 거의 항상 옳은 순서입니다.
여기까지 왔다면 — 웹 인프라의 전체 그림을 이해하고 각 계층에서 무엇을 해야 하는지 알게 됐습니다.
다음은 실제로 만들어 보는 것입니다. 도메인 하나를 사서 이 가이드대로 배포해 보세요.
데이터 계층은 🗄️ 데이터베이스 가이드에, 언어별 대규모 트래픽 대응은 ☕ 자바 · 🐍 파이썬 · 🟨 JS · TS 가이드에 이어집니다.
BACKEND · MONOLITH · MSA · GATEWAY · SAGA · CIRCUIT BREAKER

백엔드 서버와
MSA —
쪼개는 기술

웹서버 뒤에는 비즈니스 로직을 실행하는 백엔드 서버가 있습니다. 서비스가 커지면 한 덩어리(모놀리스)를 여러 서비스로 쪼개는 순간이 오고, 그때부터 게이트웨이·디스커버리·사가·서킷브레이커 같은 도구가 필요해집니다. 백엔드 서버의 내부 구조부터 MSA 설계·운영·도입 판단까지, 언어와 무관하게 다룹니다.

11SECTIONS
1→N서비스 분해
0연쇄 장애 목표
SECTION 01

백엔드 서버란 — 웹서버 뒤의 세계

웹서버가 주문을 받는 홀이라면, 백엔드는 요리를 만드는 주방입니다.

이 가이드의 앞 탭들은 웹서버(Nginx·Apache) — 요청을 받아 정적 파일을 주거나 뒤로 넘기는 프로그램을 다뤘습니다. 그 "뒤"가 백엔드 서버입니다. Spring Boot·Django·Express·FastAPI 로 만든 앱이 전부 백엔드 서버이고, 언어가 무엇이든 내부 구조는 놀랄 만큼 비슷합니다.

요청 하나가 백엔드 안에서 지나는 길 — 계층마다 책임이 다릅니다 브라우저 요청 Nginx 리버스 프록시 백엔드 서버 (WAS · 앱) 컨트롤러 — 받고 검증 서비스 — 비즈니스 규칙 리포지토리 — DB 대화 DB 🗄️ DB 가이드 규칙(돈 계산 · 재고 판단)은 전부 서비스 계층에 모입니다 이 구조는 Spring·Django·Express·FastAPI 모두 같습니다 — 이름만 다를 뿐입니다
웹서버는 어떻게 전달하나를, 백엔드는 무엇을 할 것인가를 담당합니다.

언어별 이름 대조 — 같은 것을 다르게 부릅니다

계층Spring (Java)Django (Python)Express (Node)
컨트롤러@RestControllerViewRouter · Handler
서비스@ServiceService (관례)Service (관례)
리포지토리@Repository · JPAModel · ORMPrisma · Knex
요청 처리스레드풀 (기본 200개)워커 프로세스 (gunicorn)이벤트 루프 1개
요청 처리 모델이 성능 특성을 정합니다. 스레드풀 방식(Java)은 요청 하나가 스레드 하나를 점유합니다 — DB 응답을 기다리는 동안에도. 이벤트 루프(Node)는 기다림 없이 다음 요청을 받지만 CPU 작업 하나가 전체를 멈춥니다. 이 차이가 ⚡ 성능 탭과 🧬 전문가 탭(z02·z03)의 주제입니다.

백엔드 서버의 세 갈래 — 웹 서버 · 게임 서버 · 처리 엔진 서버

“백엔드”라고 다 같은 구조가 아닙니다. 시간을 다루는 방식이 다르면 설계가 통째로 달라집니다 — 웹 서버는 요청이 올 때만 일하고, 게임 서버는 요청이 없어도 시간이 흐르며, 엔진 서버는 큐에서 꺼낸 작업을 계속 갈아 냅니다.

웹 · API 서버게임 · 실시간 서버엔진 · 워커 서버
시간 축요청-응답 단위틱 루프 (10~60Hz 로 세상이 흐름)작업(job) 단위 — 큐에서 소비
통신HTTP · 짧은 연결TCP/UDP·WebSocket 상시 연결큐(Kafka·Rabbit) · gRPC
상태무상태 — 상태는 DB·Redis 로상태ful — 방·월드가 메모리에 산다작업 안에서만 상태 유지
확장LB 뒤에 복제 (아무 서버나 OK)방 단위 배치 — 한 방은 반드시 한 서버워커 수만 늘리면 됨
장애재시도로 흡수방 전체 소실 — 스냅숏·재접속 설계 필수미완료 작업 재큐잉 (멱등 필수)
지표p99 응답시간 · RPS틱 예산 초과율 · RTT · 동시접속큐 대기 길이 · 처리율
예시쇼핑몰 API · 관리자MMO·FPS·채팅·화이트보드·시세인코딩 · 렌더 팜 · ML 추론 · 정산 배치
게임 서버의 심장 — 틱 루프 (30Hz = 한 바퀴 예산 33ms) ① 입력 수집 이동·공격 패킷을 큐에서 ② 시뮬레이션 갱신 물리·판정·AI — CPU 예산의 몸통 ③ 상태 전송 바뀐 것만 · 관심 영역만 33ms 안에 한 바퀴 — 남으면 sleep, 넘치면 전원 랙 웹 서버: 요청이 없으면 아무 일도 없다 ↔ 게임 서버: 아무도 안 움직여도 루프는 돈다 그래서 게임 서버의 튜닝은 "응답을 빨리"가 아니라 "②를 예산 안에" — 프로파일링 대상이 다릅니다
틱 예산을 넘기면 한 명이 아니라 그 방 전원이 느려집니다 — 게임 서버 성능 문제의 단위는 요청이 아니라 방입니다.

게임 · 실시간 서버 설계의 다섯 기둥

  • ① 프로토콜 — 위치처럼 최신값만 중요한 데이터는 UDP(잃어도 다음 틱이 덮음), 인벤토리·결제처럼 빠지면 안 되는 것은 TCP/신뢰 채널. 웹 기반이면 WebSocket + 델타 압축.
  • ② 권위(authority)는 서버에 — 클라이언트가 보낸 “나 여기로 이동”은 요청이지 사실이 아닙니다. 서버가 판정하고 클라이언트는 예측·보간으로 숨깁니다(치트 방어의 뿌리).
  • ③ 관심 영역(AOI) — 1,000명 방에서 전원에게 전원 상태를 보내면 패킷이 n² 으로 폭발합니다. 보이는 범위만 구독시키는 격자·그리드가 필수.
  • ④ 상태 저장의 두 층 — 매 틱 바뀌는 것은 메모리에만, 잃으면 안 되는 것(레벨·아이템)만 주기 스냅숏 + 이벤트 로그로 DB에. 매 틱 DB 를 치는 순간 게임 서버는 죽습니다.
  • ⑤ 재접속 프로토콜 — 모바일은 반드시 끊깁니다. “세션 토큰으로 방 찾기 → 스냅숏 재전송 → 이후 델타”가 처음부터 설계에 있어야 합니다.

전체 그림 — 게임도 결국 MSA 로 갑니다

[클라이언트]
   │ HTTP (로그인·상점·랭킹 — 그냥 웹 서버입니다)
   ▼
[API 서버 (무상태)] ──── [DB · Redis]
   │ "게임 시작" 요청
   ▼
[매치메이커] ── 방 배정 · 서버 선택 (Redis 큐)
   │ 접속 티켓 (서버 주소 + 토큰)
   ▼
[게임 서버 #17 (상태ful)] ← 상시 연결 · 틱 루프
   │ 경기 종료 → 결과 이벤트
   ▼
[정산 워커 (엔진 서버)] ── 보상 지급 · 통계 (Kafka)
핵심 통찰 — 게임 백엔드의 대부분(로그인·상점·랭킹·우편)은 그냥 웹 서버이고, 상태ful 틱 루프가 필요한 건 전투 그 자체뿐입니다. “어디까지가 웹이고 어디부터가 실시간인가”를 먼저 긋는 것이 게임 서버 설계의 첫 단추입니다. 실시간 채팅·알림 정도면 게임 서버가 아니라 WebSocket + Redis Pub/Sub(📈 대규모 탭)로 충분합니다.
SECTION 02

모놀리스 — 잘 만든 한 덩어리

MSA 를 배우기 전에 분명히 할 것 — 모놀리스는 열등한 구조가 아닙니다.

모놀리스는 모든 기능이 한 프로세스 안에 있는 구조입니다. 배포 파일이 하나, DB가 하나, 함수 호출로 모든 기능에 닿습니다. 쿠팡도 배민도 토스도 모놀리스로 시작했습니다. 문제는 덩어리 자체가 아니라 내부 경계 없이 뒤엉키는 것입니다.

경계 없는 모놀리스 — 실타래 주문 회원 상품 서로 아무 데나 호출 — 하나 고치면 어디가 깨질지 모릅니다 모듈러 모놀리스 — 경계 있는 한 덩어리 주문 회원 상품 정해진 창구로만 모듈 경계 = 나중에 서비스를 쪼개는 선. MSA 전 단계로 가장 안전한 구조입니다
같은 한 덩어리라도 경계가 있느냐가 미래를 가릅니다. 경계 없는 코드는 쪼갤 수도 없습니다.

모놀리스가 정답인 경우

상황이유
팀이 10명 미만MSA 는 서비스마다 배포·모니터링·온콜이 붙습니다. 사람이 안 됩니다
도메인 경계가 아직 불확실잘못 쪼개면 서비스 둘을 항상 같이 고치게 됩니다 — 최악의 결과
트래픽이 한 대로 감당됨모놀리스도 여러 대 복제(⚖️ 로드밸런싱)로 충분히 확장됩니다
빠른 실험이 중요기능 하나에 서비스 셋을 고치는 구조는 실험 속도를 죽입니다
면접 포인트 — "MSA 경험 있나요?"의 좋은 답은 "무조건 MSA"가 아니라 "모듈러 모놀리스로 경계를 만들고, 이 조건이 되면 쪼갠다"는 판단 기준을 말하는 것입니다. 그 조건이 m03·m11 입니다.
SECTION 03

MSA — 무엇을 기준으로 쪼개나

마이크로서비스는 따로 배포할 수 있는 작은 서비스들의 협력입니다. 쪼개는 기준이 전부입니다.

쪼개면 무엇이 달라지나 — 배포·확장·장애가 서비스 단위로 갈라집니다 모놀리스 주문+회원+상품 DB 하나 경계선대로 분해 주문 서비스 하루 20번 배포 회원 서비스 천천히 · 안정 우선 상품 서비스 조회 폭주 — 10대로 주문 DB 회원 DB 상품 DB 서비스 사이는 API·이벤트로만 남의 DB 직접 접근 금지 — 이 규칙이 깨지면 분산 모놀리스 쪼개는 선 = 업무 경계(주문·정산·배송) — 기술 계층(화면·로직·DB)으로 쪼개면 실패합니다
MSA 의 핵심 규칙 — 서비스마다 자기 DB, 남의 데이터는 API 로 부탁해서 얻습니다.

무엇을 얻고 무엇을 지불하나

얻는 것
① 독립 배포 — 주문 팀은 하루 20번,
   회원 팀은 주 1번. 서로 안 기다린다
② 독립 확장 — 상품 조회만 10대로
③ 장애 격리 — 추천이 죽어도 주문은 산다
④ 기술 자유 — 서비스마다 다른 언어·DB 가능
⑤ 팀 자율 — 작은 팀이 끝까지 책임진다
지불하는 것
① 네트워크 — 함수 호출이 원격 호출로
   (느리고, 실패할 수 있고, 순서가 섞인다)
② 트랜잭션 — DB 가 갈라져 JOIN·롤백 불가
   → 사가 패턴 필요 (m08)
③ 운영 — 서비스 수만큼 배포·모니터링·온콜
④ 테스트 — 전체 흐름 테스트가 어려워진다
⑤ 디버깅 — 로그가 여러 서버에 흩어진다 (m10)

쪼개는 기준 — 바운디드 컨텍스트

DDD(도메인 주도 설계)의 바운디드 컨텍스트가 실무 표준 기준입니다. 같은 "상품"이라도 주문 맥락(가격·재고)과 전시 맥락(이미지·설명)은 다른 데이터입니다. 말이 달라지는 경계, 함께 바뀌는 것들의 묶음이 서비스 경계입니다. 실용적 신호: ① 이 기능만 배포 주기가 다르다 ② 이 기능만 트래픽 패턴이 다르다 ③ 이 기능만 다른 팀이 만진다 — 셋 중 둘이면 쪼갤 후보입니다.

최악의 결과 — 분산 모놀리스. 쪼갰는데 서비스 A 를 고칠 때마다 B 도 같이 배포해야 한다면, 모놀리스의 단점(결합)과 MSA 의 단점(네트워크·운영)을 둘 다 가진 것입니다. 원인은 대부분 ① 잘못된 경계 ② DB 공유 ③ 동기 호출 사슬(m06)입니다.
SECTION 04

API 게이트웨이 · BFF

서비스가 여럿이 되는 순간, 클라이언트가 어디로 요청해야 하는지부터 문제가 됩니다.

클라이언트는 게이트웨이 하나만 안다 — 공통 관심사가 한곳에 모입니다 📱 앱 💻 웹 API 게이트웨이 인증 · 처리율 제한 · 라우팅 TLS 종료 · 로깅 /orders /users /products 주문 서비스 회원 서비스 상품 서비스 인증을 게이트웨이에서 한 번 처리하면 뒤 서비스들은 "누구인지"를 헤더로 받기만 합니다 ⚖️ 로드밸런싱 탭의 Nginx 도 게이트웨이의 원형입니다 — 전용 도구는 여기에 인증·리밋이 붙은 것
게이트웨이가 없으면 모든 서비스가 인증·CORS·리밋 코드를 복붙하게 됩니다 — 그게 신호입니다.

도구 선택

도구특징 · 쓰는 곳
Nginx이미 알고 있는 것으로 시작 — 라우팅·리밋·TLS 는 충분합니다
Spring Cloud Gateway자바 진영 표준. 필터로 인증·로깅을 코드로 제어
Kong · APISIX플러그인 생태계(인증·리밋·변환). 언어 무관 전용 게이트웨이
클라우드 관리형AWS API Gateway · ALB — 운영을 맡기고 기능만 씁니다

BFF — 클라이언트별 전용 백엔드

앱은 화면 하나에 필요한 것만 작게, 웹 관리자는 한 번에 많이 — 클라이언트마다 원하는 모양이 다릅니다. 하나의 범용 API 로 모두 만족시키려다 비대해지는 대신, 클라이언트 종류마다 전용 집계 서버(BFF, Backend For Frontend)를 둡니다. BFF 는 여러 서비스를 호출해 그 화면에 맞는 모양으로 합쳐서 돌려줍니다. 프런트 팀이 직접 소유하는 경우가 많습니다.

게이트웨이에 비즈니스 로직을 넣지 마세요. "주문 금액이 5만 원 이상이면…" 같은 규칙이 게이트웨이에 들어가는 순간, 게이트웨이가 모두가 함께 배포를 기다리는 병목이 됩니다. 게이트웨이는 인증·라우팅·리밋 같은 공통 관심사만 다룹니다.
SECTION 05

서비스 디스커버리 · 중앙 설정

서비스가 늘고 줄면 IP 가 계속 바뀝니다. 주소를 코드에 적는 순간 끝입니다.

디스커버리 — ① 등록 ② 조회 ③ 호출, 세 걸음이 전부입니다 주문 서비스 회원 정보가 필요 레지스트리 Eureka · Consul · k8s DNS 회원 #1 10.0.1.4 회원 #2 10.0.1.7 회원 #3 — 하트비트 끊김 ① 등록 + 하트비트 ② "회원 어디 있어?" ③ 받은 목록 중 하나로 호출 (클라이언트 측 LB)
쿠버네티스에서는 이 전부가 Service 라는 내장 기능입니다 — http://member-service 로 부르면 끝.

중앙 설정 · 시크릿

문제해법
설정이 서비스 20개에 흩어짐중앙 설정 서버 — Spring Cloud Config · Consul KV · k8s ConfigMap
DB 비밀번호가 코드·이미지에 박힘시크릿 저장소 — Vault · AWS Secrets Manager · k8s Secret. 코드에는 절대 금지
설정 하나 바꾸는데 전체 재배포설정 서버 + 갱신 신호(actuator refresh · 재시작 롤링)
환경(dev·stage·prod)마다 값이 다름같은 이미지 + 환경별 설정 주입 — 12-factor 원칙
순서를 기억하세요 — 코드가 아니라 환경에서. 같은 빌드 산출물(이미지)이 dev 와 prod 에서 다른 설정을 주입받아 도는 것이 정석입니다. "prod 용으로 다시 빌드"하는 순간, 테스트한 것과 배포한 것이 달라집니다.
SECTION 06

동기 통신 — REST · gRPC

서비스 간 호출의 기본형. 단, 사슬이 길어질수록 느려지고 약해집니다.

동기 호출 사슬 — 지연은 더해지고, 장애는 거슬러 올라옵니다 주문 서비스 응답까지 950ms ① 100ms 회원 서비스 등급 조회 ② 50ms 쿠폰 서비스 ③ 800ms! 재고 서비스 느려짐 — 병목 타임아웃이 없으면 기다리는 요청마다 스레드 점유 → 주문 스레드풀 고갈 (200/200) → 멀쩡한 주문 API 까지 전멸 해법: 타임아웃 + 서킷브레이커 (m09) 세 호출은 나란히가 아니라 차례로 일어납니다 — 그래서 더해집니다 주문 응답 100 50 ③ 재고 800ms — 이 하나가 전체의 84% 0 250 500 750 950 경과(ms) ① 회원 100ms → ② 쿠폰 50ms → ③ 재고 800ms — 차례로 이어 붙어 합계 950ms 가 됩니다 규칙 — 모든 원격 호출에는 반드시 타임아웃 · 사슬은 최대한 짧게 · 순서 없는 호출은 병렬로 ①②는 서로 의존하지 않습니다 — 병렬로 보내면 950ms 가 850ms 가 됩니다(⚡ a07 과 같은 이야기)
동기 호출은 가장 느린 서비스가 전체 속도가 되고, 가장 약한 서비스가 전체 가용성이 됩니다.

REST vs gRPC

REST + JSONgRPC
형식텍스트(JSON) — 사람이 읽음바이너리(Protobuf) — 작고 빠름
계약OpenAPI 문서 (선택).proto 파일이 강제 계약 — 코드 생성
전송HTTP/1.1 (보통)HTTP/2 — 멀티플렉싱·스트리밍
쓰는 곳외부 공개 API · 브라우저내부 서비스 간 · 성능 민감 구간
// member.proto — 계약이 곧 문서이자 코드입니다
syntax = "proto3";   // 빠뜨리면 proto2 로 해석되어 필드마다 optional/required 를 요구합니다

service MemberService {
  rpc GetMember (GetMemberRequest) returns (Member);
}
message GetMemberRequest { int64 id = 1; }
message Member { int64 id = 1; string name = 2; string grade = 3; }
// → protoc 가 Java·Python·Go 클라이언트/서버 코드를 생성
실무 구도 — 외부는 REST, 내부는 gRPC 가 흔한 조합입니다. 다만 내부도 REST 로 통일해 단순함을 택하는 회사가 많습니다. 성능보다 계약 관리(스키마 버전, 하위 호환)가 진짜 승부처입니다 — 필드 삭제·타입 변경은 추가만 하는 변경으로 바꿔 배포 순서 문제를 피하세요.
SECTION 07

비동기 통신 — 이벤트

"주문 완료를 알려만 주고 내 갈 길 간다" — 결합을 끊는 가장 강한 도구입니다.

동기 — 전부 기다린다 주문 서비스 알림 적립금 통계 (죽음) 통계가 죽으면 주문도 실패. 셋 다 기다려야 응답합니다 이벤트 — 알리고 끝낸다 주문 서비스 발행 1번 브로커 Kafka 탭 알림 적립금 통계 (죽음) 죽어도 주문은 성공 — 살아나면 밀린 것부터 처리 주문은 발행 즉시 응답. 소비자가 늘어도 코드 그대로
핵심 질문 — "이 결과를 지금 알아야 응답할 수 있나?" 아니라면 이벤트가 정답입니다.

언제 동기, 언제 이벤트

상황선택
결제 승인 결과가 있어야 주문 응답 가능동기 — 응답에 결과가 필요하면 어쩔 수 없습니다
주문 완료 후 알림·적립·통계이벤트 — 주문 응답과 무관합니다
재고 차감 (틀리면 안 됨)동기 + 실패 시 거절, 또는 예약-확정 사가(m08)
다른 시스템에 데이터 복사·동기화이벤트 — CDC·아웃박스 (k07)
이벤트의 대가 — 최종 일관성. 이벤트 처리 전까지 잠깐 데이터가 어긋난 상태가 존재합니다 (주문은 됐는데 적립금이 아직). 화면에 "적립 예정"처럼 어긋남을 설계에 포함하세요. 또한 소비자는 같은 이벤트를 두 번 받아도 안전해야 합니다(멱등 처리 — k06).
SECTION 08

데이터 분리 — 사가 패턴

DB 가 갈라지면 ROLLBACK 이 없습니다. 되돌리는 것도 직접 설계해야 합니다.

모놀리스에서는 주문 저장·재고 차감·결제를 한 트랜잭션으로 묶고 실패하면 통째로 롤백했습니다. MSA 에서는 세 작업이 서로 다른 서비스의 서로 다른 DB에서 일어납니다. 분산 트랜잭션(2PC)은 느리고 조정자가 죽으면 전체가 멈춰 실무에서 안 씁니다. 대신 사가(Saga) — 로컬 트랜잭션의 연쇄 + 실패 시 보상 트랜잭션으로 되돌립니다.

사가 — 앞으로는 로컬 트랜잭션의 연쇄, 실패하면 거꾸로 보상 ① 주문 생성 상태: PENDING ② 재고 차감 로컬 커밋 완료 ③ 결제 한도 초과 — 실패! 보상: 재고 복원 +1 되돌림 보상: 주문 취소 상태: CANCELED 보상은 "삭제"가 아니라 반대 방향의 새 작업입니다 — 재고 +1 · 상태 CANCELED · 환불 순서 규칙 — 되돌리기 어려운 것(결제·발송)을 사가의 마지막에 두면 보상이 줄어듭니다 중간 상태(PENDING)가 보이는 것이 정상입니다 — 최종 일관성을 화면 설계에 포함하세요
사가의 본질 — 롤백을 공짜로 받지 못하니, 되돌리는 작업을 직접 만든다.

두 가지 진행 방식

코레오그래피 — 이벤트로 릴레이
주문 → [주문생성됨] → 재고가 구독해 차감
     → [재고차감됨] → 결제가 구독해 승인
     → [결제실패됨] → 각자 보상 실행

장점  중앙 조정자가 없다 — 결합 최소
단점  전체 흐름이 코드 어디에도 없다
      → 서비스 5개 넘으면 추적 불가
적합  단계 2~3개의 단순한 흐름
오케스트레이션 — 지휘자가 지시
주문 사가 오케스트레이터가
  ① 재고에 차감 명령 → 응답
  ② 결제에 승인 명령 → 실패!
  ③ 재고에 복원 명령 (보상)

장점  흐름이 한 곳에 — 상태 추적 쉬움
단점  오케스트레이터가 비대해질 수 있음
적합  단계 많고 보상 복잡한 핵심 흐름
도구  Temporal · AWS Step Functions
실무 감각 — 주문·결제처럼 돈이 걸린 핵심 사가는 오케스트레이션으로 상태를 명시적으로 관리하고, 알림·통계처럼 부수 효과는 코레오그래피로 흘리는 조합이 흔합니다. 그리고 "이벤트 발행과 DB 커밋을 어떻게 원자적으로 하나"가 남는데 — 그 답이 아웃박스 패턴(📨 Kafka 탭 k07)입니다.
SECTION 09

장애 격리 — 서킷브레이커

MSA 최대 리스크는 연쇄 장애입니다. 두꺼비집처럼 선을 끊어서 막습니다.

서킷브레이커 상태 기계 — 호출 결과가 상태를 바꿉니다 CLOSED — 닫힘 정상 통과 · 실패율 측정 실패율 50% 초과 OPEN — 열림 즉시 실패 · 상대를 쉬게 대기 30초 경과 HALF-OPEN 시험 호출 몇 개만 시험 성공 → 닫힘 복귀 또 실패 → 다시 열림 열림 동안의 응답 — 폴백: 캐시된 값 · 기본값 · "잠시 후 다시" 안내 중 하나를 미리 정해 둡니다 도구 — Resilience4j(Java) · opossum(Node) · Istio/Envoy(메시 레벨) · 게이트웨이 내장
서킷의 목적은 둘 — 내 스레드를 지키고(즉시 실패), 상대에게 회복할 틈을 줍니다.

타임아웃 · 재시도 — 함께 써야 완성

도구규칙
타임아웃모든 원격 호출에 예외 없이. 연결 1s · 응답은 p99 기준 + 여유. "무한 대기"가 스레드 고갈의 주범입니다
재시도멱등한 호출에만(조회 O · 결제 X). 지수 백오프 + 지터. 최대 2~3회
재시도 폭풍 주의느려진 서비스에 재시도가 몰리면 부하가 배로 — 서킷과 반드시 함께
벌크헤드호출 대상별로 스레드풀·커넥션풀 분리 — 한 대상이 느려도 다른 호출은 산다
# Resilience4j 설정 감각 (Spring Boot application.yml)
resilience4j:
  circuitbreaker:
    instances:
      memberService:
        slidingWindowSize: 20            # 최근 20번 기준
        failureRateThreshold: 50         # 실패 50% 넘으면 OPEN
        waitDurationInOpenState: 30s     # 30초 쉬고 HALF-OPEN
        permittedNumberOfCallsInHalfOpenState: 3
  timelimiter:
    instances:
      memberService:
        timeoutDuration: 2s              # 응답 타임아웃
폴백이 없는 서킷은 반쪽입니다. 회원 등급 조회가 열렸을 때 — 주문을 전부 거절할 건가요, "기본 등급으로 진행"할 건가요? 장애 시 무엇을 포기하고 무엇을 지킬지는 기술이 아니라 비즈니스 결정입니다. 미리 정해 두지 않으면 새벽 장애 때 개발자가 즉석에서 정하게 됩니다.
SECTION 10

관측 가능성 — 로그 · 메트릭 · 트레이싱

"주문이 느려요" — 서비스 10개 중 어디가 느린지 5분 안에 찾을 수 있어야 합니다.

분산 트레이스 — 요청 하나의 여정이 폭포수로 보입니다 (traceId: a1b2c3) 게이트웨이 전체 820ms 주문 서비스 790ms 회원 서비스 70ms 재고 서비스 680ms └ DB 쿼리 SELECT … 600ms ← 범인 0 200 400 600 820 ms 막대의 시작 위치가 곧 시작 시각입니다 — 재고 680ms 중 600ms 가 DB 한 줄입니다
트레이싱이 없으면 이 그림을 서비스 10개의 로그를 이어 붙여 손으로 그려야 합니다 — 새벽에.

세 기둥 — 각자 답하는 질문이 다릅니다

기둥답하는 질문도구
메트릭"지금 얼마나 나쁜가?" — RPS · 에러율 · p99Prometheus + Grafana
로그"그때 정확히 무슨 일이 있었나?"Loki · ELK — 중앙 수집 필수
트레이싱"어느 구간이 느렸나?"OpenTelemetry + Tempo·Jaeger
전제는 상관관계 ID 입니다. 게이트웨이에서 요청마다 traceId 를 만들어 모든 서비스가 로그에 함께 남기고, 다음 호출에 헤더로 전달합니다(W3C traceparent 표준). OpenTelemetry 를 쓰면 전파·수집이 자동입니다 — 직접 만들지 마세요. 로그는 JSON 구조화로 남겨야 검색이 됩니다.
알람은 "골든 시그널" 넷이면 시작할 수 있습니다 — 지연(p99) · 트래픽(RPS) · 에러율 · 포화도(CPU·커넥션풀). "CPU 80%" 같은 원인 알람보다 "주문 API p99 가 1초를 넘었다" 같은 증상 알람이 먼저입니다. 원인은 그다음에 파고드는 것입니다. (🛡️ 보안·운영 o13)
SECTION 11

MSA 도입 판단 — 하지 말아야 할 때

가장 비싼 실수는 너무 일찍, 한 번에 쪼개는 것입니다. 전환은 점진적으로 합니다.

스트랭글러 피그 — 프록시 뒤에서 한 조각씩 갈아 끼웁니다 ① 시작 프록시 모놀리스 전부 처리 ② 한 조각 분리 프록시 /orders 만 주문 서비스 ✨ 모놀리스 주문 빼고 전부 ③ 반복 주문 회원 모놀리스 ↓ 빅뱅 재작성 금지 — 새 시스템을 다 만들 동안 옛것도 고쳐야 해서 대부분 실패합니다 순서 — ① 경계 명확·위험 낮은 기능부터 ② 트래픽 일부만 전환해 검증 ③ 완전 전환 후 옛 코드 삭제 전제 조건 — CI/CD 자동화 · 중앙 로그 · 모니터링이 먼저입니다 (없으면 서비스 수만큼 고통도 곱해집니다)
전환 기간에는 프록시가 신·구를 가르는 스위치입니다 — ⚖️ 로드밸런싱 탭의 카나리와 같은 원리입니다.

도입 체크리스트 — 정직하게 답해 보세요

질문아니오라면
배포가 느려서(조율·대기) 실제로 고통받고 있나?모놀리스 유지
부분별로 확장 요구가 뚜렷이 다른가?복제 확장으로 충분
도메인 경계를 팀이 설명할 수 있나?모듈러 모놀리스부터
CI/CD · 중앙 로그 · 모니터링이 자동화돼 있나?그것부터 (m10)
서비스별로 온콜 가능한 팀이 있나?운영이 무너집니다
면접 단골 — "모놀리스를 어떻게 MSA 로 전환하시겠어요?" 모범 골격: ① 먼저 전환할 이유(고통)를 확인하고 ② 모듈러 모놀리스로 경계를 검증한 뒤 ③ 스트랭글러로 한 조각씩, 배포·관측 자동화를 앞세워 전환한다 — "안 쪼개는 선택지"를 먼저 말하는 것이 오히려 신뢰를 줍니다.
KAFKA · TOPIC · PARTITION · CONSUMER GROUP · EXACTLY-ONCE · CDC

Kafka —
대규모 데이터가
흐르는 길

초당 수만 건의 주문·클릭·로그를 잃어버리지 않고 여러 시스템에 전달하는 일 — 그것이 Kafka 가 하는 일입니다. 국내 대기업 백엔드에서 사실상 표준이 된 기술이고, 면접에서도 파티션·컨슈머 그룹·정확히 한 번 처리는 단골 주제입니다. 핵심 구조부터 운영·실전 패턴까지 순서대로 갑니다.

11SECTIONS
N만/s메시지 처리
0건유실 목표
SECTION 01

왜 Kafka 인가

직접 호출로 버티던 시스템이 무너지는 순간이 있습니다 — 받는 쪽이 못 따라올 때입니다.

직접 호출 — 받는 쪽 속도가 전체 속도 주문 서비스 피크 10,000/s 정산 시스템 한계 2,000/s 타임아웃 · 요청 유실 · 같이 죽음 — 피크마다 반복됩니다 Kafka — 버퍼가 속도 차를 흡수 주문 서비스 10,000/s 발행 Kafka 디스크에 보관 정산 주문은 써 두고 즉시 응답 · 정산은 자기 속도(2,000/s)로 꺼내 처리 → 밀렸다가(랙) 피크 후 따라잡음 Kafka 는 메시지를 메모리가 아니라 디스크 로그에 쌓습니다 — 컨슈머가 죽어도 남아 있고, 읽어도 지워지지 않아 여러 시스템(정산·통계·검색색인)이 같은 데이터를 각자 읽습니다 국내 사례 — 커머스 주문 파이프라인 · 결제 이벤트 · 로그 수집 · 데이터 레이크 적재가 대부분 이 구조입니다
Kafka 의 한 줄 정의 — 생산 속도와 소비 속도를 분리해 주는, 지워지지 않는 로그.

언제 필요한가 — 신호 세 가지

신호Kafka 가 해 주는 것
피크 때 하위 시스템이 밀림버퍼로 흡수 — 받는 쪽은 자기 속도로 (📈 대규모 트래픽 x06)
같은 데이터를 여러 곳이 원함한 번 발행 → 각자 구독. 발행자는 소비자를 모릅니다 (🧱 MSA m07)
이벤트 순서·재처리가 중요로그라서 과거로 되감아 다시 읽기가 됩니다 — 버그 수정 후 재처리
메시지 "큐"와 무엇이 다른가. RabbitMQ 같은 전통 큐는 꺼내면 사라집니다 — 일감 분배에 알맞습니다. Kafka 는 읽어도 남는 로그라 구독자가 몇이든, 언제 들어오든 처음부터 읽을 수 있습니다. 상세 비교는 k11 에서 다룹니다.
SECTION 02

핵심 구조 — 토픽 · 파티션 · 오프셋

Kafka 를 안다는 것은 이 그림 하나를 안다는 뜻입니다. 나머지는 전부 여기서 나옵니다.

토픽 "orders" = 파티션 여러 개 · 파티션 = 뒤에만 추가되는 번호 붙은 로그 프로듀서 key=회원ID hash(key) % 3 P0 0 1 2 3 ←새 P1 0 1 2 ←새 P2 0 1 2 3 4 ←새 P1은 2까지 읽음 컨슈머 오프셋 기억 · 재시작 OK 순서 보장은 파티션 안에서만 — 같은 키(회원ID)는 같은 파티션 = 그 회원의 주문은 순서 유지 파티션 수 = 동시에 읽을 수 있는 컨슈머 수의 상한 = 병렬성의 단위 (k03 · k09)
외울 것은 세 문장 — 토픽은 파티션으로 나뉜다 · 순서는 파티션 안에서만 · 오프셋은 컨슈머가 기억한다.

용어 한 벌

용어뜻
브로커Kafka 서버 한 대. 보통 3대 이상 클러스터로 묶습니다
토픽메시지의 분류 채널 — orders · payments · click-logs
파티션토픽을 나눈 조각. 추가만 되는 로그이며 병렬성·순서의 단위
오프셋파티션 안에서 메시지의 번호. 컨슈머가 "어디까지 읽었나"를 이 번호로 저장
보존 기간읽든 안 읽든 설정한 기간(예: 7일) 보관 후 삭제 — 소비와 무관합니다
면접 단골 — "Kafka 는 왜 빠른가요?" ① 순차 쓰기 — 로그 끝에 붙이기만 해서 디스크에서도 빠릅니다 ② 제로카피 — 파일 → 소켓으로 커널이 직접 복사 (🧬 전문가 z05) ③ 배치·압축 — 메시지를 묶어서 보내고 저장합니다. "디스크라서 느리다"는 직관을 뒤집는 것이 포인트입니다.
SECTION 03

컨슈머 그룹 · 리밸런싱

"처리를 여러 대로 나누고 싶다"와 "같은 데이터를 여러 팀이 읽고 싶다"를 한 구조가 해결합니다.

같은 토픽 — 그룹 안에서는 나눠 읽고, 그룹끼리는 각자 다 읽습니다 P0 P1 P2 P3 orders 토픽 · 파티션 4 정산 그룹 (같은 일을 나눔) 컨슈머 A — P0·P1 컨슈머 B — P2·P3 통계 그룹 (따로 다 읽음) 컨슈머 C — P0~P3 전부 그룹에 컨슈머를 더하면 파티션을 다시 나눔 단, 컨슈머 수 > 파티션 수면 남는 컨슈머는 놉니다 리밸런싱 — 컨슈머가 들고나면 배정을 다시 섞습니다. 그 순간 그룹이 잠깐 멈춥니다
확장 공식 — 처리량이 부족하면 파티션과 컨슈머를 함께 늘립니다. 컨슈머만 늘리면 노는 놈이 생깁니다.

랙(Lag) — 가장 중요한 운영 지표

랙 = 마지막 오프셋 − 컨슈머가 커밋한 오프셋, 즉 "밀린 메시지 수"입니다. 랙이 0 근처를 유지하면 실시간으로 따라가는 것이고, 계속 증가하면 소비가 생산을 못 따라가는 것입니다 — 파티션·컨슈머 증설이나 처리 로직 최적화가 필요한 신호입니다. 피크 때 잠깐 쌓였다가 줄어드는 것은 정상입니다(그러라고 쓰는 버퍼입니다).

리밸런싱 폭풍. 컨슈머가 max.poll.interval.ms(기본 5분) 안에 다음 poll 을 못 하면 죽은 것으로 간주되어 쫓겨나고 리밸런싱이 돕니다. 한 배치 처리가 5분을 넘는 무거운 로직이 흔한 원인 — 처리 후 리밸런싱 → 재배정 후 또 초과 → 무한 반복으로 소비가 0이 됩니다. 해법: 배치 크기(max.poll.records)를 줄이거나 처리를 비동기로 나눕니다.
SECTION 04

설치 · 첫 메시지

KRaft 모드(ZooKeeper 불필요) 기준으로 10분 안에 첫 메시지를 보냅니다.

# docker-compose.yml — 개발용 단일 브로커 (KRaft 모드)
services:
  kafka:
    image: apache/kafka:3.8.0
    container_name: kafka                           # 아래 docker exec kafka … 가 이 이름을 씁니다
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller          # KRaft — ZooKeeper 없음
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1       # 단일 브로커라서 1
docker compose up -d

# ① 토픽 만들기 — 파티션 3
docker exec -it kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create --topic orders --partitions 3 --replication-factor 1

# ② 보내기 (콘솔 프로듀서 · key:value 형식)
docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server localhost:9092 --topic orders \
  --property parse.key=true --property key.separator=:
> user-7:주문 1건 15000원
> user-3:주문 1건 42000원

# ③ 받기 (콘솔 컨슈머 · 그룹 지정)
docker exec -it kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic orders \
  --group settle --from-beginning

# ④ 랙 확인 — 운영에서 가장 자주 치는 명령
docker exec -it kafka /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 --describe --group settle
실험 두 가지를 꼭 해 보세요. ① 컨슈머를 같은 그룹으로 두 개 띄우기 → 파티션이 나뉘어 절반씩 받습니다. ② 다른 그룹 이름으로 하나 더 띄우기 → 처음부터 전부 다시 받습니다. k03 의 그림이 눈앞에서 재현됩니다.
운영 최소 구성은 브로커 3대 · 복제 3입니다(k08). 단일 브로커는 개발·학습 전용입니다. 운영에서는 MSK(AWS)·Confluent Cloud 같은 관리형을 쓰는 회사도 많습니다 — 구조 이해는 똑같이 필요합니다.
SECTION 05

프로듀서 제대로 — acks · 멱등성 · 키

기본 설정으로 보내면 유실도 중복도 생깁니다. 설정 네 개가 방어선입니다.

acks — "언제 성공으로 칠 것인가"가 유실 가능성을 정합니다 프로듀서 리더 파티션 브로커 1 팔로워 (브로커 2) 팔로워 (브로커 3) acks=all 은 여기까지 쓰여야 "성공" acks=0 — 확인 안 함 · 가장 빠름 · 유실 감수 (클릭 로그 정도만) acks=1 — 리더에 쓰이면 성공 · 리더가 복제 전에 죽으면 유실 acks=all + min.insync.replicas=2 — 복제본까지 확인 · 유실 없음 · 주문·결제는 무조건 이것 enable.idempotence=true — 재시도로 같은 메시지가 두 번 저장되는 것을 브로커가 걸러 줍니다
돈이 걸린 토픽의 공식 — acks=all · 멱등성 켜기 · 키 지정. 성능이 필요한 로그만 느슨하게 갑니다.
// Spring Kafka 프로듀서 설정 감각
spring:
  kafka:
    producer:
      acks: all                       # 유실 방지
      properties:
        enable.idempotence: true      # 재시도 중복 방지 (acks=all 필요)
        max.in.flight.requests.per.connection: 5   # 멱등성 켜면 순서도 유지
      compression-type: lz4           # 배치 압축 — 처리량↑ 비용↓

// 보내기 — 키를 반드시 지정 (같은 회원 = 같은 파티션 = 순서 보장)
kafkaTemplate.send("orders", String.valueOf(memberId), orderJson);
키 없이 보내면 라운드로빈으로 파티션에 흩어집니다 — 같은 주문의 "생성 → 취소" 이벤트가 다른 파티션에 들어가 순서가 뒤집힐 수 있습니다. "취소가 먼저 처리되고 생성이 나중에" — 실제로 나는 사고입니다. 엔티티 ID 를 키로 쓰는 것이 기본기입니다. 단, 특정 키에 트래픽이 몰리면(연예인 계정) 그 파티션만 뜨거워집니다 — 핫 파티션(k09).
SECTION 06

컨슈머 제대로 — 커밋 · 중복 · 멱등

"언제 읽었다고 기록하느냐"가 유실과 중복을 가릅니다. 공짜 정답은 없습니다.

커밋 시점의 딜레마 — 유실이냐 중복이냐, 둘 중 하나는 감수해야 합니다 ① 먼저 커밋, 나중 처리 커밋 ✓ 처리 중 💥 죽음 재시작하면 다음 메시지부터 — 이 메시지는 영영 유실 (at-most-once) ② 먼저 처리, 나중 커밋 ★ 처리 ✓ 커밋 직전 💥 죽음 재시작하면 같은 메시지를 또 읽음 — 중복 처리 (at-least-once) 실무 표준 — ② 를 고르고, 처리를 멱등하게 만들어 "중복이 와도 무해"하게 방법: 이벤트 ID 를 유니크 키로 INSERT (중복이면 무시) · 처리 이력 테이블 · UPSERT
외울 문장 — "정확히 한 번 전달"은 없다. "최소 한 번 + 멱등 처리 = 사실상 정확히 한 번"이 실무의 답입니다.
// Spring Kafka — 처리 성공 후 자동 커밋 (기본 동작이 이미 ②입니다)
@KafkaListener(topics = "orders", groupId = "settle")
public void handle(ConsumerRecord<String, String> rec) {
    settleService.process(rec);        // 여기서 예외가 나면 커밋되지 않고 재시도
}

// 멱등 처리의 전형 — 이벤트 ID 유니크 제약
INSERT INTO processed_events (event_id) VALUES (?)   -- 중복이면 여기서 실패
ON CONFLICT DO NOTHING;                              -- 이미 처리한 것 → 조용히 스킵
독약 메시지(poison pill) — 파싱조차 안 되는 메시지가 오면 "실패 → 재시도 → 실패"로 그 파티션 소비가 영원히 멈춥니다. N번 실패한 메시지는 DLQ(Dead Letter Queue) 토픽으로 치우고 넘어가도록 처음부터 설계하세요 (Spring Kafka: DefaultErrorHandler + DeadLetterPublishingRecoverer). DLQ 는 모니터링 대상입니다 — 쌓이는데 아무도 안 보면 조용한 데이터 유실입니다.
SECTION 07

DB 와 Kafka 를 함께 — 아웃박스 · CDC

"DB 커밋과 이벤트 발행, 둘 중 하나만 성공하면?" — MSA 면접의 최고 빈출 문제입니다.

이중 쓰기 — 틈이 있다 ① DB 커밋 ✓ ② 발행 직전 💥 죽음 주문은 있는데 이벤트는 영영 없음 — 적립·알림 유실 아웃박스 — 한 트랜잭션에 함께 저장 하나의 DB 트랜잭션 INSERT orders INSERT outbox (이벤트) 폴링/CDC 릴레이 Debezium 등 Kafka 둘 다 저장되거나 둘 다 롤백 — 틈이 없습니다 릴레이가 죽어도 outbox 에 남아 있어 재발행됩니다 (최소 한 번 → 소비 측 멱등으로 마무리) CDC(Change Data Capture) — DB 의 변경 로그(binlog·WAL)를 Debezium 이 읽어 Kafka 로 흘리는 기술. 아웃박스의 릴레이로도, 검색색인·캐시·데이터 레이크 동기화의 표준 파이프라인으로도 쓰입니다
기억할 구조 — 비즈니스 데이터와 이벤트를 같은 트랜잭션으로 남기고, 발행은 뒤에서 따라간다.

Kafka 트랜잭션(EOS)은 언제 쓰나

Kafka 자체 트랜잭션은 "Kafka 에서 읽어 → 가공 → Kafka 로 쓰는" 스트림 구간을 정확히 한 번으로 만들어 줍니다(Kafka Streams exactly_once_v2). 하지만 외부 세계(DB 저장·메일 발송·결제 API)까지는 묶지 못합니다. 그래서 경계의 답은 결국 — DB 와의 원자성은 아웃박스, 소비 측은 멱등 처리입니다.

면접 모범 답안 골격 — "DB 커밋과 발행 사이 유실은 아웃박스 패턴으로 막고, 그 대가로 생기는 재발행 중복은 소비 측 멱등 처리로 무해하게 만듭니다. Kafka 트랜잭션은 Kafka 안 스트림 처리에만 적용됩니다" — 이 세 문장이면 충분합니다.
SECTION 08

복제 · ISR — 브로커가 죽어도

"브로커 한 대가 내려가면 어떻게 되나요?" — 운영 면접의 단골이자, 실제 새벽의 일입니다.

파티션 P0 의 복제 — 리더가 죽으면 ISR 에서 새 리더가 나옵니다 리더 (브로커 1) 💥 다운! 팔로워 (브로커 2) 완전히 따라옴 — ISR ✓ 팔로워 (브로커 3) 10초 뒤처짐 — ISR ✗ 브로커 2 가 새 리더로 ISR 안에서만 승격 — 유실 없음 ISR(In-Sync Replicas) = 리더를 제때 따라오는 복제본 집합 (리더 포함) acks=all 은 "ISR 전부에 쓰였을 때" 성공 — 그래서 acks=all + 복제 3 + min.insync.replicas=2 가 한 세트 ISR 이 1개로 줄면? min.insync.replicas=2 가 쓰기를 거절합니다 — 유실보다 일시 정지를 택하는 것 unclean.leader.election=false(기본) — 뒤처진 복제본(브로커 3)의 리더 승격 금지. 켜면 유실 감수
가용성과 유실 방지는 트레이드오프입니다 — Kafka 의 기본값은 "유실 방지 우선"이고, 대개 그게 맞습니다.
운영 표준 설정의미
replication.factor=3파티션마다 복제본 3개 — 브로커 3대 이상
min.insync.replicas=2최소 2곳에 쓰여야 성공 — 1대 다운까지는 무중단·무유실
acks=all (프로듀서)위 둘과 한 세트 — 하나라도 빠지면 구멍이 생깁니다
컨트롤러 — 클러스터에서 "누가 리더인가"를 결정하는 브로커입니다. KRaft 모드(3.x 표준)에서는 컨트롤러들이 Raft 합의로 메타데이터를 관리합니다 — ZooKeeper 는 더 이상 없습니다. Raft 자체가 궁금하면 🎓 CS 가이드 분산 탭(d14)으로.
SECTION 09

운영 — 파티션 수 · 랙 · 보존

Kafka 운영의 세 가지 상시 질문 — 파티션 몇 개? 랙 왜 늘어? 디스크 왜 차?

파티션 수 정하기 — 실전 산식

필요 파티션 수 ≥ 목표 처리량 ÷ 컨슈머 1대의 처리량

예) 피크 10,000건/s, 컨슈머 1대가 1,000건/s 처리
    → 최소 10개. 성장 여유를 두어 12~15개로 시작

원칙
· 파티션은 늘릴 수는 있지만 줄일 수 없습니다
· 늘리면 key → 파티션 배정이 바뀝니다 (기존 키의 순서 연속성 끊김!)
  → 처음부터 여유 있게 잡는 이유
· 너무 많으면(수천 개) 리더 선출·메타데이터 부담 — 무한정 늘리는 것도 답이 아님

운영 체크 항목

항목내용
컨슈머 랙그룹별 랙 추이를 대시보드에 — "계속 증가"가 경보 조건입니다 (Burrow · kafka_exporter + Grafana)
보존 정책retention.ms(기간) · retention.bytes(용량). 디스크가 차는 것은 대부분 보존 과다
컴팩션cleanup.policy=compact — 키별 최신 값만 유지. 상태 스냅숏 토픽(재고 현황 등)에
핫 파티션특정 키 폭주로 한 파티션만 랙 증가 — 키 설계 변경(키에 버킷 붙이기)으로 해소
브로커 디스크·네트워크디스크 80% 경보 · 네트워크 포화 — 브로커 증설·파티션 재배치(reassign)
토픽·스키마는 계약입니다. 발행 측이 필드 하나 빼는 순간 소비 측이 줄줄이 깨집니다. Avro/Protobuf + 스키마 레지스트리로 하위 호환을 강제(BACKWARD)하는 것이 조직이 커졌을 때의 표준 장치입니다. JSON 을 쓰더라도 "필드는 더하기만 한다" 규칙은 지키세요.
재처리는 Kafka 의 특권입니다. 소비 로직 버그를 고친 뒤 kafka-consumer-groups.sh --reset-offsets --to-datetime … 으로 오프셋을 되감아 다시 읽으면 됩니다 — 단, 소비가 멱등해야 안전한 재처리가 됩니다(k06). 이 되감기가 필요해서라도 멱등 설계는 필수입니다.
SECTION 10

실전 파이프라인 — 스트림 · 데이터 흐름

Kafka 가 회사의 중앙 신경계가 되는 그림 — 대규모 데이터 처리의 표준 구도입니다.

중앙 신경계 구도 — 만드는 쪽도 쓰는 쪽도 서로를 모릅니다. Kafka 만 압니다 주문 서비스 결제 서비스 DB 변경분 CDC · Debezium Kafka orders · payments · cdc.* 정산 서비스 실시간 집계 Streams · Flink 검색 색인 (ES) 데이터 레이크 S3 · 분석용 새 소비자(추천 팀·ML 파이프라인)가 생겨도 — 구독만 추가하면 끝. 기존 시스템은 아무것도 안 바꿉니다
이 그림이 "대규모 데이터 처리에 Kafka 를 쓴다"의 실체입니다 — 모든 데이터가 한 번은 Kafka 를 지나갑니다.

스트림 처리 — 흘러가면서 계산

// Kafka Streams — "최근 1분간 주문 금액 합계"를 흘러가면서 집계
KStream<String, Order> orders = builder.stream("orders");
orders
  .groupBy((k, v) -> v.category())
  .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(1)))
  .aggregate(() -> 0L, (k, v, sum) -> sum + v.amount())
  .toStream().to("orders-per-minute");   // 결과도 토픽으로 — 대시보드가 구독

// 배치와의 차이 — "다 모아서 밤에 계산"이 아니라 "지나갈 때 바로 계산"
// 더 큰 규모·복잡한 처리는 Flink 를 씁니다 (별도 클러스터)
이벤트 소싱과는 구분하세요. 이벤트 소싱은 "상태 대신 이벤트 전체를 진실의 원본으로 저장"하는 설계 패턴으로, 난도가 높아 정산·회계처럼 이력이 곧 가치인 도메인에서만 씁니다. Kafka 파이프라인(위 그림)은 이벤트 소싱 없이도 충분히 성립합니다 — 섞어 말하면 감점입니다.
SECTION 11

Kafka vs RabbitMQ · Redis — 선택 기준

"왜 Kafka 를 썼나요?" 에 비교 근거로 답할 수 있어야 합니다.

KafkaRabbitMQRedis (Streams·Pub/Sub)
모델지워지지 않는 로그꺼내면 사라지는 큐인메모리 — 가볍고 빠름
처리량초당 수십만~수백만초당 수만초당 수만~수십만
재읽기가능 — 오프셋 되감기불가 (기본)Streams 는 제한적 가능
다중 구독그룹마다 독립익스체인지 구성 필요Pub/Sub 은 접속 중일 때만
라우팅토픽 단위 (단순)정교 — 패턴·헤더·우선순위·지연단순
운영 부담큼 — 클러스터·파티션 관리중간작음 (이미 있는 경우 多)

고르는 규칙

상황선택
이벤트 파이프라인 · 대용량 로그 · 여러 팀이 같은 데이터 구독 · 재처리 필요Kafka
작업 큐(이메일 발송·이미지 변환) · 복잡한 라우팅 · 지연 큐·우선순위RabbitMQ
소규모 비동기 · 이미 Redis 가 있고 유실 약간 감수 · 실시간 알림 팬아웃Redis
규모의 정직함도 답입니다. 하루 수십만 건 수준이면 RabbitMQ 나 Redis Streams, 심지어 DB 테이블 큐로도 충분합니다. "재처리·다중 구독·수십만 TPS 요구가 생겨 Kafka 로 갔다"처럼 규모와 요구가 도구를 정한다는 서사가 면접에서 가장 설득력 있습니다.
HIGH TRAFFIC · CACHE · SHARDING · RATE LIMIT · AUTOSCALING · CAPACITY

대규모 트래픽 —
백만 동시 접속을
버티는 설계

선착순 쿠폰 오픈, 티켓팅, 이벤트 페이지 — 평소의 100배 트래픽이 몰리는 순간 무엇이 먼저 무너지고, 어떻게 버티게 만드는가. 무상태 설계·캐시 계층·DB 확장·큐·유량 제어·오토스케일링까지, 대기업 시스템 설계 면접의 단골 주제 전부를 실전 순서로 다룹니다.

12SECTIONS
×100피크 배수 대응
p99지연 관리 기준
SECTION 01

대규모의 기준 — 숫자 감각

"대규모 트래픽 경험"을 말하려면 먼저 숫자로 환산하는 감각이 필요합니다.

DAU(하루 사용자)  →  평균 RPS 환산 감각

DAU 100만 · 1인당 하루 요청 50개
= 5,000만 요청/일 ÷ 86,400초 ≈ 평균 580 RPS

피크는 평균의 5~10배               →  3,000~6,000 RPS
이벤트(쿠폰 오픈)는 평균의 100배+  →  수만~수십만 RPS 가 몇 분에 집중

서버 한 대의 감각 (충분히 튜닝했을 때)
· Nginx 정적 파일        수만 RPS
· 백엔드 앱 (가벼운 API)  수백~수천 RPS
· DB 단순 조회            수천~수만 QPS · 쓰기는 훨씬 작음
요청의 여정 — 바깥은 늘리기 쉽고, 안쪽으로 갈수록 늘리기 어렵습니다 CDN x03 LB ⚖️ 탭 앱 ×N x02 · x08 캐시 x04 DB x05 — 최후의 병목 ← 안쪽일수록 확장이 어렵다 — 그래서 전략은 하나입니다: 요청을 최대한 바깥쪽에서 끝내서 안쪽에 도달하는 수를 줄인다 요청 100건이 어디서 끝나는가 — 막대 길이가 곧 건수입니다 CDN 에서 끝 — 70건 캐시에서 끝 25건 DB 까지 5건 0 25 50 75 100 요청 100건 중 (건) 병목은 언제나 "한 곳"입니다 — 재기 전에는 모릅니다. 그래서 부하 테스트(x10)가 마지막에 있습니다
대규모 설계의 제1원칙 — 안쪽(DB)을 지키기 위해 바깥(CDN·캐시)에서 끝낸다.
면접에서 숫자를 먼저 물으세요. "대규모 트래픽 처리 설계해 보세요"가 나오면 DAU·피크 RPS·읽기/쓰기 비율·응답 목표(p99)부터 확인하는 것이 시작입니다. 숫자 없이 아키텍처부터 그리면 과잉 설계로 흐릅니다 — 그 자체가 감점 포인트입니다.
SECTION 02

무상태 설계 — 확장의 전제

서버를 100대로 늘리려면 먼저 어느 대에 요청이 가도 같게 만들어야 합니다.

상태가 서버 안에 — 못 늘림 서버 1 세션 · 업로드 파일 서버 2 (아무것도 모름) 다음 요청이 서버 2로 가면 — 로그아웃 · 파일 404 상태를 밖으로 — 마음껏 늘림 서버 1 서버 2 서버 N… 늘리고 줄이고 자유 Redis 세션 · 카운터 S3 · NAS 업로드 파일 서버는 언제 죽고 새로 떠도 되는 소모품이 됩니다 — 오토스케일링(x08)의 전제
스티키 세션은 임시방편입니다 — 서버가 죽으면 그 세션은 끝. 상태 외부화가 정공법입니다.

서버 밖으로 빼야 하는 것들

상태보내는 곳
로그인 세션Redis (세션 스토어) — 또는 JWT 로 상태 자체를 토큰에
업로드 파일S3 · 오브젝트 스토리지 — 로컬 디스크 금지
캐시Redis — 로컬 캐시는 서버마다 달라져 정합성 문제 (x04)
스케줄 작업분산 락 또는 전용 워커 — 안 하면 N대가 같은 배치를 N번 돌립니다
순번·카운터Redis INCR · DB — 메모리 변수는 서버마다 따로 셉니다
세션 vs JWT — 세션(Redis)은 즉시 강제 로그아웃이 되지만 매 요청 조회가 필요하고, JWT 는 조회가 없지만 만료 전 무효화가 어렵습니다. 실무 절충: 수명 짧은 액세스 토큰(JWT) + 리프레시 토큰(서버 저장). "JWT 로그아웃 어떻게 처리하나요?"는 면접 단골입니다 — 블랙리스트 또는 짧은 만료가 답의 골격.
SECTION 03

캐시 계층 ① — 브라우저 · CDN

가장 빠른 요청은 서버에 도착하지 않는 요청입니다.

캐시 깔때기 — 10,000 요청이 층마다 걸러져 서버엔 2,000만 도달합니다 브라우저 캐시 Cache-Control 3,000건 여기서 끝 7,000 CDN 엣지 전국·전 세계 분산 5,000건 여기서 끝 2,000 원본 서버 진짜 일은 여기만 CDN 이 끝내는 것 — 이미지 · JS/CSS · 폰트 · 동영상 · (설정하면) API 응답까지 정적 자산 전략 — 파일명에 해시(app.3f2a.js) + max-age=31536000, immutable → 갱신 걱정 없는 영구 캐시 HTML 은 no-cache(재검증) 또는 짧은 s-maxage — "배포했는데 옛 화면"의 대부분은 HTML 캐시 실수입니다
이벤트 페이지의 정답 — 페이지 전체를 정적으로 만들어 CDN 에 얹는 것. 서버는 열리는 순간의 API 만 받습니다.
# 원본(Nginx)에서 캐시 헤더 지정 — CDN 과 브라우저가 이것을 따릅니다
location /static/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
    add_header Cache-Control "no-cache";          # 매번 재검증 (ETag 로 304)
}
location /api/products {
    add_header Cache-Control "public, s-maxage=30, stale-while-revalidate=60";
    # CDN 이 30초 캐시 — 상품 목록 API 부하가 1/N 로
}
개인화 응답을 CDN 에 태우면 사고입니다. Cache-Control: private 없는 "내 주문 목록"이 CDN 에 캐시되면 남의 주문이 보입니다 — 실제로 반복되는 유형의 사고입니다. 쿠키·Authorization 이 붙는 응답은 기본 private, no-store 로 두고, 캐시할 것만 명시적으로 여세요.
SECTION 04

캐시 계층 ② — Redis 패턴과 함정

DB 앞의 마지막 방패. 그리고 대규모 장애의 단골 출발점이기도 합니다.

# Cache-Aside — 실무 표준 패턴
def get_product(pid):
    data = redis.get(f"product:{pid}")
    if data:                     # 히트 — DB 안 감
        return json.loads(data)
    row = db.query(...)          # 미스 — DB 조회
    redis.setex(f"product:{pid}", 300, json.dumps(row))   # TTL 필수!
    return row

# 쓰기 시 — 갱신하지 말고 지우세요 (다음 조회가 다시 채움)
def update_product(pid, ...):
    db.update(...)
    redis.delete(f"product:{pid}")   # 갱신(set)은 동시성 순서 꼬임 위험
캐시 스탬피드 — 인기 키 만료의 순간, 미스 1,000개가 동시에 DB 로 요청 1,000/s 같은 인기 상품 캐시 키 만료! 전부 미스 ×1,000 DB 💥 같은 쿼리 폭탄 해법 — 한 명만 보내고, 나머지는 기다리거나 헌 값을 씁니다 ① 뮤텍스 락 — 미스 시 락을 잡은 한 요청만 DB 조회, 나머지는 잠깐 대기 후 캐시 재확인 ② 조기 갱신 — TTL 남은 시간이 짧으면 확률적으로 미리 갱신 · 배치로 인기 키 선갱신 ③ 헌 값 허용 — 논리 만료(soft TTL): 만료 뒤에도 잠깐은 옛 값을 주고 뒤에서 갱신
TTL 에 지터(무작위 ±10%)를 더하는 것도 기본기 — 같은 시각 일괄 만료를 애초에 막습니다.

그 밖의 함정들

함정내용 · 해법
캐시 관통존재하지 않는 키(악의적 무작위 ID) 조회는 매번 DB 로 — "없음"도 짧게 캐시 · 블룸 필터
핫 키단일 키에 초당 수십만 조회 → Redis 한 노드 한계 — 로컬 캐시 1~2초를 앞에 겹칩니다
빅 키수 MB 값·수십만 원소 컬렉션 — 조회·삭제가 Redis 를 블로킹. 쪼개 두세요
전멸 대비Redis 가 통째로 죽으면 전 요청이 DB 로 — 그 순간의 DB 보호가 x07(유량 제어)입니다
무엇을 캐시하나 — 우선순위 공식. 조회 빈도 × 생성 비용 ÷ 변경 빈도가 큰 것부터. 상품 상세·랭킹·설정처럼 "많이 읽고, 만들기 비싸고, 가끔 변하는" 데이터가 1순위입니다. 잔액처럼 틀리면 안 되는 데이터는 캐시 대상이 아니라 DB 가 진실입니다.
SECTION 05

DB 확장 — 레플리카 · 샤딩

캐시로도 막을 수 없는 트래픽이 오면, 마지막 병목 DB 자체를 늘려야 합니다.

읽기 확장 — 리드 레플리카 앱 쓰기 전부 프라이머리 읽기 레플리카 1 레플리카 2 읽기:쓰기 = 9:1 인 보통 서비스에 즉효 — 단, 복제 지연 주의 (쓰기 직후 조회는 프라이머리로) 쓰기 확장 — 샤딩 (마지막 수단) 앱 라우터 hash(회원ID) % 3 샤드 0 회원 0~33% 샤드 1 33~66% 샤드 2 66~ 쓰기 자체가 나뉩니다 — 대신 샤드 간 JOIN·트랜잭션 불가, 재분배(리샤딩) 대공사
순서를 지키세요 — 인덱스·쿼리 튜닝 → 캐시 → 레플리카 → 파티셔닝 → 샤딩. 샤딩은 마지막입니다.

샤딩 전에 알아야 할 것

주제내용
샤드 키 선택대부분의 쿼리에 포함되는 키(회원ID·주문ID). 잘못 고르면 모든 샤드를 뒤지는 쿼리가 됩니다
해시 vs 범위해시 — 균등 분산·범위 조회 불리 / 범위 — 시계열 유리·최신 샤드 과열
크로스 샤드전체 집계·검색은 별도 경로로 — 통계 DB·검색 엔진에 CDC 로 복제해 처리 (📨 k07)
대안 먼저Vitess·Citus 같은 샤딩 미들웨어, 또는 처음부터 분산 DB — 직접 구현은 최후입니다
더 깊게 — 복제 지연의 원리와 대처는 🗄️ DB 가이드(MySQL 복제·심화 탭), 파티셔닝 vs 샤딩 구분도 그쪽에서 다룹니다. 여기서는 "언제 무엇을 꺼내 드는가"의 순서가 핵심입니다.
SECTION 06

큐로 피크 흡수 — 비동기 전환

"지금 다 처리"를 포기하면 10배의 피크가 평지가 됩니다.

피크 흡수 — 뾰족한 유입을 큐가 받고, 워커는 평평하게 처리합니다 50,000/s 스파이크 유입 트래픽 큐 Kafka · SQS — 쌓아 둠 워커 1 워커 2 워커 N 5,000/s 일정하게 처리 속도 사용자 경험 설계가 함께 갑니다 — "접수되었습니다(즉시)" → 처리 → "발급 완료(푸시·폴링)" 적용 대상 — 쿠폰 발급 · 주문 후처리 · 알림 발송 · 정산 · 이미지 처리 · 대량 등록 동기로 남길 것 — 결제 승인처럼 응답에 결과가 꼭 필요한 것 (🧱 MSA m07 의 판단 기준) 큐 깊이(랙)가 곧 대기 시간입니다 — 랙 모니터링과 워커 오토스케일링(x08)을 함께 두세요
면접 표현 — "쓰기 피크는 큐로 평탄화(leveling)하고, 사용자에게는 접수·완료를 나눠 알립니다."
선착순의 진실 — 선착순 쿠폰 1만 장의 정답 구조는 ① Redis 원자 연산(INCR·Lua)으로 순번만 즉시 판정 ② 당첨 요청만 큐로 넘겨 발급은 비동기 ③ DB 에는 워커가 차분히 기록. "DB 로 1만 동시 UPDATE" 를 피하는 것이 설계의 전부입니다. (x12 에서 전체 답안)
SECTION 07

유량 제어 — Rate Limit · 셰딩

한계는 반드시 옵니다. 전부 죽는 것과 일부를 거절하는 것 중에 골라야 합니다.

토큰 버킷 — 평균 속도는 제한하되, 순간 버스트는 통 크기만큼 허용 초당 100개 리필 💰 토큰 통 용량 200 (버스트 허용) 현재 3개 요청 토큰 1개 필요 토큰 있음 → 통과 처리 통 비었음 → 429 429 Too Many Requests Retry-After 헤더와 함께 거는 위치 — 게이트웨이/Nginx(limit_req)에서 IP·사용자·API별로. 안쪽 서비스 간에도 겁니다 분산 환경 — 서버 N대가 카운터를 공유해야 하므로 Redis 기반(INCR + EXPIRE · Lua)으로 구현합니다 통이 가득 찬 상태에서 몰리면 — 200건은 즉시, 그다음부터 초당 100건씩 버스트 200건 토큰 버킷 — 통 200 + 초당 100 리필 속도만 있었다면 (초당 100) 0 200 400 0 1 2 3초 누적 처리 건수
429 는 실패가 아니라 시스템을 지키는 설계된 응답입니다 — 전면 장애보다 백 배 낫습니다.

함께 쓰는 방어 도구

도구내용
부하 셰딩과부하 신호(큐 길이·p99)가 뜨면 덜 중요한 요청부터 즉시 거절 — 추천은 버리고 주문은 지킵니다
백프레셔"밀린다"는 신호를 상류로 전파해 유입 자체를 늦춥니다 — 무한 버퍼는 지연 폭탄일 뿐입니다 (🧬 z09)
대기열 시스템티켓팅의 "대기표" — 초과 유입을 순번 페이지에 세워 두고 처리량만큼만 입장시킵니다
기능 플래그비상시 무거운 기능(실시간 랭킹·추천)을 스위치로 끕니다 — 성능 예산을 핵심에 몰아주기
클라이언트 재시도까지 설계에 넣으세요. 429·503 을 받은 클라이언트가 즉시 재시도하면 부하는 배가 됩니다. 응답에 Retry-After 를 담고, 앱·웹 클라이언트는 지수 백오프 + 지터로 재시도하게 합니다 — 서버와 클라이언트가 한 팀으로 움직여야 진짜 방어입니다.
SECTION 08

오토스케일링 — 늘어나는 인프라

"미리 넉넉히"는 비용 낭비, "부족하면 수동 증설"은 새벽 호출입니다. 자동이 답입니다.

오토스케일링 루프 (k8s HPA 기준) — 재고, 비교하고, 늘리고, 검사 후 투입 ① 메트릭 수집 CPU 85% · RPS · 큐 랙 ② 목표와 비교 목표 CPU 60% ③ 4대 → 6대 replicas 조정 ④ 준비 검사 통과 → LB 투입 30초~수 분 주기로 반복 · 줄일 때는 천천히 (안정화 창) 전제 셋 — 무상태(x02) · 빠른 기동(이미지 슬림 · 워밍업) · 준비/생존 프로브 정직하게 축소의 예의 — 빼기 전 드레인(새 요청 차단 + 진행 중 요청 완료)은 ⚖️ 로드밸런싱 탭의 무중단 배포와 같은 원리
스케일링 신호는 CPU 만이 아닙니다 — 워커라면 큐 랙 기준(KEDA)이 훨씬 정확합니다 (x06).

현실 체크리스트

항목내용
급증은 못 따라갑니다새 파드 뜨는 데 수십 초~수 분 — 예고된 이벤트는 사전 증설(스케줄 스케일링)이 정답
하한을 두세요min replicas 를 피크 직전 수준으로 — 0→100 은 자동화로도 못 버팁니다
아래층도 함께앱만 늘면 DB 커넥션 풀이 곱해집니다 — 커넥션 수·캐시·DB 용량을 같이 계산
비용 상한max replicas + 비용 알람 — 오토스케일링 사고는 요금 고지서로 옵니다
서버리스(람다)는? 트래픽이 0↔폭증을 오가는 보조 기능(썸네일 생성·웹훅)에 좋습니다. 상시 대량 트래픽의 코어 API 는 컨테이너 + HPA 가 비용·지연 면에서 무난한 표준입니다. 콜드 스타트와 DB 커넥션 문제(프록시 필요)가 서버리스의 대표 함정입니다.
SECTION 09

규모별 아키텍처 견본 — 1만 · 10만 · 100만

동시 접속 규모마다 꺼내 드는 도구가 다릅니다. 한 장으로 정리합니다.

규모가 한 단계 오를 때마다 — 이전 단계 구성에 아래 도구가 "추가"됩니다 동시 1만 · 수백 RPS LB (Nginx) 1쌍 앱 서버 2~3대 (무상태) DB 프라이머리 + 레플리카 1 Redis 1 (세션·캐시) 모니터링 기본 세트 여기까지로 대부분의 서비스가 충분합니다 동시 10만 · 수천 RPS + CDN (정적 전부 오프로드) + 앱 오토스케일 5~20대 + Redis 복제·센티널/클러스터 + 레플리카 2~3 · 커넥션풀 산정 + 큐·워커 (쓰기 비동기화) + Rate Limit · APM 필수 병목이 앱 → DB 로 이동, 캐시 적중률이 생명선 동시 100만 · 수만 RPS+ + DB 샤딩 · 분산 DB + Kafka 중앙 파이프라인 + 대기열 (티켓팅 진입 제어) + 멀티 AZ/리전 · DR 훈련 + 서비스 분리 (MSA 탭) + 전담 SRE · 카오스 테스트 기술보다 조직·운영 체계가 병목이 되는 구간
중요한 것은 순서입니다 — 1만 규모에 샤딩·MSA 를 꺼내는 것은 설계가 아니라 낭비입니다.
면접에서 이 그림을 쓰는 법 — 요구 숫자를 확인하고(x01), 해당 열의 구성으로 시작해 "병목이 관측되면 다음 도구를 추가한다"는 진화 서사로 설명하세요. 처음부터 100만 아키텍처를 그리는 것보다 단계별 확장 근거를 말하는 쪽이 항상 높은 평가를 받습니다.
SECTION 10

부하 테스트 — 한계를 미리 잰다

"몇 RPS 까지 버티나요?"에 측정값으로 답하는 사람과 감으로 답하는 사람의 차이입니다.

// k6 — 코드로 쓰는 부하 테스트 (설치: brew/choco install k6)
import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  stages: [
    { duration: "2m", target: 200 },    // 2분에 걸쳐 200 VU 까지 증가
    { duration: "5m", target: 200 },    // 5분 유지 — 안정 구간 관측
    { duration: "2m", target: 1000 },   // 피크 시나리오
    { duration: "2m", target: 0 },      // 회복 관측 (이것도 중요!)
  ],
  thresholds: {
    http_req_duration: ["p(99)<500"],   // p99 500ms 넘으면 실패 처리
    http_req_failed:   ["rate<0.01"],   // 에러율 1% 미만
  },
};

export default function () {
  const res = http.get("https://stage.example.com/api/products?page=1");
  check(res, { "200": (r) => r.status === 200 });
  sleep(1);   // 실제 사용자처럼 간격 — 없으면 비현실적 최대치만 나옵니다
}

읽는 법 — 평균은 버리고 꼬리를 봅니다

지표의미
p50 / p99평균 80ms 여도 p99 가 3초면 100명 중 1명은 3초 — 사용자 경험은 꼬리가 정합니다
무릎점부하를 올리다 지연이 급격히 꺾이는 지점 — 그 직전이 실용 한계 용량입니다
에러율 변화어느 RPS 부터 5xx·타임아웃이 시작되나 — 그때 어느 자원(CPU·풀·DB)이 먼저 소진됐는지가 병목
회복부하를 내렸을 때 정상으로 돌아오나 — 못 돌아오면(풀 고갈·좀비 커넥션) 더 심각한 문제입니다
테스트의 3원칙. ① 운영과 같은 구성의 스테이징에서 (캐시 끈 로컬 테스트는 무의미) ② 시나리오는 실제 트래픽 믹스로 (조회 90 : 쓰기 10, 로그인 흐름 포함) ③ 병목을 하나 고치면 다음 병목이 나타납니다 — 측정→개선→재측정의 반복이 튜닝입니다 (⚡ 성능 탭 p13).
도구 선택 — k6(코드 기반·CI 통합, 추천) · JMeter(GUI·레거시 표준) · nGrinder(네이버 오픈소스, 국내 기업 채용공고 단골) · wrk(단일 URL 최대치 측정). 이력서에는 도구 이름보다 "p99 를 몇에서 몇으로, 무엇을 고쳐서"가 백 배 강합니다.
SECTION 11

폭주 당일 — 운영 시나리오

설계가 끝이 아닙니다. 이벤트 D-day 를 어떻게 준비하고 넘기는가가 진짜 경험입니다.

시점할 일
D-7예상 피크 산정(x01) → 부하 테스트로 검증(x10) → 부족분 증설 계획
D-1사전 증설(오토스케일링 하한 상향 · DB/Redis 스펙업) · 배포 동결 · 대기열 리허설
D-day 직전대시보드 한 화면에(골든 시그널 · 큐 랙 · DB) · 온콜 집합 · 기능 플래그 손에
오픈 순간p99 · 에러율 · 큐 랙 응시 — 이상 시 준비된 순서로: 무거운 기능 off → 셰딩 강화 → 대기열 조이기
종료 후증설분 축소 · 회고(무엇이 먼저 아팠나) → 다음 이벤트의 설계 입력값으로

그날 터지는 단골 장애

장애원인 · 예방
오픈 0초, 앱은 멀쩡한데 DB 급사캐시가 비어 있었음 — 캐시 예열(warm-up)을 오픈 전에
커넥션 풀 고갈 → 전 API 타임아웃앱 증설로 커넥션이 곱해짐 — 풀 수 × 대수 ≤ DB 한계 재계산 (⚡ p11 · 🗄️ DB)
재시도 폭풍으로 2차 붕괴클라이언트 백오프 미비 — x07 의 Retry-After 설계
로그 폭증으로 디스크 풀피크 때 로그량은 수십 배 — 샘플링·레벨 조정 스위치 준비
배포 직후 이벤트와 겹침이벤트 전 배포 동결 창 — 변수를 하나라도 줄입니다
런북(runbook)을 미리 쓰세요. "p99 1초 초과 시 → ① 추천 off ② 셰딩 레벨 2 ③ 대기열 유입 50%로" — 새벽 3시의 판단을 대낮에 미리 해 두는 문서입니다. 장애 중에 생각하는 팀과 실행만 하는 팀의 차이가 복구 시간의 차이입니다.
SECTION 12

설계 면접 — 단골 문제 풀이 골격

이 탭 전체를 면접 답안 형태로 압축합니다. 골격을 외우면 응용이 됩니다.

공통 진행 순서 — 어떤 문제든 이 순서로

1
요구 숫자 확인DAU · 피크 RPS · 읽기/쓰기 비율 · 지연 목표 · 정합성 요구 (x01)
▼
2
기본 뼈대CDN → LB → 무상태 앱 → 캐시 → DB(레플리카) — 규모별 견본에서 시작 (x09)
▼
3
읽기 경로 최적화캐시 전략·TTL·스탬피드 대책 (x03·x04)
▼
4
쓰기 경로 최적화큐로 평탄화·멱등성·순서 (x06 · 📨 Kafka)
▼
5
병목·장애 시나리오"여기가 죽으면?"을 스스로 먼저 — 유량 제어·폴백·모니터링 (x07 · 🧱 m09)

단골 문제 3제 — 핵심 답 구조

문제답의 골격
선착순 쿠폰 1만 장 페이지는 CDN 정적화 → 진입 Rate Limit → Redis 원자 감소로 순번 판정 → 당첨만 큐 → 워커가 DB 기록 · 결과는 폴링/푸시. DB 직행 금지가 핵심
타임라인/피드 읽기 폭발 문제 — 팬아웃 온 라이트(쓸 때 팔로워 타임라인 캐시에 밀어 넣기) 기본, 연예인(팔로워 수백만)은 팬아웃 온 리드로 혼합. 캐시가 사실상 저장소
실시간 랭킹 Redis Sorted Set(ZINCRBY·ZREVRANGE)으로 실시간 집계 · 영속화는 이벤트를 Kafka 로 흘려 배치 정산 — 실시간과 정확성의 이원화
마지막 한 문장. 대규모 처리의 모든 기법은 결국 세 가지입니다 — ① 요청을 줄인다(캐시) ② 나눈다(분산·샤딩) ③ 미룬다(큐·비동기). 어떤 신기술이 나와도 이 세 동사 중 하나입니다. 설계가 막히면 이 셋 중 무엇을 안 썼는지 보세요.
WINDOWS · 사내 공개 · 공유기 포트포워딩까지

윈도우 PC 하나로
사내에 서비스
띄우기

Nginx 도 Apache 도 없이, 만든 프로그램을 그냥 IP 와 포트로 여는 방법만 다룹니다. 사내 직원들이 http://192.168.0.50:8080 으로 들어오게 만드는 것이 1차 목표, IPTIME 공유기에서 포트포워딩해 집이나 외근지에서도 열리게 만드는 것이 2차 목표입니다. 마지막으로 외부에 열었을 때 최소한으로 해야 할 보안을 정리합니다. 명령은 전부 윈도우 기준으로, 복사해 붙이면 되게 적었습니다.

12섹션
8그림
0사전 지식
1대의 PC
SECTION 01

무엇을 만드는가 — 전체 그림

우리가 하려는 일은 "내 PC 에서 돌아가는 프로그램을 다른 사람 브라우저에서 열리게 하는 것" 하나입니다. 여기에 필요한 것은 세 가지뿐입니다 — 프로그램이 떠 있을 것 · 방화벽이 열려 있을 것 · 상대가 주소를 알 것.

두 갈래 길 — 사내에서 들어오는 길과 밖에서 들어오는 길 ① 사내망 (같은 사무실) 직원 PC 192.168.0.31 http://192.168.0.50:8080 ② 외부 (집 · 외근 · 휴대폰) 바깥 브라우저 http://공인IP:8080 IPTIME 공유기 포트포워딩 8080 → 192.168.0.50:8080 서버 PC 192.168.0.50 윈도우 방화벽 — 8080 인바운드 허용 내 프로그램 0.0.0.0:8080 으로 대기 중 막히면 이 순서로 봅니다 ① 프로그램이 떠 있나 (v03) ② 방화벽이 열렸나 (v04) ③ 포트포워딩이 맞나 (v07) ④ 공인 IP 가 진짜인가 (v08)
사내 공개는 ①②만으로 끝나고, 외부 공개는 ③④가 더 붙습니다.

웹서버(Nginx·Apache)를 안 써도 되나요

사내에서 수십 명이 보는 도구라면 됩니다. Spring Boot · Node · Django · .NET 은 모두 자체 서버 기능을 갖고 있어 그 자체로 요청을 받습니다. 앞단 웹서버는 정적 파일 처리 · HTTPS · 여러 앱 분배가 필요할 때 붙이는 것이고, 지금 목표에는 없어도 됩니다.

이럴 때이렇게
사내 도구 · 사내 대시보드지금 이 탭 그대로 — 앱만 띄우고 방화벽만 엽니다
외부 공개 + 도메인 + HTTPSv11 의 Caddy 방식 또는 Caddy 탭으로 — 인증서가 자동입니다
동시 접속 수백 이상 · 여러 앱Nginx 탭으로 — 리버스 프록시 구조가 필요합니다
회사 규정상 서버실 밖 공개 금지사내망까지만(v05). 외부 공개는 VPN 으로 — v10 에 씁니다
이 탭에서 쓰는 예시
· 서버 PC 사설 IP 192.168.0.50 · 서비스 포트 8080 · 공유기 IPTIME(192.168.0.1)
· 프로그램은 Spring Boot jar · Node · Python 중 무엇이든 상관없습니다 — 포트만 같으면 절차가 동일합니다
SECTION 02

내 서버의 IP 와 포트 확인하기

먼저 내 PC 의 주소를 알아야 합니다. 여기서 나오는 192.168.x.x 는 사무실 안에서만 통하는 주소이고, 바깥에서 보이는 주소는 따로 있습니다(v06). 이 둘을 헷갈리는 것이 첫 번째 함정입니다.

주소는 두 종류입니다 — 안에서 쓰는 것과 밖에서 보이는 것 사무실 안 (사설 IP) 서버 PC 192.168.0.50 직원 PC 192.168.0.31 프린터 192.168.0.20 공유기 192.168.0.1 10.x.x.x · 172.16~31.x.x · 192.168.x.x 가 사설 대역입니다 이 주소는 이 사무실 안에서만 통합니다 NAT 인터넷에서 본 모습 (공인 IP) 203.0.113.45 사무실 전체가 이 주소 하나로 보입니다 직원 PC 도 서버 PC 도 밖에서는 구분되지 않습니다 그래서 "몇 번 포트로 오면 누구에게 줄지"를 공유기에 알려 주어야 합니다 — 그것이 포트포워딩 공인 IP 는 대개 재부팅하면 바뀝니다(v09 에서 해결) 확인하는 곳 — 사설 IP 는 명령 프롬프트의 ipconfig, 공인 IP 는 공유기 관리 화면이나 검색창에 "내 아이피" 둘이 같은 값으로 나온다면 공유기 없이 직접 연결된 환경입니다 — 포트포워딩이 필요 없는 대신 보안이 훨씬 중요해집니다
사내 공개에는 사설 IP, 외부 공개에는 공인 IP + 포트포워딩이 필요합니다.

① 사설 IP 확인 — 명령 프롬프트

# 시작 → cmd 입력 → 명령 프롬프트 실행
ipconfig

# 출력에서 "이더넷 어댑터" 또는 "Wi-Fi" 아래를 봅니다
#   IPv4 주소 . . . . . . . . . : 192.168.0.50   ← 이것이 내 사설 IP
#   서브넷 마스크 . . . . . . . : 255.255.255.0
#   기본 게이트웨이 . . . . . . : 192.168.0.1    ← 이것이 공유기 주소
기본 게이트웨이 = 공유기 주소입니다. 나중에 IPTIME 관리 화면에 들어갈 때 브라우저 주소창에 이 값을 칩니다. 대부분 192.168.0.1 이고, 통신사 장비를 함께 쓰면 172.30.1.254 처럼 나오기도 합니다.

② 포트가 비어 있는지 확인

포트는 한 번에 한 프로그램만 쓸 수 있습니다. 이미 다른 프로그램이 쓰고 있으면 내 앱이 뜨지 않습니다.

# 8080 을 누가 쓰고 있는지
netstat -ano | findstr :8080

# 결과가 아무것도 없으면 → 비어 있습니다. 그대로 쓰면 됩니다.
# 결과가 나오면 맨 오른쪽 숫자가 PID(프로그램 번호)입니다
#   TCP    0.0.0.0:8080     0.0.0.0:0     LISTENING      12345
#                                                        ^^^^^ PID

# 그 PID 가 어떤 프로그램인지 확인
tasklist | findstr 12345
포트쓰기 전에 알아 둘 것
80 · 443웹 표준 포트. 관리자 권한이 필요하고, 통신사가 가정용 회선에서 막아 두는 경우가 많습니다
8080 · 8443가장 무난한 선택. 권한 문제도 없고 대개 열립니다 — 이 탭은 8080 기준입니다
3389원격 데스크톱. 절대 외부로 열지 마세요 — 자동 공격이 가장 많이 노리는 포트입니다
1~1023시스템 예약 구간. 특별한 이유가 없으면 피합니다
SECTION 03

앱 띄우기 — 창을 닫아도 살아 있게

명령 프롬프트에서 실행하면 그 창을 닫는 순간 서비스가 죽습니다. 로그아웃해도, 재부팅해도 알아서 다시 뜨게 하려면 윈도우 서비스로 등록해야 합니다. 여기까지 해야 "띄웠다"고 할 수 있습니다.

콘솔에서 실행 vs 서비스로 등록 — 무엇이 다른가 ① 콘솔 실행 (연습용) java -jar myapp.jar 창을 닫으면 → 죽습니다 로그아웃하면 → 죽습니다 재부팅하면 → 안 뜹니다 ② 서비스 등록 (실제 운영) 서비스 관리자가 대신 실행합니다 아무도 로그인하지 않아도 떠 있습니다 부팅하면 자동으로 시작됩니다 죽으면 자동으로 다시 시작합니다 부팅부터 접속까지 전원 켜짐 서비스 자동 시작 로그인 화면인 채로 8080 대기 시작 LISTENING 상태 직원이 접속 언제든 열립니다 서비스로 만들지 않으면 "어제까지 되던 게 오늘 안 돼요" 의 원인이 됩니다 — 밤사이 윈도우 업데이트로 재부팅되기 때문입니다
사내에 공유하는 순간부터는 서비스 등록이 선택이 아니라 필수입니다.

0단계 — 반드시 확인할 한 줄: 어느 주소로 대기하는가

가장 많이 막히는 지점입니다. 프로그램이 127.0.0.1:8080 으로 떠 있으면 그 PC 안에서만 열리고 다른 PC 에서는 절대 접속되지 않습니다. 방화벽을 아무리 열어도 소용없습니다.
반드시 0.0.0.0(모든 주소에서 받기)으로 떠야 합니다.
# 확인 — 왼쪽 주소가 0.0.0.0 이어야 합니다
netstat -ano | findstr :8080
#  TCP    0.0.0.0:8080     0.0.0.0:0    LISTENING   → 좋습니다
#  TCP    127.0.0.1:8080   0.0.0.0:0    LISTENING   → 밖에서 못 들어옵니다
프레임워크모든 주소에서 받게 하는 설정
Spring Bootserver.address=0.0.0.0 (기본값이 이미 전체이므로 대개 손댈 필요 없음) · server.port=8080
Node · Expressapp.listen(8080, '0.0.0.0') — 호스트를 안 적으면 환경에 따라 로컬만 열립니다
Python · FastAPIuvicorn main:app --host 0.0.0.0 --port 8080
Python · Djangopython manage.py runserver 0.0.0.0:8080 + ALLOWED_HOSTS 에 IP 추가
.NET--urls http://0.0.0.0:8080 또는 ASPNETCORE_URLS 환경 변수

방법 A — NSSM 으로 서비스 등록 (가장 쉬움)

NSSM 은 아무 실행 파일이나 윈도우 서비스로 만들어 주는 작은 도구입니다. 설치도 필요 없고 파일 하나면 됩니다. 초보자에게 가장 권합니다.

:: 1) nssm.exe 를 내려받아 C:\tools\nssm.exe 에 둡니다 (공식 사이트: nssm.cc)
:: 2) 관리자 권한 명령 프롬프트를 엽니다 (시작 → cmd 우클릭 → 관리자 권한으로 실행)

:: 3) 설치 창 열기 — 빈칸을 채우는 방식이라 편합니다
C:\tools\nssm.exe install MyApp

::    Application 탭
::      Path             : C:\Program Files\Java\jdk-21\bin\java.exe
::      Startup directory: C:\apps\myapp
::      Arguments        : -jar myapp.jar --server.port=8080
::    I/O 탭 (로그를 파일로 남깁니다 — 나중에 문제 볼 때 꼭 필요합니다)
::      Output (stdout)  : C:\apps\myapp\logs\out.log
::      Error  (stderr)  : C:\apps\myapp\logs\err.log
::    Exit actions 탭
::      Restart          : 죽으면 자동 재시작 (기본값 그대로 두면 됩니다)

:: 4) 시작 · 상태 확인 · 중지
net start MyApp
sc query MyApp
net stop MyApp

:: 5) 서비스 삭제가 필요하면
C:\tools\nssm.exe remove MyApp confirm

방법 B — 윈도우 기본 명령(sc.exe)만 쓰기

:: 실행 파일이 .exe 하나로 끝나는 경우에만 간단합니다 (.NET 게시물 · Go 빌드 등)
sc create MyApp binPath= "C:\apps\myapp\myapp.exe" start= auto DisplayName= "사내 도구"
::  ↑ 등호(=) 뒤에 반드시 공백이 한 칸 있어야 합니다. 이것이 안 되는 이유 1위입니다.

sc description MyApp "사내 재고 조회 도구 (8080)"
sc failure MyApp reset= 86400 actions= restart/5000/restart/5000/restart/10000
net start MyApp
java -jar 처럼 인자가 붙는 명령은 sc.exe 로 바로 안 됩니다. binPath 에는 실행 파일 하나만 들어가야 하므로, 이런 경우 NSSM(방법 A) 을 쓰거나 .bat 로 감싼 뒤 그 배치를 서비스로 만들어야 합니다. 초보자라면 NSSM 이 훨씬 안전합니다.

방법 C — 작업 스케줄러 (서비스가 부담스러울 때)

:: 시작 → "작업 스케줄러" → 작업 만들기
::   일반   : "사용자의 로그온 여부에 관계없이 실행" 체크 + "가장 높은 수준의 권한으로 실행" 체크
::   트리거 : 시작할 때
::   동작   : 프로그램 시작 → C:\apps\myapp\start.bat
::   설정   : "작업이 실패하면 다시 시작" 체크

:: start.bat 예시
@echo off
cd /d C:\apps\myapp
java -jar myapp.jar --server.port=8080 >> logs\out.log 2>&1
어느 방법이든 확인은 같습니다. 서버 PC 브라우저에서 http://localhost:8080 이 열리고, netstat -ano | findstr :8080 에 0.0.0.0:8080 LISTENING 이 보이면 이 단계는 끝입니다.
SECTION 04

윈도우 방화벽 — 그 포트만 열기

프로그램은 떠 있는데 다른 PC 에서 안 열린다면 열에 아홉은 방화벽입니다. 방화벽을 통째로 끄는 것은 위험하니, 필요한 포트 하나만 여는 규칙을 만듭니다.

방화벽은 '허용 규칙이 없으면 조용히 버립니다' 직원 PC 접속 → 192.168.0.50:8080 프로필 판정 개인 / 도메인 / 공용 인바운드 규칙 검사 TCP 8080 허용이 있나 통과 규칙이 없으면 — 그냥 버립니다 브라우저에는 "연결할 수 없음"만 뜨고 한참 기다립니다 프로필이 발목을 잡는 흔한 경우 규칙은 개인에만 걸어 뒀는데, 네트워크가 공용으로 잡혀 있으면 규칙이 적용되지 않습니다 — "어제는 됐는데" 의 단골 원인 → 사내망은 네트워크를 '개인'으로 바꿔 두세요 확인 순서 — 서버 PC 에서 localhost 로 열림 + 다른 PC 에서 안 열림 이면 방화벽이 범인일 확률이 가장 큽니다 백신 프로그램이 자체 방화벽을 갖고 있는 경우도 있습니다 — 윈도우 방화벽을 열었는데도 안 되면 백신 설정을 보세요
방화벽 차단은 거절이 아니라 침묵이라, 증상만으로는 원인을 알기 어렵습니다.

명령 한 줄로 여는 법 (권장)

:: 관리자 권한 PowerShell 에서
New-NetFirewallRule -DisplayName "사내도구 8080" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Domain,Private

:: 명령 프롬프트를 쓴다면 (netsh)
netsh advfirewall firewall add rule name="사내도구 8080" dir=in action=allow protocol=TCP localport=8080

:: 잘 들어갔는지 확인
netsh advfirewall firewall show rule name="사내도구 8080"

:: 나중에 지울 때
netsh advfirewall firewall delete rule name="사내도구 8080"
위 PowerShell 명령에서 -Profile Domain,Private 를 붙인 이유는 사내망에서만 열고 공용 네트워크에서는 막기 위해서입니다. 카페 와이파이에 노트북을 물렸을 때 자동으로 닫히므로 훨씬 안전합니다. 외부 공개(포트포워딩)를 할 때는 공유기를 거쳐 들어오는 접속도 사내망에서 오는 것처럼 보이므로 이 설정 그대로 동작합니다.

화면에서 클릭으로 하는 법

순서할 일
1시작 → "고급 보안이 포함된 Windows Defender 방화벽" 실행 (또는 wf.msc)
2왼쪽에서 인바운드 규칙 → 오른쪽 새 규칙
3포트 선택 → 다음 → TCP · 특정 로컬 포트에 8080
4연결 허용 → 프로필은 도메인 · 개인만 체크(공용은 해제)
5이름을 "사내도구 8080" 처럼 알아볼 수 있게 — 나중에 정리할 때 필요합니다
방화벽을 끄지 마세요. "일단 꺼 보고 되면 켠다"는 흔한 방법이지만, 끈 채로 잊어버리는 일이 자주 생깁니다. 확인이 목적이라면 규칙을 만들어 켠 상태로 테스트하고, 정 안 되면 잠깐 끄고 확인한 즉시 다시 켜세요.
SECTION 05

사내망에서 접속 확인

여기까지 왔으면 같은 사무실의 다른 PC 에서 열려야 합니다. 안 열린다면 어디가 막혔는지 순서대로 좁히면 됩니다 — 감으로 찍지 마세요.

직원에게 알려 줄 주소

http://192.168.0.50:8080

· http:// 를 빼면 브라우저가 검색어로 인식합니다 — 반드시 붙여서 알려 주세요
· 포트(:8080)를 빼면 80 번으로 가서 안 열립니다
· 사내 어디서나 같은 주소입니다 (같은 공유기·같은 대역일 때)

막혔을 때 — 이 순서로 3분이면 찾습니다

단계확인 명령 (직원 PC 에서)결과가 나쁘면
1 ping 192.168.0.50 응답 없으면 네트워크 문제 — 같은 공유기인지, 유선/무선이 분리돼 있지 않은지 확인. 서버 PC 방화벽이 ping 을 막기도 하니 2단계도 해 보세요
2 Test-NetConnection 192.168.0.50 -Port 8080
(PowerShell)
TcpTestSucceeded : False 면 포트가 막힌 것 — 방화벽(v04) 또는 앱이 안 떠 있음(v03)
3 서버 PC 에서 http://localhost:8080 여기서도 안 열리면 앱 문제입니다 — 로그 파일부터 보세요
4 서버 PC 에서 netstat -ano | findstr :8080 127.0.0.1:8080 으로 떠 있으면 0.0.0.0 으로 바꿔야 합니다(v03 0단계)
IP 대신 컴퓨터 이름으로도 됩니다. 서버 PC 이름이 SRV-TOOL 이라면 사내에서 http://srv-tool:8080 으로 접속됩니다. IP 가 바뀌어도 주소가 유지돼 편하지만, 회사 네트워크 구성에 따라 안 될 수도 있으니 IP 방식을 기본으로 안내하고 이름은 보조로 알려 주세요.
서버 PC 의 IP 는 언젠가 바뀝니다. 공유기가 자동으로 나눠 주는 주소이기 때문입니다. 바뀌면 직원들이 쓰던 주소가 전부 깨집니다 — 다음 섹션에서 IP 를 고정합니다. 사내 공개만 할 계획이어도 이 작업은 하세요.
SECTION 06

공유기 구조 — 왜 포트포워딩이 필요한가

사무실 전체가 공인 IP 하나를 나눠 쓰고 있습니다. 밖에서 그 주소로 들어오면 공유기는 "이 요청을 안쪽 어느 PC 에게 줘야 하지?" 를 알 수 없습니다. 그 답을 미리 적어 두는 것이 포트포워딩입니다.

나가는 것은 알아서 되고, 들어오는 것은 규칙이 필요합니다 ① 안 → 밖 (평소 인터넷 사용) 직원 PC 공유기 "누가 보냈는지" 기억 네이버 서버 응답이 오면 기억해 둔 그 PC 에게 돌려줍니다 ② 밖 → 안 (외부에서 우리 서비스로) 바깥 브라우저 공유기 — 규칙이 없으면 "누구 거지?" → 버림 포트포워딩 규칙을 적어 두면 "8080 으로 온 것은 192.168.0.50:8080 에게" → 전달됩니다 그래서 순서가 정해집니다 ① 서버 PC 의 사설 IP 를 고정 → ② 포트포워딩 규칙 작성 IP 가 바뀌면 규칙이 엉뚱한 기기를 가리키게 됩니다 공유기가 두 대면 안 됩니다 통신사 장비 뒤에 IPTIME 을 또 물린 구조(이중 NAT)라면 양쪽 모두에 규칙을 넣어야 합니다 — 확인법은 v08
포트포워딩은 공유기에게 배달지를 알려 주는 일입니다 — 그 이상도 이하도 아닙니다.

먼저 할 일 — 서버 PC 의 사설 IP 고정

두 가지 방법이 있고, 공유기에서 예약하는 쪽(A)이 더 안전합니다. PC 설정을 잘못 건드려 인터넷이 끊기는 사고가 없기 때문입니다.

A. 공유기에서 예약 (권장)
IPTIME 관리 화면
→ 고급 설정
→ 네트워크 관리
→ 내부 네트워크 설정
→ "수동 IP 할당" 또는 "DHCP 예약"

MAC 주소  : 서버 PC 의 랜카드 주소
할당 IP   : 192.168.0.50

· PC 설정은 자동(DHCP) 그대로 둡니다
· 공유기가 늘 같은 주소를 줍니다
· 되돌리기 쉽습니다
B. PC 에서 직접 고정
설정 → 네트워크 → 어댑터 옵션
→ 이더넷 속성 → IPv4 → 속성

IP 주소   : 192.168.0.50
서브넷    : 255.255.255.0
게이트웨이: 192.168.0.1
DNS       : 168.126.63.1 (또는 8.8.8.8)

· 게이트웨이·DNS 를 빼먹으면 인터넷이 끊깁니다
· 다른 기기와 IP 가 겹치면 둘 다 이상해집니다
· DHCP 범위 밖의 번호를 골라야 안전합니다
:: 서버 PC 의 MAC 주소 확인 (A 방법에서 필요합니다)
ipconfig /all
::   물리적 주소 . . . . . . . . : 1C-1B-0D-XX-XX-XX   ← 이 값

:: 고정한 뒤 확인 — 재부팅해도 같은 주소가 나와야 합니다
ipconfig | findstr IPv4
SECTION 07

IPTIME 포트포워딩 설정

화면을 따라가며 규칙 한 줄만 넣으면 됩니다. 넣을 값은 네 개 — 규칙 이름 · 내부 IP · 외부 포트 · 내부 포트 입니다.

규칙 한 줄이 하는 일 — 밖의 포트를 안의 기기로 잇습니다 밖에서 들어온 접속 203.0.113.45 : 8080 IPTIME 규칙 표 외부 8080 → 0.50 : 8080 서버 PC 로 전달 192.168.0.50 : 8080 규칙 이름 내부 IP 주소 외부 포트 내부 포트 사내도구 192.168.0.50 8080 ~ 8080 8080 ~ 8080 사내도구(다른 번호로 노출) 192.168.0.50 39080 ~ 39080 8080 ~ 8080 아래 줄처럼 외부 포트만 다르게 잡을 수 있습니다 — 밖에서는 :39080 으로 접속하고, 안에서는 그대로 8080 을 씁니다 흔한 번호(8080·3389)를 그대로 노출하지 않는 것만으로 자동 스캔 공격이 크게 줄어듭니다(v10)
외부 포트와 내부 포트는 달라도 됩니다 — 이 점이 간단한 보안 장치가 됩니다.

화면 순서 (IPTIME 공통)

순서할 일
1브라우저에 192.168.0.1 입력 → 관리자 로그인 (초기값 admin / admin — 반드시 바꿉니다, v10)
2왼쪽 위 관리도구 클릭 → 고급 설정
3NAT/라우터 관리 → 포트포워드 설정
4새 규칙 추가 — 규칙이름 사내도구 / 내부 IP 192.168.0.50 / 프로토콜 TCP
5외부 포트 8080 ~ 8080 · 내부 포트 8080 ~ 8080 입력 → 적용
6목록에 규칙이 보이면 끝. 공유기 재부팅은 필요 없습니다
"현재 접속된 IP 주소" 버튼이 있는 모델이 많습니다. 누르면 지금 붙어 있는 기기 목록이 나오고, 서버 PC 를 고르면 내부 IP 가 자동으로 채워집니다 — 오타를 막는 가장 쉬운 방법입니다.
DMZ 는 쓰지 마세요. "포트포워딩이 귀찮으니 DMZ 로 다 열자"는 안내가 인터넷에 많지만, DMZ 는 그 PC 의 모든 포트를 인터넷에 여는 설정입니다. 원격 데스크톱·파일 공유까지 전부 노출됩니다. 필요한 포트 하나만 여는 것이 원칙입니다.

UPnP 로 자동 등록하는 방법도 있지만

프로그램이 공유기에 스스로 포트를 여는 기능(UPnP)이 있습니다. 편하지만 어떤 프로그램이든 마음대로 포트를 열 수 있다는 뜻이라 회사 환경에서는 권하지 않습니다. 손으로 규칙을 넣고, 어떤 포트가 열려 있는지 문서로 남기는 편이 안전합니다.

SECTION 08

외부 접속 확인 — 안 될 때 원인 사전

포트포워딩을 넣었는데도 안 열리는 경우가 많습니다. 원인은 거의 다섯 가지 안에 있습니다. 하나씩 지워 나가면 됩니다.

먼저 — 공인 IP 확인하고 밖에서 접속

1) IPTIME 관리 화면 첫 페이지에서 "외부 IP 주소" 확인
   또는 서버 PC 브라우저에서 검색창에 "내 아이피" 검색

2) 휴대폰에서 Wi-Fi 를 끄고 (반드시 LTE·5G 로) 접속
   http://203.0.113.45:8080

   ※ 사무실 Wi-Fi 에 붙은 채로 공인 IP 로 접속하면
     공유기 모델에 따라 안 열립니다(헤어핀 NAT). 이것은 정상이며 고장이 아닙니다.
     반드시 외부 회선에서 테스트하세요.
증상 · 원인확인 방법해결
① 이중 NAT
공유기가 두 대
IPTIME 첫 화면의 외부 IP 가 192.168.·172.·10. 으로 시작하면 이중 NAT 입니다 앞단 통신사 장비에도 같은 포트포워딩을 넣거나, IPTIME 을 브리지 모드로 바꿉니다 — 통신사에 문의가 빠릅니다
② ISP 포트 차단 80 · 443 · 21 · 25 로 열었을 때만 안 되고 8080 은 되면 이 경우 가정용 회선은 80·443 을 막는 곳이 많습니다 — 8080 등 다른 포트를 쓰세요
③ 방화벽 프로필 사내에서는 되는데 밖에서만 안 됨 방화벽 규칙 프로필을 확인합니다. 공유기를 거친 접속도 사설 대역에서 오지만, 공용으로 잡힌 네트워크라면 규칙이 적용되지 않습니다(v04)
④ 내부 IP 가 바뀜 어제까지 되다가 오늘 안 됨 ipconfig 로 지금 IP 를 보고 포트포워딩 규칙과 같은지 확인 — 다르면 IP 고정(v06)을 안 한 것입니다
⑤ 공인 IP 가 바뀜 주소를 다시 확인하니 예전과 다름 대부분의 회선은 재부팅하면 공인 IP 가 바뀝니다 — DDNS(v09) 로 고정된 이름을 만듭니다
외부에서 포트가 열렸는지 빠르게 보는 법
휴대폰(LTE)에서 Test-NetConnection 을 쓸 수는 없으니, 브라우저로 열어 보는 것이 가장 간단합니다. "포트 열림 확인" 사이트도 있지만 회사 IP 를 외부 사이트에 입력하는 셈이므로, 가능하면 직접 접속으로 확인하세요.
여기서부터는 인터넷 전체에 열린 상태입니다. 포트를 연 순간부터 몇 분 안에 자동 스캔이 붙습니다. 로그를 보면 낯선 주소에서 /admin, /.env, /wp-login.php 를 찔러 보는 기록이 쌓입니다. v10 · v11 의 보안 조치를 같은 날 안에 끝내세요.
SECTION 09

DDNS — 바뀌는 IP 대신 이름

공인 IP 는 대개 고정이 아닙니다. 공유기를 재부팅하거나 통신사 사정으로 바뀌면 어제 알려 준 주소가 죽습니다. IPTIME 은 무료 DDNS 를 제공하므로, 우리회사도구.iptime.org 같은 이름을 만들어 쓰면 됩니다.

설정 순서

순서할 일
1IPTIME 관리도구 → 고급 설정 → 특수기능 → DDNS 설정
2호스트 이름에 회사이름도구.iptime.org 입력 (원하는 앞부분만 정하면 됩니다)
3사용자 ID 에 본인 이메일 입력 → 추가
4접속 상태가 "정상 등록" 으로 바뀌는지 확인 (몇 초 걸립니다)
5이제 주소는 http://회사이름도구.iptime.org:8080 — IP 가 바뀌어도 따라갑니다
회사 도메인이 이미 있다면 DNS 에 A 레코드를 추가하는 편이 더 낫습니다. 다만 공인 IP 가 바뀌는 회선이라면 A 레코드를 자동 갱신해 주는 서비스(Cloudflare API 등)를 함께 써야 합니다. 고정 IP 회선이라면 이 고민이 사라지므로, 상시 서비스라면 통신사에 고정 IP 를 신청하는 편이 결국 편합니다.
이름이 생기면 검색·스캔에도 잘 걸립니다. *.iptime.org 대역은 자동 스캐너가 늘 훑는 곳입니다. DDNS 를 켜는 것과 보안 조치(v10·v11) 는 반드시 같이 갑니다.
SECTION 10

최소 보안 ① — 열린 문을 줄입니다

외부 공개에서 가장 중요한 것은 화려한 보안 장비가 아니라 "필요 없는 문을 닫아 두는 것" 입니다. 아래 여섯 가지는 초보자도 30분이면 할 수 있고, 실제 사고의 대부분을 막습니다.

방어는 겹으로 — 각 단계가 통과량을 크게 줄입니다 인터넷 전체의 자동 스캔 — 하루 수천 건 ① 포트는 필요한 하나만 (DMZ 금지 · RDP·파일공유 절대 금지) ② 흔한 번호 대신 다른 외부 포트로 노출 ③ 접속 가능한 IP 를 제한 (가능하다면) ④ 로그인 요구 → 실제 사용자만 가장 효과가 큽니다 스캔이 크게 줄어듭니다 사내·지사 IP 만
완벽한 방어가 아니라 "쉬운 표적이 되지 않는 것"이 목표입니다.

① 공유기부터 잠급니다 — 5분

설정이유
관리자 비밀번호 변경
기본 설정 → 시스템 관리 → 관리자 설정
admin/admin 그대로면 공유기 자체가 먼저 뚫립니다 — 가장 먼저 하세요
원격 관리 포트 끄기
고급 설정 → 보안 기능 → 공유기 접속·관리 제한
공유기 관리 화면이 인터넷에 열려 있으면 모든 규칙을 남이 바꿀 수 있습니다
DMZ · UPnP 끄기의도치 않게 다른 포트가 열려 있을 수 있습니다 — 목록을 열어 지금 있는 규칙 전부를 확인하세요
공유기 펌웨어 업데이트공개된 취약점이 그대로 남아 있는 구형 펌웨어가 많습니다

② 절대 열면 안 되는 포트

3389(원격 데스크톱) · 445(파일 공유) · 1433·3306·5432(데이터베이스) · 22(SSH, 비밀번호 로그인 상태)
이 포트들을 포트포워딩하면 며칠 안에 무차별 로그인 시도가 시작됩니다. 실제 랜섬웨어 감염 경로 1위가 열린 원격 데스크톱입니다. 원격 접속이 필요하면 포트포워딩이 아니라 VPN 을 쓰세요 — IPTIME 에도 VPN 서버 기능이 있습니다.

③ 접속 가능한 IP 를 좁힙니다 — 가능하면 가장 강력합니다

:: 특정 IP·대역에서만 8080 을 허용 (관리자 PowerShell)
New-NetFirewallRule -DisplayName "사내도구 8080 (제한)" -Direction Inbound -Protocol TCP `
  -LocalPort 8080 -Action Allow `
  -RemoteAddress 192.168.0.0/24, 203.0.113.99, 210.100.50.0/24

::   192.168.0.0/24  → 사내망 전체
::   203.0.113.99    → 지사 고정 IP 하나
::   210.100.50.0/24 → 협력사 대역

:: 기존의 '전체 허용' 규칙은 반드시 삭제하세요 — 남아 있으면 제한이 무의미합니다
netsh advfirewall firewall delete rule name="사내도구 8080"
직원들이 집에서도 봐야 한다면 IP 제한을 쓸 수 없습니다. 그때는 로그인 요구(v11) 가 최소선이 되고, 더 안전하게 하려면 공유기 VPN 을 켜서 "VPN 붙은 사람만 사내망처럼 접속"하게 만듭니다 — 이 방식이면 포트포워딩 자체가 필요 없어집니다.

④ 서버 PC 자체를 정리합니다

항목할 일
계정사용하지 않는 계정 비활성화 · 관리자 비밀번호를 길게(12자 이상) · 자동 로그인 해제
업데이트윈도우 업데이트를 켜 두되 재부팅 시간대를 업무 외 시간으로 지정 (서비스는 자동 시작되므로 안전합니다 — v03)
불필요한 공유net share 로 열린 공유 폴더 확인 → 필요 없는 것 제거
백업앱 폴더와 데이터(DB 파일 등)를 다른 디스크나 NAS 로 매일 복사 — 이것만 있어도 최악은 면합니다
전원 설정절전으로 잠들면 서비스가 멈춥니다 — 전원 옵션에서 절전·최대 절전 해제
SECTION 11

최소 보안 ② — 로그인 · HTTPS · 기록

문을 줄였으면 다음은 들어온 사람을 확인하는 일과 오간 내용을 가리는 일입니다. 둘 다 생각보다 간단하게 붙일 수 있습니다.

① 로그인 — 앱에 없다면 앞에 붙입니다

앱에 자체 로그인이 있으면 그것으로 충분합니다. 없다면 Caddy 를 앞에 두고 기본 인증을 거는 방법이 가장 빠릅니다. Caddy 는 실행 파일 하나로 동작하고 설정도 몇 줄입니다.

# Caddyfile — 서버 PC 의 C:\caddy\Caddyfile
# 밖에서는 8443 으로 들어오고, 뒤쪽 내 앱(8080)으로 넘깁니다

# (주소 뒤 :8443 이 없으면 Caddy 는 443 에서 듣습니다 — 인증서 발급에는 80 포워딩도 필요, 아래 ②)
회사이름도구.iptime.org:8443 {
    # 로그인 요구 (아이디: staff)
    basic_auth {
        staff $2a$14$해시값을여기에
    }

    reverse_proxy 127.0.0.1:8080

    # 접속 기록 남기기
    log {
        output file C:\caddy\logs\access.log
        roll_size 10MiB
    }
}
:: 비밀번호 해시 만들기
caddy hash-password --plaintext "여기에실제비밀번호"
::   → $2a$14$... 로 시작하는 문자열이 나옵니다. 이것을 Caddyfile 에 붙여 넣습니다

:: 실행 (테스트)
caddy run --config C:\caddy\Caddyfile

:: 서비스로 등록해 두면 재부팅해도 뜹니다 (v03 의 NSSM 방식 그대로)
이 구성으로 바뀌는 것 — 포트포워딩은 8443 → 192.168.0.50:8443 로 바꾸고, 앱의 8080 은 더 이상 외부에 열지 않습니다(방화벽 규칙 삭제). 밖에서 오는 길이 Caddy 하나로 좁아집니다.

② HTTPS — DDNS 이름이 있으면 자동입니다

위 Caddyfile 의 첫 줄에 도메인 이름을 적어 두면, Caddy 가 인증서를 알아서 발급·갱신합니다. 다만 발급 과정에서 인증 기관이 그 도메인으로 접속해 확인하므로 조건이 있습니다.

상황되는지 · 방법
80·443 을 열 수 있다가장 쉽습니다. 포트포워딩에 80·443 을 추가하면 Caddy 가 자동으로 인증서를 받습니다
ISP 가 80·443 을 막았다HTTP 방식 인증이 안 됩니다 → DNS 챌린지가 필요합니다(도메인 관리 권한 필요). iptime.org 로는 어렵습니다
사내망 전용이다공인 인증서를 받을 수 없습니다 → 사설 인증서를 만들고 각 PC 에 신뢰 등록하거나, HTTP 로 두되 IP 제한 + 로그인으로 갑니다
회사 도메인이 있다서브도메인 하나를 서버로 연결하면 Caddy 자동 HTTPS 가 그대로 동작합니다 — 가장 권하는 형태입니다
HTTP 로 로그인을 받으면 비밀번호가 그대로 흘러갑니다. 사내망이라면 위험이 낮지만, 외부에 공개하면서 HTTPS 가 없다면 그 서비스에는 회사 계정과 같은 비밀번호를 쓰지 않도록 안내해야 합니다.

③ 기록을 남기고 가끔 봅니다

:: 지금 누가 붙어 있는지 (서버 PC 에서)
netstat -an | findstr :8080 | findstr ESTABLISHED

:: 방화벽이 막은 기록을 남기고 싶다면 (로그 파일 위치와 함께 켜기)
netsh advfirewall set allprofiles logging droppedconnections enable
::   기본 위치: C:\Windows\System32\LogFiles\Firewall\pfirewall.log

:: 윈도우 로그인 실패 기록 확인 (이벤트 ID 4625)
wevtutil qe Security /q:"*[System[(EventID=4625)]]" /c:20 /rd:true /f:text
무엇을 봐야 하나요
· 접속 로그에 모르는 IP 가 /admin, /.env, /wp-login.php 를 찔러 보는 기록 — 자동 스캔입니다. 로그인이 걸려 있으면 그냥 실패하고 끝납니다
· 로그인 실패가 짧은 시간에 수십 번 — 무차별 대입입니다. 해당 IP 를 방화벽에서 차단하거나 IP 제한(v10)으로 전환하세요
· 일주일에 한 번, 5분만 보면 충분합니다. 안 보는 로그는 없는 것과 같습니다
SECTION 12

체크리스트와 문제 해결 사전

순서대로 체크하면 빠진 것을 찾을 수 있습니다. 그리고 안 될 때는 아래 순서도 하나면 원인을 좁힐 수 있습니다.

안 될 때 — 이 순서로만 확인하면 원인이 나옵니다 서버 PC 에서 localhost:8080 안 열림 열림 앱 문제 — 로그부터 다른 사내 PC 에서 사설IP:8080 안 열림 열림 0.0.0.0 · 방화벽 외부(LTE)에서 공인IP:8080 안 열림 열림 포트포워딩 · 이중NAT 정상 — 보안 점검으로 각 단계의 확인 명령 netstat -ano | findstr :8080 Test-NetConnection IP -Port 8080 ipconfig · 공유기 외부 IP 확인 사무실 Wi-Fi 로는 외부 테스트 불가
막힌 지점 하나만 찾으면 됩니다 — 위에서 아래로 순서대로 보세요.

사내 공개 체크리스트

✓항목확인 방법
1앱이 0.0.0.0 으로 대기netstat -ano | findstr :8080
2서비스로 등록되어 자동 시작sc query MyApp · 재부팅 후 접속 확인
3방화벽 인바운드 규칙 존재netsh advfirewall firewall show rule name=...
4서버 PC 사설 IP 고정공유기 DHCP 예약 목록에 있는지
5서버 PC 절전 해제전원 옵션 → 절전 "안 함"
6로그 파일이 남고 있음logs 폴더에 오늘 날짜 기록이 쌓이는지

외부 공개 추가 체크리스트

✓항목확인 방법
7포트포워딩 규칙이 필요한 하나만IPTIME 포트포워드 목록 전체를 눈으로 확인 · DMZ 해제
8공유기 관리자 비밀번호 변경 · 원격 관리 차단기본 설정 → 시스템 관리
93389 · 445 · DB 포트 가 열려 있지 않음포워딩 목록에 없어야 합니다
10로그인이 걸려 있음시크릿 창으로 접속해 인증을 묻는지
11가능하면 HTTPS · 아니면 IP 제한주소창 자물쇠 · 방화벽 RemoteAddress
12백업이 매일 돌고 있음백업 폴더에 어제 날짜 파일이 있는지

증상별 원인 사전

증상가장 흔한 원인
사이트에 연결할 수 없음
한참 기다리다 실패
방화벽에서 조용히 버려진 것 — 규칙 또는 포트포워딩을 확인하세요
연결이 거부되었습니다
바로 실패
그 포트에 대기 중인 프로그램이 없음 — 앱이 죽었거나 포트가 다릅니다
서버 PC 에서만 열림127.0.0.1 로 대기 중이거나 방화벽 미개방
사내는 되는데 밖에서만 안 됨포트포워딩 · 이중 NAT · ISP 차단 셋 중 하나(v08)
어제까지 되다가 안 됨재부팅 후 앱이 안 뜬 것(서비스 미등록) 또는 IP 가 바뀐 것
가끔 끊기고 느려짐서버 PC가 절전으로 들어감 · Wi-Fi 로 연결됨 → 유선 권장
사무실에서 공인 IP 로 접속 안 됨정상입니다(헤어핀 NAT) — 사내에서는 사설 IP 로, 밖에서는 공인 IP·DDNS 로 접속하세요
다음 단계로 가고 싶다면
· 여러 앱을 한 주소로 묶고 싶다 → Nginx 탭(리버스 프록시) · 인증서까지 자동으로 → Caddy 탭
· 접속자가 늘어 서버 한 대로 부족하다 → 로드밸런싱 탭 · 대규모 트래픽 탭
· 회사 규정상 외부 공개가 어렵다 → 공유기 VPN 으로 사내망에 붙게 하고, 이 탭의 사내 공개 구성까지만 씁니다
DOCKER · CONTAINERD · COMPOSE · KUBERNETES 1.33 · HELM · ARGO CD · GITHUB ACTIONS

컨테이너로
배포하고
쿠버네티스로 운영하기

"제 PC 에서는 됐는데요"를 끝내는 것이 컨테이너이고, 컨테이너 수백 개를 사람 손 없이 굴리는 것이 쿠버네티스입니다. 이 탭은 도커를 한 번도 안 써 본 상태에서 출발해 Dockerfile 작성 → Compose 로 로컬 스택 → 레지스트리 → 쿠버네티스 첫 배포 → 무중단 · 오토스케일 · 관측 → GitOps · CI/CD → 장애 진단까지 한 줄로 잇습니다. YAML 은 전부 복사해서 그대로 도는 완본이고, 명령은 실제로 쳐 보라고 순서대로 놓았습니다. 끝나면 본인 앱을 이미지로 만들어 클러스터에 올리고, 죽었을 때 원인을 5분 안에 찾을 수 있습니다.

14SECTIONS
K8s 1.33기준
0사전 지식
100%복붙해서 도는 예제
K8S 01

컨테이너가 푸는 문제 — "제 PC 에서는 됐는데요"

기술을 배우기 전에 어떤 고통을 없애려고 만든 물건인지부터 봅니다. 이걸 모르면 도커는 그냥 "명령어 외우기"가 됩니다.

같은 코드인데 왜 서버에서만 깨지나 — 다른 것은 코드가 아니라 그 아래 전부입니다 전통 배포 — 코드만 옮김 내 PC Java 21 · glibc 2.39 TZ=Asia/Seoul Ubuntu 24.04 jar 만 복사 운영 서버 Java 17 · glibc 2.28 TZ=UTC CentOS 7 → 기동 실패 · 날짜 9시간 밀림 컨테이너 — 아래까지 통째로 내 애플리케이션 (jar · py · js) 런타임 · 라이브러리 (Java 21 · glibc) OS 파일 · 환경 변수 · 타임존 이 사각형 전체가 "이미지" 한 덩어리 → 노트북 · CI · 운영 어디서든 안이 같음 컨테이너가 없애는 것 — 환경 차이 · "설치 문서" · 서버마다 다른 손질 · 되돌리기 어려움 컨테이너가 안 없애는 것 — 코드 버그 · DB 스키마 · 네트워크 방화벽 · 용량 부족 한 문장 — 컨테이너는 "실행 환경까지 포함한 배포 단위"이지, 마법이 아닙니다
컨테이너의 한 줄 정의 — 앱 + 그 아래 실행 환경을 한 파일로 묶어 통째로 옮기는 것.

VM 과 컨테이너 — 무엇이 진짜 다른가

가상 머신(VM)은 가짜 하드웨어 위에 운영체제를 통째로 하나 더 올립니다. 커널·부팅 과정·systemd 가 전부 따로라 기동에 수십 초가 걸리고 메모리도 GB 단위로 먹습니다. 컨테이너는 호스트의 커널을 그대로 빌려 쓰면서, 프로세스에게 "너만의 세상"으로 보이게 속이는 기술입니다. 커널이 새로 뜨지 않으니 기동이 수십 밀리초입니다.

차이는 딱 한 줄 — 커널을 새로 띄우느냐(VM), 빌려 쓰느냐(컨테이너) 가상 머신 앱 A 라이브러리 앱 B 라이브러리 게스트 커널 게스트 커널 하이퍼바이저 호스트 OS · 하드웨어 기동 30초~2분 · 1~4GB · 강한 격리 컨테이너 앱 A 라이브러리 앱 B 라이브러리 앱 C 라이브러리 컨테이너 런타임 (containerd) 커널 하나를 전부 공유 호스트 OS · 하드웨어 기동 0.05~1초 · 20~300MB · 격리는 상대적으로 약함 커널이 없어서 이미지가 작고 기동이 빠릅니다 그래서 클라우드는 VM 안에 컨테이너를 돌립니다 — VM 으로 고객끼리 자르고, 컨테이너로 앱을 자릅니다
교환 조건 — 컨테이너는 가볍지만 커널을 공유합니다. 커널 취약점 하나면 격리가 뚫릴 수 있습니다.

속이는 방법 — 네임스페이스 · cgroup 🟡 중급

"컨테이너"라는 리눅스 기능은 존재하지 않습니다. 커널 기능 세 개를 조합해 프로세스 하나를 격리한 결과에 붙인 이름일 뿐입니다. 이걸 알면 docker exec 로 들어갔을 때 왜 프로세스가 몇 개 안 보이는지, 왜 컨테이너 안에서 free -m 이 호스트 전체 메모리를 보여 주는지가 설명됩니다.

커널 기능하는 일
네임스페이스
(namespace)
"안 보이게" 합니다. pid 네임스페이스를 나누면 컨테이너 안 첫 프로세스가 PID 1 이 되고 호스트의 다른 프로세스는 아예 보이지 않습니다. net(네트워크 인터페이스)·mnt(파일시스템)·uts(호스트명)·ipc·user 도 각각 나뉩니다
cgroup
(control group)
"못 넘게" 합니다. CPU·메모리·디스크 I/O 사용량에 상한을 걸고 초과하면 스로틀하거나 죽입니다. 쿠버네티스의 limits 가 결국 이 값으로 내려갑니다(d10)
오버레이 FS
(overlayfs)
"쌓아서 겹쳐" 보이게 합니다. 읽기 전용 이미지 레이어 여러 장 위에 쓰기 가능한 얇은 층 한 장을 얹어 하나의 파일시스템처럼 보여 줍니다 — 이미지 레이어의 정체입니다(d02·d03)
capabilities
seccomp · AppArmor
"못 하게" 합니다. root 라도 커널 모듈 적재·시간 변경 같은 위험한 시스템 콜을 막습니다. 컨테이너 보안의 기본선입니다
# 컨테이너가 "그냥 프로세스"라는 증거 — 호스트에서 그대로 보입니다
docker run -d --name demo nginx:1.29-alpine   # alpine — ps·free 가 들어 있습니다 (데비안판엔 없음)
ps -ef | grep nginx        # 호스트의 프로세스 목록에 nginx 가 그대로 있습니다

# 컨테이너 안에서 본 프로세스 — 자기 자신뿐 (pid 네임스페이스)
docker exec demo ps -ef
# PID 1 이 nginx 입니다. 호스트의 systemd·sshd 는 존재조차 보이지 않습니다

# cgroup 상한 확인 — 512MB 를 걸면 커널이 그 값을 들고 있습니다
docker run -d --name lim --memory 512m nginx:1.29-alpine
docker exec lim cat /sys/fs/cgroup/memory.max      # 536870912

# 주의 — free 는 cgroup 을 모릅니다. 호스트 전체를 보여 줍니다
docker exec lim free -m    # 컨테이너 상한이 아니라 호스트 메모리가 나옵니다
PID 1 의 함정 — 컨테이너가 Ctrl+C 로 안 죽는 이유. 리눅스에서 PID 1 은 시그널 기본 동작이 없습니다. 애플리케이션이 SIGTERM 핸들러를 등록하지 않으면 종료 신호를 그냥 무시하고, 10초 뒤 SIGKILL 로 강제 종료됩니다 — 처리 중이던 요청이 통째로 날아갑니다. 쿠버네티스의 무중단 배포가 안 되는 흔한 원인이 바로 이것입니다(d09).
해법 ① 앱이 SIGTERM 을 받아 graceful shutdown 하도록 구현 ② 그게 어려우면 docker run --init 또는 Dockerfile 에 tini 를 넣어 시그널을 대신 중계하게 합니다.

이미지와 컨테이너 — 클래스와 인스턴스

이미지 (image)

읽기 전용 템플릿입니다. 레이어 여러 장 + 메타데이터(시작 명령·환경 변수·포트)로 되어 있고, 내용의 해시가 곧 신원(sha256:...)입니다. 한 번 만들면 절대 바뀌지 않습니다 — 고치려면 새로 빌드해 새 이미지를 만듭니다.

비유 — 자바의 class, 파이썬의 클래스 정의.

컨테이너 (container)

이미지를 실행한 상태입니다. 이미지 레이어 위에 쓰기 가능한 얇은 층 한 장이 얹히고, 그 안에서 벌어진 변경(로그 파일·임시 파일)은 전부 그 층에 쌓입니다. 컨테이너를 지우면 그 층이 통째로 사라집니다 — 데이터가 날아가는 이유입니다(d02 볼륨).

비유 — new 로 만든 인스턴스. 하나의 이미지에서 100개를 띄울 수 있습니다.

언제 컨테이너를 안 쓰는 게 나은가 🟡 중급

도입 결정에서 가장 중요한 질문입니다. 컨테이너는 공짜가 아니라 빌드 파이프라인 · 레지스트리 · 이미지 관리 · 새 장애 유형이라는 비용을 청구합니다.

안 쓰는 게 나은 상황
  • 서버 1대 · 앱 1개 · 배포 월 1회 — systemd 서비스 하나가 훨씬 단순합니다(🔗 언어 연동 l12)
  • 커널 기능에 깊게 의존 — 특수 드라이버·커널 모듈·실시간 스케줄링
  • GPU·특수 하드웨어를 독점 사용 — 되긴 하지만 설정 비용이 큽니다
  • 상태 있는 DB 를 직접 운영 — 컨테이너로 못 하는 건 아니지만, 관리형 DB 나 전용 서버가 사고 확률이 낮습니다
  • 팀에 아무도 컨테이너를 모름 + 마감이 다음 주 — 배울 시간이 없으면 장애 대응이 불가능합니다
쓰면 확실히 이득인 상황
  • 서비스가 2개 이상이고 각각 다른 런타임 버전을 씀
  • 배포가 잦음(주 1회 이상) — 롤백이 이미지 태그 교체로 끝납니다
  • 서버가 여러 대 — 서버마다 손으로 설치하는 순간 환경이 갈라집니다
  • CI 에서 테스트를 돌림 — 테스트 환경 = 운영 환경이 됩니다
  • 오토스케일이 필요 — 대수를 늘리는 단위가 있어야 자동화가 가능합니다(d10)
  • 신입이 자주 들어옴 — docker compose up 하나로 온보딩이 끝납니다
중간 단계도 있습니다. "컨테이너는 쓰되 쿠버네티스는 안 쓰기"가 아주 흔하고 합리적인 선택입니다. 서버 1~3대라면 Docker Compose + 리버스 프록시(d04)로 충분하고, 그다음 단계가 필요해지면 관리형 쿠버네티스(EKS·GKE·NKS)로 갑니다. 쿠버네티스는 서비스 수 · 서버 수 · 배포 빈도가 셋 다 늘었을 때 값을 합니다(d06).
K8S 02

도커 기본기 — run · ps · logs · exec

명령 여섯 개면 컨테이너의 일생을 전부 다룰 수 있습니다. 나머지는 이 여섯 개의 옵션일 뿐입니다.

docker run 한 줄이 실제로 하는 일 — 받고 · 만들고 · 실행합니다 1 이미지 확보 (pull) 로컬에 있으면 건너뜀 2 컨테이너 생성 쓰기 층 · 네임스페이스 3 시작 명령 실행 CMD · ENTRYPOINT 그 뒤의 일생 — 상태가 넷뿐입니다 running ps 에 보임 stop (SIGTERM) exited 디스크는 그대로 차지 start rm 삭제 쓰기 층 소멸 = 데이터 증발 volume 여기 있으면 살아남음 exited 상태 컨테이너와 안 쓰는 이미지는 자동으로 지워지지 않습니다 — 디스크가 조용히 찹니다 "컨테이너가 사라져도 남아야 하는 것"은 반드시 볼륨에 — DB 데이터 · 업로드 파일 · 인증서 로그는 예외 — 표준 출력(stdout)으로 뱉으면 도커가 받아 파일에 적고, logs 명령으로 읽습니다 그래서 컨테이너 앱은 로그 파일을 직접 쓰지 않고 stdout 에 출력하는 것이 규칙입니다
외울 문장 — 컨테이너의 쓰기 층은 임시입니다. 남아야 할 것은 전부 볼륨으로 빼세요.

명령 여섯 개 — 이것만으로 90% 가 됩니다

# ① run — 만들고 바로 실행. 가장 많이 치는 명령
docker run -d --name api -p 8080:3000 \
  -e NODE_ENV=production -e TZ=Asia/Seoul \
  -v api-data:/app/data \
  --restart unless-stopped \
  ghcr.io/myorg/api:1.4.2
#   -d   백그라운드(detached). 빼면 터미널이 붙잡힙니다
#   -p   호스트포트:컨테이너포트  ← 순서를 뒤집는 실수가 가장 흔합니다
#   -e   환경 변수 주입
#   -v   볼륨 연결 (이름있는볼륨:컨테이너경로)
#   --restart unless-stopped   서버 재부팅 후에도 자동 기동

# ② ps — 지금 뭐가 돌고 있나
docker ps                       # 실행 중만
docker ps -a                    # 멈춘 것까지 전부 (디스크 먹는 범인 찾기)
docker ps --format '{{.Names}} · {{.Status}} · {{.Ports}}'

# ③ logs — 첫 번째 진단 도구
docker logs -f --tail 100 api           # 실시간으로 마지막 100줄부터
docker logs --since 10m api             # 최근 10분만
docker logs api 2>&1 | grep -i error    # 표준 에러까지 합쳐서 검색

# ④ exec — 돌고 있는 컨테이너 안으로 들어가기
docker exec -it api sh          # alpine 계열은 bash 가 없어 sh
docker exec api env             # 환경 변수가 제대로 들어갔는지 확인
docker exec api cat /etc/hosts  # 명령만 실행하고 나오기

# ⑤ stop / start / restart
docker stop api                 # SIGTERM → 10초 대기 → SIGKILL
docker stop -t 30 api           # 종료에 30초까지 기다려 주기
docker restart api

# ⑥ rm / rmi — 지우기
docker rm api                   # 멈춘 컨테이너 삭제
docker rm -f api                # 돌고 있어도 강제 삭제
docker rmi ghcr.io/myorg/api:1.4.2
포트 매핑의 방향을 헷갈리지 마세요. -p 8080:3000 은 "호스트의 8080 으로 들어오면 컨테이너의 3000 으로 보내라"입니다. 왼쪽이 밖, 오른쪽이 안입니다. 그리고 컨테이너 안의 앱이 127.0.0.1:3000 에 바인딩하면 밖에서 절대 못 들어옵니다 — 컨테이너의 localhost 는 호스트의 localhost 가 아니기 때문입니다. 반드시 0.0.0.0:3000 으로 리스닝해야 합니다. 이건 신입이 반드시 한 번 겪습니다.

볼륨 — 데이터를 살리는 두 가지 방법

방식표기쓰는 곳
이름 있는 볼륨
named volume
-v pgdata:/var/lib/postgresql/data운영 기본값. 도커가 관리하는 영역에 저장 — 권한 문제가 적고 백업 대상이 명확합니다
바인드 마운트
bind mount
-v $PWD/src:/app/src개발용. 호스트 폴더를 그대로 연결해 코드를 고치면 즉시 반영됩니다. 운영에 쓰면 호스트 경로 의존이 생깁니다
tmpfs--tmpfs /tmp메모리에만 존재. 임시 파일·시크릿 같은 디스크에 남으면 안 되는 것
읽기 전용-v ./conf:/etc/app:ro설정 파일은 :ro 를 붙이는 습관 — 컨테이너가 설정을 덮어쓰는 사고를 막습니다
# 볼륨 다루기
docker volume ls
docker volume inspect pgdata            # 실제 호스트 경로 확인
docker volume create pgdata

# 볼륨 백업 — 임시 컨테이너로 tar 를 뜹니다 (실무에서 자주 씁니다)
docker run --rm -v pgdata:/data -v "$PWD":/backup alpine \
  tar czf /backup/pgdata-$(date +%F).tgz -C /data .

# 복원
docker run --rm -v pgdata:/data -v "$PWD":/backup alpine \
  sh -c "rm -rf /data/* && tar xzf /backup/pgdata-2026-08-01.tgz -C /data"

디스크가 찼습니다 — docker system prune 🟡 중급

도커를 쓰는 서버에서 가장 흔한 장애가 디스크 풀입니다. 빌드할 때마다 옛 이미지 레이어·중간 캐시·멈춘 컨테이너·고아 볼륨이 쌓이는데 아무도 지워 주지 않습니다. CI 서버는 며칠이면 100GB 를 먹습니다.

# 무엇이 얼마나 먹고 있나 — 진단이 먼저
docker system df
docker system df -v            # 이미지별·볼륨별 상세

# 안전한 청소 (멈춘 컨테이너 · 안 쓰는 네트워크 · dangling 이미지 · 빌드 캐시)
docker system prune -f

# 태그 없는 것뿐 아니라 "아무 컨테이너도 안 쓰는 이미지" 까지 (주의)
docker system prune -a -f

# 볼륨까지 (★★ 위험 — DB 데이터가 날아갈 수 있습니다)
docker system prune -a --volumes -f

# 실무 타협안 — 168시간(7일) 넘은 것만 정리. cron 에 걸어 둡니다
docker system prune -af --filter "until=168h"
docker builder prune -af --filter "until=168h"
--volumes 는 손이 떨려야 정상입니다. "아무 컨테이너도 참조하지 않는 볼륨"을 지우는데, 컨테이너를 잠깐 지우고 다시 만드는 중이었다면 그 볼륨은 고아로 판정됩니다. 운영 서버 DB 볼륨이 이렇게 날아간 사례가 실제로 있습니다. 운영에서는 --volumes 없이 돌리고, 볼륨은 docker volume ls -f dangling=true 로 눈으로 확인한 뒤 하나씩 지우세요.

Docker Desktop · Podman · containerd — 뭘 쓰나 🟡 중급

도구성격선택 기준
Docker DesktopWin·macOS 용 GUI 묶음. 내부적으로 VM 안에서 리눅스 도커를 돌립니다개인 개발에는 가장 편합니다. 단 일정 규모 이상 기업은 유료 — 라이선스 확인 필요
Podman데몬이 없는(daemonless) 구현. 명령이 도커와 거의 같습니다(alias docker=podman)rootless 가 기본이라 보안에 유리. RHEL 계열 기본 선택
containerd
+ nerdctl
실제로 컨테이너를 돌리는 하부 런타임. 도커도 내부에서 이걸 씁니다쿠버네티스 노드의 표준입니다. 클러스터에서 직접 볼 일이 있습니다(crictl)
Rancher Desktop
OrbStack · Colima
Desktop 대체재라이선스 회피 + 가벼움. macOS 에서는 OrbStack 이 빠르다는 평이 많습니다
쿠버네티스는 2022년(1.24)에 도커 지원을 뗐습니다 — 그런데 왜 도커 이미지가 돌까요? 떼어 낸 것은 도커 데몬을 통해 컨테이너를 실행하던 중간 다리(dockershim)이지 이미지 형식이 아닙니다. 이미지는 OCI 표준이라 도커로 빌드한 이미지를 containerd 가 그대로 실행합니다. "도커가 죽었다"는 말은 오해입니다 — 빌드는 도커로, 실행은 containerd 로가 지금의 표준 조합입니다.
웹서버 컨테이너를 실제로 띄우는 실습은 이 가이드 🧭 시작 w05 에 Nginx·Apache·Tomcat·Caddy 예제로 이미 있습니다. 여기서는 내 애플리케이션을 이미지로 만드는 것(d03)부터 이어 갑니다.
K8S 03

Dockerfile 잘 쓰기 — 캐시와 멀티스테이지

"돌아가는 Dockerfile"은 10분이면 씁니다. 문제는 그게 빌드 5분 · 이미지 1.1GB 라는 것입니다. 줄 순서만 바꿔도 빌드 20초 · 이미지 186MB 가 됩니다.

레이어 캐시 — 바뀐 줄부터 아래는 전부 다시 만듭니다. 그래서 "순서"가 성능입니다 나쁜 순서 — 소스를 먼저 복사 FROM node:24-slim 캐시 ✓ COPY . . 코드 한 줄 바뀜 ✗ RUN npm ci 다시 · 180초 RUN npm run build 다시 · 40초 코드 한 글자 고칠 때마다 220초 좋은 순서 — 안 바뀌는 것부터 FROM node:24-slim 캐시 ✓ COPY package*.json ./ 캐시 ✓ RUN npm ci 캐시 ✓ · 0초 COPY . . 여기서부터 다시 RUN npm run build 40초 같은 수정에 40초 — 5.5배 빠릅니다 코드 한 줄 고쳤을 때 다시 도는 시간 — 막대 길이가 곧 초입니다 나쁜 순서 npm ci 180초 + build 40초 = 220초 220 좋은 순서 40초 — npm ci 는 캐시에서 0초 0 50 100 150 200 다시 빌드(초) 규칙 — 자주 바뀌는 줄일수록 아래로. 의존성 목록(package.json · pom.xml · requirements.txt)만 먼저 복사해 설치하고, 소스 전체 복사는 그다음에 둡니다 캐시를 깨는 숨은 범인 — .dockerignore 없이 COPY . . 하면 .git · node_modules 변경도 캐시를 무효화합니다
Dockerfile 최적화의 8할 — 의존성 설치와 소스 복사를 분리하는 것 하나입니다.

멀티스테이지 빌드 — 빌드 도구를 결과물에서 지우기 🟡 중급

컴파일러·빌드 도구·테스트 라이브러리는 빌드할 때만 필요하고 실행할 때는 쓰레기입니다. 그런데 한 스테이지로 만들면 그게 전부 최종 이미지에 남습니다 — JDK 포함 이미지가 1.14GB 인 이유입니다. 멀티스테이지는 빌드 결과물만 깨끗한 이미지로 복사합니다.

멀티스테이지 — 빌드용 이미지는 버리고, 결과물만 들고 나옵니다 stage 1: builder (버려짐) FROM gradle:8-jdk21 JDK · Gradle · 소스 · 테스트 의존성 캐시 수백 MB app.jar (28MB) 이 스테이지는 최종 이미지에 한 바이트도 들어가지 않습니다 COPY --from=builder jar 한 개만 stage 2: runtime (최종 이미지) FROM eclipse-temurin:21-jre-alpine JRE 만 · 컴파일러 없음 app.jar (28MB) USER 10001 · 비루트 1140MB ↓ 186MB 최종 이미지 크기 — 막대 길이가 곧 MB 입니다 한 스테이지 JDK · Gradle · 소스 · 의존성 캐시가 전부 남습니다 1140MB 멀티스테이지 186MB — JRE 베이스 + jar 한 개 그중 app.jar 28MB — 우리가 실제로 만든 것은 이만큼입니다 0 300 600 900 1200 이미지 크기(MB) 덤 — 최종 이미지에 컴파일러·패키지 매니저·소스 코드가 없으니 취약점 스캔 결과도 함께 깨끗해집니다
멀티스테이지의 진짜 이득은 크기가 아니라 공격 표면 축소입니다 — 셸도 없는 이미지가 목표입니다.

Java (Spring Boot) — 계층 분리까지

# syntax=docker/dockerfile:1
FROM gradle:8.10-jdk21 AS builder
WORKDIR /src
# ① 의존성 목록만 먼저 — 캐시가 살아남습니다
COPY build.gradle settings.gradle ./
RUN gradle dependencies --no-daemon || true
# ② 소스는 그다음
COPY src ./src
RUN gradle bootJar --no-daemon -x test

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# ③ 비루트 사용자 — root 로 돌리지 않습니다
RUN addgroup -S app && adduser -S -G app app
COPY --from=builder /src/build/libs/*.jar app.jar
USER app
EXPOSE 8080
# ④ exec 형식(대괄호)이어야 SIGTERM 이 앱까지 갑니다
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","-jar","/app/app.jar"]
MaxRAMPercentage — JVM 은 컨테이너 메모리 상한을 인식합니다(Java 10+). 고정 -Xmx 대신 비율로 주면 리소스 조정 시 같이 따라옵니다(d10).

Node.js — 개발 의존성 제거

# syntax=docker/dockerfile:1
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                       # devDependencies 포함 (빌드에 필요)

FROM node:24-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:24-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev            # 운영 의존성만 다시

FROM gcr.io/distroless/nodejs24-debian12
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nonroot
EXPOSE 3000
CMD ["dist/main.js"]
distroless — 셸조차 없는 이미지입니다. exec 로 들어갈 수 없어 디버깅은 kubectl debug 로 합니다(d14).

Python — 가상환경을 통째로 옮기기

# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
WORKDIR /app
ENV PIP_NO_CACHE_DIR=1
# 컴파일이 필요한 패키지용 빌드 도구 (최종 이미지엔 안 남습니다)
RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN python -m venv /opt/venv && \
    /opt/venv/bin/pip install -r requirements.txt

FROM python:3.13-slim
# ★ PYTHONUNBUFFERED — 로그가 안 나오는 원인 1위
#   (Dockerfile 주석은 줄 맨 앞에서만 — 지시어 뒤에 붙이면 값으로 읽혀 빌드가 깨집니다)
ENV PATH=/opt/venv/bin:$PATH \
    PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY app ./app
RUN useradd -u 10001 -m app
USER 10001
EXPOSE 8000
CMD ["gunicorn","-w","4","-k","uvicorn.workers.UvicornWorker", \
     "-b","0.0.0.0:8000","app.main:app"]
PYTHONUNBUFFERED=1 은 필수입니다. 없으면 파이썬이 stdout 을 버퍼링해 docker logs 에 아무것도 안 나옵니다 — "앱이 죽은 줄 알았는데 로그만 안 나온" 사고가 흔합니다.

Go · Rust — scratch 로 20MB 이하

# Go — 정적 링크 바이너리 하나면 OS 가 필요 없습니다
FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/api

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
USER nonroot
ENTRYPOINT ["/app"]
# 결과: 12~18MB

# Rust — musl 타깃으로 정적 링크
FROM rust:1.89 AS builder
WORKDIR /src
RUN rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=builder \
  /src/target/x86_64-unknown-linux-musl/release/api /api
ENTRYPOINT ["/api"]
scratch 는 정말로 빈 이미지입니다. TLS 를 쓰려면 CA 인증서(/etc/ssl/certs)를 별도로 복사해 넣어야 합니다.

베이스 이미지 고르기 — 네 가지 중에

베이스크기성격과 함정
표준
node:24
~1.1GB데비안 풀 세트. 편하지만 안 쓰는 패키지가 전부 취약점 목록에 잡힙니다. 빌드 스테이지용으로만
slim
node:24-slim
~230MB운영 기본 추천. 데비안 최소 세트 + glibc. 대부분의 네이티브 모듈이 그대로 돕니다
alpine
node:24-alpine
~60MB가장 작지만 musl libc 를 씁니다 — glibc 전제 바이너리가 깨지고, 파이썬은 wheel 을 못 써 소스 컴파일로 빠져 빌드가 느려집니다. DNS 해석 차이로 이상한 장애가 나기도 합니다
distroless
chainguard
~50MB가장 안전. 셸·패키지 매니저가 없어 침입해도 할 게 없습니다. 대신 exec 로 진입 불가 — 디버깅 방식을 바꿔야 합니다(d14)
alpine 을 무조건 고르지 마세요. "작으니까 좋다"로 골랐다가 ① 파이썬 패키지 빌드가 10분씩 걸리고 ② glibc 전제 라이브러리가 죽고 ③ 스레드 스택 기본값이 작아 JVM 이 이상하게 도는 일이 실제로 생깁니다. slim 으로 시작해, 크기가 진짜 문제일 때만 alpine·distroless 를 검토하는 순서가 안전합니다. 이미지 200MB 차이는 레지스트리에서 노드가 이미 캐시하고 나면 체감이 거의 없습니다.

.dockerignore — 없으면 캐시도 크기도 망가집니다

# .dockerignore — Dockerfile 옆에 둡니다. .gitignore 와 문법이 같습니다
.git
.gitignore
node_modules
__pycache__
*.pyc
target
build
dist
.venv
.env
.env.*
*.log
Dockerfile
docker-compose*.yml
.github
README.md
coverage
.idea
.vscode
.env 를 반드시 넣으세요. COPY . . 한 줄에 운영 DB 비밀번호가 담긴 .env 가 이미지에 그대로 들어갑니다. 이미지 레이어는 지워도 이전 레이어에 남습니다 — 나중에 RUN rm .env 를 해도 docker history 로 꺼낼 수 있습니다. 공개 레지스트리에 올리면 그대로 유출입니다. 시크릿은 이미지에 넣지 않고 실행 시 주입합니다(d08).

빌드를 더 빠르게 — BuildKit 캐시 마운트 🔴 고급

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
COPY src ./src
# ★ 캐시 마운트 — 레이어에는 안 남지만 빌드 간에 유지됩니다
RUN --mount=type=cache,target=/root/.m2 \
    mvn -B package -DskipTests

# 시크릿 마운트 — 사설 저장소 토큰을 이미지에 남기지 않고 쓰기
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
# 빌드와 크기 실측 — 개선했다고 말하려면 숫자가 있어야 합니다
docker build -t api:before -f Dockerfile.old .
docker build -t api:after  .
docker images api --format '{{.Tag}} · {{.Size}}'
#  before · 1.14GB
#  after  · 186MB

# 이미지 안에 뭐가 들었나 — 레이어별 크기
docker history api:after --no-trunc | head -20

# 시크릿이 레이어에 남았는지 검사 (dive · 또는 tar 로 풀어서)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive:latest api:after

# 멀티 아키텍처 빌드 (Apple Silicon 개발 → x86 서버 배포)
docker buildx build --platform linux/amd64,linux/arm64 \
  -t ghcr.io/myorg/api:1.4.2 --push .
Apple Silicon 함정. M 시리즈 맥에서 그냥 docker build 하면 arm64 이미지가 만들어집니다. 이걸 x86 서버에 올리면 exec format error 로 죽거나 에뮬레이션으로 3~10배 느리게 돕니다. --platform linux/amd64 를 명시하거나 CI 에서 빌드하세요(d13).
정리 — Dockerfile 은 순서(캐시) · 스테이지 분리(크기) · 비루트(보안) · exec 형식 ENTRYPOINT(종료) 네 가지만 지키면 실무 수준입니다.
K8S 04

Docker Compose — 여러 컨테이너를 한 번에

앱 하나만 컨테이너로 만들면 별 이득이 없습니다. 앱 + DB + 캐시 + 프록시가 up -d 한 줄로 뜨는 순간부터 세상이 달라집니다.

compose 네트워크 — 이름이 곧 주소, 밖으로 열린 문은 하나뿐 docker network: myapp_default (자동 생성) proxy nginx:1.29 app 우리 앱 · 3000 db postgres:17 redis redis:7-alpine volume pgdata volume redisdata 여기만 ports: "80:80" 앱은 DB 주소를 postgres://db:5432/app 으로 씁니다 — IP 도 링크 설정도 필요 없습니다 db · redis · app 에는 ports 를 쓰지 않습니다. 쓰는 순간 인터넷에서 DB 가 열립니다 쿠버네티스의 Service 가 정확히 이 역할입니다 — compose 를 이해하면 d07 이 쉽습니다
compose 의 핵심 두 가지 — 서비스 이름 = DNS 이름, ports 는 진짜 외부에 열 것만.

완본 — 앱 + PostgreSQL + Redis + Nginx

# compose.yaml  (예전 이름 docker-compose.yml 도 그대로 동작합니다)
name: myapp

services:
  proxy:
    image: nginx:1.29-alpine
    ports:
      - "80:80"                       # ★ 밖으로 여는 유일한 포트
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_healthy    # 앱이 "건강"해진 뒤에 뜹니다
    restart: unless-stopped

  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: prod                    # 멀티스테이지의 특정 스테이지 지정
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
      REDIS_URL: redis://redis:6379/0
      TZ: Asia/Seoul
    expose:
      - "3000"                        # 네트워크 안에서만 열림
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 30s               # ★ 기동이 느린 앱은 이걸 넉넉히
    restart: unless-stopped

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro   # 최초 1회 실행 SQL
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:
depends_on 만으로는 부족합니다. 기본 depends_on 은 "컨테이너가 시작됐다"까지만 보장합니다 — PostgreSQL 프로세스가 뜨는 것과 접속을 받을 준비가 되는 것은 다릅니다. 그래서 앱이 먼저 붙었다가 connection refused 로 죽는 일이 생깁니다. condition: service_healthy + 대상의 healthcheck 를 짝으로 써야 진짜 순서가 보장됩니다. 그래도 앱 쪽에 재시도 로직은 넣어 두세요 — 쿠버네티스에는 depends_on 자체가 없습니다(d09).

.env 와 변수 — 비밀번호를 YAML 에 적지 않기

.env (git 에 올리지 않음)

# compose.yaml 과 같은 폴더에 두면 자동으로 읽힙니다
DB_PASSWORD=s3cr3t-local-only
APP_IMAGE_TAG=1.4.2
LOG_LEVEL=debug
# .env.example — 이건 git 에 올립니다 (팀원 온보딩용)
DB_PASSWORD=changeme
APP_IMAGE_TAG=latest
LOG_LEVEL=info
.gitignore 에 .env 를 넣었는지 지금 확인하세요. 유출 사고의 절반이 여기서 납니다.

확인 · 진단 명령

# ★ 변수가 실제로 어떻게 치환됐는지 최종 결과 보기
docker compose config

# 특정 서비스만 다시 빌드해서 교체
docker compose up -d --build app

# 로그 — 서비스별 · 전체
docker compose logs -f app
docker compose logs -f --tail 50

# 컨테이너 안으로
docker compose exec app sh
docker compose exec db psql -U app -d appdb

# 일회성 명령 (마이그레이션 등) — 컨테이너를 새로 띄워 실행 후 삭제
docker compose run --rm app npm run migrate

# 상태 · 리소스
docker compose ps
docker stats --no-stream

# 완전 정리 (★ -v 는 볼륨까지 = 데이터 삭제)
docker compose down
docker compose down -v

개발용과 운영용 분리 — override 파일 🟡 중급

개발에서는 소스를 바인드 마운트해 저장하면 바로 반영되게 하고, 운영에서는 이미지를 고정하고 소스를 마운트하지 않습니다. 같은 파일을 두 벌 관리하면 반드시 어긋나므로 기본 + 덮어쓰기 구조를 씁니다.

compose.override.yaml (개발 · 자동 적용)

# up 할 때 자동으로 합쳐집니다 — 개발자는 아무것도 안 해도 됩니다
services:
  app:
    build:
      target: dev                 # 개발 스테이지
    command: ["npm", "run", "dev"]
    volumes:
      - ./src:/app/src            # 코드 즉시 반영
    environment:
      LOG_LEVEL: debug
    ports:
      - "3000:3000"               # 프록시 우회 직접 접속(디버깅용)
  db:
    ports:
      - "5432:5432"               # DB 툴로 직접 붙기 (개발에서만!)

compose.prod.yaml (운영 · 명시 지정)

services:
  app:
    image: ghcr.io/myorg/api:${APP_IMAGE_TAG}   # 빌드 안 함 — 이미지 고정
    build: !reset null
    deploy:
      resources:
        limits: { cpus: "1.0", memory: 512M }
    logging:
      driver: json-file
      options: { max-size: "20m", max-file: "5" }   # ★ 로그 로테이션
# 운영에서는 override 를 끄고 prod 파일을 명시합니다
docker compose -f compose.yaml -f compose.prod.yaml up -d

# 배포 = 태그 바꾸고 다시 up
APP_IMAGE_TAG=1.4.3 docker compose \
  -f compose.yaml -f compose.prod.yaml up -d
로그 로테이션을 빼면 json-file 드라이버가 무한히 커져 디스크를 다 먹습니다. 운영 compose 의 필수 항목입니다.

볼륨 백업 — 컨테이너를 지워도 데이터가 남게

#!/bin/sh
# backup.sh — cron 에 매일 03:00 으로 걸어 둡니다
set -eu
DATE=$(date +%F)
OUT=/backup

# ① DB 는 파일 복사 말고 논리 덤프로 (파일 복사는 깨질 수 있습니다)
docker compose exec -T db pg_dump -U app -Fc appdb > "$OUT/appdb-$DATE.dump"

# ② 업로드 파일 볼륨은 tar
docker run --rm -v myapp_uploads:/data -v "$OUT":/backup alpine \
  tar czf "/backup/uploads-$DATE.tgz" -C /data .

# ③ 7일 지난 백업 삭제
find "$OUT" -name '*.dump' -mtime +7 -delete
find "$OUT" -name '*.tgz'  -mtime +7 -delete

# ④ ★ 복원 테스트를 안 한 백업은 백업이 아닙니다 — 월 1회는 실제로 복원해 보세요
# docker compose exec -T db pg_restore -U app -d appdb_test < appdb-2026-08-01.dump
compose 로 어디까지 갈 수 있나. 서버 1~2대 · 서비스 5개 이하 · 배포 주 1~2회 규모는 compose + Nginx + 백업 스크립트로 몇 년을 무리 없이 운영합니다. 한계가 오는 지점은 명확합니다 — ①서버가 죽으면 수동 복구 ②배포 중 다운타임 ③트래픽 급증에 수동 증설 ④서버 여러 대에 걸친 배치. 이 넷 중 둘 이상이 아프면 그때가 쿠버네티스를 볼 때입니다(d06).
정리 — compose 는 서비스 이름 DNS · healthcheck 로 순서 · .env 로 비밀 분리 · override 로 환경 분리 네 개면 실무에서 씁니다. 이 개념이 그대로 쿠버네티스로 이어집니다.
K8S 05

이미지 레지스트리 — 태그 · 스캔 · 서명

이미지를 만들었으면 어딘가에 올려 두어야 다른 서버가 내려받습니다. 그 "어딘가"가 레지스트리이고, 여기서 태그를 잘못 정하면 롤백이 불가능해집니다.

태그는 "이름표"일 뿐 — 같은 이름표를 다른 내용에 다시 붙일 수 있습니다 CI 빌드 docker build push 레지스트리 GHCR · ECR · Harbor pull 노드 1 노드 2 latest 를 쓰면 노드1: latest = 8월1일 빌드 (이미 캐시됨, 다시 안 받음) 노드2: latest = 8월5일 빌드 → 같은 서비스인데 버전이 섞임 "지금 뭐가 돌고 있죠?" → 대답 불가 · 롤백 불가 불변 태그를 쓰면 api:1.4.2 · api:sha-9f3c1ab — 내용이 고정 롤백 = 이전 태그로 되돌리기, 끝 감사 로그 · 재현 · 디버깅이 전부 가능 가장 확실한 지정은 다이제스트 — api@sha256:9f3c... 는 내용 자체를 가리켜 절대 바뀌지 않습니다 실무 조합 — 사람이 읽을 태그(1.4.2)와 추적용 태그(sha-9f3c1ab)를 같은 이미지에 둘 다 붙입니다
규칙 하나만 지키세요 — 운영 배포에 latest 를 쓰지 않습니다. 롤백 불가는 장애 시간을 몇 배로 늘립니다.

레지스트리 고르기

레지스트리성격주의점
Docker Hub기본값. 공개 이미지의 원산지익명 pull 은 IP 당 시간당 제한이 있습니다 — 노드가 여러 대면 CI 가 갑자기 toomanyrequests 로 막힙니다. 운영은 로그인하거나 다른 곳을 쓰세요
GHCR
ghcr.io
GitHub Container Registry. Actions 와 붙이기 가장 쉽습니다저장소 권한과 패키지 권한이 별개 — packages: write 권한을 워크플로에 줘야 합니다(d13)
ECR · GCR · ACR
NCR(네이버)
클라우드 제공사 것. 같은 리전이면 pull 이 매우 빠르고 무료 전송로그인 토큰이 12시간짜리 — 노드에는 IAM 역할로 붙입니다. 수명주기 정책으로 오래된 이미지 자동 삭제 필수
Harbor
(사내 설치)
오픈소스 사내 레지스트리. 스캔·서명·복제·쿼터 내장폐쇄망·규제 산업의 표준 선택. 스토리지 용량 관리가 운영 포인트입니다
# 로그인 — 비밀번호를 인자로 주지 말고 표준 입력으로 (셸 히스토리에 남지 않게)
echo "$GITHUB_TOKEN" | docker login ghcr.io -u myuser --password-stdin

# 태그 붙이기 — 한 이미지에 여러 태그를 붙일 수 있습니다
SHA=$(git rev-parse --short HEAD)          # 9f3c1ab
docker build -t ghcr.io/myorg/api:1.4.2 \
             -t ghcr.io/myorg/api:sha-$SHA \
             -t ghcr.io/myorg/api:1.4 \
             -t ghcr.io/myorg/api:stable .

docker push --all-tags ghcr.io/myorg/api

# 다이제스트 확인 — 배포 매니페스트에 이걸 박으면 완전 고정입니다
docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/myorg/api:1.4.2
# ghcr.io/myorg/api@sha256:9f3c1ab...

# 원격 레지스트리를 내려받지 않고 조회 (crane · skopeo)
crane ls ghcr.io/myorg/api
crane digest ghcr.io/myorg/api:1.4.2
skopeo inspect docker://ghcr.io/myorg/api:1.4.2

태그 전략 — 무엇을 어떻게 붙이나

태그의미어디에 쓰나
1.4.2 (SemVer)사람이 읽는 릴리스 번호. 다시 덮어쓰지 않습니다운영 배포에 이것을 씁니다. 릴리스 태그(git tag)와 1:1
sha-9f3c1ab커밋 해시. 어떤 코드인지 100% 역추적 가능CI 자동 배포 · 스테이징. 장애 때 "이 코드"를 바로 찾습니다
1.4 · 1움직이는 별칭 — 패치가 나오면 옮겨 붙습니다라이브러리·베이스 이미지에는 유용. 앱 배포에는 부적합
main · pr-123브랜치·PR 미리보기리뷰 환경. 수명주기 정책으로 자동 삭제하지 않으면 무한히 쌓입니다
latest아무 보장 없음. 마지막으로 push 한 것로컬 실습·데모까지. 운영 금지
쿠버네티스에서 latest 가 특히 위험한 이유. 파드의 기본 imagePullPolicy 는 태그가 latest 면 Always, 아니면 IfNotPresent 입니다. 즉 latest 를 쓰면 파드가 재시작될 때마다 말없이 다른 버전으로 바뀔 수 있습니다. 새벽에 노드가 한 대 죽어 파드가 옮겨졌는데 그 순간 배포되지 않은 최신 이미지가 올라와 장애가 나는 — 원인 찾기 가장 어려운 사고 유형입니다.

취약점 스캔 — Trivy 🟡 중급

# 이미지 스캔 — 심각도 높은 것만, 고칠 수 있는 것만 (노이즈 제거가 핵심)
trivy image --severity HIGH,CRITICAL --ignore-unfixed ghcr.io/myorg/api:1.4.2

# CI 에서 게이트로 — 취약점이 있으면 종료 코드 1 로 빌드 실패
trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 \
  ghcr.io/myorg/api:1.4.2

# Dockerfile · 쿠버네티스 YAML 설정 검사도 됩니다
trivy config ./k8s/
trivy fs --scanners vuln,secret,misconfig .

# SBOM(구성 부품 목록) 생성 — 규제 대응·라이선스 감사에 필요합니다
trivy image --format cyclonedx -o sbom.json ghcr.io/myorg/api:1.4.2

# 오탐·미조치 항목 예외 처리 (.trivyignore)
echo "CVE-2025-12345  # 우리는 해당 기능 미사용, 2026-10-01 재검토" >> .trivyignore
스캔을 켜면 처음에는 300건이 나옵니다 — 정상입니다. 베이스 이미지가 데비안 풀 세트면 안 쓰는 패키지의 CVE 까지 전부 잡힙니다. 순서는 이렇습니다. ① --ignore-unfixed 로 패치가 나온 것만 봅니다 ② 베이스를 slim·distroless 로 바꿔 절반 이상을 한 번에 없앱니다(d03) ③ 남은 CRITICAL 만 게이트로 겁니다. 처음부터 0 을 목표로 하면 파이프라인이 영원히 빨간불이라 아무도 안 보게 됩니다.

서명과 출처 증명 🔴 고급

"이 이미지가 정말 우리 CI 가 만든 것인가"를 증명하는 단계입니다. 공급망 공격(레지스트리에 가짜 이미지를 밀어 넣기)에 대한 방어이고, cosign 이 사실상 표준입니다. 키 없이 CI 신원(OIDC)으로 서명하는 방식이 편합니다.

# 키리스 서명 — GitHub Actions 의 OIDC 신원으로 서명합니다
cosign sign --yes ghcr.io/myorg/api@sha256:9f3c1ab...

# 검증 — "누가 어떤 워크플로에서 만들었는가"까지 확인
cosign verify ghcr.io/myorg/api:1.4.2 \
  --certificate-identity-regexp 'https://github.com/myorg/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

# 클러스터에서 강제 — 서명 없는 이미지는 아예 배포를 막습니다
# (Kyverno · OPA Gatekeeper 정책으로 admission 단계에서 차단)
사내 레지스트리를 언제 세우나. ① 폐쇄망이라 외부 pull 이 안 될 때 ② Docker Hub 제한·클라우드 전송 비용이 아플 때 ③ 규제로 이미지 보관·감사 기록이 필요할 때입니다. Harbor 를 쓰면 프록시 캐시 기능으로 외부 이미지를 자동 미러링할 수 있어 "인터넷이 끊겨도 배포는 된다"를 만들 수 있습니다.

저장 비용 관리 — 안 지우면 무한히 쌓입니다

CI 가 커밋마다 이미지를 올리면 하루 수십 개, 한 달이면 수백 GB 가 됩니다. ECR·GCR 은 저장 용량으로 과금하고, 사내 Harbor 는 디스크가 차서 push 가 막힙니다. 수명주기 정책을 처음부터 켜 두는 것이 나중에 손으로 지우는 것보다 훨씬 쌉니다.

대상보관 기준
릴리스 태그 (1.4.2)영구 보관. 몇 년 전 버전으로 롤백해야 하는 순간이 실제로 옵니다
커밋 태그 (sha-9f3c1ab)최근 50~100개 또는 90일. 그 이상 과거로는 어차피 안 돌아갑니다
브랜치·PR 태그7~14일 후 삭제. PR 이 닫히면 즉시 지우는 것이 가장 깔끔합니다
태그 없는 이미지
(dangling)
1~3일. 새 태그가 같은 이름을 가져가면서 남겨진 잔여물입니다
// ECR 수명주기 정책 예 — 우선순위(rulePriority) 순서로 평가됩니다
{
  "rules": [
    {
      "rulePriority": 1,
      "description": "릴리스 태그는 건드리지 않음",
      "selection": {
        "tagStatus": "tagged", "tagPrefixList": ["v"],
        "countType": "imageCountMoreThan", "countNumber": 9999
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 2,
      "description": "커밋 태그는 최근 100개만",
      "selection": {
        "tagStatus": "tagged", "tagPrefixList": ["sha-"],
        "countType": "imageCountMoreThan", "countNumber": 100
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 3,
      "description": "태그 없는 이미지는 1일 뒤 삭제",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 1
      },
      "action": { "type": "expire" }
    }
  ]
}
지우기 전에 "지금 클러스터가 쓰는 이미지"를 확인하세요. 운영 파드가 참조 중인 태그를 지우면 당장은 멀쩡합니다(노드에 캐시가 있으니까요). 문제는 노드가 교체되거나 파드가 다른 노드로 옮겨질 때 ImagePullBackOff 로 죽는다는 것입니다 — 며칠 뒤 새벽에 터집니다.
사용 중 목록은 이렇게 뽑습니다: kubectl get pods -A -o jsonpath="{..image}" | xargs -n1 | sort -u
정리 — 레지스트리에서 지킬 것은 셋 — 불변 태그 · 스캔 게이트 · 오래된 이미지 자동 삭제. 이 셋이 없으면 롤백·보안·비용 셋 다 새어 나갑니다.
K8S 06

쿠버네티스 개념 지도 — 왜 필요한가

명령을 외우기 전에 이 물건이 무슨 문제를 대신 풀어 주는지부터 봅니다. 한 문장으로 줄이면 — "원하는 상태를 적어 두면 계속 그렇게 맞춰 주는 기계"입니다.

도커만으로 안 되는 것 — 사람이 새벽에 일어나야 하는 일들 노드가 죽음 → 사람이 다른 서버에 다시 띄움 트래픽 급증 → 사람이 대수를 늘림 서버 10대 → 어디에 무엇을 띄울지 사람이 배치 배포 중 502 → 사람이 LB 에서 빼고 넣음 앱이 먹통 → 사람이 알아채고 재시작 쿠버네티스가 대신합니다 "replicas: 3 · 이미지 1.4.2" 라고 적어 두면 현재 상태를 계속 확인해 차이를 메웁니다 2개뿐? → 1개 더 띄움 노드 사망? → 남은 노드에 재배치 이것을 선언형(declarative)이라고 합니다 — "어떻게"가 아니라 "어떤 상태"를 적습니다 명령형: "3번 서버에 ssh 로 들어가 컨테이너를 띄워라" · 선언형: "이 앱은 항상 3개 있어야 한다" 그 대가 — 개념 20개 · YAML · 새로운 장애 유형. 규모가 작으면 손해입니다(d01·d04)
한 문장 정의 — 쿠버네티스는 "원하는 상태"와 "현재 상태"의 차이를 끝없이 메우는 루프입니다.

클러스터의 구조 — 컨트롤 플레인과 노드

클러스터 한 장 — 위는 "결정하는 곳", 아래는 "실행하는 곳" kubectl apply 컨트롤 플레인 (마스터) — 보통 3대로 이중화 API 서버 모든 요청의 관문 etcd 유일한 진실의 저장소 스케줄러 컨트롤러 매니저 cloud-controller 스케줄러 — 어느 노드에 놓을지 정합니다 워커 노드 1 kubelet · kube-proxy Pod Pod 워커 노드 2 kubelet · kube-proxy Pod Pod 워커 노드 3 kubelet · containerd Pod Pod kubelet 은 "이 노드에 이런 파드를 띄우라"는 지시를 받아 containerd 에게 실행을 시킵니다
모든 것은 API 서버를 통합니다 — 사용자도, 컨트롤러도, kubelet 도 전부 API 서버에게 말을 겁니다.
구성 요소하는 일죽으면
API 서버모든 요청의 유일한 입구. 인증·인가·검증 후 etcd 에 기록새 배포·조회 불가. 이미 도는 파드는 계속 돕니다
etcd클러스터의 모든 상태를 담은 키·값 저장소. 여기가 진실클러스터가 사실상 마비. 백업이 생명줄입니다
스케줄러새 파드를 어느 노드에 놓을지 결정(리소스·제약 고려, d10)새 파드가 Pending 에서 안 움직입니다
컨트롤러 매니저Deployment·ReplicaSet 등 맞춰 주는 루프들의 집합자가 치유·스케일이 멈춥니다
kubelet노드마다 하나. 자기 노드의 파드를 띄우고 상태·프로브를 보고그 노드가 NotReady → 파드가 다른 노드로 옮겨집니다
kube-proxy
(또는 CNI)
Service 주소로 온 트래픽을 실제 파드로 전달(iptables·IPVS·eBPF)서비스 간 통신이 끊깁니다

오브젝트 관계 — Pod 부터 Ingress 까지

위에서 아래로 — 각 층이 아래층을 "원하는 개수만큼" 만들어 냅니다 Ingress 도메인 · 경로 라우팅 Service 고정 이름 · 부하 분산 라벨로 고름 app=api Deployment 버전 · 롤아웃 전략 ReplicaSet 개수 유지 담당 Pod (app=api) 10.1.2.3 Pod (app=api) 10.1.5.7 Pod (app=api) 10.1.7.1 Pod 는 죽으면 새것이 뜨고 IP 가 바뀝니다 — 그래서 절대 Pod IP 로 부르지 않고 Service 이름으로 부릅니다 Deployment 를 지우면 아래 ReplicaSet · Pod 가 함께 사라집니다(소유 관계)
외울 계보 — Deployment → ReplicaSet → Pod, 그리고 옆에서 Service → Ingress 가 주소를 붙입니다.
오브젝트한 줄 정의compose 로 치면
Pod배포의 최소 단위. 컨테이너 1개 이상이 같은 IP·같은 볼륨을 공유컨테이너 하나(+사이드카)
ReplicaSet"이 파드가 N 개 있어야 한다"를 지키는 컨트롤러deploy.replicas
DeploymentReplicaSet 을 관리하며 버전 교체·롤백을 담당없음 — compose 에는 롤아웃 개념이 없습니다
Service파드 묶음에 바뀌지 않는 이름과 IP를 주고 분산서비스 이름 DNS
Ingress클러스터 밖에서 들어오는 HTTP 입구. 도메인·경로 라우팅Nginx 프록시 컨테이너
ConfigMap · Secret설정·비밀을 파드 밖에 두고 주입environment · .env
Namespace이름 공간 분리(팀·환경). 리소스 쿼터의 단위compose 프로젝트 이름
컨트롤러 루프를 한 문장으로. for { 원하는상태 := etcd에서읽기; 현재상태 := 실제로세보기; 차이를메우기 } — 이게 전부입니다. Deployment 컨트롤러도, HPA 도(d10), Argo CD 도(d12) 전부 같은 모양입니다. 그래서 kubectl 로 파드를 직접 지워도 ReplicaSet 컨트롤러가 곧바로 새로 만듭니다. "왜 지워도 다시 생기죠?"의 답이 이것입니다 — 지우려면 위(Deployment)를 지워야 합니다.
쿠버네티스가 안 해 주는 것도 분명히 알아 두세요. ① 애플리케이션 코드의 버그 — 재시작만 반복합니다 ② DB 스키마 마이그레이션 — 별도 Job 으로 직접 설계해야 합니다 ③ 세션·상태 — 무상태로 만들어야 합니다(📈 대규모 트래픽 x02) ④ 비용 절감 — 오히려 관리 부담과 노드 여유분 때문에 초기에는 늘어납니다.
정리 — 쿠버네티스는 선언 + 컨트롤러 루프라는 아이디어 하나를 온갖 리소스에 반복 적용한 시스템입니다. 이 구조를 잡으면 나머지는 YAML 필드 익히기입니다.
K8S 07

첫 배포 — kind · kubectl · Deployment

클라우드 계정 없이 노트북에 진짜 클러스터를 띄우고, 앱을 올려 도메인으로 접속하는 전 과정을 한 번에 갑니다. 여기서 만든 YAML 이 운영에서도 거의 그대로 쓰입니다.

로컬 클러스터 고르기

도구성격추천 상황
kind컨테이너 안에 노드를 만드는 방식. 기동 30초, 멀티 노드도 쉽습니다학습·CI 에 가장 추천. 클러스터를 부수고 다시 만드는 게 편합니다
minikubeVM 또는 도커 드라이버. 애드온(대시보드·ingress·metrics)이 풍부애드온을 한 번에 켜고 싶을 때
Docker Desktop설정에서 체크 하나로 켜집니다가장 손쉬움. 단 노드 1개 · 자원을 많이 먹습니다
k3s · k3d경량 배포판(60MB). 라즈베리파이·엣지에서도 돕니다소규모 실서비스에도 씁니다 — 단일 노드 운영에 현실적
# kind-config.yaml — 컨트롤 플레인 1 + 워커 2, Ingress 용 포트 개방
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    kubeadmConfigPatches:
      - |
        kind: InitConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "ingress-ready=true"
    extraPortMappings:                # 호스트 80 → 노드 80 (Ingress 접속용)
      - { containerPort: 80,  hostPort: 80,  protocol: TCP }
      - { containerPort: 443, hostPort: 443, protocol: TCP }
  - role: worker
  - role: worker
# 클러스터 생성 (30초)
kind create cluster --name lab --config kind-config.yaml

# Nginx Ingress Controller 설치 — 이게 있어야 도메인으로 들어옵니다
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod --selector=app.kubernetes.io/component=controller \
  --timeout=180s

# 확인
kubectl get nodes
kubectl cluster-info

# 로컬 이미지를 클러스터 안으로 넣기 (레지스트리 없이 실습할 때)
docker build -t myapp:dev .
kind load docker-image myapp:dev --name lab

# 다 끝나면 통째로 삭제 — 흔적이 남지 않습니다
kind delete cluster --name lab

kubectl 필수 명령 20개

# ── 보기 ──────────────────────────────────────
kubectl get pods                          # 1. 현재 네임스페이스의 파드
kubectl get pods -o wide                  # 2. 노드·IP 까지
kubectl get pods -A                       # 3. 전체 네임스페이스
kubectl get deploy,svc,ing                # 4. 여러 종류 한 번에
kubectl get pods -w                       # 5. 변화를 실시간으로
kubectl get pods -l app=api               # 6. 라벨로 거르기

# ── 파고들기 ──────────────────────────────────
kubectl describe pod api-7d9c-x4k2        # 7. ★ 이벤트까지 — 진단 1순위
kubectl logs api-7d9c-x4k2                # 8. 로그
kubectl logs -f -l app=api --tail=100     # 9. 라벨로 여러 파드 동시에
kubectl logs api-7d9c-x4k2 --previous     # 10. ★ 죽기 직전 로그 (CrashLoop 필수)
kubectl exec -it api-7d9c-x4k2 -- sh      # 11. 안으로 들어가기
kubectl get events --sort-by=.lastTimestamp   # 12. ★ 클러스터에서 무슨 일이

# ── 바꾸기 ────────────────────────────────────
kubectl apply -f k8s/                     # 13. ★ 선언형 적용 (폴더 통째로)
kubectl diff -f k8s/                      # 14. 적용하면 뭐가 바뀌나 미리보기
kubectl set image deploy/api api=ghcr.io/myorg/api:1.4.3   # 15. 이미지 교체
kubectl rollout status deploy/api         # 16. 배포 진행 지켜보기
kubectl rollout undo deploy/api           # 17. ★ 롤백
kubectl scale deploy/api --replicas=5     # 18. 대수 조정
kubectl delete pod api-7d9c-x4k2          # 19. 파드 죽여 보기(바로 새로 뜹니다)

# ── 연결 ──────────────────────────────────────
kubectl port-forward svc/api 8080:80      # 20. ★ 내 PC 에서 클러스터 서비스 접속

# 삶의 질 — 이 두 줄은 첫날에 설정하세요
alias k=kubectl
kubectl config set-context --current --namespace=myapp   # 기본 네임스페이스 고정
-o yaml 과 --dry-run 조합이 YAML 작성을 10배 빠르게 합니다.
kubectl create deploy api --image=nginx --dry-run=client -o yaml > deploy.yaml
골격을 만들어 주니 거기서 고치면 됩니다. 필드 설명이 필요하면 kubectl explain deploy.spec.strategy 로 공식 문서를 터미널에서 볼 수 있습니다.

Deployment · Service · Ingress 한 벌

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  labels: { app: api }
spec:
  replicas: 3
  selector:
    matchLabels: { app: api }        # ★ 이 라벨로 파드를 소유합니다 (나중에 못 바꿉니다)
  template:                          # ↓ 여기부터가 "파드 설계도"
    metadata:
      labels: { app: api }           # ★ selector 와 반드시 일치
    spec:
      containers:
        - name: api
          image: ghcr.io/myorg/api:1.4.2
          ports:
            - containerPort: 3000
          env:
            - name: TZ
              value: Asia/Seoul
          resources:                 # ★ 없으면 스케줄링·안정성이 무너집니다 (d10)
            requests: { cpu: 100m, memory: 256Mi }
            limits:   { memory: 512Mi }
          readinessProbe:            # 준비되기 전엔 트래픽을 안 받습니다 (d09)
            httpGet: { path: /healthz/ready, port: 3000 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz/live, port: 3000 }
            periodSeconds: 10
            failureThreshold: 3
          securityContext:           # 보안 기본선
            runAsNonRoot: true
            runAsUser: 10001
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          volumeMounts:              # 아래 tmp 볼륨을 실제로 /tmp 에 붙입니다
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: {}               # readOnlyRootFilesystem 을 켜면 /tmp 가 필요합니다
# k8s/service.yaml — 파드들에게 "바뀌지 않는 주소"를 줍니다
apiVersion: v1
kind: Service
metadata:
  name: api
spec:
  type: ClusterIP                    # 기본값 — 클러스터 안에서만 보임
  selector:
    app: api                         # ★ 이 라벨을 가진 파드로 보냅니다
  ports:
    - name: http
      port: 80                       # 서비스가 받는 포트
      targetPort: 3000               # 파드가 듣는 포트
---
# k8s/ingress.yaml — 클러스터 밖 → 안으로 들어오는 문
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "20m"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
spec:
  ingressClassName: nginx
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api
                port: { number: 80 }
요청 한 건이 지나가는 길 — 이 그림이 쿠버네티스 네트워킹의 전부입니다 브라우저 api.example.com 클라우드 LB L4 · 공인 IP Ingress Controller nginx 파드 · L7 라우팅 host · path 규칙 적용 Service: api (/) Service: web (/static) Service 가 실제로 하는 일 — 엔드포인트 목록에서 고르기 Service api selector: app=api EndpointSlice 준비된 파드 IP 목록 10.1.2.3 · 10.1.5.7 Pod · Ready ✓ Pod · Ready ✓ Pod · NotReady ✗ 목록에서 빠져 있음 Service 는 프록시 서버가 아닙니다 — 각 노드의 kube-proxy 가 iptables/IPVS 규칙을 심어 커널에서 바로 전달합니다 그래서 Service IP 로 ping 이 안 갑니다 — 실체 없는 가상 IP 입니다 무중단 배포의 원리도 여기에 있습니다 — 종료 중인 파드를 엔드포인트에서 먼저 빼는 것(d09)
핵심 — Service 의 대상 목록은 "Ready 인 파드"입니다. readiness 프로브가 곧 트래픽 스위치입니다.

Service 타입 네 가지 — 언제 뭘 쓰나

타입동작쓰는 곳
ClusterIP클러스터 내부 전용 가상 IP. 기본값거의 항상 이것. 앱끼리의 통신은 전부 ClusterIP
NodePort모든 노드의 같은 포트(30000~32767)를 열어 전달로컬 실습·임시 확인. 운영에는 부적합(포트 번호가 지저분하고 노드 IP 노출)
LoadBalancer클라우드에 실제 로드밸런서를 생성하고 공인 IP 를 붙임클라우드에서 외부 노출. 서비스마다 LB 가 생겨 비용이 붙습니다 → 그래서 Ingress 하나로 모읍니다
Headless
(clusterIP: None)
가상 IP 없이 DNS 가 파드 IP 목록을 직접 반환StatefulSet(d08)·Kafka·DB 클러스터처럼 개별 파드를 직접 지목해야 할 때
Ingress 는 왜 따로 있나. 서비스가 20개인데 전부 LoadBalancer 로 열면 클라우드 LB 가 20개 생기고 요금도 20배입니다. Ingress 는 LB 하나 뒤에 L7 라우터(nginx) 한 벌을 두고 도메인·경로로 나눠 보냅니다. 이 가이드의 🟩 Nginx 탭에서 배운 server·location 개념이 그대로 host·path 로 옮겨온 것뿐입니다. (Nginx 설정과의 1:1 대응표는 🛡️ 보안·운영 o11 에 있습니다.)

배포하고 확인하기 — 실제 순서

# ① 적용
kubectl create namespace myapp
kubectl -n myapp apply -f k8s/

# ② 뜨는 과정 지켜보기
kubectl -n myapp get pods -w
# api-6d5f8c9b7-2xk4v   0/1   ContainerCreating   0   3s
# api-6d5f8c9b7-2xk4v   0/1   Running             0   8s   ← 아직 Ready 아님
# api-6d5f8c9b7-2xk4v   1/1   Running             0   14s  ← readiness 통과

# ③ Service 가 파드를 제대로 물었는지 — 가장 중요한 확인
kubectl -n myapp get endpointslice
kubectl -n myapp describe svc api | grep -A3 Endpoints
#   Endpoints: 10.244.1.5:3000,10.244.2.7:3000,10.244.2.9:3000
#   ★ 비어 있으면 selector 라벨 오타이거나 파드가 Ready 가 아닙니다

# ④ 클러스터 안에서 직접 호출해 보기 (임시 파드)
kubectl -n myapp run tmp --rm -it --image=curlimages/curl --restart=Never -- \
  curl -sS http://api.myapp.svc.cluster.local/healthz/ready

# ⑤ 내 PC 에서 접속 — Ingress 없이도 확인 가능
kubectl -n myapp port-forward svc/api 8080:80
# 다른 터미널에서: curl localhost:8080/healthz/ready

# ⑥ Ingress 로 접속 (kind + hosts 파일)
echo "127.0.0.1 api.example.com" | sudo tee -a /etc/hosts
curl -H "Host: api.example.com" http://localhost/healthz/ready

# ⑦ 배포 교체와 롤백
kubectl -n myapp set image deploy/api api=ghcr.io/myorg/api:1.4.3
kubectl -n myapp rollout status deploy/api --timeout=120s
kubectl -n myapp rollout history deploy/api
kubectl -n myapp rollout undo deploy/api --to-revision=2
첫날 반드시 겪는 실수 세 가지. ① selector 와 template.metadata.labels 불일치 — 파드는 뜨는데 Service 엔드포인트가 비어 502 가 납니다. ② targetPort 를 앱이 실제 듣는 포트와 다르게 씀 — connection refused. ③ 앱이 127.0.0.1 에 바인딩 — 파드 밖에서 못 붙습니다. 0.0.0.0 으로 리스닝해야 합니다(d02와 같은 함정).
정리 — 첫 배포의 골격은 Deployment(무엇을 몇 개) + Service(안쪽 주소) + Ingress(바깥 문) 세 파일입니다. 나머지 섹션은 전부 이 세 개를 튼튼하게 만드는 이야기입니다.
K8S 08

설정 · 시크릿 · 스토리지

이미지는 어느 환경에서나 같아야 합니다. 그래서 환경마다 다른 것(주소·키·비밀번호)은 전부 밖에서 주입합니다. 데이터를 남겨야 하는 앱은 여기에 스토리지가 붙습니다.

설정 주입 세 가지 — 무엇을 쓰느냐에 따라 "바뀌었을 때"의 동작이 다릅니다 ConfigMap 평문 설정 LOG_LEVEL · API_URL Secret 비밀 값 DB 비번 · API 키 ① env — 값 하나씩 지정 ② envFrom — 통째로 환경 변수 ③ volumeMounts — 파일로 파드 · 컨테이너 /etc/config/app.yaml ①② 환경 변수는 프로세스 시작 시 고정 — 값이 바뀌면 파드를 다시 만들어야 반영됩니다 ③ 파일 마운트는 kubelet 이 주기적으로 갱신 — 앱이 파일을 다시 읽으면 재시작 없이 반영됩니다 Secret 은 암호화가 아니라 base64 인코딩입니다 — 누구나 디코드할 수 있습니다 진짜 보호는 etcd 저장 암호화 + RBAC 로 조회 권한 제한 + 외부 시크릿 저장소
고르는 기준 — 짧은 값은 환경 변수, 설정 파일·인증서는 볼륨 마운트. 갱신 동작이 다릅니다.

ConfigMap 만들고 쓰기

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
data:
  LOG_LEVEL: "info"
  FEATURE_NEW_CART: "true"
  # 파일 통째로도 넣을 수 있습니다
  app.yaml: |
    server:
      port: 3000
      timeoutMs: 5000
    cache:
      ttlSeconds: 300
---
# 파드에서 쓰기 — 세 방식 전부
spec:
  containers:
    - name: api
      envFrom:
        - configMapRef: { name: api-config }   # ② 통째로
      env:
        - name: DB_PASSWORD                     # ① 하나씩
          valueFrom:
            secretKeyRef: { name: api-secret, key: db-password }
      volumeMounts:
        - name: cfg                             # ③ 파일로
          mountPath: /etc/app
          readOnly: true
  volumes:
    - name: cfg
      configMap:
        name: api-config
        items:
          - { key: app.yaml, path: app.yaml }

Secret — 만들 때부터 조심

# ★ YAML 에 base64 를 손으로 적지 말고 명령으로 만듭니다
kubectl create secret generic api-secret \
  --from-literal=db-password='p@ss w0rd!' \
  --from-file=tls.key=./server.key

# 레지스트리 로그인 정보 (사설 이미지 pull 용)
kubectl create secret docker-registry ghcr-cred \
  --docker-server=ghcr.io \
  --docker-username=myuser \
  --docker-password="$GITHUB_TOKEN"

# 값 확인 — base64 라서 그냥 보입니다 (= 암호화가 아님)
kubectl get secret api-secret -o jsonpath='{.data.db-password}' | base64 -d

# 누가 시크릿을 읽을 수 있는지 점검
kubectl auth can-i get secrets --as=system:serviceaccount:myapp:default
Secret 을 git 에 커밋하지 마세요. base64 는 암호화가 아닙니다. GitOps 로 관리하려면 Sealed Secrets · SOPS · External Secrets 중 하나를 씁니다(아래).

외부 시크릿 저장소 — Vault · External Secrets 🟡 중급

시크릿을 클러스터 안에만 두면 회전(주기적 교체)·감사 로그·환경 간 공유가 어렵습니다. 실무에서는 진짜 비밀은 Vault 나 클라우드 시크릿 매니저에 두고, 쿠버네티스에는 "거기서 가져오라"는 참조만 둡니다.

# External Secrets Operator — git 에는 "참조"만 남고 값은 없습니다
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: api-secret
spec:
  refreshInterval: 1h              # 주기적으로 다시 가져옴 = 회전 자동 반영
  secretStoreRef:
    name: aws-secretsmanager
    kind: ClusterSecretStore
  target:
    name: api-secret               # 이 이름의 Secret 이 클러스터에 생성됩니다
  data:
    - secretKey: db-password
      remoteRef:
        key: prod/myapp/db
        property: password
방식git 에 올라가는 것평가
평문 Secret YAML비밀 값 그대로절대 금지. 저장소 권한이 곧 운영 DB 권한이 됩니다
Sealed Secrets클러스터 공개키로 암호화된 값간단하고 무료. 단 해당 클러스터에서만 복호화 가능 — 클러스터 재구축 시 키 백업 필수
SOPS + age/KMS암호화된 YAML(diff 가 보임)GitOps 와 궁합이 좋습니다. 키 관리 책임은 우리 몫
External Secrets
+ Vault·ASM
참조만 (값 없음)규모가 커지면 사실상 표준. 회전·감사·환경 분리가 전부 해결됩니다

스토리지 — PV · PVC · StorageClass

"용량을 달라"고 요청하면 실제 디스크가 만들어져 붙습니다 — 동적 프로비저닝 Pod /var/lib/data 에 마운트 PVC (클레임) 20Gi · RWO 요청 StorageClass gp3 · 암호화 · 확장가능 PV (실제 볼륨) EBS vol-0a1b2c 클레임에 바인딩 → 파드가 쓰기 시작 RWO(ReadWriteOnce) — 노드 한 대에서만. 대부분의 블록 스토리지가 이것입니다 RWX(ReadWriteMany) — 여러 노드 동시. NFS · EFS · CephFS 만 지원합니다 파드를 지워도 PVC 는 남습니다 — 데이터를 정말 지우려면 PVC 를 지워야 합니다
주의 — RWO 볼륨은 여러 파드가 동시에 못 씁니다. replicas 를 2로 올리는 순간 두 번째 파드가 Pending 에 걸립니다.
# PVC — "20Gi 짜리 디스크 하나 주세요"
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: uploads
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: gp3
  resources:
    requests: { storage: 20Gi }
---
# 파드에 붙이기
spec:
  containers:
    - name: api
      volumeMounts:
        - { name: uploads, mountPath: /var/lib/uploads }
  volumes:
    - name: uploads
      persistentVolumeClaim: { claimName: uploads }

StatefulSet 은 언제 쓰나 🟡 중급

비교DeploymentStatefulSet
파드 이름랜덤 (api-7d9c-x4k2)고정 순번 (db-0, db-1)
기동·종료 순서동시에, 순서 없음0 → 1 → 2 순서대로, 종료는 역순
스토리지모든 파드가 같은 PVC 공유(또는 없음)파드마다 전용 PVC 자동 생성
네트워크 신원없음고정 DNS (db-0.db.ns.svc) — Headless Service 필요
쓰는 곳웹·API 등 무상태 앱 전부DB · Kafka · Elasticsearch · ZooKeeper 등 노드 신원이 중요한 클러스터
"DB 를 쿠버네티스에 올릴 것인가"는 신중히 결정하세요. StatefulSet 으로 PostgreSQL 을 띄우는 것 자체는 어렵지 않습니다. 어려운 것은 백업·복원·페일오버·버전 업그레이드·디스크 확장입니다. 팀에 전담 인력이 없다면 관리형 DB(RDS·Cloud SQL) 를 쓰고 앱만 쿠버네티스에 올리는 조합이 사고 확률이 훨씬 낮습니다. 꼭 클러스터 안에 둬야 한다면 오퍼레이터(CloudNativePG · Strimzi)를 쓰세요 — 백업·페일오버가 코드로 들어 있습니다.
ConfigMap 을 고쳤는데 반영이 안 됩니다. 환경 변수로 주입했다면 정상 동작입니다 — 프로세스 시작 시 값이 복사되기 때문입니다. 해법은 ① 파일 마운트로 바꾸고 앱이 다시 읽게 하거나 ② ConfigMap 내용의 해시를 파드 어노테이션에 넣어 내용이 바뀌면 파드가 새로 뜨게 하는 것입니다. Helm 에서는 checksum/config 어노테이션 패턴으로 씁니다(d12). 급하면 kubectl rollout restart deploy/api 로 무중단 재시작합니다.
정리 — 이미지는 하나, 설정은 환경마다가 원칙입니다. 평문은 ConfigMap, 비밀은 외부 저장소 참조, 남아야 할 데이터는 PVC — 이 세 갈래로 나누면 환경 이동이 쉬워집니다.
K8S 09

무중단 배포 · 프로브 제대로 쓰기

"쿠버네티스를 쓰면 무중단"이 아닙니다. 프로브 세 개와 종료 처리를 제대로 쓸 때만 무중단입니다. 대부분의 배포 중 502 는 여기 설정이 틀려서 납니다.

프로브 세 개 — 실패했을 때 "무엇을 하느냐"가 완전히 다릅니다 startup "아직 기동 중인가?" 통과 전엔 나머지 둘을 정지 실패 누적 → 재시작 readiness "트래픽 받을 수 있나?" 실패 → 엔드포인트에서 제외 재시작하지 않음 · 회복 가능 liveness "완전히 먹통인가?" 실패 → 컨테이너 강제 재시작 잘못 쓰면 재시작 폭풍 파드 하나의 상태 전이 Pending Running 0/1 · 아직 NotReady readiness ✓ Ready 1/1 트래픽 받음 readiness ✗ → 잠시 제외 회복되면 다시 투입 liveness ✗ → 재시작 핵심 구분 — readiness 는 "지금은 말 걸지 마", liveness 는 "너 죽었으니 다시 태어나"입니다 기동이 느린 앱(JVM·대용량 캐시 워밍)에 startup 을 안 쓰면 기동 중 liveness 에 걸려 무한 재시작합니다 이것이 CrashLoopBackOff 의 흔한 원인 중 하나입니다(d14)
한 줄 원칙 — readiness 는 넉넉하게 자주, liveness 는 보수적으로 드물게.

프로브 설정 — 실전 값

containers:
  - name: api
    # ① 기동 보호 — 최대 5분(6초 × 50회)까지 기다립니다
    startupProbe:
      httpGet: { path: /healthz/live, port: 3000 }
      periodSeconds: 6
      failureThreshold: 50
    # ② 트래픽 스위치 — 자주 확인, 빨리 빼고 빨리 넣기
    readinessProbe:
      httpGet: { path: /healthz/ready, port: 3000 }
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 3          # 15초 이상 나쁘면 제외
      successThreshold: 1
    # ③ 최후 수단 — 관대하게
    livenessProbe:
      httpGet: { path: /healthz/live, port: 3000 }
      periodSeconds: 15
      timeoutSeconds: 3
      failureThreshold: 5          # 75초 넘게 먹통일 때만 재시작
gRPC · TCP 도 됩니다. grpc: { port: 9090 } 또는 tcpSocket: { port: 5432 }. 명령 실행형 exec 는 비용이 크니 꼭 필요할 때만.

엔드포인트 구현 — 무엇을 검사하나

// /healthz/live — 프로세스가 살아 있나. 의존성은 절대 확인하지 않습니다
app.get('/healthz/live', (req, res) => res.status(200).send('ok'));

// /healthz/ready — 트래픽을 받을 수 있나. 필수 의존성만 짧게
app.get('/healthz/ready', async (req, res) => {
  if (shuttingDown) return res.status(503).send('shutting down'); // ★
  try {
    await db.query('SELECT 1');          // 타임아웃 1초로 짧게
    return res.status(200).send('ready');
  } catch (e) {
    return res.status(503).send('db down');
  }
});
liveness 에서 DB 를 확인하면 대형 사고가 납니다. DB 가 잠깐 느려지면 모든 파드가 동시에 재시작해 서비스가 통째로 내려갑니다. liveness 는 자기 자신만 봅니다.

롤링 업데이트 — 시간축으로 보기

maxSurge=1 · maxUnavailable=0 — "먼저 띄우고 나중에 죽인다" (용량이 줄지 않음) t0 v1 v1 v1 가용 3 — 정상 t1 v1 v1 v1 v2 v2 하나 추가 (surge) · 아직 NotReady t2 v1 v1 v1 종료 v2 ✓ v2 가 Ready 된 뒤에야 v1 하나를 뺍니다 t3 v2 ✓ v2 ✓ v2 ✓ 완료 — 가용 파드가 한 번도 3 아래로 안 내려갔습니다 maxUnavailable=1 로 두면 반대 — 먼저 죽이고 나중에 띄웁니다. 배포 중 용량이 2/3 로 줄어 피크 시간에 하면 나머지 파드가 부하를 못 견디고 연쇄로 무너집니다 반대로 maxSurge 를 크게(50%) 두면 배포가 빠르지만 순간 리소스가 1.5배 필요합니다 기본값은 25%/25% — 파드가 3개면 "하나 죽고 시작"이라 무중단이 아닙니다. 명시하세요
운영 기본값 — maxSurge: 1 · maxUnavailable: 0. 여유 노드가 없을 때만 타협합니다.

종료 처리 — 502 가 나는 진짜 이유

종료 시 두 가지 일이 동시에 시작됩니다 — 속도가 달라서 틈이 생깁니다 preStop 없음 — 502 구간 발생 엔드포인트 제거 전파 노드들에 규칙 반영 ~2초 SIGTERM → 앱 종료 즉시 종료 이 구간 요청 = 502 preStop sleep 5 — 틈이 사라짐 엔드포인트 제거 전파 규칙 반영 완료 preStop → SIGTERM sleep 5 (새 요청 안 옴) 진행 중 요청 마무리 후 종료 terminationGrace- PeriodSeconds 안에 preStop + 종료가 끝나야 합니다 안 끝나면 SIGKILL 앱이 SIGTERM 을 받으면 ① 새 요청 거부(readiness 503) ② 진행 중 요청 완료 ③ 커넥션 정리 후 종료 긴 작업이 있으면 terminationGracePeriodSeconds 를 그 작업 최대 시간보다 크게 잡아야 합니다
무중단의 마지막 조각 — preStop 지연 + 앱의 graceful shutdown. 둘 중 하나만 있어도 502 가 남습니다.
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0            # ★ 용량이 줄지 않게
  minReadySeconds: 10              # Ready 후 10초는 더 지켜본 뒤 다음으로
  progressDeadlineSeconds: 600     # 10분 안에 못 끝내면 실패 처리
  template:
    spec:
      terminationGracePeriodSeconds: 45     # preStop + 종료가 이 안에 끝나야
      containers:
        - name: api
          lifecycle:
            preStop:
              exec:
                command: ["sh","-c","sleep 5"]    # 엔드포인트 전파 대기
          # (프로브는 위 예시와 동일)
// graceful shutdown — Node.js 예. 언어가 달라도 순서는 같습니다
let shuttingDown = false;
const server = app.listen(3000);

process.on('SIGTERM', async () => {
  shuttingDown = true;              // ① readiness 를 503 으로 — 새 트래픽 차단
  await sleep(3000);                // ② LB·엔드포인트 갱신 여유
  server.close(async () => {        // ③ 진행 중 요청이 끝나기를 기다림
    await db.end();                 // ④ DB 커넥션 반납
    await redis.quit();
    process.exit(0);
  });
  // ⑤ 안전장치 — 30초 넘으면 그냥 종료
  setTimeout(() => process.exit(0), 30_000).unref();
});
Spring Boot 는 설정 두 줄이면 됩니다. server.shutdown=graceful 과 spring.lifecycle.timeout-per-shutdown-phase=30s 를 켜면 SIGTERM 에 진행 중 요청을 마치고 종료합니다. 단 이미지에서 ENTRYPOINT 를 셸 형식(ENTRYPOINT java -jar app.jar)으로 쓰면 시그널이 자바까지 안 갑니다 — 반드시 배열(exec) 형식으로 쓰세요(d03).

카나리 · 블루그린 — 더 안전한 배포 🔴 고급

전략방식장단
롤링조금씩 교체 (기본 제공)추가 리소스 거의 없음. 문제를 늦게 발견하고 롤백에도 시간이 걸립니다
카나리새 버전에 트래픽 5% → 20% → 50% 순으로 조금씩실사용자 지표로 검증. 자동 분석(에러율·지연)과 묶으면 강력합니다. 구현이 복잡
블루그린구 버전 옆에 같은 규모의 신 버전을 통째로 띄우고 한 번에 전환롤백이 즉시(스위치만 되돌림). 리소스가 2배 필요합니다
기능 플래그배포와 노출을 분리 — 코드는 나가 있고 스위치로 켬가장 유연. 배포 사고와 기능 사고를 분리합니다. 플래그 정리를 안 하면 코드가 썩습니다
# Argo Rollouts — Deployment 를 Rollout 으로 바꾸면 카나리가 됩니다
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata: { name: api }
spec:
  replicas: 10
  strategy:
    canary:
      canaryService: api-canary       # 카나리로 가는 Service
      stableService: api-stable
      trafficRouting:
        nginx: { stableIngress: api }
      steps:
        - setWeight: 5                # 5% 만 새 버전으로
        - pause: { duration: 5m }     # 5분 관찰
        - analysis:                   # ★ 지표를 보고 자동 판정
            templates: [{ templateName: error-rate }]
        - setWeight: 25
        - pause: { duration: 10m }
        - setWeight: 50
        - pause: { duration: 10m }
        # 통과하면 100% 로. 분석 실패 시 자동 롤백됩니다
  selector: { matchLabels: { app: api } }
  template: { }   # ← Deployment 의 template 과 동일한 내용이 들어갑니다
순서를 지키세요. ①먼저 롤링 + 프로브 + graceful shutdown 을 완성합니다. ②그다음 관측(d11)을 붙여 에러율·지연을 볼 수 있게 만듭니다. ③그러고 나서 카나리를 도입합니다. 지표 없이 카나리를 켜면 "5% 를 5분 보다가 그냥 넘기는" 의식용 절차가 됩니다 — 아무것도 못 잡습니다.
정리 — 무중단 배포의 재료는 넷 — readiness 정직하게 · maxUnavailable 0 · preStop 지연 · 앱의 SIGTERM 처리. 하나라도 빠지면 배포마다 502 가 조금씩 납니다.
K8S 10

오토스케일 · 리소스 — requests 와 limits

쿠버네티스에서 가장 많이 틀리는 숫자가 requests 와 limits 입니다. 이 두 값이 스케줄링 · 안정성 · 비용 · 오토스케일을 전부 결정합니다.

requests = 예약(스케줄링 기준) · limits = 상한(넘으면 제재) 노드 (CPU 4코어 · 메모리 8Gi) Pod A req 500m / 1Gi Pod B req 500m / 1Gi 남은 예약 가능량 3코어 / 6Gi ← 다음 파드는 여기 들어감 CPU 는 압축 가능 limit 초과 → 스로틀 (느려짐, 죽지는 않음) 응답 지연 급증 · p99 가 튐 — 원인 찾기 어려움 메모리는 압축 불가 limit 초과 → 즉시 OOMKilled (137) 경고 없이 프로세스 사망 → 재시작 requests 를 너무 크게 — 노드에 파드가 몇 개 안 들어가 노드 수(= 비용)가 늘어납니다 requests 를 너무 작게 — 노드가 과밀해져 서로 자원을 뺏고 p99 지연이 널뜁니다 CPU limits 는 생략이 실무 권장 — requests 로 공평 분배가 되고, 불필요한 스로틀을 피합니다 메모리는 requests = limits 로 같게 두는 것이 안전합니다(Guaranteed 등급 · 축출 우선순위 최하) HPA 는 "실사용 ÷ requests" 로 계산합니다 — requests 가 틀리면 오토스케일도 전부 틀립니다
기억할 한 줄 — CPU 초과는 느려지고, 메모리 초과는 죽습니다. 그래서 다루는 방식이 다릅니다.

숫자를 정하는 법 — 감이 아니라 측정

# ① 지금 얼마나 쓰는지 본다 (metrics-server 필요)
kubectl top pods -n myapp
kubectl top pods -n myapp --containers --sort-by=memory

# ② 부하를 준 상태에서 다시 본다 — 한가할 때 잰 숫자는 쓸모없습니다
kubectl -n myapp exec -it loadgen -- k6 run /scripts/peak.js
watch -n2 kubectl top pods -n myapp

# ③ 프로메테우스가 있으면 일주일치 백분위수로 (d11)
#    memory: max_over_time(container_memory_working_set_bytes[7d])
#    cpu:    quantile_over_time(0.95, rate(container_cpu_usage_seconds_total[5m])[7d:])

# ④ OOM 이 났는지 확인 — 재시작 원인이 여기 찍힙니다
kubectl -n myapp describe pod api-xxx | grep -A3 'Last State'
#   Last State: Terminated
#     Reason: OOMKilled   Exit Code: 137
항목정하는 방법실무 감각
memory requests일주일 최대 사용량(working set) × 1.2JVM 은 힙 + 메타스페이스 + 스레드 스택을 합해야 합니다. -Xmx 만 보면 반드시 OOM 납니다
memory limitsrequests 와 같게다르게 두면 Burstable 등급이 되어 노드가 빡빡할 때 먼저 쫓겨납니다
cpu requests평상시 p95 사용량100m = 0.1코어. 웹 API 는 보통 100m~500m 에서 시작합니다
cpu limits대개 생략. 멀티테넌트라면 requests 의 2~4배limits 를 걸면 스로틀로 p99 가 튑니다. 특히 JVM·Go 는 코어 수를 오인해 스레드를 과하게 만듭니다
JVM · Node · Go 의 "코어 수 오인" 문제. 컨테이너에 cpu: 500m 를 줘도 런타임은 노드 전체 코어 수(예: 32)를 보고 스레드 풀·GC 스레드를 만듭니다. 그 결과 32개 스레드가 0.5코어를 나눠 쓰며 컨텍스트 스위칭만 하게 됩니다.
· Java: -XX:MaxRAMPercentage 는 메모리를, -XX:ActiveProcessorCount 는 CPU 를 맞춥니다(Java 10+ 는 대부분 자동 인식)
· Go: GOMAXPROCS 를 automaxprocs 라이브러리로 맞춥니다
· Node: UV_THREADPOOL_SIZE 를 조정합니다.

HPA — 대수를 자동으로

HPA 계산식 — 원하는 파드 수 = 현재 파드 수 × (현재 사용률 ÷ 목표 사용률) 메트릭 수집 15초마다 · CPU 90% 계산 4 × (90 ÷ 60) = 6 replicas 4 → 6 Deployment 수정 파드가 뜨고 Ready 될 때까지 30초~2분 — 이 지연이 오토스케일의 한계입니다 늘릴 때는 즉시, 줄일 때는 천천히(기본 안정화 창 5분) — 안 그러면 늘렸다 줄였다 진동합니다 CPU 기준은 웹 API 에만 맞습니다 — 워커는 큐 길이, 채팅은 커넥션 수가 정확합니다(KEDA) HPA 는 requests 대비 비율로 계산합니다 — requests 를 안 적으면 HPA 가 아예 동작하지 않습니다 HPA 와 replicas 를 동시에 관리하면 충돌합니다 — HPA 를 쓰면 매니페스트에서 replicas 를 빼세요
함정 — requests 없이 HPA 를 만들면 조용히 아무 일도 일어나지 않습니다. 가장 흔한 "HPA 가 안 돌아요"의 원인입니다.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api }
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3                  # ★ 피크 직전 수준으로. 1 로 두면 급증을 못 버팁니다
  maxReplicas: 30                 # ★ 비용 상한이자 사고 방지선
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 60 }
    - type: Pods                  # 커스텀 메트릭 — 초당 요청 수
      pods:
        metric: { name: http_requests_per_second }
        target: { type: AverageValue, averageValue: "100" }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - { type: Percent, value: 100, periodSeconds: 30 }   # 30초에 최대 2배
    scaleDown:
      stabilizationWindowSeconds: 300                        # 5분간 최댓값 유지
      policies:
        - { type: Percent, value: 20, periodSeconds: 60 }    # 1분에 최대 20%씩만
오토스케일러무엇을 늘리나메모
HPA파드 개수 (가로)가장 많이 씁니다. 무상태 앱이 전제입니다(📈 대규모 트래픽 x02)
VPA파드의 requests/limits (세로)적정값 추천에 유용. HPA(CPU)와 같이 쓰면 충돌하므로 보통 updateMode: Off 로 추천만 받습니다
Cluster Autoscaler
Karpenter
노드 대수파드가 Pending 이면 노드를 추가합니다. 노드 기동에 1~3분 걸리는 것을 계산에 넣으세요
KEDA파드 개수 (외부 이벤트 기준)Kafka 랙 · SQS 길이 · Redis 큐로 스케일. 워커에는 CPU 보다 훨씬 정확합니다(📨 Kafka k03)

PDB · 배치 제어 — 자동화가 서비스를 죽이지 않게 🟡 중급

# PodDisruptionBudget — "자발적 중단 시 최소 이만큼은 남겨라"
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: api }
spec:
  minAvailable: 2                 # 또는 maxUnavailable: 1
  selector:
    matchLabels: { app: api }
# 효과: 노드 업그레이드(drain)·클러스터 축소 시 파드를 한꺼번에 못 빼갑니다
# ★ 주의: replicas 2 에 minAvailable 2 를 걸면 drain 이 영원히 멈춥니다
---
# 파드를 서로 다른 노드·존에 흩기 — 노드 하나 죽어도 전멸하지 않게
spec:
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector: { matchLabels: { app: api } }
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                topologyKey: kubernetes.io/hostname
                labelSelector: { matchLabels: { app: api } }
      # 특정 노드 그룹에만 배치 (GPU 노드 · 고사양 노드)
      nodeSelector: { workload: general }
      tolerations:
        - key: "gpu"
          operator: "Equal"
          value: "true"
          effect: "NoSchedule"
taint 와 toleration 을 한 문장으로. taint 는 노드가 붙이는 "출입 금지 표시"이고, toleration 은 파드가 가진 "출입증"입니다. GPU 노드에 taint 를 걸어 두면 일반 파드가 비싼 GPU 노드를 차지하는 일이 없고, GPU 워크로드만 toleration 을 달고 들어갑니다. nodeSelector/affinity 는 "어디로 가고 싶다", taint 는 "아무나 오지 마라" — 방향이 반대입니다. 보통 둘을 같이 씁니다.
오토스케일 사고 3종. ① 비용 폭발 — maxReplicas 를 안 걸어 무한 증설(요금 고지서로 알게 됩니다) ② DB 커넥션 고갈 — 파드가 30개로 늘면 커넥션 풀도 30배. DB 가 먼저 죽습니다 ③ 재시작 폭풍 — 축소 시 드레인 없이 죽여 처리 중 요청이 유실. 앱만 늘리는 오토스케일은 반쪽입니다 — 아래층(DB·캐시·외부 API 쿼터)을 같이 계산하세요 (📈 대규모 트래픽 x08 에 규모 산정 관점이 더 있습니다).
정리 — requests 를 측정해서 적고 · 메모리는 limits 를 같게 · CPU limits 는 신중히 · HPA 는 min 을 충분히. 이 넷이 안정성과 비용을 동시에 잡습니다.
K8S 11

관측 — 로그 · 메트릭 · 추적

컨테이너는 죽으면 흔적이 사라집니다. 파드가 재시작되면 그 안의 로그도 함께 없어지죠. 그래서 쿠버네티스에서는 관측 도구가 선택이 아니라 전제입니다.

관측 세 갈래 — 수집 방식이 다르고, 마지막에 한 화면에서 만납니다 클러스터 앱 파드 앱 파드 kube-state-metrics node-exporter 로그 — Fluent Bit 노드마다 DaemonSet 메트릭 — Prometheus 15초마다 pull(scrape) 추적 — OTel Collector 앱이 push Loki TSDB Tempo Grafana 한 화면 Alertmanager → Slack · 호출 셋을 잇는 끈이 trace_id 입니다 — 느린 요청의 추적을 열고 거기서 바로 그 요청의 로그로 건너뛸 수 있게 만드는 것이 목표입니다 붙이는 순서 — ①로그 ②메트릭·알림 ③대시보드 ④추적. 한 번에 다 하려다 아무것도 못 씁니다
관측의 목적은 대시보드가 아닙니다 — "장애 났을 때 5분 안에 원인 후보를 좁히는 것"입니다.

① 로그 — 파일이 아니라 표준 출력으로

컨테이너 앱은 로그 파일을 직접 쓰지 않습니다. stdout/stderr 로 뱉으면 컨테이너 런타임이 노드의 /var/log/pods/ 에 적고, 수집기가 그걸 읽어 갑니다. JSON 한 줄 형식으로 뱉는 것이 핵심입니다 — 파싱 없이 필드로 검색됩니다.

좋은 로그 한 줄

{"ts":"2026-08-25T10:22:31.442Z","level":"error",
 "msg":"order create failed",
 "service":"api","version":"1.4.2","pod":"api-6d5f-2xk4v",
 "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
 "user_id":"u-8812","order_id":"o-99213",
 "err":"pg: deadlock detected","duration_ms":1842}
trace_id 가 핵심입니다. 이게 있어야 추적 ↔ 로그를 오갈 수 있습니다(🧬 전문가 z13).

Fluent Bit — 노드마다 하나(DaemonSet)

[INPUT]
    Name              tail
    Path              /var/log/containers/*.log
    Parser            cri
    Tag               kube.*
    Mem_Buf_Limit     32MB
    Skip_Long_Lines   On

[FILTER]
    Name                kubernetes
    Match               kube.*
    # JSON 로그를 필드로 펼침 (주석은 줄 끝이 아니라 따로 한 줄로)
    Merge_Log           On
    Keep_Log            Off
    # 파드 라벨을 붙여 검색 가능하게
    Labels              On

[OUTPUT]
    Name   loki
    Match  kube.*
    Host   loki.observability.svc
    Labels job=fluentbit, $kubernetes['namespace_name']
로그가 디스크를 채워 노드를 죽입니다. 노드의 컨테이너 로그는 기본적으로 파일당 10MB × 5개로 로테이션되지만, 디버그 로그를 그대로 켜 두면 수집기가 못 따라가고 노드 디스크가 찹니다 → DiskPressure → 파드가 무더기로 축출(Evicted)됩니다(d14). 운영에서는 INFO 이상 · 요청 본문 미출력 · 샘플링이 기본입니다.

② 메트릭 — Prometheus 가 긁어 갑니다

# kube-prometheus-stack 한 방 설치 (Prometheus + Grafana + Alertmanager + 익스포터)
# helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
# helm install obs prometheus-community/kube-prometheus-stack -n observability --create-namespace

# 내 앱의 메트릭을 긁어 가게 등록 — ServiceMonitor
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: api
  labels: { release: obs }        # ★ Prometheus 가 고르는 라벨과 맞아야 합니다
spec:
  selector:
    matchLabels: { app: api }
  endpoints:
    - port: http                  # Service 의 포트 "이름"
      path: /metrics
      interval: 15s
무엇을 보나쿼리(PromQL) 감각
요청량 (RED-Rate)sum(rate(http_requests_total[5m])) by (service)
에러율 (Errors)sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))
지연 p95 (Duration)histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
파드 재시작increase(kube_pod_container_status_restarts_total[1h]) > 3
메모리 여유container_memory_working_set_bytes / on(pod) kube_pod_container_resource_limits{resource="memory"}
CPU 스로틀rate(container_cpu_cfs_throttled_seconds_total[5m]) — d10 의 스로틀을 여기서 봅니다
준비 안 된 파드kube_deployment_status_replicas_unavailable > 0
# 알림 규칙 — 증상 기준으로 씁니다("CPU 80%" 같은 원인 기준은 노이즈가 됩니다)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: api-slo
  labels: { release: obs }
spec:
  groups:
    - name: api
      rules:
        - alert: ApiHighErrorRate
          expr: |
            sum(rate(http_requests_total{job="api",status=~"5.."}[5m]))
            / sum(rate(http_requests_total{job="api"}[5m])) > 0.02
          for: 5m                      # ★ 5분 지속될 때만 — 순간 스파이크 무시
          labels: { severity: page }
          annotations:
            summary: "API 5xx 비율 2% 초과"
            runbook: "https://wiki/runbook/api-5xx"    # ★ 대응 문서 링크

        - alert: PodCrashLooping
          expr: increase(kube_pod_container_status_restarts_total[15m]) > 3
          for: 5m
          labels: { severity: page }
알림은 "사람이 지금 일어나야 하는 것"만 페이지로 보냅니다. SLO 기반(사용자가 느끼는 에러율·지연)으로 3~5개만 두고, 나머지는 대시보드나 티켓으로 보냅니다. 알림이 하루 50개 오면 전부 무시하게 되고 그중에 진짜가 섞여 있어도 못 봅니다 — 이것이 알림 피로(alert fatigue)입니다. 모든 알림에 런북 링크를 붙이는 것도 규칙으로 두세요.

③ 분산 추적 — 어느 구간이 느린가 🟡 중급

"주문 API 가 3초 걸립니다"까지는 메트릭이 알려 줍니다. "그 3초 중 2.4초가 재고 서비스의 DB 쿼리였다"는 추적만 알려 줍니다. 서비스가 3개를 넘으면 추적 없이 원인을 찾는 것이 사실상 불가능해집니다(🧱 MSA m10).

# 앱에 코드를 거의 안 고치고 붙이는 법 — 자동 계측
spec:
  template:
    metadata:
      annotations:
        instrumentation.opentelemetry.io/inject-java: "true"   # 자바 에이전트 자동 주입
    spec:
      containers:
        - name: api
          env:
            - name: OTEL_SERVICE_NAME
              value: api
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: http://otel-collector.observability:4318   # 자바 에이전트 2.x 기본 = http/protobuf(4318)
            - name: OTEL_TRACES_SAMPLER
              value: parentbased_traceidratio
            - name: OTEL_TRACES_SAMPLER_ARG
              value: "0.05"        # ★ 5% 샘플링 — 100% 는 비용이 감당 안 됩니다
            - name: OTEL_RESOURCE_ATTRIBUTES
              value: "service.version=1.4.2,deployment.environment=prod"
샘플링 전략. 전부 저장하면 저장 비용이 로그보다 큽니다. 실무 조합은 ①기본 1~10% 확률 샘플링 + ②에러·느린 요청은 무조건 저장(tail sampling) 입니다. OTel Collector 에 tail_sampling 프로세서를 두면 "지연 1초 초과 또는 에러 상태면 100% 저장"을 만들 수 있습니다 — 정작 보고 싶은 요청은 다 남습니다.

무엇부터 붙일까 — 순서

순서붙이는 것이유
1주차로그 집계 (Loki + Fluent Bit) + JSON 로깅파드가 죽어도 로그가 남습니다. 이것 없이는 아무것도 못 합니다
2주차메트릭 + 알림 3개 (5xx · 지연 p95 · CrashLoop)"장애를 사용자보다 먼저 아는" 상태가 됩니다
3주차대시보드 — 서비스별 RED + 노드/파드 리소스배포 후 5분간 볼 화면. 있으면 배포 자신감이 생깁니다
4주차~추적 (OTel + Tempo) · trace_id 로그 연결서비스 간 호출이 얽히기 시작할 때 진가가 나옵니다
그다음SLO·에러 예산 · 프로파일링(Pyroscope) · 비용 가시화(OpenCost)여기까지 오면 관측이 문화로 자리 잡은 것입니다
관측 스택 자체가 클러스터를 죽이는 일이 있습니다. Prometheus 는 메모리를 많이 먹고(시계열 수에 비례), 로그 수집은 디스크 I/O 를 씁니다. ①관측 스택에 별도 노드 그룹을 주고 ②라벨 카디널리티를 통제하세요 — user_id 같은 무한한 값을 라벨로 넣으면 시계열이 폭발해 Prometheus 가 OOM 으로 죽습니다. 그러면 장애가 났을 때 볼 것이 없어집니다. 규모가 커지면 관리형(Grafana Cloud · Datadog · 클라우드 네이티브 서비스)이 총비용에서 유리한 지점이 옵니다.
정리 — 구조화된 로그 → 알림 3개 → 대시보드 → 추적 순서로 붙이세요. 관측이 없는 쿠버네티스는 계기판 없는 비행기입니다.
K8S 12

패키징 · GitOps — Helm · Kustomize · Argo CD

환경이 dev·stg·prod 셋이 되는 순간 YAML 복사본이 세 벌 생기고, 곧 서로 달라집니다. 템플릿으로 묶고 · git 을 유일한 진실로 삼는 것이 해법입니다.

배포 방식의 방향이 바뀝니다 — 밀어 넣기(push)에서 끌어오기(pull)로 기존 — 사람이 클러스터를 직접 개발자 PC kubectl apply 클러스터 누가 뭘 바꿨는지 모름 git 파일과 실제 상태가 어긋납니다(drift) 권한도 사람 수만큼 뿌려야 합니다 GitOps — git 이 유일한 진실 PR · merge git 저장소 Argo CD 가 지켜봄 Argo CD (클러스터 안) 차이 발견 → 스스로 반영 K8s 배포 이력 = git log · 롤백 = revert 손으로 고쳐도 원상 복구(self-heal) 덤 — CI 가 클러스터 자격 증명을 가질 필요가 없습니다. 외부에 크레덴셜을 안 뿌리는 것이 큰 보안 이득입니다
GitOps 한 줄 — "클러스터의 상태는 git 저장소가 정의한다". 사람은 git 만 고칩니다.

Helm — 템플릿 + 값

차트 구조

charts/api/
├── Chart.yaml            # 이름 · 버전 · 의존 차트
├── values.yaml           # 기본값 (모든 환경 공통)
├── values-stg.yaml       # 스테이징 덮어쓰기
├── values-prod.yaml      # 운영 덮어쓰기
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── hpa.yaml
    ├── _helpers.tpl      # 이름·라벨 생성 함수
    └── NOTES.txt         # 설치 후 안내 문구
# values.yaml
replicaCount: 3
image:
  repository: ghcr.io/myorg/api
  tag: ""                  # ★ 비워 두고 배포 시 --set 으로 주입
resources:
  requests: { cpu: 100m, memory: 256Mi }
  limits:   { memory: 256Mi }
ingress:
  enabled: true
  host: api.example.com

템플릿과 명령

# templates/deployment.yaml (발췌)
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    metadata:
      annotations:
        # ★ ConfigMap 이 바뀌면 파드가 새로 뜨게 (d08)
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      containers:
        - name: api
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          resources: {{- toYaml .Values.resources | nindent 12 }}
# 렌더링 결과를 눈으로 먼저 (★ 습관)
helm template api ./charts/api -f charts/api/values-prod.yaml

# 설치·업그레이드 — 원자적으로, 실패하면 자동 롤백
helm upgrade --install api ./charts/api \
  -n prod -f charts/api/values-prod.yaml \
  --set image.tag=1.4.3 \
  --atomic --timeout 5m

helm history api -n prod
helm rollback api 3 -n prod        # 3번 리비전으로 되돌리기
helm diff upgrade api ./charts/api -n prod -f charts/api/values-prod.yaml   # 플러그인
Helm 템플릿을 과하게 추상화하지 마세요. 모든 필드를 values 로 뺀 "만능 차트"는 아무도 못 읽는 YAML 생성기가 됩니다. 실무 감각은 바꿀 것만 값으로(이미지 태그·replicas·리소스·호스트·환경 변수), 나머지는 템플릿에 고정입니다. 렌더 결과가 헷갈리면 그 차트는 이미 너무 복잡합니다.

Kustomize — 템플릿 없이 겹쳐 쓰기

base + overlays

k8s/
├── base/
│   ├── kustomization.yaml
│   ├── deployment.yaml       # 순수 YAML — 그냥 읽힙니다
│   └── service.yaml
└── overlays/
    ├── stg/
    │   ├── kustomization.yaml
    │   └── replicas-patch.yaml
    └── prod/
        ├── kustomization.yaml
        └── resources-patch.yaml
# overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod
resources: [../../base]
images:
  - name: ghcr.io/myorg/api
    newTag: 1.4.3               # ★ CI 가 이 줄만 고쳐 커밋합니다
replicas:
  - { name: api, count: 6 }
patches:
  - path: resources-patch.yaml
configMapGenerator:
  - name: api-config
    literals: [LOG_LEVEL=info]   # 내용이 바뀌면 이름에 해시가 붙어 자동 롤아웃

Helm vs Kustomize — 어느 쪽

기준선택
남에게 배포할 패키지Helm — 버전·의존성·배포 채널이 필요합니다
우리 앱의 환경 분기Kustomize — 결과가 그냥 YAML 이라 읽기 쉽습니다
서드파티 설치Helm — 대부분 차트로 배포됩니다
조건·반복이 많음Helm — Kustomize 는 로직이 없습니다
YAML 을 그대로 보고 싶다Kustomize
섞어 써도 됩니다. 서드파티는 Helm 으로 설치하고, 우리 앱은 Kustomize 로 관리하는 조합이 흔합니다. Argo CD 는 둘 다 지원하고, Helm 차트를 렌더한 뒤 Kustomize 로 덮어쓰는 방식도 됩니다.

Argo CD — 선언형 배포 🟡 중급

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: api-prod
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/deploy-manifests
    targetRevision: main
    path: overlays/prod
  destination:
    server: https://kubernetes.default.svc
    namespace: prod
  syncPolicy:
    automated:
      prune: true        # git 에서 지운 리소스는 클러스터에서도 삭제
      selfHeal: true     # ★ 손으로 고친 것을 git 상태로 되돌림
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true
    retry:
      limit: 3
      backoff: { duration: 15s, factor: 2, maxDuration: 3m }
개념뜻
Syncgit 상태를 클러스터에 반영. 자동/수동을 환경별로 다르게 둡니다(dev 자동 · prod 수동 승인)
Drift(어긋남)클러스터가 git 과 달라진 상태. Argo CD 화면에 OutOfSync 로 뜹니다
Self-heal어긋나면 자동으로 git 상태로 되돌림. 급한 손수정이 지워지므로 켜기 전에 팀 합의가 필요합니다
Prunegit 에서 삭제된 리소스를 클러스터에서도 삭제. 안 켜면 유령 리소스가 쌓입니다
App of AppsApplication 을 관리하는 Application. 서비스가 수십 개일 때 한 곳에서 관리합니다
Sync Wave적용 순서 지정(예: DB 마이그레이션 Job 을 앱보다 먼저). argocd.argoproj.io/sync-wave 어노테이션

환경 구성 전략 — 저장소를 어떻게 나누나

흔한 실수
  • 환경별로 브랜치(dev·stg·prod 브랜치) — 체리픽 지옥이 되고 무엇이 어디에 있는지 아무도 모르게 됩니다
  • 앱 코드와 매니페스트를 한 저장소에 두고 CI 가 같은 저장소에 커밋 — 무한 빌드 루프가 돌기 쉽습니다
  • 운영만 손으로 다르게 손질 — drift 가 쌓여 재현 불가능해집니다
실무에서 안정적인 구성
  • 매니페스트 저장소를 분리(deploy-manifests) — 앱 저장소는 코드와 이미지 빌드만
  • 브랜치는 main 하나, 환경은 디렉터리(overlays/dev · stg · prod)로 구분
  • CI 는 이미지 태그 한 줄만 커밋 → PR → 머지 시 Argo CD 가 반영
  • prod 는 수동 sync 또는 승인 필요, dev·stg 는 자동
  • 클러스터 공통 요소(ingress·모니터링·정책)는 플랫폼 저장소로 또 분리
DB 마이그레이션은 GitOps 로 안 풀립니다. 스키마 변경은 되돌릴 수 없는 작업이라 "선언형"과 궁합이 나쁩니다. 실무 패턴은 ①마이그레이션을 항상 하위 호환으로 작성(컬럼 추가만, 삭제는 두 배포 뒤) ②Argo CD PreSync Hook 또는 Job 으로 앱 배포 전에 실행 ③실패 시 배포 중단입니다. "코드 롤백해도 스키마는 안 돌아간다"를 전제로 설계하세요.
정리 — Helm/Kustomize 로 환경 차이를 흡수하고, Argo CD 로 git 을 진실로 만드는 것이 오늘날의 표준입니다. 배포 이력이 git log 가 되는 순간 운영이 훨씬 조용해집니다.
K8S 13

CI/CD — 커밋에서 배포까지 한 줄로

여기까지의 조각(이미지 · 태그 · 매니페스트 · GitOps)을 하나의 파이프라인으로 잇습니다. 완성되면 머지 → 5분 뒤 스테이징 반영 → 승인 → 운영 배포가 자동으로 흐릅니다.

파이프라인 전체 — 각 단계는 앞 단계가 성공해야만 돕니다 1 push · PR 머지 main 브랜치 2 테스트 · 린트 실패하면 여기서 끝 3 이미지 빌드 · push 태그 = 커밋 SHA 4 스캔 5 매니페스트 태그 갱신 deploy 저장소에 커밋 6 Argo CD 동기화 스테이징 자동 반영 7 스모크 테스트 · 승인 대기 사람이 버튼을 누릅니다 운영 반영 문제 → revert 롤백은 "이전 태그로 되돌리는 커밋" 하나 — 배포와 똑같은 경로로 안전하게 돌아갑니다
좋은 파이프라인의 기준 — 롤백이 배포만큼 쉬울 것. 그래야 배포가 무섭지 않습니다.

GitHub Actions — 빌드 · 스캔 · 푸시

# .github/workflows/build.yml
name: build
on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:

permissions:
  contents: read
  packages: write          # GHCR push 에 필요
  id-token: write          # cosign 키리스 서명에 필요

concurrency:               # 같은 브랜치의 이전 실행은 취소 — 큐가 밀리지 않게
  group: build-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm       # ★ 의존성 캐시 — 빌드 시간의 절반이 여기서 줄어듭니다
      - run: npm ci
      - run: npm run lint
      - run: npm test -- --coverage

  build:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}     # 별도 발급 불필요

      - id: meta                                    # 태그를 규칙대로 자동 생성
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=sha-,format=short
            type=ref,event=branch
            type=semver,pattern={{version}}

      - id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha                      # ★ 레이어 캐시를 Actions 에 저장
          cache-to: type=gha,mode=max
          provenance: true

      - name: Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }}
          severity: HIGH,CRITICAL
          ignore-unfixed: true
          exit-code: '1'                            # 취약점 있으면 실패

      - uses: sigstore/cosign-installer@v3              # 러너에 cosign 이 기본으로 없습니다

      - name: Sign image
        run: |
          cosign sign --yes \
            ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }}
# .github/workflows/deploy.yml — 매니페스트 저장소의 태그를 올려 GitOps 를 촉발
name: deploy
on:
  workflow_run:
    workflows: [build]
    types: [completed]

jobs:
  bump-staging:
    # PR 빌드는 제외 — main 푸시로 성공한 빌드만
    if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
    runs-on: ubuntu-latest
    env:
      # ★ 빌드된 커밋 — workflow_run 에서 GITHUB_SHA 는 기본 브랜치의 최신 커밋이라 다를 수 있습니다
      HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
    steps:
      - uses: actions/checkout@v4
        with:
          repository: myorg/deploy-manifests
          token: ${{ secrets.DEPLOY_REPO_TOKEN }}   # 최소 권한 PAT 또는 App 토큰
      - name: bump image tag
        run: |
          cd overlays/stg
          kustomize edit set image \
            ghcr.io/myorg/api=ghcr.io/myorg/api:sha-${HEAD_SHA::7}
      - name: commit
        run: |
          git config user.name  "ci-bot"
          git config user.email "ci-bot@myorg.dev"
          git commit -am "stg: api sha-${HEAD_SHA::7}"
          git push
      # 이후는 Argo CD 가 알아서 반영합니다(d12)

  deploy-prod:
    needs: bump-staging
    runs-on: ubuntu-latest
    environment: production        # ★ 여기서 사람 승인을 요구합니다
    env:
      HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
    steps:
      - uses: actions/checkout@v4
        with: { repository: myorg/deploy-manifests, token: '${{ secrets.DEPLOY_REPO_TOKEN }}' }
      # 스테이징에서 검증한 바로 그 이미지를 승격 (workflow_run 이벤트엔 release 정보가 없습니다)
      - run: |
          cd overlays/prod
          kustomize edit set image ghcr.io/myorg/api=ghcr.io/myorg/api:sha-${HEAD_SHA::7}
          git config user.name  "ci-bot"
          git config user.email "ci-bot@myorg.dev"
          git commit -am "prod: api sha-${HEAD_SHA::7}" && git push
항목실무 기준
시크릿 관리저장소 Secrets 대신 OIDC 연동을 우선(AWS·GCP 는 임시 자격 증명 발급이 됩니다). 장기 토큰은 만료일과 담당자를 명시
권한permissions: 를 워크플로마다 최소로 선언. 기본 write-all 을 끄는 것이 첫 단계
캐시언어 의존성 캐시 + 도커 레이어 캐시(type=gha). 5분 → 90초가 흔한 개선폭입니다
태그 기반 릴리스git tag v1.4.2 → 이미지 1.4.2 생성 → 운영은 릴리스 태그로만 배포
승인GitHub Environment protection rule 로 운영에 리뷰어 지정. Argo CD 를 수동 sync 로 두는 방식도 동일 효과
롤백매니페스트 저장소에서 revert 커밋 하나. kubectl rollout undo 는 임시 조치일 뿐(다음 sync 때 되돌아갑니다)
GitOps 에서 kubectl 로 직접 고치면 안 되는 이유. Argo CD 의 selfHeal 이 켜져 있으면 몇 분 뒤 git 상태로 되돌아갑니다 — 새벽에 급하게 고친 설정이 조용히 원복되어 장애가 재발합니다. 급할 때는 ①Argo CD 에서 해당 앱의 자동 sync 를 잠시 끄고 손을 대거나 ②그냥 git 에 핫픽스 커밋을 올리는 것이 낫습니다. 후자가 이력도 남고 다음 사람도 압니다.

배포 승인과 릴리스 절차 — 사람 몫

배포 전 체크(자동화 가능한 것)

  • 테스트·린트·타입 검사 통과
  • 이미지 취약점 CRITICAL 0(예외는 문서화)
  • kubectl diff 또는 helm diff 결과를 PR 코멘트로 자동 첨부
  • 스테이징에서 스모크 테스트(핵심 API 5개 + 로그인)
  • DB 마이그레이션이 하위 호환인지 확인

배포 직후 5분(사람이 봐야 할 것)

  • kubectl rollout status 가 완료로 끝났는가
  • 5xx 비율이 배포 전 수준인가(d11 대시보드)
  • 지연 p95가 튀지 않았는가
  • 파드 재시작 카운트가 0 인가
  • 에러 로그에 새로운 문구가 등장하지 않았는가
이 다섯 줄을 대시보드 한 화면에 모아 두면 배포 담당자가 볼 곳이 하나가 됩니다.
GitHub Actions 가 아니어도 구조는 같습니다. GitLab CI · Jenkins · Tekton · Buildkite 어느 것을 쓰든 ①테스트 ②이미지 빌드·불변 태그 ③스캔 ④매니페스트 갱신 ⑤GitOps 반영 ⑥승인이라는 뼈대는 동일합니다. 도구를 바꿀 때 옮겨야 하는 것은 문법뿐입니다.
정리 — 파이프라인의 목표는 속도가 아니라 "안심하고 자주 배포하는 것"입니다. 불변 태그 · 자동 검증 · 쉬운 롤백 셋이 그 안심을 만듭니다.
K8S 14

실전 문제 해결 — 증상에서 원인까지

장애가 났을 때 필요한 것은 지식이 아니라 순서입니다. 이 섹션은 그대로 따라 치면 원인이 좁혀지는 절차와 증상 사전입니다.

진단 흐름 — 상태를 보고 갈래를 고른 뒤, 그 갈래의 명령만 칩니다 kubectl get pods STATUS · RESTARTS · AGE 확인 Pending ImagePullBackOff CrashLoopBackOff Running 인데 502 describe pod Events 확인 리소스 · 노드 · PVC 이미지 이름 오타? imagePullSecrets 있나 태그가 실제 있나 logs --previous 종료 코드 확인 137=OOM · 1=앱오류 endpoints 비었나 readiness 통과했나 selector 라벨 맞나 막히면 항상 여기로 — kubectl get events --sort-by=.lastTimestamp 는 클러스터가 남긴 사건 기록입니다 스케줄링 실패 · 축출 · 프로브 실패 · 볼륨 마운트 실패가 전부 문장으로 적혀 있습니다
순서를 지키세요 — get → describe(이벤트) → logs(--previous) → exec/debug. 이 순서로 90% 가 잡힙니다.

증상 사전 — 원인과 해결

증상흔한 원인확인·해결
Pending
(스케줄 안 됨)
① 노드에 남은 CPU/메모리 부족 ② requests 가 어떤 노드보다 큼 ③ PVC 가 바인딩 안 됨 ④ taint 에 막힘 ⑤ nodeSelector 조건에 맞는 노드 없음kubectl describe pod 의 Events 에 0/5 nodes are available: Insufficient cpu 처럼 이유가 그대로 적혀 있습니다. requests 를 낮추거나 노드를 늘립니다(d10)
ImagePullBackOff
ErrImagePull
① 이미지 이름·태그 오타 ② 사설 레지스트리 인증 없음 ③ 레지스트리 rate limit ④ 아키텍처 불일치describe 의 Failed to pull image 문구를 그대로 읽습니다. imagePullSecrets 확인, crane ls 로 태그 존재 확인(d05)
CrashLoopBackOff① 앱이 시작하자마자 예외 ② 설정·시크릿 누락 ③ liveness 가 기동 시간보다 짧음 ④ OOMKilled 반복 ⑤ 명령/경로 오류kubectl logs POD --previous 가 정답의 90%. 종료 코드도 확인 — 137=OOM, 143=SIGTERM, 1=앱 예외, 127=명령 없음
OOMKilled
(코드 137)
① memory limits 가 실사용보다 작음 ② 메모리 누수 ③ JVM 힙 외 영역 미계산 ④ 갑작스러운 대용량 요청describe 의 Last State: Terminated / Reason: OOMKilled. limits 를 올리고, 반복되면 누수 조사(힙 덤프·프로파일러). 무작정 올리면 노드가 죽습니다(d10)
Evicted
(축출)
노드의 디스크·메모리 압박. 로그 폭주·임시 파일 누적이 흔한 원인kubectl get events 에 DiskPressure·MemoryPressure. 로그 레벨을 낮추고 emptyDir 에 sizeLimit 설정. 축출된 파드는 수동 정리 필요
Running 인데 502① Service selector 라벨 불일치 ② targetPort 틀림 ③ readiness 실패로 엔드포인트 비어 있음 ④ 앱이 127.0.0.1 바인딩kubectl get endpointslice 가 비어 있는지 먼저 봅니다. 비었으면 라벨/프로브 문제, 차 있으면 Ingress·앱 문제
배포는 됐는데
옛 버전이 돔
① latest 태그 사용 ② 노드에 이미지가 캐시되어 재사용불변 태그 사용(d05). 급하면 imagePullPolicy: Always + kubectl rollout restart
Terminating 에서
안 끝남
① 앱이 SIGTERM 무시 ② finalizer 가 남아 있음 ③ 볼륨 언마운트 실패graceful shutdown 구현(d09). finalizer 는 kubectl patch ... -p '{"metadata":{"finalizers":null}}' — 최후 수단입니다
노드 NotReadykubelet 다운 · 디스크 풀 · 네트워크 플러그인 문제kubectl describe node 의 Conditions. 파드는 자동으로 다른 노드로 옮겨집니다(약 5분 후)

진단 명령 모음 — 순서대로

# ── 1단계: 전체 파악 ──────────────────────────
kubectl get pods -n prod -o wide
kubectl get events -n prod --sort-by=.lastTimestamp | tail -30
kubectl get deploy,svc,ing,hpa -n prod

# ── 2단계: 문제 파드 파고들기 ──────────────────
POD=$(kubectl -n prod get pod -l app=api -o name | head -1)
kubectl -n prod describe $POD                 # ★ Events 섹션을 끝까지 읽습니다
kubectl -n prod logs $POD --previous          # ★ 죽기 직전 로그
kubectl -n prod logs $POD -c sidecar          # 사이드카가 있으면 컨테이너 지정
kubectl -n prod logs -l app=api --since=10m --prefix   # 여러 파드 한 번에

# 종료 코드·재시작 이유 한눈에 보기
kubectl -n prod get pod -o custom-columns=NAME:.metadata.name,REASON:.status.containerStatuses[0].lastState.terminated.reason,CODE:.status.containerStatuses[0].lastState.terminated.exitCode

# ── 3단계: 안에서 확인 ────────────────────────
kubectl -n prod exec -it $POD -- sh
kubectl -n prod exec $POD -- env | sort       # 환경 변수가 제대로 들어갔나
kubectl -n prod exec $POD -- ls -l /etc/app   # 마운트가 됐나

# 셸이 없는 이미지(distroless)는 임시 디버그 컨테이너를 붙입니다
kubectl -n prod debug $POD -it --image=nicolaka/netshoot --target=api

# ── 4단계: 네트워크 확인 ──────────────────────
kubectl -n prod get endpointslice -l kubernetes.io/service-name=api
kubectl -n prod run net --rm -it --image=nicolaka/netshoot --restart=Never -- bash
#   안에서:
#   nslookup api.prod.svc.cluster.local        # DNS 가 도나
#   curl -v http://api.prod.svc/healthz/ready  # 서비스로 붙나
#   curl -v http://10.244.1.5:3000/healthz     # 파드 IP 로 직접 붙나

# ── 5단계: 리소스·노드 ────────────────────────
kubectl top pods -n prod --sort-by=memory
kubectl top nodes
kubectl describe node NODE | grep -A10 'Allocated resources'
DNS 문제 판별 3단 논법. ① 파드 IP 로 직접 curl 이 되면 앱은 정상입니다. ② Service 이름으로 안 되면 DNS 또는 엔드포인트 문제입니다. ③ nslookup 이 실패하면 CoreDNS 를 봅니다 (kubectl -n kube-system logs -l k8s-app=kube-dns). 참고로 클러스터 안 정식 이름은 서비스이름.네임스페이스.svc.cluster.local 이고, 같은 네임스페이스면 서비스 이름만으로 붙습니다.
"일단 재시작"의 유혹. kubectl delete pod 로 문제가 사라지는 경우가 많지만 원인은 그대로 남아 다시 옵니다. 급할 때 재시작으로 서비스를 살리는 것은 맞지만, 죽기 직전 로그(--previous)와 describe 출력을 먼저 저장하고 재시작하세요. 파드를 지우면 그 증거가 함께 사라집니다.

실전 배포 체크리스트 — 운영에 올리기 전

이미지 · 컨테이너

  • 불변 태그(SemVer 또는 SHA) — latest 아님
  • 멀티스테이지 · slim/distroless 베이스
  • 비루트 실행 + readOnlyRootFilesystem
  • .dockerignore 에 .env·.git
  • ENTRYPOINT 가 exec(배열) 형식
  • 취약점 스캔 CRITICAL 0

워크로드 · 안정성

  • requests 를 실측해서 지정, 메모리는 limits 와 동일
  • readiness · liveness · startup 세 프로브
  • maxUnavailable: 0 + preStop 지연
  • 앱의 SIGTERM graceful shutdown 구현
  • replicas ≥ 2 + PDB + 노드/존 분산
  • HPA 의 min 은 피크 직전, max 는 비용 상한

설정 · 보안

  • 비밀은 이미지·git 에 없음(외부 시크릿 참조)
  • ServiceAccount 최소 권한(RBAC), 기본 SA 토큰 자동 마운트 끄기
  • NetworkPolicy 로 필요한 통신만 허용
  • Ingress HTTPS 강제 + cert-manager 자동 갱신(🔐 HTTPS 탭)
  • 네임스페이스별 ResourceQuota · LimitRange

운영 준비

  • 로그가 JSON + stdout, 수집기에 들어옴
  • 알림 3개 이상(5xx · 지연 · CrashLoop)과 런북 링크
  • 롤백 절차를 실제로 한 번 해 봤음
  • PVC·DB 백업과 복원 테스트 완료
  • etcd 백업(자체 운영 시) 또는 관리형 클러스터 사용
  • 노드 업그레이드 시 drain 이 PDB 로 안전한지 확인
다음에 볼 것. ① 트래픽이 더 커지면 📈 대규모 트래픽 탭(캐시 계층·큐·규모별 아키텍처) ② 서비스를 쪼갤 때는 🧱 백엔드·MSA 탭(게이트웨이·사가·서킷브레이커) ③ 이벤트 파이프라인은 📨 Kafka 탭 ④ Ingress 안쪽의 Nginx 설정 자체는 🟩 Nginx 탭과 🛡️ 보안·운영 o11 이 그대로 적용됩니다 — 쿠버네티스를 쓴다고 웹서버 지식이 필요 없어지지 않습니다.
정리 — 장애 대응은 순서 · 증거 보존 · 증상 사전 셋입니다. 명령을 외우기보다 get → describe → logs --previous → endpoints 흐름을 몸에 붙이세요.
OBSERVABILITY · PROMETHEUS 3.x · GRAFANA 12 · LOKI · TEMPO · OPENTELEMETRY · SLO · 2026 기준

장애를
사용자보다
먼저 아는 법

서비스가 죽었다는 사실을 고객센터 전화로 알게 되는 회사와, 알림이 먼저 울리고 5분 만에 원인 후보를 셋으로 줄이는 회사의 차이는 실력이 아니라 붙여 둔 체계입니다. 이 탭은 그 체계를 순서대로 세웁니다 — 메트릭 · 로그 · 트레이스 · 프로파일이 각각 무엇에 답하고 무엇에 답하지 못하는지, Prometheus 로 무엇을 재고 PromQL 로 어떻게 묻는지, 알림을 몇 개나 둬야 사람이 반응하는지, SLO 와 에러 예산으로 배포 속도를 어떻게 조절하는지까지. 이 탭을 마치면 빈 서버에 관측 스택 한 벌을 직접 올리고, 알림이 울린 뒤 무엇을 순서대로 볼지 말할 수 있게 됩니다.

13SECTIONS
3+1신호(메트릭·로그·추적·프로파일)
5분원인 좁히기 목표
100%복붙해서 도는 설정
OBS 01

모니터링과 관측 가능성 — 무엇이 다른가

둘은 같은 말이 아닙니다. 모니터링은 "미리 정해 둔 질문에 답하는 화면"이고, 관측 가능성은 "미리 생각 못 한 질문을 나중에 던질 수 있는 상태"입니다. 이 구분을 못 하면 대시보드는 60장인데 정작 장애 때 볼 화면이 없는 상황이 됩니다.

세 신호는 서로를 대체하지 못합니다 — 답하는 질문이 다릅니다 ① 메트릭 — 숫자 언제 · 얼마나 이상한가 싸다 · 몇 달 보관 가능 한계 — 왜 그런지 모름 먼저 붙입니다 ② 로그 — 사건 기록 그때 무슨 일이 있었나 정확하다 · 양이 곧 비용 한계 — 전체 그림이 없음 두 번째로 붙입니다 ③ 트레이스 — 경로 어느 구간이 느린가 요청 한 건을 끝까지 한계 — 샘플링돼 일부만 서비스가 셋 넘으면 실제 장애 대응은 이 넷을 순서대로 타고 내려가면서 범위를 좁히는 일입니다 ① 알림 뭔가 아프다 ② 대시보드 어디가 아픈가 ③ 로그 무슨 일이 났나 ④ 트레이스 어느 구간인가 ⑤ 프로파일 어느 코드인가 모니터링 = 미리 정한 질문에 답하는 화면 · 관측 가능성 = 생각 못 한 질문을 나중에 던질 수 있는 상태 "5xx 가 늘었나"는 모니터링 · "5xx 중 결제 API 만, 특정 버전 파드에서, 1.2초 넘는 것만"은 관측 가능성 차이를 만드는 것은 도구가 아니라 데이터에 붙은 차원(라벨)과 요청을 잇는 trace_id 입니다 비용 감각 — 메트릭 1 : 트레이스 10 : 로그 100 (같은 트래픽에서 대략의 저장 비용비)
외울 한 문장 — 메트릭으로 알아채고, 로그로 확인하고, 트레이스로 좁히고, 프로파일로 짚습니다.

"모른다"의 네 칸 — 왜 대시보드만으로 부족한가

안다모른다
물어봤다대시보드에 있음
= 모니터링
지표는 있는데
값이 이상함
안 물어봤다남이 만든 패널에
우연히 보임
여기가 진짜 장애
= 관측 가능성의 영역
실제로 아픈 장애는 늘 오른쪽 아래 칸에서 옵니다 — "특정 통신사 사용자만", "장바구니에 상품이 30개 넘는 요청만", "새 버전이 뜬 파드에서만". 미리 패널을 만들어 둘 수 없는 조건들입니다. 그래서 필요한 것이 "지금 즉석에서 잘라 볼 수 있는 데이터"입니다.

즉석에서 자를 수 있다는 것 — 예

# 미리 만들어 둔 패널: "전체 5xx 비율"
sum(rate(http_requests_total{status=~"5.."}[5m]))
  / sum(rate(http_requests_total[5m]))

# 장애 중에 즉석에서 던지는 질문들 — 라벨이 있어야 됩니다
#  ① 어느 엔드포인트인가
sum by (route) (rate(http_requests_total{status=~"5.."}[5m]))

#  ② 어느 버전에서 시작됐나 (배포가 원인인지 판별)
sum by (version) (rate(http_requests_total{status=~"5.."}[5m]))

#  ③ 특정 존/노드에만 몰렸나
topk(5, sum by (node) (rate(http_requests_total{status=~"5.."}[5m])))

# 라벨에 route·version·zone 이 없었다면
#   → 위 세 질문은 "코드를 고치고 배포한 뒤에야" 답할 수 있습니다
관측 가능성은 나중에 살 수 없습니다 — 장애가 난 순간에 없는 라벨은 그 장애에서 영원히 못 씁니다. 그렇다고 라벨을 무한정 붙이면 카디널리티 폭발로 저장소가 죽습니다(y02). 이 줄타기가 관측 설계의 본체입니다.

무엇부터 붙이나 — 순서가 정해져 있습니다 🟢 기초

순서붙이는 것이걸 붙이면 생기는 능력
0단계외부에서 보는 감시
blackbox_exporter · 업타임 서비스
서버가 통째로 죽어도 알림이 옵니다. 서버 안의 감시는 서버와 같이 죽습니다. 하루면 붙고 효과가 가장 큽니다
1단계구조화 로그 + 중앙 수집
JSON 로깅 → Loki
서버·컨테이너가 죽어도 로그가 남습니다. 여러 대를 한 번에 검색합니다 (y06)
2단계메트릭 + 알림 3~5개
Prometheus · Alertmanager
"사용자보다 먼저 아는" 상태. 여기까지가 최소 방어선입니다 (y03·y09)
3단계대시보드 한 장
서비스별 RED + 자원
배포 후 5분간 볼 화면이 생깁니다. 배포 자신감이 여기서 나옵니다 (y05)
4단계분산 추적
OpenTelemetry → Tempo
서비스 간 호출이 얽히기 시작할 때. 로그 ↔ 추적 이동이 열립니다 (y07·y08)
5단계SLO · 에러 예산"얼마나 아파야 알림인가"에 근거가 생깁니다. 알림 개수가 줄어듭니다 (y10)
그다음지속 프로파일링 · eBPFCPU·메모리를 코드 줄 단위로 (y11)
순서를 어기면 생기는 일 — 실제로 흔합니다. 추적부터 붙이는 팀이 있습니다. 멋있어 보이고 데모가 잘 되기 때문입니다. 그런데 알림이 없으니 장애를 여전히 고객이 먼저 알고, 막상 열어 본 추적은 샘플링돼서 문제의 그 요청이 없습니다. 0~2단계는 며칠이면 되고 효과의 80%를 냅니다. 화려한 것은 그다음입니다.

관측 비용이라는 현실 — 공짜가 아닙니다 🟡 중급

비용이 어디서 나오나

항목비용을 키우는 것
메트릭활성 시계열 수(라벨 조합 수). 스크레이프 주기가 아니라 차원이 지배합니다
로그바이트 수. DEBUG 를 켠 채 두면 하루에 수백 GB 가 됩니다
트레이스스팬 수 × 샘플링율. 100% 저장은 거의 항상 실패합니다
쿼리넓은 기간 × 높은 카디널리티. 대시보드 자동 새로고침이 저장소를 때립니다
사람가장 비쌉니다 — 쓸모없는 알림에 깨어난 시간
관측 비용의 상한선을 정하세요 — 실무 감각으로 인프라 비용의 5~15% 가 흔한 구간입니다. 이걸 넘어가기 시작하면 보관 기간·샘플링·라벨을 다시 봐야 한다는 신호입니다.

먼저 줄여야 할 것 · 마지막에 줄일 것

먼저 줄입니다
· DEBUG 로그 — 운영은 INFO 이상
· 헬스체크·정적 파일 액세스 로그
· 아무도 안 보는 대시보드의 자동 새로고침
· 고카디널리티 라벨(user_id · 요청 URL 원문)
· 로그 보관 기간 — 30일이면 대개 충분
여기까지로 보통 절반이 빠집니다
마지막까지 지킵니다
· 에러 로그 — 100% 남깁니다
· 느린 요청·에러 요청의 트레이스
→ 테일 샘플링으로 이것만 100% (y07)
· SLI 메트릭 — 에러 예산의 근거
· 알림에 쓰이는 시계열
잘라 놓고 나중에 "그때 데이터가 없네"가 최악
☸️ 쿠버네티스 위에서라면 — 수집기를 DaemonSet 으로 배치하고 ServiceMonitor 로 스크레이프 대상을 등록하는 쿠버네티스 고유의 붙이는 방법은 ☸️ 쿠버네티스 d11 에 있습니다. 이 탭은 플랫폼과 무관한 관측 그 자체(무엇을 재고, 어떻게 묻고, 언제 울리게 하는가)를 다룹니다. 기본 서버 운영 관점의 로그 로테이션·상태 페이지는 🛡️ 보안·운영 o07·o08 을 보세요.
정리 — 관측은 도구 목록이 아니라 순서입니다. 외부 감시 → 구조화 로그 → 메트릭·알림 → 대시보드 → 추적 → SLO. 세 신호는 각각 "얼마나 · 무슨 일이 · 어디서" 에 답하고, 셋 다 있어야 5분 안에 좁혀집니다. 그리고 관측은 공짜가 아니라서, 무엇을 안 남길지를 정하는 것도 설계입니다.
OBS 02

메트릭의 기본 — 네 가지 타입과 카디널리티

메트릭은 "숫자에 이름과 꼬리표를 붙여 시간 순서로 쌓은 것"입니다. 타입을 잘못 고르면 계산이 틀리고, 꼬리표를 잘못 붙이면 저장소가 죽습니다. 이 두 가지가 메트릭의 전부라고 해도 됩니다.

타입 네 가지 — 언제 무엇을

타입성질예
Counter증가만 합니다(재시작 시 0으로 리셋). 값 자체가 아니라 증가 속도를 봅니다http_requests_total
Gauge올라갔다 내려갔다 하는 현재 값go_goroutines
queue_depth
Histogram값을 구간(버킷)별로 세어 둡니다. 나중에 분위수를 계산 가능..._duration_seconds_bucket
Summary분위수를 클라이언트가 미리 계산해 보냅니다..._duration{quantile="0.9"}
Histogram 과 Summary 중 무엇을? — 거의 항상 Histogram 입니다. Summary 의 분위수는 인스턴스별로 이미 계산된 값이라 여러 서버의 p99 를 합칠 수 없습니다(평균 내면 틀린 값입니다). Histogram 은 버킷 카운터라서 서버 전체를 더한 뒤 분위수를 뽑을 수 있습니다. 대신 버킷 경계를 미리 잘 잡아야 합니다.

노출되는 실제 모양 — /metrics

# HELP http_requests_total 처리한 HTTP 요청 수
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/orders",status="200"} 84213
http_requests_total{method="GET",route="/orders",status="500"} 37
http_requests_total{method="POST",route="/orders",status="201"} 9120

# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 5.28482304e+08

# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{route="/orders",le="0.05"} 71204
http_request_duration_seconds_bucket{route="/orders",le="0.1"}  80112
http_request_duration_seconds_bucket{route="/orders",le="0.5"}  83990
http_request_duration_seconds_bucket{route="/orders",le="1"}    84180
http_request_duration_seconds_bucket{route="/orders",le="+Inf"} 84250
http_request_duration_seconds_sum{route="/orders"}   4021.7
http_request_duration_seconds_count{route="/orders"} 84250
버킷은 누적입니다 — le="0.1" 은 "0.1초 이하인 요청의 누적 개수"입니다. 그래서 le 가 커질수록 값이 커지고, +Inf 는 _count 와 같습니다. 이 누적 구조 덕분에 분위수 계산이 가능해집니다(y04).
시계열 개수 = 라벨 값 가짓수의 곱 — 더하기가 아니라 곱하기입니다 감당 가능 — 라벨 3개 method 5가지 × route 40가지 × status 8가지 = 1,600 시계열 · 메모리 약 5MB 이 정도면 노트북에서도 돕니다 "어느 경로의 어느 상태 코드"까지 즉석에서 잘립니다 폭발 — 라벨 하나를 더했을 뿐 위 조합 × user_id 100,000가지 = 160,000,000 시계열 · 메모리 수백 GB Prometheus 가 OOM 으로 죽습니다 그러면 장애가 났을 때 볼 것이 없어집니다 라벨로 붙여도 되는 것 / 절대 붙이면 안 되는 것 붙여도 되는 것 — 값이 유한하고 작음 service · route(패턴) · method · status · version env · region · zone · instance — 대개 수십 가지 이하 붙이면 안 되는 것 — 값이 사실상 무한 user_id · order_id · session_id · trace_id · IP 원본 URL(/orders/12345) · 에러 메시지 전문 · 타임스탬프 그것들은 로그(y06)와 트레이스(y07)에 넣습니다 — 거기서는 고카디널리티가 정상입니다
한 줄 규칙 — "이 라벨 값이 몇 가지나 되지?"를 붙이기 전에 세어 보세요. 100 이 넘으면 다시 생각합니다.

카디널리티가 이미 터졌을 때 — 범인 찾기

# ① 시계열이 많은 메트릭 이름 순위 (Prometheus 자기 지표)
topk(10, count by (__name__)({__name__=~".+"}))

# ② 특정 메트릭에서 어느 라벨이 범인인가
count(count by (user_id) (http_requests_total))     # 값 가짓수
count(count by (route)   (http_requests_total))

# ③ 전체 활성 시계열 추이 — 이 그래프가 우상향이면 경보
prometheus_tsdb_head_series

# ④ 스크레이프 대상별로 몇 개를 뱉는지
topk(10, scrape_samples_scraped)
# Prometheus 3.x 내장 TSDB 분석 — 상위 카디널리티를 바로 보여 줍니다
promtool tsdb analyze /var/lib/prometheus/data

# 웹 UI 로도 볼 수 있습니다
#   Status → TSDB Status → "Top 10 label names by cardinality"
사고를 미리 막는 안전장치 — 스크레이프 설정에 sample_limit 과 label_limit 을 걸어 두면 누가 실수로 user_id 라벨을 넣어도 그 대상만 실패하고 Prometheus 전체는 살아남습니다. 이건 반드시 켜 두세요.

앱 코드에서 — 라벨을 안전하게 붙이기

// Java · Micrometer (Spring Boot 3.x 기본)
// ❌ 나쁜 예 — 경로를 원본 그대로 라벨에
Counter.builder("http_requests_total")
       .tag("path", request.getRequestURI())   // /orders/12345 …무한
       .register(registry).increment();

// ✅ 좋은 예 — 라우트 "패턴"으로 정규화
Counter.builder("http_requests_total")
       .tag("route", "/orders/{id}")           // 값이 유한해집니다
       .tag("method", request.getMethod())
       .tag("status", String.valueOf(status))
       .register(registry).increment();

// 히스토그램 — 버킷 경계를 SLO 에 맞춰 직접 지정 ★
Timer.builder("http_request_duration_seconds")
     .tag("route", "/orders/{id}")
     .serviceLevelObjectives(                  // SLO 경계를 버킷으로
         Duration.ofMillis(100), Duration.ofMillis(300),
         Duration.ofMillis(500), Duration.ofSeconds(1),
         Duration.ofSeconds(3))
     .publishPercentileHistogram()             // 서버 합산 가능한 형태
     .register(registry)
     .record(elapsed);
버킷 경계는 나중에 못 바꿉니다(정확히는 바꾸면 과거와 비교가 깨집니다). 그래서 SLO 로 쓸 값(예: 300ms)을 반드시 경계에 포함시키세요. 경계가 0.1 / 1 / 10 뿐이면 "300ms 이하 비율"을 영원히 못 구합니다.

무엇을 재야 하나 — RED 와 USE 🟢 기초

RED — 요청을 받는 것(서비스·API)

글자지표PromQL 감각
Rate초당 요청 수sum(rate(reqs_total[5m]))
Errors실패 요청 수·비율rate(reqs_total{status=~"5.."}[5m])
Duration지연 분포(p50·p95·p99)histogram_quantile(...)

서비스가 몇 개든 이 셋만 서비스별로 갖춰도 장애의 8할은 위치를 찾습니다. "어느 서비스가 아픈가"에 답하는 최소 세트입니다.

USE — 자원을 제공하는 것(CPU·디스크·큐·풀)

글자지표예
Utilization바쁜 시간 비율CPU 사용률 · 커넥션 풀 점유율
Saturation대기 줄의 길이런큐 · 풀 대기 수 · 컨슈머 랙
Errors자원 차원의 실패디스크 에러 · 커넥션 획득 타임아웃
포화(Saturation)가 진짜 선행 지표입니다. CPU 사용률은 100%에서 더 안 올라가지만 대기 줄은 계속 길어집니다. "CPU 100%"보다 "커넥션 풀 대기 42건" 이 훨씬 먼저, 훨씬 정확하게 아픔을 알려 줍니다.
계층반드시 재는 것 (최소 세트)
앱 / APIRED 3종 · route 별 · 인플라이트 요청 수 · 스레드/고루틴 수 · GC 정지 시간
DB 클라이언트★ 커넥션 풀 활성/유휴/대기 · 획득 대기 시간 · 쿼리 시간 히스토그램 · 타임아웃 수
캐시히트율 · 축출(eviction) 수 · 메모리 사용량 · 연결 실패
큐 · 스트림랙(밀린 건수) · 소비 속도 · 처리 실패·재시도 수 · DLQ 유입
외부 호출대상별 성공률 · 지연 · 타임아웃 · 서킷브레이커 상태 (🧱 MSA 참고)
호스트CPU(사용률+런큐) · 메모리(가용) · 디스크(사용률+I/O 대기) · 네트워크 · 파일 디스크립터
비즈니스★ 분당 주문 수 · 결제 성공률 — 기술 지표가 다 정상인데 매출만 0인 장애를 잡습니다
이름 짓기 규칙(Prometheus 관례) — <도메인>_<대상>_<단위> 에 카운터는 _total 을 붙입니다. 단위는 기본 단위로 통일합니다 — 초(seconds) · 바이트(bytes). _ms·_mb 로 재면 대시보드마다 단위 환산 코드가 붙고, 언젠가 누군가 곱하기를 빼먹습니다. OpenTelemetry 의 시맨틱 규약도 같은 원칙입니다(y08).
정리 — 타입은 Counter(속도로 본다) · Gauge(현재값) · Histogram(분포) 셋이면 충분하고 Summary 는 대개 피합니다. 라벨은 값 가짓수를 세어 보고 붙이며, ID 류는 로그·트레이스로 보냅니다. 무엇을 잴지는 받는 쪽은 RED, 주는 쪽은 USE — 여기에 비즈니스 지표 하나를 얹으면 최소 세트가 완성됩니다.
OBS 03

Prometheus — 긁어 오는 방식의 메트릭 저장소

Prometheus 는 앱이 보내 주기를 기다리지 않습니다. 스스로 찾아가서 긁어 옵니다(pull). 이 한 가지 선택이 설정 방식·장애 감지·확장 전략을 전부 결정합니다. 2026년 기준 사실상 표준이고, 클라우드 관측 서비스들도 대부분 이 데이터 모델을 그대로 씁니다.

Prometheus 는 찾아가서 긁어 옵니다(pull) — 앱은 /metrics 한 경로만 열어 두면 끝입니다 서비스 디스커버리 kubernetes · consul static_configs · file_sd app:8080/metrics node-exporter:9100 postgres-exporter:9187 nginx-exporter:9113 Prometheus 3.x 15초마다 GET /metrics 로컬 TSDB 에 압축 저장 보존 기본 15일 · 규칙 평가 알림 규칙 → Alertmanager 대상이 안 열리면 up=0 Alertmanager 라우팅 · 침묵 · 억제 Grafana PromQL 로 대시보드 remote write Thanos · Mimir 장기 보관 · 전역 질의 pull 이라서 얻는 것 — 대상이 응답을 안 하면 그 자체가 신호(up=0)가 되고, 설정이 한곳에 모입니다 pull 이 어려운 것 — 배치 작업처럼 잠깐 살다 죽는 프로세스. 그때만 Pushgateway 를 씁니다
Prometheus 한 대의 일 — 긁고 · 저장하고 · 규칙을 평가해 알림을 던지는 것. 대시보드도 장기 보관도 남의 일입니다.

최소 설정 — prometheus.yml

global:
  scrape_interval: 15s          # 기본 스크레이프 주기
  scrape_timeout: 10s           # interval 보다 작아야 합니다
  evaluation_interval: 15s      # 규칙(알림) 평가 주기
  external_labels:              # ★ remote write·연합에서 출처를 구분
    cluster: prod-a
    region: ap-northeast-2

rule_files:
  - /etc/prometheus/rules/*.yml

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']

scrape_configs:
  - job_name: node
    static_configs:
      - targets: ['node-exporter:9100']
        labels: { env: prod }

  - job_name: app
    metrics_path: /actuator/prometheus   # Spring Boot 기본 경로
    scrape_interval: 10s                 # job 별로 덮어쓸 수 있습니다
    sample_limit: 20000                  # ★ 카디널리티 사고 방지 (y02)
    label_limit: 30
    static_configs:
      - targets: ['app-1:8080','app-2:8080']
external_labels 를 처음부터 넣으세요. 나중에 클러스터가 둘이 되거나 장기 저장소로 모을 때 "이 시계열이 어느 클러스터 것인지" 를 구분할 수단이 이것뿐입니다. 나중에 붙이면 그 시점 앞뒤의 데이터가 다른 시계열로 갈라집니다.

익스포터 — 남의 것을 재는 어댑터

익스포터포트 · 무엇을 주나
node_exporter9100 · CPU·메모리·디스크·네트워크·파일시스템. 무조건 첫 번째
nginx-prometheus-exporter9113 · 연결 수·요청 수 (🟩 Nginx 상태 페이지 연결)
postgres_exporter9187 · 커넥션·잠금·복제 지연·느린 쿼리 수
mysqld_exporter9104 · 스레드·InnoDB·복제 지연
redis_exporter9121 · 히트율·축출·메모리
blackbox_exporter★ 9115 · 바깥에서 본 HTTP·TCP·DNS·인증서 만료
kafka_exporter9308 · 컨슈머 랙 (📨 Kafka k03 의 그 랙)
cadvisor8080 · 컨테이너별 CPU·메모리·스로틀
앱은 익스포터가 필요 없습니다. 라이브러리를 넣으면 앱이 직접 /metrics 를 엽니다 — Java Micrometer(Spring Boot 는 actuator 의존성만 추가), Go client_golang, Python prometheus_client, Node prom-client. 익스포터는 내가 코드를 못 고치는 것(DB·Nginx·Redis)에만 씁니다.

서비스 디스커버리 — 대상을 손으로 적지 않습니다 🟡 중급

파일 기반 · 클라우드 기반

scrape_configs:
  # ① file_sd — 배포 스크립트가 JSON 파일만 갱신하면 됩니다(재시작 불필요)
  - job_name: app
    file_sd_configs:
      - files: ['/etc/prometheus/targets/*.json']
        refresh_interval: 30s

  # ② EC2 자동 발견 — 태그로 거르고 태그를 라벨로 승격
  - job_name: ec2
    ec2_sd_configs:
      - region: ap-northeast-2
        port: 9100
    relabel_configs:
      - source_labels: [__meta_ec2_tag_Env]
        regex: prod
        action: keep                       # prod 태그만 긁습니다
      - source_labels: [__meta_ec2_tag_Name]
        target_label: instance             # 태그를 라벨 이름으로

  # ③ 도커 컨테이너 자동 발견
  - job_name: docker
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
    relabel_configs:
      - source_labels: [__meta_docker_container_label_metrics_port]
        regex: (.+)
        action: keep
// /etc/prometheus/targets/app.json — 배포 때 이 파일만 갱신
[
  { "targets": ["app-1:8080", "app-2:8080"],
    "labels": { "job": "app", "env": "prod", "version": "1.4.2" } }
]

relabel — 긁기 전에 대상과 라벨을 손보는 단계

relabel_configs:            # ★ 긁기 "전" — 대상 고르기·주소 바꾸기
  - source_labels: [__address__]
    regex: '(.*):\d+'
    target_label: host
    replacement: '$1'
  - action: labeldrop        # 지저분한 메타 라벨 제거
    regex: '__meta_.*'

metric_relabel_configs:     # ★ 긁은 "후" — 저장 전에 메트릭을 거름
  - source_labels: [__name__]
    regex: 'go_gc_duration_seconds.*'
    action: drop             # 안 볼 메트릭은 아예 저장하지 않습니다
  - source_labels: [__name__, le]
    regex: 'http_request_duration_seconds_bucket;(0\.001|0\.002)'
    action: drop             # 너무 촘촘한 버킷만 버려 용량 절감
둘을 헷갈리면 아무 일도 안 일어납니다. relabel_configs 는 스크레이프 대상에 적용되고, metric_relabel_configs 는 긁어 온 샘플에 적용됩니다. "특정 메트릭을 버리고 싶다"면 후자입니다. 카디널리티가 터졌을 때 코드 배포 없이 즉시 막을 수 있는 유일한 수단이라 운영에서 아주 자주 씁니다.

저장 · 보존 · 확장 — 한 대로는 언젠가 부족합니다 🔴 실무

로컬 저장소의 성질

# 실행 플래그 (도커라면 command 로)
#   retention.time  기간 기준 보존
#   retention.size  용량 기준 (둘 중 먼저 걸리는 쪽)
#   enable-lifecycle  POST /-/reload 로 무중단 설정 갱신
#   (줄 끝 \ 뒤에 주석을 붙이면 줄 잇기가 끊깁니다 — 주석은 위로)
prometheus \
  --config.file=/etc/prometheus/prometheus.yml \
  --storage.tsdb.path=/prometheus \
  --storage.tsdb.retention.time=15d \
  --storage.tsdb.retention.size=50GB \
  --web.enable-lifecycle

# 설정을 고친 뒤 — 재시작하지 않고 반영
promtool check config /etc/prometheus/prometheus.yml
curl -X POST http://localhost:9090/-/reload

# 용량 감각 — 샘플 하나가 압축 후 약 1~2바이트
#   활성 시계열 100만 × 15초 주기 × 15일
#   ≈ 100만 × 4회/분 × 60 × 24 × 15 × 1.5B ≈ 130GB
#   → "시계열 수 × 보존 기간" 이 전부입니다 (y02 의 카디널리티!)
Prometheus 는 메모리를 시계열 수에 비례해서 먹습니다. 최근 2시간치는 메모리(head block) 에 있다가 디스크로 내려갑니다. 경험칙으로 활성 시계열 100만 ≈ 8~16GB RAM 을 잡습니다. OOM 으로 죽으면 재시작하며 WAL 재생에 몇 분이 걸리고, 그동안 눈이 멉니다.

장기 보관 · 고가용성

# remote write — 긁은 것을 그대로 장기 저장소에 복제
remote_write:
  - url: http://mimir:8080/api/v1/push
    queue_config:
      capacity: 10000
      max_shards: 50
      max_samples_per_send: 2000
    write_relabel_configs:          # ★ 보낼 것만 골라 비용 절감
      - source_labels: [__name__]
        regex: '(up|http_.*|node_.*|slo_.*)'
        action: keep

# 고가용성 — 똑같은 설정의 Prometheus 두 대를 나란히 돌립니다
#   · 서로를 모릅니다(클러스터링 없음). 둘 다 같은 대상을 긁습니다
#   · 중복 제거는 앞단(Thanos Query / Mimir)이 external_labels 로 처리
#   · Alertmanager 는 3대를 클러스터로 묶어 중복 알림을 합칩니다
규모구성
서버 몇 대Prometheus 1대 + 로컬 15일. 이걸로 충분합니다
팀 여럿Prometheus 2대(HA) + Thanos 사이드카 → 오브젝트 스토리지
대규모Mimir(또는 Cortex) — 수평 확장·멀티테넌트·장기 보관
사람이 적다★ 관리형(Grafana Cloud·AMP·Datadog). 운영 인건비가 서버비보다 비쌉니다
Prometheus 3.x 에서 달라진 것들. UI 가 새로 쓰였고, OTLP 수신이 정식 기능이 되어 OpenTelemetry 로 보낸 메트릭을 익스포터 없이 바로 받을 수 있습니다(y08). 네이티브 히스토그램(버킷을 지수적으로 자동 생성)도 실용 단계에 들어와서, 버킷 경계를 손으로 고르는 부담이 줄어드는 방향입니다. 다만 기본은 여전히 pull + 고정 버킷이니 이 절의 내용이 먼저입니다.
정리 — Prometheus 는 서비스 디스커버리로 대상을 찾고 · 15초마다 긁고 · 로컬 TSDB 에 15일 보관하고 · 규칙을 평가해 Alertmanager 로 던집니다. 앱은 라이브러리로 /metrics 를 열고, 못 고치는 것은 익스포터를 붙입니다. 비용과 안정성은 전부 활성 시계열 수에 달려 있으니 sample_limit 과 metric_relabel_configs 를 안전벨트로 걸어 두세요.
OBS 04

PromQL — 질문을 쿼리로 바꾸는 법

PromQL 은 SQL 과 닮은 데가 거의 없습니다. "시계열 여러 개를 한꺼번에 다루는 계산기" 에 가깝습니다. 벡터 두 종류와 rate 하나만 제대로 이해하면 나머지는 조합입니다.

카운터는 값이 아니라 "증가 속도"로 봅니다 — rate() 가 재시작까지 알아서 처리합니다 ① 카운터 원본 — http_requests_total 앱 재시작 → 0 그래프만 보면 값이 뚝 떨어집니다 누적값 자체는 아무 뜻이 없습니다 ② rate(...[5m]) — 초당 증가 속도 리셋을 보정해 속도는 끊기지 않습니다 이 값이 곧 "초당 요청 수"입니다 rate — 초당 평균 구간 전체를 평균해 매끈합니다 알림·대시보드의 기본값 increase — 총 증가량 rate × 구간 초 = 같은 값 "1시간에 몇 건" 을 물을 때 irate — 마지막 두 점 순간값이라 심하게 튑니다 알림에는 쓰지 마세요 구간은 스크레이프 주기의 4배 이상 — 15초 주기면 [1m] 이 최소, 알림에는 [5m] 을 씁니다
카운터를 다루는 규칙 하나 — 맨눈으로 보지 말고 항상 rate() 로 감싸세요.

벡터 두 종류 — 이것부터

# ① 인스턴트 벡터 — "지금 이 순간의 값" 시계열들의 모음
http_requests_total
http_requests_total{job="app", status="500"}
http_requests_total{route=~"/orders.*", status!~"2.."}
#   =  같음   !=  다름   =~  정규식 일치   !~  정규식 불일치

# ② 레인지 벡터 — "지난 5분간의 값들" (점 하나가 아니라 배열)
http_requests_total[5m]
#   레인지 벡터는 그대로 그릴 수 없습니다.
#   rate·increase·avg_over_time 같은 함수로 "숫자 하나"로 줄여야 합니다.

# 시간 이동 — 1주일 전 같은 시각과 비교
http_requests_total offset 1w
sum(rate(http_requests_total[5m] offset 1w))
가장 흔한 실수 — 그래프에 http_requests_total[5m] 를 넣고 "왜 안 나오지" 합니다. 레인지 벡터는 함수로 감싸야 그려집니다. 반대로 rate(http_requests_total) 처럼 구간을 빼먹어도 에러입니다. rate 는 항상 [구간] 과 짝입니다.

집계 — by 와 without

# 전부 합치기 — 라벨이 전부 사라집니다
sum(rate(http_requests_total[5m]))

# 특정 라벨만 남기고 합치기 ★ 가장 많이 씁니다
sum by (service, route) (rate(http_requests_total[5m]))

# 특정 라벨만 버리고 합치기 (인스턴스 구분만 없애고 싶을 때)
sum without (instance, pod) (rate(http_requests_total[5m]))

# 집계 함수들
sum / avg / min / max / count / stddev
topk(5, ...)      # 상위 5개 시계열
bottomk(5, ...)
count_values("v", ...)   # 값별 개수

# 순서를 주의 — rate 를 먼저, sum 을 나중에 ★
sum(rate(x[5m]))     # ✅ 올바름
rate(sum(x)[5m:])    # ❌ 카운터 리셋 보정이 깨집니다
왜 rate 가 먼저인가 — 인스턴스 하나가 재시작해 카운터가 0이 되면, 합계는 그냥 뚝 떨어진 것으로 보입니다. rate 는 시계열 하나하나에서 리셋을 판별하므로 개별 시계열 단계에서 먼저 적용해야 보정이 됩니다. "rate 안쪽, sum 바깥쪽" 으로 외우세요.
히스토그램 버킷에서 p95 를 뽑는 원리 — 누적 비율이 0.95 를 넘는 지점을 찾습니다 요청 84,250건의 누적 버킷 95% 지점 le ≤ 0.05 84.5% le ≤ 0.1 95.1% ← 여기서 넘습니다 le ≤ 0.5 99.7% le ≤ 1 99.9% le ≤ +Inf 100% (=_count) histogram_quantile(0.95, sum by (le) (rate(bucket[5m]))) 0.95 는 0.845 와 0.951 사이에 있으므로 p95 는 0.05 초와 0.1 초 사이 — 선형 보간으로 약 0.093초 버킷 경계가 성기면 분위수가 통째로 틀립니다 — 경계가 0.1 과 1 뿐이면 p99 는 "0.1~1 어디쯤" 그래서 SLO 로 쓸 값(예: 0.3초)은 반드시 버킷 경계에 넣어 둡니다 (y02·y10) 여러 서버의 p95 를 구할 때도 sum by (le) 로 버킷을 먼저 합칩니다 — 분위수를 평균 내면 틀린 값입니다
분위수는 저장된 값이 아니라 계산된 추정치입니다 — 정확도는 버킷 경계를 어떻게 잡았는가로 결정됩니다.

조인 — 서로 다른 메트릭을 엮기 🟡 중급

라벨이 같아야 계산이 됩니다

# 메모리 사용률 — 두 메트릭의 라벨 집합이 같으면 그냥 나눕니다
container_memory_working_set_bytes
  / container_spec_memory_limit_bytes

# 라벨이 다르면 어떤 라벨로 짝지을지 지정
sum by (pod) (rate(http_requests_total[5m]))
  / on (pod)
sum by (pod) (kube_pod_container_info)

# 일대다 — 왼쪽 여러 개에 오른쪽 하나를 붙일 때 group_left ★
sum by (pod, route) (rate(http_requests_total[5m]))
  * on (pod) group_left(label_version, label_team)
    kube_pod_labels{job="kube-state-metrics"}
#   → 결과에 label_version·label_team 라벨이 얹힙니다 (팀별로 묶어 볼 수 있게)
#   (kube_pod_labels 는 파드 라벨을 label_ 접두사로 붙이고,
#    kube-state-metrics 의 --metric-labels-allowlist 에 넣은 라벨만 내보냅니다)

# 무시할 라벨을 지정하는 방식
a / ignoring (instance) b
결과가 텅 비면 십중팔구 조인 실패입니다. PromQL 의 이항 연산은 양쪽 라벨 집합이 정확히 같은 것끼리만 짝지어집니다. 디버깅은 양쪽을 따로 실행해 라벨 이름을 눈으로 비교하는 것이 가장 빠릅니다. group_left 는 "왼쪽이 여럿" 이라는 뜻이라는 것만 기억하면 방향을 안 헷갈립니다.

자주 쓰는 쿼리 모음 — 복붙용

# 초당 요청 수 (서비스별)
sum by (service) (rate(http_requests_total[5m]))

# 에러율 (%) — 분모 0 이면 결과가 없어지니 알림에서는 주의
100 * sum(rate(http_requests_total{status=~"5.."}[5m]))
    / sum(rate(http_requests_total[5m]))

# p50 · p95 · p99 를 한 화면에
histogram_quantile(0.95,
  sum by (le, route) (rate(http_request_duration_seconds_bucket[5m])))

# 평균 응답 시간 (분위수보다 싸다 — 참고용)
rate(http_request_duration_seconds_sum[5m])
  / rate(http_request_duration_seconds_count[5m])

# CPU 사용률 (node_exporter · idle 의 여집합)
100 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100

# 메모리 여유 비율
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes

# 디스크가 4시간 안에 가득 찰 것 같은가 — 예측 ★
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0

# 대상이 죽었는가 · 얼마나 오래
up == 0
(time() - process_start_time_seconds) < 300   # 최근 5분 내 재시작

# 인증서 만료가 21일 이내 (blackbox_exporter)
(probe_ssl_earliest_cert_expiry - time()) / 86400 < 21

# 어제 같은 시각 대비 트래픽 급감 (배포 사고 탐지)
sum(rate(http_requests_total[10m]))
  / sum(rate(http_requests_total[10m] offset 1d)) < 0.5
predict_linear 는 알림의 격을 바꿉니다. "디스크 90% 초과" 알림은 이미 늦었거나(밤새 90%에서 멈춰 있음) 너무 이릅니다. "이 추세면 4시간 뒤 가득 찬다" 는 알림은 사람이 대응할 시간이 있는 시점에 정확히 한 번 울립니다. 같은 방식으로 인증서 만료·쿼터 소진·에러 예산 소진(y10)을 예측형으로 바꿀 수 있습니다.
정리 — PromQL 의 8할은 이 한 줄 패턴입니다 — sum by (라벨) (rate(카운터[5m])). 카운터는 반드시 rate 로 감싸고, rate 를 안쪽에 sum 을 바깥쪽에 둡니다. 지연은 histogram_quantile(0.95, sum by (le) (rate(..._bucket[5m]))) 로 뽑고, 결과가 비면 라벨 조인부터 의심하세요.
OBS 05

Grafana — 대시보드는 적을수록 좋습니다

대시보드를 60장 만든 팀과 3장 만든 팀이 있으면, 장애 때 더 빨리 복구하는 쪽은 3장인 팀입니다. 화면은 정보를 담는 곳이 아니라 판단을 돕는 곳이기 때문입니다. 이 절은 무엇을 그리느냐보다 무엇을 안 그리느냐에 시간을 씁니다.

대시보드는 "많이"가 아니라 "세 층"으로 — 위에서 아래로 파고 내려갑니다 파고 내려가는 세 층 ① 개요 — 회사 전체 한 화면 서비스별 신호등 · 남은 에러 예산 · 진행 중 배포 상황실 모니터에 띄워 두는 화면입니다 ② 서비스 — RED 3종 요청량 · 에러율 · 지연 p50/p95/p99 + 배포 표시선 서비스마다 딱 하나. 배포 후 5분간 보는 화면 ③ 디버그 — 자원 · 의존성 CPU · 메모리 · GC · 커넥션 풀 · 큐 랙 · 외부 호출 원인을 좁힐 때만 엽니다 한 화면에 무엇을 두나 좋은 대시보드 스크롤 없이 한 화면 · 패널 9개 이하 패널마다 "이 값이 얼마면 문제"가 적혀 있음 배포 시각이 세로선으로 표시됨 클릭하면 그 조건의 로그·추적으로 이동 나쁜 대시보드 패널 40개 · 스크롤 세 번 축 단위가 제각각(ms 와 s 가 섞임) 평균만 보여 줌 — 소수의 심한 지연이 숨음 결국 장애 때 아무도 열지 않음 판단 기준 — "이 패널을 보고 무엇을 결정하나?"에 답할 수 없으면 그 패널은 지웁니다 대시보드 수는 늘어나기만 합니다. 분기마다 열람 수를 보고 0인 것을 지우는 규칙을 만드세요
대시보드의 목표는 아름다움이 아니라 "지금 배포해도 되는가"에 3초 안에 답하는 것입니다.

데이터 소스 — 코드로 붙입니다

# /etc/grafana/provisioning/datasources/ds.yml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    uid: prom                       # ★ 대시보드 JSON 이 이 uid 를 참조합니다
    url: http://prometheus:9090
    isDefault: true
    jsonData:
      timeInterval: 15s             # 스크레이프 주기 — $__rate_interval 계산에 씀

  - name: Loki
    type: loki
    uid: loki
    url: http://loki:3100
    jsonData:
      derivedFields:                # ★ 로그 → 추적 점프의 핵심
        - name: TraceID
          matcherRegex: '"trace_id":"(\w+)"'
          url: '$${__value.raw}'
          datasourceUid: tempo

  - name: Tempo
    type: tempo
    uid: tempo
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:               # 추적 → 로그 역방향 점프
        datasourceUid: loki
        filterByTraceID: true
      serviceMap:
        datasourceUid: prom         # 서비스 의존 관계 그래프
이 derivedFields 다섯 줄이 관측 스택의 값어치를 두 배로 만듭니다. 로그 한 줄에서 trace_id 를 찾아 클릭 한 번으로 그 요청의 전체 경로로 넘어갑니다. 반대로 느린 트레이스에서 그 요청의 로그만 볼 수도 있습니다. 붙이는 데 10분, 장애 때 아끼는 시간은 수십 분입니다.

변수(템플릿) — 대시보드 하나로 전부 커버

# 대시보드 변수 정의 (Settings → Variables)
#  ① 쿼리 변수 — 값 목록을 PromQL 로 뽑습니다
name : service
query: label_values(http_requests_total, service)
multi : true      # 여러 개 선택 허용
includeAll: true  # "All" 옵션

#  ② 연쇄 변수 — 앞 변수에 따라 목록이 좁혀집니다 ★
name : route
query: label_values(http_requests_total{service="$service"}, route)

#  ③ 상수·구간 변수
name : window   values: 1m,5m,15m,1h

# 패널 쿼리에서 사용
sum by (route) (
  rate(http_requests_total{service=~"$service", route=~"$route"}[$window])
)

# 내장 변수 — 외우면 좋은 것들
#   $__rate_interval  스크레이프 주기에 맞춰 안전한 rate 구간을 자동 계산 ★
#   $__interval       패널 폭에 맞춘 해상도
#   $__from / $__to   선택한 시간 범위(밀리초)
[5m] 을 하드코딩하지 말고 [$__rate_interval] 을 쓰세요. 시간 범위를 30일로 늘리면 데이터 포인트 간격이 스크레이프 주기보다 훨씬 커지는데, [5m] 은 그 간격보다 짧아져서 그래프에 구멍이 뚫립니다. $__rate_interval 은 이걸 자동으로 맞춰 줍니다.

패널 고르기 — 데이터 모양이 패널을 정합니다 🟢 기초

패널쓰는 자리주의
Time series거의 전부 — 추세를 보는 기본축 단위를 반드시 지정(초/바이트). 범례에 Last · Max 를 켜 두면 값 읽기가 쉬워집니다
Stat지금 값 하나 — 에러율·가용성·잔여 예산★ 임계값(Thresholds) 색을 반드시 설정. 색이 판단을 대신합니다
Gauge / Bar gauge상한이 뚜렷한 것 — 디스크·풀 점유율상한이 없는 값(요청 수)에 쓰면 의미가 없습니다
Table순위 — 느린 엔드포인트 top 10topk() 와 함께. 정렬 기본값을 지정해 두세요
Heatmap★ 지연 분포 — 히스토그램 버킷을 그대로p95 선 하나로는 안 보이는 이중 봉우리(캐시 히트/미스)가 드러납니다
LogsLoki 쿼리 결과를 같은 화면에대시보드 시간 범위를 따라가므로 그래프에서 드래그 → 그 구간 로그가 됩니다
State timelineup/down · 배포 상태 같은 이산 상태연속값에는 쓰지 않습니다
Pie chart거의 쓰지 마세요비율 비교는 사람이 각도로 잘 못 읽습니다. Bar gauge 가 낫습니다

배포 표시선 — 인과를 눈으로 잇습니다

// 패널 annotations — 배포 이벤트를 세로선으로
// "5분 전엔 없던 version 이 나타났다" = 배포 (값이 늘 1 이라 changes() 로는 안 잡힙니다)
{
  "annotations": {
    "list": [{
      "name": "deploys",
      "datasource": { "uid": "prom" },
      "enable": true,
      "iconColor": "rgba(255, 96, 96, 1)",
      "expr": "count by (service, version) (app_build_info) unless count by (service, version) (app_build_info offset 5m)",
      "titleFormat": "deploy {{version}}",
      "textFormat": "{{service}} → {{version}}"
    }]
  }
}
장애 원인의 절반 이상이 "방금 바뀐 것"입니다. 그래프 위에 배포 시각이 세로선으로 찍혀 있으면 "에러가 오른 시점이 배포 직후인가"를 말로 논쟁하지 않고 봅니다. 앱이 app_build_info{version="1.4.2"} 1 같은 게이지 하나만 노출하면 됩니다.

대시보드를 코드로 — 손으로 만들지 않습니다

# /etc/grafana/provisioning/dashboards/dash.yml
apiVersion: 1
providers:
  - name: repo
    # folder 는 비워 둡니다 — foldersFromFilesStructure 를 쓰면 하위 디렉터리 이름이 폴더가 됩니다
    type: file
    disableDeletion: true
    allowUiUpdates: false        # ★ UI 수정 금지 — 소스가 진실
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true
# 워크플로 — UI 에서 만들고 JSON 으로 내려받아 커밋
#   ① Grafana UI 에서 실험 → Share → Export → "Export for sharing externally"
#   ② dashboards/api-red.json 으로 저장 → git commit
#   ③ 배포하면 provisioning 이 읽어 갑니다

# 규모가 커지면 코드 생성기를 씁니다
#   · Grafonnet(Jsonnet) — 함수로 패널을 찍어 냅니다
#   · Grafana Foundation SDK — Go·TS·Python 로 대시보드 정의
#   · Terraform grafana provider — 폴더·권한·알림까지 함께

# 흔한 실수 — datasource 를 이름으로 박아 두면 환경마다 깨집니다
#   "datasource": {"type":"prometheus","uid":"${DS_PROM}"}  ← 변수로
"누가 대시보드를 지웠어요"는 실제로 자주 일어납니다. UI 에서만 만든 대시보드는 백업도 리뷰도 없습니다. JSON 을 저장소에 넣어 두면 변경이 PR 로 보이고, 환경(dev/prod)마다 같은 화면이 생깁니다.
Grafana 는 저장소가 아니라 "창"입니다. Prometheus·Loki·Tempo·DB·클라우드 로그를 같은 시간축 위에 얹는 것이 본체 기능입니다. 그래서 패널마다 데이터 소스를 섞어 한 화면에 두는 것이 가능하고, 그게 장애 때 창을 여러 개 띄우지 않게 해 줍니다. 알림도 Grafana 자체 알림 기능으로 만들 수 있지만, 규칙을 코드로 관리하려면 Prometheus 룰 파일이 여전히 편합니다(y09).
정리 — 대시보드는 개요 → 서비스(RED) → 디버그 세 층이면 충분합니다. 패널마다 임계값 색과 배포 표시선을 넣고, 클릭하면 로그·추적으로 넘어가게 연결하세요. 변수로 서비스별 화면을 하나로 합치고, JSON 을 저장소에 커밋합니다. 그리고 분기마다 아무도 안 여는 대시보드를 지우세요.
OBS 06

로그 — 사람이 읽는 글에서 기계가 읽는 데이터로

로그는 셋 중 가장 오래된 신호인데, 가장 자주 잘못 쓰입니다. 문장으로 쓴 로그는 grep 밖에 못 합니다. JSON 한 줄로 바꾸는 순간 "어제 오후 3시에 결제 실패한 요청 중 응답 2초 넘은 것"을 쿼리로 물을 수 있게 됩니다.

로그 파이프라인 — 앱은 stdout 에 JSON 한 줄만 뱉고 나머지는 수집기가 합니다 ① 앱 stdout 에 JSON 한 줄 파일을 직접 쓰지 않음 ② 수집기 Fluent Bit · Vector 노드마다 하나(에이전트) ③ 저장 Loki · Elasticsearch OpenSearch · S3 ④ 조회 Grafana Kibana 수집기가 대신 해 주는 일 다섯 가지 ① 파싱 — JSON 을 필드로 펼치고 시각을 정규화 ② 라벨링 — 호스트·서비스·환경을 붙임 ③ 마스킹 — 주민번호·카드번호·토큰을 가림 ④ 버퍼·재시도 ⑤ 여러 줄 스택트레이스 합치기 저장소 고르기 Loki — 라벨만 색인 · 본문은 압축 · 싸다 Elasticsearch — 전문 검색 · 집계 · 비싸다 대부분 Loki 로 충분합니다 Loki 도 라벨 카디널리티가 곧 비용입니다 뜨거운 구간 — 7~14일 빠른 검색 · 장애 대응은 거의 여기서 끝납니다 차가운 구간 — 90일~1년 오브젝트 스토리지로 · 감사·규정 대응용 비용의 90%는 "안 봐도 되는 로그를 남긴 것"에서 나옵니다 — 줄이는 것도 설계입니다
앱이 할 일은 하나뿐입니다 — stdout 에 JSON 한 줄. 회전·전송·보관은 전부 바깥의 일입니다.

문장 로그 → 구조화 로그

이러면 grep 밖에 못 합니다
2026-08-25 10:22:31 ERROR 주문 생성 실패
  (사용자 u-8812, 주문 o-99213) - 1842ms
  pg: deadlock detected
· "1842ms 넘는 것만" 을 못 물어봅니다
· 사용자별 집계 불가 · 포맷이 매번 다름
이러면 쿼리가 됩니다
{"ts":"2026-08-25T10:22:31.442Z","level":"error",
 "msg":"order create failed","service":"api",
 "env":"prod","version":"1.4.2","host":"api-2",
 "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
 "span_id":"00f067aa0ba902b7",
 "user_id":"u-8812","order_id":"o-99213",
 "route":"/orders","status":500,"duration_ms":1842,
 "err":"pg: deadlock detected"}
반드시 넣을 필드 8개 — ts(ISO8601 · UTC) · level · msg · service · env · version · trace_id · duration_ms. 여기에 도메인 식별자(user_id·order_id)를 더하면 고카디널리티가 오히려 자산이 됩니다 — 메트릭과 정반대입니다(y02).

언어별로 붙이기

// Java — logback + logstash-encoder (Spring Boot)
// logback-spring.xml
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
  <encoder class="net.logstash.logback.encoder.LogstashEncoder">
    <includeMdcKeyName>trace_id</includeMdcKeyName>
    <includeMdcKeyName>user_id</includeMdcKeyName>
  </encoder>
</appender>

// MDC 에 넣으면 그 스레드의 모든 로그에 자동으로 붙습니다 ★
MDC.put("user_id", userId);
try { ... } finally { MDC.clear(); }   // 반드시 정리 (스레드 풀 재사용!)
# Python 3.13 — structlog
import structlog
structlog.configure(processors=[
    structlog.contextvars.merge_contextvars,   # 비동기 안전한 컨텍스트
    structlog.processors.add_log_level,
    structlog.processors.TimeStamper(fmt="iso", utc=True),
    structlog.processors.JSONRenderer(),
])
log = structlog.get_logger()
structlog.contextvars.bind_contextvars(trace_id=tid, user_id=uid)
log.error("order create failed", order_id=oid, duration_ms=1842)
// Node 24 — pino (가장 빠른 JSON 로거)
import pino from 'pino';
const log = pino({ level: process.env.LOG_LEVEL ?? 'info',
                   base: { service: 'api', env: process.env.ENV } });
// 요청마다 자식 로거 — 모든 줄에 trace_id 가 붙습니다
const rlog = log.child({ trace_id: traceId, route: '/orders' });
rlog.error({ order_id, duration_ms: 1842, err: e.message }, 'order create failed');

로그 레벨 — 기준이 없으면 전부 INFO 가 됩니다 🟢 기초

레벨언제 쓰나 — 한 문장 기준운영에서
ERROR사람이 봐야 하는 일이 실패했다 — 요청이 실패했고 자동 복구가 안 됨100% 남깁니다. 알림의 재료
WARN지금은 괜찮지만 추세가 나쁘다 — 재시도 성공, 폴백 사용, 임계 근접남깁니다. 급증하면 신호
INFO비즈니스 사건 한 건 — 주문 생성, 결제 승인, 배포 시작요청당 1~3줄로 제한
DEBUG개발자가 흐름을 좇을 때 — 분기, 중간 값운영은 끕니다. 필요하면 한시적으로
TRACE바이트 단위까지 — 프로토콜 디버깅운영에서 켜면 사고입니다
"에러를 잡아서 로그만 찍고 넘어가기"가 로그를 쓸모없게 만듭니다. catch (e) { log.error(e); } 를 곳곳에 뿌리면 한 번의 실패가 스택 위로 올라가며 다섯 번 찍힙니다 — 알림 개수도 다섯 배가 됩니다. 규칙 하나: 에러는 처리하는 곳에서 딱 한 번 로깅합니다. 중간 계층은 맥락만 덧붙여 다시 던집니다. 그리고 스택트레이스는 반드시 한 필드에 넣으세요 — 여러 줄로 흩어지면 수집기가 다섯 건으로 셉니다.

요청 ID 전파 — 한 요청의 로그를 모으는 끈 🟡 중급

입구에서 만들고, 끝까지 들고 갑니다

// 서블릿 필터 — 요청 하나에 ID 하나
@Component
public class TraceIdFilter extends OncePerRequestFilter {
  protected void doFilterInternal(HttpServletRequest req,
      HttpServletResponse res, FilterChain chain)
      throws ServletException, IOException {   // throws Exception 은 오버라이드 불가
    // ① 앞단(LB·게이트웨이)이 이미 만들었으면 그것을 씁니다
    String tp  = req.getHeader("traceparent");           // W3C 표준 (y07)
    String tid = (tp != null && tp.length() >= 55)
        ? tp.substring(3, 35) : null;                     // 00-[trace-id 32자]-… 에서 trace-id 만
    if (tid == null) tid = req.getHeader("X-Request-Id");
    if (tid == null) tid = UUID.randomUUID().toString().replace("-", "");

    MDC.put("trace_id", tid);
    res.setHeader("X-Request-Id", tid);   // ② 응답 헤더로 사용자에게도 노출 ★
    try { chain.doFilter(req, res); } finally { MDC.clear(); }
  }
}
# Nginx 앞단에서 만들어 넘기는 방법 (🟩 Nginx 참고)
map $http_x_request_id $req_id { default $http_x_request_id; "" $request_id; }
proxy_set_header X-Request-Id $req_id;
log_format json escape=json '{"ts":"$time_iso8601","req_id":"$req_id",'
  '"status":$status,"rt":$request_time,"uri":"$uri"}';
응답 헤더로 내보내는 것이 실무의 비밀 병기입니다. 고객센터가 "오류 화면에 보이는 코드" 를 받아 적으면 그 한 건의 요청 로그를 1초 만에 찾습니다. 오류 페이지에 X-Request-Id 를 작게 찍어 두세요.

LogQL — Loki 에 묻기

# 구조: {라벨 선택} | 파서 | 필터 | 집계
#   ★ 라벨 선택은 반드시 있어야 합니다(색인이 라벨뿐)

# ① 특정 서비스의 에러 로그
{service="api", env="prod"} | json | level="error"

# ② 느린 요청만 — JSON 필드로 비교
{service="api"} | json | duration_ms > 1000

# ③ 한 요청의 전체 로그 (여러 서비스에 걸쳐)
{env="prod"} | json | trace_id="4bf92f3577b34da6a3ce929d0e0e4736"

# ④ 에러 발생 추이를 그래프로 (메트릭처럼)
sum by (service) (
  rate({env="prod"} | json | level="error" [5m])
)

# ⑤ 로그에서 p95 응답 시간 뽑기 (메트릭이 없을 때 임시로)
quantile_over_time(0.95,
  {service="api"} | json | unwrap duration_ms [5m]) by (route)

# ⑥ 정규식·문자열 필터 (파싱 전 단계라 훨씬 쌉니다)
{service="api"} |= "deadlock" != "retry" | json

# ⑦ 패턴 파서 — JSON 이 아닌 로그에서 필드 뽑기
{job="nginx"} | pattern `<ip> - - <_> "<method> <uri> <_>" <status> <size>`
  | status = "500"
Loki 에서 느린 쿼리의 원인은 거의 항상 "라벨 선택이 넓다"입니다. {env="prod"} 만 주면 전체 로그를 읽어 압축을 풉니다. service·job·시간 범위로 먼저 좁히고, 문자열 필터(|=)를 파서(| json)보다 앞에 두세요 — 훨씬 빠릅니다.

비용 · 보존 · 개인정보 — 안 남기는 것도 설계 🔴 실무

수집기에서 거르고 가립니다

# Vector — 파싱 · 마스킹 · 샘플링 · 라우팅을 한곳에서
[sources.app]
type = "file"
include = ["/var/log/app/*.log"]

[transforms.parse]
type = "remap"
inputs = ["app"]
source = '''
  . = parse_json!(.message)
  .ts = parse_timestamp!(.ts, "%+")
  # ★ 개인정보 마스킹 — 저장되기 "전"에 지웁니다
  .msg = replace(string!(.msg), r'\d{6}-\d{7}', "[RRN]")   # 주민번호 (string! — JSON 필드는 타입이 미정이라)
  .msg = replace(.msg, r'\d{4}-\d{4}-\d{4}-\d{4}', "[CARD]")
  .msg = replace(.msg, r'Bearer\s+[A-Za-z0-9._-]+', "[TOKEN]")
  del(.password); del(.authorization)
'''

[transforms.sample]
type = "sample"
inputs = ["parse"]
rate = 20                                   # 20건 중 1건만
exclude = '''.level == "error" || .level == "warn"'''   # ★ 에러는 전부 남김

[sinks.loki]
type = "loki"
inputs = ["sample"]
endpoint = "http://loki:3100"
encoding.codec = "json"                     # loki 싱크의 필수 항목
# 라벨은 값 가짓수가 작은 것만! (Loki 도 카디널리티가 비용)
labels = { service = "{{ service }}", env = "{{ env }}", level = "{{ level }}" }
Loki 라벨에 trace_id·user_id 를 넣지 마세요. Prometheus 와 똑같이 스트림이 폭발합니다. 그것들은 본문(JSON 필드)에 두고 | json 으로 검색합니다 — Loki 의 설계가 원래 그렇습니다.

보존 정책 · 줄이기 체크리스트

# Loki — 테넌트별 보존 기간
limits_config:
  retention_period: 336h            # 전체 기본값 14일
  max_query_length: 721h
  ingestion_rate_mb: 20
  max_streams_per_user: 10000       # ★ 카디널리티 안전벨트
compactor:
  retention_enabled: true
  delete_request_store: filesystem  # retention 을 켜면 필요 (Loki 3.x)
runtime_config:
  file: /etc/loki/overrides.yaml    # 테넌트별 값은 이 별도 파일에

# /etc/loki/overrides.yaml
overrides:
  audit:
    retention_period: 8760h         # 감사 로그만 1년
줄이는 수단효과
DEBUG 끄기★ 보통 절반 이상
헬스체크·정적 파일 액세스 로그 제외20~40%
성공 요청 샘플링(1/20) · 에러는 전부★ 큰 절감, 손실 거의 없음
보존 30일 → 14일 (+ 감사만 장기)선형으로 감소
요청/응답 본문 로깅 금지사고 예방 + 용량
중복 로깅 제거(에러 한 번만)10~20%
개인정보는 "마스킹"보다 "안 찍기"가 먼저입니다. 수집기 마스킹은 마지막 방어선이지 1차 대책이 아닙니다 — 정규식은 언젠가 새는 경우가 생깁니다. 코드 리뷰에 "로그에 개인정보 없나" 항목을 넣고, URL 쿼리스트링에 토큰·개인정보를 넣지 않는 규칙(🛡️ 보안·운영 o07)을 함께 지키세요.
정리 — 로그는 JSON 한 줄로 stdout 에 뱉고, 회전·전송·보관은 수집기에 맡깁니다. 필드 8개(ts·level·msg·service·env·version·trace_id·duration_ms)가 최소 계약이고, trace_id 가 로그를 추적·메트릭과 잇는 끈입니다. Loki 는 라벨은 좁게, 검색은 본문으로. 그리고 안 남길 것을 정하는 일이 비용의 90%를 좌우합니다.
OBS 07

분산 추적 — 요청 한 건을 끝까지 따라가기

"주문 API 가 3초 걸립니다"까지는 메트릭이 알려 줍니다. "그 3초 중 2.4초가 재고 서비스의 재고 조회 쿼리였다" 는 추적만 알려 줍니다. 서비스가 셋을 넘는 순간, 추적 없이 원인을 찾는 것은 운에 맡기는 일이 됩니다.

트레이스 폭포수 — 요청 한 건의 시간이 어디로 갔는지 한 장에 보입니다 trace_id 4bf92f35… · 총 1,180ms 0 400ms 800ms 1200ms ① gateway GET /orders — 1180ms ② auth 검증 — 50ms ③ users 조회 — 55ms ④ inventory 호출 — 990ms ⑤ stock 조회(SQL) — 930ms ⑥ payment 호출 — 40ms 읽는 법 — 넓은 막대를 고르는 게 아니라, 자식들이 설명 못 하는 시간을 찾습니다 이 한 장에서 읽어 낸 것 1180ms 중 990ms 가 inventory 호출 · 그 안에서 930ms 가 stock 조회 SQL → DB 가 범인 inventory 자체 코드가 쓴 시간은 990 − 930 = 60ms 뿐 — 서비스를 늘려도 안 빨라집니다
추적이 답하는 질문은 하나뿐입니다 — "이 요청의 시간이 어디로 갔는가."

용어 셋 — 이게 전부입니다

용어뜻
Trace사용자 요청 한 건 전체. 고유한 trace_id(16바이트) 하나
Span그 안의 작업 한 조각 — HTTP 호출·SQL·큐 발행. 시작·끝 시각과 span_id(8바이트)
부모 관계스팬은 parent_span_id 로 트리를 이룹니다. 이 트리를 시간축에 눕힌 것이 폭포수
속성스팬에 붙는 키·값 — http.route·db.statement·user.id. 고카디널리티 OK
이벤트스팬 안의 시점 기록 — 예외 발생·재시도. 스택트레이스도 여기에
상태OK / ERROR — 에러 스팬만 골라 보는 근거가 됩니다
속성에는 마음껏 넣으세요. 메트릭 라벨과 정반대입니다 — 트레이스는 요청 단위로 저장되므로 user_id·order_id· tenant 를 넣어도 시계열이 폭발하지 않습니다. 오히려 이게 있어야 "그 사용자만 느린 이유" 를 찾을 수 있습니다.

컨텍스트 전파 — W3C traceparent

# 서비스 사이를 넘어갈 때 붙는 표준 HTTP 헤더 (W3C Trace Context)
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  └──── trace-id (16바이트) ────┘ └ span-id ─┘ └ flags
             └ version                                        01 = 샘플됨

tracestate: congo=t61rcWkgMzE,rojo=00f067aa0ba902b7   # 벤더별 부가 정보
baggage: user.tier=premium,tenant=acme                # 앱이 실어 보내는 값

# 이 헤더 하나가 전부입니다.
#   · 받은 쪽은 trace-id 를 그대로 쓰고, 자기 span-id 를 새로 만듭니다
#   · 받은 span-id 는 자기 스팬의 parent 가 됩니다
#   · 못 받으면 새 trace 를 시작합니다 (= 흐름이 끊깁니다)
전파가 끊기는 자리는 늘 정해져 있습니다. ① 스레드 풀·비동기 — 컨텍스트가 스레드 로컬이라 넘어가지 않음 ② 메시지 큐 — 헤더를 메시지 속성에 넣고 꺼내야 함(Kafka 헤더) ③ 배치·스케줄러 — 입구가 없어 새 trace 로 시작 ④ 수동으로 만든 HTTP 클라이언트 — 계측된 클라이언트를 써야 함. 추적이 "한 서비스에서 끊긴다"면 이 넷 중 하나입니다.

샘플링 — 전부 저장하면 반드시 실패합니다 🟡 중급

헤드 샘플링 — 시작할 때 결정

# 요청이 들어오는 "입구"에서 주사위를 굴려 결정합니다
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.05          # 5%

#  · parentbased  = 부모가 이미 결정했으면 그대로 따릅니다 ★
#      → 한 요청이 서비스 10개를 지나도 "전부 저장" 또는 "전부 버림"
#      → 이게 없으면 트레이스가 조각조각 납니다
#  · traceid_ratio = trace_id 해시로 결정 (서비스마다 같은 답이 나옵니다)

# 장점 — 싸다. SDK 에서 바로 버리므로 네트워크·CPU 를 안 씁니다
# 단점 — ★ 정작 보고 싶은 "느린 요청·에러 요청"이 95% 확률로 버려집니다
비율을 정하는 감각 — 초당 1,000요청이면 5%도 초당 50트레이스입니다. 트래픽이 작은 서비스는 100%로 두고, 큰 서비스만 낮춥니다. 비율을 서비스마다 다르게 두면 트레이스가 갈라지므로, 반드시 parentbased 를 씁니다.

테일 샘플링 — 끝나고 나서 결정 ★

# OTel Collector 에서 트레이스가 "완성된 뒤" 판단합니다
processors:
  tail_sampling:
    decision_wait: 10s              # 스팬이 다 모일 때까지 대기
    num_traces: 100000
    policies:
      - name: errors                # ① 에러는 무조건 저장
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow                  # ② 1초 넘으면 무조건 저장
        type: latency
        latency: { threshold_ms: 1000 }
      - name: vip                   # ③ 특정 속성은 무조건 저장
        type: string_attribute
        string_attribute: { key: user.tier, values: [premium] }
      - name: rest                  # ④ 나머지는 2%만
        type: probabilistic
        probabilistic: { sampling_percentage: 2 }
실무의 정답은 조합입니다 — SDK 에서는 넉넉히(50~100%) 보내고, Collector 의 테일 샘플링에서 걸러 저장합니다. "에러·느린 요청은 100%, 정상은 2%" 가 되면 저장량은 1/20인데 보고 싶은 것은 다 남습니다. 대신 Collector 가 트레이스를 메모리에 모아 둬야 하므로 같은 trace 의 스팬이 같은 Collector 인스턴스로 가도록 로드밸런싱해야 합니다 (loadbalancing 익스포터).

트레이스로 병목을 찾는 실제 절차 🔴 실무

단계하는 일보는 것 · 도구
1느린 요청 모집단부터 봅니다Tempo/Jaeger 검색 — service=api duration>1s. 한 건이 아니라 여러 건의 공통점을 봅니다
2대표 트레이스 3건을 엽니다가장 느린 것 1건 + 중간 1건 + 정상 1건. 정상과 비교해야 차이가 보입니다
3자기 시간(self time) 이 큰 스팬을 찾습니다★ 자식 스팬 합을 뺀 시간. Jaeger·Tempo 가 계산해 줍니다. 여기가 진짜 범인
4같은 일이 몇 번 반복되는지 셉니다SQL 스팬이 200개면 N+1 쿼리입니다. 폭포수에서 얇은 막대가 촘촘하면 이것
5스팬 속성에서 조건을 찾습니다db.statement·http.route·user.tier — "특정 조건에서만" 인지 확인
6그 구간의 로그로 넘어갑니다trace_id 로 Loki 조회 — 재시도·락 대기·타임아웃 문구가 나옵니다 (y05 연결 설정)
7재현·수정 후 같은 쿼리로 확인배포 후 같은 검색 조건으로 p95 가 떨어졌는지 확인. 여기까지가 한 사이클

폭포수에서 바로 알아보는 다섯 가지 모양

모양진단
얇은 막대가 촘촘히 계단N+1 쿼리 — 루프 안에서 DB 호출. 배치 조회로 바꿉니다
넓은 막대 하나, 자식 없음그 서비스가 계측되지 않았거나 실제로 CPU 를 쓰는 중 → 프로파일(y11)
스팬 사이의 빈틈큐 대기·스레드 풀 대기·GC 정지. 보이지 않는 시간이 가장 위험합니다
같은 호출이 3번 반복재시도가 돌고 있습니다. 타임아웃 설정과 서킷브레이커 확인
병렬이어야 할 것이 순차독립 호출을 동시에 보내면 합이 아니라 최댓값이 됩니다

저장소 고르기 — Tempo vs Jaeger

TempoJaeger
저장오브젝트 스토리지(S3·GCS) — 아주 쌈Cassandra·ES·OpenSearch
검색TraceQL — 속성으로 검색서비스·태그 검색 UI 가 성숙
Grafana★ 같은 화면에서 로그·메트릭 연결별도 UI(임베드 가능)
고르는 법Grafana 를 이미 쓰면 Tempo. 독립 UI 와 성숙한 검색이 필요하면 Jaeger. 데이터 형식은 둘 다 OTLP 라 나중에 바꿔도 됩니다
# TraceQL — 스팬 조건으로 트레이스 찾기
{ resource.service.name = "api" && duration > 1s }
{ span.http.status_code = 500 && span.http.route = "/orders" }
{ span.db.system = "postgresql" && duration > 500ms }   # duration 은 고유 필드 — span. 을 붙이면 "duration" 속성을 찾습니다

# 구조 조건 — "api 를 거쳐서 inventory 로 간 것"
{ resource.service.name="api" } >> { resource.service.name="inventory" }
추적을 붙였는데 안 쓰게 되는 가장 흔한 이유 — ① 샘플링이 낮아 찾는 요청이 없다 → 테일 샘플링으로 바꾸세요 ② trace_id 가 로그에 없어 로그와 오갈 수 없다 → y06 의 MDC 연동 ③ 서비스 이름이 제각각이라 화면이 안 읽힌다 → service.name 규칙을 먼저 정하세요 ④ DB·캐시가 계측 안 됨 → 자동 계측 라이브러리를 켜면 대부분 공짜로 붙습니다(y08).
정리 — 추적은 trace_id 하나로 요청 한 건을 끝까지 잇는 것이고, 서비스 사이는 W3C traceparent 헤더 하나로 전파됩니다. 저장은 전부 하지 말고 에러·느린 요청 100% + 나머지 2%(테일 샘플링)로 가세요. 폭포수를 읽을 때는 자기 시간이 큰 스팬과 스팬 사이의 빈틈을 봅니다.
OBS 08

OpenTelemetry — 계측을 벤더에서 떼어 내기

예전에는 Datadog 을 쓰면 Datadog 에이전트를, New Relic 을 쓰면 New Relic SDK 를 코드에 박아야 했습니다. 도구를 바꾸려면 전 서비스를 다시 계측했죠. OpenTelemetry 는 계측하는 방법을 표준으로 만들고, 어디로 보낼지는 설정으로 뺐습니다. 2026년 기준 사실상 모든 관측 도구가 OTLP 를 받습니다.

앱은 OTLP 한 곳으로만 보냅니다 — 어디에 저장할지는 Collector 설정이 정합니다 앱 · OTel SDK 자동 계측 에이전트 OTLP 4317/4318 OpenTelemetry Collector — 한 프로세스 안의 세 단계 receivers — 받는다 otlp · prometheus · filelog · hostmetrics · jaeger processors — 손본다 memory_limiter · batch · attributes · filter tail_sampling · resource · k8sattributes exporters — 내보낸다 prometheusremotewrite · loki · otlp/tempo · debug Mimir 메트릭 Loki 로그 Tempo 추적 Collector 를 두면 얻는 것 — 앱 재배포 없이 목적지 변경 · 배치·재시도 · 마스킹 · 샘플링을 한곳에서 작게 시작할 땐 Collector 없이 앱 → 저장소 직결도 됩니다. 서비스가 늘면 반드시 가운데를 두게 됩니다
한 줄 요약 — 계측은 표준(OTel), 저장은 취향(Tempo·Datadog·클라우드). 이 분리가 OTel 의 전부입니다.

붙이는 세 가지 방법 — 위에서부터 시도

방법코드 변경얻는 것
① 자동 계측
에이전트·훅
없음HTTP 서버·클라이언트·JDBC·Redis·Kafka 스팬이 전부 자동으로. 여기서 시작하세요
② 설정 기반거의 없음서비스 이름·리소스 속성·샘플링을 환경변수로 조정
③ 수동 계측있음비즈니스 구간(재고 계산·정산 루프)에 직접 스팬. 자동이 안 잡는 곳만
# 공통 환경변수 — 어느 언어든 이 이름 그대로입니다 ★
export OTEL_SERVICE_NAME=api
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=prod"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc          # 또는 http/protobuf (4318)
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_PROPAGATORS=tracecontext,baggage      # W3C 표준 (y07)
service.name 규칙을 먼저 정하세요. 이 값이 대시보드·검색·서비스 맵의 모든 축이 됩니다. 나중에 바꾸면 과거 데이터와 이어지지 않습니다. <팀>-<도메인>(예: order-api) 처럼 짧고 유일하게 짓고 문서에 적어 두세요.

언어별 — 한 줄로 붙이기

# Java (Spring Boot 3.x · Java 21/25) — JAR 하나만 얹으면 끝
curl -L -o otel.jar \
  https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
java -javaagent:./otel.jar -jar app.jar
#   → 서블릿·WebClient·JDBC·Redis·Kafka·Logback MDC 까지 자동
# Python 3.13
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install      # 설치된 라이브러리에 맞는 계측을 자동 설치
opentelemetry-instrument python app.py  # 실행만 이걸로 감싸면 됩니다

# Node 24 — 진입점 앞에 로더 하나
npm i @opentelemetry/auto-instrumentations-node @opentelemetry/sdk-node
node --import @opentelemetry/auto-instrumentations-node/register app.js

# .NET 10
dotnet add package OpenTelemetry.AutoInstrumentation
# Go — 자동 계측이 제한적이라 보통 SDK 로 직접 (otelhttp·otelsql 래퍼)
go get go.opentelemetry.io/otel go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
// 수동 스팬 — 자동이 안 잡는 비즈니스 구간에만
private static final Tracer tracer =
    GlobalOpenTelemetry.getTracer("order-api");

Span span = tracer.spanBuilder("recalculate-stock")
                  .setAttribute("order.id", orderId)
                  .setAttribute("item.count", items.size())
                  .startSpan();
try (Scope s = span.makeCurrent()) {      // ★ 이 안에서의 호출이 자식 스팬이 됩니다
    recalc(items);
} catch (Exception e) {
    span.recordException(e);
    span.setStatus(StatusCode.ERROR);
    throw e;
} finally {
    span.end();                           // ★ finally 에서 반드시
}

Collector 설정 한 벌 — 복붙해서 도는 것 🟡 중급

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }
  # 기존 Prometheus 대상도 Collector 가 대신 긁을 수 있습니다
  prometheus:
    config:
      scrape_configs:
        - job_name: node
          static_configs: [{ targets: ['node-exporter:9100'] }]

processors:
  memory_limiter:                 # ★ 항상 첫 번째. Collector 자신의 OOM 방지
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20
  batch:                          # ★ 항상 마지막. 묶어 보내 네트워크 절감
    timeout: 5s
    send_batch_size: 8192
  resource:
    attributes:
      - { key: deployment.environment, value: prod, action: upsert }
  attributes/scrub:               # 민감 정보 제거 (y06 의 마스킹을 여기서도)
    actions:
      - { key: http.request.header.authorization, action: delete }
      - { key: user.email, action: hash }
  filter/noise:                   # 헬스체크 스팬은 버립니다
    error_mode: ignore
    traces:
      span:
        - 'attributes["http.route"] == "/health"'

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls: { insecure: true }
  prometheusremotewrite:
    endpoint: http://mimir:8080/api/v1/push
    resource_to_telemetry_conversion: { enabled: true }
  otlphttp/loki:
    endpoint: http://loki:3100/otlp

extensions:
  health_check: { endpoint: 0.0.0.0:13133 }

service:
  extensions: [health_check]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, filter/noise, attributes/scrub, resource, batch]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp, prometheus]
      processors: [memory_limiter, resource, batch]
      exporters: [prometheusremotewrite]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, attributes/scrub, batch]
      exporters: [otlphttp/loki]
  telemetry:
    metrics: { level: detailed }   # Collector 자신의 상태도 봅니다

배치 형태 — 어디에 두나

형태쓰는 자리
없음(직결)서비스 1~2개. 앱이 Tempo·Prometheus 로 바로. 시작은 이걸로 충분합니다
에이전트
호스트/노드마다 하나
★ 앱은 localhost:4317 로만 보냄. 네트워크 실패에 강하고 호스트 속성을 붙여 줍니다
게이트웨이
중앙 클러스터
테일 샘플링·중앙 정책·백엔드 인증 키 보관. 에이전트 → 게이트웨이 2단이 표준형
테일 샘플링을 쓰면 게이트웨이가 필수입니다. 한 트레이스의 스팬이 여러 Collector 로 흩어지면 판단을 못 합니다. 앞단에 loadbalancing 익스포터를 두어 trace_id 로 해싱해 같은 인스턴스로 보내야 합니다.

세 신호를 한 줄기로 — 이게 목적입니다

# ① 로그에 trace_id 를 넣으면 (y06)
{"level":"error","trace_id":"4bf92f35…","msg":"stock query timeout"}

# ② 스팬에서 메트릭을 자동 생성 — spanmetrics 커넥터 ★
connectors:
  spanmetrics:
    dimensions: [{ name: http.route }, { name: service.name }]
service:
  pipelines:
    traces:  { receivers: [otlp], exporters: [spanmetrics, otlp/tempo] }
    metrics: { receivers: [spanmetrics], exporters: [prometheusremotewrite] }
#   → 계측을 한 번만 했는데 RED 메트릭이 공짜로 나옵니다
#     traces_span_metrics_duration_milliseconds_bucket{service_name,span_name,http_route,status_code}
#     (히스토그램 기본 단위가 ms — 초로 받으려면 histogram: { unit: s })

# ③ 예시(exemplar) — 메트릭 점 하나에 trace_id 를 붙임 ★★
#   Grafana 의 지연 그래프에서 튀는 점을 클릭 → 그 순간의 실제 트레이스로 이동
#   Micrometer/Prometheus 클라이언트가 지원, Prometheus 는 --enable-feature=exemplar-storage
예시(exemplar)는 관측의 "마지막 퍼즐"입니다. p99 그래프가 튀었을 때 "그 튄 요청"의 트레이스를 클릭 한 번으로 엽니다. 메트릭 → 트레이스 → 로그가 클릭 세 번으로 이어지면 y13 의 5분 진단이 실제로 가능해집니다.
성숙도는 신호마다 다릅니다(2026 기준). 추적은 완전히 안정 단계, 메트릭도 안정, 로그는 표준은 안정화됐지만 언어 SDK 는 아직 편차가 있습니다 — 로그는 당분간 기존 JSON 로거 + 수집기 조합이 현실적이고, trace_id 만 넣어 두면 통합 효과는 똑같이 얻습니다. 프로파일은 네 번째 신호로 표준화가 진행 중입니다(y11).
정리 — OTel 은 "계측 방법을 표준화하고 목적지는 설정으로 뺀 것"입니다. 붙일 때는 자동 계측 → 환경변수 → 수동 스팬 순서로 가고, Collector 는 receiver → processor → exporter 세 단계이며 memory_limiter 를 맨 앞, batch 를 맨 뒤에 둡니다. spanmetrics 와 exemplar 를 켜면 세 신호가 클릭으로 이어집니다.
OBS 09

알림 — 사람을 깨울 자격이 있는 것만

알림 개수와 장애 대응 능력은 반비례합니다. 하루에 50개가 오면 사람은 전부 무시하게 되고, 그 안에 진짜가 섞여 있어도 못 봅니다. 좋은 알림 체계는 "규칙을 많이 만드는 것"이 아니라 "울리지 않게 만드는 것"입니다.

알림 깔때기 — 규칙을 늘리는 게 아니라 사람에게 닿는 수를 줄이는 설계입니다 ① 규칙이 만든 알림 — 15분간 100건 노드 20대 × 규칙 5개가 한꺼번에 터진 상황 ② 그룹핑 — alertname·cluster 로 묶음 → 8묶음 같은 원인의 알림은 한 통에 모아 보냅니다 ③ 억제 — 상위 장애면 하위는 숨김 → 5건 노드가 죽었으면 그 위 파드 알림은 소음입니다 ④ 침묵 — 점검 중 제외 → 4건 배포 · 정기 점검 시간 창 ⑤ 실제 발송 — 4건 그중 사람을 깨우는 건 1건 −92건 묶여서 한 통으로 −3건 상위 장애에 가림 −1건 점검 창에 걸림 1건만 호출 나머지는 채널 통보 건강한 상태의 기준 — 온콜 한 사람이 밤에 깨는 횟수가 주 1회 이하, 페이지의 90%가 실제 조치로 이어짐 이 수치가 안 나오면 규칙을 고치는 게 아니라 "무엇을 알림으로 볼 것인가"를 다시 정해야 합니다
알림 개수는 규칙 수가 아니라 깔때기의 폭이 정합니다 — 그룹핑·억제·침묵이 절반을 합니다.

증상 기반 vs 원인 기반 — 무엇을 알림으로

증상 기반 (사용자가 아픈 것)
· 5xx 비율 2% 초과가 5분 지속
· p99 지연이 SLO 2배 초과
· 결제 성공률 급락
· 외부에서 본 헬스체크 실패
· 에러 예산 소진 속도 급증(y10)
→ 원인이 뭐든 사용자가 아프면 울립니다.
이런 규칙은 서비스당 3~5개면 충분합니다
원인 기반 (그 자체로는 아프지 않음)
· CPU 80% 초과 — 그래서요?
· 메모리 70% — 캐시가 쓰는 겁니다
· 파드 재시작 1회 — 정상 배포입니다
· 커넥션 수 증가 — 트래픽이 는 겁니다
→ 대시보드에 두고 알림으로 만들지 마세요.
밤마다 울리고, 결국 전부 무시하게 됩니다
예외 — "곧 확실히 아플 것"은 원인 기반이어도 됩니다. 디스크가 4시간 뒤 가득 참(predict_linear), 인증서 21일 뒤 만료, 복제 지연이 계속 증가 — 지금 안 고치면 반드시 장애가 되고, 지금이면 조용히 고칠 수 있는 것. 기준은 "이 알림에 대응 절차가 있는가" 입니다. 없으면 알림이 아닙니다.

규칙 파일 — 좋은 예와 나쁜 예

groups:
  - name: api-symptoms
    rules:
      # ✅ 좋은 예 — 증상 · 지속 시간 · 심각도 · 런북 · 요약
      - alert: ApiHighErrorRate
        expr: |
          sum(rate(http_requests_total{job="api",status=~"5.."}[5m]))
          / sum(rate(http_requests_total{job="api"}[5m])) > 0.02
        for: 5m                       # ★ 순간 스파이크 무시
        labels:
          severity: page              # page | ticket | none
          team: order
        annotations:
          summary: "api 5xx 비율 {{ $value | humanizePercentage }}"
          description: "5분 평균 5xx 비율이 2%를 넘었습니다. 최근 배포를 먼저 확인하세요."
          runbook_url: "https://wiki/runbook/api-5xx"
          dashboard_url: "https://grafana/d/api-red"

      # ✅ 좋은 예 — 예측형(대응할 시간을 줍니다)
      - alert: DiskWillFillIn4Hours
        expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0
        for: 15m
        labels: { severity: ticket }

      # ❌ 나쁜 예 — 원인 기반 · 지속 시간 없음 · 맥락 없음
      - alert: HighCPU
        expr: node_cpu_usage > 0.8
        labels: { severity: page }
        annotations: { summary: "CPU 높음" }
        # 밤마다 울리고, 아무도 무엇을 해야 할지 모릅니다
모든 알림에 런북 링크를 강제하세요. 규칙이 runbook_url 없이 머지되지 않게 CI 검사를 넣는 팀도 많습니다. 런북에는 ①이 알림의 뜻 ②먼저 볼 대시보드 ③즉시 가능한 완화 조치 ④에스컬레이션 대상 네 가지만 있으면 됩니다. 세 줄이어도 없는 것보다 낫습니다.

Alertmanager — 라우팅 · 그룹핑 · 억제 · 침묵 🟡 중급

# alertmanager.yml
global:
  resolve_timeout: 5m

route:
  receiver: default-slack
  group_by: ['alertname', 'cluster', 'service']   # ★ 이 라벨이 같으면 한 통으로
  group_wait: 30s        # 첫 알림 후 30초 더 기다렸다 묶어 보냄
  group_interval: 5m     # 같은 그룹에 새 알림이 추가되면 5분 간격으로
  repeat_interval: 4h    # 안 풀리면 4시간마다 다시

  routes:
    # ⓪ 개발 환경은 버림 — 위에서부터 첫 일치로 정해지므로 맨 앞에 둡니다
    #    (뒤에 두면 dev 알림도 위의 page·팀 라우트에 먼저 걸립니다)
    - matchers: ['env="dev"']
      receiver: null-receiver

    # ① 심각도 page 는 호출로 — 밤에도 울립니다
    - matchers: ['severity="page"']
      receiver: pagerduty
      group_wait: 10s
      continue: true                    # 아래 라우트도 계속 평가(Slack 에도 보냄)

    # ② 팀별로 다른 채널
    - matchers: ['team="order"']
      receiver: slack-order
    - matchers: ['team="payment"']
      receiver: slack-payment

# 억제 — 상위 장애가 켜져 있으면 하위 알림을 숨깁니다 ★
inhibit_rules:
  - source_matchers: ['alertname="NodeDown"']
    target_matchers: ['severity=~"page|ticket"']
    equal: ['instance']                 # 같은 instance 인 것만 숨김

  - source_matchers: ['severity="page"']
    target_matchers: ['severity="ticket"']
    equal: ['alertname', 'service']     # 같은 알림의 낮은 등급은 숨김

receivers:
  - name: pagerduty
    pagerduty_configs:
      - routing_key: '<키>'
        description: '{{ .CommonAnnotations.summary }}'
  - name: slack-order
    slack_configs:
      - api_url: '<웹훅>'
        title: '{{ .Status | toUpper }} · {{ .CommonLabels.alertname }}'
        text: |
          {{ range .Alerts }}{{ .Annotations.description }}
          런북: {{ .Annotations.runbook_url }}
          대시보드: {{ .Annotations.dashboard_url }}
          {{ end }}
  # 위에서 이름을 쓴 receiver 는 전부 정의돼 있어야 설정이 로드됩니다
  - name: slack-payment
    slack_configs: [{ api_url: '<웹훅>' }]
  - name: default-slack
    slack_configs: [{ api_url: '<웹훅>' }]
  - name: null-receiver                  # 아무 데도 보내지 않음

침묵(silence) — 배포·점검 때

# CLI 로 침묵 걸기 (amtool)
amtool silence add \
  alertname=ApiHighErrorRate service=api \
  --duration=1h --author="hong" \
  --comment="1.5.0 배포 중 · 관측 후 해제"

amtool silence query            # 현재 걸린 침묵 목록
amtool silence expire <ID>      # 조기 해제

# 배포 파이프라인에 넣는 패턴 — 배포 전 자동 침묵, 후 자동 해제
#   ★ 단, "배포 때문에 난 장애"까지 가려집니다.
#     그래서 침묵은 짧게(15~30분) 걸고, 증상 알림은 침묵하지 않는 팀도 많습니다

# 알림이 왜 안 왔는지 추적 — 가장 자주 쓰는 디버깅
amtool config routes test \
  --config.file=alertmanager.yml \
  severity=page team=order      # 이 라벨이면 어느 receiver 로 가는지 보여 줍니다
알림 체계 자체가 죽는 경우를 대비하세요. Prometheus 가 OOM 으로 죽으면 알림이 안 오는 것이 곧 정상처럼 보입니다. 대책 두 가지 — ① 항상 발화하는 Watchdog 알림을 만들어 "이 알림이 안 오면 알림 체계가 죽은 것" 으로 감시하고(Dead Man's Switch), ② 외부 업타임 서비스를 관측 스택 바깥에 하나 둡니다.

온콜 운영 — 사람 쪽 설계

항목실무 기준
심각도 등급★ page(즉시 호출) / ticket(업무시간) / none(대시보드만). 세 단계면 충분합니다
당번 주기1주 단위 · 최소 4명 이상 로테이션(2명이면 번아웃)
에스컬레이션1차 무응답 5분 → 2차 → 15분 → 팀장. 자동이어야 합니다
인수인계주간 교대 때 지난주 페이지 목록을 함께 리뷰
보상야간 대응 시 다음 날 휴식. 이게 없으면 사람이 나갑니다
알림 리뷰★ 격주로 발생한 알림 전수 검토 — "조치 없이 닫힌 알림"은 규칙을 고칩니다
알림 피로를 재는 숫자를 두세요. ①주당 페이지 수 ②그중 조치로 이어진 비율(목표 90% 이상) ③야간 페이지 수 ④자동 해결된 알림 비율. 이걸 대시보드로 만들어 두면 "알림이 너무 많다"가 느낌이 아니라 수치가 되고, 규칙을 지우자는 논의가 쉬워집니다.
규칙을 고칠 때의 우선순위 — ① 가장 많이 울린 알림 하나부터 (상위 3개가 보통 전체의 70%) ② 조치 없이 닫힌 알림은 임계값을 올리거나 for 를 늘리거나 등급을 내립니다 ③ 같은 원인이 여러 알림이면 억제 규칙으로 묶습니다 ④ 그래도 남으면 지웁니다. 지운 알림 때문에 놓친 장애가 생기면 그때 다시 만들면 됩니다. 규칙을 지우는 것도 관측 개선입니다.
정리 — 알림은 증상 기반으로 서비스당 3~5개가 목표입니다. for 로 순간 스파이크를 걸러 내고, 런북 링크를 강제하고, Alertmanager 의 그룹핑·억제·침묵으로 도달 수를 줄입니다. Watchdog 알림과 외부 감시로 알림 체계 자체의 죽음을 감시하고, 격주로 안 쓰이는 알림을 지우세요.
OBS 10

SLI · SLO · 에러 예산 — 숫자로 하는 대화

"요즘 서비스가 좀 불안한 것 같아요"는 논쟁이 되고, "이번 달 에러 예산의 82%를 이미 썼습니다"는 결정이 됩니다. SLO 는 품질을 재는 도구이자, 기능 개발과 안정화 사이의 우선순위를 감정 없이 정하는 장치입니다.

에러 예산 — "얼마나 실패해도 되는가"를 미리 정해 두고, 그 잔량으로 판단합니다 남은 에러 예산 (30일 창) 100% 75% 50% 25% 0% 장애 — 하루에 40% 소진 점선 = 균등 소진(이상) 1일 30일 남은 25%로 한 달을 버텨야 합니다 빠른 소진 — 즉시 호출 5분 + 1시간 창 · burn rate 14.4배 2일이면 한 달 예산이 바닥납니다 중간 소진 — 티켓 30분 + 6시간 창 · burn rate 6배 5일이면 바닥납니다 느린 소진 — 리뷰 6시간 + 3일 창 · burn rate 1배 월말에 아슬아슬합니다 에러 예산 = (1 − SLO) × 전체 요청 — 99.9%면 한 달에 43분, 요청 1,000건 중 1건까지 허용 두 개의 창을 함께 보는 이유 — 짧은 창은 빨리 알아채고, 긴 창은 이미 회복된 것을 안 울리게 합니다
에러 예산의 쓸모 — "지금 배포해도 되는가"와 "안정화에 시간을 써야 하는가"를 숫자로 답합니다.

SLI 고르기 — 사용자가 느끼는 것

서비스 종류좋은 SLI
요청/응답 API★ 가용성(성공 응답 비율) · 지연(300ms 이내 비율)
배치·파이프라인신선도(데이터가 N분 이내인가) · 정확성(재처리 없이 맞았나)
스트리밍·큐처리 지연(발행→소비 시간) · 유실률
저장소내구성 · 읽기 지연 · 쿼리 성공률
화면(웹)첫 렌더 시간 · 오류 화면 비율 (실사용자 측정)
# 가용성 SLI — "좋은 요청 / 전체 요청"
sum(rate(http_requests_total{job="api",status!~"5.."}[5m]))
  / sum(rate(http_requests_total{job="api"}[5m]))

# 지연 SLI — "300ms 안에 끝난 요청의 비율" (분위수보다 이게 낫습니다) ★
sum(rate(http_request_duration_seconds_bucket{job="api",le="0.3"}[5m]))
  / sum(rate(http_request_duration_seconds_count{job="api"}[5m]))
지연 SLO 는 p95 보다 "임계 이내 비율"로 쓰세요. "p95 < 300ms" 는 여러 서버·기간에 걸쳐 합치기 까다롭고 에러 예산 계산이 지저분합니다. "300ms 이내 응답 99%" 는 그냥 비율이라 가용성과 똑같은 방식으로 예산을 계산할 수 있습니다. 그래서 y02 에서 SLO 값을 버킷 경계에 넣으라고 한 것입니다.

목표치 정하기 — 9의 개수가 곧 비용

SLO월 허용 다운타임현실
99%7시간 18분사내 도구·배치
99.5%3시간 39분일반 내부 서비스
99.9%43분★ 대부분의 웹 서비스가 여기
99.95%21분결제·인증 등 핵심 경로
99.99%4분 21초다중 리전·자동 장애 조치 필수. 비용이 급증
99.999%26초사람이 개입할 시간이 없습니다. 대개 과잉
목표는 "가능한 높게"가 아니라 "현재보다 조금 위"로 잡습니다. 지난 4주 실측이 99.7% 인데 SLO 를 99.99% 로 정하면 첫날부터 예산이 마이너스가 되어 아무도 안 봅니다. ①4주 실측 → ②그보다 살짝 높게 → ③분기마다 조정 이 순서로 가세요. 그리고 SLO 는 의존하는 서비스들의 곱보다 높을 수 없습니다 — 99.9% 서비스 세 개에 의존하면 상한은 99.7%입니다.

소진 속도(burn rate) 알림 — 임계값 알림의 상위 호환 🔴 실무

기록 규칙으로 먼저 정리합니다

# recording rules — 매번 긴 쿼리를 쓰지 않도록 미리 계산해 둡니다
groups:
  - name: slo-api
    interval: 30s
    rules:
      # 나쁜 요청 비율을 여러 창으로 미리 계산
      - record: slo:api_error_ratio:rate5m
        expr: |
          sum(rate(http_requests_total{job="api",status=~"5.."}[5m]))
          / sum(rate(http_requests_total{job="api"}[5m]))
      - record: slo:api_error_ratio:rate30m      # 아래 Medium 알림이 씁니다
        expr: |
          sum(rate(http_requests_total{job="api",status=~"5.."}[30m]))
          / sum(rate(http_requests_total{job="api"}[30m]))
      - record: slo:api_error_ratio:rate1h
        expr: |
          sum(rate(http_requests_total{job="api",status=~"5.."}[1h]))
          / sum(rate(http_requests_total{job="api"}[1h]))
      - record: slo:api_error_ratio:rate6h
        expr: |
          sum(rate(http_requests_total{job="api",status=~"5.."}[6h]))
          / sum(rate(http_requests_total{job="api"}[6h]))
      - record: slo:api_error_ratio:rate3d
        expr: |
          sum(rate(http_requests_total{job="api",status=~"5.."}[3d]))
          / sum(rate(http_requests_total{job="api"}[3d]))
기록 규칙(recording rule)의 진짜 값어치 — [3d] 같은 긴 구간 쿼리는 대시보드에서 매번 돌리면 무겁습니다. 미리 계산해 두면 알림 평가도 대시보드도 즉시가 되고, 쿼리 문자열이 한곳에만 있어 정의가 갈라지지 않습니다.

다중 창 소진 속도 알림 — 그대로 복붙

# SLO 99.9% → 허용 오류율 0.001
# burn rate = 실제 오류율 / 허용 오류율
#   14.4배로 계속 타면 30일 예산을 2일 만에 소진 → 즉시 호출
  - name: slo-api-burn
    rules:
      - alert: ApiErrorBudgetBurnFast
        expr: |
          slo:api_error_ratio:rate5m  > (14.4 * 0.001)
          and
          slo:api_error_ratio:rate1h  > (14.4 * 0.001)
        for: 2m
        labels: { severity: page, slo: api-availability }
        annotations:
          summary: "api 에러 예산을 14배 속도로 소진 중 (2일이면 고갈)"
          runbook_url: "https://wiki/runbook/slo-burn"

      - alert: ApiErrorBudgetBurnMedium
        expr: |
          slo:api_error_ratio:rate30m > (6 * 0.001)
          and
          slo:api_error_ratio:rate6h  > (6 * 0.001)
        for: 15m
        labels: { severity: ticket }

      - alert: ApiErrorBudgetBurnSlow
        expr: |
          slo:api_error_ratio:rate6h > (1 * 0.001)
          and
          slo:api_error_ratio:rate3d > (1 * 0.001)
        for: 1h
        labels: { severity: ticket }
왜 창을 두 개씩 and 로 묶나 — 짧은 창만 보면 2분짜리 스파이크에도 사람이 깹니다. 긴 창만 보면 알아채는 데 몇 시간이 걸립니다. 둘을 함께 걸면 "빨리 알아채되, 이미 끝난 일로는 안 울리는" 상태가 됩니다. 긴 창이 조건에서 빠지는 순간 알림도 자동으로 풀립니다.
남은 예산운영 규칙(정책으로 문서화)실제로 하는 일
75% 이상정상 — 배포 속도를 올려도 됩니다실험·카나리 비율 확대. 예산이 남는 건 "덜 위험하게 살고 있다"는 뜻이기도 합니다
50~75%평소대로주간 리뷰에서 추세만 확인
25~50%주의 — 위험한 변경 보류큰 마이그레이션·스키마 변경은 다음 주기로. 카나리 기간 연장
25% 미만기능 배포 동결 — 안정화 작업만버그·성능 개선만 배포. 원인 상위 3개를 백로그 최상단으로
0% (초과)사후 분석 + 개선 완료까지 동결포스트모템(y13) 액션 아이템이 끝나야 해제. 사람이 아니라 규칙이 결정합니다
SLO 는 도구가 아니라 합의입니다. 개발·운영·기획이 같은 숫자를 보고 같은 결론에 도달하게 만드는 것이 목적입니다. 그래서 ①SLO 는 서비스마다 3개 이하 ②정의를 문서 한 장에 ③매달 리뷰가 실제 운영 규칙입니다. 지표를 20개 만들면 아무도 안 봅니다. 시작은 가장 중요한 사용자 여정 하나(예: 주문 생성)에 가용성 SLO 하나만 거는 것으로 충분합니다. 도구로는 Sloth·Pyrra 같은 생성기가 위 규칙들을 SLO 정의 몇 줄에서 자동 생성해 줍니다.
정리 — SLI 는 사용자가 느끼는 비율로 고르고(가용성·"임계 이내 비율"), SLO 는 실측보다 조금 위로 잡습니다. 에러 예산 = (1 − SLO) × 요청 수이고, 알림은 임계값이 아니라 소진 속도를 두 개의 창으로 겁니다. 그리고 예산 잔량이 배포 속도를 결정하는 규칙을 문서로 만들어 두세요 — 그게 SLO 의 진짜 목적입니다.
OBS 11

프로파일링 — 코드 한 줄까지 내려가기

트레이스가 "이 스팬이 900ms 걸렸다"까지 알려 줬다면, 그다음 질문은 "그 900ms 동안 CPU 는 무슨 함수에 있었나" 입니다. 예전에는 이걸 재려면 문제를 재현해야 했지만, 지금은 운영 중에 계속 켜 둡니다.

플레임그래프 — 가로 폭이 CPU 시간, 세로가 호출 깊이입니다 위로 갈수록 깊은 호출 · 넓을수록 CPU 를 많이 쓴 함수 ← 넓고 평평한 봉우리 = 여기가 범인 String.concat 27% JSON 파싱 28% db 10% validate 28% loadStock 38% handleOrder 66% GC · 백그라운드 34% root — 전체 CPU 100% 0 25 50 75 100 전체 CPU 시간 대비 % 읽는 법 세 가지 ① 넓은 것부터 봅니다 — 좁은 막대는 완벽히 고쳐도 전체가 안 빨라집니다 ② 평평한 꼭대기(자식이 없는 칸)가 실제로 CPU 를 태우는 코드입니다 ③ 좌우 순서에는 뜻이 없습니다 — 이름순으로 정렬된 것일 뿐, 시간 순이 아닙니다
폭포수(y07)가 "어느 구간"을 알려 줬다면, 플레임그래프는 "그 구간의 어느 함수"를 알려 줍니다.

지속 프로파일링 — 계속 켜 둡니다

예전 프로파일러는 느리고 무거워서 개발 환경에서만 켰습니다. 지금 쓰는 것들은 샘플링 방식이라 CPU 오버헤드가 1~2% 수준이고, 그래서 운영에서 항상 켜 둡니다. 사고가 난 어제 오후 3시의 프로파일을 지금 열어 볼 수 있다는 것이 결정적인 차이입니다.

도구성격
Pyroscope
Grafana 계열
★ Grafana 에 통합. 언어별 SDK + eBPF 자동 수집. 트레이스와 연결(스팬 프로파일)
ParcaeBPF 기반 · 코드 수정 없이 호스트 전체를 프로파일링
pprof(Go)Go 표준. net/http/pprof 만 붙이면 끝. 수집기가 이걸 긁어 갑니다
async-profiler(JVM)JVM 의 사실상 표준. safepoint 편향이 없어 정확합니다
py-spy(Python)돌고 있는 프로세스에 붙어서 뜹니다 — 코드 수정 없음
메모리 프로파일이 더 값어치 있는 경우가 많습니다. CPU 는 대개 원인이 뻔하지만, 메모리 누수는 프로파일 없이 절대 못 찾습니다. 힙 할당 프로파일을 시간 간격 두고 두 번 떠서 차이(diff)를 보는 것이 누수 추적의 정석입니다(y13 의 사례 2).

붙이기 — 언어별 한 줄

# Go — 표준 라이브러리만으로
#   import _ "net/http/pprof"  →  :6060/debug/pprof/*
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap   # 메모리
# JVM — async-profiler (운영 중인 PID 에 붙습니다)
# (3.0 부터 실행 파일이 bin/asprof — 예전 profiler.sh 는 사라졌습니다)
asprof -d 30 -e cpu   -f cpu.html   <PID>
asprof -d 30 -e alloc -f alloc.html <PID>   # 할당 프로파일
#   -e wall 은 "대기까지 포함" — 느린데 CPU 는 안 쓰는 경우에 씁니다 ★

# Python — 돌고 있는 프로세스에 붙어 실시간
py-spy top --pid 12345
py-spy record --pid 12345 --duration 30 --output flame.svg
# Pyroscope — 지속 프로파일링 서버 + 앱 SDK
services:
  pyroscope:
    image: grafana/pyroscope:latest
    ports: ["4040:4040"]

# 앱 쪽(Java) — 에이전트 한 줄
# java -javaagent:pyroscope.jar \
#      -Dpyroscope.application.name=order-api \
#      -Dpyroscope.server.address=http://pyroscope:4040 \
#      -jar app.jar

# Grafana 데이터 소스로 추가하면 트레이스 화면에서
# "이 스팬의 프로파일 보기"가 열립니다 ★
프로파일에는 심볼이 필요합니다. 컨테이너 이미지를 strip 하거나 디버그 심볼을 빼면 플레임그래프가 주소 숫자만 잔뜩 나옵니다. Go 는 기본으로 괜찮지만, C/C++·Rust 계열은 운영 이미지에 심볼을 남기거나 심볼 서버를 따로 둬야 합니다.

eBPF 기반 관측 — 코드를 안 고치고 커널에서 보기 🔴 실무

무엇이 가능해지나

eBPF 는 커널 안에서 안전하게 작은 프로그램을 돌리는 기능입니다. 관측 쪽에서 의미는 하나예요 — 앱을 전혀 안 고치고도 시스템 콜·네트워크 패킷·함수 진입을 관찰할 수 있습니다.

도구주는 것
Pixie배포만 하면 서비스 간 통신·HTTP 요청·SQL 쿼리가 계측 없이 보입니다. 개발 단계 조사에 강력
Cilium Hubble서비스 간 네트워크 흐름 지도와 차단된 연결 — 네트워크 정책 디버깅
Parca · Pyroscope eBPF호스트 전체의 CPU 프로파일 — 언어 SDK 없이
bcc · bpftrace일회성 조사 — 느린 디스크 I/O, TCP 재전송, 파일 열기 추적
# bpftrace 한 줄짜리들 (조사할 때만 잠깐)
# 디스크 I/O 요청 크기 분포 (섹터 단위 — 지연 분포는 bcc 의 biolatency 로)
sudo bpftrace -e 'tracepoint:block:block_rq_complete
  { @sectors = hist(args->nr_sector); }'

# 어떤 프로세스가 파일을 여는가
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat
  { printf("%s %s\n", comm, str(args->filename)); }'

한계와 경계 — 어디까지 eBPF 인가

eBPF 가 잘하는 것
· 계측 없는 서비스의 통신을 보기
· 언어 무관한 CPU 프로파일
· 커널 레벨 — I/O·네트워크·스케줄링
· 도입 비용이 낮음 — 배포만 하면 끝
"계측이 아직 안 된 레거시"에 특히 유용
eBPF 로는 안 되는 것
· 비즈니스 맥락 — order_id·user_tier
· 요청 단위 상관관계 — trace_id 전파
· 암호화된 페이로드(TLS 종단 밖)
· 커널 버전 의존 — 오래된 커널에선 제약
→ OTel 계측을 대체하지 못하고 보완합니다
상용 APM(Datadog·New Relic·Dynatrace)과의 경계. 상용 도구는 계측·저장·대시보드·알림·이상 탐지를 한 벌로 주고, 붙이는 시간이 압도적으로 짧습니다. 대신 비용이 트래픽에 비례해 커지고 데이터가 밖에 나갑니다. 실무 판단 기준은 "관측 스택을 돌볼 사람이 있는가" 입니다 — 전담이 없으면 상용이 총비용에서 유리한 경우가 많고, 계측만 OTel 로 해 두면 나중에 바꿀 수 있습니다(y08). 이게 OTel 의 최대 실익입니다.

무엇을 프로파일링하나 — CPU 만이 아닙니다 🟡 중급

종류답하는 질문언제 꺼내나
CPU어느 함수가 계산을 하고 있나CPU 사용률이 높은데 원인을 모를 때. 가장 기본
Wall clock★ 대기까지 포함해 시간이 어디로 갔나"느린데 CPU 는 놀고 있다" — 락 대기·I/O 대기가 범인일 때
할당(alloc)어디서 객체를 많이 만드나GC 가 자주 돌 때. GC 튜닝보다 할당을 줄이는 게 답인 경우가 대부분
힙(heap)지금 살아 있는 객체가 무엇인가메모리 누수 추적 — 시간 간격 두고 두 번 떠서 차이를 봅니다(y13 사례 2)
락 경합어느 락에서 스레드가 기다리나코어를 늘려도 처리량이 안 늘 때
블로킹 I/O어느 호출이 스레드를 붙잡나스레드 풀 고갈 · 커넥션 부족과 함께 봅니다
가장 흔한 오독 — "CPU 프로파일에 GC 가 크게 나왔으니 GC 를 튜닝하자". 거의 항상 틀린 결론입니다. GC 가 바쁜 이유는 누군가 객체를 너무 많이 만들기 때문이고, 고칠 곳은 GC 설정이 아니라 할당 프로파일에서 1등인 코드입니다. 같은 이유로 플레임그래프에 프레임워크 내부가 크게 보이는 것도 대개 결론이 아닙니다 — 그 프레임워크를 부른 내 코드까지 내려가 보세요.
정리 — 프로파일은 네 번째 신호입니다. 넓은 막대 → 평평한 꼭대기 순으로 읽고, 좌우 순서는 무시하세요. 지금은 오버헤드가 1~2%라 운영에 켜 두는 것이 표준이고, 그래야 지나간 사고의 프로파일을 열어 볼 수 있습니다. eBPF 는 계측이 없는 곳을 메우는 보완재이지 OTel 의 대체재가 아닙니다.
OBS 12

실전 — 관측 스택 한 벌을 직접 올립니다

지금까지의 조각을 한 파일로 묶습니다. docker compose up -d 하나로 Prometheus · Grafana · Loki · Tempo · OTel Collector 가 뜨고, 앱을 붙이면 메트릭 · 로그 · 트레이스가 서로 이어진 상태가 됩니다. 개인 서버·사내 환경이면 이 구성 그대로 운영해도 됩니다.

한 대에서 도는 관측 스택 — 바깥에 여는 포트는 Grafana 하나뿐입니다 app :8080 /metrics · OTLP node-exporter :9100 · 호스트 자원 otel-collector :4317 gRPC · :4318 HTTP batch · tail_sampling prometheus :9090 scrape + OTLP 수신 보존 15일 · 규칙 평가 alertmanager :9093 Slack · 호출 tempo :3200 추적 · S3 가능 loki :3100 로그 · 14일 보존 grafana :3000 유일한 공개 포트 볼륨을 반드시 붙이세요 — 컨테이너를 다시 만들면 지표·로그·대시보드가 전부 사라집니다 메모리 감각 — Prometheus 2~4GB · Loki 1GB · Tempo 1GB · Collector 512MB (소규모 기준) 공개 포트는 Grafana 만. 나머지는 내부 네트워크에 두고 리버스 프록시 뒤에 둡니다 (🛡️ 보안·운영 o03)
이 한 벌이면 메트릭·로그·추적·알림이 전부 갖춰집니다 — 남은 것은 앱에 계측을 붙이는 일뿐입니다.
# docker-compose.yml — 관측 스택 한 벌
name: obs

services:
  prometheus:
    image: prom/prometheus:latest
    command:
      - --config.file=/etc/prometheus/prometheus.yml
      - --storage.tsdb.path=/prometheus
      - --storage.tsdb.retention.time=15d
      - --web.enable-lifecycle
      - --web.enable-otlp-receiver          # ★ OTLP 메트릭 직접 수신 (Prometheus 3.x)
      - --web.enable-remote-write-receiver  # Tempo 메트릭 생성기의 remote_write 를 받으려면
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./rules:/etc/prometheus/rules:ro
      - prom-data:/prometheus
    restart: unless-stopped

  alertmanager:
    image: prom/alertmanager:latest
    volumes:
      - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
      - am-data:/alertmanager
    restart: unless-stopped

  loki:
    image: grafana/loki:latest
    command: -config.file=/etc/loki/loki.yaml
    volumes:
      - ./loki.yaml:/etc/loki/loki.yaml:ro
      - loki-data:/loki
    restart: unless-stopped

  tempo:
    image: grafana/tempo:latest
    command: -config.file=/etc/tempo/tempo.yaml
    volumes:
      - ./tempo.yaml:/etc/tempo/tempo.yaml:ro
      - tempo-data:/var/tempo
    restart: unless-stopped

  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest   # contrib = 플러그인 다 들어 있음
    command: ["--config=/etc/otel/config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel/config.yaml:ro
    ports:
      - "127.0.0.1:4317:4317"      # ★ 호스트에서만 접근 — 0.0.0.0 로 열지 마세요
      - "127.0.0.1:4318:4318"
    depends_on: [tempo, loki, prometheus]
    restart: unless-stopped

  node-exporter:
    image: prom/node-exporter:latest
    command:
      - --path.rootfs=/host
    pid: host
    volumes:
      - /:/host:ro,rslave
    restart: unless-stopped

  grafana:
    image: grafana/grafana:latest
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GF_PASS:?set GF_PASS}   # ★ 기본 비밀번호 금지
      GF_USERS_ALLOW_SIGN_UP: "false"
      GF_FEATURE_TOGGLES_ENABLE: traceToMetrics,traceqlEditor
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
      - grafana-data:/var/lib/grafana
    ports:
      - "3000:3000"                # 바깥에 여는 유일한 포트
    depends_on: [prometheus, loki, tempo]
    restart: unless-stopped

volumes:
  prom-data:
  am-data:
  loki-data:
  tempo-data:
  grafana-data:

loki.yaml — 단일 노드 최소 설정

auth_enabled: false
server:
  http_listen_port: 3100
  log_level: warn

common:
  path_prefix: /loki
  replication_factor: 1
  ring: { kvstore: { store: inmemory } }
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules

schema_config:
  configs:
    - from: 2024-04-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index: { prefix: index_, period: 24h }

limits_config:
  retention_period: 336h          # 14일
  ingestion_rate_mb: 16
  ingestion_burst_size_mb: 32
  max_streams_per_user: 10000     # ★ 카디널리티 안전벨트
  reject_old_samples: true
  reject_old_samples_max_age: 168h

compactor:
  working_directory: /loki/compactor
  retention_enabled: true
  delete_request_store: filesystem

tempo.yaml — 단일 노드 최소 설정

server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc: { endpoint: 0.0.0.0:4317 }

ingester:
  max_block_duration: 5m

compactor:
  compaction:
    block_retention: 336h         # 추적 보존 14일

storage:
  trace:
    backend: local                # 운영에서는 s3/gcs 로 바꿉니다
    local: { path: /var/tempo/blocks }
    wal:   { path: /var/tempo/wal }

# 스팬에서 RED 메트릭을 자동 생성 (y08 의 spanmetrics) ★
metrics_generator:
  registry:
    external_labels: { source: tempo }
  storage:
    path: /var/tempo/generator/wal
    remote_write:
      - url: http://prometheus:9090/api/v1/write
        send_exemplars: true      # ★ 메트릭 점 → 트레이스 점프
overrides:
  defaults:
    metrics_generator:
      processors: [service-graphs, span-metrics]
metrics_generator 를 켜면 공짜로 얻는 것 — 서비스 간 호출 관계를 그린 서비스 그래프와, 스팬에서 뽑은 서비스별 RED 메트릭입니다. 앱에 메트릭 계측을 아직 안 붙였어도 추적만으로 대시보드가 서기 시작합니다. (Prometheus 쪽에 --web.enable-remote-write-receiver 필요)

앱 붙이기 · 첫 확인 🟢 기초

# ① 스택 띄우기
export GF_PASS='바꿔주세요'
docker compose up -d
docker compose ps            # 전부 running 인지

# ② 앱을 같은 네트워크에 붙이고 환경변수만 주기 (y08)
docker run -d --name app --network obs_default \
  -e OTEL_SERVICE_NAME=order-api \
  -e OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \
  -e OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
  -e OTEL_RESOURCE_ATTRIBUTES='service.version=1.4.2,deployment.environment=prod' \
  -e OTEL_TRACES_SAMPLER=parentbased_traceidratio \
  -e OTEL_TRACES_SAMPLER_ARG=1.0 \
  myapp:1.4.2

# ③ 순서대로 확인 — 하나씩 끊어서 봐야 원인이 보입니다 ★
#    (앱·Prometheus·Loki 포트는 밖에 열지 않았으니 같은 네트워크의 임시 컨테이너로 묻습니다)
C="docker run --rm --network obs_default curlimages/curl -s"
$C http://app:8080/metrics | head                 # 앱이 메트릭을 내나
$C http://prometheus:9090/api/v1/targets | jq '.data.activeTargets[] | {job,health}'   # 긁히나
$C 'http://prometheus:9090/api/v1/query?query=up' # 저장됐나
docker compose logs otel-collector | tail -30     # 콜렉터가 에러 없이 도나
$C http://loki:3100/loki/api/v1/labels            # 로키가 로그를 받았나

# ④ Grafana 접속 → Explore 에서 세 소스를 각각 한 번씩 질의해 봅니다
open http://localhost:3000

# ⑤ 설정을 고쳤을 때 (재시작 없이)
$C -X POST http://prometheus:9090/-/reload
docker compose restart otel-collector

초기 알림 세트 — 이 다섯 개로 시작

# rules/starter.yml — 여기서 시작해서 늘려 가세요
groups:
  - name: starter
    rules:
      - alert: TargetDown                      # ① 뭔가 안 긁힘
        expr: up == 0
        for: 5m
        labels: { severity: page }
        annotations: { summary: "{{ $labels.job }} 대상이 5분간 응답 없음" }

      - alert: HighErrorRate                   # ② 사용자가 아픔
        expr: |
          sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
          / sum by (job) (rate(http_requests_total[5m])) > 0.02
        for: 5m
        labels: { severity: page }

      - alert: HighLatencyP95                  # ③ 느림
        expr: |
          histogram_quantile(0.95,
            sum by (le, job) (rate(http_request_duration_seconds_bucket[5m]))) > 1
        for: 10m
        labels: { severity: ticket }

      - alert: DiskWillFill                    # ④ 예측형
        expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0
        for: 15m
        labels: { severity: ticket }

      - alert: Watchdog                        # ⑤ 알림 체계 생존 확인 ★
        expr: vector(1)
        labels: { severity: none }
        annotations: { summary: "이 알림이 끊기면 관측 스택이 죽은 것입니다" }

비용을 미리 줄여 두는 설정

설정효과
scrape_interval: 30s15s → 30s 로 바꾸면 저장량 절반. 알림 반응이 15초 늦어질 뿐입니다
metric_relabel_configs drop★ 안 보는 메트릭 제거 — Go 런타임·JVM 세부 지표만 빼도 30% 줍니다
Loki 샘플링(1/20)성공 요청 로그만 골라 버림. 에러는 전부 남김
테일 샘플링추적 저장량 1/20 인데 보고 싶은 건 다 남음(y07)
보존 15d / 14d90일이 필요한 건 감사 로그뿐인 경우가 대부분
Tempo·Loki를 S3 로디스크 대비 수분의 일. 대신 조회가 조금 느려집니다
스택 자체를 감시하는 것을 잊지 마세요. prometheus_tsdb_head_series(시계열 수 폭발), otelcol_exporter_send_failed_spans(콜렉터가 못 보내는 중), loki_ingester_streams(스트림 폭발), 컨테이너 재시작 수 — 이 넷은 대시보드에 두세요. 관측 스택이 조용히 죽으면 장애를 통째로 못 봅니다.
규모가 커지면 바뀌는 것. 이 구성은 서버 1~10대, 서비스 10개쯤까지 편안합니다. 그 위로 가면 ① Loki·Tempo 저장을 S3 로 ② Prometheus 를 2대 + Mimir 로 ③ Collector 를 에이전트 + 게이트웨이 2단으로 나눕니다(y08). 쿠버네티스라면 이 전부를 kube-prometheus-stack 과 loki-stack 헬름 차트로 대체할 수 있습니다 — ☸️ 쿠버네티스 d11 참고.
정리 — compose 파일 하나 · 설정 네 개면 관측 스택 한 벌이 뜹니다. 확인은 앱 /metrics → 타깃 health → 쿼리 → 콜렉터 로그 순으로 끊어서 하세요. 알림은 다섯 개(TargetDown · 에러율 · 지연 · 디스크 예측 · Watchdog) 로 시작하고, 볼륨과 공개 포트 하나만 지키면 이대로 운영해도 됩니다.
OBS 13

장애 대응 실전 — 알림이 울린 뒤 5분

여기까지 붙인 것을 실제로 쓰는 절차입니다. 장애 대응 실력은 지식이 아니라 순서를 지킬 수 있는가에서 갈립니다. 기본 서버 관점의 대응 순서는 🛡️ 보안·운영 o12 에 있고, 여기서는 관측 데이터로 원인을 좁혀 가는 절차를 다룹니다.

알림이 울린 뒤 5분 — 이 순서를 지키는 것만으로 대응 시간이 절반이 됩니다 0분 알림을 읽고 런북을 엽니다 "무엇이 아픈지"를 한 문장으로 말합니다. 여기서 추측을 시작하면 30분을 잃습니다 1분 범위를 자릅니다 전체인가 한 엔드포인트인가 한 존인가 한 버전인가 — sum by 로 세 번 잘라 봅니다 2분 최근 변경을 봅니다 배포 · 설정 변경 · 피처 플래그 · 인프라 작업. 대시보드의 배포 세로선이 여기서 값을 합니다 3분 로그로 사건을 확인합니다 에러 문구를 집계해 "새로 생긴 문구"를 찾습니다. 늘 있던 에러는 범인이 아닙니다 4분 트레이스로 구간을 좁힙니다 느린 요청 3건의 공통 스팬을 봅니다. 자기 시간이 큰 스팬이 범인입니다 5분 완화 조치를 먼저 합니다 롤백 · 스케일아웃 · 기능 끄기 · 트래픽 차단. 원인 규명은 회복한 다음입니다 5분 안에 안 좁혀지면 ① 범위를 다시 넓힙니다(상위 의존·네트워크·DNS) ② 혼자 하지 말고 사람을 부릅니다
순서를 지키는 이유 — 사람은 압박을 받으면 가장 최근에 본 것을 원인이라고 믿습니다.

범위를 자르는 세 번의 쿼리 — 복붙

# ① 어느 서비스·엔드포인트인가
topk(10, sum by (service, route) (
  rate(http_requests_total{status=~"5.."}[5m])))

# ② 어느 버전인가 — 배포가 원인인지 즉시 판별 ★
sum by (version) (rate(http_requests_total{status=~"5.."}[5m]))
#   한 버전에만 몰려 있으면 → 롤백이 답입니다 (원인 분석 전에)

# ③ 어느 인스턴스·존인가 — 한 대만인지 전체인지
sum by (instance) (rate(http_requests_total{status=~"5.."}[5m]))
#   한 대만이면 → 그 인스턴스를 빼는 것으로 즉시 완화됩니다

# ④ 우리 문제인가 남의 문제인가 (외부 의존)
sum by (peer_service) (rate(http_client_requests_seconds_count{status=~"5.."}[5m]))

# ⑤ 트래픽 자체가 변했나 (원인인지 결과인지 구분) ★
sum(rate(http_requests_total[5m]))
  / sum(rate(http_requests_total[5m] offset 1d))
⑤가 의외로 중요합니다. 에러율이 오른 이유가 "에러가 늘어서"가 아니라 "정상 요청이 줄어서" 인 경우가 있습니다 — 앞단(CDN·LB·DNS)이 이미 막고 있는 상황이죠. 분자와 분모를 따로 보는 습관이 이런 착오를 막습니다.

로그에서 "새로 생긴 것" 찾기

# ① 에러 문구별 건수 — 무엇이 급증했나
topk(10, sum by (err) (
  count_over_time({service="api"} | json | level="error" [10m])))

# ② 어제 같은 시간과 비교 — "원래 있던 에러"를 걸러 냅니다 ★
sum by (err) (count_over_time({service="api"} | json | level="error" [10m]))
  unless
sum by (err) (count_over_time({service="api"} | json | level="error" [10m] offset 1d))

# ③ 특정 요청 한 건을 끝까지 (여러 서비스에 걸쳐)
{env="prod"} | json | trace_id="4bf92f35..."

# ④ 느린 요청만 뽑아 라우트별로
{service="api"} | json | duration_ms > 2000 | line_format "{{.route}} {{.duration_ms}}"
"에러 로그가 많다"는 정보가 아닙니다. 운영 시스템에는 항상 에러 로그가 흐릅니다(재시도·타임아웃·봇 요청). 의미 있는 신호는 "평소에 없던 문구" 와 "평소보다 N배" 입니다. 그래서 offset 1d 비교가 장애 대응에서 가장 유용한 도구 중 하나입니다.

사례 1 — 지연이 갑자기 3배 🔴 실무

진단 경로

단계본 것 → 알아낸 것
알림ApiHighLatencyP95 — p95 가 280ms → 900ms
범위sum by (route) → /orders 만. 다른 라우트는 정상 → 공통 인프라 문제 아님
버전sum by (version) → 두 버전 모두 느림 → 배포가 원인이 아님
변경배포 없음. 그런데 같은 시각에 트래픽 1.6배 증가(마케팅 푸시)
자원CPU 40%로 여유. 그런데 커넥션 풀 대기 시간 급증 → 포화 신호(y02 USE)
트레이스★ 폭포수에서 스팬 앞의 빈틈 400ms — 코드가 아니라 풀에서 커넥션을 기다린 시간
완화풀 크기 20 → 40, 인스턴스 2대 추가 → 5분 내 회복
근본느린 쿼리 하나가 커넥션을 오래 잡고 있었음 → 인덱스 추가로 해결
배운 것 — "CPU 가 여유롭다"는 정상의 증거가 아닙니다. 대기(포화)는 CPU 사용률에 안 나타납니다. 커넥션 풀·스레드 풀·큐 깊이를 재고 있지 않으면 이 장애는 미궁이 됩니다.

이 사례를 위해 미리 재고 있어야 하는 것

# 커넥션 풀 (HikariCP · Micrometer 가 자동 노출)
hikaricp_connections_active
hikaricp_connections_pending          # ★ 이게 0 이 아니면 이미 아픕니다
hikaricp_connections_acquire_seconds_bucket

# 알림 — 대기가 생기면 티켓
sum(hikaricp_connections_pending) > 0

# 지연의 원인 분해 — 세 개를 나란히 그려 두면 즉시 보입니다 ★
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
histogram_quantile(0.95, sum by (le) (rate(db_query_duration_seconds_bucket[5m])))
histogram_quantile(0.95, sum by (le) (rate(hikaricp_connections_acquire_seconds_bucket[5m])))
#   ① 전체만 오름 → 앱 코드 · GC
#   ② DB 도 함께 오름 → DB 쪽 문제
#   ③ 획득 시간만 오름 → 풀 부족 (이번 사례)
"지연을 세 층으로 나눠 그린 패널" 하나가 이 사례의 진단 시간을 20분에서 1분으로 줄입니다. 대시보드를 만들 때 패널 수보다 이런 조합에 시간을 쓰세요.

사례 2 — 메모리가 계속 자란다 🔴 실무

진단 경로

단계본 것 → 알아낸 것
증상파드가 이틀에 한 번 OOM 으로 재시작. 사용자는 잠깐 502
메트릭process_resident_memory_bytes 가 톱니 없이 계속 우상향 → 캐시가 아니라 누수
구분GC 후에도 힙 사용량 하한선이 계속 올라감 → 진짜 누수(회수 안 되는 참조)
상관재시작 주기가 트래픽이 아니라 시간에 비례 → 요청량과 무관한 축적
프로파일★ 힙 프로파일을 6시간 간격 두 번 떠서 diff → HashMap 하나가 계속 커짐
원인요청마다 캐시 키를 만료 없이 넣는 코드. 키에 trace_id 가 섞여 무한 증가
완화메모리 한계 상향 + 재시작 스케줄로 버티고, 다음 배포에서 TTL 캐시로 교체
누수와 "정상적인 증가"를 구분하는 법 — ① 톱니 모양이면 GC 가 회수하는 중(정상) ② 하한선이 우상향이면 누수 ③ 계단식이면 캐시가 채워지는 중(정상일 수 있음) ④ 재시작 주기가 트래픽 비례면 요청당 누수, 시간 비례면 백그라운드 작업 누수.

누수 조사 명령 모음

# Go — 두 시점의 힙을 떠서 차이만 보기 ★
curl -s localhost:6060/debug/pprof/heap > h1.pb.gz
sleep 21600
curl -s localhost:6060/debug/pprof/heap > h2.pb.gz
go tool pprof -http=:8081 -base h1.pb.gz h2.pb.gz   # 늘어난 것만 보입니다

# JVM — 힙 히스토그램의 차이
jcmd <PID> GC.class_histogram > a.txt
sleep 21600
jcmd <PID> GC.class_histogram > b.txt
diff <(head -30 a.txt) <(head -30 b.txt)

# JVM — 정말 안 잡히면 힙 덤프 (운영에서는 정지 시간 주의!)
jcmd <PID> GC.heap_dump /tmp/heap.hprof
#   → Eclipse MAT 의 "Leak Suspects" 리포트가 대개 바로 답을 줍니다

# Python
py-spy dump --pid 12345
# tracemalloc 스냅샷 두 개를 compare_to() 로 비교
# 누수를 조기에 잡는 알림 — 절대값이 아니라 "기울기"로 ★
deriv(process_resident_memory_bytes[6h]) > 0
  and
predict_linear(process_resident_memory_bytes[6h], 12*3600)
  > on(pod) max by (pod) (container_spec_memory_limit_bytes{container!=""})
# → "12시간 뒤 한계를 넘을 추세" 일 때만 티켓
#   (앱 메트릭의 instance 는 앱 주소, cAdvisor 의 instance 는 노드라 instance 로는 안 맞습니다 —
#    pod 라벨로 짝짓고, 오른쪽은 파드당 하나가 되게 max by (pod))

사례 3 — 의존 서비스 장애가 우리를 무너뜨림 🔴 실무

진단 경로

단계본 것 → 알아낸 것
알림여러 서비스에서 동시에 5xx · 지연 알림 — "전체 장애처럼" 보임
범위sum by (service) → 5개 서비스가 동시에. 공통점은 결제 API 호출
서비스 그래프★ Tempo 서비스 맵에서 payment 로 가는 간선만 빨감 — 1초 만에 판별
외부 지표peer_service="payment" 의 타임아웃 급증. 우리 자원은 전부 정상
연쇄payment 대기 때문에 우리 스레드 풀이 고갈 → 무관한 요청까지 실패(연쇄 장애)
완화① 타임아웃 10s → 2s ② 서킷브레이커 열기 ③ 결제 없는 기능은 폴백으로 계속 서비스
근본격벽(bulkhead) 도입 — 외부 호출용 스레드 풀을 분리해 다른 요청을 지킴
"우리 문제인가 남의 문제인가"를 30초 안에 답할 수 있어야 합니다. 그러려면 외부 호출을 대상별로 재고 있어야 합니다 — http_client_requests_seconds{peer_service} 처럼요. 이게 없으면 남의 장애를 우리 코드에서 몇 시간 찾습니다.

사후 분석(포스트모템) 양식

# 장애 보고서 — 2026-08-25 결제 연동 지연

## 요약 (3줄)
- 무슨 일이: 결제 API 응답 지연으로 주문/장바구니 5xx 급증
- 얼마나:   14:12~14:51 (39분) · 영향 요청 약 12만 건 · 실패율 최대 34%
- 왜 끝났나: 서킷브레이커 수동 개방 + 타임아웃 단축 배포

## 사용자 영향
- 주문 생성 실패 8.2만 건 · 에러 예산 30일치의 61% 소진
- 고객 문의 41건 · 결제 중복 없음(멱등 키로 방어됨)

## 타임라인 (관측 데이터 기준으로 씁니다 — 기억이 아니라) ★
14:12  payment 호출 p95 0.4s → 6s (트레이스)
14:14  ApiHighErrorRate 발화 (알림)
14:19  온콜 확인 시작 — 이 7분 지연이 개선 대상 ①
14:23  서비스 맵에서 payment 간선 이상 확인
14:31  결제사에 문의 · 사내 공지
14:38  타임아웃 2s 로 단축 배포 시작
14:51  에러율 정상화

## 원인 (기술적 · 5 Whys)
1. 왜 5xx?      스레드 풀 고갈로 요청을 못 받음
2. 왜 고갈?     결제 호출이 10초 타임아웃까지 스레드를 점유
3. 왜 10초?     기본값을 그대로 둠 — 리뷰에서 아무도 안 봄
4. 왜 전파?     외부 호출과 내부 처리가 같은 풀을 공유
5. 왜 늦게 알았나? 외부 의존 지연 알림이 없었음

## 액션 아이템 (담당자 · 기한 · 검증 방법을 반드시)
[ ] 외부 호출 전용 스레드 풀 분리        @kim  9/5  부하 테스트로 검증
[ ] 모든 외부 호출 타임아웃 2s 상한 강제  @lee  9/2  린트 규칙 추가
[ ] peer_service 별 지연 알림 추가        @park 8/29 스테이징에서 발화 확인
[ ] 온콜 응답 시간 단축(푸시 채널 변경)   @choi 8/30

## 잘 된 것 (반드시 쓰세요)
- 멱등 키 덕분에 결제 중복이 없었음
- 트레이스가 있어 원인 판별에 4분밖에 안 걸림

## 비난하지 않기
개인의 실수가 아니라 "그 실수가 가능했던 구조"를 고칩니다.
장애 중에 하지 말아야 할 것 · 여러 조치를 동시에 — 무엇이 효과였는지 영영 모릅니다 · 상태 수집 없이 재시작 — 힙 덤프·스레드 덤프를 먼저 뜨세요. 재시작하면 증거가 사라집니다 · 대시보드만 계속 새로고침 — 3분 봐서 안 보이면 다음 신호로 넘어가세요 · 혼자 오래 붙잡기 — 15분 지나면 무조건 사람을 부릅니다 · 원인 분석 우선 — 회복이 먼저입니다. 롤백에 자존심을 걸지 마세요.
정리 — 5분 절차는 알림 읽기 → 범위 자르기 → 최근 변경 → 로그 → 트레이스 → 완화입니다. 각 단계는 다음 단계의 후보를 줄이는 것이 목적이지 정답을 찾는 게 아닙니다. "우리 문제인가 남의 문제인가" 를 30초에 답할 수 있게 외부 호출을 재 두고, 끝나면 관측 데이터로 쓴 타임라인과 검증 방법이 있는 액션 아이템으로 포스트모템을 남기세요. 그 액션 아이템이 다음 장애의 관측 항목이 됩니다.
2026.08 기준 · Nginx 1.28 · Apache 2.4 · Tomcat 11 · Caddy 2.10 · systemd 257 · Fluent Bit 3.x · Vector 0.4x · Loki 3.x

로그는 앱이 아니라
서버가 남기고
서버가 지웁니다

앱 개발자는 log.error(...) 한 줄만 쓰면 끝이지만, 그 줄이 실제로 어디에 떨어지고 · 언제 지워지고 · 누가 볼 수 있는지는 전부 서버 쪽 설정입니다. 이 탭은 애플리케이션 코드를 한 줄도 고치지 않고 로그 운영을 세우는 법을 다룹니다 — Nginx log_format 문법 전체 · Apache/Tomcat/Caddy 의 차이 · syslog 와 journald · logrotate 가 로그를 잃는 순간 · 수집기 다섯 개의 실제 차이 · 저장소 네 가지의 비용 구조 · 요청 ID 로 서버 여러 대를 잇는 법 · 개인정보 마스킹과 감사 로그 · 컨테이너 로그 · 그리고 장애 조사 절차까지. 메트릭과 추적은 여기서 다루지 않습니다 — 그건 🔭 관측 · 모니터링 탭의 몫이고, 이 탭은 로그 하나만 끝까지 팝니다.

12SECTIONS
5수집기 비교
4저장소 비교
0앱 코드 수정
LOG 01

로그는 네 곳에서 나옵니다 — 전체 지도부터

"로그 좀 봐 주세요"라는 말은 서버에서 네 가지 서로 다른 것을 가리킬 수 있습니다. 앱이 뱉은 줄, 웹서버가 남긴 요청 기록, OS 데몬이 남긴 기록, 커널이 남긴 기록입니다. 어디를 열어야 하는지 아는 것이 로그 운영의 절반입니다.

로그가 나오는 네 곳 — 어디를 열어야 하는지부터 정합니다 ① 앱 프로세스 stdout · stderr — 파일을 열지 않음 ② 웹서버 · WAS access_log · error_log 파일 ③ OS 데몬 syslog · journald (sshd · cron) ④ 커널 링 버퍼 · dmesg (OOM · 디스크) 수집기 노드마다 하나(에이전트) 파싱 · 라벨 · 마스킹 버퍼 · 재시도 로그 저장소 Loki · ES · S3 조회 Grafana Kibana access_log 는 "요청 1건 = 1줄", error_log 는 "서버가 겪은 사고" — 섞어 두면 둘 다 못 씁니다 앱은 파일을 열지 않습니다 — 회전 · 전송 · 보관은 전부 바깥의 일 그래야 컨테이너에서도 VM 에서도 같은 설정이 그대로 돕니다
로그 지도 — 네 원천을 한 줄기로 모으는 것이 로그 운영의 전부입니다.

리눅스 서버에서 실제로 열어 보는 자리

# ② 웹서버 — 요청 기록
/var/log/nginx/access.log        # 요청 한 건에 한 줄
/var/log/nginx/error.log         # 업스트림 끊김 · 설정 오류 · 권한 문제
/var/log/apache2/{access,error}.log
$CATALINA_BASE/logs/localhost_access_log.YYYY-MM-DD.txt
$CATALINA_BASE/logs/catalina.out          # Tomcat 은 stdout 이 여기로 갑니다

# ③ OS 데몬 — 배포판마다 이름이 다릅니다
journalctl -u nginx              # systemd 계열(요즘은 여기부터)
/var/log/syslog                  # 데비안 · 우분투
/var/log/messages                # RHEL · 로키 · 아마존리눅스
/var/log/auth.log                # 로그인 · sudo (RHEL 은 secure)

# ④ 커널 — 재부팅하면 사라집니다(영구화는 journald 몫)
dmesg -T --level=err,warn
journalctl -k -b -1              # 직전 부팅의 커널 로그 ★ OOM 추적의 시작

# 지금 이 서버에서 로그가 얼마나 쌓이고 있나
du -sh /var/log/* | sort -h | tail
journalctl --disk-usage
장애 때 여는 순서 — ② 웹서버 error_log → ① 앱 로그 → ③ OS → ④ 커널. 웹서버 error_log 한 줄이 "앱이 죽었다"인지 "앱이 느리다"인지 "설정이 틀렸다"인지를 대부분 바로 알려 줍니다. 커널까지 내려가야 하는 경우는 OOM Killer · 디스크 가득 · NIC 문제 셋 정도입니다.

네 원천의 성격이 완전히 다릅니다

원천양버리면어디로 나가나
앱중~많음사업 사고stdout(권장)
access가장 많음조사 불가파일 · syslog
error적음치명적파일 · stderr
OS 데몬적음보안 사고journald
커널아주 적음원인 미상ring buffer
양의 90%는 access 로그입니다. 그래서 비용 이야기도, 샘플링 이야기도 거의 전부 access 로그 이야기입니다(lg10). 반대로 error_log · 커널 로그는 전부 남겨도 용량이 안 됩니다 — 여기서 아끼면 손해만 봅니다.
이 탭에서 다루지 않는 것 — 메트릭(Prometheus) · 분산 추적(OpenTelemetry) · 알림 · SLO 는 🔭 관측 · 모니터링 탭에 있습니다. 앱 코드에서 구조화 로그를 찍는 법(structlog · pino · logback)도 그쪽입니다. 여기서는 이미 나온 로그를 서버가 어떻게 다루는가만 봅니다.

가장 먼저 할 일 — 디스크가 차서 서버가 멈추는 것을 막기 🟢 기초

실제로 가장 흔한 로그 장애

# 증상: 갑자기 500 이 쏟아지고, 앱은 살아 있는데 아무것도 안 됨
df -h /var                      # Use% 100% ★ 십중팔구 이것입니다
df -i /var                      # inode 가 먼저 차는 경우도 있습니다

# 누가 먹었나 — 큰 것부터
du -xh /var/log --max-depth=2 | sort -h | tail -20

# ★ 지운 파일인데 용량이 안 돌아온다면: 프로세스가 아직 붙잡고 있습니다
lsof +L1 | grep -i deleted      # (deleted) 인데 크기가 GB 인 것
#   → 그 프로세스에 로그 재오픈 신호를 주거나 재시작해야 회수됩니다

# 급할 때(주의: 이력이 사라집니다)
truncate -s 0 /var/log/nginx/access.log     # rm 말고 truncate ★
journalctl --vacuum-size=500M
rm access.log 는 용량을 되돌려 주지 않습니다. Nginx 가 그 파일의 파일 디스크립터를 붙잡고 있어서, 디렉터리에서만 사라지고 디스크는 그대로입니다. 급할 땐 truncate -s 0, 정석은 logrotate + 재오픈 신호(lg06)입니다.

새 서버를 받으면 30분 안에 해 두는 것

# ① /var/log 를 별도 파티션·볼륨으로 (루트가 차면 OS 가 멈춥니다)
lsblk; findmnt /var/log

# ② journald 상한 — 안 걸어 두면 파일시스템의 10%까지 먹습니다
sudo tee /etc/systemd/journald.conf.d/limit.conf <<'EOF'
[Journal]
Storage=persistent
SystemMaxUse=1G
SystemMaxFileSize=128M
MaxRetentionSec=14day
EOF
sudo systemctl restart systemd-journald

# ③ logrotate 가 실제로 도는지 확인 (배포판마다 cron 또는 timer)
systemctl list-timers logrotate.timer
sudo logrotate -d /etc/logrotate.conf | tail -40    # -d = 예행연습

# ④ 디스크 사용률 알림을 먼저 겁니다 (85% 에서)
#    로그 알림보다 이 알림이 먼저 필요합니다
순서가 중요합니다 — 수집기를 깔기 전에 상한부터 겁니다. 수집기를 먼저 깔면 "로그를 다 보내니 안심"하다가 전송이 막힌 날 디스크가 터집니다. 버퍼도 결국 디스크입니다.
정리 — 로그는 앱 · 웹서버 · OS · 커널 네 곳에서 나옵니다. 장애 때는 웹서버 error_log 부터 열고, 양의 90%는 access 로그라는 것을 기억하세요. 그리고 무엇보다 상한(journald SystemMaxUse · logrotate)을 먼저 걸어 둡니다 — 로그 때문에 나는 장애의 대부분은 내용이 아니라 용량입니다.
LOG 02

Nginx log_format — 기본값은 반드시 바꿔야 합니다

Nginx 가 기본으로 쓰는 combined 포맷에는 이 요청이 몇 초 걸렸는지가 없습니다. 어느 백엔드가 처리했는지도 없습니다. 장애 조사에서 막히는 이유의 절반이 여기서 옵니다 — 먼저 이 한 줄을 고칩니다.

combined 한 줄의 해부 — 여덟 조각뿐입니다 $remote_addr $remote_user $time_local $request $status $body_bytes_sent $http_referer $http_user_agent 10.1.2.3 - - [31/Aug/2026:15:04:22 +0900] "GET /orders HTTP/1.1" 200 812 "-" "curl/8.9" 없어서 막히는 것 다섯 개 $request_time — 이 요청이 몇 초 걸렸나 $upstream_response_time — 그중 앱이 쓴 시간 $upstream_addr — 어느 백엔드가 처리했나 $request_id — 이 요청의 고유 번호 $http_x_forwarded_for — 진짜 클라이언트 IP 넣으면 답할 수 있게 되는 질문 느린 요청 상위 20개는 어느 경로인가 느린 원인이 앱인가 네트워크인가 5xx 가 백엔드 한 대에서만 나는가 고객이 말한 그 한 건은 어느 줄인가 프록시 뒤에서 누가 요청했는가 기본값을 그대로 쓰면 "느리다"는 신고에 로그로는 아무 답도 못 합니다 필드 다섯 개를 더하는 비용은 한 줄당 약 60바이트 — 그만한 값을 합니다
해부도 — combined 에는 시간과 업스트림이 통째로 빠져 있습니다.

문법 — log_format 과 access_log

# log_format 이름 [escape=default|json|none] '문자열' ['이어지는 문자열'];
#   · http 블록에만 쓸 수 있습니다 (server · location 안에서는 정의 불가)
#   · 작은따옴표 문자열을 여러 줄로 이어 쓰면 그대로 붙습니다
#   · escape=json 은 값 안의 " \ 제어문자를 JSON 규칙으로 이스케이프 (1.11.8+)

log_format main '$remote_addr - $remote_user [$time_local] '
                '"$request" $status $body_bytes_sent '
                '"$http_referer" "$http_user_agent" '
                'rt=$request_time urt=$upstream_response_time '
                'up=$upstream_addr id=$request_id';

# access_log 경로 [포맷 [buffer=크기] [gzip[=1~9]] [flush=시간] [if=조건]];
#   · http · server · location · if · limit_except 안에서 쓸 수 있습니다
#   · 안쪽에 하나라도 쓰면 바깥 설정을 "덮어씁니다"(상속이 아니라 대체) ★
access_log /var/log/nginx/access.log main buffer=64k flush=5s;

# 여러 곳에 동시에 쓸 수도 있습니다 (파일 + 원격 syslog)
access_log /var/log/nginx/access.log main;
access_log syslog:server=10.0.0.9:514,facility=local7,tag=nginx,severity=info main;

access_log off;                       # 이 location 만 끄기
buffer= 와 flush= 를 함께 쓰세요. buffer 만 주면 버퍼가 찰 때까지 디스크에 안 내려갑니다 — 트래픽이 적은 새벽에 장애가 나면 마지막 몇 분이 안 보입니다. flush=5s 를 붙이면 5초마다 강제로 내려갑니다. 초당 수천 요청이면 버퍼링만으로 디스크 I/O 가 눈에 띄게 줄어듭니다.

실무에서 실제로 쓰는 변수 모음

변수무엇
$time_iso86012026-08-31T15:04:22+09:00 — 정렬·파싱에 유리
$msec밀리초까지의 유닉스 시각
$request_time첫 바이트 수신 ~ 마지막 바이트 전송(초, 소수 3자리)
$upstream_connect_time백엔드에 TCP·TLS 연결까지
$upstream_header_time백엔드 응답 헤더까지 (= 앱 처리 시간)
$upstream_response_time백엔드 응답 전체까지
$upstream_addr실제로 간 백엔드 (재시도하면 쉼표로 여럿)
$upstream_status백엔드가 준 상태코드 (Nginx 가 바꿨을 수 있음)
$upstream_cache_statusHIT · MISS · BYPASS · EXPIRED · STALE
$request_id요청마다 생기는 16바이트 난수(32자 16진, 1.11.0+)
$request_length요청 줄+헤더+본문 바이트
$bytes_sent헤더까지 포함한 총 전송 바이트
$connection / $connection_requests연결 번호 / 그 연결에서 처리한 요청 수
$ssl_protocol / $ssl_cipherTLS 버전·암호군 (TLS 1.3 전환 확인용)
$http_x_forwarded_for신뢰할 수 없음 — realip 모듈과 함께
$host / $server_name요청한 호스트 / 매칭된 서버 블록
$pipep = 파이프라인 요청
변수는 만들어 쓸 수도 있습니다. map 으로 계산한 변수도 log_format 에 그대로 넣을 수 있어서, "사업 구분"이나 "요청 유형"처럼 로그에만 필요한 필드를 붙일 수 있습니다.

시간 변수 네 개 — 차이를 보면 원인이 갈립니다 🟡 중급

한 요청의 1.84초는 어디에 쓰였나 — 네 변수가 구간을 나눕니다 0 0.5 1.0 1.5 2.0 초 upstream_connect 0.008 — 연결은 문제없음 upstream_header 1.33 — 앱이 여기서 다 씀 upstream_response 1.41 request_time 1.84 0.43 = 클라이언트 구간 request_time − upstream_response_time 이 크다 = 앱이 아니라 회선·모바일·거대 응답 본문 문제 upstream_header_time 이 대부분 = 진짜 앱이 느린 것 · connect_time 이 크다 = 백엔드 포화나 TLS 핸드셰이크
워터폴 — 세 변수의 뺄셈만으로 "앱이 느린지 회선이 느린지"가 갈립니다.

주의할 함정 세 가지

# ① upstream_* 는 "요청 시작"이 아니라 "업스트림에 붙은 시점"부터 잽니다.
#    셋은 누적값이고, 재시도가 있으면 쉼표로 여러 개가 찍힙니다.
#    upstream_response_time: 0.012, 1.402   ← 첫 백엔드 실패 후 재시도
#    upstream_addr:          10.0.1.5:8080, 10.0.1.6:8080

# ② request_time 은 "클라이언트가 본문을 다 보낼 때까지"도 포함합니다.
#    느린 모바일 업로드 하나가 request_time 30초로 찍힙니다 — 앱 잘못이 아닙니다.
#    → 알림은 upstream_header_time 으로 걸고, request_time 은 참고로 봅니다.

# ③ 액세스 로그는 "응답이 끝난 뒤"에 기록됩니다.
#    앱이 응답을 못 주고 매달려 있으면 access.log 에는 아무것도 없습니다.
#    → 그 순간을 보려면 error_log 와 커넥션 상태(stub_status)를 봐야 합니다.

# 검증: 오래 걸린 요청만 뽑아 두 값을 비교
awk '$0 ~ /rt=/ {for(i=1;i<=NF;i++){if($i~/^rt=/)r=substr($i,4);
     if($i~/^urt=/)u=substr($i,5)}; if(r+0>1) print r, u, $7}' access.log \
  | sort -rn | head -20

지금 바로 복붙할 수 있는 설정

# /etc/nginx/conf.d/log.conf  — http 블록에서 include
log_format main escape=default
  '$remote_addr $http_x_forwarded_for - $remote_user [$time_iso8601] '
  '"$request" $status $body_bytes_sent $request_length '
  '"$http_referer" "$http_user_agent" '
  'host=$host rt=$request_time uct=$upstream_connect_time '
  'uht=$upstream_header_time urt=$upstream_response_time '
  'ua=$upstream_addr us=$upstream_status cs=$upstream_cache_status '
  'id=$request_id conn=$connection:$connection_requests '
  'tls=$ssl_protocol';

access_log /var/log/nginx/access.log main buffer=64k flush=5s;
error_log  /var/log/nginx/error.log warn;

# 파일 핸들 캐시 — 가상호스트가 많고 로그 파일이 수십 개일 때만 의미 있습니다
open_log_file_cache max=1000 inactive=20s valid=1m min_uses=2;

# 적용 전에 반드시 문법 검사 → 무중단 재적용
nginx -t && nginx -s reload
왜 escape=default 인가 — 사람이 읽는 텍스트 포맷에서는 기본 이스케이프가 제어문자를 \x22 형태로 바꿔 줍니다. JSON 으로 갈 거면 반드시 escape=json(다음 섹션) — 안 그러면 User-Agent 안의 따옴표 하나가 그 줄 전체를 깨뜨립니다.
access_log 는 상속이 아니라 "가장 안쪽 하나만" 적용됩니다. server 블록에 하나, location /api 에 하나를 두면 /api 요청은 server 쪽 로그에 안 남습니다. 두 곳 다 남기려면 안쪽에 두 줄을 모두 적어야 합니다.
정리 — combined 는 시간과 업스트림이 빠진 반쪽입니다. $request_time · $upstream_*_time · $upstream_addr · $request_id 네 가지를 넣는 순간 "느리다"는 신고에 앱인지 회선인지 특정 서버인지를 로그만으로 답할 수 있게 됩니다. 그리고 buffer=64k flush=5s 로 디스크 I/O 를 줄이되 마지막 몇 분을 잃지 않게 합니다.
LOG 03

JSON 로그 · error_log · 그리고 소음 지우기

로그를 기계가 읽게 만들려면 JSON 한 줄이 정답이고, 비용을 줄이려면 헬스체크와 정적 파일을 아예 안 남기는 것이 첫 수입니다. Nginx 는 두 가지를 escape=json 과 map + if= 로 해결합니다.

하루 요청 1,000만 건 — 무엇을 남기고 무엇을 버리나 들어온 요청 헬스체크 · /metrics — 180만 if=$loggable 로 아예 안 씁니다 정적 파일 · 이미지 — 320만 location 에 access_log off; 2xx 성공 — 450만 split_clients 로 1/20 샘플링 남는 줄 22.5만 4xx · 5xx 50만 — 전부 남김 1,000만 → 72.5만 줄(7%). 버린 것은 전부 "봐도 아무것도 안 나오는 줄"입니다
산키 — 밴드 두께가 곧 요금입니다. 굵은 두 갈래는 조사에 쓰이지 않습니다.

JSON 액세스 로그 — 실전 그대로

# http 블록. escape=json 이 핵심입니다 (1.11.8+)
log_format json escape=json
'{'
  '"ts":"$time_iso8601",'
  '"host":"$host",'
  '"method":"$request_method",'
  '"uri":"$uri",'
  '"args":"$args",'
  '"status":$status,'
  '"len":$body_bytes_sent,'
  '"reqlen":$request_length,'
  '"rt":$request_time,'
  '"uct":"$upstream_connect_time",'
  '"uht":"$upstream_header_time",'
  '"urt":"$upstream_response_time",'
  '"ua":"$upstream_addr",'
  '"us":"$upstream_status",'
  '"cache":"$upstream_cache_status",'
  '"cip":"$remote_addr",'
  '"xff":"$http_x_forwarded_for",'
  '"ref":"$http_referer",'
  '"agent":"$http_user_agent",'
  '"id":"$request_id",'
  '"tls":"$ssl_protocol"'
'}';

access_log /var/log/nginx/access.json json buffer=64k flush=5s;
따옴표를 붙일 자리와 안 붙일 자리가 갈립니다. $status·$body_bytes_sent·$request_time 는 항상 숫자라 따옴표 없이 두어야 저장소에서 rt > 1 같은 비교가 됩니다. 반대로 $upstream_response_time 계열은 값이 없으면 -, 재시도하면 "0.01, 1.40" 이라 반드시 문자열로 두어야 JSON 이 안 깨집니다. 이 구분을 틀리면 수집기가 하루치를 통째로 파싱 실패로 버립니다.

error_log — 레벨과 실제로 보게 될 문구

# error_log 경로 [레벨];
#   debug < info < notice < warn < error < crit < alert < emerg
#   "그 레벨 이상"이 기록됩니다. 기본은 error(빌드에 따라 warn).
error_log /var/log/nginx/error.log warn;

# 특정 서버 블록만 자세히 보고 싶을 때 (server · location 에도 쓸 수 있습니다)
server { error_log /var/log/nginx/api_debug.log info; }

# debug 는 --with-debug 로 빌드된 nginx 에서만 동작합니다
nginx -V 2>&1 | tr ' ' '\n' | grep debug

# 소음 줄이기 — 없는 파일 404 를 error_log 에 안 남기기
log_not_found off;          # favicon.ico · robots.txt 로 error.log 가 뒤덮이는 것 방지
log_subrequest off;         # 내부 서브요청까지 액세스 로그에 남기지 않기
error_log 에서 보게 될 문구뜻
upstream timed out (110: ...)앱이 proxy_read_timeout 안에 응답 못 함 → 504
connect() failed (111: Connection refused)앱이 죽었거나 포트가 다름 → 502
no live upstreams모든 백엔드가 실패 처리됨
upstream prematurely closed connection앱이 응답 도중 끊음(크래시·keepalive 불일치)
worker_connections are not enough동시 연결 상한 — 설정을 올려야 함
too many open files파일 디스크립터 상한(LimitNOFILE)
client intended to send too large bodyclient_max_body_size 초과 → 413
a client request body is buffered to a temporary file디스크로 새는 중 — 버퍼 크기 조정
SSL_do_handshake() failedTLS 협상 실패(구형 클라이언트·SNI)

조건부 로깅 — map + if= 로 소음 지우기 🟡 중급

헬스체크·정적 파일·성공 요청 걷어 내기

# http 블록 — map 은 "변수를 만드는 표"입니다. 실행 시점에 계산됩니다.
# ① 헬스체크·메트릭 제외 (첫 매치가 이깁니다)
map $request_uri $not_probe {
    ~^/healthz    0;
    ~^/readyz     0;
    ~^/metrics    0;
    default       1;
}

# ② 성공 응답만 1/20 샘플링 — split_clients 가 해시로 균등하게 나눕니다
split_clients "$request_id" $sample_ok {
    5%      1;      # 5% = 20건 중 1건
    *       0;
}

# ③ 두 개를 합쳐 최종 판단 변수를 만듭니다
map $status $keep_by_status {
    ~^[45]  1;      # 4xx · 5xx 는 무조건 남김
    default $sample_ok;
}
map $not_probe$keep_by_status $loggable {
    "11"    1;      # 프로브가 아니고 + 남길 대상일 때만
    default 0;      # ★ if= 는 값이 0 이거나 빈 문자열이면 기록하지 않습니다
}

# server 블록
access_log /var/log/nginx/access.json json buffer=64k flush=5s if=$loggable;

location ~* \.(js|css|png|jpg|svg|woff2?)$ {
    access_log off;                 # 정적 파일은 CDN·웹서버 지표로 충분합니다
    expires 30d;
}
split_clients 의 기준 변수를 잘 고르세요. $request_id 를 쓰면 요청마다 무작위로 뽑히고, $remote_addr 를 쓰면 같은 IP 는 항상 뽑히거나 항상 안 뽑힙니다. "특정 사용자의 흐름을 통째로 보고 싶다"면 후자가, "전체 분포를 대표하게 뽑고 싶다"면 전자가 맞습니다.

더 쓸 만한 조건들

# ① 느린 요청만 따로 파일로 — 사후 분석이 훨씬 편해집니다
map $request_time $slow {
    default   0;
    ~^[1-9]   1;        # 1초 이상 (문자열 비교라 이 트릭을 씁니다)
}
access_log /var/log/nginx/slow.json json if=$slow;

# ② 사내 IP 나 모니터링 봇 제외
geo $is_internal { default 0; 10.0.0.0/8 1; 172.16.0.0/12 1; }
map $is_internal $ext_only { 1 0; default 1; }

# ③ 봇 트래픽 분리 (합치면 통계가 망가집니다)
map $http_user_agent $is_bot {
    default                       0;
    ~*(bot|crawler|spider|curl)   1;
}

# ④ 로그에 사업 구분을 직접 붙이기 — 나중에 비용 배분에 씁니다
map $host $tenant {
    hostnames;
    shop.example.com   shop;
    admin.example.com  admin;
    default            etc;
}
#   log_format 에 '"tenant":"$tenant",' 를 넣으면 그대로 필드가 됩니다

# 적용
nginx -t && systemctl reload nginx
map 은 공짜가 아닙니다 — 하지만 거의 공짜입니다. 정확 일치는 해시 조회라 사실상 비용이 없고, ~ 정규식은 요청마다 실행됩니다. 정규식 map 을 열 개씩 쌓으면 초당 수만 요청에서 체감됩니다. 정확 일치와 hostnames 를 먼저 쓰고, 정규식은 꼭 필요한 곳만 남기세요.

액세스 로그를 끄기 전에 — 잃는 것을 알고 끄기 🔴 실무

이렇게 끄면 나중에 후회합니다
server {
    access_log off;          # "용량이 커서" 통째로 끔
    ...
}
· 5xx 가 언제 시작됐는지 영영 모릅니다
· 공격 흔적(스캔·크리덴셜 스터핑)이 남지 않습니다
· 국내 개인정보 규정의 접속기록 보관 의무를 못 지킵니다
· 용량 문제는 보존 기간으로 풀 문제였습니다
이렇게 줄입니다
access_log /var/log/nginx/access.json json
           buffer=64k flush=5s if=$loggable;
# + 정적 파일 location 만 off
# + 4xx/5xx 는 100% · 2xx 는 1/20
# + 보존 14일(뜨거움) + 1년(감사용 원본)
· 조사에 필요한 줄은 전부 남습니다
· 용량은 10분의 1 이하로 떨어집니다
· 규정 대응용 원본은 따로 압축 보관합니다
샘플링한 로그로 "비율"을 계산할 때는 반드시 가중치를 되돌리세요. 2xx 를 1/20 로 남겼는데 그대로 세면 에러율이 20배로 부풀어 보입니다. 로그에 "sample_rate":20 같은 필드를 함께 넣어 두고 집계할 때 곱해 주는 것이 정석입니다. 애초에 에러율 같은 비율은 메트릭(Prometheus)으로 재는 것이 맞습니다 — 로그는 "그 한 건"을 찾는 도구입니다(🔭 관측 탭).
정리 — 액세스 로그는 escape=json 으로 JSON 한 줄, 숫자 필드에는 따옴표를 빼고 upstream_* 는 반드시 문자열로 둡니다. 소음은 map + if= 로 지우고, 성공 요청은 split_clients 로 샘플링합니다. 절대 통째로 끄지는 마세요 — 끄는 대신 줄이는 수단이 이렇게 많습니다.
LOG 04

Apache · Tomcat · Caddy — 같은 일을 다른 문법으로

네 서버가 하는 일은 같습니다 — "요청 한 건에 한 줄". 그런데 Caddy 는 처음부터 JSON이고, Tomcat 은 자기가 로그를 회전시키고, Apache 는 파이프로 외부 프로그램에 넘깁니다. 이 차이가 운영 방식을 바꿉니다.

네 서버의 로그 기능 — 진할수록 "기본으로 갖춰져 있음" 기본 포맷 JSON 응답 시간 조건부 자체 회전 원격 전송 Nginx combined escape=json 변수 6종 map + if= 없음 syslog: Apache LogFormat 직접 조립 %D %T env= rotatelogs 파이프 Tomcat Valve JsonValve %D %F condition 날짜별 내장 없음 Caddy JSON 기본 기본값 duration matcher roll_size 플러그인 없음 → 기본 제공 색은 훑어보기용, 칸 안의 설정 이름이 확인용입니다 — 둘 다 있어야 읽힙니다 Nginx 만 자체 회전이 없습니다. 그래서 logrotate 이야기가 항상 따라옵니다(lg06)
매트릭스 — Caddy 는 기본이 JSON, Nginx 는 기본이 최소. 손댈 곳이 정반대입니다.

Apache 2.4 — LogFormat 과 ErrorLogFormat

# 액세스 로그 — %기호가 Nginx 의 $변수 자리입니다
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
CustomLog ${APACHE_LOG_DIR}/access.log combined

# 실무용 — 시간·업스트림·요청 ID 를 더한 버전
LogFormat "%{%Y-%m-%dT%H:%M:%S%z}t %a %{X-Forwarded-For}i %v %m %U%q \
%>s %O %I %{ms}T %{UNIQUE_ID}e \"%{User-Agent}i\"" ext
CustomLog ${APACHE_LOG_DIR}/access.log ext

#  %h 원격 호스트   %a 클라이언트 IP     %A 서버 IP
#  %t 시각          %{포맷}t strftime 지정
#  %r 요청 줄 전체  %m 메서드  %U 경로  %q 쿼리스트링  %H 프로토콜
#  %>s 최종 상태코드 (%s 는 리다이렉트 전 최초 상태) ★ 거의 항상 %>s
#  %b 본문 바이트(0이면 -)   %B 본문 바이트(0이면 0)
#  %O 보낸 총 바이트  %I 받은 총 바이트   (mod_logio 필요)
#  %D 소요 마이크로초  %T 초  %{ms}T 밀리초  %{us}T 마이크로초
#  %L 로그 ID ★ error_log 의 같은 ID 와 짝지어집니다
#  %{UNIQUE_ID}e 요청 고유 ID (mod_unique_id)
#  %v 정규 서버명   %p 포트   %X 연결 종료 상태(X=중단, +=유지, -=닫힘)
#  %{Foo}i 요청 헤더  %{Foo}o 응답 헤더  %{Foo}e 환경변수  %{Foo}C 쿠키

# 에러 로그 — 포맷도 바꿀 수 있습니다(2.4부터)
ErrorLogFormat "[%{u}t] [%-m:%l] [pid %P:tid %T] %7F: %E: [client %a] %M, referer %{Referer}i"
LogLevel warn
# ★ 모듈별로 따로 줄 수 있습니다 (Apache 는 줄 끝 주석 불가 — 주석은 따로 한 줄로)
LogLevel info ssl:warn rewrite:trace3
%L 하나가 Apache 의 숨은 무기입니다. 액세스 로그와 에러 로그에 같은 로그 ID가 찍혀서, "이 500 응답이 남긴 에러 줄"을 정확히 짝지을 수 있습니다. Nginx 에는 이에 해당하는 기능이 없어 $request_id 를 직접 넣어야 합니다.

Apache — 조건부 로깅과 파이프 로그

# ① 조건부 — 환경변수를 세우고 env= 로 거릅니다
SetEnvIf Request_URI "^/(healthz|metrics)$"     dontlog
SetEnvIf Request_URI "\.(gif|jpg|png|css|js)$"  dontlog
SetEnvIf Remote_Addr "^10\."                    dontlog
CustomLog ${APACHE_LOG_DIR}/access.log ext env=!dontlog

# 2.4.19+ 는 표현식도 됩니다 — 훨씬 읽기 좋습니다
CustomLog ${APACHE_LOG_DIR}/slow.log ext "expr=%{TIME_MS} > 1000"

# ② 파이프 로그 — 로그를 파일이 아니라 프로그램의 stdin 으로 넘깁니다
#    Apache 가 그 프로그램을 자식으로 띄우고 죽으면 다시 띄웁니다
CustomLog "|/usr/bin/rotatelogs -l ${APACHE_LOG_DIR}/access.%Y%m%d.log 86400" ext
#    -l = 로컬시간 기준  ·  86400 = 하루마다  ·  100M 처럼 크기로도 가능
#    → logrotate 없이 회전이 끝납니다. 대신 오래된 파일 삭제는 따로 해야 합니다

# ③ syslog 로 바로 (에러 로그)
ErrorLog "syslog:local7"

# ④ 문법 검사 → 무중단 재적용 (USR1 = graceful)
apachectl configtest && apachectl graceful
파이프 로그의 대가 — 그 프로그램이 느리거나 멈추면 Apache 가 같이 멈춥니다. 파이프는 버퍼가 가득 차면 쓰기가 블록되기 때문입니다. rotatelogs 처럼 단순하고 검증된 프로그램만 물리세요. 수집기로 보내고 싶다면 파이프 대신 파일로 쓰고 수집기가 tail 하게 하는 것이 안전합니다.

Tomcat — 액세스 로그는 Valve, 앱 로그는 catalina.out 🟡 중급

AccessLogValve — server.xml

<!-- conf/server.xml 의 <Host> 안 -->
<Valve className="org.apache.catalina.valves.AccessLogValve"
       directory="logs"
       prefix="access" suffix=".log"
       fileDateFormat=".yyyy-MM-dd"
       rotatable="true"
       renameOnRotate="false"
       buffered="true"
       maxDays="14"
       requestAttributesEnabled="true"
       condition="dontlog"
       pattern="%{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}t %a %{X-Forwarded-For}i %m %U%q %s %b %D %S %{X-Request-Id}i" />

<!-- Tomcat 10.1+ · 9.0.x 후반 — JSON 으로 바로 뱉는 Valve -->
<Valve className="org.apache.catalina.valves.JsonAccessLogValve"
       directory="logs" prefix="access" suffix=".json"
       maxDays="14"
       pattern="%h %l %u %t %r %s %b %D %{X-Request-Id}i" />
# 패턴 기호 (Apache 와 비슷하지만 다른 것들이 있습니다)
#  %a 클라이언트 IP   %h 호스트   %l 항상 -   %u 인증 사용자
#  %t 시각  %{포맷}t  ·  %r 요청 줄  %s 상태코드  %b 본문 바이트
#  %D 처리 시간 ★ (Tomcat 10+ 는 마이크로초 · 9 이하는 밀리초)  %T 초  %F 첫 바이트까지의 시간
#  %S 세션 ID   %I 스레드 이름 ★ 스레드 고갈 추적에 유용
#  %{헤더}i 요청 헤더  %{헤더}o 응답 헤더  %{속성}r 요청 속성
#  common · combined 는 미리 정의된 이름입니다

# requestAttributesEnabled="true" 를 켜면
#   RemoteIpValve 가 복원한 진짜 클라이언트 IP 를 %a 가 씁니다
#   (프록시 뒤에 있는 Tomcat 이라면 사실상 필수)

catalina.out 이 계속 커지는 문제

# catalina.out 은 "로그 파일"이 아니라 그냥 stdout 리다이렉트입니다.
#   bin/catalina.sh:  ... >> "$CATALINA_OUT" 2>&1 &
# 그래서 ① 회전이 없고 ② 이름을 바꿔도 Tomcat 은 옛 파일에 계속 씁니다.

# 해법 A — 애초에 안 만들기 (systemd 로 띄우고 journald 에 맡기기) ★ 권장
#   /etc/systemd/system/tomcat.service
#   start 가 아니라 run — 포그라운드로 돌아 catalina.out 리다이렉트가 없고 stdout 이 그대로 journald 로
#   (start + CATALINA_OUT=/dev/null 이면 stdout 이 /dev/null 로 사라져 journald 에도 안 남습니다)
[Service]
Type=simple
ExecStart=/opt/tomcat/bin/catalina.sh run
StandardOutput=journal
StandardError=journal
SyslogIdentifier=tomcat

# 해법 B — logrotate 에 copytruncate 를 씁니다(다음 섹션에서 왜 위험한지 설명)
/opt/tomcat/logs/catalina.out {
    daily rotate 7 compress missingok notifempty copytruncate
}

# 해법 C — conf/logging.properties 로 java.util.logging 을 파일로 분리
handlers = 1catalina.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler
1catalina.org.apache.juli.AsyncFileHandler.maxDays = 14
1catalina.org.apache.juli.AsyncFileHandler.level = FINE
# Spring Boot 내장 Tomcat 이면 이 파일 자체가 없습니다 — 앱 로거(logback)가 담당
Tomcat 로그가 세 군데로 흩어지는 것이 진짜 문제입니다 — access.log(Valve) · catalina.out(stdout) · catalina.YYYY-MM-DD.log(JULI). 여기에 앱의 logback 까지 더하면 넷입니다. 전부 stdout 한 곳으로 모으고 나머지를 끄는 것이 컨테이너 시대의 정답이고, VM 이라면 최소한 수집기가 네 경로를 모두 읽게 설정해 두세요.

Caddy — 아무것도 안 해도 JSON 입니다 🟡 중급

Caddyfile — 로그 블록 전부

shop.example.com {
    reverse_proxy 10.0.1.5:8080

    log {
        output file /var/log/caddy/access.log {
            roll_size     100MiB      # 이 크기가 되면 회전 (기본 100MiB)
            roll_keep     10          # 보관 개수 (기본 10)
            roll_keep_for 720h        # 보관 기간 (기본 90일)
            roll_uncompressed         # 붙이면 gzip 안 함 (기본은 압축)
        }
        format json                   # 기본값 — 안 써도 JSON 입니다
        level  INFO
    }
}

# 사람이 볼 때만 한시적으로
log { format console }

# 전역(런타임) 로그는 따로입니다 — 액세스 로그가 아닙니다
# (이름 없는 전역 옵션 블록은 Caddyfile 의 맨 처음에 와야 합니다)
{
    log {
        output file /var/log/caddy/caddy.log
        level ERROR
    }
}
Caddy 는 logrotate 가 필요 없습니다. 회전이 프로세스 안에 들어 있어서 파일 핸들 문제도, USR1 신호도 없습니다(lg06 의 고민이 통째로 사라집니다). 대신 회전 정책이 Caddyfile 안에 있으니 서버마다 흩어지지 않게 공통 스니펫으로 관리하세요.

Caddy 의 filter — 로그에서 값 가리기

log {
    output file /var/log/caddy/access.log
    format filter {
        wrap json
        fields {
            request>headers>Authorization delete
            request>headers>Cookie       delete
            request>remote_ip            ip_mask 16 32
            request>uri                  query {
                replace token REDACTED
                delete  sid
            }
            request>headers>User-Agent   hash
        }
    }
}
#  delete   — 필드를 통째로 제거
#  replace  — 값을 고정 문자열로 치환
#  ip_mask  — IPv4 상위 16비트만 남김 / IPv6 상위 32비트
#  hash     — 값 대신 해시(같은 값인지 비교는 되고 원본은 모름) ★
#  query / cookie — 쿼리스트링·쿠키 안의 특정 키만 골라 처리
#  regexp   — 정규식으로 부분 치환
이 filter 가 다른 서버에는 없습니다. Nginx·Apache 에서 같은 일을 하려면 수집기 단계에서 지워야 하고, 그 사이에 디스크에는 원본이 이미 적힌 상태가 됩니다. 개인정보 관점에서는 웹서버가 애초에 안 적는 것이 한 단계 앞선 방어입니다(lg11).
정리 — Apache 는 % 기호와 env=, Tomcat 은 AccessLogValve 와 흩어진 네 개의 로그 파일, Caddy 는 손대지 않아도 JSON + 자체 회전입니다. Nginx 만 회전 기능이 없어서 다음 두 섹션(syslog · logrotate)이 특히 중요해집니다.
LOG 05

syslog · journald — OS 가 관리하는 로그

웹서버 로그는 우리가 만든 것이지만, sshd · cron · sudo · systemd · 커널이 남기는 로그는 OS 가 관리합니다. 여기에는 40년 된 규칙(시설 · 심각도)과 요즘 리눅스의 journald가 겹쳐 있습니다. 둘 다 알아야 합니다.

심각도 8단계와 저널이 흘러가는 길 0 emerg — 시스템을 못 씀 1 alert — 즉시 조치 필요 2 crit — 치명적 상태 3 err — 오류 ★ 여기부터 봅니다 4 warning — 경고 ★ 5 notice — 정상이지만 특이 6 info — 정보 ★ 대부분 여기 7 debug — 운영에서는 끕니다 PRI = 시설 × 8 + 심각도 (예: local7.info = 23×8+6 = 190) ① 앱 · 데몬 · 커널 /dev/log · stdout · kmsg ② systemd-journald 구조화 필드로 저장 RateLimit 넘으면 버림 /var/log/journal Storage=persistent · SystemMaxUse=1G ③ rsyslog (선택) ForwardToSyslog=yes 원격 수집 서버 SIEM · 규정 보관 journald 는 바이너리로 저장합니다 — grep 이 아니라 journalctl 로 봅니다 텍스트 파일이 꼭 필요하면 rsyslog 를 함께 돌려 /var/log/*.log 를 만듭니다
journald 는 필드가 붙은 데이터베이스에 가깝습니다 — 그래서 조회가 쿼리처럼 됩니다.

시설(facility) — 40년째 그대로인 24칸

번호이름누가 씁니까
0kern커널 (사용자 프로세스는 못 씀)
1user일반 프로그램 기본값
2mail메일 시스템
3daemon백그라운드 데몬
4auth인증 (로그인·su)
5syslogsyslog 자기 자신
9croncron · at
10authpriv민감한 인증 — 파일 권한이 다릅니다
16~23local0~7자유롭게 쓰라고 비워 둔 8칸 ★
local0~7 을 팀 규칙으로 정해 두세요. 예를 들어 local5=웹서버 액세스, local6=배치, local7=애플리케이션 식으로요. 원격 rsyslog 서버에서 시설만 보고 파일을 나눌 수 있게 됩니다 — Nginx 의 access_log syslog:...,facility=local7 이 바로 이 이야기입니다.

journalctl — 실제로 치는 명령들

# ① 유닛 하나 따라가기 (가장 많이 씁니다)
journalctl -u nginx -f                    # tail -f
journalctl -u nginx -n 200 --no-pager

# ② 시간으로 좁히기 ★ 장애 조사의 시작
journalctl -u nginx --since "2026-08-31 15:00" --until "2026-08-31 15:30"
journalctl --since "1 hour ago"
journalctl --since yesterday --until today

# ③ 심각도로 좁히기 (숫자 또는 이름)
journalctl -p err -b                      # 이번 부팅에서 err 이상
journalctl -p warning..err                # 범위도 됩니다

# ④ 부팅 단위로 — 재부팅 원인을 찾을 때
journalctl --list-boots
journalctl -b -1 -p err                   # 직전 부팅의 오류만 ★
journalctl -k -b -1 | grep -i "out of memory"   # OOM Killer 추적

# ⑤ 필드로 질의 — journald 의 진짜 힘입니다
journalctl _SYSTEMD_UNIT=nginx.service _PID=1234
journalctl _COMM=sshd _UID=0
journalctl -u nginx -o json-pretty | head -40    # 붙어 있는 필드 전부 보기
journalctl -F _SYSTEMD_UNIT               # 이 서버에 있는 유닛 목록

# ⑥ 출력 형식 — 수집기에 넘길 때는 json
journalctl -u nginx -o cat                # 메시지 본문만
journalctl -u nginx -o short-iso          # 시각을 ISO8601 로
journalctl -f -o json                     # 스트리밍 JSON

# ⑦ 용량 — 이것부터 확인하세요
journalctl --disk-usage
sudo journalctl --vacuum-size=500M
sudo journalctl --vacuum-time=7d
sudo journalctl --verify                  # 손상 검사

journald 가 디스크를 먹는 문제 — 기본값이 관대합니다 🟡 중급

journald.conf — 실제로 손대는 항목

# /etc/systemd/journald.conf.d/00-limits.conf  (원본을 고치지 말고 드롭인으로)
# (systemd 설정 파일은 줄 끝 주석을 값으로 읽습니다 — 주석은 항상 따로 한 줄로)
[Journal]
# auto=디렉터리가 있을 때만 영구. persistent 로 못 박습니다
Storage=persistent
Compress=yes
# ★ 기본은 "파일시스템의 10%, 최대 4G" — 관대합니다
SystemMaxUse=1G
# 이만큼은 항상 비워 둡니다
SystemKeepFree=2G
# 한 조각 파일의 크기
SystemMaxFileSize=128M
SystemMaxFiles=100
# 기간 상한 (용량 상한과 함께 걸립니다)
MaxRetentionSec=14day
# 하루마다 새 조각 파일
MaxFileSec=1day

# 폭주 방지 — 이걸 모르면 "로그가 없어졌다"고 착각합니다 ★
# 30초에 10,000줄 넘으면 그 뒤는 버립니다
RateLimitIntervalSec=30s
RateLimitBurst=10000

# rsyslog 를 안 쓰면 꺼서 중복 저장을 막습니다
ForwardToSyslog=no
ForwardToWall=no
sudo systemctl restart systemd-journald
journalctl --disk-usage

# 폭주로 버려진 줄이 있었는지 확인 ★ 이 문구를 꼭 기억하세요
journalctl | grep -i "Suppressed .* messages"
"로그가 중간에 비었다"의 범인이 대개 RateLimitBurst 입니다. 장애 순간에는 로그가 폭발하는데, 바로 그때 journald 가 초과분을 조용히 버립니다. 유닛별로 풀 수도 있습니다 — 서비스 유닛에 LogRateLimitIntervalSec=0 을 주면 그 유닛만 제한이 풀립니다. 가장 중요한 서비스에만 예외를 주세요.

rsyslog — 원격 전송과 파일 만들기

# /etc/rsyslog.d/10-remote.conf  (RainerScript · rsyslog 8.x)

# ① 원격으로 보내기 — 디스크 큐를 반드시 함께 씁니다 ★
*.* action(type="omfwd"
           target="10.0.0.9" port="514" protocol="tcp"
           queue.type="LinkedList"
           queue.filename="fwd_q"          # 디스크로 넘칠 때 쓸 파일
           queue.maxdiskspace="1g"
           queue.saveonshutdown="on"       # 재시작해도 안 잃습니다
           action.resumeRetryCount="-1")   # 무한 재시도

# ② 시설별로 파일 나누기 (Nginx 를 local7 로 보냈다면)
local7.*   /var/log/nginx-remote.log
authpriv.* /var/log/secure

# ③ 파일을 읽어서 보내기 — 웹서버 로그를 rsyslog 로 실어 나를 때
module(load="imfile")
input(type="imfile"
      File="/var/log/nginx/access.json"
      Tag="nginx-access"
      Severity="info" Facility="local7"
      PersistStateInterval="100")         # 어디까지 읽었는지 저장

# ④ 더 확실하게 — RELP 는 응답을 받고서야 지웁니다(TCP 만으로는 유실 가능)
module(load="omrelp")
action(type="omrelp" target="10.0.0.9" port="20514")
2026년에 새로 만드는 시스템이라면 rsyslog 로 파일을 실어 나르지 않습니다. 그 일은 Fluent Bit·Vector 같은 수집기가 더 잘합니다(lg07). rsyslog 가 여전히 필요한 자리는 ① 네트워크 장비·어플라이언스가 syslog 로만 보내는 경우, ② 규정상 syslog 서버로 즉시 전송해야 하는 감사 로그(lg11), ③ 텍스트 /var/log/*.log 를 꼭 유지해야 하는 오래된 운영 절차 셋입니다.
systemd 유닛에서 로그를 어디로 보낼지 정하는 것도 설정입니다. StandardOutput=journal(기본) · append:/var/log/app.log · null 중에서 고를 수 있고, SyslogIdentifier= 로 이름을 붙이면 journalctl -t 이름 으로 바로 뽑힙니다. StandardOutput=append: 를 쓰면 systemd 가 파일을 열어 주므로 앱이 파일 경로를 몰라도 되고, logrotate 후 재오픈은 systemctl restart 또는 copytruncate 가 필요해집니다(다음 섹션).
정리 — syslog 의 시설 24칸 · 심각도 8단계는 지금도 프로토콜의 뼈대입니다. 요즘 리눅스에서는 journald 가 먼저 받고, 필요하면 rsyslog 로 넘깁니다. journalctl -u ... --since ... -p err 세 옵션이면 조사의 90%가 되고, SystemMaxUse 와 RateLimitBurst 두 개는 반드시 손대야 하는 기본값입니다.
LOG 06

logrotate — 로그를 잃는 가장 흔한 경로

로그 파일은 이름을 바꿔도 프로세스는 옛 파일에 계속 씁니다. 유닉스에서 파일의 정체는 이름이 아니라 inode 이기 때문입니다. 이 한 가지를 모르면 회전할 때마다 로그가 조용히 사라집니다.

회전하는 그 몇 초 — 로그는 어디로 가고 있나 logrotate nginx 워커 파일 (inode) ① rename : access.log → access.log.1 이름만 바뀌었을 뿐 inode 는 그대로 — nginx 는 눈치채지 못합니다 이 구간의 줄은 access.log.1 에 들어갑니다. 바로 압축하면 그 줄을 잃습니다 ② postrotate : kill -USR1 $(cat /run/nginx.pid) ③ nginx 가 새 access.log 를 엽니다 ④ delaycompress : 다음 회전 때 access.log.1 을 gzip delaycompress 가 없으면 ②가 끝나기 전에 압축이 시작될 수 있습니다 그래서 "회전 · 신호 · 압축"의 순서가 설정 세 줄에 그대로 담겨 있어야 합니다
시퀀스 — 이름과 inode 가 다른 것이 로그 회전 사고의 뿌리입니다.

설정 문법 — 실제로 쓰는 지시어 전부

# /etc/logrotate.d/nginx
# (줄 끝 설명은 학습용입니다 — 실제 파일에서는 주석을 지시어 뒤가 아니라 따로 한 줄로 두세요)
/var/log/nginx/*.log /var/log/nginx/*.json {
    daily                 # hourly · daily · weekly · monthly · yearly
    rotate 14             # 몇 개를 보관할지 (14일치)
    # size 100M           # 주기를 무시하고 크기만으로 회전 — daily 와 함께 쓰면 뒤에 쓴 쪽이 이겨서 보통은 maxsize 를 씁니다
    maxsize 500M          # 주기 전이라도 이 크기면 회전 (daily 와 조합)
    minsize 1M            # 이보다 작으면 주기가 와도 회전 안 함

    missingok             # 파일이 없어도 오류 내지 않음
    notifempty            # 비어 있으면 회전 안 함
    compress              # gzip (기본 gzip, compresscmd 로 변경 가능)
    delaycompress         # ★ 한 세대 미루고 압축 — 재오픈 전에 압축하는 사고 방지
    compresscmd /usr/bin/zstd
    compressoptions -19 --rm
    compressext .zst

    dateext               # access.log-20260831 형태로 (숫자 대신 날짜)
    dateformat -%Y%m%d
    dateyesterday         # 새벽에 도니 "어제 날짜"로 붙이는 게 자연스럽습니다

    create 0640 www-data adm      # 새 파일을 이 권한·소유자로 미리 만듦
    su root adm                   # 디렉터리 소유자가 root 가 아닐 때 필요
    olddir /var/log/nginx/old     # 회전본을 다른 디렉터리로
    sharedscripts                 # ★ 여러 파일이어도 스크립트는 한 번만
    postrotate
        [ -f /run/nginx.pid ] && kill -USR1 "$(cat /run/nginx.pid)"
    endscript
}

서버별 "재오픈" 방법

대상회전 뒤에 할 일
Nginxkill -USR1 $(cat /run/nginx.pid)
Apacheapachectl graceful (또는 파이프 rotatelogs 로 아예 회피)
HAProxy보통 syslog 로 보내므로 해당 없음
rsyslogsystemctl kill -s HUP rsyslog
Tomcat catalina.out재오픈 수단 없음 → copytruncate 또는 아예 안 만들기
Caddy필요 없음 — 자체 회전
journald필요 없음 — SystemMaxUse 로 관리
컨테이너필요 없음 — kubelet·도커가 회전(lg12)
# 검증 — 실제로 돌리기 전에 반드시
sudo logrotate -d /etc/logrotate.d/nginx    # -d : 예행연습(아무것도 안 바꿈)
sudo logrotate -vf /etc/logrotate.d/nginx   # -f : 지금 강제로 한 번
cat /var/lib/logrotate/status               # 마지막 회전 시각 기록

# 언제 도나 — 배포판마다 다릅니다
systemctl list-timers logrotate.timer       # systemd 계열
ls -l /etc/cron.daily/logrotate             # 옛 방식

# ★ cron.daily 는 보통 새벽에 "한꺼번에" 돕니다.
#   size·maxsize 를 걸어도 하루에 한 번만 검사하므로
#   폭주하는 날엔 디스크가 먼저 찹니다 → 타이머를 시간마다로 바꾸세요.
sudo systemctl edit logrotate.timer         # OnCalendar=hourly

copytruncate 가 로그를 잃는 이유 🔴 실무

copytruncate — 신호를 못 줄 때만
/opt/tomcat/logs/catalina.out {
    daily rotate 7 compress
    copytruncate
}
① 원본을 복사하고 ② 원본을 0바이트로 자릅니다.
· ①과 ② 사이에 들어온 줄은 사라집니다 — 큰 파일일수록 그 틈이 깁니다
· 앱이 O_APPEND 없이 열었다면 오프셋이 그대로라 잘린 파일 앞부분이 널 바이트(sparse)로 채워집니다
· 복사하는 동안 디스크를 두 배로 씁니다
rename + 신호 — 정석
/var/log/nginx/*.log {
    daily rotate 14 compress delaycompress
    missingok notifempty
    create 0640 www-data adm
    sharedscripts
    postrotate
        [ -f /run/nginx.pid ] && \
          kill -USR1 "$(cat /run/nginx.pid)"
    endscript
}
· 한 줄도 잃지 않습니다 — rename 은 원자적이고, 신호 전까지의 줄은 회전본에 온전히 들어갑니다
· delaycompress 가 압축을 한 세대 미뤄 재오픈 전에 건드리지 않게 합니다
· 디스크 여유가 없어도 안전합니다
회전 뒤에 수집기가 파일을 다시 찾지 못하는 사고도 흔합니다. Fluent Bit·Vector·Filebeat 는 inode 로 파일을 추적하기 때문에 rename 방식에서는 문제가 없지만, copytruncate 를 쓰면 같은 inode 의 크기가 갑자기 줄어든 것으로 보여 처음부터 다시 읽거나 (중복!) 그 지점부터 건너뜁니다(유실!). 수집기를 쓴다면 copytruncate 를 피하는 이유가 하나 더 생기는 셈입니다. 회전 주기를 수집기의 Refresh_Interval 보다 충분히 길게 잡는 것도 중요합니다 — 회전본이 압축되어 사라지기 전에 다 읽어야 하니까요.
정리 — 파일의 정체는 이름이 아니라 inode 입니다. 그래서 정석은 rename → 재오픈 신호(USR1) → 한 세대 뒤 압축 순서이고, 설정에 delaycompress · sharedscripts · postrotate 세 줄이 반드시 들어갑니다. copytruncate 는 신호를 줄 방법이 없을 때만(catalina.out) 쓰는 최후 수단입니다.
LOG 07

수집기 다섯 개 — 무엇을 언제 고르나

로그 파일을 읽어 파싱하고 라벨을 붙여 저장소로 보내는 프로그램을 수집기라고 합니다. 다섯 개가 사실상의 후보이고, 고르는 기준은 성능이 아니라 "얼마나 변환할 것인가"와 "이미 무엇을 쓰고 있는가" 입니다.

수집기 비교 — 모든 칸은 "높을수록 좋음"으로 맞췄습니다 메모리 효율 처리량 변환 능력 디스크 버퍼 플러그인 채택 정도 Fluent Bit 매우 높음 높음 보통 높음 보통 사실상 표준 Vector 보통 매우 높음 매우 높음 매우 높음 보통 빠르게 증가 Filebeat 보통 보통 낮음 보통 모듈 다수 ES 쓰면 Fluentd 낮음 낮음 높음 높음 압도적 유지 위주 Promtail 높음 보통 낮음 아주 낮음 Loki 전용 Alloy 로 이전 약함 → 강함 색은 훑어보기용, 칸 안의 말이 확인용입니다 — 정확한 수치는 직접 재세요 채택 정도는 CNCF 연례 설문과 GitHub 스타 추이로 스스로 확인할 수 있습니다
히트맵 — Fluent Bit 은 가볍고, Vector 는 변환이 강합니다. 나머지는 "이미 쓰는 스택"이 정합니다.

다섯 개의 성격 한 줄씩

이름언어성격
Fluent BitC가장 가벼움. 노드마다 하나 띄우는 에이전트의 사실상 표준. 파서·필터가 내장이라 설정만으로 대부분 해결
VectorRust변환이 압도적. VRL 이라는 전용 언어로 파싱·마스킹·라우팅을 다 씁니다. 종단 확인(ack)과 디스크 버퍼가 튼튼
FilebeatGoElastic 스택 전용에 가깝습니다. 변환은 ES 의 ingest pipeline 에 맡기는 설계. 이미 ES 를 쓰면 가장 편함
FluentdRuby+C플러그인이 압도적으로 많습니다. 무겁고 느려서 요즘은 에지가 아니라 중앙 취합기 자리에 남습니다
PromtailGoLoki 전용 에이전트. Grafana Alloy 로 대체되어 신규 채택은 드묾. 기존 설치는 Alloy 마이그레이션 대상
채택 정도를 숫자로 말하지 않는 이유 — 공개된 정확한 점유율 통계가 없습니다. 2026년 8월 기준 대략의 감으로는 쿠버네티스 노드 에이전트에서 Fluent Bit 이 가장 흔하고 Vector 가 빠르게 따라붙는 모양새인데, 직접 확인하는 방법이 있습니다 — CNCF 연례 설문(Annual Survey)의 관측 도구 문항, 각 저장소의 GitHub 스타·릴리스 빈도 추이, 그리고 주요 매니지드 쿠버네티스의 기본 로깅 애드온이 무엇인지. 이 세 가지면 시점에 맞는 판단을 스스로 내릴 수 있습니다.

Fluent Bit — 파일을 읽어 Loki 로

# /etc/fluent-bit/fluent-bit.conf
# (클래식 형식은 줄 끝 주석을 값으로 읽습니다 — 주석은 따로 한 줄로)
[SERVICE]
    Flush            5
    Daemon           Off
    Log_Level        info
    Parsers_File     parsers.conf
    # ★ 디스크 버퍼
    storage.path     /var/lib/fluent-bit/storage
    storage.sync     normal
    storage.max_chunks_up 128

[INPUT]
    Name             tail
    Path             /var/log/nginx/access.json
    Tag              nginx.access
    Parser           json
    # ★ 어디까지 읽었는지
    DB               /var/lib/fluent-bit/tail.db
    DB.locking       true
    Mem_Buf_Limit    32MB
    # 메모리 넘치면 디스크로
    storage.type     filesystem
    Skip_Long_Lines  On
    Refresh_Interval 10

[FILTER]
    Name             modify
    Match            nginx.*
    Add              env prod
    Add              host ${HOSTNAME}

[FILTER]
    Name             lua
    Match            nginx.*
    script           mask.lua
    call             mask_token

[OUTPUT]
    Name             loki
    Match            nginx.*
    host             loki.internal
    port             3100
    labels           job=nginx, env=$env, host=$host
    label_keys       $status
    line_format      json
    # 무한 재시도
    Retry_Limit      False
storage.type filesystem 을 안 켜면 메모리 버퍼가 전부입니다. 저장소가 10분만 죽어도 Mem_Buf_Limit 를 넘긴 순간부터 새 줄을 조용히 버립니다(그리고 tail 이 멈춥니다). 디스크 버퍼를 켜고, 그 디스크에도 상한과 알림을 거세요.

Vector — VRL 로 파싱 · 마스킹 · 라우팅을 한곳에서 🟡 중급

vector.toml

[sources.nginx]
type = "file"
include = ["/var/log/nginx/access.json"]
read_from = "beginning"
fingerprint.strategy = "checksum"       # inode 대신 내용으로 파일을 식별

[sources.journal]
type = "journald"
include_units = ["nginx.service", "sshd.service"]

[transforms.parse]
type = "remap"
inputs = ["nginx"]
source = '''
  . = parse_json!(.message)
  .ts       = parse_timestamp!(.ts, "%+")
  .rt       = to_float!(.rt)
  .status   = to_int!(.status)
  # 업스트림 시간은 "-" 이거나 "0.01, 1.40" 일 수 있습니다
  .urt = if is_string(.urt) && .urt != "-" {
            to_float(split(.urt, ", ")[0]) ?? null
          } else { null }
  # 토큰·인증 헤더 마스킹 — 저장되기 전에 지웁니다
  .uri = replace(string!(.uri), r'token=[^&]+', "token=[REDACTED]")   # string! — JSON 필드는 타입 미정
  del(.agent)                            # User-Agent 는 아예 버림
'''

[transforms.sample]
type = "sample"
inputs = ["parse"]
rate = 20                                # 20건 중 1건
exclude = '''.status >= 400'''           # ★ 오류는 전부 통과

[sinks.loki]
type = "loki"
inputs = ["sample", "journal"]
endpoint = "http://loki.internal:3100"
encoding.codec = "json"                  # loki 싱크의 필수 항목
compression = "snappy"
labels = { job = "nginx", env = "prod", host = "{{ host }}" }
  [sinks.loki.buffer]
  type = "disk"                          # ★ 디스크 버퍼
  max_size = 1073741824                  # 1GiB
  when_full = "block"                    # drop_newest 대신 block (유실 방지)
  [sinks.loki.batch]
  max_bytes = 1048576
  timeout_secs = 2

Vector 를 쓸 때 실제로 편한 것들

# ① 설정을 실제로 돌려 보지 않고 검사
vector validate --config /etc/vector/vector.toml

# ② VRL 한 줄을 즉석에서 시험 ★ 이게 정말 편합니다
#    (입력 파일의 JSON 한 줄이 이벤트 하나 = 프로그램 안의 . 입니다)
echo '{"uri":"/a?token=abc123","status":"500"}' > /tmp/ev.json
vector vrl --input /tmp/ev.json '.status = to_int!(.status); .'

# ③ 지금 흐르는 이벤트를 실시간으로 들여다보기
vector tap --outputs-of parse
vector top                        # 파이프라인별 처리량 · 오류 · 버퍼 사용률

# ④ 설정 안에 단위 테스트를 넣을 수 있습니다
#    [[tests]] 블록에 입력과 기대 출력을 적고
vector test --config /etc/vector/vector.toml
수집기를 두 층으로 두는 구성이 흔합니다. 노드마다 가벼운 에이전트(Fluent Bit)를 두고, 중앙에 취합기(Vector 또는 Fluentd)를 몇 대 두어 변환·샘플링·라우팅은 중앙에서 합니다. 노드 에이전트를 가볍게 유지할 수 있고, 변환 규칙을 한곳에서 바꿀 수 있어서 수백 대에 재배포하지 않아도 됩니다.
수집기 자체의 로그와 지표를 반드시 수집하세요. 수집기가 죽으면 "로그가 안 들어온다"는 사실을 로그로는 알 수 없습니다. Fluent Bit·Vector 모두 Prometheus 지표를 내보내니 "수집기가 마지막으로 보낸 시각"에 알림을 거는 것이 정석입니다(🔭 관측 탭).
정리 — 노드 에이전트는 Fluent Bit, 변환이 많으면 Vector, 이미 Elastic 을 쓰면 Filebeat, 플러그인이 꼭 필요하면 Fluentd, Promtail 은 Grafana Alloy 로 넘어가는 중입니다. 무엇을 고르든 디스크 버퍼를 켜고 · 오프셋 DB 를 영구 볼륨에 두고 · 수집기 자체를 감시하는 세 가지는 똑같이 해야 합니다.
LOG 08

저장소 네 가지 — 색인 방식이 요금표를 정합니다

Loki · Elasticsearch · ClickHouse · 클라우드 서비스는 "무엇을 미리 색인해 두는가"가 서로 다릅니다. 그 한 가지 선택이 저장 비용 · 조회 속도 · 할 수 있는 질문을 통째로 결정합니다.

색인을 얼마나 미리 만들어 두느냐 — 그것이 요금표입니다 Loki 색인 = 라벨(job · env · host)뿐, 본문은 압축 청크 가장 쌈 · 오브젝트 스토리지에 그대로 얹힘 라벨 밖 검색은 그 구간을 전부 풀어서 훑습니다 Elasticsearch 색인 = 모든 필드(역색인) + 집계용 doc_values 임의 검색·집계가 빠름 · RAM 과 CPU 를 많이 씀 저장 용량이 원본보다 커질 수 있습니다 ClickHouse 칼럼 저장 + 강한 압축 · 질의는 그냥 SQL 대량 집계가 압도적으로 빠르고 쌉니다 대신 스키마와 정렬 키를 직접 설계해야 합니다 클라우드 수집량 · 저장량 · 조회 스캔량에 각각 과금 운영 부담이 없고 감사·보존 요건에 유리합니다 양이 커지면 가장 비싸집니다 — 수집 단가가 셉니다 "미리 만든 색인"은 저장 요금이고, "안 만든 색인"은 조회 요금입니다 둘 중 하나는 반드시 냅니다 — 어느 쪽으로 낼지를 고르는 것뿐입니다
저장소 비교 — 색인을 언제 만드느냐가 비용을 저장 쪽과 조회 쪽으로 나눕니다.

고르는 기준 — 질문의 모양으로 정합니다

주로 하는 질문맞는 저장소
"이 서비스의 최근 1시간 에러 줄을 보고 싶다"Loki — 라벨로 좁히면 아주 쌉니다
"이 요청 ID 가 들어간 줄 전부"Loki(시간 범위를 좁히면) · ES
"지난 30일 중 이 문구가 나온 모든 줄"ES/OpenSearch — 전문 검색이 필요
"경로별 p95 를 지난 90일 추이로"ClickHouse — 대량 집계
"임의의 필드 조합으로 자유롭게 탐색"ES/OpenSearch · ClickHouse
"규정상 3년 보관, 조회는 가끔"오브젝트 스토리지 + 필요 시 복원
"팀에 운영할 사람이 없다"클라우드 관리형
대부분의 조직에는 Loki 로 충분합니다. 장애 조사의 90%는 "최근 몇 시간, 특정 서비스, 특정 요청 ID"이고 이건 라벨 색인만으로 충분히 빠릅니다. ES 가 필요한 순간은 "무엇을 찾을지 모른 채 넓게 훑어야 할 때"이고, ClickHouse 가 필요한 순간은 "로그를 분석 데이터로 쓸 때"입니다.

각각을 얹을 때 실제로 손대는 설정

# Loki 3.x — 뜨거운 구간과 보존, 그리고 안전벨트
limits_config:
  retention_period: 336h            # 14일
  max_query_length: 721h
  ingestion_rate_mb: 20
  ingestion_burst_size_mb: 40
  max_streams_per_user: 10000       # ★ 라벨 카디널리티 안전벨트
  max_label_names_per_series: 15
compactor:
  retention_enabled: true
  delete_request_store: s3
schema_config:
  configs:
    - from: 2026-01-01
      store: tsdb
      object_store: s3
      schema: v13
      index: { prefix: index_, period: 24h }
runtime_config:
  file: /etc/loki/overrides.yaml    # 테넌트별 값은 메인 설정이 아니라 이 파일에

# /etc/loki/overrides.yaml
overrides:
  audit:
    retention_period: 8760h         # 감사 로그 테넌트만 1년
// Elasticsearch — ILM 으로 단계 강등 (OpenSearch 는 ISM 이라 형식이 다릅니다)
{
  "policy": { "phases": {
    "hot":   { "actions": { "rollover": { "max_primary_shard_size": "50gb",
                                          "max_age": "1d" } } },
    "warm":  { "min_age": "3d",  "actions": {
                 "forcemerge": { "max_num_segments": 1 },
                 "shrink": { "number_of_shards": 1 } } },
    "cold":  { "min_age": "14d", "actions": {           // freeze 는 8.0 에서 제거됨
                 "allocate": { "number_of_replicas": 0 } } },
    "delete":{ "min_age": "90d", "actions": { "delete": {} } }
  } }
}
Loki 에서 trace_id·user_id 를 라벨로 만들지 마세요. 라벨 조합 하나가 곧 스트림 하나이고, 스트림 수가 폭발하면 Loki 가 Elasticsearch 보다 비싸집니다. 그런 값은 본문에 두고 | json 으로 검색하거나, Loki 3.x 의 structured metadata 에 담습니다.

클라우드 로그 서비스 — 요금이 세 군데에서 붙습니다 🔴 실무

과금 구조를 먼저 이해합니다

붙는 자리성격대응
수집(ingest)보낸 GB 당 — 보통 여기가 제일 큼안 보내는 게 유일한 답
저장GB·월 — 오브젝트 스토리지보다 비쌈보존 기간 · 등급 강등
조회스캔한 GB 당 (또는 쿼리 시간)시간 범위를 좁혀 조회
내보내기다른 저장소로 빼낼 때 별도애초에 이중 저장 설계
정확한 단가는 여기 적지 않겠습니다 — 리전마다 다르고 자주 바뀝니다. 2026년 8월 기준 대략, 주요 클라우드의 로그 수집 단가는 GB 당 수십 센트 수준이라고만 기억하세요. 하루 43GB 를 그대로 보내면 수집 요금만 월 수백 달러가 되는 자릿수입니다. 직접 확인하는 방법 — 각 클라우드의 요금 페이지에서 "수집 · 저장 · 분석" 세 항목을 각각 찾고, 비용 탐색기에서 로그 서비스의 지난달 실제 청구액을 열어 보세요. 추정보다 지난달 청구서가 항상 정확합니다.

현실적인 하이브리드 구성

# 흔히 쓰는 두 갈래 저장 (수집기에서 라우팅)
                     ┌─→ Loki      : 14일 · 뜨거운 조사용
  수집기(Vector) ────┤
                     └─→ S3(Parquet/JSON.gz) : 1~3년 · 감사·규정용
                            └─→ 필요할 때만 Athena·ClickHouse 로 조회

# Vector 로 두 곳에 동시에 보내기
[sinks.loki]
type = "loki"
inputs = ["sample"]           # 샘플링된 것만

[sinks.archive]
type = "aws_s3"
inputs = ["parse"]            # ★ 샘플링 전 원본 전부
bucket = "corp-log-archive"
key_prefix = "nginx/date=%Y-%m-%d/"
compression = "gzip"
  [sinks.archive.encoding]
  codec = "json"
"조사용"과 "보관용"을 분리하는 것이 비용 설계의 핵심입니다. 조사용은 비싸고 빠른 곳에 짧게, 보관용은 싸고 느린 곳에 길게 둡니다. 오브젝트 스토리지 요금은 검색 엔진 저장 요금의 한 자릿수 분의 일 수준이라, 이 분리만으로 대부분의 비용 문제가 풀립니다.
정리 — Loki 는 라벨만, ES 는 전부, ClickHouse 는 칼럼으로 색인합니다. 비용은 저장에서 내거나 조회에서 내거나 둘 중 하나이고, 클라우드는 수집 · 저장 · 조회 세 군데에서 각각 붙습니다. 대부분은 Loki(짧게) + 오브젝트 스토리지(길게) 두 갈래면 충분합니다.
LOG 09

요청 ID — 서버 다섯 대의 로그를 한 줄로 잇기

"결제가 안 됐어요"라는 신고 하나에서 프록시 · 앱 · 결제 게이트웨이의 로그를 각각 찾아 헤매는 일이 조사의 대부분입니다. 맨 앞 프록시가 ID 를 하나 만들어 끝까지 전달하면 이 일이 검색 한 번으로 끝납니다.

ID 하나가 프록시에서 태어나 결제 API 까지 갑니다 클라이언트 Nginx 앱 서버 결제 API ① 요청 — 헤더에 ID 가 없습니다 ② map 으로 확인 → 없으면 $request_id 로 새로 만듦 ③ X-Request-Id: 3f8a… 를 붙여 전달 ④ 같은 ID 를 그대로 다음 홉으로 ⑤ 응답 헤더에도 X-Request-Id — 사용자가 그 값을 알려 줄 수 있습니다 세 서버가 같은 값을 로그에 적으면 검색 한 번으로 전 구간이 모입니다 앞단이 이미 준 ID 가 있으면 반드시 그것을 이어받습니다 — 새로 만들면 끊깁니다
시퀀스 — ID 는 가장 바깥에서 한 번 태어나 안쪽으로만 흐릅니다.

Nginx — 만들거나, 이어받거나

# http 블록
# ① 앞단이 준 값이 있으면 그것을, 없으면 $request_id 로 새로 만듭니다
map $http_x_request_id $req_id {
    default $http_x_request_id;
    ""      $request_id;          # 빈 값일 때만 새로 발급
}

# ② 로그에 적고
log_format json escape=json '{"ts":"$time_iso8601","id":"$req_id",'
    '"status":$status,"rt":$request_time,"uri":"$uri",'
    '"ua":"$upstream_addr","urt":"$upstream_response_time"}';

server {
    # ③ 백엔드로 넘기고
    proxy_set_header X-Request-Id $req_id;

    # ④ 클라이언트에게도 돌려줍니다 (always = 4xx·5xx 응답에도)
    add_header X-Request-Id $req_id always;

    # ⑤ 오류 페이지에 찍어 두면 고객센터가 바로 받아 적을 수 있습니다
    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
        internal;
        ssi on;
        root /usr/share/nginx/html;
    }
    location /api/ { proxy_pass http://backend; }
}
인터넷에서 들어온 X-Request-Id 를 그대로 믿으면 안 됩니다. 누구든 아무 값이나 넣어 보낼 수 있고, 그러면 남의 요청과 같은 ID 로 로그가 섞입니다. 맨 바깥 프록시에서는 무조건 새로 발급하고, 내부망의 프록시에서만 이어받는 것이 안전합니다 ($http_x_request_id 대신 그냥 $request_id).

다른 앞단들은 어떻게 하나

# ── HAProxy ─────────────────────────────────────────
global
    log stdout format raw local0
defaults
    unique-id-format %{+X}o\ %ci:%cp_%fi:%fp_%Ts_%rt:%pid
    unique-id-header X-Request-Id
    log-format "%ci %ST %B %Tr %Tt %{+Q}r id=%ID hop=%si:%sp"
frontend fe
    http-request set-header X-Request-Id %[unique-id]

# ── Apache ──────────────────────────────────────────
LoadModule unique_id_module modules/mod_unique_id.so
RequestHeader set X-Request-Id "%{UNIQUE_ID}e"
LogFormat "... %{UNIQUE_ID}e ..." ext

# ── Caddy ───────────────────────────────────────────
reverse_proxy 10.0.1.5:8080 {
    header_up X-Request-Id {http.request.uuid}
}

# ── Tomcat (뒤에서 받을 때) ─────────────────────────
#   AccessLogValve pattern 에 %{X-Request-Id}i 를 넣습니다

# ── 클라우드 LB 가 이미 붙여 주는 것들 ──────────────
#   AWS ALB     : X-Amzn-Trace-Id      (Root=1-...)
#   CloudFront  : X-Amz-Cf-Id
#   Envoy · 서비스 메시 : x-request-id (자동 생성·전파)
#   → 이미 있으면 새로 만들지 말고 그 값을 로그 필드로 받으세요
이미 분산 추적을 쓰고 있다면 traceparent 가 정답입니다. W3C Trace Context 표준 헤더라 모든 언어의 계측 라이브러리가 자동으로 전파하고, trace_id 하나로 로그 · 추적 · 메트릭이 전부 이어집니다. X-Request-Id 는 추적을 아직 안 넣은 조직의 최소 장비라고 보면 됩니다 (자세한 건 🔭 관측 · 모니터링 탭).

모아 보기 — ID 하나로 전 구간 조회 🟡 중급

저장소별 질의

# Loki — 시간 범위를 반드시 좁히세요(라벨 밖 검색은 전수 훑기)
{env="prod"} |= "3f8a9c" | json | line_format "{{.host}} {{.uri}} {{.status}}"

# 서비스별로 몇 줄씩 나왔는지 한눈에
sum by (job) (count_over_time({env="prod"} |= "3f8a9c" [1h]))

# Elasticsearch / OpenSearch
GET logs-*/_search
{ "query": { "term": { "id.keyword": "3f8a9c..." } },
  "sort": [ { "@timestamp": "asc" } ], "size": 500 }

# ClickHouse
SELECT ts, host, service, status, rt, msg
FROM logs
WHERE req_id = '3f8a9c...' AND ts >= now() - INTERVAL 2 HOUR
ORDER BY ts;

# 원본 파일밖에 없을 때 (여러 서버를 한 번에)
#   (-i 는 "[1] 15:00:01 [SUCCESS] host" 머리줄이 섞여 jq 가 깨지므로 -o 로 호스트별 파일에 받습니다)
pssh -h hosts.txt -o /tmp/hit "grep -h 3f8a9c /var/log/nginx/access.json"
cat /tmp/hit/* | jq -s 'sort_by(.ts)'

실무에서 진짜 효과가 큰 두 가지

① 오류 화면에 ID 를 작게 찍어 두기. 고객센터가 "화면 아래 코드를 불러 주세요" 한마디로 그 한 건의 요청 로그를 1초 만에 찾습니다. 재현도 필요 없고 시간대를 물어볼 필요도 없습니다. 50x 페이지에 <!--# echo var="request_id" --> 같은 SSI 한 줄이면 됩니다.
② 응답 헤더를 항상 내보내기(always). 프런트엔드가 API 오류를 잡을 때 응답 헤더의 ID 를 함께 리포팅하면, 브라우저에서 난 오류 하나가 서버 로그 한 줄과 정확히 짝지어집니다. add_header 는 always 를 안 붙이면 2xx·3xx 에만 붙습니다 — 정작 필요한 5xx 에 안 붙는 실수가 흔합니다.
ID 를 라벨이나 색인 필드로 만들지 마세요. 값의 가짓수가 요청 수만큼이라, Loki 라벨이나 ES 의 keyword 필드로 두면 색인이 폭발합니다. 본문 안의 값으로 두고 문자열 검색하는 것이 맞습니다 — 시간 범위만 좁히면 충분히 빠릅니다.
정리 — 가장 바깥 프록시에서 ID 를 하나 만들고, proxy_set_header 로 안쪽에 넘기고, add_header ... always 로 밖에도 돌려줍니다. 세 서버가 같은 값을 로그에 적으면 검색 한 번으로 전 구간이 모입니다. 인터넷에서 온 ID 는 믿지 말고 새로 발급하세요.
LOG 10

비용 · 샘플링 · 보존 — 안 남길 것을 정하는 일

로그 비용은 산수로 미리 알 수 있습니다. 초당 요청 수 × 한 줄 크기 × 86,400. 이 계산을 한 번만 해 보면 "일단 다 남기자"가 왜 안 되는지가 바로 보입니다.

초당 1,000 요청 × 한 줄 500바이트 = 하루 43GB — 어디서 줄어드나 0 10 20 30 40 50 GB / 일 원본 JSON 43 GB gzip 압축 약 4 GB — 압축만으로 10배 zstd 압축 약 3.5 GB · CPU 도 더 쌉니다 Loki 저장 약 5 GB — 압축 청크 + 라벨 색인 ES 저장 50 GB 안팎 압축비는 로그 내용에 따라 크게 다릅니다 — 하루치로 직접 재 보고 정하세요
막대 — 가장 큰 절감은 압축이고, 그다음이 안 보내는 것입니다.

내 서버는 얼마나 쌓이나 — 30초 계산

# ① 한 줄 평균 크기 재기
awk '{n++; s+=length($0)+1} END {print "줄수", n, "평균바이트", s/n}' \
  /var/log/nginx/access.json

# ② 지난 1시간에 몇 줄이 쌓였나
journalctl --since "1 hour ago" -u nginx | wc -l
ls -l --time-style=+%s /var/log/nginx/access.json      # 크기 변화로도 됩니다

# ③ 하루 · 한 달 환산
#    초당 요청 × 평균 바이트 × 86400 = 하루 바이트
#    1,000 × 500 × 86,400 = 43,200,000,000 ≈ 43 GB/일 ≈ 1.3 TB/월
#    여기에 서버 대수를 곱하면 그것이 수집기가 보낼 총량입니다

# ④ 압축비를 실제로 재기 (추정하지 말고)
head -c 200000000 /var/log/nginx/access.json > /tmp/s.json
gzip  -c /tmp/s.json | wc -c
zstd -19 -c /tmp/s.json | wc -c

# ⑤ 필드 하나가 차지하는 비중 — 뭘 뺄지 정할 때
jq -r 'to_entries[] | "\(.key)\t\(.value|tostring|length)"' \
  /var/log/nginx/access.json | awk -F'\t' \
  '{s[$1]+=$2} END {for (k in s) print s[k], k}' | sort -rn
⑤ 를 해 보면 대개 User-Agent 와 Referer 가 1·2등입니다. 둘이 한 줄의 40~60%를 차지하는 경우도 흔합니다. User-Agent 를 통째로 남기는 대신 "브라우저 계열"로 축약하거나(map 으로 분류) 봇 판정 결과만 남기면 용량이 눈에 띄게 떨어집니다.

보존 설계 — 세 구간으로 나눕니다

구간기간어디에 · 왜
뜨거움7~14일Loki·ES — 장애 대응은 거의 여기서 끝납니다
미지근30~90일강등된 등급 · 압축 · 느린 조회 허용
차가움1~3년오브젝트 스토리지 — 감사·규정·분쟁 대응
# 무엇을 어느 구간에 두나 — 결정 규칙
#  ERROR · 보안 이벤트 · 감사 로그  → 100% · 장기 보관 ★
#  4xx / 5xx 액세스 로그            → 100% · 뜨거움 + 장기
#  2xx 액세스 로그                  → 샘플링 · 뜨거움만
#  DEBUG                            → 운영에서는 아예 끔
#  헬스체크 · 정적 파일             → 아예 안 남김
#  요청/응답 본문                   → 남기지 않음 (사고 예방 + 용량)

# 줄이는 수단의 효과 (큰 것부터)
#  ① DEBUG 끄기               — 보통 절반 이상
#  ② 헬스체크·정적 제외        — 20~40%
#  ③ 2xx 샘플링(1/20)          — 큰 절감, 조사 손실 거의 없음
#  ④ 긴 필드 축약(UA·Referer)  — 20~40%
#  ⑤ 보존 30일 → 14일          — 선형으로 감소
#  ⑥ 중복 로깅 제거            — 10~20%

트레이드오프를 눈으로 보기 — 무엇을 잃고 무엇을 얻나 🔴 실무

여섯 가지 결정 — 초록은 좋아짐, 붉은색은 나빠짐, 회색은 그대로 비용 조회 속도 조사 능력 DEBUG 끄기 크게 절감 조금 빨라짐 그대로 헬스체크 · 정적 제외 절감 조금 빨라짐 그대로 2xx 를 1/20 샘플링 크게 절감 조금 빨라짐 정상 흐름 일부 손실 보존 30일 → 14일 크게 절감 그대로 옛 사건 추적 불가 전체 색인 → 라벨 색인 크게 절감 넓은 검색 크게 느림 그대로 gzip → zstd 로 교체 절감 조금 빨라짐 그대로 나빠짐 ← 그대로 → 좋아짐 위의 두 줄은 잃는 것이 거의 없습니다 — 여기부터 손대는 것이 순서입니다 아래로 갈수록 "무엇을 포기할지"를 팀이 합의해야 하는 결정이 됩니다
발산 색 — 공짜인 절감과 대가가 있는 절감을 섞어 논의하지 않게 해 줍니다.
샘플링한 로그로 비율을 계산하면 틀립니다. 2xx 를 1/20 로 남겨 놓고 5xx / 전체 를 세면 에러율이 20배로 부풀어 보입니다. ① 로그에 sample_rate 필드를 함께 남겨 집계 때 가중치를 되돌리거나, ② 비율은 아예 메트릭으로 재는 두 가지 중 하나를 반드시 정하세요. 실무에서는 ②가 정석입니다 — 로그는 "그 한 건"을 찾는 도구이고, "몇 퍼센트냐"는 메트릭의 질문입니다(🔭 관측 탭).
정리 — 비용은 초당 요청 × 줄 크기 × 86,400 으로 미리 계산됩니다. 가장 큰 절감은 압축, 그다음이 DEBUG 끄기 · 헬스체크 제외 같은 잃는 것 없는 절감입니다. 샘플링과 보존 단축은 대가가 있는 결정이니 팀이 합의하고 하세요. 그리고 조사용(짧게)과 보관용(길게)을 반드시 분리합니다.
LOG 11

마스킹 · 감사 로그 · 접근 통제 — 로그 자체가 사고가 되지 않게

로그는 파일 · 회전본 · 수집기 버퍼 · 저장소 · 백업으로 자동 복제됩니다. 개인정보가 한 번 적히면 다섯 곳에서 지워야 하고, 감사 로그는 반대로 누구도 지우지 못하게 만들어야 합니다. 둘 다 앱이 아니라 서버 쪽 설계입니다.

같은 마스킹이라도 어느 레인에서 하느냐에 따라 남는 복제본 수가 다릅니다 웹서버 수집기 저장소 map 으로 토큰 제거 Authorization 안 적음 디스크에 원본 없음 0 복제 파일에 원본 기록됨 VRL·Lua 로 마스킹 전송본만 깨끗함 1 복제 저장 · 백업까지 됨 필드 삭제 API 백업엔 그대로 3+ 복제 왼쪽 레인일수록 복제본이 적습니다 — 마스킹은 가능한 한 웹서버에서 끝냅니다 수집기 마스킹은 두 번째 방어선, 저장소 삭제는 사고 뒤의 뒷수습입니다
스윔레인 — 어느 레인에서 지우느냐가 복제본 수입니다. 웹서버가 안 적는 것이 유일하게 0입니다.

Nginx — 애초에 안 적는 설정

# http 블록 — 쿼리스트링의 토큰·비밀번호 값을 로그용 변수에서 지웁니다
map $request_uri $uri_log {
    default $request_uri;
    ~^(?<p>[^?]*\?.*?)(?<k>(token|access_token|password|otp|code)=)[^&]*(?<r>.*)$
        $p$k[REDACTED]$r;
}

# IPv4 마지막 옥텟 지우기 — "접속 기록"은 남기되 개인 식별은 낮춥니다
map $remote_addr $ip_log {
    ~^(?<a>\d+\.\d+\.\d+)\.\d+$  $a.0;
    default                      $remote_addr;   # IPv6 는 별도 규칙으로
}

# log_format 에는 $uri_log · $ip_log 만 넣습니다
log_format json escape=json
  '{"ts":"$time_iso8601","cip":"$ip_log","uri":"$uri_log",'
  '"status":$status,"rt":$request_time,"id":"$request_id"}';

# ★ 절대 넣지 말 것 — 한 번 들어가면 다섯 곳에 복제됩니다
#   $http_authorization   $http_cookie   $request_body
#   $http_x_api_key       $args(그대로)  $http_referer(이메일이 섞여 옵니다)
error_log 와 임시 파일도 샙니다. client_body_in_file_only on 을 켜 두면 요청 본문이 client_body_temp_path 에 평문 파일로 남고, error_log debug 는 헤더 전체를 적습니다. 운영에서는 둘 다 끄고, 디버깅으로 켰다면 끈 뒤 그 디렉터리를 비우는 것까지가 작업입니다.

로그로 새는 대표 경로 — 점검표

어디서무엇이 새나막는 곳
쿼리스트링 ?token=세션·재설정 토큰웹서버 map
Authorization·Cookie 헤더자격 증명 통째log_format 에서 제외
Referer이전 페이지 URL 의 이메일·IDmap 으로 경로만
$remote_addrIP 는 개인정보로 봅니다옥텟 마스킹
앱 스택트레이스SQL 파라미터 · 요청 객체 dump수집기 VRL
리다이렉트 LocationOAuth code · 초대 링크응답 헤더 안 적기
업로드 파일명이름 · 생년월일이 든 파일명해시로 치환
국내 규정은 "접속기록"을 요구합니다 — 안 남기는 게 답이 아닙니다. 개인정보처리시스템의 접속기록은 일정 기간 보관하고 정기 점검해야 하고 (2026년 8월 기준 대략 1년 이상, 규모에 따라 더 길게), 그 기록에는 누가 · 언제 · 어떤 정보주체의 · 무엇을 했는지가 있어야 합니다. 정확한 기간과 대상은 개인정보보호위원회의 "개인정보의 안전성 확보조치 기준" 고시 원문에서 확인하세요 — 개정이 잦습니다. 요점은 식별자(사용자 ID)는 남기고 내용(비밀번호·토큰)은 지우는 것입니다.

감사 로그 — 지울 수 없게 만드는 다섯 단계 🔴 실무

감사 로그는 남기는 것보다 "손대지 못하게" 하는 것이 본질입니다 발생 auditd · sudo 즉시 전송 rsyslog · RELP 불변 저장 Object Lock 정기 점검 월 1회 · 기록 기한 폐기 보존 만료 시 rename · unlink 시스템콜 감시 로컬 파일은 사본일 뿐 chattr +a root 도 못 지움 점검한 사실도 감사 로그입니다 폐기 기록을 남기고 지움 서버 안의 감사 로그는 침입자도 지울 수 있습니다 — 서버 밖으로 즉시 내보내야 감사 로그입니다 "불변"은 관리자 본인으로부터도 지키는 것입니다. root 가 지울 수 있으면 불변이 아닙니다
파이프라인 — 즉시 전송 · 불변 저장 두 단계가 빠지면 감사 로그가 아니라 그냥 로그입니다.

auditd — 로그를 건드린 사람을 기록

# /etc/audit/rules.d/50-logs.rules  (auditd 3.x · 적용: augenrules --load)
# ① 설정 변경 감시 — 누가 nginx.conf · logrotate 규칙을 고쳤나
-w /etc/nginx/         -p wa -k nginx-conf
-w /etc/logrotate.d/   -p wa -k rotate-conf
-w /etc/rsyslog.d/     -p wa -k syslog-conf
-w /etc/sudoers        -p wa -k sudoers

# ② 로그 삭제·이름 변경 감시 — 사람이 한 것만 (auid = 로그인한 실제 사용자)
-a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat \
   -F dir=/var/log -F auid>=1000 -F auid!=unset -k log-tamper

# ③ 감사 규칙 자체를 못 바꾸게 잠금 (재부팅 전엔 못 풉니다)
-e 2

# 조회
ausearch -k log-tamper -i --start today
ausearch -k nginx-conf --raw | aureport -f -i     # 파일 기준 요약 (aureport 에는 --raw 로 넘깁니다)
aureport --auth --failed --start this-week        # 인증 실패 요약
액세스 로그 파일 자체에 -p w 를 걸지 마세요. 요청마다 write 시스템콜이 감사 이벤트가 되어 auditd 가 서버를 잡아먹습니다. 감시할 것은 설정 변경과 삭제·이름 변경이지 내용 추가가 아닙니다. ②의 auid>=1000 이 데몬(root 로 도는 logrotate)을 걸러 줍니다.

불변 저장과 접근 권한

# ① 로컬 — append-only 속성 (rename 도 막히므로 logrotate 와 충돌 ★)
sudo chattr +a /var/log/audit/audit.log
lsattr /var/log/audit/audit.log                    # ----a------
#   회전이 필요하면 prerotate 에서 chattr -a, postrotate 에서 +a

# ② 오브젝트 스토리지 — 버킷 단위 잠금 (S3 Object Lock · COMPLIANCE 모드)
aws s3api create-bucket --bucket corp-audit-logs \
    --object-lock-enabled-for-bucket --region ap-northeast-2 \
    --create-bucket-configuration LocationConstraint=ap-northeast-2   # us-east-1 외 리전은 필수
aws s3api put-object-lock-configuration --bucket corp-audit-logs \
    --object-lock-configuration \
    '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":400}}}'
#   COMPLIANCE = 루트 계정도 기간 안에는 삭제 불가. GOVERNANCE = 특정 권한이면 삭제 가능

# ③ 읽을 수 있는 사람 — 그룹으로 좁힙니다
ls -l /var/log/nginx/                # -rw-r----- www-data adm  ← 0640 이 정상
sudo chmod 0750 /var/log/nginx
sudo usermod -aG adm            dev-kim     # 웹서버 로그 열람
sudo usermod -aG systemd-journal dev-kim    # journalctl 전체 열람
# audit.log 는 root 만 — 그룹 열람도 열지 않습니다

# ④ 원격 저장소는 "쓰기만" 되는 자격 증명을 줍니다
#   수집기 계정: s3:PutObject 만 · Loki 테넌트: push 만 · 조회 계정은 별도
수집기 자격 증명이 "읽기·삭제"까지 되면 서버 한 대 뚫린 것으로 전체 로그가 날아갑니다. 수집기는 보내기만, 조회는 사람 계정, 삭제는 보존 정책(수명 주기 규칙)만 하게 하세요. 세 가지가 분리된 상태를 확인하는 것이 로그 접근 통제의 전부입니다.
흔한 상태 — 감사 로그가 그냥 로그인 경우
· /var/log/audit 가 서버 안에만 있고 침입자가 지우면 끝
· /var/log 가 0755 라 모든 계정이 액세스 로그를 읽음
· 수집기 IAM 이 s3:* — 한 서버가 뚫리면 아카이브 삭제 가능
· "점검"을 한 적이 없어 규정 점검에서 바로 지적
목표 상태
· auditd → rsyslog(RELP, 디스크 큐) → 중앙 서버 즉시 전송
· 중앙은 Object Lock 버킷 또는 chattr +a 파일
· 로컬 로그 0640 www-data:adm, 열람은 그룹 가입으로만
· 월 1회 aureport 요약을 티켓으로 남김 — 점검 기록도 감사 로그
정리 — 마스킹은 웹서버가 애초에 안 적는 것이 유일하게 복제본 0이고, 수집기 마스킹은 두 번째 방어선입니다. 감사 로그는 반대로 즉시 서버 밖으로 보내고 · 불변 저장하고 · 정기 점검해야 하며, 수집기는 보내기만 되는 자격 증명을 갖습니다. 접속기록 보관 의무 때문에 "안 남기기"는 답이 아니고, 식별자는 남기고 내용은 지우는 것이 답입니다.
LOG 12

컨테이너 로그와 조사 절차 — 시간 → 상태 → 경로 → 업스트림 → ID

쿠버네티스에서는 logrotate 도 파일 경로도 여러분 것이 아닙니다 — kubelet 이 회전하고 런타임이 파일을 씁니다. 그 위에서 장애가 나면 다섯 번 좁히는 절차 하나로 300만 줄에서 한 줄까지 내려갑니다. 앞의 열한 섹션이 전부 이 절차를 위해 준비한 것입니다.

노드 한 대 안에서 로그가 가는 길 — 파일을 쓰는 것은 앱이 아니라 런타임입니다 컨테이너의 stdout · stderr 파일을 열지 않습니다 — 런타임이 받아 적습니다 containerd 가 파일로 씁니다 (CRI 형식) /var/log/pods/<ns>_<pod>_<uid>/<ctr>/0.log kubelet 이 회전 containerLogMaxSize containerLogMaxFiles kubectl logs kubelet 이 같은 파일을 읽어 줍니다 --previous = 직전 컨테이너의 파일 DaemonSet 수집기 /var/log/containers/*.log 심링크를 tail 네임스페이스 · 파드 · 라벨을 붙여 보냅니다 kubectl logs 는 노드에 남은 만큼만 보여 줍니다 — 파드가 사라지면 로그도 사라집니다 그래서 수집기가 없는 클러스터의 로그는 "없는 것"과 같습니다
트리 — 파일은 런타임이 쓰고 kubelet 이 회전하며, 읽는 쪽이 kubectl 과 수집기 둘로 갈립니다.

노드 쪽 — 회전과 파일 형식

# kubelet 설정 (KubeletConfiguration) — 노드마다 적용됩니다
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
containerLogMaxSize: 50Mi      # 기본 10Mi — 시끄러운 앱은 몇 분이면 다 밀려납니다
containerLogMaxFiles: 5        # 기본 5 — 컨테이너당 최대 250Mi

# 파일 한 줄의 실제 모양 (CRI 형식 — Fluent Bit 는 multiline.parser cri 로 읽습니다)
# 2026-08-31T06:04:22.123456789Z stdout F {"level":"error","msg":"pay failed","id":"3f8a9c"}
#   시각(RFC3339Nano)  스트림  F=완전한 줄 / P=잘린 줄(16KB 넘으면 쪼개집니다)

# 파일 자리 확인 (노드에서)
ls -l /var/log/containers/ | head            # 심링크 → /var/log/pods/...
ls -l /var/log/pods/shop_api-7d9f_*/api/     # 0.log 0.log.20260831-060000 ...
// 쿠버네티스가 아닌 도커 단독 호스트 — 데몬 설정이 회전을 맡습니다 (/etc/docker/daemon.json)
{ "log-driver": "json-file",
  "log-opts": { "max-size": "50m", "max-file": "5" } }
// ★ 이미 떠 있는 컨테이너에는 적용되지 않습니다 — 다시 만들어야 합니다
회전 상한이 곧 "조사 가능한 시간"입니다. 초당 200줄 × 300바이트면 10Mi 는 3분도 안 돼 밀려납니다. 장애가 난 파드의 kubectl logs 가 비어 있는 이유가 대개 이것이고, 답은 상한을 키우는 것이 아니라 수집기로 밖에 내보내는 것입니다.

Fluent Bit DaemonSet — 실제로 손대는 설정

# (클래식 형식은 줄 끝 주석을 값으로 읽습니다 — 주석은 따로 한 줄로)
[INPUT]
    Name              tail
    Path              /var/log/containers/*.log
    Exclude_Path      /var/log/containers/*_kube-system_*.log
    # ★ CRI 형식 + 잘린 줄(P) 이어 붙이기
    multiline.parser  cri
    Tag               kube.*
    # hostPath 볼륨에 두세요
    DB                /var/lib/fluent-bit/kube.db
    Mem_Buf_Limit     50MB
    Skip_Long_Lines   On
    Refresh_Interval  10

[FILTER]
    # 파일명에서 파드를 찾아 API 로 메타데이터 조회
    Name                kubernetes
    Match               kube.*
    # 본문이 JSON 이면 필드로 펼침
    Merge_Log           On
    Merge_Log_Key       app
    Keep_Log            Off
    # 파드 애너테이션으로 파서 지정 가능 · fluentbit.io/exclude: "true" 인 파드는 제외
    K8S-Logging.Parser  On
    K8S-Logging.Exclude On
    Labels              On
    # 애너테이션은 크고 카디널리티가 높습니다
    Annotations         Off

[OUTPUT]
    Name   loki
    Match  kube.*
    host   loki.logging.svc
    labels job=kube, ns=$kubernetes['namespace_name'], app=$kubernetes['labels']['app']
    # ★ pod_name 을 라벨로 넣지 마세요 — 재배포마다 스트림이 늘어납니다(lg08)
# 파드 쪽에서 로그 수집 방식을 애너테이션으로 조정
metadata:
  annotations:
    fluentbit.io/parser: nginx        # 이 파드는 nginx 파서로
    fluentbit.io/exclude: "true"      # 이 파드는 아예 수집 안 함(부하 테스트 등)
인그레스 컨트롤러도 웹서버입니다 — lg02·lg03 의 이야기가 그대로 적용됩니다. ingress-nginx 는 ConfigMap 의 log-format-upstream 으로 액세스 로그 포맷을 바꾸고 log-format-escape-json: "true" 로 JSON 을 만듭니다. 기본 포맷에 이미 $req_id · $upstream_addr · $upstream_response_time 이 들어 있어서 lg09 의 요청 ID 전파가 설정 없이 시작됩니다 — 앱이 X-Request-ID 를 이어받기만 하면 됩니다. 사이드카 방식(파드마다 수집기)은 앱이 stdout 으로 못 내보내고 파일로만 쓸 때의 예외 수단이지 기본이 아닙니다.

장애 조사 절차 — 다섯 번 좁히기 🔴 실무

300만 줄 → 1줄 — 순서를 바꾸지 않는 것이 요령입니다 하루치 access.json — 3,000,000 줄 전체 ① 시간 창 15분 — 31,000 --since ② status ≥ 500 — 1,200 5xx 만 ③ /api/pay — 900 uri 로 집계 ④ 10.0.1.6 — 850 ua 로 집계 ⑤ ID 1건 세 서버 잇기 약 10배씩 줄어듦 폭은 자릿수입니다 — 실제 비율로 그리면 마지막 단계는 보이지 않습니다 ③④ 에서 "한곳에 몰렸는가"가 갈림길 — 몰렸으면 그곳, 고르게 퍼졌으면 공통 의존(DB·배포)
깔때기 — 시간 → 상태 → 경로 → 업스트림 → 요청 ID. 이 순서면 어느 저장소에서든 5분입니다.

원본 파일밖에 없을 때 — jq 다섯 줄

# ① 시간 창 — 신고 시각 앞뒤로 자릅니다 (ISO8601 이라 문자열 비교가 됩니다)
jq -c 'select(.ts >= "2026-08-31T15:00" and .ts < "2026-08-31T15:15")' \
   /var/log/nginx/access.json > /tmp/win.json

# ② 상태코드 — 분포와 "언제 시작됐나"
jq -r '.status' /tmp/win.json | sort | uniq -c | sort -rn
jq -r 'select(.status >= 500) | .ts[0:16]' /tmp/win.json | uniq -c   # 분 단위

# ③ 경로 — 5xx 가 어디에 몰렸나
jq -r 'select(.status >= 500) | .uri' /tmp/win.json | sort | uniq -c | sort -rn | head

# ④ 업스트림 — 어느 백엔드가, 어떤 상태로, 몇 초 만에
jq -r 'select(.status >= 500) | "\(.ua) us=\(.us) urt=\(.urt)"' /tmp/win.json \
  | sort | uniq -c | sort -rn | head

# ⑤ 요청 ID — 한 건을 골라 세 서버에서 잇습니다(lg09)
jq -r 'select(.status >= 500 and .uri == "/api/pay") | .id' /tmp/win.json | head -3
pssh -h hosts.txt -i "grep -h 3f8a9c /var/log/nginx/access.json /var/log/app/*.json"

# 같은 시각의 error_log — 원인 문구는 대개 여기 있습니다
grep '2026/08/31 15:0' /var/log/nginx/error.log | cut -d' ' -f5- | cut -c1-60 | sort | uniq -c

Loki 에서 — 같은 다섯 단계를 LogQL 로

# ① 시간 창은 Grafana 의 시간 선택기 (또는 --since 15m)
# ② 상태코드 — 분 단위 5xx 추이 (그래프로 보면 시작점이 보입니다)
sum by (status) (count_over_time({job="nginx"} | json | status >= 500 [1m]))

# ③ 경로 상위 5개
topk(5, sum by (uri) (count_over_time({job="nginx"} | json | status >= 500 [15m])))

# ④ 업스트림 상위 5개 — ua 는 라벨이 아니라 본문 필드입니다(json 파서가 뽑음)
topk(5, sum by (ua) (count_over_time({job="nginx"} | json | status >= 500 [15m])))

# ⑤ 요청 ID 하나로 전 서비스 — 시간 범위를 좁힌 상태에서
{env="prod"} |= "3f8a9c" | json | line_format "{{.job}} {{.uri}} {{.status}} {{.urt}}"

# 보너스 — "그 시각에 새로 나타난 에러 문구" (평소엔 없던 것)
sum by (msg) (count_over_time({job="app"} | json | level="error" [15m]))
④ 에서 보이는 패턴뜻 · 다음 행동
502 + error_log connect() failed앱 프로세스 없음 — 배포 중이거나 죽음
504 + urt ≈ proxy_read_timeout앱이 매달림 — DB 락 · 풀 고갈 · 외부 API
499 + rt 큼클라이언트가 먼저 끊음 — 그쪽 타임아웃이 짧음
5xx 가 ua 한 대에 몰림그 서버 하나 — 디스크 · 메모리 · 구버전
5xx 가 모든 ua 에 동시에공통 의존 — DB · 캐시 · 방금 배포
us=200 인데 status=502Nginx 가 바꿈 — upstream sent too big header
4xx 급증 + 특정 cip스캔 · 크리덴셜 스터핑 — 보안 탭으로
이 절차가 통하려면 앞에서 만든 것들이 전부 있어야 합니다 — ①은 $time_iso8601(lg02), ②④는 $status · $upstream_addr · $upstream_response_time(lg02), JSON 이라 jq 가 도는 것은 escape=json(lg03), 4xx·5xx 가 샘플링 없이 전부 있는 것은 map + if=(lg03), ⑤는 X-Request-Id 전파(lg09), 그리고 15분 전 로그가 아직 남아 있는 것은 회전과 보존 설계(lg06 · lg10)입니다. 메트릭과 트레이스로 같은 조사를 하는 절차는 🔭 관측 탭 OBS 13 에 있습니다 — 그쪽은 "무엇이 아픈가", 이쪽은 "어느 한 건인가"입니다.
정리 — 컨테이너 로그는 런타임이 쓰고 kubelet 이 회전하며, kubectl logs 는 노드에 남은 만큼만 보여 주므로 DaemonSet 수집기가 없으면 로그가 없는 것입니다. 장애 조사는 시간 → 상태코드 → 경로 → 업스트림 → 요청 ID 다섯 번 좁히기이고, 이 순서는 jq 로도 LogQL 로도 같습니다. ③④ 에서 한곳에 몰렸는지가 "그 서버"와 "공통 의존"을 가르는 갈림길입니다.
NGINX · DOCKER COMPOSE · KUBERNETES · KUSTOMIZE · HELM · TERRAFORM · ARGO CD · GITHUB ACTIONS · 2026 기준

인프라 저장소도
규모를 따라
한 칸씩 자란다

서버 한 대일 때는 /etc/nginx 를 직접 고쳐도 됩니다. 그런데 서버가 셋, 환경이 셋, 서비스가 열을 넘으면 "운영 서버 설정은 누가 언제 바꿨지?"에 아무도 답하지 못합니다. 이 탭은 서버 설정 · Compose · 쿠버네티스 매니페스트 · Terraform · GitOps · CI 파일을 저장소 안에 어떤 디렉터리로 나눠 두는지, 그리고 언제 다음 구조로 넘어가는지를 한 장씩 봅니다. 각 도구의 문법과 명령은 해당 탭(☸️ 도커 · 쿠버네티스, 🟩 Nginx 등)으로 링크하고, 여기서는 배치와 경계만 다룹니다.

10SECTIONS
10시각화
6저장소 유형
1장짜리 요약표
STR 01

인프라도 코드다 — 저장소에 둘 것 · 두지 않을 것 · 규모별 진화

인프라를 저장소에 두는 이유는 하나입니다. 서버가 지금 왜 이렇게 생겼는지를 git log 로 답하려고입니다. 그러려면 "무엇을 넣고 무엇을 빼는지"가 먼저 정해져야 합니다. 설정과 절차는 넣고, 비밀과 상태는 뺍니다.

작은 팀의 인프라 저장소 한 벌

infra/
├─ README.md            # 저장소 지도 · 처음 온 사람이 읽을 순서
├─ nginx/               # 서버 설정 (STR 02)
├─ compose/             # 서버 1~3대 단계의 배포 단위 (STR 03)
├─ k8s/                 # 매니페스트 · Kustomize (STR 04)
├─ terraform/           # 클라우드 자원 (STR 05)
├─ runbooks/            # 장애 대응 절차 — 알림마다 한 장 (STR 10)
├─ docs/adr/            # 왜 이렇게 정했나 — 결정 기록
├─ .github/workflows/   # 검사 · 배포 파이프라인 (STR 09)
├─ .pre-commit-config.yaml
└─ .gitignore           # 비밀 · 상태 파일 차단 — 첫 커밋 전에
처음부터 이 폴더가 다 필요하지는 않습니다. 서버 한 대면 nginx/ 와 runbooks/ 만으로 시작하고, 아래 진화 단계를 따라 늘립니다.

넣는 것 · 빼는 것

종류저장소이유 · 대신 어디에
서버 설정 · compose · 매니페스트✅ 넣음변경 이력과 리뷰가 곧 운영 기록
Terraform 코드 · .terraform.lock.hcl✅ 넣음잠금 파일은 프로바이더 버전 고정용 — 커밋합니다
런북 · ADR · 온콜 문서✅ 넣음코드와 같이 리뷰해야 낡지 않습니다
.env · 키 · kubeconfig❌ 뺌비밀 저장소 · 암호화 파일 (STR 08)
*.tfstate · .terraform/❌ 뺌원격 백엔드(S3 등) — 상태에는 비밀이 평문으로 들어 있습니다
렌더 결과 · DB 덤프 · 로그❌ 뺌다시 만들 수 있는 것은 넣지 않습니다
인프라 저장소가 자라는 길 — 위는 구조, 아래는 넘어갈 때의 신호 서버 한 대 nginx · systemd 설정 nginx/ · runbooks/ Compose 서버 1~3대 · 서비스 5개 compose.yaml + override 쿠버네티스 클러스터 하나 base/ + overlays/ 여러 클러스터 환경 · 리전별 클러스터 terraform/ + 설정 저장소 1단계 2단계 3단계 4단계 SSH 로 직접 수정 서버마다 설치가 다름 새 서버를 재현 못 함 서버 3대+ · 무중단 죽으면 사람이 살림 클러스터를 손으로 못 만듦 팀마다 권한을 나눠야 함 대부분의 팀은 여기서 오래 머뭅니다 단계를 건너뛰면 도구 비용만 먼저 냅니다 — 신호가 보일 때 한 칸씩
인프라 구조도 신호가 올 때 한 칸씩 — 서비스 세 개에 쿠버네티스 여러 클러스터는 비용만 큽니다.

첫 커밋 전에 — .gitignore 가 먼저

# .gitignore — 인프라 저장소 공통
.env
.env.*
!.env.example          # 이름만 적은 견본은 커밋
*.pem
*.key
kubeconfig*
# Terraform — 상태와 캐시는 원격 · 로컬 전용
*.tfstate
*.tfstate.*
.terraform/
crash.log
# 렌더 결과 · 임시
rendered/
*.dump

규칙 네 가지

규칙이유
손으로 고친 것은 없다서버에서 직접 바꿨다면 같은 날 저장소에도 반영 — 안 하면 다음 배포가 되돌립니다
환경은 디렉터리로dev · stg · prod 를 브랜치로 나누지 않습니다 (STR 08)
비밀은 참조만저장소에는 "어디서 가져오나"만 (STR 08)
한 번 새면 회전커밋한 비밀은 지워도 이력에 남습니다 — 키를 바꾸는 것만이 해결입니다
❌ 서버가 진실
운영 서버 /etc/nginx/conf.d/
├─ api.conf
├─ api.conf.bak          # 누가 언제?
├─ api.conf.20260311     # 어느 쪽이 지금 것?
└─ test.conf             # 아직 켜져 있음
✅ 저장소가 진실
infra/nginx/conf.d/
└─ api.conf              # 변경 = PR · 리뷰 · 배포
# 되돌리기  git revert <커밋>
# 누가 바꿨나  git log -p nginx/conf.d/api.conf
정리 — 설정 · 매니페스트 · IaC · 런북은 저장소에, 비밀과 상태 파일은 밖에. 구조는 서버 한 대 → Compose → 쿠버네티스 → 여러 클러스터 순으로, 신호가 올 때만 넘어갑니다.
STR 02

Nginx 설정 구조 — nginx.conf · conf.d · snippets · 템플릿

블록 중첩과 파일 위치는 🧭 시작 탭 w07 "설정 파일 구조 읽는 법"에, 공통 프록시 조각은 🟩 Nginx 탭 n04 에 있습니다. 여기서는 사이트가 열 개를 넘을 때 저장소 안에서 어떻게 나누는지만 봅니다. 핵심은 사이트당 파일 하나, 공통은 snippets/, 환경 차이는 템플릿입니다.

저장소 안의 nginx/

nginx/
├─ nginx.conf             # 전역 · events · http — 거의 안 바뀜
├─ conf.d/                # ★ 사이트(server 블록) 하나 = 파일 하나
│  ├─ 00-default.conf     # 모르는 Host 는 444 로 끊기
│  ├─ api.conf
│  └─ admin.conf
├─ snippets/              # 여러 사이트가 include 하는 조각
│  ├─ tls.conf            # 프로토콜 · 세션 캐시
│  ├─ headers.conf        # 보안 헤더
│  └─ proxy-common.conf   # 프록시 헤더 · 타임아웃 (🟩 n04)
├─ templates/             # 환경마다 다른 값 — envsubst 로 렌더
│  └─ upstream.conf.template
└─ certs/                 # .gitignore — 인증서는 밖에서 주입

sites-available 냐 conf.d 냐

방식어디서저장소에 둘 때
sites-available/ + sites-enabled/Debian · Ubuntu 패키지켜기 = 심볼릭 링크 — git 과 컨테이너에서 다루기 번거롭습니다
conf.d/*.confnginx.org 패키지 · 공식 Docker 이미지권장 — 파일이 있으면 켜짐, 지우면 꺼짐
Ubuntu 기본 nginx.conf 는 둘 다 include 합니다. 한 서버에서 두 방식을 섞으면 같은 server_name 이 두 번 잡혀 conflicting server name 경고가 나옵니다 — 하나로 통일하세요.
include 가 파일을 엮는 방식 — 위가 아래를 불러 들입니다 nginx.conf http { include conf.d/*.conf; } conf.d/ api.conf · admin.conf snippets/ tls · headers · proxy-common templates/ *.conf.template server 안에서 include snippets/… 컨테이너 시작 때 envsubst → conf.d/ 로 렌더 사이트 파일은 "무엇이 다른가"만, 공통은 snippets/ 한 곳 — 고칠 곳이 하나가 됩니다
nginx.conf → conf.d → snippets 의 세 겹 — 사이트 파일이 짧을수록 리뷰가 쉽습니다.

사이트 파일 — 다른 점만 남긴다

# conf.d/api.conf
server {
    listen 443 ssl;
    http2 on;                       # nginx 1.25.1+ 문법
    server_name api.example.com;

    ssl_certificate     /etc/nginx/certs/api.crt;
    ssl_certificate_key /etc/nginx/certs/api.key;
    include snippets/tls.conf;      # 상대 경로 = /etc/nginx 기준
    include snippets/headers.conf;

    location / {
        proxy_pass http://api_upstream;   # templates/ 에서 렌더
        include snippets/proxy-common.conf;
    }
}
add_header 는 상속되지 않을 때가 있습니다. location 안에 add_header 가 하나라도 있으면 server 레벨의 headers.conf 헤더가 그 location 에서는 통째로 사라집니다. 그런 location 에는 headers.conf 를 다시 include 하세요.

환경마다 다른 값 — 공식 이미지의 템플릿

# templates/upstream.conf.template
#  → 시작 때 /etc/nginx/conf.d/upstream.conf 로 렌더
upstream api_upstream {
    server ${API_HOST}:${API_PORT};
    keepalive 32;
}
# compose.yaml 일부
services:
  nginx:
    image: nginx:stable-alpine
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/snippets:/etc/nginx/snippets:ro
      - ./nginx/templates:/etc/nginx/templates:ro
      - ./nginx/conf.d/api.conf:/etc/nginx/conf.d/api.conf:ro
    environment:
      API_HOST: api
      API_PORT: "8080"
공식 이미지의 시작 스크립트는 정의된 환경 변수만 치환하므로 $host 같은 nginx 변수는 그대로 남습니다. conf.d 폴더 전체를 마운트하면 렌더 결과를 쓸 수 없으니 파일 단위로 마운트합니다. 검사는 docker compose run --rm nginx nginx -t.
❌ 사이트마다 복사
conf.d/api.conf     # ssl_protocols 6줄 + 헤더 8줄
conf.d/admin.conf   # 같은 14줄 (한 곳만 TLSv1 남음)
conf.d/shop.conf    # 같은 14줄 (헤더 하나 빠짐)
✅ 조각은 한 곳
snippets/tls.conf       # 여기만 고치면
snippets/headers.conf   # 모든 사이트에 반영
conf.d/*.conf           # include 두 줄 + 자기 location
정리 — 저장소에서는 conf.d/ 에 사이트당 파일 하나, 공통은 snippets/, 환경 차이는 templates/ + envsubst. 배포 전에 항상 nginx -t.
STR 03

Docker Compose 프로젝트 — 공통 파일 + override + profiles

Compose 문법과 완본 예제는 ☸️ 도커 · 쿠버네티스 탭 d04 에, 관측 스택 한 벌은 🔭 관측 탭 y12 에 있습니다. 여기서는 서비스가 늘어날 때 파일을 어떻게 쪼개고 Dockerfile 을 어디에 두는지를 봅니다. 원칙은 공통은 compose.yaml 하나, 환경 차이는 덧씌우는 파일, 가끔 쓰는 도구는 profile입니다.

서비스 서너 개짜리 Compose 저장소

shop/
├─ compose.yaml            # 공통 — 서비스 · 이미지 · 볼륨 · 네트워크
├─ compose.override.yaml   # 개발 — 소스 마운트 · 디버그 포트 (자동 적용)
├─ compose.prod.yaml       # 운영 — restart · 리소스 · 로그 (-f 로 명시)
├─ .env.example            # 변수 이름만 — 커밋
├─ .env                    # 실제 값 — .gitignore
├─ services/
│  ├─ api/
│  │  ├─ Dockerfile        # ★ 서비스 코드 옆에 — 빌드 컨텍스트가 작아짐
│  │  ├─ .dockerignore
│  │  └─ src/
│  └─ worker/
│     └─ Dockerfile
├─ nginx/                  # STR 02 의 구조 그대로
└─ ops/
   ├─ backup.sh            # 볼륨 백업 (☸️ d04)
   └─ deploy.sh            # pull → up -d → 헬스 확인

파일마다 무엇을 쓰나

파일넣는 것읽히는 때
compose.yaml이미지 · 의존 · 헬스체크 · 볼륨항상
compose.override.yaml소스 마운트 · 포트 공개 · 디버거-f 없이 실행할 때만
compose.prod.yamlrestart · 자원 한도 · 로그 회전-f 로 지정할 때
.envcompose 파일 안 ${변수} 값항상 (셸 변수가 이김)
.env 는 compose 파일을 치환하는 용도이고 컨테이너 안으로 자동으로 들어가지 않습니다. 컨테이너에 넣으려면 environment: 에 적거나 env_file: 로 지정합니다.
파일을 겹쳐 읽습니다 — 나중에 얹힌 파일이 이깁니다 docker compose config 실제로 뜨는 최종 설정 — 운영 배포 전에 반드시 출력해 봅니다 compose.prod.yaml (-f) 운영 — restart · 자원 한도 · 로그 회전 compose.override.yaml 개발 때만 자동 — -f 를 쓰면 빠집니다 compose.yaml (공통) 서비스 · 이미지 · 볼륨 · 헬스체크 .env → ${변수} 치환 셸에 같은 이름이 있으면 셸 값이 이김 나중 이김 단, ports · expose 같은 목록은 덮어쓰지 않고 합쳐집니다 — 개발 포트는 override 에만
덧씌우기 구조 — 공통 파일에는 모든 환경에서 같은 것만. 환경 하나에만 필요한 값이 공통에 있으면 뺄 방법이 없습니다.

compose.yaml — 공통

name: shop
services:
  api:
    build: ./services/api        # Dockerfile 은 서비스 폴더 안
    image: registry.example.com/shop/api:${API_TAG:-dev}
    environment:
      DB_HOST: db
      DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD 가 .env 에 필요}
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: shop
      POSTGRES_DB: shop
      POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD 가 .env 에 필요}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
      interval: 5s
      retries: 10
  adminer:                       # 가끔 쓰는 도구
    image: adminer
    profiles: [debug]            # --profile debug 일 때만 뜸
    ports: ["8081:8080"]
volumes:
  db-data:

덧씌우는 두 파일과 실행 명령

# compose.override.yaml — 개발 (자동)
services:
  api:
    build:
      target: dev                # Dockerfile 의 dev 단계
    volumes:
      - ./services/api/src:/app/src
    ports:
      - "8080:8080"

# compose.prod.yaml — 운영 (-f 로 명시)
services:
  api:
    restart: unless-stopped
    deploy:
      resources:
        limits: { cpus: "1.0", memory: 512M }
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }
  db:
    restart: unless-stopped
docker compose up -d                          # 공통 + override
docker compose -f compose.yaml -f compose.prod.yaml config   # 합친 결과 확인
docker compose -f compose.yaml -f compose.prod.yaml up -d    # 운영
docker compose --profile debug up -d          # adminer 까지
운영 서버의 .env 에 COMPOSE_FILE=compose.yaml:compose.prod.yaml 를 적어 두면 -f 두 개를 매번 치지 않아도 됩니다 (Windows 는 구분자가 ;).

서비스가 열을 넘으면 — include 로 쪼개기 🟡 중급

# compose.yaml — 팀별 파일을 불러 붙임 (Compose 2.20+)
include:
  - path: ./services/order/compose.yaml
  - path: ./services/payment/compose.yaml
  - path: ./observability/compose.yaml
services:
  nginx:
    image: nginx:stable-alpine
include 된 파일 안의 상대 경로는 그 파일이 있는 폴더 기준이라, 각 팀이 자기 폴더에서 독립적으로 작성할 수 있습니다. 이름이 겹치는 서비스는 오류가 납니다. 이 단계에서 서버가 셋을 넘고 무중단이 필요하다면 Compose 를 더 키우기보다 쿠버네티스로 넘어갈 신호입니다 (STR 01).
❌ 환경마다 통째 복사
docker-compose.dev.yml    # 140줄
docker-compose.stg.yml    # 138줄 — 어디가 다른지 diff 로만 앎
docker-compose.prod.yml   # 141줄 — 헬스체크가 하나 빠짐
✅ 공통 하나 + 차이만
compose.yaml              # 120줄 — 모든 환경 공통
compose.override.yaml     # 12줄 — 개발 차이
compose.prod.yaml         # 15줄 — 운영 차이
정리 — compose.yaml 은 공통, override 는 개발, prod 파일은 -f. Dockerfile 은 서비스 폴더 안에, 가끔 쓰는 도구는 profiles, 커지면 include — 그다음은 쿠버네티스입니다.
STR 04

쿠버네티스 매니페스트 — Kustomize base/overlays vs Helm 차트

Helm 차트 구조와 Kustomize 기본형, 둘 중 고르는 표는 ☸️ 도커 · 쿠버네티스 탭 d12 에 있습니다. 여기서는 한 걸음 더 — 서비스가 여럿일 때 폴더를 어떻게 반복하고, 환경 사이의 공통 조각을 어디에 두는지를 봅니다.

Kustomize — 서비스마다 같은 모양을 반복

k8s/
├─ components/               # 일부 환경에만 켜는 조각
│  ├─ hpa/                   # 오토스케일 (stg · prod)
│  └─ pdb/                   # PodDisruptionBudget (prod)
└─ services/
   ├─ api/
   │  ├─ base/               # 순수 YAML — 환경 공통
   │  │  ├─ kustomization.yaml
   │  │  ├─ deployment.yaml
   │  │  └─ service.yaml
   │  └─ overlays/
   │     ├─ dev/             # replicas 1 · 로그 debug
   │     ├─ stg/
   │     └─ prod/            # replicas 4 · hpa · pdb
   │        ├─ kustomization.yaml
   │        └─ replicas.yaml
   └─ worker/                # 같은 모양 반복 — 찾기 쉬움
      ├─ base/
      └─ overlays/{dev,stg,prod}/

Helm — 차트는 하나, 값 파일이 환경

charts/
├─ common/                   # type: library — 라벨 · 프로브 공통 템플릿
└─ api/
   ├─ Chart.yaml             # dependencies: common
   ├─ values.yaml            # 기본값 — 모든 환경 공통
   ├─ values-dev.yaml        # ★ 환경 차이만
   ├─ values-stg.yaml
   ├─ values-prod.yaml
   └─ templates/
      ├─ deployment.yaml
      ├─ service.yaml
      └─ _helpers.tpl
# values.yaml 은 자동, 환경 파일만 -f 로 덧씌움
helm upgrade --install api ./charts/api \
  -n shop-prod -f charts/api/values-prod.yaml
helm template api ./charts/api -f charts/api/values-prod.yaml  # 렌더만
서비스마다 템플릿을 복사하지 말고 library 차트(type: library)로 공통 템플릿을 묶습니다. 라이브러리 차트는 단독 설치가 안 되고 다른 차트가 가져다 씁니다.
같은 줄에 같은 질문 — 차이는 "원본이 YAML 이냐 템플릿이냐" 하나에서 나옵니다 Kustomize Helm 원본 — 순수 YAML (base/) 원본 — 템플릿 + values.yaml 환경 차이 — overlay 패치로 덮어씀 환경 차이 — values-prod.yaml 로 교체 로직 — 조건 · 반복 없음 (의도된 제약) 로직 — if · range · 함수 (Go 템플릿) kubectl kustomize overlays/prod helm template -f values-prod.yaml 우리 앱의 환경 분기 남에게 배포하는 패키지 · 서드파티 둘 다 마지막에는 평범한 매니페스트가 됩니다 — 그래서 PR 리뷰는 원본이 아니라 렌더 결과의 diff 로 봅니다
우리 앱은 Kustomize, 남의 것은 Helm — 섞어 쓰는 것이 가장 흔한 실무 조합입니다.

overlay 하나 — 다른 점만

# services/api/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: shop-prod
resources:
  - ../../base
components:
  - ../../../../components/hpa
  - ../../../../components/pdb
patches:
  - path: replicas.yaml
images:
  - name: shop-api                    # base 의 image 이름
    newName: registry.example.com/shop/api
    newTag: "1.8.2"                   # CI 가 바꾸는 유일한 줄
labels:
  - pairs:
      env: prod
# services/api/overlays/prod/replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-api
spec:
  replicas: 4

component — 환경 몇 개에만 켜는 조각

# components/hpa/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
resources:
  - hpa.yaml
어디에 두나기준
base/모든 환경에서 같은 것 — 이미지 이름 · 포트 · 프로브
overlays/<env>/그 환경만 다른 것 — replicas · 자원 · 태그
components/환경 둘 이상이 공유하는 선택 기능 — hpa · pdb
components 경로는 overlay 폴더 기준 상대 경로입니다. 위 예는 k8s/services/api/overlays/prod 에서 네 칸 올라가 k8s/components 를 가리킵니다. 깊이가 다르면 kubectl kustomize 가 바로 오류를 냅니다.
정리 — 서비스마다 base/ + overlays/<env>/ 를 같은 모양으로 반복하고, 몇 환경에만 켜는 조각은 components/. 남에게 줄 패키지·서드파티는 Helm, 공통 템플릿은 library 차트로.
STR 05

Terraform / IaC — modules/ 와 live/ · 상태를 어디서 자르나

Terraform 저장소의 구조는 결국 상태(state) 파일을 몇 개로, 어디서 자르느냐의 문제입니다. 상태 하나가 클수록 plan 이 느려지고, 실수 한 번이 건드릴 수 있는 범위(blast radius)가 커집니다. 그래서 재사용할 코드(modules/)와 실제로 적용하는 곳(live/ 또는 environments/)을 나누고, 적용하는 곳을 환경 × 층으로 쪼갭니다.

modules/ + live/ — 가장 흔한 배치

terraform/
├─ modules/                  # 재사용 코드 — 여기서는 apply 하지 않음
│  ├─ network/
│  │  ├─ main.tf
│  │  ├─ variables.tf        # 입력 — 타입 · 설명 · 검증
│  │  ├─ outputs.tf          # 출력 — 다른 층이 쓰는 값만
│  │  ├─ versions.tf         # required_providers
│  │  └─ README.md
│  ├─ k8s-cluster/
│  └─ postgres/
└─ live/                     # ★ 상태 하나 = 폴더 하나
   ├─ prod/
   │  ├─ network/            # backend.tf · main.tf · terraform.tfvars
   │  ├─ cluster/
   │  └─ data/
   └─ stg/
      ├─ network/
      ├─ cluster/
      └─ data/

규칙 다섯 가지

규칙이유
apply 는 live/ 에서만modules/ 는 함수 정의, live/ 는 호출 — 상태는 호출하는 쪽에 생깁니다
상태 = 환경 × 층prod 네트워크와 stg 앱이 같은 상태에 있으면 stg 작업이 prod 를 잠급니다
층끼리는 출력으로만terraform_remote_state 나 data 소스로 읽고, 리소스를 직접 참조하지 않습니다
변수는 타입 · 설명 · 검증잘못된 값이 plan 단계에서 걸러집니다
모듈 버전 고정저장소가 갈라지면 ?ref=v1.4.0 태그로 — 모듈 수정이 모든 환경에 동시에 번지지 않게
environments/ · live/ · envs/ 는 이름만 다른 같은 개념입니다. live/ 는 Terragrunt 문서에서 퍼진 이름이고, 어느 쪽이든 팀 안에서 하나로 통일하면 됩니다.
상태를 층으로 자릅니다 — 안쪽일수록 드물게 바뀌고, 터지면 크게 터집니다 네트워크 클러스터 데이터 앱 인프라 DNS 레코드 · 큐 · 대시보드 — 주 단위 · 틀려도 서비스 하나 DB · 버킷 · 백업 정책 — 삭제 보호(prevent_destroy) 필수 클러스터 · 노드 그룹 — 분기마다 · 업그레이드 단위 VPC · 서브넷 · IAM — 1년에 몇 번 · 틀리면 전부 멈춤 상태 하나에 전부 넣으면 대시보드 수정 plan 이 VPC 까지 다시 계산합니다 자주 바꾸는 바깥 층과 드물게 바꾸는 안쪽 층을 같은 상태에 두지 않습니다
상태 분리의 기준은 변경 주기와 피해 범위 — 같이 바뀌는 것끼리만 한 상태에 둡니다.

live/prod/network — 백엔드 · 모듈 호출

# live/prod/network/backend.tf
terraform {
  required_version = ">= 1.10"
  backend "s3" {
    bucket       = "myorg-tfstate"
    key          = "prod/network/terraform.tfstate"  # 폴더 경로와 같게
    region       = "ap-northeast-2"
    encrypt      = true
    use_lockfile = true    # 1.10+ S3 자체 잠금 — DynamoDB 표 불필요
  }
}

# live/prod/network/main.tf
module "network" {
  source = "../../../modules/network"
  name   = "shop-prod"
  cidr   = "10.20.0.0/16"
  azs    = ["ap-northeast-2a", "ap-northeast-2c"]
}

output "private_subnet_ids" {
  value = module.network.private_subnet_ids
}

모듈 입력 검증 · 다른 층에서 읽기

# modules/network/variables.tf
variable "cidr" {
  type        = string
  description = "VPC CIDR 블록"
  validation {
    condition     = can(cidrnetmask(var.cidr))
    error_message = "cidr 는 10.20.0.0/16 같은 IPv4 CIDR 이어야 합니다."
  }
}

# live/prod/cluster/main.tf — 네트워크 층의 출력만 읽음
data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "myorg-tfstate"
    key    = "prod/network/terraform.tfstate"
    region = "ap-northeast-2"
  }
}

module "cluster" {
  source     = "../../../modules/k8s-cluster"
  subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
}
terraform_remote_state 는 상대 상태 전체를 읽을 권한이 필요합니다. 권한을 더 좁히고 싶으면 태그로 찾는 data 소스(예: aws_subnets)나 SSM 파라미터에 출력을 써 두고 읽는 방식을 씁니다.
❌ 상태 하나에 전부
terraform/main.tf          # 3,000줄 · 리소스 600개
# plan 한 번 8분 · 누가 apply 중이면 전원 대기
# 대시보드 고치다 count 바꿔 서브넷 재생성 위험
# workspace 로 환경 구분 → 지금 어느 workspace 인지 실수
✅ 환경 × 층으로 쪼갬
live/prod/network/   # 리소스 40개 · 1년에 몇 번
live/prod/cluster/   # 리소스 60개
live/prod/apps/      # 리소스 120개 · 매주
# 폴더 = 상태 = 권한 단위 — 경로만 봐도 무엇을 건드리는지 앎
정리 — modules/ 는 정의, live/ 는 적용. 상태는 환경 × 층으로 잘라 폴더 하나에 하나, 층끼리는 출력으로만 연결합니다. 상태 파일은 원격 백엔드에 두고 잠금을 켭니다.
STR 06

GitOps 저장소 — 앱 저장소와 설정 저장소 · app-of-apps · ApplicationSet

Argo CD 의 Application · sync · prune 개념과 "매니페스트 저장소를 분리하라"는 결론은 ☸️ 도커 · 쿠버네티스 탭 d12 에 있습니다. 여기서는 그 설정 저장소 안을 어떻게 배치하는지, 서비스가 수십 개일 때 Application 을 손으로 만들지 않는 법, 그리고 이미지 태그가 바뀌는 흐름을 봅니다.

설정(config) 저장소 한 벌

deploy-config/
├─ bootstrap/
│  └─ root.yaml              # 사람이 딱 한 번 kubectl apply
├─ argocd/                   # root 가 가리키는 곳 = Argo CD 자기 설정
│  ├─ projects/              # AppProject — 팀별 권한 · 대상 네임스페이스
│  ├─ platform.yaml          # 플랫폼 Application 들 (app-of-apps)
│  └─ services-appset.yaml   # 서비스는 ApplicationSet 이 자동 생성
├─ platform/                 # 클러스터 공통 — 플랫폼 팀 소유
│  ├─ cert-manager/          # Helm 값 파일
│  ├─ ingress/
│  └─ monitoring/
└─ services/                 # 앱 팀 소유 — STR 04 의 모양 그대로
   ├─ api/
   │  ├─ base/
   │  └─ overlays/{dev,stg,prod}/
   └─ worker/

왜 앱 저장소와 나누나

이유한 저장소였다면
배포 이력코드 커밋 사이에 태그 변경이 섞여 "prod 에 지금 뭐가 떠 있나"를 찾기 어렵습니다
권한앱 개발자 전원이 prod 매니페스트를 바꿀 수 있게 됩니다
CI 루프CI 가 태그를 커밋 → 그 커밋이 다시 CI 를 깨움
여러 앱 한 번에"api 1.8 과 worker 2.3 을 같이" 를 커밋 하나로 못 묶습니다
서비스 하나 · 팀 하나라면 앱 저장소의 deploy/ 폴더로 시작해도 됩니다. 두 번째 서비스나 두 번째 팀이 생기면 나눌 신호입니다.
태그 한 줄이 배포가 됩니다 — CI 는 클러스터에 손대지 않습니다 개발자 앱 저장소 CI 레지스트리 설정 저장소 Argo CD · 클러스터 1. main 머지 2. 빌드 · 푸시 sha-3f2a9c1 3. overlays/prod 태그 변경 PR (kustomize edit set image) 4. 리뷰 · 머지 — dev · stg 는 자동, prod 는 사람 5. 변경 감지 6. 새 이미지 pull 7. Synced · Healthy 알림 클러스터 자격 증명은 Argo CD 에만 — CI 가 털려도 클러스터는 안전합니다
GitOps 의 배포 흐름 — CI 는 저장소까지, 클러스터는 Argo CD 가 끌어옵니다.

ApplicationSet — 폴더가 생기면 앱이 생긴다

# argocd/services-appset.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: services-prod
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - git:
        repoURL: https://github.com/myorg/deploy-config
        revision: main
        directories:
          - path: services/*/overlays/prod
  template:
    metadata:
      name: '{{index .path.segments 1}}-prod'   # api-prod
    spec:
      project: services
      source:
        repoURL: https://github.com/myorg/deploy-config
        targetRevision: main
        path: '{{.path.path}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{index .path.segments 1}}-prod'
      syncPolicy:
        automated: { prune: true, selfHeal: true }   # 승인은 머지 전에 (STR 09)
        syncOptions:
          - CreateNamespace=true

root 하나 — 나머지는 저장소가 만든다

# bootstrap/root.yaml — 이것만 손으로 apply
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/deploy-config
    targetRevision: main
    path: argocd            # 이 폴더의 Application · ApplicationSet 을 적용
    directory:
      recurse: true
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
방식맞는 곳
app-of-apps플랫폼처럼 앱마다 설정이 제각각 — Application 파일을 하나씩
ApplicationSet서비스처럼 모양이 같은 것 수십 개 · 클러스터 여러 개(cluster 생성기)
정리 — 앱 저장소는 코드와 이미지, 설정 저장소는 "무엇이 어디에 떠 있어야 하나". root 하나만 손으로 적용하고, 플랫폼은 app-of-apps, 같은 모양의 서비스는 ApplicationSet 으로 폴더에서 자동 생성합니다.
STR 07

MSA 저장소 전략 — 모노레포 vs 폴리레포 · 서비스 템플릿 · 공유 라이브러리

서비스를 무엇으로 쪼갤지는 🧱 백엔드 · MSA 탭 m03 에, 도입하지 말아야 할 때는 m11 에 있습니다. 여기서는 쪼갠 서비스들의 코드를 저장소 몇 개에 담을지, 그리고 서비스가 늘어도 헬스체크 · 로그 · Dockerfile 이 제각각이 되지 않게 하는 법을 봅니다.

모노레포 — 서비스 전부 한 저장소

shop/
├─ services/
│  ├─ order/            # 서비스마다 Dockerfile · deploy/ 동일한 모양
│  ├─ payment/
│  └─ catalog/
├─ libs/                # 공유 라이브러리 — 버전 없이 소스로 참조
│  ├─ observability/    # 로그 형식 · 트레이스 · 메트릭 이름
│  └─ api-contracts/    # OpenAPI · proto — 계약은 한 곳
├─ templates/service/   # 새 서비스 견본 (아래)
├─ .github/
│  ├─ CODEOWNERS        # 경로별 리뷰 책임
│  └─ workflows/        # 바뀐 서비스만 빌드 (STR 09)
└─ README.md

폴리레포 — 서비스마다 저장소

github.com/myorg/
├─ order-service        # 코드 · Dockerfile · CI
├─ payment-service
├─ catalog-service
├─ lib-observability    # 공유 라이브러리 — semver 로 배포
├─ api-contracts        # 계약 저장소 — 생성 코드를 패키지로
├─ service-template     # 새 저장소의 출발점
└─ deploy-config        # 설정 저장소 (STR 06) — 어느 쪽이든 따로
# .github/CODEOWNERS — 모노레포에서 권한을 경로로 나눔
/services/payment/   @myorg/payments-team
/libs/               @myorg/platform-team
/.github/workflows/  @myorg/platform-team
무엇이 어디서 아픈가 — 색이 진할수록 부담이 큽니다 모노레포 폴리레포 빌드 · CI 바뀐 범위만 빌드할 도구가 필요 저장소마다 단순 · 대신 N벌 버전 한 커밋 = 전체가 일관 서비스별 semver · 호환 관리 권한 CODEOWNERS 로 경로별 저장소 단위로 깔끔 공유 코드 리팩터링 PR 하나로 전부 수정 저장소 N개 · PR N개 · 순서 조율 규모 한계 수백 서비스면 clone · CI 투자 템플릿이 서비스마다 표류 부담 적음 → 큼 공짜인 쪽은 없습니다 — 아픈 곳을 도구로 메울 수 있는 쪽을 고릅니다
모노레포는 빌드 도구로, 폴리레포는 템플릿과 버전 규율로 비용을 냅니다.

서비스 템플릿 — 새 서비스가 첫날부터 같은 모양

templates/service/
├─ Dockerfile            # 멀티스테이지 · 비루트 사용자 · 고정 베이스 이미지
├─ .dockerignore
├─ src/
│  ├─ health.*           # /healthz (살아 있나) · /readyz (받을 수 있나)
│  └─ logging.*          # JSON 로그 · trace_id 필드 · 레벨은 환경 변수
├─ deploy/
│  ├─ base/              # 프로브 · 자원 요청 · 라벨 규칙이 이미 들어 있음
│  └─ overlays/{dev,stg,prod}/
├─ .github/workflows/ci.yml   # 공용 재사용 워크플로를 부르는 10줄
└─ README.md             # 온콜 · 대시보드 · 런북 링크 칸

공유 라이브러리 버전 규칙

상황규칙
모노레포소스로 참조 · 바꾸면 쓰는 서비스를 같은 PR 에서 같이 고침
폴리레포semver 로 배포 · 깨는 변경은 메이저 올림 · Renovate 같은 봇으로 갱신 PR
무엇을 넣나로그 · 트레이스 · 인증 검증처럼 모두가 똑같아야 하는 것만
넣지 말 것도메인 모델 — 공유하면 서비스가 다시 한 덩어리로 묶입니다
템플릿은 복사하는 순간부터 표류합니다. 템플릿 도구(cookiecutter · copier · Backstage 소프트웨어 템플릿 등)로 찍어 내고, 공통 부분은 재사용 워크플로 · library 차트처럼 참조로 두어야 고칠 곳이 하나로 유지됩니다.
정리 — 팀이 몇 개 안 되고 공유 코드가 많으면 모노레포 + CODEOWNERS, 팀과 배포 주기가 확실히 갈라지면 폴리레포 + 템플릿 + semver. 어느 쪽이든 설정 저장소는 따로, 공유 라이브러리에는 도메인 모델을 넣지 않습니다.
STR 08

환경 · 비밀 분리 — 디렉터리 vs 브랜치 · 시크릿 도구의 자리 · 값 우선순위

Sealed Secrets · SOPS · External Secrets 의 동작과 비교표는 ☸️ 도커 · 쿠버네티스 탭 d08 에 있습니다. 여기서는 저장소 안 어디에 무엇이 놓이는지와, 환경을 브랜치가 아니라 디렉터리로 나눠야 하는 이유를 봅니다.

같은 이미지를 승격합니다 — 환경마다 다른 것은 overlay 의 값뿐입니다 빌드 1회 sha-3f2a9c1 dev 자동 sync stg 자동 · E2E 통과 prod PR 승인 후 overlays/dev replicas 1 · 로그 debug overlays/stg replicas 2 · 운영과 같은 구성 overlays/prod replicas 4 · hpa · pdb 같은 digest 가 그대로 올라갑니다 — 다시 빌드하지 않습니다 ❌ 브랜치 방식 (dev · stg · prod 브랜치) — 환경마다 다시 빌드 · 체리픽 · 머지 충돌 → prod 것이 stg 에서 검증한 것과 같다는 보장이 없음
환경은 디렉터리, 승격은 같은 digest 를 다음 overlay 에 적는 커밋 — 차이가 diff 한 화면에 보입니다.

비밀이 놓이는 자리 — 도구별

services/api/
├─ base/
│  └─ external-secret.yaml     # ① External Secrets — "어디서 가져오나"만
└─ overlays/
   ├─ dev/
   │  └─ secrets.enc.yaml      # ② SOPS — 값은 암호화, 키 이름은 평문
   └─ prod/
      └─ sealed-secret.yaml    # ③ Sealed Secrets — 그 클러스터 공개키로 봉인

.sops.yaml                     # SOPS 규칙 — 저장소 루트
# 평문 Secret · .env · 키 파일은 어디에도 없음 (STR 01 .gitignore)
한 저장소에서 셋을 섞어 쓰라는 뜻이 아니라 도구마다 놓이는 자리를 보인 것입니다. 하나를 골라 통일하세요 — 규모가 커지면 External Secrets 쪽으로 모이는 경우가 많습니다 (☸️ d08).

SOPS — 경로마다 다른 키

# .sops.yaml — 위에서부터 처음 맞는 규칙 하나가 적용
creation_rules:
  - path_regex: overlays/prod/.*\.enc\.yaml$
    encrypted_regex: ^(data|stringData)$   # 값만 암호화
    age: age1...prod-공개키...             # prod 키는 운영자만
  - path_regex: overlays/(dev|stg)/.*\.enc\.yaml$
    encrypted_regex: ^(data|stringData)$
    age: age1...dev-공개키...
sops -e -i services/api/overlays/prod/secrets.enc.yaml   # 제자리 암호화
sops services/api/overlays/prod/secrets.enc.yaml          # 편집기로 열기

설정 값 우선순위 — 아래가 위를 이깁니다

순출처저장소 위치넣는 값
1코드 기본값앱 저장소어디서나 안전한 값 — 타임아웃 · 페이지 크기
2공통 설정 (ConfigMap · values.yaml)base/환경과 무관한 운영 값
3환경별 덮어쓰기overlays/<env>/replicas · 로그 레벨 · 외부 주소
4비밀 (Secret 으로 주입)참조 · 암호화 파일만DB 비밀번호 · API 키
5실행 시 인자 · 긴급 플래그없음 — 기록을 남기고 곧 저장소로장애 중 임시 조치
5번이 오래 남으면 drift 입니다. 장애 때 kubectl set env 로 바꾼 값은 Argo CD selfHeal 이 켜져 있으면 곧 되돌려지고, 꺼져 있으면 저장소와 클러스터가 영영 달라집니다. 어느 쪽이든 같은 날 저장소에 반영하는 것이 규칙입니다.
정리 — 환경은 브랜치가 아니라 디렉터리, 승격은 같은 digest. 저장소에는 비밀의 참조나 암호문만 두고, 값은 코드 기본값 → base → overlay → Secret 순으로 덮어씁니다.
STR 09

CI/CD 파이프라인 파일 — 재사용 워크플로 · 환경 승인 · 아티팩트 흐름

빌드 · 스캔 · 푸시 워크플로 한 벌과 배포 체크리스트는 ☸️ 도커 · 쿠버네티스 탭 d13 에 있습니다. 서비스가 늘면 같은 YAML 이 저장소마다 복사되고 조금씩 달라집니다. 여기서는 .github/workflows 를 공용 조각과 호출부로 나누고, 빌드는 한 번, 승격은 digest 로 흐르게 하는 배치를 봅니다.

.github/ 정리 — 공용 조각과 호출부

.github/
├─ workflows/                # 하위 폴더 불가 — 이 폴더에 평평하게
│  ├─ _build-image.yml       # 재사용 (workflow_call) — 밑줄은 팀 관례
│  ├─ _bump-tag.yml          # 설정 저장소의 태그 갱신
│  ├─ api.yml                # 호출부 — 서비스당 20줄 안팎
│  ├─ worker.yml
│  └─ infra-checks.yml       # kubeconform · conftest · tflint (STR 10)
├─ actions/
│  └─ setup-tools/action.yml # 여러 단계 묶음 = composite action
└─ CODEOWNERS                # workflows/ 는 플랫폼 팀 리뷰
재사용 워크플로는 job 전체를, composite action 은 step 몇 개를 묶습니다. 여러 저장소가 쓰면 myorg/ci-templates 같은 저장소로 옮기고 @v1 태그로 고정해 부릅니다.

호출부 — 서비스는 무엇만 다른가

# .github/workflows/api.yml
name: api
on:
  push:
    branches: [main]
    paths: ["services/api/**"]      # 모노레포 — 바뀐 서비스만
jobs:
  build:
    uses: ./.github/workflows/_build-image.yml
    with:
      service: api
    permissions:
      contents: read
      packages: write
  stg:
    needs: build
    uses: ./.github/workflows/_bump-tag.yml
    with:
      service: api
      stage: stg
      digest: ${{ needs.build.outputs.digest }}
    secrets: inherit
  prod:
    needs: [build, stg]
    uses: ./.github/workflows/_bump-tag.yml
    with:
      service: api
      stage: prod
      digest: ${{ needs.build.outputs.digest }}
    secrets: inherit
가로줄 하나가 역할 하나 — 사람이 끼는 곳은 prod 승인 한 군데 개발자 CI 승인자 Argo CD main 머지 prod 승인 = GitHub environment 의 필수 리뷰어 테스트 · 스캔 이미지 1회 빌드 digest 하나를 끝까지 들고 갑니다 stg 자동 반영 prod 승인 대기 prod 반영 레인을 넘는 화살표가 인수인계 — 그 수가 적을수록 배포가 빠르고 조용합니다
빌드는 한 번, 승인은 prod 앞 한 번 — 나머지는 전부 자동으로 흐릅니다.

재사용 워크플로 — 승인 게이트는 여기에

# .github/workflows/_bump-tag.yml
name: bump-tag
on:
  workflow_call:
    inputs:
      service: { type: string, required: true }
      stage:   { type: string, required: true }
      digest:  { type: string, required: true }
jobs:
  bump:
    runs-on: ubuntu-latest
    environment: ${{ inputs.stage }}   # prod 환경에 필수 리뷰어 → 여기서 대기
    steps:
      - uses: actions/checkout@v5
        with:
          repository: myorg/deploy-config
          token: ${{ secrets.DEPLOY_CONFIG_TOKEN }}
      - name: digest 고정 커밋
        working-directory: services/${{ inputs.service }}/overlays/${{ inputs.stage }}
        run: |
          kustomize edit set image shop-${{ inputs.service }}=ghcr.io/myorg/shop-${{ inputs.service }}@${{ inputs.digest }}
          git config user.name "ci-bot"
          git config user.email "ci-bot@users.noreply.github.com"
          git diff --quiet || git commit -am "deploy(${{ inputs.stage }}): ${{ inputs.service }}"
          git push

규칙 네 가지

규칙이유
빌드는 한 번stg 와 prod 가 같은 바이너리 — 환경마다 빌드하면 검증이 무의미해집니다
태그보다 digest태그는 다시 밀어 넣을 수 있고 digest 는 내용의 해시입니다
environment 는 재사용 쪽에재사용 워크플로를 부르는 job 에는 environment: 를 쓸 수 없습니다
권한은 최소로job 마다 permissions: · 설정 저장소 토큰은 그 저장소에만
_build-image.yml 은 on: workflow_call 아래 outputs.digest 를 선언하고 docker/build-push-action 의 digest 출력을 그대로 넘깁니다. 빌드 · 푸시 단계 자체는 ☸️ d13 의 것을 그대로 옮기면 됩니다.
정리 — .github/workflows 는 밑줄 붙은 재사용 조각 + 서비스별 짧은 호출부. 이미지는 한 번 빌드해 digest 로 승격하고, 사람의 승인은 재사용 워크플로의 environment 에 겁니다.
STR 10

한 장 요약 — 유형 · 규모별 권장 구조 · 운영 문서 · 규칙을 도구로 강제하기

구조는 문서로 정하면 석 달 안에 무너집니다. 마지막 섹션은 앞의 아홉 섹션을 표 한 장으로 줄이고, 운영 문서를 어디에 두는지, 그리고 정한 규칙을 pre-commit 과 CI 가 지키게 하는 법으로 끝냅니다. 전체 아키텍처 그림은 🧬 전문가 탭 z14, 트래픽 규모별 견본은 📈 대규모 탭 x09 를 보세요.

유형 · 규모권장 구조지키는 도구다음 단계 신호
서버 1대nginx/conf.d + snippets/ · runbooks/ (STR 02)nginx -t 를 CI 에서서버 2대째 · 설치가 달라짐
서버 1~3대 · 서비스 5개compose.yaml + override + prod (STR 03)docker compose config무중단 · 자동 복구 필요
클러스터 1 · 팀 1앱 저장소 deploy/ base + overlays (STR 04)kubeconform · conftest서비스 2개째 · 팀 2개째
서비스 · 팀 여럿설정 저장소 + app-of-apps · ApplicationSet · AppProject (STR 06)CODEOWNERS · 렌더 diff클러스터 · 리전이 늘어남
클러스터 · 리전 여럿terraform modules/ + live/ (환경 × 층) (STR 05)tflint · PR 에 plan플랫폼 팀 분리
MSA 코드모노레포 + CODEOWNERS 또는 폴리레포 + 템플릿 (STR 07)재사용 워크플로 (STR 09)팀 · 배포 주기가 갈라짐
무엇으로 시작할 것인가 — 마름모가 질문, 초록 상자가 답입니다 서버 1~3대 · 서비스 몇 개뿐? 아니오 예 nginx/ + Compose 한 저장소 · STR 02 · 03 서비스 · 팀이 하나뿐인가? 예 deploy/ + Kustomize 앱 저장소 안 · STR 04 아니오 클러스터 · 환경이 여럿인가? 예 live/ 분리 + AppSet Terraform · STR 05 · 06 아니오 설정 저장소 app-of-apps · STR 06 어느 갈래든 비밀 · 상태는 저장소 밖 (STR 01) 규칙을 정했다면 → 아래 pre-commit · CI 로 고정 구조는 한 번 고르는 게 아니라 신호가 올 때 다시 이 그림을 타는 것입니다
질문 셋이면 시작 구조가 정해집니다 — 대부분은 Compose 나 deploy/ + Kustomize 에서 출발합니다.

운영 문서는 코드 옆에

runbooks/
├─ api-5xx.md               # 알림 이름 = 파일 이름 → 알림의 runbook_url
└─ db-replication-lag.md    # 뜻 · 먼저 볼 대시보드 · 즉시 완화 · 연락처
docs/
├─ adr/
│  ├─ 0001-gitops-with-argo-cd.md      # 맥락 · 결정 · 결과
│  └─ 0002-split-tf-state-by-layer.md  # 번호는 재사용하지 않음
├─ oncall.md                # 당번표 위치 · 에스컬레이션 순서
└─ architecture.md          # 한 장 그림 (🧬 z14 형식)
문서규칙
런북알림 하나에 한 장 · 알림 규칙에 링크 필수 (🔭 관측 탭 알림 섹션)
ADR되돌리기 어려운 결정만 · 바뀌면 새 ADR 이 옛 것을 "대체됨"으로
온콜사람 이름이 아니라 역할과 연락 경로 — 퇴사자가 남지 않게
위키가 아니라 저장소에 두는 이유는 하나입니다 — 구조를 바꾸는 PR 에 문서 수정이 같이 들어오게 하려는 것입니다.

규칙을 도구로 강제하기 🟡 중급

① 커밋 전 — pre-commit

# .pre-commit-config.yaml (rev 는 예시 — pre-commit autoupdate 로 고정)
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: check-yaml
        args: [--allow-multiple-documents]
        exclude: ^charts/.*/templates/    # Helm 템플릿은 YAML 이 아님
      - id: detect-private-key
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks                      # 토큰 · 키 패턴 차단
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.96.1
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint

② CI — 렌더 결과를 검사

# .github/workflows/infra-checks.yml 의 run 부분
for d in k8s/services/*/overlays/*/; do
  kubectl kustomize "$d" > rendered.yaml
  kubeconform -strict -summary -ignore-missing-schemas rendered.yaml
  conftest test -p policy rendered.yaml
done
tflint --recursive

③ 조직 규칙 — conftest (Rego)

# policy/deployment.rego — conftest 는 기본으로 package main 을 읽음
package main

deny contains msg if {
  input.kind == "Deployment"
  some c in input.spec.template.spec.containers
  not c.resources.limits.memory
  msg := sprintf("%s/%s: memory limit 이 없습니다", [input.metadata.name, c.name])
}

deny contains msg if {
  input.kind == "Deployment"
  some c in input.spec.template.spec.containers
  endswith(c.image, ":latest")
  msg := sprintf("%s/%s: latest 태그 금지", [input.metadata.name, c.name])
}
도구잡는 것
kubeconform스키마 위반 · 오타 필드 (-strict) — CRD 는 스키마 위치를 따로 지정
conftest / OPA"limit 필수 · latest 금지" 같은 우리 규칙
tflint잘못된 인스턴스 타입 · 안 쓰는 변수 · 명명 규칙
gitleaks커밋에 섞인 토큰 · 키
이 도구들은 이 PC 에 설치하지 않아 실행하지 않았고, 설정은 각 공식 문서 기준입니다. Rego 는 OPA 1.0 문법(contains · if)이라 예전 conftest 에서는 맨 위에 import rego.v1 이 필요할 수 있습니다. 위반이 이미 많은 저장소는 새 위반만 막는 것부터 시작하세요.
정리 — 시작은 대부분 Compose 나 deploy/ + Kustomize, 서비스와 팀이 늘면 설정 저장소, 클러스터가 늘면 Terraform 층 분리. 정한 규칙은 pre-commit · kubeconform · conftest · tflint 로 사람 대신 도구가 지키게 합니다.