[third tool] Spring 이벤트, Third Tool에 녹여보기 — (non-blocking 도입)

junsung kim·2026년 3월 26일

[project]- thirdTool

목록 보기
23/29

Spring 이벤트, Third Tool에 녹여보기 — (non-blocking 찍먹)

이 글은 개인 프로젝트 Third Tool(Cornell Notes 기반 학습 카드 시스템)에 Spring 애플리케이션 이벤트를 도입하면서 겪은 고민의 흔적입니다.
"왜 이벤트를 써야 하는가"보다 "이 프로젝트의 어느 지점에 이벤트가 필요했는가" 에 집중했습니다.


목차

  1. 도입 전 — 서비스 메서드가 점점 뚱뚱해지던 시절
  2. Third Tool에서 이벤트가 어울리는 지점
  3. 트랜잭션 의존 관계가 어떻게 달라지나
  4. 테스트 편의성 — 이게 생각보다 큰 이유였다
  5. 정리

1. 도입 전 — 서비스 메서드가 점점 뚱뚱해지던 시절

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 생성자 주입이 점점 늘어난다
  • 새 기능을 추가할 때마다 이 메서드를 열어야 한다
  • 테스트에서 목(Mock)이 너무 많아 무엇을 테스트하는지 불분명해진다

2. Third Tool에서 이벤트가 어울리는 지점

Third Tool을 훑어보면 이벤트 패턴이 자연스럽게 맞아 들어가는 지점이 몇 군데 있다.


2-1. Card Review 완료 — 가장 먼저 눈에 띈 곳

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이 자동 발행
        // 끝. 이후에 무슨 일이 일어나는지 모른다 — 알 필요도 없다 깔끔~~
    }
}

이렇게 의존성에 대해서 함축이 가능해져버린다.


2-2. (추후)Deck 라이브러리 발행 — 바운디드 컨텍스트 경계선

현재 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 같은 메시지 브로커를 붙일 때도
발행부(registerDomainEventkafkaTemplate.send)만 교체하면 핸들러 로직은 재사용 가능하다.


3. 트랜잭션 의존 관계가 어떻게 달라지나

이벤트 도입에서 트랜잭션 설계가 가장 실질적인 이유였다.

문제 상황 — 커밋 전 외부 호출

@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);
    }
}

4. 테스트 편의성 — 이게 생각보다 큰 이유였다

이벤트 도입 전, 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 테스트 수정핸들러 테스트만 추가
테스트 실패 지점어디서 깨졌는지 추적 어려움발행 / 핸들러 중 어디인지 즉시 특정

5. 정리

Third Tool에 이벤트를 도입하면서 얻은 것을 한 줄씩 정리하면 이렇다.

의존성 측면:
ReviewService는 리뷰라는 행위 하나에만 집중한다.
통계, 알림, 덱 진행률은 각자의 핸들러가 책임진다. 새 기능이 생겨도 서비스는 안 건드린다.

트랜잭션 측면:
AFTER_COMMIT으로 DB 커밋이 확정된 뒤에만 외부 효과가 발생한다.
롤백이 일어나면 리스너 자체가 실행되지 않아, 상태 불일치 가능성이 구조적으로 차단된다.

테스트 측면:
발행자는 "이벤트를 올바르게 발행했는가"만 검증한다.
리스너는 "이벤트를 받았을 때 올바르게 처리했는가"만 검증한다.
책임이 분리되니 테스트 범위도 명확해진다.


최종 수정일: 2026-03-26

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

0개의 댓글