아무튼, 쓰기

Spring 이벤트, 혹시 이렇게 쓰고 계신가요? (Fat Event 피하기) 본문

스프링

Spring 이벤트, 혹시 이렇게 쓰고 계신가요? (Fat Event 피하기)

순원이 2025. 10. 23. 18:47

개요

좋아요 기능을 추가했을 뿐인데, NotificationServiceStatisticService까지 수정해야 했습니다. OCP(개방-폐쇄 원칙)가 훼손되는 문제를 해결하기 위해 Spring의 이벤트 기반 아키텍처로 리팩토링했습니다.

서비스 계층이 특정 구현체에 직접 의존하는 강한 결합 구조에서 Spring ApplicationEvent 기반의 비동기 이벤트 구조로 전환하는 것이 목표였습니다.

그 과정 속에서 Fat Event라는 두 번째 문제를 만났고, 이 문제를 해결하기 위해 최소 ID 원칙을 도입했습니다. 최종적으로는 원칙의 순수성과 시스템 성능 사이에서 실용적인 트레이드오프를 선택하며 해결하였습니다.


문제 정의: 왜 리팩토링이 필요했나 (강한 결합과 OCP 위반)

기능 요구사항은 간단했습니다. "사용자가 '좋아요'를 누르면, (1) 데이터를 저장하고, (2) 통계를 갱신하고, (3) 작성자에게 알림을 보낸다."

초기 코드는 다음과 같았습니다.

// 초기 구조 - 직접 의존
@Service
public class ReadingDiaryLikeService {

    private final StatisticService statisticService;
    private final NotificationService notificationService; // 기능 추가 시마다 의존성 증가

    public void createDiaryLike(Long memberId, Long diaryId) {
        // ... DB 저장 로직 ...

        // 1. 통계 서비스 직접 호출
        statisticService.increaseLikeCount(diaryId); 

        // 2. 알림 서비스 직접 호출
        notificationService.send(...); // 책임 과다
    }
}

이 구조는 심각한 문제들을 안고 있었습니다.

  1. 강한 결합: LikeServiceStatisticService, NotificationService 등 구체적인 클래스에 직접 의존합니다.
  2. OCP 위반: 만약 "좋아요 시 로깅"이나 "인기 피드 점수 반영" 같은 새 기능이 추가되면, LikeService의 코드를 직접 수정해야 합니다.
  3. 책임 과다 (SRP 위반): LikeService는 '좋아요 생성'이라는 핵심 책임 외에 통계, 알림 등 수많은 부가 처리를 직접 담당하고 있었습니다.

1차 리팩토링: Spring Event와 'Fat Event'의 함정

이 문제를 해결하기 위해 Spring의 @EventListener를 도입했습니다. LikeService는 오직 자신의 책임(DB 저장)만 다하고, 이벤트만 발행하도록 변경했습니다.

// 1. 서비스는 이벤트만 발행
@Service
public class ReadingDiaryLikeService {
    // ...
    public void createDiaryLike(Long memberId, Long diaryId) {
        // ... DB 저장 ...

        // 이벤트 발행 후 자신의 책임 종료
        eventPublisher.publishEvent(new LikeEvent(
            diaryId, 
            1, // value
            diary.getMember().getId(), // diaryOwnerId
            liker.getNickname() // likerNickname
        ));
    }
}

// 2. 이벤트 객체
public record LikeEvent(
    Long diaryId, 
    int value, 
    Long diaryOwnerId, 
    String likerNickname
) {}

// 3. 통계 리스너
@EventListener
public void onLike(LikeEvent event) { 
    /* 통계 처리: diaryId, value, diaryOwnerId 사용 */ 
}

// 4. 알림 리스너
@EventListener
public void onLike(LikeEvent event) { 
    /* 알림 처리: diaryOwnerId, likerNickname 사용 */ 
}

이제 LikeService 코드를 수정하지 않고도 새로운 리스너를 추가할 수 있게 되었습니다. OCP를 준수하게 된 것입니다.

하지만 'Fat Event'라는 새로운 문제를 발견했습니다. 통계 리스너는 likerNickname이 필요 없는데, 알림 리스너 때문에 어쩔 수 없이 LikeEvent가 이 데이터를 갖게 되었습니다.

하나의 이벤트가 여러 리스너를 모두 만족시키기 위해 모든 정보를 담으면서, 리스너 간의 '보이지 않는 결합'이 발생한 것입니다.

만약 알림 기능에 diaryTitle이 추가로 필요해진다면? LikeEventdiaryTitle이 추가되고, 이 데이터가 전혀 필요 없는 통계 리스너까지 알게 됩니다. 이로 인해 불필요한 정보가 개발자가 헷갈려 할 수 있습니다. 예를 들면, LikeEvent를 열어봤을 때 diaryTitle이 보입니다. "이 diaryTitle은 뭐지? 통계에 써야 하나?"라고 고민하게 만듭니다. 

 


해결을 위한 고민: 핸들러 패턴 vs 최소 ID 원칙

'Fat Event' 문제를 해결하기 위해 두 가지 방안을 고민했습니다.

❌ [방안 1] 중간 핸들러 패턴 (채택 안 함)

LikeService가 범용 이벤트( LikeChangedEvent)를 발행하면, 중간 핸들러가 이를 받아 각 리스너에 맞는 전용 이벤트( LikeStatisticEvent, LikeNotificationEvent)로 변환해 재발행하는 구조입니다.

  • 장점: 리스너 간 완벽한 분리가 가능합니다.
  • 단점: 흐름 추적이 너무 복잡해집니다. (이벤트 발행 → 핸들러 → 재발행 → 리스너) 이벤트와 클래스가 과도하게 증가하여 더 큰 복잡성을 만듭니다.

✅ [방안 2] 최소 ID 원칙 (채택)

필요하면, 필요한 쪽에서 직접 조회하도록 하면 최소한의 정보만 담을 수 있습니다.

  1. 이벤트는 '행위의 주체와 대상'을 나타내는 최소한의 ID만 담는다.
    • "누가( likerId), 무엇을( diaryId), 어떻게( value) 했다"
  2. 모든 리스너는 이 단일 이벤트를 구독한다.
  3. 데이터가 더 필요한 리스너는, 받은 ID로 직접 DB에서 조회한다.
// 1. 순수 '최소 ID 원칙'을 따른 이벤트
public record LikeEvent(Long diaryId, Long likerId, int value) {}

// 2. 알림 리스너 - 필요한 데이터 직접 조회
@Async // (중요) 비동기 처리
@TransactionalEventListener
public void handleLikeNotification(LikeEvent event) {
    // 필요한 데이터를 이 시점에 직접 조회!
    ReadingDiary diary = readingDiaryRepository.findByIdOrElseThrow(event.diaryId());
    Member liker = memberRepository.findByIdOrElseThrow(event.likerId());

    NotificationMessage message = buildLikeNotificationMessage(diary.getMember(), liker);
    notificationSender.send(message);
}

여기서 "리스너에서 DB 조회를 해도 괜찮을까?"라는 성능 우려가 생길 수 있습니다.

리스너가 @Async로 동작하기 때문에, DB 조회로 인한 약간의 지연이 발생하더라도 사용자 요청 스레드를 막지 않습니다. 아키텍처의 단순성과 유연성이 주는 이점이 약간의 처리 지연보다 훨씬 크다고 판단했습니다.


최종 결정: 원칙과 현실의 타협, '실용적 트레이드오프'

'최소 ID 원칙'을 적용하려던 순간, 고민이 생겼습니다

좋아요처럼 빈번하게 발생하는 이벤트에서, 통계 리스너가 매번 통계 처리를 위해 diaryOwnerIdbookId를 조회하는 것은 DB에 부담이 된다고 판단했습니다.

그래서 원칙을 100% 맹신하는 대신, 성능을 위한 실용적 트레이드오프를 적용했습니다.

가장 빈번하게 사용되는 ID는 예외적으로 이벤트에 포함시키기록 결정하였습니다.

/**
 * '좋아요' 상태 변경 이벤트 (최종)
 *
 * @param diaryId 좋아요가 달린 독서일지 ID
 * @param diaryOwnerId 독서일지 작성자 ID (성능 최적화를 위해 추가)
 * @param bookId 독서일지가 속한 도서 ID (성능 최적화를 위해 추가)
 * @param likerId 좋아요를 누른 사용자 ID
 * @param likeIncrement 좋아요 생성: 1, 좋아요 취소: -1
 */
public record LikeEvent(
    Long diaryId,
    Long diaryOwnerId,  // 순수 원칙에서는 불필요하지만 성능을 위해 추가
    Long bookId,        // 순수 원칙에서는 불필요하지만 성능을 위해 추가
    Long likerId,
    int likeIncrement
) {}

이 트레이드오프가 각 리스너에 미친 영향은 다음과 같습니다.

1. 통계 리스너 (성능 이득)

통계 리스너는 diaryOwnerIdbookId가 반드시 필요했습니다. 이 ID들을 이벤트에서 직접 전달받아 DB 조회 없이 즉시 통계 처리가 가능해졌습니다.

// 통계 리스너 - 성능 이득
@Async
@TransactionalEventListener
public void handleLikeEvent(LikeEvent event) {
    if (event.likeIncrement() > 0) {
        readingDiaryStatisticService.incrementCount(event.diaryId(), CountType.LIKE);

        // bookId와 diaryOwnerId를 이벤트에서 직접 받아 DB 조회 없이 처리
        popularDiaryFeedManager.increaseScore(
            event.diaryOwnerId(),
            event.bookId(),
            event.diaryId()
        );
    }
}

2. 알림 리스너 (유연성 유지)

알림 리스너는 bookId는 필요 없었지만, 이벤트에 포함되어 있으니 어쩔 수 없이 받게 되었지만, 여전히 유지보수성은 좋습니다.

// 알림 리스너 - 원칙 유지
@Async
@TransactionalEventListener
public void handleLikeNotification(LikeEvent event) {
    // bookId, diaryOwnerId는 받지만 사용하지 않음 (사소한 손해)

    // 알림에 필요한 데이터는 원칙대로 직접 조회
    ReadingDiary diary = readingDiaryRepository.findByIdOrElseThrow(event.diaryId());
    Member liker = memberRepository.findByIdOrElseThrow(event.likerId());

    NotificationMessage message = buildLikeNotificationMessage(diary.getMember(), liker.getNickname);
    notificationSender.send(message);
}

만약 "알림 메시지에 작성자 닉네임 대신 실명을 표시해달라"는 요구사항이 와도, LikeEvent는 건드릴 필요가 없습니다. handleLikeNotification 메서드 내부의 조회 로직만 수정하면 됩니다. Fat Event 방식이었다면 의존성 오염과 개발자 인지적 부하를 초래했을 겁니다.


결론: 결과 및 배운 점

최종적으로 다음과 같은 결과를 얻었습니다.

  1. OCP 준수: 도메인 간 결합도를 제거하여 새 기능(리스너) 추가 시 기존 코드(LikeService) 수정이 불필요해졌습니다.
  2. 유지보수성 향상: 이벤트 구조가 단순화되고 리스너별 책임이 명확해졌습니다.
  3. 성능과 원칙의 균형: 빈번한 통계 처리는 최적화하고, 알림 등 부가 기능은 유연성을 유지했습니다.

이번 리팩토링을 통해 "코드에 '완벽한 정답'은 없다"는 것을 다시 한번 경험했습니다. 원칙(최소 ID 원칙)을 이해하되 맹신하지 않고, 시스템의 특성과 요구사항(빈번한 통계 처리)에 맞춰 원칙의 순수성과 실용성 사이에서 최선의 트레이드오프를 찾아내는 것이 더 나은 개발자로 성장하는 과정임을 깨달았습니다.