아키텍처 개선 포인트
⚠️ 이 문서는 감사 커밋
7d60c82의 스냅샷이다. 조용히 틀린 답을 내던 다섯 항목(ARCH-01, ARCH-02/CODE-01, CODE-33, CODE-34, PERF-20)은 이후 수정되었다. 현재 상태는03_backend/12_fixed_correctness.md를 볼 것.
이 문서가 답하는 질문
- 지금 아키텍처의 구조적 부담은 무엇인가?
- 각 부담의 코드 근거는 어디이고, 무엇을 고쳐야 하며, 고치면 무엇이 좋아지고 무엇이 위험해지는가?
- 무엇부터 해야 하는가?
작성 원칙: 모든 항목은 실제 코드를 읽고
파일:라인근거를 붙였다. 일반론은 넣지 않았다. 확인하지 못한 것은 "미확인"이라고 적었다. 조사 시점: 2026-08-22, 커밋7d60c82.
요약표
| ID | 제목 | 심각도 |
|---|---|---|
| ARCH-01 | UNION 이 파싱되고 조용히 무시된다 |
High · fixed |
| ARCH-02 | 컴파일 캐시가 스키마 변경에 무효화되지 않는다 | High · fixed |
| ARCH-03 | 읽기 경로가 DDL을 실행한다 (stable 함수 안에서) |
High |
| ARCH-04 | compile.rs 1,591줄 단일 파일 |
Med |
| ARCH-05 | Cypher와 TypeQL이 공통 IR을 공유하지 않는다 | Med |
| ARCH-06 | compat/ 와 코어의 양방향 결합 |
Med |
| ARCH-07 | Bolt 게이트웨이 프로세스 경계의 4가지 비용 | Med |
| ARCH-08 | 읽기/쓰기 이원화가 만드는 불변식 일원 보관 | Med |
| ARCH-09 | 에이전트 max_rows 한도가 강제되지 않는다 |
Med |
| ARCH-10 | 확장 업그레이드 경로가 없다 | High |
| ARCH-11 | 결과 스트리밍이 어디에도 없다 | Med |
| ARCH-12 | 문서-코드 드리프트 (WITH) |
Low |
| ARCH-13 | shard 비트가 예약만 되고 죽어 있다 | Low |
| ARCH-14 | 동작하지 않는 설정 손잡이 3개 | Low |
| ARCH-15 | 재라벨링이 전량 재계산 + 타입당 1 SQL | Med |
| ARCH-16 | og_catalog.rule 이 쓰기 전용, role 카디널리티 미강제 |
Med |
| ARCH-17 | 오류 처리 전략 불일치 (error! / expect / unwrap / .ok()) |
Med |
| ARCH-18 | Studio에 인증 계층이 없고 임의 SQL 엔드포인트가 있다 | High |
상세
| ID | 제목 | 심각도 | 근거 (파일:라인) | 현상 | 제안 | 예상 효과 | 리스크 |
|---|---|---|---|---|---|---|---|
| ARCH-01 | UNION 이 파싱되고 조용히 무시된다 |
High · fixed | 파싱: engine/src/cypher/parser.rs:147,161-168 · AST 필드: engine/src/cypher/ast.rs:210-215 · 소비자 부재: grep -rn "\.union" engine/src/ → 0건 · 컴파일 진입: engine/src/cypher/compile.rs:351-370 는 q.clauses 만 순회 |
MATCH … RETURN … UNION MATCH … RETURN … 이 오류 없이 첫 분기의 행만 반환한다. README·bolt/README.md 는 "UNION은 실패한다"고 적지만 실제로는 실패하지 않고 부분 답을 준다. 헌법 원칙 VIII과 spec 003 plan.md의 "조용히 잘못 해석하는 것보다 명시적 오류가 낫다"에 정면으로 어긋난다 |
(1) 즉시: compile_read() 진입부에 if q.union.is_some() { return Err("UNION is not supported yet — write the branches as separate queries and combine them in SQL") } 추가. (2) 후속: build_select() 결과를 UNION [ALL] 로 잇는다. 컬럼 이름/개수 일치 검증과 ORDER BY/LIMIT 의 소속(마지막 분기 vs 전체) 규칙 필요 |
조용한 오답이 사라진다. og_cypher_check() 를 쓰는 Bolt·에이전트 경로가 사전에 거부할 수 있게 된다 |
(1)은 리스크 거의 없음 — 현재 "동작"하던 질의가 오류가 되지만 그 동작은 틀린 답이었다. (2)는 reachability_only 판정을 분기별로 다시 정의해야 하고, jsonb 컬럼 이름 정렬 문제가 재등장한다 |
| ARCH-02 | 컴파일 캐시가 스키마 변경에 무효화되지 않는다 | High · fixed | 캐시 정의/키: engine/src/cypher/mod.rs:26-31 ((String, String) = graph, query) · 유일한 비우기: mod.rs:60-65 if m.len() > 512 { m.clear() } · 뷰 폐기: engine/src/catalog/labeling.rs:172-182 bump_schema_version → drop_all_views · 뷰 생성은 컴파일 시에만: engine/src/cypher/compile.rs:773 → views.rs:93-97 |
bump_schema_version() 이 og_data.v_* / ve_* 를 전부 DROP VIEW … CASCADE 하지만 PLAN_CACHE 는 건드리지 않는다. 캐시 히트 시 ensure_view() 를 건너뛰므로, 폐기된 뷰를 참조하는 SQL이 실행된다. 트리거 경로가 넓다: og_add_property(types.rs:599), og_add_role, og_add_rule, 타입 개명, TypeQL define, 그리고 Cypher 쓰기의 프로퍼티 자동 승격(storage/mod.rs:112-119 → og_add_property) |
캐시 키에 스키마 버전을 넣는다: (graph, query, schema_version). schema_version 은 og_catalog.schema_version 의 max로 이미 존재하며 og_schema() 가 이미 읽는다(agent/mod.rs:24-30). 조회 비용이 부담되면 백엔드 로컬에 마지막 확인 버전을 두고 값이 바뀌었을 때만 캐시를 비운다 |
스키마 변경 후 첫 질의가 조용히 실패하거나 낡은 후손 집합으로 실행되는 경로가 닫힌다. 프로퍼티 승격이 잦은 Cypher 워크로드에서 특히 효과가 크다 | 캐시 히트마다 버전 조회 1회가 추가된다(경량 SPI). 캐시 히트율이 스키마 변경 직후 일시적으로 0이 된다 |
| ARCH-03 | 읽기 경로가 DDL을 실행한다 | High | stable 선언: engine/src/cypher/mod.rs:74 #[pg_extern(stable)] fn og_cypher_sql · 그 안의 DDL: engine/src/cypher/views.rs:135 CREATE OR REPLACE VIEW · 쓰기 경로 DDL: engine/src/storage/mod.rs:138-143 ALTER TABLE … TYPE text (서브타입마다 반복) · 읽기 경로 INSERT: engine/src/cypher/mod.rs:122-135 INSERT INTO og_data.og_audit |
세 가지가 겹친다. (a) stable 은 "DB를 수정하지 않는다"는 플래너 계약인데 뷰 DDL을 실행한다. (b) 읽기 질의가 감사 INSERT를 한다. (c) 프로퍼티 확장이 데이터 쓰기 도중 ALTER TABLE (ACCESS EXCLUSIVE 락)을 건다. 결과: 읽기 전용 대기 서버에서 처음 보는 라벨의 질의가 성립하지 못한다 — spec 007이 "읽기 복제는 코드 없이 동작한다"고 주장하는 것과 충돌한다 |
(a) og_cypher_sql 을 volatile 로 내리거나, ensure_view 를 하지 않는 읽기 전용 컴파일 모드를 만든다(뷰가 없으면 인라인 UNION ALL 서브쿼리를 직접 방출). (b) 감사를 옵트인(og_set_setting('audit.enabled','on'))으로 바꾸거나 읽기 전용 트랜잭션에서는 건너뛴다. (c) 확장 DDL의 락 영향을 문서화하고, 실패를 let _ = 로 삼키지 않는다 |
읽기 대기 서버에서 Cypher가 실제로 동작하게 된다 — 이것은 spec 007 P0의 유일한 실현 수단이다. 플래너 계약이 정직해진다 | 인라인 UNION ALL 방출은 SQL 길이를 키우고 플랜 캐시 효율을 떨어뜨릴 수 있다. 감사 옵트인은 보안 요구와 충돌할 수 있으므로 07_security/ 와 합의가 필요하다. 대기 서버에서의 현재 실제 동작은 미확인이며 테스트가 선행되어야 한다 |
| ARCH-04 | compile.rs 1,591줄 단일 파일 |
Med | engine/src/cypher/compile.rs 전체. 한 impl Compiler 안에 최적화 판단(prefer_reachability 34-78, multiplicity_blind 318-349, blind_expr 82-100), 절 컴파일(compile_match 172, compile_with 382, compile_call 415), 프로젝션(build_core 477, build_select 602, build_tabular 621), 패턴(compile_pattern 647, bind_node 717, join_rel 819), 표현식(expr 1146, binary 1336, func 1415, 함수 60여 개 디스패치)가 모두 있다. 구조체 필드도 7개 누산기 + 2개 모드 플래그(compile.rs:110-128) |
변경 반경이 넓다. 예를 들어 OPTIONAL MATCH 술어 배치(248-275)와 홉 조인 순서(676-678)가 같은 파일 안에서 self.from 인덱스를 공유하며 상호작용한다. 새 Cypher 기능(예: UNION)이 이 상호작용 전체를 이해해야 추가 가능하다 |
4축으로 분할: (1) compile/plan.rs — prefer_reachability, multiplicity_blind, blind_expr 등 순수 판단 함수(SPI 1회 외에는 부작용 없음, 단위 테스트 가능). (2) compile/pattern.rs — compile_pattern / bind_node / join_rel / OptionalScope. (3) compile/project.rs — build_core / build_select / build_tabular / compile_with. (4) compile/expr.rs — expr / binary / type_of / func. Compiler 구조체와 Bind 는 compile/mod.rs 에 남기고 각 파일은 impl Compiler 를 이어 쓴다 |
각 축이 독립적으로 테스트 가능해진다. 특히 (1)은 SPI 없이 순수 함수로 만들 수 있어 prefer_reachability 의 손익분기 규칙을 회귀 테스트로 고정할 수 있다 — 그 규칙은 이미 두 번 틀린 이력이 있다(compile.rs:20-33, 60-67 주석) |
impl 분할은 private 필드 접근 때문에 같은 모듈 트리 안이어야 한다. 순수 이동 커밋과 동작 변경 커밋을 반드시 분리해야 리뷰가 가능하다. 기존 git blame 이 흐려진다 |
| ARCH-05 | Cypher와 TypeQL이 공통 IR을 공유하지 않는다 | Med | grep -rn "crate::cypher" engine/src/typeql/ → 0건 · grep -rn "crate::typeql" engine/src/cypher/ → 0건 · 독립 Compiler: cypher/compile.rs:110-128 vs typeql/compile.rs:39-55 · 독립 Bind: cypher/compile.rs:102-108 vs typeql/compile.rs:23-37 · 양쪽 모두 from: Vec<String>, wheres: Vec<String>, n: usize, fresh() 를 독립 구현 |
두 프런트엔드가 문자열 SQL을 각자 조립한다. 공통 IR이 없으므로 한쪽의 개선이 다른 쪽에 오지 않는다. 실제 격차: og_reach 도달성 재작성, PLAN_CACHE, views::ensure_view 유니온 뷰, 파라미터 타입 힌트 전파가 전부 Cypher 쪽에만 있다. spec 003 README는 "003의 계획 표현은 언어 중립적·분산 확장 가능해야 한다"고 명시하는데(specs/README.md 의존 관계 절), 현재 그런 표현이 존재하지 않는다 |
최소 침습부터: (1) sql_builder 공용 모듈 — from/wheres/ctes 누산기, fresh() 별칭 생성, quote_ident/sql_str 을 한 곳으로. (2) 가변 길이 홉 컴파일을 공용화 — og_vlp/og_reach 선택 로직을 언어 중립 함수로 빼서 TypeQL 파이프라인도 쓸 수 있게. (3) 장기적으로 LogicalPlan 열거형(Scan/Expand/Filter/Project/Aggregate)을 도입하고 두 파서가 그것으로 내려가게 한다 — spec 007 분산 계획의 전제이기도 하다 |
최적화가 한 번만 구현된다. TypeQL 깊은 순회가 자동으로 도달성 재작성을 얻는다. spec 007 push-down 계획이 실현 가능해진다 | (3)은 큰 리팩터링이고 두 언어의 의미론 차이(TypeQL의 reify, 값 중복 제거 속성, role 특수화)를 IR이 표현해야 한다. 잘못 추상화하면 두 컴파일러 모두 느려지고 읽기 어려워진다. (1)부터 점진적으로 하는 것을 권한다 |
| ARCH-06 | compat/ 와 코어의 양방향 결합 |
Med | 코어 → compat: cypher/compile.rs:421 use crate::compat::procs, cypher/mod.rs:165 crate::compat::ddl::run · compat → 코어 내부: compat/ddl.rs:10-11 use crate::cypher::ast::*; use crate::cypher::eval;, compat/procs.rs:47,111,191-193 crate::cypher::compile::sql_str, compat/procs.rs:226 crate::cypher::views::ensure_view · 하드코딩: cypher/compile.rs:1545-1557 가 "genai.vector.encode" 를 코어 함수 디스패치에 직접 박아 넣음 |
헌법 원칙 VI("코어는 하나, 어댑터는 엣지에")가 코드 의존 방향에서 지켜지지 않는다. Neo4j 호환면을 제거하거나 다른 호환면(예: GQL, AGE 호환)을 추가하려면 코어 컴파일러를 고쳐야 한다. compile.rs:1558-1566 의 "지원 함수 목록" 오류 메시지에도 Neo4j 고유 이름이 섞여 있다 |
코어에 프로시저/함수 레지스트리 트레이트를 정의하고 compat/ 가 등록하게 뒤집는다. 예: trait ProcProvider { fn plan(&self, name, args, alias) -> Option<Plan>; } 를 코어에 두고, compile_call 은 등록된 provider들을 순회한다. sql_str/quote_ident 는 ARCH-05의 공용 sql_builder 로 옮겨 compat 가 코어 내부를 참조하지 않게 한다. genai.vector.encode 도 레지스트리 항목으로 내린다 |
호환면 추가·제거가 코어를 건드리지 않는다. 원칙 VI가 문서가 아니라 타입으로 강제된다. compile.rs 도 함께 얇아진다 (ARCH-04 시너지) |
트레이트 객체 디스패치가 컴파일 경로에 들어간다(컴파일은 캐시되므로 실행 비용은 아님). compat/ddl.rs 는 Cypher AST를 진짜로 필요로 하므로(Neo4j DDL이 Cypher 문법이다) AST 의존은 남는다 — 그 부분은 정당하며 굳이 없앨 필요 없다 |
| ARCH-07 | Bolt 게이트웨이 프로세스 경계의 네 가지 비용 | Med | ① 왕복 2회: bolt/src/session.rs:283-299 og_cypher_columns 후 og_cypher · ② 전체 물질화: session.rs:291-322 pg.query() → Vec<records> 를 만든 뒤 PULL n 을 그 벡터에서 서빙 · ③ 평문: session.rs:182 cfg.connect(NoTls) + TLS 미종단(bolt/README.md) · ④ 풀 없음: bolt/src/main.rs:69-79 세션당 PG 연결 1개 · ⑤ bookmark 상수: session.rs:218 항상 "ontological:0" |
게이트웨이가 의미론을 갖지 않는다는 설계 결정은 옳지만, 프로세스 경계 자체가 비용을 만든다. ②가 특히 크다 — Bolt PULL n 은 원래 페이징 프로토콜인데, 여기서는 이미 전부 메모리에 있다. 큰 결과가 게이트웨이 RSS를 그대로 밀어올린다. ⑤ 때문에 causal consistency 북마크가 의미 없는 값이며, 드라이버가 그것을 신뢰하면 잘못된 안전감을 준다 |
① og_cypher_columns 결과를 게이트웨이에 (query → columns) LRU로 캐시(파싱만 하는 immutable 함수이므로 안전). 또는 og_cypher 가 컬럼 순서를 함께 반환하는 표면 추가. ② postgres 크레이트의 포털/커서(query_raw 또는 BEGIN; DECLARE CURSOR)로 PULL n 만큼만 당긴다. ③ 게이트웨이↔︎PG 구간에 TLS 옵션(OG_BOLT_PGSSLMODE)을 추가하고, Bolt 종단 TLS는 프록시 전제를 README보다 강하게 배포 가이드에 못박는다. ⑤ 북마크를 지원하지 않음을 지원 매트릭스에 명시하거나 실제 LSN 기반 값으로 채운다 |
①은 RUN당 왕복이 절반이 된다. ②는 큰 결과의 메모리 상한이 생기고 첫 레코드 지연이 줄어든다. ③은 관리형/네트워크 배포가 가능해진다 | ②는 명시적 트랜잭션 밖에서 커서를 쓰려면 게이트웨이가 트랜잭션을 열어야 하고, 그러면 "게이트웨이는 상태를 갖지 않는다"는 성질이 약해진다. 현재 자동커밋 질의 의미론과 충돌하지 않도록 설계가 필요하다. ①의 캐시는 og_cypher_columns 가 immutable 이므로 안전하지만, 게이트웨이가 "캐시를 갖지 않는다"는 spec 011 문구와의 정합성을 plan.md에 기록해야 한다 |
| ARCH-08 | 읽기/쓰기 이원화가 만드는 불변식 일원 보관 | Med | 설계 선언: engine/src/storage/mod.rs:1-10 · 불변식 유지 코드: storage/mod.rs:429-446(생성), 501-528(삭제), adjacency.rs:19-72 · 읽기 측 가정: engine/sql/access.sql:14-37, compile.rs:900-906 · 사후 검사: storage/stats.rs:172-263 |
"레지스트리 + 타입 테이블 + 양방향 인접이 언제나 일치한다"는 불변식이 Rust 쓰기 경로에만 존재한다. DB 제약(FK, CHECK, 트리거)으로 강제되지 않으므로, og_data.og_adj 나 타입 테이블에 직접 SQL을 쓰는 경로(수동 복구, ETL, 마이그레이션, 버그)가 불변식을 깰 수 있고 읽기는 그것을 조용히 신뢰한다. og_check_integrity() 는 4종을 검사하지만 사후 진단이고 LIMIT 100 이며 자동 실행되지 않는다 |
(1) 값싼 제약부터 DB로 내린다: og_data.og_edge(src)/(dst) → og_data.og_node(id) FK, og_adj 에 CHECK (n = coalesce(array_length(nbr,1),0) AND array_length(nbr,1) = array_length(eid,1)). (2) og_check_integrity() 를 CI/벤치 하네스의 게이트로 고정(하네스가 이미 무결성 검사를 돌린다 — bench/harness.py). (3) 검사 항목에 "타입 테이블 행 ↔︎ 레지스트리 행" 양방향 대조를 추가(현재 4번 검사는 레지스트리→카탈로그만 본다) |
불변식 위반이 쓰기 시점에 즉시 드러난다. 복제 검증(spec 007 P0)의 신뢰도가 올라간다 | FK는 쓰기 경로에 인덱스 조회를 추가한다 — 대량 적재 성능에 영향이 있으므로 벌크 경로에서 DEFERRABLE 또는 일시 비활성 전략이 필요하다. CHECK 는 배열 갱신마다 평가되므로 og_adj UPDATE 비용을 측정해야 한다 |
| ARCH-09 | 에이전트 max_rows 한도가 강제되지 않는다 |
Med | engine/src/agent/mod.rs:437-439 Spi::run(&format!("SET og.max_rows = {rows}")).ok(); · 소비자 부재: grep -rn "max_rows" engine/src/ → 이 두 줄뿐 · GUC 등록 부재: grep -rn "GucRegistry" engine/src/ → 0건 · 대비: 같은 함수의 statement_timeout/work_mem/default_transaction_read_only 는 PostgreSQL이 실제로 강제한다 |
에이전트 역할에 max_rows 를 선언하면 og_apply_role() 이 성공을 보고하고 {"role":…, "applied": limits} 에 그대로 실어 돌려준다. 그러나 어떤 질의 경로도 그 값을 읽지 않는다. 헌법 원칙 VIII은 "한계가 나쁜 질의를 서버가 멈추기 전에 멈춰야" 한다고 요구한다. 에이전트(그리고 그 에이전트를 신뢰하는 사람)는 강제되는 한도로 읽는다 |
둘 중 하나. (A) 실제로 강제한다: pgrx::GucRegistry 로 og.max_rows 를 등록하고, og_cypher()/og_typeql() 의 결과 수집 루프에서 초과 시 error! 로 중단(또는 컴파일 시 LIMIT 주입). (B) 강제하지 않음을 명시한다: og_apply_role 의 반환에 "enforced": ["statement_timeout_ms","work_mem_kb","read_only"], "advisory": ["max_rows"] 를 넣고 문서화. (A)를 권한다 — spec 008의 취지가 그것이다 |
에이전트가 만든 카테시안 곱이 서버를 흔들기 전에 멈춘다. og_estimate() 의 dry-run과 짝을 이룬다 |
(A)에서 LIMIT 주입은 집계 질의의 의미를 바꿀 수 있으므로 수집 루프 중단이 안전하다. 중단 시 부분 결과를 줄지 오류를 낼지 결정이 필요하다 — 에이전트에게는 오류가 낫다(조용한 절단은 원칙 VIII 위반) |
| ARCH-10 | 확장 업그레이드 경로가 없다 | High | engine/ontological.control default_version = '0.1.0' · engine/Cargo.toml version = "0.1.0" · engine/sql/ 에 파일 2개(bootstrap.sql, access.sql)뿐 · find . -name "ontological--*.sql" → 0건 · 스키마 변경 수단은 engine/src/lib.rs:22-23 의 extension_sql_file!(… bootstrap) / (… finalize) 하나 |
ALTER EXTENSION ontological UPDATE 가 불가능하다. 지금은 버전이 하나뿐이라 문제가 드러나지 않지만, 첫 스키마 변경이 나가는 순간 기존 설치본은 (a) 확장 삭제 후 재생성(= 데이터 손실) 또는 (b) 손으로 SQL 적용밖에 길이 없다. 헌법 원칙 I이 "기존 테이블·트랜잭션·백업과 공존해야 한다"고 요구하는데, 마이그레이션 불가는 그 공존을 반쪽으로 만든다. ontological_version() 은 CARGO_PKG_VERSION 을 돌려주므로 바이너리 버전과 스키마 버전이 분리되어 있지 않다 (lib.rs:34-37) |
(1) 0.1.0 을 마지막 "재설치만" 버전으로 고정하고 0.2.0 부터 engine/sql/ontological--0.1.0--0.2.0.sql 규약을 도입한다(pgrx의 #[pg_extern] 스키마 생성과 손으로 쓴 마이그레이션의 결합 방식을 먼저 결정해야 한다 — pgrx schema 기능 조사 필요). (2) og_catalog.setting 에 schema_layout_version 을 두고 부트스트랩이 심게 한다. (3) 업그레이드 회귀 테스트: CREATE EXTENSION 이전버전 → 워크로드 → ALTER EXTENSION UPDATE → 재검증. 헌법 품질 게이트가 이미 CREATE EXTENSION → 워크로드 → pg_dump/restore → 재검증 사이클을 요구하므로 같은 하네스에 붙일 수 있다 |
운영 배포가 가능해진다. 지금은 "설치는 되지만 업그레이드는 안 되는" 상태이며 이것이 프로덕션 채택의 실질적 차단 요인이다 | pgrx의 확장 SQL 생성 방식과 손으로 쓴 마이그레이션 파일의 결합은 까다롭다. pgrx 0.19.2가 이를 어떻게 지원하는지는 미확인이며 조사가 선행되어야 한다. 잘못하면 두 경로가 서로 다른 스키마를 만든다 |
| ARCH-11 | 결과 스트리밍이 어디에도 없다 | Med | 엔진: engine/src/cypher/mod.rs:145-152 table.filter_map(…).collect() → Vec<Value>, mod.rs:108 SetOfIterator::new(rows.into_iter()…) · 도달성: engine/src/storage/traverse.rs:118-158 out: Vec<(i64,i32)> 를 다 모은 뒤 TableIterator::new(out) · Bolt: bolt/src/session.rs:291-322 · Studio: portal/server/index.js:182-217 |
네 계층 모두 전체 결과를 메모리에 물질화한다. SetOfIterator / TableIterator 는 이미 완성된 벡터를 순회할 뿐 진짜 SRF 스트리밍이 아니다. 깊은 순회 문서가 "pgGraph의 바닥은 순회가 아니라 행 출력"이라고 지적하는데(docs/deep-traversal.md), 같은 지적이 이 구현에도 적용된다 — 50,000 노드 도달성은 Vec<(i64,i32)> 800KB + jsonb 변환본이다 |
(1) og_cypher() 를 Spi::connect 밖으로 결과를 흘려보낼 수 없는 pgrx 제약이 있는지 먼저 확인한다(미확인). 가능하면 SetOfIterator 를 지연 이터레이터로 바꾼다. (2) 불가능하면 og.max_rows(ARCH-09)를 실제 상한으로 쓰고, 초과 시 명시적 오류를 낸다. (3) og_reach 는 레벨별로 산출할 수 있으므로 레벨 단위 청크 반환을 검토한다 |
큰 결과에서 백엔드/게이트웨이 RSS가 예측 가능해진다. OOM-kill로 postmaster가 전체를 재시작하는 최악 경로가 닫힌다 | pgrx의 SRF 수명 규칙과 SPI 커서 수명이 얽힌다. 잘못 구현하면 커서가 트랜잭션보다 오래 살아 크래시한다. (2)를 먼저 하는 것이 안전하다 |
| ARCH-12 | 문서-코드 드리프트 (WITH) |
Low | 코드: engine/src/cypher/compile.rs:372-408 compile_with() 구현됨 · 낡은 문서 ①: specs/003-cypher-query-engine/plan.md Phasing "P6 WITH 체이닝, UNION — 미착수" 및 Complexity Tracking "WITH 미지원" · 낡은 문서 ②: docs/architecture.md "WITH and UNION are not implemented." · 최신: README.md 상태표는 WITH 를 working으로 표기 |
같은 저장소가 같은 기능에 대해 세 가지를 말한다. LLM 에이전트가 plan.md 나 architecture.md 를 읽으면 WITH 를 쓰지 않으려 하거나 "지원되지 않는다"고 답한다. 그리고 그 문장이 UNION 과 묶여 있어서 진짜 문제인 ARCH-01이 이미 알려진 것처럼 보인다 |
(1) docs/architecture.md 의 해당 항목을 "UNION is not implemented — and it currently parses without error, which is worse; see ARCH-01" 로 수정. (2) specs/003-*/plan.md Phasing 표의 P6을 WITH ✅ / UNION 미착수 로 분리하고 Complexity Tracking의 "WITH 미지원" 항목을 제거(또는 "해소됨" 표시). (3) 상태 표기의 단일 진실 원천을 ../00_overview/05_spec_status.md 로 선언하고 나머지는 링크만 |
LLM과 신규 개발자가 같은 답을 얻는다. UNION 문제의 실제 심각도가 드러난다 |
plan.md 는 시점 기록의 성격도 있으므로 "완전 삭제"보다 "해소 표시"가 낫다. 어떤 문서가 권위인지 명확히 하지 않으면 드리프트가 재발한다 |
| ARCH-13 | shard 비트가 예약만 되고 죽어 있다 | Low | 인코딩 정의: engine/src/id.rs:1-29 · 유일한 할당자: engine/src/storage/mod.rs:33 id::make_id(0, type_id, local) — 하드코딩된 0 · 호출자 없는 헬퍼: engine/src/id.rs:62-67 with_shard() · 노출된 SQL 함수: id.rs:78-81 og_id_shard() — 항상 0 반환 · 매핑 경로도 0: interop/mod.rs:74-76 og_make_id(0, {tid}, …) · og_catalog.placement 테이블은 bootstrap.sql 에 없음(spec 007 plan.md가 설계로만 언급) |
63비트 중 9비트(약 14%)가 항상 0이다. 예약 자체는 옳은 결정이지만(식별자를 나중에 다시 쓰지 않겠다는 것, spec 007 FR-008), 현재 상태에서는 (a) og_id_shard() 가 공개 API인데 언제나 0을 돌려주어 오해를 부르고, (b) with_shard() 가 죽은 코드이며, (c) local_id 36비트 한계(타입당 687억)가 shard 비트를 회수하면 완화될 수 있다는 선택지가 문서화되어 있지 않다 |
(1) og_id_shard() 문서와 docs/api.md 에 "현재 항상 0을 반환한다 — spec 007 샤딩 미구현" 을 명시. (2) with_shard() 에 #[allow(dead_code)] 와 "spec 007 P1에서 사용 예정" 주석을 붙이거나, 샤딩 착수 전까지 제거. (3) og_check_integrity() 에 "shard != 0 인 식별자가 존재하는가" 검사를 추가 — 있다면 손상이거나 미래 데이터다. (4) ADR로 "shard 9비트를 지금 회수하지 않는 이유"를 99_decisions/ 에 남긴다 |
예약의 의도가 코드와 문서에 남는다. 나중에 샤딩을 붙일 때 "왜 0인가"를 다시 조사하지 않아도 된다 | 없음에 가깝다. 다만 (2)에서 코드를 지우면 spec 007 착수 시 다시 써야 한다 — 주석 유지를 권한다 |
| ARCH-14 | 동작하지 않는 설정 손잡이 3개 | Low | 심는 곳: engine/sql/bootstrap.sql:256-260 chunk_size, supernode_threshold, inference_max_depth, schema_version · chunk_size 소비자 부재: 실제 값은 Rust 상수 storage/adjacency.rs:15 CHUNK = 256, 보고조차 상수를 읽는다(storage/stats.rs:68,77) · supernode_threshold / inference_max_depth: grep -rn 결과 engine/src/ 에 소비자 0건 |
카탈로그에 있고 og_set_setting() 으로 바꿀 수 있는 설정 세 개가 아무 효과도 없다. 운영자는 chunk_size 를 512로 바꾸면 세그먼트가 커진다고 기대할 것이다. inference_max_depth 는 ARCH-16(추론 미구현)과 짝을 이루는 흔적이다 |
(1) 즉시: 세 키에 COMMENT 또는 og_catalog.setting 에 effective bool 컬럼을 추가해 "현재 효과 없음"을 드러낸다. (2) chunk_size 는 실제로 읽게 만들거나(세그먼트 크기를 그래프별로 조정 가능하게) 카탈로그에서 제거한다 — 읽게 만드는 쪽은 기존 세그먼트와의 혼재를 다뤄야 하므로 제거가 더 싸다. (3) supernode_threshold 는 og_degree_distribution() / og_reorganize() 의 판단 기준으로 실제 연결한다 |
설정 표면이 정직해진다. 운영 문서(08_operations/)가 거짓 손잡이를 소개하지 않게 된다 |
chunk_size 를 카탈로그에서 제거하면 이미 그 값을 읽는 외부 도구가 있을 수 있다(현재는 확인된 바 없음). og_graph_stats() 출력의 chunk_size 필드는 유지하는 편이 낫다 |
| ARCH-15 | 재라벨링이 전량 재계산 + 타입당 1 SQL | Med | 전량 재계산: engine/src/catalog/labeling.rs:117-170 — DELETE FROM og_catalog.type_label WHERE graph_id = $1 후 DFS 재배분 · 행별 INSERT: labeling.rs:160-167 루프 안에서 Spi::run_with_args("INSERT INTO og_catalog.type_label …") · 존재하지 않는 최적화 경로: labeling.rs:112-116 주석이 [insert_between] 을 "흔한 경우를 처리한다"고 말하지만 grep -rn "insert_between" engine/src/ → 주석 1건 외 0건 · DAG 로드도 2회 SPI 스캔(labeling.rs:32-71) |
(a) GAP = 1024 간격 설계의 이점(중간 삽입이 재번호를 피함)을 실현하는 코드가 없다 — 어떤 계층 변경이든 그래프 전체 재계산이다. (b) 타입 N개면 SQL N+1문장이다. 1,000타입 온톨로지의 타입 하나 추가가 1,001문장 + 전체 뷰 폐기(bump_schema_version)를 유발한다. (c) 주석이 존재하지 않는 함수를 가리켜 독자를 오도한다 |
(1) 즉시: 주석에서 insert_between 참조를 제거하거나 "계획됨"으로 표시. (2) 배치 INSERT: unnest() 로 한 문장에 전부 넣는다 — INSERT INTO og_catalog.type_label SELECT * FROM unnest($1::int4[], $2::int4[], …). 이것만으로 N+1 → 2문장이 된다. (3) insert_between 을 실제로 구현: 새 타입의 부모 구간 안에 빈 공간이 있으면 그 자리만 채우고 전량 재계산을 건너뛴다. 빈 공간이 없을 때만 fallback. (4) bump_schema_version 이 뷰를 전량 폐기하는 것도 함께 재고 — 영향받는 타입의 조상 뷰만 폐기하면 충분한지 검토 |
대형 온톨로지의 스키마 변경 지연시간이 크게 줄어든다. (2)만으로도 즉각적 효과가 있고 리스크가 거의 없다 | (3)은 구간 배분 불변식을 깨기 쉽다 — 다중 상속(경로당 라벨 행)과 사이클 검출을 함께 만족시켜야 한다. 회귀 테스트 없이 손대면 안 된다. (4)는 조상 관계를 정확히 계산해야 하고, 틀리면 낡은 뷰가 남아 ARCH-02보다 나쁜 상황이 된다 |
| ARCH-16 | og_catalog.rule 이 쓰기 전용, role 카디널리티 미강제 |
Med | rule INSERT: catalog/types.rs:675-676 (og_add_rule), adapters/rdf.rs:434-435 (OWL TransitiveProperty 등) · rule SELECT: grep -rn "og_catalog.rule" engine/src/ → INSERT 2건뿐, SELECT 0건 · 카디널리티 저장: catalog/types.rs:643-650, typeql/schema.rs:377-390 · 카디널리티 소비: agent/mod.rs:158 — og_schema() 표시용 하나뿐 · 짝이 되는 죽은 설정: inference_max_depth(ARCH-14) |
두 개의 "선언되지만 아무 일도 하지 않는" 스키마 요소. (a) 관계 특성 추론(spec 002 FR-027..FR-030, 헌법 원칙 IV의 제약 강제)이 카탈로그에 기록만 되고 질의에 반영되지 않는다. og_add_rule('kb','KNOWS','symmetric') 은 성공하지만 MATCH (a)-[:KNOWS]->(b) 가 역방향을 찾지 않는다. (b) card_min/card_max 는 og_schema() 를 통해 에이전트에게 제약으로 보고된다 — 에이전트는 그것을 강제되는 규칙으로 읽고 질의를 만든다. 헌법 원칙 VIII("스키마는 정확해야 한다")에 어긋난다 |
셋 중 하나를 고르고 문서화한다. (A) 강제/적용한다: symmetric/inverse 는 컴파일 타임에 dir 을 'b' 로 바꾸거나 관계 타입 배열을 확장하면 되므로 비교적 싸다. transitive 는 가변 길이 홉으로 확장 — inference_max_depth 가 여기 쓰인다. 카디널리티는 엣지 생성/삭제 시 검사. (B) 낮춘다: og_schema() 출력에서 강제되지 않는 필드를 "advisory": true 로 표시하고, og_add_rule() 이 "저장은 되지만 아직 적용되지 않는다"는 NOTICE 를 낸다. (C) 제거한다: 쓰지 않을 것이면 카탈로그에서 뺀다. 최소한 (B)를 즉시 하고 (A)를 spec 002 후속으로 계획할 것을 권한다 |
에이전트가 받는 스키마가 정직해진다 — 이것이 원칙 VIII의 핵심이다. (A)를 하면 TypeDB 대비 차별점(제약이 선언되고 강제된다)이 실제가 된다 | (A)의 transitive 는 질의 의미를 크게 바꾼다 — 기존 질의 결과가 달라지므로 그래프별 옵트인이어야 한다. 카디널리티 강제는 기존 데이터가 이미 위반하고 있을 수 있어 마이그레이션 경로가 필요하다(ARCH-10과 얽힌다) |
| ARCH-17 | 오류 처리 전략 불일치 | Med | 실측 분포: error!( 115, .expect( 111, .unwrap() 95 (grep -rho … engine/src --include=*.rs | wc -l) · 파일별 편중: catalog/types.rs = .expect( 31 / error!( 18, storage/stats.rs = .expect( 10 / .unwrap() 10 / error!( 0 · 이중 unwrap: catalog/labeling.rs:207,227 r.get::<i32>(1).unwrap().unwrap() · 조용히 삼키는 곳: storage/mod.rs:138-151 확장 DDL let _ =, storage/mod.rs:379 인접 삭제 .ok(), agent/mod.rs:427-438 한도 SET .ok(), cypher/mod.rs:134 감사 .ok() · 헌법 요구: 기술 제약 "elog/ereport 규약을 따른다", 원칙 VIII "오류는 결정적이고 교정 가능해야" |
세 가지 실패 방식이 뒤섞여 있고 규칙이 문서화되어 있지 않다. 사용자가 보는 결과가 (a) 의도된 교정 가능 메시지, (b) .expect 문자열, (c) 정보 없는 unwrap panic 셋 중 무엇일지 코드를 읽어야 안다. 더 심각한 것은 정합성에 직결되는 실패를 조용히 삼키는 네 지점이다 — 특히 확장(widening) DDL 실패는 og_catalog.property.data_type 만 text 로 바뀌고 실제 컬럼은 안 바뀌는 상태를 만들 수 있다 |
(1) 규칙을 정하고 CLAUDE.md 또는 03_backend/ 에 명문화한다: 사용자 입력·환경에 기인한 실패 → error! (교정 제안 포함) / "여기 도달하면 우리 버그" 인 불변식 → .expect("불변식 서술") / .unwrap() 금지(정당한 곳은 .expect 로 사유를 쓴다). (2) 조용히 삼키는 4곳을 재검토: 확장 DDL과 인접 삭제는 실패 시 error! 로 올려 트랜잭션을 되돌리는 것이 옳다. 한도 SET 실패는 og_apply_role 반환에 실패 목록을 담는다. 감사 실패만 .ok() 를 유지하되 pgrx::warning! 을 남긴다. (3) clippy::unwrap_used 를 CI 경고로 켜서 증가를 막는다 |
오류가 예측 가능해지고, 에이전트가 오류로부터 교정할 수 있는 비율이 올라간다(원칙 VIII의 측정 가능한 목표). 조용한 정합성 손상 경로가 닫힌다 | 조용히 삼키던 것을 오류로 올리면 지금까지 통과하던 워크로드가 실패할 수 있다 — 특히 확장 DDL은 뷰 의존 때문에 실패하기 쉬운 자리다. 단계적으로: 먼저 warning! 으로 관측하고, 발생 빈도를 확인한 뒤 error! 로 승격 |
| ARCH-18 | Studio에 인증 계층이 없고 임의 SQL 엔드포인트가 있다 | High | 고정 역할 풀: portal/server/index.js:21-29 new Pool({ user: process.env.PGUSER || 'dev', … max: 8 }) · 인증 코드 부재: index.js:344-366 요청 핸들러에 신원 확인 없음 · 임의 SQL: index.js:296-308 await pool.query(sql) · 평문: http.createServer · 대비: Bolt는 HELLO 자격증명으로 PG에 접속해 역할별 권한/RLS/감사를 유지한다(bolt/src/session.rs:168-184) |
네 진입면 중 Studio만 "인증은 PostgreSQL 역할 하나뿐"이라는 불변식을 지키지 않는다. 결과: (a) 모든 요청이 한 역할로 실행되어 RLS가 요청자별로 적용되지 않고, (b) og_audit.principal 이 그 고정 역할로 기록되어 감사가 의미를 잃고, (c) POST /api/sql 이 그 역할의 모든 권한을 HTTP로 노출한다. 서버 자체의 설계 의도("얇게. 이 서버가 할 수 있는 것은 psql 세션도 할 수 있다")는 옳지만, 그 논리는 고정 역할이 최소 권한일 때만 성립한다 |
(1) 즉시: 기본 바인드를 127.0.0.1 로 강제하고, 비-로컬 바인드 시 기동을 거부하거나 인증 설정을 요구한다. README.md 의 Studio 절과 08_operations/ 에 "네트워크 노출 금지"를 명시. (2) 요청별 역할: Authorization 헤더의 자격증명으로 연결을 만드는 방식(Bolt와 동일한 모델)으로 바꾸면 RLS·감사·권한이 자동으로 정합해진다. 풀은 역할별로 갖는다. (3) POST /api/sql 을 기본 비활성(OG_STUDIO_ALLOW_SQL=1 필요)으로. (4) PGUSER 기본값 'dev' 를 제거하고 명시 요구 |
진입면 공통 불변식(06_protocol_surfaces.md)이 네 곳 모두에서 성립한다. Studio가 데모 이상으로 배포 가능해진다 |
(2)는 Studio의 UX를 바꾼다(로그인 화면 필요). (3)은 SQL 탭이 이 프로젝트의 핵심 데모 기능("컴파일된 SQL을 보고 그대로 실행")이므로 로컬에서는 기본 활성을 유지해야 한다. 이 항목의 최종 설계는 07_security/ 담당과 합의할 것 |
우선순위 제안
즉시 (조용한 오답 / 배포 차단)
| 순위 | ID | 이유 | 작업 크기 |
|---|---|---|---|
| 1 | ARCH-01 | 조용한 오답. 한 줄로 막을 수 있다 | 매우 작음 |
| 2 | ARCH-02 | 조용한 오답 또는 예기치 않은 실패. 캐시 키에 버전 추가 | 작음 |
| 3 | ARCH-18 | 배포 시 즉시 위험. 바인드 제한 + SQL 엔드포인트 게이트 | 작음 |
| 4 | ARCH-09 | 에이전트에게 거짓 보장. 최소한 (B) 명시화 | 작음 |
| 5 | ARCH-16 | 에이전트에게 거짓 스키마. 최소한 (B) 명시화 | 작음 |
다음 (프로덕션 전제)
| 순위 | ID | 이유 |
|---|---|---|
| 6 | ARCH-10 | 업그레이드 경로 없이는 운영 배포가 불가능하다 |
| 7 | ARCH-03 | spec 007 P0(읽기 복제)의 실현 여부가 여기 달려 있다 |
| 8 | ARCH-08 | 불변식을 DB로 내리는 값싼 부분부터 |
| 9 | ARCH-17 | 조용히 삼키는 4곳부터 warning! 으로 관측 |
| 10 | ARCH-11 | 최소한 상한(ARCH-09와 함께) |
구조 개선 (설계 여유가 있을 때)
ARCH-04(파일 분할) → ARCH-06(레지스트리 뒤집기) → ARCH-05(공용 IR) 순서. 셋은 같은 방향의 작업이므로 이 순서로 하면 각 단계가 다음 단계를 쉽게 만든다.
ARCH-15(재라벨링)는 (2) 배치 INSERT만 먼저 해도 효과가 크고 리스크가 없다.
정리 (낮은 비용, 낮은 위험)
ARCH-12(문서 드리프트), ARCH-13(shard 비트), ARCH-14(죽은 설정) — 문서와 주석 작업이다.
이 목록에 없는 것 (범위 밖)
다음은 아키텍처가 아니라 다른 카테고리의 소관이다:
- Cypher 표면이 raw storage 대비 3홉에서 7.8배 비싼 것 (jsonb 프로젝션 + SPI 왕복) →
PERF- genai.token이og_catalog.setting에 평문 저장되는 것 →SEC-og_enable_rls의policy_expr보간 →SEC-- Bolt / PG 구간 평문 →
SEC-(ARCH-07 ③에서 아키텍처 측면만 다뤘다) web/index.html1,916줄 단일 파일 →FE-bench/harness.py1,275줄 →OPS-- openCypher TCK 통과율 게이트의 위치 (미확인) →
OPS- - SHACL 지원 여부 (미확인) →
DATA-
Forbidden / Required
Forbidden
- ❌ 이 문서의 항목을 근거 없이 "해결됨"으로 표시하는 것. 커밋 해시와 함께 표시할 것.
- ❌ ARCH-01 (2), ARCH-15 (3), ARCH-16 (A) 를 회귀 테스트 없이 착수하는 것 — 세 가지 모두 답이 바뀌는 변경이다.
- ❌ 여러 ARCH 항목을 한 PR에 묶는 것. 각각 되돌릴 수 있어야 한다.
Required
- ✅ 각 항목을 착수할 때
99_decisions/에 ADR을 남길 것 (배경 / 고려한 대안 / 선택 / 재평가 조건). - ✅ 항목이 해소되면 이 표에서 지우지 말고 "해소됨 (커밋
xxxxxxx)" 로 표시할 것 — 왜 그렇게 되어 있었는지가 다음 사람에게 필요하다. - ✅ 새 개선 포인트를 추가하면 반드시
파일:라인근거를 붙일 것. 일반론 금지. - ✅ 심각도를 바꾸면 그 이유를 함께 적을 것.