feat: 학생 회원 엑셀 다운로드 활성화
비활성 상태였던 엑셀다운로드 버튼을 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 폼 제출로 둔다 — 첨부파일 응답이라 화면을 유지한 채 파일만 받는다.
@93ff7480952268af1da672009ce7b7415715d700
--- app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
+++ app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
... | ... | @@ -5,6 +5,7 @@ |
| 5 | 5 |
import { Button } from '@/components/ui/button';
|
| 6 | 6 |
import { Select } from '@/components/ui/select';
|
| 7 | 7 |
import {
|
| 8 |
+ STUDENT_MEMBERS_EXCEL_PATH, |
|
| 8 | 9 |
STUDENT_MEMBER_PAGE_SIZE_OPTIONS, |
| 9 | 10 |
STUDENT_MEMBER_SORT_OPTIONS, |
| 10 | 11 |
buildStudentMemberHref, |
... | ... | @@ -44,9 +45,15 @@ |
| 44 | 45 |
|
| 45 | 46 |
return ( |
| 46 | 47 |
<div className="flex flex-wrap items-center justify-between gap-3"> |
| 47 |
- <Button type="button" variant="primary" disabled title="준비 중입니다"> |
|
| 48 |
- 엑셀다운로드 |
|
| 49 |
- </Button> |
|
| 48 |
+ {/* 네이티브 GET 폼 제출 — 응답이 첨부파일(Content-Disposition: attachment)이라 브라우저가
|
|
| 49 |
+ 현재 화면을 그대로 둔 채 파일만 내려받는다. 라우터 내비게이션(router.push)으로는 |
|
| 50 |
+ 첨부파일 응답을 처리할 수 없고, Button은 <button>이라 링크로 쓸 수 없어 폼으로 감쌌다. |
|
| 51 |
+ 검색·페이징 값을 hidden으로 싣지 않는 것이 사양이다 — 파일은 항상 전체 데이터다. */} |
|
| 52 |
+ <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get">
|
|
| 53 |
+ <Button type="submit" variant="primary"> |
|
| 54 |
+ 엑셀다운로드 |
|
| 55 |
+ </Button> |
|
| 56 |
+ </form> |
|
| 50 | 57 |
|
| 51 | 58 |
<div className="flex items-center gap-3"> |
| 52 | 59 |
<Select |
+++ app/(protected)/(basic)/students/excel/route.ts
... | ... | @@ -0,0 +1,78 @@ |
| 1 | +import { getSessionAccessToken, verifySession } from '@/lib/auth/dal'; | |
| 2 | +import { backendFetchStream } from '@/lib/http/backend-fetch'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 학생 회원 목록 엑셀 다운로드 — 백엔드가 만든 xlsx를 브라우저로 중계한다. | |
| 6 | + * | |
| 7 | + * 왜 라우트 핸들러인가: 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). 백엔드 | |
| 8 | + * 엑셀 API는 ROLE_ADMIN 토큰을 요구하는데 그 토큰은 httpOnly 세션 안에만 있어 브라우저가 꺼낼 수 | |
| 9 | + * 없다. Server Component는 파일 응답을 만들 수 없고 Server Action은 반환값을 브라우저 메모리에 | |
| 10 | + * 펼쳐야 하므로, 파일 다운로드는 라우트 핸들러가 유일하게 맞는 자리다. | |
| 11 | + * | |
| 12 | + * 본문은 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다(서버 메모리에 파일 전체를 올리지 | |
| 13 | + * 않는다). 파일명·MIME도 백엔드 헤더를 그대로 전달한다 — 백엔드가 이미 RFC 5987 형식 | |
| 14 | + * (`filename*=UTF-8''회원목록_YYYYMMDD.xlsx`)으로 올바르게 내려준다. | |
| 15 | + * | |
| 16 | + * 응답 캐시 금지는 next.config.ts의 전역 보안 헤더(`Cache-Control: no-store, ...`)가 담당한다. | |
| 17 | + */ | |
| 18 | + | |
| 19 | +const STUDENT_MEMBER_EXCEL_PATH = '/api/v1/mngr/user/list/excel'; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * 이 기능은 화면의 검색·페이징과 무관하게 **항상 전체 학생 데이터**를 내려받는다(요구사항). | |
| 23 | + * | |
| 24 | + * 그런데 백엔드 엑셀 API는 목록 API와 같은 `selectPagination`을 그대로 쓴다 — 즉 페이징이 | |
| 25 | + * 걸리고, 파라미터를 생략하면 기본값(`recordCountPerPage=10`)이 적용돼 **10건만** 담긴 파일이 | |
| 26 | + * 나온다. "전체" 모드가 따로 없으므로 충분히 큰 상한을 명시해 1페이지로 전부 받아온다. | |
| 27 | + * 상한을 무한대로 둘 수는 없다(백엔드가 전체 행을 메모리에 올려 워크북을 만든다). | |
| 28 | + * | |
| 29 | + * 회원 수가 이 상한을 넘으면 파일이 조용히 잘린다 — 그 시점에는 백엔드에 "전체 조회" 모드가 | |
| 30 | + * 필요하다. 상한을 올리는 것은 임시방편일 뿐이다. | |
| 31 | + */ | |
| 32 | +const EXCEL_ROW_LIMIT = 100_000; | |
| 33 | + | |
| 34 | +/** 전체 행을 모아 워크북을 만드는 시간이 있어 일반 조회보다 넉넉히 잡는다. */ | |
| 35 | +const EXCEL_TIMEOUT_MS = 60_000; | |
| 36 | + | |
| 37 | +const DOWNLOAD_FAILED_MESSAGE = | |
| 38 | + '엑셀 파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 39 | + | |
| 40 | +const XLSX_CONTENT_TYPE = | |
| 41 | + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; | |
| 42 | + | |
| 43 | +export async function GET() { | |
| 44 | + // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다 | |
| 45 | + // (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8). | |
| 46 | + await verifySession(); | |
| 47 | + | |
| 48 | + const accessToken = await getSessionAccessToken(); | |
| 49 | + | |
| 50 | + const result = await backendFetchStream(STUDENT_MEMBER_EXCEL_PATH, { | |
| 51 | + // 검색 조건(searchCondition/searchKeyword)을 의도적으로 보내지 않는다 — 화면에서 무엇을 | |
| 52 | + // 검색 중이든 파일에는 전체가 담겨야 한다. | |
| 53 | + query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT }, | |
| 54 | + accessToken: accessToken ?? undefined, | |
| 55 | + timeoutMs: EXCEL_TIMEOUT_MS, | |
| 56 | + }); | |
| 57 | + | |
| 58 | + if (!result.ok) { | |
| 59 | + // 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다. 화면에는 | |
| 60 | + // 일반화된 문구만 내보낸다. | |
| 61 | + return new Response(DOWNLOAD_FAILED_MESSAGE, { | |
| 62 | + status: 502, | |
| 63 | + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, | |
| 64 | + }); | |
| 65 | + } | |
| 66 | + | |
| 67 | + const upstream = result.data; | |
| 68 | + | |
| 69 | + return new Response(upstream.body, { | |
| 70 | + status: 200, | |
| 71 | + headers: { | |
| 72 | + 'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE, | |
| 73 | + 'Content-Disposition': | |
| 74 | + upstream.headers.get('content-disposition') ?? | |
| 75 | + 'attachment; filename="students.xlsx"', | |
| 76 | + }, | |
| 77 | + }); | |
| 78 | +} |
--- lib/domain/student-member-query.ts
+++ lib/domain/student-member-query.ts
... | ... | @@ -11,6 +11,12 @@ |
| 11 | 11 |
export const STUDENT_MEMBERS_PATH = '/students'; |
| 12 | 12 |
|
| 13 | 13 |
/** |
| 14 |
+ * 엑셀 다운로드 라우트(파일 응답 전용 핸들러). 목록의 검색·페이징 조건을 싣지 않는 것이 |
|
| 15 |
+ * 사양이라(항상 전체 데이터) `buildStudentMemberHref`의 직렬화 대상이 아니다. |
|
| 16 |
+ */ |
|
| 17 |
+export const STUDENT_MEMBERS_EXCEL_PATH = `${STUDENT_MEMBERS_PATH}/excel`;
|
|
| 18 |
+ |
|
| 19 |
+/** |
|
| 14 | 20 |
* 검색 대상 — 백엔드가 실제로 필터링해 주는 3종만 둔다. |
| 15 | 21 |
* |
| 16 | 22 |
* 백엔드 목록 쿼리(MngrUserMapper.xml)는 `searchCondition`이 "1"|"2"|"3"일 때만 조건을 붙이고, |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -104,6 +104,63 @@ |
| 104 | 104 |
} |
| 105 | 105 |
} |
| 106 | 106 |
|
| 107 |
+type BackendStreamRequestInit = {
|
|
| 108 |
+ query?: Record<string, string | number>; |
|
| 109 |
+ accessToken?: string; |
|
| 110 |
+ /** 기본 타임아웃보다 오래 걸리는 호출(대용량 파일 생성 등)이 값을 올려 잡는다. */ |
|
| 111 |
+ timeoutMs?: number; |
|
| 112 |
+}; |
|
| 113 |
+ |
|
| 114 |
+/** |
|
| 115 |
+ * 파일처럼 **JSON 봉투가 아닌 응답**을 받는 GET 호출. 본문을 파싱하지 않고 `Response`를 그대로 |
|
| 116 |
+ * 돌려주므로, 호출부(라우트 핸들러)가 스트림을 브라우저로 그대로 흘려보낼 수 있다 — 파일 전체를 |
|
| 117 |
+ * 서버 메모리에 펼치지 않기 위함이다. |
|
| 118 |
+ * |
|
| 119 |
+ * 성공 판정만 `backendFetch`와 다르다: 성공 응답에 봉투가 없어 `success` 필드를 볼 수 없으므로 |
|
| 120 |
+ * HTTP status로 판정한다. 실패는 여전히 JSON 봉투로 오므로(401 등) code/message를 살려서 내려준다. |
|
| 121 |
+ */ |
|
| 122 |
+export async function backendFetchStream( |
|
| 123 |
+ path: string, |
|
| 124 |
+ init: BackendStreamRequestInit |
|
| 125 |
+): Promise<BackendResult<Response>> {
|
|
| 126 |
+ let response: Response; |
|
| 127 |
+ try {
|
|
| 128 |
+ response = await fetch(resolveUrl(path, init.query), {
|
|
| 129 |
+ method: 'GET', |
|
| 130 |
+ headers: {
|
|
| 131 |
+ ...(init.accessToken |
|
| 132 |
+ ? { Authorization: `Bearer ${init.accessToken}` }
|
|
| 133 |
+ : {}),
|
|
| 134 |
+ }, |
|
| 135 |
+ signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS), |
|
| 136 |
+ cache: 'no-store', |
|
| 137 |
+ }); |
|
| 138 |
+ } catch (error) {
|
|
| 139 |
+ return communicationError('파일 요청 실패(네트워크·타임아웃)', error);
|
|
| 140 |
+ } |
|
| 141 |
+ |
|
| 142 |
+ if (!response.ok) {
|
|
| 143 |
+ const envelope = await readEnvelopeSafely(response); |
|
| 144 |
+ if (envelope && typeof envelope.code === 'number') {
|
|
| 145 |
+ return {
|
|
| 146 |
+ ok: false, |
|
| 147 |
+ code: envelope.code, |
|
| 148 |
+ message: |
|
| 149 |
+ typeof envelope.message === 'string' && envelope.message |
|
| 150 |
+ ? envelope.message |
|
| 151 |
+ : COMMUNICATION_ERROR_MESSAGE, |
|
| 152 |
+ }; |
|
| 153 |
+ } |
|
| 154 |
+ |
|
| 155 |
+ return communicationError( |
|
| 156 |
+ `파일 응답의 예상치 못한 HTTP 상태: ${response.status}`,
|
|
| 157 |
+ undefined |
|
| 158 |
+ ); |
|
| 159 |
+ } |
|
| 160 |
+ |
|
| 161 |
+ return { ok: true, data: response };
|
|
| 162 |
+} |
|
| 163 |
+ |
|
| 107 | 164 |
/** 백엔드 REST 호출 단일 진입점. */ |
| 108 | 165 |
export async function backendFetch<T>( |
| 109 | 166 |
path: string, |
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?