초기 Card 도메인 설계에서는 카드의 상태뿐 아니라 초기화, 복습 결과 처리, 주기 조정 등 여러 3day context와 permanent에 대해서 한번에 처리해야하는 문제가 발생 → 오브젝트 사상 하에 문제가 된다고 판단
카드를 생성하고 초기화하는 과정에서 Card 도메인 내에 두 가지 이상의 행동이 존재하고 있다는 사실을 인식했고, 이는 SRP(단일 책임 원칙) 위배라는 의심을 받기에 충분했습니다.
if(card.isArchived) 조건문으로 분기apply(Card) 안에서 공통 로직 → doApply() 메서드 분리CardBehavior 인터페이스로 행동을 추상화ThreeDayCardBehavior, PermanentCardBehavior로 분리
Card
├── 상태: dueDate, easeFactor, reps 등
└── 전략 주입: CardBehavior
CardBehavior (인터페이스)
├── ThreeDayCardBehavior
└── PermanentCardBehavior
CardBehaviorFactory → 상태(archived)로 전략 선택
public interface CardBehavior {
void processStudyResult(Card card, StudyResult result);
void resetDueGroup(Card card, String groupName);
}
public class ThreeDayCardBehavior implements CardBehavior {
public void processStudyResult(Card card, StudyResult result) {
switch (result) {
case AGAIN -> card.failReview(0.8f, 1);
case NORMAL -> card.restoreToGeneralStudy();
case GOOD -> card.successReview();
}
}
public void resetDueGroup(Card card, String groupName) { }
}
public class PermanentCardBehavior implements CardBehavior {
public void processStudyResult(Card card, StudyResult result) { }
public void resetDueGroup(Card card, String groupName) {
if (groupName == null || groupName.isBlank()) {
card.keepCurrentDue();
return;
}
switch (groupName) {
case "3day", "1week", "2week", "1month" -> card.resetDueGroup(groupName);
default -> throw new IllegalArgumentException("잘못된 그룹 선택: " + groupName);
}
}
}
public class CardBehaviorFactory {
private static final CardBehavior THREE_DAY = new ThreeDayCardBehavior();
private static final CardBehavior PERMANENT = new PermanentCardBehavior();
public static CardBehavior from(Card card) {
return card.isArchived() ? PERMANENT : THREE_DAY;
}
}
@Transactional
public void processStudyResult(Long cardId, StudyResult result) {
Card card = cardRepository.findById(cardId).orElseThrow();
CardBehavior behavior = CardBehaviorFactory.from(card);
behavior.processStudyResult(card, result);
}
| 항목 | 설명 |
|---|---|
| SRP 유지 | Card는 상태, 행동은 전략에서 분리 |
| 캡슐화 | 전략은 Card의 메서드만 호출 (내부 필드 접근 없음) |
| 테스트 용이 | 전략 단위로 독립 테스트 가능 |
| 유지보수성 | 새로운 행동 추가 시 OCP 만족, Card 수정 없음 |
Card는 상태만 가진 순수한 객체이고, 다양한 행동은 전략 객체에 위임됨으로써 객체지향의 원칙을 충실히 지켰습니다. 이 구조는 학습 도구의 복잡한 상태 흐름을 유연하고 확장 가능하게 만들어주는 핵심 설계 전략이 됩니다.
전략 패턴으로 바뀌고 좋았던 점들
이 문제를 해결하기 위해 Strategy 패턴을 도입했습니다.
즉, "카드가 어떻게 복습되어야 하는가"에 대한 행동을 도메인 객체가 결정하지 않고, 전략 객체에 위임한 것입니다.
public interface StudyPolicy {
void applyTo(Card card);
}
이제 카드 객체는 자신이 복습되어야 하는 방식에 따라 적절한 StudyPolicy 전략을 주입받고, 전략이 행동을 수행하게 됩니다.
Strategy 패턴은 복잡도가 커질수록 빛을 발합니다.
| 기존 구조 | 전략 패턴 적용 후 구조 |
|---|---|
여러 if/else로 정책 분기 | 각 정책은 독립된 클래스로 구현됨 |
| 변경 시 기존 도메인도 수정 필요 | 변경 시 전략 클래스만 수정하면 됨 |
| 확장이 어려움 | 전략 클래스를 추가만 하면 됨 |
즉, 새로운 학습 방식이나 카드의 특수한 학습 규칙이 생기더라도, 전략 클래스 하나만 추가하면 되기 때문에 유지보수가 대폭 쉬워집니다.