logo

DowanKim

모바일 온보딩 저장 API 연결

2026년 7월 19일

PLick

모바일 온보딩 저장 API 연결

온보딩 화면(닉네임 → 마이팀)에 실제 저장 API를 붙였다. 지금까지 붙인 API는 전부 인증 그 자체(login·logout·refresh)였는데, 이번이 처음으로 "로그인한 상태로 부르는" 보호 API다. 그래서 이 작업의 반은 온보딩이고, 반은 "토큰을 어떻게 실어 보내느냐"였다.

Bearer 주입은 apiFetch가 아니라 서버 액션에서

apiFetch에는 KAN-249 때부터 "인증 붙으면 여기서 토큰 주입"이라는 주석이 자리만 잡고 있었다. 막상 채우려고 보니 그 자리가 아니었다.

apiFetch가 쿠키를 직접 읽으려면 next/headerscookies()를 불러야 하는데, 이 함수는 요청 컨텍스트가 있는 서버 컴포넌트·서버 액션에서만 돈다. 그런데 apiFetch의 소비자 중 하나가 edge 미들웨어의 refreshTokens다(ADR 0021) — 미들웨어에선 cookies()가 안 돌아서, apiFetch에 넣는 순간 refresh가 깨진다.

그래서 주입 지점을 한 칸 위로 올렸다. apiFetch는 계속 "받은 걸 보내는" 순수한 래퍼로 두고, 보호 API를 부르는 서버 액션이 직접 cookies()로 access 토큰을 꺼내 Authorization: Bearer 헤더로 넘긴다. 호출부가 자기 실행 환경을 아니까 쿠키를 읽을 수 있는지도 자기가 안다. 봉합점 주석은 "여기서 주입하라"가 아니라 "주입은 호출부가 한다"로 정리된 셈이다.

두 화면에 걸친 폼 — 닉네임을 쿼리로 날랐다

온보딩은 라우트가 두 개다(/onboarding/nickname/onboarding/team). BE는 닉네임과 팀을 한 요청으로 받는다. 그러니 1단계의 닉네임이 2단계까지 살아서 와야 한다.

전역 스토어나 sessionStorage도 떠올렸지만 쿼리 파라미터로 정했다. "다음" 버튼이 /onboarding/team?nickname=…으로 이동하고, 팀 페이지(서버 컴포넌트)가 searchParams로 받아 제출 컴포넌트에 props로 내린다. 이러면 서버 컴포넌트가 값을 바로 읽을 수 있고 (스토리지는 클라 전용이라 못 읽는다), 새로고침해도 값이 살고, 닉네임 없이 팀 페이지로 직행하면 1단계로 돌려보내는 가드도 리다이렉트 한 줄로 끝난다. 값 하나 나르자고 상태 라이브러리를 들이는 건 과했다.

화면 쪽은 상태를 한 칸씩 끌어올렸다. NicknameField·TeamSelectGrid가 각자 들고 있던 로컬 상태를 부모(NicknameStep·TeamStep)로 올려 제어형으로 바꿨다 — 버튼(다음/시작하기)이 그 값을 써야 하는데 상태가 자식 안에 갇혀 있으면 못 쓰기 때문이다. "시작하기"는 useTransition으로 서버 액션을 부르고, pending 동안 버튼을 잠근다.

팀 id가 작업 도중에 바뀌었다

BE의 온보딩 요청은 teamIds: number[]인데, FE가 가진 건 팀 코드(LIV 같은 문자열)뿐이고 팀 목록을 주는 API가 없다. 그래서 실제 DB의 teams 테이블을 읽어 코드→id 매핑 상수(TEAM_IDS)를 만들었다. 그때 값은 7~12였다.

그런데 검증 도중 이상한 게 보였다. 토트넘(11)을 골라 저장했는데 DB엔 6이 들어가 있었다. 알고 보니 검증하는 사이에 BE 쪽에서 teams를 1~6으로 재시드했고, 기존 favorite_teams까지 새 id로 재매핑돼 있었다. 내 제출은 재시드 직전에 11로 성공했고, 재시드가 그 행을 6으로 옮긴 것이다. "id를 하드코딩하면 재시드에 조용히 깨진다"고 주석에 적어둔 위험이 몇 분 만에 실제로 터진 셈이라 웃음이 났다. 상수를 새 값(1 MUN · 2 MCI · 3 LIV · 4 ARS · 5 CHE · 6 TOT)으로 고치고 다시 검증해서 통과시켰다.

교훈은 분명하다. DB 자동증가 id는 계약이 아니다. teams는 어드민이 쓰기 소유하는 마스터 데이터라 FE가 id를 미리 알 방법이 없다. 지금은 상수로 버티되(어긋나면 400 "존재하지 않는 팀입니다"로 드러나긴 한다), BE에 GET /teams 같은 목록 API나 shortName 기반 계약을 요청하는 게 맞다. 상수 주석에도 이 사건을 남겨뒀다.

에러는 종류별로 갈 곳이 다르다

BE 에러 봉투의 code를 보고 세 갈래로 갈랐다. USER_ALREADY_ONBOARDED(409) 는 에러로 보여주지 않고 홈으로 리다이렉트한다 — 이미 온보딩이 끝난 유저가 다시 들어온 것이니 목적지로 보내면 그만이다. 닉네임 중복(409)·금지어(400) 는 BE 문구가 이미 사용자용 한국어라 그대로 보여주고 "이전 단계에서 바꿔달라"만 덧붙였다. 401은 세션이 끊긴 것이니 로그인으로 보낸다. 나머지는 공통 문구다. redirect()는 예외를 던져 탈출하는 함수라 catch 안에서 불러도 바깥으로 잘 빠져나간다는 걸 이번에 다시 확인했다.

OAuth 없이 보호 API를 검증한 요령

보호 API 검증엔 로그인 세션이 필요한데, 로컬에서 카카오 OAuth를 실제로 도는 건 번거롭다.

BE 액세스 토큰이 HS256 JWT(sub=userId)인 걸 알고 있어서, 로컬 시크릿으로 토큰을 직접 민팅하고 일회용 테스트 유저를 DB에 만들어 브라우저 쿠키에 심는 방식으로 실제 플로우를 밟았다.

성공(저장·리다이렉트·DB 확인) → 닉네임 중복 에러 문구 → 재호출 409 → 비로그인 401까지 네 상태를 전부 실제 BE로 확인하고, 테스트 유저는 지웠다. 서버 액션이 BE를 서버→서버로 부르니 브라우저 네트워크 탭엔 액션 POST(303)만 보인다는 점도 기억해둘 만하다.