Redis GEO Member 별로 TTL 설정하기(feat. Redis Keyspace Notifications)

sinryuji·2025년 5월 18일
post-thumbnail

Redis GEO와 TTL 설정 불가 문제

Redis Geospatial은 Redis에서 위경도 데이터를 다루는 자료구조이다. 위경도 데이터를 GeoHash로 인코딩하여 Sorted Set 형태로 저장한다.

GeoHash 값을 토대로 거리 계산 등의 연산을 간편하게 할 수 있도록 해주는 장점이 있어 위치 데이터를 다룰 때 유용하게 쓰이는 자료구조이다.

허나 Set 형태로 데이터를 저장하기에 Set이 가지고 있는 Member 별로 TTL을 설정 할 수 없는 단점 또한 가지고 있다.

Set을 사용한다면 그냥 key를 나누어 key 별로 TTL을 설정하거나, score를 timestamp로 활용하여 스케쥴링을 돌린다거나 하는 형태로 처리가 가능하지만, Redis GEO는 하나의 key에 value가 식별자, score에 GeoHash 값으로 활용되는 자료구조라 위 방법들로 처리하기가 애매하다.

그래서 이번 글에서는 Redis Keyspace NotificationsSpring Event를 활용하여 이벤트 방식으로 Redis GEO Member 별로 TTL을 처리하는 방법을 설명할 것이다.

Redis Keyspace Notifications란?

Redis Keyspace Notificaitons는 Redis의 특정 키에 대한 변경이나 만료 등이 발생했을때 알람을 수신할 수 있는 기능이다.

CPU 리소스를 일부 사용하기에 기본적으로는 비활성화 되어있는 기능이다. 그래서 별도로 활성화를 해줘야하는데 필자의 경우 Docker로 redis를 사용중이었고 docker-compose.yml 파일에서 다음과 같이 환경 변수를 통해 활성화를 해주었다. Ex는 키 만료 이벤트를 받겠다는 인자값이다.

environment:
  REDIS_ARGS: "--requirepass root --notify-keyspace-events Ex"

혹은 redis.conf에서 다음 옵션 값을 통해 활성화 해줄 수도 있다고 한다.

notify-keyspace-events ""

위와 같이 빈 문자열로 두면 비활성화를 하는 것이고, 역시나 Ex를 적으면 키 만료 이벤트를 수신한다.

Redis GEO Memeber 별로 TTL 처리하기

Redis Keyspace Notifications에 대한 설명을 들었으면 어떻게 처리할 지 대충 감이 오신 분들도 있을 것이다.

전반적인 처리 과정은 대략 다음과 같다.

  1. Redis GEO Member 별로 별도의 Key를 만들어서 그 Key에 TTL을 걸어둔다.
  2. TTL이 다 되어 Key가 만료되면 Redis Keyspace Notifications를 통해 이벤트를 수신받는다.
  3. 만료된 Key에서 Redis GEO Member의 식별자를 추출해 Spring Event를 통해 Redis GEO의 Member를 삭제한다.

이제 구현 과정을 알아보자.

1. Redis GEO Member 별로 TTL 걸기

@Service
@RequiredArgsConstructor
public class RedisRiderLocationService implements RiderLocationService {

    private final RedisTemplate<String, String> riderLocationTemplate;

    @Override
    public void saveRiderLocation(String riderId, Double longitude, Double latitude,
        Long timestamp) {
        riderLocationTemplate.opsForGeo()
            .add(RedisKey.RIDER_LOCATION_KEY, new Point(longitude, latitude), riderId);
    }
}

기존의 Rider의 위치를 Redis GEO에 저장하는 함수이다. 위치를 GEO에 저장함과 동시에 식별자(riderId)를 가지고 별도의 Key를 만들고 TTL을 설정 해 줄 것이다.

    private void setRiderLocationTtl(String riderId) {
        String key = RedisKey.RIDER_LOCATION_TTL_KEY + ":" + riderId;

        riderLocationTemplate.opsForSet().add(key, riderId);
        riderLocationTemplate.expire(key, 10, TimeUnit.MINUTES);
    }

riderId 별로 별도의 Set을 만들고 TTL을 지정해주는 함수이다. 식별자(riderId)를 :로 구분할 수 있도록 하여 Key에 포함 시켜주었다. 만료 이벤트를 받았을 때 활용 해 줄 것이다.

    @Override
    public void saveRiderLocation(String riderId, Double longitude, Double latitude,
        Long timestamp) {
        riderLocationTemplate.opsForGeo()
            .add(RedisKey.RIDER_LOCATION_KEY, new Point(longitude, latitude), riderId);
        setRiderLocationTtl(riderId);
    }

위와 같이 Redis GEO에 add함과 동시에 TTL을 설정해주었다. 이제 다음과 같이 Redie GEO에 member가 추가됨과 동시에 별도의 TTL용 Set도 저장한다.

2. Redis Keyspace Notifications로 만료 알람 수신 받기

@Configuration
public class RedisConfig {

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
        RedisConnectionFactory factory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);
        return container;
    }
}

우선 위와 같이 RedisMessageListenerContainer를 Bean으로 선언해준다. 추후 Keyspace Notifications 이벤트를 수신할 리스너를 구현할 때 필요하다.

@Component
public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener {

    public RedisKeyExpiredListener(
        RedisMessageListenerContainer listenerContainer,
        ApplicationEventPublisher applicationEventPublisher
    ) {
        super(listenerContainer);
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {

    }
}

KeyExpirationEventMessageListener를 상속하는 리스너를 하나 만들어준다. KeyExpirationEventMessageListener는 이름 그대로 Key 만료 이벤트를 수신할 수 있는 리스너이다.

앞서 Bean으로 등록한 RedisMessageListenerContainer를 생성자로 주입 받아 super()를 통해 부모 클래스에 전달한다. 이제 오버라이드한 onMessage() 함수를 통해 Key 만료 이벤트를 전달 받아 처리할 수 있다.

이제 앞서 설정했던 TTL용 Set이 만료되면 이벤트를 수신 할 것이다.

3. Spring Event로 Redis GEO Memeber 삭제하기

앞서 구현했던 리스너를 디벨롭해보자. 우선 Redis GEO Memeber 삭제를 Sprign Event로 처리하기로 했으니 ApplicationEventPublisher를 주입 받아야 한다.

@Component
public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener {

    private final ApplicationEventPublisher applicationEventPublisher;
}

KeyExpirationEventMessageListener를 통해 전달되는 메세지의 값은 만료된 Key의 이름이다. 그리고 우리는 앞서 별도의 TTL용 Set의 Key에 식별자(riderId)를 포함시켜 놓았다. TTL이 만료되어 이벤트가 온다면 그 Key에서 식별자를 파싱해 활용할 수 있다. 다음과 같이 말이다.

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String messageToStr = message.toString();

        if (messageToStr.startsWith(RedisKey.RIDER_LOCATION_TTL_KEY)) {
            String riderId = messageToStr.split(":")[3];
            applicationEventPublisher.publishEvent(new RedisGeoExpiredEvent(riderId));
        }

    }

만약에 수신 받은 Key가 앞서 설정한 TTL의 Key로 시작한다면 :를 기준으로 split 했을때 마지막이 식별(riderId)가 될 것이다. 그러면 그 riderId를 가지고 역시나 Event 방식으로 loose coupling하게 처리하기 위해 Sprign Event를 publishing 해준다.

@Component
@RequiredArgsConstructor
public class RedisGeoEventHandler {

    private final RedisRiderLocationService redisRiderLocationService;

    @EventListener
    public void GeoKeyExpiredEvent(RedisGeoExpiredEvent geoKeyExpiredEvent) {
        redisRiderLocationService.removeRiderLocation(geoKeyExpiredEvent.getRiderId());
    }
}

publishing한 Spring Event의 핸들러이다. 이벤트에서 riderId를 가져와 Redis GEO에서 라이더의 위치를 삭제하는 함수를 호출해준다.

마무리

Redis GEO의 Member를 Redis Keyspace Notifications와 Spring Event를 통해 TTL 처리하는 방법을 알아보았다. 좀 더 완벽하게 구현을 한다면 Redis Keyspace Notifications 이벤트가 유실되었을때에 대한 처리(스케쥴링으로 TTL용 Set이 없는 GEO memeber를 지운다던가) 또한 필요할 것이다.

이 글을 보신 분들 중 riderId를 Key에 활용해 TTL용 Set을 만든 것 처럼, riderId 별로 모두 별도의 Key로 GEO를 저장해놓고 TTL을 설정하면 되는 거 아니야? 라는 생각을 하신 분들이 있을 수 있다. 하지만 이 방법은 Redis GEO의 핵심 기능인 member 간 거리 계산, 반경 내 memeber 검색 등의 기능을 모두 사용할 수 없으므로 Redis GEO를 쓰는 의미가 없는 방법이다. Redis GEO는 GeoHash를 Sorted Set에 저장해 놓고 이를 기반으로 위와 같은 기능들을 지원하기 때문이다. 이 기능들을 안 쓸거면 그냥 GEO를 쓸 것도 없이 Set에 위경도 값을 String으로 넣어놓으면 된다.

그래서 이렇게 별도의 만료 처리를 구현해야만 한다. 위경도 데이터를 처리할 때 매우 유용한 자료구조이지만 이런 점은 약간 번거롭고 아쉬운 것 같다.

참고

https://670811.tistory.com/89

profile
응애 개발자입니다.

0개의 댓글