[third tool] 기술 블로그 - Card 변경 이력 분리

junsung kim·2026년 3월 27일

[project]- thirdTool

목록 보기
24/33

목차

Card 상태 이력을 분리 테이블로 관리하기 — CardStatusHistory 설계 결정기

들어가며

Third Tool의 Card는 두 가지 운영 위치를 가집니다.

  • ON_FIELD — 지금 집중 반복이 필요한 전면 노출 구간
  • ARCHIVE — 배경 지식으로 보관된 후방 대기 구간

처음에는 Card 엔티티 안에 status 컬럼 하나만 두고 현재 상태를 덮어쓰는 방식으로 구현했습니다. 단순하고 빨랐지만, 이 구조에는 조용한 문제가 하나 있었습니다.

"이 카드, 언제 ARCHIVE로 보냈더라? 몇 번이나 왔다 갔다 했지?"

현재 상태만 알 수 있고, 그 상태가 어떻게 변해왔는지는 전혀 알 수 없었습니다.


기존 구조의 한계

// 기존 Card — status를 그냥 덮어씀
public void archive() {
    this.status = CardStatus.ARCHIVE;  // 이전 상태? 모름. 언제? 모름.
}

이 방식의 문제는 명확했습니다.

  • 이력 추적 불가: ON_FIELD → ARCHIVE 전환이 몇 번 일어났는지 알 수 없습니다.
  • 시점 데이터 없음: 언제 상태가 바뀌었는지 updatedDate만으로는 원인을 특정할 수 없습니다. Card 수정과 상태 변경이 updatedDate를 공유하기 때문입니다.
  • 감사(Audit) 불가: "지난달에 ARCHIVE로 보냈다가 다시 꺼낸 카드 목록"같은 조회가 원천적으로 불가능합니다.
  • RAG 서버 연동 고려: 추후 학습 패턴 분석이나 개인화 추천에 활용하려면 상태 변화 시계열 데이터가 반드시 필요합니다.

설계 결정 — Card와 CardStatusHistory의 책임 분리

리팩토링 후 두 객체의 책임을 명확하게 나눴습니다.

CardCardStatusHistory
책임현재 위치만 관리전환 이력만 기록
변경 가능 여부status 변경 가능생성 후 불변
데이터status (현재값)fromStatus, toStatus, changedAt

Card는 "지금 어디 있는가"만 알면 됩니다. 이력은 CardStatusHistory가 전담합니다.


도메인 모델 — CardStatusHistory

@Entity
@Table(name = "card_status_history")
public class CardStatusHistory {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "card_status_history_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "card_id", nullable = false, updatable = false)
    private Card card;

    @Enumerated(EnumType.STRING)
    @Column(name = "from_status", nullable = false, updatable = false, length = 20)
    private CardStatus fromStatus;

    @Enumerated(EnumType.STRING)
    @Column(name = "to_status", nullable = false, updatable = false, length = 20)
    private CardStatus toStatus;

    @CreationTimestamp
    @Column(name = "changed_at", nullable = false, updatable = false)
    private LocalDateTime changedAt;
}

중요한 설계 포인트 세 가지입니다.

1. 모든 컬럼에 updatable = false
이력은 생성 후 절대 수정되지 않습니다. 이력을 수정하는 것 자체가 감사 데이터의 신뢰를 무너뜨립니다.

2. 팩토리 메서드의 접근 제어

// package-private — CardStatusHistoryAppender에서만 호출 가능
static CardStatusHistory of(Card card, CardStatus fromStatus, CardStatus toStatus) {
    if (fromStatus == toStatus) {
        throw CardDomainException.of(
            ErrorCode.INVALID_INPUT,
            "fromStatus와 toStatus가 동일합니다."
        );
    }
    return new CardStatusHistory(card, fromStatus, toStatus);
}

of()를 package-private으로 선언해 외부에서 직접 생성할 수 없게 막았습니다. 이력 생성의 진입점은 CardStatusHistoryAppender 하나뿐입니다.

3. 동일 상태 전환 방어
fromStatus == toStatus이면 예외를 던집니다. "ON_FIELD → ON_FIELD" 같은 의미 없는 이력이 쌓이는 것을 도메인 레벨에서 차단합니다.


CardStatusHistoryAppender — 이력 기록 전담 도메인 서비스 - 도메인 서비스의 활용

@Component
@RequiredArgsConstructor
public class CardStatusHistoryAppender {

    private final CardStatusHistoryRepository historyRepository;

    public void append(Card card, CardStatus fromStatus, CardStatus toStatus) {
        if (fromStatus == toStatus) {
            // 멱등성 처리 — 상태가 바뀌지 않으면 이력 생성 불필요
            return;
        }
        CardStatusHistory history = CardStatusHistory.of(card, fromStatus, toStatus);
        historyRepository.save(history);
    }
}

CardStatusHistoryAppender가 존재하는 이유는 이력 생성 규칙을 한 곳에 모으기 위해서입니다. Application Service에서 직접 CardStatusHistory.of()를 호출하면 이 검증 로직이 서비스 여기저기에 흩어집니다.

호출 흐름은 다음과 같습니다.

CardCommandService
    └── card.archive()              ← Card 현재 상태 변경
    └── appender.append(            ← 이력 기록 *** 
            card,
            CardStatus.ON_FIELD,    ← fromStatus
            CardStatus.ARCHIVE      ← toStatus
        )

Card는 자신의 상태만 바꾸고, 이력 기록은 Appender가 독립적으로 처리합니다.


영속성 레이어 — Repository / Adapter 패턴(포트와 어댑터- 의존성 관리 측면)

이전 글에서 다뤘던 포트와 어댑터 구조를 이력 관리에도 동일하게 적용했습니다.

// Outbound Port — 도메인이 선언하는 인터페이스
public interface CardStatusHistoryRepository {
    CardStatusHistory save(CardStatusHistory history);
}

// Adapter — JPA 구현체
@Repository
@RequiredArgsConstructor
public class CardStatusHistoryRepositoryAdapter
        implements CardStatusHistoryRepository {

    private final CardStatusHistoryJpaRepository jpaRepository;

    @Override
    public CardStatusHistory save(CardStatusHistory history) {
        return jpaRepository.save(history);
    }
}

CardStatusHistoryAppenderCardStatusHistoryRepository 인터페이스만 알고 있습니다. JPA 구현체가 바뀌어도 도메인 서비스는 영향을 받지 않습니다.


분리로 얻은 것들

추적 가능한 이력 데이터

-- 특정 카드의 상태 전환 이력 전체 조회
SELECT from_status, to_status, changed_at
FROM card_status_history
WHERE card_id = 42
ORDER BY changed_at;

-- 결과
-- ON_FIELD → ARCHIVE  2025-03-01 09:12
-- ARCHIVE  → ON_FIELD 2025-03-15 14:30
-- ON_FIELD → ARCHIVE  2025-03-27 11:05

Card의 updatedDate만 봤을 때는 불가능했던 조회입니다.

Card 엔티티의 단순함 유지

Card는 현재 상태만 관리합니다. 이력 관련 필드나 로직이 Card 안으로 흘러들어오지 않습니다. 이력이 생기더라도 Card를 열어볼 이유가 없습니다.

미래 기능의 토대

현재는 단순 저장만 하지만, 이 테이블을 기반으로 다음 기능이 가능해집니다.

  • 학습 패턴 분석: "이 사용자는 평균 며칠 만에 ARCHIVE로 보내는가"
  • RAG 서버 연동: 상태 변화 시계열을 컨텍스트로 활용한 카드 추천
  • 복습 알고리즘: ON_FIELD 복귀 빈도 기반 난이도 추정

마치며

상태를 단순히 덮어쓰는 것과 이력으로 남기는 것은 당장은 차이가 없어 보입니다. 하지만 서비스가 성장할수록 "과거에 무슨 일이 있었는가"를 묻는 순간이 반드시 옵니다.

Third Tool에서 CardStatusHistory를 분리한 핵심 이유는 단 하나입니다.

Card는 지금 어디 있는지만 알면 된다. 어떻게 여기까지 왔는지는 다른 객체가 기억한다.

책임을 명확히 나누면 각 객체가 단순해지고, 나중에 필요한 기능을 추가할 때 기존 코드를 건드리지 않아도 됩니다. 이번 리팩토링이 그 작은 증거입니다.

리팩토링 회고 끝!

최종 수정일: 2026-03-27

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

0개의 댓글