마틸다 서비스를 운영하면서 TIL 생성 기능에 심각한 문제가 발생했다. 사용자가 같은 날짜에 TIL을 생성할 때, 동시에 여러 요청이 들어오면 중복된 TIL이 생성되는 현상이 나타났다.
LLM API를 호출하는 기능이다보니, 동시에 많은 요청을 할때마다 비용이 발생하기 때문에 막아야했다.
비즈니스 요구사항상 하루에 하나의 TIL만 작성 가능해야 했는데, 동시성 제어가 제대로 되지 않아 데이터 정합성, 비용 문제가 우려됐다.
기존 코드는 단순한 중복 체크만 있어서 Race Condition에 취약했다
// 기존 코드 - 동시성 문제 발생
boolean exists = tilRepository.existsByDateAndTilUserIdAndIsDeletedFalse(tilCreateDto.date(), userId);
if (exists) {
throw new IllegalArgumentException("같은 날에 작성된 게시물이 존재합니다!");
}
// 여기서 동시 요청이 모두 통과할 수 있음
여러 분산락 방식을 검토했다:
장점
단점
장점
단점
우리 상황에서는 DB 분산락이 최적이었다:
(userId, date) 조합으로 충돌 범위가 매우 제한적@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;
}
}
@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);
}
}
락 서비스를 통해 락을 걸고, 푸는것을 구현을 했다.
@Scheduled(fixedRate = 600000) // 10분마다
public void cleanupExpiredLocks() {
lockService.cleanupExpiredLocks();
}
@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개의 스레드가 동시에 시작되는 환경으로 테스트를 구축하였다.
(userId, date) 조합으로 사용자별 독립적 처리catch (DataIntegrityViolationException e) {
return false; // 안전하게 실패 처리
}
유사 낙관락과 같다.
REQUIRES_NEW로 락 관리를 독립적인 트랜잭션으로 분리했다.위 경우에, 오히려 트랜잭션을 하나 더 사용함으로써 커넥션을 더 오래 점유하는것인가? 고민 했다.
그러나 위 케이스는 레이스 컨디션이 많이 일어나는 기능은 아니기 때문에 문제 없다고 판단했다.
최근 부하테스트에 대해서 관심이 많이 생겼다.
하지만 현실적으로 맞닿아있는 규모만큼 부하테스트를 하는것이 정말 필요한 역량이라 생각이 들었다.
우리 서비스에서 정말 이만큼의 요청이 들어올 가능성이 있는가 라고 물었을때
이번에는 로그인이 필요한 쓰기 기능인 만큼 부하테스트를 하지 않아도 된다고 과감하게 판단했다.
이번에는 정말 빠르게 문제를 해결해보았다.
그 근간에는 적절한 기술 스택선택이 있었던 것 같다.
지금 상황에서 크게 필요하지않는 단지 베스트 프랙티스, 성능이라는 이유로 레디스를 선택했다면
그만큼의 인프라비용, 시간이 들었을 것으로 생각된다.
