Waaseyaa의 도메인 라우팅: 거대한 디스패처를 작은 라우터로 교체

발행: (2026년 4월 6일 오전 10:36 GMT+9)
10 분 소요
원문: Dev.to

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 문서 – 모두 하나의 클래스에서 처리됩니다. 새로운 기능이 추가될 때마다 또 다른 분기가 생기고, 어느 하나의 경로를 테스트하려면 모든 경로에 대한 컨텍스트를 로드해야 합니다.

해결책

이 해결책은 “더 나은” 디스패처가 아니라, 각각 하나의 도메인을 담당하는 작고 집중된 라우터들입니다.

도메인 라우터는 애플리케이션의 요청 처리 중 한 부분을 담당하는 작은 클래스입니다. 모든 도메인을 알고 있는 하나의 디스패처 대신, 각 라우터는 두 가지 질문에 답합니다:

  1. “이 요청이 내 것인가?” – 요청을 검사하고 불리언을 반환합니다.
  2. “예, 처리한다.” – 작업을 수행하고 응답을 반환합니다.
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

0 조회
Back to Blog

관련 글

더 보기 »

결합도와 SRP에 대한 요약

클래스가 응집된다는 것은 무엇인가? 응집된 클래스는 오직 하나의 책임만을 갖는 클래스이다. 응집된 클래스는 더 작고, 더 조직적이며, 유지보수가 쉽고 재사용 가능하다.