원툴의 채팅을 도입하여 도면에 대한 이야기를 하거나 정보를 공유할 수 있는 커뮤니케이션 도구를 개발하려고 한다. 사용성 측면에서 보다 구현이 용이한 공개채팅으로 구현하고자 한다. 본 구현은 페어 프로그래밍을 통해 2시간씩 총 6회차 진행했습니다.
https://github.com/likelion-onetool/backend/issues/230
📚 기술 선택
모놀리식 아키텍처를 통해 서버 비용을 절감하기 위해 별도의 프레임워크가 아닌 기존에 사용중인 Java와 Spring Boot를 사용하기로 결정했다.
메시지 처리 방식
1:1이 아닌 다수의 사용자가 동시에 진행할 수 있는 채팅을 구현하기 위해 어떤 메시지 전송 방식이 접합할지 장단점을 구분했다.
| 방식 | 장점 | 단점 |
| Polling | 서비스 구현이 간편 | - Polling 주기에 따라 메시지 전송 속도가 결정 - Polling된 시점에 따라 사용자가 보고 있는 채팅 내용이 결정됨 |
| Long Polling | 클라이언트에서 주기적으로 요청을 보낼 필요 없음 | 서버에서 요청을 유지하기 때문에 부하가 발생 |
| WebSocket | - 모든 시점에서 사용자의 채팅 내용이 일치 - 여러 사용자에게 동시에 브로드캐스트 가능 | - 연결이 많아지면 복잡한 세션 관리 - Stateful 방식으로 연결 관리 필요 |
| gRPC | - 멀티플렉싱을 통한 동시 요청 처리 - Protocol Buffers로 타입 안정성 - 스트리밍 지원 - 부하분산 내장 | - 브라우저 지원 제한 - 러닝 커브 존재 - 브로드캐스팅 부재 (각각 유니캐스트) |
공개 채팅 특성상 브로드캐스트가 간편한 WebSocket을 채택했다.
채팅 메시지 저장소
현재 사용 중인 MySQL을 그대로 사용하기로 결정했다. 추후 성능 테스트를 통해 RDB인 MySQL과 Postgres, MongoDB 중 적합한 데이터베이스로 마이그레이션하는 과정을 계획하고 있다.
📝 채팅 서비스 흐름
여러 관련 아티클을 조사해본 결과 애플리케이션 간의 원활한 통신을 위해 메시지 브로커를 통해 전송한다고 한다. 대규모 시스템에 대비한 구현이 아닌 MVP가 목적인 만큼 메시지 브로커 사용에 대해선 추후 미루도록 하겠다.
일반 채팅

채팅 저장 및 조회

💡 모든 메시지를 영구 저장할 것인가?
모든 채팅 메시지는 저장 시 CreatedAt 필드가 자동으로 삽입된다. MySQL의 배치를 통해 특정 시간마다 CreatedAt + 일정 시간 인 메시지는 자동으로 삭제되도록 구현한다.
🤔 기술적 고민 및 구현
1. WebSocket는 어떻게 작동하는가?
WebSocket 내부 과정
서블릿 컨테이너 위에서 동작하기 때문에 표준 HTTP 요청으로 시작하여, WebSocket 프로토콜로 업데이트 하게 된다.
- 서블릿 컨테이너가 HTTP 요청을 받는다.
- DispatcherServlet으로 전달된다.
- 요청 URL을 기반으로 해당 요청을 처리할 핸들러를 찾는다.
이때 MVC 컨트롤러를 먼저 수행 후, WebSocketHandler를 찾는다. - 핸드셰이크 요청 수행
- 연결 확립 후 WebSocketSession으로 관리
DispatcherServlet 위에서 작동하기 때문에 기존 MVC와 동일하게 스레드 풀을 통해 작업을 수행하게 된다.
2. 어떤 데이터베이스가 적합할까?
[ 채팅 시스템의 주요 특징 ]
- 메시지는 수정 및 삭제가 이루어지지 않고, 저장 및 조회만 발생한다.
- 공개 채팅이기 때문에 대량의 메시지의 저장이 발생할 수 있다고 예상한다. → 데이터의 정합성
- 일정 시간이 지난 메시지는 자동으로 삭제된다.
[ 읽기/쓰기 연산 패턴 ]
- 사용자는 최근 메시지 내역을 조회하고, 메시지를 전송한다.
- 읽기:쓰기 비율은 대략 1:1 정도이다.
이 기준으로 미루어봤을 때 NoSQL을 사용하는 것이 올바르다고 생각한다. 그 중 키/값 형식이 아닌 다큐먼트 형식의 MongoDB가 객체 형태인 메시지 구조에 더욱 적합하다고 판단했다.
데이터베이스 성능 테스트 | Notion
개요
www.notion.so
3. 채팅 저장 시점을 언제로 해야 하는가?
채팅 메시지가 발생할 때마다 데이터베이스에 접근하여 저장하는 방식은 사용 면에서 DB I/O가 매번 발생하기 때문에 매우 비효율적이라고 판단했다. 따라서, 메시지를 일정 개수만큼 가지고 있다가 한 번에 저장하는 메시지 큐 시스템을 도입하는 것이 성능을 향상시킬 수 있다고 판단했다.
그렇게 구축한 아키텍처가 다음과 같다.

위 아키텍처의 동작 방식은 다음과 같다.
- 사용자가 메시지를 전송하는 경우
- 메시지를 WebSocket으로 전송한다.
- Spring에선 해당 메시지를 접속된 Session에게 전송한다.
- 해당 메시지는 큐에 저장된다.
- Queue에 일정 개수가 저장된 경우, DB에 저장한다.
- 사용자가 접속하는 경우
- DB에서 채팅 내역을 조회한다.
- Queue에 있는 메시지들을 조회한다.
메시지 큐로 어떤 컬렉션을 사용해야 할까?
처음에는 동시성 처리가 가능한 BlockingQueue를 사용하기로 결정했다. 그런데 사용자가 새로 접속한 경우, Queue에 있는 내용들을 조회할 필요가 발생했다. 따라서, 동시성 처리가 가능하면서 stream이 가능한 컬렉션을 찾던중 ConcurrentLinkedQueue 를 사용하게 되었다.
private final Queue<ChatMessage> messageQueue = new ConcurrentLinkedQueue<>();
추가로 채팅 메시지는 아래와 같이 구현했다. 4번에서 다룰 예정이다. MessageType의 경우, ‘ENTER’, ‘CHAT’, ‘QUIT’ 로 단순히 메시지의 종류를 구분짓는 것이다. sender의 경우, 채팅방 입장 시 사용자가 직접 입력하도록 개발하였다.
public class ChatMessage extends BaseEntity {
@Id private Long id;
@Enumerated(EnumType.STRING) private MessageType type;
private String roomId;
private String sender;
private String message;
}
4. 새로운 참여자에게 최근 메시지 보여주기
메시지 큐에는 메시지가 없는 경우에는 DB에서만 메시지를 받아오면 된다. 그러나 최근 메시지가 메시지 큐에 있는 경우 이를 가져올 수 있는 방법이 필요하다. 이때 채팅 히스토리를 조회할 때 "DB 저장 완료된 데이터"와 "큐에 대기 중인 데이터" 사이의 중복을 해결해야 한다.
중복 문제 해결을 위한 ID 전략
현재 채팅 메시지의 경우, Auto Increment로 채팅 메시지의 고유값을 지정하고 있다. 이 방식의 경우, DB 벤더에 의존하기 때문에 큐잉된 메시지와 DB에 저장된 메시지 간의 ID 순서가 보장되기 어렵다. 따라서, 이를 해결하기 위한 별도의 ID 생성기를 도입해야 한다.
먼저 ID에 대한 필요한 조건은 순서가 보장되어야 하며, 유니크 해야한다. 그래야만 메시지들의 중복을 방지하고 순서대로 사용자에게 제공할 수 있기 때문이다. 기존에 ULID를 사용한 경험이 있는데, 꾸준히 기여되는 ULID 라이브러리가 없고 설정도 까다로워서 이번에 간편한 TSID를 도입해보고자 한다.
가장 유명한 TSID 러이브러리인 hypersistence-tsid를 사용하기로 했다. 가장 최근까지 관리되는 오픈소스이면서 가장 유명한 개발자인 vladmihalcea가 개발하는 프로젝트이기 때문이다. 아래 static 메서드를 통해 생성이 가능하다.
TSID.fast().toLong();
추가적으로 ID를 Long으로 생성한 이유는 Long의 경우 고정 8 Byte이지만, String의 경우 DB에선 VARCHAR로 메타데이터 포함 20 Byte 이상을 차지하기 때문에 비효율적이다. 또한, 정렬이 무엇보다 Long 타입이 빠른 것도 하나의 이유이다.
TSID를 PK로 사용해도 되는가?
TSID는 Thread-Safe를 보장하기 때문에 중복에 대해서는 걱정할 필요가 없다. 그런데 JSON으로 직렬화 과정에서 문제가 발생할 수 있다. Java의 Long은 64비트 정수이지만, JavaScript의 Number 타입은 53비트까지만 안전하게 표현합니다. TSID는 64비트 값을 꽉 채워 쓰기 때문에, 프론트엔드(React, Vue 등)에서 이를 숫자로 그대로 받으면 마지막 자릿수가 반올림되거나 잘리는 버그가 발생한다.
서버에서 클라이언트로 보낼 때 String으로 변환해서 보내야 합니다. 현재 DTO에 JsonParser 어노테이션을 통해 이를 해결했다.
public record ChatMessageResponse(
@JsonSerialize(using = ToStringSerializer.class)
Long id,
MessageType type,
String sender,
String message,
LocalDateTime createdAt
)
ID 병합으로 중복 해결하기
이제 고유한 식별자를 지정했기 때문에 이를 이용하여 중복 메시지를 걸러내보도록 하자. 현재 가장 효율적이라고 생각되는 방법은 Map을 이용하여 구현할 수 있다. Map<ID, 메시지>에 메시지를 넣어서 중복된 ID의 메시지를 걸러낼 수 있다.
@Transactional(readOnly = true)
public List<ChatMessageResponse> findChatMessages(final String roomId) {
// 최대 50개의 메시지만 조회
Pageable limit = PageRequest.of(0, 50);
// DB와 Queue에서 각각 메시지 가져오기
List<ChatMessage> dbMessages = chatRepository.findLatestMessages(limit, roomId);
List<ChatMessage> queueMessages = messageQueue.getQueuedMessages(roomId);
// 중복을 거르기 위한 HashMap
Map<Long, ChatMessage> mergedMap = new HashMap<>();
for (ChatMessage msg : dbMessages) {
mergedMap.put(msg.getMessageId(), msg);
}
for (ChatMessage msg : queueMessages) {
mergedMap.put(msg.getMessageId(), msg);
}
// 중복이 제거된 Map에서 Response로 변환
return mergedMap.values().stream()
.sorted(Comparator.comparing(ChatMessage::getCreatedAt))
.toList()
.stream()
.map(ChatMessageResponse::from)
.collect(Collectors.toList());
}
5. 배포 후 성능 측정
성능 측정 환경
- AWS EC2
- t3a.small
- AWS RDS
- PostgreSQL
- db.t4g.micro
MessageQueue 도입 전
부하 테스트 결과
| Run | 총 메시지 요청 수 | 요청 처리 성공률 | HTTP 요청 시간(p95) | HTTP 요청 실패률 | 웹소켓 연결 시간 (p95) |
| 1 | 1,582 | 100% | 34.43s | 0.00% | 44.15s |
| 2 | 1,702 | 100% | 36.66s | 0.00% | 28.25s |
MessageQueue 도입 후
MAX_SIZE = 10
| Run | 총 메시지 요청 수 | 요청 처리 성공률 | HTTP 요청 시간(p95) | HTTP 요청 실패률 | 웹소켓 연결 시간 (p95) |
| 1 | 1,598 | 98.93% | 49.99s | 1.06% | 46.08s |
| 2 | 1,203 | 99.83% | 47.10s | 0.16% | 41.85s |
MAX_SIZE=20
| 총 메시지 요청 수 | 요청 처리 성공률 | HTTP 요청 시간(p95) | HTTP 요청 실패률 | 웹소켓 연결 시간 (p95) |
| 1,403 | 96.57% | 58.79s | 3.42% | 48.52s |
결과
이상하게도 요청 처리 성공률는 100%에서 96%까지 떨어졌고, HTTP 요청 시간(p95)은 34.43s에서 49.99s로 더 악화되는 성능 회귀가 발생했다. 이 문제를 해결하기 위해 스프링 로그를 바탕으로 어떤 문제가 발생했는지 더 자세히 알아봐야겠다.
6. 메시지 큐의 경쟁 상태 발생
문제 원인
로그를 확인해본 결과, EntityExistsException 이 발생했다. 실제 실행 쿼리를 확인해본 결과, 중복 ID로 데이터를 저장하는 것을 확인했다. 현재 MessageQueue에 있는 메시지들을 DB에 저장하는 코드와 MessageQueue의 저장된 메시지들을 제거하는 함수가 ChatService의 proccessMessageQueue()에서 수행된다. 이 과정에서 저장이 되지 않는 오류가 발생했다.
public void processMessageQueue() {
List<ChatMessage> unsavedMessages = chatMessageQueue.getMessages();
if (unsavedMessages.isEmpty()) {
return;
}
try {
// DB에 저장
Integer messageSize = chatService.saveAllTextMessage(unsavedMessages);
// 저장된 메시지 표시 및 큐에서 제거
chatMessageQueue.deleteMessages();
} catch (Exception e) {
log.error("배치 메시지 저장 중 오류 발생", e);
}
}
ChatMessageQueue는 메시지 큐를 이용하여 메시지를 관리한다. 만약 새로운 메시지가 생성되어 메시지 큐에 저장하는 상황이 발생하면, 메시지 큐의 size가 MAX_SIZE를 넘는지 확인한다. 만약 MAX_SIZE를 넘긴다면 즉시 위 메서드를 호출하여 메시지들을 가져오고(fetch) - 저장하고(Save) - 삭제하는(Delete) 로직을 수행하게 된다.
이 과정에서 동시에 여러 사용자가 메시지 큐에 접근할 경우, 여러 스레드가 fetch - save - delete를 수행하게 된다. 여기서 가장 큰 문제는 큐에 save - delete가 원자성을 가지지 못하기 때문에 메시지가 중복으로 저장되는 경쟁상태가 발생한 것이다.
즉, 현재 관점을 생산자-소비자 패턴으로 파악했을 때, 소비자(save-delete 수행 스레드)가 다수이면서 발생하는 문제이다. 이 문제를 해결하기 위해 단일 소비자를 이용하여 하나의 스레드만이 해당 메서드를 수행할 수 있도록 제한해야 한다.
메시지 큐 기술 도입이 필요한 시점인가?
굳이 멀티 스레드를 통해 메시지 큐를 소비할 이유는 없다고 생각한다. 단일 DB 서버의 단일 웹 서버로 구성된 아키텍처이기 때문에 샤딩이 전혀 적용되어 있지도 않다. 또한, 메시지 큐 용량 또한 20개로 제한을 두고 있기 때문에 단일 소비 스레드로도 충분히 배치 저장이 가능할 것이다. 따라서, 메시지 큐(Kafka, Redis Stream 등)을 사용하지 않고, 단일 스레드를 통해 이를 비동기 처리 해보고자 한다.
@Async를 이용해서 해결하기
Spring Boot에선 @Async 를 제공하여 비동기 처리가 가능하다. 이 어노테이션은 구체적으로 어떤 역할을 하는 것일까? 쉽게 말해 메서드 호출을 가로채서, 미리 등록된 Executor에게 "이것 좀 처리해줘"라고 넘겨주는 스프링 AOP 어노테이션이다. 따라서, @Async만 붙인다고 끝이 아니라, 어떤 Executor에게 일을 시킬 것인지를 설정하지 해야만 한다.
Executor는 실제로 스레드를 만들고 관리하며 작업을 실행하는 Java의 인터페이스
스
프링은 기본적으로 SimpleAsyncTaskExecutor라는 녀석을 사용한다. 이 Executor는 요청이 올 때마다 매번 새로운 스레드를 만든다. 만약 채팅 메시지가 1초에 1,000개 들어오면 스레드가 1,000개 생성되는 무시무시한 일이 발생할 수 있다. 따라서, 무조건 싱글 스레드만 생성할 수 있도록 커스텀 Executor를 설정해줘야 한다.
@EnableAsync
@Configuration
public class AsyncConfig {
@Bean(name = "chatBatchExecutor")
public Executor chatBatchExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(10000);
executor.setThreadNamePrefix("Chat-Consumer-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
ChatProcessService.java
@Async("chatBatchExecutor")
public void processMessageQueue() {
// [ ... ]
}
이렇게 Executor의 스레드 풀 크기를 1로 고정하여, 다수의 소비자가 발생하지 않도록 제한을 두었다. Service에서느 간단하게 @Async 어노테이션을 추가하여 설정할 수 있다. 이제 어떤 결과가 나왔을지 확인해보았다.
결과
@Async, MAX_SIZE=20
| 총 메시지 전송수 | 요청 처리 성공률 | HTTP 요청 시간(p95) | HTTP 요청 실패률 | 웹소켓 연결 시간 (p95) |
| 2,499 | 100% | 13.22s | 0.00% | 36.48s |
@Async, MAX_SIZE=30
| Run | 총 메시지 전송수 | 요청 처리 성공률 | HTTP 요청 시간(p95) | HTTP 요청 실패률 | 웹소켓 연결 시간 (p95) |
| 1 | 2,208 | 100% | 24.44s | 0.00% | 19.47s |
| 2 | 1,943 | 100% | 39.64s | 0.00% | 29.83s |
7. 결과 및 회고
| 설정 \ 배치 사이즈 | 10 | 20 | 30 |
| 기존 | 7.607943/s | 6.679315/s | |
| @Async | 11.898071/s | 10.512297/s |
그 결과 배치사이즈 20일 때, 최고의 TPS 지표가 도출되는 것을 확인했다. 결론적으로 채팅 쓰기 및 읽기 시나리오의 TPS 지표를 6.7/s에서 11.9/s로 약 77% 향상되었다.
현재는 간단하게 내부 메시지 큐를 도입하여 DB 힙 접근을 최소화하였다. 그러나 이런 문제의 가장 큰 원인은 서버에 장애가 발생할 경우 메시지가 유실될 수 있다는 것이다. 또한, 메모리에 메시지를 임시 저장하기 때문에 메시지를 저장할 수 있는 용량의 한계 또한 명확하다. 그 밖에도 대량의 트래픽이 발생했을 때, DB 힙에 접근하는 횟수도 증가할 수 밖에 없다.
문제를 해결한 이후, 추가적으로 미처 생각치 못한 부분이 있어 이 부분을 다음 포스팅에서 해결해보자.
'Spring' 카테고리의 다른 글
| [Spring Boot] AI 서빙에서 WebClient와 RestClient 비교 (0) | 2025.11.30 |
|---|---|
| [Spring Boot] Scale Out을 위한 Redis Pub/Sub 기반 공개 채팅 아키텍처 도입 (0) | 2025.11.30 |
| [Spring Boot] N+1 문제 해결 기술 비교 (FetchJoin, @EntityGraph, batch-size) (0) | 2025.11.30 |
| [Spring Boot] Soft Delete로 개인정보 보호하기 (0) | 2025.11.30 |
| [Test] 이럴 거면 테스트 코드를 왜 작성하는 거야? (0) | 2025.11.27 |