
이 글은 개인 프로젝트 Third Tool(Cornell Notes 기반 학습 카드 시스템)에 Spring 애플리케이션 이벤트를 도입하면서 겪은 고민의 흔적입니다.
"왜 이벤트를 써야 하는가"보다 "이 프로젝트의 어느 지점에 이벤트가 필요했는가" 에 집중했습니다.
Third Tool의 핵심 도메인은 Card다.
Card 한 장엔 MainNote(학습 맥락), KeywordCue(회상 단서), Summary(핵심 압축) 세 파트가 있고,
사용자는 이 구조를 따라 학습 → 회상(Review) → 요약 의 순서로 카드를 소화한다.
Card Review가 완료되는 시점, 처음엔 이렇게 짰다.
@Service
@RequiredArgsConstructor
@Transactional
public class ReviewService {
private final CardRepository cardRepository;
private final ReviewStatisticsRepository statsRepository;
private final NotificationService notificationService;
private final DeckProgressRepository deckProgressRepository;
public void completeReview(Long cardId) {
Card card = cardRepository.findById(cardId).orElseThrow();
card.completeReview();
cardRepository.save(card);
// 여기서부터가 문제
statsRepository.incrementReviewCount(cardId); // 통계
notificationService.scheduleReminder(cardId, card.getNextReviewDate()); // 알림 예약
deckProgressRepository.recalculate(card.getDeckId()); // 덱 진행률 갱신
// 나중에 뱃지, 라이브러리, 상담 기능까지 너무 두꺼워지는데 ... 계속 늘어난다
}
}
ReviewService가 통계 / 알림 / 덱 진행률 을 전부 알고 있어야 한다.
Card Review라는 핵심 행위가 끝났을 뿐인데, 이 메서드는 프로젝트 전체를 향해 손을 뻗고 있다.
이벤트가 필요하다는 신호 세 가지:
ReviewService 생성자 주입이 점점 늘어난다Third Tool을 훑어보면 이벤트 패턴이 자연스럽게 맞아 들어가는 지점이 몇 군데 있다.
CardReviewedEvent 발행 (여긴 바로 알 필요가 없을 것 같은데가 적격)
│
├── 리뷰 통계 갱신 (ReviewStatisticsRepository)
├── 다음 회상일 알림 예약 (NotificationService) @Async
└── 덱 전체 진행률 재계산 (DeckProgressRepository) @Async
Card 애그리거트에 AbstractAggregateRoot를 적용하면
save() 시점에 Spring이 이벤트를 자동 발행해준다. 서비스는 전혀 신경 쓸 필요 없다.
@Entity
public class Card extends AbstractAggregateRoot<Card> {
// ...
public void completeReview() {
this.lastReviewedAt = LocalDate.now();
this.nextReviewDate = ReviewPolicy.calculateNext(this.reviewCount++);
// save() 호출 시 Spring이 자동 발행
registerDomainEvent(new CardReviewedEvent(this.id, this.nextReviewDate));
}
}
public record CardReviewedEvent(Long cardId, LocalDate nextReviewDate) {}
@Component
@RequiredArgsConstructor
public class CardReviewedEventHandler {
private final ReviewStatisticsRepository statsRepository;
private final NotificationService notificationService;
private final DeckProgressRepository deckProgressRepository;
@TransactionalEventListener(phase = AFTER_COMMIT)
public void updateStats(CardReviewedEvent event) {
statsRepository.incrementReviewCount(event.cardId());
}
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void scheduleReminder(CardReviewedEvent event) {
notificationService.scheduleAt(event.cardId(), event.nextReviewDate());
}
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void recalculateDeckProgress(CardReviewedEvent event) {
deckProgressRepository.recalculateByCard(event.cardId());
}
}
ReviewService는 이렇게 남는다.
@Service
@RequiredArgsConstructor
@Transactional
public class ReviewService {
private final CardRepository cardRepository;
public void completeReview(Long cardId) {
Card card = cardRepository.findById(cardId).orElseThrow();
card.completeReview(); // 내부에서 이벤트 등록
cardRepository.save(card); // Spring이 자동 발행
// 끝. 이후에 무슨 일이 일어나는지 모른다 — 알 필요도 없다 깔끔~~
}
}
이렇게 의존성에 대해서 함축이 가능해져버린다.
현재 Library 기능이 계획 중인데, Deck을 공개 마켓에 올리는 시점이 좋은 이벤트 경계가 된다.
DeckPublishedEvent 발행
│
├── Library BC: 라이브러리 목록에 등록
├── Search BC: 검색 인덱스 갱신 @Async
├── Notification: 팔로워에게 알림 @Async
└── Stats: 발행 카운트 집계
핵심은 Deck 도메인이 Library, Search, Notification을 직접 알지 않아도 된다는 점이다.
각 BC는 이벤트를 구독해 자기 책임만 진다.
// Deck 도메인 — Library가 뭔지 모른다
public class Deck extends AbstractAggregateRoot<Deck> {
public void publish() {
this.status = DeckStatus.PUBLISHED;
this.publishedAt = LocalDateTime.now();
registerDomainEvent(new DeckPublishedEvent(this.id, this.ownerId, this.category));
}
}
public record DeckPublishedEvent(Long deckId, Long ownerId, Category category) {}
// Library BC 핸들러 — Deck 내부를 직접 참조하지 않는다
@Component
@RequiredArgsConstructor
public class DeckPublishedEventHandler {
private final LibraryRepository libraryRepository;
private final SearchIndexService searchIndexService;
private final FollowerNotificationService followerNotificationService;
@TransactionalEventListener(phase = AFTER_COMMIT)
public void registerToLibrary(DeckPublishedEvent event) {
libraryRepository.register(event.deckId(), event.category());
}
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void indexToSearch(DeckPublishedEvent event) {
searchIndexService.index(event.deckId());
}
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void notifyFollowers(DeckPublishedEvent event) {
followerNotificationService.notifyAll(event.ownerId(), event.deckId());
}
}
나중에 Kafka 같은 메시지 브로커를 붙일 때도
발행부(registerDomainEvent → kafkaTemplate.send)만 교체하면 핸들러 로직은 재사용 가능하다.
이벤트 도입에서 트랜잭션 설계가 가장 실질적인 이유였다.
@Transactional
public void completeReview(Long cardId) {
Card card = cardRepository.findById(cardId).orElseThrow();
card.completeReview();
cardRepository.save(card);
// 여기서 알림을 예약해버리면?
notificationService.scheduleAt(cardId, card.getNextReviewDate());
// 이후 덱 진행률 재계산에서 예외 발생
deckProgressRepository.recalculate(card.getDeckId()); // RuntimeException!
// → 트랜잭션 롤백 → Card 저장 취소
// → 근데 알림은 이미 예약됨 💀
}
리뷰 자체는 실패했는데, 외부에 이미 신호가 나갔다.
이걸 보정하려면 알림 취소 로직까지 같이 짜야 한다.
AFTER_COMMIT으로 경계 확정@TransactionalEventListener(phase = AFTER_COMMIT)
public void scheduleReminder(CardReviewedEvent event) {
// DB 커밋이 확정된 이후에만 실행
// 롤백되면 이 메서드 자체가 호출되지 않음
notificationService.scheduleAt(event.cardId(), event.nextReviewDate());
}
AFTER_COMMIT은 트랜잭션이 성공적으로 커밋된 뒤에만 리스너를 호출한다.
롤백이 일어나면 리스너 호출이 없다. 외부 서비스에 잘못된 신호가 나갈 여지가 없다.
Third Tool의 Review API는 사용자 응답에 포함될 필요가 없는 작업이 많다.
completeReview() 호출
│
├── [동기] 카드 저장 + 커밋 → 응답에 영향
│
└── [AFTER_COMMIT]
├── @Async 통계 갱신 → 응답에 무관
├── @Async 알림 예약 → 응답에 무관
└── @Async 덱 진행률 재계산 → 응답에 무관
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void recalculateDeckProgress(CardReviewedEvent event) {
// 이게 1초 걸려도 Review API 응답은 이미 반환됨
deckProgressRepository.recalculateByCard(event.cardId());
}
스레드 풀 설정은 한 곳에서만 관리한다.
@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("third-tool-async-");
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("[AsyncEvent] handler error: method={}", method.getName(), ex);
}
}
이벤트 도입 전, ReviewService 단위 테스트는 이렇게 생겼다.
// 이벤트 없을 때
@ExtendWith(MockitoExtension.class)
class ReviewServiceTest {
@Mock CardRepository cardRepository;
@Mock ReviewStatisticsRepository statsRepository; // 목 1
@Mock NotificationService notificationService; // 목 2
@Mock DeckProgressRepository deckProgressRepository; // 목 3
@InjectMocks ReviewService reviewService;
@Test
void 리뷰_완료시_카드_상태가_갱신된다() {
// given
Card card = CardFixture.create();
given(cardRepository.findById(any())).willReturn(Optional.of(card));
// when
reviewService.completeReview(1L);
// then
assertThat(card.getNextReviewDate()).isNotNull();
// 그런데 이것도 검증해야 하나?
verify(statsRepository).incrementReviewCount(any()); // 이게 ReviewService 테스트 관심사인가?
verify(notificationService).scheduleAt(any(), any()); // 이것도?
}
}
목이 많아질수록 "이 테스트가 ReviewService를 테스트하는 건지,
아니면 ReviewService가 다른 서비스들을 올바르게 호출하는지를 테스트하는 건지" 경계가 흐려진다.
ReviewService는 이벤트 발행만 검증한다.
@ExtendWith(MockitoExtension.class)
class ReviewServiceTest {
@Mock CardRepository cardRepository;
@Mock ApplicationEventPublisher eventPublisher; // 목은 이것 하나
@InjectMocks ReviewService reviewService;
@Test
void 리뷰_완료시_CardReviewedEvent가_발행된다() {
// given
Card card = CardFixture.createWithId(1L);
given(cardRepository.findById(1L)).willReturn(Optional.of(card));
// when
reviewService.completeReview(1L);
// then — ReviewService의 책임: 이벤트를 발행했는가
assertThat(card.getNextReviewDate()).isNotNull();
// AbstractAggregateRoot 사용 시엔 domainEvents() 로 검증
assertThat(card.domainEvents())
.hasSize(1)
.first()
.isInstanceOf(CardReviewedEvent.class);
}
}
핸들러는 핸들러 단독으로 테스트한다.
@ExtendWith(MockitoExtension.class)
class CardReviewedEventHandlerTest {
@Mock ReviewStatisticsRepository statsRepository;
@Mock NotificationService notificationService;
@Mock DeckProgressRepository deckProgressRepository;
@InjectMocks CardReviewedEventHandler handler;
@Test
void 이벤트_수신시_리뷰_통계가_갱신된다() {
// given
CardReviewedEvent event = new CardReviewedEvent(1L, LocalDate.now().plusDays(3));
// when
handler.updateStats(event);
// then
verify(statsRepository).incrementReviewCount(1L);
}
@Test
void 이벤트_수신시_다음_회상일_알림이_예약된다() {
// given
LocalDate nextDate = LocalDate.now().plusDays(3);
CardReviewedEvent event = new CardReviewedEvent(1L, nextDate);
// when
handler.scheduleReminder(event);
// then
verify(notificationService).scheduleAt(1L, nextDate);
}
}
통합 테스트에서 실제 이벤트 흐름을 검증한다.
@SpringBootTest
@Transactional
class ReviewEventIntegrationTest {
@Autowired ReviewService reviewService;
@Autowired ReviewStatisticsRepository statsRepository;
@Test
void 리뷰_완료_후_통계가_실제로_갱신된다() {
// given — 실제 Card 저장
Card card = cardRepository.save(CardFixture.create());
// when
reviewService.completeReview(card.getId());
// then — 리스너가 실제로 동작했는가
long count = statsRepository.countByCardId(card.getId());
assertThat(count).isEqualTo(1);
}
}
| 구분 | 이벤트 도입 전 | 이벤트 도입 후 |
|---|---|---|
| ReviewService 단위 테스트 목(Mock) 수 | 3~5개 | 1개 (EventPublisher) |
| 핸들러 테스트 | ReviewService와 섞여 있음 | 독립적으로 존재 |
| 새 부수 효과 추가 시 | ReviewService 테스트 수정 | 핸들러 테스트만 추가 |
| 테스트 실패 지점 | 어디서 깨졌는지 추적 어려움 | 발행 / 핸들러 중 어디인지 즉시 특정 |
Third Tool에 이벤트를 도입하면서 얻은 것을 한 줄씩 정리하면 이렇다.
의존성 측면:
ReviewService는 리뷰라는 행위 하나에만 집중한다.
통계, 알림, 덱 진행률은 각자의 핸들러가 책임진다. 새 기능이 생겨도 서비스는 안 건드린다.
트랜잭션 측면:
AFTER_COMMIT으로 DB 커밋이 확정된 뒤에만 외부 효과가 발생한다.
롤백이 일어나면 리스너 자체가 실행되지 않아, 상태 불일치 가능성이 구조적으로 차단된다.
테스트 측면:
발행자는 "이벤트를 올바르게 발행했는가"만 검증한다.
리스너는 "이벤트를 받았을 때 올바르게 처리했는가"만 검증한다.
책임이 분리되니 테스트 범위도 명확해진다.
최종 수정일: 2026-03-26