블로그
전체 82025. 03. 30.
0
[인프런 워밍업 클럽 3기 풀스택] 4주차 발자국
[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) 4주차 배운 내용 정리Supabase AuthJavaScript: Overview | Supabase Docs 이메일 인증 방식 제공Confirmation URL: 이메일을 통해 인증 링크를 보내 사용자 계정을 활성화6-Digit OTP: 6자리 숫자로 로그인 가능OAuth 로그인 지원Kakao Social Login: 카카오 계정으로 간편 로그인 가능JSON 웹 토큰(JWT) 사용클라이언트가 인증된 사용자인지 검증하는 데 활용Access Token을 활용하여 API 요청 가능SSR 환경에서는 쿠키 기반 인증(세션 관리) 활용 가능cookies()를 통해 서버에서 세션 관리Supabase의 auth.getSession()을 사용하여 인증 상태 확인Refresh Token을 사용하여 토큰 갱신 가능Access Token 만료 시 Refresh Token을 이용해 자동 갱신 가능auth.refreshSession()을 제공하여 토큰 재발급 가능auth.onAuthStateChange()를 활용하여 인증 상태 변화를 감지할 수 있음 RealtimeBroadcast모든 사용자에게 동일한 데이터를 전송하는 방식Presence현재 접속한 사용자의 상태를 추적 및 공유Postgres Changes데이터베이스 변경 사항을 실시간으로 감지한다.INSERT / UPDATE / DELETE 이벤트별로 처리 가능 RLS(Row Level Security) 적용데이터베이스에서 보안 정책을 적용하는 기능클라이언트 사이드에서 직접 데이터 권한을 확인하고 실행 가능테이블별로 RLS 활성화 후, 필요한 정책을 생성하여 특정 사용자에게만 데이터 접근 권한 부여 배포 (Vercel, AWS, 도메인 설정)Vercel: 프론트엔드 빌드 & 배포 자동화AWS: EC2사용 - 인스턴스 생성 및 배포도메인 설정: 도메인 구매 후 Vercel에 연결하여 HTTPS 설정 적용, DNS 설정을 통해 연결 미션 3이제까지 만드신 모든 프로젝트를 배포하신 후 배포된 링크를 업로드 해주세요.(선택사항) Instagram Clone 프로젝트에 아래 기능 중 하나를 선택하여 구현하세요.아래 예시 외에도 "채팅 신고", "유저 차단기능" 등 다른 기능을 추가 구현하셔도 괜찮습니다.1⃣ 채팅 메시지 삭제 기능사용자가 특정 채팅 메시지를 삭제할 수 있도록 구현(선택 사항) 삭제된 메시지 대신 “이 메시지는 삭제되었습니다” 같은 알림 표시2⃣ 채팅 읽음/안 읽음 표시 기능채팅방에서 사용자가 읽지 않은 메시지 개수를 표시상대방이 메시지를 읽었는지 확인할 수 있는 “읽음” 표시 추가Instagram Clone 프로젝트 구현 사항:사용자가 특정 채팅 메시지를 삭제할 수 있도록 구현상대방이 메시지를 읽었는지 확인할 수 있는 “읽음” 표시 추가“읽음” 표시 대신 “1” 표시상대방이 메시지 작성 중인지 타이핑 표시 추가메시지 창 진입시 마지막 채팅으로 스크롤 이동Instagram Clone 프로젝트 Github:https://inf.run/T5DSj모든 프로젝트 배포:Todo-listhttps://inf.run/cvWTCdropbox clonehttps://inf.run/3LF9Knetflix clonehttps://inf.run/73D8kinstagram clonehttps://inf.run/kNhK6 과제 해결 과정배포Vercel을 이용해 GitHub과 연동하여 간편하게 배포할 수 있었다.가비아에서 도메인 구매부터 설정을 완료하는 데까지 10분도 걸리지 않았다.또한, .env 파일에 도메인 환경 변수를 추가하고 로그인 콜백 URL을 수정한 뒤 재배포하니,정상적으로 로그인까지 이루어지는 것을 확인할 수 있었다. SMTP 설정도메인을 구매하여 SMTP 설정도 해주었다.감사하게도 러너분께서 SMTP 설정 관련 블로그를 공유해주신 덕분에 쉽게 설정할 수 있었다.가비아 도메인 설정에서 Resend의 DNS 레코드를 추가하면 간단하게 설정이 완료된다.Supabase Authentication>Emails>SMTP Settings에서 활성화한 후 SMTP를 설정해준다.openGraph 설정openGraph 메타태그를 설정할 때,이미지 경로를 상대경로로 입력했더니 메타태그 자체는 정상적으로 적용되었지만, 이미지는 제대로 불러오지 못했다.검색해보니 Open Graph의 og:image 속성에는 절대경로를 사용해야 한다는 점을 알게 되었다. 상대경로(/images/image.jpg)를 사용하면 Facebook, Twitter, Kakao 등의 플랫폼에서 이미지를 정상적으로 불러오지 못할 수 있기 때문이다.따라서, 이미지 경로를 절대경로로 변경하여 이미지를 불러왔다.export const metadata: Metadata = { title: "Instagram clone", description: "nextjs supabase Instagram clone", openGraph: { images: [ { url: "", alt: "inflearngram", }, ], }, }; 기능 구현getAllMessages 400 에러 해결과정RLS를 적용한 후, Chat 페이지에 처음 접근할 때 ChatPeopleList와 ChatScreen 두 개의 컴포넌트가 동시에 렌더링되었다.이 과정에서 ChatScreen의 getAllMessages 함수가 호출되었는데, 해당 함수는 선택된 유저의 아이디를 매개변수로 받아야 한다.문제는 페이지에 처음 진입했을 때는 아직 유저가 선택되지 않은 상태이므로, getAllMessages(null)이 호출되면서 400 에러가 발생했다.이를 해결하기 위해, getAllMessages 함수에서 매개변수가 null인 경우 빈 배열을 반환하도록 수정하여 초기 렌더링 시 발생하던 에러를 해결했다. 사용자가 특정 채팅 메시지를 삭제할 수 있도록 구현데이터베이스에서 메시지를 완전히 삭제하는 대신, is_deleted 컬럼을 사용하여 논리적으로 삭제하는 방식으로 처리하도록 구현했다.논리적 삭제(Update) 후 데이터를 다시 불러와 ‘이 메시지는 삭제되었습니다.’로 표시했다. componenets/chat/ChatScreen.tsx[Supabase에서 is_deleted 값 업데이트]export async function deletedMessage(id) { const supabase = createBrowserSupabaseClient(); const { error } = await supabase .from("message") .update({ is_deleted: true }) .eq("id", id); if (error) { throw new Error(error.message); } } 메시지의 id를 받아 해당 메시지의 is_deleted 값을 true로 업데이트한다. [Supabase Realtime 업데이트 감지]useEffect(() => { const channel = supabase.channel("message_postgres_changes") .on( "postgres_changes", { event: "UPDATE", schema: "public", table: "message" }, (payload) => { if (payload.eventType === "UPDATE" && !payload.errors) { getAllMessagesQuery.refetch(); } } ) .subscribe(); return () => { channel.unsubscribe(); }; }, []); UPDATE 이벤트가 발생하면 getAllMessagesQuery.refetch()로 변경된 메시지를 반영한다. [삭제 버튼 Mutation]const deletedMessageMutation = useMutation({ mutationFn: deletedMessage }); 삭제 버튼을 클릭했을 때 해당 메시지를 삭제하는 Mutation을 호출한다. [메시지 컴포넌트 렌더링]{getAllMessagesQuery.data?.map((message) => ( deletedMessageMutation.mutate(message.id)} message={ message.is_deleted ? "이 메시지는 삭제되었습니다." : message.message } /> ))} 삭제 버튼 클릭 시, onClickDeleted 핸들러가 호출되며 deletedMessageMutation.mutate(message.id)를 통해 삭제를 수행한다. componenets/chat/Message.tsxexport default function Messag({ onClickDeleted, isDeleted, isReadAt, isFromMe, message, }) { const [showButton, setShowButton] = useState(false); return ( setShowButton(true)} onMouseLeave={() => setShowButton(false)} onClick={() => setShowButton(true)} > {isReadAt == null && isFromMe && ( {"1"} )} {!isDeleted && isFromMe && showButton && ( )} {message} ); } Message 컴포넌트에서는 isDeleted, onClickDeleted, message 등의 props를 받는다. 각 메시지를 렌더링할 때, is_deleted 값에 따라 메시지를 다르게 표시한다. 만약 메시지가 삭제된 상태라면 "이 메시지는 삭제되었습니다."라고 표시하고, 내가 보낸 메시지 중 삭제되지 않은 메시지에는 삭제 버튼을 표시하여 삭제 기능을 수행할 수 있다. 상대방이 메시지를 읽었는지 확인할 수 있는 “읽음” 표시 추가(“읽음” 표시 대신 “1”로 읽지 않음 표시)ChatPeopleList 컴포넌트에서 온라인 상태를 추적하는 Channel을 설정하고, ChatScreen 컴포넌트에서 유저의 입장과 퇴장을 추적하는 Channel을 설정했다.Message 테이블에 read_at 컬럼을 추가하여, 각 메시지가 읽혔는지 여부를 관리하도록 한다. 채팅 화면에서 유저가 입장할 때(즉, 상대방이 채팅방에 들어올 때), Presence 채널의 join 이벤트를 활용하여 해당 유저의 메시지 중 아직 읽지 않은 메시지(read_at이 null인 메시지)를 현재 시각으로 업데이트한다. 상대방이 메시지를 읽었을 때, 메시지의 상태가 업데이트된다. componenets/chat/ChatScreen.tsx[Presence 채널과 Join 이벤트 처리]useEffect(() => { const presenceKey = `${loggedInUser.email?.split("@")?.[0]}-${ selectedUserQuery.data?.email?.split("@")?.[0] }`; const channel = supabase.channel("message_postgres_changes", { config: { presence: { key: presenceKey, }, }, }); channel .on("presence", { event: "join" }, ({ key, newPresences }) => { const newState = newPresences; Object.keys(newState).forEach((key) => { if (key === presenceKey) return; if (!isJoined) { setIsJoined(true); readMessageMutation.mutate(); } }); }) .on("presence", { event: "leave" }, ({ key }) => { if (key === presenceKey) return; setIsJoined(false); }) .subscribe(); return () => { channel.unsubscribe(); }; }, []); join 이벤트가 발생하면 readMessageMutation.mutate()를 호출하여 해당 메시지를 읽은 시점으로 업데이트한다. [읽음 시각 업데이트 Mutation]const readMessageMutation = useMutation({ mutationFn: () => readMessage({ chatUserId: selectedUserID }), }); readMessageMutation은 readMessage를 호출한다. [Supabase에서 read_at 값 업데이트]export async function readMessage({ chatUserId }) { if (chatUserId === null) return; const supabase = createBrowserSupabaseClient(); const { data: { session }, error, } = await supabase.auth.getSession(); if (error || !session.user) { throw new Error("User is not authenticated"); } const { error: readMessagesError } = await supabase .from("message") .update({ read_at: new Date().toISOString() }) .eq("receiver", session.user.id) .eq("sender", chatUserId) .is("read_at", null); if (readMessagesError) { throw new Error(readMessagesError.message); } } readMessage는 읽지 않은 메시지(read_at이 null인 메시지)를 현재 시간으로 업데이트한다.receiver: 로그인된 사용자와 sender: 채팅 상대방이 일치하고, read_at이 null인 메시지에 대해 현재 시각을 read_at에 업데이트한다. [읽음 표시 추가 → “1” 읽지 않음으로 표시]{getAllMessagesQuery.data?.map((message) => ( deletedMessageMutation.mutate(message.id)} message={ message.is_deleted ? "이 메시지는 삭제되었습니다." : message.message } /> ))} isReadAt 값을 Message 컴포넌트에 전달한다. componenets/chat/Message.tsxexport default function Messag({ onClickDeleted, isDeleted, isReadAt, isFromMe, message, }) { const [showButton, setShowButton] = useState(false); return ( setShowButton(true)} onMouseLeave={() => setShowButton(false)} onClick={() => setShowButton(true)} > {isReadAt == null && isFromMe && ( {"1"} )} {!isDeleted && isFromMe && showButton && ( )} {message} ); } Message 컴포넌트에서 isReadAt 값이 null인 경우, 즉 상대방이 메시지를 읽지 않았다면 "1"을 표시하여 읽지 않음을 나타낸다. 상대방이 메시지 작성 중인지 타이핑 표시 추가이 기능은 Precence 자료를 찾다가 한 영상을 발견하면서 프로젝트에 넣어봤다.Youtube - Chat app with Nextjs & Supabase | Postgres Changes | Presence | Supbase Realtime Course part 1 [Realtime Subscription 설정]const [typingUsers, setTypingUsers] = useState([]); const channelRef = useRef(null); const realTimeSubscription = () => { const presenceKey = `${loggedInUser.email?.split("@")?.[0]}-${ selectedUserQuery.data?.email?.split("@")?.[0] }`; const channel = supabase.channel("message_postgres_changes", { config: { presence: { key: presenceKey, }, }, }); channel .on("postgres_changes",{ event: "INSERT", schema: "public", table: "message" }, (payload) => { // ... } ) .on("postgres_changes",{ event: "UPDATE", schema: "public", table: "message" }, (payload) => { // ... } ) .on("presence", { event: "sync" }, () => { const newState = channel.presenceState(); let filteredUsers = []; // 타이핑 중인 유저 추적 Object.keys(newState).forEach((key) => { if (key === presenceKey) return; const presences = newState[key]; presences.forEach( (presence: { presence_ref: string; isTyping?: boolean; name?: string; }) => { if (presence.isTyping) { filteredUsers.push(presence.name); // 타이핑 중인 유저 이름 추적 } } ); }); setTypingUsers(filteredUsers); // 타이핑 중인 유저 상태 업데이트 }) .subscribe(async (status) => { if (status !== "SUBSCRIBED") return; // 타이핑 상태를 false로 초기화 await channel.track({ onlineAt: new Date().toISOString(), isTyping: false, name: loggedInUser.email?.split("@")?.[0], }); }); return channel; }; Presence를 통해 사용자의 타이핑 상태를 실시간으로 추적하고 동기화한다.타이핑 상태인 사용자들을 추적하여 filteredUsers 배열에 추가하고, setTypingUsers를 통해 관리한다. useEffect(() => { if (!selectedUserID) return; channelRef.current = realTimeSubscription(); return () => { channelRef.current?.unsubscribe(); }; }, [selectedUserQuery?.data]); useEffect내에서 realTimeSubscription을 호출하고, channelRef로 채널을 관리하여 구독을 설정한다. [타이핑 상태 트래킹]const isTypingRef = useRef(false); // 타이핑 상태 추적 const typingTimeoutRef = useRef(null); // 타이핑 후 시간 지연 처리 const trackTyping = async (status) => { await channelRef.current.track({ isTyping: status, name: loggedInUser.email?.split("@")?.[0], }); }; const handleInputChange = async (e) => { setMessage(e.target.value); // 타이핑이 시작되면 isTyping 상태를 true로 설정 if (!isTypingRef.current) { await trackTyping(true); isTypingRef.current = true; } // 기존 타이머가 있으면 clear if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); // 2초 뒤 타이핑 종료 typingTimeoutRef.current = setTimeout(async () => { if (!isTypingRef.current) return; await trackTyping(false); isTypingRef.current = false; }, 2000); }; trackTyping 함수는 유저가 타이핑 중인지 여부를 channel.track()로 실시간으로 업데이트한다.handleInputChange 함수는 사용자의 타이핑 상태와 타이핑 종료 타이머를 관리한다. 사용자가 메시지를 입력하기 시작하면 isTyping 상태를 true로 설정하고, 타이핑을 멈추면 2초 후에 isTyping을 false로 설정하여 타이핑 상태 변화를 반영한다. [타이핑 상태 표시] {/* 채팅방 영역 */} {typingUsers.length > 0 && ( {typingUsers[0]} is typing... )} { if (e.key === "Enter" && !e.nativeEvent.isComposing) { e.preventDefault(); sendMessageMutation.mutate(); } }} className="p-3 w-full border-2 border-light-blue-600" placeholder="메시지를 입력하세요." /> typingUsers를 통해 타이핑 중인 사용자들의 이름을 업데이트하고, 화면에서 타이핑 상태를 "is typing..." 형태로 표시한다.(etc. 한글 메시지 엔터 이벤트 시 2번 보내지는 오류는 e.nativeEvent.isComposing을 통해 해결했다. 문자 입력중인 상태를 감지하여 입력이 완료된 상태(false)만 전송이 가능하다.) 메시지 창 진입시 최신 채팅으로 스크롤 이동export default function ChatScreen({ loggedInUser }) { const chatRef = useRef(null); const [scrollOn, setScrollOn] = useState(true); useEffect(() => { if (!chatRef.current || !getAllMessagesQuery.isSuccess) return; if (scrollOn) { chatRef.current.scrollTop = chatRef.current.scrollHeight; } }, [ selectedUserID, getAllMessagesQuery.isSuccess, getAllMessagesQuery.data, scrollOn, ]); return selectedUserQuery.data !== null ? ( // ... {/* 채팅 영역 */} // ... // ... ) : ( ); } 채팅방에 들어왔을때와 새 메시지가 오면 useRef를 사용하여 최신 메시지로 스크롤을 이동한다.메시지 삭제(Update)시 데이터가 다시 불러와지는데, scrollOn으로 삭제시에는 스크롤이 유지되도록 설정했다.따라서 채팅방에서 최신 메시지를 자동으로 보여주고, 메시지 삭제 시에는 스크롤을 유지한다. 4주차 회고이번 주에는 Supabase의 유용한 기능들을 많이 사용해 볼 수 있었고, 그 과정에서 많은 것을 배웠습니다. 실시간 채팅 구현에서는 WebSocket만 사용할 수 있을 줄 알았는데, Supabase의 기능을 활용할 수 있어서 새로 배워갔습니다. 또한 배포는 Vercel로 간단하게만 해본 경험이 있었지만, 이번에는 도메인 구매부터 SMTP 설정까지 직접 해보며 기억에 남는 경험을 쌓을 수 있었습니다.미션에서는 읽음 표시 기능을 구현했는데, 현재는 presence 채널을 통해 상대방이 입장할 때 메시지의 읽음 상태를 업데이트하고, useState로 입장/퇴장 상태를 관리하며 메시지를 업데이트하고 있습니다. 더 나은 방법이 있을 것 같지만, 구체적인 해결책은 아직 떠오르지 않았습니다. 현업에서는 어떻게 처리하는지 궁금합니다. 또한, 실시간 기능은 더 공부해서 개인 프로젝트에 적용해보고 싶습니다.벌써 끝이라니 믿기지가 않네요. 그만큼 정말 재미있었고, 오랜만에 프로젝트를 만들고 따라가면서 즐겁게 보냈습니다. 처음 시작할 때는 Next.js의 페이지 라우팅만 알고 있었고, Supabase는 부끄럽지만 이름만 들어본 상태여서 잘 따라갈 수 있을지 걱정했었는데, 너무 좋은 강의를 통해 많이 성장한 것 같습니다.Supabase를 쉽고 빠르게 배울 수 있도록 강의를 준비해주신 로펀 강사님께 감사드립니다! 덕분에 어렵지 않게 재밌게 배웠습니다.😊
2025. 03. 23.
0
[인프런 워밍업 클럽 3기 풀스택] 3주차 발자국
[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) 3주차에는 Netflix 클론 코딩을 진행했다.Supabase를 사용해 영화 테이블을 생성하고 TMDB의 영화 데이터를 삽입했다.또한, react-intersection-observer 패키지를 활용해 무한 스크롤을 구현하고, useInfiniteQuery로 페이지네이션까지 적용했다.영화 개별 페이지에서는 SEO 최적화를 위해 generateMetadata를 사용하여 ogImage까지 설정했다. 무한 스크롤 적용 방식useInfiniteQuery를 활용한 페이지네이션 useInfiniteQuery를 사용하여 검색 결과를 페이지 단위로 가져온다.queryFn에서 searchMovies 함수를 호출하여 검색어search와 페이지 번호pageParam를 기반으로 데이터를 요청한다.getNextPageParam을 이용해 다음 페이지 번호를 설정하여, lastPage.page 값이 존재하면 다음 페이지를 요청하고, 없으면 추가 요청을 중단한다.react-intersection-observer를 활용한 스크롤 감지useInView를 사용하여 특정 요소가 뷰포트 안에 들어오는지를 감지한다.threshold: 0 는 요소가 화면에 보이기 시작하면 inView가 true로 변경ref를 특정 요소에 연결하면, 그 요소가 화면에 보일 때 inView 값이 변한다.useEffect를 이용한 자동 데이터 로드inView 값이 true이고, 다음 페이지가 존재(hasNextPage)하며, 현재 데이터를 요청 중(isFetching, isFetchingNextPage)이 아니라면 fetchNextPage를 호출하여 다음 페이지 데이터를 불러온다. SEO 최적화generateMetadata 함수는 특정 페이지의 메타데이터(SEO 정보)를 동적으로 생성하는 역할을 한다.generateMetadata에서 아래와 같이 반환한다면:return { title: movie.title, description: movie.overview, openGraph: { images: [movie.image_url], }, };HTML 메타태그가 아래와 같이 생성된다:Inception Open Graph images를 추가하여 SNS에서 공유 시 미리보기 이미지가 표시되도록 한다. 3주차 미션Netflix Clone 프로젝트에 “찜하기” 기능을 추가하세요.사용자가 특정 영화를 “찜”할 수 있도록 Supabase를 활용해 즐겨찾기 리스트 구현찜한 영화를 영화 리스트 화면의 최상단으로 보여주도록 정렬github: https://github.com/thayoon/inflearn-nextjs-supabase-netflix-clone결과 화면:미션 해결 과정:1. 추후 회원 기능 추가를 위해 Supabase에서 “찜(favorites) 테이블”을 생성한다.favorites: 사용자가 찜한 영화를 저장하는 테이블한 영화(movie.id)는 여러 사용자의 favorites에 포함될 수 있다. (1:N 관계)2. 전체 영화 리스트와 찜 상태를 함께 가져오는 RCP 함수 정의Supabase에서 서브쿼리를 사용하여 영화와 찜 상태를 가져오는 get_movies_with_favorites라는 Remote Procedure Call (RPC) 함수를 정의한다.이 함수는 영화 제목을 검색하고, 해당 영화가 찜 목록에 있는지 여부(boolean)를 favorite 컬럼으로 반환한다.JavaScript: Call a Postgres function | Supabase Docsget_movies_with_favorites-- DROP FUNCTION IF EXISTS get_movies_with_favorites(varchar, integer, integer); create or replace function get_movies_with_favorites(search varchar, page int, page_size int) returns table ( id int8, image_url text, title varchar, overview varchar, vote_average float8, popularity float8, release_date varchar, favorite boolean ) as $$ begin return query select movie.*, exists ( select 1 from favorites where favorites.movie_id = movie.id ) as favorite from movie where movie.title like '%' || search || '%' limit page_size offset (page - 1) * page_size; end; $$ language plpgsql;3. 기존 영화 목록 불러오기 로직 변경기존의 movie 테이블에서 select()를 사용해 영화를 불러오는 로직을:export async function searchMovies({ search = "", page, page_size }) { // ... const { data, count, error } = await supabase .from("movie") .select("*") .like("title", `%${search}%`) .range((page - 1) * pageSize, page * pageSize - 1); // ... }위에서 정의한 get_movies_with_favorites RPC를 호출하는 방식으로 변경한다.:export async function searchMovies({ search = "", page, page_size }) { // ... const { data, error } = await supabase.rpc("get_movies_with_favorites", { search, page, page_size, }); // ... }그 결과 찜 상태를 포함한 영화를 반환받을 수 있다.:[ { id: 1, image_url: 'https://image.tmdb.org/t/p/w500/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg', title: 'Dune: Part Two', overview: 'Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen while on a path of revenge against the conspirators who destroyed his family. Facing a choice between the love of his life and the fate of the known universe, Paul endeavors to prevent a terrible future only he can foresee.', vote_average: 8.3, popularity: 3437.313, release_date: '2024-02-27', favorite: false }, { id: 2, image_url: 'https://image.tmdb.org/t/p/w500/kDp1vUBnMpe8ak4rjgl3cLELqjU.jpg', title: 'Kung Fu Panda 4', overview: 'Po is gearing up to become the spiritual leader of his Valley of Peace, but also needs someone to take his place as Dragon Warrior. As such, he will train a new kung fu practitioner for the spot and will encounter a villain called the Chameleon who conjures villains from the past. movie HD QUALITY, open this link leakedcinema.com', vote_average: 7.146, popularity: 2340.977, release_date: '2024-03-02', favorite: false }, // ... ]4. 영화 카드 컴포넌트에서 찜 상태 반영components/movie-card.tsxexport default function MovieCard({ movie }) { async function handleClick(favorite: boolean) { if (favorite) { await insertFavoriteMutaion.mutate(); } else { await deleteFavoriteMutaion.mutate(); } } return ( // ... {/* 찜하기 버튼 */} { handleClick(!movie.favorite); }} > {insertFavoriteMutaion.isPending || deleteFavoriteMutaion.isPending ? ( ) : ( )} // ... ); }MovieCard 컴포넌트에서 각 영화의 찜 상태(favorite)에 맞게 버튼 색상을 변경하고, 클릭 시 handleClick 함수로 찜 상태를 변경하고 해당 상태를 mutate를 통해 서버와 동기화한다.4-1. 찜 상태 반영 후 데이터 업데이트const insertFavoriteMutaion = useMutation({ mutationFn: () => insertFavorite(movie.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["favoritesMovieList"], }); queryClient.invalidateQueries({ queryKey: ["movie"], }); }, });insertFavoriteMutaion과 deleteFavoriteMutaion에서 각각 찜 추가 및 삭제가 성공하면 queryClient.invalidateQueries를 사용해 관련된 데이터를 새로고침하여 UI를 최신 상태로 반영하여 유지한다.5. 화면 상단에 보여질 찜한 영화 리스트 불러오기Supabase에서 두 테이블을 외래키 기준으로 조인하여 찜한 영화 목록만 가져온다.Query nested foreign tables through a join table - JavaScript: Fetch data | Supabase Docsactions/movieActions.tsexport async function getFavoritesMovie() { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("favorites") .select(`*, movie!favorites_movie_id_fkey(*)`) .order("created_at", { ascending: false }); if (error) { handleError(error); } return data.map((item) => ({ ...item, movie: { ...item.movie, favorite: true, }, })); }여기서 movie 정보에 favorite 프로퍼티를 추가하고, 찜한 영화 목록이므로 모든 영화에 favorite: true를 설정한다.응답 예시:[ { id: 78, created_at: '2025-03-22T13:19:51.598+00:00', movie_id: 1, movie: { id: 1, title: 'Dune: Part Two', overview: 'Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen while on a path of revenge against the conspirators who destroyed his family. Facing a choice between the love of his life and the fate of the known universe, Paul endeavors to prevent a terrible future only he can foresee.', image_url: 'https://image.tmdb.org/t/p/w500/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg', popularity: 3437.313, release_date: '2024-02-27', vote_average: 8.3, favorite: true } } ]6. 찜한 목록 Swiper 적용하여 화면 상단에 출력하기components/movie-favorites-list.tsx찜한 영화가 없을 경우, 데이터를 추가할 수 있도록 전체 영화 리스트로 스크롤 이동 버튼을 추가한다."use client"; import { useRef } from "react"; export default function MovieFavoritesList() { // 전체 영화 리스트로 스크롤 이동 const moveRef = useRef(null); const scrollToAllMovies = () => { moveRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }; return ( // ... {getFavoritesMovieQuery.isLoading ? ( ) : ( 좋아하는 영화를 추가해보세요. )} // ... ) };찜한 영화가 있을 경우, Swiper를 적용하여 캐러셀 형식으로 나타낸다.// ... import { getFavoritesMovie } from "actions/movieActions"; import { Navigation } from "swiper/modules"; import { Swiper, SwiperSlide } from "swiper/react"; import "swiper/css"; import "swiper/css/navigation"; export default function MovieFavoritesList() { // ... const getFavoritesMovieQuery = useQuery({ queryKey: ["favoritesMovieList"], queryFn: () => getFavoritesMovie(), }); return ( Your Favorite Movies💘 {getFavoritesMovieQuery.isLoading || !getFavoritesMovieQuery?.data.length ? ( // 데이터가 없는 경우 ) : ( ◀ ▶ {getFavoritesMovieQuery.data.map((item) => ( ))} )} ); } 3주차 회고이번 주 미션을 진행하면서, 처음에는 영화 목록을 가져올 때 페이지네이션을 고려하지 않고 RPC를 사용하여 데이터를 가져왔다. 하지만 구현하면서 이 방식이 최선일까? 라는 고민이 들었다.생각해본 접근 방식으로는, 처음 데이터를 가져온 후 가공하여 Recoil로 상태 관리하는 방법이 있었다.이렇게 하면 불필요한 데이터 호출을 줄일 수 있을 것 같다.하지만 이번 과제를 통해 레퍼런스를 참고하며 다양한 SQL 쿼리문을 활용하는 경험을 쌓을 수 있었던 점은 큰 배움이었다.다음주까지 모두 학습하고 사용자 인증을 적용한 후 사용자별 찜 기능, 상세 페이지에서 찜 추가/삭제 기능까지 구현할 계획이다.다음 주가 마지막 주라 강의가 많아 최대한 빠르게 수강하고 실습해야 하지만, Supabase의 하이라이트 부분을 배우게 되어 기대가 크다!
2025. 03. 14.
1
[인프런 워밍업 클럽 3기 풀스택] 2주차 발자국
[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) - 로펀 2주차는 Supabase Storage를 어떻게 사용하는지 학습했다. Supabase Storage 설정Supabase > DashBoard > Project > Storage버킷 생성하기Name of Bucket: 버킷 이름Public bucket: trueAdditional configuration>Allowed MIME types: image/* (이미지만 허용)Policies>For Full customizationPolicy name: 정책 이름Allowed operation: 누구나 CRUD를 할 수 있기 때문에 모두 허용Target roles: anon으로 누구나 허용Review 클릭>Save policy 클릭CRUD 각각에 대한 policies 생성server action에서 storage 접근하기export async function uploadFile(formData: FormData) { const supabase = await createServerSupabaseClient(); const files = Array.from(formData.entries()).map( ([name, file]) => file as File ); const results = await Promise.all( files.map((file) => supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }) ) ); return results; }Server Action을 사용하여 Supabase Storage에 파일을 업로드하는 함수이다. FormData를 받아 파일을 읽고, Supabase Storage에 업로드하는 방식으로 동작한다. const supabase = await createServerSupabaseClient();서버 측에서 Supabase 클라이언트를 생성하여 Storage API를 사용할 수 있도록 한다. const files = Array.from(formData.entries()).map( ([name, file]) => file as File );FormData에서 모든 항목을 배열로 추출하여 File 객체로 변환하여 파일을 추출한다.const results = await Promise.all( files.map((file) => supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }) ) ); return results;Promise.all()을 사용하여 여러 개의 파일을 동시에 업로드한다.supabase.storage.from(bucket).upload(filename, file, options)을 통해 지정한 버킷에 파일을 업로드한다.{ upsert: true }: 동일한 이름의 파일이 있을 경우 덮어쓴다. (insert + update) 업로드 된 결과를 반환한다. 2주차 미션github: https://github.com/thayoon/nextjs-supabase-dropbox-cloneDropbox Clone 프로젝트에 파일의 마지막 수정(업로드) 시간을 표시하는 기능을 추가하세요.파일 목록에서 각 파일의 “마지막 수정 시간”을 표시📌 참고 문서: Supabase Storage - 파일 목록 가져오기미션 해결 방법:list() 응답값 확인Supabase의 list() 함수를 사용하면 파일 정보를 가져올 수 있다.참고 문서에서 확인한 응답값은 다음과 같다:{ "data": [ { "name": "avatar1.png", "id": "e668cf7f-821b-4a2f-9dce-7dfa5dd1cfd2", "updated_at": "2024-05-22T23:06:05.580Z", "created_at": "2024-05-22T23:04:34.443Z", "last_accessed_at": "2024-05-22T23:04:34.443Z", "metadata": { "eTag": "\"c5e8c553235d9af30ef4f6e280790b92\"", "size": 32175, "mimetype": "image/png", "cacheControl": "max-age=3600", "lastModified": "2024-05-22T23:06:05.574Z", "contentLength": 32175, "httpStatusCode": 200 } } ], "error": null }이 중에서 updated_at이 파일의 마지막 수정 시간을 나타낸다. Server Action에서 list() 호출 및 데이터 반환actions/storageActions.tsexport async function searchFiles(search: string = "") { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { sortBy: { column: "updated_at", order: "desc" }, search, }); handleError(error); return data; }파일 목록을 가져오는 searchFiles() 함수를 구현한다.참고 문서를 통해 sortBy 옵션을 적용하여 updated_at 을 기준으로 내림차순 정렬하여 최신 파일이 먼저 오도록 설정한다. 클라이언트 컴포넌트에서 데이터 가져오기components/dropbox-image-list.tsx "use client"; import { useQuery, useMutation } from "@tanstack/react-query"; export default function DropboxImageList({ searchInput }) { const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); return ( {searchImageQuery.isLoading && } {searchImageQuery.data && searchImageQuery.data.map((image) => ( )} ); } useQuery를 사용해 서버에서 데이터를 가져온다.가져온 데이터를 DropboxImage 컴포넌트로 전달한다. 마지막 수정 시간 표시components/dropbox-images.tsx"use client"; import { IconButton, Spinner, Checkbox } from "@material-tailwind/react"; import { getImageUrl } from "utils/supabase/storage"; export default function DropboxImage({ image }) { // 마지막 수정 시간 한국 시간 변환 const updated = new Date(image.updated_at) .toLocaleString("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Seoul", }) .replace(",", ""); return ( {/* Image */} {/* fileName */} {/* update time */} 마지막 수정: {updated} {/* trash Button */} ); } updated_at 값을 toLocaleString()을 사용해 한국 시간으로 변환하고 화면에 표시한다.추가 구현 사항긴 파일명 생략 표시{image.name}className에 truncate를 적용하여 긴 파일명을 한 줄로 표시하고 넘칠 경우 "..."으로 생략한다. 사진 업로드 오름차순/내림차순 정렬처음에는 사용자의 정렬 방식 선택에 따라 서버에서 데이터를 다시 호출하도록 구현했지만, 비효율적이라고 판단하여 클라이언트에서 정렬을 처리하는 방식으로 변경했다.1차 시도 - 서버에서 정렬된 데이터 요청actions/storageAction.tsexport async function searchFiles(search: string = "", isLatest) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { sortBy: { column: "updated_at", order: isLatest ? "desc" : "asc" }, search, }); handleError(error); return data; } isLatest 값에 따라 정렬 순서를 desc(최신순) 또는 asc(오래된순)으로 설정사용자가 정렬 방식을 변경할 때마다 서버 요청이 발생하여 비효율적이다.2차 시도 - 클라이언트에서 정렬 처리components/dropbox-image-list.tsx"use client"; import { Spinner, Menu, MenuHandler, MenuList, MenuItem, Button, Typography, } from "@material-tailwind/react"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; const sortMenu = [ { title: "최신순", isLatest: true }, { title: "오래된순", isLatest: false }, ]; export default function DropboxImageList({ searchInput }) { const [openMenu, setOpenMenu] = useState(false); const [isLatest, setIsLatest] = useState(true); const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); return ( {/* 정렬 버튼 */} {isLatest ? "최신순" : "오래된순"} {sortMenu.map(({ title, isLatest }) => ( setIsLatest(isLatest)}> {title} ))} {/* 이미지 리스트 */} {searchImageQuery.isLoading && } {searchImageQuery.data && (isLatest ? searchImageQuery.data.map((image) => ( )) : searchImageQuery.data .slice() .reverse() .map((image) => ))} ); }서버 요청은 기본적으로 최신순으로 설정하고, 클라이언트에서 데이터를 reverse()하여 정렬을 변경하는 방식으로 개선했다. 사진 다중 삭제사진을 다중 선택하여 삭제하는 기능은 체크박스를 활용해 구현했다.사용자는 "전체 선택" 및 "선택 삭제" 기능을 통해 한 번에 여러 사진을 삭제할 수 있다.components/dropbox-image-list.tsx "use client"; export default function DropboxImageList({ searchInput }) { // ... const [allSelected, setAllSelected] = useState(false); const [isSelected, setIsSelected] = useState([]); const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); const deleteFileMutation = useMutation({ mutationFn: deleteFile, onSuccess: () => { searchImageQuery.refetch(); }, }); function handleChecked(isChecked) { setAllSelected(isChecked); if (isChecked && searchImageQuery.data) { setIsSelected(searchImageQuery.data.map((image) => image.name)); } else { setIsSelected([]); } } return ( 전체 선택 ({isSelected.length}/ {searchImageQuery.data && searchImageQuery.data.length} {!searchImageQuery.data && 0}) } checked={allSelected} onChange={(e) => handleChecked(e.target.checked)} /> 0 ? "blue" : "gray"} disabled={isSelected.length > 0 ? false : true} onClick={() => { setIsSelected([]); setAllSelected(false); deleteFileMutation.mutate(isSelected); }} > {deleteFileMutation.isPending ? : "선택 삭제"} // ... {searchImageQuery.isLoading && } {searchImageQuery.data && searchImageQuery.data.map((image) => ( ))} ); } 전체 선택: 사용자가 "전체 선택" 체크박스를 클릭하면, 모든 이미지가 선택된다.선택된 이미지 수와 총 이미지 수가 표시된다.선택 삭제: 사용자가 선택한 이미지들을 삭제할 수 있는 "선택 삭제" 버튼을 제공한다. 이미지가 선택되었을 때만 활성화된다.상태 관리: isSelected 배열에 선택된 이미지의 이름을 저장한다. allSelected 상태로 전체 선택 여부를 관리한다.선택 삭제 버튼을 클릭하면 deleteFileMutation을 호출하여 isSelected을 전달하여 삭제를 처리한다.삭제 작업이 완료되면 searchImageQuery.refetch()를 호출하여 이미지 리스트를 최신 상태로 갱신한다.components/dropbox-images.tsx"use client"; export default function DropboxImage({ image, isSelected, setIsSelected, setAllSelected, totalLength, }) { const isChecked = isSelected.includes(image.name); const handleChecked = (checked) => { setIsSelected((prev) => { if (checked) { const newSelected = [...prev, image.name]; if (newSelected.length === totalLength) setAllSelected(true); return newSelected; } else { setAllSelected(false); return prev.filter((item) => item !== image.name); } }); }; // ... return ( {/* Image */} {/* fileName */} {/* update time */} {/* multiple checkBox */} handleChecked(e.target.checked)} /> {/* trash Button */} ); } 개별 체크박스: 각 이미지에 대해 체크박스를 제공하고 사용자가 선택한 이미지를 isSelected 배열에 추가하거나 제거한다.상태 변화: 체크박스를 클릭하면 해당 이미지가 선택되거나 선택이 해제되고 선택된 모든 이미지가 삭제될 때 "전체 선택" 체크박스도 자동으로 갱신된다. 상태 연동: isChecked 개별 이미지에 체크박스의 체크 여부를 결정한다.isSelected 배열에 현재 이미지의 name 값이 포함되어 있는지 true, false로 설정한다.개별 이미지의 체크 여부가 isSelected 상태와 동기화된다.actions/storageActions.tsexport async function deleteFile(fileName: string[]) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .remove(fileName); handleError(error); return data; } supabase.storage.from(bucket).remove(['filename'])을 통해 지정한 버킷에서 배열에 포함된 모든 파일을 삭제 요청한다. 2주차 회고☀이번주는 강의를 들으며 여러 추가 기능이 생각나 실습하는 과정이 더욱 즐거웠다.다른 러너분이 한글 파일명 업로드 오류를 찾아내고 해결하는 모습을 보고 대단하다고 느꼈다.실습에서 영어 파일명으로만 업로드 했기 때문에 이런 오류가 발생하는지 몰랐다.상황을 공유해주신 러너분 덕분에 새로운 사실을 알게 됐다. (감사합니다!)다음에 시간이 난다면 강사님께서 말씀하신 해결 방법을 적용해 보고 싶다.목요일에 중간 점검 시간에 QnA 시간을 가졌는데, 정말 도움이 많이 됐다.특히, 포트폴리오 작성 요령과 개발자로서 필요한 역량을 채우는 방법을 핵심적으로 짚어주셔서 큰 도움이 되었다.나만의 특색을 찾고 포트폴리오에 잘 정리해 봐야겠다 느꼈다.벌써 2주차가 끝났는데, 배포까지 빠르게 진행해 보고 싶다. 다음주도 화이팅!
2025. 03. 09.
0
[인프런 워밍업 클럽 3기 풀스택] 1주차 발자국
[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) - 로펀 1주차는 Supabase, Next.js, Tailwindcss, Recoil, React Query 소개와 기본 문법, Server Action CRUD 사용법을 배웠다.Supabase 특징오픈 소스 프로젝트 (자체 서버구축 가능)PostgresSQL 기반 (복잡한 요구사항 개발 가능)Firebase 대비 저렴다양한 연동방식 지원 (+SDK, DB Connection, GraphQL, API) Next.jsNext.js의 폴더명이 곧 Route와 같다.page.tsx는 각 폴더의 대표 파일과 같다.app/layout.tsx는 화면 레이아웃을 잡아주는 Next.js 서버 컴포넌트이므로, 클라이언트에서만 동작하는 상태 관리 라이브러리(Zustand, Recoil, React Query 등)를 직접 사용할 수 없다.따라서 React Query의 Client Provider를 별도의 클라이언트 컴포넌트로 만들어 layout.tsx에 주입해야 한다.(서버 컴포넌트는 클라이언트 상태나 브라우저 API에 접근할 수 없음)서버 액션은 API 라우트 없이 서버에서 직접 실행 가능하다. React Query서버에서 가져온 데이터를 캐싱, 동기화, 상태 관리까지 자동으로 해주는 라이브러리useQuery(): 데이터 조회 (GET 요청)const todosQuery = useQuery({ queryKey: ["todos"], // 캐싱 키 queryFn: fetchTodos, // API 요청 함수 }); const { data, isLoading, error } = todosQuery; queryKey: 데이터 캐싱 키queryFn: 데이터 호출 함수data: fetchTodos의 반환 값isLoading: 데이터 로딩 여부 (true면 로딩 중)error: 요청 실패 시 에러 객체 useMutation(): 데이터 변경 (POST, PUT, DELETE)const mutation = useMutation({ mutationFn: addTodo, onSuccess: () => { console.log("성공!"); }, onError: (error) => { console.error("에러 발생:", error); }, }); mutationFn: API 호출 함수onSuccess: 성공 시 실행할 함수onError: 실패 시 실행할 함수 TODO LIST 만들기Supabase todo 테이블 정의Supabase Next.js 연결.env 환경변수 설정Supabase Project API Keys를 사용하여 환경변수 추가package.json 수정scripts에서 generate-types 옵션 수정Supabase 로그인 후 타입 생성generate-types 실행하여 데이터베이스 테이블의 타입 정의 가져오기 Next.js에서 Supabase 클라이언트 생성브라우저/서버 환경에서 사용할 Supabase 클라이언트 생성Next.js의 모든 request에서 Supabase 인증 토큰을 갱신하도록 middleware 설정 TODO 조회 - server actionexport async function getTodos({ searchInput = "" }): Promise { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("todo") .select("*") .like("title", `%${searchInput}%`) .order("created_at", { ascending: true }); if (error) { handleError(error); } return data; }getTodos 함수는 searchInput을 기본값 ""로 받아 Promise 타입을 반환한다.이 함수는 서버 환경에서 실행되며, Supabase 클라이언트를 사용하여 todo 테이블에서 데이터를 조회한다.title 컬럼에서 searchInput을 포함한 값을 검색하며created_at을 기준으로 오름차순으로 정렬하여 반환한다.검색된 데이터가 없거나 오류가 발생하면 handleError 함수가 호출된다. TODO 조회 - React Query useQuery()const todosQuery = useQuery({ queryKey: ["todos", searchInput], queryFn: () => getTodos({ searchInput }), }); return ( // ... {todosQuery.data && todosQuery.data.map((todo) => )} // ... ) useQuery는 todosQuery라는 데이터를 서버에서 가져오는 비동기 작업을 관리한다.queryKey는 ["todos", searchInput]으로 설정되어, searchInput에 따라 조회된 결과가 달라지도록 한다.queryFn은 서버 액션 getTodos를 호출해, searchInput에 맞는 TODO 데이터를 반환한다.반환된 데이터(todosQuery.data)가 있을 경우, map()을 사용하여 각 TODO 항목을 렌더링한다. 1주차 미션github: https://github.com/thayoon/nextjs-supabase-todolist생성된 TODO의 생성 시간을 저장하고 이를 표시하는 기능을 추가하세요.TODO 항목 옆에 생성 시간을 표시하기component/todo.tsxexport default function Todo({ todo }) { // 한국 시간, YYYY/MM/DD HH:MM 포맷 함수 const getTime = (time: string): string => new Date(time) .toLocaleString("en-US", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Seoul", }) .replace(",", ""); const createdTime = getTime(todo.created_at); return( // ... {isEditing ? ( setTitle(e.target.value)} /> ) : ( {title} ⏱️{createdTime} )} // ... ); } getTime 함수는 created_at 값을 한국 시간에 맞게 YYYY/MM/DD HH:MM 포맷으로 변환한다.isEditing이 false일 때, 생성된 TODO 항목의 생성 시간을 createdTime을 통해 표시한다.todo 매개변수는 useQuery로 가져온 데이터 객체이며, created_at 값을 이용해 생성 시간을 표시한다. (선택 사항) completed_at 필드를 추가하여 완료한 시간도 함께 저장Supabase에서 completed_at 필드를 추가하여, TODO 완료 시각을 저장한다.completed_at은 NULL을 허용하는 timestamptz 타입 칼럼이다.actions/todo-actions.ts (server action)updateTodo 함수는 completed 상태에 따라 completed_at 값을 업데이트한다.export async function updateTodo(todo: TodoRowUpdate) { const supabase = await createServerSupabaseClient(); console.log(todo); const { data, error } = await supabase .from("todo") .update({ ...todo, updated_at: new Date().toISOString(), completed_at: todo.completed ? new Date().toISOString() : null, }) .eq("id", todo.id); if (error) { handleError(error); } return data; } completed가 true일 경우 completed_at에 현재 시각을 저장하고, false일 경우 null을 저장한다.3. components/todo.tsx (React Query useMutation())completed_at 값을 확인하여 완료 시각을 표시한다.export default function Todo({ todo }) { // 한국 시간, YYYY/MM/DD HH:MM 포맷 함수 const getTime = (time: string): string => new Date(time) .toLocaleString("en-US", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Seoul", }) .replace(",", ""); const completedTime = todo.completed_at ? getTime(todo.completed_at) : null; const updateTodoMutation = useMutation({ mutationFn: () => updateTodo({ id: todo.id, title, completed, }), onSuccess: () => { setIsEditing(false); queryClient.invalidateQueries({ queryKey: ["todos"], }); }, }); return( { await setCompleted(e.target.checked); await updateTodoMutation.mutate(); }} /> {isEditing ? ( setTitle(e.target.value)} /> ) : ( {title} ⏱️{createdTime}{" "}{completedTime && `~ ${completedTime}`} )} // ... ); } completed 상태에 따라 체크박스를 변경하고, 완료된 경우 completed_at 시각을 표시한다.setCompleted로 상태를 갱신하고, updateTodoMutation을 호출하여 서버에서 completed_at 값을 업데이트한다.완료된 TODO 항목에 대해 completed_at 값이 존재하면 완료 시각을 getTime() 함수로 포맷하여 표시한다.정리:TODO 완료 상태 변경:사용자가 체크박스를 클릭하면 setCompleted로 completed 상태를 갱신한다.갱신된 상태는 updateTodoMutation.mutate()를 통해 서버로 전송되어 completed_at 값이 업데이트된다.완료 시각 컴포넌트 업데이트:서버에서 completed_at 값이 갱신되면, 해당 TODO의 완료 시각이 한국 시간으로 포맷되어 표시된다.완료된 경우에는 completed_at을 getTime() 함수를 통해 표시하고, 완료되지 않은 경우에는 null이므로 표시되지 않는다.쿼리 데이터 갱신:updateTodoMutation.onSuccess에서 queryClient.invalidateQueries({ queryKey: ["todos"] })를 호출하여 todos 쿼리의 데이터를 무효화한다.쿼리의 데이터가 무효화되면 React Query는 자동으로 서버에서 최신 데이터를 다시 불러와 화면에 반영한다. 1주차 회고☀사실 Next.js를 아주 짧게 배운 상태로 강의를 듣게 됐는데, 섹션1에서 중요한 부분을 잘 설명해주셔서 큰 어려움없이 수강할 수 있었다. 그리고 실습을 따라하면서 어느정도 흐름은 알게 된 것 같다. 그렇지만 다른 Next.js 강의를 수강하며 구체적인 동작과정을 더 배워야 할 것 같다. 다음 실습도 기대된다. 다음주도 화이팅!
풀스택
2024. 10. 27.
0
[인프런 워밍업 스터디 클럽 2기 FE] 과제 제출
[따라하며 배우는 자바스크립터, 리액트 A-Z - John Ahn] JS 과제Day 2 - 음식 메뉴 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_2 Day 3 - 가위 바위 보 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_3 Day 4 - 퀴즈 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_4 React 과제Day 9 - 예산 계산기 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_9이 과제까지 너무 즐겁게 했는데..Day 10 - 디즈니 플러스 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_10여기서 CSS의 어려움을 직면했다..Day 12 - 퀴즈 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_12Next.js로 도전해보려 했지만, 아직 설계 단계에서부터 막히는 부분이 많아 어떻게 접근해야 할지 잘 모르겠다. 강의를 다시 보며 Next.js로 차근차근 새롭게 만들어봐야겠다.그래도 이번 인프런 스터디 클럽으로 새롭게 알게된 내용도 너무 많았고,오랜만에 과제해서 대학생으로 돌아간 기분도 났다. 신청하길 정말 잘했다..!
2024. 10. 20.
0
[인프런 워밍업 스터디 클럽 2기 FE] 3주차 발자국
[따라하며 배우는 자바스크립터, 리액트 A-Z - John Ahn] 강의 수강 정리프레임워크 (Framework):애플리케이션을 만들기 위해 필요한 대부분의 요소를 포함하고 있으며, 사용자가 작성한 코드를 호출한다.라이브러리 (Library):특정 기능을 모듈화하여 제공하며, 사용자가 라이브러리를 호출하여 기능을 구현한다.리액트 컴포넌트의 두 가지 유형클래스형 컴포넌트 (Class Component)함수형 컴포넌트 (Functional Component)가상 돔은 간략하게 말해서 이전 가상 돔과 비교하는 디핑 알고리즘을 적용하여 돔 조작 비용을 줄이는 것웹팩: 오픈 소스 자바스크립트 모듈 번들러로, 여러 개의 파일을 하나의 자바스크립트 코드로 압축하고 최적화하는 라이브러리바벨: 최신 자바스크립트 문법을 지원하지 않는 브라우저에서도 작동하도록 변환해주는 라이브러리npx는 Node 패키지 실행을 도와주는 도구로, npx create-react-app ./로 npm 레지스트리에 있는 create-react-app 패키지를 통해 리액트를 설치한다.SPA는 웹사이트의 전체 페이지를 하나의 페이지에 담아 동적으로 화면을 변경한다.JSX를 사용하면 자바스크립트와 HTML 구조를 함께 사용할 수 있어 UI의 데이터 변화나 이벤트 처리 부분을 쉽게 구현할 수 있다.가상 DOM을 사용하여 변경된 부분만 실제 DOM에 적용하는데, Key 속성을 사용하면 어떤 부분이 바뀌었는지를 인식할 수 있다.React State란 컴포넌트의 렌더링 결과물에 영향을 주는 데이터를 갖고 있는 객체다.React Hooks로 class없이 state를 사용할 수 있는 새로운 기능으로 코드가 더 간결해지고 HOC 컴포넌트를 Custom React Hooks로 대체하여 많은 Wrapper 컴포넌트를 줄이게 된다.(HOC: 화면에서 재사용 가능한 로직만을 분리해서 component로 만들고, 재사용 불가능한 UI와 같은 다른 부분들은 parameter로 받아서 처리하는 방법이다. 즉, 컴포넌트를 인자로 받아서 새로운 리액트 컴포넌트를 처리하는 함수다.)TDD: 테스트 주도 개발로 React에서 React Testing Library와 Jest를 함께 사용하여 테스트할 수 있다.Next.js: React 기반의 서버 사이드 렌더링(SSR) 및 정적 사이트 생성(SSG) 프레임워크로, 페이지 기반의 라우팅을 지원하고 API 라우트를 통해 백엔드 로직을 포함할 수 있다.Pre-rendering: 페이지가 요청되기 전에 HTML을 생성하는 방식Data Fetching: getStaticProps와 getServerSideProps, getStaticPaths사용으로 데이터를 가져와 페이지에 전달할 수 있다.Type annotation, Type inference: 타입 주석을 사용해 변수의 타입을 명시하거나, 타입 추론을 통해 자동으로 타입을 결정하는 방법Type assertion: 변수의 타입을 강제로 지정하여 TypeScript의 타입 체크를 우회하는 방법JS 과제전체 과제 제출 - https://www.inflearn.com/blogs/9104Day 2 - 음식 메뉴 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_2 Day 3 - 가위 바위 보 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_3 Day 4 - 퀴즈 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_4 React 과제Day 9 - 예산 계산기 앱github : https://github.com/thayoon/study/tree/main/%5BFE%5DInflearn_WarmingUP_Club_Study_2nd/day_93주차 회고그동안 리액트로 진행한 프로젝트가 있었기 때문에 강의 듣기 전에 자신만만한 상태였는데 강의 시작하고 전 그냥 리액트의 리도 모르는 사람이 되었습니다! 그리고 제가 지금까지 해온 학습은 학습이 아니었구나를 깨닫게 되었습니다. 진짜 너무나도 얕은 지식으로 개발하고 있었더라구요.. 반성하게 됐습니다.리액트 강의를 듣는데 next.js랑 CI/CD까지 배울 수 있었다니.. 너무 좋은 기회가 됐고 next.js부분 부터 강의 듣기로도 바빠서 배운 내용 정리도 못했는데 다시 복습하면서 천천히 정리해 봐야겠습니다. 그리고 항상 상태관리 라이브러리로 zustand만 사용했었는데 리덕스 보니까 설계부터 중요하다고 느꼈습니다.미션을 다 못 끝냈지만.. 최대한 빠르게 끝내보겠습니다!!