Files
Gnuboard7/docs/backend/language-pack-service.md
HeuJung 3d8890b118 feat(core,admin_basic): 번들 비-ko 언어팩 자동 프로비저닝 + 미설치·드리프트 발견성
신규 설치/복구 시 비-ko/en 번들 언어팩(ja 등)이 자동으로 채워지지 않아
런타임에 오류 없이 조용히 ko 로 폴백하고, 운영자가 이를 알아채기 어려웠다.
두 결손을 함께 해소한다.

프로비저닝(SSoT):
- language-pack:provision 멱등 커맨드 신설. supported_locales ∖ {ko,en} 의
 미설치 번들 팩을 설치하고, 설치본 파일이 사라진 드리프트 팩(슬롯 점유로
 getUninstalledBundledPacks 가 못 잡는)도 복구 경로로 겸한다. 재실행 시 0건 수렴.

발견성:
- CLI language-pack:list 를 Service::list 경유로 전환해 미설치(uninstalled)와
 드리프트(active·파일 없음)를 함께 표면화.
- files_missing / bundled_source_available 파생 플래그를 Resource 에 노출.
- 관리자 언어팩 목록에 "파일 없음" 배지 + 원클릭 재설치(기존 install-bundled 모달
 재사용). 재설치 경로만 auto_activate 로 바인딩해 복구 후 active 를 유지한다.

회귀 방지:
- 정적 룰 lang-pack-supported-locale-has-bundle(선언 로케일 ↔ 번들 소스 부재) 신설,
 lang-pack-installed-version-drift 를 프로비저닝된 환경 한정으로 확장.
- 백엔드/레이아웃/E2E/시나리오 테스트 + 룰 fixture 추가.

부수 수정:
- 프로비저닝 작업 중 표면화된 ecommerce 언어팩 시더 테스트 격리결함 수정
 (modules 등록 행을 매 테스트가 직접 보장 — 프로세스당 1회 시딩 전제 붕괴 대응).
2026-07-27 14:58:48 +09:00

13 KiB
Raw Permalink Blame History

LanguagePackService (백엔드 Service 레이어)

TL;DR (5초 요약)

1. LanguagePackService 가 install/activate/deactivate/uninstall 도메인 로직 단일 진입점 — 컨트롤러는 Service 만 주입
2. 슬롯(scope, target_identifier, locale) 당 active 1개는 application-level 트랜잭션이 보장 (functional unique index 미사용)
3. ZIP/GitHub/URL 3가지 설치 소스 — 모두 finalizeInstall() 로 합류 (manifest 검증 → 보안 검사 → 의존성 → 디렉토리 이동 → DB 등록)
4. 활성/비활성 시 HookManager::doAction('core.language_packs.after_activate' / 'after_deactivate', $pack) 발행 — Listener 가 DB JSON 컬럼 동기화
5. 보안: backend/ 외 PHP 차단 + eval/include/exec 정적 분석 거부 + 다운그레이드 차단 + 체크섬 검증 (URL 설치)

위치 및 의존성

  • Service: app/Services/LanguagePackService.php
  • Repository: app/Repositories/LanguagePackRepository.php (인터페이스: app/Contracts/Repositories/LanguagePackRepositoryInterface.php)
  • Validator: app/Services/LanguagePack/LanguagePackManifestValidator.php
  • Registry: app/Services/LanguagePack/LanguagePackRegistry.php (런타임 싱글톤)
  • SeedInjector: app/Services/LanguagePack/LanguagePackSeedInjector.php (HookManager 필터 리스너)
  • Translator: app/Services/LanguagePack/LanguagePackTranslator.php (Laravel Translator override)
  • BundledRegistrar: app/Services/LanguagePack/LanguagePackBundledRegistrar.php (확장 내장 가상 등록)

공개 API

class LanguagePackService
{
    // 조회 — list() 는 DB 행 + 가상 보호 행 + 미설치 번들 가상 행을 병합하며,
    //         각 행에 드리프트 파생 플래그 files_missing(active 인데 설치본 부재) 을 부여
    public function list(array $filters = [], int $perPage = 20): LengthAwarePaginator;
    public function find(int $id): ?LanguagePack;
    public function getUninstalledBundledPacks(array $filters = []): Collection;   // lang-packs/_bundled/ 미설치 번들 가상 행
    public function getDriftedInstalledPacks(array $filters = []): Collection;     // 설치 행 존재 + 설치본 파일 부재 (복구 대상)

    // 설치 (4가지 소스)
    public function installFromBundled(string $identifier, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack;
    public function installFromFile(UploadedFile $file, bool $autoActivate = true, ?int $installedBy = null): LanguagePack;
    public function installFromGithub(string $githubUrl, bool $autoActivate = true, ?int $installedBy = null): LanguagePack;
    public function installFromUrl(string $url, ?string $checksum, bool $autoActivate = true, ?int $installedBy = null): LanguagePack;

    // 상태 전환
    public function activate(LanguagePack $pack): LanguagePack;
    public function deactivate(LanguagePack $pack): LanguagePack;
    public function uninstall(LanguagePack $pack, bool $cascade = false): void;
}

설치 흐름 (finalizeInstall)

ZIP/GitHub/URL → _pending/{tmp-uuid}/ 추출
  ↓
ZipInstallHelper::findManifest('language-pack.json')
  ↓
LanguagePackManifestValidator::validate($manifest, $packageRoot)
  ↓
assertSecurityRules($packageRoot, $manifest)
  - backend/ 외의 .php 파일 → RuntimeException
  - eval/include/require/exec/system/popen/proc_open 패턴 발견 → RuntimeException
  ↓
assertDependencies($manifest)
  - scope=core 면 통과
  - scope ∈ {module, plugin, template} + requires.depends_on_core_locale=true (기본)
    → registry->hasActiveCoreLocale($manifest['locale']) 확인 → false 면 거부
  ↓
assertTargetExtensionExists($manifest)
  - scope ∈ {module, plugin, template}
    → modules/plugins/templates 테이블에서 target_identifier 존재 확인
  ↓
assertNotDowngrade($existing, $manifest)
  - identifier 가 이미 설치되어 있으면 version_compare 로 다운그레이드 차단
  ↓
checkTargetVersionMismatch($manifest)
  - target_version_constraint vs 대상 확장의 현재 version semver 비교
  - 불일치 시 target_version_mismatch=true 플래그만 저장 (차단하지 않음, 경고만)
  ↓
File::moveDirectory($packageRoot, lang-packs/{identifier}/)
  ↓
DB::transaction:
  - 슬롯 비어있고 autoActivate=true → 신규 레코드 status=active + activated_at=now
  - 그 외 → status=installed
  - Repository::create or update
  ↓
LanguagePackRegistry::invalidate()
  ↓
HookManager::doAction('core.language_packs.after_activate', $pack)  (active 진입한 경우만)

프로비저닝 커맨드 (language-pack:provision)

fresh install · 복구 · 시더가 공유하는 멱등 프로비저닝 SSoT 입니다.

php artisan language-pack:provision                # supported_locales 의 비-base 로케일 대상
php artisan language-pack:provision --locale=ja     # 특정 로케일 한정
php artisan language-pack:provision --scope=core    # 스코프 한정
php artisan language-pack:provision --no-activate   # 설치만, 자동 활성화 생략
  • 대상 로케일 = --locale 지정값, 미지정 시 config('app.supported_locales') ∖ base locale(ko/en).
  • 대상 후보는 두 갈래입니다 — getUninstalledBundledPacks() (신규 프로비저닝) + getDriftedInstalledPacks() (수동 복구). 각각 installFromBundled($id, autoActivate: true) 를 호출하며, 정상 설치된 팩은 두 집합 어디에도 속하지 않으므로 재실행 시 신규 설치 0 건으로 수렴(멱등).
  • 드리프트 팩은 슬롯이 점유돼 있어 미설치 목록에 잡히지 않습니다. 이 커맨드가 복구까지 겸하지 않으면 드리프트는 화면에서 보이기만 하고 CLI 로는 고칠 수 없습니다. 재설치는 finalizeInstall 의 update 경로를 타므로 동일 버전 재설치도 다운그레이드로 차단되지 않습니다.
  • 설치 차단 사유(install_blocked_reason: 대상 확장 미설치/미활성 등)가 있는 팩은 skip + 경고(best-effort). 신규 도메인 로직 없이 기존 Service 메서드만 조합합니다.

드리프트 표면화 (files_missing / bundled_source_available)

list() 와 getPacksForExtension() 은 각 팩에 파생 플래그 두 개를 부여합니다.

플래그 판정 용도
files_missing 실제 DB 설치 행(exists=true) + status=active 인데 설치본 디렉토리(resolveDirectory()) 부재 드리프트 발견 — 가상 보호 행/미설치 가상 행은 대상 아님
bundled_source_available lang-packs/_bundled/{identifier} 실재 복구 가능 여부 — 재설치 버튼 노출 판정

bundled_source_available 은 설치 경로(source_type)가 아니라 번들 소스의 실재 여부로 판정합니다. 판정을 source_type 에 걸면 zip/url 로 설치된 팩이 드리프트됐을 때 배지만 뜨고 복구 수단이 없는 상태로 남습니다. 반대로 번들 소스가 없는 서드파티 팩에는 재설치 버튼이 뜨지 않습니다 — 복구할 소스 자체가 없기 때문입니다.

이 플래그로 language-pack:list 는 active (파일 없음) 을 표기하고, 관리자 화면은 배지 + 원클릭 재설치를 노출합니다.

슬롯 스위칭 (activate / deactivate)

activate: 동일 슬롯의 기존 active 팩을 inactive 로 강등 + 대상 팩을 active 로 승격. 단일 트랜잭션 내에서 원자적으로 수행. application-level 보장이므로 DB functional unique index 불필요.

DB::transaction(function () use ($pack) {
    $current = repository->findActiveForSlot($pack->scope, $pack->target_identifier, $pack->locale);

    if ($current && $current->id !== $pack->id) {
        repository->update($current, ['status' => 'inactive']);
        HookManager::doAction('core.language_packs.after_deactivate', $current);
    }

    repository->update($pack, ['status' => 'active', 'activated_at' => now()]);
});

registry->invalidate();
HookManager::doAction('core.language_packs.after_activate', $pack);

deactivate: 비활성화 후 슬롯에 다른 후보(inactive/installed)가 있으면 자동 active 로 승격(promoteSlotSuccessor). 보호된 팩(번들 ko/en 등 is_protected=true)은 비활성화 거부.

우회 불가 규칙

규칙 위반 시
backend/ 외에 .php 파일 포함 설치 거부 (RuntimeException)
backend/*.php 에 eval/include/require/exec/system/popen/proc_open 패턴 설치 거부
ZIP 경로 이탈 (.. 포함) manifest validator 가 거부 (contents.* 체크)
의존성 미충족 (모듈/플러그인/템플릿 + 코어 언어팩 없음) 설치 거부
대상 확장 미설치 설치 거부
다운그레이드 시도 설치 거부
protected 팩 비활성화/제거 RuntimeException
슬롯당 active 2개 이상 application 트랜잭션이 자동으로 기존 active 강등

이벤트 (HookManager 액션 훅)

액션 이름 페이로드 발행 시점
core.language_packs.after_activate LanguagePack $pack 활성 진입 직후 (install + activate)
core.language_packs.after_deactivate LanguagePack $pack 비활성 진입 직후 (deactivate + 슬롯 스위칭 시 강등)

확장은 HookManager::addAction('core.language_packs.after_activate', fn ($pack) => ...) 로 자유롭게 구독 가능.

가상 등록 (LanguagePackBundledRegistrar)

확장(modules/plugins/templates) install/uninstall/update 후크에 자동 연결되어 확장의 lang/ 또는 resources/lang/ 디렉토리를 스캔, 발견된 locale 별로 bundled_with_extension 가상 레코드를 language_packs 테이블에 등록합니다.

후크 동작
core.modules.after_install / after_update syncFromExtension('module', identifier, vendor, version, langDir)
core.modules.after_uninstall cleanupForExtension('module', identifier) — 가상 레코드 삭제 + 외부 벤더 레코드는 error 상태 전환
(plugin/template 동일 패턴) 9개 후크 리스너 등록

캐시 무효화

LanguagePackRegistry::invalidate() 호출 시점:

  • installFromFile / Github / Url 의 finalizeInstall 종료 직전
  • activate / deactivate / uninstall 트랜잭션 종료 직후
  • BundledRegistrar::syncFromExtension / cleanupForExtension 종료 직후

invalidate 가 무효화하는 캐시:

  • activePacksCache (활성 언어팩 컬렉션)
  • activeCoreLocalesCache (코어 활성 locale 배열)

트러블슈팅

Q. 활성화 후에도 trans() 가 새 locale 키를 못 찾습니다.

  • A. LanguagePackTranslator 의 addCoreFallbackPath 가 호출되었는지 확인. LanguagePackServiceProvider::boot() 가 부팅 시 1회 등록. install/activate 후 폴백 경로가 추가되도록 ServiceProvider 부팅을 다시 실행해야 함 (다음 요청부터 자동 반영).

Q. 동일 슬롯에 active 2개가 동시에 존재합니다.

  • A. application 트랜잭션 외부에서 직접 DB 조작한 경우. LanguagePackService::activate / deactivate 외 경로로 status 변경 금지. 복구: php artisan tinker 에서 LanguagePackRepository::getPacksForSlot() 호출 후 1개만 active 유지.

Q. 사용자가 직접 수정한 다국어 키를 언어팩이 덮어썼습니다.

  • A. HasUserOverrides trait 미사용 모델일 가능성. Permission 등은 trait 미적용 → 본 보존 정책은 Role/Menu/NotificationDefinition/NotificationTemplate/Module/Plugin/Template 만 적용됩니다. language_pack 채널의 감사 로그에서 action: skipped/preserved 기록 확인.

Q. 활성 언어팩 ja 가 설치돼 있는데 비인증 API(로그인 실패 등) 응답만 영문으로 옵니다.

  • A. 응답 메시지 생성 경로가 App::getLocale() 을 신뢰하는지 점검. SetLocale 미들웨어가 config('app.supported_locales') 동적 화이트리스트(활성 코어 언어팩 포함)로 이미 정확히 set 했으므로, 모든 응답 헬퍼/미들웨어/예외 핸들러는 그 결과만 사용해야 합니다. 자체 화이트리스트(['ko', 'en'] 등)로 ja 를 거부하면 fallback 으로 떨어져 영문이 노출됩니다. 점검 위치: ResponseHelper::getUserLocale(), EnsureTokenIsValid::handle(), MaintenanceModePage::handle(), FormRequest failedValidation() 오버라이드, 플러그인의 __() 직접 호출 분기.

Q. 메인터넌스 모드(503) 응답이 활성 언어팩 로케일로 안 나옵니다.

  • A. MaintenanceModePage 는 SetLocale 미들웨어보다 먼저(prepend) 실행되므로 자체 detectLocale() 후 app()->setLocale() 을 호출해야 합니다. API 분기와 HTML 분기 모두 setLocale() 이 __() 호출보다 앞서야 합니다.

관련 문서

  • 시스템 개요: docs/extension/language-packs.md
  • 데이터 동기화 헬퍼: docs/backend/data-sync-helpers.md
  • 사용자 수정 보존: docs/backend/user-overrides.md
  • 알림 시스템 (3-tier): docs/backend/notification-system.md