[Third tool] ReviewSession의 분리

junsung kim·4일 전

[project]- thirdTool

목록 보기
26/27

ReviewSession을 도메인으로 분리했을 때 — 설계 판단과 트레이드오프

Card와 ReviewSession을 왜 나눴는가, 그리고 그 대가는 무엇인가


📚 목차

도메인 트레이드오프 기록

ThirdTool을 개발하면서 학습 흐름을 설계할 때 가장 오래 고민한 지점이 있다.

"리뷰 진행 상태를 Card 안에 둘 것인가, 아니면 ReviewSession/CardReview로 분리할 것인가."

결론부터 말하면 분리를 선택했다. 그 판단의 근거와 실제로 맞닥뜨린 단점을 함께 기록해둔다.


도메인 구조 한눈에 보기

ReviewSession
 └── CardReview (N)
       └── Card (참조)
  • Card : 학습 단위. status, viewCount, lastViewedAt만 관리한다.
  • CardReview : 특정 세션 안에서 카드 한 장의 진행 상태. reviewStep, comparingStartedAt 등을 갖는다.
  • ReviewSession : 한 번의 학습 세션. CardReview들의 흐름을 조율한다.

장점

1. 관심사가 명확하게 분리된다

Card는 "이 카드를 몇 번 봤는가", "마지막으로 언제 봤는가"라는 영속적 이력에만 책임진다.

"지금 이 카드가 회상(RECALLING) 단계인지 비교(COMPARING) 단계인지"는 Card가 알 필요가 없다. 이건 세션 안에서만 유효한 일시적 상태이기 때문이다.

ReviewSession/CardReview가 없었다면 Card 안에 이런 필드가 들어가야 했을 것이다.

// 분리하지 않았을 때의 Card
public class Card {
    private ReviewStep reviewStep;       // 회상? 비교?
    private LocalDateTime comparingStartedAt; // 비교 시작 시각
    // ... 이미 Card가 너무 많은 걸 알고 있다
}

Card가 "학습 단위"이면서 동시에 "리뷰 진행 상태"까지 책임지는 구조는 SRP(단일 책임 원칙) 위반이다.


2. 같은 Card를 여러 세션에서 독립적으로 다룰 수 있다

CardReview는 ReviewSession에 종속된다. 덕분에 이런 상황이 자연스럽게 해결된다.

세션 A → 카드 #3 : COMPARING 단계
세션 B → 카드 #3 : RECALLING 단계  ← 완전히 독립적

Card 자체에 reviewStep을 뒀다면 두 세션이 같은 필드를 공유하므로 상태 충돌이 발생한다.
현재 구조에서는 이 문제가 구조적으로 불가능하다.


3. CardReview가 통계 확장의 근거가 된다

comparingStartedAt이 CardReview에 있기 때문에, 나중에 이런 분석이 가능해진다.

  • "이 카드를 회상부터 비교까지 몇 초 걸렸는가"
  • "사용자별 평균 회상 소요 시간 추이"

Card에 이 필드를 뒀다면 세션마다 덮어쓰여서 이전 기록이 사라진다. CardReview는 세션별로 각각 한 행이 생기므로 시계열로 쌓이는 로그처럼 동작한다.


4. finished 필드가 컬렉션 로딩 없이 완료를 판단한다

// 분리하지 않았을 때 — 컬렉션 초기화 필요
boolean isFinished = currentIndex >= cardReviews.size(); // Lazy 로딩 발생

// 현재 구조 — 컬럼 하나로 판단
boolean isFinished = this.finished; // SELECT 없음

세션 목록 조회처럼 "완료 여부만 필요한 상황"에서 불필요한 JOIN을 피할 수 있다. 이건 성능 측면에서도, JPA Lazy 로딩 함정을 피한다는 측면에서도 유효하다.


단점

1. 트랜잭션 경계가 복잡해진다

PATCH /reviews/{sessionId}/next(다음 학습 카드 요청 api) 요청 하나에서 실제로 일어나는 일을 추적하면 이렇다.

ReviewSession.moveToNext()
ReviewSession.recordCurrentCardView()
  └── CardReview.recordView()
        └── Card.recordView()        ← card 업데이트 (viewCount, lastViewedAt)
archiveCard()                        ← card 업데이트 (status)
                                     ← CardStatusHistory 저장
cardRepository.save(card)

한 요청에서 ReviewSession, Card, CardStatusHistory 세 Aggregate를 건드린다.

지금은 단일 트랜잭션으로 처리하고 있어서 일관성은 보장된다. 하지만 Card BC와 Review BC를 물리적으로 분리하는 순간, 이 부분이 가장 먼저 문제가 된다. Eventually Consistent 방식으로 전환하려면 Domain Event 기반 처리가 필요해진다.(한 트랜잭션에서 여러 애그리거트를 조절 이슈)


2. isLastView 값을 세션 레이어에서 관리해야 한다

Card는 자신의 viewCount만 알고 있다. "내가 마지막 노출이다"라는 사실은 ReviewSession 레이어에서 계산해서 Application Service로 올려줘야 한다.

// ReviewSession
public boolean recordCurrentCardView() {
    CardReview current = getCurrentCardReview();
    boolean isLastView = current.recordView(); // CardReview → Card 위임
    return isLastView; // 세션 레이어가 들고 있어야 함
}

startComparing() 응답에도 isLastView가 필요한데, 이 시점에는 RECALLING에서 결정된 값을 다시 읽어야 해서 resolveIsLastView()를 따로 만들었다. Card에 이 상태를 직접 저장하지 않는 이상 이 흐름은 다소 번거롭다.


3. CardReview 행이 계속 쌓인다

세션마다 카드 수만큼 CardReview 행이 생성된다. 유저가 세션을 자주 시작할수록 card_review 테이블이 빠르게 커진다.

유저 1명 × 카드 50장 × 세션 30회 = CardReview 1,500행

지금은 세션 삭제 정책이 없어서 이 데이터가 무한정 쌓인다. 추후 세션 만료 정책이나 아카이빙 전략이 필요하다.


설계 판단의 핵심

이 설계의 핵심 전제는 하나다.

"리뷰 흐름 상태는 세션에 종속된다."

Card는 학습 단위로서의 영속적인 상태(몇 번 봤는가, 어디 있는가)만 가진다.
CardReview는 특정 세션 안에서의 일시적인 진행 상태를 가진다.

이 분리가 Card BC와 Review BC의 경계를 만들어 주고, 통계 확장 가능성을 열어준다.

단점인 트랜잭션 복잡도와 데이터 누적은 지금 단계에서는 감수할 수 있는 수준이다. 하지만 이 트레이드오프를 모르고 선택한 것과, 알고 선택한 것은 완전히 다른 이야기다.


마치며

도메인을 분리하면 코드가 깔끔해진다. 하지만 공짜가 아니다.

트랜잭션 경계, 데이터 누적, 상태를 어느 레이어에서 들고 있을지의 문제가 따라온다. 중요한 건 이 비용을 인식한 상태에서 선택했는가, 아닌가다.

ThirdTool은 지금 단일 트랜잭션 + 단일 DB 구조로 운영 중이다. Eventually Consistent가 필요해지는 시점이 오면, 이 설계가 그 전환의 기반이 된다.


ThirdTool — 간격 반복 학습 플랫폼 개발기

profile
edit하는 개발자! story 있는 삶

0개의 댓글