<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>PIPA on monkshark.dev</title><link>https://monkshark.github.io/tags/pipa/</link><description>Recent content in PIPA on monkshark.dev</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Wed, 29 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://monkshark.github.io/tags/pipa/index.xml" rel="self" type="application/rss+xml"/><item><title>#12 - manager 한 명에 모든 걸 맡길 수 없어서</title><link>https://monkshark.github.io/p/roles-and-pipa/</link><pubDate>Wed, 29 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/roles-and-pipa/</guid><description>&lt;h2 id="출시-전에-한-번-더-본-권한-모델"&gt;&lt;a href="#%ec%b6%9c%ec%8b%9c-%ec%a0%84%ec%97%90-%ed%95%9c-%eb%b2%88-%eb%8d%94-%eb%b3%b8-%ea%b6%8c%ed%95%9c-%eb%aa%a8%eb%8d%b8" class="header-anchor"&gt;&lt;/a&gt;출시 전에 한 번 더 본 권한 모델
&lt;/h2&gt;&lt;p&gt;원래 권한 모델은 단순했다. &lt;code&gt;role: &amp;quot;user&amp;quot;&lt;/code&gt; 아니면 &lt;code&gt;role: &amp;quot;manager&amp;quot;&lt;/code&gt;. manager면 신고 처리, 게시글 정지, 사용자 차단, 공지 고정까지 다 할 수 있는 구조. 출시하면 학생 한두 명에게 manager를 주고 학교 운영진은 admin으로 두면 되겠다, 이 정도로 잡고 있었다.&lt;/p&gt;
&lt;p&gt;문제는 출시 후 시나리오를 머릿속으로 돌려보면서 드러났다. 학생 운영자가 신고를 처리하다가 결국 정지 버튼까지 누르게 된다. 정지는 되돌리기 어렵다. 학교 안 커뮤니티에서 친구를 정지시키는 건 사회적 비용이 따라오고, 그 부담을 학생 운영자에게 통째로 떠넘기는 건 옳지 않다는 생각이 들었다.&lt;/p&gt;
&lt;p&gt;그리고 더 근본적인 문제 — &lt;strong&gt;사용자가 전부 미성년자다.&lt;/strong&gt; 신고 처리든 사용자 관리든 manager 권한을 가진 학생이 다른 학생의 실명, 학번, 학년/반/번호, 신고 사유 같은 개인정보에 접근하게 된다. 미성년자가 미성년자의 민감 정보를 들여다보는 구조를 단일 manager 등급으로 뭉뚱그리는 건 PIPA(특히 청소년 개인정보 처리 조항)와도 어긋나고, 학생 운영자 본인에게도 부담이 된다. 실명이 필요한 작업(정지 처분, 권한 변경)은 어른(교사/admin)의 책임으로 명확히 분리해야 했다.&lt;/p&gt;
&lt;p&gt;그렇다고 manager 부여를 막으면 신고가 쌓일 거다. 신고 처리 권한과 정지 권한, 그리고 개인정보 열람 권한을 같은 등급에 묶는 게 잘못이었다. 출시 전에 한 번 갈아엎기로 했다.&lt;/p&gt;
&lt;h2 id="누구에게-무엇을-줄-것인가"&gt;&lt;a href="#%eb%88%84%ea%b5%ac%ec%97%90%ea%b2%8c-%eb%ac%b4%ec%97%87%ec%9d%84-%ec%a4%84-%ea%b2%83%ec%9d%b8%ea%b0%80" class="header-anchor"&gt;&lt;/a&gt;누구에게 무엇을 줄 것인가
&lt;/h2&gt;&lt;p&gt;권한을 4단계로 쪼갠다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;user&lt;/strong&gt; — 일반 사용자&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;moderator&lt;/strong&gt; — 신고 처리, 게시글/댓글 숨김. 정지는 못 함&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;auditor&lt;/strong&gt; — 모든 신고/로그 읽기 전용. 쓰기는 없음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;manager&lt;/strong&gt; — 정지, 공지 고정, 사용자 승인&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;admin&lt;/strong&gt; — 전부 + 다른 사용자의 권한 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;핵심은 두 가지. moderator는 &amp;ldquo;도와주는 학생&amp;rdquo;, auditor는 &amp;ldquo;감사 권한이 필요한 교사&amp;rdquo;. 둘 다 manager가 떠안고 있던 일을 잘게 분리한 결과다.&lt;/p&gt;
&lt;p&gt;여기서 또 하나 중요한 건 &lt;strong&gt;개인정보 노출 범위가 등급별로 다르다&lt;/strong&gt;는 거다. moderator(학생)는 익명 게시글의 신고 처리는 할 수 있지만 작성자의 실명/학번을 보지 못한다. 익명 → 실명 매핑은 admin만 열람할 수 있고, 그 행위 자체가 &lt;code&gt;admin_logs&lt;/code&gt;에 기록된다. auditor(교사)는 감사를 위해 실명까지 볼 수 있지만 쓰기 권한이 없어 신고 자체에 개입할 수 없다. 같은 데이터라도 누가 보느냐에 따라 마스킹 정도를 다르게 가져가는 구조 — 이걸 권한 등급에 박아두면 화면 단에서 실수할 여지가 줄어든다.&lt;/p&gt;
&lt;p&gt;이 모델이 화면에서 어떻게 보일지가 가장 신경 쓰는 부분이다. moderator 계정으로 들어가면 &amp;ldquo;사용자 정지&amp;rdquo; 버튼은 아예 안 보이게 만들 계획이다. 권한 검사로 막는 게 아니라, 시야에서 사라지게. 버튼이 보이면 누르고 싶어지고, 안 보이면 그게 자기 일이 아니라는 신호가 된다 — 가설이지만, 배포 후 학생 운영자들의 사용 패턴을 보고 검증해야 할 부분이다.&lt;/p&gt;
&lt;h2 id="custom-claims로-옮기는-권한-검사"&gt;&lt;a href="#custom-claims%eb%a1%9c-%ec%98%ae%ea%b8%b0%eb%8a%94-%ea%b6%8c%ed%95%9c-%ea%b2%80%ec%82%ac" class="header-anchor"&gt;&lt;/a&gt;custom claims로 옮기는 권한 검사
&lt;/h2&gt;&lt;p&gt;권한 검사는 Firestore 보안 규칙에서 한다. 기존에는 이런 식이었다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;isManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/databases/&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;documents&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;manager&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;문제는 &lt;code&gt;get()&lt;/code&gt;이 Firestore 읽기 1회를 추가로 발생시킨다는 거다. 게시글 1개 조회할 때마다 사용자 문서를 1번 더 읽는다. 비용도 비용이지만, 권한 검사가 데이터 읽기에 비례해서 늘어나는 구조 자체가 마음에 안 든다.&lt;/p&gt;
&lt;p&gt;Firebase Auth의 &lt;strong&gt;custom claims&lt;/strong&gt;로 옮기기로 했다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;getAuth&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;setCustomUserClaims&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;moderator&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;approved&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이렇게 하면 ID 토큰에 role이 박혀서 온다. 보안 규칙은:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;isModerator&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;moderator&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;manager&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;admin&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;DB 조회 0회. 사용자의 권한이 변경된 직후에는 토큰을 갱신해야 반영되지만, 클라이언트에서 &lt;code&gt;getIdTokenResult(true)&lt;/code&gt; 한 번 부르면 끝이다.&lt;/p&gt;
&lt;p&gt;기존 사용자 28명에게 claims를 채우는 1회용 마이그레이션 스크립트도 짰다. &lt;code&gt;users&lt;/code&gt; 컬렉션을 돌면서 각자의 &lt;code&gt;role&lt;/code&gt; 필드 값으로 &lt;code&gt;setCustomUserClaims&lt;/code&gt;를 호출. 단순한 루프지만 한 번 잘못 돌면 권한이 다 깨지니까 &lt;strong&gt;배포 전에 dry-run 모드부터 돌릴 계획이다&lt;/strong&gt;. 그리고 마이그레이션이 끝나면 &lt;code&gt;backfillCustomClaims&lt;/code&gt; HTTP 함수는 즉시 삭제 — 일회성 도구를 운영망에 남겨두면 언젠가 사고가 난다.&lt;/p&gt;
&lt;h2 id="pipa가-따라온다"&gt;&lt;a href="#pipa%ea%b0%80-%eb%94%b0%eb%9d%bc%ec%98%a8%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;PIPA가 따라온다
&lt;/h2&gt;&lt;p&gt;권한을 정리하다 보니 자연스럽게 다음 질문이 나온다. &lt;strong&gt;사용자가 자기 데이터에 대해 가지는 권리는 어디까지인가.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;한국 개인정보 보호법(PIPA)은 사용자에게 세 가지를 보장하라고 한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;처리 결과(정지, 차단 등)에 &lt;strong&gt;이의신청&lt;/strong&gt;할 권리&lt;/li&gt;
&lt;li&gt;자기 데이터를 &lt;strong&gt;다운로드&lt;/strong&gt;할 권리&lt;/li&gt;
&lt;li&gt;어떤 규칙으로 운영되는지 &lt;strong&gt;명시된 커뮤니티 규칙&lt;/strong&gt;을 볼 권리&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;세 컬렉션이 추가된다. &lt;code&gt;appeals&lt;/code&gt;, &lt;code&gt;data_requests&lt;/code&gt;, &lt;code&gt;community_rules&lt;/code&gt;. 각각 클라이언트 화면 1개 + 어드민 처리 페이지 1개씩.&lt;/p&gt;
&lt;p&gt;이 중 가장 신경 쓰는 건 &lt;code&gt;data_requests&lt;/code&gt;다. 사용자가 &amp;ldquo;내 데이터 내놔라&amp;quot;를 누르면 Cloud Function이 그 사용자의 게시글, 댓글, 신고 이력, 채팅 메시지를 모아 JSON으로 묶고, Firebase Storage에 업로드한 뒤 다운로드 링크를 이메일로 보낸다. 시간이 걸리는 작업이라 클라이언트에서 동기적으로 못 한다. &lt;strong&gt;배포 후 첫 요청이 들어왔을 때 실제로 끝까지 동작하는지가 가장 큰 미지수&lt;/strong&gt;다.&lt;/p&gt;
&lt;h2 id="ttl이-데이터-모델의-일부가-되어야-한다"&gt;&lt;a href="#ttl%ec%9d%b4-%eb%8d%b0%ec%9d%b4%ed%84%b0-%eb%aa%a8%eb%8d%b8%ec%9d%98-%ec%9d%bc%eb%b6%80%ea%b0%80-%eb%90%98%ec%96%b4%ec%95%bc-%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;TTL이 데이터 모델의 일부가 되어야 한다
&lt;/h2&gt;&lt;p&gt;PIPA를 들여다보면서 가장 크게 다가온 건 이거였다.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;보관 의무가 끝난 데이터는 자동으로 사라져야 한다.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;화면에서 &amp;ldquo;삭제 버튼&amp;quot;을 만드는 건 일이 아니다. 진짜 일은 보관 기한이 지난 데이터를 &lt;strong&gt;개입 없이 사라지게&lt;/strong&gt; 하는 거다. 사람이 매번 청소하는 시스템은 결국 안 청소된다.&lt;/p&gt;
&lt;p&gt;그래서 거의 모든 새 컬렉션에 &lt;code&gt;expiresAt&lt;/code&gt; 필드를 박았다. Firestore TTL 정책을 그 필드에 걸면, 그 시각이 지난 문서를 Firestore가 알아서 삭제한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;appeals&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// 90일 뒤
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이의신청: 90일. 데이터 요청 기록: 30일. 관리자 로그: 1년. 보관 의무가 끝나면 알아서 사라진다. 사람이 매주 청소할 필요가 없다.&lt;/p&gt;
&lt;p&gt;다만 TTL은 코드로 자동 적용되지 않는다. &lt;strong&gt;Firebase Console에서 컬렉션별로 한 번 묶어줘야 한다.&lt;/strong&gt; 코드 배포 직후 잊지 않고 콘솔에서 &lt;code&gt;admin_logs.expiresAt&lt;/code&gt;, &lt;code&gt;appeals.expiresAt&lt;/code&gt;, &lt;code&gt;data_requests.expiresAt&lt;/code&gt; 세 개에 TTL 정책을 걸어야 한다. 잊으면 데이터가 영원히 남는다 — 가장 무서운 종류의 버그.&lt;/p&gt;
&lt;h2 id="화면-3개가-시작이었다"&gt;&lt;a href="#%ed%99%94%eb%a9%b4-3%ea%b0%9c%ea%b0%80-%ec%8b%9c%ec%9e%91%ec%9d%b4%ec%97%88%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;화면 3개가 시작이었다
&lt;/h2&gt;&lt;p&gt;처음엔 &amp;ldquo;화면 세 개만 추가하면 되겠네&amp;quot;라고 생각했다. 이의신청, 데이터 요청, 커뮤니티 규칙. 끝.&lt;/p&gt;
&lt;p&gt;지금 변경된 파일을 세보니 75개, 7천 줄에 가깝다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Firestore 보안 규칙 168줄&lt;/strong&gt; — 4단계 역할 + 새 컬렉션 3개의 권한 + 작성자 외 접근 차단&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud Functions 690줄&lt;/strong&gt; — 데이터 익스포트, 이의신청 알림, 만료 청소&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;인증 가드 202줄&lt;/strong&gt; (&lt;code&gt;verification_guard.dart&lt;/code&gt; 신규) — 약관/개인정보 동의 안 하면 메인 진입 차단&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;로그인 화면 283줄 재작성&lt;/strong&gt; — 약관/개인정보 동의 단계가 회원가입 플로우에 들어감&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;i18n 키 약 240개&lt;/strong&gt; (한/영) — 새 화면들이 전부 다국어 지원&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;관리자 웹 페이지 4개 신규&lt;/strong&gt; (admin-logs, appeals, community-rules, data-requests)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firestore 보안 규칙 테스트 589줄&lt;/strong&gt; — 새 권한 모델이 의도대로 동작하는지 에뮬레이터로 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;법 조항 몇 줄을 만족시키려고 인증, 권한, 데이터 라이프사이클, UI, i18n, 어드민, 테스트까지 거의 모든 레이어에 손이 간다. PIPA 컴플라이언스는 표면 작업이 아니라 &lt;strong&gt;사용자의 데이터를 어떻게 다루는지에 대한 입장 표명&lt;/strong&gt;이고, 그 입장은 모든 레이어를 통과해야 한다.&lt;/p&gt;
&lt;h2 id="어드민-웹도-같이-정리한다"&gt;&lt;a href="#%ec%96%b4%eb%93%9c%eb%af%bc-%ec%9b%b9%eb%8f%84-%ea%b0%99%ec%9d%b4-%ec%a0%95%eb%a6%ac%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;어드민 웹도 같이 정리한다
&lt;/h2&gt;&lt;p&gt;권한이 4단계로 늘면서 어드민 웹 페이지가 4개 새로 생긴다 (admin-logs, appeals, community-rules, data-requests). 페이지가 늘어나니 공통 레이아웃이 필요해진다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AdminShell&lt;/code&gt;이라는 셸 컴포넌트를 만들었다. 사이드바, 헤더, 다크모드 토글, 권한별 메뉴 표시 — 페이지마다 똑같이 들어가던 코드를 한 곳에 모은다. 페이지 추가 비용이 떨어진다.&lt;/p&gt;
&lt;p&gt;캐시 레이어도 단순하게 하나 짰다 (&lt;code&gt;lib/cache.ts&lt;/code&gt;). TTL 60초짜리 메모리 맵 + in-flight dedup 정도지만, 페이지 전환할 때마다 같은 Firestore 컬렉션을 다시 읽지 않는 것만으로 체감 속도가 확 빨라질 거라 본다.&lt;/p&gt;
&lt;p&gt;대시보드는 별개 작업이지만 같은 흐름에서 바꿨다. 기존엔 클라이언트가 &lt;code&gt;posts&lt;/code&gt;, &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;reports&lt;/code&gt;를 전부 불러서 카운트했는데, Cloud Function 트리거로 &lt;code&gt;app_stats/totals&lt;/code&gt; 문서에 카운터를 증분(&lt;code&gt;FieldValue.increment&lt;/code&gt;)하도록 옮겼다. 대시보드 로딩이 ms 단위로 떨어질 것으로 기대한다 — 실측은 배포 후.&lt;/p&gt;
&lt;h2 id="돌이켜보면-배포-전이지만"&gt;&lt;a href="#%eb%8f%8c%ec%9d%b4%ec%bc%9c%eb%b3%b4%eb%a9%b4-%eb%b0%b0%ed%8f%ac-%ec%a0%84%ec%9d%b4%ec%a7%80%eb%a7%8c" class="header-anchor"&gt;&lt;/a&gt;돌이켜보면 (배포 전이지만)
&lt;/h2&gt;&lt;p&gt;가장 큰 변화는 권한 모델보다 &lt;strong&gt;개인정보를 데이터 모델 안에 박아 넣었다&lt;/strong&gt;는 점이다. 화면에 &amp;ldquo;이의신청&amp;rdquo; 버튼 하나 만드는 건 한 시간이면 끝나지만, 그 데이터가 90일 뒤 자동으로 사라지도록 만드는 건 컬렉션 설계, TTL 정책, Cloud Function 트리거가 다 엮여야 한다.&lt;/p&gt;
&lt;p&gt;PIPA 같은 법적 요구사항을 만난다는 건 화면 추가 작업이 아니라 &lt;strong&gt;데이터 라이프사이클을 처음부터 다시 짜는 일&lt;/strong&gt;이라는 걸 이번에 배웠다. 다음에 비슷한 컴플라이언스를 다른 프로젝트에서 만나도, 화면보다 컬렉션부터 그릴 것 같다.&lt;/p&gt;
&lt;p&gt;manager 한 명에게 모든 걸 맡기던 시스템을 4단계로 쪼개고, 사용자의 권리를 데이터 모델에 새겨 넣었다. 75개 파일, 7천 줄. 이제 배포 버튼만 누르면 된다.&lt;/p&gt;</description></item></channel></rss>