이번 포스팅은 이전 포스팅에 이어서 코드로 구현하는 과정을 담았습니다.
구현 목표
- 모놀리식 아키텍처에서 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 LetterReplyEventListener {
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 LetterReplyFacadeService {
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 호출 분리
- 사용 횟수 선차감을 시도합니다.
- 클로바 서비스(feignClient)를 통해 답장 생성을 요청합니다.
- 만약 클로바 서비스에 에러 응답 코드를 받으면 `사용 횟수 롤백 이벤트`를 발행합니다.
- responseReply(): 편지/답장 저장을 하나의 트랜잭션에서 처리
- 응답 메시지를 저장할 `CompletableFuture` 객체를 생성해 `편지 생성 이벤트`에 포함시키고 이벤트를 발행합니다.
이벤트 수신자가 처리를 완료하면 `future`에 응답 메시지를 반환하고, 퍼사드가 이를 사용자에게 응답합니다. - 응답(`ResponseDTO`)을 기다립니다. (타임아웃 5초)
- 응답 메시지를 저장할 `CompletableFuture` 객체를 생성해 `편지 생성 이벤트`에 포함시키고 이벤트를 발행합니다.
기존 컨트롤러 코드
@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를 활용한 방식의 장단점을 알게 되었습니다.
장점으로는
- 서비스 간 느슨한 결합을 어느 정도 달성하게 해 준다는 점
- 이벤트 수신을 비동기로 처리하도록 설계한다면 더 빠른 응답이 가능하다는 점이 있습니다.
단점으로는
- 느슨한 결합이 되는 만큼 퍼사드가 없다면 이벤트 흐름을 추적하기 어렵다는 점
→ 복잡도가 증가함에 따라 디버깅이 어려워질 수 있는 점 - 각 이벤트에서 발생하는 상태를 추적하기 위한 리소스가 추가로 든다는 점
마지막으로 모놀리식 아키텍처에서는 Spring Event를 활용하지 않더라도 충분히 원자적으로 로직을 실행할 수 있다는 점을 알았습니다.
Spring Event를 제거한 퍼사드 클래스
@Slf4j
@RequiredArgsConstructor
@Service
public class LetterReplyFacadeService {
private final RateLimitService rateLimitService;
private final ClovaService clovaService;
private final LetterService letterService;
private final ReplyService replyService;
public TwoTypeMessage sendLetterToClova(LetterRequestDto letterRequestDto) {
try {
// 외부 API 호출
ClovaResponseDto clovaResponse = clovaService.send(letterRequestDto.getMessage());
return clovaService.extract(clovaResponse);
} catch (FeignException e) {
log.error("clova studio api 호출에 문제가 발생했습니다. userId: {}", letterRequestDto.getUserId());
// 이벤트를 발행하지 않고 퍼사드 레이어에서 롤백 시도
rateLimitService.rollback(letterRequestDto.getUserId());
throw e;
}
}
@Transactional
public ReplyResponseDto responseReply(LetterRequestDto letterRequestDto, TwoTypeMessage twoTypeMessage) {
// 이벤트를 발행하지 않고 퍼사드 레이어에서 트랜잭션 수행
Letter letter = letterService.save(
UUID.fromString(letterRequestDto.getUserId()),
letterRequestDto.getMessage(),
letterRequestDto.getPreference(),
letterRequestDto.isPublished());
return replyService.save(letter, twoTypeMessage);
}
}
이 코드에선 Spring Event를 제거하고, 이벤트를 발행하는 자리에 각 서비스의 로직을 가져왔습니다.
이렇게 모놀리식 아키텍처에서는 Spring Event를 사용하지 않고 필요한 로직을 퍼사드에서 처리하는 것이 더 좋다는 생각을 하게 됐습니다. 퍼사드 클래스에서 좀 더 많은 서비스에 의존성을 갖게되는 부분은 어쩔 수 없는 부분이지만, 이벤트의 흐름을 추적하지 않고, 복잡도를 줄이며, 명확한 로직을 보일 수 있다는 명확한 장점이 있기 때문입니다.
마지막 소감으로, 직접 구현하다보니 Spring Event에 대해 깊이 알게되었고, 오버 엔지니어링을 하지 않는 선에서 버그를 잘 수정하게 된 것 같습니다.
'Project' 카테고리의 다른 글
프로젝트 리팩토링 (2) - 도메인 모델 리팩토링 (0) | 2025.02.02 |
---|---|
프로젝트 리팩토링 (1) - 객체 지향 설계 (0) | 2025.01.31 |
모놀리식 아키텍처에서 이벤트 기반으로 비즈니스 로직의 원자성 확보하기 (1) (0) | 2025.01.16 |
[올려올려 라디오] 신규 분석 기능 성능 테스트 (3) (1) | 2024.12.08 |
[올려올려 라디오] 신규 분석 기능 성능 테스트 (2) (0) | 2024.12.06 |