임동욱 임동욱 08-20
feat: 파일 저장소 모듈을 실제 목록으로 교체하고 업로드 지점별로 갈라 쓰기
지어낸 값(`MODULE_CNTNTS`)을 쓰고 있었다. 저장소 설정에 없는 id를 보내도 업로드가 실패하지
않고 기본 저장소로 조용히 폴백하기 때문에(`EgovFileMngUtil.parseFileInf`) 지금까지 드러나지
않았을 뿐이다. 받은 실제 목록 8종으로 상수를 바꾼다.

id만 두지 않고 설명·허용 확장자를 함께 담았다. 화면의 `accept`를 `fileModuleAccept()`로 그
목록에서 만들고, Server Action이 업로드 직전에 `isAllowedFileExtension()`으로 한 번 더 본다 —
확장자가 틀리면 백엔드는 사유 없이 실패하므로 어느 파일이 왜 안 되는지 여기서 알려 준다.

업로드 지점별로 저장소가 갈린다:
- 콘텐츠 썸네일(쇼츠·만화) → MODULE_CONTENT_THUMB
- 만화 컷 이미지          → MODULE_TOON_CONTENT
- 금융쇼츠 영상           → MODULE_VOD_CONTENT
- 꾸미기 아이템 썸네일     → MODULE_USER_ITEM (기존과 같음)

꾸미기 아이템의 화면 제한은 JPG·PNG로 그대로 둔다 — 저장소는 gif·svg도 받지만 그 화면 시안이
안내문에 JPG/PNG를 못박고 있어 저장소 허용 범위를 상한으로만 쓴다.

게시판 첨부용 저장소는 목록에 없다. `MODULE_BBS`를 그대로 두되 상수를 분리하고 TODO를 달았다 —
지금은 기본 저장소로 저장되고 있다.

Co-Authored-By: Claude Opus 5 
@24149bcaf73600878cfe12290de2eaefb5f223b0
app/(protected)/(basic)/boards/_actions.ts
--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
@@ -23,7 +23,7 @@
 } from '@/lib/domain/board-post-form';
 import { BOARD_PATHS } from '@/lib/domain/board-post-query';
 import {
-  FILE_MODULE_ID,
+  BOARD_ATTACHMENT_MODULE_ID,
   MAX_ATTACHMENT_BYTES,
 } from '@/lib/domain/file-module';
 
@@ -105,7 +105,7 @@
     throw new Error(OVERSIZE_MESSAGE);
   }
 
-  return uploadAttachment(file, FILE_MODULE_ID.board);
+  return uploadAttachment(file, BOARD_ATTACHMENT_MODULE_ID);
 }
 
 /** 등록·수정이 공유하는 입력 읽기. */
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -25,7 +25,12 @@
   type ContentFormState,
 } from '@/lib/domain/content-form';
 import { contentListPath } from '@/lib/domain/content-query';
-import { FILE_MODULE_ID, MAX_ATTACHMENT_BYTES } from '@/lib/domain/file-module';
+import {
+  FILE_MODULE,
+  MAX_ATTACHMENT_BYTES,
+  isAllowedFileExtension,
+  type FileModule,
+} from '@/lib/domain/file-module';
 
 /**
  * 콘텐츠관리 Server Action — 세 종류가 같은 파일을 쓴다(폼 구조가 거의 같다).
@@ -69,10 +74,14 @@
   }));
 }
 
-/** 새로 고른 파일을 올려 파일 ID를 얻는다. 고르지 않았으면 폼이 들고 있던 기존 ID를 그대로 쓴다. */
+/**
+ * 새로 고른 파일을 알맞은 저장소에 올려 파일 ID를 얻는다. 고르지 않았으면 폼이 들고 있던 기존
+ * ID를 그대로 쓴다. 확장자는 백엔드가 사유 없이 실패하기 전에 여기서 먼저 잡는다.
+ */
 async function resolveFileId(
   file: File | null,
-  retainedId: string
+  retainedId: string,
+  module: FileModule
 ): Promise<string> {
   if (!file) {
     return retainedId;
@@ -80,7 +89,12 @@
   if (file.size > MAX_ATTACHMENT_BYTES) {
     throw new Error(OVERSIZE_MESSAGE);
   }
-  return uploadAttachment(file, FILE_MODULE_ID.content);
+  if (!isAllowedFileExtension(module, file.name)) {
+    throw new Error(
+      `${module.description}에는 ${module.extensions.join(', ')} 파일만 올릴 수 있습니다.`
+    );
+  }
+  return uploadAttachment(file, module.id);
 }
 
 function failure(error: unknown, fallback: string): ContentFormState {
@@ -116,11 +130,13 @@
       ...validation.values,
       videoFileId: await resolveFileId(
         readFiles(formData, 'videoFile')[0] ?? null,
-        validation.values.videoFileId
+        validation.values.videoFileId,
+        FILE_MODULE.vodContent
       ),
       thumbnailFileId: await resolveFileId(
         readFiles(formData, 'thumbnailFile')[0] ?? null,
-        validation.values.thumbnailFileId
+        validation.values.thumbnailFileId,
+        FILE_MODULE.contentThumbnail
       ),
     };
     await createContent(
@@ -166,11 +182,13 @@
       ...validation.values,
       videoFileId: await resolveFileId(
         readFiles(formData, 'videoFile')[0] ?? null,
-        validation.values.videoFileId
+        validation.values.videoFileId,
+        FILE_MODULE.vodContent
       ),
       thumbnailFileId: await resolveFileId(
         readFiles(formData, 'thumbnailFile')[0] ?? null,
-        validation.values.thumbnailFileId
+        validation.values.thumbnailFileId,
+        FILE_MODULE.contentThumbnail
       ),
     };
     await updateContent('shorts', cntntsSn, buildShortsPayload(values));
@@ -190,7 +208,11 @@
 
   const ids: string[] = [];
   for (const [index, file] of files.entries()) {
-    const id = await resolveFileId(file, retained[index] ?? '');
+    const id = await resolveFileId(
+      file,
+      retained[index] ?? '',
+      FILE_MODULE.toonContent
+    );
     if (id) {
       ids.push(id);
     }
@@ -210,7 +232,8 @@
     cutFileIds = await resolveCutFileIds(formData);
     thumbnailFileId = await resolveFileId(
       readFiles(formData, 'thumbnailFile')[0] ?? null,
-      readString(formData, 'thumbnailFileId')
+      readString(formData, 'thumbnailFileId'),
+      FILE_MODULE.contentThumbnail
     );
   } catch (error) {
     return failure(error, UPLOAD_FAILED_MESSAGE);
@@ -262,7 +285,8 @@
     cutFileIds = await resolveCutFileIds(formData);
     thumbnailFileId = await resolveFileId(
       readFiles(formData, 'thumbnailFile')[0] ?? null,
-      readString(formData, 'thumbnailFileId')
+      readString(formData, 'thumbnailFileId'),
+      FILE_MODULE.contentThumbnail
     );
   } catch (error) {
     return failure(error, UPLOAD_FAILED_MESSAGE);
app/(protected)/(basic)/contents/_components/content-cuts-field.tsx
--- app/(protected)/(basic)/contents/_components/content-cuts-field.tsx
+++ app/(protected)/(basic)/contents/_components/content-cuts-field.tsx
@@ -6,6 +6,7 @@
 import { FoxTextButton } from '@fox/core/components/fox-text-button';
 import { FoxPlusIcon, FoxTrashIcon } from '@fox/core/icons';
 import { MIN_COMIC_CUTS } from '@/lib/domain/content';
+import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
 import styles from './content-form.module.scss';
 
 interface ContentCutsFieldProps {
@@ -57,7 +58,7 @@
             <FoxFileUpload
               mode="image"
               name="cutFile"
-              accept="image/jpeg,image/png"
+              accept={fileModuleAccept(FILE_MODULE.toonContent)}
               selectLabel="파일선택"
               thumbnailWidth={132}
               thumbnailHeight={74}
app/(protected)/(basic)/contents/comics/_components/comic-form.tsx
--- app/(protected)/(basic)/contents/comics/_components/comic-form.tsx
+++ app/(protected)/(basic)/contents/comics/_components/comic-form.tsx
@@ -15,6 +15,7 @@
   TITLE_MAX_LENGTH,
 } from '@/lib/domain/content-form';
 import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
+import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
 import { createComicAction, updateComicAction } from '../../_actions';
 import { ContentCutsField } from '../../_components/content-cuts-field';
 import { ContentFormActions } from '../../_components/content-form-actions';
@@ -117,7 +118,7 @@
           <FoxFileUpload
             mode="image"
             name="thumbnailFile"
-            accept="image/jpeg,image/png"
+            accept={fileModuleAccept(FILE_MODULE.contentThumbnail)}
             selectLabel="파일선택"
             description={THUMBNAIL_HELP_TEXT}
             thumbnailWidth={213}
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
@@ -18,6 +18,7 @@
   TITLE_MAX_LENGTH,
 } from '@/lib/domain/content-form';
 import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
+import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
 import { createShortsAction, updateShortsAction } from '../../_actions';
 import { ContentFormActions } from '../../_components/content-form-actions';
 import { ContentFormRow } from '../../_components/content-form-row';
@@ -119,7 +120,7 @@
           <FoxFileUpload
             mode="default"
             name="videoFile"
-            accept="video/*"
+            accept={fileModuleAccept(FILE_MODULE.vodContent)}
             selectLabel="파일선택"
             placeholder="파일을 선택해 주세요."
           />
@@ -134,7 +135,7 @@
           <FoxFileUpload
             mode="image"
             name="thumbnailFile"
-            accept="image/jpeg,image/png"
+            accept={fileModuleAccept(FILE_MODULE.contentThumbnail)}
             selectLabel="파일선택"
             description={THUMBNAIL_HELP_TEXT}
             thumbnailWidth={213}
lib/data/repositories/decoration-item-repository.ts
--- lib/data/repositories/decoration-item-repository.ts
+++ lib/data/repositories/decoration-item-repository.ts
@@ -8,7 +8,7 @@
   DecorationItemQuery,
   DecorationItemSearchField,
 } from '@/lib/domain/decoration-item-query';
-import { FILE_MODULE_ID } from '@/lib/domain/file-module';
+import { FILE_MODULE } from '@/lib/domain/file-module';
 
 /**
  * 꾸미기 아이템 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·
@@ -348,7 +348,7 @@
   multipart.set('file', file);
 
   const result = await backendFetch<string>(
-    `${FILE_UPLOAD_PATH}/${FILE_MODULE_ID.decorationItem}`,
+    `${FILE_UPLOAD_PATH}/${FILE_MODULE.userItem.id}`,
     {
       method: 'POST',
       multipart,
lib/domain/file-module.ts
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
@@ -1,13 +1,88 @@
-// TODO: 백엔드가 모듈 목록 조회 API를 만들어 주기로 함(2026-08-19) — 나오면 이 상수를 대체한다.
-// 이슈: 틀린 값이어도 업로드가 실패하지 않는다. 백엔드가 저장 설정을 못 찾으면 기본 저장소로
-// 폴백해 조용히 다른 곳에 저장한다.
-export const FILE_MODULE_ID = {
-  decorationItem: 'MODULE_USER_ITEM',
-  board: 'MODULE_BBS',
-  content: 'MODULE_CNTNTS',
-} as const;
+/**
+ * 파일 저장소 모듈 — 업로드 시 `POST /api/v1/common/file/upload/{moduleId}`의 경로 조각이자
+ * 백엔드 파일 저장 설정(`FileStrgStngVo.strgStngId`)의 키다.
+ *
+ * 값은 백엔드 저장소 설정 표에서 받은 실제 목록이다(사용자 제공, 2026-08-20).
+ *
+ * 이슈: 설정에 없는 값을 보내도 업로드가 실패하지 않는다. 백엔드가 설정을 못 찾으면 기본
+ *   저장소로 폴백해 조용히 다른 곳에 저장한다(`EgovFileMngUtil.parseFileInf`). 그래서 여기 없는
+ *   문자열을 손으로 적지 않는다.
+ */
 
-export type FileModuleId = (typeof FILE_MODULE_ID)[keyof typeof FILE_MODULE_ID];
+export type FileModule = {
+  id: string;
+  /** 저장소 설명 — 어떤 파일이 어디로 가는지 이 파일만 보고 알 수 있게 함께 둔다. */
+  description: string;
+  /** 백엔드가 허용하는 확장자. 화면의 `accept`와 사전 검증이 같은 값을 본다. */
+  extensions: readonly string[];
+};
+
+const IMAGE = ['jpg', 'png', 'gif'] as const;
+
+export const FILE_MODULE = {
+  /** 콘텐츠(금융쇼츠·4컷금융만화) 대표 이미지. */
+  contentThumbnail: {
+    id: 'MODULE_CONTENT_THUMB',
+    description: '콘텐츠 섬네일 이미지 저장소',
+    extensions: IMAGE,
+  },
+  /** 4컷금융만화의 컷 이미지. */
+  toonContent: {
+    id: 'MODULE_TOON_CONTENT',
+    description: '4컷 만화 파일 저장소',
+    extensions: IMAGE,
+  },
+  /** 금융쇼츠의 영상. */
+  vodContent: {
+    id: 'MODULE_VOD_CONTENT',
+    description: '동영상 콘텐츠 저장소',
+    extensions: ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv'],
+  },
+  /** 꾸미기 아이템 썸네일. */
+  userItem: {
+    id: 'MODULE_USER_ITEM',
+    description: '사용자 아이템 저장소',
+    extensions: ['jpg', 'png', 'gif', 'svg'],
+  },
+  menuIcon: {
+    id: 'MODULE_MENU_ICON',
+    description: '메뉴 아이콘 저장소',
+    extensions: ['jpg', 'png', 'gif', 'svg'],
+  },
+  pocket: {
+    id: 'MODULE_POCKET',
+    description: '용돈기입장 사진 저장소',
+    extensions: ['jpg', 'jpeg', 'png', 'gif'],
+  },
+  studentImage: {
+    id: 'MODULE_STUDENT_IMAGE',
+    description: '학생 원본 이미지',
+    extensions: IMAGE,
+  },
+  userProfile: {
+    id: 'MODULE_USER_PROFILE',
+    description: '회원 프로필 저장소',
+    extensions: IMAGE,
+  },
+} as const satisfies Record<string, FileModule>;
+
+// TODO: 게시판(공지사항·FAQ·1:1문의) 첨부용 저장소가 목록에 없다. 지금은 이 값이 설정에 없어
+// 기본 저장소로 폴백해 저장된다 — 백엔드에 모듈 추가를 요청하고 그 id로 교체한다.
+export const BOARD_ATTACHMENT_MODULE_ID = 'MODULE_BBS';
+
+/** 확장자 목록 → `<input accept>` 값. 화면이 백엔드 허용 목록을 직접 베끼지 않게 한다. */
+export function fileModuleAccept(module: FileModule): string {
+  return module.extensions.map((extension) => `.${extension}`).join(',');
+}
+
+/** 파일명의 확장자가 저장소 허용 목록에 있는지. 백엔드가 사유 없이 실패하기 전에 먼저 잡는다. */
+export function isAllowedFileExtension(
+  module: FileModule,
+  fileName: string
+): boolean {
+  const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
+  return module.extensions.includes(extension);
+}
 
 /** 첨부파일 상한. 화면(사전 차단)과 Server Action(재검증)이 같은 값을 본다. */
 export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
Add a comment
List