위에서 리팩토링 전의 전체 코드를 확인할 수 있습니다.
코드를 보면 아시겠지만 전체적으로 책임의 분리, 예외처리 일관성 부족, 동시성 이슈 등이 부족합니다.
채팅방을 만들었으니 이제는 채팅방을 조회하는 메서드들을 살펴보겠습니다.
관련 메서드는 openedRoom, findRoomById, findAllRoomByZipCode 이렇게 3가지가 존재합니다. 우선 openedRoom부터 개선해 보도록 하겠습니다.
openedRoom(전)
public List<ChatRoomResponse> openedRoom(ChatRoomStatus status, int page, int pageSize) {
Pageable pageable = PageRequest.of(page - 1, pageSize);
Page<ChatRoom> chatRoomEntityPage = chatRoomRepository.findAllByStatusOrderByCreatedAtDesc(status, pageable);
List<ChatRoom> chatRooms = chatRoomEntityPage.getContent();
return chatRooms.stream().map(chatRoomConverter::toResponse)
.map(it -> {
var currentNumber = chatRoomManager.getUserCountInChatRoom(it.getId());
it.setCurrentPeopleNumber(currentNumber);
return it;
})
.collect(Collectors.toList());
}
페이지네이션을 통해 채팅방 목록을 반환해 주는 코드입니다.
채팅방의 상태, 페이지 그리고 페이지 크기를 파라미터로 받고 있습니다. 반환타입은 리스트네요.
그리고 채팅방에 현재 있는 사용자 수를 파악해 함께 보내주고 있습니다.
ChatRoom 엔티티는 현재 채팅방의 참가자 수를 필드로 가지고 있지 않습니다.
1. 왜 반환타입이 Page가 아닌 List인가
페이지네이션을 할 때 반환타입으로는 Slice, Page, List 이렇게 세 종류를 주로 사용합니다.
Slice는 데이터의 조각을 나타냅니다. 다시 말해 전체 데이터셋의 크기를 알 필요 없이 일부 데이터만 다룹니다. 따라서 추가 count 쿼리 없이 다음 페이지만 확인이 가능하다. 이는 내부적으로 limit + 1 조회를 통해 처리됩니다. 예를 들어 size가 20인 Slice를 요청하면 내부적으로는 21개의 요소를 조회하려고 시도합니다. 이렇기에 Slice는 전체 페이지를 알 필요 없이 다음 페이지의 존재 여부만 필요한 경우 사용하면 좋습니다.
여기서 Page는 Slice를 확장해 전체 데이터셋에 대한 추가 정보를 제공합니다. 내부적으로는 JPQL이나 SQL로 변환되어 실행되며 전체 데이터셋을 다루고 있기 때문에 count 쿼리가 별도로 실행됩니다.
List는 단순히 요청된 페이지에 해당하는 데이터만을 List 형태로 반환하는 경우입니다.
Pageable 객체를 사용해 offset과 limit를 계산하는 것은 위의 두 방식과 동일합니다. Slice와 비슷하지만 Slice는 다음 페이지의 존재 여부를 알 수 있는 반면 List는 불가능합니다.
이제 어떤 반환 타입을 사용해야 할지 결정해야 합니다.
페이지네이션은 방금 말한 offset 기반 방식들이 있고 커서기반 방식이 있습니다. 하지만 오프셋 기반 페이지네이션은 마지막 페이지를 구하기 위해 전체 개수를 알아야 하기 때문에 데이터가 많아질수록 부하가 커집니다.
또한 offset만큼 데이터가 읽고 버려지기 때문에 이 또한 자원 낭비로 이어집니다.
이를 보완하기 위해 커서기반 페이지네이션이 있습니다. 이는 특정 키(커서)를 기반으로 그 위치부터 개수를 받아오는 형태입니다. 즉 키를 기반으로 데이터 탐색 범위를 최소화합니다. 하지만 페이지 번호로 이동하는 형태의 UI 구현이 어렵다는 단점이 있습니다. 따라서 모바일 환경의 무한스크롤과 같은 페이지네이션 환경에서 어울립니다.
저는 무한스크롤보다는 게시판처럼 좌우 페이지를 번호를 통해 이동할 수 있는 UI를 생각하고 있습니다.
과거 구현시에는 아마 count 쿼리 때문에 성능상 문제가 있을 것이라 생각하고 List를 선택했던 것 같습니다. 하지만 채팅방 검색의 경우 열려있는 상태의 채팅방만 검색이 되기 때문에 데이터셋이 그렇게까지 크지 않을 것이라고 예상됩니다. 또한 비즈니스적으로 UI 구성을 번호로 이동하는 방식을 채택했기 때문에 Page를 통해 전체 게시글의 count를 파악해야만 합니다.
따라서 반환 타입은 Page로 변경할 예정입니다.
2. chatRoomManager.getUserCountInChatRoom
현재 이 메서드를 통해서 현재 채팅방의 사용자 수를 불러오고 있습니다.
public Long getUserCountInChatRoom(Long roomId){
Long size = redisTemplate.opsForSet().size("roomId" + String.valueOf(roomId));
if (size != null) {
return size;
}
getAllUser(roomId);
return redisTemplate.opsForSet().size("roomId" + String.valueOf(roomId));
}
public Set<String> getAllUser(Long roomId){
try{
return redisTemplate.opsForSet().members("roomId" + String.valueOf(roomId));
} catch (NullPointerException e){
roomUserRepository.findAllByRoomIdAndStatus(roomId, ChatRoomUserStatus.NORMAL).stream()
.map(it -> {
var userNickname = it.getUserNickName();
redisTemplate.opsForSet().add("roomId" + String.valueOf(roomId),userNickname);
redisTemplate.expire(String.valueOf("roomId" + String.valueOf(roomId)), 3600*3, TimeUnit.SECONDS);
return null;
});
return redisTemplate.opsForSet().members("roomId" + String.valueOf(roomId));
}
}
보시다시피 레디스에 채팅방의 현재 사용자 수를 캐싱해서 쓰고 있습니다. 만약 레디스에 없다면 db 조회를 해 캐싱을 하고 있습니다.
그런데 채팅방의 현재 인원수는 아주 빈번하게 바뀌는 정보입니다. 그런 데이터를 캐싱하면 데이터 일관성이 깨지기 딱 좋습니다. 오히려 캐시를 매 번 갱신한다고 성능에 악영향을 줄 수도 있습니다.
따라서 이러한 방식의 캐싱은 사용해서는 안됩니다.
openedRoom(후)
public Page<ChatRoomResponse> getOpenedRooms(ChatRoomStatus status, Pageable pageable) {
return chatRoomRepository.findAllDtoByStatus(status, pageable);
}
사실 여기서 중요한 것은 해당 메서드보다는 chatRoomRepository의 findAllDtoByStatus입니다.
@Query(value = """
SELECT new com.ssafy.keepham.domain.chatroom.dto.ChatRoomResponse(
cr.id, cr.title, cr.status,
(SELECT COUNT(cru) FROM ChatRoomUser cru WHERE cru.chatRoom = cr AND cru.status = 'NORMAL'),
cr.maxPeopleNumber,
s.id, s.name,
b.id,
u.id, u.name,
cr.createdAt, cr.closedAt,
cr.locked, cr.extensionCount, cr.phase
)
FROM ChatRoom cr
JOIN cr.store s
JOIN cr.box b
JOIN cr.superUser u
WHERE cr.status = :status
""",
countQuery = """
SELECT COUNT(DISTINCT cr.id)
FROM ChatRoom cr
WHERE cr.status = :status
""")
Page<ChatRoomResponse> findAllDtoByStatus(@Param("status") ChatRoomStatus status, Pageable pageable);
위와 같은 방식으로 한 번에 조인과 서브쿼리를 통해 필요한 데이터를 불러와 dto로 생성해 반환하도록 하였습니다.
이를 통해 현재 인원수에 대한 데이터 일관성을 유지할 수 있었습니다.
또한 단일 쿼리로 조회하여 네트워크 및 데이터베이스 접근 횟수를 줄일 수 있었습니다.
'PROJECT > Keepham' 카테고리의 다른 글
채팅방 API 서버 - ChatRoomService 개선(1) (1) | 2024.09.30 |
---|---|
채팅방 API 서버 - 엔티티 개선(2) (0) | 2024.09.28 |
채팅방 API 서버 - 엔티티 개선(1) (0) | 2024.09.28 |
Keepham - 리팩토링 시작 (0) | 2024.09.27 |