Skip to main content
← 블로그

하루의 매듭을 짓는 알림 — 매일 회고 리마인더 구현기

VauDium ·

매일 22시에 회고를 권하는 알림을 만들면서 마주친 선택들 — 로컬 vs 서버 푸시, 완료 신호의 정의, 언어 설정의 숨은 빈 틈.

하루의 매듭을 짓는 알림

왜 추가했나

Fecit에는 예전부터 일일 회고 기능이 있었습니다. 하루 끝에 오늘을 돌아보고 description과 satisfaction을 남기는 것. 근데 한 가지 문제가 있었어요. 유저가 스스로 들어와야 쓰는 기능이었다는 점. 회고는 “해야지” 하고 미뤄지기 쉬운 일인데, 그 미뤄짐에 개입할 장치가 없었습니다.

그래서 단순한 기능 하나 추가: 유저가 정한 시각에 “오늘 돌아볼까요?” 푸시를 보냄. 기본값 22:00, 옵트인.

별거 아닌 것 같지만 구현하면서 네다섯 번 방향이 바뀌었습니다.

첫 번째 갈림길: 로컬 vs 서버 푸시

직관적으로는 로컬 스케줄 알림이 깔끔해 보였습니다. 유저 기기에서 expo-notifications가 매일 22시에 알림을 발사하면 끝. 오프라인에서도 되고, 서버 부담도 없고.

근데 파고들수록 로컬의 문제가 쌓였어요:

  • “이미 회고했으면 안 쏘기” 를 구현하려면 클라가 이 상태를 알아야 함. 로컬 DB에 캐시는 있지만 싱크 시점 이슈가 있음.
  • iOS는 앱당 스케줄된 알림 64개 제한. 여러 피처가 공유하는데 이 쿼터를 하나 더 잡음.
  • 트리거가 6개: 앱 시작, 포그라운드, 설정 변경, 회고 작성, 로그아웃, 자정 경계. 각각이 “스케줄 재계산”을 불러야 함.
  • Timezone 변경 처리를 클라가 들고 있어야 함.

결정적으로 Fecit엔 이미 APScheduler 기반의 매 분 cron 인프라가 있었습니다. create_reserved, notify_start_date 같은 기존 잡들이 1분 간격으로 API를 찌르는 구조. “있는 걸 쓰면 된다” 가 가장 결정적이었어요.

서버 푸시로 가니까:

  • 클라는 세터 API와 푸시 수신 핸들러만 있으면 됨 — 스케줄 관리 코드 0줄
  • 서버가 authoritative — satisfaction 체크가 DB 직접 조회라 정확
  • TZ, 자정, 앱 상태 엣지 케이스 전부 무관
  • iOS 쿼터와 무관

두 번째 갈림길: “완료”의 정의

처음엔 “오늘 daily review가 있으면 회고 완료” 로 간주했습니다. 근데 유저가 회고 화면에 들어가서 description만 적고 satisfaction은 비워둔 채 나갈 수 있어요. 그러면 DB엔 review가 존재하지만 실제론 미완성.

그래서 완료 기준을 satisfaction != null 로 바꿨습니다. description은 비어도 되지만 satisfaction이 붙어야 “오늘을 평가해봤다”는 의미가 성립. 푸시 분기도 이 기준으로 통일.

review = await daily_review_collection.find_one(
    {
        'created_by': achiever_id,
        'date': local_midnight,
        'satisfaction': {'$ne': None},
    },
    {'_id': 1},
)
if review is not None:
    return  # 이미 완료 → 스킵

세 번째 갈림길: 중복 발사 방지

매 분 cron이 돌고 유저의 로컬 시각이 22:00이면 푸시 발사. 근데 cron이 잠깐 지연되거나 재시작되면 한 유저에게 여러 번 발사될 위험이 있어요.

Fire lock 패턴을 썼습니다. 기존 create_reservation_fire_lock 구조를 그대로 차용:

  • 컬렉션: daily_review_prompt_fire_lock
  • 키: (achiever_id, local_date) unique 인덱스
  • fired_at 필드에 TTL 25시간 — 하루 지나면 자동 삭제
# 멱등성: 같은 로컬 날짜엔 1회만 insert 성공
try:
    await daily_review_prompt_fire_lock_collection.insert_one({
        'achiever_id': achiever_id,
        'local_date': local_date_str,
        'fired_at': datetime.now(timezone.utc),
    })
except DuplicateKeyError:
    return  # 이미 발사한 날 → 스킵

DB가 “하루에 한 번”을 보장해줍니다. 애플리케이션 로직에서 따로 관리할 필요 없음.

네 번째 갈림길: 언어 설정의 빈 틈

푸시 메시지를 유저 언어로 보내려면 서버가 유저 언어를 알아야 합니다. Fecit엔 achiever_preference.language 필드가 있어서 “그거 읽으면 되지”라고 생각했어요.

근데 막상 찾아보니 대부분 유저의 preference.language가 null이었습니다. 이유는 단순 — 클라가 device locale을 언어 설정으로 자동 기록한 적이 없었어요. 유저가 설정에서 명시적으로 언어를 바꿀 때만 값이 채워졌고, 나머지는 i18next가 device locale fallback으로 표시할 뿐.

앱 안에서야 i18next가 알아서 잘 돌아가지만, 서버가 유저에게 뭘 보낼 때 참고할 값이 비어 있던 거. 푸시 본문, 이메일, 외부 링크 등 전부 영향 받음.

수정은 간단했습니다. 앱 시작 시 preference.language가 null이고 device locale이 ko 또는 en이면 1회 자동 기록:

if (!preference.language) {
    const deviceLang = Localization.getLocales()[0]?.languageCode;
    if (deviceLang === "ko" || deviceLang === "en") {
        setAchieverPreferenceLanguage(deviceLang)
            .then((updated) => setAchieverPreference(updated))
            .catch(() => {});
    }
}

이 수정은 daily review 알림만을 위한 게 아니라, 서버가 유저 언어를 알아야 하는 모든 미래 시나리오의 포석이 됐습니다.

네이밍 — “마무리”에서 “회고”로

초안에서 영어 라벨을 "Daily Wrap-up Reminder"로 썼습니다. “회고(Review)“라는 단어가 무거워 보여서 살짝 가볍게 해보려고요. 근데 기존 피처명이 이미 "Daily Review" / "일일 회고"였습니다. 새 설정만 다른 단어를 쓰면 유저 입장에선 “이게 뭐지, Review랑 다른 건가?” 싶어져요.

통일했습니다:

  • Daily Review Reminder / 일일 회고 알림

단, 푸시 본문은 그대로 뒀습니다 — “오늘 하루 돌아볼까요?” / “Let’s look back on your day”. 설정 라벨은 피처명이라 정확해야 하고, 푸시 본문은 초대 문구라 부드러워야 하니까 톤이 달라도 됨.

구현의 모양

서버:

  • achiever_preferencedaily_review_prompt_time: Optional[str] (예: "22:00") 필드. null = off.
  • PUT 세터 엔드포인트
  • 매 분 호출되는 배치 엔드포인트: POST /api/achiever/daily-review/notify-prompt/batch
  • Fire lock 컬렉션 + TTL 인덱스

Cron:

  • 기존 APScheduler 구조에 register_minutely("notify_daily_review_prompt", ...) 한 줄 추가

클라:

  • Shared 모델에 dailyReviewPromptTime 필드
  • 설정 화면에 Toggle + TimePicker 컴포넌트
  • 푸시 수신 핸들러에 data.type === "daily-review-prompt" 분기 → edit-daily-review 화면으로 이동
  • 앱 시작 시 language auto-set

쓰고 보니

이런 “단순한” 피처 하나에 네 번의 선택이 있었어요. 각 선택이 미세해 보이지만, 하나라도 반대로 갔으면 UX가 미묘하게 삐걱거렸을 거예요.

  • 로컬로 갔으면 “이미 회고했는데 왜 또 울리지?”
  • satisfaction 말고 “review 있으면 완료”로 갔으면 description만 적고 만 날에 안 울려서 유저가 까먹음
  • fire lock 없으면 cron 재시작 시 두 번 울림
  • language auto-set 안 하면 한국 유저가 영어 푸시 받음

제품의 세부는 이런 결정들이 쌓여서 완성돼요. 하나하나는 작지만, 그 합이 “이 앱이 나를 이해한다”는 감각을 만듭니다.