레디스는 싱글 스레드로 동작하므로 동시성 제어를 위한 복잡한 설계와 오버헤드가 적다. 그러나 싱글 스레드는 parallel 할 수 없는 것이지 concurrent 할 수 있다. 즉, 여러 레디스 클라이언트가 있고, 각 클라이언트가 여러 개의 명령을 한 번에 보낸다면, 레디스의 싱글 스레드는 여러 클라이언트들의 명령을 번갈아 하나씩 실행하게 되며 동시성 문제가 발생할 수 있다.
이를 방지하기 위해 크게 Redis Transaction과 Lua Script 2가지를 사용할 수 있다.
Redis Transaction
트랜잭션은 주로 관계형 데이터베이스에서 많이 쓰이는 용어인데, 레디스에서도 트랜잭션을 사용할 수 있다. 레디스에서 트랜잭션을 사용하면 여러 개의 커맨드에 대해 원자성이 보장된다. 따라서 concurrent로 인해 동시성 문제가 발생할 때, 요청을 순차적으로 처리하여 동시성을 제어할 수 있다.
레디스 트랜잭션을 구성하는 커맨드
레디스 트랜잭션을 위해 사용되는 커맨드는 다음과 같다.
- MULTI: 트랜잭션을 시작하는 커맨드이다. 트랜잭션에서 실행하고자 하는 커맨드를 MULTI 이후에 입력한 뒤, EXEC 커맨드를 사용하면 입력했던 커맨드를 원자적으로 실행하고, 트랜잭션이 성공하면 결과가 반환된다. MULTI 커맨드는 항상 OK로 응답하며, MULTI 이후에 입력되는 커맨드는 즉시 실행되지 않고 큐에 쌓인다. 따라서 MULTI 커맨드 이후로 실행되는 명령에 대해 QUEUED라는 응답이 반환된다.
- EXEC: MULTI가 호출된 이후 입력된 커맨드들을 한번에 실행하고 트랜잭션을 끝낸다. EXEC이 호출되면 큐에 쌓인 모든 개별 커맨드가 반환한 요소들을 순서대로 묶어 배열로 반환한다.
- DISCARD: EXEC 대신 DISCARD를 호출하면, 큐에 쌓인 모든 명령을 버리고(flush) 트랜잭션을 끝낸다.
- WATCH: 낙관적 락을 구현하기 위해 사용되는 커맨드이다.
- UNWATCH: WATCH 해둔 키들의 WATCH를 취소할 때 사용되는 커맨드이다.
다음은 MULTI로 트랜잭션을 시작하고 EXEC로 트랜잭션을 끝내는 예제이다.
127.0.0.1:6379> KEYS *
(empty array)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET key 1
QUEUED
127.0.0.1:6379(TX)> GET key
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) "1"
DISCARD 커맨드를 호출하면 MULTI 이후에 모아놓은 커맨드들이 모두 버려진다.
127.0.0.1:6379> KEYS *
(empty array)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET key 1
QUEUED
127.0.0.1:6379(TX)> DISCARD
OK
127.0.0.1:6379> KEYS *
(empty array)
MULTI와 EXEC 사이의 커맨드들은 큐에 저장되었다가, EXEC 커맨드가 호출되면 한 번에 실행된다. 만약 EXEC 이후에 해당하는 key가 없는 등 커맨드가 정상 실행되지 않더라도 트랜잭션은 취소되지 않고, 정상적으로 수행되는 커맨드들만 수행된다. 즉, 롤백되지 않는다.
127.0.0.1:6379> KEYS *
(empty array)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET key1 1
QUEUED
127.0.0.1:6379(TX)> GET key2
QUEUED
127.0.0.1:6379(TX)> EXEC # 트랜잭션이 취소되지 않고, 정상적으로 수행되는 커맨드만 수행됨
1) OK
2) (nil)
127.0.0.1:6379> KEYS *
1) "key1"
트랜잭션 중 에러가 발생하는 경우
트랜잭션 중 발생하는 에러는 두 가지 경우가 있다.
- EXEC 호출 전 발생하는 에러로, 주로 커맨드가 문법적으로 잘못된 경우
- EXEC 호출 후 발생하는 에러로, key가 없는 등 서버에 커맨드를 실행해봐야 아는 문제
EXEC 호출 전에 발생하는 에러는 DISCARD가 호출되는 것처럼 모든 커맨드들을 버려버린다.
127.0.0.1:6379> KEYS *
(empty array)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET key1 1
QUEUED
127.0.0.1:6379(TX)> SET key2 # 에러 발생
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> KEYS * # SET key1 1 커맨드까지 수행되지 않음. 즉, 모든 커맨드가 버려짐
(empty array)
반면, EXEC 호출 후에 발생하는 에러는 수행되지 않지만, 정상적으로 수행 가능한 커맨드는 수행된다. 즉, 롤백되지 않는다.
127.0.0.1:6379> KEYS *
(empty array)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET key1 1
QUEUED
127.0.0.1:6379(TX)> SET key2 2 2 2 # 에러가 발생하는 커맨드
QUEUED
127.0.0.1:6379(TX)> EXEC # EXEC 수행 시 에러가 발생
1) OK
2) (error) ERR syntax error
127.0.0.1:6379> KEYS * # 에러가 발생하지 않는 커맨드는 정상적으로 수행됨
1) "key1"
CAS 알고리즘을 통한 낙관적 락(WATCH)
레디스는 WATCH 커맨드를 통해 트랜잭션 내에서 CAS 알고리즘을 통한 낙관적 락을 구현할 수 있다. WATCH는 하나 이상의 키를 감시하고, 감시하는 키가 트랜잭션 실행(EXEC) 전에 다른 클라이언트에 의해 수정되었다면, 레디스는 데이터 일관성을 보장하기 위해 트랜잭션을 실행하지 않고 취소한다. 즉, 큐에 있던 명령어들은 실행되지 않는다.
WATCH는 여러 번 호출될 수 있으며, UNWATCH 커맨드를 통해 이미 WATCH 된 키를 변경감지 대상에서 제외할 수 있다.
WATCH를 사용하지 않는 경우, 다음과 같이 MULTI와 EXEC 사이에 명령어들을 큐에 넣는 동안, 다른 클라이언트가 관련 값을 변경해버릴 수 있다. key 값이 0이라고 생각하고, 트랜잭션을 시작하여 incr key를 통해 key 값을 1로 변경하는데, EXEC 실행 전에 다른 클라이언트가 key 값을 10로 변경했다. 따라서 해당 트랜잭션 수행 결과 key 값이 1이 아닌, 11이 되었다.
127.0.0.1:6379> GET key
"0"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> INCR key
QUEUED
127.0.0.1:6379(TX)> EXEC # EXEC 실행 전에 다른 클라이언트에 의해 key 값이 10으로 변경됨
1) (integer) 11
127.0.0.1:6379> GET key
"11" # 0 -> 1이 아닌, 10 -> 11이 수행됨
따라서 WATCH를 사용하여 key를 모니터링하면, EXEC 커맨드가 수행될 때 key 값이 변경되었으면 다음과 같이 트랜잭션이 취소된다. 따라서 마지막에 GET key 커맨드 실행 결과, 다른 클라이언트에 의해 변경된 값인 10만 출력된다.
127.0.0.1:6379> GET key
"0"
127.0.0.1:6379> WATCH key
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> INCR key
QUEUED
127.0.0.1:6379(TX)> EXEC # EXEC 실행 전에 다른 클라이언트에 의해 key 값이 10으로 변경됨
(nil) # 트랜잭션 취소됨
127.0.0.1:6379> GET key
"10"
스프링 부트에서 레디스 트랜잭션 사용하기
레디스는 MULTI, EXEC, DISCARD 커맨드를 통해 트랜잭션을 지원하는데, 스프링에서도 RedisTemplate을 통해 레디스 트랜잭션을 사용할 수 있다. SessionCallback을 통한 트랜잭션 방법과 @Transactional을 통한 트랜잭션 방법이 있다.
SessionCallback 인터페이스를 통한 트랜잭션
기본적으로 RedisTemplate이 트랜잭션 내의 모든 작업을 동일한 연결로 수행하는 것은 보장되지 않는다. 따라서 Spring Data Redis는 트랜잭션을 사용할 때와 같이 동일한 연결로 여러 작업을 수행해야 할 때 사용할 수 있는 SessionCallback 인터페이스를 제공한다. 즉, SessionCallback 인터페이스를 사용하면 트랜잭션 내의 모든 커맨드들은 동일한 커넥션을 통해 레디스 서버로 전달된다.
다음 메서드 호출 결과, 성공적으로 key1, key2가 저장되었다.
public void func() {
List<Object> txResult = stringRedisTemplate.execute(new SessionCallback<>() {
@Override
public <K, V> List<Object> execute(RedisOperations<K, V> operations) throws DataAccessException {
operations.multi();
operations.opsForValue().set((K) "key1", (V) "1");
operations.opsForValue().set((K) "key2", (V) "2");
return operations.exec();
}
});
log.info("=====트랜잭션 결과=====");
for (Object result : txResult) {
log.info("트랜잭션 결과 = {}", result);
}
}
이번에는 key2가 없는 상태에서 get("key2")를 호출할 경우, null이 반환되었다. 즉, 오류가 발생해도 롤백되지 않으며, 정상적으로 수행될 수 있는 커맨드들은 정상 수행되었다.
public void func() {
List<Object> txResult = stringRedisTemplate.execute(new SessionCallback<>() {
@Override
public <K, V> List<Object> execute(RedisOperations<K, V> operations) throws DataAccessException {
operations.multi();
operations.opsForValue().set((K) "key1", (V) "1");
operations.opsForValue().get("key2"); // 결과: null
operations.opsForValue().set((K) "key2", (V) "2");
operations.opsForValue().get("key2"); // 결과: 2
return operations.exec();
}
});
for (Object result : txResult) {
log.info("트랜잭션 결과 = {}", result);
}
}
@Transactional을 통한 트랜잭션
기본적으로 RedisTemplate은 관리되는 스프링 트랜잭션에 참여하지 않는다. 즉, @Transactional 내부에서 RedisTemplate을 사용하여 레디스에 커맨드를 수행할 경우, 레디스 내에서 MULTI, EXEC을 통한 트랜잭션은 이뤄지지 않는다.
따라서 @Transactional을 사용할 때 RedisTemplate이 레디스 트랜잭션을 사용하도록 하려면, 각 RedisTemplate에 대해 명시적으로 setEnableTransactionSupport(true)를 설정하여 트랜잭션 지원을 활성화해야 한다. 트랜잭션 지원을 활성화하면 현재 스레드 로컬이 지원하는 트랜잭션에 RedisConnection이 바인딩된다.
@Configuration
@EnableTransactionManagement // 1
public class RedisTxContextConfiguration {
@Bean
public StringRedisTemplate redisTemplate() {
StringRedisTemplate template = new StringRedisTemplate(redisConnectionFactory());
// explicitly enable transaction support
template.setEnableTransactionSupport(true); // 2
return template;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// jedis || Lettuce
}
@Bean
public PlatformTransactionManager transactionManager() throws SQLException {
return new DataSourceTransactionManager(dataSource()); // 3
}
@Bean
public DataSource dataSource() throws SQLException {
// ...
}
}
- 선언적 트랜잭션 관리를 사용하도록 스프링 컨텍스트를 구성한다.
- 현재 스레드에 연결을 바인딩하여 트랜잭션에 참여하도록 RedisTemplate 구성한다.
- 트랜잭션 관리를 위해 PlatformTransactionManager가 필요하다. Spring Data Redis는 PlatformTransactionManager 구현체를 함께 제공하지 않는다. JDBC를 사용할 경우, Spring Data Redis는 기존 트랜잭션 매니저를 통해 트랜잭션에 참여할 수 있다.
우선 @Transactional을 통해 트랜잭션을 사용하기 위해 다음과 같이 설정해줬다. (참고로, @EnableTransactionManagement와 PlatformTransactionManager도 설정해줘야 할 거 같은데, setEnableTransactionSupport(true)만 설정해줘도 정상적으로 트랜잭션이 수행되었다.)
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public StringRedisTemplate stringRedisTemplate() {
StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
stringRedisTemplate.setConnectionFactory(redisConnectionFactory());;
stringRedisTemplate.setKeySerializer(new StringRedisSerializer());
stringRedisTemplate.setValueSerializer(new StringRedisSerializer());
stringRedisTemplate.setEnableTransactionSupport(true); // true 설정
return stringRedisTemplate;
}
}
그리고 다음 메서드 호출 결과 정상적으로 key1, key2가 저장되었다.
@Transactional
public void func() {
stringRedisTemplate.opsForValue().set("key1", "1");
stringRedisTemplate.opsForValue().set("key2", "2");
}
이번에는 key2가 없는 상태에서 get("key2")를 호출했다. 이 경우 null이 반환되는데 오류가 발생해도 롤백되지 않으며, 정상적으로 수행될 수 있는 커맨드들은 정상 수행되었다. 따라서 결과적으로 key1, key2가 모두 저장되었다.
@Transactional
public void func() {
stringRedisTemplate.opsForValue().set("key1", "1");
String result = stringRedisTemplate.opsForValue().get("key2");
log.info(result);
stringRedisTemplate.opsForValue().set("key2", "2");
}
get() 호출 시 주의사항
공식 문서에는 다음과 같이 나와있다.
RedisTemplate is not guaranteed to run all the operations in the transaction with the same connection.
Spring Data Redis provides the SessionCallback interface for use when multiple operations need to be performed with the same connection, such as when using Redis transactions.The following example uses the multi method:
Spring Data Redis distinguishes between read-only and write commands in an ongoing transaction. Read-only commands, such as KEYS, are piped to a fresh (non-thread-bound) RedisConnection to allow reads. Write commands are queued by RedisTemplate and applied upon commit.
SessionCallback
즉, SessionCallback을 통한 트랜잭션에서는 모든 커맨드가 동일한 커넥션을 통해 레디스 서버로 전달된다. 다음과 같이, SessionCallback을 통한 트랜잭션 내에서 set()과 get()을 호출했을 때, 같은 커넥션을 통해 레디스 서버로 커맨드가 전달된 것을 알 수 있다.
public void func() {
List<Object> txResult = stringRedisTemplate.execute(new SessionCallback<>() {
@Override
public <K, V> List<Object> execute(RedisOperations<K, V> operations) throws DataAccessException {
operations.multi();
operations.opsForValue().set((K) "key1", (V) "1");
operations.opsForValue().get("key1");
return operations.exec();
}
});
}
@Transactional
반면, @Transactional을 통한 트랜잭션에서는 읽기 전용 커맨드와 쓰기 전용 커맨드가 구분되어, 서로 다른 커넥션을 통해 레디스 서버로 전달된다.
그리고 이때 읽기 커맨드는 호출 즉시 레디스 서버로 전달되고, 쓰기 커맨드는 RedisTemplate에 의해 큐에 저장되었다가 exec() 호출 시점에 한 번에 레디스 서버로 전달된다. 이는 다음 실행 결과를 보면 알 수 있다.
@Transactional
public void func() {
stringRedisTemplate.opsForValue().set("key1", "1");
String result = stringRedisTemplate.opsForValue().get("key1");
log.info("key1 = {}", result);
stringRedisTemplate.opsForValue().set("key2", "2");
result = stringRedisTemplate.opsForValue().get("key2");
log.info("key2 = {}", result);
stringRedisTemplate.opsForValue().set("key3", "3");
result = stringRedisTemplate.opsForValue().get("key3");
log.info("key3 = {}", result);
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
- set(), get()을 번갈아 호출한다.
- 그리고 10초 뒤에 트랜잭션이 종료된다.
실행 결과, MULTI와 GET 명령어가 먼저 레디스 서버로 전달되었다. 그리고 10초 뒤에 트랜잭션이 종료될 때 RedisTemplate 큐에 저장된 SET 커맨드들이 한 번에 전달되었다. 그리고 읽기 커맨드와 쓰기 커맨드가 서로 다른 커넥션을 통해 레디스 서버로 전달된 것도 확인할 수 있다.
그리고 읽기 커맨드가 바로 레디스로 전달되었다고 하더라고, 레디스 내 큐에 저장된 것이지 아직 실제로 실행되기 전이기 때문에 get() 호출 결과는 null이다.
즉, 실행 흐름을 정리하면 다음과 같다.
- multi(): MULTI 커맨드가 레디스 서버로 전달된 후 실행된다. 이제 트랜잭션이 시작된다.
- get(): 읽기 커맨드가 RedisTemplate 큐에 쌓이지 않고 바로 레디스 서버로 전달된다. 그리고 레디스 내의 큐에 저장된다.
- set(): 쓰기 커맨드가 바로 레디스 서버로 전달되지 않고 RedisTemplate 큐에 저장된다.
- exec(): RedisTemplate 큐에 있는 커맨드가 모두 레디스 서버로 전달되고, 레디스 큐에 저장된 모든 커맨드들이 실제로 실행된다.
결론적으로, 트랜잭션 내에서 호출되는 커맨드들은 레디스 서버 내에서 바로 실행되지 않고 EXEC 명령어 호출 시에 실행된다. 따라서 트랜잭션 내에서 get() 호출을 하면 그 결과는 항상 null이다.
Lua Script
레디스 트랜잭션 외에도 Lua라는 스크립트 언어를 통해 여러 명령어에 대한 원자성을 보장할 수 있다. 레디스에서 루아 스크립트를 사용하는 것에 대한 더 자세한 설명은 Redis 공식 문서를 참고하자.
Reference
'Backend > Redis' 카테고리의 다른 글
센티널 로컬과 도커에 적용해보기 (1) | 2024.05.31 |
---|---|
레디스 복제(Replication)와 센티널(Sentinel) (0) | 2024.05.30 |
프로젝트에서 레디스를 사용하며 한 고민들 (0) | 2024.05.24 |
스프링부트에서 Redis 사용하기 (0) | 2024.02.17 |
Redis 알아보기 (1) | 2024.02.16 |