포스트

수사 온톨로지 기반 지식 그래프 시스템 — 단계별 테스트 가이드

수사 온톨로지 기반 지식 그래프 시스템 — 단계별 테스트 가이드

참고 원문: 수사 온톨로지 기반 지식 그래프 시스템 구축 가이드
대상: Python 기초 이상, 실습 우선 입문자
목표: OWL 온톨로지 → Neo4j → FastAPI 전체 스택을 직접 손으로 실행하고 검증한다


목차

  1. 테스트 환경 준비
  2. PHASE 1 — OWL 온톨로지 검증
  3. PHASE 2 — Neo4j 그래프 구축 및 쿼리 테스트
  4. PHASE 3 — FastAPI 애플리케이션 실행
  5. PHASE 4 — 통합 시나리오 테스트
  6. PHASE 5 — 무결성 검증 (disjoint 제약)
  7. 트러블슈팅
  8. 테스트 체크리스트

1. 테스트 환경 준비

1.1 전제 조건

본 가이드를 실습하려면 세 가지 도구가 필요하다. Python 3.11 이상, Docker Desktop, curl 또는 웹 브라우저다. Neo4j는 별도 설치 없이 Docker로 실행하므로 Docker만 있으면 충분하다.

Python 버전 확인부터 시작한다.

1
2
3
python --version     # 3.11.x 이상이어야 한다
docker --version     # 20.x 이상 권장
docker compose version

1.2 프로젝트 디렉토리 구성

작업 디렉토리를 만들고 필요한 파일들을 생성한다. 아래 구조를 그대로 따르면 이후 모든 명령어가 오류 없이 동작한다.

1
2
3
4
5
6
7
8
investigation-api/
├── investigation.ttl    ← OWL 온톨로지 파일
├── owl_loader.py        ← rdflib 테스트 스크립트
├── database.py          ← Neo4j 드라이버 설정
├── models.py            ← Pydantic 데이터 모델
├── main.py              ← FastAPI 메인 앱
├── requirements.txt     ← 의존성 목록
└── docker-compose.yml   ← 전체 환경 구성
1
2
mkdir investigation-api
cd investigation-api

1.3 의존성 설치

가상환경을 먼저 만드는 것이 좋다. 가상환경 없이 설치하면 다른 프로젝트의 패키지와 충돌할 수 있다.

1
2
3
python -m venv .venv
source .venv/bin/activate       # macOS / Linux
# .venv\Scripts\activate        # Windows PowerShell

활성화가 되면 터미널 프롬프트 앞에 (.venv)가 붙는다. 이 상태에서 패키지를 설치한다.

1
pip install fastapi "uvicorn[standard]" "neo4j>=5.0,<6.0" "pydantic>=2.0" rdflib

requirements.txt도 함께 만들어 두면 나중에 Docker 빌드에서 사용된다.

1
2
3
4
5
6
# requirements.txt
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
neo4j>=5.0,<6.0
pydantic>=2.0
rdflib>=6.3.0

2. PHASE 1 — OWL 온톨로지 검증

OWL 코드를 실행하기 전에, 먼저 파일로 저장하고 두 가지 방법으로 검증한다. 첫 번째는 온라인 도구를 이용한 문법 검사이고, 두 번째는 Python rdflib를 이용한 프로그래밍 방식 검사다.

2.1 온톨로지 파일 생성

아래 내용을 investigation.ttl 파일로 저장한다. Turtle 형식에서 들여쓰기는 의미가 없지만, 세미콜론(;)과 마침표(.)의 위치가 중요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

# ─────────────────────────────────────────────
# 클래스(Class) 정의 — "어떤 종류의 존재가 있는가"
# ─────────────────────────────────────────────

:Person a owl:Class .

:Suspect a owl:Class ;
    rdfs:subClassOf :Person .

:Victim a owl:Class ;
    rdfs:subClassOf :Person .

:Investigator a owl:Class ;
    rdfs:subClassOf :Person .

:Organization a owl:Class .

:CriminalOrganization a owl:Class ;
    rdfs:subClassOf :Organization .

:Event a owl:Class .

:Crime a owl:Class ;
    rdfs:subClassOf :Event .

:DrugCrime a owl:Class ;
    rdfs:subClassOf :Crime .

# ─────────────────────────────────────────────
# 프로퍼티(Property) 정의 — "어떤 관계가 있는가"
# ─────────────────────────────────────────────

:knows a owl:ObjectProperty ;
    rdfs:domain :Person ;
    rdfs:range  :Person .

:memberOf a owl:ObjectProperty ;
    rdfs:domain :Person ;
    rdfs:range  :Organization .

:partOf a owl:ObjectProperty ;
    rdfs:domain :Event ;
    rdfs:range  :Event .

# ─────────────────────────────────────────────
# 논리적 제약 (Constraint)
# ─────────────────────────────────────────────

:Investigator owl:disjointWith :Suspect .

⚠️ 주의: 파일 상단의 @prefix 선언이 없으면 :로 시작하는 모든 식별자가 오류가 된다. 원문 코드를 복붙할 때 prefix 선언을 빠뜨리기 쉬우므로 반드시 포함시킨다.

2.2 온라인 문법 검증 (설치 없이)

브라우저에서 RDFShape에 접속한다. 사이트 좌측 패널에 위 Turtle 코드를 붙여넣고, Format 드롭다운에서 Turtle을 선택한 뒤 Validate 버튼을 누른다. 오류가 없으면 초록색 체크마크와 함께 트리플(Triple) 수가 표시된다.

정상 결과 예시:

1
2
3
✅ Valid RDF document
Triples found: 18
Format: Turtle

2.3 Python rdflib로 프로그래밍 방식 검증

아래 스크립트를 owl_loader.py로 저장하고 실행한다. 이 스크립트는 온톨로지 파일을 파싱하여 클래스 목록, 상속 관계, disjoint 제약을 출력한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# owl_loader.py
from rdflib import Graph, Namespace, RDF, RDFS, OWL

# 온톨로지 파일 로드
g = Graph()
g.parse("investigation.ttl", format="turtle")

print(f"✅ 온톨로지 로드 완료 — 총 트리플(Triple) 수: {len(g)}")
print()

# ─── 예시 1: 모든 OWL 클래스 출력 ───
print("=== 정의된 클래스 목록 ===")
for cls in g.subjects(RDF.type, OWL.Class):
    local = str(cls).split("#")[-1]
    print(f"  클래스: {local}")

# ─── 예시 2: 상속 관계(subClassOf) 조회 ───
print("\n=== 상속(subClassOf) 관계 ===")
for child, _, parent in g.triples((None, RDFS.subClassOf, None)):
    child_name  = str(child).split("#")[-1]
    parent_name = str(parent).split("#")[-1]
    print(f"  {child_name}  →  subClassOf  →  {parent_name}")

# ─── 예시 3: disjoint 관계 확인 ───
print("\n=== Disjoint(상호 배타) 관계 ===")
for s, _, o in g.triples((None, OWL.disjointWith, None)):
    s_name = str(s).split("#")[-1]
    o_name = str(o).split("#")[-1]
    print(f"  {s_name}{o_name}  (동시에 속할 수 없음)")

# ─── 예시 4: ObjectProperty(관계) 목록 ───
print("\n=== ObjectProperty (관계 정의) ===")
for prop in g.subjects(RDF.type, OWL.ObjectProperty):
    prop_name = str(prop).split("#")[-1]
    domains = list(g.objects(prop, RDFS.domain))
    ranges  = list(g.objects(prop, RDFS.range))
    d = str(domains[0]).split("#")[-1] if domains else "?"
    r = str(ranges[0]).split("#")[-1] if ranges else "?"
    print(f"  :{prop_name}  ({d}{r})")

실행한다.

1
python owl_loader.py

정상 출력 예시:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
✅ 온톨로지 로드 완료 — 총 트리플(Triple) 수: 18

=== 정의된 클래스 목록 ===
  클래스: Person
  클래스: Suspect
  클래스: Victim
  클래스: Investigator
  클래스: Organization
  클래스: CriminalOrganization
  클래스: Event
  클래스: Crime
  클래스: DrugCrime

=== 상속(subClassOf) 관계 ===
  Suspect        →  subClassOf  →  Person
  Victim         →  subClassOf  →  Person
  Investigator   →  subClassOf  →  Person
  CriminalOrganization  →  subClassOf  →  Organization
  Crime          →  subClassOf  →  Event
  DrugCrime      →  subClassOf  →  Crime

=== Disjoint(상호 배타) 관계 ===
  Investigator  ⊕  Suspect  (동시에 속할 수 없음)

=== ObjectProperty (관계 정의) ===
  :knows     (Person → Person)
  :memberOf  (Person → Organization)
  :partOf    (Event → Event)

클래스가 9개, 상속 관계가 6개, disjoint가 1개, 프로퍼티가 3개 출력되면 온톨로지가 정확히 파싱된 것이다.

2.4 온톨로지 구조 다이어그램

이 시스템의 클래스 계층 구조를 한눈에 파악하면 이후 Neo4j 구현 단계가 훨씬 쉬워진다.

classDiagram
    direction TB

    class Thing { <<OWL Root>> }
    class Person { <<owl:Class>> }
    class Suspect {
        <<owl:Class>>
        riskScore : float
        aliases : list
    }
    class Victim { <<owl:Class>> }
    class Investigator {
        <<owl:Class>>
        rank : string
    }
    class Organization { <<owl:Class>> }
    class CriminalOrganization {
        <<owl:Class>>
        foundedYear : int
    }
    class Event {
        <<owl:Class>>
        occurredAt : datetime
    }
    class Crime {
        <<owl:Class>>
        status : string
        location : string
    }
    class DrugCrime {
        <<owl:Class>>
        drugType : string
    }

    Thing <|-- Person
    Thing <|-- Organization
    Thing <|-- Event
    Person <|-- Suspect
    Person <|-- Victim
    Person <|-- Investigator
    Organization <|-- CriminalOrganization
    Event <|-- Crime
    Crime <|-- DrugCrime

    Suspect .. Investigator : owl disjointWith

3. PHASE 2 — Neo4j 그래프 구축 및 쿼리 테스트

3.1 Neo4j 컨테이너 실행

Neo4j를 Docker로 실행한다. NEO4J_AUTH 환경 변수로 초기 비밀번호를 설정한다. 비밀번호는 최소 8자 이상이어야 한다.

1
2
3
4
5
6
docker run -d \
  --name investigation-neo4j \
  -p 7474:7474 \
  -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/password1234! \
  neo4j:5.18-community

컨테이너 상태를 확인한다.

1
2
docker ps
# investigation-neo4j 컨테이너가 Up 상태이어야 한다

30초 정도 기다린 후 브라우저에서 http://localhost:7474를 연다. 로그인 화면이 나타나면 다음 정보로 로그인한다.

  • Username: neo4j
  • Password: password1234!

💡 최초 로그인 시 비밀번호 변경을 요구하는 경우, 동일한 값(password1234!)을 새 비밀번호로 입력하면 된다.

3.2 샘플 데이터 생성

Neo4j Browser 상단의 Cypher 입력창에 아래 쿼리를 순서대로 붙여넣고 실행한다. 하나의 쿼리 블록을 실행한 후 다음으로 넘어간다.

Step 1: 기존 데이터 초기화

1
MATCH (n) DETACH DELETE n;

실행 후 Deleted X nodes, deleted Y relationships 메시지가 나타나면 성공이다.

Step 2: 조직 노드 생성

OWL에서 :CriminalOrganization rdfs:subClassOf :Organization으로 정의한 계층을, Neo4j에서는 다중 레이블 :CriminalOrganization:Organization으로 표현한다.

1
2
CREATE (o1:CriminalOrganization:Organization {name: 'BlackShark', foundedYear: 2010})
CREATE (o2:Organization {name: 'SeoulMetroPolice'});

Step 3: 인물 노드 생성

:Person:Suspect처럼 다중 레이블을 부여하여 OWL의 subClassOf 관계를 물리적으로 표현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
MATCH (o1:CriminalOrganization {name: 'BlackShark'})
MATCH (o2:Organization {name: 'SeoulMetroPolice'})

CREATE (p1:Person:Suspect {name: '강두식', age: 45, nationality: 'KR', riskScore: 8.5})
CREATE (p2:Person:Suspect {name: '이칠성', age: 38, nationality: 'KR', riskScore: 7.2})
CREATE (p3:Person:Victim   {name: '최민준', age: 29, nationality: 'KR'})
CREATE (p4:Person:Investigator {name: '박문수', age: 32, rank: '경감'})
CREATE (p5:Person:Investigator {name: '김정도', age: 41, rank: '경정'})

CREATE (p1)-[:MEMBER_OF {joinedAt: date('2018-03-01'), rank: '두목'}]->(o1)
CREATE (p2)-[:MEMBER_OF {joinedAt: date('2020-06-15'), rank: '조직원'}]->(o1)
CREATE (p4)-[:MEMBER_OF {assignedAt: date('2024-01-01')}]->(o2)
CREATE (p5)-[:MEMBER_OF {assignedAt: date('2022-05-01')}]->(o2);

Step 4: 사건 노드 및 관계 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
MATCH (p1:Suspect {name: '강두식'})
MATCH (p2:Suspect {name: '이칠성'})
MATCH (p3:Victim  {name: '최민준'})
MATCH (p4:Investigator {name: '박문수'})
MATCH (p5:Investigator {name: '김정도'})

CREATE (c1:Crime:Event {
    id: 'C-2025-001',
    type: '마약 밀매',
    occurredAt: datetime('2025-08-15T22:30:00'),
    status: '수사중',
    location: '서울 마포구'
})
CREATE (c2:DrugCrime:Crime:Event {
    id: 'C-2025-002',
    type: '필로폰 유통',
    occurredAt: datetime('2025-09-01T03:00:00'),
    status: '기소',
    location: '인천 남동구'
})

CREATE (p1)-[:KNOWS {since: date('2015-01-01'), relationshipType: '동생'}]->(p2)
CREATE (p2)-[:KNOWS {since: date('2023-06-01'), relationshipType: '알게 된 사이'}]->(p3)

CREATE (p1)-[:PARTY_TO {role: '주범', confidence: 0.95}]->(c1)
CREATE (p2)-[:PARTY_TO {role: '공범', confidence: 0.88}]->(c1)
CREATE (p1)-[:PARTY_TO {role: '주범', confidence: 0.99}]->(c2)
CREATE (p3)-[:PARTY_TO {role: '피해자', confidence: 1.0}]->(c1)

CREATE (p4)-[:INVESTIGATES {assignedAt: date('2025-08-16')}]->(c1)
CREATE (p5)-[:INVESTIGATES {assignedAt: date('2025-09-02')}]->(c2)

CREATE (c1)-[:PART_OF]->(c2);

Step 5: 전체 그래프 시각화 확인

1
MATCH (n)-[r]-(m) RETURN n, r, m LIMIT 50;

브라우저에 노드 9개(인물 5명 + 조직 2개 + 사건 2개)와 관계들이 그래프로 표시되면 데이터 구축이 완료된 것이다.

3.3 핵심 Cypher 쿼리 테스트

데이터가 준비되었으니 이제 온톨로지에서 설계한 관계들을 실제로 조회해본다.

테스트 1: 피의자 전체 조회 (위험도 순)

1
2
3
MATCH (s:Suspect)
RETURN s.name AS 이름, s.age AS 나이, s.riskScore AS 위험도점수
ORDER BY s.riskScore DESC;

기대 결과:

1
2
3
이름    | 나이 | 위험도점수
강두식  |  45  |   8.5
이칠성  |  38  |   7.2

테스트 2: 특정 인물의 지인 네트워크

1
2
MATCH (s:Suspect {name: '강두식'})-[:KNOWS]->(connected)
RETURN s.name AS 기준인물, labels(connected) AS 역할, connected.name AS 연결인물;

기대 결과: 강두식 → [Person, Suspect] → 이칠성

테스트 3: 조직 구성원 조회

1
2
MATCH (p:Person)-[:MEMBER_OF]->(o:Organization {name: 'BlackShark'})
RETURN p.name AS 구성원, [lbl IN labels(p) WHERE lbl <> 'Person'][0] AS 역할, p.riskScore AS 위험도;

기대 결과: 강두식(Suspect, 8.5)과 이칠성(Suspect, 7.2)이 반환된다.

테스트 4: 두 인물 간 최단 경로 탐색

이것이 그래프 DB의 핵심 기능이다. 강두식(피의자)에서 박문수(수사관)까지의 연결 고리를 최대 6단계 이내에서 찾는다.

1
2
3
4
MATCH path = shortestPath(
    (a:Person {name: '강두식'})-[*..6]-(b:Person {name: '박문수'})
)
RETURN path, length(path) AS 거리;

기대 결과: 경로가 4~5단계 안에 발견된다. 강두식 → 이칠성 → 최민준 → … → 박문수 형태의 연결 경로가 시각화된다.

테스트 5: 범죄 계층 구조 활용 (다중 레이블 조회)

OWL에서 DrugCrime subClassOf Crime이므로, :Crime 레이블로 조회하면 DrugCrime 사건도 함께 잡힌다.

1
2
3
4
5
6
MATCH (p:Person)-[:PARTY_TO]->(c:Crime)
RETURN p.name AS 연루인물,
       labels(c) AS 사건유형,
       c.type AS 사건명,
       c.status AS 상태
ORDER BY c.occurredAt;

DrugCrime은 :DrugCrime:Crime:Event 레이블을 가지므로, :Crime으로 조회해도 결과에 포함된다. 이것이 다중 레이블로 OWL 상속 관계를 표현하는 실질적 이점이다.

3.4 데이터 관계 구조 다이어그램

graph TD
    subgraph 조직
        BS["🏴 BlackShark\nCriminalOrganization"]
        SP["🏛️ SeoulMetroPolice\nOrganization"]
    end

    subgraph 인물
        GDS["강두식\nPerson:Suspect\nriskScore 8.5"]
        LCS["이칠성\nPerson:Suspect\nriskScore 7.2"]
        CMJ["최민준\nPerson:Victim"]
        PMS["박문수\nPerson:Investigator\nrank 경감"]
        KJD["김정도\nPerson:Investigator\nrank 경정"]
    end

    subgraph 사건
        C1["C-2025-001\nCrime:Event\n마약 밀매 / 수사중"]
        C2["C-2025-002\nDrugCrime:Crime:Event\n필로폰 유통 / 기소"]
    end

    GDS -- "MEMBER_OF 두목" --> BS
    LCS -- "MEMBER_OF 조직원" --> BS
    PMS -- "MEMBER_OF" --> SP
    KJD -- "MEMBER_OF" --> SP

    GDS -- "KNOWS 동생" --> LCS
    LCS -- "KNOWS" --> CMJ

    GDS -- "PARTY_TO 주범 0.95" --> C1
    LCS -- "PARTY_TO 공범 0.88" --> C1
    CMJ -- "PARTY_TO 피해자" --> C1
    GDS -- "PARTY_TO 주범 0.99" --> C2

    PMS -- "INVESTIGATES" --> C1
    KJD -- "INVESTIGATES" --> C2

    C1 -- "PART_OF" --> C2

4. PHASE 3 — FastAPI 애플리케이션 실행

4.1 소스 파일 작성

database.py — Neo4j 드라이버 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
# database.py
from neo4j import AsyncGraphDatabase, AsyncDriver

NEO4J_URI  = "bolt://localhost:7687"
NEO4J_USER = "neo4j"
NEO4J_PASS = "password1234!"

def create_driver() -> AsyncDriver:
    return AsyncGraphDatabase.driver(
        NEO4J_URI,
        auth=(NEO4J_USER, NEO4J_PASS),
        max_connection_pool_size=50,
    )

💡 2025년 모범 사례: 비밀번호를 코드에 직접 쓰지 않고 환경 변수로 관리하는 것이 권장된다. 프로덕션에서는 os.getenv("NEO4J_PASS") 형태로 교체한다.

models.py — Pydantic 데이터 모델

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# models.py
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from enum import Enum

class PersonRole(str, Enum):
    SUSPECT      = "Suspect"
    VICTIM       = "Victim"
    INVESTIGATOR = "Investigator"

class PersonCreate(BaseModel):
    name:         str             = Field(..., min_length=1, max_length=50)
    role:         PersonRole      = Field(...)
    age:          Optional[int]   = Field(None, ge=0, le=150)
    rank:         Optional[str]   = None
    risk_score:   Optional[float] = Field(None, ge=0.0, le=10.0)
    organization: Optional[str]   = None

class PersonResponse(BaseModel):
    name:       str
    role:       str
    age:        Optional[int]   = None
    rank:       Optional[str]   = None
    risk_score: Optional[float] = None

class OrganizationMember(BaseModel):
    name: str
    role: str
    age:  Optional[int] = None

class RelationCreate(BaseModel):
    from_name:         str            = Field(...)
    to_name:           str            = Field(...)
    relationship_type: str            = Field(...)
    metadata:          Optional[dict] = Field(default_factory=dict)

class PathResult(BaseModel):
    source:     str
    target:     str
    hops:       int
    path_nodes: List[str]

main.py — FastAPI 메인 애플리케이션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# main.py
from contextlib import asynccontextmanager
from typing import List
from fastapi import FastAPI, HTTPException, Depends, Request
from neo4j import AsyncSession

from database import create_driver
from models import (
    PersonCreate, PersonResponse,
    OrganizationMember, RelationCreate, PathResult
)

VALID_ROLES     = {"Suspect", "Victim", "Investigator"}
VALID_RELATIONS = {"KNOWS", "PARTY_TO", "INVESTIGATES"}

# ── 라이프스팬: 앱 시작/종료 시 드라이버 관리 ──
async def lifespan(app: FastAPI):
    driver = create_driver()
    app.state.driver = driver
    print("✅ Neo4j 드라이버 연결 완료")
    yield
    await driver.close()
    print("🔒 Neo4j 드라이버 종료 완료")

app = FastAPI(
    title="수사 인텔리전스 시스템",
    description="OWL 온톨로지 기반 수사 프로파일링 REST API",
    version="1.0.0",
    lifespan=lifespan,
)

# ── 세션 의존성 주입 ──
async def get_session(request: Request) -> AsyncSession:
    async with request.app.state.driver.session() as session:
        yield session

# ── /setup-sample-data ──
async def setup_sample_data(session: AsyncSession = Depends(get_session)):
    await session.run("MATCH (n) DETACH DELETE n")
    data_query = """
    CREATE (o1:CriminalOrganization:Organization {name:'BlackShark', foundedYear:2010})
    CREATE (o2:Organization {name:'SeoulMetroPolice'})
    CREATE (p1:Person:Suspect {name:'강두식', age:45, riskScore:8.5})
    CREATE (p2:Person:Suspect {name:'이칠성', age:38, riskScore:7.2})
    CREATE (p3:Person:Victim  {name:'최민준', age:29})
    CREATE (p4:Person:Investigator {name:'박문수', age:32, rank:'경감'})
    CREATE (p5:Person:Investigator {name:'김정도', age:41, rank:'경정'})
    CREATE (c1:Crime:Event {id:'C-001', type:'마약 밀매',   status:'수사중'})
    CREATE (c2:DrugCrime:Crime:Event {id:'C-002', type:'필로폰 유통', status:'기소'})
    CREATE (p1)-[:MEMBER_OF {rank:'두목'}]->(o1)
    CREATE (p2)-[:MEMBER_OF {rank:'조직원'}]->(o1)
    CREATE (p4)-[:MEMBER_OF]->(o2)
    CREATE (p5)-[:MEMBER_OF]->(o2)
    CREATE (p1)-[:KNOWS {relationshipType:'동생'}]->(p2)
    CREATE (p2)-[:KNOWS]->(p3)
    CREATE (p1)-[:PARTY_TO {role:'주범', confidence:0.95}]->(c1)
    CREATE (p2)-[:PARTY_TO {role:'공범', confidence:0.88}]->(c1)
    CREATE (p3)-[:PARTY_TO {role:'피해자'}]->(c1)
    CREATE (p1)-[:PARTY_TO {role:'주범', confidence:0.99}]->(c2)
    CREATE (p4)-[:INVESTIGATES]->(c1)
    CREATE (p5)-[:INVESTIGATES]->(c2)
    CREATE (c1)-[:PART_OF]->(c2)
    """
    await session.run(data_query)
    return {"status": "success", "message": "샘플 데이터 생성 완료"}

# ── /persons/register ──
async def register_person(
    person: PersonCreate,
    session: AsyncSession = Depends(get_session),
):
    if person.role.value not in VALID_ROLES:
        raise HTTPException(status_code=400, detail=f"허용 역할: {VALID_ROLES}")
    role_label = person.role.value
    query = f"""
    MERGE (p:Person:{role_label} )
    SET p.age       = $age,
        p.riskScore = $risk_score,
        p.rank      = $rank
    WITH p
    FOREACH (_ IN CASE WHEN $org IS NOT NULL THEN [1] ELSE [] END |
        MERGE (o:Organization )
        MERGE (p)-[:MEMBER_OF]->(o)
    )
    RETURN p.name AS name
    """
    try:
        result = await session.run(
            query,
            name=person.name, age=person.age,
            risk_score=person.risk_score, rank=person.rank,
            org=person.organization,
        )
        record = await result.single()
        return {"status": "success", "message": f"'{person.name}' 등록 완료"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ── /persons/{name} ──
async def get_person(name: str, session: AsyncSession = Depends(get_session)):
    query = """
    MATCH (p:Person {name: $name})
    RETURN p.name AS name, p.age AS age, p.rank AS rank,
           p.riskScore AS risk_score,
           [lbl IN labels(p) WHERE lbl <> 'Person'][0] AS role
    """
    result = await session.run(query, name=name)
    record = await result.single()
    if not record:
        raise HTTPException(status_code=404, detail=f"'{name}' 인물 없음")
    return PersonResponse(
        name=record["name"], role=record["role"] or "Unknown",
        age=record["age"], rank=record["rank"], risk_score=record["risk_score"],
    )

# ── /suspects ──
async def list_suspects(session: AsyncSession = Depends(get_session)):
    query = """
    MATCH (s:Suspect)
    RETURN s.name AS name, s.age AS age, s.riskScore AS risk_score, 'Suspect' AS role
    ORDER BY s.riskScore DESC
    """
    result = await session.run(query)
    return [
        PersonResponse(name=r["name"], role=r["role"], age=r["age"], risk_score=r["risk_score"])
        async for r in result
    ]

# ── /organizations/{org_name}/members ──
         response_model=List[OrganizationMember], tags=["조직"])
async def get_organization_chart(org_name: str, session: AsyncSession = Depends(get_session)):
    query = """
    MATCH (p:Person)-[:MEMBER_OF]->(o:Organization {name: $org_name})
    RETURN p.name AS name, p.age AS age,
           [lbl IN labels(p) WHERE lbl <> 'Person'][0] AS role
    ORDER BY p.name
    """
    result = await session.run(query, org_name=org_name)
    members = [
        OrganizationMember(name=r["name"], role=r["role"] or "Unknown", age=r["age"])
        async for r in result
    ]
    if not members:
        raise HTTPException(status_code=404, detail=f"'{org_name}' 조직 없음")
    return members

# ── /analysis/path ──
async def find_path(
    from_name: str, to_name: str,
    max_hops: int = 6,
    session: AsyncSession = Depends(get_session),
):
    if not (1 <= max_hops <= 10):
        raise HTTPException(status_code=400, detail="max_hops는 1~10 사이")
    query = f"""
    MATCH (a:Person ), (b:Person )
    MATCH path = shortestPath((a)-[*..{max_hops}]-(b))
    RETURN [n IN nodes(path) | coalesce(n.name, n.id, 'unknown')] AS path_nodes,
           length(path) AS hops
    LIMIT 1
    """
    result = await session.run(query, from_name=from_name, to_name=to_name)
    record = await result.single()
    if not record:
        return {"status": "not_found", "message": f"{max_hops}단계 이내 경로 없음"}
    return {"status": "found", "from": from_name, "to": to_name,
            "hops": record["hops"], "path_nodes": record["path_nodes"]}

# ── /analysis/integrity ──
async def check_integrity(session: AsyncSession = Depends(get_session)):
    query = """
    MATCH (n:Investigator) WHERE n:Suspect
    RETURN n.name AS name, labels(n) AS labels, '논리 위반 (disjointWith)' AS violation
    """
    result = await session.run(query)
    violations = [
        {"name": r["name"], "labels": r["labels"], "violation": r["violation"]}
        async for r in result
    ]
    return {"status": "ok" if not violations else "violations_found",
            "violations": violations, "count": len(violations)}

4.2 FastAPI 서버 실행

1
uvicorn main:app --reload

터미널에 아래 메시지가 나타나면 서버가 정상 기동된 것이다.

1
2
3
✅ Neo4j 드라이버 연결 완료
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

브라우저에서 http://localhost:8000/docs를 열면 Swagger UI가 나타난다. 모든 엔드포인트가 문서화되어 있으며, 각 엔드포인트를 직접 실행해볼 수 있다.


5. PHASE 4 — 통합 시나리오 테스트

서버가 실행 중인 상태에서 별도 터미널을 열고 아래 curl 명령어들을 순서대로 실행한다. Swagger UI(http://localhost:8000/docs)를 이용해 GUI로 테스트해도 동일하다.

5.1 API 요청 흐름 다이어그램

sequenceDiagram
    actor 클라이언트
    participant FastAPI
    participant Pydantic
    participant Neo4j

    클라이언트->>FastAPI: POST /setup-sample-data
    FastAPI->>Neo4j: DETACH DELETE + CREATE 쿼리
    Neo4j-->>FastAPI: 완료
    FastAPI-->>클라이언트: {"status":"success"}

    클라이언트->>FastAPI: POST /persons/register (JSON body)
    FastAPI->>Pydantic: PersonCreate 모델 검증
    Pydantic-->>FastAPI: 통과 / 실패(422 Unprocessable)
    FastAPI->>Neo4j: MERGE (p:Person:Suspect {...})
    Neo4j-->>FastAPI: 레코드 반환
    FastAPI-->>클라이언트: {"status":"success"}

    클라이언트->>FastAPI: GET /organizations/BlackShark/members
    FastAPI->>Neo4j: MATCH (p)-[:MEMBER_OF]->(o {name:$org})
    Neo4j-->>FastAPI: 구성원 목록
    FastAPI-->>클라이언트: [{name, role, age}, ...]

    클라이언트->>FastAPI: GET /analysis/path?from=강두식&to=박문수
    FastAPI->>Neo4j: shortestPath((a)-[*..6]-(b))
    Neo4j-->>FastAPI: 경로 노드 및 홉 수
    FastAPI-->>클라이언트: {"hops":4,"path_nodes":[...]}

5.2 curl 명령어 테스트

① 샘플 데이터 초기화

1
curl -X POST http://localhost:8000/setup-sample-data

예상 응답:

1
{"status":"success","message":"샘플 데이터 생성 완료"}

② 새 인물 등록

1
2
3
4
5
6
7
8
9
curl -X POST http://localhost:8000/persons/register \
  -H "Content-Type: application/json" \
  -d '{
    "name": "홍길동",
    "role": "Suspect",
    "age": 35,
    "risk_score": 6.5,
    "organization": "BlackShark"
  }'

예상 응답:

1
{"status":"success","message":"'홍길동' 등록 완료"}

③ 잘못된 역할로 등록 시도 (에러 케이스)

1
2
3
curl -X POST http://localhost:8000/persons/register \
  -H "Content-Type: application/json" \
  -d '{"name": "테스트", "role": "Criminal"}'

예상 응답: HTTP 422 Unprocessable Entity (Pydantic 검증 실패)

④ 조직 구성원 조회

1
curl http://localhost:8000/organizations/BlackShark/members

예상 응답:

1
2
3
4
5
[
  {"name":"강두식","role":"Suspect","age":45},
  {"name":"이칠성","role":"Suspect","age":38},
  {"name":"홍길동","role":"Suspect","age":35}
]

⑤ 피의자 전체 목록 (위험도 순)

1
curl http://localhost:8000/suspects

⑥ 특정 인물 조회

1
curl http://localhost:8000/persons/강두식

예상 응답:

1
{"name":"강두식","role":"Suspect","age":45,"rank":null,"risk_score":8.5}

⑦ 인물 간 경로 분석

1
curl "http://localhost:8000/analysis/path?from_name=강두식&to_name=박문수&max_hops=6"

예상 응답:

1
2
3
4
5
6
7
{
  "status": "found",
  "from": "강두식",
  "to": "박문수",
  "hops": 4,
  "path_nodes": ["강두식", "이칠성", "최민준", "...중간경로...", "박문수"]
}

6. PHASE 5 — 무결성 검증 (disjoint 제약)

이 단계가 이 시스템에서 가장 핵심적인 온톨로지 개념을 검증하는 부분이다. OWL에서 :Investigator owl:disjointWith :Suspect로 정의한 제약이 실제로 동작하는지 확인한다.

6.1 정상 상태 확인

먼저 현재 데이터가 무결한지 확인한다.

1
curl http://localhost:8000/analysis/integrity

예상 응답:

1
{"status":"ok","violations":[],"count":0}

6.2 의도적 위반 데이터 삽입

Neo4j Browser(http://localhost:7474)에서 아래 쿼리를 실행하여 규칙 위반 노드를 만든다. 이 노드는 수사관이면서 동시에 피의자인, 논리적으로 불가능한 존재다.

1
CREATE (:Person:Investigator:Suspect {name: '오류인물_테스트', age: 99});

6.3 위반 탐지 확인

다시 무결성 검사 API를 호출한다.

1
curl http://localhost:8000/analysis/integrity

예상 응답:

1
2
3
4
5
6
7
8
9
10
11
{
  "status": "violations_found",
  "violations": [
    {
      "name": "오류인물_테스트",
      "labels": ["Person","Investigator","Suspect"],
      "violation": "논리 위반 (disjointWith)"
    }
  ],
  "count": 1
}

6.4 위반 데이터 정리

테스트가 끝나면 반드시 위반 데이터를 삭제한다.

1
MATCH (n {name: '오류인물_테스트'}) DETACH DELETE n;

6.5 OWL 추론 vs. 애플리케이션 수동 검사 비교

항목OWL + Reasoner (Protégé)이 시스템 (FastAPI + Cypher)
disjoint 위반 감지자동 (추론 엔진이 즉시 감지)수동 (/analysis/integrity API 호출)
위반 시 동작“Ontology is inconsistent” 오류위반 노드 목록 반환 (삭제는 수동)
적합 환경설계·검증 단계운영 환경 모니터링

이 차이가 “OWL 온톨로지를 Neo4j로 구현한다”는 표현이 절반만 맞는 이유다. OWL의 논리적 강제력은 Neo4j로 이전되지 않는다. 대신 API 레이어에서 명시적으로 검사해야 한다.


7. 트러블슈팅

실습 중 자주 발생하는 문제와 해결 방법을 정리했다.

7.1 Neo4j 연결 오류

1
ServiceUnavailable: Failed to establish connection to ('localhost', 7687)

Neo4j 컨테이너가 완전히 시작되지 않았을 가능성이 높다. 아래 명령어로 로그를 확인한다.

1
docker logs investigation-neo4j | tail -20

Started 메시지가 보일 때까지 기다린 후 재시도한다. 보통 30~60초가 소요된다.

7.2 인증 오류

1
AuthError: The client is unauthorized due to authentication failure

비밀번호가 다르다. database.pyNEO4J_PASS가 Docker 실행 시 설정한 NEO4J_AUTH 값과 일치하는지 확인한다.

1
2
# 현재 컨테이너 환경 변수 확인
docker inspect investigation-neo4j | grep NEO4J_AUTH

7.3 Turtle 파싱 오류

1
rdflib.exceptions.ParserError: ...

@prefix 선언이 누락되었거나 세미콜론/마침표가 잘못된 경우다. RDFShape 온라인 도구로 먼저 문법을 검증한 후 Python으로 실행한다.

7.4 Cypher 레이블 파라미터 오류

Neo4j에서 레이블이나 관계 유형은 $param으로 전달할 수 없다. 이것은 Neo4j의 설계 제약이다.

1
2
3
4
5
--  동작하지 않음
MERGE (p:$role {name: $name})

--  화이트리스트 검증  f-string으로 삽입
-- Python: query = f"MERGE (p:Person:{role_label} )"

main.pyregister_person 함수가 이 방식을 사용하는 이유다. VALID_ROLES 화이트리스트로 인젝션을 방어한다.

7.5 async/await 관련 경고

FastAPI 0.95.0 이전 방식인 @app.on_event("startup")을 사용하면 다음과 같은 경고가 나타난다.

1
DeprecationWarning: on_event is deprecated, use lifespan event handlers instead.

main.py에서 lifespan 방식을 사용하면 이 경고가 사라진다. 본 가이드의 코드는 이미 최신 방식을 적용하고 있다.


8. 테스트 체크리스트

전체 실습을 마치면 아래 항목들을 확인한다.

flowchart TD
    A[시작] --> B{Python 3.11+\n설치 확인}
    B -- OK --> C[investigation.ttl 파일 생성]
    C --> D[RDFShape 온라인 검증]
    D --> E[owl_loader.py 실행\n클래스 9개 확인]
    E --> F[Neo4j Docker 컨테이너 실행]
    F --> G[Neo4j Browser 로그인\nlocalhost:7474]
    G --> H[샘플 데이터 Cypher 실행\n노드 9개 생성]
    H --> I{전체 그래프\n시각화 확인}
    I -- OK --> J[핵심 쿼리 5개 실행]
    J --> K[FastAPI 서버 실행\nuvicorn main:app]
    K --> L[Swagger UI 확인\nlocalhost:8000/docs]
    L --> M[curl 테스트 7개 실행]
    M --> N[무결성 위반 데이터\n삽입 및 탐지]
    N --> O[위반 데이터 삭제]
    O --> P[완료 🎉]

    B -- NG --> B2[Python 3.11 설치]
    B2 --> B
#항목예상 결과완료
1investigation.ttl 파싱 성공트리플 18개
2owl_loader.py 출력클래스 9개, subClassOf 6개, disjoint 1개
3Neo4j 컨테이너 실행localhost:7474 접속 성공
4샘플 데이터 생성노드 9개, 관계 14개
5피의자 조회 쿼리강두식(8.5), 이칠성(7.2) 순서
6최단 경로 쿼리강두식→박문수 경로 발견
7FastAPI 서버 실행Swagger UI 표시
8POST /setup-sample-data"status":"success"
9POST /persons/register정상 등록 + 조직 소속
10GET /organizations/BlackShark/members3명 반환
11GET /analysis/pathhops와 path_nodes 반환
12무결성 정상 상태"status":"ok"
13위반 데이터 삽입 후 재검사"status":"violations_found"
14위반 데이터 삭제 후 재검사"status":"ok"

부록 A — Docker Compose로 한번에 실행

로컬 개발 시 Neo4j와 FastAPI를 함께 띄우려면 Docker Compose를 사용하는 것이 편하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# docker-compose.yml
version: "3.9"

services:
  neo4j:
    image: neo4j:5.18-community
    container_name: investigation-neo4j
    environment:
      - NEO4J_AUTH=neo4j/password1234!
      - NEO4J_server_memory_heap_initial__size=512m
      - NEO4J_server_memory_heap_max__size=1g
    ports:
      - "7474:7474"
      - "7687:7687"
    volumes:
      - neo4j_data:/data
    healthcheck:
      test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "password1234!", "RETURN 1"]
      interval: 15s
      timeout: 10s
      retries: 5

  fastapi:
    build: .
    container_name: investigation-api
    environment:
      - NEO4J_URI=bolt://neo4j:7687
      - NEO4J_USER=neo4j
      - NEO4J_PASS=password1234!
    ports:
      - "8000:8000"
    depends_on:
      neo4j:
        condition: service_healthy
    volumes:
      - .:/app
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload

volumes:
  neo4j_data:
1
2
3
4
5
6
7
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
1
2
3
4
5
6
7
8
# 전체 환경 시작
docker compose up -d

# 로그 확인
docker compose logs -f fastapi

# 전체 종료
docker compose down

💡 database.py에서 NEO4J_URI = "bolt://localhost:7687"bolt://neo4j:7687로 변경해야 Docker 컨테이너 간 통신이 된다. Docker Compose 사용 시 서비스 이름(neo4j)이 호스트 이름이 된다.

부록 B — Cypher 주요 패턴 참조

패턴설명예시
MERGE없으면 생성, 있으면 그대로MERGE (p:Person {name: $name})
DETACH DELETE노드와 연결 관계 모두 삭제MATCH (n) DETACH DELETE n
shortestPath()두 노드 간 최단 경로 탐색shortestPath((a)-[*..6]-(b))
labels(n)노드의 레이블 목록 반환[l IN labels(n) WHERE l <> 'Person'][0]
FOREACH...IN CASE조건부 실행 (Neo4j의 IF/ELSE)FOREACH (_ IN CASE WHEN $x IS NOT NULL THEN [1] ELSE [] END \| ...)
coalesce(a, b)null 대신 기본값 반환coalesce(n.name, 'unknown')
collect()여러 값을 리스트로 집계collect(c.type) AS 사건목록

작성 기준일: 2026-04-22
원문: BLUEBUG’S BLOG — 수사 온톨로지 기반 지식 그래프 시스템 구축 가이드

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.