Spring

Spring boot MongoDB Aggregation에서 sum 조건 정하기

비뀨_ 2023. 4. 19. 21:25

Spring boot와 MongoDB를 이용해 채팅방을 구현하는데, 

채팅방 목록을 구하는 API를 작성하려고 했다.

아래와 같은 정보 (채팅방 번호, 마지막 채팅 메세지, 안 읽은 메세지, 마지막 채팅시간) 을 가져오려고 한다.

 

 

좌 : ChatRoom, 우 : ChatMessage

근데, MongoDB가 처음이라 일단 만들면서 배우는거라 여기 깨지고 알아보는데 sum에 대한건 별로 없어서 고생 좀 해서 메모를 해본다! 

 

우선 sum에 대한 공식문서는 아래에서 볼 수 있다.

https://www.mongodb.com/docs/v4.2/reference/operator/aggregation/sum/

 

$sum (aggregation) — MongoDB Manual

Array Operand In the $group stage, if the expression resolves to an array, $sum treats the operand as a non-numerical value. In the other supported stages: With a single expression as its operand, if the expression resolves to an array, $sum traverses into

www.mongodb.com

 

명령어 자체에서 볼 수 있듯이 sum은 결과를 더해서 반환해준다. 

 

안 읽은 메세지를 처리하기 위해서는 ChatMessage에서 읽지 않고, 받은 회원의 번호가 '나'이고, isRead(읽음유무)가 false여야 한다.

sum에서도 표현식(expression)을 사용할 수 있기 때문에 조건문을 사용할 수있다.

 

조건문은 $cond를 통해 사용할 수 있다.

{ 
	$cond: { 
    	if: <boolean-expression>, then: <true-case>, else: <false-case> 
    } 
}
// 또는
{ 
	$cond: [ 
    	<boolean-expression>, <true-case>, <false-case> 
    ] 
}

cond는 조건문이 참일 때 <true-case>를 반환하고 거짓일 때 <false-case>를 반환한다.

 

아래는 Stage 5의 전문이다.

{
  _id: "$_id",
  participations: {
    $last: "$participations",
  },
  messages: {
    $last: "$messages",
  },
  notReadCount: {
    $sum: {
      $cond: [
        {
          $and: [
            {
              $eq: ["$messages.receiverId", 5],
            },
            {
              $eq: ["$messages.isRead", false],
            },
          ],
        },
        1,
        0,
      ],
    },
  },
}

 

읽어보면 조건문으로 

메세지 수신자가 5이고, 메시지가 읽지 않음이면 1을 반환하고, 아니면 0을 반환한다.

그걸 sum을 돌리면 결과는 다음과 같다.

 

결과문

 

채팅방에서 채팅메시지 정보를 뽑기 위해 사용한 Aggregation의 모든 이미지이다.

 

위의 Stage 1~5를 Spring boot로 사용해보자.

 

1. MongoDB의 Id는 ObjectId 형식이다. lookup을 할 때 String과 ObjectId는 형식이 다르기 때문에 참조키로 사용할 수 없기 때문에 변환해야 한다.

 

AddFieldsOperation objectRoomIdToString = AddFieldsOperation.builder().addField(roomId)
                .withValue(
                        ConvertOperators.ToString.toString("$" + roomId)
                ).build();

2. MatchOperation은 일치하는 항목을 선택할 때 사용한다. 채팅방이 "삭제 또는 차단"되지 않아야 하기 때문에 일치하는 것을 정해준다.

// and 괄호 안에는 컬럼명이 들어가야한다. enum으로 관리하고, 따로 변수로 빼놨기 때문에 변수로 사용한다.
// 원래는 String 형식의 칼럼명을 작성한다. is는 eq과 같다.
MatchOperation roomMatch = Aggregation.match(
                new Criteria()
                        .and(participation).is(userId)
                        .and(status).is(ChatStatus.NORMAL.name())
        		);

3. ChatRoom과 ChatMessage를 lookup(SQL에서의 Join) 하기 위해 정해준다.

// lookup("조인할 컬렉션명", "기준컬렉션의 ID", "조인할 컬렉션에서의 참조키ID")
LookupOperation lookupOperation = Aggregation.lookup(
                MessageColumn.COLLECTION_NAME.getWord(),
                RoomColumn.ID.getWord(),
                MessageColumn.ROOMID.getWord(),
                JOIN_AS
        );

4. 채팅방 1, 메세지 N 이기 때문에 배열 형식을 하나하나로 풀기 위해 사용한다.

// path는 lookup한 것의 이름을 적으면 되고, noArrayIndex()는 메세지가 없을 때에 빈 배열을 보여줄거냐라는 거다.
// 보여주기 싫으면 skipNullAndEmptyArrays()를 사용하면 됨
UnwindOperation unwindOperation = UnwindOperation.UnwindOperationBuilder
                                            .newBuilder().path("$"+ JOIN_AS)
                                            .noArrayIndex().preserveNullAndEmptyArrays();

5. unwind로 푼 것들을 묶기 위해서 사용한다. 

/**
   last는 가장 마지막의 것을 가져오게 된다. 나는 마지막 메세지를 찾고 싶어 last를 사용.
   sum안에 $cond는 아래와 같은 식으로 줄 수 있다. 
   Spring boot starter MongoDB 2.7.9 버전을 사용할 때에는 이렇게 사용한다.
   
*/
GroupOperation roomGrouping = group(roomId)
        .last(participation).as(participation)
        .last(JOIN_AS).as("lastMessage")
        .sum(
            ConditionalOperators.Cond.newBuilder().when(
                    Criteria.where(JOIN_AS + "." + isRead).is(false)
                    .and(JOIN_AS + "." + MessageColumn.RECEIVERID.getWord()).is(userId)
            ).then(1)
            .otherwise(0)
        ).as("notReadCount");

6. 메세지가 가장 최근에 이뤄진 채팅방을 맨 위로 올리기 위해서 마지막 메세지의 생성시간 역순으로 조회한다.

SortOperation latestSort = Aggregation.sort(Sort.Direction.DESC, "lastMessage." + createdAt);

 

 

마지막으로 해당 메서드의 전문을 첨부해보겠다!

// 채팅방 목록 Aggregation을 반환하는 메서드
private Aggregation makeRoomSearchAggregation(Long userId) {
        String JOIN_AS = "messages";
        String roomId = RoomColumn.ID.getWord();
        String participation = RoomColumn.PARTICIPATIONS.getWord();
        String status = RoomColumn.STATUS.getWord();
        String createdAt = MessageColumn.CREATEDAT.getWord();
        String isRead = MessageColumn.ISREAD.getWord();

        AddFieldsOperation objectRoomIdToString = AddFieldsOperation.builder().addField(roomId)
                .withValue(
                        ConvertOperators.ToString.toString("$" + roomId)
                ).build();
        MatchOperation roomMatch = Aggregation.match(
                new Criteria()
                        .and(participation).is(userId)
                        .and(status).is(ChatStatus.NORMAL.name())
        );
        LookupOperation lookupOperation = Aggregation.lookup(
                MessageColumn.COLLECTION_NAME.getWord(),
                RoomColumn.ID.getWord(),
                MessageColumn.ROOMID.getWord(),
                JOIN_AS
        );
        UnwindOperation unwindOperation = UnwindOperation.UnwindOperationBuilder
                                            .newBuilder().path("$"+ JOIN_AS)
                                            .noArrayIndex().preserveNullAndEmptyArrays();
        GroupOperation roomGrouping = group(roomId)
                .last(participation).as(participation)
                .last(JOIN_AS).as("lastMessage")
                .sum(
                    ConditionalOperators.Cond.newBuilder().when(
                            Criteria.where(JOIN_AS + "." + isRead).is(false)
                                    .and(JOIN_AS + "." + MessageColumn.RECEIVERID.getWord()).is(userId)
                    ).then(1)
                    .otherwise(0)
                ).as("notReadCount");
        SortOperation latestSort = Aggregation.sort(Sort.Direction.DESC, "lastMessage." + createdAt);

        return Aggregation.newAggregation(
                objectRoomIdToString, roomMatch, lookupOperation
                , unwindOperation, roomGrouping, latestSort
        );
    }
    
    @Override
    @Transactional(readOnly = true)
    public List<ChatRoomResDto> getList(Long userId) {
        List<ChatRoomResDto> resDtos = new ArrayList<>();
        // 사실상 이 makeRoomSearchAggregation()과 아래부분만 보면 된다
        Aggregation roomSearchAggregation = makeRoomSearchAggregation(userId);

        List<ChatRoomFindDto> roomFindDtos =
                mongoTemplate.aggregate(
                    roomSearchAggregation, // Aggregation 조건
                    RoomColumn.COLLECTION_NAME.getWord(), // 기준 컬레션 이름
                    ChatRoomFindDto.class // 반환받을 클래스
                ).getMappedResults();

        // ... 후략
    }