“독서는 흐름이다”라는 문장은 결과였다

“독서는 흐름이다”라는 문장은 지금 보면 꽤 단단한 주장처럼 보인다. 마치 처음부터 그 개념을 중심에 두고 설계를 시작한 것처럼 느껴지기도 한다. 하지만 실제 과정은 전혀 그렇지 않았다. 이 문장은 출발점이 아니라, 여러 번의 설계 실패 이후에야 남은 결론에 가까웠다. 처음에는 그저 불편함을 해결하려는 의도만 있었고, 그 불편함을 어떻게 정의해야 하는지도 명확하지 않았다. 그래서 처음 설계는 직관과 경험에 기대어 빠르게 만들어졌다. 그리고 그 직관은 생각보다 오래 유지되지 못했다.

처음에는 단순히 “기록이 남지 않는다”는 문제를 해결하려 했다. 하지만 그 문제를 해결하기 위한 구조를 만들면서, 내가 실제로 무엇을 보고 싶은지 스스로도 명확히 설명하지 못하고 있다는 사실이 드러났다. 기록이 필요하다는 건 알았지만, 어떤 형태의 기록이어야 하는지는 정의되지 않았다. 그래서 자연스럽게 기존 서비스들이 사용하는 방식과 비슷한 구조를 따라가게 된다. 상태를 저장하고, 진행률을 업데이트하고, 완료 여부를 표시하는 방식이다. 이 접근은 틀리지 않아 보였고, 구현도 어렵지 않았다. 문제는 그 구조가 실제 사용 경험과 맞지 않는다는 점이 나중에야 드러났다는 것이다.

결국 “흐름”이라는 개념은 처음부터 있었던 것이 아니라, 기존 구조가 계속해서 질문에 답하지 못하는 상황에서 밀려 나온 결과였다. 왜 특정 날에는 읽지 않았는지, 왜 어떤 책은 중간에 멈췄는지, 왜 읽는 패턴이 일정하지 않은지 같은 질문들이 쌓이면서 기존 모델이 점점 설명력을 잃기 시작했다. 그때서야 상태가 아니라 시간과 변화의 연속으로 바라봐야 한다는 인식이 생겼다. 이 문장은 철학처럼 보이지만, 실제로는 설계가 더 이상 버티지 못했을 때 남은 최소한의 설명이었다. 그 출발점이 불완전했기 때문에, 이후의 모든 설계는 이미 틀린 기반 위에서 시작되고 있었다.

처음 설계는 왜 그렇게 자연스럽게 보였는가

초기 도메인 설계는 놀랄 만큼 단순했다. 책이라는 엔티티가 있고, 그 안에 상태와 진행률이 있으며, 읽기 행위는 세션으로 관리한다. 이 구조는 직관적이고, 설명하기도 쉽고, 구현 난이도도 낮다. 실제로 많은 서비스가 이와 유사한 모델을 사용하고 있기 때문에 낯설지도 않다. 오히려 이 구조를 의심하는 것이 더 부자연스럽게 느껴질 정도였다. 처음 설계를 그릴 때는 오히려 이 단순함이 장점이라고 생각했다. 불필요한 복잡성을 제거한, 깔끔한 구조라고 믿었다.

문제는 이 단순함이 실제 도메인을 제대로 반영한 결과가 아니라는 점이다. 단순해 보였던 이유는 개념을 명확히 분리하지 않았기 때문이다. 상태와 기록, 현재 값과 과거의 변화, 시간과 결과가 하나의 모델 안에 섞여 있었지만, 그 차이를 인식하지 못한 채 “하나의 필드로 표현 가능하다”고 가정해버렸다. 예를 들어 진행률은 책의 속성처럼 보이지만, 실제로는 시간에 따라 계속 변화하는 값이다. 그런데도 그것을 하나의 숫자로 고정해서 저장하는 순간, 이미 중요한 정보가 손실된다. 이 손실은 초기에는 눈에 띄지 않지만, 기능이 조금만 확장되면 바로 문제로 드러난다.

또 하나의 이유는, 이 구조가 대부분의 CRUD 중심 애플리케이션과 잘 맞기 때문이다. 상태를 저장하고, 값을 업데이트하고, 조회하는 방식은 개발자에게 매우 익숙하다. 그래서 자연스럽게 도메인도 그 틀에 맞춰 해석하게 된다. 하지만 독서라는 행위는 단순한 상태 변화로 환원하기 어렵다. 읽는다는 것은 특정 시점의 값이 아니라, 시간의 누적과 변화의 연속이다. 그럼에도 불구하고 처음 설계는 이 복잡한 개념을 단순한 상태 모델로 압축해버렸다. 그 결과, 구조는 깔끔했지만 설명력은 부족했다.

결국 이 초기 설계는 “잘못된 단순화”였다. 복잡한 도메인을 단순하게 만든 것이 아니라, 중요한 축을 제거한 채 단순한 형태로 보이게 만든 것이다. 그래서 처음에는 문제가 없어 보였지만, 실제 데이터를 다루기 시작하자마자 균열이 생기기 시작했다. 이 시점에서는 아직 구조가 완전히 무너지지는 않았지만, 이미 그 기반이 불안정하다는 신호는 충분히 나타나고 있었다. 그리고 그 첫 번째 신호가 바로 진행률이었다.

첫 번째 균열 — progress는 상태가 아니다

진행률(progressPct)을 처음 설계할 때는 거의 고민하지 않았다. 책을 읽고 있다면, 지금 몇 퍼센트까지 읽었는지를 나타내는 값이 필요하다는 것은 너무 당연해 보였기 때문이다. 그래서 자연스럽게 이 값을 Book에 포함시켰다. “이 책의 현재 상태”를 나타내는 필드라고 생각했고, 그 이상으로 깊게 생각하지 않았다. 하지만 실제 사용 시나리오를 하나씩 따라가다 보니, 이 가정이 얼마나 취약한지 금방 드러났다.

하루에 20%까지 읽고 세션을 종료한다. 다음 날 35%까지 읽고 다시 종료한다. 여기까지는 문제가 없다. 그런데 며칠 뒤, 사용자가 실수로 30%를 입력한다고 가정해보자. Book에 저장된 progress 값은 단순히 30%로 덮어써진다. 이전에 35%까지 읽었다는 사실은 사라진다. 어떤 날에는 거의 읽지 않았다는 정보도 남지 않는다. 결국 남는 것은 하나의 숫자뿐이다. 이 숫자는 현재 상태를 나타내는 것처럼 보이지만, 실제로는 과거의 정보를 지워버린 결과다. 나는 “현재 값”을 저장한 것이 아니라, “변화의 흔적”을 제거하고 있었던 셈이다.

이 지점에서 중요한 인식이 생긴다. 진행률은 단순한 상태 값이 아니라, 특정 시점에서의 결과라는 것이다. 즉, 진행률은 독립적으로 존재하는 값이 아니라, 세션이라는 시간 단위에 종속된 값이다. 세션이 끝날 때, 그 시점의 진행률이 기록되어야 의미가 생긴다. 그래야 변화의 흐름을 추적할 수 있다. 그렇지 않으면, 모든 변화는 마지막 값 하나로 압축되어버린다. 이건 단순한 데이터 손실이 아니라, 도메인의 본질을 잃어버리는 문제다.

이 작은 필드 하나가 전체 설계를 흔들기 시작했다. progress를 Book에 두는 순간, Book은 현재 상태뿐만 아니라 과거의 히스토리까지 암묵적으로 책임지게 된다. 하지만 그 책임은 제대로 수행되지 않는다. 결국 모델이 감당해야 할 역할과 실제 구현이 어긋나기 시작한다. 이 어긋남은 처음에는 눈에 잘 보이지 않지만, 기능이 쌓일수록 점점 커진다. 그리고 이 시점에서 하나는 분명해진다. 지금의 구조는 단순히 개선이 필요한 수준이 아니라, 근본적으로 다시 생각해야 하는 상태라는 것이다.

상태 모델의 본질적 한계 — “현재만 존재한다”

progress 문제는 단순한 구현 실수가 아니라, 모델 자체의 한계를 드러내는 신호였다. 처음에는 특정 필드를 잘못 둔 것처럼 보였지만, 조금만 더 깊이 들어가면 문제의 본질이 전혀 다른 곳에 있다는 것이 보인다. 나는 상태(state)라는 개념을 너무 당연하게 받아들이고 있었고, 그 상태가 도메인을 충분히 설명할 수 있다고 믿고 있었다. 하지만 상태라는 것은 언제나 특정 시점의 압축된 결과일 뿐이다. 그 안에는 변화의 과정이 존재하지 않는다. 상태는 결과를 표현하는 데는 유용하지만, 과정 자체를 담기에는 구조적으로 한계가 있다.

이 점을 이해하지 못한 채 설계를 진행하면, 결국 하나의 모델이 여러 시간대를 동시에 설명하려고 시도하게 된다. Book에 저장된 status와 progress는 현재 상태를 나타내지만, 동시에 과거의 흐름을 암묵적으로 포함해야 하는 역할까지 떠안게 된다. 그러나 상태 모델은 그 역할을 수행할 수 없다. 과거의 정보는 덮어쓰기 방식으로 사라지고, 변화의 맥락은 전혀 남지 않는다. 이 구조에서는 “왜 이 상태가 되었는가”라는 질문에 답할 수 없다. 상태는 항상 “지금”만을 말할 수 있고, 그 이전의 모든 과정은 제거된다.

이 한계는 단순히 독서 앱에만 해당하는 문제가 아니다. 상태 기반 모델은 대부분의 시스템에서 기본으로 사용되지만, 시간의 흐름이 중요한 도메인에서는 반드시 같은 문제를 만든다. 상태는 데이터를 압축하고, 그 압축 과정에서 의미를 잃는다. 그리고 그 손실은 점진적으로 쌓이다가, 어느 순간 구조 전체를 흔들기 시작한다. 나는 이 사실을 이론적으로 이해한 것이 아니라, 설계가 점점 설명력을 잃어가는 과정을 통해 체감하게 되었다. 결국 상태 모델은 틀린 것이 아니라, 적용할 수 없는 영역에 사용된 것이었다.

이 지점에서 중요한 변화가 생긴다. 더 이상 “어떻게 상태를 더 잘 관리할 것인가”를 고민하는 것이 아니라, “이 문제를 상태로 표현하는 것이 맞는가”를 다시 묻게 된다. 이 질문은 설계의 방향을 완전히 바꾼다. 그리고 그 순간부터, 기존 모델은 더 이상 확장 가능한 구조가 아니라, 반드시 붕괴해야 하는 구조로 보이기 시작한다. progress에서 시작된 균열은, 결국 상태 모델 전체를 의심하게 만드는 계기가 되었다.

두 번째 균열 — 이벤트 모델과의 충돌

상태 모델의 한계를 인식하기 시작했을 때, 나는 자연스럽게 다른 방향을 시도하게 되었다. 그 중 하나가 타임라인 기능이었다. 사용자가 언제 읽기 시작했고, 언제 멈췄으며, 어떤 시점에서 완독했는지를 시간 순서대로 보여주고 싶었다. 이 기능은 단순한 UI 요소처럼 보이지만, 실제로는 완전히 다른 모델을 요구한다. 상태가 아니라 사건(event)을 기록해야 하기 때문이다. 그래서 SESSION_STARTED, SESSION_ENDED, RECORD_COMPLETED 같은 이벤트를 정의하고, 이를 기반으로 타임라인을 구성하기 시작했다.

처음에는 이 접근이 기존 모델을 보완해줄 것처럼 보였다. 상태는 상태대로 유지하면서, 이벤트를 추가로 기록하면 더 풍부한 정보를 제공할 수 있을 것이라고 생각했다. 하지만 실제로 구현을 진행하면서, 이 두 모델이 서로 충돌한다는 사실이 드러났다. 가장 대표적인 사례가 세션 종료와 완독 이벤트였다. 대부분의 경우, 사용자가 책을 완독하는 순간은 세션이 종료되는 시점과 동일하다. 그렇다면 이 두 이벤트를 동시에 기록해야 하는가? 아니면 하나로 합쳐야 하는가? 이 질문은 단순한 구현 문제가 아니라, 모델 간의 경계가 불명확하다는 신호였다.

이 문제는 단순히 이벤트 개수가 많아지는 수준에서 끝나지 않았다. 이벤트 순서에 따라 결과가 달라지는 상황이 발생했고, 동일한 행위를 두 번 처리했을 때 다른 결과가 나오는 경우도 생겼다. 즉, 시스템이 idempotent하지 않게 되었다. 이는 데이터 정합성 문제로 이어질 수 있는 위험한 상태였다. 상태 모델과 이벤트 모델이 각각 다른 기준으로 데이터를 해석하면서, 하나의 행위가 두 개의 의미를 가지게 된 것이다. 이 충돌은 점점 더 많은 예외 케이스를 만들어냈고, 그 예외를 처리하기 위한 조건문이 늘어나면서 설계는 점점 복잡해졌다.

이 시점에서 나는 더 이상 “어떻게 이 둘을 잘 섞을 것인가”를 고민할 수 없게 되었다. 문제는 통합 방식이 아니라, 서로 다른 개념을 같은 레이어에 두고 있다는 것이었다. 상태는 결과를 표현하고, 이벤트는 과정을 기록한다. 이 둘은 역할이 다르며, 서로를 대체할 수 없다. 그런데도 하나의 모델 안에서 동시에 다루려고 했기 때문에 충돌이 발생한 것이다. 결국 이 균열은 구조적 문제였고, 단순한 리팩토링으로 해결할 수 있는 수준이 아니었다.

붕괴의 순간 — 모델이 질문을 감당하지 못한다

설계가 정말로 틀렸다는 것을 깨닫는 순간은, 코드가 동작하지 않을 때가 아니라 질문에 답할 수 없을 때다. 나는 점점 더 많은 질문을 던지기 시작했다. “이 사용자가 왜 이 시점에서 읽기를 멈췄는가?”, “완독 직전의 세션은 어떤 패턴을 가지고 있었는가?”, “읽지 않은 기간은 어떻게 표현해야 하는가?” 같은 질문들이다. 이 질문들은 기능 요구사항이라기보다는, 도메인을 이해하기 위한 시도였다. 하지만 기존 모델은 이 질문들에 제대로 답하지 못했다.

예를 들어 세션 종료와 완독이 동시에 발생하는 경우를 다시 생각해보자. 이 두 이벤트는 같은 시점에 발생하지만, 의미는 다르다. 세션 종료는 시간의 끝이고, 완독은 상태의 변화다. 그런데 기존 모델에서는 이 둘을 명확히 분리할 수 없었다. 하나의 이벤트로 합치면 정보가 손실되고, 두 개로 나누면 중복과 순서 문제가 발생한다. 어떤 선택을 하든 일관성을 유지하기 어려웠다. 이건 단순한 설계 선택의 문제가 아니라, 모델이 질문을 처리할 수 있는 구조를 가지고 있지 않다는 의미였다.

이런 상황이 반복되면서 하나의 패턴이 보이기 시작했다. 새로운 질문이 등장할 때마다, 기존 구조를 유지하려면 예외 처리를 추가해야 했다. 그리고 그 예외는 또 다른 예외를 낳았다. 결국 시스템은 점점 복잡해지지만, 설명력은 오히려 떨어진다. 데이터는 존재하지만, 그 데이터가 무엇을 의미하는지는 점점 불분명해진다. 이 상태에서는 기능을 추가하는 것이 아니라, 문제를 더 키우는 것에 가깝다. 설계는 더 이상 확장 가능한 기반이 아니라, 유지 비용만 증가시키는 구조가 된다.

이 지점에서 나는 선택해야 했다. 기존 구조를 유지한 채 계속 보완할 것인가, 아니면 근본적으로 다시 정의할 것인가. 그리고 그 선택은 어렵지 않았다. 이미 모델이 질문을 감당하지 못하고 있었기 때문이다. 이건 개선의 문제가 아니라, 붕괴의 문제였다. 결국 나는 기존 구조를 유지하는 것을 포기하고, 처음부터 다시 생각하기로 했다. 이 결정은 단순한 리팩토링이 아니라, 도메인을 바라보는 관점 자체를 바꾸는 계기가 된다.

재설계의 기준 — “역할 분리”

기존 모델이 더 이상 질문을 감당하지 못한다는 사실을 받아들이는 순간, 다음 단계는 자연스럽게 이어진다. 무엇을 바꿔야 하는지가 아니라, 어떤 기준으로 다시 설계해야 하는가를 정해야 한다. 이전까지의 문제는 특정 필드나 이벤트 정의의 실수가 아니었다. 서로 다른 개념들이 하나의 모델 안에서 뒤섞여 있었고, 그 결과 각 요소가 자신의 역할을 명확히 수행하지 못하고 있었다. 따라서 재설계의 출발점은 기능이 아니라 개념의 분리가 되어야 했다.

여기서 기준은 단순하다. 하나의 모델은 하나의 질문에만 답해야 한다. 이 원칙은 당연해 보이지만, 실제 설계에서는 자주 무너진다. Book은 상태를 나타내야 하는데, 동시에 진행률의 히스토리까지 암묵적으로 포함하려 했고, 세션은 시간 단위의 기록이어야 하는데 상태 변화까지 끌어안으려 했다. 이처럼 역할이 겹치기 시작하면, 어느 순간부터는 어떤 모델이 어떤 책임을 가지는지 설명할 수 없게 된다. 그리고 그 지점이 바로 설계 붕괴의 시작이다.

그래서 나는 기능을 나누는 대신, 질문을 나누기 시작했다. “지금 상태는 무엇인가?”, “언제 어떤 행동이 발생했는가?”, “시간의 흐름 속에서 어떤 변화가 있었는가?” 이 세 가지 질문은 서로 겹치지 않는다. 그리고 각각은 서로 다른 형태의 데이터를 요구한다. 상태는 단일 값으로 표현되지만, 시간은 구간으로 표현되어야 하고, 사건은 순서와 함께 기록되어야 한다. 이 차이를 인정하는 순간, 기존 모델을 유지하는 것이 오히려 비효율적으로 보이기 시작한다.

이 기준은 설계를 단순하게 만드는 대신, 더 엄격하게 만든다. 각 모델이 자신이 담당하지 않는 영역을 절대 침범하지 않도록 강제해야 하기 때문이다. 하지만 이 제약이 생기면서 오히려 구조는 안정되기 시작한다. 더 이상 하나의 모델이 여러 역할을 떠안지 않기 때문에, 새로운 기능이 추가되더라도 기존 구조를 흔들 필요가 없다. 결국 재설계의 핵심은 새로운 구조를 만드는 것이 아니라, 각 개념이 어디까지 책임져야 하는지를 명확히 정의하는 것이었다.

Record / Session / Event — 구조가 아니라 관점이다

이 기준을 바탕으로 도메인을 다시 나누기 시작했을 때, 세 가지 축이 자연스럽게 드러났다. 상태를 나타내는 Record, 시간을 나타내는 Session, 그리고 사건을 기록하는 Event였다. 이 세 가지는 새로운 모델처럼 보이지만, 사실은 이미 존재하던 개념을 분리한 결과에 가깝다. 이전에는 이 모든 것이 Book이라는 하나의 구조 안에 압축되어 있었고, 그 압축이 문제를 만들고 있었다. 이제는 그 압축을 풀고, 각 개념이 독립적으로 존재할 수 있도록 재배치하는 과정이었다.

ReadingRecord는 가장 단순하다. 이 모델은 오직 현재 상태만을 표현한다. 읽는 중인지, 완독했는지, 중단했는지만을 관리한다. 여기에는 시간 정보도 없고, 히스토리도 없다. 그 대신 상태에 대한 명확한 정의만 존재한다. ReadingSession은 완전히 다른 역할을 가진다. 이 모델은 언제 시작해서 언제 끝났는지, 그리고 그 세션에서 어떤 변화가 있었는지를 기록한다. 즉, 시간과 수치의 결합이다. 마지막으로 TimelineEvent는 사실 자체를 기록한다. 어떤 일이 발생했는지를 순서대로 남기고, 그 의미를 해석하지는 않는다.

여기서 중요한 점은, 이 세 모델이 서로를 대체하지 않는다는 것이다. Record는 상태를 설명하지만, 그 상태가 어떻게 만들어졌는지는 설명하지 않는다. Session은 과정을 기록하지만, 현재 상태를 결정하지 않는다. Event는 사건을 나열하지만, 그 사건의 결과를 계산하지 않는다. 이처럼 각 모델은 명확하게 제한된 역할을 가지며, 그 경계를 넘지 않는다. 이 구조는 처음에는 더 복잡해 보일 수 있지만, 실제로는 훨씬 단순하다. 왜냐하면 각 모델이 해야 할 일이 명확하기 때문이다.

이 재정의는 단순한 구조 변경이 아니라, 도메인을 바라보는 관점의 변화였다. 더 이상 “이 데이터를 어디에 저장할 것인가”를 고민하는 것이 아니라, “이 데이터는 어떤 종류의 정보인가”를 먼저 정의하게 된다. 그리고 그 정의가 명확해질수록, 모델 간의 충돌은 자연스럽게 사라진다. 이 시점에서 설계는 더 이상 불안정한 구조가 아니라, 확장 가능한 기반으로 바뀌기 시작한다.

progress 제거 — 도메인이 가벼워지는 순간

이 재설계 과정에서 가장 상징적인 변화는, progress를 Book에서 제거한 순간이었다. 처음에는 이 결정이 오히려 불편하게 느껴졌다. 진행률은 사용자에게 가장 직관적인 정보 중 하나이기 때문이다. 그런데 그 값을 Book에서 제거한다는 것은, 현재 상태를 표현하는 가장 간단한 방법을 포기하는 것처럼 보였다. 하지만 실제로는 그 반대였다. 이 제거는 기능을 줄이는 것이 아니라, 책임을 명확히 하는 과정이었다.

progress를 Session으로 이동시키는 순간, 그 값의 의미가 완전히 달라진다. 더 이상 “현재 얼마까지 읽었는가”를 나타내는 값이 아니라, “이 세션이 끝났을 때 어디까지 읽었는가”를 기록하는 값이 된다. 이 변화는 단순한 위치 이동이 아니라, 데이터의 성격을 바꾸는 결정이다. 이제 progress는 하나의 상태 값이 아니라, 시간 위에 놓인 여러 개의 점으로 존재하게 된다. 그리고 이 점들을 연결하면, 비로소 독서의 흐름이 드러난다.

Book은 이 과정에서 훨씬 가벼워진다. 더 이상 변화하는 값을 관리하지 않고, 오직 현재 상태만을 유지하면 된다. 이 단순화는 설계를 안정적으로 만든다. 이전에는 progress를 업데이트할 때마다 상태와 히스토리를 동시에 고려해야 했지만, 이제는 그런 부담이 사라진다. Session이 모든 변화를 책임지고, Record는 그 결과만을 반영한다. 이 역할 분리가 이루어지면서, 데이터 간의 관계도 훨씬 명확해진다.

이 경험을 통해 하나의 중요한 사실이 드러난다. 설계는 무엇을 추가하느냐보다, 무엇을 제거하느냐에 의해 결정된다. 처음에는 더 많은 정보를 담기 위해 모델을 확장하려 했지만, 실제로 구조를 안정시킨 것은 불필요한 책임을 제거하는 과정이었다. progress를 제거한 순간, 도메인은 더 단순해졌고, 동시에 더 많은 것을 설명할 수 있게 되었다. 이 역설적인 결과는, 잘못된 설계가 얼마나 많은 불필요한 복잡성을 만들어내는지를 보여준다. 그리고 이 시점에서, 새로운 구조는 더 이상 실험이 아니라, 하나의 방향으로 자리 잡기 시작한다.

설계는 코드가 아니라 문장으로 정의된다

모델을 분리하고 구조를 재정렬하는 과정까지는 비교적 빠르게 진행되었다. Record, Session, Event라는 세 축이 정리되면서, 더 이상 서로 다른 개념이 충돌하는 일은 크게 줄어들었다. 하지만 이상하게도, 여전히 설계가 완전히 안정되었다는 느낌은 들지 않았다. 코드 자체는 점점 정리되고 있었지만, 특정 상황에서 어떻게 동작해야 하는지에 대한 판단은 여전히 모호하게 남아 있었다. 그 모호함은 테스트를 작성하는 순간마다 드러났고, 예상하지 못한 케이스에서 계속 흔들렸다.

이 문제의 원인은 코드가 아니라 정의의 부재였다. 구조는 나뉘었지만, 그 구조가 어떤 규칙 아래에서 동작해야 하는지는 명확히 정리되어 있지 않았다. 예를 들어 “한 책에는 동시에 몇 개의 세션이 존재할 수 있는가”라는 질문에 대해, 코드에서는 자연스럽게 하나로 제한하고 있었지만, 그 이유를 설명할 수는 없었다. “progress 값은 null이 될 수 있는가”라는 질문도 마찬가지였다. 구현은 가능했지만, 도메인 관점에서 그것이 의미 있는지에 대한 판단은 흐릿했다. 이런 상태에서는 코드가 늘어날수록 불확실성도 함께 증가한다.

그래서 나는 설계를 코드가 아니라 문장으로 정의하기 시작했다. 각 모델이 어떤 제약을 가져야 하는지, 어떤 상황에서 어떤 동작을 보장해야 하는지를 하나씩 글로 풀어냈다. 이 과정은 생각보다 많은 것을 드러냈다. null을 허용할지 말지, 동일한 이벤트가 반복될 때 어떻게 처리할지, 특정 조건에서 데이터를 삭제할지 유지할지 같은 결정들이 단순한 구현 선택이 아니라 도메인 정의라는 사실이 분명해졌다. 문장으로 표현하지 못하는 규칙은, 코드에서도 일관되게 유지될 수 없었다.

이 변화 이후 설계는 훨씬 단단해졌다. 코드가 복잡해졌기 때문이 아니라, 오히려 반대로 모호함이 제거되었기 때문이다. 모든 정책이 명확하게 정의되면서, 새로운 기능을 추가하더라도 기존 구조를 다시 해석할 필요가 없어졌다. 결국 설계의 안정성은 코드의 품질이 아니라, 정의의 명확성에서 나온다는 것을 이 과정에서 확인하게 된다. 설계는 구현 이전에 이미 결정되어야 하고, 그 결정은 코드가 아니라 언어로 먼저 존재해야 한다.

이 글의 결론 — 설계는 “맞는 것”이 아니라 “남는 것”

이 지점까지 오면, 처음 설계를 다시 떠올리게 된다. 처음에는 분명히 논리적으로 맞다고 생각했던 구조였다. Book에 상태와 진행률을 두고, 세션을 연결하는 모델은 직관적이었고, 구현도 어렵지 않았다. 그 구조는 틀린 것처럼 보이지 않았다. 하지만 시간이 지나고, 더 많은 질문이 쌓이고, 더 많은 예외를 처리해야 하는 상황이 되자, 그 설계는 더 이상 유지될 수 없었다. 맞았던 설계가 아니라, 버틸 수 없었던 설계였다는 것이 드러난 것이다.

이 과정에서 중요한 것은, 무엇이 맞았는지를 찾는 것이 아니었다. 오히려 무엇이 끝까지 남을 수 있는지를 확인하는 과정에 가까웠다. progress를 제거하고, 모델을 분리하고, 정책을 명확히 정의하는 과정은 모두 기존의 가정을 하나씩 제거하는 과정이었다. 그 과정에서 살아남은 것만이 실제 구조가 된다. 설계는 추가되는 것이 아니라, 제거되면서 정제된다. 그리고 그 정제 과정은 한 번으로 끝나지 않는다. 계속해서 반복된다.

이 글은 도메인 설계를 완성한 이야기가 아니다. 오히려 설계가 얼마나 쉽게 틀릴 수 있는지, 그리고 그 틀림이 어떻게 드러나는지를 기록한 과정에 가깝다. 처음의 가정은 대부분 틀렸고, 그 틀림은 기능이 아니라 질문을 통해 드러났다. 이 경험은 이후의 설계에도 그대로 이어진다. 앞으로도 같은 방식으로 가정을 세우고, 그것이 깨지는 과정을 반복하게 될 것이다.

다음 글에서는 이 재정렬된 구조 위에서도 여전히 남아 있던 문제, 특히 세션 모델이 가지고 있던 또 다른 한계를 다룬다. 구조를 나눴다고 해서 모든 문제가 해결된 것은 아니었고, 오히려 더 깊은 수준의 질문이 등장하기 시작했다. 이 시리즈는 그 질문들이 어떻게 다시 설계를 흔들었는지를 계속 따라간다.