# Symfony 7: API Platform과 베스트 프랙티스 > Symfony 7과 API Platform 4로 전문적인 REST API를 구축하는 완벽 가이드입니다. State Provider, Processor, 유효성 검사, 직렬화를 실전 예제와 함께 설명합니다. - Published: 2026-01-12 - Updated: 2026-04-07 - Author: SharpSkill - Tags: symfony, api platform, php, rest api, api development - Reading time: 14 min --- API Platform 4는 Symfony 7과 함께 REST 및 GraphQL API 개발 방식을 근본적으로 혁신합니다. 새로운 버전은 명확한 관심사 분리, 단순화된 State Provider 및 Processor, Symfony Object Mapper의 네이티브 통합이라는 재정립된 철학을 제시합니다. 전문적인 API 구축이 이보다 직관적이었던 적은 없었습니다. > **API Platform 4.2의 새로운 기능** > > 버전 4.2에서는 최대 +32% RPS 성능 향상을 제공하는 JSON Streamer, 재설계된 필터 시스템, 코어를 건드리지 않고 오퍼레이션을 커스터마이즈할 수 있는 Mutator가 도입되었습니다. Symfony 7 및 8 지원이 네이티브로 제공됩니다. ## 설치 및 초기 설정 API Platform은 Symfony Flex를 통해 몇 가지 명령어로 설치할 수 있습니다. 기본 설정은 대부분의 사용 사례를 커버하면서도 완전한 커스터마이즈가 가능합니다. ```bash # terminal # Create a new Symfony project with API Platform composer create-project symfony/skeleton my-api cd my-api # Install API Platform with Doctrine ORM composer require api # Verify installation php bin/console debug:router | grep api ``` Symfony Flex는 라우트, OpenAPI 문서, `/api`에서 접근 가능한 Swagger UI 인터페이스를 자동으로 설정합니다. ```yaml # config/packages/api_platform.yaml api_platform: title: 'My API' version: '1.0.0' # Supported response formats formats: jsonld: ['application/ld+json'] json: ['application/json'] # OpenAPI documentation swagger: versions: [3] # Default pagination defaults: pagination_items_per_page: 30 pagination_maximum_items_per_page: 100 ``` 이 설정은 직렬화 형식, 전역 페이지네이션, API 문서 메타데이터를 정의합니다. ## 간단한 API 리소스 생성 `#[ApiResource]` 어트리뷰트는 Doctrine 엔티티를 REST 리소스로 노출시킵니다. API Platform은 CRUD 엔드포인트, OpenAPI 문서, 기본 유효성 검사를 자동으로 생성합니다. ```php 'DESC'], // Pagination configuration for this resource paginationItemsPerPage: 20 )] class Book { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] #[Assert\NotBlank(message: 'Title is required')] #[Assert\Length(min: 2, max: 255)] private ?string $title = null; #[ORM\Column(type: 'text')] #[Assert\NotBlank] private ?string $description = null; #[ORM\Column(length: 13, unique: true)] #[Assert\Isbn] private ?string $isbn = null; #[ORM\Column] private ?\DateTimeImmutable $publishedAt = null; // Getters and setters... public function getId(): ?int { return $this->id; } public function getTitle(): ?string { return $this->title; } public function setTitle(string $title): static { $this->title = $title; return $this; } public function getDescription(): ?string { return $this->description; } public function setDescription(string $description): static { $this->description = $description; return $this; } public function getIsbn(): ?string { return $this->isbn; } public function setIsbn(string $isbn): static { $this->isbn = $isbn; return $this; } public function getPublishedAt(): ?\DateTimeImmutable { return $this->publishedAt; } public function setPublishedAt(\DateTimeImmutable $publishedAt): static { $this->publishedAt = $publishedAt; return $this; } } ``` 이 엔티티는 `GET /api/books`, `POST /api/books`, `GET /api/books/{id}`, `PUT /api/books/{id}`, `PATCH /api/books/{id}`, `DELETE /api/books/{id}` 여섯 개의 엔드포인트를 생성합니다. > **UUID v7 권장** > > API Platform은 식별자로 UUID v7을 네이티브 지원합니다. 이 방식은 보안(예측 불가능한 식별자)과 성능(생성 날짜 기준 자연 정렬)을 모두 향상시킵니다. ## 노출 데이터 제어를 위한 직렬화 그룹 직렬화 그룹은 읽기(정규화)와 쓰기(역정규화) 시 어떤 프로퍼티를 노출할지 정확하게 정의합니다. 이 분리는 API 보안과 유연성을 위해 필수적입니다. ```php ['Default', 'user:create']]), new Get(), new Put(processor: UserPasswordHasher::class), new Patch(processor: UserPasswordHasher::class), new Delete(), ], // Properties exposed when reading normalizationContext: ['groups' => ['user:read']], // Properties accepted when writing denormalizationContext: ['groups' => ['user:create', 'user:update']], )] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['user:read'])] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] #[Assert\NotBlank] #[Assert\Email] #[Groups(['user:read', 'user:create', 'user:update'])] private ?string $email = null; #[ORM\Column] private ?string $password = null; // Never exposed when reading, only when writing #[Assert\NotBlank(groups: ['user:create'])] #[Groups(['user:create', 'user:update'])] private ?string $plainPassword = null; #[ORM\Column(length: 100)] #[Groups(['user:read', 'user:create', 'user:update'])] private ?string $fullName = null; #[ORM\Column(type: 'json')] #[Groups(['user:read'])] private array $roles = []; #[ORM\Column] #[Groups(['user:read'])] private ?\DateTimeImmutable $createdAt = null; public function __construct() { $this->createdAt = new \DateTimeImmutable(); } // UserInterface implementation public function getUserIdentifier(): string { return (string) $this->email; } public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; return array_unique($roles); } public function getPassword(): string { return $this->password; } public function eraseCredentials(): void { $this->plainPassword = null; } // Getters and setters... public function getId(): ?int { return $this->id; } public function getEmail(): ?string { return $this->email; } public function setEmail(string $email): static { $this->email = $email; return $this; } public function setPassword(string $password): static { $this->password = $password; return $this; } public function getPlainPassword(): ?string { return $this->plainPassword; } public function setPlainPassword(?string $plainPassword): static { $this->plainPassword = $plainPassword; return $this; } public function getFullName(): ?string { return $this->fullName; } public function setFullName(string $fullName): static { $this->fullName = $fullName; return $this; } public function setRoles(array $roles): static { $this->roles = $roles; return $this; } public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } } ``` 이 설정을 통해 `plainPassword`는 응답에 절대 노출되지 않지만, 생성 또는 업데이트 시 전송할 수 있습니다. ## 비즈니스 로직을 위한 State Processor State Processor는 퍼시스턴스 오퍼레이션을 가로채 비즈니스 로직을 추가합니다. API Platform 4는 어트리뷰트 기반 의존성 주입을 통해 Processor 생성을 단순화합니다. ```php */ final class UserPasswordHasher implements ProcessorInterface { public function __construct( // Injection of standard Doctrine processor #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private ProcessorInterface $persistProcessor, private UserPasswordHasherInterface $passwordHasher, ) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User { // Hash password if provided if ($data->getPlainPassword()) { $hashedPassword = $this->passwordHasher->hashPassword( $data, $data->getPlainPassword() ); $data->setPassword($hashedPassword); $data->eraseCredentials(); } // Delegate persistence to standard processor return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } } ``` 이 컴포지션 패턴은 표준 퍼시스턴스 동작을 유지하면서 이메일 전송, 이벤트 발행, 로깅 등 모든 로직을 추가할 수 있게 해줍니다. ### 조건부 로직이 있는 Processor Processor는 오퍼레이션 유형에 따라 동작을 조정할 수 있습니다. ```php */ final class BookProcessor implements ProcessorInterface { public function __construct( #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private ProcessorInterface $persistProcessor, #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] private ProcessorInterface $removeProcessor, private NotificationService $notifications, private SearchIndexer $searchIndexer, ) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed { // Deletion: use remove processor if ($operation instanceof DeleteOperationInterface) { $this->searchIndexer->remove($data); return $this->removeProcessor->process($data, $operation, $uriVariables, $context); } // Creation: set publication date if ($operation instanceof Post) { $data->setPublishedAt(new \DateTimeImmutable()); } // Standard persistence $result = $this->persistProcessor->process($data, $operation, $uriVariables, $context); // Post-processing: indexing and notification $this->searchIndexer->index($result); if ($operation instanceof Post) { $this->notifications->notifyNewBook($result); } return $result; } } ``` ## 커스텀 데이터 소스를 위한 State Provider State Provider는 외부 API, 캐시, 파일, 복잡한 비즈니스 로직 등 어떤 소스에서든 데이터를 가져올 수 있습니다. ```php */ final class PopularBooksProvider implements ProviderInterface { public function __construct( private BookRepository $bookRepository, private CacheInterface $cache, ) { } public function provide(Operation $operation, array $uriVariables = [], array $context = []): array { // 5-minute cache for popular books return $this->cache->get('popular_books', function (ItemInterface $item) { $item->expiresAfter(300); return $this->bookRepository->findPopular(limit: 10); }); } } ``` 이 Provider는 전용 오퍼레이션에서 사용됩니다. ```php ['Default', 'article:create']]), // More lenient validation for updates new Put(validationContext: ['groups' => ['Default', 'article:update']]), ], )] class Article { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] #[Assert\NotBlank] #[Assert\Length(min: 10, max: 255)] private ?string $title = null; #[ORM\Column(type: 'text')] #[Assert\NotBlank] // Minimum 500 characters on creation #[Assert\Length(min: 500, groups: ['article:create'])] // Minimum 100 characters for updates #[Assert\Length(min: 100, groups: ['article:update'])] private ?string $content = null; #[ORM\Column(length: 50)] #[Assert\NotBlank(groups: ['article:create'])] #[Assert\Choice(choices: ['draft', 'published', 'archived'])] private ?string $status = 'draft'; #[ORM\Column(nullable: true)] // Required only if status is "published" #[Assert\NotBlank(groups: ['article:publish'])] private ?\DateTimeImmutable $publishedAt = null; // Getters and setters... } ``` ### 서비스를 이용한 동적 유효성 검사 복잡한 유효성 검사 규칙의 경우, 커스텀 그룹 생성기가 완전한 유연성을 제공합니다. ```php security->isGranted('ROLE_ADMIN')) { $groups[] = 'admin'; return $groups; } // Additional validation if publishing if ($object->getStatus() === 'published') { $groups[] = 'article:publish'; } return $groups; } } ``` > **유효성 검사 성능** > > 복잡한 유효성 검사는 성능에 영향을 줄 수 있습니다. 대량 가져오기의 경우 특정 유효성 검사를 일시적으로 비활성화하거나 비동기 제약 조건을 사용하는 것을 고려하십시오. ## 유연한 쿼리를 위한 필터 API Platform 4.2는 명확한 관심사 분리를 통해 필터 시스템을 완전히 재설계했습니다. 필터는 API 클라이언트가 데이터를 검색하고 정렬할 수 있도록 합니다. ```php 'partial', // LIKE %value% 'description' => 'partial', 'category.name' => 'exact', // Search on relation 'sku' => 'exact', // Exact match ])] // Range filtering #[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])] // Date filtering #[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])] // Boolean filtering #[ApiFilter(BooleanFilter::class, properties: ['isActive', 'isFeatured'])] // Customizable sorting #[ApiFilter(OrderFilter::class, properties: [ 'name', 'price', 'createdAt', ], arguments: ['orderParameterName' => 'sort'])] class Product { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private ?string $name = null; #[ORM\Column(type: 'text', nullable: true)] private ?string $description = null; #[ORM\Column(length: 50, unique: true)] private ?string $sku = null; #[ORM\Column(type: 'decimal', precision: 10, scale: 2)] private ?string $price = null; #[ORM\Column] private ?int $stock = null; #[ORM\Column] private ?bool $isActive = true; #[ORM\Column] private ?bool $isFeatured = false; #[ORM\ManyToOne(targetEntity: Category::class)] private ?Category $category = null; #[ORM\Column] private ?\DateTimeImmutable $createdAt = null; #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $updatedAt = null; // Getters and setters... } ``` 이 필터들은 OpenAPI 문서를 자동으로 생성하며 다음과 같은 쿼리를 가능하게 합니다. ``` GET /api/products?name=phone&price[gte]=100&price[lte]=500&isActive=true&sort[price]=asc ``` ## 관계 및 서브리소스 API Platform은 직렬화 옵션과 서브리소스를 통해 엔티티 관계를 우아하게 처리합니다. ```php ['author:read']], )] class Author { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['author:read', 'book:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Groups(['author:read', 'book:read'])] private ?string $name = null; #[ORM\Column(type: 'text', nullable: true)] #[Groups(['author:read'])] private ?string $biography = null; #[ORM\OneToMany(mappedBy: 'author', targetEntity: Book::class)] #[Groups(['author:read'])] private Collection $books; public function __construct() { $this->books = new ArrayCollection(); } // Getters and setters... } ``` ```php ['book:read']], )] // Subresource: GET /api/authors/{authorId}/books #[ApiResource( uriTemplate: '/authors/{authorId}/books', operations: [new GetCollection()], uriVariables: [ 'authorId' => new Link( fromProperty: 'books', fromClass: Author::class ), ], normalizationContext: ['groups' => ['book:read']], )] class Book { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['book:read', 'author:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Groups(['book:read', 'author:read'])] private ?string $title = null; #[ORM\ManyToOne(targetEntity: Author::class, inversedBy: 'books')] #[ORM\JoinColumn(nullable: false)] #[Groups(['book:read'])] private ?Author $author = null; // Getters and setters... } ``` ## 보안 및 접근 제어 API Platform은 Symfony의 보안 시스템과 완벽하게 통합됩니다. Voter와 보안 표현식이 리소스 접근을 제어합니다. ```php customer; } } ``` `object.getCustomer() == user` 표현식은 세밀한 검사를 위해 현재 엔티티와 로그인한 사용자 모두에 접근할 수 있게 합니다. ## 자동화된 API 테스팅 API Platform은 엔드포인트를 쉽게 테스트할 수 있는 PHPUnit 트레이트를 제공합니다. ```php request('GET', '/api/books'); // Assertions $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); $this->assertJsonContains([ '@context' => '/api/contexts/Book', '@type' => 'Collection', 'totalItems' => 30, ]); // Verify pagination (20 items per page) $this->assertCount(20, $response->toArray()['member']); } public function testCreateBook(): void { $user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]); static::createClient()->request('POST', '/api/books', [ 'auth_bearer' => $this->getToken($user), 'json' => [ 'title' => 'Clean Code', 'description' => 'A Handbook of Agile Software Craftsmanship', 'isbn' => '9780132350884', ], ]); $this->assertResponseStatusCodeSame(201); $this->assertJsonContains([ '@type' => 'Book', 'title' => 'Clean Code', ]); } public function testCreateBookValidationFails(): void { $user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]); static::createClient()->request('POST', '/api/books', [ 'auth_bearer' => $this->getToken($user), 'json' => [ 'title' => '', // Empty title = error 'isbn' => 'invalid-isbn', ], ]); $this->assertResponseStatusCodeSame(422); $this->assertJsonContains([ '@type' => 'ConstraintViolationList', 'violations' => [ ['propertyPath' => 'title', 'message' => 'Title is required'], ], ]); } private function getToken(object $user): string { // Implementation depends on your authentication system return 'test_token'; } } ``` ## 결론 Symfony 7과 함께하는 API Platform 4는 PHP에서 전문적인 REST API를 만드는 최신 기술을 대표합니다. State Provider(읽기)와 State Processor(쓰기)의 명확한 분리, 직렬화 그룹 및 유효성 검사 시스템의 조합은 견고하고 유지보수 가능한 API 구축을 가능하게 합니다. ### 고품질 API를 위한 체크리스트 - ✅ 읽기와 쓰기에 별도의 직렬화 그룹을 사용합니다 - ✅ 비즈니스 로직(비밀번호 해싱, 알림)에 State Processor를 구현합니다 - ✅ 검색 및 정렬을 위한 필터를 설정합니다 - ✅ 그룹을 사용하여 오퍼레이션별 유효성 검사를 적용합니다 - ✅ 보안 표현식으로 엔드포인트를 보호합니다 - ✅ 각 엔드포인트에 대한 기능 테스트를 작성합니다 - ✅ OpenAPI 메타데이터를 통해 API를 문서화합니다 API Platform 4의 철학은 상속보다 컴포지션을, 관례보다 설정을 지향합니다. 그 결과: 확장 가능하고 테스트 가능하며 REST/JSON-LD 표준을 준수하는 API가 첫날부터 프로덕션 준비 완료 상태로 만들어집니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/symfony/symfony-7-api-platform-best-practices