PostgreSQL ltree와 JPA를 활용한 마이크로서비스 계층적 데이터 저장
개발자들은 조직 구조, 제품 카탈로그, 가계도와 같은 계층적 데이터를 저장해야 할 때가 많습니다. PostgreSQL은 ltree라는 특화된 자료형을 제공하여 물리적 경로(materialized path)를 구현하고 트리 연산을 단순화합니다. 이 글에서는 ltree를 인접 리스트(adjacency list)와 단일 테이블에서 결합하고, 마이크로서비스 아키텍처에서 JPA와 통합하는 방법을 살펴봅니다.
트리 저장의 주요 접근법으로는 인접 리스트(부모 참조)와 물리적 경로(루트부터의 전체 경로)가 있습니다. ltree는 점(.) 구분자를 사용해 후자를 구현하며, GIST 인덱스, @>(포함), <@(포함됨) 같은 연산자, 그리고 nlevel() 같은 함수를 제공합니다.
테이블 구조와 엔티티
테이블은 두 접근법을 결합합니다:
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE TABLE nodes (
id bigint NOT NULL,
parent_id bigint,
code varchar(6) NOT NULL,
path ltree NOT NULL,
depth integer NOT NULL GENERATED ALWAYS AS (nlevel(path)) STORED,
CONSTRAINT nodes_pk PRIMARY KEY (id),
CONSTRAINT nodes_parent_id_fk FOREIGN KEY (parent_id) REFERENCES nodes (id),
CONSTRAINT nodes_code_unq UNIQUE (code)
);
CREATE INDEX nodes_parent_id_idx ON nodes (parent_id);
CREATE INDEX nodes_path_idx ON nodes USING GIST (path);
ltree를 위한 커스텀 타입을 사용한 JPA 엔티티:
@Accessors(chain = true)
@Getter
@Setter
@Entity
@Table(name = "nodes")
public class Node {
@Id
@Column(name = "id", nullable = false, updatable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Node parent;
@Size(max = 6)
@NotBlank
@Column(name = "code", nullable = false, length = 6, updatable = false)
private String code;
@Type(PostgreSQLLTreeType.class)
@NotBlank
@Column(name = "path", columnDefinition = "ltree")
private String path;
@Generated(event = {EventType.INSERT, EventType.UPDATE})
@Column(name = "depth", insertable = false, updatable = false)
private Integer depth;
}
커스텀 함수와 조건식
PostgreSQL 방언에 조상 확인 함수 등록:
public class PGDialect extends PostgresPlusDialect {
public static final String LTREE_ANCESTORS = "ancestors";
@Override
public void initializeFunctionRegistry(FunctionContributions functionContributions) {
super.initializeFunctionRegistry(functionContributions);
SqmFunctionRegistry functionRegistry = functionContributions.getFunctionRegistry();
BasicTypeRegistry basicTypeRegistry = functionContributions.getTypeConfiguration().getBasicTypeRegistry();
BasicType<Boolean> booleanType = basicTypeRegistry.resolve(StandardBasicTypes.BOOLEAN);
functionRegistry.patternDescriptorBuilder(
LTREE_ANCESTORS,
"(?1::ltree @> ?2::ltree)"
)
.setExactArgumentCount(2)
.setArgumentTypeResolver(StandardFunctionArgumentTypeResolvers.ARGUMENT_OR_IMPLIED_RESULT_TYPE)
.setInvariantType(booleanType)
.register();
}
}
조상 조건식:
public class NodePredicateUtils {
public static Predicate ancestors(From<?, Node> from, CriteriaBuilder builder, String path) {
return builder.isTrue(
builder.function(LTREE_ANCESTORS, Boolean.class, from.get("path"), builder.literal(path))
);
}
}
리포지토리 메서드
네이티브 쿼리와 Specification 지원을 포함한 리포지토리:
public interface NodeRepository extends JpaRepository<Node, Long>, JpaSpecificationExecutor<Node> {
@Query(nativeQuery = true, value = """
WITH RECURSIVE tree AS (
SELECT n.* FROM nodes n WHERE n.id = :nodeId
UNION ALL
SELECT n.* FROM nodes n, tree WHERE n.id = tree.parent_id
)
SELECT * FROM tree
""")
Collection<Node> getAncestors(Long nodeId);
@Query(nativeQuery = true, value = "select * from nodes n " +
"where n.path @> (select path from nodes where id = :nodeId)")
Collection<Node> getAncestorsByPath(Long nodeId);
}
성능 비교
1,000개의 무작위 노드로 테스트한 결과:
- 재귀적 SQL(인접 리스트): 1118 ms
- ltree
@>연산자: 820 ms - JPA Specification: 1083 ms
ltree는 GIST 인덱스와 최적화된 연산자 덕분에 우수합니다. Specification은 경로를 먼저 가져와야 하므로 오버헤드가 추가됩니다.
테스트 코드:
@Test
@DisplayName("다양한 방법으로 '조상' 검색 성능 테스트")
@Transactional(readOnly = true)
public void testPerformance() {
// ... 1000개 ID 생성
// 각 방법별 시간 측정
}
마이크로서비스 적용
분산 시스템에서 구조는 조직 서비스에 저장되고, 뉴스는 콘텐츠 서비스에 저장됩니다. 사용자는 자신의 부서와 상위 레벨의 뉴스를 요청합니다.
옵션:
- 경로 없는 뉴스 서비스: 각 뉴스 항목마다 구조 서비스에 여러 쿼리.
- 뉴스 서비스에 경로 포함: 사용자의 경로에 대한 한 번의 쿼리, 데이터베이스 측에서
@>로 필터링.
ltree의 장점:
- 서비스 간 네트워크 호출 감소.
- 빠른 계층적 필터링.
- 경로 비정규화로 트래픽 최소화.
핵심 요약
- ltree는 인접 리스트와 단일 테이블에서 충돌 없이 결합됩니다.
- 경로에 대한 GIST 인덱스는
@>,<@연산 성능을 보장합니다. - 마이크로서비스에서 경로 중복은 서비스 간 호출을 줄입니다.
- 재귀적 CTE는 큰 트리에서 ltree보다 느립니다.
- JPA는 ltree를 위해 커스텀 Type과 Dialect가 필요합니다.
— Editorial Team
아직 댓글이 없습니다.