API 연결 전략을 정하자
2026년 7월 16일
API 연결 전략을 정하다 (KAN-249)
퍼블리싱이 얼추 끝나고, 이제 로컬에 백엔드를 띄워 스웨거를 보며 API를 하나씩 붙이는 단계로 넘어왔다. 코드를 붙이기 전에 먼저 "어떤 전략으로 붙일 건지"를 정하는 게 이번 작업이었다. 결과물은 화면 하나가 아니라 api-integration 스킬과 /wire-api 커맨드의 확정본이다. 지금까지 그 스킬은 "BE 계약 나오면 채운다"는 초안 상태로 열려 있었는데, 이제 BE가 실제로 로컬에 뜨니 열어둔 항목들을 하나씩 닫았다.
시작점은 생각보다 백지였다. .env도, fetch도, api.ts도 없었다. 정말 첫 연결이라, 여기서 정하는 관례가 앞으로 붙일 모든 엔드포인트의 기본값이 된다. 그래서 편하게 흘려보내지 않고, 스킬에 영구히 박힐 결정 몇 개는 짚고 넘어갔다.
제일 큰 갈림길 — 페칭 방식
가장 오래 고민한 건 데이터를 어떻게 가져오냐였다. 첫 직감은 "릴스는 다음 항목을 미리 당겨와야 하고 무한스크롤도 있으니, 그냥 TanStack Query(리액트 쿼리, 클라이언트 캐시 라이브러리)를 전면에 깔자"였다. 그런데 반대편엔 이미 이 코드베이스가 서버 컴포넌트를 기본으로 쓰고 있다는 사실이 있었다. 홈 페이지를 열어보니 서버 컴포넌트가 목데이터를 import해서 자식에 props로 내려주는, 깔끔한 구조였다. 여기에 리액트 쿼리를 전면 도입하면 그 화면들이 전부 "use client"로 내려가면서 서버 렌더 이점을 버리게 된다.
결국 하이브리드로 갔다. 이게 어중간한 타협처럼 들릴까 봐 스스로도 한 번 의심했는데, 뜯어보니 오히려 App Router의 정석에 가까웠다. 가르는 기준은 딱 하나 — "클라이언트에서 이어지느냐"다. 홈 첫 로드, 상세 요약, 프로필처럼 한 번 읽고 렌더가 끝나는 화면은 서버 컴포넌트 fetch로 충분하다. 반면 릴스의 다음 페이지 prefetch, 좋아요·투표·댓글의 낙관적 업데이트(먼저 UI를 바꾸고 실패하면 되돌리는 것)처럼 클라이언트에서 계속 이어지는 건 리액트 쿼리가 잘하는 일이다. 이걸 순수 fetch로 직접 짜면 캐시 키, 중복요청 제거, 다음 항목 미리 당기기, 낙관적 업데이트와 롤백을 전부 손으로 재발명해야 하고, 버그가 사는 곳이 정확히 거기다. 둘이 만나는 릴스는 서버가 첫 페이지를 fetch해서 리액트 쿼리에 씨앗으로 심어주면 클라이언트가 2페이지부터 이어받는다. 이중 페치도 없다.
여기서 하나 더 정한 게 타이밍이다. 결론이 "릴스엔 리액트 쿼리"라고 해서 지금 당장 깔지는 않기로 했다. 앞으로 처음 붙일 엔드포인트들은 홈 피드, 상세처럼 단순 GET이라 서버 fetch면 된다. 리액트 쿼리는 처음으로 "클라이언트 연속이나 뮤테이션"이 필요한 엔드포인트, 그러니까 릴스 페이지네이션이나 첫 좋아요를 붙일 때 도입한다. 미리 깔면 안 쓰는 의존성이 되고, 나중에 통째로 갈아야 하면 대공사가 된다. 도입 트리거와 코드 패턴을 스킬에 미리 적어두면 그때 "새로 설계"가 아니라 "문서대로 한 스텝"이 되니, 조기 도입과 뒤늦은 리트로핏 둘 다 피할 수 있다.
캐시가 셋이라는 걸 짚고 넘어갔다
릴스에서 "서버가 1페이지 받고 클라가 이어받는다"를 정리하다 보니, 정작 헷갈리는 건 캐시가 한둘이 아니라 셋이고 서로 남남이라는 점이었다. 이걸 흐릿하게 두면 "왜 굳이 하이드레이션까지 해야 하나"가 안 풀린다. 그래서 셋을 확실히 갈라놨다.
첫째는 Next 데이터 캐시. 서버에 살고, 서버 컴포넌트의 fetch가 next: { revalidate }로 제어한다. 핵심은 이게 모든 유저가 공유한다는 거다. 한 유저가 릴스 1페이지를 받아 캐시에 채우면, 그 뒤 60초 동안 접속하는 전원이 BE를 안 때리고 그 저장분을 쓴다. 주방의 공용 팬트리 같은 거다.
둘째는 브라우저 HTTP 캐시. 각 유저의 브라우저에 살고, 프론트 코드로 켜는 게 아니라 브라우저에 내장돼 자동으로 돈다. 얼마나 캐시할지는 BE가 응답에 붙이는 Cache-Control·ETag 헤더가 정한다. 손님이 자기 집 냉장고에 담아가는 것 — 사적이고, 유통기한 라벨대로 보관된다. 이적 루머 피드처럼 자주 바뀌는 API는 BE가 대개 no-store를 줘서 실은 잘 안 낀다.
셋째가 리액트 쿼리 캐시. 브라우저 JS 메모리에 있는, queryKey로 데이터를 넣고 빼는 Map이다. 앞의 둘과 결이 다른데, 브라우저 HTTP 캐시가 히트하면 fetch는 부르되 네트워크만 아끼는 반면, 리액트 쿼리 캐시가 히트하면 fetch 호출 자체를 안 하고 로딩 깜빡임 없이 데이터를 바로 쥔다.
여기서 이중 페치가 왜 나는지가 분명해졌다. 세 캐시가 서로의 존재를 모른다. 서버 fetch는 Node에서 돌아 Next 데이터 캐시에만 담기고, 그 결과는 브라우저 HTTP 캐시에도 리액트 쿼리 캐시에도 들어가지 않는다. 그러니 클라 릴스 훅이 마운트되면 자기 캐시(Map)가 비어 있어서 같은 1페이지를 또 부른다. 충돌이 아니라 격리 때문에 일이 공유가 안 되는 문제였다. 그 사이를 잇는 유일한 다리가 하이드레이션 — 서버가 채운 캐시를 dehydrate로 말려 넘기고 클라 리액트 쿼리 캐시에 도로 부어주는 것 — 이고, 그래서 릴스에 그걸 넣기로 했다. 실행 장소(서버냐 브라우저냐)가 어떤 캐시가 붙을지를 가른다는 감각 하나만 쥐고 있으면 이 셋은 안 헷갈린다.
타입은 손으로 매핑, 진실은 스웨거
타입을 스웨거에서 자동 생성(openapi-typescript 같은 코드젠)할지, 손으로 도메인 타입에 매핑할지도 정해야 했다. 손 매핑을 골랐다. 이미 _lib/types.ts에 FeedPost·Comment 같은 도메인 타입이 화면 관용에 맞게 잘 잡혀 있어서, BE 응답을 이 모양으로 경계에서 변환하는 게 화면 이름을 지키면서 통제력도 갖는 길이었다. 코드젠은 BE 변경이 타입에 자동 반영되는 대신 생성 타입과 도메인 타입 사이 매핑 레이어가 어차피 또 필요하고, 생성물 관리 부담이 붙는다. 지금 규모에선 남는 장사가 아니었다.
대신 규칙 하나를 강하게 박았다 — 응답 shape의 진실은 티켓이 아니라 스웨거다. 티켓 설명을 믿지 말고 스웨거 UI나 /v3/api-docs JSON에서 실제 필드와 타입, 페이지네이션 래핑, 에러 모양을 직접 확인한 다음 붙인다. 특히 로컬 BE가 Spring이라, 페이지네이션이 Page<T>로 감싸져 content·totalElements·last로 오거나 날짜가 ISO 문자열로 오는 식의 차이가 흔하다. 이런 건 전부 데이터 레이어 경계에서 흡수하고, 화면은 도메인 타입만 보게 둔다.
base URL은 서버 전용, CORS는 프록시로
로컬 BE는 Spring localhost:8080으로 확정했다. base URL을 NEXT_PUBLIC_으로 클라이언트에 노출할까 하다가, 서버 전용 env(API_BASE_URL)로 뒀다. 서버 컴포넌트 fetch는 서버에서 도니 base가 클라이언트로 샐 일이 없고, 공개 env로 열어두면 나중에 프록시로 옮길 때 되돌려야 한다. 그 프록시가 두 번째 결정인데, 브라우저(리액트 쿼리)가 localhost:3001에서 localhost:8080을 직접 때리면 cross-origin이라 CORS에 막힌다. BE에 CORS를 여는 대신 Next rewrites로 same-origin 프록시(/be/*)를 깔아 브라우저는 상대경로만 부르게 하기로 했다. base가 한 곳에 모이고 CORS가 사라진다. 이것도 클라이언트 fetch가 처음 생길 때 깐다 — 서버 전용 단계에선 CORS 자체가 안 나기 때문이다.
공용화는 기존 게이트를 그대로, 인증은 자리만
무엇을 web·mobile 공용으로 뺄지는 ADR 0011의 게이트를 그대로 물려받았다. 다만 이번에 대상이 하나 늘었다 — fetch 인프라(apiFetch 래퍼, 리액트 쿼리 provider·쿼리키 규약)도 데이터·타입과 같은 성숙도 게이트를 탄다. 첫 앱에선 앱별 _lib에 두고, 두 번째 앱이 같은 규약을 쓰게 될 때 @plick/core로 올릴지 판단한다. 첫 앱에서 미리 빼지 않는다는 원칙은 그대로다. BE가 붙으며 도메인 데이터 모양이 처음으로 굳는 지금이, 사실 "무엇을 공용으로 뺄지"를 판단하기 딱 좋은 시점이기도 하다.
인증은 아직 공개 API부터라 비워뒀다. 다만 apiFetch의 헤더 자리에 토큰 주입 지점만 봉합으로 남겼다. 인증이 붙으면 저장 위치와 주입 방식(Bearer 헤더냐 HttpOnly 쿠키냐)은 추측하지 않고 확인해서 그 자리만 채운다. 자격증명을 코드로 직접 넣거나 저장하는 건 하지 않는다.
남긴 것
분량이 커져서 스킬은 척추(SKILL.md)만 남기고 코드 패턴은 참조 파일 둘로 갈랐다 — data-layer.md(env·프록시·apiFetch·도메인 fetcher·경계 변환·스웨거 읽는 법·로컬 검증)와 tanstack-query.md(도입 트리거·provider·무한스크롤·prefetch·낙관적 뮤테이션·하이드레이션). 정적 컨텍스트를 얇게 유지하려는 의도다. /wire-api 커맨드는 이 확정 절차를 그대로 따르게 갱신했다.
정리하면 이번에 정한 전략은 이렇다. 스웨거를 진실로 삼아 엔드포인트를 하나씩, 작업 하나 = PR 하나로 붙인다. 단발 읽기는 서버 컴포넌트 fetch, 릴스와 뮤테이션은 리액트 쿼리를 쓰되 필요해지는 그 지점에서 도입한다. 타입은 손으로 도메인에 매핑하고 차이는 경계에서 흡수한다. 공용화는 두 번째 사용처가 생길 때 게이트로 판단한다. 관통하는 감각 하나는 "두 번째로 필요해질 때 도입한다"였다 — 리액트 쿼리든 공용 패키지든, 첫 번째부터 미리 빼지 않는다.
관련: 공용 경계 게이트는 ADR 0011, 승격 절차는 ADR 0005. 실제 붙이는 규칙은 api-integration 스킬과 /wire-api 커맨드.