1. 들어가며
개발을 하다 보면 카테고리, 회사의 조직도, 혹은 게시판의 댓글/대댓글이나 카테고리처럼 '계층 구조(Hierarchy)'를 가진 데이터를 다뤄야 할 때가 있다. 만약 이 계층의 깊이가 1단계나 2단계로 고정되어 있다면 설계가 간단하겠지만, 요구사항에 따라 깊이가 5단계, 10단계, 혹은 그 이상으로 무한히 늘어나야 한다면 어떻게 데이터베이스를 설계해야 할까? 본 글에서는 이러한 무한 계층 구조를 유연하게 처리하기 위한 표준적인 설계 방식인 자기 참조(Self-Referencing) 모델링에 대해 정리한다.
1. 가장 흔한 실수: 컬럼 나열 방식 (Legacy)
계층형 데이터를 처음 접할 때 가장 직관적으로 떠올리는 방식은 깊이(Depth)별로 컬럼을 만드는 것이다.
[구조 예시]
depth1_id | depth2_id | depth3_id | depth4_id
이 방식은 조회 쿼리가 단순해 보인다는 장점이 있지만, 실제 운영 환경에서는 치명적인 한계를 가진다.
- 확장성의 부재: 서비스 운영 중 5단계 분류가 필요해지면, DB 테이블에 컬럼을 추가(ALTER TABLE)하고 관련된 모든 자바 코드를 수정해야 한다.
- 데이터 희소성(Sparsity): 깊이가 얕은 데이터(예: 1단계에서 끝나는 경우)는 나머지 하위 컬럼들이 모두 NULL로 채워져 저장 공간을 낭비하게 된다.
2. 해결책: 자기 참조 (Self-Referencing)
이러한 계층 구조의 확장성 문제를 해결하는 핵심 열쇠는 '자기 참조' 관계다. 컴퓨터의 '폴더(디렉터리)' 시스템을 생각하면 이해가 쉽다. 폴더 안에 폴더가 있고, 그 안에 또 폴더가 들어가는 구조와 동일하다.
💡 자기 참조란?
"같은 테이블의 PK(기본키)를, 같은 테이블의 FK(외래키)가 참조하는 구조"를 말한다. 즉, 부모를 찾기 위해 다른 테이블을 참조하는 것이 아니라, 나와 같은 테이블(족보) 안에서 나의 부모를 찾는 방식이다.
| ID (PK) | Name / Content | Parent_ID (FK) | 의미 |
| 1 | 패션 | NULL | 최상위 노드 (Root) |
| 2 | 여성 | 1 | 1번의 자식 |
| 3 | 상의 | 2 | 2번의 자식 |
위 데이터에서 Parent_ID는 외부 테이블이 아닌, 자신의 테이블 내 ID를 가리킨다. 이렇게 연결고리를 만들면 깊이의 제한 없이 데이터를 무한정 확장할 수 있다.
3. JPA 엔티티 구현
이를 객체지향적인 JPA 엔티티로 구현하면 다음과 같다. 핵심은 부모(parent)와 자식(children) 필드가 모두 자기 자신과 동일한 클래스 타입을 가진다는 점이다.
✅ 해당 내용은 이론일 뿐, 실제로는 아래 [4. 실무형 최적화] 방식을 사용해야 한다.
@Entity
public class CategoryEntity { // 혹은 CommentEntity, OrgEntity
@Id @GeneratedValue
private Long id;
private String name;
// 내 부모 (ManyToOne)
// 부모는 하나지만, 자식은 여럿일 수 있다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private CategoryEntity parent;
// 내 자식들 (OneToMany)
// 양방향 매핑을 통해 내 하위 목록을 바로 조회할 수 있다.
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<CategoryEntity> children = new ArrayList<>();
// ...
}
1) "내 부모가 누구인가?" (@ManyToOne)
DB에 parent_id 컬럼을 만들어야 하니까 필수다.
2) "내 자식들이 누구인가?" (@OneToMany)
DB 구조상에는 없지만, Java 코드 짤 때 category.getChildren()으로 메뉴 트리를 쉽게 그리고 싶으니까 추가한 것이다.
4. 실무형 최적화: 하이브리드 전략
앞서 소개한 [3. JPA 엔티티 구현] 방식은 객체지향적이고 깔끔해 보이지만, 실제 대규모 트래픽 환경에서 그대로 사용하기에는 치명적인 성능 한계가 존재한다. 이를 보완하기 위해 실무에서는 '순수 자기 참조'에 '반정규화(Denormalization)' 기법을 섞은 하이브리드 전략을 사용해야 한다.
4.1. 왜 순수 자기 참조만으로는 부족한가? (Anti-Pattern의 이유)
순수하게 parent와 children 관계만 맺은 상태에서 "패션(Root) 카테고리 하위에 있는 모든 상품을 조회하라"는 요청이 들어왔다고 가정해 보자.
- 재귀 쿼리의 늪 (N+1 문제): 최상위 '패션'에서 시작해 자식(남성), 그 자식(상의), 그 자식(반팔)... 순으로 데이터를 찾기 위해 수많은 JOIN이나 추가 쿼리가 발생한다.
- 인덱스 활용 불가: "내 조상이 '패션'인 모든 카테고리"를 한 번에 찾을 수 있는 인덱스를 태우기가 어렵다. 결국 DB는 테이블을 풀 스캔하거나 복잡한 연산을 수행해야 한다.
4.2. 해결책: Enum과 Depth를 활용한 "평탄화(Flattening)"
이 문제를 해결하는 핵심은 "트리 구조를 유지하되, 조회는 리스트처럼 단순하게 만드는 것"이다. 이를 위해 Root와 Depth라는 두 가지 보조 필드를 추가한다.
4.2.1. Enum (Root Type) - "검색 범위를 한 방에 좁히기"
- 개념: 모든 하위 카테고리들이 자신의 최상위 뿌리(Root)가 누구인지를 기억하게 하는 것이다.
- 이점: 복잡한 트리를 탈 필요 없이, 단순한 WHERE 절 하나로 해당 그룹의 전체 데이터를 가져올 수 있다.
- Before: 부모 찾고 -> 자식 찾고 -> 또 자식 찾고... (재귀)
- After: SELECT * FROM Category WHERE root = 'FASHION' (단순 조회)
- 활용: 이렇게 가져온 데이터를 애플리케이션 메모리 상에서 조립하면 DB 부하를 획기적으로 줄일 수 있다.
4.2.2. Depth (계층 깊이) - "UI 렌더링 최적화"
- 개념: 현재 카테고리가 몇 번째 단계인지 숫자로 저장한다. (1, 2, 3...)
- 이점: 프론트엔드에서 메뉴를 그릴 때 필수적이다.
- "GNB(상단 메뉴)에는 1단계(대분류)만 보여줘": WHERE depth = 1
- "필터에는 2단계(중분류)까지만 노출해": WHERE depth <= 2
- 효과: 계층을 계산하기 위해 부모를 타고 올라가는 연산 비용을 제거한다.
4.3. 구현 코드 예시
앞서 설명한 전략을 실제 코드로 구현하면 다음과 같다. 핵심은 RootCategory(Enum)와 depth 필드를 엔티티에 추가하고, 이를 조회 조건으로 활용하는 것이다.
4.3.1. Enum 정의 (Root Type)
먼저 비즈니스 로직의 대분류 기준이 될 Enum을 정의한다.
public enum RootCategory {
FASHION("패션의류"),
FOOD("식품/생필품"),
ELECTRONICS("가전/디지털");
private final String description;
RootCategory(String description) {
this.description = description;
}
}
4.3.2. Entity 개선 (하이브리드 필드 추가)
기존 자기 참조 엔티티에 최적화를 위한 필드(rootCategory, depth)를 추가한다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CategoryEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// [최적화 1] 검색 범위를 한 방에 좁히기 위한 대분류 식별자
@Enumerated(EnumType.STRING)
private RootCategory rootCategory;
// [최적화 2] UI 계층 표현 및 필터링을 위한 깊이 값
private int depth;
// 자기 참조 (부모)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private CategoryEntity parent;
// 자기 참조 (자식)
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<CategoryEntity> children = new ArrayList<>();
@Builder
public CategoryEntity(String name, RootCategory rootCategory, int depth, CategoryEntity parent) {
this.name = name;
this.rootCategory = rootCategory;
this.depth = depth;
this.parent = parent;
}
}
4.3.3. Repository 구현 (단순화된 조회)
이제 복잡한 재귀 쿼리나 조인 없이, 단순한 조건문으로 특정 그룹의 카테고리 전체를 조회할 수 있다.
public interface CategoryRepository extends JpaRepository<CategoryEntity, Long> {
// Before: 부모를 타고 타고 올라가야 함 (비효율적)
// After: RootCategory 조건 하나로 인덱스를 태워 고속 조회
List<CategoryEntity> findAllByRootCategory(RootCategory rootCategory);
// GNB용 조회: 특정 루트의 1뎁스만 조회
List<CategoryEntity> findAllByRootCategoryAndDepth(RootCategory rootCategory, int depth);
}
💡 이렇게 설계하면 데이터가 아무리 깊어져도 조회 쿼리는 항상 단순한 `SELECT ... WHERE root_category = ?` 형태를 유지하게 된다. 이것이 바로 무한 계층 구조를 성능 저하 없이 운용하는 비결이다.
5. 마치며: 더 깊은 성능 최적화를 위해
지금까지 자기 참조(Self-Referencing)를 통해 무한 계층 구조를 유연하게 설계하고, 하이브리드 전략(Enum + Depth)을 더해 실무적인 조회 효율까지 챙기는 방법을 알아보았다.
사실 데이터가 수백만, 수천만 건을 넘어가게 되면 이 설계만으로는 부족할 수 있다. 그때부터는 단순한 모델링을 넘어 아래와 같은 쿼리와 인덱스 레벨의 튜닝이 필요해진다.
- Recursive Query 최소화: IN 절을 활용해 애플리케이션 레벨에서 부모-자식을 조립하는 기법
- 반정규화(Denormalization): 조회 속도를 극대화하기 위해 경로(Path) 컬럼(1/2/3)을 추가하는 기법
- 복합 인덱스(Composite Index): 카테고리와 가격, 인기순 정렬이 섞일 때의 인덱스 설계 전략
이러한 [대용량 계층형 데이터의 쿼리 최적화] 주제는 내용이 방대하므로, 다른 포스팅에서 별도로 깊이 있게 다뤄보도록 하겠다.
'Spring > JPA' 카테고리의 다른 글
| [Practice-6] 단뱡향/양방향 선택 기준 (⭐⭐⭐) (0) | 2025.12.25 |
|---|---|
| [Practice-5] 일대다/다대일 관계 정의 (0) | 2025.12.16 |
| [Practice-3] Spring Data: Enum 고도화 (0) | 2025.12.16 |
| [Practice-2] 연관관계 편의 메서드 (1) | 2025.08.31 |
| [Practice-1] JPA 연관관계 매핑과 성능 최적화: 흔한 오해와 올바른 접근법 (⭐⭐) (0) | 2025.08.28 |