"분명히 bio 컬럼 추가했는데 왜 없대?"
사용자 프로필에 bio(자기소개) 기능을 추가했습니다.
Supabase 대시보드에서 profiles 테이블에 bio 컬럼(Text, Nullable)을 추가했습니다.
그리고 프론트엔드 코드를 짰습니다.
const { data } = await supabase.from('profiles').select('bio');
console.log(data.bio); // Error: Property 'bio' does not exist on type ...
TypeScript 컴파일러가 빨간 줄을 그으며 화를 냅니다.
"야, profiles 테이블엔 bio 같은 거 없어!"
"아니, 방금 내가 추가했다니까? 내 눈엔 보이는데?" 저는 모니터를 삿대질하며 억울해했습니다.
처음엔 뭐가 이해가 안 갔나? (실시간 연동의 환상)
저는 Supabase 클라이언트가 실시간으로 DB 스키마를 읽어오는 줄 알았습니다.
supabase-js가 마법처럼 DB를 스캔해서 타입을 알아낼 거라고 생각했죠.
하지만 TypeScript는 컴파일 타임(Compile Time)에 도는 정적 분석 도구입니다.
코드를 짜는 시점(VSCode)에 DB가 어떻게 생겼는지 알 방법이 없습니다.
누군가 "지금 DB는 이렇게 생겼어"라고 타입 정의 파일(database.types.ts)을 만들어주지 않으면, TS는 영원히 옛날 기억만 가지고 삽니다.
어떤 포인트에서 이해가 됐나? (사진 촬영 비유)
이걸 "졸업 앨범 촬영"에 비유하니 이해가 됐습니다.
- Database: 학생들(데이터)이 계속 성장하고 전학 가고 변합니다.
- Generated Types (
database.types.ts): 졸업 앨범(스냅샷)입니다.
제가 bio 컬럼을 추가한 건, 학생이 한 명 전학 온 겁니다.
하지만 제 손에 들린 건 작년에 찍은 졸업 앨범입니다.
앨범을 아무리 뒤져봐도 전학 온 학생 사진은 없습니다.
새로 사진을 찍어서(Type Generation) 앨범을 인쇄해야 비로소 그 학생이 보입니다.
"아, DB를 고쳤으면 앨범도 다시 찍어야 하는구나."
해결 과정 - 타입 생성 및 자동화
1단계 - 수동으로 타입 생성하기
가장 기본적인 방법은 CLI 명령어를 치는 겁니다.
npx supabase gen types typescript --project-id "your-project-id" > src/types/database.types.ts
이 명령어가 바로 "사진 촬영" 버튼입니다. Supabase 서버에 접속해서 현재 스키마를 읽어오고, 그걸 예쁜 TypeScript 인터페이스로 변환해서 파일로 저장해줍니다. 이걸 실행하고 나면 거짓말처럼 빨간 줄이 사라집니다.
2단계 - package.json에 스크립트 등록
매번 저 긴 명령어를 칠 순 없습니다.
// package.json
"scripts": {
"update-types": "npx supabase gen types typescript --project-id \"abcdefg\" > src/types/database.types.ts",
"dev": "npm run update-types && next dev"
}
이제 npm run update-types만 치면 됩니다.
3단계 - GitHub Actions로 자동화 (팀 프로젝트 필수)
팀원이 DB를 고치고 코드를 푸시했는데, 타입을 안 업데이트했다면?
제 로컬에서 git pull 받고 실행하자마자 에러가 터집니다.
"아, 김대리님! 타입 업데이트 안 하셨죠?"
이런 대화를 줄이려면 CI/CD 단계에서 막아야 합니다.
# .github/workflows/type-check.yml
name: Type Check
on: [push]
jobs:
check-types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm install
# DB랑 타입이 맞는지 확인 (생성 후 git diff가 있으면 실패 처리)
- run: npm run update-types
- run: git diff --exit-code
이렇게 하면 타입 파일이 최신이 아닐 때 커밋이 막히거나 CI가 실패해서, "앨범 업데이트"를 강제할 수 있습니다.
깊이 파고들기 - Helper Type 활용
생성된 타입 파일은 꽤 복잡합니다.
매번 Database['public']['Tables']['profiles']['Row'] 이렇게 쓰면 손가락 아픕니다.
Supabase가 제공하는 Helper Type을 쓰세요.
import { Database } from '@/types/database.types';
// 이렇게 길게 쓰지 마세요 ❌
// type Profile = Database['public']['Tables']['profiles']['Row'];
// 이렇게 쓰세요 ✅
type Profile = Database['public']['Tables']['profiles']['Row'];
type UnsavedProfile = Database['public']['Tables']['profiles']['Insert']; // id, created_at 제외됨
type ProfileUpdate = Database['public']['Tables']['profiles']['Update']; // 모든 필드가 Optional
저는 이걸 더 줄이기 위해 types/helpers.ts를 따로 만들어 둡니다.
export type Tables<T extends keyof Database['public']['Tables']> = Database['public']['Tables'][T]['Row'];
export type Enums<T extends keyof Database['public']['Enums']> = Database['public']['Enums'][T];
// 사용
type Profile = Tables<'profiles'>;
Introspection의 원리 (pg_catalog) 제대로 파보기
supabase gen types가 어떻게 동작하는지 궁금하지 않으신가요?
이 녀석은 마법을 부리는 게 아니라, Postgres의 시스템 카탈로그(pg_catalog)를 조회합니다.
SELECT
column_name,
data_type,
is_nullable
FROM
information_schema.columns
WHERE
table_name = 'profiles';
이 쿼리 결과를 가져와서 TS 문법(interface Profile { ... })으로 변환(Transpile)하는 겁니다.
그래서 DB가 꺼져 있거나 접근 권한이 없으면 타입 생성도 실패합니다.
(그래서 CI/CD에서 돌릴 때 SUPABASE_ACCESS_TOKEN이 필요한 이유입니다.)
8. Case Study: 금요일 오후 5시의 배포 참사
실제로 겪은 일입니다.
팀원이 DB에 is_premium 컬럼을 추가하고 로컬에서 잘 돌렸습니다.
그런데 배포 후 프로덕션 앱이 터졌습니다.
원인
- 팀원은 로컬 DB에만 컬럼을 추가함.
- 마이그레이션 파일(
supabase/migrations)을 안 만듦. - CI/CD 파이프라인이 돌면서 프로덕션 DB를 바라보고 타입을 생성함.
- 프로덕션 DB에는
is_premium이 없음 -> 타입 생성 시 필드 누락 -> 빌드 에러? 아니요, 빌드 성공! (왜냐하면 CI는 로컬 DB 기준으로 타입을 생성했으니까) - 런타임에 없는 컬럼 참조 -> 망함.
교훈
"DB 변경은 반드시 마이그레이션 코드로 관리해야 한다."
Dashboard UI로 깔짝거리는 건 혼자 할 때나 하는 겁니다. 팀 프로젝트에선 무조건 supabase migration new를 쓰세요.
9. Tip: 타입 오버라이딩 (Views & Functions)
Supabase가 타입을 잘 못 만들어주는 경우가 있습니다. 특히 복잡한 View나 SQL Function의 리턴 타입입니다.
이럴 땐 Override 타입을 만드세요.
// 원래 타입
// type UserStats = Database['public']['Views']['user_stats']['Row'];
// (이게 any로 나오거나 부정확할 때가 있음)
// 오버라이딩
export interface UserStatsOverride {
total_posts: number; // 원래 string으로 잘못 나올 때 강제 수정
last_active: string;
}
생성된 타입을 맹신하지 말고, 필요하면 확장(Extends)해서 쓰세요.
Supabase CLI 버전 지옥 더 알아보기
"어제는 됐는데 오늘은 타입 생성이 안 돼요." 범인은 Supabase CLI 버전 불일치일 확률이 높습니다.
로컬 CLI 버전(v1.100.0)과 Supabase 호스팅 버전(v1.110.0)이 차이가 많이 나면, gen types가 엉뚱한 결과를 뱉거나 에러를 뿜습니다.
해결책: 버전 고정
package.json에 supabase 패키지 버전을 명시하고, 팀원 모두가 같은 버전을 쓰도록 강제하세요. (engines 필드 활용)
brew install supabase로 설치하면 각자 버전이 달라지기 쉽습니다. npm install -D supabase로 프로젝트 로컬에 설치하는 것이 안전합니다.
11. Case Study: JsonB 컬럼 타입 정의하기
Supabase의 jsonb 타입은 TS에서 기본적으로 Json (any랑 비슷함)으로 나옵니다.
이걸 구체적인 타입으로 바꾸고 싶다면?
방법 1: 제네릭 활용 (수동 변환)
interface UserMeta {
theme: 'dark' | 'light';
notifications: boolean;
}
const { data } = await supabase.from('users').select('metadata');
const meta = data.metadata as unknown as UserMeta; // 못생김
방법 2: Database 타입 오버라이드 (추천)
database.types.ts 생성 후, Json 타입을 찾아서 바꿔치기합니다.
하지만 매번 생성할 때마다 덮어씌워지므로, 별도의 Wrapper Type을 만드는 게 낫습니다.
type Row = Database['public']['Tables']['users']['Row'];
export interface UserRow extends Omit<Row, 'metadata'> {
metadata: UserMeta; // 여기서 타입을 좁혀줌
}
12. Anti-Pattern: @ts-ignore의 유혹
타입 에러가 안 잡히면 @ts-ignore나 any로 땜질하고 싶은 유혹이 듭니다.
"일단 배포하고 나중에 고치자."
하지만 이건 기술 부채(Technical Debt)가 아니라 기술 파산(Technical Bankruptcy)으로 가는 지름길입니다. DB 스키마가 변경되었을 때, 이 무시된 코드들은 런타임 에러의 지뢰밭이 됩니다.
차라리 Partial<Type>이나 Pick을 써서 타입을 느슨하게 만들더라도, any는 쓰지 마세요.
TS를 쓰는 이유를 스스로 부정하지 마십시오.
13. Refactoring Challenge: Generic Wrapper Function
문제:
매번 supabase.from('table').select() 할 때마다 타입을 단언(as)하거나 제네릭을 길게 써야 합니다.
도전: Supabase Client를 래핑해서, 테이블 이름만 넣으면 자동으로 타입이 추론되게 만드세요.
// 목표:
const users = await db.get('users'); // users는 User[] 타입으로 자동 추론됨
힌트:
Database['public']['Tables']를 제네릭 T로 받아서 Row를 리턴하는 헬퍼 함수를 작성하면 됩니다.
이것이 진정한 "Type Safety"의 시작입니다.