Waaseyaa의 도메인 라우팅: 거대한 디스패처를 작은 라우터로 교체
Source: Dev.to
번역할 텍스트가 제공되지 않았습니다. 번역을 원하는 본문을 여기에 붙여 주시면 한국어로 번역해 드리겠습니다.
Ahnii!
graph TD
A[HTTP Request] --> B[Router]
B -->|matches URL to route| C[Dispatcher]
C -->|picks controller, injects context| D[Controller]
D --> E[Response]라우터는 URL을 라우트 정의와 매칭합니다. 디스패처는 그 매칭을 받아 어떤 컨트롤러를 인스턴스화하고, 어떤 메서드를 호출하며, 요청 컨텍스트를 어떻게 전달할지 결정합니다. 대부분의 프레임워크에서는 프레임워크가 이를 처리해 주기 때문에 별로 생각하지 않게 됩니다.
문제
디스패처는 “어떤 코드를 실행할지”가 긴 조건문 체인으로 변환되는 곳이 됩니다. 모든 요청 유형을 처리하는 단일 디스패처는 조건문이 빠르게 누적됩니다. 결과적으로 다음과 같은 코드가 됩니다:
public function dispatch(Request $request): Response
{
$controller = $request->attributes->get('_controller');
if ($controller === 'entity_types') {
// 40 lines of entity type listing logic
} elseif (str_starts_with($controller, 'entity_type.')) {
// 60 lines of lifecycle management
} elseif ($controller === 'openapi') {
// 80 lines of OpenAPI spec generation
} elseif (str_contains($controller, 'SchemaController')) {
// 50 lines of schema handling
}
// ... and so on for every domain
}엔티티 CRUD, 스키마 생성, 라이프사이클 관리, OpenAPI 문서 – 모두 하나의 클래스에서 처리됩니다. 새로운 기능이 추가될 때마다 또 다른 분기가 생기고, 어느 하나의 경로를 테스트하려면 모든 경로에 대한 컨텍스트를 로드해야 합니다.
해결책
이 해결책은 “더 나은” 디스패처가 아니라, 각각 하나의 도메인을 담당하는 작고 집중된 라우터들입니다.
도메인 라우터는 애플리케이션의 요청 처리 중 한 부분을 담당하는 작은 클래스입니다. 모든 도메인을 알고 있는 하나의 디스패처 대신, 각 라우터는 두 가지 질문에 답합니다:
- “이 요청이 내 것인가?” – 요청을 검사하고 불리언을 반환합니다.
- “예, 처리한다.” – 작업을 수행하고 응답을 반환합니다.
interface DomainRouterInterface
{
// "Is this request mine?" — inspects the request, returns a boolean.
public function supports(Request $request): bool;
// "Yes, handle it." — does the work, returns a response.
public function handle(Request $request): Response;
}디스패처는 등록된 라우터들을 순서대로 반복합니다. supports()에서 true를 반환하는 첫 번째 라우터가 승리합니다. 이것은 명시적 계약을 가진 책임 연쇄(Chain of Responsibility) 패턴입니다.
예제 라우터
엔티티‑타입 라이프사이클 라우터
Waaseyaa는 엔티티 타입(예: “Article”, “User”, “Comment”)을 정의할 수 있게 합니다. 때때로 하나를 비활성화하거나, 콘텐츠 타입을 폐기하거나, 꺼진 것을 다시 활성화해야 할 때가 있습니다. 이 라우터가 그 일을 처리합니다.
final class EntityTypeLifecycleRouter implements DomainRouterInterface
{
use JsonApiResponseTrait; // consistent JSON:API response formatting
public function __construct(
// Registry: knows which entity types exist and their capabilities
private readonly EntityTypeManager $entityTypeManager,
// Handles disable/enable state changes
private readonly EntityTypeLifecycleManager $lifecycleManager,
) {}
public function supports(Request $request): bool
{
// Prefix match: anything starting with "entity_type." belongs here.
// Adding entity_type.archive later requires zero changes to this method.
$controller = $request->attributes->get('_controller', '');
return $controller === 'entity_types'
|| str_starts_with($controller, 'entity_type.');
}
public function handle(Request $request): Response
{
$controller = $request->attributes->get('_controller', '');
return match ($controller) {
'entity_types' => $this->listTypes($request),
'entity_type.disable' => $this->disableType($request),
'entity_type.enable' => $this->enableType($request),
default => $this->jsonApiResponse(404, []),
};
}
}하나의 클래스, 하나의 도메인, 격리된 상태에서 완전하게 테스트 가능.
스키마 라우터
API는 또한 각 엔티티 타입에 대한 OpenAPI 사양 및 스키마 정의를 제공해야 합니다. 이는 라이프사이클 관리와는 다른 도메인이므로 별도의 라우터를 가집니다.
final class SchemaRouter implements DomainRouterInterface
{
use JsonApiResponseTrait;
public function __construct(
private readonly EntityTypeManager $entityTypeManager,
// Schema endpoints enforce the same access rules as the entities themselves.
// Can't access an entity type? Can't read its schema either.
private readonly EntityAccessHandler $accessHandler,
) {}
public function supports(Request $request): bool
{
$controller = $request->attributes->get('_controller', '');
return $controller === 'openapi'
|| str_contains($controller, 'SchemaController');
}
public function handle(Request $request): Response
{
$controller = $request->attributes->get('_controller', '');
if ($controller === 'openapi') {
return $this->generateOpenApiSpec($request);
}
// SchemaController routes carry the entity type as a route parameter
$entityType = $request->attributes->get('entity_type_id', '');
return $this->showSchema($entityType, $request);
}
}두 라우터 모두 요청에서 타입이 지정된 컨텍스트를 직접 파싱하지 않고 가져옵니다.
그 컨텍스트는 어디서 오는가?
Middleware가 상위에서 인증, 본문 파싱, 그리고 컨텍스트 조립을 라우터가 요청을 보기 전에 처리합니다:
// By the time handle() runs, the request carries everything the router needs.
// No token parsing, no JSON decoding, no service lookups.
$account = $request->attributes->get('_account'); // authenticated user
$storage = $request->attributes->get('_broadcast_storage'); // storage backend
$body = $request->attributes->get('_parsed_body'); // already decoded JSON, etc.이러한 분리를 통해:
- 라우터는 작고 단일 도메인에 집중합니다.
- 디스패칭은
DomainRouterInterface구현체들을 순회하는 간단한 루프가 됩니다. - 테스트는 각 라우터를 격리된 상태에서 수행하기 쉬워집니다 – 라우터가 기대하는 요청 속성만 제공하면 됩니다.
TL;DR
단일 디스패처를 도메인‑특화 라우터들의 컬렉션으로 교체하세요. 각 라우터는 두 개의 메서드 계약(supports + handle)을 구현합니다. 이렇게 하면:
- 코드가 더 깔끔하고 유지보수가 쉬워집니다.
- 테스트가 더 빠르고 집중됩니다.
- 책임 연쇄(Chain of Responsibility) 패턴을 적용하기에 자연스러운 위치가 됩니다.
다음 프로젝트에서 한 번 시도해 보세요 – 첫 번째 라우터를 추가한 뒤부터 차이를 확실히 느낄 수 있을 겁니다!
$t->attributes->get('_parsed_body'); // deserialized JSON
$context = $request->attributes->get('_waaseyaa_context'); // framework context인프라 작업은 한 번만, 상류에서 수행됩니다. 라우터는 도메인 로직에만 집중합니다.
새로운 도메인 스켈레톤
대량 import 작업을 처리하고 싶다고 가정해 보세요:
final class BulkImportRouter implements DomainRouterInterface
{
use JsonApiResponseTrait;
public function supports(Request $request): bool
{
return str_starts_with(
$request->attributes->get('_controller', ''),
'bulk_import.',
);
}
public function handle(Request $request): Response
{
$controller = $request->attributes->get('_controller', '');
return match ($controller) {
'bulk_import.csv' => $this->importCsv($request),
'bulk_import.json' => $this->importJson($request),
default => $this->jsonApiResponse(404, []),
};
}
}- 기존 라우터를 수정할 필요가 없습니다.
- 디스패처를 수정할 필요도 없습니다.
- 라우터 컬렉션에 등록하면 디스패처가 자동으로 찾아 사용합니다.
- 인터페이스가 새로운 도메인이 추가되는 형태임을 보장합니다.
1,000 줄짜리 디스패처는 사라지고, 그 자리를 차지하는 것은 경계가 명확한 작은 클래스들이며, 각각을 독립적으로 테스트할 수 있습니다:
$router = new EntityTypeLifecycleRouter($entityTypeManager, $lifecycleManager);
$request = new Request();
$request->attributes->set('_controller', 'entity_type.disable');
$request->attributes->set('_parsed_body', ['entity_type_id' => 'article']);
$response = $router->handle($request);
// 프레임워크 부팅, 데이터베이스, 미들웨어 체인 없이 동작entity_type.disable 요청을 보면, 어떤 파일이 이를 처리하는지 정확히 알 수 있습니다. 거대한 클래스 안의 switch 문을 추적할 필요가 없습니다.
Baamaapii