함수 분리, get/exists 참조, 커스텀 클레임 기반 권한과 에뮬레이터 단위 테스트로 견고한 규칙을 만든다.
보안 규칙 심화와 규칙 테스트
입문편에서는 match, allow, request.auth, request.resource.data를 활용해 기본적인 읽기·쓰기 제어를 구현하는 방법을 다뤘습니다. 실무 프로젝트가 성장하면 규칙이 수십 줄을 넘어서고, 조건을 반복하거나 역할(role) 체계가 복잡해지면서 규칙 파일 자체가 버그의 온상이 됩니다. 이번 강에서는 규칙을 함수로 추출해 재사용하고, 다른 문서를 참조하는 get()/exists(), 커스텀 클레임으로 역할 모델을 구축하며, 에뮬레이터 기반 단위 테스트로 규칙의 정확성을 자동으로 검증하는 방법까지 다룹니다.
학습 목표
- 규칙 함수 추출로 복잡한 권한 로직을 구조화하고 중복을 제거할 수 있다.
get()·exists()로 다른 문서를 참조하는 규칙과 그에 따른 읽기 비용을 이해한다.- 커스텀 클레임(custom claims) 기반 역할 모델을 설계하고 규칙에 적용할 수 있다.
- 수정 불가 필드를 diff 검증으로 보호하는 패턴을 구현할 수 있다.
@firebase/rules-unit-testing과 에뮬레이터로 규칙 단위 테스트를 작성하고 실행할 수 있다.
규칙 함수 추출과 재사용
Firestore 보안 규칙은 function 키워드로 로직을 분리할 수 있습니다. 같은 조건이 여러 match 블록에 반복된다면 함수로 추출하는 것이 유지보수의 첫걸음입니다.
함수 분리 전/후 비교
// ❌ 반복이 많은 규칙
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /posts/{postId} {
allow read: if request.auth != null;
allow write: if request.auth != null
&& request.auth.uid == resource.data.authorId
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() <= 100;
}
match /comments/{commentId} {
allow read: if request.auth != null;
allow write: if request.auth != null
&& request.auth.uid == resource.data.authorId
&& request.resource.data.body is string
&& request.resource.data.body.size() > 0
&& request.resource.data.body.size() <= 1000;
}
}
}
// ✅ 함수로 추출해 재사용
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 로그인 여부 확인
function isSignedIn() {
return request.auth != null;
}
// 리소스 소유자 여부 확인
function isOwner(resource) {
return isSignedIn() && request.auth.uid == resource.data.authorId;
}
// 문자열 필드 길이 검증
function validString(value, min, max) {
return value is string && value.size() >= min && value.size() <= max;
}
match /posts/{postId} {
allow read: if isSignedIn();
allow write: if isOwner(resource)
&& validString(request.resource.data.title, 1, 100);
}
match /comments/{commentId} {
allow read: if isSignedIn();
allow write: if isOwner(resource)
&& validString(request.resource.data.body, 1, 1000);
}
}
}
💡 TIP 함수는
match /databases/{database}/documents블록 내 어디에나 선언할 수 있으며, 선언 순서는 관계없습니다. 인자로resource,request같은 전역 변수도 그대로 전달할 수 있습니다.
함수 내부에서 다른 함수를 호출하는 중첩 호출도 가능합니다. 단, 규칙 엔진은 재귀(순환) 함수 호출을 허용하지 않으며, 함수 호출 깊이는 한 요청당 최대 20단계로 제한되므로, 지나치게 깊은 함수 체인은 피해야 합니다.
get()·exists()로 다른 문서 참조
get()과 exists()는 현재 작업 대상이 아닌 다른 Firestore 문서를 규칙 평가 중에 읽을 수 있게 해줍니다. 이를 통해 "이 사용자가 해당 팀의 멤버인가?" 같은 관계 기반 권한을 표현할 수 있습니다.
| 함수 | 반환값 | 용도 |
|---|---|---|
exists(/path) | boolean | 문서 존재 여부만 확인 |
get(/path) | resource 객체 | 문서 전체 데이터를 읽어 필드 접근 가능 |
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 사용자가 특정 팀의 멤버인지 확인
function isTeamMember(teamId) {
return exists(/databases/$(database)/documents/teams/$(teamId)/members/$(request.auth.uid));
}
// 팀 문서의 role 필드를 읽어 관리자 여부 확인
function isTeamAdmin(teamId) {
let memberDoc = get(/databases/$(database)/documents/teams/$(teamId)/members/$(request.auth.uid));
return memberDoc.data.role == 'admin';
}
match /teams/{teamId}/projects/{projectId} {
allow read: if isTeamMember(teamId);
allow write: if isTeamAdmin(teamId);
}
}
}
⚠️ 주의
get()과exists()는 각각 Firestore 읽기 1회로 과금됩니다. 한 규칙 평가에서 동일한 경로를 여러 번 참조하면 결과가 캐시되어 추가 과금이 없지만, 서로 다른 경로를 참조하면 각각 별도로 과금됩니다.get()/exists()/getAfter()호출은 단일 문서 요청에서는 최대 10회, 다중 문서 요청(쿼리)·트랜잭션·배치 쓰기에서는 최대 20회로 제한됩니다.
경로 보간 시에는 반드시 $(변수) 구문을 사용해야 합니다. 문자열 연결(+)은 경로 타입에 작동하지 않습니다.
// ✅ 올바른 경로 보간
get(/databases/$(database)/documents/users/$(request.auth.uid))
// ❌ 문자열 연결은 동작하지 않음
get("/databases/" + database + "/documents/users/" + request.auth.uid)
커스텀 클레임 기반 역할 모델
Firebase Authentication의 커스텀 클레임(custom claims) 은 JWT 토큰에 임의의 키-값을 추가해 서버 측에서 관리하는 방식입니다. 역할(role) 정보를 Firestore 문서 대신 토큰에 포함하면 get() 호출 없이 규칙에서 바로 참조할 수 있어 성능과 비용 모두 유리합니다.
Admin SDK로 커스텀 클레임 설정
// Cloud Functions 또는 서버 환경에서 실행
const admin = require('firebase-admin');
async function setUserRole(uid, role) {
// role: 'admin' | 'editor' | 'viewer'
await admin.auth().setCustomUserClaims(uid, { role });
console.log(`User ${uid} 에게 role="${role}" 클레임 설정 완료`);
}
// 예: 관리자 지정
await setUserRole('USER_UID_HERE', 'admin');
⚠️ 주의
setCustomUserClaims호출 후 클라이언트의 기존 토큰에는 즉시 반영되지 않습니다. 클라이언트에서user.getIdToken(true)로 강제 갱신하거나, 토큰 만료(기본 1시간) 후 재발급을 기다려야 합니다.
규칙에서 커스텀 클레임 활용
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 토큰의 role 클레임 추출 (클레임 없으면 null)
function userRole() {
return request.auth.token.role;
}
function isAdmin() {
return isSignedIn() && userRole() == 'admin';
}
function isEditor() {
return isSignedIn() && (userRole() == 'admin' || userRole() == 'editor');
}
function isSignedIn() {
return request.auth != null;
}
match /articles/{articleId} {
allow read: if isSignedIn();
allow create: if isEditor();
allow update: if isEditor();
allow delete: if isAdmin();
}
// 사용자 역할 변경은 관리자만 가능
match /userRoles/{userId} {
allow read: if isAdmin() || request.auth.uid == userId;
allow write: if isAdmin();
}
}
}
역할 계층 표현
역할이 많아지면 in 연산자로 허용 역할 목록을 간결하게 표현할 수 있습니다.
function hasAnyRole(roles) {
return isSignedIn() && userRole() in roles;
}
match /reports/{reportId} {
allow read: if hasAnyRole(['admin', 'editor', 'analyst']);
allow write: if hasAnyRole(['admin', 'editor']);
allow delete: if hasAnyRole(['admin']);
}
수정 불가 필드 보호와 diff 검증
일부 필드는 최초 생성 시에만 설정하고 이후 변경을 막아야 합니다. 예를 들어 createdAt, authorId, userId 같은 필드입니다. request.resource.data(새 데이터)와 resource.data(기존 데이터)를 비교해 변경 여부를 확인합니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 특정 필드가 수정되지 않았는지 확인
function fieldUnchanged(field) {
return request.resource.data[field] == resource.data[field];
}
// 생성 시에만 허용되는 필드 목록 검증
function immutableFields() {
return fieldUnchanged('authorId')
&& fieldUnchanged('createdAt')
&& fieldUnchanged('userId');
}
match /posts/{postId} {
allow create: if request.auth != null
&& request.resource.data.authorId == request.auth.uid
&& request.resource.data.createdAt == request.time;
// 업데이트 시: 변경 불가 필드 보호 + 수정 가능 필드 검증
allow update: if request.auth != null
&& request.auth.uid == resource.data.authorId
&& immutableFields()
&& request.resource.data.keys().hasOnly(['title', 'body', 'updatedAt', 'authorId', 'createdAt', 'userId']);
}
}
}
허용 필드만 업데이트되는지 검증
request.resource.data.keys().hasOnly([...]) 패턴으로 클라이언트가 허용되지 않은 필드를 추가하는 것을 막을 수 있습니다.
// ✅ 수정 가능한 필드만 변경됐는지 확인
function onlyUpdatableFields() {
let allowedFields = ['title', 'body', 'tags', 'updatedAt'];
// 새 데이터에 포함된 키가 허용 목록의 부분집합인지 확인
return request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(allowedFields);
}
diff() 메서드는 두 맵의 차이를 반환하고, .affectedKeys()는 변경된 키의 집합을 반환합니다. 이를 통해 실제로 수정된 필드만 정밀하게 검사할 수 있습니다.
💡 TIP
diff().affectedKeys()는 추가·변경·삭제된 키를 모두 포함합니다. 삭제된 키만 확인하려면diff().removedKeys(), 추가된 키만 보려면diff().addedKeys()를 사용하세요.
에뮬레이터 + @firebase/rules-unit-testing으로 규칙 테스트
규칙의 정확성을 사람이 수동으로 검증하는 데는 한계가 있습니다. 특히 엣지 케이스나 역할 조합이 복잡할 때 테스트 자동화가 필수입니다. Firebase는 로컬 에뮬레이터와 @firebase/rules-unit-testing 패키지를 통해 규칙을 완전히 격리된 환경에서 테스트할 수 있습니다.
환경 준비
# 에뮬레이터 설치 및 초기화
npm install -g firebase-tools
firebase init emulators # Firestore 에뮬레이터 선택
# 테스트 의존성 설치
npm install --save-dev @firebase/rules-unit-testing firebase-admin mocha
firebase.json에 에뮬레이터 설정이 있는지 확인합니다.
{
"emulators": {
"firestore": {
"port": 8080
}
},
"firestore": {
"rules": "firestore.rules"
}
}
테스트 파일 작성
// tests/firestore.rules.test.js
const { initializeTestEnvironment, assertFails, assertSucceeds } = require('@firebase/rules-unit-testing');
const { readFileSync } = require('fs');
const { doc, getDoc, setDoc, updateDoc, deleteDoc } = require('firebase/firestore');
let testEnv;
before(async () => {
testEnv = await initializeTestEnvironment({
projectId: 'demo-test-project',
firestore: {
rules: readFileSync('firestore.rules', 'utf8'),
host: 'localhost',
port: 8080,
},
});
});
after(async () => {
await testEnv.cleanup();
});
afterEach(async () => {
// 각 테스트 후 데이터 초기화
await testEnv.clearFirestore();
});
describe('posts 컬렉션 규칙', () => {
it('비로그인 사용자는 posts를 읽을 수 없다', async () => {
const unauthedDb = testEnv.unauthenticatedContext().firestore();
await assertFails(getDoc(doc(unauthedDb, 'posts/post1')));
});
it('로그인 사용자는 posts를 읽을 수 있다', async () => {
const authedDb = testEnv.authenticatedContext('user1').firestore();
await assertSucceeds(getDoc(doc(authedDb, 'posts/post1')));
});
it('작성자만 자신의 게시글을 수정할 수 있다', async () => {
// 먼저 관리자 권한으로 초기 데이터 설정
await testEnv.withSecurityRulesDisabled(async (context) => {
await setDoc(doc(context.firestore(), 'posts/post1'), {
authorId: 'user1',
title: '원본 제목',
createdAt: new Date(),
});
});
const ownerDb = testEnv.authenticatedContext('user1').firestore();
const otherDb = testEnv.authenticatedContext('user2').firestore();
// ✅ 작성자는 수정 가능
await assertSucceeds(
updateDoc(doc(ownerDb, 'posts/post1'), {
title: '수정된 제목',
updatedAt: new Date(),
})
);
// ❌ 다른 사용자는 수정 불가
await assertFails(
updateDoc(doc(otherDb, 'posts/post1'), {
title: '무단 수정',
updatedAt: new Date(),
})
);
});
});
describe('커스텀 클레임 기반 역할 테스트', () => {
it('admin 클레임이 있는 사용자만 삭제할 수 있다', async () => {
await testEnv.withSecurityRulesDisabled(async (context) => {
await setDoc(doc(context.firestore(), 'articles/article1'), {
title: '테스트 글',
});
});
// ✅ admin 클레임 보유 사용자
const adminDb = testEnv.authenticatedContext('admin-user', {
token: { role: 'admin' },
}).firestore();
// ❌ editor 클레임 보유 사용자
const editorDb = testEnv.authenticatedContext('editor-user', {
token: { role: 'editor' },
}).firestore();
await assertSucceeds(deleteDoc(doc(adminDb, 'articles/article1')));
await assertFails(deleteDoc(doc(editorDb, 'articles/article1')));
});
});
테스트 실행
# 에뮬레이터를 백그라운드에서 실행 후 테스트
firebase emulators:exec --only firestore "npx mocha tests/firestore.rules.test.js"
# 또는 에뮬레이터를 별도 터미널에서 먼저 실행
firebase emulators:start --only firestore
# 다른 터미널에서
npx mocha tests/firestore.rules.test.js
💡 TIP
withSecurityRulesDisabled()는 테스트 초기 데이터 설정 시 보안 규칙을 우회해 원하는 상태를 만드는 데 사용합니다. 이 컨텍스트로 쓴 데이터는 실제 규칙 검증을 거치지 않으므로, 초기화 용도로만 사용해야 합니다.
규칙 평가 한계와 디버깅
규칙이 예상대로 작동하지 않을 때를 대비해 평가 한계와 디버깅 방법을 알아두어야 합니다.
주요 평가 한계
| 항목 | 제한 |
|---|---|
get()/exists() 호출 수 | 단일 문서 요청 최대 10회, 다중 문서·트랜잭션·배치 최대 20회 |
| 함수 호출 깊이 | 최대 20개 함수 스택 |
| 규칙 파일 크기 | 256KB 이하 |
| 표현식 평가 시간 | 단계별 제한 있음 (공식 문서 참조) |
debug() 함수 활용
에뮬레이터 환경에서는 debug() 함수로 규칙 평가 중 값을 로그로 출력할 수 있습니다.
// firestore.rules
function isAdmin() {
let role = debug(request.auth.token.role); // 에뮬레이터 로그에 출력됨
return role == 'admin';
}
에뮬레이터를 실행하면 터미널에 debug() 출력이 표시되어 어떤 값이 평가되는지 바로 확인할 수 있습니다. 단, debug()는 에뮬레이터에서만 동작하며 프로덕션 규칙에는 영향을 주지 않습니다.
Firebase 콘솔의 Rules Playground
실제 Firebase 프로젝트 콘솔에서 Rules Playground 탭을 활용하면 특정 경로·인증 상태·데이터를 직접 입력해 규칙이 허용(allow)되는지 거부(deny)되는지 즉시 확인할 수 있습니다. 에뮬레이터 없이도 빠른 검증이 가능하지만, 복잡한 시나리오는 단위 테스트를 우선하세요.
흔한 실수와 해결책
// ❌ resource가 null일 수 있는 상황에서 필드 접근 시 오류 발생
// (신규 문서 생성 시 resource는 null)
allow write: if resource.data.authorId == request.auth.uid;
// ✅ 생성과 수정을 분리해서 처리
allow create: if request.resource.data.authorId == request.auth.uid;
allow update: if resource.data.authorId == request.auth.uid;
// ❌ request.auth가 null인 상태에서 .token 접근 시 오류
allow read: if request.auth.token.role == 'admin';
// ✅ null 체크 선행
allow read: if request.auth != null && request.auth.token.role == 'admin';
⚠️ 주의 Firestore 규칙은 단락 평가(short-circuit evaluation)를 지원합니다.
&&앞 조건이false면 뒤 조건은 평가하지 않으므로, null 체크를 앞에 두면 null 참조 오류를 방지할 수 있습니다.
요약
- 반복되는 권한 조건은 규칙 함수로 추출해 재사용성과 가독성을 높인다.
get()/exists()로 다른 문서를 참조할 수 있지만 각 호출은 읽기 1회 과금이 발생하며, 단일 문서 요청은 최대 10회·다중 문서 요청·트랜잭션·배치는 최대 20회로 제한된다.- 커스텀 클레임을 이용하면
get()없이 토큰에서 역할을 읽어 성능·비용을 절약할 수 있다. fieldUnchanged()와diff().affectedKeys()로 수정 불가 필드를 보호하고 허용된 변경만 통과시킬 수 있다.@firebase/rules-unit-testing과 에뮬레이터를 조합하면 규칙을 자동화된 단위 테스트로 검증할 수 있다.- 에뮬레이터의
debug()함수와 콘솔 Rules Playground로 규칙 평가 흐름을 빠르게 디버깅한다.
연습문제
-
다음 조건을 만족하는
comments컬렉션 규칙을 작성하세요. 로그인한 사용자는 읽기 가능, 작성은 로그인한 사용자이면서request.resource.data.authorId가 본인 uid와 같을 때만 허용, 수정은 작성자만 가능하며authorId와createdAt필드는 변경 불가, 삭제는 커스텀 클레임role == 'admin'인 사용자와 작성자 본인만 가능해야 합니다.힌트
allow create,allow update,allow delete를 각각 분리하고, 함수를 적극 활용하세요. -
teams/{teamId}/members/{userId}서브컬렉션이 존재한다고 가정할 때,projects/{projectId}문서에 대해 "해당 팀의 멤버인 경우에만 읽기 허용, 멤버이면서role == 'lead'인 경우에만 쓰기 허용"하는 규칙을get()/exists()를 사용해 작성하세요.힌트
projects/{projectId}문서에teamId필드가 있다고 가정하고,resource.data.teamId로 팀 ID를 가져오세요. -
@firebase/rules-unit-testing을 사용해 다음 두 케이스를 테스트하는 코드를 작성하세요. (1) 비로그인 사용자가articles컬렉션에 문서를 생성할 때 실패해야 한다. (2)role: 'editor'클레임을 가진 사용자가articles컬렉션에 문서를 생성할 때 성공해야 한다.힌트
testEnv.unauthenticatedContext()와testEnv.authenticatedContext('uid', { token: { role: 'editor' } })를 사용하세요. -
diff().affectedKeys().hasOnly()를 사용해posts컬렉션 업데이트 시title,body,updatedAt세 필드 외의 변경은 거부하는 규칙을 작성하고, 이것이request.resource.data.keys().hasOnly()와 어떻게 다른지 설명하세요.힌트
hasOnly()는 전체 문서 키를 검사하고,diff().affectedKeys()는 변경된 키만 검사합니다.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Firebase 심화” 강좌에 대한 댓글입니다.