블로그

찬우 이

인프런 워밍업 클럽 3기 풀스택 - 4주차 발자국

4주차 학습 내용Part 1. Git Repository 생성 및 초기 설정 진행이번 프로젝트는 기존 강의에서 학습했던 방식대로 GitHub에 레포지토리를 생성하고, 초기 설정을 진행하며 시작했다.폴더 구조와 기본적인 세팅은 이전과 동일하게 구성하여 빠르게 시작할 수 있었다.새롭게 만든 message 테이블에는 다음과 같은 컬럼들을 추가했다.id: 기본 키 (primary key)message: 메시지 내용sender: 보낸 사람의 UUIDreceiver: 받는 사람의 UUIDis_deleted: 삭제 여부 (boolean)created_at: 생성 시간 (timestamp) Part 2. 회원가입, 로그인 화면 제작이전에 하던대로 틀 잡고, components로 구분지으면서 제작했다.새롭게 배운 부분으로는 배경색을 그라데이션으로 준 부분이였다.  테일윈드 css로 편하게 gradient값을 줬다.사용법bg-gradient-to-r: 그라디언트를 오른쪽 방향으로 흐르게 함 / ex) to-r = to right  from-{color}: 그라디언트의 시작 색상 지정 / ex) from-green-400 to-{color}: 그라디언트의 끝 색상 지정 / ex) to-blue-500via-{color}: 그라디언트의 중간 색상 지정 / ex) via-pink-500  Part 3. Supabase Auth 소개 및 인증방식 기획Supabase Auth는 이메일 기반 인증부터 소셜 로그인까지 다양한 인증 방식을 제공하며, 회원가입, 로그인, 세션 유지 등을 쉽게 처리할 수 있는 백엔드 인증 서비스다.1. Confirmation URL 방식사용자 이메일로 인증 링크를 전송하고, 사용자가 해당 링크를 클릭함으로써 인증이 완료되는 방식이다.사용자가 회원가입(또는 로그인)을 하면, 이메일로 인증 링크가 전송되고사용자가 해당 링크를 클릭하면 Supabase가 사용자의 인증을 완료함  2. 6-Digit OTP 방식사용자 이메일로 6자리 숫자 코드(OTP)를 전송하고 사용자가 해당 코드를 입력해서 인증하는 방식  사용자가 이메일을 입력하면, Supabase는 해당 이메일로 6자리 OTP 코드를 전송하고사용자는 입력창에 이 코드를 입력하고 인증을 완료하게 된다.  Part 6. 채팅 화면 구현1. 유저 아바타 랜덤 이미지 사용https://randomuser.me/photos API를 활용해 유저 프로필 사진을 랜덤으로 가져옴별도 이미지 업로드 없이 테스트용 아바타를 빠르게 구현할 수 있음  const randomImage = https://randomuser.me/api/portraits/men/${index}.jpg; 2. javascript-time-ago 라이브러리채팅 메시지 시간 표시를 "1분 전", "3시간 전"처럼 사람이 보기 쉬운 형태로 변환국제화(i18n)도 지원함 (예: 한글/영어/중국어 등)import TimeAgo from "javascript-time-ago"; import ko from "javascript-time-ago/locale/ko.json"; TimeAgo.addDefaultLocale(ko); const timeAgo = new TimeAgo("ko"); timeAgo.format(new Date()) // → "방금 전" Part 7. Supabase Realtime 소개 & 채팅목록 구현 Supabase Realtime은 PostgreSQL 데이터베이스의 변경 사항을 실시간으로 감지해서 클라이언트에 push해주는 기능.기본적으로 WebSocket을 기반으로 작동하고, 내부적으로는 PostgreSQL의 logical replication 기능을 활용한다. 🔧 작동 방식클라이언트가 WebSocket으로 채널 생성.channel() 메서드를 이용해서 원하는 테이블과 이벤트 종류를 구독함예: message 테이블의 INSERT 이벤트Supabase 서버에서 PostgreSQL의 변경 스트림 감지PostgreSQL에서 발생하는 INSERT, UPDATE, DELETE 이벤트를 감지이를 wal2json 등의 logical decoding plugin을 통해 JSON으로 변환Supabase Realtime 서버가 이를 브로드캐스트WebSocket 연결된 클라이언트에게 변경된 데이터(payload)를 push로 전달함프론트에서 실시간 UI 반영받은 payload로 상태를 업데이트하거나 refetch()를 통해 데이터를 다시 불러와서 렌더링Part 9. 배포하기 vercel 배포Add New 파일을 열고 프로젝트를 선택해준다. 나의 깃허브와 연동시켜주고 배포를 원하는 프로젝트를 선택해준다 Deploy를 누르면 배포가 되게 되는데 그전에 프로젝트의 이름을 정하고,프로젝트에서 npm run build를 해서 배포시에 발생할 에러가 있는지 미리 체크한다.그리고 환경변수에는 프로젝트에 있는 .env 파일에 있는 코드를 복사해서 넣어줘야한다. 사진과 같은 에러가 발생했고, 현재 에러를 해결하는 방법은 2가지가 있다.하나는 직접 에러의 원인을 찾아 제거하는 방식 = <Spinner onPointerEnterCapture={undefined} onPointerLeaveCapture={undefined} />)}둘째는 any로 지정하는것 처럼 무시하고 지나가라는식의 방법으로 해결하는 방법이다.루트에 index.d.ts를 만들고, declare module "@material-tailwind/react";를 해줌으로써 material-tailwind로 발생하는 에러는 넘겨주는 것. 배포가 끝났고 이제 휴대폰에서도 정상적으로 동작한다!  미션: 그동안 만든 4개 프로젝트 배포하기선택 미션에는 채팅 메세지 삭제 기능 이나 채팅 읽음, 안 읽음 표시 등 추가 작업인데 우선 후순위로 미루고 배포에 집중함.  미션 이외로미션 이외로 나는 휴대폰으로 채팅하는 모습을 확인하고 싶어서 배포를 진행하고 휴대폰을 확인했다.하지만 이메일 인증을 하고 이동하는 코드는 아래처럼 되어 있어서 연결이 깨지게 되어 있다. const signupMutation = useMutation({ mutationFn: async () => { const { data, error } = await supabase.auth.signUp({ email, password, options: { emailRedirectTo: "http://localhost:3000/signup/confirm", }, }); if (data) setConfirmationRequired(true); if (error) alert(error.message); }, });그렇다고 여기서 저부분을 배포 주소로 바꾸자니, 나중에 버그나 새로운 기능을 넣기 위해 작업할때는 또 번거롭게 바꿔줘야 하기때문에 이 문제를 어떻게 해결할지에 대한 고민을 했다. const redirectUrl = process.env.NEXT_PUBLIC_REDIRECT_URL || (process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://supabase-instagram-clone.vercel.app"); const signupMutation = useMutation({ mutationFn: async () => { const { data, error } = await supabase.auth.signUp({ email, password, options: { emailRedirectTo: `${redirectUrl}/signup/confirm`, // ✅ 변수 사용 }, }); if (data) setConfirmationRequired(true); if (error) alert(error.message); }, });우선 리다이렉트를 구분하기 위해 로컬호스트에서는 로컬호스트를 보내고 수퍼베이스에서는 수퍼베이스만 보내도록 수정함.버셀에서 환경변수를 추가해주고수퍼베이스에서도 배포한 링크를 연결시켜주니 해결했다.말은 쉬운데 사실 해결하려고 몇시간을 싸매고 한 결과다..ㅠ성공한 사진!!🎉🎉UI적으로는 다듬지 못해서 엉망인 모습을 보이고, 남들이 보기엔 별거 아닐지라도 나한테는 휴대폰과 노트북으로 서로 소통을 하는게 이렇게 돌아가는구나 라는것을 느끼고 되게 신기했다.---📝 마지막 회고4주차는 지금까지 중 가장 힘든 한 주였던 것 같다.평소엔 기능을 하나씩 구현해왔는데 이번 주차는 로그인과 채팅 기능을 함께 다루다 보니 정신없고 벅찼다.점점 기능 난이도가 깊어지고 있다는 느낌도 들어서, 생각보다 어렵게 다가왔다.특히 이번 주에는 코드를 따라 치긴 했지만 이해가 부족했던 순간들도 많았고주말에 큰 약속이 있어서 과제를 빨리 끝내야 한다는 부담감 때문에 급하게 하다 보니 놓친 부분도 많았던 것 같다.그렇게 어느새 4주차.. 이 스터디의 마지막 주차가 되어버렸다.돌이켜보면 한 달 전의 나와 비교해 분명 많이 성장한 시간이었다. 📌 이번 한 달간, 내가 얻은 것들처음엔 next.js와 tailwind를 조금만 다룰 줄 알았고 전역 상태 관리도 zustand 정도만 써봤었다.하지만 이번 강의를 통해→ next.js의 프로젝트 구조 잡는 방식→ tailwind를 더 효율적으로 사용하는 법→ 그리고 새롭게 배운 recoil, supabase, server actions, vercel 배포까지!정말 다양한 기술들을 직접 써보면서 익힐 수 있었다. 🙃 좋았던 순간, 힘들었던 순간강사님 코드랑 똑같이 썼는데도 에러가 나서 괜히 억울했던 순간,반대로 내가 직접 구현한 기능이 잘 작동해서 뿌듯했던 순간도 있었다.물론 한 번 배웠다고 해서 금방 능숙하게 다루진 못하지만이 강의를 통해 전반적인 흐름을 훑을 수 있었고이제는 Supabase를 활용해 어떤 기능을 만들어볼까? 상상해보는 재미도 생겼다. 🎉 마지막으로다음엔 이번 주차에서 만들었던 인스타그램 클론을 좀 더 확장해서게시물 기능까지 구현해보고 싶은 욕심도 있다.좋은 아이디어가 생긴다면? 더 멋있는 기능을 섞어서 구현해보고 싶다...어쨌든, 이번 스터디는 정말 기억에 남는 한 달이었다.한 달 동안 함께 완주해온 스터디 분들 정말 수고 많았고,좋은 강의 만들어주신 강사님께도 진심으로 감사합니다! 🎉

풀스택풀스택미션인프런워밍업클럽supabasenext.js

찬우 이

인프런 워밍업 클럽 3기 풀스택 - 3주차 발자국

3주차 학습 내용Part 1. Git Repository 생성 및 초기 설정 진행이전과 동일하게  npx create-next-app@14 inflearn-supabase-netflix-clone 해주고, 2주차의 코드를 가져왔다.테이블의 column을 지정해주고, 강사님께서 가져오신 영화 데이터를 받아 DB를 구성했다. Part 2. UI 작업이번에는 다이나믹 라우트를 사용해서 포스터를 클릭하면 해당 포스터의 id를 들고 상세페이지로 이동한다. 사용법은 간단하게 대괄호를 열고 닫은 폴더명을 사용하면 됨.그거 말고는 전체적으로 예제와 같게 UI작업을 했고, 이전 드롭박스때 처럼 grid로 간단하게 반응형을 구현해줌.  Part 3. 영화 검색 기능 & 영화 개별 상세페이지 구현🌀 Recoil 사용 방법✅ 1. 설치먼저 Recoil을 설치한다: npm install recoil✅ 2. 서버 컴포넌트(layout.tsx)에 직접 쓰면 ❌Recoil은 클라이언트 사이드 전용 라이브러리이기 때문에layout.tsx에서 바로 사용하면 에러가 발생한다.✅ 3. RecoilProvider 따로 만들어 감싸기config/RecoilProvider.tsx 파일을 만들고, 아래와 같이 구성한다// config/RecoilProvider.tsx "use client"; import { RecoilRoot } from "recoil"; export default function RecoilProvider({ children }: React.PropsWithChildren) { return <RecoilRoot>{children}</RecoilRoot>; } ✅ 4. 전역 상태 정의 (atoms.ts)utils/recoil/atoms.ts에 전역 상태를 선언한다:// utils/recoil/atoms.ts import { atom } from "recoil"; export const searchState = atom({ key: "searchState", default: "", }); ✅ 5. 컴포넌트에서 사용하기import { useRecoilState } from "recoil"; import { searchState } from "utils/recoil/atoms"; const [search, setSearch] = useRecoilState(searchState); Part 4. 무한 스크롤 기능 구현하기 & 더 나은 검색을 위한 SEO 작업하기 핵심 포인트react-query의 useInfiniteQuery 사용react-intersection-observer로 마지막 요소 감지해서 추가 데이터 불러오기🔧 구현 흐름useInfiniteQuery에서 pageParam으로 현재 페이지 관리searchMovies()를 호출해서 검색어와 페이지 정보를 넘김getNextPageParam으로 다음 페이지 조건 처리마지막 아이템에 ref 붙여서 화면에 보이면 자동 로딩 "use client"; import { useInfiniteQuery } from "@tanstack/react-query"; import MovieCard from "./movie-card"; import { searchMovies } from "actions/movieActions"; import { Spinner } from "@material-tailwind/react"; import { useRecoilValue } from "recoil"; import { searchState } from "utils/recoil/atoms"; import { useInView } from "react-intersection-observer"; import { useEffect } from "react"; export default function MovieCardList() { const search = useRecoilValue(searchState); const { data, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery({ initialPageParam: 1, queryKey: ["movie", search], queryFn: ({ pageParam }) => searchMovies({ search, page: pageParam, pageSize: 12 }), getNextPageParam: (lastPage) => lastPage.page ? lastPage.page + 1 : null, }); const { ref, inView } = useInView({ threshold: 0, }); useEffect(() => { if (inView && hasNextPage && !isFetching && !isFetchingNextPage) { fetchNextPage(); } }, [inView, hasNextPage]); useEffect(() => { console.log(inView); }, [inView]); return ( <div className="grid gap-1 md:grid-cols-4 grid-cols-3 w-full h-full"> {isFetching || (isFetchingNextPage && <Spinner />)} <> {data?.pages ?.map((page) => page.data) ?.flat() ?.map((movie) => ( <MovieCard key={movie.id} movie={movie} /> ))} <div className="h-1" ref={ref}></div> </> </div> ); } 🔧 구현 흐름Next.js의 generateMetadata()를 사용해서페이지별로 동적으로 메타 태그 생성. 💡 포인트페이지 타이틀에 영화 제목 자동 반영설명은 영화 overview에서 가져옴OG 이미지도 함께 등록해서 링크 공유 시 썸네일 출력됨export async function generateMetadata({ params, searchParams }) { const movie = await getMovie(params.id); return { title: movie.title, description: movie.overview, openGraph: { images: [movie.image_url], }, }; }  미션: 북마크 기능 만들기미션: 찜하기 기능을 만들어서, 찜한 영화를 영화 리스트 화면의 최상단에 보여주기원래는 영화 리스트 화면에서 북마크한 영화를 최상단에 보여주는 미션이었지만,실제 사용자 입장에서 불편할 것 같아서 헤더에 "bookmark" 메뉴를 따로 만들고해당 페이지에서 북마크한 영화만 모아보는 방식으로 약간 변형해서 구현했습니다. 1. Supabase 테이블에 bookmark 추가해주기Supabase의 movie 테이블에 bookmark라는 boolean 컬럼을 추가하고,기본값을 false로 설정하기 위해 SQL Editer에서 UPDATE movie SET bookmark = false WHERE bookmark IS NULL;를 입력해줌 2. 북마크 토글 함수 생성// actions/movieActions.ts export async function toggleBookmark(id: number, current: boolean) { const supabase = await createServerSupabaseClient(); const { error } = await supabase .from("movie") .update({ bookmark: !current }) // 현재 값 반대로 토글 .eq("id", id); // 해당 ID만 업데이트 handleError(error); } movieActions에서 북마크 토글을 지원하는 함수를 만들어줌. 3. 상세 페이지에서 북마크 버튼 만들기// app/movies/[id]/ui.tsx "use client"; import { toggleBookmark } from "actions/movieActions"; import { useState, useTransition } from "react"; import { BookmarkIcon } from "@heroicons/react/24/outline"; import { BookmarkIcon as BookmarkSolidIcon } from "@heroicons/react/24/solid"; export default function MovieDetail({ movie }) { const [bookmarked, setBookmarked] = useState(movie.bookmark); const [isPending, startTransition] = useTransition(); const handleClick = () => { setBookmarked((prev) => !prev); // UI 먼저 변경 startTransition(async () => { await toggleBookmark(movie.id, bookmarked); // 서버에 실제 반영 }); }; return ( <div> <h1>{movie.title}</h1> <button onClick={handleClick} disabled={isPending}> {bookmarked ? ( <BookmarkSolidIcon className="w-6 h-6 text-yellow-500" /> ) : ( <BookmarkIcon className="w-6 h-6 text-gray-500" /> )} </button> </div> ); } npm install @heroicons/react을 설치해서 토글 아이콘을 생성함.2번에서 만든 함수를 연결해서 북마크를 키고 끄면 DB에도 연동되게 만들었음. 4. 북마크한 영화만 보여주는 페이지 만들기// actions/movieActions.ts export async function getBookmarkedMovies() { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("movie") .select("*") .eq("bookmark", true) // bookmark가 true인 영화만 가져옴 .order("id", { ascending: true }); // id 순으로 정렬 handleError(error); return data; }movieActions에서 북마크한 영화만 보여주는 함수를 만들어줌. // app/movies/bookmark/page.tsx import MovieCard from "@/components/movie-card"; import { getBookmarkedMovies } from "actions/movieActions"; export default async function BookmarkPage() { const movies = await getBookmarkedMovies(); return ( <div className="grid grid-cols-3 gap-4"> {movies.map((movie) => ( <MovieCard key={movie.id} movie={movie} /> ))} </div> ); }header에서 북마크를 클릭하면 북마크 페이지로 이동하게 만들었고위에서 만든 북마크 영화 함수를 사용해서 북마크한 영화만 보여주도록 만들었음. 아쉬운 부분무한스크롤이 아직 조금 어려워서 북마크 페이지에서는 아직 구현해지 못했다.추후에 좀 더 공부해보고 넣어주면 좋을 것 같다!회고이번 주차 수업 중에서는 무한 스크롤이 가장 어려웠지만,과제는 이전 주차처럼 단순히 '딸깍하면 되는 문제'가 아니어서"어떻게 구현할까?" 부터 고민하는 재미가 있었다.Supabase에서 테이블을 직접 작성하고,서버 액션에서 원하는 기능을 하는 함수를 만들고,그 함수를 페이지에 붙여서내가 의도한 대로 동작하는 걸 확인했을 때 정말 재밌었다.뭔가 시간이 지나면서 점점 supabase와 친해지는 느낌을 받았다.엄청 어렵지는 않지만,약간만 생각하면 풀리는 적당한 난이도라서 더 좋았던 과제였다.

풀스택풀스택미션인프런워밍업클럽supabasenext.js

hee j

[인프런 워밍업 클럽 3기 풀스택 ] 2주차 발자국

목차Dropbox Clone ProjectDrag & Drop 할 영역 설정 및 서버에 파일 전송supabase의 Storage에 첨부파일 업로드첨부파일 검색, 삭제 2주차 미션파일의 마지막 수정(업로드) 시간을 표시하기파일명을 UUID로 변경하여 업로드하기Dropbox Clone Project1. Drag & Drop 할 영역 설정Drag & Drop 라이브러리 설치npm i --save react-dropzonefile-dragdropzone.tsx 파일div: 파일을 받는 영역 태그input: 파일 정보를 받는 태그isDragActive: 어떤 파일을 드래그 앤 드롭할 때 영역에 무엇을 보여줄지 정할 수 있게 해주는 값 ex) 드래그를 했다면? 파일을 여기에 드롭: 아니라면 파일을 드래그 앤 드롭을 해라 라는 문구 출력formData에 파일 이름과 파일 정보를 담아서 전송multiple을 true로 작성하여 여러 파일을 받을 수 있게 한다 export default function FileDragDropZone() { const uploadImageMutation = useMutation({ mutationFn: uploadFile, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["images"], // images로 시작하는 것들 전부 리셋 }); }, }); const onDrop = useCallback(async (acceptedFiles) => { // 10개 이하의 파일만 업로드함 if (acceptedFiles.length > 0 && acceptedFiles.length <= 10) { const formData = new FormData(); acceptedFiles.forEach((file) => { formData.append(file.name, file); }); await uploadImageMutation.mutate(formData); } }, []); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, multiple: true, }); return ( <div {...getRootProps()} className="w-full border-4 border-dotted border-blue-700 flex flex-col items-center justify-center py-20 cursor-pointer" > <input {...getInputProps()} /> {uploadImageMutation.isPending ? ( <Spinner /> ) : isDragActive ? ( <p>파일을 놓아주세요.</p> ) : ( <p>파일을 여기에 끌어다 놓거나 클릭하여 업로드 하세요.</p> )} </div> ); }  2. supabase의 Storage에 첨부파일 업로드.env 파일에 Storage 명 추가NEXT_PUBLIC_STORAGE_BUCKET=miniboxroot/actions 폴더 생성storageActions.ts 파일에 storage에 업로드할 함수 작성export async function uploadFile(formData: FormData) { const files = Array.from(formData.entries()).map( ([name, file]) => file as File ); // all(): 여러 파일을 한 번에 업로드 진행하기 위해 사용 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; 3. 첨부파일 검색, 삭제storageActions.ts 파일에 파일 검색, 파일 삭제 함수 추가export async function searchFiles(search: string = "") { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { search }); // path, options, parameters handleError(error); return data; } export 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; } 2주차 미션[미션 완성 이미지][파일의 마지막 수정(업로드) 시간을 표시하기]dropbox-image.tsxstorage에서 받아오는 정보 내에 마지막 수정 시간을 담고 있는 updated_at을 가져와 표시formData로 날짜와 시간의 가독성을 높여줌const formData = (dateString: string) => { return format(new Date(dateString), "yyyy-MM-dd HH:mm:ss"); }; return ( ... {/* FileName */} <div>{image.name}</div> <div>수정된 시간: {formData(image.updated_at)}</div> ... ) [파일명을 UUID로 변경하여 업로드하기]- 한글명 파일이 업로드 되지 않아 uuid로 파일 명을 변경하여 업로드 해보았음- 업로드는 잘 되나 같은 파일을 업로드 하여도 파일 명이 변경되어 업로드 되기 때문에 새로 업로드 되어 업로드 시간이 변경되지 않아 적용하지는 않음uuid 설치하기npm install uuidstorageActions.tsext 변수에 첨부 파일의 확장자 추출fileName 변수에 uuid+.확장자를 합쳐 파일명 생성const results = await Promise.all( files.map((file) => { const ext = file.name.split(".").pop(); // 확장자 추출 const fileName = `${uuidv4()}.${ext}`; // UUID 기반 파일명 생성 supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(fileName, file, { upsert: true }); }) );

풀스택풀스택next.jsreactsupabase워밍업발자국

찬우 이

인프런 워밍업 클럽 3기 풀스택 - 2주차 발자국

2주차 학습 내용Part 1 - Git Repository 생성 및 초기 설정 진행create-next-app을 통해 초기 세팅을 했으며,이전에 TODO에서 했던 코드들을 일부 가져와서 빠르게 세팅함. Part 2 - UI 작업알게된 사실 - page.tsx에는 클라이언트 컴포넌트를 사용하면 좋지않다.그 이유는 나중에 메타데이터를 쓰고 하는데 그건 서버 컴포넌트에서만 돌아가기 때문에 피해줘야 한다.나는 평소 flex만 사용하고 grid는 잘 사용하지 않는다. 항상 쓰던것만 써서 그렇기도 하고 grid로 편하게 구현하는 것도 flex로 구현은 가능하기 때문에 그렇게 했다. 강의에서는 grid를 사용했고 디테일하진 않지만 간단하게 3단계로 반응형도 쉽게 구현되는 모습을 보고 grid를 다시보게 됨className="grid md:grid-cols-3 lg:grid-cols-4 grid-cols-2"그 이후로는 컴포넌트 별로 분리해서 퍼블리싱 작업을 구현했다. Part 3 - 파일 업로드 구현(Supabase Storage) 사진을 업로드 하는데 알 수 없는 에러가 있었고, 분명 코드도 다른부분이 없는데 문제가 생겨서 오랫동안 붙잡고 있었다.원인은 사진이름이 한글로된 경우 안되는 부분이였고, 잠시 오류 수정으로 고쳐서 한글이름으로 된 사진도 업로드가 가능하게 했다. 하지만 한글이름이 아닌 a__a__a같은 이름으로 저장되는 문제가 발생해서 이 문제는 추후 고쳐봐야 할 문제 같다.// actions/storageActions.ts function sanitizeFileName(fileName: string) { return fileName .normalize("NFKD") // 유니코드 정규화 .replace(/[^\w.-]/g, "_") // 특수 문자 제거 .replace(/\s+/g, "_") // 공백을 `_`로 변경 .toLowerCase(); // 소문자로 변환 } export async function uploadFile(formData: FormData) { const supabase = await createServerSupabaseClient(); const file = formData.get("file") as File | null; if (!file) { console.error("❌ 업로드할 파일이 없습니다."); throw new Error("파일이 없습니다."); } // 파일 이름을 안전한 형식으로 변환 const safeFileName = sanitizeFileName(file.name); console.log("✅ 변환된 파일 이름:", safeFileName); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET!) .upload(safeFileName, file, { upsert: true }); if (error) { console.error("❌ Supabase 업로드 실패:", error.message); throw new Error(error.message); } return data; }  Part 4 - 파일제거 구현, Darg & Drop, 멀티파일 업로드 구현Darg & Drop을 위해 설치해줌.  npm i --save react-dropzone이번주 미션 - 파일의 마지막 수정(업로드) 시간을 표시하는 기능을 추가하기!// components/dropbox-image.tsx <div>생성일: {formatDate(image.updated_at)}</div> 간단하게 이 부분 넣어서 해결함. 미션 이외로...- 강사님과 같은 코드로 하니까 생긴 에러가 있어서 코드를 약간 수정함.수정한 부분은 actions/storageActions.ts 의 uploadFile 부분임1⃣ 파일 필터링 (undefined 값 제거)✅ 첫 번째 코드 (위 코드) → undefined 또는 잘못된 파일 제거const files = Array.from(formData.entries()) .map(([name, file]) => file as File) .filter((file) => file instanceof File && file.name); // ✅ undefined 제거 filter()를 사용하여 undefined 또는 비정상적인 파일을 제거파일이 null이거나 undefined면 upload()에서 에러 발생 가능성이 있음 → 이를 방지❌ 두 번째 코드 (아래 코드) → 필터링 없음const files = Array.from(formData.entries()).map(([name, file]) => file as File); undefined 파일이 포함될 가능성이 있음 → 업로드 시 오류 발생 가능 2⃣ 파일명 변환 (sanitizeFileName)✅ 첫 번째 코드 (위 코드) → 파일명 변환 추가function sanitizeFileName(fileName: string) { return fileName .normalize("NFC") // ✅ 한글 깨짐 방지 .replace(/[^a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣_.-]/g, "_"); // ✅ 특수 문자 제거 } const safeFileName = sanitizeFileName(file.name); 특수 문자, 공백 제거 (file.name을 정리)한글 깨짐 방지 (NFC 정규화)→ Supabase는 일부 특수 문자나 공백이 포함된 파일명을 허용하지 않으므로 안정적❌ 두 번째 코드 (아래 코드) → 원본 파일명 그대로 사용supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }); 파일명을 그대로 사용하기 때문에, 특수 문자나 공백이 포함되면 Supabase에서 오류 발생 가능 3⃣ async 처리 및 오류 핸들링✅ 첫 번째 코드 (위 코드) → async 사용 및 오류 처리const results = await Promise.all( files.map(async (file) => { // ✅ async 사용 const safeFileName = sanitizeFileName(file.name); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(safeFileName, file, { upsert: true }); if (error) { // ✅ 오류 처리 console.error("❌ Supabase 업로드 실패:", error.message); throw new Error(error.message); } return data; }) ); 각 파일 업로드가 비동기(async)로 처리됨오류 발생 시 console.error로 출력하고 예외 처리각 파일 업로드 후 결과(data) 반환❌ 두 번째 코드 (아래 코드) → 오류 핸들링 없음const results = await Promise.all( files.map((file) => supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }) ) ); async 키워드 없이 바로 upload() 실행오류가 발생해도 catch되지 않으며, 전체 업로드가 실패할 가능성이 있음업로드 성공 여부를 확인할 방법 없음 (data를 반환하지 않음)  2주차 회고 2주차에는 중간점검을 하는 시간을 가졌다. 수강생들이 하고 싶었던 질문을 하나하나 답변해주시는 시간을 가져서 꽤 유용한 시간이였고, 더 열심히 하자는 마음을 다지는 계기가 되었다.첫주때보단 수퍼베이스에 적응을 하는거같다. 아직 친해지기에는 시간이 더 많이 필요할꺼 같긴한데 정처기 준비하고 CS 스터디 하고, 다른 프젝도 마무리 하고, 매일 알고리즘 문제도 풀고 있다보니 시간이 많이 부족한 것 같다.중간점검때 강사님께서 시간관리에 대한 얘기도 했었는데, 매우 동감하는 부분...시간 관리나 스케줄 관리를 잘 해야 할 것 같다.. 의욕만 앞서서 살짝 망하는거 같기도함.그래도 뭐 흥미있고 재미있으니까 만족한다.

풀스택풀스택미션인프런워밍업클럽supabasenext.js

hee j

[인프런 워밍업 클럽 3기 풀스택 ] 1주차 발자국

목차Next.js, TailwindCss, Recoil, supabase 특징Todo List 미션  Next.jsReact 기반의 풀스택 프레임워크SSR(서버사이드 렌더링) 지원서버에서 미리 HTML을 렌더링하여 SEO와 초기 로딩 속도 개선fetch 등의 API 요청을 서버에서 처리해 클라이언트의 부담 감소SSG(정적 사이트 생성) 지원빌드 시 HTML을 미리 생성하여 빠르게 페이지 로딩파일 기반 라우팅pages/ 폴더 내 파일이 자동으로 라우트 됨app/ 디렉토리에서는 레이아웃 공유 & 동적 라우팅 가능서버 컴포넌트 & 클라이언트 컴포넌트 지원 API Routes 제공백엔드 서버 없이 NextJS 내에서 API 구축 가능이미지 최적화<Image /> 컴포넌트를 사용하면 자동으로 이미지 크기 조절 & 포맷 변환웹페이지 속도 향상SEO 최적화Middleware 지원요청이 처리되기 전에 인증, 리디렉션, 캐싱 등 제어 가능   TailwindCss유틸리티 퍼스트 방식의 CSS 프레임워크미리 정의된 클래스를 조합하여 빠르게 스타일 적용하기 때문에 CSS를 직접 작성할 필요 없음 => 개발 속도 향상 RecoilFacebook에서 개발한 React 전역 상태 관리 라이브러리React의 Context API보다 강력하고, Redux보다 간단하게 사용할 수 있음간단한 전역 상태 관리useState 처럼 쉽게 사용할 수 있음Atom을 이용해 상태를 관리하고 여러 컴포넌트에서 공유 가능 비동기 상태 관리 지원Selector를 이용하면 useEffect 없이도 비동기 데이터 관리 가능Redux보다 가볍고 React의 상태 관리 방식과 유사해 학습 부담이 적음   supabase오픈 소스 백엔스 서비스로 Firebase의 대체제로 사용됨PostgreSQL 기반의 데이터베이스RDBMS 기능 제공JSONB 데이터 타입 지원인증 권한 관리이메일, OAuth(Google, GitHub 등), Magic Link 로그인 지원 Row-Level Security(RLS) 를 통해 사용자별 데이터 접근 제한 가능스토리지 제공이미지, 파일 업로드 가능접근 권한을 설정해 보안 유지서버리스 함수(Edge Functions) 지원서버리스 API 생성 가능TypeScript와 호환Firebase 보다 쉬운 SQL 기반 데이터 관리  1주차 미션 - TODO List 제작 미션Next.js + Supabase 기반의 TODO List 제작생성 날짜와 수정 날짜 표시하기 TODO list 만들기생성 날짜와 수정 날짜 표시하기날짜를 "yyyy-MM-dd HH:mm:ss" 형태로 보여주기 위해 date-fns 설치 npm install date-fnscreated_at과 updated_at 값을 각각 상태로 관리하고,updated_at 값이 있다면 "수정됨:"이라는 텍스트와 함께 표시하고, 없다면 created_at 날짜만 표시// todo.tsx export default function Todo({ todo }) { const [created_at] = useState(todo.created_at); const [updated_at] = useState(todo.updated_at); const formatDate = (dateString: string) => { return format(new Date(dateString), "yyyy-MM-dd HH:mm:ss"); }; return <span className="text-sm text-gray-500"> {updated_at ? "수정됨: " + formatDate(updated_at) : formatDate(created_at)} </span>   

풀스택풀스택next.jsreactsupabase워밍업발자국

찬우 이

인프런 워밍업 클럽 3기 풀스택 - 1주차 발자국

1주차 학습 내용Firebase vs SupabaseFirebase 특징BaaS: 서버 없이도 빠르게 앱 출시 가능커뮤니티, 문서화 잘되어 있음, 단순한 NoSQL 기반으로 돌아감Supabse보다 비쌈, 오픈소스 아님NoSQL기반이라 복잡한 쿼리 불가하고, 웹 개발엔 그닥,, Supabase 특징오픈소스 구성, PostgreSQL 기반다양한 연동 방식을 지원함커뮤니티, 문서화 부족비교적 적은 기능 Server Action서버에서 실행되는 비동기 함수API 호출 없이 서버에서 바로 데이터 변환 메타데이터SEO를 정의하는 방식Static과 Dynamic 방식이 있는데 추 후 더 배울 예정Tailwindcss<div class="bg-blue-500 text-white p-4 rounded-lg shadow-lg"> 테일윈드 CSS 예제 </div>유틸리티 퍼스트(Utility-First) 방식의 CSS 프레임워크로, 미리 정의된 클래스를 조합하여 빠르게 스타일을 적용할 수 있도록 도와준다.CSS를 직접 작성할 필요 없이 클래스만 조합하여 스타일링 가능 RecoilReact에서 상태 관리를 쉽게 할 수 있도록 도와주는 상태 관리 라이브러리Redux 같은 라이브러리보다 가볍고 사용법이 간단함전역 상태뿐만 아니라 컴포넌트 간의 상태 공유를 효율적으로 관리할 수 있음비동기 상태 관리도 지원하여 서버 데이터를 다룰 때도 유용함  React Query React Query는 비동기 데이터(fetching, caching, synchronization)를 효율적으로 관리하는 라이브러리다.서버 상태 관리에 특화되어 있으며, API 호출 후 데이터를 캐싱하고 자동으로 최신 상태를 유지할 수 있도록 도와준다.React에서 API 요청을 효율적으로 관리하려면 필수적인 라이브러리다.1주차 미션1주차 미션에서는 강의에서 배운 TODO 앱을 기반으로,새로 생성한 TODO는 생성된 시간(created_at)을 표시하고,수정한 TODO는 수정된 시간(updated_at)을 즉시 UI에 반영하도록 구현하는 것이 목표였다. // ui.tsx const createTodoMutation = useMutation({ mutationFn: () => createTodo({ title: "New TODO", completed: false, created_at: new Date().toISOString(), }), onSuccess: () => { todosQuery.refetch(); }, });✅ useMutation을 통해 새로운 TODO를 생성할 때, created_at을 추가하여 서버에 요청하도록 구현했다.✅ 이를 통해 TODO가 생성된 시간을 함께 저장할 수 있도록 설정했다. // todo.tsx const [updatedTime, setUpdatedTime] = useState(todo.updated_at); const updateTodoMutation = useMutation({ mutationFn: () => updateTodo({ id: todo.id, title, completed, updated_at: updatedTime, }), onSuccess: () => { setIsEditing(false); queryClient.invalidateQueries({ queryKey: ["todos"], }); }, }); ---------------------- <> <p className={`flex-1 ${completed && "line-through"}`}>{title}</p> <p> {new Date(todo.updated_at ?? todo.created_at).toLocaleString( "ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "numeric", minute: "2-digit", hour12: true, } )} </p> </>✅ 수정할 때마다 updated_at을 추가하여 최신화된 시간을 서버로 전송하도록 설정했다.✅ 수정된 TODO(updated_at)가 있다면 수정 시간을, 그렇지 않다면 생성 시간(created_at)을 표시하도록 구현했다.✅ 기본적으로 ?? 연산자를 사용해 updated_at이 존재하는 경우 이를 우선적으로 표시하도록 처리했다.✅ 시간 포맷이 "2025.03.09 오후 7:34" 형식으로 나타나도록 toLocaleString()을 활용해 변환했다.  1주차 회고 내가 이 강의를 수강한 이유는 배우고 싶었던 Recoil, React Query, Supabase를 익히기 위해서였다.하지만 생각보다 많이 어려웠다. 역시 한 번 본다고 해서 쉽게 익힐 수 있는 건 아니라고 느꼈다.섹션 2까지는 본격적인 실습을 하기 전에 준비 단계라고 생각했다.그리고 섹션 3부터는 본격적으로 TODO 프로젝트를 만들면서 학습을 진행했다.Supabase를 사용하면서 백엔드의 기본적인 동작 방식을 조금이나마 이해할 수 있었다.신기하기도 했지만, 한편으로는 어렵기도 했다.미션 자체는 그렇게 어렵다고 느껴지지는 않았다.결국 등록 시간과 수정 시간을 표현하는 것이 핵심이었기 때문이다.하지만 강의에서 React Query나 Supabase를 다루는 부분은 이해가 잘되지 않아 여러 번 반복해서 학습해야 할 것 같다.어려워서 살짝 우울하기도 하지만...결국 어려운 만큼 성장할 수 있는 부분이 많다는 뜻이니까! 😂🔥

풀스택풀스택미션인프런워밍업클럽supabasenext.js

codestudy

[인프런 워밍업 스터디 클럽 3기 풀스택] 1주차 발자국

Next.js와 Supabase를 활용한 Todo 애플리케이션 개발강의 수강 내용 요약이번 주에는 Next.js 14, TypeScript, Material Tailwind, Supabase를 연동하여 Todo 리스트 애플리케이션을 만드는 방법을 학습했습니다.사용한 기술 스택과 그 이유1. SupabasePostgreSQL 기반의 오픈소스 백엔드 서비스 플랫폼Firebase의 대안으로 관계형 DB의 장점을 활용할 수 있음자동 생성되는 API와 인증 시스템 제공장점: 오픈소스, PostgreSQL 기반, 비용 효율성, 다양한 연동 방식단점: 상대적으로 작은 커뮤니티, 문서화 부족, Firebase보다 높은 러닝커브2. Next.jsReact 기반 풀스택 웹 애플리케이션 프레임워크서버 사이드 렌더링, 정적 생성, API 라우트 등 다양한 기능 제공App Router를 통한 새로운 라우팅 시스템클라이언트/서버 컴포넌트 분리를 통한 성능 최적화3. Material TailwindMaterial UI와 Tailwind CSS를 결합한 디자인 시스템유틸리티 기반의 스타일링과 미리 구성된 컴포넌트 제공타입스크립트 지원으로 개발 경험 향상4. React Query (Tanstack Query)서버 상태 관리를 위한 강력한 라이브러리데이터 페칭, 캐싱, 동기화, 상태 업데이트 기능 제공자동 리패칭과 캐싱 기능으로 UX 향상장점: 자동 캐싱, 데이터 동기화, 개발자 경험, 성능 최적화단점: 학습 곡선, 복잡한 설정, 캐시 관리의 복잡성   🍭미션할일 CRUD 기능 구현 (feat. Server Action)Next.js의 Server Actions 활용한 데이터 조작 함수 구현getTodos, createTodo, updateTodo, deleteTodo 기능 구현React Query hooks를 활용한 서버 상태 관리Todo 컴포넌트 개발 및 CRUD 로직 연결이 과정에서 Material Tailwind 컴포넌트의 타입 불일치 문제로 여러 타입 에러가 발생했습니다. 이를 해결하기 위해 래퍼 컴포넌트 패턴을 적용했습니다:// 커스텀 IconButton 래퍼 컴포넌트 interface IconButtonProps { onClick: () => void | Promise<void>; children: ReactNode; className?: string; } function IconButton({ onClick, children, className }: IconButtonProps) { return ( <MTIconButton onClick={onClick as any} className={className} {...({} as any)} // 타입 에러 방지 > {children} </MTIconButton> ); } 또한 todoQuery와 mutation을 활용하여 데이터 관리를 구현했습니다:const updateTodoMutation = useMutation({ mutationFn: () => updateTodo({ id: todo.id, title, completed, }), onSuccess: () => { setIsEditing(false); queryClient.invalidateQueries({ queryKey: ["todos"], }); }, }); queryClient는 쿼리 관련된 요청의 캐시 역할을 해주어 React Query가 캐시를 정상적으로 동작하게 해줍니다.학습 내용 회고아쉬웠던 점타입스크립트 타입 에러 해결에 많은 시간을 소비했습니다. 라이브러리 문서를 먼저 자세히 살펴봤다면 더 효율적이었을 것입니다.프로젝트 구조 설계와 파일 경로 설정 등 기초적인 부분에서 오류가 발생했습니다.컴포넌트 재사용성과 코드 구조화 측면에서 개선할 여지가 있습니다.보완하고 싶은 점래퍼 컴포넌트를 별도 파일로 분리하여 코드의 재사용성을 높이고 싶습니다.Supabase의 인증 기능을 추가하여 사용자별 Todo 관리 시스템으로 확장하고 싶습니다.테스트 코드 작성을 통해 애플리케이션의 안정성을 높이고 싶습니다.모바일 반응형 디자인을 더 개선하여 다양한 디바이스에서의 사용성을 향상시키고 싶습니다.복습을 계속 해야할 것 같습니다.

풀스택supabasenext.jsmaterialtailwind

merry

풀스택 - 1주차

강의 수강supabase 장점오픈소스 프로젝트 (자체 서버구축 가능)PostgreSQL 기반 (관계형 DB 장점을 살릴 수 있다)Firebase 대비 저렴다양한 연동방식 지원NEXT.JS 특징“use server” → 서버에서만 동작함. (DB를 연결해도 됨)fetch + REST API 조합 api/route.ts를 사용하여 api를 만들수 있음. Server Actions api 구현을 하지않아도 데이터를 구현할 수 있다. SEO를 위한 Metadata 투두리스트 CRUD 기능 구현actions/todo-action.ts"use server"; // Next.js의 서버 액션(Server Action)으로 사용하기 위해 명시해야 함. import { Database } from "types_db"; import { createServerSupabaseClient } from "utils/supabase/server"; // 📝 todo 테이블에서 조회할 때 반환되는 데이터의 타입 export type TodoRow = Database["public"]["Tables"]["todo"]["Row"]; // 📝 todo 테이블에 새로운 데이터를 추가할 때 필요한 데이터의 타입 export type TodoRowInsert = Database["public"]["Tables"]["todo"]["Insert"]; // 📝 todo 테이블에서 기존 데이터를 수정할 때 사용할 데이터의 타입 export type TodoRowUpdate = Database["public"]["Tables"]["todo"]["Update"]; // 🔹 에러 핸들링 함수 function handleError(error: any) { console.error(error); // 에러 로그 출력 throw new Error(error.message); // 에러 메시지를 포함한 예외 발생 } export async function getTodos({ searchInput = "" }) { const supabase = await createServerSupabaseClient(); // 🔹 Supabase를 통해 todo 테이블에서 데이터 조회 const { data, error } = await supabase // ✅ `await` 추가하여 비동기 데이터 처리 .from("todo") // 📌 todo 테이블에 접근 .select("*") // 📌 모든 컬럼 조회 .like("title", `%${searchInput}%`) // 📌 LIKE 연산자로 title에 searchInput이 포함된 데이터 필터링 .order("created_at", { ascending: true }); // 📌 생성 날짜 기준 오름차순 정렬 if (error) { handleError(error); // 에러 발생 시 핸들링 } return data; // 조회된 데이터 반환 } export async function createTodo(todo: TodoRowInsert) { const supabase = await createServerSupabaseClient(); // ✅ Supabase 서버 클라이언트 생성 // 🔹 Supabase를 사용하여 todo 테이블에 새로운 데이터 삽입 const { data, error } = await supabase .from("todo") // 📌 todo 테이블에 접근 .insert({ ...todo, // 📌 클라이언트에서 받은 데이터(todo) 삽입 created_at: new Date().toISOString(), // 📌 클라이언트에서 전달된 값이 이상할 수 있으므로, 서버에서 직접 현재 시간을 생성하여 추가 }); if (error) { handleError(error); // 🔹 에러 발생 시 핸들링 } return data; // ✅ 삽입된 데이터 반환 } //todo 업데이트 export async function updateTodo(todo: TodoRowUpdate) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("todo") .update({ ...todo, updated_at: new Date().toISOString(), }) .eq("id", todo.id); // 📌 id가 일치하는 행만 업데이트 if (error) { handleError(error); } return data; } //todo 삭제 export async function deleteTodo(id: number) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.from("todo").delete().eq("id", id); if (error) { handleError(error); } return data; } new Date().toISOString() 이란? 현재 시간을 ISO 8601 형식의 문자열로 변환하는 메서드입니다.1⃣ 시간 표준화 (Time Standardization)문제: 서버와 클라이언트의 시간대가 다를 수 있음사용자가 다른 시간대(한국, 미국, 유럽 등)에서 앱을 사용할 수 있음.예를 들어, 한국(KST)에서 저장한 날짜가 미국(PST)에서 보면 이상하게 보일 수 있음.해결책:모든 시간을 UTC 기준으로 저장하여 시간대를 통일하면 문제 해결!new Date().toISOString()은 항상 UTC(세계 표준시)로 변환되므로, 서버-클라이언트 간 시간 오차 없이 일관성 유지 가능.2⃣ 클라이언트와 서버 간 시간 차이 해결문제: 클라이언트에서 new Date()를 사용하면 각 기기의 로컬 시간이 저장됨만약 한국(KST)에서 new Date()를 저장하면 2025-03-02T23:30:45미국(PST)에서 new Date()를 저장하면 2025-03-02T06:30:45서버에서 보면 시간이 뒤죽박죽이 됨 😵해결책:모든 시간을 UTC 기준으로 저장(new Date().toISOString())클라이언트가 받을 때 필요한 시간대로 변환해서 사용예시:js 복사편집 const utcTime = new Date().toISOString(); console.log(utcTime); // "2025-03-02T14:30:45.123Z" (UTC) 한국(KST)에서는 2025-03-02 23:30:45로 변환해서 보여주면 됨.미국(PST)에서는 2025-03-02 06:30:45로 변환해서 보여주면 됨.3⃣ 데이터베이스와의 호환성문제: DB에서 created_at 필드를 올바르게 저장하려면?Supabase(PostgreSQL)에서는 TIMESTAMP 또는 TIMESTAMP WITH TIME ZONE 타입을 사용함.클라이언트에서 로컬 시간을 넣으면, DB에서 잘못 해석할 수 있음.해결책:ISO 8601 형식(2025-03-02T14:30:45.123Z)을 사용하면 DB에서 자동 변환 가능!Supabase에서 created_at이 TIMESTAMP 필드라면, new Date().toISOString()을 넣으면 자동으로 올바른 값이 저장됨.  미션TODO 항목 옆에 생성시간을 표시하기 테이블에서 생성시간을 뽑아오니 utc기준으로 보여지고 있었다. 한국시간으로 바꾸기위해 day.js 라이브러리를 활용하였다. 🤟🏻 created_at(UTC)을 로컬 시간으로 변환 로컬 시간(Local Time)이란 현재 사용자의 컴퓨터(브라우저) 또는 서버에서 설정된 시간대(Time Zone)에 맞는 시간을 의미**npm install dayjs** <p>{dayjs(todo.created_at).format("YYYY-MM-DD HH:mm:ss")}</p>  completed_at 필드를 추가하여 완료한 시간도 함께 저장하기supabase에서 compledte_at 컬럼 추가터미널에서 npm run generate-types 입력하면 types_db.ts 에 타입정보가 뜬다. completed_at 타입 추가하면 완성.//todo 업데이트 export async function updateTodo(todo: TodoRowUpdate) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("todo") .update({ ...todo, updated_at: new Date().toISOString(), completed_at: new Date().toISOString(), //추가 }) .eq("id", todo.id); // 📌 id가 일치하는 행만 업데이트 if (error) { handleError(error); } return data; } 검색 입력창 디바운스 적용하여 렌더링 개선.export default function UI() { const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(searchInput); // 검색어 입력 시 `debounce` 적용 (100ms 딜레이) useEffect(() => { const handler = setTimeout(() => { setDebouncedSearch(searchInput); }, 100); return () => clearTimeout(handler); }, [searchInput]); const { data: todosQuery, isPending, refetch, } = useQuery({ queryKey: ["todos", debouncedSearch], // 검색어가 변경될 때마다 쿼리 새로 실행 queryFn: () => getTodos({ searchInput: debouncedSearch }), }); 회고퇴근 후 매일 1시간씩 꾸준히 공부한 내 자신을 칭찬하고 싶다. 완강을 목표로 하고 있지만, 한 단계 더 나아가 다양한 기능을 직접 구현하며 부딪혀 보고 익히고 싶다. 이를 통해 한 달 후에는 스스로 미니 프로젝트를 완성해 보는 것이 목표다.시간이 날 때 조금 더 개선해 보고 싶은 사항:리액트 훅을 hooks 폴더로 분리 → 비즈니스 로직을 컴포넌트에서 분리하여 코드의 가독성과 재사용성을 높이기리액트 쿼리 키를 별도로 관리 → 쿼리 키를 체계적으로 관리하여 유지보수성과 확장성을 강화하기

풀스택supabasenext.jsreact미션

채널톡 아이콘