본문 바로가기
프로젝트/FitTrip

[커뮤니티 서비스] Redis를 활용한 초대코드 구현

by 진꿈청 2024. 7. 5.

커뮤니티 서비스

커뮤니티 서비스는 우리 FitTrip 프로젝트에서 제일 중요한 뼈대가 되는 서비스이다.

 

  1. 서버가 있고 채널이 있어야 채팅/음성/화상이 가능하다.
  2. DM이 있어야 채팅이 가능하다.
  3. 사용자는 커뮤니티 서비스로부터 수많은 오픈서버에 접속하여 다양한 챌린지가 가능하다.
  4. 각종 이벤트 처리를 담당한다.
  5. 친구가 아니여도 오픈서버나 서버에서 유저는 서로의 존재를 알 수 있다.
  6. 프론트가 수많은 정보를 커뮤니티 서비스로부터 받아 처리한다.

이외에도 수많은 CRUD 기능들이 존재하며 사용자에게 FitTrip 환경을 제공한다.

 

프론트에게 수많은 데이터를 전송하기에 최대한 많은 정보를 전달하는것과

관련된 예시로 서버 READ API 예시가 있다.

 

서버 READ API 예시

  • 해당 채널의 초기 채팅 정보
  • 서버에 속해있는 유저 정보
  • 유저 ON/OFF 정보
  • 유저가 접속한 시점 서버에서의 유저 채널 위치 정보
  • 카테고리/채널 정보
  • 서버 기본 정보(프로필, 이름 등)

유저 READ API 예시

  • 유저가 속해있는 서버 목록의 정보(프로필, 이름)
  • 유저가 속해있는 DM 목록의 정보(프로필, 이름)

 

위 안에서도 OpenFeign을 통해 다른 서비스들로부터 정보를 받아온다.

이 밖에도 수많은 데이터를 전달하는 API가 존재한다.

 

이처럼 커뮤니티 서비스는 많은 기능을 내포하고 있다.

 

 

커뮤니티 서비스의 서버

그 중에서 서버는 오픈서버와 개인서버로 나눌 수 있다.

  • 오픈서버
    • 초대코드로도 서버 참여가 가능하며, 누구나 일반 검색으로도 검색을 통한 접속이 가능하다.
  • 개인서버
    • 초대코드를 통해서만 서버 참여 가능하다.

 

초대코드

 

초대코드는 단어 그대로 서버에 접속할 수 있는 초대코드를 말한다.

커뮤니티 서비스가 무작위로 생성된 8자리의 초대코드를 사용자에게 전달한다.

해당 초대코드를 이용한 프론트단의 URL 생성으로 사용자는 서버에 접속이 가능하다.

 

 

초대코드에 Redis를 사용 이유

 

 

 

초기 커뮤니티 서비스는 MariaDB를 이용하여 각 서버마다의 고유한 초대코드를 갖도록 설계하였다.

하지만, 이렇게 설계하였을 경우 만약 초대코드가 유출이 된다면 개인 서버는 영원히 공개서버가 되어버린다.

 

따라서, MariaDB를 고집한다면 해당 문제를 해결하려면 아래 방법으로 해결할 수 있을 것이다.

  • Spring Scheduler/Spring Batch를 사용하여 일정 주기마다 초대코드를 변경
  • MariaDB의 Event Scheduler를 등록하여 일정 주기마다 초대코드를 변경

위 방법은 확실한 해결책은 될 수 있다. 하지만, 스프링 스케쥴러/스프링 배치의 경우 배보다 배꼽이 더 크다는 생각이 들었다.

 

MariaDB의 Event Scheduler의 경우도 좋은 방법이 될 수 있겠지만 서버가 굉장히 많아진다면?

RDBMS에서 처리해야 하는 작업이 굉장히 많아질 것이다. -> 서버마다 일정 주기에 따른 새로운 초대코드 및 UPDATE 쿼리

 

 

따라서, 나는 초대코드를 저장하는 부분을 Redis를 활용하기로 결정하였다.

Rediskey, value 쌍의 데이터의 만료기간(TTL, expire date, expiry 등)을 설정할 수 있다.

또한, Redis는 메모리 기반의 데이터 저장소이므로 굉장히 빠른 속도로 데이터를 접근할 수 있다는 장점도 가질 수 있다.

 

FitTrip 서버는 MSA을 기반으로 하는 프로젝트여서 커뮤니티 서비스 전용의 Redis가 필요하긴 하지만,

만약 커뮤니티 서비스에서 특정 데이터를 또 캐싱 처리를 하게 된다면 해당 Redis를 사용하면 되므로

의미가 있는 작업이라는 생각이 들었다.

 

정리

 

초대코드 작업에 Redis를 사용하여 얻을 수 있는 이점

  1. 하나의 서버는 영원히 유지되는 초대코드를 갖지 않음
  2. 메모리 기반으로 하는 Redis를 사용하므로 빠른 데이터 접근
  3. 추후 커뮤니티 서비스의 캐시 작업을 위해서 사용 가능

 

 

초대코드 구현

 

ServerCommandController

@RestController
@RequiredArgsConstructor
@RequestMapping("/server")
public class ServerCommandController {

    private final ServerCommandService serverCommandService;

    ...

    @GetMapping("/{serverId}/invitation")
    public DataResponseDto<Object> generateInvitationCode(@PathVariable("serverId") Long serverId){
        ServerInviteCodeResponse response = serverCommandService.generatedServerInviteCode(serverId);

        return DataResponseDto.of(response);
    }

    ...
}

 

ServerCommandControllerServerQueryController를 나눠서 작업하였다.

 

ServerCommandController(데이터를 생성/추가 하는 작업)

  • CREATE(@PostMapping)
  • UPDATE(@PatchMapping)
  • DELETE(@PostMapping)

ServerQueryController(데이터를 읽는 작업)

  • READ

그에따라 본래는 ServerCommandController에는 @GetMapping이 들어가면 안된다.

하지만, 초대코드를 받아오는 작업을 데이터를 생성하기도 하며 동시에 Body값으로 특별히 받는 것이 없다.

따라서, ServerCommandController에 추가하였다.

 

 

ServerCommandService

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ServerCommandService {
    private static final String INVITE_LINK_PREFIX = "serverId=%d";

    private final FileUploadService fileUploadService;
    private final RedisService redisService;

    private final UserQueryService userQueryService;
    private final ServerUserCommandService serverUserCommandService;
    private final ServerQueryService serverQueryService;
    private final CategoryCommandService categoryCommandService;
    private final ChannelCommandService channelCommandService;

    private final ServerRepository serverRepository;
    private final ServerUserRepository serverUserRepository;
    private final ChannelRepository channelRepository;
    private final CategoryRepository categoryRepository;

    private final KafkaEventPublisher kafkaEventPublisher;

    ...

    public ServerInviteCodeResponse generatedServerInviteCode(Long serverId) {
        validateExistServer(serverId);

        String key = INVITE_LINK_PREFIX.formatted(serverId);

        String value = redisService.getValues(key);

        if(value.equals("false")){
            final String randomCode = RandomUtil.generateRandomCode();
            redisService.setValues(key, randomCode, RedisService.toTomorrow());
            return ServerInviteCodeResponse.of(randomCode);
        }

        return ServerInviteCodeResponse.of(value);
    }

    ...
}

 

generatedServerInviteCode()

  • 인자로 전달받은 serverId를 통해 실제 존재하는 서버인지 검증한다.
  • 인자로 전달받은 serverId를 이용해 Redis key를 생성한다.
  • 해당 key를 사용해 레디스에 저장된 value를 불러온다.
  •  만약 valuefalse라면 초대코드가 존재하지 않는 것이므로
    • RandomUtil.generateRandomCode()를 활용하여 randomCode를 생성한다.
    • 생성한 randomCode를 사용하여 만료기간을 하루로 정하여 Redis에 저장한다
  • 만약 valuefalse가 아니라면 초대코드가 이미 존재하는 것이므로 그냥 반환해준다.

 

 

RedisService

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisService {

    private final RedisTemplate<String, Object> redisTemplate;

    public void setValues(String key, String value){
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, value);
    }

    public void setValues(String key, String value, Duration duration){
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, value, duration);
    }

    @Transactional(readOnly = true)
    public String getValues(String key){
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        return (values.get(key) == null) ? "false" : (String) values.get(key);
    }

    public void deleteValues(String key){
        redisTemplate.delete(key);
    }

    public void expireValues(String key, int timeout){
        redisTemplate.expire(key, timeout, TimeUnit.MILLISECONDS);
    }

    public void setHashOps(String key, Map<String, String> data){
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        values.putAll(key, data);
    }

    @Transactional(readOnly = true)
    public String getHashOps(String key, String hashKey){
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        return Boolean.TRUE.equals(values.hasKey(key, hashKey)) ? (String) redisTemplate.opsForHash().get(key, hashKey) : "";
    }

    public void deleteHashOps(String key, String hashKey){
        HashOperations<String, Object, Object> values = redisTemplate.opsForHash();
        values.delete(key, hashKey);
    }

    public boolean checkExistsValue(String value){
        return !value.equals("false");
    }

    public static Duration toTomorrow() {
        final LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
        final LocalDateTime tomorrow = now.plusDays(1);
        return Duration.between(now, tomorrow);
    }
}

 

RedisService는 개인 프로젝트에서 사용했던 RedisService 설정에 static methodtoTomorrow() 메소드를 추가했다.

 

 

 

ValueOperations 지원 메소드의 set은 Duration 형식으로 인자를 받기 때문에 toTomorrow() 메소드도 아래와 같이 설정했다.

 

public static Duration toTomorrow() {
        final LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
        final LocalDateTime tomorrow = now.plusDays(1);
        return Duration.between(now, tomorrow);
}

 

 

추가로, getValues() 메소드를 살펴보면

    @Transactional(readOnly = true)
    public String getValues(String key){
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        return (values.get(key) == null) ? "false" : (String) values.get(key);
    }

 

만약, 해당 key값이 존재하지 않으면 false를 반환하고 그렇지 않다면 key값에 해당하는 value를 반환하도록 설정했다.

 

 

RandomUtil

public class RandomUtil {
    private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    private static final int LENGTH = 8;
    private static SecureRandom random = new SecureRandom();

    public static String generateRandomCode() {
        StringBuilder builder = new StringBuilder(LENGTH);
        for (int i = 0; i < LENGTH; i++) {
            builder.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
        }
        return builder.toString();
    }
}

 

RandomUtil은 A - Z, a - z, 0 ~ 9를 적절히 조합하여 8자리의 초대코드를 생성한다.

 

SecureRandom

 

일반적인 Random은 함수는 이름처럼 무작위가 아니라 사실 의사 난수가 생성된다.

 

의사 난수

  • 난수처럼 보이기 위해 어떠한 알고리즘을 사용한 규칙적인 난수를 생성
  • 결론적으로, 진짜 난수가 아니라 규칙적으로 만들어진 난수처럼 보이는 값

따라서, Random 함수는 Seed값만 같으면 랜덤이 아니다.

 

또한, Random은 시스템 시간을 시드로 사용하거나 시드를 생성한다.

그렇기에 공격자가 시드가 생성된 시간을 알고 있으면 쉽게 재현이 가능하다.

 

하지만, SecureRandom은 다르다.

 

1. 시드 생성

SecureRandom은 OS에서 임의 데이터(키 입력 사이 시간 간격 등)를 가져와 이를 시드로 사용한다.

(대부분의 OS는 키 입력 사이 시간 간격등의 데이터를 수집하여 파일에 저장한다고 한다.)

(Linux / solaris의 경우 / dev /random 및 / dev / urandom)

 

2. 공격자 디코드

Random의 경우 2 ^ 48번만 시도하면 디코드가 가능하다. 이는 오늘날의 고급 CPU를 사용하면 쉽게 가능한 일이다.

하지만, SecureRandom은 2 ^ 128번의 시도가 필요하다. 이는 몇 년이 소모된다.

 

좀 더 자세한 설명

 

위의 이유들로 나는 SecureRandom을 활용하여 무작위 초대코드를 구현했다.

 

지금까지의 설명이 내가 커뮤니티 서비스에 Redis를 활용하여 초대코드를 구현한 이유과정이다.

 

 

마무리하며

원래는 설명한것처럼 MariaDB에 서버의 초대코드를 고정하려 하였지만,

초대코드가 공개된다면 모든 사용자가 접속이 가능하다는 문제가 있어 다른 방법을 모색했고

 

Redis를 활용하였다.

 

커뮤니티 서비스의 개인서버는 초대코드로만 서버 참여가 가능하고

오픈서버는 초대코드 및 검색으로 서버 참여가 가능하다.

 

만약, 개인서버의 초대코드가 존재하면 해당 서버는 그냥 오픈서버와 다름이 없다.

 

 

Redis를 적재적소에 잘 활용했다는 생각이다.