Symfony 보안 2026: Voter, 방화벽 그리고 기술 면접 질문

Symfony 보안 컴포넌트 심층 가이드: 방화벽 아키텍처, Voter 시스템, Access Token Handler, IsGranted 어트리뷰트, 접근 결정 전략, 그리고 2026년 기술 면접에 자주 등장하는 보안 관련 질문과 답변을 다룹니다.

Symfony security voters firewalls architecture

웹 애플리케이션 보안은 프레임워크를 선택하는 단계에서부터 시작됩니다. Symfony는 PHP 생태계에서 가장 정교한 보안 컴포넌트를 제공하는 프레임워크로, 인증(Authentication)과 인가(Authorization)를 명확하게 분리하는 계층적 아키텍처를 갖추고 있습니다. 방화벽(Firewall)이 HTTP 요청의 인증 방식을 결정하고, Voter가 세밀한 비즈니스 로직 기반의 접근 제어를 담당하며, 이 두 메커니즘이 유기적으로 결합하여 강력한 보안 체계를 구성합니다.

2026년 현재, Symfony의 보안 컴포넌트는 상당한 성숙 단계에 도달했습니다. 통합 인증 시스템(Unified Authenticator), 네이티브 IsGranted 어트리뷰트, 디버깅을 위한 Vote 객체 등 Symfony 6.0 이후 도입된 변경 사항들이 7.4 LTS에서 안정화되었습니다. 이 글에서는 Symfony 보안 아키텍처의 핵심 구성 요소를 코드 예제와 함께 상세히 살펴보고, 기술 면접에서 자주 출제되는 보안 관련 질문들을 분석합니다.

Symfony 7.4 LTS와 보안

Symfony 7.4 LTS는 2025년 11월에 출시되어 2029년 11월까지 지원됩니다. 이 버전은 Symfony 6.0 이후 진행된 Security 컴포넌트의 주요 변경 사항을 집대성합니다. 통합 인증 시스템, 네이티브 IsGranted 어트리뷰트, Profiler 및 Twig에서의 접근 결정 디버깅, Voter 시스템 개선 등이 포함됩니다. 2026년 프로덕션 프로젝트와 기술 면접에서의 기준 버전입니다.

방화벽: 첫 번째 방어선

Symfony의 방화벽은 모든 HTTP 요청이 통과하는 보안의 관문입니다. 각 방화벽은 URL 패턴을 기준으로 요청을 분류하며, 해당 요청에 적용할 인증 메커니즘을 결정합니다. 중요한 점은 방화벽이 선언 순서대로 매칭된다는 것입니다. 첫 번째로 일치하는 방화벽이 해당 요청을 처리하며, 이후의 방화벽은 무시됩니다.

yaml
# config/packages/security.yaml
security:
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt))/
            security: false

        api:
            pattern: ^/api/
            stateless: true
            custom_authenticators:
                - App\Security\ApiTokenHandler

        main:
            lazy: true
            provider: app_user_provider
            form_login:
                login_path: app_login
                check_path: app_login
            logout:
                path: app_logout

    access_control:
        - { path: ^/api/public, roles: PUBLIC_ACCESS }
        - { path: ^/api/, roles: ROLE_API_USER }
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/dashboard, roles: ROLE_USER }

위 설정에는 세 개의 방화벽이 정의되어 있습니다. dev 방화벽은 Symfony Profiler와 Web Debug Toolbar에 대해 보안을 완전히 비활성화합니다. api 방화벽은 /api/ 경로의 모든 요청을 상태 비저장(stateless) 방식으로 처리하며, 커스텀 인증기를 사용합니다. main 방화벽은 전통적인 폼 로그인 기반의 상태 저장(stateful) 인증을 담당합니다.

access_control 섹션도 선언 순서가 중요합니다. /api/public 경로에 대한 PUBLIC_ACCESS 규칙이 /api/ 전체에 대한 ROLE_API_USER 규칙보다 먼저 선언되어야 합니다. 순서가 뒤바뀌면 공개 API 엔드포인트에도 인증이 요구됩니다. PUBLIC_ACCESS는 단순히 security: false와 다릅니다. 방화벽은 여전히 활성 상태이므로 보안 이벤트가 발생하고, 토큰이 존재하면 사용자 정보에 접근할 수 있습니다.

lazy: true 옵션은 성능 최적화를 위한 설정입니다. Symfony는 보안 정보가 실제로 필요한 시점까지 세션 시작과 사용자 로딩을 지연시킵니다. 공개 페이지에서는 불필요한 데이터베이스 조회를 방지하여 응답 속도를 개선합니다.

API 라우트에서 security: false 사용 시 주의

security: false는 Security 컴포넌트를 완전히 비활성화합니다. 인증, 보안 이벤트 디스패치, 사용자 컨텍스트 로딩이 모두 중단됩니다. 프로덕션 라우트에는 절대 사용해서는 안 됩니다. 대신 방화벽을 활성 상태로 유지하면서 access_control에서 PUBLIC_ACCESS를 사용하는 것이 올바른 접근 방식입니다.

두 가지 인증 패러다임: Stateless vs Stateful

Symfony 보안 아키텍처에서 인증 방식의 선택은 애플리케이션의 특성에 따라 결정됩니다. Stateful 인증은 서버 측 세션에 사용자 정보를 저장하는 방식입니다. 사용자가 로그인하면 세션이 생성되고, 이후의 요청은 세션 쿠키를 통해 자동으로 인증됩니다. 전통적인 웹 애플리케이션에서 폼 로그인과 함께 사용하는 것이 일반적입니다.

Stateless 인증은 서버에 상태를 저장하지 않습니다. 모든 요청은 독립적이며, 각 요청에 인증 정보(주로 Bearer 토큰이나 API 키)가 포함되어야 합니다. REST API, 마이크로서비스, 모바일 앱 백엔드에 적합합니다. 서버 간 세션 공유가 필요 없으므로 수평 확장이 용이합니다.

실무에서는 하나의 애플리케이션에서 두 가지 방식을 동시에 사용하는 경우가 흔합니다. 관리자 패널은 폼 로그인(stateful)으로, API 엔드포인트는 토큰 기반(stateless)으로 보호하는 구성이 대표적입니다. Symfony의 다중 방화벽 구조는 이러한 하이브리드 설정을 자연스럽게 지원합니다.

커스텀 인증: Access Token Handler

Symfony는 AccessTokenHandlerInterface를 통해 API 토큰 기반 인증의 표준 구현 방식을 제공합니다. 이 인터페이스를 구현하면 Symfony의 보안 시스템과 완전히 통합되는 커스텀 토큰 핸들러를 만들 수 있습니다.

src/Security/ApiTokenHandler.phpphp
namespace App\Security;

use App\Repository\ApiTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;

final readonly class ApiTokenHandler implements AccessTokenHandlerInterface
{
    public function __construct(
        private ApiTokenRepository $repository,
    ) {}

    public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
    {
        $token = $this->repository->findOneByValue($accessToken);

        if ($token === null || !$token->isValid()) {
            throw new BadCredentialsException('Invalid or expired token.');
        }

        return new UserBadge($token->getUser()->getUserIdentifier());
    }
}

이 구현에서 주목할 부분이 여러 가지 있습니다. #[\SensitiveParameter] 어트리뷰트는 PHP 8.2에서 도입된 기능으로, 스택 트레이스나 오류 로그에 토큰 값이 노출되는 것을 방지합니다. 프로덕션 환경에서 보안 사고 발생 시 민감한 정보가 유출되지 않도록 하는 필수적인 보호 장치입니다.

getUserBadgeFrom() 메서드는 단일 책임 원칙을 충실히 따릅니다. 토큰의 유효성을 검증하고, 해당 토큰에 연결된 사용자를 식별하는 UserBadge를 반환하는 것이 전부입니다. 토큰이 유효하지 않거나 만료된 경우 BadCredentialsException을 발생시키면, Symfony가 자동으로 401 Unauthorized 응답을 반환합니다. final readonly 클래스 선언은 이 핸들러가 확장이나 수정 없이 그대로 사용되어야 함을 명시합니다.

세밀한 접근 제어 메커니즘: Voter

Voter는 Symfony 인가 시스템의 핵심입니다. 단순한 역할(Role) 기반 접근 제어가 "관리자인가, 일반 사용자인가"를 판단한다면, Voter는 "이 사용자가 이 특정 게시글을 편집할 수 있는가"와 같은 비즈니스 로직 기반의 복잡한 접근 제어를 가능하게 합니다.

Symfony 7.1에서 도입된 Vote 파라미터는 Voter의 디버깅 능력을 획기적으로 향상시켰습니다. 각 접근 결정에 이유(reason)를 첨부할 수 있어, Profiler나 Twig에서 왜 접근이 허용되었거나 거부되었는지를 명확하게 확인할 수 있습니다.

src/Security/Voter/PostVoter.phpphp
namespace App\Security\Voter;

use App\Entity\Post;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;

final class PostVoter extends Voter
{
    public const EDIT = 'POST_EDIT';
    public const DELETE = 'POST_DELETE';
    public const PUBLISH = 'POST_PUBLISH';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::EDIT, self::DELETE, self::PUBLISH])
            && $subject instanceof Post;
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token,
        ?Vote $vote = null,
    ): bool {
        $user = $token->getUser();
        if (!$user instanceof UserInterface) {
            $vote?->addReason('User is not authenticated.');
            return false;
        }

        /** @var Post $post */
        $post = $subject;

        return match ($attribute) {
            self::EDIT => $this->canEdit($post, $user, $vote),
            self::DELETE => $this->canDelete($post, $user, $vote),
            self::PUBLISH => $this->canPublish($post, $user, $vote),
            default => false,
        };
    }

    private function canEdit(Post $post, UserInterface $user, ?Vote $vote): bool
    {
        if ($post->getAuthor() === $user) {
            $vote?->addReason('User is the author of the post.');
            return true;
        }

        $vote?->addReason('User is not the author.');
        return false;
    }

    private function canDelete(Post $post, UserInterface $user, ?Vote $vote): bool
    {
        if (in_array('ROLE_ADMIN', $user->getRoles())) {
            $vote?->addReason('User has ROLE_ADMIN.');
            return true;
        }

        if ($post->getAuthor() === $user && !$post->isPublished()) {
            $vote?->addReason('Author can delete unpublished posts.');
            return true;
        }

        $vote?->addReason('Only admins or authors of unpublished posts can delete.');
        return false;
    }

    private function canPublish(Post $post, UserInterface $user, ?Vote $vote): bool
    {
        if (in_array('ROLE_EDITOR', $user->getRoles())) {
            $vote?->addReason('User has ROLE_EDITOR.');
            return true;
        }

        $vote?->addReason('Only editors can publish posts.');
        return false;
    }
}

supports() 메서드는 게이트키퍼 역할을 합니다. 주어진 어트리뷰트가 이 Voter가 처리할 수 있는 것인지, 그리고 대상 객체가 Post 인스턴스인지를 확인합니다. 이 메서드가 false를 반환하면 Voter는 기권(ABSTAIN)하며, true를 반환할 때만 voteOnAttribute()가 호출됩니다.

voteOnAttribute()에서는 PHP 8.0의 match 표현식을 사용하여 어트리뷰트별 로직을 깔끔하게 분기합니다. 특히 canDelete() 메서드는 역할 기반 검사와 소유권 검사를 결합하는 좋은 예시입니다. 관리자는 모든 게시글을 삭제할 수 있지만, 일반 작성자는 아직 발행되지 않은 자신의 게시글만 삭제할 수 있습니다. 이러한 복합 조건은 단순한 access_control로는 표현할 수 없으며, Voter가 필요한 대표적인 사례입니다.

Symfony 면접 준비가 되셨나요?

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

IsGranted 어트리뷰트: 선언적 접근 제어

#[IsGranted] 어트리뷰트는 컨트롤러 메서드에 접근 제어 규칙을 선언적으로 적용하는 방법입니다. $this->denyAccessUnlessGranted()를 메서드 본문 내에서 호출하는 명령형 방식과 달리, 어트리뷰트는 메서드 시그니처에서 바로 보안 요구사항을 명시합니다. 코드의 가독성이 향상되고, 보안 로직이 비즈니스 로직과 분리됩니다.

src/Controller/PostController.phpphp
namespace App\Controller;

use App\Entity\Post;
use App\Security\Voter\PostVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/post')]
final class PostController extends AbstractController
{
    #[Route('/{id}/edit', methods: ['GET', 'POST'])]
    #[IsGranted(PostVoter::EDIT, subject: 'post', message: 'You cannot edit this post.')]
    public function edit(Post $post): Response
    {
        // User is guaranteed to have edit permission at this point
        return $this->render('post/edit.html.twig', [
            'post' => $post,
        ]);
    }

    #[Route('/{id}/publish', methods: ['POST'])]
    #[IsGranted(PostVoter::PUBLISH, subject: 'post')]
    public function publish(Post $post): Response
    {
        // Only editors reach this code
        $post->setPublished(true);
        // ...
        return $this->redirectToRoute('post_show', ['id' => $post->getId()]);
    }
}

edit() 메서드의 subject: 'post' 파라미터는 컨트롤러 인자 $post를 Voter에 전달할 대상 객체로 자동 매핑합니다. Symfony의 ParamConverter가 URL의 {id}Post 엔티티로 변환한 뒤, 이 엔티티가 PostVotersupports() 메서드에 전달됩니다. 접근이 거부되면 message 파라미터에 지정된 메시지와 함께 403 Forbidden 응답이 반환됩니다.

publish() 메서드는 ROLE_EDITOR 역할을 가진 사용자만 도달할 수 있습니다. Voter 내부의 canPublish() 로직이 이를 보장하므로, 메서드 본문에서는 권한 검사 없이 비즈니스 로직에만 집중할 수 있습니다.

의사 결정 전략: unanimous, affirmative, consensus

하나의 어트리뷰트에 대해 여러 Voter가 투표할 수 있습니다. 이때 최종 결정을 내리는 방식을 Access Decision Manager의 전략(strategy)이 결정합니다.

yaml
# config/packages/security.yaml
security:
    access_decision_manager:
        strategy: unanimous
        allow_if_all_abstain: false

affirmative 전략은 기본값으로, 하나의 Voter라도 승인하면 접근이 허용됩니다. 대부분의 애플리케이션에 적합합니다. consensus 전략은 과반수의 Voter가 승인해야 접근이 허용됩니다. unanimous 전략은 모든 Voter가 승인해야 하며, 하나라도 거부하면 접근이 차단됩니다. 보안 요구사항이 엄격한 환경에서 사용합니다. priority 전략은 기권하지 않는 첫 번째 Voter의 결정을 따릅니다.

allow_if_all_abstain: false 설정은 모든 Voter가 기권한 경우 접근을 거부합니다. "기본 거부(deny by default)" 원칙을 적용하는 것으로, 보안 관점에서 권장되는 설정입니다.

Twig에서의 접근 결정 디버깅 (Symfony 7.4)

Symfony 7.4에서는 Twig 템플릿에서 접근 결정의 상세 정보를 직접 확인할 수 있는 디버깅 기능이 도입되었습니다. is_granted()가 단순한 불리언 값을 반환하는 것과 달리, 디버깅 함수는 각 Voter의 투표 결과와 이유를 포함하는 상세 객체를 제공합니다.

twig
{# templates/post/show.html.twig #}
{% if is_granted('POST_EDIT', post) %}
    <a href="{{ path('post_edit', {id: post.id}) }}">Edit</a>
{% endif %}

{% if is_granted('POST_DELETE', post) %}
    <form method="post" action="{{ path('post_delete', {id: post.id}) }}">
        <button type="submit">Delete</button>
    </form>
{% endif %}

{% if app.debug %}
    {# Symfony 7.4: access decision debugging in Twig #}
    {% set decision = is_granted_debug('POST_EDIT', post) %}
    <details>
        <summary>Access Decision Debug</summary>
        <ul>
            {% for voter_detail in decision.voterDetails %}
                <li>
                    {{ voter_detail.class }}:
                    {{ voter_detail.result > 0 ? 'GRANTED' : (voter_detail.result < 0 ? 'DENIED' : 'ABSTAIN') }}
                    {% for reason in voter_detail.reasons %}
                        <br>&rarr; {{ reason }}
                    {% endfor %}
                </li>
            {% endfor %}
        </ul>
    </details>
{% endif %}

is_granted_debug() 함수는 개발 환경에서만 사용해야 합니다. app.debug 조건으로 감싸면 프로덕션 빌드에서는 디버깅 블록이 렌더링되지 않습니다. 각 Voter의 클래스명, 투표 결과(GRANTED, DENIED, ABSTAIN), 그리고 Vote 객체에 추가된 이유가 모두 표시되므로, 접근 거부의 원인을 즉시 파악할 수 있습니다.

Voter 단위 테스트

Voter는 순수한 PHP 클래스이므로, Symfony 커널이나 데이터베이스 없이 단위 테스트가 가능합니다. 이는 Voter가 가진 가장 큰 장점 중 하나입니다. 모든 비즈니스 로직 경로를 빠르고 결정적(deterministic)으로 검증할 수 있습니다.

tests/Security/Voter/PostVoterTest.phpphp
namespace App\Tests\Security\Voter;

use App\Entity\Post;
use App\Entity\User;
use App\Security\Voter\PostVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

final class PostVoterTest extends TestCase
{
    private PostVoter $voter;

    protected function setUp(): void
    {
        $this->voter = new PostVoter();
    }

    public function testAuthorCanEditOwnPost(): void
    {
        $user = new User();
        $post = (new Post())->setAuthor($user);
        $token = new UsernamePasswordToken($user, 'main', ['ROLE_USER']);

        $this->assertSame(
            VoterInterface::ACCESS_GRANTED,
            $this->voter->vote($token, $post, [PostVoter::EDIT]),
        );
    }

    public function testNonAuthorCannotEditPost(): void
    {
        $author = new User();
        $otherUser = new User();
        $post = (new Post())->setAuthor($author);
        $token = new UsernamePasswordToken($otherUser, 'main', ['ROLE_USER']);

        $this->assertSame(
            VoterInterface::ACCESS_DENIED,
            $this->voter->vote($token, $post, [PostVoter::EDIT]),
        );
    }

    public function testAdminCanDeleteAnyPost(): void
    {
        $admin = new User();
        $post = (new Post())->setAuthor(new User());
        $token = new UsernamePasswordToken($admin, 'main', ['ROLE_ADMIN']);

        $this->assertSame(
            VoterInterface::ACCESS_GRANTED,
            $this->voter->vote($token, $post, [PostVoter::DELETE]),
        );
    }

    public function testAuthorCanDeleteUnpublishedPost(): void
    {
        $user = new User();
        $post = (new Post())->setAuthor($user)->setPublished(false);
        $token = new UsernamePasswordToken($user, 'main', ['ROLE_USER']);

        $this->assertSame(
            VoterInterface::ACCESS_GRANTED,
            $this->voter->vote($token, $post, [PostVoter::DELETE]),
        );
    }

    public function testAuthorCannotDeletePublishedPost(): void
    {
        $user = new User();
        $post = (new Post())->setAuthor($user)->setPublished(true);
        $token = new UsernamePasswordToken($user, 'main', ['ROLE_USER']);

        $this->assertSame(
            VoterInterface::ACCESS_DENIED,
            $this->voter->vote($token, $post, [PostVoter::DELETE]),
        );
    }

    public function testOnlyEditorCanPublish(): void
    {
        $user = new User();
        $post = (new Post())->setAuthor($user);
        $token = new UsernamePasswordToken($user, 'main', ['ROLE_EDITOR']);

        $this->assertSame(
            VoterInterface::ACCESS_GRANTED,
            $this->voter->vote($token, $post, [PostVoter::PUBLISH]),
        );
    }

    public function testVoterAbstainsOnUnsupportedAttribute(): void
    {
        $user = new User();
        $post = new Post();
        $token = new UsernamePasswordToken($user, 'main', ['ROLE_USER']);

        $this->assertSame(
            VoterInterface::ACCESS_ABSTAIN,
            $this->voter->vote($token, $post, ['UNSUPPORTED']),
        );
    }
}

테스트 케이스는 Voter의 모든 주요 분기를 포괄합니다. 작성자의 편집 권한, 비작성자의 접근 거부, 관리자의 삭제 권한, 미발행 게시글에 대한 작성자의 삭제 권한, 발행된 게시글에 대한 작성자의 삭제 거부, 편집자의 발행 권한, 그리고 지원하지 않는 어트리뷰트에 대한 기권까지 모든 경로가 검증됩니다. UsernamePasswordToken을 직접 생성하여 다양한 역할과 사용자 조합을 시뮬레이션할 수 있습니다.

보안 이벤트

Symfony는 보안 처리의 각 단계에서 이벤트를 디스패치합니다: AuthenticationSuccessEvent, LoginSuccessEvent, LogoutEvent, SwitchUserEvent, AccessDeniedEvent 등이 있습니다. 이 이벤트들은 로깅, 알림 전송, 추가 검증 등에 활용됩니다. 특히 AccessDeniedEvent를 수신하면 모니터링 시스템에 접근 거부 시도를 기록하여 보안 위협을 조기에 탐지할 수 있습니다.

보안 강화: UserChecker와 모범 사례

인증 과정에서 사용자 계정의 상태를 검증하는 추가적인 계층이 필요한 경우, UserCheckerInterface를 구현합니다. 차단된 계정이나 이메일 미인증 계정의 로그인을 방지하는 데 사용됩니다.

src/Security/UserEnabledChecker.phpphp
namespace App\Security;

use App\Entity\User;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;

final class UserEnabledChecker implements UserCheckerInterface
{
    public function checkPreAuth(UserInterface $user): void
    {
        if (!$user instanceof User) {
            return;
        }

        if ($user->isBanned()) {
            throw new CustomUserMessageAccountStatusException(
                'Your account has been banned. Contact support.'
            );
        }
    }

    public function checkPostAuth(UserInterface $user): void
    {
        if (!$user instanceof User) {
            return;
        }

        if (!$user->isVerified()) {
            throw new CustomUserMessageAccountStatusException(
                'Please verify your email address before logging in.'
            );
        }
    }
}

checkPreAuth()는 자격 증명 검증 이전에 호출됩니다. 차단된 계정의 비밀번호를 검증하는 것은 불필요한 리소스 낭비이므로, 이 단계에서 조기에 차단합니다. checkPostAuth()는 자격 증명이 유효한 이후에 호출되며, 이메일 인증 상태와 같은 추가 조건을 검사하기에 적합합니다.

Symfony 보안 설정에서 반드시 준수해야 할 모범 사례는 다음과 같습니다. **속도 제한(Rate Limiting)**은 login_throttling 옵션으로 무차별 대입 공격을 방지합니다. CSRF 보호는 폼 로그인과 상태 변경 요청에 반드시 활성화해야 합니다. 비밀번호 해싱은 Symfony의 PasswordHasher 컴포넌트를 사용하여 안전한 알고리즘(bcrypt 또는 sodium)을 적용합니다. 시크릿 로테이션kernel.secret 값을 주기적으로 변경하고, 환경 변수를 통해 관리합니다. 보안 헤더Content-Security-Policy, X-Frame-Options, Strict-Transport-Security 등을 웹 서버 또는 미들웨어 수준에서 설정합니다.

기술 면접 질문

Q1. Symfony에서 인증(Authentication)과 인가(Authorization)의 차이는 무엇입니까?

인증은 사용자의 신원을 확인하는 과정으로, 방화벽과 인증기(Authenticator)가 담당합니다. "이 사용자는 누구인가"에 대한 답을 제공합니다. 인가는 인증된 사용자가 특정 리소스에 접근할 수 있는지를 결정하는 과정으로, Voter와 Access Decision Manager가 담당합니다. "이 사용자가 이 작업을 수행할 수 있는가"에 대한 답을 제공합니다. 두 과정은 독립적이지만 순차적으로 실행되며, 인증이 반드시 먼저 이루어져야 합니다.

Q2. Voter와 access_control은 각각 언제 사용해야 합니까?

access_control은 URL 패턴과 역할에 기반한 정적 접근 제어에 적합합니다. 예를 들어 "/admin 경로는 ROLE_ADMIN만 접근 가능"과 같은 규칙입니다. Voter는 도메인 객체의 상태나 사용자와 객체 간의 관계에 따라 결정이 달라지는 동적 접근 제어에 사용합니다. "게시글 작성자만 자신의 게시글을 편집할 수 있다"와 같은 비즈니스 규칙은 Voter로만 구현할 수 있습니다. 대상 엔티티를 subject로 전달해야 하는 상황이라면 Voter를 사용해야 합니다.

Q3. Symfony 7.1에서 도입된 Vote 파라미터의 역할은 무엇입니까?

?Vote $vote = null 파라미터를 통해 Voter는 각 접근 결정에 설명을 추가할 수 있습니다. $vote->addReason('User is the author of the post.')처럼 사용하며, 이 정보는 Symfony Profiler의 Security 패널과 Twig의 is_granted_debug() 함수에서 확인할 수 있습니다. 접근이 거부된 원인을 파악하는 디버깅 시간을 크게 단축하며, 보안 감사(audit) 로그에도 활용할 수 있습니다. 하위 호환성을 위해 nullable 타입으로 선언되어 기존 Voter와의 호환이 유지됩니다.

Q4. 상태 비저장(Stateless) API를 어떻게 보호합니까?

방화벽에서 stateless: true를 설정하고, AccessTokenHandlerInterface를 구현한 커스텀 핸들러를 등록합니다. #[\SensitiveParameter] 어트리뷰트로 토큰 값의 로그 노출을 방지하고, 토큰의 유효성 및 만료 여부를 검증합니다. access_control에서 URL 패턴별 역할을 지정하되, 공개 엔드포인트에는 PUBLIC_ACCESS를 사용합니다. HTTPS를 필수로 적용하고, 토큰 로테이션 정책을 수립하는 것도 중요합니다.

Q5. Voter를 철저하게 테스트하는 방법은 무엇입니까?

Voter는 외부 의존성이 없는 순수 PHP 클래스이므로, PHPUnit의 TestCase만으로 단위 테스트가 가능합니다. UsernamePasswordToken을 직접 생성하여 다양한 역할 조합을 시뮬레이션합니다. 테스트해야 할 핵심 시나리오는 다음과 같습니다: 정상 접근 허용, 접근 거부, 비인증 사용자, 지원하지 않는 어트리뷰트에 대한 기권(ABSTAIN), 역할과 소유권의 복합 조건, 그리고 객체 상태(발행 여부 등)에 따른 분기입니다. 모든 match 분기와 조건 조합에 대해 테스트를 작성하여 100% 분기 커버리지를 목표로 합니다.

연습을 시작하세요!

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

결론

Symfony의 보안 컴포넌트는 2026년 현재 PHP 생태계에서 가장 완성도 높은 인증/인가 프레임워크입니다. 이 글에서 다룬 핵심 사항을 정리하면 다음과 같습니다.

  • 방화벽은 HTTP 요청의 인증 방식을 결정하는 첫 번째 관문이며, 선언 순서가 매칭 결과에 직접적인 영향을 미칩니다
  • Stateless vs Stateful 선택은 애플리케이션 아키텍처에 따라 결정되며, 하나의 애플리케이션에서 두 방식을 동시에 사용할 수 있습니다
  • Access Token HandlerAccessTokenHandlerInterface를 통해 API 인증의 표준 구현을 제공합니다
  • Voter는 비즈니스 로직 기반의 세밀한 접근 제어를 가능하게 하며, Vote 파라미터로 디버깅 투명성이 대폭 향상되었습니다
  • IsGranted 어트리뷰트는 컨트롤러에 선언적으로 접근 제어를 적용하여 코드의 가독성과 유지보수성을 높입니다
  • 의사 결정 전략(affirmative, consensus, unanimous, priority)은 여러 Voter의 투표를 집계하는 방식을 결정합니다
  • UserChecker는 인증 과정에서 계정 상태를 검증하는 추가 보안 계층을 제공합니다
  • Voter 단위 테스트는 커널 없이 빠르게 실행되며, 모든 비즈니스 로직 분기를 철저히 검증해야 합니다

연습을 시작하세요!

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

태그

#symfony
#security
#php
#voters
#firewall
#authentication
#interview

공유

관련 기사