비동기가 무엇

yboy·2022년 9월 17일
2

Learning Log 

목록 보기
20/41
post-thumbnail

학습동기

우아한테크코스 프로젝트 중, 큰 문제가 발생했다. 문제가 뭐냐면...

image

(연속으로 예약하기 버튼을 계속 누르면 위 에러가 계속 발생해 서비스가 다운되는 문제....)

  • 예약하기가 2초가 걸리는 상황
  • 사용자 입장에서 오래걸리면 예약하기를 연속해서 누르게 되는데 그렇게 하면 에러가 발생 두번째 누르면 이미 예약한 예외라는 checked exception이 발생하지만, 세번째 누르면…. 그때부터

image

다음과 같이 db 예러가 발생되고 이 에러가 프론트 단까지 전파되는 문제(사용자가 예외를 보게된다.)가 발생한다.
문제를 해결하기 위해서 학습을 다짐하게 되었다.

학습내용

우선 위 문제가 발생한 원인에 대해 분석해 보자.

원인

@Transactional
public Long save(Long crewId, ReservationReserveRequest reservationReserveRequest) {
		// 예약 테이블관련 작업 
        Crew crew = crewRepository.findById(crewId)
                .orElseThrow(NotFoundCrewException::new);
        Schedule schedule = scheduleRepository.findById(reservationReserveRequest.getScheduleId())
                .orElseThrow(NotFoundScheduleException::new);
        schedule.reserve();
        Reservation reservation = reservationRepository.save(new Reservation(schedule, crew));
        
        // 예약 시트 테이블관련 작업 
				sheetService.save(reservation.getId());

		// 알람을 보내는 작업 
        AlarmInfoDto dto = AlarmInfoDto.of(schedule.getCoach(), crew, schedule.getLocalDateTime());
        alarmService.send(dto, AlarmTitle.APPLY);

        return reservation.getId();
    }

예약하기 로직은 다음과 같다.

  1. 예약 테이블관련 작업

  2. 예약 시트 테이블관련 작업

  3. 외부 슬렉 라이브러리를 사용해 알람을 보내는 작업

    심지어 슬렉 라이브러리를 직접 이용하는 것이 아니라……

    3-1. 외부 알람 서버에 알람을 보내달라는 요청을 보낸다.

    3-2. 외부 알람 서버가 슬렉 라이브러리를 이용해 알람을 보낸다.

팀원들과 회의한 끝에 한 트랜잭션 단위가 너무 많기 때문에 생긴 문제(3번 작업이 너무 무겁다)라는 결론에 도달했다.

해결

알람 처리 로직을 비동기로 처리해 해결하였다.

@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class AlarmService {

    @Value("${slack.bot.secret-key}")
    private String botSecretKey;
    private final WebClient botClient;
    private final ReservationRepository reservationRepository;

    public void requestAlarm(SlackAlarmDto alarmDto) {
        try {
            botClient.post()
                    .uri("/api/send")
                    .header("Authorization", botSecretKey)
                    .bodyValue(alarmDto)
                    .retrieve()
                    .bodyToMono(SlackAlarmDto.class)
                    .block()
        } catch (WebClientException e) {
            log.error("슬랙 알람 전송 중 예외가 발생했습니다. {} {}", e.getMessage(), e);
        }
    }
}

위 코드가 알람을 보내는 (위에서 3-1에 해당되는 로직) 기능을 하는 비동기로 처리되어야 하는 코드이다.

그럼 문제 해결의 실마리가 되는 동기/비동기 개념에 대해 알아보자.

동기/비동기

동기와 비동기는 호출한 결과의 완료 여부를 확인하는지 안하는지에 따라 구분된다.

동기(Synchronous)

  • 순서대로 실행된다.
  • 요청을 보내고 응답이 오기 전까지 아무것도 할 수가 없다.

위의 문제 로직에서 1, 2, 3번을 모두 동기로 처리해서 많은 시간이 소요됐던 것이다.

비동기(Asynchronous)

  • 순서대로 실행되지 않는다.
  • 요청을 보내고 응답이 오기 전까지 다른 일을 처리할 수 있다.
  • 싱글스레드에서는 비동기에 대해서 고민하지 않아도 된다.
  • 자바에서 다른 스레드없이 Main함수를 통하여 실행한다면 이는 항상 동기로 동작한다.

비동기에 대해 알고난 후, 위의 문제 로직이 모두 동기로 처리될 필요는 없다는 판단에 이르렀다. 예약이 완료됐다 는 것을 사용자에게 알람이 가고 난 후에 꼭 알려줄 필요는 없다.

알람이 가던지 말던지 예약은 완료된 것이고 무거운 외부 알람 로직 때문에 중요 로직에 시간 지연 문제가 발생하는 것은 옳지 않다. 알람을 보내는 과정 자체를 스레드가 기다리는 것이 아니라 요청을 보내고 스레드는 다른 일(다른 스레드가 알람을 보내는 일을 처리한다.)을 하는 것이 적합한 방법이라고 생각한다.

그럼 스프링에서 어떻게 비동기를 적용했을까?

비동기 적용

@Async

  • 비동기적으로 처리할 수 있게끔 스프링에서 제공하는 어노테이션

  • 해당 어노테이션이 붙은 메서드는 다른 스레드로 분리되어 실행

  • 어노테이션을 사용하기 위해서 @EnabledAsync가 달려있는 configuration 클래스 생성이 선행되어야 한다.

  • 해당 어노테이션이 붙은 메서드는 public이어야 한다.

  • 동일 클래스에서 호출하는 메서드이면 안된다.

    스프링 어노테이션(AOP)은 프록시로 동작하기 때문

@EnabledAsync

  • @Async 어노테이션 감지

스프링은 비동기적으로 메서드를 실행하기 위해서 SimpleAsyncTaskExecutor 를 사용한다. SimpleAsyncTaskExecutor는 요청이 오는대로 계속해서 쓰레드를 생성하는데 요청이 오는대로 계속해서 쓰레드를 생성하는 것은 비효율적이라는 판단을 했다.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(5);
        executor.setThreadNamePrefix("asyncThread");
        executor.initialize();
        return executor;
    }
}

따라서 다음과 같이 AsyncConfigurer 인터페이스를 구현하고getAsyncExecutor() 를 오버라이딩해주었다. 위와 같이 하면 default Executor가 getAsyncExecutor()을 통해 설정된 Executor가 된다.

  • CorePoolSize: 기본적으로 실행 대기 중인 Thread 개수
  • MaxPoolSize: 동시에 동작하는 최대 Thread 개수
  • ThreadNamePrefix: 동작하는 스레드 이름
	@Async 
    public void requestAlarm(SlackAlarmDto alarmDto) {
        try {
            botClient.post()
                    .uri("/api/send")
                    .header("Authorization", botSecretKey)
                    .bodyValue(alarmDto)
                    .retrieve()
                    .bodyToMono(SlackAlarmDto.class)
                    .block()
        } catch (WebClientException e) {
            log.error("슬랙 알람 전송 중 예외가 발생했습니다. {} {}", e.getMessage(), e);
        }
    }

그리고 다음과 같이 비동기로 동작해야 할 메서드에 @Async 어노테이션을 붙여주면 비동기로 동작하게 된다.

참고

@Configuration
@EnableAsync
public class AsyncConfig {

   @Bean("customAsyncExecutor")
    public Executor customAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(5);
        executor.setThreadNamePrefix("asyncThread");
        executor.initialize();
        return executor;
    }
}

다음과 같이 비동기 처리에서 사용할 Executor를 bean으로 등록해 사용할 수도 있다. 비동기로 사용될 로직이 여러 개이고 각각이 다른 설정 값을 갖는 경우라면 위와 같이 bean으로 여러 개 만들어 사용하는 것이 적절해 보인다.

근데 다음과 같이 bean으로 등록된 Executor를 사용한다면 @Async 어노테이션을 사용할 때 @Async("customAsyncExecutor")

다음과 같이 사용해야 한다.

실행 시간 비교

할 때 마다 차이가 있었지만 비동기로 처리된 로직이 3배정도 빠르다는 것을 포스트맨을 통해 확인해 볼 수 있었다.

비동기 처리를 안해준 경우

image

                       <알람 비동기 처리가 안된 prod 환경>

비동기 처리를 해준 경우

image

                      <알람 비동기 처리가  dev 환경>

마무리

문제가 발생하고 해결하는 과정에서 새로운 개념(비동기)에 대해 알게 된 시간이었다. 문제가 발생하면 그 당시에는 막막하더라도 차근차근 문제에 대해 분석하고 해결해 나가는 것에 재미가 붙게 된 학습이었다.

0개의 댓글