기록에 없으면 거짓일까?
SPARQL과 wirelog로 살펴보는 열린 세계와 닫힌 세계
by Justin Kim
전기차 충전소를 추천하는 프로그램을 만든다고 해 봅시다. 가까운 충전소를 찾았는데, 운영에 대한 기록이 없습니다. 이 충전소는 운영하지 않는 곳일까요? 아니면 운영 여부를 아직 확인하지 못한 곳일까요?
사람에게는 다른 상황입니다. 운영하지 않는다고 확인한 곳과, 상태를 모르는 곳은 안내 문구부터 달라야 합니다. 그런데 프로그램을 작성하다 보면 둘을 같은 조건으로 묶기 쉽습니다. 운영 중인 곳을 찾고, 그 결과에 없는 곳을 모두 비운영으로 분류하는 것입니다.
앞선 시맨틱 리즈닝 글에서는 알고 있는 사실에서 어떤 결론이 따라오는지를 살펴보았습니다. 이번에는 그 반대편을 보겠습니다. 어떤 결론을 얻지 못했을 때, 우리는 무엇까지 말할 수 있을까요? 먼저 SPARQL로 질문하고, 같은 데이터와 질문을 wirelog로 옮겨 보겠습니다.
충전소 세 곳과 두 개의 상태 기록
설명을 위해 만든 작은 데이터입니다. 실제 공공데이터를 조회한 결과는 아닙니다. 한 시점의 스냅샷을 생각하고, 운영 상태는 true와 false만 사용하겠습니다.
| 충전소 | 운영 상태 기록 | 이 기록으로 할 수 있는 말 |
|---|---|---|
| A | true |
운영 중이라고 기록되어 있다 |
| B | false |
운영 중이 아니라고 기록되어 있다 |
| C | 없음 | 운영 여부가 기록되어 있지 않다 |
이 데이터를 RDF의 Turtle 문법으로 적으면 다음과 같습니다. a는 대상의 종류를 나타내는 rdf:type의 줄임말입니다. ex:operating은 이 예제에서 정한 운영 상태 속성입니다.
@prefix ex: <https://example.org/charging/> .
ex:A a ex:ChargingStation ;
ex:operating true .
ex:B a ex:ChargingStation ;
ex:operating false .
ex:C a ex:ChargingStation .
C도 충전소라는 기록은 있습니다. 빠진 것은 C의 존재가 아니라 운영 상태입니다. 따라서 ‘알고 있는 충전소 목록’에는 A, B, C가 모두 들어갑니다.
아래 SPARQL은 이 데이터가 기본 그래프에 들어 있다고 가정합니다. 별도의 RDFS·OWL 추론은 켜지 않습니다. 또 false를 ‘운영 중이 아님’으로 읽는 것은 이 예제의 데이터 모델에서 정한 약속입니다.
SPARQL: 운영 중인 곳과 운영 중이라고 확인되지 않은 곳
먼저 운영 중이라고 기록된 충전소를 찾겠습니다.
PREFIX ex: <https://example.org/charging/>
SELECT DISTINCT ?station
WHERE {
?station a ex:ChargingStation .
?station ex:operating true .
}
ORDER BY ?station
결과는 ex:A입니다. 충전소이면서 운영 상태가 true인 대상을 찾았기 때문입니다. 결과의 ex:A 같은 표기는 읽기 쉽도록 줄인 IRI입니다.
이제 운영 중이라는 기록이 없는 충전소를 찾아봅시다.
PREFIX ex: <https://example.org/charging/>
SELECT DISTINCT ?station
WHERE {
?station a ex:ChargingStation .
FILTER NOT EXISTS {
?station ex:operating true .
}
}
ORDER BY ?station
결과는 ex:B, ex:C입니다. B에는 false가 있고 C에는 상태가 없지만, 둘 다 true라는 기록이 없다는 조건을 만족합니다.
FILTER NOT EXISTS는 바깥에서 찾은 각 ?station 값을 사용해 안쪽 패턴이 일치하는지 검사합니다. 이 예제에서는 A, B, C를 각각 대입하고, ex:operating true가 발견되지 않는 대상을 남깁니다. 이것은 현재 질의 범위에서 패턴의 부재를 확인하는 연산입니다. SPARQL 1.1 명세의 NOT EXISTS 설명도 이 동작을 정의합니다.
따라서 이 결과에 붙일 이름은 ‘운영 중인 것으로 확인되지 않은 충전소’입니다. ‘운영하지 않는 충전소’라고 부르면 C에 대해 우리가 가진 정보보다 더 강한 주장을 하게 됩니다.
SPARQL: 비운영 기록과 미확인을 나누기
운영 중이 아니라고 명시된 곳을 찾으려면 false라는 값을 직접 찾아야 합니다.
PREFIX ex: <https://example.org/charging/>
SELECT DISTINCT ?station
WHERE {
?station a ex:ChargingStation .
?station ex:operating false .
}
ORDER BY ?station
결과는 ex:B뿐입니다. C에는 false라는 기록도 없으므로 포함되지 않습니다. 여기서는 ‘없음’을 검사하지 않았습니다. false라는 값이 있는 트리플을 찾았습니다.
운영 여부를 모르는 곳은 어떻게 찾을까요? 이번 데이터에서는 상태가 true와 false 두 가지뿐이므로, 어느 쪽 기록도 없는 충전소를 찾으면 됩니다.
PREFIX ex: <https://example.org/charging/>
SELECT DISTINCT ?station
WHERE {
?station a ex:ChargingStation .
FILTER NOT EXISTS {
?station ex:operating true .
}
FILTER NOT EXISTS {
?station ex:operating false .
}
}
ORDER BY ?station
결과는 ex:C입니다. 첫 번째 필터를 통과한 B와 C 가운데, false 기록이 있는 B도 제외됩니다. 실제 데이터에 ‘점검 중’ 같은 다른 값이나 잘못된 값이 허용된다면 상태 모델과 검사 조건을 함께 확장해야 합니다.
지금까지의 질문은 비슷해 보이지만 결과가 다릅니다.
| 질문 | 결과 |
|---|---|
| 운영 중이라고 기록된 곳은? | A |
| 운영 중이라는 기록이 없는 곳은? | B, C |
| 운영 중이 아니라고 기록된 곳은? | B |
| 어느 쪽 기록도 없는 곳은? | C |
ASK를 사용해도 이 차이는 유지됩니다. 다음 질문의 결과는 false입니다.
PREFIX ex: <https://example.org/charging/>
ASK {
ex:C ex:operating true .
}
여기서 반환된 false는 질의 패턴을 만족하는 해가 없다는 뜻입니다. C가 운영하지 않는다는 새로운 사실을 얻었다는 뜻은 아닙니다. 위 질의의 true를 false로 바꾸어 물어도 결과는 false입니다. C에는 어느 쪽 기록도 없기 때문입니다. SPARQL의 ASK 정의는 패턴에 해가 있는지를 불리언으로 반환하도록 정합니다.
열린 세계에서 ‘모른다’고 말하기
OWL의 열린 세계 가정(open-world assumption)에서는 어떤 사실을 도출하지 못했다고 그 사실을 곧바로 거짓으로 보지 않습니다. 현재 지식이 불완전할 수 있기 때문입니다. C가 운영 중이라는 기록이 빠져 있어도, 실제로는 운영 중일 가능성이 남아 있습니다. OWL 2 Primer는 이 점을 닫힌 세계 가정과 대비해 설명합니다.
그렇다고 SPARQL이 항상 ‘모름’이라는 값을 반환한다는 뜻은 아닙니다. 앞에서 ASK는 분명히 false를 반환했고, NOT EXISTS는 C를 결과에 포함했습니다. 지식의 의미를 어떻게 해석하는가와, 현재 데이터에서 어떤 질의 결과를 계산하는가는 구분해야 합니다.
우리 예제에서 C는 운영 중인 세계와 운영 중이 아닌 세계 어느 쪽과도 양립할 수 있습니다. 질의는 지금 적힌 데이터를 대상으로 수행되므로, 두 세계 가운데 어느 쪽이 현실인지 결정해 주지 않습니다.
반면 닫힌 세계 가정(closed-world assumption)은 정해진 범위에서 참으로 알려지거나 도출되는 것만 참으로 인정하고, 나머지는 거짓으로 취급합니다. 가령 특정 지역의 특정 시점에 운영 중인 충전소 목록이 빠짐없이 수집되었다고 보장된다면, 그 범위 안에서 목록에 없는 충전소를 비운영으로 판단할 근거가 생깁니다.
단순히 파일을 끝까지 읽었다는 것으로는 이런 보장이 생기지 않습니다. 누락된 페이지나 늦게 도착한 관측이 있을 수 있습니다. 어떤 지역과 시점의 어떤 상태를 완전하게 수집했다고 볼 것인지 정해야 합니다. 이 예제에는 그런 완전성 보장이 없으므로 C는 미확인으로 남깁니다.
같은 데이터를 wirelog로 옮기기
이제 표현만 바꾸고 질문은 유지해 보겠습니다. RDF의 충전소 종류 트리플은 station 관계로, 운영 상태 트리플은 status 관계로 옮깁니다.
| RDF | wirelog 입력 사실 |
|---|---|
ex:A a ex:ChargingStation |
station("A") |
ex:B a ex:ChargingStation |
station("B") |
ex:C a ex:ChargingStation |
station("C") |
ex:A ex:operating true |
status("A", "true") |
ex:B ex:operating false |
status("B", "false") |
간결하게 쓰기 위해 이 예제의 IRI는 A, B, C라는 문자열로, RDF 불리언은 "true", "false"라는 문자열로 대응시켰습니다. 이 변환은 위의 작은 데이터에 대해 정한 매핑입니다. RDF 전체의 자료형이나 의미론을 자동으로 옮기는 변환기는 아닙니다.
wirelog 프로그램은 다음과 같습니다.
.decl station(s: symbol)
.decl status(s: symbol, value: symbol)
.decl operating(s: symbol)
.decl non_operating(s: symbol)
.decl unconfirmed(s: symbol)
.decl unknown(s: symbol)
operating(S) :- station(S), status(S, "true").
non_operating(S) :- station(S), status(S, "false").
unconfirmed(S) :- station(S), !operating(S).
unknown(S) :- station(S), !operating(S), !non_operating(S).
첫 번째 규칙은 운영 중이라고 기록된 충전소를 operating으로 모읍니다. 두 번째 규칙은 비운영이라고 기록된 충전소를 non_operating으로 모읍니다. 앞에서 SPARQL로 true와 false를 각각 찾았던 질문입니다.
세 번째 규칙의 !operating(S)는 operating 관계에 S가 없다는 조건입니다. station(S)로 후보를 정한 다음, 운영 중이라고 도출된 대상에 포함되지 않는 후보를 unconfirmed로 모읍니다. 이 예제에서는 SPARQL의 FILTER NOT EXISTS { ?station ex:operating true }와 같은 대상을 고릅니다.
네 번째 규칙은 operating에도 non_operating에도 없는 후보를 unknown으로 모읍니다. SPARQL에서 두 개의 NOT EXISTS를 함께 쓴 질문과 같습니다.
여기서도 !operating(S)와 non_operating(S)는 다릅니다. 전자는 운영 중이라는 결론의 부재이고, 후자는 "false"라는 입력 기록에서 얻은 결론입니다. 이름이 비슷해도 둘을 같은 관계로 합치지 않은 이유입니다.
station(S)도 필요합니다. 세상에 존재할 수 있는 모든 이름 가운데 운영 중이 아닌 것을 찾는 것이 아니라, 알고 있는 충전소를 후보로 삼기 때문입니다. 부정에 쓰는 변수 S를 긍정 조건으로 먼저 제한합니다.
wirelog는 이처럼 !를 쓰는 계층화된 부정(stratified negation)을 지원합니다. 이 프로그램에서는 operating과 non_operating을 구한 뒤, 그 결과를 기준으로 부재를 검사할 수 있습니다. 규칙을 파일에 적은 순서대로 실행한다는 뜻은 아닙니다. 부정으로 참조하는 관계를 먼저 확정할 수 있도록 의존 관계가 나뉜다는 뜻입니다. 문법은 wirelog 0.70.0의 Syntax Reference를 참고할 수 있습니다.
PyreWire로 같은 결과 확인하기
Python 바인딩인 PyreWire로 실행해 보겠습니다. 앞선 연재와 환경을 맞춰 PyreWire 1.1.2를 사용합니다. 이 글의 예제는 Python 3.13, PyreWire 1.1.2, 함께 설치된 wirelog 0.70.0에서 확인했습니다.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install pyrewire==1.1.2 rdflib==7.2.1
다음 코드를 charging.py로 저장하고 python charging.py로 실행하면 됩니다. PROGRAM은 방금 설명한 wirelog 프로그램이고, insert는 표의 다섯 입력 사실을 넣습니다.
from pyrewire import EasySession
PROGRAM = """
.decl station(s: symbol)
.decl status(s: symbol, value: symbol)
.decl operating(s: symbol)
.decl non_operating(s: symbol)
.decl unconfirmed(s: symbol)
.decl unknown(s: symbol)
operating(S) :- station(S), status(S, "true").
non_operating(S) :- station(S), status(S, "false").
unconfirmed(S) :- station(S), !operating(S).
unknown(S) :- station(S), !operating(S), !non_operating(S).
"""
with EasySession(PROGRAM) as session:
for name in ["A", "B", "C"]:
session.insert("station", [name])
session.insert("status", ["A", "true"])
session.insert("status", ["B", "false"])
for relation in ["operating", "unconfirmed", "non_operating", "unknown"]:
print(relation, sorted(session.snapshot(relation)))
출력은 다음과 같습니다.
operating [('A',)]
unconfirmed [('B',), ('C',)]
non_operating [('B',)]
unknown [('C',)]
SPARQL의 네 질문과 같은 결과입니다. RDF에서는 트리플 패턴을 사용했고 wirelog에서는 관계와 규칙을 사용했지만, 어떤 대상을 남기는지는 일치합니다.
| 질문 | SPARQL의 조건 | wirelog의 조건 | 결과 |
|---|---|---|---|
| 운영 중으로 기록됨 | ex:operating true |
operating(S) |
A |
| 운영 중으로 확인되지 않음 | NOT EXISTS로 true의 부재 검사 |
!operating(S) |
B, C |
| 비운영으로 기록됨 | ex:operating false |
non_operating(S) |
B |
| 상태 미확인 | 두 값 모두 NOT EXISTS |
두 관계 모두 부정 | C |
두 표현 모두 알려진 충전소를 후보로 제한한 상태입니다. SPARQL에서는 SELECT DISTINCT로 중복을 제거했습니다. 이 비교는 위의 패턴과 데이터에 대한 것으로, 임의의 SPARQL 질의를 wirelog 규칙으로 그대로 치환할 수 있다는 뜻은 아닙니다.
SPARQL도 로컬에서 실행하려면 첫 Turtle 블록을 charging.ttl로, 확인할 질의 블록 하나를 query.rq로 저장한 뒤 다음 코드를 query.py로 실행하면 됩니다. 앞에서 RDFLib도 함께 설치했으므로 추가 설치는 필요하지 않습니다.
from pathlib import Path
from rdflib import Graph
graph = Graph().parse("charging.ttl", format="turtle")
result = graph.query(Path("query.rq").read_text(encoding="utf-8"))
if result.type == "ASK":
print(bool(result))
else:
for row in result:
print(row.station.n3(graph.namespace_manager))
C의 상태를 알게 되면 무엇이 달라질까
이제 같은 스냅샷에 빠졌던 정보가 도착했다고 합시다. C도 운영 중이라고 확인되었습니다. RDF 데이터에 다음 트리플을 추가하고 앞의 질의를 다시 실행합니다.
ex:C ex:operating true .
운영 중인 곳은 A와 C가 됩니다. 운영 중이라는 기록이 없는 곳은 B만 남고, 상태 미확인 결과는 비게 됩니다.
wirelog에서도 같은 입력 하나를 추가하면 됩니다. charging.py의 with 블록 안에서, 기존 출력 루프 다음에 아래 코드를 같은 들여쓰기로 붙입니다.
session.insert("status", ["C", "true"])
print("after")
for relation in ["operating", "unconfirmed", "non_operating", "unknown"]:
print(relation, sorted(session.snapshot(relation)))
추가 출력은 다음과 같습니다.
after
operating [('A',), ('C',)]
unconfirmed [('B',)]
non_operating [('B',)]
unknown []
입력 사실은 늘었는데 unconfirmed와 unknown에서는 C가 빠졌습니다. 부재를 근거로 얻은 결과는 새 정보가 들어오면 유지되지 않을 수 있습니다. 이런 성질을 비단조적(non-monotonic)이라고 합니다. 여기서는 현실에서 운영 상태가 바뀐 경우가 아니라, 빠진 기록이 채워진 경우만 다루고 있습니다.
앞선 고정점 글에서는 부정이 없는 규칙으로 사실을 계속 보태는 과정을 설명했습니다. 이번에는 부정이 있으므로 그 설명을 그대로 적용할 수 없습니다. 참조하는 관계의 결과를 먼저 구하고 그 안에서 부재를 판단해야 하며, 입력이 달라지면 그 판단도 다시 계산해야 합니다.
unknown이라는 이름의 관계를 만들었다고 wirelog 자체가 3값 논리 엔진이 된 것은 아닙니다. 엔진은 정해진 관계의 존재와 부재를 계산합니다. 우리는 그 위에 ‘어느 쪽 기록도 없음’을 표현하는 관계를 하나 더 정의했습니다. SPARQL에서 미확인 목록을 따로 구했던 것과 같은 모델링 선택입니다.
추천에서 제외하는 것과 비운영이라고 단정하는 것
충전소 추천으로 돌아가 봅시다. 운영 중이라고 확인된 곳만 추천한다면, 첫 데이터에서는 A만 추천할 수 있습니다. B와 C는 모두 추천에서 빠집니다. 하지만 제외된 이유는 다릅니다.
- B: 운영 중이 아니라고 기록되어 있어 제외합니다.
- C: 운영 여부를 확인할 수 없어 제외합니다.
같은 행동으로 이어져도 지식의 상태까지 같아지는 것은 아닙니다. B에는 ‘비운영으로 기록됨’, C에는 ‘운영 상태 미확인’이라고 안내할 수 있습니다. 운영자에게 보낼 확인 요청도 C를 별도로 모으면 됩니다.
실제 서비스라면 관측 시각과 출처도 함께 다루어야 합니다. 오래된 true 기록 하나만으로 지금 운영 중이라고 보장할 수는 없습니다. 같은 시점에 true와 false가 함께 기록되어 있다면, 위 프로그램은 그 대상을 operating과 non_operating 양쪽에 포함합니다. 이 경우는 미확인이 아니라 상충하는 기록이 있는 경우이므로 별도 검사가 필요합니다. 여기서는 부재의 의미에 집중하기 위해 이런 충돌이 없는 단일 시점의 데이터를 사용했습니다.
‘기록에 없으면 거짓일까?’라는 질문에는 어떤 범위의 기록을 얼마나 완전하다고 보는지가 따라붙습니다. SPARQL의 NOT EXISTS와 wirelog의 !는 정해진 질의 범위나 관계에서 부재를 검사할 수 있게 해 줍니다. 그 결과를 현실의 부정으로 해석할 근거는 데이터의 완전성과 우리가 정한 모델에서 마련해야 합니다.
C의 상태를 모른다는 사실만으로도 프로그램은 유용한 결정을 내릴 수 있습니다. 추천을 보류하고, 확인을 요청하고, 사용자에게 정보가 부족하다고 알릴 수 있습니다. 모르는 것을 모른다고 남겨 두는 일도 지식을 설계하는 일의 일부입니다.
참고 자료와 실행 환경
예제는 2026년 11월 2일 macOS ARM64의 Python 3.13에서 실행했습니다. SPARQL은 RDFLib 7.2.1의 기본 그래프에서 별도의 추론 없이 평가했고, wirelog 예제는 PyreWire 1.1.2에 포함된 wirelog 0.70.0으로 평가했습니다.
- SPARQL 1.1 Query Language — NOT EXISTS: 현재 바인딩을 이용한 패턴 부재 검사.
- SPARQL 1.1 Query Language — ASK: 질의 패턴의 해 존재 여부.
- OWL 2 Primer — What is OWL 2?: 열린 세계 가정과 닫힌 세계 가정의 차이.
- wirelog 0.70.0 Syntax Reference: 규칙과 계층화된 부정 문법.
- PyreWire 1.1.2: 이 글에서 사용한 Python 바인딩 배포판.
Subscribe via RSS
Comments