#12 - manager 한 명에 모든 걸 맡길 수 없어서

신고가 쌓이고, 정지 권한자와 처리 도우미를 같은 등급에 둘 수 없겠다는 결론. 권한 모델을 4단계로 다시 짜고 PIPA(개인정보보호법)까지 묶어서 정리하려는 구상 — 코드는 다 짰고, 이제 배포만 남았다.

출시 전에 한 번 더 본 권한 모델

원래 권한 모델은 단순했다. 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 보안 규칙에서 한다. 기존에는 이런 식이었다.

1
2
3
function isManager() {
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'manager';
}

문제는 get()이 Firestore 읽기 1회를 추가로 발생시킨다는 거다. 게시글 1개 조회할 때마다 사용자 문서를 1번 더 읽는다. 비용도 비용이지만, 권한 검사가 데이터 읽기에 비례해서 늘어나는 구조 자체가 마음에 안 든다.

Firebase Auth의 custom claims로 옮기기로 했다.

1
2
3
4
await getAuth().setCustomUserClaims(uid, {
  role: "moderator",
  approved: true,
});

이렇게 하면 ID 토큰에 role이 박혀서 온다. 보안 규칙은:

1
2
3
function isModerator() {
  return request.auth.token.role in ['moderator', 'manager', 'admin'];
}

DB 조회 0회. 사용자의 권한이 변경된 직후에는 토큰을 갱신해야 반영되지만, 클라이언트에서 getIdTokenResult(true) 한 번 부르면 끝이다.

기존 사용자 28명에게 claims를 채우는 1회용 마이그레이션 스크립트도 짰다. users 컬렉션을 돌면서 각자의 role 필드 값으로 setCustomUserClaims를 호출. 단순한 루프지만 한 번 잘못 돌면 권한이 다 깨지니까 배포 전에 dry-run 모드부터 돌릴 계획이다. 그리고 마이그레이션이 끝나면 backfillCustomClaims HTTP 함수는 즉시 삭제 — 일회성 도구를 운영망에 남겨두면 언젠가 사고가 난다.

PIPA가 따라온다

권한을 정리하다 보니 자연스럽게 다음 질문이 나온다. 사용자가 자기 데이터에 대해 가지는 권리는 어디까지인가.

한국 개인정보 보호법(PIPA)은 사용자에게 세 가지를 보장하라고 한다.

  1. 처리 결과(정지, 차단 등)에 이의신청할 권리
  2. 자기 데이터를 다운로드할 권리
  3. 어떤 규칙으로 운영되는지 명시된 커뮤니티 규칙을 볼 권리

세 컬렉션이 추가된다. appeals, data_requests, community_rules. 각각 클라이언트 화면 1개 + 어드민 처리 페이지 1개씩.

이 중 가장 신경 쓰는 건 data_requests다. 사용자가 “내 데이터 내놔라"를 누르면 Cloud Function이 그 사용자의 게시글, 댓글, 신고 이력, 채팅 메시지를 모아 JSON으로 묶고, Firebase Storage에 업로드한 뒤 다운로드 링크를 이메일로 보낸다. 시간이 걸리는 작업이라 클라이언트에서 동기적으로 못 한다. 배포 후 첫 요청이 들어왔을 때 실제로 끝까지 동작하는지가 가장 큰 미지수다.

TTL이 데이터 모델의 일부가 되어야 한다

PIPA를 들여다보면서 가장 크게 다가온 건 이거였다.

보관 의무가 끝난 데이터는 자동으로 사라져야 한다.

화면에서 “삭제 버튼"을 만드는 건 일이 아니다. 진짜 일은 보관 기한이 지난 데이터를 개입 없이 사라지게 하는 거다. 사람이 매번 청소하는 시스템은 결국 안 청소된다.

그래서 거의 모든 새 컬렉션에 expiresAt 필드를 박았다. Firestore TTL 정책을 그 필드에 걸면, 그 시각이 지난 문서를 Firestore가 알아서 삭제한다.

1
2
3
4
5
6
await db.collection("appeals").add({
  uid,
  reason,
  createdAt: new Date(),
  expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90일 뒤
});

이의신청: 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천 줄. 이제 배포 버튼만 누르면 된다.