1:1 채팅 기능 개선 (feat. NoSQL 및 비동기 로직 적용)
프론트엔드와 함께하는 프로젝트에서 1:1 채팅 기능 개발을 맡았다. 기본 기능을 구현한 후, 남은 3일 동안 가능한 리팩토링 방안을 고민했고, 최종적으로 데이터베이스를 RDBMS에서 NoSQL로 변경하고(채팅 저장, 조회) 추가적으로 채팅 저장 로직을 비동기로 전환하기로 결정했다. 아래는 개선 과정에서 고려했던 주요 사항들을 기록으로 남겨보려 한다. 부족한 부분이 있겠지만 기한안에 완성을 목표로 했다.🤠
목차
채팅 설계
저장소 (MySQL → MongoDB → 아카이빙)
어떤 데이터베이스를 쓸 것인가?
AS-IS : MySQL
TO-BE : 키-값 저장소로 변경(MongoDB)
그럼에도, 아카이빙
동기 → 비동기
AS-IS : 동기
TO-BE : 비동기
스프링 이벤트
외부 브로커(메시지 큐)
스프링 이벤트를 선택한 이유
성능 테스트
확인 할 포인트
아틀러리 툴을 사용해 성능 테스트 진행
Case 1. RDBMS vs 스프링 이벤트
Case 2. RDBMS → NoSQL
최종 확인 (스프링 이벤트 + NoSQL 적용)
채팅 설계
조건
• 응답 시간이 낮은 1:1 채팅
• 텍스트만 주고 받을 수 있음
• 채팅 이력은 영원히 보관 → 아카이빙
웹소켓 연결(양방향)
• 처음에는 HTTP 연결이지만 특정 핸드셰이크 절차를 거쳐 웹 소켓 연결로 업그레이드된다
• 연결 후에는 서버는 클라이언트에게 비동기적으로 메시지를 전송할 수 있다
• 상태 유지가 필요하다
- 각 클라이언트가 채팅 서버와 독립적인 네트워크 연결을 유지해야 한다
- 클라이언트는 보통 서버가 살아있는 한 다른 서버로 연결을 변경하지 않는다
추가로 확인 할 부분
트래픽 규모가 얼마 되지 않을 때는 모든 기능을 서버 한 대로 구현할 수 있다. 그렇다면 서버 한 대로 얼마나 많은 접속을 동시에 허용할 수 있는지는 모니터링이 필요하다
저장소 (MySQL → MongoDB → 아카이빙)
어떤 데이터베이스를 쓸 것인가?
데이터의 유형과 읽기/쓰기 연산의 패턴을 고려하여 결정한다
채팅 시스템에서 다루는 데이터는 크게 두 가지이다
(1) 사용자 프로파일, 설정, 친구 목록과 같은 일반적인 데이터는 → 안정성을 보장하는 관계형 데이터베이스에 보관한다.
(2) 채팅 이력(읽기/쓰기 연산 패턴)
• 페이스북이나 왓츠앱은 매일 600억 개의 메시지를 처리
• 주로 최근에 주고받은 메시지를 사용
• 하지만 검색이나 특정 메시지로 점프하는 경우도 데이터 계층에서 지원해야 한다
• 1:1 채팅 앱의 경우 읽기:쓰기 비율은 대략 1:1
⇒ 이러한 이유로 키-값 저장소로 변경하였다.
AS-IS : MySQL
TO-BE : 키-값 저장소로 변경(MongoDB)
• 수평적 규모 확장이 쉽다
• 데이터 접근 지연시간이 낮다
• 관계형 데이터베이스는 인덱스가 커지면 데이터에 대한 무작위 접근을 처리하는 비용이 늘어난다
• 많은 안정적인 채팅 서비스가 키-값 저장소를 쓰고 있다
메시지 데이터를 어떻게 보관할 것인가?
메시지 ID는 고유하고 시간 순서와 일치해야 한다
• RDBMS는 auto_increment가 대안
• 하지만, NoSQL은 보통 해당 기능을 제공하지 않음
- 지역적 순서 번호 생성기
- 지역적이라 함은 ID의 유일성은 같은 그룹 안에서만 보증하면 충분
- 왜 나면, 1:1 채팅 세션 안에서만 유지하면 되니까
➡️ 채팅 세션 내 유일성을 보장하기 위해 chatRoomId_sendTime 복합 인덱스 적용
db.chat_messages.getIndexes()
[
{
"key": {
"_id": 1
},
"name": "_id_",
"v": 2
},
{
"key": {
"chatRoomId": 1,
"sendTime": -1
},
"name": "chatRoomId_sendTime_idx",
"v": 2
}
]
마지막은, 아카이빙
모든 데이터를 메모리에 해시 테이블 O(1)로 저장한다고 해도 메모리 최적화가 여전히 필요하다. 다음과 같은 개선 방법이 있다.
• 데이터 압축(compression)
• 특정 시점이 지난 데이터는 디스크나 AWS 별도의 스토리지로 아카이빙
동기 → 비동기
AS-IS : 동기
웹소켓을 통해 채팅 메시지를 받을 때마다 동기적으로 데이터베이스에 저장
성능 테스트는 H2 대신 MySQL을 사용해 진행했습니다.
(H2는 메모리 기반이라 정확성이 떨어질 수 있어, 로컬 환경에서 MySQL을 적용해 테스트를 진행함)
TO-BE : 비동기
• 스프링 이벤트
• 외부 브로커
- Kafka
- RabbitMQ
스프링 이벤트
스프링 애플리케이션에서 이벤트를 발행/구독할 수 있는 기능, 메시지 무손실을 보장하지 않고, 확장성에 제한적이나 특별한 설정이 없는 간편함
• ApplicationEventPublisher
• EventListener
외부 브로커(메시지 큐)
생산자 또는 발행자(producer / publisher)라고 불리는 입력 서비스가 메시지를 만들어 메시지 큐에 발행(publish)한다. 큐에는 보통 소비자 혹은 구독자(consumer / subscriber)라 불리는 서비스 혹은 서버가 연결되어 있는데, 메시지를 받아 동작을 수행한다.
특징
• 메시지의 무손실(durability)
- 메시지 큐에 일단 보관된 메시지는 소비자가 꺼낼 때까지 안전히 보관된다는 특성
• 비동기 통신 지원
- 메시지의 버퍼 역할을 하면서 비동기적으로 전송
장점
• 서비스 또는 서버 간 결합이 느슨해져 규모 확장성이 보장되어야 하는 애플리케이션 구성에 좋다
• 생산자는 소비자 프로세스가 다운되어 있어도 메시지를 발행할 수 있고
• 소비자는 생산자 서비스가 가용한 상태가 아니더라도 메시지를 수신할 수 있다
스프링 이벤트를 선택한 이유
현재는 모놀리식 구조이기 때문이다. 외부 브로커(예: Kafka, RabbitMQ)를 도입하는 것은 멀티 모듈 환경에서 더 적합하다고 생각하고, 현재 구조에서는 오버 엔지니어링이라고 판단했다.
성능 테스트
확인할 포인트
RDBMS로 메시지 저장(동기), 메세지 조회
NoSQL로 메세지 저장, 조회
이벤트 메세지 저장(비동기)
아틀러리 툴을 사용해 성능 테스트 진행
artillery-load-test-event.yaml
config:
target: 'http://localhost:8090' # 테스트 대상 서버 URL
phases:
- duration: 10 # 해당 단계의 지속 시간 (초)
arrivalRate: 5 # 초당 도달하는 가상 사용자 수
name: Warm up # 첫 번째 단계 이름 (서버 준비 단계)
- duration: 10 # 해당 단계의 지속 시간 (초)
arrivalRate: 20 # 초당 도달하는 가상 사용자 수
name: Ramp up load # 두 번째 단계 이름 (부하 증가 단계)
- duration: 30 # 해당 단계의 지속 시간 (초)
arrivalRate: 100 # 초당 도달하는 가상 사용자 수
name: Sustained load # 세 번째 단계 이름 (지속적인 부하 단계)
- duration: 10 # 해당 단계의 지속 시간 (초)
arrivalRate: 20 # 초당 도달하는 가상 사용자 수
name: End of load # 네 번째 단계 이름 (부하 종료 단계)
scenarios:
- name: 'event chatting message load test' # 시나리오 이름 (채팅 메시지 부하 테스트)
flow:
- post:
url: '/api/v1/chat/1/logs' # API 호출 URL (채팅 메시지 로그 전송)
json:
message: 'event message test' # 전송할 메시지 내용
사용한 명령어
artillery run --output report.json artillery-test.yaml
• 성능 테스트를 실행하고, 결과를 report.json 파일에 저장
artillery report report.json --output report.html
• report.json 파일을 기반으로 HTML 형식의 보고서를 생성하여 report.html로 저장
Case 1. RDBMS vs 스프링 이벤트
H2는 메모리 기반이라 정확성이 떨어질 수 있어, 로컬 환경에서 MySQL을 적용해 테스트를 진행
MySQL 메시지 저장(동기)
• 95p: 124ms
• 99p: 211ms
요청의 99%는 211ms이하의 응답 시간을 기록함
⬇️ MySQL적용한 코드
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/flowday_dev
username: [username]
password: [password]
ChatApiController
/**
* 채팅 메세지 저장
*/
@PostMapping("/{roomId}/logs")
public ResponseEntity<ApiResponse> saveMessage(
@PathVariable Long roomId,
@AuthenticationPrincipal SecurityUser user,
@RequestBody ChatMessage chatMessage
) {
Long senderId = user.getId();
LocalDateTime time = LocalDateTime.now();
//String responseMessage = HtmlUtils.htmlEscape(chatMessage.message());
chatService.saveMessage(roomId, senderId, chatMessage.message(), time);
return ResponseEntity.ok(ApiResponse.success(chatMessage.message()));
}
이벤트 메세지 저장(비동기)
• 95p: 12ms
• 99p: 75ms
요청의 99%는 75ms이하의 응답 시간을 기록함
스프링 이벤트 (refactor/#78) → PR
https://github.com/prgrms-web-devcourse-final-project/WEB1_2_FlowDay_BE/pull/104
⬇️ 코드
Application
@EnableAsync
@EnableWebSocket
@SpringBootApplication
@EnableJpaAuditing
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
ChatController
/**
* 웹 소켓 연결
*/
@MessageMapping("/chat/{roomId}")
@SendTo("/topic/rooms/{roomId}")
public ChatResponse chatting(
@DestinationVariable Long roomId,
@AuthenticationPrincipal SecurityUser user,
ChatMessage chatMessage
) {
Long senderId = user.getId();
LocalDateTime time = LocalDateTime.now();
String responseMessage = HtmlUtils.htmlEscape(chatMessage.message());
chatService.saveMessage(roomId, senderId, responseMessage, time);
// 페이지 정보는 웹소켓 연결에서는 의미 없으므로 0으로 설정
return new ChatResponse(senderId, responseMessage, time, 0, 0);
}
ChatService
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ChatService {
private final ChatMessageRepository chatMessageRepository;
private final ChatRoomRepository chatRoomRepository;
private final ApplicationEventPublisher applicationEventPublisher;
/**
* [비동기 - 스프링 이벤트] 채팅 메세지 저장
*/
public void saveMessage(
final Long roomId,
final Long senderId,
final String responseMessage,
final LocalDateTime time
) {
applicationEventPublisher.publishEvent(
new ChatMessageEvent(roomId, senderId, responseMessage, time)
);
}
}
ChatMessageEvent
public record ChatMessageEvent(
Long roomId,
Long senderId,
String responseMessage,
LocalDateTime time
) {
}
ChatMessageEventHandler
@RequiredArgsConstructor
@Service
public class ChatMessageEventHandler {
private final ChatMessageRepository chatMessageRepository;
@Async
@TransactionalEventListener
public void handle(ChatMessageEvent event) {
ChatMessageEntity chatMessage = ChatMessageEntity.create(
event.roomId(),
event.senderId(),
event.responseMessage(),
event.time()
);
chatMessageRepository.save(chatMessage);
}
}
성능 개선
스프링 이벤트 메시지 저장(비동기)은 MySQL 메시지 저장(동기) 대비 99%의 요청에서 응답 시간이 136ms (약 64%) 개선되었다.
Case 2. RDBMS → NoSQL
채팅 메시지 저장/조회 RDBMS → NoSQL(MongoDB) 전환
MongoDB 연결 (refactor/#84) - PR 완료
https://github.com/prgrms-web-devcourse-final-project/WEB1_2_FlowDay_BE/pull/103
⬇️ 코드
application.yml
spring:
data:
mongodb:
uri: mongodb://localhost:27017/flowday_chat_db
auto-index-creation: true
build.gradle
// mongodb
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
ChatApiController
/**
* [NoSQL] 채팅 메세지 저장
*/
@PostMapping("{roomId}/logs")
public ResponseEntity<ApiResponse> chatting(
@PathVariable Long roomId,
@AuthenticationPrincipal SecurityUser user,
@RequestBody ChatMessage chatMessage
) {
Long senderId = user.getId();
LocalDateTime time = LocalDateTime.now();
String responseMessage = HtmlUtils.htmlEscape(chatMessage.message());
ChatMessageDocument chatMessageDocument =
chatService.saveMessage(roomId, senderId, responseMessage, time);
return ResponseEntity.ok(ApiResponse.success(chatMessageDocument));
}
/**
* [NoSQL] 채팅 조회 (최신 10개, page)
*/
@GetMapping("/{roomId}")
public ResponseEntity<ApiResponse> getPagedChatMessages(
@PathVariable Long roomId,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "10") int size
) {
Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "sendTime"));
Page<ChatMessageDocument> messages = chatService.getPagedChatMessages(roomId, pageable);
int pageNumber = messages.getNumber() + 1;
List<ChatResponse> chatResponses = messages.getContent().stream()
.map(message -> ChatResponse.from(message, pageNumber, messages.getTotalPages()))
.toList();
return ResponseEntity.ok(ApiResponse.success(chatResponses));
}
ChatService
private final ChatMessageDocumentRepository chatMessageDocumentRepository;
/**
* [NoSQL] 채팅 메세지 저장
*/
public ChatMessageDocument saveMessage(
final Long roomId,
final Long senderId,
final String responseMessage,
final LocalDateTime time
) {
ChatMessageDocument chatMessageDocument = ChatMessageDocument.create(
roomId,
senderId,
responseMessage,
time
);
return chatMessageDocumentRepository.save(chatMessageDocument);
}
/**
* [NoSQL] 채팅 조회
*/
public Page<ChatMessageDocument> getPagedChatMessages(
final Long roomId,
final Pageable pageable
) {
return chatMessageDocumentRepository.findByChatRoomId(roomId, pageable);
}
ChatResponse
public record ChatResponse(
Long senderId,
String message,
LocalDateTime time,
Integer pageNumber, // 현재 페이지 번호
Integer totalPages // 총 페이지 수
) {
public static ChatResponse from(
final ChatMessageDocument chatMessageDocument,
Integer pageNumber,
Integer totalPages
) {
return new ChatResponse(
chatMessageDocument.getFromId(),
chatMessageDocument.getTextMessage(),
chatMessageDocument.getSendTime(),
pageNumber,
totalPages
);
}
}
ChatMessgeDocument
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Document(collection = ChatMessageDocument.COLLECTION_NAME)
@CompoundIndexes({
@CompoundIndex(name = "chatRoomId_sendTime_idx", def = "{'chatRoomId': 1, 'sendTime': -1}")
})
public class ChatMessageDocument {
public static final String COLLECTION_NAME = "chat_messages";
@Id
private ObjectId id;
private Long chatRoomId;
private Long fromId;
private String textMessage;
private LocalDateTime sendTime;
@Builder
public ChatMessageDocument(
final ObjectId id,
final Long chatRoomId,
final Long fromId,
final String textMessage,
final LocalDateTime sendTime
) {
this.id = id;
this.chatRoomId = chatRoomId;
this.fromId = fromId;
this.textMessage = textMessage;
this.sendTime = sendTime;
}
public static ChatMessageDocument create(
final Long chatRoomId,
final Long fromId,
final String textMessage,
final LocalDateTime sendTime
) {
return ChatMessageDocument.builder()
.chatRoomId(chatRoomId)
.fromId(fromId)
.textMessage(textMessage)
.sendTime(sendTime)
.build();
}
}
ChatMessageDocumentRepository
public interface ChatMessageDocumentRepository extends MongoRepository<ChatMessageDocument, ObjectId> {
Page<ChatMessageDocument> findByChatRoomId(Long chatRoomId, Pageable pageable);
}
docker-compose.yml
version: '3.8'
services:
mongodb:
image: mongo:6.0.3
container_name: mongodb_container
volumes:
- ./db/mongodb/data:/data/db
ports:
- "27017:27017"
networks:
- flowday
command: mongod --noauth
networks:
flowday:
채팅 메세지 저장
채팅 메세지 조회
최종 확인 (스프링 이벤트 + NoSQL 적용)
웹소켓 연결 + 채팅 메세지 전송
NoSQL 채팅 메세지 저장
NoSQL 채팅 메세지 조회
참고 :
[도서] 가상 면접 사례로 배우는 대규모 시스템 설계 기초