feat(core,admin_basic): .env 키 단위 우선 모드 추가

관리자 환경설정 저장값이 config 를 덮는 기존 설계는 공유호스팅을 1차 대상으로
삼은 결과다. .env 를 배포 기준값으로 관리하는 설치에서는 그 설계가 반대로
작동해, 설치기가 defaults 를 시드하는 순간부터 .env 의 28개 항목이 오류도
로그도 없이 사문화된다.

G7_ENV_PRIORITY 옵트인으로 그 소유권을 키 단위로 되돌린다. 스위치를 켜지 않은
설치는 전 경로가 조기 return 이라 동작이 100% 동일하다.

명시 여부는 config 빌드 시점에 캡처한다 — 런타임 env 는 config:cache 환경에서
null 로 고정되어 판정이 영구 미발동한다(attachment.disk_explicit 와 같은 함정).
판정은 strict 라 APP_DEBUG=false·REDIS_DB=0 같은 falsy 명시도 명시로 본다.

잠금은 그 키의 주입만 건너뛰는 것이지만, 제거된 키의 부재를 값으로 읽는 자리가
있으면 형제 설정까지 무너진다. 게이트(웹소켓 토글·메일 드라이버·디버그 모드)와
기본값 주입(mailgun.endpoint·ses.region), 형제 폴백(reverb server endpoint),
파생 판정(storage_driver→attachment.disk)을 유효값으로 보정했다.
This commit is contained in:
HeuJung committed 2026-09-04 10:08:30 +09:00
1 parent b97deee43a
commit 381fcc4e0b
37 files changed
+4026 -118

No files matched your search

+68 -2
View File
@@ -11,7 +11,8 @@
2. sync 된 키는 config() 와 g7_core_settings() 가 동치 — 둘 중 어느 쪽을 써도 같은 값
3. 예외 1: app.timezone 은 항상 UTC (서버 저장용). 사용자 시간대는 g7_core_settings('general.timezone') 또는 config('app.default_user_timezone')
4. 예외 2: testing 환경에서 drivers.cache/session/queue/session_lifetime 은 sync 차단 (격리 보호) — config() 는 phpunit.xml 격리값, g7_core_settings() 는 dev 공유 파일값
5. 결론: 드라이버 카테고리는 config() 우선. 그 외 admin 관리 키는 둘 다 가능 (취향)
5. 예외 3: `.env` 에 `G7_ENV_PRIORITY=true` 를 명시한 설치는 .env 에 값이 있는 항목만 sync 에서 빠진다 (기본 OFF)
6. 결론: 드라이버 카테고리는 config() 우선. 그 외 admin 관리 키는 둘 다 가능 (취향)
```
---
@@ -50,6 +51,8 @@
위 매핑은 [`app/Providers/SettingsServiceProvider.php`](../../app/Providers/SettingsServiceProvider.php) 가 단일 SSoT.
이 sync 를 키 단위로 되돌리는 옵트인 모드가 있다 — 아래 [env 우선 모드](#env-우선-모드-g7_env_priority) 참조.
---
## 어느 쪽을 쓸 것인가
@@ -138,7 +141,8 @@ plugin_setting('sirsoft-pay_kginicis', 'api_key');
2. sync 코드를 추가하지 않으면 → `g7_core_settings()` 만 사용 가능. `config()` 는 sync 되지 않은 키를 모르기 때문이다.
3. testing 격리가 필요한 키 (드라이버, 외부 서비스 자격증명 등) 는 `! $isTestingEnv` 가드로 sync 를 차단한다. 이 키들은 `config()` 가 testing 격리 SSoT 다.
4. 의미가 다른 키 (`app.timezone` 처럼) 는 sync 하지 않고 별도 키 (`app.default_user_timezone`) 로 분리한다.
5. 고급 탭 화면에 얹을 카테고리는 `config/settings/defaults.json` 의 `frontend_schema.{카테고리}.merge_into` 를 `advanced` 로 선언한다. 저장 시 어느 카테고리 파일에 쓸지는 이 선언에서 도출되므로 별도 등록이 필요 없다. 선언이 없으면 화면·검증·읽기가 모두 정상인데 입력값만 저장되지 않고 버려진다 — 저장 응답은 성공이고 화면에도 값이 보여 실패 신호가 없으므로, 새 카테고리를 추가했으면 저장 후 `storage/app/settings/{카테고리}.json` 이 생성되는지 직접 확인한다.
5. sync 코드를 추가했다면 `App\Support\EnvPriority` 의 `MAP`(env 대응이 있는 경우) 또는 `EXEMPT`(없는 경우)에 그 키를 등재한다. 등재를 잊으면 env 우선 모드를 켠 설치에서 그 키만 계속 settings 승으로 남는데, 오류도 로그도 남지 않아 "그 키가 `.env` 에 없다" 와 구분되지 않는다. 계약 테스트가 미등재를 red 로 만든다.
6. 고급 탭 화면에 얹을 카테고리는 `config/settings/defaults.json` 의 `frontend_schema.{카테고리}.merge_into` 를 `advanced` 로 선언한다. 저장 시 어느 카테고리 파일에 쓸지는 이 선언에서 도출되므로 별도 등록이 필요 없다. 선언이 없으면 화면·검증·읽기가 모두 정상인데 입력값만 저장되지 않고 버려진다 — 저장 응답은 성공이고 화면에도 값이 보여 실패 신호가 없으므로, 새 카테고리를 추가했으면 저장 후 `storage/app/settings/{카테고리}.json` 이 생성되는지 직접 확인한다.
---
@@ -181,6 +185,68 @@ plugin_setting('sirsoft-pay_kginicis', 'api_key');
---
## env 우선 모드 (`G7_ENV_PRIORITY`)
기본 설계에서 admin UI 는 운영 SSoT 다. 공유호스팅처럼 `.env` 를 직접 만지기 어려운 환경을 1차 대상으로 삼았기 때문이다. 그런데 `.env` 를 배포 기준값으로 관리하는 설치(컨테이너·IaC·다중 서버)에서는 그 설계가 반대로 작동한다 — `.env` 에 적은 값이 설치 직후부터 조용히 사문화된다(설치기가 `config/settings/defaults.json` 을 시드하므로 저장값이 항상 존재한다).
`.env` 에 `G7_ENV_PRIORITY=true` 를 명시하면 그 소유권이 **키 단위**로 되돌아간다.
- **잠금 대상**: `.env` 에 값이 명시된 env 변수에 대응하는 설정 키만. 나머지는 종전대로 admin UI 가 이긴다.
- **스위치 미설정**: 아무 것도 달라지지 않는다(기존 설치 무영향).
- **화면**: 잠긴 필드는 편집 불가 + `.env` 고정 배지. 표시값은 저장값이 아니라 **실제로 적용 중인 값**이다.
- **저장 API**: 잠긴 키는 서버측에서 제거된다. 화면의 `disabled` 는 게이트가 아니다.
### 명시 판별 규약
명시 여부는 `config/env-priority.php` 가 **config 빌드 시점에 캡처**한다. `env()` 를 런타임에 부르면 `config:cache` 환경에서 null 로 고정되어 판별이 영구 미발동하기 때문이다(`config/attachment.php` 의 `disk_explicit` 와 같은 함정).
판별은 strict 다.
| `.env` 표기 | 판정 |
|---|---|
| `MAIL_PORT=2599` | 명시 |
| `APP_DEBUG=false` | **명시** (falsy 값도 운영자가 정한 값이다) |
| `REDIS_DB=0` | **명시** |
| `MAIL_PORT=` (빈 값) | 미명시 |
| `AWS_BUCKET=null` | 미명시 |
| 줄 자체가 없음 | 미명시 |
한 설정을 env 두 개가 나누는 키(`drivers.redis_database` ↔ `REDIS_DB`/`REDIS_CACHE_DB`)는 **하나라도** 명시되면 잠긴다 — 주입이 그 하나를 덮어 버리기 때문이다.
### 매핑 SSoT
대상 키와 env 변수의 대응은 `App\Support\EnvPriority::MAP` 이 단독으로 갖는다. env 대응이 없어 대상에서 제외한 키는 그 사유와 함께 `EXEMPT` 에 등재한다 — 이 구분이 없으면 새 주입이 추가됐을 때 "빠뜨린 것"과 "대응이 없는 것"을 판별할 수 없다.
`SettingsServiceProvider` 에 settings → config 주입을 추가하면 **`MAP` 또는 `EXEMPT` 를 동반 갱신**해야 한다.
### 웹소켓 — 소유권이 통째로 넘어간다
`drivers.websocket_enabled`(마스터 토글)가 잠기면 웹소켓 계층 전체의 권위가 `.env`/config 기본값으로 넘어간다. 관리자 화면의 토글은 그 설치에서 무력화되며, OFF 강제(브로드캐스트 드라이버 `null` 화, reverb key 비우기)도 수행하지 않는다. `.env` 에 `BROADCAST_CONNECTION`·`REVERB_*` 를 적었다면 운영자가 그 소유권을 가져간 것으로 본다.
### 잠금은 그 키의 주입만 건너뛴다
제거된 키를 읽는 자리가 그 부재를 **값**으로 해석하면, 잠금이 형제 설정까지 무너뜨린다. 다섯 형태가 있고 전부 오류도 로그도 남기지 않는다.
| 형태 | 보정하지 않으면 |
|---|---|
| 마스터 토글의 OFF 강제 | `.env` 로 웹소켓을 켠 설치에 강제 OFF 가 걸린다 |
| 게이트가 되는 키 | 디버그 모드 잠금 시 로그 레벨 강제·프록시 적용이 죽고, 메일 드라이버 잠금 시 mailgun·ses 하위 저장값이 주입되지 않는다 |
| 저장값이 비어도 박히던 기본값 | `services.mailgun.endpoint`·`services.ses.region` 의 기본값 주입이 저장값 없는 호출에서도 사라진다 |
| 형제 값으로의 폴백 | 웹소켓 server endpoint 가 비면 관리자 소유 client 값으로 폴백하는데, 그 값이 `.env` 가 소유한 `REVERB_HOST`/`PORT`/`SCHEME` 자리에 덮인다 |
| 파생 판정 | `storage_driver=s3` → `attachment.disk` 파생이 멈춰, `FILESYSTEM_DISK=s3` 를 잠근 설치에서 첨부 업로드만 로컬 디스크로 되돌아간다 |
판정에 쓰는 자리는 유효값(런타임 config)으로 보정하고, 주입을 건너뛰어야 하는 자리는 `EnvPriority::isLocked()` 를 직접 묻는다. `array_key_exists`/`empty()` 로 판정하면 "저장값이 없는 호출" 과 "잠겨서 제거된 호출" 이 구분되지 않아, 전자에서도 종전 동작이 사라진다.
### 민감 키
`.env` 의 비밀값(비밀번호·시크릿·토큰·라이선스 키)은 화면·응답에 싣지 않는다. 잠금 표시만 하고 값은 저장값 그대로 남긴다.
### `.env` 만 고쳤을 때
설정 저장 경로는 config 캐시를 자동으로 다시 굽지만, `.env` 파일만 편집한 경우에는 반영되지 않는다. `php artisan config:cache` 를 다시 실행한다.
---
## 관련 문서
- [service-provider.md](service-provider.md) — ServiceProvider 안전성 (DB 접근 가드)
+4 -1
View File
@@ -72,7 +72,10 @@ _단건 응답: `data` 객체의 필드._
| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 탭 설정 그룹. channels 는 알림 채널 목록으로 각 원소가 id(채널 식별자)·is_active(활성 여부)·sort_order(표시 순서)를 가짐 |
| identity | object | `{"default_provider":"g7:core.mail","purpose_providers":{"…` | 본인인증(IDV) 탭 설정 그룹 (기본 provider·목적별 provider 매핑(purpose_providers)·챌린지 유효시간(분)·최대 시도 횟수) |
| available_drivers | object | `{"storage":[{"id":"local","label":{"ko":"로컬","en":"Local"…` | 드라이버 선택지 카탈로그 (DriverRegistryService 산물). 종류별(storage/public_asset/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공. `public_asset` 은 공개 자산 직접 URL 서빙 디스크 선택지 (코어 none/public/s3 + 플러그인 훅 등록분) |
| _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | 설정값이 아니라 화면이 쓰는 메타. `limits` 는 각 설정 항목의 min/max 경계값 맵 (`config/core.php` 의 `settings_limits` 가 SSoT, 화면 입력 힌트와 FormRequest 검증이 같은 값을 공유) |
| _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | 설정값이 아니라 화면이 쓰는 메타. 조회(GET)와 저장(POST) 응답이 같은 모양으로 동봉한다. 아래 세 필드로 구성 |
| _meta.limits | object | `{"upload_max_file_size_min":1,"upload_max_file_size_max":1024,…}` | 각 설정 항목의 min/max 경계값 맵 (`config/core.php` 의 `settings_limits` 가 SSoT, 화면 입력 힌트와 FormRequest 검증이 같은 값을 공유) |
| _meta.env_priority_enabled | boolean | `false` | `.env` 키 단위 우선 모드(`G7_ENV_PRIORITY`) 활성 여부. 화면은 이 값으로 안내 배너 표시를 판정한다. 기본값 false (스위치 미설정 시 종전 동작) |
| _meta.env_locked | object | `{"general.site_name":true,"advanced.debug_mode":true}` | `.env` 에 값이 명시되어 편집이 잠긴 필드 목록. 키는 **화면이 쓰는 프론트엔드 키**(`frontend_key`/`merge_into` 변환 후 — 예: `debug.mode` → `advanced.debug_mode`)이며 값은 항상 true. 병합 대상 카테고리와 원본 카테고리 양쪽에 실린다(설정값 자체가 두 위치에 실리는 것과 같은 규칙). 스위치가 꺼져 있으면 빈 객체. 잠긴 필드는 저장 요청에 포함되어도 서버가 제거하므로 값이 반영되지 않는다. 잠긴 필드의 값은 저장값이 아니라 실제로 적용 중인 값으로 내려가되, 비밀번호·시크릿·토큰 등 민감 항목은 그 대체를 하지 않는다 |
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
**응답 예시**
+4
View File
@@ -180,6 +180,8 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
브로드캐스팅은 부가 기능이므로 실패해도 메인 작업(사용자 업데이트, 주문 처리, 알림 발송 등)을 중단시키지 않습니다. 개별 리스너에서 try-catch를 추가할 필요가 없습니다.
> **중요**: 웹소켓 OFF 시 `broadcasting.default`가 `'null'`로 강제되는 동작은 `.env`의 `BROADCAST_CONNECTION` 값을 무시하고 적용됩니다. 따라서 운영자가 환경설정에서 OFF한 경우, `.env`에 `REVERB_HOST=localhost` 등이 남아 있어도 broadcast 시도가 발생하지 않습니다. 알림 시스템(mail/database 채널)은 이 설정과 독립적으로 정상 동작합니다.
>
> 예외: `.env` 우선 모드(`G7_ENV_PRIORITY=true`)를 켜고 `.env` 에 `BROADCAST_CONNECTION` 을 명시한 설치에서는 이 강제가 적용되지 않습니다 — 웹소켓 계층 전체의 권위가 `.env` 로 넘어가고 관리자 토글이 그 설치에서 무력화됩니다. 자세한 규약은 [admin-settings-access.md](admin-settings-access.md) "env 우선 모드" 를 참조하세요.
#### 웹소켓 토글은 전 계층 SSoT (송신 · 프론트연결 · 채널인증)
@@ -193,6 +195,8 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
따라서 `.env` 에 `REVERB_APP_KEY` 가 살아 있고 Reverb 서버가 운영자 OS 레벨로 기동 중이어도, 토글 OFF 면 프론트가 연결을 시도하지 않고 직접 연결 시도도 채널 인증 단계에서 거부됩니다. `.env` 는 토글 ON 일 때만 자격증명·endpoint 소스로 사용됩니다.
단, `.env` 우선 모드를 켜고 `BROADCAST_CONNECTION` 을 명시한 설치는 예외입니다. 그 설치에서는 마스터 토글이 잠겨 위 세 계층 모두 `.env`/config 기본값을 따르며, 관리자 화면의 토글은 잠금 표시와 함께 편집 불가가 됩니다.
### 채널 타입
| 타입 | 클래스 | 용도 | 인증 필요 |
+3 -1
View File
@@ -348,7 +348,9 @@ public function boot(): void
```
등록 후 `.env`에서 `SCOUT_DRIVER=meilisearch`로 전환하거나, 관리자 환경설정 > 드라이버의
검색엔진 항목에서 선택할 수 있습니다.
검색엔진 항목에서 선택할 수 있습니다. 두 값이 모두 있으면 관리자 환경설정이 이깁니다 —
`.env` 값을 기준으로 삼으려면 `.env` 우선 모드(`G7_ENV_PRIORITY=true`)를 켭니다
([admin-settings-access.md](admin-settings-access.md) "env 우선 모드").
#### 드라이버 폴백 가드
+1
View File
@@ -410,6 +410,7 @@ public static function isSchemaMutatingCommand(): bool
### config 캐시 재생성 주의
- `config:cache` 는 config 소스를 변경하는 라이프사이클에서 `App\Support\ConfigCacheHelper::rebuild()` 로 자동 재생성된다 (아래 "config 캐시 자동 재생성" 참조). `.env` 를 직접 편집한 경우처럼 헬퍼를 거치지 않는 변경은 여전히 `php artisan config:clear && php artisan config:cache` 를 수동 실행한다
- `.env` 우선 모드(`G7_ENV_PRIORITY`)에서는 이 수동 재실행이 기능의 일부다 — `.env` 의 키 명시 여부는 config 빌드 시점에 `config/env-priority.php` 로 캡처되므로, `.env` 만 고치고 재빌드하지 않으면 잠금 상태가 갱신되지 않는다. 런타임 `env()` 로 명시 여부를 판별하면 `config:cache` 환경에서 항상 null 이라 판정이 영구 미발동한다. 상세: [admin-settings-access.md](admin-settings-access.md)
### 확장 Trait 패턴