여러가지 이야기

[Spring] 객체 중복 생성 방지를 위한 Redis 분산 Lock 도입 본문

study/Spring

[Spring] 객체 중복 생성 방지를 위한 Redis 분산 Lock 도입

jimddong 2026. 1. 23. 02:38

최근 진행 중인 프로젝트에서, AI를 활용하여 사용자가 원하는 주제의 요약글과 퀴즈를 생성하는 기능을 개발하는 역할을 맡았다.
사용자는 원하는 목표 기간 동안 최대 하루 한 번씩 생성된 요약글과 퀴즈 세트를 확인 후 풀이할 수 있다.

 

하루 1회 요약글과 퀴즈 세트가 모두 OpenAI 모델을 활용해 생성되게끔 구현하였는데, 사용자가 그날 최초로 홈 화면에 접속 했을 때 해당 객체들이 순차적으로 생기게끔 구현하기로 했다.

 

하지만 실제 서버 로그와 DB를 확인했을 때는 그렇지 않았다. 그날 최초 홈화면 접속 시 하루 1회만 생성 되어야 할 요약글과 퀴즈 세트가 여러 개 생성되는 것이다.

 

사용자 입장에서 요약글이나 퀴즈 세트를 조회하면 그날분의 것만 볼 수 있어서 문제가 되지 않았지만, DB 비용을 낭비한다는 점에서 문제가 있어 수정이 필요했다.

 

사실 요약글과 퀴즈 세트가 생기기까지 즉, LLM이 생성한 응답을 조회하기까지 OpenAI 특성 상 시간이 꽤 걸린다. 그동안 유저는 홈 화면에서 기다리기만 하지 않고 다른 탭을 눌러보는 등 홈 화면을 여러 번 들락거릴 수 있다. 아까 언급 했듯이 '홈 화면에 들어오면 요약글과 퀴즈 세트가 하루 최대 한 번 생김'에서 나는 하루 한 번 생성되어야하는 요약글, 퀴즈 세트 로직에만 집중했었다. 실제로 그 날 한 번 이 객체들이 생성 되면, 그 날에 더이상 요약글, 퀴즈 세트를 생성할 수 없게 코드는 작성되어있다.

 

내가 놓친 것은 LLM이 응답을 만들고 있을 때였다. 현재 프론트 쪽에서는 요약글과 퀴즈가 생기기 전에 홈화면에 들어 오면 요약글 생성 API와 퀴즈 생성 API를 호출한다. 객체가 생성되기 직전인, LLM이 응답(요약글과 퀴즈 세트)을 만들고 있을 동안에 사용자 홈화면 버튼을 여러 번 연타하거나 들락거리는 경우는 고려하지 못 했던 것이다..!

문제 상황을 시퀀스 다이어그램으로 나타낸 모습

(역시 실제 화면 플로우와 그 속에서 일어날 수 있는 다양한 케이스를 고려해야하는 걸 여기서 뼈저리게 느꼈다.)

이처럼 요약글 및 퀴즈가 생기기 전까지 홈 화면에 여러번 접속해서 생성 요청이 여러 번 보내진다면, 그 횟수만큼 요약글 및 퀴즈가 중복되어 생성되며 안 그래도 LLM 응답이 느린데 응답이 더 느려지는 문제도 있었다.

 

이내 '그날 최초로 홈 화면에 접속 했을 때만 해당 객체들이 생기게 하기'에 집중했다. 처음에는 2가지 방법을 함께 도입했다.

 

객체 중복 생성 방지

시도 1. DB Unique 제약 조건 -> 실패

요약글 생성은 단일 엔티티를 생성하는 것이라 이런 식으로 요약글 엔티티 코드 앞에 해당 날에는 딱 하나의 요약글이 생기게 제약을 두었다.

@Table(name = "category_document", uniqueConstraints = {
        @UniqueConstraint(name = "uk_category_document_goal_date", columnNames = { "goal_id", "created_date" })
})

하지만, 배포 서버에 반영 후 DB를 확인해보니 여전히 요약글이 생기기 전까지 LLM이 작동하는 동안 홈 화면을 여러 번 들어올 때마다 그 횟수만큼 요약글이 여러 개 생기는 문제가 있었다. 하루 생성 중복이 막아지지 않은 것이다.

 

이유는 다음과 같았다.

요약글 엔티티는 공통 필드를 사용하기 위해 BaseEntity를 상속하는데, 이 중 LocalDateTime createdDate은 초 단위까지 기록하게 되어있다. 이러한 경우에, 현재처럼 createdDate를 비교하여 엔티티가 독립적인지 판별하려면 당연히 초 단위까지 고려할 것이다.

하지만 LLM 응답이 오기 전까지 홈 화면을 여러 번 들어오거나 생성 요청이 연타되었다면 요청 시간대는 다음과 같을 수 있다.

// 요청 예시
첫 번째 요청: 2026-01-19 16:49:02.471919
두 번째 요청: 2026-01-19 16:49:11.590923

 

즉, DB Unique Constraint 설정에서 (goal_id, created_date)가 중복되지 않게 막았지만 초 단위(밀리초 차이로 다른 시간에 들어온 요청은 모두 다른 데이터로 인식)로 판단하여, 요약글 생성이 1일 1회로 지켜지지 않는 문제가 발생한 것이다. 따라서 요약글에도 다음 2번 방법을 적용하기로 결정했다.

 

(해결 방법) 2. Redis 분산 Lock

사실 퀴즈에 우선 이 방법을 적용했었다. 하루에 퀴즈 세트 하나를 생성하는 것이지, 퀴즈 엔티티를 딱 하나 만드는 것이 아니었기에 퀴즈에는 DB Unique 제약을 걸 수 없었기 때문이다.

 

Redis 분산 Lock 개념

  • tryLock(waitTime, leaseTime, timeUnit): 앞선 lock을 최대 waitTime만큼 기다리고, 최대 leaseTime만큼 점유(Lock 획득)할 수 있다.
    • 앞선 첫 요청이 Lock을 점유하고 있을 때(작업 중) → 두 번째 요청은 첫 번째 요청이 끝날 때까지 최대 waitTime까지 기다려준다.
    • 앞선 요청이 Lock을 점유하고 있지 않다면 두 번째 요청은 락을 즉시 획득한다. (대기 0초)

 

퀴즈에서의 Redis 분산 Lock의 흐름은 다음과 같다.

<로직>
1. 퀴즈 생성 전에 lock:quiz:generation:{문서ID}:{유저ID} 라는 이름으로 lock 걸기
2. 1번째 요청: lock(최대 60초)-> 퀴즈 생성(LLM 호출) -> unlock(생성 시 바로 잠금 해제)
=> 예를 들어 사용자가 버튼을 10번 연타해도, 2~10번째 요청은 대기하다가 1번 요청 확인 후 퀴즈 생성 안 하고 리턴하게 됨.

 

// Redisson Distributed Lock 도입
            String lockKey = "lock:quiz:generation:" + documentId + ":" + userId;
            RLock lock = redissonClient.getLock(lockKey);

            try {
                // 3초 대기, 60초 락 점유 (LLM 응답이 늦어질 수 있으므로 넉넉하게 잡음)
                if (lock.tryLock(3, 60, TimeUnit.SECONDS)) {
                    try {
                        // Double-Check: 락 획득 후 다시 한 번 개수 확인 (다른 스레드가 이미 생성했을 수도 있음)
                        priorityQuizzes = quizService.getUnsolvedQuizzesByAttributes(documentId, userId,
                                targetDifficulty, targetTopic, quizCount);

                        if (priorityQuizzes.size() < quizCount) {
                            int remainingCount = quizCount - priorityQuizzes.size();
                            quizUseCase.createQuizzesForDocument(documentId, userId, remainingCount);

                            // 재생성 후 최종 조회
                            priorityQuizzes = quizService.getUnsolvedQuizzesByAttributes(documentId, userId,
                                    targetDifficulty, targetTopic, quizCount);
                        }
                    } finally {
                        if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                            lock.unlock();
                        }
                    }
                } else {
                    // 락 획득 실패 시 (Timeout) -> 기존 조회된 것만 반환하거나 예외처리
                    // 여기서는 최대한 생성된 만큼만 반환
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new BaseException(CommonResponseCode.INTERNAL_SERVER_ERROR);
            }
        }

 

퀴즈에 Redis 분산 Lock을 도입했을 때는 퀴즈 세트가 딱 하나가 생성 되어 문제 없이 작동하는 것을 확인했다.

따라서 요약글 생성에도 위 로직을 도입하기 위해 공통 메서드를 만들었다.

 

 public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, Supplier<T> action) {
        RLock lock = redissonClient.getLock(lockKey);

        try {
            if (!lock.tryLock(waitTime, leaseTime, timeUnit)) {
                log.warn("Failed to acquire lock: {}", lockKey);
                throw new BaseException(CommonResponseCode.INTERNAL_SERVER_ERROR);
            }

            try {
                return action.get();
            } finally {
                if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Lock acquisition interrupted: {}", lockKey, e);
            throw new BaseException(CommonResponseCode.INTERNAL_SERVER_ERROR);
        }
    }

 

하지만 Lock 로직이 여러 개 생겼을 때는 또 다른 문제가 생겼다.

요약글이 먼저 생성되고, 그 글의 내용을 기반으로 퀴즈를 생성해야하기에 Lock 로직을 두 개로 분리하였는데 요약글이 여러 개 생기는 것이다.


이 문제는 트랜잭션과 관련이 있었고, 바로 다음 글에서 자세히 해결 과정을 기술하겠다.