Single-thread 기반의 Redis 동시성?
Redis는 싱글 스레드(Single-thread) 기반으로 데이터를 처리합니다. 싱글 스레드를 기반으로 동작하지만 여러 명의 클라이언트 요청에 동시에 응답하는 동시성도 가지고 있는데요. 이것이 가능한 이유는 레디스의 동장 원리에서 알 수 있습니다.
Redis의 동작 원리를 살펴보면 Redis는 이벤트 루프(Event Loop)를 이용하여 요청을 수행합니다.
즉, 실제 명령에 대한 작업(Task)은 커널 레벨에서 멀티플렉싱(Multiplexing)을 통해 처리하여 동시성을 보장합니다. 쉽게 유저 레벨에서는 싱글 스레드로 동작하지만, 커널 I/O 레벨에서는 스레드 풀을 이용하는 것입니다.
(위 이미지는 참고를 위한 node.js의 event loop 이미지입니다.)
동시성이 있다는 것은 동시성으로 인한 문제가 발생할 수 있다는 것이고, 따라서 동시성 문제에 대한 처리가 필요합니다.
***
동시성(Concurrency)과 병렬성(Parallelism)은 다른 개념이며,
간단하게만 이야기하면 동시성은 적어도 두 개의 스레드가 진행 중일 때 존재하는 조건이며, 두 개 이상의 알고리즘이 하나의 코어 내에서 스레드 간에 빠르게 교차되면서 실행되기 때문에 '동시'라고 느끼는 것입니다.
병렬성은 적어도 두 개 이상의 코어가 있어야 하며, 병렬성도 동시성을 의미하지만 동시성의 차이는 각 코어 내의 스레드가 실제로 동시에 명령어를 실행할 수 있음을 말합니다. 두 개의 알고리즘이 정확히 같은 시점에 실행될 때 이를 병렬적이라고 할 수 있습니다.
Redis 동시성 처리를 위한 Transaciton
먼저 Spring Data Redis에서 Transaction을 사용할 수 있는 두 가지 방법이 있습니다.
한 가지는 SessionCallback 인터페이스를 통해 여러 명령을 하나로 묶어서 처리하는 방법인데, 이 방법은 인터페이스를 통해 직접적으로 Redis 명령어를 사용하여 트랜잭션 경계를 설정하는 방법이고, 다른 한 가지는 간편하게 @Transactional 어노테이션을 사용하는 방법입니다.
***
@Transactional 어노테이션을 사용하기 위해서는 PlatfromTransactionManager를 Bean으로 등록하는 과정이 필요한데요.
Spring에서 공식적으로 채택하고 있는 Redis Client인 Jedis와 Lettuce에 대해서 PlatformTransactionManager 구현체를 제공하지 않기 때문에 JDBC의 DataSourceTransactionManager를 사용하거나 JPA의 JpaTransactionManager를 사용해야 합니다.
(두 방법 모두 @EnableTransactionManagement 적용이 필요하고, redisTemplate의 EnableTransactionSupport 값을 true로 설정해야 합니다.)
@Configuration
@EnableTransactionManagement
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory();
return lettuceConnectionFactory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setEnableTransactionSupport(true); // 설정 필요한 부분
return redisTemplate;
}
@Bean
public PlatformTransactionManager transactionManager() {
return new JpaTransactionManager();
}
}
(JPA의 JpaTransactionManager를 사용하는 방법)
@Configuration
@EnableTransactionManagement
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory();
return lettuceConnectionFactory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setEnableTransactionSupport(true); // 설정 필요한 부분
return redisTemplate;
}
@Bean
public PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSource());
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
return DataSourceBuilder.create().build();
}
}
(JDBC의 DataSourceTransactionManager를 사용하는 방법)
***
@Transactional 어노테이션을 사용하지 않고 SessionCallback 인터페이스를 사용할 경우 해당 적용은 필요 없습니다.
SessionCallBack 사용하는 방법
Redis의 MULTI, EXEC, DISCARD, WATCH, UNWATCH 명령어를 사용하여 직접 트랜잭션을 설정하는 방법이며, 각각의 명령어에 대해서 먼저 살펴보겠습니다.
MULTI
Redis의 트랜잭션을 시작하는 커맨드로 MULTI 커맨드로 트랜잭션을 시작하면 Redis는 이후에 입력되는 커맨드를 바로 실행하지 않고 Queue에 쌓습니다.
EXEC
정상적으로 처리되어 Queue에 쌓여있는 커맨드를 일괄적으로 실행합니다. RDBMS의 commit과 비슷합니다.
DISCARD
Queue에 쌓여있는 커맨드를 일괄적으로 폐기합니다. RDBMS의 Rollback과 비슷합니다.
WATCH, UNWATCH
Redis에서 Lock을 담당하는 커맨드로 낙관적 락(Optimistic Lock)을 기반으로 하며, WATCH 명령어를 사용하면 이후 UNWATCH가 되기 전까지는 한 번의 EXEC 또는 Trasaction이 아닌 다른 커맨드만 허용합니다.
동시성 문제에서 단순히 MULTI, EXEC 만으로 트랜잭션의 고립성을 보장할 수 없기 때문에 사용되며, WATCH로 인하여 예외가 발생했을 때 트랜잭션의 Queue에 쌓여있는 커맨드들을 폐기하는 DISCARD 명령어 등을 통해 처리할 수 있습니다.
@GetMapping("/transaction")
public ResponseEntity<?> transaction() {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.multi(); // transaction start
operations.opsForValue().set("user2", "222");
operations.opsForValue().set("user3", "333");
return operations.exec(); // transaction end
}
});
return null;
}
MULTI 명령어와 EXEC 명령어를 사용한 기본적인 Transaction입니다.
@GetMapping("/transaction")
public ResponseEntity<?> transaction() {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.multi(); // transaction start
operations.opsForValue().set("user2", "222");
operations.opsForValue().set("user3", "3333");
if (true) {
throw new RuntimeException("exception");
}
return operations.exec(); // transaction end
}
});
return null;
}
이 경우 중간에 Exception이 발생하게 되면 Transacion으로 인해 set이 동작하지 않는 것을 볼 수 있습니다.
@PostMapping("/transaction")
public ResponseEntity<?> transaction(String key) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
try {
operations.watch(key); // lock
operations.multi(); // transaction start
operations.opsForValue().set(key, "Someting");
} catch (Exception e) {
e.printStackTrace();
operations.discard();
}
return operations.exec();
}
});
return null;
}
이 경우는 WATCH 명령어를 사용하여 Optimistic Locking을 적용하는 경우입니다.
WATCH 명령어를 이용하면 해당 Key는 트랜잭션에서 값 변경을 1번으로 제한할 수 있습니다.
@PostMapping("/transaction")
public ResponseEntity<?> transaction(String key) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
try {
operations.watch(key); // optimistic locking
operations.opsForValue().set(key, "abc");
operations.multi(); // transaction start
operations.opsForValue().set(key, "def");
} catch (Exception e) {
e.printStackTrace();
operations.discard();
}
return operations.exec(); // transaction end
}
});
return null;
}
다음 경우는 WATCH 명령어를 걸고 key에 "abc"라는 set 했기 때문에 MULTI 명령어와 EXEC 명령어 사이의 Transaction에서는 값 변경이 일어나지 않습니다.
@PostMapping("/transaction")
public ResponseEntity<?> transaction(String key) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
try {
operations.watch(key);
operations.multi(); // transaction start
String value = (String) operations.opsForValue().get(key);
BigDecimal bigDecimalValue = new BigDecimal(value);
operations.opsForValue().set(key, bigDecimalValue.add(BigDecimal.TEN).toPlainString());
} catch (Exception e) {
operations.discard();
}
return operations.exec(); // transaction end
}
});
return null;
}
Redis에서 Transaction을 사용할 때 주의해야 할 경우 중 하나는 Transaction 내부에서 get(key)를 통해 값을 가지고 오려고 할 때입니다. 이 경우 아무리 실행하더라도 value 값이 null로 나오게 되는데요. 이유는 get(Object key) 메서드의 내용을 통해 알 수 있습니다.
"null when key does not exist or used in pipeline / transaction."
transaction에서 사용할 때는 null 값을 리턴한다는 것을 알 수 있습니다. 이유는 무엇일까요?
MULTI 커맨드로 Transaction 시작 구간을 설정하고, 이후 발생하는 커맨드는 EXEC가 실행되기 전까지 Queue에 계속해서 쌓이며 실제 요청이 실행되지 않는데요. 이때 get 요청 마찬가지로 실행되지 않는데 EXEC 구문이 끝난 뒤에 get 요청에 대한 값이 return 되는 것은 의미가 없기 때문에 null 이 리턴된다고 합니다.
* 내용 중 잘못된 부분은 댓글 달아주시면 다시 공부하고 수정하겠습니다. 감사합니다.
< 함께 보면 좋은 자료 >
< 참고 자료 >
'Programming > Spring Boot' 카테고리의 다른 글
생성자 주입과 필드 주입, 수정자 주입 정리 (feat. 의존성 관계 주입) (0) | 2022.03.31 |
---|---|
Spring Batch 개념과 구조, 동작 방식에 대한 정리 (0) | 2022.03.15 |
Spring Boot 타임리프 Thymeleaf layout 적용하는 방법 (4) | 2022.02.28 |
Querydsl Paging 페이징 처리, Custom PageRequest 사용하는 이유 (0) | 2022.02.28 |
DB 트래픽 분산을 위한 DataSource Read, Write 분기 처리 (0) | 2022.02.08 |