Symfony 의존성 주입: 서비스 컨테이너, 오토와이어링, 태그 (2026)

Symfony 의존성 주입을 깊이 있게 다룹니다. 서비스 컨테이너, 오토와이어링, #[Autowire] 어트리뷰트, 인터페이스 바인딩, #[AutowireIterator]를 활용한 태그된 서비스, 컴파일러 패스, 지연 서비스를 Symfony 7.4와 8.0에서 설명합니다.

Symfony 의존성 주입의 서비스 컨테이너와 오토와이어링 아키텍처 다이어그램

Symfony 의존성 주입(DI)은 모든 Symfony 애플리케이션의 근간입니다. 서비스 컨테이너가 객체를 생성하고 설정하며 서로 연결해 주기 때문에, 각 클래스는 필요한 것을 직접 만드는 대신 무엇이 필요한지 선언하기만 하면 됩니다. Symfony 7.4와 8.0에서는 오토와이어링, PHP 어트리뷰트, 태그된 서비스 덕분에 명시적인 설정이 거의 필요 없어졌습니다. 그러면서도 컨테이너는 컴파일 시점에 완전히 해석되어 동작이 예측 가능한 상태로 유지됩니다. 이 해석 과정이 어떻게 동작하는지 이해하는지 여부가, 컨테이너와 씨름하는 개발자와 컨테이너에게 일을 맡기는 개발자를 가릅니다.

Symfony에서 의존성 주입이란 무엇일까요?

Symfony에서 의존성 주입은 객체가 자신의 의존성을 직접 생성하도록 두는 대신, 서비스 컨테이너가 생성자를 통해 객체의 의존성을 전달하는 패턴입니다. 오토와이어링은 타입 힌트가 붙은 각 인수를 대응하는 서비스로 자동으로 해석하므로, 대부분의 클래스는 수동 설정이 전혀 필요 없습니다.

Symfony 서비스 컨테이너가 의존성을 해석하는 방식

서비스 컨테이너는 애플리케이션 안의 모든 서비스를 어떻게 만들어야 하는지 알고 있는, 컴파일된 PHP 클래스입니다. 컨테이너 컴파일 단계에서 Symfony는 클래스 정의를 스캔하고 생성자 시그니처를 읽어 캐시된 팩토리를 생성합니다. 런타임에 서비스를 요청하면 지연 생성된 공유 인스턴스가 반환됩니다. 연결이 컴파일 시점에 이루어지기 때문에, 잘못 설정된 의존성은 운영 환경에서 요청이 들어올 때가 아니라 캐시가 빌드될 때 실패합니다.

서비스란 단지 어떤 작업을 수행하며 컨테이너가 관리하는 클래스일 뿐입니다. 기본 config/services.yamlsrc/ 아래의 모든 것을 오토와이어링과 오토컨피규레이션이 활성화된 서비스로 등록합니다. 평범한 클래스가 별도 설정 없이 동작하는 이유가 바로 이것입니다.

yaml
# config/services.yaml
services:
    _defaults:
        autowire: true       # inject arguments by type-hint
        autoconfigure: true  # apply tags based on implemented interfaces

    App\:
        resource: '../src/'
        exclude:
            - '../src/Entity/'
            - '../src/Kernel.php'

이러한 기본값이 갖춰져 있으면, 클래스는 생성자를 통해 협력 객체를 요청하고 컨테이너가 그것들을 공급합니다. 팩토리도, new도, 손으로 작성한 인수 목록도 필요 없습니다.

src/Notification/SlackNotifier.phpphp
namespace App\Notification;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class SlackNotifier
{
    // The container reads each type-hint and injects a matching service
    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly LoggerInterface $logger,
    ) {}

    public function send(string $channel, string $message): void
    {
        $this->httpClient->request('POST', 'https://slack.example/api/post', [
            'json' => ['channel' => $channel, 'text' => $message],
        ]);
        $this->logger->info('Slack message dispatched', ['channel' => $channel]);
    }
}

컨테이너는 SlackNotifier를 한 번만 인스턴스화하고, 공유된 HTTP 클라이언트와 로거를 주입한 뒤, 요청되는 모든 곳에서 그 인스턴스를 재사용합니다. 이 기본적으로 공유하는 동작이야말로 컨테이너를 빠르면서도 메모리 효율적으로 만드는 요소입니다. 전체 모델은 공식 Symfony 서비스 컨테이너 가이드에 문서화되어 있으며, 이는 PHP 생태계 전반에서 공유되는 PSR-11 컨테이너 인터페이스 표준 위에 세워져 있습니다.

요청마다 새 인스턴스가 필요한 경우, 예컨대 데이터를 누적하는 상태를 가진 빌더 같은 경우에는 공유를 끌 수 있습니다. 정의에 shared: false를 설정하면, 그 서비스는 주입될 때마다 새 객체를 반환하는 팩토리가 됩니다. 대부분의 서비스는 상태가 없는 협력 객체이므로 실제로는 드문 경우이지만, 이 기본값이 존재한다는 사실과 그것을 재정의하는 방법을 알아 두는 것은, 두 소비자가 같은 객체를 예상치 못하게 변경하던 데서 버그의 원인이 밝혀질 때 중요합니다.

Symfony 오토와이어링: 타입 힌트와 Autowire 어트리뷰트

Symfony 오토와이어링은 생성자 인수를 그 타입 선언으로 해석합니다. 인수에 정확히 하나의 서비스로 대응되는 클래스나 인터페이스 타입 힌트가 붙어 있으면, 컨테이너는 아무 설정 없이 그것을 주입합니다. 오토와이어링이 무너지는 경우는 서비스가 아닌 값, 즉 스칼라 값, 환경 변수, 파라미터뿐입니다. #[Autowire] 어트리뷰트는 그 틈을 인수 위에서 직접 메우며, 연결을 그것을 사용하는 코드 바로 옆에 둡니다.

src/Service/PaymentGateway.phpphp
namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class PaymentGateway
{
    public function __construct(
        // Inject a container parameter with the %...% syntax
        #[Autowire('%kernel.environment%')]
        private readonly string $environment,

        // Inject an environment variable, resolved at runtime
        #[Autowire(env: 'STRIPE_SECRET')]
        private readonly string $apiKey,

        // Bind a specific service when the type alone is ambiguous
        #[Autowire(service: 'monolog.logger.payment')]
        private readonly LoggerInterface $logger,
    ) {}
}

각 인수는 파라미터, 환경 변수, 이름 있는 서비스 중 하나라는 자신의 출처를 선언합니다. 이 어트리뷰트는 Expression Language 값을 위한 expression: 인수도 받는데, 의존성이 정적인 정의가 아니라 런타임 계산에서 나올 때 유용합니다. 이 메타데이터를 생성자에 두면, 읽는 사람은 별도의 YAML 파일을 열지 않고도 모든 값이 어디서 비롯되는지 정확히 알 수 있습니다. 어트리뷰트 레퍼런스는 Symfony 오토와이어링 문서에 있습니다.

구체 클래스가 아니라 인터페이스에 타입 힌트를 붙이세요

S3Storage 같은 구체 클래스를 오토와이어링하면 모든 소비자가 그 구현에 결합되어, 테스트에서 교체하기가 고통스러워집니다. 대신 인터페이스에 타입 힌트를 붙이고, 구체적인 서비스는 #[AsAlias]#[Autowire(service: ...)]가 결정하도록 맡기세요. 컨테이너 전체를 주입해 서비스를 손으로 꺼내는 방식은 컴파일 시점 검사를 무력화하며, 오토와이어링이 제거하려고 존재하는 바로 그 안티패턴입니다.

구현이 여러 개일 때 인터페이스 바인딩하기

인터페이스를 대상으로 한 오토와이어링은 두 클래스가 그것을 구현하기 전까지는 깔끔하게 동작합니다. 그 시점이 되면 컨테이너는 어느 것을 주입해야 할지 추측할 수 없어, 컴파일 시점에 모호성 오류를 던집니다. 이를 해결하는 어트리뷰트는 두 가지입니다. #[AsAlias]는 한 구현을 그 인터페이스의 기본값으로 지정하고, #[Autowire(service: ...)]는 개별 인수에 특정 구현을 고정합니다.

src/Storage/StorageInterface.phpphp
namespace App\Storage;

interface StorageInterface
{
    public function put(string $key, string $contents): void;
}
src/Storage/S3Storage.phpphp
namespace App\Storage;

use Symfony\Component\DependencyInjection\Attribute\AsAlias;

// Becomes the default service returned for StorageInterface
#[AsAlias(StorageInterface::class)]
final class S3Storage implements StorageInterface
{
    public function put(string $key, string $contents): void { /* upload to S3 */ }
}
src/Service/BackupService.phpphp
namespace App\Service;

use App\Storage\LocalStorage;
use App\Storage\StorageInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class BackupService
{
    public function __construct(
        // Resolves to the #[AsAlias] default (S3Storage)
        private readonly StorageInterface $primary,

        // Pins the concrete implementation explicitly by service id
        #[Autowire(service: LocalStorage::class)]
        private readonly StorageInterface $fallback,
    ) {}
}

#[Target] 어트리뷰트는 세 번째 선택지를 제공합니다. 이것은 구현을 그 오토와이어링 별칭 이름으로 참조하여, 인수 변수 이름을 해석 과정에서 분리합니다. 이 셋 사이의 선택은 의도로 귀결됩니다. 코드베이스 전반에서 한 구현이 진정한 기본값일 때는 #[AsAlias]를, 단일 소비자만 기본값이 아닌 변형을 필요로 할 때는 #[Autowire(service: ...)]#[Target]을 사용합니다. 이 패턴은 운영 애플리케이션에서 끊임없이 등장하며, Symfony 의존성 주입 면접 모듈에서도 단골 주제입니다.

Symfony 면접 준비가 되셨나요?

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

Symfony 서비스 태그와 AutowireIterator 수집기

태그는 관련된 서비스들을 묶어 다른 서비스가 한 번에 소비할 수 있게 하는 Symfony의 방식입니다. 이는 프레임워크 자체 곳곳에 나타나는 전략(strategy), 책임 연쇄(chain of responsibility), 플러그인 패턴을 뒷받침합니다. 폼 타입 확장, Twig 확장, 이벤트 구독자는 모두 태그 기반입니다. 수동으로 태그를 붙이는 대신, 인터페이스에 붙인 #[AutoconfigureTag]는 그것을 구현하는 모든 클래스에 자동으로 태그를 붙이도록 컨테이너에 지시합니다.

src/Export/ExporterInterface.phpphp
namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

// Every class implementing this interface is tagged automatically
#[AutoconfigureTag('app.exporter')]
interface ExporterInterface
{
    public function supports(string $format): bool;
    public function export(array $rows): string;
}

개별 구현은 #[AsTaggedItem]으로 컬렉션 안에서 자신의 위치를 제어하는데, 이는 우선순위(값이 높을수록 먼저 실행)와 키 기반 접근을 위한 선택적 문자열 인덱스를 설정합니다.

src/Export/CsvExporter.phpphp
namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

// Higher priority services appear earlier in the injected iterable
#[AsTaggedItem(priority: 10)]
final class CsvExporter implements ExporterInterface
{
    public function supports(string $format): bool
    {
        return $format === 'csv';
    }

    public function export(array $rows): string
    {
        return implode("\n", array_map(fn ($r) => implode(',', $r), $rows));
    }
}

소비하는 서비스는 #[AutowireIterator]를 사용해 태그된 모든 익스포터를 순서가 정해진 iterable로 수집합니다. 그런 다음 그 컬렉션을 순회하며, 요청된 포맷을 지원하는 첫 번째 전략에 처리를 위임합니다.

src/Export/ExportManager.phpphp
namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

final class ExportManager
{
    // Collects all services tagged 'app.exporter', ordered by priority
    public function __construct(
        #[AutowireIterator('app.exporter')]
        private readonly iterable $exporters,
    ) {}

    public function export(string $format, array $rows): string
    {
        foreach ($this->exporters as $exporter) {
            if ($exporter->supports($format)) {
                return $exporter->export($rows);
            }
        }

        throw new \InvalidArgumentException("No exporter for format: $format");
    }
}

컬렉션이 크거나 구축 비용이 높을 때는 #[AutowireLocator]가 지연 대안이 됩니다. 이것은 PSR-11 컨테이너를 주입하며, 태그된 서비스는 실제로 키로 가져올 때에만 인스턴스화됩니다. 미리 순회하는 방식과 지연해서 조회하는 방식 사이의 이 차이는, 연결이나 무거운 상태를 보유하는 서비스에 중요합니다.

오토컨피규레이션, 컴파일러 패스, 지연 서비스

오토컨피규레이션은 #[AutoconfigureTag]#[AsTaggedItem] 같은 어트리뷰트를 읽어 컴파일 시점에 적용하는 메커니즘입니다. EventSubscriberInterface를 구현하는 것만으로 설정 없이 이벤트 구독자가 등록되는 것이 바로 이 때문입니다. 어트리뷰트로 표현할 수 없는 변환을 위해, 컴파일러 패스는 컨테이너 빌드에 직접 후킹하여 캐시가 기록되기 전에 정의를 변형합니다. GitHub에 있는 DependencyInjection 컴포넌트 소스를 보면, 프레임워크 자신이 이런 패스를 내부적으로 수십 개나 사용하는 방식을 확인할 수 있습니다.

성능에 민감한 그래프는 지연 서비스의 이점을 누립니다. 인수에 #[Autowire(lazy: true)]를 붙이면, 생성된 프록시 라이브러리가 아니라 PHP 8.4 네이티브 지연 객체를 기반으로, 의존성이 처음 호출될 때까지 인스턴스화가 미뤄집니다.

src/Service/ReportBuilder.phpphp
namespace App\Service;

use App\Pdf\PdfRenderer;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class ReportBuilder
{
    public function __construct(
        // PdfRenderer is only instantiated on first actual use
        #[Autowire(lazy: true)]
        private readonly PdfRenderer $renderer,
    ) {}

    public function buildMonthly(): string
    {
        // If no method touches $this->renderer, it is never constructed
        return $this->renderer->render('monthly-report');
    }
}

네이티브 지연 객체는 PHP 8.4와 함께 등장했으며, Symfony 8과 PHP 8.4 지연 객체 가이드에서 깊이 있게 다룹니다. 그 대가로 얻는 것은 군더더기 없는 컨테이너입니다. PDF 렌더러, 서드파티 SDK 클라이언트, 리포트 생성기 같은 무거운 협력 객체는, 그것을 필요로 하는 코드 경로가 실제로 실행되기 전까지 아무런 비용도 들지 않습니다.

Symfony DI 면접 질문과 답변

Symfony DI에 관한 흔한 면접 질문은 오토와이어링과 오토컨피규레이션의 차이를 묻습니다. 오토와이어링은 개별 서비스의 생성자 인수를 타입으로 해석하고, 오토컨피규레이션은 서비스가 구현하는 인터페이스를 기준으로 태그, 메서드 호출, 설정을 적용합니다. 둘은 독립적인 스위치입니다. 서비스는 오토컨피규레이션 없이 오토와이어링될 수도 있고, 그 반대도 가능합니다.

또 다른 잦은 질문은 컨테이너가 왜 public 서비스와 private 서비스를 구분하는가입니다. 서비스는 기본적으로 private이며, 이는 $container->get()으로 컨테이너에서 직접 가져올 수 없고 오직 주입으로만 사용할 수 있다는 뜻입니다. private 서비스는 컴파일 시점에 인라인되어 최적화로 제거될 수 있어 더 빠릅니다. 반면 public 서비스는 조회 가능한 상태로 남아 있어야 하므로 최적화가 그대로 둡니다. 컨테이너에서 서비스를 수동으로 가져오는 행위가 안티패턴으로 취급되는 것은, 바로 이 최적화를 무력화하고 의존성을 숨기기 때문입니다.

순환 의존성은 고전적인 함정이자 자주 이어지는 질문입니다. 서비스 A가 생성자에서 B를 필요로 하고 B가 A를 필요로 하면, 컨테이너는 둘 중 어느 것도 만들 수 없어 컴파일 시점에 순환 참조를 보고합니다. 표준적인 해결책은 공유되는 동작을 세 번째 서비스로 추출해 순환을 끊거나, #[Autowire(lazy: true)]로 한쪽을 지연 주입으로 바꿔 의존성이 첫 호출 때에만 해석되도록 하거나, 세터 주입을 사용해 객체가 협력 객체가 연결되기 전에 존재하도록 하는 것입니다. 순환을 잠재우는 방법뿐 아니라 왜 순환이 설계상의 냄새인지 인식하는 것이야말로, 면접관이 실제로 귀 기울여 듣는 부분입니다.

면접관은 컨테이너 해석의 타이밍도 파고듭니다. Symfony는 컨테이너를 한 번 컴파일해 캐시하기 때문에, 해석할 수 없는 의존성은 요청 시점이 아니라 캐시 워밍업 시점에 드러납니다. 이 보장은 컴파일 시점 모델의 직접적인 결과이며, 대규모 Symfony 애플리케이션이 부하 속에서도 예측 가능한 상태를 유지하는 이유 중 하나입니다. 이런 질문의 더 폭넓은 모음은 Symfony 기술 트랙이 컴포넌트와 난이도별로 정리해 두었습니다.

정리

  • 컨테이너가 일하게 두세요. src/ 아래에 클래스를 등록하고, 생성자에서 의존성에 타입 힌트를 붙이며, 수동 YAML 연결은 완전히 건너뜁니다.
  • #[Autowire]는 서비스가 아닌 값, 파라미터, 환경 변수, 특정 서비스 ID에 대해서만 사용합니다.
  • 인터페이스 모호성은 기본값에는 #[AsAlias], 명시적 선택에는 #[Autowire(service: ...)]#[Target]로 해결합니다.
  • 전략과 플러그인 패턴은 #[AutoconfigureTag], #[AsTaggedItem] 우선순위, #[AutowireIterator]로 구동하고, 미리 순회하는 것보다 지연 조회가 나을 때는 #[AutowireLocator]로 전환합니다.
  • 비용이 큰 협력 객체는 #[Autowire(lazy: true)]로 미루며, 이는 PHP 8.4 네이티브 지연 객체를 사용합니다.
  • 서비스는 가져오는 대신 private로 두고 주입해, 컴파일러가 그래프를 인라인하고 최적화할 수 있게 합니다.

연습을 시작하세요!

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

태그

#symfony
#dependency injection
#service container
#autowiring
#php
#di

공유

관련 기사