← 개발 로그 목록

website: 어드민 로그인·초대·비밀번호재설정·대시보드 뼈대 구현

/ 8분 분량 / 개발 로그

멋사 경희대 사이트 프로젝트에서 어드민 페이지의 인증 관련 프론트엔드 화면들을 한 번에 구현한 PR입니다. 로그인부터 초대 수락, 비밀번호 재설정, 운영진 관리 대시보드까지 어드민이 필요로 하는 기본 흐름을 전부 채워 넣었습니다.

요약

이 PR은 /admin 하위의 다섯 개 페이지—로그인, 대시보드, 초대 수락, 비밀번호 찾기, 비밀번호 재설정—를 구현하고, 이를 지원하는 API 클라이언트와 유효성 검사 로직을 함께 추가했습니다. 2026년 7월 11일에 생성되어 이틀 뒤인 7월 13일에 dev 브랜치로 병합되었고, 총 1110줄이 추가된 순수 신규 구현 PR입니다. 이슈 #74를 닫는 커밋으로, 커밋 히스토리가 한 개로 깔끔하게 정리되어 있는 걸 보면 로컬에서 충분히 다듬은 뒤 올린 것으로 보입니다.

배경 및 목적

이슈 #74에서 요구된 어드민 프론트엔드 뼈대를 만드는 작업이었습니다. 백엔드에 이미 /api/admin/* 엔드포인트들(로그인, 초대, 비밀번호 재설정, 운영진 CRUD)이 준비되어 있다는 전제 하에, 이를 소비하는 클라이언트 화면과 상태 관리 로직을 구성하는 게 목표였습니다. 운영진이 이메일/비밀번호로 로그인하고, 최고관리자가 새 운영진을 초대하고, 잊어버린 비밀번호를 재설정할 수 있는 일련의 흐름을 프론트엔드에서 완결시켜야 했습니다.

구현 내용

PR 설명에 나온 대로 다섯 개 페이지가 추가됐습니다.

  • /admin/login — 이메일/비밀번호 로그인, 에러 코드별 메시지 분기
  • /admin — 운영진 목록·초대·역할변경·로그아웃 기능이 있는 대시보드
  • /admin/invite/[token] — 초대 수락 (토큰 유효/만료/무효 상태 분기)
  • /admin/forgot-password — 비밀번호 재설정 요청
  • /admin/reset-password/[token] — 비밀번호 재설정 (토큰 상태 분기)

핵심은 frontend/src/lib/adminApi.ts에 있는 공통 요청 래퍼입니다. access_token/refresh_token이 HttpOnly 쿠키로 관리되기 때문에 JS에서 직접 다룰 수 없는데, 같은 오리진 요청이면 브라우저가 알아서 쿠키를 실어 보낸다는 점을 이용했습니다. 401을 받으면 refresh를 한 번 시도하고 재요청하는 로직을 공통 request 함수에 넣어뒀습니다.

async function request<T>(
  path: string,
  init: RequestInit,
  fallbackMessage: string,
  retryOn401: boolean
): Promise<T> {
  const res = await fetch(`/api/admin${path}`, { ... });

  if (res.ok) {
    if (res.status === 204) return undefined as T;
    return res.json();
  }

  if (res.status === 401 && retryOn401) {
    const refreshed = await refreshAccessToken();
    if (refreshed) {
      return request<T>(path, init, fallbackMessage, false);
    }
  }

  return throwApiError(res, fallbackMessage);
}

주목할 점은 retryOn401 플래그를 함수마다 명시적으로 다르게 넘긴다는 것입니다. 로그인이나 초대 수락, 비밀번호 재설정처럼 아직 세션이 없는 흐름에서는 재시도 자체가 의미 없으니 false, 대시보드에서 쓰는 listAdmins, updateAdminRole 같은 세션 기반 API는 true로 넘겨서 액세스 토큰 만료를 자연스럽게 처리합니다.

또 하나 재밌는 부분은 대시보드에서 현재 로그인한 관리자 정보를 가져오는 방식입니다. 별도의 "me" 엔드포인트가 없어서, refreshSession() 호출 결과에 포함된 admin 필드를 재활용해 신원을 확인합니다. 코드에도 "별도 me 엔드포인트가 없어 refresh 응답으로 현재 세션 신원을 확인한다"는 주석을 남겨뒀는데, API 설계상의 제약을 프론트에서 우회한 흔적입니다.

InviteAcceptForm과 ResetPasswordForm은 구조가 거의 동일합니다. 마운트 시 토큰을 검증(verifyInvitation/verifyPasswordReset)해서 checking → valid/expired/invalid/error 상태로 분기하고, 유효한 경우에만 폼을 보여준 뒤 제출 성공 시 submitted 상태로 전환해 완료 화면과 로그인 페이지 링크를 노출합니다. 만료된 토큰인 경우 재설정 페이지는 "다시 요청하기" 버튼으로 /admin/forgot-password로 유도하는 것도 눈에 띕니다.

동시 다발적인 401 응답으로 refresh가 중복 호출되는 걸 막기 위해 refreshInFlight 프로미스를 모듈 스코프에 두고 공유하는 패턴도 사용했습니다.

let refreshInFlight: Promise<boolean> | null = null;

function refreshAccessToken(): Promise<boolean> {
  if (!refreshInFlight) {
    refreshInFlight = fetch('/api/admin/auth/refresh', { method: 'POST' })
      .then((res) => res.ok)
      .catch(() => false)
      .finally(() => {
        refreshInFlight = null;
      });
  }
  return refreshInFlight;
}

대시보드(AdminDashboard.tsx)는 이 PR에서 가장 큰 컴포넌트로, 운영진 목록 조회·초대 폼 토글·역할 변경(select)·삭제까지 한 화면에 담았습니다. SUPER_ADMIN과 일반 ADMIN을 구분해서 최고관리자만 초대/역할변경/삭제 버튼을 볼 수 있게 처리했고, 본인 계정은 역할변경·삭제 대상에서 제외하는 방어 로직도 들어가 있습니다.

기술적 의사결정

  • 에러 처리를 커스텀 AdminApiError 클래스로 통일: status와 code(백엔드가 내려주는 에러 코드)를 함께 들고 있어서, 컴포넌트 단에서 err.code === 'ACCOUNT_LOCKED' 같은 식으로 세분화된 분기를 할 수 있게 했습니다. LoginForm의 loginErrorMessage 함수가 이걸 활용해 잘못된 자격증명과 계정 잠김을 다른 메시지로 보여줍니다.
  • 401 재시도를 함수별 옵션으로 제어: 모든 요청에 무조건 재시도 로직을 넣는 대신, 세션이 필요 없는 흐름은 명시적으로 재시도를 끄는 방식을 택해서 불필요한 refresh 호출을 방지했습니다.
  • 별도 상태 관리 라이브러리 없이 useState/useEffect로 처리: 대시보드 정도 규모에서는 로컬 상태만으로 충분하다고 판단한 듯 보이며, reloadIndex를 증가시켜 useEffect를 재실행하는 방식으로 간단한 리페치를 구현했습니다.

배운 점 및 개선점

HttpOnly 쿠키 기반 인증에서 "me" 엔드포인트 없이 refresh 응답을 재활용하는 방식은 임시방편에 가까워서, 나중에 별도 세션 조회 엔드포인트가 생기면 AdminDashboard의 초기 로딩 로직을 정리할 여지가 있어 보입니다. 또한 커밋 메시지가 단일 커밋으로 정리된 걸 보면 이번 PR 자체는 기능 뼈대를 잡는 데 집중했고, 세부 UX(로딩 스피너, 토스트 알림 등)나 실제 다양한 에러 케이스에 대한 QA는 다음 단계에서 보완될 부분으로 남아 있습니다. PR 설명에 tsc, eslint, next build, Playwright 테스트를 통과했다고 명시된 만큼 기본적인 동작 검증은 마쳤지만, 실제 운영 환경에서의 토큰 만료 타이밍이나 동시 요청 시나리오는 이후 실사용을 통해 더 다듬어질 것으로 보입니다.