임동욱 임동욱 09-03
feat: OX퀴즈 정답여부 선택지를 QUIZ_ANSWER 상세코드 코드명으로 받는다
하드코딩 O·X 대신 공통코드 QUIZ_ANSWER의 상세코드 목록을 조회해 코드명(cdNm)을
그대로 선택지·저장 값(문자열)으로 쓴다. 폼 필드명·노출 문구 줄도 선택지 수를 따른다.

Co-Authored-By: Claude Fable 5 
@f2af4f282489d775b30b84572fddbc40d96da546
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -4,7 +4,10 @@
 import { redirect, unstable_rethrow } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
 import { BackendRequestError } from '@/lib/http/backend-fetch';
-import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
+import {
+  fetchQuizAnswerNames,
+  fetchSchoolGradeCodes,
+} from '@/lib/data/repositories/common-code-repository';
 import {
   uploadAttachment,
   uploadAttachments,
@@ -21,11 +24,7 @@
   deleteQuiz,
   updateQuiz,
 } from '@/lib/data/repositories/content-quiz-repository';
-import {
-  CONTENT_TYPE_CODE,
-  QUIZ_ANSWER_OPTIONS,
-  type ContentKind,
-} from '@/lib/domain/content';
+import { CONTENT_TYPE_CODE, type ContentKind } from '@/lib/domain/content';
 import {
   ContentInputError,
   validateComicForm,
@@ -174,7 +173,7 @@
   };
 }
 
-function readQuizValues(formData: FormData) {
+function readQuizValues(formData: FormData, answerOptions: readonly string[]) {
   return {
     schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
@@ -182,8 +181,8 @@
     keywords: readKeywords(formData),
     question: readString(formData, 'question'),
     answer: readString(formData, 'answer'),
-    // O·X 두 줄을 각각 읽는다 — 정답이 아닌 쪽도 함께 보낸다.
-    answerMessages: QUIZ_ANSWER_OPTIONS.map((answer) => ({
+    // 정답 선택지 줄을 각각 읽는다 — 정답이 아닌 쪽도 함께 보낸다.
+    answerMessages: answerOptions.map((answer) => ({
       answer,
       code: readString(formData, `answerMessage${answer}Code`),
       text: readString(formData, `answerMessage${answer}Text`),
@@ -390,9 +389,11 @@
   await verifySession();
 
   const failed = await runWrite(async () => {
+    const answerOptions = await fetchQuizAnswerNames();
     const validation = validateQuizForm(
-      readQuizValues(formData),
-      await fetchSchoolGradeCodes()
+      readQuizValues(formData, answerOptions),
+      await fetchSchoolGradeCodes(),
+      answerOptions
     );
     if (!validation.ok) {
       return { status: 'error', errors: validation.errors };
@@ -421,9 +422,11 @@
   }
 
   const failed = await runWrite(async () => {
+    const answerOptions = await fetchQuizAnswerNames();
     const validation = validateQuizForm(
-      readQuizValues(formData),
-      await fetchSchoolGradeCodes()
+      readQuizValues(formData, answerOptions),
+      await fetchSchoolGradeCodes(),
+      answerOptions
     );
     if (!validation.ok) {
       return { status: 'error', errors: validation.errors };
app/(protected)/(basic)/contents/quizzes/[quizId]/page.tsx
--- app/(protected)/(basic)/contents/quizzes/[quizId]/page.tsx
+++ app/(protected)/(basic)/contents/quizzes/[quizId]/page.tsx
@@ -1,7 +1,10 @@
 import type { Metadata } from 'next';
 import { notFound } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
-import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
+import {
+  fetchQuizAnswerNames,
+  fetchSchoolGradeCodes,
+} from '@/lib/data/repositories/common-code-repository';
 import { fetchQuizDetail } from '@/lib/data/repositories/content-quiz-repository';
 import { QuizForm } from '../_components/quiz-form';
 
@@ -15,7 +18,10 @@
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
-  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
+  const [{ schools, gradesBySchool }, answerOptions] = await Promise.all([
+    fetchSchoolGradeCodes(),
+    fetchQuizAnswerNames(),
+  ]);
 
   const { quizId } = await params;
   const item = await fetchQuizDetail(quizId);
@@ -23,5 +29,12 @@
     notFound();
   }
 
-  return <QuizForm schools={schools} gradesBySchool={gradesBySchool} item={item} />;
+  return (
+    <QuizForm
+      schools={schools}
+      gradesBySchool={gradesBySchool}
+      answerOptions={answerOptions}
+      item={item}
+    />
+  );
 }
app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
--- app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
+++ app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
@@ -12,7 +12,6 @@
 import {
   ANSWER_MESSAGE_ETC,
   ANSWER_MESSAGE_OPTIONS,
-  QUIZ_ANSWER_OPTIONS,
   QUIZ_LEVEL_OPTIONS,
   type QuizItem,
   type GradesBySchool,
@@ -36,29 +35,37 @@
 interface QuizFormProps {
   schools: readonly CommonCode[];
   gradesBySchool: GradesBySchool;
+  /** 공통코드 `QUIZ_ANSWER` 상세코드의 코드명 — 이 문자열이 그대로 저장된다. */
+  answerOptions: readonly string[];
   item?: QuizItem;
 }
 
 /**
  * 콘텐츠 등록·수정 (금융OX퀴즈) — 시안 5402:12360.
  *
- * 정답여부는 O·X 두 줄이고 각 줄이 「사용자에게 노출할 문구」를 갖는다. 두 문구 모두
- * 백엔드로 보낸다 — 정답이 아닌 쪽에도 보여 줄 문구가 필요하기 때문이다(사용자 확정).
- * 「기타」를 고르면 문구를 직접 적는다. 앞의 라디오는 어느 쪽이 정답인지만 정한다.
+ * 정답여부는 선택지(공통코드 `QUIZ_ANSWER`)마다 한 줄이고 각 줄이 「사용자에게 노출할
+ * 문구」를 갖는다. 문구는 모두 백엔드로 보낸다 — 정답이 아닌 쪽에도 보여 줄 문구가 필요하기
+ * 때문이다(사용자 확정). 「기타」를 고르면 문구를 직접 적는다. 앞의 라디오는 어느 쪽이
+ * 정답인지만 정한다.
  */
-export function QuizForm({ schools, gradesBySchool, item }: QuizFormProps) {
+export function QuizForm({
+  schools,
+  gradesBySchool,
+  answerOptions,
+  item,
+}: QuizFormProps) {
   const isEdit = item !== undefined;
   const [state, formAction, isPending] = useActionState(
     isEdit ? updateQuizAction : createQuizAction,
     INITIAL_CONTENT_FORM_STATE
   );
-  const [answer, setAnswer] = useState(item?.answer || QUIZ_ANSWER_OPTIONS[0]);
-  // O·X 각각의 노출 문구. 정답이 아닌 쪽도 보내므로 두 벌을 따로 들고 있는다.
+  const [answer, setAnswer] = useState(item?.answer || answerOptions[0] || '');
+  // 선택지 각각의 노출 문구. 정답이 아닌 쪽도 보내므로 선택지 수만큼 따로 들고 있는다.
   const [messages, setMessages] = useState<
     Record<string, { code: string; text: string }>
   >(() =>
     Object.fromEntries(
-      QUIZ_ANSWER_OPTIONS.map((option) => [
+      answerOptions.map((option) => [
         option,
         { code: ANSWER_MESSAGE_OPTIONS[0].value, text: '' },
       ])
@@ -75,6 +82,14 @@
     }));
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+  const answerError =
+    errors.answer ??
+    answerOptions
+      .flatMap((option) => [
+        errors[`answerMessage${option}Code`],
+        errors[`answerMessage${option}Text`],
+      ])
+      .find(Boolean);
 
   return (
     <>
@@ -146,7 +161,7 @@
 
         <ContentFormRow label="정답여부" required>
           <div className={styles.answerLines}>
-            {QUIZ_ANSWER_OPTIONS.map((option) => (
+            {answerOptions.map((option) => (
               <div key={option} className={styles.inline}>
                 <FoxRadio
                   name="answer"
@@ -193,19 +208,7 @@
               </div>
             ))}
           </div>
-          {(errors.answer ||
-            errors.answerMessageOText ||
-            errors.answerMessageXText) && (
-            <FoxHelperText
-              type="danger"
-              message={
-                errors.answer ??
-                errors.answerMessageOText ??
-                errors.answerMessageXText ??
-                ''
-              }
-            />
-          )}
+          {answerError && <FoxHelperText type="danger" message={answerError} />}
         </ContentFormRow>
 
         <ContentFormRow label="해설" required>
app/(protected)/(basic)/contents/quizzes/new/page.tsx
--- app/(protected)/(basic)/contents/quizzes/new/page.tsx
+++ app/(protected)/(basic)/contents/quizzes/new/page.tsx
@@ -1,6 +1,9 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
-import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
+import {
+  fetchQuizAnswerNames,
+  fetchSchoolGradeCodes,
+} from '@/lib/data/repositories/common-code-repository';
 import { QuizForm } from '../_components/quiz-form';
 
 export const metadata: Metadata = { title: '콘텐츠 등록 (금융OX퀴즈)' };
@@ -9,6 +12,15 @@
 export default async function Page() {
   await verifySession();
 
-  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
-  return <QuizForm schools={schools} gradesBySchool={gradesBySchool} />;
+  const [{ schools, gradesBySchool }, answerOptions] = await Promise.all([
+    fetchSchoolGradeCodes(),
+    fetchQuizAnswerNames(),
+  ]);
+  return (
+    <QuizForm
+      schools={schools}
+      gradesBySchool={gradesBySchool}
+      answerOptions={answerOptions}
+    />
+  );
 }
lib/data/repositories/common-code-repository.ts
--- lib/data/repositories/common-code-repository.ts
+++ lib/data/repositories/common-code-repository.ts
@@ -173,6 +173,15 @@
   return details.map((detail) => ({ code: detail.comDtlCd, label: detail.name }));
 }
 
+/**
+ * OX퀴즈 정답여부 선택지 — 코드값(`comDtlCd`)이 아니라 **코드명(`cdNm`)을 그대로 값으로**
+ * 화면·저장에 쓴다(사용자 확정). 백엔드 `CNTNTS_QUIZ_ANSWER`가 문자열을 받기 때문이다.
+ */
+export async function fetchQuizAnswerNames(): Promise<string[]> {
+  const details = await fetchCodeDetails(CODE_GROUP.quizAnswer);
+  return details.map((detail) => detail.name);
+}
+
 export type SchoolGradeCodes = {
   /** `COM_SCHUL_CD` — 학교 선택지. */
   schools: CommonCode[];
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -31,6 +31,8 @@
   school: 'COM_SCHUL_CD',
   /** 학년. 학교마다 다르므로 `atrbNm`에 학교 코드를 넣어 조회한다. */
   grade: 'GRD_CD',
+  /** OX퀴즈 정답여부. */
+  quizAnswer: 'QUIZ_ANSWER',
 } as const;
 
 /** 값이 없는 항목의 화면 표기. */
lib/domain/content-form.ts
--- lib/domain/content-form.ts
+++ lib/domain/content-form.ts
@@ -7,7 +7,6 @@
   ANSWER_MESSAGE_ETC,
   ANSWER_MESSAGE_OPTIONS,
   MIN_COMIC_CUTS,
-  QUIZ_ANSWER_OPTIONS,
   QUIZ_LEVEL_OPTIONS,
   gradesOf,
   type ContentKeyword,
@@ -82,7 +81,7 @@
   keywords: ContentKeyword[];
   question: string;
   answer: string;
-  /** O·X 두 줄의 노출 문구. 정답이 아닌 쪽도 함께 보낸다(사용자 확정). */
+  /** 정답 선택지마다 한 줄씩의 노출 문구. 정답이 아닌 쪽도 함께 보낸다(사용자 확정). */
   answerMessages: QuizAnswerMessage[];
   explanation: string;
   isVisible: boolean;
@@ -235,7 +234,8 @@
 
 export function validateQuizForm(
   values: QuizFormValues,
-  codes: SchoolGradeOptions
+  codes: SchoolGradeOptions,
+  answerOptions: readonly string[]
 ): ValidationResult<QuizFormValues> {
   const errors: ContentFormErrors = {};
   validateGrades(values, codes, errors);
@@ -243,7 +243,7 @@
   if (!QUIZ_LEVEL_OPTIONS.some((option) => option.value === values.level)) {
     errors.level = '난이도를 선택해 주세요.';
   }
-  if (!(QUIZ_ANSWER_OPTIONS as readonly string[]).includes(values.answer)) {
+  if (!answerOptions.includes(values.answer)) {
     errors.answer = '정답을 선택해 주세요.';
   }
 
@@ -262,7 +262,7 @@
     errors
   );
 
-  // O·X 두 줄을 각각 검사한다 — 정답이 아닌 쪽도 보내므로 둘 다 유효해야 한다.
+  // 정답 선택지 줄을 각각 검사한다 — 정답이 아닌 쪽도 보내므로 모두 유효해야 한다.
   const answerMessages = values.answerMessages.map((message) => {
     if (
       message.code !== '' &&
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -43,9 +43,6 @@
   { value: '1', label: '하' },
 ];
 
-export const QUIZ_ANSWER_OPTIONS = ['O', 'X'] as const;
-export type QuizAnswer = (typeof QUIZ_ANSWER_OPTIONS)[number];
-
 /** 정답여부에 함께 노출할 문구. 기타를 고르면 직접 입력한다. */
 export const ANSWER_MESSAGE_ETC = 'ETC';
 
@@ -57,10 +54,10 @@
   { value: ANSWER_MESSAGE_ETC, label: '기타' },
 ];
 
-/** 정답여부 한 줄이 갖는 노출 문구. O·X 각각 하나씩 둔다. */
+/** 정답여부 한 줄이 갖는 노출 문구. 정답 선택지마다 하나씩 둔다. */
 export type QuizAnswerMessage = {
-  /** 'O' 또는 'X'. 어느 쪽에 붙는 문구인지. */
-  answer: QuizAnswer;
+  /** 어느 정답에 붙는 문구인지 — `QUIZ_ANSWER` 상세코드의 코드명. */
+  answer: string;
   /** `ANSWER_MESSAGE_OPTIONS`의 코드값. */
   code: string;
   /** `code`가 기타일 때만 쓰는 직접 입력 문구. */
Add a comment
List