토끼리
리뉴얼된 Next.js 튜토리얼을 진행할 때 데이터베이스도 Vercel 인프라 내에서 사용할 수 있다는 점이 인상깊었다. 마침 개인 프로젝트 개발 중 데이터베이스가 필요했고 Vercel Storage 중 하나인 Vercel Postgres를 사용해보기로 했다. 이 과정에서 충돌이 있었던 이슈들을 정리해본다.
Vercel Storage는 2023년 5월에 Vercel에서 발표한 데이터베이스 서비스이다. 별도의 서버 설정 없이 프론트엔드에서 바로 사용할 수 있게 서버리스 방식으로 동작한다. 총 4종류의 데이터베이스를 제공한다.
Redis는 key-value의 형태로 메모리에 데이터를 저장해 빠른 속도를 보장하는 데이터베이스이다. 메모리는 휘발성이므로 주기적으로 디스크에 스냅샷을 저장해 영속성을 보장한다. Vercel KV에
Durable
이라는 설명이 붙은 이유는 모든 write 요청마다 스냅샷을 저장하는 Upstash를 기반으로 동작하기 때문이다.
모두 Vercel Dashboard에서 저장소를 생성하고 환경변수를 프로젝트에 설정하면 프론트엔드에서 함수를 import해서 바로 사용할 수 있다.
DBaaS(Database as a Service)에도 많은 서비스가 있지만, 서버리스 중심인 Next.js에 맞춰 사용량 기반 과금 모델인 Firebase와 Supabase를 기반으로 비교한다.
사실상 유일하게 Vercel Storage가 이점을 가지고 있다고 느껴지는 부분이다. Vercel Storage 는 데이터베이스에 대한 지식이 부족하더라도 입문하기 쉽다. 근본적으로 Next.js를 타겟으로 나온 서비스를 통합한 것이기 때문에 보일러플레이트가 적고 충돌이 일어날 가능성 역시 낮다. 또한 핵심적인 기능만을 제공하기에 어떤 기능을 사용해야 할지에 대한 고민의 여지가 적다.
기본적으로 Vercel Storage는 타 DBaaS에 비해 덜 성숙하다는 느낌이 든다.
대시보드로 들어가면 아직 Beta 딱지도 붙어있다. 다만 실제로 Beta인 서비스는 Blob만 해당된다.
장점에서는 덕분에 고민이 적다고 했지만 복잡한 기능을 구현하기 시작하면 문제가 될 것이다. 특히 타 서비스는 Realtime을 지원해 실시간 동기화 기능을 구현할 수 있지만 Vercel에는 없다.
비슷한 과금 플랜을 가진 Supabase와 비교했을 때 hobby 플랜의 저장공간은 절반이고 CPU 배정량도 훨씬 적다. 또한 오픈소스인 Supabase는 로컬 호스팅을 지원해서 개인 컴퓨터에 설정한다면 용량 제한을 회피할 수도 있다.
Firebase와 Supabase 사이에는 타 서비스로 이관을 돕는 기능이 존재한다. Vercel Storage에는 없는 것으로 보이는데 서비스가 커져서 추가 기능이 필요할 때 문제가 될 수 있다.
단점이 많지만 일단 빠른 프로토타입 구현이 중요한 토이 프로젝트나 사이드 프로젝트에선 크게 문제가 안될 수도 있다. 데이터베이스에 대한 지식이 부족한 프론트엔드 개발자가 단순히 저장 기능을 하는 백엔드를 빠르게 만들고 싶을 때 한 가지 수단이 될 수 있다.
다만 어느정도 익숙해지고 나면 다른 걸 쓸 것 같다. 막상 정리해보니 나도 바꾸고싶다.
Vercel Postgres의 기본 사용법으로 개발사가 권장하는 건 sql
함수다. 다만 SQL 문법을 정확히 알고 있어야 하는데다가 타입이 적용되지 않아 불편할 수 있다. 이를 보완하기 위해 있는 기능이 ORM(Object Relational Model) 이다. ORM은 DB와 어플리케이션의 연결을 객체지향적으로 도와주는 기술로, SQL 언어 대신 어플리케이션 개발 언어를 사용하여 DB에 접근할 수 있게 해준다.
/**@ sql **/
import { sql } from '@vercel/postgres';
const likes = 100;
const { rows, fields } =
await sql`SELECT * FROM posts WHERE likes > ${likes} LIMIT 5;`;
/**@ Kysely ORM **/
import { createKysely } from '@vercel/postgres-kysely';
interface Database {
person: PersonTable;
pet: PetTable;
}
const db = createKysely<Database>();
await db
.insertInto('pet')
.values({ name: 'Catto', species: 'cat', owner_id: id })
.execute();
ORM도 SQL의 문법을 알고있어야 하는 건 맞지만 IDE에서 자동완성이 지원되므로 더 쉽게 사용할 수 있다. 또한 DB의 타입을 선언해두면 위 values
같은 경우에 타입 추론이 작동한다.
Vercel Postgres는 Kysely, Prisma, Drizzle 3종류의 ORM을 지원한다. 이 중 보일러플레이트가 가장 적은 Kysely를 사용하기로 했다.
Kysely는 정확히 따지면 ORM은 아니다. 타입 세이프하고 자동완성 친화적인 TypeScript SQL 쿼리 빌더로, 원래는 Prisma같은 별도의 ORM과 연동해서 사용한다고 한다. createKysely
로 DB 객체를 생성하는 기능은 초기엔 없었던 기능으로 추측된다.
공식 문서에서도 ORM이라고는 소개 안한다.
아래 코드 예시처럼 Database의 타입을 정의하고 createKysely
로 DB 객체를 export한 뒤 서버컴포넌트나 서버액션에서 사용하면 된다.
export interface Database {
Post: {
id: GeneratedAlways<string>;
userId: string | null;
content: string;
createdAt: GeneratedAlways<Date>;
updatedAt: Generated<Date>;
}
}
export const db = createKysely<Database>();
export { sql } from "kysely";
GeneratedAlways
처럼 수정 가능 여부를 나타내는 타입도 제공한다. 마지막 줄의 sql
은 ORM으로 매핑되지 않은 SQL의 기능이 필요할 때 사용한다.
초기 데이터베이스 테이블을 생성하는 기능도 ORM을 이용해 스크립트로 작성해두면 편하다.
/**@ ./script/seed.ts **/
import { db, sql } from "../app/lib/database/db.js";
export async function create() {
await db.schema
.createTable("Post")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`)
)
.addColumn("userId", "uuid", (col) =>
col.references("User.id").onDelete("set null")
)
.addColumn("content", "text", (col) => col.notNull())
.addColumn("createdAt", "timestamptz", (col) =>
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
)
.addColumn("updatedAt", "timestamptz", (col) =>
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
)
.execute();
await db.schema
.createIndex("Post_userId_index")
.on("Post")
.column("userId")
.execute();
console.log("생성 완료")
}
export async function drop() {
await db.schema.dropTable("Post").ifExists().execute();
console.log("제거 완료")
}
async function main() {
await (process.argv[2] === "drop" ? drop() : create());
process.exit();
}
main();
Kysely는 공식 문서보단 파일의 주석에 설명을 자세히 남기는 방식으로 가이드되어 있다. 기본 기능은 메서드에 마우스를 hover하거나 Ctrl + 클릭
으로 파일에 이동해 설명을 보고 사용하면 비교적 쉽게 사용할 수 있다. sql
을 사용해야하는 부분은 copilot에 SQL로 질문하면 잘 나온다.
원래는 테스트를 위핸 더미 데이터도 추가하는게 seed 과정이지만 편의를 위해 테이블 생성만 진행했다.
위 스크립트를 터미널에서 작동시킬 수 있도록 명령어를 등록한다.
/**@ package.json **/
"scripts": {
"seed": "DOTENV_CONFIG_PATH=.env.local node --no-warnings=ExperimentalWarning \
--loader ts-node/esm -r dotenv/config ./scripts/seed.ts"
},
나중에 데이터 추가도 작업할 예정이라 타입스크립트로 작성했다. 그런데 실행을 위해 ts-node
를 사용하려고 하면 노드 버전 20부터 에러가 생기는 이슈가 있다. 때문에 node
에 --loader ts-node/esm
옵션을 붙여 실행해야 한다. 관련 ts-node issue
-r dotenv/config
는 현재 프로젝트의 환경변수를 사용하겠다는 옵션이다. Next.js에서는 환경변수 파일의 이름이 보통 .env.local
이므로 DOTENV_CONFIG_PATH=.env.local
로 path를 지정해준다. 기본값은 .env
이다.
이처럼 설정하면 npm run seed
나 npm run seed drop
으로 데이터베이스를 초기화할 수 있다. 데이터베이스 타입을 수정할 때 유용하다.
다른 DBaaS와 다르게 Auth 기능이 Vercel Storage엔 없지만 NextAuth가 있다. NextAuth로 로그인한 사용자를 Postgres에 저장하고 싶다면 연결과정이 필요하다.
/**@ ./auth.ts **/
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: KyselyAdapter(db as KyselyAuth<Database>),
providers,
});
어댑터를 연결하면 Kysely를 경유해서 DB와 NextAuth를 연결할 수 있다.
위 코드에선
db
에 타입단언을 사용했다. 이는KyselyAdapter
내부에 테이블의id
타입이string
으로 설정되어있어서GeneratedAlways<string>
타입과 충돌하기 때문이다. 프로젝트 데이터베이스의id
타입을string
으로 변경해도 되지만 수정 불가 필드를 타입에서 명시적으로 표시하고 싶어서 타입단언을 선택했다.
NextAuth의 공식문서를 보면 Kysely의 DB 객체를 생성할 때 KyselyAuth
라는 구현체를 사용한다.
export const db = new KyselyAuth<Database>({
dialect: new PostgresDialect({
pool: new Pool({
host: process.env.DATABASE_HOST,
database: process.env.DATABASE_NAME,
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
}),
}),
})
이는 필수 데이터베이스 타입을 검증하는 기능만 추가된 wrapper class이므로 반드시 써야하는 것은 아니다. Vercel Postgres와 연결 기능을 하는 createKysely
를 그대로 사용하면 된다. 대신 NextAuth 예시에 나오는 User
, Account
, Session
, VerificationToken
테이블을 데이터베이스에 포함해야 한다.
Session과 VerificationToken은 원래 DB Session과 email 인증을 사용할 계획이 아니면 필요없는 테이블이라고 한다. 다만 제거하고 실행하면 NextAuth에서 두 테이블을 선언하라는 오류가 발생해 선언해놓았다.
이제 NextAuth를 이용해 소셜로그인을 하면 User
와 Account
테이블에 항목이 추가되는 것을 볼 수 있다.