Redis를 이용한 중복 Request 처리
2023-05-08 14:48
"따닥 이슈" 라고 불리는, 서버로 복수의 요청이 동시에 들어와서 오류를 발생시키는 상황을 Redis를 이용하여 해결하는 방법을 소개합니다. 코드 예제도 함께 제공하니 빠르게 적용해 보세요.
개요
개발을 하다보면, 한번씩은 따닥 이슈 라고 하는 상황을 만나게 된다. 분명 요청은 한번만 했다고 생각했지만, 동일한 요청이 중복되어 서버로 전달되고, 서버는 이해할 수 없는 오류를 뱉어내는 상황이며, 이미 일상처럼 벌어지고 있는 일이다. 이 글에서는 redis 의 SET 를 활용한 server-side 에서의 중복 요청 방지 방법을 소개하고자 한다.
상황 설정
사용자가 화면에서 파일을 업로드 한다고 가정해보자.
- 서버는 여러대로 다중화 되어있고, 화면의 요청은 LoadBalancer를 통해서 여러대의 서버에 골고루 요청된다.
- 10메가의 파일을 선택하고 업로드 버튼을 클릭했다. 그런데 클릭이 두번 눌렸다고 가정한다.
- 파일은 총 2회에 걸쳐서 각각 다른 서버로 업로드 된다.
- 서버측의 처리에 따라서 각기 다른 파일로 2개가 업로드 될 수도 있고, 내용이 중복이 될수도 있으며, 각양각색의 피곤한 상황이 발생할 수 있다.
- 우리는 redis 의 기능을 이용해서 이 상황을 극복할 수 있다.
Redis를 사용해 보자
Redis 에는 get 이라는 아주 사용하기 쉬운 명령이 있다. 그리고 이 명령은 atomic 하게 동작한다. 즉 동시에 100대의 서버가 요청을 하더라도, 각 요청은 순차적으로 한번에 하나씩만 atomic(원자적으로)하게 동작한다는 의미이다.
Key를 잘 설정하자
Redis에 key-value 형태로 데이터를 Set 할 것이므로, key 를 잘 결정해야 중복을 막을 수 있다. 다른 사용자에게 영향을 미칠 일이 없으면서도, 중복을 막을 수 있는 적당한 key 를 잘 선정해야 한다.
- ex) 로그인한 사용자의 user_id + unix time epoch 조합, uuid 등등
- ex) fileupload_{user_id}_{yyyymmdd}
SET
Redis Set에는 다양한 옵션들이 제공된다. 이중 우리는 EX, NX 옵션을 사용한다.
- NX : 기존 키가 없을 경우에만 값을 세팅한다.
- return
- set 했다면, 1
- 값이 이미 있어서 set 을 못했다면, 0
- return
- EX : 해당 키의 만료 시간을 지정한다. redis 는 메모리를 사용하므로, 최대한 알뜰하게 아껴쓰도록 하자.
실제 호출 순서
- client
- 서버로 api 요청
- 서버
- 요청에 대해서 key 를 조립
- redis 에 해당 key 값을 이용해서 키를 생성
- value 에는 적당한 내용. 요청 request 를 넣어도 되고.
- set 의 result 값을 체크한다.
- 만약 새로운 값으로 등록된 것이라면, 이후의 절차를 진행.
- 만약 기존 값이 이미 존재했다면, 현재의 요청은 실패 처리
- 이후에는 본래 api 가 수행하려고 했던 작업을 진행한다.
- client
- 응답을 받는다. 성공이라면, 중복없이 성공한것.
- 실패했다면, 그리고 그것이 중복요청 때문이라면, 적절하게 처리해주기.
- 실패했다면, 진짜 실패라면, 진짜 적절하게 처리해주기 😂
- 일정시간 이후
- redis 에서 사용했던 key 는 expire time 이 지나면 자연스럽게 제거될 것이므로, 리소스 관리에서도 신경쓸 부분이 사라졌다.
예제
Configuration
- redisTemplate 을 적절히 설정하는 예제는 인터넷에 다채롭다.
@Configuration
@EnableRedisRepositories("com.example.*")
public class RedisSpringDataConfiguration {
@Bean
public RedisTemplate<?, ?> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
...
}
Controller
@RequiredArgsConstructor
@RestController
@RequestMapping("/example")
public class ExampleController {
private final ExampleService exampleService;
@GetMapping("/process")
public String process(@RequestParam Long uuid) {
return exampleService.process(uuid);
}
}
Service
@Slf4j
@RequiredArgsConstructor
@Service
public class ExampleService {
private final RedisTemplate redisTemplate;
public String process(Long uuid) {
// redis 로 lock 을 잡을 key 를 생성.
String key = getKey(uuid);
Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(key, "message", Duration.ofMinutes(5));
log.info("## isSuccess=${}", isSuccess);
if (null == isSuccess || false == isSuccess) {
// 이미 키가 선점되어있으므로, 진행할 수 없습니다.
return "이미 락이 걸려있습니다.";
}
try {
// 여기서 하고싶은 처리를 합니다.
// 테스트를 위한 코드이므로, 적절히 수정해야 합니다.
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "처리했습니다.";
} finally {
// 처리가 일찍 끝나면, lock 으로 잡았던 key 는 해제해줍니다.
redisTemplate.unlink(key);
}
}
private String getKey(Long id) {
// 보통 redis 는 depth 를 구분할때 : 를 사용한다.
return "processkey:" + id;
}
}
setIfAbsent()
- 이 메소드는 set 에 NX 옵션을 포함해서 호출된다.
@Override
public Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit) {
byte[] rawKey = rawKey(key);
byte[] rawValue = rawValue(value);
Expiration expiration = Expiration.from(timeout, unit);
return execute(connection -> connection.set(rawKey, rawValue, expiration, SetOption.ifAbsent()), true);
}
spring-data-redis
- org.springframework.data.redis.connection.RedisStringCommands
- enum 의 옵션들을 확인하자.
/**
* {@code SET} command arguments for {@code NX}, {@code XX}.
*
* @author Christoph Strobl
* @since 1.7
*/
enum SetOption {
/**
* Do not set any additional command argument.
*
* @return
*/
UPSERT,
/**
* {@code NX}
*
* @return
*/
SET_IF_ABSENT,
/**
* {@code XX}
*
* @return
*/
SET_IF_PRESENT;
/**
* Do not set any additional command argument.
*
* @return
*/
public static SetOption upsert() {
return UPSERT;
}
/**
* {@code XX}
*
* @return
*/
public static SetOption ifPresent() {
return SET_IF_PRESENT;
}
/**
* {@code NX}
*
* @return
*/
public static SetOption ifAbsent() {
return SET_IF_ABSENT;
}
}
테스트
- 터미널에 탭을 2개 띄우고 번갈아가면서 실행해본다.
- 락을 획득하면, 5초 대기 후 응답이 온다.
- 락을 획득 못하면, 즉시 리턴한다.
맺음말
이번 포스팅에서는 서버측으로 중복 요청이 전달될 때, Redis를 이용하는 이유와 해결하는 방법에 대해 다뤘다. 일명 따닥 이슈라 불리는 이 현상은 개발자가 흔하디 흔하게 맞닥뜨리는 이슈이며, 이 글을 읽는 당신도 이미 접하고 해결한 경험이 있을 수 있다. Redis를 사용하여 조금 더 효율적으로 핸들링해 보도록 하자.