🎁 모든 강의 30% + 무료 강의 선물🎁

블로그

leeebug

워밍업 클럽 스터디 3기 FS - 2주차 발자국

인프런 워밍업 클럽 스터디에 참여하고 벌써 2주 차도 마무리에 접어들고 있다. 4주간의 스터디이기 때문에 생각보다 일정이 타이트하여 시간관리가 무엇보다도 중요한 시기라고 생각한다.이번 주에는 파일 업로드 기능을 구현해야하기에 1주 차 과제를 급하게 마무리하고 지난주 토요일부터 서둘어서 미리 학습을 시작했다.깃 레포지토리의 경우에는 지난주에 사용했던 템플릿을 거의 수정없이 그대로 사용해서 개발환경 구축은 크게 어렵지 않았다.이번 주 강의에서는 Supabase Storage를 사용했는데 API가 잘 준비되어있어서 사용 방법을 익히는데도 크게 어렵지는 않았다. 다만 아래에서도 언급하겠지만 Supabase Storage는 AWS S3 기반으로 구현되어 강력한 네이밍 규칙이 적용되어 개인적으로는 Supabase Storage와 Supabase DB를 함께 사용했다. 📝 2주차 학습Supabase Storage클라우드 기반 객체 저장소로, AWS S3와 유사한 방식으로 파일을 저장하고 관리하는 서비스파일 및 이미지 업로드 및 관리 기능 제공PostgreSQL과 연동 가능권한 관리(RLS) 및 퍼블릭/프라이빗 파일 설정 가능Supabase SDK 또는 Restful API로 사용 가능  ✔ 파일명 규칙 (Supabase & AWS 공통) ASCII 문자, 숫자, 일부 특수 문자 허용 (- _ . /) 파일명을 /로 구분하여 폴더처럼 사용 가능 (folder/image.png) 공백 포함 가능하지만, URL Encoding이 필요할 수 있음 파일명에 한글, 이모지, 특수문자가 포함될 경우 정상적으로 업로드되지 않을 가능성이 있음 → URL-safe 변환 권장 React DropzoneReact에서 간편하게 파일 Drag & Drop 기능을 구현할 수 있는 라이브러리HTML5 File API를 활용하여 파일 업로드를 쉽게 구현할 수 있는 기능을 제공파일 타입, 크기, 개수 등 다양한 제약 조건 설정 가능비동기로 파일을 처리할 수 있는 onDrop 이벤트 제공 📋 2주차 미션💬 GitHub 저장소🚀 데모 영상 보러가기미션 해결 과정 요약2주 차 미션은 Next.js, React Query, TailwindCSS를 사용하여 이미지 업로드 앱을 구현하기였다. 필수 구현 기능으로는 이미지 업로드 기능(클릭 업로드 방식과 Drag & Drop 방식, 다중 업로드)과 이미지 삭제와 이미지 검색 기능 구현하기였다. 추가 기능은 파일의 마지막 수정 시간을 화면에 출력하는 UI 구현하기였다. 여기에 과제의 완성도를 높이기 위해서 개인적인 챌린지로 파일명에 한글 또는 특수문자 포함된 파일 업로드 기능, 1MB 미만으로 이미지를 압축하는 기능, 다운로드 기능을 추가로 구현하였다. 과제 추가 구현 기능✅ 마지막 수정 시간 표시const { error: insertError } = await supabase.from(DB_TABLE_NAME).insert({ name: file.name, originalName: originalFileName, imageId: uploadedFile.id, imageUrl: publicUrl, createdAt: new Date(file.lastModified).toISOString(), })생성: DB에 파일 데이터 업로드 시 createdAt에 file.lastModified를 ISOString 형식으로 저장 if (dbData) { const { error: updateError } = await supabase .from(DB_TABLE_NAME) .update({ name: file.name, originalName: originalFileName, imageUrl: publicUrl, updatedAt: new Date().toISOString(), }) .eq('imageId', uploadedFile.id)수정: DB에 해당 ID가 존재할 경우 updatedAt(string | null)에 현재 시간을 ISOString 형식으로 저장// DropImageManager 컴포넌트에서 생성 시간, 수정 시간을 포멧팅하여 DropImage 컴포넌트에 프롭스로 전달 const localCreatedAt = getLocalTime(image.createdAt) const localUpdatedAt = image.updatedAt ? getLocalTime(image.updatedAt) : null <!-- JSX 정렬이 잘 안되서 렌더링 형태만 봐주세요! --> <div className="w-5/6 truncate"> <span className={`text-[0.7rem] font-semibold ${localUpdatedAt ? 'text-mint-800' : 'text-gray-500'}`} > {localUpdatedAt ? localUpdatedAt : localCreatedAt} </span> {localUpdatedAt && ( <span className="text-[0.7rem] font-semibold text-mint-800"> (수정)</span> )} </div>출력: updatedAt이 존재할 경우 updatedAt과 (수정) 을 함께 출력, updatedAt: null이라면 createdAt를 출력 개인 챌린지 기능✅ 파일명 자동 변환 후 이미지 업로드하는 기능을 구현 (UX 개선)파일명 검증: 정규식을 활용하여 한글 및 특수 문자 포함 여부를 확인자동 변환: 검증 후 8자리 랜덤 문자열로 안전한 파일명 생성업로드 처리: 변환된 파일명으로 File 객체 생성 후 formData.append로 원본 파일명 함께 전송서버 액션: Supabase Storage에 저장 후, 완료 시 DB에 메타데이터 저장하여 연동결론: 파일명 변환을 자동화하여 업로드 오류를 방지하고, 원본 파일명도 유지하여 검색 및 관리 UX 개선✅ 파일 용량이 1MB 초과 시 자동 압축 후 업로드하는 기능을 구현 (UX 개선)browser-image-compression 라이브러리를 사용하여 파일의 용량 검증 후 1MB 초과 시 이미지 압축 후 업로드결론: 이미지 최적화로 업로드 속도 향상, 스토리지 비용 절감 효과✅ Blob URL을 활용한 다운로드 기능 추가 (UX 개선)Blob URL 생성: 업로드된 이미지를 fetch()로 가져와 Blob으로 변환다운로드 기능 구현: window.URL.createObjectURL(blob)으로 브라우저에서 직접 다운로드 가능하도록 처리결론: Blob URL 다운로드 방식을 적용하여 최적화된 이미지를 빠르게 다운로드 받을 수 있도록 개선🚧 기능 구현 시 어려웠던 부분Supabase Storage에 전달하는 File 객체 커스텀 불가원본 파일명을 추가하려 했으나, File 객체 자체를 수정하는 것이 제한적이다.파일 객체를 복사하여 원본 파일명을 추가하는 방법 시도전개 연산자를 사용하여 객체 복사 후 원본 파일명을 추가하려 시도하였으나 file 객체는 일반적인 방법으로는 복사할 수 없는 특별한 객체이다.ExtendedFile 확장 클래스로 인스턴스를 생성했으나 서버에 전달되지 않는 문제 발생확장된 ExtendedFile 객체를 formData에 담아 서버로 전송했지만, 서버에 정상적으로 전달되지 않았다.최종 해결 방법formData.append("file", file) formData.append("originalFileName, file.name)file 객체와 원본 파일명을 함께 서버로 전송 후 가공하여 Supabase Storage의 파일명에는 안전한 파일명만 저장하고 DB에 스토리지Id, 원본 파일명, 안전한 파일명, 이미지URL 등 정보를 저장했다. 🧾 ERD 다이어그램👀 2주차 회고아직 갈 길이 멀지만, 리팩토링을 통해 Next.js의 장점을 살릴 수 있는 구조로 점점 개선되어가는 과정을 경험하면서 이번 주 역시 알차게 보냈다고 생각한다.이번 주는 특히 MVP 패턴과 비슷한 형태로 컴포넌트 구조를 잡는 것에 익숙해지는 것을 개인적인 목표로 삼았다. 처음부터 MVP 패턴을 염두해 두고 설계한 것은 아니었지만, 진행하다보니 자연스럽게 MVP와 유사한 패턴으로 정리되어 가는 것을 느꼈다.화면 렌더링 시 상호작용이 필요하지 않은 정적인 요소들까지 클라이언트 컴포넌트로 관리하면 불필요한 하이드레이션 부담이 증가할 수 있다는 점을 다시 한번 체감했다.클라이언트 컴포넌트 내에서도 역할을 나눠 서비스 레이어나 상태 관리만 담당하는 매니져 컴포넌트와 프롭스로 상태를 전달받아 단순히 화면을 렌더링을 담당하는 UI 컴포넌트로 분리하는 연습을 진행했다.이러한 구조로 개선하면서 클라이언트 컴포넌트의 부담을 줄이고, 유지보수성을 높이는 방향으로 점차 최적화되고 있다는 점이 느껴졌다. 아직 개선해야 할 부분이 많지만 점진적으로 개선하여 더 나은 아키텍쳐를 만들어가는 과정이 의미 있었다고 생각한다.

풀스택워밍업클럽3기발자국회고과제미션

leeebug

워밍업 클럽 스터디 3기 FS - 스터디 후기

워밍업 클럽을 처음 알게 된 건 사실 이번 3기가 아니라 지난 2기 때였다.당시에도 이 스터디에 흥미가 있었지만 개인적인 사유로 아쉽게도 참여하지 못했는데, 이번 3기에 Next.js 과정이 포함되었다는 소식을 듣고 주저 없이 참여 신청했다.결과적으로는 아주 만족스러운 선택이었다. SLL 회고Startany를 배제하고, 모든 컴포넌트에 명시적 타입 선언을 적용해보았다.단일 책임 원칙을 기반으로, UI와 비즈니스 로직을 분리하는 컴포넌트 구조화를 시도해보았다.클라이언트와 서버 상태를 별도로 관리하기 위해서 React Query를 도입해보았다.Zod + React Hook Form 조합을 처음 적용해보면서 스키마 기반 유효성 검증의 흐름을 경험해보았다.LearnUI와 비즈니스 로직을 분리하는 패턴은 가독성 향상에는 도움이 되었지만, 반대로 전체적인 복잡도가 증가할 수 있다는 점도 느꼈다.Next.js 환경에서는 SWR보다 React Query가 더 세밀한 제어와 상태 관리에 적합하다는 것을 체감했다.기능을 직접 구현하는것도 충분히 의미가 있지만, 이미 검증된 라이브러리를 적절히 도입하는 것이 생산성 측면이나 유지보수 측면에서 더 큰 도움이 될 수 있다는 점을 배웠다.Love타입 설계와 컴포넌트 구조를 직접 개선해본 경험 자체만으로 코드에 대한 자신감과 설계 감각이 향상되었음을 체감했다.주차별 다른 주제의 과제를 만들어가는 과정을 통해서 작은 성공의 중요성을 다시 한 번 느낄 수 있었다.  마무리짧다면 짧은 4주의 기간동안 매주 스스로 도전하고 개선해가는 과정을 통해 한 걸음 더 성장할 수 있었다고 느꼈다.혹시나 아직도 워밍업 클럽 참여를 망설이고 있다면 꼭 한 번 경험해보길 추천하며 스터디 회고를 마친다.

풀스택인프런인프런워밍업클럽스터디3기

LC-02s

[인프런 워밍업 클럽 3기] 풀스택 스터디 2주차 미션 회고 발자국

학습 내용 요약 인프런 워밍업 클럽 3기 풀스택 스터디 2주차에는 Supabase Storage를 활용하는 방법을 다루었습니다. 인프런에서 발자국을 작성할 때 강의를 보지 않고도 강의 내용을 파악할 수 있을 만큼 자세한 내용을 작성하는 건 지양해 달라고 가이드한 만큼 강의에 대한 필기는 최소화 하고 회고 위주로 적어보겠습니다. Supabase Strorage 기능 간단 정리Supabase의 Storage 기능은 파일 저장을 위한 서비스로, AWS S3와 같은 오브젝트 스토리지 기능을 제공함. Next.js, React, Flutter, Node.js 등 다양한 환경에서 사용할 수 있으며, PostgreSQL 기반의 권한 관리(RLS)를 지원하는 것이 특징임. 1. 파일 저장 및 관리이미지, 동영상, 문서 등 다양한 파일 형식을 저장 가능파일 업로드, 다운로드, 삭제, 이동 등의 기능 제공버킷(Bucket) 단위로 파일을 관리2. 권한 및 보안 (RLS)Row Level Security (RLS): PostgreSQL과 동일한 방식으로 접근 권한을 설정 가능공개(Public) 및 비공개(Private) 버킷 지원JWT 기반 인증을 사용하여 사용자별 접근 제한 가능3. 파일 접근 방식퍼블릭 파일: 누구나 URL을 통해 접근 가능프라이빗 파일: 인증된 사용자만 접근 가능 (Signed URL 활용)서명된 URL (Signed URLs): 일정 시간 동안만 유효한 URL 생성 가능4. 폴더 및 파일 구조디렉토리(폴더) 개념 지원폴더 내에서 파일 정리 및 관리 가능5. API 및 SDK 지원Supabase Client SDK를 통해 간편한 파일 업로드 및 관리 가능RESTful API 제공 (HTTP Client를 사용하여 직접 호출 가능)6. 이미지 변환 및 최적화Supabase Storage Image Transformation 지원 (이미지 크기 조절, 포맷 변경 등)CDN을 통해 빠른 이미지 제공 가능  Dropbox 클론 미션 회고 풀스택 스터디 2주차 미션은 강의에서 진행하는 Next.js와 Supabase Storage를 활용한 Dropbox 클론 앱에 사진 별 마지막 수정 시간을 표시하는 것이었지만, 저는 기타 편의기능을 더 추가해 보았습니다. 진행했던 미션은 해당 링크에서 보실 수 있습니다.  미션 수행 내용아래는 제가 수행한 미션에 대한 내용을 정리해보았습니다. 기능 명세이미지 파일 업로드 기능드래그 앤 드롭 기능다중 업로드 기능업로드한 이미지 파일 조회 기능키워드 검색 기능이미지 파일 다운로드 기능업로드한 이미지 파일 수정 기능이미지 파일명 변경 기능업로드한 이미지 파일 삭제 기능 강의에서는 기본적으로 파일 입출력에 관련된 기능만 다루었지만, 저는 실제 사용성을 고려하여 파일 업로드 전 미리보기 기능, 이미지 다운로드 기능, 파일 이름 변경 기능 등을 추가해 보았습니다. 또한 강의에서는 react-dropzone 라이브러리를 사용해서 드래그 앤 드롭 기능을 구현하였지만, 저는 프로젝트의 기본적인 스타일 프레임워크로 만타인을 사용하고 있었기 때문에, 만타인에서 따로 지원하는 @mantine/dropzone 라이브러리를 사용하여 구현하였습니다. 미션에 사용한 기술들은 아래에 따로 정리해 두었습니다. 사용할 때마다 느끼는 거지만 만타인은 정말 편한 것 같아요. 사용 기술프레임워크: Next.js v15, React v19데이터베이스: Supabase서버 상태관리: Tanstack Query v5클라이언트 상태관리: Zustand v5스타일 프레임워크: TailWindCSS v3, Mantine v7, Tabler Icons모노레포: Turbo Repo패키지 매니저: pnpm   트러블 슈팅아래는 미션을 진행하면서 만났던 문제들을 해결하는 과정에 대한 내용입니다. 파일명에 한글이 포함될 경우 Supabase Storage에 업로드하지 못하는 문제이미지 파일 이름에 한글이 포함될 경우 업로드가 되지 않는 문제가 있었습니다. 이슈를 찾아보니 Supabase Storage의 정책적인 문제였고, AWS의 S3 서비스도 동일한 문제를 가지고 있었기에 아래 조치들을 취하였습니다. 조치 1.처음 취했던 조치는 아래와 같이 nanoid 라이브러리를 활용하여 중복되지 않는 이름을 생성 후 기존의 파일 이름을 대체하는 방식을 사용했었습니다. 하지만 해당 방식을 사용하면 기존의 파일 이름이 사용자가 식별하지 못하는 값으로 대체되는 문제와, 중복되는 파일을 확인할 수 있는 방법이 없어지는 문제가 있어 최종적으로는 사용하지 않았습니다.'use server' import { nanoId } from 'nanoid' export const uploadImages = async ({ files, }: UploadImagesParams): Promise<{ data: { id: string; path: string } | null }[]> => { const client = await createServerSupabaseClient() return await Promise.all( files.map((file) => { const extension = extractExtension(file.name) const path = `/${nanoId() + '.' + extension}` return client.storage .from(process.env.SUPABASE_BUCKET_NAME!) .upload(path, file, { upsert: true }) }), ) } 조치 2.두 번째로 취한 조치는 조금 번거롭긴 하지만 파일과 1 대 1 로 대응되는 데이터베이스 테이블을 만들어서 관리하는 방식을 사용하였습니다. Supabase에서 지원하는 uuid를 활용하여 테이블의 Primary Key를 설정해 주었고, 이미지 업로드 시 먼저 테이블에 기존 파일 이름을 기반한 데이터 insert 후 생성된 uuid를 사용하여 파일명을 재설정하는 방식으로 우회하였습니다. Supabase에서 지원하는 uuid를 사용했기에 nanoid 같은 별도의 식별자 생성 라이브러리를 관리하지 않을 수 있었습니다.export const uploadImages = async ({ files, }: UploadImagesParams): Promise<{ data: { id: string; path: string } | null }[]> => { const client = await createServerSupabaseClient() const databaseQueries = files.flatMap((file) => { return client .from('minibox') .upsert({ name: file.name }) .select() .then((result) => result.data?.[0] ?? null) }) const targetFiles = await Promise.all(databaseQueries) const storageQueries = targetFiles.map((data) => { if (!data) { return { data: null } } const extension = extractExtension(data.name) const path = `/${data.id + '.' + extension}` const file = files.find((file) => file.name === data.name)! return client.storage .from(process.env.SUPABASE_BUCKET_NAME!) .upload(path, file, { upsert: false }) }) return await Promise.all(storageQueries) }업로드한 파일명이 한글일 경우 올바르게 검색 되지 않는 문제 (feat. MacOS)MacOS 환경에서 업로드한 파일을 별도의 후처리 없이 그대로 데이터베이스에 업로드 했더니 한글이 포함된 파일명에 대해서 아래와 같이 문자열 포함 여부를 판단하는 ilike 쿼리가 제대로 동작하지 않는 문제가 있었습니다.export const getImages = async ({ query = '' }: GetImagesParams): Promise<DroppedImageFile[]> => { const client = await createServerSupabaseClient() const imagesDataAll = await client.from('minibox').select('*').ilike('name', `%${query}}%`) } 원인을 분석해보니 아래와 같이 파일 이름에 한글이 포함되어 있을 시 자음과 모음이 모두 분리된 상태로 저장되어 있어 특정 키워드 포함 여부를 올바르게 판단하지 못해 발생한 문제였습니다.// input 'temp-훈이머리귤.jpeg'.split('')조치기존에 사용하던 ilike 쿼리를 제거하고, 자바스크립트에서 지원하는 String.prototype.normalize 메서드를 사용하여 기존 데이터에 대한 정규형 정준 결합(Normalization Form Canonical Composition) 절차를 거친 후 필터링을 거치는 방법으로 해결하였습니다.export const getImages = async ({ query = '' }: GetImagesParams): Promise<DroppedImageFile[]> => { const client = await createServerSupabaseClient() const imagesDataAll = await client.from('minibox').select('*') const targetData = imagesDataAll.data .filter(({ name }) => name.normalize('NFC').includes(query)) .map(({ id, name }) => `${id + '.' + extractExtension(name)}`) }  후기저는 이제껏 프론트엔드 개발을 접해보면서 한 번도 이미지 파일에 관련된 기능을 작업해보지 않았었습니다. 물론 서버 액션을 사용해서 조금 더 간략한 인터페이스를 사용했기에 실제 FormData 인터페이스를 사용하여 통신 로직을 작성하는 경험은 해보지 못했다는 한계가 있지만, 이번 미션을 통해 자바스크립트로 이미지 파일을 핸들링하는 방법과, Storage 서비스를 연동하여 저장까지 모두 접해볼 수 있어서 개인적으로는 만족스러웠던 한 주였던 것 같습니다. 긴글 읽어주셔서 감사합니다. ☺  

프론트엔드워밍업클럽3기풀스택Next.jsSupabase

치현

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

학습 내용인프런 워밍업 클럽 스터디 2주차로, 이번 주는 드롭 박스 프로젝트와 함께 Supabase의 Storage를 다뤄볼 수 있는 시간이었다. Supbase Storage1. 기본 구성 요소Files: 모든 종류의 미디어 파일 저장 가능 (이미지, GIF, 비디오 등)Folders: 파일을 체계적으로 구성하기 위한 디렉토리 구조Buckets: 파일과 폴더를 담는 최상위 컨테이너 (접근 규칙별로 구분)2. 접근 제어 모델Private Buckets (기본값) RLS(Row Level Security) 정책을 통한 접근 제어JWT 인증 필요Signed URL을 통한 임시 접근 가능Public Buckets파일 조회 시 접근 제어 없음URL만 있으면 누구나 접근 가능업로드/삭제 등 다른 작업은 여전히 접근 제어 적용3. 보안 기능RLS 정책 설정 가능SELECT (다운로드)INSERT (업로드)UPDATE (수정)DELETE (삭제)소유권 관리owner_id 필드로 리소스 소유자 추적JWT의 sub claim 기반 소유권 할당4. 이미지 변환 기능 (Pro Plan 이상)실시간 이미지 최적화크기 조정품질 조정 (20-100)WebP 자동 최적화변환 옵션resize 모드: cover, contain, fillwidth/height 지정 (1-2500px)최대 파일 크기: 25MB최대 해상도: 50MP5. 인증 방식S3 액세스 키서버 사이드 전용모든 버킷에 대한 완전한 접근 권한세션 토큰클라이언트 사이드 사용 가능RLS 정책 기반 제한된 접근6. 통합 기능Next.js 이미지 로더 지원AWS S3 호환성PostgreSQL DB와 연동7. 제한사항파일명은 AWS S3 명명 규칙 준수 필요HTML 파일은 보안상 plain text로 반환이미지 변환 기능은 Pro Plan 이상에서만 사용 가능미션 2 구현 내용과제 구현 저장소Dropbox 중파일의 마지막 수정(업로드) 시간을 표시하는 기능 추가 하기 export interface FileObject { name: string bucket_id: string owner: string id: string updated_at: string created_at: string last_accessed_at: string metadata: Record<string, any> buckets: Bucket }=> DropboxImage 컴포넌트가 prop으로 받는 image의 타입은 FileObject로 그 중 업로드시간은 created_at을 의미하기에 이를 이미지에 추가하였다.(사진 참고) 포인트 1: 한글 파일명 es-hangul 사용// 안전한 파일명 생성을 위한 유틸리티 export class FileNameConverter { // 안전한 문자 패턴 정의 private static readonly SAFE_CHARACTERS = /^[a-zA-Z0-9!\-_.*'()]+$/; private static generateRandomString(length: number = 8): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; return Array.from({ length }, () => chars.charAt(Math.floor(Math.random() * chars.length)) ).join(""); } // 파일명이 안전한 문자들로만 구성되었는지 확인 private static isSafeFileName(name: string): boolean { return this.SAFE_CHARACTERS.test(name); } // 안전하지 않은 문자를 포함한 파일명을 안전한 형식으로 변환 private static convertToSafeFileName(name: string): string { try { // 파일명 정규화 const normalized = name.trim().normalize(); // 한글이나 특수문자가 있는지 확인 const hasKorean = /[ㄱ-ㅎㅏ-ㅣ가-힣]/.test(normalized); const hasSpecialChars = /[^A-Za-z0-9]/.test(normalized); if (!hasKorean && !hasSpecialChars) { return normalized; } // 한글이 있는 경우 로마자로 변환 시도 if (hasKorean) { const romanized = romanize(normalized); if (romanized && romanized !== normalized) { // 로마자 변환 결과에서 안전하지 않은 문자 제거 return romanized.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(); } } // 변환 실패 시 랜덤 문자열 생성 return this.generateRandomString(); } catch (error) { console.error("Conversion error:", error); return this.generateRandomString(); } } // 원본 파일명을 안전한 형식으로 변환 static encode(fileName: string): string { console.log("Original filename:", fileName); const extension = fileName.split(".").pop() || ""; const nameWithoutExt = fileName.slice(0, fileName.lastIndexOf(".")); const safeName = this.isSafeFileName(nameWithoutExt) ? nameWithoutExt : this.convertToSafeFileName(nameWithoutExt); console.log("Safe filename:", safeName); return `${safeName}_${Date.now()}.${extension}`; } // 파일명에서 타임스탬프 제거하여 원본 이름 추출 static decode(fileName: string): string { const [name] = fileName.split("_"); return name || fileName; } }포인트 2 : 업로드 날짜 표시export function formatDate(timestamp: string): string { const date = new Date(timestamp); const now = new Date(); const diff = now.getTime() - date.getTime(); // 1일 이내 if (diff < 24 * 60 * 60 * 1000) { const hours = Math.floor(diff / (60 * 60 * 1000)); if (hours < 1) { const minutes = Math.floor(diff / (60 * 1000)); return `${minutes}분 전`; } return `${hours}시간 전`; } // 30일 이내 if (diff < 30 * 24 * 60 * 60 * 1000) { const days = Math.floor(diff / (24 * 60 * 60 * 1000)); return `${days}일 전`; } // 그 외 return date.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", }); } 회고파일명 변환하는데 생각보다 시간이 많이 소요됐다.여찌저찌 구현은 헀지만, 이미지가 어떻게 encoding되고 decoding되는지 일련의 과정에 대한 공부가 필요함을 느끼는 이번주 였다.  

풀스택풀스택인프런워밍업스터디클럽Next3기SupabaseReact프론트엔드2주차발자국

codestudy

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

4주차 학습 내용강의 및 학습 내용React/Next.js 기본 레이아웃 구성Flex 속성 활용: flex, justify-center, items-center 등의 특성 학습컴포넌트 구조화 방법과 레이아웃 관리테일윈드 CSS를 활용한 그라데이션 배경 적용bg-gradient-to-r from-cyan500 to-blue500 등의 문법 활용로그인/회원가입 화면 구현SetView 상태관리를 통한 화면 전환 구현사용자가 로그인과 회원가입 사이를 전환할 수 있도록 기능 개발이메일과 패스워드 입력폼 설계 및 유효성 검사 구현Supabase 활용Supabase Auth를 통한 인증 시스템 구현메시지 테이블 설계와 컬럼(id, message, sender, receiver, is_deleted, created_at) 구성Supabase Realtime을 활용한 실시간 채팅 기능 구현RLS(Row Level Security) 정책 설정으로 데이터 보안 강화채팅 기능 구현메시지 전송 및 수신 기능 개발메시지 상태(읽음/안읽음) 관리를 위한 is_read 필드 활용메시지 삭제 기능 구현(is_deleted 필드 활용)UUID를 사용한 사용자 식별 및 메시지 보내기/받기 구현디자인 및 UI 개선Material Tailwind 활용className 속성을 통한 스타일링 관리반응형 디자인 구현(모바일 최적화)유저 아바타 구현을 위한 랜덤 이미지 API 활용학습 회고Instagram 클론코딩 4주차에서는 채팅 기능 고도화를 진행했습니다.Supabase의 실시간 데이터 처리 기능을 활용해 메시지 전송, 수신, 상태 관리를 구현했습니다.로그인/회원가입 페이지의 UI를 개선하고 테일윈드 CSS로 그라디언트 배경 효과를 적용했습니다.React와 Next.js, Supabase를 조합해 백엔드 인프라 없이도 강력한 기능을 빠르게 구현하는 현대적인 웹 개발 방식을 경험했습니다.실시간으로 채팅이 된다는 점이 너무 재밌었습니다.🛠 미션 해결 과정채팅 기능 고도화 미션 구현구현 과정데이터베이스 테이블 설계:message 테이블 확장: is_read, is_deleted 필드 추가blocked_users 테이블 생성: blocker_id, blocked_id 필드 포함reports 테이블 생성: reporter_id, reported_id, message_id, reason 필드 포함각 테이블에 적절한 외래 키(Foreign Key) 설정Row Level Security 정책 구현:메시지 읽기, 쓰기, 업데이트 권한 설정"사용자가 수신한 메시지 읽음 표시 가능" 정책 추가차단 및 신고 기능에 대한 사용자별 접근 권한 설정메시지 삭제 기능 구현:Message 컴포넌트에 삭제 버튼 추가is_deleted 필드를 업데이트하는 서버 액션 구현삭제된 메시지는 UI에서 필터링하여 표시하지 않도록 처리메시지 읽음/안읽음 표시 기능:is_read 필드를 이용해 메시지 상태 관리메시지를 읽을 때 자동으로 상태 업데이트UI에 읽음 상태 표시 (읽음/안읽음)사용자 차단 기능 구현:사용자 차단 버튼 및 확인 모달 추가blocked_users 테이블에 차단 정보 저장차단된 사용자와의 메시지 교환 제한메시지 신고 기능 구현:부적절한 메시지 신고 버튼 및 신고 사유 입력 모달 추가reports 테이블에 신고 정보 저장신고 후 사용자 피드백 제공실시간 업데이트 기능 개선:Supabase Realtime을 통한 INSERT와 UPDATE 이벤트 동시 구독메시지 상태 변경 시 실시간으로 UI 업데이트새 메시지 알림 구현로그인/회원가입 페이지 버그 수정:화면 전환 로직 오류 수정회원가입/로그인 버튼 기능 정상화 미션 해결 회고구현 내용데이터베이스 테이블 설계: message, blocked_users, reports 테이블 구성Row Level Security 정책 구현: 메시지 읽기/쓰기/업데이트 권한 설정메시지 기능 구현:메시지 삭제 기능읽음/안읽음 표시 기능사용자 차단 기능메시지 신고 기능Supabase Realtime을 통한 실시간 업데이트 기능 개선로그인/회원가입 페이지 버그 수정배운 점Supabase RLS(Row Level Security) 정책 설정을 통한 데이터베이스 보안 구현 방법WebSocket 기반 Supabase Realtime을 활용한 실시간 UI 업데이트 구현TypeScript를 활용한 타입 안전성 확보Supabase를 활용한 서버리스 백엔드 구축의 효율성실시간 기능이 사용자 경험에 미치는 중요성 

풀스택supabase워밍업3기

LC-02s

[인프런 워밍업 클럽 3기] 풀스택 스터디 4주차 미션 회고 발자국

학습 내용 요약인프런 워밍업 클럽 3기 풀스택 스터디 4주차에는 Supabase의 Real Time Database와 Authentication, RLS(Row Level Security) 기능을 활용하여 실시간 채팅 기능을 지원하는 인스타그램 클론 프로젝트를 진행해 보았습니다. 인프런에서 발자국을 작성할 때 강의를 보지 않고도 강의 내용을 파악할 수 있을 만큼 자세한 내용을 작성하는 건 지양해 달라고 가이드한 만큼 강의에 대한 필기는 최소화 하고 회고 위주로 적어보겠습니다. Supabase Realtime Database 주요 기능데이터 실시간 업데이트데이터베이스의 INSERT, UPDATE, DELETE 이벤트를 감지특정 테이블, 컬럼 또는 조건에 따라 변경 사항을 필터링 가능PostgreSQL 기반 트리거 (Trigger) 활용기존 Postgres 데이터베이스와 완벽하게 통합트리거(Trigger)를 사용해 복잡한 로직을 추가 가능채널 기반 구독 (Channels & Broadcast)특정 테이블 또는 쿼리에 대해 채널을 구독(Subscribe) 하여 변경 사항 수신클라이언트 간 브로드캐스트(Broadcast) 메시지 전송 가능Row Level Security (RLS) 지원실시간 데이터도 Postgres RLS 규칙을 준수특정 사용자만 특정 데이터에 대한 실시간 업데이트를 수신 가능저지연(Low Latency) 데이터 업데이트PostgreSQL의 LISTEN/NOTIFY를 사용하여 빠른 데이터 전송웹소켓(WebSocket) 기반으로 구현되어 빠른 응답 가능 Supabase Authentication 주요 기능다양한 로그인 방식 지원이메일/비밀번호 로그인 (기본 제공)소셜 로그인 (OAuth) → Google, GitHub, Apple, Discord 등Magic Link (비밀번호 없이 이메일 링크 클릭으로 로그인)OTP (SMS 기반 인증)SAML, OpenID Connect 지원 (엔터프라이즈 인증)Row Level Security (RLS) 통합Postgres의 Row Level Security (RLS) 를 활용하여 사용자별 데이터 접근 제한 가능auth.uid() 함수를 사용하여 현재 로그인한 사용자 필터링 가능JWT 기반 인증 및 커스텀 클레임로그인 시 JWT(JSON Web Token) 가 발급됨필요에 따라 사용자 정의 클레임(Custom Claims) 추가 가능비밀번호 재설정 및 사용자 관리이메일을 통한 비밀번호 재설정 기능 제공사용자 프로필 정보 (auth.users 테이블) 관리 가능기기 기반 인증 및 다중 세션 관리기기별 세션을 유지하고 관리할 수 있음다중 기기 로그인 지원 Supabase RLS 주요 기능사용자별 데이터 접근 제어로그인한 사용자만 자신의 데이터에 접근하도록 설정 가능auth.uid()를 사용해 현재 로그인한 사용자의 UID를 자동 감지PostgreSQL 정책(Policy) 기반 권한 관리CREATE POLICY 를 사용하여 테이블 단위의 접근 정책 설정 가능특정 역할(Role) 또는 조건을 만족하는 사용자만 데이터 접근 허용JWT 기반 인증과 연동Supabase의 Authentication(JWT) 와 함께 사용 가능JWT의 사용자 ID(UID), 이메일, 역할(Role) 정보를 활용하여 정책 적용유연한 권한 설정읽기(Read) 전용 정책 설정 가능소유자(Owner) 기반 정책을 설정하여 자신의 데이터만 수정 가능특정 사용자 그룹(Role)에게만 접근 권한 부여 가능  인스타그램 클론 미션 회고풀스택 스터디 4주차 미션은 강의에서 진행하는 Next.js와 Supabase Realtime Database, Authentication, RLS를 활용한 인스타그램 클론 앱에 여러 편의 기능을 추가하는 것과, 지금까지 진행했던 모든 프로젝트들을 배포까지 해보는 것이었습니다. 이번에도 역시 강의와는 다르게 실제 인스타그램의 MVP를 구현하는 것을 목적으로 했으며, 4주간 학습했던 내용들을 최대한 담아보았습니다. 진행했던 미션은 해당 링크에서 보실 수 있습니다. 미션 수행 내용아래는 제가 수행한 미션에 대한 내용을 정리해보았습니다. 로그인 기능 이메일 기반 로그인Confirmation URL 방식프로필 설정아이디 설정 기능이름 설정 기능프로필 이미지 설정 기능유저 탐색 기능 유저 프로필 조회 기능팔로잉 수 조회 기능팔로워 수 조회 기능팔로잉 기능 팔로잉 목록 조회 기능팔로워 목록 조회 기능실시간 채팅 기능 메시지 삭제 기능메시지 읽음 상태 확인 기능채팅방 접속 상태 확인 기능  사용 기술프레임워크: Next.js v15, React v19데이터베이스: Supabase서버 상태관리: Tanstack Query v5클라이언트 상태관리: Zustand v5스타일 프레임워크: TailWindCSS v3, Mantine v7, Tabler Icons모노레포: Turbo Repo패키지 매니저: pnpm  트러블 슈팅 Next.js useSearchParams 훅 빌드 환경 에러개발환경에서는 다른 문제가 없었는데, 빌드 시점에 Next.js 에서 제공하는 useSearchParams 훅을 사용한 로직이 아래의 에러를 반환하는 문제가 있었습니다. usesearchparams() should be wrapped in a suspense boundary at page "/404". 관련 이슈를 찾아보니 Next.js 14 버전 부터 useSearchParams 훅이 기본적으로 suspense boundary 에서 동작하도록 수정되어서 발생하는 이슈였습니다. 저의 경우에는 탐색 페이지의 검색어, 팔로잉 페이지의 탭메뉴, 리다이렉트 시 토스트 메시지 노출 로직에 쿼리 파라미터를 사용했기에 useSearchParams 훅을 사용한 부분을 모두 아래와 같이 Suspense 로 감싸주어 해결하였습니다. const FollowingPage: React.FC = () => ( <WithAuth> <Suspense> <FollowingTabs /> </Suspense> </WithAuth> )const SearchPage: React.FC = () => ( <Suspense> <div className="p-4 pb-7 pt-5"> <SearchBar /> </div> <SearchProfileResultList /> </Suspense> )export const Provider: React.FC<React.PropsWithChildren> = ({ children }) => ( <QueryProvider> <MantineProvider theme={defaultThemeSchema} defaultColorScheme="auto"> <ColorSchemeScript defaultColorScheme="auto" /> <ModalsProvider labels={{ confirm: '확인', cancel: '취소' }}> <Suspense> <SearchParamsMessengerProvider> {children} <Toaster position="bottom-right" /> </SearchParamsMessengerProvider> </Suspense> </ModalsProvider> </MantineProvider> </QueryProvider> ) Module Federation (Micro FrontEnd)모노레포로 모든 프로젝트를 구성했기에 마지막 배포 미션에서 4개의 프로젝트를 모두 배포하는 대신 1개의 프로젝트만 관리할 목적으로 마이크로 프론트엔드 기술을 적용해 보고 싶었습니다. 빌드 시점에 4개의 프로젝트를 하나의 앱으로 통합하여 하나의 도메인을 공유하는 상태로 엔드포인트만 바뀌는 방식으로 배포해보고 싶었는데, 마이크로 프론트엔드를 구성하는 핵심 기술인 Module Federation의 Next.js 플러그인이 App Router는 지원하지 않더라구요. 저는 이미 App Router로 모든 로직을 작성한데다 지금 시점에서 모두 Pages Router로 포팅하기에는 시간이 부족하다 판단하여 해당 방식은 포기하였습니다. 그래서 대체재로 Wrapper 형식의 앱을 추가로 생성한 후 Next.js의 Multi Zones 기능과 Middleware, Rewrites 기능을 조합하여 4개의 프로젝트를 묶는 방식을 고려해봤었는데, 해당 방식은 결국 4개의 프로젝트를 개별로 배포해야하는 문제가 여전히 존재하여 결국 포기하게 되었습니다. 아무리 찾아봐도 마땅한 대안이 없더라구요. 해당 내용은 직접적인 트러블 슈팅은 아니지만 아쉬워서 남겨보았습니다.  후기처음 OT 때도 코치님이 말씀해주셨지만, 이번 주차에서는 미션의 난의도가 확 올라갔던 것 같습니다. 저는 특히 테이블 설계가 어려웠던 것 같아요. 기본적인 테이블들을 정규화를 거쳐 분리해놓으니 각 기능 별로 JOIN 로직을 거치는 단계가 복잡해져서 API 로직을 작성하면서도 이게 맞나 라는 생각이 들었던 적이 많았던 것 같습니다. 백엔드와 작업할 때 상황별로 필요한 인터페이스를 하나의 API에 모아 달라고 요청하는 경우가 많았었는데, 그게 백엔드 입장에서는 생각보다 쉽지 않은 일일 수 있었겠구나 라는 생각도 하게 되었습니다. 이번 4주차 미션을 끝으로 워밍업 클럽을 완주하게 되었는데, 개인적으로 풀스택을 지향하기에 앞으로 백엔드를 더 공부해 볼 계획이라 이번 경험이 앞으로의 학습에 많은 도움이 될 것 같아요. 이번 스터디의 핵심인 Supabase는 한달 동안 사용해보니 커스터마이징 하고 싶은 부분이 몇가지 있어 Supabase 하나로 기존 백엔드의 역할을 모두 대체하는 것은 한계가 있다고 느껴졌지만, 백엔드를 모르는 프론트엔드 개발자도 손쉽게 하나의 서비스를 온전히 구현할 수 있을 정도로, 쉽고 강력하다는 장점이 크기에 앞으로도 간단한 MVP를 구현해볼 일이 있다면 종종 사용해 볼 것 같아요. 풀스택 3기 러너분들 모두 수고 많으셨습니다!  긴글 읽어주셔서 감사합니다. ☺ 

프론트엔드워밍업클럽3기풀스택Next.jsSupabase

희주

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

학습 내용마지막 주에는 인스타그램 프로젝트 클론코딩과 Vercel, AWS EC2 배포, 도메인 등록 및 연결까지 진행했다. Supabase Auth를 활용해 인증을 다루고, Supabase Realtime에 대해 알아보고 실시간 DM 채팅을 구현했다.Supabase Auth를 이용해 인증방식 기획, 회원가입/로그인/로그아웃 기능 구현회원가입 방식: Confirmation URL / 6-Digit OTP로그인 방식: Email, password카카오 소셜 로그인/회원가입SMTP 서비스랜덤 이미지 API로 유저 프로필 이미지 적용Supabase Realtime으로 채팅 구현 Postgres Changes로 DB 상태 변경 실시간 추적 - message INSERT 이벤트 구독, 실시간 채팅 구현Presence로 유저 실시간 추적 - 온라인 유저 파악, 유저별 마지막 접속시간 표시Supabase RLS(Row Level Security) 설정 - 테이블 접근 권한 설정, Client-Side에서 Supabase 접근Vercel, AWS EC2로 프로젝트 배포도메인 구매 및 등록(Vercel과 연결하기) 미션이제까지 만드신 모든 프로젝트를 배포하신 후 배포된 링크를 업로드 해주세요.강의 내용대로 배포를 진행하니 Vercel로 모두 배포할 수 있었다. Vercel은 Next.js 팀이 만든 플랫폼이라 Next.js 프로젝트를 배포하기에 수월하겠다는 생각이 들었다. 지금까지의 프로젝트는 모두 Github에 올려놓았기에, 이를 연동해 배포할 리포지토리를 고르고 Import를 눌러 환경변수를 입력한 뒤 Deploy하면 끝이었다. Deploy에 앞서 프로젝트 build에 문제가 없는지 체크한 후 진행했다.모두 배포를 완료한 뒤 인스타그램 프로젝트에서 카카오 로그인을 시도하니 리다이렉트 문제를 겪어 처음에 당황했는데 강의 질문 답변을 보고 Supabase Authentication에서 Redirect URLs에 배포 URL를 추가하니 해결되었다. 마무리예상대로 마지막 4주차 내용이 가장 어려워 따라가기 쉽지 않았다. 이전까지는 어떻게든 이해가 됐었는데, Supabase Realtime의 Presence를 이용하는 부분은 처음에 도저히 이해되질 않아서 일단 똑같이 따라만 했던 기억이 난다. 그래도 이렇게 복잡할 수 있는 부분을 쉽게 따라가며 습득할 수 있다는 것에 감사했다😌짧은 시간 동안 다양한 핵심 기술과 유용한 기능 구현도 빠르게 터득할 수 있었고, 덕분에 구현에 대한 자신감도 조금씩 늘어 의미 있는 시간이었던 것 같다. 또 예전에 AWS EC2를 이용해 겨우 배포해보고 배포는 너무 어려운 것이라 느끼고 흥미를 잃었었는데 이번에 전체적인 EC2 배포 과정도 알아가고, Vercel로 프로젝트가 정말 쉽게 배포되는 걸 보면서 ‘이제 뭐든 시도하고 만들어 올려볼 수 있겠다’는 생각이 들었다!개인적으로는 배포만이라도 하는 것이 목표였기에…ㅎㅎ 일단 완주했다는 것에 만족하고 있다. 자유자재로 코드를 바꾸지 못할 때, 발견한 문제를 해결하고 싶어도 방법을 모를 때😂 스스로의 부족함에 고민도 많았던 것 같다. 그럼에도 포기하지 않고 여기까지 올 수 있었던 건 워밍업 클럽에 참여하고 강의를 진행하며 느낀 성취 덕분이라고 생각한다.가끔씩 처음 코딩을 시작했을 때를 떠올리며 몰랐던 성장을 체감하곤 했는데, 이번 워밍업 클럽도 마찬가지로 나중에 돌이켜보면 한 달 동안 내 생각보다 많은 것을 배우고 해냈다고 느끼리라는 생각이 들었다. 이번 학습이 헛되지 않도록 배운 것을 활용한 간단한 프로젝트부터 만들어보고 전진할 생각이다.🙂 많은 도움 되는 시간 만들어주신 강사님과 인프런에 감사드리고, 함께 워밍업에 참여한 분들도 진심 대단하세요! 모두 응원합니다!

풀스택풀스택웹개발Next.jsSupabase

leeebug

워밍업 클럽 스터디 3기 FS - 4주차 발자국

3주차 과제를 일찍 마무리하고 4주차는 조금 일찍 학습을 시작했기 때문인지 유난히도 길었던 마지막 주차도 끝나간다.이번주는 특히나 굉장히 많은 이슈를 경험했다. 결론부터 말하자면 채팅방 생각보다 구현하기 쉽지 않았다. 간단하게 하나만 언급하자면 URL로 장난질 치는 것에 대한 방어로직 구현이 특히 어려웠다. 사실 예전에 firebase 기반으로 미니 SNS를 구현했던 경험이 있는데 이번에 채팅을 구현했으니 이 프로젝트를 그대로 고도화해서 SNS를 완성해볼 계획이다. (실제로 완성할 수 있을지는...)📝 4주차 학습Supabase Auth이메일/비밀번호, OAuth, Magic Link, SMS 인증 등 다양한 인증 방식을 지원하는 인증 서비스supabase.auth.signUp, signInWithOAuth, getUser() 등으로 유저 관리와 세션 제어가 가능JWT 기반으로 RLS와 연동되며, 로그인 상태 자동 유지 및 세션 갱신 기능도 제공Supabase RealtimePostgreSQL의 Listen, Notify 기능을 기반으로 실시간 데이터 동기화 제공테이블 변경(Insert, Update, Delete)을 클라이언트에서 실시간으로 브로드캐스트supabase.chaeenel()로 원하는 이벤트를 구독Supabase RLS(Row Level Security)데이터베이스의 행(Row) 단위로 접근 제어를 설정하는 보안 기능Create Policy를 적용하여 유저별로 조회/ 수정 권한을 세밀하게 조정활성화 시 명시적인 권한 정책 필수아래는 개인적으로 나머지 공부로 학습하고 적용해본 라이브러리입니다.ZodTS 환경에서 런타임 스키마 검증과 타입 추론을 제공하는 유효성 라이브러리z.object() 등 메서드로 구조화된 데이터의 유효성 검사 수행 및 타입 자동 생성서버 및 클라이언트 모두에서 안전한 폼 및 api 검증에 활용React Hook Form과 함께 사용하기 유용한 라이브러리React Tostify토스트 메시지를 손쉽게 띄울 수 있는 라이브러리간단한 API로 다양한 유형의 토스트 알림 제공강력한 커스터마이징 제공Kyfetch API 기반의 모던한 HTTP 클라이언트간결한 문법 제공자동 재시도, 에러 핸들링, 인터셉터 등 확장성과 다수의 편의 기능 포함📋 4주차 미션💬GitHub 저장소👉체험하러 가기 미션 해결 과정 요약이번주 미션의 필수 구현 과제는 Supabas Auth를 사용한 회원가입, 로그인 기능 구현 및 Supabase Realtime을 활용한 1:1 채팅 기능 구현하기였다. 추가 구현 과제는 메시지 삭제, 메시지 알림, 메시지 읽음 여부 표시, 채팅 신고, 유저 차단 기능 등 자유롭게 구현하기였는데 시간 관계 상 전부 구현하긴 어려워서 비교적 쉬운 메시지 삭제와 메시지 알림을 제한적으로 구현했다. 원래는 DB 스키마를 꼼꼼하게 고민하고 시작했어야하는데 급하게 하다보니 처음 계획했던 내용과 많이 달라졌다.myon_users.id -> auth.users.idSupabase Auth로 회원가입된 유저만 등록 가능myon_rooms.userA_id -> myon.users.idmyon_rooms.userB_id -> myon.users.id회원가입된 유저만 채팅에 참여 가능myon_messages.sender_id -> myon_users.id회원가입된 유저만 채팅 전송 가능myon_rooms.last_message_id -> myon_messages.id가장 최근 메시지 미리보기 시 테이블 join에 활용myon_users.username회원가입 시 입력한 닉네임을 기반으로 한글과 특수 문자 등을 제거한 후 중복 발생 시 유틸함수를 통해서 suffix를 불여서 고유한 username을 자동 생성(회원가입 시 입력 폼의 간소화를 위한 선택)✅ 이메일 로그인, 회원가입GET app/auth/signup/callbackPOST api/user/email/register✅ OAuth 로그인, 회원가입GET app/auth/oauth/kakao/callbackPOST api/user/oauth/register우선 회원 기능부터 만들기 시작했는데 강의 패턴을 참고하여 자동 생성되는 auth.users 테이블만 사용하여 회원 로직을 만들었는데 메타 데이터의 형태가 provider 별로 일정하지 않고.. 무엇보다도 auth.users 테이블은 커스텀이 제한적이기 때문에 public.users 테이블을 별도로 관리하였다. 카카오 계정 로그인의 경우 user_metadata를 커스텀 인터페이스로 관리하여 타입 오류를 방지하였다.또한 auth.users 테이블만 단독 사용시의 문제는 회원가입 단계에서 사전에 이메일 중복 검증이 어렵다는 점도 단점이었다.찾아보니 보안상의 이유로 Supabase 내부적으로 auth.users 테이블을 직접 조회하는 기능은 별도로 제공하지 않아서 가입 요청 후에 에러를 캐치할 수 있는 구조이기 때문에 이부분도 public.users를 조회하여 이메일 중복 검증을 통과한 경우에만 회원가입 요청을 할 수 있도록 처리했다.회원가입이나 로그인 인풋 유효성 검증은 Zod + react-hook-form 라이브러리도 대체했다.이메일 회원가입이메일 로그인✅1:1 채팅POST, GET api/rooms/[roomId]POST api/messagesGET api/messages/[roomId]문제는 채팅 기능 구현이었는데 채팅 기능 자체는 Realtime 구독으로 어렵지않게 완성했으나 문제는 방어로직 구현이었다. 몇 가지 예시를 들자면 /direct-message 로 접근 시에 해당 페이지에서 나 자신을 제외한 모든 유저 리스트를 불러온 뒤, /direct-message/:roomId 로 동적 라우트를 구현할 때, 처음에는 고유성을 보장하기 위해서userA_username-userB_username-suffix 형태로 roomId를 생성하는 유틸 함수를 사용했는데 렌더링 시 마다 suffix가 변동되기 때문에 서버단에서 해당 URL이 유효한 URL인지 검증하기가 쉽지 않아서 userA_username과 userB_username을 정렬하여 항상 동일한 roomId를 생성하는 순수 유틸 함수로 변경하여 해당 문제를 해결하였다.export function generateRoomId({ usernameA, usernameB }: { usernameA: string; usernameB: string }) { const sortedUsernames = [usernameA, usernameB].sort() return `${sortedUsernames[0]}-${sortedUsernames[1]}` }과제 추가 구현 기능✅ 메시지 삭제(Soft Delete)PATCH api/message/[messageId]메시지 삭제는 두가지 방식이 있는데 DELETE 메서드를 사용하여 DB Row에서 아예 삭제하는 하드 삭제와 실제 DB Row에서 삭제하지 않지만 is_delete 같은 플래그를 true 하여 클라이언트단에서 감추는 방식인 소프트 삭제 방식이 있다. 하드 삭제의 경우 DB 공간 절약이 필요하거나 탈퇴 회원 정보 등 영구 삭제가 필요한 경우에 적합하고 소프트 삭제의 경우는 복구가 필요하거나 삭제 이력을 추적해야하는 경우에 적합한데 채팅은 로그를 남기는게 중요해서 개인적으로는 소프트 삭제로 구현했다.메시지 호버 시 삭제 아이콘 표시삭제된 메시지✅ 메시지 알림(토스트 메시지 활용)별도 API route 없이 구독으로 구현그냥 마무리하기 아쉬워서 추추가 기능으로 구현했다. 예전부터 토스트 메시지에 관심이 많았는데 직접 구현해보니 생각보다 비효율적이라서 react-tostify 라는 라이브러리를 적용했다.토스트 메시지는 스크롤이 최하단이 아닌 지난 메시지를 읽고 있을때만 우측 상단에 스택 형태로 알림을 보내도록 구현했다. 👀 4주차 회고이번 주는 지난 스터디 기간 동안 진행했던 프로젝트를 배포하는 과정이 포함되어 있었기 때문에, 추가적인 기능보다는 안정적인 배포에 중점을 둘 계획이었으나.. 다행히도 지난주에 미리 매를 맞아두었기 때문에 이번 주 과제 배포는 크게 문제 없이 마무리할 수 있었다.다만 실제 배포 경험이 많지 않다 보니 환경 변수 관련 이슈를 자주 겪게 되었고, OAuth Redirect URL 설정 누락, 빌드 시 타입 오류 등의 경험으로 배포 시 고려해야 할 요소들을 더 잘 이해하게 되었다. 앞으로는 다른 프로젝트 배포 시 참고할 수 있도록 트러블슈팅 내역을 꼼꼼하게 정리하는 습관을 들일 계획이다.이번주에 과제를 진행하면서 딱 한가지 아쉬웠던 점이라면 2주차부터 꾸준히 적용해오던 Container-Presentational Component 패턴을 이번에는 적용시키지 못했다는 점이다. 이번주 과제가 전반적으로 복잡도가 높다보니 관심사 분리를 코드에 녹여내지 못했으나 점진적으로 리팩토링을 통해서 개선해나가기 위해서 백로그에 기록해두었다. 스터디 이후..이번에 구현한 채팅 기능은 실제 배포를 해보니 전송 시 약간의 딜레이가 발생하는 것을 발견했다. 지금 타이밍에서 메시지 전송에 대한 낙관적 업데이트를 적용해서 최적화하는것이 가장 시급한 과제라고 생각한다.끝으로 이번 프로젝트는 이후에 포트폴리오로 활용할 수 있도록 고도화 작업을 이어갈 생각이며, 동시에 타입스크립트에 대한 이해를 더 심화시키고, 쉽진 않겠지만 최근 관심이 생긴 테스트 코드 작성 관련 학습도 병행해나가 보려 한다.정말 마지막으로.. 풀스택 과정을 포함한 모든 3기 스터디 러너분들, 멘토님들과 서포터분들, 워밍업 클럽 관계자 여러분들 모두 고생하셨습니다👏 여러분들 덕분에 좋은 인사이트 얻어갑니다 :)  

풀스택워밍업클럽3기풀스택Next.js4주차회고미션

찬우 이

인프런 워밍업 클럽 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기 - BE/Project] 4주차 회고 발자국 🐾

마지막까지 KPT 회고 프레임워크를 선택해서 작성해보려고 한다! Keep(만족, 지속하고 싶은 부분)마지막 4주차는 개인프로젝트는 잠시 stop하고 강의와 관련된 부분을 이어 나갔다.Kotlin, Springboot, MySQL, JPA, Docker/compose, Thymeleaf, Bootstrap으로 개발하고 GCP에 Docker로 배포하고 Domain을 구입하고, certbot nginx로 인증서를 발급받아서 https까지 적용했다.2주 쫌 안되게 걸렸는데, 신기했던 부분이 몇 가지 있었다. 첫번째, 코틀린은 언어의 특성으로 인해 주 생성자 부분에 인자를 나열하는 스타일, 주 생성자에는 파라미터만 받고 { } 본문에 val/var등을 붙여 프로퍼티를 만드는 방식, 주 생성자에는 최소한의 인자를 받고 부 생성자로 처리하는 스타일, 주 생성자의 호출을 private으로 막고 정적 팩토리 메서드 패턴을 사용하는 방식 등 다양한 스타일이 나올 수 있는데 지식공유자님은 이 중 어떤 하나의 스타일을 선호하셨다..!! ㅎ내가 말한 것들을 예로 들면 아래와 같은 코드이다.// 스타일 1: 주 생성자 부분에 인자를 나열하는 스타일 class Person1(val name: String, val age: Int) // 스타일 2: 주 생성자에는 파라미터만 받고, 본문에서 프로퍼티를 생성하는 방식 class Person2(name: String, age: Int) { val name = name var age = age } // 스타일 3: 주 생성자에 최소한의 인자를 받고, 부 생성자로 처리하는 스타일 class Person3(val name: String) { var age: Int = 0 constructor(name: String, age: Int) : this(name) { this.age = age } } // 스타일 4: 주 생성자의 호출을 private으로 막고, 정적 팩토리 메서드 패턴을 사용하는 방식 class Person4 private constructor(val name: String, val age: Int) { companion object { fun create(name: String, age: Int): Person4 { return Person4(name, age) } } }참고로 프로퍼티(주생성자에 붙이던, 클래스 바디부분에 붙이던)에 private을 붙인다면 딱 생성자에만 사용할 수 있고 어떠한 getter나 setter로 인한 조작을 제공받을 수 없게된다(코틀린을 써본 사람이 있다면 이걸로 검증할 수 있을 것 같다ㅎㅎ) 두번째, 테스트코드의 클래스 최상단에는 DisplayName을 명시하지 않고 테스트함수(메서드)에만 붙여왔었는데, 강의에서는 최상단의 클래스에도 붙이니 depth가 생겨서 읽기 좋았다. 세번째, 실제 쿼리를 처리하는 JPA또는 CRUD Repository들은 domain 레이어에 두고, 이 repository들을 따로 모아서 presentation/repository 패키지에서 PresentationRepository로 사용하신 부분이었다. 일종의 Facade로 사용하신 것 같은데 강의에서는 처음 봤다. 그리고 이와 관련해서 Spring에는 Repository로 메타데이터를 표시하면(어노테이션), 그 내부에서 호출하는 예외를 DataAccessException으로 Wrapping해준다. 그래서 계속 @Repository를 사용 안하시길래 의아했었는데, Presentation Layer쪽에서 여러 레포지토리들을 사용하는 부분에서 마킹해준걸 보고 오호~! 했다. 네번째, 나는 회사에서 application-{환경}-example.yaml 형태는 github에 올리고 example이 안붙은거는 gitignore에 추가하는 방식으로 사용한다던지.. 아니면 아예 application관련 파일들은 다 gitignore에 추가하고 AWS, GCP 등의 터미널에서 복사해와서 사용한다던지, 외부 환경변수도 그냥 export를 사용한 linux방식으로 외부에서 주입한다던지 요런 방법들만 써왔었고 다른 사람들의 프로젝트에서 우연히 jasypt(JAva Simplified encrYPTion)을 사용한 걸 보긴 했는데 이번 강의에서 처음 써봐서 신기했다. 하지만 현재 프로젝트를 시간이 지나서 관리를 안한다던가, 과거의 것이 되어버린다면 plainPW를 까먹을수도 있으니 어딘가에 기억을 해두고 관리를 해야한다는 점은 있었다. 마지막으로, 거의 끝에 Docker로 빌드하고 Docker-Compose로 사용하는 부분에서 CLI보다는 UI 위주로 알려주는게 좋았다. 개인적으로 나는 docker buildx build --platform linux/amd64,linux/arm64 -t ... 를 사용하는 CLI 방식을 썼었는데 강의에서는 builx를 사용하는 것은 아니었지만, IntelliJ의 UI를 사용하는.. 정확히는 Run Configuration의 IntelliJ Docker 관련 기능을 사용하는 것이 신기했다. 도커 빌드가 실행되기전에 gradle task 2개를 추가하고, 태그를 붙이고 build option을 리눅스에서 돌아가는 환경으로 쫙 쓸 수 있는게 좋았다.나도 전회사에서 프론트엔드분이 도커나 shell script에 대해서 모르셨는데.. 음 WebStorm인가 IntelliJ를 쓰셨는데.. 프론트가 켜지기 전에(npm) 쉘스크립트와 도커컴포즈가 실행되게 Before launch에 Task를 추가해서 프론트엔드분은 프론트 개발에만 치중할 수 있게 셋업해드린 경험이 있었는데 도커로는 처음 써봐서 좋았다.그리고 Docker나 Docker Compose나 Docker Desktop으로 보여줘서 좋았다. 개인적으로는 CLI도 정말 잘써야하지만 그것을 잘 쓸 수 있게 해주는 UI 툴도 잘 사용할 줄 알아야 좀 더 손이 빠른 개발자가 된다고 생각한다. 물론 git/github CLI도 모르면서 UI로만 잘하면 좀 가오떨어지긴하니깐 둘 다 쓸 줄 알아야한다고도 생각한다.UIUI를 사용한 빌드결국 완성한 귀여운 프로젝트 ㅎㅎ 궁금하신 분들은 https://boki-dev.com/로 접속하면 볼 수 있다.메인 화면 Problem(부족, 아쉬웠던 부분)목요일에 어머니가 병원에 입원하셔서 다음날인 금요일에 수술하셨고, 쭉 어머니를 돌보느라 2차 중간 점검 라이브때 참여를 못했다 ㅠㅠ.강의에서 아쉬웠던 부분은.. 음 많은 데이터를 추가한 테이블에서 Pagination이 필요한 경우에는 fetch join으로만으로는 해결이 안될텐데 JPA를 사용하면서 그 부분에 대해서도 있으면 좋았을 것 같다.그 외에는 지식공유자님의 첫 강의였던만큼 부족한 부분이 조금씩은 누구나 있을 것이라고 생각한다. 그리고 나는 약간 젊꼰(젊은꼰대)에 속해서 Backend 영역 즉, Spring을 깊게 공부하고 싶은 사람이라면(내 생각)1. CS & Java Language(언어 학습)2. Java Application Project(프로젝트 실습) - 순수 Java프로젝트, JDBC정도3. JVM, GC 공부4. Spring(프레임워크 학습)5. Spring boot(프레임워크 학습2)6. Springboot Project(프로젝트 실습)7. Kotlin(언어 학습)8. Kotlin/Springboot Project(최종)이렇게 가야지 자신의 실력이나 깊이에 대해서 좌절 또는 현타가 좀 덜 오는 현업에서 부족함 없는 실력자 될 수 있지 않을까 생각하는데..얕게 알고 취업하면 언젠가는 자신의 개발실력에 헌타가 오기도 하고 그러더라..주위에서 보기도 했고 나도 조금은 겪었었다(나도 알고싶지 않았다). 근데 이렇게 말하면 꼰대소리 들으며 왜 코프링(코틀린스프링)부터 시작하면 안되냐고 반문할 지도 모르겠다..ㅎㅎ Try(도전할 부분)강의를 보고 만든 프로젝트는 완성했으니, 기존의 내 개인 프로젝트로 돌아가서 계속 살과 뼈를 붙여가며 완성해보고 싶다.백엔드도 첨에 사이즈를 크게 잡아서.. Redis도 RedisTemplate, Spring Annotation 방식(@Cacheable..), Repository(@RedisHash) 방식들 차이에 대해서 알아보고 더 잘 사용하고, Kotlin에 좀 더 친화적인 Kotlin JDSL도 사용해보고 활성유저의 트래킹이나, 매니저->작업자의 Task 할당에 MQ를 사용한다던지 기능추가를 하며 고도화를 해보고 싶다.프론트쪽은 현업에서 Angular/Vue를 주로 썼었는데, React/Ts 강의도 들어서 프론트엔드 부분은 CRA로 만들 것이다.

풀스택인프런워밍업클럽백엔드프로젝트KotlinSpringbootJPA스터디워밍업클럽스터디

hee j

[인프런 워밍업 클럽 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를 사용할 수 있게 해준 것처럼 <RecoilRoot>가 있음하지만 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할 엔티티에 대해 레퍼런스를 넣기 위한 값<div ref={ref}> {inView && 'Element is in view!'} </div> ** 우리가 왜 이것을 사용해야 하나?스크롤 맨 아랫부분에 보이지 않는 태그를 넣어서 해당 태그가 보이면 다음 페이지를 가져올 수 있도록 함수를 만들 예정즉, 페이징을 커서 방식으로 개발[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 ( <div className="col-span-1 relative"> {isFavorit ? ( <button onClick={handleClick} className={`absolute top-2 right-2 z-20 fa-solid fa-heart text-red-500 text-3xl`} /> ) : ( <button onClick={handleClick} className={`absolute top-2 right-2 z-20 fa-regular fa-heart text-red-500 text-3xl`} /> )} {/* image */} <div> <img src={movie.image_url} className="w-full" /> <Link href={`/movies/${movie.id}`}> <div className="absolute flex items-center justify-center top-0 bottom-0 left-0 right-0 z-10 bg-black opacity-0 hover:opacity-80 transition-opacity duration-300"> <p className="text-xl font-bold text-white">{movie.title}</p> </div> </Link> </div> </div> ); }아이디와 상태 값을 가져와 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 ( <div className="col-span-1 relative"> <button className={`absolute top-2 right-2 z-20 ${ movie.favorit ? "fa-solid fa-heart" : "fa-regular fa-heart" } text-red-500 text-3xl`} /> <div> <img src={movie.image_url} className="w-full" /> <Link href={...}> <div className="absolute flex items-center justify-center top-0 bottom-0 left-0 right-0 z-10 bg-black opacity-0 hover:opacity-80 transition-opacity duration-300"> <p className="text-xl font-bold text-white">{movie.title}</p> </div> </Link> </div> </div> ); } 

풀스택풀스택워밍업

희주

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

학습 내용이번 주에는 Netflix 프로젝트 클론코딩을 진행했다. 영화 목록 페이지 및 검색 기능과 개별 영화 상세페이지를 구현했다.supabase에서 영화 테이블을 만들고, 준비된 csv 파일로 바로 영화 데이터를 추가header에서 입력한 검색어를 다른 컴포넌트에서 사용하기 위해 Recoil 사용dynamic routing으로 개별 영화 상세페이지 구현영화 목록을 보여줄 때 무한스크롤 적용react-intersection-observer 라이브러리를 사용해, 보이지 않는 태그를 심어놓고 감지react-query의 useQuery 대신 무한스크롤을 쉽게 구현할 수 있는 useInfiniteQuery 이용상세페이지의 동적 meta tag 생성을 위해 generateMetadata() 사용해 SEO 작업 미션Netflix Clone 프로젝트에 “찜하기” 기능을 추가하세요.공용 즐겨찾기 기능을 구현하고, 찜한 영화를 리스트 최상단에 보이도록 정렬했다. 찜한 영화 리스트를 가져와서 별도의 찜 목록 페이지에서 보여주는 방법도 고려해볼 수 있을 것 같다.movie 테이블에 bool 타입의 bookmarked column을 추가하고 초기값은 FALSE로 설정searchMovies()에서 order()로 정렬 기준을 추가해 찜한 영화부터 보이도록 하고 다중 정렬하여 찜한 영화 중에서도 id 순서대로 정렬되도록 작성 const { data, error } = await supabase .from("movie") .select("*") .like("title", `%${search}%`) .order("bookmarked", { ascending: false }) .order("id", { ascending: true }) .range((page - 1) * pageSize, page * pageSize - 1); 북마크 버튼을 누르면 북마크 상태가 반대로 바뀔 수 있도록 Server Action 작성export async function updateBookmark(id, status) { const supabase = await createServerSupabaseClient(); const { error } = await supabase .from("movie") .update({ bookmarked: !status }) // 현재 상태 반대로 변경 .eq("id", id); handleError(error); return !status; } movie-card에서 북마크 mutation을 작성해 updateBookmark()를 실행하고 성공하면 화면이 바로 업데이트되도록 함const updateBookmarkMutation = useMutation({ mutationFn: () => updateBookmark(movie.id, movie.bookmarked), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["movie"] }); }, }); bookmarked 값에 따라 북마크 icon을 다르게 표시하고,북마크 클릭 시 mutation을 실행해 해당 영화의 bookmarked 값을 반대로 바꿔줌return ( ... {/* Bookmark 부분 */} <i onClick={() => updateBookmarkMutation.mutate()} className={`text-yellow-600 drop-shadow-md absolute flex items-center justify-center p-2 text-2xl top-2 right-2 z-20 ${ movie.bookmarked ? "fa-solid fa-bookmark" : "fa-regular fa-bookmark" }`} ></i> ... )  마무리이번 주에 배운 기능 중에는 이미 한두 번 다뤄본 내용들도 있었는데, 특히 useInfiniteQuery를 이용해 무한스크롤을 더 편리하게 적용해볼 수 있었던 것 같다(그래도 어려웠다…😅). 중간에 막히는 부분도 있었지만, 자세한 사용법을 찾아보고 강의를 따라가며 무한스크롤 구현 과정을 되짚어볼 수 있었다. Next.js가 알아서 해주는 동적 메타데이터 생성으로 간편하게 SEO 적용하는 방법도 알아갈 수 있었다. 현재 아쉬운 점 중 하나는 찜하기 기능을 적용했을 때 비교적 아래쪽의 영화를 찜하니 모든 영화 데이터를 다시 요청해오느라 북마크 표시가 늦게 반영된다는 것인데, 이번 경험으로 invalidateQueries() 대신 setQueryData() 활용을 고려하는 등 네트워크 요청 최적화의 필요성을 느꼈다. 마지막으로 다음 주가 가장 어려운 부분이 될 것 같지만, 끝까지 완주하는 것을 목표로 최선을 다해보려 한다!

풀스택풀스택웹개발Next.jsSupabase

찬우 이

인프런 워밍업 클럽 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

LC-02s

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

학습 내용 요약인프런 워밍업 클럽 3기 풀스택 스터디 3주차에는 Supabase Database를 활용하여 페이지네이션을 다루는 방법을 학습하였습니다. 인프런에서 발자국을 작성할 때 강의를 보지 않고도 강의 내용을 파악할 수 있을 만큼 자세한 내용을 작성하는 건 지양해 달라고 가이드한 만큼 강의에 대한 필기는 최소화 하고 회고 위주로 적어보겠습니다. Supabase Client Query Builderlet { data: movie, error } = await supabase .from('movie') // 'movie' 테이블에서 데이터를 가져옴 .select("*") // 모든 컬럼을 선택 // 필터 조건 (WHERE 절과 유사) .eq('column', 'Equal to') // column이 'Equal to' 값과 같은 경우 .gt('column', 'Greater than') // column이 지정 값보다 큰 경우 (>) .lt('column', 'Less than') // column이 지정 값보다 작은 경우 (<) .gte('column', 'Greater than or equal to') // column이 지정 값보다 크거나 같은 경우 (≥) .lte('column', 'Less than or equal to') // column이 지정 값보다 작거나 같은 경우 (≤) .like('column', '%CaseSensitive%') // column이 특정 패턴과 일치하는 경우 (대소문자 구분, LIKE '%...%') .ilike('column', '%CaseInsensitive%') // column이 특정 패턴과 일치하는 경우 (대소문자 구분 없음, ILIKE '%...%') .is('column', null) // column 값이 NULL인 경우 .in('column', ['Array', 'Values']) // column이 지정된 배열 값들 중 하나와 일치하는 경우 (IN 연산자) .neq('column', 'Not equal to') // column이 지정된 값과 다른 경우 (!=) // 배열 관련 필터 .contains('array_column', ['array', 'contains']) // array_column이 주어진 배열 요소를 모두 포함하는 경우 .containedBy('array_column', ['contained', 'by']) // array_column이 지정된 배열에 완전히 포함되는 경우 // 논리 연산자 .not('column', 'like', 'Negate filter') // column이 'like' 조건을 만족하지 않는 경우 (NOT) .or('some_column.eq.Some value, other_column.eq.Other value') // OR 연산자: some_column이 'Some value'이거나 other_column이 'Other value'인 경우 Supabase에서 Text와 Varchar 이해하기요약두 유형 모두 문자열을 저장하는 목적으로 사용됨저장 방식과 성능에 대한 차이가 있지만, Supabase는 단순성을 강조하므로, 특별한 이유가 없다면 기본적으로 text를 사용하는 것이 권장됨 Text긴 문자열을 저장하는 데 사용됨문자 개수에 대한 제한이 없으며, 길이를 예측하기 어려운 문자열에 적합함VarcharVariable Character Length(가변 길이 문자)의 약어최대 길이를 설정할 수 있음 → 데이터 일관성을 유지하는 데 유용하며, 특정 쿼리에서 성능이 약간 향상될 수 있음  페이지네이션 구현 방식: Offset vs CursorOffset Based Pagination 동작 방식OFFSET과 LIMIT을 사용해 특정 범위의 데이터를 가져옴장점특정 페이지로 바로 이동 가능 (예: 1페이지, 5페이지 등)직관적이고 구현이 간단함단점데이터가 많아질수록 OFFSET 성능 저하 (큰 OFFSET 값일수록 느려짐)데이터가 변경되면 순서가 달라질 수 있어 불안정함Cursor Based Pagination동작 방식마지막 항목의 특정 필드(예: created_at 또는 id)를 커서로 사용해 이후 데이터를 가져옴 장점성능이 우수함 (특히 큰 데이터셋에서 OFFSET 사용 없이 빠르게 조회 가능).데이터가 변경되더라도 안정적인 페이지네이션이 가능함.단점특정 페이지로 바로 이동이 어렵고, 이전 페이지로 돌아가는 것이 복잡할 수 있음.구현이 상대적으로 복잡함.  Netflix 클론 미션 회고풀스택 스터디 3주차 미션은 강의에서 진행하는 Next.js와 Supabase Database를 활용한 Netflix 클론 앱에 찜 기능을 추가하는 것이었습니다. 진행했던 미션은 해당 링크에서 보실 수 있습니다. 미션 수행 내용아래는 제가 수행한 미션에 대한 내용을 정리해보았습니다. 영화 목록 조회 기능찜 리스트 조회 기능키워드 검색 기능무한 스크롤 지원영화 상세 정보 조회 기능동적 메타데이터 지원SSR 지원영화 찜하기 기능낙관적 업데이트 지원  사용 기술프레임워크: Next.js v15, React v19데이터베이스: Supabase서버 상태관리: Tanstack Query v5클라이언트 상태관리: Zustand v5스타일 프레임워크: TailWindCSS v3, Mantine v7, Tabler Icons모노레포: Turbo Repo패키지 매니저: pnpm 페이지네이션강의에서는 옵셋 기반으로 페이지네이션을 구현하였지만, 저는 커서 기반 페이지네이션이 데이터 추가 또는 삭제 시 페이지 별 인덱스가 꼬여 다른 페이지에 같은 데이터가 존재하거나 데이터를 건너뛸 수 있는 문제가 없기에 무한스크롤 기능에 조금 더 적합하다고 판단하여 커서 기반으로 페이지네이션을 구현하였습니다.export interface SearchMoviesParams { keyword?: string cursor?: number | null size?: number like?: boolean } export interface SearchMovies { (params: SearchMoviesParams): Promise<{ data: Movie[] nextCursor: number | null first: boolean last: boolean }> } export const searchMovies: SearchMovies = async ({ cursor = null, keyword = '', like = false, size = 12, }: SearchMoviesParams) => { const client = await createServerSupabaseClient() const first = !cursor let query = client.from('movie').select('*').order('id', { ascending: true }) if (keyword) { query = query.ilike('title', `%${keyword}%`) } if (like === true) { query = query.eq('is_like', true) } if (cursor) { query = query.gt('id', cursor) } const { data, error } = await query.limit(size + 1) if (error) { console.log(error) return { data: [], nextCursor: null, first, last: true, error } } const hasNextPage = data.length > size const nextCursor = hasNextPage ? (data[size - 1]?.id ?? null) : null return { data: ( data?.map((movie) => ({ id: movie.id, title: movie.title, imageURL: movie.image_url, overview: movie.overview, popularity: movie.popularity, releaseDate: movie.release_date, voteAverage: movie.vote_average, isLike: movie.is_like, })) ?? [] ).slice(0, size), nextCursor, first, last: !hasNextPage, } }  찜 기능 테이블 스키마아래는 3주차 미션인 찜 기능을 구현할 때 작성한 movie 테이블 스키마입니다.CREATE TABLE movie ( id SERIAL PRIMARY KEY, image_url TEXT NOT NULL, title TEXT NOT NULL, overview TEXT NOT NULL, vote_average FLOAT8 NOT NULL, popularity FLOAT8 NOT NULL, release_date DATE NOT NULL, is_like BOOLEAN NOT NULL DEFAULT FALSE );해당 프로젝트에서는 별도로 회원 관리를 하지 않기 때문에 간단하게 컬럼을 하나 추가하여 구현하였지만, 만약 회원이 존재하는 상황이라면 테이블을 따로 분리한 후 회원 id와 영화 id를 받아와서 왜래키(FK)로 관리하는 방식도 괜찮았을 것 같습니다.  후기이번 주차에는 백엔드의 대표적인 작업 중 하나인 페이지네이션을 학습해 볼 수 있었습니다. 강의와는 다르게 Supabase를 사용한 커서 기반 페이지네이션을 구현해 보았는데, 공식 문서를 읽는 것이 쉽지 않았던 것 같습니다. 이전 주차들에서도 동일하게 Supabase 클라이언트에서 제공하는 쿼리 빌더 메서드들이 무슨 역할을 하는지 정리 해야겠다고 생각했는데 이번 주차에 드디어 하게 되었네요. 이제 다음 주차 미션인 인스타그램 클론까지 학습하면 간단한 MVP는 혼자서 구현해 볼 수 있을 것 같아서 기대됩니다. 긴글 읽어주셔서 감사합니다. ☺    

프론트엔드워밍업클럽3기풀스택Next.jsSupabase

치현

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

학습 내용인프런 워밍업 클럽 스터디 3주차로,이번 주는 넷플릭스 프로젝트를 다루는 시간이었다.useInfiniteQuery와 Jotai(recoil 대체 전역상태 라이브러리)를 사용해볼 수 있었다.미션 3 구현 내용과제 구현 저장소Netflix 중 찜하기 관련 기능 포인트 1: favorites 테이블 추가actions/favoriteActions.ts"use server"; import { createServerSupabaseClient, handleError, PostgrestError, } from "@next-inflearn/supabase"; // Movie 타입 정의 export type Movie = { id: number; image_url: string; overview: string; popularity: number; release_date: string; title: string; vote_average: number; // Movie 타입에 favorites 필드 추가 favorites?: { // optional field로 추가 id: number; } | null; }; // SearchMoviesResponse 타입 정의 export type SearchMoviesResponse = { data: Movie[]; page: number; pageSize: number; hasNextPage: boolean; }; // 에러 케이스를 위한 타입 정의 type SearchMoviesError = { data: never[]; count: number; page: null; pageSize: null; error: PostgrestError; }; // 성공 케이스를 위한 타입 정의 type SearchMoviesSuccess = { data: Movie[]; page: number; pageSize: number; hasNextPage: boolean; }; export async function searchMovies({ search, page, pageSize, }: { search: string; page: number; pageSize: number; }): Promise<SearchMoviesSuccess> { const supabase = await createServerSupabaseClient(); // 현재 사용자 정보 가져오기 const { data: { user }, } = await supabase.auth.getUser(); // 쿼리 설정 const query = supabase .from("movie") .select( user ? ` *, favorites!left ( id ) ` : "*", // 로그인하지 않은 경우 favorites 정보를 가져오지 않음 { count: "exact" } ) .ilike("title", `%${search}%`) .order("popularity", { ascending: false }); // 로그인한 경우 현재 사용자의 즐겨찾기만 조회하도록 필터링 if (user) { query.eq("favorites.user_id", user.id); } const { data, count, error } = await query.range( (page - 1) * pageSize, page * pageSize - 1 ); const hasNextPage = count ? count > page * pageSize : false; if (error) { return { data: [], page, pageSize, hasNextPage: false, }; } // 반환된 데이터를 Movie 타입에 맞게 변환 const moviesWithFavorites = (data || []).map((movie: any) => ({ ...movie, favorites: movie.favorites?.[0] || null, // 즐겨찾기 정보 포함 })); return { data: moviesWithFavorites, page, pageSize, hasNextPage, }; } export async function getMovie(id: string) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("movie") .select("*") .eq("id", id) .maybeSingle(); handleError(error); return data; } 포인트 2: profiles 테이블 추가utils/AuthProvider.tsx"use client"; import { useEffect } from "react"; import { createBrowserSupabaseClient } from "@next-inflearn/supabase"; import { useSetAtom } from "jotai"; import { userAtom } from "@/utils/jotai/atoms"; export function AuthProvider({ children }: { children: React.ReactNode }) { const setUser = useSetAtom(userAtom); const supabase = createBrowserSupabaseClient(); useEffect(() => { // 현재 세션 확인 supabase.auth.getSession().then(({ data: { session } }) => { setUser(session?.user ?? null); }); // Auth 상태 변경 구독 const { data: { subscription }, } = supabase.auth.onAuthStateChange((_event, session) => { setUser(session?.user ?? null); }); return () => subscription.unsubscribe(); }, [supabase, setUser]); return children; } 포인트 3: movies / favorites / profiles 테이블 연결favorites 테이블 내 profiles 테이블 내 회고 시간이 부족하다는 핑계로,더 디벨롭할 수 있는 부분이 많음에도 불구하고 생각했던 것들을 다 구현하진 못했던 것 같다.다만, 이렇게 틀을 갖춰놧으니 추후에 추가적인 기능을 정의해서 구현해보기 너무 좋을 것 같다.  

풀스택풀스택인프런워밍업스터디클럽Next3기SupabaseReact프론트엔드3주차발자국

codestudy

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

Netflix 클론코딩 - 영화 검색 서비스 1. 프로젝트 기본 설정 및 구조기술 스택Next.js 기반 프레임워크React 컴포넌트 시스템TypeScript 타입 정의Tailwind CSS 스타일링Supabase 데이터베이스TMDB API (영화 데이터 소스)프로젝트 구조config, app, components, utils 폴더 구성페이지 라우팅 설정환경 변수(.env) 구성2. 데이터베이스 구축Supabase 설정TMDB에서 가져온 60개의 영화 데이터 활용CSV 파일 업로드 방식으로 데이터 임포트영화 테이블 스키마 구성:제목, 설명, 이미지 URL, 평점, 인기도, 개봉일 등데이터 타입 설정 (float, string, nullable 등)3. UI 컴포넌트 개발헤더 컴포넌트상단 고정 네비게이션 바로고 및 네비게이션 메뉴 (Movies, Dramas)검색 기능 UI 구현푸터 컴포넌트하단 고정 레이아웃저작권 및 출처 정보 표시영화 카드 컴포넌트그리드 시스템으로 반응형 레이아웃 구현MD 사이즈에서 4개, 기본 사이즈에서 3개 카드 표시호버 효과 및 트랜지션 적용영화 상세 페이지동적 라우팅을 통한 개별 영화 페이지영화 포스터, 제목, 설명, 평점, 인기도, 개봉일 표시4. 데이터 관리 및 API 연동React Query 활용useQuery hook으로 데이터 페칭 및 캐싱로딩 상태 및 에러 처리Recoil 상태 관리atom을 활용한 검색어 상태 관리useRecoilState, useRecoilValue 등의 훅 활용Supabase API 연동영화 목록 조회 (getMovies)개별 영화 상세 정보 조회 (getMovie)검색 기능 구현 (SearchMovies)5. 고급 기능 구현무한 스크롤React Intersection Observer 활용useInfiniteQuery로 페이지네이션 구현Range Query를 통한 효율적인 데이터 로딩페이지 단위로 12개 항목씩 로드검색 기능실시간 검색어 상태 관리Supabase LIKE 쿼리를 활용한 검색 기능검색 결과 렌더링데이터 최적화flatten을 통한 배열 데이터 처리페이지 상태 관리 (hasNextPage, isFetching 등)6. SEO 최적화메타데이터 관리generateMetadata 함수 구현동적 메타데이터 생성영화 제목, 설명, 이미지 정보 포함소셜 미디어 공유 최적화오픈 그래프 태그 추가트위터 카드 설정카카오톡 공유 시 이미지와 설명 표시 최적화7. 학습 회고이 Netflix 클론 프로젝트를 통해 Next.js의 App Router와 TypeScript, Supabase를 결합한 풀스택 개발을 경험했습니다. 컴포넌트 아키텍처: 서버 컴포넌트와 클라이언트 컴포넌트를 적절히 분리하는 Next.js의 패턴을 익힘데이터 페칭 최적화: React Query를 활용한 효율적인 데이터 관리와 무한 스크롤 구현 방법 서버리스 백엔드: Supabase를 활용한 데이터베이스 관리와 서버 액션으로 별도 백엔드 없이 기능 구현복습을 꾸준히 하고 supabase의 다양한 기능을 습득하도록 해야겠습니다.  🛠 미션 해결 과정찜 기능 구현 과정 및 회고구현 과정Supabase 테이블 설계:favorites 테이블 생성: id, movie_id, device_id, created_at 필드 포함Foreign key는 설정하지 않고 간단한 구조로 진행클라이언트-사이드 식별자 구현:deviceId.ts 유틸리티 생성: 사용자 브라우저를 식별하기 위한 고유 ID 생성 및 로컬 스토리지에 저장로그인 없이도 각 사용자의 즐겨찾기를 식별할 수 있는 방법 제공서버 액션 구현:favoriteActions.ts: 즐겨찾기 추가/제거 및 목록 조회 기능 구현searchMovies 함수 수정: 즐겨찾기 정보 포함 및 즐겨찾기 항목 상단 정렬 로직 추가UI 컴포넌트 수정:MovieCard 컴포넌트에 하트 아이콘 추가: Font Awesome과 Material Tailwind 사용찜 상태에 따라 아이콘 스타일 변경 (빨간색/회색)클릭 이벤트 처리 및 비동기 동작 구현타입 관리:기본 Movie 타입 확장하여 MovieWithFavorite 인터페이스 정의TypeScript 타입 오류 해결을 위한 전략 수립미션 해결 회고로그인 없이 사용자별 데이터를 관리하기 위해 기기 식별자를 활용한 접근법을 알게되어 활용해보았습니다. Supabase로 찜한 정보를 저장하고 관리하는 과정에서 서버리스 데이터베이스의 강력함을 경험했습니다.타입스크립트의 인터페이스 확장을 통해 is_favorite 속성을 추가하며 타입 시스템의 중요성에 대해 또 느꼈습니다.원하는 기능이 있을때마다 supabase에 테이블을 추가하여 활용하는 것이 신기했습니다.

풀스택인프런워밍업풀스택supabase

이수진

[인프런 워밍업 클럽 Full-Stack 3기] 3주차 발자국 - Netflix 클론코딩

이번주는 Netflix 클론코딩에 관한 내용이었다. Netflix 클론코딩이라던지 무한스크롤 구현이라던지 하는 내용은 이전에도 다른 강의에서 자주 나왔던 항목이어서 그런지 들을 때 좀 가볍게 들었던 것 같다. 다음 주에 있는 인스타그램 클론코딩의 강의+과제 볼륨이 거의 2주치가 되어서 이번주는 쉬어가는 듯 가볍게 듣고 다음 챕터 강의에 집중하려했다.이번 강의의 핵심은 supabase table에서 데이터 가져오기, 무한 스크롤 구현, SEO 최적화하기였다. 수강 내용Section 5. Netflix 클론코딩 - 영화검색 서비스 제작하기이번 챕터에선 Netflix 클론코딩을했다. 어느 때와 마찬가지로 tmdb에서 데이터를 가져와 영화를 뿌려주고, 해당 영화에 대한 정보를 보여주는 것이었다. 기술 스택은 지난번과 마찬가지로 Next.js, tailwind css를 기반으로했고 이번 프로젝트에서는 특히 상태관리가 필요해 zustand 라이브러리를 선택했다. 강의에서는 recoil을 사용했지만 리코일의경우 Next.js 15버전과 호환이 잘 되지 않기도 하고 평소에 가벼운 zustand를 자주 사용해서 해당 라이브러리로 상태관리를 진행했다.영화 목록 전체 불러오기우선 영화 데이터는 강의에서 준비해서 supabase 테이블에 모두 넣고 해당 테이블의 데이터들을 모두 불러오는 코드를 작성했다. const { data, error } = await supabase .from("movie") .select("*")검색 기능 추가하기위에서 말한 상태관리 라이브러리는 이 검색 기능을 활용하기 위해 사용한다. Header 컴포넌트에 있는 SearchInput 에서 검색을 하면 다른 곳에서도 해당 검색어를 사용하기 위해 검색어를 전역 변수로 지정했다.const { data, error } = await supabase .from("movie") .select("*") .like("title", `%${search}%`)search 키워드를 받아와 supabase table 내 title 컬럼에서 search 키워드가 포함된 항목들을 검색한다. 보면서 느끼는건데 확실히 SQL을 알고있으면 이런 키워드를 이해하는 데 좀 더 쉬운거같단 생각이 든다. table이라 그런지 다 SQL문법 사용하네.. 아무튼 이렇게 하면 supabase table에서도 검색어를 손쉽게 찾을 수 있다.무한 스크롤 구현무한 스크롤 구현하는 방법은 매우 다양하다. intersection observer을 사용한다던지.. 이번 강의에서는 가볍게 tanstack-query와 react-intersection-observer 라이브러리를 통해 무한 스크롤을 구현했다.const { ref, inView } = useInView({ threshold: 0 });위는 react-intersection-observer에서 사용하는 hooks이다.ref : 참조할 요소를 지정한다. inView : 요소를 불러와야 할 경우를 true false로 판별한다.threshold : 얼만큼 겹쳤을 경우 inView를 변경할 지 설정한다.이를 통해 데이터를 불러오는 곳 하단에 <div ref={ref} /> 을 작성하면 하단에 닿았을 경우의 트리거가 완성된다. 그리고 그 다음에는 tanstack-query에 있는 useInfiniteQuery를 사용한다.const { data, isFetchingNextPage, isFetching, hasNextPage, fetchNextPage } = useInfiniteQuery({ initialPageParam: 1, queryKey: ["movie", keyword], queryFn: ({ pageParam }) => searchMovies({ search: keyword, page: pageParam, pageSize: 12 }), getNextPageParam: (lastPage, allPages) => { return lastPage.page ? lastPage.page + 1 : undefined; }, });대충 이런식으로.. useQuery랑은 비슷하지만 hasNextPage, fetNextPage등 무한 스크롤 구현에 유용한 기능들이 포함되어있다. 강의를 들으면 깔끔하게 무한 스크롤까지 구현이 가능해진다.SEO (Next.js generateMetadata)Next.js에서는 동적으로 metadata를 생성해주는 기능을 제공한다. dynamic page같은 경우 각 페이지별로 메타데이터를 설정해주려고 하면 예를들어 id를 1, 2, 3 이렇게 다 따로 만들 수 없으니 이 때 generateMetadata를 사용하면 된다.export async function generateMetadata({ params }: any) { // Next.js에서는 params를 await 해야 함 const { id } = await params; const movie = await getMovie(Number(id)); return { title: movie?.title, description: movie?.overview, openGraph: { images: [movie?.image_url], }, }; }강의에서는 Next.js 14 버전이라 별 문제가 없었지만 나는 Next.js 15 버전을 사용해서 params를 가져올 때 async-await을 사용해서 가져왔다. 15버전에서 그냥 가져오면 동작은 하지만 에러가 발생한다. 이렇게까지 하면 가볍게 Netflix 클론코딩은 클리어. 미션3주차미션은 "찜하기" 기능을 구현하는 것이다. 유저 정보도 아직 없고 어떻게 구현할까 하다가 역시 로컬에 저장하는건 localstorage가 답이다 생각했다. 하지만 Next.js 는 SSR이라 localstorage를 그저 React처럼 사용한다면 기능이 정상적으로 동작하지 않는다. 따라서 zustand의 persist를 통해 로컬스토리지에 데이터를 쉽게 사용할 수 있도록 구현했다.마무리 이번주도 주말까지 무사히 일정을 잘 맞췄다. 몇번 했던 기술들이었지만 한번 더 복습 겸 꼼꼼히 들었다. 예전에는 javascript api 중 intersection observer을 이용해 무한스크롤을 깡으로 구현했었는데 확실히 라이브러리를 통해 구현하니까 많이 간편했다. 다음 인스타 클론코딩은 거의 2주치 분량이던데 다음주는 진짜 미리미리 듣고 추가미션까지 해낼 수 있도록 노력해야겠다. 마지막 4주차도 화이팅!

웹 개발웹개발프론트엔드풀스택supabase

leeebug

워밍업 클럽 스터디 3기 FS - 3주차 발자국

워밍업 클럽도 벌써 3주차!처음 스터디를 시작할 때와 비교하면 가장 큰 소득은 React Query에 익숙해졌다는 것 그리고 무언가에 몰입하면서 성취감을 느꼈다는 것이다.단순히 기능을 구현하는 걸 넘어서 최적화나 에러 핸들링까지 고민하는 과정이 꽤 재밌었다.이번주에는 인피니트 쿼리와 추가 기능으로 좋아요 기능을 구현해야해서 지난주와 마찬가지로 조금 일찍 학습을 시작했다.깃 레포는 역시 첫번째 과제에 사용했던 템플릿을 거의 수정없이 그대로 사용해서 역시나 개발환경 구축은 무리없이 진행했다.다만, 한 가지 아쉬운 점이라면 터보레포 같은 모노레포 도구를 도입했어야 하지 않았나 하는 생각이 들었다는 점이다.현재 방식은 각 주차별 과제를 독립적인 레포로 관리하고 있는데, 공통 유틸이나 자주 사용하는 설정 파일을 계속 복붙하는 과정에서 의외로 피로감을 느꼈다.우선 마지막 과제까진 지금의 방식을 유지하고 스터디 마무리 이후에 터보 레포에 대해서는 개별적으로 학습을 해볼 예정이다.📝 3주차 학습useInfiniteQuery무한 스크롤 및 페이지네이션을 위한 React Query 훅fetchNextPage를 사용해 추가 데이터 요청getNextPageParams로 다음 페이지 여부 관리 useInViewreact-intersection-observer 라이브러리의 훅특정 요소가 화면에 보이는지 감지(뷰포트 진입 여부로 확인)무한 스크롤 구현 시 useInfiniteQuery와 함께 사용threshold, rootMargin으로 감지 범위 조절 가능📋 3주차 미션💬 GitHub 저장소🚀 데모 영상 보러가기미션 해결 과정 요약이번주 미션의 필수 구현 과제는 무한 스크롤과 SEO 추가, 영화 검색 기능 구현하기였다. 추가 기능으로 영화 좋아요 기능을 구현했는데, 예전에 SNS를 만들때 경험해봤던 기능이라 쉽게 구현할 수 있을 것이라 기대했다. 하지만 예상과는 달리, 유저 식별 기능이 없다는 점이 문제였다. SNS 좋아요 기능 구현 당시에는 사용자 ID 기반으로 좋아요를 관리했지만 이번 프로젝트는 익명 유저 환경이라 데이터를 어떻게 저장하고 관리할지 고민이 필요했다.처음에는 movies, users, liked_movies 3개의 테이블을 생성하여 user_id, movies_id를 복합키로 설정해 브라우저별 익명 유저를 관리하는 방식을 시도했으나 구현 복잡도가 너무 높아지는 문제로 단순화하는 방식으로 변경했다.movies 단일 테이블에 like_count 필드를 추가하고 브라우저별로 좋아요 상태를 관리하는 방식으로 해당 기능을 구현했다. 이 방식의 단점은 브라우저 변경 시 개인별 좋아요 리스트를 추적할 수 없다는 점이지만 애초에 유저 식별 기능을 배제한 상황에서 선택할 수 있는 최적의 방식이라고 판단하여 적용했다.그리고 강의에서는 movies.id를 auto increment id로 구현했지만 더 나은 확장성을 위해서 uuid를 고려했다. 다만 uuid는 URL에서 사용하기 불편하여 가독성이 좋은 slug 칼럼을 별도로 추가하였다. API 요청 파라미터를 id에서 slug로 대체하면서 가독성과 SEO 최적화까지 함께 챙겨갈 수 있었다.slug Column 추가ALTER TABLE myreel_movies ADD COLUMN slug TEXT UNIQUE;중복되는 Row 제거 (제공되는 DB에 중복되는 데이터가 9건 발견되었다.)DELETE FROM myreel_movies WHERE id NOT IN ( SELECT id FROM ( SELECT id, title, order_index, ROW_NUMBER() OVER (PARTITION BY title ORDER BY order_index ASC) AS row_num FROM myreel_movies ) ranked WHERE row_num = 1 );영화 title 기준으로 slug 생성예시 - 'Dune: Part Two' -> 'dune-part-two'UPDATE movies SET slug = LOWER(REGEXP_REPLACE(title, '[^a-zA-Z0-9]+', '-', 'g')) WHERE slug IS NULL;과제 추가 구현 기능✅ 영화 좋아요 추가api/movies/:slug/likeconst likeMovie = async () => { try { const res = await fetch(`${baseUrl}${API_ENDPOINTS.LIKE(slug)}`, { method: 'POST', }) if (!res.ok) { throw new Error(CLIENT_ERROR.MOVIE_LIKE_FAILED.message) } const data: LikeMovieResponseDTO = await res.json() setLikeCount(data.like_count) // 서버에서 받아온 새로운 좋아요 수로 업데이트 setIsLiked(true) // 로컬 스토리지에 영화 추가 또는 업데이트 const likedMovies: LikedMovie[] = JSON.parse(localStorage.getItem('likedMovies') || '[]') // 이미 좋아요를 누른 영화가 있다면, likeCount를 업데이트 const existingMovieIndex = likedMovies.findIndex((movie) => movie.slug === slug) if (existingMovieIndex >= 0) { likedMovies[existingMovieIndex].likeCount = data.like_count // 좋아요 수 업데이트 } else { // 좋아요를 누른 적이 없다면 새로 추가 const newLikedMovie = { slug, likeCount: data.like_count } likedMovies.push(newLikedMovie) } localStorage.setItem('likedMovies', JSON.stringify(likedMovies)) } catch (error) { console.error(error) } }✅ 영화 좋아요 삭제api/movies/:slug/unlikeconst unlikeMovie = async () => { try { const res = await fetch(`${baseUrl}${API_ENDPOINTS.UNLIKE(slug)}`, { method: 'POST', }) if (!res.ok) { throw new Error(CLIENT_ERROR.MOVIE_UNLIKE_FAILED.message) } const data: LikeMovieResponseDTO = await res.json() setLikeCount(data.like_count) setIsLiked(false) // 로컬 스토리지에서 해당 영화 정보 삭제 const likedMovies: LikedMovie[] = JSON.parse(localStorage.getItem('likedMovies') || '[]') const updatedLikedMovies = likedMovies.filter((movie) => movie.slug !== slug) // 로컬 스토리지 갱신 localStorage.setItem('likedMovies', JSON.stringify(updatedLikedMovies)) } catch (error) { console.error(error) } }개인 챌린지 기능✅ 메인 페이지 최상단으로 가는 버튼 추가메인 페이지에서 500px 이상 스크롤 내릴 경우 최상단으로 이동하는 버튼 생성behavior: 'smooth' 로 부드럽게 이동 ✅ 검색 결과 없을 경우, 좋아요 많은 순 추천 영화 6개 노출되는 기능 구현좋아요가 많은 영화 외에도 최근 개봉한 영화 같은 다양한 리스트 제공 예정api/movies/most-liked👀 3주차 회고지난주에 적용했던 매니져 컴포넌트 / UI 컴포넌트로 분리하는 방식이 Container-Presentational Component 패턴 과 유사한 방식이라는 것을 다른 러너분의 발자국을 통해 알게되었다. 궁금해서 조금 더 찾아보니, 이 패턴은 과거 클래스형 컴포넌트 시절에는 HOC(High Order Component)와 함께 많이 사용되었지만, 함수형 컴포넌트에서도 여전히 유효한 방식이라는 것을 알게되었다. 이번주에는 기존 패턴을 유지하면서도, 비즈니스 로직을 최대한 커스텀 훅으로 분리하는 연습을 진행했다. 이를 통해 컴포넌트의 역할을 더욱 명확하게 나누고, 재사용성과 유지보수성을 높이는 방향으로 조금씩 개선되고 있다는 것을 체감했다.👻 배포 관련 이슈 (3월 22일 추가)4주차에 스터디 기간 개발한 4개의 프로젝트를 모두 배포하는것이 기존 스터디 일정이지만.. 시간적 여유가 생겨서 1~3주차 프로젝트를 미리 배포해봤다. vercel은 기존에 사용하던 툴이었는데 한번에 3개의 프로젝트를 배포하려고 시도하는 과정에서 수 많은 에러를 경험했다. 4주차 프로젝트 배포시, 추후 다른 프로젝트 배포시에 참고할 수 있도록 간단하게 정리해본다. ✅ @/components/... 앨리어스 관련 캐싱 이슈문제 개발 환경에서는 정상 작동하던 import가 Vercel 배포 시에만 Module not found 에러 발생원인Vercel의 캐싱 문제 또는 파일명 인식 관련 문제(대소문자, 내부 경로 변경 후 캐시 꼬임)해결@ 앨리어스 문제를 의심하여 상대 경로로 변경 후 재배포 시도 -> 해결 안됨컴포넌트 경로의 대소문자 확인후 재배포 시도 -> 해결 안됨 Title.tsx 파일명을 AppTitle.tsx로 변경하여 강제로 캐시 무력화 후 재배포 시도 -> 해결 ✅ params 비동기 처리 관련 타입 에러 (Next.js 15)문제page.tsx에서 params를 비동기적으로 처리하려 하자, params 타입이 Promise로 인식되어 타입 오류 발생원인 (깃헙 이슈 참고)Next.js 15 내부적으로 PageProps가 비동기적 처리를 기대하거나 타입 추론이 변경됨params 타입이 Promise<any>로 추론되어 관련 에러 발생PageParams 제네릭 타입 해석 충돌next dev에서는 정상 작동하지만 next build 시 오류 발생해결params의 인터페이스를 명시적 타이핑 -> 해결 안됨params의 타입 any로 명시하고 타입 단언으로 처리 -> 해결 안됨배포 시 안정성 확보를 위해서 Next.js 14 + React 18 버전으로 롤백 -> 해결✅ Tailwind CSS 적용 안됨문제배포된 페이지에서 Tailwindcss 클래스가 적용되지 않음원인Next.js 15 -> Next.js 14, React 19 -> React 18로 롤백하는 과정에서 관련된 의존성 충돌이 일어난것으로 예상됨해결Tailwindcss, postcss, autoprefixer 의존성 삭제 후 캐시 초기화 후 재설치 -> 해결 ✅ 환경 변수(NEXT_PUBLIC_BASE_URL) 미설정으로 fetch 실패문제빌드 시 fetch 요청이 localhost:3000으로 날아가면서 ECONNREFUSED 에러 발생원인Vercel 환경 변수 설정 시 NEXT_PUBLIC_BASE_URL 값을 localhost:3000으로 설정하여 에러 발생해결해당 환경 변수를 실제 배포 URL로 변경 후 재배포 시도 -> 해결

풀스택워밍업클럽3기회고발자국3주차

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워밍업발자국

강신욱

[인프런 워밍업 클럽 Full Stack 3기] 스터디 1주차

강의 주요 내용  서버 액션: 비동기 함수서버에서 실행되는 함수Next.js는 서버에서 실행되는 서버 컴포넌트와 브라우저에서 실행되는 클라이언트 컴포넌트를 구분하여 동작한다.파일 최상단에 'use server'가 선언되어 있으면 서버 컴포넌트이다.'use client'가 선언되어 있으면 클라이언트 컴포넌트이다.서버 컴포넌트 내에서 정의한 함수를 클라이언트에서 직접 호출할 수 있다. 즉, 별도의 API를 구현하지 않아도 클라이언트에서 서버 함수를 바로 사용할 수 있어 개발이 더욱 간편해진다.Recoil: 전역 상태 관리 라이브러리Recoil이란?Recoil은 클라이언트 상태 관리 라이브러리로, 여러 컴포넌트에서 동일한 상태를 공유할 수 있도록 도와준다. 예를 들어:뷰어 상태 관리헤더 검색창 상태 관리이처럼 다양한 상태를 효율적으로 관리할 수 있으며, Redux나 MobX보다 간편하게 사용할 수 있다.Recoil의 핵심 개념Recoil은 Atom과 Selector라는 두 가지 핵심 개념으로 구성된다.1. Atom전역 상태를 정의하는 가장 기본적인 단위여러 컴포넌트에서 공유 가능2. SelectorAtom 상태에서 파생된 값을 생성할 때 사용특정한 값만 변형하여 제공 가능예: 텍스트 상태에서 문자열 길이만 가져오는 Selector 정의Selector를 활용하면 불필요한 연산을 줄이고 최적화된 방식으로 상태를 관리할 수 있다.React Query: 클라이언트 상태 관리 및 데이터 동기화React Query란?React Query는 TanStack에서 개발한 서버 상태 관리 라이브러리로,서버에서 데이터를 가져오거나데이터를 변경하는 요청을 보낼 때 사용된다.React Query의 역할클라이언트에서 데이터 가져오기(Fetching)가져온 데이터 캐싱(Caching)서버 데이터 변경 시 동기화(Syncing)즉, 서버와 클라이언트 간의 데이터 흐름을 효율적으로 관리할 수 있도록 도와준다.React Query의 주요 장점✅ 자동 캐싱 (Auto Caching)✅ 서버 데이터와 클라이언트 데이터 분리 (Separation of Server & Client State)Supabase 주요 기능1. Table EditorTable Editor는 데이터베이스에서 직접 테이블을 생성 및 수정할 수 있는 기능이다.2. SQL EditorSQL Editor를 통해SELECT, UPDATE, DELETE 등의 SQL 쿼리를 실행할 수 있다.Assistant 기능 지원: AI가 DB와 연동되어 SQL을 자동 생성해준다.예: "이 테이블에서 특정 컬럼을 가져오고 싶어!"라고 입력하면, 적절한 SQL 쿼리를 자동 생성해준다.SQL이 익숙하지 않은 사용자에게 유용한 무료 기능이다.3. 데이터베이스 관리생성된 테이블 조회데이터베이스 함수 및 트리거 설정예: 특정 row가 생성, 수정, 삭제될 때 실행되는 트리거 설정 가능액세스 컨트롤(Role-based Access Control)특정 유저가 특정 테이블에 접근할 수 있도록 권한을 설정할 수 있다.(※ 액세스 컨트롤 관련 내용은 본 강좌 범위에서 제외)미션 수행이번 미션에서는 실습에서 구현한 TODO 리스트에 생성 날짜를 표시하는 기능을 추가하는 것이 기본 목표였다.처음 도전해보는 분야였던 만큼 시행착오가 많았고, 라이브러리 버전 호환 문제나 기타 충돌로 인해 많은 구글링이 필요했다.관련해서 강사님이 추천해주신 강의를 다시 한 번 정독한 뒤, 선택 과제도 수행해볼 예정이다.이번 실습을 통해 Next.js, Recoil, React Query, 그리고 Supabase를 더욱 깊이 있게 이해할 수 있었으며, 앞으로의 프로젝트에서도 적극적으로 활용해볼 계획이다.

풀스택

찬우 이

인프런 워밍업 클럽 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

김진현

[인프런 워밍업 클럽 Full Stack 3기] 2주차 Dropbox

Part 1. Git Repository 생성 및 초기 설정 진행초기 코드 생성npx create-next-app@latest inflearn-supabase-dropbox-clone cd inflearn-supabase-dropbox-cloneTODO List 코드 복사config/*app/layout.tsx , app/middleware.tsx , app/global.csscomponents/material-tailwind-theme-provider.tsxutils/*package.jsontailwind.config.ts, tsconfig.json , .envPart 2. UI 작업app/page.tsximport UI from "./ui"; export const metadata = { title: "Minibox", description: "A minimalistic Dropbox clone", }; export default function Home() { return <UI />; }app/ui.tsx"use client"; import DropboxImageList from "components/dropbox-image-list"; import FileDragDropZone from "components/file-dragdropzone"; import Logo from "components/logo"; import SearchComponent from "components/search-component"; import Image from "next/image"; import { useState } from "react"; export default function UI() { const [searchInput, setSearchInput] = useState(""); return ( <main className="w-full p-2 flex flex-col gap-4"> {/* Logo */} <Logo /> {/* Search Component */} <SearchComponent searchInput={searchInput} setSearchInput={setSearchInput} /> {/* File Drag&Drop Zone */} <FileDragDropZone /> {/* Dropbox Image List */} <DropboxImageList /> </main> ); }components/dropbox-image-list.tsx"use client"; import DropboxImage from "./dropbox-image"; export default function DropboxImageList() { return ( <section className="grid md:grid-cols-3 lg:grid-cols-4 grid-cols-2"> <DropboxImage /> <DropboxImage /> <DropboxImage /> <DropboxImage /> </section> ); }  components/dropbox-image.tsx"use client"; import { IconButton } from "@material-tailwind/react"; export default function DropboxImage() { return ( <div className="relative w-full flex flex-col gap-2 p-4 border border-gray-100 rounded-2xl shadow-md"> {/* Image */} <div> <img src="/images/cutedog.jpeg" className="w-full aspect-square rounded-2xl" /> </div> {/* FileName */} <div className="">cutedog.jpeg</div> <div className="absolute top-4 right-4"> <IconButton onClick={() => {}} color="red"> <i className="fas fa-trash" /> </IconButton> </div> </div> ); } components/file-dragdropzone.tsx"use client"; export default function FileDragDropZone() { return ( <section className="w-full py-20 border-4 border-dotted border-indigo-700 flex flex-col items-center justify-center"> <input type="file" className="" /> <p>파일을 여기에 끌어다 놓거나 클릭하여 업로드하세요.</p> </section> ); } components/logo.tsx"use client"; import Image from "next/image"; export default function Logo() { return ( <div className="flex items-center gap-1"> <Image src="/images/dropbox_icon.png" alt="Mini Dropbox Logo" width={50} height={30} className="!w-8 !h-auto" /> <span className="text-xl font-bold">Minibox</span> </div> ); } components/search-component.tsx "use client"; import { Input } from "@material-tailwind/react"; import { useState } from "react"; export default function SearchComponent({ searchInput, setSearchInput }) { return ( <Input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} label="Search Images" icon={<i className="fa-solid fa-magnifying-glass" />} /> ); }  Part 3. Supabase Storage 설명 & 파일 업로드 구현Supabase Storage Bucket 생성 및 CRUD Policy 추가 액션 함수 구현actions/storageActions.tsx"use server"; import { createServerSupabaseClient } from "utils/supabase/server"; function handleError(error) { if (error) { console.error(error); throw error; } } export async function uploadFile(formData: FormData) { const supabase = await createServerSupabaseClient(); const file = formData.get("file") as File; const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }); handleError(error); return data; } 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, }); handleError(error); return data; } components/file-dragdropzone.tsx"use client"; import { Button } from "@material-tailwind/react"; import { useMutation } from "@tanstack/react-query"; import { uploadFile } from "actions/storageActions"; import { queryClient } from "config/ReactQueryClientProvider"; import { useRef } from "react"; export default function FileDragDropZone() { const fileRef = useRef(null); const uploadImageMutation = useMutation({ mutationFn: uploadFile, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["images"], }); }, }); return ( <form onSubmit={async (e) => { e.preventDefault(); const file = fileRef.current.files?.[0]; if (file) { const formData = new FormData(); formData.append("file", file); const result = await uploadImageMutation.mutate(formData); console.log(result); } }} className="w-full py-20 border-4 border-dotted border-indigo-700 flex flex-col items-center justify-center" > <input ref={fileRef} type="file" className="" /> <p>파일을 여기에 끌어다 놓거나 클릭하여 업로드하세요.</p> <Button loading={uploadImageMutation.isPending} type="submit"> 파일 업로드 </Button> </form> ); }Part 4. 필수 라이브러리 설정 - React Query, Supabase라이브러리 추가 설치npm i --save @supabase/ssr @tanstack/react-queryReact Query 설정config/ReactQueryClientProvider.tsx"use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; export const queryClient = new QueryClient({}); export default function ReactQueryClientProvider({ children, }: React.PropsWithChildren) { return ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ); }app/layout.tsximport ReactQueryClientProvider from "config/ReactQueryClientProvider"; export default function RootLayout({ children }) { return ( <ReactQueryClientProvider> ... </ReactQueryClientProvider> ) } Supabase 설정.envNEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_SUPABASE_SERVICE_ROLE= NEXT_SUPABASE_DB_PASSWORD= utils/supabase/client.ts"use client"; import { createBrowserClient } from "@supabase/ssr"; export const createBrowserSupabaseClient = () => createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! );utils/supabase/server.ts"use server"; import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { cookies } from "next/headers"; import { Database } from "types_db"; export const createServerSupabaseClient = async ( cookieStore: ReturnType<typeof cookies> = cookies(), admin: boolean = false ) => { return createServerClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, admin ? process.env.NEXT_SUPABASE_SERVICE_ROLE! : process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return cookieStore.get(name)?.value; }, set(name: string, value: string, options: CookieOptions) { try { cookieStore.set({ name, value, ...options }); } catch (error) { // The `set` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, remove(name: string, options: CookieOptions) { try { cookieStore.set({ name, value: "", ...options }); } catch (error) { // The `delete` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, }, } ); }; export const createServerSupabaseAdminClient = async ( cookieStore: ReturnType<typeof cookies> = cookies() ) => { return createServerSupabaseClient(cookieStore, true); }; app/middleware.tsimport { createServerClient, type CookieOptions } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; export const applyMiddlewareSupabaseClient = async (request: NextRequest) => { // Create an unmodified response let response = NextResponse.next({ request: { headers: request.headers, }, }); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return request.cookies.get(name)?.value; }, set(name: string, value: string, options: CookieOptions) { // If the cookie is updated, update the cookies for the request and response request.cookies.set({ name, value, ...options, }); response = NextResponse.next({ request: { headers: request.headers, }, }); response.cookies.set({ name, value, ...options, }); }, remove(name: string, options: CookieOptions) { // If the cookie is removed, update the cookies for the request and response request.cookies.set({ name, value: "", ...options, }); response = NextResponse.next({ request: { headers: request.headers, }, }); response.cookies.set({ name, value: "", ...options, }); }, }, } ); // refreshing the auth token await supabase.auth.getUser(); return response; }; export async function middleware(request) { return await applyMiddlewareSupabaseClient(request); } export const config = { matcher: [ /* * Match all request paths except for the ones starting with: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * Feel free to modify this pattern to include more paths. */ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; Part 5. 할일 CRUD 기능 구현 (feat. Server Action)"use server"; import { Database } from "types_db"; import { createServerSupabaseClient } from "utils/supabase/server"; export type TodoRow = Database["public"]["Tables"]["todo"]["Row"]; export type TodoRowInsert = Database["public"]["Tables"]["todo"]["Insert"]; export type TodoRowUpdate = Database["public"]["Tables"]["todo"]["Update"]; function handleError(error) { console.error(error); throw new Error(error.message); } export async function getTodos({ searchInput = "" }): Promise<TodoRow[]> { 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; } export async function createTodo(todo: TodoRowInsert) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.from("todo").insert({ ...todo, created_at: new Date().toISOString(), }); if (error) { handleError(error); } return data; } 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(), }) .eq("id", todo.id); if (error) { handleError(error); } return data; } 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; }

풀스택Next.jsSupabaseReact-Query

희주

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

학습 내용이번 주에는 Supabase Storage를 활용하여 파일 업로드가 가능한 Minibox 프로젝트 클론코딩을 진행했다.Supabase Storage Bucket 생성 및 접근 정책 설정 방법파일 업로드, 검색, 삭제 Server Action과 기능 구현같은 파일명이 있을 경우 덮어쓰는 upsert 옵션storage bucket에 올라간 이미지 URL 구조를 확인해보고, 업로드 응답으로 받는 path를 활용해 직접 URL을 만드는 getImageUrl() 함수 작성해 화면에 이미지 표시react-dropzone 라이브러리를 적용해 Drag & Drop으로 파일 업로드 기능 구현react-dropzone 라이브러리의 useDropzone에 multiple 옵션을 적용하고 멀티 파일 업로드 구현1주차에 만든 TODO List 프로젝트 코드를 활용해 빠르게 초기 설정을 마치고, 저번 주 학습 내용에 익숙해지면서 Supabase Storage 사용법과 파일을 다루는 방법을 익힐 수 있었다. 미션Dropbox Clone 프로젝트에 파일의 마지막 수정(업로드) 시간을 표시하는 기능을 추가하세요.파일 목록에서 각 파일의 “마지막 수정 시간”을 표시 📌 참고 문서: Supabase Storage - 파일 목록 가져오기 https://supabase.com/docs/reference/javascript/storage-from-list위의 참고 문서에서 파일 리스트를 가져올 때 받는 data에 updated_at 값이 포함된다는 것을 알 수 있었고, 이를 활용해 파일의 마지막 수정(업로드) 시간을 표시하기로 했다.파일들의 data를 받아오면 DropboxImage 컴포넌트에 전달된다. 이 컴포넌트에서 각 파일 data(image)의 image.updated_at(마지막 수정 시간)을 파일 이름 바로 아래에 표시해주었다. 이때 TODO List 미션 때 작성했던 날짜 포맷 함수를 적용했다.... {/* FileName */} <div className="">{image.name}</div> {/* Updated_at */} <div>{formatDate(image.updated_at)}</div> ... 마무리이번 주는 파일 업로드와 Drag & Drop 등 자주 쓰이는 유용한 기능을 따라 구현해볼 수 있었어서 앞으로 필요할 때 쉽게 적용할 수 있을 것 같다. 다만 한글 파일명은 업로드되지 않는 문제와 첫 글자부터 입력해야만 뜨는 검색 기능도 별도로 개선이 필요할 것 같다.🥺또한 이번주에는 중간점검 라이브가 진행됐는데, 질문에 대해 자세히 답해주셔서 앞으로의 방향 설정에도 많은 도움이 되었고 짧지만 유익한 시간이었다! 벌써 진도의 절반이 지나가고 있는데 지금까지의 내용도 잘 보강하면서 나머지 강의와 미션도 끝까지 해내고 싶다🙂

풀스택풀스택웹개발Next.jsSupabase

디렉투스바스키

얼어붙은 채용시장에 대한 우리의 파훼법

얼어붙은 채용시장에 대한 우리의 파훼법파멸적인 개발자 채용 시장의 현재를 걱정하고 우려하시는 분들이 많네요.그런 와중에, 그럼에도, 아직 꾸준히 성장중인 시장은 분명히 존재합니다.(Calyptus 채용시장 보고서 요약 바로가기) 블록체인-웹3 개발자, 당신의 가치는 계속 상승 중! 🚀💰블록체인-웹3 시장은 간간히 주춤하다가도, 분명히 지속적으로 성장하고 있습니다.반면에 블록체인 관련 역량을 갖춘 개발자는 여전히 부족합니다.전세계의 블록체인 재단, 기업은 끝임없이 개발자 부족을 말하고 있죠.따라서 블록체인 개발자의 가치는 그만큼 상승하고 있습니다.글로벌 스마트 컨트랙트 개발자의 평균 연봉은 $150,269 입니다.통계상 국내 일반 개발자 평균의 약 2배 입니다.물론 통계의 헛점이 있을 수 있지만 평균적으로 높은 건 사실이죠. 블록체인-웹3 기업, 전 세계에서 경계 없는 협업! 🌍💡디지털 노마드 성향이 짖은 블록체인-웹3 업계는 주로 원격(재택)근무를 선호합니다.아무래도 부족한 개발자 인력을 채우기 위해 글로벌한 협업이 지향되는 편이고프로젝트 위주의 조직화가 빈번하다보니 그런면도 있죠.그래서 대부분의 블록체인-웹3 기업(82.5%)이 원격 근무를 선호합니다.글로벌 시장에 진입하면서도 굳이 거주지의 제약을 받지 않는다는 장점이 있습니다. 어떤 방향이든 세계는 새로운 시대로 나아가고 있습니다.그리고 분명하게 전방위적으로 디지털화가 진행되고있죠.블록체인-웹3 분야에 관심을 가져보시는 것도 나쁘지 않은 선택일 것 같습니다.(Calyptus 채용시장 보고서 요약 바로가기)

풀스택블록체인웹3

채널톡 아이콘