임동욱 임동욱 09-03
feat: OX퀴즈의 O·X 노출 문구를 둘 다 보낸다
지금까지는 정답으로 고른 쪽만 문구를 정할 수 있었고(나머지 줄은 비활성) 한 벌만
나갔다. 두 줄 모두 항상 편집할 수 있게 하고 둘 다 전송한다 — 정답이 아닌 쪽에도
보여 줄 문구가 필요하다.

정답 라디오는 그대로 둔다. 어느 쪽이 정답인지는 여전히 그것이 정한다.

백엔드가 아직 이 필드를 모른다(작업 중). 지금 보내도 폼은 미지의 파라미터를, JSON은
미지의 속성을 버리므로 조용히 무시된다. 이름이 확정되면 QUIZ_ANSWER_MESSAGE_FIELD
한 곳만 고치면 된다.

Co-Authored-By: Claude Opus 5 
@2baa0121c7cd9ee76570572eedb5bfb16b62b35e
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -21,7 +21,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,
@@ -178,8 +182,12 @@
     keywords: readKeywords(formData),
     question: readString(formData, 'question'),
     answer: readString(formData, 'answer'),
-    answerMessageCode: readString(formData, 'answerMessageCode'),
-    answerMessageText: readString(formData, 'answerMessageText'),
+    // O·X 두 줄을 각각 읽는다 — 정답이 아닌 쪽도 함께 보낸다.
+    answerMessages: QUIZ_ANSWER_OPTIONS.map((answer) => ({
+      answer,
+      code: readString(formData, `answerMessage${answer}Code`),
+      text: readString(formData, `answerMessage${answer}Text`),
+    })),
     explanation: readString(formData, 'explanation'),
     isVisible: readString(formData, 'isVisible') === 'true',
   };
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
@@ -42,8 +42,9 @@
 /**
  * 콘텐츠 등록·수정 (금융OX퀴즈) — 시안 5402:12360.
  *
- * 정답여부는 O·X 두 줄이고, 고른 쪽에서만 「사용자에게 노출할 문구」를 정한다(기획 ③).
- * 「기타」를 고르면 문구를 직접 적는다.
+ * 정답여부는 O·X 두 줄이고 각 줄이 「사용자에게 노출할 문구」를 갖는다. 두 문구 모두
+ * 백엔드로 보낸다 — 정답이 아닌 쪽에도 보여 줄 문구가 필요하기 때문이다(사용자 확정).
+ * 「기타」를 고르면 문구를 직접 적는다. 앞의 라디오는 어느 쪽이 정답인지만 정한다.
  */
 export function QuizForm({ schools, gradesBySchool, item }: QuizFormProps) {
   const isEdit = item !== undefined;
@@ -52,8 +53,26 @@
     INITIAL_CONTENT_FORM_STATE
   );
   const [answer, setAnswer] = useState(item?.answer || QUIZ_ANSWER_OPTIONS[0]);
-  const [messageCode, setMessageCode] = useState(ANSWER_MESSAGE_OPTIONS[0].value);
-  const [messageText, setMessageText] = useState('');
+  // O·X 각각의 노출 문구. 정답이 아닌 쪽도 보내므로 두 벌을 따로 들고 있는다.
+  const [messages, setMessages] = useState<
+    Record<string, { code: string; text: string }>
+  >(() =>
+    Object.fromEntries(
+      QUIZ_ANSWER_OPTIONS.map((option) => [
+        option,
+        { code: ANSWER_MESSAGE_OPTIONS[0].value, text: '' },
+      ])
+    )
+  );
+
+  const patchMessage = (
+    option: string,
+    patch: Partial<{ code: string; text: string }>
+  ) =>
+    setMessages((current) => ({
+      ...current,
+      [option]: { ...current[option], ...patch },
+    }));
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
@@ -140,37 +159,51 @@
                 <div className={styles.answerMessage}>
                   <FoxSelect
                     size="md"
+                    ariaLabel={`${option} 노출 문구`}
                     options={ANSWER_MESSAGE_OPTIONS.map((message) => ({
                       value: message.value,
                       label: message.label,
                     }))}
-                    value={messageCode}
-                    onValueChange={setMessageCode}
-                    disabled={answer !== option}
+                    value={messages[option].code}
+                    onValueChange={(code) => patchMessage(option, { code })}
                   />
                 </div>
-                {messageCode === ANSWER_MESSAGE_ETC && (
+                {messages[option].code === ANSWER_MESSAGE_ETC && (
                   <div className={styles.answerMessage}>
                     <FoxInput
                       size="md"
-                      aria-label="노출할 문구"
-                      value={messageText}
-                      onChange={setMessageText}
+                      aria-label={`${option}에 노출할 문구`}
+                      value={messages[option].text}
+                      onChange={(text) => patchMessage(option, { text })}
                       placeholder="내용을 입력하세요."
                       maxLength={ANSWER_MESSAGE_MAX_LENGTH}
-                      disabled={answer !== option}
                     />
                   </div>
                 )}
+                <input
+                  type="hidden"
+                  name={`answerMessage${option}Code`}
+                  value={messages[option].code}
+                />
+                <input
+                  type="hidden"
+                  name={`answerMessage${option}Text`}
+                  value={messages[option].text}
+                />
               </div>
             ))}
           </div>
-          <input type="hidden" name="answerMessageCode" value={messageCode} />
-          <input type="hidden" name="answerMessageText" value={messageText} />
-          {(errors.answer || errors.answerMessageText) && (
+          {(errors.answer ||
+            errors.answerMessageOText ||
+            errors.answerMessageXText) && (
             <FoxHelperText
               type="danger"
-              message={errors.answer ?? errors.answerMessageText ?? ''}
+              message={
+                errors.answer ??
+                errors.answerMessageOText ??
+                errors.answerMessageXText ??
+                ''
+              }
             />
           )}
         </ContentFormRow>
lib/data/repositories/content-quiz-repository.ts
--- lib/data/repositories/content-quiz-repository.ts
+++ lib/data/repositories/content-quiz-repository.ts
@@ -230,6 +230,34 @@
 
 type QuizKeywordPayload = Record<string, string>;
 
+/**
+ * 정답 노출 문구 필드명. **백엔드가 아직 이 필드를 모른다**(작업 중) — 지금 보내면 조용히
+ * 무시된다(폼은 미지의 파라미터를, JSON은 미지의 속성을 버린다). 이름이 확정되면 여기 세 값만
+ * 고치면 요청·전송이 모두 따라간다(키워드와 같은 방식).
+ */
+const QUIZ_ANSWER_MESSAGE_FIELD = {
+  /** 목록 자체의 이름. form에서는 `answerMessageList[0].…` 형태가 된다. */
+  list: 'answerMessageList',
+  /** 어느 쪽(O/X)에 붙는 문구인지. */
+  answer: 'cntntsQuizAnswer',
+  /** 고른 문구의 코드값. */
+  code: 'answerMessageCode',
+  /** 기타일 때 직접 적은 문구. */
+  text: 'answerMessageText',
+} as const;
+
+type QuizAnswerMessagePayload = Record<string, string>;
+
+function toAnswerMessagePayload(
+  messages: QuizFormValues['answerMessages']
+): QuizAnswerMessagePayload[] {
+  return messages.map((message) => ({
+    [QUIZ_ANSWER_MESSAGE_FIELD.answer]: message.answer,
+    [QUIZ_ANSWER_MESSAGE_FIELD.code]: message.code,
+    [QUIZ_ANSWER_MESSAGE_FIELD.text]: message.text,
+  }));
+}
+
 type QuizWritePayload = {
   cntntsQuizLev: string;
   cntntsQuizQuestion: string;
@@ -237,6 +265,7 @@
   cntntsQuizAnswer: string;
   useYn: string;
   keywordList: QuizKeywordPayload[];
+  answerMessageList: QuizAnswerMessagePayload[];
   schulGrdList: Array<{ schulCd: string; grdCd: string | null }>;
 };
 
@@ -255,6 +284,7 @@
       [QUIZ_KEYWORD_FIELD.keyword]: item.keyword,
       [QUIZ_KEYWORD_FIELD.description]: item.description,
     })),
+    answerMessageList: toAnswerMessagePayload(values.answerMessages),
     schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
   };
 }
@@ -284,6 +314,12 @@
     }
   });
 
+  payload.answerMessageList.forEach((item, index) => {
+    for (const [field, value] of Object.entries(item)) {
+      params[`${QUIZ_ANSWER_MESSAGE_FIELD.list}[${index}].${field}`] = value;
+    }
+  });
+
   payload.schulGrdList.forEach((item, index) => {
     params[`schulGrdList[${index}].schulCd`] = item.schulCd;
     // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다.
@@ -298,8 +334,12 @@
  * (form 경로는 `toFormParams`가 같은 상수로 인덱스 표기를 만든다.)
  */
 function toJsonBody(payload: QuizWritePayload): Record<string, unknown> {
-  const { keywordList, ...rest } = payload;
-  return { ...rest, [QUIZ_KEYWORD_FIELD.list]: keywordList };
+  const { keywordList, answerMessageList, ...rest } = payload;
+  return {
+    ...rest,
+    [QUIZ_KEYWORD_FIELD.list]: keywordList,
+    [QUIZ_ANSWER_MESSAGE_FIELD.list]: answerMessageList,
+  };
 }
 
 async function sendWrite(
lib/domain/content-form.ts
--- lib/domain/content-form.ts
+++ lib/domain/content-form.ts
@@ -11,6 +11,7 @@
   QUIZ_LEVEL_OPTIONS,
   gradesOf,
   type ContentKeyword,
+  type QuizAnswerMessage,
   type GradesBySchool,
 } from '@/lib/domain/content';
 import type { CommonCode } from '@/lib/domain/common-code';
@@ -81,8 +82,8 @@
   keywords: ContentKeyword[];
   question: string;
   answer: string;
-  answerMessageCode: string;
-  answerMessageText: string;
+  /** O·X 두 줄의 노출 문구. 정답이 아닌 쪽도 함께 보낸다(사용자 확정). */
+  answerMessages: QuizAnswerMessage[];
   explanation: string;
   isVisible: boolean;
 };
@@ -245,14 +246,6 @@
   if (!(QUIZ_ANSWER_OPTIONS as readonly string[]).includes(values.answer)) {
     errors.answer = '정답을 선택해 주세요.';
   }
-  if (
-    values.answerMessageCode !== '' &&
-    !ANSWER_MESSAGE_OPTIONS.some(
-      (option) => option.value === values.answerMessageCode
-    )
-  ) {
-    errors.answerMessageCode = '노출 문구를 다시 선택해 주세요.';
-  }
 
   const question = requireText(
     values.question,
@@ -269,16 +262,27 @@
     errors
   );
 
-  let answerMessageText = values.answerMessageText
-    .trim()
-    .slice(0, ANSWER_MESSAGE_MAX_LENGTH);
-  if (values.answerMessageCode === ANSWER_MESSAGE_ETC) {
-    if (answerMessageText === '') {
-      errors.answerMessageText = '노출할 문구를 입력해 주세요.';
+  // O·X 두 줄을 각각 검사한다 — 정답이 아닌 쪽도 보내므로 둘 다 유효해야 한다.
+  const answerMessages = values.answerMessages.map((message) => {
+    if (
+      message.code !== '' &&
+      !ANSWER_MESSAGE_OPTIONS.some((option) => option.value === message.code)
+    ) {
+      errors[`answerMessage${message.answer}Code`] =
+        '노출 문구를 다시 선택해 주세요.';
     }
-  } else {
-    answerMessageText = '';
-  }
+
+    let text = message.text.trim().slice(0, ANSWER_MESSAGE_MAX_LENGTH);
+    if (message.code === ANSWER_MESSAGE_ETC) {
+      if (text === '') {
+        errors[`answerMessage${message.answer}Text`] =
+          `${message.answer}에 노출할 문구를 입력해 주세요.`;
+      }
+    } else {
+      text = '';
+    }
+    return { ...message, text };
+  });
 
   const keywords = normalizeKeywords(values.keywords);
   validateKeywords(keywords, errors);
@@ -288,6 +292,6 @@
   }
   return {
     ok: true,
-    values: { ...values, question, explanation, answerMessageText, keywords },
+    values: { ...values, question, explanation, answerMessages, keywords },
   };
 }
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -57,6 +57,16 @@
   { value: ANSWER_MESSAGE_ETC, label: '기타' },
 ];
 
+/** 정답여부 한 줄이 갖는 노출 문구. O·X 각각 하나씩 둔다. */
+export type QuizAnswerMessage = {
+  /** 'O' 또는 'X'. 어느 쪽에 붙는 문구인지. */
+  answer: QuizAnswer;
+  /** `ANSWER_MESSAGE_OPTIONS`의 코드값. */
+  code: string;
+  /** `code`가 기타일 때만 쓰는 직접 입력 문구. */
+  text: string;
+};
+
 export type ContentKeyword = {
   keyword: string;
   description: string;
Add a comment
List