[Shylog] Supabase SSR 인증과 RLS, /admin 가드
Soshy·

블로그를 직접 만들기로 했을 때, 가장 먼저 고민한 것은 "어떻게 내 글을 안전하게 지킬 것인가" 였다. ShyLog는 나 혼자 쓰는 1인 블로그이기에 거창한 권한 체계는 필요 없지만, 그렇다고 아무나 내 글을 수정하거나 댓글창을 어지럽히게 둘 순 없었다. 이번 글에서는 클로드와 함께 인증 레이어를 구축한 과정에 대해 풀어보려 한다.
왜 @supabase/ssr인가
Supabase 공식 클라이언트는 한동안 @supabase/auth-helpers-nextjs로 제공되었지만, Next.js 13 App Router의 등장은 큰 변화를 몰고 왔다. 서버 컴포넌트(RSC), 서버 액션, 미들웨어가 각각 다른 실행 컨텍스트를 가지며 쿠키에 접근하는 방식도 제각각이었기 때문이다.
auth-helpers는 이 파편화된 환경을 충분히 대응하지 못했고, 그 후속으로 나온 것이 바로 프레임워크 중립적인 @supabase/ssr이다. 이 패키지의 핵심 설계 원칙은 "쿠키의 읽기/쓰기 구현을 호출자가 직접 주입한다" 는 것이다. 덕분에 Next.js의 cookies(), NextRequest.cookies, document.cookie라는 세 가지 서로 다른 API를 하나의 라이브러리로 일관되게 다룰 수 있게 되었다.
lib/supabase/ 환경별 클라이언트 분리
인증 클라이언트를 사용 환경에 따라 세 파일로 나누어 관리하기 시작했다.
lib/supabase/
├── client.ts ← 브라우저 (Client Component)
├── server.ts ← 서버 컴포넌트 / Server Action
└── middleware.ts ← Edge 미들웨어
client.ts - 브라우저용
createBrowserClient는 내부적으로 document.cookie를 사용하여 클라이언트 측 세션을 관리한다.
server.ts - 서버 컴포넌트 · Server Action용
서버 환경에서는 next/headers의 cookies를 주입한다. setAll의 try/catch는 의도적인 설계다. 서버 컴포넌트는 렌더링 도중 쿠키를 변경할 수 없으므로 오류를 삼키고, 실제 세션 갱신은 미들웨어가 전담하도록 역할을 분리했다.
middleware.ts - 세션 갱신과 라우트 가드
미들웨어는 요청이 애플리케이션 깊숙이 도달하기 전, 문지기 역할을 수행한다.
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(/* ... cookie 주입 ... */)
// IMPORTANT: 보안을 위해 항상 getSession()이 아닌 getUser()를 사용한다.
const { data: { user } } = await supabase.auth.getUser()
// 관리자 페이지 보호 로직
if (
request.nextUrl.pathname.startsWith('/admin') &&
!request.nextUrl.pathname.startsWith('/admin/login') &&
!user
) {
const url = request.nextUrl.clone()
url.pathname = '/admin/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
주석에 getSession() 대신 getUser()를 쓰라고 명시한 이유가 있다. getSession()은 단순히 로컬 쿠키의 JWT를 읽어오지만, getUser()는 Supabase Auth 서버에 직접 요청을 보내 토큰의 유효성을 실시간으로 검증한다. 보안이 최우선인 미들웨어에서는 다소의 비용을 감수하더라도 서버 검증을 거치는 것이 좋다.
미들웨어 매처(Matcher) 최적화
최초 설계 시 미들웨어는 정적 파일을 제외한 모든 경로를 감시했다. 하지만 이는 블로그 메인이나 글 목록 등 누구나 볼 수 있는 페이지에서도 매번 네트워크 왕복이 발생하는 비효율을 낳았다.
// 변경 후: '/admin/*' 경로에서만 실행
export const config = {
matcher: ['/admin/:path*'],
}
ShyLog의 공개 페이지는 읽기 전용이다. 이 최적화를 통해 일반 방문자의 페이지 로딩 속도를 저해하는 불필요한 인증 오버헤드를 제거했다. 공개 페이지에서 관리자 메뉴 노출 여부는 클라이언트 컴포넌트에서 필요한 시점에만 호출하도록 트레이드오프를 조정했다.
스키마 설계: profiles 테이블과 is_admin 플래그
애플리케이션 고유 데이터를 관리하기 위해 public.profiles 테이블을 1:1 관계로 생성했다.
CREATE TABLE profiles (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
is_admin boolean DEFAULT false
);
- 보안성: Supabase 내부 스키마인
auth.users를 직접 건드리지 않아 안전하다. - 확장성: 추후 닉네임, 아바타 등 유저 정보를 추가할 때 자유롭다.
- RLS 연동:
auth.uid()와 조인하여 관리자 여부를 판별하는 정책 작성이 간결해진다.
RLS 정책 설계
RLS(Row Level Security) 는 데이터베이스 레이어에서 접근을 제어한다. 코드 레벨에서 실수가 있더라도 DB가 데이터를 지키는 마지막 보루가 된다.
특히 댓글 정책에서 WITH CHECK 절을 활용한 유효성 검증이 핵심이다.
CREATE POLICY "Public insert on published" ON comments
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM posts WHERE id = comments.post_id AND published = true)
);
이 정책 덕분에 공격자가 게시글 ID를 임의로 조작하더라도, 비공개(Draft) 상태이거나 존재하지 않는 글에는 댓글을 달 수 없도록 DB 수준에서 원천 봉쇄된다.
DB 트리거를 통한 운영 자동화
새 사용자가 등록될 때 profiles 레코드가 자동으로 생성되지 않으면 관리 권한이 차단되는 리스크가 있다. 이를 위해 SECURITY DEFINER를 설정한 트리거 함수를 구축했다.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.profiles (id, is_admin) VALUES (NEW.id, false)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
이 설정은 함수를 실행한 유저가 아닌 '생성자(슈퍼유저)'의 권한으로 실행되도록 한다. 덕분에 신규 유저에게 권한이 없는 시점에도 profiles 테이블에 안전하게 데이터를 삽입할 수 있으며, 배포 시마다 수동으로 DB를 조작해야 하는 운영 리스크를 해결했다.
심층 방어(Defense in Depth) 전략
보안에 있어 '단일 실패 지점'을 두는 것은 위험하다. 나는 데이터베이스에 도달하기 전 애플리케이션 레이어에서 한 번 더 권한을 검증하는 requireAdmin 헬퍼를 도입하여 모든 Server Action의 최상단에 배치했다.
async function requireAdmin() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Unauthorized')
const { data: profile } = await supabase
.from('profiles')
.select('is_admin')
.eq('id', user.id)
.single()
if (!profile?.is_admin) throw new Error('Forbidden')
}
결과적으로 ShyLog의 보안은 다음과 같이 방어 타겟이 다른 세 개의 레이어로 구성된다.
| 보안 레이어 | 실행 위치 | 핵심 역할 |
|---|---|---|
| 미들웨어 | Edge | 비인가 사용자의 관리자 UI 진입 및 불필요한 서버 렌더링 차단 |
| 서버 액션 | Server | 애플리케이션 비즈니스 로직 보호 및 비정상적인 API 호출 차단 |
| RLS | DB | 코드 버그나 설정 오류 발생 시 데이터 오염을 막는 최종 방어선 |
동일한 로직을 세 번 반복하는 비효율처럼 보일 수 있지만, 각 레이어는 서로 다른 공격 벡터를 방어한다. 이 중첩된 방어 체계 덕분에 어느 한 곳에서 실수가 발생하더라도 블로그의 핵심 데이터는 안전하게 유지될 수 있다.