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 | 부모가 없으면(최상위면) 빈 배열 반환 |
unless | if !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 백그라운드가 있으시다면 아래 대응표가 도움이 될 겁니다.
| Rails | Spring/JPA |
|---|
belongs_to | @ManyToOne |
has_many | @OneToMany |
dependent: :destroy | CascadeType.REMOVE |
scope | Repository 커스텀 쿼리 메서드 |
validate | @Valid + ConstraintValidator |
optional: true | (optional = true) |