임동욱 임동욱 08-13
feat: 게시판관리(공지사항·1:1문의·FAQ) 화면 추가
사이드바 「게시판관리(고객센터)」 그룹과 3개 화면 신설
(/boards/notices, /boards/inquiries, /boards/faqs).

백엔드 최신화(develop 924db37)로 새로 생긴 관리자 게시판 API에 연동한다.
세 화면이 TB_COM_BBS 한 테이블을 stngId로만 구분하므로 도메인·Repository·
Server Action·공용 컴포넌트를 한 벌로 두고 BoardType으로 분기한다.

- 공지사항: 목록·등록·수정·삭제 (구분/사용여부 필터, 상단고정·노출기간·앱푸쉬)
- 1:1문의: 목록·상세답변·삭제 (등록 없음, 전화번호 마스킹, 진행상태)
- FAQ: 목록·등록·수정·삭제 (구분/유형 필터)
- 첨부파일 업로드(Server Action 경유)와 다운로드 중계 라우트 추가

백엔드 계약 확인에 따른 결정(사용자 확인 완료):
- totalCount·검색조건 한계로 전체를 받아 서버에서 검색·필터·페이징
- stngId·moduleId·구분/유형/진행상태 코드는 백엔드 미확정이라 상수 한 곳에
  임시값을 두고 추후 교체(BOARD_SETTING_IDS 등)
- 유형은 저장 필드가 없어 임시 파라미터명으로 전송(백엔드 추가 예정)
- 노출기간·앱푸쉬는 조회 SELECT에 없어 전송만 하고 조회는 빈 값
- 본문은 리치 에디터 대신 글자수 카운트 textarea

backend-fetch: PUT/DELETE·폼 인코딩·멀티파트 지원 추가. 쓰기 API가
success(null)을 반환해 기존 backendFetch로는 성공이 실패로 읽히므로
데이터 없는 쓰기 전용 backendCommand를 분리했다.

Co-Authored-By: Claude Opus 5 
@673e232dfded9ff3358317dc32d9f064ca851423
app/(protected)/(basic)/_components/admin-sidebar.tsx
--- app/(protected)/(basic)/_components/admin-sidebar.tsx
+++ app/(protected)/(basic)/_components/admin-sidebar.tsx
@@ -10,9 +10,9 @@
 // 그룹 헤딩(라우트 없음) 아래 자식 링크를 두는 2단 구조다 — 「학생 회원」은 「회원정보관리」
 // 그룹의 자식이다(「학생회원관리」가 아니다).
 //
-// 시안에는 이 외에도 꾸미기아이템관리·게시판관리(고객센터)·포인트관리·콘텐츠관리·시스템관리
-// 5개 그룹이 더 있지만, 전부 대응하는 화면/라우트가 아직 없어 지금 추가하면 눌러도 갈 곳 없는
-// 죽은 메뉴가 된다. 각 화면이 실제로 구현될 때 해당 그룹과 자식 링크를 추가한다.
+// 시안에는 이 외에도 꾸미기아이템관리·포인트관리·콘텐츠관리·시스템관리 4개 그룹이 더 있지만,
+// 전부 대응하는 화면/라우트가 아직 없어 지금 추가하면 눌러도 갈 곳 없는 죽은 메뉴가 된다.
+// 각 화면이 실제로 구현될 때 해당 그룹과 자식 링크를 추가한다.
 const SIDEBAR_NAV_TREE: SidebarNavNode[] = [
   { type: 'link', label: '홈', href: '/' },
   {
@@ -27,6 +27,16 @@
     label: '관리자정보관리',
     children: [{ type: 'link', label: '관리자 회원', href: '/admins' }],
   },
+  {
+    // 기획(Figma A_BOA_006/012/014) 사이드바의 그룹명이 「게시판관리(고객센터)」다.
+    type: 'group',
+    label: '게시판관리(고객센터)',
+    children: [
+      { type: 'link', label: '공지사항', href: '/boards/notices' },
+      { type: 'link', label: '1:1문의', href: '/boards/inquiries' },
+      { type: 'link', label: 'FAQ', href: '/boards/faqs' },
+    ],
+  },
 ];
 
 interface AdminSidebarProps {
 
app/(protected)/(basic)/boards/_actions.ts (added)
+++ app/(protected)/(basic)/boards/_actions.ts
@@ -0,0 +1,256 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  createBoardPost,
+  deleteBoardPost,
+  saveInquiryAnswer,
+  updateBoardPost,
+} from '@/lib/data/repositories/board-post-repository';
+import {
+  MAX_ATTACHMENT_BYTES,
+  uploadAttachment,
+} from '@/lib/data/repositories/file-repository';
+import {
+  ANSWER_STATUS_DONE,
+  BOARD_FILE_MODULE_ID,
+  type BoardType,
+} from '@/lib/domain/board-post';
+import {
+  validateBoardPostForm,
+  validateInquiryAnswerForm,
+  type BoardPostFormErrors,
+  type InquiryAnswerFormErrors,
+} from '@/lib/domain/board-post-form';
+import { BOARD_PATHS } from '@/lib/domain/board-post-query';
+
+/**
+ * 게시판(공지사항·1:1문의·FAQ) 등록/수정/삭제/답변 Server Action.
+ *
+ * 세 화면이 같은 API를 쓰므로 Action도 한 파일이 담당하고, 어느 게시판인지는 폼에 실린
+ * `boardType`으로 가른다. **그 값도 신뢰하지 않는다** — 허용 목록에 없으면 즉시 거절한다.
+ *
+ * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접
+ * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8).
+ *
+ * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/board-post-form.ts`의 규칙을 호출).
+ *
+ * 첨부파일은 **같은 폼에 실려 온 File을 이 Action이 직접 업로드**한다. 브라우저에서 먼저 업로드해
+ * id를 받아 두는 방식보다 단순하고(왕복 1회), 업로드 API가 요구하는 관리자 토큰이 서버에만
+ * 있으므로 자연스럽다 — 토큰은 httpOnly 세션 안에 있어 브라우저가 꺼낼 수 없다.
+ */
+
+const BOARD_TYPES: readonly BoardType[] = ['notice', 'inquiry', 'faq'];
+
+export type BoardPostFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: BoardPostFormErrors }
+  | { status: 'success' };
+
+export type InquiryAnswerFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: InquiryAnswerFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_BOARD_POST_FORM_STATE: BoardPostFormState = {
+  status: 'idle',
+};
+export const INITIAL_INQUIRY_ANSWER_FORM_STATE: InquiryAnswerFormState = {
+  status: 'idle',
+};
+
+const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
+const UPLOAD_FAILED_MESSAGE =
+  '첨부파일을 업로드하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const OVERSIZE_MESSAGE = `첨부파일은 ${Math.floor(
+  MAX_ATTACHMENT_BYTES / (1024 * 1024)
+)}MB 이하만 올릴 수 있습니다.`;
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+/** 체크박스·라디오는 값이 있을 때만 전송되므로 "Y로 온 경우"만 참으로 본다. */
+function readBoolean(formData: FormData, key: string): boolean {
+  return readString(formData, key) === 'Y';
+}
+
+function readBoardType(formData: FormData): BoardType | null {
+  const raw = readString(formData, 'boardType');
+  return BOARD_TYPES.find((type) => type === raw) ?? null;
+}
+
+/**
+ * 새로 첨부된 파일이 있으면 업로드하고 새 id를, 없으면 기존 id를 그대로 돌려준다.
+ *
+ * 빈 파일(파일을 고르지 않으면 브라우저가 size 0으로 보낸다)은 "선택 안 함"이므로 무시한다 —
+ * 이때 기존 첨부를 지우지 않는 것이 의도다(수정 화면에서 첨부를 건드리지 않은 경우).
+ */
+async function resolveAttachmentId(formData: FormData): Promise<string> {
+  const existingId = readString(formData, 'attachmentId');
+  const file = formData.get('attachment');
+
+  if (!(file instanceof File) || file.size === 0) {
+    return existingId;
+  }
+
+  if (file.size > MAX_ATTACHMENT_BYTES) {
+    throw new Error(OVERSIZE_MESSAGE);
+  }
+
+  return uploadAttachment(file, BOARD_FILE_MODULE_ID);
+}
+
+/** 등록·수정이 공유하는 입력 읽기. */
+function readPostValues(formData: FormData, attachmentId: string) {
+  return {
+    categoryCode: readString(formData, 'categoryCode'),
+    typeCode: readString(formData, 'typeCode'),
+    title: readString(formData, 'title'),
+    content: readString(formData, 'content'),
+    attachmentId,
+    isPinned: readBoolean(formData, 'isPinned'),
+    isVisible: readBoolean(formData, 'isVisible'),
+    isPushEnabled: readBoolean(formData, 'isPushEnabled'),
+    startDate: readString(formData, 'startDate'),
+    endDate: readString(formData, 'endDate'),
+  };
+}
+
+/** 시안 A_BOA_007_p / 015_p — 공지사항·FAQ 신규 등록. */
+export async function createBoardPostAction(
+  _prevState: BoardPostFormState,
+  formData: FormData
+): Promise<BoardPostFormState> {
+  await verifySession();
+
+  const boardType = readBoardType(formData);
+  if (boardType === null || boardType === 'inquiry') {
+    // 1:1문의는 사용자가 작성하는 글이라 관리자 등록 경로가 없다(사용자 확인 사항).
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  let attachmentId: string;
+  try {
+    attachmentId = await resolveAttachmentId(formData);
+  } catch (error) {
+    return {
+      status: 'error',
+      message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE,
+    };
+  }
+
+  const validation = validateBoardPostForm(
+    readPostValues(formData, attachmentId),
+    { boardType, isCategoryEditable: true }
+  );
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  await createBoardPost(boardType, validation.values);
+
+  revalidatePath(BOARD_PATHS[boardType]);
+  return { status: 'success' };
+}
+
+/** 시안 A_BOA_008_p / 016_p — 공지사항·FAQ 수정. 구분은 읽기 전용이라 검증 대상이 아니다. */
+export async function updateBoardPostAction(
+  _prevState: BoardPostFormState,
+  formData: FormData
+): Promise<BoardPostFormState> {
+  await verifySession();
+
+  const boardType = readBoardType(formData);
+  const bbsId = readString(formData, 'id');
+  if (boardType === null || boardType === 'inquiry' || !bbsId) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  let attachmentId: string;
+  try {
+    attachmentId = await resolveAttachmentId(formData);
+  } catch (error) {
+    return {
+      status: 'error',
+      message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE,
+    };
+  }
+
+  const validation = validateBoardPostForm(
+    readPostValues(formData, attachmentId),
+    { boardType, isCategoryEditable: false }
+  );
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  await updateBoardPost(boardType, bbsId, validation.values);
+
+  revalidatePath(BOARD_PATHS[boardType]);
+  return { status: 'success' };
+}
+
+/** 시안 A_BOA_013_p — 1:1문의 답변 저장. */
+export async function saveInquiryAnswerAction(
+  _prevState: InquiryAnswerFormState,
+  formData: FormData
+): Promise<InquiryAnswerFormState> {
+  await verifySession();
+
+  const bbsId = readString(formData, 'id');
+  if (!bbsId) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  let attachmentId: string;
+  try {
+    attachmentId = await resolveAttachmentId(formData);
+  } catch (error) {
+    return {
+      status: 'error',
+      message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE,
+    };
+  }
+
+  const validation = validateInquiryAnswerForm(
+    {
+      answerStatusCode: readString(formData, 'answerStatusCode'),
+      answerContent: readString(formData, 'answerContent'),
+      attachmentId,
+    },
+    { doneStatusCode: ANSWER_STATUS_DONE }
+  );
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  await saveInquiryAnswer(bbsId, validation.values);
+
+  revalidatePath(BOARD_PATHS.inquiry);
+  return { status: 'success' };
+}
+
+/**
+ * 삭제(논리 삭제) — 확인 얼럿은 화면이 띄우고 여기서는 권한과 입력만 확인한다.
+ *
+ * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
+ * (prevState, formData) 규약 대신 값을 직접 받는다.
+ */
+export async function deleteBoardPostAction(
+  boardType: BoardType,
+  bbsId: string
+): Promise<BoardPostFormState> {
+  await verifySession();
+
+  if (!BOARD_TYPES.includes(boardType) || !bbsId) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  await deleteBoardPost(bbsId);
+
+  revalidatePath(BOARD_PATHS[boardType]);
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/boards/_components/board-attachment-field.tsx (added)
+++ app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { Field } from '@/components/ui/field';
+
+interface BoardAttachmentFieldProps {
+  label?: string;
+  /** 이미 저장돼 있는 첨부파일 식별자(수정 화면). 없으면 신규 등록이다. */
+  attachmentId?: string | null;
+}
+
+/** 다운로드 라우트 — 백엔드 파일 API를 중계한다(`boards/attachments/route.ts`). */
+const ATTACHMENT_DOWNLOAD_PATH = '/boards/attachments';
+
+/**
+ * 첨부파일 입력 — 파일을 고르면 **폼과 함께 그대로 제출**되고, Server Action이 업로드한 뒤
+ * 게시물에 연결한다(브라우저에서 미리 업로드하지 않는다 — `_actions.ts` 주석 참조).
+ *
+ * 기존 첨부는 **다운로드 링크로만** 보여준다. 시안(A_BOA_008_p)은 `pic_0001.jpg 88KB`처럼 파일명과
+ * 용량을 표기하지만, **백엔드에 atchFileId로 파일 정보를 조회하는 API가 없어** 저장된 첨부의
+ * 이름·크기를 알 방법이 없다(업로드·다운로드·삭제·이미지·스트림만 존재). 그 API가 생기면 여기에
+ * 파일명·용량 표기와 개별 삭제 버튼을 붙이면 된다.
+ *
+ * 새 파일을 고르면 기존 첨부는 교체된다(백엔드 필드가 `atchFileId` 하나뿐이다). 파일을 고르지
+ * 않으면 기존 첨부가 그대로 유지된다.
+ */
+export function BoardAttachmentField({
+  label = '첨부파일',
+  attachmentId,
+}: BoardAttachmentFieldProps) {
+  return (
+    <Field label={label}>
+      {/* 기존 첨부 id를 함께 제출해 "파일을 안 골랐으면 유지"를 서버가 판단할 수 있게 한다. */}
+      <input type="hidden" name="attachmentId" value={attachmentId ?? ''} />
+
+      {attachmentId ? (
+        <p className="text-body-sm text-foreground-muted">
+          기존 첨부파일{' '}
+          <a
+            href={`${ATTACHMENT_DOWNLOAD_PATH}?atchFileId=${encodeURIComponent(attachmentId)}`}
+            className="text-primary underline underline-offset-2"
+          >
+            다운로드
+          </a>{' '}
+          — 새 파일을 선택하면 교체됩니다.
+        </p>
+      ) : null}
+
+      <input
+        type="file"
+        name="attachment"
+        className="w-full text-body-sm text-foreground file:mr-3 file:rounded-md file:border file:border-border file:bg-surface file:px-3 file:py-1.5 file:text-body-sm file:text-foreground"
+      />
+    </Field>
+  );
+}
 
app/(protected)/(basic)/boards/_components/board-content-field.tsx (added)
+++ app/(protected)/(basic)/boards/_components/board-content-field.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { useState } from 'react';
+import { Field } from '@/components/ui/field';
+
+interface BoardContentFieldProps {
+  name: string;
+  label: string;
+  defaultValue?: string;
+  maxLength: number;
+  placeholder?: string;
+}
+
+/**
+ * 본문/답변 입력 — 글자수 카운트가 붙은 textarea(시안의 "50/1000자" 표기).
+ *
+ * 시안은 리치 텍스트 에디터(굵게·기울임·링크·목록)지만, 사용자 확인 결과 **이번 범위는 로직만**
+ * 이라 일반 textarea로 구현했다. 에디터 라이브러리 도입은 별도 협의 대상이다 — 도입하더라도
+ * 값이 `name`으로 제출되는 계약만 지키면 이 컴포넌트만 교체하면 된다.
+ *
+ * 상한 초과는 `maxLength` 속성으로 입력 단계에서 막지만, 최종 판정은 Server Action의 검증이다
+ * (화면 속성은 신뢰 경계가 아니다).
+ */
+export function BoardContentField({
+  name,
+  label,
+  defaultValue = '',
+  maxLength,
+  placeholder,
+}: BoardContentFieldProps) {
+  const [length, setLength] = useState(defaultValue.length);
+
+  return (
+    <Field label={label}>
+      <textarea
+        name={name}
+        defaultValue={defaultValue}
+        maxLength={maxLength}
+        placeholder={placeholder}
+        rows={6}
+        onChange={(event) => setLength(event.target.value.length)}
+        className="w-full rounded-md border border-border bg-surface px-3 py-2 text-body-md text-foreground placeholder:text-foreground-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
+      />
+      <p className="text-right text-body-sm text-foreground-muted">
+        {length}/{maxLength}자
+      </p>
+    </Field>
+  );
+}
 
app/(protected)/(basic)/boards/_components/board-filter-bar.tsx (added)
+++ app/(protected)/(basic)/boards/_components/board-filter-bar.tsx
@@ -0,0 +1,144 @@
+import Link from 'next/link';
+import { Button } from '@/components/ui/button';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Select } from '@/components/ui/select';
+import { BOARD_CATEGORY_OPTIONS, type BoardType } from '@/lib/domain/board-post';
+import {
+  ANSWERED_FILTER_OPTIONS,
+  BOARD_PATHS,
+  BOARD_QUERY_FIELDS,
+  BOARD_SEARCH_FIELDS,
+  BOARD_TYPE_OPTIONS,
+  VISIBILITY_FILTER_OPTIONS,
+  buildBoardPostHref,
+  type BoardPostQuery,
+} from '@/lib/domain/board-post-query';
+
+interface BoardFilterBarProps {
+  boardType: BoardType;
+  query: BoardPostQuery;
+}
+
+/**
+ * 검색·필터 영역 — 세 게시판이 공유한다. 어떤 필터를 그릴지는 `BOARD_QUERY_FIELDS`가 정한다
+ * (공지사항: 구분·사용여부 / 1:1문의: 구분·질문유형·답변여부 / FAQ: 구분·유형).
+ *
+ * 순수 `<form method="get">` GET 제출로 URL을 갱신한다(자바스크립트 없이도 동작). `pageSize`는
+ * hidden으로 함께 제출해 검색 후에도 유지되며, 검색 자체는 항상 1페이지로 돌아간다(hidden page를
+ * 두지 않아 `parseBoardPostQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라
+ * 기본값 링크라 현재 입력값과 무관하게 동작한다.
+ *
+ * 상호작용이 전부 네이티브 폼 제출/링크라 클라이언트 자바스크립트가 필요 없다 — Server Component.
+ */
+export function BoardFilterBar({ boardType, query }: BoardFilterBarProps) {
+  const fields = BOARD_QUERY_FIELDS[boardType];
+  const searchFields = BOARD_SEARCH_FIELDS[boardType];
+  const typeOptions = BOARD_TYPE_OPTIONS[boardType];
+
+  const resetHref = buildBoardPostHref(boardType, query, {
+    category: '',
+    type: '',
+    visibility: '',
+    answered: '',
+    searchField: searchFields[0].value,
+    keyword: '',
+    page: 1,
+  });
+
+  return (
+    <form
+      method="get"
+      action={BOARD_PATHS[boardType]}
+      className="flex flex-wrap items-end gap-3"
+    >
+      <input type="hidden" name="pageSize" value={query.pageSize} />
+
+      {fields.category && (
+        <Field label="구분">
+          <Select name="category" defaultValue={query.category}>
+            <option value="">전체</option>
+            {BOARD_CATEGORY_OPTIONS.map((option) => (
+              <option key={option.value} value={option.value}>
+                {option.label}
+              </option>
+            ))}
+          </Select>
+        </Field>
+      )}
+
+      {fields.type && (
+        <Field label={boardType === 'inquiry' ? '질문유형' : '유형'}>
+          <Select name="type" defaultValue={query.type}>
+            <option value="">전체</option>
+            {typeOptions.map((option) => (
+              <option key={option.value} value={option.value}>
+                {option.label}
+              </option>
+            ))}
+          </Select>
+        </Field>
+      )}
+
+      {fields.visibility && (
+        <Field label="사용여부">
+          <Select name="visibility" defaultValue={query.visibility}>
+            <option value="">전체</option>
+            {VISIBILITY_FILTER_OPTIONS.map((option) => (
+              <option key={option.value} value={option.value}>
+                {option.label}
+              </option>
+            ))}
+          </Select>
+        </Field>
+      )}
+
+      {fields.answered && (
+        <Field label="답변여부">
+          <Select name="answered" defaultValue={query.answered}>
+            <option value="">전체</option>
+            {ANSWERED_FILTER_OPTIONS.map((option) => (
+              <option key={option.value} value={option.value}>
+                {option.label}
+              </option>
+            ))}
+          </Select>
+        </Field>
+      )}
+
+      {/* 검색 대상이 하나뿐인 화면(공지사항)은 select를 그리지 않고 hidden으로만 싣는다. */}
+      {searchFields.length > 1 ? (
+        <Field label="검색 대상">
+          <Select name="searchField" defaultValue={query.searchField}>
+            {searchFields.map((option) => (
+              <option key={option.value} value={option.value}>
+                {option.label}
+              </option>
+            ))}
+          </Select>
+        </Field>
+      ) : (
+        <input type="hidden" name="searchField" value={searchFields[0].value} />
+      )}
+
+      <Field label="검색어">
+        <Input
+          type="text"
+          name="keyword"
+          defaultValue={query.keyword}
+          placeholder="검색어를 입력하세요."
+        />
+      </Field>
+
+      <Button type="submit" variant="secondary">
+        검색
+      </Button>
+      <Link
+        href={resetHref}
+        className="text-body-md text-foreground-muted underline-offset-2 hover:underline"
+      >
+        초기화
+      </Link>
+    </form>
+  );
+}
 
app/(protected)/(basic)/boards/_components/board-list-toolbar.tsx (added)
+++ app/(protected)/(basic)/boards/_components/board-list-toolbar.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, type ChangeEvent } from 'react';
+import { Button } from '@/components/ui/button';
+import { Select } from '@/components/ui/select';
+import type { BoardType } from '@/lib/domain/board-post';
+import {
+  BOARD_PAGE_SIZE_OPTIONS,
+  buildBoardPostHref,
+  type BoardPageSize,
+  type BoardPostQuery,
+} from '@/lib/domain/board-post-query';
+import { BoardPostFormModal } from './board-post-form-modal';
+
+interface BoardListToolbarProps {
+  boardType: BoardType;
+  query: BoardPostQuery;
+  /** 1:1문의는 관리자가 글을 등록하지 않으므로 false다(사용자 확인 사항). */
+  canCreate: boolean;
+}
+
+/**
+ * 목록 툴바 — [신규 등록]과 페이지 크기 select. 페이지 크기가 바뀌면 1페이지로 되돌린다
+ * (기존 페이지 번호가 새 크기 기준으로는 의미가 달라지므로).
+ *
+ * 시안의 "최근등록순" 정렬 select는 두지 않았다 — 백엔드 목록 SQL의 정렬이 최신순으로 고정돼
+ * 있고 시안의 select에도 다른 선택지가 없어, 선택지가 하나뿐인 컨트롤이 되기 때문이다.
+ * 정렬 기준이 추가되면 그때 select를 붙인다.
+ */
+export function BoardListToolbar({
+  boardType,
+  query,
+  canCreate,
+}: BoardListToolbarProps) {
+  const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+
+  function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
+    const pageSize = Number(event.target.value) as BoardPageSize;
+    router.replace(buildBoardPostHref(boardType, query, { pageSize, page: 1 }));
+  }
+
+  return (
+    <div className="flex flex-wrap items-center justify-end gap-3">
+      {canCreate && (
+        <Button
+          type="button"
+          variant="primary"
+          onClick={() => setIsCreateOpen(true)}
+        >
+          + 신규 등록
+        </Button>
+      )}
+
+      <Select
+        name="pageSize"
+        aria-label="페이지 크기"
+        defaultValue={query.pageSize}
+        onChange={handlePageSizeChange}
+      >
+        {BOARD_PAGE_SIZE_OPTIONS.map((size) => (
+          <option key={size} value={size}>
+            {size}개씩
+          </option>
+        ))}
+      </Select>
+
+      {isCreateOpen && (
+        <BoardPostFormModal
+          boardType={boardType}
+          mode="create"
+          onClose={() => setIsCreateOpen(false)}
+        />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx (added)
+++ app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
@@ -0,0 +1,258 @@
+'use client';
+
+import { useActionState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Modal } from '@/components/ui/modal';
+import { RadioGroup } from '@/components/ui/radio-group';
+import { Select } from '@/components/ui/select';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  BOARD_CATEGORY_OPTIONS,
+  formatBoardCategoryLabel,
+  formatOptionalValue,
+  type BoardPost,
+  type BoardType,
+} from '@/lib/domain/board-post';
+import {
+  CONTENT_MAX_LENGTH,
+  TITLE_MAX_LENGTH,
+} from '@/lib/domain/board-post-form';
+import { BOARD_TYPE_OPTIONS } from '@/lib/domain/board-post-query';
+import {
+  INITIAL_BOARD_POST_FORM_STATE,
+  createBoardPostAction,
+  updateBoardPostAction,
+} from '../_actions';
+import { BoardAttachmentField } from './board-attachment-field';
+import { BoardContentField } from './board-content-field';
+
+interface BoardPostFormModalProps {
+  boardType: BoardType;
+  mode: 'create' | 'edit';
+  /** 수정 모드의 기존 값. 등록 모드는 넘기지 않는다. */
+  post?: BoardPost;
+  onClose: () => void;
+}
+
+const FORM_ID = 'board-post-form';
+
+const PINNED_OPTIONS = [
+  { value: 'N', label: '사용안함' },
+  { value: 'Y', label: '사용' },
+];
+const VISIBILITY_OPTIONS = [
+  { value: 'Y', label: '노출' },
+  { value: 'N', label: '미노출' },
+];
+const PUSH_OPTIONS = [
+  { value: 'Y', label: '발송' },
+  { value: 'N', label: '미발송' },
+];
+
+const BOARD_TITLES: Record<BoardType, string> = {
+  notice: '공지사항',
+  inquiry: '1:1문의',
+  faq: 'FAQ',
+};
+
+/**
+ * 공지사항·FAQ의 등록/수정 팝업 — 시안 A_BOA_007_p·008_p(공지사항), 015_p·016_p(FAQ).
+ *
+ * 네 시안이 같은 폼의 변형이라 한 컴포넌트가 게시판 종류(`boardType`)와 모드(`mode`)로 분기한다.
+ * 게시판별 차이는 두 가지뿐이다 — 공지사항에만 상단고정·노출기간·앱푸쉬가, FAQ에만 유형이 있다.
+ *
+ * **수정 시 구분은 읽기 전용이다**(시안 ①). 값 자체를 제출하지 않고, Server Action도 수정
+ * 경로에서는 구분을 검증하지 않는다 — 직접 POST로 다른 값을 보내도 반영되지 않는다.
+ *
+ * 첨부파일이 있어 폼은 multipart로 제출된다(Server Action은 이를 자동으로 처리한다).
+ * 저장 버튼은 footer에서 `form={FORM_ID}`로 연결한다 — 네이티브 HTML의 `form` 속성은 버튼이
+ * DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을 제출한다.
+ */
+export function BoardPostFormModal({
+  boardType,
+  mode,
+  post,
+  onClose,
+}: BoardPostFormModalProps) {
+  const { showToast } = useFeedback();
+  const isCreate = mode === 'create';
+
+  const [state, formAction, isPending] = useActionState(
+    isCreate ? createBoardPostAction : updateBoardPostAction,
+    INITIAL_BOARD_POST_FORM_STATE
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({
+        variant: 'success',
+        message: isCreate ? '게시물을 등록했습니다.' : '게시물을 수정했습니다.',
+      });
+      onClose();
+    }
+  }, [state, showToast, onClose, isCreate]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+  const typeOptions = BOARD_TYPE_OPTIONS[boardType];
+  const hasNoticeOnlyFields = boardType === 'notice';
+
+  return (
+    <Modal
+      title={`${BOARD_TITLES[boardType]} ${isCreate ? '신규 등록' : '수정'}`}
+      onClose={onClose}
+      footer={
+        <>
+          <Button type="button" variant="ghost" onClick={onClose}>
+            취소
+          </Button>
+          <Button
+            type="submit"
+            form={FORM_ID}
+            variant="primary"
+            disabled={isPending}
+          >
+            {isPending ? '저장 중...' : isCreate ? '등록' : '수정'}
+          </Button>
+        </>
+      }
+    >
+      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+        <input type="hidden" name="boardType" value={boardType} />
+        {!isCreate && post && <input type="hidden" name="id" value={post.id} />}
+
+        <p className="text-right text-body-sm text-danger">
+          * 는 필수 항목입니다.
+        </p>
+
+        <Field label={isCreate ? '구분 *' : '구분'}>
+          {isCreate ? (
+            <Select name="categoryCode" defaultValue={BOARD_CATEGORY_OPTIONS[0].value}>
+              {BOARD_CATEGORY_OPTIONS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </Select>
+          ) : (
+            <Input
+              value={formatBoardCategoryLabel(post?.categoryCode ?? null)}
+              readOnly
+            />
+          )}
+        </Field>
+        <FieldError message={errors.categoryCode} />
+
+        {typeOptions.length > 0 && (
+          <>
+            <Field label="유형 *">
+              <Select name="typeCode" defaultValue={post?.typeCode ?? ''}>
+                <option value="">선택</option>
+                {typeOptions.map((option) => (
+                  <option key={option.value} value={option.value}>
+                    {option.label}
+                  </option>
+                ))}
+              </Select>
+            </Field>
+            <FieldError message={errors.typeCode} />
+          </>
+        )}
+
+        <Field label="제목 *">
+          <Input
+            type="text"
+            name="title"
+            defaultValue={post?.title ?? ''}
+            maxLength={TITLE_MAX_LENGTH}
+            placeholder="제목을 입력하세요."
+          />
+        </Field>
+        <FieldError message={errors.title} />
+
+        <BoardContentField
+          name="content"
+          label="내용 *"
+          defaultValue={post?.content ?? ''}
+          maxLength={CONTENT_MAX_LENGTH}
+          placeholder="내용을 입력하세요."
+        />
+        <FieldError message={errors.content} />
+
+        <BoardAttachmentField attachmentId={post?.attachmentId} />
+
+        {hasNoticeOnlyFields && (
+          <>
+            <Field label="상단고정">
+              <RadioGroup
+                name="isPinned"
+                options={PINNED_OPTIONS}
+                defaultValue={post?.isPinned ? 'Y' : 'N'}
+              />
+            </Field>
+
+            <Field label="노출기간">
+              <div className="flex items-center gap-2">
+                <Input
+                  type="date"
+                  name="startDate"
+                  defaultValue={post?.startDate ?? ''}
+                  aria-label="게시시작일시"
+                />
+                <span className="text-foreground-muted">~</span>
+                <Input
+                  type="date"
+                  name="endDate"
+                  defaultValue={post?.endDate ?? ''}
+                  aria-label="게시종료일시"
+                />
+              </div>
+            </Field>
+            <FieldError message={errors.startDate} />
+            <FieldError message={errors.endDate} />
+          </>
+        )}
+
+        <Field label="사용여부 *">
+          <RadioGroup
+            name="isVisible"
+            options={VISIBILITY_OPTIONS}
+            defaultValue={post?.isVisible === false ? 'N' : 'Y'}
+          />
+        </Field>
+
+        {hasNoticeOnlyFields && (
+          <Field label="앱푸쉬 설정 *">
+            <RadioGroup
+              name="isPushEnabled"
+              options={PUSH_OPTIONS}
+              defaultValue={post?.isPushEnabled === false ? 'N' : 'Y'}
+            />
+          </Field>
+        )}
+
+        {!isCreate && post && (
+          <Field label="작성자 / 작성일">
+            <div className="flex items-center gap-2">
+              <Input value={formatOptionalValue(post.authorName)} readOnly />
+              <Input value={formatOptionalValue(post.createdAt)} readOnly />
+            </div>
+          </Field>
+        )}
+
+        {state.status === 'error' && state.message && (
+          <p className="text-body-sm text-danger">{state.message}</p>
+        )}
+      </form>
+    </Modal>
+  );
+}
+
+/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
+export function FieldError({ message }: { message?: string }) {
+  if (!message) {
+    return null;
+  }
+  return <p className="text-body-sm text-danger">{message}</p>;
+}
 
app/(protected)/(basic)/boards/_components/board-row-actions.tsx (added)
+++ app/(protected)/(basic)/boards/_components/board-row-actions.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+import { Button } from '@/components/ui/button';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { BoardPost, BoardType } from '@/lib/domain/board-post';
+import { deleteBoardPostAction } from '../_actions';
+import { BoardPostFormModal } from './board-post-form-modal';
+
+interface BoardRowActionsProps {
+  boardType: BoardType;
+  post: BoardPost;
+}
+
+/**
+ * 목록 행의 "관리" 셀(시안 A_BOA_006 ④ / 014 ④) — 수정 팝업의 열림 상태만 소유하는 최말단
+ * 상호작용 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다).
+ *
+ * 삭제는 시안대로 **확인 얼럿을 거친 뒤** 실행한다. 백엔드는 물리 삭제가 아니라 `DEL_YN='Y'`로
+ * 표시만 바꾸지만, 관리자 화면에서 다시 볼 방법이 없으므로 사용자에게는 되돌릴 수 없는 동작으로
+ * 안내한다.
+ *
+ * 시안은 연필·휴지통 아이콘 버튼을 지정하지만 `components/ui/icons/`에 두 아이콘이 아직 없어
+ * (신규 아이콘 추가는 design 레인 소관) 텍스트 버튼으로 대체했다 — 관리자 회원 목록과 같은 처리다.
+ */
+export function BoardRowActions({ boardType, post }: BoardRowActionsProps) {
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isEditOpen, setIsEditOpen] = useState(false);
+  const [isDeleting, startDeleting] = useTransition();
+
+  function runDelete() {
+    hideAlert();
+    startDeleting(async () => {
+      const result = await deleteBoardPostAction(boardType, post.id);
+      if (result.status === 'error') {
+        showToast({
+          variant: 'danger',
+          message: result.message ?? '삭제하지 못했습니다.',
+        });
+        return;
+      }
+      showToast({ variant: 'success', message: '게시물을 삭제했습니다.' });
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '게시물을 삭제하시겠습니까?',
+      message: `"${post.title}" 게시물을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <>
+          <Button type="button" variant="ghost" onClick={hideAlert}>
+            취소
+          </Button>
+          <Button type="button" variant="primary" onClick={runDelete}>
+            삭제
+          </Button>
+        </>
+      ),
+    });
+  }
+
+  return (
+    <div className="flex items-center gap-1">
+      <Button
+        type="button"
+        variant="ghost"
+        size="sm"
+        onClick={() => setIsEditOpen(true)}
+      >
+        수정
+      </Button>
+      <Button
+        type="button"
+        variant="ghost"
+        size="sm"
+        onClick={confirmDelete}
+        disabled={isDeleting}
+      >
+        {isDeleting ? '삭제 중...' : '삭제'}
+      </Button>
+
+      {isEditOpen && (
+        <BoardPostFormModal
+          boardType={boardType}
+          mode="edit"
+          post={post}
+          onClose={() => setIsEditOpen(false)}
+        />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/boards/attachments/route.ts (added)
+++ app/(protected)/(basic)/boards/attachments/route.ts
@@ -0,0 +1,64 @@
+import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
+import { backendFetchStream } from '@/lib/http/backend-fetch';
+
+/**
+ * 게시판 첨부파일 다운로드 — 백엔드 파일을 브라우저로 중계한다.
+ *
+ * 왜 라우트 핸들러인가: 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). 백엔드 파일
+ * API는 토큰을 요구하는데 그 토큰은 httpOnly 세션 안에만 있어 브라우저가 꺼낼 수 없다. 학생 회원
+ * 엑셀 다운로드와 같은 구조다.
+ *
+ * 본문은 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다(서버 메모리에 파일 전체를 올리지
+ * 않는다). 파일명·MIME도 백엔드 헤더를 그대로 전달한다.
+ */
+
+const FILE_DOWNLOAD_PATH = '/api/v1/common/file/download';
+const DOWNLOAD_FAILED_MESSAGE =
+  '첨부파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DOWNLOAD_TIMEOUT_MS = 60_000;
+
+export async function GET(request: Request) {
+  // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다.
+  await verifySession();
+
+  const url = new URL(request.url);
+  const attachmentId = url.searchParams.get('atchFileId');
+  if (!attachmentId) {
+    return new Response('첨부파일 식별자가 없습니다.', {
+      status: 400,
+      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+    });
+  }
+
+  // 백엔드는 파일 일련번호(fileSn)로 개별 파일을 지목한다. 게시판은 파일 하나만 붙이므로 1이
+  // 기본이며, 여러 개를 붙이게 되면 호출부가 번호를 넘긴다.
+  const fileSn = url.searchParams.get('fileSn') ?? '1';
+
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetchStream(FILE_DOWNLOAD_PATH, {
+    query: { atchFileId: attachmentId, fileSn },
+    accessToken: accessToken ?? undefined,
+    timeoutMs: DOWNLOAD_TIMEOUT_MS,
+  });
+
+  if (!result.ok) {
+    // 실패 사유는 backendFetchStream이 서버 콘솔에 남긴다. 화면에는 일반화된 문구만 내보낸다.
+    return new Response(DOWNLOAD_FAILED_MESSAGE, {
+      status: 502,
+      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+    });
+  }
+
+  const upstream = result.data;
+
+  return new Response(upstream.body, {
+    status: 200,
+    headers: {
+      'Content-Type':
+        upstream.headers.get('content-type') ?? 'application/octet-stream',
+      'Content-Disposition':
+        upstream.headers.get('content-disposition') ?? 'attachment',
+    },
+  });
+}
 
app/(protected)/(basic)/boards/error.tsx (added)
+++ app/(protected)/(basic)/boards/error.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { useEffect } from 'react';
+
+interface ErrorPageProps {
+  error: Error & { digest?: string };
+  unstable_retry: () => void;
+}
+
+// 게시판 세 화면이 공유하는 에러 경계. 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만
+// 콘솔에 남기고(프로덕션에서는 Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로
+// 치환해 전달한다), 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라
+// 세그먼트 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다.
+export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
+  useEffect(() => {
+    console.error(error);
+  }, [error]);
+
+  return (
+    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
+      <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1>
+      <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p>
+      <button
+        type="button"
+        onClick={() => unstable_retry()}
+        className="mt-4 bg-foreground px-4 py-2 text-background"
+      >
+        다시 시도
+      </button>
+    </section>
+  );
+}
 
app/(protected)/(basic)/boards/faqs/_components/faq-table.tsx (added)
+++ app/(protected)/(basic)/boards/faqs/_components/faq-table.tsx
@@ -0,0 +1,73 @@
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeaderCell,
+  TableRow,
+} from '@/components/ui/table';
+import {
+  formatBoardCategoryLabel,
+  formatFaqTypeLabel,
+  formatOptionalValue,
+  formatVisibilityLabel,
+  type BoardPost,
+} from '@/lib/domain/board-post';
+import { BoardRowActions } from '../../_components/board-row-actions';
+
+interface FaqTableProps {
+  items: BoardPost[];
+  page: number;
+  pageSize: number;
+  totalCount: number;
+}
+
+const COLUMN_HEADERS = [
+  '번호',
+  '구분',
+  '유형',
+  '제목',
+  '작성자',
+  '등록일',
+  '사용여부',
+  '관리',
+];
+
+/**
+ * FAQ 목록 테이블 — 시안 A_BOA_014 ③.
+ *
+ * "유형"은 현재 항상 `-`다 — 백엔드에 유형 저장 필드가 아직 없어 조회 응답에 값이 없다
+ * (`board-post.ts`의 `BOARD_TYPE_CODE_PARAM` 주석). 열은 유지해 두었으므로 백엔드에 필드가
+ * 추가되면 Repository 매핑만 늘리면 그대로 채워진다.
+ */
+export function FaqTable({ items, page, pageSize, totalCount }: FaqTableProps) {
+  const offset = (page - 1) * pageSize;
+
+  return (
+    <Table>
+      <TableHead>
+        <TableRow>
+          {COLUMN_HEADERS.map((header) => (
+            <TableHeaderCell key={header}>{header}</TableHeaderCell>
+          ))}
+        </TableRow>
+      </TableHead>
+      <TableBody>
+        {items.map((post, index) => (
+          <TableRow key={post.id}>
+            <TableCell>{totalCount - offset - index}</TableCell>
+            <TableCell>{formatBoardCategoryLabel(post.categoryCode)}</TableCell>
+            <TableCell>{formatFaqTypeLabel(post.typeCode)}</TableCell>
+            <TableCell>{formatOptionalValue(post.title)}</TableCell>
+            <TableCell>{formatOptionalValue(post.authorName)}</TableCell>
+            <TableCell>{formatOptionalValue(post.createdAt)}</TableCell>
+            <TableCell>{formatVisibilityLabel(post.isVisible)}</TableCell>
+            <TableCell>
+              <BoardRowActions boardType="faq" post={post} />
+            </TableCell>
+          </TableRow>
+        ))}
+      </TableBody>
+    </Table>
+  );
+}
 
app/(protected)/(basic)/boards/faqs/page.tsx (added)
+++ app/(protected)/(basic)/boards/faqs/page.tsx
@@ -0,0 +1,78 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { Alert } from '@/components/ui/alert';
+import { Pagination } from '@/components/ui/pagination';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
+import {
+  buildBoardPostHref,
+  parseBoardPostQuery,
+} from '@/lib/domain/board-post-query';
+import { BoardFilterBar } from '../_components/board-filter-bar';
+import { BoardListToolbar } from '../_components/board-list-toolbar';
+import { FaqTable } from './_components/faq-table';
+
+export const metadata: Metadata = {
+  title: 'FAQ',
+};
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+/** FAQ 목록 — 시안 A_BOA_014. 구조는 공지사항과 같고 컬럼·필터만 다르다. */
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseBoardPostQuery('faq', await searchParams);
+  const { items, totalCount } = await fetchBoardPosts('faq', query);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <section className="flex flex-1 flex-col gap-6 p-8">
+      <h1 className="text-headline-sm text-foreground">FAQ 목록</h1>
+
+      <BoardFilterBar boardType="faq" query={query} />
+
+      <BoardListToolbar boardType="faq" query={query} canCreate />
+
+      <p className="text-body-md text-foreground-muted">
+        총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
+      </p>
+
+      {items.length === 0 ? (
+        <Alert
+          title="검색 결과가 없습니다."
+          actions={
+            <Link
+              href={buildBoardPostHref('faq', query, { keyword: '', page: 1 })}
+              className="bg-foreground px-4 py-2 text-background"
+            >
+              확인
+            </Link>
+          }
+        >
+          다른 검색어로 다시 시도해 주세요.
+        </Alert>
+      ) : (
+        <>
+          <FaqTable
+            items={items}
+            page={currentPage}
+            pageSize={query.pageSize}
+            totalCount={totalCount}
+          />
+          <Pagination
+            currentPage={currentPage}
+            totalPages={totalPages}
+            buildHref={(page: number) =>
+              buildBoardPostHref('faq', query, { page })
+            }
+          />
+        </>
+      )}
+    </section>
+  );
+}
 
app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx (added)
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
@@ -0,0 +1,163 @@
+'use client';
+
+import { useActionState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Modal } from '@/components/ui/modal';
+import { Select } from '@/components/ui/select';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  ANSWER_STATUS_OPTIONS,
+  formatInquiryTypeLabel,
+  formatOptionalValue,
+  type BoardPost,
+} from '@/lib/domain/board-post';
+import { ANSWER_MAX_LENGTH } from '@/lib/domain/board-post-form';
+import {
+  INITIAL_INQUIRY_ANSWER_FORM_STATE,
+  saveInquiryAnswerAction,
+} from '../../_actions';
+import { BoardAttachmentField } from '../../_components/board-attachment-field';
+import { BoardContentField } from '../../_components/board-content-field';
+import { FieldError } from '../../_components/board-post-form-modal';
+
+interface InquiryAnswerModalProps {
+  post: BoardPost;
+  onClose: () => void;
+}
+
+const FORM_ID = 'inquiry-answer-form';
+
+/**
+ * 1:1문의 상세 및 답변 팝업 — 시안 A_BOA_013_p.
+ *
+ * 질문자가 작성한 항목(질문유형·제목·질문자명·작성일·이메일·전화번호·질문내용)은 **전부 읽기
+ * 전용**이고, 관리자가 바꾸는 것은 진행상태·답변내용·답변첨부 셋뿐이다. 그래서 읽기 전용 값은
+ * 폼에 싣지 않는다 — Server Action도 그 값들을 읽지 않으므로 위조해 보내도 반영되지 않는다.
+ *
+ * 전화번호는 **마스킹하지 않고 원본을 보여준다** — 목록에서는 마스킹하지만(시안 A_BOA_012),
+ * 상세는 답변을 위해 연락처를 확인하는 화면이라 시안에도 원본(`010-1234-5678`)으로 표기돼 있다.
+ *
+ * 첨부는 **답변 첨부만** 다룬다(사용자 확인 사항). 백엔드 필드가 `atchFileId` 하나뿐이라 질문
+ * 첨부와 공유되며, 시안에도 "질문 첨부파일 → 일단 삭제" 메모가 있다.
+ *
+ * 시안 메모의 "답변완료 시 사용자에게 앱푸쉬 전송"은 **백엔드 미구현**이다 — 게시판 서비스의 푸시
+ * 분기가 빈 블록이라 프론트가 할 수 있는 일이 없다. 별도로 보고한 사항이다.
+ */
+export function InquiryAnswerModal({ post, onClose }: InquiryAnswerModalProps) {
+  const { showToast } = useFeedback();
+  const [state, formAction, isPending] = useActionState(
+    saveInquiryAnswerAction,
+    INITIAL_INQUIRY_ANSWER_FORM_STATE
+  );
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({ variant: 'success', message: '답변을 저장했습니다.' });
+      onClose();
+    }
+  }, [state, showToast, onClose]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <Modal
+      title="1:1문의 상세 및 답변"
+      onClose={onClose}
+      footer={
+        <>
+          <Button type="button" variant="ghost" onClick={onClose}>
+            취소
+          </Button>
+          <Button
+            type="submit"
+            form={FORM_ID}
+            variant="primary"
+            disabled={isPending}
+          >
+            {isPending ? '저장 중...' : '저장'}
+          </Button>
+        </>
+      }
+    >
+      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+        <input type="hidden" name="id" value={post.id} />
+
+        <Field label="질문유형">
+          <Input value={formatInquiryTypeLabel(post.typeCode)} readOnly />
+        </Field>
+
+        <p className="text-body-sm text-foreground-muted">
+          아래 질문 항목은 조회 전용입니다.
+        </p>
+
+        <Field label="질문제목">
+          <Input value={formatOptionalValue(post.title)} readOnly />
+        </Field>
+
+        <div className="flex gap-2">
+          <Field label="질문자명" className="flex-1">
+            <Input value={formatOptionalValue(post.authorName)} readOnly />
+          </Field>
+          <Field label="작성일" className="flex-1">
+            <Input value={formatOptionalValue(post.createdAt)} readOnly />
+          </Field>
+        </div>
+
+        <div className="flex gap-2">
+          <Field label="이메일" className="flex-1">
+            <Input value={formatOptionalValue(post.authorEmail)} readOnly />
+          </Field>
+          <Field label="전화번호" className="flex-1">
+            <Input value={formatOptionalValue(post.authorPhoneNumber)} readOnly />
+          </Field>
+        </div>
+
+        <Field label="질문내용">
+          <textarea
+            value={post.content}
+            readOnly
+            rows={5}
+            className="w-full cursor-default rounded-md border border-border bg-surface-muted px-3 py-2 text-body-md text-foreground"
+          />
+        </Field>
+
+        <Field label="진행상태 *">
+          <Select
+            name="answerStatusCode"
+            defaultValue={post.answerStatusCode ?? ANSWER_STATUS_OPTIONS[0].value}
+          >
+            {ANSWER_STATUS_OPTIONS.map((option) => (
+              <option key={option.value} value={option.value}>
+                {option.label}
+              </option>
+            ))}
+          </Select>
+        </Field>
+        <p className="text-body-sm text-foreground-muted">
+          답변완료를 선택한 경우에만 사용자 화면에 답변 내용이 노출됩니다.
+        </p>
+        <FieldError message={errors.answerStatusCode} />
+
+        <BoardContentField
+          name="answerContent"
+          label="답변내용"
+          defaultValue={post.answerContent ?? ''}
+          maxLength={ANSWER_MAX_LENGTH}
+          placeholder="답변 내용을 입력하세요."
+        />
+        <FieldError message={errors.answerContent} />
+
+        <BoardAttachmentField
+          label="답변 첨부파일"
+          attachmentId={post.attachmentId}
+        />
+
+        {state.status === 'error' && state.message && (
+          <p className="text-body-sm text-danger">{state.message}</p>
+        )}
+      </form>
+    </Modal>
+  );
+}
 
app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx (added)
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+import { Button } from '@/components/ui/button';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { BoardPost } from '@/lib/domain/board-post';
+import { deleteBoardPostAction } from '../../_actions';
+import { InquiryAnswerModal } from './inquiry-answer-modal';
+
+interface InquiryRowActionsProps {
+  post: BoardPost;
+}
+
+/**
+ * 1:1문의 행의 "관리" 셀 — 시안 A_BOA_012 ③.
+ *
+ * 공지사항·FAQ의 `BoardRowActions`와 나눈 이유: 여기서 여는 팝업이 수정 폼이 아니라 **상세 및 답변**
+ * 팝업이고(관리자는 질문 글을 수정하지 않는다), 등록 경로도 없다. 삭제만 동작이 같다.
+ */
+export function InquiryRowActions({ post }: InquiryRowActionsProps) {
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isAnswerOpen, setIsAnswerOpen] = useState(false);
+  const [isDeleting, startDeleting] = useTransition();
+
+  function runDelete() {
+    hideAlert();
+    startDeleting(async () => {
+      const result = await deleteBoardPostAction('inquiry', post.id);
+      if (result.status === 'error') {
+        showToast({
+          variant: 'danger',
+          message: result.message ?? '삭제하지 못했습니다.',
+        });
+        return;
+      }
+      showToast({ variant: 'success', message: '문의를 삭제했습니다.' });
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '문의를 삭제하시겠습니까?',
+      message: `"${post.title}" 문의를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <>
+          <Button type="button" variant="ghost" onClick={hideAlert}>
+            취소
+          </Button>
+          <Button type="button" variant="primary" onClick={runDelete}>
+            삭제
+          </Button>
+        </>
+      ),
+    });
+  }
+
+  return (
+    <div className="flex items-center gap-1">
+      <Button
+        type="button"
+        variant="ghost"
+        size="sm"
+        onClick={() => setIsAnswerOpen(true)}
+      >
+        답변
+      </Button>
+      <Button
+        type="button"
+        variant="ghost"
+        size="sm"
+        onClick={confirmDelete}
+        disabled={isDeleting}
+      >
+        {isDeleting ? '삭제 중...' : '삭제'}
+      </Button>
+
+      {isAnswerOpen && (
+        <InquiryAnswerModal post={post} onClose={() => setIsAnswerOpen(false)} />
+      )}
+    </div>
+  );
+}
 
app/(protected)/(basic)/boards/inquiries/_components/inquiry-table.tsx (added)
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-table.tsx
@@ -0,0 +1,84 @@
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeaderCell,
+  TableRow,
+} from '@/components/ui/table';
+import {
+  formatAnswerStatusLabel,
+  formatInquiryTypeLabel,
+  formatOptionalValue,
+  maskPhoneNumber,
+  type BoardPost,
+} from '@/lib/domain/board-post';
+import { InquiryRowActions } from './inquiry-row-actions';
+
+interface InquiryTableProps {
+  items: BoardPost[];
+  page: number;
+  pageSize: number;
+  totalCount: number;
+}
+
+const COLUMN_HEADERS = [
+  '번호',
+  '질문유형',
+  '질문제목',
+  '질문자명',
+  '전화번호',
+  '질문일',
+  '상태',
+  '답변일',
+  '관리',
+];
+
+/**
+ * 1:1문의 목록 테이블 — 시안 A_BOA_012 ②.
+ *
+ * **전화번호는 마스킹해서 표시한다**(사용자 확인 사항 — 시안의 `010-12**-**78`). 상세 팝업에서는
+ * 답변을 위해 연락처를 확인해야 하므로 원본을 보여준다.
+ *
+ * "답변일"은 답변이 실제로 달린 경우에만 값이 있고 그 외에는 `-`다(시안 ② "답변일은 답변완료
+ * 시에만 표시").
+ */
+export function InquiryTable({
+  items,
+  page,
+  pageSize,
+  totalCount,
+}: InquiryTableProps) {
+  const offset = (page - 1) * pageSize;
+
+  return (
+    <Table>
+      <TableHead>
+        <TableRow>
+          {COLUMN_HEADERS.map((header) => (
+            <TableHeaderCell key={header}>{header}</TableHeaderCell>
+          ))}
+        </TableRow>
+      </TableHead>
+      <TableBody>
+        {items.map((post, index) => (
+          <TableRow key={post.id}>
+            <TableCell>{totalCount - offset - index}</TableCell>
+            <TableCell>{formatInquiryTypeLabel(post.typeCode)}</TableCell>
+            <TableCell>{formatOptionalValue(post.title)}</TableCell>
+            <TableCell>{formatOptionalValue(post.authorName)}</TableCell>
+            <TableCell>{maskPhoneNumber(post.authorPhoneNumber)}</TableCell>
+            <TableCell>{formatOptionalValue(post.createdAt)}</TableCell>
+            <TableCell>
+              {formatAnswerStatusLabel(post.answerStatusCode)}
+            </TableCell>
+            <TableCell>{formatOptionalValue(post.answeredAt)}</TableCell>
+            <TableCell>
+              <InquiryRowActions post={post} />
+            </TableCell>
+          </TableRow>
+        ))}
+      </TableBody>
+    </Table>
+  );
+}
 
app/(protected)/(basic)/boards/inquiries/page.tsx (added)
+++ app/(protected)/(basic)/boards/inquiries/page.tsx
@@ -0,0 +1,90 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { Alert } from '@/components/ui/alert';
+import { Pagination } from '@/components/ui/pagination';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
+import {
+  buildBoardPostHref,
+  parseBoardPostQuery,
+} from '@/lib/domain/board-post-query';
+import { BoardFilterBar } from '../_components/board-filter-bar';
+import { BoardListToolbar } from '../_components/board-list-toolbar';
+import { InquiryTable } from './_components/inquiry-table';
+
+export const metadata: Metadata = {
+  title: '1:1문의',
+};
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+/**
+ * 1:1문의 목록 — 시안 A_BOA_012.
+ *
+ * 관리자는 글을 등록하지 않고 **조회·답변·삭제만** 한다(사용자 확인 사항) — 그래서 툴바의
+ * [신규 등록]을 끈다. 등록을 막는 것은 화면뿐 아니라 Server Action도 마찬가지다.
+ */
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseBoardPostQuery('inquiry', await searchParams);
+  const { items, totalCount } = await fetchBoardPosts('inquiry', query);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <section className="flex flex-1 flex-col gap-6 p-8">
+      <h1 className="text-headline-sm text-foreground">1:1문의 목록</h1>
+
+      <BoardFilterBar boardType="inquiry" query={query} />
+
+      <BoardListToolbar
+        boardType="inquiry"
+        query={query}
+        canCreate={false}
+      />
+
+      <p className="text-body-md text-foreground-muted">
+        총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
+      </p>
+
+      {items.length === 0 ? (
+        <Alert
+          title="검색 결과가 없습니다."
+          actions={
+            <Link
+              href={buildBoardPostHref('inquiry', query, {
+                keyword: '',
+                page: 1,
+              })}
+              className="bg-foreground px-4 py-2 text-background"
+            >
+              확인
+            </Link>
+          }
+        >
+          다른 검색어로 다시 시도해 주세요.
+        </Alert>
+      ) : (
+        <>
+          <InquiryTable
+            items={items}
+            page={currentPage}
+            pageSize={query.pageSize}
+            totalCount={totalCount}
+          />
+          <Pagination
+            currentPage={currentPage}
+            totalPages={totalPages}
+            buildHref={(page: number) =>
+              buildBoardPostHref('inquiry', query, { page })
+            }
+          />
+        </>
+      )}
+    </section>
+  );
+}
 
app/(protected)/(basic)/boards/loading.tsx (added)
+++ app/(protected)/(basic)/boards/loading.tsx
@@ -0,0 +1,11 @@
+import { Spinner } from '@/components/ui/spinner';
+
+// 게시판 세 화면(공지사항·1:1문의·FAQ)이 공유하는 스트리밍 폴백 — 이 세그먼트에 두면 하위
+// 라우트 전체에 적용되므로 화면마다 따로 두지 않는다. Server Component로 충분하다.
+export default function Loading() {
+  return (
+    <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8">
+      <Spinner size="lg" />
+    </section>
+  );
+}
 
app/(protected)/(basic)/boards/notices/_components/notice-table.tsx (added)
+++ app/(protected)/(basic)/boards/notices/_components/notice-table.tsx
@@ -0,0 +1,83 @@
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeaderCell,
+  TableRow,
+} from '@/components/ui/table';
+import {
+  EMPTY_FIELD_PLACEHOLDER,
+  formatBoardCategoryLabel,
+  formatOptionalValue,
+  formatVisibilityLabel,
+  type BoardPost,
+} from '@/lib/domain/board-post';
+import { BoardRowActions } from '../../_components/board-row-actions';
+
+interface NoticeTableProps {
+  items: BoardPost[];
+  page: number;
+  pageSize: number;
+  totalCount: number;
+}
+
+const COLUMN_HEADERS = [
+  '번호',
+  '구분',
+  '제목',
+  '첨부',
+  '작성자',
+  '사용여부',
+  '작성일',
+  '관리',
+];
+
+/**
+ * 공지사항 목록 테이블 — 시안 A_BOA_006 ③.
+ *
+ * "번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다(시안이 2·1처럼 내림차순으로
+ * 표기하고 목록이 최신순이라 가장 최근 글이 가장 큰 번호가 된다). 정확한 전체 건수를 전제하는데,
+ * Repository가 전체를 받아 세기 때문에 그 전제가 성립한다.
+ *
+ * "첨부"는 파일 유무만 표시한다 — 백엔드에 atchFileId로 파일 정보를 조회하는 API가 없어 파일명·
+ * 용량을 알 수 없다(`board-attachment-field.tsx` 주석 참조).
+ */
+export function NoticeTable({
+  items,
+  page,
+  pageSize,
+  totalCount,
+}: NoticeTableProps) {
+  const offset = (page - 1) * pageSize;
+
+  return (
+    <Table>
+      <TableHead>
+        <TableRow>
+          {COLUMN_HEADERS.map((header) => (
+            <TableHeaderCell key={header}>{header}</TableHeaderCell>
+          ))}
+        </TableRow>
+      </TableHead>
+      <TableBody>
+        {items.map((post, index) => (
+          <TableRow key={post.id}>
+            <TableCell>{totalCount - offset - index}</TableCell>
+            <TableCell>{formatBoardCategoryLabel(post.categoryCode)}</TableCell>
+            <TableCell>{formatOptionalValue(post.title)}</TableCell>
+            <TableCell>
+              {post.attachmentId ? '있음' : EMPTY_FIELD_PLACEHOLDER}
+            </TableCell>
+            <TableCell>{formatOptionalValue(post.authorName)}</TableCell>
+            <TableCell>{formatVisibilityLabel(post.isVisible)}</TableCell>
+            <TableCell>{formatOptionalValue(post.createdAt)}</TableCell>
+            <TableCell>
+              <BoardRowActions boardType="notice" post={post} />
+            </TableCell>
+          </TableRow>
+        ))}
+      </TableBody>
+    </Table>
+  );
+}
 
app/(protected)/(basic)/boards/notices/page.tsx (added)
+++ app/(protected)/(basic)/boards/notices/page.tsx
@@ -0,0 +1,88 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { Alert } from '@/components/ui/alert';
+import { Pagination } from '@/components/ui/pagination';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchBoardPosts } from '@/lib/data/repositories/board-post-repository';
+import {
+  buildBoardPostHref,
+  parseBoardPostQuery,
+} from '@/lib/domain/board-post-query';
+import { BoardFilterBar } from '../_components/board-filter-bar';
+import { BoardListToolbar } from '../_components/board-list-toolbar';
+import { NoticeTable } from './_components/notice-table';
+
+export const metadata: Metadata = {
+  title: '공지사항',
+};
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+/**
+ * 공지사항 목록 — 시안 A_BOA_006.
+ *
+ * 전체 건수가 **확정값**이라 페이지 수를 그대로 계산한다 — Repository가 백엔드 페이징을 쓰지 않고
+ * 전체를 받아 직접 세기 때문이다(백엔드 `totalCount`는 현재 페이지 행 수라 신뢰할 수 없다).
+ */
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseBoardPostQuery('notice', await searchParams);
+  const { items, totalCount } = await fetchBoardPosts('notice', query);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 —
+  // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다.
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <section className="flex flex-1 flex-col gap-6 p-8">
+      <h1 className="text-headline-sm text-foreground">공지사항 목록</h1>
+
+      <BoardFilterBar boardType="notice" query={query} />
+
+      <BoardListToolbar boardType="notice" query={query} canCreate />
+
+      <p className="text-body-md text-foreground-muted">
+        총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages}
+      </p>
+
+      {items.length === 0 ? (
+        <Alert
+          title="검색 결과가 없습니다."
+          actions={
+            <Link
+              href={buildBoardPostHref('notice', query, {
+                keyword: '',
+                page: 1,
+              })}
+              className="bg-foreground px-4 py-2 text-background"
+            >
+              확인
+            </Link>
+          }
+        >
+          다른 검색어로 다시 시도해 주세요.
+        </Alert>
+      ) : (
+        <>
+          <NoticeTable
+            items={items}
+            page={currentPage}
+            pageSize={query.pageSize}
+            totalCount={totalCount}
+          />
+          <Pagination
+            currentPage={currentPage}
+            totalPages={totalPages}
+            buildHref={(page: number) =>
+              buildBoardPostHref('notice', query, { page })
+            }
+          />
+        </>
+      )}
+    </section>
+  );
+}
 
lib/data/repositories/board-post-repository.ts (added)
+++ lib/data/repositories/board-post-repository.ts
@@ -0,0 +1,377 @@
+import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import {
+  BackendRequestError,
+  backendCommand,
+  backendFetch,
+} from '@/lib/http/backend-fetch';
+import {
+  BOARD_SETTING_IDS,
+  BOARD_TYPE_CODE_PARAM,
+  type BoardPost,
+  type BoardType,
+} from '@/lib/domain/board-post';
+import type { BoardPostQuery } from '@/lib/domain/board-post-query';
+
+/**
+ * 게시판 Repository — 공지사항·1:1문의·FAQ가 **같은 백엔드 API**를 쓰므로 한 파일이 셋을 모두
+ * 담당하고, 게시판 구분은 `BoardType` → `stngId` 변환으로만 처리한다.
+ *
+ *   GET    /api/v1/mngr/bbs/pagination   (searchStngId 필수)
+ *   GET    /api/v1/mngr/bbs/{bbsId}
+ *   POST   /api/v1/mngr/bbs
+ *   PUT    /api/v1/mngr/bbs/{bbsId}
+ *   DELETE /api/v1/mngr/bbs/{bbsId}      (논리 삭제 — DEL_YN='Y')
+ *
+ * 아래는 백엔드 저장소(edupay-backend develop 924db37)의 실제 구현을 읽고 확인한 것이다 —
+ * MngrBbsApiController / MngrBbsServiceImpl / MngrBbsMapper.xml / PaginationUtil / CrudLogInterceptor.
+ *
+ * - **쓰기 요청은 JSON이 아니라 폼 인코딩이다.** 컨트롤러의 `MngrBbsRequestVo`에 `@RequestBody`가
+ *   없어 Spring이 요청 파라미터로 바인딩한다. 그래서 `application/x-www-form-urlencoded`로 보낸다.
+ * - **작성자·수정자·삭제자는 보내지 않는다.** `CrudLogInterceptor`(MyBatis 플러그인)가 인증
+ *   주체에서 꺼내 자동 기록한다. 프론트가 보내도 덮어써진다.
+ * - **`totalCount`를 신뢰할 수 없다.** count 쿼리가 없어 `PaginationUtil`이 현재 페이지 행 수를
+ *   총건수로 반환한다(학생·관리자 목록과 동일한 결함).
+ * - **노출기간·앱푸쉬는 저장되지만 조회되지 않는다.** INSERT/UPDATE에는 있으나 조회 SQL의 select
+ *   목록에 START_DT·END_DT·PUSH_YN이 없다. 사용자 확인 후 "전송은 하되 조회는 빈 값" 방침이다.
+ * - **`reg_dt` 별칭이 두 번 쓰인다**(ANS_DT에 한 번, FRST_REG_DT에 한 번). 같은 이름의 컬럼이 둘이라
+ *   작성일 자리에 답변일이 들어올 수 있다 — 보고한 백엔드 결함이며, 값이 이상하면 이 지점을 의심한다.
+ *
+ * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용. 캐시: `no-store`(조건이 매 요청 다르고 관리 데이터다).
+ */
+
+const BOARD_LIST_PATH = '/api/v1/mngr/bbs/pagination';
+const BOARD_BASE_PATH = '/api/v1/mngr/bbs';
+
+/**
+ * 한 번에 받아올 최대 행 수. **백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다** — 사용자가
+ * "전체 조회 후 프론트에서 필터링"을 선택했기 때문이다.
+ *
+ * 그렇게 정한 이유: ① 백엔드 목록 쿼리의 검색 조건이 제목 하나뿐이고 `searchUseYn`은 쿼리에서
+ * 아예 쓰이지 않아 시안의 필터를 백엔드에 맡길 수 없다. ② `totalCount`가 전체 건수가 아니라
+ * 페이지가 가득 차면 다음 페이지에 닿을 수 없다. 전체를 손에 쥐면 두 문제가 함께 풀린다.
+ *
+ * 게시물이 이 상한을 넘으면 그 위로는 조회·검색 대상에서 빠진다 — 그 규모가 되면 백엔드에 count와
+ * 검색 조건이 필요하다(상한 인상은 임시방편일 뿐이다).
+ */
+const BOARD_FETCH_LIMIT = 10_000;
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+/** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */
+function parseYesNo(value: unknown): boolean | null {
+  if (value === 'Y') return true;
+  if (value === 'N') return false;
+  return null;
+}
+
+/** `inqCnt`는 VO 타입이 String이라 숫자 문자열로 온다 — 숫자로 못 읽으면 null. */
+function parseCount(value: unknown): number | null {
+  if (typeof value === 'number') return value;
+  if (typeof value === 'string' && value.trim() !== '') {
+    const parsed = Number(value);
+    return Number.isFinite(parsed) ? parsed : null;
+  }
+  return null;
+}
+
+/**
+ * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다.
+ *
+ * `bbsId`만 없으면 예외로 끊는다(fail-fast) — 행의 key이자 수정·삭제의 입력값이라 없으면 목록
+ * 자체가 성립하지 않는다. 제목·내용은 비어 있어도 화면이 `-`로 표시하면 되므로 끊지 않는다.
+ */
+function toBoardPost(raw: unknown): BoardPost {
+  if (!isRecord(raw)) {
+    throw new Error('게시판 응답 항목의 형식이 올바르지 않습니다.');
+  }
+
+  const id = readString(raw, 'bbsId');
+  if (id === null) {
+    throw new Error('게시판 응답에 bbsId가 없습니다.');
+  }
+
+  return {
+    id,
+    title: readString(raw, 'bbsNm') ?? '',
+    content: readString(raw, 'bbsCn') ?? '',
+    categoryCode: readString(raw, 'bbsCd') ?? '',
+    // 백엔드에 유형 저장 필드가 아직 없다(BOARD_TYPE_CODE_PARAM 주석) — 응답에도 없으므로 null이다.
+    typeCode: readString(raw, BOARD_TYPE_CODE_PARAM),
+    attachmentId: readString(raw, 'atchFileId'),
+    isPinned: parseYesNo(raw.hghrkYn),
+    isVisible: parseYesNo(raw.rlsYn),
+    startDate: readString(raw, 'startDt'),
+    endDate: readString(raw, 'endDt'),
+    isPushEnabled: parseYesNo(raw.pushYn),
+    authorName: readString(raw, 'regNm'),
+    createdAt: readString(raw, 'regDt'),
+    viewCount: parseCount(raw.inqCnt),
+    authorPhoneNumber: readString(raw, 'telNo'),
+    authorEmail: readString(raw, 'emlAddr'),
+    answerContent: readString(raw, 'ansCn'),
+    answeredAt: readString(raw, 'ansDt'),
+    answererName: readString(raw, 'answrNm'),
+    answerStatusCode: readString(raw, 'ansSttsCd'),
+  };
+}
+
+/** 게시판 전체를 한 번에 받아온다(검색·필터·페이징은 호출부가 처리한다). */
+async function fetchAllBoardPosts(boardType: BoardType): Promise<BoardPost[]> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(BOARD_LIST_PATH, {
+    method: 'GET',
+    query: {
+      searchStngId: BOARD_SETTING_IDS[boardType],
+      pageIndex: 1,
+      recordCountPerPage: BOARD_FETCH_LIMIT,
+    },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  const data = result.data;
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    throw new Error('게시판 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return data.list.map(toBoardPost);
+}
+
+/** 검색 대상 → 비교할 값. 목록·상세에 실제로 보이는 값으로만 거른다. */
+const SEARCH_VALUE_BY_FIELD: Record<string, (post: BoardPost) => string> = {
+  title: (post) => post.title,
+  content: (post) => post.content,
+  authorName: (post) => post.authorName ?? '',
+  authorPhoneNumber: (post) => post.authorPhoneNumber ?? '',
+  answerContent: (post) => post.answerContent ?? '',
+};
+
+function matchesQuery(post: BoardPost, query: BoardPostQuery): boolean {
+  if (query.category && post.categoryCode !== query.category) {
+    return false;
+  }
+  if (query.type && post.typeCode !== query.type) {
+    return false;
+  }
+  if (query.visibility === 'visible' && post.isVisible !== true) {
+    return false;
+  }
+  if (query.visibility === 'hidden' && post.isVisible === true) {
+    return false;
+  }
+
+  // 답변여부는 별도 플래그가 없어 답변 내용의 유무로 판단한다(진행상태 코드는 잠정값이라
+  // 그것으로 판정하면 코드가 확정될 때 함께 틀어진다).
+  const hasAnswer = Boolean(post.answerContent);
+  if (query.answered === 'answered' && !hasAnswer) {
+    return false;
+  }
+  if (query.answered === 'unanswered' && hasAnswer) {
+    return false;
+  }
+
+  const keyword = query.keyword.trim().toLowerCase();
+  if (!keyword) {
+    return true;
+  }
+
+  const readValue = SEARCH_VALUE_BY_FIELD[query.searchField];
+  return readValue ? readValue(post).toLowerCase().includes(keyword) : true;
+}
+
+export type BoardPostPage = {
+  items: BoardPost[];
+  /** 검색·필터를 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */
+  totalCount: number;
+};
+
+/**
+ * 검색·필터·페이징이 적용된 목록을 조회한다.
+ *
+ * 정렬은 하지 않는다 — 백엔드 목록 SQL이 `ORDER BY rnum DESC`(= 최초등록일시 최신순)로 고정돼
+ * 있고 그것이 곧 시안의 "최근등록순"이다. 시안의 정렬 select에도 다른 선택지가 없다.
+ */
+export async function fetchBoardPosts(
+  boardType: BoardType,
+  query: BoardPostQuery
+): Promise<BoardPostPage> {
+  const all = await fetchAllBoardPosts(boardType);
+  const matched = all.filter((post) => matchesQuery(post, query));
+
+  const offset = (query.page - 1) * query.pageSize;
+
+  return {
+    items: matched.slice(offset, offset + query.pageSize),
+    totalCount: matched.length,
+  };
+}
+
+/** 단건 조회 — 수정·답변 팝업의 진입점. */
+export async function findBoardPostById(
+  bbsId: string
+): Promise<BoardPost | null> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${BOARD_BASE_PATH}/${bbsId}`, {
+    method: 'GET',
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  return result.data === null ? null : toBoardPost(result.data);
+}
+
+/** boolean → 백엔드 Y/N 플래그. */
+function toYesNo(value: boolean): string {
+  return value ? 'Y' : 'N';
+}
+
+export type BoardPostWriteInput = {
+  categoryCode: string;
+  typeCode: string;
+  title: string;
+  content: string;
+  attachmentId: string;
+  isPinned: boolean;
+  isVisible: boolean;
+  isPushEnabled: boolean;
+  startDate: string;
+  endDate: string;
+};
+
+/**
+ * 등록/수정 공통 파라미터.
+ *
+ * 빈 문자열은 보내지 않는다 — 수정 SQL이 `<if test="... != null">`로 동적 SET을 만들기 때문에,
+ * 빈 문자열을 보내면 기존 값을 빈 값으로 덮어쓴다(예: 첨부파일을 안 건드렸는데 지워지는 상황).
+ * "값을 안 보냄 = 기존 유지"가 백엔드의 규약이라 그에 맞춘다.
+ */
+function buildWriteParams(
+  boardType: BoardType,
+  input: BoardPostWriteInput
+): Record<string, string> {
+  const params: Record<string, string> = {
+    stngId: BOARD_SETTING_IDS[boardType],
+    bbsNm: input.title,
+    bbsCn: input.content,
+    hghrkYn: toYesNo(input.isPinned),
+    rlsYn: toYesNo(input.isVisible),
+    pushYn: toYesNo(input.isPushEnabled),
+  };
+
+  // 구분(bbsCd)은 아직 요청 VO에 없어 백엔드가 무시한다. 필드가 추가되는 즉시 동작하도록 보낸다.
+  if (input.categoryCode) params.bbsCd = input.categoryCode;
+  if (input.typeCode) params[BOARD_TYPE_CODE_PARAM] = input.typeCode;
+  if (input.attachmentId) params.atchFileId = input.attachmentId;
+  if (input.startDate) params.startDt = input.startDate;
+  if (input.endDate) params.endDt = input.endDate;
+
+  return params;
+}
+
+export async function createBoardPost(
+  boardType: BoardType,
+  input: BoardPostWriteInput
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendCommand(BOARD_BASE_PATH, {
+    method: 'POST',
+    form: buildWriteParams(boardType, input),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function updateBoardPost(
+  boardType: BoardType,
+  bbsId: string,
+  input: BoardPostWriteInput
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, {
+    method: 'PUT',
+    // 수정은 컨트롤러가 `@ParameterObject`라 쿼리스트링으로 받는다(본문이 아니다).
+    query: buildWriteParams(boardType, input),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export type InquiryAnswerInput = {
+  answerStatusCode: string;
+  answerContent: string;
+  attachmentId: string;
+};
+
+/**
+ * 1:1문의 답변 저장 — 백엔드에 전용 엔드포인트가 없어 수정 API에 답변 필드를 실어 보낸다.
+ *
+ * 제목·내용은 보내지 않는다(질문자가 쓴 값이라 관리자가 건드릴 이유가 없고, 위에서 적었듯
+ * 안 보내면 기존 값이 유지된다). `stngId`는 수정 SQL의 WHERE 조건이라 반드시 필요하다.
+ */
+export async function saveInquiryAnswer(
+  bbsId: string,
+  input: InquiryAnswerInput
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const query: Record<string, string> = {
+    stngId: BOARD_SETTING_IDS.inquiry,
+    ansSttsCd: input.answerStatusCode,
+  };
+  if (input.answerContent) query.ansCn = input.answerContent;
+  if (input.attachmentId) query.atchFileId = input.attachmentId;
+
+  const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, {
+    method: 'PUT',
+    query,
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+/** 삭제 — 백엔드가 물리 삭제가 아니라 `DEL_YN='Y'`로 표시만 바꾼다. */
+export async function deleteBoardPost(bbsId: string): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendCommand(`${BOARD_BASE_PATH}/${bbsId}`, {
+    method: 'DELETE',
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
 
lib/data/repositories/file-repository.ts (added)
+++ lib/data/repositories/file-repository.ts
@@ -0,0 +1,68 @@
+import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+
+/**
+ * 첨부파일 Repository — 업로드해서 `atchFileId`를 받아오는 것만 담당한다.
+ *
+ *   POST /api/v1/common/file/upload/{moduleId}   multipart/form-data, 파트 이름 `file`
+ *   → data: "생성된 atchFileId" (문자열)
+ *
+ * 게시판 등록/수정은 이 id를 `atchFileId`로 실어 보내 게시물과 파일을 연결한다.
+ *
+ * **파일 메타데이터(파일명·용량) 조회 API가 없다.** 백엔드 파일 컨트롤러에는 업로드·다운로드·삭제·
+ * 이미지·스트림만 있고 "이 atchFileId에 무슨 파일이 붙어 있는가"를 알려 주는 엔드포인트가 없다.
+ * 그래서 시안(A_BOA_008_p)의 `pic_0001.jpg 88KB` 같은 표기는 **이미 저장된 첨부에 대해서는
+ * 불가능**하고, 화면은 "첨부 있음 + 다운로드 링크"까지만 보여준다. 방금 업로드한 파일은
+ * 브라우저가 이름·크기를 알고 있어 그 세션 동안만 표시할 수 있다.
+ *
+ * 다운로드는 이 파일이 다루지 않는다 — 파일 응답은 라우트 핸들러가 스트림으로 중계한다
+ * (`app/(protected)/(basic)/boards/attachments/route.ts`).
+ */
+
+const FILE_UPLOAD_PATH = '/api/v1/common/file/upload';
+
+/** 업로드 상한. 백엔드에 명시적 제한이 보이지 않아 프론트에서 방어적으로 건다. */
+export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
+
+/** 파일 생성은 일반 조회보다 오래 걸릴 수 있어 넉넉히 잡는다. */
+const UPLOAD_TIMEOUT_MS = 60_000;
+
+/**
+ * 파일 하나를 업로드하고 `atchFileId`를 돌려준다.
+ *
+ * `moduleId`는 백엔드에서 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리키며, 설정에 없는
+ * 값이면 `UNKNOWN` 경로로 저장된다(EgovFileMngUtil) — 즉 잘못된 값이어도 실패하지 않고 조용히
+ * 다른 곳에 저장되므로, 실제 설정 ID가 확정되면 반드시 맞춰야 한다.
+ */
+export async function uploadAttachment(
+  file: File,
+  moduleId: string
+): Promise<string> {
+  const accessToken = await getSessionAccessToken();
+
+  const multipart = new FormData();
+  multipart.set('file', file);
+
+  const result = await backendFetch<unknown>(
+    `${FILE_UPLOAD_PATH}/${moduleId}`,
+    {
+      method: 'POST',
+      multipart,
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      timeoutMs: UPLOAD_TIMEOUT_MS,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  // 성공 응답의 data는 atchFileId 문자열 그대로다(ApiResponseVO.success(atchFileId)).
+  if (typeof result.data !== 'string' || result.data.length === 0) {
+    throw new Error('첨부파일 업로드 응답에 파일 식별자가 없습니다.');
+  }
+
+  return result.data;
+}
 
lib/domain/board-post-form.ts (added)
+++ lib/domain/board-post-form.ts
@@ -0,0 +1,180 @@
+/**
+ * 게시판 등록/수정/답변 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
+ *
+ * **이 파일이 검증의 단일 진실원천이다.** Server Action이 저장 직전에 여기를 거치고, 화면의
+ * 글자수 상한 표시도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다.
+ * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다(Server Action은 UI를 거치지 않고 직접
+ * 호출될 수 있다).
+ *
+ * 규칙의 근거는 시안(A_BOA_007_p / 008_p / 013_p / 015_p / 016_p)이다 — 백엔드에 게시판 입력
+ * 검증이 전혀 없어(요청 VO에 Bean Validation 애너테이션이 하나도 없다) 시안이 유일한 근거다.
+ */
+
+import {
+  ANSWER_STATUS_OPTIONS,
+  BOARD_CATEGORY_OPTIONS,
+  type BoardType,
+} from '@/lib/domain/board-post';
+import { BOARD_TYPE_OPTIONS } from '@/lib/domain/board-post-query';
+
+/** 본문 글자수 상한 — 시안 하단의 "50/1000자" 표기 기준. */
+export const CONTENT_MAX_LENGTH = 1000;
+/** 제목 상한. 시안에 수치가 없어 통상적인 게시판 제목 길이로 둔다(DB 컬럼 길이는 미확인). */
+export const TITLE_MAX_LENGTH = 200;
+/** 답변 내용 상한 — 시안 A_BOA_013_p ④ "텍스트 입력, 글자수 세기 적용". */
+export const ANSWER_MAX_LENGTH = 1000;
+
+export type BoardPostFormValues = {
+  categoryCode: string;
+  typeCode: string;
+  title: string;
+  content: string;
+  attachmentId: string;
+  isPinned: boolean;
+  isVisible: boolean;
+  isPushEnabled: boolean;
+  startDate: string;
+  endDate: string;
+};
+
+export type BoardPostFormErrors = Partial<
+  Record<keyof BoardPostFormValues, string>
+>;
+
+export type InquiryAnswerFormValues = {
+  answerStatusCode: string;
+  answerContent: string;
+  attachmentId: string;
+};
+
+export type InquiryAnswerFormErrors = Partial<
+  Record<keyof InquiryAnswerFormValues, string>
+>;
+
+export type ValidationResult<V, E> =
+  | { ok: true; values: V }
+  | { ok: false; errors: E };
+
+/** `YYYY-MM-DD` 형식인지. 달력 위젯 없이 입력될 수 있으므로 형식을 직접 본다. */
+function isIsoDate(value: string): boolean {
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
+    return false;
+  }
+  // 2026-02-31처럼 형식은 맞지만 존재하지 않는 날짜를 걸러낸다.
+  const parsed = new Date(`${value}T00:00:00Z`);
+  return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value);
+}
+
+/**
+ * 등록·수정 공통 검증.
+ *
+ * `isCategoryEditable`로 등록과 수정을 가른다 — 수정 시 구분은 읽기 전용이라(시안 008_p ① /
+ * 016_p ①) 값이 오지 않거나 기존 값 그대로 온다. 그 값에까지 "선택 필수"를 적용하면 코드 표가
+ * 바뀐 뒤 기존 게시물을 저장할 수 없게 되므로, 수정에서는 구분을 검증하지 않는다.
+ */
+export function validateBoardPostForm(
+  values: BoardPostFormValues,
+  options: { boardType: BoardType; isCategoryEditable: boolean }
+): ValidationResult<BoardPostFormValues, BoardPostFormErrors> {
+  const errors: BoardPostFormErrors = {};
+
+  const title = values.title.trim();
+  const content = values.content.trim();
+
+  if (options.isCategoryEditable) {
+    const isKnownCategory = BOARD_CATEGORY_OPTIONS.some(
+      (option) => option.value === values.categoryCode
+    );
+    if (!isKnownCategory) {
+      errors.categoryCode = '구분을 선택해 주세요.';
+    }
+  }
+
+  // 유형은 공지사항에 없는 항목이라 해당 게시판에서만 검증한다.
+  const typeOptions = BOARD_TYPE_OPTIONS[options.boardType];
+  if (typeOptions.length > 0) {
+    const isKnownType = typeOptions.some(
+      (option) => option.value === values.typeCode
+    );
+    if (!isKnownType) {
+      errors.typeCode = '유형을 선택해 주세요.';
+    }
+  }
+
+  if (!title) {
+    errors.title = '제목을 입력해 주세요.';
+  } else if (title.length > TITLE_MAX_LENGTH) {
+    errors.title = `제목은 ${TITLE_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  if (!content) {
+    errors.content = '내용을 입력해 주세요.';
+  } else if (content.length > CONTENT_MAX_LENGTH) {
+    errors.content = `내용은 ${CONTENT_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  // 노출기간은 선택 항목이지만, 넣는다면 형식과 선후 관계는 맞아야 한다.
+  const startDate = values.startDate.trim();
+  const endDate = values.endDate.trim();
+
+  if (startDate && !isIsoDate(startDate)) {
+    errors.startDate = '게시시작일시 형식이 올바르지 않습니다.';
+  }
+  if (endDate && !isIsoDate(endDate)) {
+    errors.endDate = '게시종료일시 형식이 올바르지 않습니다.';
+  }
+  if (
+    !errors.startDate &&
+    !errors.endDate &&
+    startDate &&
+    endDate &&
+    startDate > endDate
+  ) {
+    errors.endDate = '게시종료일시는 게시시작일시보다 뒤여야 합니다.';
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: { ...values, title, content, startDate, endDate },
+  };
+}
+
+/**
+ * 1:1문의 답변 검증(시안 A_BOA_013_p).
+ *
+ * 답변 내용은 **답변완료로 바꿀 때만 필수**다 — 답변대기·처리중으로 상태만 바꿔 두는 것도 정상
+ * 흐름이기 때문이다(시안 ③ "답변완료 선택 시에만 FO에 답변 내용 노출").
+ */
+export function validateInquiryAnswerForm(
+  values: InquiryAnswerFormValues,
+  options: { doneStatusCode: string }
+): ValidationResult<InquiryAnswerFormValues, InquiryAnswerFormErrors> {
+  const errors: InquiryAnswerFormErrors = {};
+
+  const answerContent = values.answerContent.trim();
+
+  const isKnownStatus = ANSWER_STATUS_OPTIONS.some(
+    (option) => option.value === values.answerStatusCode
+  );
+  if (!isKnownStatus) {
+    errors.answerStatusCode = '진행상태를 선택해 주세요.';
+  }
+
+  if (values.answerStatusCode === options.doneStatusCode && !answerContent) {
+    errors.answerContent = '답변완료로 저장하려면 답변 내용을 입력해 주세요.';
+  }
+
+  if (answerContent.length > ANSWER_MAX_LENGTH) {
+    errors.answerContent = `답변 내용은 ${ANSWER_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return { ok: true, values: { ...values, answerContent } };
+}
 
lib/domain/board-post-query.ts (added)
+++ lib/domain/board-post-query.ts
@@ -0,0 +1,200 @@
+/**
+ * 게시판 목록(공지사항·1:1문의·FAQ)의 검색·필터·페이징 조건 — 순수 규칙(허용 값·기본값·URL
+ * 직렬화)만 담는다. next/react 의존이 없다.
+ *
+ * 학생/관리자 회원 목록은 화면마다 query 파일을 따로 두었지만 **게시판 셋은 한 파일로 합쳤다.**
+ * 세 화면이 같은 API·같은 엔티티를 쓰고 조건도 같은 모양(구분·유형·검색어·페이징)이라, 나누면
+ * 같은 규칙이 세 벌로 복제되어 드리프트한다. 화면마다 다른 것은 "어떤 필터를 노출하는가"뿐이므로
+ * 그 목록만 `BOARD_QUERY_FIELDS`로 선언해 두고 파싱·직렬화 로직은 공유한다.
+ *
+ * 쓰지 않는 필터는 화면에서 렌더하지 않으면 그만이다(값이 빈 문자열로 남아 필터가 통과된다).
+ */
+
+import {
+  BOARD_CATEGORY_OPTIONS,
+  FAQ_TYPE_OPTIONS,
+  INQUIRY_TYPE_OPTIONS,
+  type BoardType,
+} from '@/lib/domain/board-post';
+
+/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 표를 참조한다. */
+export const BOARD_PATHS: Record<BoardType, string> = {
+  notice: '/boards/notices',
+  inquiry: '/boards/inquiries',
+  faq: '/boards/faqs',
+};
+
+/** 필터 select의 "전체" 선택지 값. 빈 문자열이면 URL에서 생략되어 링크가 짧아진다. */
+export const FILTER_ALL = '';
+
+export type BoardSearchFieldOption = { value: string; label: string };
+
+/**
+ * 화면별 검색 대상.
+ *
+ * 백엔드 목록 쿼리는 제목(`searchCondition=1`) 하나만 지원하지만, 사용자 확인 결과 **전체를 받아
+ * 프론트에서 필터링**하기로 해서 시안의 검색 대상을 모두 제공할 수 있다(Repository 주석 참조).
+ */
+export const BOARD_SEARCH_FIELDS: Record<BoardType, readonly BoardSearchFieldOption[]> = {
+  notice: [{ value: 'title', label: '제목' }],
+  inquiry: [
+    { value: 'title', label: '제목' },
+    { value: 'authorName', label: '질문자명' },
+    { value: 'authorPhoneNumber', label: '전화번호' },
+    { value: 'content', label: '내용' },
+    { value: 'answerContent', label: '답변내용' },
+  ],
+  faq: [
+    { value: 'title', label: '제목' },
+    { value: 'content', label: '내용' },
+  ],
+};
+
+/** 화면별로 노출하는 필터 — 화면이 무엇을 그릴지 판단하는 근거이자, 이 파일의 검증 기준이다. */
+export const BOARD_QUERY_FIELDS: Record<
+  BoardType,
+  { category: boolean; type: boolean; visibility: boolean; answered: boolean }
+> = {
+  notice: { category: true, type: false, visibility: true, answered: false },
+  inquiry: { category: true, type: true, visibility: false, answered: true },
+  faq: { category: true, type: true, visibility: false, answered: false },
+};
+
+/** 화면별 유형 선택지. 공지사항은 유형 항목이 없다. */
+export const BOARD_TYPE_OPTIONS: Record<
+  BoardType,
+  readonly BoardSearchFieldOption[]
+> = {
+  notice: [],
+  inquiry: INQUIRY_TYPE_OPTIONS,
+  faq: FAQ_TYPE_OPTIONS,
+};
+
+/** 사용여부 필터(공지사항) — 시안의 「전체/노출/미노출」. */
+export const VISIBILITY_FILTER_OPTIONS: readonly BoardSearchFieldOption[] = [
+  { value: 'visible', label: '노출' },
+  { value: 'hidden', label: '미노출' },
+];
+
+/** 답변여부 필터(1:1문의) — 시안의 「전체/답변완료/미답변」. */
+export const ANSWERED_FILTER_OPTIONS: readonly BoardSearchFieldOption[] = [
+  { value: 'answered', label: '답변완료' },
+  { value: 'unanswered', label: '미답변' },
+];
+
+export const BOARD_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type BoardPageSize = (typeof BOARD_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_BOARD_PAGE_SIZE: BoardPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type BoardPostQuery = {
+  /** 구분 코드. 빈 문자열이면 전체. */
+  category: string;
+  /** 유형 코드. 빈 문자열이면 전체. */
+  type: string;
+  /** 'visible' | 'hidden' | '' */
+  visibility: string;
+  /** 'answered' | 'unanswered' | '' */
+  answered: string;
+  searchField: string;
+  keyword: string;
+  page: number;
+  pageSize: BoardPageSize;
+};
+
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string | undefined {
+  const value = params[key];
+  return Array.isArray(value) ? value[0] : value;
+}
+
+/** 허용 목록에 있는 값만 통과시키고, 아니면 빈 문자열(전체)로 떨어뜨린다. */
+function readOneOf(
+  params: RawSearchParams,
+  key: string,
+  allowed: readonly { value: string }[]
+): string {
+  const raw = readParam(params, key) ?? '';
+  return allowed.some((option) => option.value === raw) ? raw : FILTER_ALL;
+}
+
+function isBoardPageSize(value: number): value is BoardPageSize {
+  return (BOARD_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
+}
+
+/**
+ * URL의 searchParams를 검증된 `BoardPostQuery`로 정규화한다. 값이 없거나 허용 목록을 벗어나면
+ * 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 신뢰하지 않는다.
+ *
+ * 화면이 쓰지 않는 필터(`BOARD_QUERY_FIELDS`)는 URL에 실려 와도 무시한다 — 예를 들어 FAQ에
+ * `?visibility=hidden`을 붙여도 FAQ에는 사용여부 필터가 없으므로 결과가 달라지지 않아야 한다.
+ */
+export function parseBoardPostQuery(
+  boardType: BoardType,
+  searchParams: RawSearchParams
+): BoardPostQuery {
+  const fields = BOARD_QUERY_FIELDS[boardType];
+  const searchFields = BOARD_SEARCH_FIELDS[boardType];
+
+  const pageRaw = Number(readParam(searchParams, 'page'));
+  const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
+  const searchFieldRaw = readParam(searchParams, 'searchField') ?? '';
+
+  return {
+    category: fields.category
+      ? readOneOf(searchParams, 'category', BOARD_CATEGORY_OPTIONS)
+      : FILTER_ALL,
+    type: fields.type
+      ? readOneOf(searchParams, 'type', BOARD_TYPE_OPTIONS[boardType])
+      : FILTER_ALL,
+    visibility: fields.visibility
+      ? readOneOf(searchParams, 'visibility', VISIBILITY_FILTER_OPTIONS)
+      : FILTER_ALL,
+    answered: fields.answered
+      ? readOneOf(searchParams, 'answered', ANSWERED_FILTER_OPTIONS)
+      : FILTER_ALL,
+    searchField: searchFields.some((field) => field.value === searchFieldRaw)
+      ? searchFieldRaw
+      : searchFields[0].value,
+    keyword: (readParam(searchParams, 'keyword') ?? '')
+      .trim()
+      .slice(0, MAX_KEYWORD_LENGTH),
+    page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
+    pageSize: isBoardPageSize(pageSizeRaw)
+      ? pageSizeRaw
+      : DEFAULT_BOARD_PAGE_SIZE,
+  };
+}
+
+/**
+ * `BoardPostQuery`(+ 부분 override)를 해당 게시판 링크로 직렬화한다. `parseBoardPostQuery`의
+ * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다.
+ */
+export function buildBoardPostHref(
+  boardType: BoardType,
+  query: BoardPostQuery,
+  overrides: Partial<BoardPostQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.category) params.set('category', merged.category);
+  if (merged.type) params.set('type', merged.type);
+  if (merged.visibility) params.set('visibility', merged.visibility);
+  if (merged.answered) params.set('answered', merged.answered);
+  if (merged.searchField !== BOARD_SEARCH_FIELDS[boardType][0].value) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) params.set('keyword', merged.keyword);
+  if (merged.pageSize !== DEFAULT_BOARD_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) params.set('page', String(merged.page));
+
+  const queryString = params.toString();
+  const path = BOARD_PATHS[boardType];
+  return queryString ? `${path}?${queryString}` : path;
+}
 
lib/domain/board-post.ts (added)
+++ lib/domain/board-post.ts
@@ -0,0 +1,210 @@
+/**
+ * 게시판(공지사항·1:1문의·FAQ) 도메인 타입 — 순수 데이터 표현, 외부 의존 없음.
+ *
+ * 세 화면은 **백엔드에서 같은 테이블(TB_COM_BBS)·같은 API**를 쓰고 `stngId`(게시판 설정 ID)로만
+ * 갈린다. 그래서 타입과 코드 표는 여기 한 곳에 모으고, 화면별로 다른 것(검색 조건·정렬·컬럼)만
+ * 각자의 query 파일에서 다룬다.
+ *
+ * 데이터 출처: `GET /api/v1/mngr/bbs/pagination` (edupay-backend develop 924db37)
+ *
+ * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 노출기간·앱푸쉬는 등록/수정으로 저장은
+ * 되지만 조회 SQL의 select 목록에 빠져 있어 다시 읽으면 항상 비어 있다(사용자 확인 후 "입력은
+ * 만들고 전송하되 조회는 빈 값" 방침). 백엔드가 select에 컬럼을 추가하면 Repository 매핑만
+ * 늘리면 값이 그대로 채워진다.
+ */
+
+/** 세 화면을 구분하는 값 — 라우트·API 파라미터·코드 표의 키를 겸한다. */
+export type BoardType = 'notice' | 'inquiry' | 'faq';
+
+/**
+ * 백엔드 `stngId`(게시판 설정 ID) — **임시값이다.**
+ *
+ * 백엔드에 게시판 설정 목록 API도 테이블도 없고 소스에는 예시값 하나
+ * (`5e9ae040df1b41c1b03ce11d1bafcc60`)만 있어 세 게시판 중 무엇인지 알 수 없다. 사용자 확인 결과
+ * **"추후 백엔드에 추가 예정, 지금은 임의값으로 개발"** 이라 아래 값을 쓴다.
+ *
+ * 실제 값이 나오면 이 표만 고치면 된다 — 나머지 코드는 `BoardType`만 다루고 이 값을 직접 알지
+ * 못한다. 그때까지 목록 조회는 빈 결과가 정상이다(존재하지 않는 stngId라 매칭되는 행이 없다).
+ */
+export const BOARD_SETTING_IDS: Record<BoardType, string> = {
+  notice: 'TEMP_STNG_ID_NOTICE',
+  inquiry: 'TEMP_STNG_ID_INQUIRY',
+  faq: 'TEMP_STNG_ID_FAQ',
+};
+
+/**
+ * 파일 업로드 API(`POST /api/v1/common/file/upload/{moduleId}`)의 moduleId — **임시값이다.**
+ * 백엔드에서 이 값은 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리키며, 설정에 없는 값이면
+ * `UNKNOWN` 경로로 떨어진다(EgovFileMngUtil). 실제 설정 ID가 정해지면 여기만 고친다.
+ */
+export const BOARD_FILE_MODULE_ID = 'MODULE_BBS';
+
+export type BoardPost = {
+  /** 백엔드 `bbsId`. 행의 key이자 단건 조회·수정·삭제의 입력값이다. */
+  id: string;
+  /** 제목 — 백엔드 `bbsNm`. */
+  title: string;
+  /** 본문 — 백엔드 `bbsCn`. */
+  content: string;
+  /** 구분 코드 — 백엔드 `bbsCd`. */
+  categoryCode: string;
+  /**
+   * 유형 코드(FAQ의 유형 / 1:1문의의 질문유형).
+   * **백엔드에 아직 저장 필드가 없다** — `BOARD_TYPE_CODE_PARAM` 주석 참조. 조회 시 항상 null이다.
+   */
+  typeCode: string | null;
+  /** 첨부파일 식별자 — 백엔드 `atchFileId`. 파일명·용량 조회 API가 없어 존재 여부만 알 수 있다. */
+  attachmentId: string | null;
+  /** 상단고정 — 백엔드 `hghrkYn`. */
+  isPinned: boolean | null;
+  /** 사용여부(노출여부) — 백엔드 `rlsYn`. */
+  isVisible: boolean | null;
+  /** 노출 시작일. 백엔드 조회 응답에 없어 현재는 항상 null이다(파일 상단 주석). */
+  startDate: string | null;
+  /** 노출 종료일. 위와 같다. */
+  endDate: string | null;
+  /** 앱푸쉬 발송 여부. 위와 같다. */
+  isPushEnabled: boolean | null;
+  /** 작성자 표기 — 백엔드 `regNm`(이름(로그인ID) 형태로 이미 조합돼 온다). */
+  authorName: string | null;
+  /** 작성일(YYYY-MM-DD) — 백엔드 `regDt`. */
+  createdAt: string | null;
+  /** 조회수 — 백엔드 `inqCnt`. */
+  viewCount: number | null;
+
+  /* ── 1:1문의 전용 ── */
+  /** 질문자 연락처 — 백엔드 `telNo`. */
+  authorPhoneNumber: string | null;
+  /** 질문자 이메일 — 백엔드 `emlAddr`. */
+  authorEmail: string | null;
+  /** 답변 내용 — 백엔드 `ansCn`. */
+  answerContent: string | null;
+  /** 답변일 — 백엔드 `ansDt`. */
+  answeredAt: string | null;
+  /** 답변자 표기 — 백엔드 `answrNm`. */
+  answererName: string | null;
+  /** 진행상태 코드 — 백엔드 `ansSttsCd`. */
+  answerStatusCode: string | null;
+};
+
+/** 값이 없는 항목의 화면 표기. 표·팝업이 같은 문자를 쓰도록 여기 한 곳에 둔다. */
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+export function formatOptionalValue(value: string | number | null): string {
+  return value === null || value === '' ? EMPTY_FIELD_PLACEHOLDER : String(value);
+}
+
+/**
+ * 「유형」을 실어 보낼 요청 파라미터 이름 — **임시 이름이다.**
+ *
+ * 시안은 구분과 유형을 별도 항목으로 요구하지만 백엔드에는 `BBS_CD` 하나뿐이고, 등록/수정 요청
+ * VO(`MngrBbsRequestVo`)에는 그마저도 없다. 사용자 확인 결과 **"백엔드가 필드를 추가할 예정이니
+ * 프론트는 미리 구현"** 이라, 구분은 `bbsCd`로 유형은 이 이름으로 보낸다.
+ *
+ * 백엔드가 실제 이름을 확정하면 이 상수 한 줄만 고치면 된다. 그전까지 백엔드는 이 파라미터를
+ * 무시하므로 **유형은 저장되지 않는다**(화면 입력과 전송은 정상 동작한다).
+ */
+export const BOARD_TYPE_CODE_PARAM = 'bbsTypeCd';
+
+type CodeOption = { value: string; label: string };
+
+/**
+ * 「구분」 코드 표 — **임시값이다.** 백엔드에 코드 상수도 코드테이블도 없어 시안의 라벨을 기준으로
+ * 잠정 코드를 붙였다. 실제 코드가 확정되면 이 표만 교체한다.
+ *
+ * 시안의 공지사항 구분은 "공통 / 메뉴명1, 메뉴명2…"이고 FAQ는 "공통 / FOX PAY"다. 확정되지 않은
+ * "메뉴명N"은 임의로 지어내지 않고 두 화면이 공통으로 쓰는 값만 둔다.
+ */
+export const BOARD_CATEGORY_OPTIONS: readonly CodeOption[] = [
+  { value: 'COMMON', label: '공통' },
+  { 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: '회원가입/로그인' },
+  { value: 'ERROR', label: '오류신고' },
+  { value: 'ETC', label: '기타' },
+];
+
+/**
+ * 1:1문의 「진행상태」(`ansSttsCd`) — **임시값이다.** 백엔드에 코드 정의가 없어 사용자 확인 후
+ * 잠정 코드를 한 곳에 모아 두는 방침으로 정했다. 실제 코드가 나오면 이 표만 교체한다.
+ */
+export const ANSWER_STATUS_OPTIONS: readonly CodeOption[] = [
+  { value: 'WAIT', label: '답변대기' },
+  { value: 'ING', label: '처리중' },
+  { value: 'DONE', label: '답변완료' },
+];
+
+/** 답변완료 상태 코드 — "답변일 표시"·"답변 완료 시 푸쉬" 판단이 이 값을 참조한다. */
+export const ANSWER_STATUS_DONE = 'DONE';
+
+/** 코드 → 라벨. 표에 없는 코드는 지어내지 않고 코드 그대로 노출한다(잘못된 라벨보다 낫다). */
+function formatCode(options: readonly CodeOption[], code: string | null): string {
+  if (code === null || code === '') {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+  return options.find((option) => option.value === code)?.label ?? code;
+}
+
+export function formatBoardCategoryLabel(code: string | null): string {
+  return formatCode(BOARD_CATEGORY_OPTIONS, code);
+}
+
+export function formatFaqTypeLabel(code: string | null): string {
+  return formatCode(FAQ_TYPE_OPTIONS, code);
+}
+
+export function formatInquiryTypeLabel(code: string | null): string {
+  return formatCode(INQUIRY_TYPE_OPTIONS, code);
+}
+
+export function formatAnswerStatusLabel(code: string | null): string {
+  return formatCode(ANSWER_STATUS_OPTIONS, code);
+}
+
+/** 사용여부 표기 — 시안의 「노출 / 미노출」. */
+export function formatVisibilityLabel(isVisible: boolean | null): string {
+  if (isVisible === null) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+  return isVisible ? '노출' : '미노출';
+}
+
+/**
+ * 1:1문의 목록의 전화번호 마스킹(시안 A_BOA_012 — `010-12**-**78`).
+ *
+ * 사용자 확인 후 **적용**하기로 한 항목이다(학생 회원 목록은 반대로 "마스킹 없음"이 지시였다 —
+ * 화면마다 방침이 다르므로 이 함수를 1:1문의에서만 쓴다).
+ *
+ * 가운데 자리는 앞 2자리만, 끝자리는 뒤 2자리만 남긴다. 형식이 `nnn-nnnn-nnnn`이 아니면 마스킹
+ * 규칙을 적용할 자리를 알 수 없으므로 **전부 가린다** — 규칙 밖의 값을 원본 그대로 흘리는 것이
+ * 마스킹의 목적에 어긋나기 때문이다.
+ */
+export function maskPhoneNumber(phoneNumber: string | null): string {
+  if (phoneNumber === null || phoneNumber === '') {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+
+  const parts = phoneNumber.split('-');
+  if (parts.length !== 3) {
+    return '***';
+  }
+
+  const [prefix, middle, last] = parts;
+  const maskedMiddle = middle.slice(0, 2).padEnd(middle.length, '*');
+  const maskedLast = '*'.repeat(Math.max(0, last.length - 2)) + last.slice(-2);
+
+  return `${prefix}-${maskedMiddle}-${maskedLast}`;
+}
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -50,8 +50,21 @@
 }
 
 type BackendRequestInit = {
-  method: 'GET' | 'POST';
+  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
   body?: unknown;
+  /**
+   * 폼 인코딩(`application/x-www-form-urlencoded`) 본문.
+   *
+   * 백엔드 컨트롤러 중에는 요청 VO에 `@RequestBody`가 없는 것들이 있다(예: 게시판 등록
+   * `MngrBbsApiController.insert`). 그런 엔드포인트는 JSON 본문을 읽지 못하고 요청 파라미터로만
+   * 바인딩하므로 폼으로 보내야 한다. `body`와 함께 쓰지 않는다.
+   */
+  form?: Record<string, string>;
+  /**
+   * 멀티파트 본문(파일 업로드). `Content-Type`을 **직접 지정하지 않는다** — 헤더에는 파트 경계
+   * (boundary) 문자열이 함께 들어가야 하는데 그 값은 런타임이 FormData에서 생성하기 때문이다.
+   */
+  multipart?: FormData;
   /** 쿼리 스트링 파라미터. 값은 문자열로 직렬화해 붙인다. */
   query?: Record<string, string | number>;
   /**
@@ -63,6 +76,8 @@
   /** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */
   cache?: RequestCache;
   next?: { revalidate?: number | false; tags?: string[] };
+  /** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */
+  timeoutMs?: number;
 };
 
 function communicationError(reason: string, detail: unknown): BackendResult<never> {
@@ -161,23 +176,51 @@
   return { ok: true, data: response };
 }
 
-/** 백엔드 REST 호출 단일 진입점. */
-export async function backendFetch<T>(
+/** 본문 인코딩 선택 — 멀티파트 > 폼 > JSON 순으로 우선한다. */
+function buildRequestBody(init: BackendRequestInit): {
+  headers: Record<string, string>;
+  body: string | FormData | undefined;
+} {
+  if (init.multipart !== undefined) {
+    // Content-Type을 비워 둬야 런타임이 boundary를 포함해 채워 넣는다(위 주석 참조).
+    return { headers: {}, body: init.multipart };
+  }
+
+  if (init.form !== undefined) {
+    return {
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
+      body: new URLSearchParams(init.form).toString(),
+    };
+  }
+
+  return {
+    headers: { 'Content-Type': 'application/json' },
+    body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
+  };
+}
+
+/**
+ * 요청 전송 + 실패 정규화까지의 공통 경로. 성공 시 파싱된 봉투를 그대로 돌려주고, `data`를
+ * 어떻게 다룰지(필수인지 없어도 되는지)는 호출부인 `backendFetch`/`backendCommand`가 정한다.
+ */
+async function sendBackendRequest(
   path: string,
   init: BackendRequestInit
-): Promise<BackendResult<T>> {
+): Promise<BackendResult<BackendEnvelope<unknown>>> {
+  const { headers, body } = buildRequestBody(init);
+
   let response: Response;
   try {
     response = await fetch(resolveUrl(path, init.query), {
       method: init.method,
       headers: {
-        'Content-Type': 'application/json',
+        ...headers,
         ...(init.accessToken
           ? { Authorization: `Bearer ${init.accessToken}` }
           : {}),
       },
-      body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
-      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+      body,
+      signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS),
       cache: init.cache,
       next: init.next,
     });
@@ -204,16 +247,53 @@
     return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
   }
 
-  let envelope: BackendEnvelope<T>;
+  let envelope: BackendEnvelope<unknown>;
   try {
-    envelope = (await response.json()) as BackendEnvelope<T>;
+    envelope = (await response.json()) as BackendEnvelope<unknown>;
   } catch (error) {
     return communicationError('응답 파싱 실패', error);
   }
 
-  if (!envelope.success || envelope.data === null) {
+  if (!envelope.success) {
     return { ok: false, code: envelope.code, message: envelope.message };
   }
 
-  return { ok: true, data: envelope.data };
+  return { ok: true, data: envelope };
+}
+
+/**
+ * 백엔드 REST 호출 단일 진입점 — **응답 데이터를 기대하는 호출용**이다.
+ * 봉투의 `data`가 없으면 실패로 본다(조회인데 실을 것이 없다면 계약 위반이다).
+ */
+export async function backendFetch<T>(
+  path: string,
+  init: BackendRequestInit
+): Promise<BackendResult<T>> {
+  const result = await sendBackendRequest(path, init);
+  if (!result.ok) {
+    return result;
+  }
+
+  const envelope = result.data;
+  if (envelope.data === null || envelope.data === undefined) {
+    return { ok: false, code: envelope.code, message: envelope.message };
+  }
+
+  return { ok: true, data: envelope.data as T };
+}
+
+/**
+ * **응답 데이터가 없는 쓰기 호출용**(등록·수정·삭제). 성공 판정은 봉투의 `success`만 본다.
+ *
+ * `backendFetch`와 나눈 이유: 백엔드의 쓰기 API 상당수가 `ApiResponseVO.success(null)`을 반환한다
+ * (예: 게시판 등록·수정·삭제). `backendFetch`는 `data`가 없으면 실패로 보므로 그대로 쓰면 **성공한
+ * 요청이 전부 실패로 보고된다.** 두 규약을 한 함수에 섞으면 조회 쪽의 계약 위반 감지가 무뎌지므로
+ * 함수를 나눴다.
+ */
+export async function backendCommand(
+  path: string,
+  init: BackendRequestInit
+): Promise<BackendResult<null>> {
+  const result = await sendBackendRequest(path, init);
+  return result.ok ? { ok: true, data: null } : result;
 }
Add a comment
List