MongoDB는 데이터 정합성이 낮은 대신, MySQL보다 빠른 읽기/쓰기 속도를 제공한다.
그렇기 때문에 채팅처럼 입출력이 많을 때 사용하기 적합하다.
이번 글에서는 채팅 메시지를 MongoDB에 저장한 후, 채팅방별 최근 메시지를 조회해볼 것이다.
MongoDB 컬렉션 매핑하기
MySQL이 table을 사용한다면, MongoDB는 collection을 사용한다.
@Document로 MongoDB의 collection을 매핑하였다.
JPA Auditing을 사용하지 않으므로 createdAt을 직접 넣어주었다.
그런데 DB에서는 시간이 UTC 기준으로 저장되어서 불편했다.
이에 UTC로 변환된 값을 DB에 저장하고, 데이터를 가져올 때 다시 KST로 변환하는 코드를 추가하였다.
collection이 제대로 매핑됐는지 확인하고자 MongoDB Compass로 조회해보았다.

MongoDB는 ObjectId를 기본키로 사용한다.
_class는 스프링에서 객체를 MongoDB에 저장할 때 자동으로 추가하는 메타 데이터 필드이다.
채팅방별 최신 메시지 조회
채팅방 아이디 리스트에 속하는 채팅방들의 최신 메시지를 각각 조회해야 했다.
처음에는 JPQL로 구현하려고 했지만, JPQL은 RDBMS 전용 쿼리라 MongoDB에서 사용할 수 없었다.
다행히 MongoDBTemplate을 사용해 커스텀 커리를 작성할 수 있었다.
Aggregation Pipeline을 이용해 match->sort->group->project 순으로 집계하면, 채팅방별 최신 메시지를 빠르게 가져올 수 있다.
각각 SQL의 where, order by, group by, select 연산에 해당한다.
AggregationOperation match = Aggregation.match(Criteria.where("chatRoomId").in(chatRoomIds));
AggregationOperation sort = Aggregation.sort(Sort.Direction.DESC, "createdAt");
AggregationOperation group = Aggregation.group("chatRoomId")
.first("createdAt").as("createdAt")
.first("content").as("content")
.first("type").as("type");
AggregationOperation project = Aggregation.project()
.and("_id").as("chatRoomId")
.and("createdAt").as("createdAt")
.and("content").as("content")
.and("type").as("type");
Aggregation aggregation = Aggregation.newAggregation(sort, match, group, project);
AggregationResults<LatestChatMessage> results = mongoTemplate.aggregate(aggregation, "chat_messages",
LatestChatMessage.class);
return results.getMappedResults();
필터링, 정렬, 집계, 필드 추출을 한 번에 실행할 파이프라인을 만든다.
자세히 살펴보면 $match로 채팅방을 필터링 -> $sort로 최신순 정렬 → $group으로 id별 집계 → $first로 첫 번째 값만 추출 을 진행한다.
이후 컬렉션 객체에서 파이프라인을 실행하고, 자바 객체 리스트로 반환한다.
성능 비교
MongoDB에서는 최신순으로 정렬한 후 그룹별로 하나의 메시지만 조회할 수 있다.
반면 MySQL은 그룹화 전에 정렬을 수행할 수 없다.
따라서 서브쿼리로 각 채팅방 최신 메시지 시간을 조회한 후, 그 시간과 일치하는 메시지를 조회한다.
이를 QueryDSL로 나타내면 아래와 같다.
.select(new QLatestChatMessage(
cm.chatMessage
))
.from(cm)
.where(
cm.chatRoomId.in(chatRoomIds)
.and(cm.createdAt.eq(
JPAExpressions.select(cm2.createdAt.max())
.from(cm2)
.where(cm2.chatRoomId.eq(cm.chatRoomId))
))
)
.fetch();
50개의 채팅방의 최신 메시지를 조회하는 테스트를 진행하였다.
MongoDB 쿼리가 MySQL 쿼리보다 10배 정도 응답 시간이 빨랐다.

트러블슈팅
1. 문제: 최신순 조회가 되지 않았다.

원인: group by에서 $first연산을 수행하기 때문에 group 이후에 sort 연산을 수행하면 적용되지 않기 때문이었다.
해결: 맨 뒤에 있던 $sort 연산을 group 연산 전에 수행하여 해결하였다.
2. 문제: chatRoomId가 null로 조회되었다.

원인: 집계에서 사용한 필드값은 _id로 나오는데 chatRoomId로 조회해서 null로 나온 것이었다.
해결: _id로 조회하고 as()로 별칭을 지정해주었다.
AggregationOperation group = Aggregation.group("chatRoomId")
.first("createdAt").as("createdAt")
.first("content").as("content")
.first("type").as("type");
AggregationOperation project = Aggregation.project()
//.and("chatRoomId") null 발생
.and("_id")
.as("chatRoomId");
느낀점
MongoDB를 써보며 각 스테이지별로 연산을 분리할 수 있어서 가독성이 좋다는 생각이 들었다.
스테이지 내에서 인덱스를 사용할 수 있다는 데 기회가 된다면 배워서 적용해보고 싶다.