<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>NoSQL on monkshark.dev</title><link>https://monkshark.github.io/tags/nosql/</link><description>Recent content in NoSQL on monkshark.dev</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Sat, 11 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://monkshark.github.io/tags/nosql/index.xml" rel="self" type="application/rss+xml"/><item><title>#7 - Firestore 스키마, 처음부터 다시 설계한다면</title><link>https://monkshark.github.io/p/firestore-schema/</link><pubDate>Sat, 11 Apr 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/firestore-schema/</guid><description>&lt;h2 id="학교-앱에-firestore가-필요한-이유"&gt;&lt;a href="#%ed%95%99%ea%b5%90-%ec%95%b1%ec%97%90-firestore%ea%b0%80-%ed%95%84%ec%9a%94%ed%95%9c-%ec%9d%b4%ec%9c%a0" class="header-anchor"&gt;&lt;/a&gt;학교 앱에 Firestore가 필요한 이유
&lt;/h2&gt;&lt;p&gt;급식과 시간표는 NEIS API로 충분하다. 하지만 학교 앱이 단순 정보 조회를 넘어서려면 — 게시판, 채팅, 사용자 인증 — 자체 데이터베이스가 필요하다.&lt;/p&gt;
&lt;p&gt;Firebase의 Firestore를 선택한 이유는 단순했다. 서버를 직접 운영할 필요가 없고, 실시간 동기화가 기본이고, Flutter와의 통합이 잘 되어 있다. 무엇보다 학생 혼자 운영하는 앱에서 서버 관리까지 할 여유는 없었다.&lt;/p&gt;
&lt;p&gt;현재 앱에는 8개의 Firestore 컬렉션이 있다. 각각의 구조와 설계 과정에서 배운 것들을 정리한다.&lt;/p&gt;
&lt;h2 id="users-사용자-프로필"&gt;&lt;a href="#users-%ec%82%ac%ec%9a%a9%ec%9e%90-%ed%94%84%eb%a1%9c%ed%95%84" class="header-anchor"&gt;&lt;/a&gt;users: 사용자 프로필
&lt;/h2&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;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&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-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;users/{uid}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── uid, name, email
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── studentId, grade, classNum
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── role: &amp;#34;user&amp;#34; | &amp;#34;manager&amp;#34; | &amp;#34;admin&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── userType: &amp;#34;student&amp;#34; | &amp;#34;graduate&amp;#34; | &amp;#34;teacher&amp;#34; | &amp;#34;parent&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── approved: true/false
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── blockedUsers: [uid, uid, ...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── fcmToken: &amp;#34;...&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── profilePhotoUrl, graduationYear, teacherSubject
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── lastProfileUpdate, updatedAt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;│
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── /sync/schedules → 개인 시간표
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── /sync/ddays → D-day 목록
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└── /notifications → 알림 내역
&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;role&lt;/code&gt; 필드가 3단계(&lt;code&gt;user&lt;/code&gt;, &lt;code&gt;manager&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;)인 건 나중에 추가한 것이다. 처음에는 &lt;code&gt;isAdmin: true/false&lt;/code&gt;로 시작했다가, 학생회 임원에게 일부 권한만 주고 싶어서 &lt;code&gt;manager&lt;/code&gt; 역할을 중간에 넣었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;approved&lt;/code&gt; 필드는 가입 승인 시스템이다. 아무나 학교 앱에 글을 쓸 수 없도록, 가입 후 관리자가 승인해야 게시판 접근이 가능하다. 학교 앱이라는 특성상 필수적인 기능이었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;blockedUsers&lt;/code&gt; 배열은 사용자 차단 기능이다. 차단한 사용자의 게시글과 댓글이 보이지 않는다. 이걸 서버 쿼리로 처리하면 Firestore &lt;code&gt;not-in&lt;/code&gt; 쿼리 제한(10개)에 걸리기 때문에, 클라이언트에서 필터링한다.&lt;/p&gt;
&lt;h3 id="서브컬렉션-sync"&gt;&lt;a href="#%ec%84%9c%eb%b8%8c%ec%bb%ac%eb%a0%89%ec%85%98-sync" class="header-anchor"&gt;&lt;/a&gt;서브컬렉션: sync
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;users/{uid}/sync/schedules&lt;/code&gt;와 &lt;code&gt;users/{uid}/sync/ddays&lt;/code&gt;는 개인 데이터의 기기 간 동기화를 위한 구조다. 로컬 SQLite에 저장하되, Firestore에도 백업하여 기기를 바꿔도 데이터가 유지된다.&lt;/p&gt;
&lt;p&gt;처음에는 Firestore만 사용했다. 하지만 시간표를 볼 때마다 네트워크 요청이 발생하는 게 급식 API와 같은 문제였다. 결국 로컬 SQLite + Firestore 동기화 구조로 바꿨다.&lt;/p&gt;
&lt;h2 id="posts-게시판"&gt;&lt;a href="#posts-%ea%b2%8c%ec%8b%9c%ed%8c%90" class="header-anchor"&gt;&lt;/a&gt;posts: 게시판
&lt;/h2&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;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&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-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;posts/{postId}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── title, content, authorUid, authorName
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── category: &amp;#34;자유&amp;#34; | &amp;#34;질문&amp;#34; | &amp;#34;정보공유&amp;#34; | &amp;#34;분실물&amp;#34; | &amp;#34;학생회&amp;#34; | &amp;#34;동아리&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── isAnonymous, isPinned, isResolved
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── likes: {uid: true, uid: true, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── dislikes: {uid: true, uid: true, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── likeCount, dislikeCount ← 비정규화된 카운터
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── commentCount
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── bookmarkedBy: [uid, uid, ...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── searchTokens: [&amp;#34;급식&amp;#34;, &amp;#34;식메&amp;#34;, &amp;#34;메뉴&amp;#34;, ...] ← n-gram
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── pollOptions, pollVoters
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── imageUrls: [...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── createdAt, pinnedAt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;│
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└── /comments/{commentId}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── content, authorUid, authorName
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── isAnonymous, likes, dislikes
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── imageUrl, mentions
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── createdAt
&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;strong&gt;좋아요 구조&lt;/strong&gt;다.&lt;/p&gt;
&lt;h3 id="좋아요-배열-vs-map"&gt;&lt;a href="#%ec%a2%8b%ec%95%84%ec%9a%94-%eb%b0%b0%ec%97%b4-vs-map" class="header-anchor"&gt;&lt;/a&gt;좋아요: 배열 vs Map
&lt;/h3&gt;&lt;p&gt;처음에는 &lt;code&gt;likes: [uid1, uid2, uid3]&lt;/code&gt; 배열이었다. 누가 좋아요를 눌렀는지 확인하려면 &lt;code&gt;arrayContains&lt;/code&gt;로 쿼리하면 된다. 하지만 문제가 있었다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;좋아요/취소가 동시에 발생하면 배열이 꼬일 수 있다&lt;/li&gt;
&lt;li&gt;Firestore의 &lt;code&gt;arrayUnion&lt;/code&gt;/&lt;code&gt;arrayRemove&lt;/code&gt;가 있지만, 트랜잭션 없이는 race condition에 취약하다&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Map 구조(&lt;code&gt;likes: {uid: true}&lt;/code&gt;)로 바꾸면서 해결했다. 특정 uid의 좋아요 여부를 확인하는 것도, 추가/삭제하는 것도 간단하다.&lt;/p&gt;
&lt;h3 id="likecount-비정규화의-필요성"&gt;&lt;a href="#likecount-%eb%b9%84%ec%a0%95%ea%b7%9c%ed%99%94%ec%9d%98-%ed%95%84%ec%9a%94%ec%84%b1" class="header-anchor"&gt;&lt;/a&gt;likeCount: 비정규화의 필요성
&lt;/h3&gt;&lt;p&gt;&amp;ldquo;인기글&amp;rdquo; 정렬이 필요했다. Firestore에서 Map의 크기로 정렬하는 건 불가능하다. &lt;code&gt;likes&lt;/code&gt; Map의 키 개수를 실시간으로 세는 것도 비효율적이다.&lt;/p&gt;
&lt;p&gt;결국 &lt;code&gt;likeCount&lt;/code&gt;, &lt;code&gt;dislikeCount&lt;/code&gt; 필드를 별도로 두고, 좋아요를 누를 때마다 트랜잭션으로 함께 업데이트한다. NoSQL에서는 이런 비정규화가 일상이다. RDB의 &lt;code&gt;COUNT(*)&lt;/code&gt; 대신 미리 계산해두는 것.&lt;/p&gt;
&lt;h3 id="검색-n-gram-토큰"&gt;&lt;a href="#%ea%b2%80%ec%83%89-n-gram-%ed%86%a0%ed%81%b0" class="header-anchor"&gt;&lt;/a&gt;검색: n-gram 토큰
&lt;/h3&gt;&lt;p&gt;Firestore는 전문 검색(full-text search)을 지원하지 않는다. &amp;ldquo;급식 메뉴&amp;quot;를 검색하려면 별도 검색 엔진(Algolia, Typesense 등)이 필요한데, 학생 프로젝트에서 외부 서비스를 붙이기는 부담스러웠다.&lt;/p&gt;
&lt;p&gt;대안으로 n-gram 토큰을 사용했다. 게시글을 저장할 때 제목과 내용에서 2글자 단위로 토큰을 추출하여 &lt;code&gt;searchTokens&lt;/code&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;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#34;급식 메뉴 변경&amp;#34; → [&amp;#34;급식&amp;#34;, &amp;#34;식 &amp;#34;, &amp;#34; 메&amp;#34;, &amp;#34;메뉴&amp;#34;, &amp;#34;뉴 &amp;#34;, &amp;#34; 변&amp;#34;, &amp;#34;변경&amp;#34;]
&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;arrayContainsAny&lt;/code&gt;로 쿼리한다. 완벽한 전문 검색은 아니지만, 학교 게시판 규모에서는 충분히 동작한다.&lt;/p&gt;
&lt;h3 id="익명-게시판"&gt;&lt;a href="#%ec%9d%b5%eb%aa%85-%ea%b2%8c%ec%8b%9c%ed%8c%90" class="header-anchor"&gt;&lt;/a&gt;익명 게시판
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;isAnonymous&lt;/code&gt; 필드와 함께 &lt;code&gt;anonymousMapping&lt;/code&gt;, &lt;code&gt;anonymousCount&lt;/code&gt;가 있다. 같은 게시글에 같은 익명 사용자가 여러 댓글을 달면 &amp;ldquo;익명1&amp;rdquo;, &amp;ldquo;익명1&amp;quot;로 일관되게 표시해야 한다. &lt;code&gt;anonymousMapping&lt;/code&gt;은 &lt;code&gt;{uid: 1, uid: 2}&lt;/code&gt; 형태로 익명 번호를 추적한다.&lt;/p&gt;
&lt;h2 id="chats-11-채팅"&gt;&lt;a href="#chats-11-%ec%b1%84%ed%8c%85" class="header-anchor"&gt;&lt;/a&gt;chats: 1:1 채팅
&lt;/h2&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;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&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-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chats/{chatId} ← chatId = 정렬된 두 uid 조합
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── participants: [uid, uid]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── participantNames: {uid: &amp;#34;이름&amp;#34;, uid: &amp;#34;이름&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── lastMessage, lastMessageAt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── unreadCount: {uid: 3, uid: 0}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;│
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└── /messages/{messageId}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── type: &amp;#34;text&amp;#34; | &amp;#34;system&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── content, imageUrl
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── senderUid, senderName
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── deletedFor: [uid, ...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── createdAt
&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;chatId&lt;/code&gt;를 두 사용자의 uid를 정렬하여 합친 값으로 쓴다. A와 B의 채팅방은 항상 같은 ID를 가지므로, 중복 채팅방이 생기지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;unreadCount&lt;/code&gt;를 Map으로 관리하는 것은 각 사용자가 읽지 않은 메시지 수를 독립적으로 추적하기 위해서다. A가 메시지를 보내면 B의 카운트가 올라가고, B가 채팅방을 열면 B의 카운트가 0으로 초기화된다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;deletedFor&lt;/code&gt; 배열은 &amp;ldquo;나만 삭제&amp;rdquo; 기능이다. 메시지를 실제로 삭제하지 않고, 삭제를 요청한 사용자의 uid를 배열에 추가한다. 클라이언트에서 자신의 uid가 &lt;code&gt;deletedFor&lt;/code&gt;에 있으면 해당 메시지를 표시하지 않는다.&lt;/p&gt;
&lt;h2 id="나머지-컬렉션들"&gt;&lt;a href="#%eb%82%98%eb%a8%b8%ec%a7%80-%ec%bb%ac%eb%a0%89%ec%85%98%eb%93%a4" class="header-anchor"&gt;&lt;/a&gt;나머지 컬렉션들
&lt;/h2&gt;&lt;h3 id="reports--신고"&gt;&lt;a href="#reports--%ec%8b%a0%ea%b3%a0" class="header-anchor"&gt;&lt;/a&gt;reports — 신고
&lt;/h3&gt;&lt;p&gt;게시글 신고 시 &lt;code&gt;postId&lt;/code&gt;, &lt;code&gt;reporterUid&lt;/code&gt;, &lt;code&gt;reason&lt;/code&gt;, &lt;code&gt;detail&lt;/code&gt;을 저장한다. 관리자 화면에서 목록을 보고 조치한다.&lt;/p&gt;
&lt;h3 id="admin_logs--관리-기록"&gt;&lt;a href="#admin_logs--%ea%b4%80%eb%a6%ac-%ea%b8%b0%eb%a1%9d" class="header-anchor"&gt;&lt;/a&gt;admin_logs — 관리 기록
&lt;/h3&gt;&lt;p&gt;사용자 정지, 게시글 삭제 등 관리자 행동을 기록한다. &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;targetUid&lt;/code&gt;, &lt;code&gt;details&lt;/code&gt;, &lt;code&gt;timestamp&lt;/code&gt;. 누가 무엇을 했는지 추적할 수 있어야 관리자가 여러 명이어도 문제를 파악할 수 있다.&lt;/p&gt;
&lt;h3 id="app_feedbacks--council_feedbacks--피드백"&gt;&lt;a href="#app_feedbacks--council_feedbacks--%ed%94%bc%eb%93%9c%eb%b0%b1" class="header-anchor"&gt;&lt;/a&gt;app_feedbacks / council_feedbacks — 피드백
&lt;/h3&gt;&lt;p&gt;사용자가 앱이나 학생회에 보내는 피드백. &lt;code&gt;content&lt;/code&gt;, &lt;code&gt;imageUrls&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;(pending/addressed). 이미지 첨부가 가능하고, 처리 상태를 관리자가 업데이트한다.&lt;/p&gt;
&lt;h3 id="crash_logs--오류-기록"&gt;&lt;a href="#crash_logs--%ec%98%a4%eb%a5%98-%ea%b8%b0%eb%a1%9d" class="header-anchor"&gt;&lt;/a&gt;crash_logs — 오류 기록
&lt;/h3&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;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&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-dart" data-lang="dart"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// main.dart
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;FlutterError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&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="n"&gt;FirebaseFirestore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;crash_logs&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&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="s1"&gt;&amp;#39;error&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptionAsString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;500&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="s1"&gt;&amp;#39;stack&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1000&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="s1"&gt;&amp;#39;uid&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentUser&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&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="s1"&gt;&amp;#39;createdAt&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTimestamp&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;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;Crashlytics 대신 간단하게 만든 오류 수집기. &lt;code&gt;error&lt;/code&gt;와 &lt;code&gt;stack&lt;/code&gt;을 각각 500자, 1000자로 잘라서 저장한다. 문서 크기 폭발을 방지하기 위한 장치다.&lt;/p&gt;
&lt;h3 id="app_config--앱-설정"&gt;&lt;a href="#app_config--%ec%95%b1-%ec%84%a4%ec%a0%95" class="header-anchor"&gt;&lt;/a&gt;app_config — 앱 설정
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;app_config/popup&lt;/code&gt; 문서 하나로 팝업 공지를 관리한다. 앱을 열 때 이 문서를 확인하고, 활성화된 팝업이 있으면 표시한다. 관리자 화면에서 실시간으로 팝업을 켜고 끌 수 있다.&lt;/p&gt;
&lt;h2 id="설계하면서-배운-것"&gt;&lt;a href="#%ec%84%a4%ea%b3%84%ed%95%98%eb%a9%b4%ec%84%9c-%eb%b0%b0%ec%9a%b4-%ea%b2%83" class="header-anchor"&gt;&lt;/a&gt;설계하면서 배운 것
&lt;/h2&gt;&lt;h3 id="1-firestore는-쿼리부터-설계한다"&gt;&lt;a href="#1-firestore%eb%8a%94-%ec%bf%bc%eb%a6%ac%eb%b6%80%ed%84%b0-%ec%84%a4%ea%b3%84%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;1. Firestore는 쿼리부터 설계한다
&lt;/h3&gt;&lt;p&gt;RDB에서는 데이터를 정규화하고, 필요할 때 JOIN한다. Firestore에서는 &lt;strong&gt;어떤 쿼리를 할 것인지&lt;/strong&gt; 먼저 정하고, 그 쿼리에 맞게 데이터를 배치한다. &lt;code&gt;likeCount&lt;/code&gt; 같은 비정규화가 그 예다.&lt;/p&gt;
&lt;h3 id="2-배열의-한계를-알아야-한다"&gt;&lt;a href="#2-%eb%b0%b0%ec%97%b4%ec%9d%98-%ed%95%9c%ea%b3%84%eb%a5%bc-%ec%95%8c%ec%95%84%ec%95%bc-%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;2. 배열의 한계를 알아야 한다
&lt;/h3&gt;&lt;p&gt;Firestore에서 배열은 편리하지만 제약이 많다. &lt;code&gt;arrayContainsAny&lt;/code&gt;는 최대 30개 값만 비교할 수 있고, &lt;code&gt;not-in&lt;/code&gt;은 10개까지다. &lt;code&gt;blockedUsers&lt;/code&gt;를 서버 쿼리로 필터링하지 못하고 클라이언트에서 처리하는 것도 이 제약 때문이다.&lt;/p&gt;
&lt;h3 id="3-문서-크기를-의식해야-한다"&gt;&lt;a href="#3-%eb%ac%b8%ec%84%9c-%ed%81%ac%ea%b8%b0%eb%a5%bc-%ec%9d%98%ec%8b%9d%ed%95%b4%ec%95%bc-%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;3. 문서 크기를 의식해야 한다
&lt;/h3&gt;&lt;p&gt;Firestore 문서 최대 크기는 1MB다. &lt;code&gt;likes&lt;/code&gt; Map에 사용자가 수천 명 좋아요를 누르면 문서가 커진다. 학교 앱 규모에서는 문제가 안 되지만, 설계 단계에서 &amp;ldquo;이 필드가 무한히 커질 수 있는가&amp;quot;를 항상 생각해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;crash_logs&lt;/code&gt;에서 &lt;code&gt;error&lt;/code&gt;와 &lt;code&gt;stack&lt;/code&gt;을 잘라서 저장하는 것도 같은 이유다. 스택 트레이스 전체를 저장하면 문서 하나가 수십 KB가 될 수 있다.&lt;/p&gt;
&lt;h3 id="4-보안-규칙은-스키마의-일부다"&gt;&lt;a href="#4-%eb%b3%b4%ec%95%88-%ea%b7%9c%ec%b9%99%ec%9d%80-%ec%8a%a4%ed%82%a4%eb%a7%88%ec%9d%98-%ec%9d%bc%eb%b6%80%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;4. 보안 규칙은 스키마의 일부다
&lt;/h3&gt;&lt;p&gt;Firestore Security Rules로 &amp;ldquo;자기 게시글만 수정 가능&amp;rdquo;, &amp;ldquo;승인된 사용자만 글 작성 가능&amp;rdquo;, &amp;ldquo;관리자만 사용자 정지 가능&amp;rdquo; 같은 규칙을 강제한다. 스키마를 설계할 때 보안 규칙에서 검증 가능한 구조인지도 함께 고려해야 한다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;8개 컬렉션은 한 번에 설계한 게 아니다. &lt;code&gt;users&lt;/code&gt;와 &lt;code&gt;posts&lt;/code&gt;로 시작해서, 채팅이 필요해지면 &lt;code&gt;chats&lt;/code&gt;를, 신고가 필요해지면 &lt;code&gt;reports&lt;/code&gt;를 추가했다. 기능이 늘어날 때마다 컬렉션이 하나씩 생겼다.&lt;/p&gt;
&lt;p&gt;처음부터 다시 설계한다면 크게 바꿀 것은 없다. 다만 &lt;code&gt;searchTokens&lt;/code&gt;의 n-gram 방식은 게시글이 많아지면 한계가 있으니, Algolia 같은 외부 검색 서비스를 처음부터 고려했을 것이다. 그리고 &lt;code&gt;crash_logs&lt;/code&gt;는 Crashlytics로 대체하는 게 더 나았을 것이다.&lt;/p&gt;
&lt;p&gt;하지만 학생이 혼자 만드는 앱에서 &amp;ldquo;완벽한 설계&amp;quot;를 추구하면 아무것도 못 만든다. 일단 동작하게 만들고, 문제가 생기면 고치는 것. NEIS API가 80줄에서 320줄로 진화한 것처럼, Firestore 스키마도 사용하면서 계속 진화하고 있다.&lt;/p&gt;
&lt;p&gt;아직 배포를 하진 않았지만, 배포 후 사용자가 많아져서 Firebase에 요금이 청구되기 시작하면 Firestore를 걷어내고 직접 백엔드를 구축할 생각도 있다. 내 첫 메인 프로젝트지만 돈이 아깝다.&lt;/p&gt;</description></item></channel></rss>