
Third Tool의 Card는 두 가지 운영 위치를 가집니다.
처음에는 Card 엔티티 안에 status 컬럼 하나만 두고 현재 상태를 덮어쓰는 방식으로 구현했습니다. 단순하고 빨랐지만, 이 구조에는 조용한 문제가 하나 있었습니다.
"이 카드, 언제 ARCHIVE로 보냈더라? 몇 번이나 왔다 갔다 했지?"
현재 상태만 알 수 있고, 그 상태가 어떻게 변해왔는지는 전혀 알 수 없었습니다.
// 기존 Card — status를 그냥 덮어씀
public void archive() {
this.status = CardStatus.ARCHIVE; // 이전 상태? 모름. 언제? 모름.
}
이 방식의 문제는 명확했습니다.
updatedDate만으로는 원인을 특정할 수 없습니다. Card 수정과 상태 변경이 updatedDate를 공유하기 때문입니다.리팩토링 후 두 객체의 책임을 명확하게 나눴습니다.
| Card | CardStatusHistory | |
|---|---|---|
| 책임 | 현재 위치만 관리 | 전환 이력만 기록 |
| 변경 가능 여부 | status 변경 가능 | 생성 후 불변 |
| 데이터 | status (현재값) | fromStatus, toStatus, changedAt |
Card는 "지금 어디 있는가"만 알면 됩니다. 이력은 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" 같은 의미 없는 이력이 쌓이는 것을 도메인 레벨에서 차단합니다.
@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가 독립적으로 처리합니다.
이전 글에서 다뤘던 포트와 어댑터 구조를 이력 관리에도 동일하게 적용했습니다.
// 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);
}
}
CardStatusHistoryAppender는 CardStatusHistoryRepository 인터페이스만 알고 있습니다. 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를 열어볼 이유가 없습니다.
미래 기능의 토대
현재는 단순 저장만 하지만, 이 테이블을 기반으로 다음 기능이 가능해집니다.
상태를 단순히 덮어쓰는 것과 이력으로 남기는 것은 당장은 차이가 없어 보입니다. 하지만 서비스가 성장할수록 "과거에 무슨 일이 있었는가"를 묻는 순간이 반드시 옵니다.
Third Tool에서 CardStatusHistory를 분리한 핵심 이유는 단 하나입니다.
Card는 지금 어디 있는지만 알면 된다. 어떻게 여기까지 왔는지는 다른 객체가 기억한다.
책임을 명확히 나누면 각 객체가 단순해지고, 나중에 필요한 기능을 추가할 때 기존 코드를 건드리지 않아도 됩니다. 이번 리팩토링이 그 작은 증거입니다.
리팩토링 회고 끝!
최종 수정일: 2026-03-27