임동욱 임동욱 09-03
fix: OX퀴즈 정답여부는 다시 고정 O·X로 두고 문구만 코드로 받는다
공통코드 QUIZ_ANSWER로 O·X 선택지를 조회하던 것을 되돌린다(f2af4f2). 정답여부는 화면
고정값이고, 코드로 받는 것은 각 줄의 「사용자에게 노출할 문구」뿐이다 — O는 QUIZ_ANSWER_O,
X는 QUIZ_ANSWER_X로 목록이 서로 다르다.

Co-Authored-By: Claude Opus 5 
@e5c611aa1abb81b9e9e5f4fc7262005f9a0c4b1d
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -6,7 +6,6 @@
 import { BackendRequestError } from '@/lib/http/backend-fetch';
 import {
   fetchQuizAnswerMessages,
-  fetchQuizAnswerNames,
   fetchSchoolGradeCodes,
 } from '@/lib/data/repositories/common-code-repository';
 import {
@@ -25,7 +24,11 @@
   deleteQuiz,
   updateQuiz,
 } from '@/lib/data/repositories/content-quiz-repository';
-import { CONTENT_TYPE_CODE, type ContentKind } from '@/lib/domain/content';
+import {
+  CONTENT_TYPE_CODE,
+  QUIZ_ANSWER_OPTIONS,
+  type ContentKind,
+} from '@/lib/domain/content';
 import {
   ContentInputError,
   validateComicForm,
@@ -174,7 +177,7 @@
   };
 }
 
-function readQuizValues(formData: FormData, answerOptions: readonly string[]) {
+function readQuizValues(formData: FormData) {
   return {
     schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
@@ -182,8 +185,8 @@
     keywords: readKeywords(formData),
     question: readString(formData, 'question'),
     answer: readString(formData, 'answer'),
-    // 정답 선택지 줄을 각각 읽는다 — 정답이 아닌 쪽도 함께 보낸다.
-    answerMessages: answerOptions.map((answer) => ({
+    // O·X 두 줄을 각각 읽는다 — 정답이 아닌 쪽도 함께 보낸다.
+    answerMessages: QUIZ_ANSWER_OPTIONS.map((answer) => ({
       answer,
       code: readString(formData, `answerMessage${answer}Code`),
       text: readString(formData, `answerMessage${answer}Text`),
@@ -390,12 +393,10 @@
   await verifySession();
 
   const failed = await runWrite(async () => {
-    const answerOptions = await fetchQuizAnswerNames();
     const validation = validateQuizForm(
-      readQuizValues(formData, answerOptions),
+      readQuizValues(formData),
       await fetchSchoolGradeCodes(),
-      answerOptions,
-      await fetchQuizAnswerMessages(answerOptions)
+      await fetchQuizAnswerMessages()
     );
     if (!validation.ok) {
       return { status: 'error', errors: validation.errors };
@@ -424,12 +425,10 @@
   }
 
   const failed = await runWrite(async () => {
-    const answerOptions = await fetchQuizAnswerNames();
     const validation = validateQuizForm(
-      readQuizValues(formData, answerOptions),
+      readQuizValues(formData),
       await fetchSchoolGradeCodes(),
-      answerOptions,
-      await fetchQuizAnswerMessages(answerOptions)
+      await fetchQuizAnswerMessages()
     );
     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
@@ -3,7 +3,6 @@
 import { verifySession } from '@/lib/auth/dal';
 import {
   fetchQuizAnswerMessages,
-  fetchQuizAnswerNames,
   fetchSchoolGradeCodes,
 } from '@/lib/data/repositories/common-code-repository';
 import { fetchQuizDetail } from '@/lib/data/repositories/content-quiz-repository';
@@ -19,12 +18,8 @@
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
-  const [{ schools, gradesBySchool }, answerOptions] = await Promise.all([
-    fetchSchoolGradeCodes(),
-    fetchQuizAnswerNames(),
-  ]);
-  // 문구 그룹 이름이 정답 코드명에서 나오므로 정답 목록을 받은 뒤에 조회한다.
-  const messageOptionsByAnswer = await fetchQuizAnswerMessages(answerOptions);
+  const [{ schools, gradesBySchool }, messageOptionsByAnswer] =
+    await Promise.all([fetchSchoolGradeCodes(), fetchQuizAnswerMessages()]);
 
   const { quizId } = await params;
   const item = await fetchQuizDetail(quizId);
@@ -36,7 +31,6 @@
     <QuizForm
       schools={schools}
       gradesBySchool={gradesBySchool}
-      answerOptions={answerOptions}
       messageOptionsByAnswer={messageOptionsByAnswer}
       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
@@ -11,7 +11,9 @@
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
 import {
   ANSWER_MESSAGE_ETC,
+  QUIZ_ANSWER_OPTIONS,
   QUIZ_LEVEL_OPTIONS,
+  type QuizAnswer,
   type QuizItem,
   type GradesBySchool,
 } from '@/lib/domain/content';
@@ -34,29 +36,26 @@
 interface QuizFormProps {
   schools: readonly CommonCode[];
   gradesBySchool: GradesBySchool;
-  /** 공통코드 `QUIZ_ANSWER` 상세코드의 코드명 — 이 문자열이 그대로 저장된다. */
-  answerOptions: readonly string[];
   /**
-   * 정답별 「노출할 문구」 선택지. O와 X의 목록이 달라 그룹이 갈린다
+   * O·X 각각의 「노출할 문구」 선택지. 두 목록이 서로 달라 그룹이 갈린다
    * (`QUIZ_ANSWER_O`·`QUIZ_ANSWER_X`).
    */
-  messageOptionsByAnswer: Record<string, readonly CommonCode[]>;
+  messageOptionsByAnswer: Record<QuizAnswer, readonly CommonCode[]>;
   item?: QuizItem;
 }
 
 /**
  * 콘텐츠 등록·수정 (금융OX퀴즈) — 시안 5402:12360.
  *
- * 정답여부는 선택지(공통코드 `QUIZ_ANSWER`)마다 한 줄이고 각 줄이 「사용자에게 노출할
- * 문구」를 갖는다. 문구 선택지는 정답마다 다른 공통코드에서 온다 — O는 `QUIZ_ANSWER_O`,
- * X는 `QUIZ_ANSWER_X`다(사용자 확정). 문구는 모두 백엔드로 보낸다 — 정답이 아닌 쪽에도
- * 보여 줄 문구가 필요하기 때문이다. 「기타」를 고르면 문구를 직접 적고 그 값이 전송된다.
- * 앞의 라디오는 어느 쪽이 정답인지만 정한다.
+ * 정답여부는 O·X 두 줄이고 각 줄이 「사용자에게 노출할 문구」를 갖는다. 문구 선택지는
+ * O·X가 서로 다른 공통코드 그룹에서 온다 — `QUIZ_ANSWER_O` · `QUIZ_ANSWER_X`(사용자 확정).
+ * 문구는 둘 다 백엔드로 보낸다 — 정답이 아닌 쪽에도 보여 줄 문구가 필요하기 때문이다.
+ * 「기타」를 고르면 문구를 직접 적고 그 값이 전송된다. 앞의 라디오는 어느 쪽이 정답인지만
+ * 정한다.
  */
 export function QuizForm({
   schools,
   gradesBySchool,
-  answerOptions,
   messageOptionsByAnswer,
   item,
 }: QuizFormProps) {
@@ -65,26 +64,21 @@
     isEdit ? updateQuizAction : createQuizAction,
     INITIAL_CONTENT_FORM_STATE
   );
-  const [answer, setAnswer] = useState(item?.answer || answerOptions[0] || '');
-  // 선택지 각각의 노출 문구. 정답이 아닌 쪽도 보내므로 선택지 수만큼 따로 들고 있는다.
-  const [messages, setMessages] = useState<
-    Record<string, { code: string; text: string }>
-  >(() =>
-    Object.fromEntries(
-      answerOptions.map((option) => [
-        option,
-        // 코드가 아직 등록되지 않았을 수 있다 — 그때는 「기타」로 시작한다.
-        {
-          code: messageOptionsByAnswer[option]?.[0]?.code ?? ANSWER_MESSAGE_ETC,
-          text: '',
-        },
-      ])
-    )
+  const [answer, setAnswer] = useState<string>(
+    item?.answer || QUIZ_ANSWER_OPTIONS[0]
   );
+  // O·X 각각의 노출 문구. 정답이 아닌 쪽도 보내므로 둘 다 따로 들고 있는다.
+  const [messages, setMessages] = useState<
+    Record<QuizAnswer, { code: string; text: string }>
+  >(() => ({
+    // 코드가 아직 등록되지 않았을 수 있다 — 그때는 「기타」로 시작한다.
+    O: { code: messageOptionsByAnswer.O[0]?.code ?? ANSWER_MESSAGE_ETC, text: '' },
+    X: { code: messageOptionsByAnswer.X[0]?.code ?? ANSWER_MESSAGE_ETC, text: '' },
+  }));
 
   /** 코드 목록 + 「기타」. 기타는 코드가 아니라 직접 입력을 여는 자리라 항상 뒤에 붙인다. */
-  const messageOptionsOf = (option: string) => [
-    ...(messageOptionsByAnswer[option] ?? []).map((code) => ({
+  const messageOptionsOf = (option: QuizAnswer) => [
+    ...messageOptionsByAnswer[option].map((code) => ({
       value: code.code,
       label: code.label,
     })),
@@ -92,7 +86,7 @@
   ];
 
   const patchMessage = (
-    option: string,
+    option: QuizAnswer,
     patch: Partial<{ code: string; text: string }>
   ) =>
     setMessages((current) => ({
@@ -103,12 +97,10 @@
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
   const answerError =
     errors.answer ??
-    answerOptions
-      .flatMap((option) => [
-        errors[`answerMessage${option}Code`],
-        errors[`answerMessage${option}Text`],
-      ])
-      .find(Boolean);
+    QUIZ_ANSWER_OPTIONS.flatMap((option) => [
+      errors[`answerMessage${option}Code`],
+      errors[`answerMessage${option}Text`],
+    ]).find(Boolean);
 
   return (
     <>
@@ -180,7 +172,7 @@
 
         <ContentFormRow label="정답여부" required>
           <div className={styles.answerLines}>
-            {answerOptions.map((option) => (
+            {QUIZ_ANSWER_OPTIONS.map((option) => (
               <div key={option} className={styles.inline}>
                 <FoxRadio
                   name="answer"
app/(protected)/(basic)/contents/quizzes/new/page.tsx
--- app/(protected)/(basic)/contents/quizzes/new/page.tsx
+++ app/(protected)/(basic)/contents/quizzes/new/page.tsx
@@ -2,7 +2,6 @@
 import { verifySession } from '@/lib/auth/dal';
 import {
   fetchQuizAnswerMessages,
-  fetchQuizAnswerNames,
   fetchSchoolGradeCodes,
 } from '@/lib/data/repositories/common-code-repository';
 import { QuizForm } from '../_components/quiz-form';
@@ -13,17 +12,12 @@
 export default async function Page() {
   await verifySession();
 
-  const [{ schools, gradesBySchool }, answerOptions] = await Promise.all([
-    fetchSchoolGradeCodes(),
-    fetchQuizAnswerNames(),
-  ]);
-  // 문구 그룹 이름이 정답 코드명에서 나오므로 정답 목록을 받은 뒤에 조회한다.
-  const messageOptionsByAnswer = await fetchQuizAnswerMessages(answerOptions);
+  const [{ schools, gradesBySchool }, messageOptionsByAnswer] =
+    await Promise.all([fetchSchoolGradeCodes(), fetchQuizAnswerMessages()]);
   return (
     <QuizForm
       schools={schools}
       gradesBySchool={gradesBySchool}
-      answerOptions={answerOptions}
       messageOptionsByAnswer={messageOptionsByAnswer}
     />
   );
lib/data/repositories/common-code-repository.ts
--- lib/data/repositories/common-code-repository.ts
+++ lib/data/repositories/common-code-repository.ts
@@ -8,6 +8,7 @@
   type CommonCodeDetail,
   type CommonCodeGroup,
 } from '@/lib/domain/common-code';
+import type { QuizAnswer } from '@/lib/domain/content';
 import type {
   CommonCodeDetailValues,
   CommonCodeGroupValues,
@@ -174,34 +175,16 @@
 }
 
 /**
- * OX퀴즈 정답여부 선택지 — 코드값(`comDtlCd`)이 아니라 **코드명(`cdNm`)을 그대로 값으로**
- * 화면·저장에 쓴다(사용자 확정). 백엔드 `CNTNTS_QUIZ_ANSWER`가 문자열을 받기 때문이다.
+ * OX퀴즈 「사용자에게 노출할 문구」 선택지. O와 X가 서로 다른 목록이라 그룹을 둘 다 읽는다.
  */
-export async function fetchQuizAnswerNames(): Promise<string[]> {
-  const details = await fetchCodeDetails(CODE_GROUP.quizAnswer);
-  return details.map((detail) => detail.name);
-}
-
-/**
- * 정답별 「노출할 문구」 선택지. O와 X의 목록이 달라 그룹이 둘로 갈린다 —
- * `QUIZ_ANSWER_O` · `QUIZ_ANSWER_X`(사용자 확정).
- *
- * 정답 코드명(`O`/`X`)을 접미로 붙여 그룹 이름을 만든다. 정답 선택지가 늘면 같은 규칙으로
- * 그룹만 등록하면 화면은 손대지 않아도 된다.
- */
-export async function fetchQuizAnswerMessages(
-  answerNames: readonly string[]
-): Promise<Record<string, CommonCode[]>> {
-  const entries = await Promise.all(
-    answerNames.map(
-      async (answer) =>
-        [
-          answer,
-          await fetchCommonCodes(`${CODE_GROUP.quizAnswer}_${answer}`),
-        ] as const
-    )
-  );
-  return Object.fromEntries(entries);
+export async function fetchQuizAnswerMessages(): Promise<
+  Record<QuizAnswer, CommonCode[]>
+> {
+  const [answerO, answerX] = await Promise.all([
+    fetchCommonCodes(CODE_GROUP.quizAnswerMessage.O),
+    fetchCommonCodes(CODE_GROUP.quizAnswerMessage.X),
+  ]);
+  return { O: answerO, X: answerX };
 }
 
 export type SchoolGradeCodes = {
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -31,8 +31,14 @@
   school: 'COM_SCHUL_CD',
   /** 학년. 학교마다 다르므로 `atrbNm`에 학교 코드를 넣어 조회한다. */
   grade: 'GRD_CD',
-  /** OX퀴즈 정답여부. */
-  quizAnswer: 'QUIZ_ANSWER',
+  /**
+   * OX퀴즈 「사용자에게 노출할 문구」. O와 X의 목록이 서로 달라 그룹이 둘로 갈린다
+   * (사용자 확정).
+   */
+  quizAnswerMessage: {
+    O: 'QUIZ_ANSWER_O',
+    X: 'QUIZ_ANSWER_X',
+  },
 } as const;
 
 /** 값이 없는 항목의 화면 표기. */
lib/domain/content-form.ts
--- lib/domain/content-form.ts
+++ lib/domain/content-form.ts
@@ -6,8 +6,10 @@
 import {
   ANSWER_MESSAGE_ETC,
   MIN_COMIC_CUTS,
+  QUIZ_ANSWER_OPTIONS,
   QUIZ_LEVEL_OPTIONS,
   gradesOf,
+  type QuizAnswer,
   type ContentKeyword,
   type QuizAnswerMessage,
   type GradesBySchool,
@@ -234,8 +236,7 @@
 export function validateQuizForm(
   values: QuizFormValues,
   codes: SchoolGradeOptions,
-  answerOptions: readonly string[],
-  messageOptionsByAnswer: Record<string, readonly CommonCode[]>
+  messageOptionsByAnswer: Record<QuizAnswer, readonly CommonCode[]>
 ): ValidationResult<QuizFormValues> {
   const errors: ContentFormErrors = {};
   validateGrades(values, codes, errors);
@@ -243,7 +244,7 @@
   if (!QUIZ_LEVEL_OPTIONS.some((option) => option.value === values.level)) {
     errors.level = '난이도를 선택해 주세요.';
   }
-  if (!answerOptions.includes(values.answer)) {
+  if (!(QUIZ_ANSWER_OPTIONS as readonly string[]).includes(values.answer)) {
     errors.answer = '정답을 선택해 주세요.';
   }
 
@@ -264,7 +265,7 @@
 
   // 정답 선택지 줄을 각각 검사한다 — 정답이 아닌 쪽도 보내므로 모두 유효해야 한다.
   const answerMessages = values.answerMessages.map((message) => {
-    const allowedCodes = messageOptionsByAnswer[message.answer] ?? [];
+    const allowedCodes = messageOptionsByAnswer[message.answer];
     if (
       message.code !== '' &&
       message.code !== ANSWER_MESSAGE_ETC &&
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -43,18 +43,21 @@
   { value: '1', label: '하' },
 ];
 
+export const QUIZ_ANSWER_OPTIONS = ['O', 'X'] as const;
+export type QuizAnswer = (typeof QUIZ_ANSWER_OPTIONS)[number];
+
 /**
- * 정답여부에 함께 노출할 문구. 선택지는 정답마다 다른 공통코드 그룹에서 온다 —
+ * 정답여부에 함께 노출할 문구. 선택지는 O·X 각각의 공통코드 그룹에서 온다 —
  * `QUIZ_ANSWER_O` · `QUIZ_ANSWER_X`(사용자 확정). 「기타」만 코드가 아니라 직접 입력을
  * 여는 자리라 프론트가 붙인다.
  */
 export const ANSWER_MESSAGE_ETC = 'ETC';
 
-/** 정답여부 한 줄이 갖는 노출 문구. 정답 선택지마다 하나씩 둔다. */
+/** 정답여부 한 줄이 갖는 노출 문구. O·X 각각 하나씩 둔다. */
 export type QuizAnswerMessage = {
-  /** 어느 정답에 붙는 문구인지 — `QUIZ_ANSWER` 상세코드의 코드명. */
-  answer: string;
-  /** 정답별 문구 그룹의 상세코드값, 또는 `ANSWER_MESSAGE_ETC`. */
+  /** 'O' 또는 'X'. 어느 쪽에 붙는 문구인지. */
+  answer: QuizAnswer;
+  /** 그 쪽 문구 그룹의 상세코드값, 또는 `ANSWER_MESSAGE_ETC`. */
   code: string;
   /** `code`가 기타일 때만 쓰는 직접 입력 문구. */
   text: string;
Add a comment
List