대용량 트래픽 환경에서 동일한 요청이 여러 번 처리되는 문제는 시스템 안정성을 해치는 주요 원인 중 하나입니다. 이 글에서는 Redis를 활용해 효과적으로 중복 제출을 차단하는 방법을 살펴봅니다.
중복 요청이 발생하는 상황
사용자 경험 측면에서 중복 요청은 다음과 같은 경우에 발생합니다:
- 버튼을 빠르게 여러 번 클릭
- 네트워크 지연으로 인한 클라이언트 재시도
- 모바일 앱에서 백그라운드 복귀 후 자동 갱신
Redis 기반 중복 방지 메커니즘
Redis의 원자적 연산과 TTL 기능을 활용하면 간단하면서도 강력한 중복 방지 계층을 구축할 수 있습니다.
핵심 원리
요청 식별자를 키로 사용하여 Redis에 임시 저장하고, 동일 키 존재 여부로 중복을 판단합니다.
의존성 설정
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Redis 설정
spring:
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 20
중복 검사 서비스 구현
@Service
public class RequestDeduplicationService {
private final RedisTemplate<String, String> cacheTemplate;
public RequestDeduplicationService(RedisTemplate<String, String> cacheTemplate) {
this.cacheTemplate = cacheTemplate;
}
/**
* 요청이 처음인지 확인하고, 첫 요청인 경우 잠금 생성
*/
public boolean acquireProcessingLock(String requestSignature, Duration ttl) {
Boolean isNew = cacheTemplate.opsForValue()
.setIfAbsent(requestSignature, "PROCESSING", ttl);
return Boolean.TRUE.equals(isNew);
}
/**
* 처리 완료 후 상태 갱신 (선택적)
*/
public void markAsCompleted(String requestSignature, Duration resultTtl) {
cacheTemplate.opsForValue().set(
requestSignature, "COMPLETED", resultTtl);
}
}
AOP 기반 선언적 적용
어테이션을 통해 중복 방지 로직을 비즈니스 코드와 분리합니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DuplicateGuard {
long seconds() default 5;
String[] keyFields() default {};
}
@Aspect
@Component
public class DuplicateGuardAspect {
@Autowired
private RequestDeduplicationService dedupService;
@Around("@annotation(guard)")
public Object intercept(ProceedingJoinPoint point, DuplicateGuard guard) throws Throwable {
String signature = generateKey(point, guard.keyFields());
boolean locked = dedupService.acquireProcessingLock(
signature,
Duration.ofSeconds(guard.seconds())
);
if (!locked) {
throw new DuplicateRequestException("동일한 요청이 처리 중입니다");
}
try {
return point.proceed();
} finally {
// 필요시 완료 표시 또는 키 삭제
}
}
private String generateKey(ProceedingJoinPoint point, String[] fields) {
// 메서드명 + 파라미터 기반 해시 생성
MethodSignature ms = (MethodSignature) point.getSignature();
String base = ms.getDeclaringTypeName() + "." + ms.getName();
Object[] args = point.getArgs();
String data = Arrays.stream(args)
.map(Object::toString)
.collect(Collectors.joining("|"));
return "dedup:" + base + ":" + DigestUtils.sha256Hex(data);
}
}
컨트롤러 적용 예시
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping
@DuplicateGuard(seconds = 10, keyFields = {"userId", "productCode"})
public ResponseEntity<OrderResult> createOrder(@RequestBody OrderRequest req) {
// 중복 요청은 AOP에서 자동 차단
return ResponseEntity.ok(orderService.process(req));
}
}
고급 고려사항
분산 환경에서의 정합성
Redisson의 RBucket 또는 RLock을 활용하면 분산 락과 원자적 연산을 더욱 안전하게 구현할 수 있습니다.
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379");
return Redisson.create(config);
}
}
키 설계 전략
| 요청 유형 | 키 구성 예시 | TTL 권장 |
|---|---|---|
| 주문 생성 | order:{userId}:{productSku} | 30초 |
| 결제 승인 | payment:{orderNo} | 5분 |
| 재고 차감 | stock:{warehouse}:{itemId} | 10초 |
예외 처리 패턴
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateRequestException.class)
public ResponseEntity<ErrorResponse> handleDuplicate(DuplicateRequestException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(
"DUPLICATE_REQUEST",
"이전 요청이 처리 중입니다. 잠시 후 확인해주세요."
));
}
}
Redis 기반 중복 방지는 구현이 간단하고 성능이 우수하여 대부분의 동시성 제어 시나리오에 적합합니다. 다만 비즈니스 특성에 따라 TTL 값과 키 설계를 세밀하게 조정하는 것이 중요합니다.