# QPOST 운영 및 배포 가이드

이 문서는 현재 저장소 코드와 설정에서 확인되는 운영 요구사항의 기준 문서입니다. 운영은 cPanel 공유호스팅과 cPanel Queue Cron을 기준으로 하며, 실제 OS, PHP-FPM/Apache 구성, Git checkout 경로 및 PHP CLI 경로는 저장소에서 확정할 수 없습니다. `/PATH/TO/QPOST`와 PHP/flock 경로는 운영 서버에서 확인한 값으로 바꾸세요. 비밀값은 문서, Git, 배포 로그에 기록하지 마세요.

## Production architecture

- Laravel 12 앱이며 `composer.json`은 PHP `^8.3`을 요구합니다. 웹서버 document root는 프로젝트의 `public/`이어야 하며, 요청은 PHP-FPM 등 운영 PHP runtime으로 `public/index.php`에 전달해야 합니다. `php artisan serve`는 개발용이며 production 웹서버가 아닙니다.
- `CENTRAL_DOMAINS`에는 플랫폼 host만 지정합니다. Subdomain/custom-domain tenant 요청은 DB의 `domains` 테이블을 통해 식별되므로 reverse proxy/CDN을 쓴다면 원래 Host와 HTTPS scheme을 앱에 올바르게 전달해야 합니다. wildcard DNS와 인증서 발급은 hosting/DNS 계층 설정이며 저장소가 자동 구성하지 않습니다.
- Central 데이터는 `DB_CONNECTION`의 central DB에 있고 tenant 데이터는 tenant별 DB에 있습니다. MySQL/MariaDB tenant manager는 tenant DB 생성/삭제를 수행하므로 DB 계정 권한과 호스트 정책을 확인하세요. SQLite는 개발 설정 예시이며 실제 production DB 종류는 저장소로 확인할 수 없습니다.
- 현재 `.env.example` 기본값은 `QUEUE_CONNECTION=database`, `CACHE_STORE=database`, `SESSION_DRIVER=file`, `FILESYSTEM_DISK=local`입니다. Redis는 설정 선택지이며 기본 운영에 필수는 아닙니다. 여러 웹 인스턴스가 파일 session/cache를 공유하지 못하는 구성이라면 공용 database 또는 Redis로 바꾸어야 합니다.
- 업로드는 `storage/app/platform/` 및 `storage/app/tenants/{tenant-id}/`에, sitemap 파일은 `storage/app/sitemaps/`에 저장됩니다. 이 경로와 `storage/framework/`, `storage/logs/`, `bootstrap/cache/`는 PHP CLI 및 웹 프로세스가 필요한 범위에서 쓸 수 있어야 하며 업로드/sitemap 데이터는 배포 때 삭제하지 말고 영구 저장·백업하세요. 파일은 `/platform/files/...` 및 `/files/...` 앱 route로 제공되므로 `storage:link`는 현재 구조상 필수가 아닙니다.
- 별도 Renderer daemon/service는 없습니다. Rich-content 렌더링은 PHP `DOMDocument`로 실행합니다. 이미지 resize/encode는 GD 기능을 사용하므로 GD와 필요한 이미지 포맷(WebP 포함)을 제공하는 PHP build를 권장합니다. 네트워크 연동은 OpenAI, BFL, Tagmoa 및 코드에서 쓰는 외부 검색/환율 endpoint로 직접 요청합니다.
- `public/build/`은 Git에서 제외되어 있습니다. 따라서 `npm ci && npm run build`를 배포 서버에서 실행하거나, 동일 commit의 build artifact를 배포물에 포함해야 합니다. 저장소에는 GitHub Actions artifact/deploy workflow가 없습니다.

## Runtime prerequisites

- PHP 8.3 이상 호환 버전, Composer 2, Node/npm은 lockfile 및 Vite build와 호환되는 버전.
- Laravel package runtime 요구조건에 맞는 PDO database driver, `dom`, `fileinfo`, `openssl`, `json`, `tokenizer`, `xml/libxml` 등. MySQL/MariaDB를 쓴다면 `pdo_mysql`; 이미지 편집은 GD/WebP 지원도 확인하세요. 현재 개발 환경에서 `composer check-platform-reqs --no-dev`가 통과했지만 이는 production 서버 확정 검사가 아닙니다.
- cPanel Queue Cron을 사용할 운영 서버에서는 실제 CLI PHP 경로/version을 확인하고, worker timeout 동작에 필요한 `pcntl` 확장도 CLI에서 확인합니다. CLI와 FPM의 PHP version/extensions가 다를 수 있습니다.
- 앱 root, `vendor/`, `.env`, `storage/`, `bootstrap/cache/`가 운영 사용자 권한으로 읽히고 필요한 writable directory가 쓰기 가능해야 합니다. `.env`는 웹에서 제공되지 않게 보호하고 `APP_DEBUG=false`로 둡니다.
- `.env`의 핵심 항목: `APP_ENV=production`, `APP_DEBUG=false`, 안정적으로 보존할 `APP_KEY`, 올바른 HTTPS `APP_URL`, 플랫폼 host를 담은 `CENTRAL_DOMAINS`, central DB 접속값, `QUEUE_CONNECTION`, `CACHE_STORE`, `SESSION_DRIVER`, `FILESYSTEM_DISK`.
- Tagmoa 동기화를 쓴다면 tenant/플랫폼의 `TAGMOA_ENABLED`, `TAGMOA_PARENT_API_URL`, `TAGMOA_API_KEY`, `TAGMOA_TIMEOUT`이 실제 수신 API와 일치해야 합니다. 기본 queue connection이 `sync`이면 full resync가 HTTP 요청에서 실행되므로 production에서는 사용하지 마세요.
- AI 기능을 쓴다면 `OPENAI_API_KEY`, `PLATFORM_AI_*`, 이미지 생성에 `BFL_API_KEY`와 `PLATFORM_IMAGE_*`를 구성합니다. `.env.example`의 Tagmoa URL/key는 로컬 개발 예시일 뿐 운영값이 아닙니다. 공개 저장소나 문서에 실제 secret을 넣지 마세요.
- `MAIL_*`은 Laravel 기본 설정에 있지만 현재 tenant password reset 코드는 이메일 발송 대신 재설정 URL을 응답에 준비합니다. 따라서 mail 설정만으로 실제 메일 delivery가 생기지는 않습니다.

### 계정/seed 주의 — 운영 전 차단 항목

운영 DB에 `php artisan db:seed` 또는 `DatabaseSeeder`를 실행하지 마세요. 현재 기본 seeder는 `admin@example.com` / `password` 계정을 만들고 `TestTenantSeeder`는 `test` tenant와 해당 storage를 삭제한 뒤 다시 만듭니다. 또한 tenant provisioning은 `TenantDatabaseSeeder`를 자동 실행하며 알려진 `admin@example.com` / `staff@example.com`, 기본 비밀번호 `password`를 생성합니다. 이 계정이 production에서 로그인 가능한 상태로 노출되면 안 됩니다. 운영 전에 안전한 관리자 생성 및 tenant 초기 비밀번호 교체/강제 변경 절차를 마련하고 검증하세요. 이 보안 동작을 자동화하는 production-safe 명령은 현재 저장소에서 확인되지 않았습니다.

## First deployment

### Tagmoa canonical host

`public/.htaccess`는 `www.tagmoa.com`의 HTTP/HTTPS 요청과 `tagmoa.com`의 HTTP 요청을 `https://tagmoa.com`으로 301 전환합니다. 이 규칙은 Laravel tenant 라우팅과 trailing slash 제거보다 먼저 실행되어야 합니다. 운영 `.env`의 `APP_URL`은 `https://tagmoa.com`으로 설정하고, `CENTRAL_DOMAINS`에는 최소한 `tagmoa.com`을 포함하세요. 값을 바꾸면 config 및 route cache를 다시 생성합니다.

cPanel에서 apex/www DNS와 TLS 인증서가 유효하고 두 호스트의 요청에 해당 `.htaccess`가 적용되는지 확인하세요. www가 별도 document root를 사용한다면 cPanel Redirects 또는 그 vhost에서 경로와 쿼리를 보존하는 동일한 301을 설정해야 합니다. 배포 후 `http://tagmoa.com/`, `http://www.tagmoa.com/`, `https://www.tagmoa.com/robots.txt`가 각각 apex HTTPS의 같은 경로로 301되는지 확인하세요. `/sites/{tenant}/`는 `/sites/{tenant}`로 301되고, sitemap의 Tenant 홈 loc는 슬래시 없는 최종 URL이어야 합니다.

기존 sitemap XML은 최대 24시간 파일 캐시로 남습니다. URL 정책 변경을 배포할 때 운영 서버의 `storage/app/sitemaps/platform/`과 `storage/app/sitemaps/tenants/` 아래 생성된 sitemap XML 캐시를 비운 뒤 첫 요청에서 재생성되게 하세요. Tenant의 실제 `site_settings.indexable` 값과 게시판별 `tagmoa_collect_allowed` 값은 운영 DB에서 각각 확인해야 합니다.

1. 운영 DB, DNS/TLS, PHP runtime, web root (`public/`), process manager, 파일 영속성/백업을 준비합니다. 실제 호스트가 tenant DB를 동적으로 생성할 수 있는지도 확인합니다.
2. 저장소를 배치하고 production `.env`를 안전하게 공급합니다. 초기 설치라면 `cp .env.example .env` 후 production DB/URL/secret을 설정하고 `php artisan key:generate`를 최초 한 번만 실행합니다. 이후 배포에서 key를 재생성하지 마세요. 그다음 `composer install --no-dev --prefer-dist --optimize-autoloader`를 실행합니다.
3. `npm ci && npm run build`를 실행하거나 동등한 build artifact를 `public/build/`에 배치합니다.
4. Central schema를 `php artisan migrate --force`로 적용합니다. 이 작업은 `domains`, queue/cache 테이블 및 새 `tenant_domain_histories` migration을 포함합니다.
5. 이미 존재하는 tenant가 있다면 `php artisan tenants:migrate --force`를 실행해 `database/migrations/tenant`의 pending migration을 적용합니다. 신규 tenant는 현재 provisioning pipeline에서 DB 생성·migration·seeding을 동기식으로 수행합니다.
6. writable 경로와 파일 영속성을 확인하고 config/view cache를 준비합니다(아래 Cache 절 참조). 기본 `DatabaseSeeder`는 실행하지 마세요.
7. 초기 platform 관리자 생성은 알려진 기본 계정을 쓰는 seeder에 의존하지 말고, 사전에 승인된 보안 절차를 이용합니다. 신규 tenant의 기본 계정도 외부 공개 전에 안전하게 바꾸는 절차가 필요합니다.
8. 아래 Verification을 수행한 다음 web runtime을 시작합니다. Queue cron은 아래 절차대로 cPanel에서 별도로 등록합니다.

## Normal deployment

저장소에는 deploy script나 release-directory/symlink 배포가 없습니다. 아래는 단일 Git checkout을 쓰는 경우의 안전한 순서입니다. 기존 서비스 명령은 실제 hosting 방식에 맞게 조정하세요.

```bash
cd /path/to/qpost
php artisan down --retry=60
# cPanel에서 Queue Cron을 일시 중지하고 실행 중인 작업이 끝나도록 기다림
git pull --ff-only
composer install --no-dev --prefer-dist --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan tenants:migrate --force
php artisan config:clear
php artisan route:clear
php artisan view:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 배포 후 cPanel Queue Cron을 다시 활성화
php artisan up
```

이 순서는 애플리케이션을 maintenance mode에 두고 **새 migration 파일과 새 코드가 함께 배치된 뒤** migration을 실행하므로 Git pull 배포에서 가능한 실제 순서입니다. 새 middleware 요청을 받기 전에 central migration을 완료합니다. cPanel cron을 잠시 중지해 실행 중인 worker가 끝나도록 한 뒤 배포하고 다시 활성화하세요.

## Migration을 포함하는 배포

`2026_08_21_000000_create_tenant_domain_histories_table`은 central DB migration입니다. 새 historical-domain middleware가 `tenant_domain_histories`를 사용하므로 migration 전 새 코드가 legacy/unknown host 요청을 처리하면 테이블 조회 오류가 날 수 있습니다. 따라서:

- 단일 checkout/Git pull: maintenance mode → cPanel Queue Cron 중지 및 실행 중 job 종료 대기 → pull (migration 파일 포함) → dependencies/assets → `migrate --force` → tenant migration → cache 준비 → cron 재개 → 서비스 재개 순으로 진행합니다. **Git pull 전에 migration을 실행하라는 순서는 불가능하며 잘못된 안내입니다.**
- 별도 release-directory 배포: 새 release의 migration 파일을 포함한 상태로 DB migration을 먼저 적용할 수 있지만, 이전 코드가 새 additive history table을 무시하는지 확인하고 domain 변경 작업을 migration 완료 이후에만 허용합니다. code symlink 전환과 web reload, cPanel cron 재개는 조직의 release 절차에 따릅니다.
- migration 도중 실패하면 서비스를 maintenance 상태로 두고 `php artisan migrate:status` 및 DB backup을 확인한 뒤 원인을 해결해 다시 적용합니다. history table을 임의로 drop하거나 migration rollback하지 마세요. 기록된 domain history를 삭제하면 301 대상 정보가 사라집니다.
- `tenants:migrate --force`는 모든 tenant DB의 pending tenant migrations를 수행합니다. 점검 시간, DB backup 및 tenant DB 생성/접속 권한을 먼저 확인하세요.

## Queue worker

### 실제 queue 사용

- QPOST는 cPanel 공유호스팅에서 **cPanel Cron으로만 Queue를 실행**합니다. Supervisor, systemd, Horizon 또는 상시 daemon worker는 사용하지 않습니다.
- 현재 `ShouldQueue` 구현은 `ResyncTenantTagmoaJob` 하나입니다. 기본 `database` connection, `default` queue를 사용하며 job은 최대 100개 글을 동기 Tagmoa 요청으로 처리하고 더 있으면 continuation job을 dispatch합니다. job class는 `$tries`, `$timeout`, `$backoff`를 지정하지 않습니다.
- Laravel의 database queue migration은 central DB에 `jobs`, `job_batches`, `failed_jobs`를 생성합니다. QPOST에서 batch dispatch API 사용은 확인되지 않았지만 migration 기본 세트에 `job_batches`도 포함됩니다. 실패 저장은 `database-uuids` driver입니다.
- 일반 Tagmoa upsert/remove/purge의 `afterResponse` callback과 sitemap generation의 `dispatchAfterResponse`는 `ShouldQueue` job이 아니므로 이 worker가 처리하지 않습니다.
- Job이 처리할 글은 최대 100개이며 각 Tagmoa 요청 timeout 기본값은 5초입니다. 모두 timeout이면 한 job이 약 500초까지 걸릴 수 있습니다. 따라서 worker `--timeout=600`, database `retry_after=660`을 사용합니다. 반드시 `retry_after > timeout`이어야 하며 cPanel/PHP CLI 프로세스 제한도 600초보다 길어야 합니다.
- `.env.example`은 `DB_QUEUE_RETRY_AFTER=660`을 예시 기본값으로 명시합니다. 운영 `.env`에도 다음을 설정하고 실제 설정값을 확인하세요:

```bash
QUEUE_CONNECTION=database
DB_QUEUE_RETRY_AFTER=660
```

Worker CLI의 `--tries=3`을 사용합니다(현재 job별 tries/backoff 재정의는 없음; CLI 기본 backoff는 0초). Queue table migration을 아직 적용하지 않았다면 `php artisan migrate --force`로 적용한 뒤 사용하세요.

### cPanel Cron — 운영자가 최초 1회 직접 등록

코드 배포만으로 Queue가 자동 실행되지는 않습니다. 운영자가 **cPanel → Cron Jobs**에서 아래 작업을 직접 등록해야 합니다. 기본 주기는 매 1분이며, queue가 비면 worker가 종료합니다:

```cron
* * * * * cd /PATH/TO/QPOST && /PATH/TO/FLOCK -n storage/framework/queue-worker.lock /PATH/TO/PHP artisan queue:work database --queue=default --stop-when-empty --timeout=600 --tries=3 >> storage/logs/queue-cron.log 2>&1
```

이 명령은 `flock`을 사용할 수 있을 때 권장하는 최종형입니다. `flock`이 없다면 잠금 없는 명령을 그대로 등록하지 말고 cPanel/provider의 cron overlap 방지 기능 또는 동등한 lock 방법을 확인하세요. 어떤 overlap 방지도 제공되지 않으면 worker 중복 실행 가능성이 남으므로 hosting provider와 먼저 해결해야 합니다.

경로를 repository에서 임의로 추정하지 마세요. 서버 SSH/Terminal에서 아래를 실행해 계정과 프로젝트의 실제 값을 확인합니다. `pwd`는 프로젝트 root에서 실행해야 합니다:

```bash
which php
php -v
pwd
command -v flock
php -m | grep -i '^pcntl$'
```

운영자는 확인한 절대 PHP 경로와 프로젝트 경로로 placeholder를 교체하고, 실행 계정에서 `storage/logs` 및 `storage/framework`가 writable인지 확인합니다. `php -v`/확장 검사는 cPanel cron과 같은 PHP CLI binary로 실행하세요.

### Cron 중복 실행 방지

Database queue의 reservation은 같은 미완료 job을 `retry_after` 전에 다른 worker가 가져가는 것을 제한하지만, 별도 worker process가 서로 다른 job들을 동시에 처리하는 것까지 막지는 않습니다. 1분 주기 cron에서 이전 worker가 1분 이상 실행될 수 있으므로 DB reservation만으로 cron-level overlap 방지는 충분하지 않습니다.

서버에서 `flock`을 제공하면 lock file로 worker 전체를 감싸세요:

```cron
* * * * * cd /PATH/TO/QPOST && /PATH/TO/FLOCK -n storage/framework/queue-worker.lock /PATH/TO/PHP artisan queue:work database --queue=default --stop-when-empty --timeout=600 --tries=3 >> storage/logs/queue-cron.log 2>&1
```

`command -v flock`으로 binary 경로를 확인합니다. Lock은 현재 실행이 끝날 때 자동 해제되므로 lock 파일 자체를 삭제할 필요가 없습니다. `flock`이 없다면 cPanel/provider에 overlap 방지 기능 또는 `flock` 설치/제공 여부를 문의하세요. `mkdir` 기반 lock은 프로세스 비정상 종료 시 stale lock이 남을 수 있어 자동 제거 절차 없이 기본 대안으로 권하지 않습니다. provider가 어떤 overlap 방지도 제공하지 않으면 다음 cron이 겹칠 수 있음을 감안해 운영하지 말고 실행 제한 및 lock 지원을 먼저 확인하세요.

Cron은 매분 시작 시도하므로 queue가 비어 있으면 곧 종료합니다. backlog가 있으면 모든 대기 작업을 비울 때까지 실행이 이어질 수 있습니다. cPanel이 허용하는 단일 cron execution maximum이 600초보다 충분히 긴지 확인하세요. 너무 짧으면 긴 resync 작업이 종료되거나 매번 재시도될 수 있습니다.

### Queue worker가 없는 경우

Cron을 등록하지 않거나 실패하면 `ResyncTenantTagmoaJob`이 수행되지 않아 domain 변경/board 허용 전환에 따른 Tagmoa 기존 글 재수집이 `jobs`에 쌓입니다. `jobs`가 계속 늘면 cron 등록, PHP/프로젝트 경로, DB connection, 로그 및 `flock` 상태를 점검하세요. `QUEUE_CONNECTION=sync`는 worker를 우회하지만 재수집을 웹 요청에서 실행해 요청 timeout을 유발할 수 있으므로 사용하지 마세요.

일반 게시글 Tagmoa upsert/remove/purge는 `TagmoaSyncClient`에서 `afterResponse` callback으로 처리하고, sitemap generation job도 `ShouldQueue`가 아닌 dispatch-after-response 방식입니다. 이들은 현재 구현상 database queue worker로 처리되지 않습니다. **Queue worker와 Scheduler는 별개입니다.**

### 확인 및 복구

```bash
php artisan queue:monitor default --max=100
php artisan queue:failed
php artisan queue:retry <failed-job-id>
```

처리된 실패 job은 재시도 전에 exception/tenant/connection 원인을 확인하세요. Tagmoa HTTP 오류 일부는 client에서 로그만 남기고 예외를 삼키므로 `failed_jobs`에 나타나지 않을 수 있습니다. 이 경우 앱 log와 중앙 Tagmoa 레코드를 점검한 뒤 재수집 job을 다시 dispatch해야 합니다. 현재 운영자용 전용 resync Artisan 명령은 없습니다. Tinker에서 전체 또는 board 단위 재수집은 다음과 같이 요청할 수 있습니다.

```php
App\Jobs\ResyncTenantTagmoaJob::dispatch('TENANT_ID');
App\Jobs\ResyncTenantTagmoaJob::dispatch('TENANT_ID', 123); // 123을 확인한 board ID로 교체
```

이 명령은 queue에 작업을 추가하는 쓰기 동작이므로 tenant ID와 필요 시 board ID를 확인하고 사용하세요.

과거 게시판 slug 등 URL 구성값 변경으로 tenant 게시글 URL과 중앙 `tagmoa_posts.post_url`이 불일치하면, 해당 게시판을 지정해 복구할 수 있습니다. `BOARD_ID`는 `free`, `community` 같은 slug가 아니라 DB의 게시판 숫자 ID입니다. 기존 중앙 글을 삭제할 필요는 없습니다. `(tenant_key, source_board_id, source_post_id)`가 같으면 기존 row가 현재 URL로 갱신되어 중복 row가 생기지 않습니다. 안전을 위해 중앙 비활성/차단 row는 resync에서 제외됩니다. 게시판 slug 변경 기능은 제거되었으므로 이는 과거 데이터 또는 URL 불일치 발생 시에만 사용하는 복구 방법입니다.

```bash
php artisan tinker
```

```php
App\Jobs\ResyncTenantTagmoaJob::dispatch('TENANT_ID', BOARD_ID);
```

처리 후 queue 상태가 필요하면 다음을 확인하세요.

```bash
php artisan queue:monitor default --max=100
php artisan queue:failed
```

## Scheduler / cron

현재 `routes/console.php`에는 tenant orphan image cleanup 명령만 등록되어 있고 Laravel Scheduler task(`Schedule::...`, `withSchedule`)는 없습니다. `php artisan schedule:list` 기준 예약 작업이 없으므로 현재 `schedule:run` cron은 필요하지 않습니다. **cPanel Queue Cron → `queue:work` → 비동기 Job 처리**만 필요합니다. Laravel Scheduler Cron → `schedule:run`은 코드에 정기 예약 작업이 생길 때 별도로 등록합니다. Queue worker 실행을 위해 `schedule:run`을 추가하지 마세요.

이미지 정리는 자동 예약되어 있지 않습니다. 수동 실행 시 먼저 확인하세요:

```bash
php artisan qpost:tenant-cleanup-orphan-images --dry-run
```

실제 삭제 실행은 dry-run 결과/tenant 범위와 보존 정책을 확인한 뒤 별도 승인 절차로 수행합니다. 무인 cron 등록은 현재 코드에서 요구하지 않습니다.

## Frontend build 및 cache

- Vite manifest는 `public/build/manifest.json`에서 제공됩니다. `public/build/`은 Git ignore 대상이므로 매 배포 시 `npm ci && npm run build` 또는 검증된 artifact 배치가 필요합니다.
- `optimize:clear`는 bootstrap cache뿐 아니라 기본 application cache도 비울 수 있으므로 무조건 실행하지 마세요. 배포 중 targeted clear(`config:clear`, `route:clear`, `view:clear`) 후 사용하는 cache만 다시 build합니다.
- `config:cache`, `route:cache`, `view:cache`는 production에서 생성할 수 있습니다. `CENTRAL_DOMAINS` 설정이 바뀌면 config cache를 갱신한 뒤 route cache도 다시 생성하세요. tenant domain DB records는 route cache에 박아넣지 않고 domain middleware가 요청 Host로 해결합니다.
- Domain history migration 자체는 route/config cache clear를 요구하지 않습니다. 변경된 PHP class 코드를 읽기 위해 PHP-FPM/OPcache 설정에 따라 web runtime reload가 필요할 수 있습니다.

## Process / service matrix

| Process/service | 필수 여부 | 역할 | 중단 시 영향 | 저장소에서 확인되는 실행 방식 |
|---|---|---|---|---|
| PHP web runtime + web server | 필수 | Laravel HTTP 요청, tenant routing, 파일 응답 | 사이트 전체 중단 | server config 미포함; `public/`을 document root로 설정 |
| Central/tenant DB | 필수 | platform data, tenant별 data, queue/cache 선택 시 해당 table | 사이트/tenant/queue 장애 | `.env` DB driver 및 tenancy database manager |
| Queue Cron | Tagmoa 재수집 운영 시 필수 | `ResyncTenantTagmoaJob` 실행 | 재수집 pending, Tagmoa stale data | cPanel Cron Jobs에서 운영자가 매분 직접 등록; database/default queue |
| Scheduler cron | 현재 불필요 | 예약된 Artisan 작업 없음 | 현재 영향 없음 | scheduler entry 없음 |
| Redis | 선택 | 선택 시 cache/queue/session backend | 선택 구성 기능 장애 | `phpredis` 옵션 제공, 기본 연결 아님 |
| Renderer daemon | 불필요 | 별도 renderer 없음 | 해당 없음 | RichContentRenderer가 PHP request 내 실행 |
| Node/npm | 배포 시 필요 | Vite assets build | manifest/assets 누락 시 Blade/Vite 오류 | build script만 있음; CI artifact workflow 없음 |

## Verification (CLI)

maintenance 해제 전에 실제 운영 env/DB를 대상으로 확인합니다. 명령 출력/지원 옵션은 설치된 Laravel 버전에 맞춰 사용하고 secret이 포함된 `config:show` 결과는 공유하지 마세요.

```bash
php artisan about
php artisan migrate:status
php artisan tenants:list
php artisan schedule:list
php artisan queue:monitor default --max=100
php artisan queue:failed
php artisan route:list --path=robots.txt
php artisan route:list --path=sitemap.xml
php artisan route:list --name=platform.sitemap
php artisan route:list --name=tenant.sitemap
php artisan route:list --name=path.tenant.sitemap
test -f public/build/manifest.json
test -w storage/app && test -w storage/framework && test -w bootstrap/cache
```

확인 기준: history migration은 `Ran`, tenant list의 기대 tenant들이 존재, cPanel Queue Cron이 `default` queue를 실행하고, Vite manifest가 있으며 upload/sitemap storage가 영속·writable이어야 합니다. `route:list`는 코드 등록 확인이며 실제 DNS/TLS/proxy 동작 확인을 대신하지 않습니다.

## Rollback / failure recovery

- **Central migration 실패:** maintenance 유지, `migrate:status`와 DB 로그 확인, backup 확인 후 문제 해결. 자동으로 `migrate:rollback`하지 마세요. Domain history migration은 실제 redirect 이력을 저장하므로 이미 사용된 상태에서 rollback하면 데이터를 잃습니다.
- **Tenant migration 실패:** 실패 tenant와 migration status를 기록하고 해당 DB backup/권한을 확인합니다. `tenants:migrate --tenants=TENANT_ID --force`로 범위를 제한해 재시도할 수 있습니다.
- **Queue Cron 중단/pending 증가:** cPanel Cron Jobs에서 등록 상태와 command paths, `jobs` 수, `storage/logs`를 확인한 뒤 cron 실행을 복구합니다. 무인 queue 삭제/flush는 금지합니다.
- **Failed job:** `queue:failed`로 식별하고 payload/exception을 검토한 뒤 대상 ID만 `queue:retry`합니다. Tagmoa HTTP 실패는 로그에만 남을 수 있습니다. 이때 resync를 재요청하고 중앙 데이터 상태를 확인합니다.
- **Domain redirect 이상:** active `domains`와 `tenant_domain_histories`의 소유권/primary를 점검합니다. history row는 오래된 host redirect 정보를 담으므로 임의 삭제/다른 tenant 재할당을 하지 마세요. 복구 전 DB backup을 확보합니다.
- **코드 rollback:** additive migration/table은 이전 코드가 무시할 수 있으므로 code rollback과 DB rollback을 분리합니다. 해당 history migration을 제거하지 않고 이전 release로 돌아가면 새 301 기능만 비활성화됩니다. 이미 도메인을 변경한 상태에서 이전 코드로 복귀하면 legacy redirect가 중단될 수 있으므로 먼저 영향 평가가 필요합니다.

## 확인되지 않은 배포 요소 / 문서 불일치

- Repository에는 deploy script, `.github/workflows`, Nginx/Apache config, systemd/Supervisor/Horizon 또는 cPanel cron 설정이 없습니다. Queue Cron은 운영자가 cPanel에서 최초 1회 직접 등록하고 실제 PHP binary/project 경로와 cron execution limit은 운영 서버에서 확인해야 합니다.
- `.env.example`은 local SQLite/database queue 예시입니다. `LOCAL_TENANT_DOMAIN_SUFFIX`, 이미지 기본 크기, 일부 AI credit/retention 옵션 등 코드에서 읽지만 예제에서 빠진 항목은 선택값이며 코드 기본값으로 동작합니다. Queue timeout 보장을 위해 production `.env`에는 `DB_QUEUE_RETRY_AFTER=660`을 명시하고 `queue:work --timeout=600`과의 관계를 유지해야 합니다. production Tagmoa parent URL/key는 배포자가 실제 운영값으로 공급해야 합니다.
- Production에서 사용할 실제 DB driver, cache/session 공유 방식, `TAGMOA_PARENT_API_URL` 연결 대상, 웹서버 및 TLS termination 구성은 repository만으로 확정할 수 없습니다.
