[Spring/SpringBoot] 이벤트를 사용해 도메인 간 결합도 낮추기

이수진·2023년 3월 12일
3
post-thumbnail

✅ 이벤트를 사용하는 이유 & 도입하게 된 배경

서비스 간의 의존성을 줄이기 위해서 이벤트를 도입하게 되었습니다

지금 프로젝트에서는 간단한 두 도메인 간의 관계이지만, 이후로의 확장이나 디벨롭에 있어서
서로 다른 두 도메인 간의 강한 의존성으로 시스템이 더욱 복잡해질 수 있음을 고려해 이벤트를 고려하였고, 도입하게 되었습니다

✅ 이벤트란?

✔️ 소개

스프링에서 자주 쓰이는 이벤트로 @EventListener@TransactionalEventListener 가 있습니다.
프로젝트에서는 둘 중 @TransactionalEventListener 를 이용하였는데, 이 둘의 각각의 차이와 왜 @TransactionalEventListener 를 선택하게 되었는지 천천히 알아봅시다


✔️ @EventListener@TransactionalEventListener 소개, 그 둘의 차이에 대해서

@EventListener

@EventListener 는 이벤트를 발행하는 순간에 리스너가 동작합니다
즉, 리스너의 동작 시점을 지정할 수 없습니다

@TransactionalEventListener

@TransactionalEventListener 는 트랜잭션이나 커밋이 롤백이 되는 시점에 리스너가 동작합니다
즉, 이벤트의 실질적인 실행을 트랜잭션의 종료를 기준으로 합니다


✅ 프로젝트에 적용한 부분

✔️ 프로젝트 구조 설명 - Participant와 TimeBlock 구조도

참여자 그리고 TimeBlock이 있고
이 둘의 관계는 다음과 같습니다

  • 회원가입시 참여자가 생성이 됩니다
  • 생성된 참여자 정보를 바탕으로 TimeBlock이 세팅이 됩니다 (초기화됩니다)

✔️ 이벤트 사용하기 전의 의존도와 이벤트를 사용한 이후 의존도 비교

이벤트를 사용하지 않았더라면 참여자 서비스에서 TimeBlock 서비스에 의존해 TimeBlock을 세팅했을 것입니다
하지만, 두 서비스(참여자, TimeBlock) 간의 강한 의존성을 줄이기 위해서 이벤트를 사용하게 되었습니다
이를 그림으로 나타내면 다음과 같습니다

이벤트 사용 전

  • ParticipantService에서 TimeBlockService를 의존해 timeBlock을 create 하도록 요청한다
  • Participant 와 TimeBlock 이 같은 붉은색으로 강하게 의존하고 있음을 알 수 있다
  • Participant 와 TimeBlock 두 도메인 간의 분리가 필요하였다
  • 각각은 다른 서비스이고, 책임과 역할이 분명히 나뉘어져 있으므로 두 도메인 서비스 간의 분리가 필요하였다

이벤트 사용 후

  • 참여자 생성 시 이벤트를 발행한다
  • 참여자가 생성되고 commit 이후, 이벤트 리스너가 동작하며 이벤트 리스너는 timeBlock 을 세팅한다
  • Participant 와 TimeBlock 두 서비스 도메인이 분리되었음을 확인할 수 있다 (붉은색과 푸른색으로 분리되었음)

✔️ @EventListener가 아닌 @TransactionalEventListener를 선택한 이유

이벤트를 사용하기로 결정했다면, 이제 @EventListener@TransactionalEventListener 둘 중 어느 이벤트리스너를 사용할 지 결정해야 합니다

아까 EventListener를 소개하는 부분에서
EventListener는 이벤트를 발행하는 순간에 즉시 리스너가 동작을 하게되어 리스너의 동작 시점을 지정할 수가 없다고 했습니다.

즉, EventListener를 사용하면
참여자가 생성되지 않았음에도 리스너가 동작해 TimeBlock을 생성할 수 있으므로
우리는 참여자 생성이 성공시(트랜잭션 성공시) 이벤트를 실행해야 하므로

이벤트가 실행되는 시점을 트랜잭션 이후로 지정해주기 위해 @EventListener가 아닌 @TransactionalEventListener 를 이용하게 되었습니다.

그림으로 두 @EventListener@TransactionalEventListener의 리스너의 실행 시점을 확인해보면 다음과 같습니다

즉, 참여자가 생성되고 commit 이후에
리스너가 동작하여 timeBlock 을 세팅해 주어야 하므로
@TransactionalEventListener를 이용하게 되었습니다


✔️ 코드로 확인하기 및 @TransactionalEventListener 에 @Transactional(propagation = Propagation.REQUIRES_NEW) 를 같이 써줘야하는 이유

코드를 확인하면 다음과 같습니다

// Participant.java

@Entity
@NoArgsConstructor
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"room_uuid", "name"})})
@Slf4j
public class Participant extends AbstractAggregateRoot<Participant> {

    private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[0-9]{4}$");

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "room_uuid", nullable = false)
    private String roomUuid;

    @Column(nullable = false)
    private String name;

    ....

    @PostPersist
    private void registerCreateEvent() {
        registerEvent(new ParticipantCreateEvent(roomUuid, name));
        log.info("이벤트 발행완료");
    }
  • Entity가 AbstractAggregateRoot 를 상속하여 이벤트 등록이 가능하도록 해줍니다
  • AbstractAggregateRoot를 상속하면 registerEvent(T event) 메서드를 통해 간단히 이벤트를 등록할 수 있습니다
// ParticipantCreateEvent.java

@Getter
@AllArgsConstructor
public class ParticipantCreateEvent {

    private String roomUuid;
    private String name;
}
// TimeBlockCreateService.java

@Service
@RequiredArgsConstructor
@Slf4j
public class TimeBlockCreateService {

    private final TimeBlockRepository timeBlockRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void create(ParticipantCreateEvent event) {
        timeBlockRepository.save(new TimeBlock(event.getRoomUuid(), event.getName()));
        log.info("이벤트 실행 완료, timeBlock 생성 완료");
    }
}
  • TransactioanlEventListener 에서는 단지 등록한 이벤트와 같은 타입의 클래스를 인자로 받아주기만 하면 이벤트 register - listen 을 구현할 수 있습니다
  • 이때 @Transactional(propagation = Propagation.REQUIRES_NEW)를 써줘야하는 이유?
    • @TransactionalEventListener 는 phase를 통해 언제 실행될지 지정할 수 있습니다. 그리고 위 코드에서는 phase = TransactionPhase.AFTER_COMMIT 으로 지정해 트랜잭션이 성공적으로 마무리(commit)됐을 때 이벤트 실행하도록 하였습니다. 즉 새로운 트랜잭션이 없다면 리스너가 작동하지 않습니다

      이미 이전 트랜잭션이 완료된 이후이므로, 이벤트를 처리할 수 있도록 새로운 트랜잭션을 열어주어야 합니다. 즉, 이벤트 리스너 메서드를 이전 트랜잭션과 독립적인 별도의 트랜잭션에서 실행되도록 해주어야 합니다.

      이렇게 하면 이전 publisher의 커밋도 보장하고, 이벤트 리스너에서도 새로운 트랜잭션 안에서 데이터 추가 작업을 진행하도록 할 수 있습니다


✔️ 테스트 코드로 확인하기

이를 확인하기위한 테스트코드는 다음과 같습니다.

// ParticipantIntegrationTest.java

@SpringBootTest
@RecordApplicationEvents
public class ParticipantIntegrationTest {

    @Autowired
    private ParticipantService participantService;

    @Autowired
    private TimeBlockRepository timeBlockRepository;

    @Autowired
    private ApplicationEvents events;

    @Test
    void 참여자가_생성되면_참여자의_TimeBlock이_생성된다() {
        participantService.create(ROOM_UUID, "참여자1", "1234");
        Optional<TimeBlock> actual = timeBlockRepository.findByRoomUuidAndParticipantName(ROOM_UUID, "참여자1");
        assertAll(
                () -> assertThat(actual.isPresent()).isTrue(), // TimeBlock 이 만들어졌는지 확인
                () -> assertThat(events.stream(ParticipantCreateEvent.class).count()).isEqualTo(1) // 이벤트가 발행되었는지 확인
        );
    }
}

테스트 break point를 걸고 디버깅으로 호출 순서 확인해보기

  • 참여자가 생성되고 save 됨

  • 이벤트 발행됨

  • domainEvents에 이벤트 등록됨

  • 새로운 트랜잭션에서 이벤트 리스너가 실행됨
    * 이벤트 리스너가 하는 역할은 timeBlock을 세팅해준다
profile
꾸준히, 열심히, 그리고 잘하자

1개의 댓글

comment-user-thumbnail
2023년 9월 12일

잘 읽었습니다.

답글 달기