Symfony Live Components와 UX 3.0: JavaScript 없이 리액티브 앱 구축하기 (2026년)

Symfony Live Components와 UX 3.0을 활용하여 JavaScript 프레임워크 없이 리액티브 인터페이스를 구축하는 방법을 다룹니다. LiveProp, LiveAction, 폼 처리, 지연 로딩, URL 바인딩을 포함한 완전한 튜토리얼입니다.

Symfony Live Components와 UX 3.0을 활용한 PHP와 Twig 기반 리액티브 인터페이스

Symfony Live Components는 PHP와 Twig만으로 리액티브하고 실시간적인 사용자 인터페이스를 구현하는 기능입니다. 2026년 4월에 릴리스된 Symfony UX 3.0에서는 2.x의 모든 지원 중단 기능이 제거되었으며, 최소 요구 사항이 PHP 8.4 및 Symfony 7.4로 상향되었습니다. 또한 4개의 폐기된 패키지가 제외되어 역대 가장 경량화된 UX 툴킷이 되었습니다.

Live Components란?

Live Components는 모든 Twig 컴포넌트를 상태를 유지하는 인터랙티브 요소로 변환합니다. 사용자 상호작용에 따라 서버 측에서 재렌더링이 이루어지며, JavaScript를 작성할 필요가 전혀 없습니다. 데이터 바인딩, 액션, 유효성 검사, 폼 처리가 모두 PHP 내에서 처리됩니다.

Symfony UX 3.0과 Live Components 설정

설치에는 Symfony 7.4 이상, PHP 8.4 이상, 그리고 프론트엔드 에셋을 위한 AssetMapper 또는 Webpack Encore가 필요합니다.

bash
# terminal
composer require symfony/ux-live-component

# With AssetMapper (recommended for Symfony 7.4+)
php bin/console importmap:require @symfony/ux-live-component

# Verify installation
php bin/console debug:twig --filter=component

AssetMapper는 Node.js, Webpack 또는 기타 빌드 단계 없이 JavaScript를 제공합니다. Live Components를 구동하는 Stimulus 컨트롤러는 자동으로 로드됩니다.

LiveProp을 활용한 리액티브 검색 컴포넌트 만들기

사용자가 입력할 때마다 결과를 필터링하는 검색 바를 JavaScript 없이 구현할 수 있습니다. #[LiveProp] 속성은 프로퍼티를 상태 유지(stateful)로 표시하며, 암호화된 URL 안전 토큰을 통해 재렌더링 간에 값을 유지합니다.

php
<?php
// src/Twig/Components/ProductSearch.php

namespace App\Twig\Components;

use App\Repository\ProductRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
class ProductSearch
{
    use DefaultActionTrait;

    // writable: true allows the frontend to modify this property
    // url: true syncs the value with ?query= in the address bar
    #[LiveProp(writable: true, url: true)]
    public string $query = '';

    #[LiveProp(writable: true)]
    public string $category = 'all';

    public function __construct(
        private ProductRepository $productRepository,
    ) {
    }

    public function getProducts(): array
    {
        if (strlen($this->query) < 2) {
            return [];
        }

        return $this->productRepository->search(
            query: $this->query,
            category: $this->category === 'all' ? null : $this->category,
            limit: 20,
        );
    }
}

해당 Twig 템플릿에서는 data-model을 사용하여 입력 필드를 프로퍼티에 바인딩합니다.

twig
{# templates/components/ProductSearch.html.twig #}
<div {{ attributes }}>
    <input
        type="search"
        data-model="debounce(300)|query"
        placeholder="Search products..."
        class="form-input w-full"
    />

    <select data-model="category" class="form-select mt-2">
        <option value="all">All categories</option>
        <option value="electronics">Electronics</option>
        <option value="books">Books</option>
    </select>

    <div class="mt-4">
        {% for product in this.products %}
            <div key="{{ product.id }}" class="border-b py-2">
                <h3>{{ product.name }}</h3>
                <span>{{ product.price|format_currency('EUR') }}</span>
            </div>
        {% else %}
            {% if query|length >= 2 %}
                <p>No results for "{{ query }}".</p>
            {% endif %}
        {% endfor %}
    </div>
</div>

debounce(300) 수정자는 마지막 키 입력 후 300밀리초를 대기한 뒤 요청을 전송합니다. 리스트 항목의 key 속성은 효율적인 DOM 모핑을 가능하게 하여 변경된 요소만 업데이트됩니다.

LiveProp의 URL 바인딩

LiveProp에서 url: true를 설정하면 프로퍼티 값이 쿼리 파라미터로 브라우저 URL에 동기화됩니다. 단 하나의 속성으로 북마크 가능하고 공유 가능한 검색 결과를 구현할 수 있습니다.

LiveAction으로 사용자 액션 처리하기

데이터 바인딩 외에도, #[LiveAction]은 PHP 메서드를 프론트엔드에서 호출 가능한 액션으로 노출합니다. 인자는 #[LiveArg]를 통해 전달됩니다.

php
<?php
// src/Twig/Components/ShoppingCart.php

namespace App\Twig\Components;

use App\Entity\CartItem;
use App\Service\CartService;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
class ShoppingCart
{
    use DefaultActionTrait;
    use ComponentToolsTrait;

    #[LiveProp]
    public array $items = [];

    public function __construct(
        private CartService $cartService,
    ) {
    }

    #[LiveAction]
    public function addItem(#[LiveArg] int $productId, #[LiveArg] int $quantity = 1): void
    {
        $this->cartService->add($productId, $quantity);
        $this->items = $this->cartService->getItems();

        // Notify other components on the page
        $this->emit('cart:updated', [
            'count' => count($this->items),
        ]);
    }

    #[LiveAction]
    public function removeItem(#[LiveArg] int $itemId): void
    {
        $this->cartService->remove($itemId);
        $this->items = $this->cartService->getItems();
        $this->emit('cart:updated', ['count' => count($this->items)]);
    }

    public function getTotal(): float
    {
        return array_sum(array_map(
            fn(CartItem $item) => $item->getPrice() * $item->getQuantity(),
            $this->items,
        ));
    }
}

템플릿에서는 live_action() 헬퍼 또는 data-action 속성을 사용하여 액션을 트리거합니다.

twig
{# templates/components/ShoppingCart.html.twig #}
<div {{ attributes }}>
    <h2>Cart ({{ items|length }})</h2>

    {% for item in items %}
        <div key="{{ item.id }}" class="flex justify-between py-2">
            <span>{{ item.name }} x{{ item.quantity }}</span>
            <span>{{ (item.price * item.quantity)|format_currency('EUR') }}</span>
            <button {{ live_action('removeItem', { itemId: item.id }) }}
                    class="text-red-600">
                Remove
            </button>
        </div>
    {% endfor %}

    <div class="border-t pt-2 font-bold">
        Total: {{ this.total|format_currency('EUR') }}
    </div>
</div>

emit() 메서드는 페이지 내 다른 Live Components에 이벤트를 브로드캐스트합니다. 예를 들어, 장바구니 배지 컴포넌트는 #[LiveListener('cart:updated')]로 리슨하여 전체 페이지를 새로고침하지 않고도 카운트를 업데이트할 수 있습니다.

Symfony 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

JavaScript 없는 실시간 폼 유효성 검사

Live Components는 Symfony Forms와 직접 통합됩니다. ComponentWithFormTrait는 폼 상태, 유효성 검사 오류, 제출을 컴포넌트 라이프사이클에 연결합니다.

php
<?php
// src/Twig/Components/RegistrationForm.php

namespace App\Twig\Components;

use App\Entity\User;
use App\Form\RegistrationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
class RegistrationForm extends AbstractController
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    #[LiveProp]
    public ?User $initialFormData = null;

    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(RegistrationType::class, $this->initialFormData ?? new User());
    }

    #[LiveAction]
    public function save(EntityManagerInterface $em): mixed
    {
        // Submits + validates — re-renders with errors if invalid
        $this->submitForm();
        $user = $this->getForm()->getData();

        $em->persist($user);
        $em->flush();

        $this->addFlash('success', 'Account created.');

        return $this->redirectToRoute('app_login');
    }
}
twig
{# templates/components/RegistrationForm.html.twig #}
<div {{ attributes }}>
    {{ form_start(form, {
        attr: {
            'data-action': 'live#action:prevent',
            'data-live-action-param': 'save'
        }
    }) }}
        {{ form_row(form.email) }}
        {{ form_row(form.plainPassword, { label: 'Password' }) }}
        {{ form_row(form.fullName) }}

        <button type="submit"
                class="btn-primary w-full"
                data-loading="addAttribute(disabled) addClass(opacity-50)">
            <span data-loading="hide">Create Account</span>
            <span data-loading="show" class="animate-spin">Loading...</span>
        </button>
    {{ form_end(form) }}
</div>

유효성 검사 오류는 사용자가 필드 간에 이동할 때 인라인으로 표시됩니다. data-modelon(change) 수정자가 blur 시 재렌더링을 트리거하여 Symfony Validator의 제약 조건 메시지가 즉시 표시됩니다.

성능을 위한 지연 로딩과 레이지 로딩

대시보드, 분석 차트, 긴 목록 등 무거운 컴포넌트는 지연 렌더링의 이점을 누릴 수 있습니다. 초기 페이지 로드를 차단하는 대신 컴포넌트가 플레이스홀더를 렌더링하고, 페이지가 준비된 후 AJAX로 콘텐츠를 가져옵니다.

php
<?php
// src/Twig/Components/AnalyticsDashboard.php

namespace App\Twig\Components;

use App\Service\AnalyticsService;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
class AnalyticsDashboard
{
    use DefaultActionTrait;

    #[LiveProp]
    public string $period = '30d';

    public function __construct(
        private AnalyticsService $analytics,
    ) {
    }

    public function getMetrics(): array
    {
        // Expensive query — runs only after page load
        return $this->analytics->getMetrics($this->period);
    }
}
twig
{# Parent page: load the dashboard after the page renders #}
<twig:AnalyticsDashboard loading="defer" period="30d" />

{# Or load when scrolled into view #}
<twig:AnalyticsDashboard loading="lazy" period="30d" />

컴포넌트 템플릿 내의 플레이스홀더 매크로는 실제 콘텐츠가 로드되는 동안 사용자에게 표시할 내용을 정의합니다.

twig
{# templates/components/AnalyticsDashboard.html.twig #}
<div {{ attributes }}>
    <h2>Analytics — {{ period }}</h2>
    {% for metric in this.metrics %}
        <div class="stat-card">
            <span class="stat-label">{{ metric.label }}</span>
            <span class="stat-value">{{ metric.value|number_format }}</span>
        </div>
    {% endfor %}
</div>

{% macro placeholder(props) %}
    <div class="animate-pulse space-y-4">
        <div class="h-6 bg-gray-200 rounded w-1/3"></div>
        <div class="h-20 bg-gray-200 rounded"></div>
        <div class="h-20 bg-gray-200 rounded"></div>
    </div>
{% endmacro %}

loading="defer" 모드는 페이지 로드 직후 AJAX 요청을 발행합니다. loading="lazy" 모드는 IntersectionObserver를 사용하여 컴포넌트가 뷰포트에 스크롤될 때만 요청이 발행됩니다. 두 방식 모두 초기 페이지 응답을 빠르게 유지합니다.

UX 3.0에서 CSRF 제거

Symfony UX 3.0은 Live Components의 CSRF 토큰을 동일 출처/CORS 보호로 대체했습니다. #[AsLiveComponent]csrf 인자는 더 이상 존재하지 않습니다. 서버가 동일 출처 정책을 적용하고 있는지 확인해야 합니다.

이벤트를 통한 컴포넌트 간 통신

Live Components는 이벤트 시스템을 통해 통신합니다. 자식 컴포넌트가 이벤트를 발행하고, 부모(또는 형제) 컴포넌트가 리슨하여 반응합니다.

php
<?php
// src/Twig/Components/CartBadge.php

namespace App\Twig\Components;

use App\Service\CartService;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
class CartBadge
{
    use DefaultActionTrait;

    #[LiveProp]
    public int $count = 0;

    public function __construct(CartService $cartService)
    {
        $this->count = $cartService->getItemCount();
    }

    // Automatically re-renders when ShoppingCart emits 'cart:updated'
    #[LiveListener('cart:updated')]
    public function onCartUpdated(#[LiveArg] int $count): void
    {
        $this->count = $count;
    }
}

이 패턴을 통해 컴포넌트 간 결합도가 낮아집니다. ShoppingCart 컴포넌트는 CartBadge를 직접 참조하지 않으며, 이벤트 버스가 연결을 처리합니다.

Symfony UX 3.0의 주요 변경 사항

UX 3.0은 정리(cleanup) 릴리스입니다. 2.x에서 지원 중단 경고 없이 실행되던 애플리케이션은 최소한의 수정으로 업그레이드할 수 있습니다.

| 변경 사항 | 이전 (2.x) | 이후 (3.0) | |--------|-------------|-------------| | CSRF 보호 | #[AsLiveComponent]csrf: true | 동일 출처/CORS (자동) | | Twig CVA 함수 | cva() | twig/html-extra 3.12+의 html_cva() | | 컴포넌트 기본 설정 | 선택 사항 | twig_component.defaults 필수 | | 제거된 패키지 | Swup, LazyImage, Typed, TogglePassword | 네이티브 API 또는 UX Toolkit | | PHP 요구 사항 | 8.1 이상 | 8.4 이상 | | Symfony 요구 사항 | 6.4 이상 | 7.4 이상 |

2.x 사이클 동안 도입된 UX Toolkit은 Shadcn UI 또는 Flowbite 4.0으로 스타일링된 프리빌트 UI 컴포넌트(Button, Dialog, Card, Table, Pagination)를 제공하여 제거된 패키지의 빈자리를 채웁니다.

Symfony 면접 준비를 하고 있다면, Live Components와 이벤트 시스템에 대한 이해는 시니어급 포지션에서 갈수록 중요해지고 있습니다. Symfony 면접 질문 가이드에서는 이 실무 지식과 잘 어울리는 기초 주제를 다루고 있습니다.

결론

  • Live Components는 CRUD 중심의 Symfony 애플리케이션 대부분에서 JavaScript 프레임워크의 필요성을 제거합니다. 데이터 바인딩, 액션, 유효성 검사, 폼 처리가 모두 PHP와 Twig에서 이루어집니다
  • #[LiveProp(writable: true, url: true)]는 단 하나의 속성으로 북마크 가능하고 공유 가능한 상태 유지 인터페이스를 생성합니다
  • 지연 로딩과 레이지 로딩(loading="defer" / loading="lazy")은 무거운 컴포넌트가 비동기적으로 렌더링되는 동안 초기 페이지 로드를 빠르게 유지합니다
  • UX 3.0은 CSRF 토큰을 폐지하고 동일 출처/CORS 보호를 도입했습니다. 움직이는 부품이 적은 더 단순한 보안 구조입니다
  • 이벤트 시스템(emit / #[LiveListener])은 전역 상태 관리 없이 컴포넌트 간 느슨한 결합 통신을 가능하게 합니다
  • API 구축에는 백엔드 중심 아키텍처를 위해 Symfony 7의 API Platform을 보완적으로 활용할 수 있습니다

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#symfony
#live components
#symfony ux
#php
#twig
#reactive ui

공유

관련 기사