JUINTINATION
Redis 동시성 처리(Lettuce vs Redisson) 본문
카카오테크 부트캠프 파이널 프로젝트를 진행하면서 성능 개선을 위해 Redis를 도입하게 되었다. 우리 프로젝트는 팀별 품앗이 수를 기반으로 주기적으로 프로젝트 랭킹 스냅샷을 생성한다. 이 작업은 수많은 프로젝트 데이터를 집계하고 정렬해야 하며, 한 시점에서 하나의 스냅샷만 생성되어야 한다.
처음에는 이 데이터를 RDB에서만 관리했지만, 스냅샷 생성 시점마다 프로젝트를 집계하고 정렬하는 작업이 동시에 수행되면 성능 저하나 예기치 않은 충돌이 발생할 수 있다는 우려가 있었고, 이러한 문제를 해결하기 위해 우리 프로젝트에 Redis를 도입했다.
Redis란?
Redis는 Remote Dictionary Server의 약자로, 데이터를 메모리에 저장해 빠르게 접근할 수 있도록 해주는 인메모리 데이터 구조 저장소다. 자주 접근하거나 빠르게 조회해야 하는 데이터를 저장할 때 주로 사용되며, 대량의 데이터를 영구 저장하는 용도로는 적합하지 않다.
Redis는 문자열(String), 리스트(List), 셋(Set), 해시(Hash) 등의 다양한 자료구조를 지원하며, 이러한 유연한 데이터 구조 덕분에 다양한 형태의 데이터를 효율적으로 처리할 수 있다.
Redis 서버는 데이터를 메모리에 저장하는 방식으로 동작하는 독립적인 프로세스다. 애플리케이션은 이 서버에 연결해 데이터를 읽고 쓸 수 있다. 메모리 기반이기 때문에 속도는 매우 빠르지만, 시스템이 다운되거나 서버가 종료되면 데이터가 모두 사라지는 특성이 있다. 물론 스냅샷 저장(RDB)이나 AOF(Append Only File) 같은 내구성 옵션을 사용하면 데이터 손실을 최소화할 수 있다.
- Redis는 다음과 같은 상황에서 특히 유용하게 쓰인다.
- 동일한 요청이 자주 반복될 때 캐시로 사용하여 성능 개선
- 세션 저장소
- 실시간 순위 정보 저장
- 분산 시스템에서의 동기화/락 관리
예를 들어, 어떤 데이터가 자주 조회되는데 자주 바뀌지는 않는다면, 그 결과를 Redis에 캐시해두고 매번 DB를 조회하지 않도록 하면 수백~수천 ms의 지연을 줄일 수 있다. 반면 Redis에서 조회하면 몇 ms만에 결과를 반환받을 수 있다.
Redis의 장점
- 메모리 기반이므로 읽기/쓰기 성능이 매우 빠름
- 다양한 자료구조 제공으로 유연한 데이터 모델링 가능
- 간단하고 직관적인 API
- 원자적(Atomic) 연산 지원으로 멀티스텝 작업 시 안정성 확보
- RDB/AOF를 통한 내구성 확보 가능
- 마스터-슬레이브 복제와 Sentinel을 통한 고가용성 지원
Redis의 단점
- 메모리 기반이라 많은 데이터를 저장하면 비용이 큼
- 영속성 설정(RDB, AOF)이 복잡하고 성능에 영향을 줄 수 있음
- 복잡한 쿼리 기능 부족 (SQL 미지원)
- 전통적으로 싱글 스레드 구조라 멀티코어 자원 활용이 제한적
- 기본적인 보안 기능이 부족해 신뢰할 수 있는 환경에서 사용해야 함
우리 프로젝트에서는 프로젝트 랭킹 데이터를 자주 조회하고, 비동기적인 스냅샷 생성 작업을 제어해야 하므로, 빠른 응답성과 동시성 제어 능력을 갖춘 Redis가 적합했다. 특히 일시적인 데이터 저장, TTL(Time-To-Live) 설정, 그리고 분산락 기능이 요구되었기 때문에 Redis는 단순한 캐시 이상의 역할을 수행한다.
분산락이 필요한 이유
우리는 프로젝트 랭킹 스냅샷을 생성하는 작업이 동시에 여러 번 실행되는 것을 방지해야 했다. 예를 들어, 랭킹 스냅샷 생성은 주기적으로 혹은 이벤트 기반으로 비동기 트리거되는데, 만약 동일한 시점에 여러 스레드가 동시에 이 작업을 실행하게 되면 다음과 같은 문제가 발생할 수 있다.
- 중복 스냅샷 생성
- DB 트랜잭션 충돌 또는 불필요한 롤백
- 동일 자원(project 테이블)에 대한 과도한 접근으로 성능 저하
이를 해결하기 위해 분산락(distributed lock)이 필요했다. 단일 서버 환경에서는 synchronized 나 ReentrantLock 등으로 임계 구역을 보호할 수 있지만, 우리는 스프링 비동기 작업(@Async), 다중 스레드 환경, 추후 확장성까지 고려해 분산 환경에서도 유효한 락이 필요했다. 따라서 Redis 기반의 락 도구인 Redisson을 도입하게 되었다.
Redisson vs Lettuce 비교
Redis 클라이언트는 대표적으로 Redisson과 Lettuce가 있다.
항목 | Redisson | Lettuce |
Lock 구현 | 분산락 지원 (Reentrant, Fair 등) | 수동 구현 필요 (setnx 방식 직접 구현) |
분산락 | 지원 (RLock, MultiLock 등) | 미지원 (직접 구현 필요) |
사용성 | 고수준 API 제공 (Map, List, Lock 등 Java 객체처럼 사용) | 저수준 API 중심 (Redis 명령어 중심) |
메모리 사용량 | 비교적 높음 | 낮음 |
락 안정성 | watchdog, TTL 자동 연장 등의 기능 탑재 | 직접 구현 필요 |
Redisson은 내부적으로 "lock watchdog"이라는 기능을 기본 활성화해 일정 주기마다 락의 TTL을 자동 연장해준다. 즉, 락이 갑자기 해제되는 것을 방지할 수 있고, 분산락의 안정성을 높인다. 반면 Lettuce는 이러한 기능이 내장되어 있지 않아서 직접 구현해야 하며, 주로 SETNX + EXPIRE 방식으로 락을 구현한다.
우리 프로젝트는 단기성과 안정성이 중요한 파이널 프로젝트였기 때문에, 락을 직접 구현할 필요 없이 높은 수준의 추상화된 API를 제공하고, 자동으로 락을 유지시켜주는 Watchdog 기능이 기본 포함된 Redisson을 선택했다. 이로 인해 동시 요청 상황에서도 한 번에 하나의 스냅샷만 생성되도록 안정적으로 제어할 수 있었다.
ProjectRankingSnapshotService.java
package com.tebutebu.apiserver.service.project.snapshot;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tebutebu.apiserver.domain.ProjectRankingSnapshot;
import com.tebutebu.apiserver.dto.project.snapshot.response.ProjectRankingSnapshotResponseDTO;
import com.tebutebu.apiserver.dto.project.snapshot.response.RankingItemDTO;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@Transactional
public interface ProjectRankingSnapshotService {
Long register();
@Transactional(readOnly = true)
ProjectRankingSnapshotResponseDTO getLatestSnapshot();
default ProjectRankingSnapshotResponseDTO entityToDTO(ProjectRankingSnapshot snapshot) {
List<RankingItemDTO> items;
try {
Map<String, List<RankingItemDTO>> wrapper = new ObjectMapper()
.readValue(
snapshot.getRankingData(),
new TypeReference<>() {
}
);
items = wrapper.get("projects");
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
return ProjectRankingSnapshotResponseDTO.builder()
.id(snapshot.getId())
.data(items)
.build();
}
}
ProjectRankingSnapshotServiceImpl.java
package com.tebutebu.apiserver.service.project.snapshot;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tebutebu.apiserver.domain.Project;
import com.tebutebu.apiserver.domain.ProjectRankingSnapshot;
import com.tebutebu.apiserver.dto.project.snapshot.response.ProjectRankingSnapshotResponseDTO;
import com.tebutebu.apiserver.dto.project.snapshot.response.RankingItemDTO;
import com.tebutebu.apiserver.global.errorcode.BusinessErrorCode;
import com.tebutebu.apiserver.global.exception.BusinessException;
import com.tebutebu.apiserver.repository.ProjectRankingSnapshotRepository;
import com.tebutebu.apiserver.repository.ProjectRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service
@Log4j2
@RequiredArgsConstructor
public class ProjectRankingSnapshotServiceImpl implements ProjectRankingSnapshotService {
private final ProjectRankingSnapshotRepository projectRankingSnapshotRepository;
private final ProjectRepository projectRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redissonClient;
@Value("${ranking.snapshot.duration.minutes:5}")
private long snapshotDurationMinutes;
@Value("${ranking.snapshot.cache.key-prefix}")
private String snapshotCacheKeyPrefix;
@Value("${ranking.snapshot.cache.key-latest-suffix:latest:id}")
private String snapshotCacheKeyLatestSuffix;
@Value("${ranking.snapshot.lock.key-register}")
private String registerLockKey;
@Value("${ranking.snapshot.cache.key-generating-flag:snapshot:generating}")
private String snapshotGeneratingKey;
@Value("${ranking.snapshot.cache.generating-ttl-seconds:60}")
private long snapshotGeneratingTtlSeconds;
@Override
public Long register() {
RLock lock = redissonClient.getLock(registerLockKey);
boolean isLocked = false;
LocalDateTime now = LocalDateTime.now();
try {
// 락을 최대 15초까지 대기하며, 60초 동안 점유함
isLocked = lock.tryLock(15, 60, TimeUnit.SECONDS);
if (!isLocked) {
log.warn("Failed to acquire lock for snapshot registration.");
throw new BusinessException(BusinessErrorCode.SNAPSHOT_LOCK_UNAVAILABLE);
}
log.info("Lock acquired for snapshot registration.");
// 캐시에 저장된 스냅샷 ID가 있는 경우 재사용
String latestCacheKey = snapshotCacheKeyPrefix + snapshotCacheKeyLatestSuffix;
String cachedSnapshotId = (String) redisTemplate.opsForValue().get(latestCacheKey);
if (cachedSnapshotId != null) {
Long snapshotId = Long.parseLong(cachedSnapshotId);
log.info("Reusing cached snapshot with ID={}", snapshotId);
return snapshotId;
}
// 스냅샷 생성 중인 경우 중단
Boolean generating = redisTemplate.opsForValue()
.setIfAbsent(snapshotGeneratingKey, "true", Duration.ofSeconds(snapshotGeneratingTtlSeconds));
if (Boolean.FALSE.equals(generating)) {
log.warn("Snapshot is already being generated. Skipping duplicate request.");
throw new BusinessException(BusinessErrorCode.SNAPSHOT_ALREADY_IN_PROGRESS);
}
// DB fallback 확인
LocalDateTime threshold = now.minusMinutes(snapshotDurationMinutes);
ProjectRankingSnapshot latestSnapshot = projectRankingSnapshotRepository
.findTopByOrderByRequestedAtDesc()
.orElse(null);
if (latestSnapshot != null) {
boolean hasNewProject = projectRepository.existsByCreatedAtAfter(latestSnapshot.getRequestedAt());
if (!hasNewProject && latestSnapshot.getRequestedAt().isAfter(threshold)) {
long remainingTime = Duration.between(now, latestSnapshot.getRequestedAt().plusMinutes(snapshotDurationMinutes)).toMinutes();
if (remainingTime > 0) {
redisTemplate.opsForValue().set(latestCacheKey,
latestSnapshot.getId().toString(), Duration.ofMinutes(remainingTime));
}
log.info("Reusing DB fallback snapshot with ID={}", latestSnapshot.getId());
return latestSnapshot.getId();
}
}
return createAndSaveSnapshot();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("lockInterrupted", e);
} finally {
// 생성 중 플래그 제거
redisTemplate.delete(snapshotGeneratingKey);
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("Lock released for snapshot registration.");
}
}
}
@Override
public ProjectRankingSnapshotResponseDTO getLatestSnapshot() {
ProjectRankingSnapshot snapshot = projectRankingSnapshotRepository
.findTopByOrderByRequestedAtDesc()
.orElseThrow(() -> new BusinessException(BusinessErrorCode.SNAPSHOT_NOT_FOUND));
String cacheKey = snapshotCacheKeyPrefix + snapshot.getId();
ProjectRankingSnapshotResponseDTO cachedSnapshot = (ProjectRankingSnapshotResponseDTO) redisTemplate.opsForValue().get(cacheKey);
if (cachedSnapshot != null) {
return cachedSnapshot;
}
ProjectRankingSnapshotResponseDTO projectRankingSnapshotResponseDTO = entityToDTO(snapshot);
redisTemplate.opsForValue().set(cacheKey, projectRankingSnapshotResponseDTO, Duration.ofMinutes(snapshotDurationMinutes));
return projectRankingSnapshotResponseDTO;
}
private Long createAndSaveSnapshot() {
List<RankingItemDTO> ranking = generateRanking();
String json = serializeToJson(ranking);
ProjectRankingSnapshot saved = persistSnapshot(json);
cacheSnapshot(saved);
return saved.getId();
}
private List<RankingItemDTO> generateRanking() {
List<Project> projects = projectRepository.findAllForRanking();
List<RankingItemDTO> rankingList = new ArrayList<>();
int rank = 1;
for (Project p : projects) {
if (p.getId() == null || p.getTeam() == null || p.getTeam().getGivedPumatiCount() == null) continue;
rankingList.add(RankingItemDTO.builder()
.projectId(p.getId())
.rank(rank++)
.givedPumatiCount(p.getTeam().getGivedPumatiCount())
.build());
}
return rankingList;
}
private String serializeToJson(List<RankingItemDTO> ranking) {
try {
return objectMapper.writeValueAsString(Map.of("projects", ranking));
} catch (JsonProcessingException e) {
throw new BusinessException(BusinessErrorCode.SNAPSHOT_SERIALIZATION_FAILED, e);
}
}
private ProjectRankingSnapshot persistSnapshot(String json) {
ProjectRankingSnapshot newSnap = ProjectRankingSnapshot.builder()
.rankingData(json)
.requestedAt(LocalDateTime.now())
.build();
ProjectRankingSnapshot saved = projectRankingSnapshotRepository.save(newSnap);
projectRankingSnapshotRepository.flush();
return saved;
}
private void cacheSnapshot(ProjectRankingSnapshot snapshot) {
ProjectRankingSnapshotResponseDTO dto = entityToDTO(snapshot);
String idKey = snapshotCacheKeyPrefix + snapshot.getId();
String latestKey = snapshotCacheKeyPrefix + snapshotCacheKeyLatestSuffix;
redisTemplate.opsForValue().set(idKey, dto, Duration.ofMinutes(snapshotDurationMinutes));
redisTemplate.opsForValue().set(latestKey, snapshot.getId().toString(), Duration.ofMinutes(snapshotDurationMinutes));
log.info("New snapshot created with ID={}", snapshot.getId());
}
}
이 서비스는 프로젝트 랭킹 스냅샷을 생성하고 Redis에 캐시하는 역할을 한다. 클라이언트는 이 스냅샷 ID를 기반으로 정렬된 프로젝트 목록을 조회할 수 있다.
- 클라이언트가 프로젝트 랭킹 리스트를 조회하려고 하면,
- 먼저 ProjectRankingSnapshotServiceImpl#register()를 호출해서 스냅샷을 새로 만들거나 기존 ID를 재사용한다.
- 이후 이 ID를 ProjectPagingRepositoryImpl에서 사용해서 해당 시점 기준의 랭킹 리스트를 조회한다.
register() 핵심 로직 요약
- Redisson 분산락 (RLock) 사용해서 동시에 여러 인스턴스에서 스냅샷을 생성하지 못하게 막는다.
- 이미 캐시에 ID가 있으면 그대로 반환해서 스냅샷 생성을 생략한다.
- "스냅샷 생성 중" 플래그가 Redis에 있으면 중복 요청을 차단한다.
- 최근 DB 스냅샷이 유효하고, 새로운 프로젝트가 없다면 그 ID를 재사용하고 캐시에 다시 넣는다.
- 위 조건을 모두 넘기면 랭킹 데이터를 새로 만들고 저장하고, 그걸 Redis에 캐싱한다.
Redis 키 전략
키 | 설명 |
ranking:snapshot:{id} | 각 스냅샷 데이터 캐시 |
ranking:snapshot:latest:id | 가장 최근 생성된 스냅샷 id |
snapshot:generating | 생성 중 여부를 나타내는 락 역할 |
getLatestSnapshot()
DB에서 최신 스냅샷을 조회하고, Redis에 캐시된 버전이 있으면 반환한다. 없으면 새로 DTO를 만들어서 캐싱 후 반환.
ProjectRankingSnapshotConcurrencyTest.java
나는 동시성 환경에서도 프로젝트 랭킹 스냅샷이 단 한 번만 생성되도록 보장하기 위해 다음과 같이 테스트 코드를 작성했다.
package com.tebutebu.apiserver.integration.concurrency.project.snapshot;
import com.tebutebu.apiserver.repository.ProjectRankingSnapshotRepository;
import com.tebutebu.apiserver.service.project.snapshot.ProjectRankingSnapshotService;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@Log4j2
@SpringBootTest
@ActiveProfiles("test")
@DisplayName("ProjectRankingSnapshot 동시성 테스트")
public class ProjectRankingSnapshotConcurrencyTest {
@Autowired
private ProjectRankingSnapshotService snapshotService;
@Autowired
private ProjectRankingSnapshotRepository snapshotRepository;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void clearSnapshots() {
snapshotRepository.deleteAll();
// Clear Redis cache as well
Objects.requireNonNull(redisTemplate.getConnectionFactory()).getConnection().flushAll();
}
@Nested
@DisplayName("register() 호출 시")
class Register {
@RepeatedTest(5)
@DisplayName("동시 호출 시 register() 결과는 하나의 Snapshot ID만 생성됨")
void concurrentRegister_createsSingleSnapshot() throws InterruptedException {
// given
int threadCount = 50;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
List<Long> snapshotIds = new CopyOnWriteArrayList<>();
List<Exception> exceptions = new CopyOnWriteArrayList<>();
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
final int threadNum = i;
executor.submit(() -> {
try {
startLatch.await(); // All threads start at the same time
log.info("Thread {} starting register()", threadNum);
Long id = snapshotService.register();
snapshotIds.add(id);
log.info("Thread {} completed with ID={}", threadNum, id);
} catch (Exception e) {
log.error("Thread {} failed with error: {}", threadNum, e.getMessage());
exceptions.add(e);
} finally {
endLatch.countDown();
}
});
}
startLatch.countDown(); // Start all threads
endLatch.await(30, TimeUnit.SECONDS); // Wait up to 30 seconds
// then
List<Long> distinctIds = snapshotIds.stream().distinct().toList();
log.info("All snapshot IDs: {}", snapshotIds);
log.info("Distinct snapshot IDs: {}", distinctIds);
log.info("Snapshot count in DB: {}", snapshotRepository.count());
if (!exceptions.isEmpty()) {
log.error("Exceptions occurred: {}", exceptions);
}
assertThat(exceptions).isEmpty();
assertThat(snapshotIds).hasSize(threadCount);
assertThat(distinctIds).hasSize(1);
assertThat(snapshotRepository.count()).isEqualTo(1);
}
@Test
@DisplayName("연속 호출 시 캐시된 결과 재사용")
void consecutiveRegister_reusesCachedResult() throws InterruptedException {
// given
Long firstId = snapshotService.register();
// when - 짧은 간격으로 연속 호출
List<Long> subsequentIds = new ArrayList<>();
for (int i = 0; i < 5; i++) {
subsequentIds.add(snapshotService.register());
Thread.sleep(100);
}
// then
assertThat(subsequentIds).allMatch(id -> id.equals(firstId));
assertThat(snapshotRepository.count()).isEqualTo(1);
}
}
}
테스트 구성
1. 핵심 테스트
concurrentRegister_createsSingleSnapshot
- 50개의 스레드가 동시에 register()를 호출한다.
- 각 스레드는 CountDownLatch를 사용해 거의 동시에 시작되도록 동기화됨.
- 각 스레드는 결과로 반환된 스냅샷 ID를 snapshotIds 리스트에 저장한다.
- 테스트 종료 후 다음을 검증한다.
- 모든 스레드가 예외 없이 성공했는지 (exceptions 리스트 확인)
- 총 반환된 ID 수가 스레드 수(50개)와 동일한지
- 반환된 ID는 모두 동일한 값(중복 없이 하나)인지
- 실제 DB에 저장된 스냅샷은 1개뿐인지
이 테스트는 Redisson 분산락이 실제 동시 상황에서 유일한 자원 생성만 허용한다는 것을 확인해준다.
2. 캐시 재사용 확인
consecutiveRegister_reusesCachedResult
- register()를 최초 1회 호출해 스냅샷 ID를 생성하고 캐싱시킨다.
- 이후 짧은 시간 간격(100ms) 으로 5번 연속 호출하여 같은 ID를 반환받는지 확인한다.
- 모든 반환값이 첫 번째 ID와 동일해야 하며, DB에는 여전히 스냅샷이 하나만 있어야 한다.
이 테스트는 Redis에 저장된 스냅샷 ID를 의도한 TTL 안에서 재사용하는 캐시 전략이 잘 작동하는지를 검증한다.
테스트 결과로 확인할 수 있는 것
- Redisson 분산 락이 실제로 단일 스냅샷만 생성되도록 제어하는지 확인 가능
- Redis 캐시 TTL을 활용해 스냅샷 ID를 재사용하는 전략이 유효한지 검증 가능
- 동시에 여러 요청이 들어와도 서비스가 정합성 있는 상태로 응답을 줄 수 있음
테스트 결과 요약
테스트 | 확인 사항 |
concurrentRegister_createsSingleSnapshot | 락이 잘 작동해 동시 생성이 차단되는지 |
consecutiveRegister_reusesCachedResult | TTL 내에는 캐시된 결과가 재사용되는지 |
이러한 테스트 결과, Redisson을 통한 분산락은 동시 작업을 효과적으로 제어해주었고, 안정적인 스냅샷 생성을 보장할 수 있었다.
깃허브
이외의 자세한 코드는 깃허브 참고
GitHub - 100-hours-a-week/8-pumati-be: 카테부 판교 2기 8조 백엔드
카테부 판교 2기 8조 백엔드. Contribute to 100-hours-a-week/8-pumati-be development by creating an account on GitHub.
github.com
카카오테크 부트캠프 판교 2기의 파이널 프로젝트가 궁금하다면?
품앗이 서비스 바로가기: https://tebutebu.com/
품앗이
카카오테크 부트캠프 교육생들을 위한 트래픽 품앗이 플랫폼, 품앗이는 프로젝트 홍보를 통해 교육생들이 서로의 성공을 함께 만들어가는 공간입니다.
tebutebu.com
결론
이번 프로젝트에서 우리는 프로젝트 랭킹 스냅샷 기능을 동시성 안전하게 처리하기 위해 Redis 기반 캐시와 Redisson 분산락을 결합한 구조를 도입했다. 단순한 정렬 기능처럼 보일 수 있는 기능이지만, 수십 개 이상의 스레드 또는 서버 인스턴스에서 동시에 스냅샷을 생성할 수 있는 환경이라면 예기치 못한 중복 생성이나 데이터 꼬임이 충분히 발생할 수 있다.
이를 방지하기 위해 Redisson의 RLock을 통해 분산락을 구현했고, tryLock()과 watchdog 기능을 이용해 안정성을 높였다. 동시에 Redis 캐시를 통해 불필요한 연산을 줄이고 최신 스냅샷을 재사용할 수 있도록 구조를 설계했다.
실제 통합 테스트를 통해 락이 하나의 스레드만 통과시키는지, 캐시가 유효하게 재사용되는지 확인했고, 성능과 신뢰성 측면에서 충분히 실서비스에 도입 가능한 구조임을 확인할 수 있었다. 이 구조는 이후 프로젝트 내 다른 동시성 이슈나 캐시 활용이 필요한 기능에도 확장 가능한 패턴으로 작용할 수 있을 것이다.
'StudyNote' 카테고리의 다른 글
OAuth2 Code Grant에서의 프론트/백엔드 책임 분리와 JWT 발급 고민기 (3) | 2025.05.31 |
---|---|
우테코 백엔드 프리코스 체험해보기 2(자동차 경주 게임) (0) | 2025.01.14 |
우테코 백엔드 프리코스 체험해보기(숫자 야구 게임) (0) | 2025.01.05 |
다사다난했던 2024년도 후기 (3) | 2024.12.31 |
SSAFY 13기 지원부터 SW 적성 진단까지 (2) | 2024.11.27 |