본문으로 바로가기
반응형

개요

동시성 문제는 상당히 빈번하게 발생한다. 현 회사에서는 유료 이벤트에 참가신청을 하는 경우 비즈니스 로직에서 결제를 같이 진행하는데 동시에 여러번의 요청이 들어왔을 때 중복 결제가 발생했던 이슈가 있었다. 물론, 파이썬으로 구현된 서버이기 때문에 Redlock을 사용하여 해결했었다. 자바에서는 Redisson이라는 레디스 클라이언트가 존재하는데 해당 클라이언트는 분산 락을 위한 메소드를 제공해주기 때문에 간단하게 분산 시스템 환경에서 레디스를 통해 락을 적용할 수 있다.

몇가지 특성

Lua 스크립트의 사용

일반적으로 레디스는 단일 스레드로 돌아가기때문에 그냥 사용하기만 한다면 동시성 문제를 쉽게 해결할 수 있을 것이라 생각할 수 있다. 하지만 여기에는 문제가 존재하는데, 예를 들어 아래와 같은 상황이 있다고 가정해보자.

1. 락이 존재하는지 확인

2. 존재하지 않는다면 락을 걸고 특정 작업 수행. 존재한다면 락이 풀릴 때 까지 대기

1번 락이 존재하는 지 확인하는 구간까지는 문제가 없다. 만약 락이 걸려있지 않은 상황에서 이어서 2번의 작업을 수행하던 도중 다른 요청이 1번 구간을 수행한다면, 동시에 여러개의 요청에서 락이 없다고 판단할 수 있고 의도치 않은 상황이 발생한다. 따라서 1번, 2번의 작업을 한번에 수행해야하는데 그럴 때 Lua 스크립트를 사용하면 여러개의 명령을 atomic하게 수행할 수 있다. redlock에 대해 괜찮은 문서가 있는데 https://redis.io/topics/distlock 를 참고해보자.

스핀락 대신 Pub/Sub 사용

스핀락의 경우 락을 획득하기위해 지속적으로 Redis에 요청을 보내야한다. 지속적인 요청을 통해 락이 풀렸는 지 확인하고 다음 작업을 수행하는데, 대규모 분산 서비스의 경우 이러한 요청 자체가 Redis 서버에 무리가 간다. 따라서 Redisson은 Redis의 Pub/Sub기능을 통해 락이 해제되었을 때 이벤트를 발행하고 클라이언트는 해당 이벤트를 수신하여 처리하는 형태로 구현되어있다.

타임아웃 기능 제공

Redisson을 통해 락을 걸 때 타임아웃을 명시하도록 되어있다. 만약 이 기능이 없다면 특정 요청에서 락을 걸고 작업을 수행하던 도중 예기치 못한 오류로 인해 작업이 중단된다면 락은 영원히 풀리지 않는다. 이러한 사태를 방지하기위해 타임아웃을 통해 작업이 끊겨도 언젠가는 락이 해제될 수 있도록 한다.

설정

implementation 'org.redisson:redisson:3.2.0'

위 라인을 build.gradle에 추가하고 의존성을 추가해준다.

@Configuration
@EnableCaching
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory).cacheDefaults(redisCacheConfiguration).build();
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }

    @Bean
    public RedissonClient redissonClient() {
        Config redisConfig = new Config();
        redisConfig.useSingleServer()
                .setAddress(host + ":" + port)
                .setConnectionPoolSize(5)
                .setConnectionMinimumIdleSize(5);
        return Redisson.create(redisConfig);
    }
}

RedisConfig 클래스를 하나 만들고 위처럼 코드를 작성해준다. 나는 이전 포스팅에 이어서 작성하기때문에 기존 Redis와 캐시 관련 코드들이 혼재되어있지만 가장 하단에 있는 redissonClient 메소드쪽 코드를 참고하자. Redisson 클라이언트를 생성하고 빈으로 등록하는 부분이다.

TryLock

RLock lock = redissonClient.getLock("hide");

먼저 위 코드로 락 객체를 얻어온다.

lock.tryLock(4, 5, TimeUnit.SECONDS);

다음으로 tryLock() 메소드를 통해 락을 건다. 첫 번째 인자는 기다릴 시간이고 두 번째 인자는 락의 타임아웃이다. 마지막 인자로 단위를 주는데 초로 줬다.

@Service
@RequiredArgsConstructor
public class RoomService {
    private final RedissonClient redissonClient;

    @Transactional
    public void createRoom(String password, String code) {
        ...
        RLock lock = redissonClient.getLock("hide");

        try {
            boolean isLocked = lock.tryLock(4, 5, TimeUnit.SECONDS);
            if (!isLocked) {
                System.out.println("LOCK 획득 실패");
            }
        } catch (InterruptedException e) {
            System.out.println(e.toString());
        } finally {
            lock.unlock();
        }
        ...
    }
}

서비스 레이어에 적용을 한 대략적인 코드이다. try~catch 구문을 통해 대략적인 예외처리를 진행해주고 최종적으로 unlock() 메소드를 통해 락을 해제해준다.

반응형