출시 전에 한 번 더 본 권한 모델
원래 권한 모델은 단순했다. role: "user" 아니면 role: "manager". manager면 신고 처리, 게시글 정지, 사용자 차단, 공지 고정까지 다 할 수 있는 구조. 출시하면 학생 한두 명에게 manager를 주고 학교 운영진은 admin으로 두면 되겠다, 이 정도로 잡고 있었다.
문제는 출시 후 시나리오를 머릿속으로 돌려보면서 드러났다. 학생 운영자가 신고를 처리하다가 결국 정지 버튼까지 누르게 된다. 정지는 되돌리기 어렵다. 학교 안 커뮤니티에서 친구를 정지시키는 건 사회적 비용이 따라오고, 그 부담을 학생 운영자에게 통째로 떠넘기는 건 옳지 않다는 생각이 들었다.
그리고 더 근본적인 문제 사용자가 전부 미성년자다. 신고 처리든 사용자 관리든 manager 권한을 가진 학생이 다른 학생의 실명, 학번, 학년/반/번호, 신고 사유 같은 개인정보에 접근하게 된다. 미성년자가 미성년자의 민감 정보를 들여다보는 구조를 단일 manager 등급으로 뭉뚱그리는 건 PIPA(특히 청소년 개인정보 처리 조항)와도 어긋나고, 학생 운영자 본인에게도 부담이 된다. 실명이 필요한 작업(정지 처분, 권한 변경)은 어른(교사/admin)의 책임으로 명확히 분리해야 했다.
그렇다고 manager 부여를 막으면 신고가 쌓일 거다. 신고 처리 권한과 정지 권한, 그리고 개인정보 열람 권한을 같은 등급에 묶는 게 잘못이었다. 출시 전에 한 번 갈아엎기로 했다.
누구에게 무엇을 줄 것인가
권한을 4단계로 쪼갠다.
- user 일반 사용자
- moderator 신고 처리, 게시글/댓글 숨김. 정지는 못 함
- auditor 모든 신고/로그 읽기 전용. 쓰기는 없음
- manager 정지, 공지 고정, 사용자 승인
- admin 전부 + 다른 사용자의 권한 변경
핵심은 두 가지. moderator는 “도와주는 학생”, auditor는 “감사 권한이 필요한 교사”. 둘 다 manager가 떠안고 있던 일을 잘게 분리한 결과다.
여기서 또 하나 중요한 건 개인정보 노출 범위가 등급별로 다르다는 거다. moderator(학생)는 익명 게시글의 신고 처리는 할 수 있지만 작성자의 실명/학번을 보지 못한다. 익명 → 실명 매핑은 admin만 열람할 수 있고, 그 행위 자체가 admin_logs에 기록된다. auditor(교사)는 감사를 위해 실명까지 볼 수 있지만 쓰기 권한이 없어 신고 자체에 개입할 수 없다. 같은 데이터라도 누가 보느냐에 따라 마스킹 정도를 다르게 가져가는 구조 이걸 권한 등급에 박아두면 화면 단에서 실수할 여지가 줄어든다.
이 모델이 화면에서 어떻게 보일지가 가장 신경 쓰는 부분이다. moderator 계정으로 들어가면 “사용자 정지” 버튼은 아예 안 보이게 만들 계획이다. 권한 검사로 막는 게 아니라, 시야에서 사라지게. 버튼이 보이면 누르고 싶어지고, 안 보이면 그게 자기 일이 아니라는 신호가 된다 가설이지만, 배포 후 학생 운영자들의 사용 패턴을 보고 검증해야 할 부분이다.
custom claims로 옮기는 권한 검사
권한 검사는 Firestore 보안 규칙에서 한다. 기존에는 이런 식이었다.
| |
문제는 get()이 Firestore 읽기 1회를 추가로 발생시킨다는 거다. 게시글 1개 조회할 때마다 사용자 문서를 1번 더 읽는다. 비용도 비용이지만, 권한 검사가 데이터 읽기에 비례해서 늘어나는 구조 자체가 마음에 안 든다.
Firebase Auth의 custom claims로 옮기기로 했다.
| |
이렇게 하면 ID 토큰에 role이 박혀서 온다. 보안 규칙은:
| |
DB 조회 0회. 사용자의 권한이 변경된 직후에는 토큰을 갱신해야 반영되지만, 클라이언트에서 getIdTokenResult(true) 한 번 부르면 끝이다.
기존 사용자 28명에게 claims를 채우는 1회용 마이그레이션 스크립트도 짰다. users 컬렉션을 돌면서 각자의 role 필드 값으로 setCustomUserClaims를 호출. 단순한 루프지만 한 번 잘못 돌면 권한이 다 깨지니까 배포 전에 dry-run 모드부터 돌릴 계획이다. 그리고 마이그레이션이 끝나면 backfillCustomClaims HTTP 함수는 즉시 삭제 일회성 도구를 운영망에 남겨두면 언젠가 사고가 난다.
PIPA가 따라온다
권한을 정리하다 보니 자연스럽게 다음 질문이 나온다. 사용자가 자기 데이터에 대해 가지는 권리는 어디까지인가.
한국 개인정보 보호법(PIPA)은 사용자에게 세 가지를 보장하라고 한다.
- 처리 결과(정지, 차단 등)에 이의신청할 권리
- 자기 데이터를 다운로드할 권리
- 어떤 규칙으로 운영되는지 명시된 커뮤니티 규칙을 볼 권리
세 컬렉션이 추가된다. appeals, data_requests, community_rules. 각각 클라이언트 화면 1개 + 어드민 처리 페이지 1개씩.
이 중 가장 신경 쓰는 건 data_requests다. 사용자가 “내 데이터 내놔라"를 누르면 Cloud Function이 그 사용자의 게시글, 댓글, 신고 이력, 채팅 메시지를 모아 JSON으로 묶고, Firebase Storage에 업로드한 뒤 다운로드 링크를 이메일로 보낸다. 시간이 걸리는 작업이라 클라이언트에서 동기적으로 못 한다. 배포 후 첫 요청이 들어왔을 때 실제로 끝까지 동작하는지가 가장 큰 미지수다.
TTL이 데이터 모델의 일부가 되어야 한다
PIPA를 들여다보면서 가장 크게 다가온 건 이거였다.
보관 의무가 끝난 데이터는 자동으로 사라져야 한다.
화면에서 “삭제 버튼"을 만드는 건 일이 아니다. 진짜 일은 보관 기한이 지난 데이터를 개입 없이 사라지게 하는 거다. 사람이 매번 청소하는 시스템은 결국 안 청소된다.
그래서 거의 모든 새 컬렉션에 expiresAt 필드를 박았다. Firestore TTL 정책을 그 필드에 걸면, 그 시각이 지난 문서를 Firestore가 알아서 삭제한다.
| |
이의신청: 90일. 데이터 요청 기록: 30일. 관리자 로그: 1년. 보관 의무가 끝나면 알아서 사라진다. 사람이 매주 청소할 필요가 없다.
다만 TTL은 코드로 자동 적용되지 않는다. Firebase Console에서 컬렉션별로 한 번 묶어줘야 한다. 코드 배포 직후 잊지 않고 콘솔에서 admin_logs.expiresAt, appeals.expiresAt, data_requests.expiresAt 세 개에 TTL 정책을 걸어야 한다. 잊으면 데이터가 영원히 남는다 가장 무서운 종류의 버그.
화면 3개가 시작이었다
처음엔 “화면 세 개만 추가하면 되겠네"라고 생각했다. 이의신청, 데이터 요청, 커뮤니티 규칙. 끝.
지금 변경된 파일을 세보니 75개, 7천 줄에 가깝다.
- Firestore 보안 규칙 168줄 4단계 역할 + 새 컬렉션 3개의 권한 + 작성자 외 접근 차단
- Cloud Functions 690줄 데이터 익스포트, 이의신청 알림, 만료 청소
- 인증 가드 202줄 (
verification_guard.dart신규) 약관/개인정보 동의 안 하면 메인 진입 차단 - 로그인 화면 283줄 재작성 약관/개인정보 동의 단계가 회원가입 플로우에 들어감
- i18n 키 약 240개 (한/영) 새 화면들이 전부 다국어 지원
- 관리자 웹 페이지 4개 신규 (admin-logs, appeals, community-rules, data-requests)
- Firestore 보안 규칙 테스트 589줄 새 권한 모델이 의도대로 동작하는지 에뮬레이터로 검증
법 조항 몇 줄을 만족시키려고 인증, 권한, 데이터 라이프사이클, UI, i18n, 어드민, 테스트까지 거의 모든 레이어에 손이 간다. PIPA 컴플라이언스는 표면 작업이 아니라 사용자의 데이터를 어떻게 다루는지에 대한 입장 표명이고, 그 입장은 모든 레이어를 통과해야 한다.
어드민 웹도 같이 정리한다
권한이 4단계로 늘면서 어드민 웹 페이지가 4개 새로 생긴다 (admin-logs, appeals, community-rules, data-requests). 페이지가 늘어나니 공통 레이아웃이 필요해진다.
AdminShell이라는 셸 컴포넌트를 만들었다. 사이드바, 헤더, 다크모드 토글, 권한별 메뉴 표시 페이지마다 똑같이 들어가던 코드를 한 곳에 모은다. 페이지 추가 비용이 떨어진다.
캐시 레이어도 단순하게 하나 짰다 (lib/cache.ts). TTL 60초짜리 메모리 맵 + in-flight dedup 정도지만, 페이지 전환할 때마다 같은 Firestore 컬렉션을 다시 읽지 않는 것만으로 체감 속도가 확 빨라질 거라 본다.
대시보드는 별개 작업이지만 같은 흐름에서 바꿨다. 기존엔 클라이언트가 posts, users, reports를 전부 불러서 카운트했는데, Cloud Function 트리거로 app_stats/totals 문서에 카운터를 증분(FieldValue.increment)하도록 옮겼다. 대시보드 로딩이 ms 단위로 떨어질 것으로 기대한다 실측은 배포 후.
돌이켜보면 (배포 전이지만)
가장 큰 변화는 권한 모델보다 개인정보를 데이터 모델 안에 박아 넣었다는 점이다. 화면에 “이의신청” 버튼 하나 만드는 건 한 시간이면 끝나지만, 그 데이터가 90일 뒤 자동으로 사라지도록 만드는 건 컬렉션 설계, TTL 정책, Cloud Function 트리거가 다 엮여야 한다.
PIPA 같은 법적 요구사항을 만난다는 건 화면 추가 작업이 아니라 데이터 라이프사이클을 처음부터 다시 짜는 일이라는 걸 이번에 배웠다. 다음에 비슷한 컴플라이언스를 다른 프로젝트에서 만나도, 화면보다 컬렉션부터 그릴 것 같다.
manager 한 명에게 모든 걸 맡기던 시스템을 4단계로 쪼개고, 사용자의 권리를 데이터 모델에 새겨 넣었다. 75개 파일, 7천 줄. 이제 배포 버튼만 누르면 된다.