임동욱 임동욱 08-20
fix: 새 콘텐츠가 목록 마지막 페이지로 밀리던 것 — 전량 수령 후 로컬 페이징
목록 SQL이 rnum(등록일 내림차순)을 ORDER BY RNUM DESC로 다시 뒤집어 옛 글부터
내보내고, LIMIT도 키워드·학교급·파일 조인으로 불어난 행 기준이라 방금 등록한
글이 첫 페이지에 없었다. 페이지 경계에서 잘린 콘텐츠는 키워드 목록도 반쪽으로
온다.

benWord와 같은 우회를 쓴다 — 한 번에 받아(상한 500) 일련번호 내림차순으로 다시
세우고 로컬에서 자른다. 검색어도 서버에 보내지 않는다. 검색 조건이 c.·k. 별칭을
쓰는데 count 쿼리에 별칭이 없어 키워드를 실으면 count가 SQL 오류로 죽는다.
학교·학년·키워드 필터를 모두 로컬로 걸어 총건수도 정확해졌다.

Co-Authored-By: Claude Opus 5 
@bd31b63f18eee6ddc59e461c40d23b2385710740
lib/data/repositories/content-repository.ts
--- lib/data/repositories/content-repository.ts
+++ lib/data/repositories/content-repository.ts
@@ -24,14 +24,14 @@
  *
  * 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다.
  *
- * ⚠️ 백엔드 결함(보고함, 미수정)
- * 1. `selectPaginationCount`가 `FROM TB_COM_CNTNTS`만 두고 `searchWhere`를 그대로 포함한다.
- *    그 조건은 `c.`·`k.`·`g.` 별칭을 쓰므로 별칭을 모르는 count 쿼리에서 SQL이 깨진다.
- * 2. 학교·학년 조건의 `<if test='searchSchulGradeCd != null != ""'>`가 OGNL에서 항상 참이고,
- *    본문도 값 대신 `#{searchKeyword}`를 비교한다. 파라미터를 보내도 의미가 없어 보내지 않고,
- *    학교·학년 필터는 받아온 뒤 여기서 건다.
- * 3. 목록 SQL이 키워드·학교급·파일을 모두 LEFT JOIN해 한 콘텐츠가 여러 행이 된다. LIMIT이 그
- *    늘어난 행에 걸려 한 페이지에 오는 콘텐츠 수가 `recordCountPerPage`보다 적을 수 있다.
+ * ⚠️ 백엔드 결함(보고함, 미수정) — 그래서 목록은 전량 수령 후 로컬 검색·정렬·페이징이다.
+ * 1. 검색 조건(`c.CNTNTS_TITLE`·`k.CNTNTS_KEYWORD`)이 별칭을 쓰는데 `selectPaginationCount`는
+ *    `FROM TB_COM_CNTNTS`뿐이라 검색어를 실으면 count가 SQL 오류로 죽는다.
+ * 2. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시 뒤집어
+ *    **옛 글부터** 나간다. 새로 등록한 글이 첫 페이지가 아니라 마지막 페이지에 붙는다.
+ * 3. 키워드·학교급·파일 LEFT JOIN으로 한 콘텐츠가 여러 행이 되고 LIMIT이 그 행 기준이라, 페이지
+ *    경계에서 잘린 콘텐츠는 키워드·컷 목록이 반쪽으로 온다.
+ * 4. 학교·학년 파라미터는 `<if>` 본문이 값 대신 `#{searchKeyword}`를 비교해 보내도 의미가 없다.
  */
 
 const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns';
@@ -129,17 +129,14 @@
 
 function buildListParams(
   kind: ContentApiKind,
-  query: ContentQuery,
   pageIndex: number,
   recordCountPerPage: number
 ): Record<string, string | number> {
-  const keyword = query.keyword.trim();
-
   return {
     // 없으면 `CNTNTS_TYPE = NULL`이 되어 한 건도 오지 않는다.
+    // 검색어는 보내지 않는다 — 검색 조건이 c.·k. 별칭을 쓰는데 count 쿼리에는 별칭이 없어
+    // 키워드를 실으면 count가 SQL 오류로 죽는다(파일 상단 ⚠️ 1). 검색은 아래에서 로컬로 건다.
     searchCntntsType: CONTENT_TYPE_CODE[kind],
-    searchCondition: keyword ? '1' : '',
-    searchKeyword: keyword,
     pageIndex,
     recordCountPerPage,
   };
@@ -147,7 +144,6 @@
 
 async function requestList(
   kind: ContentApiKind,
-  query: ContentQuery,
   pageIndex: number,
   recordCountPerPage: number
 ): Promise<{ items: ContentItem[]; reportedTotalCount: number }> {
@@ -155,7 +151,7 @@
 
   const result = await backendFetch<unknown>(`${CONTENT_BASE_PATH}/pagination`, {
     method: 'GET',
-    query: buildListParams(kind, query, pageIndex, recordCountPerPage),
+    query: buildListParams(kind, pageIndex, recordCountPerPage),
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     canHaveNullData: true,
@@ -188,26 +184,50 @@
   isTotalCountExact: boolean;
 };
 
+// TODO(백엔드): 목록 정렬·LIMIT이 고쳐지면 서버 페이징으로 되돌린다. 그전까지 이 상한을
+// 넘는 콘텐츠는 목록에서 조용히 잘린다.
+const FETCH_LIMIT = 500;
+
+function matchesContentQuery(item: ContentItem, query: ContentQuery): boolean {
+  if (query.school && item.schoolCode !== query.school) {
+    return false;
+  }
+  if (query.grade && !item.grades.includes(query.grade)) {
+    return false;
+  }
+
+  const keyword = query.keyword.trim().toLowerCase();
+  if (!keyword) {
+    return true;
+  }
+  if (query.searchField === 'keyword') {
+    return item.keywords.some((entry) =>
+      entry.keyword.toLowerCase().includes(keyword)
+    );
+  }
+  // title(쇼츠)·name(만화) 모두 같은 컬럼(CNTNTS_NM=CNTNTS_TITLE)이라 한 값을 본다.
+  return item.title.toLowerCase().includes(keyword);
+}
+
 export async function fetchContents(
   kind: ContentApiKind,
   query: ContentQuery
 ): Promise<ContentPage> {
-  const { items, reportedTotalCount } = await requestList(
-    kind,
-    query,
-    query.page,
-    query.pageSize
-  );
+  // 서버 페이징을 쓰지 않는다 — 목록 SQL이 rnum(등록일 내림차순)을 다시 뒤집어 옛 글부터
+  // 내보내고, LIMIT도 조인으로 불어난 행 기준이라 새 글이 마지막 페이지로 밀린다(⚠️ 2·3).
+  const { items } = await requestList(kind, 1, FETCH_LIMIT);
 
-  const confirmedCount = (query.page - 1) * query.pageSize + items.length;
+  const matched = items
+    .filter((item) => matchesContentQuery(item, query))
+    // rnum은 resultMap에 없고 등록일은 일 단위라, 새 글이 먼저 오도록 일련번호로 세운다.
+    .sort((a, b) => Number(b.cntntsSn) - Number(a.cntntsSn));
+
+  const offset = (query.page - 1) * query.pageSize;
 
   return {
-    items,
-    // 백엔드 `PaginationUtil`이 count 없이 페이지 행 수를 총건수로 내려주는 결함이 있어
-    // 하한값으로 보정한다(다른 목록 화면과 같은 처리).
-    totalCount: Math.max(reportedTotalCount, confirmedCount),
-    isTotalCountExact:
-      items.length < query.pageSize || reportedTotalCount > confirmedCount,
+    items: matched.slice(offset, offset + query.pageSize),
+    totalCount: matched.length,
+    isTotalCountExact: true,
   };
 }
 
Add a comment
List