refactor: Supabase 직접 호출을 app/api Route Handler로 이관 - #201
Conversation
|
@hogiljung is attempting to deploy a commit to the hm1n Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad7e710a48
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| if (response.status === 401) { | ||
| await clearDeadSession() |
There was a problem hiding this comment.
Redirect after clearing an invalid session
When a request still returns 401 after refresh, this branch clears both persisted authentication and the Zustand user but leaves the current protected page mounted. ProtectRoute performs its persisted-user redirect check in an effect whose dependencies are only [pathname, router], so clearing the user while the URL is unchanged does not trigger that check; the user can remain on a protected page with stale content until they navigate or reload. Explicitly trigger protected-route handling or navigation when invalidating the session.
AGENTS.md reference: AGENTS.md:L94-L101
Useful? React with 👍 / 👎.
Issue andbread#170 0단계. 공통 기반을 세우고 notification 6함수와 fcmToken 1함수를 파일럿으로 이관한다. 공통 기반 - src/lib/apiClient.ts: Route Handler 호출 클라이언트. Authorization 헤더 부착, 401 시 세션 갱신 후 1회 재시도, 실패 시 로그아웃 - src/app/api/_lib/supabaseRouteClient.ts: 사용자 JWT를 바인딩한 클라이언트. 다른 사용자 토큰 유출을 막기 위해 요청마다 새로 만든다 - src/app/api/_lib/requireAuth.ts: Bearer 파싱과 토큰 검증 - src/app/api/_lib/response.ts: 응답 헬퍼 서버 쿼리 - src/lib/server/** 함수는 Supabase 클라이언트를 첫 인자로 주입받는다. 이후 SSR 전환 시 서버 컴포넌트가 같은 함수를 그대로 호출할 수 있게 한다 클라이언트 lib - 함수 이름, 인자, 반환 타입을 그대로 두고 내부 구현만 apiClient 호출로 바꿨다. 호출부 8곳은 수정하지 않았다 설계서와 다른 부분 - updateNotificationState가 원시 행 대신 NotificationSettings를 반환한다. API 명세의 camelCase 규약을 따랐고 반환값을 쓰는 호출부는 없다 - 401 재시도에 getSession 대신 refreshSession을 쓴다. getSession은 캐시된 같은 토큰을 돌려줄 수 있어 재시도가 무의미해진다 - auth.ts의 logout은 router를 인자로 받아 재사용할 수 없어 apiClient 안에 로컬 signOut과 리다이렉트로 최소 구현했다 RLS는 그대로 유지한다. 로컬 Supabase에서 인증, RLS 격리, 소유권 404, 설정 기본값 자동 생성, 상태 코드를 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
코드 리뷰 지적 사항 중 두 건을 반영한다.
401 강제 리다이렉트 제거
- useUserStore는 localStorage에 영속되고 protectRoute는 살아있는 세션이 아니라
hasPersistedUser()만 본다. 그래서 refresh token이 만료된 상태로 앱을 열면
화면은 정상 렌더되고, Header의 배경 요청이 401을 받아
window.location.replace('/login')을 실행했다.
- 배경 요청 하나가 실패했다고 사용자를 페이지 밖으로 밀어내는 동작이고,
clearUser() 이후 protectRoute는 '/'로 보내려 해 리다이렉트가 경쟁했다.
- forceLogout을 clearDeadSession으로 바꾸고 화면 이동을 뺐다.
세션 정리는 apiClient가, 이동 판단은 기존대로 protectRoute가 담당한다.
미사용 코드 정리
- response.ts의 created(), AuthResult의 accessToken을 지웠다.
이후 단계가 계속 복제할 기반 파일이라 지금 걷어낸다.
브라우저에서 확인했다. 토큰을 만료시킨 뒤 /notification에 진입하면
401과 갱신 실패를 거쳐 ApiError가 호출부로 전파되고, 경로는 유지되며
세션과 스토어만 정리된다. 이후 이동 시 protectRoute가 '/'로 보낸다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue andbread#170 1단계. nbread 8함수와 nbreadRecord 2함수를 옮긴다. Route Handler 6개 - GET/POST /api/nbreads - GET /api/nbreads/summary - GET /api/nbreads/records - GET/PATCH/DELETE /api/nbreads/[nbreadId] - GET/PATCH /api/nbreads/[nbreadId]/records - POST /api/nbreads/[nbreadId]/invites/link 0단계에서 만든 requireAuth, createRouteClient, 응답 헬퍼를 그대로 쓴다. 클라이언트 lib은 시그니처를 유지해 호출부 16곳을 수정하지 않았다. 설계서의 개별 처리 항목 - fetchNbreadData의 Date 변환은 클라이언트 lib에 남겼다. 서버는 원시 문자열을 준다 - createLinkInvite는 서버가 invite_token만 주고 URL 조립은 클라이언트가 한다 - getUserNbreads의 paidCount N+1과 실패 시 빈 배열 반환은 그대로 옮겼다 설계서와 다른 부분 - getUserNbreads에 currentMonth 쿼리 파라미터를 추가했다. new Date().getMonth()를 서버로 옮기면 배포 서버 시간대(UTC) 기준이 되어, 한국 시간 매월 1일 0시부터 9시 사이에 연간 결제 엔빵이 이번 달 목록에서 빠지는 회귀가 생긴다. 현지 시간 판정을 유지하려고 클라이언트가 계산해 넘긴다 이관 중 확인한 후속 과제 - NbreadDetail이 같은 조건의 getNbreadRecords를 참여자 수만큼 반복한다. 상세 화면 한 번을 여는 데 동일 조회가 7회 나간다. 설계서 반영은 새 버전 문서로 따로 정리한다 로컬 Supabase에서 확인했다. 월 필터, paidCount와 DB 일치, 비참여자 격리, 생성부터 삭제까지의 왕복, 납부 상태 변경, 입력 검증 400을 확인했고 브라우저에서 /home, /calendar, /nbread/[id] 렌더를 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue andbread#170 2단계. participant 5함수와 invite 5함수를 옮긴다. Route Handler 6개 - GET/POST/DELETE /api/nbreads/[nbreadId]/participants - POST /api/nbreads/[nbreadId]/invites - GET /api/nbreads/[nbreadId]/invites/candidates - GET /api/invites/pending - GET /api/invites/[token] 인증 선택 - POST /api/invites/[token]/response 설계서의 개별 처리 항목 - getInviteUser는 useUserStore 대신 토큰의 user.id를 쓴다. 응답 키는 호출부가 그대로 읽으므로 profile_image를 유지한다 - isGetParticipantsUser와 participantUsers는 서버 내부 헬퍼로만 두고 API로 노출하지 않는다. 클라이언트 lib에서는 제거했다 - insertParticipant는 네 번 왕복하던 로직을 한 Route Handler로 옮겼다. 정원 검사와 삽입 사이의 경쟁 조건은 그대로 남는다 - respondToInvite는 respond_to_nbread_invite RPC를 그대로 호출한다 apiClient의 auth 옵션 제거 - 처음에 getInviteByToken을 auth: false로 보냈더니 로그인한 사용자도 토큰 없이 요청해 RLS에 막혔고 초대 화면이 비어 보였다. 서버가 토큰을 요구하지 않는 것과 클라이언트가 토큰을 보내지 않는 것은 다르다 - 세션이 있으면 항상 토큰을 붙이도록 바꾸고 옵션 자체를 없앴다. 로그아웃 상태면 getSession이 null을 주므로 헤더가 붙지 않는다 호출부 수정 1건 - InviteBottomSheet의 지역 User 타입에서 avatar를 string | null로 넓혔다. profile_image가 nullable인데 기존에는 응답이 any라 드러나지 않았다. InviteUserListItem은 이미 string | null을 받으므로 런타임 영향은 없다 비로그인 초대 조회는 nbread_invite의 SELECT 정책이 authenticated만 허용해 이관 전과 동일하게 막힌다. 엔드포인트는 anon을 허용하지만 실제로 공개하려면 정책 변경이 필요하며 이번 범위에서는 다루지 않는다. 로컬 Supabase에서 확인했다. 참여자 조회와 내보내기, 정원 초과 처리, 중복 초대 방지, 초대 후보 검색의 본인 제외, 입력 검증 400을 확인했고 브라우저에서 초대 수락을 눌러 참여자 반영까지 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue andbread#170 3단계. friend 6함수 중 5개를 옮기고 1개는 노출하지 않는다. Route Handler 3개 - GET /api/users/search?tag= - GET /api/friends?nbreadId= - POST/PATCH /api/friends/requests sendFriendRequest는 POST, updateAcceptFriend와 updateRejectedFriend는 같은 PATCH에 status로 갈라 붙였다. 보내는 쪽과 응답하는 쪽 모두 토큰의 사용자이므로 상대방 id만 본문으로 받는다. getInviteFriendList는 노출하지 않는다 - 저장소 전체에서 호출부가 0곳이라 엔드포인트를 만들지 않았다. 설계서의 확인 필요 항목을 이렇게 정리한다. 함수 자체의 제거는 미사용 코드 정리 후속 이슈에서 함께 다룬다 설계서 개별 처리 항목 - .or() 필터를 문자열로 조립하는 두 곳을 그대로 옮기고 주석으로 남겼다 - 실패 시 빈 배열이나 undefined를 돌려주던 동작을 클라이언트 lib에서 유지했다 호출부 수정 2건 - PlusFriendBottomSheet의 searchFriendProps.profileImage와 PlusFriendListItem의 profile을 string | null로 넓혔다. profile_image가 nullable인데 기존에는 응답이 any라 드러나지 않았다. 렌더링에 이미 falsy 가드가 있어 런타임 영향은 없다 lint 오류가 22개에서 21개로 줄었다. getFriendList의 any[] 지역 변수를 서버로 옮기며 타입을 붙였기 때문이다. 로컬 Supabase에서 확인했다. 친구 목록과 inviteState 부착, 태그 검색의 본인 제외와 기존 요청 상태 반영, 신규 요청과 중복 요청, 수락과 거절, 거절 후 재요청의 update 분기, 입력 검증 400을 확인했고 브라우저에서 /friendList 렌더와 태그 검색을 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue andbread#170 4단계. post 4함수와 chatMessage 2함수를 옮긴다. 이것으로 설계서의 이관 대상 39함수가 모두 끝난다. Route Handler 3개 - GET/POST /api/nbreads/[nbreadId]/posts - PATCH/DELETE /api/posts/[postId] - GET/POST /api/nbreads/[nbreadId]/messages 설계서와 다른 부분 - 게시글 수정과 삭제를 /api/posts/[postId]에 두었다. 설계서는 /api/nbreads/[nbreadId]/posts/[postId]였으나 deletePost 호출부가 postId만 넘겨 nbreadId를 알 수 없다. 경로에 의미 없는 조각을 채워 넣는 대신 게시글 식별자만으로 접근한다. 접근 제어는 기존과 동일하게 RLS가 담당한다 설계서 개별 처리 항목 - formattedTime은 표시용 값이라 클라이언트 lib에서 만든다. 서버는 createdAt 원시 값만 내려보낸다 - ChatRoom의 실시간 구독은 그대로 클라이언트에 남긴다 getPost 응답 타입 - 처음에는 snake_case 원본 행을 그대로 내려보냈으나 응답 본문은 camelCase를 쓴다는 규약에 어긋났다. 이미 있는 Post 타입으로 맞췄다. - 그에 따라 Community의 mapToPost가 하던 snake에서 camel 변환이 서버로 넘어가고, 호출부에는 표시용 날짜 포맷만 남는 formatPostDate가 남았다 lint 오류가 21개에서 18개로 줄었다. getPost와 insertPost, Community의 mapToPost에 있던 any가 사라졌다. deletePost와 UpdatePost의 any는 시그니처 유지를 위해 남기고 eslint-disable 주석을 붙였다. 타입 정리는 후속 이슈로 남는다. 로컬 Supabase에서 확인했다. 게시글 조회와 작성, 수정, 삭제, 작성자가 본문이 아니라 토큰 사용자로 저장되는 점, 메시지 조회와 전송, 입력 검증 400을 확인했고 브라우저에서 게시판 렌더와 채팅방의 formattedTime 표시, UI를 통한 메시지 전송을 확인했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
코드 리뷰에서 나온 회귀를 고친다. Nbread.startDate는 string | null인데 두 호출부가 !로 넘긴다. 이관 전에는 new Date(null)이 1970-01-01이 되어 조회 결과가 항상 비어 있었고 갱신은 아무 행도 건드리지 않았다. 이관 후에는 apiClient의 buildUrl이 null 쿼리 값을 빼 버리므로 startDate 없이 요청이 나가 라우트가 400을 돌려주고 클라이언트 lib이 다시 던졌다. 두 호출부 모두 try/catch도 .catch도 없어 미처리 rejection이 됐다. 요청을 보내지 않고 이관 전과 같은 값을 돌려주도록 바꿨다. 서버의 startDate 검증은 그대로 두어 잘못된 요청은 여전히 400이다. start_date가 null인 엔빵으로 재현해 확인했다. 상세 화면이 오류 없이 렌더되고 records 요청이 나가지 않는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ad7e710 to
6f209b0
Compare
Why
클라이언트에서 Supabase를 직접 호출하는 구조는 다음 문제가 있었습니다.
What
Supabase 직접 호출을
app/apiRoute Handler 뒤로 이관했습니다.src/app/api/_lib/—requireAuth,supabaseRouteClient, 공통 응답 헬퍼 추가src/app/api/**/route.ts— 엔빵/납부기록/참여자/초대/알림/FCM/게시글/채팅/친구·사용자 검색 엔드포인트 신설src/lib/server/**— 기존 Supabase 쿼리 로직을 서버 전용 모듈로 이동src/lib/**— 기존 함수는apiClient를 통한 Route Handler 호출로 대체 (호출부 시그니처 유지)src/lib/apiClient.ts— 공통 fetch 래퍼 추가Commits
feat: 알림/FCM 토큰 Supabase 호출을 app/api Route Handler로 이관fix: 401 처리에서 강제 리다이렉트를 제거하고 미사용 응답 헬퍼를 정리feat: 엔빵/납부기록 Supabase 호출을 app/api Route Handler로 이관feat: 참여자/초대 Supabase 호출을 app/api Route Handler로 이관feat: 친구/사용자 검색 Supabase 호출을 app/api Route Handler로 이관feat: 게시글/채팅 Supabase 호출을 app/api Route Handler로 이관fix: startDate가 없을 때 납부 기록 요청을 보내지 않는다Impact
Community.tsx,PlusFriendBottomSheet,PlusFriendListItem,InviteBottomSheet).클라이언트 → Route Handler → Supabase로 한 단계 늘어나므로, 세션 쿠키 전달과 401 처리 동작 확인이 필요합니다.Review Points
src/app/api/_lib/requireAuth.ts— 인증 실패 시 응답 형태와 호출부 처리src/app/api/_lib/supabaseRouteClient.ts— 쿠키 기반 세션 전달이 SSR/CSR 양쪽에서 올바른지37990ae)의 사용자 영향src/lib/server/friend/getSearchFriend.ts등 조회 로직 이동 과정에서의 동작 동등성Test
npm run lint— 통과 (기존 warning만 존재)npm run build— 통과