## 등록이 "아무 반응 없이" 실패하던 원인
썸네일 이미지는 Server Action **본문에 실려** 간다(브라우저가 백엔드를 직접 부르지 않으므로).
그런데 `serverActions.bodySizeLimit`이 설정돼 있지 않아 Next 기본값 1MB가 걸려 있었고, 요즘
이미지는 그걸 쉽게 넘는다. 초과하면 요청이 413으로 끊겨 **액션 본문이 실행조차 되지 않으므로**
`useActionState`는 idle 그대로다 — 오류도 토스트도 없고, React가 액션 종료 후 폼을 리셋하니
화면에는 "입력값만 지워지고 아무 일도 안 일어남"으로 보인다.
재현·확인: 임시 Server Action에 2MB를 보내면 1mb 설정에서 `Body exceeded 1mb limit.` + 500이
뜨고, 10mb로 올리면 2MB·8MB 모두 통과한다. 백엔드는 `CommonsMultipartResolver`가 100MB까지
받으므로 막고 있던 것은 Next 쪽뿐이었다. 본문이 Next 서버 메모리에 통째로 올라가는 값이라
관리 화면에 필요한 만큼(10MB)만 열었다.
같은 구조인 **게시판 첨부파일도 함께 낫는다** — 그쪽도 File을 Server Action 본문으로 보낸다.
## 아이템 수정 API (백엔드 4d98756, 2026-08-18)
- `PUT /mngr/item/{itemSn}`가 `@ParameterObject` → `@RequestBody`로 바뀌었는데 프론트는 POST·PUT
둘 다 form으로 보내고 있었다(415). 등록은 여전히 form이라 **형식이 갈린다** — 관리자 API와
똑같은 모양이다.
- 목록 SQL 별칭이 `LAST_MDFCN_DT` → `last_mdfcn_dt_str`로 개명돼 수정일이 늘 비어 있었다.
매핑을 `lastMdfcnDtStr`로 맞췄다(다만 컨트롤러가 `lastMdfcnDt`를 세팅하지 않아 백엔드가
그것을 채우기 전까지는 계속 빈 값이다 — 주석에 남겼다).
"수정하면 아이템이 목록에서 사라지던" 종전 결함은 그 커밋에서 해소돼 주석을 갱신했다. 대신
UPDATE가 ``로 감싸지면서 **설명을 비워도 지워지지 않는** 제약이 새로 생겼다.
Co-Authored-By: Claude Opus 5
비밀번호에 이어 ID·이메일도 `@fox/core/validation` 위에 올렸다. 핵심은 검증기를 화면이 아니라
**도메인이 소유**한다는 점이다 — `ADMIN_LOGIN_ID_VALIDATORS`·`adminEmailValidators()`를
`admin-member-form.ts`가 내보내고, 팝업의 입력 칸과 Server Action의 검증이 **같은 배열**을
돌린다. 규칙이 한 군데뿐이라 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않고, 서버가
돌려주는 문구와 입력 중에 보이는 문구가 같다.
ID의 "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자는 `pattern`이 소문자·숫자로
묶고, 그 안에서 둘 다 있어야 한다는 것은 `characterKinds(2)`가 본다. 종전의 손으로 쓴 세 갈래
분기(`validateAdminLoginId`)와 `isValidEmail`은 지웠다.
이메일은 필수 여부만 두 시안이 달라(등록에 `*`, 수정에 없음) 그것만 인자로 받는다.
등록 팝업의 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 합친 값이다. FoxInput 한 칸이 스스로
판정할 수 없으므로 팝업이 돌리고 `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점).
아울러 **로컬부가 비면 빈 이메일로 본다** — 도메인만 고른 `@naver.com`을 값으로 보내면 "형식이
올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다.
브라우저 확인: ID는 대문자(`Admin`)·22자에서 각각 다른 사유가 뜨고 `admin01`에서 사라진다.
이메일은 빈 로컬부에서 "이메일을 입력해 주세요.", 입력하면 사라지며 hidden 값이 채워진다.
Co-Authored-By: Claude Opus 5
두 변경이 등록 팝업의 같은 파일에 겹쳐 한 커밋으로 둔다.
## 중복 확인 실패 사유
확인 호출이 백엔드에서 깨지면 `isAdminLoginIdTaken`이 던진 예외가 클라이언트 transition의
처리되지 않은 rejection으로 사라졌다 — 버튼만 원복되고 아무 말도 남지 않아 "눌러도 아무 일이
없는" 상태로 보였다. 확인 결과에 `failed`를 더해 **값의 판정(unavailable)과 확인 실패(failed)를
구분**하고, 백엔드 메시지를 ID 칸 아래 같은 자리에 그대로 띄운다.
## @fox/core/validation
Angular `Validators`의 계약을 옮겼다 — 검증기는 값을 받아 통과면 `null`, 아니면 **오류 객체**를
돌려주는 순수 함수이고, 키가 오류 이름, 값이 문구를 만들 맥락이다
(`{ minlength: { requiredLength, actualLength } }`). 인자가 `AbstractControl`이 아니라 문자열인
것만 다르다 — @fox에는 폼 모델이 없고 검증 대상이 칸의 값 하나뿐이다.
`required`를 뺀 검증기는 빈 값을 통과시키고(Angular와 같다), `compose`는 오류를 **병합**해
"10자 이상"과 "2종류 이상"을 함께 판정할 수 있게 한다.
**문구는 검증기가 갖지 않는다**(Angular와 같은 이유 — 같은 규칙도 자리마다 다른 말이 필요하다).
`foxValidationMessage(errors, overrides)`가 옮기고, 한 번에 한 문구만 낸다.
비밀번호처럼 규칙이 여러 개인 칸을 위해 `foxPasswordValidator(policy)`를 둔다 — 길이·조합에
더해 같은 문자 연속·잇따르는 문자·특정 문자열 포함 금지를 옵션으로 받는다.
## FoxInput 연결
`validators` · `validationMessages` · `validateOn` · `onValidationChange`.
- `message`는 평소 헬퍼이고 검증이 걸리면 그 자리에 사유가 들어간다. 호출부가 `invalid`를 직접
켜면 `message`가 이긴다 — 서버가 준 사유(중복·권한)는 화면 규칙이 알 수 없어 덮이면 안 된다.
- 오류는 **한 번 포커스를 벗어난 뒤부터** 보이고 그 뒤로는 입력할 때마다 갱신된다
(Angular의 `touched`. `updateOn` 기본값만 다르다 — 한 글자에 "10자 이상"이 뜨면 방해가 된다).
## 사용
비밀번호 규칙을 `ADMIN_PASSWORD_POLICY` 한 곳으로 모아 **화면과 Server Action이 같은 값을 본다**
— 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. 등록은 `required: true`,
수정은 비우면 "바꾸지 않음"이라 걸지 않는다.
Co-Authored-By: Claude Opus 5
## 백엔드 최신화 반영 (edupay-backend develop 4d98756)
- **수정이 동작하지 않던 것을 고쳤다.** `PUT /mngr/admin`에 form으로 보내고 있었으나 백엔드는
`PUT /mngr/admin/{admUserId}` + `@RequestBody`(JSON)다. 등록만 `@ParameterObject`(form)라
같은 도메인인데 본문 형식이 갈린다 — 두 함수 주석에 그 이유를 적어 두었다.
- 삭제를 mock 오버레이에서 실 API(`DELETE /{admUserId}`, soft delete)로 옮기고
`lib/data/mock/admin-member-store.ts`를 지웠다.
- 엑셀 다운로드 API가 생겨 `/admins/excel` 라우트 핸들러로 중계하고 버튼을 열었다.
- 목록 응답에 `admTelNo`·`admEmlAddr`·`frstRegDtStr`이 추가돼 생성일 매핑을 넣었다 —
휴대전화번호·이메일·생성일 세 열의 `-`가 실값으로 바뀐다.
- 검색 분기가 `USER_TELNO`→`ADM_TEL_NO`로 고쳐져 휴대전화번호 검색을 되살렸다(시안 ①의 셋).
- 수정 시 비밀번호 필수 제약을 없앴다 — UPDATE의 `LOGIN_PW`가 빈 값이면 SET 절에서 빠지도록
바뀌어 "바꾸지 않음"을 표현할 수 있게 됐다.
- `totalCount`는 여전히 `list.size()`라 전체를 받아 서버에서 세는 방식은 유지한다.
## 등록·수정 팝업 (시안 5227:2035 / 5227:3351)
@fox로 다시 그렸다 — modal·input·btn·form-label·form-phonenumber·select·chip-area·toggle이
Figma 인스턴스 이름과 그대로 대응한다. 확인한 값: 모달 400, 입력 40, 칩 28, 푸터 버튼 80×40,
필드 간격 32.
저장 버튼은 모달 actions 슬롯이라 폼 밖에 그려진다. FoxButton에 네이티브 `form` 속성이 없어
폼을 ref로 잡아 `requestSubmit()`을 부른다. @fox 제어 위젯(전화번호·역할·메뉴)은 값이
FormData에 실리지 않아 hidden input으로 함께 낸다.
이메일은 두 시안이 다르다 — 등록은 `[아이디] @ [도메인]`, 수정은 단일 입력이라 그대로 따랐다.
등록 쪽은 `FoxEmail`을 쓰지 않는다: 잠긴 '직접입력' 상자를 하나 더 그리고 칸마다 240px 최소폭이
박혀 320px 안에서 세 줄로 쌓인다(사용자 확정).
메뉴 선택은 시안대로 그리되 **필수 검증을 걸지 않는다**(사용자 확정) — 백엔드에 관리자별 메뉴
권한이 테이블·VO·SQL 어디에도 없어 고른 값이 저장되지 않는다.
삭제 확인 얼럿의 버튼도 무스타일 `components/ui/button`에서 FoxButton으로 옮겼다.
Co-Authored-By: Claude Opus 5
시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) snb-area 3041:11860.
메뉴는 화면이 소유하지 않는다. 계정 권한마다 항목이 달라 매 요청 백엔드가 구성해 주는 값이라,
라우트 목록을 컴포넌트 상수로 두던 구조를 걷어내고 서버에서 조회해 내려주는 흐름으로 바꿨다.
그래서 새 화면이 생겨도 사이드바 컴포넌트는 손대지 않는다.
- lib/domain/sidebar-menu.ts: section > 1depth > 2depth > 3depth + 즐겨찾기 타입. 아이콘은
컴포넌트가 아니라 이름 문자열로 받는다 — 백엔드가 넘길 수 있는 형태가 그것뿐이다.
- lib/data/repositories/sidebar-menu-repository.ts: 조회 지점. 아직 API가 없어 mock을 돌려주되
권한(roleCode)을 인자로 받아 `cache()`가 권한별로 따로 기억하게 했다 — API가 생기면 이 함수
본문만 교체하면 호출부는 그대로다.
- layout.tsx가 세션 권한으로 메뉴를 조회해 AdminShell을 거쳐 사이드바에 넘긴다. 클라이언트에서
불러오면 첫 페인트에 메뉴가 비었다가 채워지고 조회용 토큰도 브라우저로 내려가야 한다.
- sidebar-menu-icon.tsx: 이름 → @fox 아이콘 화이트리스트. 동적 import로 하면 번들러가 1,512종을
전부 싣게 되므로 적힌 것만 번들에 넣는다. 모르는 이름이 와도 기본 아이콘으로 그려진다.
- use-sidebar-nav.ts: 3단 트리용으로 다시 썼다. 펼침은 "현재 경로에서 파생된 기본값 + 사용자가
누른 값" 2층이라, 이동하면 그 가지가 저절로 열리고 직접 접으면 그 선택이 유지된다.
@fox 사용 범위(사용자 확정) — 1·2·3depth 항목은 @fox에 없어 사이드바 전용으로 짜되 토큰·아이콘은
전부 @fox를 쓰고, 있는 것은 그대로 재사용했다.
- 토글 버튼: FoxIconButton xsm/ghost가 24×24·radius 4·아이콘 20으로 시안과 정확히 일치한다.
- 검색창: FoxInput md가 크기·radius·여백까지 시안과 같고 테두리 색만 달라(input-border는
neutral-60, 시안은 border-neutral-subtler) 그 한 줄만 덮었다.
mock 메뉴는 실제로 존재하는 화면만 담았다 — 시안의 통합플랫폼 메뉴(기관 관리·역할 관리 등)를
그대로 넣으면 전부 갈 곳 없는 죽은 링크가 된다.
검증 — 브라우저 실측이 시안과 일치한다. 셸 240px·패딩 12·우측 1px, 배경 그라데이션
#f4f5f6 70% → #ecf2fe, 검색창 40/radius 8/테두리 #e6e8ea, 토글 24×24/radius 4/아이콘 20,
1depth 40/패딩 10·8/radius 8/14px ExtraBold/mix-blend multiply/아이콘 20, 활성 배경 #063a74 +
흰 글자, 2depth 판 흰 배경+1px+radius 8+패딩 8, 2depth 행 28, 별·캐럿 12(자리는 유지하고
opacity 0으로 감춤 — 시안과 같다).
AdminShell이 더 이상 useSidebar를 쓰지 않는다. 헤더 구현 때 시안에 없어 걷어낸 토글의 후속이다.
Co-Authored-By: Claude Opus 5
공지사항 등록 시 "A 'use server' file can only export async functions,
found object"로 500이 발생했다. boards/_actions.ts가 INITIAL_* 상태 상수를
함께 export한 것이 원인이다.
관리자 회원에서 같은 원인을 고친 방식(abb6259)에 맞춰 *FormState 타입과
INITIAL_* 상수를 lib/domain/board-post-form.ts로 옮겼다. 빌드·타입체크는
통과하고 폼을 실제로 제출해야만 드러나는 런타임 전용 제약이라, 근거를
domain 파일 주석에 남겼다.
Co-Authored-By: Claude Opus 5
충돌 2건 해결:
- lib/http/backend-fetch.ts: hub 버전 채택. hub가 이미 PUT/DELETE·form·
multipart를 갖췄고 data:null 성공을 canHaveNullData로 다루므로, 같은 목적으로
추가했던 backendCommand를 걷어내고 게시판 Repository를 그 규약에 맞췄다.
파일 업로드용 timeoutMs만 BackendRequestInit에 추가로 남겼다.
- admin-sidebar.tsx: 꾸미기아이템관리(hub)와 게시판관리(고객센터)를 함께 둔다.
Co-Authored-By: Claude Opus 5
사이드바 「게시판관리(고객센터)」 그룹과 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
두 레인이 같은 시기에 backendFetch에 form-urlencoded 전송을 추가해 lib/http/backend-fetch.ts가
충돌했다. 원인이 같다 — 백엔드의 쓰기 API가 @RequestBody가 아니라 @ParameterObject로 받아
JSON을 보내면 전 필드가 null로 저장된다(관리자 등록·수정, 아이템 등록·수정 모두).
아이템 쪽 구현이 관리자 쪽을 포함하므로 그것을 채택했다:
- method에 DELETE가 더 있다(아이템 삭제).
- form 값이 string 외에 number·undefined를 받는다(undefined는 전송에서 제외).
- multipart 본문을 지원한다(아이템 이미지 업로드).
- 본문·Content-Type 결정을 buildRequestBody 한 곳으로 모아 세 형식을 한눈에 볼 수 있다.
관리자 레인의 호출부는 Record을 넘기므로 넓어진 타입에 그대로 들어맞는다.
주석에는 두 도메인의 예시를 함께 남겼다.
백엔드(develop 924db37)에 관리자 쓰기 API가 추가되어 mock을 걷어낸다.
등록 POST /mngr/admin, 수정 PUT /mngr/admin, 중복확인과 단건 조회는 전용 GET을
쓴다. mock 저장소에는 삭제만 남는다 — 백엔드에 삭제 엔드포인트가 없고, 수정
API로도 USE_YN을 끌 수 없어 비활성화로 대신할 수 없다.
수정 시 비밀번호를 필수로 막는다. 백엔드 UPDATE가 LOGIN_PW를 조건 없이
덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 되기 때문이다.
메뉴 선택은 시안대로 유지하되 전송하지 않는다 — 관리자별 메뉴 권한 개념이
백엔드에 없다.
Co-Authored-By: Claude Opus 5
mock 저장소를 제거하고 /api/v1/mngr/item CRUD와 파일 업로드 API에 연결한다. 기존 구현은
"이 도메인에는 백엔드 API가 전혀 없다"는 전제로 만들어져 있었으나, 백엔드에 등록·수정·삭제까지
갖춘 API가 존재해 스키마에 맞춰 도메인 타입부터 다시 맞췄다.
- 아이템ID는 사용자 입력이 아니라 백엔드 자동 채번(itemSn)이다. 시안도 "등록 후 자동발급
(Read Only)"이라 등록 폼의 입력 필드를 없애고 목록·수정 팝업에는 itemSn을 그대로 노출한다.
- 유형은 개별=N, 셋트=S로 매핑한다. 목록 조회는 searchItemType이 필수라 값이 없으면 아무것도
조회되지 않는다.
- 카테고리는 공통코드가 정비되기 전까지 프론트 임시 코드로 개발한다(사용자 지시). 표기는
백엔드가 조인해 준 이름을 우선 쓰고 없으면 임시 목록에서 찾는다.
- 이미지는 파일 하나만 받아 atchFileId에만 저장한다(썸네일은 추후 백엔드 자동생성 예정).
브라우저가 백엔드에 직접 올리지 않도록 Server Action이 File을 받아 중계한다. 목록 썸네일은
인증이 필요 없는 이미지 GET 주소를 서버에서 만들어 내려준다.
- 총건수 보정은 학생 목록과 동일하다(백엔드 PaginationUtil에 count 쿼리가 없다).
lib/http/backend-fetch.ts에 PUT/DELETE와 form·multipart 본문을 추가했다. 아이템 쓰기 API는
@RequestBody가 아니라 @ParameterObject로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다.
디자인 시안이 나오면 다시 추가한다. Tailwind 제거 이후 퇴장 애니메이션(animate-fade-out)
클래스가 죽어 onAnimationEnd가 영영 발화하지 않았고, 그 결과 markSplashShown()도
setIsVisible(false)도 호출되지 않아 모든 화면 상단에 스플래시가 영구히 남아 있었다.
삭제: app/_components/splash-gate.tsx · components/ui/splash-screen.tsx ·
lib/constants/splash.ts. app/layout.tsx의 inline style/script와 도
함께 걷어냈다.
lib/constants/theme.ts의 주석이 삭제될 splash 상수 파일을 가리키고 있어 그 상호참조만
제거했다. public/images/logo-foxpay-white.svg는 재추가 시 다시 쓰므로 남겨둔다.
(public)/layout.tsx는 AuthCard를 더 이상 감싸지 않으므로 그 설명 주석을 제거한다.
Co-Authored-By: Claude Opus 5
admins/_actions.ts('use server')가 AdminMemberFormState 타입의 초깃값
INITIAL_ADMIN_MEMBER_FORM_STATE를 값으로 export하고 있어, 관리자 등록/수정/
삭제/ID 중복확인 Server Action을 호출할 때마다 "A 'use server' file can
only export async functions, found object" 런타임 에러로 500이 발생했다.
빌드·타입체크는 통과하고 실제로 폼을 제출해야만 드러나는 문제였다.
decoration-items가 이미 적용해 둔 동일 패턴을 따라 AdminMemberFormState
타입과 INITIAL_ADMIN_MEMBER_FORM_STATE 상수를 'use server'가 없는
lib/domain/admin-member-form.ts로 옮기고, _actions.ts는 타입만 다시
import하도록 정리했다. 두 소비처(admin-member-create-modal.tsx,
admin-member-edit-modal.tsx)의 import 경로도 함께 갱신했다.
Co-Authored-By: Claude Opus 5
Next.js 16의 'use server' 파일은 async 함수 외의 값을 export할 수 없어(런타임 전용 제약),
폼 상태 초깃값(INITIAL_DECORATION_ITEM_FORM_STATE)을 domain 계층으로 옮겼다.
Co-Authored-By: Claude Opus 5
사이드바 「관리자정보관리 > 관리자 회원」(/admins) 신설.
목록은 백엔드 GET /api/v1/mngr/admin/pagination으로 조회하고,
등록·수정·삭제는 백엔드 API가 없어 메모리 mock 오버레이로 처리한다
(lib/data/mock/admin-member-store.ts — API 추가 시 Repository 세 함수만 교체).
백엔드 계약 확인에 따른 결정:
- 응답에 휴대전화번호·이메일·생성일이 없어 해당 열은 `-`로 표기(열은 유지)
- totalCount가 현재 페이지 행 수라 신뢰할 수 없어, 전체를 받아 서버에서
검색·정렬·페이징한다(총건수 확정 → 번호 역순·현재페이지 표기 성립)
- searchCondition=3(휴대전화번호)이 TB_ADM_USER에 없는 USER_TELNO를
참조해 검색 대상에서 제외
- admRoleCd 코드 정의가 백엔드에 없어 잠정 매핑 + 미지의 코드는 원문 노출
엑셀다운로드는 버튼만 배치(추후 진행), 아이콘 부재로 관리 열은 텍스트 버튼.
Co-Authored-By: Claude Opus 5
로그아웃 Server Action이 GET /api/v1/common/auth/logout을 best-effort로 호출하도록
Repository(requestAdminLogout)를 추가하고 연동한다. 백엔드 호출이 실패해도 로컬 세션
파기·리다이렉트는 항상 진행된다.
이 엔드포인트의 성공 응답은 data가 항상 null(ApiResponseVO.success(null))인데
backendFetch의 기존 규약은 data===null을 실패로 간주해 정상 성공을 오판하므로,
backend-fetch.ts에 opt-in 플래그 canHaveNullData를 추가해 이 호출에만 적용한다(기존
호출부는 동작 변화 없음).
백엔드 호출 실패 시에는 /login?logoutError=1로 리다이렉트해 실패 신호를 남긴다(다음
커밋의 알림 UI가 소비).
Co-Authored-By: Claude Opus 5
세션에 실린 백엔드 accessToken의 형식·만료·유저 일치(adminId/loginId)를 검사하는
isAccessTokenFresh를 추가하고, getSessionAdmin·getSessionAccessToken이 공통 헬퍼
getValidSessionPayload를 통해 이 검사를 공유하도록 dal.ts를 리팩터링한다. 검사 실패 시
두 함수 모두 기존과 동일하게 null을 반환한다(fail-closed, 외부 계약 불변).
Co-Authored-By: Claude Opus 5
비활성이던 정렬 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)까지 키워 한 번에 받는다. 전체를 쥐게 되므로 이 경로에서는 총건수가 확정된다.
회원 수가 상한을 넘으면 그 위로는 정렬 대상에서 빠지고 총건수도 하한값으로 표기된다 —
그 규모에서는 백엔드에 정렬 파라미터가 필요하고, 상한 인상은 임시방편일 뿐이다.
비활성 상태였던 엑셀다운로드 버튼을 GET /api/v1/mngr/user/list/excel에 연결한다.
브라우저는 백엔드를 직접 부르지 않으므로(토큰이 httpOnly 세션 안에만 있다) 라우트 핸들러
/students/excel이 인증을 확인하고 백엔드 xlsx를 스트림으로 중계한다. 파일명·MIME은 백엔드가
RFC 5987 형식으로 이미 올바르게 내려주므로 그대로 전달한다.
백엔드 엑셀 API는 목록 API와 같은 selectPagination을 그대로 타서 페이징이 걸린다 —
파라미터를 생략하면 기본값 recordCountPerPage=10이 적용돼 10건짜리 파일이 나온다. "항상 전체"
사양을 만족시키려고 recordCountPerPage에 충분히 큰 상한(100,000)을 명시해 1페이지로 전부
받아온다. 검색 조건은 의도적으로 싣지 않는다(화면에서 무엇을 검색 중이든 파일은 전체).
- lib/http/backend-fetch.ts: JSON 봉투가 아닌 응답을 위한 backendFetchStream 추가. 성공 판정만
HTTP status 기준이고(성공 응답에 봉투가 없다) 실패 봉투의 code/message 보존은 동일하다.
파일 생성 시간을 감안해 호출부가 타임아웃을 늘려 잡을 수 있게 했다.
- 버튼은 네이티브 GET 폼 제출로 둔다 — 첨부파일 응답이라 화면을 유지한 채 파일만 받는다.
학생 회원 목록 조회를 로그인 구현이 세운 인증·통신 기반 위로 옮긴다.
- 백엔드 HTTP 클라이언트를 lib/http/backend-fetch.ts 하나로 통일한다. 목록 연동에서 임시로
두었던 lib/data/api-client.ts는 삭제하고, 거기에만 있던 세 가지를 backendFetch로 옮겼다:
쿼리스트링 조립, Authorization 헤더(accessToken), 그리고 비2xx 응답의 봉투 code 보존.
마지막 항목이 없으면 백엔드가 HTTP 401로 주는 인증 실패가 통신 오류(code -1)로 뭉개져
"세션이 끊겼다"와 "서버가 죽었다"를 호출부가 구분할 수 없다.
- 세션에 보관된 accessToken을 꺼내는 getSessionAccessToken()을 DAL에 추가한다. 화면 DTO
(AdminUser)는 토큰을 담지 않으므로 서버 전용 경로를 따로 둔다.
- 개발용 토큰 우회로(getDevApiAccessToken / EDUPAY_API_ACCESS_TOKEN)를 제거한다. 실제
로그인이 토큰을 공급하므로 존재 이유가 사라졌다.
- lib/env.ts 충돌은 hub 쪽 getApiBaseUrl()을 채택해 해소했다(같은 환경변수 키, 같은 동작).
mock 학생 회원 생성기를 제거하고 GET /api/v1/mngr/user/pagination에 연결한다.
백엔드 저장소(develop b742bb4)의 실제 구현을 읽고 계약을 확정했다.
- searchCondition은 "1"(이름)·"2"(아이디)·"3"(휴대전화)만 유효하다. 목록에 없는 값을
보내면 조건 없이 전체가 반환되므로 지원되지 않는 학교명·회원코드 검색은 제거했다.
- 유효한 페이징 파라미터는 pageIndex·recordCountPerPage 둘뿐이다(서버가 offset을 직접
계산해 나머지를 덮어쓴다).
- 응답의 totalCount는 전체 건수가 아니라 현재 페이지 행 수다(백엔드에 count 쿼리 없음).
그대로 믿으면 가득 찬 페이지 뒤 데이터에 접근할 수 없어 하한값으로 보정하고, 확정되지
않은 건수는 화면에 "N명 이상"으로 표기한다.
- 응답이 주지 않는 항목(휴대전화·이메일·학교·학년/반·보호자·가입일·사용여부)은 열을
유지한 채 '-'로 표시한다. 정렬 파라미터와 사용여부 변경 API가 없어 해당 컨트롤은
비활성으로 둔다.