매뉴얼 목록
MUBLO MANUAL

mublo 코어 아키텍처 가이드

코어 코드가 어떻게 짜여 있는지 — 요청 흐름·핵심 클래스·서브시스템·확장 지점을 설명합니다. 코어를 읽거나 유지보수하려는 개발자용.

이 가이드에 대하여

이 가이드는 mublo 코어가 실제로 어떻게 짜여 있는지를 설명한다. 확장(플러그인/패키지)을 만드는 법이 아니라, src/ 아래의 클래스들이 하나의 HTTP 요청을 어떤 순서로 처리하는지, 각 책임이 어느 파일에 있는지를 짚어 준다. 코드를 처음 여는 사람이 "이 기능은 이 파일을 보면 된다"를 알 수 있도록 이정표를 주는 것이 목적이다.

실행의 중심 — Application

모든 요청은 src/Core/App/Application.php 한 곳으로 수렴한다. Application은 진입점이자 실행 흐름의 지휘자로, 스스로 비즈니스 로직을 두지 않고 각 단계를 전담 객체에 위임한다. 실제로 처리되는 큰 흐름은 다음과 같다.

Request → Context → (Extensions) → Middleware → Router → Dispatcher → Response → Rendering

Application::run()의 주석에도 축약된 형태(Request → Context → Router → Dispatcher → Response → Rendering / Output)로 흐름이 적혀 있으니 참고하라. 그에 앞서 Application::boot()이 환경변수·에러핸들러·ServiceProvider·이벤트 시스템을 준비하고, run() 본문이 위 순서를 차례로 돌린다. 참고로 코어 버전은 Application::VERSION 상수가 단일 진실원천(SSOT)이다.

요청 흐름과 담당 파일

단계 책임 주요 파일
Request전역 변수 수집·위험 경로 차단src/Core/Http/Request.php
Context요청 해석 결과(도메인·Front/Admin/Api·스킨) 보관src/Core/Context/Context.php, ContextBuilder.php
Extension활성 플러그인/패키지 로딩·Provider bootsrc/Core/Extension/ExtensionManager.php
Middleware세션·CSRF·보안헤더·인증src/Core/Middleware/MiddlewarePipeline.php
RouterURL → Controller/Method 결정(FastRoute)src/Core/App/Router.php
DispatcherController 생성·Action 호출(DI 주입)src/Core/App/Dispatcher.php
RenderingViewResponse → 화면 조립src/Core/Rendering/FrontViewRenderer.php, AdminViewRenderer.php

관통하는 두 축 — 컨테이너와 이벤트

흐름 전체를 지탱하는 두 기반 시설이 있다. 하나는 src/Core/Container/DependencyContainer.php로, PSR-11 경량 DI 컨테이너다. 명시적 등록을 우선하고 Service 네임스페이스만 auto-wiring 한다(Controller/Context/Response는 auto-wiring 금지). Dispatcher가 Controller 생성자와 Action 파라미터를 Reflection으로 분석해 여기서 의존성을 채운다.

// Dispatcher::invokeAction — Context/라우트 파라미터 자동 주입
public function index(Context $context) { ... }
public function show(array $params, Context $context) { ... }

다른 하나는 src/Core/Event/EventDispatcher.php다. 코어는 확장점마다 이벤트를 발행하고 확장은 이를 구독해 개입한다. 예로 SiteContextReadyEvent·RendererResolveEventsrc/Core/Event/Rendering/ 하위에, RequestInterceptEventsrc/Core/Event/ 바로 아래에 있다. 코어와 확장의 계약은 src/Contract/ 인터페이스 + src/Core/Registry/ContractRegistry.php(1:1 bind / 1:N register)로 이어진다.

src/ 디렉토리 지도

  • src/Core/ — 프레임워크 뼈대. 위 표의 App·Context·Middleware·Rendering·Container·Event·Extension·Registry가 모두 여기 있다.
  • src/Controller/Admin/, Front/, Api/로 나뉜 진입 컨트롤러.
  • src/Service/ — 비즈니스 로직 계층(auto-wiring 대상).
  • src/Repository/ · src/Entity/ — 데이터 접근과 도메인 객체.
  • src/Infrastructure/ — DB·캐시·로그·보안 등 하부 기술.
  • src/Contract/ — 확장이 구현하는 표준 인터페이스.
  • src/Helper/ · src/Enum/ · src/Exception/ · src/Subscriber/ — 보조 유틸·상수·예외·이벤트 구독자.

읽는 순서 팁: Application::run()을 위에서 아래로 한 번 훑으면 전체 뼈대가 잡힌다. 이후 관심 단계(예: 화면 조립이면 FrontViewRenderer)로 내려가면 된다. 각 코어 클래스 상단 주석에 "책임"과 "금지"가 명시돼 있으니, 그 파일이 무엇을 하고 무엇을 하지 않는지 먼저 확인하라.

레이어와 디렉토리 지도

mublo 코어는 하나의 PSR-4 네임스페이스 Mublo\ 아래에 산다. composer.json 의 autoload 를 보면 코어는 "Mublo\\": "src/" 에 매핑된다(그 밑의 Mublo\Plugin\ → plugins/, Mublo\Packages\ → packages/ 는 확장용 별도 매핑이다). 그래서 클래스명 Mublo\Repository\Member\MemberRepository 는 곧 파일 src/Repository/Member/MemberRepository.php 다. 이 규칙만 알면 어떤 코어 클래스든 파일을 즉시 찾을 수 있다.

레이어 한눈에 보기

src/ 의 최상위 디렉토리가 그대로 레이어다. 각 디렉토리는 책임이 분리되어 있고, 위에서 아래로 의존한다(엔티티·인프라는 아무에게도 의존하지 않는 바닥층).

디렉토리책임대표 파일
src/Core프레임워크 뼈대 — 진입점, 라우팅, DI, 렌더링, 이벤트, 확장 로딩. 비즈니스 로직 없음.Core/App/Application.php, Core/Container/DependencyContainer.php
src/Service도메인별 비즈니스 로직. 컨트롤러가 호출하고, 리포지토리를 조합한다.Service/Member/MemberService.php
src/RepositoryDB 접근 · CRUD · Entity 매핑. 여기서만 SQL 을 다룬다.Repository/BaseRepository.php
src/EntityDB 행을 담는 데이터 객체. toArray/fromArray 등 순수 로직만.Entity/BaseEntity.php
src/Contract코어와 확장 사이의 인터페이스(계약). 코어가 구현, 확장이 소비.Contract/Site/CompanyInfoInterface.php
src/Infrastructure외부 세계 어댑터 — DB 드라이버, 캐시, 메일, 세션, 스토리지, 로그.Infrastructure/Database/Database.php

이 밖에 src/Controller(HTTP 요청 처리 — Admin·Api·Front), src/Enum·src/Exception·src/Helper(전역 함수는 files autoload 로 지정된 src/Helper/EnvHelpers.php 하나가 로드되고, 나머지 Helper 클래스는 PSR-4 로 로드), src/Subscriber(이벤트 구독자)가 보조 레이어로 있다.

실행 흐름의 중심

모든 요청은 Application 을 통과한다. 클래스 주석이 그 책임을 못 박아 둔다 — Request 생성, Context 생성, Router/Dispatcher 흐름 제어, Response 출력 위임. 반대로 비즈니스 로직 · DB 직접 접근 · 인증 판단은 여기서 금지다. 코어 버전도 여기 한 곳에 있다.

// src/Core/App/Application.php
class Application
{
    public const VERSION = '1.0.0'; // 코어 버전의 단일 진실원천(SSOT)
    // Request → Context → Router/Dispatcher → Response
}

Repository·Entity 짝

DB 를 만지는 코드를 찾으면 Repository 로 가면 된다. 모든 리포지토리는 BaseRepository(RepositoryInterface 구현)를 상속하고, $table$entityClass 두 속성만 지정하면 표준 CRUD(find·all·findBy)·페이지네이션(paginate)·Entity 매핑을 물려받는다.

class MemberRepository extends BaseRepository
{
    protected string $table = 'members';
    protected string $entityClass = Member::class;
}

계약을 읽어야 할 때: 확장이 코어 데이터를 읽는 통로는 언제나 src/Contract 아래 인터페이스다. 예로 CompanyInfoInterface 는 코어가 DI 컨테이너에 단일 구현을 제공하고, 확장은 생성자 타입힌트로 주입만 받는다. 코어 내부 구현을 직접 new 하지 말고 계약을 먼저 찾아라.

객체는 어떻게 조립되나

레이어들을 실제로 이어 붙이는 것은 DependencyContainer(경량 PSR-11)다. 명시적 등록(set/singleton/factory)만 has() 로 인정하며, 자동 해석은 Service 네임스페이스만 Reflection 으로 auto-wiring 한다. Controller · Context · Response 의 자동 해석과 컨테이너 안의 비즈니스 로직은 금지다. 클래스가 어디서 조립되는지 궁금하면 src/Core/Provider/ServiceProvider.php 와 이 컨테이너를 보면 된다.

요청 한 번의 여정

브라우저의 요청 하나가 코어를 통과해 HTML이 되어 나가기까지, 처리의 뼈대는 public/index.php 가 부트스트랩한 Mublo\Core\App\Application 한 곳에서 지휘된다. 흐름을 따라가고 싶다면 src/Core/App/Application.phprun() 부터 열면 된다. 이 메서드가 전체 여정의 목차다.

1. Application::run() — 여정의 지휘자

run() 은 순서대로 이렇게 움직인다.

  • Request 생성createRequest(). $_SERVER/$_GET/$_POST 같은 전역 변수 접근은 오직 이 메서드 안에서만 한다. 널바이트·인코딩된 구분자(%00·%2F)가 섞인 경로는 여기서 setInvalid() 로 표시해 두고, run()isInvalid() 로 확인해 400으로 조기 차단한다.
  • Context 생성createContext()ContextBuilder 로 요청을 "애플리케이션 상태"(Mublo\Core\Context\Context)로 해석한다. 도메인·관리자 여부·스킨이 여기 담긴다. 이후 컨테이너에 등록되어 어디서나 주입받을 수 있다.
  • 도메인 검증 → 확장 로딩validateDomain()loadEnabledExtensions() 가 해당 도메인에 활성화된 Plugin/Package만 로딩한다.

2. 전역 Middleware 파이프라인

라우팅 이전에 모든 요청 공통 미들웨어가 감싼다. run()MiddlewarePipeline 에 세 개를 태운다.

$globalPipeline = new MiddlewarePipeline($this->container);
$globalPipeline->through([
    SecurityHeadersMiddleware::class,
    SessionMiddleware::class,
    CsrfMiddleware::class,
]);
$response = $globalPipeline->run($request, $context, function ($request, $context) {
    // ... 여기 안에서 Router → Dispatcher 실행
});

파이프라인의 구현은 src/Core/Middleware/MiddlewarePipeline.phprun() 에 있다. array_reduce 로 미들웨어를 양파 껍질(onion) 처럼 중첩해, 각 미들웨어의 handle($request, $context, $next)$next 를 호출하며 안쪽으로 파고든다. 모든 미들웨어는 Mublo\Core\Middleware\MiddlewareInterface 를 구현한다.

3. Router — URL을 Controller로

파이프라인의 가장 안쪽 콜백에서 Mublo\Core\App\Router::dispatch()(src/Core/App/Router.php)가 실행된다. FastRoute 기반으로, 코어 라우트(registerCoreRoutes())에 더해 도메인별 활성 확장의 routes.phpPrefixedRouteCollector 로 접두사를 붙여 등록한다. 반환값은 실행 대상을 가리키는 배열이다.

return [
    'controller' => $routeInfo[1]['controller'], // FQCN
    'method'     => $routeInfo[1]['method'],
    'params'     => array_merge($routeInfo[1]['defaults'] ?? [], $routeInfo[2] ?? []),
    'middleware' => $routeInfo[1]['middleware'] ?? [],
];
Router는 "무엇을 실행할지"만 결정한다. Controller를 직접 실행하거나 인증을 판단하지 않는다 — 그건 Dispatcher와 Middleware의 몫이다.

4. Dispatcher — Controller 실행

Mublo\Core\App\Dispatcher::dispatch()(src/Core/App/Dispatcher.php)는 라우트 배열을 받아 Controller 인스턴스를 생성(생성자 DI, createController())하고, 라우트별 미들웨어를 다시 MiddlewarePipeline 으로 감싼 뒤 액션을 호출한다. invokeAction() 은 Reflection으로 메서드 시그니처를 읽어 Request·Context 타입과 params 를 자동 주입하므로, Controller는 필요한 것만 인자로 선언하면 된다. public·비static 메서드만 허용해 내부 메서드 노출을 막는다.

5. ViewResponse — "무엇을" 보여줄지

Controller는 HTML을 직접 만들지 않고 의도를 담은 Response를 반환한다. 대표가 Mublo\Core\Response\ViewResponse(src/Core/Response/ViewResponse.php). 생성자는 protected이고 Named Constructor로만 만든다.

return ViewResponse::view('Auth/Login')
    ->withData(['error' => $msg])
    ->fullPage();

view()(상대 경로)·absoluteView()(Plugin/Package용 절대 경로), 데이터 병합 withData(), 그리고 fullPage()/partial() 힌트가 전부다. Header/Layout/Footer를 포함할지는 ViewResponse 가 알지 못하며, 이 힌트는 "명령"이 아니라 Renderer가 참고하는 "힌트"다.

6. Renderer — "어떻게" 보여줄지

반환된 Response는 Application::handleResponse() 로 돌아온다. 타입에 따라 분기하는데, ViewResponse 면 먼저 RendererResolveEvent 를 발행해 Package/Plugin이 커스텀 렌더러를 지정할 기회를 준다. 지정이 없으면 Context::isAdmin() 으로 AdminViewRendererFrontViewRenderer(src/Core/Rendering/FrontViewRenderer.php) 중 하나를 골라 render($response, $context) 를 호출한다. FrontViewRenderer가 스킨·프레임(header/footer)·에디터 런타임·에셋을 조립해 최종 HTML을 출력하며, 여기서 여정이 끝난다.

JsonResponse·RedirectResponse·FileResponse·HtmlResponse는 Renderer를 거치지 않고 handleResponse() 안에서 각자 toJson()·헤더·send() 로 직접 응답한다. 실행 중 던져진 예외는 run()catch 가 받아 ErrorHandler 로 넘겨 404/403/500 페이지로 수렴시킨다.

기반 시스템

mublo 코어는 네 개의 저수준 시스템 위에 세워져 있다. 컨테이너는 객체를 만들고, 컨텍스트는 한 요청의 상태를 담고, 이벤트는 흐름 중간에 확장이 끼어들 지점을 열고, 레지스트리는 계약(인터페이스)과 구현체를 이어 준다. 코어 어느 파일을 보든 이 넷 중 하나에 기대고 있으므로, 여기부터 읽으면 나머지 흐름이 눈에 들어온다.

1. 컨테이너 — 객체를 어떻게 얻는가

src/Core/Container/DependencyContainer.php. 경량 PSR-11 DI 컨테이너로, getInstance() 싱글톤으로 접근한다. 등록 방식은 세 가지다.

  • set($id, $instance) — 이미 만든 객체를 직접 넣는다(예: 요청 단위 Context).
  • singleton($id, $factory) — 최초 get() 때 한 번 생성해 캐시한다.
  • factory($id, $factory) — 호출마다 새 인스턴스(상태 있는 Renderer/Router 등).

get()는 인스턴스 캐시 → 싱글톤 팩토리 → 일반 팩토리 순으로 찾고, 못 찾으면 Mublo\Service\·Mublo\Repository\ 등 허용 네임스페이스(isServiceClass())에 한해 리플렉션으로 autoResolve() 한다. 이때 생성자 인자는 전부 클래스 타입이어야 하고(스칼라·배열 불가), 순환 참조는 buildStack으로 감지해 예외를 던진다.

Controller·Context·Response는 auto-wiring 대상이 아니다. 서비스 계층만 자동 조립되고, 나머지는 명시적으로 등록해야 한다.

2. 컨텍스트 — 한 요청의 해석 결과

src/Core/Context/Context.php는 판단하지 않고 결과만 담는 상태 컨테이너다. Admin/Api 여부, 도메인(getDomainInfo()), 프레임·프론트·블록 스킨, 현재 메뉴 코드/레이아웃, 사이트 이미지 URL 등이 여기 모인다. 실제 판단은 전부 src/Core/Context/ContextBuilder.php가 맡아 Request를 분석해 setter로 채운다.

확장이 요청 흐름에 신호를 남길 때는 setAttribute('shop.is_checkout', true)처럼 네임스페이스 키를 쓴다. 단, 이 쓰기는 부팅 단계 한정이다.

// boot 단계에서만 허용
$context->setAttribute('shop.is_checkout', true);

// 이후 lockAttributes() 가 호출되면
$context->setAttribute('x', 1); // LogicException: attributes are locked

lockAttributes()는 확장 로딩 직후 불려, 부팅 이후의 속성 변경을 막는다. 읽기(getAttribute())는 잠금과 무관하게 언제든 가능하다.

3. 이벤트 — 흐름에 끼어드는 지점

src/Core/Event/EventDispatcher.php. 리스너는 addListener($eventName, $listener, $priority)로 등록하며, 이벤트명은 보통 이벤트 클래스명이다(EventInterface::getName()). 우선순위가 높을수록 먼저 실행되고, 리스너가 stopPropagation()을 부르면 dispatch() 루프가 그 자리에서 멈춘다.

$dispatcher->dispatch($event); // 처리된 $event 를 그대로 반환

안정성 설계가 핵심이다. 리스너가 Throwable을 던져도 dispatch()는 이를 로거로 흘려보내고 나머지 리스너를 계속 실행한다 — 단 \Error(TypeError 등 치명 오류)와 FailFastEventInterface 이벤트는 예외를 재-throw 한다. 한 클래스로 여러 이벤트를 구독하려면 EventSubscriberInterface::getSubscribedEvents()를 구현하고 addSubscriber()로 한 번에 등록한다. 도메인별 이벤트는 src/Core/Event/ 하위(Auth·Block·Rendering·Member 등)에 모여 있다.

4. 레지스트리 — 계약과 구현체의 결합

src/Core/Registry/ContractRegistry.php는 인터페이스(계약) FQCN을 키로 구현체를 보관해, 코어가 구현체를 몰라도 인터페이스만으로 조회하게 한다. 두 축이 있다.

용도등록 / 조회
1:1 단일 제공자bind() / resolve()본인인증, SMS
1:N 복수 제공자register() / get(), keys()결제 PG, 소셜 로그인

구현체 대신 Closure를 넘기면 resolve()/get() 시점에 lazy 생성되고, 이후 캐시된다. 이때 반환 객체가 계약을 실제로 구현하는지 instanceof로 재검증한다. register()$meta에 라벨·아이콘 등을 담아 두면 인스턴스를 만들지 않고도 allMeta()로 관리자 목록을 그릴 수 있다.

$registry->register(
    PaymentGatewayInterface::class,
    'tosspay',
    fn() => new TossPayGateway(),   // lazy
    ['label' => '토스페이']          // 인스턴스 없이 조회 가능
);
$keys = $registry->keys(PaymentGatewayInterface::class); // ['tosspay', ...]
컨테이너·이벤트·레지스트리 세 시스템은 ExtensionRegistrationScope와 맞물려, 확장이 등록한 정의·리스너·바인딩을 소유자(scope)별로 추적하고 되돌릴 수 있게 한다(recordUndo()). 확장을 껐다 켜도 코어 상태가 깨끗하게 원복되는 이유가 여기에 있다. 컨텍스트는 이 scope 대신 lockAttributes()로 부팅 이후의 변경을 막는 별도의 수명주기 장치를 쓴다.
의존성 컨테이너와 Provider

mublo 의 객체 조립은 Mublo\Core\Container\DependencyContainer 하나로 모인다. 이 클래스는 PSR-11 Psr\Container\ContainerInterface 를 구현하는 경량 컨테이너로, 서비스가 서로를 new 로 직접 만들지 않고 컨테이너에서 꺼내 쓰도록 배선한다. "어떤 클래스가 어떻게 만들어지는가"를 알고 싶으면 이 파일과 src/Core/Provider/ServiceProvider.php 두 곳만 보면 된다.

컨테이너 자체 — 등록과 조회

DependencyContainergetInstance() 로 얻는 프로세스 싱글톤이다(테스트에서는 resetInstance() 로 초기화). 등록 API 는 세 가지이며, 각각 저장소가 다르다.

  • set(string $id, $instance) — 이미 만들어진 객체를 그대로 등록(Context 같은 런타임 객체). $instances 배열에 담긴다.
  • singleton(string $id, callable $factory) — 최초 get() 때 팩토리를 한 번 실행하고 결과를 캐시. 이후 같은 인스턴스를 반환.
  • factory(string $id, callable $factory) — 매 get() 마다 새 인스턴스. Router·Dispatcher·FrontViewRenderer 처럼 상태를 가지는 객체에 쓴다.

get() 의 해석 우선순위는 코드상 고정되어 있다: (1) 캐시된 인스턴스 → (2) 싱글톤 팩토리(실행 후 캐시) → (3) 일반 팩토리(매번 새로) → (4) auto-wiring. 어디에도 없으면 NotFoundExceptionInterface 예외를 던진다.

has() 는 명시적으로 등록된 것만 true(PSR-11 규약). auto-wiring 으로 만들 수 있는지까지 포함해 확인하려면 canResolve() 를 쓴다.

Service 만 auto-wiring

명시 등록이 없어도, 대상이 "Service 클래스"면 컨테이너가 리플렉션으로 생성한다. 판별은 isServiceClass() 의 네임스페이스 화이트리스트로만 이뤄진다 — Mublo\Service\, Mublo\Infrastructure\, Mublo\Repository\, Mublo\Model\, Mublo\Core\Middleware\, Mublo\Core\Block\Renderer\, Mublo\Core\Crypto\. 이 밖(Controller·Context·Response)은 auto-wiring 하지 않는다.

실제 생성은 autoResolve() 가 담당한다. 생성자 인자를 리플렉션으로 훑어 클래스 타입 인자는 재귀적으로 get(), 스칼라/빌트인 optional 인자는 기본값을 쓴다. $buildStack 으로 순환 참조를 감지해 사슬을 담은 RuntimeException("Circular dependency detected: …") 을 던진다.

ServiceProvider — 실제 배선표

ServiceProvider::register(DependencyContainer $container) 가 코어의 모든 배선을 담는다. Infrastructure → Repository → Service → Middleware → Rendering → Router 순으로 등록하며, 인터페이스를 구현체에 연결하는 패턴이 반복된다.

// 인터페이스(계약) → 코어 구현으로 위임
$container->singleton(
    \Mublo\Contract\Block\BlockKitGatewayInterface::class,
    fn (DependencyContainer $c) => $c->get(\Mublo\Service\Block\BlockKitGateway::class)
);

// 상태 있는 렌더러는 factory 로 매번 새로
$container->factory(
    FrontViewRenderer::class,
    fn (DependencyContainer $c) => new FrontViewRenderer(
        $c->get(LayoutManager::class),
        $c->get(AuthService::class),
        $c->get(MenuService::class),
        // …
    )
);

이벤트 구독자 등록은 register() 밖에 따로 있다. EventDispatcher 가 준비된 뒤 호출되는 bootSubscribers()(코어 구독자·대시보드 위젯 등록), 그리고 Plugin/Package 로드 이후 Package 의존 서비스를 쓰는 bootPostExtensionSubscribers() 로 나뉜다. 부팅 순서를 추적할 때 이 두 메서드가 이정표다.

check-di 게이트 — 배선 규율의 강제

tools/check-di-violations.phpsrc/Controller·src/Service 를 스캔해 "컨테이너를 우회한 직접 생성"을 잡는 정적 검사다. 정규식으로 ?? new XxxService() 폴백, = new XxxRepository(, XxxManager::getInstance()·CacheFactory::getInstance() 정적 팩토리, new SessionManager( 등을 검출한다.

DTO·값 객체·엔티티·이벤트·예외(new Result(), new Member(), new \RuntimeException, new \DateTimeImmutable 등)는 $allowedPatterns 로 걸러 오탐을 줄인다. 위반이 있으면 파일·라인과 함께 출력하고 종료 코드 1 로 끝난다.

php tools/check-di-violations.php

새 Service·Repository 를 추가했다면 배선은 ServiceProvider::register() 에 넣고, 커밋 전 이 스크립트로 우회 생성이 없는지 확인한다.

Context와 멀티테넌트

mublo 는 하나의 코드베이스로 여러 도메인(사이트)을 서비스하는 도메인 기반 멀티테넌트 구조다. 한 HTTP 요청이 들어오면 "이 요청이 어느 도메인의 것이고, 관리자/API/프론트 중 무엇이며, 어떤 스킨으로 그려야 하는가"를 한 번 해석해 두고, 이후 Controller·Renderer 는 그 결과를 신뢰해서 읽기만 한다. 그 해석 결과를 담는 그릇이 Context, 해석을 수행하는 주체가 ContextBuilder, 도메인을 찾아내는 전담 서비스가 DomainResolver 다.

역할 분담: 판단은 Builder, 보관은 Context

가장 중요한 설계 원칙은 Context 는 판단하지 않고 결과만 보관한다는 것이다. src/Core/Context/Context.php 는 순수 상태 컨테이너로, DB 접근·인증·비즈니스 로직을 두지 않는다. 생성자는 Request 하나만 받고, 나머지 필드는 전부 setter 로 채워진다.

  • 영역 플래그: isAdmin(), isApi(), isFront() (isFront() 는 나머지 둘이 모두 아닐 때 true)
  • 도메인: getDomain()(호스트 문자열), getDomainInfo()(Domain 엔티티), getDomainId(), getDomainGroup()
  • 스킨 선택 결과: getAdminSkin(), getFrameSkin(), getFrontSkin($group), getBlockSkin($type)
  • 현재 메뉴: getCurrentMenuCode(), getCurrentMenuLayout()

실제 해석은 전부 src/Core/Context/ContextBuilder.phpbuild(Request $request) 안에서 순서대로 일어난다. 코드 흐름을 따라가고 싶다면 이 메서드 하나만 읽으면 된다.

public function build(Request $request): Context
{
    $context = new Context($request);

    // 1. 영역 판별 (/admin/*, /api/* 경로 prefix)
    if ($this->isAdminRequest($request)) { $context->setAdmin(true); }
    if ($this->isApiRequest($request))   { $context->setApi(true); }

    // 2. 도메인 해석
    $domainName = $request->getHost();
    $context->setDomain($domainName);
    $context->setDomainInfo($this->resolveDomain($domainName));

    // 3. 스킨 결정  4. 현재 메뉴 매칭 ...
    return $context;
}

영역 판별은 순수 경로 규약이다. isAdminRequest() 는 경로가 /admin 이거나 /admin/ 로 시작하는지, isApiRequest()/api 계열인지만 본다. API 로 판정되면 스킨 개념이 없으므로 도메인 설정 직후 곧바로 반환하고, Admin 이면 adminSkin 만 정하고 반환한다. Front 일 때만 프레임·콘텐츠·블록 스킨과 메뉴 매칭까지 진행한다.

DomainResolver: 호스트 → Domain 3단 조회

src/Service/Domain/DomainResolver.php 가 멀티테넌시의 핵심이다. resolve($domainName) 는 요청 호스트를 받아 해당 Domain 엔티티(없으면 null)를 돌려준다. 조회는 3단 계층을 거친다.

  1. 메모리 캐시static $memoryCache, 같은 요청 내 재사용
  2. 파일 캐시DomainCache, 요청 간 유지
  3. DB 조회DomainRepository::findByDomain(), 캐시 미스 시

호스트 문자열은 그대로 쓰지 않고 정규화한다. 소문자화·공백 제거 후 stripWww()www. 를 떼고, 포트 포함/미포함 후보를 순서대로 시도한다. 여기까지 실패하면 stripFirstSubdomain() 으로 첫 세그먼트를 떼어 상위 도메인으로 폴백한다(예: demo.example.co.krexample.co.kr). 개발 환경을 위해 coway.localhostlocalhost 같은 예외와 포트 유지 로직도 들어 있다.

이 폴백 덕분에 와일드카드 서브도메인을 하나의 등록 도메인에 묶을 수 있다. 반대로 "왜 서브도메인이 엉뚱한 사이트로 붙나"를 디버깅한다면 resolveByName() 의 후보 순서를 먼저 확인하라.

도메인 스코프가 이후 흐름을 결정한다

일단 domainInfo 가 확정되면 그것이 요청 전체의 테넌트 스코프가 된다. ContextBuilder::build()DomaingetThemeConfig() 로 프레임/멤버/게시판 등 영역별 스킨을 정하고, getSeoConfig()·getSiteConfig() 로 로고·파비콘 등 사이트 이미지 URL 을 만든다. 이때 DB·캐시에는 상대경로 원본을 유지하고, scheme+host 를 붙인 Full URL 변환만 요청 범위인 Context 에서 처리한다(buildImageUrl()).

메뉴 매칭도 도메인 스코프 위에서 이뤄진다. resolveCurrentMenu()getDomainId() 로 그 도메인의 메뉴 URL 맵을 캐시에서 가져와(menu:urlmap:{domainId}) 현재 요청 경로와 대조한다. 매칭 우선순위는 (1) path+query 완전 일치, (2) 쿼리 없는 path 일치, (3) 최장 prefix 일치 순이며, 확정된 menuCode 와 레이아웃 오버라이드가 Context 에 실려 이후 블록 시스템의 메뉴 스코프와 프론트 active 표시에 쓰인다.

패키지 확장 지점: 동적 속성과 잠금

Context 에는 패키지/플러그인이 요청 범위 신호를 실을 수 있는 확장 슬롯이 있다. setAttribute($key, $value) / getAttribute($key)'패키지명.속성명' 규약의 임의 값을 담는데, 쓰기는 boot 단계에서만 허용된다. Application::run() 이 확장 로드 직후 lockAttributes() 를 호출하면, 이후 setAttribute()LogicException 을 던진다. 읽기는 잠금과 무관하게 항상 가능하다.

// boot 단계
$context->setAttribute('shop.is_checkout', true);

// 잠금 이후 (Controller / View)
$context->getAttribute('shop.is_checkout', false); // 읽기 OK
$context->setAttribute('x', 1);                    // LogicException

표시값 오버라이드는 별도 경로다. siteImageUrls·siteLogoText·siteOverridesSiteContextReadyEvent 구독자가 요청 시점에 바꿀 수 있고, 이 세 가지는 attributes 잠금의 영향을 받지 않는다.

알고 싶은 것먼저 볼 파일 / 메서드
요청이 어떤 상태로 해석되는가ContextBuilder::build()
해석 결과를 어떻게 읽는가Context 의 getter 들
호스트 → 도메인 매핑·폴백DomainResolver::resolve()
현재 메뉴 결정 규칙ContextBuilder::resolveCurrentMenu()
이벤트 시스템

mublo 의 이벤트 시스템은 src/Core/Event/ 아래 몇 개의 작은 파일로 구성된다. 발송의 중심은 EventDispatcher, 이벤트 객체의 기반은 AbstractEvent, 다수 이벤트를 한 클래스에서 묶어 구독할 때는 EventSubscriberInterface 를 쓴다.

이벤트 이름 = FQCN

이벤트 이름은 문자열 상수가 아니라 클래스명(FQCN) 이다. AbstractEvent::getName()static::class 를 그대로 돌려주므로, 이벤트를 구독·발송할 때 SomeEvent::class 를 키로 쓴다. 오타 대신 클래스 참조로 안전하게 연결된다.

public function getName(): string
{
    return static::class; // 이벤트명 = 클래스 FQCN
}

리스너 등록과 우선순위

addListener(string $eventName, callable $listener, int $priority = 0) 로 리스너를 건다. 내부 저장 구조는 [eventName => [priority => [callable...]]] 이며, 발송 시 sortListeners()krsort() 로 우선순위를 내림차순 정렬한다. 즉 priority 가 높을수록 먼저 실행된다. 정렬 결과는 sortedListeners 에 캐시되고, 등록·제거 시 해당 캐시만 무효화된다.

구독자(Subscriber) 등록

관련 리스너를 한 클래스로 묶으려면 EventSubscriberInterface 를 구현하고 getSubscribedEvents() 가 매핑 배열을 반환한다. addSubscriber() 가 이를 풀어 addListener() 를 반복 호출한다. 세 가지 표기를 지원한다.

public static function getSubscribedEvents(): array
{
    return [
        PostCreated::class => 'onCreate',              // 메서드명만
        PostUpdated::class => ['onUpdate', 10],        // [메서드, 우선순위]
        PostDeleted::class => [['audit', 20], ['clean', 5]], // 여러 리스너
    ];
}

전파 중단(veto)과 fail-fast

dispatch() 는 정렬된 리스너를 순회하며 매 반복마다 isPropagationStopped() 를 검사한다. 어느 리스너가 stopPropagation() 을 호출하면 이후 리스너는 실행되지 않는다 — 이것이 veto 지점이다.

예외 처리는 두 갈래다. \Error(TypeError 등 치명 오류)는 무조건 재throw 한다. 그 외 \Throwable 은 기본적으로 삼키고 로깅(생성자에 넘긴 exceptionLogger)하여 한 리스너의 실패가 나머지를 막지 않게 한다. 단, 이벤트가 FailFastEventInterface 마커를 구현했다면 예외를 그대로 호출자에게 전파한다.

foreach ($listeners as $listener) {
    if ($event->isPropagationStopped()) {
        break; // veto — 이후 리스너 중단
    }
    try {
        $listener($event);
    } catch (\Error $e) {
        throw $e; // 치명 오류는 재throw
    } catch (\Throwable $e) {
        if ($event instanceof FailFastEventInterface) {
            throw $e; // 중요 도메인 이벤트는 즉시 실패
        }
        // 그 외엔 로깅 후 계속 진행
    }
}

추적·UI 장식처럼 best-effort 인 이벤트는 기본(삼키고 로깅)으로 두고, 실패하면 요청 자체가 멈춰야 하는 중요한 도메인 이벤트에만 FailFastEventInterface 를 붙인다.

계약(Contract) 시스템

코어와 확장(Plugin/Package)은 서로의 구체 클래스를 직접 알지 않는다. 대신 Mublo\Contract\ 네임스페이스의 인터페이스를 표준으로 두고, 그 구현체를 ContractRegistry(src/Core/Registry/ContractRegistry.php)에 등록·조회한다. 코어의 어느 계층이든 인터페이스 FQCN만 알면 구현체를 얻는다.

두 가지 등록 모드

레지스트리는 계약당 구현체 개수에 따라 두 API 쌍을 제공한다.

모드등록 / 조회용도
1:1bind() / resolve()단일 제공자 (본인인증, SMS 등)
1:Nregister() / get()복수 제공자 (PG사, 리포트 렌더러 등)

bind()는 이미 바인딩된 계약에 다시 바인딩하면 DuplicateRegistryException을 던지고, register()(contract, key) 쌍이 유일해야 한다. 두 등록 모두 구현체 인스턴스 또는 Closure 팩토리를 받으며, Closure는 resolve()/get()가 처음 호출될 때 실행되어 인스턴스화된 뒤 그 자리에 캐싱된다(lazy). 인스턴스는 등록 시점에, Closure 결과는 조회 시점에 instanceof $contract로 계약 준수를 검증한다.

메타데이터 — resolve 없이 목록화

register()의 4번째 인자 $meta는 구현체를 생성하지 않고도 목록·검색에 쓸 정보다. 관리자 화면에서 라벨만 뿌릴 때 allMeta()·getMeta()로 조회하면 Closure를 실행하지 않아 lazy 이점이 유지된다.

// ServiceProvider — 코어 기본 리포트 렌더러를 1:N 등록
$registry->register(
    ReportRendererInterface::class,
    'csv',
    fn() => new CsvReportRenderer(),   // lazy 팩토리
    ['label' => 'CSV', 'description' => '기본 CSV 렌더러'],
);

// 소비 측 — key 로 구현체 조회
$renderer = $registry->get(ReportRendererInterface::class, 'csv');

DI 싱글턴과의 관계

ContractRegistry 자체는 ServiceProvider에서 $container->singleton(ContractRegistry::class, …)로 한 번 구성되어, 앱 전역에서 같은 인스턴스를 공유한다. 다만 모든 계약을 레지스트리로 조회하지는 않는다. 항상 코어 구현이 하나뿐인 안정 계약(예: BlockKitGatewayInterface)은 컨테이너에서 인터페이스를 곧장 구현 클래스에 바인딩해 생성자 주입으로 소비한다. 레지스트리는 확장이 갈아끼우거나 여러 제공자가 공존하는 계약에 쓴다.

구분 기준: "확장이 교체·추가할 수 있는가"이면 ContractRegistry, "코어 구현이 늘 유일한가"이면 컨테이너 직접 바인딩 + 생성자 주입.

DTO 경계 — 무엇을 주고받나

계약이 노출하는 데이터는 세션 구조나 Entity가 아니라 final readonly DTO다. Mublo\Contract\Auth\AuthenticatedUser, Mublo\Contract\Member\MemberProfile이 대표 예로, 확장에는 내부 구조 대신 이 안정 표현만 건넨다.

final readonly class AuthenticatedUser
{
    public function __construct(
        public int $memberId,
        public int $domainId,
        public string $userId,
        public ?string $nickname,
        public int $levelValue,
        public bool $admin,
        // …
    ) {}

    public function displayName(): string { /* nickname ?: userId */ }
}

readonly이므로 소비자가 값을 바꿀 수 없고, 내부 세션·Member Entity 리팩터링이 확장 API를 깨지 않는다. 계약 인터페이스는 이런 DTO를 반환 타입으로 삼아 코어 내부와 확장 사이의 경계를 고정한다.

렌더링과 블록

한 요청이 화면으로 조립되는 과정은 두 축으로 나뉜다. 프레임 조립(페이지의 골격을 짜는 렌더러)과 블록 시스템(각 영역 안을 채우는 콘텐츠 조각)이다. 코어를 처음 열 때는 이 두 파일을 기준점으로 삼으면 된다.

화면 조립의 최상위 anchor — FrontViewRenderer

Front 영역의 출력 주도권은 src/Core/Rendering/FrontViewRenderer.phpFrontViewRenderer 가 쥔다. 컨트롤러가 돌려준 ViewResponse 를 받아 render(ViewResponse $response, Context $context) 에서 페이지 전체를 조립한다. 핵심은 2-pass 렌더링이다.

  • 1차: 본문(Content) 뷰를 먼저 버퍼에 렌더한다. 이때 스킨이 $this->layout([...]) 로 헤더·푸터·레이아웃 힌트를 선언할 수 있다.
  • 2차: 그 힌트를 반영해 Head → topbar → Header → subhead → LayoutOpen → (좌 사이드바 / 본문 / 우 사이드바) → LayoutClose → subfoot → Footer → Foot 순으로 골격을 이어 붙인다.

본문 바깥의 영역(topbar·subhead·좌우 사이드바·subfoot)은 모두 BlockRenderService::renderPosition() 호출로 채워진다. 즉 프레임은 자리를 잡고, 그 자리의 내용물은 블록 시스템이 담당한다.

레이아웃 타입(전체/좌·우/양쪽 사이드바) 결정은 LayoutManager::resolve()(src/Core/Rendering/LayoutManager.php)에 위임된다. 이 클래스는 무대(stage)만 만들 뿐 헤더·푸터를 그리지 않는다 — 조립 순서는 항상 렌더러 몫이다. 우선순위(블록페이지 > 메뉴 오버라이드 > 스킨 힌트 > 도메인 기본)는 순수 함수 applyLayoutPrecedence() 로 고정돼 있다.

에셋 슬롯과 스킨 격리

블록·스킨이 렌더 도중 AssetManageraddCss()/addJs() 를 호출하면, 출력은 일단 버퍼에 모였다가 flushWithAssets()<!-- MUBLO_CSS -->·<!-- MUBLO_JS --> 플레이스홀더를 실제 링크로 치환한다. 그래서 CSS 는 <head>, JS 는 </body> 앞으로 정확히 들어간다.

스킨 하나가 예외로 죽어도 페이지 전체가 무너지지 않는다. renderViewIsolated() 가 각 스킨을 자체 버퍼에 먼저 담고, 실패 시 그 자리에만 에러 박스를 출력한다.

블록 시스템 — 타입 레지스트리와 렌더러

블록의 "무엇을 그릴지"는 src/Core/Block/BlockRegistry.php 가 정한다. 콘텐츠 타입 코드(html, image, movie, outlogin, menu, include)를 렌더러 클래스에 매핑하는 정적 레지스트리다. 코어 타입은 initializeCoreTypes() 에서 등록되고, 플러그인은 같은 registerContentType() 로 자기 타입을 얹는다.

BlockRegistry::registerContentType(
    type: 'gallery',
    kind: BlockContentKind::PLUGIN->value,
    title: '갤러리',
    rendererClass: GalleryRenderer::class,
);

모든 렌더러는 src/Core/Block/Renderer/RendererInterface.php 의 단일 메서드를 구현한다.

interface RendererInterface
{
    public function render(BlockColumn $column): string;
}

렌더링 흐름과 캐시

실제 조립은 src/Service/Block/BlockRenderService.php 가 맡는다. renderPosition()(위치별) 또는 renderPage()(페이지별)가 진입점이며, 안쪽은 행(row) → 칸(column) → 콘텐츠 순으로 내려간다. 출력 골격은 다음 구조로 고정된다.

<section class="block-section">
  <div class="block-container">
    <div class="block-row">
      <div class="block-column"> … 렌더러 결과 … </div>
    </div>
  </div>
</section>

각 칸은 renderColumnContent() 에서 BlockRegistry::getRendererClass() 로 렌더러를 찾아 render($column) 를 호출한다. 코어 렌더러들은 SkinRendererTrait::renderSkin() 을 통해 views/Block/{type}/{skin}/{skin}.php 스킨 파일을 include 하는 방식이다.

성능의 핵심은 2단계 캐시다. ① 위치/페이지의 행 ID 목록 캐시(block:ids:...), ② 행별 HTML 캐시(block:row:{rowId}). 덕분에 행 하나만 수정하면 그 행만 다시 렌더되고 같은 위치의 다른 행은 캐시를 유지한다.

로그인 위젯처럼 사용자 상태에 의존하는 타입은 등록 시 options: ['noCache' => true] 를 준다. BlockRegistry::isNoCache() 가 이를 감지하면 그 행은 캐시에서 제외된다. 개별 칸의 렌더 실패는 renderErrorPlaceholder() 로 격리돼, 한 칸이 죽어도 나머지 블록은 정상 출력된다.
렌더링 파이프라인

Front 영역 한 요청이 화면으로 조립되는 전 과정은 src/Core/Rendering/FrontViewRenderer.php 한 곳에서 지휘된다. 컨트롤러가 돌려준 ViewResponse 를 받아 Head → Header → Layout → Content → Footer → Foot 순서로 출력하는 최상위 렌더러다. LayoutManager 는 그 안에서 body 레이아웃만 계산해 주는 도구이고, 출력 주도권은 항상 FrontViewRenderer 에 있다.

2-pass 렌더 — 콘텐츠를 먼저 버퍼링한다

진입점은 render(ViewResponse $response, Context $context). 일반 페이지(헤더·푸터·사이드바 포함) 요청에서는 프레임을 곧바로 그리지 않고, 콘텐츠를 한 번 먼저 렌더해 버퍼에 담는다. 스킨이 그 안에서 $this->layout([...]) 로 헤더/푸터/사이드바 힌트를 선언할 수 있기 때문이다. 1차에서 힌트를 수집한 뒤 2차에서 실제 페이지를 조립한다.

// --- 1차: Content 버퍼링 ---
ob_start();
$this->renderContent($response, $context);
$contentHtml = ob_get_clean();

// standalone 스킨은 프레임 골격 없이 그대로 출력
if ($this->viewContext->getLayoutOption('standalone', false)) {
    echo $contentHtml;
    return;
}

// 스킨 힌트 반영 (스킨 > _pageConfig > 기본값)
$useHeader = $this->viewContext->getLayoutOption('header', $useHeader);
$useFooter = $this->viewContext->getLayoutOption('footer', $useFooter);

스킨이 선언하는 힌트는 ViewContext 가 보관한다. 스킨 상단에서 부른 layout() 값은 getLayoutOption()(불리언)과 getLayoutOptionValue()(문자열 힌트)로 렌더러가 되읽는다.

레이아웃 결정 — 우선순위와 LayoutManager

어떤 레이아웃을 쓸지는 순수 정적 함수 FrontViewRenderer::applyLayoutPrecedence() 에 고정돼 있다. 순위는 블록페이지 > 메뉴 오버라이드 > 스킨 힌트 > 헤더없음(full) > 도메인 기본. 블록페이지가 _pageConfig['layout_type'] 을 이미 담아 오면 이 판정 자체를 건너뛴다.

확정된 pageConfigLayoutManager::resolve($context, $pageConfig) 로 넘어간다. 여기서 사이트 설정(siteConfig['layout_type'])을 parseLayoutType() 로 정수화하고, 사이드바 너비·모바일 노출 여부를 병합해 ['type' => ..., 'data' => [...]] 배열을 돌려준다. 타입 정수는 1=full, 2=left, 3=right, 4=both 로 매핑되며, 렌더러는 이 값으로 좌/우 <aside> 를 출력할지 결정하고 각 자리에 BlockRenderService::renderPosition() 으로 블록을 채운다.

스킨·프레임 폴백 — per-file 폴백 철학

콘텐츠 스킨은 renderContent()views/Front/{Group}/{skin}/{File}.php 에서 찾는다. 선택 스킨에 파일이 없으면 같은 파일의 basic 스킨으로 폴백한다. 프레임(Head/Header/LayoutOpen/Footer/Foot 등)은 includeFrameView() 가 담당하며 폴백 순위가 명확하다.

  1. 패키지 프레임 오버라이드(frameBasePath) — 파일이 있으면 최우선
  2. 도메인 프레임 오버라이드(게시본) — renderFrameOverride(), 실패하면 false 를 돌려 파일 스킨으로 폴백
  3. 파일 스킨 — 선택 프레임 스킨에 파트가 없으면 basic 프레임으로 per-file 폴백
부분 오버라이드가 안전한 이유가 여기 있다. 커스텀 스킨이 Login.php 한 장만 덮어도 나머지는 basic 그대로 렌더되고, 프레임 오버라이드가 깨져도 사이트는 파일 스킨으로 살아남는다. 스킨 파일에서 예외가 나도 renderViewIsolated() 가 그 자리에만 에러 박스를 출력해 페이지 전체를 죽이지 않는다.

에셋 치환 — 플레이스홀더를 마지막에 채운다

블록·플러그인이 렌더 도중 addCss()/addJs() 를 호출하면, 전체 출력은 바깥 ob_start() 버퍼에 모였다가 finallyflushWithAssets() 에서 최종 처리된다. 템플릿에 심어둔 주석 마커를 실제 링크로 치환하는 방식이다.

  • addCss($path, 'name')<!-- MUBLO_CSS_name --> 슬롯 자리에 주입(템플릿이 위치 결정)
  • 마커가 없는 슬롯의 에셋은 유실되지 않도록 기본 대역 <!-- MUBLO_CSS -->(스킨 뒤)로 폴백
  • JS 도 동일 메커니즘 — <!-- MUBLO_JS_name --> / 기본 대역은 body 끝

이 구조 덕에 각 스킨은 CSS/JS 등록 시점을 신경 쓰지 않고 자기 필요만 선언하면 되고, 삽입 위치는 프레임 템플릿이 마커로 통제한다.

코드를 처음 열 때의 이정표

알고 싶은 것볼 곳
전체 조립 순서·2-pass 흐름FrontViewRenderer::render()
레이아웃 우선순위 규칙FrontViewRenderer::applyLayoutPrecedence()
레이아웃 타입·사이드바 데이터LayoutManager::resolve()
스킨/프레임 폴백renderContent() · includeFrameView()
에셋 마커 치환flushWithAssets()
스킨에서 $this 로 쓰는 기능ViewContext(component·pagination·layout)
블록 시스템 내부

mublo 의 블록 시스템은 화면을 행(Row) → 칸(Column) → 콘텐츠(Content) 3계층으로 조립한다. 데이터 모델은 database/migrations/003_create_block_tables.sql 에, 등록·렌더링 로직은 src/Core/Block/src/Service/Block/ 에 있다.

데이터 모델: block_pages · block_rows · block_columns

테이블은 세 개다. block_rowspage_id(페이지 소속)와 position(index·header·footer·topbar 등 고정 위치) 중 하나로 연결된다. 즉 같은 행 구조를 페이지 조립위치 삽입 두 용도에 쓴다. block_columnsrow_id 에 매달리며, 한 행의 칸 수는 행의 column_count(1~4)가 정한다. 콘텐츠는 다음 컬럼들로 표현된다.

  • content_type — 콘텐츠 타입 코드(html, image, board, menu 등)
  • content_kind — 출처 구분(CORE / PLUGIN / PACKAGE)
  • content_skin — 출력 스킨
  • content_config / content_items — 렌더 설정과 출력 데이터(JSON)

레이아웃·배경·테두리·제목은 모두 행/칸의 JSON 컬럼(background_config, border_config, title_config)에 들어간다.

BlockRegistry: 콘텐츠 타입 등록

src/Core/Block/BlockRegistry.phpcontent_type 문자열을 렌더러 클래스에 매핑하는 정적 레지스트리다. 코어 타입 6종(HTML·이미지·동영상·로그인위젯·메뉴·PHP포함)은 initializeCoreTypes() 에서 등록되고, 플러그인/패키지는 Provider 의 boot() 에서 registerContentType() 를 호출해 자신의 타입을 추가한다.

BlockRegistry::registerContentType(
    type: 'gallery',
    kind: BlockContentKind::PLUGIN->value,
    title: '갤러리',
    rendererClass: GalleryRenderer::class,   // RendererInterface 구현 필수
);

중복 등록은 예외(options['allowOverwrite'] 로만 허용), 등록 시 is_subclass_of(..., RendererInterface::class) 로 계약을 검증한다(클래스가 로드돼 있을 때). 조회는 getRendererClass($type), getContentTypeOptions(), isNoCache($type) 등으로 한다. 소유권은 ExtensionRegistrationScope 로 추적되어 확장 언로드 시 undo 된다.

RendererInterface: 단 하나의 계약

src/Core/Block/Renderer/RendererInterface.php 는 메서드가 하나뿐이다. 칸 엔티티를 받아 HTML 문자열을 돌려준다.

interface RendererInterface
{
    public function render(BlockColumn $column): string;
}

BlockRenderService: 조립과 캐싱

src/Service/Block/BlockRenderService.php 가 실제 렌더 흐름을 지휘한다. 진입점은 위치용 renderPosition() 과 페이지용 renderPage() 두 개다. 내부 흐름은 행 목록 조회 → 행별 렌더 → 칸별 렌더 → 콘텐츠 렌더 로 내려간다.

  • buildRowHtml()<section> + .block-row 골격과 여백용 동적 <style> 을 만든다.
  • buildColumnHtml() 이 각 .block-column 을 만들고,
  • renderColumnContent()getRenderer($contentType) 로 렌더러를 얻어 $renderer->render($column) 을 호출한다. 렌더러 인스턴스는 DI 컨테이너(DependencyContainer)에서 우선 해석하고, 실패 시 직접 new 한다(플러그인 하위호환).

캐시는 2단계다. (1) 위치/페이지의 행 ID 목록(block:ids:pos:..., block:ids:page:...), (2) 개별 행 HTML(block:row:{rowId}). 덕분에 한 행만 고치면 그 행 캐시만 지우면 되고 같은 위치의 다른 행 캐시는 살아남는다. 무효화는 invalidateRowRelatedCache()(구조 변경) / invalidateRowContentCache()(내용 변경)로 구분한다.

로그인 위젯처럼 사용자 상태에 의존하는 타입은 options['noCache'] 로 등록되고, buildRowHtml() 이 그 칸을 감지하면 행 전체를 캐시하지 않는다. 렌더 실패 시엔 renderErrorPlaceholder() 가 프로덕션에선 빈 문자열(에러 은닉), 디버그에선 주석을 반환한다.

정화기(BlockContentSanitizer): 저장 시점 방어

src/Service/Block/BlockContentSanitizer.phpHTML 직접입력 타입만 저장 경로에서 정화한다. sanitizeColumnData()sanitizeHtmlConfig() 흐름에서 html·슬라이드 htmlHtmlSanitizer::sanitizeForBlock()(HTMLPurifier block 프로파일)로 script·on* 등 액티브 콘텐츠를 제거하고, css</style> 브레이크아웃만 무력화한다. js 필드는 의도된 스크립트 채널이라 보존한다.

주의: 정화기는 html/css/js 필드만 손댄다. background_config·border_config 같은 설정 JSON 값은 정화 대상이 아니므로, BlockRenderService 가 이를 인라인 style 로 조립할 때 반드시 htmlspecialchars() 로 이스케이프한다(주석에 명시된 저장형 XSS 방어). 참고로 block 프로파일의 배경 url() 은 그라디언트·단색·同출처 상대경로만 통과한다.

데이터·확장·보안

이 장은 mublo 코어에서 데이터가 안전하게 드나드는 계층, 플러그인·패키지가 런타임에 붙는 방식, 그리고 암·복호화와 비밀번호 해싱의 단일 지점을 다룬다. 셋 모두 "직접 손대지 말고 이 클래스를 거쳐라"는 규율을 코드로 강제한다는 공통점이 있다.

데이터 접근 — Infrastructure\Database

모든 쿼리는 Database(PDO 래퍼)와 그 위의 QueryBuilder를 통과한다. Databaseselect()·selectOne()·insert()·execute()·transaction()을 제공하고, 실패 시 DatabaseException::queryFailed()로 감싸며, 느린 쿼리는 slowQueryThreshold 기준으로 로깅한다(로그에서 password·token 등 키는 sanitizeParams()가 마스킹).

QueryBuilder의 핵심은 값은 항상 ? 바인딩, 식별자는 화이트리스트 정규식이라는 원칙이다. 컬럼·테이블·연산자는 각각 assertIdentifier()·assertTableIdentifier()·assertOperator()가 검증하고, whereLike()escapeLikeValue()%·_를 리터럴 처리한다. WHERE 없는 update()·delete()allowFullTableOperation()을 명시하지 않으면 예외를 던진다.

$rows = $db->table('members')
    ->where('status', 'active')
    ->whereIn('level', [1, 2, 3])
    ->orderByDesc('created_at')
    ->forPage(2, 20)
    ->get();
정적 컬럼명은 빌더에 맡기되, 계산식·서브쿼리가 필요하면 whereRaw()·orderByRaw()를 쓴다. 이때 ? 개수와 바인딩 수가 assertPlaceholderCount()로 대조되니 개수를 맞춰야 한다.

확장 로딩 — Core\Extension

활성 플러그인·패키지는 Application::run()ExtensionManager::loadExtensions()를 부르며 켜진다. 각 확장은 ExtensionProviderInterface를 구현해 두 단계로 붙는다. register()에서 컨테이너에 서비스를 담고, Context 생성 후 boot()에서 이벤트 구독·라우트를 등록한다. 로딩 순서는 패키지 register → 플러그인 register → 전체 boot다.

안정성의 핵심은 ExtensionRegistrationScope다. 확장이 코어에 등록하는 모든 상태는 소유자(plugin:Name)별로 undo/commit 콜백에 기록되고, register()·boot() 중 예외가 나면 rollbackRegistration()이 그 확장의 등록만 되돌린다. 부모 패키지가 실패하면 종속 플러그인은 recordDependencySkip()으로 건너뛴다. manifest.jsoncritical=true이거나 APP_DEBUG=true면 예외를 그대로 전파하고, 아니면 격리해 나머지를 계속 부팅한다.

보안 기반 — Core\Crypto

대칭키 암호화는 EncryptionService 한 곳뿐이다. config/security.phpencryption.key(32바이트)로 AES-256-GCM 인증 암호화를 하며, encrypt()nonce · tag · ciphertext를 base64로 반환하고 decrypt()는 실패 시 null을 준다(PG 시크릿·외부 토큰 등 평문 저장 금지 값 용).

비밀번호는 PasswordHasher로 통일한다. password_hash()를 직접 부르지 말고 hash()를 쓸 것 — 그래야 설정한 algo·cost가 실제로 반영된다. 범위 밖 cost는 예외 대신 10~15로 클램프되고, 로그인 성공 직후 needsRehash()로 점진 재해싱한다.

if (password_verify($input, $hash)) {
    if ($hasher->needsRehash($hash)) {
        $hash = $hasher->hash($input); // 새 cost로 저장
    }
}
QueryBuilder

QueryBuilder(src/Infrastructure/Database/QueryBuilder.php)는 메서드 체이닝으로 SQL을 조립하고, 모든 값은 prepared statement 바인딩으로 넘기며, 식별자·연산자는 화이트리스트로 검증하는 쿼리 빌더다. 인스턴스는 직접 new 하지 않고 Database::table()(src/Infrastructure/Database/Database.php)로 얻는다. 생성자는 테이블명을 prefixTable()에 통과시키지만, 이 메서드는 프리픽스 시스템이 제거되면서 현재 입력값을 그대로 돌려주는 @deprecated 상태다.

기본 조회 흐름

체인의 끝에서 get()(여러 행, array) 또는 first()(단일 행, ?array)를 호출하면 실행된다. first()는 내부에서 limit(1)을 걸었다가 원래 limit을 복원한다.

$rows = $db->table('posts')
    ->select('id', 'title', 'created_at')
    ->where('status', 'published')      // 2인자 = where('status','=','published')
    ->whereIn('category_id', [1, 3, 7])
    ->orderBy('created_at', 'DESC')
    ->limit(20)
    ->get();

$one = $db->table('members')
    ->where('email', '[email protected]')
    ->first();                          // 없으면 null

JOIN·집계

join()/leftJoin()JoinClause로 조인 조건을 만든다. count()·sum()·avg()·max()·min()aggregate()를 거치며, exists()count() > 0이다.

$stats = $db->table('orders o')
    ->select('o.user_id', 'COUNT(*) AS cnt')
    ->leftJoin('users u', 'u.id', '=', 'o.user_id')
    ->where('o.status', 'paid')
    ->groupBy('o.user_id')
    ->having('cnt', '>', 3)
    ->get();

$total = $db->table('orders')->where('status', 'paid')->count();

쓰기: insert·update·delete

insert()는 마지막 삽입 ID를, update()/delete()는 영향받은 행 수를 반환한다. 키·값은 각각 식별자 검증과 ? 바인딩으로 분리된다.

$id = $db->table('posts')->insert([
    'title'  => $title,
    'status' => 'draft',
]);

$db->table('posts')->where('id', $id)->update(['status' => 'published']);
$db->table('posts')->where('id', $id)->delete();

안전 설계

임의 문자열이 SQL에 그대로 박히지 않도록 세 겹으로 막는다.

  • 식별자 allowlist — 컬럼은 assertIdentifier()/^[a-zA-Z_][a-zA-Z0-9_.]*$/로, 테이블·SELECT 표현식은 assertTableIdentifier()·assertSelectExpression()가 별칭·집계 형태까지만 허용한다. 벗어나면 DatabaseException.
  • 연산자 allowlistassertOperator()=, !=, <>, >, >=, <, <=, LIKE, IN, BETWEEN 등 고정 목록만, assertBoolean()AND/OR만 통과시킨다.
  • Prepared statement — 모든 조건·데이터 값은 addBinding()으로 모아 ? 자리표시자로만 전달된다. whereRaw()·orderByRaw()조차 assertPlaceholderCount()? 개수와 바인딩 개수가 일치하는지 검사한다. whereLike()escapeLikeValue()% _ \를 리터럴화한다.

WHERE 없는 UPDATE/DELETE 거부update()·delete()wheres가 비어 있으면 DatabaseException을 던진다. 전체 테이블 작업이 정말 의도된 경우에만 allowFullTableOperation()를 명시적으로 호출해야 통과한다.

디버깅에는 실행 없이 SQL과 바인딩을 뽑는 toSql()·getBindings()·debug()를 쓴다.

마이그레이션

mublo 는 코어·플러그인·패키지의 스키마 변경을 하나의 실행기로 통합 추적한다. 진입점은 Mublo\Core\Extension\MigrationRunner(src/Core/Extension/MigrationRunner.php)이고, 이력은 schema_migrations 테이블에 source(core|plugin|package)·name·file·checksum 조합으로 남는다.

MigrationRunner 실행 흐름

run(string $source, string $name, string $migrationPath) 가 한 번의 실행 단위다. 먼저 getStatus() 로 대상 디렉터리의 *.sql 을 이름순 정렬(getMigrationFiles())해 pending·executed·drift 로 분류한다. drift(체크섬 불일치)가 하나라도 있으면 실행을 중단하고, pending 이 없으면 곧바로 성공을 돌려준다. 실제 적용은 pending 파일별로 다음을 반복한다.

  • 파일 raw bytes 로 hash('sha256', $sql) 체크섬 계산
  • SqlStatementSplitter 로 문자열·주석 속 ; 를 보존하며 문(statement) 단위 분할
  • 각 문을 exec() 가 아니라 $pdo->query() 로 실행 후 closeCursor()SET @var=(SELECT ...) 같은 내부 결과셋까지 소비하기 위함
  • 파일 완료 시 recordMigration()INSERT IGNORE 로 이력 기록
$stmt = $pdo->query($query);
if ($stmt) {
    $stmt->closeCursor();
}

멱등 판정 — 문맥 기반

재실행 시 "컬럼이 이미 존재" 같은 오류는 무시해야 하지만, 오류번호만 보면 UPDATE/SELECT 의 컬럼 오타까지 성공 처리될 위험이 있다. 그래서 MigrationErrorPolicy::canIgnorePdo($e, $query)(src/Infrastructure/Database/MigrationErrorPolicy.php)가 오류코드 + SQL 문맥을 함께 본다. 예컨대 1060(중복 컬럼)은 그 문장이 실제로 ALTER TABLE ... ADD COLUMN 일 때만 무시된다.

코드허용 문맥
1060ADD COLUMN
1061ADD INDEX/KEY, CREATE INDEX
1091DROP COLUMN/INDEX/KEY/FK
1050CREATE TABLE

1072(Key column doesn't exist)는 ADD INDEX 의 실제 결함일 수 있어 항상 실패시킨다.

개별 ALTER 부분 적용 방지

핵심은 hasSingleAlterOperation() 이다. ADD COLUMN A, ADD COLUMN B 같은 복합 ALTER 에서 A 가 중복이면 B 도 적용되지 않으므로, 정규식으로 ADD/DROP 키워드가 정확히 하나일 때만 멱등 무시를 허용한다. 이때 sqlForClassification() 이 주석·문자열 리터럴을 먼저 제거해, 리터럴 안의 ADD 단어가 작업 수로 오집계되는 것을 막는다.

실무 권장: 마이그레이션은 한 문장당 하나의 DDL 작업으로 쪼개라. 복합 ALTER 는 재실행 시 멱등 무시 대상에서 빠져 전체가 실패한다.

checksum 과 드리프트

이력의 checksum 은 실행 당시 파일 raw bytes 의 SHA-256 이다. getStatus() 는 실행된 파일을 hash_file() 로 다시 계산해 hash_equals() 로 비교하고, 다르면 drift 로 보고해 재실행을 막는다(이미 적용된 파일의 사후 편집 탐지). 기존 이력에 checksum 이 NULL(레거시)이면 baselineChecksum() 이 현재 값을 1회 기입해 기준선을 잡는다. 추적 테이블 자체가 없거나 checksum 컬럼이 없으면 ensureTrackingTable()CREATE TABLE IF NOT EXISTSADD COLUMN 으로 보정하며, 이때 동시 요청이 먼저 컬럼을 추가한 1060 만 예외적으로 허용한다.

SQL 파일 첫머리에 -- @optional-table: t1, t2 주석을 두면, 해당 테이블 부재(42S02/1146)로 나는 오류를 parseOptionalTables()·isOptionalTableError() 조합이 스킵한다. 다른 확장 유무에 따라 선택적으로 걸리는 마이그레이션에 쓴다.
확장 로딩

확장(플러그인·패키지)의 로딩은 src/Core/Extension/ExtensionManager.php가 총괄한다. 진입점은 loadExtensions(Context, array $enabledPlugins, array $enabledPackages) 하나이며, 여기서 register → boot 두 단계가 순서대로 실행된다.

register → boot 2단계

loadExtensions()는 먼저 부모 패키지를 loadPackage()로, 이어서 독립·종속 플러그인을 loadPlugin()으로 순회하며 각 Provider의 register($container)를 호출한다. 모든 register가 끝난 뒤에야 bootProviders()가 저장해 둔 Provider들을 다시 돌며 boot($container, $context)를 호출한다. 즉 등록(서비스 바인딩)과 부팅(이벤트 구독 등)이 분리되어, 부팅 시점엔 모든 확장의 register가 완료되어 있음을 보장한다.

// 1) 패키지 register  2) 플러그인 register  3) 전체 boot
foreach ($enabledPackages as $packageName) $this->loadPackage($packageName, $context);
foreach ($enabledPlugins  as $pluginName)  $this->loadPlugin($pluginName, $context);
$this->bootProviders($context);

Provider 격리와 실패 전파

각 Provider의 register()·boot() 호출은 try/catch (\Throwable)로 감싸여, 한 확장의 예외가 다른 확장으로 번지지 않는다. 잡힌 예외는 handleExtensionError()로 모여 $loadErrors에 기록되고 ExtensionLoadDiagnostics에 넘어간다. 다만 shouldRethrow()가 참이면 예외를 다시 던진다 — 조건은 APP_DEBUG=true이거나 해당 확장 manifest.jsoncritical이 true인 경우(isCriticalExtension())다.

패키지가 register/boot에서 실패하면 $failedPackages에 표시된다. 그 패키지에 종속된 플러그인(NestedPlugin::parentPackage()로 판별)은 loadPlugin()·bootProviders()에서 recordDependencySkip()으로 건너뛰어, 반쪽짜리 부모 위에서 자식이 부팅되는 일을 막는다.

부분 등록 롤백

Provider가 코어 런타임에 등록한 상태는 src/Core/Extension/ExtensionRegistrationScope.php가 확장 단위로 추적한다. loadPlugin()/loadPackage()begin($owner)로 스코프를 열고 register 후 suspend(), boot 시점엔 resume()suspend()commit()으로 확정한다. 도중에 예외가 나면 rollbackRegistration()rollback($owner)를 호출해 그 확장이 쌓아 둔 recordUndo() 콜백을 역순으로 실행, 절반만 등록된 상태를 되돌린다. loadExtensions() 최상위 catch에서도 남은 모든 Provider를 롤백하고 ExtensionRegistrationScope::reset() 후 예외를 재전파한다.

스코프는 정적(static) 상태이므로 요청 경계에서 reset()으로 초기화된다. 한 번에 하나의 activeOwner만 허용되며, 중첩 beginLogicException을 던진다.

서명 검증

설치되는 확장 ZIP의 무결성·출처는 src/Service/Extension/ExtensionPackageVerifier.phpverify(\ZipArchive $zip, string $rootDir, string $source)Result로 반환한다. calculatePayloadDigest()가 서명 파일을 뺀 모든 항목을 경로순 정렬해 파일별 SHA-256을 합성한 canonical digest를 만들고, extension-signature.jsonrsa-sha256 서명을 신뢰 publisher 공개키로 openssl_verify() 검증한다. 서명이 없을 때 require_signature가 true면 실패, false면 status: unsigned로 통과시킨다.

단계담당
payload digest 계산calculatePayloadDigest() — 경로 정렬 후 파일별 해시 합성
서명 파싱·검증schema=1·rsa-sha256, key_id·payload_sha256(hash_equals)·source 화이트리스트
publisher 로딩config/extension-publishers.phpnormalizePublishers()
보안 기반

mublo 코어의 보안 기반은 암호화·비밀번호·세션·CSRF 네 축으로 나뉘고, 각 책임은 단일 진입점 클래스로 응집되어 있다. 아래 이정표대로 파일을 열면 흐름을 따라갈 수 있다.

대칭키 암호화 — EncryptionService

src/Core/Crypto/EncryptionService.php가 도메인 무관 대칭키 암호화의 단일 지점이다. AES-256-GCM(인증된 암호화, AEAD)을 쓰며, config/security.phpencryption.key(hex, 32바이트)를 생성자에서 검증한다. 암호문 포맷은 base64(nonce || tag || ciphertext)로, 12바이트 nonce는 random_bytes()로 매번 새로 뽑는다.

// encrypt(): nonce(12) + tag(16) + ciphertext 를 base64 로
$nonce = random_bytes(self::NONCE_LENGTH);
$cipherText = openssl_encrypt(
    $plainText, self::CIPHER, $this->encryptionKey,
    OPENSSL_RAW_DATA, $nonce, $tag, '', self::TAG_LENGTH
);
return base64_encode($nonce . $tag . $cipherText);
복호화 실패(태그 불일치·변조·잘못된 키)는 예외가 아니라 decrypt()null을 반환하는 것으로 표현된다. 호출부에서 null 처리를 잊지 말 것.

회원 필드 암호화 + Blind Index — FieldEncryptionService

src/Service/Member/FieldEncryptionService.php는 암호화 자체를 EncryptionService에 위임하고, 회원 필드 특화 책임인 검색 인덱스(blind index)를 얹는다. 암호화된 값은 그대로는 검색할 수 없으므로, 정규화(소문자+trim)한 원문을 search.pepper로 HMAC-SHA256 해싱한 64자 hex를 별도 컬럼에 저장한다. pepper가 DB 밖 config에 있어 DB만 유출돼도 레인보우 테이블이 무력화된다.

public function createSearchIndex(string $value): string {
    $normalized = strtolower(trim($value));
    return hash_hmac('sha256', $normalized, $this->searchPepper);
}
// 비교는 타이밍 공격 방지를 위해 hash_equals()

processFieldValue()is_encrypted·is_searchable 플래그에 따라 {field_value, search_index}를 함께 만들어 주고, readFieldValue()가 저장값을 되읽어 복호화한다.

비밀번호 해싱 — PasswordHasher

src/Core/Crypto/PasswordHasher.php는 비밀번호 해싱의 단일 지점이다. 과거 각 서비스가 옵션 없는 password_hash()를 직접 불러 설치 시 기록한 cost가 무시되던 문제를 없앴다. 이 클래스는 config/security.phppassword.algo/cost를 실제로 소비하며, 잘못 적힌 cost는 예외 대신 안전 범위(MIN_COST 10 ~ MAX_COST 15)로 클램프한다. 새 해싱 코드는 반드시 이 클래스를 쓸 것.

로그인 성공 직후(평문이 있는 유일한 시점) needsRehash()로 구식 해시를 판정해 점진 재해싱한다. 검증은 해시 문자열에 algo/cost가 담겨 있으므로 password_verify()를 그대로 쓴다.

세션 강화 — SessionManager

src/Infrastructure/Session/SessionManager.phpconfigureSession()이 세션 고정(fixation) 방어의 핵심이다. session.use_strict_mode=1강제해 서버가 발급한 적 없는 세션 ID를 거부하고(PHP 기본 0은 미발급 ID를 채택), use_only_cookies=1로 URL 노출을 막는다. 쿠키는 httponly·samesite=Lax가 기본이며, config가 껐어도 실제 HTTPS 요청이면 secure를 자동으로 켠다.

ini_set('session.use_strict_mode', '1');   // 미발급 ID 거부
ini_set('session.use_only_cookies', '1');  // URL 세션ID 차단
$secure = ($this->config['cookie_secure'] ?? false) || $this->isHttpsRequest();

로그인 시 regenerate()(session_regenerate_id)로 ID를 새로 발급하고, enforceIdleTimeout()이 마지막 활동 시각을 서버측에 기록해 lifetime(분) 초과 유휴 세션을 비우고 재발급하는 슬라이딩 만료를 강제한다.

CSRF — CsrfManager

src/Infrastructure/Security/CsrfManager.php가 세션 기반 토큰을 _csrf_token 키에 저장한다. getToken()은 없으면 생성, validateToken()CryptoManager::secureCompare()(상수 시간 비교)로 검증한다. 로그인 후 regenerateToken()으로 회전한다. 실제 요청 검증은 src/Core/Middleware/CsrfMiddleware.php가, 발급 API는 src/Controller/Api/CsrfController.php가 담당한다.

관리자 보안 헤더 — SecurityHeadersMiddleware

src/Core/Middleware/SecurityHeadersMiddleware.php는 관리자 HTML 응답(ViewResponse·HtmlResponse)에 최소 보안 헤더를 보장한다. 블록 편집기가 관리자 화면을 same-origin iframe으로 쓰므로 DENY 대신 X-Frame-Options: SAMEORIGIN과 CSP frame-ancestors 'self'를 기본으로 넣되, 이미 설정된 값이 있으면 덮어쓰지 않고 frame-ancestors만 보강한다.

인증과 회원 권한

인증의 중심은 src/Service/Auth/AuthService.phpAuthService 다. AuthContextInterface·MemberAuthenticatorInterface 두 계약을 구현하며, 세션(SessionInterface)·회원 조회(MemberRepository)·비밀번호 해싱(PasswordHasher)·CSRF(CsrfManager)를 생성자로 주입받는다.

로그인 흐름 — attempt()

attempt(int $domainId, string $userId, string $password, string $ipAddress) 가 관문이다. 도메인 스코프로 findByDomainAndUserId() 를 조회한 뒤, 순서가 보안 설계의 핵심이다.

  • 타이밍 균등화: 계정이 없으면 상수 DUMMY_PASSWORD_HASHpassword_verify() 를 한 번 돌려 "존재하는 계정"과 검증 소요 시간을 맞춘다. 응답 시간 차이로 계정 존재 여부를 알아내는 타이밍 기반 열거를 막는다.
  • 비밀번호 먼저, 상태는 나중: 비밀번호 검증을 계정 상태(isActive()) 안내보다 앞에 둔다. 자격증명 없이는 휴면·정지 같은 상태를 열거할 수 없다.
  • 점진 재해싱: 평문이 있는 유일한 시점이므로 needsRehash() 면 새 설정으로 다시 해싱한다. 실패는 삼켜 로그인을 막지 않는다.

세션 재생성

실제 세션 확립은 loginUser() 가 한다. 권한 상승 직전에 반드시 두 경계를 넘긴다.

// 세션 고정 공격 방지
$this->session->regenerate(true);
// 로그인 전 발급 토큰의 재사용 차단
$this->csrfManager->regenerateToken();

이후 toSafeArray() 로 민감정보를 제거한 배열에 아바타 URL을 캐시해 세션에 저장한다. SNS 로그인은 loginByMember(), ID 기반은 loginByMemberId() 가 같은 loginUser() 경계를 공유한다.

세션의 권한 플래그는 로그인 시점 스냅샷이다. 로그인 이후 강등·차단된 관리자를 위해 revalidatePrivileges(int $ttl = 60) 가 최대 $ttl초에 한 번 DB를 재조회해 세션을 갱신하고, 계정이 사라졌거나 비활성이면 false(호출자가 로그아웃)를 반환한다.

권한 — 플래그 기반

등급은 src/Entity/Member/MemberLevel.php 의 세 불리언으로 판단한다. level_value 숫자 비교가 아니라 플래그가 진실의 원천이다.

플래그의미판정 메서드
is_super전체 시스템 최고관리자isSuper()
is_admin관리자 모드 접근canAccessAdmin()
can_operate_domain도메인 소유·운영canOperateDomain()

canAccessAdmin()canOperateDomain()isSuper 를 흡수한다 — 슈퍼는 항상 통과다. isAdmin()@deprecated 이니 canAccessAdmin() 을 쓴다.

public function canOperateDomain(): bool
{
    return $this->canOperateDomain || $this->isSuper;
}

AuthServiceisAdmin()·isSuper()·canOperateDomain() 은 세션에 저장된 같은 플래그를 읽는다. 특히 canOperateDomain() 은 직접 입력 JS 같은 '신뢰 관리자' 전용 자유 채널의 게이트로 쓰인다. 확장 경계에는 세션 배열을 그대로 넘기지 않고 currentUser()AuthenticatedUser DTO로 감싸 반환한다.