개발자 첫 화면Swagger미디어 보관함Markdown 원문

서비스와 논리 폴더로 미디어 관리하기

이 문서는 OpenAPI 1.3.0의 서비스 구분, 논리 경로, 폴더 관리 계약을 설명합니다. 기존 원본 보관과 파일 다운로드 구조는 유지합니다. 공개 주소는 https://media.ctmm.cocifee.com, 관리 화면은 /admin, 대화형 명세는 /docs, 기계가 읽는 명세는 /openapi.json입니다.

식별자와 경로를 분리하는 이유

namespace는 관리 권한이 연결된 서비스 구분입니다. path는 그 서비스 안에서 사용하는 논리 경로입니다. id는 서버가 생성하는 파일 식별자이며, 원본과 변환본의 실제 위치와 다운로드 주소는 계속 이 식별자를 사용합니다.

예를 들어 wedding의 업체/123/갤러리/실내 사진.jpeg를 업체/123/대표사진/메인 사진.jpeg로 바꾸어도 /m/{id}/detail.webp는 바뀌지 않습니다. SeaweedFS 객체, SSD 게시 파일, 이미 만들어진 RAM 사본을 옮기거나 다시 변환하지 않습니다. 공개 설정과 캐시 정책도 그대로입니다. 비공개 파일의 기존 서명 주소도 경로 변경 때문에 무효화하지 않습니다. 다만 그 주소의 원래 만료 시간은 그대로 적용됩니다.

다른 namespace로 옮기는 기능은 제공하지 않습니다. 파일이 없는 폴더를 따로 만들지도 않습니다. 폴더는 해당 경로 아래의 미디어 기록으로부터 구성합니다. 논리 삭제는 원본을 물리적으로 지우지 않으므로 실패·삭제 기록만 남은 폴더도 이력과 보관 용량을 표시할 수 있습니다.

API 키와 화면의 권한

기존 주 API 키는 서버 운영자용입니다. 실제 배포의 허용 범위는 legacy, wedding, media-validation이며, 기본 범위는 legacy입니다. media-validation은 기능 검증에 사용하는 영역입니다. Wedding 백엔드에는 wedding에만 접근하는 별도 키를 사용합니다. 비밀값은 지정된 자격 증명 등록부에만 보관합니다.

추가 키는 Secret의 MEDIA_API_KEYS_JSON에 다음 구조의 배열로 설정합니다. 아래는 실제 비밀값이 없는 형식 설명입니다.

[
  {
    "id": "wedding-backend",
    "key": "서버에서 생성하여 비밀 저장소에 보관하는 32자 이상의 키",
    "namespaces": ["wedding"],
    "default_namespace": "wedding"
  }
]

기본 범위는 허용 목록 안에 있어야 합니다. namespace는 소문자 영문자로 시작하고 영문 소문자, 숫자, 밑줄, 하이픈만 사용하며 최대 63자입니다. 클라이언트가 요청에 다른 namespace를 넣어도 권한이 생기지 않습니다. 파일 식별자로 직접 호출하는 조회·서명 주소 발급·공개 설정 변경·삭제·이름 변경·RAM 제어도 해당 파일의 namespace를 검사합니다. 시스템 전체 통계 API는 운영자 키로 제한합니다.

관리 화면은 계속 로그인 없이 열립니다. 익명 사용자가 볼 수 있는 범위는 서버의 MEDIA_PUBLIC_ADMIN_NAMESPACES이며 기본값과 이번 배포값은 legacy입니다. 이 영역에서는 기존 정책대로 비공개 파일의 이름·논리 경로·저장 위치·상태는 보이지만 비공개 본문과 서명은 제공하지 않습니다. 익명 RAM 변경은 이 영역의 공개 파일에만 허용합니다. 기존 파일이 모두 legacy로 이전되므로 기존 화면의 열람 범위를 임의로 로그인 뒤에 숨기지 않습니다. 새 wedding 영역의 경로 정보는 익명 화면에 노출하지 않습니다. 기존 공개 시스템 현황에는 전체 건수와 저장량 같은 합계가 남습니다.

파일 이름 변경과 폴더 이동, 새 서비스 영역의 관리에는 짧은 관리 토큰을 사용합니다. 백엔드에서 POST /v1/admin-sessions에 namespace를 보내 발급합니다. 900초 동안 해당 namespace만 관리할 수 있으며, 화면의 ‘서비스 관리 토큰’에 붙여 넣습니다. 페이지 자체의 로그인 정책을 바꾸는 기능이 아닙니다. 토큰은 탭의 메모리에만 보관하며 URL이나 브라우저 저장소에 넣지 않습니다. 토큰으로 새 토큰을 발급하거나 업로드 예약을 만들 수 없습니다. 발급 키가 삭제되거나 교체되면 그 키로 만든 토큰도 거절됩니다. 토큰이 허용하는 파일 관리 작업에는 삭제와 서명 읽기 주소 발급도 포함되므로 신뢰하는 관리자에게만 전달해야 합니다. 만료 후에는 새 토큰을 연결합니다.

공개 이미지 다운로드에는 이 관리 토큰이 필요하지 않습니다. 비공개 이미지에는 기존과 같이 특정 식별자·변환본·접근 버전·만료 시간에 묶인 서명 주소가 필요합니다. 논리 경로로 본문을 직접 읽는 엔드포인트는 제공하지 않습니다.

경로 규칙

서버는 Unicode NFC 정규화를 적용합니다. 한국어, 공백, 일반적인 유니코드 문자를 사용할 수 있습니다. 대소문자를 구분하므로 사진/A.jpeg와 사진/a.jpeg는 서로 다른 경로입니다. 파일명 부분 검색은 SQLite LIKE의 기본 동작에 따라 영문 ASCII 대소문자를 구분하지 않을 수 있지만, 경로의 동일성 판단과 중복 제약은 대소문자를 구분합니다.

- 파일 경로는 UTF-8 기준 최대 1024바이트입니다. 각 경로 요소는 최대 255바이트이며 폴더는 최대 16단계입니다. 한글 한 글자는 일반적으로 UTF-8에서 3바이트이므로 글자 수와 바이트 수를 혼동하지 않아야 합니다.

- 파일 경로는 /로 시작하거나 끝날 수 없습니다. .와 .., 빈 요소, 역슬래시, 제어 문자·보이지 않는 형식 제어 문자·잘못된 유니코드 서로게이트를 거절합니다. 연속 슬래시를 자동으로 합치지 않습니다.

- 폴더 조회·이동에서는 마지막 / 하나를 허용합니다. 루트 폴더는 빈 문자열입니다. / 하나를 루트의 다른 표기로 허용하지 않습니다.

- 파일 경로의 마지막 요소가 관리 화면의 파일명입니다. 별도로 filename을 보내면 NFC 정규화 후 마지막 요소와 정확히 같아야 하며, 다르면 422를 반환합니다.

- 파일이 이미 차지한 경로를 폴더로 쓰거나, 기존 폴더 경로를 새 파일 이름으로 쓰면 409를 반환합니다.

사용자가 입력한 경로는 데이터베이스의 문자열입니다. 운영체제 경로나 SeaweedFS 객체 키로 직접 사용하지 않습니다.

업로드와 예약

import { MediaClient, MediaApiError, uploadWithTicket } from './sdk/media-client.mjs';

// 백엔드에서만 생성합니다. 이 API 키를 프런트엔드 번들에 포함하지 마세요.
const media = new MediaClient({
  baseUrl: 'https://media.ctmm.cocifee.com',
  apiKey: process.env.WEDDING_MEDIA_API_KEY,
});

let ticket;
try {
  ticket = await media.createUpload({
    namespace: 'wedding',
    path: '업체/123/갤러리/실내 사진.jpeg',
    contentType: 'image/jpeg',
    visibility: 'public',
    cachePolicy: 'manual',
  });
} catch (error) {
  if (error instanceof MediaApiError && error.status === 409) {
    // 기존 파일을 덮어쓰지 않습니다. 사용자에게 다른 경로를 선택하게 합니다.
  }
  throw error;
}

// ticket만 브라우저에 전달할 수 있습니다. 실제 File 객체의 종류와 contentType이 같아야 합니다.
await uploadWithTicket(ticket, file);
// 상태 조회와 기다리기는 다시 백엔드에서 수행합니다.
const ready = await media.waitUntilReady(ticket.id);
const imageUrl = ready.variants['detail.webp'];

업로드 응답에는 id, namespace, path, filename, contentType, uploadUrl, expiresIn, statusUrl, downloadUrls, maxUploadBytes가 있습니다. downloadUrls는 변환 완료 후 사용할 주소입니다. 예약 응답을 받았다는 사실이 그 주소에 파일이 준비됐다는 뜻은 아닙니다. 상태가 ready가 되어야 합니다. 비공개 파일은 access()로 서명 주소를 받아야 합니다. 사진과 영상은 같은 분류 규칙을 사용하며 변환본 이름만 다릅니다.

같은 namespace 안의 같은 정규화 경로는 동시에 하나의 활성 기록만 차지할 수 있습니다. 사전 확인 외에 SQLite의 부분 고유 인덱스가 마지막 방어선으로 작동합니다. 업로드 대기, 전송, 원본 보관, 변환 대기, 변환 중, 재시도 대기, 준비 완료 상태가 경로를 차지합니다.

업로드 대기 예약은 생성 후 900초입니다. 새 업로드 생성 또는 경로 변경·폴더 이동 트랜잭션이 만료된 대기 기록을 expired로 정리합니다. 별도 정리 요청이 없으면 상태 조회에 오래된 대기 상태가 남을 수 있지만, 같은 경로의 새 예약 시 만료 여부를 검사하므로 만료된 예약이 재사용을 막지 않습니다. 유효 시간 안에 업로드를 시작해 전송·보관 중인 작업은 단순히 대기 예약 시간이 지났다는 이유로 경로를 빼앗지 않습니다. 본문 전송 오류로 awaiting_upload에 돌아온 작업은 원래 유효 시간 안에서 다시 보낼 수 있습니다.

최종 failed, expired, deleted 상태는 경로 고유 제약 대상에서 제외됩니다. 일시 실패 후 retry_wait 상태는 예약을 유지합니다. 경로 예약 해제와 저장 용량 예약 해제는 다른 문제입니다. S3 저장 성공 여부가 불분명한 실패는 기존 정책대로 저장 용량 예약을 보수적으로 유지할 수 있습니다. 이때 경로는 새 식별자로 재사용할 수 있지만 저장 예산 때문에 새 업로드가 거절될 수 있습니다. 과거 식별자는 새 파일의 별칭으로 바꾸지 않습니다. 내용 교체, 버전 관리, 실패 기록 재활성화 API는 이번 범위에 없습니다.

현재 폴더와 하위 경로 조회

하위 폴더와 파일은 각각 페이지를 가집니다. 어느 쪽도 전체 목록을 브라우저로 보내지 않습니다.

const folders = await media.folders({
  namespace: 'wedding', folder: '업체/123/', limit: 24,
});
const files = await media.list({
  namespace: 'wedding', folder: '업체/123/', recursive: false, limit: 24,
});
// 각 응답의 nextCursor로 해당 종류의 다음 페이지를 요청합니다.
const nextFiles = files.nextCursor
  ? await media.list({namespace: 'wedding', folder: '업체/123/', recursive: false,
                     limit: 24, cursor: files.nextCursor})
  : null;

const descendants = await media.list({
  namespace: 'wedding', folder: '업체/123/', recursive: true,
  q: '실내', kind: 'image', visibility: 'public', status: 'ready', limit: 24,
});

/v1/folders는 현재 위치 바로 아래 폴더를 반환합니다. /v1/media는 recursive=false이면 현재 폴더 바로 아래 파일만, true이면 모든 하위 파일을 페이지 단위로 반환합니다. 기존 목록 호출과의 호환을 위해 API의 기본값은 recursive=true이고 관리 화면은 현재 폴더 탐색을 위해 false부터 시작합니다. 사진·영상, 공개 여부, 처리 상태, 파일명·논리 경로 부분 검색을 조합할 수 있습니다. 폴더 탐색 목록 자체는 파일 검색 필터와 독립적이며 화면에 이를 표시합니다.

업체/123/의 범위는 경로 요소 경계로 제한하므로 업체/1234/가 섞이지 않습니다. 기본 페이지 크기는 24, 최대 50입니다. 정렬은 논리 경로와 식별자의 오름차순입니다. 이전 버전의 생성 시각 내림차순과 다릅니다. 커서는 namespace·폴더·재귀 여부·검색·필터에 묶입니다. 조건을 바꿀 때는 첫 페이지부터 조회해야 합니다. 이전 버전의 커서도 다시 사용하지 않습니다. 동시에 파일이 이동·생성되면 페이지 사이의 완전한 스냅샷 일관성은 보장하지 않으므로 변경 후 첫 페이지부터 갱신합니다. 커서는 접근 권한이 아니며 매번 키 범위를 검사합니다.

이름 변경과 원자적인 폴더 이동

await media.rename(ticket.id, '업체/123/대표사진/메인 사진.jpeg');
await media.moveFolder({
  namespace: 'wedding', source: '업체/123', destination: '보관/업체123',
});

파일 이동은 같은 namespace의 path만 변경합니다. 폴더 이동은 대상 경로를 모두 검증한 후 한 SQLite 쓰기 트랜잭션으로 반영합니다. 대상 폴더가 이미 있으면 병합하지 않고 409로 거절합니다. 루트 이동과 자기 하위 폴더로의 이동도 거절합니다. 최대 5000개 기록까지 처리하며 초과하면 413으로 전체를 거절합니다. 실패·삭제 이력을 포함하여 해당 폴더 아래 기록 전체를 이동하므로 보관 용량 설명도 함께 이동합니다. 단일 실패·중복 제약 오류·프로세스 종료 시 커밋되지 않은 변경은 롤백됩니다. 응답을 받기 전에 연결이 끊겼다면 이전·새 폴더와 파일 상태를 조회하여 커밋 여부를 확인하고, 확인 없이 같은 이동을 반복하지 마세요.

기록이 많은 폴더 이동은 메타데이터 쓰기 잠금을 오래 잡을 수 있습니다. 이번 버전은 무제한 이동이나 비동기 대규모 작업을 주장하지 않습니다. 최대 범위에서도 운영 부하에 따른 지연은 별도 측정 대상입니다.

RAM 작업과 용량 표시

const admission = await media.warmCache(ticket.id, ['detail.webp']);
const state = await media.cache(ticket.id);
await media.evictCache(ticket.id); // 원본·SSD·cachePolicy 유지

const batch = await media.cacheBatch([ticket.id], 'warm'); // 최대 20개
const first = await media.folderCache({namespace: 'wedding', folder: '업체/123',
                                       action: 'warm', limit: 20});
// first.nextCursor가 있어도 서버가 다음 묶음을 자동 실행하지 않습니다.
// 필요하면 개발자가 명시적으로 다음 페이지를 요청합니다.

파일별 결과에는 HTTP 상태에 해당하는 httpStatus, 접수 결과, 변환본별 resident, queued, serving, reason이 있습니다. 202는 적재 완료가 아닙니다. 파일별 캐시 조회를 반복하여 실제 완료를 확인합니다. 비공개·미완료·SSD 전용·크기 초과·메모리 압박 등 거절 이유를 확인할 수 있습니다. 일부 파일 거절은 해당 파일 결과에 남으며 다른 파일의 결과와 구분합니다. 묶음에 권한 밖의 파일이 포함되면 먼저 전체를 거절하므로 권한 검사 전에 일부 파일만 조작하지 않습니다.

기존 64MiB 파일 제한, 실제 tmpfs 여유 검사, 메모리 압박 보호, 캐시 목표 용량, 대기열 제한을 유지합니다. 기본 warmCache()는 모든 변환본을 평가하지만 변환본별 결과를 반환합니다. 폴더 단위 작업도 한 요청당 최대 20개이며 20개를 초과하여 한꺼번에 받아들이지 않습니다. RAM 사본은 영구 고정되지 않으며 재시작·압박·용량 회수로 없어질 수 있습니다.

RAM 해제는 원본 삭제가 아닙니다. 화면의 새 일괄 해제와 개별 해제는 정책을 유지합니다. auto이면 다음 다운로드·미리보기에서 다시 적재할 수 있으므로 다시 적재하지 않으려면 먼저 manual이나 disabled로 바꾸세요. 이전 화면의 ‘해제하면서 자동으로 manual로 변경’ 동작과 달라졌으며 화면에도 설명합니다.

폴더의 영속 원본 바이트와 변환본 바이트는 구분합니다. 폴더 및 조상 폴더의 합계는 파일 생성·이동과 저장 바이트 변경 시 같은 데이터베이스 트랜잭션에서 갱신합니다. 실패·논리 삭제 후에도 실제 원본을 보관하는 구조이므로 그 기록의 보관 바이트도 합계에 남습니다. 저장 성공 여부가 불분명한 파일은 기존 메타데이터가 아는 바이트만 표시하며 S3를 매번 전수 대조한 값은 아닙니다.

RAM 바이트는 약 30초마다 사본 파일을 조사하여 별도의 스냅샷으로 기록합니다. 화면에는 측정 시각을 표시합니다. 목록 조회마다 모든 파일을 읽어 합산하지 않습니다. 폴더 이동·해제 직후에는 다음 집계까지 RAM 합계가 이전 상태일 수 있습니다. 파일별 캐시 조회는 이 폴더 합계보다 최신 상태를 제공합니다. 열린 전송 핸들이 붙잡고 있는 메모리와 파일 경로 기준 사본 합계는 같지 않을 수 있습니다.

조회 비용과 인덱스

파일 기록 수를 N, 현재 선택 범위에서 검사하는 기록 수를 M, 반환 페이지 크기를 P, 폴더 깊이를 D라고 하겠습니다.

- 활성 경로에는 (namespace,path) 부분 고유 인덱스가 있습니다. 같은 경로의 예약 경쟁은 데이터베이스에서 판정합니다.

- 바로 아래 파일은 (namespace,parent_path,path,id) 인덱스로 탐색합니다. 하위 파일 전체는 (namespace,path,id) 인덱스로 접두 경계와 커서 이후를 찾습니다. 필터 없는 일반 페이지는 대략 O(log N + P)의 탐색·반환 비용입니다. 요청한 파일의 변환본 수는 최대 5개이며 해당 페이지에 대해서만 SSD·RAM 상태를 확인합니다.

- 폴더 탐색은 (namespace,parent,name,path) 인덱스를 사용하며 폴더 수를 F라고 하면 대략 O(log F + P)입니다. 페이지의 각 폴더 합계는 미리 유지한 값을 읽습니다. 요청 시 하위 파일을 전수 합산하지 않습니다.

- 파일명·경로의 중간 문자열 검색에는 앞부분 B-tree 인덱스로 바로 찾을 수 없는 조건이 포함됩니다. 공개 여부·상태·종류 필터도 선택 범위 안에서 후보를 추가 검사합니다. 최악에는 선택 범위의 M개를 검사하므로 모든 검색을 O(P)라고 설명하지 않습니다. 검색 전용 색인이나 모든 필터 조합의 인덱스는 이번 범위에 추가하지 않았습니다.

- 폴더별 소속 관계는 파일 하나당 최대 D+1개 행입니다. 영속 바이트 집계 변경과 파일 이동 시 조상 폴더를 갱신합니다. 전체 폴더 이동은 해당 M개 파일의 경로·소속을 바꾸므로 최소한 M에 비례하는 작업이며 인덱스 탐색과 깊이 비용이 추가됩니다. 최대 5000개 제한이 있습니다.

- RAM 집계는 전체 영속 파일이 아니라 현재 RAM 사본과 그 소속을 조사합니다. RAM 사본 수를 R이라고 하면 파일 조사와 소속 조회에 대략 O(R log N + R D)가 들며 약 30초 주기로 수행합니다. 서버 전체 물리 디스크 전수 검색은 하지 않습니다.

실제 조회 계획 시험에서 현재 폴더·하위 경로·하위 폴더 조회에 각각 위 인덱스가 사용되는지 확인합니다. 이 복잡도 설명은 처리량 재측정이나 기존 성능 병목 최적화의 완료를 의미하지 않습니다.

기존 파일과 기존 클라이언트

이전 데이터는 소유 서비스를 추측하지 않고 namespace=legacy로 이전합니다. 초기 경로는 기존파일/{id}/{기존파일명}입니다. 이전에 허용됐지만 새 경로 규칙에 맞지 않는 이름은 안전한 기존파일이라는 마지막 요소로 표시하고 원래 메타데이터는 이전 전 백업에 보존합니다. 식별자, 원본 키, 변환본, 공개 여부, 접근 버전, 캐시 정책은 그대로입니다. 관리자가 확인한 뒤 legacy 안에서 경로를 정리할 수 있습니다. wedding으로 소유권을 옮기는 기능은 이번 범위에서 제공하지 않습니다.

새 필드가 없는 이전 클라이언트는 API 키의 서버 기본 namespace에 저장합니다. path를 생략하면 uploads/{새 id}/{filename}으로 만듭니다. 따라서 이전처럼 동일한 filename을 여러 번 업로드하는 클라이언트도 서로 다른 식별자와 논리 경로를 얻습니다. 이번 배포에서 기존 주 키의 기본은 legacy이고 새 Wedding 키의 기본은 wedding입니다.

데이터 이전과 되돌리기

1. 배포 전에 backup_metadata.py로 실행 중인 SQLite의 일관된 백업을 SeaweedFS 비공개 _metadata/에 만들고 다시 내려받아 SHA-256과 무결성을 확인합니다. 기존 이미지 식별자, 실행 소스, 파일 기록과 게시 파일 SHA-256도 기록합니다.

2. 새 API와 작업자는 공통 schema.lock을 획득한 순서대로 초기화합니다. 열 추가·기존 행 분류·인덱스·폴더 소속과 집계 생성은 한 데이터베이스 트랜잭션입니다. 실패하면 커밋되지 않은 변경을 롤백하고 시작을 실패 처리합니다. 원본과 게시 파일은 건드리지 않습니다. 같은 새 버전으로 재시작하면 이미 완료한 이전을 반복하지 않습니다.

3. 기존 Deployment가 Recreate 방식이므로 배포 시 짧은 중단이 있습니다. Pod가 교체되면 tmpfs 사본은 사라집니다. 이것은 배포 재시작의 특성입니다. 배포 뒤 파일·폴더의 논리 이동 자체는 RAM 사본을 유지하며, 이 두 상황을 구분해야 합니다.

4. 되돌리기는 단순히 옛 이미지로 바꾸는 작업이 아닙니다. 옛 이미지는 namespace 권한과 경로 예약을 이해하지 않습니다. 새 데이터베이스는 빈 경로 삽입을 거절하는 트리거를 두어 옛 작성자가 미분류 파일을 추가하지 못하게 합니다.

5. 이전 전 백업으로 되돌려야 한다면 미디어의 외부 진입을 유지보수 상태로 전환하고 API·작업자를 모두 중지합니다. PVC를 연결한 별도 유지보수 컨테이너에서 백업 객체를 로컬 파일로 내려받아 기록된 SHA-256을 확인합니다. python metadata_guard.py check /data/backups/이전전.sqlite3으로 검사합니다. 도구는 이전 백업의 모든 미디어 열과 현재 행을 비교합니다. 신규 업로드·상태 변경·이름 변경·이동 등 승인된 쓰기가 있으면 자동 복구를 거절합니다. 이때 현재 데이터베이스를 보존한 채 수정 버전으로 전진 배포하는 것이 기본입니다.

6. 검사에서 rollbackAllowed=true일 때만 같은 유지보수 컨테이너에서 python metadata_guard.py restore-offline /data/backups/이전전.sqlite3을 실행합니다. 도구는 API·작업자 잠금을 독점 획득해야 하며, 현재 데이터베이스의 추가 안전 사본을 만든 뒤 복구합니다. 원본·SSD 파일은 삭제하지 않습니다. 옛 버전은 서비스별 권한을 보장하지 못하므로 공개 API를 다시 열지 않은 상태에서 복구 범위와 새 버전 재배포를 검토합니다.

새 업로드를 받은 뒤 옛 백업을 덮어써서 승인된 작업을 없애는 무조건적 자동 되돌리기는 제공하지 않습니다. 백업은 같은 ML350의 SeaweedFS에 있으므로 서버 전체 손실을 견디는 외부 백업과도 구분합니다.

1.3.0의 다운로드 성능과 관측 기능

파일 식별자와 기존 다운로드 주소, namespace 권한, 비공개 서명 규칙은 유지됩니다. 관리 화면의 로그인 정책도 유지됩니다. 새 API GET /v1/performance는 백엔드 키로 다운로드 승인 구간의 누적 시간과 SQLite 연결 재사용 횟수를 조회합니다. 실제 본문 전송 시간은 Nginx와 클라이언트에서 별도로 측정해야 합니다. 익명 /admin/api/state에도 같은 식별자 없는 전체 집계가 포함됩니다. 파일별 경로·키·토큰은 이 집계에 포함하지 않습니다.

다운로드 보호 한도는 전체 미디어 요청 256개, 그 안에서 영상 요청 32개입니다. 한도 초과는 429로 응답하며 업로드·관리·상태 확인 요청은 이 다운로드 연결 수에 포함하지 않습니다. 이는 서버의 검증된 사용자 수나 초당 요청 수를 보장하는 숫자가 아닙니다. 반복 재시도는 지수적으로 지연하고, 첫 화면의 이미지 요청 수도 제한하세요.

SQLite 연결은 최대 16개를 독점 대여하여 재사용합니다. 메타데이터 캐시는 MEDIA_METADATA_CACHE=1일 때만 켜지며 연결당 최대 128개 기록을 보관합니다. 각 조회에서 SQLite data_version을 검사해 다른 연결·프로세스의 변경을 반영하고, 같은 연결의 쓰기 후에는 자체 캐시도 비웁니다. 만료 시간만으로 공개 여부를 신뢰하지 않습니다. 캐시 사용 여부의 최종 운영 결정과 성능은 개선 보고서에 기록합니다.

브라우저에서 imageUrlFor(media, 'list')는 썸네일, 'detail'은 상세 이미지, 'zoom'은 확대용 이미지를 선택합니다. 영상에는 포스터 주소를 반환합니다. 목록 API가 비공개 파일의 URL을 숨긴 경우 null을 반환하며 비공개 서명 URL을 만들어 주지는 않습니다. 상태 API의 비공개 URL도 별도 접근 서명이 필요하다는 기존 규칙을 따릅니다.

import { imageUrlFor, loadImageBatch } from '/sdk/media-client.mjs';
// items는 애플리케이션 백엔드에서 받은, 현재 페이지의 공개 미디어입니다.
const urls = items.map(item => imageUrlFor(item, 'list')).filter(Boolean);
const results = await loadImageBatch(urls, { concurrency: 4 });
for (const result of results) {
  if (result.status === 'loaded') {
    result.image.alt = '업체 사진';
    document.querySelector('#gallery').append(result.image);
  }
}

loadImageBatch는 브라우저 전용이며 최대 50개 URL, 동시 요청 1~6개를 받습니다. 기본 동시 요청은 4개입니다. 결과 배열은 입력 순서를 유지하고 각 파일의 loaded/error/aborted 상태를 반환합니다. AbortSignal도 받을 수 있습니다. 반환한 Image 요소를 화면에 붙이면 같은 주소를 즉시 다시 요청하는 작업을 피할 수 있습니다. 첫 화면 밖 사진은 이 묶음에 넣지 않고 필요할 때 lazy 이미지로 요청하세요. API 키를 이 함수나 브라우저 코드에 넣지 마세요.

RAM 상태에는 pressureCauses, pressureObservedAt, protectionThresholds, protectionEvents가 추가됩니다. 최근 보호 사건은 프로세스당 최대 32개이며 호스트 가용 메모리, 컨테이너 메모리 한도, PSI 대기 수치, 메모리 이벤트 증가를 구분합니다. 보호 임계값과 정상 상태 5분 유지 조건, 8GiB 목표·24GiB 상한·파일당 64MiB 제한은 이 관측 개선 때문에 완화되지 않습니다.