2026. 1. 6. 15:36ใJava/SpringBoot
๐งฉ Redis Queue + Scheduler๋ก ์ ์ฐฉ์ ์ฟ ํฐ(100์ฅ) ๋์์ฑ ํด๊ฒฐ ์ค์ต
โถ ์ํฉ ์ค๋ช
์ ์ฐฉ์ ์ฟ ํฐ ์ด๋ฒคํธ๋ “๋์์ ๋ง์ ์ฌ์ฉ์๊ฐ ๋ชฐ๋ฆฌ๋” ๋ํ์ ์ธ ๋์์ฑ ๋ฌธ์ ๋ค.
์ฒ์์ JPA์์ quantity๋ฅผ ๊ทธ๋ฅ ๊ฐ์์ํค๋ฉด, ์ฌ๋ฌ ์์ฒญ์ด ๋์์ ๋ค์ด์ฌ ๋ ๊ฐฑ์ ์ ์ค(Lost Update) ๋๋ฌธ์ ์ฌ๊ณ ๊ฐ ๊นจ์ง๊ธฐ ์ฝ๋ค.
์ด๋ฒ ์ค์ต์์๋ DB ๋ฝ์๋ง ์์กดํ์ง ์๊ณ , Redis Queue๋ก ์์ฒญ์ ์ค ์ธ์ด ๋ค Scheduler๊ฐ ์์ฐจ์ ์ผ๋ก ๋ฐ๊ธํ๋ ๊ตฌ์กฐ๋ก ์ ์ฐฉ์ 100์ฅ์ ์์ ์ ์ผ๋ก ์ฒ๋ฆฌํ๋ค.
โท ํต์ฌ ์์ด๋์ด (ํ ๋ฌธ์ฅ ์์ฝ)
์ ์ฒญ(apply)์ Redis ํ์ ๋ฃ๊ธฐ๋ง ํ๊ณ ๋น ๋ฅด๊ฒ ์ข ๋ฃ → ๋ฐ๊ธ(publish)์ ์ค์ผ์ค๋ฌ๊ฐ ํ์์ ๊บผ๋ด DB ์ฌ๊ณ ๋ฅผ ๊ฐ์ → ์ฌ๊ณ ๊ฐ 0์ด๋ฉด SoldOut ์ฒ๋ฆฌ
- Producer: apply() → Redis List์ userId๋ฅผ ์๋๋ค (๋๊ธฐ์ด ์์ฑ)
- Consumer: Scheduler → userId๋ฅผ popํด์ publish() ์คํ (๋ฐ๊ธ ๋ก์ง)
- ์ ์ฐฉ์ ์ ํ: publish()์์ quantity <= 0 ์ด๋ฉด SoldOutException ๋ฐ์
โธ ์ ์ฒด ํ๋ฆ(๋๋ฒ๊น ๊ด์ ์ผ๋ก ๋ณด๊ธฐ)
1) 1000๋ช ์ด ๋์์ ์ ์ฒญ ์์ฒญ
- Thread 1000๊ฐ๊ฐ ๋์์ couponService.apply(userId)๋ฅผ ํธ์ถ
- apply()๋ DB๋ฅผ ๊ฑด๋๋ฆฌ์ง ์๊ณ Redis Queue์๋ง userId๋ฅผ push
- ์ฆ, “์ ์ฒญ ๋จ๊ณ”๋ ๋น ๋ฅด๊ฒ ๋๋๊ณ ์๋ฒ๋ ๋ฒํด๋ค
2) Scheduler๊ฐ ํ๋ฅผ ์๋นํ๋ฉฐ ๋ฐ๊ธ
- fixedDelay=100ms ๋ง๋ค ์ค์ผ์ค๋ฌ๊ฐ ์คํ
- ํ์์ userId๋ฅผ ํ๋์ฉ pop
- publish(userId)๋ก DB ์ฌ๊ณ (quantity)๋ฅผ 1 ๊ฐ์
3) ์๋์ด 0์ด๋ฉด ๋ง๊ฐ
- quantity <= 0 ์ด๋ฉด SoldOutException ๋ฐ์
- Scheduler๊ฐ isSoldOut=true๋ก ๋ณ๊ฒฝ ํ ๋ ์ด์ ๋ฐ๊ธ ๋ก์ง์ ํ์ฐ์ง ์์
- ๊ฒฐ๊ณผ์ ์ผ๋ก ์ต์ข quantity๋ 0์์ ๋ฉ์ถ๋ค
โน ์ฝ๋ (์ค์ต ์ ์ฒด)
โ Entity: Coupon
package com.mycom.myapp.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Data
@NoArgsConstructor
public class Coupon {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Long id;
private String name;
private int quantity; // ์ฟ ํฐ ์๋
public Coupon(String name,int quantity) {
this.name = name;
this.quantity = quantity;
}
}
โ Exception: SoldOutException
package com.mycom.myapp.exception;
// ์ฟ ํฐ ์์ง์ ์๋ฏธํ๋ ์ฌ์ฉ์ ์ ์ ์์ธ
public class SoldOutException extends RuntimeException{
private static final long serialVersionUID = 1L;
public SoldOutException(String message) {
super(message);
}
}
โ Redis Repository: CouponRedisRepository (Queue)
package com.mycom.myapp.repository;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import lombok.RequiredArgsConstructor;
// Redis Queueing ์์
๊ด๋ จ ์ฒ๋ฆฌ
@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {
private final RedisTemplate<String, String> redisTemplate;
// queue์ ์ถ๊ฐ (Producer)
public void addToQueue(Long userId) {
redisTemplate.opsForList().rightPush("coupon_queue", String.valueOf(userId));
}
// queue์์ ๊บผ๋ด๊ธฐ (Consumer)
public Long popFromQueue() {
String userId = redisTemplate.opsForList().leftPop("coupon_queue");
return userId != null ? Long.valueOf(userId) : null;
}
// queue ํฌ๊ธฐ
public Long getSize() {
return redisTemplate.opsForList().size("coupon_queue");
}
}
โ JPA Repository: CouponRepository
package com.mycom.myapp.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.mycom.myapp.entity.Coupon;
public interface CouponRepository extends JpaRepository<Coupon, Long> {
}
โ Service Interface
package com.mycom.myapp.service;
// ์ ์ฒญ(apply): ์ฌ์ฉ์๋ฅผ ์ค ์ธ์(Redis)
// ๋ฐ๊ธ(publish): ์ฟ ํฐ ์ ๊ฐ์(DB)
public interface CouponService {
void apply(Long userId);
void publish(Long userId);
}
โ Service ๊ตฌํ: CouponServiceImpl
package com.mycom.myapp.service;
import org.springframework.stereotype.Service;
import com.mycom.myapp.entity.Coupon;
import com.mycom.myapp.exception.SoldOutException;
import com.mycom.myapp.repository.CouponRedisRepository;
import com.mycom.myapp.repository.CouponRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class CouponServiceImpl implements CouponService {
private final CouponRepository couponRepository;
private final CouponRedisRepository couponRedisRepository;
// Producer: ์ ์ฒญ → Redis Queue์ ๋ฃ๊ณ ๋
@Override
public void apply(Long userId) {
couponRedisRepository.addToQueue(userId);
}
// Consumer: ๋ฐ๊ธ → DB์์ ์๋ ๊ฐ์
@Transactional
@Override
public void publish(Long userId) {
Coupon coupon = couponRepository.findById(1L).orElseThrow();
// ์ ์ฐฉ์ 100๊ฐ ์ ํ
if (coupon.getQuantity() <= 0) {
throw new SoldOutException(coupon.getName() + " ์ฟ ํฐ์ด ๋ชจ๋ ์์ง๋์์ต๋๋ค.");
}
coupon.setQuantity(coupon.getQuantity() - 1);
System.out.println(
coupon.getName() + " ๋ฐ๊ธ ์๋ฃ : ์ฌ์ฉ์ : " + userId +
" ์์ฌ ์๋ : " + coupon.getQuantity()
);
}
}
โ Scheduler: CouponScheduler
package com.mycom.myapp.scheduler;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.mycom.myapp.exception.SoldOutException;
import com.mycom.myapp.repository.CouponRedisRepository;
import com.mycom.myapp.service.CouponService;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class CouponScheduler {
private final CouponRedisRepository couponRedisRepository;
private final CouponService couponService;
private boolean isSoldOut = false;
@Scheduled(fixedDelay = 100) // 0.1์ด๋ง๋ค ํธ์ถ
public void couponEventScheduler() {
while (true) {
Long userId = couponRedisRepository.popFromQueue();
if (userId == null) {
// ์ฒ๋ฆฌํ ์์ฒญ์ด ์์ผ๋ฉด ์ข
๋ฃ → ๋ค์ ์ค์ผ์ค์์ ์ฌ์๋
break;
}
if (isSoldOut) {
// ๋ง๊ฐ ์ดํ ์์ฒญ์ ๋ํ ์ ์ฑ
(๋ก๊ทธ/์ ์ฅ/์๋ฆผ ๋ฑ)์ ํ์ฅ ํฌ์ธํธ
continue;
}
try {
couponService.publish(userId);
} catch (SoldOutException e) {
isSoldOut = true;
System.out.println("์ ์ฐฉ์ ์ฟ ํฐ์ด ๋ง๊ฐ๋์์ต๋๋ค!");
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
โ Main: ์ค์ผ์ค๋ฌ ํ์ฑํ
package com.mycom.myapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class SpringBootJpaRedisApplication {
public static void main(String[] args) {
SpringApplication.run(SpringBootJpaRedisApplication.class, args);
}
}
โบ ํ ์คํธ ์ฝ๋ (๋์ ์์ฒญ 1000๋ช + ์ ์ฐฉ์ 100๊ฐ ๊ฒ์ฆ)
ํ
์คํธ๋ ํต์ฌ์ด 2๊ฐ์ง๋ค.
1) apply()๋ DB๋ฅผ ์ ๊ฑด๋๋ฆฐ๋ค → ๋ฐ๋ผ์ ๋ฐ๊ธ ๊ฒฐ๊ณผ๋ Scheduler๊ฐ ์๋นํ ๋ค์ ๊ฒ์ฆํด์ผ ํ๋ค.
2) ํ
์คํธ๋ง๋ค coupon_queue๋ฅผ ์ด๊ธฐํํด์ผ ํ๋ค → Redis๋ ์ํ๋ฅผ ๋จ๊ธฐ๋ฏ๋ก ํ
์คํธ๊ฐ ์ค์ผ๋๋ค.
package com.mycom.myapp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import com.mycom.myapp.entity.Coupon;
import com.mycom.myapp.repository.CouponRepository;
import com.mycom.myapp.service.CouponService;
@SpringBootTest
public class CouponConcurrencyTest {
@Autowired
private CouponRepository couponRepository;
@Autowired
private CouponService couponService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@BeforeEach
void setUp() {
couponRepository.save(new Coupon("์ ์ฐฉ์ ์ฟ ํฐ", 100));
redisTemplate.delete("coupon_queue");
}
@Test
void queueingTest() throws Exception {
int threadCount = 1000; // thread 1๊ฐ = user 1๋ช
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
long userId = i; // 0~999
executorService.submit(() -> {
try {
couponService.apply(userId);
} finally {
latch.countDown();
}
});
}
latch.await(); // ์ ์ฒญ(ํ ์ ์ฌ) ์๋ฃ ๋๊ธฐ
// Scheduler๊ฐ ํ๋ฅผ ์๋นํ ์๊ฐ ๋ถ์ฌ
Thread.sleep(10000);
int finalQuantity = couponRepository.findById(1L).orElseThrow().getQuantity();
System.out.println("finalQuantity : " + finalQuantity);
assertEquals(0, finalQuantity);
}
}



โป ๋ด๊ฐ ํ ์ค์(์ค์) - Test ํด๋์ค์์ main ์คํ ์๋
๋ง์ง๋ง์ ๋ด๊ฐ ํท๊ฐ๋ ธ๋ ๋ถ๋ถ์ด ์ด๊ฑฐ์๋ค:
public SpringBootJpaRedisApplicationTests(String[] args) {
SpringApplication.run(SpringBootJpaRedisApplication.class, args);
}
์ด๊ฑด JUnit ํ
์คํธ ํด๋์ค์์ main์ ์ง์ ์คํํ๋ ค๋ ์ฝ๋๋ผ์ ์๋ชป๋ ๋ฐฉํฅ์ด๋ค.
ํ
์คํธ ํด๋์ค๋ Spring์ด ์์์ ์ปจํ
์คํธ๋ฅผ ๋์ฐ๋๋ก @SpringBootTest๋ง ์์ผ๋ฉด ๋๋ค.
โ ์ฌ๋ฐ๋ฅธ ํ ์คํธ ํด๋์ค ํํ
package com.mycom.myapp;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class SpringBootJpaRedisApplicationTests {
@Test
void contextLoads() {
// ์ปจํ
์คํธ ๋ก๋ฉ๋ง ํ์ธ
}
}
โผ ํต์ฌ ๊ฐ๋ ์ ๋ฆฌ
- Redis Queue: Redis List๋ฅผ ํ์ฒ๋ผ ์ฌ์ฉ(RPUSH + LPOP)ํด์ ๋๊ธฐ์ด์ ๋ง๋ ๋ค
- Producer/Consumer: ์ ์ฒญ์ ๋น ๋ฅด๊ฒ ํ์ ์ ์ฌ, ๋ฐ๊ธ์ ์๋น์๊ฐ ์์ฐจ ์ฒ๋ฆฌ
- ๋์์ฑ ํด๊ฒฐ ์ ๋ต: “DB์ ๋์์ ์ ๊ทผ” ์์ฒด๋ฅผ ์ค์ฌ์ ๊ฒฝ์์ ์ต์ํ
- ํ ์คํธ ํฌ์ธํธ: apply๋ DB ๋ณํ ์์ → Scheduler ์๋น ํ quantity๋ฅผ ๊ฒ์ฆํด์ผ ํจ