Files
Gnuboard7/app/Providers/CoreServiceProvider.php
T
HeuJung 2ae1972a3b perf(core): 대용량 목록 상한 총 건수·커서 계약 신설 및 요청당 반복 실행 비용 정리
공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다.
응답·행의 무게를 다룬 · 과 겹치지 않는다.

공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이
기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은
per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지
번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로
정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은
계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다.

계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면
그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다.
응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새
필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate
응답은 필드 단위로 이전과 동일하다.

요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일
재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어
화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한
변경이 종전처럼 다음 요청에 반영된다.

검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한
엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어
활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩
대신 선언형 config 로 옮겼다.

총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을
돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
2026-08-06 11:13:51 +09:00

1057 lines
45 KiB
PHP

<?php
namespace App\Providers;
use App\Benchmark\Axes\BatchAxisRunner;
use App\Benchmark\Axes\ListAxisRunner;
use App\Benchmark\Axes\ScreenAxisRunner;
use App\Benchmark\Axes\WriteAxisRunner;
use App\Console\Commands\BenchCommand;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
use App\Contracts\Extension\HookListenerInterface;
use App\Contracts\Extension\ModuleSettingsInterface;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Contracts\Repositories\IdentityMessageDefinitionRepositoryInterface;
use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Contracts\Repositories\LayoutExtensionRepositoryInterface;
use App\Contracts\Repositories\LayoutExtensionVersionRepositoryInterface;
use App\Contracts\Repositories\LayoutPreviewRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
use App\Contracts\Repositories\MenuRepositoryInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use App\Contracts\Repositories\NotificationLogRepositoryInterface;
use App\Contracts\Repositories\NotificationRepositoryInterface;
use App\Contracts\Repositories\NotificationTemplateRepositoryInterface;
use App\Contracts\Repositories\PasswordResetTokenRepositoryInterface;
use App\Contracts\Repositories\PermissionRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
use App\Contracts\Repositories\ScheduleRepositoryInterface;
use App\Contracts\Repositories\SeoCacheStatRepositoryInterface;
use App\Contracts\Repositories\SystemConfigRepositoryInterface;
use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface;
use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Contracts\Repositories\UserConsentRepositoryInterface;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Contracts\UniqueIdServiceInterface;
use App\Enums\DeactivationReason;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\CoreVersionChecker;
use App\Extension\ExtensionManager;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\HookCacheManager;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Extension\IdentityVerification\Providers\MailIdentityProvider;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\Storage\CoreStorageDriver;
use App\Extension\TemplateManager;
use App\Listeners\ExtensionCompatibilityAlertListener;
use App\Listeners\Identity\EnforceIdentityPolicyListener;
use App\Repositories\ActivityLogRepository;
use App\Repositories\AttachmentRepository;
use App\Repositories\IdentityMessageDefinitionRepository;
use App\Repositories\IdentityMessageTemplateRepository;
use App\Repositories\IdentityPolicyRepository;
use App\Repositories\IdentityVerificationLogRepository;
use App\Repositories\JsonConfigRepository;
use App\Repositories\LayoutExtensionRepository;
use App\Repositories\LayoutExtensionVersionRepository;
use App\Repositories\LayoutPreviewRepository;
use App\Repositories\LayoutRepository;
use App\Repositories\LayoutVersionRepository;
use App\Repositories\MenuRepository;
use App\Repositories\ModuleRepository;
use App\Repositories\NotificationDefinitionRepository;
use App\Repositories\NotificationLogRepository;
use App\Repositories\NotificationRepository;
use App\Repositories\NotificationTemplateRepository;
use App\Repositories\PasswordResetTokenRepository;
use App\Repositories\PermissionRepository;
use App\Repositories\PluginRepository;
use App\Repositories\RoleRepository;
use App\Repositories\ScheduleHistoryRepository;
use App\Repositories\ScheduleRepository;
use App\Repositories\SeoCacheStatRepository;
use App\Repositories\SystemConfigRepository;
use App\Repositories\TemplateCustomTranslationRepository;
use App\Repositories\TemplateLayoutAttachmentRepository;
use App\Repositories\TemplateRepository;
use App\Repositories\UserConsentRepository;
use App\Repositories\UserRepository;
use App\Services\AttachmentService;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Services\TemplateLayoutAttachmentService;
use App\Services\UniqueIdService;
use App\Support\PrivilegedDatabaseAccounts;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class CoreServiceProvider extends ServiceProvider
{
/**
* registerDynamicHooks() 메서드를 가진 리스너 클래스 목록.
* boot 후반부(DB 접근 가능 시점)에서 일괄 실행됩니다.
*
* @var array<string>
*/
private array $deferredDynamicListeners = [];
/**
* 코어 서비스들을 등록합니다.
*/
public function register(): void
{
// Extension autoload 등록 (진입점에서 누락된 경우를 대비)
$this->registerExtensionAutoload();
$this->registerRepositoryBindings();
$this->registerExtensionManagers();
$this->registerBenchmarkAxes();
// ActivityLogManager 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체
}
/**
* 성능 계측 축 실행기를 등록합니다.
*
* 축이 늘어날 때 `g7:bench` 커맨드를 고치지 않고 여기에 실행기만 추가하면 되도록
* 태그로 묶어 주입합니다. 실행기는 CLI 계측 시점에만 해석되므로 웹 요청 비용은 없습니다.
*/
private function registerBenchmarkAxes(): void
{
$this->app->tag([
ListAxisRunner::class,
ScreenAxisRunner::class,
WriteAxisRunner::class,
BatchAxisRunner::class,
], 'benchmark.axes');
$this->app->when(BenchCommand::class)
->needs('$runners')
->giveTagged('benchmark.axes');
}
/**
* Extension(모듈/플러그인) PSR-4 오토로드를 등록합니다.
*
* index.php 또는 artisan에서 이미 등록된 경우 스킵합니다.
* 진입점에서 등록되지 않은 경우(예: queue worker)에만 실행됩니다.
*/
private function registerExtensionAutoload(): void
{
// 이미 진입점(index.php, artisan)에서 등록된 경우 스킵
if (defined('G7_EXTENSION_AUTOLOAD_REGISTERED')) {
return;
}
$extensionAutoloadFile = base_path('bootstrap/cache/autoload-extensions.php');
if (! file_exists($extensionAutoloadFile)) {
return;
}
$loader = require base_path('vendor/autoload.php');
$extensionAutoloads = require $extensionAutoloadFile;
// PSR-4 네임스페이스 등록
if (! empty($extensionAutoloads['psr4'])) {
foreach ($extensionAutoloads['psr4'] as $namespace => $paths) {
$paths = (array) $paths;
foreach ($paths as $path) {
$absolutePath = base_path($path);
if (is_dir($absolutePath)) {
$loader->addPsr4($namespace, $absolutePath);
}
}
}
}
// 확장 소스 classmap 등록 (FQCN → 절대경로) — findFile 파일시스템 스캔 제거 (lazy include).
if (! empty($extensionAutoloads['src_classmap'])) {
$absoluteClassmap = [];
foreach ($extensionAutoloads['src_classmap'] as $fqcn => $relPath) {
$absoluteClassmap[$fqcn] = base_path($relPath);
}
$loader->addClassMap($absoluteClassmap);
}
// Classmap 파일 로드 (module.php, plugin.php)
if (! empty($extensionAutoloads['classmap'])) {
foreach ($extensionAutoloads['classmap'] as $file) {
$absolutePath = base_path($file);
if (file_exists($absolutePath)) {
require_once $absolutePath;
}
}
}
// Files 로드 (헬퍼 함수 등)
if (! empty($extensionAutoloads['files'])) {
foreach ($extensionAutoloads['files'] as $file) {
$absolutePath = base_path($file);
if (file_exists($absolutePath)) {
require_once $absolutePath;
}
}
}
}
/**
* Repository 인터페이스 바인딩을 등록합니다.
*/
private function registerRepositoryBindings(): void
{
$this->app->bind(ActivityLogRepositoryInterface::class, ActivityLogRepository::class);
$this->app->bind(AttachmentRepositoryInterface::class, AttachmentRepository::class);
$this->app->bind(PasswordResetTokenRepositoryInterface::class, PasswordResetTokenRepository::class);
$this->app->bind(LayoutExtensionRepositoryInterface::class, LayoutExtensionRepository::class);
$this->app->bind(LayoutExtensionVersionRepositoryInterface::class, LayoutExtensionVersionRepository::class);
$this->app->singleton(ConfigRepositoryInterface::class, JsonConfigRepository::class);
$this->app->bind(LayoutPreviewRepositoryInterface::class, LayoutPreviewRepository::class);
$this->app->bind(LayoutRepositoryInterface::class, LayoutRepository::class);
$this->app->bind(LayoutVersionRepositoryInterface::class, LayoutVersionRepository::class);
$this->app->bind(MenuRepositoryInterface::class, MenuRepository::class);
$this->app->bind(ModuleRepositoryInterface::class, ModuleRepository::class);
$this->app->bind(PermissionRepositoryInterface::class, PermissionRepository::class);
$this->app->bind(PluginRepositoryInterface::class, PluginRepository::class);
$this->app->bind(RoleRepositoryInterface::class, RoleRepository::class);
$this->app->bind(ScheduleHistoryRepositoryInterface::class, ScheduleHistoryRepository::class);
$this->app->bind(ScheduleRepositoryInterface::class, ScheduleRepository::class);
$this->app->bind(SystemConfigRepositoryInterface::class, SystemConfigRepository::class);
$this->app->bind(TemplateRepositoryInterface::class, TemplateRepository::class);
$this->app->bind(TemplateCustomTranslationRepositoryInterface::class, TemplateCustomTranslationRepository::class);
$this->app->bind(TemplateLayoutAttachmentRepositoryInterface::class, TemplateLayoutAttachmentRepository::class);
$this->app->bind(UserConsentRepositoryInterface::class, UserConsentRepository::class);
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
$this->app->bind(NotificationDefinitionRepositoryInterface::class, NotificationDefinitionRepository::class);
$this->app->bind(NotificationLogRepositoryInterface::class, NotificationLogRepository::class);
$this->app->bind(NotificationRepositoryInterface::class, NotificationRepository::class);
$this->app->bind(NotificationTemplateRepositoryInterface::class, NotificationTemplateRepository::class);
// SEO Repository 바인딩
$this->app->bind(SeoCacheStatRepositoryInterface::class, SeoCacheStatRepository::class);
// IdentityVerification Repository 바인딩
$this->app->bind(IdentityVerificationLogRepositoryInterface::class, IdentityVerificationLogRepository::class);
$this->app->bind(IdentityPolicyRepositoryInterface::class, IdentityPolicyRepository::class);
$this->app->bind(IdentityMessageDefinitionRepositoryInterface::class, IdentityMessageDefinitionRepository::class);
$this->app->bind(IdentityMessageTemplateRepositoryInterface::class, IdentityMessageTemplateRepository::class);
// IdentityVerification Manager + 기본 MailProvider 등록
$this->app->singleton(IdentityVerificationManager::class, function ($app) {
$manager = new IdentityVerificationManager;
$manager->register($app->make(MailIdentityProvider::class));
return $manager;
});
// UniqueIdService 바인딩
$this->app->singleton(UniqueIdServiceInterface::class, UniqueIdService::class);
// AttachmentService용 CoreStorageDriver 바인딩
$this->app->when(AttachmentService::class)
->needs(StorageInterface::class)
->give(function () {
return new CoreStorageDriver(config('attachment.disk', 'local'));
});
// TemplateLayoutAttachmentService용 CoreStorageDriver 바인딩
$this->app->when(TemplateLayoutAttachmentService::class)
->needs(StorageInterface::class)
->give(function () {
return new CoreStorageDriver(config('attachment.disk', 'local'));
});
// 코어 서비스용 CoreCacheDriver 바인딩
// Phase 7(이관)에서 각 서비스에 CacheInterface DI 추가 시 when() 목록 확대 예정
$this->app->bind(CacheInterface::class, function () {
return new CoreCacheDriver(config('cache.default'));
});
// 확장 선언 미들웨어 self-gate 인덱스 (요청 내 인덱스 1회 빌드 → singleton)
$this->app->singleton(ExtensionMiddlewareRegistryInterface::class, ExtensionMiddlewareRegistry::class);
}
/**
* 확장 매니저들을 등록합니다.
*/
private function registerExtensionManagers(): void
{
// 확장 매니저 등록 (모듈/플러그인 공통 기능)
$this->app->singleton(ExtensionManager::class, function ($app) {
return new ExtensionManager(
$app->make(ModuleRepositoryInterface::class),
$app->make(PluginRepositoryInterface::class)
);
});
// 모듈 매니저 등록
$this->app->singleton(ModuleManager::class, function ($app) {
return new ModuleManager(
$app->make(ExtensionManager::class),
$app->make(ModuleRepositoryInterface::class),
$app->make(PermissionRepositoryInterface::class),
$app->make(RoleRepositoryInterface::class),
$app->make(MenuRepositoryInterface::class),
$app->make(TemplateRepositoryInterface::class),
$app->make(PluginRepositoryInterface::class),
$app->make(LayoutRepositoryInterface::class),
$app->make(LayoutExtensionService::class)
);
});
// 플러그인 매니저 등록
$this->app->singleton(PluginManager::class, function ($app) {
return new PluginManager(
$app->make(ExtensionManager::class),
$app->make(PluginRepositoryInterface::class),
$app->make(PermissionRepositoryInterface::class),
$app->make(RoleRepositoryInterface::class),
$app->make(TemplateRepositoryInterface::class),
$app->make(ModuleRepositoryInterface::class),
$app->make(LayoutRepositoryInterface::class),
$app->make(LayoutExtensionService::class)
);
});
// 템플릿 매니저 등록
$this->app->singleton(TemplateManager::class, function ($app) {
return new TemplateManager(
$app->make(ExtensionManager::class),
$app->make(TemplateRepositoryInterface::class),
$app->make(LayoutRepositoryInterface::class),
$app->make(ModuleRepositoryInterface::class),
$app->make(PluginRepositoryInterface::class),
$app->make(LayoutExtensionService::class)
);
});
// 템플릿 매니저 인터페이스 바인딩
$this->app->singleton(TemplateManagerInterface::class, function ($app) {
return $app->make(TemplateManager::class);
});
}
/**
* 코어 서비스들을 부트스트랩합니다.
*/
public function boot(): void
{
// 코어 훅 리스너 자동 발견 및 등록 (환경 무관)
$this->registerCoreHookListeners();
// 시스템 라우트 주입 (프리뷰 등 코어 전역 라우트)
$this->registerSystemRouteFilters();
// .env 파일이 없으면 스킵 (인스톨러 실행 전)
if (! File::exists(base_path('.env'))) {
return;
}
// DB 연결 유효성 검증 (최고권한 계정 접속 방지)
if (! $this->isDatabaseConnectionValid()) {
Log::error('Database connection invalid: using a privileged database account or missing credentials. Skipping extension loading.');
return;
}
// 콘솔 환경에서는 템플릿 로딩 건너뛰기 (성능 최적화)
$skipTemplateLoading = $this->app->runningInConsole() &&
! in_array(request()->server('argv')[1] ?? '', ['serve', 'test']);
// 모듈 로드 및 버전 검증
$moduleManager = $this->app->make(ModuleManager::class);
$moduleManager->loadModules();
$this->validateAndDeactivateIncompatibleExtensions($moduleManager, 'modules');
// 코어 업그레이드 후 재호환된 모듈 감지 (자동 재활성화 없음, 알림만 저장)
$this->detectRecoveredExtensions('modules');
// 모듈 환경설정 로딩 (활성화된 모듈만)
$this->loadModuleSettingsToConfig($moduleManager);
// 플러그인 로드 및 버전 검증
$pluginManager = $this->app->make(PluginManager::class);
$pluginManager->loadPlugins();
$this->validateAndDeactivateIncompatibleExtensions($pluginManager, 'plugins');
$this->detectRecoveredExtensions('plugins');
// 플러그인 환경설정 로딩 (활성화된 플러그인만)
$this->loadPluginSettingsToConfig($pluginManager);
// 확장 드라이버 Config 적용: 유효성 검증 + 폴백 + 훅 발행
$this->applyExtensionDriverConfigs();
// 템플릿 로드 (콘솔 환경에서는 조건부 실행)
if (! $skipTemplateLoading) {
$templateManager = $this->app->make(TemplateManager::class);
$templateManager->loadTemplates();
$this->validateAndDeactivateIncompatibleTemplates($templateManager);
$this->detectRecoveredExtensions('templates');
}
// 동적 훅 리스너 일괄 실행 (registerDynamicHooks 메서드를 가진 리스너)
$this->registerDeferredDynamicHooks();
// 활성 모듈/플러그인이 선언한 IDV purpose 를 Manager 레지스트리에 수집
// (DB 저장 없음 — 런타임 계약)
$this->collectDeclaredIdentityPurposes($moduleManager, $pluginManager);
// 모듈/플러그인 로드가 끝나 모듈 IDV 정책이 모두 identity_policies 에 적재된 뒤,
// hook scope 정책 target 에 enforce 구독을 멱등 (재)바인딩한다.
// 코어 리스너 자동발견(registerCoreHookListeners, boot 전반부)은 모듈 로드보다 먼저
// 일어나므로, 모듈 hook target(예: 결제 직전 가드)이 그 시점엔 누락될 수 있다.
// 이 호출이 누락분을 보충한다(이미 바인딩된 target 은 멱등 스킵 — 이중 enforce 없음).
EnforceIdentityPolicyListener::syncDynamicHookSubscriptions();
}
/**
* 활성 모듈·플러그인이 `getIdentityPurposes()` 로 선언한 purpose 들을
* `IdentityVerificationManager` 에 일괄 등록합니다.
*
* DB 에 저장되지 않으며, 매 요청 부팅 시 수집됩니다 (코드 계약).
*/
private function collectDeclaredIdentityPurposes(ModuleManager $moduleManager, PluginManager $pluginManager): void
{
try {
$manager = $this->app->make(IdentityVerificationManager::class);
} catch (\Throwable) {
return;
}
// 코어 purpose 메타데이터: config/core.php 가 SSoT
$corePurposes = config('core.identity_purposes', []);
if (is_array($corePurposes) && ! empty($corePurposes)) {
$manager->registerDeclaredPurposes($corePurposes, 'core', 'core');
}
foreach ($moduleManager->getActiveModules() as $module) {
if (method_exists($module, 'getIdentityPurposes')) {
$purposes = $module->getIdentityPurposes();
if (is_array($purposes) && ! empty($purposes)) {
$manager->registerDeclaredPurposes(
$purposes,
'module',
method_exists($module, 'getIdentifier') ? $module->getIdentifier() : null,
);
}
}
}
foreach ($pluginManager->getActivePlugins() as $plugin) {
if (method_exists($plugin, 'getIdentityPurposes')) {
$purposes = $plugin->getIdentityPurposes();
if (is_array($purposes) && ! empty($purposes)) {
$manager->registerDeclaredPurposes(
$purposes,
'plugin',
method_exists($plugin, 'getIdentifier') ? $plugin->getIdentifier() : null,
);
}
}
}
}
/**
* 데이터베이스 연결이 유효한지 검증합니다.
*
* DB 최고권한 계정으로 접속하거나 사용자명이 비어 있는 경우를 감지하여
* 잘못된 설정으로 인한 접속 오류를 방지합니다.
* 계정 판정은 `PrivilegedDatabaseAccounts` 가 SSoT 입니다.
*
* @return bool 연결이 유효하면 true
*/
protected function isDatabaseConnectionValid(): bool
{
try {
// read/write 분리 설정의 우선순위 규칙까지 PrivilegedDatabaseAccounts 가 소유한다.
// 호출처마다 규칙이 달라지면 같은 설정에 서로 다른 판정이 나온다.
$username = PrivilegedDatabaseAccounts::resolveUsername(DB::connection()->getConfig());
return PrivilegedDatabaseAccounts::isUsable($username);
} catch (\Throwable $e) {
Log::error('Database configuration check failed: '.$e->getMessage());
return false;
}
}
/**
* 호환되지 않는 확장(모듈/플러그인)을 자동 비활성화합니다.
*
* 코어 버전 업데이트 후 기존 확장이 새 코어 버전과 호환되지 않는 경우
* 자동으로 비활성화하고 로그를 기록합니다.
*
* @param ModuleManager|PluginManager $manager 확장 매니저
* @param string $type 확장 타입 (modules, plugins)
*/
protected function validateAndDeactivateIncompatibleExtensions($manager, string $type): void
{
// 코어 업데이트 진행 중에는 자동 비활성화 스킵.
//
// 업데이트는 다음 순서로 진행된다:
// 1. 코어 파일 교체 (디스크)
// 2. 업그레이드 스텝 실행 (spawn 프로세스)
// 3. 번들 확장 일괄 업데이트 (spawn 내부, 파일 교체 + DB sync)
// 4. .env APP_VERSION 갱신
//
// 3단계 동안 spawn 프로세스가 부팅될 때, 코어는 이미 신버전이지만 확장의 manifest 는
// 아직 구버전이거나 그 반대. 버전 기반 호환성 판정을 수행하면 일시적 불일치로 전 확장이
// 자동 비활성화되며, updateModule 이 `previousStatus` 를 잘못 캡처해 영구 비활성 상태로
// 복원된다. 업데이트 중에는 판정 자체가 의미 없으므로 전면 skip 한다.
//
// `G7_UPDATE_IN_PROGRESS` 는 CoreUpdateCommand 가 시작 시 설정하고 spawn 의 `$env` 로
// 전파된다. 업데이트가 종료되면 부모 프로세스 종료와 함께 env 도 소멸.
if (self::isCoreUpdateInProgress()) {
return;
}
$cache = $this->app->make(CacheInterface::class);
$cacheKey = CoreVersionChecker::getCacheKey($type);
// 캐시가 있으면 이미 검증된 것으로 간주
if ($cache->has($cacheKey)) {
return;
}
$deactivated = [];
$activeMethod = $type === 'modules' ? 'getActiveModules' : 'getActivePlugins';
$deactivateMethod = $type === 'modules' ? 'deactivateModule' : 'deactivatePlugin';
foreach ($manager->$activeMethod() as $identifier => $extension) {
$requiredVersion = $extension->getRequiredCoreVersion();
if (! CoreVersionChecker::isCompatible($requiredVersion)) {
// 자동 비활성화: reason='incompatible_core' + 요구 버전 전달
// (수동 비활성화와 DB 레벨 구분 → UI 라벨링 / 알림 영속화 / 재호환 복구 판정)
$manager->$deactivateMethod(
$identifier,
false,
DeactivationReason::IncompatibleCore->value,
$requiredVersion
);
$deactivated[] = [
'identifier' => $identifier,
'required' => $requiredVersion,
];
Log::warning(__('extensions.warnings.auto_deactivated'), [
'type' => $type,
'identifier' => $identifier,
'required_version' => $requiredVersion,
'core_version' => config('app.version'),
]);
}
}
// 비활성화된 확장이 있으면 세션에 저장 (관리자 알림용)
if (! empty($deactivated)) {
$this->storeDeactivatedExtensionsAlert($type, $deactivated);
}
$cache->put($cacheKey, true, CoreVersionChecker::getCacheTtl());
}
/**
* 코어 업그레이드 후 재호환된 자동 비활성화 확장을 감지합니다.
*
* 자동 재활성화는 수행하지 않습니다 — 사용자 명시적 복구 (recover 엔드포인트) 만 허용.
* 결과는 `ext.recovery_check.{type}.{coreVersion}` 캐시에 저장되어
* `ExtensionCompatibilityAlertListener` 가 대시보드 알림으로 표시합니다.
*
* @param string $type 확장 타입 (modules|plugins|templates)
*/
protected function detectRecoveredExtensions(string $type): void
{
// 코어 업데이트 진행 중에는 스킵 (validateAndDeactivate 와 동일 정책)
if (self::isCoreUpdateInProgress()) {
return;
}
$cache = $this->app->make(CacheInterface::class);
$cacheKey = ExtensionCompatibilityAlertListener::RECOVERY_CACHE_PREFIX
.$type.'.'.CoreVersionChecker::getCoreVersion();
// 이미 감지된 결과가 있으면 재계산 스킵 (TTL 1시간 + 코어 버전 변경 시 키 자체가 바뀜)
if ($cache->has($cacheKey)) {
return;
}
$repo = match ($type) {
'modules' => $this->app->make(ModuleRepositoryInterface::class),
'plugins' => $this->app->make(PluginRepositoryInterface::class),
'templates' => $this->app->make(TemplateRepositoryInterface::class),
default => null,
};
if (! $repo) {
return;
}
$recovered = [];
foreach ($repo->findAutoDeactivated() as $record) {
$required = $record->incompatible_required_version;
if ($required && CoreVersionChecker::isCompatible($required)) {
$recovered[] = [
'identifier' => $record->identifier,
'previously_required' => $required,
'deactivated_at' => $record->deactivated_at,
];
}
}
$cache->put($cacheKey, $recovered, CoreVersionChecker::getCacheTtl());
}
/**
* 호환되지 않는 템플릿을 자동 비활성화합니다.
*
* 코어 버전 업데이트 후 템플릿이 새 코어 버전과 호환되지 않는 경우
* 자동으로 비활성화하고 로그를 기록합니다.
*
* @param TemplateManager $templateManager 템플릿 매니저
*/
/**
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
*
* 판정 조건 (OR):
* 1. 환경변수 `G7_UPDATE_IN_PROGRESS=1` — 부모 CoreUpdateCommand 가 시작 시 설정,
* spawn 자식에도 `$env` 로 전파
* 2. artisan command 이름이 `core:update` / `core:execute-upgrade-steps` — 1 이 전파되지
* 않은 극단 상황 대비 보조 판정
*
* 둘 중 하나라도 true 면 업데이트 컨텍스트로 간주하여 version-based 자동 비활성화 스킵.
*/
public static function isCoreUpdateInProgress(): bool
{
$envFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
if ($envFlag === '1' || $envFlag === 1 || $envFlag === true) {
return true;
}
$argv = $_SERVER['argv'] ?? [];
$command = $argv[1] ?? '';
return in_array($command, ['core:update', 'core:execute-upgrade-steps'], true);
}
protected function validateAndDeactivateIncompatibleTemplates(TemplateManager $templateManager): void
{
// 업데이트 중 자동 비활성화 스킵 (validateAndDeactivateIncompatibleExtensions 와 동일 사유)
if (self::isCoreUpdateInProgress()) {
return;
}
$cache = $this->app->make(CacheInterface::class);
$cacheKey = CoreVersionChecker::getCacheKey('templates');
// 캐시가 있으면 이미 검증된 것으로 간주
if ($cache->has($cacheKey)) {
return;
}
$deactivated = [];
$allTemplates = $templateManager->getAllTemplates();
$templateRepository = $this->app->make(TemplateRepositoryInterface::class);
foreach ($allTemplates as $identifier => $template) {
// 설치되고 활성화된 템플릿만 검증
$templateRecord = $templateRepository->findByIdentifier($identifier);
if (! $templateRecord || $templateRecord->status !== 'active') {
continue;
}
$requiredVersion = $template['g7_version'] ?? null;
if (! CoreVersionChecker::isCompatible($requiredVersion)) {
// 자동 비활성화: reason='incompatible_core' + 요구 버전 전달
$templateManager->deactivateTemplate(
$identifier,
DeactivationReason::IncompatibleCore->value,
$requiredVersion
);
$deactivated[] = [
'identifier' => $identifier,
'required' => $requiredVersion,
];
Log::warning(__('extensions.warnings.auto_deactivated'), [
'type' => 'templates',
'identifier' => $identifier,
'required_version' => $requiredVersion,
'core_version' => config('app.version'),
]);
}
}
// 비활성화된 템플릿이 있으면 세션에 저장 (관리자 알림용)
if (! empty($deactivated)) {
$this->storeDeactivatedExtensionsAlert('templates', $deactivated);
}
$cache->put($cacheKey, true, CoreVersionChecker::getCacheTtl());
}
/**
* 비활성화된 확장 알림을 캐시에 저장합니다.
*
* 관리자 대시보드에서 비활성화된 확장에 대한 알림을 표시하기 위해
* 캐시에 정보를 저장합니다.
*
* @param string $type 확장 타입 (modules, plugins, templates)
* @param array $deactivated 비활성화된 확장 목록
*/
protected function storeDeactivatedExtensionsAlert(string $type, array $deactivated): void
{
$cache = $this->app->make(CacheInterface::class);
$alerts = $cache->get('ext.compatibility_alerts', []);
$alerts[$type] = [
'deactivated' => $deactivated,
'core_version' => config('app.version'),
'timestamp' => now()->toIso8601String(),
];
$cache->put('ext.compatibility_alerts', $alerts, 86400); // 24시간
}
/**
* 활성화된 모듈의 환경설정을 Config에 로드합니다.
*
* ModuleSettingsInterface를 구현한 서비스가 바인딩된 모듈의 경우
* getAllSettings()를 호출하여 g7_settings.modules.{identifier}에 저장합니다.
*
* @param ModuleManager $moduleManager 모듈 매니저
*/
protected function loadModuleSettingsToConfig(ModuleManager $moduleManager): void
{
$moduleSettings = [];
foreach (array_keys($moduleManager->getActiveModules()) as $identifier) {
try {
// 모듈별 환경설정 서비스 조회
$settingsService = $this->resolveModuleSettingsService($identifier);
if ($settingsService instanceof ModuleSettingsInterface) {
$settings = $settingsService->getAllSettings();
if (! empty($settings)) {
$moduleSettings[$identifier] = $settings;
}
}
} catch (\Throwable $e) {
Log::warning("모듈 환경설정 로딩 실패: {$identifier}", [
'error' => $e->getMessage(),
]);
}
}
Config::set('g7_settings.modules', $moduleSettings);
}
/**
* 모듈의 환경설정 서비스를 찾아 인스턴스화합니다.
*
* 다음 순서로 설정 서비스를 찾습니다:
* 1. 인터페이스 바인딩: Modules\Vendor\Module\Contracts\ModuleSettingsServiceInterface
* 2. 구체 클래스: Modules\Vendor\Module\Services\ModuleSettingsService
*
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
* @return ModuleSettingsInterface|null 설정 서비스 인스턴스
*/
protected function resolveModuleSettingsService(string $identifier): ?ModuleSettingsInterface
{
// vendor-module 형식을 네임스페이스로 변환
$parts = explode('-', $identifier);
if (count($parts) < 2) {
return null;
}
$vendor = ucfirst($parts[0]);
$moduleName = ucfirst($parts[1]);
// 1. 인터페이스 바인딩 확인
$interfaceClass = "Modules\\{$vendor}\\{$moduleName}\\Contracts\\{$moduleName}SettingsServiceInterface";
if ($this->app->bound($interfaceClass)) {
$service = $this->app->make($interfaceClass);
if ($service instanceof ModuleSettingsInterface) {
return $service;
}
}
// 2. 구체 클래스 확인
$concreteClass = "Modules\\{$vendor}\\{$moduleName}\\Services\\{$moduleName}SettingsService";
if (class_exists($concreteClass)) {
$service = $this->app->make($concreteClass);
if ($service instanceof ModuleSettingsInterface) {
return $service;
}
}
return null;
}
/**
* 활성화된 플러그인의 환경설정을 Config에 로드합니다.
*
* storage/app/plugins/{identifier}/settings/setting.json 파일을 읽어
* g7_settings.plugins.{identifier}에 저장합니다.
*
* @param PluginManager $pluginManager 플러그인 매니저
*/
protected function loadPluginSettingsToConfig(PluginManager $pluginManager): void
{
$pluginSettings = [];
foreach (array_keys($pluginManager->getActivePlugins()) as $identifier) {
try {
$settingsPath = storage_path("app/plugins/{$identifier}/settings/setting.json");
if (File::exists($settingsPath)) {
$content = File::get($settingsPath);
$settings = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE && ! empty($settings)) {
$pluginSettings[$identifier] = $settings;
}
}
} catch (\Throwable $e) {
Log::warning("플러그인 환경설정 로딩 실패: {$identifier}", [
'error' => $e->getMessage(),
]);
}
}
Config::set('g7_settings.plugins', $pluginSettings);
}
/**
* 확장 드라이버 Config을 적용합니다.
*
* 플러그인이 필터 훅으로 등록한 드라이버의 유효성을 검증하고,
* 사용 가능하면 액션 훅을 발행하여 Config을 적용합니다.
* 사용 불가능하면 기본 드라이버로 안전하게 폴백합니다.
*
* SettingsServiceProvider::register()에서 코어 드라이버 Config은 이미 적용된 상태이므로,
* 여기서는 플러그인 드라이버만 처리합니다.
*/
protected function applyExtensionDriverConfigs(): void
{
try {
$driverRegistry = $this->app->make(DriverRegistryService::class);
$configRepository = $this->app->make(JsonConfigRepository::class);
foreach ($driverRegistry->getCategories() as $category) {
$settingsKey = $driverRegistry->getSettingsKey($category);
if ($settingsKey === null) {
continue;
}
$settings = $configRepository->getCategory($settingsKey['category']);
$selectedDriver = $settings[$settingsKey['key']] ?? '';
if (empty($selectedDriver)) {
continue;
}
// 코어 드라이버는 SettingsServiceProvider::register()에서 이미 적용됨
if ($driverRegistry->isCoreDriver($category, $selectedDriver)) {
continue;
}
// 플러그인 드라이버: 사용 가능 여부 확인
if ($driverRegistry->isDriverAvailable($category, $selectedDriver)) {
// 플러그인이 Config을 직접 적용하도록 액션 훅 발행
HookManager::doAction(
'core.settings.apply_driver_config',
$category,
$selectedDriver,
$settings
);
} else {
// 플러그인 드라이버가 사용 불가 → 기본 드라이버로 폴백
$defaultDriver = $driverRegistry->getDefaultDriver($category);
$configKey = $driverRegistry->getConfigKey($category);
if ($configKey && $defaultDriver) {
Config::set($configKey, $defaultDriver);
}
Log::warning("플러그인 드라이버 '{$selectedDriver}'가 '{$category}' 카테고리에서 사용 불가능합니다. 기본 드라이버 '{$defaultDriver}'로 폴백합니다.");
}
}
} catch (\Throwable $e) {
Log::warning('확장 드라이버 Config 적용 실패', [
'error' => $e->getMessage(),
]);
}
}
// registerActivityLogManager() 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체
private function registerCoreHookListeners(): void
{
// 캐시 우선: bootstrap/cache/hooks.php 가 있으면 스캔·리플렉션 없이 등록.
// 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔).
if (! $this->app->environment('testing') && $this->registerCoreHookListenersFromCache()) {
return;
}
$listenersPath = app_path('Listeners');
if (! is_dir($listenersPath)) {
return;
}
// 재귀적으로 모든 PHP 파일 스캔
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
// 파일 경로에서 클래스명 추출
$relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
$relativePath = str_replace('.php', '', $relativePath);
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$listenerClass = 'App\\Listeners\\'.$relativePath;
// 클래스 존재 여부 확인
if (! class_exists($listenerClass)) {
Log::warning("코어 훅 리스너 클래스를 찾을 수 없습니다: {$listenerClass}");
continue;
}
// HookListenerInterface 구현 여부 확인
if (! in_array(HookListenerInterface::class, class_implements($listenerClass))) {
continue;
}
$this->registerCoreHookListener($listenerClass);
}
}
/**
* 훅 캐시에서 코어 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)이 존재하면 디렉토리 스캔·class_implements
* 리플렉션·getSubscribedHooks() 클래스 로딩 없이 사전 계산 매핑으로 등록한다.
* 동적 훅(registerDynamicHooks) 보유 리스너는 스캔 경로와 동일하게 boot 후반부 실행을 위해 지연 목록에 담는다.
*
* @return bool 캐시로 등록했으면 true, 캐시 부재/손상 시 false (스캔 폴백)
*/
private function registerCoreHookListenersFromCache(): bool
{
$cache = $this->app->make(HookCacheManager::class)->read();
if ($cache === null) {
return false;
}
foreach ($cache['core'] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], 'core');
if (! empty($entry['dynamic'])) {
$this->deferredDynamicListeners[] = $entry['listener'];
}
} catch (\Throwable $e) {
Log::error('코어 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 단일 코어 리스너를 HookManager에 등록합니다.
*
* @param string $listenerClass 리스너 클래스명
*/
private function registerCoreHookListener(string $listenerClass): void
{
try {
HookListenerRegistrar::register($listenerClass, 'core');
// registerDynamicHooks() 메서드를 가진 리스너는 boot 후반부에서 실행
if (method_exists($listenerClass, 'registerDynamicHooks')) {
$this->deferredDynamicListeners[] = $listenerClass;
}
} catch (\Exception $e) {
Log::error('코어 훅 리스너 등록 중 오류 발생', [
'listener' => $listenerClass,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
/**
* registerDynamicHooks() 메서드를 가진 리스너들의 동적 훅을 일괄 등록합니다.
*
* DB 접근이 필요하므로 boot() 후반부(DB 유효성 검증 후)에서 호출됩니다.
*/
private function registerDeferredDynamicHooks(): void
{
foreach ($this->deferredDynamicListeners as $listenerClass) {
try {
$listener = app($listenerClass);
$listener->registerDynamicHooks();
// 등록 성공은 로그로 남기지 않는다 — 요청마다 부팅되는 경로다. 실패만 아래에 남긴다.
} catch (\Throwable $e) {
Log::warning('동적 훅 리스너 등록 실패', [
'listener' => $listenerClass,
'error' => $e->getMessage(),
]);
}
}
}
/**
* 시스템 전역 라우트를 주입하는 필터를 등록합니다.
*
* core.routes.filter_merged 필터를 통해 프리뷰 등 코어 시스템 라우트를
* 모든 템플릿의 routes.json 응답에 자동 주입합니다.
*/
private function registerSystemRouteFilters(): void
{
HookManager::addFilter('core.routes.filter_merged', function (array $routes, string $templateType, string $identifier) {
$basePath = $templateType === 'admin' ? '*/admin' : '*';
$routes[] = [
'path' => "{$basePath}/preview/:token",
'layout' => '__preview__',
'auth_required' => false,
'meta' => [
'title' => 'Preview',
'is_system_route' => true,
],
];
return $routes;
}, 100);
}
}