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

엔티티 간 관계는 Doctrine ORM을 사용하는 모든 Symfony 애플리케이션의 근간입니다. 다양한 관계 유형, 로딩 전략, 성능 함정을 깊이 이해하면 견고하고 성능 좋은 애플리케이션을 만들 수 있습니다. 이 가이드는 Doctrine 관계를 효과적으로 관리하기 위한 핵심 패턴을 다룹니다.
Doctrine은 소유 측과 역측이라는 개념을 사용합니다. 이 구분을 이해하면 관계 영속화와 관련된 많은 버그를 방지할 수 있습니다.
Doctrine 관계 유형
Doctrine은 엔티티 간에 OneToOne, OneToMany, ManyToOne, ManyToMany 네 가지 관계 유형을 제공합니다. 각 유형은 특정 비즈니스 요구를 충족하며 고유한 성능 특성을 갖습니다.
ManyToOne 관계: 가장 흔한 경우
ManyToOne 관계는 데이터베이스에서 가장 자주 등장하는 연결을 나타냅니다. 한 쪽의 여러 엔티티가 다른 쪽의 하나의 엔티티와 연결됩니다. 이 관계는 항상 연관의 소유 측입니다.
// A comment belongs to a single article
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CommentRepository::class)]
class Comment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'text')]
private string $content;
// ManyToOne: many comments → one article
// This entity owns the relationship (foreign key stored here)
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)] // Relationship is required
private Article $article;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getArticle(): Article
{
return $this->article;
}
public function setArticle(Article $article): self
{
$this->article = $article;
return $this;
}
}Comment 엔티티는 외래 키 article_id를 보유합니다. Doctrine이 영속화를 자동으로 처리하며, $comment->setArticle($article)을 호출하면 데이터베이스에 연결이 생성됩니다.
OneToMany 관계: 역측
OneToMany 관계는 ManyToOne의 역측을 나타냅니다. 「하나」 엔티티에서 「여러」 엔티티로 탐색할 수 있게 해 줍니다. 이 관계는 결코 소유 측이 아니며 외래 키도 보유하지 않습니다.
// An article has multiple comments
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: 'text')]
private string $content;
// OneToMany: one article → many comments
// Inverse side: mappedBy points to the owning entity's property
#[ORM\OneToMany(
targetEntity: Comment::class,
mappedBy: 'article',
cascade: ['persist', 'remove'], // Cascade operations
orphanRemoval: true // Remove orphaned comments
)]
private Collection $comments;
public function __construct()
{
// Initialize collection in the constructor
$this->comments = new ArrayCollection();
}
/**
* @return Collection<int, Comment>
*/
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(Comment $comment): self
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
// CRITICAL: synchronize the owning side
$comment->setArticle($this);
}
return $this;
}
public function removeComment(Comment $comment): self
{
if ($this->comments->removeElement($comment)) {
// orphanRemoval handles deletion
}
return $this;
}
}addComment()와 removeComment() 메서드는 양방향 일관성을 보장합니다. $comment->setArticle($this) 줄이 없으면 관계가 올바르게 영속화되지 않습니다.
양방향 관계의 양쪽을 동기화하는 것을 잊는 일이 가장 흔한 실수입니다. 영속화를 보장하려면 항상 소유 측을 수정해야 합니다.
ManyToMany 관계: 다대다 연관
ManyToMany 관계는 양쪽에서 여러 엔티티를 연결합니다. Doctrine이 자동으로 연결 테이블을 생성합니다. inversedBy 속성을 통해 한쪽을 소유 측으로 지정해야 합니다.
// An article can have multiple tags
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
// ... other properties
// ManyToMany: owning side (inversedBy)
// Join table: article_tag (auto-generated)
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
#[ORM\JoinTable(name: 'article_tag')] // Explicit table name
private Collection $tags;
public function __construct()
{
$this->tags = new ArrayCollection();
}
/**
* @return Collection<int, Tag>
*/
public function getTags(): Collection
{
return $this->tags;
}
public function addTag(Tag $tag): self
{
if (!$this->tags->contains($tag)) {
$this->tags->add($tag);
// Owning side: no need to sync other side for persistence
// but recommended for in-memory consistency
$tag->addArticle($this);
}
return $this;
}
public function removeTag(Tag $tag): self
{
if ($this->tags->removeElement($tag)) {
$tag->removeArticle($this);
}
return $this;
}
}// A tag can belong to multiple articles
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TagRepository::class)]
class Tag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50, unique: true)]
private string $name;
// Inverse side: mappedBy points to the owner
#[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')]
private Collection $articles;
public function __construct()
{
$this->articles = new ArrayCollection();
}
/**
* @return Collection<int, Article>
*/
public function getArticles(): Collection
{
return $this->articles;
}
public function addArticle(Article $article): self
{
if (!$this->articles->contains($article)) {
$this->articles->add($article);
}
return $this;
}
public function removeArticle(Article $article): self
{
$this->articles->removeElement($article);
return $this;
}
}추가 데이터(연관 날짜, 순서 등)가 있는 ManyToMany 관계의 경우, 중간 엔티티를 가리키는 두 개의 ManyToOne 관계로 변환하는 편이 좋습니다.
Symfony 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
로딩 전략과 성능
관계 로딩은 Doctrine에서 성능의 결정적 지점입니다. LAZY(기본), EAGER, EXTRA_LAZY 세 가지 전략이 있습니다.
Lazy Loading: 요청 시점 로딩
지연 로딩은 처음 접근할 때만 관계를 가져옵니다. 이 기본 전략은 불필요한 쿼리를 피하지만 N+1 문제를 일으킬 수 있습니다.
// Demonstrating the N+1 problem
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 PROBLEM: one query per article for comments
public function findAllWithLazyComments(): array
{
// Query 1: SELECT * FROM article
$articles = $this->findAll();
// In view or service:
// foreach ($articles as $article) {
// $article->getComments(); // Query N: SELECT * FROM comment WHERE article_id = ?
// }
return $articles;
}
}100개의 기사에 대해 이 코드는 101개의 SQL 쿼리를 생성합니다. 기사 목록을 위한 쿼리 한 번과, 각 기사의 댓글을 로딩하기 위한 쿼리 100번입니다.
join을 통한 즉시 로딩
즉시 로딩은 join을 사용해 같은 쿼리에서 관계를 가져옴으로써 N+1 문제를 해결합니다.
// Optimized loading with joins
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);
}
// SOLUTION: explicit join with fetch join
public function findAllWithComments(): array
{
return $this->createQueryBuilder('a')
// LEFT JOIN loads articles even without comments
->leftJoin('a.comments', 'c')
// addSelect includes comments in the result
->addSelect('c')
// Sort by comment creation date
->orderBy('c.createdAt', 'DESC')
->getQuery()
->getResult();
// Single SQL query with JOIN
}
// Loading multiple relationships
public function findAllWithCommentsAndTags(): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.comments', 'c')
->addSelect('c')
->leftJoin('a.tags', 't')
->addSelect('t')
->leftJoin('c.author', 'ca') // Comment author join
->addSelect('ca')
->getQuery()
->getResult();
}
}각 leftJoin() 뒤에 addSelect()를 사용하는 것이 필수적입니다. addSelect()가 없으면 Doctrine은 join을 수행하지만 관련 엔티티는 로딩하지 않습니다.
Extra Lazy: 대규모 컬렉션 최적화
EXTRA_LAZY 전략은 모든 요소를 로딩하지 않고도 대규모 컬렉션 작업을 최적화합니다.
// Category with potentially thousands of products
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private string $name;
// EXTRA_LAZY: optimizes count(), contains(), slice()
#[ORM\OneToMany(
targetEntity: Product::class,
mappedBy: 'category',
fetch: 'EXTRA_LAZY' // Does not load all products
)]
private Collection $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
// count() executes SELECT COUNT(*) instead of loading collection
public function getProductCount(): int
{
return $this->products->count();
// SQL: SELECT COUNT(*) FROM product WHERE category_id = ?
}
// contains() checks existence without loading everything
public function hasProduct(Product $product): bool
{
return $this->products->contains($product);
// SQL: SELECT 1 FROM product WHERE id = ? AND category_id = ?
}
// slice() loads only a portion
public function getRecentProducts(int $limit = 5): array
{
return $this->products->slice(0, $limit);
// SQL: SELECT * FROM product WHERE category_id = ? LIMIT 5
}
}EXTRA_LAZY는 단순한 확인이나 개수 집계를 위해 수천 개의 엔티티를 로딩하는 것을 막아 줍니다.
count(), contains(), slice() 작업이 자주 발생하는 잠재적으로 큰 컬렉션(100개 이상의 요소)에는 EXTRA_LAZY를 적용하는 편이 좋습니다.
캐스케이드와 라이프사이클 관리
캐스케이드 옵션은 관련 엔티티로의 작업 전파를 자동화합니다. 주요 옵션은 persist, remove, orphanRemoval 세 가지입니다.
Cascade Persist: 자동 영속화
Cascade persist는 flush 시점에 새 관련 엔티티를 자동으로 저장합니다.
// Order with cascaded order lines
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: '`order`')] // order is a SQL reserved word
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50)]
private string $reference;
// cascade persist: OrderLines are persisted with Order
#[ORM\OneToMany(
targetEntity: OrderLine::class,
mappedBy: 'order',
cascade: ['persist'] // Automatically persist lines
)]
private Collection $lines;
public function __construct()
{
$this->lines = new ArrayCollection();
$this->reference = 'ORD-' . uniqid();
}
public function addLine(OrderLine $line): self
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
$line->setOrder($this);
}
return $this;
}
}// Order creation with cascade persist
namespace App\Service;
use App\Entity\Order;
use App\Entity\OrderLine;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
class OrderService
{
public function __construct(
private readonly EntityManagerInterface $em
) {}
public function createOrder(array $cartItems): Order
{
$order = new Order();
foreach ($cartItems as $item) {
$line = new OrderLine();
$line->setProduct($item['product']);
$line->setQuantity($item['quantity']);
$line->setUnitPrice($item['product']->getPrice());
// addLine synchronizes the relationship
$order->addLine($line);
}
// Only Order is explicitly persisted
// OrderLines are persisted automatically (cascade)
$this->em->persist($order);
$this->em->flush();
return $order;
}
}Cascade Remove와 OrphanRemoval
Cascade remove는 관련 엔티티를 삭제합니다. OrphanRemoval은 컬렉션에서 분리된 엔티티까지 삭제하여 한 단계 더 나아갑니다.
// Blog post with images
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BlogPostRepository::class)]
class BlogPost
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\OneToMany(
targetEntity: Image::class,
mappedBy: 'blogPost',
cascade: ['persist', 'remove'], // Delete images with post
orphanRemoval: true // Also delete detached images
)]
private Collection $images;
public function __construct()
{
$this->images = new ArrayCollection();
}
public function removeImage(Image $image): self
{
if ($this->images->removeElement($image)) {
// orphanRemoval: image will be deleted on flush
// Without orphanRemoval: image would remain in DB without blogPost
}
return $this;
}
public function clearImages(): self
{
// Clear collection → all images will be deleted
$this->images->clear();
return $this;
}
}핵심 차이는 다음과 같습니다. cascade: ['remove']는 부모 엔티티가 삭제될 때만 관련 엔티티를 삭제합니다. orphanRemoval: true는 컬렉션에서 제거된 엔티티까지 삭제합니다.
관계를 위한 고급 DQL 쿼리
DQL(Doctrine Query Language)은 복잡한 관계를 포함하는 쿼리에 대해 최대의 유연성을 제공합니다.
관계 기반 필터링
// Advanced queries on relationships
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);
}
// Articles with at least one comment
public function findWithComments(): array
{
return $this->createQueryBuilder('a')
->innerJoin('a.comments', 'c') // INNER JOIN excludes articles without comments
->addSelect('c')
->groupBy('a.id')
->getQuery()
->getResult();
}
// Articles by tag with comment count
public function findByTagWithCommentCount(string $tagName): array
{
return $this->createQueryBuilder('a')
->select('a', 'COUNT(c.id) as commentCount')
->leftJoin('a.comments', 'c')
->innerJoin('a.tags', 't')
->where('t.name = :tagName')
->setParameter('tagName', $tagName)
->groupBy('a.id')
->orderBy('commentCount', 'DESC')
->getQuery()
->getResult();
}
// Recent articles with comment authors
public function findRecentWithAuthors(\DateTimeInterface $since): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.comments', 'c')
->addSelect('c')
->leftJoin('c.author', 'u') // Join on comment author
->addSelect('u')
->where('a.publishedAt > :since')
->setParameter('since', $since)
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
}서브쿼리와 집계
// Complex queries with subqueries
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
// Most active users (by article count)
public function findMostActiveAuthors(int $limit = 10): array
{
return $this->createQueryBuilder('u')
->select('u', 'COUNT(a.id) as articleCount')
->leftJoin('u.articles', 'a')
->where('a.status = :published')
->setParameter('published', 'published')
->groupBy('u.id')
->having('COUNT(a.id) > 0') // HAVING to filter after GROUP BY
->orderBy('articleCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
// Users with articles having more than 10 comments
public function findAuthorsWithPopularArticles(): array
{
// DQL subquery
$em = $this->getEntityManager();
$subQuery = $em->createQueryBuilder()
->select('IDENTITY(a2.author)')
->from('App\Entity\Article', 'a2')
->leftJoin('a2.comments', 'c2')
->groupBy('a2.id')
->having('COUNT(c2.id) > 10')
->getDQL();
return $this->createQueryBuilder('u')
->where('u.id IN (' . $subQuery . ')')
->getQuery()
->getResult();
}
}연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
모범 사례와 고급 패턴
컬렉션의 올바른 초기화
타입 오류를 피하려면 컬렉션은 항상 생성자에서 초기화해야 합니다.
// Proper relationship initialization
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuthorRepository::class)]
class Author
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToMany(targetEntity: Book::class, mappedBy: 'author')]
private Collection $books;
#[ORM\ManyToMany(targetEntity: Genre::class)]
private Collection $favoriteGenres;
// ALWAYS initialize in constructor
public function __construct()
{
$this->books = new ArrayCollection();
$this->favoriteGenres = new ArrayCollection();
}
// Utility method to check if collection is loaded
public function areBooksLoaded(): bool
{
return $this->books->isInitialized();
}
}순환 참조 피하기
양방향 관계는 직렬화 시 무한 루프를 유발할 수 있습니다.
// Handling circular references
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
#[ORM\Entity(repositoryClass: DepartmentRepository::class)]
class Department
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['department:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Groups(['department:read', 'employee:read'])]
private string $name;
// MaxDepth limits serialization depth
#[ORM\OneToMany(targetEntity: Employee::class, mappedBy: 'department')]
#[Groups(['department:read'])]
#[MaxDepth(1)] // Does not serialize employee relations
private Collection $employees;
public function __construct()
{
$this->employees = new ArrayCollection();
}
}동적 조건을 사용하는 Repository 패턴
// Flexible search criteria
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
// Search with optional filters
public function findByCriteria(array $criteria): array
{
$qb = $this->createQueryBuilder('p')
->leftJoin('p.category', 'c')
->addSelect('c')
->leftJoin('p.tags', 't')
->addSelect('t');
// Conditional filters
if (isset($criteria['category'])) {
$qb->andWhere('c.slug = :category')
->setParameter('category', $criteria['category']);
}
if (isset($criteria['minPrice'])) {
$qb->andWhere('p.price >= :minPrice')
->setParameter('minPrice', $criteria['minPrice']);
}
if (isset($criteria['maxPrice'])) {
$qb->andWhere('p.price <= :maxPrice')
->setParameter('maxPrice', $criteria['maxPrice']);
}
if (isset($criteria['tags']) && is_array($criteria['tags'])) {
$qb->andWhere('t.name IN (:tags)')
->setParameter('tags', $criteria['tags']);
}
if (isset($criteria['inStock']) && $criteria['inStock']) {
$qb->andWhere('p.stock > 0');
}
return $qb->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
}결론
Doctrine ORM 관계의 마스터링은 몇 가지 기본 원칙 위에 세워집니다.
✅ 소유 측 vs 역측: 영속화를 보장하기 위해 항상 소유 측을 수정합니다
✅ 양방향 동기화: add/remove 메서드는 양쪽을 동기화해야 합니다
✅ Fetch joins: N+1 문제를 피하기 위해 각 join 뒤에 addSelect()를 사용합니다
✅ EXTRA_LAZY: 큰 컬렉션에서 활성화하여 count()와 contains()를 최적화합니다
✅ 신중한 캐스케이드: persist는 자주 유용하지만, remove와 orphanRemoval은 비즈니스 맥락에 따라 다릅니다
✅ 컬렉션 초기화: 항상 생성자에서 ArrayCollection으로 초기화합니다
이러한 패턴이 성능 좋은 Symfony 애플리케이션의 토대를 이룹니다. 다음 단계는 극한의 성능이 필요한 경우를 위해 인덱스와 네이티브 쿼리를 마스터하는 것입니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

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

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, 유효성 검사, 직렬화를 실전 예제와 함께 설명합니다.