Post

Rails 자기 참조 연관관계(Self-referential Association) 훑어보기

Rails에서 자기 참조 연관관계를 활용해 계층형 카테고리 구조를 구현하는 방법을 Spring JPA와 비교하며 살펴봅니다.

Rails를 공부하다 보면 트리 구조의 데이터를 다뤄야 할 때가 있습니다. 대표적인 예가 카테고리처럼 부모-자식 관계가 재귀적으로 이어지는 구조입니다. 이번 글에서는 자기 참조 연관관계(Self-referential Association)를 사용한 Category 모델 코드를 분석해 보겠습니다.

아래 코드가 이번 글에서 살펴볼 전체 예시입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Category < ApplicationRecord
  belongs_to :parent, class_name: "Category", optional: true
  has_many :children, class_name: "Category", foreign_key: "parent_id", dependent: :destroy

  validate :prevent_circular_reference

  scope :roots, -> { where(parent_id: nil) }

  # Category.roots -> 최상위 카테고리들

  def all_children
    children + children.map(&:all_children).flatten
  end

  def ancestors
    return [] unless parent
    [ parent ] + parent.ancestors
  end
end

1. 클래스 선언

1
class Category < ApplicationRecord
  • ApplicationRecord를 상속받는 Ruby 클래스입니다.
  • Rails에서 DB 테이블(categories)과 자동으로 연결됩니다.
  • Java Spring의 @Entity + JPA Repository 기능이 합쳐진 개념으로 이해하면 됩니다.

2. 자기 참조 연관관계 (Self-referential Association)

1
2
belongs_to :parent, class_name: "Category", optional: true
has_many :children, class_name: "Category", foreign_key: "parent_id", dependent: :destroy

DB 구조로 보면 이렇습니다:

1
2
3
4
5
6
7
8
categories 테이블
┌────┬──────────┬───────────┐
│ id │ name     │ parent_id │
├────┼──────────┼───────────┤
│ 1  │ 전자제품  │ NULL      │  ← 최상위
│ 2  │ 노트북    │ 1         │  ← 전자제품의 자식
│ 3  │ 삼성노트북 │ 2        │  ← 노트북의 자식
└────┴──────────┴───────────┘

belongs_to :parent

  • 이 카테고리의 부모를 가리킵니다.
  • class_name: "Category" → 연관 대상이 다른 모델이 아니라 자기 자신 클래스입니다.
  • optional: true → parent_id가 NULL 허용 (최상위 카테고리는 부모가 없으므로)
  • Spring으로 치면 @ManyToOne(optional = true)에 해당합니다.

has_many :children

  • 이 카테고리의 자식 목록을 가리킵니다.
  • foreign_key: "parent_id" → 자식들이 갖고 있는 FK 컬럼명을 명시합니다.
  • dependent: :destroy → 부모 삭제 시 자식도 연쇄 삭제됩니다.
  • Spring으로 치면 @OneToMany(cascade = CascadeType.REMOVE)에 해당합니다.

3. 유효성 검사

1
validate :prevent_circular_reference
  • prevent_circular_reference라는 커스텀 validator 메서드를 호출합니다.
  • 순환 참조 방지 용도입니다 (A→B→C→A 같은 상황).
  • 단, 이 코드엔 해당 메서드 구현이 없으므로 실제로는 동작하지 않습니다. 실무에서는 메서드를 별도로 정의해야 합니다.
  • Spring의 @Valid + 커스텀 ConstraintValidator 개념과 유사합니다.

참고: 속성값을 검증할 때는 validates :name, presence: true처럼 validates(복수)를 쓰고, 커스텀 메서드를 호출할 때는 validate(단수)를 씁니다.


4. 스코프 (Scope)

1
scope :roots, -> { where(parent_id: nil) }
  • 자주 쓰는 쿼리 조건에 이름을 붙여두는 것입니다.
  • -> { } 는 Ruby의 람다(lambda) 문법입니다.
  • where(parent_id: nil) → SQL의 WHERE parent_id IS NULL에 해당합니다.
1
2
3
4
# 사용 예시
Category.roots          # SELECT * FROM categories WHERE parent_id IS NULL
Category.roots.first    # 최상위 카테고리 중 첫번째
Category.roots.count    # 최상위 카테고리 개수

Spring의 @Query나 QueryDSL로 만든 메서드를 Repository에 등록해두는 것과 같은 개념입니다.


5. 인스턴스 메서드

all_children — 모든 하위 자식을 재귀로 가져오기

1
2
3
def all_children
  children + children.map(&:all_children).flatten
end
코드설명
children직계 자식 목록 (ActiveRecord 배열)
children.map(&:all_children)각 자식마다 all_children 재귀 호출
&:all_children{ \|c\| c.all_children } 의 축약 문법
.flatten중첩 배열을 1차원으로 펼침
+두 배열을 합침

동작 예시:

1
2
3
전자제품.all_children
  → [노트북] + [삼성노트북, LG노트북, ...]
  → [노트북, 삼성노트북, LG노트북, ...]

⚠️ N+1 문제 주의 — 카테고리가 많으면 재귀 호출마다 DB 쿼리가 날아가므로, 실무에서는 includes로 eager loading 하거나 재귀 CTE 쿼리를 씁니다.


ancestors — 모든 상위 부모를 재귀로 가져오기

1
2
3
4
def ancestors
  return [] unless parent
  [ parent ] + parent.ancestors
end
코드설명
return [] unless parent부모가 없으면(최상위면) 빈 배열 반환
unlessif !parent 와 동일, Ruby스러운 부정 조건
[ parent ]부모 하나를 배열로 감쌈
+ parent.ancestors부모의 조상들을 재귀로 붙임

동작 예시:

1
2
3
삼성노트북.ancestors
  → [노트북] + [전자제품] + []
  → [노트북, 전자제품]

전체 구조 한눈에 보기

1
2
3
4
5
6
7
8
전자제품 (parent_id: nil)  ← roots 스코프
  └── 노트북 (parent_id: 1)
        ├── 삼성노트북 (parent_id: 2)
        └── LG노트북   (parent_id: 2)

삼성노트북.ancestors    → [노트북, 전자제품]
전자제품.all_children  → [노트북, 삼성노트북, LG노트북]
전자제품.children      → [노트북]  ← 직계만

Spring JPA 백그라운드가 있으시다면 아래 대응표가 도움이 될 겁니다.

RailsSpring/JPA
belongs_to@ManyToOne
has_many@OneToMany
dependent: :destroyCascadeType.REMOVE
scopeRepository 커스텀 쿼리 메서드
validate@Valid + ConstraintValidator
optional: true(optional = true)
This post is licensed under CC BY 4.0 by the author.