💡 핵심 요약 (Executive Summary)
생산성 앱에서 가장 까다로운 난제는 "몰입을 방해하지 않으면서 어떻게 수익을 창출할 것인가"와 "리워드 시스템을 노리는 어뷰저(Abuser)를 어떻게 차단할 것인가"입니다. 본 2편에서는 전면 팝업 광고를 100% 배제한 자발적 보상형 광고(Opt-in Rewarded Ad) 모델과, 클라이언트 Wall-Clock 시간 교차 검증부터 Supabase RLS(Row Level Security) 및 SECURITY DEFINER 저장 프로시저까지 이어지는 4중 어뷰징 방어 아키텍처를 실제 코드와 함께 해부합니다.
AdMob 보상형 광고 수익화와 매크로·해커를 막는 4중 방어 아키텍처
▲ 집중 흐름을 방해하지 않는 보상형 광고 분리 설계와 클라이언트-서버 4중 어뷰징 방어 아키텍처

1. 집중 앱의 딜레마: "타이머가 끝났는데 갑자기 전면 광고가 뜬다면?"

주간 복권(Focus-to-Earn) 당첨금 재원을 지속 가능하게 마련하려면 광고 수익화(Google AdMob)가 필수였습니다. 하지만 시중의 많은 무료 유틸리티 앱들이 범하는 치명적인 실수가 있습니다. 바로 25분 집중이 끝나고 휴식 타이머로 넘어가는 순간, 화면 전체를 덮는 전면 광고(Interstitial Ad)와 시끄러운 사운드를 강제로 재생하는 것입니다.

조용한 독서실이나 새벽 서재에서 빗소리를 들으며 몰입하던 사용자에게 갑작스러운 전면 광고는 즉시 '앱 삭제'로 이어집니다. 그래서 v2.0에서는 다음의 철칙(Golden Rule)을 세웠습니다.

🛡️ 수익화 UX 3대 원칙
  1. Zero Intrusive Ads: 스튜디오(타이머) 화면과 사운드 믹서 화면에서는 그 어떠한 팝업 광고나 배너도 띄우지 않는다.
  2. 100% Opt-In Rewarded Ads: 오직 세 번째 탭인 [라운지(Reward Lounge)]에서 사용자가 스스로 "광고 보고 보너스 티켓 받기" 버튼을 눌렀을 때만 Google AdMob 보상형 광고(RewardedAd)를 재생한다.
  3. Graceful Fallback: 광고 인벤토리 부족(No-Fill)이나 오프라인 환경에서도 사용자 경험이 깨지지 않도록 예외 처리를 완비한다.

2. "해커와 매크로는 반드시 온다" — 4중 어뷰징 방어 아키텍처

보상(주간 추첨 경품)이 걸린 시스템에는 필연적으로 어뷰징 시도가 뒤따릅니다. 설계 단계에서 예상한 주요 공격 벡터(Attack Vectors)는 크게 4가지였습니다.

  • 공격 시나리오 A (초단타 타이머): 집중 시간을 1분으로 설정해 두고 3분 만에 티켓 3장을 챙기는 행위
  • 공격 시나리오 B (매크로 무한 파밍): 공기계나 에뮬레이터에서 오토클리커를 돌려 하루 수백 장의 티켓을 쓸어 담는 행위
  • 공격 시나리오 C (클라이언트 시간 변조): 타이머 시작 직후 기기 시간을 앞으로 돌리거나 강제 완료 함수를 호출해 1초 만에 25분 세션을 끝내는 행위
  • 공격 시나리오 D (네트워크 패킷 위조): 앱 내부의 Supabase Anon Key를 추출한 뒤 cURL이나 Python 스크립트로 DB 테이블에 직접 티켓 10,000장을 INSERT하는 행위

이 4가지 공격을 각각 격파하기 위해 클라이언트(Flutter)부터 데이터베이스 커널(PostgreSQL)까지 4중 방어선(4-Layer Defense-in-Depth)을 구축했습니다.

sequenceDiagram autonumber participant User as 사용자 / 타이머 엔진 participant App as Flutter AppStateService (Layer 1~3) participant RLS as Supabase RLS Policy (Layer 4a) participant RPC as PostgreSQL Security Definer RPC (Layer 4b) User->>App: 집중 세션 완료 이벤트 발생 Note over App: [Layer 1] 설정 집중 시간 >= 20분 검증 Note over App: [Layer 2] 금일 집중 티켓 발급 수 < 3장 검증 Note over App: [Layer 3] Wall-Clock 실제 경과 시간 >= 80% 검증 App->>RLS: rpc('claim_focus_ticket', params) 호출 Note over RLS: 익명 사용자의 테이블 직접 INSERT는 전면 차단! RLS->>RPC: 서버 내부 권한으로 검증 로직 실행 Note over RPC: 1. p_focus_minutes >= 20 재검증
2. 당일 디바이스 티켓 < 3장 재검증
3. 직전 티켓 발급 후 15분 경과(Cooldown) 검증 RPC-->>App: 검증 통과 시 고유 티켓 발급 완료

Layer 1 & Layer 2: 최소 20분 컷오프와 일일 하드캡 분리

우선 집중 세션 설정 시간이 최소 20분 이상일 때만 티켓 발급 자격을 부여합니다. 또한 하루에 획득할 수 있는 티켓의 상한선을 집중 완료 티켓 최대 3장, 보상형 광고 시청 티켓 최대 5장으로 엄격히 분리하여, 아무리 매크로를 돌려도 하루 최대치를 절대 넘을 수 없도록 설계했습니다.

Layer 3: 클라이언트 Wall-Clock(실제 경과 시각) 80% 교차 검증

단순히 타이머 카운터가 00:00이 되었다고 해서 무조건 티켓을 주지 않습니다. AppStateService(lib/service/app_state_service.dart)는 세션이 시작된 시점의 실제 시각(sessionStartedAt)과 종료 시점의 실제 시각 차이를 계산하여, 목표 집중 시간의 최소 80% 이상(예: 25분 세션 기준 최소 20분 = 1,200초) 실제 물리적 시간이 흘렀는지 교차 검증합니다.

// lib/service/app_state_service.dart — Layer 1~3 실제 검증 로직 발췌
class AppStateService extends ChangeNotifier {
  static const int minFocusMinutesForTicket = 20;
  static const int maxFocusTicketsPerDay = 3;
  static const int maxAdTicketsPerDay = 5;

  Future<TicketClaimResult> recordCompletedFocusSession({
    required int minutes,
    required int actualElapsedSeconds,
  }) async {
    _checkAndResetCycles();

    // [Layer 1] 20분 미만 집중 세션은 통계에만 기록하고 티켓은 미지급
    if (minutes < minFocusMinutesForTicket) {
      return TicketClaimResult.minDurationNotMet;
    }

    // [Layer 2] 일일 집중 티켓 하드캡(3장) 초과 여부 확인
    if (_todayFocusTickets >= maxFocusTicketsPerDay) {
      return TicketClaimResult.dailyLimitReached;
    }

    // [Layer 3] Wall-Clock 실제 경과 시간 교차 검증 (최소 80% 이상 실제 시간 경과 필수)
    final int minRequiredSeconds = (minutes * 60 * 0.8).floor();
    if (actualElapsedSeconds < minRequiredSeconds) {
      return TicketClaimResult.suspiciousFastCompletion;
    }

    // 검증 통과 시 고유 6자리 Hex 티켓 발급 및 Supabase RPC 동기화
    final ticket = _issueNewTicket(source: 'FOCUS');
    await _syncTicketViaSupabaseRpc(ticket, minutes, actualElapsedSeconds);
    return TicketClaimResult.issued(ticket);
  }
}

Layer 4: Supabase RLS 차단 & PostgreSQL SECURITY DEFINER RPC

클라이언트 검증만으로는 부족합니다. 악의적인 사용자가 앱 바이너리를 디컴파일하여 Supabase URL과 Anon Key를 알아낸 뒤, HTTP POST 요청으로 focus_tickets 테이블에 직접 데이터를 밀어 넣을 수 있기 때문입니다.

이를 원천 봉쇄하기 위해 supabase_schema.sql에서 익명(anon) 사용자의 직접 INSERT 권한을 완전히 박탈하고, 오직 데이터베이스 서버 내부에서 실행되는 SECURITY DEFINER RPC 함수(claim_focus_ticket)를 통해서만 티켓이 기록되도록 설계했습니다.

-- supabase_schema.sql — Layer 4: RLS 직접 INSERT 차단 및 서버 측 RPC 검증
ALTER TABLE public.focus_tickets ENABLE ROW LEVEL SECURITY;

-- ⚠️ 핵심: 익명 클라이언트(anon)의 테이블 직접 INSERT 정책을 생성하지 않음 (전면 차단)
-- 오직 아래의 SECURITY DEFINER 함수 내부에서만 검증 후 INSERT 수행

CREATE OR REPLACE FUNCTION public.claim_focus_ticket(
    p_device_id TEXT,
    p_ticket_code TEXT,
    p_source TEXT,
    p_week_key TEXT,
    p_focus_minutes INT DEFAULT 25,
    p_elapsed_seconds INT DEFAULT 1500
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
    v_today_count INT;
    v_last_created_at TIMESTAMPTZ;
    v_daily_limit INT;
BEGIN
    -- 1. 소스 타입별 일일 하드캡 설정 (FOCUS: 3장, AD: 5장)
    IF p_source = 'FOCUS' THEN
        v_daily_limit := 3;
        -- 최소 20분 이상 집중 및 실제 경과 시간(80%) 서버 재검증
        IF p_focus_minutes < 20 OR p_elapsed_seconds < (p_focus_minutes * 48) THEN
            RETURN jsonb_build_object('success', false, 'reason', 'INVALID_FOCUS_DURATION');
        END IF;
    ELSIF p_source = 'AD' THEN
        v_daily_limit := 5;
    ELSE
        RETURN jsonb_build_object('success', false, 'reason', 'INVALID_SOURCE');
    END IF;

    -- 2. 금일(UTC 기준 당일) 해당 디바이스의 티켓 발급 횟수 조회
    SELECT COUNT(*), MAX(created_at)
      INTO v_today_count, v_last_created_at
      FROM public.focus_tickets
     WHERE device_id = p_device_id
       AND source = p_source
       AND created_at >= date_trunc('day', now());

    IF v_today_count >= v_daily_limit THEN
        RETURN jsonb_build_object('success', false, 'reason', 'DAILY_LIMIT_REACHED');
    END IF;

    -- 3. [Rate Limiting] FOCUS 티켓은 직전 발급 시각으로부터 최소 15분(900초) 쿨다운 강제!
    IF p_source = 'FOCUS' AND v_last_created_at IS NOT NULL THEN
        IF EXTRACT(EPOCH FROM (now() - v_last_created_at)) < 900 THEN
            RETURN jsonb_build_object('success', false, 'reason', 'COOLDOWN_ACTIVE');
        END IF;
    END IF;

    -- 4. 모든 검증 통과 시에만 테이블에 티켓 INSERT
    INSERT INTO public.focus_tickets (device_id, ticket_code, source, week_key, focus_minutes)
    VALUES (p_device_id, p_ticket_code, p_source, p_week_key, p_focus_minutes);

    RETURN jsonb_build_object('success', true, 'ticket_code', p_ticket_code);
END;
$$;

주목할 부분은 3번 서버 사이드 Rate Limiting(쿨다운)입니다. 설령 해커가 cURL로 claim_focus_ticket RPC를 1초에 100번 연속 호출하더라도, PostgreSQL 서버가 now() - v_last_created_at < 900(15분 미만) 조건으로 첫 번째 요청 이후의 연속 호출을 모두 거부합니다. 즉, 물리적으로도 최소 15분 이상의 간격을 두고 하루 최대 3번까지만 호출이 통과됩니다.

3. 다음 이야기 예고: "왜 합성 빗소리는 라디오 잡음처럼 들릴까?"

보상과 보안 아키텍처를 단단히 다진 뒤, 저는 'Lo-Fi Focus Studio'의 핵심 감성인 백색소음(ASMR) 오디오 믹서 개발에 착수했습니다. 처음에는 앱 용량을 아끼겠다며 수학 공식으로 빗소리와 장작 타는 소리를 직접 합성(Synthesis)했는데, 결과는 처참했습니다.

이어지는 [Part 3: "합성음은 가짜 같다" — Google 공식 현장 녹음 음원 도입과 다중 채널 백색소음 믹서 엔진 제작기]에서는 2번의 합성음 폐기 끝에 찾은 Google 공식 현장 녹음 라이브러리 도입기와, 24MB에 달하던 음원을 3.1MB로 87% 압축하면서도 24시간 끊김 없는(Gapless) 5채널 믹서를 구현한 비결을 공개합니다.