블로그
전체 42025. 03. 30.
0
[인프런 워밍업 클럽 Full Stack 3기] 4주차
1. 학습 내용1.1. Supabase 인증 방식1.1.1. Confirmation URL사용자가 이메일로 받은 확인 링크를 클릭하여 계정을 인증하는 방식입니다.1.1.2. 6-Digit OTP 방식이메일로 6자리 일회용 코드를 전송하고, 사용자가 이를 입력하여 인증하는 방식입니다.1.2. Supabase realtimeSupabase Realtime은 PostgreSQL 데이터베이스의 변경사항을 실시간으로 클라이언트에 제공하는 기능으로, 채팅, 알림, 실시간 협업 등을 구현할 수 있게 해주는 서비스입니다.1.2.1. Broadcast특정 채널을 통해 연결된 모든 클라이언트 간에 직접 메시지를 주고받을 수 있는 기능입니다. 데이터베이스를 거치지 않고 실시간 통신이 가능합니다.특징- 클라이언트 간 직접 메시지 교환- 데이터베이스 저장 없이 임시 통신 가능- 타이핑 표시기, 커서 위치 등 임시 상태에 적합- 채널 기반 구독 시스템1.2.2. Presence (중요)Presence는 채널에 연결된 사용자의 상태 정보를 실시간으로 추적하고 공유하는 기능입니다. 온라인 상태, 사용자 활동, 현재 위치 등을 관리할 수 있습니다. 이번 미션을 진행할 때 presence가 굉장히 핵심적인 역할을 합니다.특징- 사용자 온라인/오프라인 상태 관리- 자동 정리(cleanup) 기능- 상태 데이터 저장 및 공유- 연결 끊김 자동 감지1.2.3. Postgres ChangesPostgreSQL 데이터베이스의 변경사항(INSERT, UPDATE, DELETE)을 실시간으로 감지하여 클라이언트에 전달하는 기능입니다.특징- 데이터베이스 수준의 변경사항 감지- 테이블, 로우, 열 수준의 구독 가능- 정교한 필터링 옵션- RLS(Row Level Security)와 통합1.3. RLS (중요)PostgreSQL의 보안 기능으로, 데이터베이스 테이블의 개별 행(row)에 대한 접근을 사용자 수준에서 제어할 수 있는 메커니즘입니다. Supabase에서는 이를 통해 인증된 사용자가 자신의 데이터만 접근할 수 있도록 세밀한 보안 정책을 설정할 수 있습니다.(Policy 기능) 2. 미션2.1. 지금까지 만든 모든 프로젝트 배포TODO: https://inf.run/Gi4eADROPBOX: https://inf.run/ScSd4NETFLIX: https://inf.run/TMWKrINSTAGRAM: https://inf.run/HS4Sn2.2. 메시지 삭제 기능제가 구현한 메시지 삭제 기능은 데이터베이스에서 실제로 행을 제거하지 않고 상태 플래그(is_deleted)만 변경하는 방식으로 구현했습니다.2.2.1. 클라이언트 측 구현// /components/chat/MessageArea.tsx const deleteMessageMutation = useMutation({ mutationFn: async (id: number) => { return deleteMessage({ id }); }, onSuccess: () => { getAllMessageQuery.refetch(); }, onError: error => { console.error('메시지 삭제 오류:', error); alert('메시지 삭제 중 오류가 발생했습니다.'); }, }); function handleDeleteMessage(id: number) { confirm('정말 메시지를 삭제하시겠습니까?') && deleteMessageMutation.mutate(id); }useMutation 훅을 사용하여 메시지 삭제 작업 정의삭제 전 confirm 대화상자로 사용자 확인 절차 구현성공 시 메시지 목록 갱신을 위해 refetch 호출오류 발생 시 콘솔 로그 및 사용자 알림 표시2.2.2. 서버 액션 구현// /actions/chatAction.ts export async function deleteMessage({ id }: { id: number }) { try { const user = await getCurrentUser(); const supabase = await createServerSupabaseClient(); const { error } = await supabase .from('message') .update({ is_deleted: true }) .eq('id', id) .eq('sender', user.id); if (error) { throw new Error(error.message); } } catch (error) { console.error('메시지 삭제 중 오류:', error); throw error; } }서버 측에서 메시지 삭제를 논리적 삭제로 처리 (is_deleted 필드 업데이트)현재 인증된 사용자 정보 가져오기본인이 보낸 메시지만 삭제할 수 있도록 보안 검증 (eq('sender', user.id))오류 발생 시 클라이언트로 전파하여 적절한 처리 가능2.2.3. Message UI 구현// components/chat/Message.tsx export function Message({ isFromMe, message, deleteMessage, isDeleted, isRead = false, }: MessageProps) { const DELETE_MESSAGE = '이미 삭제된 메세지입니다.'; return ( {/* ... 메시지 내용 ... */} {isDeleted ? DELETE_MESSAGE : message} {/* 삭제 버튼 (자신의 메시지이고 삭제되지 않은 경우에만 표시) */} {isFromMe && !isDeleted && ( X )} ); }삭제된 메시지는 "이미 삭제된 메세지입니다" 텍스트로 대체자신의 메시지(isFromMe)이고 삭제되지 않은 경우에만 삭제 버튼 표시메시지에 마우스를 올렸을 때만 삭제 버튼 표시 (group-hover)2.2.4. 기능 설명 요약사용자가 메시지의 X 버튼 클릭handleDeleteMessage 함수가 호출되어 확인 대화상자 표시확인 시 deleteMessageMutation.mutate(id) 실행서버 액션 deleteMessage가 호출되어 데이터베이스 업데이트성공 시 getAllMessageQuery.refetch()로 메시지 목록 새로고침삭제된 메시지는 UI에서 "이미 삭제된 메세지입니다"로 표시2.3. 채팅 '읽음/안읽음' 표시 기능2.3.1. 읽음 상태 관리// /hooks/reactQueries.ts export function useMarkMessagesAsRead(selectedUserId: string | null) { const queryClient = useQueryClient() return useMutation({ mutationFn: () => { if (!selectedUserId) return Promise.resolve() return markMessagesAsRead(selectedUserId) }, onSuccess: () => { // 쿼리 무효화 로직 }, }) }선택된 사용자 ID를 받아 해당 사용자의 메시지를 읽음 처리함사용자 ID가 없으면 즉시 빈 Promise 반환성공 시 안 읽은 메시지 카운트와 메시지 목록 쿼리를 무효화하여 UI 갱신React Query의 useMutation 사용으로 서버 상태 변경 관리2.3.2. 메시지 카운트 조회 기능// /actions/chatActions.ts export async function getNotReadMessageCount(chatUserId: string) { // 수행 로직 const { count, error: countError } = await supabase .from('message') .select('*', { count: 'exact', head: true }) .match({ sender: chatUserId, receiver: user.id, is_read: false, }) }특정 사용자(chatUserId)가 보낸 안 읽은 메시지 개수만 조회head: true 옵션으로 데이터 없이 카운트만 가져와 효율성 향상count: 'exact' 옵션으로 정확한 카운트 값 요청is_read: false 필터로 안 읽은 메시지만 카운트2.3.3. 자동 읽음 처리 기능// /components/chat/ChatScreen.tsx const checkUserPresence = useCallback(async () => { if (!selectedUserId || !loggedUser) return const chatPartnerId = presence?.[`${selectedUserId}`]?.[0]?.activeChatId if (chatPartnerId === loggedUser.id) { markAsRead() } }, [selectedUserId, presence, markAsRead, loggedUser])Presence 정보에서 상대방의 활성 채팅 ID 확인상대방이 현재 사용자와의 채팅을 보고 있으면 자동으로 읽음 처리옵셔널 체이닝(?.)으로 안전하게 데이터 접근useCallback으로 함수 재생성 방지 및 의존성 관리2.3.4. 읽음 상태 UI 표시// /compoents/chat/Message.tsx export function Message({ isFromMe, message, isRead = false }) { return ( {message} {isFromMe && ( {isRead ? '읽음' : '안읽음'} )} ) }메시지 발신자(isFromMe)가 본인일 경우에만 읽음 상태 표시isRead 상태에 따라 '읽음' 또는 '안읽음' 텍스트 표시기본값 false로 설정하여 읽음 상태 없을 때 안전하게 처리2.3.5. Presence 채널 설정// /components/chat/CHatPeopleList.tsx const channel = supabase.channel('online_users', { config: { presence: { key: loggedInUser.id, }, }, }) channel.on('presence', { event: 'sync' }, () => { const newState = channel.presenceState() setPresence(newStateOObject) }) 'online_users' 채널 생성으로 사용자 상태 공유현재 사용자 ID를 키로 설정해 사용자별 상태 구분presence 이벤트의 sync 타입으로 상태 변경 감지presenceState()로 전체 사용자의 최신 상태 조회Recoil 상태로 저장해 앱 전체에서 접근 가능2.3.6. 활성 채팅 상태 추적 기능// /components/chat/ChatPeopleList.tsx channel.Subscribe(async status => { if (status !== 'SUBSCRIBED') return const newPresenceStatus = await channel.track({ onlineAt: new Date().toISOString(), activeChatId: selectedUserId, }) })채널 구독 성공(SUBSCRIBED) 확인 후 상태 추적 시작현재 시간을 ISO 문자열로 저장해 최신 접속 시간 기록activeChatId에 현재 대화 중인 상대방 ID 저장channel.track()으로 상태 정보 실시간 브로드캐스트2.3.7. 실시간 메시지 변경 감지 및 처리// /components/chat/ChatScreen.tsx supabase .channel('message_postgres_changes') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'message', }, payload => { if (payload.eventType === 'INSERT') { refetch() checkUserPresence() } } ) .subscribe()'message_postgres_changes' 채널로 메시지 테이블 변경 구독새 메시지 추가(INSERT) 이벤트만 필터링새 메시지 발생 시 즉시 메시지 목록 갱신(refetch())동시에 사용자 상태 확인하여 자동 읽음 처리 실행관련 쿼리(안 읽은 메시지 카운트 등) 무효화로 UI 일관성 유지2.3.8. 정책 설정 (supabase policy)서버 액션에서 createServerSupabaseAdminClient() 를 이용하여 관리자 권한으로 is_read 필드를 수정하려 했지만 강의에서 보고 적용한 update policy를 넘어설 수 없었음.어쩔 수 없이 위와 같은 설정대로 누구나 업데이트를 할 수 있게 적용하되 모든 서버 액션 코드에서 권한 확인을 하는 코드를 추가2.3.9. 기능 설명 요약초기 설정: 사용자가 로그인하면 Presence 채널 구독 및 상태 추적 시작채팅방 전환: 사용자가 특정 채팅방을 선택하면 activeChatId 업데이트자동 읽음 처리: 상대방이 대화를 보고 있으면 자동으로 메시지 읽음 처리상태 변경 감지: 메시지 읽음 상태가 변경되면 UI 업데이트안 읽은 메시지 카운트: 각 사용자별 안 읽은 메시지 개수 조회 및 표시 3. 그 외 추가 작업 사항3.1. 코드 구조 개선3.1.1. 인증 및 상태 관리session 대신 supabase getUser 메서드를 활용하여 로그인된 사용자 상태 확인 3.1.2. 커스텀 훅 도입react-query 및 로그인 확인 코드는 hooks로 모듈화하여 필요한 컴포넌트마다 사용할 수 있게 변경3.1.3. 컴포넌트 구조 개선ChatScreen 컴포넌트 분리MessageArea.tsx: 채팅 메시지 표시 영역 담당ChatInput.tsx: 사용자 입력 폼 처리 담당3.2. 사용자 경험(UX) 개선3.2.1. 현재 사용자 표시로그인한 사용자를 유저 리스트 최상단에 시각적으로 구분하여 표시3.2.2. 가이드 메시지 추가새 대화 시작 시 "아직 메시지가 없습니다. 대화를 시작해 보세요!" 안내 문구 표시3.2.3. 자동 스크롤 구현채팅방 진입 시 최신 메시지 위치로 자동 스크롤 새 메시지 전송 시 스크롤 위치 자동 조정 3.2.4. 입력 경험 개선 태그 활용으로 Enter 키 제출 지원전송 버튼 type="submit" 속성 적용메시지 전송 후 입력창 자동 포커스 유지로 연속 입력 편의성 향상 4. 한 달 간의 개발 스터디를 마치며매주 개발 프로젝트와 미션을 수행하며 그 과정에서 해보고 싶다고 생각되는 것도 시도해보다 보니 어느새 스터디 마지막에 도달했습니다. 한 달 간의 꾸준한 코딩은 개발 트렌드 확인에 많은 도움이 되었습니다. 공부 습관도 다시 생길 것 같습니다.아쉬운 점은 인프런 블로그 에디터가 일정 분량 이상 작성하면 급격히 느려져 매주 하고 싶었던 이야기를 충분히 담지 못했다는 것입니다. 오늘도 같은 이유로 긴 회고를 남기기 어렵네요.짧은 시간이었지만 이번 스터디를 통해 얻은 기술적 성장과 개발 습관은 앞으로의 여정에 큰 도움이 될 것입니다.
2025. 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주 차 결과물보다 더 높은 퀄리티의 결과물을 만들어보고자 합니다.