저장 라우팅 단계 — 포털별 파일 보관은 FileStore가 소유한다
작성: 2026-09-15 · 상태: 계약 확정 + 콘솔 표면 구현됨 — FileStore의 실제 구현과 라우트로 검증했고, 콘솔 "파일 보관" 데스크(PortalConsole server/fileStore.ts · client/src/components/FileStoreDesk.tsx · docs/FILE_STORE_DESK.md)가 이 계약을 프록시한다. 관련 문서: PLATFORM_VISION.md §6 · SERVICE_BOUNDARIES.md · SEARCH_SCOPE.md(§6 공유 스토리지 경로 규약) · ONTOLOGY_SCOPE.md(§6 정본·색인) · DECISION_LOG.md
1. 왜 지금 필요한가
앞 단계는 무엇을 어디에 두는가를 정하지 않은 채 진행됐다.
| 표면 | 현재 보관 | 문제 |
|---|---|---|
| 문서 라이브러리(플랫폼 생성 문서) | 콘솔 앱의 단일 오브젝트 버킷(platform-docs/ 접두) · 미설정 시 로컬 파일시스템 | 콘솔에 스토리지 환경변수가 없으면 로컬 디스크로 저장되고 컨테이너 재배포 시 사라진다 |
| 포털 업무 파일(첨부·미디어) | FileStore(포털별 정책) | 어떤 백엔드·보존·허용 형식인지 콘솔에서 보이지 않는다 |
| 사내 정책·비정형 문서 | FileStore | 색인 연결 전 |
문서 라이브러리 보관 문제의 실측 근거와 복구 절차는 PortalConsole docs/DOCUMENT_LIBRARY_STORAGE.md에 있다. 이 문서는 그 상위 규칙 — 포털별 비정형 파일의 저장 라우팅 소유자와 콘솔 표면 — 을 고정한다.
2. 결정
| 항목 | 결정 |
|---|---|
| 소유 | FileStore — 백엔드(R2·S3·S3 호환·로컬 볼륨) 등록, 소비자(포털)별 저장 정책, 보존·암호화 요구·RAG 발행 허용, 객체 키 규약 |
| 운영 표면 | PortalConsole 관리자 메뉴 — 목록·등록·수정·진단만 제공하고 서버 간 호출로 프록시한다(브라우저는 FileStore에 직접 붙지 않는다) |
| 자격증명 소유 | FileStore 서버 환경변수. 콘솔은 secret 이름(secretRef)만 표시하고 Access Key·Secret Key는 저장·표시하지 않는다 |
| FileStore 콘솔 | 계속 유지 — 계정·권한(admin:manage)·정리(cleanup)·감사처럼 권한 게이트가 걸린 작업은 FileStore 자신의 /admin 화면이 담당 |
| 신설 금지 | 새 저장 서비스·새 버킷 체계를 만들지 않는다. 이미 구현된 모델을 그대로 쓴다 |
이 결정은 PLATFORM_VISION.md의 원칙 두 개를 그대로 따른다 — 서비스 분리 · 표면 통합(실행은 FileStore, 운영 표면은 콘솔 한 곳)과 플랫폼은 업무 파일의 내용을 판단하지 않는다(보관·검증·색인 발행만).
3. 검증 — FileStore에 이미 있는 것 (2026-09-15 실제 소스)
| 구성 | 위치 | 내용 |
|---|---|---|
| 저장 백엔드 모델 | src/domain.ts storageBackendSchema | id(소문자 슬러그 3~64) · provider(CLOUDFLARE_R2·AMAZON_S3·S3_COMPATIBLE·LOCAL_VOLUME) · label · secretRef · endpoint · bucket · localRoot · region · enabled |
| 소비자(포털) 정책 | src/domain.ts consumerStoragePolicySchema | id(uuid) · consumerId(포털 ID) · backendId · pathPrefix · maxBytes · allowedContentTypes · retentionDays · encryptionRequired · ragPublishAllowed · readEnabled · writeEnabled · enabled |
| 객체 키 규약 | canonicalObjectKey | {pathPrefix}/{consumerId}/{assetId}/{fileName} — 포털·자산 단위로 분리되고 파일명은 정규화 후 120자로 절단 |
| 라우팅 | src/storage.ts createStorage | provider가 객체 저장소면 S3 호환 어댑터, LOCAL_VOLUME이면 로컬 볼륨 어댑터. 정책의 backendId가 백엔드를 고른다 |
| secret 해석 | src/storage.ts | secretRef는 환경변수 이름 접두다 — ${secretRef}_ACCESS_KEY_ID · ${secretRef}_SECRET_ACCESS_KEY를 FileStore 서버 환경에서 읽는다. DB에는 이름만 저장된다 |
| 정책 규칙 | createAssetRecord · canPublishToRag | 쓰기 가능 여부, 최대 크기, 허용 MIME, 보존 기한, RAG 발행 허용은 모두 정책이 판정한다 |
| RAG 발행 계약 | RagPublishEvent | assetId · consumerId · sourceDocumentId · sourceVersion · contentType · classification · organizationIds · storageLocator · checksum · sizeBytes |
| 로컬 볼륨 위치자 | LocalVolumeStorage.getReadUrl | local://{key} 형태의 내부 불투명 locator다(브라우저 접근용 URL이 아니다 — 서버가 권한 확인 후 스트리밍) |
결론: 포털별 저장소 라우팅은 FileStore에 이미 구현돼 있다 — 정책(consumerStoragePolicy)이 consumerId ↔ backendId 매핑이므로 포털마다 다른 백엔드(예: 업무 파일은 R2, 프로필 사진은 로컬 볼륨)를 쓸 수 있고, 업로드는 요청의 mappingId로 정책을 찾아 그 정책의 backendId로 스토리지를 만든다(AssetService.resolveMapping). 없는 것은 콘솔 쪽 운영 표면·백엔드별 자격증명 입력과 RAG 쪽 연결이다(자격증명은 2026-10-11 화면 입력 → 봉투 암호화 저장으로 정리했다).
4. 관리 API 계약 (실제 경로 · 2026-09-15 확인)
모든 경로의 접두는 /api/file-store/v1이다.
| 기능 | 메서드 · 경로 | 인증 | 비고 |
|---|---|---|---|
| 설정 스냅샷(백엔드 + 정책) | GET /admin/configuration | 관리자 | providers 목록·secret 정책 문구, 백엔드별 credentialsConfigured(화면 등록)·secretRefConfigured(실제 해석 가능)·missingCredentialNames(비어 있는 환경변수 이름)·connectionTest(마지막 연결 테스트 상태·시각·stale) 포함 |
| 백엔드 검증 | POST /admin/backends/validate | 관리자 | 스키마·자격증명 해석 확인(본문 credentials가 있으면 그 값, 없으면 저장된 값 → secretRef). LOCAL_VOLUME은 root 읽기·쓰기 권한까지 확인 |
| 백엔드 연결 실측 | POST /admin/backends/test-connection | 관리자 | Cloudflare R2 전용 — HeadBucket으로 bucket 권한 확인(자격증명 해석은 validate와 같다) |
| 백엔드 등록(신규) | POST /admin/backends | 관리자 | ID 는 서버가 채번(이름 로마자 slug → provider → 중복 시 -2) — 화면은 ID 를 입력받지 않는다 |
| 백엔드 등록·수정 | PUT /admin/backends/:backendId | 관리자 | id는 본문이 아니라 경로가 정본(수정 전용). 본문 credentials가 있으면 백엔드별 자격증명을 봉투 암호화 저장(값은 응답에 없음) |
| 백엔드 자격증명 등록·회전 | PUT /admin/backends/:backendId/credentials | 관리자 | write-only — 응답은 credentialsConfigured만 |
| 백엔드 자격증명 삭제·상태 | DELETE · GET /admin/backends/:backendId/credentials | 관리자 | 값은 내려보내지 않고 등록 여부만 |
| 백엔드 삭제 | DELETE /admin/backends/:backendId | 관리자 | 204 — 저장된 자격증명도 함께 지운다 |
| 정책 등록·수정 | PUT /admin/consumer-policies/:policyId | 관리자 | policyId는 uuid, 본문의 consumerId(또는 portalId)가 포털 |
| 정책 삭제 | DELETE /admin/consumer-policies/:policyId | 관리자 | 204 |
| 정책 조회(서버 간) | GET /consumer-policies | 서비스 토큰 | 포털 어댑터가 자기 정책을 읽는 경로 |
| 포털 R2 자격증명 전달 | POST /admin/directory/r2-credentials | 관리자 | 콘솔이 소유한 포털별 R2 bundle 등록·배포와 연결 |
| 자산 목록·상세·감사 | GET /admin/assets · /admin/assets/:assetId · /admin/audit-logs | 관리자 | portalId(정책의 consumerId)로 필터 |
| 요약(개수·용량) | GET /admin/summary | 관리자 | 사용량 뷰의 원천 |
| 세션 확인·로그인 | GET /admin/session · GET /auth/local/status · POST /auth/local/login | — | 로그인 응답은 Set-Cookie만 반환하고 본문에 토큰을 주지 않는다 |
인증 3경로 — 콘솔이 쓸 수 있는 것
authenticateAdmin은 다음 순서로 통과시킨다.
- 로컬 관리자 세션 — 쿠키 또는
Authorization: Bearer <세션 토큰> - DirectoryService 세션 — 중앙 로그인 공유 세션
- 레거시 관리자 토큰 —
x-filestore-admin-token: ${FILE_STORE_ADMIN_TOKEN}
주의: requireAdminPermission이 걸린 라우트(GET·POST /admin/accounts, /admin/cleanup)는 로컬 관리자 세션과 역할 권한(admin:manage·storage:write …)을 요구한다. 즉 2·3번 경로로는 호출할 수 없다 → 이 라우트들은 콘솔이 아니라 FileStore /admin 화면이 담당한다(§2).
반대로 자산 API와 GET /consumer-policies는 authenticateService — Authorization: Bearer ${FILE_STORE_SERVICE_TOKEN}이다.
콘솔에 필요한 환경변수(서버 전용, 브라우저 비노출):
| 이름 | 용도 |
|---|---|
FILE_STORE_BASE_URL | 서버 간 호출 주소 |
FILE_STORE_ADMIN_TOKEN | 백엔드·정책 등록·수정·진단 |
FILE_STORE_SERVICE_TOKEN | 자산·정책 조회 |
5. 콘솔 표면 설계 — "파일 보관(FileStore)" 데스크
| 탭 | 내용 | 호출 |
|---|---|---|
| 저장 백엔드 | 백엔드 목록(provider·bucket·endpoint·자격증명 설정됨/미설정·활성 여부), 등록(ID 는 입력받지 않는다 — FileStore 가 이름에서 채번)·수정·비활성, 자격증명 입력·회전·삭제, R2 연결 실측, 비활성화 = enabled:false | GET /admin/configuration · POST /admin/backends · POST /admin/backends/validate · POST /admin/backends/test-connection · PUT·DELETE /admin/backends/:id · PUT·DELETE /admin/backends/:id/credentials |
| 포털별 정책 | 포털 × 백엔드 라우팅, pathPrefix, 허용 형식, 최대 크기, 보존 기한, ragPublishAllowed, read/write 허용 | PUT·DELETE /admin/consumer-policies/:id |
| 진단 | 콘솔이 이미 저장하는 포털별 FileStore 연결(fileStoreConsumerId·fileStoreMappingId·fileStoreBaseUrl)과 FileStore 실제 정책의 불일치 표시 — 매핑 ID가 없거나 정책이 비활성인 포털을 찾아 준다 | 콘솔 포털 설정 + FileStore GET /consumer-policies |
구현 위치(2026-09-15): PortalConsole server/fileStore.ts(접속·호출·진단 대조) · server/routers.ts fileStore(프록시) · client/src/components/FileStoreDesk.tsx(탭 3개) · 테스트 server/fileStore.test.ts · 운영 문서 docs/FILE_STORE_DESK.md.
표시 규칙: 자격증명 값(Access Key·Secret Key, OAuth Client ID·Secret)은 어떤 경우에도 콘솔 DB·브라우저·응답·로그에 남기지 않는다 — 화면은 설정됨/미설정(필요하면 secretRef 이름과 비어 있는 환경변수 이름)만 보여 준다. 값은 폼 입력 → 콘솔 서버 통과 → FileStore가 백엔드별 봉투 암호화 저장(FILE_STORE_ENVELOPE_MASTER_KEY)이고, 회전은 재입력, 정리는 자격증명 삭제다(ModelGateway 키 화면·문서 라이브러리 보관 진단과 같은 규칙을 값까지 확장).
위험 작업 안내: (a) LOCAL_VOLUME은 백엔드 컨테이너에 볼륨 마운트가 없으면 재배포 시 파일이 사라진다(문서 라이브러리에서 실제로 발생) → 화면에 경고를 고정한다. (b) 백엔드 삭제 전에 그 backendId를 쓰는 정책이 있는지 확인해 함께 보여 준다. (c) ragPublishAllowed를 켜면 그 포털 자산이 색인 대상이 된다 — 색인 연결(§6) 전에는 켜지 않는다.
6. 남은 것
- 공유 스토리지 경로 규약(FileStore → RagService) —
SEARCH_SCOPE.md§6이 남긴 항목. RagService 색인 입력은 파일 경로 기반(BatchDocumentJob{path, metadata})이고 FileStore의 로컬 위치자는local://불투명 locator다. 후보: (a) 공유 볼륨 마운트 (b) FileStore가 읽기 URL을 발급하고 RagService가 회수 (c) FileStore가 push. 미결정 — 결정 전까지ragPublishAllowed는 끈 상태로 둔다. - RagPublisher 실연결 — FileStore의 발행 포트는 현재 로그 스텁이다(
asset.rag.publish이벤트를 서버 로그로만 남긴다). 실제 전달 매체를 위 1번과 함께 정한다. - 색인 재발행 시점 — 승인 즉시 vs 배치.
ONTOLOGY_SCOPE.md§7과 같은 질문이다. - 문서 라이브러리 보관 이관 여부 — 플랫폼 생성 문서를 콘솔 자체 버킷(
platform-docs/)에 둘지, FileStore 소비자(platform-docsconsumer)로 옮길지. 옮기면 문서도 같은 정책·감사·RAG 경로를 탄다. - 콘솔 자체 스토리지 진단의 일반화 — 문서 라이브러리 진단(PortalConsole
docs/DOCUMENT_LIBRARY_STORAGE.md)을 백엔드 진단으로 확장.
7. 하지 않는 것
- 콘솔이 파일 본문·저장 자격증명·버킷을 소유하지 않는다 ❌
- 새 저장 서비스·새 버킷 체계를 만들지 않는다 ❌ (이미 구현된 FileStore 모델을 쓴다)
- 파일 내용의 업무적 판단·업무 로직 실행 ❌ (보관·검증·색인 발행까지만 — 비전 §2)
- FileStore의 권한 게이트 작업(계정·권한·cleanup)을 콘솔로 복제 ❌