PostgreSQL에서 SQL/PGQ를 활용한 그래프 표현

John Nevin
2025년 2월 18일

토르킨(Tolkien)과 그래프 이론을 좋아하는 저에게, EDB 동료들이 공유한 이 훌륭한 블로그 글은 무척 흥미로웠습니다.

작성자는 PostgreSQL에서 그래프를 모델링하는 방법과 재귀적 CTE를 활용하여 그래프 탐색 쿼리를 실행하는 방법을 설명하며 다음과 같이 결론을 내립니다.

PostgreSQL에서 그래프 데이터를 표현하고 이를 유연하게 쿼리하는 한 가지 방법입니다.

또한, 다른 기법이나 개선 방안이 있다면 공유해 달라는 의견을 남겼습니다.

실제로 PostgreSQL에서 그래프를 보다 효과적으로 다룰 수 있도록 하는 새로운 기술이 개발되고 있습니다.

SQL 속성 그래프 쿼리 (SQL Property Graph Queries)

SQL 속성 그래프 쿼리(SQL/PGQ)는 최근 SQL:2023 ISO 표준에 포함되었으며, 별도의 그래프 데이터베이스 관리 시스템(예: Neo4j) 없이도 기존 관계형 데이터를 그래프로 표현하고 효율적으로 쿼리할 수 있도록 지원합니다.

노드 간의 관계를 연결된 엣지(Edge)로 표현하는 것은 매우 직관적이고 유용한 기능으로, 특히 소셜 네트워크(친구의 친구 관계), 교통망, 컴퓨터 네트워크 등 다양한 네트워크를 모델링하는 데 적합합니다.

Postgres에 이 기능 추가하기

Postgres에 그래프 데이터베이스 기능을 추가하는 Apache AGE™와 같은 서드파티 확장이 존재하지만, PostgreSQL에 SQL/PGQ를 도입하기 위한 논의와 작업이 pgsql-hackers 메일링 리스트에서 꾸준히 진행되고 있습니다.

아직 정식 출시 일정은 정해지지 않았지만, 현재 기능적으로 동작하며 Postgres에 패치를 적용하면 직접 실험해볼 수 있습니다.

세 단계로 간편하게 패치 적용하기

이 패치를 적용하려면 Postgres를 소스 코드에서 빌드 및 설치해야 합니다. Postgres 공식 문서에서 이에 대한 상세한 가이드를 제공하고 있습니다.

1. 패치 파일 다운로드

먼저, pgsql-hackers 스레드에서 모든 .patch 파일을 다운로드합니다.

2. Postgres 소스 코드 다운로드

Postgres 소스 코드를 가져온 후, 해당 디렉터리로 이동하여 설치 절차를 진행합니다.
(Postgres 소스 코드 다운로드 방법: 공식 문서)

3. 패치 파일 적용

다운로드한 각 .patch 파일에 대해 다음 명령어를 실행합니다.
(예: Debian 12에서 Downloads 디렉터리에 다운로드된 경우)


patch -p1 < ~/Downloads/v10-00NN-x-y-z.patch

NN-x-y-z 부분을 적절한 패치 파일명으로 변경하세요.

모든 패치를 적용한 후, 이제 SQL/PGQ가 적용된 Postgres를 빌드 및 설치할 준비가 완료되었습니다!

빌드 및 설치하기

⚠️ 참고:
Postgres를 빌드하기 전에 필수 도구들이 설치되어 있는지 확인하세요.
(Debian 12 기준, libicu-dev, bison, flex 등의 패키지를 추가로 설치해야 했습니다.)

Postgres를 빌드하고 설치하는 기본적인 절차는 간단하지만, 필요에 따라 빌드 설정을 조정할 수도 있습니다.

./configure
make
su
make install
adduser postgres
mkdir -p /usr/local/pgsql/data
chown postgres /usr/local/pgsql/data
su - postgres
/usr/local/pgsql/bin/initdb -D /usr/local/pgsql/data
/usr/local/pgsql/bin/pg_ctl -D /usr/local/pgsql/data -l logfile start
/usr/local/pgsql/bin/createdb test
/usr/local/pgsql/bin/psql test

실제 그래프 활용 예시!

이 글의 영감을 준 블로그 게시물에서는 톨킨(Tolkien) 세계관의 캐릭터를 예제 데이터로 사용하고 있습니다.
여기서도 동일한 데이터를 생성한 후, SQL/PGQ 방식으로 동일한 쿼리 결과를 얻을 수 있는지 확인해 보겠습니다.


테이블 생성하기

먼저, psql을 실행한 후 두 개의 테이블을 생성합니다.

하나는 **캐릭터(노드, Nodes)**를 위한 테이블 다른 하나는 **관계(엣지, Edges)**를 위한 테이블입니다

CREATE TABLE nodes (
 id SERIAL PRIMARY KEY,
 name TEXT,
 details JSONB
);
CREATE TABLE edges (
 id SERIAL PRIMARY KEY,
 type TEXT,
 from_id INTEGER REFERENCES nodes(id),
 to_id INTEGER REFERENCES nodes(id),
 details JSONB
);

데이터 생성하기

이제 해당 테이블에 데이터를 입력해 보겠습니다.


INSERT INTO nodes (name, details) VALUES
 ('Frodo Baggins', '{"species": "Hobbit"}'), -- 1
 ('Bilbo Baggins', '{"species": "Hobbit"}'), -- 2
 ('Samwise Gamgee', '{"species": "Hobbit"}'), -- 3
 ('Hamfast Gamgee', '{"species": "Hobbit"}'), -- 4
 ('Gandalf', '{"species": "Wizard"}'), -- 5
 ('Aragorn', '{"species": "Human"}'), -- 6
 ('Arathorn', '{"species": "Human"}'), -- 7
 ('Legolas', '{"species": "Elf"}'), -- 8
 ('Thranduil', '{"species": "Elf"}'), -- 9
 ('Gimli', '{"species": "Dwarf"}'), -- 10
 ('Gloin', '{"species": "Dwarf"}'); -- 11
-- Parents
INSERT INTO edges (type, from_id, to_id, details) VALUES
 ('parent', 2, 1, '{}'),
 ('parent', 4, 3, '{}'),
 ('parent', 7, 6, '{}'),
 ('parent', 9, 8, '{}'),
 ('parent', 11, 10, '{}');

속성 그래프(Property Graph) 생성하기

SQL/PGQ를 사용하면 기존의 관계형 테이블 위에 **속성 그래프(Property Graph)**를 만들고, 이를 GRAPH_TABLE 연산자를 통해 직접 SQL에서 쿼리할 수 있습니다.
GRAPH_TABLE은 SQL에 완전히 통합된 그래프 패턴 매칭 언어를 제공하여, 보다 직관적인 방식으로 그래프 데이터를 조회할 수 있도록 해줍니다.

🔹 참고:
현재 PostgreSQL에서 SQL/PGQ는 개발 진행 중이라 공식 문서가 충분하지 않습니다.

따라서 아래에서는 OracleDB의 PGQL(Property Graph Query Language) 문서를 참고합니다.
PGQL은 SQL:2023 표준을 기반으로 하며, PostgreSQL과는 다른 DBMS지만 개념을 이해하는 데 도움이 됩니다.

속성 그래프에서는 VERTEX TABLE(정점 테이블)을 사용해 노드(Node)를 정의하고, EDGE TABLE(엣지 테이블)을 사용해 노드 간의 관계(Relationship)를 표현합니다.

이 예제에서는:

  • 캐릭터(노드)를 저장하는 VERTEX TABLE, 캐릭터 간의 관계(엣지)를 저장하는 EDGE TABLE

을 생성합니다.

관계(엣지)에는 LABEL(레이블) “relationship”을 지정하고, 관계 유형은 'parent'(부모) 또는 'friend'(친구) 중 하나가 될 수 있습니다.

SOURCE KEY(출발 노드)DESTINATION KEY(도착 노드)를 올바르게 설정해야 합니다.그래프 탐색에서는 방향성이 중요하기 때문입니다. (이 부분은 이후 쿼리에서 확인할 수 있습니다.)

CREATE PROPERTY GRAPH characters 
  VERTEX TABLES ( 
    nodes LABEL node PROPERTIES ( id, name, details )
  ) 
  EDGE TABLES ( 
   edges 
    SOURCE KEY ( from_id ) REFERENCES nodes ( id ) 
    DESTINATION KEY ( to_id ) REFERENCES nodes ( id ) 
    LABEL relationship PROPERTIES (type) 
  );

간단한 쿼리 – 부모와 자녀 찾기

속성 그래프에서 관계(Relationship) 레이블이 ‘parent’인 엣지(Edge)를 따라 그래프를 탐색하면 특정 캐릭터의 부모를 찾을 수 있습니다.

예를 들어, 샘와이즈 갬지(Samwise Gamgee)의 부모를 찾아보겠습니다.

재귀적 CTE(Recursive CTE) 활용

WITH child AS (SELECT id FROM nodes WHERE name = 'Samwise Gamgee')
SELECT parent.name FROM child
JOIN edges ON edges.to_id = child.id
JOIN nodes parent ON edges.from_id = parent.id;

SQL/PGQ

SELECT name FROM GRAPH_TABLE (characters
MATCH (a IS node WHERE a.name='Samwise Gamgee') <-[e IS relationship WHERE e.type='parent']- (b IS node)
COLUMNS (b.name AS name)
);

예상대로, Hamfast Gamgee가 반환되었습니다.

속성 그래프를 생성할 때 SOURCE KEY(출발 노드)와 DESTINATION KEY(도착 노드)의 방향성에 대해 언급한 바 있습니다.


여기에서 확인할 수 있듯이, SQL/PGQ의 쿼리 문법에서는 ←[e]→ 형태의 화살표를 사용하여 두 노드 간의 관계를 표현합니다.

  • 화살표의 **머리(→ 방향)**가 **DESTINATION KEY(도착 노드)**를 가리키고,
  • 화살표의 **꼬리(← 방향)**가 **SOURCE KEY(출발 노드)**를 나타냅니다.

즉, 이번 쿼리는 SOURCE KEY가 “parent”(부모) 관계를 맺고 있는 DESTINATION KEY를 찾는 구조입니다.

     a                        e                    b
DESTINATION KEY <-[relationship.type = parent]- SOURCE KEY
   to_id                                        from_id
Samwise Gamgee                               Hamfast Gamgee

부모를 찾는 쿼리는 자식(DESTINATION KEY)에서 부모(SOURCE KEY) 방향으로 이동하는 구조이므로, 화살표 방향이 왼쪽(←), 즉 **”위쪽”**을 가리키는 형태가 됩니다.

   a                     e                       b
SOURCE KEY -[relationship.type = parent]-> DESTINATION KEY
 from_id                                       to_id
Samwise Gamgee                                 NO MATCH

화살표 방향을 올바르게 유지하는 쉬운 방법은 다음과 같이 표현하는 것입니다:

"SOURCE는 DESTINATION에 대한 RELATIONSHIP이다."

더 복잡한 쿼리 – 친구의 친구 찾기

모든 캐릭터가 서로 직접 아는 것은 아닙니다.
따라서 우정(Friendship) 그래프는 조금 더 복잡한 구조를 가집니다.

다음 쿼리를 실행하여 “friend”(친구) 관계를 엣지(Edges) 테이블에 추가하세요:

SERT INTO edges (type, from_id, to_id, details) VALUES
 -- Everyone in the fellowship is friends with everyone else
 ('friend', 1, 3, '{}'), -- Frodo and Sam
 ('friend', 1, 5, '{}'), -- Frodo and Gandalf
 ('friend', 1, 6, '{}'), -- Frodo and Aragorn
 ('friend', 1, 8, '{}'), -- Frodo and Legolas
 ('friend', 1, 10, '{}'), -- Frodo and Gimli
 ('friend', 3, 1, '{}'), -- Sam and Frodo
 ('friend', 3, 5, '{}'), -- Sam and Gandalf
 ('friend', 3, 6, '{}'), -- Sam and Aragorn
 ('friend', 3, 8, '{}'), -- Sam and Legolas
 ('friend', 3, 10, '{}'), -- Sam and Gimli
 ('friend', 5, 1, '{}'), -- Gandalf and Frodo
 ('friend', 5, 3, '{}'), -- Gandalf and Sam
 ('friend', 5, 6, '{}'), -- Gandalf and Aragorn
 ('friend', 5, 8, '{}'), -- Gandalf and Legolas
 ('friend', 5, 10, '{}'), -- Gandalf and Gimli
 ('friend', 6, 1, '{}'), -- Aragorn and Frodo
 ('friend', 6, 3, '{}'), -- Aragorn and Sam
 ('friend', 6, 5, '{}'), -- Aragorn and Gandalf
 ('friend', 6, 8, '{}'), -- Aragorn and Legolas
 ('friend', 6, 10, '{}'), -- Aragorn and Gimli
 ('friend', 8, 1, '{}'), -- Legolas and Frodo
 ('friend', 8, 3, '{}'), -- Legolas and Sam
 ('friend', 8, 5, '{}'), -- Legolas and Gandalf
 ('friend', 8, 6, '{}'), -- Legolas and Aragorn
 ('friend', 8, 10, '{}'), -- Legolas and Gimli
 ('friend', 10, 1, '{}'), -- Gimli and Frodo
 ('friend', 10, 3, '{}'), -- Gimli and Sam
 ('friend', 10, 5, '{}'), -- Gimli and Gandalf
 ('friend', 10, 6, '{}'), -- Gimli and Aragorn
 ('friend', 10, 8, '{}'), -- Gimli and Legolas
 -- Bilbo was friends with Hamfast and Gandalf
 ('friend', 2, 4, '{}'), -- Bilbo and Hamfast
 ('friend', 2, 5, '{}'), -- Bilbo and Gandalf
 -- And vice versa
 ('friend', 4, 2, '{}'), -- Hamfast and Bilbo
 ('friend', 5, 2, '{}'), -- Gandalf and Bilbo
 -- Gandalf was friends with Bilbo, Hamfast and Thranduil, but for the sake of
 -- argument let's say he didn't know Gloin or Arathorn
 ('friend', 5, 2, '{}'), -- Gandalf and Bilbo
 ('friend', 5, 4, '{}'), -- Gandalf and Hamfast
 ('friend', 5, 9, '{}'), -- Gandalf and Thranduil
 -- And vice versa
 ('friend', 2, 5, '{}'), -- Bilbo and Gandalf
 ('friend', 4, 5, '{}'), -- Hamfast and Gandalf
 ('friend', 9, 5, '{}'); -- Thranduil and Gandalf

SQL/PGQ를 사용하면 친구의 친구 부모를 찾는 쿼리의 문법이 재귀 CTE와 비교했을 때 훨씬 간단해집니다.

재귀적 CTE(Recursive CTE) 활용

WITH RECURSIVE 
root(id) AS (SELECT id FROM nodes WHERE name = 'Samwise Gamgee'),
paths(path) AS (VALUES ('{friend,friend,parent}'::text[])),
results(id) AS (
 SELECT root.id, 1 as path_index from root
UNION
 SELECT edges.from_id, path_index + 1 AS path_index FROM results
 JOIN edges ON edges.to_id = results.id
 JOIN paths ON edges.type = paths.path[path_index]
)
SELECT * FROM results
JOIN nodes ON nodes.id = results.id
JOIN paths ON cardinality(paths.path) + 1 = results.path_index;

SQL/PGQ

SELECT DISTINCT id, fof_parents, details FROM GRAPH_TABLE(characters
 MATCH (a IS node WHERE a.name='Samwise Gamgee')-[x IS relationship WHERE x.type='friend']->(b IS node)-[y IS relationship WHERE y.type='friend']->(c IS node)<-[z IS relationship WHERE z.type='parent']-(d IS node)  
 COLUMNS (d.id, d.name as fof_parents, d.details as details)
);

MATCH 문에서 화살표의 형태는 그래프 탐색 과정을 직관적으로 표현해 줍니다.

(Samwise Gamgee) -[friendship]-> (friend) -[friendship]-> (friend-of-friend) <-[parent]-(parent-of-friend-of-friend)

우리는 샘와이즈 갬지(Samwise Gamgee)와 연결된 ‘friend'(친구) 관계의 엣지를 따라 두 단계 이동하여 친구의 친구 노드를 찾습니다.
그리고 처음 예제에서처럼 왼쪽 화살표(←)를 사용하여 각 친구의 친구 노드의 부모를 조회합니다.

쿼리 결과는 원래 블로그 게시물과 동일하게 반환됩니다.

id |  fof_parents   |        details        
----+----------------+-----------------------
 2 | Bilbo Baggins  | {"species": "Hobbit"}
 4 | Hamfast Gamgee | {"species": "Hobbit"}
 7 | Arathorn       | {"species": "Human"}
 9 | Thranduil      | {"species": "Elf"}
11 | Gloin          | {"species": "Dwarf"}
(5 rows)

결론

SQL/PGQ는 PostgreSQL에서 그래프 데이터를 직접 다룰 수 있는 강력하고 유연하며, 직관적인 인터페이스를 제공합니다!
이번 예제들은 기본적인 개념만 다루었지만, 참고한 블로그 게시물과 동일한 결과를 도출하며, Postgres에서 그래프를 활용하는 또 다른 방법을 보여줍니다.

아직 공식 출시 일정이 정해지지는 않았으며, 이 글에서 소개한 기능도 개발 중인 상태입니다.
그러나 앞으로 PostgreSQL의 정식 버전에서 이 흥미로운 기능이 완전히 지원되기를 기대합니다!

마지막으로, 이 기술을 가능하게 해준 pgsql-hackers 커뮤니티의 모든 개발자분들께 감사드립니다. 😊

원문: Representing graphs in PostgreSQL with SQL/PGQ

이메일: salesinquiry@enterprisedb.com