🙏내용에 대한 피드백은 언제나 환영입니다!!🙏
동시성 문제는 여러 개의 프로세스나 스레드가 동시에 같은 자원에 접근하거나 수정하려고 할 때 발생하는 문제이다. 이런 경우 데이터가 꼬이거나 예상치 못한 결과가 나올 수 있다. 그로 인해 발생하는 문제로는 대표적으로 데이터 손실, 중복 처리, 오류 등이 발생할 수 있다.
아래 코드는, 내가 진행했던 프로젝트의 코드이고, 식당 예약 서비스
이다.
식당 예약을 하기위해, 번호표 예약, 시간대 예약 등이 있을 텐데, 아래는 시간대 예약
부분이고, 아래의 코드에서 동시성 문제
가 발생했다.
/* 가게 예약하기 (번호표) */
public void saveQueueReservation(String username, Long restaurantId) {
String key = RESERVATION_KEY_PREFIX + QUEUE + restaurantId;
if (isUserAlreadyInQueue(username, restaurantId)) // 중복 예약 확인.
throw new CustomException(ErrorCode.ALREADY_USER_RESERVATION);
int size = getQueueWaitingCount(restaurantId); // redis 사이즈를 통해 예약 번호 지정
QueueReservation queueReservation = QueueReservation.builder()
.username(username)
.booking(size + 1)
.build();
redisUtil.hashPutQueue(key, queueReservation);
redisUtil.expire(key, QUEUE_RESERVATION_TTL);
.
.
.
}
/* 가게 예약자 수 읽기 */
public int getQueueWaitingCount(Long restaurantId) {
String key = RESERVATION_KEY_PREFIX + QUEUE + restaurantId;
return redisUtil.hashSize(key); // redis 사이즈를 통해 예약 번호 지정
}
발생한 부분은 int size = getQueueWaitingCount(restaurantId);
이 부분이다.
만약, 여러 개의 프로세스 또는 스레드가 동시에 접근을 한다면, 모두 같은 값을 반환하여 예약하는데 있어서 문제가 발생한다.
/* 예약 테스트 (여러 개의 스레드) */
@Test
public void testSaveQueueReservationConcurrency() throws InterruptedException {
// 스레드 풀 생성
ExecutorService executorService = Executors.newFixedThreadPool(5); // 스레드 5개 지정
Long restaurantId = 1L;
String baseUsername = "user";
String key = "reservation:queue:" + restaurantId;
redisUtil.del(key); // 기존 값 제거
// 여러 사용자가 동시에 예약을 추가하는 것을 시뮬레이션
for (int i = 0; i < 10; i++) { // 10개의 예약 요청을 동시에 실행
String username = baseUsername + i;
executorService.submit(() -> {
try {
queueReservationService.saveQueueReservation(username, restaurantId);
} catch (Exception e) {
System.out.println("에러 발생 " + username + " - " + e.getMessage());
}
});
}
// 스레드 풀 종료
executorService.shutdown();
// 스레드 풀에 남아 있는 작업 끝날 때 까지 대기 (최대 1분)
executorService.awaitTermination(1, TimeUnit.MINUTES);
}
/* 예약 번호 들고오기 */
@Test
public void testVerifyQueueReservation() {
Long restaurantId = 1L;
String key = "reservation:queue:" + restaurantId;
// Redis에서 예약된 모든 데이터를 리스트로 가져오기
List<QueueReservationResDto> reservations = redisUtil.getQueueEntries(key);
// Redis에 저장된 예약 번호 및 사용자 확인
reservations.forEach(queueReservation -> {
System.out.println("사용자: " + queueReservation.getUsername() +
", 예약 번호: " + queueReservation.getBooking());
});
}
사용자: user6, 예약 번호: 6
사용자: user2, 예약 번호: 1
사용자: user4, 예약 번호: 1
사용자: user7, 예약 번호: 6
사용자: user3, 예약 번호: 1
사용자: user1, 예약 번호: 1
사용자: user0, 예약 번호: 1
사용자: user5, 예약 번호: 6
사용자: user9, 예약 번호: 6
사용자: user8, 예약 번호: 6
위와 같이, 5개 씩 동시에 스레드가 접근하니 동일한 공유 자원에 접근을 하여, 값이 중복으로 발생하게 되었다.
우선, 동시성 문제를 해결할 수 있는 방법은 여러가지이다.
하지만, 나는 여기서는 Redis client인 Redisson
을 선택하였다.
그 이유는 자바에서 제공하는 synchronized
는 여러 서버라면 동기화되지 않는다는 문제점 (하나의 서버라면, 빈은 싱글톤이라기에 문제 x),
DB를 사용하는 것이 아니기 때문에 DB에서의 동시성 제외,
Redis의 Lua스크립트 사용은 사용하기 어려움이 그 이유이다.
다른 동시성 문제 해결 방법은 다음 글에 포스팅하려고 한다.
분산락은 여러 서버가 동시에 접근하는 공유 자원에 대해 하나의 프로세스 또는 스레드만 접근할 수 있도록 제어하는 메커니즘이다. 즉, 여러 클라이언트가 동시에 공유 자원에 접근할 때 발생하는 동시성 문제를 해결하기 위한 방법이다. 이를 통해 데이터 무결성을 보장할 수 있다.
<< 이러한 경우 여러 스레드나 서버가 동시에 자원에 접근하지 않도록 락(Lock)을 설정하여 해결할 수 있다. >>
Redis Client인 Redisson
, 분산 환경에서 락을 제공하여 다중 서버나 다중 스레드 환경에서도 안정적으로 동시성 문제를 해결할 수 있는 라이브러리이다.
Redisson
이용하여 분산락(Distributed Lock)을 구현하면, 여러 서버에서 동일한 자원에 접근할 때에도 하나의 자원만 동시에 수정할 수 있도록 제어할 수 있다.
implementation 'org.redisson:redisson-spring-boot-starter:3.16.3'
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
return Redisson.create(config);
}
}
private final RedissonClient redissonClient;
private static final String QUEUE = "queue:";
private static final long QUEUE_RESERVATION_TTL = 10*60*60;
/* 가게 번호표 예약자 추가 */
public void saveQueueReservation(String username, Long restaurantId) {
String key = RESERVATION_KEY_PREFIX + QUEUE + restaurantId;
String lockKey = "lock:" + key; // 락을 걸 키
RLock lock = redissonClient.getLock(lockKey); // Redisson 락을 가져옴
try {
boolean available = lock.tryLock(5L, 5L, TimeUnit.SECONDS);
if (!available) {
throw new CustomException(ErrorCode.LOCK_ACQUISITION_ERROR);
}
if (isUserAlreadyInQueue(username, restaurantId)) // 중복 예약 확인.
throw new CustomException(ErrorCode.ALREADY_USER_RESERVATION);
int size = getQueueWaitingCount(restaurantId); // redis 사이즈를 통해 예약 번호 지정
QueueReservation queueReservation = QueueReservation.builder()
.username(username)
.booking(size + 1)
.build();
redisUtil.hashPutQueue(key, queueReservation);
redisUtil.expire(key, QUEUE_RESERVATION_TTL);
.
.
.
} catch (InterruptedException e) {
throw new CustomException(ErrorCode.THREAD_INTERRUPTED);
} finally {
lock.unlock();
}
}
주요 코드는 아래와 같다.
RLock lock = redissonClient.getLock(lockKey);
을 통해 락을 생성. 이 락을 통해 특정 자원에 대한 접근을 제한.
boolean available = lock.tryLock(5L, 5L, TimeUnit.SECONDS);
첫 번째 매개변수 : 대기시간 (다른 스레드가 이미 락을 가지고 있는 경우)
두 번째 매개변수 : 독점시간 (스레드가 락을 들고 있을 수 있는 시간)
세 번째 매개변수 : 시간 타입
독점시간의 경우, 너무 짧게하면 작업이 덜 끝났는데 스레드가 종료될 수 있고, 너무 길게한다면 스레드에 문제가 생겨 해제가 안되는 상황이 있을 수 있는데, 이 때 데드락이 발생할 수 있다.
나의 경우에는, 예약 외에도 알림 전송 과정이 있기 때문에 독점 시간을 5초로 하였다.
lock.unlock();
사용자: user2, 예약 번호: 1
사용자: user3, 예약 번호: 7
사용자: user7, 예약 번호: 4
사용자: user8, 예약 번호: 5
사용자: user1, 예약 번호: 8
사용자: user6, 예약 번호: 3
사용자: user5, 예약 번호: 2
사용자: user9, 예약 번호: 6
사용자: user4, 예약 번호: 10
사용자: user0, 예약 번호: 9
이 전과는 달리 예약 번호가 중복된 결과 값이 없다.
동시성 문제는, 운영체제를 공부할 때 알게 되었다. 이론과 결과를 알 뿐, 실제로 발생하는 경우는 보지 못해서, '시스템적으로 잘 처리가 되는구나'라는 생각을 했던 적이 있다.
하지만, 내가 동시성 문제에 대해 직면해보지 못했거나, 위의 동시성 문제 코드처럼 문제가 있지만 지나간 경우가 있다고 생각한다.
이번 계기로 테스트의 중요성을 다시 느끼게 되었고, 다양한 문제에 대해서 직면해보고 싶다는 생각 또한 하게 되었다.
그리고, 동시성 문제를 해결하는 방법은 여러가지인데, 다음 글을 통해 다뤄보고자 한다.