#[MapRequestToForm]를 활용한 Symfony 폼을 컨트롤러 인자로 사용하기

발행: (2026년 6월 6일 PM 10:32 GMT+9)
10 분 소요
원문: Dev.to

출처: Dev.to

최근에 Request To Form Bundle 라는 작은 Symfony 번들을 공개했습니다.
저는 Symfony를 꽤 오래 사용해왔고, 여러 REST API 프로젝트에서 Symfony Form을 활용해왔습니다.
Form은 강력하고 잘 동작했지만, 매 컨트롤러마다 다음과 같은 로직을 반복하고 싶지는 않았습니다.

  • 현재 요청을 읽는다
  • 요청 데이터를 Form에 제출할 수 있는 형태로 변환한다
  • Form을 만든다
  • 제출한다
  • 검증한다
  • 매핑된 데이터를 얻는다

그래서 아래와 같이 작업을 대신해 주는 작은 내부 서비스를 만들었습니다.

$form = $formHandler->handleCurrentRequest($post);

이 서비스는 현재 요청을 읽고, 제공된 데이터 객체로부터 Form 타입을 찾아서 요청 데이터를 Form에 제출하고, Form이 유효하지 않을 경우 예외를 발생시킵니다. 여러 프로젝트에서 이 서비스를 사용했으며, 컨트롤러 코드를 훨씬 깔끔하게 만들 수 있었습니다.

예를 들어, 컨트롤러는 다음과 같이 작성될 수 있습니다.

#[Route('/posts', methods: ['POST'])]
public function create(FormRequestHandler $formHandler): JsonResponse
{
    $post = new Post();

    $formHandler->handleCurrentRequest($post);

    $this->postService->create($post);

    return $this->json($post);
}

그 후에 생각했습니다. Symfony에는 이미 #[MapRequestPayload]#[MapQueryString] 같은 컨트롤러 인자 어트리뷰트가 있습니다. 이걸 계속 사용하면서도 같은 로직을 유지할 수 있다면 어떨까?
이것이 바로 번들을 만들게 된 계기였습니다.

엔티티와 직접 연결되지 않은 페이로드에는 보통 DTO를 선호합니다. 검색 쿼리, 필터, 로그인 페이로드, 혹은 일반적인 액션 등은 가벼운 DTO가 잘 맞습니다.
하지만 요청이 엔티티와 거의 일치하거나 입력 구조가 복잡할 경우 Symfony Form이 매우 유용합니다. Form 컴포넌트는 이미 중첩 Form, 컬렉션, EntityType, CollectionType 같은 강력한 타입을 지원합니다. 또한 데이터 트랜스포머, Form 이벤트, 타입 확장, 커스텀 옵션, 기존 객체에 직접 데이터를 제출하는 기능도 제공합니다. Form 타입은 상속과 임베디드 서브‑Form을 통해 쉽게 재사용·확장할 수 있다는 점도 큰 장점입니다.

이러한 상황에서는 Form을 사용함으로써 다음과 같은 보일러플레이트를 크게 줄일 수 있습니다.

  • 요청을 역직렬화
  • 별도의 DTO를 검증
  • DTO를 엔티티 혹은 다른 객체에 수동 매핑

입력을 이미 Symfony Form이 정의하고 목표 객체에 매핑하는 방법을 안다면, 바로 그 Form을 사용하는 것이 더 편리합니다.


번들의 핵심 기능

번들은 #[MapRequestToForm] 어트리뷰트를 추가합니다.
이 어트리뷰트를 사용하면 현재 요청이 Symfony Form에 제출되고, Form이 유효하면 컨트롤러는 매핑된 데이터를 받게 됩니다. Form이 유효하지 않으면 컨트롤러가 호출되기 전에 HTTP 오류가 발생하고, 실패한 Form은 FormValidationFailedException을 통해 접근할 수 있습니다.

use App\Entity\Post;
use AzYouness\RequestToFormBundle\Attribute\MapRequestToForm;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/posts', methods: ['POST'])]
public function create(
    #[MapRequestToForm]
    Post $post,
): JsonResponse {
    $this->entityManager->persist($post);
    $this->entityManager->flush();

    return $this->json($post);
}

Form 타입은 해당 Form 타입 클래스에 정의된 data_class 로부터 자동으로 추론됩니다. PostTypedata_classPost::class 로 설정돼 있다면, 별도 지정이 필요 없습니다.
자동 추론이 불가능한 경우에는 명시적으로 지정할 수 있습니다.

#[MapRequestToForm(formType: PostType::class)]
Post $post,

업데이트·수정 엔드포인트와의 연계

Symfony는 라우트 파라미터로부터 엔티티를 먼저 해석할 수 있습니다(예: EntityValueResolver 혹은 #[MapEntity]). 번들은 컨트롤러 인자를 모두 해석한 뒤에 Form 매핑을 수행하므로, 기존 객체가 초기 Form 데이터로 사용되고 요청이 그 객체에 바로 적용됩니다.

#[Route('/posts/{id}', methods: ['PUT'])]
public function update(
    #[MapRequestToForm]
    Post $post,
): JsonResponse {
    // $post 는 {id} 로부터 EntityValueResolver 가 먼저 해석하고,
    // 이후 현재 요청 데이터를 폼에 제출합니다.

    $this->entityManager->flush();

    return $this->json($post);
}

#[MapEntity] 와도 함께 사용할 수 있습니다.

#[Route('/posts/{slug}', methods: ['PUT'])]
public function update(
    #[MapRequestToForm]
    #[MapEntity(mapping: ['slug' => 'slug'])]
    Post $post,
): JsonResponse {
    $this->entityManager->flush();

    return $this->json($post);
}

PATCH 요청 처리

PATCH 요청에서는 기본적으로 누락된 필드를 유지합니다(clearMissing: false). 다른 메서드에서는 누락된 필드가 초기화됩니다. 필요에 따라 옵션을 재정의할 수 있습니다.

#[MapRequestToForm(clearMissing: false)]
Post $post,

Form 옵션 전달

특정 검증 그룹을 사용하고 싶다면 Form 옵션을 전달하면 됩니다.

#[MapRequestToForm(formOptions: ['validation_groups' => ['Default', 'publish']])]
Post $post,

FormFactoryInterface::create() 가 받는 모든 옵션을 여기서 지정할 수 있습니다.

Form 자체가 필요한 경우

컨트롤러가 매핑된 데이터가 아니라 실제 FormInterface 객체가 필요할 때는 다음과 같이 작성합니다.

use App\Form\PostType;
use AzYouness\RequestToFormBundle\Attribute\MapRequestToForm;
use Symfony\Component\Form\FormInterface;

public function create(
    #[MapRequestToForm(formType: PostType::class)]
    FormInterface $form,
): JsonResponse {
    return $this->json($form->getData());
}

dataArgument 로 초기 데이터 지정

다른 컨트롤러 인자를 초기 Form 데이터로 사용하고 싶을 때는 dataArgument 옵션을 활용합니다.

public function update(
    Post $post,
    #[MapRequestToForm(formType: PostType::class, dataArgument: 'post')]
    FormInterface $form,
): JsonResponse {
    // $post 를 초기 데이터로 하여 폼이 제출됩니다.
    // 현재 요청 데이터가 $post 에 바로 반영됩니다.

    return $this->json($form->getData());
}

지원 포맷

기본적으로 번들은 jsonform 두 가지 요청 포맷을 모두 허용합니다. form 포맷은 multipart/form-data 를 포함하므로 파일 업로드도 바로 사용할 수 있습니다. 필요에 따라 액션별로 허용 포맷을 제한할 수 있습니다.

#[MapRequestToForm(acceptFormat: 'json')]
Post $post,

RequestToFormMapper 서비스 활용

컨트롤러가 요청을 Form에 전달하기 전에 데이터를 미리 가공하거나 Form 옵션을 조정하고 싶다면, 번들이 제공하는 RequestToFormMapper 서비스를 사용할 수 있습니다.

use AzYouness\RequestToFormBundle\RequestToFormMapper;

public function create(RequestToFormMapper $mapper): JsonResponse
{
    $post = new Post();

    // 요청을 제출하기 전에 객체를 준비한다.

    $mapper->handleCurrentRequest($post);

    // $post 가 이제 제출·검증된 상태다.

    return $this->json($post);
}

명시적으로 Request 객체를 전달하고 싶다면 저수준 handle() 메서드를 사용할 수도 있습니다.

$form = $mapper->handle(
    request: $request,
    formType: PostType::class,
    data: $post,
    throwOnInvalid: false,
);

if (!$form->isValid()) {
    // 유효하지 않은 폼 처리
}

구조

번들은 크게 두 부분으로 구성됩니다.

  1. Mapper 서비스 – 요청 데이터를 읽어 Form에 제출하고 검증까지 수행한다.
  2. 컨트롤러 인자 통합 – 위 서비스를 기반으로, Symfony가 다른 인자를 모두 해석한 뒤에 Form 매핑을 수행한다.

이 순서는 의도된 설계이며, 다음과 같은 데이터 흐름을 가능하게 합니다.

Symfony가 라우트·엔티티 인자를 해석

번들이 요청 데이터를 Form에 제출

컨트롤러가 최종 매핑된 값을 받는다

또한 번들은 data_class 로부터 Form 타입을 자동으로 추론하려고 시도합니다. 따라서 많은 단순 케이스에서는 컨트롤러 인자 타입만으로 충분합니다.

더 알아보기

GitHub 저장소: https://github.com/azyouness/request-to-form-bundle

Symfony 개발자 여러분의 피드백을 환영합니다. API 설계, 엣지 케이스, 네이밍, 혹은 실제 Symfony 애플리케이션에 이 접근법이 적합한지 등에 대한 의견을 주세요.

0 조회
Back to Blog

관련 글

더 보기 »

모바일 한여름 열풍

!Cover image for Mobile Midsommer Madnesshttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploa...