본문 바로가기
C.W.K.
Stream
Lesson 01 of 05 · published

추가만 하는 원본 — 상태 말고 행위를 기록해

~12 min · event-sourcing, architecture, durability, data-modeling

Level 0미분류
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

기사를 좋아했다는 걸 기억하는 두 가지 방법

뻔한 쪽은 컬럼이야. 기사 행에 liked 를 달고, 참으로 놓고, 끝. 쓰기 한 번이고, 질의하기 쉽고, 대부분의 앱이 그렇게 해. 다른 쪽은 기록을 덧붙이는 거야. 이 순간, 이 기사, 좋아함이라는 행위를. 절대 안 고치는 파일에다가. 그리고 누가 물으면 불리언을 그때 유도하고.

컬럼은 결론을 저장해. 로그는 일어난 일을 저장하고. 저 차이는 결론이 답할 수 없는 게 처음 필요해지기 전까진 학술적으로 들려. 언제 좋아했지, 읽기 전이었나 후였나, 좋아함을 취소한 적 있나, 지난 화요일에 몇 개 좋아했지, 한 달 전 독자 취향은 어땠지. 컬럼은 저걸 전부 설계상 버렸고, 로그는 어떤 질문이 중요할지 미리 정할 필요가 없었어.

어휘는 작고 닫혀 있어

이벤트 어휘는 머리에 담길 만큼 짧아야 해. 열기, 좋아하기, 싫어하기, 저장, 공유, 대기열, 그리고 그 취소들. 닫아두는 게 투영을 다룰 만하게 만들어. 모든 소비자가 나올 수 있는 것의 전체 집합을 알고, 동사 추가는 폭발 반경이 눈에 보이는 의도적 행위가 되지.

취소도 이벤트라는 걸 짚어둘게. 저장 취소는 저장의 삭제가 아냐. 나중에 일어난 새 사실이야. 규율 전체가 한 줄이야. 과거는 절대 안 고치고 정정을 덧붙인다. 그게 투영을 패치를 어떤 순서로 적용했는지가 아니라 로그의 순수 함수로 만들어주는 거야.

왜 JSONL 이고, 왜 DB 옆인가

로그는 관계형 표가 못하는 성질을 원해. 앞 줄을 망가뜨릴 수 없는 추가, 아무거나 읽을 수 있는 형식, 그리고 다시 지어지는 작업 집합의 스키마에 안 매달리는 내구성. 한 줄에 JSON 객체 하나짜리 평범한 파일이 셋 다 줘. DB 재구축을 견디고, 새 스키마로 재생할 수 있고, 뭔가 이상해 보이면 눈으로 읽을 수 있어.

관계형 저장소도 자기 자리를 벌어. 기사 행, 선반, 캐시, 인덱스가 필요한 투영을 들고 있지. 분리는 의도적이야. 내구성 있는 척추는 파일, 질의 가능한 작업 집합은 DB, 그리고 둘이 어긋나면 파일이 이겨.

진짜인지 알려주는 시험

진짜 이벤트 로그랑 타임스탬프 컬럼 달린 표를 가르는 질문이 하나 있어. DB 를 통째로 지우고 모든 선반이랑 점수랑 통계를 로그만으로 다시 지을 수 있나? 답이 예면 투영은 마음대로 바꿔도 안전해. 틀린 투영은 고치고 다시 계산하면 되는 버그니까. 답이 아니오면, 어딘가에서 투영이 어떤 사실의 유일한 사본이 된 거고, 그 사실은 이제 그걸 쓴 코드만큼 부서지기 쉬워.

행위를 저장하고 상태는 유도해. 결론은 네가 미리 떠올린 질문 하나에 대한 답 하나야. 행위는 아직 안 떠올린 모든 질문의 원재료고. 미래의 순위 함수가 필요로 하는데 소급해서는 물어볼 수 없는 것들까지 포함해서.

Code

추가만 하는 척추, 그리고 그 위를 접어 만드는 투영·python
# One JSON object per line, appended, never rewritten.
# {"ts": "...", "type": "like", "article_id": 4812}
# {"ts": "...", "type": "save", "article_id": 4812}
# {"ts": "...", "type": "unsave", "article_id": 4812}

EVENT_KINDS = (
    "read", "like", "unlike", "dislike", "undislike",
    "save", "unsave", "share", "queue", "unqueue",
)


def append_event(kind: str, article_id: int, **extra) -> None:
    """The only write path into ground truth. A reversal is a NEW event,
    never an edit of the one it reverses -- that is what keeps a
    projection a pure function of the log."""
    if kind not in EVENT_KINDS:
        raise ValueError(f"unknown event kind: {kind}")
    row = {"ts": utcnow(), "type": kind, "article_id": article_id, **extra}
    with open(EVENT_LOG_PATH, "a", encoding="utf-8") as fh:
        fh.write(json.dumps(row, ensure_ascii=False) + "\n")


def project_state(log):
    """Every shelf, badge and score is one of these -- a fold over the
    log. Drop the database and this still returns the right answer."""
    state = {}
    for e in log:                      # chronological; later wins
        st = state.setdefault(e["article_id"], {})
        if e["type"] == "save":       st["saved_at"] = e["ts"]
        elif e["type"] == "unsave":   st["saved_at"] = None
        elif e["type"] == "like":     st["liked_at"], st["disliked_at"] = e["ts"], None
        elif e["type"] == "dislike":  st["disliked_at"], st["liked_at"] = e["ts"], None
    return state

External links

Exercise

네가 관리하는 시스템에서 불리언이나 상태 컬럼을 하나 골라. 사용자 행동으로 바뀌는 거면 뭐든. 결론만 저장했기 때문에 오늘 답할 수 없는 질문 세 개를 적어봐. 이어서 그 답 중 하나가 이미 필요한 데가 있는지, 그리고 나중에 덧댄 타임스탬프나 감사 표로 그걸 근사하고 있는지 확인해봐.
Hint
세 질문은 거의 항상 이거야. 언제 바뀌었나, 그전엔 뭐였나, 어떤 기간에 몇 개가 바뀌었나. 딱 저걸 답하려고 나중에 붙인 감사 표를 찾았다면 우연히 자란 로그를 찾은 거고, 아마 불완전할 거야. 원본이 되려고가 아니라 보고서 하나 채우려고 쓰였을 테니까.

Progress

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

댓글 0

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

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