카테고리 없음

Redis /재난 대응 프로젝트 Redis 설계 및 활용 정리

creator7087 2025. 6. 7. 16:56

✅ 1. Redis 정의

  • Redis(REmote DIctionary Server)는 인메모리 기반의 Key-Value 스토어
  • 빠른 읽기/쓰기 성능
  • 다양한 데이터 구조 지원: String, Hash, List, Set, Sorted Set, Streams, HyperLogLog, Bitmaps, Geospatial

✅ 2. 사용 목적

목적 설명
캐시(Cache) 자주 조회되는 데이터를 메모리에 저장하여 DB 부하 감소
세션 관리 사용자 세션 정보를 Redis에 저장하여 빠른 인증 처리
분산 락 Redisson 등을 활용하여 분산 환경에서의 락 처리
실시간 순위/통계 Sorted Set 등을 활용하여 실시간 랭킹 시스템 구현
메시지 브로커 Pub/Sub 또는 Streams를 활용한 이벤트 메시징 처리
지리정보 처리 GEO 명령어로 위치 기반 검색 가능
 

✅ 3. Pub/Sub vs Streams vs Sorted Set 비교

항목 Pub/SubRedis  Streams Sorted Set (ZSet)
개념 발행/구독 모델 로그 기반 메시지 큐 우선순위 기반의 정렬된 컬렉션
데이터 유지 ❌ 유지되지 않음 (실시간) ✅ 메시지 보존됨 ✅ 데이터 보존
소비 모델 실시간 브로드캐스트 소비자 그룹 기반 소비 (Kafka 유사) 데이터 조회
재처리 ❌ 불가 ✅ 오프셋 기반 재처리 가능 ✅ 항목 재조회 가능
사용 목적 실시간 알림, 단순 브로드캐스트 안정적인 메시지 처리 (로그 수집, 비동기 처리 등) 랭킹 시스템, 스코어 기반 정렬 등
적합한 예시 채팅방 메시지 전파, 알림 주문 처리 큐, 로그 저장 실시간 인기 검색어, 게임 점수 보드
 

✅ 4. 선택 기준 및 설정 방식 적합성

시나리오추천  데이터 구조 설정 시 고려할 점
실시간 알림 전파 Pub/Sub 메시지 유실 가능성, 소비자 수 적을 때 적합
비동기 로그 수집 / 안정적 이벤트 처리 Streams 메시지 저장 및 소비 그룹, 오프셋 관리 필요
랭킹 / 인기 검색어 집계 Sorted Set (ZSet) 정렬 기준 스코어 지정, TTL 설정 고려
단순 캐싱 String / Hash TTL (만료시간), evict 정책 고려
사용자 세션 저장 Hash / String 세션 만료 정책과 함께 사용
분산 락 Redisson 기반의 SET NX / Lua Script 락 해제 시점, 클럭 동기화 주의
 

✅ 요약

  • Pub/Sub: 실시간 전파 → "속도 우선, 신뢰도 낮음"
  • Streams: 로그/이벤트 큐 → "속도 + 안정성, 소비자 그룹 처리"
  • Sorted Set: 정렬된 값 저장/집계 → "우선순위 기반 데이터 처리"

✅ 참고 공식 문서

1. 주요 요구사항 및 사용 목적

기능 사용  목적 Redis  자료구조 설명 및 역할
재난 이벤트 기록 및 큐 처리 재난 발생 로그 저장, 장애 복구, 재처리 Streams 이벤트 순서 보장, 다중 소비자 그룹 지원, 장애 복구에 유리
사용자 위치 정보 저장 (시/구 단위) 지역 단위 위치 정보 조회 및 관리 Hash/String 빠른 조회 및 갱신, user:location:<userId> 형태로 저장
사용자 위도/경도 좌표 저장 및 반경 검색 특정 반경 내 사용자 또는 대피소 탐색 Geospatial GEOADD, GEORADIUS 명령어 활용, 반경 탐색 지원
로그인 Refresh Token 관리 안전한 토큰 저장, 검증 및 만료 처리 String (Key-Value) refreshToken:<token> 키로 사용자 ID 저장, TTL 적용 자동 만료
실시간 알림 → Kafka 사용 (Redis Pub/Sub는 사용하지 않음) N/A Kafka로 알림 처리

2. Redis 네임스페이스 및 키 설계 예

데이터 타입   예시설명
Streams disaster:events 재난 이벤트 로그 스트림
Hash/String location:<userId> 사용자 위치 시/구 정보 저장
Geospatial location(위도,경도):users 사용자 좌표 저장 및 반경 검색
String (Refresh Token) refreshToken:<token> Refresh Token 저장 및 만료 관리

3. 실제 코드 (서비스 분리 필요)

더보기

1. RedisConfig (기본 RedisTemplate 설정)

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

2. RefreshTokenRepository (로그인 토큰 관리)

@Repository
@RequiredArgsConstructor
public class RefreshTokenRepository {

    private final RedisTemplate<String, String> redisTemplate;
    private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(7);

    public void saveToken(String userId, String refreshToken) {
        redisTemplate.opsForValue().set(userId, refreshToken, REFRESH_TOKEN_TTL);
    }

    public String getToken(String userId) {
        return redisTemplate.opsForValue().get(userId);
    }

    public void deleteToken(String userId) {
        redisTemplate.delete(userId);
    }
}

3. LocationRepository (사용자 위치 관리 — Geo 저장 + TTL)

@Repository
@RequiredArgsConstructor
public class LocationRepository {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String LOCATION_KEY = "user:locations";

    public void saveCoordinates(String userId, double latitude, double longitude) {
        redisTemplate.opsForGeo().add(LOCATION_KEY, new Point(longitude, latitude), userId);
        String ttlKey = "location:ttl:" + userId;
        redisTemplate.opsForValue().set(ttlKey, "1", Duration.ofMinutes(1));
    }

    public Point getCoordinates(String userId) {
        List<Point> positions = redisTemplate.opsForGeo().position(LOCATION_KEY, userId);
        if (positions == null || positions.isEmpty()) return null;
        return positions.get(0);
    }

    public List<String> findUsersWithinRadius(double latitude, double longitude, double radiusMeters) {
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo()
                .radius(LOCATION_KEY, new Circle(new Point(longitude, latitude),
                        new Distance(radiusMeters / 1000.0, Metrics.KILOMETERS)));

        if (results == null) return Collections.emptyList();

        return results.getContent().stream()
                .map(r -> r.getContent().getName())
                .toList();
    }
}


4. RegionRepository (행정구역 기반 사용자 관리 — List + TTL)

@Repository
@RequiredArgsConstructor
public class RegionRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public void saveRegion(String si, String gu, String userId) {
        String key = si + " " + gu;
        redisTemplate.opsForList().rightPush(key, userId);
        redisTemplate.expire(key, Duration.ofMinutes(5));
    }

    public List<String> getUsersByRegion(String regionKey) {
        return redisTemplate.opsForList().range(regionKey, 0, -1);
    }
}


5. DisasterEventStreamRepository (재난 이벤트 스트림 관리)

@Repository
@RequiredArgsConstructor
public class DisasterEventStreamRepository {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String STREAM_KEY = "disaster:events";

    public String addEvent(Map<String, String> eventData) {
        RecordId recordId = redisTemplate.opsForStream()
                .add(StreamRecords.newRecord().in(STREAM_KEY).ofMap(eventData));
        return recordId.getValue();
    }

    public void createConsumerGroup(String groupName) {
        try {
            redisTemplate.opsForStream()
                    .createGroup(STREAM_KEY, ReadOffset.lastConsumed(), groupName);
        } catch (Exception e) {
            // 그룹이 이미 존재할 경우 무시
        }
    }

    public List<MapRecord<String, String, String>> readEvents(String groupName, String consumerName, int count, Duration blockMillis) {
        return redisTemplate.opsForStream()
                .read(Consumer.from(groupName, consumerName),
                        StreamReadOptions.empty().count(count).block(blockMillis),
                        StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed()));
    }

    public void acknowledge(String groupName, List<String> recordIds) {
        redisTemplate.opsForStream().acknowledge(STREAM_KEY, groupName, recordIds.toArray(new String[0]));
    }
}

6. RedisService (통합 서비스 예시)

@Service
@RequiredArgsConstructor
public class RedisService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final LocationRepository locationRepository;
    private final RegionRepository regionRepository;
    private final DisasterEventStreamRepository disasterEventStreamRepository;

    // --- 로그인 토큰 관리 ---
    public void saveRefreshToken(String userId, String refreshToken) {
        refreshTokenRepository.saveToken(userId, refreshToken);
    }
    public String getRefreshToken(String userId) {
        return refreshTokenRepository.getToken(userId);
    }
    public void deleteRefreshToken(String userId) {
        refreshTokenRepository.deleteToken(userId);
    }

    // --- 위치정보 관리 ---
    public void saveUserCoordinates(String userId, double latitude, double longitude) {
        locationRepository.saveCoordinates(userId, latitude, longitude);
    }
    public Point getUserCoordinates(String userId) {
        return locationRepository.getCoordinates(userId);
    }
    public List<String> findUsersNearLocation(double latitude, double longitude, double radiusMeters) {
        return locationRepository.findUsersWithinRadius(latitude, longitude, radiusMeters);
    }

    // --- 행정구역 기반 사용자 관리 ---
    public void saveUserRegion(String si, String gu, String userId) {
        regionRepository.saveRegion(si, gu, userId);
    }
    public List<String> getUsersByRegion(String region) {
        return regionRepository.getUsersByRegion(region);
    }

    // --- 재난 이벤트 스트림 관리 ---
    public String publishDisasterEvent(Map<String, String> eventData) {
        return disasterEventStreamRepository.addEvent(eventData);
    }
    public void createDisasterConsumerGroup(String groupName) {
        disasterEventStreamRepository.createConsumerGroup(groupName);
    }
    public List<MapRecord<String, String, String>> consumeDisasterEvents(String groupName, String consumerName, int count, Duration block) {
        return disasterEventStreamRepository.readEvents(groupName, consumerName, count, block);
    }
    public void acknowledgeDisasterEvents(String groupName, List<String> recordIds) {
        disasterEventStreamRepository.acknowledge(groupName, recordIds);
    }
}