# 2026년 Symfony와 Docker: 개발 환경부터 프로덕션 배포까지 > 2026년을 위한 완전한 Symfony Docker 워크플로. FrankenPHP 개발 환경, 멀티 스테이지 프로덕션 이미지, 워커 모드, 암호화된 시크릿, 이미지 최적화, 그리고 마이그레이션을 포함한 무중단 배포를 다룹니다. - Published: 2026-06-25 - Updated: 2026-07-06 - Author: SharpSkill - Tags: symfony, docker, frankenphp, php, deployment, devops - Reading time: 10 min --- 이 Symfony Docker 튜토리얼은 완전한 컨테이너 워크플로를 구축합니다. 재현 가능한 로컬 개발 환경과 견고하게 다듬은 프로덕션 이미지를 다루며, 두 가지 모두 FrankenPHP와 Symfony 7.4 LTS를 중심으로 구성됩니다. Symfony를 컨테이너에서 실행하면 노트북과 서버 사이에 발생하던 고전적인 환경 불일치가 사라지고, 매 배포가 취약한 수작업 서버 명령 시퀀스를 실행하는 대신 단일 불변 아티팩트를 배포하는 일로 바뀝니다. > **2026년 Symfony Docker 스택** > > 공식 Symfony Docker 템플릿은 이제 런타임으로 [FrankenPHP](https://frankenphp.dev/)를 제공하며, 전통적인 Nginx와 PHP-FPM 조합을 대체합니다. 단일 바이너리가 HTTP/2와 HTTP/3를 서빙하고, 요청마다 커널을 다시 만드는 대신 요청 사이에 커널을 부팅된 상태로 유지하는 영구 워커 모드로 Symfony를 실행합니다. ## Symfony를 위한 Docker Compose 개발 환경 좋은 개발 환경은 호스트 머신을 건드리지 않고도 모든 기여자에게 동일한 PHP 버전, 동일한 확장, 동일한 데이터베이스를 제공합니다. Docker Compose는 그 스택을 선언적으로 기술합니다. 아래 예제는 로컬 `Dockerfile`로 빌드한 FrankenPHP 컨테이너를 PostgreSQL 17 서비스와 짝지어 기본 Compose 네트워크로 연결합니다. ```yaml # compose.yaml services: php: build: context: . target: frankenphp_dev ports: - "80:80" - "443:443" volumes: - ./:/app # bind mount: edits on the host appear instantly - caddy_data:/data # persist TLS certificates across restarts environment: DATABASE_URL: "postgresql://app:app@database:5432/app?serverVersion=17" depends_on: - database database: image: postgres:17-alpine environment: POSTGRES_DB: app POSTGRES_USER: app POSTGRES_PASSWORD: app volumes: - db_data:/var/lib/postgresql/data volumes: caddy_data: db_data: ``` `php` 서비스의 바인드 마운트는 프로젝트 루트를 컨테이너 안으로 매핑하므로, 파일 변경이 이미지를 다시 빌드하지 않아도 반영됩니다. FrankenPHP는 첫 부팅 시 로컬 TLS 인증서를 생성하는데, 이 때문에 443 포트를 노출하고 `caddy_data`를 명명된 볼륨으로 두는 것입니다. 이렇게 하면 인증서가 `docker compose down` 이후에도 유지됩니다. DSN의 `serverVersion=17` 힌트는 Doctrine이 연결마다 버전 감지 쿼리를 실행하는 것을 생략하게 해줍니다. 스택이 실행되면 모든 Symfony 명령은 호스트가 아니라 `php` 컨테이너 안에서 실행되며, 이는 올바른 PHP 버전과 확장을 보장합니다. 데이터베이스 생성, 마이그레이션 실행, 셸 열기 모두 `docker compose exec`를 거치는 동일한 패턴을 따릅니다. > **컨테이너에서 콘솔 명령 실행하기** > > Symfony와 Composer 호출 앞에 `docker compose exec php`를 붙이면 컨테이너의 PHP 런타임을 대상으로 실행됩니다. 예를 들어 `docker compose exec php bin/console make:entity`나 `docker compose exec php composer require symfony/uid`처럼 사용합니다. 호스트에는 PHP를 설치할 필요가 전혀 없습니다. ## Symfony 프로덕션을 위한 멀티 스테이지 Dockerfile [멀티 스테이지 Dockerfile](https://docs.docker.com/build/building/multi-stage/)은 공통 베이스를 공유한 뒤 개발 타깃과 프로덕션 타깃으로 갈라집니다. 프로덕션 스테이지는 런타임 의존성만 설치하고, Composer 개발 패키지를 제거하며, 빌드 시점에 캐시를 워밍업하여 실행 중인 컨테이너가 즉시 기동되게 합니다. ```dockerfile # Dockerfile FROM dunglas/frankenphp:1-php8.4 AS base WORKDIR /app # Install the PHP extensions Symfony relies on RUN install-php-extensions \ intl \ opcache \ pdo_pgsql \ zip COPY --from=composer/composer:2-bin /composer /usr/bin/composer # --- Development target --- FROM base AS frankenphp_dev ENV APP_ENV=dev RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" # --- Production target --- FROM base AS frankenphp_prod ENV APP_ENV=prod ENV FRANKENPHP_CONFIG="worker ./public/index.php" ENV APP_RUNTIME="Runtime\FrankenPhpSymfony\Runtime" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" # Layer caching: copy manifests first, install, then copy the source COPY composer.json composer.lock symfony.lock ./ RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist COPY . . RUN composer dump-autoload --no-dev --classmap-authoritative \ && composer dump-env prod \ && bin/console cache:warmup ``` 나머지 소스보다 `composer.json`과 락 파일을 먼저 복사하는 것이 핵심 최적화입니다. Docker는 의존성 설치 레이어를 캐시하며, 매 코드 수정이 아니라 매니페스트가 바뀔 때만 이 레이어를 다시 실행합니다. `--classmap-authoritative` 플래그는 클래스맵이 완전하다고 Composer에 알려주므로, 오토로더가 런타임에 파일시스템 조회로 되돌아가는 일이 없습니다. 빌드 중에 캐시를 워밍업하면 첫 프로덕션 요청이 완전히 컴파일된 컨테이너에 도달합니다. ## FrankenPHP 워커 모드와 Symfony 런타임 전통적인 PHP-FPM은 Symfony 커널을 부팅하고, 요청 하나를 처리한 뒤, 모든 것을 폐기합니다. 워커 모드는 커널과 의존성 주입 컨테이너를 요청 사이에 살아 있게 유지하며, 여기서 대부분의 지연 시간 절감이 나옵니다. `runtime/frankenphp-symfony` 패키지는 Symfony Runtime 컴포넌트와 표준 `public/index.php` 부트스트랩을 통해 이를 동작시킵니다. ```php // public/index.php use App\Kernel; require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; return function (array $context) { return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); }; ``` Dockerfile에 설정된 `FRANKENPHP_CONFIG="worker ./public/index.php"` 라인이 이 루프를 활성화합니다. 컨테이너가 재사용되므로, 요청별 상태를 보유하는 서비스는 요청 사이에 스스로를 초기화해야 합니다. Symfony는 프레임워크 서비스를 자동으로 처리하지만, 커스텀 상태 유지 서비스는 `Symfony\Contracts\Service\ResetInterface`를 구현하고 `kernel.reset` 태그를 붙여야 누적된 상태가 다음 요청으로 새어 나가지 않습니다. 이는 장시간 실행되는 [Messenger 워커](/blog/symfony/symfony-messenger-queues-workers-async-architecture)가 요구하는 것과 동일한 규율이며, 그 보상은 동일한 하드웨어에서 PHP-FPM보다 몇 배 높은 요청 처리량입니다. 로컬 개발에서는 `--watch` 플래그로 FrankenPHP를 실행하면 파일이 변경될 때 워커가 자동으로 재시작되므로, 워커 모드가 편집-새로고침 루프를 방해하지 않습니다. ## Symfony에서 FrankenPHP와 PHP-FPM 비교 FrankenPHP와 고전적인 Nginx + PHP-FPM 스택 중 무엇을 선택하느냐는 Dockerfile과 런타임 동작을 모두 좌우합니다. 아래 표는 Symfony 프로덕션 환경에서의 실질적인 차이를 정리합니다. | 항목 | FrankenPHP 워커 모드 | Nginx + PHP-FPM | |--------|------------------------|-----------------| | 컨테이너 | 하나 | 둘 (웹 서버 + PHP) | | 커널 부팅 | 워커당 한 번 | 요청당 한 번 | | HTTP/3 | 내장 | 추가 설정 필요 | | TLS | 자동 (Caddy) | 수동 인증서 설정 | | 상태 처리 | 요청 사이에 초기화 필요 | 요청별 격리 | | 콜드 스타트 비용 | 부팅 시 한 번 지불 | 매 요청마다 지불 | 워커 모드는 값비싼 커널 및 컨테이너 컴파일 단계를 수천 건의 요청에 걸쳐 분산하기 때문에 처리량에서 우위를 점합니다. PHP-FPM은 애플리케이션이 전역 변수에 의존하거나 요청마다 새 프로세스를 전제로 하는 서드파티 라이브러리를 사용할 때 여전히 유효합니다. 그런 코드는 재사용되는 워커에서 깨지기 때문입니다. > **워커 모드의 상태 누수** > > 요청 데이터를 프라이빗 속성에 캐시하는 서비스는 초기화되지 않으면 한 요청의 데이터를 다음 요청에 그대로 제공합니다. 프로덕션에서 워커 모드로 전환하기 전에 싱글턴, 이벤트 구독자, 그리고 보안 토큰이나 현재 요청을 보유하는 모든 것을 점검하십시오. ## 프로덕션에서 환경 변수와 시크릿 관리하기 Symfony는 환경 변수에서 설정을 읽어들이며, 프로덕션에서 이를 공급하는 견실한 방법이 두 가지 있습니다. 첫 번째는 오케스트레이터나 Compose 파일을 통해 일반 변수를 주입하는 것입니다. 두 번째는 Symfony의 암호화된 시크릿 볼트로, 민감한 값을 암호문으로 저장소에 저장했다가 런타임에 단일 개인 키로 복호화합니다. ```bash # Generate the keypair; commit the public key, keep the private key out of the image php bin/console secrets:generate-keys # Encrypt a value into config/secrets/prod/ php bin/console secrets:set DATABASE_URL # In production, expose only the decryption key to the container export SYMFONY_DECRYPTION_SECRET="$(cat config/secrets/prod/prod.decrypt.private.php)" ``` Dockerfile의 `composer dump-env prod` 단계는 `.env` 파일들을 최적화된 단일 `.env.local.php`로 컴파일하여, 부팅 시 dotenv 파일을 파싱하는 런타임 비용을 제거합니다. 실제 컨테이너 환경에 설정된 변수는 여전히 컴파일된 기본값을 재정의하므로, 오케스트레이터가 제공한 시크릿이 항상 우선합니다. [Symfony 배포 가이드](https://symfony.com/doc/current/deployment.html)는 전체 우선순위 순서를 문서화하지만, 실용적인 규칙은 단순합니다. `APP_SECRET`이나 데이터베이스 자격 증명을 절대 이미지에 굽지 말고, 컨테이너 시작 시점에 주입하십시오. ## Symfony 프로덕션 이미지 최적화 두 가지 OPcache 설정이 프로덕션 성능 격차의 대부분을 차지합니다. 타임스탬프 검증 비활성화와 프리로딩 활성화입니다. 컨테이너 이미지는 불변이므로 소스가 런타임에 바뀌지 않으며, 따라서 OPcache가 편집 여부를 확인하려고 파일을 stat 할 이유가 전혀 없습니다. 프리로딩은 한 걸음 더 나아가 서버 시작 시 Symfony와 애플리케이션 클래스를 공유 메모리에 한 번 로드합니다. ```ini ; docker/php/prod.ini opcache.enable=1 opcache.preload=/app/var/cache/prod/App_KernelProdContainer.preload.php opcache.preload_user=root opcache.memory_consumption=256 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 ; immutable image: never re-check the filesystem realpath_cache_size=4096K realpath_cache_ttl=600 ``` Symfony는 위에 나열된 `preload.php` 파일을 `cache:warmup` 중에 생성하므로, 빌드 이후 이미지 안에 이 파일이 이미 존재합니다. [OPcache 프리로딩](https://www.php.net/manual/en/opcache.preloading.php)은 시작 시 이 클래스들을 링크하고, 해당 클래스가 필요한 첫 요청에서 컴파일 단계를 건너뜁니다. `validate_timestamps=0` 설정은 새 릴리스가 곧 새 이미지를 의미하는 불변 배포에서만 안전합니다. 가변 호스트에서는 오래된 코드를 제공하게 됩니다. `max_accelerated_files`를 실제 파일 수보다 크게 잡으면 프레임워크 전체가 축출 없이 캐시된 상태로 유지됩니다. ## .dockerignore로 Symfony 이미지 크기 줄이기 `.dockerignore` 파일이 없으면 빌드가 조용히 비대해집니다. 이 파일이 없으면 `COPY . .` 단계가 로컬 `vendor` 디렉터리, 개발용 `var/cache`, 노드 빌드 산출물, 그리고 `.git` 히스토리를 그대로 이미지와 빌드 컨텍스트로 실어 나릅니다. 이것들을 제외하면 이미지가 작아지고, 빌드 컨텍스트를 데몬으로 업로드하는 속도가 빨라지며, 개발 산출물이 방금 설치한 프로덕션 의존성을 덮어쓰는 일을 막아줍니다. ```gitignore # .dockerignore /.git/ /vendor/ /node_modules/ /var/ /.env.local /.env.*.local /tests/ /docker/ compose*.yaml Dockerfile ``` `/vendor/`를 무시하는 것이 가장 중요합니다. 프로덕션 스테이지는 `composer install --no-dev`를 실행하므로, 호스트의 개발용 vendor 디렉터리를 실어 나르면 그 단계가 완전히 무의미해집니다. `/var/`를 제외하면 개발 캐시와 로그가 이미지에서 빠져, 빌드 시점의 `cache:warmup`이 깨끗한 프로덕션 캐시를 생성할 수 있습니다. 멀티 스테이지 빌드와 Alpine 기반 확장을 결합하면, 군더더기 없는 Symfony 이미지는 보통 150 MB를 넉넉히 밑돕니다. ## Symfony 컨테이너 배포와 마이그레이션 실행 프로덕션 Compose 파일은 로컬에서 빌드하는 대신 레지스트리의 사전 빌드된 이미지를 참조하며, 응답하는 컨테이너로만 오케스트레이터가 트래픽을 라우팅하도록 헬스 체크를 정의합니다. 시크릿은 이미지 레이어가 아니라 환경 변수로 도착합니다. ```yaml # compose.prod.yaml services: php: image: registry.example.com/app:${TAG} environment: APP_ENV: prod APP_SECRET: ${APP_SECRET} DATABASE_URL: ${DATABASE_URL} healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost/health"] interval: 10s timeout: 3s retries: 3 restart: unless-stopped ``` 헬스 체크는 `/health` 라우트를 curl 하므로, 애플리케이션은 이 라우트를 노출해야 합니다. 데이터베이스를 건드리지 않고 200을 반환하는 최소한의 컨트롤러는 체크를 빠르게 유지하고, 일시적인 데이터베이스 장애가 일어나도 컨테이너를 unhealthy로 표시하는 것을 피하게 해줍니다. 데이터베이스 준비 상태가 중요하다면 두 번째 심층 프로브가 가벼운 쿼리를 실행할 수 있지만, 라이브니스 엔드포인트 자체는 자명하게 단순한 상태로 유지합니다. ```php // src/Controller/HealthController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; final class HealthController extends AbstractController { #[Route('/health', name: 'health', methods: ['GET'])] public function __invoke(): JsonResponse { return new JsonResponse(['status' => 'ok']); } } ``` 데이터베이스 마이그레이션은 애플리케이션 부팅 안이 아니라, 새 컨테이너가 트래픽을 받기 전에 별개의 단계로 실행되어야 합니다. 일회성 컨테이너에서 마이그레이션을 실행하면, 여러 애플리케이션 레플리카가 병렬로 시작되더라도 릴리스마다 정확히 한 번씩 스키마가 최신 상태가 됩니다. ```bash # deploy.sh set -euo pipefail docker compose -f compose.prod.yaml pull # Apply migrations once, in an isolated container docker compose -f compose.prod.yaml run --rm php \ bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration # Start replicas and wait for the health check to pass docker compose -f compose.prod.yaml up -d --wait ``` `--wait` 플래그는 모든 서비스가 healthy를 보고할 때까지 블로킹하여, 배포 스크립트에 발사 후 방치식 시작이 아니라 진짜 성공 신호를 제공합니다. 이를 Kubernetes나 Docker Swarm 같은 오케스트레이터의 롤링 업데이트와 결합하면 무중단 릴리스가 됩니다. 새 컨테이너가 헬스 체크를 통과할 때까지 기존 컨테이너가 계속 서빙합니다. 이 워크플로가 대상으로 삼는 프레임워크 버전을 더 폭넓게 보려면, [Symfony 플랫폼 개요](/technologies/symfony)와 [Symfony 8 및 PHP 8.4 기능 가이드](/blog/symfony/symfony-8-new-features-php-84-lazy-objects)가 현재 릴리스 라인에서 무엇이 바뀌었는지 다룹니다. ## 결론 - 멀티 스테이지 Dockerfile을 사용하여 개발 이미지와 프로덕션 이미지가 하나의 베이스를 공유하게 하되, 프로덕션 타깃은 Composer 개발 의존성 없이 배포하십시오 - `composer.json`과 락 파일을 소스보다 먼저 복사하여 의존성 설치 레이어를 코드 변경에 걸쳐 캐시된 상태로 유지하십시오 - FrankenPHP를 워커 모드로 실행하여 부팅된 커널을 요청 사이에 재사용하고, 상태 유지 서비스는 `ResetInterface`로 초기화하여 상태 누수를 방지하십시오 - 빌드 시점에 Symfony 캐시를 워밍업하고 `dump-env prod`로 `.env`를 컴파일하여, 실행 중인 컨테이너가 완전히 초기화된 상태로 기동되게 하십시오 - 프로덕션에서 OPcache 프리로딩을 활성화하고 `validate_timestamps=0`을 설정하십시오. 이는 이미지가 불변이기 때문에 정확히 안전합니다 - `APP_SECRET`, 데이터베이스 자격 증명, 볼트 복호화 키를 이미지 레이어 밖에 두고, 컨테이너 시작 시점에 환경 변수로 주입하십시오 - Doctrine 마이그레이션을 새 레플리카가 트래픽을 받기 전에 일회성 컨테이너에서 적용하고, `up -d --wait`로 헬스 체크에 롤아웃을 걸어두십시오 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/symfony/symfony-docker-development-production