Light Blue Pointer
본문 바로가기
Developing/Journal

한 시간 후 만료되는 초대 링크 개발기 : DB 구성에 대한 고민

by Greedy 2024. 6. 20.

디스코드 클론코딩 서비스를 개발하면서

채팅방 입장 링크를 발급해준 후 한시간 후 만료시키고

만료되지 않은 코드라면 참여가 가능하게 해야하는 기능을 개발해야했다

 

Key:Value 형태로 간단하게 저장할 수 있을 것 같고 만료되는 기능도 TTL로 하기 쉬운 Redis를 쓰기로 했는데,

 

코드 발급 시에는 채팅방id, userId -> 코드 이렇게 접근해야 했고

코드로 참여 시에는 코드 -> 채팅방 id 이렇게 접근해야 했다

 

그러나 Redis는 Key로 Value를 찾는 것은 효율적인 반면, Value로 Key를 찾는 것은 효율적이지 않아서

처음에는 채팅방id, userId -> 코드, 코드 -> 채팅방 id 이렇게 DB를 두개로 나눠서 저장했다

 

 

@Service
class ChatRoomService(
  private val chatRoomRepository: ChatRoomRepository,
  private val invitationRepository: InvitationRepository,
) {

  fun getChatRoomInvitation(
    chatRoomId: String,
    userId: String,
  ): InvitationResponse {
    verifyChatRoomAccess(chatRoomId, userId)
    val code = invitationRepository.getInvitationCode(userId, chatRoomId)
    return InvitationResponse(code)
  }
  
}
@Component
class InvitationChatRoomRepository(
  private val redisTemplate: RedisTemplate<String, String>,
  private val propertyConfig: PropertyConfig,
) {
  fun setInvitationCode(
    invitationLink: String,
    chatRoomId: String,
  ) {
    val ops: ValueOperations<String, String> = redisTemplate.opsForValue()
    val result = ops.setIfAbsent(invitationLink, chatRoomId, propertyConfig.getExpiration())

    if (result == null || !result) {
      throw GlobalException(ErrorCode.INVITATION_LINK_SAVE_FAILURE)
    }
  }
}
@Component
class UserChatRoomInvitationRepository(
  private val redisTemplate: RedisTemplate<String, String>,
  private val propertyConfig: PropertyConfig,
  private val invitationChatRoomRepository: InvitationChatRoomRepository,
) {
  fun getInvitation(
    userId: String,
    chatRoomId: String,
  ): InvitationResponse {
    val key = generateKey(userId, chatRoomId)
    var value = redisTemplate.opsForValue().get(key)
    if (value == null) {
      value = setInvitation(key, chatRoomId)
    }

    val expiration = redisTemplate.getExpire(key)

    return InvitationResponse(value, LocalDateTime.now().plusSeconds(expiration).toString())
  }

  fun setInvitation(
    key: String,
    chatRoomId: String,
  ): String {
    val invitationCode = generateCode()
    val ops: ValueOperations<String, String> = redisTemplate.opsForValue()
    ops.set(key, invitationCode, propertyConfig.getExpiration())
    invitationChatRoomRepository.setInvitationCode(invitationCode, chatRoomId)
    return invitationCode
  }

  private fun generateKey(
    userId: String,
    chatRoomId: String,
  ): String {
    return "$userId:$chatRoomId"
  }

  private fun generateCode(): String {
    return UUID.randomUUID().toString()
  }
}

 

1차 개선 : Repository 2개 -> 1개

그랬다가

chatroom id : user id -> code만 저장한 후

그 정보를 링크로 암호화해서 내보내고

chatroom id와  user id로 code를 찾았을때

저장되어있는 code가 암호화된 링크를 decode했을 때 나온 code와 일치하면(만료되지 않은 code이면) 

해당 chatroom에 추가하는 방식으로 쓰면 

채팅방id, userId -> 코드 Repository와 코드 -> 채팅방 id Repository 두개를 쓸 필요가 없이

채팅방id, userId -> 코드 Repository 만 있으면 되어서 하나를 삭제했다

 

그 후 Redis 의 여러 자료구조를 찾아보고 chatRoomId를 key, userId를 subkey로 쓰는 Hash를 쓰고싶었지만

userId마다 expiration을 적용할 수 없어서 사용할 수 없었다.

(userId가 필요한 이유 : 채팅방에 참여중인 사람 한 명당 한 시간동안 유효한 링크 하나를 발급하고 싶었다, 채팅방 전체가 링크를 공용으로 사용할 수 있다면 다른 사람이 59분전에 발급해서 1분 남은 링크를 뿌리게 되는 일이 있을 수도 있으니까...)

@Component
class InvitationRepository(
  private val redisTemplate: RedisTemplate<String, String>,
  private val chatRoomProperty: ChatRoomProperty,
) {
  fun getInvitationCode(
    userId: String,
    chatRoomId: String,
  ): String {
    val key = generateKey(userId, chatRoomId)
    var value = redisTemplate.opsForValue().get(key)
    if (value == null) {
      value = setInvitation(key, chatRoomId)
    }
    return generateCode(key, value)
  }

  fun setInvitation(
    key: String,
    chatRoomId: String,
  ): String {
    val value = generateValue()
    val ops: ValueOperations<String, String> = redisTemplate.opsForValue()
    ops.set(key, value, chatRoomProperty.getExpiration())
    return value
  }

  fun getExpiration(
    userId: String,
    chatRoomId: String,
  ): Long {
    val key = generateKey(userId, chatRoomId)
    val expiration = redisTemplate.getExpire(key)
    return expiration
  }

  fun generateCode(
    key: String,
    value: String,
  ): String {
    val combine = "$key,$value"
    val digest = MessageDigest.getInstance("SHA-256")
    val hash = digest.digest(combine.toByteArray(StandardCharsets.UTF_8))
    return Base64.getEncoder().encodeToString(hash)
  }

  private fun generateKey(
    userId: String,
    chatRoomId: String,
  ): String {
    return "$userId:$chatRoomId"
  }

  private fun generateValue(): String {
    return UUID.randomUUID().toString()
  }
}

그래서 이 Repository 만 남겼다

 

이 이후 Repository에 있는 로직들을 Service에 의존하는 Service Layer를 추가적으로 만들어서 분리했다

https://greedydeveloper.tistory.com/377

 

Service에서 다른 Service 를 의존하게 하기

채팅방 기능을 개발할 때 ChatRoomService에서 좀 동떨어진 Invitation 관련 기능들을 많이 이용하게 되었는데그게 ChatRoomService에 있으면 좀 의미가 맞지 않는 것 같아서 모두 InvitationRepository로 분리해

greedydeveloper.tistory.com