> drizzle orm대신에 supabase 클라이언트 사용해보기
drizzle orm은 sql 구문도 알아야하고, 관계가 엮여 있을 경우 복잡해진다. 그래서 supabase 클라이언트 orm을 대신해서 사용해보자
npm install @supabase/supabase-js 설치
- env파일에 SUPABASE_URL 과 SUPABASE_ANON_KEY 추가하기 (해당 값은 SUPABASE 대쉬보드 -> project settings -> Configuration - Data API에서 확인 가능)
- supa-clinet.ts 파일
import { createClient } from "@supabase/supabase-js";
import type { Database } from "database.types";
const client = createClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
export default client;
이 때, supabase 데이터베이스의 타입 지정이 안되어 있기 때문에, type을 지정해주어야 한다. 그렇게 해야지, 나중에 client를 쓸 때 자동완성 기능 및 타입이 활성화 된다. 아래 package.json 파일에 db:typegen 부분 추가 project-id 부분은 내 프로젝트 id에 맞게 변경해준다. 그리고 npm run db:typegen을 입력하면 database.types.ts 파일이 생성되고 그 안에 내 db에 있는 값들이 타입이 지정되어 저장된다.
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc --build --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:typegen": "supabase gen types typescript --project-id gvqgqdtaexljujjearal > database.types.ts"
},
** Access token not provided. Supply an access token by running supabase login or setting the SUPABASE_ACCESS_TOKEN environment variable.
database.types.ts 생성시 login 오류 나시면
npx supabase login > enter > url > 코드 인증 후
npx run db:typegen ✅
- features/community/queries.ts
// import db from "~/db";
// import { posts, postUpvotes, topics } from "./schema";
// import { asc, count, desc, eq } from "drizzle-orm";
// import { profiles } from "../users/schema";
import client from "~/supa-client";
// export const getTopics = async () => {
// const allTopics = await db
// .select({
// name: topics.name,
// slug: topics.slug,
// })
// .from(topics);
// return allTopics;
// };
// export const getPosts = async () => {
// const allPosts = await db
// .select({
// id: posts.post_id,
// title: posts.title,
// createdAt: posts.created_at,
// topic: topics.name,
// author: profiles.name,
// authorAvatarUrl: profiles.avatar,
// username: profiles.username,
// upvotes: count(postUpvotes.post_id),
// })
// .from(posts)
// .innerJoin(topics, eq(posts.topic_id, topics.topic_id))
// .innerJoin(profiles, eq(posts.profile_id, profiles.profile_id))
// .leftJoin(postUpvotes, eq(posts.post_id, postUpvotes.post_id))
// .groupBy(
// posts.post_id,
// profiles.name,
// profiles.avatar,
// profiles.username,
// topics.name
// )
// .orderBy(asc(posts.post_id));
// return allPosts;
// };
export const getTopics = async () => {
const { data, error } = await client.from("topics").select("name, slug");
if (error) throw new Error(error.message);
return data;
};
export const getPosts = async () => {
const { data, error } = await client.from("posts").select(`
post_id,
title,
created_at,
topic:topics!inner (
name
),
author:profiles!posts_profile_id_profiles_profile_id_fk!inner (
name,
username,
avatar
),
upvotes:post_upvotes (
count
)
`);
console.log(error);
if (error) throw new Error(error.message);
return data;
};
- 외래키가 있는 경우에는 inner를 지정해야한다. 그리고 위 author같은 경우는 posts 테이블과 profiles테이블의 외래키가 post_upvotes와도 엮여 있기 때문에 직접적으로 외래키(fk)를 지정해주어야한다. 그리고 upvotes 같은 경우도 배열로 값이 리턴된다. 이런 복잡한 문제를 아래 이어서 해결해보자.
> Views사용하기
sql 문법을 사용하여 supabase에 view를 만들어 supabase client 관계형데이터를 호출 할 때 쉽게 쓸 수 있게 해보자.
*복잡하지 않은 외래키 구문을 사용할때는 굳이 view 만들필요 없이 그냥 바로 supabase client사용하면 된다.
app/sql/views/community-post-list-view.sql
CREATE VIEW community_post_list_view AS
SELECT
posts.post_id,
posts.title,
posts.created_at,
topics.name AS topic,
profiles.name AS author,
profiles.avatar AS author_avatar,
profiles.username AS author_username,
COUNT(post_upvotes.post_id) AS upvotes
FROM posts
INNER JOIN topics USING (topic_id)
INNER JOIN profiles USING (profile_id)
LEFT JOIN post_upvotes USING (post_id)
GROUP BY posts.post_id, topics.name, profiles.name, profiles.avatar, profiles.username;
참고로 이 파일은 어디 업로드 되는게 아니라, 해당 데이터를 복.붙 하여 supabase db 부분에 쓰는 것이다.
아래와 같이 supabase sql editor 화면에 views부분을 만들어 run해주자. 그리고 마지막 SELECT 구문을 보면 생성된 view를 run해보면 문제 없이 작동하는것을 확인 할 수 있다. 이제 이것을 client에서 호출해보자.
그전에 먼저, 해당 view값이 업데이트 되었기 때문에 npm run db:typegen을 해주자. (타입 업데이트)
- features/community/queries.ts
// import db from "~/db";
// import { posts, postUpvotes, topics } from "./schema";
// import { asc, count, desc, eq } from "drizzle-orm";
// import { profiles } from "../users/schema";
import client from "~/supa-client";
// export const getTopics = async () => {
// const allTopics = await db
// .select({
// name: topics.name,
// slug: topics.slug,
// })
// .from(topics);
// return allTopics;
// };
// export const getPosts = async () => {
// const allPosts = await db
// .select({
// id: posts.post_id,
// title: posts.title,
// createdAt: posts.created_at,
// topic: topics.name,
// author: profiles.name,
// authorAvatarUrl: profiles.avatar,
// username: profiles.username,
// upvotes: count(postUpvotes.post_id),
// })
// .from(posts)
// .innerJoin(topics, eq(posts.topic_id, topics.topic_id))
// .innerJoin(profiles, eq(posts.profile_id, profiles.profile_id))
// .leftJoin(postUpvotes, eq(posts.post_id, postUpvotes.post_id))
// .groupBy(
// posts.post_id,
// profiles.name,
// profiles.avatar,
// profiles.username,
// topics.name
// )
// .orderBy(asc(posts.post_id));
// return allPosts;
// };
export const getTopics = async () => {
const { data, error } = await client.from("topics").select("name, slug");
if (error) throw new Error(error.message);
return data;
};
export const getPosts = async () => {
const { data, error } = await client
.from("community_post_list_view")
.select(`*`);
console.log(error);
if (error) throw new Error(error.message);
return data;
};
community_post_list_view를 생성했기 때문에, client에서 호출해올 수 있고(타입에러도 안생김) 거기서 select(*)을 하게 되면 이전에 view파일에서 정의했던 값들을 가져올 수 있다. (번거로운데...?)
이렇게 하고 실제 값을 가져다 쓰는 곳을 보면 아래와 같이 직접 null이 아니라고 지정해야 타입 에러가 안난다. (null 값이 아닌데도
string | null 이런식으로 나와서 타입에러가 나옴.... 버그인듯?? --> !를 붙여서 해당 문제를 해결하는게 가장 쉬운 방법이나 다른 방법도 있다. )
<PostCard
key={post.post_id}
id={post.post_id!}
title={post.title!}
author={post.author!}
authorAvatarUrl={post.author_avatar}
category={post.topic!}
postedAt={post.created_at!}
votesCount={post.upvotes!}
expanded
그건 바로 type-fest를 사용하여 타입을 오버라이드 하는 것이다.(귀찮다) npm install type-fest로 설치를 진행해보자.
supa-client.ts
import { createClient } from "@supabase/supabase-js";
import type { MergeDeep, SetNonNullable, SetFieldType } from "type-fest";
import type { Database as SupabaseDatabase } from "database.types";
type Database = MergeDeep<
SupabaseDatabase,
{
public: {
Views: {
community_post_list_view: {
Row: SetFieldType<
SetNonNullable<
SupabaseDatabase["public"]["Views"]["community_post_list_view"]["Row"]
>,
"author_avatar",
string | null
>;
};
};
};
}
>;
const client = createClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
export default client;
위와 같이 수정 하게 되면, views에서 가져온 타입과 내가 직접 지정한 타입을 오버라이딩 할 수 있다. (그냥 ! 를 붙이자..)
> trigger를 활용해서 upvote 바꾸기
기존에는 count를 사용해서 upvote값을 구했다. 하지만 사용자가 많아지면 count시에 시간이 지연될 수 있는 문제에 직면할수 있다. 미리 공부 차원에서 알아두자.
export const posts = pgTable("posts", {
post_id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
title: text().notNull(),
content: text().notNull(),
upvotes: bigint({ mode: "number" }).default(0),
created_at: timestamp().notNull().defaultNow(),
updated_at: timestamp().notNull().defaultNow(),
topic_id: bigint({ mode: "number" }).references(() => topics.topic_id, {
onDelete: "cascade",
}),
profile_id: uuid().references(() => profiles.profile_id, {
onDelete: "cascade",
}),
});
posts 테이블에 upvotes열 추가
- sql/trigger/post-upvote-trigger.sql
CREATE FUNCTION public.handle_post_upvote()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
UPDATE public.posts SET upvotes = upvotes + 1 WHERE post_id = NEW.post_id;
RETURN NEW;
END;
$$;
CREATE TRIGGER post_upvote_trigger
AFTER INSERT ON public.post_upvotes
FOR EACH ROW EXECUTE FUNCTION public.handle_post_upvote();
CREATE FUNCTION public.handle_post_unvote()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
UPDATE public.posts SET upvotes = upvotes - 1 WHERE post_id = OLD.post_id;
RETURN OLD;
END;
$$;
CREATE TRIGGER post_unvote_trigger
AFTER DELETE ON public.post_upvotes
FOR EACH ROW EXECUTE FUNCTION public.handle_post_unvote();
트리거에 대한 함수 선언과 트리거 세팅. 해당 값을 supabase sql editor에 사용할 것이다. 트리거 생성은 sql editor에서 사용할수도 있고, 직접 화면에서 클릭하면서 수동으로 설정 할수도 있다. 참고로 여기서 NEW와 OLD의 의미는?
> SSR 로딩 화면
- community-page.tsx
export const loader = async () => {
await new Promise((resolve) => setTimeout(resolve, 10000));
const topics = await getTopics();
const posts = await getPosts();
return { topics, posts };
};
특정페이지 로딩이 길어진다고 가정해보자.
- root.tsx
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLocation,
useNavigation,
} from "react-router";
import type { Route } from "./+types/root";
import stylesheet from "./app.css?url";
import Navigation from "./common/components/navigation";
import { Settings } from "luxon";
import { cn } from "./lib/utils";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
{ rel: "stylesheet", href: stylesheet },
];
export function Layout({ children }: { children: React.ReactNode }) {
Settings.defaultLocale = "ko";
Settings.defaultZone = "Asia/Seoul";
return (
<html lang="en" className="">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<main>{children}</main>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
const { pathname } = useLocation();
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
return (
<div
className={cn({
"py-28 px-5 md:px-20": !pathname.includes("/auth/"),
"transition-opacity animate-pulse": isLoading,
})}
>
{pathname.includes("/auth") ? null : (
<Navigation
isLoggedIn={true}
hasNotifications={false}
hasMessages={false}
/>
)}
<Outlet />
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
useNavigation을 사용해서 loading의 상태를 확인 할 수 있고 해당 값을 이용하여 로딩 화면을 구현해줄 수 있다. (그냥 이 방법으로 진행하는게 좋겠다)
- 위 방법 말고도, 화면 이동 후에 suspense를 활용해서 loading이 필요한 부분만 따로 표시해줄 수 있다. (아래코드 참조)
https://github.com/nomadcoders/wemake/commit/874adeb6b1dc31c78d4654569abc003d3dc3937e
5.8 Suspense and Await · nomadcoders/wemake@874adeb
@@ -13,19 +13,24 @@ import { PERIOD_OPTIONS, SORT_OPTIONS } from "../constants";
github.com
- prefetch 방법도 있다.(3종류) 하는 방법은, Link에 props에 prefetch가 있는데 여기에 1)intent (마우스 호버 되면 해당 링크에 연결된 db값들이 prefetch됨) 2)render는 Link가 렌더 될 때 prefetch 3)viewport는 화면에 해당 Link가 보일 때 prefetch 된다.
> 클라이언트상에서 로딩 방법
서버쪽 말고 클라이언트단에서 로딩이 가능하다. 로컬 스토리지 호출같은 경우에는 서버 단에서 값을 불러올 수 없기 때문에 클라이언트단에서 불러와야 한다. 이럴 때 쓰면 되겠다. (일반적 상황에서는 server이용 하면 될 듯하다.
export const loader = async () => {
const [topics, posts] = await Promise.all([getTopics(), getPosts()]);
return { topics, posts };
};
export const clientLoader = async ({
serverLoader,
}: Route.ClientLoaderArgs) => {
//track analytics
};
export const loader는 서버에서 값 호출 하는 것이고, export const clientLoader는 클라이언트에서 호출 하는 것이다. 그리고 동시에 사용 할수도 있는데, 이 때는 서버에서 받은 값이 clientLoader의 인자값으로 serverLoader이 쓰인다. 그리고 clientLoader만 사용 할 경우에는, supa-client.ts 파일의 .env파일을 읽어올 수 없기 때문에 직접 지정해주어야 한다.
const client = createClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
// 위 코드 대신에, 직접 입력해주어야함
'코딩강의 > Maker 마스터클래스(노마드코더)' 카테고리의 다른 글
#7 Authentication (0) | 2025.04.28 |
---|---|
#6 Public Pages (0) | 2025.04.17 |
#4 Supabase & Drizzle Database (3) | 2025.04.08 |
#3 UI with Cursor & Shadcn (0) | 2025.03.18 |
#2 Using CursorAI (0) | 2025.03.17 |