발생한 문제
클라이언트에서 새로운 방 생성 요청은 POST 요청을 통해 이루어진다. 방 생성 버튼을 빠르게 눌러 요청이 서버로 여러번 요청될 경우 방이 중복으로 생성되었고 방 리스트 페이지에서 아무도 없는 빈방이 생기는 문제가 생겼다. 그래서 클라이언트의 중복 요청을 막는 기능을 구현해야 했다.
우선 scale out 전 단일 서버 상황에서 해결한 방법과 scale out 후 해결방법에 대해 작성해보겠다.
단일서버
우선 클라이언트 요청의 중복검사가 필요했다. 중복방지를 위해 할 수 있는 방법으로 다음과 같은 방법이 있었다.
- 데이터베이스 유니크 키 활용
- 요청에 UUID와 같은 유일값을 이용해 중복방지
위 방법 중 요청에 UUID를 넣어 요청하는 방법을 선택했다. DB를 이용할 경우 유니크키 값이 중복될 경우 DuplicateKeyException 등 에러가 발생할 경우 예외 처리를 따로 해야한다. 또한 UUID 저장을 위해 로컬캐시를 이용할 경우 DB를 사용할 때 보다 속도면에서도 이득이 있을거라 생각했다.
UUID 저장을 위한 로컬 캐시 후보로는 세가지가 있었다. 각각의 장단점을 알아보자.
캐시 라이브러리 | 장점 | 단점 |
Guava Cache | 1. 간단한 사용 2. 기본적인 TTL/크기 제한 3. Soft Reference 를 지원해 메모리 부족할 때 캐시 자동 해제 가능. |
1. 단일 LRU 정책 2. 비동기 미지원 3. 최신 업데이트가 느림 |
Caffeine Cache | 1. W-TinyLFU 알고리즘을 사용해 캐시 히트율 높음 2. AsyncLoadingCache를 제공하여 비동기 로딩 지원 3. 유연한 정책 설정 |
1. Guava Cache보다 옵션이 많아 다소 복잡 2. Ehcache보다 영속성 부족 |
Ehcache | 1. 디스크 영속성 지원 2. 클러스터링 지원 |
1. 설정 복잡 2. 로컬 캐시만 필요할 경우 과도 |
이 중 Caffeine Cache를 선택하였다.
- 벤치마크 테스트에서 Guava나 Ehcache 보다 성능이 좋음
- Window TinyLFU 알고리즘을 사용하여 높은 캐시 히트율
Window TinyLFU
캐시 적중율을 극대화하기 위해 설계된 캐시 제거 정책. 기존 LRU와 LFU의 장점을 합친 방식으로 최근성과 빈도를 동시에 고려.
Window Cache : 새 데이터가 처음 캐시에 들어가는 영역. LRU(자장 최근에 사용되지 않은 항목 제거) 방식으로 관리됨.
Probation Cache : Window Cache에서 밀려난 데이터가 임시 저장되는 영역. LFU(가정 적게 사용된 항목 제거) 정책 . 빈도가 높은 데 이터는 ProtectedCache로 승격.
Protected Cache: 자주 접근되는 데이터 저장. LFU기반 영역.
위와같은 이유로 Caffeine Cache를 선택했다.
클라이언트는 room list 페이지에서 방을 생성할 수 있는데 room list 페이지에 접속하면 새로고침을 하지 않는 이상 고정된 UUID를 가지게 된다. 그래서 클라이언트에서 방 생성 요청을 할 경우 UUID와 함께 서버에 요청을 보내게 된다. Caffeine Cache에 해당 UUID를 key, 생성되는 방 정보를 value로 하여 저장하게 되고 같은 UUID를 가진 요청이 오면 저장했단 방 정보를 다시 반환하게 된다.
도입
우선 Caffeine Cache의 의존성을 추가한다. 서버 환경은 spring boot / gradle 이다.
implementation 'com.github.ben-manes.caffeine:caffeine:3.2.0'
의존성 추가 후 설정파일을 작성한다.
@Configuration
public class CaffeineCacheConfig {
@Bean
public Cache<String, RoomResponse> roomCreateCache() {
return Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(1000)
.build();
}
}
간단하게 캐시 저장 최대 크기 1000, 캐시 만료 10초로 설정하였다. 이 외에도 많은 설정이 존재한다.
설정 옵션 | 설명 | 예시 코드 |
maximumSize() | 캐시에 저장할 최대 엔트리 수 지정 | Caffeine.newBuilder().maximumSize(1000) |
maximumWeight() | 엔트리 가중치 기반 총 무게 제한 (사용자 정의 weigher 필요) | Caffeine.newBuilder().maximumWeight(500).weigher((k,v) -> v.getSize()) |
expireAfterAccess() | 마지막 접근 후 지정 시간 경과 시 제거 | Caffeine.newBuilder().expireAfterAccess(30, TimeUnit.MINUTES) |
expireAfterWrite() | 데이터 생성/갱신 후 지정 시간 경과 시 제거 | Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.HOURS) |
refreshAfterWrite() | 지정 시간 간격으로 백그라운드에서 데이터 갱신 | Caffeine.newBuilder().refreshAfterWrite(10, TimeUnit.MINUTES) |
weakKeys() | 키를 약한 참조(Weak Reference)로 저장 (GC 대상) | Caffeine.newBuilder().weakKeys() |
weakValues() | 값을 약한 참조로 저장 | Caffeine.newBuilder().weakValues() |
softValues() | 값을 소프트 참조(Soft Reference)로 저장 (메모리 부족 시 GC 대상) | Caffeine.newBuilder().softValues() |
initialCapacity() | 초기 해시 테이블 크기 지정 | Caffeine.newBuilder().initialCapacity(100) |
recordStats() | 캐시 적중률, 누락 횟수 등 통계 수집 활성화 | Caffeine.newBuilder().recordStats() |
executor() | 비동기 작업용 커스텀 스레드 풀 지정 | Caffeine.newBuilder().executor(ForkJoinPool.commonPool()) |
Caffeine Cache에 값이 있는지 확인하고 쓰는 과정은 원자적으로 수행되어야 한다. 그래서 ReentrantLock을 이용하여 잠금을 하였다.
private final Cache<String, RoomResponse> roomCreateCache;
private final Map<Long, AtomicInteger> roomSubscriptionCount;
private final Lock lock = new ReentrantLock();
public RoomResponse createRoom(RoomCreateRequest roomRequest, LoginUserRequest loginUserRequest) throws IllegalAccessException {
RoomResponse roomResponse;
lock.lock();
try {
roomResponse = roomCreateCache.getIfPresent(roomRequest.UUID());
if (roomResponse != null) {
return roomResponse;
}
Room savedRoom = saveRoom(roomRequest, loginUserRequest);
initializeRoomState(savedRoom.getRoomId());
roomResponse = RoomMapper.INSTANCE.RoomToRoomResponse(savedRoom);
roomCreateCache.put(roomRequest.UUID(), roomResponse);
} finally {
lock.unlock();
}
publishRoomCreatedEvent(roomResponse);
return roomResponse;
}
하지만 scale out 하면서 방 생성 요청이 라운드로빈 정책을 가진 로드밸런싱에 의해 어떤 서버로 요청이 들어올지 알 수 없게되었다. 따라서 모든 서버가 동일한 UUID가 캐시에 있는지 확인할 수 있게 동기화가 필요했다. 동시에 A라는 서버가 캐시에 읽고 쓰는 동안 다른 서버는 접근 하지 못하게 분산락 또한 필요하게 되었다.
위와 같은 조건을 만족하기 위해Redis를 선택하였다. spring boot 환경에서 라이브러리를 통해 쉽게 사용할 수 있고 master/slave 구조와 함께 sentinel을 통해 고가용성을 보장할 수 있다. 또한 redis는 redisson 을 이용해 분산락도 간단하게 할 수 있어 redis를 선택하게 되었다.
도입
이제 redis를 도입해보자. 현재 진행중인 프로젝트는 spring boot / gradle 환경이다.
우선 redis 의존성을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
application.yaml에 redis의 host와 port를 추가해야한다.
spring:
datasource:
redis:
host: "${spring_data_redis_uri}"
port: "${spring_data_redis_port}"
host와 port는 본인의 환경에 맞게 변경하면 된다. 참고로 redis의 default port는 6379다.
이제 분산락을 위한 redisson을 설정하고 캐싱을 위한 RedisTemplate를 만들어 보자.
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisUrl;
@Value("${spring.data.redis.port}")
private String redisPort;
@Bean
public RedisTemplate<String, RoomResponse> roomCreateCacheTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, RoomResponse> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
@Bean
public RedissonClient redissonClient() {
Config redissonConfig = new Config();
redissonConfig.useSingleServer()
.setAddress("redis://" + redisUrl + ":" + redisPort);
return Redisson.create(redissonConfig);
}
}
RedissonClient에 사용중인 redis의 url과 port를 넣어 설정한다. 또한 방 생성 정보 캐싱을 위한 RedisTemplate<K, V> 을 만든다. 이제 설정이 끝났다.
@Slf4j
@Service
@RequiredArgsConstructor
public class RoomProducerService {
private final UserRepository userRepository;
private final RoomRepository roomRepository;
private final GameRepository gameRepository;
private final String ROOM_CREATE_LOCK_PREFIX = "room:create:";
private final RedissonClient redissonClient;
private final RedisTemplate<String, RoomResponse> roomCreateCacheTemplate;
public RoomResponse createRoom(RoomCreateRequest roomRequest, LoginUserRequest loginUserRequest) {
RoomResponse roomResponse = null;
String lockKey = ROOM_CREATE_LOCK_PREFIX + roomRequest.UUID();
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
roomResponse = roomCreateCacheTemplate.opsForValue().get(roomRequest.UUID());
if (roomResponse != null) {
return roomResponse;
}
Room savedRoom = saveRoom(roomRequest, loginUserRequest);
roomResponse = RoomMapper.INSTANCE.RoomToRoomResponse(savedRoom);
roomCreateCacheTemplate.opsForValue().set(roomRequest.UUID(), roomResponse, 1, TimeUnit.MINUTES);
}
} catch (InterruptedException e) {
log.error("Lock acquisition interrupted: {}", e.getMessage());
} finally {
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return roomResponse;
}
private Room saveRoom(RoomCreateRequest roomRequest, LoginUserRequest loginUserRequest) {
Room room = RoomMapper.INSTANCE.RoomCreateRequestToRoom(roomRequest, loginUserRequest.email());
return roomRepository.save(room);
}
}
RLock은 Redisson이 제공하는 Redis 기반 분산락 기능이다. 멀티 서버 환경에서 하나의 프로세스만 특정 리소스에 접근하도록 보장한다. 분산락이 필요한 이유는 synchronized 나 ReentrantLock은 단일 서버 내에서만 유효하기 때문에 RLock이 필요하다.
lock.tryLock() 은 첫번째 매개변수로 락 획득을 위한 최대 대기 시간, 두번째 매개변수로 락 획득 후 자동으로 해제되는 시간, 세번째 매개변수는 시간단위다. 즉 위 코드의 tryLcok은 락 획득을 위해 최대 5초를 대기하고 락 획득 후 10초후 자동으로 해제된다.
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
...
}
락 획득 후 redis에 UUID로 값을 찾아보고 있을 경우 생성된 방 정보를 return 하고 끝내게 된다. 없을 경우 DB에 방을 저장하고 redis에 저장하게 된다. 그리고 무슨일이 있어도 lock을 해제 되야 하므로 finally 블럭안에서 lock.unlock() 을 호출한다.
락을 획득한 스레드만 락을 해제할 수 있어야한다. 또한 만약 락을 획득하지 못하였는데 unlock() 을 호출하면 예외가 발생하여 isHeldByCurrentThread()를 이용하였다.
isHeldByCurrentThread() 란 현재 스레드가 락을 보유하고 있는지 확인하는 역할을 한다. 즉, 현재 실행 중인 스레드가 락을 획득한 주체인지 검사할 수 있다.
최종적으로 위와 같은 아키택처가 된다.
테스트
세가지 테스트를 진행하였다.
모든 입력이 올바를 때
방 생성 요청으로 방의 이름, topicId, max people, quiz count, uuid를 받게되고 요청의 내용이 올바르다면 방을 생성하게 된다.
@Test
@DisplayName("입력이 올바를 때 방이 생성되는지 테스트한다.")
void createRoomTest() throws InterruptedException {
// given
String uuid = "test-uuid";
RoomCreateRequest roomCreateRequest = new RoomCreateRequest("Test Room", 1L, 8, 5, uuid);
LoginUserRequest loginUserRequest = new LoginUserRequest(1L, "test@example.com", Role.USER);
userRepository.save(new User(1L, "test@example.com_3002860612", "test@example.com", Role.USER));
// when
RoomResponse createdRoom = roomProducerService.createRoom(roomCreateRequest, loginUserRequest);
// then
assertThat(createdRoom).isNotNull();
assertThat(createdRoom)
.extracting("roomId", "roomName", "topicId", "maxPeople", "quizCount", "currentPeople")
.contains(1L, "Test Room", 1L, 8, 5, 1);
}
방 중복 생성 방지
@Test
@DisplayName("방 중복 생성 요청이 됐을 때 생성된 방을 반환하는지 테스트한다.")
void testCreateRoomCacheHit() {
// given
String uuid = "test-uuid";
RoomCreateRequest roomCreateRequest = new RoomCreateRequest("Test Room", 1L, 8, 5, uuid);
LoginUserRequest loginUserRequest = new LoginUserRequest(1L, "test@example.com", Role.USER);
userRepository.save(new User(1L, "test@example.com_3002860612", "test@example.com", Role.USER));
// when
roomProducerService.createRoom(roomCreateRequest, loginUserRequest);
RoomResponse actualResponse = roomProducerService.createRoom(roomCreateRequest, loginUserRequest);
List<Room> rooms = roomRepository.findAll();
// then
assertNotNull(actualResponse);
assertThat(actualResponse)
.extracting("roomId", "roomName", "topicId", "maxPeople", "quizCount", "currentPeople")
.contains(1L, "Test Room", 1L, 8, 5, 1);
assertEquals(1, rooms.size());
}
when에서 같은 RoomCreateRequest를 가지고 createRoom을 2번 호출하였다. 그리고 then에서 첫번째 요청과 두번째 요청이 같은지, 그리고 DB에 방이 하나만 생성했는데 테스트하였다.
서로 다른 UUID로 요청
다른 UUID를 가지고 요청을 했다는 것은 다른 방 생성 요청이라는 것이므로 방 생성 요청이 동시에 실행되어야 한다.
@Test
@DisplayName("다른 UUID로 동시 요청 시 분산락이 서로 간섭하지 않고 작동하는지 테스트한다.")
void testDistributeLockWithDifferentUUIDs() throws InterruptedException {
// given
String uuid1 = "uuid-A";
String uuid2 = "uuid-B";
RoomCreateRequest request1 = new RoomCreateRequest("Room A", 1L, 8, 5, uuid1);
RoomCreateRequest request2 = new RoomCreateRequest("Room B", 2L, 8, 5, uuid2);
LoginUserRequest user1 = new LoginUserRequest(1L, "user1@example.com", Role.USER);
LoginUserRequest user2 = new LoginUserRequest(2L, "user2@example.com", Role.USER);
userRepository.save(new User(1L, "user1@example.com_hash", "user1@example.com", Role.USER));
userRepository.save(new User(2L, "user2@example.com_hash", "user2@example.com", Role.USER));
CountDownLatch latch = new CountDownLatch(2);
List<Long> startTimes = Collections.synchronizedList(new ArrayList<>());
List<RoomResponse> results = Collections.synchronizedList(new ArrayList<>());
// when
Runnable task1 = () -> {
startTimes.add(System.currentTimeMillis());
RoomResponse res = roomProducerService.createRoom(request1, user1);
results.add(res);
latch.countDown();
};
Runnable task2 = () -> {
startTimes.add(System.currentTimeMillis());
RoomResponse res = roomProducerService.createRoom(request2, user2);
results.add(res);
latch.countDown();
};
new Thread(task1).start();
new Thread(task2).start();
latch.await();
// then
assertThat(results).hasSize(2);
assertThat(results.get(0)).isNotNull();
assertThat(results.get(1)).isNotNull();
assertThat(results.get(0).roomId()).isNotEqualTo(results.get(1).roomId());
long diff = Math.abs(startTimes.get(0) - startTimes.get(1));
assertThat(diff).isLessThan(100);
}
CountDownLatch를 이용해 두 개의 요청이 가능한 동시에 실행되도록 구성했다. then 절에서는 두 요청이 모두 정상적으로 처리되었는지, 그리고 서로 다른 요청임을 확인하였다. diff는 두 요청의 실제 실행 시점 차이를 측정하기 위해 추가한 검사로, 동시성 테스트에 대한 개인적인 궁금증을 해결하기 위해 포함했다. 로컬 환경에서는 diff 값이 0으로 측정되어, 두 요청이 거의 동시에 실행되었음을 확인할 수 있었다.
Reference
'프로젝트' 카테고리의 다른 글
STOMP 구독 해제 – @EventListener vs Heartbeat (0) | 2025.03.19 |
---|---|
ConcurrentHashMap과 CAS 연산 (0) | 2025.03.17 |
Redis pub/sub을 이용한 서버 동기화 (0) | 2025.03.17 |