블로그

코드캠프

입문자도 가넝한 8주만에 개발자 되는 법

안녕하세요! 실무 코딩부트캠프, 코드캠프입니다 :)날씨가 조금씩 따뜻해지면서 식곤증이 부쩍 늘어나고 있지 않나요?(전지적 컴퓨터 시점)그래서 코드캠프가 눈이 번쩍! 뜨이는(👀) 강의 업데이트 소식을 준비해왔어요!특히 비전공이지만 개발 커리어에 관심이 있는 분들이라면, 주목해주셔도 좋아요 :)코딩을 몰라도(NEW!) 2개월만에 개발자가 되는 '관리형' 코딩 부트캠프인프런에 입성한지 얼마 되지 않았지만 뜨거운 관심과 애정 어린 피드백을 받으며, 더 나은 컨텐츠를 제공하기 위해 코드캠프도 열심히 성장 중인데요. (정말 감사합니다)그 중 사전 기초 지식 없이는 부트캠프 합류가 어려워 신청을 망설였던 분들을 많이 보았어요.그래서 상담 후 등록을 완료해주신 모든 분들께,[시작은 프리캠프], [강력한 CSS], [훈훈한 Javascript] 기초 강의를 무료로 제공합니다!사전 지식을 필요로 하시는 분들은 웹 개발의 기초를 습득하시고사전 지식이 이미 있으신 분들은 기초 내용을 복습하면서 튼튼하게 다져보세요 :)⚠️ 단, 기초 강의 수강 기간은 부트캠프 기간(8주)에 포함되지 않으므로 등록하신 기수의 개강 일정에 맞춰 수강해 주셔야 합니다!(MBTI가 P인 분들)개강 전 기초 강의 수강 계획을 짜기 힘드신 분들은 언제든 코드캠프에 문의해주세요!개강일에 맞춰 수강할 수 있도록 체계적인 시간표를 제공해드리겠습니다 😊👉 2기 일정 : 03.06 - 04.28 (선착순 모집) ▶ 20% 할인 중!👉 3기 일정 : 04.03 - 05.29 (선착순 모집) ▶ 20% 할인 중!

웹 개발웹개발프론트엔드백엔드부트캠프비전공자JavascriptNode.jsReactNext.jsNest.js

이정환 Winterlood

한입 FE 완강 챌린지 2기를 모집합니다

🏃 시작부터 완강까지! 함께합니다.한입 FE 챌린지는 수강생 여러분들의 완강을 도와드리고자 하는 목적으로 기획되었습니다.매일 매일(일요일 및 공휴일 제외) 조금씩 강의를 완강하실 수 있도록 진도표를 제공해드리며당일 배운 내용을 바로 복습하실 수 있도록 매일 미션도 함께 제공해 드립니다.미션 검사도 당연히 제공됩니다 🫡 챌린지 강의 목록한입 FE 챌린지 2기는 다음 2개의 강의로 진행됩니다.한 입 크기로 잘라먹는 Next.js  한 번에 끝내는 자바스크립트: 바닐라 자바스크립트로 SPA 개발까지자바스크립트 학습을 염두에 두셨던 분들이라면 “한 번에 끝내는 자바스크립트” 챌린지에Next.js 학습을 염두에 두셨던 분들이라면 “한 입 크기로 잘라먹는 Next.js”에 참여하시는걸 추천드립니다. 상세 안내참여 혜택참여 리워드참가하시기만 해도 받으실 수 있는 리워드입니다.완강 의지를 불태우기 위한 특별 강의 할인 쿠폰을 제공합니다. (미 구매자 한정)완주 리워드모든 미션을 완료해야 받을 수 있는 리워드입니다.한입 FE 멘토들의 다른 강의 할인 쿠폰을 제공합니다.향후 챌린지 개설시 프리패스로 참여하실 수 있습니다.기간 및 일정모집 기간 :09월 1일 ~ 09월 07일(토) 자정까지활동 기간 :한 번에 끝내는 자바스크립트 : 09.09(월) ~ 09.27(금), 전체 기간 3주, 미션 수행일 14일한 입 크기로 잘라먹는 Next.js : 09.09(월) ~ 10.05(토), 전체 기간 4주, 미션 수행일 20일매주 일요일, 공휴일(추석 연휴 기간 포함)에는 쉽니다 😴활동 내용진도표에 맞춰 강의 수강하기하나의 강의를 선택해 완강합니다.매일 매일 체계적으로 수강하실 수 있도록 강의별 진도표를 제공합니다.커뮤니티를 통해 매일 인증합니다.퀴즈 및 과제 수행하기당일 배운 내용을 복습할 수 있는 퀴즈(or 과제)를 매일 제공합니다.커뮤니티를 통해 매일 인증합니다.커뮤니티에서 지식&경험 공유하기챌린지 참여자분들과 함께 한입 FE Discord 채널에서 소통합니다.미션 제출, 수강 인증, 스몰톡 등의 활동을 진행합니다.접수 방법https://bit.ly/4cJqGgZ위 링크로 신청해주세요 문의onebite.fe@gmail.com

프론트엔드챌린지스터디완강JSNext.jsJavaScriptNextjsNext

Yang HyeonBin

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

2주차에 배운 내용을 정리해본다.깃허브 링크 1. 배운 내용1. Next.js에서 메타데이터 정의하기<meta> 태그를 이용해 사이트 정보를 정의하려면,서버 컴포넌트 파일에서Metadata를 정의해줘야 함// page.tsx import { Metadata } from "next"; import Ui from "./Ui"; // 페이지의 메타데이터를 정의 // use client에서는 사용 불가 - 클라이언트 코드는 Ui.tsx에서 정의하는 이유 export const metadata: Metadata = { title: "Dropbox Clone", description: "A minimalist Dropbox Clone", }; export default function Home() { return <Ui />; } 2. 파일 드랍 존 만들기<input /> 태그를 이용, type="file"3. supabase1. storage bucket 만들기업로드 가능한 파일 종류 설정 가능만들 때 Allowed MIME types 옵션에서 image/* 등의 조건을 추가하면 됨2. policy 생성사이드바 policy 메뉴에서 생성 가능이름, 가능한 액션 종류 선택, 누구에게 가능하게 할지 선택 가능4. 파일 드래그앤드롭 - react-dropzone 라이브러리 사용사용법은 npm 공식 문서의 코드 조각을 확인https://www.npmjs.com/package/react-dropzone  2. 이슈 사항1. storage의 get url 형태 변경: getImageUrl 함수 커스텀 어려움이미지의 만료일을 지정할 수 있게 변경됨그러면서 token이라는 서치 파라미터가 필수값으로 추가된 듯토큰을 누락한 형태로 확인 시 에러가 발생하며 이미지 로드 실패{"statusCode":"400","error":"Error","message":"querystring must have required property 'token'"} bucket을 public으로 전환하고, supabase에서 제공하는 getPublicUrl 메서드를 사용storage에서 bucket 이름 옆 드롭다운 메뉴 → edit bucket → public으로 설정 getImageUrl 함수 내부를 아래와 같이 수정const { data } = supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET!) .getPublicUrl(path); /** * A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset. * This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset. * * @param path The path and name of the file to generate the public URL for. For example `folder/image.png`. * @param options.download Triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. * @param options.transform Transform the asset before serving it to the client. */ getPublicUrl( path: string, options?: { download?: string | boolean; transform?: TransformOptions } ): { data: { publicUrl: string } } { const _path = this._getFinalPath(path) const _queryString = [] const downloadQueryParam = options?.download ? `download=${options.download === true ? '' : options.download}` : '' if (downloadQueryParam !== '') { _queryString.push(downloadQueryParam) } const wantsTransformation = typeof options?.transform !== 'undefined' const renderPath = wantsTransformation ? 'render/image' : 'object' const transformationQuery = this.transformOptsToQueryString(options?.transform || {}) if (transformationQuery !== '') { _queryString.push(transformationQuery) } let queryString = _queryString.join('&') if (queryString !== '') { queryString = `?${queryString}` } return { data: { publicUrl: encodeURI(`${this.url}/${renderPath}/public/${_path}${queryString}`) }, } } StorageFileApi.ts를 참고하면 다양한 메서드가 있음 - createSingedUrl를 이용하면 expiresIn을 직접 지정 가능. 이걸 이용하면 bucket이 public이 아니어도 가능할듯 /** * Creates a signed URL. Use a signed URL to share a file for a fixed amount of time. * * @param path The file path, including the current file name. For example `folder/image.png`. * @param expiresIn The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute. * @param options.download triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. * @param options.transform Transform the asset before serving it to the client. */ async createSignedUrl( path: string, expiresIn: number, options?: { download?: string | boolean; transform?: TransformOptions } ): Promise< | { data: { signedUrl: string } error: null } | { data: null error: StorageError } > { try { let _path = this._getFinalPath(path) let data = await post( this.fetch, `${this.url}/object/sign/${_path}`, { expiresIn, ...(options?.transform ? { transform: options.transform } : {}) }, { headers: this.headers } ) const downloadQueryParam = options?.download ? `&download=${options.download === true ? '' : options.download}` : '' const signedUrl = encodeURI(`${this.url}${data.signedURL}${downloadQueryParam}`) data = { signedUrl } return { data, error: null } } catch (error) { if (isStorageError(error)) { return { data: null, error } } throw error } } /** * Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time. * * @param paths The file paths to be downloaded, including the current file names. For example `['folder/image.png', 'folder2/image2.png']`. * @param expiresIn The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute. * @param options.download triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. */ async createSignedUrls( paths: string[], expiresIn: number, options?: { download: string | boolean } ): Promise< | { data: { error: string | null; path: string | null; signedUrl: string }[] error: null } | { data: null error: StorageError } > { try { const data = await post( this.fetch, `${this.url}/object/sign/${this.bucketId}`, { expiresIn, paths }, { headers: this.headers } ) const downloadQueryParam = options?.download ? `&download=${options.download === true ? '' : options.download}` : '' return { data: data.map((datum: { signedURL: string }) => ({ ...datum, signedUrl: datum.signedURL ? encodeURI(`${this.url}${datum.signedURL}${downloadQueryParam}`) : null, })), error: null, } } catch (error) { if (isStorageError(error)) { return { data: null, error } } throw error } } 2. server action 파일에서 console.log는 개발자 도구가 아닌 터미널에 찍힌다는 사실..storageActions.ts에 정의한 uploadFile 함수를 수정하고 제대로 데이터가 들어가는지 확인하려고 console.log를 사용아무리 해도 useQuery까지는 잘 로그가 찍히는데, uploadFile에 작성한 로그가 브라우저 개발자도구에 전혀 찍히지 않아 뭐가 문제인지 한참 헤맴코드 에디터 터미널에 찍히고 있었음.. ㅠㅠ3. 파일명에 한글이 포함될 경우 업로드 안되는 오류디스코드에 다른 러너분들이 공유해준 정보에 따르면 supabase storage는 AWS의 S3 스토리지와 호횐되어, 파일명도 AWS S3의 Safe charaters - 영문, 숫자, 일부 특수기호 만 허용한다고 함어떻게 저장할까?base64 인코딩을 통해 S3-safe한 이름으로 변경해 저장하면 업로드 가능⇒ 저장 및 다운로드, 이름 표시하는 코드에서 인코딩/디코딩 함수를 사용하게 변경 완료어떻게 검색할까?인코딩/디코딩된 값으로 검색 호환이 안됨⇒ db에 저장해야 함db에 저장하도록 변경했는데, 한글 초성만 검색됨..todo-list 검색 때와의 차이가 뭐길래 안되는거지? 3. 미션 - 파일의 마지막 수정(업로드) 시간 표시하기storage를 확인하면, Added on, Last modified 정보가 있음file이 어떤 형태인지 로그 찍어 확인file.updated_at 키에 저장된 값임을 확인, 이 값을 사용추가로 file.metadata에 파일 타입과 사이즈 등의 정보도 있어 그 정보들도 적절히 표시하도록 함 (ui는 supabase storage를 참고함)

웹 개발Next.jsSupabaseReact.jsFile

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기 풀스택] 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

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

Yang HyeonBin

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

어느새 3주차다!느낀점은, 혼자 강의를 신청했으면 절대 이만큼 못왔을 거란 거다. 스터디에 참여했기에 강제성이 있어 이만큼 올 수 있었다.일하면서 강의를 듣는다는게 시간도 체력도 많이 필요한 일임을 느꼈다.다행인 점은 과제가 그렇게 많이 어렵진 않아서 다행히도 주말만 투자해서도 해낼 수 있다는 것..!마지막 주는 좀 더 빡셀지 모르겠으나,, 이번 주 공부한 내용을 정리해본다. 깃허브 링크 결과물 이미지! 이슈 해결1. 초기 세팅 후 npm install, npm run dev 다시 하기tailwind 같은 라이브러리는 설치 후 서버 다시 런해야 적용될 때가 있으니 스타일 적용이 안된다 싶으면 npm run dev 다시 해주는 걸 잊지 말자.2. Recoil과 최신 React 버전 충돌 문제Error: Cannot destructure property 'ReactCurrentDispatcher' of 'react__WEBPACK_IMPORTED_MODULE_0___default(...).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED' as it is undefined.Recoil의 현재 버전은 0.7.7에 멈춰있음React 19, 18과 호환이 안되는 것으로 보임Zustand를 사용할까 했는데, Next.js의 속성과 충돌하는 면이 있다고 함아래의 인용 참고 (원문)in Next.jsNext.js 환경에서는 위 장점이 온전히 발휘되지 못한다. 공식적으로 Context 안에서 스토어를 초기화하고 저장하는 것을 권장한다. 때문에 기존 스토어 생성 -> 사용의 두 단계에서 Context 생성 -> 스토어 생성 -> 스토어 초기화 -> Provider 등록 -> 사용의 다섯 단계로 과정이 늘어난다.예시 코드는 공식 문서에서 볼 수 있다.이렇게 세팅의 차이가 나는 이유는 Zustand의 스토어가 전역 변수 기반이기 때문이다. Next.js는 서버와 클라이언트, 두 가지 환경에서 모두 실행되므로 전역 변수의 동작이 Zustand의 가정과 다르다.서버 컴포넌트, 클라이언트 컴포넌트 나뉘는 것이 원인인 듯함추가로, 가끔 발생하던 Hydration 에러를 좀 더 이해하게 됐는데, 서버와 클라이언트 컴포넌트 간에 공유되는 상태의 값이 서로 다를 때 발생하는 에러라고 함ssr과 hydration 관련 zustand 문서그래서, 궁금하기도 했던 Context API를 활용해보기로 함3. 무한 스크롤 - 새 데이터 불러오고 스크롤 위치 초기화되는 문제원인: 데이터 패치 중인 상태에 리턴할 컴포넌트를 먼저 배치하고, 그 아래 리스트 컴포넌트를 배치 → 새 데이터를 불러온 뒤 리스트 컴포넌트가 다시 렌더링되게 하는 듯if (isFetching || isFetchingNextPage) { return ( <div className="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center"> <i className="fa-solid fa-spinner animate-spin text-3xl"></i> </div> ); } return ( <div className="w-full h-full grid md:grid-cols-4 grid-cols-3 gap-1"> {/* 배열(전체 무비 데이터) 속 배열(페이지별 무비 데이터)이므로, 평탄화 */} {data?.pages?.map((page) => page.data ?.flat() ?.map((movie) => <MovieCard key={movie.id} movie={movie} />) )} {/* IntersectionObserver - 무한스크롤 구현을 위해 */} <div ref={ref} className="h-10"></div> </div> ); 해결: 순서를 바꿈return ( <div className="w-full h-full relative"> {/* 로딩 인디케이터 */} {isFetching && !isFetchingNextPage && ( <div className="fixed inset-0 flex items-center justify-center z-10"> <i className="fa-solid fa-spinner animate-spin text-3xl"></i> </div> )} {data && ( <div className="w-full h-full grid md:grid-cols-4 grid-cols-3 gap-1"> {/* 배열(전체 무비 데이터) 속 배열(페이지별 무비 데이터)이므로, 평탄화 */} {data?.pages?.map((page, pageIndex) => page.data ?.flat() ?.map((movie) => ( <MovieCard key={`page-${pageIndex}-movie-${movie.id}`} movie={movie} /> )) )} {/* IntersectionObserver - 무한스크롤 구현을 위해 */} <div ref={ref} className="h-10 col-span-full flex justify-center items-center"> {isFetchingNextPage && ( <i className="fa-solid fa-spinner animate-spin text-xl"></i> )} </div> </div> )} </div> ); 4. 검색 - 대소문자 구분 없이 검색 및 로딩 인디케이터 제거대소문자 구분 없이 검색 - ilike를 사용.ilike("title", `%${search.trim().replace(/\\s+/g, "%")}%`) // 대소문자 구분 X 로딩 인디케이터 관련: 검색 시 무한 스크롤 코드에서 계속 hasNextPage가 true로 되어 데이터를 무한으로 호출하는 버그였음hasNextPage 값을 처리하는 코드 개선 - 데이터 패치 필요없는 경우들에 명확히 undefined를 반환해 더이상 데이터 불러오지 않게끔 개선// useInfiniteQuery 사용하는 코드 getNextPageParam: (lastPage) => { // lastPage?.page ? lastPage.page + 1 : null, // 검색어 입력 시 무한으로 다시 호출하는 버그 // 데이터가 없으면 undefined를 반환하여 hasNextPage를 false로 설정 if (!lastPage.data || lastPage.data.length === 0) { return undefined; } // 마지막 페이지의 데이터 개수가 pageSize보다 작으면 undefined를 반환 if (lastPage.data.length < pageSize) { return undefined; } return lastPage.page + 1; }, 새로운 정보1. 무한스크롤 구현1) React-Intersection-Observer 라이브러리 (useInView 훅)화면에 요소가 보이게 됐는지 확인할 때 사용ref를 전달해 어떤 요소를 감지할지 명시,무한스크롤 구현에 사용보여줄 데이터 제일 밑에 보이지 않는 태그를 추가이 태그가 화면에 보이는 순간 다음 페이지 데이터를 가져오는 함수 호출‘이 태그가 보이는 순간’을 감지하는 데 useInView 사용순간을 감지하면 실행하라,는 코드는 useEffect 로 구현2) React-Query의 useInfiniteQueryuseQuery와 유사한데, fetchNextPage, hasNextPage, isFetchingNextPage라는 특수한 props 가짐2. Next.js - 페이지별 메타데이터 생성: generateMetadata영화 리스트 - 영화 상세 페이지로 이동했을 때 그 영화 관련 정보로 그 페이지의 메타데이터를 구성하고 싶을 경우 등에 사용ogImage: 해당 페이지 링크 공유 시 뜨는 이미지// movies/[id]/page.tsx 상단에 정의 // 각 영화 페이지에 맞는 동적인 메타데이터를 생성 export async function generateMetadata({params}: { params: { id: string }; }) { // 사용할 데이터 패치 const movie = await getMovieById({ movieId: Number(params.id), }); return { title: movie?.title || "", description: movie?.overview || "", // 메타데이터를 위한 이미지 URL - og이미지. 사이트 url 공유 시 보이는 이미지임 openGraph: { images: [movie?.image_url || ""], }, }; } export default function MovieDetail({ // 후략... 미션Notflix Clone 프로젝트에 "찜하기" 기능을 추가하세요. • 사용자가 특정 영화를 "찜"할 수 있도록 Supabase를 활용해 즐겨찾기 리스트 구현 찜한 영화를 영화 리스트 화면의 최상단으로 보여주도록 정렬찜하기 기능 추가 - movie 테이블에 favorited row 추가, boolean 타입으로최상단 보여주기 - 데이터 조회 코드를 수정찜한 데이터를 먼저 불러오고, 그외의 데이터를 불러오도록 수정?1. 찜하기 update 처리 후 movies 리스트 순서가 바뀌는 문제쿼리문의 order 기준을 정해 항상 같은 순서로 데이터를 불러오게 함release_date가 같은 경우 존재, 고유 값인 id를 보조 정렬 규칙으로 사용.order("release_date", { ascending: false }) .order("id", { ascending: true }) // 고유한 ID를 보조 정렬 키로 추가 2. 찜한 데이터 리스트 따로 조회 및 보여주기최근에 찜한 순서로 보여주는 게 일반적이라 판단, favorited → favorited_date (timestampz)로 변경조회하는 코드 분리 및 따로 리스트 생성: 찜한 컨텐츠는 Row, 가로 스크롤 배치

웹 개발웹개발Next.jsSupabase

김진현

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

이수진

[인프런 워밍업 클럽 Full-Stack 3기] 2주차 발자국 - Dropbox 구현

이번주에 배운 내용은 Storage를 통해 Image를 CRUD 하는 기능이다. 이 강의를 들은 다음 추후 Next.js + Supabase 스택으로 블로그를 만들 생각이었어서 그 때 Supabase Storage를 사용할거라 좀 더 유심히 들었다.이번 Dropbox 프로젝트에서는 지난 TodoList 프로젝트를 했을 때 개인적으로 Meterial UI Tailwind를 사용할 때 마음에 들지 않았던 부분들이 있어서 한번 써보고 싶기도 했던 Shadcn/ui를 통해 css를 구현해봤다.수강 내용Section 4. Dropbox 클론코딩이번 섹션에서 배운 내용들은 다음과 같다.Storage 설정 (Bucket 생성 및 Policy 설정)파일 업로드, 검색, 수정, 삭제 기능드래그 앤 드롭을 통한 파일 업로드 기능 추가주로 이제 스토리지에 어떻게 접근해 파일들을 제어할 것인지가 핵심 내용이었다.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; }예를들어 다음과 같은 코드는 파일을 업로드할 때 사용하는 코드이다. upsert를 통해 insert + update 기능으로 같은 이름이 있다면 자동으로 수정해주는 기능이 있다는 것도 처음 알게 된 사실이다.프로젝트에서 한가지 아쉬운 점이 있었다면 파일을 검색할 때 예를들어 A 를 검색했다면 A 로 시작하는 파일 이름만 검색된다는 점이었다. 따라서 이 부분을 개인적으로 한번 수정해보기도 했다.export async function searchFiles(keyword: string = "") { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET!) .list(); // 전체 파일 목록 가져오기 handleError(error); return data ? data.filter((file) => file.name.includes(keyword)) : []; }다음과 같이 keyword를 받아오면 supabase storage의 전체 파일 리스트들을 가져오고, 그 안에서 filter을 통해 keyword가 포함된 데이터들만 표시되도록 구현해봤다. 이렇게 하면 검색 키워드가 포함된 파일 이름들은 모두 나타나게 된다. 드래그 앤 드랍 기능도 구현했는데 이는 react-dropzone 라이브러리를 사용했다.const onDrop = useCallback((acceptedFiles: File[]) => { // Do something with the files }, []); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });npm에 적혀있는 공식 사용법인데, 기존에 사용했던 form을 삭제하고 div로 바꾼 다음 div와 input에 getRootProps getInputProps를 넣고 각각에 적절한 로직들을 넣어주면 드래그 앤 드랍을 통한 파일 업로드 기능도 정상적으로 이루어진다.미션미션은 마지막으로 업데이트 된 날짜를 이미지 카드에 표시해주는 내용이었다. supabase에서 이미지 리스트들을 불러오면 각각의 이미지들은 다음과 같은 구조를 띈다.따라서 lastModified 나 updated_at을 작성해주면 된다. (둘이 무슨 차이가 있는지 잘 모르겠다.....) 날짜 포맷은 지난 Todolist때 사용했던 것 그대로 사용했다. :)결과 화면은 다음과 같다.마무리파일 업로드를 Supabase에 어떤 방식으로 해야할 지 알게 된 시간이었다. 그리고 지난번 기초를 잘 잡아두니 (초기 세팅 등) 별다른 불편함 없이 프로젝트를 새로 시작할 수 있어서 역시 초기 세팅이 중요하다 생각됬다. 또한 강의만 따라 듣는 것이 아닌 부족한 점을 개선하고 내 방식을 추가해서 작업했다는 점도 고무적이었다.다음 프로젝트는 Netflix 프로젝트인데 사실 넷플릭스 프로젝트는.. 다른 강의에서도 몇번 해봤던 내용이지만 특히 그 중 기대하는 점은 SEO에 관한 내용이 있다는 것이었다. Next.js를 사용하는 큰 이유 중 하나가 바로 SEO 관련인데 이 부분에 대해 어떤 내용일지가 궁금하다. 예를 들어 Next.js는 자체적으로 sitemap이나 robots.txt 기능을 제공한다는 점과 강의에서도 말씀해주셧든 page.tsx가 서버 컴포넌트여야 하는 이유 등 어떻게하면 Next.js만이 가지는 장점을 최대한 효율적으로 활용하면서 프로젝트를 만들 수 있을지 기대가 된다. 

웹 개발웹개발Next.js프론트엔드Supabase

희주

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

희주

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

학습 내용1주차에는 주로 사용할 기술들에 대한 소개와 기본 문법을 배우고 이를 이용해 TODO List를 만들었다.Firebase 이후 등장한 Supabase에 대해서 배웠다.오픈소스 프로젝트여서 자체 서버구축이 가능하고 특히 개인/소규모 풀스택 개발에 필요한 것이 대부분 갖춰져 있다는 장점이 있어 앞으로 진행할 프로젝트에 적절한 서비스임을 느꼈다. 부족한 문서 한글화가 단점이라고 하셨는데 강의에서 설정 방법을 자세히 알려주셔서 초보인 나도 쉽게 적용시킬 수 있었다.Next.js는 풀스택 개발에 최적화된 프레임워크로, SSR을 지원하고 별도 서버 구축 없이도 API 구축이 가능하다.이외에도 TailwindCSS(+MaterialUI), Recoil, React Query(TanStack Query)에 대해 배우고 사용해보았다.기존에 GraphQL을 써봤는데, 이번 기회에 궁금했던 React Query의 장점과 기본 문법을 알게 되어서 좋았다. 사용법 자체는 비슷하기도 해서 금방 적응할 수 있을 것 같았다.나는 프론트엔드만 더듬더듬 배운 적이 있고 제공되는 백엔드 API를 사용하기만 했었는데(만드는 걸 아주 간단하게 배운 적 있는데 엄청 어려웠다) Server Action 함수 만들고 호출하기만 해도 백엔드 없이 직접 DB를 조작할 수 있다는 게 신기했다.  미션생성된 TODO의 생성 시간을 저장하고 이를 표시하는 기능을 추가하세요.TODO 항목 옆에 생성 시간을 표시하기날짜 포맷 함수 만들기// utils/formatDate.ts export const formatDate = (dateString?: string): string => { if (!dateString) return ""; const date = new Date(dateString); const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, "0"); const dd = String(date.getDate()).padStart(2, "0"); const hh = String(date.getHours()).padStart(2, "0"); const min = String(date.getMinutes()).padStart(2, "0"); const ss = String(date.getSeconds()).padStart(2, "0"); return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`; };생성 시각은 이미 supabase에 있으므로 created_at 필드 값을 가져와 사용나중에 완료 시각이 추가되어도 생성 시간은 위치가 바뀌지 않게 위쪽에 배치<> <p className={`flex-1 ${completed && "line-through"}`}>{title}</p> <div className="grid grid-rows-2 items-end pr-1"> <p className="text-xs text-right text-gray-500"> <i className="fas fa-pen pr-1" /> {formatDate(todo.created_at)} </p> <p className="text-xs text-right text-gray-500"></p> </div> </>completed_at 필드를 추가하여 완료한 시간도 함께 저장하기created_at과 같이 supabase에 completed_at 필드부터 추가(Allow Nullable 체크)이후 npm run generate-types 실행해 타입 파일도 수정todo가 체크되어 completed가 true일 때는, completed_at에 완료 시각을 저장하도록 updateTodo Server Action을 수정export async function updateTodo(todo: TodoRowUpdate) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("todo") .update({ ...todo, updated_at: new Date().toISOString(), completed_at: todo.completed ? new Date().toISOString() : null, }) .eq("id", todo.id); if (error) { handleError(error); } return data; }마지막으로 completed_at도 포맷팅하여 완료 시각을 표시하고, 조건부 렌더링을 적용해 완료 시각이 없을 때는 아이콘과 시각 모두 표시하지 않도록 작성<> <p className={`flex-1 ${completed && "line-through"}`}>{title}</p> <div className="grid grid-rows-2 items-end pr-1"> <p className="text-xs text-right text-gray-500"> <i className="fas fa-pen pr-1" /> {formatDate(todo.created_at)} </p> <p className="text-xs text-right text-gray-500"> {todo.completed_at ? ( <> <i className="fas fa-check mr-2" /> {formatDate(todo.completed_at)} </> ) : null} </p> </div> </>마무리이번 주 학습에서는 스스로 부족한 점을 많이 느꼈던 것 같다... 처음 배운 것, 지금까지 잘 몰랐던 것도 많았고 버전 문제 등 사소한 이슈를 많이 겪어서 시간이 꽤 들었다.그래도 생각보다 간단하게 Todo List가 만들어지는 걸 보면서, 완강할 때쯤 되면 나만의 프로젝트도 만들 수 있겠다는 자신감이 생기고 있다🙂 아직 너무나 익숙하지 않지만 클론하다 보면 조금씩 감이 잡힐 것 같다.미션을 진행하면서 발전시킬 수 있는 부분도 많을 것 같은데 이번 주에는 시간을 거의 내지 못해 아쉬웠다. 다음 주에는 더 열심히 따라가고 싶다!

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

Yang HyeonBin

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

1주차 주요 내용강의 및 스터디 소개SupabaseNext.jsReact QueryRecoilTailwind CSSTodo List 프로젝트 들어가며Firebase와 Flutter Web으로 개발한 서비스를 Supabase와 React.js로 (기획을 보강하여) 다시 개발하는 작업을 진행 중이다. 클라이언트 상태 관리로는 Zustand, 서버 상태 관리로는 React Query를 사용하고 CSS는 Mantine 키트를 사용하고 있다. 새로운 기술 스택이 많아 삽질도 많이 하며 작업 속도가 안 나고 있던 중에, Supabase, React Query 그리고 궁금했던 Next.js를 사용하는 강의+스터디가 있음을 알게 되어 좋은 기회라고 생각해 참여했다. 일과 병행하느라 강의를 들을 시간을 많이 확보하진 못했지만, 진득하게 앉아서 해보니 재미있게 진행했다. 강의를 통해 새롭게 알게 된 것들 위주로 정리하고, 미션 수행에서 겪었던 어려움과 회고를 정리해보려 한다. 새롭게 알게 된 것들Next.js기본 개념, 특징리액트 기반의 서버사이드 렌더링 프레임워크SSR의 이점 - 서버에서 이미 렌더링한 HTML을 검색 봇(브라우저)&사용자에게 전달하는 것이기 때문에SEO에 유리: 모든 페이지의 html 마크업이 서버에서 생성 → 검색 엔진이 페이지 인식&색인하기 유리CSR(Client Side Rendering)이 SEO에 불리한 이유는: JavaScript가 실행되기 전에는 빈 HTML 파일에 불과하기 때문페이지 로딩 속도 개선HTTP API 구현 가능 - route.ts 파일에!route.ts 파일의 코드는 웹에서 접근 불가: 서버에서 돌아가는 코드이기 때문 ⇒ DB 접속 등 보안 중요한 코드 여기서 넣으면 더 안전browser api (window, navigation, 리액트 코드 등) 사용 불가 리액트와의 차이 - route 폴더명이 route와 일치 dynamic route도 가능 (폴더명을 app/movies/[id]와 같이 [] 이용)params, searchParams를 컴포넌트의 props로 받아서 컴포넌트 내에서 사용 가능폴더 내 파일의 역할이 정해져 있음page.tsx: 각 폴더(루트)의 대표 파일layout.tsx: 화면 레이아웃 정의하는 파일route.ts: HTTP API 구현하는 파일Link 태그:a 태그 대신 Link 태그 사용할 것을 권함 Server Side Redirect 말고 Client Side Routing이 동작하게 됨 Next.js가 프로젝트 안에 어떤 링크들이 있는지 수집하는 것을 도움서버 컴포넌트, 클라이언트 컴포넌트가 구분되어 존재함React Query개요데이터 fetching, caching, db와의 동기화를 돕는 클라이언트 라이브러리cf) 캐싱 = 데이터의 복사본을 저장해 동일한 데이터의 재접근 속도를 높이는 것장점페이지 이동할 때마다 다시 불러올 필요가 없는 데이터를 캐싱하여 사용 → 불필요한 API 콜 줄임클라이언트 데이터 (zustand, recoil, useState 등으로 관리)와 서버 데이터 (react query 등으로 관리) state 관리 명확히 분리 가능React Query - 관련 글 아카이브카카오 페이 테크React Query로 비동기 데이터를 처리하는 세가지 이유불필요한 코드의 감소 (<-> Redux의 보일러플레이트 코드)업무와 협업의 효율성을 위한 규격화된 방식 제공: useQuery, useMutation 사용 규칙이 정해져 있음사용자 경험 향상을 위한 다양한 Built-in 기능: 로딩, 에러 처리 등이 간단리액트 쿼리 개념잡기데이터의 상태최신 데이터는 fresh, 기존 데이터는 stalestaleTime은 데이터가 fresh → stale 상태로 변경되는 데 걸리는 시간, 기본값 0cacheTime은 데이터가 inactive한 상태일 때 캐싱된 상태로 남아있는 시간특정 컴포넌트가 unmount(페이지 전환 등으로 화면에서 사라질 때) 되면 사용된 데이터는 inactive상태로 바뀌고, 이때 데이터는 cacheTime만큼 유지cacheTime 이후 데이터는 가비지 콜렉터로 수집되어 메모리에서 해제cacheTime이 지나지 않았는데 해당 데이터를 사용하는 컴포넌트가 다시 mount되면, 새로운 데이터를 fetch해오는 동안 캐싱된 데이터를 보여줌React-query는 React의 ContextAPI를 기반으로 동작useQueries - Promise.all() 처럼 여러 useQuery를 한번에 실행, 배열을 리턴select 키워드로 raw data로부터 원하는 데이터 추출해 리턴하는 사용도 가능 import { useQuery } from 'react-query' function User() { const { data } = useQuery({ queryKey: ["user"], queryFn: fetchUser, select: user => user.username, }) return <div>Username: {data}</div> }+궁금해진 것 - React Query의 데이터 리패치 트리거 종류에는 무엇이 있는지?Recoil개요전역 상태 관리 라이브러리 ⇒ 여러 컴포넌트에서 하나의 상태를 공유/관리하는 상황에서 유용오픈 소스주요 개념atom.ts - 모든 state를 모아두는 파일 (store.ts와 유사)atom: 초기 기본 상태를 정의하는 함수selector: 기존 상태에서 파생된 상태 - 클라이언트단에서 특정 컴포넌트에 필요한 상태만 뽑아서 사용할 때 유용useRecoilState: 리액트의 useState와 유사한 형태로 사용useRecoilValue: 값을 set 할 필요는 없고 value만 필요할 경우에 사용Supabase 연동하기DB 데이터의 타입 자동 생성하기package.json의 "scripts" 부분에, "generate-types"로 시작하는 명령어를 추가, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "generate-types": "npx supabase gen types typescript --project-id [YOUR_PROJECT_ID] --schema public > types_db.ts" },project-id 뒤 [YOUR_PROJECT_ID] 부분에 프로젝트 아이디를 추가 ([] 없이 문자열 바로)프로젝트 아이디는 api settings의 url 부분에 https:// 와 .supabase 사이에 있는 문자열실행하기 위해서 supabase 로그인하기npx supabase login브라우저 열리며 코드 입력 과정이 진행됨!(안될 경우, supabase 먼저 설치하기)실행하기 - npm run generate-typestypes_db.ts 파일이 생성된 걸 확인할 수 있음!ANON KEY란?브라우저에서 동작할 때 누구나 사용할 수 있는 API 키 값 => 이 키를 사용할 땐 누구나 사용 가능하게 설정되어 있는 API들에만 접근 가능cf) SERVICE_ROLE은 서버쪽에서 사용하는 값, secret으로 유지해야 하므로 환경변수명에 public 포함하지 않을 것 미들웨어란?새로고침이나 API 호출을 하는 등의 Request를 하고 Response를 받는 과정 중간에 추가 작업을 넣고 싶거나 쿠키를 세팅하는 등의 중간 단계를 거치고 싶을 때 인터셉트를 할 수 있게 돕는 것 미션 수행깃허브 레포지토리 링크도 함께 첨부한다!어려움미션을 수행함에 있어서 예상치 못한 난제가 있었는데, Material Tailwind 라이브러리 세팅 과정에서 버전 이슈로 충돌이 나는지 무한히 무언가 실행되려고 하는 듯했다.Error: Maximum call stack size exceeded Call Stack 55 Hide 45 ignore-listed frames Array.forEach <anonymous> (0:0) ...강의에서 알려주신 대로 Next.js 프로젝트를 만들었는데도 tailwind.config.ts 파일이 기본으로 없어서 직접 추가했고, tailwind css를 import 구문에서 계속 찾지 못한다고 해 uninstall과 install을 몇번을 했는데도 해결되지 않았다. 결국 tailwind 4에서 3으로 다운그레이드도 시도해보고, 다른 분들이 올린 소스코드를 참고해 package.json을 수정하는 등 여러 방법을 시도했으나, 속시원히 해결하지 못했다.시간이 많이 남진 않았어서 Tailwind CSS만 사용해 스타일링을 진행하기로 결정했다. Supabase, React Query, Next.js 사용법을 익히는 것이 지금 당장 더 중요했기 때문이다. 아 그리고 잊고 있었는데 하나 더 있었다. 기본으로 사용하는 크롬 브라우저에 익스텐션을 여러 개 깔아뒀는데, 지금 보고 있는 화면의 문서를 건드리는 것들도 있어서 그런지 로컬호스트로 실행했을 때 Hydration 관련 오류도 났었다. 조금 헤매다가 익스텐션 때문일 수 있단 답을 얻고 아무런 설정 없는 계정으로 브라우저를 열어 개발했다. 회고수행하며 느낀 점도 정리해보겠다. 우선 FontAwesome을 오랜만에 다시 썼는데 굉장히 편리했다. 쉽게 임포트할 수 있어서 간단한 프로젝트에선 이것을 사용하는 게 좋겠다. 플러터 백그라운드여서 타입을 명시해 개발하는 걸 선호하는데, 타입스크립트에서 타입 명시하는 방식도 감을 잡아가고 있다. 그래서 강의 내용에서 보충해 타입을 추가해서 작업을 해보았다. 또 React Query 사용하는 코드들은 UI, Todo 컴포넌트와 분리하여 services 내에 따로 파일을 추가했고 각 컴포넌트에서 임포트하여 사용했다.export function updateTodoMutation() { return useMutation({ mutationFn: ({ todo }: { todo: TodoRowUpdate }) => updateTodo({ todo }), onSuccess: () => { // 모든 todos 쿼리 무효화 queryClient.invalidateQueries({ queryKey: ["todos"], }); }, onError: (error) => { console.error("Error updating todo:", error); throw error; }, }); }import { deleteTodoMutation, updateTodoMutation } from "services/todo_queries"; const updateTodo = updateTodoMutation(); const deleteTodo = deleteTodoMutation(); const handleUpdate = ({ newTitle, newCompleted, }: { newTitle?: string; newCompleted?: boolean; }) => { updateTodo.mutate( { todo: { id, title: newTitle ?? title, completed: newCompleted ?? completed, completed_at: newCompleted ? new Date().toISOString() : null, }, }, { onSuccess: () => { // updateTodoMutation의 onSuccess 콜백과 함께 실행할 함수 // 컴포넌트 내부 상태관리! setIsEditing(false); }, } ); };completed 값은, 클릭 이벤트 시점의 값으로 setState를 해주고 state 값으로 업데이트하려 했을 때 정확한 상태값이 저장되지 않아서 처리 코드를 보완해줬다.onChange={(e: React.ChangeEvent<HTMLInputElement>) => { const newCompleted = e.target.checked; // 정확한 값 업데이트를 위해 setCompleted(newCompleted); // promise를 반환하지 않아 await해도 작업 완료까지 기다리지 않음 handleUpdate({ newCompleted }); }}일을 하며 알아낸 방법들과 강의에서 소개해준 방법들을 조합하여 간단한 프로젝트를 진행하는 거라 재미있었다. 초기 Material Tailwind 세팅에서 헤맸던 순간은 좀 고통스러웠지만.. 추가 미션인 completed_at을 저장하는 것은 위에 첨부한 코드 스니펫에서 보이는데, 서버에 저장할 completed의 값이 true이면 그 시간 정보를, false이면 null을 저장하도록 했다.투두 추가한 시간 보여주는 것도 간단하게 세로로 배치했다.<div className="flex-1"> <p className={`text-gray-800 ${ completed ? "line-through" : "" }`}> {title} </p> {todo.created_at && ( <span className="text-xs text-gray-400"> <i className="fas fa-clock mr-1" /> {new Date(todo.created_at).toLocaleString()} </span> )} </div>사실 우리 서비스가 Todo를 다루는 거라 굉장히 익숙한 주제여서 더 재밌게 했던 것 같다. 다음 주부터는 모르는 개념이 더 많을텐데, 화이팅해서 완주하고 많이 배워가고 싶다. 화이팅.

풀스택워밍업클럽ReactQueryNext.jssupabase

김진현

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

Next.js 기본 개념 정리 (Part 1)Next.js란?“개인이 풀스택 개발을 하기에 최적화된 웹 프레임워크”서버사이드 렌더링(SSR) 지원 → SEO(검색 최적화) 강점별도 서버 구축 없이 API까지 개발 가능Next.js 프로젝트 생성npx create-next-app@latest [프로젝트명] 폴더 구조 및 주요 파일폴더 구조 = Route(URL) 구조와 동일app/movies → /moviesapp/movies/[id] → /movies/1 (Dynamic Route)주요 파일page.tsx(js): 해당 경로의 페이지 컴포넌트layout.tsx(js): 페이지 레이아웃 관리route.ts(js): API 서버 역할 (GET, POST, PUT, DELETE 등 구현 가능)Link 사용a 태그 대신 Link 컴포넌트 사용 권장 (클라이언트 사이드 라우팅 최적화)import Link from 'next/link'; export default function Page() { return <Link href="/dashboard">Dashboard</Link>; } Next.js Part 2 - Metadata & Server ActionServer Action이란?Next.js에서 백엔드 API 없이 서버에서 직접 데이터 처리 가능기존 fetch + REST API 방식보다 간단하고 빠름Server Action 사용 예시유저 검색 기능을 구현할 때 두 가지 방식 비교기존 방식 → fetch + REST API개선 방식 → Server Action 활용Metadata (SEO 및 공유성 강화)Next.js는 SEO 및 링크 미리보기를 위해 메타데이터 API 제공Static Metadata (정적 메타데이터 설정)import type { Metadata } from 'next' export const metadata: Metadata = { title: '페이지 제목', description: '페이지 설명', } export default function Page() {} Dynamic Metadata (동적 메타데이터 설정)import type { Metadata, ResolvingMetadata } from 'next' type Props = { params: { id: string } searchParams: { [key: string]: string | string[] | undefined } } export async function generateMetadata( { params }: Props, parent: ResolvingMetadata ): Promise<Metadata> { const id = params.id const product = await fetch(`https://.../${id}`).then((res) => res.json()) const previousImages = (await parent).openGraph?.images || [] return { title: product.title, openGraph: { images: ['/some-specific-page-image.jpg', ...previousImages], }, } } export default function Page({ params, searchParams }: Props) {} Next.js Part 3 - Supabase 프로젝트 생성1. Supabase 가입 및 프로젝트 설정Supabase 회원가입 후 대시보드 접속새 프로젝트 생성 → 프로젝트명: inflearn-supabase-projects테이블 생성 (todo 테이블)title (text)completed (boolean)created_at (timestampz)updated_at (timestampz)2. .env 파일 설정NEXT_PUBLIC_SUPABASE_URL=https://[project_id].supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=[anon_key] NEXT_SUPABASE_SERVICE_ROLE=[service_role_key] NEXT_SUPABASE_DB_PASSWORD=[db_password] 3. package.json 수정 (타입 자동 생성 스크립트 추가)"scripts": { "generate-types": "npx supabase gen types typescript --project-id [project_id] --schema public > types_db.ts" } 4. Supabase CLI 설치 및 로그인npx supabase loginNext.js Part 4 - 필수 라이브러리 설정 (React Query, Supabase)1. 필수 라이브러리 설치npm i @supabase/ssr @tanstack/react-query 2. React 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> ); } 3. Supabase 설정환경 변수 설정 (.env)NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_SUPABASE_SERVICE_ROLE= NEXT_SUPABASE_DB_PASSWORD= utils/supabase/client.ts (브라우저용 Supabase 클라이언트)"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 (서버용 Supabase 클라이언트)"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) { // 서버 컴포넌트에서 set 호출 시 무시 } }, remove(name: string, options: CookieOptions) { try { cookieStore.set({ name, value: "", ...options }); } catch (error) { // 서버 컴포넌트에서 delete 호출 시 무시 } }, }, } ); }; export const createServerSupabaseAdminClient = async ( cookieStore: ReturnType<typeof cookies> = cookies() ) => { return createServerSupabaseClient(cookieStore, true); }; 4. Middleware 설정app/middleware.tsimport { createServerClient, type CookieOptions } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; export const applyMiddlewareSupabaseClient = async (request: NextRequest) => { 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) { request.cookies.set({ name, value, ...options }); response = NextResponse.next({ request: { headers: request.headers } }); response.cookies.set({ name, value, ...options }); }, remove(name: string, options: CookieOptions) { request.cookies.set({ name, value: "", ...options }); response = NextResponse.next({ request: { headers: request.headers } }); response.cookies.set({ name, value: "", ...options }); }, }, } ); await supabase.auth.getUser(); return response; }; export async function middleware(request: NextRequest) { return await applyMiddlewareSupabaseClient(request); } export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], };Part 5. 할일 CRUD 기능 구현 (feat. Server Action)1. CRUD Server Action 만들기server/actions/todo.ts"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: any) { 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; }Part 6. 추가 과제 - 할 일의 생성 및 업데이트 시간 표시기능 추가: 할 일 목록에 created_at(생성 시간)과 updated_at(업데이트 시간) 표시{setCreated_at ? ( <p className="flex-1">created_at: {formatDate(created_at)}</p> ) : ( <p className="flex-1">created_at: {formatDate(created_at)}</p> )} <p className="flex-1">updated_at: {formatDate(todo.updated_at)}</p> 업데이트 내용created_at: 할 일이 처음 생성된 시간 표시updated_at: 할 일이 수정된 최신 시간 표시

풀스택Next.jsReactQueryRecoilTailwindCSSSupabase

LC-02s

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

학습 내용 요약인프런 워밍업 클럽 3기 풀스택 스터디 1주차에는 해당 강의에서 사용될 라이브러리 및 프레임워크에 대한 소개와 사용법 위주로 다루었습니다. 인프런에서 발자국을 작성할 때 강의를 보지 않고도 강의 내용을 파악할 수 있을 만큼 자세한 내용을 작성하는 건 지양해 달라고 가이드한 만큼 강의에 대한 필기는 최소화 하고 회고 위주로 적어보겠습니다. 강의의 목적인 Next.js와 Supabase를 사용해서 서비스를 만들어보는 것에 집중하여 스타일링이나 기타 상태관리 라이브러리에 대한 내용은 작성하지 않았습니다. Next.js 간단 정리Next.js란?개인이 풀스택 개발을 하기에 최적화 된 웹 프레임워크서버사이드 렌더링이 큰 특징이며, React와 비슷한 문법으로 더 현대적인 웹 서비스를 구현할 수 있음서버에서부터 HTML을 최적화해서 웹으로 내려주기 때문에 SEO에 도움이 됨자체적으로 API 구축이 가능하기 때문에 사이즈가 크지않은 개인 프로젝트 정도의 규모라면 별도로 서버구축을 할 필요 없이 Next.js만으로 서버 구축까지 전부 가능함Server Actions서버에서 실행되는 비동기 함수 생성 기능"use server" 지시어를 파일 최상단에 선언하면 해당 파일 내 모든 함수가 Server Action으로 간주됨서버 및 클라이언트 컴포넌트에서 호출 가능주로 폼 제출 및 데이터 변경(뮤테이션)에 사용내부적으로 POST 요청을 사용하며, 인자 및 반환값은 React에서 직렬화 가능해야 함 Supabase 간단 정리Supabase 란?PostgreSQL 기반의 오픈소스 백엔드 서비스로, 개발자들이 손쉽게 데이터베이스, 인증, 스토리지, 서버리스 함수 등을 활용할 수 있도록 지원하는 플랫폼상용 서비스인 Firebase의 대안으로 자주 언급됨Supabase 장점오픈소스 프로젝트 (자체 서버 구축 가능)PostgreSQL 기반 (관계형 DB 장점을 살릴 수 있다)Firebase 대비 저렴다양한 연동 방식 지원 (+ GraphQL, API, SDK, DB Connection)Supabase 단점아직 성숙하지 않은 커뮤니티 기반비교적 적은 기능들, 적은 서비스 연동 지원부족한 문서화, 한글 문서 부족Firebase보다 높은 러닝커브  Todo List 미션 회고풀스택 스터디 1주차 미션은 강의에서 진행하는 Next.js와 Supabase를 활용한 Todo List 앱에 기능을 더 추가해보는 것이었습니다. 진행했던 미션은 해당 링크에서 보실 수 있습니다. 미션 수행 내용아래는 제가 수행한 미션에 대한 내용과 함께 고민했던 점과 아쉬웠던 점을 정리해보았습니다. 기능 명세할 일 목록 조회 기능키워드 검색 기능할 일 수정 기능내용 변경 기능완료 여부 변경 기능할 일 삭제 기능정리해보면 위의 기능을 Next.js와 Supabase를 활용하여 구현하는 것이었습니다. 강의에서는 Material Tailwind 라이브러리를 사용했지만 저는 기존에 즐겨썼었던 TailWindCSS와 최근 관심가지게된 Mantine 라이브러리를 사용하여 UI를 구축하였고, 4주차 모두 동일한 환경으로 진행되는 것 같아 Turbo Repo와 pnpm workspace를 활용한 모노레포 환경을 구축해두고 프리셋을 관리하였습니다. 상세한 내용은 아래에 정리해두었습니다. 사용 기술프레임워크: Next.js v15, React v19데이터베이스: Supabase서버 상태관리: Tanstack Query v5클라이언트 상태관리: Zustand v5스타일 프레임워크: TailWindCSS v3, Mantine v7모노레포: Turbo Repo패키지 매니저: pnpm 강의에서는 TypeScript를 사용했지만 별도의 인터페이스를 작성하지는 않았는데, 저의 최근 관심사가 환경(라이브러리) 변화에 유연한 도메인 로직 작성하기여서 저는 아래와 같이 별도로 인터페이스를 작성 후 서버 액션을 사용하는 로직을 인터페이스에 맞추어 작성해 보았습니다. export interface Todo { id: number title: string createdAt: string updatedAt: string completed: boolean completedAt: string | null }export interface GetTodoListParams { query?: string } export interface GetTodoList { (params: GetTodoListParams): Promise<Todo[]> }export type CreateTodoParams = Pick<Todo, 'title'> export interface CreateTodo { (params: CreateTodoParams): Promise<void> }export type UpdateTodoParams = Pick<Todo, 'id' | 'title' | 'completed'> export interface UpdateTodo { (params: UpdateTodoParams): Promise<void> }export type DeleteTodoParams = Pick<Todo, 'id'> export interface DeleteTodo { (params: DeleteTodoParams): Promise<void> } 기존에는 서버 액션에 대한 이해가 부족했어서 항상 Next.js를 사용하며 Route Handler로 별도의 API를 만들어서 작업했었는데 별도의 API 엔드포인트를 관리하지 않고 서버 액션으로 모두 구축해볼 수 있었습니다. 이번에는 강의에서 진행한 것과 같이 GET 요청에도 서버 액션을 사용했었는데 개인적으로 관련 내용에 대한 고민을 조금 했었습니다. Next.js의 공식 문서에서는 일반적으로 서버 액션을 데이터 변경(뮤테이션) 로직에만 사용하는 것으로 소개되었기도 했고, 기본적으로 POST 메서드를 사용한다고 했었는데, 이렇게 GET, PUT, DELETE 메서드를 사용해야하는 로직도 모두 POST로 통합해도 되는가라는 의문이 하나 있었고, GET 요청에도 서버 액션을 사용하니 생긴 문제인데 Next.js에서의 캐싱 전략을 어떻게 사용할 수 있을까에 대한 의문이 있었습니다. 강사님께서 제시하는 생산성 높은 풀스택 개발 방법론은 Supabase로 백엔드 필요 기능을 쉽고 빠르게 구축하고 Next.js의 서버 액션을 활용하여 별도의 API를 관리하는 절차를 건너뛰는 것이라고 이해했습니다. 저도 1인 풀스택 개발에 뜻이 있는 입장에서 되게 괜찮은 방법론이라 생각했는데, 해당 의문은 이로인해 근본적으로 서버 액션이라는 기술의 활용 범위를 넓힘으로 인해 발생했다고 생각합니다. 첫 번째 의문은 Next.js의 특수성으로 미루어 보아 문제가 없다고 결론지었습니다. 서버 액션이라는 개념 자체가 일반적으로 REST API를 설계하는데 사용되는 개념이 아니기도 하고, 애초에 엔드포인트 자체도 루트 경로로 받기 때문에 의미가 없는 것이죠. 프레임워크가 관리해주기 때문에 메서드와 엔드포인트로 작업 단위를 표현하지 않아도 괜찮다고 생각했습니다. 두 번째는 의문이라기 보다는 문제였죠. 저는 해당 문제를 해결하기 위해 Tanstack Query를 사용했습니다만 근본적인 해결은 아니었습니다. HydrationBoundary 와 prefetchQuery를 활용하여 SSR과 서버 컴포넌트 환경을 통합하였고, useQuery 훅과 Query Key Factor 방식을 사용하여 캐싱 전략 관리를 시도했는데, 다 만들고 보니 이건 클라이언트에 해당하는 캐싱 전략이었어요. 서버 쪽에서는 적절한 캐싱 전략이 없었습니다. 해당 문제는 공식 문서를 찾아보니 라우트 세그먼트의 설정을 상속받아 maxDuration 같은 설정을 따른다고 안내되고 있었습니다. 적용해보지는 못했지만 page.tsx에서 결정할 수 있는 것 같아 아마 다음 미션에서 기회가 된다면 사용해볼 예정입니다. 벌써 다음 미션이 기대되네요. 긴글 읽어주셔서 감사합니다. ☺

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

채널톡 아이콘