From b06c26449de2a564a12dd8d2592163d597d3f2c6 Mon Sep 17 00:00:00 2001 From: junghogil Date: Fri, 14 Aug 2026 18:33:45 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20Supabase=20=ED=98=B8=EC=B6=9C=EC=9D=84=20a?= =?UTF-8?q?pp/api=EB=A1=9C=20=EC=9D=B4=EA=B4=80=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=84=A4=EA=B3=84=EC=84=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib 전수 조사로 이관 대상 28파일 39함수 확정 - app/api 디렉토리 구조와 Route Handler 22개 설계 - 엔드포인트 36개 API 명세 및 공통 규약 정리 - 리스크 6건, 후속 이슈 8건, PR 분리 계획 도출 Co-Authored-By: Claude Opus 5 --- llm-wiki/index.md | 2 +- llm-wiki/log.md | 1 + ...pabase-client-to-app-api-migration-plan.md | 429 ++++++++++++++++++ 3 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 llm-wiki/output/2026-08-14-github-issue-170-supabase-client-to-app-api-migration-plan.md diff --git a/llm-wiki/index.md b/llm-wiki/index.md index 234ea4f..95e048c 100644 --- a/llm-wiki/index.md +++ b/llm-wiki/index.md @@ -16,5 +16,5 @@ ## 최근 산출물 -- +- [클라이언트 Supabase 호출을 app/api로 이관하는 구현 설계서](output/2026-08-14-github-issue-170-supabase-client-to-app-api-migration-plan.md) diff --git a/llm-wiki/log.md b/llm-wiki/log.md index 691eafc..b291e6f 100644 --- a/llm-wiki/log.md +++ b/llm-wiki/log.md @@ -8,3 +8,4 @@ | 2026-08-13 | GitHub 이슈 #193을 `raw/2026-08-13-github-issue-193-llm-wiki-setup.md`에 저장 | 작업 기획서 원본 보존 | - | | 2026-08-13 | 첫 `wiki/` 문서 `llm-wiki-background-and-structure.md` 작성 | 배경, 해결 범위, 구조, 다음 질문 정리 | 이슈 #193 "후속 문서 작성 및 에이전트 연동 범위 정리" 작업 미완료 | | 2026-08-13 | GitHub 이슈 #195을 `raw/2026-08-13-github-issue-195-naver-search-advisor.md`에 저장 | 네이버 서치어드바이저 등록 작업 기획서 원본 보존 | 아직 `wiki/` 정리 문서 미작성 | +| 2026-08-14 | `src/lib/**` 전수 조사 후 `output/2026-08-14-github-issue-170-supabase-client-to-app-api-migration-plan.md` 작성 | 이관 대상 28파일 39함수 확정, Route Handler 22개 구조와 API 명세 정리, 후속 이슈 8건 도출 | `getInviteFriendList` 호출부 확인, 왕복 지연 실측 | diff --git a/llm-wiki/output/2026-08-14-github-issue-170-supabase-client-to-app-api-migration-plan.md b/llm-wiki/output/2026-08-14-github-issue-170-supabase-client-to-app-api-migration-plan.md new file mode 100644 index 0000000..4c958f5 --- /dev/null +++ b/llm-wiki/output/2026-08-14-github-issue-170-supabase-client-to-app-api-migration-plan.md @@ -0,0 +1,429 @@ +# 클라이언트 Supabase 호출을 app/api로 이관하는 구현 설계서 + +## 한 문장 요약 + +브라우저에서 직접 호출하던 Supabase 데이터 접근 39개 함수를 Next.js Route Handler 뒤로 옮기되, RLS와 기존 동작을 그대로 유지하는 순수 이동으로 진행하여 이후 SSR 및 쿠키 세션 전환의 기반을 만든다. + +## 근거 + +- 원천 자료: 저장소 코드 직접 조사 (`src/lib/**`, `src/app/api/**`, `src/hooks/**`, `supabase/migrations/**`) +- 확인 날짜: 2026-08-14 +- 자료 성격: 코드 +- 관련 이슈: [GitHub Issue #170](https://github.com/andbread/Andbread_Frontend/issues/170) / 브랜치 `refactor-63/api` +- 관련 문서: [llm-wiki 배경과 구조](../wiki/llm-wiki-background-and-structure.md) + +--- + +## 1. 배경과 목표 + +### 1.1 현재 상태 + +데이터 접근 계층은 `src/lib/<도메인>/<동작><엔티티>.ts` 형태로 이미 분리되어 있고, 이 파일들이 모두 `src/lib/supabaseClient.ts`를 직접 가져다 쓴다. 이 클라이언트는 `'use client'` 지시자가 붙은 브라우저 전용 클라이언트이며 anon key를 사용한다. 컴포넌트와 훅은 이 lib 함수를 48곳에서 가져다 쓴다. + +세션은 `localStorage`에 저장한다. `@supabase/ssr`을 사용하지 않으며 `middleware.ts`도 없다. 따라서 서버 코드가 쿠키에서 세션을 읽을 수 있는 경로가 존재하지 않는다. + +### 1.2 이번 작업의 목표 + +이번 작업의 목표는 SSR 전환과 쿠키 세션 전환을 위한 사전 기반 작업이다. 보안 강화나 성능 개선은 이번 범위가 아니다. 데이터 접근 경로만 서버로 옮기고, 나머지는 전부 그대로 둔다. + +### 1.3 이번 작업의 비목표 + +- RLS를 우회하는 service role 전환은 하지 않는다. RLS는 그대로 유지한다. +- 쿠키 세션 전환과 `@supabase/ssr` 도입은 하지 않는다. 인증은 Bearer 토큰 헤더로 처리한다. +- 성능 개선(N+1 제거, 쿼리 통합)은 하지 않는다. +- 에러 처리 규약 변경, 타입 정리, 페이지의 서버 컴포넌트 전환은 하지 않는다. + +--- + +## 2. 설계 원칙 + +### 2.1 교체 지점을 두 곳으로 고정한다 + +다음 이슈에서 쿠키 세션으로 전환할 때 바꿔야 할 곳이 두 곳뿐이도록 설계한다. + +``` +[클라이언트] [서버] +src/lib/apiClient.ts src/app/api/_lib/supabaseRouteClient.ts + └ Authorization 헤더를 붙인다 └ 사용자 JWT를 바인딩한 클라이언트를 만든다 + │ │ + ▼ ▼ +src/lib/<도메인>/*.ts src/lib/server/<도메인>/*.ts + (시그니처를 그대로 유지한다) (클라이언트를 인자로 주입받는다) +``` + +쿠키 전환 시점에는 `apiClient`에서 헤더 부착 코드를 제거하고 `createRouteClient`를 `createServerClient`로 바꾸면 된다. 서버 쿼리 코드는 한 줄도 바꾸지 않는다. + +### 2.2 서버 쿼리 함수는 클라이언트를 주입받는다 + +`src/lib/server/**`의 함수는 절대 Supabase 클라이언트를 직접 만들거나 가져오지 않는다. 항상 첫 번째 인자로 받는다. + +```ts +// src/lib/server/nbread/getUserNbreads.ts +export const getUserNbreads = async ( + client: SupabaseClient, + userId: string, +) => { /* ... */ } +``` + +이 규칙을 지켜야 다음 이슈에서 서버 컴포넌트가 같은 함수를 그대로 호출할 수 있다. + +### 2.3 클라이언트 lib의 시그니처를 바꾸지 않는다 + +`src/lib/<도메인>/*.ts`의 함수 이름, 인자, 반환 타입을 그대로 두고 내부 구현만 `supabase` 호출에서 `apiClient` 호출로 바꾼다. 이렇게 하면 컴포넌트와 훅 48곳을 전혀 수정하지 않는다. 이것이 회귀를 최소화하는 핵심 장치다. + +### 2.4 RLS를 유지하기 위해 사용자 JWT를 바인딩한다 + +Route Handler에서 anon key로 클라이언트를 만들되 전역 헤더에 사용자 토큰을 실어야 RLS가 현재와 동일하게 적용된다. + +```ts +// src/app/api/_lib/supabaseRouteClient.ts +export function createRouteClient(accessToken: string) { + return createClient(supabaseUrl, supabaseAnonKey, { + global: { headers: { Authorization: `Bearer ${accessToken}` } }, + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }) +} +``` + +**이 클라이언트는 반드시 요청마다 새로 만들어야 한다.** 모듈 최상위에서 만들어 재사용하면 서버리스 인스턴스가 재사용될 때 다른 사용자의 토큰으로 쿼리가 나갈 수 있다. 기존 `src/app/api/auth/delete-account/route.ts`는 모듈 최상위에서 클라이언트를 만들지만, 그곳은 토큰을 `getUser(token)`에 명시적으로 넘기는 방식이라 문제가 없다. 이 차이를 코드 리뷰에서 반드시 확인한다. + +--- + +## 3. lib 폴더 변경 범위 + +### 3.1 집계 + +| 항목 | 수 | +|---|---| +| 이관 대상 파일 | 28 | +| 이관 대상 함수 | 39 | +| 외부 노출 엔드포인트 | 36 ~ 37 | +| 신규 Route Handler 파일 | 22 | +| 수정이 필요한 호출부 | 0 | + +### 3.2 도메인별 대상 + +| 도메인 | 파일 | 함수 | 비고 | +|---|---|---|---| +| `lib/nbread` | 8 | 8 | `createLinkInvite`는 부분 이관한다 | +| `lib/nbreadRecord` | 2 | 2 | | +| `lib/notification` | 5 | 6 | `deleteNotifications.ts`에 함수가 2개 있다 | +| `lib/fcmToken` | 1 | 1 | | +| `lib/participant` | 3 | 5 | 함수 2개는 내부 헬퍼라 노출하지 않는다 | +| `lib/invite` | 5 | 5 | 1개는 RPC이고 1개는 공개 엔드포인트다 | +| `lib/friend` | 3 | 6 | | +| `lib/post` | 4 | 4 | `any` 타입을 여러 곳에서 쓴다 | +| `lib/chatMessage` | 2 | 2 | | + +### 3.3 변경하지 않는 파일 + +다음 파일은 Supabase를 호출하지 않거나 클라이언트에 남아야 하므로 손대지 않는다. + +- 순수 함수: `notification/getNotificationDestination.ts`, `notification/sortNotifications.ts`, `authRedirect.ts`, `authStorage.ts`, `jsonLd.ts`, `seo.ts` +- 부가 기능: `lib/analytics/**`, `lib/sentry/**` +- 재수출: 각 도메인의 `index.ts` +- 인증과 실시간 통신에 계속 필요한 클라이언트: `lib/supabaseClient.ts` + +### 3.4 범위에서 제외하는 파일과 그 이유 + +#### `lib/auth.ts` — 전체 제외 + +`login`, `logout`, `deleteAccount`, `hasAuthenticatedSession`은 Supabase Auth SDK를 쓰므로 이관 대상이 아니다. DB를 조회하는 함수는 `getUserName`(`auth.ts:110`)과 `getUser`(`auth.ts:118`) 둘뿐인데, **저장소 전체에서 이 두 함수를 호출하는 곳이 없다.** `@/lib/auth`를 가져오는 곳은 세 군데이며 각각 `login`, `logout`과 `deleteAccount`, `hasAuthenticatedSession`만 사용한다. + +따라서 `auth.ts`는 이번 범위에서 완전히 빠진다. 미사용 함수 2개의 제거는 후속 이슈로 분리한다. + +#### `lib/termsAgreement.ts` — 전체 제외 + +이 파일은 SSR 및 쿠키 세션 전환 이슈에서 함께 다루는 편이 낫다. 이유는 네 가지다. + +첫째, `getCurrentUserRow`가 로그인 흐름에서 가장 불안정한 구간에 있다. `src/hooks/useAuthCallbackFlow.ts:35`에서 OAuth 콜백 직후 `supabase.auth.getUser()` 성공 바로 다음에 호출한다. 이 함수에 재시도 3회와 300ms 대기가 붙어 있는 것 자체가, 데이터베이스 트리거로 사용자 행이 생성되기를 기다리는 경합 구간이라는 뜻이다. Bearer 방식으로 바꾸면 `getSession()` 호출이 하나 더 끼어드는데, 그 시점은 access token이 URL에서 감지되어 저장된 직후다. 여기서 회귀가 나면 증상이 로그인 실패다. + +둘째, `src/app/protectRoute.tsx:65`가 경로가 바뀔 때마다 `getCurrentUserRow`를 호출한다. API를 거치면 `getSession`, fetch, 서버 `getUser`, DB 조회로 왕복이 늘어난다. 전체 이관 범위에서 체감 성능이 나빠질 가능성이 가장 큰 지점이다. + +셋째, `toUserStoreValue`(`termsAgreement.ts:20`)는 `authUser.app_metadata.provider`와 `user_metadata`를 사용하므로 서버로 옮길 수 없다. `getCurrentUserRow`만 서버로 보내면 호출부에서 두 값을 다시 합쳐야 하고, 그러면 API의 성격이 사용자 행 조회가 아니라 현재 사용자 컨텍스트 조립으로 바뀐다. + +넷째, 쿠키로 전환하면 `protectRoute`의 판정은 middleware로, `useAuthCallbackFlow`는 `/auth/callback`의 code exchange Route Handler로 옮겨간다. 지금 Bearer로 만들어 둔 코드가 재사용되지 않고 폐기된다. 다른 도메인은 `createRouteClient`만 교체하면 되지만 이 영역만 예외다. + +`agreeRequiredTerms` 하나는 위험이 낮아 포함할 수도 있었으나, 도메인이 절반만 이관된 상태로 남으면 다음 이슈에서 파악 비용이 생기므로 통째로 미룬다. + +### 3.5 이관 시 개별 처리가 필요한 항목 + +#### ① `getInviteUser`가 lib 안에서 스토어를 직접 읽는다 + +`src/lib/invite/getInviteUser.ts:3`이 `useUserStore.getState()`로 현재 사용자를 꺼낸다. 서버에서는 스토어에 접근할 수 없으므로, 서버가 토큰에서 얻은 `user.id`를 사용하도록 바꾼다. 클라이언트 lib의 시그니처는 그대로 두므로 호출부 수정은 없다. + +#### ② `createLinkInvite`는 절반만 이관한다 + +`src/lib/nbread/insertLink.ts:26`이 `window.location.origin`으로 초대 URL을 조립한다. 서버는 `invite_token`만 반환하고, URL 조립은 클라이언트 lib에 그대로 남긴다. + +#### ③ `insertParticipant`는 순수 이동만 한다 + +`src/lib/participant/insertParticipant.ts`는 `getNbread`, `participantUsers`, `isGetParticipantsUser`, `insert` 순으로 네 번 왕복하며 정원 초과를 검사한다. 검사와 삽입 사이에 경쟁 조건이 존재한다. + +이번 작업에서는 이 로직을 그대로 하나의 Route Handler 안으로 옮긴다. 결과적으로 브라우저와 서버 사이의 왕복은 1회로 줄지만, 경쟁 조건 자체는 그대로 남는다. **정원 검사와 참여 삽입을 원자적으로 처리하는 작업은 후속 이슈로 분리한다.** + +#### ④ 내부 헬퍼는 엔드포인트로 노출하지 않는다 + +`isGetParticipantsUser`와 `participantUsers`(`src/lib/participant/getParticipants.ts`)는 `insertParticipant` 전용 헬퍼다. `src/lib/server/participant/` 내부 함수로 흡수하고 API로 노출하지 않는다. + +`getInviteFriendList`(`src/lib/friend/getSearchFriend.ts`)는 호출부를 확인한 뒤 노출 여부를 판단한다. `확인 필요`. + +#### ⑤ `getInviteByToken`만 비로그인 접근을 허용한다 + +`src/app/invite/[token]/page.tsx`는 로그인 전에도 열린다. `src/app/protectRoute.tsx`의 공개 경로 판정에도 `/invite/`가 들어 있다. 따라서 `GET /api/invites/[token]`은 39개 중 유일하게 인증이 선택적인 엔드포인트다. `requireAuth`를 거치지 않고 anon 클라이언트로 처리한다. + +#### ⑥ 에러를 삼키는 동작을 그대로 유지한다 + +`lib/post/*`, `lib/friend/updateFriend.ts`, `lib/invite/sendInviteRequest.ts` 등이 `catch` 블록에서 아무 처리 없이 `undefined`를 반환한다. `lib/nbread/getUserNbread.ts`와 `lib/nbread/fetchNbreadData.ts`는 실패 시 빈 배열이나 `null`을 반환한다. + +순수 이동 원칙에 따라 이 동작을 그대로 유지한다. 클라이언트 lib이 HTTP 오류를 받으면 기존과 동일한 값으로 되돌려 준다. 그래야 UI 분기에 회귀가 생기지 않는다. 에러 처리 개선은 후속 이슈로 분리한다. + +#### ⑦ 직렬화가 불가능한 변환은 클라이언트에 남긴다 + +JSON 왕복이 끼어들면서 새로 생기는 제약이다. + +- `fetchNbreadData`(`src/lib/nbread/fetchNbreadData.ts:20`)의 `payment_date` → `Date` 변환 +- `getChatMessages`(`src/lib/chatMessage/getChatMessages.tsx:27`)와 `insertChatMessage`의 `formattedTime` 생성 + +서버는 원시 문자열 값을 내려보내고, 위 변환은 클라이언트 lib에서 수행한다. 반환 타입은 기존과 동일하게 유지한다. + +#### ⑧ `.or()` 문자열 보간을 그대로 옮긴다 + +`src/lib/friend/getSearchFriend.ts:32`와 `src/lib/friend/sendFriendRequest.ts:11`이 필터 조건을 문자열로 조립한다. 값이 UUID라 위험은 낮으므로 이번에는 그대로 옮기고 기록만 남긴다. + +--- + +## 4. app/api 디렉토리 구조 + +``` +src/app/api/ +├── _lib/ route.ts가 아니므로 라우팅되지 않는다 +│ ├── supabaseRouteClient.ts createRouteClient(accessToken) +│ ├── requireAuth.ts Bearer 파싱 + getUser 검증 → { user, client } +│ └── response.ts ok() / fail() 응답 헬퍼 +│ +├── auth/delete-account/route.ts (기존) +├── sentry-webhook/route.ts (기존) +│ +├── users/ +│ └── search/route.ts GET +│ +├── nbreads/ +│ ├── route.ts GET / POST +│ ├── summary/route.ts GET +│ ├── records/route.ts GET +│ └── [nbreadId]/ +│ ├── route.ts GET / PATCH / DELETE +│ ├── records/route.ts GET / PATCH +│ ├── participants/route.ts GET / POST / DELETE +│ ├── posts/route.ts GET / POST +│ ├── posts/[postId]/route.ts PATCH / DELETE +│ ├── messages/route.ts GET / POST +│ └── invites/ +│ ├── route.ts POST +│ ├── link/route.ts POST +│ └── candidates/route.ts GET +│ +├── invites/ +│ ├── pending/route.ts GET +│ └── [token]/ +│ ├── route.ts GET ← 인증 선택 +│ └── response/route.ts POST +│ +├── friends/ +│ ├── route.ts GET +│ └── requests/route.ts POST / PATCH +│ +├── notifications/ +│ ├── route.ts GET / DELETE +│ ├── [notificationId]/route.ts PATCH / DELETE +│ └── settings/route.ts GET / PATCH +│ +└── fcm-tokens/route.ts PUT +``` + +신규 Route Handler 파일은 22개이고 기존 파일 2개는 그대로 둔다. 경로와 메서드 조합이 lib 함수와 1대1로 대응한다. + +서버 쿼리 코드는 다음 위치에 둔다. + +``` +src/lib/server/ +├── nbread/ nbreadRecord/ notification/ +├── fcmToken/ participant/ invite/ +├── friend/ post/ chatMessage/ +``` + +--- + +## 5. API 명세 + +### 5.1 공통 규약 + +| 항목 | 규약 | +|---|---| +| 인증 | `Authorization: Bearer `을 보낸다. `GET /api/invites/[token]`만 예외다 | +| 성공 응답 | `200 { "data": ... }`, 생성은 `201`, 본문이 없으면 `204`를 쓴다 | +| 실패 응답 | `{ "message": string, "code"?: string }` 형태를 쓴다 | +| 상태 코드 | `400` 입력 검증 실패, `401` 토큰 없음 또는 만료, `403` 권한 없음, `404` 대상 없음, `409` 중복 또는 정원 초과, `500` 그 외 | +| 런타임 | 모든 Route Handler에 `export const runtime = 'nodejs'`를 선언한다 | +| 캐시 | `export const dynamic = 'force-dynamic'`을 선언하고 클라이언트 fetch에 `cache: 'no-store'`를 붙인다 | +| 네이밍 | 요청과 응답 본문은 camelCase를 쓴다. snake_case 변환은 서버가 담당한다 | +| 사용자 식별 | 서버는 항상 토큰의 `user.id`를 기준으로 동작한다. 클라이언트 lib의 `userId` 인자는 시그니처에 남기되 서버는 무시한다. 다른 사용자의 id를 다루는 경우에만 본문으로 받는다 | +| 401 처리 | 클라이언트 `apiClient`가 401을 받으면 `getSession()`으로 세션을 갱신하고 1회 재시도한다. 그래도 실패하면 로그아웃 처리한다 | + +### 5.2 매핑표 + +#### nbread + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `getUserNbreads` | `GET /api/nbreads` | — | `{ monthlyNbreads, myNbreads }` | +| `insertNbread` | `POST /api/nbreads` | `Nbread` | `201 { id }` | +| `getNbread` | `GET /api/nbreads/[nbreadId]` | — | `Nbread` | +| `updateNbread` | `PATCH /api/nbreads/[nbreadId]` | `Nbread` | `204` | +| `deleteNbread` | `DELETE /api/nbreads/[nbreadId]` | — | `204` | +| `getUserTotalNbreadAmount` | `GET /api/nbreads/summary` | — | `{ totalAmount }` | +| `fetchNbreadData` | `GET /api/nbreads/records` | — | `{ nbreadId, paymentDate }[]` | +| `createLinkInvite` | `POST /api/nbreads/[nbreadId]/invites/link` | — | `201 { inviteToken }` | + +#### nbreadRecord + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `getNbreadRecords` | `GET /api/nbreads/[nbreadId]/records?startDate=` | — | `NbreadRecord[]` | +| `updateNbreadRecord` | `PATCH /api/nbreads/[nbreadId]/records` | `{ userId, isPaid, startDate }` | `204` | + +`userId`를 본문으로 받는 이유는 다른 참여자의 납부 상태를 변경하는 기능이기 때문이다. + +#### participant + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `getParticipants` | `GET /api/nbreads/[nbreadId]/participants` | — | `Participant[]` | +| `insertParticipant` | `POST /api/nbreads/[nbreadId]/participants` | `{ isLeader }` | `{ isInsert, title, subTitle, buttonTitle }` | +| `deleteParticipants` | `DELETE /api/nbreads/[nbreadId]/participants?userId=` | — | `204` | +| `isGetParticipantsUser` | 노출하지 않는다 | | 서버 내부 헬퍼다 | +| `participantUsers` | 노출하지 않는다 | | 서버 내부 헬퍼다 | + +#### post + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `getPost` | `GET /api/nbreads/[nbreadId]/posts` | — | `PostRow[]` | +| `InsertPost` | `POST /api/nbreads/[nbreadId]/posts` | `PostInsert` | `201` | +| `UpdatePost` | `PATCH /api/nbreads/[nbreadId]/posts/[postId]` | `{ content }` | `204` | +| `deletePost` | `DELETE /api/nbreads/[nbreadId]/posts/[postId]` | — | `204` | + +#### chatMessage + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `getChatMessages` | `GET /api/nbreads/[nbreadId]/messages` | — | `ChatMessageRow[]` | +| `insertChatMessage` | `POST /api/nbreads/[nbreadId]/messages` | `{ content }` | `201 ChatMessageRow` | + +`formattedTime`은 클라이언트 lib에서 생성한다. 실시간 구독(`src/components/chat/ChatRoom.tsx:140`)은 그대로 클라이언트에 남긴다. + +#### invite + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `getInviteByToken` | `GET /api/invites/[token]` | — | `InviteDetails \| null` (**인증 선택**) | +| `getPendingInvites` | `GET /api/invites/pending` | — | `PendingInvite[]` | +| `respondToInvite` | `POST /api/invites/[token]/response` | `{ response }` | `InviteResponseResult` | +| `sendInviteRequest` | `POST /api/nbreads/[nbreadId]/invites` | `{ targetUserId }` | `{ status, inviteToken }[]` | +| `getInviteUser` | `GET /api/nbreads/[nbreadId]/invites/candidates?tag=` | — | `{ id, profile_image, name, status }[]` | + +`respondToInvite`는 `respond_to_nbread_invite` RPC를 호출한다. Route Handler에서도 동일하게 RPC로 호출하며 함수의 보안 속성은 건드리지 않는다. + +#### friend + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `getSearchFriend` | `GET /api/users/search?tag=` | — | `{ name, profileImage, senderId, receiverId, status }[]` | +| `getFriendList` | `GET /api/friends?nbreadId=` | — | `FriendListItem[]` | +| `sendFriendRequest` | `POST /api/friends/requests` | `{ receiverId, status }` | `{ status }[]` | +| `updateAcceptFriend` | `PATCH /api/friends/requests` | `{ senderId, status: 'accepted' }` | `204` | +| `updateRejectedFriend` | `PATCH /api/friends/requests` | `{ senderId, status: 'rejected' }` | `204` | +| `getInviteFriendList` | 호출부 확인 후 결정한다 | | `확인 필요` | + +#### notification + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `getNotification` | `GET /api/notifications` | — | `Notification[]` | +| `deleteAllNotifications` | `DELETE /api/notifications` | — | `204` | +| `markNotificationAsRead` | `PATCH /api/notifications/[notificationId]` | `{ isRead: true }` | `204` / `404` | +| `deleteNotification` | `DELETE /api/notifications/[notificationId]` | — | `204` / `404` | +| `getNotificationState` | `GET /api/notifications/settings` | — | `NotificationSettings` | +| `updateNotificationState` | `PATCH /api/notifications/settings` | `NotificationSettingsUpdate` | `NotificationSettings` | + +`getNotification`의 정렬(`sortNotifications`)은 순수 함수이므로 클라이언트에 남긴다. `getNotificationState`는 설정 행이 없으면 기본값을 생성해 반환하는 현재 동작을 그대로 유지한다. `markNotificationAsRead`와 `deleteNotification`은 영향받은 행이 1개가 아니면 오류를 내는 현재 동작을 `404`로 매핑한다. + +#### fcmToken + +| lib 함수 | 메서드 · 경로 | 요청 | 응답 | +|---|---|---|---| +| `upsertFcmToken` | `PUT /api/fcm-tokens` | `{ fcmToken }` | `204` | + +--- + +## 6. 리스크 + +| 항목 | 내용 | 대응 | +|---|---|---| +| 왕복 지연 증가 | 브라우저에서 Supabase로 직행하던 요청이 브라우저 → 배포 서버 → Supabase로 늘어난다. 배포 리전과 Supabase 리전이 멀면 체감 지연이 생긴다 | 파일럿 단계에서 실측하고, 문제가 되면 리전 배치를 검토한다 | +| RLS 정책 원본 부재 | 정책이 대시보드에서 관리되고 있어 마이그레이션에 SQL이 남아 있지 않다. `supabase/migrations/`에 `create policy` 구문이 하나도 없다 | 이번 작업은 RLS를 그대로 유지하므로 영향이 없다. 다만 후속 이슈에서 service role로 전환할 때는 서버에서 재구현할 권한 규칙의 원본이 필요하므로 그 시점에 정책을 다시 확인한다 | +| Sentry 이슈 분류 변경 | `captureAppError` 호출이 클라이언트에서 서버로 이동하면서 이슈가 서버 이슈로 분류된다 | 태그로 구분할 수 있도록 `action` 값을 유지한다 | +| 토큰 만료 처리 | Bearer 방식은 매 요청마다 유효한 토큰이 필요하다 | `apiClient`에서 401을 받으면 `getSession()`으로 갱신 후 1회 재시도한다 | +| 요청당 검증 왕복 | `requireAuth`가 매 요청 `getUser(token)`을 호출하므로 Supabase 왕복이 1회 추가된다 | 명확한 401 응답과 Sentry 사용자 식별을 위해 감수한다 | +| 클라이언트 재사용 사고 | JWT를 바인딩한 클라이언트를 모듈 최상위에 두면 다른 사용자의 토큰으로 쿼리가 나간다 | `createRouteClient`는 요청마다 호출하도록 강제하고 코드 리뷰 항목으로 지정한다 | + +--- + +## 7. 후속 이슈 목록 + +이번 작업 범위에서 의도적으로 제외했으며 별도 이슈가 필요한 항목이다. + +1. **SSR 및 쿠키 세션 전환** — `@supabase/ssr` 도입, `middleware.ts` 추가, `createRouteClient`를 `createServerClient`로 교체, `lib/termsAgreement.ts`와 `protectRoute.tsx`, `useAuthCallbackFlow.ts` 정리 +2. **`insertParticipant` 정원 검사 원자화** — 정원 확인과 참여 삽입 사이의 경쟁 조건을 RPC 또는 트랜잭션으로 제거한다 +3. **service role 전환과 서버 측 권한 검증** — RLS 우회 대상 엔드포인트를 선별하고 권한 규칙을 서버에 구현한다 +4. **`getUserNbreads` N+1 제거** — `src/lib/nbread/getUserNbread.ts:60`이 엔빵 개수만큼 count 쿼리를 반복한다 +5. **미사용 코드 제거** — `lib/auth.ts`의 `getUserName`, `getUser` +6. **에러 처리 규약 통일** — `catch` 블록에서 값을 삼키는 함수들을 정리한다 +7. **`lib/post` 타입 정리** — `any` 사용을 제거한다 +8. **클라이언트 lib의 `userId` 인자 제거** — 서버가 토큰 기준으로 동작하므로 인자가 불필요해진다 + +--- + +## 8. PR 분리 계획 + +`AGENTS.md`의 "하나의 Issue는 가능한 하나의 PR로 구현한다" 원칙에 따라 아래와 같이 나눈다. + +| 단계 | 범위 | 산출물 | +|---|---|---| +| 0 | 공통 기반 + 파일럿 | `src/lib/apiClient.ts`, `src/app/api/_lib/*`, `notification` 6함수, `fcmToken` 1함수 | +| 1 | `nbread`, `nbreadRecord` | 10함수 | +| 2 | `participant`, `invite` | 10함수 | +| 3 | `friend`, `users/search` | 6함수 | +| 4 | `post`, `chatMessage` | 6함수 | + +0단계에서 응답 형식, 401 처리, `requireAuth`, `createRouteClient`를 모두 검증한 뒤 나머지 단계는 같은 규약을 기계적으로 따른다. 파일럿으로 `notification`과 `fcmToken`을 고른 이유는 단순 CRUD이고 다른 도메인과의 결합이 없어 회귀 위험이 가장 낮기 때문이다. + +각 단계마다 `npm run lint`와 `npm run build`를 확인하고, 해당 기능에 Playwright 테스트가 있으면 함께 검증한다. + +--- + +## 확인 필요 + +- `getInviteFriendList`(`src/lib/friend/getSearchFriend.ts`)의 실제 호출부가 있는지 확인하고 엔드포인트 노출 여부를 결정한다. +- 배포 리전과 Supabase 리전의 물리적 거리에 따른 지연 증가폭을 파일럿 단계에서 실측한다. +- `lib/post`의 `PostRow` 대응 타입이 `src/types/post.ts`에 정의되어 있는지 확인한다. + +## 다시 물어볼 질문 + +- 파일럿 단계 실측 결과 지연 증가가 허용 범위를 넘으면 어떤 대안을 택할 것인가? +- SSR 전환 이슈에서 `protectRoute.tsx`의 역할을 middleware로 어디까지 옮길 것인가? From 3d0083bba2ab014abba176cb2014c4873550dac7 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 18 Aug 2026 16:31:31 +0900 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20FAQ=20=EB=9E=9C=EB=94=A9=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 랜딩페이지와 검색 관련 코드(page.tsx, jsonLd.ts, landing.spec.ts) 조사 - FAQ 데이터 원본 공용화, 구조화 데이터 연동 방향 설계 - 작업 분리(Task 1~5)와 리스크, 완료 조건 정리 Co-Authored-By: Claude Sonnet 5 --- llm-wiki/index.md | 1 + llm-wiki/log.md | 1 + ...18-faq-landing-page-implementation-plan.md | 216 ++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 llm-wiki/output/2026-08-18-faq-landing-page-implementation-plan.md diff --git a/llm-wiki/index.md b/llm-wiki/index.md index 95e048c..475538a 100644 --- a/llm-wiki/index.md +++ b/llm-wiki/index.md @@ -16,5 +16,6 @@ ## 최근 산출물 +- [FAQ 랜딩페이지 추가 구현 설계서](output/2026-08-18-faq-landing-page-implementation-plan.md) - [클라이언트 Supabase 호출을 app/api로 이관하는 구현 설계서](output/2026-08-14-github-issue-170-supabase-client-to-app-api-migration-plan.md) diff --git a/llm-wiki/log.md b/llm-wiki/log.md index b291e6f..f0f49a3 100644 --- a/llm-wiki/log.md +++ b/llm-wiki/log.md @@ -9,3 +9,4 @@ | 2026-08-13 | 첫 `wiki/` 문서 `llm-wiki-background-and-structure.md` 작성 | 배경, 해결 범위, 구조, 다음 질문 정리 | 이슈 #193 "후속 문서 작성 및 에이전트 연동 범위 정리" 작업 미완료 | | 2026-08-13 | GitHub 이슈 #195을 `raw/2026-08-13-github-issue-195-naver-search-advisor.md`에 저장 | 네이버 서치어드바이저 등록 작업 기획서 원본 보존 | 아직 `wiki/` 정리 문서 미작성 | | 2026-08-14 | `src/lib/**` 전수 조사 후 `output/2026-08-14-github-issue-170-supabase-client-to-app-api-migration-plan.md` 작성 | 이관 대상 28파일 39함수 확정, Route Handler 22개 구조와 API 명세 정리, 후속 이슈 8건 도출 | `getInviteFriendList` 호출부 확인, 왕복 지연 실측 | +| 2026-08-18 | 랜딩페이지와 검색 관련 코드 조사 후 `output/2026-08-18-faq-landing-page-implementation-plan.md` 작성 | FAQ 섹션, 공용 콘텐츠 원본, 구조화 데이터와 플레이wright 검증 범위 설계 | 최종 문항과 답변, 콘텐츠 목업, 펼침 방식 확인 | diff --git a/llm-wiki/output/2026-08-18-faq-landing-page-implementation-plan.md b/llm-wiki/output/2026-08-18-faq-landing-page-implementation-plan.md new file mode 100644 index 0000000..947aef6 --- /dev/null +++ b/llm-wiki/output/2026-08-18-faq-landing-page-implementation-plan.md @@ -0,0 +1,216 @@ +# FAQ 랜딩페이지 추가 구현 설계서 + +## 한 문장 요약 + +공개 랜딩페이지에 서비스별 구독 공유 정산 질문을 다루는 FAQ 섹션을 추가하고, 화면 콘텐츠와 구조화 데이터가 어긋나지 않도록 같은 데이터 원본을 사용하여 서비스명과 엔빵을 조합한 검색어의 노출 기반을 넓힌다. + +## 근거 + +- 원천 자료: 사용자 제공 노션 요약과 저장소 코드 직접 조사 (`src/app/page.tsx`, `src/components/landing/**`, `src/lib/jsonLd.ts`, `e2e/landing.spec.ts`) +- 확인 날짜: 2026-08-18 +- 자료 성격: 기획 요약, 코드 +- 관련 문서: [llm-wiki 배경과 구조](../wiki/llm-wiki-background-and-structure.md) +- 관련 이슈: [GitHub Issue #200](https://github.com/andbread/Andbread_Frontend/issues/200) + +--- + +## 1. 배경과 목적 + +### 1.1 현재 상태 + +공개 랜딩페이지 배포 뒤 검색 노출 발생일은 14%에서 81%로, 일평균 검색 노출은 0.14에서 1.90으로 늘었다. 현재 확인된 노출 키워드 중에서는 `넷플릭스 엔빵`의 노출이 가장 높아 `서비스명 + 엔빵` 형태의 검색 수요가 있음을 확인했다. + +현재 랜딩페이지는 엔빵의 문제 정의, 해결 방식, 핵심 기능, 사용 방법과 시작하기 유도 영역으로 구성되어 있다. 넷플릭스, 유튜브 프리미엄, 왓챠 등 서비스명은 소개 문구에 일부 포함되어 있지만, 사용자가 검색하는 구체적인 질문과 답변을 다루는 영역은 없다. + +### 1.2 이번 작업의 목표 + +- 공개 랜딩페이지에 FAQ 콘텐츠를 추가한다. +- 서비스명과 엔빵을 조합한 검색어를 자연스럽게 포함할 수 있는 콘텐츠 기반을 마련한다. +- 질문과 답변을 검색 로봇과 보조 기술이 이해할 수 있는 의미 구조로 제공한다. +- 화면에 표시하는 FAQ와 FAQ 구조화 데이터가 같은 내용을 유지하도록 한다. +- 기존 랜딩페이지의 모바일 중심 배치, 색상, 간격, 스크롤 진입 효과를 따른다. + +### 1.3 이번 작업의 비목표 + +- FAQ 문항과 답변의 최종 문구를 이 설계서에서 확정하지 않는다. 최종 콘텐츠는 별도 목업을 전달받은 뒤 확정한다. +- 목업에 없는 FAQ 검색, 분류, 별도 상세 페이지, 관리자 편집 기능은 만들지 않는다. +- 랜딩페이지의 기존 섹션이나 전체 정보 구조를 다시 설계하지 않는다. +- 검색 노출 순위나 노출량의 개선을 완료 조건으로 삼지 않는다. 검색엔진 반영 지연과 작은 측정 모수 때문에 이번 구현만으로 인과관계를 확정할 수 없다. +- 메타데이터의 전면 개편이나 별도 검색 최적화 작업은 진행하지 않는다. + +--- + +## 2. 코드베이스 확인 결과 + +### 2.1 랜딩페이지 구성 + +- `src/app/page.tsx`는 서버 컴포넌트이며 모든 랜딩 섹션을 위에서 아래로 직접 조합한다. +- 반복 콘텐츠는 `painCards`, `solutionPoints`, `featureCards`, `steps`와 같은 정적 배열에서 렌더링한다. +- 각 섹션은 `px-24`, `py-48` 또는 `py-54`를 중심으로 구성하고, 배경색을 번갈아 사용한다. +- 섹션 제목은 페이지 내부의 `SectionHeading`을 재사용한다. +- 화면 진입 효과는 `src/components/landing/RevealOnScroll.tsx`가 담당한다. 이 파일만 클라이언트 컴포넌트이고 나머지 랜딩 콘텐츠는 서버에서 렌더링된다. +- 마지막 시작하기 영역 바로 앞이 FAQ를 추가하기에 자연스러운 위치로 보이지만, 최종 위치와 배경색은 목업 확인이 필요하다. + +### 2.2 검색 관련 구성 + +- 랜딩페이지 메타데이터는 `src/lib/seo.ts`의 `createPageMetadata`를 사용한다. +- `src/lib/jsonLd.ts`의 `landingPageJsonLd`는 현재 `Organization`, `WebSite`, `WebApplication` 정보를 제공한다. +- `src/components/seo/JsonLdScript.tsx`가 구조화 데이터를 안전하게 직렬화하여 페이지에 삽입한다. +- FAQ 구조화 데이터는 현재 없다. + +### 2.3 테스트 구성 + +- `e2e/landing.spec.ts`에 랜딩페이지 제목, 주요 제목과 로그인 링크를 확인하는 플레이wright 테스트가 있다. +- FAQ 추가 시 같은 테스트 파일에 FAQ 노출과 상호작용을 검증하는 것이 기존 범위에 가장 가깝다. + +--- + +## 3. 구현 방향 + +### 3.1 FAQ 데이터 원본을 하나로 유지한다 + +질문과 답변 목록은 타입이 있는 정적 데이터로 정의하고 화면과 구조화 데이터가 함께 사용한다. 이렇게 하면 문구 수정 시 화면에는 바뀌었지만 구조화 데이터에는 이전 문구가 남는 문제를 막을 수 있다. + +데이터를 `src/app/page.tsx` 안에 둘지 `src/components/landing/FaqSection.tsx`와 가까운 별도 파일에 둘지는 최종 컴포넌트 경계를 정할 때 결정한다. 화면과 `src/lib/jsonLd.ts`가 함께 가져와야 한다면 순환 참조를 피할 수 있는 공용 모듈로 분리한다. 정확한 파일 위치는 `확인 필요`다. + +초안으로 전달된 다음 네 문항은 콘텐츠 검토를 위한 참고 자료로만 사용한다. + +1. 엔빵은 어떻게 시작하나요? +2. 넷플릭스 구독료는 어떻게 나눠서 정산하나요? +3. 유튜브 프리미엄 가족 요금제는 어떻게 정산하나요? +4. 왓챠나 티빙도 구독 공유 정산이 가능한가요? + +최종 질문, 답변, 표기 방식은 목업을 전달받은 뒤 확정한다. 서비스 정책이나 실제 지원 범위를 확인하지 않은 답변을 추측해서 작성하지 않는다. + +### 3.2 기존 랜딩 컴포넌트 패턴을 따른다 + +`src/components/landing/FaqSection.tsx`를 추가하고 `src/app/page.tsx`에서 기존 섹션과 같은 방식으로 조합한다. FAQ 섹션은 다음 원칙을 따른다. + +- `SectionHeading`과 `RevealOnScroll`을 재사용할 수 있도록 필요한 범위만 조정한다. +- 기존 최대 너비와 모바일 우선 배치를 유지한다. +- 제목과 답변은 의미에 맞는 제목과 본문 요소로 렌더링한다. +- 목업이 펼침과 접힘을 요구하면 키보드와 보조 기술로 조작 가능한 요소를 사용하고 상태를 알 수 있는 속성을 제공한다. +- 목업이 모든 답변을 펼쳐 보이는 형태라면 불필요한 클라이언트 상태를 추가하지 않는다. + +`SectionHeading`은 현재 `src/app/page.tsx` 내부에 있어 새 컴포넌트에서 바로 재사용할 수 없다. FAQ만을 위해 공용 컴포넌트로 옮길지, FAQ 안에서 같은 시각 규칙을 적용할지는 목업과 중복 정도를 본 뒤 최소 변경안을 선택한다. 이 판단은 `확인 필요`다. + +### 3.3 FAQ 구조화 데이터를 함께 제공한다 + +기존 `landingPageJsonLd`의 `@graph`에 `FAQPage` 항목을 추가하고, 화면에 실제 표시되는 최종 질문과 답변만 `mainEntity`에 넣는다. 구조화 데이터가 검색 결과의 별도 표시를 보장하지는 않지만, 페이지 콘텐츠의 의미를 기계가 이해할 수 있는 형태로 제공한다. + +구조화 데이터와 화면이 같은 FAQ 데이터 원본을 사용하게 하고, 숨겨진 키워드나 화면에 없는 답변은 넣지 않는다. 문항이 확정되기 전에는 임시 문구로 구조화 데이터를 배포하지 않는다. + +### 3.4 검토한 대안 + +| 대안 | 판단 | 이유 | +|---|---|---| +| FAQ를 `src/app/page.tsx`에 모두 직접 작성한다 | 선택하지 않는다 | 페이지 파일이 이미 여러 섹션과 정적 데이터를 포함하고 있어 FAQ의 표시와 상호작용까지 추가하면 책임이 더 커진다 | +| FAQ를 별도 경로로 만든다 | 선택하지 않는다 | 이번 목표는 기존 공개 랜딩페이지의 검색 콘텐츠를 보강하는 것이며 별도 페이지 요구사항이 없다 | +| 구조화 데이터에만 FAQ를 추가한다 | 선택하지 않는다 | 사용자에게 보이지 않는 콘텐츠를 구조화 데이터에만 넣으면 화면과 데이터가 불일치한다 | +| 화면과 구조화 데이터의 문항을 각각 관리한다 | 선택하지 않는다 | 콘텐츠가 바뀔 때 두 위치가 어긋날 위험이 있다 | + +--- + +## 4. 작업 분리 + +이번 범위는 한 이슈와 한 풀 리퀘스트로 구현 가능한 크기다. 목업과 최종 문항이 전달된 뒤 아래 순서로 진행한다. + +### Task 1. 목업과 콘텐츠 확정 + +- 전달받은 목업에서 섹션 위치, 배경색, 간격, 질문 항목의 표시 방식과 펼침 동작을 확인한다. +- 최종 질문과 답변이 실제 엔빵 기능 및 정책과 맞는지 확인한다. +- 목업과 초안 사이에 차이가 있으면 목업과 별도 확정 내용을 기준으로 범위를 기록한다. + +### Task 2. FAQ 데이터와 섹션 컴포넌트 추가 + +- 질문과 답변의 타입과 정적 데이터 원본을 정의한다. +- `src/components/landing/FaqSection.tsx`를 추가한다. +- 최종 목업에 따라 정적 목록 또는 접근 가능한 펼침 목록으로 구현한다. +- 기존 `RevealOnScroll`과 디자인 토큰을 재사용한다. + +### Task 3. 랜딩페이지에 FAQ 섹션 배치 + +- `src/app/page.tsx`에 FAQ 섹션을 추가한다. +- 기존 섹션 순서와 시작하기 흐름이 유지되는지 확인한다. +- `SectionHeading` 재사용을 위한 변경이 필요하다면 랜딩 영역 안에서 최소 범위로만 조정한다. + +### Task 4. FAQ 구조화 데이터 연결 + +- `src/lib/jsonLd.ts`의 랜딩페이지 그래프에 `FAQPage` 정보를 추가한다. +- 화면과 구조화 데이터가 같은 최종 질문과 답변을 사용하게 한다. +- 생성된 제이슨 엘디가 화면에 없는 내용이나 임시 문구를 포함하지 않는지 확인한다. + +### Task 5. 검증 + +- `e2e/landing.spec.ts`에 FAQ 제목과 최종 문항 노출을 확인하는 시나리오를 추가한다. +- 펼침 방식이면 키보드 또는 클릭으로 답변을 열고 닫는 동작을 확인한다. +- 기존 시작하기 링크와 랜딩 섹션이 그대로 동작하는지 확인한다. +- `npm run lint`, `npm run build`, 영향받은 플레이wright 테스트를 실행한다. + +--- + +## 5. 영향 범위 + +### 변경 예정 + +- `src/app/page.tsx`: FAQ 섹션 배치 +- `src/components/landing/FaqSection.tsx`: FAQ 화면 구성 요소 추가 +- FAQ 정적 데이터 모듈: 정확한 위치는 확인 필요 +- `src/lib/jsonLd.ts`: FAQ 구조화 데이터 추가 +- `e2e/landing.spec.ts`: FAQ 노출과 동작 검증 추가 + +### 변경하지 않음 + +- 로그인 뒤 서비스 화면과 인증 흐름 +- Supabase 스키마, 데이터, 인증, 보안 정책 +- 상태 관리 저장소와 서버 통신 +- 기존 메타데이터 전반과 사이트맵 설정 +- 기존 랜딩페이지 문구와 기능 카드의 내용 + +--- + +## 6. 제약사항 + +- 콘텐츠 목업과 문항 최종본을 받기 전에는 실제 구현을 시작하지 않는다. +- 서비스명과 키워드를 반복하기 위해 부자연스러운 문장을 만들지 않는다. +- 질문과 답변은 실제 제공 기능과 정책을 넘어 추측하지 않는다. +- FAQ 구조화 데이터에는 화면에서 사용자가 확인할 수 있는 내용만 포함한다. +- 접힘 상태를 사용하더라도 서버가 질문과 답변 본문을 렌더링해 검색 로봇과 자바스크립트를 쓰지 않는 환경에서도 콘텐츠를 읽을 수 있어야 한다. +- 기존 랜딩페이지의 공개 접근, 로그인 링크와 마지막 시작하기 흐름을 유지한다. +- 새 의존성은 추가하지 않는다. + +--- + +## 7. 리스크와 대응 + +| 리스크 | 영향 | 대응 | +|---|---|---| +| 최종 문항과 답변이 미확정이다 | 추측한 서비스 정책이나 목업과 다른 화면을 구현할 수 있다 | 목업과 확정 문구를 받은 뒤 Task 1을 완료하고 구현한다 | +| 검색 효과를 짧은 기간에 판단하기 어렵다 | 구현 직후 성과를 잘못 해석할 수 있다 | 검색 노출은 별도 관찰 지표로 두고 구현 완료 조건과 분리한다 | +| 화면과 구조화 데이터가 어긋날 수 있다 | 검색 로봇이 실제 화면과 다른 내용을 읽을 수 있다 | 같은 타입이 있는 데이터 원본에서 두 출력을 만든다 | +| 펼침 목록의 접근성이 빠질 수 있다 | 키보드 및 보조 기술 사용자가 답변을 이용하기 어렵다 | 목업이 펼침 방식을 요구하면 의미 요소와 상태 속성을 쓰고 동작 테스트를 추가한다 | +| 클라이언트 컴포넌트 범위가 커질 수 있다 | 불필요한 자바스크립트가 늘고 서버 렌더링 경계가 흐려질 수 있다 | 상호작용이 필요한 최소 구성 요소만 클라이언트로 두고 정적 형태면 서버 컴포넌트로 유지한다 | +| 긴 답변이 모바일 화면 흐름을 해칠 수 있다 | 마지막 시작하기 영역 도달성과 가독성이 떨어질 수 있다 | 목업 기준으로 줄바꿈과 간격을 확인하고 320픽셀부터 600픽셀 범위에서 검증한다 | + +--- + +## 8. 완료 조건 + +- 확정된 FAQ 질문과 답변이 공개 랜딩페이지에 표시된다. +- FAQ 섹션이 전달받은 목업과 기존 랜딩페이지 스타일을 따른다. +- 상호작용이 있다면 마우스, 터치와 키보드로 사용할 수 있고 상태가 보조 기술에 전달된다. +- 화면 FAQ와 `FAQPage` 구조화 데이터의 질문 및 답변이 일치한다. +- 기존 시작하기 링크와 랜딩페이지 주요 콘텐츠가 정상 동작한다. +- 랜딩페이지 플레이wright 테스트가 FAQ를 포함해 통과한다. +- `npm run lint`와 `npm run build`가 통과한다. + +--- + +## 확인 필요 + +- 최종 FAQ 질문과 답변 문구 +- FAQ 콘텐츠 목업과 섹션의 정확한 위치 및 배경색 +- 답변을 항상 펼쳐 둘지, 한 항목씩 또는 여러 항목을 펼칠 수 있게 할지 +- 질문 항목에 사용할 아이콘과 열림 및 닫힘 상태 표현 +- `SectionHeading`을 별도 파일로 옮겨 재사용할지 FAQ 안에서 최소한으로 구성할지 +- FAQ 데이터 원본의 파일 위치 From 688d422507c3c56efde24a967a469ebb0aa0dbf7 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 18 Aug 2026 16:31:49 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EB=9E=9C=EB=94=A9=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=20FAQ=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/faq.ts에 질문/답변 공용 데이터 원본 정의 - 기존 SectionHeading, RevealOnScroll 재사용해 FAQ 섹션 배치 - FaqAccordionItem 클라이언트 컴포넌트로 4개 항목 독립 개폐, 첫 항목 기본 열림, 트랜지션 적용 - jsonLd.ts의 @graph에 FAQPage 추가해 화면과 같은 데이터 원본 사용 - angle-up/angle-down 아이콘 추가 및 Icon 컴포넌트에 등록 Co-Authored-By: Claude Sonnet 5 --- src/app/page.tsx | 23 ++++++ src/assets/icons/angle-down.svg | 10 +++ src/assets/icons/angle-up.svg | 3 + src/components/common/icon/Icon.tsx | 4 + src/components/landing/FaqAccordionItem.tsx | 82 +++++++++++++++++++++ src/lib/faq.ts | 32 ++++++++ src/lib/jsonLd.ts | 13 ++++ 7 files changed, 167 insertions(+) create mode 100644 src/assets/icons/angle-down.svg create mode 100644 src/assets/icons/angle-up.svg create mode 100644 src/components/landing/FaqAccordionItem.tsx create mode 100644 src/lib/faq.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index cea5da3..75148cf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,9 +3,11 @@ import Image from 'next/image' import type { ReactNode } from 'react' import Icon, { type IconType } from '@/components/common/icon/Icon' import NbreadsImage from '@/components/common/nbreadImage/NbreadsImage' +import FaqAccordionItem from '@/components/landing/FaqAccordionItem' import RevealOnScroll from '@/components/landing/RevealOnScroll' import JsonLdScript from '@/components/seo/JsonLdScript' import NbreadLogo from '@/assets/logo/nbread-logo-text.svg' +import { faqItems } from '@/lib/faq' import { landingPageJsonLd } from '@/lib/jsonLd' import { createPageMetadata } from '@/lib/seo' @@ -352,6 +354,27 @@ export default function LandingPage() { +
+ + +

+ 가장 많이 물어보시는 질문을 모았어요. +

+
+ +
+ {faqItems.map((item, index) => ( + + + + ))} +
+
+
diff --git a/src/assets/icons/angle-down.svg b/src/assets/icons/angle-down.svg new file mode 100644 index 0000000..05a0999 --- /dev/null +++ b/src/assets/icons/angle-down.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/angle-up.svg b/src/assets/icons/angle-up.svg new file mode 100644 index 0000000..1234365 --- /dev/null +++ b/src/assets/icons/angle-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/icon/Icon.tsx b/src/components/common/icon/Icon.tsx index a045f37..0b22977 100644 --- a/src/components/common/icon/Icon.tsx +++ b/src/components/common/icon/Icon.tsx @@ -1,5 +1,7 @@ +import AngleDown from '@/assets/icons/angle-down.svg' import AngleLeft from '@/assets/icons/angle-left.svg' import AngleRight from '@/assets/icons/angle-right.svg' +import AngleUp from '@/assets/icons/angle-up.svg' import Badge from '@/assets/icons/badge.svg' import Calendar from '@/assets/icons/calendar.svg' import Check from '@/assets/icons/check.svg' @@ -16,8 +18,10 @@ import Profile from '@/assets/icons/profile.svg' import Alarm from '@/assets/icons/alarm.svg' const iconMap = { + angleDown: AngleDown, angleLeft: AngleLeft, angleRight: AngleRight, + angleUp: AngleUp, badge: Badge, calendar: Calendar, check: Check, diff --git a/src/components/landing/FaqAccordionItem.tsx b/src/components/landing/FaqAccordionItem.tsx new file mode 100644 index 0000000..af9f468 --- /dev/null +++ b/src/components/landing/FaqAccordionItem.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useId, useState } from 'react' +import Icon from '@/components/common/icon/Icon' + +interface FaqAccordionItemProps { + question: string + answer: string[] + defaultOpen?: boolean +} + +const FaqAccordionItem = ({ + question, + answer, + defaultOpen = false, +}: FaqAccordionItemProps) => { + const [isOpen, setIsOpen] = useState(defaultOpen) + const contentId = useId() + + return ( +
+ + +
+
+
+ {answer.map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+
+
+
+ ) +} + +export default FaqAccordionItem diff --git a/src/lib/faq.ts b/src/lib/faq.ts new file mode 100644 index 0000000..2062ca8 --- /dev/null +++ b/src/lib/faq.ts @@ -0,0 +1,32 @@ +export interface FaqItem { + question: string + answer: string[] +} + +export const faqItems: FaqItem[] = [ + { + question: '엔빵은 어떻게 시작하나요?', + answer: [ + '엔빵은 여러 명이 함께 쓰는 구독료를 자동으로 나눠서 정산해주는 서비스에요. 이미 있는 구독 공유 사이트나 플랫폼과 달리, 인원을 나누는 계산 기능 뿐만 아니라 결제일 알림과 정산 내역 관리까지 한 곳에서 관리할 수 있어요.', + '시작하는 방법은 간단해요. 회원가입 후 정산하고 싶은 구독 서비스를 등록하면 바로 시작할 수 있어요. 서비스 이름과 함께 나눠 쓰는 인원을 추가하면 인원수에 맞춰 금액이 자동으로 계산되고, 이후에는 결제일에 맞춰 정산 알림을 받아볼 수 있어요.', + ], + }, + { + question: '넷플릭스 구독료는 어떻게 나눠서 정산하나요?', + answer: [ + '넷플릭스 요금을 엔빵으로 나눠서 정산하려면 먼저 넷플릭스 요금제와 함께 쓸 인원을 엔빵에 등록해야 해요. 등록한 인원수에 맞춰 금액이 자동으로 계산되고, 실제로 프로필을 나눠 쓰는 인원이 다르면 인원수를 직접 조정해서 정산 금액을 맞출 수 있어요.', + ], + }, + { + question: '유튜브 프리미엄 가족 요금제는 어떻게 정산하나요?', + answer: [ + '유튜브 프리미엄 같은 OTT 가족 요금제로 함께 쓰는 인원을 엔빵에 등록하면 등록한 금액이 인원수에 맞게 자동으로 분배돼요. 가족 그룹 안에서 실제로 몇 명이 함께 쓰는지에 맞춰 인원 수를 입력하면 그에 맞는 금액으로 정산할 수 있어요.', + ], + }, + { + question: '왓챠나 티빙도 구독 공유 정산이 가능한가요?', + answer: [ + '네, 왓챠나 티빙을 엔빵으로 정산하는 것도 다른 서비스와 동일한 방식으로 할 수 있어요. 특정 서비스에 한정하지 않고 어떤 구독 서비스든 등록해서 정산할 수 있고, 왓챠나 티빙처럼 요금제와 인원 구성이 다른 서비스도 나눠 쓸 인원을 등록하고 금액을 계산할 수 있어요.', + ], + }, +] diff --git a/src/lib/jsonLd.ts b/src/lib/jsonLd.ts index 1f2be84..ba916d2 100644 --- a/src/lib/jsonLd.ts +++ b/src/lib/jsonLd.ts @@ -1,3 +1,4 @@ +import { faqItems } from '@/lib/faq' import { SITE_URL } from '@/lib/seo' type JsonLdValue = @@ -57,5 +58,17 @@ export const landingPageJsonLd: JsonLdObject = { '@id': `${SITE_URL}/#organization`, }, }, + { + '@type': 'FAQPage', + '@id': `${SITE_URL}/#faq`, + mainEntity: faqItems.map((item) => ({ + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer.join('\n\n'), + }, + })), + }, ], } From 7100dcee216c350899a45f258e6d69336f39d5ea Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 18 Aug 2026 16:51:16 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=20=EB=AC=B4=EC=9E=90?= =?UTF-8?q?=EB=B0=94=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=8F=20=EC=A0=91=ED=9E=98=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RevealOnScroll에 scripting:none 미디어 쿼리로 자바스크립트 비활성 환경에서 항상 노출되도록 처리 - FaqAccordionItem 접힌 답변 영역에 aria-hidden 추가해 접근성 트리 상태를 실제 개폐 상태와 일치시킴 Co-Authored-By: Claude Sonnet 5 --- src/components/landing/FaqAccordionItem.tsx | 1 + src/components/landing/RevealOnScroll.tsx | 2 +- src/styles/globals.css | 9 +++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/landing/FaqAccordionItem.tsx b/src/components/landing/FaqAccordionItem.tsx index af9f468..83a82ba 100644 --- a/src/components/landing/FaqAccordionItem.tsx +++ b/src/components/landing/FaqAccordionItem.tsx @@ -59,6 +59,7 @@ const FaqAccordionItem = ({
diff --git a/src/components/landing/RevealOnScroll.tsx b/src/components/landing/RevealOnScroll.tsx index a99815b..768580b 100644 --- a/src/components/landing/RevealOnScroll.tsx +++ b/src/components/landing/RevealOnScroll.tsx @@ -64,7 +64,7 @@ const RevealOnScroll = ({ return ( {children} diff --git a/src/styles/globals.css b/src/styles/globals.css index 0e5ee89..33f84b9 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -292,3 +292,12 @@ a { .floating { animation: floating 3s ease-in-out infinite; } + +/* 자바스크립트가 없는 환경에서는 스크롤 진입 효과를 적용하지 않고 항상 노출 */ +@media (scripting: none) { + .reveal-on-scroll { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } +} From 16fc2388347d7559354a20fe475ee3367926e2c7 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 18 Aug 2026 16:58:37 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20FAQ=20=EB=AC=B8=EA=B5=AC=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=EB=B2=95=20=EB=B0=8F=20=ED=91=9C=EA=B8=B0=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조사 "뿐" 띄어쓰기 오류 수정 - 한 문장 내 "관리" 중복 표현 정리 - "인원수" 표기를 문항 전체에서 붙여쓰기로 통일 Co-Authored-By: Claude Sonnet 5 --- src/lib/faq.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/faq.ts b/src/lib/faq.ts index 2062ca8..7bbd40f 100644 --- a/src/lib/faq.ts +++ b/src/lib/faq.ts @@ -7,7 +7,7 @@ export const faqItems: FaqItem[] = [ { question: '엔빵은 어떻게 시작하나요?', answer: [ - '엔빵은 여러 명이 함께 쓰는 구독료를 자동으로 나눠서 정산해주는 서비스에요. 이미 있는 구독 공유 사이트나 플랫폼과 달리, 인원을 나누는 계산 기능 뿐만 아니라 결제일 알림과 정산 내역 관리까지 한 곳에서 관리할 수 있어요.', + '엔빵은 여러 명이 함께 쓰는 구독료를 자동으로 나눠서 정산해주는 서비스에요. 이미 있는 구독 공유 사이트나 플랫폼과 달리, 인원을 나누는 계산 기능뿐만 아니라 결제일 알림과 정산 내역까지 한 곳에서 관리할 수 있어요.', '시작하는 방법은 간단해요. 회원가입 후 정산하고 싶은 구독 서비스를 등록하면 바로 시작할 수 있어요. 서비스 이름과 함께 나눠 쓰는 인원을 추가하면 인원수에 맞춰 금액이 자동으로 계산되고, 이후에는 결제일에 맞춰 정산 알림을 받아볼 수 있어요.', ], }, @@ -20,7 +20,7 @@ export const faqItems: FaqItem[] = [ { question: '유튜브 프리미엄 가족 요금제는 어떻게 정산하나요?', answer: [ - '유튜브 프리미엄 같은 OTT 가족 요금제로 함께 쓰는 인원을 엔빵에 등록하면 등록한 금액이 인원수에 맞게 자동으로 분배돼요. 가족 그룹 안에서 실제로 몇 명이 함께 쓰는지에 맞춰 인원 수를 입력하면 그에 맞는 금액으로 정산할 수 있어요.', + '유튜브 프리미엄 같은 OTT 가족 요금제로 함께 쓰는 인원을 엔빵에 등록하면 등록한 금액이 인원수에 맞게 자동으로 분배돼요. 가족 그룹 안에서 실제로 몇 명이 함께 쓰는지에 맞춰 인원수를 입력하면 그에 맞는 금액으로 정산할 수 있어요.', ], }, { From cc8ea7ebe304ad4a6985d6745d92025d7e3aa6fa Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 18 Aug 2026 17:33:33 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20FAQ=20=EC=95=84=EC=BD=94=EB=94=94?= =?UTF-8?q?=EC=96=B8=20=EC=9E=90=EB=B0=94=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EB=B9=84=ED=99=9C=EC=84=B1=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자바스크립트 없으면 하이드레이션이 없어 isOpen 초기값이 SSR 시점에 고정되고, aria-hidden과 grid-template-rows도 함께 굳어져 닫힌 답변이 sighted/스크린리더 사용자 모두에게 노출되지 않는 문제 수정. - aria-hidden 제거로 열림 상태와 무관하게 항상 접근성 트리에 노출 - scripting:none 미디어쿼리로 무자바스크립트 환경 시각적 확장 Closes #204 --- src/components/landing/FaqAccordionItem.tsx | 3 +-- src/styles/globals.css | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/landing/FaqAccordionItem.tsx b/src/components/landing/FaqAccordionItem.tsx index 83a82ba..bcff6d9 100644 --- a/src/components/landing/FaqAccordionItem.tsx +++ b/src/components/landing/FaqAccordionItem.tsx @@ -59,8 +59,7 @@ const FaqAccordionItem = ({
diff --git a/src/styles/globals.css b/src/styles/globals.css index 33f84b9..e888a60 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -300,4 +300,8 @@ a { transform: none !important; transition: none !important; } + + .faq-answer-panel { + grid-template-rows: 1fr !important; + } }