권한 기반 RAG 접근 제어 - 사용자마다 다른 문서만 검색되게 하기
지난 글에서 멀티턴 대화형 RAG를 구현하면서, 다음 주제로 권한에 따라 검색 대상을 제한하는 접근 제어를 예고했습니다. 실제 사내 챗봇이나 멀티테넌트 서비스에서는 모든 사용자가 모든 문서에 접근해서는 안 되는 경우가 많습니다. 이번 글에서는 RAG 검색 단계에 권한 필터링을 적용하는 방법을 정리합니다.
1. 왜 RAG에서 접근 제어가 특히 중요한가
일반적인 API는 데이터를 조회할 때 WHERE 절 하나로 권한을 걸면 되지만, RAG는 벡터 검색을 거쳐 문서 내용이 그대로 LLM 프롬프트에 들어가고, 그 내용이 답변에 노출됩니다. 권한 필터링을 빠뜨리면 검색 결과뿐 아니라 최종 답변에까지 남의 정보가 그대로 새어나갈 수 있습니다.
[권한 필터링이 없는 경우]
회장님이 소속된 A팀 직원이 질문
→ 벡터 검색이 B팀의 기밀 문서까지 유사도만 보고 검색
→ LLM이 그 내용을 근거로 답변에 포함시켜버림
특히 지금까지 다룬 멀티테넌트 CLM 플랫폼처럼 여러 조직이 하나의 시스템을 공유하는 구조라면, 검색 단계에서부터 테넌트/부서/직급 단위로 접근 가능한 문서만 걸러지도록 설계하는 것이 필수입니다.
2. 문서에 메타데이터로 권한 정보 저장하기
가장 먼저, 문서를 적재하는 시점에 어떤 조직·부서·권한 레벨이 이 문서에 접근할 수 있는지를 메타데이터로 함께 저장해야 합니다.
@Service
@RequiredArgsConstructor
public class DocumentIngestService {
private final VectorStore vectorStore;
public void ingest(String content, String source, Long tenantId, Set<String> allowedRoles) {
TextSplitter splitter = new TokenTextSplitter(500, 350, 5, 10000, true);
Map<String, Object> metadata = Map.of(
"source", source,
"tenant_id", tenantId,
"allowed_roles", String.join(",", allowedRoles)
);
Document document = new Document(content, metadata);
List<Document> chunks = splitter.apply(List.of(document));
vectorStore.add(chunks);
}
}
청크 단위로 분할되더라도, 원본 문서의 메타데이터(테넌트 ID, 접근 가능 역할)는 각 청크에 그대로 상속되도록 처리합니다.
3. 검색 시점에 필터를 함께 걸기
검색할 때는 유사도만으로 정렬하지 않고, 현재 요청자의 테넌트·권한과 일치하는 문서로 후보를 먼저 좁힌 뒤 유사도 검색을 수행합니다.
SELECT id, content,
embedding <=> :query_vector AS distance
FROM documents
WHERE tenant_id = :tenant_id
AND (allowed_roles = '' OR string_to_array(allowed_roles, ',') && :user_roles)
ORDER BY embedding <=> :query_vector
LIMIT 5;
WHERE 절로 테넌트와 역할 조건을 먼저 걸어서 후보군 자체를 줄인 다음 벡터 유사도로 정렬합니다. 이렇게 하면 애초에 권한 밖의 문서는 검색 후보에 오르지도 못합니다.
@Repository
@RequiredArgsConstructor
public class SecureVectorSearchRepository {
private final JdbcTemplate jdbcTemplate;
public List<SearchResult> search(float[] queryVector, Long tenantId,
List<String> userRoles, int topK) {
String sql = """
SELECT id, content, embedding <=> ?::vector AS distance
FROM documents
WHERE tenant_id = ?
AND (allowed_roles = '' OR string_to_array(allowed_roles, ',') && ?::text[])
ORDER BY embedding <=> ?::vector
LIMIT ?
""";
return jdbcTemplate.query(sql,
new Object[]{new PGvector(queryVector), tenantId,
userRoles.toArray(new String[0]), new PGvector(queryVector), topK},
(rs, rowNum) -> new SearchResult(
rs.getLong("id"), rs.getString("content"), rs.getDouble("distance")));
}
}
4. 서비스 레이어에서 현재 사용자 컨텍스트 주입하기
컨트롤러나 서비스 단에서 현재 로그인한 사용자의 테넌트와 역할 정보를 꺼내, 검색 호출 시 항상 함께 넘기도록 강제합니다.
@Service
@RequiredArgsConstructor
public class RagChatService {
private final SecureVectorSearchRepository vectorSearchRepository;
private final ChatClient chatClient;
public String answer(String question) {
AuthenticatedUser currentUser = SecurityContextHolderUtil.getCurrentUser();
List<SearchResult> results = vectorSearchRepository.search(
embed(question), currentUser.getTenantId(), currentUser.getRoles(), 5);
if (results.isEmpty()) {
return "관련된 정보를 찾을 수 없습니다.";
}
String context = results.stream()
.map(SearchResult::content)
.collect(Collectors.joining("\n\n"));
String promptText = "다음 문서를 참고해서 답변해줘.\n[문서]\n%s\n[질문]\n%s"
.formatted(context, question);
return chatClient.prompt().user(promptText).call().content();
}
}
핵심은 search 메서드에 테넌트·역할 정보가 없는 오버로드 자체를 아예 만들지 않는 것입니다. 실수로 필터 없는 검색이 호출될 여지를 코드 구조상 차단해두는 것이 안전합니다.
5. 애플리케이션 레벨 필터링이 위험한 이유
"검색은 넓게 하고, 결과를 받은 뒤 애플리케이션 코드에서 권한 없는 문서를 걸러내면 되지 않을까"라는 접근은 피해야 합니다.
[위험한 방식]
벡터 검색으로 Top 5 조회 (권한 무관하게 유사도만 기준)
→ 애플리케이션에서 권한 없는 문서를 사후에 제거
→ 결과: 5개 중 3개가 걸러지면 최종 2개만 남아 검색 품질 저하
→ 게다가 그 사이 로그나 캐시에 필터링 전 데이터가 남을 위험
DB 쿼리 단계에서부터 필터링해야, 애초에 권한 밖 문서가 애플리케이션 메모리에 올라오는 순간 자체가 없어집니다. 앞서 다룬 SQL의 WHERE 절 필터링이 반드시 DB 레이어에서 처리돼야 하는 이유입니다.
6. 권한 변경 시 재색인이 필요한 경우
부서 이동이나 조직 개편으로 문서의 접근 권한 자체가 바뀌는 경우도 있습니다. 이때는 문서를 재임베딩할 필요 없이, 메타데이터만 업데이트하면 됩니다.
UPDATE documents
SET allowed_roles = 'TEAM_A,TEAM_B'
WHERE source = 'company_policy_v2.pdf';
벡터 자체는 문서 내용이 바뀌지 않는 한 그대로 유효하기 때문에, 권한 메타데이터만 갱신하는 이 작업은 비용이 거의 들지 않습니다. 지난 글에서 다룬 "임베딩 모델 교체 시 전체 재임베딩이 필요하다"는 상황과는 성격이 다른 문제입니다.
7. 접근 제어 적용 전후 비교
| 항목 | 필터링 없음 | DB 레벨 필터링 적용 |
|---|---|---|
| 권한 밖 문서 노출 위험 | 높음 | 검색 후보에서부터 원천 차단 |
| 검색 품질 | 무관한 문서 혼입 가능 | Top-K가 모두 권한 내 문서로 채워짐 |
| 성능 | 전체 문서 대상 검색 | WHERE 절로 후보군 축소, 오히려 빨라짐 |
| 구현 위치 | 없음 | SQL/쿼리 레이어에서 강제 |
8. 정리
- RAG는 검색된 문서 내용이 그대로 답변에 노출되기 때문에, 권한 필터링이 빠지면 정보 유출로 직결됩니다.
- 문서 적재 시점에 테넌트·역할 메타데이터를 함께 저장하고, 검색 쿼리의
WHERE절에서부터 필터링해야 안전합니다. - 애플리케이션 레벨에서 사후 필터링하는 방식은 성능과 보안 양쪽에서 불리하므로, DB 쿼리 레벨에서 처리하는 것이 원칙입니다.
지금까지 이론, 실습, 검색 개선, 평가, Spring Boot 연동, 트래픽 대응, 모델 마이그레이션, Hybrid Search, 멀티턴 대화, 접근 제어까지 RAG 시리즈를 이어왔습니다. 다음 글에서는 지금까지 다룬 여러 요소들이 실제로 함께 잘 동작하는지, RAG 시스템 전체를 대상으로 한 부하 테스트와 모니터링을 다뤄보겠습니다.
'RAG를 활용한 LLM 서비스 구축' 카테고리의 다른 글
| STEP 20 - GraphRAG (0) | 2026.07.15 |
|---|---|
| STEP 19 - RAG 시스템 부하 테스트와 모니터링 (0) | 2026.07.15 |
| STEP 17 - 대화형 RAG 챗봇 구현하기 (0) | 2026.07.14 |
| STEP 16 - Hybrid Search (0) | 2026.07.13 |
| STEP 15 - 임베딩 모델 교체 시 벡터 데이터 마이그레이션하기 (0) | 2026.07.13 |