[LG U+ ์œ ๋ ˆ์นด 3๊ธฐ] Redis Queue + Scheduler๋กœ ์„ ์ฐฉ์ˆœ ์ฟ ํฐ(100์žฅ) ๋™์‹œ์„ฑ ํ•ด๊ฒฐ ์‹ค์Šต

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๋ฅผ ๊ฒ€์ฆํ•ด์•ผ ํ•จ