임동욱 임동욱 08-20
feat: 금융쇼츠 영상을 미들웨어 밖 스트리밍 라우트로 올린다
10MB 벽의 원인이 Server Action이 아니었다. **미들웨어(`proxy.ts`)를 지나는 요청은 본문이
10MiB에서 잘린다** — 30MB를 보내면 라우트에 10,485,472바이트만 도달하고, 같은 경로를 matcher에서
빼면 31,457,478바이트가 전부 도달한다(curl 측정). 잘려도 오류가 아니라 조용히 끝나서 업로드가
성공한 것처럼 실패한다. 그동안 `bodySizeLimit`을 올려도 낫지 않던 `Unexpected end of form`이
이것이었다.

그래서 영상은 Server Action(페이지 URL로 POST → 반드시 미들웨어 통과)을 쓰지 않고, matcher에서
제외한 `/contents/upload`로 브라우저가 직접 올린다. 라우트는 본문을 파싱하지 않고 백엔드로 그대로
흘려보낸다 — 여기서 버퍼링하면 같은 문제를 다시 만드는 셈이다. 토큰이 httpOnly 세션 안에만 있어
브라우저가 백엔드를 직접 부를 수 없으므로 이 중계가 필요하다.

인증은 느슨해지지 않는다. 미들웨어의 쿠키 확인은 원래 낙관적 체크이고 최종 판단은 DAL의
`verifySession()`이며, 이 라우트도 그것으로 시작한다(확인: 세션 없이 POST → 303 /login,
GET → 405로 라우트까지는 도달, 다른 경로는 그대로 307 /login).

상한은 백엔드 `multipartResolver.maxUploadSize`와 같은 100MB로 잡았다. 썸네일·게시판 첨부는
Server Action 경로 그대로라 10MB 가드를 유지한다.

확장자는 저장소 설정만으로 부족했다 — `Globals.fileUpload.Extensions`가 먼저 거른다.
`MODULE_VOD_CONTENT`가 mov·avi·mkv·webm·wmv를 허용해도 실제로 통과하는 것은 mp4뿐이라
두 목록의 교집합을 화면 accept와 사전 검증이 함께 본다(확인: 영상 칸 accept=".mp4").

Co-Authored-By: Claude Opus 5 
@9194d072c4cb8c29308c7d91a4dcb0098eb64b53
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -42,6 +42,8 @@
 const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 const UPLOAD_FAILED_MESSAGE = '파일을 업로드하지 못했습니다.';
+const VIDEO_UPLOADING_MESSAGE =
+  '영상을 올리는 중입니다. 업로드가 끝난 뒤 저장해 주세요.';
 const OVERSIZE_MESSAGE = `첨부파일은 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`;
 
 function readString(formData: FormData, key: string): string {
@@ -110,6 +112,10 @@
 ): Promise<ContentFormState> {
   await verifySession();
 
+  if (readString(formData, 'videoUploading') === '1') {
+    return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
+  }
+
   const validation = validateShortsForm({
     schoolLevel: readString(formData, 'schoolLevel'),
     grades: readGrades(formData),
@@ -128,11 +134,6 @@
   try {
     const values = {
       ...validation.values,
-      videoFileId: await resolveFileId(
-        readFiles(formData, 'videoFile')[0] ?? null,
-        validation.values.videoFileId,
-        FILE_MODULE.vodContent
-      ),
       thumbnailFileId: await resolveFileId(
         readFiles(formData, 'thumbnailFile')[0] ?? null,
         validation.values.thumbnailFileId,
@@ -159,6 +160,10 @@
   await verifySession();
 
   const cntntsSn = readString(formData, 'cntntsSn');
+  if (readString(formData, 'videoUploading') === '1') {
+    return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
+  }
+
   const validation = validateShortsForm({
     schoolLevel: readString(formData, 'schoolLevel'),
     grades: readGrades(formData),
@@ -180,11 +185,6 @@
   try {
     const values = {
       ...validation.values,
-      videoFileId: await resolveFileId(
-        readFiles(formData, 'videoFile')[0] ?? null,
-        validation.values.videoFileId,
-        FILE_MODULE.vodContent
-      ),
       thumbnailFileId: await resolveFileId(
         readFiles(formData, 'thumbnailFile')[0] ?? null,
         validation.values.thumbnailFileId,
 
app/(protected)/(basic)/contents/_components/content-video-field.tsx (added)
+++ app/(protected)/(basic)/contents/_components/content-video-field.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { useState } from 'react';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import {
+  FILE_MODULE,
+  MAX_STREAM_UPLOAD_BYTES,
+  allowedExtensions,
+  fileModuleAccept,
+  isAllowedFileExtension,
+} from '@/lib/domain/file-module';
+
+const UPLOAD_ENDPOINT = `/contents/upload?module=${FILE_MODULE.vodContent.id}`;
+
+interface ContentVideoFieldProps {
+  defaultFileId?: string | null;
+}
+
+/**
+ * 영상 콘텐츠 — 고르는 즉시 스트리밍 라우트로 올리고 파일 ID만 폼에 싣는다.
+ *
+ * 영상은 Server Action의 multipart 한도(10MB)를 넘기 때문에 폼 제출에 파일을 실을 수 없다.
+ * 업로드가 끝나기 전에 저장하면 영상 없이 저장되므로 그동안은 `videoUploading`을 실어 보내
+ * Server Action이 막게 한다.
+ */
+export function ContentVideoField({ defaultFileId }: ContentVideoFieldProps) {
+  const [fileId, setFileId] = useState(defaultFileId ?? '');
+  const [isUploading, setIsUploading] = useState(false);
+  const [error, setError] = useState('');
+
+  async function upload(picked: File[]) {
+    const file = picked[0];
+    if (!file) {
+      return;
+    }
+
+    setError('');
+
+    if (!isAllowedFileExtension(FILE_MODULE.vodContent, file.name)) {
+      setError(
+        `${allowedExtensions(FILE_MODULE.vodContent).join(', ')} 파일만 올릴 수 있습니다.`
+      );
+      return;
+    }
+    if (file.size > MAX_STREAM_UPLOAD_BYTES) {
+      setError(
+        `${Math.floor(MAX_STREAM_UPLOAD_BYTES / 1_000_000)}MB 이하만 올릴 수 있습니다.`
+      );
+      return;
+    }
+
+    const body = new FormData();
+    body.set('file', file);
+
+    setIsUploading(true);
+    try {
+      const response = await fetch(UPLOAD_ENDPOINT, { method: 'POST', body });
+      const payload = (await response.json()) as { id?: string; message?: string };
+      if (!response.ok || !payload.id) {
+        setError(payload.message ?? '영상을 업로드하지 못했습니다.');
+        return;
+      }
+      setFileId(payload.id);
+    } catch {
+      setError('영상을 업로드하지 못했습니다.');
+    } finally {
+      setIsUploading(false);
+    }
+  }
+
+  return (
+    <>
+      <FoxFileUpload
+        mode="default"
+        accept={fileModuleAccept(FILE_MODULE.vodContent)}
+        selectLabel="파일선택"
+        placeholder="파일을 선택해 주세요."
+        disabled={isUploading}
+        onSelect={upload}
+        onRemove={() => setFileId('')}
+      />
+      <input type="hidden" name="videoFileId" value={fileId} />
+      {isUploading && <input type="hidden" name="videoUploading" value="1" />}
+
+      {isUploading && (
+        <FoxHelperText type="information" message="영상을 올리는 중입니다…" />
+      )}
+      {error && <FoxHelperText type="danger" message={error} />}
+    </>
+  );
+}
app/(protected)/(basic)/contents/shorts/_components/shorts-form.tsx
--- app/(protected)/(basic)/contents/shorts/_components/shorts-form.tsx
+++ app/(protected)/(basic)/contents/shorts/_components/shorts-form.tsx
@@ -24,6 +24,7 @@
 import { ContentFormRow } from '../../_components/content-form-row';
 import { ContentGradeField } from '../../_components/content-grade-field';
 import { ContentKeywordField } from '../../_components/content-keyword-field';
+import { ContentVideoField } from '../../_components/content-video-field';
 import { ContentVisibilityField } from '../../_components/content-visibility-field';
 import styles from '../../_components/content-form.module.scss';
 
@@ -117,18 +118,7 @@
         </ContentFormRow>
 
         <ContentFormRow label="영상 콘텐츠 ID">
-          <FoxFileUpload
-            mode="default"
-            name="videoFile"
-            accept={fileModuleAccept(FILE_MODULE.vodContent)}
-            selectLabel="파일선택"
-            placeholder="파일을 선택해 주세요."
-          />
-          <input
-            type="hidden"
-            name="videoFileId"
-            defaultValue={item?.attachmentId ?? ''}
-          />
+          <ContentVideoField defaultFileId={item?.attachmentId} />
         </ContentFormRow>
 
         <ContentFormRow label="썸네일">
 
app/(protected)/(basic)/contents/upload/route.ts (added)
+++ app/(protected)/(basic)/contents/upload/route.ts
@@ -0,0 +1,68 @@
+import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
+import { backendFetchUpload } from '@/lib/http/backend-fetch';
+import {
+  MAX_STREAM_UPLOAD_BYTES,
+  findFileModule,
+} from '@/lib/domain/file-module';
+
+/**
+ * 큰 파일 업로드 중계 — 브라우저 → 이 라우트 → 백엔드로 본문을 그대로 흘려보낸다.
+ *
+ * Server Action을 쓰지 않는 이유는 그 경로의 multipart가 10MB 부근에서 잘리기 때문이다
+ * (`MAX_ATTACHMENT_BYTES` 주석). 브라우저가 백엔드를 직접 부를 수는 없다 — 토큰이 httpOnly
+ * 세션 안에만 있어서 여기서 붙여 준다.
+ */
+
+const UPLOAD_PATH = '/api/v1/common/file/upload';
+
+function fail(status: number, message: string): Response {
+  return Response.json({ message }, { status });
+}
+
+export async function POST(request: Request) {
+  await verifySession();
+
+  const moduleId = new URL(request.url).searchParams.get('module') ?? '';
+  const storage = findFileModule(moduleId);
+  if (!storage) {
+    return fail(400, '알 수 없는 저장소입니다.');
+  }
+
+  const contentType = request.headers.get('content-type') ?? '';
+  if (!contentType.startsWith('multipart/form-data')) {
+    return fail(400, '요청 형식이 올바르지 않습니다.');
+  }
+
+  const declaredLength = Number(request.headers.get('content-length') ?? '');
+  if (Number.isFinite(declaredLength) && declaredLength > MAX_STREAM_UPLOAD_BYTES) {
+    return fail(
+      413,
+      `${Math.floor(MAX_STREAM_UPLOAD_BYTES / 1_000_000)}MB 이하만 올릴 수 있습니다.`
+    );
+  }
+
+  if (!request.body) {
+    return fail(400, '파일이 없습니다.');
+  }
+
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetchUpload<unknown>(
+    `${UPLOAD_PATH}/${storage.id}`,
+    {
+      body: request.body,
+      contentType,
+      accessToken: accessToken ?? undefined,
+    }
+  );
+
+  if (!result.ok) {
+    return fail(502, result.message);
+  }
+
+  if (typeof result.data !== 'string' || result.data === '') {
+    return fail(502, '업로드 응답에 파일 식별자가 없습니다.');
+  }
+
+  return Response.json({ id: result.data });
+}
lib/domain/file-module.ts
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
@@ -70,19 +70,55 @@
 // 기본 저장소로 폴백해 저장된다 — 백엔드에 모듈 추가를 요청하고 그 id로 교체한다.
 export const BOARD_ATTACHMENT_MODULE_ID = 'MODULE_BBS';
 
-/** 확장자 목록 → `<input accept>` 값. 화면이 백엔드 허용 목록을 직접 베끼지 않게 한다. */
-export function fileModuleAccept(module: FileModule): string {
-  return module.extensions.map((extension) => `.${extension}`).join(',');
+/**
+ * 업로드 전체에 걸린 확장자 화이트리스트 — 백엔드 `Globals.fileUpload.Extensions`.
+ * `EgovMultipartResolver`가 저장소 설정보다 **먼저** 이 목록으로 거른다.
+ *
+ * 이슈: 저장소 설정이 허용해도 여기 없으면 못 올린다. `MODULE_VOD_CONTENT`는 mov·avi·mkv·
+ *   webm·wmv를 허용하지만 실제로 통과하는 것은 mp4뿐이고, svg도 마찬가지로 막힌다.
+ *   백엔드에 이 목록 확장을 요청하면 그때 여기만 늘리면 된다.
+ */
+const GLOBAL_UPLOAD_EXTENSIONS: readonly string[] = [
+  'gif', 'jpg', 'jpeg', 'png',
+  'xls', 'xlsx', 'ppt', 'pptx', 'doc', 'docx', 'hwp', 'pdf',
+  'mp4', 'mp3', 'txt', 'html', 'htm',
+];
+
+/** 실제로 통과하는 확장자 — 저장소 설정과 전역 화이트리스트의 교집합이다. */
+export function allowedExtensions(module: FileModule): string[] {
+  return module.extensions.filter((extension) =>
+    GLOBAL_UPLOAD_EXTENSIONS.includes(extension)
+  );
 }
 
-/** 파일명의 확장자가 저장소 허용 목록에 있는지. 백엔드가 사유 없이 실패하기 전에 먼저 잡는다. */
+/** 확장자 목록 → `<input accept>` 값. 화면이 백엔드 허용 목록을 직접 베끼지 않게 한다. */
+export function fileModuleAccept(module: FileModule): string {
+  return allowedExtensions(module)
+    .map((extension) => `.${extension}`)
+    .join(',');
+}
+
+/** 파일명의 확장자가 허용 목록에 있는지. 백엔드가 사유 없이 실패하기 전에 먼저 잡는다. */
 export function isAllowedFileExtension(
   module: FileModule,
   fileName: string
 ): boolean {
   const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
-  return module.extensions.includes(extension);
+  return allowedExtensions(module).includes(extension);
 }
 
-/** 첨부파일 상한. 화면(사전 차단)과 Server Action(재검증)이 같은 값을 본다. */
+export function findFileModule(id: string): FileModule | undefined {
+  return Object.values(FILE_MODULE).find((module) => module.id === id);
+}
+
+/**
+ * Server Action을 지나는 첨부파일의 상한.
+ *
+ * 이슈: 미들웨어(`proxy.ts`)를 지나는 요청은 본문이 10MiB에서 잘린다(측정치는 그 파일 주석
+ *   참조). Server Action은 페이지 URL로 POST되어 반드시 미들웨어를 지나므로 이 한도를 피할 수
+ *   없다. 더 큰 파일은 미들웨어에서 제외한 스트리밍 라우트(`/contents/upload`)로 보낸다.
+ */
 export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
+
+/** 스트리밍 업로드 상한 — 백엔드 `multipartResolver`의 `maxUploadSize`(100,000,000)와 같은 값. */
+export const MAX_STREAM_UPLOAD_BYTES = 100_000_000;
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -20,6 +20,9 @@
 
 const REQUEST_TIMEOUT_MS = 10_000;
 export const COMMUNICATION_ERROR_CODE = -1;
+
+/** 큰 파일을 흘려보내는 동안 끊기지 않게 넉넉히 잡는다. */
+const UPLOAD_STREAM_TIMEOUT_MS = 10 * 60_000;
 const COMMUNICATION_ERROR_MESSAGE =
   '서버와 통신할 수 없습니다. 잠시 후 다시 시도해 주세요.';
 
@@ -326,3 +329,61 @@
   // 값이므로(그리고 그런 호출부는 보통 `backendFetch<null>(...)`로 T=null을 지정한다) 단언한다.
   return { ok: true, data: envelope.data as T };
 }
+
+/**
+ * 스트리밍 업로드 — 브라우저가 보낸 multipart 본문을 **읽지 않고** 백엔드로 그대로 흘려보낸다.
+ *
+ * Server Action의 multipart는 10MB 부근에서 잘리므로(`MAX_ATTACHMENT_BYTES` 주석 참조) 큰 파일은
+ * 이 경로로 보낸다. 여기서 본문을 버퍼링하면 같은 문제를 다시 만드는 셈이라 파싱하지 않는다.
+ */
+export async function backendFetchUpload<T>(
+  path: string,
+  init: {
+    body: ReadableStream<Uint8Array>;
+    /** 브라우저가 보낸 값 그대로여야 한다 — multipart 경계 문자열이 여기 들어 있다. */
+    contentType: string;
+    accessToken?: string;
+    timeoutMs?: number;
+  }
+): Promise<BackendResult<T>> {
+  let response: Response;
+  try {
+    response = await fetch(resolveUrl(path, undefined), {
+      method: 'POST',
+      headers: {
+        'Content-Type': init.contentType,
+        ...(init.accessToken
+          ? { Authorization: `Bearer ${init.accessToken}` }
+          : {}),
+      },
+      body: init.body,
+      // 스트림 본문은 본문을 다 보내기 전에 응답을 받을 수 있어야 한다(undici 요구사항).
+      duplex: 'half',
+      signal: AbortSignal.timeout(init.timeoutMs ?? UPLOAD_STREAM_TIMEOUT_MS),
+      cache: 'no-store',
+    } as RequestInit);
+  } catch (error) {
+    return communicationError('업로드 실패(네트워크·타임아웃)', error);
+  }
+
+  const envelope = await readEnvelopeSafely(response);
+
+  if (!response.ok || !envelope || envelope.success !== true) {
+    if (envelope && typeof envelope.code === 'number') {
+      return {
+        ok: false,
+        code: envelope.code,
+        message:
+          typeof envelope.message === 'string' && envelope.message
+            ? envelope.message
+            : COMMUNICATION_ERROR_MESSAGE,
+      };
+    }
+    return communicationError(
+      `업로드 응답의 예상치 못한 HTTP 상태: ${response.status}`,
+      undefined
+    );
+  }
+
+  return { ok: true, data: envelope.data as T };
+}
proxy.ts
--- proxy.ts
+++ proxy.ts
@@ -55,8 +55,19 @@
  * 빼면 세션 없는 방문자의 폰트 요청이 `/login`으로 리다이렉트돼 로그인 화면만 대체 서체로
  * 그려진다.
  */
+/**
+ * `contents/upload`(큰 파일 스트리밍 업로드)는 이 목록에서 뺀다.
+ *
+ * 이슈: 미들웨어를 지나는 요청은 본문이 **10MiB에서 잘린다**(측정: 30MB 전송 → 라우트에
+ *   10,485,472바이트만 도달, 미들웨어 제외 시 31,457,478바이트 전부 도달). 잘려도 오류가 아니라
+ *   조용히 끝나서 업로드가 "성공한 것처럼" 실패한다. 그동안 Server Action의 한도로 보였던
+ *   `Unexpected end of form`도 같은 원인이다.
+ *
+ * 인증이 느슨해지지 않는다 — 이 파일의 쿠키 확인은 낙관적 체크일 뿐이고 최종 판단은 라우트
+ * 핸들러의 `verifySession()`이 한다(파일 상단 주석과 같은 규칙).
+ */
 export const config = {
   matcher: [
-    '/((?!_next/static|_next/image|favicon\\.ico|robots\\.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2|woff|ttf|otf)$).*)',
+    '/((?!contents/upload|_next/static|_next/image|favicon\\.ico|robots\\.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2|woff|ttf|otf)$).*)',
   ],
 };
Add a comment
List