Skip to content

ProcessCenter 전역 통합 검색 API 설계안 ​

구현 현황(2026-10-05) — 진입점은 상단바 검색창 하나로 통합됐다.

  • 입력 중 결과: 이 포털 메뉴·프로세스(로컬) + explore.search(서버 어댑터) + explore.gatewaySearch(ExploreGateway 연합 — 카탈로그의 모든 포털, 문서 포함. useDeferredValue 로 호출)
  • Enter(질문): explore.gatewayAsk → ExploreGateway answer 능력 → 중앙 RagService(포털은 답을 만들지 않는다)
  • 별도 화면 진입은 제거: /explore(ExplorePage)·/rag(RagPage+AIChatBox) 화면과 시스템 예약 메뉴 항목이 사라지고, 옛 주소는 /processes 로 되돌린다. trpc.rag.* 서버 계약은 폴백 자산으로 유지한다(docs/DB_STRATEGY.md P3).
  • 연합 검색이 일부 포털에서 실패하면 화면이 빠진 포털을 밝힌다(전체 결과로 오해 금지).

1. 설계 목표 ​

ProcessCenter의 우측 상단 공통 검색창을 일반 사용자와 관리자 모두에게 동일하게 제공한다. 1단계에서는 기존 ProcessCenter 프로세스 내부 검색을 사용하고, 이후 PortalConsole에 등록된 포탈 중 사용자가 접근 가능한 포탈의 기록과 데이터를 통합 검색한다. 최종 단계에서는 RagService가 웹 에디터 본문과 FileStore 첨부파일을 포함한 모든 권한 허용 데이터를 검색하고, 답변과 근거를 제공한다.

검색 UI는 단계가 바뀌어도 변경하지 않는다. 프론트엔드는 하나의 SearchRequest와 SearchResponse 계약만 사용하고, 백엔드는 mode에 따라 내부 검색·통합 검색·RAG 답변을 확장한다.

가장 중요한 보안 원칙은 검색 결과를 모은 다음 권한을 검사하는 것이 아니라, 각 데이터 원천이 권한 필터를 적용한 결과만 반환하도록 하는 것이다. RAG 생성 모델에는 이미 권한 필터가 끝난 chunk만 전달해야 한다.

2. 현재 구현과 전환 지점 ​

현재 ProcessCenter 일반 화면은 trpc.process.list에 { query }를 전달하여 프로세스명·코드·설명을 검색한다. 현재 RAG 라우터에는 rag.search와 rag.ask가 있으며, 사용자 조직·보안 수준을 서버의 principal로 구성한다. 공통 PortalFrame은 searchValue, onSearchChange, onSearchSubmit, searchPlaceholder를 지원한다.

현재 요소현재 역할통합 검색 전환 방향
PortalFrame 검색창입력·Enter 제출동일 계약 유지
trpc.process.listProcessCenter 내부 검색search.query 내부 모드의 데이터 공급자로 유지
rag.search질문 기반 RAG 검색권한 필터가 적용된 통합 검색 결과로 확장
rag.ask질문에 대한 답변 생성검색 결과의 citation을 포함한 최종 답변 API로 유지
PortalConsole포탈 등록 및 중앙 인증·권한 원천허용 포탈 목록과 사용자 위임 권한 조회
FileStore첨부파일 원본·메타데이터 관리파일 텍스트 chunk의 원천 및 원문 링크 검증

3. 권장 API 경계 ​

외부 포탈 UI가 직접 여러 포탈을 호출하지 않도록 한다. 프론트엔드는 ProcessCenter의 검색 API 하나만 호출하고, ProcessCenter 검색 오케스트레이터가 PortalConsole, 각 포탈 검색 커넥터, FileStore, RagService를 호출한다.

3.1 API 엔드포인트 ​

초기에는 기존 tRPC 구조를 유지하는 것이 안전하다. 외부 포탈 커넥터가 추가되는 시점에는 같은 계약을 REST gateway로 노출할 수 있지만, ProcessCenter 내부 호출은 tRPC procedure를 우선 사용한다.

API용도권장 시점
search.capabilities현재 사용자의 검색 가능 범위와 지원 모드 확인1단계
search.query키워드·자연어 질의에 대한 검색 결과 반환1단계부터
search.ask검색 결과를 근거로 RAG 답변 생성RagService 도입 단계
search.sourcescitation의 원문 접근 가능 여부 재검증RagService 도입 단계

4. 공통 타입 계약 ​

다음 타입은 shared/search.ts 또는 별도 @hohyun94/search-contract 패키지로 분리하는 것을 권장한다. 프론트엔드와 백엔드가 같은 Zod 스키마를 사용하면 허용되지 않은 포탈 ID나 임의 권한을 클라이언트가 주입하는 문제를 줄일 수 있다.

ts
import { z } from "zod";

export const searchModeSchema = z.enum(["process", "federated", "rag"]);
export type SearchMode = z.infer<typeof searchModeSchema>;

export const searchScopeSchema = z.object({
  portalIds: z.array(z.string().min(1).max(100)).max(100).optional(),
  contentTypes: z.array(z.string().min(1).max(80)).max(100).optional(),
});

export const searchRequestSchema = z.object({
  query: z.string().trim().min(1).max(2_000),
  mode: searchModeSchema.default("process"),
  scope: searchScopeSchema.optional(),
  cursor: z.string().max(500).optional(),
  limit: z.number().int().min(1).max(50).default(20),
  locale: z.enum(["ko", "en"]).default("ko"),
  includeAnswer: z.boolean().default(false),
});
export type SearchRequest = z.infer<typeof searchRequestSchema>;

export const searchResultSchema = z.object({
  id: z.string(),
  portalId: z.string(),
  portalName: z.string(),
  contentType: z.string(),
  title: z.string(),
  snippet: z.string().max(2_000),
  url: z.string().url().optional(),
  sourceId: z.string(),
  attachmentId: z.string().optional(),
  score: z.number().min(0).max(1).optional(),
  highlights: z.array(z.string().max(500)).max(20).default([]),
  citation: z.object({
    sourceId: z.string(),
    page: z.number().int().positive().optional(),
    section: z.string().max(300).optional(),
    chunkId: z.string().optional(),
  }).optional(),
});
export type SearchResult = z.infer<typeof searchResultSchema>;

export const searchResponseSchema = z.object({
  requestId: z.string(),
  mode: searchModeSchema,
  query: z.string(),
  items: z.array(searchResultSchema),
  nextCursor: z.string().optional(),
  totalApproximate: z.number().int().nonnegative().optional(),
  searchedPortals: z.array(z.object({
    portalId: z.string(),
    portalName: z.string(),
    status: z.enum(["ok", "timeout", "unavailable", "forbidden"]),
    itemCount: z.number().int().nonnegative(),
  })),
  authorization: z.object({
    principalId: z.string(),
    permissionVersion: z.string(),
    filtered: z.boolean(),
  }),
  answer: z.object({
    text: z.string(),
    citations: z.array(z.string()),
    grounded: z.boolean(),
  }).optional(),
});
export type SearchResponse = z.infer<typeof searchResponseSchema>;

scope.portalIds는 사용자가 임의로 권한을 확대하는 필드가 아니다. 서버는 요청 scope와 PortalConsole가 반환한 허용 포탈 집합의 교집합만 사용해야 한다. 서버가 권한을 확인하지 못한 포탈은 결과에 포함하지 않는다.

5. tRPC 라우터 계약 ​

초기 ProcessCenter 내부 검색은 다음처럼 기존 process.list를 재사용하되, 프론트엔드가 향후 search.query로 교체할 수 있도록 새 procedure를 추가한다.

ts
export const searchRouter = router({
  capabilities: protectedProcedure.query(({ ctx }) =>
    getSearchCapabilities({ user: ctx.user }),
  ),

  query: protectedProcedure
    .input(searchRequestSchema)
    .query(async ({ ctx, input }) => {
      const principal = await resolveSearchPrincipal(ctx.user);
      return searchOrchestrator.query({
        principal,
        request: input,
      });
    }),

  ask: protectedProcedure
    .input(searchRequestSchema.extend({
      mode: z.literal("rag"),
      includeAnswer: z.literal(true),
    }))
    .mutation(async ({ ctx, input }) => {
      const principal = await resolveSearchPrincipal(ctx.user);
      return searchOrchestrator.ask({
        principal,
        request: input,
      });
    }),
});

내부 ProcessCenter 모드의 searchOrchestrator.query는 기존 프로시저와 동일한 결과를 표준 SearchResult로 변환한다. 이 단계에서는 자연어 답변을 생성하지 않으며, 검색 결과의 portalId는 항상 process-center로 고정된다.

6. 프론트엔드 계약과 동작 ​

공통 PortalFrame은 검색 상태를 직접 소유할 수도 있고, 포탈 페이지가 제어할 수도 있다. 현재 ProcessCenter는 일반 화면에서 controlled value를 사용하므로, 우선 내부 검색을 유지하려면 아래처럼 연결한다.

tsx
const [search, setSearch] = useState("");
const searchQuery = trpc.search.query.useQuery(
  {
    query: search.trim(),
    mode: "process",
    limit: 20,
    locale,
    includeAnswer: false,
  },
  { enabled: isAuthenticated && search.trim().length >= 1 },
);

<PortalFrame
  productName="Process Center"
  searchValue={search}
  onSearchChange={setSearch}
  onSearchSubmit={value => setSearch(value.trim())}
  searchPlaceholder={text("프로세스명·코드·설명 검색", "Search process name, code, or description")}
/>

RAG 도입 후에는 PortalFrame의 props를 바꾸지 않고 검색 훅의 구현만 바꾼다. mode는 서버가 capabilities와 기능 플래그를 기준으로 선택하도록 하며, 클라이언트가 mode: "rag"를 보내도 권한과 기능이 없으면 서버가 거부해야 한다.

검색창은 다음 상태를 지원해야 한다.

상태UI 동작
빈 입력결과 영역을 초기 상태로 유지하고 불필요한 요청을 보내지 않음
입력 중250~300ms debounce 후 검색 요청
Enter현재 질의를 확정하고 결과 영역으로 이동
로딩검색창 내부 또는 결과 영역에 진행 상태 표시
일부 포탈 실패성공한 포탈 결과는 표시하고 실패 포탈 상태를 비차단 안내로 표시
권한 변경permissionVersion 변경 시 결과 캐시 폐기 후 재검색
결과 없음검색 대상 포탈 수와 함께 결과 없음 표시

7. 권한 처리 규칙 ​

권한 검사는 세 계층으로 나눈다.

계층담당검사 내용
포탈 범위PortalConsole사용자가 접근 가능한 등록 포탈, 허용 scope, 권한 버전
콘텐츠 ACL각 포탈문서·기록·업무 데이터의 조직·그룹·소유자·공개 범위
첨부파일 원문FileStore파일 다운로드·미리보기·텍스트 접근 권한과 삭제 상태

PortalConsole가 반환해야 하는 최소 계약은 다음과 같다.

ts
type SearchPortalGrant = {
  portalId: string;
  baseUrl: string;
  allowedScopes: string[];
  credentialRef: string; // 실제 토큰이 아닌 서버 측 참조 ID
  permissionVersion: string;
  expiresAt: string;
};

실제 서비스 토큰이나 R2 자격 증명을 프론트엔드에 반환하지 않는다. ProcessCenter 서버가 짧은 수명의 서버 간 자격증명으로 각 포탈에 요청하고, 각 포탈은 요청된 principal과 서명된 권한 문맥을 검증해야 한다.

RagService에는 principalId, tenantId, portalIds, permissionVersion, contentAcl 등 권한 필터용 메타데이터를 전달한다. 모델에게 권한 필터를 맡기지 않으며, 검색 인덱스의 metadata filter가 먼저 적용되어야 한다.

8. 포탈별 검색 커넥터 계약 ​

모든 포탈은 콘텐츠 종류가 달라도 동일한 표준 결과를 반환해야 한다.

ts
type PortalSearchContext = {
  principalId: string;
  portalId: string;
  permissionVersion: string;
  scopes: string[];
  signal?: AbortSignal;
};

type PortalSearchConnector = {
  search(
    request: { query: string; limit: number; cursor?: string },
    context: PortalSearchContext,
  ): Promise<{
    items: SearchResult[];
    nextCursor?: string;
  }>;
};

커넥터는 자신의 데이터베이스를 직접 노출하지 않고 반드시 콘텐츠 ACL을 적용한다. 검색 오케스트레이터는 포탈별 요청을 병렬 실행하되, 포탈 하나의 timeout이나 장애가 전체 검색을 실패시키지 않도록 결과 상태를 searchedPortals에 기록한다.

9. RagService·FileStore 연동 ​

FileStore는 파일 원본과 파일 메타데이터의 시스템 오브 레코드로 유지한다. RagService는 파일의 추출 텍스트와 chunk를 인덱싱하되 다음 필드를 반드시 저장한다.

ts
type IndexedChunkMetadata = {
  portalId: string;
  sourceId: string;
  attachmentId?: string;
  assetKey?: string;
  contentType: string;
  ownerId?: string;
  organizationIds: string[];
  groupIds: string[];
  aclVersion: string;
  sourceUpdatedAt: string;
  deletedAt?: string;
};

첨부파일이 soft delete되었거나 FileStore에서 권한 검증에 실패하면 RAG 검색 결과에서 제외한다. 검색 결과의 URL은 원본 R2 URL이 아니라 FileStore의 인증된 다운로드·미리보기 endpoint를 사용한다.

RAG 답변 생성은 반드시 다음 순서로 처리한다.

text
사용자 질의
→ PortalConsole 허용 포탈·권한 조회
→ 포탈별 ACL 필터 검색
→ FileStore 원문 접근 재검증
→ 권한 허용 chunk만 rerank
→ RagService 답변 생성
→ citation별 원문 접근 재검증
→ 프론트엔드 응답

답변에 인용되지 않은 내부 데이터나 검색에서 탈락한 chunk를 모델이 추론해 추가하지 않도록 시스템 프롬프트와 응답 검증을 적용한다. 근거가 부족하면 grounded: false와 함께 답변 생성을 제한한다.

10. 오류 및 상태 코드 ​

코드의미프론트엔드 처리
400질의·scope·limit 형식 오류입력 오류 표시
401중앙 세션 없음 또는 만료로그인 흐름 시작
403검색 권한 없음접근 불가 안내, 결과 미표시
404선택한 포탈·콘텐츠가 없음결과 없음 처리
409권한 버전 또는 검색 인덱스 버전 충돌자동 1회 재조회 후 재검색
429rate limit 초과backoff 안내
500검색 오케스트레이터·DB 오류requestId와 함께 일반 오류 표시
502/504특정 포탈·RagService upstream 장애부분 결과와 실패 포탈 상태 표시

서버 오류 응답은 requestId, code, message만 외부에 반환하고 SQL, 토큰, 내부 URL, 원문 ACL을 노출하지 않는다.

ts
type SearchError = {
  requestId: string;
  code: "INVALID_QUERY" | "UNAUTHENTICATED" | "FORBIDDEN" | "PERMISSION_STALE" | "UPSTREAM_UNAVAILABLE" | "INTERNAL";
  message: string;
  retryable: boolean;
};

11. 캐시·성능 정책 ​

검색 결과 캐시 키에는 사용자 식별자 또는 권한 fingerprint를 포함해야 한다. query만으로 캐시하면 사용자의 결과가 다른 사용자에게 노출될 수 있다.

text
cacheKey = hash(principalId + permissionVersion + mode + normalizedQuery + scope)

권장 기본값은 debounce 300ms, 포탈별 timeout 1.5초, 전체 요청 deadline 4초, 결과 page size 20이다. RAG 답변은 검색 결과가 먼저 표시된 뒤 별도 요청으로 생성해 검색 UI의 응답성을 유지한다.

12. 단계별 도입 순서 ​

단계구현 범위완료 기준
1ProcessCenter 내부 검색을 search.query(mode=process)로 표준화기존 프로세스 검색 결과와 동일하고 일반·관리자 모두 같은 헤더 사용
2PortalConsole 포탈 registry·권한 grant 조회허용 포탈만 capabilities와 검색 대상에 포함
3포탈별 federated connector포탈 장애 격리와 콘텐츠 ACL 필터 검증
4FileStore 첨부파일 text extraction·metadata 동기화삭제·권한 변경 파일이 검색에서 즉시 제외
5RagService hybrid retrieval·rerank·citation답변마다 접근 가능한 근거만 인용
6감사·운영 지표검색 requestId, 포탈별 latency, 차단 결과, citation 검증 기록 보존

13. 이번 UI 단계에서의 결정 ​

이번 단계에서는 공통 우측 상단 검색창의 위치와 입력 계약을 고정한다. 실제 RagService 호출은 하지 않으며, ProcessCenter 내부 검색을 제공하는 동안에도 프론트엔드가 향후 search.query 계약으로 전환할 수 있도록 서버 adapter를 먼저 추가하는 것이 다음 구현 순서다.

관리자와 일반 사용자는 같은 검색창을 사용한다. 다만 검색 결과와 원문 링크는 각 사용자의 PortalConsole 권한과 포탈별 콘텐츠 ACL을 서버가 적용한 결과만 반환한다. 검색창에 표시되는 문구나 위치는 권한에 따라 바뀌지 않는다.