[SpringBoot] Redis를 이용하여 동시성 문제 해결
시작하기 앞서
참가정원이 존재하는 이벤트가 있고 사용자가 선착순으로 해당 이벤트에 참여하려고 할 때 여러명이 동시에 참가하는 경우 동시성 문제가 일어날 수 있다.
저장이 되기 위한 흐름
- 이벤트 생성 및 참가정원 20명
- 참가정원이 0보다 큰지 조회한다
- 참가자를 저장하기 전에 이미 저장했는지 체크한다. (중복 참가 방지)
- 참가정원을 20에서 19로 1 빼고 참가자를 저장한다.
이 문제를 나는 레디스를 이용하여 해결해보았다.
레디스 트랜잭션
공식문서에서는 아래와 같이 트랜잭션을 사용하라고 하고있다.
내부 구현에서 동일한 비즈니스로직을 처리해봤는데 데이터를 조회한 뒤 조건을 확인하여 데이터를 저장해야 했기 때문에 조회한 데이터를 로그로 확인해봤더니 null이었다.
나의 경우 아래 방법으로 해결이 되지 않았다.
//execute a transaction
List<Object> txResults = redisOperations.execute(new SessionCallback<List<Object>>() {
public List<Object> execute(RedisOperations operations) throws DataAccessException {
operations.multi();
operations.opsForSet().add("key", "value1");
// This will contain the results of all operations in the transaction
return operations.exec();
}
});
System.out.println("Number of items added to set: " + txResults.get(0));https://docs.spring.io/spring-data/redis/reference/redis/transactions.html
스프링부트에서 레디스 트랜잭션을 쓸 때 제한사항
// returns null as values set within a transaction are not visible
template.opsForValue().get("thing1");이 말은 트랜잭션이 실행되는 동안 실행결과를 볼 수 없다는 뜻이다.
-
Configures a Spring Context to enable declarative transaction management.
-
Configures RedisTemplate to participate in transactions by binding connections to the current thread.
-
Transaction management requires a PlatformTransactionManager. Spring Data Redis does not ship with a PlatformTransactionManager implementation. Assuming your application uses JDBC, Spring Data Redis can participate in transactions by using existing transaction managers.
@Transactional을 직접적으로 지원하지 않기때문에 아래와 같이 Bean을 추가하고 JDBC를 빌려서 사용가능하도록 만들 수 있다.
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) throws SQLException {
return new DataSourceTransactionManager(dataSource);
}RedisConfig.class
@Transactional 사용
@Transactional
public EventJoin joinEvent1(EventJoin eventJoin) {
Event event = eventService.getEventQuestionForm(eventJoin.getEventJoinId().getEventUrlUUID());
log.info(event.toString());
if(event.getStartDate().isAfter(eventJoin.getDatetime())) throw new IllegalArgumentException("이벤트 참여시간이 아닙니다.");
return eventJoinRepository.save1(eventJoin);
}EventJoinServiceImpl.class
일단 테스트용으로 위와같이 서비스로직에 작성하고 로그를 확인해봤다.

공식문서에서도 나와있듯이 트랜잭션 내부에서 가져온값을 외부에서 불러올 수 없어야한다.
즉 eventService.getEventQuestionForm을 호출하여 event객체에 저장한 값을 log.info(event.toString()) 로그로 확인할 수 없어야한다.
그래서 직접 redis-cli로 monitor명령어를 이용해 확인해보았다.

7115번 포트에서 MULTI로 트랜잭션을 시작했다. 그리고 EXEC가 실행되기 전에 HSET으로 저장하는 명령어가 트랜잭션에 속해있다.
그런데 중간에 7111번 포트에 HGETALL과 HGET명령어가 섞여있다.
다른포트에서 이러한 연산이 수행된것도 이해가 안되었고 트랜잭션 내에서 가져온 데이터를 로그에 출력되는 현상도 이상했다.
그래서 트랜잭션이 정상적으로 작동하지 않는다고 판단했다.
LuaScript
정보를 좀 찾아보니 스프링부트에서 LuaScript를 작성하여 레디스에 전송하면 해당 스크립트가 원자적으로 실행된다 나와있다.
그리고 아래처럼 구현했다.
@Bean
public RedisScript<Boolean> script() {
Resource scriptSource = new ClassPathResource("luascript/insertEventJoin.lua");
return RedisScript.of(scriptSource, Boolean.class);
}RedisConfig.class에 루아스크립트 사용을 위한 Bean등록
local key = KEYS[1]
local primaryId = KEYS[2]
local dataMap = KEYS[3]
local eventKey = KEYS[4]
local remainEventLimitStr = redis.call('HGET', eventKey, 'joinLimit')
local remainEventLimit = tonumber(remainEventLimitStr)
if remainEventLimit and remainEventLimit > 0 then
local isSaved = redis.call('SADD', key, primaryId)
if isSaved == 1 then
redis.call('HINCRBY', eventKey, 'joinLimit', -1)
redis.call('RPUSH', key .. ':list', dataMap)
return true
else
return false
end
else
return false
endinsertEventJoin.lua
private final RedisScript<Boolean> script; // RedisConfig에 등록한 빈 주입
@Override
public EventJoin save(EventJoin value) {
String eventUrlUUID = value.getEventJoinId().getEventUrlUUID();
String key = redisHash + eventUrlUUID;
String primaryId = value.getEventJoinId().getPrimaryId();
String eventKey = "Event:" + eventUrlUUID;
try {
String dataMap = objectMapper.writeValueAsString(value);
boolean isSaved = executeScript(key, primaryId, dataMap, eventKey);
if (isSaved)
return value;
else
throw new DuplicateKeyException("이미 존재하는 아이디입니다.");
} catch (JsonProcessingException e) {
throw new RuntimeException("Error serializing EventJoin object", e);
}
}
private boolean executeScript(String key, String primaryId, String dataMap, String eventKey) {
Boolean result = eventRedisTemplate.execute(script, Arrays.asList(key, primaryId, dataMap, eventKey));
return Boolean.TRUE.equals(result);
}RedisEventJoinRepository.Impl최종 저장로직

루아스크립트로 여러 조건들을 검증하고 데이터를 저장할 수 있게되었다.
스크립트를 직접 redis에 전송하면 네트워크 오버헤드가 클 것 같다는 생각이 들었지만 찾아보니 같은 스크립트는 hash값으로 만들어서 스프링에서 전송시 캐싱된다 나와있다.
결론
구현의 세부사항이 크게 복잡하진 않았다.
다만 Redis를 RDBMS처럼 사용하려 했는데 RDBMS처럼 사용하라고 만들어진 DB가 아니기 때문에 동시성 문제를 해결하는게 쉽지 않았다.
추가로 SpinLock, pub/sub 등으로 동시성 문제를 다룬 블로그도 많이 참고했는데 SpinLock은 매번 unlock 체크를 해야해서 성능 효율이 별로였다고 판단했고 pub/sub는 원자적으로 감소는 가능하지만 결국 결과값을 얻어와 추가적인 작업을 처리할 수 없어서 사용하지 않았다.