| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 공통기술
- model
- 클린아키텍처
- string
- TDD
- SEQUENCE
- 커뮤니티서버
- JDBC
- OOP
- index
- 단위테스트
- 사면초가
- 공통규약
- SpringLegacy
- 이벤트핸들러등록
- 냄새라도
- 이벤트핸들러this
- MVC
- springsecurity
- MVC요청플로우
- Transaction
- Ajax
- 전전긍긍
- JPA
- 문제선택근거
- 설정세팅
- DDD
- 디스크i/o
- 로그백
- AOP
- Today
- Total
개발이군고구마
[Redis/Kafka 이용한 대용량 트래픽 예약 및 쿠폰 발급 서비스] 3. Kafka와 Redis로 동시성, 대량 트래픽 제어 본문
[Redis/Kafka 이용한 대용량 트래픽 예약 및 쿠폰 발급 서비스] 3. Kafka와 Redis로 동시성, 대량 트래픽 제어
김구황 2025. 3. 24. 07:42[Redis/Kafka 이용한 대용량 트래픽 예약 및 쿠폰 발급 서비스] 3. Kafka와 Redis로 동시성, 대량 트래픽
🥑 프로젝트 설계
medium.com
🥑 프로젝트 설계

1. 동시성 제어
멀티 쓰레드가 동시에 같은 자원에 접근하는 것을 막아야 하므로 Lock 을 걸어주어야 함
Lost Update 문제를 락으로만 해결할 수 있을까?
🔏 앞서 살펴본 비관 락 (SELECT FOR UPDATE)의 방법도 무리 없음
@Lock PESSIMISTIC_FORCE_INCREMENT (읽기 조차 막아버림)
=> 바꾸는 테이블 데이터가 HOST 테이블 밖에 없고
=> 현재 아키텍처 구조상 트랜잭션이 길어지는 경우도 없기 때문
- 분산 서버에서 DB 동기화를 한다는 가정
- DISK I/O 방지 용으로 REDIS 를 사용하기 때문에, REDISSON 사용 할 수 있음
위의 2가지 이유로 REDISSON 분산 락을 사용하여 동시성 문제를 해결
2. 디스크 I/O
레디스에 재고를 담음
Look Aside + Write Through 전략 사용
- 캐시에 데이터가 없는 경우 DB를 조회 후 캐시 업데이트
- 캐시에 데이터 저장 이후 DB 에 데이터 저장
네트워크 통신을 해야 하기 때문에 성능 오버헤드 문제 발생할 수 있음
3. 트랜잭션 성능 향상
카프카 MQ 를 사용하여 바로 실행할 필요 없는 트랜잭션을 분리
🥑 카프카 설치 과정

💥 Zookeeper & Kafka error KeeperErrorCode=NodeExists
- kafka 설치는 properties에 변수를 임의로 변경하는 작업이 필요함
- https://dev-youngjun.tistory.com/178 카프카 세팅 참고
- 브로커 안 데이터의 상태 변경 관리를 위해 zookeeper를 사용
# zookeeper 실행
$ bin/windows/zookeeper-server-start.bat config/zookeeper.properties
# kafka server 실행
$ bin/windows/kafka-server-start.bat config/server.properties
1. application.properties 설정
#Kafka
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.properties.max.partition.fetch.bytes = 1048576
spring.kafka.consumer.properties.receive.buffer.bytes = 65536
spring.kafka.producer.properties.delivery.timeout.ms = 120000
2. config 파일 설정
@Configuration
public class KafkaProducerConfig {
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
return new DefaultKafkaProducerFactory<>(config);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
@Configuration
public class KafkaConsumerConfig {
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
return new DefaultKafkaConsumerFactory<>(config);
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
return factory;
}
}
3. producer
// a) kafka 예약 히스토리 적재
histRequestDto.setUserid(userId);
String resrvObject = objectMapper.writeValueAsString(histRequestDto.toEntity());
kafkaTemplate.send("save_resrv_his", resrvObject);
// b) kafka 쿠폰 발급
kafkaTemplate.send("issue_coupon", userId);
- producer config에서 지정한 kafka template을 활용해서 String, String 형태로 프로듀싱
- 객체를 value로 전달하는 경우는 objectMapper를 활용해서 String 형태로 포맷하여 다시 푸는 작업이 필요함
4. consumer
@KafkaListener(topics = "save_resrv_his", groupId = "reserv")
public void saveResrvHis(ConsumerRecord<String, String> record) {
try {
var resrvHis = objectMapper.readValue(record.value(), ResrvHis.class);
resrvHisRepository.save(resrvHis);
} catch (Exception e) {
e.printStackTrace();
}
}
@KafkaListener(topics = "issue_coupon", groupId = "coupon")
public void issueCoupon(String userId) {
try {
Optional<Cpn> cpn = cpnRepository.findById(1L);
// ** 회원이 쿠폰 가지고 있는지
int couponCntByUser = cpnRepository.searchCpnByUserId(1L, userId);
if (cpn.isPresent() && cpn.get().getIvtCnt() > 0 && couponCntByUser <= 0) {
cpn.get().updateIvtCnt();
cpnRepository.save(cpn.get());
// 회원에 따른 쿠폰 발급
cpnIssuRepository.save(CpnIssu.builder()
.cpnNum(cpn.get().getCpnNum())
.userid(userId)
.build()
);
} else {
log.info("쿠폰 발급 실패 재고 부족");
}
} catch (Exception e) {
log.info(" 카프카 소비 쿠폰 발급 실패 : {}", e.getMessage());
}
}
🥑 레디스 및 레디슨 설치과정
1. application.properties
# Redis
spring.redis.host=localhost
spring.redis.port=6379
2. Redis/Redisson Config
@EnableCaching
@Configuration
public class RedisConfig {
@Value("${spring.redis.host:localhost}")
private String host;
@Value("${spring.redis.port:6379}")
private String port;
private static final String REDISSON_HOST_PREFIX = "redis://";
// Redisson client
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
return Redisson.create(config);
}
// Redis template
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(Integer.parseInt(port));
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
return lettuceConnectionFactory;
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
💥 Could not resolve placeholder 'spring.redis.host' in value "${spring.redis.host}"
- @Value 읽는 과정에서 annotation 못 읽는 문제 발생하여, 디폴트 값으로 직접 넣어주었음
3. 비즈니스 로직
@Override
public void saveRequest(Principal principal, ResrvHistRequestDto histRequestDto) throws Exception {
String userId = principal.getName();
Optional<User> user = userRepository.findById(userId);
Optional<Host> host = hostRepository.findById(Long.valueOf(histRequestDto.getHnum()));
if(user.isPresent()){
String lockKey = "LockKey:" + userId; // Redisson Lock 시작
RLock lock = redissonClient.getLock(lockKey);
try {
boolean isLocked = lock.tryLock(3, 3, TimeUnit.SECONDS);
if (!isLocked) {
throw new NoLockException();
}
int curReqPpl = histRequestDto.getReqPpl();
long hnum = host.get().getHnum();
int curIvtPplByHost = cahceCheck(hnum);
if (curReqPpl > curIvtPplByHost) {
throw new NoAvailableExeption(String.valueOf(hnum));
} else{
redisService.deductAvailPpl(hnum, curReqPpl); // Redis 차감
host.get().updateIvtPpl(curIvtPplByHost, histRequestDto.getReqPpl());
hostRepository.save(host.get()); // DB 차감 -> Write Through
log.info("host 예약 가능인원 차감 : {}", host.get().getIvtPpl());
..... (카프카 영역).....
}
} catch (Exception e) {
log.info("예약 과정에서의 오류 : {}", e.getMessage());
}finally {
lock.unlock(); // Redisson Lock 반납
}
} else{
throw new NoUserException();
}
}
💥 redis server 가동시키고 테스트 진행해야함

4. 코드 작성시 고려한 점
- 예외 처리 : 커스텀 에러에선 RuntimeException을 사용 (필요 없는 검사 예외는 바깥으로 문제를 전파하기만 한다.
public class NoLockException extends RuntimeException {
public NoLockException() {
super("Redisson Lock Failure");
}
}

- 동시성 : 최대한 적은 부분만 락을 걸어야 성능 문제를 최소화 할 수 있음
- 메서드 : 성격에 맞게 분리하여 작성함
private int cahceCheck(long hnum) {
// Look aside
// 1) 캐시 먼저 확인
int curIvtPplByHost = -1;
if (redisService.findRedisKey(hnum)) {
curIvtPplByHost = redisService.getAvailPpl(hnum);
log.info("현재 호스트 redis 예약 재고 : {}", curIvtPplByHost);
} else{
// 2) 없다면 DB 조회
curIvtPplByHost = hostRepository.countIvtPpl(hnum);// DB 조회
redisService.setInitialPpl(hnum, curIvtPplByHost); // redis set
log.info("현재 호스트 db 예약 재고 : {}", curIvtPplByHost);
}
return curIvtPplByHost;
}
🥑 결과
1. 통합 테스트


- 만들어진 스레드 하나씩 redis를 조회하고 DB 에 재고를 차감함
- 이후 쿠폰 적재 예약 확정은 카프카를 통해 후처리 진행하고 있음
2. 성능 테스트 (ngrinder)
- localhost로 테스트가 어려워서 ngrok을 설치하여 해당 ip로 우회하여 테스트 진행
@Test
public void test() {
// JSON 데이터 구성
Map<String, Object> histRequestDto = new HashMap<>();
histRequestDto.put("hnum", 1);
histRequestDto.put("userId", "user123");
histRequestDto.put("reqPpl", 2);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("histRequestDto", histRequestDto);
// JSON 변환
String jsonBody = new groovy.json.JsonBuilder(requestBody).toString();
// JSON 데이터 params에 추가
params.put("body", jsonBody);
HTTPResponse response = request.POST("https://ngrok에서 받은 ip/", params)
if (response.statusCode == 301 || response.statusCode == 302) {
grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
} else {
assertThat(response.statusCode, is(200))
}
}
- user 정보를 담은 Principal 객체를 security 단에서 부터 넘기 어려워 테스트용 user 정보를 db 에 집어넣고 실행함

- 호스트의 재고를 10개로만 설정해두었기 때문에 금방 재고가 떨어져버려 throw exception을 던지기 때문에 테스트 결과는 유의미하지 않았음


🏁 요청에 대해 재고가 제대로 차감이 되었는지 확인하는 통합테스트만 유의미
🥑 아쉬운 점
단위테스트
- 의존 관계를 제거하여 서비스 단위로 Mock 객체를 생성하여 테스트 코드 작성하였으나, 주입 받는 객체가 계속해서 nullPointerException이 뜸
성능테스트
- 재고가 빠르게 없어지기 때문에 테스트의 의미가 크게 있진 않았음
성능이슈
분산 락을 사용하거나 캐시 서버를 덧붙였다는 것은 곧 스레드가 네트워크 통신에 대한 비용이 발생할 수 있다는 것이다.
하나의 DB 로만 연결할 때에는 공유 자원에만 락을 거는 방법도 고려해볼 수 있음
실무 로직 파악 必
❓ 현재 실무에서 재고 관리는 어떻게 되고 있을까?
동시에 요청이 들어왔을 때 update 시에 xLock 얻으므로 동시에 다른 스레드가 해당 데이터를
"읽고(xLock 가지고 있기 때문에 읽는 것도 대기, xLock 반납된 이후에 읽음)
-> 업데이트"
하는 로직이라면 문제가 없을까?
만약 DTO 에서 재고를 담아와서 그 값에서 요청값을 차감하는 로직이라면
Lost Update 문제 발생가능성이 있음
'SERVER > Kafka' 카테고리의 다른 글
| [Kafka/Redis] Kafka 설치 (0) | 2026.02.18 |
|---|---|
| [Kafka/Redis] Kafka 개념 (0) | 2026.02.03 |
| [Redis/Kafka 이용한 대용량 트래픽 예약 및 쿠폰 발급 서비스] 2.대용량 트래픽을 견디기 위한 방법 고민 (0) | 2025.03.24 |
| [Redis/Kafka 이용한 대용량 트래픽 예약 및 쿠폰 발급 서비스] 1.멀티 스레드의 동시성을 고려하지 못한 기존 구조 (0) | 2025.03.02 |
| [Kafka] 왜 카프카 인가 (0) | 2025.02.02 |