
배경
e-커머스 주문 데이터를 Neo4j 그래프 DB에 적재해둔 상태에서, 이걸 LangFlow AI Agent를 통해 자연어로 질의할 수 있는 파이프라인을 만들고 싶었다.
핵심 요구사항은 두 가지였다.
- Role-Based Access Control (RBAC) — 직급/권한에 따라 접근 가능한 데이터가 달라야 한다. reader는 상품·국가만 볼 수 있고, pii_admin만 이메일·전화번호 같은 PII 데이터에 접근 가능하다.
- 그래프 관계 탐색 — "영국 고객들의 주문 목록"처럼 Country → Customer → Order를 넘나드는 멀티홉 탐색이 자연어로 동작해야 한다.
환경
항목버전
| LangFlow | 1.9.0 |
| Neo4j | 5.27-aura (Enterprise) |
| Python | 3.12 |
| neo4j 드라이버 | 6.1.0 |
패키지는 LangFlow가 쓰는 venv에 설치해야 한다. 시스템 Python에 깔아봤자 소용없다.
/Users/jumpcloud/.langflow/.langflow-venv/bin/pip install neo4j langchain-neo4j
VS Code Pyright가 langflow, langchain_core import에 빨간 줄을 긋는데, IDE가 시스템 Python 경로를 보기 때문이고 LangFlow 실행 시엔 정상 동작한다. 신경 끄면 된다.
데이터 구조
그래프 스키마는 이렇게 생겼다.
레이블노드 수접근 레벨
| Product | 3,684 | public |
| Country | 37 | public |
| Customer | 4,372 | internal |
| Order | 22,190 | internal |
| OrderLineItem | 406,829 | internal |
| Return | 3,654 | internal |
| ValidCommunity | 1,101 | internal |
| Outlier | 6 | restricted |
| EmailAddress | 4,213 | confidential (PII) |
| Phone | 4,235 | confidential (PII) |
| Identifier | 4,288 | confidential (PII) |
총 노드 449,848개, 관계 1,192,605개다.
주요 관계 경로는 이렇다.
Country ←[LOCATION]─ Customer ─[PURCHASED]→ Order ─[ORDERED]→ OrderLineItem ─[LINE_ITEM]→ Product
└─[ASSOC_EMAIL]→ EmailAddress (pii_admin 전용)
└─[ASSOC_PHONE]→ Phone (pii_admin 전용)
└─[ASSOC_ID]──→ Identifier (pii_admin 전용)
RBAC 설계: 왜 애플리케이션 레이어인가
처음엔 Neo4j Aura에서 DB 레벨 RBAC를 쓰려 했다. CREATE ROLE, GRANT, DENY 같은 Cypher 명령을 쓰면 되겠지 싶었는데, Aura Enterprise는 이걸 플랫폼 레벨에서 막고 있다.
Neo.ClientError.Security.Forbidden
Permission has not been granted for CREATE ROLE
그래서 애플리케이션 레이어 RBAC로 방향을 바꿨다. 쿼리 실행 전에 코드에서 레이블 접근 권한을 체크하는 방식이다.
ROLE_ACCESS = {
"reader": ["Product", "Country"],
"analyst_role": ["Customer", "Order", "Product", "OrderLineItem", "Country"],
"pii_restricted_role": ["Customer", "Order", "Product", "OrderLineItem",
"Country", "Return", "ValidCommunity", "Outlier"],
"pii_admin": ["Customer", "Order", "Product", "OrderLineItem",
"Country", "Return", "EmailAddress", "Phone",
"Identifier", "ValidCommunity", "Outlier"],
}
권한 체크는 쿼리 실행 전에 빠르게 실패(fail fast)하도록 했다.
def _check_access(self, role: str, label: str) -> tuple[bool, str]:
allowed = ROLE_ACCESS.get(role)
if allowed is None:
return False, f"알 수 없는 Role: '{role}'"
if label not in allowed:
return False, f"Role '{role}'은 '{label}' 노드에 접근할 수 없습니다."
return True, ""
컴포넌트 개발: v1 → v2 → v3
v1 — 일단 돌아가게
처음 구현은 두 개의 Tool Output을 가진 컴포넌트였다.
- Search Tool: keyword로 허용 레이블 전체를 순차 검색
- Explore Tool: start_label → target_label 관계 탐색
빠르게 붙이는 데는 성공했는데 문제가 산더미였다. 에러 처리 없음, 레이블 인젝션 검증 없음, connection timeout 설정 없음.
v2 — 꼼꼼하게
에러 처리, 레이블 화이트리스트 검증(_sanitize_label()), connection_timeout/max_hops UI 입력, truncated 플래그 등을 추가했다.
그리고 collect() 메모리 버그를 고쳤다. 원래 explore 쿼리를 이렇게 짰었는데:
WITH DISTINCT target
WITH count(target) AS total, collect(target) AS all_targets
RETURN total, [t IN all_targets[..$limit] | properties(t)] AS targets
Country(United Kingdom) → Order처럼 결과가 22,190건이면 전체를 메모리에 올린 뒤 잘라내는 방식이라 OOM 위험이 있다. 2개의 쿼리로 분리해서 해결했다.
# 쿼리 1: 결과 조회 — DB에서 LIMIT으로 잘라냄
result_rows = s.run(path_match + "RETURN DISTINCT target LIMIT $limit", ...)
# 쿼리 2: 총 건수 조회 — 집계 연산만, 메모리 부담 없음
count_row = s.run(path_match + "RETURN count(DISTINCT target) AS total", ...)
v3 — 도구를 하나로
v2를 쓰면서 결정적인 문제가 생겼다. Agent가 Explore Tool을 전혀 안 쓴다.
User: "United Kingdom 주문 목록 보여줘"
AI: Country 노드만 반환 후 "주문은 조회할 수 없습니다" 안내
User: "France 고객들의 주문은 뭐가 있어?"
AI: 이름에 'France'가 포함된 고객(Francesco...)을 Search로 반환
User: "Netherlands 고객 누구누구야?"
AI: Country 노드만 반환 후 "LOCATION 관계 탐색 지원 안 됨" 안내
tool description을 아무리 강화해도, System Prompt에 규칙을 아무리 넣어도 안 됐다. Agent 입장에서 두 도구 중 뭘 쓸지 판단 자체가 흔들리는 것이다.
해결책은 단순했다: 도구를 하나로 합쳐서 Agent가 선택을 못 하게 만든다.
기존: neo4j_rbac_search + neo4j_explore_relationships → Agent가 선택
변경: neo4j_graph_query 하나 → target_label 유무로 내부 자동 분기
class Neo4jGraphQueryInput(BaseModel):
start_label: str # 시작 노드 레이블 (필수)
start_keyword: str # 검색 키워드 (필수)
target_label: Optional[str] # None → search, 값 있음 → explore
limit: Optional[int] # 기본 10, 최대 50
내부 분기:
if target_label:
return _explore(driver, role, start_label, start_keyword,
target_label, limit, creds["max_hops"])
else:
return _search(driver, role, start_label, start_keyword, limit)
이 변경 후 Netherlands, France, United Kingdom 등 모든 국가 → 고객/주문 탐색이 정상 동작하기 시작했다.
또 다른 삽질: PII 이메일 검색 거부
User: "zimfam125 이메일 계정 찾아줘"
AI: "저희 데이터베이스에 포함된 정보가 아니며,
외부 이메일 계정 검색은 지원하지 않습니다"
→ 도구 미호출
Agent가 zimfam125를 외부 이메일 계정 조회 요청으로 판단해서 도구 자체를 안 불렀다. System Prompt에 "e-커머스 DB 어시스턴트"라고만 정의되어 있고, EmailAddress가 내부 노드 레이블이라는 걸 명시하지 않았기 때문이다.
tool description에 직접 예시를 박았다.
"zimfam125 이메일 찾아줘" → start_label=EmailAddress, start_keyword=zimfam125 (target 없음)
"+44 전화번호" → start_label=Phone, start_keyword=+44 (target 없음)
그 후 정상 동작. LLM에게 "이게 DB 안에 있는 노드야"라고 명시적으로 알려줘야 한다는 교훈.
또 다른 삽질: ValidCommunity 데이터 구조 오해
이상거래 탐지 커뮤니티 데이터를 조회하려고 이런 질문을 넣었다.
"ValidCommunity에 속한 고객들 보여줘"
48초 대기 후 빈 결과. 처음엔 쿼리 최적화 문제라 생각했는데, 데이터 구조 자체를 잘못 이해하고 있었다.
오해: ValidCommunity → Customer 관계가 있을 것이다
실제: SHARED_PII는 Customer → Customer 관계이고, ValidCommunity 노드는 Customer와 직접 연결이 없다. ValidCommunity 노드 자체에 firstName, lastName, customerId 등 고객 정보가 내장되어 있다.
따라서 올바른 접근은 explore가 아니라 ValidCommunity 노드를 직접 search하는 것이다.
# 잘못된 접근 (explore 모드)
start_label=ValidCommunity, target_label=Customer → 빈 결과
# 올바른 접근 (search 모드)
start_label=ValidCommunity, start_keyword=Johnson → ValidCommunity 노드 직접 반환
실제 검색 결과:
#고객 ID이름커뮤니티
| 1 | 13607 | Danielle Johnson | 4000 |
| 2 | 12581 | William Johnson | 4000 |
실제 테스트 결과
v3 컴포넌트로 진행한 테스트 결과 일부다.
국가 → 주문 탐색
질문: "영국(United Kingdom)에서 온 주문 목록 보여줘"
- total_count: 22,190건
- 상위 10건 반환
#주문번호주문일자
| 1 | 558390 | 2011-06-29 |
| 2 | 561031 | 2011-07-24 |
| 3 | 573746 | 2011-11-01 |
질문: "France 고객들의 주문은 뭐가 있어?"
- total_count: 5,983건
국가 → 고객 탐색
질문: "Netherlands 고객 누구누구야?"
- total_count: 9명 (전원 반환)
#고객 ID이름성
| 1 | 14646 | Joannie | Hodkiewicz |
| 2 | 14682 | Mariah | Bashirian |
| 3 | 15891 | Huey | Stroman |
고객 → 상품 탐색
질문: "고객 17850이 구매한 상품 목록 알려줘"
- 고객 Willy Gallegos (customerId=17850)
- total_count: 3,682개
- 상위 10개 반환
이상거래 탐지 데이터
질문: "Outlier 노드 zimfam125 찾아줘"
Outlier로 분류된 항목 5건:
유형값커뮤니티
| 이메일 | zimfam125@gmail.com | 4000 |
| 이메일 | asmith333@gmail.com | 4015 |
| 전화번호 | +33 9 50 51 28 09 | 4015 |
| 정부ID | 7211755002 | 4000 |
| 정부ID | 8773210779 | 4015 |
성능 이슈: 가변 길이 경로 탐색의 함정
Customer → Phone 탐색에 517초가 걸렸다. 원인은 [*1..3] 가변 길이 경로 탐색이다.
Customer 노드 기준으로 [*1..3]을 펼치면 Customer → Order가 21,200건이고, 각 Order에서 다시 3홉을 탐색하니 경우의 수가 폭발한다.
해결책은 직접 연결(1홉)이 확실한 경로를 명시적으로 고정하는 것이다.
_ONE_HOP_PATHS = frozenset([
("Customer", "Order"), # PURCHASED
("Customer", "Country"), # LOCATION
("Customer", "EmailAddress"), # ASSOC_EMAIL
("Customer", "Phone"), # ASSOC_PHONE
("Customer", "Identifier"), # ASSOC_ID
("Order", "OrderLineItem"), # ORDERED
("Order", "Return"),
("OrderLineItem", "Product"), # LINE_ITEM
("Country", "Customer"), # LOCATION 역방향
])
# explore 시 경로 결정
if (start_label, target_label) in _ONE_HOP_PATHS:
hops = "1..1" # 고정 1홉 — 경로 폭발 없음
else:
hops = f"1..{max_hops}" # 가변 길이 (max 3)
Agent System Prompt 설계
도구가 1개가 됐으므로 System Prompt는 파라미터 변환 규칙에 집중했다.
당신은 Neo4j 그래프 DB 기반 e-커머스 질의 어시스턴트입니다.
도구 neo4j_graph_query 하나만 사용합니다.
## 파라미터 변환 규칙
| 질문 패턴 | start_label | start_keyword | target_label |
|---------------------------|--------------|-----------------|--------------|
| "Netherlands 고객 목록" | Country | Netherlands | Customer |
| "France 주문 현황" | Country | France | Order |
| "United Kingdom 주문 목록"| Country | United Kingdom | Order |
| "고객 17850 구매 상품" | Customer | 17850 | Product |
| "Willy 주문 내역" | Customer | Willy | Order |
| "주문 536365 상품" | Order | 536365 | Product |
| "RED 상품 구매 고객" | Product | RED | Customer |
| "RED 상품 정보" | Product | RED | (없음) |
| "zimfam125 이메일 찾아줘" | EmailAddress | zimfam125 | (없음) |
"규칙을 쓰고 예시를 넣어라"가 LLM Agent System Prompt의 핵심이다. 추상적인 지시보다 구체적인 입출력 매핑 테이블이 훨씬 잘 동작한다.
버전별 비교 요약
항목v1v2v3
| 도구 수 | 2 | 3 (Search + Explore + Toolkit) | 1 |
| 에러 처리 | 없음 | 있음 | 있음 |
| 레이블 인젝션 방지 | 없음 | 있음 | 있음 |
| Connection Timeout UI | 없음 | 있음 | 있음 |
| Max Hops UI | 없음 | 있음 | 있음 |
| collect() 메모리 위험 | 있음 | 없음 | 없음 |
| Agent 도구 선택 실수 | 가능 | 가능 | 불가 |
| 1홉 경로 최적화 | 없음 | 없음 | 있음 |
적용 방법 요약
- LangFlow UI → Custom Components → neo4j_rbac_tool_v3.py 붙여넣기
- 캔버스에 컴포넌트 추가 후 URI / 비밀번호 / Role 설정
- Graph Query Tool output → Agent Tools 포트 연결
- Agent Instructions에 System Prompt 붙여넣기
- 테스트: "RED 상품 찾아줘" → "Netherlands 고객 목록" → RBAC 차단 확인 순으로 검증
배운 것
- LLM Agent에게 도구 선택을 맡기지 마라. 도구가 2개라면 하나를 못 쓰는 경우가 생긴다. 통합 가능하면 통합하고, Agent는 파라미터만 채우게 하는 게 낫다.
- tool description은 예시가 전부다. "관계 탐색 시 사용하세요" 같은 추상적 문구는 효과 없다. "Netherlands 고객 목록 → start_label=Country, target_label=Customer" 같은 구체적 입출력 매핑을 넣어야 한다.
- 그래프 DB 쿼리는 카디널리티를 먼저 확인하라. [*1..3]이 특정 노드에서 출발하면 경로가 수십만 개로 폭발할 수 있다. 알려진 직접 연결 경로는 반드시 [*1..1]로 고정하라.
- 데이터 구조는 가정하지 말고 확인하라. ValidCommunity → Customer 관계가 있을 거라는 가정이 48초짜리 빈 결과를 만들었다. CSV 파일이나 관계 타입을 직접 확인하는 습관이 필요하다.
- Neo4j Aura는 DB 레벨 RBAC를 지원하지 않는다. 처음부터 애플리케이션 레이어 RBAC로 설계하는 게 맞다.