트랜잭션이란 여러가지 명령어들을 처리하는 하나의 단위입니다.
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incr click
QUEUED
127.0.0.1:6379(TX)> incr click
QUEUED
127.0.0.1:6379(TX)> exec
1) (integer) 1
2) (integer) 2
Redis-cli 에서 명령을 보면 multi
로 트랜잭션을 시작하고, 여러 명령어의 실행 결과값을 exec
하면 명령 순서에 따라 결과값을 반환하는 것을 볼 수 있습니다.
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incr click
QUEUED
127.0.0.1:6379(TX)> discard
OK
127.0.0.1:6379> get click
"2"
#### 커넥션 1 ###
127.0.0.1:6379> get click
"6"
127.0.0.1:6379> watch click
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> incr click
QUEUED
127.0.0.1:6379(TX)> set secondkey secret
QUEUED
###############
#### 커넥션 2 ###
127.0.0.1:6379> get click
"6"
127.0.0.1:6379> incr click
(integer) 7
127.0.0.1:6379>
###############
#### 커넥션 1 ###
127.0.0.1:6379(TX)> exec
(nil) <- 명령 실패
127.0.0.1:6379> get secondkey
(nil)
127.0.0.1:6379> get click
"7"
###############
또한 watch 명령어를 통해 하나의 key에 optimistic lock을 걸고, Transaction 도중에 다른 커넥션에서 해당 key에 변경을 주게되면 트랜잭션 내의 명령어가 모두 실패합니다.
위의 예시는 2개의 커넥션에서의 예시로 명령어 입력 순서대로 작성했습니다.
결과적으로 트랜잭션이 실패해서 click을 1 증가시키지 못했고, secondkey가 저장되지않습니다.
관계형 DB를 사용하면서 트랜잭션에 대해서 공부하신 분들이라면 위의 명령어들과 작동원리에 대해서 가볍게 이해하셨을 겁니다.
exec
명령어는 commit, discard
는 rollback이라고 이해하실 수 있습니다.
하지만 이를 동일한 개념으로 이해하고 실제로 적용하려고 한다면 문제가 발생할 수 있습니다.
기억해두어야 할 점은 아래와 같이 5가지입니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
SessionCallback
을 사용하는 방법과 @Transactional
어노테이션을 사용하는 방법이있습니다.public interface RedisService {
void incr(String key, boolean isException);
RedisDto incrAndCopy(String originkey, String newkey, int count);
}
incr()
메서드의 경우 uncheckException이 발생했을 때 데이터가 저장이 되는지를 확인하고자 합니다.incrAndCopy()
메서드의 경우 트랜잭션의 활용할 때의 주의점에 대해서 설명하고자합니다.@Service
@RequiredArgsConstructor
@Transactional // 클래스 단위로 적용
@Slf4j
public class RedisTxService implements RedisService {
private final StringRedisTemplate stringRedisTemplate;
@Override
public void incr(String key, boolean isException) {
// 하나의 Strings 자료구조의 key의 value를 1 증가 시키는 메서드입니다.
stringRedisTemplate.opsForValue().increment(key);
// 예외 상황을 위해서 isException이 true이면 RuntimeException을 던집니다.
if(isException) {
throw new RuntimeException();
}
}
@Override // 무조건 실패하는 로직입니다.
public RedisDto incrAndCopy(String originkey, String newkey, int count) {
// 기존키의 값을 count만큼 증가시킵니다.
stringRedisTemplate.opsForValue().increment(originkey, count);
// 증가된 값을 가져옵니다.
String value = stringRedisTemplate.opsForValue().get(originkey);
log.info("after increment, get(originKey) = {}", value);
// 새로운 key에 증가된 값을 저장합니다.
stringRedisTemplate.opsForValue().set(newkey, value);
// 새로 저장된 key와 value를 Dto로 만들어 반환합니다.
return new RedisDto(newkey, value);
}
}
@SpringBootTest
class RedisTxServiceTest {
@Autowired
private RedisTxService redisTxService;
@Autowired
private StringRedisTemplate redisTemplate;
private final String key = "txKey";
private final String copyKey = "copyKey";
@BeforeEach
void setUp() {
redisTemplate.opsForValue().set(key, "1"); // test마다 시도 전에 txKey의 값을 1로 설정합니다.
}
@Test
@DisplayName("예외가 발생하지 않으면, 정상적으로 key의 값이 1 증가한다.")
void incr_tx_test() {
// when
redisTxService.incr(key, false); // 예외 발생 X
// then
String value = redisTemplate.opsForValue().get(key); // 결과 조회
assertThat(Integer.parseInt(value)).isEqualTo(2); // 1 증가 성공
}
@Test
@DisplayName("트랜잭션안에서 exception이 발생하면 transaction이 discard된다.")
void incr_tx_test_throw_exception() {
// when
assertThatThrownBy(() -> redisTxService.incr(key, true)) // 예외 발생
.isInstanceOf(RuntimeException.class); // RuntimeException 발생
// then
String value = redisTemplate.opsForValue().get(key);
assertThat(Integer.parseInt(value)).isEqualTo(1); // 기존 그대로의 값
}
}
redis에서의 트랜잭션 또한 uncheckedException이 발생했을 때, 데이터를 저장하지 않는 것을 확인할 수 있습니다.
다음으로 incrAndCopy() 테스트입니다.
위에서 Spring에서 redis를 트랜잭션으로 활용할 때의 가장 중요한 점을 테스트합니다. Transaction 내부에서는 값을 조회해서 조작할 수 없는 것을 확인할 수 있습니다.
@Test
@DisplayName("트랜잭션 내부에서 값을 조회해서 활용하고자하면 Exception이 발생한다.")
void incrAndCopy_txTest_exception() {
assertThatThrownBy(() -> redisTxService.incrAndCopy(key, copyKey, 10))
.isInstanceOf(IllegalArgumentException.class); // null값을 조작하려고해서 IllegalArgumentException 발생
//then
String value = redisTemplate.opsForValue().get(key);
assertThat(Integer.parseInt(value)).isEqualTo(1); // 값 변동없음
}
ValueOperation의 get 메서드를 확인해보면 transaction과 pipeline 내부에서 사용시 null을 반환함을 정의합니다.
그렇기 때문에 해당 메서드의 로직처럼 트랜잭션 내부에서 데이터를 조회해서 그 데이터를 토대로 조건을 걸거나, 조작할 수 없습니다.
이 부분이 기존 관계형 DB의 트랜잭션과 레디스의 트랜잭션의 가장 큰 차이점입니다.
log.info("after increment, get(originKey) = {}", value); // null
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisSessionCallbackService implements RedisService {
private final StringRedisTemplate redisTemplate;
@Override
public void incr(String key, boolean isException) {
List<Object> execute = redisTemplate.execute(new SessionCallback<>() {
@Override
public <K, V> List<Object> execute(RedisOperations<K, V> operations) throws DataAccessException {
operations.multi(); // 트랜잭션 시작
operations.opsForValue().increment((K) key); // 1 증가
if (isException) {
throw new RuntimeException();
}
return operations.exec();
}
});
log.info("결과는={}", execute.get(0));
}
@Override
public RedisDto incrAndCopy(String originkey, String newkey, int count) {
List<Object> result = redisTemplate.execute(new SessionCallback<>() {
@Override
public <K, V> List<Object> execute(RedisOperations<K, V> operations) throws DataAccessException {
operations.multi(); // 트랜잭션 시작
operations.opsForValue().increment((K) originkey, count); // 1증가
String value = (String) operations.opsForValue().get(originkey); // 값 조회
log.info("after increment, get(originKey) = {}", value);
operations.opsForValue().set((K) newkey, (V) value); // 새로운 키에 값 저장
return operations.exec(); // 트랜잭션 종료
}
});
return new RedisDto(newkey, (String) result.get(1)); // 두번째 결과값 (값조회)
}
}
@SpringBootTest
class RedisSessionCallbackServiceTest {
@Autowired
private RedisSessionCallbackService sessionCallbackService;
@Autowired
private StringRedisTemplate redisTemplate;
private final String key = "txKey";
private final String copyKey = "copyKey";
@BeforeEach
void setUp() {
redisTemplate.opsForValue().set(key, "1");
}
@Test
void incr_session_test() {
// when
sessionCallbackService.incr(key, false);
// then
String value = redisTemplate.opsForValue().get(key);
assertThat(Integer.parseInt(value)).isEqualTo(2);
}
@Test
@DisplayName("트랜잭션안에서 exception이 발생하면 transaction이 discard 되는지 확인해본다.")
void incr_session_test_throw_exception() {
// when
assertThatThrownBy(() -> sessionCallbackService.incr(key, true))
.isInstanceOf(RuntimeException.class);
//then
String value = redisTemplate.opsForValue().get(key);
assertThat(Integer.parseInt(value)).isEqualTo(1);
}
@Test
void incrAndCopy_sessionTest_exception() {
assertThatThrownBy(() -> sessionCallbackService.incrAndCopy(key, copyKey, 10))
.isInstanceOf(IllegalArgumentException.class);
//then
String value = redisTemplate.opsForValue().get(key);
assertThat(Integer.parseInt(value)).isEqualTo(1);
}
}
watch
명령어를 사용해서 낙관적 락을 걸 수가 없습니다.그래서 두가지를 잘 조합해서 활용하는 편이 좋을 것 같습니다.
resource 디렉토리에 .lua
script를 작성합니다.
-- incr.lua
redis.call("INCR", KEYS[1])
-- incrAndCopy.lua
redis.call('INCRBY', KEYS[1], ARGV[1])
local value = redis.call('GET', KEYS[1])
redis.call('SET', KEYS[1], value)
return tonumber(value)
그리고 해당 스크립트를 Bean으로 등록합니다.
@Bean
public RedisScript<Long> IncrAndCopyScript() {
Resource script = new ClassPathResource("scripts/incrAndCopy.lua");
return RedisScript.of(script, Long.class);
}
@Bean
public RedisScript<Void> IncrScript() {
Resource script = new ClassPathResource("scripts/incr.lua");
return RedisScript.of(script);
}
RedisScript를 redisTemplate으로 활용합니다.
@Service
@RequiredArgsConstructor
public class RedisLuaService implements RedisService {
private final StringRedisTemplate redisTemplate;
private final RedisScript<Long> incrAndCopyScript;
private final RedisScript<Void> incrScript;
@Override
public void incr(String key, boolean isException) {
redisTemplate.execute(incrScript, List.of(key));
if (isException) {
throw new RuntimeException();
}
}
@Override
public RedisDto incrAndCopy(String originkey, String newkey, int count) {
Long value = redisTemplate.execute(incrAndCopyScript, List.of(originkey, newkey), String.valueOf(count));
return new RedisDto(newkey, String.valueOf(value));
}
}
@Test
@DisplayName("예외가 발생해도, rollback이 되지 않는다.")
void incr_lua_test_throw_exception() {
// when
assertThatThrownBy(() -> luaService.incr(key, true))
.isInstanceOf(RuntimeException.class);
// then
String value = redisTemplate.opsForValue().get(key);
assertThat(Integer.parseInt(value)).isEqualTo(2);
}
@Test
@DisplayName("예외가 발생하지 않으면, 정상적으로 key의 값이 1 증가한다.")
void incrAndCopy_luaTest() {
RedisDto redisDto = luaService.incrAndCopy(key, copyKey, 10);
assertThat(redisDto.getKey()).isEqualTo(copyKey);
assertThat(Integer.parseInt(redisDto.getValue())).isEqualTo(11);
//then
String value = redisTemplate.opsForValue().get(key);
assertThat(Integer.parseInt(value)).isEqualTo(11);
}
setnx
명령어를 통한 비관적 락을 활용해서 동시성 문제를 해결하는 방법, LuaScript를 활용해서 동시성 문제를 활용하는 방법에 대해서도 따로 공부해보시면 redis를 이해하고 활용하시는데 많은 도움이 될 것이라고 생각합니다.
잘보고가요! 정리 넘 잘하셨네요 도움 많이 되었습니다! 로니짱 🥹
루아스크립트가 깔끔한거 같은데.. 코드레벨이 아니라 아쉽긴 하네요.
근데 트랜잭션 어노테이션이나 세션 콜백이 좋냐고는 ... 흑..
로니는 무엇을 더 즐겨하시는 편인가요?