블로그
전체 32025. 03. 23.
0
[인프런 워밍업 클럽 Full Stack 3기] 3주차
1. 주요 학습 내용1.1. generageMetadata- App Router에서 페이지별 메타데이터를 동적으로 생성하기 위한 비동기 함수1.1.1. 사용할 만한 메타데이터 객체 키와 타입interface MovieMetadata { title: string description: string keywords: string[] authors: { name: string, url: string }[] openGraph: { title: string description: string url: string siteName: string images: string[] locale: string type: string } robots: { index: boolean follow: Boolean } }1.2. useInfiniteQuery무한 스크롤이나 "더 보기" 버튼과 같은 페이지네이션 UI를 구현할 때 사용하는 TanStack Query의 특수 훅1.2.1. 주요 매개변수1. queryKey: 쿼리 식별자 (캐싱 키)2. queryFn: 데이터를 가져오는 함수3. initialPageParam: 첫 페이지 파라미터4. getNextPageParam: 다음 페이지 파라미터 계산 함수5. getPreviousPageParam: 이전 페이지 파라미터 계산 함수 (양방향 페이지네이션) 2. 미션2.1. 사용자가 특정 영화를 "찜" 할 수 있도록 Supabase를 활용해 즐겨찾기 리스트 구현우선 이 넷플릭스의 사용자는 나 한사람만 있다 가정하고 작업을 진행했습니다.이를 기반으로 movie 테이블엔 is_favorite 컬럼을 추가하고 타입은 boolean으로 설정했습니다.찜하기 버튼은 토글 방식의 하트 모양 버튼(❤/🤍)으로 직관적인 UI를 만들고자 했습니다. 이 버튼을 영화 목록 페이지와 상세 페이지 모두에서 사용할 수 있게 포스터 우측 상단에 배치했습니다. 사용자 경험을 개선하기 위해 Sonner 라이브러리를 활용한 토스트 메시지도 추가했습니다.2.1.1. 미션 진행 중 고민사항영화 목록 페이지에서 찜하기 버튼을 배치할 때 두 가지 UX 부분 고민이 있었습니다.클릭 영역 분리: Link 컴포넌트와 찜하기 버튼의 클릭 영역이 겹치는 문제를 z-index를 활용해 해결했습니다.상태 갱신 전략: 찜하기 버튼 클릭 시 전체 목록을 갱신할지, 해당 버튼의 상태만 갱신할지 고민했습니다. 전체 목록을 갱신하게 되면 사용자 입장에선 스크롤을 다시 내려봐야 하는 불편함이 클 것 같아 개별 버튼만 갱신하는 방식을 선택했습니다.2.2. 찜한 영화를 리스트 화면의 최상단으로 보여주도록 정렬영화 목록을 요청하는 서버 액션 코드엔 찜 여부: 내림차순, id: 오름차순으로 정렬된 결과를 받을 수 있게 order 메서드를 추가했습니다.// actions/movieAction.ts // (일부 코드는 '...'으로 생략했습니다.) export const searchMovies = async ({ search, page, pageSize }: searchMoviesProps) => { console.log({ search, page, pageSize }); const supabase = await createServerSupabaseClient(); const { data, count, error } = await supabase .from("movie") .select("*") .ilike("title", %${search}%) .order("is_favorite", { ascending: false }) .order("id", { ascending: true }) .range((page - 1) pageSize, page pageSize - 1); // ... }); 3. 추가 작업사항3.1. 영화 검색 시 대소문자 구분 없이 가능하게 수정영화 검색을 몇 번 사용해보니 대소문자를 구분하여 검색이 되고 있는 것을 발견했습니다. 대부분의 사용자라면 해당 기능은 불편함을 느낄거라 생각되어 영화 목록을 요청하는 서버 액션 코드에서 사용되던 like 메서드를 ilike 메서드로 교체하여 대소문자 구분 없이 영화 검색을 할 수 있게 만들었습니다. (적용 코드는 앞서 소개된 2.2. 항목을 참조해 주세요.)3.2. Image 컴포넌트 사용img 태그를 사용하니 경고 문구가 표시되서 next.js에서 권장하는 Image 컴포넌트를 사용하여 영화 포스터를 표시했습니다.Image 컴포넌트는 기본적으로 몇몇 css 속성이 미리 지정되어 있어서 img 태그와 다르게 이미지 크기를 설정해야 했습니다.// /components/MovieCard/MovieCardItem.tsx // (일부 코드는 '...'으로 생략했습니다.) 'use client'; // ... export default function MovieCardItem({ movie: { id, image_url, title, overview, popularity, release_date, vote_average, is_favorite }, }: { movie: Movie; }) { // ... return ( {title} // ... ); }외부 도메인에서 이미지를 가져와서 표시할 경우 next.config.mjs에 도메인 설정이 필요하여 추가 작업을 진행했습니다.// next.config.mjs /** @type {import('next').NextConfig} */ const nextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'image.tmdb.org', port: '', pathname: '/t/p/w500/**', }, ], }, }; export default nextConfig;3.2.1. Image 컴포넌트의 특징- 자동 이미지 최적화: WebP, AVIF와 같은 최신 포맷으로 변환- 레이아웃 이동 방지: CLS(Cumulative Layout Shift) 개선- 지연 로딩(Lazy Loading): 화면에 이미지가 나타날 때만 로드- 자동 크기 조정: 다양한 디바이스에 맞는 최적 크기 생성- 이미지 CDN 지원: 다양한 이미지 CDN과의 호환성3.3. custom hooks 추가- 이번 미션의 찜하기 기능은 영화 목록과 상세 페이지에서 사용하는 기능입니다.- 동일한 기능이 두 곳의 페이지에서 사용되기 때문에 찜하기 기능을 hooks로 만들어서 코드 중복을 최소화하고 역할 별로 코드를 구분했습니다.찜하기 토글 hooks// /hooks/useToggleFavorite.ts "use client"; import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toggleFavoriteMovie } from "@/actions/movieAction"; import { toast } from "sonner"; interface UseToggleFavoriteProps { id: number; title: string; initialIsFavorite: boolean; } interface UseToggleFavoriteReturn { isFavorite: boolean; toggleFavoriteMovieMutation: () => void; isLoading: boolean; } export default function useToggleFavorite({ id, title, initialIsFavorite, }: UseToggleFavoriteProps): UseToggleFavoriteReturn { const [isFavorite, setFavorite] = useState(initialIsFavorite); // const queryClient = useQueryClient() const mutation = useMutation({ mutationFn: () => toggleFavoriteMovie(id, initialIsFavorite), onSuccess: () => { const newState = !isFavorite; setFavorite(newState); const message = newState ? `${title}을 즐겨찾기에 추가했습니다` : `${title}을 즐겨찾기에서 제거했습니다.`; toast.success(message); // queryClient.invalidateQueries({ queryKey: ["movies"] }) }, onError: (error) => { toast.error("찜하기 처리 중 오류가 발생했습니다."); console.error("찜하기 오류:", error); }, }); function toggleFavoriteMovieMutation() { mutation.mutate(); } return { isFavorite, toggleFavoriteMovieMutation, isLoading: mutation.isPending, }; }hooks 구분 후 영화 목록 아이템 컴포넌트 코드// /components/MovieCard/MovieCardItem.tsx // (일부 코드는 '...'으로 생략했습니다.) 'use client'; // ... export default function MovieCardItem({ movie: { id, image_url, title, overview, popularity, release_date, vote_average, is_favorite }, }: { movie: Movie; }) { // 찜하기 토글 기능 hooks 호출 const { isFavorite, toggleFavoriteMovieMutation, isLoading } = useToggleFavorite({ id, title, initialIsFavorite: is_favorite, }); return ( // ... // 찜하기 컴포턴트 시작 // 찜하기 컴포턴트 끝 );3.4. custom component 추가 (찜하기 토글 버튼)찜하기 버튼을 재사용 가능한 컴포넌트로 분리했습니다. 이 과정에서 TypeScript의 ButtonHTMLAttributes를 활용해 타입 확장하는 방법을 배웠습니다."use client"; import { ButtonHTMLAttributes } from "react"; interface FavoriteButtonProps extends ButtonHTMLAttributes { isFavorite: boolean; } export default function FavoriteButton({ isFavorite, className = "absolute top-2 right-2 z-10 text-lg", ...props }: FavoriteButtonProps) { return ( {isFavorite ? "❤️" : "🤍"} ); }3.5. 폴더 구조 작업스터디 1주차때 거의 못했던 폴더 구조 개편 작업을 조금 더 진행해 보았습니다.위에서 먼저 언급됐던 hooks 폴더가 추가됐고 컴포넌트 중 서로 연관이 있는 MovieCardList와 MovieCardItem은 MovieCard 라는 폴더로 합쳐서 서로 연관 있는 컴포넌트라는 것을 강조했습니다. 그리고 header, footer처럼 여러 컴포넌트로 이루어진 영역들을 모은 containers 폴더를 만들어서 구별했습니다.nextjs-netflix-with-supabase/ ├── app/ │ └── movies/ │ └── [id]/ │ └── page.tsx # 영화 상세 페이지 (동적 라우트) │ ├── components/ │ ├── MovieCard/ │ │ ├── MovieCardItem.tsx # 영화 카드 개별 아이템 컴포넌트 │ │ └── MovieCardList.tsx # 영화 카드 목록 컴포넌트 │ ├── FavoriteButton.tsx # 찜하기 버튼 컴포넌트 │ └── Logo.tsx # 로고 컴포넌트 │ ├── config/ │ ├── material-tailwind-provider.tsx # Material Tailwind 설정 제공자 │ ├── react-query-provider.tsx # TanStack Query 설정 제공자 │ └── recoil-provider.tsx # Recoil 상태 관리 제공자 │ ├── containers/ │ ├── Footer.tsx # 푸터 컨테이너 컴포넌트 │ ├── Header.tsx # 헤더 컨테이너 컴포넌트 │ ├── Main.tsx # 메인 컨텐츠 컨테이너 │ └── MovieDetail.tsx # 영화 상세 정보 컨테이너 │ ├── hooks/ │ └── useToggleFavorite.ts # 찜하기 기능 커스텀 훅 │ ├── public/ │ └── ... # 정적 자산 파일들 (이미지 등) │ └── utils/ ├── recoil/ │ └── atoms.ts # Recoil 상태 원자 정의 └── supabase/ ├── client.ts # Supabase 클라이언트 설정 ├── server.ts # Supabase 서버 클라이언트 설정 └── storage.ts # Supabase 스토리지 유틸리티3.5.1. 일부 영역에서 tailwindcss가 적용 안되던 문제폴더 구조 변경 후 일부 영역에서 TailwindCSS가 적용되지 않는 문제를 발견했습니다. 원인은 새로 추가한 containers 폴더가 Tailwind 설정에 포함되지 않았기 때문이었습니다.다음부턴 폴더 구조 변경 시 관련 설정 파일들도 더 꼼꼼하게 확인해야 할 것 같습니다.import type { Config } from "tailwindcss"; import withMT from "@material-tailwind/react/utils/withMT"; const config: Config = { content: [ "./utils/**/*.{js,ts,jsx,tsx,mdx}", "./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", // "./containers/**/*.{js,ts,jsx,tsx,mdx}", 나중에 추가함 ], theme: {}, plugins: [require("@tailwindcss/typography")], }; export default withMT(config); 4. 회고이번 주는 여러 선약으로 인해 지난주만큼 스터디에 많은 시간을 쏟지 못했습니다. 하지만 공부했던 내용을 발자국에 정리하면서 보니, 생각보다 많은 것을 배웠다는 사실을 깨달았습니다. Next.js와 Supabase에 조금씩 적응해가고 있음을 체감할 수 있어 뿌듯했습니다.어느덧 다음 주면 4주차 스터디가 마무리됩니다. 마지막 주차의 과제들은 보다 더 도전적이고 흥미로운 내용으로 구성되어 있어 기대가 됩니다.다음 주에도 모든 과제를 완수하고, 추가적인 새로운 것도 시도하면서 배운 내용을 발자국에 정리해 나갈 계획입니다. 그러면 제 자신의 성장을 더 잘 확인할 수 있을 것 같습니다.이상입니다.
2025. 03. 16.
1
[인프런 워밍업 클럽 Full Stack 3기] 2주차
1. 학습 내용1.1. Supabase Storage파일과 이미지를 저장하고 관리하기 위한 서비스 (아마존 S3와 유사)1.1.1. 주요 개념버킷(Buckets): 파일을 논리적으로 구분하는 컨테이너객체(Objects): 저장된 개별 파일정책(Policies): 파일에 대한 접근 권한 규칙 2. 미션2.1. 미션 내용파일 목록에서 각 파일의 "마지막 수정 시간"을 표시2.2. 미션 진행- 리액트 쿼리로 요청, 응답 받은 이미지 데이터 객체 내 update_at 키-값을 이미지 컴포넌트에 표시함으로써 작업을 진행했습니다.3. 추가 학습 및 적용 기술3.1. pnpm 도입과 경험npm의 문제점(패키지 중복 설치, 디스크 낭비)에 불편함을 느껴 이번 기회에 pnpm을 도입했습니다. 설치 속도가 확연히 빨라진 것을 체감할 수 있었습니다.3.1.1. npm과 pnpm의 주요 차이점저장 구조와 디스크 사용량npm: 프로젝트마다 의존성 중복 저장으로 디스크 낭비pnpm: 전역 저장소에 패키지를 한 번만 저장하고 심링크로 연결하여 공간 절약성능npm: 중복 다운로드로 인한 네트워크 부하 및 시간 소요pnpm: 공유 저장소와 링크 기반 구조로 설치 속도 향상, 병렬 처리 최적화3.2. material-tailwind 경고 제거material-tailwind 컴포넌트 사용 시 발생하는 경고 메시지를 d.ts 파일을 통해 제거했습니다. 이 방법이 안전한지 고민했지만, 큰 문제가 없다고 판단하여 적용했습니다. 인프런 커뮤니티의 질문과 답변이 해결에 도움이 되었습니다.3.3. supabase storage type 적용db type과 달리 storage type은 supabase cli를 통해 자동 생성할 수 없다는 점을 알게 되었습니다. 대신 `@supabase/storage-js` 플러그인을 통해 필요한 type을 활용할 수 있었습니다.// UploadedImage Component import { FileObject } from '@supabase/storage-js'; // ...existing code... export default function UploadedImage({ file: { name, updated_at }, }: { file: FileObject; }) { return (// component) }3.4. prettier 설정GitHub Copilot의 도움을 받아 Next.js 프로젝트에 적합한 옵션으로 설정했으며, prettier-plugin-tailwindcss를 통해 Tailwind 클래스 자동 정렬 기능을 추가했습니다.// .prettierrc { "singleQuote": true, "semi": true, "useTabs": false, "tabWidth": 2, "trailingComma": "es5", "printWidth": 80, "arrowParens": "avoid", "jsxSingleQuote": false, "bracketSpacing": true, "bracketSameLine": false, "htmlWhitespaceSensitivity": "css", "requirePragma": false, "insertPragma": false, "proseWrap": "preserve", "endOfLine": "auto", "plugins": [ "prettier-plugin-tailwindcss" ] }3.5. eslint 설정@tanstack/eslint-plugin-query를 도입하여 React Query 사용 시 모범 사례를 따르도록 설정했습니다. 이를 통해 쿼리 키 검증, 의존성 확인 등의 이점을 얻을 수 있었습니다.// .eslint.json { "plugins": ["@tanstack/query"], "extends": ["next/core-web-vitals", "plugin:@tanstack/query/recommended"] } 4. 아쉽게 적용하지 못한 기술아래 3 가지 항목들은 모두 조사, 기능 개발 계획, 프로젝트에 일부 적용까지 하기도 했으나 시간이 부족해 결국 완성되지 못한 기능들입니다.4.1. 한글 파일명 업로드 문제 해결Supabase Storage에서 한글 이름의 파일을 업로드할 수 없는 문제에 직면했습니다. 원인은 파일명 인코딩 과정이 없어서 발생한 문제였습니다. 두 가지 해결책을 구상했지만 시간 부족으로 완성하지 못했습니다.파일정보 DB 테이블 접근법: 파일명과 UUID를 매핑하여 DB에 저장하고, 실제 스토리지엔 UUID로 업로드하는 방식customMetadata 활용: Supabase Storage의 메타데이터 기능을 활용하는 방식4.2. 직접 Tailwindcss 컴포넌트 구현 시도material-tailwind 대신 Tailwindcss만으로 모든 컴포넌트를 스타일링해보고 싶었으나, 시간 부족으로 실현하지 못했습니다.4.3. 컴포넌트 구조 설계의 고민개발자 관점에서 명확하고 구분하기 쉬운 파일 구조를 만들기 위해 다양한 React 컴포넌트 구조 패턴을 조사했습니다.각 패턴의 장단점을 분석하며 프로젝트에 가장 적합한 구조를 고민했지만, 시간 관계상 실제 적용은 제한적이었습니다.제가 찾아본 React 주요 패턴들은 다음과 같습니다. 이해를 돕기 위해 각 패턴마다 특징 및 예시 코드까지 준비하였으나 글이 너무 길어져 읽는데 어려움이 있을 것 같아 최종적으로 간단하게 한 줄로 요약했습니다.4.3.1. Presentational and Container Pattern로직과 UI를 분리하는 패턴으로, 재사용성과 테스트 용이성이 향상되지만 props drilling 문제가 발생할 수 있습니다.4.3.2. Compound Component Pattern복합적인 UI를 구성하는 관련 컴포넌트들을 그룹화하고 내부적으로 상태를 공유하는 패턴입니다. API 사용 경험은 향상되지만 TypeScript 타입 정의가 복잡해질 수 있습니다.4.3.3. Render Props Pattern컴포넌트의 렌더링 로직을 prop 함수로 전달하는 방식으로, 로직 재사용은 용이하지만 콜백 중첩으로 인한 디버깅 어려움이 있을 수 있습니다.4.3.4. Custom Hook Pattern로직을 훅으로 추출하여 여러 컴포넌트에서 재사용하는 패턴입니다. React의 핵심 패턴 중 하나로, UI와 로직의 분리가 명확합니다.4.3.5. Context API Pattern여러 컴포넌트에서 데이터를 공유하기 위한 패턴으로, props drilling을 방지할 수 있지만 불필요한 리렌더링이 발생할 수 있습니다.4.3.6. Atomic Design PatternUI 컴포넌트를 원자(Atoms), 분자(Molecules), 유기체(Organisms), 템플릿(Templates), 페이지(Pages)로 나누는 구조로, 체계적인 UI 구성이 가능하지만 초기 설계 시간이 많이 소요됩니다.4.3.7. Client/Server Component PatternNext.js 14 App Router의 핵심 패턴으로, 서버에서 데이터를 페칭하고 클라이언트에서 인터랙션을 처리하여 번들 크기를 최적화합니다.4.3.8. Server Components and Suspense Pattern데이터 로딩 상태를 선언적으로 처리하는 패턴으로, 점진적 UI 로딩을 지원하고 사용자 경험을 향상시킬 수 있습니다. 5.마무리이번 스터디를 통해 많은 것을 배우고 적용해보는 즐거움을 느꼈습니다. 계획했던 것보다는 적게 구현했지만, 새로운 기술들을 탐색하고 실험해본 경험은 매우 가치 있었습니다. 더 많은 공부 시간을 확보하니 다양한 시도를 해볼 수 있었지만, 동시에 욕심이 커져 모든 계획을 실현하지는 못했습니다. 3주 차에는 개인 약속으로 인해 학습 시간이 줄어들 것 같지만, 지금까지의 경험을 바탕으로 더 효율적으로 학습하고 구현해보겠습니다.무엇보다, 호기심을 가지고 새로운 기술을 탐색하고 적용해보는 과정 자체가 개발자로서 성장하는 중요한 발판이 된다는 것을 다시 한번 느낄 수 있었습니다.
2025. 03. 09.
0
[인프런 워밍업 클럽 Full Stack 3기] 1주차
1.강의에서 사용된 기술 요약1.1.Next.js정의: 풀스택 개발에 최적화된 React 프레임워크특징:서버사이드 렌더링으로 SEO 최적화폴더 구조가 URL 라우팅과 일치 (app/movies → /movies)중요 파일: page.tsx(라우트), layout.tsx(레이아웃), route.ts(API)Link 컴포넌트로 클라이언트 사이드 라우팅 지원도구: Server Actions(서버 기능), Metadata API(SEO 최적화)1.2.TailwindCSS정의: 유틸리티 우선 CSS 프레임워크특징: HTML에 직접 클래스 적용으로 빠른 스타일링주요 클래스:레이아웃: flex, grid, flex-col, justify-center크기/여백: w-{n}, h-{n}, p-{n}, m-{n}스타일링: bg-{color}, text-{color}, rounded-{size}, shadow-{size}1.3.Recoil정의: Facebook의 React 상태 관리 라이브러리특징: 간편한 전역 상태 관리, 높은 성능주요 개념:atom: 상태 기본 단위useRecoilState: 상태 읽기/쓰기useRecoilValue: 상태 읽기1.4.React Query정의: 서버 상태 관리 라이브러리특징: 데이터 페칭, 캐싱, 동기화 자동화주요 함수:useQuery: 데이터 조회useMutation: 데이터 변경자동 백그라운드 갱신, 캐싱, 에러 처리2.Todo list 개발2.1.미션 해결supabase의 todo 테이블에 completed_at 컬럼을 추가 후 화면에 todo 생성일과 완료일을 함께 표시했습니다.완료일과 생성일의 날짜, 시간을 자연스럽게 표시하기 위해 시간 표시 함수export function getKoreanTime() { const now = new Date(); const koreaTimeDiff = 9 * 60 * 60 * 1000; // 한국 시간대(UTC+9)의 밀리초 const koreanDate = new Date(now.getTime() + koreaTimeDiff); return koreanDate.toISOString(); } export function convertTime(time: string) { const date = new Date(time); const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); const hours = date.getHours(); const minutes = date.getMinutes(); return `${year}-${month}-${day} ${hours}:${minutes}`; }2.2.추가 개선 사항폴더 구조 및 파일명 변경root/ ├── actions/ # 서버 액션 관련 코드 ├── app/ # 페이지 라우팅 및 레이아웃 ├── components/ # 재사용 가능한 UI 컴포넌트 ├── config/ # 환경 설정 파일 ├── containers/ # 상태 관리 및 비즈니스 로직 ├── public/ # 정적 파일 (이미지, 폰트 등) └── utils/ # 유틸리티 함수 └── supabase/ # Supabase 클라이언트 설정2.3.문제 해결 (런타임 에러)웹브라우저에서 체감상 5분 정도 시간이 흐르면 데브툴 콘솔창에서 아래와 같은 런타임 에러가 표시됐었습니다.제 컴퓨터 웹브라우저의 특정 확장 플러그인이 중복 실행되고 있었던 문제였습니다. 문제의 확장 플러그인이 1번만 실행되게 조치하니 해결됐습니다.Unchecked runtime.lastError: A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received3. 회고 이번 Todo 리스트를 개발하면서 추가적으로 시도해보고 싶었던 것들이 있었지만, 대부분 실천하지 못해 아쉬움이 남습니다. 2주 차부터는 더 많은 공부 시간을 확보하고, 이번에 시도하지 못했던 추가 개발 사항들을 더욱 구체화하여 1주 차 결과물보다 더 높은 퀄리티의 결과물을 만들어보고자 합니다.