- 기간
- 2026.07 ~ 08 (약 4주)
- 역할
- 프론트엔드 설계 · 구현 · 배포 (백엔드 1명과 협업)
- 작업 단위
- PR 45개 · 커밋 30개
- 앱 코드의 목 분기
- 0개
무엇을 만들었나
대한장애인체육회 같은 공공기관을 위한 선수관리 어드민입니다. 기관이 소속 지도자의 콘텐츠 활동을 조회하고 보고 문서로 출력하며, 기관마다 데이터가 갈리는 멀티테넌트 구조입니다. 백엔드 개발자 1명과 함께 개발했고, 백엔드 API 계약이 확정되기 전에 프론트엔드를 먼저 시작해 설계·구현·배포를 맡았습니다.
Next.js 16 App Router, TypeScript, TanStack Query, zod, MSW를 쓰고, Docker와 Cloud Build로 Cloud Run에 배포합니다.
규칙부터 적어 두기
코드보다 먼저 규칙을 문서로 남겼습니다. 전역 스토어는 하나만 두고, 도메인끼리는 서로 직접 import하지 않으며, 여러 도메인이 같이 쓰게 되면 전역으로 "승격"한다는 규칙입니다. 기존 프로젝트에서 전역 상태가 여기저기 흩어졌던 경험을 새 프로젝트의 규칙으로 옮긴 것이고, 규칙마다 이유도 함께 적었습니다.
실제로 규칙에 걸리는 상황이 두 번 있었고, 두 번 다 규칙대로 풀었습니다. 기관 선택과 로그아웃을 함께 다루는 접근 게이트는 특정 도메인이 아니라 앱 레이아웃 층으로 올렸고, 선택한 기관을 담는 스토어는 요청 함수가 전역에서 읽어야 해서 전역 스토어로 올렸습니다.
백엔드보다 먼저 출발하기
API가 없으니 zod 스키마를 API 계약 초안으로 먼저 썼습니다. 이 스키마는 백엔드 팀과 협의하는 문서이자, 실제 응답을 검사하는 런타임 검증기입니다. 요청 함수는 스키마를 필수 인자로 받게 만들어 검증을 빠뜨리는 것 자체가 타입 에러가 되게 했고, 타입은 스키마에서 파생해 두 곳을 따로 관리하지 않게 했습니다.
목은 MSW가 네트워크 층에서 가로채게 했습니다. 앱 코드에 if (USE_MOCK) 같은 분기가 들어가면 나중에 전부 걷어내야 하고, 걷어내는 순간 실서버 경로가 처음 실행됩니다. 네트워크 층에서 가로채면 앱은 늘 진짜 요청을 보낸다고 믿으므로, 환경변수 하나로 실서버 전환이 끝납니다.
- 목 응답도
satisfies로 계약 타입에 묶어, 계약이 바뀌면 목이 컴파일 단계에서 깨집니다. - 목 데이터는 해시로 결정론적으로 만들어 새로고침해도 같은 데이터가 나오게 했고, 이미지 없는 콘텐츠·빈 제목·콘텐츠 없는 날 같은 경계 사례를 일정 비율로 섞었습니다.
- 커서 페이지네이션까지 목에 구현해 무한스크롤을 실제처럼 검증했습니다.
- 브라우저와 테스트가 같은 핸들러를 씁니다.
기관 간 데이터 격리
멀티테넌트 어드민에서 가장 중요했던 건 기관끼리 데이터가 섞이지 않는 것이었습니다.
내부 개발자는 기관을 바꿔 가며 볼 수 있는데, 기관을 바꾸면 요청 함수가 기관 코드 헤더만 바꿔 같은 URL을 부릅니다. 쿼리 키에 기관 코드가 없으면 기관 A의 응답이 기관 B의 키로 캐시되어 데이터가 새어 나옵니다. 같은 URL에 헤더만 다르면 다른 데이터인 구조에서는 캐시 키가 곧 격리의 경계입니다. 그래서 모든 쿼리 키에 기관 코드를 넣었습니다.
export const organizationKeys = {
me: (orgCode) => ['organizations', 'me', orgCode ?? null] as const,
coaches: (orgCode) => ['organizations', 'coaches', orgCode ?? null] as const,
};
- API 설계 제안: 처음 계약은
/organizations/:id였습니다. 이러면 클라이언트가 임의의 기관 id를 지목해 소속되지 않은 기관까지 조회를 시도할 수 있습니다. 파라미터 없이 로그인한 신원으로 조회 범위를 서버가 고정하는/organizations/me로 바꾸자고 제안해 계약을 바꿨습니다. - 로그아웃하면 캐시 전체를 비웁니다. 세션 쿼리만 무효화하면 창을 닫지 않고 계정을 바꿨을 때 이전 사용자의 데이터가 잠깐 보일 수 있기 때문입니다.
- 선택한 기관은 sessionStorage에 둡니다. 탭마다 다른 기관을 동시에 열 수 있고, 창을 닫으면 사라져 오래된 선택이 남지 않습니다.
인증: 토큰을 복제하지 않기
로그인 폼을 따로 만들지 않고 CameraFi 통합 로그인에 맡겼습니다. 통합 로그인에서 돌아온 토큰만으로는 Firebase 클라이언트 로그인이 되지 않아, Next.js Route Handler에서 커스텀 토큰으로 교환하는 단계를 두고 세션 쿠키를 발급했습니다.
- 토큰을 전역 스토어에 저장하지 않았습니다. 기준은 Firebase SDK 하나이고, 요청 함수가 요청 시점에 SDK에서 토큰을 받아 붙입니다. SDK가 캐시와 갱신을 맡으므로 매 요청마다 불러도 네트워크 비용이 없습니다.
- 401이면 토큰을 강제로 갱신해 한 번만 재시도합니다. 401은 서버가 요청을 처리하기 전에 거부한 것이라 POST여도 부수효과가 없어 재시도가 안전합니다. 두 번째도 401이면 멈춰 무한 반복을 막습니다.
- 로그인돼 있는데 401이 나던 버그: 새로고침 직후 SDK가 인증 상태를 복원하기 전에 요청이 나가면 토큰 없이 요청이 갔습니다. 복원이 끝날 때까지 기다린 뒤 토큰을 읽도록 고쳤습니다.
조용한 실패를 테스트로 막기
에러 없이 틀리게 동작하는 것들이 가장 위험했습니다.
- PDF 저장: 화면의 목록은 무한스크롤이라 사용자가 스크롤한 만큼만 존재합니다. 그래서 화면을 캡처하지 않고, 저장 범위의 데이터를 전부 새로 받아 PDF를 그립니다. 폭주를 막는 상한(5,000건)을 두되, 넘으면 조용히 자르지 않고 에러를 냅니다. PDF 엔진과 폰트는 수백 KB라 저장 버튼을 누를 때 불러오게 했습니다.
- PDF에서만 사라지는 이미지: PDF 라이브러리는 이미지를
fetch()로 읽어 CORS가 적용되는데, 이미지 저장소에 CORS 설정이 없어 이미지가 빠졌습니다. 라이브러리가 경고만 남기고 넘어가던 실패였고, Next.js 이미지 경로를 거쳐 받도록 바꿨습니다. - 한글 폰트: PDF 기본 폰트에는 한글 글리프가 없어, 폰트 등록이 빠지면 에러 없이 한글만 비어 나옵니다. PDF에 한글 폰트가 실제로 들어갔는지를 테스트로 고정했습니다.
- 시간대: 서버는 보통 UTC로 돌아서, 월초 0~9시에는 서버와 브라우저가 서로 다른 달을 계산해 SSR 결과와 하이드레이션 결과가 어긋납니다. 캘린더의 날짜 계산을 KST로 고정했습니다.
테스트가 모르는 사이 실제 네트워크를 부르지 않도록, 처리되지 않은 요청은 에러로 떨어지게 설정했습니다.
크기가 아니라 TTFB
썸네일이 늦게 뜨는 문제가 있었습니다. 이미지 크기를 먼저 의심할 수 있는 증상이었지만, 실측해 보니 32kB 이미지가 1.03초, 132kB 이미지가 1.23초였습니다. 바이트는 4.1배인데 시간은 1.19배라, 병목은 대역폭이 아니라 첫 바이트까지의 시간(TTFB)이라고 판단했습니다.
이미지를 next/image로 거쳐 받게 하고 캐시 기간을 30일로 늘렸습니다. 그리고 사내에 이미 구성돼 있던 Cloud CDN에 이 서비스를 추가했습니다. 이때 캐시 모드를 보안 결정으로 보고, 인증이 필요한 페이지가 엣지에 캐시되지 않도록 설정했습니다. 설정 절차와 검증, 운영 방법은 팀 문서로 남겼습니다.
배포
dev·staging·prod 세 환경을 Docker 멀티 스테이지 빌드와 Cloud Build로 직접 구성했습니다.
NEXT_PUBLIC_*값은 실행할 때가 아니라 빌드할 때 번들에 박힙니다. 그래서 런타임 환경변수가 아니라 빌드 인자로 넣었고, 배포 단계 구분에NODE_ENV를 쓰지 않도록 분리했습니다.- MSW를 켜는 플래그의 기본값은 끔으로, Dockerfile과 Cloud Build 설정 양쪽에 따로 고정했습니다. 한쪽 설정이 빠져도 운영에서 목이 켜지지 않게 하기 위해서입니다.
- GitHub Actions에서 lint → 타입 검사 → 테스트를 돌리고, 목·실서버 전환 전략과 CDN 운영 방법은 팀 문서로 남겼습니다.