feat: 학생 회원 목록 정렬 활성화
비활성이던 정렬 select를 되살린다. 백엔드 목록 API에는 정렬 파라미터가 없고 목록 SQL의 ORDER BY가 하드코딩돼 있어(ORDER BY rnum DESC), 두 정렬을 서로 다른 방식으로 만족시킨다. - 가입일순: 백엔드의 고정 순서가 이미 가입일 최신순이다. rnum이 ROW_NUMBER() OVER (ORDER BY frst_reg_dt, user_nm DESC)로 매겨진 최초등록일시 순번이고 그것을 역순으로 뒤집기 때문이다. 따라서 페이지 하나만 받아오면 되고 기존 경로 그대로다. - 이름순: 전체를 받아 서버에서 정렬하고 페이지를 잘라낸다. 받아온 한 페이지만 정렬하면 "그 페이지 안에서만 이름순"이 되어 2페이지의 '김'이 1페이지의 '이'보다 뒤에 오는, 전체 기준 정렬처럼 보이는 잘못된 결과가 나온다. 검색 조건은 백엔드에 그대로 위임하고 페이지 크기만 상한(10,000)까지 키워 한 번에 받는다. 전체를 쥐게 되므로 이 경로에서는 총건수가 확정된다. 회원 수가 상한을 넘으면 그 위로는 정렬 대상에서 빠지고 총건수도 하한값으로 표기된다 — 그 규모에서는 백엔드에 정렬 파라미터가 필요하고, 상한 인상은 임시방편일 뿐이다.
@a289f10481e89f6f28f96a5bca429e6f9cec4cb8
--- app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
+++ app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
... | ... | @@ -25,10 +25,9 @@ |
| 25 | 25 |
* 엑셀다운로드는 이번 범위에서 버튼 배치만 하고 동작은 구현하지 않는다(사용자 지시) — |
| 26 | 26 |
* disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. |
| 27 | 27 |
* |
| 28 |
- * 정렬 select도 같은 이유로 비활성이다 — 백엔드 목록 API(`/api/v1/mngr/user/pagination`)에 |
|
| 29 |
- * 정렬 파라미터가 없어 값을 바꿔도 결과가 달라지지 않는다. 현재 페이지 안에서만 다시 정렬하는 |
|
| 30 |
- * 것은 "전체 기준 정렬"처럼 보이는 잘못된 결과라 하지 않는다. 정렬 파라미터가 생기면 이 |
|
| 31 |
- * disabled만 걷어내고 Repository에 매핑을 추가하면 된다. |
|
| 28 |
+ * 정렬 select는 URL의 `sort`만 바꾸고, 그 값을 어떻게 만족시킬지는 Repository가 정한다 — |
|
| 29 |
+ * 가입일순은 백엔드의 고정 순서와 같아 그대로 받아오고, 이름순은 백엔드에 정렬 파라미터가 없어 |
|
| 30 |
+ * 전체를 받아 서버에서 정렬한다. 화면은 그 차이를 알 필요가 없다. |
|
| 32 | 31 |
*/ |
| 33 | 32 |
export function StudentListToolbar({ query }: StudentListToolbarProps) {
|
| 34 | 33 |
const router = useRouter(); |
... | ... | @@ -61,8 +60,6 @@ |
| 61 | 60 |
aria-label="정렬" |
| 62 | 61 |
defaultValue={query.sort}
|
| 63 | 62 |
onChange={handleSortChange}
|
| 64 |
- disabled |
|
| 65 |
- title="정렬은 백엔드 API가 지원하지 않습니다." |
|
| 66 | 63 |
> |
| 67 | 64 |
{STUDENT_MEMBER_SORT_OPTIONS.map((option) => (
|
| 68 | 65 |
<option key={option.value} value={option.value}>
|
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
... | ... | @@ -22,8 +22,9 @@ |
| 22 | 22 |
* SCH_NM·GRADE·CLS_NO·BIRTH까지 이미 select하지만 응답 VO(MngrUserVo)가 4개 필드만 노출해 |
| 23 | 23 |
* 나머지가 버려진다. 그래서 화면의 나머지 항목은 `null` → `-`다. **백엔드가 VO에 필드를 |
| 24 | 24 |
* 추가하면** `toStudentMember`의 매핑만 늘리면 되고 화면은 손대지 않는다. |
| 25 |
- * - **정렬은 고정이다** — `ORDER BY rnum DESC`(rnum은 최초등록일시 기준 행번호)라 사실상 |
|
| 26 |
- * 가입일 최신순이고, 정렬 파라미터는 없다. |
|
| 25 |
+ * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY rnum DESC`가 하드코딩돼 있어 순서를 지정할 수 |
|
| 26 |
+ * 없다. 그 고정 순서가 곧 가입일 최신순이라(rnum은 최초등록일시 기준 행번호) 가입일순은 그대로 |
|
| 27 |
+ * 쓰고, 이름순만 전체를 받아 서버에서 정렬한다(`fetchByName`). |
|
| 27 | 28 |
* - **사용자 유형을 걸러 주지 않는다** — 조회 대상이 TB_COM_USER 전체라 student 외 |
| 28 | 29 |
* teacher·manager도 섞일 수 있고, 응답에 USER_TYPE도 없어 구분할 방법이 없다. |
| 29 | 30 |
* 화면이 역할을 '학생'으로 표기하는 것은 이 화면의 전제일 뿐 백엔드가 보장하는 값이 아니다. |
... | ... | @@ -58,6 +59,14 @@ |
| 58 | 59 |
const STUDENT_ROLE_LABEL = '학생'; |
| 59 | 60 |
|
| 60 | 61 |
/** |
| 62 |
+ * 이름순 정렬을 위해 한 번에 받아올 최대 행 수. 백엔드가 정렬을 지원하지 않아 우리가 정렬하려면 |
|
| 63 |
+ * 전체를 받아와야 하는데, 상한 없이 요청하면 백엔드가 전 행을 메모리에 올리게 되므로 상한을 둔다. |
|
| 64 |
+ * 회원 수가 이 값을 넘으면 그 위로는 정렬 대상에서 빠지고 총건수도 하한값으로 표기된다 — |
|
| 65 |
+ * 그 규모가 되면 백엔드에 정렬 파라미터가 필요하다(상한 인상은 임시방편일 뿐이다). |
|
| 66 |
+ */ |
|
| 67 |
+const NAME_SORT_FETCH_LIMIT = 10_000; |
|
| 68 |
+ |
|
| 69 |
+/** |
|
| 61 | 70 |
* 페이징 파라미터. 계약 예시에는 7종이 나열돼 있지만 **실제로 쓰이는 것은 두 개뿐**이다. |
| 62 | 71 |
* |
| 63 | 72 |
* `MngrUserServiceImpl`이 `PaginationUtil.execute(pageIndex, recordCountPerPage, ...)`로 |
... | ... | @@ -66,12 +75,10 @@ |
| 66 | 75 |
* 읽히지 않으므로 보내지 않는다 — 보내면 "이 값이 결과에 영향을 준다"는 오해만 남는다. |
| 67 | 76 |
*/ |
| 68 | 77 |
function buildPaginationParams( |
| 69 |
- query: StudentMemberQuery |
|
| 78 |
+ pageIndex: number, |
|
| 79 |
+ recordCountPerPage: number |
|
| 70 | 80 |
): Record<string, string | number> {
|
| 71 |
- return {
|
|
| 72 |
- pageIndex: query.page, |
|
| 73 |
- recordCountPerPage: query.pageSize, |
|
| 74 |
- }; |
|
| 81 |
+ return { pageIndex, recordCountPerPage };
|
|
| 75 | 82 |
} |
| 76 | 83 |
|
| 77 | 84 |
/** |
... | ... | @@ -154,34 +161,19 @@ |
| 154 | 161 |
isTotalCountExact: boolean; |
| 155 | 162 |
}; |
| 156 | 163 |
|
| 157 |
-/** |
|
| 158 |
- * 검색·페이징이 적용된 학생 회원 목록을 조회한다. |
|
| 159 |
- * |
|
| 160 |
- * 정렬(`query.sort`)은 백엔드에 정렬 파라미터가 없어 전달하지 않는다 — 순서는 백엔드가 |
|
| 161 |
- * `ORDER BY rnum DESC`로 고정(사실상 가입일 최신순)하며, 화면의 정렬 select도 그래서 비활성이다. |
|
| 162 |
- * |
|
| 163 |
- * **응답의 `totalCount`는 전체 건수가 아니라 "그 페이지에 담긴 행 수"다.** 백엔드에 count 쿼리가 |
|
| 164 |
- * 없어 `PaginationUtil`이 `list.size()`를 그대로 총건수로 쓰기 때문이다(edupay-backend |
|
| 165 |
- * `PaginationUtil.execute`). 그 값을 그대로 믿으면 한 페이지가 가득 찰 때마다 `totalPages`가 1로 |
|
| 166 |
- * 계산돼 **2페이지 이후 데이터에 영원히 접근할 수 없다.** 그래서 여기서 하한값으로 보정한다: |
|
| 167 |
- * |
|
| 168 |
- * - 페이지가 가득 차지 않았다 → 마지막 페이지다 → 전체 건수 = offset + 받은 행 수 (확정). |
|
| 169 |
- * - 가득 찼다 → 더 있을 수 있다 → 하한값만 알리고(`isTotalCountExact: false`) 화면이 다음 |
|
| 170 |
- * 페이지를 열어 두게 한다. |
|
| 171 |
- * |
|
| 172 |
- * 백엔드가 진짜 count 쿼리를 넣으면 `totalCount`가 offset+행 수보다 커지므로 `Math.max`가 |
|
| 173 |
- * 자동으로 그 값을 채택하고 확정으로 표기된다 — 이 함수를 다시 고칠 필요가 없다. |
|
| 174 |
- */ |
|
| 175 |
-export async function fetchStudentMembers( |
|
| 176 |
- query: StudentMemberQuery |
|
| 177 |
-): Promise<StudentMemberPage> {
|
|
| 164 |
+/** 백엔드 목록 호출 1회 — 응답 검증까지만 하고 정렬·페이징 판단은 호출부에 맡긴다. */ |
|
| 165 |
+async function requestStudentMemberList( |
|
| 166 |
+ query: StudentMemberQuery, |
|
| 167 |
+ pageIndex: number, |
|
| 168 |
+ recordCountPerPage: number |
|
| 169 |
+): Promise<{ items: StudentMember[]; reportedTotalCount: number }> {
|
|
| 178 | 170 |
const accessToken = await getSessionAccessToken(); |
| 179 | 171 |
|
| 180 | 172 |
const result = await backendFetch<unknown>(STUDENT_MEMBER_PAGINATION_PATH, {
|
| 181 | 173 |
method: 'GET', |
| 182 | 174 |
query: {
|
| 183 | 175 |
...buildSearchParams(query), |
| 184 |
- ...buildPaginationParams(query), |
|
| 176 |
+ ...buildPaginationParams(pageIndex, recordCountPerPage), |
|
| 185 | 177 |
}, |
| 186 | 178 |
accessToken: accessToken ?? undefined, |
| 187 | 179 |
cache: 'no-store', |
... | ... | @@ -197,8 +189,42 @@ |
| 197 | 189 |
} |
| 198 | 190 |
|
| 199 | 191 |
const items = data.list.map(toStudentMember); |
| 200 |
- const reportedTotalCount = |
|
| 201 |
- typeof data.totalCount === 'number' ? data.totalCount : items.length; |
|
| 192 |
+ |
|
| 193 |
+ return {
|
|
| 194 |
+ items, |
|
| 195 |
+ reportedTotalCount: |
|
| 196 |
+ typeof data.totalCount === 'number' ? data.totalCount : items.length, |
|
| 197 |
+ }; |
|
| 198 |
+} |
|
| 199 |
+ |
|
| 200 |
+/** |
|
| 201 |
+ * 가입일순 — 백엔드가 이미 이 순서로 내려준다. |
|
| 202 |
+ * |
|
| 203 |
+ * 백엔드 목록 SQL은 `ORDER BY rnum DESC`로 고정돼 있고 `rnum`은 |
|
| 204 |
+ * `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, user_nm DESC)`, 즉 **최초등록일시 오름차순 순번**이다. |
|
| 205 |
+ * 그것을 역순으로 뒤집으니 결과는 가입일 최신순(동일 시각이면 이름 오름차순)이 된다. 그래서 이 |
|
| 206 |
+ * 정렬은 페이지 하나만 받아오면 되고 추가 비용이 없다. |
|
| 207 |
+ * |
|
| 208 |
+ * **응답의 `totalCount`는 전체 건수가 아니라 "그 페이지에 담긴 행 수"다.** 백엔드에 count 쿼리가 |
|
| 209 |
+ * 없어 `PaginationUtil`이 `list.size()`를 그대로 총건수로 쓰기 때문이다(edupay-backend |
|
| 210 |
+ * `PaginationUtil.execute`). 그 값을 그대로 믿으면 한 페이지가 가득 찰 때마다 `totalPages`가 1로 |
|
| 211 |
+ * 계산돼 **2페이지 이후 데이터에 영원히 접근할 수 없다.** 그래서 하한값으로 보정한다: |
|
| 212 |
+ * |
|
| 213 |
+ * - 페이지가 가득 차지 않았다 → 마지막 페이지다 → 전체 건수 = offset + 받은 행 수 (확정). |
|
| 214 |
+ * - 가득 찼다 → 더 있을 수 있다 → 하한값만 알리고(`isTotalCountExact: false`) 화면이 다음 |
|
| 215 |
+ * 페이지를 열어 두게 한다. |
|
| 216 |
+ * |
|
| 217 |
+ * 백엔드가 진짜 count 쿼리를 넣으면 `totalCount`가 offset+행 수보다 커지므로 `Math.max`가 |
|
| 218 |
+ * 자동으로 그 값을 채택하고 확정으로 표기된다 — 이 함수를 다시 고칠 필요가 없다. |
|
| 219 |
+ */ |
|
| 220 |
+async function fetchByJoinedAt( |
|
| 221 |
+ query: StudentMemberQuery |
|
| 222 |
+): Promise<StudentMemberPage> {
|
|
| 223 |
+ const { items, reportedTotalCount } = await requestStudentMemberList(
|
|
| 224 |
+ query, |
|
| 225 |
+ query.page, |
|
| 226 |
+ query.pageSize |
|
| 227 |
+ ); |
|
| 202 | 228 |
|
| 203 | 229 |
const offset = (query.page - 1) * query.pageSize; |
| 204 | 230 |
const confirmedCount = offset + items.length; |
... | ... | @@ -210,3 +236,44 @@ |
| 210 | 236 |
isTotalCountExact: reachedLastPage || reportedTotalCount > confirmedCount, |
| 211 | 237 |
}; |
| 212 | 238 |
} |
| 239 |
+ |
|
| 240 |
+/** |
|
| 241 |
+ * 이름순 — 백엔드가 정렬을 지원하지 않아 **전체를 받아 여기서 정렬하고 페이지를 잘라낸다.** |
|
| 242 |
+ * |
|
| 243 |
+ * 받아온 한 페이지만 정렬하면 "그 페이지 안에서만 이름순"이 되어 전체 기준 정렬처럼 보이는 잘못된 |
|
| 244 |
+ * 결과가 나온다(2페이지의 '김'이 1페이지의 '이'보다 뒤에 오는 식). 그래서 검색 조건은 백엔드에 |
|
| 245 |
+ * 그대로 위임하되 페이지 크기만 상한까지 키워 한 번에 받아온 뒤 정렬한다. |
|
| 246 |
+ * |
|
| 247 |
+ * 전체를 손에 쥐므로 총건수는 확정이다 — 상한에 걸린 경우에만 하한값으로 표기한다. |
|
| 248 |
+ */ |
|
| 249 |
+async function fetchByName( |
|
| 250 |
+ query: StudentMemberQuery |
|
| 251 |
+): Promise<StudentMemberPage> {
|
|
| 252 |
+ const { items } = await requestStudentMemberList(
|
|
| 253 |
+ query, |
|
| 254 |
+ 1, |
|
| 255 |
+ NAME_SORT_FETCH_LIMIT |
|
| 256 |
+ ); |
|
| 257 |
+ |
|
| 258 |
+ const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name, 'ko')); |
|
| 259 |
+ const offset = (query.page - 1) * query.pageSize; |
|
| 260 |
+ |
|
| 261 |
+ return {
|
|
| 262 |
+ items: sorted.slice(offset, offset + query.pageSize), |
|
| 263 |
+ totalCount: sorted.length, |
|
| 264 |
+ isTotalCountExact: sorted.length < NAME_SORT_FETCH_LIMIT, |
|
| 265 |
+ }; |
|
| 266 |
+} |
|
| 267 |
+ |
|
| 268 |
+/** |
|
| 269 |
+ * 검색·정렬·페이징이 적용된 학생 회원 목록을 조회한다. |
|
| 270 |
+ * |
|
| 271 |
+ * 정렬 기준에 따라 조회 전략이 갈린다 — 백엔드에 정렬 파라미터가 없기 때문이다(목록 SQL의 |
|
| 272 |
+ * `ORDER BY`가 하드코딩돼 있다). 가입일순은 백엔드의 고정 순서와 같아 페이지 하나만 받으면 되고, |
|
| 273 |
+ * 이름순은 전체를 받아 여기서 정렬한다. 두 경로의 비용 차이는 그 사실에서 온다. |
|
| 274 |
+ */ |
|
| 275 |
+export async function fetchStudentMembers( |
|
| 276 |
+ query: StudentMemberQuery |
|
| 277 |
+): Promise<StudentMemberPage> {
|
|
| 278 |
+ return query.sort === 'name' ? fetchByName(query) : fetchByJoinedAt(query); |
|
| 279 |
+} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?