Docker + WordPress + Redis: AI가 설계한 인프라 적용기

이전 에피소드에서 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

문제점을 나열하면:

  1. wordpress:latest — 어떤 버전이 설치될지 모름
  2. Health check 없음 — 서비스가 죽어도 모름
  3. Resource limit 없음 — 하나가 메모리 폭주하면 전체 서버 다운
  4. PostgreSQL이 포트 노출된 채 존재 — 사용도 안 하면서 인터넷에 열림
  5. Redis 설정 없음 — 비밀번호 없이 기본값으로 실행
  6. 비밀번호에 $ 포함 — Docker가 환경변수로 해석해서 깨짐
  7. MariaDB와 WordPress 비밀번호 불일치 — 끝자리가 ^와 !로 다름
  8. 로깅 설정 없음 — 로그가 무한히 커질 수 있음

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환경변수인증 필수 (비밀번호 없으면 누구나 접속)
--maxmemory128mb메모리 제한 (무한 증가 방지)
--maxmemory-policyallkeys-lru메모리 가득 차면 가장 오래된 키 삭제
--appendonlyyesAOF 영속성 활성화 (데이터 유실 방지)
--appendfsynceverysec1초마다 디스크 동기화

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번째1CPU 특화 최적화 사용
2번째2Tracing 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가지 문제:

  1. 두 비밀번호가 다름 — 끝자리가 ^와 !. MariaDB가 ^로 사용자를 만들고, WordPress가 !로 접속하면 인증 실패
  2. $M이 Docker 환경변수로 해석 — $M이 변수 보간되어 빈 문자열로 치환됨. 실제 비밀번호가 예상과 다름
  3. @와 &도 일부 셸에서 문제 — 스크립트에서 인용 없이 사용하면 예상치 못한 동작

해결: 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]
모드대상보존 기간
dbMariaDB dump (gzip)30일
contentwp-content (tar.gz)12주
fullDB + content + 오래된 백업 정리위와 동일
pre-deployDB + 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 총 대조표

항목BeforeAfter
이미지 태그latest6.9-php8.3-apache, 10.11, 7-alpine
Health check없음3개 서비스 모두
Resource limits없음합계 최대 1,216M
네트워크기본proxy_net + internal(격리)
Redis 인증없음환경변수 비밀번호
Redis 메모리무제한128MB + LRU
PHP 캐시기본 OPcacheOPcache + JIT
업로드 제한2M64M
비밀번호특수문자 포함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 팀과 함께 기술블로그 만들기

회차제목상태
1Agent Teams란? 3인 AI 팀 구성기발행
2AI 3인이 내 블로그를 진단했다발행
3Caddy와 싸운 하루: 리다이렉트 루프 3연전발행
4Docker + WordPress + Redis: AI가 설계한 인프라 적용기현재 글
5기술블로그 UX: 코드가 읽히는 블로그발행
6AI 코드 리뷰어가 찾아낸 22개 이슈발행
7마크다운으로 글 쓰고 자동 발행하기예정
8AI 팀 vs 혼자 코딩: 비용 대비 효과 분석예정

이 글은 Claude Code를 사용하여 작성되었습니다.
프로젝트: yndl.dev

댓글 남기기