규칙에 주인이 없으면 버그가 된다 — 재독 기능의 객체지향 회고
완독한 책에 “이어서 인증하기"를 눌렀더니, 시작 페이지가 353쪽으로 채워졌다. 책은 총 352쪽이다. 끝 페이지는 총쪽수를 넘을 수 없으니, 이 화면에서는 어떤 입력으로도 저장이 불가능하다. 사용자가 갇힌 것이다.
이 글은 그 버그가 왜 “실수"가 아니라 구조의 필연이었는지 — 객체지향 관점에서 무엇이 잘못돼 있었고 어떻게 고쳤는지에 대한 회고다.
배경: 재독 기능의 데이터 모델
매일열장은 하루 한 문장을 남기는 독서 습관 앱이다. 서재의 책은 네 가지 상태를
가진다 — reading(읽는 중) / wishlist(찜) / stopped(중단) / finished(완독).
재독(다시 읽기) 기능을 넣으면서 핵심 결정이 하나 있었다. “지금 무엇을 하는가"와
“완독한 적이 있는가"는 다른 개념이라는 것. 완독한 책을 다시 읽기 시작하면 상태는
reading으로 돌아가고 진행률은 0부터 다시 시작하지만, 완독 이력(완독 탭, 배지,
회차)은 사라지면 안 된다. 그래서 상태와 별개로 완독 회차 컬럼을 분리했다:
| 필드 | 의미 |
|---|---|
status |
지금 무엇을 하는가 (현재) |
progress_page |
현재 회차의 진행 쪽수 |
finished_at |
최신 완독 시점 |
finished_count |
완독 회차 — 이력 (재독해도 보존) |
그리고 전이 규칙이 세 갈래다:
- 재독 시작:
finished → reading, 진행 0으로 리셋, 이력은 보존 - 수동 정정(사용자가 상태를 직접 변경): 완독 진입 시 회차 +1, 이탈 시 −1 — 왕복해도 회차가 불어나지 않는 ±1 대칭
- 인증 반영: 진행은 max로 전진, 찜·중단은 읽는 중으로 승격, 끝 페이지 도달 시 완독 처리 + 회차 +1 (재완독이면 2회독)
여기까지는 괜찮았다. 문제는 이 규칙들이 어디에 사는가였다.
사고: 353쪽의 막다른 길
인증 플로우의 시작 페이지는 “지난 진행 + 1"로 미리 채워진다. 완독한 책은 진행이 총쪽수(352)에 가 있으니 프리필이 353이 된다. 그래서 완독한 책은 인증 전에 반드시 재독 전환(진행 리셋)을 거쳐야 한다.
이 “반드시"를 코드에 넣을 때, 나는 책 상세 화면의 버튼 하나만 고쳤다. 그런데 인증으로 들어가는 문은 앱 전체에 여덟 개였다:
기록 탭의 케밥 메뉴와 한마디 상세 화면은 그 지식 없이 완독 책을 곧장 인증 플로우로 보냈고, 사용자는 353쪽에서 갇혔다. 배포 당일 실사용에서 발견됐다.
원인 1: 물어보고 각자 처리하기 (Tell, Don’t Ask 위반)
첫 번째 문제는 클라이언트 쪽 구조다. 각 화면이 서재 상태를 물어보고(ask), 그 답에 따라 각자 재독 절차를 오케스트레이션해야 했다:
화면마다 반복되는 코드:
상태 = 책장에서_조회(bookId)
if 상태 == 완독:
확인 다이얼로그 → 재독 API → 캐시 갱신 → 인증 진입
else:
인증 진입
이 구조에서 “완독이면 재독을 거친다"는 지식은 특정 화면의 소유물이다. 지식이 필요한 화면이 하나 늘 때마다 복사해야 하고, 복사를 잊으면 곧 버그다. 여덟 개의 문이 있는데 규칙을 아는 문지기가 한 명뿐이었던 셈이다.
객체지향의 오래된 격언 Tell, Don’t Ask가 정확히 이 상황을 겨냥한다 — 상태를 물어보고 바깥에서 판단하지 말고, 판단을 아는 객체에게 시켜라. 처방은 진입 로직 전체를 훅 하나로 캡슐화하는 것이었다:
// useStartReading — "이 책 읽기"의 단일 진입점
const { ctaFor, startReading } = useStartReading();
// 화면은 이제 이 한 줄이 전부다. 판단도, 절차도 모른다.
startReading({ id: bookId, title, coverUrl });
훅 내부가 서재 조회, 완독 분기, 확인 다이얼로그, 재독 API 호출, 캐시 갱신, 인증 진입까지 전부 처리한다. 이제 아홉 번째 진입점이 생겨도 규칙을 몰라도 된다 — 같은 부류의 버그가 “조심해서” 막는 게 아니라 구조적으로 막힌다.
원인 2: 빈약한 도메인 모델 (서버)
두 번째 문제는 서버에 있었고, 더 근본적이다. BookshelfItem 엔티티는 필드만
가진 데이터 그릇이었고, 앞서 정리한 전이 규칙 — 회차 ±1 대칭, 이력 보존,
승격, 재완독 — 은 전부 라우터(HTTP 핸들러) 세 곳에 흩어져 있었다:
# PATCH 라우터 — 수동 정정의 ±1 규칙이 여기 직접
was_finished = item.status == BookshelfStatus.finished
item.status = payload.status
if payload.status == BookshelfStatus.finished:
if not was_finished:
item.finished_count += 1
if item.finished_at is None:
item.finished_at = datetime.now(UTC)
else:
if was_finished:
item.finished_count = max(0, item.finished_count - 1)
item.finished_at = None
# reread 라우터 — 리셋·보존 규칙이 여기 직접
item.status = BookshelfStatus.reading
item.progress_page = 0
# 인증 라우터 — 승격·재완독 규칙이 또 여기 직접
if item.status in (BookshelfStatus.wishlist, BookshelfStatus.stopped):
item.status = BookshelfStatus.reading
if book.total_pages and page_end >= book.total_pages and item.status != finished:
item.status = BookshelfStatus.finished
item.finished_at = datetime.now(UTC)
item.finished_count += 1
이것이 이른바 **빈약한 도메인 모델(anemic domain model)**이다. 겉보기에 엔티티 클래스는 있지만, 행동과 불변식이 전부 바깥에 있어 사실상 구조체다. 무엇이 문제인가:
- 불변식에 주인이 없다. “완독 왕복이 회차를 불리면 안 된다"는 규칙을 지키는 책임이 어느 코드에도 명시돼 있지 않다. 세 라우터가 각자 암묵적으로 협조할 뿐이다.
- 네 번째 조작자를 막을 수 없다. 내일 누군가(혹은 석 달 뒤의 나) 새
엔드포인트에서
item.status = ...를 직접 쓰면, 컴파일러도 테스트도 그 시점엔 경고하지 않는다. 회차는 조용히 어긋난다. - 규칙의 전체상을 볼 수 있는 파일이 없다. 전이 규칙을 이해하려면 라우터 세 개를 다 읽고 머릿속에서 합쳐야 한다.
처방은 교과서적이다 — 행동을 데이터 옆으로. 전이 로직을 엔티티 메서드로 옮기고, 라우터에는 HTTP 관심사(소유 검증, 전제 검증, 에러 코드)만 남겼다:
class BookshelfItem(Base):
# ... 필드 정의 ...
def start_reread(self) -> None:
"""재독 시작 — 새 회차. 완독 이력(finished_count·finished_at)은
보존하고 진행만 0으로 리셋한다 (→ 인증 시작 프리필이 1쪽)."""
self.status = BookshelfStatus.reading
self.progress_page = 0
def correct_status(self, status: BookshelfStatus) -> None:
"""수동 정정 — 완독 진입 +1 / 이탈 −1. 왕복이 회차를 불리지 않는
±1 대칭. "새 회차 시작"은 start_reread가 맡는다 (의미가 다르다)."""
was_finished = self.status == BookshelfStatus.finished
self.status = status
if status == BookshelfStatus.finished:
if not was_finished:
self.finished_count += 1
if self.finished_at is None:
self.finished_at = datetime.now(UTC)
else:
if was_finished:
self.finished_count = max(0, self.finished_count - 1)
self.finished_at = None
def advance(self, page_end: int, total_pages: int | None) -> bool:
"""인증 1건 반영 — 진행 max 전진, 찜·중단 승격, 끝 도달 시
완독 +회차. 새로 완독됐을 때만 True (축하·알림 트리거)."""
self.progress_page = max(self.progress_page, page_end)
if self.status in (BookshelfStatus.wishlist, BookshelfStatus.stopped):
self.status = BookshelfStatus.reading
if (total_pages is not None and page_end >= total_pages
and self.status != BookshelfStatus.finished):
self.status = BookshelfStatus.finished
self.finished_at = datetime.now(UTC)
self.finished_count += 1
return True
return False
라우터는 이렇게 줄어든다:
# PATCH — 검증하고, 시킨다
item.correct_status(payload.status)
# reread — 전제(완독 상태)만 확인하고, 시킨다
if item.status != BookshelfStatus.finished:
raise APIError(422, "not_finished", "완독한 책만 다시 읽기를 시작할 수 있습니다.")
item.start_reread()
# 인증 — 39줄이던 헬퍼가 위임 한 줄로
return item.advance(page_end, book.total_pages)
동작은 하나도 바뀌지 않았다 — 서버 계약 테스트 238개, 모바일 테스트 187개가 수정 없이 그대로 통과한다. 바뀐 것은 규칙이 사는 곳뿐이다.
왜 처음부터 이렇게 안 했나
변명이 아니라 진단으로 적어둔다. 이 서버는 의도적으로 “얇은 라우터 + 순수 함수” 스타일이고, 1인 개발 규모에서 그 선택 자체는 옳다. 풀 DDD 애그리거트는 과하다. 함정은 규칙이 자라는 속도였다 — 처음엔 라우터 한 곳의 if문 두 줄 이던 것이, 기능(승격, 재독, 회차)이 붙으며 세 곳의 암묵적 협조로 자랐는데도 구조는 그대로였다. 빈약한 모델은 규칙이 한 곳에 있을 땐 문제가 아니다. 같은 필드를 두 번째 코드가 만지는 순간부터 문제다.
교훈
- 불변식은 데이터 옆에 산다. 같은 필드 묶음을 두 곳 이상에서 조작하기 시작하면, 그 시점이 엔티티 메서드(또는 도메인 서비스)로 모을 시점이다.
- 상태를 물어보고 바깥에서 분기하는 코드가 N곳에 복제되면, N+1번째가 버그다. 판단을 아는 객체를 만들어 시켜라 (Tell, Don’t Ask).
- “동작 무변경 리팩터링"의 증거는 테스트다. 계약 테스트가 촘촘하면 구조를 옮기는 일이 두렵지 않다 — 이번에도 테스트 수정 0건이 리팩터링의 정당성을 증명했다.
버그는 코드 두 줄이 빠져서 난 것이 아니라, 그 두 줄이 어디에 있어야 하는지를 정하지 않아서 났다. 규칙에 주인을 만들어 주는 것 — 그게 이번 회고의 전부다.