Supabase 완전 정복 시리즈 4편 — RLS(Row Level Security) 완벽 가이드




시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 – 인증(Auth) — 소셜 로그인, JWT, MFA, SSO 4편 👉 RLS 보안 — Row Level Security 완벽 가이드 (현재 글) 5편 – 실시간 기능 (Realtime) — 구독 & 채널 …


들어가며

2025년 1월, 보안 연구자들이 Lovable로 만들어진 170개 이상의 앱에서 데이터베이스가 통째로 노출된 사건이 있었습니다. 원인은 단 하나였습니다. RLS를 활성화하지 않은 것입니다.

Row Level Security(RLS)는 Supabase를 안전하게 운영하기 위한 핵심 보안 장치입니다. anon 키가 클라이언트에 노출되어 있어도, RLS만 제대로 설정되어 있으면 사용자는 자신이 볼 수 있는 데이터만 볼 수 있습니다. API 레벨이 아닌 데이터베이스 레벨에서 강제되는 보안이기 때문입니다.

이번 편에서 다룰 내용:

  • RLS의 작동 원리와 핵심 개념
  • USING vs WITH CHECK 절의 차이
  • SELECT / INSERT / UPDATE / DELETE 정책 패턴
  • 자주 쓰는 실전 패턴 (개인 데이터, 공개 데이터, 팀/조직, 역할 기반)
  • JWT 클레임을 활용한 고급 정책
  • 멀티테넌트 앱 RLS 설계
  • 성능 최적화 (인덱스, initPlan 캐싱)
  • 정책 테스트 방법
  • 자주 하는 실수 모음

RLS의 작동 원리

개념: DB 레벨의 WHERE 절

RLS 정책은 쉽게 말하면 모든 쿼리에 자동으로 붙는 WHERE 절입니다. 예를 들어 이런 정책이 있다면:

CREATE POLICY "본인 데이터만 조회"
  ON todos
  FOR SELECT
  TO authenticated
  USING (auth.uid() = user_id);

클라이언트가 SELECT * FROM todos를 실행해도, 실제로는 SELECT * FROM todos WHERE user_id = '현재사용자UUID'처럼 동작합니다. 아무리 교묘한 쿼리를 날려도 RLS를 우회할 수 없습니다.

Supabase 역할(Role) 구조

RLS 정책은 역할(Role) 에 적용됩니다. Supabase에는 두 가지 핵심 역할이 있습니다.

역할설명언제 사용
anon비로그인 사용자공개 데이터 조회 허용 시
authenticated로그인 사용자대부분의 데이터 접근
service_role관리자 (RLS 우회)서버사이드 관리 작업

RLS 활성화

테이블 생성 후 RLS는 기본적으로 비활성화 상태입니다. SQL Editor나 대시보드에서 반드시 활성화해야 합니다.

-- RLS 활성화
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- 중요: RLS 활성화 후 정책 없으면 아무도 접근 불가
-- (service_role 제외)

⚠️ RLS 활성화 후 정책이 하나도 없으면 모든 접근이 차단됩니다. 정책은 허용 규칙입니다. 기본은 “모두 거부”입니다.


USING vs WITH CHECK

정책을 작성할 때 두 가지 절을 사용합니다.

적용 시점사용 용도
USING쿼리 실행 전 (읽기)SELECT, UPDATE, DELETE 대상 행 필터링
WITH CHECK쿼리 실행 후 (쓰기)INSERT, UPDATE 결과 행 검증
-- SELECT: USING만 사용 (어떤 행을 볼 수 있는가)
CREATE POLICY "본인 게시글 조회"
  ON posts FOR SELECT
  TO authenticated
  USING ((select auth.uid()) = author_id);

-- INSERT: WITH CHECK만 사용 (어떤 행을 삽입할 수 있는가)
CREATE POLICY "본인 게시글 작성"
  ON posts FOR INSERT
  TO authenticated
  WITH CHECK ((select auth.uid()) = author_id);

-- UPDATE: USING(대상 선택) + WITH CHECK(결과 검증) 모두 사용
CREATE POLICY "본인 게시글 수정"
  ON posts FOR UPDATE
  TO authenticated
  USING ((select auth.uid()) = author_id)        -- 수정할 행 선택
  WITH CHECK ((select auth.uid()) = author_id);  -- 수정 후 결과 검증

-- DELETE: USING만 사용 (어떤 행을 삭제할 수 있는가)
CREATE POLICY "본인 게시글 삭제"
  ON posts FOR DELETE
  TO authenticated
  USING ((select auth.uid()) = author_id);

💡 FOR ALL 대신 SELECT, INSERT, UPDATE, DELETE를 각각 분리하는 것이 권장 패턴입니다. 의도가 명확해지고 유지보수가 쉬워집니다.


자주 쓰는 핵심 헬퍼 함수

-- 현재 로그인 사용자의 UUID 반환
auth.uid()

-- JWT 전체 페이로드 반환 (JSONB)
auth.jwt()

-- JWT에서 특정 클레임 추출 예시
auth.jwt() ->> 'role'                         -- 사용자 역할
(auth.jwt() -> 'app_metadata') ->> 'role'     -- app_metadata의 role
auth.jwt() ->> 'email'                        -- 이메일

실전 RLS 패턴 모음

패턴 1: 개인 데이터 (본인만 접근)

가장 기본적인 패턴입니다. 각 사용자는 자신의 데이터만 읽고 쓸 수 있습니다.

-- todos 테이블 예시
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;

CREATE POLICY "본인 할 일 조회"
  ON todos FOR SELECT
  TO authenticated
  USING ((select auth.uid()) = user_id);

CREATE POLICY "본인 할 일 생성"
  ON todos FOR INSERT
  TO authenticated
  WITH CHECK ((select auth.uid()) = user_id);

CREATE POLICY "본인 할 일 수정"
  ON todos FOR UPDATE
  TO authenticated
  USING ((select auth.uid()) = user_id)
  WITH CHECK ((select auth.uid()) = user_id);

CREATE POLICY "본인 할 일 삭제"
  ON todos FOR DELETE
  TO authenticated
  USING ((select auth.uid()) = user_id);

패턴 2: 공개 읽기 + 본인 쓰기

블로그 게시글처럼 누구나 읽을 수 있지만 작성자만 수정할 수 있는 패턴입니다.

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- 공개 게시글은 누구나 조회 (anon 포함)
CREATE POLICY "공개 게시글 조회"
  ON posts FOR SELECT
  TO anon, authenticated
  USING (published = true);

-- 본인 게시글은 미공개여도 조회 가능
CREATE POLICY "본인 미공개 게시글 조회"
  ON posts FOR SELECT
  TO authenticated
  USING ((select auth.uid()) = author_id);

-- 로그인한 사용자만 게시글 작성
CREATE POLICY "게시글 작성"
  ON posts FOR INSERT
  TO authenticated
  WITH CHECK ((select auth.uid()) = author_id);

-- 본인 게시글만 수정
CREATE POLICY "본인 게시글 수정"
  ON posts FOR UPDATE
  TO authenticated
  USING ((select auth.uid()) = author_id)
  WITH CHECK ((select auth.uid()) = author_id);

-- 본인 게시글만 삭제
CREATE POLICY "본인 게시글 삭제"
  ON posts FOR DELETE
  TO authenticated
  USING ((select auth.uid()) = author_id);

패턴 3: 팀/조직 기반 접근 (멀티테넌트)

팀 멤버만 팀 데이터에 접근할 수 있는 패턴입니다. SaaS 서비스에서 가장 많이 쓰입니다.

-- 팀 테이블
CREATE TABLE teams (
  id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL
);

-- 팀 멤버 테이블
CREATE TABLE team_members (
  team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  role    TEXT DEFAULT 'member', -- 'owner', 'admin', 'member'
  PRIMARY KEY (team_id, user_id)
);

-- 프로젝트 테이블
CREATE TABLE projects (
  id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
  name    TEXT NOT NULL
);

-- RLS 활성화
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- 팀 멤버인 경우 팀 조회
CREATE POLICY "팀 멤버 팀 조회"
  ON teams FOR SELECT
  TO authenticated
  USING (
    id IN (
      SELECT team_id FROM team_members
      WHERE user_id = (select auth.uid())
    )
  );

-- 팀 멤버인 경우 프로젝트 조회
CREATE POLICY "팀 멤버 프로젝트 조회"
  ON projects FOR SELECT
  TO authenticated
  USING (
    team_id IN (
      SELECT team_id FROM team_members
      WHERE user_id = (select auth.uid())
    )
  );

-- 팀 owner/admin만 프로젝트 생성
CREATE POLICY "관리자 프로젝트 생성"
  ON projects FOR INSERT
  TO authenticated
  WITH CHECK (
    team_id IN (
      SELECT team_id FROM team_members
      WHERE user_id = (select auth.uid())
        AND role IN ('owner', 'admin')
    )
  );

패턴 4: 역할 기반 접근 제어 (RBAC)

JWT의 app_metadata에 역할을 저장하고, RLS에서 활용하는 패턴입니다. 관리자 콘솔 같은 기능에 유용합니다.

-- JWT의 app_metadata에서 역할 추출하는 헬퍼 함수
CREATE OR REPLACE FUNCTION get_user_role()
RETURNS TEXT AS $$
  SELECT (auth.jwt() -> 'app_metadata' ->> 'role')::TEXT;
$$ LANGUAGE sql STABLE SECURITY DEFINER;

-- 관리자만 전체 조회 가능
CREATE POLICY "관리자 전체 조회"
  ON posts FOR SELECT
  TO authenticated
  USING (get_user_role() = 'admin');

-- 본인 또는 관리자 조회
CREATE POLICY "본인 또는 관리자 조회"
  ON posts FOR SELECT
  TO authenticated
  USING (
    (select auth.uid()) = author_id
    OR get_user_role() = 'admin'
  );

역할 부여는 서버사이드(service_role)에서만:

// app/admin/actions.ts — Server Action (service_role 사용)
'use server'

import { createClient } from '@supabase/supabase-js'

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // 절대 클라이언트에 노출 금지
)

export async function assignAdminRole(userId: string) {
  const { error } = await supabaseAdmin.auth.admin.updateUserById(userId, {
    app_metadata: { role: 'admin' },
  })
  if (error) throw new Error(error.message)
}

패턴 5: 댓글 — 게시글 접근 권한 상속

댓글은 게시글이 공개된 경우에만 볼 수 있어야 합니다.

ALTER TABLE comments ENABLE ROW LEVEL SECURITY;

-- 공개 게시글의 댓글만 조회 가능
CREATE POLICY "공개 게시글 댓글 조회"
  ON comments FOR SELECT
  TO anon, authenticated
  USING (
    post_id IN (
      SELECT id FROM posts WHERE published = true
    )
  );

-- 로그인 사용자만 댓글 작성 (작성자 ID 강제)
CREATE POLICY "댓글 작성"
  ON comments FOR INSERT
  TO authenticated
  WITH CHECK (
    (select auth.uid()) = author_id
    AND post_id IN (
      SELECT id FROM posts WHERE published = true
    )
  );

-- 본인 댓글만 수정
CREATE POLICY "본인 댓글 수정"
  ON comments FOR UPDATE
  TO authenticated
  USING ((select auth.uid()) = author_id)
  WITH CHECK ((select auth.uid()) = author_id);

-- 본인 댓글 삭제 (또는 게시글 작성자가 삭제)
CREATE POLICY "댓글 삭제"
  ON comments FOR DELETE
  TO authenticated
  USING (
    (select auth.uid()) = author_id
    OR (select auth.uid()) IN (
      SELECT author_id FROM posts WHERE id = post_id
    )
  );

고급: JWT 클레임 커스텀

Custom Access Token Hook을 사용하면 JWT에 커스텀 클레임을 추가할 수 있습니다. 이를 RLS에서 활용하면 별도 테이블 조회 없이 빠른 정책을 만들 수 있습니다.

-- 1. JWT에 추가할 클레임을 만드는 Hook 함수
CREATE OR REPLACE FUNCTION custom_access_token_hook(event JSONB)
RETURNS JSONB AS $$
DECLARE
  claims JSONB;
  user_role TEXT;
BEGIN
  -- profiles 테이블에서 역할 조회
  SELECT role INTO user_role
  FROM public.profiles
  WHERE id = (event->>'user_id')::UUID;

  claims := event->'claims';
  claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));

  RETURN jsonb_set(event, '{claims}', claims);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

-- 2. Supabase 대시보드 → Authentication → Hooks에서 등록
-- 3. RLS에서 JWT 클레임 바로 사용 (테이블 조회 없음 = 빠름)
CREATE POLICY "역할 기반 접근"
  ON admin_data FOR SELECT
  TO authenticated
  USING (
    (auth.jwt() ->> 'user_role') = 'admin'
  );

성능 최적화

RLS 정책은 모든 쿼리에 실행되므로 성능에 직접 영향을 줍니다.

1. 인덱스 추가 (필수)

-- RLS 정책에 사용되는 컬럼에 인덱스 추가
CREATE INDEX ON posts (author_id);
CREATE INDEX ON team_members (user_id);
CREATE INDEX ON team_members (team_id, user_id); -- 복합 인덱스
CREATE INDEX ON comments (post_id);

2. auth.uid()를 서브쿼리로 감싸기 (initPlan 캐싱)

-- ❌ 느린 방식 — 매 행마다 auth.uid() 호출
USING (auth.uid() = author_id);

-- ✅ 빠른 방식 — 쿼리당 1회만 호출 (Postgres optimizer가 캐싱)
USING ((select auth.uid()) = author_id);

3. 복잡한 JOIN은 security definer 함수로 분리

RLS 정책 안에 다른 테이블과의 JOIN이 있으면 성능이 급격히 저하될 수 있습니다. 이런 경우 SECURITY DEFINER 함수로 분리합니다.

-- ❌ 느린 방식 — 정책 안에서 JOIN
CREATE POLICY "팀 멤버 접근"
  ON projects FOR SELECT
  TO authenticated
  USING (
    team_id IN (
      SELECT team_id FROM team_members -- 매 쿼리마다 JOIN 발생
      WHERE user_id = (select auth.uid())
    )
  );

-- ✅ 빠른 방식 — 함수로 캐싱
CREATE OR REPLACE FUNCTION get_my_team_ids()
RETURNS SETOF UUID AS $$
  SELECT team_id FROM team_members
  WHERE user_id = (select auth.uid());
$$ LANGUAGE sql STABLE SECURITY DEFINER;

CREATE POLICY "팀 멤버 접근"
  ON projects FOR SELECT
  TO authenticated
  USING (team_id IN (SELECT get_my_team_ids()));

⚠️ SECURITY DEFINER 함수는 반드시 공개 스키마(public)가 아닌 비공개 스키마에 만들거나, API 노출 설정에서 제외해야 합니다.


RLS 정책 테스트

대시보드에서 사용자 가장하기

Supabase 대시보드 → Authentication → Policies에서 특정 사용자로 가장한 쿼리를 실행할 수 있습니다.

SQL로 직접 테스트

-- 1. anon 역할로 테스트 (비로그인 시뮬레이션)
SET ROLE anon;
SELECT * FROM posts; -- 공개 게시글만 보여야 함
RESET ROLE;

-- 2. 특정 사용자로 가장
SET LOCAL role = 'authenticated';
SET LOCAL request.jwt.claims = '{"sub": "특정-사용자-UUID", "role": "authenticated"}';
SELECT * FROM posts; -- 해당 사용자의 접근 권한으로 조회
RESET ROLE;

Next.js에서 RLS 동작 확인

// 테스트: anon 클라이언트로 다른 사용자 데이터 접근 시도
const supabase = createClient(url, anonKey)

const { data, error } = await supabase
  .from('todos')
  .select('*')
  // RLS가 제대로 설정되어 있다면 → 본인 데이터만 반환
  // RLS가 없다면 → 모든 사용자의 데이터 반환 (보안 위협!)

console.log('반환된 데이터 수:', data?.length)

Storage에서의 RLS

스토리지 버킷도 RLS로 보호할 수 있습니다. storage.objects 테이블에 정책을 적용합니다.

-- 본인 파일만 업로드 가능 (경로: {user_id}/파일명)
CREATE POLICY "본인 파일 업로드"
  ON storage.objects FOR INSERT
  TO authenticated
  WITH CHECK (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
  );

-- 본인 파일만 조회
CREATE POLICY "본인 파일 조회"
  ON storage.objects FOR SELECT
  TO authenticated
  USING (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
  );

-- 공개 버킷: 누구나 읽기
CREATE POLICY "공개 이미지 조회"
  ON storage.objects FOR SELECT
  TO anon, authenticated
  USING (bucket_id = 'public-images');

자주 하는 실수 TOP 5

1. RLS 활성화만 하고 정책을 안 만드는 경우

-- ❌ 이 상태면 아무도 데이터에 접근 불가
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 정책 없음 → 모든 접근 차단

-- ✅ 반드시 정책을 함께 생성
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "..." ON posts FOR SELECT TO authenticated USING (...);

2. FOR ALL을 사용해 의도치 않은 권한 부여

-- ❌ FOR ALL을 쓰면 WITH CHECK 없이 INSERT도 허용될 수 있음
CREATE POLICY "모든 작업"
  ON posts FOR ALL
  USING (auth.uid() = author_id);

-- ✅ 각 동작을 명시적으로 분리
CREATE POLICY "조회" ON posts FOR SELECT ...;
CREATE POLICY "생성" ON posts FOR INSERT ...;
CREATE POLICY "수정" ON posts FOR UPDATE ...;
CREATE POLICY "삭제" ON posts FOR DELETE ...;

3. 정책 없이 service_role로만 운영

service_role 키는 RLS를 우회합니다. 클라이언트에서 service_role 키를 쓰면 RLS가 의미 없어집니다.

// ❌ 절대 클라이언트에서 service_role 사용 금지
const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)

// ✅ 클라이언트는 anon 키 사용 + RLS로 보호
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)

4. 뷰(View)에서 RLS 미적용

-- ❌ 기본 뷰는 postgres 사용자로 생성되어 RLS를 우회
CREATE VIEW post_summaries AS SELECT ...;

-- ✅ security_invoker = true 추가 (Postgres 15 이상)
CREATE VIEW post_summaries
  WITH (security_invoker = true)
  AS SELECT ...;

5. auth.uid() 반환값이 null인 경우를 처리 안 함

-- ❌ auth.uid()가 null이면 모든 행이 null = null (false)로 처리됨
-- → 접근 불가 (의도치 않은 차단)
USING (auth.uid() = author_id);

-- ✅ 명시적으로 인증 여부 확인
USING (
  auth.uid() IS NOT NULL
  AND auth.uid() = author_id
);

블로그 서비스 전체 RLS 예시

2편에서 만든 블로그 스키마에 RLS를 완전하게 적용하는 예시입니다.

-- =====================
-- categories 테이블
-- =====================
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;

-- 카테고리는 누구나 조회 가능
CREATE POLICY "카테고리 공개 조회"
  ON categories FOR SELECT
  TO anon, authenticated
  USING (true);

-- =====================
-- posts 테이블
-- =====================
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "공개 게시글 조회"
  ON posts FOR SELECT
  TO anon, authenticated
  USING (published = true);

CREATE POLICY "본인 게시글 전체 조회"
  ON posts FOR SELECT
  TO authenticated
  USING ((select auth.uid()) = author_id);

CREATE POLICY "게시글 작성"
  ON posts FOR INSERT
  TO authenticated
  WITH CHECK ((select auth.uid()) = author_id);

CREATE POLICY "본인 게시글 수정"
  ON posts FOR UPDATE
  TO authenticated
  USING ((select auth.uid()) = author_id)
  WITH CHECK ((select auth.uid()) = author_id);

CREATE POLICY "본인 게시글 삭제"
  ON posts FOR DELETE
  TO authenticated
  USING ((select auth.uid()) = author_id);

-- =====================
-- comments 테이블
-- =====================
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;

CREATE POLICY "공개 게시글 댓글 조회"
  ON comments FOR SELECT
  TO anon, authenticated
  USING (
    post_id IN (SELECT id FROM posts WHERE published = true)
  );

CREATE POLICY "댓글 작성"
  ON comments FOR INSERT
  TO authenticated
  WITH CHECK (
    (select auth.uid()) = author_id
    AND post_id IN (SELECT id FROM posts WHERE published = true)
  );

CREATE POLICY "본인 댓글 수정"
  ON comments FOR UPDATE
  TO authenticated
  USING ((select auth.uid()) = author_id)
  WITH CHECK ((select auth.uid()) = author_id);

CREATE POLICY "댓글 삭제"
  ON comments FOR DELETE
  TO authenticated
  USING (
    (select auth.uid()) = author_id
    OR (select auth.uid()) IN (
      SELECT author_id FROM posts WHERE id = post_id
    )
  );

-- =====================
-- 인덱스 추가 (성능)
-- =====================
CREATE INDEX ON posts (author_id);
CREATE INDEX ON posts (published);
CREATE INDEX ON comments (post_id);
CREATE INDEX ON comments (author_id);

Next.js에서 RLS와 함께하는 올바른 패턴

RLS가 잘 설정되어 있으면 Next.js 코드가 훨씬 단순해집니다.

// app/dashboard/posts/page.tsx — Server Component
// RLS가 자동으로 본인 게시글만 필터링하므로 별도 where 조건 불필요
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export default async function MyPostsPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) redirect('/login')

  // RLS가 author_id = auth.uid() 조건을 자동 적용
  const { data: posts } = await supabase
    .from('posts')
    .select('id, title, published, created_at')
    .order('created_at', { ascending: false })

  // posts에는 자동으로 본인 게시글만 담김
  return (
    <ul>
      {posts?.map(post => (
        <li key={post.id}>
          {post.title} {post.published ? '(공개)' : '(비공개)'}
        </li>
      ))}
    </ul>
  )
}

마치며

RLS는 Supabase 보안의 핵심입니다. 처음에는 복잡하게 느껴지지만, 몇 가지 패턴을 익히고 나면 아주 강력하고 우아한 보안 도구가 됩니다. 특히 “API 레이어가 뚫려도 DB는 안전하다”는 Defense in Depth 개념은 실제 서비스에서 엄청난 안전망이 됩니다.

핵심을 다시 정리하면:

  • RLS는 반드시 활성화하고, 활성화하면 반드시 정책도 함께 작성
  • (select auth.uid())로 성능 최적화
  • FOR ALL 대신 각 동작을 분리해서 명시적으로 정의
  • service_role 키는 절대 클라이언트에 노출 금지
  • 정책 작성 후 반드시 테스트

다음 편에서는 Supabase의 실시간 기능(Realtime) 을 다룹니다. WebSocket 기반 실시간 구독으로 채팅, 라이브 대시보드, 협업 도구를 어떻게 만드는지 Next.js 코드와 함께 살펴봅니다.




댓글 남기기