모놀리식 아키텍처에서 이벤트 기반으로 비즈니스 로직의 원자성 확보하기 (2)

2025. 1. 17. 19:46·Project

이번 포스팅은 이전 포스팅에 이어서 코드로 구현하는 과정을 담았습니다.


구현 목표

  • 모놀리식 아키텍처에서 MSA로 전환하는 작업의 초석이 되도록 서비스 간 결합도를 낮추기
    • 외부 API 호출 응답을 받으면 이벤트를 발행하는 방식으로 서비스 간 결합도를 낮춥니다.
    • `편지 서비스`는 `처리율 제한기 서비스`에 대한 의존성을 제거하고, 편지를 저장하는 책임만 집니다.
    • `답장 서비스`는 `외부 API 호출 서비스`에 대한 의존성을 제거해 트랜잭션에서 분리하고, 답장을 저장하는 책임만 집니다.
    • 다만, 데이터베이스에 저장하는 로직은 하나의 트랜잭션에서 처리할 수 있도록 상위 계층에서 편지와 답장을 저장하도록 설계합니다. ⇒ 이벤트 발행의 주체를 퍼사드 서비스로 구현합니다.
  • 이벤트에 상태(Status)를 추적할 수 있도록 하기
    • Spring Event를 사용하면 서비스 간 결합도는 낮아질 순 있어도 복잡해지기 때문에 서비스의 흐름을 파악하기가 어려워집니다.
    • 따라서 이벤트 처리 과정에 대한 추적을 용이하게 하기 위해 이벤트에 상태(Status)를 추가합니다.
  • 3 Layered Architecture 구조 잘 지키기
    • Spring Event를 활용할 때 이벤트 수신자는 비즈니스 로직을 처리합니다.
    • 따라서 Business Logic Layer에서만 동작하도록 설계합니다.

흐름도 대강 잡기

위의 흐름도에서 클라이언트의 요청을 처리하고, 응답 메시지를 생성하는 것은 Facade가 됩니다.

편지 발행 이벤트의 수신자(event lintener)가 이벤트를 처리할 때 응답 메시지를 반환해야 하는데, Event Listener는 일반적으로 반환하지 않습니다. (반환 타입이 void)

 

 

따라서 이벤트 수신자가 편지와 답장을 데이터베이스에 저장한 뒤 그 결과를 DTO로 만들고, CompletableFuture 객체를 통해 전달하는 방식으로 구현했습니다.


A. 이벤트 상태(Event Status) 작성

public enum EventStatus {

    // 기본 상태
    PENDING,
    PROCESSING,
    COMPLETED,

    // 롤백 상태
    ROLLBACK_REQUIRED,
    ROLLBACK_PROCESSING,
    ROLLBACK_COMPLETED,
}

이벤트의 상태를 추적하기 위해 enum 클래스를 작성했습니다.

`기본 상태`와 `롤백 시 상태`로 구분해 각 상황에서 이벤트의 상태를 추적하기 용이하게 설계했습니다.

B-1. 사용 횟수 롤백 이벤트 작성

@Getter
public class RateLimitRollbackEvent {

    private final String userId;
    private EventStatus status;

    public RateLimitRollbackEvent(String userId) {
        this.userId = userId;
        this.status = EventStatus.ROLLBACK_REQUIRED;
    }

    public static RateLimitRollbackEvent createEvent(String userId) {
        return new RateLimitRollbackEvent(userId);
    }

    public void process() {
        this.status = EventStatus.ROLLBACK_PROCESSING;
    }

    public void complete() {
        this.status = EventStatus.ROLLBACK_COMPLETED;
    }
}
  • 사용자 ID와 이벤트의 상태를 저장합니다.
  • 이벤트가 생성됐을 때는 PENDING 상태입니다.
  • 상태의 전이가 ROLLBACK_REQUIRED → ROLLBACK_PROCESSING → ROLLBACK_COMPLETED 가 되도록 내부에 `process()`, `complete()` 메서드를 구현했습니다.
    • 상태 전이의 순서를 강제하는 로직을 빼서 간단하게 구현했습니다.

B-2. 사용 횟수 롤백 이벤트 수신자

@Slf4j
@Service
@RequiredArgsConstructor
public class RateLimitService {

    @EventListener
    public void handleRollbackEvent(RateLimitRollbackEvent event) {
        // event status => ROLLBACK_PROCESSING
        event.process();

        rollback(event.getUserId());

        // event status => ROLLBACK_COMPLETE
        event.complete();
    }

    public void rollback(String userId) {
        String key = getKey(userId);
        redisTemplate.opsForValue().decrement(key, 1);
        log.info("사용 횟수가 롤백 되었습니다. userId: {}", userId);
    }
}
  • 사용 횟수 롤백 이벤트를 수신하면 사용자 ID로 사용 횟수를 롤백합니다.
  • 이벤트 처리의 앞뒤로 이벤트의 상태를 변경합니다.
    (ROLLBACK_REQUIRED → ROLLBACK_PROCESSING → ROLLBACK_COMPLETED)

C-1. 편지 생성 이벤트 작성

@Getter
@RequiredArgsConstructor
public class LetterCreationEvent {

    private final String userId;
    private final String message;
    private final Preference preference;
    private final boolean published;
    private final TwoTypeMessage twoTypeMessage;
    private final CompletableFuture<ReplyResponseDto> future;
    private EventStatus status = EventStatus.PENDING;

    public static LetterCreationEvent createEvent(String userId,
                                                  String message,
                                                  Preference preference,
                                                  boolean published,
                                                  TwoTypeMessage twoTypeMessage,
                                                  CompletableFuture<ReplyResponseDto> future) {
        return new LetterCreationEvent(userId, message, preference, published, twoTypeMessage, future);
    }

    public void process() {
        this.status = EventStatus.PROCESSING;
    }

    public void complete() {
        this.status = EventStatus.COMPLETED;
    }
}
  • 편지 Entity 생성에 필요한 정보를 담는 이벤트를 구현했습니다.
  • 만찬가지로 이벤트의 상태를 추적할 수 있도록 `EventStatus`를 포함합니다.
  • 특별한 점으로는 `CompletableFuture`를 필드에 포함시킵니다.
    • 편지 생성 이벤트는 편지의 생성과 답장 생성을 트리거합니다.
    • 편지와 답장은 1:1 관계이며, 답장까지 생성이 완료되면 사용자에게 응답할 `ResponseDTO`를 반환해야 합니다.
    • 상위 계층에서 `ReponseDTO`를 사용자에게 응답하기 위해 DTO를 `CompletableFuture` 객체에 담습니다.

C-2. 편지 생성 이벤트 수신자 작성

@Slf4j
@RequiredArgsConstructor
@Component
public class LetterEventListener {

    private final LetterService letterService;
    private final ReplyService replyService;

    @Transactional
    @EventListener
    public void handleCreationLetter(LetterCreationEvent event) {
        // event status => PROCESSING
        event.process();

        // letter 저장
        Letter letter = letterService.save(UUID.fromString(event.getUserId()),
                event.getMessage(),
                event.getPreference(),
                event.isPublished());

        // reply 저장
        ReplyResponseDto replyResponse = replyService.save(letter, event.getTwoTypeMessage());

        // future complete
        if (!event.getFuture().complete(replyResponse)) {
            log.warn("Failed to complete future for letter {}", letter.getId());
        }

        // event status => COMPLETE
        event.complete();
    }
}
  • 편지 생성 이벤트를 수신해 처리합니다.
  • 편지와 답장은 1:1 관계이므로 한 번에 저장할 수 있도록 구현했습니다.
  • 이벤트를 처리 시작과 끝에 상태를 변경합니다.
    (PENDING → PROCESSING → COMPLETED)
  • 이벤트 처리가 완료되면 `future`에 `ResponseDTO`를 반환하도록 합니다.

D. 퍼사드 클래스 작성

퍼사드 클래스 코드

@Slf4j
@RequiredArgsConstructor
@Service
public class ReplyFacadeService {

    private final RateLimitService rateLimitService;
    private final ClovaService clovaService;
    private final ApplicationEventPublisher eventPublisher;

    public TwoTypeMessage sendLetterToClova(LetterRequestDto letterRequestDto) {
        // 사용 횟수 선차감
        if (!rateLimitService.preDeductUsage(letterRequestDto.getUserId())) {
            throw new RateLimitException("요청 제한 횟수 초과");
        }

        try {
            // 외부 API 호출
            ClovaResponseDto clovaResponse = clovaService.send(letterRequestDto.getMessage());
            return clovaService.extract(clovaResponse);
        } catch (FeignException e) {
            // 외부 API 호출에 예외 발생 시 사용 횟수 롤백 이벤트 발행, event status => ROLLBACK_REQUIRED
            log.error("clova studio api 호출에 문제가 발생했습니다. userId: {}", letterRequestDto.getUserId());
            eventPublisher.publishEvent(RateLimitRollbackEvent.createEvent(letterRequestDto.getUserId()));
            throw e;
        }
    }

    @Transactional
    public ReplyResponseDto responseReply(LetterRequestDto letterRequestDto, TwoTypeMessage twoTypeMessage) {
        CompletableFuture<ReplyResponseDto> future = new CompletableFuture<>();

        // event status => PENDING
        LetterCreationEvent event = LetterCreationEvent.createEvent(
                letterRequestDto.getUserId(),
                letterRequestDto.getMessage(),
                letterRequestDto.getPreference(),
                letterRequestDto.isPublished(),
                twoTypeMessage,
                future);

        // 편지 생성 이벤트 발행
        eventPublisher.publishEvent(event);

        try {
            // 편지부터 답장까지 생성이 완료되면 응답을 반환
            return future.get(5, TimeUnit.SECONDS);
        } catch (InterruptedException | ExecutionException | TimeoutException e) {
            throw new RuntimeException("Failed future for reply response", e);
        }
    }
}
  • sendLetterToClova(): 외부 API 호출 분리
    1. 사용 횟수 선차감을 시도합니다.
    2. 클로바 서비스(feignClient)를 통해 답장 생성을 요청합니다.
    3. 만약 클로바 서비스에 에러 응답 코드를 받으면 `사용 횟수 롤백 이벤트`를 발행합니다.
  • responseReply(): 편지/답장 저장을 하나의 트랜잭션에서 처리
    1. 응답 메시지를 저장할 `CompletableFuture` 객체를 생성해 `편지 생성 이벤트`에 포함시키고 이벤트를 발행합니다.
      이벤트 수신자가 처리를 완료하면 `future`에 응답 메시지를 반환하고, 퍼사드가 이를 사용자에게 응답합니다.
    2. 응답(`ResponseDTO`)을 기다립니다. (타임아웃 5초)

기존 컨트롤러 코드

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public ReplyResponseDto receiveLetter(@Valid @RequestBody LetterRequestDto letterRequestDto) {
    LetterResponseDto letterResponse = letterService.saveLetter(letterRequestDto);

    return replyService.makeAndSaveReply(letterResponse);
}

기존 컨트롤러는 `편지 서비스`와 `답장 서비스` 두 서비스를 바라봤습니다.

 

수정 후 컨트롤러 코드

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public ReplyResponseDto receiveLetter(@Valid @RequestBody LetterRequestDto letterRequestDto) {
    TwoTypeMessage twoTypeMessage = letterReplyFacadeService.sendLetterToClova(letterRequestDto);

    return letterReplyFacadeService.responseReply(letterRequestDto, twoTypeMessage);
}

이제 컨트롤러 계층에서 퍼사드를 바라보도록 수정했습니다.

개선할만한 부분

이벤트 상태 변경 로직의 관심사 분리

이벤트 기반으로 처리하다 보니 상태를 관리하는 코드가 비즈니스 로직에 들어가 있습니다.

이 부분을 관심사 분리(AOP 적용)를 통해 이벤트 상태 관리 로직을 분리한다면 더 나은 코드가 될 것 같습니다.

이벤트 상태 전이의 강제성 추가

현재 코드에선 이벤트 상태의 전이를 강제하는 부분이 없습니다.

예를 들어, 편지 생성 이벤트는 PENDING → PROCESSING → COMPLETED 순서로 전이가 되야합니다.

만약 상태 정보를 디테일하게 추적하기 위해 이벤트 상태(EventStatus)가 추가되야 한다면, 기존 로직에선 개발자가 직접 상태 전이를 일일이 바꿔야 합니다. 따라서 새로운 이벤트 상태(EventStatus)가 추가되도 정상적인 흐름이 될 수 있도록 강제할 수 있는 로직이 필요하다고 생각합니다.

소감: 모놀리식 아키텍처는 Spring Event 없이도 원자성 확보가 가능하다

이번 리팩토링을 통해 Spring Event를 활용한 방식의 장단점을 알게 되었습니다.

장점으로는

  1. 서비스 간 느슨한 결합을 어느 정도 달성하게 해 준다는 점
  2. 이벤트 수신을 비동기로 처리하도록 설계한다면 더 빠른 응답이 가능하다는 점이 있습니다.

단점으로는

  1. 느슨한 결합이 되는 만큼 퍼사드가 없다면 이벤트 흐름을 추적하기 어렵다는 점
    → 복잡도가 증가함에 따라 디버깅이 어려워질 수 있는 점
  2. 각 이벤트에서 발생하는 상태를 추적하기 위한 리소스가 추가로 든다는 점

마지막으로 모놀리식 아키텍처에서는 Spring Event를 활용하지 않더라도 충분히 원자적으로 로직을 실행할 수 있다는 점을 알았습니다.

Spring Event를 제거한 퍼사드 클래스

@Slf4j
@RequiredArgsConstructor
@Service
public class ReplyFacadeService {

    private final RateLimitService rateLimitService;
    private final ReplyService replyService;

    public ReplyResponse responseReply(ReplyRequest replyRequest) {
        // 1. 사용 횟수 선차감
        if (!rateLimitService.preDeductUsage(replyRequest.getUserId())) {
            throw new RateLimitException("요청 제한 횟수 초과");
        }

        try {
            // 2. 외부 API 호출
            TwoTypeMessage twoTypeMessage = replyService.sendLetterToClova(replyRequest);

            // 3. 트랜잭션 안에서 저장
            return replyService.save(replyRequest, twoTypeMessage);
        } catch (Exception e) {
            // 외부 API 장애 발생 시 사용 횟수 차감 롤백
            log.error("Clova API 호출 실패: {}", e.getMessage());
            rateLimitService.rollback(replyRequest.getUserId());
            throw e;
        }
    }
}

이 코드에선 Spring Event를 제거하고, 이벤트를 발행하는 자리에 각 서비스의 로직을 가져왔습니다.

 

이렇게 모놀리식 아키텍처에서는 Spring Event를 사용하지 않고 필요한 로직을 퍼사드에서 처리하는 것이 더 좋다는 생각을 하게 됐습니다. 퍼사드 클래스에서 좀 더 많은 서비스에 의존성을 갖게되는 부분은 어쩔 수 없는 부분이지만, 이벤트의 흐름을 추적하지 않고, 복잡도를 줄이며, 명확한 로직을 보일 수 있다는 명확한 장점이 있기 때문입니다.

 

마지막 소감으로, 직접 구현하다보니 Spring Event에 대해 깊이 알게되었고, 오버 엔지니어링을 하지 않는 선에서 버그를 잘 수정하게 된 것 같습니다.

'Project' 카테고리의 다른 글

프로젝트 리팩토링 (2) - 도메인 모델 리팩토링  (0) 2025.02.02
프로젝트 리팩토링 (1) - 객체 지향 설계  (1) 2025.01.31
모놀리식 아키텍처에서 이벤트 기반으로 비즈니스 로직의 원자성 확보하기 (1)  (1) 2025.01.16
[올려올려 라디오] 신규 분석 기능 성능 테스트 (3)  (1) 2024.12.08
[올려올려 라디오] 신규 분석 기능 성능 테스트 (2)  (3) 2024.12.06
'Project' 카테고리의 다른 글
  • 프로젝트 리팩토링 (2) - 도메인 모델 리팩토링
  • 프로젝트 리팩토링 (1) - 객체 지향 설계
  • 모놀리식 아키텍처에서 이벤트 기반으로 비즈니스 로직의 원자성 확보하기 (1)
  • [올려올려 라디오] 신규 분석 기능 성능 테스트 (3)
옐리yelly
옐리yelly
  • 옐리yelly
    개발 갤러리
    옐리yelly
  • 전체
    오늘
    어제
    • 모든 글 보기 (84)
      • Project (22)
      • Java (4)
      • Spring (8)
      • Kubernetes (6)
      • Docker (2)
      • JPA (2)
      • Querydsl (2)
      • MySQL (9)
      • ElasticSearch (7)
      • DevOps (4)
      • Message Broker (3)
      • Git & GitHub (2)
      • Svelte (1)
      • Python (8)
        • Python Distilled (4)
        • Anaconda (1)
        • Django (0)
        • pandas (3)
      • Algorithm (1)
      • Computer Science (0)
      • 내 생각 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    비사이드
    svelte
    Spring
    pandas
    데드락
    mybatis
    MySQL
    ncloud
    k8s
    gitops
    blue-green 배포
    리팩토링
    Project
    nks
    프로젝트
    OOP
    예약 시스템
    JPA
    커넥션 풀
    argocd
    Message Broker
    elasticsearch
    성능 테스트
    querydsl
    포텐데이
    devops
    Python
    docker
    RabbitMQ
    pymysql
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
옐리yelly
모놀리식 아키텍처에서 이벤트 기반으로 비즈니스 로직의 원자성 확보하기 (2)
상단으로

티스토리툴바