From 50907c1435c55468670fb30206bc3fe4451cb804 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 31 Aug 2026 16:12:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=80=EB=A5=BC=20=EA=B3=A1=EC=9D=84=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B3=84=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20(#317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃 상태에서 담은 부를 곡이 로그인하는 순간 화면에서 사라졌다. useToSingSongQuery가 isAuthenticated로 queryKey와 queryFn을 통째로 갈아타 서버 응답만 반환하는데, localStorage의 게스트 목록을 서버로 옮기는 코드가 없었다(clearGuestToSingSongs는 어디에서도 호출되지 않았다). 곡을 열심히 담고 로그인한 순간 목록이 비어 보이는, 가장 아까운 이탈 지점이었다. 데이터 자체는 지워지지 않았으므로 이 변경은 앞으로의 유입뿐 아니라 과거에 목록을 잃었던 사용자도 같은 기기에서 자동 복구한다. 카카오 로그인은 /api/auth/callback에서 서버 리다이렉트로 끝나 전체 페이지가 다시 뜬다. 클라이언트에 "로그인 성공" 시점이 없어 전환을 감지하는 대신 "로그인 상태에서는 게스트 목록이 비어 있다"는 불변식을 지킨다. 성공했을 때만 로컬을 비우므로 실패해도 곡은 남고 다음 방문에서 다시 시도한다. 기존 /songs/tosing/array를 쓰지 않고 /songs/tosing/merge를 따로 둔 이유는 이 요청만 중복·유령 곡을 만나기 때문이다. 모달에서 담을 때는 isInToSingList가 클라이언트에서 걸러주지만, 병합은 이미 담아둔 곡과 겹치고 브라우저가 오래 들고 있던 삭제된 곡 id도 섞인다. 둘 중 하나만 있어도 배치 insert 전체가 깨지고, 그러면 로컬이 비워지지 않아 방문할 때마다 같은 실패를 반복한다. 서버에서 실재하는 곡만 남기고 이미 담긴 곡은 건너뛴 뒤, 게스트가 잡아둔 순서를 유지한 채 기존 목록 뒤에 붙인다. 로그아웃 시에는 아무것도 하지 않는다 — 서버 목록을 로컬로 복사하면 공용 기기에 남는다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- .../src/app/api/songs/tosing/merge/route.ts | 81 +++++++++++++++++++ apps/web/src/auth.tsx | 3 + apps/web/src/hooks/useMergeGuestToSing.ts | 57 +++++++++++++ apps/web/src/lib/api/tosing.ts | 8 ++ apps/web/src/queries/tosingSongQuery.ts | 19 +++++ 5 files changed, 168 insertions(+) create mode 100644 apps/web/src/app/api/songs/tosing/merge/route.ts create mode 100644 apps/web/src/hooks/useMergeGuestToSing.ts diff --git a/apps/web/src/app/api/songs/tosing/merge/route.ts b/apps/web/src/app/api/songs/tosing/merge/route.ts new file mode 100644 index 00000000..b0148045 --- /dev/null +++ b/apps/web/src/app/api/songs/tosing/merge/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; + +// 게스트 목록은 localStorage에 무한정 쌓일 수 있어 한 번에 옮길 양을 막아둔다. +const MAX_MERGE_COUNT = 100; + +/** + * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. + * + * `/songs/tosing/array`를 쓰지 않는 이유는 이 요청만 중복·유령 곡을 만나기 때문이다. + * 모달에서 담을 때는 `isInToSingList`가 클라이언트에서 걸러주지만, 병합은 이미 담아둔 + * 곡과 겹치고 브라우저가 오래 들고 있던 삭제된 곡 id도 섞인다. 둘 중 하나만 있어도 + * 배치 insert 전체가 깨지고, 그러면 로컬이 비워지지 않아 방문할 때마다 같은 실패를 + * 반복한다. 그래서 넣기 전에 서버에서 거른다. + */ +export async function POST( + request: Request, +): Promise>> { + try { + const supabase = await createClient(); + const userId = await getAuthenticatedUser(supabase); + + const { songIds } = await request.json(); + if (!Array.isArray(songIds) || songIds.length === 0) { + return NextResponse.json({ success: true, data: { merged: 0 } }); + } + + const ids = [...new Set(songIds)].slice(0, MAX_MERGE_COUNT); + + const { data: realSongs, error: songError } = await supabase + .from('songs') + .select('id') + .in('id', ids); + if (songError) throw songError; + + const { data: mine, error: mineError } = await supabase + .from('tosings') + .select('song_id, order_weight') + .eq('user_id', userId); + if (mineError) throw mineError; + + const realIds = new Set((realSongs ?? []).map(row => row.id)); + const mineIds = new Set((mine ?? []).map(row => row.song_id)); + + // 게스트가 잡아둔 순서를 유지한 채, 이미 담긴 곡과 사라진 곡만 걸러낸다 + const targets = ids.filter(id => realIds.has(id) && !mineIds.has(id)); + if (targets.length === 0) { + return NextResponse.json({ success: true, data: { merged: 0 } }); + } + + // 기존 목록 뒤에 붙인다 — 계정에 있던 순서가 밀리지 않게 + const lastWeight = (mine ?? []).reduce((max, row) => Math.max(max, row.order_weight), 0); + + const { error } = await supabase.from('tosings').insert( + targets.map((songId, index) => ({ + user_id: userId, + song_id: songId, + order_weight: lastWeight + index + 1, + })), + ); + if (error) throw error; + + return NextResponse.json({ success: true, data: { merged: targets.length } }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + + console.error('Error in tosing merge API:', error); + return NextResponse.json( + { success: false, error: 'Failed to merge tosing songs' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx index 41165455..463126f0 100644 --- a/apps/web/src/auth.tsx +++ b/apps/web/src/auth.tsx @@ -3,6 +3,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; +import useMergeGuestToSing from '@/hooks/useMergeGuestToSing'; import useAuthStore from '@/stores/useAuthStore'; const ALLOW_PATHS = [ @@ -24,6 +25,8 @@ export default function AuthProvider({ children }: { children: React.ReactNode } const { checkAuth } = useAuthStore(); const [isAuthChecked, setIsAuthChecked] = useState(false); + useMergeGuestToSing(); + useEffect(() => { const isPublicPath = ALLOW_PATHS.includes(pathname); diff --git a/apps/web/src/hooks/useMergeGuestToSing.ts b/apps/web/src/hooks/useMergeGuestToSing.ts new file mode 100644 index 00000000..5d537fa0 --- /dev/null +++ b/apps/web/src/hooks/useMergeGuestToSing.ts @@ -0,0 +1,57 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; + +import { useMergeGuestToSingMutation } from '@/queries/tosingSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; + +/** + * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. + * + * 카카오 로그인은 `/api/auth/callback`에서 서버 리다이렉트로 끝나 전체 페이지가 다시 뜬다. + * 클라이언트에 "로그인 성공" 시점이 없으므로 전환을 감지하는 대신 "로그인 상태에서는 + * 게스트 목록이 비어 있다"는 불변식을 지킨다. + * + * 성공했을 때만 로컬을 비운다 — 실패하면 다음 방문에서 다시 시도하고, 그 사이에도 + * 사용자의 곡은 localStorage에 그대로 남는다. + */ +export default function useMergeGuestToSing() { + const { isAuthenticated } = useAuthStore(); + const { guestToSingSongs, clearGuestToSingSongs } = useGuestToSingStore(); + const { mutate } = useMergeGuestToSingMutation(); + + // StrictMode의 이중 실행과 리렌더로 인한 중복 요청을 막는다 + const isMergingRef = useRef(false); + + useEffect(() => { + if (!isAuthenticated || guestToSingSongs.length === 0) return; + if (isMergingRef.current) return; + + isMergingRef.current = true; + mutate( + guestToSingSongs.map(item => item.songs.id), + { + onSuccess: response => { + if (!response.success) { + isMergingRef.current = false; + return; + } + + clearGuestToSingSongs(); + + const merged = response.data?.merged ?? 0; + if (merged > 0) { + toast.success('담아둔 곡을 옮겼어요', { + description: `부를 곡 목록에 ${merged}곡을 추가했어요.`, + }); + } + }, + onError: () => { + isMergingRef.current = false; + }, + }, + ); + }, [isAuthenticated, guestToSingSongs, mutate, clearGuestToSingSongs]); +} diff --git a/apps/web/src/lib/api/tosing.ts b/apps/web/src/lib/api/tosing.ts index ed25688a..8cf304dd 100644 --- a/apps/web/src/lib/api/tosing.ts +++ b/apps/web/src/lib/api/tosing.ts @@ -30,6 +30,14 @@ export async function postToSingSongArray(body: { songIds: string[] }) { return response.data; } +export async function postToSingSongMerge(body: { songIds: string[] }) { + const response = await instance.post>( + '/songs/tosing/merge', + body, + ); + return response.data; +} + export async function deleteToSingSong(body: { songId: string }) { const response = await instance.delete>('/songs/tosing', { data: body }); return response.data; diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index dd6f0baa..df8b9d23 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -5,6 +5,7 @@ import { getToSingSong, patchToSingSong, postToSingSongArray, + postToSingSongMerge, } from '@/lib/api/tosing'; import { ToSingSong } from '@/types/song'; @@ -46,6 +47,24 @@ export function usePostToSingSongMutation() { }); } +// 게스트로 담아둔 곡을 로그인 계정으로 병합 +// 실패해도 로컬을 비우지 않아야 재시도가 가능하므로, 성공 판정은 호출부에서 한다. +export function useMergeGuestToSingMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (songIds: string[]) => postToSingSongMerge({ songIds }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); + queryClient.invalidateQueries({ queryKey: ['searchSong'] }); + }, + onError: error => { + // 사용자가 시킨 동작이 아니라 배경에서 도는 병합이라 alert로 막지 않는다 + console.error('게스트 부를 곡 병합 실패:', error); + }, + }); +} + // 부를 노래 삭제 export function useDeleteToSingSongMutation() { const queryClient = useQueryClient(); From e7ad3ab84016882792d641d9ed60c9f6ddc6a22a Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 31 Aug 2026 17:08:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?doc=20:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=ED=9B=85=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=A9=EC=8B=9D=20=EC=A0=84?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=9D=BC=EB=B0=98=ED=99=94=20(#3?= =?UTF-8?q?17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 로그인만 예로 들고 있어, 이메일/비밀번호 로그인은 따로 처리해야 하는 것처럼 읽힐 수 있었다. 실제로는 두 경우 모두 checkAuth()가 isAuthenticated를 뒤집는 지점을 지나므로 같은 상태 조건 하나로 덮인다. 방식마다 끝나는 모습이 다르다는 점(상태만 바뀜 vs 전체 리로드)과, 그래서 이벤트가 아니라 불변식을 쓴다는 점을 함께 적는다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3Qg1DFPBGjtDjhBN5CuAn --- apps/web/src/hooks/useMergeGuestToSing.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useMergeGuestToSing.ts b/apps/web/src/hooks/useMergeGuestToSing.ts index 5d537fa0..9798d151 100644 --- a/apps/web/src/hooks/useMergeGuestToSing.ts +++ b/apps/web/src/hooks/useMergeGuestToSing.ts @@ -10,9 +10,11 @@ import useGuestToSingStore from '@/stores/useGuestToSingStore'; /** * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. * - * 카카오 로그인은 `/api/auth/callback`에서 서버 리다이렉트로 끝나 전체 페이지가 다시 뜬다. - * 클라이언트에 "로그인 성공" 시점이 없으므로 전환을 감지하는 대신 "로그인 상태에서는 - * 게스트 목록이 비어 있다"는 불변식을 지킨다. + * "로그인 성공" 이벤트를 잡는 대신 "로그인 상태에서는 게스트 목록이 비어 있다"는 + * 불변식을 지킨다. 로그인 방식마다 끝나는 모습이 달라서다 — 이메일/비밀번호는 + * checkAuth()가 상태만 뒤집고 화면이 그대로 이어지는 반면, 카카오와 가입 확인 링크는 + * 서버 리다이렉트라 전체 페이지가 다시 뜬다. 상태 조건 하나면 두 경우가 함께 덮이고, + * 로그인 수단이 늘어도 checkAuth()만 거치면 따라온다. * * 성공했을 때만 로컬을 비운다 — 실패하면 다음 방문에서 다시 시도하고, 그 사이에도 * 사용자의 곡은 localStorage에 그대로 남는다.