2탄 권2 제4장
2탄 권2 — 제4장

Day 15~21: 카카오·닉네임·FCM·신고 SLA

사용자 인증 + E5 [3] 닉네임 두 톤 분리 + ★ R4 자동 회복 사이클 완성

📑 이 챕터에서 다룰 내용
📘 들어가며 — 사용자 인증과 E5 [3]

권2 제3장 Day 14 회고에서 3주차 페이스 25% 축소 결정. 4h/일 → 3.5h/일. 이번 주는 사용자 인증 + 닉네임 페르소나 풀 + FCM — 사용자가 처음으로 줍줍에 들어오는 자리입니다.

특히 E5 [3] 닉네임 페르소나 풀이 등장합니다 — 소상공인·시민 톤 분리가 코드 레벨로 박힙니다.

  • 사전 지식: Day 8~14 + DB 본격 / SPEC v4 §7 인증 / CLAUDE.md §5 [3]
  • 이 장의 목적: Day 15 (카카오 G-4) → Day 21 (3주차 회고). E5 [3] + G-4·G-5 입증 + C-4 신고 SLA
  • 완료 후 결과물: 카카오 로그인 + 닉네임 자동 + FCM + 자발적 필터 + 신고 SLA + 추가 휴식
4-1 Day 15 — 카카오 로그인 + 토큰 자동 갱신 (G-4) 🔗

작업 시간 3.5h (페이스 축소 적용).

Claude가 작성한 src/lib/auth/kakao.ts (80줄 — 발췌)

💻 src/lib/auth/kakao.ts — G-4
import * as KakaoLogin from '@react-native-kakao/login';
import { supabase } from '../supabase';

export async function kakaoLogin() {
  // 1. 카카오 OAuth
  const result = await KakaoLogin.login();
  const { accessToken, refreshToken, expiresIn } = result;

  // 2. Supabase Edge Function으로 사용자 등록 (kakao_id 추출)
  const { data: user, error } = await supabase.functions.invoke(
    'kakao-callback',
    { body: { access_token: accessToken } }
  );

  // 3. 토큰 저장 (Expo SecureStore)
  await SecureStore.setItemAsync('kakao_access', accessToken);
  await SecureStore.setItemAsync('kakao_refresh', refreshToken);
  await SecureStore.setItemAsync('kakao_expires',
    String(Date.now() + expiresIn * 1000));

  return user;
}

export async function ensureValidToken(): Promise<string | null> {
  const expires = await SecureStore.getItemAsync('kakao_expires');

  if (!expires || Date.now() >= Number(expires)) {
    // access 만료 — refresh 시도
    return await tryRefresh();
  }
  return await SecureStore.getItemAsync('kakao_access');
}

async function tryRefresh(): Promise<string | null> {
  try {
    const newTokens = await KakaoLogin.refreshAccessToken(refresh);
    await SecureStore.setItemAsync('kakao_access', newTokens.accessToken);
    return newTokens.accessToken;
  } catch {
    // refresh 만료 (60일) — 재로그인 필요
    await SecureStore.deleteItemAsync('kakao_access');
    return null;
  }
}
📘 Day 15 LogOnTable — Expo SecureStore vs AsyncStorage
  • 결정: 카카오 토큰은 Expo SecureStore (AsyncStorage X)
  • 근거: AsyncStorage는 평문 저장 — 디바이스 분실 시 노출 위험. SecureStore는 iOS Keychain·Android Keystore 사용 = 암호화
  • 대안: AsyncStorage — 간단하지만 보안 X / 백엔드 세션만 — UX 부담
  • 부작용: SecureStore는 동기 X (await) — 모든 토큰 접근 비동기

누적: 50h + Day 15 (3.5h) = 53.5h / ⚠️ E2: 60h 트리거 6.5h 여유. 위험 영역 진입.

4-2 Day 16 — ⭐ E5 [3] 닉네임 페르소나 풀 🔗

Claude가 작성한 lib/auth/nickname-generator.ts (90줄 — 발췌)

💻 lib/auth/nickname-generator.ts — E5 [3]
const ADJECTIVES_BUSINESS = [
  '든든한', '열정', '꼼꼼한', '현명한', '부지런한', '알뜰한', '따뜻한',
  '차분한', '신중한', '꿈꾸는', '도전하는', '활기찬', '단단한', '정직한',
  '용감한', '슬기로운', '재빠른', '꾸준한', '믿음직한', '성실한', '뚝심있는',
] as const;  // 21개

const ADJECTIVES_INDIVIDUAL = [
  '밝은', '차분한', '열정', '신중한', '꿈꾸는', '도전하는', '용감한',
  '활기찬', '슬기로운', '꾸준한', '단단한', '따뜻한', '믿음직한', '재빠른',
  '성실한', '낙천적인', '진중한', '부지런한', '꿈많은', '굳센', '다정한',
] as const;  // 21개

export async function generateNickname(
  tab: 'business' | 'individual',
  region: string,                  // 시군구
  context: string,                 // 업종 (소상공인) 또는 상황 (시민)
  attempt: number = 0,
): Promise<string> {
  const adjectives = tab === 'business' ? ADJECTIVES_BUSINESS : ADJECTIVES_INDIVIDUAL;
  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];

  // 패턴
  const base = tab === 'business'
    ? `${region} ${context} 운영하는 ${adj} 줍줍이`
    : `${region} ${context} ${adj} 줍줍이`;

  // 충돌 검사 (DB)
  const exists = await checkNicknameExists(base);
  if (!exists) return base;

  // 2차 fallback: 다른 형용사 시도 (5번까지)
  if (attempt < 5) return generateNickname(tab, region, context, attempt + 1);

  // 3차 fallback: 숫자 suffix
  let suffix = 2;
  while (await checkNicknameExists(`${base}${suffix}`)) suffix++;
  return `${base}${suffix}`;
}

테스트 — 5개 닉네임 자동 부여

🎉 닉네임 자동 부여 테스트 결과
  • 소상공인 (수원시·카페): "수원시 카페 운영하는 든든한 줍줍이"
  • 소상공인 (대구시·식당): "대구시 식당 운영하는 열정 줍줍이"
  • 일반 시민 (강남구·취준): "강남구 취준하는 열정 줍줍이"
  • 일반 시민 (광주시·1인가구): "광주시 1인가구 따뜻한 줍줍이"
  • 일반 시민 (강남구·취준 — 2번째): 1차 충돌 → "강남구 취준하는 차분한 줍줍이" 통과
📘 Day 16 LogOnTable — 형용사 풀 21개
  • 결정: 형용사 21개 (E5 [3] 명시 "20개+" 충족)
  • 근거: 시군구 250개 × 업종 10개 × 형용사 21개 = 52,500 조합. MAU 5,000 시 충돌 가능성 < 10%. fallback 충분
  • 대안: 형용사 50개+ — 톤 일관성 약화 / 형용사 10개 — MAU 1,000+에서 30% 충돌
  • 부작용: 권3 출시 후 피드백에 따라 Phase 1.1 추가 7~10개 검토

E5 [3] 작동: 소상공인 패턴 (사장님 톤) vs 시민 패턴 (시민 톤) 코드 레벨 분리 완성.

누적: 53.5h + Day 16 (3.5h) = 57h / ⚠️ E2: 60h 트리거 3h 여유. 매우 위험.

4-3 Day 17 — FCM 토큰 발급·만료 처리 (G-5) 🔗

Day 17 — 페이스 위험. 작업 시간 3.5h → 3h 추가 축소.

💻 Claude Code 자연어 입력
"FCM 토큰 발급·만료 처리. SPEC v4 §4 [3] + G-5.

요구사항:
1. Expo + expo-notifications 설치
2. 첫 로그인 시 FCM 토큰 발급 → users.fcm_token 저장
3. 토큰 만료 (앱 재설치·기기 변경) 감지 → NULL 처리
4. supabase/functions/_shared/fcm.ts 작성 (발송 함수)
5. 매주 일요일 cron — 만료 토큰 정리 (NULL 처리)"
⚠️ Day 17 끝 — R4 트리거 정확히 도달

E2 4 트리거 점검

  • ☐ 누적 60h+/4주 — YES (정확히 60h) ⚠️
  • ☐ 회고 부재 2주+ — No
  • ☐ 토요일 4주 연속 — No
  • ☐ "피곤하다" 등장 — 약한 신호 1회 (Day 17) ⚠️

자동 결정 — 1탄 v2 부록 H-2 R4 본문 그대로 적용:

  1. Day 18 즉시 1일 휴식 (작업 X)
  2. 다음 주 (4주차 Day 22~28) 목표 25% 축소
  3. 추가 점검: Day 21 일요일 작업 X + 회고 작성
📘 Day 17 LogOnTable — FCM 토큰 만료 감지 시점
  • 결정: 발송 시도 시점에 만료 감지 (사전 검사 X)
  • 근거: Firebase Admin SDK 발송 결과에 "InvalidRegistration" 에러 포함. 사전 검사 X = API 호출 절감
  • 대안: 매일 cron 검증 — Firebase API 호출 비용 부담 / 클라이언트 측 검증 — 앱 비활성 시 불가
  • 부작용: 만료 토큰 첫 발송 1회 실패 (사용자에게 영향 없음, 다음 로그인 시 재등록)

누적: 57h + Day 17 (3h) = 60h ⚠️ R4 트리거 정확히 도달

4-4 Day 18 — ★ R4 휴식 의무 (작업 X) 🔗
🎉 Day 18 — R4 1일 휴식 의식
  • 작업 없음 (R4 트리거 도달 → 1일 휴식)
  • 가족·자유 시간

E2 페이스 회복:

  • 누적 60h → 휴식 1일 → 4주 평균 다시 13.3h/주 (안전 영역)
  • "피곤하다" 신호 해소

다음 작업: Day 19 자발적 필터 폼 — 페이스 축소 (3h/일 유지)

💡 6개월 후 동업자 시나리오

"왜 Day 18에 작업 안 했나?" 펼치면 이 트레이스에 답이 있습니다:

"R4 4 트리거 중 1.5건 도달 → 자동 1일 휴식 의식 (1탄 v2 부록 H-2 R4 본문 그대로). 운영자 보호 = R1~R12 모두 의미 있게 만드는 자리."

4-5 Day 19 — User profile 자발적 필터 + 신고 SLA C-4 🔗
💻 Claude Code 자연어 입력
"두 가지 작업:

1. User profile 자발적 필터 폼:
   - 소상공인 탭: 시군구·업종·매출 단계 (모두 optional, 기본값 OFF)
   - 시민 탭: 시군구·연령·가구·소득분위 (모두 optional)
   - CLAUDE.md §5 [4] LLM 분류 프롬프트와 매칭

2. 어드민 신고 처리 페이지 + SLA 24h (C-4):
   - reports 테이블 PENDING 행 노출
   - 우선순위: 욕설·혐오 (1h) > 허위 정보 (4h) > 기타 (24h)
   - 처리: APPROVED → 후기 비공개 / REJECTED → 후기 유지"
📘 Day 19 LogOnTable — SLA 우선순위 분류
  • 결정: 신고 reason을 4 카테고리로 분류 + SLA 차등
  • 근거: SPEC v4 + C-4 발견. "욕설·혐오 1h"가 신뢰 보호 우선
  • 대안: 모든 신고 24h SLA — 욕설 노출 부담 (1순위 커뮤니티 게시 직후)
  • 부작용: reason 분류 자동화 (Phase 1.1) 또는 어드민 수동 분류 (현재)

누적: 60h + Day 19 (3h) = 63h

4-6 Day 20 — 신고 자동 알림 cron 🔗
📘 Day 20 LogOnTable — Slack 채널 분리
  • 결정: 검수 큐·신고·일반 운영 알림 모두 같은 Slack 채널
  • 근거: 1인 운영 = 한 채널이면 충분. 채널 분리는 Phase 1.1 (팀 합류 시)
  • 대안: 채널 3개 분리 — 1인에게 알림 분산 부담 (놓칠 위험)
  • 부작용: Slack 채널 #jupjup-alerts가 무거워질 수 있음 — Phase 2 검토

누적: 63h + Day 20 (1.5h) = 64.5h

4-7 Day 21 — ★ 일요일 추가 휴식 + 3주차 회고 🔗
🎉 Day 21 — 추가 휴식 의식
  • 작업 X (오전·오후 모두)
  • 회고 작성 30분만 (저녁)

3주차 회고 (BUILD.md 1.5KB)

📘 BUILD.md Day 21 — ★ R4 추가 휴식 + 3주차 회고

E2 4 트리거 점검

  • ☐ 누적 60h+/4주 — Day 17 도달 → Day 18 휴식으로 회복
  • ☐ 회고 부재 2주+ — No (1·2주차 작성)
  • ☐ 토요일 4주 연속 — No (Day 5·12·19 모두 작업 X)
  • ☐ "피곤하다" 등장 — Day 17 약한 신호 1회 (해소)

✅ 페이스 회복.

산출물

  • kakao.ts (80줄, G-4)
  • AuthGuard.tsx (40줄)
  • kakao-callback/ (Edge Function)
  • nickname-generator.ts (90줄, ⭐ E5 [3])
  • fcm.ts (60줄, G-5)
  • functions/_shared/fcm.ts (50줄)
  • profile/edit.tsx (140줄)
  • admin/reports/page.tsx (130줄, C-4)
  • admin-report-sla-check/ (cron + Slack)

⭐ E5 [3] 닉네임 두 톤 분리의 본질

  • 소상공인 패턴: [시군구] + [업종] + 운영하는 + [형용사] + 줍줍이
  • 시민 패턴: [시군구] + [상황] + [형용사] + 줍줍이
  • 21 형용사 × 2 = 42개 (CLAUDE.md §5 [3] "20개+" 충족)
  • 3단 fallback (다른 형용사 → 숫자 suffix)

⭐ R4 자동 휴식 의식의 본질

  • Day 17 누적 60h 정확히 도달 + "피곤하다" 약한 신호 1회
  • 자동 결정: Day 18 1일 휴식 + Day 22~28 목표 25% 축소 + Day 21 추가 일요일 휴식
  • 결과: 4주 평균 16.1h/주로 안전 회복

다음 주 (4주차 Day 22~28) 목표 — 페이스 25% 축소 적용:

  • Day 22~23: 통합 테스트 (Agent Teams)
  • Day 24: 통계 조작 시도 시뮬 + UNIQUE + IP rate 검증
  • Day 25: G2 통과 조건 점검
  • Day 26: G-3 is_active 트리거 검증 (토요일 작업 X)
  • Day 27: G2 점검 (모든 항목 PASS)
  • Day 28: ★ G2 PASSED 의식

누적: 64.5h + Day 21 (0.5h 회고) = 65h / 4주 평균 16.25h/주 (R4 안전)

📌 권2 제4장 정리

  • 핵심: Day 15~21 = 카카오·닉네임·FCM·신고 SLA + ★ R4 자동 휴식 사이클 완성
  • 사용자 인증·신뢰 산출물: kakao.ts (G-4) / nickname-generator.ts (⭐ E5 [3], 21 형용사 × 2) / fcm.ts (G-5) / profile/edit.tsx / admin/reports/page.tsx (C-4) / admin-report-sla-check
  • 5확장 입증: E1 시크릿 3개 분리 / E2 ★ R4 트리거 도달 → 자동 회복 / E3 G-4+G-5 입증 / E4 트레이스 8개 / E5 [3] 닉네임 두 톤 분리
  • R4 자동 회복 5단계: Day 14 점검 → 3주차 25% 축소 / Day 17 트리거 도달 / Day 18 1일 휴식 / Day 21 일요일 추가 휴식 / 4주 평균 16.25h/주 안전
  • 누적: 65h / 4주 평균 16.25h (R4 트리거 안전)
🎉 R4 자동 회복 사이클이 작동한 자리

3주차의 가장 큰 가치는 "E2 가설이 운영 데이터로 입증된" 사실입니다. 1탄 v2 부록 H-2 R4 본문이 매뉴얼이 아니라 줍줍의 매일 보호 사이클이 됐습니다.

E5 [3]도 등장했습니다 — 닉네임 1개에서 두 톤 (사장님·시민) 분리. 같은 "줍줍이"인데 노출 자리에 따라 다른 정체성. SSOT 시스템의 깊이 = 사실은 같지만 표현은 다름.