[Spring] WebSocket 채팅 데이터 캐싱 전략 With Redis 1편

한호성·2022년 10월 11일
0

Websocket With Redis

목록 보기
1/2

글의 목적

  1. Spring Boot stomp-websocket & spring-boot-starter-data-redis 라이브러리를 활용하여 만든 채팅이 기반이 된 글입니다.
  2. WebSocket을 통해 들어오는 채팅 데이터 캐싱 처리할 것인지에 대해 설명합니다.
  3. DataBase : MySQL , Cache : Redis 로 활용한 방식을 설명합니다.

데이터 쓰기 캐싱전략 : Write Back 방식

Write Back 방식의 데이터 흐름
1. Data Cache 저장
2. Cache에 있는 Data를 일정 기간동안 보관
3. 모여있는 Data를 DB에 저장
4. Cache에 있는 Data 삭제

쓰기 전략 대략적인 모식도

코드로 살펴보기

ChatMessageSaveDto.class

  • 채팅 데이터 객체DTO (데이터의 분류 및 채팅 데이터,작성자, 작성일자, 데이터)
public class ChatMessageSaveDto {

    public enum MessageType{
        ENTER,TALK,QUIT
    }

    private MessageType type;
    private String roomId;
    private String writer;
    private String nickname;
    private String message;
    private String createdAt;
    private List<String> userList;

}

ChatRedisCacheService.class

  • Redis에 채팅 데이터 넣어주는 Class
    ( Redis 의 Sorted set 활용 / Sort 방식 생성일자 -> Doule로 바꾼 후 사용)
@Service
@RequiredArgsConstructor
@Slf4j
public class ChatRedisCacheService {

    private final RedisTemplate<String, ChatMessageSaveDto> chatRedisTemplate;

	private final RedisTemplate<String, String> roomRedisTemplate;

    // Redis의 Chatting data caching 처리 
    public void addChat(ChatMessageSaveDto chatMessageSaveDto) {

        ChatMessageSaveDto savedData = 
        			ChatMessageSaveDto.createChatMessageSaveDto(chatMessageSaveDto);

        redisTemplate.opsForZSet()
        .add(
        	NEW_CHAT, 
        	savedData, 
        	chatUtils.changeLocalDateTimeToDouble(savedData.getCreatedAt()));
    }
    
    //채팅 데이터 생성일자(String) ->  Double 형으로 형변환
    public Double changeLocalDateTimeToDouble(String createdAt) {

        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss.SSS");
        LocalDateTime localDateTime = LocalDateTime.parse(createdAt, formatter);
        return ((Long) localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()).doubleValue();
    }

ChatWriteBackScheduling.class

  • 스케줄링을 통해 1시간마다 Redis에 있는 데이터를 List로 바꾼 후, Batch Insert를 통해 하나의 Query를 통해 데이터 MySQL에 Insert.

@Component
@RequiredArgsConstructor
@Slf4j
public class ChatWriteBackScheduling {

    private final RedisTemplate<String,Object> redisTemplate;

    private final RedisTemplate<String, ChatMessageSaveDto> chatRedisTemplate;

  
    private final ChatJdbcRepository chatJdbcRepository;
    private final WorkSpaceRepository workSpaceRepository;
    
    // 1시간 마다 Chatting data Redis -> MySQL
    @Scheduled(cron = "0 0 0/1 * * *")
    @Transactional
    public void writeBack(){
        log.info("Scheduling start");
  
        BoundZSetOperations<String, ChatMessageSaveDto> setOperations
        				= chatRedisTemplate.boundZSetOps("NEW_CHAT");

        ScanOptions scanOptions = ScanOptions.scanOptions().build();

        List<Chat> chatList = new ArrayList<>();
        try(Cursor<ZSetOperations.TypedTuple<ChatMessageSaveDto>> cursor
        			= setOperations.scan(scanOptions)){

            while(cursor.hasNext()){
                ZSetOperations.TypedTuple<ChatMessageSaveDto> chatMessageDto =cursor.next();

                WorkSpace workSpace= workSpaceRepository
                        .findById(Long.parseLong(chatMessageDto.getValue().getRoomId()))
                        .orElse(null);

                if(workSpace==null) {
                    continue;
                }

                chatList.add( Chat.of(chatMessageDto.getValue(),workSpace));
            }
            chatJdbcRepository.batchInsertRoomInventories(chatList);

            redisTemplate.delete("NEW_CHAT");

        }catch (Exception e){
            e.printStackTrace();
        }

        log.info("Scheduling done");
    }
}

데이터 읽기 캐싱전략 : Cache Aside 방식

Cache Aside
1. Redis에 해당하는 채팅 데이터가 있는지 확인
2. Redis에 해당하는 데이터가 없을 경우 DB에 추가조회 후,
3. 조회해 온 것을 다시 Redis에 올리는 방식
#cf ) 기존의 7일치 데이터를 매일 새벽 최신화 하는 방식을 추가로 사용

읽기 전략 대략적인 모식도

image

코드로 살펴보기

ChatPagingDto.class & ChatDataController.class

-Chatting data를 불러오기 위해 Cursor,채팅방 Id Frontend로 부터 받아온다.



public class ChatPagingDto {

    private String  message;
    private String  writer;
    private String  cursor;
}

public class ChatDataController {

    private final ChatRedisCacheService cacheService;


    @PostMapping("/api/chats/{workSpaceId}")
    public ResponseDto<List<ChatPagingResponseDto>> getChatting(
    		@PathVariable Long workSpaceId,
    		@RequestBody(required = false) ChatPagingDto chatPagingDto){

        //Cursor 존재하지 않을 경우,현재시간을 기준으로 paging
        if(chatPagingDto==null||
        chatPagingDto.getCursor()==null || 
        chatPagingDto.getCursor().equals(""))
        {
            chatPagingDto= ChatPagingDto.builder()
                    .cursor( LocalDateTime
                    .now()
                    .format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss.SSS")))
                    .build();
        }
        return cacheService.getChatsFromRedis(workSpaceId,chatPagingDto);
    }
}

ChatRedisCacheService.class

  • Redis에 Cursor를 확인하여 해당 cursor 이전 채팅을 11개씩 불러옴 만약 11개가 채워지지 않는다면, MySQL로부터 30개의 채팅 데이터를 불러오고, Redis caching 해둔다.

  public ResponseDto<List<ChatPagingResponseDto>> getChatsFromRedis(Long workSpaceId, 
  ChatPagingDto chatPagingDto) {

        //마지막 채팅을 기준으로 redis의 Sorted set에 몇번째 항목인지 파악
        ChatMessageSaveDto cursorDto = ChatMessageSaveDto.builder()
                .type(ChatMessageSaveDto.MessageType.TALK)
                .roomId(workSpaceId.toString())
                .createdAt(chatPagingDto.getCursor())
                .message(chatPagingDto.getMessage())
                .writer(chatPagingDto.getWriter())
                .build();


        //마지막 chat_data cursor Rank 조회
        Long rank = 
        		zSetOperations.reverseRank(CHAT_SORTED_SET_ + workSpaceId, cursorDto);

        //Cursor 없을 경우 -> 최신채팅 조회
        if (rank == null)
            rank = 0L;
        else rank = rank + 1;

        //Redis 로부터 chat_data 조회
        Set<ChatMessageSaveDto> chatMessageSaveDtoSet = 
        	zSetOperations.reverseRange(CHAT_SORTED_SET_ + workSpaceId, rank, rank + 10);

        List<ChatPagingResponseDto> chatMessageDtoList =
                chatMessageSaveDtoSet
                        .stream()
                        .map(ChatPagingResponseDto::byChatMessageDto)
                        .collect(Collectors.toList());

        //Chat_data 부족할경우 MYSQL 추가 조회
        if (chatMessageDtoList.size() != 10) {
            findOtherChatDataInMysql(chatMessageDtoList, workSpaceId, chatPagingDto.getCursor());
        }

        //redis caching 닉네임으로 작성자 삽입
        for (ChatPagingResponseDto chatPagingResponseDto : chatMessageDtoList) {
            chatPagingResponseDto
            .setNickname(findUserNicknameByUsername(chatPagingResponseDto.getWriter()));
        }

        return ResponseDto.success(chatMessageDtoList);
    }
    
    
        private void findOtherChatDataInMysql(
        List<ChatPagingResponseDto> chatMessageDtoList,
        Long workSpaceId, 
        String cursor) {

        String lastCursor;
        // 데이터가 하나도 없을 경우 현재시간을 Cursor로 활용
        if (chatMessageDtoList.size() == 0 && cursor == null) {
            ;
            lastCursor = LocalDateTime
            .now()
            .format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss.SSS"));
        }

        //redis 적재된 마지막 데이터를 입력했을 경우.
        else if (chatMessageDtoList.size() == 0 && cursor != null) {
            lastCursor = cursor;
        }

        // 데이터가 존재할 경우 CreatedAt을 Cursor로 사용
        else lastCursor = chatMessageDtoList.get(chatMessageDtoList.size() - 1).getCreatedAt();

        int dtoListSize = chatMessageDtoList.size();
        Slice<Chat> chatSlice =
                chatRepository
                        .findAllByCreatedAtBeforeAndWorkSpace_IdOrderByCreatedAtDesc(
                                lastCursor,
                                workSpaceId,
                                PageRequest.of(0, 30)
                        );

        for (Chat chat : chatSlice.getContent()) {
            cachingDBDataToRedis(chat);
        }


        //추가 데이터가 없을 때 return;
        if (chatSlice.getContent().isEmpty())
            return;

        //추가 데이터가 존재하다면, responseDto에  데이터 추가.
        for (int i = dtoListSize; i <= 10; i++) {
            try {
                Chat chat = chatSlice.getContent().get(i - dtoListSize);
                chatMessageDtoList.add(ChatPagingResponseDto.of(chat));
            } catch (IndexOutOfBoundsException e) {
                return;
            }
        }

    }
    

채팅 데이터 Scheduling 관련 코드링크
https://github.com/develkitProject/backend/tree/main/src/main/java/com/hanghae/final_project/global/util/scheduler
채팅 데이터 관련 로직 코드링크
https://github.com/develkitProject/backend/tree/main/src/main/java/com/hanghae/final_project/service/chat

Reference

https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EC%BA%90%EC%8B%9CCache-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%EC%A7%80%EC%B9%A8-%EC%B4%9D%EC%A0%95%EB%A6%AC#Cache_Aside_%ED%8C%A8%ED%84%B4_(Look_Aside)

profile
개발자 지망생입니다.

0개의 댓글