하이어코딩 RSS 태그 관리 글쓰기 방명록 mahiru
전체 글 (54)
2026-03-16 12:05:03
728x90
반응형

MICEMore 크롤링 서버 개요

MICEMore 앱에 표시되는 MICE 행사 데이터는 어디서 올까요? 바로 크롤링 서버가 전국 8개 행사 사이트에서 자동으로 데이터를 수집하고, 정규화하여 Firebase에 동기화합니다. 이 포스트에서는 Python으로 구축된 MICEMore 크롤링 서버의 전체 아키텍처를 상세히 분석합니다.

기술 스택 한눈에 보기

영역 기술 선택 이유
언어 Python 3.7+ 풍부한 스크래핑 라이브러리 생태계
DB SQLite3 (WAL 모드) 설치 불필요, 파일 기반, 충분한 성능
HTTP 서버 http.server + ThreadingMixIn 외부 프레임워크 없이 경량 서버
스크래핑 requests + BeautifulSoup4 + lxml HTML/JSON 파싱 표준 조합
스케줄링 schedule (6시간 간격) 간단한 주기 작업 실행
클라우드 동기화 Firebase Admin SDK → Firestore Flutter 앱과 데이터 공유
프론트엔드 Vanilla HTML5/CSS3/JS (SPA) 프레임워크 없는 순수 웹 대시보드
실시간 통신 Server-Sent Events (SSE) 서버 → 클라이언트 단방향 스트림
외부 접속 Cloudflare Tunnel / ngrok 로컬 서버를 외부에 안전하게 노출

핵심 설계 철학: 외부 의존성 최소화! requests, beautifulsoup4, lxml, schedule, firebase-admin 단 5개만 사용하고, 나머지는 모두 Python 표준 라이브러리입니다.

크롤러 시스템: 8개 크롤러 상세 분석

크롤러(Crawler)란?

크롤러는 웹사이트를 자동으로 방문해서 데이터를 수집하는 프로그램입니다. 사람이 브라우저로 사이트에 접속해서 정보를 복사하는 것을 프로그램이 대신 해주는 것이죠. 마치 로봇 비서가 여러 신문사를 돌아다니며 기사를 스크랩해오는 것과 같습니다.

8개 크롤러 한눈에 보기

크롤러 소스 방식 파싱 기법
ExcoCrawler EXCO 대구전시컨벤션센터 HTML 스크래핑 CSS 셀렉터 + 상세페이지 보강
WeMiceCrawler 위마이스 JSON REST API API 응답 파싱
HicoCrawler HICO 경주화백컨벤션센터 HTML 스크래핑 data-* 속성 + 그리드 카드
PohangCrawler 포항시 관광 HTML 캘린더 CSS 셀렉터 + 날짜 추출
GyeongjuCrawler 경주시 관광 HTML 리스트 dl/dd 구조 파싱
DaeguFestivalCrawler 대구관광 축제 HTML 스크래핑 .festival_box 카드 파싱
VisitKoreaCrawler 한국관광공사 JSON API (GET/POST) 지역코드 필터링
EventbriteCrawler Eventbrite JSON-LD Schema.org 구조화 데이터

크롤링 방식 3가지 비교

크롤러마다 데이터를 가져오는 방식이 다릅니다. 크게 3가지로 나뉩니다:

1. HTML 스크래핑 (5개 크롤러)

웹 브라우저에 표시되는 HTML 코드를 직접 읽어서 필요한 정보를 추출하는 방식입니다.

import requests
from bs4 import BeautifulSoup

# 1. 웹페이지 HTML 가져오기
response = requests.get("https://exco.co.kr/events")
html = response.text

# 2. BeautifulSoup으로 HTML 파싱 (구조화)
soup = BeautifulSoup(html, "lxml")  # lxml은 빠른 파서

# 3. CSS 셀렉터로 원하는 요소 찾기
event_cards = soup.select(".event-card")  # class="event-card"인 요소들

for card in event_cards:
    title = card.select_one(".title").text.strip()
    date = card.select_one(".date").text.strip()
    venue = card.select_one(".venue").text.strip()
    print(f"행사: {title}, 날짜: {date}, 장소: {venue}")

2. JSON REST API (2개 크롤러)

사이트가 제공하는 API를 호출해서 정형화된 JSON 데이터를 받는 방식입니다. HTML 파싱보다 안정적입니다.

import requests

# API 호출 - 깔끔한 JSON 데이터 수신
response = requests.get(
    "https://api.wemice.com/events",
    params={"region": "daegu", "page": 1}
)
data = response.json()  # JSON을 Python dict로 변환

for event in data["events"]:
    title = event["title"]
    start_date = event["startDate"]
    venue = event["venue"]["name"]

3. JSON-LD Schema.org (1개 크롤러)

JSON-LD는 HTML 페이지 안에 숨겨진 구조화된 데이터입니다. 검색엔진(Google)이 읽을 수 있도록 표준 형식으로 작성됩니다.

# HTML 안에 이런 script 태그가 숨겨져 있음
# <script type="application/ld+json">
# {"@type": "Event", "name": "Tech Conference", ...}
# </script>

soup = BeautifulSoup(html, "lxml")
# JSON-LD 스크립트 태그 찾기
script_tag = soup.find("script", type="application/ld+json")
event_data = json.loads(script_tag.string)  # JSON 파싱

title = event_data["name"]
start_date = event_data["startDate"]
venue = event_data["location"]["name"]
방식 장점 단점 비유
HTML 스크래핑 어떤 사이트든 가능 HTML 구조 변경 시 깨짐 신문 오려서 스크랩
JSON API 안정적, 정형화 API가 있어야만 가능 기자에게 직접 기사 요청
JSON-LD 표준화된 구조 모든 사이트에 있진 않음 기사 뒤 요약본 읽기

데이터베이스: SQLite3 최적화 전략

SQLite란?

SQLite는 파일 하나가 곧 데이터베이스인 경량 DB입니다. MySQL이나 PostgreSQL처럼 별도 서버를 설치할 필요 없이, events.db라는 파일 하나만 있으면 됩니다.

비교 MySQL/PostgreSQL SQLite
설치 서버 설치 필요 설치 불필요 (Python 내장)
실행 별도 프로세스로 실행 앱 내부에서 직접 실행
저장 서버 디렉토리에 저장 단일 파일 (events.db)
적합한 용도 대규모 웹서비스 로컬 앱, 크롤링 서버
비유 대형 도서관 개인 서재

events 테이블 구조 (20개 컬럼)

CREATE TABLE events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,          -- 행사 제목
    source TEXT NOT NULL,         -- 출처 (exco, wemice, hico...)
    source_id TEXT NOT NULL,      -- 원본 사이트의 고유 ID
    category TEXT,                -- 카테고리 (축제, 전시회, 컨퍼런스...)
    region TEXT,                  -- 지역 (대구, 경주, 포항...)
    venue TEXT,                   -- 장소명
    start_date TEXT,              -- 시작일 (YYYY-MM-DD)
    end_date TEXT,                -- 종료일
    description TEXT,             -- 상세 설명
    image_url TEXT,               -- 대표 이미지 URL
    source_url TEXT,              -- 원본 페이지 URL
    organizer TEXT,               -- 주최자
    price TEXT,                   -- 가격 정보
    tags TEXT,                    -- 태그 (JSON 배열)
    latitude REAL,                -- GPS 위도
    longitude REAL,               -- GPS 경도
    status TEXT DEFAULT "active", -- 상태
    created_at TEXT,              -- 생성 시간
    updated_at TEXT,              -- 수정 시간
    UNIQUE(source, source_id)     -- 중복 방지 제약조건
);

UNIQUE(source, source_id) 중복 방지 원리

같은 행사가 여러 번 크롤링되어도 중복 저장되지 않게 하는 복합 유니크 제약조건입니다.

-- source + source_id 조합이 유일해야 함
-- 예시:
-- ("exco", "EVT001")  → 최초 삽입 OK
-- ("exco", "EVT001")  → 중복! INSERT 실패 (무시)
-- ("hico", "EVT001")  → source가 다르므로 OK

-- INSERT OR IGNORE: 중복이면 에러 없이 건너뜀
INSERT OR IGNORE INTO events (source, source_id, title, ...)
VALUES (?, ?, ?, ...);  -- ? = 파라미터 바인딩 (SQL Injection 방지)

SQLite 성능 최적화 4가지

최적화 설정 효과 비유
WAL 모드 PRAGMA journal_mode=WAL 읽기/쓰기 동시 가능 도서관에서 책 빌리는 동안 다른 사람도 열람 가능
캐시 크기 PRAGMA cache_size=8000 (8MB) 자주 쓰는 데이터 메모리에 보관 자주 보는 책을 책상 위에 놓기
동기화 모드 PRAGMA synchronous=NORMAL 쓰기 속도 향상 (약간의 안전성 트레이드오프) 매번 금고 잠그기 vs 가끔 잠그기
스레드별 커넥션 threading.local() 멀티스레드 안전 각 직원에게 개인 열쇠 지급

WAL(Write-Ahead Logging) 모드란?

WAL은 "변경사항을 별도 로그 파일에 먼저 기록"하는 방식입니다. 기본 모드에서는 쓰기 중 읽기가 차단되지만, WAL 모드에서는 읽기와 쓰기가 동시에 가능합니다.

import sqlite3
import threading

# 스레드별 독립 커넥션 관리
_thread_local = threading.local()

def get_connection():
    """현재 스레드 전용 DB 커넥션 반환"""
    if not hasattr(_thread_local, "connection"):
        conn = sqlite3.connect("events.db")
        conn.execute("PRAGMA journal_mode=WAL")      # WAL 모드
        conn.execute("PRAGMA cache_size=8000")        # 8MB 캐시
        conn.execute("PRAGMA synchronous=NORMAL")     # 빠른 동기화
        conn.row_factory = sqlite3.Row                # dict처럼 접근
        _thread_local.connection = conn
    return _thread_local.connection

crawl_logs 테이블: 크롤링 이력 추적

CREATE TABLE crawl_logs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source TEXT NOT NULL,       -- 어떤 크롤러가 실행했는지
    status TEXT NOT NULL,       -- success / error
    total_found INTEGER,        -- 발견한 행사 수
    new_added INTEGER,          -- 새로 추가된 수
    updated INTEGER,            -- 업데이트된 수
    duration_seconds REAL,      -- 소요 시간
    error_message TEXT,         -- 에러 메시지 (있다면)
    created_at TEXT DEFAULT (datetime("now"))
);

API 서버: 프레임워크 없이 구축한 REST API

왜 Flask/Django를 안 쓰나요?

MICEMore 크롤링 서버는 Python 표준 라이브러리의 http.server만으로 API를 구축했습니다. 외부 프레임워크 없이도 충분한 기능을 구현할 수 있고, 의존성을 최소화하여 배포와 유지보수가 간단합니다.

from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn

# ThreadingMixIn: 요청마다 별도 스레드에서 처리
# → 여러 클라이언트가 동시에 접속해도 서로 기다리지 않음
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
    daemon_threads = True  # 메인 종료 시 스레드도 함께 종료

class APIHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # URL 경로에 따라 분기 처리
        if self.path == "/api/events":
            self.handle_events()
        elif self.path == "/api/stats":
            self.handle_stats()
        elif self.path == "/api/stream":
            self.handle_sse_stream()
        elif self.path == "/dashboard":
            self.serve_dashboard()
        else:
            self.send_error(404)

    def do_POST(self):
        if self.path == "/api/crawl":
            self.trigger_crawl()  # 수동 크롤링 실행

# 서버 시작
server = ThreadedHTTPServer(("0.0.0.0", 8080), APIHandler)
server.serve_forever()

API 엔드포인트 상세

엔드포인트 메서드 설명 캐시 TTL
/dashboard GET 실시간 대시보드 UI (HTML) ETag
/api/events GET 이벤트 목록 (region/category 필터) 10초
/api/stats GET 통계 (소스별/지역별) 30초
/api/crawl-logs GET 크롤링 이력 30초
/api/stream GET SSE 실시간 스트림 없음
/api/live GET 최근 50개 이벤트 없음
/api/health GET 서버 상태 확인 60초
/api/crawl POST 수동 크롤링 트리거 없음

성능 최적화 전략 4가지

1. Gzip 압축 (70-80% 대역폭 절감)

import gzip

def send_response(self, data, content_type="application/json"):
    body = json.dumps(data).encode("utf-8")
    
    # 256바이트 이상이면 Gzip 압축 적용
    if len(body) > 256 and "gzip" in self.headers.get("Accept-Encoding", ""):
        body = gzip.compress(body)
        self.send_header("Content-Encoding", "gzip")
    
    self.send_header("Content-Type", content_type)
    self.send_header("Content-Length", str(len(body)))
    self.end_headers()
    self.wfile.write(body)

# 효과: 100KB JSON → 20-30KB로 압축 (70-80% 절감)

2. TTL 캐시 + 이벤트 기반 무효화

TTL(Time To Live) 캐시는 "일정 시간 동안 같은 응답을 재사용"하는 것입니다. 크롤링이 완료되면 캐시를 자동으로 갱신합니다.

import time

class TTLCache:
    def __init__(self):
        self._cache = {}  # {key: (data, expire_time)}
    
    def get(self, key, ttl_seconds):
        """캐시에서 데이터 조회. 만료되었으면 None 반환"""
        if key in self._cache:
            data, expire_time = self._cache[key]
            if time.time() < expire_time:  # 아직 유효
                return data
        return None  # 만료되었거나 없음
    
    def set(self, key, data, ttl_seconds):
        """캐시에 데이터 저장 (TTL 설정)"""
        self._cache[key] = (data, time.time() + ttl_seconds)
    
    def invalidate(self, key=None):
        """크롤링 완료 시 캐시 무효화"""
        if key:
            self._cache.pop(key, None)
        else:
            self._cache.clear()  # 전체 캐시 초기화

# 사용 예시
cache = TTLCache()

# /api/events 요청 처리
def handle_events(self):
    cached = cache.get("events", ttl_seconds=10)  # 10초 캐시
    if cached:
        return self.send_json(cached)  # 캐시 히트!
    
    events = db.query_events()  # DB 조회
    cache.set("events", events, ttl_seconds=10)
    return self.send_json(events)

3. ETag + 304 Not Modified (대시보드 캐싱)

ETag는 "콘텐츠의 지문(fingerprint)"입니다. 브라우저가 이전에 받은 ETag를 보내면, 서버는 콘텐츠가 변경되지 않았을 때 304 (변경 없음)만 응답하여 데이터 전송을 생략합니다.

import hashlib

def serve_dashboard(self):
    html = load_dashboard_html()
    
    # ETag 생성 (HTML 내용의 해시값)
    etag = hashlib.md5(html.encode()).hexdigest()
    
    # 브라우저가 보낸 ETag와 비교
    if self.headers.get("If-None-Match") == etag:
        self.send_response(304)  # 변경 없음! 본문 전송 생략
        self.end_headers()
        return
    
    # 변경되었으면 새 데이터 전송
    self.send_response(200)
    self.send_header("ETag", etag)
    self.send_header("Content-Type", "text/html")
    self.end_headers()
    self.wfile.write(html.encode())

4. Rate Limiting (IP당 120req/60s)

Rate Limiting은 "특정 IP에서 너무 많은 요청이 오면 차단"하는 보안 기능입니다. 슬라이딩 윈도우 방식으로 구현됩니다.

from collections import defaultdict, deque
import time

class RateLimiter:
    def __init__(self, max_requests=120, window_seconds=60):
        self.max_requests = max_requests
        self.window = window_seconds
        self.requests = defaultdict(deque)  # IP별 요청 시간 기록
    
    def is_allowed(self, ip):
        now = time.time()
        timestamps = self.requests[ip]
        
        # 윈도우 밖의 오래된 요청 제거
        while timestamps and timestamps[0] < now - self.window:
            timestamps.popleft()
        
        if len(timestamps) >= self.max_requests:
            return False  # 429 Too Many Requests
        
        timestamps.append(now)
        return True

이벤트 버스: 실시간 통신의 핵심

이벤트 버스(Event Bus)란?

이벤트 버스는 "앱 내부의 방송 시스템"입니다. 크롤러가 "크롤링 완료!"라고 외치면, 대시보드, 캐시 관리자, SSE 스트림 등 관심 있는 모든 컴포넌트가 동시에 이를 듣고 반응합니다. 이것을 Observer/Pub-Sub 패턴이라고 합니다.

비유 이벤트 버스
학교 방송 방송실(Publisher)에서 안내하면, 교실(Subscriber)마다 스피커로 동시에 들음
유튜브 구독 채널(Publisher)에 영상 올리면, 구독자(Subscriber)에게 알림
from collections import deque
import threading
import queue

class EventBus:
    def __init__(self):
        self._subscribers = []           # SSE 클라이언트 목록
        self._buffer = deque(maxlen=200) # 최근 200개 이벤트 보관
        self._lock = threading.Lock()    # 스레드 안전 보장
    
    def publish(self, event_type, data):
        """이벤트 발행 (크롤러가 호출)"""
        event = {"type": event_type, "data": data, "time": time.time()}
        
        with self._lock:
            self._buffer.append(event)  # 버퍼에 보관
            # 모든 구독자에게 전달
            for subscriber_queue in self._subscribers:
                try:
                    subscriber_queue.put_nowait(event)
                except queue.Full:
                    pass  # 큐가 가득 차면 건너뜀
    
    def subscribe(self):
        """새 SSE 클라이언트 구독"""
        q = queue.Queue(maxsize=50)
        with self._lock:
            self._subscribers.append(q)
        return q
    
    def unsubscribe(self, q):
        """SSE 클라이언트 구독 해제"""
        with self._lock:
            self._subscribers.remove(q)

# 사용 예시
event_bus = EventBus()

# 크롤러에서 이벤트 발행
event_bus.publish("crawl_done", {
    "source": "exco",
    "new_events": 5,
    "duration": 3.2
})

이벤트 타입 5가지

이벤트 발생 시점 데이터 내용
crawl_start 크롤링 시작 크롤러 이름, 시작 시간
crawl_done 크롤링 완료 새 행사 수, 업데이트 수, 소요시간
crawl_error 크롤링 실패 에러 메시지, 크롤러 이름
event_new 새 행사 발견 행사 제목, 장소, 날짜
event_updated 행사 정보 변경 변경된 필드, 행사 ID

Server-Sent Events (SSE): 실시간 스트림

SSE란?

SSE는 서버에서 클라이언트로 단방향으로 데이터를 계속 보내는 기술입니다. WebSocket과 달리 HTTP만으로 동작하고, 브라우저가 자동 재연결을 지원합니다.

비교 일반 HTTP SSE WebSocket
방향 요청-응답 (1회) 서버 → 클라이언트 (단방향) 양방향
연결 매번 새로 연결 한번 연결 후 유지 한번 연결 후 유지
자동 재연결 없음 브라우저 내장 직접 구현 필요
프로토콜 HTTP HTTP WS (별도 프로토콜)
적합한 용도 일반 API 실시간 알림, 대시보드 채팅, 게임
# 서버 측: SSE 스트림 전송
def handle_sse_stream(self):
    self.send_response(200)
    self.send_header("Content-Type", "text/event-stream")  # SSE 타입
    self.send_header("Cache-Control", "no-cache")          # 캐시 금지
    self.send_header("Connection", "keep-alive")           # 연결 유지
    self.end_headers()
    
    # 이벤트 버스 구독
    client_queue = event_bus.subscribe()
    
    try:
        while True:
            # 이벤트가 올 때까지 대기 (최대 30초)
            try:
                event = client_queue.get(timeout=30)
                # SSE 형식으로 전송
                msg = f"event: {event['type']}\n"
                msg += f"data: {json.dumps(event['data'])}\n\n"
                self.wfile.write(msg.encode())
                self.wfile.flush()
            except queue.Empty:
                # 30초 동안 이벤트 없으면 하트비트 전송
                self.wfile.write(b": heartbeat\n\n")
                self.wfile.flush()
    except (BrokenPipeError, ConnectionResetError):
        pass  # 클라이언트 연결 종료
    finally:
        event_bus.unsubscribe(client_queue)  # 구독 해제

# 클라이언트 측: JavaScript에서 SSE 수신
# const eventSource = new EventSource("/api/stream");
# eventSource.addEventListener("crawl_done", (e) => {
#     const data = JSON.parse(e.data);
#     updateDashboard(data);
# });

데이터 정규화: 8개 소스를 하나의 형식으로

정규화(Normalization)란?

8개의 서로 다른 사이트에서 가져온 데이터는 형식이 제각각입니다. 정규화란 이 다양한 형식을 하나의 통일된 형식으로 변환하는 과정입니다.

마치 한국, 미국, 일본에서 온 편지를 모두 한국어로 번역하는 것과 같습니다.

카테고리 정규화 (7종 표준화)

# 각 사이트마다 카테고리 표현이 다름
# EXCO: "전시/박람회", 위마이스: "Exhibition", 경주: "전시"
# → 모두 "전시회"로 통일!

CATEGORY_MAP = {
    # 원본 → 표준화
    "전시": "전시회",
    "전시/박람회": "전시회",
    "Exhibition": "전시회",
    "expo": "전시회",
    "축제/페스티벌": "축제",
    "Festival": "축제",
    "학술대회": "컨퍼런스",
    "Conference": "컨퍼런스",
    "교육": "세미나",
    "Seminar": "세미나",
    "Workshop": "워크숍",
    "모임": "네트워킹",
}

# 표준 카테고리: 축제, 전시회, 컨퍼런스, 세미나, 워크숍, 네트워킹, 기타
def normalize_category(raw_category):
    return CATEGORY_MAP.get(raw_category, "기타")

지역 정규화 (8종)

표준 지역 매핑되는 원본 값들
대구 "대구", "대구광역시", "Daegu", "대구시"
포항 "포항", "포항시", "Pohang"
경주 "경주", "경주시", "Gyeongju"
경산 "경산", "경산시"
구미 "구미", "구미시"
안동 "안동", "안동시"
김천 "김천", "김천시"
경북 "경북", "경상북도" (위 지역 외 경북 전체)

날짜 정규화 (6+ 포맷 지원)

사이트마다 날짜 형식이 전부 다릅니다. 이걸 모두 YYYY-MM-DD 형식으로 통일합니다.

from datetime import datetime

def normalize_date(raw_date):
    """다양한 날짜 형식을 YYYY-MM-DD로 변환"""
    formats = [
        "%Y-%m-%d",        # 2025-03-16
        "%Y%m%d",          # 20250316
        "%Y.%m.%d",        # 2025.03.16
        "%Y/%m/%d",        # 2025/03/16
        "%m.%d.(%a)",      # 3.16.(월) - 요일 포함
        "%Y년 %m월 %d일",  # 2025년 03월 16일
    ]
    
    for fmt in formats:
        try:
            dt = datetime.strptime(raw_date.strip(), fmt)
            return dt.strftime("%Y-%m-%d")  # 표준 형식으로 반환
        except ValueError:
            continue
    
    return None  # 파싱 실패

# 예시:
# normalize_date("20250316")      → "2025-03-16"
# normalize_date("2025.03.16")    → "2025-03-16"
# normalize_date("2025년 03월 16일") → "2025-03-16"

유효성 검증: 필수 필드 6개

REQUIRED_FIELDS = ["title", "source_id", "venue", "start_date", "category", "region"]

def validate_event(event):
    """이벤트 데이터 유효성 검증"""
    for field in REQUIRED_FIELDS:
        if not event.get(field):
            raise ValueError(f"필수 필드 누락: {field}")
    
    # 날짜 형식 검증
    try:
        datetime.strptime(event["start_date"], "%Y-%m-%d")
    except ValueError:
        raise ValueError(f"잘못된 날짜 형식: {event['start_date']}")
    
    # 카테고리 검증
    valid_categories = ["축제", "전시회", "컨퍼런스", "세미나", "워크숍", "네트워킹", "기타"]
    if event["category"] not in valid_categories:
        event["category"] = "기타"  # 잘못된 값은 "기타"로
    
    return True

Firebase 동기화: 크롤링 데이터를 Flutter 앱으로

동기화 흐름

크롤링 서버 (Python)
       |
  [SQLite DB]
       |
  Firebase Admin SDK
       |
  [Firestore]
       |
  Flutter 앱 (MICEMore)
       |
  사용자 화면에 행사 표시
import firebase_admin
from firebase_admin import credentials, firestore

# Firebase 초기화 (서비스 계정 키 사용)
cred = credentials.Certificate("firebase-key.json")
firebase_admin.initialize_app(cred)
db = firestore.client()

def sync_to_firebase(events):
    """크롤링된 이벤트를 Firestore에 동기화"""
    batch = db.batch()  # Batch Write로 효율적 전송
    count = 0
    
    for event in events:
        # source + source_id로 고유 문서 ID 생성
        doc_id = f"{event['source']}_{event['source_id']}"
        doc_ref = db.collection("crawled_events").document(doc_id)
        
        batch.set(doc_ref, {
            "title": event["title"],
            "category": event["category"],
            "region": event["region"],
            "venue": event["venue"],
            "startDate": event["start_date"],
            "endDate": event["end_date"],
            "description": event["description"],
            "imageUrl": event["image_url"],
            "sourceUrl": event["source_url"],
            "source": event["source"],
            "updatedAt": firestore.SERVER_TIMESTAMP,
        }, merge=True)  # merge=True: 기존 데이터와 병합
        
        count += 1
        if count % 450 == 0:  # Batch 최대 500개 제한
            batch.commit()
            batch = db.batch()
    
    batch.commit()  # 나머지 전송
    print(f"Firebase 동기화 완료: {count}건")

보안 전략

SQL Injection 방지: 파라미터 바인딩

SQL Injection은 악의적인 사용자가 URL 파라미터에 SQL 코드를 삽입해서 데이터베이스를 조작하는 공격입니다.

# 위험한 코드 (SQL Injection 취약)
region = request.params["region"]  # 사용자 입력
query = f"SELECT * FROM events WHERE region = '{region}'"  # 직접 삽입!
# 만약 region = "'; DROP TABLE events; --" 이면?
# → SELECT * FROM events WHERE region = ''; DROP TABLE events; --'
# → events 테이블 삭제!

# 안전한 코드 (파라미터 바인딩)
query = "SELECT * FROM events WHERE region = ?"
cursor.execute(query, (region,))  # ? 자리에 안전하게 삽입
# SQL 코드가 아닌 순수 문자열로 처리됨 → 공격 불가

보안 체크리스트

보안 항목 구현 방법 효과
SQL Injection 파라미터 바인딩 (?) DB 조작 공격 차단
Rate Limiting IP당 120req/60s DDoS/브루트포스 방지
CORS 헤더 Access-Control-Allow-Origin 설정 허용된 도메인만 API 호출
Firebase 키 보안 .gitignore에 추가 키 유출 방지

CLI 실행 모드: 7가지 명령어

MICEMore 크롤링 서버는 CLI(Command Line Interface)로 다양한 모드를 지원합니다.

import sys

def main():
    if len(sys.argv) < 2:
        command = "serve"  # 기본 모드
    else:
        command = sys.argv[1]
    
    if command == "serve":
        start_api_server()      # API 서버 시작
        start_auto_crawling()   # 6시간마다 자동 크롤링
    elif command == "crawl":
        run_all_crawlers()      # 1회 크롤링 후 종료
    elif command == "api":
        start_api_server()      # API 서버만 (크롤링 없음)
    elif command == "tunnel":
        start_api_server()
        start_cloudflare_tunnel() # 외부 접속 터널
    elif command == "stats":
        print_statistics()      # 통계 출력
    elif command == "export":
        export_to_json()        # JSON 파일 내보내기
    elif command == "init":
        initialize_database()   # DB 초기화

if __name__ == "__main__":
    main()
명령어 용도 사용 시나리오
python main.py serve API + 자동 크롤링 운영 서버 (기본 모드)
python main.py crawl 1회 크롤링 수동 데이터 수집
python main.py api API 서버만 크롤링 없이 데이터 조회
python main.py tunnel API + Cloudflare 터널 외부에서 접속 시
python main.py stats 통계 출력 현재 데이터 현황 확인
python main.py export JSON 내보내기 데이터 백업/분석
python main.py init DB 초기화 최초 설치 시

외부 의존성: 단 5개

# requirements.txt
requests>=2.28.0          # HTTP 요청 (웹페이지 다운로드)
beautifulsoup4>=4.11.0    # HTML 파싱 (데이터 추출)
lxml>=4.9.0               # 빠른 XML/HTML 파서
schedule>=1.1.0           # 주기적 작업 스케줄링
firebase-admin>=6.0.0     # Firebase Firestore 동기화

# 나머지는 모두 Python 표준 라이브러리:
# http.server, sqlite3, json, threading,
# collections, hashlib, gzip, time, sys 등

전체 아키텍처 요약

[8개 웹사이트] ──스크래핑──> [크롤러들]
                                |
                           [데이터 정규화]
                                |
                    +-----------+-----------+
                    |                       |
               [SQLite DB]           [이벤트 버스]
                    |                       |
              [API 서버]              [SSE 스트림]
                    |                       |
              [REST API]            [실시간 대시보드]
                    |
            [Firebase 동기화]
                    |
            [Firestore DB]
                    |
            [Flutter 앱 (MICEMore)]

핵심 정리

개념 핵심 포인트
크롤러 8개 사이트에서 HTML/JSON/JSON-LD 3가지 방식으로 데이터 수집
SQLite WAL 모드 + 8MB 캐시 + 스레드별 커넥션으로 최적화
API 서버 프레임워크 없이 http.server + ThreadingMixIn으로 구축
캐시 TTL 캐시 + ETag + Gzip 압축으로 성능 극대화
이벤트 버스 Thread-safe Pub/Sub 패턴으로 실시간 이벤트 전파
SSE 서버 → 클라이언트 단방향 실시간 스트림
데이터 정규화 카테고리 7종, 지역 8종, 날짜 6+ 포맷 표준화
Firebase 동기화 Batch Write + merge로 Firestore에 효율적 업로드
보안 파라미터 바인딩, Rate Limiting, CORS, 키 관리
의존성 외부 패키지 단 5개, 나머지 표준 라이브러리만 사용
728x90
반응형
2026-03-16 10:27:27
728x90
반응형

성능 최적화 개요

99개 화면, 39개 Provider, 44개 Service를 가진 대규모 Flutter 앱에서 성능은 사용자 경험의 핵심입니다. MICEMore에서 적용한 7가지 성능 최적화 전략을 소개합니다.

전략 적용 영역 효과
Future.wait 병렬 초기화 앱 시작 로딩 시간 40~60% 단축
비블로킹 폰트 프리로드 웹 렌더링 스플래시 500~1500ms 단축
플랫폼별 Firestore 캐시 데이터 접근 오프라인 지원 + 멀티탭 안정성
Stream 구독 관리 실시간 데이터 메모리 누수 방지
Batch Write 대량 데이터 처리 네트워크 요청 최소화
Fire-and-Forget 패턴 부차적 작업 응답 속도 향상
네트워크 상태 모니터링 전체 앱 오프라인 대응

1. Future.wait 병렬 초기화

앱 시작 시 독립적인 초기화 작업을 병렬로 동시 실행합니다.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
  usePathUrlStrategy();
  _setupErrorHandlers();

  // 4개 독립 작업 병렬 실행
  await Future.wait([
    SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
    initializeDateFormatting('ko', null),
    _initializeFirebase(),
    _initializeKakaoSdk(),
  ]);

  runApp(MultiProvider(providers: [...], child: MyApp()));
}

순차 vs 병렬 성능 비교

작업 소요 시간 순차 실행 병렬 실행
화면 방향 고정 ~50ms 0~50ms max(50, 200, 800, 300)
= ~800ms
날짜 포맷 초기화 ~200ms 50~250ms
Firebase 초기화 ~800ms 250~1050ms
카카오 SDK 초기화 ~300ms 1050~1350ms
합계 ~1350ms ~800ms (41% 단축)

2. 비블로킹 폰트 프리로드

웹 환경에서 한글 폰트 로드를 await 없이 트리거합니다.

if (kIsWeb) {
  // 비블로킹: 폰트 로드 시작만 하고 완료를 기다리지 않음
  GoogleFonts.notoSansKr(fontWeight: FontWeight.w400);
  GoogleFonts.notoSansKr(fontWeight: FontWeight.w500);
  GoogleFonts.notoSansKr(fontWeight: FontWeight.w600);
  GoogleFonts.notoSansKr(fontWeight: FontWeight.w700);
  // await GoogleFonts.pendingFonts() 제거
  // → 스플래시 중 500~1500ms 블로킹 방지
}

CanvasKit 렌더러가 시스템 폰트로 폴백하므로, 폰트 로드 전에도 텍스트가 정상 표시됩니다. 백그라운드에서 폰트가 준비되면 자동으로 교체됩니다.

3. 플랫폼별 Firestore 캐시 전략

웹과 모바일에서 서로 다른 캐시 전략을 적용합니다.

if (kIsWeb) {
  // 웹: 영속성 비활성화 (멀티탭 충돌 방지)
  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: false,
  );
} else {
  // 모바일: 무제한 캐시 (오프라인 지원)
  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: true,
    cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
  );
}

플랫폼별 캐시 전략 이유

플랫폼 영속성 캐시 크기 이유
비활성화 - 멀티탭에서 IndexedDB 동시 접근 시 충돌 방지
모바일 활성화 무제한 오프라인 지원, 네트워크 절약, 빠른 데이터 접근

4. Stream 구독 관리: 메모리 누수 방지

39개 Provider가 Firestore Stream을 구독하므로, 체계적인 구독 관리가 필수입니다.

방어적 구독 패턴

class GamificationProvider with ChangeNotifier {
  StreamSubscription<List<LeaderboardEntryModel>>? _leaderboardSubscription;
  StreamSubscription<LeaderboardEntryModel?>? _myRankSubscription;
  StreamSubscription<List<PointRecordModel>>? _myPointHistorySubscription;

  void loadLeaderboard(String eventId, {int? limit}) {
    // 핵심: 새 구독 전 기존 구독 해제
    _leaderboardSubscription?.cancel();
    _leaderboardSubscription =
        _service.getLeaderboard(eventId, limit: limit).listen(
      (data) {
        _leaderboard = data;
        notifyListeners();
      },
      onError: (e) {
        _error = e.toString();
        notifyListeners();
      },
    );
  }

  @override
  void dispose() {
    // 모든 구독 해제
    _leaderboardSubscription?.cancel();
    _myRankSubscription?.cancel();
    _myPointHistorySubscription?.cancel();
    super.dispose();
  }
}

구독 관리 3원칙

  • Cancel-Before-Subscribe: 새 구독 시작 전 기존 구독 해제로 중복 구독 방지
  • Nullable Subscription: StreamSubscription?로 선언하여 null-safe cancel 호출
  • Dispose Cleanup: Provider dispose()에서 모든 구독 일괄 해제

5. Batch Write: 대량 데이터 처리

Firestore에 여러 문서를 한 번에 처리할 때 Batch Write로 네트워크 요청을 최소화합니다.

리더보드 순위 일괄 업데이트

Future<void> _updateRanks(String eventId) async {
  final snapshot = await _firestore
      .collection('events').doc(eventId)
      .collection('leaderboard')
      .orderBy('totalPoints', descending: true)
      .get();

  final batch = _firestore.batch();
  int rank = 1;
  for (var doc in snapshot.docs) {
    batch.update(doc.reference, {'rank': rank});
    rank++;
  }
  await batch.commit(); // 단일 네트워크 요청으로 모든 순위 업데이트
}

알림 전체 읽음 처리

Future<void> markAllAsRead() async {
  final batch = _firestore.batch();
  final unreadNotifications = _notifications.where((n) => !n.isRead);

  for (final notification in unreadNotifications) {
    batch.update(
      _firestore.collection('notifications').doc(notification.id),
      {'isRead': true},
    );
  }
  await batch.commit();

  // Optimistic Update: Firestore 응답 전 로컬 상태 즉시 반영
  _notifications = _notifications.map((n) => n.copyWith(isRead: true)).toList();
  notifyListeners();
}

Batch 500 제한 대응: 계단식 커밋

// 계정 삭제 시 대량 문서 삭제
int operationCount = 0;
for (var doc in participations.docs) {
  batch.delete(doc.reference);
  operationCount++;
  if (operationCount >= 500) {
    await batch.commit();  // 500개마다 커밋
    operationCount = 0;    // 새 batch 시작
  }
}

Firestore Batch는 한 번에 최대 500개 연산만 허용합니다. 대량 삭제 시 500개 단위로 커밋하는 계단식 패턴을 사용합니다.

6. Fire-and-Forget 패턴: 응답 속도 우선

핵심 작업 완료 후 부차적인 작업을 await 없이 비동기로 실행하여 응답 속도를 우선합니다.

포인트 부여 후 비동기 처리

Future<void> awardPoints({...}) async {
  // 핵심: Transaction으로 포인트 부여 + 리더보드 업데이트
  await _firestore.runTransaction((transaction) async {
    // ... 포인트 기록 + 리더보드 업데이트
  });

  // Fire-and-Forget: await 없이 비동기 실행
  _updateRanks(eventId);           // 순위 재계산
  _checkBadgesAfterPoints(...)     // 배지 조건 체크
  // → 사용자는 포인트 부여 즉시 응답을 받음
  // → 순위와 배지는 백그라운드에서 처리
}

배지 체크 실패 격리

Future<void> _checkBadgesAfterPoints({...}) async {
  try {
    // 소스별 선별적 배지 체크 (불필요한 체크 방지)
    if (source.startsWith('quiz_correct')) {
      await _badgeService.checkQuizMasterBadge(...);
    }
    // 공통 배지 병렬 체크
    await Future.wait([
      _badgeService.checkSocialButterflyBadge(...),
      _badgeService.checkTopScorerBadge(...),
    ]);
  } catch (e) {
    // 배지 체크 실패해도 포인트 부여는 이미 성공
    print('배지 체크 중 오류 발생 (무시): $e');
  }
}

try-catch로 배지 체크를 격리하여 실패가 포인트 부여에 영향을 주지 않습니다. 또한 source prefix 기반으로 관련 배지만 선별 체크하여 불필요한 Firestore 쿼리를 방지합니다.

7. 네트워크 상태 모니터링

ConnectivityService가 실시간 네트워크 상태를 추적하여 오프라인 시 적절한 UI 피드백을 제공합니다.

class ConnectivityService extends ChangeNotifier {
  final Connectivity _connectivity = Connectivity();
  StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;

  bool _isOnline = true;
  bool _hasWifi = false;
  bool _hasMobile = false;

  ConnectivityService() {
    _init();
  }

  Future<void> _init() async {
    await _updateConnectionStatus();
    _connectivitySubscription = 
        _connectivity.onConnectivityChanged.listen(_onConnectivityChanged);
  }

  void _onConnectivityChanged(List<ConnectivityResult> results) {
    _hasWifi = results.contains(ConnectivityResult.wifi);
    _hasMobile = results.contains(ConnectivityResult.mobile);
    _isOnline = _hasWifi || _hasMobile || 
                results.contains(ConnectivityResult.ethernet);
    notifyListeners();
  }
}

ChangeNotifier를 상속하여 Provider로 등록되므로, UI 어디서든 Consumer나 context.watch로 네트워크 상태를 감시할 수 있습니다. 모바일 환경에서 Firestore 무제한 캐시와 결합하면 오프라인에서도 캐시된 데이터로 앱을 사용할 수 있습니다.

추가 최적화: 웹 스크롤 동작 커스터마이징

/// Flutter 웹에서 마우스 스크롤 및 트랙패드 드래그 활성화
class AppScrollBehavior extends MaterialScrollBehavior {
  @override
  Set<PointerDeviceKind> get dragDevices => {
    PointerDeviceKind.touch,
    PointerDeviceKind.mouse,
    PointerDeviceKind.trackpad,
    PointerDeviceKind.stylus,
  };
}

Flutter 웹은 기본적으로 마우스 드래그 스크롤을 지원하지 않습니다. AppScrollBehavior를 MaterialApp의 scrollBehavior에 설정하여 데스크톱 브라우저에서도 자연스러운 스크롤 경험을 제공합니다.

에러 핸들링: 전역 에러 캐처

void _setupErrorHandlers() {
  // Flutter 프레임워크 에러 (위젯 빌드 에러 등)
  FlutterError.onError = (details) {
    if (kDebugMode) {
      FlutterError.dumpErrorToConsole(details);
    } else if (!kIsWeb) {
      FirebaseCrashlytics.instance.recordFlutterFatalError(details);
    }
  };

  // 비동기 에러 (Zone 밖의 에러)
  PlatformDispatcher.instance.onError = (error, stack) {
    if (!kDebugMode && !kIsWeb) {
      FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    }
    return true; // 에러 처리 완료
  };
}

2단계 에러 핸들링으로 모든 종류의 에러를 포착합니다. 디버그 모드에서는 콘솔 출력, 프로덕션에서는 Crashlytics로 전송하여 무음 크래시를 방지합니다. 웹은 Crashlytics를 지원하지 않으므로 kIsWeb 체크로 분기합니다.

성능 최적화 체크리스트

항목 적용 여부 적용 위치
병렬 초기화 O main.dart Future.wait
비블로킹 리소스 로드 O GoogleFonts 프리로드
플랫폼별 캐시 전략 O Firestore Settings
Stream 구독 관리 O 39개 Provider dispose()
Batch Write O 리더보드, 알림, 계정삭제
Transaction 원자성 O 포인트, 토큰, 배지
Fire-and-Forget O 순위 재계산, 배지 체크
에러 격리 O try-catch 부차적 작업
네트워크 모니터링 O ConnectivityService
전역 에러 핸들링 O FlutterError + PlatformDispatcher

마치며

MICEMore는 대규모 Flutter 프로젝트에서 실전적인 성능 최적화를 적용했습니다. Future.wait 병렬 초기화로 앱 시작 속도를 40% 이상 단축하고, 체계적인 Stream 구독 관리로 메모리 누수를 방지하며, Batch Write와 Transaction으로 Firestore 연산을 최적화했습니다. 이 시리즈를 통해 99개 화면, 42개 모델, 39개 Provider, 44개 서비스로 구성된 MICEMore 프로젝트의 전체 아키텍처와 핵심 기술을 분석했습니다.


초보자를 위한 상세 가이드: 성능 최적화 전략 완전 정복

성능 최적화란? - 왜 중요한가

앱 성능 최적화는 "앱이 빠르고 부드럽게 동작하도록 만드는 것"입니다. 사용자가 앱을 열었을 때 3초 이상 걸리면 53%의 사용자가 이탈합니다. MICEMore는 28개의 Provider와 Firebase를 사용하는 대규모 앱이므로, 최적화가 특히 중요합니다.

최적화 영역 하지 않으면? 최적화하면? 비유
병렬 초기화 앱 시작 5초+ 앱 시작 1~2초 한 명이 순서대로 vs 여러 명이 동시에
Firestore 캐시 매번 서버 요청 로컬에서 즉시 로드 매번 마트 vs 냉장고에서 꺼내기
Stream 관리 메모리 누수, 앱 느려짐 깔끔한 메모리 관리 수도꼭지 안 잠그기 vs 쓰고 잠그기
Batch Write 100번 네트워크 요청 1번 네트워크 요청 편지 100번 보내기 vs 택배 1상자

1. 병렬 초기화 (Future.wait) 상세 분석

순차 초기화 vs 병렬 초기화

앱이 시작될 때 여러 가지를 준비해야 합니다. 이걸 하나씩 순서대로 하면 느리고, 동시에 하면 빠릅니다.

// 나쁜 예: 순차 초기화 (하나 끝나면 다음 하나)
await SystemChrome.setPreferredOrientations(...);  // 100ms
await initializeDateFormatting('ko', null);        // 200ms
await Firebase.initializeApp(...);                  // 800ms
await _initializeKakaoSdk();                        // 300ms
// 총 시간: 100 + 200 + 800 + 300 = 1400ms (1.4초)

// 좋은 예: 병렬 초기화 (모두 동시에 실행!)
await Future.wait([
  SystemChrome.setPreferredOrientations(...),  // 100ms ┐
  initializeDateFormatting('ko', null),        // 200ms ├ 동시 실행
  _initializeFirebase(),                       // 800ms ├ 
  _initializeKakaoSdk(),                       // 300ms ┘
]);
// 총 시간: max(100, 200, 800, 300) = 800ms (0.8초!)
// 약 43% 시간 단축!

Future.wait의 원리:

비유 순차 실행 병렬 실행 (Future.wait)
요리 밥 짓고 → 국 끓이고 → 반찬 만들기 밥+국+반찬 동시에!
택배 A배달 → B배달 → C배달 택배기사 3명이 동시에!
총 시간 모든 시간의 합계 가장 오래 걸리는 것만큼

병렬 초기화 시 주의할 점

// 주의: 의존성이 있는 작업은 병렬로 실행하면 안 됩니다!

// 잘못된 예: Firebase가 초기화되기 전에 Firestore 사용
await Future.wait([
  Firebase.initializeApp(),    // Firebase 초기화
  loadUserData(),              // Firebase 필요! (에러 발생)
]);

// 올바른 예: 의존성 있는 작업은 순서대로
await Future.wait([
  Firebase.initializeApp(),    // 독립적인 작업들만
  initializeDateFormatting(),  // 병렬로 실행
]);
// Firebase 초기화 완료 후에
await loadUserData();  // 의존적인 작업 실행

2. Firestore 캐시 전략 상세 분석

캐시(Cache)란?

캐시는 "자주 쓰는 데이터를 가까운 곳에 미리 저장해두는 것"입니다. 마트에서 매번 장보러 가는 대신, 냉장고에 재료를 넣어두는 것과 같습니다.

// 모바일: 캐시 활성화 (오프라인에서도 동작!)
if (!kIsWeb) {
  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: true,              // 캐시 켜기
    cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,  // 용량 제한 없음
  );
}

// 웹: 캐시 비활성화 (멀티탭 충돌 방지)
if (kIsWeb) {
  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: false,  // 캐시 끄기
  );
}

왜 웹에서는 캐시를 끄나요?

웹 브라우저에서는 여러 탭을 동시에 열 수 있습니다. 각 탭이 같은 캐시를 사용하면 충돌이 발생합니다. 마치 두 사람이 동시에 같은 냉장고 문을 열면 부딪히는 것과 같습니다.

플랫폼 캐시 설정 이유 효과
iOS/Android 활성화 (무제한) 오프라인 지원, 빠른 로딩 서버 요청 50~70% 감소
Web 비활성화 멀티탭 충돌 방지 데이터 일관성 보장

3. MultiProvider와 28개 Provider 관리

MultiProvider란?

MultiProvider는 여러 Provider를 한 곳에서 등록하는 방법입니다. Provider는 앱의 상태(데이터)를 관리하는 매니저입니다.

// MultiProvider: 28개의 매니저를 한꺼번에 등록
runApp(
  MultiProvider(
    providers: [
      // 인프라 서비스
      ChangeNotifierProvider(create: (_) => ConnectivityService()),
      ChangeNotifierProvider(create: (_) => AuthProvider()),
      
      // 핵심 기능
      ChangeNotifierProvider(create: (_) => EventProvider()),
      ChangeNotifierProvider(create: (_) => NotificationProvider()),
      ChangeNotifierProvider(create: (_) => FCMProvider()),
      
      // 게이미피케이션
      ChangeNotifierProvider(create: (_) => GamificationProvider()),
      ChangeNotifierProvider(create: (_) => TokenProvider()),
      ChangeNotifierProvider(create: (_) => BadgeProvider()),
      
      // ... 총 28개
    ],
    child: MyApp(),
  ),
);

왜 28개나 필요한가요?

MICEMore는 대규모 MICE 행사 앱이기 때문에 관리할 상태가 많습니다:

카테고리 Provider 수 예시
인프라 3개 Connectivity, Auth, Mode
행사 관리 6개 Event, Participant, Question, Announcement, Lottery, Quiz
알림 2개 Notification, FCM
게이미피케이션 3개 Gamification, Badge, Token
부스 시스템 5개 Booth, BoothQuestion, BoothVote, BoothVisit, BoothGamification
AI/추천 4개 AIPlanning, Recommendation, ImplicitFeedback, Chatbot
기타 5개 Vote, Team, EventFlow, Congestion, Community 등

Provider 성능 최적화 팁

// 나쁜 예: context.watch (불필요한 리빌드 발생)
// 이벤트 목록이 바뀔 때마다 이 위젯 전체가 다시 그려짐
Widget build(BuildContext context) {
  final events = context.watch<EventProvider>().events;
  return Column(
    children: [
      Text('행사 수: ' + events.length.toString()),
      BigExpensiveWidget(),  // 이것도 다시 그려짐! (불필요)
    ],
  );
}

// 좋은 예: Consumer + context.read 조합
Widget build(BuildContext context) {
  return Column(
    children: [
      // Consumer: EventProvider 변경 시 이 부분만 다시 그려짐
      Consumer<EventProvider>(
        builder: (context, eventProvider, child) {
          return Text('행사 수: ' + eventProvider.events.length.toString());
        },
      ),
      BigExpensiveWidget(),  // 이건 다시 안 그려짐! (성능 UP)
    ],
  );
}

4. Stream 관리와 메모리 누수 방지

Stream이란?

Stream은 "시간이 지나면서 계속 데이터가 흘러오는 파이프"입니다. 수도꼭지를 틀면 물이 계속 나오듯, Firestore의 snapshots()를 구독하면 데이터 변경이 계속 흘러옵니다.

비유 설명 코드
수도꼭지 틀기 Stream 구독 시작 .listen((data) { ... })
물 받기 데이터 수신 처리 콜백 함수 실행
수도꼭지 잠그기 Stream 구독 해제 subscription.cancel()
수도꼭지 안 잠그면? 메모리 누수! dispose()에서 cancel 안 함

메모리 누수(Memory Leak)란?

메모리 누수는 "사용하지 않는 데이터가 메모리에 계속 쌓이는 것"입니다. 마치 수도꼭지를 안 잠그면 물이 계속 나와 바닥이 잠기는 것과 같습니다.

// 메모리 누수가 발생하는 코드 (나쁜 예)
class BadProvider extends ChangeNotifier {
  void startListening() {
    // 구독은 하지만 해제를 안 함!
    _firestore.collection('events').snapshots().listen((snapshot) {
      // 화면을 벗어나도 계속 실행됨...
      // 메모리가 점점 쌓임...
    });
  }
  // dispose()가 없음! 수도꼭지 안 잠금!
}

// 올바른 코드 (좋은 예)
class GoodProvider extends ChangeNotifier {
  StreamSubscription? _subscription;  // 구독 참조 저장

  void startListening() {
    _subscription?.cancel();  // Cancel-Before-Subscribe
    _subscription = _firestore
        .collection('events')
        .snapshots()
        .listen((snapshot) {
      // 데이터 처리
    });
  }

  @override
  void dispose() {
    _subscription?.cancel();  // 수도꼭지 잠그기!
    super.dispose();
  }
}

ConnectivityService의 Stream 관리 예시

ConnectivityService는 네트워크 연결 상태를 실시간으로 감시합니다. Wi-Fi가 끊기면 즉시 감지해서 사용자에게 알려줍니다.

class ConnectivityService extends ChangeNotifier {
  final Connectivity _connectivity = Connectivity();
  
  // Stream 구독 참조를 반드시 변수에 저장!
  StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;

  bool _isOnline = true;
  bool _hasWifi = false;
  bool _hasMobile = false;

  ConnectivityService() {
    _init();  // 생성자에서 초기화
  }

  Future<void> _init() async {
    await _updateConnectionStatus();  // 현재 상태 확인
    // 상태 변경 실시간 감지 시작
    _connectivitySubscription = _connectivity
        .onConnectivityChanged
        .listen(_onConnectivityChanged);
  }

  void _onConnectivityChanged(List<ConnectivityResult> results) {
    _hasWifi = results.contains(ConnectivityResult.wifi);
    _hasMobile = results.contains(ConnectivityResult.mobile);
    _isOnline = _hasWifi || _hasMobile || 
               results.contains(ConnectivityResult.ethernet);
    notifyListeners();  // UI에 상태 변경 알림
  }

  @override
  void dispose() {
    _connectivitySubscription?.cancel();  // 반드시 해제!
    super.dispose();
  }
}

5. Batch Write: 대량 데이터 처리의 핵심

Batch Write란?

Batch Write는 "여러 개의 데이터베이스 작업을 하나로 묶어서 한 번에 실행"하는 것입니다. 편지를 10통 보낼 때 우체국을 10번 가는 대신, 한 번에 10통을 들고 가는 것과 같습니다.

// 나쁜 예: 하나씩 보내기 (네트워크 요청 100번)
for (final userId in userIds) {  // 100명
  await _firestore.collection('notifications').add({
    'userId': userId,
    'title': '행사 시작',
    'isRead': false,
  });
  // 매번 서버와 통신! 100번 왕복!
}
// 시간: 100 x 200ms = 20초

// 좋은 예: Batch Write (네트워크 요청 1번)
final batch = _firestore.batch();
for (final userId in userIds) {  // 100명
  final docRef = _firestore.collection('notifications').doc();
  batch.set(docRef, {
    'userId': userId,
    'title': '행사 시작',
    'isRead': false,
  });
}
await batch.commit();  // 한 번에 전송!
// 시간: 1 x 500ms = 0.5초 (40배 빠름!)
비교 항목 개별 Write Batch Write
100개 문서 쓰기 ~20초 ~0.5초
네트워크 요청 수 100번 1번
원자성 (Atomicity) 일부만 성공 가능 전부 성공 또는 전부 실패
최대 문서 수 제한 없음 500개

Batch vs Transaction 차이점

둘 다 여러 작업을 묶는 것이지만, 목적이 다릅니다:

구분 Batch Write Transaction
읽기 가능? 쓰기만 가능 읽기 + 쓰기 가능
사용 시나리오 알림 100개 생성, 전체 읽음 처리 포인트 증감, 토큰 차감
비유 택배 100개 한 번에 보내기 ATM에서 잔액 확인 후 출금
충돌 시 전체 실패 자동 재시도 (최대 5회)

6. 비블로킹(Non-blocking) 초기화 패턴

블로킹 vs 비블로킹

블로킹은 작업이 끝날 때까지 다음 코드가 실행되지 않는 것이고, 비블로킹은 작업을 시작만 하고 다음 코드로 넘어가는 것입니다.

// 블로킹 (느림): 폰트 로딩이 끝날 때까지 앱 표시 안됨
GoogleFonts.notoSansKr(fontWeight: FontWeight.w400);
GoogleFonts.notoSansKr(fontWeight: FontWeight.w700);
await GoogleFonts.pendingFonts();  // 500~1500ms 대기!
// 이 시간 동안 스플래시 화면만 보임...

// 비블로킹 (빠름): 폰트 로딩 시작만 하고 앱 바로 표시
GoogleFonts.notoSansKr(fontWeight: FontWeight.w400);
GoogleFonts.notoSansKr(fontWeight: FontWeight.w700);
// await 없음! 백그라운드에서 로딩
// 앱이 즉시 표시되고, 폰트는 준비되면 자동 적용

7. 에러 핸들링과 Crashlytics

// 전역 에러 핸들러: 앱 어디서든 발생하는 에러를 잡아냄
void _setupErrorHandlers() {
  // Flutter 프레임워크 에러 (UI 관련)
  FlutterError.onError = (details) {
    if (!kIsWeb) {
      FirebaseCrashlytics.instance.recordFlutterError(details);
    }
  };

  // Dart 에러 (비동기 에러 등)
  PlatformDispatcher.instance.onError = (error, stack) {
    if (!kIsWeb) {
      FirebaseCrashlytics.instance.recordError(error, stack);
    }
    return true;
  };
}

// Crashlytics: 릴리즈 모드에서만 에러 수집
// 디버그 모드에서는 꺼둠 (개발 중 에러가 대시보드를 더럽히지 않게)
if (!kDebugMode) {
  await FirebaseCrashlytics.instance
      .setCrashlyticsCollectionEnabled(true);
}

성능 최적화 체크리스트

체크 항목 적용 여부 효과
Future.wait로 병렬 초기화 적용 앱 시작 시간 43% 단축
Firestore 캐시 (모바일) 적용 서버 요청 50~70% 감소
Firestore 캐시 끄기 (웹) 적용 멀티탭 충돌 방지
Cancel-Before-Subscribe 적용 Stream 중복 구독 방지
dispose()에서 Stream cancel 적용 메모리 누수 방지
Batch Write 사용 적용 네트워크 요청 40배 감소
비블로킹 폰트 로딩 적용 스플래시 시간 0.5~1.5초 단축
Consumer로 부분 리빌드 적용 불필요한 위젯 리빌드 방지
Crashlytics 릴리즈만 활성화 적용 디버그 로그 오염 방지

핵심 정리

개념 한 줄 요약
Future.wait 독립적인 비동기 작업을 동시에 실행해 총 시간 단축
Firestore Cache 모바일은 캐시 ON(오프라인 지원), 웹은 OFF(멀티탭 안전)
Stream 관리 구독 시작과 해제를 쌍으로, dispose()에서 반드시 cancel
Batch Write 여러 문서 쓰기를 1회 요청으로 묶어 네트워크 절약
Transaction 읽기+쓰기가 필요한 원자적 작업(포인트, 토큰)
비블로킹 초기화 await 없이 시작만 하고 앱 즉시 표시
Consumer 변경된 부분만 리빌드해서 불필요한 UI 갱신 방지
Crashlytics 프로덕션 에러만 수집, 디버그 모드에서는 비활성화
728x90
반응형
2026-03-16 10:16:47
728x90
반응형

알림 시스템 아키텍처

MICEMore의 알림 시스템은 3가지 계층으로 구성됩니다. Firebase Cloud Messaging(FCM)으로 서버 푸시 알림을 처리하고, Flutter Local Notifications로 포그라운드 알림을 표시하며, Firestore 기반 인앱 알림으로 알림 이력을 관리합니다.

계층 기술 역할 동작 조건
서버 푸시 Firebase Cloud Messaging 백그라운드/종료 상태 알림 앱 비활성 시
로컬 알림 flutter_local_notifications 포그라운드 알림 표시 앱 활성 시
인앱 알림 Firestore + Stream 알림 이력 관리/읽음 처리 항상

알림 흐름도

서버 이벤트 발생 (공지, 퀴즈, 추첨 등)
    │
    ▼
Cloud Function → FCM 메시지 전송
    │
    ├── 앱 종료/백그라운드 → 시스템 알림 트레이 표시
    │       └── 알림 탭 → getInitialMessage() / onMessageOpenedApp
    │                       └── 딥링크 라우팅
    │
    └── 앱 포그라운드 → onMessage 리스너
            └── flutter_local_notifications로 로컬 알림 표시
                    └── 알림 탭 → onDidReceiveNotificationResponse
                                    └── 딥링크 라우팅

FCMProvider: 알림 초기화와 라이프사이클 관리

FCMProvider는 알림 권한 요청부터 토큰 관리, 메시지 수신, 딥링크 처리까지 전체 알림 라이프사이클을 담당합니다.

초기화 프로세스

Future<void> initialize(String userId) async {
  // 1. 로컬 알림 플러그인 초기화
  await _initializeLocalNotifications();

  // 2. 알림 권한 요청
  NotificationSettings settings = await _messaging.requestPermission(
    alert: true,
    badge: true,
    sound: true,
  );

  if (settings.authorizationStatus == AuthorizationStatus.authorized) {
    // 3. FCM 토큰 발급 + Firestore 저장
    _fcmToken = await _messaging.getToken();
    if (_fcmToken != null) {
      await _saveFCMToken(userId, _fcmToken!);
    }

    // 4. 토큰 갱신 리스너 (토큰 만료 시 자동 갱신)
    _tokenRefreshSubscription = _messaging.onTokenRefresh.listen((newToken) {
      _fcmToken = newToken;
      _saveFCMToken(userId, newToken);
      notifyListeners();
    });

    // 5. 포그라운드 메시지 리스너
    _foregroundMessageSubscription = 
        FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

    // 6. 백그라운드→앱 열림 메시지 리스너
    _messageOpenedAppSubscription = 
        FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageOpenedApp);

    // 7. 앱 종료 상태에서 알림으로 열렸을 때
    RemoteMessage? initialMessage = await _messaging.getInitialMessage();
    if (initialMessage != null) {
      _handleMessageOpenedApp(initialMessage);
    }
  }
}

핵심 설계 포인트

1. 3가지 메시지 수신 경로

  • onMessage: 앱 포그라운드 상태 — 로컬 알림으로 직접 표시
  • onMessageOpenedApp: 백그라운드에서 알림 탭 — 딥링크 처리
  • getInitialMessage: 앱 종료 상태에서 알림 탭 — 초기 딥링크 처리

2. FCM 토큰 자동 갱신

// Firestore에 FCM 토큰 저장
Future<void> _saveFCMToken(String userId, String token) async {
  await _firestore.collection('users').doc(userId).update({
    'fcmToken': token,
    'fcmTokenUpdatedAt': Timestamp.now(),
  });
}

토큰이 갱신될 때마다 Firestore의 사용자 문서에 자동 저장합니다. Cloud Function에서 알림을 보낼 때 이 토큰을 조회하여 사용합니다.

포그라운드 알림: 로컬 알림 표시

앱이 포그라운드 상태일 때 FCM 메시지를 수신하면, 시스템 알림 트레이에 직접 표시되지 않습니다. 이를 해결하기 위해 flutter_local_notifications를 사용합니다.

로컬 알림 초기화: 플랫폼별 설정

Future<void> _initializeLocalNotifications() async {
  // Android 설정
  const AndroidInitializationSettings androidSettings =
      AndroidInitializationSettings('@mipmap/ic_launcher');

  // iOS 설정
  const DarwinInitializationSettings iOSSettings =
      DarwinInitializationSettings(
    requestAlertPermission: true,
    requestBadgePermission: true,
    requestSoundPermission: true,
  );

  const InitializationSettings settings = InitializationSettings(
    android: androidSettings,
    iOS: iOSSettings,
  );

  await _localNotifications.initialize(
    settings,
    onDidReceiveNotificationResponse: _onNotificationTapped,
  );
}

포그라운드 메시지 핸들러

Future<void> _handleForegroundMessage(RemoteMessage message) async {
  final notification = message.notification;
  if (notification != null) {
    await _showLocalNotification(
      title: notification.title ?? '알림',
      body: notification.body ?? '',
      payload: message.data['eventId'] ?? '',
    );
  }
  notifyListeners();
}

Future<void> _showLocalNotification({
  required String title,
  required String body,
  String? payload,
}) async {
  const AndroidNotificationDetails androidDetails =
      AndroidNotificationDetails(
    'micemore_channel',           // 채널 ID
    'MICEMore Notifications',     // 채널 이름
    channelDescription: 'MICE 행사 알림',
    importance: Importance.high,
    priority: Priority.high,
    showWhen: true,
    icon: '@mipmap/ic_launcher',
  );

  const DarwinNotificationDetails iOSDetails = DarwinNotificationDetails(
    presentAlert: true,
    presentBadge: true,
    presentSound: true,
  );

  const NotificationDetails details = NotificationDetails(
    android: androidDetails,
    iOS: iOSDetails,
  );

  await _localNotifications.show(
    DateTime.now().millisecondsSinceEpoch % 100000, // 고유 ID 생성
    title,
    body,
    details,
    payload: payload,
  );
}

알림 고유 ID 생성: millisecondsSinceEpoch % 100000으로 간단하면서도 충돌 가능성이 낮은 고유 ID를 생성합니다. Android에서는 같은 ID의 알림은 교체되므로 이 방식이 효과적입니다.

딥링크: 알림 탭 시 특정 화면으로 이동

알림을 탭하면 관련 이벤트의 특정 화면으로 직접 이동합니다.

딥링크 데이터 구조

void _handleMessageOpenedApp(RemoteMessage message) {
  final data = message.data;
  if (data.containsKey('eventId')) {
    _pendingDeepLink = {
      'type': data['type'] ?? 'event',     // 'announcement', 'qna', 'lottery' 등
      'eventId': data['eventId'],
      'screen': data['screen'],            // 'detail', 'qna', 'announcements' 등
    };
    notifyListeners();
  }
}

// 로컬 알림 탭 핸들러
void _onNotificationTapped(NotificationResponse response) {
  if (response.payload != null && response.payload!.isNotEmpty) {
    _pendingDeepLink = {
      'type': 'notification',
      'eventId': response.payload!,
    };
    notifyListeners();
  }
}

pendingDeepLink는 UI 레이어에서 감시하여 라우팅을 실행합니다. 처리 완료 후 clearPendingDeepLink()를 호출하여 중복 라우팅을 방지합니다.

백그라운드 메시지 핸들러

FCM 백그라운드 핸들러는 최상위 함수여야 합니다. 클래스 메서드로는 등록할 수 없습니다.

// main.dart - 최상위 함수
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Firebase 재초기화 (백그라운드 Isolate에서 필요)
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 데이터 메시지만 처리
  // (notification 메시지는 시스템이 자동으로 트레이에 표시)
  if (message.data.isNotEmpty) {
    final type = message.data['type'] as String?;
    final eventId = message.data['eventId'] as String?;

    // Crashlytics에 비치명적 이벤트로 기록
    if (!kDebugMode && !kIsWeb) {
      FirebaseCrashlytics.instance.log(
        '백그라운드 FCM 수신: type=$type, eventId=$eventId',
      );
    }
  }
}

// main()에서 등록
if (!kIsWeb) {
  FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
}

@pragma('vm:entry-point') 어노테이션은 Dart 컴파일러에게 이 함수가 외부에서 호출될 수 있음을 알려, tree-shaking으로 제거되는 것을 방지합니다. 또한 백그라운드 핸들러는 별도 Isolate에서 실행되므로, Firebase를 다시 초기화해야 합니다.

인앱 알림: Firestore 기반 알림 이력 관리

FCM 푸시 알림과 별도로, Firestore에 알림 이력을 저장하여 앱 내에서 알림 목록을 확인하고 읽음/삭제 처리를 할 수 있습니다.

NotificationModel: 5가지 알림 타입

class NotificationModel {
  final String id;
  final String userId;
  final String title;
  final String message;
  final String type;     // 'event', 'checkin', 'qna', 'announcement', 'lottery'
  final String? eventId;
  final bool isRead;
  final DateTime createdAt;
}

NotificationProvider: 실시간 + Batch 처리

class NotificationProvider with ChangeNotifier {
  List<NotificationModel> _notifications = [];
  StreamSubscription<QuerySnapshot>? _notificationsSubscription;

  int get unreadCount => _notifications.where((n) => !n.isRead).length;

  // 실시간 알림 리스너
  void startListening(String userId) {
    _notificationsSubscription?.cancel();
    _notificationsSubscription = _firestore
        .collection('notifications')
        .where('userId', isEqualTo: userId)
        .orderBy('createdAt', descending: true)
        .limit(50)
        .snapshots()
        .listen((snapshot) {
      _notifications = snapshot.docs
          .map((doc) => NotificationModel.fromFirestore(doc))
          .toList();
      notifyListeners();
    });
  }

  // 모든 알림 읽음 처리 (Batch Write)
  Future<void> markAllAsRead() async {
    final batch = _firestore.batch();
    final unreadNotifications = _notifications.where((n) => !n.isRead);

    for (final notification in unreadNotifications) {
      batch.update(
        _firestore.collection('notifications').doc(notification.id),
        {'isRead': true},
      );
    }
    await batch.commit();

    // 로컬 상태 즉시 업데이트 (Firestore 응답 대기하지 않음)
    _notifications = _notifications
        .map((n) => n.copyWith(isRead: true))
        .toList();
    notifyListeners();
  }
}

Optimistic Update 패턴: markAllAsRead()에서 Firestore batch.commit() 후 로컬 상태를 즉시 업데이트합니다. Stream 리스너가 Firestore 변경을 감지하기 전에 UI가 먼저 반영되어 사용자 경험이 향상됩니다.

토픽 구독: 이벤트별 알림 분리

// 특정 이벤트 토픽 구독
Future<void> subscribeToTopic(String topic) async {
  await _messaging.subscribeToTopic(topic);
  // 예: subscribeToTopic('event_abc123')
}

// 토픽 구독 해제
Future<void> unsubscribeFromTopic(String topic) async {
  await _messaging.unsubscribeFromTopic(topic);
}

참가자가 이벤트에 참여하면 해당 이벤트 토픽을 구독하고, 이벤트 종료 시 구독을 해제합니다. 이를 통해 관련 이벤트의 공지, 퀴즈, 추첨 알림만 선별적으로 수신합니다.

Stream 구독 관리와 메모리 안전

FCMProvider는 3개의 StreamSubscription을 관리합니다:

StreamSubscription<String>? _tokenRefreshSubscription;
StreamSubscription<RemoteMessage>? _foregroundMessageSubscription;
StreamSubscription<RemoteMessage>? _messageOpenedAppSubscription;

@override
Future<void> dispose() async {
  _tokenRefreshSubscription?.cancel();
  _foregroundMessageSubscription?.cancel();
  _messageOpenedAppSubscription?.cancel();
  _fcmToken = null;
  _isInitialized = false;
  super.dispose();
}

dispose()에서 모든 구독을 해제하여 메모리 누수를 방지합니다. 새 구독 시작 전에 기존 구독을 cancel()하는 방어적 패턴을 적용합니다.

마치며

MICEMore의 알림 시스템은 FCM + 로컬 알림 + Firestore 인앱 알림의 3계층 구조로 모든 앱 상태에서 알림을 안정적으로 전달합니다. 딥링크를 통해 알림에서 직접 관련 화면으로 이동하고, Batch Write로 대량 읽음 처리를 효율적으로 수행합니다. 다음 포스트에서는 성능 최적화 기법을 분석하겠습니다.


초보자를 위한 상세 가이드: 알림 시스템 완전 정복

알림 시스템이란? - 일상 속 비유로 이해하기

알림 시스템은 카카오톡 메시지와 비슷합니다. 누군가 메시지를 보내면 핸드폰에 "띵!" 소리와 함께 알림이 뜨죠? 앱에서도 마찬가지입니다. 사용자에게 중요한 정보를 전달하기 위해 알림을 보내는 것입니다.

일상 속 비유 앱 알림 시스템 기술 용어
카카오톡 메시지 받기 서버에서 앱으로 메시지 전송 FCM Push Notification
알람 시계 설정 특정 시간에 알림 표시 Local Notification (Scheduled)
메시지 클릭해서 대화방 들어가기 알림 클릭해서 특정 화면 이동 Deep Linking
카톡 단체방에 공지 보내기 여러 사용자에게 동시 알림 Batch Notification

MICEMore의 3계층 알림 아키텍처

MICEMore는 알림을 3개의 계층으로 나누어 관리합니다. 이것은 마치 우편 시스템과 같습니다:

계층 역할 비유 파일
1. FCM Push (원격 알림) 서버 → 앱으로 메시지 전송 우체국에서 편지 배달 fcm_provider.dart
2. Local Notification (로컬 알림) 앱 내에서 직접 알림 생성 집안 알람시계 local_notification_service.dart
3. In-App Notification (앱 내 알림) 알림 목록 화면에서 읽기/관리 우편함에 보관된 편지들 notification_provider.dart

1계층: FCM(Firebase Cloud Messaging) 푸시 알림 상세 분석

FCM이란 무엇인가?

FCM은 Google이 제공하는 무료 메시지 전송 서비스입니다. 쉽게 말해 "앱 전용 카카오톡 서버"라고 생각하면 됩니다.

FCM 동작 과정 (택배 배송 비유):

1. 앱 설치 → FCM 토큰 발급 (= 집 주소 등록)
2. 서버에서 메시지 생성 (= 택배 발송)
3. FCM 서버가 중간 배달 (= 택배 허브)
4. 사용자 기기에 알림 표시 (= 택배 수령)
5. 알림 클릭으로 앱 내 이동 (= 택배 개봉)

FCM Provider 코드 완전 해부

FCMProvider는 ChangeNotifier를 상속받아 상태 변화를 UI에 자동으로 알려줍니다.

class FCMProvider with ChangeNotifier {
  // 1. Firebase 서비스 인스턴스
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;
  final FlutterLocalNotificationsPlugin _localNotifications = 
      FlutterLocalNotificationsPlugin();

  // 2. 상태 변수들
  String? _fcmToken;        // FCM 토큰 (= 기기 고유 주소)
  bool _isInitialized = false;  // 초기화 완료 여부

  // 3. Stream 구독 관리 (Cancel-Before-Subscribe 패턴)
  StreamSubscription<String>? _tokenRefreshSubscription;
  StreamSubscription<RemoteMessage>? _foregroundMessageSubscription;
  StreamSubscription<RemoteMessage>? _messageOpenedAppSubscription;

  // 4. 딥 링킹 데이터
  Map<String, dynamic>? _pendingDeepLink;
}

Cancel-Before-Subscribe 패턴이란?

이 패턴은 "물 틀기 전에 수도꼭지 잠그기"와 같습니다. Stream을 새로 구독하기 전에 반드시 이전 구독을 취소하는 것입니다.

// 왜 이렇게 하나요?
// 만약 cancel 없이 listen을 계속 하면...
// → 구독이 쌓여서 알림이 2번, 3번 중복으로 옵니다!

// Cancel-Before-Subscribe 패턴
_tokenRefreshSubscription?.cancel();  // 1. 기존 구독 해제 (없으면 skip)
_tokenRefreshSubscription = _messaging
    .onTokenRefresh
    .listen((newToken) {              // 2. 새 구독 시작
      _fcmToken = newToken;
      _saveFCMToken(userId, newToken);
      notifyListeners();
    });
패턴 하지 않으면? 비유
Cancel-Before-Subscribe 알림 중복 수신, 메모리 누수 TV 채널 안 끄고 새 채널 틀면 소리 겹침
dispose()에서 cancel 화면 벗어나도 계속 동작 방 나갔는데 불 안 끔

FCM 초기화 과정 단계별 설명

Future<void> initialize(String userId) async {
  // Step 1: 로컬 알림 초기화 (알림 표시 도구 준비)
  await _initializeLocalNotifications();

  // Step 2: 알림 권한 요청 (사용자에게 "알림 보내도 될까요?" 물어봄)
  NotificationSettings settings = await _messaging.requestPermission(
    alert: true,   // 팝업 알림
    badge: true,   // 앱 아이콘 숫자 표시
    sound: true,   // 알림 소리
  );

  // Step 3: 권한 승인되었을 때만 진행
  if (settings.authorizationStatus == AuthorizationStatus.authorized) {
    // Step 4: FCM 토큰 발급 (= 이 기기의 고유 주소 받기)
    _fcmToken = await _messaging.getToken();

    // Step 5: Firestore에 토큰 저장 (서버가 알 수 있게)
    if (_fcmToken != null) {
      await _saveFCMToken(userId, _fcmToken!);
    }

    // Step 6: 3개의 리스너 등록
    // 6-1. 토큰 갱신 리스너 (주소 바뀌면 서버에 알리기)
    // 6-2. 포그라운드 메시지 리스너 (앱 사용 중 알림 받기)
    // 6-3. 백그라운드 앱 열기 리스너 (알림 클릭으로 앱 열기)
  }
}

2계층: Local Notification (로컬 알림) 상세 분석

로컬 알림이란?

로컬 알림은 서버 없이 앱 자체에서 생성하는 알림입니다. 인터넷 연결 없이도 동작합니다. 마치 핸드폰에 설정한 알람처럼, 앱이 직접 알림을 띄워줍니다.

구분 FCM 푸시 알림 로컬 알림
알림 생성 위치 서버(Cloud Functions) 앱 내부
인터넷 필요 여부 필요 불필요
사용 시나리오 다른 사용자 행동 알림 부스 근접, 일정 리마인더
비유 택배 배달 집안 알람시계

Singleton 패턴으로 구현된 LocalNotificationService

Singleton(싱글톤)이란 "앱 전체에서 딱 하나만 존재하는 객체"를 만드는 패턴입니다. 알림 서비스가 여러 개 생기면 알림이 중복되거나 충돌할 수 있기 때문입니다.

class LocalNotificationService {
  // 1. 유일한 인스턴스를 저장할 변수 (static final)
  static final LocalNotificationService _instance =
      LocalNotificationService._internal();

  // 2. factory 생성자: new로 만들어도 항상 같은 인스턴스 반환
  factory LocalNotificationService() {
    return _instance;  // 항상 동일한 객체!
  }

  // 3. private 생성자: 외부에서 직접 생성 불가
  LocalNotificationService._internal();
}

// 사용 예시:
final service1 = LocalNotificationService();
final service2 = LocalNotificationService();
// service1 == service2  → true! (같은 객체)

Android 알림 채널(Channel) 시스템

Android 8.0(Oreo)부터 알림을 채널별로 분류해야 합니다. 이것은 마치 TV 채널처럼, 사용자가 채널별로 알림을 끄거나 켤 수 있습니다.

채널 ID 이름 용도 중요도
booth_proximity Booth Proximity 부스 근접 알림 Default
token_earned Tokens Earned 토큰 획득 알림 High
congestion_alert Congestion Alerts 혼잡도 알림 Default
game_available Game Available 미니게임 알림 Default
event_reminder Event Reminders 행사 리마인더 High
// 알림 채널 생성 코드 분석
await _flutterLocalNotificationsPlugin
    .resolvePlatformSpecificImplementation<
        AndroidFlutterLocalNotificationsPlugin>()
    ?.createNotificationChannel(
      const AndroidNotificationChannel(
        id: 'token_earned',          // 채널 고유 ID
        name: 'Tokens Earned',       // 사용자에게 보이는 이름
        description: '토큰 획득 알림',  // 채널 설명
        importance: Importance.high,  // 중요도 (소리+진동+팝업)
        enableVibration: true,       // 진동 활성화
        enableLights: true,          // LED 불빛
        showBadge: true,             // 앱 아이콘 배지 표시
      ),
    );

중요도(Importance) 레벨 설명:

레벨 동작 사용 예
Importance.max 소리 + 진동 + 팝업 + 전체화면 긴급 알림
Importance.high 소리 + 진동 + 팝업 토큰 획득, 리마인더
Importance.defaultImportance 소리 + 상태바 부스 근접, 혼잡도
Importance.low 상태바만 (소리 없음) 일반 정보
Importance.min 접힌 상태로만 표시 백그라운드 정보

3계층: In-App Notification (앱 내 알림 관리)

NotificationProvider - 알림 목록 관리자

NotificationProvider는 앱 내 알림함입니다. 카카오톡의 "알림" 탭처럼, 지금까지 받은 모든 알림을 목록으로 보여주고 읽음/삭제 관리를 합니다.

class NotificationProvider with ChangeNotifier {
  List<NotificationModel> _notifications = [];  // 알림 목록
  bool _isLoading = false;                       // 로딩 중인지
  String? _currentUserId;                        // 현재 사용자

  // Stream 구독 (실시간 알림 수신용)
  StreamSubscription<QuerySnapshot>? _notificationsSubscription;

  // 읽지 않은 알림 개수 (앱 아이콘 배지에 표시)
  int get unreadCount => _notifications.where((n) => !n.isRead).length;
}

실시간 알림 수신: Firestore snapshots()

Firestore의 snapshots()데이터가 변경될 때마다 자동으로 알려주는 기능입니다. 마치 CCTV 실시간 모니터링처럼, 새 알림이 추가되면 즉시 감지합니다.

void startListening(String userId) {
  // Cancel-Before-Subscribe 패턴 적용
  _notificationsSubscription?.cancel();  // 기존 구독 해제

  // Firestore 실시간 구독 시작
  _notificationsSubscription = _firestore
      .collection('notifications')       // notifications 컬렉션에서
      .where('userId', isEqualTo: userId) // 내 알림만 필터링
      .orderBy('createdAt', descending: true) // 최신순 정렬
      .limit(50)                          // 최대 50개만
      .snapshots()                        // 실시간 구독!
      .listen((snapshot) {                // 변경될 때마다 실행
    _notifications = snapshot.docs
        .map((doc) => NotificationModel.fromFirestore(doc))
        .toList();
    notifyListeners();  // UI 자동 업데이트
  });
}

Batch Write로 전체 읽음 처리

알림이 30개 있는데 하나씩 "읽음"으로 바꾸면 Firestore에 30번 요청해야 합니다. Batch Write를 사용하면 1번의 요청으로 30개를 한꺼번에 처리할 수 있습니다.

Future<void> markAllAsRead() async {
  final batch = _firestore.batch();  // 배치 시작
  
  // 읽지 않은 알림만 필터링
  final unreadNotifications = _notifications.where((n) => !n.isRead);

  for (final notification in unreadNotifications) {
    final docRef = _firestore
        .collection('notifications')
        .doc(notification.id);
    batch.update(docRef, {'isRead': true});  // 배치에 작업 추가
  }

  await batch.commit();  // 한 번에 실행! (네트워크 요청 1번)

  // 로컬 상태도 업데이트 (UI 즉시 반영)
  _notifications = _notifications
      .map((n) => n.copyWith(isRead: true))
      .toList();
  notifyListeners();
}

딥 링킹(Deep Linking): 알림에서 특정 화면으로 이동

딥 링킹이란?

딥 링킹은 "알림을 클릭하면 앱의 특정 화면으로 바로 이동"하는 기능입니다. 카카오톡에서 메시지 알림을 누르면 해당 채팅방으로 바로 들어가는 것과 같습니다.

// 알림 클릭 시 딥 링크 데이터 설정
void _handleMessageOpenedApp(RemoteMessage message) {
  final data = message.data;  // 알림에 포함된 데이터
  
  if (data.containsKey('eventId')) {
    // 딥 링크 정보를 저장해둠
    _pendingDeepLink = {
      'type': data['type'] ?? 'event',    // 알림 타입
      'eventId': data['eventId'],          // 행사 ID
      'screen': data['screen'],            // 이동할 화면
    };
    notifyListeners();  // UI에 알림 (GoRouter가 감지)
  }
}

// GoRouter에서 딥 링크를 감지하여 화면 이동
// app.dart에서 FCMProvider를 watch하다가
// pendingDeepLink가 생기면 해당 화면으로 navigate

딥 링킹 동작 흐름:

알림 수신 → 사용자가 알림 클릭
  ↓
_handleMessageOpenedApp() 호출
  ↓
_pendingDeepLink 에 데이터 저장
  ↓
notifyListeners() 호출
  ↓
app.dart의 Consumer<FCMProvider>가 감지
  ↓
GoRouter.go('/events/행사ID/detail') 실행
  ↓
해당 행사 상세 화면으로 이동!
  ↓
clearPendingDeepLink() 호출 (처리 완료 표시)

FCMNotificationService: 서버 측 알림 전송 로직

알림 전송 방식 3가지

FCMNotificationService는 알림을 Firestore에 저장하는 역할을 합니다. 실제 FCM 메시지 전송은 Cloud Functions가 Firestore 변경을 감지하여 자동으로 처리합니다.

메서드 대상 사용 예시
sendNotificationToUser() 특정 1명 체크인 완료, 추첨 당첨
sendNotificationToUsers() 여러 명 (Batch) 행사 공지
sendNotificationToEventParticipants() 행사 참가자 전체 행사 시작/종료
// 1명에게 알림 보내기
Future<void> sendNotificationToUser({
  required String userId,    // 받을 사람
  required String title,     // 알림 제목
  required String message,   // 알림 내용
  required String type,      // 알림 종류
  String? eventId,           // 관련 행사 (선택)
}) async {
  // Firestore에 알림 데이터 저장
  // → Cloud Functions가 이걸 감지해서 실제 FCM 전송
  await _firestore.collection('notifications').add({
    'userId': userId,
    'title': title,
    'message': message,
    'type': type,
    'eventId': eventId,
    'isRead': false,          // 처음엔 읽지 않은 상태
    'createdAt': Timestamp.now(),  // 생성 시간
  });
}

Batch 알림: 여러 명에게 한번에 보내기

100명에게 알림을 보낼 때, 하나씩 보내면 100번의 네트워크 요청이 필요합니다. Batch를 사용하면 1번의 요청으로 처리됩니다.

Future<void> sendNotificationToUsers({
  required List<String> userIds,  // 받을 사람들 목록
  required String title,
  required String message,
  required String type,
}) async {
  final batch = _firestore.batch();  // 배치 시작
  final now = Timestamp.now();

  for (final userId in userIds) {
    // 각 사용자별 알림 문서 생성
    final docRef = _firestore.collection('notifications').doc();
    batch.set(docRef, {
      'userId': userId,
      'title': title,
      'message': message,
      'type': type,
      'isRead': false,
      'createdAt': now,  // 모든 알림 동일한 시간
    });
  }

  await batch.commit();  // 한 번에 전송!
  // 주의: Firestore Batch는 최대 500개까지 가능
}

NotificationModel: 알림 데이터 구조

NotificationModel은 알림 한 건의 데이터를 담는 그릇입니다.

class NotificationModel {
  final String id;        // 알림 고유 ID
  final String userId;    // 수신자 ID
  final String title;     // 제목 (예: "체크인 완료")
  final String message;   // 내용 (예: "행사 체크인이 완료되었습니다!")
  final String type;      // 종류
  final String? eventId;  // 관련 행사 ID
  final bool isRead;      // 읽었는지 여부
  final DateTime createdAt; // 생성 시간
}

알림 타입(type) 분류:

타입 설명 발생 시점
event 행사 관련 행사 생성/수정/시작/종료
checkin 체크인 QR 체크인 완료
qna Q&A 질문에 답변 등록
announcement 공지사항 새 공지 등록
lottery 추첨 추첨 당첨

copyWith 패턴 상세 설명

copyWith"원본은 그대로 두고, 일부만 바꾼 복사본을 만드는" 패턴입니다. Dart에서 불변(immutable) 객체를 다룰 때 필수적인 패턴입니다.

// copyWith 사용 예시
final notification = NotificationModel(
  id: 'abc123',
  userId: 'user1',
  title: '체크인 완료',
  message: '행사 체크인이 완료되었습니다!',
  type: 'checkin',
  isRead: false,      // 아직 안 읽음
  createdAt: DateTime.now(),
);

// isRead만 true로 바꾼 새 객체 생성
final readNotification = notification.copyWith(isRead: true);

// 원본은 그대로! (isRead: false)
// 복사본만 변경됨! (isRead: true)

앱 상태별 알림 처리 정리

앱 상태 알림 수신 방법 처리 코드
Foreground (앱 사용 중) onMessage 리스너 _handleForegroundMessage()
Background (앱 최소화) 시스템 알림 자동 표시 firebaseMessagingBackgroundHandler()
Terminated (앱 종료) 시스템 알림 자동 표시 getInitialMessage()
알림 클릭 (Background) onMessageOpenedApp _handleMessageOpenedApp()
알림 클릭 (Terminated) getInitialMessage _handleMessageOpenedApp()

백그라운드 메시지 핸들러: 최상위 함수의 비밀

// @pragma는 Dart 컴파일러에게 특별한 지시를 하는 주석입니다
// 'vm:entry-point'는 "이 함수는 직접 호출되지 않아도 제거하지 마세요"라는 의미
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(
    RemoteMessage message) async {
  // 이 함수는 반드시 최상위 함수여야 합니다!
  // 클래스 안에 넣으면 동작하지 않습니다.
  // 이유: 백그라운드에서는 별도의 Isolate에서 실행되기 때문
  AppLogger.d('백그라운드 메시지 수신');
}

전체 알림 시스템 데이터 흐름도

[Cloud Functions에서 FCM 전송]
        |
        v
[FCM 서버] ----> [사용자 기기]
                     |
        +-----------+-----------+
        |           |           |
   [Foreground] [Background] [Terminated]
        |           |           |
   onMessage    시스템알림    시스템알림
        |           |           |
   로컬알림표시  클릭시       클릭시
        |      onMessageOpened getInitialMsg
        |           |           |
        +-----+-----+-----+----+
              |           |
         [딥 링크]   [알림함 저장]
              |           |
         GoRouter    NotificationProvider
              |           |
         화면 이동    목록 표시/관리

핵심 정리

개념 핵심 포인트
FCM Google 무료 푸시 알림 서비스, 토큰 기반 전송
Local Notification 서버 없이 앱 자체에서 생성, Singleton 패턴
알림 채널 Android 8.0+ 필수, 사용자가 채널별 on/off 가능
Cancel-Before-Subscribe Stream 중복 구독 방지 필수 패턴
딥 링킹 알림 클릭 → 특정 화면 이동, pendingDeepLink로 관리
Batch Write 여러 문서 동시 쓰기 (최대 500개), 네트워크 절약
copyWith 불변 객체의 일부만 변경한 복사본 생성
@pragma('vm:entry-point') 백그라운드 핸들러 최상위 함수 필수 지정
728x90
반응형


이 페이지는 리디주식회사에서 제공한 리디바탕 글꼴이 사용되어 있습니다.