[STYNA] Firebase 보안 규칙 설계 – Firestore·Storage 실전 적용

주요 포인트 한눈에 보기

이 문서는 GPT를 활용해 Firebase 보안 규칙을 학습하고 실제 프로젝트에 적용하는 과정을 정리합니다.
Firestore Database와 Firebase Storage 규칙을 중심으로,
왜 규칙이 필요한지와 어떻게 설계해야 하는지를 구조적으로 설명합니다.

작업 목적

쇼핑몰 프로젝트를 진행하면서 Firebase 보안 규칙 작성을 뒤로 미뤄두었습니다.
기능 구현에 집중하다 보니 인증은 되어 있지만,
실제로 누가 어떤 데이터에 접근할 수 있는지에 대한 통제가 부족한 상태였습니다.

이번 문서의 목적은 단순히 규칙 문법을 외우는 것이 아니라,
“왜 규칙이 반드시 필요한지”와
“내 프로젝트 구조에서는 어떤 기준으로 규칙을 설계해야 하는지”를 이해하는 데 있습니다.

왜 Firebase 규칙을 반드시 작성해야 하는가

Next.js 기반 Firebase 프로젝트의 환경 변수를 확인해보면
NEXT_PUBLIC_FIREBASE_API_KEY와 같이
NEXT_PUBLIC_ 접두사가 붙은 값들이 사용되고 있는 것을 확인할 수 있습니다.

이 접두사는 해당 환경 변수가 클라이언트 코드에 그대로 노출됨을 전제로 설계되었다는 의미이며,
Firebase를 사용하는 대부분의 프론트엔드 프로젝트는 이러한 공개 환경 변수를 기반으로 동작합니다.

이로 인해 자주 발생하는 오해가
“환경 변수가 공개되어 있으니 누구나 Firebase 데이터에 접근할 수 있는 것 아닌가?”라는 의문입니다.
하지만 실제로 데이터 접근을 제어하는 요소는 환경 변수가 아니라
Firebase 보안 규칙(Security Rules)입니다.

인증(Auth)은 사용자가 누구인지를 증명하는 단계이고,
보안 규칙(Rules)은 그 사용자가 무엇을 할 수 있는지를 결정하는 단계입니다.
즉, 인증만 존재하고 규칙이 없다면,
로그인 여부와 관계없이 데이터 접근이 허용되는 구조가 될 수 있습니다.

따라서 Firebase를 사용하는 서비스에서는
공개 환경 변수 사용을 전제로 하되,
보안 규칙을 통해 인증된 사용자만 읽기·쓰기 작업이 가능하도록 제한하는 설계가 필수적입니다.

특히 쇼핑몰과 같이
상품 정보, 주문 내역, 사용자 개인정보가 함께 존재하는 서비스에서는
이러한 규칙이 없다면
심각한 데이터 노출 및 변조 문제가 발생할 수 있습니다.

Firebase 규칙은 어떻게 작성되는가

Firebase 보안 규칙은 단순히 “허용 / 차단”을 나누는 조건문이 아니라,
요청(request)이 들어오는 시점의 정보와
실제 접근하려는 데이터(resource)의 상태를 함께 비교하여 판단합니다.
이 판단은 서버에서 수행되며,
클라이언트 코드는 이 결과를 절대 우회할 수 없습니다.

규칙을 이해하기 위해 반드시 알고 있어야 할 기본 구성 요소는
request, resource, auth, match 네 가지입니다.
이 개념들을 이해하면 대부분의 규칙을 스스로 설계할 수 있습니다.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // 인증된 사용자만 접근 가능
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}

위 코드는 Firestore 보안 규칙의 가장 기본이 되는 뼈대 구조입니다.
각 줄은 단순한 선언처럼 보이지만, 실제로는 Firestore가 요청을 해석하는 기준점 역할을 합니다.

먼저 rules_version = '2';는 현재 사용 가능한 최신 규칙 문법 버전을 의미합니다.
Firebase는 규칙 문법의 하위 호환을 보장하지 않기 때문에,
신규 프로젝트에서는 반드시 버전 2를 명시적으로 선언하는 것이 권장됩니다.
이 값은 규칙의 기능 집합과 표현식을 결정하는 기준이 됩니다.

service cloud.firestore
“이 규칙이 Firestore Database에 적용된다”는 것을 선언하는 영역입니다.
Firebase에는 Firestore, Storage 등 여러 서비스가 존재하며,
각 서비스마다 규칙 파일과 문법이 분리되어 있습니다.
따라서 Firestore 규칙을 작성할 때는 반드시 이 service 블록 안에서 작성해야 합니다.

match /databases/{database}/documents
Firestore 규칙의 기준이 되는 최상위(base) 경로입니다.
실제 데이터베이스 ID가 무엇이든 간에,
모든 Firestore 문서 접근 요청은 이 경로를 시작점으로 평가됩니다.

이 match 블록 내부에 작성되는 모든 하위 경로들은
해당 base 경로의 하위 문서 및 컬렉션을 의미합니다.
즉, 이후에 작성하는 /users, /products, /orders와 같은 규칙들은
모두 이 base 경로를 기준으로 상대적으로 해석됩니다.

마지막으로 match /{document=**}
모든 문서 경로를 포괄적으로 매칭하는 와일드카드 규칙입니다.
이 예제에서는 로그인된 사용자라면
어떤 컬렉션, 어떤 문서든 접근이 가능한 상태를 의미합니다.
따라서 이 구조는 학습용으로는 적합하지만,
실제 서비스에서는 반드시 화이트리스트 방식으로 대체되어야 합니다.

match /users/{userId} {
  allow read, write: if request.auth != null
    && request.auth.uid == userId;
}

위 규칙은 Firestore에서 사용자 개인 문서를 보호하기 위해 가장 기본적으로 사용되는 패턴입니다.
핵심은 “요청한 사용자”와 “접근하려는 문서의 소유자”가 동일한지를 비교하는 데 있습니다.

match /users/{userId}에서 {userId}
문서 ID를 변수로 받아오는 경로 매개변수입니다.
즉, /users/abc123 문서에 접근할 경우
userId에는 abc123 값이 바인딩됩니다.

request.auth는 현재 요청을 보낸 사용자의 인증 정보를 의미하며,
로그인하지 않은 상태라면 null이 됩니다.
따라서 request.auth != null 조건은
“비로그인 사용자의 접근을 전면 차단한다”는 의미를 가집니다.

request.auth.uid
Firebase Authentication에서 발급한 로그인 사용자의 고유 식별자(uid)입니다.
이 값은 클라이언트에서 임의로 변경할 수 없으며,
Firestore 규칙 평가 시 서버에서 신뢰할 수 있는 값으로 사용됩니다.

마지막으로 request.auth.uid == userId 조건은
“로그인한 사용자가 자기 자신의 문서에만 접근하고 있는지”를 검증합니다.
이 비교를 통해 다른 사용자의 개인정보 문서에 접근하는 시도를
규칙 단계에서 완전히 차단할 수 있습니다.

이 패턴은 사용자 프로필, 계정 설정, 개인 데이터 보호의 출발점이며,
이후 관리자 권한 분리나 하위 컬렉션 제어와 같은
보다 복잡한 규칙 설계의 기반이 됩니다.

match /posts/{postId} {
  allow read: if true;
  allow write: if request.auth != null;
}

이 규칙은 Firestore에서 자주 사용되는 “공개 읽기 / 제한된 쓰기” 패턴의 대표적인 예시입니다.
데이터는 누구나 조회할 수 있도록 개방하되,
데이터의 생성이나 수정은 인증된 사용자에게만 허용하는 구조입니다.

allow read: if true;
인증 여부와 관계없이 모든 사용자가 해당 문서를 조회할 수 있음을 의미합니다.
이때 read는 기존에 저장된 resource를 조회하는 행위만 허용하며,
데이터의 변경 권한과는 분리되어 평가됩니다.

allow write: if request.auth != null; 조건은
문서를 생성하거나 수정하려는 요청이
반드시 로그인된 사용자로부터 왔는지를 검증합니다.
이를 통해 비로그인 사용자의 스팸성 데이터 생성이나
무차별적인 데이터 변경 시도를 차단할 수 있습니다.

이 패턴은 공지사항, 공개 게시판, 댓글 시스템과 같이
“보는 것은 자유롭지만, 작성에는 책임이 필요한 데이터”에 적합합니다.
이후 단계에서는 작성자 본인만 수정·삭제할 수 있도록
추가적인 소유자 검증 로직을 결합하는 경우가 많습니다.

match /admin/{docId} {
  allow read, write: if request.auth != null
    && request.auth.token.admin == true;
}

이 규칙은 Firebase Authentication의 커스텀 클레임을 활용한
관리자 전용 접근 제어 패턴을 보여줍니다.
일반 사용자와 관리자 영역을 규칙 수준에서 완전히 분리하는 데 사용됩니다.

request.auth.token.admin 값은
Firebase Auth 토큰에 포함된 커스텀 클레임으로,
서버(관리자 SDK 또는 Functions)를 통해서만 설정할 수 있습니다.
클라이언트에서는 이 값을 임의로 조작할 수 없습니다.

따라서 request.auth.token.admin == true 조건은
“이 요청이 관리자 권한을 가진 사용자로부터 왔는가”를
신뢰 가능한 방식으로 검증합니다.
이를 통해 관리자 페이지, 내부 설정, 운영 데이터와 같은 영역을
일반 사용자 접근으로부터 완전히 차단할 수 있습니다.

이 방식은 관리자 권한이 자주 변경되지 않고,
전역적으로 일관된 관리자 판단이 필요한 경우에 적합합니다.
다만 권한 변경 시 토큰 재발급이 필요하므로,
프로젝트 성격에 따라 Firestore 기반 role 방식과 선택적으로 사용됩니다.

앞서 살펴본 규칙들은
Firestore 보안 규칙에서 가장 기본이 되는 접근 제어 패턴들입니다.
개인 문서 보호, 공개 데이터 분리, 관리자 전용 영역 구성이
어떻게 코드로 표현되는지를 단계적으로 보여줍니다.

이러한 기본 패턴들을 정확히 이해하고 조합하는 것이
이후 섹션에서 다루는 프로젝트 전체 규칙 설계의 출발점이 됩니다.
규칙은 문법보다 “어떤 데이터를 누구에게까지 허용할 것인가”에 대한
명확한 기준을 세우는 과정임을 기억하는 것이 중요합니다.

내 프로젝트에 맞게 규칙 설계하기

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {

    /* ===============================
       공통 헬퍼 함수
    =============================== */
    function isSignedIn() {
      return request.auth != null;
    }

    function isOwner(userId) {
      return isSignedIn() && request.auth.uid == userId;
    }

    function isAdmin() {
      return isSignedIn() && request.auth.token.admin == true;
    }

    /* ===============================
       기본 차단 (화이트리스트 방식)
    =============================== */
    match /{document=**} {
      allow read, write: if false;
    }

    /* ===============================
       카테고리 / 상품 (공개 조회)
    =============================== */
    match /categories/{categoryId} {
      allow read: if true;
      allow write: if isAdmin();
    }

    match /categories/{categoryId}/products/{productId} {
      allow read: if true;
      allow write: if isAdmin();
    }

    match /products/{productId} {
      allow read: if true;
      allow write: if isAdmin();
    }

    /* ===============================
       사용자 정보
    =============================== */
    match /users/{userId} {
      allow read, write: if isOwner(userId) || isAdmin();

      match /pointHistory/{docId} {
        allow read: if isOwner(userId) || isAdmin();
        allow write: if false; // Functions 전용
      }
    }

    /* ===============================
       장바구니 (문서 ID = uid)
    =============================== */
    match /carts/{userId} {
      allow read, write: if isOwner(userId);
    }

    /* ===============================
       주문
    =============================== */
    match /orders/{orderId} {
      allow create: if isSignedIn()
        && request.resource.data.userId == request.auth.uid;

      allow read: if isAdmin()
        || (isSignedIn() && resource.data.userId == request.auth.uid);

      allow update: if isAdmin();
      allow delete: if false;
    }

    /* ===============================
       리뷰
    =============================== */
    match /reviews/{reviewId} {
      allow read: if true;

      allow create: if isSignedIn()
        && request.resource.data.userId == request.auth.uid;

      allow update, delete: if isSignedIn()
        && resource.data.userId == request.auth.uid;
    }

    /* ===============================
       QnA
       - 조회수 증가를 위해 views 필드만 일반 사용자도 수정 가능
    =============================== */
    match /qna/{qnaId} {
      allow read: if true;
      allow create: if isSignedIn();
      
      allow update: if isAdmin()
        || (isSignedIn() 
            && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
            && request.resource.data.views == resource.data.views + 1);
      
      allow delete: if isAdmin();
    }

    /* ===============================
       1:1 문의
    =============================== */
    match /inquiries/{inquiryId} {
      allow create: if isSignedIn();

      allow read: if isAdmin()
        || (isSignedIn() && resource.data.userId == request.auth.uid);

      allow update, delete: if isAdmin();
    }

    /* ===============================
       쿠폰
    =============================== */
    match /coupons/{couponId} {
      allow read: if true;
      allow write: if isAdmin();
    }

    match /user_coupons/{docId} {
      allow read: if isAdmin()
        || (isSignedIn() && resource.data.uid == request.auth.uid);

      allow write: if false;
    }

    /* ===============================
       이벤트
    =============================== */
    match /events/{eventId} {
      allow read: if true;
      allow write: if isAdmin();
    }

    /* ===============================
       이벤트 참여자 (최상위 컬렉션!)
    =============================== */
    match /eventParticipants/{docId} {
      allow read: if isAdmin()
        || (isSignedIn() && resource.data.userId == request.auth.uid);

      allow create: if isSignedIn()
        && request.resource.data.userId == request.auth.uid;

      allow update, delete: if isAdmin();
    }

    /* ===============================
       설정성 데이터
    =============================== */
    match /featuredProducts/{docId} {
      allow read: if true;
      allow write: if isAdmin();
    }

    match /categoryOrder/{docId} {
      allow read: if true;
      allow write: if isAdmin();
    }

    /* ===============================
       유저 활동 데이터
    =============================== */
    match /userRecentProducts/{docId} {
      allow read: if isSignedIn()
        && resource.data.userId == request.auth.uid;

      allow create: if isSignedIn()
        && request.resource.data.userId == request.auth.uid;

      allow update, delete: if isSignedIn()
        && resource.data.userId == request.auth.uid;
    }

    match /userWishlist/{docId} {
      allow read: if isSignedIn()
        && resource.data.userId == request.auth.uid;

      allow create: if isSignedIn()
        && request.resource.data.userId == request.auth.uid;

      allow update, delete: if isSignedIn()
        && resource.data.userId == request.auth.uid;
    }

  }
}

실제 서비스에서 사용하는 규칙은 단일 컬렉션 기준의 단순 조건이 아니라,
프로젝트 전체 데이터 구조를 기준으로 한 “화이트리스트 방식”으로 설계하는 것이 핵심입니다.
즉, 기본적으로 모든 접근을 차단한 뒤,
반드시 필요한 경로만 하나씩 열어주는 구조입니다.

이를 위해 먼저 공통으로 사용할 수 있는 헬퍼 함수를 정의합니다.
로그인 여부, 소유자 여부, 관리자 여부를 함수로 분리하면
이후 규칙의 가독성과 유지보수성이 크게 향상됩니다.

function isSignedIn() {
  return request.auth != null;
}

function isOwner(userId) {
  return isSignedIn() && request.auth.uid == userId;
}

function isAdmin() {
  return isSignedIn() && request.auth.token.admin == true;
}

이 헬퍼 함수들은 단순 편의용이 아니라,
규칙 전체에서 반복되는 인증·권한 판단 로직을 하나의 기준으로 통합하기 위한 장치입니다.
이후 권한 정책이 변경되더라도 함수 내부만 수정하면
모든 규칙에 일관되게 반영할 수 있습니다.

다음 단계는 모든 경로를 기본적으로 차단하는 선언입니다.
이는 Firestore 규칙 설계에서 가장 중요한 안전 장치이며,
“허용하지 않은 접근은 모두 거부한다”는 원칙을 코드로 고정합니다.

match /{document=**} {
  allow read, write: if false;
}

이 선언이 존재하기 때문에,
이후 등장하는 규칙들은 모두 예외적으로 열어주는 경로가 됩니다.
실수로 규칙을 누락하더라도 데이터가 외부로 노출되는 상황을 방지할 수 있습니다.

상품과 카테고리와 같은 공개 데이터는
조회(read)는 모두에게 허용하되,
생성·수정·삭제(write)는 관리자만 가능하도록 분리합니다.
이때 read와 write를 명확히 나누는 것이 중요합니다.

match /products/{productId} {
  allow read: if true;
  allow write: if isAdmin();
}

여기서 readresource 기준의
이미 저장된 데이터 조회를 의미합니다.
즉, 현재 데이터베이스에 존재하는 값을
그대로 읽는 행위만 허용되는 경우입니다.

반면 writerequest.resource 기준으로 평가되며,
새로 생성되거나 수정될 데이터의 구조와 값을 대상으로 검사합니다.
이 차이를 이해하지 못하면
의도치 않게 “읽기 전용 데이터”가 수정 가능해지는 실수가 발생할 수 있습니다.

따라서 “읽기만 허용하고 쓰기는 차단”하는 패턴은
공개 데이터 보호에서 가장 기본적이면서도 중요한 규칙 패턴이며,
Firestore 규칙 설계의 출발점이라고 볼 수 있습니다.

사용자 데이터는 문서 소유자 또는 관리자만 접근할 수 있도록 제한합니다.
특히 포인트 이력과 같이 금전적 의미를 가지는 데이터는
클라이언트에서 절대 write 할 수 없도록 차단하고,
서버(Firebase Functions)를 통해서만 변경되도록 설계합니다.

match /users/{userId} {
  allow read, write: if isOwner(userId) || isAdmin();

  match /pointHistory/{docId} {
    allow read: if isOwner(userId) || isAdmin();
    allow write: if false;
  }
}

이 구조는 “클라이언트는 신뢰하지 않는다”는 전제를 코드로 명확히 표현합니다.
실제 포인트 적립·차감 로직은 Firebase Functions에서만 수행되며,
규칙을 통해 클라이언트 조작 가능성을 원천적으로 차단합니다.

실무에서 자주 발생하는 사고 사례는 대부분
“읽기는 되는데 쓰기까지 열려 있었던 경우”,
“서버 전용 데이터가 클라이언트에서 수정 가능했던 경우”에서 발생합니다.

예를 들어 주문(order) 데이터에서 allow write: if isSignedIn()와 같이
단순히 로그인 여부만으로 쓰기를 허용한 경우,
사용자가 개발자 도구나 REST API를 통해
자신의 주문 금액이나 상태 값을 직접 변경하는 사고가 발생할 수 있습니다.

또한 포인트, 쿠폰, 적립 내역과 같이
서버 계산 결과를 신뢰해야 하는 데이터가
클라이언트 쓰기 권한을 가진 상태라면,
의도하지 않은 값 조작이나 중복 적립 문제가 발생하게 됩니다.

이러한 문제를 방지하기 위해
이 프로젝트에서는 처음부터
화이트리스트 방식
서버 전용 데이터(Function-only write) 개념을 기준으로
규칙을 설계했습니다.
허용하지 않은 모든 접근은 차단하고,
반드시 필요한 경로만 최소 범위로 열어주는 것이 핵심 원칙입니다.

FAQ

Q. Firebase 규칙 없이 개발해도 되지 않나요?
개발 초기에는 가능하지만,
실제 서비스 단계에서는 반드시 규칙이 필요합니다.
규칙이 없는 상태는 모든 데이터를 공개한 것과 동일합니다.

Q. 규칙 테스트는 어떻게 하나요?
Firebase 콘솔의 Rules Playground를 사용하거나,
Emulator 환경에서 실제 요청 흐름을 테스트하는 것이 가장 안전합니다.

Q. Storage 규칙도 꼭 작성해야 하나요?
이미지 URL이 노출되는 서비스라면 반드시 필요합니다.
Storage 규칙이 없으면 누구나 파일을 업로드하거나 삭제할 수 있습니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기