이전 에피소드에서 Caddy와 3번 싸웠습니다. 그건 프록시 레이어의 이야기였고, 이번에는 그 뒤에 있는 애플리케이션 인프라 — Docker, PHP, Redis, 보안 설정을 다룹니다.
AI 아키텍처 에이전트가 설계한 docker-compose.yml 재설계안을 실제로 적용하면서 배운 것들입니다.
Before: 원래 docker-compose.yml
프로젝트를 시작할 때의 docker-compose.yml은 이랬습니다.
services:
wordpress:
image: wordpress:latest
ports:
- "80:80"
volumes:
- ./wp_data:/var/www/html
environment:
WORDPRESS_DB_HOST: mariadb
WORDPRESS_DB_PASSWORD: d&a9X5McNt7s2PX^@$M^
mariadb:
image: mariadb
volumes:
- ./db_data:/var/lib/mysql
environment:
MARIADB_ROOT_PASSWORD: secure_root_password_here
MARIADB_PASSWORD: d&a9X5McNt7s2PX^@$M!
postgres:
image: postgres:alpine
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: postgres_password_example
redis:
image: redis:alpine
문제점을 나열하면:
wordpress:latest— 어떤 버전이 설치될지 모름- Health check 없음 — 서비스가 죽어도 모름
- Resource limit 없음 — 하나가 메모리 폭주하면 전체 서버 다운
- PostgreSQL이 포트 노출된 채 존재 — 사용도 안 하면서 인터넷에 열림
- Redis 설정 없음 — 비밀번호 없이 기본값으로 실행
- 비밀번호에
$포함 — Docker가 환경변수로 해석해서 깨짐 - MariaDB와 WordPress 비밀번호 불일치 — 끝자리가
^와!로 다름 - 로깅 설정 없음 — 로그가 무한히 커질 수 있음
AI 코드 리뷰 에이전트가 이 중 7개를 Critical 또는 Warning으로 분류했습니다. 맞는 판단이었습니다.
After: 재설계된 인프라
전체 구조
[Cloudflare] → [Caddy] → [WordPress + PHP 8.3]
↓ ↓
[Redis 7] [MariaDB 10.11]
(Object Cache) (Database)
네트워크는 두 개:
- proxy_net (외부) — Caddy ↔ WordPress 통신
- internal (내부,
internal: true) — WordPress ↔ MariaDB ↔ Redis
MariaDB와 Redis는 internal 네트워크에만 연결됩니다. 외부에서 절대 접근할 수 없습니다.
변경 1: 이미지 버전 고정
# Before
image: wordpress:latest
image: mariadb
image: redis:alpine
# After
image: wordpress:6.9-php8.3-apache
image: mariadb:10.11
image: redis:7-alpine
latest는 편리하지만 위험합니다. 어느 날 docker compose pull을 하면 메이저 버전이 올라가 있을 수 있습니다. WordPress 6.x → 7.x, MariaDB 10.x → 11.x. 그리고 플러그인 호환성이 깨집니다.
버전을 고정하면 언제 어디서 빌드해도 같은 결과가 나옵니다. 업그레이드는 의도적으로, 테스트 후에 합니다.
WordPress 이미지에서 php8.3을 명시한 것도 중요합니다. PHP 8.3의 JIT 컴파일러를 활용하기 위해서입니다.
변경 2: Health Check — “살아있나요?”
wordpress:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
mariadb:
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
redis:
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 3
Health check가 없으면 docker compose up이 끝나는 순간 “서비스가 올라갔다”고 간주합니다. 실제로는 MariaDB가 아직 초기화 중일 수 있습니다. WordPress가 DB에 접속하려다 실패하고, 흰 화면이 뜹니다.
Health check의 진짜 가치는 depends_on + condition과의 조합입니다.
wordpress:
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
이 설정은 “MariaDB와 Redis가 healthy 상태가 될 때까지 WordPress를 시작하지 마”라는 의미입니다. 서비스 시작 순서가 보장됩니다.
MariaDB의 특별한 Health Check
MariaDB의 healthcheck.sh --connect --innodb_initialized는 단순히 포트가 열렸는지가 아니라 InnoDB 엔진이 완전히 초기화되었는지까지 확인합니다. 포트는 열렸지만 InnoDB가 아직 복구 중인 상태에서 쿼리가 들어오면 에러가 발생합니다.
이 스크립트는 MariaDB 10.11 공식 이미지에 포함되어 있습니다. 직접 만들 필요 없이 healthcheck.sh를 호출하면 됩니다.
변경 3: Resource Limits — 폭주 방지
wordpress:
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
mariadb:
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
redis:
deploy:
resources:
limits:
memory: 192M
reservations:
memory: 64M
합계: 최소 576M, 최대 1,216M. 1GB 메모리 VPS에서도 동작할 수 있는 사이즈입니다.
limits는 하드 리밋입니다. 이 이상 사용하면 Docker가 OOM Kill합니다. reservations는 소프트 리밋입니다. Docker가 “최소한 이 정도는 보장해줄게”라는 약속입니다.
Resource limit이 없으면 하나의 서비스가 서버 전체 메모리를 먹을 수 있습니다. Redis가 캐시를 무한히 쌓거나, PHP가 무거운 플러그인을 로드할 때 발생합니다. 서버가 OOM으로 죽으면 SSH도 못 붙습니다.
변경 4: Redis — 캐시 서버답게
Before
redis:
image: redis:alpine
# 그게 전부
After
redis:
image: redis:7-alpine
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--maxmemory 128mb
--maxmemory-policy allkeys-lru
--appendonly yes
--appendfsync everysec
각 설정의 의미:
| 설정 | 값 | 역할 |
|---|---|---|
--requirepass | 환경변수 | 인증 필수 (비밀번호 없으면 누구나 접속) |
--maxmemory | 128mb | 메모리 제한 (무한 증가 방지) |
--maxmemory-policy | allkeys-lru | 메모리 가득 차면 가장 오래된 키 삭제 |
--appendonly | yes | AOF 영속성 활성화 (데이터 유실 방지) |
--appendfsync | everysec | 1초마다 디스크 동기화 |
allkeys-lru가 핵심입니다. “Least Recently Used” 정책으로, 메모리가 128MB에 도달하면 가장 오래 사용하지 않은 캐시부터 삭제합니다. 캐시 서버에 가장 적합한 정책입니다.
WordPress ↔ Redis 연결
/* wp-config.php에 자동 추가되는 설정 */
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_PASSWORD', '${REDIS_PASSWORD}');
define('WP_REDIS_TIMEOUT', 1);
define('WP_REDIS_READ_TIMEOUT', 1);
define('WP_REDIS_MAXTTL', 86400);
define('WP_REDIS_GRACEFUL', true);
WP_REDIS_GRACEFUL이 중요합니다. Redis가 다운되었을 때 WordPress가 에러를 뿜는 대신 조용히 DB로 폴백합니다. 캐시 서버가 죽어도 사이트는 살아있어야 합니다.
WP_REDIS_TIMEOUT과 WP_REDIS_READ_TIMEOUT을 1초로 설정한 것도 같은 이유입니다. Redis 응답이 1초 이상 걸리면 캐시 히트의 의미가 없으므로, 타임아웃 후 DB로 갑니다.
변경 5: OPcache + JIT — PHP를 빠르게
AI 아키텍처 에이전트가 제안한 PHP 설정 중 가장 인상적이었던 부분입니다.
OPcache: 바이트코드 캐시
; config/php/opcache.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.revalidate_freq=60
opcache.save_comments=1
PHP는 매 요청마다 .php 파일을 읽어서 컴파일합니다. OPcache는 컴파일 결과를 메모리에 저장해서 다음 요청부터 컴파일 과정을 건너뜁니다.
revalidate_freq=60은 “60초마다 파일이 변경되었는지 확인”합니다. 프로덕션에서는 파일이 자주 바뀌지 않으므로 확인 주기를 길게 잡아 성능을 높입니다.
save_comments=1은 주석을 보존합니다. 일부 플러그인(Rank Math 등)이 PHP DocBlock 주석을 런타임에 읽기 때문에 꺼버리면 플러그인이 깨집니다.
JIT: Just-In-Time 컴파일
; JIT (PHP 8.x)
opcache.jit=1255
opcache.jit_buffer_size=64M
JIT는 OPcache 위에 얹는 레이어입니다. 바이트코드를 기계어(native code)로 한 번 더 컴파일합니다.
1255의 각 자릿수:
| 위치 | 값 | 의미 |
|---|---|---|
| 1번째 | 1 | CPU 특화 최적화 사용 |
| 2번째 | 2 | Tracing JIT (가장 강력) |
| 3번째 | 5 | 전체 스크립트에 적용 |
| 4번째 | 5 | 전체 함수에 적용 |
솔직히 개인 블로그에서 JIT의 체감 효과는 미미합니다. 하지만 opcache.ini에 두 줄 추가하는 비용 대비 CPU-bound 작업에서 30~50% 성능 향상 가능성이 있으므로, 안 할 이유가 없습니다.
Upload 설정
; config/php/uploads.ini
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
memory_limit = 256M
WordPress 기본값은 upload_max_filesize = 2M입니다. 스크린샷 하나도 2MB를 넘기 쉽습니다. 64M이면 대부분의 이미지와 플러그인 zip 파일을 커버합니다.
이 설정 파일들은 Docker 볼륨으로 마운트합니다.
volumes:
- ./config/php/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
- ./config/php/opcache.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
:ro (read-only)는 컨테이너에서 이 파일을 수정할 수 없게 합니다. 설정 파일은 호스트에서만 관리합니다.
변경 6: 환경변수 보안
비밀번호 문제
이전 .env의 비밀번호:
MARIADB_PASSWORD=d&a9X5McNt7s2PX^@$M^
WORDPRESS_DB_PASSWORD=d&a9X5McNt7s2PX^@$M!
3가지 문제:
- 두 비밀번호가 다름 — 끝자리가
^와!. MariaDB가^로 사용자를 만들고, WordPress가!로 접속하면 인증 실패 $M이 Docker 환경변수로 해석 —$M이 변수 보간되어 빈 문자열로 치환됨. 실제 비밀번호가 예상과 다름@와&도 일부 셸에서 문제 — 스크립트에서 인용 없이 사용하면 예상치 못한 동작
해결: hex-only 비밀번호
openssl rand -hex 16
# 출력: a36e81299c2218db0be92434155a2b87
hex-only 비밀번호는 0-9와 a-f만 사용합니다. 특수문자가 없으므로:
- Docker
$보간 문제 없음 - 셸 이스케이핑 문제 없음
- URL 인코딩 문제 없음
.env.example에 이 원칙을 명시했습니다.
MARIADB_PASSWORD= # 필수! WORDPRESS_DB_PASSWORD와 반드시 동일한 값
WORDPRESS_DB_PASSWORD= # 필수! MARIADB_PASSWORD와 반드시 동일한 값
“반드시 동일한 값”이라는 주석은 미래의 나를 위한 것입니다. 3개월 후에 이 파일을 다시 열면 왜 두 값이 같아야 하는지 까먹을 수 있습니다.
WordPress Salt
8개의 Salt를 환경변수로 분리했습니다. 이전에는 Docker 이미지의 기본 생성값을 그대로 사용하고 있었습니다. 기본값은 소스코드에 공개되어 있으므로, 이걸 사용하면 누구든 세션 쿠키를 위조할 수 있습니다.
변경 7: 네트워크 분리
networks:
proxy_net:
external: true
internal:
internal: true
internal: true가 핵심입니다. 이 네트워크에 연결된 컨테이너는 외부 인터넷에 접근할 수 없습니다. MariaDB와 Redis는 이 네트워크에만 연결합니다.
# WordPress: 두 네트워크 모두 연결
wordpress:
networks:
- proxy_net # Caddy와 통신
- internal # DB, Redis와 통신
# MariaDB: 내부만
mariadb:
networks:
- internal
# Redis: 내부만
redis:
networks:
- internal
이전에는 PostgreSQL이 ports: "5432:5432"로 인터넷에 노출되어 있었습니다. internal 네트워크를 사용하면 포트를 명시적으로 열지 않는 한 외부 접근이 원천 차단됩니다.
변경 8: 로깅
x-logging: &default-logging
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
YAML 앵커(&default-logging)로 한 번 정의하고 모든 서비스에서 logging: *default-logging으로 참조합니다.
max-size: 10m, max-file: 3 — 각 서비스의 로그가 최대 30MB(10MB x 3파일)로 제한됩니다. 로그 설정이 없으면 Docker 로그가 디스크를 가득 채울 수 있습니다.
변경 9: 백업 스크립트
./scripts/backup.sh [db|content|full|pre-deploy]
| 모드 | 대상 | 보존 기간 |
|---|---|---|
db | MariaDB dump (gzip) | 30일 |
content | wp-content (tar.gz) | 12주 |
full | DB + content + 오래된 백업 정리 | 위와 동일 |
pre-deploy | DB + content (하나로 묶음) | 최근 5개 |
pre-deploy 모드가 유용합니다. docker compose pull && docker compose up -d 전에 ./scripts/backup.sh pre-deploy를 실행하면, 업그레이드에 문제가 생겼을 때 이전 상태로 복원할 수 있습니다.
백업 스크립트의 핵심 원칙: set -euo pipefail로 시작합니다. 명령 하나가 실패하면 즉시 중단하고, 미정의 변수 사용 시 에러를 발생시키고, 파이프라인 중간의 실패도 감지합니다. 백업이 반쯤 성공하고 반쯤 실패하는 것보다, 깔끔하게 실패하고 알려주는 게 낫습니다.
변경 10: wp-config.php 보안 상수
/* 보안 강화 */
define('DISALLOW_FILE_EDIT', true);
define('FORCE_SSL_ADMIN', true);
define('WP_AUTO_UPDATE_CORE', 'minor');
| 상수 | 역할 |
|---|---|
DISALLOW_FILE_EDIT | 관리자 패널에서 테마/플러그인 파일 편집 차단 |
FORCE_SSL_ADMIN | 관리자 페이지 HTTPS 강제 |
WP_AUTO_UPDATE_CORE | 보안 패치만 자동 업데이트 (메이저는 수동) |
DISALLOW_FILE_EDIT는 특히 중요합니다. WordPress 관리자 패널에는 “테마 편집기”가 있는데, 여기서 PHP 파일을 직접 수정할 수 있습니다. 공격자가 관리자 계정을 탈취하면 이 편집기로 백도어를 심을 수 있습니다. 이 상수를 true로 설정하면 편집기 자체가 비활성화됩니다.
Before → After 총 대조표
| 항목 | Before | After |
|---|---|---|
| 이미지 태그 | latest | 6.9-php8.3-apache, 10.11, 7-alpine |
| Health check | 없음 | 3개 서비스 모두 |
| Resource limits | 없음 | 합계 최대 1,216M |
| 네트워크 | 기본 | proxy_net + internal(격리) |
| Redis 인증 | 없음 | 환경변수 비밀번호 |
| Redis 메모리 | 무제한 | 128MB + LRU |
| PHP 캐시 | 기본 OPcache | OPcache + JIT |
| 업로드 제한 | 2M | 64M |
| 비밀번호 | 특수문자 포함 | hex-only |
| DB 비밀번호 | 불일치 | 동일 (명시적 주석) |
| Salt | 기본값 | 환경변수 분리 |
| 로깅 | 무제한 | 서비스당 30MB |
| 보안 상수 | 없음 | DISALLOW_FILE_EDIT + FORCE_SSL_ADMIN |
| 불필요 서비스 | PostgreSQL 노출 | 삭제 |
| 백업 | 없음 | 4가지 모드 스크립트 |
AI 설계의 채택률과 판단
이 인프라 변경에서 AI 에이전트의 권장 사항 채택률은 거의 100%입니다. Episode 2에서 테마 선택이나 Caddy HTTPS처럼 “AI가 틀린” 경우와 달리, 인프라 구성은 명확한 Best Practice가 존재하기 때문입니다.
특히 코드 리뷰 에이전트가 발견한 “비밀번호 $ 보간 문제”와 아키텍처 에이전트가 설계한 “네트워크 분리”는, 직접 발견하려면 보안 사고가 나기 전까지 모를 수 있는 것들이었습니다.
다만 한 가지 기억할 것:
인프라 Best Practice는 “설정해야 할 것”은 알려주지만, “왜 이전에 안 했는지”는 설명하지 않는다.
대부분의 개인 프로젝트가 wordpress:latest에 health check 없이 시작합니다. 그 이유는 게으름이 아니라 “지금 당장 동작하는 것”이 우선이기 때문입니다. AI가 설계한 완벽한 인프라는 “나중에 정비해야 할 목록”으로서 가치가 있지, “처음부터 이렇게 해야 한다”는 족쇄가 아닙니다.
이 블로그도 처음에는 latest로 시작했고, 글을 쓸 수 있는 상태가 된 후에 인프라를 정비했습니다. 순서가 중요합니다.
다음 에피소드 예고
인프라가 안정되었으니, 이제 블로그답게 보이게 만들 차례입니다. 다음 에피소드에서는 기술블로그에 최적화된 UX를 구축한 과정을 다룹니다. 코드 블록이 읽히는 타이포그래피, 다크 모드, 그리고 한국어 + 영어 + 코드가 혼재하는 콘텐츠의 스타일링.
예고: Episode 5 — 기술블로그 UX: 코드가 읽히는 블로그
시리즈 안내: AI 팀과 함께 기술블로그 만들기
| 회차 | 제목 | 상태 |
|---|---|---|
| 1 | Agent Teams란? 3인 AI 팀 구성기 | 발행 |
| 2 | AI 3인이 내 블로그를 진단했다 | 발행 |
| 3 | Caddy와 싸운 하루: 리다이렉트 루프 3연전 | 발행 |
| 4 | Docker + WordPress + Redis: AI가 설계한 인프라 적용기 | 현재 글 |
| 5 | 기술블로그 UX: 코드가 읽히는 블로그 | 발행 |
| 6 | AI 코드 리뷰어가 찾아낸 22개 이슈 | 발행 |
| 7 | 마크다운으로 글 쓰고 자동 발행하기 | 예정 |
| 8 | AI 팀 vs 혼자 코딩: 비용 대비 효과 분석 | 예정 |
이 글은 Claude Code를 사용하여 작성되었습니다.
프로젝트: yndl.dev