Day 15~21: 카카오·닉네임·FCM·신고 SLA
사용자 인증 + E5 [3] 닉네임 두 톤 분리 + ★ R4 자동 회복 사이클 완성
📑 이 챕터에서 다룰 내용
권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 + 추가 휴식
작업 시간 3.5h (페이스 축소 적용).
Claude가 작성한 src/lib/auth/kakao.ts (80줄 — 발췌)
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;
}
}
- 결정: 카카오 토큰은 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 여유. 위험 영역 진입.
Claude가 작성한 lib/auth/nickname-generator.ts (90줄 — 발췌)
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차 충돌 → "강남구 취준하는 차분한 줍줍이" 통과
- 결정: 형용사 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 여유. 매우 위험.
Day 17 — 페이스 위험. 작업 시간 3.5h → 3h 추가 축소.
"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 처리)"
E2 4 트리거 점검
- ☐ 누적 60h+/4주 — YES (정확히 60h) ⚠️
- ☐ 회고 부재 2주+ — No
- ☐ 토요일 4주 연속 — No
- ☐ "피곤하다" 등장 — 약한 신호 1회 (Day 17) ⚠️
자동 결정 — 1탄 v2 부록 H-2 R4 본문 그대로 적용:
- Day 18 즉시 1일 휴식 (작업 X)
- 다음 주 (4주차 Day 22~28) 목표 25% 축소
- 추가 점검: Day 21 일요일 작업 X + 회고 작성
- 결정: 발송 시도 시점에 만료 감지 (사전 검사 X)
- 근거: Firebase Admin SDK 발송 결과에 "InvalidRegistration" 에러 포함. 사전 검사 X = API 호출 절감
- 대안: 매일 cron 검증 — Firebase API 호출 비용 부담 / 클라이언트 측 검증 — 앱 비활성 시 불가
- 부작용: 만료 토큰 첫 발송 1회 실패 (사용자에게 영향 없음, 다음 로그인 시 재등록)
누적: 57h + Day 17 (3h) = 60h ⚠️ R4 트리거 정확히 도달
- 작업 없음 (R4 트리거 도달 → 1일 휴식)
- 가족·자유 시간
E2 페이스 회복:
- 누적 60h → 휴식 1일 → 4주 평균 다시 13.3h/주 (안전 영역)
- "피곤하다" 신호 해소
다음 작업: Day 19 자발적 필터 폼 — 페이스 축소 (3h/일 유지)
"왜 Day 18에 작업 안 했나?" 펼치면 이 트레이스에 답이 있습니다:
"R4 4 트리거 중 1.5건 도달 → 자동 1일 휴식 의식 (1탄 v2 부록 H-2 R4 본문 그대로). 운영자 보호 = R1~R12 모두 의미 있게 만드는 자리."
"두 가지 작업: 1. User profile 자발적 필터 폼: - 소상공인 탭: 시군구·업종·매출 단계 (모두 optional, 기본값 OFF) - 시민 탭: 시군구·연령·가구·소득분위 (모두 optional) - CLAUDE.md §5 [4] LLM 분류 프롬프트와 매칭 2. 어드민 신고 처리 페이지 + SLA 24h (C-4): - reports 테이블 PENDING 행 노출 - 우선순위: 욕설·혐오 (1h) > 허위 정보 (4h) > 기타 (24h) - 처리: APPROVED → 후기 비공개 / REJECTED → 후기 유지"
- 결정: 신고 reason을 4 카테고리로 분류 + SLA 차등
- 근거: SPEC v4 + C-4 발견. "욕설·혐오 1h"가 신뢰 보호 우선
- 대안: 모든 신고 24h SLA — 욕설 노출 부담 (1순위 커뮤니티 게시 직후)
- 부작용: reason 분류 자동화 (Phase 1.1) 또는 어드민 수동 분류 (현재)
누적: 60h + Day 19 (3h) = 63h
- 결정: 검수 큐·신고·일반 운영 알림 모두 같은 Slack 채널
- 근거: 1인 운영 = 한 채널이면 충분. 채널 분리는 Phase 1.1 (팀 합류 시)
- 대안: 채널 3개 분리 — 1인에게 알림 분산 부담 (놓칠 위험)
- 부작용: Slack 채널 #jupjup-alerts가 무거워질 수 있음 — Phase 2 검토
누적: 63h + Day 20 (1.5h) = 64.5h
- 작업 X (오전·오후 모두)
- 회고 작성 30분만 (저녁)
3주차 회고 (BUILD.md 1.5KB)
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 트리거 안전)
3주차의 가장 큰 가치는 "E2 가설이 운영 데이터로 입증된" 사실입니다. 1탄 v2 부록 H-2 R4 본문이 매뉴얼이 아니라 줍줍의 매일 보호 사이클이 됐습니다.
E5 [3]도 등장했습니다 — 닉네임 1개에서 두 톤 (사장님·시민) 분리. 같은 "줍줍이"인데 노출 자리에 따라 다른 정체성. SSOT 시스템의 깊이 = 사실은 같지만 표현은 다름.