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]);
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);
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) {
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를 활용한 방법이 성능적으로 가장 우수하였습니다
- 하지만 우리는 아래와 같은 문제들로 인해 고르지 않았습니다
- 비동기적으로 처리됐을때의 예외처리의 복잡함
- 우리 프로젝트에서는 트래픽이 몰릴일이 많지않음 (추후 문제 발생시 개선 여지 가능성 있음)
- rabbitMq라는 서버를 띄워야하는 서버비용 부담
- 우리는 따라서 redis의 싱글쓰레드 특성을 활용하여 해결하는 방법을 택하여, 서버비용 부담을 줄이면서도 성능적으로 우위를 점 할 수 있는 기술을 선택하였습니다