해외 유저를 고려하면서, 콘텐츠와 사용자 구조를 깔끔하게 분리할 수 있는 아키텍처가 필요했다.
국문 콘텐츠와 영문 콘텐츠는 완전히 따로 관리하면서도, 사용자 계정은 공통으로 유지하고 싶었다.
국내 / 글로벌 콘텐츠는 완전히 분리
UI는 다국어(i18n) 유지
next-intl로 정적 텍스트 번역만 처리향후 글로벌 확장 대응 필요
유저 계정은 공유 가능하게
| 구조 유형 | 설명 |
|---|---|
| 하나의 Supabase + 다중 스키마 분리 | 가장 추천. 유저는 공유, 콘텐츠는 완전 분리. 확장성 좋고 유지보수 편함 |
| Supabase 2개 분리 | 유저도 따로 쓰는 구조. 콘텐츠 분리는 쉽지만, 로그인/공통 기능 불편 |
하나의 테이블 + locale 컬럼 | 한 테이블에 다 때려넣는 방식. 나중에 꼬일 위험 큼. 비추 ❌ |
그럼 네 블로그 구조를 유지하면서, 방금 내가 설명한 내용을 "스키마 단위 분리의 핵심 장점" 부분에 바로 녹여서 한눈에 읽히게 재구성해줄게.
처음엔 나도 단순히 테이블 이름만 log_en, place_en 식으로 구분하면 충분하다고 생각했다.
하지만 실제로 글로벌 콘텐츠가 늘어나고 유지보수를 고려하니 스키마 레벨에서의 분리가 훨씬 합리적이었다.
RLS 정책을 스키마 단위로 적용
public 스키마에 섞여 있으면, 한국/영문 콘텐츠를 구분하려면 테이블마다 조건(locale='en')을 넣어야 함.public에는 한국 콘텐츠 정책만, en에는 글로벌 콘텐츠 정책만 설정 가능.권한 설정을 스키마 단위로 적용
Supabase나 PostgreSQL에서 권한을 테이블 단위로 주면, 새 테이블이 생길 때마다 권한을 다시 부여해야 함.
스키마 단위로 주면 예를 들어,
grant usage on schema en to authenticated;
grant select, insert, update, delete on all tables in schema en to authenticated;
이렇게 하면 en 폴더 전체에 한 번에 권한 부여 가능.
새 테이블이 생겨도 자동으로 권한 적용 → 관리 효율성 향상.
마이그레이션 안정성
en 스키마만 변경해도 public 콘텐츠에는 전혀 영향 없음.정리
“한국/글로벌 콘텐츠를 DB 폴더처럼 따로 두고, 그 폴더 단위로 접근 권한·보안 정책·변경 작업을 관리하는 것”
→ 이렇게 하면 보안 실수 줄고, 관리 편하고, 확장도 쉬움.
"DB 스키마 분리 = 콘텐츠를 폴더 단위로 깔끔히 나누는 것"
public스키마 → 한국 콘텐츠 전용 폴더en스키마 → 글로벌 콘텐츠 전용 폴더
📂 auth → 인증
📂 public → 한국 콘텐츠
📂 en → 영어 콘텐츠
| 비교 항목 | 테이블 네이밍 분리 (log_en) | 스키마 분리 (en.log_en) |
|---|---|---|
| 실수 방지 | ❌ 테이블명으로만 구분, 혼동 위험 | ✅ Prisma에서 명확히 분리 가능 |
| 권한 제어(RLS 등) | ❌ 제어 어려움 | ✅ 스키마 단위로 정책 적용 가능 |
| 유지보수/마이그레이션 | ❌ 나중에 테이블 늘어나면 복잡해짐 | ✅ 스키마 단위로 백업/배포/추적 용이 |
| Prisma 관리 | ❌ 모든 모델이 한 client에 뒤섞임 | ✅ prisma.en.log_en 등 명시적 접근 가능 |
간혹 "아예 Supabase 프로젝트를 2개로 나누면 되지 않나?"라는 질문이 있을 수 있다.
하지만 Supabase는 DB 단위로 유저 인증(auth schema)을 관리
즉, 콘텐츠는 분리돼도 유저 공통 기능이 전혀 불가능하다.
| 스키마 이름 | 용도 |
|---|---|
auth | Supabase 기본 인증 (유저 인증 등) |
public | 🇰🇷 한국 콘텐츠 + 유저 프로필 |
en | 글로벌(영문) 콘텐츠 전용 |
schema.prisma 설정// datasource 설정
// datasource 설정
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
schemas = ["auth", "public", "en"] // en 추가: 사용할 DB 스키마들 명시
}
// generator는 그대로 유지
generator client {
provider = "prisma-client-js"
previewFeatures = ["multiSchema"]
}
schemas 옵션은 Prisma가 어떤 PostgreSQL 스키마들을 읽고 관리할지 지정@@schema("en")만 붙여도 en 스키마가 실제로 동작하진 않으며, 이 설정이 반드시 필요log_en 예시)model log_en {
log_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
title String
user_id String @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
// 유저 정보는 기존 public_users 사용
users public_users @relation(fields: [user_id], references: [user_id], onDelete: Cascade)
@@schema("en") // en 스키마로 명시
}
_en 네이밍 + @@schema("en")으로 구분public_users는 한국/글로벌 공통 유저 테이블로 그대로 참조 가능npx prisma generate
import { prisma } from "@/lib/prisma";
export const getLogs = async (locale: string) => {
if (locale === "en") {
return await prisma.en.log_en.findMany();
} else {
return await prisma.public.log.findMany();
}
};
언어에 따라 다른 테이블에서 데이터 조회하도록 분기 처리
Supabase SQL Editor에서 아래 쿼리 실행:
create schema if not exists en;
prisma db push로 생성npx prisma db push
en 스키마 노출하기Prisma에서 en 스키마를 선언해도, Supabase API로 접근하려면 해당 스키마를 Exposed Schemas에 추가해야 한다.
en 추가public, graphql_public, en)이 설정을 해두면 Supabase REST API나 client SDK에서 en 스키마 내 테이블에 접근할 수 있다.
좋아, 그럼 내가 아까 준 SQL 절차를 프론트엔드 개발자 기준으로,
SQL에 익숙하지 않아도 이해할 수 있게 주석과 설명을 덧붙여서 써줄게.
-- 'en'이라는 이름의 폴더(스키마)가 DB에 있는지 확인
select schema_name
from information_schema.schemata
where schema_name = 'en';
-- 없으면 새로 만든다
create schema if not exists en;
public 폴더에는 한국 콘텐츠, en 폴더에는 글로벌 콘텐츠를 넣는 구조다.-- 'anon' = 로그인 안 한 사용자.
-- 익명 유저가 en 스키마에 있는 데이터를 만질 수 없도록 권한 제거
revoke all on all tables in schema en from anon;
revoke all on all sequences in schema en from anon;
revoke all on all routines in schema en from anon;
revoke usage on schema en from anon;
-- 앞으로 생기는 새 테이블/시퀀스에도 anon 권한 자동 부여되지 않도록 제거
alter default privileges for role postgres in schema en
revoke all on tables from anon;
alter default privileges for role postgres in schema en
revoke all on sequences from anon;
alter default privileges for role postgres in schema en
revoke all on routines from anon;
en 데이터가 보이지 않게 잠그는 과정이다.-- 'authenticated' = 로그인한 사용자
-- en 스키마를 쓸 수 있는 권한(usage)과 데이터 조작 권한(select, insert, update, delete) 부여
grant usage on schema en to authenticated;
grant select, insert, update, delete on all tables in schema en to authenticated;
-- 자동 증가 번호(pk 시퀀스)를 읽고 갱신할 수 있게 sequence 권한도 부여
grant usage, select, update on all sequences in schema en to authenticated;
-- 앞으로 새로 생길 테이블/시퀀스에도 자동으로 동일 권한 부여
alter default privileges in schema en
grant select, insert, update, delete on tables to authenticated;
alter default privileges in schema en
grant usage, select, update on sequences to authenticated;
en 폴더의 데이터에 접근할 수 있다.-- log_tag_en의 자동 증가 번호(pk) 시퀀스 권한 확인
select grantee, object_schema, object_name, privilege_type
from information_schema.role_usage_grants
where object_schema='en'
and object_name='log_tag_en_log_tag_id_seq';
-- SELECT/USAGE/UPDATE 권한 없으면 부여
grant usage, select, update on sequence en.log_tag_en_log_tag_id_seq to authenticated;
permission denied for sequence ... 에러는 INSERT 시 자동 증가 PK 값을 못 가져올 때 생긴다.en을 추가하고 저장// 특정 스키마로 접근
supabase.schema('en').from('log_en').select('*')
이렇게 하면 RLS 정책만 맞게 설정되어 있다면
프론트엔드에서 en 스키마로 INSERT/SELECT가 정상 동작한다.
en 스키마 테이블은 기본적으로 권한이 부여되지 않은 상태이므로,
데이터 삽입/조회 전에 RLS 활성화 여부와 정책 설정을 반드시 확인해야 한다.

Next.js 15 서버 액션/라우트/미들웨어에서 Supabase SSR 클라이언트를 만들 때
cookies.setAll을 전달하지 않으면 다음 경고가 뜬다
createServerClient was configured without the setAll cookie method ...
이 경고를 무시하면 랜덤 로그아웃, 세션 조기 종료, 토큰 재발급 과다 같은 미묘한 버그가 생길 수 있다.
아래와 같이 공용 유틸을 만들어 서버 액션/Route Handler/Middleware에서 공통 사용하면 해결된다.
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
},
},
// DB 기본 스키마는 public로 두고, en은 쿼리 시 명시적으로 지정하면 된다.
// 예: supabase.schema('en').from('log_en').insert(...)
db: { schema: 'public' },
}
)
return supabase
}
서버 액션, Route Handler, Middleware에서 위 유틸을 그대로 호출하면 된다.
Prisma는 DB 서버 연결을 직접 사용하므로 별도 쿠키 설정이 필요 없지만, Supabase Auth/Storage/Edge Functions를 SSR로 쓸 때는 위 설정이 안정성을 좌우한다.
/kor/ 폴더에는 한국 콘텐츠 이미지 저장/en/ 폴더에는 글로벌 콘텐츠 이미지 저장콘텐츠별로 place_id나 log_id 기반 폴더에 저장하도록 이미 설계되어 있다면
굳이 /kor, /en 폴더로 나누지 않아도 된다.
const folder = locale === "en" ? "en" : "kor";
const fullPath = `${folder}/${filename}`;
권한(RLS)도 경로 기반으로 제어 가능하니 문제 없음
단, 다음과 같은 경우에는 폴더 분리도 고려 가능
추후 고려
상황 폴더 분리 필요성 Supabase 콘솔에서 사람이 직접 접근 kor/,en/으로 분리하면 보기 쉬움이미지 수백~수천 개로 폭증 디렉토리 트리 구조로 관리 유리 경로 기반 접근 제어(RLS) 필요 path LIKE 'en/%'조건 제어
| 항목 | 설정 |
|---|---|
| 한국 콘텐츠 | public 스키마 |
| 글로벌 콘텐츠 | en 스키마 |
| 유저 계정 | public_users로 공유 |
| 이미지 저장 | 버킷은 하나, ID 기반 경로 관리 (폴더 분리는 상황에 따라 선택) |
단순히 테이블 이름에 _en을 붙이는 것만으로는 불충분하다.
스키마 레벨에서의 분리는 추후 권한 설정(RLS), 마이그레이션, 백업, 유지보수 등에 있어
확실한 기준과 안정성을 만들어준다.
글로벌 콘텐츠가 본격적으로 늘어나기 전에,
이러한 다중 스키마 전략을 미리 설계하고 적용해두는 것은 매우 전략적인 선택이다.