블로그
전체 42025. 03. 30.
0
[인프런 워밍업 스터디 클럽 3기 풀스택] 4주차 발자국
강의수강supabase Auth다양한 인증방식을 지원하는 인증 시스템이메일 인증, 소셜 로그인 등 다양한 방법을 지원confirmation URL 방식supabase가 사용자의 이메일로 확인 링크를 전송사용자가 해당 링크를 클릭하면, supabase에서 해당 이메일을 확인하고 인증 완료6 - Digit OTP 방식이메일이나 SMS를 통해 6자리 숫자를 전송사용자는 이 코드를 입력하여 본인 확인supabase Realtime데이터베이스의 변경 사항을 실시간으로 클라이언트에 전달하는 기능을 제공broadcast모든 사용자에게 동일한 데이터를 전송하는 방식presence현재 연결된 사용자를 실시간으로 추적하는 방식postgres changes데이터베이스에서 발생하는 변경 사항을 실시간으로 추적하는 방식supabase RLSRow Level Security데이터베이스 테이블에 대해 구체적으로 접근 권한을 설정할 수 있게 해줌 미션채팅 메시지 삭제사용자가 특정 채팅 메시지를 삭제다른 사용자가 작성한 메시지는 삭제 불가능메시지 클릭 시 모달 화면을 띄어 삭제 여부 결정삭제된 메시지 대신 "이 메시지는 삭제되었습니다" 같은 알림 표시 메시지 삭제 기능은 message 테이블의 is_deleted 컬럼 값을 이용하기로 했다. is_deleted 가 true 이면 삭제된 메시지고, false면 삭제되지 않은 메시지다. 구현 전 생각했던 방식은 is_deleted 값을 변경하는 action 함수를 만들고, 특정 메시지를 클릭 시 모달 창을 띄어 해당 메시지를의 삭제 여부를 묻는다. 삭제한다고 하면 action 함수를 호출해 is_deleted 값을 변경한다. 또한 상대방 채팅에서도 실시간으로 변경하기 위해 channel에서 update event도 구독한다./actions/chat-actions.tsexport async function deleteMessage({ message }) { const supabase = await createServerSupabaseClient(); const { data: { session }, error, } = await supabase.auth.getSession(); if (error || !session.user) { throw new Error("User is not authenticated"); } const { error: updateError } = await supabase .from("message") .update({ ...message, is_deleted: true, }) .eq("id", message.id); if (updateError) { throw new Error("Message error"); } } export async function getMessage(id: string) { if (!id) { return null; } const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("message") .select("*") .eq("id", Number(id)) .maybeSingle(); if (error) { return null; } return data; }deleteMessage: 메시지 객체를 받아 is_deleted 컬럼의 값을 true 변경getMessage: 모달 창에서 이용할 action message의 id를 받아 해당 id로 message 테이블을 쿼리해 메시지를 반환 /components/chat/chat-delete-button.tsx"use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { deleteMessage } from "actions/chat-actions"; import { useRouter } from "next/navigation"; import { useRecoilValue } from "recoil"; import { selectedUserIdState } from "utils/recoil/atoms"; export default function ChatDeleteButton({ message }) { const queryClient = useQueryClient(); const selectedUserId = useRecoilValue(selectedUserIdState); const deleteMessageMutaition = useMutation({ mutationFn: () => deleteMessage({ message }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["messages", selectedUserId] }); }, }); const router = useRouter(); return ( message : {message.message} Are you sure you want to delete this message? { deleteMessageMutaition.mutate(); router.back(); }} className="w-14 py-1 px-2 bg-red-100 rounded-md border-2" > yes router.back()} className="w-14 py-1 px-2 bg-blue-100 rounded-md border-2" > no ); } 모달 창에서 렌더링할 컴포넌트 yes 버튼은 deleteMessageMutation.mutate()를 호출해 deleteMessage action을 호출한다. yes, no 버튼 마지막에 router.back()을 호출해 모달창을 종료한다./components/chat/chat-screen.tsx useEffect(() => { const channel = supabase .channel("message_postgres_changes") .on( "postgres_changes", { event: "INSERT", schema: "public", table: "message" }, (payload) => { if ( payload.eventType === "INSERT" && !payload.errors && !!payload.new ) { getAllMessagesQuery.refetch(); } } ) .on( "postgres_changes", { event: "UPDATE", schema: "public", table: "message" }, (payload) => { if (payload.eventType === "UPDATE" && !payload.errors) { getAllMessagesQuery.refetch(); } } ) .subscribe(); return () => { channel.unsubscribe(); }; }, []);chat-screen.tsx 컴포넌트에 supabase.channel 구독에서 { event: 'UPDATE' }를 추가함으로 message 테이블에 변경사항이 있으면 모든 메시지를 리패치하도록 함
2025. 03. 22.
0
[인프런 워밍업 스터디 클럽 3기 풀스택] 3주차 발자국
강의수강recoilatom: 상태의 조각useRecoilState: useState hook과 유사하게 전역적으로 recoil 상태를 읽고 쓰는 데 사용useRecoilValue: recoil 상태를 읽는 데만 사용 react-intersection-observerintersercion Observer API를 react에서 사용하기 쉽도록 도와주는 라이브러리useInView 훅은 react-intersection-observer 라이브러리에서 제공하는 기능으로, 특정 요소가 화면에 보이는지(inView)를 감지하고, 해당 요소에 ref를 할당하여 추적할 수 있게 한다.threshold: 0 옵션은 요소의 0%라도 화면에 나타나면 inView가 true가 되도록 설정useInfiniteQuery const { data, isFetchingNextPage, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ initialPageParam: 1, queryKey: ["movie", search], queryFn: ({ pageParam }) => searchMovies({ search, pageSize, page: pageParam, favoriteCount, }), getNextPageParam: (lastPage) => { if (lastPage.data.length 무한 스크롤 기능을 쉽게 구현할 수 있도록 해주는 hookinitialPageParam 으로 처음에 가져올 페이지 번호를 설정queryFn: -> searchMovies 함수를 호출해 데이터를 패치getNextPageParam: 다음 페이지 번호를 반환해 추가 데이터를 불러 오도록 함 조건에 따라 null을 반환해 무한 스크롤을 멈출 수 있게 해준다미션사용자가 특정 영화를 “찜”할 수 있도록 Supabase를 활용해 즐겨찾기 리스트 구현supabase movie 테이블에 favorite: boolean column 추가 기본 값: falseactions/move-actions.ts에 update action 코드 추가type MovieUpdate = Database["public"]["Tables"]["movie"]["Update"]; export async function updateMovie(movie: MovieUpdate) { const supabase = await createServerSupabaseClient(); const { error } = await supabase .from("movie") .update({ ...movie, }) .eq("id", movie.id); handleError(error); } 현재 Movie가 찜되어 있는지 나타내는 컴포넌트 MovieFavoriteIconfavorite boolean값을 받아 찜했으면 핑크색 하트를 찜 안되어있으면 빈하트를 렌더링export default function MovieFavoriteIcon({ favorite }: { favorite: boolean }) { return ( {favorite ? ( ) : ( )} ); } components/movie-card, movies/[id]/ui에 MovieFavoriteIcon 컴포넌트 추가import Link from "next/link"; import type { Movie } from "actions/movie-actions"; import MovieFavoriteIcon from "./movie-favorite-icon"; export default function MovieCard({ movie }: { movie: Movie }) { return ( {movie.title} ); } "use client"; import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { Movie, updateMovie } from "actions/movie-actions"; import { queryClient } from "config/react-query-client-provider"; import MovieFavoriteIcon from "components/movie-favorite-icon"; export default function UI({ movie }: { movie: Movie }) { const [isFavorite, setIsFavorite] = useState(movie.favorite); const updateMovieMutation = useMutation({ mutationFn: () => updateMovie({ ...movie, favorite: !isFavorite, }), onSuccess: () => { setIsFavorite((prev) => !prev); queryClient.invalidateQueries({ queryKey: ["movie"] }); }, }); return ( {movie.title} updateMovieMutation.mutate()} > {movie.overview} Vote Average : {movie.vote_average} Popularity: {movie.popularity} Release Date: {movie.release_date} ); } movies/[id]/ui.tsx에서 해당 movie의 favorite을 설정하도록 mutate 추가찜한 영화를 영화 리스트 화면의 최상단으로 보여주도록 정렬위에서 추가했던 movie 테이블의 favorite 컬럼을 기준으로, 무한 스크롤 movie 요청시 찜한 영화를 우선해 가지고 오는 것을 목표로 했다.찜한 영화 favoriteMovie , 나머지 기본 영화 remainMovie 를 각각 가지고 오는 방식으로,예를 들어 favoriteMovie 가 6개이고 pageSize 가 12인 경우page = 1: favoriteMovie.length = 6 , remainMovie.length = 6page = 2: favoriteMovie.length = 0 , remainMovie.length = 12page = 3: favoriteMovie.length = 0 , remainMovie.length = 12favoriteMovie 가 13개이고 pageSize 가 12인 경우page = 1: favoriteMovie.length = 12 , remainMovie.length = 0page = 2: favoriteMovie.length = 1 , remainMovie.length = 11page = 3: favoriteMovie.length = 0 , remainMovie.length = 12favoriteMoive 를 먼저 pageSize 만큼 가지고 오고 favoriteMoive 의 수가 pageSize 보다 작은 경우 remainMovie 를 가지고 오는 방식이다.여기서 가장 중요한 것은 각각의 영화들의 range 다. 기존 page 와 pageSize 를 기준으로 range 를 정하는 경우 remainMovie 를 쿼리할 때 중간에 지나가는 경우가 생긴다. 위 2번 째 예의 page = 2일 때 remainMovie 의 범위는 0 ~ 10 이여야 한다. 이전 Movie들의 정보를 알아야 구할 수 있는데, 현재 코드에서는 이를 간단하게 구할 좋은 방법이 떠오르지 않았다. 따라서 supabase에 저장한 favoriteMovie 의 수를 미리 가지고와 이를 이용해 favoriteCount , page, pageNumber 세 개의 값들을 조합해 범위를 구했다.function getRange(favoriteCount: number, page: number, pageSize: number) { const start = (page - 1) * pageSize; // 현재 페이지의 시작 인덱스 const end = start + pageSize - 1; // 현재 페이지의 마지막 인덱스 const favStart = start 0 ? [favStart, favEnd] : [], remainRange: remLength > 0 ? [remStart, remEnd] : [], }; } getRange 함수는 favoriteCount , page, pageNumber 세 개의 값들을 이용해 favoriteMovie , remainMovie 의 범위를 구해 반환하는 함수다.현재 페이지의 시작 인덱스 start 가 찜한 영화의 수 favoriteCount 보다 작은 경우 이번 페이지에서는 찜한 영화만을 가지고 올 거기에 favStart 가 start 가 된다. 크거나 같은 경우에는 찜한 영화가 부족함으로 favoriteCount 값으로 설정한다.favEnd = end 가 favoriteCount 보다 큰 경우, 존재하는 찜한 영화 개수까지만 가져온다.remStart = 찜한 영화 개수를 넘어서야 나머지 영화를 가지고 오므로 start - favoriteCount 가 0보다 큰 경우인지 확인하면 값을 설정remEnd = 찜한 영화 개수(favLength ) 를 채운 후 남은 공간만큼 나머지 영화를 가지고 온다최종 변경 코드actions/movie-actions.tsasync function searchMoviesByFavorite( search: string, range: number[], isFavorite: boolean ) { if (range.length === 0) { return []; } const [start, end] = range; const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("movie") .select("*") .like("title", `%${search}%`) .eq("favorite", isFavorite) .order("id") .range(start, end); handleError(error); return data; } export async function searchMovies({ search, page, pageSize, favoriteCount, }: { search: string; page: number; pageSize: number; favoriteCount: number; }) { const { favoriteRange, remainRange } = getRange( favoriteCount, page, pageSize ); const [favoriteMovies, remainMovies] = await Promise.all([ searchMoviesByFavorite(search, favoriteRange, true), searchMoviesByFavorite(search, remainRange, false), ]); const data = [...favoriteMovies, ...remainMovies]; return { data, page, pageSize, hasNextPage: data.length === pageSize, }; } app/page.tsx// page.tsx import { createServerSupabaseClient } from "utils/supabase/server"; import UI from "./ui"; export const metadata = { title: "TMDBFLIX", description: "Netflix clone using TMDB API", }; export default async function Page() { const supabase = await createServerSupabaseClient(); const { count } = await supabase .from("movie") .select("*", { count: "exact", head: true }) .eq("favorite", true); return ; } // ui.tsx "use client"; import MovieCardList from "components/movie-card-list"; export default function UI({ favoriteCount }: { favoriteCount: number }) { return ( ); } MovieCardList 컴포넌트에서 사용할 수 있도록 총 찜한 영화의 수를 쿼리해서 prop으로 전달components/Movie-card-list.tsxexport default function MovieCardList({ favoriteCount, }: { favoriteCount: number; }) { const search = useRecoilValue(searchState); const pageSize = 12; const { data, isFetchingNextPage, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ initialPageParam: 1, queryKey: ["movie", search], queryFn: ({ pageParam }) => searchMovies({ search, pageSize, page: pageParam, favoriteCount, }), getNextPageParam: (lastPage) => { if (lastPage.data.length 찜한 영화의 수 favoriteCount 를 useInfiniteQuery queryFn searchMovies 에 추가로 전달
2025. 03. 16.
0
[인프런 워밍업 스터디 클럽 3기 풀스택] 2주차 발자국
강의수강Supabase storage파일과 미디어를 저장하고 관리할 수 있는 서비스bucket파일을 저장하는 컨테이너클라이언트 또는 서버에서 파일을 업로드하고 가져올 수 있음drag and drop을 도와주는 라이브러리 react-dropzoneHTML5 호환 drag 와 drop 영역을 생성하는 react hookgetRootProps : drag and drop 영역을 감싸는 요소의 props를 반환, 이 props를 추가하면 해당 요소가 drop 존 역할을 함getInputProps : 파일 입력 요소에 적용할 props를 반환, 브라우저에서 파일 선택 버튼을 크릭하여 업로드하는 기능을 제공isDragActive : 사용자가 파일을 drag해서 drop존 위에 올려놓았을 때 true 가 됨onDrop : 파일을 drop했을 때 동작할 함수 미션파일 목록에서 각 파일의 "마지막 수정 시간"을 표시 supabase-storage에서 반환하는 파일의 형식 created_at : "2025-03-05T10:15:27.972Z", id: "ca54ea55-4dce-4fc8-a64c-7066b757d663", last_accessed_at: "2025-03-05T10:15:27.972Z", metadata: {eTag: '"72c7f0e0341f03be49876c063d3ff79f"', size: 340, mimetype: 'image/png', cacheControl: 'max-age=3600', lastModified: '2025-03-05T10:15:28.000Z', …}, name: "25-phd.png", updated_at: "2025-03-05T10:15:27.972Z"update_at 값을 이용해 마지막 수정시간을 렌더링한다. 시간을 좀 더 보기 쉽게 변경하기 위해 지난 미션에서 사용했던 함수 formatDate 로 렌더링
2025. 03. 08.
0
[인프런 워밍업 스터디 클럽 3기 풀스택] 1주차 발자국
강의수강Next.js 기본기next.js란? 풀스택 개발을 하기에 최적화된 웹 프레임워크서버에서 html을 최적화해 웹으로 내려주기에 검색엔진에 유리폴더 구조 route는 폴더명, 경로와 일치page.tsx, layout.tsx, route.ts 등 역할이 정해진 파일명들이 존재 tailwindcss클래스로 구성된 유틸리티 우선 css 프레임워크recoilfacebook에서 만든 React 상태 관리 라이브러리컴포넌트에 상태를 손쉽게 공유할 수 있게 함 React Queryfetching, caching, 서버 데이터와의 동기화를 지원해주는 라이브러리 useQuery (데이터 조회)서버에서 데이터를 가져오고 캐싱하는 데 사용useMutaion (데이터 변경)데이터를 수정, 생성, 삭제할 때 사용useQuery와 다르게 자동 캐싱 x, 수동으로 트리거해야 함 (invalidateQueries) SupabasePostgreSQL 기반의 백엔드 서비스데이터베이스, 인증, 스토리지, 엣지 함수 등을 제공미션TODO 항목 옆에 생성 시간을 표시하기supabase todos table에 created_at column을 이용 생성 시간을 보기 편하게 formatDate 란 함수를 만들어 형식을 변경 export function formatDate(date: string) { if (!date) { return ''; } const d = new Date(date); const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const min = String(d.getMinutes()).padStart(2, '0'); return `${yyyy}/${mm}/${dd} ${hh}:${min}`; }ui/todo.tsx 컴포넌트 파일에 created_at을 렌더링 부분을 추가 Created: ${formatDate(todo.created_at)} completed_at 필드를 추가하여 완료한 시간도 함께 저장supabase todos 테이블에 completed_at column 추가todos.tsx 컴포넌트에 completed_at 관련 상태를 관리하는 completedAt useState 추가todos.tsx 컴포넌트 내부의 Checkbox 컴포넌트의 onChange 이벤트에 e.target.checked 가 true인 경우 완료한 타이밍으로 간주해 현재 시간을 설정(setCompletedAt)updatedTodoMutaition mutationFn의 updatedTodo 액션에 보낼 객체에 completed_at 필드를 추가해 mutation 시 supabase에 저장된 completed_at의 값을 변경 완료한 시간을 추가하면서 개선한 점완료한 상태에서는 completed_at 값을, 완료하지 않은 상태에서는 created_at 값을 렌더링하는 방식으로 목표 기능을 설정했다.초기 구현에서는 completedAt 상태값을 추가하지 않고, todo-actions의 updateTodo 함수에서 updated_at 필드를 업데이트하는 방식과 동일하게 진행했다. 그리고 todo.tsx에서 completed 값이 true이면 todo.completed_at을, false이면 todo.created_at을 렌더링했다.이 방식은 completed가 false일 때는 문제가 없었지만, false → true로 변경될 때 todo.completed_at 값이 null이라 화면이 깜빡이는 문제가 발생했다.문제의 원인completed는 리액트 상태값이고, todo.completed_at은 Supabase에서 가져오는 값이기 때문에 completed가 먼저 true로 변경된 후 Supabase에서 completed_at 값을 업데이트하고 가져오는 과정에서 지연이 발생했다. 그로 인해 completed_at 값이 null인 순간이 있어 깜빡임이 나타났다.해결 방법화면 깜빡임을 방지하려면 completed 값과 함께 completedAt 상태값을 리액트에서 관리해야 한다.즉, checkbox의 값이 변경될 때 completedAt 상태값도 동시에 변경하고, mutate 시 completedAt 값을 전달하면 completed와 completedAt 상태가 같은 타이밍에 변경된다. 이렇게 하면 Supabase 응답을 기다리는 동안에도 UI가 자연스럽게 유지될 수 있다.
프론트엔드