[인프런 워밍업 클럽 3기 풀스택] 2주차 발자국
[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) - 로펀 2주차는 Supabase Storage를 어떻게 사용하는지 학습했다. Supabase Storage 설정Supabase > DashBoard > Project > Storage버킷 생성하기Name of Bucket: 버킷 이름Public bucket: trueAdditional configuration>Allowed MIME types: image/* (이미지만 허용)Policies>For Full customizationPolicy name: 정책 이름Allowed operation: 누구나 CRUD를 할 수 있기 때문에 모두 허용Target roles: anon으로 누구나 허용Review 클릭>Save policy 클릭CRUD 각각에 대한 policies 생성server action에서 storage 접근하기export async function uploadFile(formData: FormData) { const supabase = await createServerSupabaseClient(); const files = Array.from(formData.entries()).map( ([name, file]) => file as File ); 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; }Server Action을 사용하여 Supabase Storage에 파일을 업로드하는 함수이다. FormData를 받아 파일을 읽고, Supabase Storage에 업로드하는 방식으로 동작한다. const supabase = await createServerSupabaseClient();서버 측에서 Supabase 클라이언트를 생성하여 Storage API를 사용할 수 있도록 한다. const files = Array.from(formData.entries()).map( ([name, file]) => file as File );FormData에서 모든 항목을 배열로 추출하여 File 객체로 변환하여 파일을 추출한다.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;Promise.all()을 사용하여 여러 개의 파일을 동시에 업로드한다.supabase.storage.from(bucket).upload(filename, file, options)을 통해 지정한 버킷에 파일을 업로드한다.{ upsert: true }: 동일한 이름의 파일이 있을 경우 덮어쓴다. (insert + update) 업로드 된 결과를 반환한다. 2주차 미션github: https://github.com/thayoon/nextjs-supabase-dropbox-cloneDropbox Clone 프로젝트에 파일의 마지막 수정(업로드) 시간을 표시하는 기능을 추가하세요.파일 목록에서 각 파일의 “마지막 수정 시간”을 표시📌 참고 문서: Supabase Storage - 파일 목록 가져오기미션 해결 방법:list() 응답값 확인Supabase의 list() 함수를 사용하면 파일 정보를 가져올 수 있다.참고 문서에서 확인한 응답값은 다음과 같다:{ "data": [ { "name": "avatar1.png", "id": "e668cf7f-821b-4a2f-9dce-7dfa5dd1cfd2", "updated_at": "2024-05-22T23:06:05.580Z", "created_at": "2024-05-22T23:04:34.443Z", "last_accessed_at": "2024-05-22T23:04:34.443Z", "metadata": { "eTag": "\"c5e8c553235d9af30ef4f6e280790b92\"", "size": 32175, "mimetype": "image/png", "cacheControl": "max-age=3600", "lastModified": "2024-05-22T23:06:05.574Z", "contentLength": 32175, "httpStatusCode": 200 } } ], "error": null }이 중에서 updated_at이 파일의 마지막 수정 시간을 나타낸다. Server Action에서 list() 호출 및 데이터 반환actions/storageActions.tsexport async function searchFiles(search: string = "") { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { sortBy: { column: "updated_at", order: "desc" }, search, }); handleError(error); return data; }파일 목록을 가져오는 searchFiles() 함수를 구현한다.참고 문서를 통해 sortBy 옵션을 적용하여 updated_at 을 기준으로 내림차순 정렬하여 최신 파일이 먼저 오도록 설정한다. 클라이언트 컴포넌트에서 데이터 가져오기components/dropbox-image-list.tsx "use client"; import { useQuery, useMutation } from "@tanstack/react-query"; export default function DropboxImageList({ searchInput }) { const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); return ( <section className="grid lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-2"> {searchImageQuery.isLoading && <Spinner />} {searchImageQuery.data && searchImageQuery.data.map((image) => ( <DropboxImage key={image.id} image={image} /> )} </section> ); } useQuery를 사용해 서버에서 데이터를 가져온다.가져온 데이터를 DropboxImage 컴포넌트로 전달한다. 마지막 수정 시간 표시components/dropbox-images.tsx"use client"; import { IconButton, Spinner, Checkbox } from "@material-tailwind/react"; import { getImageUrl } from "utils/supabase/storage"; export default function DropboxImage({ image }) { // 마지막 수정 시간 한국 시간 변환 const updated = new Date(image.updated_at) .toLocaleString("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Seoul", }) .replace(",", ""); return ( <div className="relative w-full flex flex-col gap-2 p-4 border border-gray-100 rounded-2xl shadow-md"> {/* Image */} {/* fileName */} {/* update time */} <p className="flex justify-end text-xs text-gray-500"> 마지막 수정: {updated} </p> {/* trash Button */} </div> ); } updated_at 값을 toLocaleString()을 사용해 한국 시간으로 변환하고 화면에 표시한다.추가 구현 사항긴 파일명 생략 표시<div className="truncate">{image.name}</div>className에 truncate를 적용하여 긴 파일명을 한 줄로 표시하고 넘칠 경우 "..."으로 생략한다. 사진 업로드 오름차순/내림차순 정렬처음에는 사용자의 정렬 방식 선택에 따라 서버에서 데이터를 다시 호출하도록 구현했지만, 비효율적이라고 판단하여 클라이언트에서 정렬을 처리하는 방식으로 변경했다.1차 시도 - 서버에서 정렬된 데이터 요청actions/storageAction.tsexport async function searchFiles(search: string = "", isLatest) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { sortBy: { column: "updated_at", order: isLatest ? "desc" : "asc" }, search, }); handleError(error); return data; } isLatest 값에 따라 정렬 순서를 desc(최신순) 또는 asc(오래된순)으로 설정사용자가 정렬 방식을 변경할 때마다 서버 요청이 발생하여 비효율적이다.2차 시도 - 클라이언트에서 정렬 처리components/dropbox-image-list.tsx"use client"; import { Spinner, Menu, MenuHandler, MenuList, MenuItem, Button, Typography, } from "@material-tailwind/react"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; const sortMenu = [ { title: "최신순", isLatest: true }, { title: "오래된순", isLatest: false }, ]; export default function DropboxImageList({ searchInput }) { const [openMenu, setOpenMenu] = useState(false); const [isLatest, setIsLatest] = useState(true); const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); return ( <div> {/* 정렬 버튼 */} <div className="flex flex-row-reverse"> <Menu open={openMenu} handler={setOpenMenu} allowHover> <MenuHandler> <Button variant="text" className="flex items-center gap-1 align-middle text-base font-normal capitalize tracking-normal" > {isLatest ? "최신순" : "오래된순"} <i className={`fas fa-angle-down transition-transform ${ openMenu ? "rotate-180" : "" }`} /> </Button> </MenuHandler> <MenuList className="hidden gap-3 overflow-visible lg:grid"> <ul className="flex w-full flex-col gap-1"> {sortMenu.map(({ title, isLatest }) => ( <MenuItem key={title} onClick={() => setIsLatest(isLatest)}> <Typography variant="h6" color="blue-gray" className="mb-1"> {title} </Typography> </MenuItem> ))} </ul> </MenuList> </Menu> </div> {/* 이미지 리스트 */} <section className="grid lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-2"> {searchImageQuery.isLoading && <Spinner />} {searchImageQuery.data && (isLatest ? searchImageQuery.data.map((image) => ( <DropboxImage key={image.id} image={image} /> )) : searchImageQuery.data .slice() .reverse() .map((image) => <DropboxImage key={image.id} image={image} />))} </section> </div> ); }서버 요청은 기본적으로 최신순으로 설정하고, 클라이언트에서 데이터를 reverse()하여 정렬을 변경하는 방식으로 개선했다. 사진 다중 삭제사진을 다중 선택하여 삭제하는 기능은 체크박스를 활용해 구현했다.사용자는 "전체 선택" 및 "선택 삭제" 기능을 통해 한 번에 여러 사진을 삭제할 수 있다.components/dropbox-image-list.tsx "use client"; export default function DropboxImageList({ searchInput }) { // ... const [allSelected, setAllSelected] = useState(false); const [isSelected, setIsSelected] = useState([]); const searchImageQuery = useQuery({ queryKey: ["images", searchInput], queryFn: () => searchFiles(searchInput), }); const deleteFileMutation = useMutation({ mutationFn: deleteFile, onSuccess: () => { searchImageQuery.refetch(); }, }); function handleChecked(isChecked) { setAllSelected(isChecked); if (isChecked && searchImageQuery.data) { setIsSelected(searchImageQuery.data.map((image) => image.name)); } else { setIsSelected([]); } } return ( <div role="section"> <div className="flex justify-between"> <div className="flex justify-center items-center gap-3"> <Checkbox color="blue" label={ <Typography> 전체 선택 ({isSelected.length}/ {searchImageQuery.data && searchImageQuery.data.length} {!searchImageQuery.data && 0}) </Typography> } checked={allSelected} onChange={(e) => handleChecked(e.target.checked)} /> <Button className="rounded-full" size="sm" variant="outlined" color={isSelected.length > 0 ? "blue" : "gray"} disabled={isSelected.length > 0 ? false : true} onClick={() => { setIsSelected([]); setAllSelected(false); deleteFileMutation.mutate(isSelected); }} > {deleteFileMutation.isPending ? <Spinner /> : "선택 삭제"} </Button> </div> // ... </div> <section className="grid lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-2"> {searchImageQuery.isLoading && <Spinner />} {searchImageQuery.data && searchImageQuery.data.map((image) => ( <DropboxImage key={image.id} image={image} isSelected={isSelected} setIsSelected={setIsSelected} setAllSelected={setAllSelected} totalLength={searchImageQuery.data.length} /> ))} </section> </div> ); } 전체 선택: 사용자가 "전체 선택" 체크박스를 클릭하면, 모든 이미지가 선택된다.선택된 이미지 수와 총 이미지 수가 표시된다.선택 삭제: 사용자가 선택한 이미지들을 삭제할 수 있는 "선택 삭제" 버튼을 제공한다. 이미지가 선택되었을 때만 활성화된다.상태 관리: isSelected 배열에 선택된 이미지의 이름을 저장한다. allSelected 상태로 전체 선택 여부를 관리한다.선택 삭제 버튼을 클릭하면 deleteFileMutation을 호출하여 isSelected을 전달하여 삭제를 처리한다.삭제 작업이 완료되면 searchImageQuery.refetch()를 호출하여 이미지 리스트를 최신 상태로 갱신한다.components/dropbox-images.tsx"use client"; export default function DropboxImage({ image, isSelected, setIsSelected, setAllSelected, totalLength, }) { const isChecked = isSelected.includes(image.name); const handleChecked = (checked) => { setIsSelected((prev) => { if (checked) { const newSelected = [...prev, image.name]; if (newSelected.length === totalLength) setAllSelected(true); return newSelected; } else { setAllSelected(false); return prev.filter((item) => item !== image.name); } }); }; // ... return ( <div className="relative w-full flex flex-col gap-2 p-4 border border-gray-100 rounded-2xl shadow-md"> {/* Image */} {/* fileName */} {/* update time */} {/* multiple checkBox */} <div className="absolute top-4 left-4"> <Checkbox color="blue" className="border-2 border-white bg-white/30 checked:border-white checked:bg-blue-500" checked={isChecked} onChange={(e) => handleChecked(e.target.checked)} /> </div> {/* trash Button */} </div> ); } 개별 체크박스: 각 이미지에 대해 체크박스를 제공하고 사용자가 선택한 이미지를 isSelected 배열에 추가하거나 제거한다.상태 변화: 체크박스를 클릭하면 해당 이미지가 선택되거나 선택이 해제되고 선택된 모든 이미지가 삭제될 때 "전체 선택" 체크박스도 자동으로 갱신된다. 상태 연동: isChecked 개별 이미지에 체크박스의 체크 여부를 결정한다.isSelected 배열에 현재 이미지의 name 값이 포함되어 있는지 true, false로 설정한다.개별 이미지의 체크 여부가 isSelected 상태와 동기화된다.actions/storageActions.tsexport 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; } supabase.storage.from(bucket).remove(['filename'])을 통해 지정한 버킷에서 배열에 포함된 모든 파일을 삭제 요청한다. 2주차 회고☀이번주는 강의를 들으며 여러 추가 기능이 생각나 실습하는 과정이 더욱 즐거웠다.다른 러너분이 한글 파일명 업로드 오류를 찾아내고 해결하는 모습을 보고 대단하다고 느꼈다.실습에서 영어 파일명으로만 업로드 했기 때문에 이런 오류가 발생하는지 몰랐다.상황을 공유해주신 러너분 덕분에 새로운 사실을 알게 됐다. (감사합니다!)다음에 시간이 난다면 강사님께서 말씀하신 해결 방법을 적용해 보고 싶다.목요일에 중간 점검 시간에 QnA 시간을 가졌는데, 정말 도움이 많이 됐다.특히, 포트폴리오 작성 요령과 개발자로서 필요한 역량을 채우는 방법을 핵심적으로 짚어주셔서 큰 도움이 되었다.나만의 특색을 찾고 포트폴리오에 잘 정리해 봐야겠다 느꼈다.벌써 2주차가 끝났는데, 배포까지 빠르게 진행해 보고 싶다. 다음주도 화이팅!