외주 매칭 동시성 제어 문제

Terror·2024년 11월 11일

최종 프로젝트

목록 보기
25/28

Overivew

  • 상황에 맞는 동시성 제어 처리를 고를 수 있습니다

시나리오

  • 회사는 공고(외주)를 올릴 수 있다
  • 유저는 개인(포트폴리오)를 올릴 수 있다
  • 유저는 회사의 공고에 대해 여러명이 동시에 매칭(신청)을 할 수 있다
  • 이 때의 동시성제어 처리 문제에 대해 해결하여 보자

과부하 테스트 도구

  • 파이썬 기반의 Locust를 활용하였습니다
  • 자세한 활용법은 이곳 을 참고 바랍니다

MatchingServiceImpl

    @Transactional
    @Override
    public ApiResponse<Void> toggleMatching(MatchingRequest.MatchingRequestCreate reqDto, AuthUser authUser) {
        try {
            MatchingValidator.isIndividual(authUser);
            User user = User.fromAuthUser(authUser);
            Outsourcing outsourcing = outsourcingService.findById(reqDto.outsourcingId());
            Portfolio portfolio = portfolioService.findById(reqDto.portfolioId());
            Optional<Matching> findMatching = matchingRepository.findByUserIdAndOutsourcingIdAndPortfolioId(user.getId(), outsourcing.getId(), portfolio.getId());
            // 값이 존재한다면
            if (findMatching.isPresent()) {
                MatchingValidator.isMe(findMatching.get().getUser().getId(), user.getId());
                matchingRepository.delete(findMatching.get());
            } else {
                MatchingValidator.isMe(portfolio.getUser().getId(), user.getId());
                Matching matching = Matching.builder()
                        .user(user)
                        .portfolio(portfolio)
                        .outsourcing(outsourcing)
                        .status(MathingStatusType.READY)
                        .build();
                matchingRepository.save(matching);
            }
            return ApiResponse.of(MATHCING_SUCCESS);
        } catch (OptimisticLockingFailureException e) {
            throw new MatchingException(MATCHING_IS_MANY);
        }
    }

낙관적락

  • 어플리케이션단에서의 처리가 가능하고, DB의 부하를 줄여주는 낙관적락을 통해 해결하여보자
  • Matching Entity에 version entity를 주고, 아래와 같이 요청해보자

실험결과

  • 98번의 요청중 9번이 실패 하였습니다
  • 평균(Median)의 의 요청속도는 240ms입니다
  • 제일 빠른 요청은 13ms에 처리되었습니다
  • 제일 느린 요청은 543ms에 처리되었습니다
  • 초당 서버는 7.5개를 처리하였습니다

에러

Query did not return a unique result: 2 results were returned

  • 동시에 너무 많은 요청이 들어오면서 낙관적락을 처리하였음에도, 불구하고 동시성 제어 처리가 되지못해 동일한 매칭처리가 DB에 저장된 모습이다
  • 사실 당연하다, 낙관적락은 애초에 이렇게 많은 동시성제어 처리를 하기위해 만들어진것이 아니기 때문에

차트

비관적락

  • 상단과 동일한 과부하 테스트 도구의 요청으로 진행 하였습니다
@Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT m from Matching m " +
            "WHERE m.user.id = :userId " +
            "AND m.outsourcing.id = :outsourcingId " +
            "AND m.portfolio.id = :portfolioId"
    )
    Optional<Matching> findByUserIdAndOutsourcingIdAndPortfolioId(Long userId, Long outsourcingId, Long portfolioId);

실험결과

  • 120번의 요청중 0번이 실패 하였습니다
  • 평균(Median)의 의 요청속도는 270ms입니다
  • 제일 빠른 요청은 16ms에 처리되었습니다
  • 제일 느린 요청은 588ms에 처리되었습니다
  • 초당 서버는 9.14개를 처리하였습니다

에러

  • 없음
  • 확실히 데이터베이스에 직접 Lock을 걸어 제어하니, 에러가 발생되지 않는 모습이다
  • 하지만 성능은 다소 떨어진 모습이다

차트

Redis 싱글쓰레드

  • redis의 싱글쓰레드 특성을 활용하여, 해결하는 예제이다

Field

    private final StringRedisTemplate redisTemplate;
    private final ExecutorService executorService = Executors.newSingleThreadExecutor(); // 단일 스레드 풀로 요청을 순차 처리합니다.
    private static final String QUEUE_KEY = "toggleMatchingQueue";

Constructor

    public MatchingServiceImpl(MatchingRepository matchingRepository, RedisTemplate<String, Object> redisTemplate) {
        this.matchingRepository = matchingRepository;
        this.redisTemplate = redisTemplate;
        startProcessingQueue();
    }

startProcessingQueue()

    private void startProcessingQueue() {
        executorService.submit(() -> {
            while (true) {
                String requestId = (String) redisTemplate.opsForList().rightPop(QUEUE_KEY);
                if (requestId != null) {
                    processMatching(requestId);
                }
            }
        });
    }

processMatching()

    private void processMatching(String requestId) {
        String[] parts = requestId.split(":");
        Long userId = Long.valueOf(parts[0]);
        Long outsourcingId = Long.valueOf(parts[1]);
        Long portfolioId = Long.valueOf(parts[2]);

        // 기존 toggleMatching 로직을 여기에 넣어줍니다.
        // 예시: matchingRepository.findByUserIdAndOutsourcingIdAndPortfolioId 등을 이용하여 로직 수행
        Optional<Matching> findMatching = matchingRepository.findByUserIdAndOutsourcingIdAndPortfolioId(userId, outsourcingId, portfolioId);
        if (findMatching.isPresent()) {
            matchingRepository.delete(findMatching.get());
        } else {
            Matching matching = Matching.builder()
                    .user(User.builder().id(userId).build()) // 필요한 유저 객체 생성
                    .outsourcing(Outsourcing.builder().id(outsourcingId).build()) // 필요한 아웃소싱 객체 생성
                    .portfolio(Portfolio.builder().id(portfolioId).build()) // 필요한 포트폴리오 객체 생성
                    .status(MathingStatusType.READY)
                    .build();
            matchingRepository.save(matching);
        }
    }

toggleMatching()

    public ApiResponse<Void> toggleMatching(MatchingRequest.MatchingRequestCreate reqDto, AuthUser authUser) {
        String requestId = authUser.getUserId() + ":" + reqDto.outsourcingId() + ":" + reqDto.portfolioId();
        redisTemplate.opsForList().leftPush(QUEUE_KEY, requestId); // 요청을 Redis 큐에 추가합니다.
        return ApiResponse.of(MATHCING_SUCCESS);
    }

실험결과

  • 197번의 요청중 0번이 실패 하였습니다
  • 평균(Median)의 의 요청속도는 10ms입니다
  • 제일 빠른 요청은 3ms에 처리되었습니다
  • 제일 느린 요청은 136ms에 처리되었습니다
  • 초당 서버는 14.5개를 처리하였습니다

에러

  • 없음
  • redis의 싱글쓰레드 특성을 활용하였기 떄문에, 확실히 에러도 없다
  • 또한 redis의 InMemory 특성을 활용하다보니 시간도 훨씬 감축된 모습이다

차트

Message Queue

  • 메세지큐중, RabbitMQ를 활용하여 비동기 + 순차적으로 처리해보자

gradle

implementation 'org.springframework.boot:spring-boot-starter-amqp'

yml

  rabbitmq:
    host: localhost
    port: 5672
  • rabbitMq설정 추가 (본인은 도커로 띄우고 실행하였음)

toggleMatching()

public void toggleMatching(MatchingRequest.MatchingRequestCreate reqDto, AuthUser authUser) {
    MatchingRequestMessage message = new MatchingRequestMessage(
        reqDto.getOutsourcingId(),
        reqDto.getPortfolioId(),
        authUser.getId(),
        authUser.getRole()
    );

    rabbitTemplate.convertAndSend("toggleMatchingQueue", message);
}

MatchingRequestMessage

import java.io.Serializable;

public class MatchingRequestMessage implements Serializable {

    private Long outsourcingId;
    private Long portfolioId;
    private Long userId;
    private String userRole;

    public MatchingRequestMessage() {
    }

    public MatchingRequestMessage(Long outsourcingId, Long portfolioId, Long userId, String userRole) {
        this.outsourcingId = outsourcingId;
        this.portfolioId = portfolioId;
        this.userId = userId;
        this.userRole = userRole;
    }

    // 게터와 세터
}

MatchingMessageListener

@Component
public class MatchingMessageListener {

    @Autowired
    private MatchingRepository matchingRepository;
    @Autowired
    private OutsourcingService outsourcingService;
    @Autowired
    private PortfolioService portfolioService;

    @RabbitListener(queues = "toggleMatchingQueue") // 메시지 큐의 소비자로 등록
    public void processMatching(MatchingRequestMessage message) {
        // 메서드 내 toggleMatching 로직 구현
        try {
            AuthUser authUser = message.getAuthUser();
            MatchingValidator.isIndividual(authUser);
            User user = User.fromAuthUser(authUser);
            Outsourcing outsourcing = outsourcingService.findById(message.getOutsourcingId());
            Portfolio portfolio = portfolioService.findById(message.getPortfolioId());
            Optional<Matching> findMatching = matchingRepository.findByUserIdAndOutsourcingIdAndPortfolioId(user.getId(), outsourcing.getId(), portfolio.getId());

            if (findMatching.isPresent()) {
                MatchingValidator.isMe(findMatching.get().getUser().getId(), user.getId());
                matchingRepository.delete(findMatching.get());
            } else {
                MatchingValidator.isMe(portfolio.getUser().getId(), user.getId());
                Matching matching = Matching.builder()
                        .user(user)
                        .portfolio(portfolio)
                        .outsourcing(outsourcing)
                        .status(MatchingStatusType.READY)
                        .build();
                matchingRepository.save(matching);
            }
        } catch (OptimisticLockingFailureException e) {
            throw new MatchingException(MATCHING_IS_MANY);
        }
    }
}

실험결과

  • 213번의 요청중 0번이 실패 하였습니다
  • 평균(Median)의 의 요청속도는 5ms입니다
  • 제일 빠른 요청은 2ms에 처리되었습니다
  • 제일 느린 요청은 50ms에 처리되었습니다
  • 초당 서버는 15.44개를 처리하였습니다

에러

  • 없음
  • 비동기적으로 처리될 뿐만 아니라, 메세지 큐를 활용한 순차적으로 처리되기 까지하여 신속하고 빠르게 처리되는 이점을 보였다

차트

결론

  • RabbitMq > Redis > 낙관적 락 > 비관적 락
  • 정도의 성능순서가 있었지만, 낙관적락은 동시성 제어가 제대로 활용되지 못하여 제외되고, 비관적 락은 성능적으로의 이점이 정확히 표출되지않아 제외 시켰습니다
  • 위에의 상황으로 미루어 보았을때, rabbitMq를 활용한 방법이 성능적으로 가장 우수하였습니다
  • 하지만 우리는 아래와 같은 문제들로 인해 고르지 않았습니다
    1. 비동기적으로 처리됐을때의 예외처리의 복잡함
    2. 우리 프로젝트에서는 트래픽이 몰릴일이 많지않음 (추후 문제 발생시 개선 여지 가능성 있음)
    3. rabbitMq라는 서버를 띄워야하는 서버비용 부담
  • 우리는 따라서 redis의 싱글쓰레드 특성을 활용하여 해결하는 방법을 택하여, 서버비용 부담을 줄이면서도 성능적으로 우위를 점 할 수 있는 기술을 선택하였습니다
profile
테러대응전문가

0개의 댓글