실무에 사용할법한 주제를 학습합니다.
Before
이전 회사에서 대량의 회원을 대상으로 메일을 발송한 경우에
데이터베이스 조회 성능이 저하되며 락이 걸리고
이후 서버가 죽는 경우가 실제로 발생했었다.
여러가지 해결 방법이 많겠지만 당시에는 긴 조회 쿼리를 해결해야한다는 생각만 가지고 쿼리를 분석했던 경험이 있다.
이렇게 트래픽이 몰리는 상황에서 사용자당 1건만 수신 가능한 이벤트가 있는 경우에
어떤 기술을 사용해서 해결할 수 있는지 궁금하여 수강을 했다.
당장 실무에서 사용할지 모르겠으나, 개인적으로 시작할 이후 사이드프로젝트에 적용을 목표로 공부했다.
선착순 100명 쿠폰 발급, 어떻게 구현하지?
서비스로직 version1
public void applyV1(Long userId) {
long count = couponRepository.count();
if (count > 100) {
return;
}
couponRepository.save(new Coupon(userId));
}
가장 쉽게 생각할 수 있는 로직.
선착순 쿠폰이 100건 초과 한 경우 더이상 발급 못하게 하면 되잖아?
세팅
docker pull mysql
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=1234 --name mysql mysql
docker exec -it mysql bash
mysql -u root -p
create database coupon_example;
use coupon_example;
Test code 작성
@DisplayName("V1.1000개의 요청을 받았을때 100개까지 쿠폰을 등록한다.")
@Test
void checkWhenApplyMultipleRequestV1() throws InterruptedException {
// given
int threadCount = 1000;
// 멀티스레드 병렬작업을 도와주는 자바 API
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 스레드에서 수행하는 작업을 기다리도록 처리하는 클래스
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// when
for(int i = 0; i < threadCount; i++) {
long userId = i;
executorService.submit(() -> {
try {
applyService.applyV1(userId);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
long count = couponRepository.count();
// then
assertThat(count).isEqualTo(100);
}
위에서 만든 서비스 로직을 테스트코드로 옮기고 실행을 해보았다.
내가 의도한 바와 다르게 121개나 쿠폰이 생성되었다.
왜그런걸까?
Thread-1 | 쿠폰 갯수 | Thread-2 |
.. | 1, 2 ... 98 | ... |
(쿠폰 갯수 조회) -> | 99개 | |
99개 | <- (쿠폰 갯수 조회) | |
(쿠폰 생성) | 100개 | |
101개 | <- (쿠폰 생성) |
*스레드가 2개만 있다고 가정했을때 공유 자원에 접근하고 작업을 하려고 할때 발생하는 문제점을
Race Condition이라고 하는데 위 상황은 이 문제가 발생한것이다.
1. Redis 사용
Redis는 여러 데이터 조작 명령을 "원자적"으로 처리한다.
즉, 특정 명령이 수행되는 동안 다른 명령이 끼어들거나 중간에 방해를 받지 않도록 보장하는데,
이는 Race Condition을 해결하는 가장 큰 요소이다.
따라서, 쿠폰 발급횟수를 레디스 명령어인 INCR(Increment)로 카운트 하는 경우에
여러 스레드가 동시에 자원 수정을 시도하려고 하더라도
데이터의 일관성을 유지할 수 있다.
세팅
docker pull redis
docker run --name myredis -d -p 6379:6379 redis
docker exec -it redisContainerName redis-cli
서비스로직 version2(레디스)
public void applyV2(Long userId) {
// 수정
Long count = couponCountRepository.increment();
if (count > 100) {
return;
}
couponRepository.save(new Coupon(userId));
}
// redis의 increment 사용
public Long increment() {
return redisTemplate
.opsForValue()
.increment("coupon_count");
}
(아주 산뜻한 초록색 결과)
그러나 또 다른 문제점 발생
쿠폰 발급 갯수를 제한하는것은 성공했으나, 다른 문제점이 발생했다.
서버와 데이터베이스를 하나씩 사용한다고 했을 때,
데이터베이스에 Insert를 1분에 100건씩이 가능하다고 가장해보자,
시간 | 요청 |
15:00 | 쿠폰 생성 요청 10,000건 |
15:01 | 주문 생성 요청 2건(대기) |
15:02 | 회원가입 요청 1건(대기) |
... 계속 대기 중 |
이런 상황에서 쿠폰 요청이 모두 처리될때까지 주문과 회원가입 요청은 앞선 요청이 완료될때까지 대기 상태로 있고,
대부분의 데이터베이스는 타임아웃을 가지고 있다보니,
실제로 기능상 장애가 없지만, 주문과 회원가입은 타임아웃으로 요청이 실패하게 된다.
2. Kafka 사용
쿠폰 발행을 레디스로 처리하게 되면
동시성 문제를 해결할 수 있으나, 타임아웃 같은 사태가 발생할 수 있다.
기존 로직
쿠폰 생성 요청
-> 레디스 명령어로 카운트
-> DB에 인서트
하는 과정을 카프카 이벤트 처리방식으로 개선해보자.
카프카
쿠폰 생성 요청
-> 카프카 프로듀서를 통해 이벤트(메세지)를 coupon_create라는 토픽으로 발행
kafkaTemplate.send("coupon_create", userId);
-> 컨슈머에서 원하는 로직 실행 (CouponRepository에 저장)
@KafkaListener(groupId = "group_1", topics = "coupon_create")
public void listener(Long userId) {
couponRepository.save(new Coupon(userId));
}
-> 끝
세팅
docker-compose.yml
version: '2'
services:
zookeeper:
image: wurstmeister/zookeeper
container_name: zookeeper
ports:
- "2181:2181"
kafka:
image: wurstmeister/kafka:2.12-2.5.0
container_name: kafka
ports:
- "9092:9092"
environment:
KAFKA_ADVERTISED_HOST_NAME: 127.0.0.1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
volumes:
- /var/run/docker.sock:/var/run/docker.sock
// 토픽 생성
docker exec -it kafka kafka-topics.sh --bootstrap-server localhost:9092 --create --topic coupon_create
// 컨슈머 생성
docker exec -it kafka kafka-console-consumer.sh --topic coupon_create --bootstrap-server localhost:9092 --key-deserializer "org.apache.kafka.common.serialization.StringDeserializer" --value-deserializer "org.apache.kafka.common.serialization.LongDeserializer"
서비스로직 version3
// kafka
public void applyV3(Long userId) {
Long count = couponCountRepository.increment();
if (count > 100) {
return;
}
couponCreateProducer.create(userId);
}
private final KafkaTemplate<String, Long> kafkaTemplate;
// 프로듀서 생성
public void create(Long userId) {
kafkaTemplate.send("coupon_create", userId);
}
// 컨슈머
private final CouponRepository couponRepository;
private final FailedEventRepository failedEventRepository;
private final Logger logger = LoggerFactory.getLogger(CouponCreatedConsumer.class);
@KafkaListener(groupId = "group_1", topics = "coupon_create")
public void listener(Long userId) {
try {
System.out.println(userId);
couponRepository.save(new Coupon(userId));
} catch (Exception e) {
logger.error("failed event : "+e.getMessage());
failedEventRepository.save(new FailedEvent(userId));
}
}
}
끝
전체 소스코드
(https://github.com/pnci1029/mypro/tree/master/coupon-system)
강의 링크
'공부' 카테고리의 다른 글
가상화 기술을 사용하는 이유 (쉬운 도커1) (0) | 2025.03.15 |
---|---|
Props Drilling을 개선하는 방법 (0) | 2025.02.14 |
팩토리 메소드 패턴이란? (0) | 2024.06.06 |
Practical Testing: 실용적인 테스트 가이드 강의를 마치며 (0) | 2024.03.06 |
동시성과 병렬성 (0) | 2024.02.02 |