Symfony 인터뷰 질문: 2026년 Top 25
가장 많이 묻는 Symfony 인터뷰 질문 25선. 아키텍처, Doctrine ORM, 서비스, 보안, 폼, 테스트를 상세한 답변과 코드 예제로 다룹니다.

Symfony 인터뷰는 전문가용 PHP 레퍼런스 프레임워크에 대한 숙련도, 컴포넌트 지향 아키텍처와 Doctrine ORM에 대한 이해, 견고하고 확장 가능한 애플리케이션을 구축하는 능력을 평가합니다. 이 가이드는 Symfony의 기초부터 고급 프로덕션 패턴까지, 가장 많이 묻는 25가지 질문을 다룹니다.
채용 담당자는 Symfony의 철학을 이해하는 지원자를 선호합니다. 즉 서비스를 통한 결합 분리, 명시적인 구성, PSR 표준 준수입니다. 프레임워크의 아키텍처 선택을 설명할 수 있다는 점이 차이를 만듭니다.
Symfony 기초
질문 1: Symfony의 요청 라이프사이클을 설명해 주세요
Symfony 요청 라이프사이클은 HTTP Kernel을 거치며, 각 단계에서 확장이 가능하도록 이벤트 시스템을 사용합니다. 이를 이해하는 것은 디버깅과 애플리케이션 동작을 커스터마이즈하는 데 필수적입니다.
// 모든 HTTP 요청의 진입점
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
// Symfony Runtime이 부트스트랩을 처리합니다
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};// Kernel이 요청 처리를 조율합니다
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
// Kernel은 번들을 로드하고 컨테이너를 구성합니다
// 라이프사이클의 주요 이벤트:
// 1. kernel.request - 라우팅 전
// 2. kernel.controller - 컨트롤러 해석 후
// 3. kernel.view - 컨트롤러가 Response를 반환하지 않을 때
// 4. kernel.response - 응답 전송 전
// 5. kernel.terminate - 전송 후(비동기 작업)
}전체 사이클: index.php → Runtime → Kernel → HttpKernel → EventDispatcher (kernel.request) → Router → Controller → EventDispatcher (kernel.response) → Response. 각 단계는 Event Subscribers로 가로챌 수 있습니다.
질문 2: Symfony의 Service Container와 Dependency Injection이란 무엇입니까?
Service Container(또는 DIC, Dependency Injection Container)는 Symfony의 핵심입니다. 애플리케이션의 모든 서비스의 인스턴스화, 구성, 주입을 관리합니다.
// 의존성이 자동 주입되는 서비스
namespace App\Service;
use App\Repository\OrderRepository;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PaymentService
{
public function __construct(
private readonly HttpClientInterface $stripeClient, // 구성된 HTTP 클라이언트
private readonly OrderRepository $orderRepository, // Doctrine 리포지토리
private readonly LoggerInterface $logger, // PSR-3 로거
private readonly string $stripeApiKey, // 주입된 파라미터
) {}
public function processPayment(int $orderId, float $amount): bool
{
$order = $this->orderRepository->find($orderId);
try {
$response = $this->stripeClient->request('POST', '/charges', [
'body' => [
'amount' => $amount * 100,
'currency' => 'eur',
'source' => $order->getPaymentToken(),
],
]);
$order->markAsPaid($response->toArray()['id']);
$this->orderRepository->save($order, true);
return true;
} catch (\Exception $e) {
$this->logger->error('Payment failed', [
'order' => $orderId,
'error' => $e->getMessage(),
]);
return false;
}
}
}# config/services.yaml
# 서비스 구성
services:
_defaults:
autowire: true # type-hint 기반 자동 주입
autoconfigure: true # 태그 자동 구성
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# 파라미터를 포함한 명시적 구성
App\Service\PaymentService:
arguments:
$stripeClient: '@stripe.client'
$stripeApiKey: '%env(STRIPE_API_KEY)%'Autowiring은 type-hint를 통해 의존성을 자동으로 해결합니다. 스칼라 파라미터는 명시적 구성이 필요합니다.
질문 3: Bundle과 Symfony 컴포넌트의 차이는 무엇입니까?
Bundle은 Symfony 애플리케이션에 기능을 통합하는 재사용 가능한 패키지입니다. 컴포넌트는 Symfony 없이도 사용할 수 있는 독립적인 PHP 라이브러리입니다.
// 사용자 정의 Bundle 구조
namespace App\MyBundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class MyBundle extends AbstractBundle
{
// 번들 구성 로드
public function loadExtension(
array $config,
ContainerConfigurator $container,
ContainerBuilder $builder
): void {
// 번들 서비스 로드
$container->import('../config/services.yaml');
// 조건부 구성
if ($config['feature_enabled']) {
$container->services()
->set('my_bundle.feature_service', FeatureService::class)
->autowire();
}
}
// 번들 기본 구성
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->booleanNode('feature_enabled')->defaultTrue()->end()
->scalarNode('api_key')->isRequired()->end()
->end();
}
}// Symfony 없이 컴포넌트 사용
// 컴포넌트는 독립적인 PHP 라이브러리입니다
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
// 어떤 PHP 프로젝트에서도 사용 가능
$request = Request::createFromGlobals();
$response = new Response('Hello World', 200);
$response->send();Bundle은 구성, 서비스, 자원을 캡슐화합니다. 컴포넌트는 어디서나 재사용 가능한 저수준 도구입니다.
질문 4: Symfony의 Event Subscriber는 어떻게 동작합니까?
Event Subscribers는 프레임워크나 애플리케이션의 이벤트에 반응하도록 하여 비즈니스 로직을 메인 코드와 분리할 수 있게 합니다.
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
class ApiExceptionSubscriber implements EventSubscriberInterface
{
// 구독할 이벤트와 우선순위 선언
public static function getSubscribedEvents(): array
{
return [
// 높은 우선순위(다른 것들보다 먼저 실행)
KernelEvents::EXCEPTION => ['onKernelException', 100],
KernelEvents::RESPONSE => ['onKernelResponse', 0],
];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
$request = $event->getRequest();
// API 요청만 처리
if (!str_starts_with($request->getPathInfo(), '/api')) {
return;
}
$statusCode = $exception instanceof HttpExceptionInterface
? $exception->getStatusCode()
: 500;
$response = new JsonResponse([
'error' => true,
'message' => $exception->getMessage(),
'code' => $statusCode,
], $statusCode);
// 응답을 우리가 만든 JSON으로 대체
$event->setResponse($response);
}
public function onKernelResponse(\Symfony\Component\HttpKernel\Event\ResponseEvent $event): void
{
// 사용자 정의 헤더 추가
$event->getResponse()->headers->set('X-Api-Version', '1.0');
}
}Event Subscribers는 autoconfigure 덕분에 자동으로 발견됩니다. 우선순위는 실행 순서를 결정합니다(값이 클수록 먼저 실행).
Doctrine ORM
질문 5: Doctrine 관계와 그 차이를 설명해 주세요
Doctrine은 엔티티 간의 연관을 모델링하기 위해 여러 관계 타입을 제공합니다. 각 타입은 쿼리와 성능에 영향을 미칩니다.
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// OneToOne 관계: 사용자는 하나의 프로필을 가집니다
#[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
private ?Profile $profile = null;
// OneToMany 관계: 사용자는 여러 글을 가집니다
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Article::class, orphanRemoval: true)]
private Collection $articles;
// ManyToMany 관계: 여러 사용자가 여러 역할을 가집니다
#[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
#[ORM\JoinTable(name: 'user_roles')]
private Collection $roles;
public function __construct()
{
// 컬렉션 초기화는 필수입니다
$this->articles = new ArrayCollection();
$this->roles = new ArrayCollection();
}
public function addArticle(Article $article): static
{
if (!$this->articles->contains($article)) {
$this->articles->add($article);
$article->setAuthor($this); // 양방향 동기화
}
return $this;
}
public function removeArticle(Article $article): static
{
if ($this->articles->removeElement($article)) {
if ($article->getAuthor() === $this) {
$article->setAuthor(null);
}
}
return $this;
}
}#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
// ManyToOne 관계: 여러 글이 하나의 저자에 속합니다
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
// pivot 엔티티를 통해 추가 속성을 가지는 ManyToMany
#[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleTag::class)]
private Collection $articleTags;
}양방향 관계는 수동 동기화가 필요합니다. "owning" 측(JoinColumn/JoinTable을 가진 쪽)이 영속성을 제어합니다.
질문 6: N+1 문제란 무엇이고 Doctrine으로 어떻게 해결합니까?
N+1 문제는 메인 쿼리가 관계를 로드하기 위해 N개의 추가 쿼리를 만들 때 발생합니다. Symfony 애플리케이션에서 가장 흔한 성능 저하 원인입니다.
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
// 나쁨: 저자 접근 시 N+1 쿼리
public function findAllBad(): array
{
return $this->findAll();
// + 글마다 저자를 로드하기 위한 1개의 쿼리
}
// 좋음: eager fetch와 함께 JOIN
public function findAllWithAuthor(): array
{
return $this->createQueryBuilder('a')
->addSelect('u') // 저자도 SELECT
->leftJoin('a.author', 'u') // 관계 기반 JOIN
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
// 좋음: 여러 관계에 대한 다중 JOIN
public function findAllWithDetails(): array
{
return $this->createQueryBuilder('a')
->addSelect('u', 'c', 't') // 모든 관계를 SELECT
->leftJoin('a.author', 'u')
->leftJoin('a.comments', 'c')
->leftJoin('a.tags', 't')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
// 좋음: 큰 리스트는 배치 로딩
public function findAllOptimized(): array
{
$query = $this->createQueryBuilder('a')
->getQuery();
// 100개 단위로 관계를 로드
$query->setFetchMode(Article::class, 'author', ClassMetadata::FETCH_BATCH);
return $query->getResult();
}
}Doctrine 패널이 포함된 Symfony Profiler는 N+1 문제 검출에 도움이 됩니다. 쿼리 수는 Web Debug Toolbar에서 확인할 수 있습니다.
질문 7: Query Extensions와 Doctrine 필터를 어떻게 만듭니까?
Query Extensions와 Doctrine 필터는 모든 쿼리에 자동으로 조건을 적용할 수 있게 해주며 멀티 테넌시나 소프트 삭제에 적합합니다.
// 사용자별 자동 필터링을 위한 API Platform 확장
namespace App\Doctrine\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Article;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private readonly Security $security,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = []
): void {
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
// Article에만 적용
if ($resourceClass !== Article::class) {
return;
}
// 관리자는 모두 볼 수 있음
if ($this->security->isGranted('ROLE_ADMIN')) {
return;
}
$user = $this->security->getUser();
$rootAlias = $queryBuilder->getRootAliases()[0];
// 저자 기준 자동 필터
$queryBuilder
->andWhere(sprintf('%s.author = :current_user', $rootAlias))
->setParameter('current_user', $user);
}
}// 삭제된 항목을 제외하는 글로벌 Doctrine 필터
namespace App\Doctrine\Filter;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
class SoftDeleteFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
// 엔티티에 deletedAt 필드가 있는지 확인
if (!$targetEntity->hasField('deletedAt')) {
return '';
}
return sprintf('%s.deleted_at IS NULL', $targetTableAlias);
}
}# config/packages/doctrine.yaml
doctrine:
orm:
filters:
soft_delete:
class: App\Doctrine\Filter\SoftDeleteFilter
enabled: true필터는 SQL 수준에서, extension은 QueryBuilder 수준에서 적용됩니다. 일시적으로 비활성화하려면 $em->getFilters()->disable('soft_delete')를 사용합니다.
Symfony 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
Symfony 보안
질문 8: Symfony의 보안 시스템은 어떻게 동작합니까?
Symfony의 Security 컴포넌트는 인증(사용자가 누구인가)과 인가(무엇을 할 수 있는가)를 확장 가능한 아키텍처로 관리합니다.
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
jwt: ~ # Lexik JWT Bundle
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: ROLE_USER }
- { path: ^/admin, roles: ROLE_ADMIN }// 특정 로직을 위한 사용자 정의 인증자
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
// 이 인증자는 X-API-KEY가 있는 요청만 처리
return $request->headers->has('X-API-KEY');
}
public function authenticate(Request $request): Passport
{
$apiKey = $request->headers->get('X-API-KEY');
if (null === $apiKey) {
throw new AuthenticationException('No API key provided');
}
// UserBadge가 식별자로 사용자를 로드
return new SelfValidatingPassport(
new UserBadge($apiKey, function (string $apiKey) {
// API 키로 사용자 로드 로직
return $this->userRepository->findByApiKey($apiKey);
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// null = 요청을 정상적으로 계속 진행
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse([
'error' => $exception->getMessage(),
], Response::HTTP_UNAUTHORIZED);
}
}보안 아키텍처는 Firewalls(구성), Authenticators(인증), Voters(인가)로 구성됩니다.
질문 9: 세분화된 인가를 위해 Voter를 어떻게 구현합니까?
Voter는 복잡하고 재사용 가능한 인가 로직을 작성할 수 있게 해주며 비즈니스 규칙을 컨트롤러 코드와 분리합니다.
namespace App\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
public const VIEW = 'ARTICLE_VIEW';
public const EDIT = 'ARTICLE_EDIT';
public const DELETE = 'ARTICLE_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
// 이 voter는 Article과 해당 속성만 처리
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
$article = $subject;
// 게시된 글은 누구나 볼 수 있음
if ($attribute === self::VIEW && $article->isPublished()) {
return true;
}
// 다른 동작은 인증된 사용자가 필요
if (!$user instanceof User) {
return false;
}
return match ($attribute) {
self::VIEW => $this->canView($article, $user),
self::EDIT => $this->canEdit($article, $user),
self::DELETE => $this->canDelete($article, $user),
default => false,
};
}
private function canView(Article $article, User $user): bool
{
// 초안은 저자나 관리자에게만 보임
return $article->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
private function canEdit(Article $article, User $user): bool
{
// 저자만 편집할 수 있음
return $article->getAuthor() === $user;
}
private function canDelete(Article $article, User $user): bool
{
// 저자나 관리자가 삭제할 수 있음
return $article->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
}// 컨트롤러에서 Voter 사용
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ArticleController extends AbstractController
{
#[Route('/articles/{id}/edit', name: 'article_edit')]
#[IsGranted(ArticleVoter::EDIT, subject: 'article')]
public function edit(Article $article): Response
{
// 인가는 자동으로 검증됩니다
// voter가 거부하면 403
}
// 프로그래밍 방식의 대안
#[Route('/articles/{id}', name: 'article_show')]
public function show(Article $article): Response
{
$this->denyAccessUnlessGranted(ArticleVoter::VIEW, $article);
// 또는 조건과 함께
if (!$this->isGranted(ArticleVoter::EDIT, $article)) {
// 편집 버튼 숨기기
}
}
}{# Twig 안에서 #}
{% if is_granted('ARTICLE_EDIT', article) %}
<a href="{{ path('article_edit', {id: article.id}) }}">편집</a>
{% endif %}Voter는 자동으로 발견되며 isGranted() 호출 시 참조됩니다. 기본 전략은 적어도 한 Voter가 찬성표를 던지면 접근을 허용합니다.
질문 10: Symfony에서 JWT로 API를 어떻게 보호합니까?
JWT(JSON Web Token) 인증은 무상태 API를 위한 표준 솔루션입니다. Symfony는 보통 LexikJWTAuthenticationBundle을 사용합니다.
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600 # 1시간namespace App\Controller\Api;
use App\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api')]
class AuthController extends AbstractController
{
#[Route('/login', name: 'api_login', methods: ['POST'])]
public function login(
Request $request,
UserPasswordHasherInterface $passwordHasher,
JWTTokenManagerInterface $jwtManager,
): JsonResponse {
$data = json_decode($request->getContent(), true);
$user = $this->userRepository->findOneBy(['email' => $data['email']]);
if (!$user || !$passwordHasher->isPasswordValid($user, $data['password'])) {
return new JsonResponse(['error' => 'Invalid credentials'], 401);
}
// JWT 토큰 생성
$token = $jwtManager->create($user);
return new JsonResponse([
'token' => $token,
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
],
]);
}
#[Route('/refresh-token', name: 'api_refresh_token', methods: ['POST'])]
public function refreshToken(): JsonResponse
{
// 구성된 경우 번들이 자동 처리
// refresh token에서 새 토큰을 반환
}
}// JWT 페이로드 사용자 정의
namespace App\EventListener;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
class JWTCreatedListener
{
public function onJWTCreated(JWTCreatedEvent $event): void
{
$user = $event->getUser();
$payload = $event->getData();
// 토큰에 사용자 정의 데이터 추가
$payload['user_id'] = $user->getId();
$payload['email'] = $user->getEmail();
$payload['permissions'] = $user->getPermissions();
$event->setData($payload);
}
}JWT 토큰은 Authorization: Bearer <token> 헤더로 전송됩니다. 번들이 자동으로 서명과 만료 시점을 검증합니다.
Symfony 폼
질문 11: 검증을 포함한 고급 폼은 어떻게 만듭니까?
Symfony의 Form 컴포넌트는 HTML 폼을 생성하고 제출을 처리하며 constraint로 데이터를 검증합니다.
namespace App\Form;
use App\Entity\Article;
use App\Entity\Category;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => '글 제목',
'attr' => ['placeholder' => '제목을 입력하세요...'],
'constraints' => [
new Assert\NotBlank(message: '제목은 필수입니다'),
new Assert\Length(
min: 10,
max: 255,
minMessage: '제목은 최소 {{ limit }}자 이상이어야 합니다',
),
],
])
->add('content', TextareaType::class, [
'label' => '내용',
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 100),
],
])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'placeholder' => '카테고리를 선택하세요',
'query_builder' => function ($repo) {
return $repo->createQueryBuilder('c')
->where('c.active = true')
->orderBy('c.name', 'ASC');
},
])
->add('coverImage', FileType::class, [
'label' => '커버 이미지',
'mapped' => false, // 엔티티에 매핑되지 않음
'required' => false,
'constraints' => [
new Assert\Image(
maxSize: '5M',
mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
mimeTypesMessage: '지원되지 않는 이미지 형식입니다',
),
],
])
->add('publishedAt', DateTimeType::class, [
'widget' => 'single_text',
'required' => false,
'label' => '게시일',
]);
// 폼을 동적으로 수정하기 위한 이벤트 리스너
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$article = $event->getData();
$form = $event->getForm();
// 편집 시에만 필드 추가
if ($article && $article->getId()) {
$form->add('slug', TextType::class, [
'disabled' => true,
'help' => 'slug는 변경할 수 없습니다',
]);
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
'validation_groups' => ['Default', 'article_creation'],
]);
}
}#[Route('/articles/new', name: 'article_new')]
public function new(Request $request, SluggerInterface $slugger): Response
{
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// 파일 업로드 처리
$coverImage = $form->get('coverImage')->getData();
if ($coverImage) {
$filename = $slugger->slug($article->getTitle()).'-'.uniqid().'.'.$coverImage->guessExtension();
$coverImage->move($this->getParameter('covers_directory'), $filename);
$article->setCoverImageFilename($filename);
}
$this->entityManager->persist($article);
$this->entityManager->flush();
$this->addFlash('success', '글이 성공적으로 작성되었습니다!');
return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
}
return $this->render('article/new.html.twig', [
'form' => $form,
]);
}FormEvents(PRE_SET_DATA, POST_SUBMIT 등)는 컨텍스트에 따라 필드를 동적으로 변경할 수 있게 합니다.
질문 12: constraint를 사용해 사용자 정의 검증을 어떻게 구현합니까?
Symfony는 복잡한 비즈니스 규칙을 위한 사용자 정의 검증 constraint를 만들 수 있게 합니다.
// 사용자 정의 constraint
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniqueEmail extends Constraint
{
public string $message = '이메일 "{{ value }}"는 이미 사용 중입니다.';
public ?int $excludeId = null; // 업데이트 시 현재 사용자를 제외하기 위해
}// constraint와 연결된 검증기
namespace App\Validator;
use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class UniqueEmailValidator extends ConstraintValidator
{
public function __construct(
private readonly UserRepository $userRepository,
) {}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof UniqueEmail) {
throw new UnexpectedTypeException($constraint, UniqueEmail::class);
}
if (null === $value || '' === $value) {
return; // NotBlank가 빈 값을 처리함
}
$existingUser = $this->userRepository->findOneBy(['email' => $value]);
// 이 이메일을 가진 사용자가 있는지 확인
// 그리고 현재 사용자가 아닌지 확인(업데이트 시)
if ($existingUser && $existingUser->getId() !== $constraint->excludeId) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}
}// 엔티티에 constraint 사용
use App\Validator as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
class User
{
#[Assert\NotBlank]
#[Assert\Email]
#[AppAssert\UniqueEmail]
private ?string $email = null;
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
#[Assert\Regex(
pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/',
message: '비밀번호에는 대문자, 소문자, 숫자가 포함되어야 합니다'
)]
private ?string $plainPassword = null;
}// 다중 필드 검증을 위한 클래스 레벨 constraint
// src/Validator/PasswordMatch.php
#[\Attribute(\Attribute::TARGET_CLASS)]
class PasswordMatch extends Constraint
{
public string $message = '비밀번호가 일치하지 않습니다.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}사용자 정의 constraint는 자동으로 발견됩니다. Validator 접미사는 검증기에 필수입니다.
Messenger와 비동기 통신
질문 13: Messenger로 비동기 처리를 어떻게 구현합니까?
Symfony Messenger는 메시지를 큐로 보내 비동기 처리를 가능하게 하여 애플리케이션 응답성을 높입니다.
// 메시지(데이터를 담는 DTO)
namespace App\Message;
final class SendWelcomeEmail
{
public function __construct(
public readonly int $userId,
public readonly string $locale = 'en',
) {}
}// 메시지를 처리하는 핸들러
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use App\Repository\UserRepository;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly MailerInterface $mailer,
) {}
public function __invoke(SendWelcomeEmail $message): void
{
$user = $this->userRepository->find($message->userId);
if (!$user) {
return; // 그 사이 사용자가 삭제됨
}
$email = (new TemplatedEmail())
->to($user->getEmail())
->subject('우리 플랫폼에 오신 것을 환영합니다!')
->htmlTemplate('emails/welcome.html.twig')
->context([
'user' => $user,
'locale' => $message->locale,
]);
$this->mailer->send($email);
}
}# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
# 메시지를 async 트랜스포트로 라우팅
App\Message\SendWelcomeEmail: async
App\Message\ProcessImage: async
App\Message\GenerateReport: async// 메시지 디스패치
use Symfony\Component\Messenger\MessageBusInterface;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register', methods: ['POST'])]
public function register(
Request $request,
MessageBusInterface $bus,
): Response {
// ... 사용자 생성
// 비동기 디스패치 - 이메일은 백그라운드에서 발송
$bus->dispatch(new SendWelcomeEmail(
userId: $user->getId(),
locale: $request->getLocale(),
));
// 사용자에게 즉시 응답
return $this->redirectToRoute('app_login');
}
}워커는 php bin/console messenger:consume async -vv로 시작합니다. 운영 환경에서는 워커 유지를 위해 Supervisor를 사용합니다.
질문 14: Messenger에서 오류와 재시도를 어떻게 처리합니까?
Messenger는 자동 재시도, 데드 레터 큐, 실패 메시지 수동 처리 등 견고한 실패 처리 메커니즘을 제공합니다.
namespace App\MessageHandler;
use App\Exception\PaymentFailedException;
use App\Exception\PaymentRetryableException;
use App\Message\ProcessPayment;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
#[AsMessageHandler]
final class ProcessPaymentHandler
{
public function __invoke(ProcessPayment $message): void
{
try {
$this->paymentGateway->process($message->orderId);
} catch (PaymentRetryableException $e) {
// 일시적 오류(timeout, rate limit) → 재시도
throw new RecoverableMessageHandlingException(
$e->getMessage(),
previous: $e
);
} catch (PaymentFailedException $e) {
// 영구적 오류(잘못된 카드) → 재시도하지 않음
throw new UnrecoverableMessageHandlingException(
$e->getMessage(),
previous: $e
);
}
}
}// 메시지 단위의 재시도 구성
namespace App\Message;
use Symfony\Component\Messenger\Stamp\DelayStamp;
final class ProcessPayment
{
public function __construct(
public readonly int $orderId,
public readonly int $attempt = 1,
) {}
// 시도 횟수에 따른 사용자 정의 재시도 지연
public function getRetryDelay(): int
{
return match ($this->attempt) {
1 => 5000, // 5초
2 => 30000, // 30초
3 => 300000, // 5분
default => 600000,
};
}
}# 실패 메시지 관리 명령
php bin/console messenger:failed:show # 실패 메시지 목록 보기
php bin/console messenger:failed:retry # 모든 메시지 재시도
php bin/console messenger:failed:retry 123 # 특정 메시지 재시도
php bin/console messenger:failed:remove 123 # 메시지 삭제재시도 전략과 "failed" 트랜스포트 덕분에 메시지가 손실되지 않습니다. 메시지를 분석하고 수동으로 다시 시도할 수 있습니다.
Symfony 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
Symfony에서 테스트
질문 15: Symfony에서 테스트는 어떻게 구성합니까?
Symfony는 PHPUnit과 함께 애플리케이션의 다양한 계층을 테스트하기 위한 전용 헬퍼를 제공합니다: 단위, 기능, 통합 테스트입니다.
// 단위 테스트: 격리된 클래스를 검증
namespace App\Tests\Unit\Service;
use App\Service\PriceCalculator;
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PriceCalculator();
}
public function testCalculateTotalWithoutDiscount(): void
{
$total = $this->calculator->calculateTotal(100.00, 0);
$this->assertEquals(100.00, $total);
}
public function testCalculateTotalWithPercentageDiscount(): void
{
$total = $this->calculator->calculateTotal(100.00, 20);
$this->assertEquals(80.00, $total);
}
/**
* @dataProvider discountProvider
*/
public function testCalculateTotalWithVariousDiscounts(
float $price,
int $discount,
float $expected
): void {
$total = $this->calculator->calculateTotal($price, $discount);
$this->assertEquals($expected, $total);
}
public static function discountProvider(): array
{
return [
'no discount' => [100.00, 0, 100.00],
'10% discount' => [100.00, 10, 90.00],
'50% discount' => [200.00, 50, 100.00],
'max discount' => [100.00, 100, 0.00],
];
}
}// 기능 테스트: HTTP를 통해 컨트롤러 검증
namespace App\Tests\Functional\Controller;
use App\Entity\Article;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ArticleControllerTest extends WebTestCase
{
private $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = static::createClient();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
}
public function testListArticlesReturnsOk(): void
{
$this->client->request('GET', '/articles');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('h1');
}
public function testCreateArticleRequiresAuthentication(): void
{
$this->client->request('GET', '/articles/new');
$this->assertResponseRedirects('/login');
}
public function testAuthenticatedUserCanCreateArticle(): void
{
// 인증
$user = $this->createUser();
$this->client->loginUser($user);
// 폼 접근
$crawler = $this->client->request('GET', '/articles/new');
$this->assertResponseIsSuccessful();
// 폼 제출
$form = $crawler->selectButton('생성')->form([
'article[title]' => 'Test Article Title',
'article[content]' => '이것은 충분한 글자수를 가진 제 테스트 글의 내용입니다.',
]);
$this->client->submit($form);
// 검증
$this->assertResponseRedirects();
$this->client->followRedirect();
$this->assertSelectorTextContains('h1', 'Test Article Title');
// 데이터베이스에서 확인
$article = $this->entityManager->getRepository(Article::class)
->findOneBy(['title' => 'Test Article Title']);
$this->assertNotNull($article);
}
private function createUser(): User
{
$user = new User();
$user->setEmail('test@example.com');
$user->setPassword('$2y$13$hashedpassword');
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
protected function tearDown(): void
{
// 테스트 데이터베이스 정리
$this->entityManager->getConnection()->executeStatement('DELETE FROM article');
$this->entityManager->getConnection()->executeStatement('DELETE FROM user');
parent::tearDown();
}
}단위 테스트(kernel 없음), 기능 테스트(kernel 사용), 통합 테스트(실 서비스 사용)를 분리하는 것이 좋습니다.
질문 16: fixture와 DatabaseResetter는 어떻게 사용합니까?
Fixture는 현실적인 테스트 데이터로 데이터베이스를 채웁니다. DoctrineTestBundle 컴포넌트는 테스트 간 초기화를 쉽게 합니다.
namespace App\DataFixtures;
use App\Entity\Article;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class ArticleFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
for ($i = 1; $i <= 20; $i++) {
$article = new Article();
$article->setTitle("Test Article Number $i");
$article->setSlug("test-article-$i");
$article->setContent("$i번 글의 상세 내용...");
$article->setStatus($i <= 15 ? 'published' : 'draft');
$article->setPublishedAt($i <= 15 ? new \DateTimeImmutable("-$i days") : null);
// UserFixtures가 만든 사용자 참조
$article->setAuthor($this->getReference('user-'.($i % 3), User::class));
$manager->persist($article);
// 다른 fixture를 위해 reference 생성
$this->addReference("article-$i", $article);
}
$manager->flush();
}
public function getDependencies(): array
{
// UserFixtures는 ArticleFixtures보다 먼저 로드되어야 함
return [
UserFixtures::class,
];
}
}namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserFixtures extends Fixture implements FixtureGroupInterface
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
public function load(ObjectManager $manager): void
{
$users = [
['email' => 'admin@example.com', 'roles' => ['ROLE_ADMIN'], 'ref' => 'user-0'],
['email' => 'author@example.com', 'roles' => ['ROLE_AUTHOR'], 'ref' => 'user-1'],
['email' => 'user@example.com', 'roles' => ['ROLE_USER'], 'ref' => 'user-2'],
];
foreach ($users as $userData) {
$user = new User();
$user->setEmail($userData['email']);
$user->setRoles($userData['roles']);
$user->setPassword($this->passwordHasher->hashPassword($user, 'password123'));
$manager->persist($user);
$this->addReference($userData['ref'], $user);
}
$manager->flush();
}
public static function getGroups(): array
{
return ['test', 'dev'];
}
}// 자동 초기화를 위한 DAMADoctrineTestBundle 사용
namespace App\Tests\Functional;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ArticleControllerTest extends WebTestCase
{
use \DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver;
public function testPublishedArticlesCount(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$count = $em->getRepository(Article::class)
->count(['status' => 'published']);
$this->assertEquals(15, $count); // fixture 기준
}
}# fixture 로드
php bin/console doctrine:fixtures:load
php bin/console doctrine:fixtures:load --group=test
php bin/console doctrine:fixtures:load --env=testDAMADoctrineTestBundle은 각 테스트를 롤백되는 트랜잭션으로 감싸 테스트 간 fixture 재로드를 피합니다.
아키텍처와 고급 패턴
질문 17: Symfony에서 CQRS는 어떻게 구현합니까?
CQRS(Command Query Responsibility Segregation)는 읽기와 쓰기 작업을 분리하여 각각 독립적으로 최적화할 수 있게 합니다.
// Command: 변경 의도를 표현
namespace App\Message\Command;
final class CreateArticleCommand
{
public function __construct(
public readonly string $title,
public readonly string $content,
public readonly int $authorId,
public readonly array $tagIds = [],
) {}
}// CommandHandler: 변경을 실행
namespace App\MessageHandler\Command;
use App\Entity\Article;
use App\Message\Command\CreateArticleCommand;
use App\Repository\TagRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\String\Slugger\SluggerInterface;
#[AsMessageHandler]
final class CreateArticleCommandHandler
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly TagRepository $tagRepository,
private readonly SluggerInterface $slugger,
) {}
public function __invoke(CreateArticleCommand $command): Article
{
$author = $this->userRepository->find($command->authorId)
?? throw new \InvalidArgumentException('Author not found');
$article = new Article();
$article->setTitle($command->title);
$article->setSlug($this->slugger->slug($command->title)->lower());
$article->setContent($command->content);
$article->setAuthor($author);
foreach ($command->tagIds as $tagId) {
$tag = $this->tagRepository->find($tagId);
if ($tag) {
$article->addTag($tag);
}
}
$this->entityManager->persist($article);
$this->entityManager->flush();
return $article;
}
}// Query: 읽기 요청을 표현
namespace App\Message\Query;
final class GetArticleBySlugQuery
{
public function __construct(
public readonly string $slug,
public readonly bool $withComments = false,
) {}
}// QueryHandler: 데이터 조회(최적화된 read model 사용 가능)
namespace App\MessageHandler\Query;
use App\DTO\ArticleDTO;
use App\Message\Query\GetArticleBySlugQuery;
use App\Repository\ArticleRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class GetArticleBySlugQueryHandler
{
public function __construct(
private readonly ArticleRepository $repository,
) {}
public function __invoke(GetArticleBySlugQuery $query): ?ArticleDTO
{
$qb = $this->repository->createQueryBuilder('a')
->select('a', 'u')
->leftJoin('a.author', 'u')
->where('a.slug = :slug')
->setParameter('slug', $query->slug);
if ($query->withComments) {
$qb->addSelect('c')
->leftJoin('a.comments', 'c');
}
$article = $qb->getQuery()->getOneOrNullResult();
return $article ? ArticleDTO::fromEntity($article) : null;
}
}// Command Bus와 Query Bus 사용
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
class ArticleController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $commandBus,
private readonly MessageBusInterface $queryBus,
) {}
#[Route('/articles', methods: ['POST'])]
public function create(Request $request): Response
{
$data = json_decode($request->getContent(), true);
$envelope = $this->commandBus->dispatch(new CreateArticleCommand(
title: $data['title'],
content: $data['content'],
authorId: $this->getUser()->getId(),
));
$article = $envelope->last(HandledStamp::class)->getResult();
return $this->json($article, 201);
}
#[Route('/articles/{slug}', methods: ['GET'])]
public function show(string $slug): Response
{
$envelope = $this->queryBus->dispatch(new GetArticleBySlugQuery(
slug: $slug,
withComments: true,
));
$article = $envelope->last(HandledStamp::class)->getResult();
return $this->json($article);
}
}CQRS는 읽기(캐싱, 프로젝션)와 쓰기(검증, 이벤트)를 별도로 최적화할 수 있게 해줍니다.
질문 18: Symfony에서 Repository Pattern은 어떻게 올바르게 구현합니까?
Repository Pattern은 이미 Doctrine을 통해 Symfony에 존재하지만, 인터페이스와 비즈니스 메서드로 보강할 수 있습니다.
// 디커플링과 테스트 용이성을 위한 인터페이스
namespace App\Repository\Contract;
use App\Entity\Article;
use Doctrine\Common\Collections\Collection;
interface ArticleRepositoryInterface
{
public function find(int $id): ?Article;
public function findBySlug(string $slug): ?Article;
public function findPublished(int $limit = 20, int $offset = 0): array;
public function findByAuthor(int $authorId): array;
public function save(Article $article, bool $flush = false): void;
public function remove(Article $article, bool $flush = false): void;
}// 리포지토리의 Doctrine 구현
namespace App\Repository;
use App\Entity\Article;
use App\Repository\Contract\ArticleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository implements ArticleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
public function findBySlug(string $slug): ?Article
{
return $this->createQueryBuilder('a')
->addSelect('u', 't')
->leftJoin('a.author', 'u')
->leftJoin('a.tags', 't')
->where('a.slug = :slug')
->setParameter('slug', $slug)
->getQuery()
->getOneOrNullResult();
}
public function findPublished(int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.publishedAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function findByAuthor(int $authorId): array
{
return $this->createQueryBuilder('a')
->where('a.author = :authorId')
->setParameter('authorId', $authorId)
->orderBy('a.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function save(Article $article, bool $flush = false): void
{
$this->getEntityManager()->persist($article);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Article $article, bool $flush = false): void
{
$this->getEntityManager()->remove($article);
if ($flush) {
$this->getEntityManager()->flush();
}
}
// 복잡한 쿼리 메서드
public function findPopularByCategory(int $categoryId, int $limit = 5): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.category = :categoryId')
->andWhere('a.status = :status')
->setParameter('categoryId', $categoryId)
->setParameter('status', 'published')
->orderBy('a.viewCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}# config/services.yaml
# 인터페이스를 구현체에 바인딩
services:
App\Repository\Contract\ArticleRepositoryInterface:
alias: App\Repository\ArticleRepository인터페이스를 사용하면 테스트 구현(InMemoryArticleRepository)을 만들거나, 비즈니스 코드를 변경하지 않고 데이터 소스를 변경할 수 있습니다.
질문 19: Symfony에서 구성과 환경은 어떻게 관리합니까?
Symfony는 환경 변수, 시크릿, 환경별 YAML 파일을 지원하는 유연한 구성 시스템을 사용합니다.
# config/packages/framework.yaml
# 기본 구성
framework:
secret: '%env(APP_SECRET)%'
http_method_override: false
handle_all_throwables: true
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
php_errors:
log: true# config/packages/prod/framework.yaml
# 운영 환경 오버라이드
framework:
session:
handler_id: '%env(REDIS_URL)%'
cookie_secure: true
when@prod:
framework:
router:
strict_requirements: null// 민감한 시크릿 관리(암호화)
// php bin/console secrets:set DATABASE_URL --env=prod
// php bin/console secrets:list --reveal --env=prod// 번들을 위한 사용자 정의 구성
namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('my_app');
$treeBuilder->getRootNode()
->children()
->scalarNode('api_key')
->isRequired()
->cannotBeEmpty()
->end()
->integerNode('cache_ttl')
->defaultValue(3600)
->min(0)
->end()
->arrayNode('features')
->addDefaultsIfNotSet()
->children()
->booleanNode('dark_mode')->defaultTrue()->end()
->booleanNode('beta_features')->defaultFalse()->end()
->end()
->end()
->end();
return $treeBuilder;
}
}// 구성 주입
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ConfigurableService
{
public function __construct(
#[Autowire('%env(API_KEY)%')]
private readonly string $apiKey,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
#[Autowire('%my_app.cache_ttl%')]
private readonly int $cacheTtl,
) {}
}Symfony 시크릿은 암호화되어 버전 관리됩니다. 런타임 변수에는 %env(...)%, 정적 값에는 파라미터를 사용해야 합니다.
성능과 운영
질문 20: Symfony 애플리케이션의 성능을 어떻게 최적화합니까?
최적화는 opcache, 구성, 애플리케이션 캐시, Doctrine 쿼리 등 여러 수준을 다룹니다.
# 운영 환경에 최적화된 Doctrine 구성
doctrine:
orm:
auto_generate_proxy_classes: false
metadata_cache_driver:
type: pool
pool: doctrine.system_cache_pool
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool# config/packages/cache.yaml
# Redis를 사용하는 캐시 구성
framework:
cache:
app: cache.adapter.redis
system: cache.adapter.system
pools:
doctrine.result_cache_pool:
adapter: cache.adapter.redis
default_lifetime: 3600
doctrine.system_cache_pool:
adapter: cache.adapter.system
services:
Redis:
class: Redis
calls:
- connect:
- '%env(REDIS_HOST)%'
- '%env(int:REDIS_PORT)%'
Symfony\Component\Cache\Adapter\RedisAdapter:
arguments:
- '@Redis'// Doctrine 결과 캐시 사용
public function findPopularCached(): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.viewCount', 'DESC')
->setMaxResults(10)
->getQuery()
->enableResultCache(3600, 'popular_articles') // 1시간 캐시
->getResult();
}# 운영 환경 최적화 명령
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
php bin/console doctrine:cache:clear-metadata --env=prod
php bin/console doctrine:cache:clear-query --env=prod
# Doctrine 프록시 생성
php bin/console doctrine:proxy:create-proxy-classes
# 최적화된 autoloader 컴파일
composer install --no-dev --optimize-autoloader --classmap-authoritativeOPcache는 운영 환경에서 최적 설정으로 활성화되어야 합니다. warmup은 컨테이너와 라우터 캐시를 생성합니다.
질문 21: Symfony에서 로깅과 모니터링은 어떻게 구성합니까?
구조화된 로깅과 적절한 모니터링은 운영 환경의 문제 진단에 필수입니다.
# config/packages/prod/monolog.yaml
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50
nested:
type: stream
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ['!event', '!doctrine']
slack:
type: slack
token: '%env(SLACK_TOKEN)%'
channel: '#alerts'
level: critical
bot_name: 'SymfonyBot'// 컨텍스트가 있는 구조화된 로깅
namespace App\Service;
use Psr\Log\LoggerInterface;
class PaymentService
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function processPayment(Order $order): bool
{
$this->logger->info('Payment processing started', [
'order_id' => $order->getId(),
'amount' => $order->getTotal(),
'currency' => $order->getCurrency(),
'user_id' => $order->getUser()->getId(),
]);
try {
$result = $this->gateway->charge($order);
$this->logger->info('Payment successful', [
'order_id' => $order->getId(),
'transaction_id' => $result->getTransactionId(),
]);
return true;
} catch (\Exception $e) {
$this->logger->error('Payment failed', [
'order_id' => $order->getId(),
'error' => $e->getMessage(),
'exception' => $e,
]);
return false;
}
}
}// HTTP 요청 로깅
namespace App\EventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class RequestLoggerSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::TERMINATE => 'onKernelTerminate',
];
}
public function onKernelTerminate(TerminateEvent $event): void
{
$request = $event->getRequest();
$response = $event->getResponse();
$this->logger->info('Request completed', [
'method' => $request->getMethod(),
'uri' => $request->getRequestUri(),
'status' => $response->getStatusCode(),
'duration_ms' => round((microtime(true) - $request->server->get('REQUEST_TIME_FLOAT')) * 1000),
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
]);
}
}JSON 형식은 모니터링 도구(ELK, Datadog) 수집에 용이합니다. 채널은 로그 종류별 필터링을 가능하게 합니다.
질문 22: Symfony 애플리케이션을 운영 환경에 어떻게 배포합니까?
견고한 Symfony 배포는 빌드 준비, 안전한 마이그레이션, 원자적인 전환을 결합합니다.
#!/bin/bash
# deploy.sh - 배포 스크립트
set -e
RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)"
SHARED_DIR="/var/www/shared"
CURRENT_LINK="/var/www/current"
echo "릴리스 디렉터리 생성 중..."
mkdir -p $RELEASE_DIR
echo "리포지토리 클론 중..."
git clone --depth 1 --branch main git@github.com:org/repo.git $RELEASE_DIR
echo "의존성 설치 중..."
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --classmap-authoritative
echo "공유 파일 링크 중..."
ln -sf $SHARED_DIR/.env.local $RELEASE_DIR/.env.local
ln -sf $SHARED_DIR/var/log $RELEASE_DIR/var/log
ln -sf $SHARED_DIR/public/uploads $RELEASE_DIR/public/uploads
echo "마이그레이션 실행 중..."
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
echo "캐시 워밍업 중..."
php bin/console cache:clear --env=prod --no-debug
php bin/console cache:warmup --env=prod --no-debug
echo "권한 설정 중..."
chown -R www-data:www-data $RELEASE_DIR
echo "새 릴리스로 전환 중..."
ln -sfn $RELEASE_DIR $CURRENT_LINK
echo "PHP-FPM 재시작 중..."
sudo systemctl reload php8.3-fpm
echo "Messenger 워커 재시작 중..."
php bin/console messenger:stop-workers
echo "이전 릴리스 정리 중..."
ls -dt /var/www/releases/*/ | tail -n +6 | xargs rm -rf
echo "배포 완료!"# .github/workflows/deploy.yml
# GitHub Actions를 사용한 CI/CD 배포
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: intl, pdo_pgsql, redis
- name: Install dependencies
run: composer install --no-dev --optimize-autoloader
- name: Run tests
run: php bin/phpunit
- name: Deploy to production
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/app
./deploy.sh심볼릭 링크를 통한 원자적 배포는 즉시 롤백을 가능하게 합니다. 새 코드를 적재하기 위해 Messenger 워커는 재시작되어야 합니다.
API Platform과 REST
질문 23: API Platform으로 REST API를 어떻게 만듭니까?
API Platform은 Symfony에서 REST와 GraphQL API를 만들기 위한 표준 솔루션이며 자동 문서화와 HTTP 표준을 제공합니다.
// API Platform 리소스 구성
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['article:list']],
),
new Get(
normalizationContext: ['groups' => ['article:read']],
),
new Post(
security: "is_granted('ROLE_AUTHOR')",
denormalizationContext: ['groups' => ['article:write']],
),
new Put(
security: "is_granted('ARTICLE_EDIT', object)",
),
new Patch(
security: "is_granted('ARTICLE_EDIT', object)",
),
new Delete(
security: "is_granted('ARTICLE_DELETE', object)",
),
],
order: ['publishedAt' => 'DESC'],
paginationItemsPerPage: 20,
)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['article:list', 'article:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 10, max: 255)]
#[Groups(['article:list', 'article:read', 'article:write'])]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
#[Groups(['article:read', 'article:write'])]
private ?string $content = null;
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['article:list', 'article:read'])]
private ?User $author = null;
#[ORM\Column(nullable: true)]
#[Groups(['article:list', 'article:read'])]
private ?\DateTimeImmutable $publishedAt = null;
}// 비즈니스 로직을 위한 사용자 정의 processor
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\String\Slugger\SluggerInterface;
final class ArticleProcessor implements ProcessorInterface
{
public function __construct(
private readonly ProcessorInterface $persistProcessor,
private readonly Security $security,
private readonly SluggerInterface $slugger,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): Article {
if ($data instanceof Article && !$data->getId()) {
// 새 글: 저자와 slug 설정
$data->setAuthor($this->security->getUser());
$data->setSlug($this->slugger->slug($data->getTitle())->lower());
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}# config/packages/api_platform.yaml
api_platform:
title: 'My API'
version: '1.0.0'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
docs_formats:
jsonld: ['application/ld+json']
jsonopenapi: ['application/vnd.openapi+json']
html: ['text/html']
defaults:
pagination_items_per_page: 20
pagination_client_items_per_page: true
pagination_maximum_items_per_page: 100
swagger:
versions: [3]API Platform은 OpenAPI 문서를 자동 생성하고 필터, 페이지네이션, 검증을 제공합니다.
질문 24: API Platform 작업을 어떻게 사용자 정의합니까?
API Platform은 컨트롤러나 State Provider/Processor를 사용하여 사용자 정의 작업을 만들 수 있습니다.
// 사용자 정의 작업
#[ApiResource(
operations: [
// 표준 작업
new GetCollection(),
new Get(),
// 컨트롤러를 사용하는 사용자 정의 작업
new Post(
uriTemplate: '/articles/{id}/publish',
controller: PublishArticleController::class,
openapi: new Model\Operation(
summary: '글 게시',
description: '글의 상태를 "published"로 변경',
),
security: "is_granted('ARTICLE_EDIT', object)",
),
// 사용자 정의 State Provider를 사용하는 작업
new GetCollection(
uriTemplate: '/articles/trending',
provider: TrendingArticlesProvider::class,
openapiContext: ['summary' => '인기 글'],
),
],
)]
class Article
{
// ...
}// 사용자 정의 작업을 위한 컨트롤러
namespace App\Controller;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpKernel\Attribute\AsController;
#[AsController]
class PublishArticleController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(Article $article): Article
{
if ($article->getStatus() === 'published') {
throw $this->createNotFoundException('이미 게시된 글입니다');
}
$article->setStatus('published');
$article->setPublishedAt(new \DateTimeImmutable());
$this->entityManager->flush();
return $article;
}
}// 사용자 정의 읽기 로직을 위한 State Provider
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\ArticleRepository;
final class TrendingArticlesProvider implements ProviderInterface
{
public function __construct(
private readonly ArticleRepository $repository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
// 인기 글을 위한 사용자 정의 로직
return $this->repository->createQueryBuilder('a')
->where('a.status = :status')
->andWhere('a.publishedAt > :date')
->setParameter('status', 'published')
->setParameter('date', new \DateTimeImmutable('-7 days'))
->orderBy('a.viewCount', 'DESC')
->setMaxResults(10)
->getQuery()
->getResult();
}
}State Provider는 읽기를, State Processor는 쓰기를 처리합니다. 컨트롤러는 복잡한 경우에 여전히 사용 가능합니다.
질문 25: 운영 환경에서 데이터베이스 마이그레이션을 어떻게 관리합니까?
Doctrine 마이그레이션은 무중단 실행이 가능하고 쉽게 롤백할 수 있도록 설계되어야 합니다.
// 안전한 마이그레이션: nullable 컬럼 추가
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260202100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add role column to users table (nullable first)';
}
public function up(Schema $schema): void
{
// 1단계: nullable 컬럼 추가
$this->addSql('ALTER TABLE users ADD role VARCHAR(50) DEFAULT NULL');
// CONCURRENTLY로 인덱스 생성(PostgreSQL - 비차단)
$this->addSql('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_role ON users (role)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS idx_users_role');
$this->addSql('ALTER TABLE users DROP COLUMN role');
}
}// 데이터 마이그레이션(분리)
final class Version20260202100001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Populate role column with default value';
}
public function up(Schema $schema): void
{
// 큰 테이블을 위한 배치 마이그레이션
$this->addSql("UPDATE users SET role = 'ROLE_USER' WHERE role IS NULL");
}
public function down(Schema $schema): void
{
// 데이터 롤백 불필요
}
}// 최종 마이그레이션: 컬럼을 NOT NULL로 변경
final class Version20260202100002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make role column NOT NULL';
}
public function up(Schema $schema): void
{
// 사전 검증
$count = $this->connection->fetchOne('SELECT COUNT(*) FROM users WHERE role IS NULL');
if ($count > 0) {
throw new \RuntimeException("$count users still have NULL role");
}
$this->addSql('ALTER TABLE users ALTER COLUMN role SET NOT NULL');
$this->addSql("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'ROLE_USER'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users ALTER COLUMN role DROP NOT NULL');
$this->addSql('ALTER TABLE users ALTER COLUMN role DROP DEFAULT');
}
}# 마이그레이션 관리 명령
php bin/console doctrine:migrations:status # 마이그레이션 상태
php bin/console doctrine:migrations:migrate # 마이그레이션 실행
php bin/console doctrine:migrations:migrate prev # 마지막 마이그레이션 롤백
php bin/console doctrine:migrations:diff # 스키마에서 마이그레이션 생성
php bin/console doctrine:migrations:execute --down # 특정 롤백expand-contract 전략(3회 배포)은 무중단 배포를 보장합니다: nullable 추가 → 데이터 마이그레이션 → 제약 추가.
결론
이 25가지 질문은 Service Container 기초부터 고급 운영 패턴까지 Symfony 인터뷰의 핵심을 다룹니다.
준비 체크리스트:
- ✅ Service Container와 의존성 주입
- ✅ Doctrine ORM: 관계, 쿼리, 필터
- ✅ 보안: 인증, voter, JWT
- ✅ 폼과 사용자 정의 검증
- ✅ Messenger: 비동기 처리와 오류 처리
- ✅ 테스트: 단위, 기능, fixture
- ✅ 아키텍처: CQRS, Repository Pattern
- ✅ API Platform: REST, 사용자 정의 작업
- ✅ 운영: 성능, 로깅, 배포
각 질문은 Symfony 공식 문서와 함께 더 깊이 탐구할 가치가 있습니다. 채용 담당자는 프레임워크의 아키텍처 선택을 이해하고 기술적 결정을 정당화할 수 있는 지원자를 높이 평가합니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

Doctrine ORM: Symfony에서 관계 마스터하기
Symfony의 Doctrine ORM 관계에 대한 완벽 가이드. OneToMany, ManyToMany, 로딩 전략, 그리고 실용적인 예제를 통한 성능 최적화.

Symfony 8 완벽 가이드: PHP 8.4 레이지 오브젝트, 멀티스텝 폼, 2026년 면접 대비까지
Symfony 8은 PHP 8.4를 필수로 요구하며 네이티브 레이지 오브젝트, AbstractFlowType, 호출 가능 커맨드 등 다수의 신기능을 탑재했습니다. 주요 기능을 코드 예제와 함께 분석하고 2026년 면접 대비 포인트를 정리합니다.

Symfony 7: API Platform과 베스트 프랙티스
Symfony 7과 API Platform 4로 전문적인 REST API를 구축하는 완벽 가이드입니다. State Provider, Processor, 유효성 검사, 직렬화를 실전 예제와 함께 설명합니다.