티스토리 뷰

728x90
반응형

Reddison Vs Lettuce


우선 시작하기 전에 Spring Boot에서 Redis를 사용하기 위해서는 Redisson과 Lettuce 둘 중 하나를 사용할 수 있습니다. 해당 글에서는 Redisson을 사용하였으며, 왜 Redisson을 사용했는지 두개의 라이브러리가 어떤 차이점을 가지고 있는지 살펴보고 넘어가겠습니다.

 

 

💡 Redisson

 

  • Redisson은 우선 Publisher와 Subscriber 기반으로 Lock을 획득 및 제공하고 있습니다.
  • Lettuce와 다르게 SpinLock 방식이 아닙니다.
  • SpinLock의 경우 순회를 하면서 Lock의 획득 여부를 계속하여 물어보게 됩니다. 이는 Busy Waiting이 발생하게 되며 많은 CPU 자원을 낭비하게 됩니다.

Redisson Pub/Sub

💡 Lettuce

 

  • Lettuce는 setnx 명령어를 사용하여 분산락을 구현하고 있습니다. setnx는 Set if not exists의 약자입니다.
  • 락을 획득하기 위해서는 "락이 존재하는지 확인한다", 그리고 "존재하지 않는다면 락을 획득한다" 라는 두 연산인 Atomic하게 이루어져야 하는데 Lettuce에서 setnx라는 명령어를 사용하여 "값이 존재하지 않으면 세팅한다" 처럼 Atomic하게 사용됩니다.
  • Lettuce는 SpinLock 방식을 통해 Lock을 획득합니다. 이는 앞에서 말한거처럼 Busy Waiting이 발생하게 되며 많은 CPU 자원을 낭비하게 됩니다.

Lettuce Spin Lock

 

Spring Boot Reddison 세팅


💡 Gradle 설정

// redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'

 

💡 yml 설정

spring:
  redis:
    host: localhost
    port: 6379

 

💡 Redisson Config 설정

@Configuration
public class RedissonConfig {

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private String port;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
        RedissonClient redisson = Redisson.create(config);
        return redisson;
    }
}

 

Redis와 @Transactional 그리고 AOP에 대해


먼저 Redis와 @Transactional 어노테이션과 같이 사용시 어떻게 동작하는지 살펴 볼 필요성이 있습니다.

 

💡 Presentation Layer

 

  • Presentation Layer에서는 사용자의 요청이 들어오면 Application Layer로 보내고 있습니다. 
@RestController
@RequestMapping("/received-gift-box")
@RequiredArgsConstructor
public class ReceivedGiftBoxApi {

    private final ReceivedGiftService receivedGiftService;

    @PostMapping
    public BaseResponse<Void> create(@Valid @RequestBody ReceivedGiftBoxCreateRequest request) {
        receivedGiftService.create(request.getGiftSerialCode(), request.getMemberId());
        return new BaseResponse<>(CODE_201);
    }
}

 

💡 Application Layer

 

  • Service 로직에서는 코드를 살펴보면 redisson을 통해 Lock을 획득하고 DB에 데이터를 조회한 뒤, 데이터를 저장 후 Lock을 반납하고 있습니다.
  • 하지만 아래 코드는 제대로 동작하지 않습니다. 우선 그 이유가 어떤건지 살펴보고 넘어가겟습니다.
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ReceivedGiftService {

    private static final int WAIT_TIME = 1;
    private static final int LEASE_TIME = 2;
    private static final String GIFT_LOCK = "gift_lock";

    private final RedissonClient redissonClient;
    private final SendGiftBoxDao sendGiftBoxDao;
    private final ReceivedGiftBoxRepository receivedGiftBoxRepository;

    public void create(String giftSerialCode, String memberId) {
        RLock lock = redissonClient.getLock(GIFT_LOCK);
        try {

            boolean hasLock = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);
            if (!hasLock) {
                log.info("lock 획득 실패");
                throw new IllegalStateException("Lock을 획득하지 못하였습니다.");
            }
            log.info("lock 획득!!");

            // DB 정보 조회 및 저장 시작 !!
            SendGiftBox sendGiftBox = sendGiftBoxDao.findByGiftSerialCode(giftSerialCode);
            if (sendGiftBox.hasAvailableQuantity()) {
                sendGiftBox.decreaseAvailableQuantity();
                ReceivedGiftBox receivedGiftBox = new ReceivedGiftBox(memberId, sendGiftBox);
                receivedGiftBoxRepository.save(receivedGiftBox);
            }
            // DB 정보 조회 및 저장 끝 !!
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
            log.info("lock 반납");
        }
    }
}

 

💡 Test Code

@SpringBootTest
class ReceivedGiftBoxApiTest {

    private static final ExecutorService executor = Executors.newFixedThreadPool(30);
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    @Autowired
    private SendGiftBoxService sendGiftBoxService;

    @Test
    void 동시에_1000명이_요청하는_테소트() throws InterruptedException {
        int threadCount = 1000;
        CountDownLatch latch = new CountDownLatch(threadCount);
        String giftSerialCode = "2307262320C3PT3LGE:23072623205CI9WVH9";

        for (int i = 0; i < threadCount; i++) {
            CompletableFuture.runAsync(() -> {
                try {
                    ReceivedGiftBoxCreateRequest request = new ReceivedGiftBoxCreateRequest(String.valueOf(System.currentTimeMillis()), giftSerialCode);
                    String requestBody = OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(request);
                    HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofString(requestBody);

                    HttpRequest httpRequest = HttpRequest.newBuilder()
                            .uri(URI.create("http://localhost:8080/received-gift-box"))
                            .POST(body)
                            .header("Content-Type", "application/json")
                            .build();

                    HttpClient client = HttpClient.newBuilder().build();
                    client.send(httpRequest, HttpResponse.BodyHandlers.discarding());
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    latch.countDown();
                }
            }, executor);
        }

        latch.await();
    }
}

 

💡 결과

 

  • 결과를 보면 이용가능한 재고는 처음에는 10개였지만 이후 0개로 정상적으로 감소한 것을 볼 수 있습니다. 하지만 선물을 받은 사용자를 조회하면 20명이 선물을 받았다는 것을 확인할 수 있습니다. 이는 Redisson을 통해 Lock을 획득하고, 정상적으로 Lock을 반납했다고 생각을 할 수 있지만 실제로는 정상적으로 수행되지 않습니다.

💡 원인

 

  • 원인은 사실 AOP로 동작하는 @Transactional 어노테이션에 있습니다.
  • 우리가 원하는 것은 Lock을 점유한 상태에서 Transaction을 얻어 정상적인 로직을 수행 후 Transaction을 commit하고 Lock을 반납하는 과정입니다.
  • 하지만 과정은 아래와 같이 동작하게 됩니다. 

 

문제를 해결해보자.


해당 문제는 Facade의 역할을 수행하는 클래스를 앞단에 배치함으로써 문제를 해결할 수 있습니다. 

 

💡 Presentation Layer

 

  • 기존 Presentation Layer에서 바로 Application Layer에게 위임을 하였습니다. 하지만 이번에는 퍼사드의 역할을 수행하는 메서드에게 위임을 시킵니다.
@RestController
@RequestMapping("/received-gift-box")
@RequiredArgsConstructor
public class ReceivedGiftBoxApi {

    private final RedissonLockFacade redissonLockFacade;

    @PostMapping
    public BaseResponse<Void> create(@Valid @RequestBody ReceivedGiftBoxCreateRequest request) {
        redissonLockFacade.create(request.getGiftSerialCode(), request.getMemberId());
        return new BaseResponse<>(CODE_201);
    }
}

 

💡 Facade

 

  • Service를 호출하기 전에 Redis의 Lock을 먼저 획득하고 Service의 Transaction이 수행될 수 있도록 퍼사드 역할을 수행하는 클래스를 하나 둡니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonLockFacade {

    private static final int WAIT_TIME = 1;
    private static final int LEASE_TIME = 2;
    private static final String GIFT_LOCK = "gift_lock";

    private final RedissonClient redissonClient;
    private final ReceivedGiftService receivedGiftService;

    public void create(String giftSerialCode, String memberId) {
        RLock lock = redissonClient.getLock(GIFT_LOCK);
        try {

            boolean hasLock = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);
            if (!hasLock) {
                log.info("lock 획득 실패");
                throw new IllegalStateException("Lock을 획득하지 못하였습니다.");
            }
            log.info("lock 획득!!");
            
            // Application Layer 호출
            receivedGiftService.create(giftSerialCode, memberId);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
            log.info("lock 반납");
        }
    }
}

 

💡 Application Layer

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ReceivedGiftService {

    private final SendGiftBoxDao sendGiftBoxDao;
    private final ReceivedGiftBoxRepository receivedGiftBoxRepository;

    public void create(String giftSerialCode, String memberId) {
        SendGiftBox sendGiftBox = sendGiftBoxDao.findByGiftSerialCode(giftSerialCode);
        if (sendGiftBox.hasAvailableQuantity()) {
            sendGiftBox.decreaseAvailableQuantity();
            ReceivedGiftBox receivedGiftBox = new ReceivedGiftBox(memberId, sendGiftBox);
            receivedGiftBoxRepository.save(receivedGiftBox);
        }
    }
}

 

💡 결과

 

  • 결과를 보면 재고가 정상적으로 감소하고, 이벤트 담청자도 10명인 것을 확인할 수 있습니다.

💡 과정

 

  • Facade의 역할을 수행하는 클래스 덕분에 Lock을 획득하고 트랜잭션 수행 후 Lock을 반납하는 과정으로 인해 동시성 제어를 제대로 할 수 있습니다.

💡 마치며

 

  • 지금까지 redisson을 사용하여 어떻게 동시성을 제어할 수 있는지 알아보았습니다. 그리고 Lock을 획득하고 반납하는 과정은 코드의 복잡성과 중복을 초래할 수 있다고 생각합니다. 그렇기 때문에 AOP를 적용하면 조금 더 깔끔한 코드가 될거 같습니다.
  • 잘못된 내용이 있을 수 있으니, 언제나 피드백은 환영입니다!!
  • 글을 읽어 주셔서 감사합니다!! 😁

 

 

 

GitHub - kdg0209/kakaotalk-gift-distributed-lock

Contribute to kdg0209/kakaotalk-gift-distributed-lock development by creating an account on GitHub.

github.com

 

 

참고

 

 

 

 

 

 

728x90
반응형