GS리테일 AX 블로그

AI와 데이터로 고객의 생활 가치를 혁신하는 GS리테일 AX 이야기

Data&AI

[사내 지식 AI 만들기 ②·권한] 지식을 한곳에 모았더니, 아무나 다 볼 수 있게 됐다 — payload 필터로 RBAC를 다시 세운 이유

DarionKim 2026. 7. 2. 11:07

통합의 청구서

1탄에서 우리는 KB마다 따로 있던 컬렉션을 하나로 합쳤다. 컬렉션 폭증은 사라졌고, 끝없이 늘어나는 지식에 드디어 대응이 됐다. 그런데 합쳐진 건 컬렉션만이 아니었다 — 격리도 같이 합쳐졌다.

per-KB 컬렉션 시절엔 격리가 _공짜_였다. 인사 KB는 인사 컬렉션에만 있으니, 다른 컬렉션을 안 보면 그만이었다. 물리적인 벽. 그런데 모든 KB가 한 컬렉션에 들어오자 — 그 벽이 사라졌다. 아무 필터 없이 검색하면 모두가 모든 지식을 본다. 인사 평가도, 다른 부문 기밀도.

통합의 청구서가 날아온 것이다: "이제 누가, 무엇을, 볼 수 있게 할 것인가?"

사실 이 질문은 우리가 먼저 던진 게 아니었다. 엔터프라이즈 AI를 도입하려는 조직이 가장 먼저 묻는 질문이 항상 이거였다. "우리 회사 전체 지식을 하나의 AI에 모으면, 신입사원이 임원 보고서를 검색해서 읽을 수 있는 거 아닌가?" — 보안·권한 관리는 옵션이 아니라 엔터프라이즈 AI가 팔리기 위한 전제조건이었다.

물리 벽 대신, 출입증

선진사례(Qdrant multitenancy, Azure의 security trimming)의 답은 같았다. 물리 분리 대신 payload 필터. 모든 청크에 "출입증" 메타데이터를 박고, 질의 시 그 사용자가 통과할 수 있는 것만 본다.

청크 payload
{ tenant_id, kb_id, kb_tier, acl, ... }   + tenant_index (is_tenant=true)

4-tier 권한 모델:

Tier 접근
GLOBAL 전사 공통 (인프라·전사 지식)
BU 조직(부문) 일치
TEAM 부서 일치 또는 명시적 공유
PERSONAL 개인 소유 — 본인만 접근, 본인이 직접 지식을 만들고 관리

기존 조직 위계(GLOBAL→BU→TEAM)만으로는 부족했다. 개인도 자기 지식을 직접 관리할 수 있어야 했다 — 회사 조직도에 없는, 그 사람만의 메모·노트·업무 자료. 그래서 tier 하나를 더 열었다.

질문 → 사용자 신원(tenant/부서/role/tier) 해석
     → payload 필터 자동 주입
     → 통합 컬렉션 검색 (접근 가능한 KB만 매칭)
     → 결과

Qdrant는 이 4-tier 자체를 코드로 하드코딩하지 않는다. payload 스키마가 YAML 설정이라, tier 종류를 늘리거나 조직의 RBAC 정책이 바뀌어도 컬렉션 구조를 새로 짜지 않고 설정만 반영하면 됐다 — 이게 개인 tier를 추가로 여는 결정이 부담 없었던 이유다.

권한 스택:

신원 확인   : Azure AD(Teams 싱글사인온) / OIDC(로그인 표준 프로토콜)
조직 동기화 : HR 시스템 ↔ 조직 디렉토리 sync
정책 엔진   : Casbin (RBAC/ABAC 정책 평가)
격리 실행   : Qdrant payload 필터 + tenant index
위반 감지   : 무필터 검색 차단 + 관측 알림(Datadog)

풀어 쓰면: 사용자가 Teams에 로그인한 세션(싱글사인온, SSO)을 그대로 넘겨받아 "이 사람이 누구인지"를 확인하고, 그 신원에 조직 위계에 따른 권한(역할 기반 접근제어, RBAC)과 민감도 등급에 따른 권한(속성 기반 접근제어, ABAC)을 함께 적용한다.

"kb_id는 장식이 아니다"

가장 중요한 깨달음 한 줄:

공유 컬렉션에서 kb_id는 cosmetic 메타필드가 아니라 _접근 경계_다.

per-KB 시절 kb_id는 그냥 라벨이었다. 통합 컬렉션에선 그게 이다. 그래서 우리는 이 계약을 검색 엔진이 소유하게 했다 — 호출자가 매번 필터를 깜빡하지 않도록, 엔진이 항상 강제한다.

그리고 fail-closed: 혹시라도 필터 없이 공유 컬렉션을 검색하려는 코드가 있으면? 거부하고 경보한다. "권한을 깜빡한 검색"은 버그가 아니라 보안 사고다. 그래서 무필터 공유-컬렉션 검색은 차단이 기본값이다.

출입증은 한 번 발급으로 끝나지 않는다

출입증에는 또 다른 함정이 있다. 발급하고 끝이 아니라는 것. 사람은 입사하고, 부서를 옮기고, 퇴사한다. 출입증이 그때그때 갱신되지 않으면 — 옮긴 부서의 문은 못 열고, 떠난 부서의 문은 여전히 열리는 유령 출입증이 남는다.

그래서 접근제어는 권한 로직 하나로 끝나는 일이 아니었다. 인사 시스템의 조직 정보와 지속적으로 동기화돼야 했다 — 누가 어느 조직에 속했는지가 바뀔 때마다, 그 사람의 출입증(payload 필터가 참조하는 신원)도 같이 갱신된다. 권한은 한 번 세우는 구조가 아니라, 계속 맞춰 나가야 하는 흐름이었다.

한 단계 더 — tier ABAC

같은 KB 안에서도 민감도(tier)가 다른 청크가 있다. 그래서 사용자 tier에 맞는 것만 통과시키는 속성 기반 접근제어(ABAC)를 공유 검색 경로에 얹었다. kb_id로 _어느 KB_를, tier로 _그 안의 어느 등급_까지를 거른다.

실전에서 — 4단계 권한이 3단계까지만 살아있었던 이야기

우리 시스템은 GLOBAL → BU → TEAM → PERSONAL, 4단계로 권한을 나눴다. 그런데 실제로 붙여보니 GLOBAL과 BU까지만 제대로 동작했다. TEAM과 PERSONAL은 설계는 있었지만, 코드는 "항상 빈 목록"을 돌려주는 껍데기였다 — 자기 팀 KB조차 누구도 볼 수 없는 상태로 방치돼 있었던 것.

원인을 파고들자 진짜 문제가 나왔다. 부서 매칭을 이름으로 하고 있었다. "인사팀"이라는 문자열이 인사 시스템 쪽과 우리 쪽에서 한 글자라도 다르면 — 매칭 실패, 그 사람은 자기 부서 KB를 못 본다. 조직개편 한 번이면 전체가 깨질 수 있는 구조였다.

고친 방법은 이름 대신 안정적인 코드를 쓰는 것이었다. 인사 시스템이 주는 고유 조직 코드를 사람의 신원 토큰에 실어 나르고, 그 코드로만 매칭한다. 이름은 바뀌어도 코드는 안 바뀐다. 그리고 그 코드를 매 HR 동기화마다 다시 써준다 — "출입증은 한 번 발급으로 끝나지 않는다"는 원칙을 코드로 만든 것.

심화 — 권한을 "검색 엔진이 소유"하게 만든 코드

이 절은 깊다. payload 필터가 실제로 어떻게 강제되는지 코드까지 궁금한 분을 위한 detail이다. 바쁘면 맺음으로 건너뛰어도 좋다.

① fail-closed는 문장이 아니라 코드다

앞에서 "무필터 검색은 차단이 기본값"이라 했다. 그 문장의 실체는 이렇다 — 공유 컬렉션을 kb_id 필터 없이 검색하려 하면, 검색 엔진이 거부하고 빈 결과를 돌려주며 경보를 쏜다. 에러로 죽이지도, 조용히 전체를 훑지도 않는다.

# 공유 프로파일 컬렉션은 여러 KB의 청크를 kb_id로 나눠 담는다.
# kb_id 필터 없이 조회 = KB 경계를 넘는 스캔(교차-KB 유출) → 거부.
if self._is_shared_profile_collection(collection_name, cfg) \
        and not self._filter_has_kb_id(filter_conditions):
    logger.error("공유 컬렉션 무필터 검색 거부 — kb_id 필터 없음: %s", collection_name)
    emit_silent_fail(area="kb_search", signal="profile_collection_unfiltered",
                     reason="no_kb_id_filter", collection=collection_name)
    return []   # ← raise가 아니라 "빈 결과 + 카운터"

이 계약을 검색 엔진이 소유한다. 동반 함수의 docstring이 이 글 본문 문장을 거의 그대로 적고 있다 — "kb_id는 cosmetic payload 필드가 아니라 접근 경계다. 검색 엔진이 이 계약을 소유하므로, 호출자가 매 진입점마다 같은 필터를 기억할 필요가 없다." 본문의 "검색 엔진이 소유"는 마케팅 문구가 아니라 실제 코드 주석의 의역이다.

② 영리한 선택 — 엔진을 새로 들이지 않고, 있는 걸 확장

조직 위계(org-tree) + 상속 + 민감도(tier) 속성을 한 번에 평가하는 이 문제엔 업계 정석이 여럿 있다. 우리가 비교한 4가지 — SharePoint식 site→library 상속, Confluence식 space 격리, Google Drive OU+공유드라이브, 그리고 Casbin/OpenFGA식 ReBAC 튜플. 개념적으로 가장 잘 맞은 건 ReBAC 튜플 shape(상속 있는 org-tree)였다.

그런데 우리는 그걸 위해 새 엔진(OpenFGA)을 들이지 않고, 이미 쓰던 Casbin 하나 안에서 표현했다 — Casbin의 role 계층(g)과 org-tree 그룹핑(g2)이 같은 shape를 추가 인프라 0으로 커버했기 때문. "새 문제 = 새 엔진"이 아니라 "있는 엔진의 표현력부터 확인"이 먼저였다. (model.conf엔 정밀도 선택도 박혀 있다 — keyMatch2 대신 keyMatch: keyMatch2:를 경로 파라미터로 취급해 mcp:adapter:tool 같은 식별자를 너무 넓게 매칭시키기 때문.)

③ 숫자, 정직하게 — 아직 없다

이 권한 엔진의 실측 성능 숫자(enforce() p99 latency 등)는 아직 없다. 설계 목표로 "org-tree 깊이 ≤5 → 정책 평가 <5ms p99"를 잡아뒀지만 그건 target이지 measured가 아니다. 여기서도 시리즈 규율을 지킨다 — 없는 숫자를 지어내지 않는다.

④ 우리가 처음엔 틀렸다 — 같은 기능, 두 경로가 조용히 갈라지다

본문의 "이름 매칭 → 조직 코드 매칭"은 신원 데이터가 시간이 지나며 낡는 이야기였다. 결이 다른 두 번째 사고가 하나 더 있다.

tier-ABAC(민감도 필터)가 같은 공유 컬렉션을 치는 두 경로 사이에서 비대칭이었다. 주(主) 검색 경로는 tier 필터를 제대로 걸었는데, 나중에 붙은 "보조" 경로는 안 걸었다. 요청이 어쩌다 보조 경로로 흐르면, 자기 tier보다 높은 청크가 조용히 보일 수 있었다 — 에러도 알림도 없이.

이걸 잡은 건 사용자 신고가 아니라 일부러 "두 경로가 대칭인가"를 감사한 것이었다("당연히 같겠지" 하고 안 보던 경로였으니까). 고친 방식이 흥미롭다 — 한 방에 안 고치고 3단계로: (1) 우선 무필터 스캔을 막는 fail-closed 가드(①)를 안전망으로 즉시 투입 → (2) 빠진 tier 필터를 보조 경로에 추가하되 기본 off인 feature flag 뒤에 둬서 격리 검증 → (3) 그다음에야 검색 실행기 전체를 tier-aware로 확장.

재발을 눈에 보이게 하려고 카운터 3개를 심었다: "무필터로 공유 컬렉션 도달"(항상 0이어야) · "tier 필터 빌더가 실패해 no-tier로 fallback"(항상 0이어야) · "tier 필터가 실제 적용됨"(이게 0인데 flag는 켜져 있으면 = 코드가 새 경로를 안 타는 silent no-op cutover 감지).

교훈: 신원 데이터가 낡는 것(본문)과, 같은 기능을 구현한 두 코드 경로가 조용히 갈라지는 것은 다른 종류의 사고다. 후자는 실패 신호를 기다리면 안 보이고, "대칭인지 일부러 확인"해야만 드러난다.

맺음 — 그리고 새로운 문제

출입증 구조(payload 필터 + fail-closed + tier ABAC)는 섰다. 통합 컬렉션의 효율을 가지면서도, 권한을 다시 세웠다.

그런데 1탄에서 배운 게 하나 있다. 구조가 섰다고 문제가 끝난 게 아니다. "코드에 출입증 검사가 있다"와 "모든 경로가 실제로 그 검사를 통과하는가"는 다른 질문이다. 새 경로가 하나 늘 때마다, 그 경로도 출입증을 확인하는지 계속 확인해야 한다 — 이것도 결국 저장 구조와 똑같이, _운영하면서 증명해야 하는 일_이다.

그리고 — 사용자는 여전히 한 줄로 묻지 않았다. "이 질문은 _어느 KB_를 봐야 하지?"가 남았다. 출입증은 "못 들어가는 문"만 막아줄 뿐, "이 사람이 평소 자주 드나드는 문이 어딘지"는 알려주지 않는다. 그걸 사람이 골라주던 시절의 한계, 그리고 그걸 에이전트에게 넘긴 이야기는 3탄-1부 — 사용자는 한 줄로 묻지 않았다에서.


시리즈: 1탄 저장 · 2탄 권한 (이 글) · 3탄-1부 대화 · 3탄-2부 대화 · 4탄 채널 · 번외 수집

김헌기 Darion · AX본부 > AI데이터부문 > AI혁신지원팀

AI 및 공통 Tech 기반의 기술 표준화 업무를 수행하고 있습니다. 동료들과 함께 기술 문화 만들기와 낯선 기술자와의 인연의 시작에 관심이 많습니다.