먼저 저장해야 하는 데이터 트래픽 특성을 알아보겠습니다.
채팅 기능을 구현하고 채팅 기록을 저장해야하는데 채팅은 주로
현재 요구사항에 삭제/수정 기능이 없지만 추후에 추가 되더라도 Insert, Select 의 비율이 대부분 입니다.
채팅이 저장안되거나 싱크가 안맞는 문제가 있으면 안되겠지만 결제, 회원의 민감정보를 다루는 것보단 강력한 트랜잭션 또한 필요없을 것이라 생각했습니다.
복잡한 쿼리의 불필요, 아직 n:m 의 채팅기능이 없기도하고 추가되더라도 엄청나게 복잡한 쿼리도 필요없고 많은 트래픽이 예상되는 만큼 Scale-up, Scale-out 이 필요하고 간단하게 Json 형태로 저장 후 API 로 제공 해주고 싶었기 때문에 위와 같은 이유로 NoSQL 이 적합하다 생각했습니다.
NoSQL 도 종류가 많지만 안전한 서비스를 제공하기 위해 우리가 사용하는 AWS(CSP) 에서 제공하는 솔루션을 활용하고 대용량 데이터, 성능 적으로도 뛰어난 DynamoDB를 도입하기로 하였습니다.
DynamoDB 는 Key-value 형태입니다. 따라서 뛰어난 성능을 가지지만 PK 를 어떻게 설계할지부터 막막했습니다.
따라서 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 에 대한 생명주기를 간단하게 요약해보겠습니다.
먼저 채팅을 진입하는 방법은 두가지가 존재합니다.
1. 기존의 채팅방으로 진입
2. 신규 채팅을 생성
사실 1번의 경우에는 큰 문제가 없었지만...
2번의 경우에서 기존의 채팅을 못받아 올 수 있기때문에 chat_room 이라는 entity 를 채팅이 시작하든 시작하지 않든 먼저 생성, 조회를 하고 그 후에 채팅 내역을 조회합니다.
다음은 DynamoDB 를 사용하기 위한 설정입니다. sdk 를 사용하기 때문에 config 설명은 생략하겠습니다.
@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))
}
}
@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 를 별도로 구현했습니다.
@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
}
}
@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 모델링에 대해서 다뤄봤는데,
다음은 서비스를 구현하며 겪었던 문제를 공유하고자 합니다.
Redis VS DynamoDB
Amazon DynamoDB 키 디자인 패턴 - 이혁, DynamoDB Specialist Solutions Architect, AWS