본문으로 건너뛰기
Junsu Kim

Posts

수강기록은 엣지가 아니라 노드다

관계에 속성이 붙었다는 사실은 노드와 엣지를 가르지 못한다. 그 관계를 다른 것이 가리켜야 하는지가 가른다.

관계를 실로 그리면취소해 주세요김민준수학 3반붙일 곳이 없다관계를 점으로 그리면취소해 주세요김민준수강수학 3반여기에 붙는다

김민준이 수학 3반을 듣는다는 사실은 두 가지로 그릴 수 있다.

두 사람을 실로 잇거나, 둘 사이에 점을 하나 두는 것이다.

그런데 이 수강을 취소해 달라는 쪽지는 실에 붙지 않는다.

그래서 나중에 무언가 붙을 관계는 점으로 둔다.

여기까지가 이 글의 답이다

여기서부터는 위 네 줄을 그래프의 말로 다시 적는다. 온톨로지, 지식그래프, 택소노미를 구분한다에서 수강기록이 왜 독립된 대상이 되는지 한 번 답했다. 은 칸이 셋뿐이라 역할과 상태와 기간을 넣을 자리가 없고, 그래서 관계를 대상으로 승격시킬 수밖에 없었다.

이 글은 그 답이 통하지 않는 자리를 다룬다. 노드와 엣지로 담는 프로퍼티 그래프에서는 엣지도 속성을 가진다. 칸이 모자란다는 이유가 사라지므로 질문이 다시 열린다.

이 글의 범위

단위: 두 대상을 잇는 관계 하나다.

범위: 그 관계를 노드로 둘지 엣지로 둘지 정하는 기준을 다룬다.

제외 대상: 어느 저장소를 쓸지, 그래프 데이터베이스를 도입할지는 이 글의 범위가 아니다.

실패 조건: 그 관계를 가리켜야 하는데 가리킬 수 없는 상태다.

같은 질문을 두 언어로 풀었다

샘플을 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를 부여한다. 식별자를 준다는 것은 바깥에서 그것을 가리키라는 뜻이다. 엣지에도 식별자를 적어 둘 수는 있지만, 그 식별자로 찾아가 무언가를 매달 수는 없다.

다른 것이 그것을 가리킨다

취소 요청, 승인 이력, 수강 맥락을 참조하는 학습 기록이 전부 이 수강 건 하나를 가리킨다. 가리킬 대상이 필요하면 노드여야 한다.

상태가 변한다

상태가 유효에서 삭제 예정으로 옮겨 가고 유효 구간이 따로 있다. 상태가 변하는 것에는 이력이 쌓이고, 이력은 대상을 가리켜야 남길 수 있다.

셋 중 하나라도 해당하면 노드로 둔다. 수강기록은 셋 다 해당한다. 명단에서 가 무엇을 하는 칸인지는 OneRoster는 사람을 번호로 가리킨다에 적어 두었다.

아닌 것: 속성이 붙은 관계가 모두 노드는 아니다

같은 데이터에 있는 다른 관계 둘은 엣지로 남긴다. 수업이 어느 과정에 속하는지, 조직이 어느 상위 조직에 속하는지다. 둘 다 식별자가 없고, 아무도 그것을 가리키지 않으며, 상태가 변하지 않는다. 세 신호가 하나도 없으므로 엣지가 맞다.

노드로 올리는 판단은 공짜가 아니다. 수강기록을 노드로 두면 사람에서 수업까지 가는 데 홉이 두 배가 된다. 질의도 그만큼 길어진다. 그런데도 노드로 두는 이유는, 되돌릴 수 없는 쪽이 더 비싸기 때문이다.

한 가지가 더 있다. 이 질문은 관계형 데이터베이스에서는 아예 생기지 않는다. 속성이 붙은 관계는 거기서 무조건 연결 테이블이 되기 때문이다. enrollments가 테이블인 순간 이미 노드다. 프로퍼티 그래프가 새로 만들어 낸 질문이었고, 답은 관계형이 처음부터 강제하던 자리로 돌아왔다.

다음 글에서 다룰 것