스프링 비동기 처리와 이벤트 프로그래밍

junto·2024년 7월 13일
0

spring

목록 보기
25/30
post-thumbnail

동기 방식과 비동기 방식을 비교하고, 이벤트 방식으로 무엇을 개선할 수 있는지 살펴본다.
전체 코드: https://github.com/ji-jjang/Learning/tree/main/Practice/Event

동기 방식과 비동기 방식

  • 사용자가 회원가입을 완료하면, 다음 작업을 한다고 가정해 보자.
  1. 사용자에게 가입 축하 및 마케팅 이메일을 보낸다. (외부 Gmail Service)
  2. 사용자에게 가입 축하 쿠폰을 보낸다.
  3. 사용자가 입력한 추천인에게 포인트를 제공한다.
  • 각 단계가 3초, 2초, 2초 걸린다고 해보자. 동기 프로그램은 대략 7초, 비동기 프로그램은 3초 정도에 작업이 완료될 것이다.
  • 만약 외부 서비스(구글 이메일)에 장애가 생긴다면? 동기 방식의 경우 서버는 이메일 전송이 완료될 때까지 기다리게 된다. 외부 서비스를 호출하기 전에 별도로 회원가입 트랜잭션을 커밋하지 않는다면, 회원가입이 완료되지 않는 서비스 장애가 발생한다. 물론 회원가입만을 별도로 커밋했어도 여전히 가입 축하 쿠폰이나 추천인 포인트 제공 서비스가 진행되지 않는 문제점이 존재한다.

cf) 동기적으로 실행 중인 스레드가 Block 되었다면 다른 HTTP 요청을 처리할 수 있을까?

스프링 톰캣(WAS)는 멀티 스레드로 동작하기 때문에 현재 요청이 Block 상태여도, 다른 요청은 별도의 스레드로 실행된다는 점을 주의하자!

사용자에게 빠른 응답을 제공하고, 외부서비스와의 강한 결합을 해소하기 위해 비동기 프로그래밍이 필요하다는 걸 알 수 있다.

스프링 비동기 프로그래밍

  • 위의 각 작업을 스레드를 사용하여 비동기적으로 처리할 수 있다. 스프링은 멀티쓰레딩을 지원하기에 @Async 어노테이션을 사용하면, 스레드 풀에 있는 스레드에게 작업을 할당할 수 있게 된다.
  • 스프링 설정 파일에 @EnableAsync 를 추가하고, 비동기적으로 실행할 메서드 위에 @Async 를 추가하면 된다.
@Configuration
@EnableAsync
public class Config { ...}
@Async
public User register(ReqCreateUser req) throws InterruptedException {

  User savedUser = userRepository.save(new User(req.name(), req.email(), req.password(), req.recommender()));

  emailService.sendEmailToNewUser(req.email());

  couponService.sendCouponToNewUser(req.email());

  pointService.chargePoint(req.recommender());

  return savedUser;
}

@Async
public void sendCouponToNewUser(String email) throws InterruptedException { ... }

@Async
public void sendCouponToNewUser(String email) throws InterruptedException { ... }

@Async
public void chargePoint(String referrer) throws InterruptedException { ... }
  • 비동기 처리를 적용하면 회원 가입할 때 해야 하는 작업을 동시에 처리할 수 있다. 아래는 출력 결과(동기, 비동기)이다.

@Async 호출 스택

// 1. Spring이 비동기 메소드를 프록시 객체로 감싸도록 함
public @interface EnableAsync {
	  ... 
	  boolean proxyTargetClass() default false;
    AdviceMode mode() default AdviceMode.PROXY;
		...
}

// 2. 비동기 처리 설정을 하는 ProxyAsyncConfiguration 객체 생성
public class AsyncConfigurationSelector extends AdviceModeImportSelector<EnableAsync> {
	
	@NonNull
  public String[] selectImports(AdviceMode adviceMode) {
			case PROXY -> var10000 = new String[]{ProxyAsyncConfiguration.class.getName()};
			...
  }

  // 3. @Async 어노테이션이 붙은 메소드를 프록시로 변환
  public AsyncAnnotationBeanPostProcessor asyncAdvisor() {
	    ...
	    AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
	    bpp.configure(this.executor, this.exceptionHandler); // 실행자 설정
	    bpp.setProxyTargetClass(this.enableAsync.getBoolean("proxyTargetClass"));
	    ...
  }
}

CompletableFuture, Join

  • 회원가입이 완료될 때 이메일 발행, 쿠폰 발행, 포인트 충전 등을 비동기적으로 진행하면서도 성공 여부를 로그로 남길 수 있을까? CompletableFuture.allOf()를 이용해 특정 비동기 작업을 선택할 수 있고 join() 메서드를 통해 비동기 작업의 결과를 받을 수 있다.
  • CompletableFuture는 다양한 메서드를 지원한다.
    • supplyAsync(): 비동기 작업을 수행하고 결과를 반환
    • runAsync(): 비동기 작업을 수행하지만 결과를 반환하지 않음
    • thenApply(): 비동기 작업이 완료된 후 그 결과를 다른 작업에 적용
    • thenAccept(): 비동기 작업이 완료된 후 그 결과를 소비
    • thenRun(): 비동기 작업이 완료된 후 실행할 작업을 지정
    • thenCombine(): 두 개의 비동기 작업을 조합하여 결과를 처리
    • exceptionally(): 예외가 발생했을 때 처리할 작업을 지정
    • handle(): 비동기 작업의 성공 또는 실패를 처리
    • allOf(): 여러 비동기 작업을 모두 완료할 때까지 대기
    • anyOf(): 여러 비동기 작업 중 하나가 완료될 때까지 대기
  • 작업 결과가 실패한다면 로그에 기록하고, join 메서드로 해당 작업이 종료될 때까지 기다린다. 변경된 코드는 아래와 같다.
public User register(ReqCreateUser req) throws InterruptedException {

    User savedUser = userRepository.save(new User(req.name(), req.email(), req.password(), req.recommender()));

		CompletableFuture<Void> emailFuture = emailService.sendEmailToNewUser(req.email());

		CompletableFuture<Void> couponFuture = couponService.sendCouponToNewUser(req.email());

		CompletableFuture<Void> pointFuture = pointService.chargePoint(req.recommender());

		CompletableFuture.allOf(emailFuture, couponFuture, pointFuture).join(); 
		
		return savedUser;
}

비동기 방식 단점

1. 단일 책임 원칙 위반

  • 서비스 로직에 비동기 처리 로직이 섞이게 된다.

2. 여전히 외부 서비스와 강한 결합

  • 클라이언트가 외부 서비스를 직접적으로 호출하게 된다. 이는 외부 서비스 코드가 변경되면, 클라이언트 코드도 변경해야 함을 의미한다.
  • 외부 서비스에 장애가 있어 응답이 오지 않는다면? 비동기 방식으로 처리하지만, join() 메서드로 해당 외부 서비스가 완료될 때까지 기다리므로 서비스에 장애가 생긴다. 외부 서비스를 호출하기 전에 별도로 트랜잭션을 커밋해야 한다.

서비스의 단일 책임 원칙 그리고 느슨한 결합을 위해 이벤트 프로그래밍이 필요하다는 것을 알 수 있다!

이벤트 프로그래밍

  • 특정 사건(이벤트)이 발생했을 때, publisher는 이벤트를 발행하고 eventListener는 특정 작업을 수행하는 방식을 말한다.
  • Spring에서는 이벤트 발행과 구독을 위한 기능을 제공한다. 이벤트 발행은 ApplicationEventPublisher, 이벤트 구독은 ApplicationListener 객체를 사용한다.

적용 방법

1. 스프링 설정 추가

@Bean(name = "applicationEventMulticaster")
public ApplicationEventMulticaster simpleApplicationEventMulticaster() {

  SimpleApplicationEventMulticaster eventMulticaster = new SimpleApplicationEventMulticaster();

  eventMulticaster.setTaskExecutor(new SimpleAsyncTaskExecutor());

  return eventMulticaster;
}
  • 애플리케이션 컨텍스트에서 ApplicationEventMulticaster를 찾을 때 해당 타입으로 찾는 것이 아니라 applicationEventMulticaster라는 빈의 이름으로 찾기 때문에 Bean이름을 명시적으로 지정해주어야 한다고 한다.

2. 이벤트 객체 추가

  • 해당 객체를 기준으로 이벤트를 발행하고, 구독한다. 당연한 말이지만, 구독하는 곳에서 이벤트 객체를 인자로 받아야 이벤트를 읽어올 수 있다.
public record UserCreatedEvent(
  String email,
  String recommender) { }

3. 발행자 추가

@Transactional
public User register(ReqCreateUser req) throws InterruptedException {

  User savedUser = userRepository.save(new User(req.name(), req.email(), req.password(), req.recommender()));

  eventPublisher.publishEvent(new UserCreatedEvent(req.email(), req.recommender()));

  return savedUser;
}
  • 기존 코드와 달리 이메일 전송 서비스, 쿠폰 발행 서비스, 포인트 서비스 의존성이 사라진 걸 볼 수 있다.
  • 이벤트를 발행하고, 구독자가 처리하는 구조이기 때문에 느슨한 결합이 가능하다!

4. 구독자 추가

@Service
@Slf4j
public class PointService {

  @Async
  @EventListener
  public void chargePoint(UserCreatedEvent event) throws InterruptedException {

    if (event.recommender() == null || event.recommender().isEmpty()) {
      log.info("referrer is empty or null : {}", event.recommender());
      return;
    }

    log.info("charge point");
    for (int i = 0; i < 2; i++) {
      log.info("charging point...");
      Thread.sleep(1000);
    }
  }
}
  • 이벤트 객체를 인자로 받은 것을 유의하자! 아래와 같이 회원 가입 이벤트가 발생하면 여러 구독자가 특정 행위를 하는 것을 볼 수 있다.

이벤트 방식 단점

1. 코드 흐름 파악 어려움

  • 느슨하게 결합되어 있는 만큼 이벤트를 발행했을 때 어디에서 해당 이벤트를 소비하는지 발행한 코드에서 보이지 않기 때문이다.

2. 예외 처리

  • 특정 이벤트에서 예외가 발생했을 때 어떤 이벤트를 실행시키지 않아야 한다면 이벤트 리스너는 각각 독립적으로 동작하므로 전후관계 처리가 까다롭다.
  • 각각의 이벤트 리스너에서 예외 처리를 별도로 해야 한다.

참고자료

profile
꾸준하게

0개의 댓글