블로그
전체 42025. 03. 30.
0
[인프런 워밍업 클럽 3기 풀스택 ] 4주차 발자국
목차회원가입 & 로그인Supabase Auth 소개인증 구현 - Confirmation URL 방식 (이메일 코드 전송)인증 구현 - 6-digit OTP 방식 채팅 기능 구현Supabse Realtime 소개 실시간 채팅 구현유저별 마지막 접속 시간 구현4주차 제출메시지 삭제 시 '이 메세지는 삭제되었습니다'로 표시 1. 회원가입 & 로그인[Supabase Auth 소개]다양한 인증 방식을 지원하는 인증 시스템사용자 인증 및 권한 관리를 웹사이트에서 할 수 있음이메일 인증, Magic Link를 통한 비밀번호 없는 로그인, 전화번호를 통한 로그인, 소셜 로그인, 그리고 기업용 SSO 등 다양한 방법으로 사용자 인증을 지원소셜 로그인 방식 지원전화번호 인증은 Twilio 등의 외부 Provider를 사용하여 진행 가능인증 방식은 JWT나 Session을 사용하여 설정next.js를 사용하는 경우, 별도의 설정 문서를 통해 Supabase Auth 설정을 진행[인증 구현 - Confirmation URL 방식]supabase 접속 - Authentication - Emails 페이지에서 이메일로 전송 될 내용과 링크를 설정 {.confirmationURL} 템플릿을 활용해 인증 링크 설정 [가입하기] 버튼 클릭 시 인증 주소가 담긴 이메일을 보냄 메일 전송 후 인증된 주소로 접속 시 가입 확인 완료를 체크함supabase.auth.signup({..})로 인증 코드 주소를 통해 로그인 세션 획득setConfirmationRequired로 가입하기 완료를 체크함// 가입하기 완료 확인 체크 const [confirmaitonRequired, setConfirmationRequired] = useState(false); const supabase = createBrowserSupabaseClient(); const signupMutation = useMutation({ mutationFn: async () => { const { data, error } = await supabase.auth.signUp({ email, password, options: { // 유저가 회원가입 끝나고 이메일 링크 클릭 => 서버처리 완료 => 해당 url로 다시 이동 emailRedirectTo: "http://loacalhost:3000/signup/confirm", }, }); if (data) { setConfirmationRequired(true); } if (error) { alert(error.message); } }, });Web Client에서 받은 인증 코드 값을 활용해 로그인 세션을 획득route.ts 에서 code 값이 있으면 supabase.auth.exchangeCodeForSession(code)를 통해 세션 획득export async function GET(request: Request) { const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get("code"); if (code) { const supabase = await createServerSupabaseClient(); await supabase.auth.exchangeCodeForSession(code); // 세션 획득 } return NextResponse.redirect(requestUrl.origin); // 화면 메인페이지로 이동 }획득한 세션이 존재하면 자동으로 로그인 됨 [인증 구현 - 6-digit OTP 방식]supabase 접속 - Authentication - Emails 페이지에서 이메일로 전송 될 내용과 인증 코드 설정{.Token} 템플릿을 활용해 인증 코드 설정[가입하기] 버튼 클릭 시 인증 코드가 담긴 이메일을 보냄메일 전송 후 인증 코드를 입력하면 가입 확인 완료를 체크함supabase.auth.verifyOtp({...})로 otp token값을 이용해 로그인 세션 획득 const verityOtpMutation = useMutation({ mutationFn: async () => { const { data, error } = await supabase.auth.verifyOtp({ type: "signup", email, token: otp, }); if (data) { setConfirmationRequired(true); } if (error) { alert(error.message); } }, });획득한 세션이 존재하면 자동으로 로그인 됨 2. 채팅 기능 구현 [Supabse Realtime 소개]연결된 클라이언트와 메시지를 주고 받는 실시간 서비스를 제공Broadcast모든 사용자에게 동일한 데이터를 전송하는 방식채팅 시스템에서 실시간 메시지 전송, 동시 알림 보낼 때 등에 유용Presence현재 연결된 사용자를 실시간으로 추적하는 방식실시간 채팅에서 현재 접속 중인 사용자 표시, 같은 세션에 있는 유저 보여주는 등에 유용Postgres Changes데이터베이스에서 발생하는 변경 사항을 실시간으로 추적하는 방식사용자는 DB의 상태 변경을 실시간으로 모니터링, 필요에 따라 즉각 반응 가능 [실시간 채팅 구현]supabase 페이지 - Table Editor - message DB 클릭 오른쪽 상단의 [Realtime off] 버튼을 클릭해 [Realtime on] 으로 변경채팅으로 전송되는 메시지 DB에 저장하기chatActions.ts전송하는 메시지 내용과, 유저 id값을 받아 DB에 insertexport async function sendMessage({ message, chatUserId }) { const supabase = await createServerSupabaseClient(); // 현재 로그인한 유저 const { data: { session }, error, } = await supabase.auth.getSession(); if (error || !session.user) { throw new Error("User is not authenticated"); } const { data, error: sendMessageError } = await supabase .from("message") .insert({ message, receiver: chatUserId, sender: session.user.id, }); if (sendMessageError) { throw new Error(sendMessageError.message); } return data; }채팅 중인 메시지 모두 가져오기chatActions.ts현재 채팅중인 user ID 값을 받아서 전송되는 메시지 값을 selectexport async function getAllMessages({ chatUserId }) { // 현재 나와 채팅중인 메시지 모두 가져오기 const supabase = await createServerSupabaseClient(); const { data: { session }, error, } = await supabase.auth.getSession(); if (error || !session.user) { throw new Error("User is not authenticated"); } const { data, error: getMessagesError } = await supabase .from("message") .select("*") .or(`receiver.eq.${chatUserId},receiver.eq.${session.user.id}`) // 상대방 또는 나 .or(`sender.eq.${chatUserId},sender.eq.${session.user.id}`) .order("created_at", { ascending: true }); if (getMessagesError) { return []; } return data; }메시지를 전송하고 메시지 창에 표시하기 ChatScreen.tsxsendMessageMutation: 메시지 DB로 전송 후, 성공하면setMessage(""): 메시지 입력 창 초기화getAllMessagesQuery.refetch(): 메시지 내용 가져와서 보여주기getAllMessagesQuery: 상대 유저 id 값으로 현재 대화중인 메시지 가져오기const sendMessageMutation = useMutation({ mutationFn: async () => { return sendMessage({ message, chatUserId: selectedUserId, }); }, onSuccess: () => { setMessage(""); // 메시지 입력 칸 비우기 getAllMessagesQuery.refetch(); // 메시지 가져오기 }, }); const getAllMessagesQuery = useQuery({ queryKey: ["messages", selectedUserId], queryFn: () => getAllMessages({ chatUserId: selectedUserId }), }); // 새로고침 없이 대화를 실시간으로 표시 useEffect(() => { const channel = supabase .channel("message_postgres_changes") .on( "postgres_changes", { event: "INSERT", schema: "public", table: "message", }, (payload) => { if (payload.eventType === "INSERT" && !payload.errors) { getAllMessagesQuery.refetch(); } } ) .subscribe(); return () => { channel.unsubscribe(); }; }, []); [유저별 마지막 접속 시간 구현]channel의 config내 key 값(로그인한 유저 id)을 넣어 실제 로그인한 유저의 상태를 트래킹함presence에서 sync 사용channel이 subscribe 되었다면 새로운 precence status 가 들어옴channel.track 안에 트래킹 하고 싶은 오브젝트(현재 날짜)를 넣어 가져오기 const supabase = createBrowserSupabaseClient(); useEffect(() => { const channel = supabase.channel("online_users", { config: { presence: { key: loggedInUser.id, }, }, }); channel.on("presence", { event: "sync" }, () => { const newState = channel.presenceState(); console.log(newState); }); channel.subscribe(async (status) => { // 구독이 완료되지 않았을 때 종료 if (status !== "SUBSCRIBED") { return; } // 구독 성공시 새로운 presence status가 들어옴 // channel.track안에 트래킹하고 싶은 오브젝트 넣기 const newPresenceStatus = await channel.track({ onlineAt: new Date().toISOString(), }); }); return () => { channel.unsubscribe(); }; }, []);가져온 현재 활동 시간을 직접적으로 넣을 수 없어 atoms.ts에서 정의export const presenceState = atom({ key: "presenceState", default: null, });현재 활동 시간 표시하기ChatPeopleList.tsxonlineAt에 presence가 있으면 유저의 onlineAt을 가져옴 const [presence, setPresence] = useRecoilState(presenceState); ... channel.on("presence", { event: "sync" }, () => { const newState = channel.presenceState(); // newState를 그냥 쓰면 값을 마음대로 바꿔버릴 수 있기 때문에 // 다시 파싱하여 오브젝트를 깔끔하게 셋팅해줌 const newStateObj = JSON.parse(JSON.stringify(newState)); setPresence(newStateObj); }); ... return ( {getAllUsersQuery.data?.map((user, index) => ( { setSelectedUserId(user.id); setSelectedUserIndex(index); }} index={index} isActive={selectedUserId === user.id} name={user.email.split("@")[0]} onChatScreen={false} onlineAt={presence?.[user.id]?.[0]?.onlineAt} // onlineAt={new Date().toISOString()} userId={user.id} /> ))} 3. 4주차 제출 [메시지 삭제]메시지 삭제 시 '이 메세지는 삭제되었습니다'로 표시메시지 삭제 쿼리메시지 변경 및 is_deleted를 true로 변경export async function updateMessage({ messageId }: { messageId: string }) { const supabase = await createBrowserSupabaseClient(); const { data: { session }, error: authError, } = await supabase.auth.getSession(); if (authError || !session?.user) { throw new Error("User is not authenticated"); } const { data, error: updateError } = await supabase .from("message") .update({ message: "이 메시지는 삭제되었습니다", is_deleted: true }) // 메시지 내용 변경 .eq("id", messageId); if (updateError) { throw new Error(updateError.message); } return data; } 메시지 삭제시 실시간 업데이트 const updateMessageMutation = useMutation({ mutationFn: async ({ messageId }: { messageId: string }) => { return updateMessage({ messageId }); }, onSuccess: () => { // 성공적으로 메시지를 업데이트한 후 메시지 목록을 다시 가져옵니다. getAllMessagesQuery.refetch(); }, });
2025. 03. 23.
0
[인프런 워밍업 클럽 3기 풀스택 ] 3주차 발자국
목차다른 페이지에서 데이터를 받아 전달rocoil을 사용하는 방법Supabase에서 maybeSingle()과 single()react-intersection-observeruseInView 함수 사용법useInfiniteQuery 기본 사용법3주차 미션supabase에 컬럼 추가찜 기능 설정하기찜한 영화를 화면 최상단으로 보여주도록 정렬Netflix Clone Project 1. 다른 페이지에서 데이터를 받아 전달search 검색 란은 header에 있고 해당 값을 전달해서 받아 오는 곳은 movie-card-list.tsx 에 있으므로 Recoil(전역 상태 관리 라이브러리)을 사용하여 해당 값을 넘겨준다reccoil도 레이아웃에서 react query와 materia ui를 사용할 수 있게 해준 것처럼 가 있음하지만 recoil은 기본적으로 클라이언트 라이브러리라 별도의 provider를 정의 해준다.(app/config/RecoilProvider.tsx)[rocoil을 사용하는 방법]atom 함수를 사용/utils/recoil/atoms.ts 파일 생성recoil을 사용할 페이지에 atomes.ts에 생성한 search atom을 넣어줌ex) header.tsx const {search, setSerch} = useRecoilState(searchState)ex) movie-card-list.tsxqueryKey에 search 값을 넣어야 search 값이 변경될 때마다 query function이 재호출 됨 const search = useRecoilValue(searchState); const getAllMoviesQuery = useQuery({ queryKey: ["movie",search], queryFn: () => searchMovies({ search }), }); [Supabase에서 maybeSingle()과 single()]single(): 반환되는 데이터가 무조건 한 행이여야 하며, null이 존재 또는 데이터 1개 초과 조회 시 오류 발생maybeSingle(): 반환되는 데이터에 null이 존재해도 오류가 발생하지 않고, 빈 값을 반환함 2. react-intersection-observerreact-intersection-observer 설치npm install react-intersection-observer화면에 이 컴포넌트가 몇 퍼센트 들어왔을 때, inView 값이 true가 됨즉, 특정 요소가 화면에 노출되었는지 감지하는 기능import { useInView } from 'react-intersection-observer'; const [ref, inView, entry] = useInView({ threshold: 0, });ref는 감지할 요소에 연결해야 하는 참조이며, inview는 해당 요소가 화면에 노출되었는지 여부를 나타내는 불리언 값즉, 현재 observe할 엔티티에 대해 레퍼런스를 넣기 위한 값 {inView && 'Element is in view!'} ** 우리가 왜 이것을 사용해야 하나?스크롤 맨 아랫부분에 보이지 않는 태그를 넣어서 해당 태그가 보이면 다음 페이지를 가져올 수 있도록 함수를 만들 예정즉, 페이징을 커서 방식으로 개발[useInfiniteQuery]기존에 사용한 useQuery로 무한 스크롤을 구현하기에는 매우 복잡함(다양한 값 필요)useInfiniteQuery는 react-query 라이브러리의 핵심 기능 중 하나입니다. 이를 사용하면 무한 스크롤과 같은 기능을 쉽게 구현할 수 있다.isFetchingNextPage: isLoading 대신 사용fetchNextPage: 다음 페이지hasNextPage: 가지고 있는 다음 페이지const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status, } = useInfiniteQuery('todos', fetchTodos, { getNextPageParam: (lastPage, pages) => lastPage.nextPage, });3. 3주차 미션 [구현 이미지][supabase에 컬럼 추가]찜 기능을 구현할 favorit 이라는 컬럼 추가0이면 false, 1이면 true[찜 기능 설정하기]하트를 클릭하면 하트의 상태가 바뀌면서 데이터 저장import { updateFavorit } from "actions/movieActions"; import Link from "next/link"; import { useState } from "react"; export default function MovieCard({ movie }) { const [isFavorit, setIsFavorit] = useState(movie.favorit); const handleClick = async () => { setIsFavorit(!isFavorit); await updateFavorit(movie.id, movie.favorit); }; return ( {isFavorit ? ( ) : ( )} {/* image */} {movie.title} ); }아이디와 상태 값을 가져와 1 이면 0, 0이면 1로 바꾸어 update해줌 export async function updateFavorit(id, state) { const supabase = await createServerSupabaseClient(); state = state == 1 ? 0 : 1; const { data, error } = await supabase .from("movie") .update({ favorit: state, }) .eq("id", id); handleError(error); return data; } [찜한 영화를 화면 최상단으로 보여주도록 정렬]favorit 값을 0과 1로 설정했기 때문에 order에서 ascending를 사용해 내림차순으로 정렬export async function searchMovies({ search, page, pageSize }) { const supabase = await createServerSupabaseClient(); const { data, count, error } = await supabase .from("movie") .select("*", { count: "exact" }) .like("title", `%${search}%`) .order("favorit", { ascending: false }) .range((page - 1) * pageSize, page * pageSize - 1); const hasNextPage = count > page * pageSize; favorit 값이 1이면 꽉찬 하트, 0이면 빈 하트로 보여주며, 이미지보다 상단에 띄워 놓아 클릭 시 해당 값이 바뀌도록 설정함export default function MovieCard({ movie }) { return ( {movie.title} ); }
풀스택
・
풀스택
・
워밍업
2025. 03. 16.
0
[인프런 워밍업 클럽 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 { formData.append(file.name, file); }); await uploadImageMutation.mutate(formData); } }, []); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, multiple: true, }); return ( {uploadImageMutation.isPending ? ( ) : isDragActive ? ( 파일을 놓아주세요. ) : ( 파일을 여기에 끌어다 놓거나 클릭하여 업로드 하세요. )} ); } 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 */} {image.name} 수정된 시간: {formData(image.updated_at)} ... ) [파일명을 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.js
・
react
・
supabase
・
워밍업
・
발자국
2025. 03. 09.
0
[인프런 워밍업 클럽 3기 풀스택 ] 1주차 발자국
목차Next.js, TailwindCss, Recoil, supabase 특징Todo List 미션 Next.jsReact 기반의 풀스택 프레임워크SSR(서버사이드 렌더링) 지원서버에서 미리 HTML을 렌더링하여 SEO와 초기 로딩 속도 개선fetch 등의 API 요청을 서버에서 처리해 클라이언트의 부담 감소SSG(정적 사이트 생성) 지원빌드 시 HTML을 미리 생성하여 빠르게 페이지 로딩파일 기반 라우팅pages/ 폴더 내 파일이 자동으로 라우트 됨app/ 디렉토리에서는 레이아웃 공유 & 동적 라우팅 가능서버 컴포넌트 & 클라이언트 컴포넌트 지원 API Routes 제공백엔드 서버 없이 NextJS 내에서 API 구축 가능이미지 최적화 컴포넌트를 사용하면 자동으로 이미지 크기 조절 & 포맷 변환웹페이지 속도 향상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 {updated_at ? "수정됨: " + formatDate(updated_at) : formatDate(created_at)}
풀스택
・
풀스택
・
next.js
・
react
・
supabase
・
워밍업
・
발자국