1. Embedded Redis 설정
Local, DEV 환경에서 사용할 내장 Redis 설정. STAGE/PROD 환경에서는 ElastiCache를 사용한다.
내장 Redis 라이브러리 의존성을 gradle 빌드 설정 파일에 추가한다.
현재로서는 codemonstur의 라이브러리가 Apple Silicon CPU도 지원한다.
// build.gradle
dependencies {
// ...
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'com.github.codemonstur:embedded-redis:1.4.3'
// ...
}
application.yaml에 Redis 서버의 HOST, PORT 프로퍼티 값을 설정한다.
# application.yaml
spring:
redis:
host: 127.0.0.1 # Redis Server가 localhost라면 default값이기 때문에 생략 가능
port: 6380
Java Config로 Redis Server, Redis Template 등의 Bean 생성 설정을 추가한다.
RedisConnectionFactory Bean 생성 시 환경별로 생성하는 부분이 다른 것에 주의.
// RedisConfigure.java
import redis.embedded.RedisServer;
@Configuration
public class RedisConfigure {
@Value("${spring.redis.host:}")
private String redisHost;
@Value("${spring.redis.port:0}")
private int redisPort;
@Bean
public RedisTemplate<String, Object> redisTemplate(final RedisConnectionFactory redisConnectionFactory) {
final RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
@Profile(value = {"stg", "prod"})
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Profile(value = {"default", "local", "dev"})
@Bean("redisConnectionFactory")
public RedisConnectionFactory redisConnectionFactoryLocal(final RedisServer redisServer) throws IOException {
redisServer.start();
final List<Integer> redisPorts = redisServer.ports();
if (CollectionUtils.isEmpty(redisPorts)) {
throw new RuntimeException("Embedded Redis Server starting error.");
}
return new LettuceConnectionFactory("127.0.0.1", redisPort);
}
@Profile(value = {"default", "local", "dev"})
@Bean
public RedisServer redisServer() throws IOException {
return new RedisServer(redisPort);
}
}
프로그램 종료 시 Embedded Redis 서버를 Shutdown하는 코드도 추가한다.
(Graceful Down하는 구조가 아니라면, 사실 효용성은 없다)
// RedisEmbeddedConfigure.java
@Profile(value = {"default", "local", "dev"})
@Configuration
public class RedisEmbeddedConfigure {
private final RedisServer redisServer;
public RedisEmbeddedConfigure(RedisServer redisServer) {
this.redisServer = redisServer;
}
@PreDestroy
public void stopEmbeddedRedisServer() throws IOException {
this.redisServer.stop();
}
}
2. Annotation 생성
횡단 관심사이기 때문에, 비즈니스 로직에 영향을 주지 않는 방향으로 고안.
Annotation을 정의하고 해당 Annotation이 적용된 메서드 실행 전후로 접속 카운트를 하고 접속 허용 / 거부를 하는 방향으로 개발하기로 한다.
AccessLimiter 라는 이름의 Annotation을 정의하고 옵션 항목들도 정의한다.
// AccessLimiter.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLimiter {
RestrictMethod restrictMethod() default IP;
String keyPrefix() default "";
int limitMaxCount() default 5;
boolean isAutoReset() default false;
long ttlHours() default 24L;
}
각 옵션의 설명은 다음과 같다.
restrictMethod: 접속 제한을 설정할 기준. Enum Class로 지정하며, IP / SESSION / IP+SESSION / NONE 4개의 선택 항목으로 설정. IP가 기본값.keyPrefix: Redis에 접속 횟수를 저장할 때 key로 쓸 이름의 prefix.limitMaxCount: 접속 제한 기준. 기본값 5로 5번 이상 접속 시 접속 차단한다는 의미.isAutoReset:true이면 다음날 자정에 접속 횟수 기록이 일괄 리셋되며,false이면 설정된 TTL이 경과하면 리셋된다.ttlHours: TTL로 설정한 시간. 기본값 24시간.
Enum Class인 RestrictMethod는 HttpServletRequest 객체를 매개변수로 받아 keyPrefix를 리턴하는 메서드를 포함한다.
// RestrictMethod.java
@Getter
public enum RestrictMethod {
NONE("All Access"),
IP("Client IP Address"),
SESSION("Server Session"),
COMPLEX("Complex IP and Session");
RestrictMethod(String method) {
this.method = method;
}
private final String method;
public String findKey(HttpServletRequest httpServletRequest) {
switch (this) {
case IP:
return httpServletRequest.getRemoteAddr();
case SESSION:
return httpServletRequest.getSession().getId();
case COMPLEX:
final String[] requestValues = {
httpServletRequest.getRemoteUser(),
httpServletRequest.getSession().getId()
};
return StringUtils.join(requestValues, ".");
case NONE:
default:
return "";
}
}
}
3. Aspect (AOP) 로직 정의 컴포넌트
접근 전에 이전 접속 이력을 확인하여 허용 / 거부를 수행하고,
접근 후에 접속 이력에 count +1을 추가하기 때문에 PointCut은 Around로 설정하고 로직을 구현한다.
// AccessRestrictAspect.java
@Slf4j
@Aspect
@Component
public class AccessRestrictAspect {
private final RedisTemplate<String, Object> redisTemplate;
public AccessRestrictAspect(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Around("@annotation(com.lgensol.battery.springrestfulapi.blifeCareApi.config.annotation.AccessLimiter)")
public Object beforeMethod(final ProceedingJoinPoint pjp) throws Throwable {
final HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
final AccessLimiter accessLimiter = getAnnotation(pjp, AccessLimiter.class);
final String key = getKey(pjp, request, accessLimiter);
final Pair<Boolean, Integer> accessRecord = getAccessRecord(key, accessLimiter.limitMaxCount());
if (accessRecord.getFirst()) {
throw new ExceededRestrictException(accessRecord.getSecond());
}
final Object methodResult = pjp.proceed();
CompletableFuture.runAsync(() -> setAccessRecord(key, accessLimiter))
.join();
return methodResult;
}
private <T extends Annotation> T getAnnotation(final ProceedingJoinPoint pjp, final Class<T> t) {
final MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
return methodSignature.getMethod().getAnnotation(t);
}
private String getKey(final ProceedingJoinPoint pjp, final HttpServletRequest request,
final AccessLimiter accessLimiter) {
final MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
final String keyPrefix = accessLimiter.keyPrefix();
if (!StringUtils.isEmpty(keyPrefix)) {
return keyPrefix;
}
return String.format("%s.%s.%s-%s",
accessLimiter.restrictMethod().name(),
methodSignature.getMethod().getDeclaringClass().getSimpleName(),
methodSignature.getMethod().getName(),
accessLimiter.restrictMethod().findKey(request)
);
}
private Pair<Boolean, Integer> getAccessRecord(final String key, final int limit) {
try {
final Object value = redisTemplate.opsForValue().get(key);
final int count = NumberUtils.toInt(String.valueOf(value));
return Pair.of(count >= limit, count);
} catch (Exception e) {
log.error(e.toString());
}
return Pair.of(false, 0);
}
private void setAccessRecord(final String key, final AccessLimiter accessLimiter) {
final ValueOperations<String, Object> valueOps = redisTemplate.opsForValue();
if (valueOps.get(key) == null) {
valueOps.set(key, 1, getTtl(accessLimiter), TimeUnit.MINUTES);
return;
}
valueOps.increment(key);
}
private long getTtl(final AccessLimiter accessLimiter) {
if (!accessLimiter.isAutoReset()) {
return accessLimiter.ttlHours() * 60L;
}
final LocalDateTime current = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
return ChronoUnit.MINUTES.between(current, current.plusDays(1).with(LocalTime.MIDNIGHT));
}
}
접속 허용 초과 시 throw할 ExceededRestrictException의 정의. RuntimeException을 상속한다.
// ExceededRestrictException.java
import static com.lgensol.battery.springrestfulapi.blifeCareApi.error.ErrorCode.FORBIDDEN_ERROR;
import lombok.Getter;
@Getter
public class ExceededRestrictException extends RuntimeException {
private final ErrorCode errorCode;
private final int maxLimitCount;
public ExceededRestrictException(final int maxLimitCount) {
this.errorCode = FORBIDDEN_ERROR;
this.maxLimitCount = maxLimitCount;
}
}
4. Controller 적용
접속 제한을 걸고 싶은 Controller 메서드에 아래와 같이 @AccessLimiter Annotation을 추가하면 된다.
// InquiryController.java
@AccessLimiter(isAutoReset = true)
@PostMapping("/api/guest/renewal/inquiry")
public RestResponseNoData renewalInquiry(@RequestBody @Validated RenewalInquiryDto param) {
return RestResponseNoData.of(inquiryService.renewalInquiry(param));
}