
이미지 업로드가 자꾸 403 Forbidden이에요 (Storage RLS의 함정)
테이블 RLS는 켰는데, 스토리지 파일 업로드가 막힙니다. 'new row violates row-level security policy' 에러의 진짜 원인인 `storage.objects` 정책 설정법을 정리해봤습니다.

테이블 RLS는 켰는데, 스토리지 파일 업로드가 막힙니다. 'new row violates row-level security policy' 에러의 진짜 원인인 `storage.objects` 정책 설정법을 정리해봤습니다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

개발 중에 코드를 수정했는데 브라우저가 반응이 없나요? 새로고침을 백만 번 하다가 지쳐서 찾아낸 HMR(Hot Module Replacement)의 원리와 고장 원인, 그리고 해결 방법을 '노가다 개발자'의 시선으로 정리했습니다.

하드디스크는 그저 0과 1이 적힌 거대한 운동장입니다. 여기에 '파일'과 '폴더'라는 개념을 입히는 마법.

TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.

사용자 프로필 사진 변경 기능을 만들고 있었습니다.
Flutter에서 supabase.storage.from('avatars').upload()를 호출했습니다.
그런데 빨간색 에러가 콘솔을 뒤덮었습니다.
StorageException: new row violates row-level security policy for table "objects"
StatusCode: 403
"아니, 내가 profiles 테이블에 INSERT 정책 다 넣어줬는데 왜?"
저는 데이터베이스 테이블 권한과 스토리지 권한이 별개라는 걸 몰랐습니다.
Supabase 튜토리얼을 보면 Table RLS만 주구장창 설명하고, Storage RLS는 은근슬쩍 넘어가는 경우가 많거든요.
제 오개념은 이거였습니다: "로그인했으면(Authenticated) 다 되는 거 아냐?"
Auth가 뚫려있고, 테이블 RLS도 설정했으니 스토리지도 당연히 될 줄 알았습니다.
하지만 에러 메시지를 자세히 보니 table "objects"라고 되어 있었습니다.
"난 objects라는 테이블을 만든 적이 없는데?"
알고 보니 Supabase Storage는 내부적으로 storage라는 스키마(Schema) 안에 objects라는 테이블로 파일 메타데이터를 관리하고 있었습니다.
사용자가 파일을 올리면, 실제론 storage.objects 테이블에 행(Row)을 추가(INSERT)하는 작업이 일어납니다.
그래서 여기서 RLS가 터지는 것이었습니다.
이걸 "백화점 vs 물품보관소"로 비유해보니 이해가 됐습니다.
"아, profiles 테이블 권한이 있다고 해서 avatars 버킷 권한이 자동으로 생기는 게 아니구나. 물품보관소 이용권을 따로 끊어야 하는구나."
Supabase 대시보드 Storage 메뉴에서 'Policies' 탭을 찾기 힘들 수 있습니다. 보통은 SQL Editor로 가서 직접 쿼리를 날리는 게 가장 확실합니다.
가장 쉬운 방법은 버킷을 만들 때 Public Bucket으로 만드는 것입니다.
Public Bucket은 SELECT (다운로드) 권한이 전 세계 모두에게 열려 있습니다.
프로필 사진 같은 건 Public이 편합니다.
하지만 업로드(INSERT)는 여전히 막혀 있습니다. Public Bucket이라고 해서 아무나 1TB짜리 파일을 올리게 둘 순 없으니까요.
storage.objects 테이블에 정책을 추가해야 합니다.
여기서 중요한 건 bucket_id를 꼭 명시해야 한다는 점입니다. 안 그러면 다른 버킷에도 권한이 생길 수 있습니다.
-- avatars 버킷에 내 폴더(uid) 안에만 파일 올리기 허용
CREATE POLICY "Allow Upload to own folder"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);
이 정책의 의미:
bucket_id = 'avatars': avatars 버킷이어야 함.storage.foldername(name)[1]: 파일 경로(uid/filename.png)의 첫 번째 폴더가auth.uid(): 로그인한 내 ID와 같아야 함.즉, "자기 ID로 된 폴더 안에만 파일 넣을 수 있음"이라는 규칙입니다. 깔끔하죠?
사진을 바꾸거나 지우려면 UPDATE, DELETE 정책도 필요합니다.
CREATE POLICY "Allow Update own file"
ON storage.objects FOR UPDATE
TO authenticated
USING (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY "Allow Delete own file"
ON storage.objects FOR DELETE
TO authenticated
USING (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);
주의: UPDATE vs INSERT (Upsert)
Supabase JS 클라이언트에서 upsert: true 옵션을 쓰더라도,
파일이 없으면 INSERT, 있으면 UPDATE 권한을 체크합니다.
그래서 덮어쓰기를 하려면 둘 다 있어야 안전합니다.
Storage RLS의 핵심은 파일 경로(Path) 설계입니다.
아무 생각 없이 파일을 루트(avatars/my-face.png)에 올리면 권한 관리가 어려워집니다.
추천하는 구조:
{bucket}/{user_id}/{filename}: 가장 표준적이고 안전함.{bucket}/{post_id}/{filename}: 게시글 첨부파일용.잘못된 예시:
{bucket}/{uuid-filename}: 파일 이름만으로는 누가 주인인지 알 수 없어서, DB를 조회해야 하는 복잡한 정책(EXISTS 서브쿼리)이 필요해짐. 성능 저하의 주범.SQL 정책 안에서 auth.uid()와 파일 경로를 매칭시키는 것이 가장 빠르고 비용이 적게 듭니다.
RLS가 통과되어도 폰 자체에서 파일 접근을 막으면 답이 없습니다. 특히 Android 10(API 29) 이상부터 도입된 Scoped Storage 때문에 많은 개발자가 미쳐버립니다.
"분명 READ_EXTERNAL_STORAGE 권한을 받았는데 왜 파일이 안 읽혀?"
이제는 "내 앱 전용 폴더"가 아니면 접근할 수 없습니다.
그래서 갤러리에서 사진을 가져오려면 File 경로가 아니라 ContentUri를 써야 하거나,
Flutter에서는 편하게 image_picker 라이브러리를 써야 합니다.
image_picker는 시스템 UI를 띄워서 사진을 가져오므로, 별도의 권한 요청(Permission Request)이 필요 없습니다. (Android 13+ Photo Picker 기준)
불필요하게 permission_handler로 저장소 권한 전체를 달라고 하지 마세요. 구글 플레이 심사에서 거절당합니다.
사용자가 10MB짜리 4K 사진을 프로필로 올리려고 합니다. 이걸 그대로 Supabase Storage에 올리면?
반드시 클라이언트(Flutter)에서 리사이징을 해서 올려야 합니다.
flutter_image_compress 패키지를 쓰면 10MB -> 200KB로 줄어듭니다.
final compressedFile = await FlutterImageCompress.compressAndGetFile(
file.absolute.path,
targetPath,
quality: 80, // 화질 80%
minWidth: 500, // 가로 500px로 축소
);
Storage RLS는 "권한"을 막는 거지 "용량"을 막아주진 않습니다. (물론 파일 크기 제한 정책을 걸 수도 있습니다.) 최적화는 클라이언트의 매너입니다.
final userId = supabase.auth.currentUser!.id;
final file = File('path/to/image.png');
try {
// 경로에 userId를 포함시키는 것이 핵심!
await supabase.storage.from('avatars').upload(
'$userId/profile.png', // path
file,
fileOptions: const FileOptions(upsert: true), // 덮어쓰기
);
} catch (e) {
print('업로드 실패: $e'); // 403이면 정책 문제
}
이제 403 에러 없이 업로드가 잘 됩니다.
"프로필 사진을 바꿨는데도 옛날 사진이 떠요." Supabase Storage는 기본적으로 CDN(Cloudflare)을 타고 서빙됩니다. 즉, URL이 같으면 브라우저나 CDN이 캐싱된 구버전 이미지를 보여줍니다.
해결책 1: URL 버저닝 (추천)
파일 이름 뒤에 ?t=timestamp를 붙입니다.
https://.../profile.png?t=12345678
하지만 이건 브라우저 캐시는 막아도 CDN 캐시는 못 막을 수도 있습니다.
해결책 2: cacheControl 옵션 업로드할 때 캐시 수명을 지정합니다.
await supabase.storage.from('avatars').upload(
path, file,
fileOptions: const FileOptions(
upsert: true,
cacheControl: '3600', // 1시간 (기본값은 1년일 수 있음)
),
);
프로필 사진처럼 자주 바뀌는 건 캐시 시간을 짧게(3600초) 잡으세요.
bucket을 Private으로 만들었다면, 다운로드 링크를 어떻게 줄까요?
createSignedUrl을 씁니다.
// 60초 뒤에 만료되는 임시 URL 생성
final url = await supabase.storage
.from('contracts')
.createSignedUrl('secret-doc.pdf', 60);
이 방식은 RLS와는 또 다른 보안 계층입니다. "누구에게나(Public)"가 아니라 "이 링크를 가진 사람에게만(Token)" 허용하는 것이죠. 채팅방에서 사진 보낼 때 유용합니다.
어떤 방식을 써야 할지 표로 정리해드립니다.
| 방식 | 접근 권한 | 용도 | 장점 | 단점 |
|---|---|---|---|---|
| Public Bucket | 누구나 (URL만 알면) | 프로필 사진, 블로그 이미지, 로고 | 가장 빠름, 구현 쉬움 | 보안 없음, 스크래핑 위험 |
| Private Bucket (RLS) | 로그인한 유저 (JWT) | 내 이력서, 구매 영수증 | 적당한 보안, DB 연동 | 토큰 헤더 필요, 다운로드 로직 복잡 |
| Signed URL | URL 소지자 (일시적) | 1:1 채팅 사진, 기간 한정 공유 파일 | 완벽한 제어, 시간 제한 | URL 생성 오버헤드, 캐싱 어려움 |
대부분의 경우 Public Bucket으로 시작하고, 민감한 개인정보만 Private으로 격리하는 것이 운영상 편합니다. 모든 걸 Private으로 막으면 이미지 띄우는 코드마다 토큰 넣느라 멘탈이 나갈 수 있습니다.
user_id를 넣어서 folder name = auth.uid 조건으로 걸러내는 게 제일 쉽고 빠르다.