← 염승준
10

B2G 선수관리 어드민: API 계약보다 먼저 출발하기

공공기관용 멀티테넌트 어드민을 백엔드 개발자 1명과 함께 만들며, 프론트엔드의 설계·구현·배포를 맡았습니다.

문제
  • 공공기관이 소속 지도자의 콘텐츠 활동을 조회·보고하는 멀티테넌트 어드민을, 백엔드 API 계약이 확정되기 전에 프론트엔드부터 시작해야 했음
  • 기관을 바꾸면 같은 URL에 헤더만 달라지는 구조라, 캐시를 통해 기관 간 데이터가 섞일 위험
해결
  • zod 스키마를 API 계약 초안이자 런타임 검증기로 쓰고, MSW가 네트워크 층에서 가로채 앱 코드에 목 분기를 두지 않음
  • 모든 쿼리 키에 기관 코드를 넣고, /organizations/:id를 /me로 바꾸자고 제안해 소속되지 않은 기관 조회 가능성을 제거
  • 토큰을 전역 스토어에 복제하지 않고 Firebase SDK를 기준으로, 401은 강제 갱신 후 한 번만 재시도
  • PDF 전량 조회 상한 초과 시 에러, 한글 폰트 누락처럼 에러 없이 틀리는 실패를 테스트로 고정
  • 썸네일 지연을 실측해 크기가 아닌 TTFB 병목으로 판단, next/image와 기존 Cloud CDN에 서비스를 추가해 엣지 캐시 적용. 인증 페이지는 엣지에 캐시되지 않도록 설정
결과
  • 약 4주간 PR 45개로 설계부터 dev·staging·prod 3환경 배포까지 완료
  • 환경변수 하나로 목에서 실서버로 전환
기간
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) 같은 분기가 들어가면 나중에 전부 걷어내야 하고, 걷어내는 순간 실서버 경로가 처음 실행됩니다. 네트워크 층에서 가로채면 앱은 늘 진짜 요청을 보낸다고 믿으므로, 환경변수 하나로 실서버 전환이 끝납니다.

기관 간 데이터 격리

멀티테넌트 어드민에서 가장 중요했던 건 기관끼리 데이터가 섞이지 않는 것이었습니다.

내부 개발자는 기관을 바꿔 가며 볼 수 있는데, 기관을 바꾸면 요청 함수가 기관 코드 헤더만 바꿔 같은 URL을 부릅니다. 쿼리 키에 기관 코드가 없으면 기관 A의 응답이 기관 B의 키로 캐시되어 데이터가 새어 나옵니다. 같은 URL에 헤더만 다르면 다른 데이터인 구조에서는 캐시 키가 곧 격리의 경계입니다. 그래서 모든 쿼리 키에 기관 코드를 넣었습니다.

export const organizationKeys = {
  me: (orgCode) => ['organizations', 'me', orgCode ?? null] as const,
  coaches: (orgCode) => ['organizations', 'coaches', orgCode ?? null] as const,
};

인증: 토큰을 복제하지 않기

로그인 폼을 따로 만들지 않고 CameraFi 통합 로그인에 맡겼습니다. 통합 로그인에서 돌아온 토큰만으로는 Firebase 클라이언트 로그인이 되지 않아, Next.js Route Handler에서 커스텀 토큰으로 교환하는 단계를 두고 세션 쿠키를 발급했습니다.

조용한 실패를 테스트로 막기

에러 없이 틀리게 동작하는 것들이 가장 위험했습니다.

테스트가 모르는 사이 실제 네트워크를 부르지 않도록, 처리되지 않은 요청은 에러로 떨어지게 설정했습니다.

크기가 아니라 TTFB

썸네일이 늦게 뜨는 문제가 있었습니다. 이미지 크기를 먼저 의심할 수 있는 증상이었지만, 실측해 보니 32kB 이미지가 1.03초, 132kB 이미지가 1.23초였습니다. 바이트는 4.1배인데 시간은 1.19배라, 병목은 대역폭이 아니라 첫 바이트까지의 시간(TTFB)이라고 판단했습니다.

이미지를 next/image로 거쳐 받게 하고 캐시 기간을 30일로 늘렸습니다. 그리고 사내에 이미 구성돼 있던 Cloud CDN에 이 서비스를 추가했습니다. 이때 캐시 모드를 보안 결정으로 보고, 인증이 필요한 페이지가 엣지에 캐시되지 않도록 설정했습니다. 설정 절차와 검증, 운영 방법은 팀 문서로 남겼습니다.

배포

dev·staging·prod 세 환경을 Docker 멀티 스테이지 빌드와 Cloud Build로 직접 구성했습니다.