Supabase 하나로 글로벌 콘텐츠 분리하기

김현준·2025년 7월 29일

해외 유저를 고려하면서, 콘텐츠와 사용자 구조를 깔끔하게 분리할 수 있는 아키텍처가 필요했다.
국문 콘텐츠와 영문 콘텐츠는 완전히 따로 관리하면서도, 사용자 계정은 공통으로 유지하고 싶었다.


요구사항 정리

  1. 국내 / 글로벌 콘텐츠는 완전히 분리

    • 한국 콘텐츠는 한국 유저가 작성한 한글 데이터
    • 글로벌 콘텐츠는 글로벌 유저가 영어로 직접 작성한 콘텐츠만 사용
  2. UI는 다국어(i18n) 유지

    • next-intl로 정적 텍스트 번역만 처리
  3. 향후 글로벌 확장 대응 필요

    • 외국 관광지 중심의 콘텐츠가 글로벌의 주 대상
  4. 유저 계정은 공유 가능하게

    • 한국/글로벌 페이지 이동 시 로그인 유지되도록

아키텍처 선택

구조 유형설명
하나의 Supabase + 다중 스키마 분리가장 추천. 유저는 공유, 콘텐츠는 완전 분리. 확장성 좋고 유지보수 편함
Supabase 2개 분리유저도 따로 쓰는 구조. 콘텐츠 분리는 쉽지만, 로그인/공통 기능 불편
하나의 테이블 + locale 컬럼한 테이블에 다 때려넣는 방식. 나중에 꼬일 위험 큼. 비추 ❌

그럼 네 블로그 구조를 유지하면서, 방금 내가 설명한 내용을 "스키마 단위 분리의 핵심 장점" 부분에 바로 녹여서 한눈에 읽히게 재구성해줄게.


왜 굳이 스키마를 분리했을까?

처음엔 나도 단순히 테이블 이름만 log_en, place_en 식으로 구분하면 충분하다고 생각했다.
하지만 실제로 글로벌 콘텐츠가 늘어나고 유지보수를 고려하니 스키마 레벨에서의 분리가 훨씬 합리적이었다.

스키마 단위 분리의 핵심 장점

  1. RLS 정책을 스키마 단위로 적용

    • RLS(Row Level Security)는 누가 어떤 데이터를 볼 수 있는지를 제어하는 기능.
    • 모든 콘텐츠가 public 스키마에 섞여 있으면, 한국/영문 콘텐츠를 구분하려면 테이블마다 조건(locale='en')을 넣어야 함.
    • 테이블이 늘어날수록 정책 추가·수정이 번거롭고, 하나라도 빠뜨리면 보안 사고 발생 가능.
    • 스키마를 분리하면 public에는 한국 콘텐츠 정책만, en에는 글로벌 콘텐츠 정책만 설정 가능.
    • 스키마에 한 번 설정하면 내부 모든 테이블에 일괄 적용 → 실수 방지.
  2. 권한 설정을 스키마 단위로 적용

    • Supabase나 PostgreSQL에서 권한을 테이블 단위로 주면, 새 테이블이 생길 때마다 권한을 다시 부여해야 함.

    • 스키마 단위로 주면 예를 들어,

      grant usage on schema en to authenticated;
      grant select, insert, update, delete on all tables in schema en to authenticated;

      이렇게 하면 en 폴더 전체에 한 번에 권한 부여 가능.

    • 새 테이블이 생겨도 자동으로 권한 적용 → 관리 효율성 향상.

  3. 마이그레이션 안정성

    • 마이그레이션(테이블 추가·삭제·변경) 시 스키마 단위로 백업·변경 가능.
    • 예: en 스키마만 변경해도 public 콘텐츠에는 전혀 영향 없음.
    • 롤백도 해당 스키마만 하면 되므로 변경 위험 최소화.

정리
“한국/글로벌 콘텐츠를 DB 폴더처럼 따로 두고, 그 폴더 단위로 접근 권한·보안 정책·변경 작업을 관리하는 것”
→ 이렇게 하면 보안 실수 줄고, 관리 편하고, 확장도 쉬움.

간단 요약

"DB 스키마 분리 = 콘텐츠를 폴더 단위로 깔끔히 나누는 것"

  • public 스키마 → 한국 콘텐츠 전용 폴더
  • en 스키마 → 글로벌 콘텐츠 전용 폴더
📂 auth      → 인증
📂 public    → 한국 콘텐츠
📂 en        → 영어 콘텐츠

이 구조의 장점

비교 항목테이블 네이밍 분리 (log_en)스키마 분리 (en.log_en)
실수 방지❌ 테이블명으로만 구분, 혼동 위험✅ Prisma에서 명확히 분리 가능
권한 제어(RLS 등)❌ 제어 어려움✅ 스키마 단위로 정책 적용 가능
유지보수/마이그레이션❌ 나중에 테이블 늘어나면 복잡해짐✅ 스키마 단위로 백업/배포/추적 용이
Prisma 관리❌ 모든 모델이 한 client에 뒤섞임prisma.en.log_en 등 명시적 접근 가능

반대로 DB 자체를 분리할 경우

간혹 "아예 Supabase 프로젝트를 2개로 나누면 되지 않나?"라는 질문이 있을 수 있다.
하지만 Supabase는 DB 단위로 유저 인증(auth schema)을 관리

  • 유저 계정 공유 불가
  • 로그인 상태 유지도 안 됨
  • API, 코드 관리 2배로 복잡해짐

즉, 콘텐츠는 분리돼도 유저 공통 기능이 전혀 불가능하다.


구조 설계

스키마 이름용도
authSupabase 기본 인증 (유저 인증 등)
public🇰🇷 한국 콘텐츠 + 유저 프로필
en글로벌(영문) 콘텐츠 전용
  • 단순히 place_en 같은 이름으로만 구분하고 public 스키마에 넣는 건 불완전
  • 스키마는 단순한 네이밍이 아니라, 실제 데이터 격리와 정책 분리까지 가능하게 하는 논리적 경계

적용 단계

1. 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 스키마가 실제로 동작하진 않으며, 이 설정이 반드시 필요

2. 글로벌 콘텐츠 모델 추가 (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는 한국/글로벌 공통 유저 테이블로 그대로 참조 가능

3. Prisma generate 실행

npx prisma generate

4. 코드에서 locale에 따라 분기

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();
  }
};

언어에 따라 다른 테이블에서 데이터 조회하도록 분기 처리


5. Supabase에 en 스키마 생성

Supabase SQL Editor에서 아래 쿼리 실행:

create schema if not exists en;

6. 콘텐츠 테이블 prisma db push로 생성

npx prisma db push

7. Supabase Data API에 en 스키마 노출하기

Prisma에서 en 스키마를 선언해도, Supabase API로 접근하려면 해당 스키마를 Exposed Schemas에 추가해야 한다.

  1. Supabase 대시보드 접속
  2. 프로젝트 세팅 → Data API로 이동
  3. Exposed schemas 필드에 en 추가
    (예: public, graphql_public, en)
  4. 저장(Save) 버튼 클릭

이 설정을 해두면 Supabase REST API나 client SDK에서 en 스키마 내 테이블에 접근할 수 있다.


좋아, 그럼 내가 아까 준 SQL 절차를 프론트엔드 개발자 기준으로,
SQL에 익숙하지 않아도 이해할 수 있게 주석과 설명을 덧붙여서 써줄게.


8. 설정 확인 방법

1) 스키마가 있는지 확인 → 없으면 생성

-- 'en'이라는 이름의 폴더(스키마)가 DB에 있는지 확인
select schema_name
from information_schema.schemata
where schema_name = 'en';

-- 없으면 새로 만든다
create schema if not exists en;
  • 스키마(schema) = 데이터베이스 안에 있는 폴더 같은 것.
    public 폴더에는 한국 콘텐츠, en 폴더에는 글로벌 콘텐츠를 넣는 구조다.

2) 기본적으로 열려 있는 권한(특히 anon)을 회수

-- '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;
  • 이 단계는 보안 클린업.
    공개 API 키로도 en 데이터가 보이지 않게 잠그는 과정이다.

3) 로그인한 사용자(authenticated)에게 필요한 최소 권한 부여

-- '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 폴더의 데이터에 접근할 수 있다.

4) 특정 시퀀스 권한 보완 (에러 방지)

-- 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 값을 못 가져올 때 생긴다.
    이 권한을 주면 해결된다.

5) API에서 스키마 접근 허용

  1. Supabase Dashboard → Settings → API → Exposed schemas로 이동
  2. en을 추가하고 저장
  3. 코드에서:
// 특정 스키마로 접근
supabase.schema('en').from('log_en').select('*')

이렇게 하면 RLS 정책만 맞게 설정되어 있다면
프론트엔드에서 en 스키마로 INSERT/SELECT가 정상 동작한다.


9. RLS(Row Level Security) 활성화 확인

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로 쓸 때는 위 설정이 안정성을 좌우한다.


이미지 스토리지 전략

  • Supabase 스토리지 버킷은 하나만 사용
  • /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), 마이그레이션, 백업, 유지보수 등에 있어
확실한 기준과 안정성을 만들어준다.

글로벌 콘텐츠가 본격적으로 늘어나기 전에,
이러한 다중 스키마 전략을 미리 설계하고 적용해두는 것은 매우 전략적인 선택이다.

profile
기록하자

0개의 댓글