임동욱 임동욱 09-03
feat: 게시판 검색을 백엔드가 되는 것만 남기고 연동한다
목록 쿼리가 거를 수 있는 것은 제목(searchCondition=1)과 노출여부(RLS_YN) 둘뿐이다.
그 둘은 백엔드로 넘기고 페이징도 맡긴다 — 별도 카운트 쿼리가 있다. 지금까지는 전체를
받아 프론트에서 거르고 잘랐다.

거르지 못하는 것은 감춘다(사용자 확정) — 내용·질문자명·전화번호·답변내용 검색과
유형·답변여부 필터다. 골라도 걸러지지 않으면 고장으로 보인다. 1:1문의는 남는 대상이
하나도 없어 검색 상자 자체를 감춘다.

Co-Authored-By: Claude Opus 5 
@46de31d88be4129c26f7b16063486475424ac724
app/(protected)/(basic)/boards/_components/board-post-list.tsx
--- app/(protected)/(basic)/boards/_components/board-post-list.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-list.tsx
@@ -106,6 +106,7 @@
             />
           </>
         }
+        searchHidden={BOARD_SEARCH_FIELDS[boardType].length === 0}
         searchFields={BOARD_SEARCH_FIELDS[boardType].map((field) => ({
           value: field.value,
           label: field.label,
lib/data/repositories/board-post-repository.ts
--- lib/data/repositories/board-post-repository.ts
+++ lib/data/repositories/board-post-repository.ts
@@ -3,7 +3,6 @@
 import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
 import {
   BOARD_SETTING_IDS,
-  formatBoardCategoryLabel,
   type BoardPost,
   type BoardType,
 } from '@/lib/domain/board-post';
@@ -37,18 +36,6 @@
 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';
@@ -119,16 +106,15 @@
 }
 
 /** 게시판 전체를 한 번에 받아온다(검색·필터·페이징은 호출부가 처리한다). */
-async function fetchAllBoardPosts(boardType: BoardType): Promise<BoardPost[]> {
+async function requestBoardPage(
+  boardType: BoardType,
+  params: Record<string, string | number>
+): Promise<{ items: BoardPost[]; totalCount: number }> {
   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,
-    },
+    query: { searchStngId: BOARD_SETTING_IDS[boardType], ...params },
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
   });
@@ -142,50 +128,10 @@
     throw new Error('게시판 목록 응답의 형식이 올바르지 않습니다.');
   }
 
-  return data.list.map(toBoardPost);
-}
+  const totalCount =
+    typeof data.totalCount === 'number' ? data.totalCount : data.list.length;
 
-/** 검색 대상 → 비교할 값. 목록·상세에 실제로 보이는 값으로만 거른다. */
-const SEARCH_VALUE_BY_FIELD: Record<string, (post: BoardPost) => string> = {
-  category: (post) => formatBoardCategoryLabel(post.categoryCode),
-  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;
+  return { items: data.list.map(toBoardPost), totalCount };
 }
 
 export type BoardPostPage = {
@@ -204,15 +150,25 @@
   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,
+  const params: Record<string, string | number> = {
+    pageIndex: query.page,
+    recordCountPerPage: query.pageSize,
   };
+
+  // 백엔드가 거를 수 있는 둘만 넘긴다 — 제목(searchCondition=1)과 노출여부(RLS_YN).
+  // 나머지 조건은 화면에서 감췄다(`BOARD_SEARCH_FIELDS`·`BOARD_QUERY_FIELDS`).
+  const keyword = query.keyword.trim();
+  if (keyword && query.searchField === 'title') {
+    params.searchCondition = '1';
+    params.searchKeyword = keyword;
+  }
+  if (query.visibility === 'visible') {
+    params.searchUseYn = 'Y';
+  } else if (query.visibility === 'hidden') {
+    params.searchUseYn = 'N';
+  }
+
+  return requestBoardPage(boardType, params);
 }
 
 /** 단건 조회 — 수정·답변 팝업의 진입점. */
lib/domain/board-post-query.ts
--- lib/domain/board-post-query.ts
+++ lib/domain/board-post-query.ts
@@ -32,8 +32,12 @@
 /**
  * 화면별 검색 대상.
  *
- * 백엔드 목록 쿼리는 제목(`searchCondition=1`) 하나만 지원하지만, 사용자 확인 결과 **전체를 받아
- * 프론트에서 필터링**하기로 해서 시안의 검색 대상을 모두 제공할 수 있다(Repository 주석 참조).
+ * **백엔드가 거를 수 있는 것만 남긴다**(사용자 확정). 목록 쿼리가 지원하는 것은 제목
+ * (`searchCondition=1`)과 노출여부(`searchUseYn`) 둘뿐이라, 시안에 있던 내용·질문자명·
+ * 전화번호·답변내용은 감춘다 — 골라도 걸러지지 않으면 고장으로 보인다.
+ *
+ * 1:1문의는 제목이 없어 남는 대상이 하나도 없다. 그래서 검색 상자 자체를 감춘다.
+ * 백엔드에 조건이 추가되면 여기에 줄을 되살리면 된다.
  */
 export const BOARD_SEARCH_FIELDS: Record<BoardType, readonly BoardSearchFieldOption[]> = {
   notice: [
@@ -42,17 +46,9 @@
       : []),
     { value: 'title', label: '제목' },
   ],
-  // 1:1문의는 제목 없이 내용만 쓴다 — 제목 검색은 대상이 없다.
-  inquiry: [
-    { value: 'authorName', label: '질문자명' },
-    { value: 'authorPhoneNumber', label: '전화번호' },
-    { value: 'content', label: '내용' },
-    { value: 'answerContent', label: '답변내용' },
-  ],
-  faq: [
-    { value: 'title', label: '제목' },
-    { value: 'content', label: '내용' },
-  ],
+  // 1:1문의는 제목 없이 내용만 쓴다 — 백엔드가 내용을 못 걸러 남는 대상이 없다.
+  inquiry: [],
+  faq: [{ value: 'title', label: '제목' }],
 };
 
 /** 화면별로 노출하는 필터 — 화면이 무엇을 그릴지 판단하는 근거이자, 이 파일의 검증 기준이다. */
@@ -60,9 +56,10 @@
   BoardType,
   { category: boolean; type: boolean; visibility: boolean; answered: boolean }
 > = {
+  // 유형(bbsCd)·답변여부는 백엔드 목록 쿼리에 조건이 없어 감춘다(사용자 확정).
   notice: { category: IS_BOARD_CATEGORY_ENABLED, type: false, visibility: true, answered: false },
-  inquiry: { category: IS_BOARD_CATEGORY_ENABLED, type: true, visibility: false, answered: true },
-  faq: { category: IS_BOARD_CATEGORY_ENABLED, type: true, visibility: false, answered: false },
+  inquiry: { category: IS_BOARD_CATEGORY_ENABLED, type: false, visibility: false, answered: false },
+  faq: { category: IS_BOARD_CATEGORY_ENABLED, type: false, visibility: false, answered: false },
 };
 
 /**
Add a comment
List