임동욱 임동욱 08-20
feat: 학교·학년을 공통코드(COM_SCHUL_CD·GRD_CD) 기반으로 교체
학교급을 프론트 상수 표(ELEM/MIDD/HIGH/ALL + 학년 수)로 들고 있었다. 그 표를 지우고 공통코드를
조회해 쓴다 — 1:1문의 진행상태(BBS_ANS_CD), 꾸미기 아이템 카테고리(ITEM_CATE_CD)와 같은 방식이다.

학년은 학교마다 다르므로 `GET /api/v1/mngr/code/list/GRD_CD?atrbNm={학교코드}`로 학교별로
조회한다(백엔드 매퍼가 `ATRB_NM1 = #{atrbNm}`로 거른다). 상세 목록 SQL이 `ATRB_NM1`을 select하지
않아 한 번에 받아 화면에서 가를 수 없어 학교 수만큼 왕복하고, `fetchSchoolGradeCodes()`가 그
왕복을 한 곳에 모아 `cache()`로 중복을 막는다.

저장에는 `GRD_CD` 코드값을 보낸다 — 화면의 학년 칩이 코드값을 그대로 hidden 필드에 싣고,
Server Action은 저장 직전에 코드표를 다시 조회해 그 학교에 없는 학년 코드를 거른다(화면이 보낸
값을 믿지 않는 기존 규칙과 같다).

"전체연령은 학년 선택 불가"가 하드코딩에서 데이터로 바뀌었다 — 학년 코드가 하나도 없는 학교는
칩을 그리지 않고 검증도 학년을 요구하지 않는다.

확인: 학교 전환 시 학년 칩이 그 학교 것으로 갈리고 이전 선택이 비워지는 것, 다중 선택이
`GRD001,GRD003`처럼 코드값으로 실리는 것, 학년 코드가 없는 학교는 칩이 0개인 것을 임시 페이지로
확인한 뒤 지웠다.

Co-Authored-By: Claude Opus 5 
@3902bd06ddaa3537bec0c1250f5cf2d6029eaa6b
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -3,6 +3,7 @@
 import { revalidatePath } from 'next/cache';
 import { redirect } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
 import { uploadAttachment } from '@/lib/data/repositories/file-repository';
 import {
   buildComicPayload,
@@ -117,7 +118,7 @@
   }
 
   const validation = validateShortsForm({
-    schoolLevel: readString(formData, 'schoolLevel'),
+    schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
     title: readString(formData, 'title'),
     subject: readString(formData, 'subject'),
@@ -126,7 +127,7 @@
     videoFileId: readString(formData, 'videoFileId'),
     thumbnailFileId: readString(formData, 'thumbnailFileId'),
     isVisible: readString(formData, 'isVisible') === 'true',
-  });
+  }, await fetchSchoolGradeCodes());
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
   }
@@ -165,7 +166,7 @@
   }
 
   const validation = validateShortsForm({
-    schoolLevel: readString(formData, 'schoolLevel'),
+    schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
     title: readString(formData, 'title'),
     subject: readString(formData, 'subject'),
@@ -174,7 +175,7 @@
     videoFileId: readString(formData, 'videoFileId'),
     thumbnailFileId: readString(formData, 'thumbnailFileId'),
     isVisible: readString(formData, 'isVisible') === 'true',
-  });
+  }, await fetchSchoolGradeCodes());
   if (!cntntsSn) {
     return { status: 'error', message: '요청이 올바르지 않습니다.' };
   }
@@ -240,7 +241,7 @@
   }
 
   const validation = validateComicForm({
-    schoolLevel: readString(formData, 'schoolLevel'),
+    schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
     name: readString(formData, 'name'),
     subject: readString(formData, 'subject'),
@@ -249,7 +250,7 @@
     thumbnailFileId,
     cutFileIds,
     isVisible: readString(formData, 'isVisible') === 'true',
-  });
+  }, await fetchSchoolGradeCodes());
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
   }
@@ -293,7 +294,7 @@
   }
 
   const validation = validateComicForm({
-    schoolLevel: readString(formData, 'schoolLevel'),
+    schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
     name: readString(formData, 'name'),
     subject: readString(formData, 'subject'),
@@ -302,7 +303,7 @@
     thumbnailFileId,
     cutFileIds,
     isVisible: readString(formData, 'isVisible') === 'true',
-  });
+  }, await fetchSchoolGradeCodes());
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
   }
@@ -319,7 +320,7 @@
 
 function readQuizValues(formData: FormData) {
   return {
-    schoolLevel: readString(formData, 'schoolLevel'),
+    schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
     level: readString(formData, 'level'),
     keywords: readKeywords(formData),
@@ -338,7 +339,7 @@
 ): Promise<ContentFormState> {
   await verifySession();
 
-  const validation = validateQuizForm(readQuizValues(formData));
+  const validation = validateQuizForm(readQuizValues(formData), await fetchSchoolGradeCodes());
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
   }
@@ -364,7 +365,7 @@
     return { status: 'error', message: '요청이 올바르지 않습니다.' };
   }
 
-  const validation = validateQuizForm(readQuizValues(formData));
+  const validation = validateQuizForm(readQuizValues(formData), await fetchSchoolGradeCodes());
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
   }
app/(protected)/(basic)/contents/_components/content-grade-field.tsx
--- app/(protected)/(basic)/contents/_components/content-grade-field.tsx
+++ app/(protected)/(basic)/contents/_components/content-grade-field.tsx
@@ -5,50 +5,51 @@
 import { FoxChipArea } from '@fox/core/components/fox-chip-area';
 import { FoxHelperText } from '@fox/core/components/fox-helper-text';
 import { FoxSelect } from '@fox/core/components/fox-select';
-import {
-  DEFAULT_SCHOOL_LEVEL,
-  SCHOOL_LEVEL_OPTIONS,
-  findSchoolLevel,
-  gradeOptionsOf,
-} from '@/lib/domain/content';
+import { gradesOf, type GradesBySchool } from '@/lib/domain/content';
+import type { CommonCode } from '@/lib/domain/common-code';
 import styles from './content-form.module.scss';
 
 interface ContentGradeFieldProps {
-  defaultSchoolLevel?: string;
+  /** 공통코드 `COM_SCHUL_CD`. */
+  schools: readonly CommonCode[];
+  /** 학교 코드 → 그 학교의 `GRD_CD` 목록. */
+  gradesBySchool: GradesBySchool;
+  defaultSchoolCode?: string;
   defaultGrades?: readonly string[];
-  schoolLevelError?: string;
+  schoolError?: string;
   gradesError?: string;
 }
 
 /**
- * 학년선택 — 학교급을 고르면 그 학교급의 학년 칩이 열리고 여러 개를 고를 수 있다.
- * 전체연령은 학년이 없어 칩을 그리지 않는다(기획 "전체 연령 선택 시 학년 선택 비활성").
+ * 학년선택 — 학교를 고르면 그 학교의 학년 코드만 칩으로 열린다.
+ * 학년이 없는 학교(전체연령 등)는 코드 목록이 비어 있어 칩을 그리지 않는다.
  */
 export function ContentGradeField({
-  defaultSchoolLevel,
+  schools,
+  gradesBySchool,
+  defaultSchoolCode,
   defaultGrades = [],
-  schoolLevelError,
+  schoolError,
   gradesError,
 }: ContentGradeFieldProps) {
-  const [schoolLevel, setSchoolLevel] = useState(
-    defaultSchoolLevel || DEFAULT_SCHOOL_LEVEL
+  const [schoolCode, setSchoolCode] = useState(
+    defaultSchoolCode || schools[0]?.code || ''
   );
   const [grades, setGrades] = useState<string[]>([...defaultGrades]);
 
-  const gradeOptions = gradeOptionsOf(schoolLevel);
-  const hasGrades = (findSchoolLevel(schoolLevel)?.gradeCount ?? 0) > 0;
+  const gradeCodes = gradesOf(gradesBySchool, schoolCode);
 
-  function changeSchoolLevel(next: string) {
-    setSchoolLevel(next);
-    // 학교급이 바뀌면 이전 학교급의 학년은 남길 수 없다(초 4학년 → 중학교).
+  function changeSchool(next: string) {
+    setSchoolCode(next);
+    // 학교가 바뀌면 이전 학교의 학년 코드는 남길 수 없다.
     setGrades([]);
   }
 
-  function toggleGrade(grade: string) {
+  function toggleGrade(code: string) {
     setGrades((current) =>
-      current.includes(grade)
-        ? current.filter((value) => value !== grade)
-        : [...current, grade].sort()
+      current.includes(code)
+        ? current.filter((value) => value !== code)
+        : [...current, code]
     );
   }
 
@@ -57,37 +58,35 @@
       <div className={styles.inline}>
         <FoxSelect
           size="md"
-          options={SCHOOL_LEVEL_OPTIONS.map((option) => ({
-            value: option.value,
-            label: option.label,
+          options={schools.map((school) => ({
+            value: school.code,
+            label: school.label,
           }))}
-          value={schoolLevel}
-          onValueChange={changeSchoolLevel}
-          error={Boolean(schoolLevelError)}
+          placeholder="학교를 선택하세요."
+          value={schoolCode}
+          onValueChange={changeSchool}
+          error={Boolean(schoolError)}
         />
-        {hasGrades && (
+        {gradeCodes.length > 0 && (
           <FoxChipArea size="md" label="학년" wrap>
-            {gradeOptions.map((grade) => (
+            {gradeCodes.map((grade) => (
               <FoxChip
-                key={grade}
+                key={grade.code}
                 type="check"
-                label={`${grade}학년`}
-                checked={grades.includes(grade)}
-                onCheckedChange={() => toggleGrade(grade)}
+                label={grade.label}
+                checked={grades.includes(grade.code)}
+                onCheckedChange={() => toggleGrade(grade.code)}
               />
             ))}
           </FoxChipArea>
         )}
       </div>
 
-      <input type="hidden" name="schoolLevel" value={schoolLevel} />
+      <input type="hidden" name="schoolCode" value={schoolCode} />
       <input type="hidden" name="grades" value={grades.join(',')} />
 
-      {(schoolLevelError || gradesError) && (
-        <FoxHelperText
-          type="danger"
-          message={schoolLevelError ?? gradesError ?? ''}
-        />
+      {(schoolError || gradesError) && (
+        <FoxHelperText type="danger" message={schoolError ?? gradesError ?? ''} />
       )}
     </div>
   );
app/(protected)/(basic)/contents/_components/content-list-filters.tsx
--- app/(protected)/(basic)/contents/_components/content-list-filters.tsx
+++ app/(protected)/(basic)/contents/_components/content-list-filters.tsx
@@ -4,57 +4,61 @@
 import { FoxChipArea } from '@fox/core/components/fox-chip-area';
 import {
   QUIZ_LEVEL_OPTIONS,
-  SCHOOL_LEVEL_OPTIONS,
-  gradeOptionsOf,
+  gradesOf,
+  type GradesBySchool,
 } from '@/lib/domain/content';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { ContentQuery } from '@/lib/domain/content-query';
 
 const ALL = '';
 
 // 칩은 고른 옵션의 라벨을 칩 글자 자리에 보여 준다 — 「전체」만 적으면 어떤 필터인지 사라져서
 // 선택지 이름에 필터 이름을 붙인다.
-
 interface ContentListFiltersProps {
   query: ContentQuery;
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
   /** 난이도 칩은 금융OX퀴즈에만 있다. */
   withLevel?: boolean;
   onChange: (patch: Partial<ContentQuery>) => void;
 }
 
-/** 목록 상단 필터 — 기획의 「학교급 / 학년 / 난이도」 셀렉트. */
+/** 목록 상단 필터 — 기획의 「학교급 / 학년 / 난이도」 셀렉트. 학교·학년은 공통코드다. */
 export function ContentListFilters({
   query,
+  schools,
+  gradesBySchool,
   withLevel = false,
   onChange,
 }: ContentListFiltersProps) {
-  const gradeOptions = gradeOptionsOf(query.schoolLevel);
+  const gradeCodes = gradesOf(gradesBySchool, query.school);
 
   return (
     <FoxChipArea size="md" label="목록 필터" wrap>
       <FoxChip
         type="select"
-        label="학교급"
+        label="학교"
         options={[
-          { value: ALL, label: '학교급 전체' },
-          ...SCHOOL_LEVEL_OPTIONS.map((option) => ({
-            value: option.value,
-            label: option.label,
+          { value: ALL, label: '학교 전체' },
+          ...schools.map((school) => ({
+            value: school.code,
+            label: school.label,
           })),
         ]}
-        value={query.schoolLevel}
+        value={query.school}
         onValueChange={(value) =>
-          onChange({ schoolLevel: value, grade: ALL, page: 1 })
+          onChange({ school: value, grade: ALL, page: 1 })
         }
       />
       <FoxChip
         type="select"
         label="학년"
-        disabled={gradeOptions.length === 0}
+        disabled={gradeCodes.length === 0}
         options={[
           { value: ALL, label: '학년 전체' },
-          ...gradeOptions.map((grade) => ({
-            value: grade,
-            label: `${grade}학년`,
+          ...gradeCodes.map((grade) => ({
+            value: grade.code,
+            label: grade.label,
           })),
         ]}
         value={query.grade}
app/(protected)/(basic)/contents/_components/content-list-shell.tsx
--- app/(protected)/(basic)/contents/_components/content-list-shell.tsx
+++ app/(protected)/(basic)/contents/_components/content-list-shell.tsx
@@ -26,6 +26,8 @@
   type ContentSortOption,
 } from '@/lib/domain/content-query';
 import { ContentListFilters } from './content-list-filters';
+import type { CommonCode } from '@/lib/domain/common-code';
+import type { GradesBySchool } from '@/lib/domain/content';
 
 interface ContentListShellProps<T> {
   kind: ContentKind;
@@ -36,6 +38,8 @@
   currentPage: number;
   totalPages: number;
   totalCount: number;
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
   /** 툴바 오른쪽에 덧붙일 것(금융OX퀴즈의 엑셀 버튼). */
   extraActions?: ReactNode;
 }
@@ -50,6 +54,8 @@
   currentPage,
   totalPages,
   totalCount,
+  schools,
+  gradesBySchool,
   extraActions,
 }: ContentListShellProps<T>) {
   const router = useRouter();
@@ -71,6 +77,8 @@
       filter={
         <ContentListFilters
           query={query}
+          schools={schools}
+          gradesBySchool={gradesBySchool}
           withLevel={kind === 'quiz'}
           onChange={go}
         />
app/(protected)/(basic)/contents/comics/[cntntsSn]/page.tsx
--- app/(protected)/(basic)/contents/comics/[cntntsSn]/page.tsx
+++ app/(protected)/(basic)/contents/comics/[cntntsSn]/page.tsx
@@ -1,6 +1,7 @@
 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 {
   contentCutUrls,
   contentThumbnailUrl,
@@ -19,6 +20,8 @@
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
+
   const { cntntsSn } = await params;
   const item = await fetchContentDetail('comic', cntntsSn);
   if (!item) {
@@ -27,6 +30,8 @@
 
   return (
     <ComicForm
+      schools={schools}
+      gradesBySchool={gradesBySchool}
       item={item}
       thumbnailUrl={contentThumbnailUrl(item)}
       cutUrls={contentCutUrls(item, MIN_COMIC_CUTS)}
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
@@ -7,7 +7,11 @@
 import { FoxPageHeader } from '@fox/core/components/fox-page-header';
 import { FoxTextArea } from '@fox/core/components/fox-text-area';
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
-import { THUMBNAIL_HELP_TEXT, type ContentItem } from '@/lib/domain/content';
+import {
+  THUMBNAIL_HELP_TEXT,
+  type ContentItem,
+  type GradesBySchool,
+} from '@/lib/domain/content';
 import {
   INITIAL_CONTENT_FORM_STATE,
   MESSAGE_MAX_LENGTH,
@@ -15,6 +19,7 @@
   TITLE_MAX_LENGTH,
 } from '@/lib/domain/content-form';
 import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
+import type { CommonCode } from '@/lib/domain/common-code';
 import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
 import { createComicAction, updateComicAction } from '../../_actions';
 import { ContentCutsField } from '../../_components/content-cuts-field';
@@ -26,13 +31,21 @@
 import styles from '../../_components/content-form.module.scss';
 
 interface ComicFormProps {
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
   item?: ContentItem;
   thumbnailUrl?: string | null;
   cutUrls?: string[];
 }
 
 /** 콘텐츠 등록·수정 (4컷금융만화) — 시안 5449:10850. */
-export function ComicForm({ item, thumbnailUrl, cutUrls = [] }: ComicFormProps) {
+export function ComicForm({
+  schools,
+  gradesBySchool,
+  item,
+  thumbnailUrl,
+  cutUrls = [],
+}: ComicFormProps) {
   const isEdit = item !== undefined;
   const [state, formAction, isPending] = useActionState(
     isEdit ? updateComicAction : createComicAction,
@@ -62,9 +75,11 @@
 
         <ContentFormRow label="학년선택" required>
           <ContentGradeField
-            defaultSchoolLevel={item?.schoolLevel}
+            schools={schools}
+            gradesBySchool={gradesBySchool}
+            defaultSchoolCode={item?.schoolCode}
             defaultGrades={item?.grades}
-            schoolLevelError={errors.schoolLevel}
+            schoolError={errors.schoolCode}
             gradesError={errors.grades}
           />
         </ContentFormRow>
app/(protected)/(basic)/contents/comics/_components/comics-list.tsx
--- app/(protected)/(basic)/contents/comics/_components/comics-list.tsx
+++ app/(protected)/(basic)/contents/comics/_components/comics-list.tsx
@@ -6,10 +6,12 @@
   formatContentText,
   formatGradesLabel,
   formatKeywordsLabel,
-  formatSchoolLevelLabel,
+  formatSchoolLabel,
   formatVisibilityLabel,
   type ContentItem,
+  type GradesBySchool,
 } from '@/lib/domain/content';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { ContentQuery } from '@/lib/domain/content-query';
 import { ContentListShell } from '../../_components/content-list-shell';
 import { ContentRowActions } from '../../_components/content-row-actions';
@@ -20,6 +22,8 @@
   currentPage: number;
   totalPages: number;
   totalCount: number;
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
 }
 
 export function ComicsList({
@@ -28,6 +32,8 @@
   currentPage,
   totalPages,
   totalCount,
+  schools,
+  gradesBySchool,
 }: ComicsListProps) {
   const columns: FoxListColumn<ContentItem>[] = [
     {
@@ -37,16 +43,17 @@
       render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
     },
     {
-      key: 'schoolLevel',
-      header: '학교급',
+      key: 'school',
+      header: '학교',
       width: 120,
-      render: (row) => formatSchoolLevelLabel(row.schoolLevel),
+      render: (row) => formatSchoolLabel(row.schoolCode, schools),
     },
     {
       key: 'grades',
       header: '학년',
       width: 100,
-      render: (row) => formatGradesLabel(row.grades),
+      render: (row) =>
+        formatGradesLabel(row.grades, gradesBySchool[row.schoolCode] ?? []),
     },
     {
       key: 'name',
@@ -105,6 +112,8 @@
       currentPage={currentPage}
       totalPages={totalPages}
       totalCount={totalCount}
+      schools={schools}
+      gradesBySchool={gradesBySchool}
     />
   );
 }
app/(protected)/(basic)/contents/comics/new/page.tsx
--- app/(protected)/(basic)/contents/comics/new/page.tsx
+++ app/(protected)/(basic)/contents/comics/new/page.tsx
@@ -1,5 +1,6 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
 import { ComicForm } from '../_components/comic-form';
 
 export const metadata: Metadata = { title: '콘텐츠 등록 (4컷금융만화)' };
@@ -7,5 +8,7 @@
 
 export default async function Page() {
   await verifySession();
-  return <ComicForm />;
+
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
+  return <ComicForm schools={schools} gradesBySchool={gradesBySchool} />;
 }
app/(protected)/(basic)/contents/comics/page.tsx
--- app/(protected)/(basic)/contents/comics/page.tsx
+++ app/(protected)/(basic)/contents/comics/page.tsx
@@ -1,5 +1,6 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchContents } from '@/lib/data/repositories/content-repository';
 import { parseContentQuery } from '@/lib/domain/content-query';
 import { ComicsList } from './_components/comics-list';
@@ -13,6 +14,8 @@
 
 export default async function Page({ searchParams }: PageProps) {
   await verifySession();
+
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
 
   const query = parseContentQuery('comic', await searchParams);
   const { items, totalCount, isTotalCountExact } = await fetchContents('comic', query);
@@ -29,6 +32,8 @@
       currentPage={currentPage}
       totalPages={totalPages}
       totalCount={totalCount}
+      schools={schools}
+      gradesBySchool={gradesBySchool}
     />
   );
 }
app/(protected)/(basic)/contents/quizzes/[quizSn]/page.tsx
--- app/(protected)/(basic)/contents/quizzes/[quizSn]/page.tsx
+++ app/(protected)/(basic)/contents/quizzes/[quizSn]/page.tsx
@@ -1,6 +1,7 @@
 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 { fetchQuizDetail } from '@/lib/data/repositories/content-quiz-repository';
 import { QuizForm } from '../_components/quiz-form';
 
@@ -14,11 +15,13 @@
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
+
   const { quizSn } = await params;
   const item = await fetchQuizDetail(quizSn);
   if (!item) {
     notFound();
   }
 
-  return <QuizForm item={item} />;
+  return <QuizForm schools={schools} gradesBySchool={gradesBySchool} 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
@@ -15,6 +15,7 @@
   QUIZ_ANSWER_OPTIONS,
   QUIZ_LEVEL_OPTIONS,
   type QuizItem,
+  type GradesBySchool,
 } from '@/lib/domain/content';
 import {
   ANSWER_MESSAGE_MAX_LENGTH,
@@ -23,6 +24,7 @@
   QUESTION_MAX_LENGTH,
 } from '@/lib/domain/content-form';
 import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
+import type { CommonCode } from '@/lib/domain/common-code';
 import { createQuizAction, updateQuizAction } from '../../_actions';
 import { ContentFormActions } from '../../_components/content-form-actions';
 import { ContentFormRow } from '../../_components/content-form-row';
@@ -32,6 +34,8 @@
 import styles from '../../_components/content-form.module.scss';
 
 interface QuizFormProps {
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
   item?: QuizItem;
 }
 
@@ -41,7 +45,7 @@
  * 정답여부는 O·X 두 줄이고, 고른 쪽에서만 「사용자에게 노출할 문구」를 정한다(기획 ③).
  * 「기타」를 고르면 문구를 직접 적는다.
  */
-export function QuizForm({ item }: QuizFormProps) {
+export function QuizForm({ schools, gradesBySchool, item }: QuizFormProps) {
   const isEdit = item !== undefined;
   const [state, formAction, isPending] = useActionState(
     isEdit ? updateQuizAction : createQuizAction,
@@ -74,7 +78,9 @@
 
         <ContentFormRow label="학년선택" required>
           <ContentGradeField
-            schoolLevelError={errors.schoolLevel}
+            schools={schools}
+            gradesBySchool={gradesBySchool}
+            schoolError={errors.schoolCode}
             gradesError={errors.grades}
           />
         </ContentFormRow>
app/(protected)/(basic)/contents/quizzes/_components/quizzes-list.tsx
--- app/(protected)/(basic)/contents/quizzes/_components/quizzes-list.tsx
+++ app/(protected)/(basic)/contents/quizzes/_components/quizzes-list.tsx
@@ -7,8 +7,10 @@
   formatContentText,
   formatQuizLevelLabel,
   formatVisibilityLabel,
+  type GradesBySchool,
   type QuizItem,
 } from '@/lib/domain/content';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { ContentQuery } from '@/lib/domain/content-query';
 import { ContentListShell } from '../../_components/content-list-shell';
 import { ContentRowActions } from '../../_components/content-row-actions';
@@ -20,6 +22,8 @@
   currentPage: number;
   totalPages: number;
   totalCount: number;
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
 }
 
 export function QuizzesList({
@@ -28,6 +32,8 @@
   currentPage,
   totalPages,
   totalCount,
+  schools,
+  gradesBySchool,
 }: QuizzesListProps) {
   const columns: FoxListColumn<QuizItem>[] = [
     {
@@ -112,6 +118,8 @@
       currentPage={currentPage}
       totalPages={totalPages}
       totalCount={totalCount}
+      schools={schools}
+      gradesBySchool={gradesBySchool}
       extraActions={<QuizExcelActions />}
     />
   );
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,5 +1,6 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
 import { QuizForm } from '../_components/quiz-form';
 
 export const metadata: Metadata = { title: '콘텐츠 등록 (금융OX퀴즈)' };
@@ -7,5 +8,7 @@
 
 export default async function Page() {
   await verifySession();
-  return <QuizForm />;
+
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
+  return <QuizForm schools={schools} gradesBySchool={gradesBySchool} />;
 }
app/(protected)/(basic)/contents/quizzes/page.tsx
--- app/(protected)/(basic)/contents/quizzes/page.tsx
+++ app/(protected)/(basic)/contents/quizzes/page.tsx
@@ -1,5 +1,6 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchQuizzes } from '@/lib/data/repositories/content-quiz-repository';
 import { parseContentQuery } from '@/lib/domain/content-query';
 import { QuizzesList } from './_components/quizzes-list';
@@ -13,6 +14,8 @@
 
 export default async function Page({ searchParams }: PageProps) {
   await verifySession();
+
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
 
   const query = parseContentQuery('quiz', await searchParams);
   const { items, totalCount, isTotalCountExact } = await fetchQuizzes(query);
@@ -29,6 +32,8 @@
       currentPage={currentPage}
       totalPages={totalPages}
       totalCount={totalCount}
+      schools={schools}
+      gradesBySchool={gradesBySchool}
     />
   );
 }
app/(protected)/(basic)/contents/shorts/[cntntsSn]/page.tsx
--- app/(protected)/(basic)/contents/shorts/[cntntsSn]/page.tsx
+++ app/(protected)/(basic)/contents/shorts/[cntntsSn]/page.tsx
@@ -1,6 +1,7 @@
 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 {
   contentThumbnailUrl,
   fetchContentDetail,
@@ -17,11 +18,20 @@
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
+
   const { cntntsSn } = await params;
   const item = await fetchContentDetail('shorts', cntntsSn);
   if (!item) {
     notFound();
   }
 
-  return <ShortsForm item={item} thumbnailUrl={contentThumbnailUrl(item)} />;
+  return (
+    <ShortsForm
+      schools={schools}
+      gradesBySchool={gradesBySchool}
+      item={item}
+      thumbnailUrl={contentThumbnailUrl(item)}
+    />
+  );
 }
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
@@ -10,6 +10,7 @@
 import {
   THUMBNAIL_HELP_TEXT,
   type ContentItem,
+  type GradesBySchool,
 } from '@/lib/domain/content';
 import {
   INITIAL_CONTENT_FORM_STATE,
@@ -18,6 +19,7 @@
   TITLE_MAX_LENGTH,
 } from '@/lib/domain/content-form';
 import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
+import type { CommonCode } from '@/lib/domain/common-code';
 import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
 import { createShortsAction, updateShortsAction } from '../../_actions';
 import { ContentFormActions } from '../../_components/content-form-actions';
@@ -29,13 +31,20 @@
 import styles from '../../_components/content-form.module.scss';
 
 interface ShortsFormProps {
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
   /** 수정 화면에서만 넘어온다. 없으면 등록이다. */
   item?: ContentItem;
   thumbnailUrl?: string | null;
 }
 
 /** 콘텐츠 등록·수정 (금융쇼츠) — 시안 5402:10628. */
-export function ShortsForm({ item, thumbnailUrl }: ShortsFormProps) {
+export function ShortsForm({
+  schools,
+  gradesBySchool,
+  item,
+  thumbnailUrl,
+}: ShortsFormProps) {
   const isEdit = item !== undefined;
   const [state, formAction, isPending] = useActionState(
     isEdit ? updateShortsAction : createShortsAction,
@@ -65,9 +74,11 @@
 
         <ContentFormRow label="학년선택" required>
           <ContentGradeField
-            defaultSchoolLevel={item?.schoolLevel}
+            schools={schools}
+            gradesBySchool={gradesBySchool}
+            defaultSchoolCode={item?.schoolCode}
             defaultGrades={item?.grades}
-            schoolLevelError={errors.schoolLevel}
+            schoolError={errors.schoolCode}
             gradesError={errors.grades}
           />
         </ContentFormRow>
app/(protected)/(basic)/contents/shorts/_components/shorts-list.tsx
--- app/(protected)/(basic)/contents/shorts/_components/shorts-list.tsx
+++ app/(protected)/(basic)/contents/shorts/_components/shorts-list.tsx
@@ -6,10 +6,12 @@
   formatContentText,
   formatGradesLabel,
   formatKeywordsLabel,
-  formatSchoolLevelLabel,
+  formatSchoolLabel,
   formatVisibilityLabel,
   type ContentItem,
+  type GradesBySchool,
 } from '@/lib/domain/content';
+import type { CommonCode } from '@/lib/domain/common-code';
 import type { ContentQuery } from '@/lib/domain/content-query';
 import { ContentListShell } from '../../_components/content-list-shell';
 import { ContentRowActions } from '../../_components/content-row-actions';
@@ -20,6 +22,8 @@
   currentPage: number;
   totalPages: number;
   totalCount: number;
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
 }
 
 export function ShortsList({
@@ -28,6 +32,8 @@
   currentPage,
   totalPages,
   totalCount,
+  schools,
+  gradesBySchool,
 }: ShortsListProps) {
   const columns: FoxListColumn<ContentItem>[] = [
     {
@@ -37,16 +43,17 @@
       render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
     },
     {
-      key: 'schoolLevel',
-      header: '학교급',
+      key: 'school',
+      header: '학교',
       width: 120,
-      render: (row) => formatSchoolLevelLabel(row.schoolLevel),
+      render: (row) => formatSchoolLabel(row.schoolCode, schools),
     },
     {
       key: 'grades',
       header: '학년',
       width: 100,
-      render: (row) => formatGradesLabel(row.grades),
+      render: (row) =>
+        formatGradesLabel(row.grades, gradesBySchool[row.schoolCode] ?? []),
     },
     {
       key: 'title',
@@ -105,6 +112,8 @@
       currentPage={currentPage}
       totalPages={totalPages}
       totalCount={totalCount}
+      schools={schools}
+      gradesBySchool={gradesBySchool}
     />
   );
 }
app/(protected)/(basic)/contents/shorts/new/page.tsx
--- app/(protected)/(basic)/contents/shorts/new/page.tsx
+++ app/(protected)/(basic)/contents/shorts/new/page.tsx
@@ -1,5 +1,6 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
 import { ShortsForm } from '../_components/shorts-form';
 
 export const metadata: Metadata = { title: '콘텐츠 등록 (금융쇼츠)' };
@@ -7,5 +8,7 @@
 
 export default async function Page() {
   await verifySession();
-  return <ShortsForm />;
+
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
+  return <ShortsForm schools={schools} gradesBySchool={gradesBySchool} />;
 }
app/(protected)/(basic)/contents/shorts/page.tsx
--- app/(protected)/(basic)/contents/shorts/page.tsx
+++ app/(protected)/(basic)/contents/shorts/page.tsx
@@ -1,5 +1,6 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchContents } from '@/lib/data/repositories/content-repository';
 import { parseContentQuery } from '@/lib/domain/content-query';
 import { ShortsList } from './_components/shorts-list';
@@ -13,6 +14,8 @@
 
 export default async function Page({ searchParams }: PageProps) {
   await verifySession();
+
+  const { schools, gradesBySchool } = await fetchSchoolGradeCodes();
 
   const query = parseContentQuery('shorts', await searchParams);
   const { items, totalCount, isTotalCountExact } = await fetchContents('shorts', query);
@@ -29,6 +32,8 @@
       currentPage={currentPage}
       totalPages={totalPages}
       totalCount={totalCount}
+      schools={schools}
+      gradesBySchool={gradesBySchool}
     />
   );
 }
lib/data/repositories/common-code-repository.ts
--- lib/data/repositories/common-code-repository.ts
+++ lib/data/repositories/common-code-repository.ts
@@ -2,10 +2,11 @@
 import { cache } from 'react';
 import { getSessionAccessToken } from '@/lib/auth/dal';
 import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
-import type {
-  CommonCode,
-  CommonCodeDetail,
-  CommonCodeGroup,
+import {
+  CODE_GROUP,
+  type CommonCode,
+  type CommonCodeDetail,
+  type CommonCodeGroup,
 } from '@/lib/domain/common-code';
 import type {
   CommonCodeDetailValues,
@@ -135,12 +136,19 @@
   return rows.flatMap(toGroup);
 });
 
-/** 한 그룹의 상세코드 전체. */
+/**
+ * 한 그룹의 상세코드 전체.
+ *
+ * `atrbNm`을 주면 백엔드가 `ATRB_NM1 = #{atrbNm}`으로 걸러 준다 — 학년(`GRD_CD`)처럼 상위
+ * 코드에 딸린 목록을 뽑을 때 쓴다(학교 코드를 넣으면 그 학교의 학년만 온다).
+ */
 export const fetchCodeDetails = cache(async function fetchCodeDetails(
-  comCd: string
+  comCd: string,
+  atrbNm?: string
 ): Promise<CommonCodeDetail[]> {
+  const query = atrbNm ? `?atrbNm=${encodeURIComponent(atrbNm)}` : '';
   const rows = await requestList(
-    `${CODE_PATH}/list/${encodeURIComponent(comCd)}`
+    `${CODE_PATH}/list/${encodeURIComponent(comCd)}${query}`
   );
   return rows.flatMap(toDetail);
 });
@@ -149,11 +157,43 @@
  * 다른 화면이 선택지로 쓰는 최소 표현. 상세코드 조회를 그대로 쓰되 화면이 알 필요 없는 것을
  * 덜어 낸다 — 같은 `cache()`를 타므로 한 요청 안에서 왕복이 늘지 않는다.
  */
-export async function fetchCommonCodes(groupCode: string): Promise<CommonCode[]> {
-  const details = await fetchCodeDetails(groupCode);
+export async function fetchCommonCodes(
+  groupCode: string,
+  atrbNm?: string
+): Promise<CommonCode[]> {
+  const details = await fetchCodeDetails(groupCode, atrbNm);
   return details.map((detail) => ({ code: detail.comDtlCd, label: detail.name }));
 }
 
+export type SchoolGradeCodes = {
+  /** `COM_SCHUL_CD` — 학교 선택지. */
+  schools: CommonCode[];
+  /** 학교 코드 → 그 학교의 `GRD_CD` 목록. 학년이 없는 학교는 빈 배열이다. */
+  gradesBySchool: Record<string, CommonCode[]>;
+};
+
+/**
+ * 학교와 학교별 학년을 함께 가져온다.
+ *
+ * 학년 조회에 학교 코드가 필요해 학교 수만큼 왕복한다 — 상세 목록 SQL이 `ATRB_NM1`을 select하지
+ * 않아 한 번에 받아 화면에서 가르는 방법이 없다. 학교는 몇 개뿐이고 `cache()`가 한 요청 안의
+ * 중복 호출을 막는다.
+ */
+export async function fetchSchoolGradeCodes(): Promise<SchoolGradeCodes> {
+  const schools = await fetchCommonCodes(CODE_GROUP.school);
+
+  const grades = await Promise.all(
+    schools.map((school) => fetchCommonCodes(CODE_GROUP.grade, school.code))
+  );
+
+  return {
+    schools,
+    gradesBySchool: Object.fromEntries(
+      schools.map((school, index) => [school.code, grades[index]])
+    ),
+  };
+}
+
 /*
  * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
  * 상세 등록만 form이고 나머지는 JSON이다(파일 상단 주석 참조).
lib/data/repositories/content-quiz-repository.ts
--- lib/data/repositories/content-quiz-repository.ts
+++ lib/data/repositories/content-quiz-repository.ts
@@ -16,8 +16,8 @@
  *
  * 이슈: 등록·수정·삭제·단건조회 API가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약으로
  *   미리 맞춰 둔 것이라 백엔드가 생기기 전에는 실패한다.
- * 이슈: 학교급·학년은 컬럼이 없다. 검색 VO에 `searchSchul`/`searchGrade`가 있지만 매퍼의
- *   `<if>` 블록이 비어 있어 필터가 조용히 무시된다.
+ * 이슈: 학교(`COM_SCHUL_CD`)·학년(`GRD_CD`)을 담을 컬럼이 없다. 검색 VO에 `searchSchul`/
+ *   `searchGrade`가 있지만 매퍼의 `<if>` 블록이 비어 있어 필터가 조용히 무시된다.
  * 이슈: 키워드가 별도 테이블이 아니라 `quiz_title` 한 칸이다. 목록의 「키워드」 열이 이 값이고,
  *   기획의 "키워드+설명 쌍"을 담을 자리는 아직 없다.
  * 이슈: 정답 노출 문구(기획 "기본 3종 + 기타")를 담을 컬럼이 없다.
@@ -76,7 +76,7 @@
   const keyword = query.keyword.trim();
 
   return {
-    searchSchul: query.schoolLevel,
+    searchSchul: query.school,
     searchGrade: query.grade,
     searchLevel: query.level,
     searchCondition: keyword ? '1' : '',
@@ -148,7 +148,7 @@
 export async function fetchQuizDetail(quizSn: string): Promise<QuizItem | null> {
   const { items } = await requestList(
     {
-      schoolLevel: '',
+      school: '',
       grade: '',
       level: '',
       searchField: '',
@@ -171,7 +171,7 @@
     quizQuestion: values.question,
     quizExplan: values.explanation,
     quizAnswer: values.answer,
-    schulGradeCd: values.schoolLevel,
+    schulCd: values.schoolCode,
     gradeCd: values.grades.join(','),
     answerMssageCd: values.answerMessageCode,
     answerMssage: values.answerMessageText,
lib/data/repositories/content-repository.ts
--- lib/data/repositories/content-repository.ts
+++ lib/data/repositories/content-repository.ts
@@ -25,8 +25,8 @@
  * 이슈: 등록·수정·삭제·단건조회 API가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약
  *   (`POST {base}` / `PUT {base}/{sn}` / `DELETE {base}/{sn}`)으로 미리 맞춰 둔 것이라
  *   백엔드가 생기기 전에는 실패한다.
- * 이슈: 학교급·학년은 컬럼도 SQL도 없다(검색 VO에 파라미터만 있고 매퍼의 `<if>`가 비어 있다).
- *   주제·전달메시지도 컬럼이 없어 목록·상세에서 늘 빈 값이다.
+ * 이슈: 학교(`COM_SCHUL_CD`)·학년(`GRD_CD`)을 담을 컬럼도 필터 SQL도 없다 — 검색 VO에 파라미터만
+ *   있고 매퍼의 `<if>`가 비어 있다. 주제·전달메시지도 컬럼이 없어 목록·상세에서 늘 빈 값이다.
  * 이슈: toon 매퍼의 검색 조건이 금칙어 테이블 컬럼(`BEN_WORD_NM`)을 보고 있어 검색어가 걸리면
  *   SQL이 깨진다. 그래서 검색어는 보내되 백엔드 수정 전까지는 결과를 신뢰할 수 없다.
  * 이슈: 목록 SQL이 키워드를 LEFT JOIN해 행이 키워드 수만큼 늘어나는데 count는 조인 없이 센다.
@@ -90,7 +90,7 @@
     title: readString(raw, 'cntntsTitle') ?? '',
     subject: readString(raw, 'cntntsSbjct') ?? '',
     message: readString(raw, 'cntntsMssage') ?? '',
-    schoolLevel: readString(raw, 'schulGradeCd') ?? '',
+    schoolCode: readString(raw, 'schulCd') ?? '',
     grades: (readString(raw, 'gradeCd') ?? '')
       .split(',')
       .map((grade) => grade.trim())
@@ -112,7 +112,7 @@
   const keyword = query.keyword.trim();
 
   return {
-    searchSchulGradeCd: query.schoolLevel,
+    searchSchulGradeCd: query.school,
     searchGradeCd: query.grade,
     searchCondition: keyword ? '1' : '',
     searchKeyword: keyword,
@@ -203,7 +203,7 @@
   const { items } = await requestList(
     kind,
     {
-      schoolLevel: '',
+      school: '',
       grade: '',
       level: '',
       searchField: '',
@@ -225,7 +225,7 @@
   cntntsTitle: string;
   cntntsSbjct: string;
   cntntsMssage: string;
-  schulGradeCd: string;
+  schulCd: string;
   gradeCd: string;
   atchFileId: string;
   thumbAtchFileId: string;
@@ -248,7 +248,7 @@
     cntntsTitle: values.title,
     cntntsSbjct: values.subject,
     cntntsMssage: values.message,
-    schulGradeCd: values.schoolLevel,
+    schulCd: values.schoolCode,
     gradeCd: values.grades.join(','),
     atchFileId: values.videoFileId,
     thumbAtchFileId: values.thumbnailFileId,
@@ -263,7 +263,7 @@
     cntntsTitle: values.name,
     cntntsSbjct: values.subject,
     cntntsMssage: values.message,
-    schulGradeCd: values.schoolLevel,
+    schulCd: values.schoolCode,
     gradeCd: values.grades.join(','),
     // 이슈: 기획은 "각 컷을 개별 이미지로 업로드"인데 백엔드 컬럼은 `atchFileId` 한 칸뿐이라
     // 컷 묶음을 여러 개 연결할 자리가 없다. 지금은 첫 묶음만 저장된다.
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -23,6 +23,10 @@
   decorationItemCategory: 'ITEM_CATE_CD',
   /** 1:1문의 진행상태. */
   inquiryAnswerStatus: 'BBS_ANS_CD',
+  /** 학교(학교급). */
+  school: 'COM_SCHUL_CD',
+  /** 학년. 학교마다 다르므로 `atrbNm`에 학교 코드를 넣어 조회한다. */
+  grade: 'GRD_CD',
 } as const;
 
 /** 값이 없는 항목의 화면 표기. */
lib/domain/content-form.ts
--- lib/domain/content-form.ts
+++ lib/domain/content-form.ts
@@ -9,10 +9,11 @@
   MIN_COMIC_CUTS,
   QUIZ_ANSWER_OPTIONS,
   QUIZ_LEVEL_OPTIONS,
-  findSchoolLevel,
-  gradeOptionsOf,
+  gradesOf,
   type ContentKeyword,
+  type GradesBySchool,
 } from '@/lib/domain/content';
+import type { CommonCode } from '@/lib/domain/common-code';
 
 export const TITLE_MAX_LENGTH = 200;
 export const SUBJECT_MAX_LENGTH = 200;
@@ -32,8 +33,16 @@
 export const INITIAL_CONTENT_FORM_STATE: ContentFormState = { status: 'idle' };
 
 export type GradeSelection = {
-  schoolLevel: string;
+  /** `COM_SCHUL_CD` 코드값. */
+  schoolCode: string;
+  /** `GRD_CD` 코드값 — 백엔드가 받는 것도 이 값이다. */
   grades: string[];
+};
+
+/** 학년 검증에 필요한 코드표. Server Action이 저장 직전에 다시 조회해 넘긴다. */
+export type SchoolGradeOptions = {
+  schools: readonly CommonCode[];
+  gradesBySchool: GradesBySchool;
 };
 
 export type ShortsFormValues = GradeSelection & {
@@ -87,22 +96,28 @@
 
 function validateGrades(
   values: GradeSelection,
+  codes: SchoolGradeOptions,
   errors: ContentFormErrors
 ): void {
-  const level = findSchoolLevel(values.schoolLevel);
-  if (!level) {
-    errors.schoolLevel = '학교급을 선택해 주세요.';
+  if (!codes.schools.some((school) => school.code === values.schoolCode)) {
+    errors.schoolCode = '학교를 선택해 주세요.';
     return;
   }
-  if (level.gradeCount === 0) {
+
+  const allowed = gradesOf(codes.gradesBySchool, values.schoolCode);
+  // 학년 코드가 하나도 없는 학교(전체연령 등)는 학년을 고르지 않는다.
+  if (allowed.length === 0) {
     return;
   }
-  const allowed = gradeOptionsOf(values.schoolLevel);
   if (values.grades.length === 0) {
     errors.grades = '학년을 하나 이상 선택해 주세요.';
     return;
   }
-  if (values.grades.some((grade) => !allowed.includes(grade))) {
+  if (
+    values.grades.some(
+      (grade) => !allowed.some((option) => option.code === grade)
+    )
+  ) {
     errors.grades = '선택할 수 없는 학년이 포함돼 있습니다.';
   }
 }
@@ -131,10 +146,11 @@
 }
 
 export function validateShortsForm(
-  values: ShortsFormValues
+  values: ShortsFormValues,
+  codes: SchoolGradeOptions
 ): ValidationResult<ShortsFormValues> {
   const errors: ContentFormErrors = {};
-  validateGrades(values, errors);
+  validateGrades(values, codes, errors);
 
   const title = requireText(
     values.title,
@@ -169,10 +185,11 @@
 }
 
 export function validateComicForm(
-  values: ComicFormValues
+  values: ComicFormValues,
+  codes: SchoolGradeOptions
 ): ValidationResult<ComicFormValues> {
   const errors: ContentFormErrors = {};
-  validateGrades(values, errors);
+  validateGrades(values, codes, errors);
 
   const name = requireText(
     values.name,
@@ -213,10 +230,11 @@
 }
 
 export function validateQuizForm(
-  values: QuizFormValues
+  values: QuizFormValues,
+  codes: SchoolGradeOptions
 ): ValidationResult<QuizFormValues> {
   const errors: ContentFormErrors = {};
-  validateGrades(values, errors);
+  validateGrades(values, codes, errors);
 
   if (!QUIZ_LEVEL_OPTIONS.some((option) => option.value === values.level)) {
     errors.level = '난이도를 선택해 주세요.';
lib/domain/content-query.ts
--- lib/domain/content-query.ts
+++ lib/domain/content-query.ts
@@ -82,7 +82,9 @@
 const MAX_KEYWORD_LENGTH = 100;
 
 export type ContentQuery = {
-  schoolLevel: string;
+  /** 공통코드 `COM_SCHUL_CD` 코드값. 빈 값이면 전체. */
+  school: string;
+  /** 공통코드 `GRD_CD` 코드값. 빈 값이면 전체. */
   grade: string;
   /** 금융OX퀴즈 전용 난이도 필터. 다른 화면에서는 늘 빈 값이다. */
   level: string;
@@ -112,7 +114,7 @@
   const pageSize = Number(readParam(searchParams, 'pageSize'));
 
   return {
-    schoolLevel: readParam(searchParams, 'schoolLevel'),
+    school: readParam(searchParams, 'school'),
     grade: readParam(searchParams, 'grade'),
     level: kind === 'quiz' ? readParam(searchParams, 'level') : '',
     searchField: fields.some((field) => field.value === searchField)
@@ -136,7 +138,7 @@
   const params = new URLSearchParams();
   const defaultField = CONTENT_SEARCH_FIELDS[kind][0].value;
 
-  if (merged.schoolLevel) params.set('schoolLevel', merged.schoolLevel);
+  if (merged.school) params.set('school', merged.school);
   if (merged.grade) params.set('grade', merged.grade);
   if (merged.level) params.set('level', merged.level);
   if (merged.searchField !== defaultField) {
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -5,7 +5,11 @@
  * 쇼츠·만화(`TB_COM_CNTNTS`)와 퀴즈(`TB_COM_QUIZ`)로 갈린다.
  */
 
-import { EMPTY_FIELD_PLACEHOLDER } from '@/lib/domain/common-code';
+import {
+  EMPTY_FIELD_PLACEHOLDER,
+  formatCommonCode,
+  type CommonCode,
+} from '@/lib/domain/common-code';
 
 export const CONTENT_KINDS = ['shorts', 'quiz', 'comic'] as const;
 export type ContentKind = (typeof CONTENT_KINDS)[number];
@@ -22,30 +26,14 @@
   comic: 'T',
 };
 
-export type SchoolLevelOption = {
-  value: string;
-  label: string;
-  /** 학년 칩 개수. 0이면 학년을 고르지 않는다(전체연령). */
-  gradeCount: number;
-};
+/** 학교 코드 → 그 학교의 학년 목록. 학년이 없는 학교(전체연령 등)는 빈 배열이다. */
+export type GradesBySchool = Record<string, readonly CommonCode[]>;
 
-// TODO: 학교급 코드값이 백엔드에 없다(검색 파라미터만 있고 컬럼·SQL이 없음). 잠정값이다.
-export const SCHOOL_LEVEL_OPTIONS: readonly SchoolLevelOption[] = [
-  { value: 'ELEM', label: '초등학교', gradeCount: 6 },
-  { value: 'MIDD', label: '중학교', gradeCount: 3 },
-  { value: 'HIGH', label: '고등학교', gradeCount: 3 },
-  { value: 'ALL', label: '전체연령', gradeCount: 0 },
-];
-
-export const DEFAULT_SCHOOL_LEVEL = SCHOOL_LEVEL_OPTIONS[0].value;
-
-export function findSchoolLevel(value: string): SchoolLevelOption | undefined {
-  return SCHOOL_LEVEL_OPTIONS.find((option) => option.value === value);
-}
-
-export function gradeOptionsOf(schoolLevel: string): readonly string[] {
-  const count = findSchoolLevel(schoolLevel)?.gradeCount ?? 0;
-  return Array.from({ length: count }, (_, index) => String(index + 1));
+export function gradesOf(
+  gradesBySchool: GradesBySchool,
+  schoolCode: string
+): readonly CommonCode[] {
+  return gradesBySchool[schoolCode] ?? [];
 }
 
 /** 난이도 — 백엔드 `MngrQuizVo.quizLev` 주석(1:하, 2:중, 3:상)에서 확인한 실제 코드값. */
@@ -91,7 +79,9 @@
   title: string;
   subject: string;
   message: string;
-  schoolLevel: string;
+  /** 공통코드 `COM_SCHUL_CD` 코드값. */
+  schoolCode: string;
+  /** 공통코드 `GRD_CD` 코드값. */
   grades: string[];
   keywords: ContentKeyword[];
   /** 만화의 컷 이미지·쇼츠의 영상이 묶인 첨부 ID. */
@@ -115,12 +105,22 @@
   modifiedAt: string | null;
 };
 
-export function formatSchoolLevelLabel(value: string): string {
-  return findSchoolLevel(value)?.label ?? (value || EMPTY_FIELD_PLACEHOLDER);
+export function formatSchoolLabel(
+  code: string,
+  schools: readonly CommonCode[]
+): string {
+  return code ? formatCommonCode(schools, code) : EMPTY_FIELD_PLACEHOLDER;
 }
 
-export function formatGradesLabel(grades: readonly string[]): string {
-  return grades.length > 0 ? grades.join(',') : EMPTY_FIELD_PLACEHOLDER;
+/** 학년 코드값 목록 → 코드명. 표에 없는 코드는 지어내지 않고 코드 그대로 보여준다. */
+export function formatGradesLabel(
+  grades: readonly string[],
+  gradeCodes: readonly CommonCode[]
+): string {
+  if (grades.length === 0) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+  return grades.map((grade) => formatCommonCode(gradeCodes, grade)).join(', ');
 }
 
 export function formatKeywordsLabel(keywords: readonly ContentKeyword[]): string {
Add a comment
List