DB) DynamoDB 입문 (feat: 채팅 서비스 DB 설계)

박우영·2023년 12월 24일
0

DB

목록 보기
5/6

왜 DynamoDB 일까

먼저 저장해야 하는 데이터 트래픽 특성을 알아보겠습니다.

채팅 기능을 구현하고 채팅 기록을 저장해야하는데 채팅은 주로
현재 요구사항에 삭제/수정 기능이 없지만 추후에 추가 되더라도 Insert, Select 의 비율이 대부분 입니다.

채팅이 저장안되거나 싱크가 안맞는 문제가 있으면 안되겠지만 결제, 회원의 민감정보를 다루는 것보단 강력한 트랜잭션 또한 필요없을 것이라 생각했습니다.

복잡한 쿼리의 불필요, 아직 n:m 의 채팅기능이 없기도하고 추가되더라도 엄청나게 복잡한 쿼리도 필요없고 많은 트래픽이 예상되는 만큼 Scale-up, Scale-out 이 필요하고 간단하게 Json 형태로 저장 후 API 로 제공 해주고 싶었기 때문에 위와 같은 이유로 NoSQL 이 적합하다 생각했습니다.

NoSQL 도 종류가 많지만 안전한 서비스를 제공하기 위해 우리가 사용하는 AWS(CSP) 에서 제공하는 솔루션을 활용하고 대용량 데이터, 성능 적으로도 뛰어난 DynamoDB를 도입하기로 하였습니다.

DB 설계


DynamoDB 는 Key-value 형태입니다. 따라서 뛰어난 성능을 가지지만 PK 를 어떻게 설계할지부터 막막했습니다.

  • Simple PK 로 설계하는 방법
  • Composite Primary Key 설계
    • RDBMS 에서 composite key 를 설계하는 것과 비슷하게 느낌

따라서 Chat_room 을 RDB 로 설계후 DDB 는 Simple PK 로 설정 하여 채팅 내역을 List 로 관리하는 방식으로 선택

채팅을 최초에 시작할때 Flow 를 비즈니스 로직을 제외한 DB 설계적으로만 표현해 봤습니다.

사실 AS-IS 는 1:1 채팅만 구현하기 때문에 chat_room entity 는 필요 없을 수 있지만 1:N, N:M 의 채팅을 추후에 구현한다 했을때 chat_room entity 를 미리 구현하여 확장에 있어 유연하게 대응할 수 있도록 하였습니다.

캐릭터 정보, Account 의 정보를 join 하기 쉽게 하기 위해 chat_room 은 같은 RDS 로 선택하였고 데이터가 많고 join이 불필요한 채팅 목록은 DDB로 구현했습니다.

Flow Lifecycle

위 시퀀스 다이어그램으론 설명이 부족하다고 느낄 수 있기때문에 채팅 flow 에 대한 생명주기를 간단하게 요약해보겠습니다.

먼저 채팅을 진입하는 방법은 두가지가 존재합니다.
1. 기존의 채팅방으로 진입
2. 신규 채팅을 생성

사실 1번의 경우에는 큰 문제가 없었지만...
2번의 경우에서 기존의 채팅을 못받아 올 수 있기때문에 chat_room 이라는 entity 를 채팅이 시작하든 시작하지 않든 먼저 생성, 조회를 하고 그 후에 채팅 내역을 조회합니다.

  1. 채팅방 생성,조회 (채팅 시작)
  2. 채팅방 id 로 생성된 pk로 dynamodb 에 조회 쿼리
  3. 있다면 채팅내역을 불러오고, 없다면 LinkedList 혹은 redis 에 채팅내역 저장
  4. dynamoDB에 채팅 내역 저장, chat_room 에 마지막 대화 내역 저장 (채팅종료)

Code

다음은 DynamoDB 를 사용하기 위한 설정입니다. sdk 를 사용하기 때문에 config 설명은 생략하겠습니다.

DynamoDBConfig

@Configuration
class DynamoDBConfig(
    @Value("\${aws.dynamodb.endpoint}")
    val endPoint: String,
    @Value("\${aws.region}")
    val region: String,
    @Value("\${aws.accessKey}")
    val accessKey: String,
    @Value("\${aws.secretKey}")
    val secretKey: String,
) {
    @Bean
    fun dynamoDBMapper(): DynamoDBMapper {
        val config = DynamoDBMapperConfig.builder()
            .withSaveBehavior(DynamoDBMapperConfig.SaveBehavior.CLOBBER)
            .withConsistentReads(DynamoDBMapperConfig.ConsistentReads.CONSISTENT)
            .withPaginationLoadingStrategy(DynamoDBMapperConfig.PaginationLoadingStrategy.EAGER_LOADING)
            .build()
        return DynamoDBMapper(amazonDynamoDB(), config)
    }

    @Bean
    fun amazonDynamoDB(): AmazonDynamoDB {
        return AmazonDynamoDBClientBuilder
            .standard()
            .withEndpointConfiguration(
                AwsClientBuilder.EndpointConfiguration(endPoint, region),
            )
            .withCredentials(awsCredentialsProvider())
            .build()
    }

    @Bean
    @Primary
    fun awsCredentialsProvider(): AWSCredentialsProvider {
        return AWSStaticCredentialsProvider(BasicAWSCredentials(accessKey, secretKey))
    }
}

converter

@Suppress("TooGenericExceptionThrown")
class ChatMessageListConverter : DynamoDBTypeConverter<String, List<ChatMessage>> {
    override fun convert(messages: List<ChatMessage>?): String {
        return try {
            Jackson.mapper().writeValueAsString(messages)
        } catch (e: JsonProcessingException) {
            throw RuntimeException("Failed to convert ChatMessage list to String", e)
        }
    }

    override fun unconvert(jsonString: String?): List<ChatMessage> {
        return try {
            Jackson.mapper().readValue(jsonString, object : TypeReference<List<ChatMessage>>() {})
        } catch (e: JsonProcessingException) {
            throw java.lang.RuntimeException("Failed to convert String to ChatMessage list", e)
        }
    }
}

처음에 Table 을 설계할때 sdk 를 사용하고, json 형태로 그대로 넣을 예정이라 converter 를 사용하지 않고 구현했으나 DynamoDB 에서 해당 Object 를 인식하지 못하는 문제가 발생하여 Converter 를 별도로 구현했습니다.

table

@DynamoDBTable(tableName = "dev_cre-chat_chat_table")
class ChatTable(
    roomId: String = "",
    message: List<ChatMessage> = emptyList(),
) {
    @DynamoDBHashKey(attributeName = "chat_room_id")
    var chatRoomId: String = roomId

    @DynamoDBAttribute(attributeName = "message")
    @DynamoDBTypeConverted(converter = ChatMessageListConverter::class)
    var message: List<ChatMessage> = message

    fun messageUpdate(message: List<ChatMessage>) {
        this.message = message
    }
}

DynamoRepository

@Repository
@Suppress("SwallowedException", "PrintStackTrace")
class ChatDynamoRepository(
    private val dynamoDBMapper: DynamoDBMapper,
) : Log {
    fun save(chatTable: ChatTable) {
        val existingChatTable = findById(chatTable.chatRoomId)

        if (existingChatTable.isPresent) {
            // 기존 데이터가 있으면 새 메시지를 추가합니다.
            val table = existingChatTable.get()
            val updatedMessages = table.message.toMutableList()
            updatedMessages.addAll(chatTable.message)
            table.messageUpdate(updatedMessages)
            dynamoDBMapper.save(table)
        } else {
            // 기존 데이터가 없으면 새로 저장합니다.
            dynamoDBMapper.save(chatTable)
        }
    }

    fun findById(id: String): Optional<ChatTable> {
        return try {
            Optional.ofNullable(dynamoDBMapper.load(ChatTable::class.java, id))
        } catch (e: DynamoDBMappingException) {
            log.error(e.printStackTrace().toString())
            Optional.empty()
        }
    }
}

repository 는 다음과 같이 구현하였습니다. 처음 써보는 기술스택과 정해진 시간내에 output 을 내기 위해 로직적으로 고민을 많이 못한것이 아쉬움이 남는 코드 입니다.


RDBMS 와 DDB 를 사용한 채팅서비스의 DB 모델링에 대해서 다뤄봤는데,
다음은 서비스를 구현하며 겪었던 문제를 공유하고자 합니다.

  • 동시성 문제

Reference

Redis VS DynamoDB
Amazon DynamoDB 키 디자인 패턴 - 이혁, DynamoDB Specialist Solutions Architect, AWS

0개의 댓글