본문 바로가기

⛳️ 공동구매 서비스 총대마켓

⚠️ 동시성 이슈 해결 과정: Spring Event로 비동기 처리하기

 

푸시알림 기능 추가 후 QA 과정에서 발생한 문제의 원인을 파악하고 해결하는 과정을 공유합니다.

 

 

문제 

채팅방 목록을 조회할 때 500 예외가 발생하여, 채팅방 목록에 데이터가 조회되지 않는 상황이 발생했습니다.

LoggingErrorResponse[identifier=ceedb3fa-300d-4ee4-852d-c9d83826132e, memberIdentifier={"sub":"1010","exp":1729660386}, httpMethod=GET, uri=/comments, requestBody=, statusCode=500, errorMessage=, latency=24ms, stacktrace=org.springframework.dao.IncorrectResultSizeDataAccessException: Query did not return a unique result: 2 results were returned

 

발생 지점은 아래 코드였습니다.

@Transactional(readOnly = true)
public CommentRoomAllResponse getAllCommentRoom(MemberEntity member) {
    // ...
	OfferingMemberEntity offeringMember = offeringMemberRepository.findByOfferingIdAndMember(offeringId, member)
            .orElseThrow(() -> new MarketException(OfferingMemberErrorCode.NOT_FOUND));
    // ...
}

 

Repository에서 한건의 데이터가 Optional로 반환되어야 하는데 여러 건의 데이터가 조회되었기 때문입니다. OfferingMember는 사용자가 공모에 참여할 때 삽입되는 데이터로, 한 번의 참여만 가능합니다.

 

하지만 왜 중복 참여가 가능했을까요?

 

 

문제 원인 분석

동일한 API가 중복으로 호출되어 일명 '따닥' 문제로 중복 참여가 가능한 상황이었습니다. 동일한 API가 중복으로 두 번 호출되었다 가정하면, 첫 번째 API가 완료되기 전 두 번째 API가 호출되어 중복 검증에 걸리지 않게 되는 것이죠. 따라서 동일한 데이터 두 개가 모두 DB 테이블에 저장되는 것입니다.

 

그렇다면 왜 동일한 API가 중복으로 호출되었을까요?

 

아래 로그로 알 수 있듯, 알림 기능을 도입하니 참여하기 API Latency가 1초를 초과하였습니다. 1초 이내에 사용자가 버튼을 한 번 더 클릭할 경우 같은 API가 한 번 더 호출될 수 있겠죠. 1초는 사용자가 버튼을 여러 번 클릭하기에 매우 충분한 시간입니다.

 

 

그렇다면 알림 기능 도입 후 API Latency가 왜 증가했을까요?

 

아래와 같이, 기존 로직에 이어 알림 로직을 동기로 작성하였기 때문입니다. 알림 메시지 전송이 완료될 때까지 클라이언트 및 사용자는 응답을 받지 못하는 것이죠. 참여 로직이 완료되어도 알림 로직이 완료되지 않으면 응답을 받지 못하는 상황입니다. 참여 API 뿐 아니라 알림 로직이 추가된 모든 API에 대해 위와 같이 Latency가 증가하는 현상이 발생했습니다.

@Transactional
public Long participate(ParticipationRequest request, MemberEntity member) {
    // 1. 참여 로직
    // ...
    
    // 2. 알림 로직
    notificationService.participate(saved);
    return /**/;
}

 

정리하면 위 예외가 발생하게 된 흐름은 아래와 같습니다.

알림 로직 동기 처리 → API Latency 증가 → 동일 API 중복 호출 → 중복 데이터로 인한 500 예외 발생

 

 

해결 과정

이에 두 가지 해결 방안이 존재합니다.

 

1. 클라이언트의 디바운스 처리

 

일정시간 동안 API 요청에 지연을 두어, 사용자가 버튼을 여러 번 클릭해도 한 번의 API만 호출되도록 하는 방법입니다. 협업하는 클라이언트 팀원에게 1초의 디바운스를 걸어줄 것을 요청하였고, 감사하게도 API Latency를 개선하지 않아도 `따닥` 문제는 어느 정도 방지할 수 있었습니다.

 

2. 서버의 알림 로직 비동기 처리

 

알림 로직을 비동기로 처리하여, 알림 로직의 성공 여부와 관계없이 기존 로직이 완료될 경우 즉시 응답하는 방법입니다. 비동기 처리를 어떻게 진행하였는지와 더불어 비동기 처리로 얻게 된 장점들은 아래에 이어 설명하겠습니다.

 

알림 로직 비동기 처리

구현은 아래 두 방식을 통해 진행하였습니다.

  • 스프링 이벤트
  • @Async 어노테이션

스프링 이벤트

아래는 동기로 알림 로직을 도입했을 때의 설계도입니다. 알림 로직을 아래와 같이 설계하였기 때문에 XXXService의 기존 로직이 마무리되어도 하위의 알림 로직이 마무리되어야만 응답할 수 있었습니다.

기존 설계도

아래는 이벤트를 통해 개선된 설계도입니다. XXXService에서 알림 로직을 직접 호출하는 방식이 아닌, EventPublisher를 통해 이벤트를 발행하고 EventListener가 발행된 이벤트를 감지해 알림 로직을 호출하도록 합니다.

개선 설계도

 

아래는 실제 구현한 총대마켓 코드입니다.

 

Event

Event 클래스는 특정 이벤트를 구현하는 역할입니다. EventPublisher에 의해 발행되고, EventListener에 의해 감지됩니다. 스프링 4.2 버전부터는 ApplicationEvent를 확장하지 않고 Object 객체로서 이벤트의 역할을 수행할 수 있으나, 해당 클래스가 이벤트임을 나타내기 위해 ApplicationEvent를 확장해 주었습니다. Baeldung에서는 이벤트 클래스를 단순히 이벤트 데이터를 저장하는 플레이스홀더라고 설명합니다.

@Getter
public class ParticipateEvent extends ApplicationEvent {

    private final OfferingMemberEntity offeringMember;

    public ParticipateEvent(Object source, OfferingMemberEntity offeringMember) {
        super(source);
        this.offeringMember = offeringMember;
    }
}

 

EventPublisher

Publisher는 특정 이벤트를 발행하는 역할이며, spring context에서 제공하는 ApplicationEventPublisher를 사용하였습니다. 아래 코드를 통해 Event 클래스가 ApplicationEvent를 꼭 확장할 필요가 없음을 알 수 있습니다.

@FunctionalInterface
public interface ApplicationEventPublisher {
    default void publishEvent(ApplicationEvent event) {
        this.publishEvent((Object)event);
    }

    void publishEvent(Object event);
}

 

XXXService가 ApplicationEventPublisher에 의존하여 이벤트를 발행합니다.

public class OfferingMemberService {

    // ...
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Long participate(ParticipationRequest request, MemberEntity member) {
        // ...
        eventPublisher.publishEvent(new ParticipateEvent(this, saved));
        // ...
    }
}

 

EventListener

Listener는 특정 이벤트를 감지하였을 때 선언된 로직을 실행하는 역할입니다. 스프링 4.2 버전부터는 추가 구현 없이 @EventListner 어노테이션만으로 쉽게 구현이 가능해 해당 방법을 채택했습니다. @EventListener 어노테이션이 붙은 메서드의 파라미터로 선언된 이벤트가 감지되면 해당 메서드가 실행됩니다.

EventListener는 NotificationService에 의존하도록 하여, 이벤트를 감지한 시점에 알림 로직을 수행하도록 하였습니다. 별도로 비동기 처리를 하지 않으면 아래 로직은 기본적으로 동기로 수행됩니다.

@RequiredArgsConstructor
@Component
public class FcmEventListener {

    private final FcmNotificationService notificationService;

    @EventListener
    public void handleParticipateEvent(ParticipateEvent event) {
        notificationService.participate(event.getOfferingMember());
    }
    // ...
}

 

@Async 어노테이션

AsyncConfig

비동기 관련 설정을 위해 아래와 같이 Config 클래스를 선언합니다.

getAsyncExecutor() 메서드에서 반환되는 Executor로 비동기 로직이 실행되며, 풀사이즈와 큐사이즈는 추후 테스트를 통해 더 의미 있는 값들로 수정할 예정입니다. getAsyncUncaughtExceptionHandler() 메서드는 예외 핸들러를 정의한 메서드입니다.

@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        int processors = Runtime.getRuntime().availableProcessors();
        executor.setCorePoolSize(processors);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setThreadNamePrefix("AsyncExecutor-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncEventExceptionHandler();
    }
}

 

EventListener

감지된 이벤트에 의해 로직을 수행할 때 알림 로직을 비동기적으로 처리하기 위해 @Async 어노테이션을 달아주었습니다.

@RequiredArgsConstructor
@Component
public class FcmEventListener {

    private final FcmNotificationService notificationService;

    @EventListener
    @Async
    public void handleParticipateEvent(ParticipateEvent event) {
        notificationService.participate(event.getOfferingMember());
    }
    // ...
}

 

@Async 어노테이션을 통해, 아래와 같이 알림 로직은 비동기적으로 다른 스레드에서 처리됨을 확인할 수 있습니다.

 

ExceptionHandler

비동기 로직의 경우, void로 반환되는 메서드는 호출 스레드로 예외가 전파되지 않습니다. 따라서 AsyncUncaughtExceptionHandler 인터페이스 구현을 통해 잡히지 않은 예외를 다룰 수 있습니다. 저는 알림 관련 예외를 로깅하는 방향으로 예외를 처리해 주었습니다.

@Slf4j
public class AsyncEventExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable throwable, Method method, Object... obj) {
        log.info("비동기 예외 발생", throwable);
    }
}

 

결과 비교

동기 → 비동기

알림 로직을 동기에서 비동기 처리 방식으로 변경했을 때의 API Latency를 비교 측정해 보았습니다.

 

글 작성 API

  • API Latency 1.9초 → 0.03초, 약 50배 개선
  • 작성 로직과 알림 로직이 다른 스레드에서 비동기적으로 실행됨을 확인

동기
비동기

 

참여 API

  • API Latency 1.1초 → 0.05초, 약 20배 개선

동기
비동기

 

댓글방 상태 변경 API

  • API Latency 0.7초 → 0.03초, 약 20배 개선

동기
비동기

 

채팅 전송 API

  • API Latency 0.2초 → 0.04초 약 7배 개선

동기
비동기

 

 

얻은 점

동기로 실행되던 알림 로직을 비동기로 실행함으로써 얻은 이점은 아래와 같습니다.

 

API Latency 감소

API Latency가 평균 20배 감소하여, 동일한 API를 중복 호출하는 `따닥` 문제를 방지할 수 있었습니다.

 

관심사 분리

알림 로직 관련 예외는 클라이언트에게 던지지 않고 따로 관리할 수 있었습니다. 또한, 기존 비즈니스 로직과 알림 로직을 분리해 관심사를 달리하여 관리할 수 있었습니다.

 

느슨한 결합

비즈니스 서비스가 알림 서비스에 직접 의존하지 않고, 이벤트를 활용했습니다. 덕분에 비즈니스 로직과 알림 로직은 느슨한 결합을 유지하여 로직 간 결합도를 최소화할 수 있었습니다.

 

 

참고

- https://www.baeldung.com/spring-events

- https://www.baeldung.com/spring-async