임동욱 임동욱 08-10
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
+++ app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
@@ -5,6 +5,7 @@
 import { Button } from '@/components/ui/button';
 import { Select } from '@/components/ui/select';
 import {
+  STUDENT_MEMBERS_EXCEL_PATH,
   STUDENT_MEMBER_PAGE_SIZE_OPTIONS,
   STUDENT_MEMBER_SORT_OPTIONS,
   buildStudentMemberHref,
@@ -44,9 +45,15 @@
 
   return (
     <div className="flex flex-wrap items-center justify-between gap-3">
-      <Button type="button" variant="primary" disabled title="준비 중입니다">
-        엑셀다운로드
-      </Button>
+      {/* 네이티브 GET 폼 제출 — 응답이 첨부파일(Content-Disposition: attachment)이라 브라우저가
+          현재 화면을 그대로 둔 채 파일만 내려받는다. 라우터 내비게이션(router.push)으로는
+          첨부파일 응답을 처리할 수 없고, Button은 <button>이라 링크로 쓸 수 없어 폼으로 감쌌다.
+          검색·페이징 값을 hidden으로 싣지 않는 것이 사양이다 — 파일은 항상 전체 데이터다. */}
+      <form action={STUDENT_MEMBERS_EXCEL_PATH} method="get">
+        <Button type="submit" variant="primary">
+          엑셀다운로드
+        </Button>
+      </form>
 
       <div className="flex items-center gap-3">
         <Select
 
app/(protected)/(basic)/students/excel/route.ts (added)
+++ app/(protected)/(basic)/students/excel/route.ts
@@ -0,0 +1,78 @@
+import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
+import { backendFetchStream } from '@/lib/http/backend-fetch';
+
+/**
+ * 학생 회원 목록 엑셀 다운로드 — 백엔드가 만든 xlsx를 브라우저로 중계한다.
+ *
+ * 왜 라우트 핸들러인가: 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). 백엔드
+ * 엑셀 API는 ROLE_ADMIN 토큰을 요구하는데 그 토큰은 httpOnly 세션 안에만 있어 브라우저가 꺼낼 수
+ * 없다. Server Component는 파일 응답을 만들 수 없고 Server Action은 반환값을 브라우저 메모리에
+ * 펼쳐야 하므로, 파일 다운로드는 라우트 핸들러가 유일하게 맞는 자리다.
+ *
+ * 본문은 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다(서버 메모리에 파일 전체를 올리지
+ * 않는다). 파일명·MIME도 백엔드 헤더를 그대로 전달한다 — 백엔드가 이미 RFC 5987 형식
+ * (`filename*=UTF-8''회원목록_YYYYMMDD.xlsx`)으로 올바르게 내려준다.
+ *
+ * 응답 캐시 금지는 next.config.ts의 전역 보안 헤더(`Cache-Control: no-store, ...`)가 담당한다.
+ */
+
+const STUDENT_MEMBER_EXCEL_PATH = '/api/v1/mngr/user/list/excel';
+
+/**
+ * 이 기능은 화면의 검색·페이징과 무관하게 **항상 전체 학생 데이터**를 내려받는다(요구사항).
+ *
+ * 그런데 백엔드 엑셀 API는 목록 API와 같은 `selectPagination`을 그대로 쓴다 — 즉 페이징이
+ * 걸리고, 파라미터를 생략하면 기본값(`recordCountPerPage=10`)이 적용돼 **10건만** 담긴 파일이
+ * 나온다. "전체" 모드가 따로 없으므로 충분히 큰 상한을 명시해 1페이지로 전부 받아온다.
+ * 상한을 무한대로 둘 수는 없다(백엔드가 전체 행을 메모리에 올려 워크북을 만든다).
+ *
+ * 회원 수가 이 상한을 넘으면 파일이 조용히 잘린다 — 그 시점에는 백엔드에 "전체 조회" 모드가
+ * 필요하다. 상한을 올리는 것은 임시방편일 뿐이다.
+ */
+const EXCEL_ROW_LIMIT = 100_000;
+
+/** 전체 행을 모아 워크북을 만드는 시간이 있어 일반 조회보다 넉넉히 잡는다. */
+const EXCEL_TIMEOUT_MS = 60_000;
+
+const DOWNLOAD_FAILED_MESSAGE =
+  '엑셀 파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+const XLSX_CONTENT_TYPE =
+  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+export async function GET() {
+  // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다
+  // (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8).
+  await verifySession();
+
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetchStream(STUDENT_MEMBER_EXCEL_PATH, {
+    // 검색 조건(searchCondition/searchKeyword)을 의도적으로 보내지 않는다 — 화면에서 무엇을
+    // 검색 중이든 파일에는 전체가 담겨야 한다.
+    query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT },
+    accessToken: accessToken ?? undefined,
+    timeoutMs: EXCEL_TIMEOUT_MS,
+  });
+
+  if (!result.ok) {
+    // 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다. 화면에는
+    // 일반화된 문구만 내보낸다.
+    return new Response(DOWNLOAD_FAILED_MESSAGE, {
+      status: 502,
+      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+    });
+  }
+
+  const upstream = result.data;
+
+  return new Response(upstream.body, {
+    status: 200,
+    headers: {
+      'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE,
+      'Content-Disposition':
+        upstream.headers.get('content-disposition') ??
+        'attachment; filename="students.xlsx"',
+    },
+  });
+}
lib/domain/student-member-query.ts
--- lib/domain/student-member-query.ts
+++ lib/domain/student-member-query.ts
@@ -11,6 +11,12 @@
 export const STUDENT_MEMBERS_PATH = '/students';
 
 /**
+ * 엑셀 다운로드 라우트(파일 응답 전용 핸들러). 목록의 검색·페이징 조건을 싣지 않는 것이
+ * 사양이라(항상 전체 데이터) `buildStudentMemberHref`의 직렬화 대상이 아니다.
+ */
+export const STUDENT_MEMBERS_EXCEL_PATH = `${STUDENT_MEMBERS_PATH}/excel`;
+
+/**
  * 검색 대상 — 백엔드가 실제로 필터링해 주는 3종만 둔다.
  *
  * 백엔드 목록 쿼리(MngrUserMapper.xml)는 `searchCondition`이 "1"|"2"|"3"일 때만 조건을 붙이고,
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -104,6 +104,63 @@
   }
 }
 
+type BackendStreamRequestInit = {
+  query?: Record<string, string | number>;
+  accessToken?: string;
+  /** 기본 타임아웃보다 오래 걸리는 호출(대용량 파일 생성 등)이 값을 올려 잡는다. */
+  timeoutMs?: number;
+};
+
+/**
+ * 파일처럼 **JSON 봉투가 아닌 응답**을 받는 GET 호출. 본문을 파싱하지 않고 `Response`를 그대로
+ * 돌려주므로, 호출부(라우트 핸들러)가 스트림을 브라우저로 그대로 흘려보낼 수 있다 — 파일 전체를
+ * 서버 메모리에 펼치지 않기 위함이다.
+ *
+ * 성공 판정만 `backendFetch`와 다르다: 성공 응답에 봉투가 없어 `success` 필드를 볼 수 없으므로
+ * HTTP status로 판정한다. 실패는 여전히 JSON 봉투로 오므로(401 등) code/message를 살려서 내려준다.
+ */
+export async function backendFetchStream(
+  path: string,
+  init: BackendStreamRequestInit
+): Promise<BackendResult<Response>> {
+  let response: Response;
+  try {
+    response = await fetch(resolveUrl(path, init.query), {
+      method: 'GET',
+      headers: {
+        ...(init.accessToken
+          ? { Authorization: `Bearer ${init.accessToken}` }
+          : {}),
+      },
+      signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS),
+      cache: 'no-store',
+    });
+  } catch (error) {
+    return communicationError('파일 요청 실패(네트워크·타임아웃)', error);
+  }
+
+  if (!response.ok) {
+    const envelope = await readEnvelopeSafely(response);
+    if (envelope && typeof envelope.code === 'number') {
+      return {
+        ok: false,
+        code: envelope.code,
+        message:
+          typeof envelope.message === 'string' && envelope.message
+            ? envelope.message
+            : COMMUNICATION_ERROR_MESSAGE,
+      };
+    }
+
+    return communicationError(
+      `파일 응답의 예상치 못한 HTTP 상태: ${response.status}`,
+      undefined
+    );
+  }
+
+  return { ok: true, data: response };
+}
+
 /** 백엔드 REST 호출 단일 진입점. */
 export async function backendFetch<T>(
   path: string,
Add a comment
List