Supabase를 도입하면 초반에는 컴포넌트 안에서 직접 supabase.from을 호출하는 방식으로 빠르게 시작한다. 하지만 프로젝트가 커질수록 비즈니스 로직과 DB 접근 코드가 뒤섞여 유지보수가 어려워진다. 역할을 명확히 나눈 폴더 구조가 필요한 이유다.
src/
├── lib/
│ └── supabase.ts # 클라이언트 싱글톤
├── types/
│ └── database.types.ts # supabase gen으로 자동 생성
├── repositories/
│ └── postRepository.ts # 순수 DB 쿼리
├── services/
│ └── postService.ts # 비즈니스 로직
└── hooks/
└── usePosts.ts # React 상태 + 호출
각 레이어는 단방향으로만 의존한다. Hooks → Services → Repositories → Supabase Client 순이다.
클라이언트 싱글톤은 앱 전체에서 하나의 인스턴스를 공유한다. 환경에 따라 브라우저용과 서버용을 분리한다.
// src/lib/supabase.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/types/database.types";
export const supabase = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
createBrowserClient: 브라우저 환경에서 세션 쿠키를 자동으로 관리하는 Supabase SSR 패키지의 함수. 서버 컴포넌트에서는 createServerClient를 사용한다.
타입은 직접 작성하지 않는다. Supabase CLI로 DB 스키마에서 자동 생성한다.
npx supabase gen types typescript \
--project-id YOUR_PROJECT_ID \
--schema public \
> src/types/database.types.ts
생성된 파일을 수정하지 않는다. 스키마가 바뀔 때마다 재생성한다.
Repository 패턴은 DB 접근 코드를 한 곳에 모은다. 비즈니스 로직 없이 쿼리만 담는다.
// src/repositories/postRepository.ts
import { supabase } from "@/lib/supabase";
import type { Database } from "@/types/database.types";
type Post = Database["public"]["Tables"]["posts"]["Row"];
export const postRepository = {
async findAll(): Promise<Post[]> {
const { data, error } = await supabase
.from("posts")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data;
},
async findById(id: string): Promise<Post | null> {
const { data, error } = await supabase
.from("posts")
.select("*")
.eq("id", id)
.single();
if (error) throw error;
return data;
},
};
Service 레이어는 Repository를 조합해 실제 애플리케이션 규칙을 구현한다. 예를 들어 게시글 작성 시 slug 중복 검사나 권한 확인이 여기에 들어간다.
// src/services/postService.ts
import { postRepository } from "@/repositories/postRepository";
export const postService = {
async getPublishedPosts() {
const posts = await postRepository.findAll();
return posts.filter((p) => p.status === "published");
},
};
Service 레이어: Controller(또는 Hook)와 Repository 사이에 위치하며, 단일 책임 원칙에 따라 비즈니스 규칙만 담당한다.
Custom Hook은 Service를 호출하고 로딩·에러 상태를 컴포넌트에 노출한다.
// src/hooks/usePosts.ts
import { useEffect, useState } from "react";
import { postService } from "@/services/postService";
export function usePosts() {
const [posts, setPosts] = useState<Awaited<ReturnType<typeof postService.getPublishedPosts>>>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
postService.getPublishedPosts()
.then(setPosts)
.finally(() => setLoading(false));
}, []);
return { posts, loading };
}
| 레이어 | 위치 | 책임 |
|---|---|---|
| Client | lib/supabase.ts | 인스턴스 생성 및 설정 |
| Types | types/database.types.ts | DB 스키마 타입 (자동 생성) |
| Repository | repositories/ | SQL 쿼리 추상화 |
| Service | services/ | 비즈니스 로직 |
| Hook | hooks/ | React 상태 관리 |
컴포넌트는 Hook만 호출하고, DB 접근 방식이 바뀌어도 Repository만 수정하면 된다. 레이어가 명확하면 테스트 작성도 쉬워진다. Service는 Repository를 Mock으로 교체해 순수 함수처럼 테스트할 수 있다.