동일 사용자에 대해서 일정 시간 동안 특정 횟수를 초과하여 요청시 막는 기능을 개발해야한다면?!
1. 해당 사용자의 key별 접근 시간과 접근 횟수를 보관해야한다.
2. 저장소에서 조회하고 갱신하는 작업이 무겁지 않아야한다.
인메모리 캐시 사용이 적합!
그 중 키-값(Key-Value) 데이터 저장소인 ElastiCache(Redis)를 사용하기로 결정
ElastiCache 란
AWS에서 제공하는 분산 인 메모리 캐시(In-Memory-Cache)를 손쉽게 생성하고 확장할 수 있는 서비스
ElastiCache는 두 가지 캐시 엔진을 지원
• Memcached : 유명한 분산 메모리 캐시 시스템
• Redis : String, Hash, List, Set, Sorted Set 등 다양한 데이터 형식을 제공하는 키-값(Key-Value) 데이터 저장소
보안을 위해 ElastiCache for Redis 노드 엑세스는 허용하는 Amazon EC2 인스턴스에 실행되는 애플리케이션으로 제한 된다.
따라서 Redis 서버에 붙고 싶다면, Putty와 같은 클라이언트 애플리케이션으로 해당 EC2 서버에 연결 한 후 다음과 같이 실행하면 된다.
redis-cli -c -h {endpoint} -p 6379
AWS RDS와 마찬가지로 트래픽 분산을 하기위하여 Primary endpoint 이외의 Reader endpoint(읽기 전용 복제본)가 동일하게 생성된다.
ElastiCache는 유료 서비스이므로 로컬에서 테스트를 하고 싶을 경우 설치형 Redis 서버를 로컬에 설치하여 테스트 하자
- host: 127.0.0.1 / port: 6379
Java의 Redis Client는 크게 Jedis, Lettuce 2가지가 있다. Lettuce는 Netty(비동기 이벤트 기반 고성능 네트워크 프레임워크) 기반의 Redis 클라이언트로 비동기로 요청을 처리하기 때문에 Jedis보다 고성능으로 사용할 수 있다. 다만, 현재 적용하려는 프로젝트의 Spring framework(4.3.29.RELEASE), Spring boot(1.5.8.RELEASE) 버전이 낮아서 Lettuce 사용이 불가했다. 😥 따라서 Jedis를 사용하여 구현한 부분은 다음과 같다.
redis.host={Primary endpoint}
redis.slave={Reader endpoint}
redis.port={port}
compile group: 'org.springframework.data', name: 'spring-data-redis', version: '1.8.23.RELEASE'
compile group: 'redis.clients', name: 'jedis', version: '2.9.0'
@Configuration
public class RedisRepositoryConfig {
@Value("${redis.host}")
private String host;
@Value("${redis.slave}")
private String slave;
@Value("${redis.port}")
private int port;
@Bean (value = "jedisConnectionFactory")
JedisConnectionFactory jedisConnectionFactory() {
return createJedisConnectionFactory(host, port);
}
@Bean (value = "slaveJedisConnectionFactory")
JedisConnectionFactory slaveJedisConnectionFactory() {
return createJedisConnectionFactory(slave, port);
}
@Bean (value = "redisTemplate")
public RedisTemplate<String, String> redisTemplate() {
return createStringRedisTemplate(jedisConnectionFactory());
}
@Bean (value = "slaveRedisTemplate")
public RedisTemplate<String, String> slaveRedisTemplate() {
return createStringRedisTemplate(slaveJedisConnectionFactory());
}
private JedisConnectionFactory createJedisConnectionFactory(String host, int port) {
JedisConnectionFactory factory = new JedisConnectionFactory();
factory.setHostName(host);
factory.setPort(port);
factory.setUsePool(true); // JedisPoolConfig 기본 설정을 따름 (30초에 한번씩 검사하여 60초 동안 비활성화 상태인 connection pool 에서 제거)
factory.setDatabase(1);
return factory;
}
private RedisTemplate<String, String> createStringRedisTemplate(JedisConnectionFactory factory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setDefaultSerializer(new StringRedisSerializer());
return template;
}
}
게시글을 작성하는 기능이 있다고 하자. 이때 게시글 작성을 10분동안 5회 이상 요청했을 경우 막아야한다면 게시글을 작성하는 기능이 핵심 기능이 되고, 요청을 확인해서 막는 기능은 부가 기능이라고 할 수 있다. 따라서 해당 로직을 Aspect로 구현하였다.
BlockRequest.java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface BlockRequest {
}
BlockRequestAspect.java
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class BlockRequestAspect {
private final RedisTemplate<String, String> redisTemplate;
private final RedisTemplate<String, String> slaveRedisTemplate;
private final int BLOCK_TIMEOUT = 10; // block 시간 (min)
private final int MAX_TRY_COUNT = 5; // 최대 허용 횟수
@Around("@annotation(test.api.annotation.BlockRequest)")
public Object blockRequest(ProceedingJoinPoint jointPoint) throws Throwable {
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = servletRequestAttributes.getRequest();
String key = request.getAttribute("userKey");
if (StringUtils.isEmpty(key)) {
log.warn("Request parameter of 'userKey' key is null.");
return jointPoint.proceed();
}
int count = getValue(key);
// Before
if (count >= MAX_TRY_COUNT){
throw new CustomException(ErrorCode.TOO_MANY_REQUESTS);
}
Object result = jointPoint.proceed();
// After
HttpServletResponse response = servletRequestAttributes.getResponse();
if (response.getStatus() == HttpStatus.OK) {
if (count == 0) {
initValue(key);
} else {
incrementValue(key);
}
}
return result;
}
private int getValue(String key) {
BoundValueOperations<String, String> bvo = slaveRedisTemplate.boundValueOps(key);
return Integer.parseInt(Optional.ofNullable(bvo.get()).orElse("0"));
}
private long getExpire(String key) {
return slaveRedisTemplate.getExpire(key, TimeUnit.MINUTES)+1;
}
private void initValue(String key) {
BoundValueOperations<String, String> bvo = redisTemplate.boundValueOps(key);
bvo.set("1", BLOCK_TIMEOUT, TimeUnit.MINUTES);
}
private void incrementValue(String key) {
BoundValueOperations<String, String> bvo = redisTemplate.boundValueOps(key);
bvo.increment(1);
}
}
BoardController.java
@BlockRequest // 부가 기능
@RequestMapping(value = "/write", method = RequestMethod.POST)
public void reportWrite(@RequestParam(String userKey)) {
// 핵심 기능
}
🚨 Spring boot에 구현시
Spring boot에 위와 동일하게 구현했는데 다음과 같은 에러가 발생하고 구동이 실패하는 문제가 발생했다.
에러 로그를 분석해보면 redisTemplate 는 single bean이여야하는데 두개가 생성됬단다. Spring framework 에서 구현했을때는 redisTemplate, slaveRedisTemplate 이렇게 각각 빈을 생성해도 문제가 되지 않았다. 왜일까..?🙄
구글링 해보니 Spring boot의 경우에는 RedisAutoConfiguration 가 적용 되어있어서 별도의 설정을 하지 않으면 위와 같은 에러가 발생 한다는것! 따라서 다음과 같이 구동시 RedisAutoConfiguration 설정을 제외 시켜주었다.@EnableAsync @SpringBootApplication(exclude = { RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class }) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
잘 올라간닷😊