Blog / Java/Kotlin / AOP와 Redis를 활용한 접속 제한 설정 기능 구현

AOP와 Redis를 활용한 접속 제한 설정 기능 구현

1. Embedded Redis 설정

Local, DEV 환경에서 사용할 내장 Redis 설정. STAGE/PROD 환경에서는 ElastiCache를 사용한다.

내장 Redis 라이브러리 의존성을 gradle 빌드 설정 파일에 추가한다.
현재로서는 codemonstur의 라이브러리가 Apple Silicon CPU도 지원한다.

groovy
// 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 프로퍼티 값을 설정한다.

yaml
# application.yaml
spring:
  redis:
    host: 127.0.0.1  # Redis Server가 localhost라면 default값이기 때문에 생략 가능
    port: 6380

Java Config로 Redis Server, Redis Template 등의 Bean 생성 설정을 추가한다.
RedisConnectionFactory Bean 생성 시 환경별로 생성하는 부분이 다른 것에 주의.

java
// 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하는 구조가 아니라면, 사실 효용성은 없다)

java
// 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을 정의하고 옵션 항목들도 정의한다.

java
// 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인 RestrictMethodHttpServletRequest 객체를 매개변수로 받아 keyPrefix를 리턴하는 메서드를 포함한다.

java
// 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로 설정하고 로직을 구현한다.

java
// 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을 상속한다.

java
// 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을 추가하면 된다.

java
// InquiryController.java
@AccessLimiter(isAutoReset = true)
@PostMapping("/api/guest/renewal/inquiry")
public RestResponseNoData renewalInquiry(@RequestBody @Validated RenewalInquiryDto param) {
    return RestResponseNoData.of(inquiryService.renewalInquiry(param));
}
Written by
author
풍우래기

여행을 좋아하는 집돌이 개발자입니다.

블로그에 새로운 글이 발행되었습니다.