김민준이 수학 3반을 듣는다는 사실은 두 가지로 그릴 수 있다.
두 사람을 실로 잇거나, 둘 사이에 점을 하나 두는 것이다.
그런데 이 수강을 취소해 달라는 쪽지는 실에 붙지 않는다.
그래서 나중에 무언가 붙을 관계는 점으로 둔다.
여기까지가 이 글의 답이다
여기서부터는 위 네 줄을 그래프의 말로 다시 적는다. 온톨로지, 지식그래프, 택소노미를 구분한다에서 수강기록이 왜 독립된 대상이 되는지 한 번 답했다. 트리플사실 하나를 주어, 술어, 목적어 세 칸으로 적은 최소 단위. 표와 달리 컬럼을 미리 맞출 필요가 없어서, 서로 다른 대상에 대한 사실을 같은 형식으로 쌓을 수 있다.W3C RDF 1.1 Primer은 칸이 셋뿐이라 역할과 상태와 기간을 넣을 자리가 없고, 그래서 관계를 대상으로 승격시킬 수밖에 없었다.
이 글은 그 답이 통하지 않는 자리를 다룬다. 노드와 엣지로 담는 프로퍼티 그래프에서는 엣지도 속성을 가진다. 칸이 모자란다는 이유가 사라지므로 질문이 다시 열린다.
이 글의 범위
단위: 두 대상을 잇는 관계 하나다.
범위: 그 관계를 노드로 둘지 엣지로 둘지 정하는 기준을 다룬다.
제외 대상: 어느 저장소를 쓸지, 그래프 데이터베이스를 도입할지는 이 글의 범위가 아니다.
실패 조건: 그 관계를 가리켜야 하는데 가리킬 수 없는 상태다.
같은 질문을 두 언어로 풀었다
OneRoster학교·지구 단위의 명단·수강·성적 등 교육 로스터 데이터를 시스템 간에 교환하기 위한 1EdTech 표준 (현재 1.2가 실무 기준).1EdTech OneRoster 샘플을 SQLite와 Neo4j에 각각 넣고 같은 질문을 던졌다. 민준이 듣는 모든 수업의 담당 교사는 누구인가.
SQL은 조인 세 번이다. 수강에서 수업으로, 수업에서 다시 수강으로, 거기서 사용자로 되짚어 간다.
SELECT c.title, u.familyName || u.givenName AS teacher
FROM enrollments e_student
JOIN classes c ON c.sourcedId = e_student.classSourcedId
JOIN enrollments e_teacher ON e_teacher.classSourcedId = c.sourcedId
JOIN users u ON u.sourcedId = e_teacher.userSourcedId
WHERE e_student.userSourcedId = 'usr-minjun'
AND e_student.role = 'student' AND e_student.status = 'active'
AND e_teacher.role = 'teacher' AND e_teacher.status = 'active';
Cypher는 같은 경로를 패턴 하나로 적는다.
MATCH (s:User {sourcedId:'usr-minjun'})
-[:ENROLLED_IN {role:'student', status:'active'}]->(c:Class)
<-[:ENROLLED_IN {role:'teacher', status:'active'}]-(t:User)
RETURN c.title, t.name;
결과는 같다. 상태가 tobedeleted인 수강 한 건이 양쪽에서 똑같이 걸러져서, 과학 2반에는 담당 교사가 한 명만 나온다.
조인 세 번은 관계형 데이터베이스가 원래 잘하는 일이다. Cypher가 나은 점은 성능이 아니라 문장의 모양이 질문을 닮았다는 것 하나뿐이고, 그것만으로 저장소를 바꿀 이유는 되지 않는다. 이 질문에서는 두 방식이 비긴다.
속성이 붙었다는 사실은 근거가 아니다
수강에는 역할과 상태와 기간이 매달려 있다. 이것이 노드로 올릴 근거처럼 보이지만 그렇지 않다. 프로퍼티 그래프의 엣지는 원래 속성을 가진다.
MERGE (u)-[r:ENROLLED_IN {sourcedId:$sourcedId}]->(c)
SET r.role = $role, r.status = $status, r.beginDate = $beginDate;
이렇게 넣고 나면 역할과 상태로 거르는 질의도 그대로 동작한다. 위의 Cypher가 바로 그 모양이다.
여기가 부모 글과 갈라지는 지점이다. 트리플은 칸이 셋뿐이므로 속성을 붙이려면 관계를 개체로 올릴 수밖에 없다. 프로퍼티 그래프는 그 승격을 강제하지 않는다. 그래서 RDF에서는 저절로 닫혔던 질문이 여기서는 남는다.
갈리는 지점은 무엇이 그것을 가리키는가
이 수강 건에 취소 요청이 들어왔다는 사실을 붙여 보면 두 모델이 갈린다. 엣지로 두면 만들 수 없다.
MATCH (:User {sourcedId:'usr-minjun'})-[r:ENROLLED_IN {sourcedId:'enr-001'}]->(:Class)
CREATE (a:CancelRequest {requestedAt:'2026-09-10'})-[:TARGETS]->(r);
Neo.ClientError.Statement.SyntaxError
Type mismatch: r defined with conflicting type Relationship (expected Node)
프로퍼티 그래프의 엣지는 다른 엣지의 끝점이 되지 못한다. 노드로 두면 엣지 하나가 더 붙을 뿐이다.
이 실패에는 우회할 방법이 없다. 나중에 필요해지면 그때 바꾸면 되는 종류의 결정도 아니다. 관계를 노드로 쪼개고, 그 관계를 참조하던 모든 질의를 다시 쓰는 작업이 된다.
무엇을 노드로 올릴지 가르는 세 가지
관계 하나를 노드로 올릴지 가르는 세 가지
도해의 지점을 짚거나 Tab으로 옮겨 보십시오
이름표가 있다
OneRoster는 수강기록마다 sourcedId를 부여한다. 식별자를 준다는 것은 바깥에서 그것을 가리키라는 뜻이다. 엣지에도 식별자를 적어 둘 수는 있지만, 그 식별자로 찾아가 무언가를 매달 수는 없다.
다른 것이 그것을 가리킨다
취소 요청, 승인 이력, 수강 맥락을 참조하는 학습 기록이 전부 이 수강 건 하나를 가리킨다. 가리킬 대상이 필요하면 노드여야 한다.
상태가 변한다
상태가 유효에서 삭제 예정으로 옮겨 가고 유효 구간이 따로 있다. 상태가 변하는 것에는 이력이 쌓이고, 이력은 대상을 가리켜야 남길 수 있다.
셋 중 하나라도 해당하면 노드로 둔다. 수강기록은 셋 다 해당한다. 명단에서 sourcedIdOneRoster에서 사람, 반, 수업 같은 대상을 가리키는 식별자가 들어가는 칸의 이름. 값은 원천 시스템이 관리하며, 표시 이름이나 로그인 계정과는 층위가 다르다.OneRoster 1.2 Standard가 무엇을 하는 칸인지는 OneRoster는 사람을 번호로 가리킨다에 적어 두었다.
아닌 것: 속성이 붙은 관계가 모두 노드는 아니다
같은 데이터에 있는 다른 관계 둘은 엣지로 남긴다. 수업이 어느 과정에 속하는지, 조직이 어느 상위 조직에 속하는지다. 둘 다 식별자가 없고, 아무도 그것을 가리키지 않으며, 상태가 변하지 않는다. 세 신호가 하나도 없으므로 엣지가 맞다.
노드로 올리는 판단은 공짜가 아니다. 수강기록을 노드로 두면 사람에서 수업까지 가는 데 홉이 두 배가 된다. 질의도 그만큼 길어진다. 그런데도 노드로 두는 이유는, 되돌릴 수 없는 쪽이 더 비싸기 때문이다.
한 가지가 더 있다. 이 질문은 관계형 데이터베이스에서는 아예 생기지 않는다. 속성이 붙은 관계는 거기서 무조건 연결 테이블이 되기 때문이다. enrollments가 테이블인 순간 이미 노드다. 프로퍼티 그래프가 새로 만들어 낸 질문이었고, 답은 관계형이 처음부터 강제하던 자리로 돌아왔다.
다음 글에서 다룰 것
- 읽기만 하는 온톨로지에 동사를 붙이는 방법. Object, Link, Action 패턴이고, 그 Action이 대상으로 삼는 것이 이 글에서 노드로 만든 수강기록이다.
- 깊이를 미리 모르는 질문에서 그래프가 관계형을 앞서는 지점. 조직 계층과 역량 트리가 그런 경우이고, 저장소 선택은 온톨로지는 세 번 만들어봐야 안다의 마지막 단계에서 정한다.