이 글은, 아키텍처의 변화에 따라 기존의 동시성 제어 방법을 개선하기 위한 Redis 도입 과정을 다룬다.
🌱 들어가기 전
이전 포스팅에서, 프로젝트의 중복 예약 문제를 해결하기 위해 MySQL의 네임드 락을 활용하여 동시성을 제어했었다.
그 이후 프로젝트를 더 개선하며 아키텍처가 변화했다. 단일 서버를 다중 서버 환경으로 바꾸었고, DB 서버도 다중화하며 고려해야 할 사항들이 생겼다.
그 중 동시성을 제어하는 분산락에 관련된 고민과 개선 경험을 공유한다.
'MySQL 네임드 락을 활용한 동시성 제어' 내용은 이전 포스팅에서 확인하실 수 있습니다!
https://deeper-dev.tistory.com/3
[프로젝트] 네임드락을 통한 의자 예약 기능의 동시성 문제 해결
🌱 들어가기 전이번 포스팅에서는 개발중인 서비스의 예약 시스템을 만들며 발견한 동시성 문제를 해결하는 과정을 다룹니다. - 동시성 제어를 구현한 처음에는 바로 예상한 결과가 나오지
deeper-dev.tistory.com
🌱 기술적 고민사항
기존의 단일 데이터베이스 서버를 Master-Slave 구조로 다중화하며 고민이 생겼다.
- 기존 단일 서버 환경에서 동시성 제어를 위해 MySQL 네임드 락을 활용하고 있었는데, 다중화 된 서버간 락을 어떻게 공유하고 동기화 할 것인가?
이 고민을 해결할 수 있는 여러가지 방법이 있다. 내가 고민한 방법과 각 방법의 특징은 다음과 같다.
🌱 해결방법 분석 및 Redisson 선정 이유
💭 1. 기존의 MySQL 네임드 락 활용하기
MySQL의 네임드 락을 그대로 사용하면서 각 서버간 락 동기화를 어떻게 할 수 있을까? 그리고 이 때 발생할 수 있는 문제는 무엇일까?
우선, MySQL의 네임드 락은 개별 MySQL 인스턴스의 시스템에서 관리된다. 따라서 락의 복제보다, 락에 관련된 모든 과정을 각 MySQL 인스턴스에서 진행한다고 생각했다.
특징
- MySQL은 락을 사용하기 위해 별도의 커넥션 풀을 관리해야 한다. 서버가 다중화 된 만큼 커넥션 풀 관리도 복잡해진다.
- 락을 획득하고 해제하는 작업이 빈번할 경우, 트랜잭션 오버헤드가 증가해 성능 저하가 발생할 수 있다.
- MySQL의 목적은 데이터 저장 및 관계형 데이터 관리이다. 락 관리는 그 중 하나의 기능일 뿐이지, 락을 목적으로 설계된 것은 아니다.
추가로, 주 서버에서 락을 복제하는 방식도 생각해봤다.
만약 주 서버에 락 정보가 업데이트되는 찰나의 순간에 부 서버에 요청이 들어와 정상 수행되면? 또 다른 문제가 발생할 수도 있다.
💭 2. Redis를 통한 분산락 구현
하나의 중앙 Redis 서버 또는 Redis 클러스터를 통해 전체 서버에서 락을 공유하고 동기화하는 방법이 있다.
현재 시점에서는, 서비스 규모상 Redis 중앙 서버 하나만 두고 관리하는 방법을 생각했다.
특징
- 간단하다.
- 단일 Redis 서버가 단일 장애 지점이 될 수 있지만 작은 규모의 시스템에서는 충분하다.
- 인메모리 저장소이기 때문에 매우 빠른 속도로 락을 관리할 수 있다.
- 키-값 저장소이기 때문에 락을 관리하는 데 필요한 최소한의 오버헤드만을 가지며, 데이터베이스 트랜잭션이나 복잡한 관계형 데이터 처리 기능을 필요로 하지 않는다.
추가로, 추후 일어날 수 있는 락 관리 서버의 다중화도 생각해봤다. 이 때에는 여러 노드 간에 분산된 락 관리를 쉽게 구현할 수 있는 Redis의 Redlock 알고리즘을 사용하면 적합하다.
즉, Redis는 다중 서버 환경에서 락을 전역적으로 관리하는 데 있어 설계가 적합하다.
따라서, Redis를 사용하기로 결정했다.
💭 Redis의 Redisson 선택 이유
redis에서 가장 많이 사용되는 것은 Lettuce이다. 그러나 나는 Redisson을 선택했다.
이 둘은 락의 사용방식에 차이가 있다.
Lettuce로 분산락을 사용하기 위해서 락을 직접 구현해야하며, 스핀락 방식으로 Lock 획득을 계속 재시도하기 때문에 Redis서버에 부하를 줄 수 있다. Thread.sleep()을 통해 부하를 줄여줄 수 있긴하지만, 기본적으로 어느정도는 부하가 가는 방식이다.
Redisson은 pub/sub 기능을 지원하기 때문에 Redis서버에 부하가 덜하고, 락에 대한 타임아웃을 지원하기때문에 안전하게 사용할 수 있다.
🌱 구현
기존에 분산락 처리 로직을 비즈니스 로직과 분리해서 구현했었다. 이 부분을 그대로 살려, 설정파일 외에 분산락 처리 로직을 담당하는 NamedLockUserReservationFacade 클래스만 수정한다.
(분리 구현에 관한 자세한 내용은 상단에 첨부한 이전 포스팅을 참고해주세요!)
<build.gradle>
// redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
<application.properties>
# redisson
spring.redis.host=localhost
spring.redis.port=6379
<RedissonConfig.java>
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}
}
<NamedLockUserReservationFacade.java>
@Transactional
public long chairReservation(
StoreChair storeChair,
LocalDateTime startDateTime,
LocalDateTime endDateTime,
Reservation reservation) {
long chairId = -1;
String lockName = "CHAIR" + storeChair.getId();
RLock rLock = redissonClient.getLock(lockName);
long waitTime = 5L;
long leaseTime = 3L;
TimeUnit timeUnit = TimeUnit.SECONDS;
try {
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
if (!available) {
throw new BaseException(LOCK_NOT_AVAILABLE);
}
log.info("acquire lock: " + redissonClient.getLock(lockName).getName());
/** 락 획득 후 로직 수행 */
// 예약 가능한지 확인 및 가능한 경우 예약
chairId =
reservationService.checkExistsReservationDateTimeAndSave(
storeChair, startDateTime, endDateTime, reservation);
/** 로직 끝 */
} catch (InterruptedException e) {
// 락을 얻으려고 시도하다가 인터럽트를 받았을 때 발생
throw new BaseException(LOCK_INTERRUPTED_ERROR);
} finally {
try {
rLock.unlock();
log.info("unlock complete: {}", rLock.getName());
} catch (IllegalMonitorStateException e) {
// 이미 종료된 락일 때 발생하는 예외
throw new BaseException(UNLOCKING_A_LOCK_WHICH_IS_NOT_LOCKED);
}
}
return chairId;
}
🌱 테스트
시나리오
1. 두 명의 유저가 동일한 의자에 예약 시간이 겹치도록 동시에 예약을 요청한다.
2. 먼저 들어온 요청: 현재 예약 상황 조회시, 겹치는 예약건이 없으므로 정상적으로 예약 처리된다.
3. 이후에 들어온 요청: 현재 예약 상황 조회시, 동일한 의자에 대해 예약 시간이 겹치기 때문에 예약이 거절된다.
4. 결과적으로 저장된 예약 데이터는 1개이다.
코드
void testChairReservationConcurrency() throws InterruptedException {
// Given
int numberOfThreads = 2;
ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
// 예약자 2명
String user1Email = "gildong@naver.com";
String user2Email = "minji@naver.com";
// 예약 의자 id (겹침)
long storeChairId = 1L;
// 예약 시간1
LocalDateTime startSchedule1 = LocalDateTime.of(2024, 10, 29, 3, 0, 0);
LocalDateTime endSchedule1 = LocalDateTime.of(2024, 10, 29, 6, 30, 0);
// 예약 시간2
LocalDateTime startSchedule2 = LocalDateTime.of(2024, 10, 29, 4, 0, 0);
LocalDateTime endSchedule2 = LocalDateTime.of(2024, 10, 29, 7, 30, 0);
// 예약시 작성해야하는 컨텐츠 필드 id
long fieldId = 1;
// 예약시 작성해야하는 컨텐츠 필드의 내용
List<String> content = Arrays.asList("스터디");
// 예약시 작성하는 컨텐츠 요청 객체
CustomUtilizationContentRequest customUtilizationContentRequest =
new CustomUtilizationContentRequest(fieldId, content);
// 예약 요청 객체 1
ChairUtilizationRequest utilizationRequest1 =
new ChairUtilizationRequest(
storeChairId,
startSchedule1,
endSchedule1,
Arrays.asList(customUtilizationContentRequest));
// 예약 요청 객체 2
ChairUtilizationRequest utilizationRequest2 =
new ChairUtilizationRequest(
storeChairId,
startSchedule2,
endSchedule2,
Arrays.asList(customUtilizationContentRequest));
// When
List<Long> ids = new ArrayList<>();
service.execute(
() -> {
long id = -1;
try {
id =
userReservationService.chairReservation(
user1Email, utilizationRequest1);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
} catch (BaseException baseException) {
log.error(baseException.getResponseCode().getMessage());
}
if (id != -1) {
synchronized (this) {
ids.add(id);
}
}
latch.countDown();
});
service.execute(
() -> {
long id = -1;
try {
id =
userReservationService.chairReservation(
user2Email, utilizationRequest2);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
} catch (BaseException baseException) {
log.error(baseException.getResponseCode().getMessage());
}
if (id != -1) {
synchronized (this) {
ids.add(id);
}
}
latch.countDown();
});
latch.await();
// Then
Assertions.assertThat(ids.size()).isEqualTo(1);
}
결과
🌱 마무리
예약 기능은 작업중인 서비스의 주 기능이기 때문에 락 관리가 특히 중요하다. 따라서, 서버를 다중화함에 따라 락 관리를 어떻게하면 좋을지 고민하고, 문제를 해결하는 시간이었다.
많은 사람들이 하는 것을 따라서 무조건 분산락에 Redis를 도입하는 것은 적절하지않다고 생각한다. 어떤 기술이든, 해당 기술이 왜 필요한지 상황에 맞게 분석하고 trade-off를 따져봐야한다고 생각한다.
앞으로도 다양한 기술적 문제에 대해 고민하고, 적절한 기술을 사용해 상황을 해결해야겠다.
'프로젝트' 카테고리의 다른 글
[프로젝트 - 트러블 슈팅] 동시성 제어의 트랜잭션 이슈 및 테스트 환경 이슈 해결 (1) | 2024.09.18 |
---|---|
[프로젝트] JWT 재발급 자동화를 통해, 반복 작업을 줄이고 네트워크 I/O 감소시키기 (0) | 2024.04.14 |
[프로젝트] 네임드락을 통한 의자 예약 기능의 동시성 문제 해결 (0) | 2024.04.14 |
[프로젝트] 대량 트래픽에 대한 조회 성능 개선 (쿼리 최적화 및 캐시) (0) | 2024.03.17 |