동시성 문제
동일한 자원(data)에 대해 여러 스레드가 동시에 접근하면서 발생하는 동시성 문제는 상품의 재고 관리 등 프로세스의 여러 곳에서 발생할 수 있다. 내가 개발했던 게임 서비스에서는, 웹소켓을 통해 실시간으로 다수의 사용자에 대한 요청을 처리하기에 동시성 문제가 발생할 여지가 있었다.
나의 경우에는 게임 종료 시 해당 게임을 진행한 플레이어들의 점수에 따른 경험치와 레벨 반영을 종료 후 게임 종료 버튼이 눌렸을 때 요청을 받기로 했었다. 결과 화면과 게임 종료 버튼이 한 화면안에 있고, 결과 화면을 볼 수 있는 시간 20초 동안 게임 종료 버튼을 누를 수 있다. 20초가 지나면 자동으로 게임 종료가 된다.
여러 플레이어들이 20초가 끝나기 전에 게임 종료 요청을 여러번 보내게 되고, 그러면 매번 요청이 들어와서 경험치 반영이 중복으로 발생하게 되는 문제가 있었다.
이러한 동시성 문제를 해결하는 방법을 고민했고, 그 중 하나인 Redisson을 통한 분산 락을 통해 동시성 문제를 해결했다.
•
동시성 문제(Concurrency Issue) : 하나의 스레드가 데이터를 수정 중인 상황에서 다른 스레드에서 수정 전의 데이터를 조회하여 수정함으로써 데이터의 정합성(consistency)이 깨지는 문제
•
분산 락(Distributed Lock) : 경쟁 상황(race condition)에서 하나의 공유자원에 접근할 때, 데이터의 결함이 발생하지 않도록 원자성(atomic)을 보장하는 기법
프론트의 디바운스 및 쓰로틀링
동시성 문제에 대해 고민할 때, 최대한 게임 프로세스 개선을 통해 풀어보려 했지만 서비스 요구사항 특성상 불가능한 부분이었고, 다음 생각한 건 프론트쪽에서 다중 요청에 대한 부분을 미리 처리해서 보내주면 되는거 아닐까 하는 것이었다.
디바운스나 쓰로틀링을 적용하면 어떨까 했는데, 디바운스는 해당 부분의 연이은 호출을 그룹화하고, 특정 시간이 지난 후 가장 마지막의 호출만을 보내는 것이다. 쓰로틀링은 마지막 함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것이다.
물론 허점은 있다. 위의 기술들은 더블 클릭과 같은 단일 브라우저 내에서의 동시 요청은 막을 수 있지만, 멀티 브라우저에서의 동시 요청은 판별할 수 없다.
나의 경우에는 소켓 통신을 통해 실시간으로 게임이 진행되었기에 멀티 브라우저가 애초에 불가능해서 아마 디바운스가 가장 적절한 선택이었던 것 같은데, 프론트 측 개발 시간에 대한 이슈가 있어서 서버 쪽에서 처리하기로 했다.
Synchronized
동시성 처리에 대한 대표적인 방법으로는 Java의 synchronized 키워드가 있다. 현재 어드민 서버를 인스턴스 1대로 운영하고 있기에, 멀티 스레드의 동시 접근을 막으면 될거라고 생각했다. 한 스레드가 상태 변경을 완료하면, 다음 요청을 처리하는 스레드가 예외 처리에 걸리지 않을까?
때문에 synchronized를 활용했다. 메서드에 직접 적용하는 방법을 활용했다. Service 단에서 @Transactional 과 함께 활용했는데, 이 방법은 문제가 있었다. 일단 인스턴스는 한대지만, 인프라측면에서 docker-compose를 사용한 배포 자동화를 도입한 MSA구조였기에 메모리 공간 자체가 분리되어있어서 락이 의미가 없었다.
이외에도 @Transactional 은 메서드의 앞에 트랜잭션 begin, 뒤에 트랜잭션 commit을 추가시켜주고, 예외가 발생했을 때 자동으로 rollback 시켜준다. 이 부분이 스프링 AOP로 내가 만든 메소드를 스프링의 프록시 객체로 감싸주는 역할을 한다.
그런데 트랜잭션의 begin과 commit은 synchronized 메서드에 포함되지 않는다.(@Transactional 은 DB 트랜잭션 영역에서 처리, synchronized는 JVM 메모리 상에서 처리하기에 아예 영역이 다름) 그 결과 커밋이 날아가서 상태 변화가 DB에 반영되기 전에, 그 다음 요청을 담당하는 스레드가 synchronized 메서드에 진입하여 아직 수정이 반영되지 않은 값을 읽게되고, 예외처리에 걸리지 않는다.
결과적으로, 분산 인프라 환경에서는 Redisson을 활용한 분산 락을 걸어주는게 최적의 답안이었다.
Redisson
Redis에서는 분산 락을 구현하기 위해 다양한 구현체를 제공하는데, Redisson은 Redis를 Java에서 쉽게 사용할 수 있게 도와주는 클라이언트 라이브러리이다. 특히, 분산 락, 캐시, 세마포어, 큐, 맵 등 고수준 구조를 쉽게 구현할 수 있도록 도와준다.
Lettuce가 아닌 Redisson을 사용하는 이유 (락 획득 방식의 차이)
Spring Boot 2.0 부터는 Netty(비동기 이벤트 기반 고성능 네트워크 프레임워크) 기반의 Lettuce가 Redis의 기본 클라이언트로 사용되고 있다. (spring-boot-starter-data-redis)
Lettuce는 공식적으로 분산 락 기능을 제공하지 않기에 필요한 경우 직접 구현해서 사용해야한다. Lettuce의 락 획득 방식은 락을 획득하지 못한 경우 락을 획득하기 위해 redis에 계속해서 요청을 보내는 스핀 락(spin lock)으로 구성되어 있으며, 이러한 방식으로 인해 redis에 부하가 생길 수 있다는 특징이 있다.
반면 Redisson의 경우, 락 획득 시 스핀 락 방식이 아닌 발행/구독(pub/sub) 방식이 사용된다. pub/sub 방식은 락이 해제될 때마다 subscribe 중인 클라이언트들에게 ‘락 획득을 시도해도 된다.’라는 알림을 publish 하기에, 클라이언트 측에서는 락 획득에 실패했을 때 redis에 계속 락 획득 요청을 하는 과정이 사라지게 되며, 부하도 발생하지 않는다.
Redisson의 또 다른 장점으로는 타임아웃 기능이 있다.
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
Java
복사
여기서 tryLock() 메서드는 Redisson에서 Lock을 점유하는 시도를 수행하는 메서드이다. 첫 번째 파라미터는 락 획득을 대기하는 시간이고, 두 번째 파라미터는 락을 점유할 수 있는 최대 시간이다. 세 번째 파라미터는 각 시간의 단위를 나타내는 파라미터(TimeUnit.SECONDS)이다.
waitTime동안 Lock 획득을 시도하기에, 재시도를 위한 로직을 따로 작성할 필요가 없고, Lock을 획득하고 나서 오류로 인해 UnLock을 실행하지 않아도 leaseTime 이후 자동으로 UnLock이 수행된다. 따라서 타임아웃 실패로 인한 스레드의 데드락 상태를 걱정하지 않아도 된다.
사용 예시
1.
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Java
복사
2.
RedisConfig
나는 그때그때 게임 데이터를 정리해서 RDB에 적재하고, TTL을 활용해서 DB자원을 자동으로 확보하기 위해 내부 DB를 모두 Redis로 전환해서 RedisConfig를 따로 적용해놓았고 redissonClient를 빈으로 등록해서 함께 사용했다.
@Configuration
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
private static final String REDIS_HOST_PREFIX = "redis://";
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress(REDIS_HOST_PREFIX + redisHost + ":" + redisPort);
return Redisson.create(config);
}
}
Java
복사
3.
RedissonLock 사용 코드
락 획득 대기 : 5초, 락 점유 시간 : 20초
아래 코드는 락 획득 실패 시 exception을 발생시키고, 획득 시 사용이 끝나면 unlock() 메서드로 해제한다.
// 게임 종료 (분산 락 적용)
@Override
public GameEndRes finishGame(String gameId) {
String lockKey = "lock:game:" + gameId;
RLock lock = redissonClient.getLock(lockKey);
try {
log.info("게임 종료 락 획득 시도 | gameId: {}", gameId);
boolean available = lock.tryLock(5, 20, TimeUnit.SECONDS);
if (!available) {
throw new GameException(ExceptionMessage.GAME_ALREADY_COMPLETED, "게임 종료 중 다른 요청이 처리됨");
}
log.info("게임 종료 락 획득 성공 | gameId: {}", gameId);
...
} catch (InterruptedException e) {
Thread.currentThread().interrupt();;
throw new RuntimeException("게임 종료 락 획득 실패", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("게임 종료 락 해제 | gameId: {}", gameId);
}
}
}
Java
복사
이런 코드도 있다. 락 획득 실패 시 return false를 반환하고, 락 획득 시 따로 return true를 반환하는데 unlock을 하지 않고 leaseTime만큼만 잠금을 획득하는 방식이다.
public boolean acquireLock2() {
...
RLock lock = redissonClient.getLock("{key}");
//락 여부 확인
boolean isLocked = lock.isLocked();
if (!isLocked) {
//락이 걸려있지 않은 경우 락 획득
lock.lock(3, TimeUnit.SECONDS);
}
return isLocked;
}
Java
복사
Redisson을 통한 락에는 RLock 인터페이스가 사용된다.
•
lock() : leaseTime 만큼 락 획득, 락 획득 후 leaseTime이 지나면 자동으로 락 해제
•
tryLock() : return 타입이 boolean. 락 획득 성공 시 true, 실패시 false를 반환. 나머지는 일반 lock() 메서드와 동일.