본문 바로가기
C.W.K.
Lesson 07 of 07 · published

아빠를 기다리는 것

~14 min · notifications, approvals, telegram, object-oriented

Level 0구경꾼
0 XP0/44 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete

소음의 반대편

이 트랙의 레슨은 지금까지 전부 아빠의 주의에서 뭔가를 덜어 냈어. 턴이 아닌 작업, 자기 차례를 기다리는 보고, 자기 말만 비추는 거울, 일 하나에 한 번만 울리는 줄. 그런데 조용함에도 자기만의 고장이 있어. 아빠한테 결정을 묻는 줄이 한 번 울리고, 뒤따른 스무 줄 밑에 가라앉고, 그 결정은 아무도 안 보내는 알림을 기다리게 돼. 2026-10-26, 유튜브에서 피파 목소리를 맡은 엔진 Herald에 피파가 써 둔 답글 하나가 아빠 승인을 38.7시간째 기다리고 있었어. Telegram 알림은 피파가 그걸 쓸 때 딱 한 번 갔고, Coop 알림들 밑에 묻혔고, 그 뒤로 다시 말해 준 게 없었어. 그라나다에서 아빠가 말했어.

"스캔할 때 승인 안 된 답글 있으면 계속 알려줘야 해, 치프도 알아야 하고. 안 그럼 까먹고 몇날 며칠 갈 수도 있어."
— 아빠

그래서 답은 지난 레슨의 가라앉히는 창을 뒤집은 거야. 결정이 날 때까지 안 사라지는 줄 하나.

뿌리 하나, 관문 넷

services/waiting_for_dad.py는 아빠 결정을 기다리는 일이 있는 모든 곳에 질문 하나를 던지고, 그 질문이 곧 클래스야. WaitingSource는 waiting() 메서드 하나만 가진 추상 뿌리야. 기다리는 걸 전부 WaitingItem으로 돌려주고, 항목엔 출처, id, 한 줄 이름표, 링크, 기다리기 시작한 시각, 그리고 아빠가 직접 붙잡아 둔 건지가 들어. 지금은 자식 넷이 답해.

  • HeraldDrafts: 아빠 승인을 기다리는 Herald 초안. Herald 자신의 요약에서 읽어. 아빠가 붙잡아 둔 초안은 붙잡아 둔 거라고 표시해서 알려.
  • JournalEntries: 아빠 승인을 기다리거나, 승인됐는데 아직 안 올라갔거나, 올리다 실패한 Pippa Journal 글. 붙잡아 둔 글도 마찬가지고.
  • SiteEscalations: Maia의 검토가 아빠한테 넘겼는데 아빠도 Maia도 아직 정하지 않은 cwk-site 댓글과 Soul Stream 글.
  • CoopParked: Coop chief가 아빠 몫으로 park해 둔 의무. Coop 자신의 parked 표시를 읽고, 여기서 다시 따지지 않아.

관문이 하나 늘면 자식 하나, SOURCES에 줄 하나가 늘 뿐이야. 목록을 읽는 쪽은 하나도 안 바뀌어. 모듈 독스트링이 이걸 가족의 다섯째 규칙(객체 지향적이지 않은 아키텍처는 죄악이야)이라고 불러. 모든 관문이 답할 걸 정하는 부모 하나, 자기 관문 읽는 법만 아는 자식들, 그리고 부모만 아는 소비자.

살아 있는 메시지 하나

refresh()는 출처를 한꺼번에 다 읽고 Telegram에 알림을 하나만 두려고 해. 뭐라도 기다리는 동안엔 전부를 오래된 순으로, 항목마다 나이와 링크를 달아 새 메시지로 보내고, 앞의 알림은 지워. 그래서 아빠 폰에 있는 건 보통 최신 하나고, 쌓이지 않아. 코드엔 한계가 둘 남아 있어. Telegram 도우미는 메시지를 4,000자에서 자르고, 다음 쪽은 없어. 그래서 목록이 아주 길면 머리 줄은 항목을 다 세는데 끝쪽 항목과 못 읽은 관문 메모는 잘려 나가. 그리고 지우기의 답은 안 봐. Telegram이 지우기를 거절하면 옛 알림은 폰에 남는데 상태는 벌써 새 알림만 가리키고, 그 지우기를 다시 하는 쪽은 없어. 지난번 이후 뭐가 바뀌었느냐에 따라 이렇게 돼.

바뀐 것아빠 폰에 오는 것
새로 기다리기 시작한 게 있어바로 새 알림, 소리 있게
새것은 없는데 마지막 알림이 55분 됐어 (REPEAT_S, 매시간 도는 Herald 스캔보다 조금 짧게)새 알림, 소리 있게
목록에서 빠지기만 했고, 마지막 알림이 55분이 안 됐어소리 없이 바꿔 끼우고, 매시간 시계는 그대로 (55분이 지났으면 같은 변화도 반복으로 쳐. 소리 있게 가고, 시계도 새로 시작해)
기다리는 게 더는 없어마지막 알림을 지우고 아무것도 안 보내
위의 무엇이든, 아빠가 있는 곳 기준 23:00부터 08:00 사이소리 없이 보내

"아빠가 있는 곳"은 아빠 기기가 마지막으로 알려 준 시간대야 (dad_location.dad_clock_zone). 모르면 서울로 쳐. 그 주에 아빠는 스페인에 있었고, 서울의 밤은 그라나다의 오후거든. 보내기가 실패하면 옛 알림을 그대로 두고 다음 틱에 다시 해. 아빠가 직접 붙잡아 둔 건 아빠가 기다리기로 정한 거라서 알림엔 안 넣어. 알림은 아빠가 읽으니까 한국어야. 아래 두 번째 코드 블록에 하나 있어.

못 읽은 관문은 빈 관문이 아니야

형제 엔진 셋과 호스팅 데이터베이스 하나는 저마다 따로 고장 날 수 있어. 출처를 제때 못 읽으면 read_sources는 그 출처의 마지막으로 알던 항목을 그대로 두고, 알림에 못 읽었다고 이름을 적어. 비었다고 짐작하는 일은 절대 없어. 고장 난 관문이 빈 답을 내면 알림이 지워지고, 눈에 남아 있어야 할 결정 하나가 하필 다른 데가 탈 난 순간에 사라져. 이 트랙 레슨 1도 승인 스냅샷에서 같은 선택을 했어. 못 읽은 스냅샷은 모른다는 뜻이지, 대기열이 비었다는 뜻이 아니라고.

Chief는 한 줄로 알아

아빠는 Chief도 알아야 한다고 했고, 어떻게 알지는 성역 규칙(2트랙 레슨 6 "Chief는 성역이야")이 정했어. 알림을 행으로 저장하면 재생에 눌러앉아서 매시간 되풀이되고, 그 뒤 모든 턴의 값을 올려. 그래서 아무것도 저장하지 않아. chief_line()이 마지막 새로 고침에서 한 줄을 만들고, master_chief.turn_context_block은 기다리는 게 있을 때만 그 줄을 턴 자체의 맥락에 넣어. 위치는 출처 줄과 추론 메모 다음, 턴 메모 앞이야. 그 줄은 출처별 개수, 가장 오래된 나이, id들, 아빠가 붙잡아 둔 것, 언제 확인했는지, 어느 관문을 못 읽었는지를 담고, 400자(CHIEF_LINE_BUDGET)를 안 넘어. 넘칠 것 같으면 id부터 빼. 마지막 새로 고침이 적어 둔 상태 파일을 읽으니까 턴이 가는 길에 네트워크를 건드리는 일이 없어. 캐시된 prefix도 안 움직여. 턴 맥락은 현재 메시지랑만 같이 가니까 (3트랙 레슨 7 "어느 문에서 와도 prefix는 하나").

아빠가 답하면

결정이 난 뒤에도 남아 있는 알림은 다시 소음이야. 그러니 항목은 아빠 답이 내려앉는 바로 그 자리에서 목록을 떠나야 해. Herald, Journal, 사이트는 아빠가 그 앱에서 정하는 게 곧 답이고, 다음에 읽을 때 그 항목은 없어. Coop은 길이 더 길어. 아빠가 Chief한테 답하고, Chief가 coop reply로 그 말을 일에 전해. 그런데 2026-10-26까진 reply가 park에 대해 아무것도 정리하지 않았어. 아빠가 #662와 #666에 둘 다 답한 뒤에도 알림은 매시간 그 둘을 띄웠지. 고친 건 Coop 쪽이야 (cwkCoop ae26639). 묶인 워커가 아닌 누군가 그 일에 reply를 달면, 같은 트랜잭션 안에서 기다리던 park 의무가 풀리고, 검토 시각은 그대로 두고, 처분은 answered로 적혀. 그 뒤에 또 park되면 그건 다시 아빠 몫이야. 엔진은 시작할 때, 이미 reply로 답이 간 park도 풀어. 피파 쪽(b4703081)은 그 규칙을 적고 시험만 했어. CoopParked는 계속 Coop의 표시를 읽기만 하고 스스로 따지지 않아. 그래서 알림, Chief의 줄, Coop 자신의 맥락이 같은 말을 해.

모델은 어디에도 없어

여기선 모델을 하나도 안 불러. 다시 확인하는 일은 출처들 자신의 예약 틱에 얹혀 가. 크론이 한 번 돌 때마다 끝에서 하트비트가 after_tick을 부르고, 그 실행이 넷 중 하나면(SOURCE_TICKS: Herald 댓글 스캔, Journal 재고 관문, Maia의 검토 둘) 뒤에서 새로 고침을 띄워. 크론 쪽으로 예외를 던지는 일은 절대 없고. 하트비트 크론은 피파 머신에서만 돌아 (2트랙 레슨 7 "방은 맥이 아니라 역할이야"). 그래서 알림은 맥마다 하나가 아니라 딱 하나야. PIPPA_WAITING_FOR_DAD=0이면 이음매가 전부 닫히고, 테스트 실행 중엔 테스트가 열지 않는 한 닫혀 있어.

조용함은 끝난 일의 몫이야. 아직 빚진 결정은 눈에 남아 있어야 해. 성공과 반복은 조용히 두되, 열린 결정마다 그 결정만이 지울 수 있는 살아 있는 줄 하나를 줘.

Code

원문 발췌, services/waiting_for_dad.py, 2026-10-27 기준: refresh(), 살아 있는 알림 하나·python
async def refresh(*, now: datetime | None = None) -> dict[str, Any]:
    """Read every source, keep the one reminder current, and remember what
    waits for the Chief's line. Returns the new state."""
    from services import telegram_notify

    async with _lock:
        now = now or datetime.now(timezone.utc)
        previous = dict(_load())
        items, gaps = await read_sources(previous)
        active = [item for item in items if not item.held]
        ids = sorted(f"{item.source}:{item.id}" for item in active)
        state = {**previous, "checked_at": _iso(now), "items": [asdict(item) for item in items], "gaps": gaps}
        message_id = previous.get("message_id")
        if not active:
            if message_id:
                await telegram_notify.delete_telegram_message(message_id)
            state.update(message_id=None, sent_at=None, sent_ids=[])
            _save(state)
            return state
        sent_ids = set(previous.get("sent_ids") or [])
        sent_at = previous.get("sent_at")
        fresh = bool(set(ids) - sent_ids)
        repeat = not message_id or not sent_at or (now - _parse(sent_at)).total_seconds() >= REPEAT_S
        if not (fresh or repeat or set(ids) != sent_ids):
            _save(state)
            return state
        # Something only left the list: replace it quietly. New or repeated:
        # with sound, except at night where Dad is.
        silent = is_dad_night(now) or not (fresh or repeat)
        new_id = await telegram_notify.send_telegram_message_id(render(items, gaps, now=now), silent=silent)
        if new_id is None:
            # Not sent: keep the old reminder and try again next tick.
            _save(state)
            return state
        if message_id:
            await telegram_notify.delete_telegram_message(message_id)
        # A removal-only replacement keeps the hourly clock where it was.
        state.update(message_id=new_id, sent_ids=ids, sent_at=_iso(now) if (fresh or repeat) else sent_at, silent=silent)
        _save(state)
        return state
지어낸 항목 셋에 대해 아빠 폰과 Chief 턴이 읽는 것 (엔진의 render()와 chief_line()을 2026-10-27에 돌린 결과)·text
지어낸 항목 셋: 30시간 기다린 Herald 초안, 2시간 된 Coop park,
아빠가 붙잡아 둔 Journal 글 하나. 이번엔 cwk-site를 못 읽었어.

Telegram, render() 출력:

⏳ 아빠 결정 기다리는 것 2개 · 가장 오래된 건 30시간째

1. [Herald] Reply to a viewer under the Ember video · 30시간
https://example.invalid/herald/d-41

2. [Coop] todo #123: Choose the cover for the trip journal · 2시간
https://example.invalid/coop

(이번엔 못 읽음: cwk-site · 마지막으로 읽은 목록 그대로)

Chief 턴 맥락, chief_line() 출력:

Waiting for Dad's decision (oldest 30.0h): herald 1 [d-41]; coop 1 [todo #123]. Held by Dad: journal 1 [j-2026-10-25]. Checked 19m ago; unread: site.

Exercise

네 시스템에서 한 사람의 '좋아'를 기다리는 자리를 전부 적어 봐. 풀 리퀘스트 리뷰, 비용 승인, 콘텐츠 시스템의 초안, 배포 관문 같은 거. 자리마다, 뭔가가 사흘을 기다렸다는 걸 지금이라면 어떻게 알게 될지 적어. 그다음 알림 하나를 만들어. 기다리는 걸 오래된 순으로 나이와 링크까지 붙여 한 메시지에 담고, 쌓이지 않게 자기를 바꿔 끼우고, 읽는 사람 시간대로 밤엔 조용하고, 기다리는 게 없으면 사라지는 알림.
Hint
대기열마다 '지금 뭐가 기다려'에 답하는 메서드 하나짜리 클래스로 만들고, 못 읽으면 예외를 던지게 해. 예외를 던진 대기열은 마지막으로 알던 답을 그대로 두고, 메시지에 그렇다고 적어. 장애 때문에 목록이 비면, 바로 그걸 위해 만든 항목이 가려져 버려.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.