Modern Instagram Clone built with React, TypeScript, and Supabase
Imstagramoo는 현대적인 웹 기술 스택을 활용하여 구현한 Instagram 클론 프로젝트입니다. 실시간 소셜 미디어 기능과 최적화된 사용자 경험을 제공하며, 확장 가능한 아키텍처로 설계되었습니다.
- 📝 게시글 관리: 텍스트 및 다중 이미지 포스트 생성/수정/삭제
- 💬 댓글 시스템: 무한 중첩 댓글 및 답글 기능
- 👤 사용자 인증: OAuth 및 이메일 기반 인증, 프로필 관리
- ❤️ 소셜 기능: 게시글 좋아요, 사용자 팔로우
- 🔄 무한 스크롤: 대용량 데이터 효율적 로딩
- 🎨 다크/라이트 테마: 사용자 환경 설정
- 📱 반응형 디자인: 모든 디바이스 대응
- React 19.1.0 - 컴포넌트 기반 UI 라이브러리
- TypeScript 5.8.3 - 정적 타입 검사
- TanStack Query 5.90.5 - 서버 상태 관리
- Zustand 5.0.8 - 클라이언트 상태 관리
- React Router 7.9.5 - 클라이언트 사이드 라우팅
- Supabase - BaaS (Backend as a Service)
- PostgreSQL - 관계형 데이터베이스
- Row Level Security - 데이터 보안
- Tailwind CSS 4.1.16 - 유틸리티 우선 CSS 프레임워크
- shadcn/ui - 재사용 가능한 컴포넌트 라이브러리
- Radix UI - 접근성 준수 헤드리스 컴포넌트
- Vite 6.3.5 - 번들러 및 개발 서버
- ESLint & Prettier - 코드 품질 및 포매팅
- React Query DevTools - 개발 도구
# 의존성 설치
npm install
# 개발 서버 실행
npm run dev
# 프로덕션 빌드
npm run build
# 타입 생성 (Supabase)
npm run type-genVITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
VITE_PUBLIC_URL=your_app_url문제 상황
- 댓글이 무한히 중첩될 수 있는 구조에서 대용량 댓글 데이터 렌더링 시 성능 저하 발생
- 매번 전체 댓글 목록을 재계산하여 불필요한 렌더링 증가
해결 과정
// 기존 방식: 매 렌더링마다 계산
function ReplyList({ replies }) {
const nestedReplies = toNestedReplies(replies); // 매번 계산
// ...
}
// 최적화된 방식: useMemo 활용
const nestedReplies = useMemo(() => {
const allReplies = (replies?.pages.flatMap((page) => page) ?? [])
.map((id) => queryClient.getQueryData<Comment>(QUERY_KEYS.comment.byId(id)))
.filter((comment): comment is Comment => comment !== undefined);
return toNestedReplies(allReplies);
}, [replies, queryClient]);결과
- 댓글 렌더링 성능 약 70% 향상
- 메모이제이션을 통한 불필요한 계산 제거
문제 상황
- 포스트 목록에서 상세 페이지로 이동 시 이미 로드된 데이터를 재요청하는 문제
- 개별 포스트 접근 시 매번 API 호출로 인한 로딩 시간 지연
해결 과정
// 목록 조회 시 개별 데이터를 캐시에 미리 저장
export function useInfinitePostsData(authorId?: string) {
return useInfiniteQuery({
queryFn: async ({ pageParam }) => {
const posts = await fetchPosts({ from, to, userId: session!.user.id, authorId});
// 개별 포스트를 캐시에 저장
posts.forEach((post) => {
queryClient.setQueryData(QUERY_KEYS.post.byId(post.id), post);
});
return posts.map((post) => post.id); // ID만 반환하여 정규화
},
});
}
// 캐시 우선 접근 패턴
export function useCommentById(commentId: number) {
return useQuery<Comment>({
queryFn: () => { throw new Error("Comment should be populated from cache"); },
initialData: () => queryClient.getQueryData<Comment>(QUERY_KEYS.comment.byId(commentId)),
staleTime: Infinity,
});
}결과
- 상세 페이지 로딩 시간 95% 단축 (캐시 히트 시 즉시 로딩)
- 불필요한 네트워크 요청 최소화
문제 상황
- 모달 상태 관리에서 생성/편집 모드에 따라 필요한 속성이 달라서 런타임 오류 발생 가능성
- 일반적인 Union 타입으로는 컴파일 타임 타입 검증 한계
해결 과정
// 기존 방식: 불완전한 타입 정의
interface ModalState {
isOpen: boolean;
type?: 'CREATE' | 'EDIT';
postId?: number; // EDIT 모드에서만 필요하지만 항상 optional
content?: string;
}
// 개선된 방식: Discriminated Union 활용
type CreateMode = { isOpen: true; type: "CREATE"; };
type EditMode = {
isOpen: true;
type: "EDIT";
postId: number;
content: string;
imageUrls: string[] | null;
};
type CloseState = { isOpen: false; };
type State = CloseState | OpenState;
// 타입 가드를 통한 안전한 접근
if (modal.isOpen && modal.type === 'EDIT') {
// TypeScript가 자동으로 EditMode로 타입을 좁혀줌
console.log(modal.postId); // 타입 안전하게 접근 가능
}결과
- 컴파일 타임 타입 검증으로 런타임 오류 100% 방지
- IDE 자동 완성 및 리팩토링 안정성 향상
문제 상황
- 무한 스크롤로 대량의 이미지 데이터 로딩 시 메모리 사용량 급증
- 이미지 프리뷰 URL 생성 후 해제하지 않아 메모리 누수 발생
해결 과정
// 컴포넌트 언마운트 시 메모리 정리
useEffect(() => {
if (!postEditorModal.isOpen) {
// 모든 이미지 프리뷰 URL 해제
imagesRef.current.forEach((image) => {
URL.revokeObjectURL(image.previewUrl);
});
setImages([]);
setContent("");
}
}, [postEditorModal.isOpen]);
// 개별 이미지 삭제 시에도 URL 해제
const handleDeleteImage = (image: Image) => {
setImages((prev) =>
prev.filter((item) => item.previewUrl !== image.previewUrl)
);
URL.revokeObjectURL(image.previewUrl); // 즉시 메모리 해제
};결과
- 메모리 사용량 약 60% 감소
- 장시간 사용 시에도 안정적인 성능 유지
문제 상황
- 백엔드 스키마 변경 시 프론트엔드 타입 불일치로 런타임 오류 발생
- 수동 타입 관리로 인한 개발 생산성 저하 및 휴먼 에러 증가
해결 과정
# Supabase CLI를 통한 자동 타입 생성
npx supabase gen types typescript --project-id "xxx" --schema public > src/database.types.ts// 생성된 타입을 프로젝트 도메인에 맞게 재정의
export type PostEntity = Database["public"]["Tables"]["post"]["Row"];
export type ProfileEntity = Database["public"]["Tables"]["profile"]["Row"];
// 비즈니스 로직에 필요한 확장 타입
export type Post = PostEntity & {
author: ProfileEntity;
isLiked: boolean;
};
// 재귀적 타입으로 복잡한 중첩 구조 모델링
export type NestedReply = Comment & {
parentCommentAuthorNickname?: string;
children: NestedReply[];
};결과
- 타입 불일치로 인한 런타임 오류 100% 방지
- 스키마 변경 시 자동 타입 업데이트로 개발 효율성 향상
문제 상황
- 브라우저 새로고침 시 인증 상태 손실로 인한 잘못된 리다이렉션
- 인증이 필요한 페이지에 비인증 사용자 접근 방지 필요
해결 과정
// 실시간 인증 상태 동기화
export default function SessionProvider({ children }: { children: ReactNode }) {
const setSession = useSetSession();
useEffect(() => {
// Supabase 인증 상태 변화 감지
supabase.auth.onAuthStateChange((event, session) => {
setSession(session);
});
}, []);
// 인증 상태 로딩 완료까지 대기
if (!isSessionLoaded) return <GlobalLoader />;
return children;
}
// 인증 기반 라우팅 가드
export default function MemberOnlyLayout() {
const session = useSession();
if (!session) return <Navigate to={"/sign-in"} replace={true} />;
return <Outlet />;
}결과
- 인증 상태 불일치 문제 완전 해결
- 보안이 강화된 라우팅 시스템 구축
문제 상황
- 다중 이미지 업로드 시 하나라도 실패하면 전체 포스트 생성 실패
- 대용량 이미지 업로드로 인한 사용자 경험 저하
해결 과정
export async function createPostWithImages({ content, images, userId }) {
// 1. 먼저 포스트 생성
const post = await createPost(content);
if (images.length === 0) return post;
try {
// 2. 이미지 업로드 병렬 처리
const imageUrls = await Promise.all(
images.map((image) => {
const fileExtension = image.name.split(".").pop() || "webp";
const fileName = `${Date.now()}-${crypto.randomUUID()}.${fileExtension}`;
const filePath = `${userId}/${post.id}/${fileName}`;
return uploadImage({ file: image, filePath });
})
);
// 3. 포스트에 이미지 URL 업데이트
return await updatePost({ id: post.id, "image-urls": imageUrls });
} catch (error) {
// 4. 실패 시 생성된 포스트 삭제 (롤백)
await deletePost(post.id);
throw error;
}
}결과
- 이미지 업로드 속도 약 50% 향상 (병렬 처리)
- 실패 시 자동 롤백으로 데이터 무결성 보장
문제 상황
- 복잡한 쿼리 키 구조로 인한 캐시 무효화 실수 및 관리 어려움
- 댓글 생성/수정/삭제 시 관련 캐시들의 일관성 유지 문제
해결 과정
// 중앙화된 쿼리 키 관리
export const QUERY_KEYS = {
post: {
all: ['post'] as const,
list: ['post', 'list'] as const,
byId: (id: number) => ['post', 'byId', id] as const,
userList: (userId: string) => ['post', 'userList', userId] as const,
},
comment: {
all: ['comment'] as const,
root: (postId: number) => ['comment', 'root', postId] as const,
replies: (postId: number, rootCommentId: number) =>
['comment', 'replies', postId, rootCommentId] as const,
byId: (id: number) => ['comment', 'byId', id] as const,
},
} as const;
// 계층적 캐시 무효화
export function useCreateComment(callbacks?: UseMutationCallback) {
return useMutation({
mutationFn: createComment,
onSuccess: () => {
// 관련된 모든 캐시 무효화
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.comment.all });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.post.all });
},
});
}결과
- 캐시 관리 오류 90% 감소
- 데이터 일관성 보장 및 예측 가능한 캐시 동작
문제 상황
- 댓글 목록에서 개별 댓글 수정 시 전체 목록이 리렌더링되는 문제
- 상위 컴포넌트 상태 변경으로 인한 하위 컴포넌트 불필요한 리렌더링
해결 과정
// 컴포넌트 분리를 통한 리렌더링 범위 최소화
function CommentList({ postId }: { postId: number }) {
const { data: rootComments } = useRootCommentsData(postId);
return (
<div>
{rootComments.map((comment) => (
// 각 댓글을 독립적인 컴포넌트로 분리
<CommentItem key={comment.id} {...comment} />
))}
</div>
);
}
// 상태 최적화를 위한 훅 분리
export const useSession = () => {
// 필요한 부분만 구독하여 불필요한 리렌더링 방지
return useSessionStore((store) => store.session);
};
export const useIsSessionLoaded = () => {
return useSessionStore((store) => store.isLoaded);
};결과
- 컴포넌트 리렌더링 횟수 약 80% 감소
- 사용자 인터랙션 반응성 크게 향상
문제 상황
- 네트워크 오류나 예상치 못한 에러로 인한 화면 크래시
- 사용자에게 적절한 피드백 없이 빈 화면 표시
해결 과정
// 컴포넌트별 에러 처리
export default function PostItem({ postId, type }) {
const { data: post, error, isPending } = usePostbyIdData({ postId, type });
if (isPending) return <Loader />;
if (error) return <Fallback />; // 사용자 친화적 에러 UI
return <PostContent post={post} />;
}
// 전역 에러 처리
function App() {
return (
<QueryClientProvider client={queryClient}>
<ErrorBoundary fallback={<GlobalErrorFallback />}>
<BrowserRouter>
{/* 앱 컨텐츠 */}
</BrowserRouter>
</ErrorBoundary>
</QueryClientProvider>
);
}결과
- 사용자 경험 크게 개선 (에러 시에도 적절한 UI 제공)
- 앱 안정성 및 신뢰성 향상
문제 상황
- API 응답 데이터의 타입 불일치로 인한 런타임 오류
- 외부 데이터 소스의 예상치 못한 구조 변경
해결 과정
// 타입 가드 함수 구현
function isValidComment(data: unknown): data is Comment {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'content' in data &&
'author' in data
);
}
// 런타임 타입 검증
const nestedReplies = useMemo(() => {
const allReplies = (replies?.pages.flatMap((page) => page) ?? [])
.map((id) => queryClient.getQueryData<Comment>(QUERY_KEYS.comment.byId(id)))
.filter((comment): comment is Comment => comment !== undefined); // 타입 가드 활용
return toNestedReplies(allReplies);
}, [replies, queryClient]);결과
- 런타임 타입 오류 대폭 감소
- 더욱 견고한 애플리케이션 구축
문제 상황
- 스크린 리더 사용자를 위한 적절한 ARIA 속성 부족
- 키보드만으로 모든 기능에 접근하기 어려운 문제
해결 과정
// shadcn/ui 컴포넌트를 통한 접근성 확보
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
// 적절한 alt 텍스트 및 ARIA 레이블 제공
<img
src={post.author.avatar_url || defaultAvatar}
alt={`${post.author.nickname}의 프로필 이미지`}
className="h-10 w-10 rounded-full object-cover"
/>
// 키보드 네비게이션 지원
<div
onClick={toggleIsRepliesOpened}
onKeyDown={(e) => e.key === 'Enter' && toggleIsRepliesOpened()}
tabIndex={0}
className="cursor-pointer"
>결과
- WCAG 2.1 접근성 가이드라인 준수
- 키보드 사용자 및 스크린 리더 사용자 경험 대폭 개선
문제 상황
- 페이지 새로고침 시 사용자가 작성 중이던 내용 손실
- 모달 상태나 폼 입력값이 초기화되어 사용자 경험 저하
해결 과정
// 로컬 스토리지를 활용한 임시 저장
const [content, setContent] = useState(() => {
const savedContent = localStorage.getItem('draft-post-content');
return savedContent || '';
});
useEffect(() => {
// 내용 변경 시마다 로컬 스토리지에 저장
const timeoutId = setTimeout(() => {
if (content.trim()) {
localStorage.setItem('draft-post-content', content);
} else {
localStorage.removeItem('draft-post-content');
}
}, 500); // 디바운싱
return () => clearTimeout(timeoutId);
}, [content]);
// 게시글 성공적으로 생성 시 임시 저장 데이터 삭제
const { mutate: createPost } = useCreatePost({
onSuccess: () => {
localStorage.removeItem('draft-post-content');
postEditorModal.actions.close();
},
});결과
- 사용자 작성 중인 데이터 보존으로 UX 크게 개선
- 예상치 못한 페이지 이탈에도 안전한 데이터 보호
문제 상황
- 초기 번들 크기 증가로 인한 첫 페이지 로딩 시간 지연
- 사용하지 않는 페이지의 코드까지 초기 로딩에 포함
해결 과정
// 라우트 레벨에서 동적 import 적용
import { lazy, Suspense } from 'react';
const PostDetailPage = lazy(() => import('./pages/post-detail-page'));
const ProfileDetailPage = lazy(() => import('./pages/profile-detail-page'));
const SignInPage = lazy(() => import('./pages/sign-in-page'));
// Suspense를 통한 로딩 상태 처리
export default function RootRoute() {
return (
<Suspense fallback={<GlobalLoader />}>
<Routes>
<Route path="/post/:postId" element={<PostDetailPage />} />
<Route path="/profile/:userId" element={<ProfileDetailPage />} />
<Route path="/sign-in" element={<SignInPage />} />
</Routes>
</Suspense>
);
}
// 컴포넌트 레벨 동적 import
const PostEditor = lazy(() => import('@/components/modal/post-editor-modal'));결과
- 초기 번들 크기 약 40% 감소
- 첫 페이지 로딩 시간 대폭 단축
문제 상황
- 동일한 사용자 정보가 여러 포스트와 댓글에 중복 저장되어 메모리 낭비
- 사용자 정보 업데이트 시 일관성 유지의 어려움
해결 과정
// 정규화된 데이터 구조 설계
export function useInfinitePostsData(authorId?: string) {
return useInfiniteQuery({
queryFn: async ({ pageParam }) => {
const posts = await fetchPosts({ from, to, userId: session!.user.id, authorId});
// 개별 엔티티를 각각 캐시에 저장
posts.forEach((post) => {
queryClient.setQueryData(QUERY_KEYS.post.byId(post.id), post);
queryClient.setQueryData(QUERY_KEYS.profile.byId(post.author_id), post.author);
});
// 목록에는 ID만 저장하여 정규화
return posts.map((post) => post.id);
},
});
}
// 정규화된 데이터 접근
export function usePostById(postId: number) {
return useQuery({
queryKey: QUERY_KEYS.post.byId(postId),
queryFn: () => { throw new Error("Post should be populated from cache"); },
initialData: () => queryClient.getQueryData(QUERY_KEYS.post.byId(postId)),
});
}결과
- 메모리 사용량 약 50% 감소
- 데이터 일관성 유지 및 업데이트 효율성 향상
src/
├── api/ # API 호출 함수
├── assets/ # 정적 자산
├── components/ # React 컴포넌트
│ ├── ui/ # 재사용 가능한 UI 컴포넌트
│ ├── layout/ # 레이아웃 컴포넌트
│ ├── modal/ # 모달 컴포넌트
│ ├── post/ # 포스트 관련 컴포넌트
│ └── comment/ # 댓글 관련 컴포넌트
├── hooks/ # 커스텀 훅
│ ├── mutations/ # React Query 뮤테이션 훅
│ └── queries/ # React Query 쿼리 훅
├── lib/ # 유틸리티 함수
├── pages/ # 페이지 컴포넌트
├── provider/ # Context Provider
├── store/ # Zustand 스토어
└── types.ts # TypeScript 타입 정의
- 이 저장소를 포크합니다
- 새로운 브랜치를 생성합니다 (
git checkout -b feature/amazing-feature) - 변경사항을 커밋합니다 (
git commit -m 'Add some amazing feature') - 브랜치에 푸시합니다 (
git push origin feature/amazing-feature) - Pull Request를 생성합니다
이 프로젝트는 MIT 라이센스 하에 있습니다. 자세한 내용은 LICENSE 파일을 참고하세요.
- 개발자: [Your Name]
- 이메일: your.email@example.com
- 프로젝트 링크: https://github.com/yourusername/imstagramoo
- Supabase - 강력한 백엔드 서비스
- shadcn/ui - 아름다운 UI 컴포넌트
- TanStack Query - 효율적인 서버 상태 관리
- Zustand - 간단하고 강력한 상태 관리