콘텐츠로 이동

파일 스토리지 아키텍처

이 문서는 PR #125에서 도입된 영속적 메타데이터 스토리지를 포함하여 OpenAI Files API 호환 레이어를 위한 파일 스토리지 시스템 아키텍처를 설명합니다.

목차

개요

파일 스토리지 시스템은 영속적 메타데이터 스토리지와 함께 OpenAI Files API 호환 파일 관리를 제공합니다. 사용자가 파인튜닝, 배치 처리 및 기타 목적을 위해 파일을 업로드할 수 있게 하면서 서버 재시작 후에도 데이터 내구성을 보장합니다.

주요 기능

  • OpenAI Files API 호환: /v1/files 엔드포인트 완전 지원
  • 영속적 메타데이터: 파일 메타데이터가 서버 재시작 후에도 유지
  • 자동 복구: 시작 시 사이드카 파일에서 메타데이터 인덱스 재구축
  • 고아 관리: 불일치 파일 상태 감지 및 정리
  • 플러그인 가능한 백엔드: 메모리 및 영속 스토리지 백엔드 지원

문제 정의

이전 (인메모리 스토리지)

이전에는 파일 메타데이터가 인메모리 DashMap에 저장되었습니다:

┌─────────────────────────────────────────────────────┐
│                    서버                              │
│  ┌───────────────────────────────────────────────┐  │
│  │           DashMap<FileId, Metadata>           │  │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐        │  │
│  │  │ file-1  │ │ file-2  │ │ file-3  │  ...   │  │
│  │  └─────────┘ └─────────┘ └─────────┘        │  │
│  └───────────────────────────────────────────────┘  │
│                       ↓                             │
│              서버 재시작                             │
│                       ↓                             │
│  ┌───────────────────────────────────────────────┐  │
│  │           DashMap<FileId, Metadata>           │  │
│  │                  (비어 있음)                   │  │  ← 데이터 손실!
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

문제점:

  • 서버 재시작 시 메타데이터 완전 손실
  • API 접근 없이 디스크에 고아 파일 생성
  • 파일과 메타데이터 간 불일치 상태
  • 업로드된 파일 복구 방법 없음

이후 (영속 스토리지)

영속적 메타데이터 스토리지 적용:

┌─────────────────────────────────────────────────────┐
│                    서버                              │
│  ┌───────────────────────────────────────────────┐  │
│  │        인메모리 캐시 (DashMap)                │  │
│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐        │  │
│  │  │ file-1  │ │ file-2  │ │ file-3  │  ...   │  │
│  │  └────┬────┘ └────┬────┘ └────┬────┘        │  │
│  └───────┼───────────┼───────────┼───────────────┘  │
│          │           │           │                  │
│          ▼           ▼           ▼                  │
│  ┌───────────────────────────────────────────────┐  │
│  │              파일 시스템                      │  │
│  │  ┌─────────────────────────────────────────┐  │  │
│  │  │ subdir1/                                │  │  │
│  │  │   file-abc123.bin      ← 데이터         │  │  │
│  │  │   file-abc123.meta.json ← 메타데이터    │  │  │
│  │  │ subdir2/                                │  │  │
│  │  │   file-def456.bin                       │  │  │
│  │  │   file-def456.meta.json                 │  │  │
│  │  └─────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘
              서버 재시작
┌─────────────────────────────────────────────────────┐
│                    서버                              │
│  ┌───────────────────────────────────────────────┐  │
│  │   .meta.json 파일 스캔 → 캐시 재구축          │  │  ← 자동 복구!
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

아키텍처

컴포넌트 다이어그램

┌─────────────────────────────────────────────────────────────────┐
│                         HTTP Layer                               │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                    /v1/files Handlers                       ││
│  │  POST /v1/files  GET /v1/files  GET /v1/files/:id  DELETE  ││
│  └──────────────────────────┬──────────────────────────────────┘│
└─────────────────────────────┼───────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                       Services Layer                             │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                      FileService                            ││
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ ││
│  │  │   Upload    │  │   Delete    │  │   List / Retrieve   │ ││
│  │  │   Handler   │  │   Handler   │  │      Handler        │ ││
│  │  └──────┬──────┘  └──────┬──────┘  └──────────┬──────────┘ ││
│  │         │                │                    │            ││
│  │         ▼                ▼                    ▼            ││
│  │  ┌───────────────────────────────────────────────────────┐ ││
│  │  │              Arc<dyn MetadataBackend>                 │ ││
│  │  └───────────────────────────────────────────────────────┘ ││
│  └──────────────────────────┬──────────────────────────────────┘│
└─────────────────────────────┼───────────────────────────────────┘
            ┌─────────────────┴─────────────────┐
            ▼                                   ▼
┌───────────────────────┐           ┌───────────────────────────┐
│    MetadataStore      │           │  PersistentMetadataStore  │
│    (인메모리)         │           │    (사이드카 JSON)        │
├───────────────────────┤           ├───────────────────────────┤
│ • DashMap 스토리지    │           │ • DashMap 캐시            │
│ • 빠른 연산           │           │ • JSON 파일 영속성        │
│ • 내구성 없음         │           │ • 시작 복구               │
│                       │           │ • 고아 감지               │
└───────────────────────┘           └───────────────────────────┘

레이어 책임

레이어 컴포넌트 책임
HTTP Handlers 요청 파싱, 유효성 검사, 응답 포맷팅
Services FileService 비즈니스 로직, 조정
Services MetadataBackend 메타데이터 스토리지 추상화
Infrastructure LocalFileStorage 물리적 파일 I/O

스토리지 구조

디렉토리 레이아웃

storage/
├── a1b2c/                          # 하위 디렉토리 (파일 ID 첫 5자)
│   ├── file-a1b2c3d4e5f6.bin       # 바이너리 데이터 파일
│   └── file-a1b2c3d4e5f6.meta.json # 메타데이터 사이드카 파일
├── x9y8z/
│   ├── file-x9y8z7w6v5u4.bin
│   └── file-x9y8z7w6v5u4.meta.json
└── ...

파일 명명 규칙

파일 유형 확장자 패턴 설명
데이터 .bin file-{id}.bin 원시 파일 콘텐츠
메타데이터 .meta.json file-{id}.meta.json JSON 메타데이터 사이드카

사이드카 패턴의 이점

  1. 공동 배치: 데이터와 메타데이터가 함께 저장됨
  2. 원자적 연산: 메타데이터 쓰기는 원자적 이름 변경 패턴 사용
  3. 쉬운 백업: 간단한 디렉토리 복사로 모든 것 보존
  4. 디버그 친화적: 사람이 읽을 수 있는 JSON 메타데이터
  5. 외부 의존성 없음: 데이터베이스 불필요

메타데이터 스키마

FileMetadata 구조

{
  "id": "file-abc123def456",
  "object": "file",
  "filename": "training_data.jsonl",
  "bytes": 1048576,
  "purpose": "fine-tune",
  "created_at": 1699574400,
  "content_type": "application/jsonl",
  "storage_path": "a1b2c/file-abc123def456.bin"
}

필드 설명

필드 타입 설명
id string 고유 파일 식별자 (OpenAI 형식: file-{random})
object string API 호환성을 위해 항상 "file"
filename string 업로드된 원본 파일명
bytes integer 바이트 단위 파일 크기
purpose string 파일 목적: fine-tune, batch, assistants
created_at integer 생성 Unix 타임스탬프
content_type string 파일의 MIME 타입
storage_path string 데이터 파일의 상대 경로

지원되는 목적

목적 설명
fine-tune 파인튜닝용 훈련 데이터
batch Batch API 입력 파일
assistants Assistants API용 파일
vision 비전 모델용 이미지 파일
user_data 일반 사용자 업로드

스토리지 백엔드

MetadataBackend 트레이트

#[async_trait]
pub trait MetadataBackend: Send + Sync {
    async fn insert(&self, metadata: FileMetadata) -> Result<(), FileError>;
    async fn get(&self, id: &str) -> Option<FileMetadata>;
    async fn remove(&self, id: &str) -> Option<FileMetadata>;
    async fn list(&self, query: &FileListQuery) -> Vec<FileMetadata>;
    async fn len(&self) -> usize;
    async fn is_empty(&self) -> bool;
}

백엔드 비교

기능 MetadataStore (메모리) PersistentMetadataStore
영속성 아니오
시작 복구 아니오
성능 가장 빠름 빠름 (캐시됨)
고아 감지 아니오
사용 사례 개발/테스트 프로덕션

쓰기 경로 (영속)

1. 파일 ID 생성
2. 데이터 파일 저장: storage/{subdir}/file-{id}.bin
3. 메타데이터 JSON 생성
4. 임시 파일에 쓰기: file-{id}.meta.json.tmp
5. 원자적 이름 변경: file-{id}.meta.json.tmp → file-{id}.meta.json
6. 인메모리 캐시 업데이트

읽기 경로 (영속)

1. 인메모리 캐시 확인 (DashMap)
2. 캐시 히트 → 캐시된 메타데이터 반환
3. 캐시 미스 → (시작 복구 시에만)
   a. .meta.json 파일 디렉토리 스캔
   b. 각 파일 파싱 및 유효성 검사
   c. 캐시 채우기

인증 및 권한 부여

Files API는 파일 작업을 보호하기 위한 포괄적인 인증 및 권한 부여를 포함합니다.

인증 방법

방법 설명 사용 사례
api_key (기본) Bearer 토큰 인증 프로덕션 환경
none 인증 없음 개발/테스트 전용

권한 부여 모델

┌─────────────────────────────────────────────────────────────────┐
│                    Files API 요청                                │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                   인증 레이어                                    │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Bearer 토큰 추출 → API 키 유효성 검사 → 스코프 확인        ││
│  └─────────────────────────────────────────────────────────────┘│
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                   권한 부여 레이어                               │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  파일 소유권 확인 → 관리자 오버라이드 → 허용/거부            ││
│  └─────────────────────────────────────────────────────────────┘│
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                      파일 작업                                   │
└─────────────────────────────────────────────────────────────────┘

파일 소유권

enforce_ownership이 활성화된 경우 (기본값):

연산 소유자 관리자 다른 사용자
업로드 소유 파일 생성 소유 파일 생성 소유 파일 생성
목록 본인 파일만 모든 파일 본인 파일만
조회 본인 파일만 모든 파일 403 Forbidden
다운로드 본인 파일만 모든 파일 403 Forbidden
삭제 본인 파일만 모든 파일 403 Forbidden

권한 부여를 위한 메타데이터 필드

FileMetadata 구조에는 소유권 필드가 포함됩니다:

{
  "id": "file-abc123def456",
  "owner_id": "user-xyz789",
  "organization_id": "org-abc123",
  "source_ip": "192.168.1.100",
  "created_at": 1699574400
}
필드 설명
owner_id 파일을 업로드한 사용자 ID
organization_id 사용자가 속한 조직
source_ip 업로드 요청의 IP 주소 (감사용)

감사 로깅

모든 파일 작업은 인증 컨텍스트와 함께 로깅됩니다:

INFO file_uploaded file_id="file-abc123" user_id="user-xyz" org_id="org-abc" client_ip="192.168.1.1"
INFO file_downloaded file_id="file-abc123" user_id="user-xyz"
INFO file_deleted file_id="file-abc123" user_id="user-xyz" client_ip="192.168.1.1"
WARN file_access_denied file_id="file-abc123" user_id="user-xyz" file_owner="user-other"

보안 고려사항

  1. 개발 키: CONTINUUM_DEV_MODE가 설정되거나 디버그 빌드에서만 사용 가능
  2. 스코프 요구사항: API 키는 설정된 스코프가 있어야 함 (기본: "files")
  3. 레거시 파일: owner_id가 없는 파일은 모든 인증된 사용자가 접근 가능
  4. 관리자 오버라이드: "admin" 스코프가 있는 사용자는 설정된 경우 소유권 검사 우회

시작 복구

복구 프로세스

┌─────────────────────────────────────────────────────┐
│                 서버 시작                            │
└──────────────────────┬──────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│         스토리지 디렉토리 재귀적 스캔               │
│         모든 *.meta.json 파일 찾기                  │
└──────────────────────┬──────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│         각 .meta.json 파일에 대해:                  │
│         1. JSON 콘텐츠 파싱                         │
│         2. 스키마 유효성 검사                       │
│         3. 해당 .bin 파일 존재 확인                 │
│         4. 인메모리 캐시에 추가                     │
└──────────────────────┬──────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│         복구 통계 로깅:                             │
│         - 복구된 파일: N                            │
│         - 감지된 고아: M                            │
└─────────────────────────────────────────────────────┘

복구 보장

  1. 멱등성: 여러 번 실행해도 안전
  2. 비파괴적: 복구 중 파일 삭제하지 않음
  3. 부분 성공: 일부 파일이 손상되어도 계속 진행
  4. 로깅: 모든 복구 작업이 디버깅을 위해 로깅됨

고아 감지 및 정리

고아 유형

유형 설명 원인
고아 데이터 .meta.json 없는 .bin 파일 업로드 중 크래시, 수동 삭제
고아 메타데이터 .bin 없는 .meta.json 삭제 중 크래시, 디스크 손상

감지 알고리즘

pub async fn detect_orphans(&self) -> Result<(Vec<PathBuf>, Vec<PathBuf>), FileError> {
    // 스토리지 디렉토리의 모든 파일 스캔
    for file in storage_directory {
        if file.ends_with(".bin") {
            // 해당 .meta.json 존재 확인
            let meta_path = file.replace(".bin", ".meta.json");
            if !meta_path.exists() {
                orphaned_data.push(file);
            }
        } else if file.ends_with(".meta.json") {
            // 해당 .bin 존재 확인
            let data_path = file.replace(".meta.json", ".bin");
            if !data_path.exists() {
                orphaned_metadata.push(file);
            }
        }
    }
    Ok((orphaned_data, orphaned_metadata))
}

정리 옵션

옵션 cleanup_orphans_on_startup 효과
비활성화 (기본) false 고아 감지 및 로깅만
활성화 true 고아 메타데이터 파일 자동 삭제

경고: 데이터 파일 정리는 우발적 데이터 손실 방지를 위해 수동 개입 필요.

TOCTOU 안전 주의사항

고아 정리는 TOCTOU(Time-of-Check-Time-of-Use) 경쟁 조건으로 인해 활성 파일 작업 중에는 안전하지 않습니다:

스레드 A: detect_orphans() → file-X를 고아로 발견
스레드 B: upload() → file-X에 대한 메타데이터 생성
스레드 A: cleanup() → "고아" 삭제 (이제 유효함!)

권장사항: 서버 시작 또는 유지보수 기간에만 정리 실행.

설정

YAML 설정

files:
  enabled: true
  max_file_size: 536870912        # 512MB
  storage_path: "./data/files"    # ~ 확장 지원
  retention_days: 0               # 0 = 영구 보관
  metadata_storage: persistent    # "memory" 또는 "persistent"
  cleanup_orphans_on_startup: false

환경 변수

변수 기본값 설명
CONTINUUM_FILES_ENABLED true Files API 활성화/비활성화
CONTINUUM_FILES_MAX_SIZE 536870912 최대 파일 크기 (바이트)
CONTINUUM_FILES_STORAGE_PATH ./data/files 스토리지 디렉토리
CONTINUUM_FILES_RETENTION_DAYS 0 N일 후 자동 삭제
CONTINUUM_FILES_METADATA_STORAGE persistent 백엔드 유형
CONTINUUM_FILES_CLEANUP_ORPHANS false 시작 시 자동 정리

스토리지 백엔드 선택

백엔드 사용 시기
memory 개발, 테스트, 일시적 워크로드
persistent 프로덕션, 데이터 내구성 필요 시

API 엔드포인트

POST /v1/files

새 파일 업로드.

curl -X POST http://localhost:8080/v1/files \
  -H "Content-Type: multipart/form-data" \
  -F "file=@training.jsonl" \
  -F "purpose=fine-tune"

GET /v1/files

모든 파일 목록.

curl http://localhost:8080/v1/files?purpose=fine-tune&limit=10

GET /v1/files/:id

파일 메타데이터 조회.

curl http://localhost:8080/v1/files/file-abc123

GET /v1/files/:id/content

파일 콘텐츠 다운로드.

curl http://localhost:8080/v1/files/file-abc123/content -o downloaded.jsonl

DELETE /v1/files/:id

파일 삭제.

curl -X DELETE http://localhost:8080/v1/files/file-abc123

설계 결정

왜 사이드카 JSON 파일인가?

고려된 대안:

옵션 장점 단점
SQLite ACID, 쿼리 추가 의존성, 복잡성
단일 JSON 파일 단순함 동시성 문제, 대용량 파일 문제
RocksDB/LevelDB 빠름, 내구성 무거운 의존성
사이드카 JSON 단순, 의존성 없음, 공동 배치 많은 작은 파일

결정: 다음 이유로 사이드카 JSON 파일 선택:

  1. 기존 파일 기반 아키텍처에 적합
  2. 추가 의존성 없음 (기존 serde_json 사용)
  3. 파일과 메타데이터가 함께 위치해 백업/복원 용이
  4. 이름 변경 패턴으로 원자적 쓰기 가능
  5. 디버깅을 위한 사람이 읽을 수 있는 형식

왜 인메모리 캐시 + 디스크인가?

패턴: 디스크 영속성을 가진 Write-through 캐시

쓰기: 캐시 → 디스크 (동기적)
읽기: 캐시 (빠른 경로)

이점:

  • 밀리초 미만의 읽기 지연 시간
  • 내구성 있는 쓰기
  • 재시작 시 자동 복구

왜 데이터베이스가 아닌가?

Files API 사용 사례의 경우:

  • 일반적으로 수백에서 수천 개의 파일, 수백만 개가 아님
  • 단순한 키-값 접근 패턴
  • 복잡한 쿼리 불필요
  • 파일 시스템이 이미 원자성 보장 제공

데이터베이스는 다음을 추가할 것:

  • 운영 복잡성
  • 추가 의존성
  • 잠재적 단일 장애 지점

관련 문서