[마틸다] LLM API 호출 동시성 문제 해결

찬디·2025년 9월 6일

우테코

목록 보기
16/19

Situation (상황)

마틸다 서비스를 운영하면서 TIL 생성 기능에 심각한 문제가 발생했다. 사용자가 같은 날짜에 TIL을 생성할 때, 동시에 여러 요청이 들어오면 중복된 TIL이 생성되는 현상이 나타났다.
LLM API를 호출하는 기능이다보니, 동시에 많은 요청을 할때마다 비용이 발생하기 때문에 막아야했다.

문제 발생 시나리오

  • 악의적인 사용자가 TIL 작성 버튼을 연속으로 클릭
  • 악의적인 사용자가 이니어도, 서버가 느린 경우에 동작이 되지 않는다고 사용자가 빨리 클릭하는 경우

비즈니스 요구사항상 하루에 하나의 TIL만 작성 가능해야 했는데, 동시성 제어가 제대로 되지 않아 데이터 정합성, 비용 문제가 우려됐다.

Task (과제)

  1. 데이터 정합성 보장: 날짜별로 TIL이 하나만 존재하도록 보장
  2. 동시성 제어: 여러 요청이 동시에 와도 안전하게 처리
  3. 성능 최적화: 락으로 인한 성능 저하 최소화
  4. 장애 복구: 락 획득 후 서버 장애 시 데드락 방지

기존 코드는 단순한 중복 체크만 있어서 Race Condition에 취약했다

// 기존 코드 - 동시성 문제 발생
boolean exists = tilRepository.existsByDateAndTilUserIdAndIsDeletedFalse(tilCreateDto.date(), userId);
if (exists) {
    throw new IllegalArgumentException("같은 날에 작성된 게시물이 존재합니다!");
}
// 여기서 동시 요청이 모두 통과할 수 있음

Action (행동)

1. 해결 방안 검토

여러 분산락 방식을 검토했다:

대안 1: Redis 분산락

장점

  • 빠른 성능 (인메모리)
  • 풍부한 기능 제공

단점

  • 새로운 인프라 도입 필요
  • 직접 락 관리 로직 구현 필요
  • 운영 복잡도 증가

대안 2: DB 분산락

장점

  • 기존 인프라 활용 가능
  • 트랜잭션과 일관성 보장
  • 구현 복잡도 낮음

단점

  • Redis 대비 상대적으로 느림

2. DB 분산락 선택 이유

우리 상황에서는 DB 분산락이 최적이었다:

  • TIL 생성은 고빈도 작업이 아님 (사용자당 하루 1회)
  • (userId, date) 조합으로 충돌 범위가 매우 제한적
  • Redis 성능이 필요한 수준의 요청량이 아님
  • 기존 DB 인프라 활용으로 운영 부담 최소화

3. 구현 설계

락 테이블 설계

@Entity
@Table(name = "til_creation_lock", 
       uniqueConstraints = @UniqueConstraint(
           name = "uk_til_creation_lock_user_date",
           columnNames = {"user_id", "lock_date"}
       ))
public class TilCreationLock {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "user_id", nullable = false)
    private Long userId;
    
    @Column(name = "lock_date", nullable = false)
    private LocalDate lockDate;
    
    @Column(name = "expires_at", nullable = false)
    private LocalDateTime expiresAt; // 5분 후 만료
}

expiresAt은 추후에 락 건 데이터를 스케줄링 기반 정리해주기 위함이다.

동시성 제어 구현 코드

@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean acquireLock(Long userId, LocalDate lockDate) {
    try {
        // 기존 락 확인
        Optional<TilCreationLock> existingLock = lockRepository.findByUserIdAndLockDate(userId, lockDate);
        
        if (existingLock.isPresent()) {
            TilCreationLock lock = existingLock.get();
            if (lock.isExpired()) {
                lockRepository.delete(lock);
                return createNewLock(userId, lockDate);
            } else {
                return false; // 이미 락이 존재
            }
        }
        
        return createNewLock(userId, lockDate);
        
    } catch (DataIntegrityViolationException e) {
        // 유니크 제약조건 위반 - 동시 접근으로 락 획득 실패
        return false;
    }
}
트랜잭션 레벨을 REQUIRES_NEW로 설정한 이유
  • 만약에, 정말 만약에 락을 걸다가 실패시, TIL이 생성이 되는것은 막기 위함이다.
  • 사실, 현재 상황에서 실패되는 케이스를 생각은 크게 안나고 만약 실패한다면 정말 큰 문제일것으로 예상된다.

TilService 적용

@Transactional
public Til createTil(final TilDefinitionRequest tilCreateDto, final long userId) {
    LocalDate targetDate = tilCreateDto.date();
    
    // 분산락 획득 시도
    boolean lockAcquired = lockService.acquireLock(userId, targetDate);
    if (!lockAcquired) {
        throw new IllegalStateException("같은 날짜에 TIL을 생성하는 다른 요청이 진행 중입니다. 잠시 후 다시 시도해주세요.");
    }

    try {
        // 중복 체크 (락 내에서 다시 한번 확인)
        boolean exists = tilRepository.existsByDateAndTilUserIdAndIsDeletedFalse(targetDate, userId);
        if (exists) {
            throw new IllegalArgumentException("같은 날에 작성된 게시물이 존재합니다!");
        }

        // TIL 생성 로직
        TilUser user = userService.findById(userId);
        Til newTil = tilCreateDto.toEntity(user);
        return tilRepository.save(newTil);
        
    } finally {
        // 락 해제
        lockService.releaseLock(userId, targetDate);
    }
}

락 서비스를 통해 락을 걸고, 푸는것을 구현을 했다.

  • createTil에 락 로직이 거는것이 아쉬워서 상위 서비스를 만드는 방식도 생각해보았으나,
    오히려 락을 건다는것을 명시적으로 하는 면에서 위 코드도 나쁘지 않다고 생각해 유지했다.

4. 락 테이블 정리

만료된 락 자동 정리

@Scheduled(fixedRate = 600000) // 10분마다
public void cleanupExpiredLocks() {
    lockService.cleanupExpiredLocks();
}

락 만료 시간 설정

  • 5분 후 자동 만료로 데드락 방지
  • TIL 생성 시간을 고려한 적절한 타임아웃 설정

Result (테스트로 검증)

1. 동시성 문제 해결

	@Test
    @DisplayName("동시성 테스트 - 100개 스레드가 동시에 락 획득 시도")
    void concurrentLockAcquisition_OnlyOneShouldSucceed() throws InterruptedException {
        // given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failureCount = new AtomicInteger(0);

        // when
        List<CompletableFuture<Void>> futures = new ArrayList<>();
        for (int i = 0; i < threadCount; i++) {
            CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
                boolean acquired = lockService.acquireLock(userId, testDate);
                if (acquired) {
                    successCount.incrementAndGet();
                } else {
                    failureCount.incrementAndGet();
                }
            }, executorService);
            futures.add(future);
        }

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
        executorService.shutdown();
        executorService.awaitTermination(10, TimeUnit.SECONDS);

        // then
        assertThat(successCount.get()).isEqualTo(1); // 정확히 하나만 성공
        assertThat(failureCount.get()).isEqualTo(threadCount - 1); // 나머지는 모두 실패
        assertThat(lockRepository.findByUserIdAndLockDate(userId, testDate)).isPresent();
    }

위와 같이 100개의 스레드가 동시에 시작되는 환경으로 테스트를 구축하였다.

2. 성능 영향 최소화

  • 격리 범위: (userId, date) 조합으로 사용자별 독립적 처리
  • 락 지속 시간: 평균 200ms 이내로 매우 짧음
  • 전체 시스템 영향: 거의 없음 (다른 사용자/날짜에 영향 없음)

고민, 느낀점

비관적,낙관적락을 고려하지 않은 이유

  • 둘다 데이터가 존재할때 의미가 있는 락이기 때문이다.
  • TIL 생성이 문제 케이스기 때문에 테이블방식의 락을 채택할 수 밖에 없었다.
    • 사실 낙관적 락과 비슷하긴하다 유니크 제약조건이 실패하는 경우에 제어하는 방식이어서

1. 레디스 VS DB 락

  • 분산락은 거의 레디스가 병목도 좋다고 하지만, 지금은 빠르지않아도 되기 때문에 문제없다 판단했다.
  • 적절한 기술 선택을 하니 빠르게 구현할 수 있었다.

2. DB 유니크 제약조건의 활용으로 실패처리

catch (DataIntegrityViolationException e) {
    return false; // 안전하게 실패 처리
}

유사 낙관락과 같다.

3. 방어적 설계(트랜잭션 격리 수준)

  • REQUIRES_NEW로 락 관리를 독립적인 트랜잭션으로 분리했다.
  • 메인 비즈니스 로직과 락 관리 로직의 격리를 통해 방어적으로 설계했다

위 경우에, 오히려 트랜잭션을 하나 더 사용함으로써 커넥션을 더 오래 점유하는것인가? 고민 했다.
그러나 위 케이스는 레이스 컨디션이 많이 일어나는 기능은 아니기 때문에 문제 없다고 판단했다.

4. 쓰기 방식에 대한 부하테스트는 필요없나?

최근 부하테스트에 대해서 관심이 많이 생겼다.
하지만 현실적으로 맞닿아있는 규모만큼 부하테스트를 하는것이 정말 필요한 역량이라 생각이 들었다.
우리 서비스에서 정말 이만큼의 요청이 들어올 가능성이 있는가 라고 물었을때
이번에는 로그인이 필요한 쓰기 기능인 만큼 부하테스트를 하지 않아도 된다고 과감하게 판단했다.

결론

이번에는 정말 빠르게 문제를 해결해보았다.
그 근간에는 적절한 기술 스택선택이 있었던 것 같다.
지금 상황에서 크게 필요하지않는 단지 베스트 프랙티스, 성능이라는 이유로 레디스를 선택했다면
그만큼의 인프라비용, 시간이 들었을 것으로 생각된다.
이대로 괜찮은가상

profile
깃허브에서 velog로 블로그를 이전했습니다.

0개의 댓글