사용자의 포인트 로직을 설계하고 개발하는 과정에서, 도장판을 생성하는 요청, 회원 가입하는 요청 등 특정 기능이 성공적으로 완료되면 포인트가 적립되는 2 가지의 동작을 해야 하는 경우가 발생했다. 2 가지의 동작을 하나의 메서드에서 구현하게 되면, 도장판 생성 기능과 포인트 적립 기능이 강한 결합(Tight Coupling)이 된다. 이는 로직을 관리하기가 어렵고, 특정 기능에 문제가 발생하였을 때, 해당 문제를 처리하는 로직 역시 섞이게 되어 가독성이 떨어지고 유지보수 측면에서 좋지 않다고 생각했다.
✨ 문제 코드
@Service
@RequiredArgsConstructor
public class StampBoardService {
private final StampBoardRepository stampBoardRepository;
private final PointService pointService;
@Transactional
public void createStampBoard(final String username, final StampBoardCreateRequest stampBoardCreateRequest) {
// 요청 데이터 검증
...
// 도장판 생성
StampBoard stampBoard = stampBoardRepository.save(createStampBoard(stampBoardCreateRequest, findMember));
...
// 포인트 적립
MemberPoint memberPoint = pointService.getPoint(username);
memberPoint.updatePoint(~);
}
}
위 코드는, 도장판을 생성하면 포인트를 적립하는 코드이다. 해당 코드는 다음과 같은 개선점이 발생한다.
✔ 강한 결합
도장판 생성 기능과 포인트 적립 기능간의 강한 결합이 발생하고, 만일 PointService를 다른 모듈이나 시스템으로 분리해야 할 때 StampBoardService에서 PointService 의존 관계를 제거하는 등 별도의 수정이 필요하다.
✔ 트랜잭션의 경계
도장판 생성과 포인트 적립은 동일한 트랜잭션에서 실행이 된다. 도장판 생성이 정상적으로 완료되었는데 포인트 적립이 실패했다고 롤백이 되어야 할까? 포인트 적립이 실패하더라도 createStampBoard 메서드의 목적인 도장판 생성이 정상 처리되면 Commit 되어야 한다. 따라서, 트랜잭션을 분리해야 한다.
✔ 동기적 처리
도장판 생성과 포인트 적립이 동기적으로 처리가 되어야 할까? 위와 마찬가지로 createStampBoard 메서드의 목적은 도장판 생성이고, 해당 요청 역시 도장판을 생성하기 위해 API 요청을 하는 것이다. 포인트 적립의 과정까지 수행된 후 사용자가 응답을 받을 필요 없이, 도장판 생성이 완료되면 OK 응답을 반환하고 비동기적으로 포인트 관련 로직을 처리해도 괜찮을 것이다.
위 강한 결합을 해결하는 방법으로 Spring Event를 선택하기로 했다.
💬 팀 프로젝트 과금 상, Queue 서비스(카프카) 서버를 사용할 수 없고 서버 내에서 처리할 수 있는 방법을 고민하다 Event를 사용하기로 했다.
Spring Event를 사용하면, 위와 같이 강한 결합으로 인한 시스템이 복잡해지는 경우를 방지할 수 있으며, 도메인 간의 의존성도 줄일 수 있게 된다. 즉, 느슨한 결합으로 만들 수 있다.
💬 Spring Event는 크게 Event Class와 이벤트를 발생시키는 Event Publisher, 이벤트를 처리하는 Event Listener 3 가지 요소로 볼 수 있다.
✔ 이벤트 생성
public record StampBoardCreatedEvent(
Member guardian
) {
}
◼ spring framework 4.2 이전에는 ApplicationEvent 클래스를 상속받아서 구현해야 했다.
◼ Event Class는 이벤트를 처리하는데 필요한 데이터를 가지고 있다.
✔ 이벤트 퍼블리셔 등록
@Service
@RequiredArgsConstructor
public class StampBoardService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void createStampBoard(final String username, final StampBoardCreateRequest stampBoardCreateRequest) {
...
eventPublisher.publishEvent(new StampBoardCreatedEvent(findMember));
}
}
◼ ApplicationEventPublisher 빈을 주입받아, publishEvent() 메서드를 통해 생성한 이벤트를 등록한다.
✔ 이벤트 리스너로 처리
@Component
public class MemberPointTransactionEventHandler {
private final MemberPointService memberPointService;
public MemberPointTransactionEventHandler(final MemberPointService memberPointService) {
this.memberPointService = memberPointService;
}
//@Async // 1번 (비동기 적용)
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW) // 2번 (동기)
public void handleStampBoardCreateEvent(final StampBoardCreatedEvent event) {
MemberPoint memberPoint = memberPointService.getMemberPoint(event.guardian().getId());
memberPoint.updatePoint(MemberPointType.STAMP_BOARD_CREATION.getIncreasedPoint());
memberPointService.saveMemberPointHistory(
createMemberPointHistory(MemberPointType.STAMP_BOARD_CREATION, memberPoint));
}
}
핵심 포인트 1
Spring Event 에서 @EventListener 를 사용하면 Event를 PublishEvent() 메서드가 호출되는 시점에 바로 이벤트를 처리한다. 하지만, 도장판을 생성하고 해당 트랜잭션이 성공적으로 마쳤을 경우에만 이벤트를 처리하고 싶다면, @TransactionEventListener 를 사용해야 한다. @TransactionEventListener 은 TransactionPhase 값(트랜잭션 작업 처리 상태)에 따라 이벤트를 언제 처리할 것인지 지정할 수 있다.
TransactionPhase.BEFORE_COMMIT: 트랜잭션이 commit 되기 전에
TransactionPhase.AFTER_COMMIT: default 값, 트랜잭션이 commit 된 후
TransactionPhase.AFTER_ROLLBACK: 트랜잭션이 rollback 된 후
TransactionPhase.AFTER_COMPLETION: 트랜잭션이 Completion(Commit or Rollback) 되었을 때
핵심 포인트 2
@Async를 사용하지 않는 상태에서, @Transactional(propagation = Propagation.REQUIRES_NEW) 트랜잭션 전파를 REQUIRES_NEW로 설정하지 않으면, "InvalidDataAccessApiUsageException" 예외가 발생한다. 별도의 트랜잭션을 설정하지 않으면, 트랜잭션이 동일한 스레드로 참여하게 되는데 기존 트랜잭션은 이미 Commit과 함께 종료되어 재사용이 불가능해 에러가 발생한다. 따라서, REQUIRES_NEW로 설정하여 새로운 트랜잭션에서 실행되도록 해야 한다. @Asnyc로 비동기적으로 다른 스레드에서 실행하면 위 옵션은 필요 없다.
핵심 포인트 3
Spring Event 처리는 기본적으로 동기적 처리이다. 따라서 Event 처리를 비동기적으로 처리하고 싶으면 @Async를 사용하면 된다.
@EnableAsync
@Configuration
public class AsyncConfig {
private final int corePoolSize = 3;
private final int maxPoolSize = 10;
private final int queueCapacity = 10;
private final String customThreadNamePrefix = "ASYNC_THREAD-";
@Bean
public Executor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(corePoolSize);
taskExecutor.setMaxPoolSize(maxPoolSize);
taskExecutor.setQueueCapacity(queueCapacity);
taskExecutor.setThreadNamePrefix(customThreadNamePrefix);
taskExecutor.initialize();
return taskExecutor;
}
}
◼ @EnableAsync로 Async를 활성화한다.
◼ 기본적으로 Spring은 비동기적으로 메서드를 실행하기 위해 SimpleAsyncTaskExecutor를 사용하는데, 이는 요청이 오는대로 계속 스레드를 생성하기 때문에 스레드 풀을 만들어서 사용하는 것이 좋다. 다음과 같이 스레드 풀을 설정할 수 있다.
setCorePoolSize(int corePoolSize): 스레드 풀의 최소 스레드 수를 설정한다.
setMaxPoolSize(int maxPoolSize): 스레드 풀의 최대 스레드 수를 설정한다.
setQueueCapacity(int queueCapacity): 작업 큐의 용량을 설정한다.
setThreadNamePrefix(String threadNamePrefix): 생성된 스레드의 접두사를 설정한다.
◼ 처음 corePoolSize 만큼 처음에 생성되어 동작하다가 작업 Queue에 작업이 가득 차게 되면, 그때 MaxPoolSize 만큼 스레드를 생성해서 처리한다.
💥 주의
느슨한 결합: 이벤트를 사용한다고 강한 결합이 해제되는 것은 아니다. 도메인 외부의 후속 행위를 아는 것은 강한 결합을 해제했다고 할 수 없다.
✨ 강한 결합인 이벤트 처리
@Transactional
public void createStampBoard(final String username, final StampBoardCreateRequest stampBoardCreateRequest) {
// 요청 데이터 검증
...
// 도장판 생성
StampBoard stampBoard = stampBoardRepository.save(createStampBoard(stampBoardCreateRequest, findMember));
...
// 문제되는 이벤트 등록
eventPublisher.publishEvent(new AddMemberPointEvent(List.of(findMember), MemberPointType.STAMP_BOARD_CREATION));
}
◼ 도장판을 생성하고 후속 처리를 위한 Event를 등록할 때, 이벤트 명이 어떤 작업이 이루어질지 알 수 있는 후속 행위의 의도를 담고 있어 강한 결합을 해제했다고 볼 수 없다.
✨ 느슨한 결합 이벤트 처리
@Transactional
public void createStampBoard(final String username, final StampBoardCreateRequest stampBoardCreateRequest) {
// 요청 데이터 검증
...
// 도장판 생성
StampBoard stampBoard = stampBoardRepository.save(createStampBoard(stampBoardCreateRequest, findMember));
...
eventPublisher.publishEvent(new StampBoardCreateEvent(findMember));
}
◼ 발행해야 하는 이벤트는 이벤트로 인해 달성하려는 목적이 아닌 도메인 이벤트(도장판 생성) 그 자체이다.
'Backend > Spring' 카테고리의 다른 글
동시성 문제와 해결 방법들 - 2편 (분산락 with. Database Lock, Redis) (0) | 2023.09.07 |
---|---|
동시성 문제와 해결 방법들 - 1편 (Synchronized) (1) | 2023.09.06 |
kotlin + HttpInterface 알아보기 (0) | 2023.06.22 |
Kotlin에서 Rest Docs 문서화 코드 개선하기 (0) | 2023.05.29 |
Kotest를 통한 DCI 패턴 적용 (0) | 2023.05.27 |