임동욱 임동욱 08-20
feat: FAQ 유형을 공통코드 BBS_FAQ_CD의 상세코드명으로 표기
유형을 프론트 상수 표(SIGNUP/PAYMENT/…)로 치환하고 있었다. 그 표를 지우고 공통코드
`BBS_FAQ_CD`를 조회해 상세코드명으로 옮긴다 — 1:1문의 진행상태(BBS_ANS_CD), 학교·학년
(COM_SCHUL_CD·GRD_CD)과 같은 방식이다.

목록의 「유형」 열과 등록·수정 팝업의 유형 선택지, Server Action의 허용 코드 검증이 모두 같은
응답을 본다. 페이지가 한 번 조회해 목록으로 내리고, Server Action은 저장 직전에 다시 조회한다
(화면이 보낸 코드를 그대로 믿지 않기 위해서다).

목록 URL의 `type` 필터만 예외로 뒀다 — `parseBoardPostQuery`는 도메인 계층이라 코드 조회를 할 수
없어 FAQ에서는 값을 그대로 받는다. 모르는 코드면 결과가 비는 것으로 끝나고, 저장 검증은 위에서
코드표로 한다.

코드가 한 건도 없으면 유형 칸을 그리지 않고 검증도 요구하지 않는다 — 빈 셀렉트를 보여 주고
"유형을 선택해 주세요"로 막으면 저장할 방법이 없어진다.

확인: 임시 페이지로 목록의 FAQ01→회원가입&로그인 치환, 표에 없는 코드(ZZZ99)는 코드 그대로
노출, 등록 팝업 선택지가 코드명으로 뜨고 고르면 hidden 필드에 코드값(FAQ01)이 실리는 것을
확인한 뒤 지웠다.

Co-Authored-By: Claude Opus 5 
@ac0ecaf1b83b23348210f534a1fb0df5a0a6398e
app/(protected)/(basic)/boards/_actions.ts
--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
@@ -57,6 +57,15 @@
 const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 
+/** FAQ 유형 허용 목록. 화면이 보낸 코드를 그대로 믿지 않기 위해 저장 직전에 다시 조회한다. */
+async function fetchFaqTypeCodes(boardType: BoardType): Promise<string[]> {
+  if (boardType !== 'faq') {
+    return [];
+  }
+  const codes = await fetchCommonCodes(CODE_GROUP.faqType);
+  return codes.map((code) => code.code);
+}
+
 async function runWrite(
   write: () => Promise<void>,
   fallback: string
@@ -149,7 +158,11 @@
 
   const validation = validateBoardPostForm(
     readPostValues(formData, attachmentId),
-    { boardType, isCategoryEditable: true }
+    {
+      boardType,
+      isCategoryEditable: true,
+      allowedTypeCodes: await fetchFaqTypeCodes(boardType),
+    }
   );
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
@@ -192,7 +205,11 @@
 
   const validation = validateBoardPostForm(
     readPostValues(formData, attachmentId),
-    { boardType, isCategoryEditable: false }
+    {
+      boardType,
+      isCategoryEditable: false,
+      allowedTypeCodes: await fetchFaqTypeCodes(boardType),
+    }
   );
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
app/(protected)/(basic)/boards/_components/board-post-columns.tsx
--- app/(protected)/(basic)/boards/_components/board-post-columns.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-columns.tsx
@@ -44,14 +44,17 @@
   };
 }
 
-function actionsColumn(boardType: BoardType): FoxListColumn<BoardPost> {
+function actionsColumn(
+  boardType: BoardType,
+  faqTypes: readonly CommonCode[] = []
+): FoxListColumn<BoardPost> {
   return {
     key: 'actions',
     header: '관리',
     width: 120,
     render: (row) => (
       <span onClick={(event) => event.stopPropagation()}>
-        <BoardRowActions boardType={boardType} post={row} />
+        <BoardRowActions boardType={boardType} post={row} faqTypes={faqTypes} />
       </span>
     ),
   };
@@ -102,7 +105,16 @@
   ];
 }
 
-function faqColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] {
+function faqColumns(
+  page: number,
+  pageSize: number,
+  faqTypes: readonly CommonCode[]
+): FoxListColumn<BoardPost>[] {
+  const typeOptions = faqTypes.map((code) => ({
+    value: code.code,
+    label: code.label,
+  }));
+
   return [
     numberColumn(page, pageSize),
     {
@@ -119,7 +131,7 @@
       key: 'type',
       header: '유형',
       width: 120,
-      render: (row) => formatFaqTypeLabel(row.typeCode),
+      render: (row) => formatFaqTypeLabel(row.typeCode, typeOptions),
     },
     { key: 'title', header: '제목', width: 320, emphasis: true },
     {
@@ -135,7 +147,7 @@
       render: (row) => formatOptionalValue(row.createdAt),
     },
     visibilityColumn(),
-    actionsColumn('faq'),
+    actionsColumn('faq', faqTypes),
   ];
 }
 
@@ -236,9 +248,10 @@
   boardType: BoardType,
   page: number,
   pageSize: number,
-  answerStatuses: readonly CommonCode[] = []
+  answerStatuses: readonly CommonCode[] = [],
+  faqTypes: readonly CommonCode[] = []
 ): FoxListColumn<BoardPost>[] {
   if (boardType === 'notice') return noticeColumns(page, pageSize);
-  if (boardType === 'faq') return faqColumns(page, pageSize);
+  if (boardType === 'faq') return faqColumns(page, pageSize, faqTypes);
   return inquiryColumns(page, pageSize, answerStatuses);
 }
app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
--- app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
@@ -12,9 +12,9 @@
 import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
 import {
   BOARD_CATEGORY_OPTIONS,
-  FAQ_TYPE_OPTIONS,
   INQUIRY_TYPE_OPTIONS,
   type BoardPost,
   type BoardType,
@@ -31,6 +31,8 @@
   boardType: BoardType;
   mode: 'create' | 'edit';
   post?: BoardPost;
+  /** 공통코드 `BBS_FAQ_CD` — FAQ의 유형 선택지. 다른 게시판에서는 쓰이지 않는다. */
+  faqTypes?: readonly CommonCode[];
   onClose: () => void;
 }
 
@@ -40,8 +42,9 @@
   faq: 'FAQ',
 };
 
-const TYPE_OPTIONS: Partial<Record<BoardType, readonly { value: string; label: string }[]>> = {
-  faq: FAQ_TYPE_OPTIONS,
+const STATIC_TYPE_OPTIONS: Partial<
+  Record<BoardType, readonly { value: string; label: string }[]>
+> = {
   inquiry: INQUIRY_TYPE_OPTIONS,
 };
 
@@ -49,6 +52,7 @@
   boardType,
   mode,
   post,
+  faqTypes = [],
   onClose,
 }: BoardPostFormModalProps) {
   const isCreate = mode === 'create';
@@ -78,7 +82,10 @@
   }, [state, showToast, onClose, isCreate]);
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
-  const typeOptions = TYPE_OPTIONS[boardType];
+  const typeOptions =
+    boardType === 'faq'
+      ? faqTypes.map((code) => ({ value: code.code, label: code.label }))
+      : STATIC_TYPE_OPTIONS[boardType];
   const hasNoticeOnlyFields = boardType === 'notice';
 
   return (
@@ -124,7 +131,7 @@
           <input type="hidden" name="categoryCode" value={categoryCode} />
         </div>
 
-        {typeOptions && (
+        {typeOptions && typeOptions.length > 0 && (
           <div className={styles.field}>
             <FoxSelect
               size="md"
app/(protected)/(basic)/boards/_components/board-post-list.tsx
--- app/(protected)/(basic)/boards/_components/board-post-list.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-list.tsx
@@ -39,6 +39,8 @@
   extraToolbar?: ReactNode;
   /** 공통코드 `BBS_ANS_CD` — 1:1문의 진행상태 표기·선택지. */
   answerStatuses?: readonly CommonCode[];
+  /** 공통코드 `BBS_FAQ_CD` — FAQ의 유형. */
+  faqTypes?: readonly CommonCode[];
 }
 
 const SORT_OPTIONS = [{ value: 'recent', label: '최근등록순' }];
@@ -55,6 +57,7 @@
   canCreate = true,
   extraToolbar,
   answerStatuses,
+  faqTypes,
 }: BoardPostListProps) {
   const router = useRouter();
   const [isCreateOpen, setIsCreateOpen] = useState(false);
@@ -74,7 +77,8 @@
           boardType,
           currentPage,
           query.pageSize,
-          answerStatuses
+          answerStatuses,
+          faqTypes
         )}
         rows={items}
         rowKey={(row) => row.id}
@@ -165,6 +169,7 @@
         <BoardPostFormModal
           boardType={boardType}
           mode="create"
+          faqTypes={faqTypes}
           onClose={() => setIsCreateOpen(false)}
         />
       )}
app/(protected)/(basic)/boards/_components/board-row-actions.tsx
--- app/(protected)/(basic)/boards/_components/board-row-actions.tsx
+++ app/(protected)/(basic)/boards/_components/board-row-actions.tsx
@@ -7,12 +7,15 @@
 import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { BoardPost, BoardType } from '@/lib/domain/board-post';
+import type { CommonCode } from '@/lib/domain/common-code';
 import { deleteBoardPostAction } from '../_actions';
 import { BoardPostFormModal } from './board-post-form-modal';
 
 interface BoardRowActionsProps {
   boardType: BoardType;
   post: BoardPost;
+  /** 공통코드 `BBS_FAQ_CD` — FAQ 수정 팝업의 유형 선택지. */
+  faqTypes?: readonly CommonCode[];
 }
 
 /**
@@ -27,7 +30,11 @@
  * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는
  * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
  */
-export function BoardRowActions({ boardType, post }: BoardRowActionsProps) {
+export function BoardRowActions({
+  boardType,
+  post,
+  faqTypes,
+}: BoardRowActionsProps) {
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isEditOpen, setIsEditOpen] = useState(false);
   const [isDeleting, startDeleting] = useTransition();
@@ -83,6 +90,7 @@
         <BoardPostFormModal
           boardType={boardType}
           mode="edit"
+          faqTypes={faqTypes}
           post={post}
           onClose={() => setIsEditOpen(false)}
         />
app/(protected)/(basic)/boards/faqs/page.tsx
--- app/(protected)/(basic)/boards/faqs/page.tsx
+++ app/(protected)/(basic)/boards/faqs/page.tsx
@@ -1,6 +1,8 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
 import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
 import { parseBoardPostQuery } from '@/lib/domain/board-post-query';
 import { BoardPostList } from '../_components/board-post-list';
 
@@ -16,6 +18,7 @@
 
   const query = parseBoardPostQuery('faq', await searchParams);
   const { items, totalCount } = await fetchBoardPosts('faq', query);
+  const faqTypes = await fetchCommonCodes(CODE_GROUP.faqType);
 
   const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
   const currentPage = Math.min(query.page, totalPages);
@@ -34,6 +37,7 @@
       currentPage={currentPage}
       totalPages={totalPages}
       totalCount={totalCount}
+      faqTypes={faqTypes}
     />
   );
 }
lib/domain/board-post-form.ts
--- lib/domain/board-post-form.ts
+++ lib/domain/board-post-form.ts
@@ -100,7 +100,12 @@
  */
 export function validateBoardPostForm(
   values: BoardPostFormValues,
-  options: { boardType: BoardType; isCategoryEditable: boolean }
+  options: {
+    boardType: BoardType;
+    isCategoryEditable: boolean;
+    /** FAQ 유형(`BBS_FAQ_CD`) 코드값. 다른 게시판에서는 쓰이지 않는다. */
+    allowedTypeCodes?: readonly string[];
+  }
 ): ValidationResult<BoardPostFormValues, BoardPostFormErrors> {
   const errors: BoardPostFormErrors = {};
 
@@ -116,15 +121,14 @@
     }
   }
 
-  // 유형은 공지사항에 없는 항목이라 해당 게시판에서만 검증한다.
-  const typeOptions = BOARD_TYPE_OPTIONS[options.boardType];
-  if (typeOptions.length > 0) {
-    const isKnownType = typeOptions.some(
-      (option) => option.value === values.typeCode
-    );
-    if (!isKnownType) {
-      errors.typeCode = '유형을 선택해 주세요.';
-    }
+  // 유형은 공지사항에 없는 항목이라 해당 게시판에서만 검증한다. FAQ는 공통코드라 허용 목록을
+  // 호출부(Server Action)가 조회해 넘긴다.
+  const allowedTypeCodes =
+    options.boardType === 'faq'
+      ? (options.allowedTypeCodes ?? [])
+      : BOARD_TYPE_OPTIONS[options.boardType].map((option) => option.value);
+  if (allowedTypeCodes.length > 0 && !allowedTypeCodes.includes(values.typeCode)) {
+    errors.typeCode = '유형을 선택해 주세요.';
   }
 
   if (!title) {
lib/domain/board-post-query.ts
--- lib/domain/board-post-query.ts
+++ lib/domain/board-post-query.ts
@@ -12,7 +12,6 @@
 
 import {
   BOARD_CATEGORY_OPTIONS,
-  FAQ_TYPE_OPTIONS,
   INQUIRY_TYPE_OPTIONS,
   type BoardType,
 } from '@/lib/domain/board-post';
@@ -63,14 +62,17 @@
   faq: { category: true, type: true, visibility: false, answered: false },
 };
 
-/** 화면별 유형 선택지. 공지사항은 유형 항목이 없다. */
+/**
+ * 화면별 유형 선택지. 공지사항은 유형 항목이 없고, FAQ는 공통코드(`BBS_FAQ_CD`)라 여기 없다 —
+ * 실행 중에 조회한 목록을 화면이 직접 들고 쓴다.
+ */
 export const BOARD_TYPE_OPTIONS: Record<
   BoardType,
   readonly BoardSearchFieldOption[]
 > = {
   notice: [],
   inquiry: INQUIRY_TYPE_OPTIONS,
-  faq: FAQ_TYPE_OPTIONS,
+  faq: [],
 };
 
 /** 사용여부 필터(공지사항) — 시안의 「전체/노출/미노출」. */
@@ -150,8 +152,12 @@
     category: fields.category
       ? readOneOf(searchParams, 'category', BOARD_CATEGORY_OPTIONS)
       : FILTER_ALL,
+    // FAQ 유형은 공통코드라 허용 목록을 여기서 알 수 없다. 값은 그대로 받고 목록을 거르는 데만
+    // 쓰며(모르는 코드면 결과가 비는 것으로 끝난다), 저장 검증은 Server Action이 코드표로 한다.
     type: fields.type
-      ? readOneOf(searchParams, 'type', BOARD_TYPE_OPTIONS[boardType])
+      ? boardType === 'faq'
+        ? (readParam(searchParams, 'type') ?? '').trim().slice(0, MAX_KEYWORD_LENGTH)
+        : readOneOf(searchParams, 'type', BOARD_TYPE_OPTIONS[boardType])
       : FILTER_ALL,
     visibility: fields.visibility
       ? readOneOf(searchParams, 'visibility', VISIBILITY_FILTER_OPTIONS)
lib/domain/board-post.ts
--- lib/domain/board-post.ts
+++ lib/domain/board-post.ts
@@ -107,16 +107,6 @@
   { value: 'FOXPAY', label: 'FOX PAY' },
 ];
 
-/** FAQ의 「유형」 — 시안 A_BOA_015_p ②. 시안에도 "추후 재정의 필요"로 적혀 있어 잠정값이다. */
-export const FAQ_TYPE_OPTIONS: readonly CodeOption[] = [
-  { value: 'SIGNUP', label: '회원가입&로그인' },
-  { value: 'PAYMENT', label: '결제관련' },
-  { value: 'STUDENT_CARD', label: '학생증' },
-  { value: 'DECO_ITEM', label: '꾸미기아이템' },
-  { value: 'MERCHANT', label: '가맹점관련' },
-  { value: 'ETC', label: '기타' },
-];
-
 /** 1:1문의의 「질문유형」 — 시안 A_BOA_012. 잠정값이다. */
 export const INQUIRY_TYPE_OPTIONS: readonly CodeOption[] = [
   { value: 'SIGNUP', label: '회원가입/로그인' },
@@ -143,8 +133,12 @@
   return formatCode(BOARD_CATEGORY_OPTIONS, code);
 }
 
-export function formatFaqTypeLabel(code: string | null): string {
-  return formatCode(FAQ_TYPE_OPTIONS, code);
+/** FAQ의 「유형」 — 공통코드 `BBS_FAQ_CD`. 코드표는 호출부가 조회해 넘긴다. */
+export function formatFaqTypeLabel(
+  code: string | null,
+  options: readonly CodeOption[]
+): string {
+  return formatCode(options, code);
 }
 
 export function formatInquiryTypeLabel(code: string | null): string {
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -23,6 +23,8 @@
   decorationItemCategory: 'ITEM_CATE_CD',
   /** 1:1문의 진행상태. */
   inquiryAnswerStatus: 'BBS_ANS_CD',
+  /** FAQ 유형. */
+  faqType: 'BBS_FAQ_CD',
   /** 학교(학교급). */
   school: 'COM_SCHUL_CD',
   /** 학년. 학교마다 다르므로 `atrbNm`에 학교 코드를 넣어 조회한다. */
Add a comment
List