웹서버 —
요청은
어떻게 흘러가나
이 가이드는 서버를 한 번도 만져 본 적 없는 사람을 기준으로 시작합니다. 설치 → 첫 페이지 → 리버스 프록시 → HTTPS → 언어별 배포까지 막히지 않게 명령을 그대로 적어 두었고, 이후 성능 튜닝·보안 운영·내부 구조로 이어집니다. Nginx · Apache · Tomcat · Caddy 를 각각 전용 탭으로 다룹니다.
웹서버란 무엇인가
브라우저 주소창에 무언가를 치면, 어딘가의 컴퓨터가 그 요청을 받아 답을 돌려줍니다. 그 "받아서 돌려주는 프로그램"이 웹서버입니다.
정적과 동적 — 이 구분이 전부의 출발점
정적 (Static)
브라우저: /about.html 주세요
웹서버 : (디스크에서 파일을 읽어) 여기 있습니다
· HTML · CSS · JS · 이미지
· 누가 요청해도 같은 내용
· 웹서버 혼자서 처리 가능
· 매우 빠름
동적 (Dynamic)
브라우저: /orders/1234 주세요
웹서버 : (앱에게 물어봄) → 앱 → DB 조회
앱 : 이 사람의 주문 정보입니다
웹서버 : 여기 있습니다
· 사람마다 · 시점마다 내용이 다름
· 웹서버 혼자서는 못 만듦 → 앱이 필요
· 웹서버 — 요청을 받고 정적 파일을 돌려주며, 동적 요청은 뒤로 넘깁니다 (Nginx · Apache)
· WAS (웹 애플리케이션 서버) — 코드를 실행해 결과를 만듭니다 (Tomcat · Gunicorn · Node)
· 앱 — 우리가 작성한 프로그램 (Spring · Django · Express)
실무에서는 웹서버(앞) + WAS/앱(뒤) 조합이 표준입니다.
그냥 앱만 띄우면 안 되나요
됩니다. Node·Spring Boot·Django 모두 자체 서버 기능이 있어 혼자서도 요청을 받습니다. 실제로 개발 중에는 그렇게 씁니다. 그런데 실서비스에서는 거의 항상 앞에 웹서버를 둡니다. 이유는 이렇습니다.
| 앞단 웹서버가 하는 일 | 안 두면 생기는 문제 |
|---|---|
| 정적 파일 서빙 | 이미지·CSS 요청까지 앱이 처리하느라 정작 로직 처리가 밀립니다 |
| HTTPS 처리 | 앱마다 인증서를 관리해야 하고, 언어별로 방식이 제각각입니다 |
| 여러 앱 통합 | 한 도메인에서 /api 는 자바, /admin 은 파이썬 같은 구성이 불가능해집니다 |
| 부하 분산 | 앱 서버를 여러 대로 늘릴 수 없습니다 |
| 느린 클라이언트 흡수 | 모바일 3G 사용자 한 명이 앱 워커를 수 초간 붙잡습니다 |
| 보안 · 접근 제어 | 앱이 인터넷에 직접 노출됩니다 |
| 압축 · 캐시 | 같은 응답을 매번 새로 만듭니다 |
요청이 흐르는 길
주소를 치고 화면이 뜨기까지 무슨 일이 일어나는지 — 이 길을 알아야 어디서 막혔는지 찾을 수 있습니다.
example.com → 93.184.x.x. 여기서 실패하면 "사이트를 찾을 수 없음".GET /orders/1234 HTTP/1.1 + 헤더들. 이제야 "무엇을 원하는지" 전달됩니다.location 설정입니다. 여기서 잘못되면 404.· 사이트를 못 찾음 → 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
time_starttransfer(첫 바이트까지)가 크면 서버·앱·DB 처리가 느린 것이고,
total 과의 차이가 크면 응답 본문 전송이 느린 것입니다(용량·네트워크).
time_appconnect 가 크면 TLS 협상이 오래 걸린 것입니다.
4대 웹서버 비교
아래 버튼을 눌러 서버를 바꿔 보세요. 성격 · 강점 · 함정 · 대표 설정이 함께 바뀝니다.
한 장 비교표
| 항목 | Nginx | Apache | Tomcat | Caddy |
|---|---|---|---|---|
| 분류 | 웹서버 · 프록시 | 웹서버 | WAS (자바) | 웹서버 · 프록시 |
| 처리 방식 | 이벤트 기반 | MPM 선택 (prefork·worker·event) | 스레드 기반 | 고루틴 기반 |
| 동시 접속 효율 | 매우 높음 | 보통 (event MPM 이면 개선) | 보통 | 높음 |
| 자동 HTTPS | ✗ (직접 설정) | ✗ | ✗ | ✓ 기본 제공 |
| 동적 언어 직접 실행 | ✗ | 모듈로 가능 (mod_php 등) | ✓ 자바 | ✗ |
| 설정 난이도 | 보통 | 장황함 | XML · 보통 | 매우 쉬움 |
| 주요 설정 파일 | nginx.conf | httpd.conf·apache2.conf | server.xml | Caddyfile |
| 기본 포트 | 80 · 443 | 80 · 443 | 8080 · 8443 | 80 · 443 |
| 이 가이드 탭 | 🟩 Nginx | 🦅 Apache | 🐱 Tomcat | 🔒 Caddy |
고르는 기준
🟢 처음 배운다면
- Nginx로 시작하세요 — 자료가 가장 많습니다
- 개인 프로젝트에 HTTPS만 빨리 붙이고 싶으면 Caddy
- 자바를 배운다면 Tomcat은 어차피 만나게 됩니다
🔵 실무라면
- 회사가 이미 쓰는 것을 그대로 (대개 Nginx)
- 레거시 시스템·워드프레스면 Apache일 확률이 높습니다
- 자바 앱은 Nginx + Tomcat 또는 Nginx + Spring Boot
🟣 규모가 커지면
- 앞단에 CDN·클라우드 LB가 추가됩니다
- 쿠버네티스면 Ingress Controller(대부분 Nginx 기반)
- 서버 종류보다 캐시·분산·관측 설계가 중요해집니다
설치 ① 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
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 | 서비스 로그 실시간 보기 |
설치 ② 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;
}
}
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
· 권한 오류 — 컨테이너 안 사용자와 호스트 파일 소유자가 다를 수 있습니다
첫 페이지 띄우기
내가 만든 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
· 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 apache2Caddy
# /etc/caddy/Caddyfile
mysite.local {
root * /var/www/mysite
file_server
}
# sudo systemctl reload caddy
# 실제 도메인이면 HTTPS 까지 자동입니다① 폴더에 실행(
x) 권한이 있어야 들어갈 수 있습니다:
chmod 755 /var/www/mysite
② 파일은 읽기 권한:
chmod 644 index.html
③ 상위 폴더 전부에 접근 권한이 있어야 합니다 (홈 디렉터리 아래에 두면 자주 막힙니다)
④ SELinux 가 켜진 RHEL 계열이면:
sudo restorecon -Rv /var/www/mysite
설정 파일 구조 읽는 법
남이 써 놓은 설정 파일을 열었을 때 당황하지 않으려면 구조의 규칙만 알면 됩니다.
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.conf | sites-available/ → sites-enabled/ 심볼릭 링크 |
| Nginx (RHEL) | /etc/nginx/nginx.conf | /etc/nginx/conf.d/*.conf |
| Apache (Ubuntu) | /etc/apache2/apache2.conf | sites-available/ + a2ensite |
| Apache (RHEL) | /etc/httpd/conf/httpd.conf | /etc/httpd/conf.d/*.conf |
| Tomcat | $CATALINA_HOME/conf/server.xml | conf/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 가 여러 겹이면 어느 파일의 설정이 실제로 적용되는지 헷갈립니다.
이 명령은 최종 결과를 한 번에 보여 줍니다.
포트 · 방화벽 · 권한
서버가 안 뜨거나 접속이 안 될 때, 원인의 대부분이 이 셋 중 하나입니다.
포트
# 지금 누가 쓰고 있나
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 을 잡고 있는 경우가 흔합니다
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 도 확인해야 합니다
# (서버 안 방화벽만 열어도 클라우드 쪽에서 막혀 있으면 안 됩니다)
·
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 {} \;
sudo ausearch -m avc -ts recent 로 차단 로그를 확인하고,
sudo restorecon -Rv /var/www/mysite 로 컨텍스트를 복구하세요.
프록시를 쓸 때는 sudo setsebool -P httpd_can_network_connect 1 도 필요합니다.
SELinux 를 끄는 것은 마지막 수단이며 권장되지 않습니다.
로그 읽기
문제가 생기면 로그부터 보세요. 추측으로 설정을 고치는 것보다 훨씬 빠릅니다.
| 로그 | 내용 | 위치 (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
리버스 프록시란
웹서버가 요청을 받아 뒤쪽 앱에 대신 물어보고 결과를 돌려주는 구조입니다. 현대 웹 구성의 기본형입니다.
정방향 프록시 (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 + 방화벽으로 제한하세요.
오류 사전
증상이나 오류 코드로 검색하세요. 원인과 해결책이 바로 나옵니다.
| 증상 | 원인과 해결 |
|---|---|
| 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 |
curl -v 로 실제 응답을 확인합니다
③ 앱에 직접 요청해 봅니다(curl localhost:3000) — 프록시 문제인지 앱 문제인지 갈립니다
④ 설정을 최소 상태로 되돌려 하나씩 다시 붙입니다
학습 로드맵
목적에 따라 필요한 탭이 다릅니다. 최단 경로를 정리했습니다.
| 목적 | 순서 |
|---|---|
| 개인 프로젝트 배포 | 🧭 시작 → 🔒 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시간 기준)
Nginx —
앞단의
표준
오늘날 웹 인프라의 앞단은 대부분 Nginx 입니다. 정적 파일 · 리버스 프록시 · HTTPS · 로드밸런싱 · 캐시 · 접근 제어를 한 프로그램이 처리합니다. 이 탭은 설정 문법부터 시작해 실무에서 실제로 쓰는 구성을 순서대로 쌓아 올립니다.
마스터 · 워커 구조
Nginx가 적은 메모리로 수만 연결을 감당하는 이유가 여기 있습니다. 연결마다 스레드를 만들지 않습니다.
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 로 "준비된 연결"만 처리
· 기다리는 연결은 자원을 거의 안 씀
· 메모리 사용량이 연결 수에 거의 비례하지 않음
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개를 씁니다
설치와 디렉터리
파일이 어디에 있는지만 알면 절반은 끝납니다.
| 경로 | 내용 |
|---|---|
/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.org 의 공식 저장소를 등록하면 mainline 또는 stable 최신 버전을 쓸 수 있습니다.
다만 배포판 패키지와 경로 구조가 조금 다르므로 문서를 확인하세요.
막혔을 때 먼저 칠 명령
| 알고 싶은 것 | 명령 |
|---|---|
| 설정 문법이 맞나 | 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 가 느린가 앱이 느린가"를 놓고
몇 시간을 허비하게 됩니다.
설정 문법
문법은 단순합니다 — 지시어와 블록 둘뿐입니다.
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 |
| http | HTTP 전역 — 압축·로그 포맷·타임아웃·업스트림 |
| server | 사이트 하나 — 포트·도메인·인증서 |
| location | URL 경로별 처리 |
| upstream | 백엔드 서버 그룹 (http 안에) |
| stream | TCP·UDP 프록시 (HTTP 가 아닌 것 — DB·SMTP 등) |
자주 쓰는 내장 변수
| 변수 | 값 |
|---|---|
$host | 요청의 Host 헤더 (없으면 server_name) |
$remote_addr | 접속한 클라이언트 IP |
$request_uri | 원본 URI 전체 (쿼리스트링 포함) |
$uri | 정규화된 URI (rewrite 로 바뀔 수 있음) |
$args | 쿼리스트링 |
$scheme | http 또는 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 를 습관으로 만드세요.
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 설정이 대표적인 대상입니다.
^~ 는 "여기서 멈춰라" 라는 뜻입니다 — 정적 파일 경로에 붙여 두면 아래의 정규식 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 으로 먼저 잡아 프록시로 넘기세요 —
위 예시가 그 구조입니다.
location 매칭 규칙
Nginx 설정에서 가장 많이 헷갈리는 부분입니다. 순서대로 위에서 아래로 찾는 것이 아닙니다 — 우선순위 규칙이 따로 있습니다.
server {
location = /images/logo.png { … } # ① 정확히 일치 (최우선 · 즉시 확정)
location ^~ /static/ { … } # ② 접두사 + 정규식 건너뛰기
location ~ \.(jpg|png)$ { … } # ④ 정규식 (대소문자 구분)
location ~* \.(jpg|png)$ { … } # ④ 정규식 (대소문자 무시)
location /api/ { … } # ③ 일반 접두사
location / { … } # ③ 가장 짧은 접두사 = 최후의 보루
}
요청 경로
선택된 location
설정된 location 목록
| ① | = /images/logo.png |
| ② | ^~ /static/ |
| ④ | ~ \.(jpg|png)$ |
| ④ | ~* \.(jpg|png)$ |
| ③ | /api/ |
| ③ | / |
규칙을 말로 풀면
=로 정확히 일치하는 것이 있으면 거기서 끝입니다- 없으면 가장 길게 일치하는 접두사를 기억해 둡니다
- 그 접두사가
^~였다면 정규식을 아예 보지 않고 확정합니다 - 아니면 정규식들을 설정 파일에 적힌 순서대로 검사해 처음 일치하는 것을 씁니다
- 정규식에 하나도 안 걸리면 2에서 기억해 둔 접두사 매칭을 씁니다
· 정적 파일 경로에는
^~ 를 붙이세요 — 불필요한 정규식 검사를 건너뜁니다
·
= 는 아주 자주 호출되는 한 경로에 (예: = /health, = /favicon.ico)
· 정규식은 최소한으로 — 개수가 많아지면 매 요청마다 순회 비용이 듭니다
·
location / 는 반드시 하나 두세요 (없으면 404)
location /api(슬래시 없음)는 /apitest 도 걸립니다.
의도한 것이 /api/ 하위라면 끝에 슬래시를 붙이거나
location ^~ /api/ 로 명확히 하세요.
정적 파일 · 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;
}
/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 → 캐시 금지
이 조합이 프런트엔드 배포의 표준입니다.
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; 와 변수 사용으로 해결합니다.
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 만 넣습니다
① 브라우저가 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 에 그대로 적용)
로드 밸런싱 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 의 헬스체크에 맡깁니다.
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 — 작은 파일이 많은 페이지에서 효과가 큽니다(요청을 한 연결에 다중화). 이미 번들링된 SPA 라면 체감 차이가 작을 수 있습니다
· HTTP/3 — 불안정한 모바일 네트워크에서 특히 유리합니다(패킷 손실에 강함). 유선 환경에서는 차이가 크지 않습니다
· 둘 다 HTTPS 가 전제입니다
자세한 원리는 🧬 전문가 z05 참고.
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 안에서 계속 진행 |
redirect | 302 임시 리다이렉트 (브라우저에 응답) |
permanent | 301 영구 리다이렉트 |
· 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 으로 푸는 것이 정석입니다.
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 은 항상 압축되므로 목록에 넣지 않습니다
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
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";
원칙 — 개인화된 응답은 캐시 대상에서 명시적으로 제외하고, 캐시 대상은 화이트리스트로 관리하세요.
캐시 무효화
# 오픈소스 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 로 운영
업로드 용량 · 타임아웃
파일 업로드가 실패하는 원인은 거의 정해져 있습니다.
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 로 스트리밍 전달 |
대용량 파일은 우회하는 것이 정답
수백 MB~GB 파일을 웹서버와 앱을 거쳐 올리는 것은 메모리·디스크·타임아웃 모두에 부담입니다. 실무에서는 클라이언트가 스토리지에 직접 업로드하는 방식을 씁니다.
① 브라우저 → 앱 : "파일 올릴 건데 업로드 주소 좀"
② 앱 → 브라우저 : 서명된 임시 업로드 URL (presigned URL)
③ 브라우저 → S3 등 스토리지 : 파일을 직접 전송 ← 서버를 거치지 않습니다
④ 브라우저 → 앱 : "다 올렸어요, 이 키로 저장됐습니다"
· 서버 부하 0 · 타임아웃 문제 없음 · 확장에 유리
로그 포맷 커스터마이징
기본 로그에는 응답 시간이 없습니다. 성능 분석을 하려면 반드시 추가해야 합니다.
# 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_status | HIT · 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;
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=20 | 20건까지는 즉시, 그 이후 버스트는 지연 (절충안) |
$binary_remote_addr | IP 를 이진으로 저장 — 문자열보다 메모리를 적게 씁니다 |
$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;
}
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;
}
}
①
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 | 양방향 | Upgrade·Connection·http_version 1.1 | 채팅 · 협업 편집 · 게임 로비 |
| SSE | 서버 → 클라 | proxy_buffering off | 알림 · 진행률 · 실시간 대시보드 — 훨씬 단순합니다 |
| 롱 폴링 | 서버 → 클라(흉내) | proxy_read_timeout 만 길게 | 구형 환경 호환 |
| gRPC 스트림 | 양방향 | grpc_pass + HTTP/2 | 서비스 간 통신 |
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;
}
}
무중단 재시작 · 검증
설정을 바꿀 때 접속을 끊지 않는 것이 기본입니다. 그리고 검증 없이 반영하지 마세요.
| 명령 | 동작 |
|---|---|
nginx -t | ★ 문법 검사 — 반영 전 반드시 |
nginx -T | 최종 설정 전체 출력 (include 펼침) |
nginx -s reloadsystemctl reload nginx |
★ 무중단 반영 — 새 워커를 띄우고 옛 워커는 처리 중인 요청을 마친 뒤 종료 |
systemctl restart nginx | 완전 재시작 — 순간 접속이 끊깁니다 |
nginx -s quit | 정상 종료 (처리 중인 요청을 마치고) |
nginx -s stop | 즉시 종료 (진행 중 요청도 끊김) |
nginx -s reopen | 로그 파일 다시 열기 (로테이션 후) |
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 우선순위 착각 → 엉뚱한 응답
반영 후 실제 요청으로 확인하는 단계를 반드시 넣으세요.
/etc/nginx 를 저장소로 두면 누가 언제 무엇을 바꿨는지 남고
되돌리기도 한 명령입니다.
더 나아가 Ansible·Terraform 으로 관리하면
서버가 통째로 날아가도 재현할 수 있습니다 → 🛡️ 운영 탭 o10.
Apache —
가장 오래된
웹서버
1995년부터 웹을 떠받쳐 온 서버입니다. 신규 구축에서는 Nginx 에 자리를 내줬지만, 기존 시스템·워드프레스·공유 호스팅에는 여전히 압도적으로 많습니다. 실무에서 Apache 를 만나는 이유는 대부분 유지보수이므로, 이 탭은 남이 만든 설정을 읽고 고치는 것에 초점을 둡니다.
MPM — 세 가지 처리 방식
Apache 는 요청을 어떻게 나눠 처리할지 골라 쓸 수 있습니다. 이 선택이 메모리 사용량과 동시 처리 능력을 결정합니다.
| MPM | 구조 | 메모리 | 동시성 | 쓰는 곳 |
|---|---|---|---|---|
| prefork | 요청당 프로세스 1개 | 많음 | 낮음 | 스레드 안전하지 않은 모듈(옛 mod_php)이 있을 때 |
| worker | 프로세스 × 스레드 | 중간 | 중간 | prefork 와 event 의 중간 |
| event | worker + 유휴 연결 분리 | 적음 | 높음 | ★ 2.4 기본값 — 특별한 이유가 없으면 이것 |
다만 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) ÷ 워커당 메모리 를 상한으로 잡으세요.
설치와 디렉터리
배포판마다 이름과 경로가 다릅니다. 이것만 정리하면 문서를 보고 헤매지 않습니다.
| 항목 | Ubuntu · Debian | RHEL · Rocky |
|---|---|---|
| 패키지·서비스명 | apache2 | httpd |
| 주 설정 파일 | /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-data | apache |
| 문서 루트 | /var/www/html | /var/www/html |
| 로그 | /var/log/apache2/ | /var/log/httpd/ |
| 제어 명령 | apache2ctl · apachectl | apachectl |
# 설치
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 가 먼저 잡고 있어서입니다.
sites-enabled 와 구조는 같지만, Apache 는 .htaccess 라는 한 층이 더 있습니다 — 편리한 만큼 느립니다.막혔을 때 먼저 칠 명령
| 알고 싶은 것 | 명령 | 보이는 것 |
|---|---|---|
| 어느 VirtualHost 가 처리하나 | apachectl -S | 포트별 VirtualHost 목록 · 기본이 무엇인지 |
| 이 모듈이 켜져 있나 | apachectl -M | grep 이름 | 로드된 모듈 전부 |
| 지금 MPM 이 무엇인가 | apachectl -V | grep MPM | prefork · worker · event |
| 설정이 최종적으로 어떻게 되나 | apachectl -t -D DUMP_CONFIG | include 를 전부 펼친 결과 |
| 문법이 맞나 | apachectl configtest | 오류 파일명과 줄 번호 |
| 왜 403 이 나나 | tail -f /var/log/apache2/error.log | 거부 이유가 그대로 찍힙니다 |
| 지금 무엇을 처리 중인가 | apachectl status(mod_status 필요) | 워커별 상태 · 요청 URL |
| 포트를 누가 잡고 있나 | sudo ss -tlnp | grep :80 | 충돌하는 프로세스 |
①
a2ensite 를 안 했다 — sites-available 만 고쳤습니다
② 다른 VirtualHost 가 먼저 매칭됐다 —
apachectl -S 로 순서 확인
③ 브라우저가 301 리다이렉트를 캐시했다 — 시크릿 창이나
curl -I 로 확인
세 번째가 특히 자주 사람을 붙잡습니다. 301 은 브라우저가 영구 저장하므로, 테스트 중에는 302 로 두고 확정된 뒤에 301 로 바꾸는 편이 안전합니다.
설정 구조 · 모듈
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_rewrite | URL 재작성 — 거의 항상 켭니다 |
mod_proxy · mod_proxy_http | 리버스 프록시 |
mod_ssl | HTTPS |
mod_headers | 응답 헤더 조작 (보안 헤더) |
mod_deflate | gzip 압축 |
mod_expires | 캐시 헤더 |
mod_remoteip | 프록시 뒤에서 실제 IP 복원 |
mod_proxy_fcgi | PHP-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(설정·상태 노출)는
인터넷에 열려 있으면 심각한 정보 유출이 됩니다.
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 가 어떤 순서인지 확인
ServerName·ServerAlias 를 비교하고,
어디에도 안 맞으면 첫 번째 VirtualHost 가 처리합니다.
Nginx 의 default_server 같은 명시적 지정이 없으므로,
파일 이름 순서(000-default)로 기본을 정하는 관례가 있습니다.
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() 호출이 반복됩니다.
설정 파일을 직접 고칠 수 있는 환경이라면 끄는 것이 정답이고,
공유 호스팅처럼 어쩔 수 없는 경우에만 켜세요.
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 지시어
| 옵션 | 의미 |
|---|---|
Indexes | index 파일이 없으면 디렉터리 목록을 보여 줍니다 — 운영에서는 반드시 -Indexes |
FollowSymLinks | 심볼릭 링크를 따라감 (성능상 권장) |
SymLinksIfOwnerMatch | 소유자가 같을 때만 따라감 (더 안전하지만 느림) |
ExecCGI | CGI 실행 허용 — 필요한 곳에만 |
MultiViews | 확장자 없이 접근 시 추측 — 예상 밖 동작의 원인이 되므로 끄는 편이 낫습니다 |
.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
.htaccess 의 내용을 <Directory /var/www/app> 블록 안에
그대로 복사하고 AllowOverride None 으로 바꾸면 됩니다.
RewriteBase 만 상황에 맞게 조정하세요.
성능이 개선되고 설정이 한 곳에 모입니다.
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]
| 플래그 | 의미 |
|---|---|
L | Last — 여기서 규칙 처리를 멈춥니다 (거의 항상 붙입니다) |
R=301 | 브라우저에 리다이렉트 응답 (301 영구 · 302 임시) |
QSA | Query String Append — 원래 쿼리스트링을 유지 |
NC | 대소문자 무시 |
P | 프록시로 전달 (mod_proxy 필요) |
F | 403 Forbidden 응답 |
E=VAR:val | 환경 변수 설정 |
디버깅 — 규칙이 왜 안 먹는지
# 2.4 부터는 LogLevel 로 rewrite 추적을 켭니다
LogLevel warn rewrite:trace3
# error.log 에 각 단계가 기록됩니다
# trace1(간단) ~ trace8(매우 상세) · 운영에서는 반드시 끄세요 (로그 폭증)
· 무한 루프 — 재작성 결과가 다시 같은 규칙에 걸립니다.
RewriteCond 로 탈출 조건을 넣으세요
· .htaccess 에서는 경로 앞의
/ 가 제거됩니다 — VirtualHost 와 패턴이 달라집니다
·
L 플래그가 .htaccess 에서는 완전히 끝내지 않습니다 — 재작성 후 다시 처음부터 돕니다.
확실히 끝내려면 END 플래그(2.3.9+)를 쓰세요
· 조건이 여러 개면 AND 입니다. OR 를 원하면
[OR] 플래그를 붙이세요
Redirect 301 /old /new 나 RedirectMatch 가
훨씬 빠르고 읽기 쉽습니다.
mod_rewrite 는 조건이 필요한 복잡한 경우에만 씁니다.
리버스 프록시 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/
secretRequired 와 address="127.0.0.1" 이 필요합니다.
특별한 이유가 없으면 그냥 HTTP 프록시를 쓰세요 — 성능 차이는 미미합니다.
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
sudo a2enmod http2 후 VirtualHost 에
Protocols h2 http/1.1 을 추가합니다.
다만 prefork MPM 에서는 HTTP/2 가 동작하지 않습니다 — event MPM 이 필요합니다.
인증서 발급·갱신·진단은 🔐 HTTPS 탭에서 자세히 다룹니다.
Nginx 와 지시어 대응 — 이름만 다릅니다
| 하는 일 | Nginx | Apache |
|---|---|---|
| 인증서 | ssl_certificate | SSLCertificateFile |
| 개인 키 | ssl_certificate_key | SSLCertificateKeyFile |
| 프로토콜 버전 | ssl_protocols TLSv1.2 TLSv1.3; | SSLProtocol -all +TLSv1.2 +TLSv1.3 |
| 암호 스위트 | ssl_ciphers | SSLCipherSuite |
| 서버 우선순위 | ssl_prefer_server_ciphers | SSLHonorCipherOrder |
| OCSP 스테이플링 | ssl_stapling on; | SSLUseStapling on |
| 세션 캐시 | ssl_session_cache | SSLSessionCache |
| HTTP/2 | listen 443 ssl; http2 on; | Protocols h2 http/1.1 |
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 MPM | apachectl -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
인증 · 접근 제어
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>
또 2.2 문법(
Order·Allow from)은 2.4 에서 폐기됐습니다.
옛 설정을 옮길 때는 Require 문법으로 바꿔야 합니다.
2.2 → 2.4 접근 제어 문법 변환
| 2.2 (폐기) | 2.4 |
|---|---|
Order allow,denyAllow from all | Require all granted |
Order deny,allowDeny from all | Require all denied |
Allow from 10.0.0.0/8 | Require ip 10.0.0.0/8 |
Allow from example.com | Require host example.com |
Require 지시어 — 쓸 수 있는 조건들
| 조건 | 뜻 | 예 |
|---|---|---|
Require all granted / denied | 전부 허용 / 거부 | 기본 정책 |
Require ip | IP · 대역 | 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
Require ip 10.0.0.0/8 이 모두 통과하거나 모두 차단되는 두 가지 결과 중 하나가 되는데,
앞쪽이면 관리자 페이지가 인터넷에 열립니다.
mod_remoteip 를 설정하고 RemoteIPTrustedProxy 를 반드시 명시하세요 —
안 그러면 헤더를 위조해 IP 제한을 우회할 수 있습니다.
압축 · 캐시 헤더
모듈을 켜고 설정하면 정적 자원 성능이 크게 달라집니다.
# 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.js | max-age=31536000, immutable | 내용이 바뀌면 이름이 바뀝니다 |
index.html | no-cache | 새 파일명을 알려 주는 문서 |
| 이미지(고정 이름) | max-age=2592000 (30일) | 바뀔 수는 있으나 드묾 |
| API 응답 | no-store | 사용자별 데이터 |
| 로그인 뒤 페이지 | no-store, private | 공용 PC 뒤로 가기 노출 방지 |
robots.txt·favicon | max-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"
로그와 진단
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 로 제한하세요.
Nginx vs Apache
감정적인 논쟁이 많은 주제입니다. 사실만 정리합니다.
| 항목 | Nginx | Apache |
|---|---|---|
| 동시 접속 효율 | 우수 (이벤트) | 보통 (event MPM 이면 개선) |
| 정적 파일 | 빠름 | 보통 |
| 동적 언어 직접 실행 | 불가 (항상 프록시) | 모듈로 가능 |
| 디렉터리별 설정 | 없음 | .htaccess |
| 설정 문법 | 간결 | 장황함 |
| URL 재작성 | 충분 | 표현력이 더 큼 |
| 모듈 생태계 | 보통 (동적 모듈) | 방대함 |
| 메모리 사용 | 적음 | 많음 |
| 자료·사례 | 최신 자료가 많음 | 오래된 자료가 많음 |
| 공유 호스팅 | 부적합 | 사실상 표준 |
언제 무엇을
🟢 Nginx 를 쓰세요
- 신규 구축
- 리버스 프록시·로드밸런서
- 정적 파일·SPA 서빙
- 동시 접속이 많은 서비스
- 컨테이너·쿠버네티스 환경
🔵 Apache 가 나은 경우
- 이미 Apache 로 돌아가는 시스템
- .htaccess 가 필요한 공유 호스팅
- 특정 Apache 모듈에 의존
- 워드프레스 등 기본 지원이 Apache 인 솔루션
- 팀이 Apache 에 익숙할 때
🟣 둘 다 쓰는 구성
- Nginx(앞) + Apache(뒤)
- Nginx 가 정적·TLS·캐시를 처리
- Apache 가 .htaccess 와 PHP 를 처리
- 레거시를 유지하며 점진적 이관
실무에서 Nginx 를 고르는 진짜 이유는 설정의 간결함·최신 자료의 양· 컨테이너 생태계와의 궁합입니다. 이미 잘 돌아가는 Apache 를 바꿀 이유는 대개 없습니다.
설정 변환 대조표
Apache 설정을 Nginx 로 옮길 때 하나씩 대응시키세요.
| 목적 | Apache | Nginx |
|---|---|---|
| 사이트 정의 | <VirtualHost *:80> | server { listen 80; } |
| 도메인 | ServerName · ServerAlias | server_name a.com b.com; |
| 문서 루트 | DocumentRoot /path | root /path; |
| 기본 파일 | DirectoryIndex index.html | index index.html; |
| 경로별 설정 | <Location /api> | location /api { } |
| 디렉터리 목록 금지 | Options -Indexes | autoindex off; (기본값) |
| 접근 허용 | Require all granted | (기본 허용) |
| IP 제한 | Require ip 10.0.0.0/8 | allow 10.0.0.0/8; deny all; |
| Basic 인증 | AuthType Basic … | auth_basic "…"; auth_basic_user_file …; |
| 리다이렉트 | Redirect 301 /old /new | return 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 인증서 | SSLCertificateFile | ssl_certificate |
| 압축 | AddOutputFilterByType DEFLATE | gzip on; gzip_types …; |
| 캐시 헤더 | ExpiresByType | expires 1y; |
| 응답 헤더 추가 | Header always set X "y" | add_header X "y" always; |
| 실제 IP 복원 | RemoteIPHeader X-Forwarded-For | real_ip_header X-Forwarded-For; |
| 로그 포맷 | LogFormat "…" name | log_format name '…'; |
| 문법 검사 | apachectl configtest | nginx -t |
| 무중단 반영 | apachectl graceful | nginx -s reload |
① .htaccess 를 쓰고 있었다면 그 내용을 전부 Nginx 설정으로 옮겨야 합니다 (Nginx 에는 대응 기능이 없습니다)
② mod_php 를 쓰고 있었다면 PHP-FPM 으로 전환해야 합니다
③ 복잡한 RewriteRule 은 동작이 미묘하게 다를 수 있으니 하나씩 테스트하세요
④ MultiViews·ContentNegotiation 같은 Apache 고유 기능은 대응 방법이 다릅니다
curl 로 주요 URL 을 양쪽에 요청해 응답을 비교합니다
③ 정적 파일·리다이렉트·프록시를 각각 확인합니다
④ 문제가 없으면 포트를 바꿔 전환하고, Apache 는 며칠간 남겨 둡니다
Tomcat —
자바 앱을
실행하는 엔진
Tomcat 은 웹서버가 아니라 WAS(웹 애플리케이션 서버)입니다. 자바 서블릿·JSP 를 실행하며, 실무에서는 거의 항상 Nginx 뒤에 둡니다. Spring Boot 를 쓴다면 이미 톰캣이 앱 안에 들어 있습니다 — 그래서 톰캣을 아는 것이 곧 자바 웹 운영을 아는 것입니다.
Tomcat은 무엇인가
"Nginx 가 있는데 왜 Tomcat 이 또 필요한가요?" — 역할이 다르기 때문입니다.
웹서버 (Nginx · Apache)
· 요청을 받고 응답을 보냄
· 정적 파일을 디스크에서 읽어 전달
· HTTPS · 압축 · 캐시 · 로드밸런싱
· 자바 코드는 실행하지 못함
WAS (Tomcat)
· 자바 서블릿·JSP 를 실행
· 요청마다 스레드를 배정해 코드 실행
· 세션 · 트랜잭션 · 커넥션 풀 관리
· 정적 파일도 줄 수는 있지만 비효율
/static/ 은 직접 응답, 나머지는 톰캣으로 넘깁니다.서블릿 컨테이너란
자바에는 서블릿(Servlet)이라는 표준 규격이 있습니다. "HTTP 요청을 받으면 이런 형태의 객체로 전달하고, 이런 형태로 응답을 돌려준다"는 약속입니다. Tomcat 은 이 규격을 구현한 프로그램이고, Spring 은 그 위에서 도는 프레임워크입니다.
| 구현체 | 특징 |
|---|---|
| Tomcat | 가장 널리 쓰임 · Spring Boot 기본값 · 자료가 압도적 |
| Jetty | 가볍고 임베디드에 적합 · 비동기 처리에 강점 |
| Undertow | 논블로킹 · 낮은 메모리 · WildFly 기반 |
| WebLogic · WebSphere · JEUS | 상용 WAS — 대기업·금융 기간계. 톰캣보다 많은 기능(EJB·JTA 등) |
javax.servlet → jakarta.servlet 으로 바뀌었습니다.
· Tomcat 9 이하 —
javax.*
· Tomcat 10 이상 —
jakarta.*
이 때문에 WAR 를 톰캣 10 이상으로 그냥 옮기면 동작하지 않습니다. Spring Boot 3 이상은
jakarta.* 기준입니다.
설치와 디렉터리
압축을 푸는 것이 곧 설치입니다. 폴더 네 개만 알면 됩니다.
# 자바 먼저 (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
HOME)으로 여러 인스턴스(BASE)를 돌릴 수 있습니다.
BASE 에 conf·webapps·logs 만 두면
업그레이드 시 HOME 만 교체하면 됩니다.
한 서버에 여러 앱을 올리는 환경에서 유용합니다.
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 "%r" %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 복원·접근 제어) |
SHUTDOWN 문자열을 보내면 누구나 톰캣을 종료시킬 수 있습니다.
·
port="-1" 로 아예 비활성화하거나
·
shutdown 문자열을 추측 불가능한 값으로 바꾸고
· 방화벽에서 외부 접근을 차단하세요
Connector — HTTP · AJP
동시 처리 능력이 여기서 결정됩니다.
| 속성 | 의미 | 기본값 |
|---|---|---|
maxThreads | 동시에 처리할 최대 요청 수 | 200 |
minSpareThreads | 미리 만들어 두는 스레드 | 10 |
acceptCount | 스레드가 다 찼을 때 대기열 길이 — 넘치면 연결 거부 | 100 |
maxConnections | 동시에 유지할 연결 수 (NIO 기준) | 8192 |
connectionTimeout | 연결 후 요청이 오기까지 대기 (ms) | 60000 |
keepAliveTimeout | 유휴 연결 유지 시간 | connectionTimeout 과 동일 |
maxHttpHeaderSize | 헤더 최대 크기 — 쿠키가 크면 늘려야 합니다 | 8192 |
URIEncoding | URL 인코딩 | UTF-8 (8.0+) |
프로토콜 구현체
| 구현 | 설명 |
|---|---|
NIO (Http11NioProtocol) | 기본값. 논블로킹 IO — 대부분 이것을 씁니다 |
| NIO2 | 비동기 IO. 특정 워크로드에서 이점이 있으나 차이가 크지 않습니다 |
| APR/native | OpenSSL 기반 네이티브. 9.0·10.0 에서 폐기 예고 → 10.1 에서 커넥터가 제거됐습니다 (11 에도 없음) |
| AJP | Apache 전용 이진 프로토콜 — 보안 이슈로 기본 비활성화 |
<!-- 프록시 뒤에 있을 때의 권장 설정
(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 인 경우가 있습니다.
프록시 뒤에 둘 때 꼭 맞춰야 하는 값
| 설정 | 안 맞추면 생기는 일 |
|---|---|
address="127.0.0.1" | 8080 포트가 인터넷에 노출 — 프록시의 보안 설정이 전부 우회됩니다 |
keepAliveTimeout | Nginx 값보다 짧으면 간헐적 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 로 끝납니다.
재현이 안 되고 부하가 낮을 때 오히려 자주 나는 것이 특징입니다
(유휴 시간이 길어야 생기므로).
백엔드 쪽을 항상 더 길게 잡는 것이 원칙입니다.
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 로 배포. 보안 위험이 크므로 운영에서는 제거 권장 |
| 병렬 배포 | ##버전 표기로 신구 버전을 동시에 두고 세션이 끝나면 옛 버전 제거 |
| 컨테이너 교체 | ★ 요즘 표준 — 이미지를 새로 빌드해 컨테이너를 갈아 끼웁니다 |
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 가 필요한 경우는 기존 WAS 에 배포해야 하는 조직 정책 정도입니다.
정적 자원 분리
이미지·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 "";
}
}
src/main/resources/static/ 의 파일은 jar 에 포함되므로
Nginx 가 직접 읽을 수 없습니다.
· 방법 ① — 빌드 시 정적 파일을 별도 폴더로 복사해 배포
· 방법 ② — 프런트엔드를 완전히 분리해 별도 빌드·배포 (권장)
· 방법 ③ — CDN·오브젝트 스토리지에 업로드
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 가 이벤트 루프로 |
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) 로 응답되는 혼란도 사라집니다.
세션 · 클러스터링
서버가 여러 대가 되는 순간 로그인 정보를 어떻게 공유할지 정해야 합니다.
| 방식 | 설명 | 평가 |
|---|---|---|
| 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;
스레드 풀 튜닝
maxThreads 를 무작정 올리면 오히려 느려집니다. 근거를 갖고 정해야 합니다.
산정하는 법
필요한 스레드 수 ≈ 목표 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 풀을 함께 맞추기
| 서비스 성격 | maxThreads | DB 풀 · 근거 |
|---|---|---|
| DB 위주 CRUD | 100~200 | 풀 10~20 — 요청 대부분이 DB 를 씁니다. 풀보다 스레드를 크게 잡아도 대기만 늘어납니다 |
| 외부 API 호출 위주 | 200~400 | 풀 10 — DB 는 거의 안 쓰고 네트워크 대기가 깁니다 |
| 계산 위주 | 코어 수 × 2 | 풀 5 — 더 늘리면 문맥 전환만 늘어납니다 |
| 정적 · 프록시 | — | 애초에 톰캣까지 오면 안 됩니다(t07) |
| 가상 스레드 Java 21+ | 제약 없음 | 풀이 유일한 상한이 됩니다 — 풀 크기를 반드시 재검토 |
커넥션 수 = (코어 수 × 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 제거 · 캐시 · 풀 크기 재조정 순입니다.
지표를 안 보고 스레드 수부터 올리는 것이 가장 흔한 잘못된 대응입니다.
maximum-pool-size 를 다시 검토하고,
속도 제한(rate limit)을 함께 두세요 —
수만 요청이 한꺼번에 들어와 DB 를 밀어붙이는 구조가 될 수 있습니다.
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'
로그 설정
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 "%r" %s %b %D "%{User-Agent}i" %{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>
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 로그에만 남고 톰캣 로그에는 없는지
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
무중단 배포
배포 중에도 처리 중인 요청이 끊기지 않게 만듭니다.
① 우아한 종료 (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.
보안 설정
기본 상태의 톰캣은 관리자 화면과 버전 정보가 노출돼 있습니다. 반드시 정리하세요.
① 기본 앱 제거 — 가장 먼저
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 을 방화벽에서 막고 프록시만 접근하게 |
문제 진단
응답이 없거나 느릴 때 무엇을 봐야 하는지 순서대로 정리합니다.
스레드 덤프 — 어디서 멈췄나
# 프로세스 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
| 상태 | 의미 |
|---|---|
RUNNABLE | 실행 중 또는 IO 대기 |
BLOCKED | 락을 기다림 — 많으면 동기화 병목 |
WAITING | 다른 스레드를 무한 대기 (커넥션 풀 대기 등) |
TIMED_WAITING | 시간 제한 대기 (sleep·poll) |
증상별 진단표
| 증상 | 확인 순서 |
|---|---|
| 응답 없음 (행) | ① 스레드 덤프 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
Caddy —
인증서를
알아서 해 주는
Caddy 의 존재 이유는 하나입니다 — HTTPS 를 설정 없이 자동으로. 도메인만 적으면 인증서를 발급받고 갱신까지 알아서 합니다. Go 로 만들어 바이너리 하나로 동작하고, 설정 문법이 압도적으로 간단합니다.
자동 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 스테이플링, 세션 재개
그럼에도 Nginx 가 여전히 주류인 이유
- 자료와 사례의 양 — 문제가 생겼을 때 검색 결과가 압도적으로 다릅니다
- 조직의 익숙함 — 운영팀이 아는 도구를 쓰는 것이 안전합니다
- 대규모 운영 사례 — 초대형 트래픽 환경의 검증 사례가 Nginx 쪽이 훨씬 많습니다
- 플러그인 방식 — Caddy 는 기능을 추가하려면 재빌드가 필요합니다
- 세밀한 제어 — Nginx 가 제공하는 저수준 튜닝 옵션이 더 많습니다
설치와 실행
바이너리 하나면 됩니다. 의존성이 없습니다.
# ── 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 발급 한도에 걸릴 수 있습니다.
발급이 안 될 때 — 원인 순서대로
| 로그에 나오는 것 | 원인 | 확인 명령 |
|---|---|---|
no valid A/AAAA records | DNS 가 이 서버를 안 가리킴 | 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
}
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) |
route { } 로 감싸세요.
# SPA 배포 — 이 세 줄이면 됩니다
example.com {
root * /var/www/spa
try_files {path} /index.html
file_server
}
리버스 프록시 · 로드 밸런싱
한 줄이면 프록시가 되고, 서버를 더 적으면 자동으로 분산됩니다.
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
}
}
}
경로별 라우팅
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
}
}
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 로 — 대응표
| 하고 싶은 것 | Nginx | Caddy |
|---|---|---|
| 프록시 | proxy_pass http://127.0.0.1:3000;+ proxy_set_header 4줄 | reverse_proxy 127.0.0.1:3000 |
| WebSocket | map $http_upgrade + Upgrade/Connection 헤더 | 자동(설정 없음) |
| 경로 접두사 제거 | location /api/ { proxy_pass .../; }끝 슬래시 유무가 동작을 바꿉니다 | handle_path /api/* |
| SPA 폴백 | try_files $uri $uri/ /index.html; | try_files {path} /index.html |
| HTTPS | certbot + ssl_certificate + cron | 도메인만 적으면 끝 |
| HTTP → HTTPS | return 301 https://... 서버 블록 추가 | 자동 |
| 헬스체크 기반 LB | Nginx Plus(유료) | health_uri — 오픈소스 |
| 압축 | gzip on; + 타입 목록 | encode gzip zstd |
| HTTP/3 | 별도 빌드·설정 | 기본 활성(443/udp 만 열면 됨) |
| 설정 재적용 | nginx -s reload | caddy reload · API 로도 가능 |
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 은
반드시 명시하세요 — 이것만으로 백엔드 장애가
프록시까지 무너뜨리는 것을 막을 수 있습니다.
자동 인증서 발급 원리
어떻게 자동으로 되는지 알아야 실패했을 때 진단할 수 있습니다.
/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
}
# 직접 준비한 인증서를 쓰려면
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"
}
정적 사이트 · 파일 서버
개발 중 임시 서버로도, 실제 배포용으로도 씁니다.
# 지금 폴더를 즉시 서빙 (개발용)
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 가 있으면 그것을 전송
}
}
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.js | max-age=31536000, immutable | 내용이 바뀌면 이름이 바뀝니다 — 무한 캐시가 안전 |
index.html | no-cache | 새 파일명을 알려 주는 유일한 문서 |
| 이미지(해시 없음) | max-age=3600 | 바뀔 수 있으나 자주는 아님 |
| API 응답 | no-store 또는 짧게 | 사용자별 데이터 |
manifest.json·sw.js | no-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
}
헤더 · 압축 · 캐시
보안 헤더와 성능 설정을 간결하게 적용합니다.
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
}
보안 헤더 — 무엇이 무엇을 막나
| 헤더 | 막는 공격 | 깨질 수 있는 것 |
|---|---|---|
Strict-Transport-Security | 중간자가 HTTP 로 끌어내리기 | 되돌리기 어렵습니다 — 아래 warn |
X-Content-Type-Options: nosniff | 업로드 파일을 스크립트로 실행 | Content-Type 이 틀린 파일 |
X-Frame-Options / frame-ancestors | 클릭재킹(투명 iframe) | 정상적인 iframe 임베드 |
Content-Security-Policy | XSS — 가장 강력합니다 | 인라인 스크립트 · 외부 CDN |
Referrer-Policy | URL 에 담긴 정보 유출 | 유입 경로 분석 |
Permissions-Policy | iframe 이 카메라·위치 사용 | 지도·화상 기능 |
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 | 주의 | 버퍼링되어 실시간성이 깨집니다 |
encode zstd gzip 처럼 순서를 지켜 적으면
지원하는 브라우저는 zstd, 나머지는 gzip 으로 자동 분기합니다.
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·CDN | trusted_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
curl 한 번이면 됩니다.
컨테이너에서 ports 로 2019 를 노출하지 말고,
필요하면 admin 지시어로 바인딩 주소를 명시하거나 아예 끄세요.
Nginx 대비 장단점
감정 빼고 사실만 정리합니다.
| 항목 | Caddy | Nginx |
|---|---|---|
| HTTPS 설정 | 자동 (설정 0) | certbot + 설정 필요 |
| 설정 분량 | 매우 적음 | 보통 |
| HTTP/3 | 기본 활성 | 빌드·설정 필요 |
| 능동 헬스체크 | 기본 제공 | 상용판(Plus) 또는 모듈 |
| 성능 (정적) | 좋음 | 약간 우세 |
| 메모리 사용 | 보통 (Go 런타임) | 적음 |
| 자료·사례 | 적음 | 압도적으로 많음 |
| 플러그인 | 재빌드 필요 (xcaddy) | 동적 모듈 |
| 설정 API | REST API 내장 | 파일 기반 |
| 운영 인력 풀 | 적음 | 많음 |
🟢 Caddy 가 유리
- 개인·사이드 프로젝트
- 사내 도구·내부 서비스
- 도메인이 많고 인증서 관리가 부담
- 설정 시간을 아끼고 싶을 때
- 컨테이너 기반 간단한 배포
🔵 Nginx 가 유리
- 팀·회사가 이미 쓰고 있을 때
- 대규모 트래픽 운영
- 세밀한 튜닝이 필요할 때
- 기존 설정 자산이 많을 때
- 문제 해결 자료가 중요할 때
🟣 함께 쓰기
- 앞단 Caddy(TLS 자동) + 뒤 Nginx
- 내부망 서비스만 Caddy
- 개발 환경만 Caddy, 운영은 Nginx
- 다만 두 개를 관리하는 비용도 고려하세요
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
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 | 응답 본문 치환 |
· 장점 — 단일 바이너리 유지, 의존성 없음, 배포가 단순
· 단점 — 플러그인 추가 시 재빌드·재배포 필요, 업데이트 시마다 다시 빌드
도커를 쓴다면
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 —
발급부터
갱신까지
HTTPS 는 이제 선택이 아닙니다. 브라우저가 HTTP 사이트에 "안전하지 않음"을 표시하고, HTTP/2·3 과 여러 브라우저 API 가 HTTPS 를 요구합니다. 다행히 Let's Encrypt 덕분에 무료이고 자동화도 어렵지 않습니다. 인증서 만료로 서비스가 멈추는 사고만 피하면 됩니다.
HTTPS는 무엇을 지키나
자물쇠 아이콘이 보장하는 것은 세 가지입니다.
| 보장 | 없으면 생기는 일 |
|---|---|
| 기밀성 (암호화) |
같은 와이파이의 누구나 비밀번호·쿠키·개인정보를 그대로 볼 수 있습니다 |
| 무결성 (변조 방지) |
중간에서 내용을 바꿔치기할 수 있습니다 — 광고 삽입·악성 스크립트 주입이 실제로 있었습니다 |
| 인증 (신원 확인) |
가짜 사이트에 접속해도 알 수 없습니다 (피싱) |
· 사이트가 안전하다는 뜻이 아닙니다 — 피싱 사이트도 HTTPS 를 씁니다
· 서버가 해킹되지 않는다는 뜻이 아닙니다 — 전송 구간만 보호합니다
· 어느 사이트에 접속했는지는 숨겨지지 않습니다 — 도메인은 노출됩니다(SNI)
· 데이터는 서버에 도착한 뒤에는 평문입니다
HTTPS 가 필수인 실질적 이유
- 브라우저 경고 — HTTP 폼에 입력하면 "안전하지 않음" 경고가 뜹니다
- HTTP/2 · HTTP/3 — 사실상 HTTPS 에서만 동작합니다
- 브라우저 API 제한 — 위치·카메라·서비스워커·클립보드 등이 보안 컨텍스트에서만 동작합니다
- 검색 순위 — 검색엔진이 HTTPS 를 순위 요소로 반영합니다
- 쿠키 보안 속성 —
Secure·SameSite=None이 HTTPS 를 요구합니다
HTTP 로 두면 실제로 무엇이 막히나
| 기능 | HTTP 에서 |
|---|---|
| 서비스 워커 · PWA | 등록 자체가 안 됩니다(localhost 예외) |
| 위치 · 카메라 · 마이크 | 권한 요청이 차단됩니다 |
| 클립보드 API | 동작하지 않습니다 |
| HTTP/2 · HTTP/3 | 브라우저가 HTTPS 에서만 씁니다 → 느립니다 |
Secure 쿠키 | 설정해도 전송되지 않습니다 |
SameSite=None | Secure 가 함께 필요 → 크로스 사이트 인증 불가 |
| 비밀번호 입력란 | 주소창에 "안전하지 않음" 경고 |
| 혼합 콘텐츠 | HTTPS 페이지에서 HTTP 자원은 차단됩니다 |
TLS 핸드셰이크
접속할 때 무슨 대화가 오가는지 알면 인증서 오류와 지연을 진단할 수 있습니다.
다만 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
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 를 의심하세요.
ssl_stapling on;, Apache SSLUseStapling on 한 줄입니다.
※ 단, Let's Encrypt 는 2025년에 OCSP 서비스를 종료해 LE 인증서에는 OCSP 주소가 없습니다 — LE 인증서라면 스테이플링이 동작하지 않으며(경고만 남음), 폐기 확인은 CRL 로 넘어갔습니다. 유료 CA 인증서처럼 OCSP 주소가 있는 경우에만 의미가 있습니다.
인증서의 종류와 구성
파일이 여러 개인데 각각 무엇인지 헷갈립니다. 여기서 정리합니다.
| 파일 | 내용 |
|---|---|
privkey.pem | 개인키 — 절대 유출되면 안 됩니다. 권한 600 |
cert.pem | 서버 인증서 (내 도메인용) |
chain.pem | 중간 인증서 — CA 와 내 인증서를 잇는 다리 |
fullchain.pem | ★ cert + chain — Nginx·Apache 에는 이것을 지정합니다 |
*.p12 · *.jks | 자바용 키스토어 (톰캣) |
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 챌린지가 필요합니다 |
# 인증서 내용 확인
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
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;
}
}
테스트에는 반드시
--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
따라하기 — 발급부터 적용까지
도메인 하나를 HTTP 상태에서 HTTPS 완성까지 한 번에 갑니다. 단계마다 확인 명령이 있으니 그대로 순서대로 실행하면 됩니다. (Nginx 기준 · 소요 약 15분)
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 에서 확인할 수 있습니다
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!" 가 나오면 성공입니다
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" 가 나오면 완료입니다
· 갱신 후 reload 훅과 타이머 구조 → s06
· Apache · Tomcat · Caddy 에 적용 → s07 (Caddy 는 이 전 과정이 자동입니다 · 🔒 Caddy c05)
· 보안 등급 올리기(TLS 버전 · HSTS) → s09 · s10
· 만료 감시 알람 → s12
①
DNS problem: NXDOMAIN — DNS 전파 전에 실행 (STEP 1 로)
②
Connection refused — 80 포트가 닫혀 있음 (STEP 0 방화벽·보안 그룹)
③
unauthorized — 도메인이 다른 서버를 가리키고 있음 (dig 로 IP 재확인)
갱신 자동화
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
# 갱신 성공 시 자동으로 실행될 훅
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
서버별 설치 방법
같은 인증서라도 서버마다 지정 방식이 다릅니다.
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 nginxApache
<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 apache2Tomcat · 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=tomcatCaddy
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 덕분에 가능합니다 — 클라이언트가 핸드셰이크에서 도메인을 알려 주기 때문입니다
체인 · 중간 인증서
"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'
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 브라우저는 문제를 감춰 줄 수 있습니다
TLS 버전 · 암호 스위트
보안 점검에서 등급이 낮게 나오는 이유는 대부분 여기입니다.
| 버전 | 상태 |
|---|---|
| SSL 2.0 · 3.0 | 폐기 — 절대 쓰면 안 됩니다 |
| TLS 1.0 · 1.1 | 2020년 주요 브라우저에서 지원 종료 — 비활성화하세요 |
| 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;
· 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 # 실패해야 정상
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;
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 을 고치는 것입니다
report-uri 를 설정해 실제 사용자 환경에서 수집할 수도 있습니다.
앱이 HTTPS 를 인식하지 못해 HTTP URL 을 생성하는 경우도 흔합니다 —
X-Forwarded-Proto 설정을 확인하세요(🟩 Nginx n08).
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 기기 | 기기마다 인증서를 심어 신원 확인 |
· 클라이언트가 100개면 인증서도 100개 — 발급·배포·갱신·폐기 체계가 필요합니다
· 만료 관리가 서버 인증서보다 훨씬 어렵습니다
· 폐기 목록(CRL·OCSP) 운영도 필요합니다
일반 서비스라면 API 키·OAuth 토큰이 운영 부담이 훨씬 적습니다. 쿠버네티스 환경이라면 서비스 메시(Istio·Linkerd) 가 이를 자동화해 줍니다.
일반 TLS 와 무엇이 다른가
| 항목 | 일반 TLS | mTLS |
|---|---|---|
| 누구를 확인 | 클라이언트가 서버를 확인 | 서로 확인 |
| 클라이언트 신원 | 토큰·쿠키 (앱 계층) | 인증서 (전송 계층) |
| 탈취되면 | 토큰만 있으면 아무 데서나 사용 가능 | 인증서 + 개인 키가 함께 있어야 함 |
| 폐기 | 토큰 무효화 (즉시) | CRL·OCSP 갱신 필요 (느립니다) |
| 갱신 | 자동 (리프레시 토큰) | 수동 또는 별도 자동화 |
| 운영 난이도 | 낮음 | 높음 — 인증서 수명 관리가 전부입니다 |
# 경로별로 다르게 — 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 블록)
클라이언트가 몇 개 안 되고 갱신 자동화를 만들 여력이 없다면, mTLS 대신 API 키 + IP 제한 + 짧은 수명 토큰이 현실적으로 더 안전합니다. 운영할 수 없는 보안 장치는 보안이 아닙니다.
만료 감시
인증서 만료로 서비스가 멈추는 사고는 대기업에서도 매년 일어납니다. 감시가 유일한 예방책입니다.
# 남은 일수 확인 (한 줄)
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 인증서 — 드물지만 만료됩니다
진단 도구
문제가 생겼을 때 확인하는 명령들입니다.
# ── 기본 확인
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 Observatory | TLS + 보안 헤더까지 종합 평가 |
| crt.sh | 인증서 투명성 로그 조회 — 내 도메인으로 발급된 인증서 전부를 확인 |
| Hardenize | DNS·메일·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)
⑦ 만료 감시 알람이 걸려 있는지
언어별 연동 —
내 앱을
서버에 올리기
개발이 끝난 앱을 실제 서버에서 서비스하는 과정입니다. 언어마다 관례가 다르지만 구조는 같습니다 — 앱을 프로세스로 띄우고 · 죽으면 되살리고 · 앞에 웹서버를 둔다. 각 언어의 표준 방식을 처음부터 끝까지 정리합니다.
웹서버와 앱은 어떻게 대화하나
방식은 세 가지뿐입니다. 언어마다 관례가 다를 뿐입니다.
| 방식 | 설명 | 쓰는 곳 |
|---|---|---|
| HTTP 프록시 | 웹서버가 앱에 HTTP 요청을 그대로 다시 보냅니다 | ★ Node · Spring Boot · Go · Python(Gunicorn) — 가장 흔합니다 |
| FastCGI | 이진 프로토콜로 앱 프로세스에 전달 | PHP-FPM |
| 유닉스 소켓 | TCP 대신 파일 형태의 소켓으로 (같은 서버 안에서만) | Gunicorn · PHP-FPM — 약간 더 빠르고 안전 |
| (AJP) | Apache ↔ Tomcat 전용 이진 프로토콜 | 보안 이슈로 요즘은 잘 안 씁니다 |
| 언어 | 앱 실행 | 프로세스 관리 | 웹서버 연결 |
|---|---|---|---|
| Java | java -jar · WAR | systemd · 컨테이너 | HTTP 프록시 |
| Python | Gunicorn · Uvicorn | systemd · 컨테이너 | HTTP 프록시 · 유닉스 소켓 |
| Node.js | node app.js | PM2 · systemd · 컨테이너 | HTTP 프록시 |
| PHP | PHP-FPM | FPM 자체가 관리 | FastCGI |
| Go · Rust | 바이너리 직접 | systemd · 컨테이너 | HTTP 프록시 |
| 정적(React 등) | 없음 — 파일뿐 | 없음 | 웹서버가 직접 서빙 |
어떤 언어든 쓰는 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회 실패 후 멈추게 해 두면, 감시 도구가 "죽어 있음"을 정확히 알려 줍니다.
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'
}
javax.* → jakarta.* 로 바뀌었습니다.
Spring Boot 2.x 로 만든 WAR 는 Tomcat 10+ 에서 동작하지 않습니다.
Spring Boot 3 이상으로 올려야 합니다.
WAR vs 실행 가능 jar — 실제 차이
| 항목 | WAR (외부 톰캣) | 실행 jar (내장) |
|---|---|---|
| 배포 단위 | WAR 파일 + 톰캣 설치 | jar 하나 |
| 버전 관리 | 앱과 톰캣을 따로 | 앱에 포함 — 함께 올라갑니다 |
| 로컬 실행 | 톰캣 설치 필요 | java -jar 한 줄 |
| 컨테이너화 | 번거로움 | 기본 이미지 + jar 복사 |
| 한 서버에 여러 앱 | webapps 에 여러 개 | 포트를 나눠 여러 프로세스 |
| 무중단 배포 | 톰캣 재시작 필요(또는 병렬 배포) | 인스턴스 단위 롤링 |
| 설정 위치 | server.xml·context.xml | application.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 이나 파드 이벤트에서 확인할 수 있습니다.
ROOT##002.war 처럼 ##버전 을 붙여 넣으면
톰캣이 기존 세션은 옛 버전에서 마치게 하고 새 요청만 새 버전으로 보냅니다.
재시작 없이 전환되므로, 외부 톰캣 환경에서 가장 현실적인 무중단 수단입니다.
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 — 배포 시 요청 유실 방지
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 API | WSGI + Gunicorn |
| FastAPI | ASGI + Uvicorn |
| WebSocket 이 필요 | ASGI 필수 |
| 외부 API 호출이 많은 서비스 | ASGI 가 유리 (IO 대기 중 다른 요청 처리) |
| CPU 연산이 많은 서비스 | 둘 다 GIL 제약 — 프로세스를 늘리는 것이 핵심 |
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() 가 호스트 코어를 반환할 수 있습니다 — 값을 직접 지정 |
max_requests 는 파이썬 배포에서 특히 유용합니다.
라이브러리의 미묘한 누수로 메모리가 며칠에 걸쳐 서서히 오르는 일이 흔한데,
워커를 1000요청마다 교체하면 원인을 못 찾아도 운영은 안정됩니다.
근본 해결은 아니지만, 새벽에 OOM 으로 깨는 것보다는 훨씬 낫습니다.
jitter 를 함께 주어 워커가 동시에 교체되지 않게 하는 것을 잊지 마세요.
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.UvicornWorker | ASGI 앱을 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 이 자주 걸리면 워커가 계속 재시작되며 성능이 무너집니다
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;
}
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;
}
}
①
collectstatic 을 실행했나
②
STATIC_ROOT 와 Nginx alias 경로가 일치하나
③ Nginx 실행 계정이 그 폴더를 읽을 수 있나
④ 브라우저 개발자 도구 Network 탭에서 404 인지 403 인지 확인
⑤
STATIC_URL 과 location 경로가 맞나
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
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 — 클러스터 모드·모니터링이 편리. Node 전용
· systemd — 다른 언어와 관리 방식이 통일됩니다. 클러스터는 앱에서 직접 구현
· 컨테이너 — 요즘 표준. 오케스트레이터가 재시작·확장을 담당하므로 PM2 가 불필요합니다
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'));
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; }
}
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 재시작 필요)
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 참고)
순수 정적 SPA 라면 Cloudflare Pages · Netlify · Vercel · S3+CloudFront 같은 정적 호스팅이 더 빠르고 저렴합니다. 서버를 운영할 필요 자체가 없어집니다.
API 만 별도 서버에 두고 CORS 를 설정하는 구성이 요즘 흔합니다.
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 | 언제 |
|---|---|
simple | ExecStart 가 포그라운드로 계속 실행될 때 — 대부분 이것 |
forking | 프로세스가 백그라운드로 빠질 때 (톰캣 startup.sh) |
notify | 앱이 준비 완료를 알려 줄 때 (Gunicorn) |
oneshot | 한 번 실행하고 끝나는 작업 |
·
daemon-reload 를 안 함 — 유닛 파일을 고쳤는데 반영이 안 됩니다
· 상대 경로 사용 —
ExecStart 는 절대 경로여야 합니다
· 환경 변수 부재 — 로그인 셸의 PATH·환경이 없습니다. 명시적으로 지정하세요
· 가상환경 미지정 — 파이썬은
venv/bin/ 안의 실행 파일을 절대 경로로
· 비밀번호를 ExecStart 인자로 —
ps 로 누구나 볼 수 있습니다
소켓 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
배포 체크리스트
언어와 무관하게 빠뜨리기 쉬운 항목을 모았습니다.
| 분류 | 확인 항목 |
|---|---|
| 보안 | 앱이 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
성능 —
측정하고
고칩니다
"느리다"를 숫자로 바꾸는 것이 시작입니다. 그다음 어디가 병목인지 지목하고, 효과가 큰 것부터 고칩니다. 설정부터 만지는 것은 거의 항상 잘못된 순서입니다 — 측정 → 병목 지목 → 개선 → 재측정의 순환을 익힙니다.
성능 지표 읽기
평균은 거짓말을 합니다. 백분위(percentile)로 말해야 합니다.
| 지표 | 의미 |
|---|---|
| RPS · TPS | 초당 처리한 요청 수 — 처리량 |
| 지연 (latency) | 요청 하나에 걸린 시간 — 체감 속도 |
| p50 (중앙값) | 절반의 사용자가 이보다 빠름 |
| p95 · p99 | ★ 상위 5%·1% 느린 요청 — 실제 불만이 여기서 나옵니다 |
| 동시 접속 | 같은 시점에 열려 있는 연결 수 |
| 오류율 | 5xx 비율 — 성능 문제는 오류로 나타나는 경우가 많습니다 |
| 포화도 | CPU·메모리·커넥션 풀의 사용률 — 한계에 얼마나 가까운가 |
페이지 하나가 요청 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]
}'
목표를 먼저 정하세요
나쁜 목표 : "빠르게 만들자"
좋은 목표 : "상품 목록 API 를 p95 200ms 이내로, 초당 300건 처리"
이렇게 정하면
· 달성했는지 판단할 수 있고
· 어디까지 최적화할지 멈출 지점이 생기고
· 부하 테스트 시나리오가 저절로 나옵니다
필요 동시 처리 수 ≈ 목표 RPS × 평균 응답 시간(초)
목표 300 RPS · 평균 0.2초 → 동시 60건을 처리할 수 있어야 합니다. 여기에 여유 2배를 두고 워커·스레드·커넥션 풀을 설계합니다.
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 가 worker_processes × worker_connections 에 가까워지면 한계입니다.
Waiting 이 크면 keepalive 연결이 많다는 뜻으로 정상입니다.
accepts 와 handled 가 다르면 연결을 거부한 것이므로 즉시 조치가 필요합니다.
서버별 동시성 모델 — 무엇을 쓰고 있는지 확인하세요
| 서버 | 모델 | 대략적 한계 | 주의 |
|---|---|---|---|
| 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 이 많으면 커넥션 재사용 부족
mod_php 를 쓰면 prefork 를 강제당하는데,
이때 동시 연결 수백 개에서 메모리가 바닥납니다 —
워커 하나가 PHP 인터프리터를 통째로 안고 있기 때문입니다.
php-fpm 으로 분리하고 event MPM 으로 바꾸면
같은 서버에서 처리량이 몇 배가 되고 HTTP/2 도 쓸 수 있게 됩니다.
설정 몇 줄로 끝나는 작업 중 효과가 가장 큰 편입니다.
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' 헤더를 지웁니다
}
}
①
upstream 블록에 keepalive N;
②
proxy_http_version 1.1;
③
proxy_set_header Connection "";
하나라도 빠지면 매 요청마다 새 TCP 연결을 맺습니다. 이것만 고쳐도 지연이 눈에 띄게 줄어드는 경우가 많습니다.
트레이드오프
| 값을 늘리면 | 효과 |
|---|---|
keepalive_timeout ↑ |
재사용률 상승 · 유휴 연결이 자원을 점유 |
keepalive_requests ↑ |
핸드셰이크 감소 · 한 연결에 편중될 수 있음 |
upstream keepalive ↑ |
백엔드 연결 비용 감소 · 백엔드가 유지할 연결이 늘어남 |
keepalive_timeout 보다 짧으면,
Nginx 가 이미 끊긴 연결로 요청을 보내다 502 가 간헐적으로 발생합니다.
Nginx 쪽을 백엔드보다 짧게 잡는 것이 안전합니다.
정적 파일 최적화
가장 값싸고 효과가 큰 최적화입니다. 요청 자체를 없앱니다.
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=31536000 | 1년간 재요청하지 않음 |
immutable | 새로고침해도 재검증하지 않음 — 해시 붙은 파일에 필수 |
public | CDN·프록시도 캐시 가능 |
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 ← 본문을 안 보냄
압축 전략
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 9 | 6 대비 1~2% 추가 | 몇 배 증가 |
| brotli 5 | gzip 대비 15~20% 더 작음 | 적정 |
| brotli 11 | 최대 | 매우 높음 — 사전 압축 전용 |
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 · 압축률 최대
트래픽이 아주 많은 서버에서는 압축이 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 # 압축
프록시 버퍼링
느린 클라이언트가 앱 워커를 붙잡지 못하게 막는 중요한 장치입니다.
버퍼링 없음
앱 → (느린 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 를 늘려 메모리에서 처리하세요
업스트림 커넥션 재사용
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;
}
proxy_ssl_session_reuse 를 함께 쓰지 않으면
지연이 크게 늘어납니다.
같은 사설망 안이라면 평문 HTTP 로 두고 망 자체를 보호하는 구성도 흔합니다 (다만 보안 요구사항에 따라 다릅니다).
# 재사용이 되고 있는지 확인 — 백엔드의 연결 수를 보면 알 수 있습니다
ss -tn state established '( dport = :8080 )' | wc -l
# keepalive 설정 전후로 이 값을 비교해 보세요
# 설정 후에는 요청 수와 무관하게 연결 수가 안정적으로 유지됩니다
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 값보다 크게 |
keepAliveTimeout 은 5초라 Nginx 기본값과 어긋납니다 —
백엔드를 65초 정도로 올리는 것이 정석적인 해결입니다.
proxy_next_upstream error timeout; 로 한 번 재시도를 열어 두면
남은 확률까지 흡수됩니다.
캐시 계층 설계
앞쪽에서 막을수록 좋습니다. 네 개의 층이 있습니다.
Cache-Control 헤더로 제어. 정적 자원은 여기서 끝내는 것이 최선입니다.proxy_cache — 앱을 거치지 않고 응답
동적 응답도 캐시할 수 있습니다(🟩 Nginx n13).무엇을 어디에 캐시할까
| 대상 | 위치와 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 오픈소스는 선택 삭제 기능이 없습니다 |
"이 데이터가 5분 늦어도 괜찮은가?"를 기능별로 판단하세요.
커널 파라미터 튜닝
동시 접속이 아주 많아지면 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 files | fs.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
파일 디스크립터 한계
too many open files — 트래픽이 늘면 반드시 만나는 오류입니다.
# 현재 한계 확인
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·컨테이너 런타임 설정을 따릅니다
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;
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 모드) |
부하 테스트
성능은 측정하지 않으면 알 수 없습니다. "몇 명까지 버티냐"에 숫자로 답합니다.
| 도구 | 특징 |
|---|---|
| k6 | ★ JS 로 시나리오 작성 · 지표가 풍부 · CI 연동이 쉽습니다 |
| wrk | 가볍고 빠름 · 단순 부하에 적합 |
| ab (ApacheBench) | 가장 단순 · 빠른 확인용 (keepalive 처리에 한계) |
| JMeter | GUI · 복잡한 시나리오 · 무겁습니다 |
| Locust | 파이썬으로 시나리오 작성 |
| Gatling | Scala 기반 · 리포트가 훌륭합니다 |
# 빠른 확인
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
│ ╱ ← 한계를 넘으면 지연이 급격히 치솟습니다
│ ╱
│ ────────
└────────────── 동시 사용자
★ 처리량이 평평해지는 지점 = 한계
★ 그 지점 이후에는 지연만 늘고 처리량은 그대로입니다
병목 찾기 절차
감으로 고치지 마세요. 순서가 있습니다.
$request_time 과 $upstream_response_time 을 비교합니다.
둘 다 크면 앱, request_time 만 크면 네트워크·전송입니다.# ── 구간 나누기 (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 가이드의 튜닝 탭을 먼저 확인하는 것이 실제로는 더 빠른 길인 경우가 많습니다.
성능 체크리스트
배포 전에 한 번씩 훑어보세요.
| 분류 | 확인 항목 |
|---|---|
| 기본 | 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 도입
- 하드웨어 증설
- 아키텍처 변경
- ※ 앞의 것을 다 하고 나서
로드밸런싱 —
한 대에서
여러 대로
서버 한 대로 버티던 서비스가 커지면 여러 대에 트래픽을 나누는 순간이 옵니다. 로드밸런서는 트래픽을 나누는 것뿐 아니라 죽은 서버를 숨기고, 배포 중에도 서비스를 살려 두는 핵심 장비입니다. 개념부터 Nginx·HAProxy 실전 구성, 이중화까지 순서대로 갑니다.
왜 필요한가
서버 한 대의 한계는 반드시 옵니다. 문제는 어떻게 늘리느냐입니다.
수직 확장 (Scale-Up) — 더 좋은 한 대
CPU 4코어 → 32코어, RAM 8GB → 256GB
장점 코드·구조 변경이 전혀 없다
한계 ① 가격이 선형이 아니라 폭증한다
② 아무리 커도 상한이 있다
③ 그 한 대가 죽으면 → 전체 장애
④ 업그레이드하려면 → 내려야 한다
수평 확장 (Scale-Out) — 보통 여러 대
서버 1대 → 같은 앱을 3대, 5대, 10대로
장점 ① 값싼 서버를 필요한 만큼 추가
② 한 대가 죽어도 나머지가 받는다
③ 한 대씩 빼서 배포 → 무중단
전제 ★ 트래픽을 나눠 줄 무언가가 필요
→ 그것이 로드밸런서입니다
로드밸런서가 실제로 해 주는 세 가지
| 역할 | 의미 |
|---|---|
| ① 분산 | 요청을 여러 서버에 나눠 한 대의 처리량 한계를 넘습니다 |
| ② 장애 숨김 | 죽은 서버를 자동으로 빼서 사용자는 장애를 모르게 합니다 (헬스체크 · b06) |
| ③ 무중단 배포 | 한 대씩 빼서 새 버전을 올립니다 — 배포 중에도 서비스가 살아 있습니다 (b09) |
알고리즘 고르기 — 판단표
| 알고리즘 | 언제 | 주의 |
|---|---|---|
| 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) — 그 뒤로는 이 네 가지가 전부 사라집니다.
큰 그림 — 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 |
"DNS 라운드로빈으로 되지 않나요?"
# 한 도메인에 A 레코드를 여러 개 등록하는 방법
example.com → 10.0.0.11
example.com → 10.0.0.12
example.com → 10.0.0.13
① 죽은 서버를 모릅니다 — 10.0.0.12 가 죽어도 1/3 의 사용자는 계속 그리 갑니다
② 캐시를 통제할 수 없습니다 — 통신사·브라우저가 제각각 캐시합니다
③ 분배 비율을 클라이언트가 결정합니다 — 균등을 보장하지 못합니다
그래서 DNS 라운드로빈은 로드밸런서 자체를 여러 개 두는 최상단(b10)에서 보조 수단으로 쓰고, 실제 분산은 로드밸런서가 맡는 것이 표준입니다.
첫 로드밸런싱 — 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 을 강제 종료
kill %1
for i in $(seq 1 4); do curl -s http://127.0.0.1:8080/; done
max_fails·fail_timeout)은 b06 에서 정밀하게 다룹니다.
실험이 끝나면 kill %2 로 나머지 앱도 정리하세요.
분산 알고리즘 고르기
아래 시뮬레이터에서 알고리즘을 바꿔 가며 요청을 보내 보세요. 분배가 어떻게 달라지는지 보입니다.
| 알고리즘 | 동작 · 언제 쓰나 |
|---|---|
| 라운드로빈 (기본) | 순서대로 한 번씩. 서버 성능이 같고 요청 처리 시간이 비슷하면 이것으로 충분합니다 |
| 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; # 점검 중 — 목록엔 두고 제외
}
① IPv4 는 앞 세 옥텟만 해시합니다 — 같은 회사·통신사 대역이 몽땅 한 서버로 몰릴 수 있습니다
② 회사 방화벽·모바일 통신사 뒤의 수천 명이 같은 공인 IP 로 나옵니다 — 분산이 심하게 기웁니다
세션 때문에 ip_hash 를 고려한다면 먼저 b05 를 읽어 보세요.
$upstream_addr(🟩 Nginx n17)로 확인합니다.
세션 문제 — "로그인이 풀려요"
서버를 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 로
서버를 늘리기 전에 "이 앱이 로컬에 쌓는 것이 무엇인가"를 먼저 점검하세요.
헬스체크 — 죽은 서버 빼기
로드밸런서의 진짜 가치는 분산보다 장애를 숨기는 것에 있습니다.
| 방식 | 동작 |
|---|---|
| 수동적 (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 checkCaddy
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) |
location /health { access_log off; return 200; } 처럼
앱까지 가지 않고 끊는 방법도 있습니다 — 단, 이건 Nginx 의 생존만 보장할 뿐이니
LB 가 앱을 검사할 때는 앱의 /health 를 쓰는 것이 맞습니다.
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 기능(경로 라우팅·헤더)을 전부 포기합니다 |
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 로 재시작 없이 |
| 정적 파일 · 캐시 | ✓ 강함 | ✗ (프록시 전용) |
| 역할 요약 | 웹서버 + 준수한 LB | LB 특화 — 그 외엔 관심 없음 |
무중단 배포 — LB 로 하는 법
로드밸런서가 있으면 배포 중에도 503 이 안 나는 구성이 가능해집니다.
롤링 배포 — 한 대씩 빼서 올린다
curl http://10.0.0.11:8080/health 로 직접 확인합니다.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 을 지우고 다시 reloadHAProxy — 재시작 없이 런타임 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 참고.
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 }
}
track_script), 머신이 통째로 죽어도(광고 중단) 자동 전환됩니다.
같은 L2 네트워크(같은 서브넷)에서 동작하는 방식이라 온프레미스·IDC 구성에서 씁니다.
| 방법 | 설명 |
|---|---|
| keepalived (VRRP) | ★ 온프레미스 표준. VIP 자동 전환 · 수 초 내 복구 |
| DNS 다중 A + 헬스체크 | Route 53 등 헬스체크 연동 DNS 로 죽은 LB 를 응답에서 제외 — 지역 간(멀티 리전) 분산에 적합. TTL 만큼 전환이 느립니다 |
| 관리형 LB (클라우드) | ★ ALB·NLB 는 이중화가 내장돼 있습니다 — 클라우드라면 고민 자체가 사라집니다 (b11) |
systemctl stop haproxy 로 내리고
VIP 가 BACKUP 으로 넘어가는지, 서비스가 몇 초 만에 복구되는지 실측해야
진짜 이중화입니다. 테스트해 본 적 없는 페일오버는 없는 것과 같습니다.
클라우드 로드밸런서
개념은 지금까지 배운 그대로입니다. 이름만 바뀝니다.
| 이 가이드의 개념 | AWS | GCP · Azure |
|---|---|---|
| L7 로드밸런서 | ALB (Application LB) | Application LB · App Gateway |
| L4 로드밸런서 | NLB (Network LB) | Network LB · Azure LB |
| upstream (서버 그룹) | Target Group | Backend Service · Backend Pool |
| 능동 헬스체크 | Target Group 의 Health Check | Health Check (동일 개념) |
| 드레인 (b09) | Deregistration delay | Connection 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·UDP | NLB — 게임 서버 · MQTT · DB 프록시 |
| 초저지연 · 초대량 | NLB — L4 라 오버헤드가 최소 |
관리형 LB 에서 처음에 반드시 확인할 값
| 설정 | 기본값 | 왜 봐야 하나 |
|---|---|---|
| 유휴 타임아웃 | ALB 60초 | WebSocket·SSE 가 정확히 60초마다 끊깁니다 |
| 드레인 지연 | 300초 | 배포가 느려집니다 — 30~60초로 줄이세요 |
| 헬스체크 주기 | 30초 | 죽은 서버를 빼기까지 주기 × 실패 횟수가 걸립니다 |
| 정상 판정 횟수 | 5회 | 배포한 서버가 트래픽을 받기까지의 시간 |
| 헬스체크 경로 | / | / 는 무겁습니다 — 가벼운 /health 로 |
| 슬로 스타트 | 꺼짐 | 새 인스턴스에 트래픽을 서서히 — JIT·캐시 예열 |
| 교차 영역 분산 | ALB 켜짐 / NLB 꺼짐 | NLB 는 AZ 별 인스턴스 수가 다르면 쏠립니다 |
| 액세스 로그 | 꺼짐 | 사고 조사에 필수 — S3 로 켜 두세요 |
proxy_read_timeout 을 3600초로 늘렸는데도 60초마다 끊긴다" —
앞단 ALB 가 자르고 있는 것입니다.
경로가 여러 겹이면 가장 짧은 타임아웃이 이깁니다.
클라이언트 → CDN → LB → Nginx → 앱, 이 다섯 곳의 값을 전부 확인하세요.
직접 운영 vs 관리형 — 실제 비교
| 항목 | Nginx·HAProxy 직접 | 관리형(ALB 등) |
|---|---|---|
| 비용 | 인스턴스 비용만 | 시간 + 처리량 과금 — 트래픽이 크면 무시 못 합니다 |
| 이중화 | 직접 구성(keepalived VIP) | 내장 |
| 고정 IP | 가능 | ALB 는 불가(NLB 는 가능) |
| 세밀한 제어 | 매우 넓음 | 제공하는 옵션 안에서만 |
| 설정 반영 | 즉시(reload) | 수십 초~수 분 |
| 패치·업그레이드 | 직접 | 자동 |
| 디버깅 | 로그·설정을 직접 봄 | 블랙박스 — 액세스 로그에 의존 |
| WAF·인증 연동 | 모듈·별도 구축 | 클릭 몇 번 |
set_real_ip_from 에 LB 대역을 반드시 등록해야
앱 로그에 실제 방문자 IP 가 남습니다.
규모별 구성 레시피
지금 단계에 맞는 구성을 골라 그대로 쓰세요. 과한 구성은 그 자체가 비용입니다.
🟢 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)
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 가 나가는 구성이 의외로 많습니다.
운영에 올리기 전에 스테이징에서 한 번, 올린 뒤에 한가한 시간에 한 번 해 보세요.
보안과 운영 —
돌아가게
유지하기
서버를 띄우는 것보다 계속 안전하게 돌아가게 하는 것이 어렵습니다. 설정 몇 줄로 막을 수 있는 공격이 많고, 로그·모니터링 없이는 장애가 나도 원인을 못 찾습니다. 실무에서 실제로 필요한 것만 정리합니다.
보안 헤더 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;
①
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
정보 노출 줄이기
공격자에게 정보를 주지 마세요. 정찰 단계를 어렵게 만듭니다.
# 지금 무엇이 노출되고 있는지 확인
curl -I https://example.com
# 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 등)
접근 제어 · 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 (앞단에서만 호출되는 것)
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 영역 |
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 에 강하지만
# 위 설정으로 자원 점유를 더 확실히 제한할 수 있습니다
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
· 반드시
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 는 알려지지 않은 취약점의 시간을 벌어 주는 역할이고, 운영 비용(오탐 조정·성능)이 만만치 않습니다.
부담이 크다면 클라우드 WAF(관리형)를 쓰는 것이 현실적입니다.
파일 업로드 보안
업로드 폴더에서 스크립트가 실행되면 서버가 통째로 넘어갑니다. 침해 사고의 대표적인 경로입니다.
# ★ 업로드 폴더에서 실행 차단 (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;
}
<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
로그 관리 · 로테이션
로그를 방치하면 디스크가 가득 차서 서비스가 멈춥니다.
# /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 —
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;
이때 JSON 로그 포맷(🟩 Nginx n15)이 훨씬 다루기 편합니다.
· 민감 정보는 POST 본문으로 (URL 에 넣지 않기)
· 필요하면 로그 수집 단계에서 마스킹
· 보관 기간을 정하고 기간이 지나면 삭제
모니터링 · 상태 페이지
서버 상태를 숫자로 보이게 만들어 두면 장애를 미리 알 수 있습니다.
# 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 | 현재 열린 연결 수 (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) |
| 프로세스 다운 | 즉시 |
| 외부 접속 실패 | 외부에서 본 가용성 (내부 지표만으로는 못 잡습니다) |
무중단 배포 전략
배포 중에도 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 설정만
제대로 하면 롤링 업데이트가 자동으로 무중단이 됩니다 → 다음 섹션.
설정 관리 · 버전 관리
서버에서 직접 고친 설정은 다음 사람이 알 수 없습니다. 서버가 날아가면 복구도 못 합니다.
# 가장 단순한 방법 — /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 — 인프라 전체를 코드로 |
· 서버가 날아가도 복구할 수 있습니다
· 누가 언제 왜 바꿨는지 남습니다
· 리뷰를 거칠 수 있습니다 (설정 실수로 인한 장애 감소)
· 스테이징과 운영이 같은 설정임을 보장할 수 있습니다
· 새 서버를 동일하게 만들 수 있습니다
컨테이너 · 쿠버네티스
쿠버네티스에서는 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 리소스 |
upstream | Service (엔드포인트 자동 관리) |
location | Ingress 의 path |
| certbot | cert-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 갱신이 완료됩니다.
장애 대응 시나리오
자주 나는 유형과 대응 순서입니다. 당황하지 않는 것이 절반입니다.
| 증상 | 확인 순서 → 조치 |
|---|---|
| 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
· 여러 조치를 동시에 — 무엇이 효과였는지 알 수 없게 됩니다
· 상태 수집 없이 재시작 — 원인이 사라지고 다시 같은 장애가 납니다
· 혼자 판단하고 되돌릴 수 없는 조치 — 데이터 삭제·강제 복구는 반드시 공유 후에
· 원인 분석을 먼저 — 서비스 복구가 우선입니다. 분석은 그다음
정기 점검 체크리스트
주기별로 무엇을 봐야 하는지 정리합니다.
매일 (자동화하세요)
- 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
취약점 대응 · 패치
"잘 돌아가니까 건드리지 말자"가 가장 위험한 태도입니다.
# 현재 버전 확인
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
패치 절차
libssl 을 업데이트해도 이미 실행 중인 프로세스는 옛 라이브러리를 메모리에 들고 있습니다.
needrestart 나 lsof | grep DEL 로
재시작이 필요한 서비스를 찾아 반드시 재시작하세요.
"패치는 했는데 여전히 취약한" 상태가 실제로 자주 발생합니다.
· 보안 업데이트는 자동 적용(unattended-upgrades) — 대부분의 경우 안전합니다
· 메이저 업그레이드는 계획적으로 — 분기 단위로
· EOL(지원 종료) 버전은 쓰지 않기 — 패치 자체가 나오지 않습니다
· 컨테이너라면 베이스 이미지를 주기적으로 새로 빌드하세요
리버스 터널 — 포트를 안 열고 사내 서버 노출하기
공인 IP 도 없고, 방화벽에 구멍도 못 뚫는데 밖에서 접속은 돼야 할 때. 해법은 방향을 뒤집는 것입니다 — 안에서 밖으로 먼저 연결을 걸어 두고, 그 통로로 바깥 요청을 되받습니다.
언제 쓰나 — 실제로 쓰이는 자리
- 사내망 서비스를 외부에 공개 — 공인 IP·고정 IP 없이 (지사·공장·매장 서버)
- 고객사 장비 원격 관리 — 고객 방화벽을 건드리지 않고 지원 채널 확보
- 개발 중인 로컬 서버를 팀·외부에 시연 — 결제사 웹훅 테스트가 대표적
- CGNAT·모바일 회선 — 통신사가 공인 IP 를 안 줄 때 유일한 방법
- IoT · 키오스크 원격 접속 — 수백 대가 각자 밖으로 붙어 옵니다
네 가지 선택지 비교
| 방식 | 설치 | HTTPS | 적합한 곳 |
|---|---|---|---|
SSH -R | 없음 서버에 이미 있음 |
직접 구성 | 임시 · 운영자 접속 가장 빠르게 뚫을 때 |
| Cloudflare Tunnel | cloudflared | 자동 | 상시 서비스 공개 무료 · 도메인 필요 |
| frp | frps + frpc | 직접 구성 | 내가 다 통제해야 할 때 TCP·UDP 도 됨 |
| ngrok | ngrok | 자동 | 개발 중 임시 공개 웹훅 테스트 |
-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;
}
}
② 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 로 옮겨야 합니다
③ 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
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)
- 노출 대상은 한 서비스·한 포트로 최소화
- 만료일을 정하고 캘린더에 회수 일정 등록
전문가 —
서버 안에서
무슨 일이
여기서부터는 "왜 그렇게 동작하는가"입니다. 워커 4개가 어떻게 수만 연결을 다루는지, HTTP/2 가 정말 빠른지, 프록시를 거치면 왜 IP 가 사라지는지 — 설정으로 안 풀리는 문제는 이 층위에서 원인을 찾습니다.
소켓부터 — accept 와 커넥션
연결은 웹서버가 받기 전에 커널의 대기줄에 먼저 쌓입니다. 이 줄이 넘치면 접속이 거부됩니다.
tcp_max_syn_backlog.accept() 하지 않은 상태.
크기는 min(somaxconn, listen backlog).이때 웹서버 로그에는 아무것도 안 남습니다 — 요청이 도달하지 못했으니까요. "서버는 멀쩡한데 접속이 안 된다"는 상황의 원인입니다.
# 큐 넘침 확인
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 등)
이벤트 루프 · 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 와 함께 쓸 때
}
io_uring 이라는 더 발전된 방식도 있습니다.
이벤트 루프를 멈추게 하는 것들
| 블로킹 원인 | 영향과 대응 |
|---|---|
| 디스크 읽기 (캐시 미스) | 워커가 통째로 멈춤 → 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;
한 줄이면 됩니다. 기본 설정은 요청마다 디스크에 쓰기라,
디스크가 잠깐 느려지는 순간 모든 응답이 함께 느려집니다 —
네트워크 스토리지를 쓰거나 로그가 많은 서버에서 실제로 겪는 문제입니다.
대신 서버가 갑자기 죽으면 마지막 몇 초의 로그를 잃습니다.
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
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 가 제대로 동작하지 않습니다.
Apache MPM 내부 비교
Apache 가 메모리를 더 쓰는 이유를 구조로 설명합니다.
| MPM | 연결 1000개일 때 | 메모리 추정 |
|---|---|---|
| prefork | 프로세스 1000개 | 프로세스당 20~50MB → 수십 GB |
| worker | 프로세스 40개 × 스레드 25개 | 스레드 스택 + 프로세스 → 수 GB |
| event | worker 와 같지만 유휴 연결은 전담 스레드가 관리 | worker 보다 크게 절약 |
| (Nginx) | 워커 4개 | 수십~수백 MB |
# 실제 메모리 사용 측정
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"}'
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 가 낫습니다 — 구조적 차이입니다 |
.htaccess 와 기존 모듈을 그대로 쓰는 구성입니다.
Nginx 가 동시 연결을 흡수해 주므로
Apache 는 실제 처리 중인 요청만 상대하면 됩니다 —
prefork 라도 워커 수를 훨씬 적게 잡을 수 있게 됩니다.
HTTP/1.1 · 2 · 3
"HTTP/2 를 켜면 빨라진다"는 상황에 따라 다릅니다. 왜 그런지 봅니다.
| 항목 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 전송 계층 | TCP | TCP | QUIC (UDP) |
| 동시 요청 | 연결당 1개 (브라우저가 6개 연결) | 한 연결에 다중화 | 한 연결에 다중화 |
| 헤더 | 텍스트 · 매번 전체 | HPACK 압축 | QPACK 압축 |
| HOL 블로킹 | 애플리케이션 레벨 | TCP 레벨에 남음 | 해결 |
| 연결 수립 | TCP + TLS (2~3 RTT) | TCP + TLS | 1 RTT · 재접속 0-RTT |
| 네트워크 전환 | 연결 재수립 | 연결 재수립 | 연결 유지 (Connection ID) |
| 서버 푸시 | 없음 | 있었으나 사실상 폐기 | 없음 |
HTTP/3 은 QUIC(UDP 기반)으로 스트림마다 독립적으로 처리해 이 문제를 해결했습니다. 패킷 손실이 잦은 모바일에서 차이가 큽니다.
언제 효과가 있나
🟢 HTTP/2 효과 큼
- 작은 파일이 많은 페이지
- 지연이 큰 네트워크
- 번들링하지 않은 사이트
- 이미지가 많은 페이지
🔵 효과 작음
- 이미 번들링된 SPA (파일 2~3개)
- 대용량 파일 하나를 받는 경우
- 빠른 유선 네트워크
- CDN 이 이미 앞에 있는 경우
🟣 HTTP/3 이 유리
- 모바일 · 불안정한 네트워크
- 패킷 손실이 있는 환경
- 네트워크 전환이 잦은 사용자
- 전 세계 대상 서비스
· 도메인 샤딩(정적 파일을 여러 도메인에 분산)을 그대로 두면 HTTP/2 의 다중화 이점이 사라집니다 — 한 도메인으로 합치는 것이 맞습니다
· 스프라이트 이미지·인라인 CSS 같은 HTTP/1.1 시대 최적화도 HTTP/2 에서는 불필요하거나 역효과일 수 있습니다
· 서버 푸시는 브라우저에서 제거됐으므로 쓰지 마세요
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에 가까워지는지 확인하세요.
세션 재개가 동작하면 두 번째 접속부터 크게 줄어듭니다.
제로카피 · 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;
· 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; 로 활성화
언제 켜고 언제 무의미한가
| 상황 | sendfile 효과 |
|---|---|
| 정적 파일 · 평문 HTTP | 큼 — 그대로 커널이 보냅니다 |
| 정적 파일 + 사전 압축(.br·.gz) | 큼 — 이미 만들어진 파일을 그대로 |
| 정적 파일 + 실시간 gzip | 없음 — 내용을 만들어야 합니다 |
| HTTPS (kTLS 없음) | 없음 — 암호화가 유저 공간에서 |
| HTTPS + kTLS | 큼 — 커널이 암호화까지 |
| 프록시 응답 | 해당 없음 — 파일이 아닙니다 |
| 대용량 다운로드 | 큼 — sendfile_max_chunk 를 함께 |
sendfile_max_chunk 를 안 주면 큰 파일 하나가 워커를 독점합니다.
이벤트 루프는 한 번에 하나씩 처리하므로,
1GB 파일을 한 번에 밀어 넣으면 그 워커의 다른 연결이 전부 기다립니다.
sendfile_max_chunk 512k; 를 주면 조금씩 나눠 보내며
사이사이 다른 연결을 처리합니다.
다운로드 서버에서 "큰 파일을 받는 사람이 있으면 사이트가 느려진다"는
현상의 원인이 대개 이것입니다.
free -m 의 buff/cache 가 충분한지,
vmstat 1 의 bi(블록 입력) 가 0 에 가까운지 확인하세요.
디스크를 계속 읽고 있다면 메모리를 늘리는 것이 어떤 설정보다 효과적입니다.
백프레셔와 버퍼
보내는 쪽이 빠르고 받는 쪽이 느릴 때 어떻게 되는지 — 버퍼가 그 완충 장치입니다.
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 를 넣으면 그 응답만 버퍼링이 꺼집니다
커넥션과 파일 디스크립터
연결을 오래 유지하면 빠르지만 자원을 계속 차지합니다.
프록시 구성에서 요청 하나가 쓰는 파일 디스크립터
클라이언트 ↔ 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 환경에서 연결 실패를 일으키며 커널에서 제거됐습니다
net.ipv4.ip_local_port_range = 10240 65535 로 넓히고,
keepalive 로 연결 자체를 줄이는 것이 정답입니다.
FD 한계는 네 곳에 있습니다 — 하나만 올리면 소용없습니다
| 계층 | 설정 | 확인 |
|---|---|---|
| ① 시스템 전체 | fs.file-max | sysctl fs.file-max |
| ② 프로세스(systemd) | LimitNOFILE | systemctl show nginx -p LimitNOFILE |
| ③ Nginx 워커 | worker_rlimit_nofile | cat /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 files | FD 한계 — 위 네 곳 확인 |
worker_connections are not enough | worker_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 가 없어 연결이 계속 새로 생기거나,
죽은 연결이 정리되지 않는 것입니다.
한계를 올리는 것은 시간을 버는 조치일 뿐,
원인을 그대로 두면 더 큰 숫자에서 같은 일이 반복됩니다.
프록시 프로토콜 · 실제 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 기반 차단·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 '*' 는
모두 위험한 설정입니다.
로드밸런싱 알고리즘
상황마다 정답이 다릅니다. 각각의 성질을 알아야 고를 수 있습니다. (기초 개념·구성 실습은 ⚖️ 로드밸런싱 탭에서 — 여기서는 내부 성질을 파고듭니다)
| 알고리즘 | 동작과 성질 |
|---|---|
| 라운드 로빈 | 순서대로. 요청 처리 시간이 비슷할 때 가장 고르게 분산됩니다 |
| 가중 라운드 로빈 | 서버 성능이 다를 때 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;
}
· 서버가 죽으면 그 서버에 붙어 있던 사용자는 로그아웃됩니다
· 부하가 고르게 분산되지 않습니다
· 배포·확장이 어려워집니다
세션을 Redis 등 외부에 두고 앱을 무상태로 만드는 것이 정답입니다.
least_conn 으로 바꿉니다.
캐시 계층에는 일관된 해싱을 씁니다.
헬스체크 · 서킷 브레이킹
죽은 서버에 요청을 계속 보내지 않게 하고, 장애 전파를 막습니다.
| 방식 | 설명 |
|---|---|
| 수동적 (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 등)이나 서비스 메시에서 합니다.
자세한 패턴은 ☕ 자바 가이드의 대규모 트래픽 탭에 있습니다.
관측 가능성 · 추적 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"한 단계 위 — 분산 추적
· 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;
}
아키텍처 정리
전체 그림을 한 장으로. 설계 회의와 면접에서 그대로 쓸 수 있습니다.
[ 사용자 ]
│ 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 가이드에 이어집니다.
백엔드 서버와
MSA —
쪼개는 기술
웹서버 뒤에는 비즈니스 로직을 실행하는 백엔드 서버가 있습니다. 서비스가 커지면 한 덩어리(모놀리스)를 여러 서비스로 쪼개는 순간이 오고, 그때부터 게이트웨이·디스커버리·사가·서킷브레이커 같은 도구가 필요해집니다. 백엔드 서버의 내부 구조부터 MSA 설계·운영·도입 판단까지, 언어와 무관하게 다룹니다.
백엔드 서버란 — 웹서버 뒤의 세계
웹서버가 주문을 받는 홀이라면, 백엔드는 요리를 만드는 주방입니다.
이 가이드의 앞 탭들은 웹서버(Nginx·Apache) — 요청을 받아 정적 파일을 주거나 뒤로 넘기는 프로그램을 다뤘습니다. 그 "뒤"가 백엔드 서버입니다. Spring Boot·Django·Express·FastAPI 로 만든 앱이 전부 백엔드 서버이고, 언어가 무엇이든 내부 구조는 놀랄 만큼 비슷합니다.
언어별 이름 대조 — 같은 것을 다르게 부릅니다
| 계층 | Spring (Java) | Django (Python) | Express (Node) |
|---|---|---|---|
| 컨트롤러 | @RestController | View | Router · Handler |
| 서비스 | @Service | Service (관례) | Service (관례) |
| 리포지토리 | @Repository · JPA | Model · ORM | Prisma · Knex |
| 요청 처리 | 스레드풀 (기본 200개) | 워커 프로세스 (gunicorn) | 이벤트 루프 1개 |
백엔드 서버의 세 갈래 — 웹 서버 · 게임 서버 · 처리 엔진 서버
“백엔드”라고 다 같은 구조가 아닙니다. 시간을 다루는 방식이 다르면 설계가 통째로 달라집니다 — 웹 서버는 요청이 올 때만 일하고, 게임 서버는 요청이 없어도 시간이 흐르며, 엔진 서버는 큐에서 꺼낸 작업을 계속 갈아 냅니다.
| 웹 · API 서버 | 게임 · 실시간 서버 | 엔진 · 워커 서버 | |
|---|---|---|---|
| 시간 축 | 요청-응답 단위 | 틱 루프 (10~60Hz 로 세상이 흐름) | 작업(job) 단위 — 큐에서 소비 |
| 통신 | HTTP · 짧은 연결 | TCP/UDP·WebSocket 상시 연결 | 큐(Kafka·Rabbit) · gRPC |
| 상태 | 무상태 — 상태는 DB·Redis 로 | 상태ful — 방·월드가 메모리에 산다 | 작업 안에서만 상태 유지 |
| 확장 | LB 뒤에 복제 (아무 서버나 OK) | 방 단위 배치 — 한 방은 반드시 한 서버 | 워커 수만 늘리면 됨 |
| 장애 | 재시도로 흡수 | 방 전체 소실 — 스냅숏·재접속 설계 필수 | 미완료 작업 재큐잉 (멱등 필수) |
| 지표 | p99 응답시간 · RPS | 틱 예산 초과율 · RTT · 동시접속 | 큐 대기 길이 · 처리율 |
| 예시 | 쇼핑몰 API · 관리자 | MMO·FPS·채팅·화이트보드·시세 | 인코딩 · 렌더 팜 · ML 추론 · 정산 배치 |
게임 · 실시간 서버 설계의 다섯 기둥
- ① 프로토콜 — 위치처럼 최신값만 중요한 데이터는 UDP(잃어도 다음 틱이 덮음), 인벤토리·결제처럼 빠지면 안 되는 것은 TCP/신뢰 채널. 웹 기반이면 WebSocket + 델타 압축.
- ② 권위(authority)는 서버에 — 클라이언트가 보낸 “나 여기로 이동”은 요청이지 사실이 아닙니다. 서버가 판정하고 클라이언트는 예측·보간으로 숨깁니다(치트 방어의 뿌리).
- ③ 관심 영역(AOI) — 1,000명 방에서 전원에게 전원 상태를 보내면 패킷이 n² 으로 폭발합니다. 보이는 범위만 구독시키는 격자·그리드가 필수.
- ④ 상태 저장의 두 층 — 매 틱 바뀌는 것은 메모리에만, 잃으면 안 되는 것(레벨·아이템)만 주기 스냅숏 + 이벤트 로그로 DB에. 매 틱 DB 를 치는 순간 게임 서버는 죽습니다.
- ⑤ 재접속 프로토콜 — 모바일은 반드시 끊깁니다. “세션 토큰으로 방 찾기 → 스냅숏 재전송 → 이후 델타”가 처음부터 설계에 있어야 합니다.
전체 그림 — 게임도 결국 MSA 로 갑니다
[클라이언트]
│ HTTP (로그인·상점·랭킹 — 그냥 웹 서버입니다)
▼
[API 서버 (무상태)] ──── [DB · Redis]
│ "게임 시작" 요청
▼
[매치메이커] ── 방 배정 · 서버 선택 (Redis 큐)
│ 접속 티켓 (서버 주소 + 토큰)
▼
[게임 서버 #17 (상태ful)] ← 상시 연결 · 틱 루프
│ 경기 종료 → 결과 이벤트
▼
[정산 워커 (엔진 서버)] ── 보상 지급 · 통계 (Kafka)
모놀리스 — 잘 만든 한 덩어리
MSA 를 배우기 전에 분명히 할 것 — 모놀리스는 열등한 구조가 아닙니다.
모놀리스는 모든 기능이 한 프로세스 안에 있는 구조입니다. 배포 파일이 하나, DB가 하나, 함수 호출로 모든 기능에 닿습니다. 쿠팡도 배민도 토스도 모놀리스로 시작했습니다. 문제는 덩어리 자체가 아니라 내부 경계 없이 뒤엉키는 것입니다.
모놀리스가 정답인 경우
| 상황 | 이유 |
|---|---|
| 팀이 10명 미만 | MSA 는 서비스마다 배포·모니터링·온콜이 붙습니다. 사람이 안 됩니다 |
| 도메인 경계가 아직 불확실 | 잘못 쪼개면 서비스 둘을 항상 같이 고치게 됩니다 — 최악의 결과 |
| 트래픽이 한 대로 감당됨 | 모놀리스도 여러 대 복제(⚖️ 로드밸런싱)로 충분히 확장됩니다 |
| 빠른 실험이 중요 | 기능 하나에 서비스 셋을 고치는 구조는 실험 속도를 죽입니다 |
MSA — 무엇을 기준으로 쪼개나
마이크로서비스는 따로 배포할 수 있는 작은 서비스들의 협력입니다. 쪼개는 기준이 전부입니다.
무엇을 얻고 무엇을 지불하나
얻는 것
① 독립 배포 — 주문 팀은 하루 20번,
회원 팀은 주 1번. 서로 안 기다린다
② 독립 확장 — 상품 조회만 10대로
③ 장애 격리 — 추천이 죽어도 주문은 산다
④ 기술 자유 — 서비스마다 다른 언어·DB 가능
⑤ 팀 자율 — 작은 팀이 끝까지 책임진다
지불하는 것
① 네트워크 — 함수 호출이 원격 호출로
(느리고, 실패할 수 있고, 순서가 섞인다)
② 트랜잭션 — DB 가 갈라져 JOIN·롤백 불가
→ 사가 패턴 필요 (m08)
③ 운영 — 서비스 수만큼 배포·모니터링·온콜
④ 테스트 — 전체 흐름 테스트가 어려워진다
⑤ 디버깅 — 로그가 여러 서버에 흩어진다 (m10)
쪼개는 기준 — 바운디드 컨텍스트
DDD(도메인 주도 설계)의 바운디드 컨텍스트가 실무 표준 기준입니다. 같은 "상품"이라도 주문 맥락(가격·재고)과 전시 맥락(이미지·설명)은 다른 데이터입니다. 말이 달라지는 경계, 함께 바뀌는 것들의 묶음이 서비스 경계입니다. 실용적 신호: ① 이 기능만 배포 주기가 다르다 ② 이 기능만 트래픽 패턴이 다르다 ③ 이 기능만 다른 팀이 만진다 — 셋 중 둘이면 쪼갤 후보입니다.
API 게이트웨이 · BFF
서비스가 여럿이 되는 순간, 클라이언트가 어디로 요청해야 하는지부터 문제가 됩니다.
도구 선택
| 도구 | 특징 · 쓰는 곳 |
|---|---|
| Nginx | 이미 알고 있는 것으로 시작 — 라우팅·리밋·TLS 는 충분합니다 |
| Spring Cloud Gateway | 자바 진영 표준. 필터로 인증·로깅을 코드로 제어 |
| Kong · APISIX | 플러그인 생태계(인증·리밋·변환). 언어 무관 전용 게이트웨이 |
| 클라우드 관리형 | AWS API Gateway · ALB — 운영을 맡기고 기능만 씁니다 |
BFF — 클라이언트별 전용 백엔드
앱은 화면 하나에 필요한 것만 작게, 웹 관리자는 한 번에 많이 — 클라이언트마다 원하는 모양이 다릅니다. 하나의 범용 API 로 모두 만족시키려다 비대해지는 대신, 클라이언트 종류마다 전용 집계 서버(BFF, Backend For Frontend)를 둡니다. BFF 는 여러 서비스를 호출해 그 화면에 맞는 모양으로 합쳐서 돌려줍니다. 프런트 팀이 직접 소유하는 경우가 많습니다.
서비스 디스커버리 · 중앙 설정
서비스가 늘고 줄면 IP 가 계속 바뀝니다. 주소를 코드에 적는 순간 끝입니다.
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 원칙 |
동기 통신 — REST · gRPC
서비스 간 호출의 기본형. 단, 사슬이 길어질수록 느려지고 약해집니다.
REST vs gRPC
| REST + JSON | gRPC | |
|---|---|---|
| 형식 | 텍스트(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 클라이언트/서버 코드를 생성
비동기 통신 — 이벤트
"주문 완료를 알려만 주고 내 갈 길 간다" — 결합을 끊는 가장 강한 도구입니다.
언제 동기, 언제 이벤트
| 상황 | 선택 |
|---|---|
| 결제 승인 결과가 있어야 주문 응답 가능 | 동기 — 응답에 결과가 필요하면 어쩔 수 없습니다 |
| 주문 완료 후 알림·적립·통계 | 이벤트 — 주문 응답과 무관합니다 |
| 재고 차감 (틀리면 안 됨) | 동기 + 실패 시 거절, 또는 예약-확정 사가(m08) |
| 다른 시스템에 데이터 복사·동기화 | 이벤트 — CDC·아웃박스 (k07) |
데이터 분리 — 사가 패턴
DB 가 갈라지면 ROLLBACK 이 없습니다. 되돌리는 것도 직접 설계해야 합니다.
모놀리스에서는 주문 저장·재고 차감·결제를 한 트랜잭션으로 묶고 실패하면 통째로 롤백했습니다. MSA 에서는 세 작업이 서로 다른 서비스의 서로 다른 DB에서 일어납니다. 분산 트랜잭션(2PC)은 느리고 조정자가 죽으면 전체가 멈춰 실무에서 안 씁니다. 대신 사가(Saga) — 로컬 트랜잭션의 연쇄 + 실패 시 보상 트랜잭션으로 되돌립니다.
두 가지 진행 방식
코레오그래피 — 이벤트로 릴레이
주문 → [주문생성됨] → 재고가 구독해 차감
→ [재고차감됨] → 결제가 구독해 승인
→ [결제실패됨] → 각자 보상 실행
장점 중앙 조정자가 없다 — 결합 최소
단점 전체 흐름이 코드 어디에도 없다
→ 서비스 5개 넘으면 추적 불가
적합 단계 2~3개의 단순한 흐름
오케스트레이션 — 지휘자가 지시
주문 사가 오케스트레이터가
① 재고에 차감 명령 → 응답
② 결제에 승인 명령 → 실패!
③ 재고에 복원 명령 (보상)
장점 흐름이 한 곳에 — 상태 추적 쉬움
단점 오케스트레이터가 비대해질 수 있음
적합 단계 많고 보상 복잡한 핵심 흐름
도구 Temporal · AWS Step Functions
장애 격리 — 서킷브레이커
MSA 최대 리스크는 연쇄 장애입니다. 두꺼비집처럼 선을 끊어서 막습니다.
타임아웃 · 재시도 — 함께 써야 완성
| 도구 | 규칙 |
|---|---|
| 타임아웃 | 모든 원격 호출에 예외 없이. 연결 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 # 응답 타임아웃
관측 가능성 — 로그 · 메트릭 · 트레이싱
"주문이 느려요" — 서비스 10개 중 어디가 느린지 5분 안에 찾을 수 있어야 합니다.
세 기둥 — 각자 답하는 질문이 다릅니다
| 기둥 | 답하는 질문 | 도구 |
|---|---|---|
| 메트릭 | "지금 얼마나 나쁜가?" — RPS · 에러율 · p99 | Prometheus + Grafana |
| 로그 | "그때 정확히 무슨 일이 있었나?" | Loki · ELK — 중앙 수집 필수 |
| 트레이싱 | "어느 구간이 느렸나?" | OpenTelemetry + Tempo·Jaeger |
traceId 를 만들어 모든 서비스가 로그에 함께 남기고,
다음 호출에 헤더로 전달합니다(W3C traceparent 표준).
OpenTelemetry 를 쓰면 전파·수집이 자동입니다 — 직접 만들지 마세요.
로그는 JSON 구조화로 남겨야 검색이 됩니다.
MSA 도입 판단 — 하지 말아야 할 때
가장 비싼 실수는 너무 일찍, 한 번에 쪼개는 것입니다. 전환은 점진적으로 합니다.
도입 체크리스트 — 정직하게 답해 보세요
| 질문 | 아니오라면 |
|---|---|
| 배포가 느려서(조율·대기) 실제로 고통받고 있나? | 모놀리스 유지 |
| 부분별로 확장 요구가 뚜렷이 다른가? | 복제 확장으로 충분 |
| 도메인 경계를 팀이 설명할 수 있나? | 모듈러 모놀리스부터 |
| CI/CD · 중앙 로그 · 모니터링이 자동화돼 있나? | 그것부터 (m10) |
| 서비스별로 온콜 가능한 팀이 있나? | 운영이 무너집니다 |
Kafka —
대규모 데이터가
흐르는 길
초당 수만 건의 주문·클릭·로그를 잃어버리지 않고 여러 시스템에 전달하는 일 — 그것이 Kafka 가 하는 일입니다. 국내 대기업 백엔드에서 사실상 표준이 된 기술이고, 면접에서도 파티션·컨슈머 그룹·정확히 한 번 처리는 단골 주제입니다. 핵심 구조부터 운영·실전 패턴까지 순서대로 갑니다.
왜 Kafka 인가
직접 호출로 버티던 시스템이 무너지는 순간이 있습니다 — 받는 쪽이 못 따라올 때입니다.
언제 필요한가 — 신호 세 가지
| 신호 | Kafka 가 해 주는 것 |
|---|---|
| 피크 때 하위 시스템이 밀림 | 버퍼로 흡수 — 받는 쪽은 자기 속도로 (📈 대규모 트래픽 x06) |
| 같은 데이터를 여러 곳이 원함 | 한 번 발행 → 각자 구독. 발행자는 소비자를 모릅니다 (🧱 MSA m07) |
| 이벤트 순서·재처리가 중요 | 로그라서 과거로 되감아 다시 읽기가 됩니다 — 버그 수정 후 재처리 |
핵심 구조 — 토픽 · 파티션 · 오프셋
Kafka 를 안다는 것은 이 그림 하나를 안다는 뜻입니다. 나머지는 전부 여기서 나옵니다.
용어 한 벌
| 용어 | 뜻 |
|---|---|
| 브로커 | Kafka 서버 한 대. 보통 3대 이상 클러스터로 묶습니다 |
| 토픽 | 메시지의 분류 채널 — orders · payments · click-logs |
| 파티션 | 토픽을 나눈 조각. 추가만 되는 로그이며 병렬성·순서의 단위 |
| 오프셋 | 파티션 안에서 메시지의 번호. 컨슈머가 "어디까지 읽었나"를 이 번호로 저장 |
| 보존 기간 | 읽든 안 읽든 설정한 기간(예: 7일) 보관 후 삭제 — 소비와 무관합니다 |
컨슈머 그룹 · 리밸런싱
"처리를 여러 대로 나누고 싶다"와 "같은 데이터를 여러 팀이 읽고 싶다"를 한 구조가 해결합니다.
랙(Lag) — 가장 중요한 운영 지표
랙 = 마지막 오프셋 − 컨슈머가 커밋한 오프셋, 즉 "밀린 메시지 수"입니다. 랙이 0 근처를 유지하면 실시간으로 따라가는 것이고, 계속 증가하면 소비가 생산을 못 따라가는 것입니다 — 파티션·컨슈머 증설이나 처리 로직 최적화가 필요한 신호입니다. 피크 때 잠깐 쌓였다가 줄어드는 것은 정상입니다(그러라고 쓰는 버퍼입니다).
max.poll.interval.ms(기본 5분) 안에
다음 poll 을 못 하면 죽은 것으로 간주되어 쫓겨나고 리밸런싱이 돕니다.
한 배치 처리가 5분을 넘는 무거운 로직이 흔한 원인 —
처리 후 리밸런싱 → 재배정 후 또 초과 → 무한 반복으로 소비가 0이 됩니다.
해법: 배치 크기(max.poll.records)를 줄이거나 처리를 비동기로 나눕니다.
설치 · 첫 메시지
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
프로듀서 제대로 — acks · 멱등성 · 키
기본 설정으로 보내면 유실도 중복도 생깁니다. 설정 네 개가 방어선입니다.
// 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);
컨슈머 제대로 — 커밋 · 중복 · 멱등
"언제 읽었다고 기록하느냐"가 유실과 중복을 가릅니다. 공짜 정답은 없습니다.
// 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; -- 이미 처리한 것 → 조용히 스킵
DefaultErrorHandler + DeadLetterPublishingRecoverer).
DLQ 는 모니터링 대상입니다 — 쌓이는데 아무도 안 보면 조용한 데이터 유실입니다.
DB 와 Kafka 를 함께 — 아웃박스 · CDC
"DB 커밋과 이벤트 발행, 둘 중 하나만 성공하면?" — MSA 면접의 최고 빈출 문제입니다.
Kafka 트랜잭션(EOS)은 언제 쓰나
Kafka 자체 트랜잭션은 "Kafka 에서 읽어 → 가공 → Kafka 로 쓰는" 스트림 구간을
정확히 한 번으로 만들어 줍니다(Kafka Streams exactly_once_v2).
하지만 외부 세계(DB 저장·메일 발송·결제 API)까지는 묶지 못합니다.
그래서 경계의 답은 결국 — DB 와의 원자성은 아웃박스, 소비 측은 멱등 처리입니다.
복제 · ISR — 브로커가 죽어도
"브로커 한 대가 내려가면 어떻게 되나요?" — 운영 면접의 단골이자, 실제 새벽의 일입니다.
| 운영 표준 설정 | 의미 |
|---|---|
replication.factor=3 | 파티션마다 복제본 3개 — 브로커 3대 이상 |
min.insync.replicas=2 | 최소 2곳에 쓰여야 성공 — 1대 다운까지는 무중단·무유실 |
acks=all (프로듀서) | 위 둘과 한 세트 — 하나라도 빠지면 구멍이 생깁니다 |
운영 — 파티션 수 · 랙 · 보존
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) |
kafka-consumer-groups.sh --reset-offsets --to-datetime … 으로
오프셋을 되감아 다시 읽으면 됩니다 — 단, 소비가 멱등해야 안전한 재처리가 됩니다(k06).
이 되감기가 필요해서라도 멱등 설계는 필수입니다.
실전 파이프라인 — 스트림 · 데이터 흐름
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 vs RabbitMQ · Redis — 선택 기준
"왜 Kafka 를 썼나요?" 에 비교 근거로 답할 수 있어야 합니다.
| Kafka | RabbitMQ | Redis (Streams·Pub/Sub) | |
|---|---|---|---|
| 모델 | 지워지지 않는 로그 | 꺼내면 사라지는 큐 | 인메모리 — 가볍고 빠름 |
| 처리량 | 초당 수십만~수백만 | 초당 수만 | 초당 수만~수십만 |
| 재읽기 | 가능 — 오프셋 되감기 | 불가 (기본) | Streams 는 제한적 가능 |
| 다중 구독 | 그룹마다 독립 | 익스체인지 구성 필요 | Pub/Sub 은 접속 중일 때만 |
| 라우팅 | 토픽 단위 (단순) | 정교 — 패턴·헤더·우선순위·지연 | 단순 |
| 운영 부담 | 큼 — 클러스터·파티션 관리 | 중간 | 작음 (이미 있는 경우 多) |
고르는 규칙
| 상황 | 선택 |
|---|---|
| 이벤트 파이프라인 · 대용량 로그 · 여러 팀이 같은 데이터 구독 · 재처리 필요 | Kafka |
| 작업 큐(이메일 발송·이미지 변환) · 복잡한 라우팅 · 지연 큐·우선순위 | RabbitMQ |
| 소규모 비동기 · 이미 Redis 가 있고 유실 약간 감수 · 실시간 알림 팬아웃 | Redis |
대규모 트래픽 —
백만 동시 접속을
버티는 설계
선착순 쿠폰 오픈, 티켓팅, 이벤트 페이지 — 평소의 100배 트래픽이 몰리는 순간 무엇이 먼저 무너지고, 어떻게 버티게 만드는가. 무상태 설계·캐시 계층·DB 확장·큐·유량 제어·오토스케일링까지, 대기업 시스템 설계 면접의 단골 주제 전부를 실전 순서로 다룹니다.
대규모의 기준 — 숫자 감각
"대규모 트래픽 경험"을 말하려면 먼저 숫자로 환산하는 감각이 필요합니다.
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 · 쓰기는 훨씬 작음
무상태 설계 — 확장의 전제
서버를 100대로 늘리려면 먼저 어느 대에 요청이 가도 같게 만들어야 합니다.
서버 밖으로 빼야 하는 것들
| 상태 | 보내는 곳 |
|---|---|
| 로그인 세션 | Redis (세션 스토어) — 또는 JWT 로 상태 자체를 토큰에 |
| 업로드 파일 | S3 · 오브젝트 스토리지 — 로컬 디스크 금지 |
| 캐시 | Redis — 로컬 캐시는 서버마다 달라져 정합성 문제 (x04) |
| 스케줄 작업 | 분산 락 또는 전용 워커 — 안 하면 N대가 같은 배치를 N번 돌립니다 |
| 순번·카운터 | Redis INCR · DB — 메모리 변수는 서버마다 따로 셉니다 |
캐시 계층 ① — 브라우저 · CDN
가장 빠른 요청은 서버에 도착하지 않는 요청입니다.
# 원본(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 로
}
Cache-Control: private 없는 "내 주문 목록"이 CDN 에 캐시되면
남의 주문이 보입니다 — 실제로 반복되는 유형의 사고입니다.
쿠키·Authorization 이 붙는 응답은 기본 private, no-store 로 두고,
캐시할 것만 명시적으로 여세요.
캐시 계층 ② — 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)은 동시성 순서 꼬임 위험
그 밖의 함정들
| 함정 | 내용 · 해법 |
|---|---|
| 캐시 관통 | 존재하지 않는 키(악의적 무작위 ID) 조회는 매번 DB 로 — "없음"도 짧게 캐시 · 블룸 필터 |
| 핫 키 | 단일 키에 초당 수십만 조회 → Redis 한 노드 한계 — 로컬 캐시 1~2초를 앞에 겹칩니다 |
| 빅 키 | 수 MB 값·수십만 원소 컬렉션 — 조회·삭제가 Redis 를 블로킹. 쪼개 두세요 |
| 전멸 대비 | Redis 가 통째로 죽으면 전 요청이 DB 로 — 그 순간의 DB 보호가 x07(유량 제어)입니다 |
DB 확장 — 레플리카 · 샤딩
캐시로도 막을 수 없는 트래픽이 오면, 마지막 병목 DB 자체를 늘려야 합니다.
샤딩 전에 알아야 할 것
| 주제 | 내용 |
|---|---|
| 샤드 키 선택 | 대부분의 쿼리에 포함되는 키(회원ID·주문ID). 잘못 고르면 모든 샤드를 뒤지는 쿼리가 됩니다 |
| 해시 vs 범위 | 해시 — 균등 분산·범위 조회 불리 / 범위 — 시계열 유리·최신 샤드 과열 |
| 크로스 샤드 | 전체 집계·검색은 별도 경로로 — 통계 DB·검색 엔진에 CDC 로 복제해 처리 (📨 k07) |
| 대안 먼저 | Vitess·Citus 같은 샤딩 미들웨어, 또는 처음부터 분산 DB — 직접 구현은 최후입니다 |
큐로 피크 흡수 — 비동기 전환
"지금 다 처리"를 포기하면 10배의 피크가 평지가 됩니다.
유량 제어 — Rate Limit · 셰딩
한계는 반드시 옵니다. 전부 죽는 것과 일부를 거절하는 것 중에 골라야 합니다.
함께 쓰는 방어 도구
| 도구 | 내용 |
|---|---|
| 부하 셰딩 | 과부하 신호(큐 길이·p99)가 뜨면 덜 중요한 요청부터 즉시 거절 — 추천은 버리고 주문은 지킵니다 |
| 백프레셔 | "밀린다"는 신호를 상류로 전파해 유입 자체를 늦춥니다 — 무한 버퍼는 지연 폭탄일 뿐입니다 (🧬 z09) |
| 대기열 시스템 | 티켓팅의 "대기표" — 초과 유입을 순번 페이지에 세워 두고 처리량만큼만 입장시킵니다 |
| 기능 플래그 | 비상시 무거운 기능(실시간 랭킹·추천)을 스위치로 끕니다 — 성능 예산을 핵심에 몰아주기 |
Retry-After 를 담고, 앱·웹 클라이언트는 지수 백오프 + 지터로
재시도하게 합니다 — 서버와 클라이언트가 한 팀으로 움직여야 진짜 방어입니다.
오토스케일링 — 늘어나는 인프라
"미리 넉넉히"는 비용 낭비, "부족하면 수동 증설"은 새벽 호출입니다. 자동이 답입니다.
현실 체크리스트
| 항목 | 내용 |
|---|---|
| 급증은 못 따라갑니다 | 새 파드 뜨는 데 수십 초~수 분 — 예고된 이벤트는 사전 증설(스케줄 스케일링)이 정답 |
| 하한을 두세요 | min replicas 를 피크 직전 수준으로 — 0→100 은 자동화로도 못 버팁니다 |
| 아래층도 함께 | 앱만 늘면 DB 커넥션 풀이 곱해집니다 — 커넥션 수·캐시·DB 용량을 같이 계산 |
| 비용 상한 | max replicas + 비용 알람 — 오토스케일링 사고는 요금 고지서로 옵니다 |
규모별 아키텍처 견본 — 1만 · 10만 · 100만
동시 접속 규모마다 꺼내 드는 도구가 다릅니다. 한 장으로 정리합니다.
부하 테스트 — 한계를 미리 잰다
"몇 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)이 먼저 소진됐는지가 병목 |
| 회복 | 부하를 내렸을 때 정상으로 돌아오나 — 못 돌아오면(풀 고갈·좀비 커넥션) 더 심각한 문제입니다 |
폭주 당일 — 운영 시나리오
설계가 끝이 아닙니다. 이벤트 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 설계 |
| 로그 폭증으로 디스크 풀 | 피크 때 로그량은 수십 배 — 샘플링·레벨 조정 스위치 준비 |
| 배포 직후 이벤트와 겹침 | 이벤트 전 배포 동결 창 — 변수를 하나라도 줄입니다 |
설계 면접 — 단골 문제 풀이 골격
이 탭 전체를 면접 답안 형태로 압축합니다. 골격을 외우면 응용이 됩니다.
공통 진행 순서 — 어떤 문제든 이 순서로
단골 문제 3제 — 핵심 답 구조
| 문제 | 답의 골격 |
|---|---|
| 선착순 쿠폰 1만 장 | 페이지는 CDN 정적화 → 진입 Rate Limit → Redis 원자 감소로 순번 판정 → 당첨만 큐 → 워커가 DB 기록 · 결과는 폴링/푸시. DB 직행 금지가 핵심 |
| 타임라인/피드 | 읽기 폭발 문제 — 팬아웃 온 라이트(쓸 때 팔로워 타임라인 캐시에 밀어 넣기) 기본, 연예인(팔로워 수백만)은 팬아웃 온 리드로 혼합. 캐시가 사실상 저장소 |
| 실시간 랭킹 | Redis Sorted Set(ZINCRBY·ZREVRANGE)으로 실시간 집계 · 영속화는 이벤트를 Kafka 로 흘려 배치 정산 — 실시간과 정확성의 이원화 |
윈도우 PC 하나로
사내에 서비스
띄우기
Nginx 도 Apache 도 없이, 만든 프로그램을 그냥 IP 와 포트로 여는 방법만 다룹니다.
사내 직원들이 http://192.168.0.50:8080 으로 들어오게 만드는 것이 1차 목표,
IPTIME 공유기에서 포트포워딩해 집이나 외근지에서도 열리게 만드는 것이 2차 목표입니다.
마지막으로 외부에 열었을 때 최소한으로 해야 할 보안을 정리합니다.
명령은 전부 윈도우 기준으로, 복사해 붙이면 되게 적었습니다.
무엇을 만드는가 — 전체 그림
우리가 하려는 일은 "내 PC 에서 돌아가는 프로그램을 다른 사람 브라우저에서 열리게 하는 것" 하나입니다. 여기에 필요한 것은 세 가지뿐입니다 — 프로그램이 떠 있을 것 · 방화벽이 열려 있을 것 · 상대가 주소를 알 것.
웹서버(Nginx·Apache)를 안 써도 되나요
사내에서 수십 명이 보는 도구라면 됩니다. Spring Boot · Node · Django · .NET 은 모두 자체 서버 기능을 갖고 있어 그 자체로 요청을 받습니다. 앞단 웹서버는 정적 파일 처리 · HTTPS · 여러 앱 분배가 필요할 때 붙이는 것이고, 지금 목표에는 없어도 됩니다.
| 이럴 때 | 이렇게 |
|---|---|
| 사내 도구 · 사내 대시보드 | 지금 이 탭 그대로 — 앱만 띄우고 방화벽만 엽니다 |
| 외부 공개 + 도메인 + HTTPS | v11 의 Caddy 방식 또는 Caddy 탭으로 — 인증서가 자동입니다 |
| 동시 접속 수백 이상 · 여러 앱 | Nginx 탭으로 — 리버스 프록시 구조가 필요합니다 |
| 회사 규정상 서버실 밖 공개 금지 | 사내망까지만(v05). 외부 공개는 VPN 으로 — v10 에 씁니다 |
· 서버 PC 사설 IP 192.168.0.50 · 서비스 포트 8080 · 공유기 IPTIME(192.168.0.1)
· 프로그램은 Spring Boot jar · Node · Python 중 무엇이든 상관없습니다 — 포트만 같으면 절차가 동일합니다
내 서버의 IP 와 포트 확인하기
먼저 내 PC 의 주소를 알아야 합니다. 여기서 나오는 192.168.x.x 는 사무실 안에서만 통하는 주소이고, 바깥에서 보이는 주소는 따로 있습니다(v06). 이 둘을 헷갈리는 것이 첫 번째 함정입니다.
① 사설 IP 확인 — 명령 프롬프트
# 시작 → cmd 입력 → 명령 프롬프트 실행
ipconfig
# 출력에서 "이더넷 어댑터" 또는 "Wi-Fi" 아래를 봅니다
# IPv4 주소 . . . . . . . . . : 192.168.0.50 ← 이것이 내 사설 IP
# 서브넷 마스크 . . . . . . . : 255.255.255.0
# 기본 게이트웨이 . . . . . . : 192.168.0.1 ← 이것이 공유기 주소
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 | 시스템 예약 구간. 특별한 이유가 없으면 피합니다 |
앱 띄우기 — 창을 닫아도 살아 있게
명령 프롬프트에서 실행하면 그 창을 닫는 순간 서비스가 죽습니다. 로그아웃해도, 재부팅해도 알아서 다시 뜨게 하려면 윈도우 서비스로 등록해야 합니다. 여기까지 해야 "띄웠다"고 할 수 있습니다.
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 Boot | server.address=0.0.0.0 (기본값이 이미 전체이므로 대개 손댈 필요 없음) · server.port=8080 |
| Node · Express | app.listen(8080, '0.0.0.0') — 호스트를 안 적으면 환경에 따라 로컬만 열립니다 |
| Python · FastAPI | uvicorn main:app --host 0.0.0.0 --port 8080 |
| Python · Django | python 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
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
http://localhost:8080 이 열리고,
netstat -ano | findstr :8080 에 0.0.0.0:8080 LISTENING 이 보이면 이 단계는 끝입니다.
윈도우 방화벽 — 그 포트만 열기
프로그램은 떠 있는데 다른 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"
-Profile Domain,Private 를 붙인 이유는 사내망에서만 열고 공용 네트워크에서는 막기 위해서입니다.
카페 와이파이에 노트북을 물렸을 때 자동으로 닫히므로 훨씬 안전합니다.
외부 공개(포트포워딩)를 할 때는 공유기를 거쳐 들어오는 접속도 사내망에서 오는 것처럼 보이므로 이 설정 그대로 동작합니다.
화면에서 클릭으로 하는 법
| 순서 | 할 일 |
|---|---|
| 1 | 시작 → "고급 보안이 포함된 Windows Defender 방화벽" 실행 (또는 wf.msc) |
| 2 | 왼쪽에서 인바운드 규칙 → 오른쪽 새 규칙 |
| 3 | 포트 선택 → 다음 → TCP · 특정 로컬 포트에 8080 |
| 4 | 연결 허용 → 프로필은 도메인 · 개인만 체크(공용은 해제) |
| 5 | 이름을 "사내도구 8080" 처럼 알아볼 수 있게 — 나중에 정리할 때 필요합니다 |
사내망에서 접속 확인
여기까지 왔으면 같은 사무실의 다른 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단계) |
SRV-TOOL 이라면
사내에서 http://srv-tool:8080 으로 접속됩니다. IP 가 바뀌어도 주소가 유지돼 편하지만,
회사 네트워크 구성에 따라 안 될 수도 있으니 IP 방식을 기본으로 안내하고 이름은 보조로 알려 주세요.
공유기 구조 — 왜 포트포워딩이 필요한가
사무실 전체가 공인 IP 하나를 나눠 쓰고 있습니다. 밖에서 그 주소로 들어오면 공유기는 "이 요청을 안쪽 어느 PC 에게 줘야 하지?" 를 알 수 없습니다. 그 답을 미리 적어 두는 것이 포트포워딩입니다.
먼저 할 일 — 서버 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
IPTIME 포트포워딩 설정
화면을 따라가며 규칙 한 줄만 넣으면 됩니다. 넣을 값은 네 개 — 규칙 이름 · 내부 IP · 외부 포트 · 내부 포트 입니다.
화면 순서 (IPTIME 공통)
| 순서 | 할 일 |
|---|---|
| 1 | 브라우저에 192.168.0.1 입력 → 관리자 로그인 (초기값 admin / admin — 반드시 바꿉니다, v10) |
| 2 | 왼쪽 위 관리도구 클릭 → 고급 설정 |
| 3 | NAT/라우터 관리 → 포트포워드 설정 |
| 4 | 새 규칙 추가 — 규칙이름 사내도구 / 내부 IP 192.168.0.50 / 프로토콜 TCP |
| 5 | 외부 포트 8080 ~ 8080 · 내부 포트 8080 ~ 8080 입력 → 적용 |
| 6 | 목록에 규칙이 보이면 끝. 공유기 재부팅은 필요 없습니다 |
UPnP 로 자동 등록하는 방법도 있지만
프로그램이 공유기에 스스로 포트를 여는 기능(UPnP)이 있습니다. 편하지만 어떤 프로그램이든 마음대로 포트를 열 수 있다는 뜻이라 회사 환경에서는 권하지 않습니다. 손으로 규칙을 넣고, 어떤 포트가 열려 있는지 문서로 남기는 편이 안전합니다.
외부 접속 확인 — 안 될 때 원인 사전
포트포워딩을 넣었는데도 안 열리는 경우가 많습니다. 원인은 거의 다섯 가지 안에 있습니다. 하나씩 지워 나가면 됩니다.
먼저 — 공인 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 의 보안 조치를 같은 날 안에 끝내세요.
DDNS — 바뀌는 IP 대신 이름
공인 IP 는 대개 고정이 아닙니다. 공유기를 재부팅하거나 통신사 사정으로 바뀌면 어제 알려 준 주소가 죽습니다.
IPTIME 은 무료 DDNS 를 제공하므로, 우리회사도구.iptime.org 같은 이름을 만들어 쓰면 됩니다.
설정 순서
| 순서 | 할 일 |
|---|---|
| 1 | IPTIME 관리도구 → 고급 설정 → 특수기능 → DDNS 설정 |
| 2 | 호스트 이름에 회사이름도구.iptime.org 입력 (원하는 앞부분만 정하면 됩니다) |
| 3 | 사용자 ID 에 본인 이메일 입력 → 추가 |
| 4 | 접속 상태가 "정상 등록" 으로 바뀌는지 확인 (몇 초 걸립니다) |
| 5 | 이제 주소는 http://회사이름도구.iptime.org:8080 — IP 가 바뀌어도 따라갑니다 |
*.iptime.org 대역은 자동 스캐너가 늘 훑는 곳입니다.
DDNS 를 켜는 것과 보안 조치(v10·v11) 는 반드시 같이 갑니다.
최소 보안 ① — 열린 문을 줄입니다
외부 공개에서 가장 중요한 것은 화려한 보안 장비가 아니라 "필요 없는 문을 닫아 두는 것" 입니다. 아래 여섯 가지는 초보자도 30분이면 할 수 있고, 실제 사고의 대부분을 막습니다.
① 공유기부터 잠급니다 — 5분
| 설정 | 이유 |
|---|---|
| 관리자 비밀번호 변경 기본 설정 → 시스템 관리 → 관리자 설정 | admin/admin 그대로면 공유기 자체가 먼저 뚫립니다 — 가장 먼저 하세요 |
| 원격 관리 포트 끄기 고급 설정 → 보안 기능 → 공유기 접속·관리 제한 | 공유기 관리 화면이 인터넷에 열려 있으면 모든 규칙을 남이 바꿀 수 있습니다 |
| DMZ · UPnP 끄기 | 의도치 않게 다른 포트가 열려 있을 수 있습니다 — 목록을 열어 지금 있는 규칙 전부를 확인하세요 |
| 공유기 펌웨어 업데이트 | 공개된 취약점이 그대로 남아 있는 구형 펌웨어가 많습니다 |
② 절대 열면 안 되는 포트
이 포트들을 포트포워딩하면 며칠 안에 무차별 로그인 시도가 시작됩니다. 실제 랜섬웨어 감염 경로 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"
④ 서버 PC 자체를 정리합니다
| 항목 | 할 일 |
|---|---|
| 계정 | 사용하지 않는 계정 비활성화 · 관리자 비밀번호를 길게(12자 이상) · 자동 로그인 해제 |
| 업데이트 | 윈도우 업데이트를 켜 두되 재부팅 시간대를 업무 외 시간으로 지정 (서비스는 자동 시작되므로 안전합니다 — v03) |
| 불필요한 공유 | net share 로 열린 공유 폴더 확인 → 필요 없는 것 제거 |
| 백업 | 앱 폴더와 데이터(DB 파일 등)를 다른 디스크나 NAS 로 매일 복사 — 이것만 있어도 최악은 면합니다 |
| 전원 설정 | 절전으로 잠들면 서비스가 멈춥니다 — 전원 옵션에서 절전·최대 절전 해제 |
최소 보안 ② — 로그인 · 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 방식 그대로)
② HTTPS — DDNS 이름이 있으면 자동입니다
위 Caddyfile 의 첫 줄에 도메인 이름을 적어 두면, Caddy 가 인증서를 알아서 발급·갱신합니다. 다만 발급 과정에서 인증 기관이 그 도메인으로 접속해 확인하므로 조건이 있습니다.
| 상황 | 되는지 · 방법 |
|---|---|
| 80·443 을 열 수 있다 | 가장 쉽습니다. 포트포워딩에 80·443 을 추가하면 Caddy 가 자동으로 인증서를 받습니다 |
| ISP 가 80·443 을 막았다 | HTTP 방식 인증이 안 됩니다 → DNS 챌린지가 필요합니다(도메인 관리 권한 필요). iptime.org 로는 어렵습니다 |
| 사내망 전용이다 | 공인 인증서를 받을 수 없습니다 → 사설 인증서를 만들고 각 PC 에 신뢰 등록하거나, HTTP 로 두되 IP 제한 + 로그인으로 갑니다 |
| 회사 도메인이 있다 | 서브도메인 하나를 서버로 연결하면 Caddy 자동 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분만 보면 충분합니다. 안 보는 로그는 없는 것과 같습니다
체크리스트와 문제 해결 사전
순서대로 체크하면 빠진 것을 찾을 수 있습니다. 그리고 안 될 때는 아래 순서도 하나면 원인을 좁힐 수 있습니다.
사내 공개 체크리스트
| ✓ | 항목 | 확인 방법 |
|---|---|---|
| 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 | 공유기 관리자 비밀번호 변경 · 원격 관리 차단 | 기본 설정 → 시스템 관리 |
| 9 | 3389 · 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 으로 사내망에 붙게 하고, 이 탭의 사내 공개 구성까지만 씁니다
컨테이너로
배포하고
쿠버네티스로 운영하기
"제 PC 에서는 됐는데요"를 끝내는 것이 컨테이너이고, 컨테이너 수백 개를 사람 손 없이 굴리는 것이 쿠버네티스입니다. 이 탭은 도커를 한 번도 안 써 본 상태에서 출발해 Dockerfile 작성 → Compose 로 로컬 스택 → 레지스트리 → 쿠버네티스 첫 배포 → 무중단 · 오토스케일 · 관측 → GitOps · CI/CD → 장애 진단까지 한 줄로 잇습니다. YAML 은 전부 복사해서 그대로 도는 완본이고, 명령은 실제로 쳐 보라고 순서대로 놓았습니다. 끝나면 본인 앱을 이미지로 만들어 클러스터에 올리고, 죽었을 때 원인을 5분 안에 찾을 수 있습니다.
컨테이너가 푸는 문제 — "제 PC 에서는 됐는데요"
기술을 배우기 전에 어떤 고통을 없애려고 만든 물건인지부터 봅니다. 이걸 모르면 도커는 그냥 "명령어 외우기"가 됩니다.
VM 과 컨테이너 — 무엇이 진짜 다른가
가상 머신(VM)은 가짜 하드웨어 위에 운영체제를 통째로 하나 더 올립니다. 커널·부팅 과정·systemd 가 전부 따로라 기동에 수십 초가 걸리고 메모리도 GB 단위로 먹습니다. 컨테이너는 호스트의 커널을 그대로 빌려 쓰면서, 프로세스에게 "너만의 세상"으로 보이게 속이는 기술입니다. 커널이 새로 뜨지 않으니 기동이 수십 밀리초입니다.
속이는 방법 — 네임스페이스 · 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 # 컨테이너 상한이 아니라 호스트 메모리가 나옵니다
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하나로 온보딩이 끝납니다
도커 기본기 — run · ps · logs · exec
명령 여섯 개면 컨테이너의 일생을 전부 다룰 수 있습니다. 나머지는 이 여섯 개의 옵션일 뿐입니다.
명령 여섯 개 — 이것만으로 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 Desktop | Win·macOS 용 GUI 묶음. 내부적으로 VM 안에서 리눅스 도커를 돌립니다 | 개인 개발에는 가장 편합니다. 단 일정 규모 이상 기업은 유료 — 라이선스 확인 필요 |
| Podman | 데몬이 없는(daemonless) 구현. 명령이 도커와 거의 같습니다(alias docker=podman) | rootless 가 기본이라 보안에 유리. RHEL 계열 기본 선택 |
| containerd + nerdctl | 실제로 컨테이너를 돌리는 하부 런타임. 도커도 내부에서 이걸 씁니다 | 쿠버네티스 노드의 표준입니다. 클러스터에서 직접 볼 일이 있습니다(crictl) |
| Rancher Desktop OrbStack · Colima | Desktop 대체재 | 라이선스 회피 + 가벼움. macOS 에서는 OrbStack 이 빠르다는 평이 많습니다 |
Dockerfile 잘 쓰기 — 캐시와 멀티스테이지
"돌아가는 Dockerfile"은 10분이면 씁니다. 문제는 그게 빌드 5분 · 이미지 1.1GB 라는 것입니다. 줄 순서만 바꿔도 빌드 20초 · 이미지 186MB 가 됩니다.
멀티스테이지 빌드 — 빌드 도구를 결과물에서 지우기 🟡 중급
컴파일러·빌드 도구·테스트 라이브러리는 빌드할 때만 필요하고 실행할 때는 쓰레기입니다. 그런데 한 스테이지로 만들면 그게 전부 최종 이미지에 남습니다 — JDK 포함 이미지가 1.14GB 인 이유입니다. 멀티스테이지는 빌드 결과물만 깨끗한 이미지로 복사합니다.
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"]
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 | 데비안 풀 세트. 편하지만 안 쓰는 패키지가 전부 취약점 목록에 잡힙니다. 빌드 스테이지용으로만 |
slimnode:24-slim | ~230MB | 운영 기본 추천. 데비안 최소 세트 + glibc. 대부분의 네이티브 모듈이 그대로 돕니다 |
alpinenode:24-alpine | ~60MB | 가장 작지만 musl libc 를 씁니다 — glibc 전제 바이너리가 깨지고, 파이썬은 wheel 을 못 써 소스 컴파일로 빠져 빌드가 느려집니다. DNS 해석 차이로 이상한 장애가 나기도 합니다 |
| distroless chainguard | ~50MB | 가장 안전. 셸·패키지 매니저가 없어 침입해도 할 게 없습니다. 대신 exec 로 진입 불가 — 디버깅 방식을 바꿔야 합니다(d14) |
.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 .
docker build 하면
arm64 이미지가 만들어집니다. 이걸 x86 서버에 올리면
exec format error 로 죽거나 에뮬레이션으로 3~10배 느리게 돕니다.
--platform linux/amd64 를 명시하거나 CI 에서 빌드하세요(d13).
Docker Compose — 여러 컨테이너를 한 번에
앱 하나만 컨테이너로 만들면 별 이득이 없습니다. 앱 + DB + 캐시 + 프록시가
up -d 한 줄로 뜨는 순간부터 세상이 달라집니다.
완본 — 앱 + 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
볼륨 백업 — 컨테이너를 지워도 데이터가 남게
#!/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
이미지 레지스트리 — 태그 · 스캔 · 서명
이미지를 만들었으면 어딘가에 올려 두어야 다른 서버가 내려받습니다. 그 "어딘가"가 레지스트리이고, 여기서 태그를 잘못 정하면 롤백이 불가능해집니다.
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
--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 단계에서 차단)
저장 비용 관리 — 안 지우면 무한히 쌓입니다
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
쿠버네티스 개념 지도 — 왜 필요한가
명령을 외우기 전에 이 물건이 무슨 문제를 대신 풀어 주는지부터 봅니다. 한 문장으로 줄이면 — "원하는 상태를 적어 두면 계속 그렇게 맞춰 주는 기계"입니다.
클러스터의 구조 — 컨트롤 플레인과 노드
| 구성 요소 | 하는 일 | 죽으면 |
|---|---|---|
| API 서버 | 모든 요청의 유일한 입구. 인증·인가·검증 후 etcd 에 기록 | 새 배포·조회 불가. 이미 도는 파드는 계속 돕니다 |
| etcd | 클러스터의 모든 상태를 담은 키·값 저장소. 여기가 진실 | 클러스터가 사실상 마비. 백업이 생명줄입니다 |
| 스케줄러 | 새 파드를 어느 노드에 놓을지 결정(리소스·제약 고려, d10) | 새 파드가 Pending 에서 안 움직입니다 |
| 컨트롤러 매니저 | Deployment·ReplicaSet 등 맞춰 주는 루프들의 집합 | 자가 치유·스케일이 멈춥니다 |
| kubelet | 노드마다 하나. 자기 노드의 파드를 띄우고 상태·프로브를 보고 | 그 노드가 NotReady → 파드가 다른 노드로 옮겨집니다 |
| kube-proxy (또는 CNI) | Service 주소로 온 트래픽을 실제 파드로 전달(iptables·IPVS·eBPF) | 서비스 간 통신이 끊깁니다 |
오브젝트 관계 — Pod 부터 Ingress 까지
| 오브젝트 | 한 줄 정의 | compose 로 치면 |
|---|---|---|
| Pod | 배포의 최소 단위. 컨테이너 1개 이상이 같은 IP·같은 볼륨을 공유 | 컨테이너 하나(+사이드카) |
| ReplicaSet | "이 파드가 N 개 있어야 한다"를 지키는 컨트롤러 | deploy.replicas |
| Deployment | ReplicaSet 을 관리하며 버전 교체·롤백을 담당 | 없음 — compose 에는 롤아웃 개념이 없습니다 |
| Service | 파드 묶음에 바뀌지 않는 이름과 IP를 주고 분산 | 서비스 이름 DNS |
| Ingress | 클러스터 밖에서 들어오는 HTTP 입구. 도메인·경로 라우팅 | Nginx 프록시 컨테이너 |
| ConfigMap · Secret | 설정·비밀을 파드 밖에 두고 주입 | environment · .env |
| Namespace | 이름 공간 분리(팀·환경). 리소스 쿼터의 단위 | compose 프로젝트 이름 |
for { 원하는상태 := etcd에서읽기; 현재상태 := 실제로세보기; 차이를메우기 } —
이게 전부입니다. Deployment 컨트롤러도, HPA 도(d10), Argo CD 도(d12) 전부 같은 모양입니다.
그래서 kubectl 로 파드를 직접 지워도 ReplicaSet 컨트롤러가 곧바로 새로 만듭니다.
"왜 지워도 다시 생기죠?"의 답이 이것입니다 — 지우려면 위(Deployment)를 지워야 합니다.
첫 배포 — kind · kubectl · Deployment
클라우드 계정 없이 노트북에 진짜 클러스터를 띄우고, 앱을 올려 도메인으로 접속하는 전 과정을 한 번에 갑니다. 여기서 만든 YAML 이 운영에서도 거의 그대로 쓰입니다.
로컬 클러스터 고르기
| 도구 | 성격 | 추천 상황 |
|---|---|---|
| kind | 컨테이너 안에 노드를 만드는 방식. 기동 30초, 멀티 노드도 쉽습니다 | 학습·CI 에 가장 추천. 클러스터를 부수고 다시 만드는 게 편합니다 |
| minikube | VM 또는 도커 드라이버. 애드온(대시보드·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 }
Service 타입 네 가지 — 언제 뭘 쓰나
| 타입 | 동작 | 쓰는 곳 |
|---|---|---|
| ClusterIP | 클러스터 내부 전용 가상 IP. 기본값 | 거의 항상 이것. 앱끼리의 통신은 전부 ClusterIP |
| NodePort | 모든 노드의 같은 포트(30000~32767)를 열어 전달 | 로컬 실습·임시 확인. 운영에는 부적합(포트 번호가 지저분하고 노드 IP 노출) |
| LoadBalancer | 클라우드에 실제 로드밸런서를 생성하고 공인 IP 를 붙임 | 클라우드에서 외부 노출. 서비스마다 LB 가 생겨 비용이 붙습니다 → 그래서 Ingress 하나로 모읍니다 |
| Headless (clusterIP: None) | 가상 IP 없이 DNS 가 파드 IP 목록을 직접 반환 | StatefulSet(d08)·Kafka·DB 클러스터처럼 개별 파드를 직접 지목해야 할 때 |
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와 같은 함정).
설정 · 시크릿 · 스토리지
이미지는 어느 환경에서나 같아야 합니다. 그래서 환경마다 다른 것(주소·키·비밀번호)은 전부 밖에서 주입합니다. 데이터를 남겨야 하는 앱은 여기에 스토리지가 붙습니다.
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
외부 시크릿 저장소 — 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
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 은 언제 쓰나 🟡 중급
| 비교 | Deployment | StatefulSet |
|---|---|---|
| 파드 이름 | 랜덤 (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 등 노드 신원이 중요한 클러스터 |
checksum/config 어노테이션 패턴으로 씁니다(d12).
급하면 kubectl rollout restart deploy/api 로 무중단 재시작합니다.
무중단 배포 · 프로브 제대로 쓰기
"쿠버네티스를 쓰면 무중단"이 아닙니다. 프로브 세 개와 종료 처리를 제대로 쓸 때만 무중단입니다. 대부분의 배포 중 502 는 여기 설정이 틀려서 납니다.
프로브 설정 — 실전 값
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: { 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');
}
});
롤링 업데이트 — 시간축으로 보기
maxSurge: 1 · maxUnavailable: 0. 여유 노드가 없을 때만 타협합니다.종료 처리 — 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();
});
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 과 동일한 내용이 들어갑니다
오토스케일 · 리소스 — requests 와 limits
쿠버네티스에서 가장 많이 틀리는 숫자가 requests 와 limits 입니다. 이 두 값이 스케줄링 · 안정성 · 비용 · 오토스케일을 전부 결정합니다.
숫자를 정하는 법 — 감이 아니라 측정
# ① 지금 얼마나 쓰는지 본다 (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.2 | JVM 은 힙 + 메타스페이스 + 스레드 스택을 합해야 합니다. -Xmx 만 보면 반드시 OOM 납니다 |
| memory limits | requests 와 같게 | 다르게 두면 Burstable 등급이 되어 노드가 빡빡할 때 먼저 쫓겨납니다 |
| cpu requests | 평상시 p95 사용량 | 100m = 0.1코어. 웹 API 는 보통 100m~500m 에서 시작합니다 |
| cpu limits | 대개 생략. 멀티테넌트라면 requests 의 2~4배 | limits 를 걸면 스로틀로 p99 가 튑니다. 특히 JVM·Go 는 코어 수를 오인해 스레드를 과하게 만듭니다 |
cpu: 500m 를 줘도
런타임은 노드 전체 코어 수(예: 32)를 보고 스레드 풀·GC 스레드를 만듭니다.
그 결과 32개 스레드가 0.5코어를 나눠 쓰며 컨텍스트 스위칭만 하게 됩니다.
· Java:
-XX:MaxRAMPercentage 는 메모리를, -XX:ActiveProcessorCount 는 CPU 를 맞춥니다(Java 10+ 는 대부분 자동 인식)
· Go:
GOMAXPROCS 를 automaxprocs 라이브러리로 맞춥니다
· Node:
UV_THREADPOOL_SIZE 를 조정합니다.
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"
nodeSelector/affinity 는 "어디로 가고 싶다",
taint 는 "아무나 오지 마라" — 방향이 반대입니다. 보통 둘을 같이 씁니다.
관측 — 로그 · 메트릭 · 추적
컨테이너는 죽으면 흔적이 사라집니다. 파드가 재시작되면 그 안의 로그도 함께 없어지죠. 그래서 쿠버네티스에서는 관측 도구가 선택이 아니라 전제입니다.
① 로그 — 파일이 아니라 표준 출력으로
컨테이너 앱은 로그 파일을 직접 쓰지 않습니다. 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']
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 }
③ 분산 추적 — 어느 구간이 느린가 🟡 중급
"주문 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"
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) | 여기까지 오면 관측이 문화로 자리 잡은 것입니다 |
user_id 같은 무한한 값을 라벨로 넣으면 시계열이 폭발해
Prometheus 가 OOM 으로 죽습니다. 그러면 장애가 났을 때 볼 것이 없어집니다.
규모가 커지면 관리형(Grafana Cloud · Datadog · 클라우드 네이티브 서비스)이 총비용에서 유리한 지점이 옵니다.
패키징 · GitOps — Helm · Kustomize · Argo CD
환경이 dev·stg·prod 셋이 되는 순간 YAML 복사본이 세 벌 생기고, 곧 서로 달라집니다. 템플릿으로 묶고 · 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 # 플러그인
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 |
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 }
| 개념 | 뜻 |
|---|---|
| Sync | git 상태를 클러스터에 반영. 자동/수동을 환경별로 다르게 둡니다(dev 자동 · prod 수동 승인) |
| Drift(어긋남) | 클러스터가 git 과 달라진 상태. Argo CD 화면에 OutOfSync 로 뜹니다 |
| Self-heal | 어긋나면 자동으로 git 상태로 되돌림. 급한 손수정이 지워지므로 켜기 전에 팀 합의가 필요합니다 |
| Prune | git 에서 삭제된 리소스를 클러스터에서도 삭제. 안 켜면 유령 리소스가 쌓입니다 |
| App of Apps | Application 을 관리하는 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·모니터링·정책)는 플랫폼 저장소로 또 분리
CI/CD — 커밋에서 배포까지 한 줄로
여기까지의 조각(이미지 · 태그 · 매니페스트 · GitOps)을 하나의 파이프라인으로 잇습니다. 완성되면 머지 → 5분 뒤 스테이징 반영 → 승인 → 운영 배포가 자동으로 흐릅니다.
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 때 되돌아갑니다) |
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 인가
- 에러 로그에 새로운 문구가 등장하지 않았는가
실전 문제 해결 — 증상에서 원인까지
장애가 났을 때 필요한 것은 지식이 아니라 순서입니다. 이 섹션은 그대로 따라 치면 원인이 좁혀지는 절차와 증상 사전입니다.
증상 사전 — 원인과 해결
| 증상 | 흔한 원인 | 확인·해결 |
|---|---|---|
| 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}}' — 최후 수단입니다 |
| 노드 NotReady | kubelet 다운 · 디스크 풀 · 네트워크 플러그인 문제 | 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'
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 로 안전한지 확인
장애를
사용자보다
먼저 아는 법
서비스가 죽었다는 사실을 고객센터 전화로 알게 되는 회사와, 알림이 먼저 울리고 5분 만에 원인 후보를 셋으로 줄이는 회사의 차이는 실력이 아니라 붙여 둔 체계입니다. 이 탭은 그 체계를 순서대로 세웁니다 — 메트릭 · 로그 · 트레이스 · 프로파일이 각각 무엇에 답하고 무엇에 답하지 못하는지, Prometheus 로 무엇을 재고 PromQL 로 어떻게 묻는지, 알림을 몇 개나 둬야 사람이 반응하는지, SLO 와 에러 예산으로 배포 속도를 어떻게 조절하는지까지. 이 탭을 마치면 빈 서버에 관측 스택 한 벌을 직접 올리고, 알림이 울린 뒤 무엇을 순서대로 볼지 말할 수 있게 됩니다.
모니터링과 관측 가능성 — 무엇이 다른가
둘은 같은 말이 아닙니다. 모니터링은 "미리 정해 둔 질문에 답하는 화면"이고, 관측 가능성은 "미리 생각 못 한 질문을 나중에 던질 수 있는 상태"입니다. 이 구분을 못 하면 대시보드는 60장인데 정작 장애 때 볼 화면이 없는 상황이 됩니다.
"모른다"의 네 칸 — 왜 대시보드만으로 부족한가
| 안다 | 모른다 | |
|---|---|---|
| 물어봤다 | 대시보드에 있음 = 모니터링 | 지표는 있는데 값이 이상함 |
| 안 물어봤다 | 남이 만든 패널에 우연히 보임 | 여기가 진짜 장애 = 관측 가능성의 영역 |
즉석에서 자를 수 있다는 것 — 예
# 미리 만들어 둔 패널: "전체 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 이 없었다면
# → 위 세 질문은 "코드를 고치고 배포한 뒤에야" 답할 수 있습니다
무엇부터 붙이나 — 순서가 정해져 있습니다 🟢 기초
| 순서 | 붙이는 것 | 이걸 붙이면 생기는 능력 |
|---|---|---|
| 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) |
| 그다음 | 지속 프로파일링 · eBPF | CPU·메모리를 코드 줄 단위로 (y11) |
관측 비용이라는 현실 — 공짜가 아닙니다 🟡 중급
비용이 어디서 나오나
| 항목 | 비용을 키우는 것 |
|---|---|
| 메트릭 | 활성 시계열 수(라벨 조합 수). 스크레이프 주기가 아니라 차원이 지배합니다 |
| 로그 | 바이트 수. DEBUG 를 켠 채 두면 하루에 수백 GB 가 됩니다 |
| 트레이스 | 스팬 수 × 샘플링율. 100% 저장은 거의 항상 실패합니다 |
| 쿼리 | 넓은 기간 × 높은 카디널리티. 대시보드 자동 새로고침이 저장소를 때립니다 |
| 사람 | 가장 비쌉니다 — 쓸모없는 알림에 깨어난 시간 |
먼저 줄여야 할 것 · 마지막에 줄일 것
먼저 줄입니다
· DEBUG 로그 — 운영은 INFO 이상· 헬스체크·정적 파일 액세스 로그
· 아무도 안 보는 대시보드의 자동 새로고침
· 고카디널리티 라벨(user_id · 요청 URL 원문)
· 로그 보관 기간 — 30일이면 대개 충분
여기까지로 보통 절반이 빠집니다
마지막까지 지킵니다
· 에러 로그 — 100% 남깁니다· 느린 요청·에러 요청의 트레이스
→ 테일 샘플링으로 이것만 100% (y07)
· SLI 메트릭 — 에러 예산의 근거
· 알림에 쓰이는 시계열
잘라 놓고 나중에 "그때 데이터가 없네"가 최악
ServiceMonitor 로 스크레이프 대상을 등록하는
쿠버네티스 고유의 붙이는 방법은 ☸️ 쿠버네티스 d11 에 있습니다.
이 탭은 플랫폼과 무관한 관측 그 자체(무엇을 재고, 어떻게 묻고, 언제 울리게 하는가)를 다룹니다.
기본 서버 운영 관점의 로그 로테이션·상태 페이지는 🛡️ 보안·운영 o07·o08 을 보세요.
메트릭의 기본 — 네 가지 타입과 카디널리티
메트릭은 "숫자에 이름과 꼬리표를 붙여 시간 순서로 쌓은 것"입니다. 타입을 잘못 고르면 계산이 틀리고, 꼬리표를 잘못 붙이면 저장소가 죽습니다. 이 두 가지가 메트릭의 전부라고 해도 됩니다.
타입 네 가지 — 언제 무엇을
| 타입 | 성질 | 예 |
|---|---|---|
| Counter | 증가만 합니다(재시작 시 0으로 리셋). 값 자체가 아니라 증가 속도를 봅니다 | http_requests_total |
| Gauge | 올라갔다 내려갔다 하는 현재 값 | go_goroutinesqueue_depth |
| Histogram | 값을 구간(버킷)별로 세어 둡니다. 나중에 분위수를 계산 가능 | ..._duration_seconds_bucket |
| Summary | 분위수를 클라이언트가 미리 계산해 보냅니다 | ..._duration{quantile="0.9"} |
노출되는 실제 모양 — /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).
카디널리티가 이미 터졌을 때 — 범인 찾기
# ① 시계열이 많은 메트릭 이름 순위 (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);
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 | 자원 차원의 실패 | 디스크 에러 · 커넥션 획득 타임아웃 |
| 계층 | 반드시 재는 것 (최소 세트) |
|---|---|
| 앱 / API | RED 3종 · route 별 · 인플라이트 요청 수 · 스레드/고루틴 수 · GC 정지 시간 |
| DB 클라이언트 | ★ 커넥션 풀 활성/유휴/대기 · 획득 대기 시간 · 쿼리 시간 히스토그램 · 타임아웃 수 |
| 캐시 | 히트율 · 축출(eviction) 수 · 메모리 사용량 · 연결 실패 |
| 큐 · 스트림 | 랙(밀린 건수) · 소비 속도 · 처리 실패·재시도 수 · DLQ 유입 |
| 외부 호출 | 대상별 성공률 · 지연 · 타임아웃 · 서킷브레이커 상태 (🧱 MSA 참고) |
| 호스트 | CPU(사용률+런큐) · 메모리(가용) · 디스크(사용률+I/O 대기) · 네트워크 · 파일 디스크립터 |
| 비즈니스 | ★ 분당 주문 수 · 결제 성공률 — 기술 지표가 다 정상인데 매출만 0인 장애를 잡습니다 |
<도메인>_<대상>_<단위> 에 카운터는 _total 을 붙입니다.
단위는 기본 단위로 통일합니다 — 초(seconds) · 바이트(bytes).
_ms·_mb 로 재면 대시보드마다 단위 환산 코드가 붙고,
언젠가 누군가 곱하기를 빼먹습니다. OpenTelemetry 의 시맨틱 규약도 같은 원칙입니다(y08).
Prometheus — 긁어 오는 방식의 메트릭 저장소
Prometheus 는 앱이 보내 주기를 기다리지 않습니다. 스스로 찾아가서 긁어 옵니다(pull). 이 한 가지 선택이 설정 방식·장애 감지·확장 전략을 전부 결정합니다. 2026년 기준 사실상 표준이고, 클라우드 관측 서비스들도 대부분 이 데이터 모델을 그대로 씁니다.
최소 설정 — 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_exporter | 9100 · CPU·메모리·디스크·네트워크·파일시스템. 무조건 첫 번째 |
| nginx-prometheus-exporter | 9113 · 연결 수·요청 수 (🟩 Nginx 상태 페이지 연결) |
| postgres_exporter | 9187 · 커넥션·잠금·복제 지연·느린 쿼리 수 |
| mysqld_exporter | 9104 · 스레드·InnoDB·복제 지연 |
| redis_exporter | 9121 · 히트율·축출·메모리 |
| blackbox_exporter | ★ 9115 · 바깥에서 본 HTTP·TCP·DNS·인증서 만료 |
| kafka_exporter | 9308 · 컨슈머 랙 (📨 Kafka k03 의 그 랙) |
| cadvisor | 8080 · 컨테이너별 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 의 카디널리티!)
장기 보관 · 고가용성
# 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). 운영 인건비가 서버비보다 비쌉니다 |
/metrics 를 열고, 못 고치는 것은 익스포터를 붙입니다.
비용과 안정성은 전부 활성 시계열 수에 달려 있으니
sample_limit 과 metric_relabel_configs 를 안전벨트로 걸어 두세요.PromQL — 질문을 쿼리로 바꾸는 법
PromQL 은 SQL 과 닮은 데가 거의 없습니다. "시계열 여러 개를 한꺼번에 다루는 계산기" 에 가깝습니다.
벡터 두 종류와 rate 하나만 제대로 이해하면 나머지는 조합입니다.
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:]) # ❌ 카운터 리셋 보정이 깨집니다
조인 — 서로 다른 메트릭을 엮기 🟡 중급
라벨이 같아야 계산이 됩니다
# 메모리 사용률 — 두 메트릭의 라벨 집합이 같으면 그냥 나눕니다
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
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)을 예측형으로 바꿀 수 있습니다.
sum by (라벨) (rate(카운터[5m])).
카운터는 반드시 rate 로 감싸고, rate 를 안쪽에 sum 을 바깥쪽에 둡니다.
지연은 histogram_quantile(0.95, sum by (le) (rate(..._bucket[5m]))) 로 뽑고,
결과가 비면 라벨 조인부터 의심하세요.Grafana — 대시보드는 적을수록 좋습니다
대시보드를 60장 만든 팀과 3장 만든 팀이 있으면, 장애 때 더 빨리 복구하는 쪽은 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 10 | topk() 와 함께. 정렬 기본값을 지정해 두세요 |
| Heatmap | ★ 지연 분포 — 히스토그램 버킷을 그대로 | p95 선 하나로는 안 보이는 이중 봉우리(캐시 히트/미스)가 드러납니다 |
| Logs | Loki 쿼리 결과를 같은 화면에 | 대시보드 시간 범위를 따라가므로 그래프에서 드래그 → 그 구간 로그가 됩니다 |
| State timeline | up/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}"} ← 변수로
로그 — 사람이 읽는 글에서 기계가 읽는 데이터로
로그는 셋 중 가장 오래된 신호인데, 가장 자주 잘못 쓰입니다. 문장으로 쓴 로그는 grep 밖에 못 합니다. JSON 한 줄로 바꾸는 순간 "어제 오후 3시에 결제 실패한 요청 중 응답 2초 넘은 것"을 쿼리로 물을 수 있게 됩니다.
문장 로그 → 구조화 로그
이러면 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"}
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"}';
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"
{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 }}" }
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% |
ts·level·msg·service·env·version·trace_id·duration_ms)가 최소 계약이고,
trace_id 가 로그를 추적·메트릭과 잇는 끈입니다.
Loki 는 라벨은 좁게, 검색은 본문으로. 그리고 안 남길 것을 정하는 일이 비용의 90%를 좌우합니다.분산 추적 — 요청 한 건을 끝까지 따라가기
"주문 API 가 3초 걸립니다"까지는 메트릭이 알려 줍니다. "그 3초 중 2.4초가 재고 서비스의 재고 조회 쿼리였다" 는 추적만 알려 줍니다. 서비스가 셋을 넘는 순간, 추적 없이 원인을 찾는 것은 운에 맡기는 일이 됩니다.
용어 셋 — 이게 전부입니다
| 용어 | 뜻 |
|---|---|
| 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 를 시작합니다 (= 흐름이 끊깁니다)
샘플링 — 전부 저장하면 반드시 실패합니다 🟡 중급
헤드 샘플링 — 시작할 때 결정
# 요청이 들어오는 "입구"에서 주사위를 굴려 결정합니다
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.05 # 5%
# · parentbased = 부모가 이미 결정했으면 그대로 따릅니다 ★
# → 한 요청이 서비스 10개를 지나도 "전부 저장" 또는 "전부 버림"
# → 이게 없으면 트레이스가 조각조각 납니다
# · traceid_ratio = trace_id 해시로 결정 (서비스마다 같은 답이 나옵니다)
# 장점 — 싸다. SDK 에서 바로 버리므로 네트워크·CPU 를 안 씁니다
# 단점 — ★ 정작 보고 싶은 "느린 요청·에러 요청"이 95% 확률로 버려집니다
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 }
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
| Tempo | Jaeger | |
|---|---|---|
| 저장 | 오브젝트 스토리지(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" }
service.name 규칙을 먼저 정하세요
④ DB·캐시가 계측 안 됨 → 자동 계측 라이브러리를 켜면 대부분 공짜로 붙습니다(y08).
traceparent 헤더 하나로 전파됩니다.
저장은 전부 하지 말고 에러·느린 요청 100% + 나머지 2%(테일 샘플링)로 가세요.
폭포수를 읽을 때는 자기 시간이 큰 스팬과 스팬 사이의 빈틈을 봅니다.OpenTelemetry — 계측을 벤더에서 떼어 내기
예전에는 Datadog 을 쓰면 Datadog 에이전트를, New Relic 을 쓰면 New Relic SDK 를 코드에 박아야 했습니다. 도구를 바꾸려면 전 서비스를 다시 계측했죠. OpenTelemetry 는 계측하는 방법을 표준으로 만들고, 어디로 보낼지는 설정으로 뺐습니다. 2026년 기준 사실상 모든 관측 도구가 OTLP 를 받습니다.
붙이는 세 가지 방법 — 위에서부터 시도
| 방법 | 코드 변경 | 얻는 것 |
|---|---|---|
| ① 자동 계측 에이전트·훅 | 없음 | 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단이 표준형 |
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
trace_id 만 넣어 두면 통합 효과는 똑같이 얻습니다.
프로파일은 네 번째 신호로 표준화가 진행 중입니다(y11).
memory_limiter 를 맨 앞, batch 를 맨 뒤에 둡니다.
spanmetrics 와 exemplar 를 켜면 세 신호가 클릭으로 이어집니다.알림 — 사람을 깨울 자격이 있는 것만
알림 개수와 장애 대응 능력은 반비례합니다. 하루에 50개가 오면 사람은 전부 무시하게 되고, 그 안에 진짜가 섞여 있어도 못 봅니다. 좋은 알림 체계는 "규칙을 많이 만드는 것"이 아니라 "울리지 않게 만드는 것"입니다.
증상 기반 vs 원인 기반 — 무엇을 알림으로
증상 기반 (사용자가 아픈 것)
· 5xx 비율 2% 초과가 5분 지속· p99 지연이 SLO 2배 초과
· 결제 성공률 급락
· 외부에서 본 헬스체크 실패
· 에러 예산 소진 속도 급증(y10)
→ 원인이 뭐든 사용자가 아프면 울립니다.
이런 규칙은 서비스당 3~5개면 충분합니다
원인 기반 (그 자체로는 아프지 않음)
· CPU 80% 초과 — 그래서요?· 메모리 70% — 캐시가 쓰는 겁니다
· 파드 재시작 1회 — 정상 배포입니다
· 커넥션 수 증가 — 트래픽이 는 겁니다
→ 대시보드에 두고 알림으로 만들지 마세요.
밤마다 울리고, 결국 전부 무시하게 됩니다
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 높음" }
# 밤마다 울리고, 아무도 무엇을 해야 할지 모릅니다
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 로 가는지 보여 줍니다
Watchdog 알림을 만들어
"이 알림이 안 오면 알림 체계가 죽은 것" 으로 감시하고(Dead Man's Switch),
② 외부 업타임 서비스를 관측 스택 바깥에 하나 둡니다.
온콜 운영 — 사람 쪽 설계
| 항목 | 실무 기준 |
|---|---|
| 심각도 등급 | ★ page(즉시 호출) / ticket(업무시간) / none(대시보드만). 세 단계면 충분합니다 |
| 당번 주기 | 1주 단위 · 최소 4명 이상 로테이션(2명이면 번아웃) |
| 에스컬레이션 | 1차 무응답 5분 → 2차 → 15분 → 팀장. 자동이어야 합니다 |
| 인수인계 | 주간 교대 때 지난주 페이지 목록을 함께 리뷰 |
| 보상 | 야간 대응 시 다음 날 휴식. 이게 없으면 사람이 나갑니다 |
| 알림 리뷰 | ★ 격주로 발생한 알림 전수 검토 — "조치 없이 닫힌 알림"은 규칙을 고칩니다 |
for 를 늘리거나 등급을 내립니다
③ 같은 원인이 여러 알림이면 억제 규칙으로 묶습니다
④ 그래도 남으면 지웁니다. 지운 알림 때문에 놓친 장애가 생기면 그때 다시 만들면 됩니다.
규칙을 지우는 것도 관측 개선입니다.
for 로 순간 스파이크를 걸러 내고, 런북 링크를 강제하고,
Alertmanager 의 그룹핑·억제·침묵으로 도달 수를 줄입니다.
Watchdog 알림과 외부 감시로 알림 체계 자체의 죽음을 감시하고,
격주로 안 쓰이는 알림을 지우세요.SLI · SLO · 에러 예산 — 숫자로 하는 대화
"요즘 서비스가 좀 불안한 것 같아요"는 논쟁이 되고, "이번 달 에러 예산의 82%를 이미 썼습니다"는 결정이 됩니다. SLO 는 품질을 재는 도구이자, 기능 개발과 안정화 사이의 우선순위를 감정 없이 정하는 장치입니다.
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]))
목표치 정하기 — 9의 개수가 곧 비용
| SLO | 월 허용 다운타임 | 현실 |
|---|---|---|
| 99% | 7시간 18분 | 사내 도구·배치 |
| 99.5% | 3시간 39분 | 일반 내부 서비스 |
| 99.9% | 43분 | ★ 대부분의 웹 서비스가 여기 |
| 99.95% | 21분 | 결제·인증 등 핵심 경로 |
| 99.99% | 4분 21초 | 다중 리전·자동 장애 조치 필수. 비용이 급증 |
| 99.999% | 26초 | 사람이 개입할 시간이 없습니다. 대개 과잉 |
소진 속도(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]))
[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) 액션 아이템이 끝나야 해제. 사람이 아니라 규칙이 결정합니다 |
프로파일링 — 코드 한 줄까지 내려가기
트레이스가 "이 스팬이 900ms 걸렸다"까지 알려 줬다면, 그다음 질문은 "그 900ms 동안 CPU 는 무슨 함수에 있었나" 입니다. 예전에는 이걸 재려면 문제를 재현해야 했지만, 지금은 운영 중에 계속 켜 둡니다.
지속 프로파일링 — 계속 켜 둡니다
예전 프로파일러는 느리고 무거워서 개발 환경에서만 켰습니다. 지금 쓰는 것들은 샘플링 방식이라 CPU 오버헤드가 1~2% 수준이고, 그래서 운영에서 항상 켜 둡니다. 사고가 난 어제 오후 3시의 프로파일을 지금 열어 볼 수 있다는 것이 결정적인 차이입니다.
| 도구 | 성격 |
|---|---|
| Pyroscope Grafana 계열 | ★ Grafana 에 통합. 언어별 SDK + eBPF 자동 수집. 트레이스와 연결(스팬 프로파일) |
| Parca | eBPF 기반 · 코드 수정 없이 호스트 전체를 프로파일링 |
| pprof(Go) | Go 표준. net/http/pprof 만 붙이면 끝. 수집기가 이걸 긁어 갑니다 |
| async-profiler(JVM) | JVM 의 사실상 표준. safepoint 편향이 없어 정확합니다 |
| py-spy(Python) | 돌고 있는 프로세스에 붙어서 뜹니다 — 코드 수정 없음 |
붙이기 — 언어별 한 줄
# 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 계측을 대체하지 못하고 보완합니다
무엇을 프로파일링하나 — CPU 만이 아닙니다 🟡 중급
| 종류 | 답하는 질문 | 언제 꺼내나 |
|---|---|---|
| CPU | 어느 함수가 계산을 하고 있나 | CPU 사용률이 높은데 원인을 모를 때. 가장 기본 |
| Wall clock | ★ 대기까지 포함해 시간이 어디로 갔나 | "느린데 CPU 는 놀고 있다" — 락 대기·I/O 대기가 범인일 때 |
| 할당(alloc) | 어디서 객체를 많이 만드나 | GC 가 자주 돌 때. GC 튜닝보다 할당을 줄이는 게 답인 경우가 대부분 |
| 힙(heap) | 지금 살아 있는 객체가 무엇인가 | 메모리 누수 추적 — 시간 간격 두고 두 번 떠서 차이를 봅니다(y13 사례 2) |
| 락 경합 | 어느 락에서 스레드가 기다리나 | 코어를 늘려도 처리량이 안 늘 때 |
| 블로킹 I/O | 어느 호출이 스레드를 붙잡나 | 스레드 풀 고갈 · 커넥션 부족과 함께 봅니다 |
실전 — 관측 스택 한 벌을 직접 올립니다
지금까지의 조각을 한 파일로 묶습니다. docker compose up -d 하나로
Prometheus · Grafana · Loki · Tempo · OTel Collector 가 뜨고,
앱을 붙이면 메트릭 · 로그 · 트레이스가 서로 이어진 상태가 됩니다.
개인 서버·사내 환경이면 이 구성 그대로 운영해도 됩니다.
# 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: 30s | 15s → 30s 로 바꾸면 저장량 절반. 알림 반응이 15초 늦어질 뿐입니다 |
metric_relabel_configs drop | ★ 안 보는 메트릭 제거 — Go 런타임·JVM 세부 지표만 빼도 30% 줍니다 |
| Loki 샘플링(1/20) | 성공 요청 로그만 골라 버림. 에러는 전부 남김 |
| 테일 샘플링 | 추적 저장량 1/20 인데 보고 싶은 건 다 남음(y07) |
| 보존 15d / 14d | 90일이 필요한 건 감사 로그뿐인 경우가 대부분 |
| Tempo·Loki를 S3 로 | 디스크 대비 수분의 일. 대신 조회가 조금 느려집니다 |
prometheus_tsdb_head_series(시계열 수 폭발),
otelcol_exporter_send_failed_spans(콜렉터가 못 보내는 중),
loki_ingester_streams(스트림 폭발),
컨테이너 재시작 수 — 이 넷은 대시보드에 두세요.
관측 스택이 조용히 죽으면 장애를 통째로 못 봅니다.
kube-prometheus-stack 과
loki-stack 헬름 차트로 대체할 수 있습니다 — ☸️ 쿠버네티스 d11 참고.
장애 대응 실전 — 알림이 울린 뒤 5분
여기까지 붙인 것을 실제로 쓰는 절차입니다. 장애 대응 실력은 지식이 아니라 순서를 지킬 수 있는가에서 갈립니다. 기본 서버 관점의 대응 순서는 🛡️ 보안·운영 o12 에 있고, 여기서는 관측 데이터로 원인을 좁혀 가는 절차를 다룹니다.
범위를 자르는 세 번의 쿼리 — 복붙
# ① 어느 서비스·엔드포인트인가
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))
로그에서 "새로 생긴 것" 찾기
# ① 에러 문구별 건수 — 무엇이 급증했나
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}}"
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분 내 회복 |
| 근본 | 느린 쿼리 하나가 커넥션을 오래 잡고 있었음 → 인덱스 추가로 해결 |
이 사례를 위해 미리 재고 있어야 하는 것
# 커넥션 풀 (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 쪽 문제
# ③ 획득 시간만 오름 → 풀 부족 (이번 사례)
사례 2 — 메모리가 계속 자란다 🔴 실무
진단 경로
| 단계 | 본 것 → 알아낸 것 |
|---|---|
| 증상 | 파드가 이틀에 한 번 OOM 으로 재시작. 사용자는 잠깐 502 |
| 메트릭 | process_resident_memory_bytes 가 톱니 없이 계속 우상향 → 캐시가 아니라 누수 |
| 구분 | GC 후에도 힙 사용량 하한선이 계속 올라감 → 진짜 누수(회수 안 되는 참조) |
| 상관 | 재시작 주기가 트래픽이 아니라 시간에 비례 → 요청량과 무관한 축적 |
| 프로파일 | ★ 힙 프로파일을 6시간 간격 두 번 떠서 diff → HashMap 하나가 계속 커짐 |
| 원인 | 요청마다 캐시 키를 만료 없이 넣는 코드. 키에 trace_id 가 섞여 무한 증가 |
| 완화 | 메모리 한계 상향 + 재시작 스케줄로 버티고, 다음 배포에서 TTL 캐시로 교체 |
누수 조사 명령 모음
# 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) 도입 — 외부 호출용 스레드 풀을 분리해 다른 요청을 지킴 |
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분밖에 안 걸림
## 비난하지 않기
개인의 실수가 아니라 "그 실수가 가능했던 구조"를 고칩니다.
로그는 앱이 아니라
서버가 남기고
서버가 지웁니다
앱 개발자는 log.error(...) 한 줄만 쓰면 끝이지만, 그 줄이 실제로
어디에 떨어지고 · 언제 지워지고 · 누가 볼 수 있는지는 전부 서버 쪽 설정입니다.
이 탭은 애플리케이션 코드를 한 줄도 고치지 않고 로그 운영을 세우는 법을 다룹니다 —
Nginx log_format 문법 전체 · Apache/Tomcat/Caddy 의 차이 ·
syslog 와 journald · logrotate 가 로그를 잃는 순간 · 수집기 다섯 개의 실제 차이 ·
저장소 네 가지의 비용 구조 · 요청 ID 로 서버 여러 대를 잇는 법 ·
개인정보 마스킹과 감사 로그 · 컨테이너 로그 · 그리고 장애 조사 절차까지.
메트릭과 추적은 여기서 다루지 않습니다 — 그건 🔭 관측 · 모니터링 탭의 몫이고,
이 탭은 로그 하나만 끝까지 팝니다.
로그는 네 곳에서 나옵니다 — 전체 지도부터
"로그 좀 봐 주세요"라는 말은 서버에서 네 가지 서로 다른 것을 가리킬 수 있습니다. 앱이 뱉은 줄, 웹서버가 남긴 요청 기록, OS 데몬이 남긴 기록, 커널이 남긴 기록입니다. 어디를 열어야 하는지 아는 것이 로그 운영의 절반입니다.
리눅스 서버에서 실제로 열어 보는 자리
# ② 웹서버 — 요청 기록
/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
네 원천의 성격이 완전히 다릅니다
| 원천 | 양 | 버리면 | 어디로 나가나 |
|---|---|---|---|
| 앱 | 중~많음 | 사업 사고 | stdout(권장) |
| access | 가장 많음 | 조사 불가 | 파일 · syslog |
| error | 적음 | 치명적 | 파일 · stderr |
| OS 데몬 | 적음 | 보안 사고 | journald |
| 커널 | 아주 적음 | 원인 미상 | ring buffer |
가장 먼저 할 일 — 디스크가 차서 서버가 멈추는 것을 막기 🟢 기초
실제로 가장 흔한 로그 장애
# 증상: 갑자기 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% 에서)
# 로그 알림보다 이 알림이 먼저 필요합니다
SystemMaxUse · logrotate)을 먼저 걸어 둡니다 —
로그 때문에 나는 장애의 대부분은 내용이 아니라 용량입니다.Nginx log_format — 기본값은 반드시 바꿔야 합니다
Nginx 가 기본으로 쓰는 combined 포맷에는
이 요청이 몇 초 걸렸는지가 없습니다. 어느 백엔드가 처리했는지도 없습니다.
장애 조사에서 막히는 이유의 절반이 여기서 옵니다 — 먼저 이 한 줄을 고칩니다.
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_iso8601 | 2026-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_status | HIT · MISS · BYPASS · EXPIRED · STALE |
$request_id | 요청마다 생기는 16바이트 난수(32자 16진, 1.11.0+) |
$request_length | 요청 줄+헤더+본문 바이트 |
$bytes_sent | 헤더까지 포함한 총 전송 바이트 |
$connection / $connection_requests | 연결 번호 / 그 연결에서 처리한 요청 수 |
$ssl_protocol / $ssl_cipher | TLS 버전·암호군 (TLS 1.3 전환 확인용) |
$http_x_forwarded_for | 신뢰할 수 없음 — realip 모듈과 함께 |
$host / $server_name | 요청한 호스트 / 매칭된 서버 블록 |
$pipe | p = 파이프라인 요청 |
map 으로 계산한 변수도
log_format 에 그대로 넣을 수 있어서,
"사업 구분"이나 "요청 유형"처럼 로그에만 필요한 필드를 붙일 수 있습니다.
시간 변수 네 개 — 차이를 보면 원인이 갈립니다 🟡 중급
주의할 함정 세 가지
# ① 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 를 줄이되 마지막 몇 분을 잃지 않게 합니다.JSON 로그 · error_log · 그리고 소음 지우기
로그를 기계가 읽게 만들려면 JSON 한 줄이 정답이고,
비용을 줄이려면 헬스체크와 정적 파일을 아예 안 남기는 것이 첫 수입니다.
Nginx 는 두 가지를 escape=json 과 map + if= 로 해결합니다.
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 body | client_max_body_size 초과 → 413 |
a client request body is buffered to a temporary file | 디스크로 새는 중 — 버퍼 크기 조정 |
SSL_do_handshake() failed | TLS 협상 실패(구형 클라이언트·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 이하로 떨어집니다
· 규정 대응용 원본은 따로 압축 보관합니다
"sample_rate":20 같은 필드를 함께 넣어 두고
집계할 때 곱해 주는 것이 정석입니다. 애초에 에러율 같은 비율은
메트릭(Prometheus)으로 재는 것이 맞습니다 — 로그는 "그 한 건"을 찾는 도구입니다(🔭 관측 탭).
escape=json 으로 JSON 한 줄,
숫자 필드에는 따옴표를 빼고 upstream_* 는 반드시 문자열로 둡니다.
소음은 map + if= 로 지우고, 성공 요청은 split_clients 로 샘플링합니다.
절대 통째로 끄지는 마세요 — 끄는 대신 줄이는 수단이 이렇게 많습니다.Apache · Tomcat · Caddy — 같은 일을 다른 문법으로
네 서버가 하는 일은 같습니다 — "요청 한 건에 한 줄". 그런데 Caddy 는 처음부터 JSON이고, Tomcat 은 자기가 로그를 회전시키고, Apache 는 파이프로 외부 프로그램에 넘깁니다. 이 차이가 운영 방식을 바꿉니다.
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
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)가 담당
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 의 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).
% 기호와 env=,
Tomcat 은 AccessLogValve 와 흩어진 네 개의 로그 파일,
Caddy 는 손대지 않아도 JSON + 자체 회전입니다.
Nginx 만 회전 기능이 없어서 다음 두 섹션(syslog · logrotate)이 특히 중요해집니다.syslog · journald — OS 가 관리하는 로그
웹서버 로그는 우리가 만든 것이지만, sshd · cron · sudo · systemd · 커널이 남기는 로그는 OS 가 관리합니다. 여기에는 40년 된 규칙(시설 · 심각도)과 요즘 리눅스의 journald가 겹쳐 있습니다. 둘 다 알아야 합니다.
시설(facility) — 40년째 그대로인 24칸
| 번호 | 이름 | 누가 씁니까 |
|---|---|---|
| 0 | kern | 커널 (사용자 프로세스는 못 씀) |
| 1 | user | 일반 프로그램 기본값 |
| 2 | mail | 메일 시스템 |
| 3 | daemon | 백그라운드 데몬 |
| 4 | auth | 인증 (로그인·su) |
| 5 | syslog | syslog 자기 자신 |
| 9 | cron | cron · at |
| 10 | authpriv | 민감한 인증 — 파일 권한이 다릅니다 |
| 16~23 | local0~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")
/var/log/*.log 를 꼭 유지해야 하는 오래된 운영 절차 셋입니다.
StandardOutput=journal(기본) · append:/var/log/app.log ·
null 중에서 고를 수 있고, SyslogIdentifier= 로 이름을 붙이면
journalctl -t 이름 으로 바로 뽑힙니다.
StandardOutput=append: 를 쓰면 systemd 가 파일을 열어 주므로
앱이 파일 경로를 몰라도 되고, logrotate 후 재오픈은
systemctl restart 또는 copytruncate 가 필요해집니다(다음 섹션).
journalctl -u ... --since ... -p err 세 옵션이면 조사의 90%가 되고,
SystemMaxUse 와 RateLimitBurst 두 개는 반드시 손대야 하는 기본값입니다.logrotate — 로그를 잃는 가장 흔한 경로
로그 파일은 이름을 바꿔도 프로세스는 옛 파일에 계속 씁니다. 유닉스에서 파일의 정체는 이름이 아니라 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
}
서버별 "재오픈" 방법
| 대상 | 회전 뒤에 할 일 |
|---|---|
| Nginx | kill -USR1 $(cat /run/nginx.pid) |
| Apache | apachectl graceful (또는 파이프 rotatelogs 로 아예 회피) |
| HAProxy | 보통 syslog 로 보내므로 해당 없음 |
| rsyslog | systemctl 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 가 압축을 한 세대 미뤄 재오픈 전에 건드리지 않게 합니다· 디스크 여유가 없어도 안전합니다
copytruncate 를 쓰면
같은 inode 의 크기가 갑자기 줄어든 것으로 보여 처음부터 다시 읽거나
(중복!) 그 지점부터 건너뜁니다(유실!). 수집기를 쓴다면
copytruncate 를 피하는 이유가 하나 더 생기는 셈입니다.
회전 주기를 수집기의 Refresh_Interval 보다 충분히 길게 잡는 것도 중요합니다 —
회전본이 압축되어 사라지기 전에 다 읽어야 하니까요.
delaycompress · sharedscripts · postrotate 세 줄이 반드시 들어갑니다.
copytruncate 는 신호를 줄 방법이 없을 때만(catalina.out) 쓰는 최후 수단입니다.수집기 다섯 개 — 무엇을 언제 고르나
로그 파일을 읽어 파싱하고 라벨을 붙여 저장소로 보내는 프로그램을 수집기라고 합니다. 다섯 개가 사실상의 후보이고, 고르는 기준은 성능이 아니라 "얼마나 변환할 것인가"와 "이미 무엇을 쓰고 있는가" 입니다.
다섯 개의 성격 한 줄씩
| 이름 | 언어 | 성격 |
|---|---|---|
| Fluent Bit | C | 가장 가벼움. 노드마다 하나 띄우는 에이전트의 사실상 표준. 파서·필터가 내장이라 설정만으로 대부분 해결 |
| Vector | Rust | 변환이 압도적. VRL 이라는 전용 언어로 파싱·마스킹·라우팅을 다 씁니다. 종단 확인(ack)과 디스크 버퍼가 튼튼 |
| Filebeat | Go | Elastic 스택 전용에 가깝습니다. 변환은 ES 의 ingest pipeline 에 맡기는 설계. 이미 ES 를 쓰면 가장 편함 |
| Fluentd | Ruby+C | 플러그인이 압도적으로 많습니다. 무겁고 느려서 요즘은 에지가 아니라 중앙 취합기 자리에 남습니다 |
| Promtail | Go | Loki 전용 에이전트. Grafana Alloy 로 대체되어 신규 채택은 드묾. 기존 설치는 Alloy 마이그레이션 대상 |
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
저장소 네 가지 — 색인 방식이 요금표를 정합니다
Loki · Elasticsearch · ClickHouse · 클라우드 서비스는 "무엇을 미리 색인해 두는가"가 서로 다릅니다. 그 한 가지 선택이 저장 비용 · 조회 속도 · 할 수 있는 질문을 통째로 결정합니다.
고르는 기준 — 질문의 모양으로 정합니다
| 주로 하는 질문 | 맞는 저장소 |
|---|---|
| "이 서비스의 최근 1시간 에러 줄을 보고 싶다" | Loki — 라벨로 좁히면 아주 쌉니다 |
| "이 요청 ID 가 들어간 줄 전부" | Loki(시간 범위를 좁히면) · ES |
| "지난 30일 중 이 문구가 나온 모든 줄" | ES/OpenSearch — 전문 검색이 필요 |
| "경로별 p95 를 지난 90일 추이로" | ClickHouse — 대량 집계 |
| "임의의 필드 조합으로 자유롭게 탐색" | ES/OpenSearch · ClickHouse |
| "규정상 3년 보관, 조회는 가끔" | 오브젝트 스토리지 + 필요 시 복원 |
| "팀에 운영할 사람이 없다" | 클라우드 관리형 |
각각을 얹을 때 실제로 손대는 설정
# 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": {} } }
} }
}
trace_id·user_id 를 라벨로 만들지 마세요.
라벨 조합 하나가 곧 스트림 하나이고, 스트림 수가 폭발하면
Loki 가 Elasticsearch 보다 비싸집니다.
그런 값은 본문에 두고 | json 으로 검색하거나,
Loki 3.x 의 structured metadata 에 담습니다.
클라우드 로그 서비스 — 요금이 세 군데에서 붙습니다 🔴 실무
과금 구조를 먼저 이해합니다
| 붙는 자리 | 성격 | 대응 |
|---|---|---|
| 수집(ingest) | 보낸 GB 당 — 보통 여기가 제일 큼 | 안 보내는 게 유일한 답 |
| 저장 | GB·월 — 오브젝트 스토리지보다 비쌈 | 보존 기간 · 등급 강등 |
| 조회 | 스캔한 GB 당 (또는 쿼리 시간) | 시간 범위를 좁혀 조회 |
| 내보내기 | 다른 저장소로 빼낼 때 별도 | 애초에 이중 저장 설계 |
현실적인 하이브리드 구성
# 흔히 쓰는 두 갈래 저장 (수집기에서 라우팅)
┌─→ 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"
요청 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)'
실무에서 진짜 효과가 큰 두 가지
<!--# echo var="request_id" --> 같은 SSI 한 줄이면 됩니다.
always).
프런트엔드가 API 오류를 잡을 때 응답 헤더의 ID 를 함께 리포팅하면,
브라우저에서 난 오류 하나가 서버 로그 한 줄과 정확히 짝지어집니다.
add_header 는 always 를 안 붙이면
2xx·3xx 에만 붙습니다 — 정작 필요한 5xx 에 안 붙는 실수가 흔합니다.
keyword 필드로 두면 색인이 폭발합니다.
본문 안의 값으로 두고 문자열 검색하는 것이 맞습니다 —
시간 범위만 좁히면 충분히 빠릅니다.
proxy_set_header 로 안쪽에 넘기고, add_header ... always 로 밖에도 돌려줍니다.
세 서버가 같은 값을 로그에 적으면 검색 한 번으로 전 구간이 모입니다.
인터넷에서 온 ID 는 믿지 말고 새로 발급하세요.비용 · 샘플링 · 보존 — 안 남길 것을 정하는 일
로그 비용은 산수로 미리 알 수 있습니다. 초당 요청 수 × 한 줄 크기 × 86,400. 이 계산을 한 번만 해 보면 "일단 다 남기자"가 왜 안 되는지가 바로 보입니다.
내 서버는 얼마나 쌓이나 — 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%
트레이드오프를 눈으로 보기 — 무엇을 잃고 무엇을 얻나 🔴 실무
5xx / 전체 를 세면 에러율이 20배로 부풀어 보입니다.
① 로그에 sample_rate 필드를 함께 남겨 집계 때 가중치를 되돌리거나,
② 비율은 아예 메트릭으로 재는 두 가지 중 하나를 반드시 정하세요.
실무에서는 ②가 정석입니다 — 로그는 "그 한 건"을 찾는 도구이고,
"몇 퍼센트냐"는 메트릭의 질문입니다(🔭 관측 탭).
마스킹 · 감사 로그 · 접근 통제 — 로그 자체가 사고가 되지 않게
로그는 파일 · 회전본 · 수집기 버퍼 · 저장소 · 백업으로 자동 복제됩니다. 개인정보가 한 번 적히면 다섯 곳에서 지워야 하고, 감사 로그는 반대로 누구도 지우지 못하게 만들어야 합니다. 둘 다 앱이 아니라 서버 쪽 설계입니다.
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(이메일이 섞여 옵니다)
client_body_in_file_only on 을 켜 두면 요청 본문이
client_body_temp_path 에 평문 파일로 남고,
error_log debug 는 헤더 전체를 적습니다.
운영에서는 둘 다 끄고, 디버깅으로 켰다면 끈 뒤 그 디렉터리를 비우는 것까지가 작업입니다.
로그로 새는 대표 경로 — 점검표
| 어디서 | 무엇이 새나 | 막는 곳 |
|---|---|---|
쿼리스트링 ?token= | 세션·재설정 토큰 | 웹서버 map |
Authorization·Cookie 헤더 | 자격 증명 통째 | log_format 에서 제외 |
Referer | 이전 페이지 URL 의 이메일·ID | map 으로 경로만 |
$remote_addr | IP 는 개인정보로 봅니다 | 옥텟 마스킹 |
| 앱 스택트레이스 | SQL 파라미터 · 요청 객체 dump | 수집기 VRL |
리다이렉트 Location | OAuth code · 초대 링크 | 응답 헤더 안 적기 |
| 업로드 파일명 | 이름 · 생년월일이 든 파일명 | 해시로 치환 |
감사 로그 — 지울 수 없게 만드는 다섯 단계 🔴 실무
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 요약을 티켓으로 남김 — 점검 기록도 감사 로그
컨테이너 로그와 조사 절차 — 시간 → 상태 → 경로 → 업스트림 → ID
쿠버네티스에서는 logrotate 도 파일 경로도 여러분 것이 아닙니다 — kubelet 이 회전하고 런타임이 파일을 씁니다. 그 위에서 장애가 나면 다섯 번 좁히는 절차 하나로 300만 줄에서 한 줄까지 내려갑니다. 앞의 열한 섹션이 전부 이 절차를 위해 준비한 것입니다.
노드 쪽 — 회전과 파일 형식
# 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" } }
// ★ 이미 떠 있는 컨테이너에는 적용되지 않습니다 — 다시 만들어야 합니다
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" # 이 파드는 아예 수집 안 함(부하 테스트 등)
log-format-upstream 으로 액세스 로그 포맷을 바꾸고
log-format-escape-json: "true" 로 JSON 을 만듭니다. 기본 포맷에 이미
$req_id · $upstream_addr · $upstream_response_time 이 들어 있어서
lg09 의 요청 ID 전파가 설정 없이 시작됩니다 — 앱이 X-Request-ID 를 이어받기만 하면 됩니다.
사이드카 방식(파드마다 수집기)은 앱이 stdout 으로 못 내보내고 파일로만 쓸 때의 예외 수단이지 기본이 아닙니다.
장애 조사 절차 — 다섯 번 좁히기 🔴 실무
원본 파일밖에 없을 때 — 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=502 | Nginx 가 바꿈 — 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 에 있습니다 — 그쪽은 "무엇이 아픈가", 이쪽은 "어느 한 건인가"입니다.
kubectl logs 는 노드에 남은 만큼만 보여 주므로 DaemonSet 수집기가 없으면 로그가 없는 것입니다.
장애 조사는 시간 → 상태코드 → 경로 → 업스트림 → 요청 ID 다섯 번 좁히기이고,
이 순서는 jq 로도 LogQL 로도 같습니다. ③④ 에서 한곳에 몰렸는지가 "그 서버"와 "공통 의존"을 가르는 갈림길입니다.인프라 저장소도
규모를 따라
한 칸씩 자란다
서버 한 대일 때는 /etc/nginx 를 직접 고쳐도 됩니다. 그런데 서버가 셋, 환경이 셋, 서비스가 열을 넘으면
"운영 서버 설정은 누가 언제 바꿨지?"에 아무도 답하지 못합니다. 이 탭은 서버 설정 · Compose · 쿠버네티스 매니페스트 · Terraform · GitOps · CI 파일을
저장소 안에 어떤 디렉터리로 나눠 두는지, 그리고 언제 다음 구조로 넘어가는지를 한 장씩 봅니다.
각 도구의 문법과 명령은 해당 탭(☸️ 도커 · 쿠버네티스, 🟩 Nginx 등)으로 링크하고, 여기서는 배치와 경계만 다룹니다.
인프라도 코드다 — 저장소에 둘 것 · 두지 않을 것 · 규모별 진화
인프라를 저장소에 두는 이유는 하나입니다. 서버가 지금 왜 이렇게 생겼는지를 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 # 비밀 · 상태 파일 차단 — 첫 커밋 전에
넣는 것 · 빼는 것
| 종류 | 저장소 | 이유 · 대신 어디에 |
|---|---|---|
| 서버 설정 · compose · 매니페스트 | ✅ 넣음 | 변경 이력과 리뷰가 곧 운영 기록 |
Terraform 코드 · .terraform.lock.hcl | ✅ 넣음 | 잠금 파일은 프로바이더 버전 고정용 — 커밋합니다 |
| 런북 · ADR · 온콜 문서 | ✅ 넣음 | 코드와 같이 리뷰해야 낡지 않습니다 |
.env · 키 · kubeconfig | ❌ 뺌 | 비밀 저장소 · 암호화 파일 (STR 08) |
*.tfstate · .terraform/ | ❌ 뺌 | 원격 백엔드(S3 등) — 상태에는 비밀이 평문으로 들어 있습니다 |
| 렌더 결과 · DB 덤프 · 로그 | ❌ 뺌 | 다시 만들 수 있는 것은 넣지 않습니다 |
첫 커밋 전에 — .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
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/*.conf | nginx.org 패키지 · 공식 Docker 이미지 | 권장 — 파일이 있으면 켜짐, 지우면 꺼짐 |
nginx.conf 는 둘 다 include 합니다. 한 서버에서 두 방식을 섞으면 같은 server_name 이 두 번 잡혀
conflicting server name 경고가 나옵니다 — 하나로 통일하세요.
사이트 파일 — 다른 점만 남긴다
# 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 가 하나라도 있으면
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
nginx -t.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.yaml | restart · 자원 한도 · 로그 회전 | -f 로 지정할 때 |
.env | compose 파일 안 ${변수} 값 | 항상 (셸 변수가 이김) |
.env 는 compose 파일을 치환하는 용도이고 컨테이너 안으로 자동으로 들어가지 않습니다.
컨테이너에 넣으려면 environment: 에 적거나 env_file: 로 지정합니다.
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줄 — 운영 차이
-f. Dockerfile 은 서비스 폴더 안에, 가끔 쓰는 도구는 profiles, 커지면 include — 그다음은 쿠버네티스입니다.쿠버네티스 매니페스트 — 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 # 렌더만
type: library)로 공통 템플릿을 묶습니다. 라이브러리 차트는 단독 설치가 안 되고 다른 차트가 가져다 씁니다.
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 가 바로 오류를 냅니다.
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 문서에서 퍼진 이름이고, 어느 쪽이든 팀 안에서 하나로 통일하면 됩니다.
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개 · 매주
# 폴더 = 상태 = 권한 단위 — 경로만 봐도 무엇을 건드리는지 앎
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/ 폴더로 시작해도 됩니다. 두 번째 서비스나 두 번째 팀이 생기면 나눌 신호입니다.
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 생성기) |
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
서비스 템플릿 — 새 서비스가 첫날부터 같은 모양
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 |
| 무엇을 넣나 | 로그 · 트레이스 · 인증 검증처럼 모두가 똑같아야 하는 것만 |
| 넣지 말 것 | 도메인 모델 — 공유하면 서비스가 다시 한 덩어리로 묶입니다 |
환경 · 비밀 분리 — 디렉터리 vs 브랜치 · 시크릿 도구의 자리 · 값 우선순위
Sealed Secrets · SOPS · External Secrets 의 동작과 비교표는 ☸️ 도커 · 쿠버네티스 탭 d08 에 있습니다. 여기서는 저장소 안 어디에 무엇이 놓이는지와, 환경을 브랜치가 아니라 디렉터리로 나눠야 하는 이유를 봅니다.
비밀이 놓이는 자리 — 도구별
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)
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 | 실행 시 인자 · 긴급 플래그 | 없음 — 기록을 남기고 곧 저장소로 | 장애 중 임시 조치 |
kubectl set env 로 바꾼 값은 Argo CD selfHeal 이 켜져 있으면 곧 되돌려지고,
꺼져 있으면 저장소와 클러스터가 영영 달라집니다. 어느 쪽이든 같은 날 저장소에 반영하는 것이 규칙입니다.
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/ 는 플랫폼 팀 리뷰
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
재사용 워크플로 — 승인 게이트는 여기에
# .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 에 겁니다.한 장 요약 — 유형 · 규모별 권장 구조 · 운영 문서 · 규칙을 도구로 강제하기
구조는 문서로 정하면 석 달 안에 무너집니다. 마지막 섹션은 앞의 아홉 섹션을 표 한 장으로 줄이고, 운영 문서를 어디에 두는지, 그리고 정한 규칙을 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) | 팀 · 배포 주기가 갈라짐 |
운영 문서는 코드 옆에
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 이 옛 것을 "대체됨"으로 |
| 온콜 | 사람 이름이 아니라 역할과 연락 경로 — 퇴사자가 남지 않게 |
규칙을 도구로 강제하기 🟡 중급
① 커밋 전 — 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 | 커밋에 섞인 토큰 · 키 |
contains · if)이라
예전 conftest 에서는 맨 위에 import rego.v1 이 필요할 수 있습니다. 위반이 이미 많은 저장소는 새 위반만 막는 것부터 시작하세요.