두레 Redis 도입기 - 초대 링크 생성 기능을 구현하며

BDD·2024년 2월 22일
0

서론

해당 글은 보름🌕 이 작성했습니다.

이번 글에서는 두레 서비스에 Redis를 도입하게 된 배경과 그 과정을 기술해보도록 하겠습니다.

Redis 도입 배경

제가 Redis를 도입해야겠다고 느끼게 된 이유는, 제가 팀원 초대 코드를 생성하는 파트를 맡았기 때문이었습니다.

두레 서비스에는 팀이 있고, 팀에 팀원을 초대하는 방법으로 유효기간이 1일인 팀원 초대 코드를 생성하고, 그것을 팀원들에게 공유하여 팀에 가입하는 방법이 채택되었습니다.

초기 개발 방향

유효성을 가진 초대 코드를 배정 받자마자 바로 떠오른 생각은 Redis 사용이었으나, 현재 다른 도메인에 Redis가 사용되고 있지 않았기에 바로 Redis를 도입하기보다는 다른 방향은 없나 고민했었습니다.

떠오른 생각은 날짜teamId로 암호화하여 초대 코드를 생성한 뒤, 요청받은 초대 코드를 복호화하여 날짜가 유효한지 확인하는 방법이 있겠다 싶었습니다.

하지만, 위 방식에는 문제점이 있었는데

  1. 암호화, 복호화 하는 과정이 불필요하게 복잡하고 보안에 취약할 수 있다.
  2. 24시간 내에 생성된 유효한 코드가 있는지 확인하기 위해서는 코드 데이터를 저장해야 한다.
  3. 저장한 코드데이터는 1일이 지나면 무의미한 자원이 되기에 유사 배치 시스템을 통해서 주기적으로 지워줘야 한다.

따라서 해당 문제점들에 대해 디스커션을 통해 팀원들과 논의하였습니다.

Redis 사용 여부에 대해 · BDD-CLUB 01-doo-re-back · Discussion #34

Redis 도입

논의 결과, Redis를 도입하는 것 자체는 문제점보다는 이점들이 더 많았습니다.

  1. Redis 도입은 간단하고 가볍기에 리소스가 적게 든다.
  2. 추후 Redis를 통해 빈번한 로그인 만료로 인한 사용자의 이용 흐름을 방해하지 않도록 개선하는 등 다른 여러 방향으로 Redis를 사용할 여지도 충분하다.
  3. 짧은 유효기간을 가지고 있고 빈번하게 접근되는 데이터는 디스크에 저장하기 보다는 메모리에 저장하는게 효율적이다.

따라서, 두레 서비스에는 Redis를 도입하기로 하였습니다.

스프링 부트와 Redis를 사용하여 초대 코드 기능 구현

그럼, 스프링부트에서 초대 코드를 생성하는 기능을 Redis를 사용해서 구현한 과정과 결과물을 설명하도록 하겠습니다.

설정

의존성 주입

dependencies {
    ...
    implementation("org.springframework.boot:spring-boot-starter-data-redis")

build.gradle에 spring data redis 의존성을 추가해줍니다.

application.yml 설정

spring:
  redis:
    host: localhost
    port: 6379

그다음은 application.yml에 redis host와 port를 설정합니다.

참고로 redis localhost:6379는 기본값이기에 로컬 환경에서는 따로 설정해주지 않아도 동작합니다. 하지만 보통은 운영 or 개발 서버의 redisd의 host, port 번호가 다르기 때문에 값을 설정해주는 편입니다.

RedisConfig

Redis Client로 Lettuce 등록

@Configuration
public class RedisConfig {

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

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }
}

Java 의 Redis Client 는 크게 JedisLettuce 두 가지가 있지만, Spring Boot 2.0 부터 Jedis 가 기본 클라이언트에서 deprecated 되고 Lettuce가 탑재되었다고 합니다.

따라서 redisConnectionFactory의 빈으로 LettuceConnectionFactory를 생성하면 되고, @Value 어노테이션을 통해 application.yml에서 설정한 값을 주입받습니다.

RedisTemplate

@Configuration
public class RedisConfig {

    ...

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

RedisTemplate을 정의하여 Redis에 원하는 값을 넣도록 설정할 수 있습니다. RedisTemplate<String, Object>으로 템플릿을 선언하여 키가 문자열 유형이고 값에 객체를 넣을 수 있도록 구성했습니다. 이는 나중에 Entity와 같은 것을 value로 설정하기 위함입니다.

각각을 더 상세히 설명해보자면,

  • redisTemplate.setConnectionFactory(redisConnectionFactory());
    • 앞서 빈으로 등록한 redisConnectionFactory 설정
  • redisTemplate.setKeySerializer(new StringRedisSerializer());
    • Redis의 키가 문자열로 직렬화되도록 설정
  • redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
    • 객체를 JSON 형식으로 직렬화 및 역직렬화하기 위해 설정

RedisUtil 클래스 작성

@Component
@RequiredArgsConstructor
public class RedisUtil {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
		...

Redis를 코드에서 더 사용하기 쉽고, 중복을 줄이고자 Util 클래스를 작성했습니다.

objectMapper는 객체를 문자열로 변환하기 위해 사용하였습니다.

public <T> Optional<T> getData(final String key, final Class<T> classType) {
    final ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
    final String value = valueOperations.get(key);
    if (value == null) {
        return Optional.empty();
    }
    try {
        return Optional.ofNullable(objectMapper.readValue(value, classType));
    } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
}

getData() 메서드는 Redis에 key에 해당하는 value가 있는지 확인하고, 값이 없다면 Optional을 반환하는 메서드 입니다.

public <T> void setDataExpire(final String key, T value, final long durationMillis) {
    final ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
    final Duration expireDuration = Duration.ofMillis(durationMillis);
    try {
        valueOperations.set(key, objectMapper.writeValueAsString(value), expireDuration);
    } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
}

setDataExpire() 메서드는 key와 value를 Redis에 저장하고 durationMillis만큼의 TTL을 설정해주는 메서드 입니다.

public void flushAll() {
    requireNonNull(redisTemplate.getConnectionFactory()).getConnection().serverCommands();
}

flushAll() 메서드는 redis에 있는 값을 전부 비우는 메서드 입니다. 주로 테스트 코드에서 @AfterAll에 작성해두어 테스트 후 남아있는 데이터를 지우는 용도로 호출할 것 같습니다.

초대 코드 생성 API

@PostMapping("/{teamId}/invite-code")
public ResponseEntity<TeamInviteCodeResponse> generateTeamInviteCode(
        @PathVariable final Long teamId
) {
    final TeamInviteCodeResponse teamInviteCodeResponse = teamCommandService.generateTeamInviteCode(teamId);
    return ResponseEntity.ok(teamInviteCodeResponse);
}

아직 권한 처리가 되진 않았지만, 초대 코드 생성 API는 팀 관리자용 API 입니다.

따라서 관리자가 초대 코드를 생성하고 그것을 조회할 수 있는 API를 작성했습니다.

@Service
@Transactional
@RequiredArgsConstructor
public class TeamCommandService {

    ...
    private final RedisUtil redisUtil;

    private static final String INVITE_LINK_PREFIX = "teamId=%d";

		public TeamInviteCodeResponse generateTeamInviteCode(final Long teamId) {
		    validateExistTeam(teamId);
		
		    final Optional<String> link = redisUtil.getData(INVITE_LINK_PREFIX.formatted(teamId), String.class);
		    if (link.isEmpty()) {
		        final String randomCode = RandomUtil.generateRandomCode('0', 'z', 10);
		        redisUtil.setDataExpire(INVITE_LINK_PREFIX.formatted(teamId), randomCode, RedisUtil.toTomorrow());
		        return new TeamInviteCodeResponse(randomCode);
		    }
		    return new TeamInviteCodeResponse(link.get());
		}

서비스 코드에서는 RedisUtil을 주입받고 Redis에 저장된 teamId에 해당하는 값이 있는지 확인합니다.

만약 초대 코드가 이미 생성되어 redis에 해당하는 값이 있다면, 해당 코드를 반환합니다.

그렇지 않고 생성된 초대 코드가 없다면, 랜덤한 문자열을 만들어 value로 지정하고, 유효기간 1일의 TTL을 설정합니다.

초대 코드를 통해 팀 가입 API

팀원은 초대 코드를 통해 팀에 가입해야 합니다. 따라서 초대 코드를 통해서만 팀에 가입해야 하고, 이를 위한 API를 개발했습니다.

@PostMapping("/{teamId}/join")
public ResponseEntity<Void> joinTeam(
        @PathVariable final Long teamId,
        @Valid @RequestBody final TeamInviteCodeRequest request
) {
    teamCommandService.joinTeam(teamId, request);
    return ResponseEntity.status(HttpStatus.CREATED).build();
}

POST api를 통해서 teamId와 초대 코드를 요청받도록 합니다.

public void joinTeam(final Long teamId, final TeamInviteCodeRequest request) {
    validateExistTeam(teamId);

    Optional<String> link = redisUtil.getData(INVITE_LINK_PREFIX.formatted(teamId), String.class);
    if (link.isPresent()) {
        validateMatchLink(link.get(), request.code());
        // TODO: 2/14/24 권한 관련 작업이 추가되면 팀원으로 회원 추가, 이미 가입된 팀원이라면 예외 처리.
    }
		throw new TeamException(EXPIRED_LINK);
}

서비스 코드에서는 teamId의 key에 대응하는 value가 redis에 있는지 확인합니다.

만약 value가 존재한다면, 요청받은 코드와 동일한지 확인하고, 동일하지 않다면 예외를 반환합니다.

만약 value가 존재하지 않는다면, TTL이 만료되었거나 생성된 적 없는 링크이므로 이에 해당하는 예외를 반환합니다.


끝으로

이번 글에 해당하는 내용의 실제 코드는 아래 PR에서 보실 수 있습니다.

https://github.com/BDD-CLUB/01-doo-re-back/pull/85

profile
부산 개발 동아리

0개의 댓글