WebSocket & Stomp & Redis - 채팅방, 채팅 내역 유지

박영준·2023년 8월 22일
4

Spring

목록 보기
54/58

1. Cache

1) 정의

  • 나중에 요청할 결과를 미리 저장해둔 후 빠르게 서비스해 주는 것

    • 미리 결과를 저장하고, 나중에 요청이 오면 그 요청에 대해서 DB 또는 API를 참조하지 않고 Cache를 접근하여 요청을 처리
  • 이는 '파레토 법칙(80%의 결과는 20%의 원인으로 인해 발생한다)'과 관련된다.

    • 캐시는 모든 결과를 캐싱할 필요가 없으며, 서비스를 할 때 많이 사용되는 20%만 캐싱함으로써 전체적으로 효율을 끌어올릴 수 있다.

2) 필요성

사용자가 늘어나면 DB에 무리가 가기 시작한다.
매 트랜잭션마다 디스크에 접근해야하므로, 부하가 많아지면 성능이 떨어질 수 밖에 없다.

이 경우, 다음 방법들을 고려해볼 수 있다.

  • 스케일 인
  • 스케일 아웃
  • 캐시 서버 검토

3) 캐싱 전략

(1) look aside cache

내가 참여한 프로젝트에서 채팅 내역 조회 시 사용한 전략이다.

  1. Cache 에 데이터 존재 유무 확인

  2. Cache 에 데이터가 있다면, Cache 의 데이터 사용
    Cache 에 데이터가 없다면, Cache 의 실제 DB 데이터 사용

  3. DB 에서 가져 온 데이터를 Cache 에 저장

(2) write back

주로 쓰기 작업이 많아서, INSERT 쿼리를 일일이 날리지 않고 한꺼번에 여러 개의 INSERT 쿼리를 날려서 배치 처리를 하기 위해 사용한다.

컴퓨터로 시험을 진행할 경우, 많은 수험생들이 한꺼번에 시험지 제출 버튼을 누르면 DB 에 쓰기 요청이 몰리게되어 DB가 죽을 수도 있다.
이때, write back 은 캐시 메모리에 데이터를 저장해 두고, 이후에 DB 디스크에 업데이트를 하므로 좀 더 안전하게 쓰기 작업을 할 수 있다.

단, 메모리 공간에 머무르는 동안 서버에 장애가 발생하여 다운된다면 데이터가 손실될 수 있다는 단점이 있다.

  1. 모든 데이터를 Cache 에 저장

  2. Cache 의 데이터를 일정 주기마다 DB 에 한꺼번에 저장 (배치)

  3. DB 에 저장한 데이터를 Cache 에서 제거

2. Redis

1) 정의

  • "Remote Dictionary Server"

  • 외부에서 사용 가능한 Key-Value 쌍의 해시 맵 형태의 서버

    • 따라서, 별도의 쿼리 없이 Key 를 통해 빠르게 결과를 가져올 수 있다.
  • NoSQL 의 일종이다

2) 특징

(1) 빠른 작업 속도

  • 디스크에 데이터를 쓰는 구조가 아니라 메모리에서 데이터를 처리하기 때문에, 작업 속도가 빠르다.

(2) 다양한 자료 구조를 지원

  • Key가 될 수 있는 데이터 구조체가 다양하다.
    • 따라서, 개발의 편의성이 좋아지고 난이도가 낮아진다는 장점이 있다.

(3) 인메모리

  • 정의

    • 컴퓨터의 주기억장치인 RAM에 데이터를 올려서 사용하는 방법
      • RAM에 데이터를 저장하면 메모리 내부에서 처리가 되므로, 데이터를 저장/조회할 때 하드디스크를 오고 가는 과정을 거치지 않아도 된다.
      • SSD,HDD 같은 저장공간에서 데이터를 가져오는 것보다 RAM 에서 데이터를 가져오는 속도가 많게는 수백배 이상 빠르다.
  • 단점

    • 서버의 메모리 용량을 초과하는 데이터를 redis에서 처리할 경우, 용량으로 인해 데이터 유실이 발생하고 서버 자체에도 문제가 생길 수 있다.
    • Ram 의 특성인 휘발성에 따라 Ram 에 저장되었던 레디스의 데이터들은 유실될 수 있다.
      (휘발성 : 전원이 꺼지면 가지고 있던 데이터가 사라진다)

(4) 영속성

Redis 는 인메모리를 사용하나, 단점인 휘발성을 보완하는 영속성을 가질 수 있다.

  • Redis 는 영속성을 지원하는 인 메모리 데이터 저장소

    • 영속성을 보장하기 위해, 데이터를 디스크에 저장할 수 있다.
    • 서버가 내려가더라도, 디스크에 저장된 데이터를 읽어서 메모리에 로딩한다.
  • 데이터를 디스크에 저장하는 2 가지 방식

    • RDB (Snapshotting) 방식

      • 순간적으로 메모리에 있는 내용 전체를 디스크에 옮겨 담는 방식
    • AOF (Append On File) 방식

      • Redis의 모든 write/update 연산 자체를 모두 log 파일에 기록하는 형태

(5) 싱글 쓰레드

  • 한 번에 하나의 명령만 처리할 수 있습니다.

  • 따라서, 중간에 처리 시간이 긴 명령어가 들어오면 그 뒤에 명령어들은 모두 앞에 있는 명령어가 처리될 때까지 대기하게 된다.
    (단, get, set 명령어의 경우 초당 10만 개 이상 처리할 수 있을 만큼 빠르다)

  • 이 때문에, Race Condition 를 방지할 수 있다.

    • Race Condition 는 두 개 이상의 프로세스가 공통 자원을 병행적으로 읽기/쓰기 동작 시, 공용 데이터에 대한 접근의 순서에 따라 실행 결과가 달라지는 상황
      (즉, 두 개의 스레드가 하나의 자원을 놓고 서로 사용하려고 경쟁하는 상황)

3. 구현

1) 세팅하기

(1) 의존성 추가

dependencies {
	//embedded-redis
    implementation group: 'it.ozimov', name: 'embedded-redis', version: '0.7.2'
    // redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
  • embedded-redis 는 Redis 설치 없이 간단하게 로컬에서 사용해 볼 수 있도록 해준다.
    • 원래라면, Git Bash 에 Redis 를 설치해줘야 한다.

(2) application.properties

spring.profiles.include=key

(3) application-key.properties

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.database=0
  • database=0 은 데이터베이스의 인덱스를 설정하는데, 0으로 설정돼 있으면 첫 번째 데이터베이스에 해당

2) Config

(1) EmbeddedRedisConfig

@Profile("local")       // 1.
@Configuration
public class EmbeddedRedisConfig {

    @Value("${spring.data.redis.port}")
    private int redisPort;

    private RedisServer redisServer;        // redis 데이터베이스 서버

    // redis 서버 초기화 및 시작
    @PostConstruct
    public void redisServer() {
        redisServer = new RedisServer(redisPort);
        redisServer.start();
    }

    // redis 서버 중지. container 에서 빈 제거 전 실행
    @PreDestroy
    public void stopRedis() {
        if (redisServer != null) {
            redisServer.stop();
        }
    }
}

1.

  • local 환경에서만 실행되도록 설정
  • 로컬 환경일 경우, 채팅 서버가 실행될 때 내장형 레디스(Embedded Redis)도 동시에 실행된다

(2) RedisConfig

@Configuration
public class RedisConfig {

    // redis 연결, redis 의 pub/sub 기능을 이용하기 위해 pub/sub 메시지를 처리하는 MessageListener 설정(등록)
    @Bean
    public RedisMessageListenerContainer redisMessageListener(RedisConnectionFactory connectionFactory) {		// 1.
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);		// 2.

        return container;
    }

    // Redis 데이터베이스와의 상호작용을 위한 RedisTemplate 을 설정. JSON 형식으로 담기 위해 직렬화
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());        // Key Serializer
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));      // Value Serializer

        return redisTemplate;
    }

    // Redis 에 메시지 내역을 저장하기 위한 RedisTemplate 을 설정
    @Bean
    public RedisTemplate<String, MessageDto> redisTemplateMessage(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, MessageDto> redisTemplateMessage = new RedisTemplate<>();
        redisTemplateMessage.setConnectionFactory(connectionFactory);
        redisTemplateMessage.setKeySerializer(new StringRedisSerializer());        // Key Serializer
        redisTemplateMessage.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));      // Value Serializer

        return redisTemplateMessage;
    }
}

1.

  • RedisConnectionFactory : Redis 서버와의 연결을 생성하고 관리하는 데 사용

2.

  • setConnectionFactory : RedisMessageListenerContainer 에 사용할 Redis 연결 팩토리(RedisConnectionFactory)를 설정

3) RedisPublisher

@Service
@RequiredArgsConstructor
public class RedisPublisher {
    private final RedisTemplate<String, Object> redisTemplate;

    // Redis Topic 에 메시지 발행.  메시지를 발행 후, 대기 중이던 redis 구독 서비스(RedisSubscriber)가 메시지를 처리
    public void publish(ChannelTopic topic, MessageDto message) {
        redisTemplate.convertAndSend(topic.getTopic(), message);
    }
}

4) RedisSubscriber

@Service
@RequiredArgsConstructor
public class RedisSubscriber implements MessageListener {		// 1.
    private final ObjectMapper objectMapper;
    private final RedisTemplate redisTemplate;
    private final SimpMessageSendingOperations messagingTemplate;

    // 2. Redis 에서 메시지가 발행(publish)되면, listener 가 해당 메시지를 읽어서 처리
    @Override
    public void onMessage(Message message, byte[] pattern) {		// 3.
        try {
            // redis 에서 발행된 데이터를 받아 역직렬화
            String publishMessage = (String) redisTemplate.getStringSerializer().deserialize(message.getBody());

            // 4. 해당 객체를 MessageDto 객체로 맵핑
            MessageDto roomMessage = objectMapper.readValue(publishMessage, MessageDto.class);

            // 5. Websocket 구독자에게 채팅 메시지 전송
            messagingTemplate.convertAndSend("/sub/chat/room/" + roomMessage.getRoomId(), roomMessage);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }
}

1.

  • MessageListener
    • Redis 에 메시지가 발행될 때까지 대기하다가, 메시지가 발행되면 해당 메시지를 읽어서 처리해주는 리스너

2.

  • RedisConfig 클래스에서 redis 의 pub/sub 기능을 위해 MessageListener 설정을 추가했었음

3.

  • onMessage : Redis 의 Pub/Sub 구독자로부터 메시지를 수신할 때마다 호출됨
  • pattern : Redis 에서 메시지를 수신한 패턴(특정 채널 이름)

4.

  • objectMapper : JSON 데이터를 Java 객체로 변환

5.

  • "/sub/chat/room/{roomId}" 로 특정 쪽지방에 메시지를 전송
  • 프론트 쪽에서도 해당 url 로 특정 쪽지방에 입장할 수 있다.

5) Controller

(1) MessageController

@Controller
@RequiredArgsConstructor
public class MessageController {
    private final RedisPublisher redisPublisher;
    private final MessageRoomService messageRoomService;
    private final MessageService messageService;

    // 대화 & 대화 저장
    @MessageMapping("/message")     // 1.
    public void message(MessageDto messageDto) {
        // 클라이언트의 쪽지방(topic) 입장, 대화를 위해 리스너와 연동
        messageRoomService.enterMessageRoom(messageDto.getRoomId());

        // Websocket 에 발행된 메시지를 redis 로 발행. 해당 쪽지방을 구독한 클라이언트에게 메시지가 실시간 전송됨 (1:N, 1:1 에서 사용 가능)
        redisPublisher.publish(messageRoomService.getTopic(messageDto.getRoomId()), messageDto);

        // DB & Redis 에 대화 저장
        messageService.saveMessage(messageDto);
    }

    // 대화 내역 조회
    @GetMapping("/api/room/{roomId}/message")
    public ResponseEntity<List<MessageDto>> loadMessage(@PathVariable String roomId) {
        return ResponseEntity.ok(messageService.loadMessage(roomId));
    }
}

1.

  • websocket "/pub/message" 로 들어오는 메시지를 처리
  • @SendTo("/topic/message") 는 특정 채팅방을 구독한 클라이언트들에게 메시지가 전송되는데
    • 나의 경우 해당 어노테이션을 사용하지 않고도, Redis 를 이용한 pub/sub 로 특정 채팅방(roomId)으로 메시지를 전송할 수 있었다.

(2) MessageRoomController

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class MessageRoomController {
    private final MessageRoomService messageRoomService;

    // 쪽지방 생성
    @PostMapping("/room")
    public MessageResponseDto createRoom(@RequestBody MessageRequestDto messageRequestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return messageRoomService.createRoom(messageRequestDto, userDetails.getUser());
    }

    // 사용자 관련 쪽지방 전체 조회
    @GetMapping("/rooms")
    public List<MessageResponseDto> findAllRoomByUser(@AuthenticationPrincipal UserDetailsImpl userDetails) {
        return messageRoomService.findAllRoomByUser(userDetails.getUser());
    }

    // 사용자 관련 쪽지방 선택 조회
    @GetMapping("room/{roomId}")
    public MessageRoomDto findRoom(@PathVariable String roomId, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return messageRoomService.findRoom(roomId, userDetails.getUser());
    }

    // 쪽지방 삭제
    @DeleteMapping("room/{id}")
    public MsgResponseDto deleteRoom(@PathVariable Long id, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return messageRoomService.deleteRoom(id, userDetails.getUser());
    }
}

6) Service

(1) MessageService

@Slf4j
@Service
@RequiredArgsConstructor
public class MessageService {
    private final RedisTemplate<String, MessageDto> redisTemplateMessage;
    private final MessageRepository messageRepository;
    private final MessageRoomRepository messageRoomRepository;

    // 대화 저장
    public void saveMessage(MessageDto messageDto) {
        // DB 저장
        Message message = new Message(messageDto.getSender(), messageDto.getRoomId(), messageDto.getMessage());
        messageRepository.save(message);

        // 1. 직렬화
        redisTemplateMessage.setValueSerializer(new Jackson2JsonRedisSerializer<>(Message.class));

        // 2. redis 저장
        redisTemplateMessage.opsForList().rightPush(messageDto.getRoomId(), messageDto);

        // 3. expire 을 이용해서, Key 를 만료시킬 수 있음
        redisTemplateMessage.expire(messageDto.getRoomId(), 1, TimeUnit.MINUTES);
    }

    // 6. 대화 조회 - Redis & DB
    public List<MessageDto> loadMessage(String roomId) {
        List<MessageDto> messageList = new ArrayList<>();

        // Redis 에서 해당 채팅방의 메시지 100개 가져오기
        List<MessageDto> redisMessageList = redisTemplateMessage.opsForList().range(roomId, 0, 99);

        // 4. Redis 에서 가져온 메시지가 없다면, DB 에서 메시지 100개 가져오기
        if (redisMessageList == null || redisMessageList.isEmpty()) {
            // 5.
            List<Message> dbMessageList = messageRepository.findTop100ByRoomIdOrderByCreatedAtAsc(roomId);
            
            for (Message message : dbMessageList) {
            	MessageDto messageDto = new MessageDto(message);
                messageList.add(messageDto);
                redisTemplateMessage.setValueSerializer(new Jackson2JsonRedisSerializer<>(Message.class));      // 직렬화
                redisTemplateMessage.opsForList().rightPush(roomId, messageDto);                                // redis 저장
            }
        } else {
            // 7.
            messageList.addAll(redisMessageList);
        }

        return messageList;
    }
}

1.
Message 클래스(테이블)의 형식에 맞춰야 저장되므로, Message 형태로 분해(직렬화) 재조립(역직렬화)한 것!

  • setValueSerializer : redis 에 저장되기 전, 값들을 직렬화
  • new Jackson2JsonRedisSerializer<>(Message.class) : Java 객체를 JSON 형식으로 직렬화하고, Jackson JSON 라이브러리로 역직렬화
  • 참고: Redis 사용 시, 역직렬화 에러

2.

  • list 의 오른쪽 끝에 메시지 데이터를 추가
  • 오래된 메시지는 왼쪽. 최신 메시지 일수록 오른쪽

3.

  • redis 에 저장된 쪽지방 및 쪽지 내역들은 1시간마다 삭제됨 --> 즉, DB 에만 남아있게 됨

4.

  • redisMessageList == null
    • Redis 에서 메시지를 가져오는데 실패한 경우, null 반환
    • redisMessageList == null 조건식이 없다면, Redis 에서 메시지를 가져오지 못한 경우에 대한 처리가 누락될 수 있다.

5.
findTop100ByRoomIdOrderByCreatedAtAsc

  • findTop100 : 최대 100개의 데이터를 조회
  • OrderByCreatedAtAsc : 생성된 순서로 오름차순 = 생성시간이 빠른 순서부터 정렬

6.
캐싱 전략 中 look aside cache 를 사용했다.
1. Redis 에서 해당 채팅방의 메시지 100개 가져오기
2. Cache 에 데이터가 있다면(즉, Redis 에 데이터가 있다면), Cache 의 데이터 사용
Redis 에서 가져온 메시지가 없다면, DB 에서 메시지 100개 가져오기
3. for 문 내부에서는 DB 에서 조회해 온 메시지들을 다시 Redis 에 저장한다.
(단, 이때 직렬화를 통해 데이터가 저장될 수 있는 형태로 만들어줘야 한다)

7.

  • add : 연결리스트에서 맨 뒤에 새 노드를 연결시키는 것과 같다. -> 뒤쪽으로 데이터가 쌓이고, 순서를 가진다.
  • addAll : ArrayList 에 다른 ArrayList 의 데이터를 통째로 붙이기 위한 메서드

(2) MessageRoomService

@Slf4j
@Service
@RequiredArgsConstructor
public class MessageRoomService {
    private final MessageRoomRepository messageRoomRepository;
    private final MessageRepository messageRepository;

    // 쪽지방(topic)에 발행되는 메시지 처리하는 리스너
    private final RedisMessageListenerContainer redisMessageListener;

    // 구독 처리 서비스
    private final RedisSubscriber redisSubscriber;

    // 1. redis
    private static final String Message_Rooms = "MESSAGE_ROOM";
    private final RedisTemplate<String, Object> redisTemplate;
    private HashOperations<String, String, MessageRoomDto> opsHashMessageRoom;

    // 2. 쪽지방의 대화 메시지 발행을 위한 redis topic(쪽지방) 정보
    private Map<String, ChannelTopic> topics;

    // 3. redis 의 Hash 데이터 다루기 위함
    @PostConstruct
    private void init() {
        opsHashMessageRoom = redisTemplate.opsForHash();
        topics = new HashMap<>();
    }

    // 쪽지방 생성
    public MessageResponseDto createRoom(MessageRequestDto messageRequestDto, User user) {
        Post post = postRepository.findById(messageRequestDto.getPostId()).orElseThrow(
                () -> new IllegalArgumentException("게시글을 찾을 수 없습니다.")
        );

		// 4.
        MessageRoom messageRoom = messageRoomRepository.findBySenderAndReceiver(user.getNickname(), messageRequestDto.getReceiver());

        // 5. 처음 쪽지방 생성 또는 이미 생성된 쪽지방이 아닌 경우
        if ((messageRoom == null) || (messageRoom != null && (!user.getNickname().equals(messageRoom.getSender()) && !messageRequestDto.getReceiver().equals(messageRoom.getReceiver()) && !messageRequestDto.getPostId().equals(post.getId())))) {
            MessageRoomDto messageRoomDto = MessageRoomDto.create(messageRequestDto, user);
            opsHashMessageRoom.put(Message_Rooms, messageRoomDto.getRoomId(), messageRoomDto);      // redis hash 에 쪽지방 저장해서, 서버간 채팅방 공유
            messageRoom = messageRoomRepository.save(new MessageRoom(messageRoomDto.getId(), messageRoomDto.getRoomName(), messageRoomDto.getSender(), messageRoomDto.getRoomId(), messageRoomDto.getReceiver(), user, post));

            return new MessageResponseDto(messageRoom);
        // 6. 이미 생성된 쪽지방인 경우
        } else {
            return new MessageResponseDto(messageRoom.getRoomId());
        }
    }

    // 7. 사용자 관련 쪽지방 전체 조회
    public List<MessageResponseDto> findAllRoomByUser(User user) {
        List<MessageRoom> messageRooms = messageRoomRepository.findByUserOrReceiver(user, user.getNickname());      // sender & receiver 모두 해당 쪽지방 조회 가능 (1:1 대화)

        List<MessageResponseDto> messageRoomDtos = new ArrayList<>();

        for (MessageRoom messageRoom : messageRooms) {
            //  user 가 sender 인 경우
            if (user.getNickname().equals(messageRoom.getSender())) {
                MessageResponseDto messageRoomDto = new MessageResponseDto(
                        messageRoom.getId(),
                        messageRoom.getReceiver(),        // roomName
                        messageRoom.getRoomId(),
                        messageRoom.getSender(),
                        messageRoom.getReceiver());

                // 8. 가장 최신 메시지 & 생성 시간 조회
                Message latestMessage = messageRepository.findTopByRoomIdOrderByCreatedAtDesc(messageRoom.getRoomId());
                if (latestMessage != null) {
                    messageRoomDto.setLatestMessageCreatedAt(latestMessage.getCreatedAt());
                    messageRoomDto.setLatestMessageContent(latestMessage.getMessage());
                }

                messageRoomDtos.add(messageRoomDto);
            // user 가 receiver 인 경우
            } else {
                MessageResponseDto messageRoomDto = new MessageResponseDto(
                        messageRoom.getId(),
                        messageRoom.getSender(),        // roomName
                        messageRoom.getRoomId(),
                        messageRoom.getSender(),
                        messageRoom.getReceiver());

                // 가장 최신 메시지 & 생성 시간 조회
                Message latestMessage = messageRepository.findTopByRoomIdOrderByCreatedAtDesc(messageRoom.getRoomId());
                if (latestMessage != null) {
                    messageRoomDto.setLatestMessageCreatedAt(latestMessage.getCreatedAt());
                    messageRoomDto.setLatestMessageContent(latestMessage.getMessage());
                }

                messageRoomDtos.add(messageRoomDto);
            }
        }

        return messageRoomDtos;
    }

    // 사용자 관련 쪽지방 선택 조회
    public MessageRoomDto findRoom(String roomId, User user) {
        MessageRoom messageRoom = messageRoomRepository.findByRoomId(roomId);

        // 게시글 조회
        Post post = postRepository.findById(messageRoom.getPost().getId()).orElseThrow(
                () -> new IllegalArgumentException("게시글이 존재하지 않습니다.")
        );

        // 사용자 조회
        User receiver = userRepository.findById(post.getUser().getId()).orElseThrow(
                () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
        );

        // 9. sender & receiver 모두 messageRoom 조회 가능
        messageRoom = messageRoomRepository.findByRoomIdAndUserOrRoomIdAndReceiver(roomId, user, roomId, receiver.getNickname());
        if (messageRoom == null) {
            throw new IllegalArgumentException("쪽지방이 존재하지 않습니다.");
        }

        MessageRoomDto messageRoomDto = new MessageRoomDto(
                messageRoom.getId(),
                messageRoom.getRoomName(),
                messageRoom.getRoomId(),
                messageRoom.getSender(),
                messageRoom.getReceiver());

        messageRoomDto.setMessageRoomPostId(post.getId());
        messageRoomDto.setMessageRoomCategory(post.getCategory().getValue());		// getValue() : category 는 int 타입이었기 때문. 없다면 String 타입으로 반환됨
        messageRoomDto.setMessageRoomCountry(post.getCountry());
        messageRoomDto.setMessageRoomTitle(post.getTitle());

        return messageRoomDto;
    }

    // 10. 쪽지방 삭제
    public MsgResponseDto deleteRoom(Long id, User user) {
        MessageRoom messageRoom = messageRoomRepository.findByIdAndUserOrIdAndReceiver(id, user, id, user.getNickname());

        // sender 가 삭제할 경우
        if (user.getNickname().equals(messageRoom.getSender())) {
            messageRoomRepository.delete(messageRoom);
            opsHashMessageRoom.delete(Message_Rooms, messageRoom.getRoomId());
        // receiver 가 삭제할 경우
        } else if (user.getNickname().equals(messageRoom.getReceiver())) {
            messageRoom.setReceiver("Not_Exist_Receiver");
            messageRoomRepository.save(messageRoom);
        }

        return new MsgResponseDto("쪽지방을 삭제했습니다.", HttpStatus.OK.value());
    }

    // 쪽지방 입장
    public void enterMessageRoom(String roomId) {
        ChannelTopic topic = topics.get(roomId);

        if (topic == null) {
            topic = new ChannelTopic(roomId);
            redisMessageListener.addMessageListener(redisSubscriber, topic);        // pub/sub 통신을 위해 리스너를 설정. 대화가 가능해진다
            topics.put(roomId, topic);
        }
    }

    // redis 채널에서 쪽지방 조회
    public ChannelTopic getTopic(String roomId) {
        return topics.get(roomId);
    }
}

1.

  • private static final String Message_Rooms = "MESSAGE_ROOM";

    • Redis 에서 채팅방 정보를 저장하기 위한 해시맵의 키
    • 이 키를 사용하여 채팅방 정보를 Redis 에서 조회 및 저장
  • private final RedisTemplate<String, Object> redisTemplate;

    • Redis 와 상호 작용하기 위한 RedisTemplate
  • private HashOperations<String, String, MessageRoomDto> opsHashMessageRoom;

    • opsHashMessageRoom : RedisTemplate 을 사용하여 Redis 해시(Hash) 데이터 구조에 접근하기 위한 HashOperations
    • 해시맵에 채팅방 정보를 저장하고 조회하기 위해 사용

2.

  • 서버 별로 쪽지방에 매치되는 topic 정보를 Map 에 넣어서, roomId 로 찾음

3.

  • @PostConstruct : 해당 빈이 생성된 후에 해당 메서드가 자동으로 호출됩니다. 초기화 작업을 수행하는 데 사용
  • opsHashMessageRoom = redisTemplate.opsForHash() : RedisTemplate 을 사용하여 Hash 데이터 구조에 접근하기 위한 HashOperations 객체를 초기화 (opsHashMessageRoom 필드를 초기화)
  • topics = new HashMap<>() : HashMap 객체를 초기화하여 topics 필드를 초기화

이후는 원래 현재 프로젝트에서 내가 한거로

4.

  • 일반적으로 1:N 채팅방을 구현하는 것이 아닌, 다른 사람들은 들어올 수 없는 1:1 대화방 구성해야 했다.
  • 따라서, sender 와 receiver 를 정해서, 이들을 DB 에서 조회해왔다.

5.

  • sender 와 receiver 둘 다 돌일한 경우엔 한 게시글에 하나의 채팅방만 생성되도록 해야 했다.

6.

  • 이미 생성된 쪽지방일 경우, roomId 를 반환하도록 해서 쪽지방이 중복 생성되지 않고 기존에 만들어진 쪽지방으로 이동하도록 해야 했다.

7.

  • 쪽지방을 생성한 user 가 sender 와 receiver 인 경우로 나눴다. (카카오톡의 채팅방을 생각해보면)
    • sender 에게는 해당 쪽지방의 이름이 receiver 의 이름으로 보이도록
    • receiver 에게는 해당 쪽지방의 이름이 sender 의 이름을 보이도록

8.

  • TimeStamped 클래스에서 설정해둔 생성시간(createdAt) 을 통해, 각 채팅방에서 가장 최근 메시지와 그 메시지가 보내진 시간을 꺼내왔다.

9.

  • sender 와 receiver 만 해당 쪽지방을 조회하도록 했다.
    • 처음에는 sender 와 receiver 각각 채팅방을 생성하도록 해야하는지 고민했으나, 이 경우 동일한 방이 DB에 2개 생성/저장 되어버린다.
    • 결국, 조회 가능/불가능으로 user 의 해당 채팅방으로의 접근 권한을 구별해주면 되는 것이었다.

10.

  • sender 가 삭제할 경우, Redis 및 DB 모두에서 삭제
  • receiver 가 삭제할 경우, 삭제되는 것이 아니라 해당 채팅방의 receiver 컬럼의 데이터정보를 receiver 자신의 nickname 이 아닌 "Not_Exist_Receiver" 로 변경하여 자신은 더이상 해당 채팅방을 조회하지 못하도록 했다.

7) DTO

(1) MessageDto

@Getter
@Setter
@NoArgsConstructor
public class MessageDto {
    private String sender;
    private String roomId;
    private String message;
    private String sentTime;

	// 대화 조회
    public MessageDto(Message message) {
        this.sender = message.getSender();
        this.roomId = message.getRoomId();
        this.message = message.getMessage();
    }
}

(2) MessageRequestDto

@Getter
@NoArgsConstructor
public class MessageRequestDto {
    private String receiver;    // 메세지 수신자
    private Long postId;        // 게시글 id
}
  • 채팅방 생성 시 postId 로 함께 요청으로 보내줌으로써, 해당 채팅방 정보에 postId 에 대한 정보도 담도록 했다.
    • 게시글과 채팅방을 1:1 연관관계를 맺어, 채팅방 내에서도 게시글의 제목 등...을 확인하고 제목을 눌렀을 때 해당 게시글로 바로 이동할 수 있도록 하기 위함

(3) MessageResponseDto

@Getter
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MessageResponseDto {
    private Long id;
    private String roomName;
    private String sender;
    private String roomId;
    private String receiver;
    private Long postId;
    private String message;
    private LocalDateTime createdAt;

    // 쪽지방 생성
    public MessageResponseDto(MessageRoom messageRoom) {
        this.id = messageRoom.getId();
        this.roomName = messageRoom.getRoomName();
        this.sender = messageRoom.getSender();
        this.roomId = messageRoom.getRoomId();
        this.receiver = messageRoom.getReceiver();
        this.postId = messageRoom.getPost().getId();
    }

    // 사용자 관련 쪽지방 전체 조회
    public MessageResponseDto(Long id, String roomName, String roomId, String sender, String receiver) {
        this.id = id;
        this.roomName = roomName;
        this.roomId = roomId;
        this.sender = sender;
        this.receiver = receiver;
    }

    public MessageResponseDto(String roomId) {
        this.roomId = roomId;
    }

    public void setLatestMessageContent(String message) {
        this.message = message;
    }

    public void setLatestMessageCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }
}

(4) MessageRoomDto

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MessageRoomDto implements Serializable {       // Redis 에 저장되는 객체들이 직렬화가 가능하도록

    private static final long serialVersionUID = 6494678977089006639L;      // 역직렬화 위한 serialVersionUID 세팅
    private Long id;
    private String roomName;
    private String roomId;
    private String sender;     // 메시지 송신자
    private String receiver;   // 메시지 수신자
    private Long postId;
    private int category;      // 게시글 카테고리
    private String title;       // 게시글 제목
    private String country;     // 게시글 나라

    // 쪽지방 생성
    public static MessageRoomDto create(MessageRequestDto messageRequestDto, User user) {
        MessageRoomDto messageRoomDto = new MessageRoomDto();
        messageRoomDto.roomName = messageRequestDto.getReceiver();
        messageRoomDto.roomId = UUID.randomUUID().toString();
        messageRoomDto.sender = user.getNickname();
        messageRoomDto.receiver = messageRequestDto.getReceiver();

        return messageRoomDto;
    }

    // 사용자 관련 쪽지방 선택 조회
    public MessageRoomDto(Long id, String roomName, String roomId, String sender, String receiver) {
        this.id = id;
        this.roomName = roomName;
        this.roomId = roomId;
        this.sender = sender;
        this.receiver = receiver;
    }

    public void setMessageRoomPostId(Long postId) {
        this.postId = postId;
    }
    public void setMessageRoomCategory(int category) {
        this.category = category;
    }

    public void setMessageRoomTitle(String title) {
        this.title = title;
    }

    public void setMessageRoomCountry(String country) {
        this.country = country;
    }
}
  • set 메서드로 설정되는 데이터들은 쪽지방 선택 조회 시, 프론트 쪽으로 postId 와 해당 게시글의 category, title, country 를 넘겨주기 위함이다.
    • servcie 단에서 postRepository 를 통해 조회한 데이터들을 담아서 그대로 redponse 로 반환해줄 뿐이다.

8) Entity

(1) Message

@Entity
@Getter
@Table(name = "message")
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Message extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "sender")
    private String sender;
    
    @Column(name = "roomId")
    private String roomId;
    
    @Column(name = "receiver")
    private String receiver;
    
    @Column(name = "message")
    private String message;

    @Column(name = "sentTime")
    private String sentTime;

	// 1. 
    @ManyToOne
    @JoinColumn(name = "roomId", referencedColumnName = "roomId", insertable = false, updatable = false)
    private MessageRoom messageRoom;

    // 대화 저장
    public Message(String sender, String roomId, String message) {
        super();
        this.sender = sender;
        this.roomId = roomId;
        this.message = message;
    }
}    

1.
MessageRoom 의 컬럼명을 Message 엔티티에서 roomId 로 하고, MessageRoom 엔티티에서도 roomId 로 설정하기 위해

  • name : 현재 엔티티의 외래 키 컬럼의 이름을 지정
  • referencedColumnName : 연관 엔티티의 외래 키 컬럼의 이름을 지정
  • insertable, updatable : 해당 컬럼이 데이터베이스에 삽입(insert) 및 수정(update)되지 않도록 막는 역할 --> 즉, 읽기 전용만 가능하도록

(2) MessageRoom

@Entity
@Setter
@Getter
@Table(name = "messageRoom")
@NoArgsConstructor
public class MessageRoom {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String roomName;
    
    private String sender;			// 채팅방 생성자
    
    @Column(unique = true)
    private String roomId;
    
    private String receiver;        // 채팅방 수신자

    @OneToMany(mappedBy = "messageRoom", cascade = CascadeType.REMOVE)
    private List<Message> messageList = new ArrayList<>();

    @ManyToOne
    @JoinColumn(name = "userId", nullable = false)
    private User user;

    @OneToOne			// 채팅방과 Post 는 1:1 연관관계
    @JoinColumn(name = "postId")
    private Post post;

    // 쪽지방 생성
    public MessageRoom(Long id, String roomName, String sender, String roomId, String receiver, User user, Post post) {
        this.id = id;
        this.roomName = roomName;
        this.sender = sender;
        this.roomId = roomId;
        this.receiver = receiver;
        this.user = user;
        this.post = post;
    }
}

9) Repository

(1) MessageRepository

public interface MessageRepository extends JpaRepository<Message, Long> {

    List<Message> findTop100ByRoomIdOrderByCreatedAtAsc(String roomId);

    Message findTopByRoomIdOrderByCreatedAtDesc(String roomId);
}

(2) MessageRoomRepository

public interface MessageRoomRepository extends JpaRepository<MessageRoom, Long> {

    List<MessageRoom> findByUserOrReceiver(User user, String receiver);
    
    MessageRoom findByIdAndUserOrIdAndReceiver(Long id, User user, Long id1, String nickname);
    
    MessageRoom findBySenderAndReceiver(String nickname, String receiver);

    MessageRoom findByRoomIdAndUserOrRoomIdAndReceiver(String roomId, User user, String roomId1, String nickname);

    MessageRoom findByRoomId(String roomId);
}

테스트

stomp 를 테스트 할 환경이 마땅치 않았기 때문에,
MessageController 에 메시지 작성 post 요청을 만들고, MessageService 에 해당 요청을 받아서 redis 에 저장 기능 등을 테스트 했다.

실제 stomp 테스트는 프론트 쪽과 연결 이후에 진행되었다.


참고: Spring websocket chatting server(3) - 여러대의 채팅서버간에 메시지 공유하기 by Redis pub/sub
참고: [데이터베이스] Redis란?
참고: Redis란? 레디스의 기본적인 개념 (인메모리 데이터 구조 저장소)
참고: Redis란 무엇일까..?
참고: [운영체제] Race Condition과 예방할 방법(세마포어, 뮤텍스)

profile
개발자로 거듭나기!

2개의 댓글

comment-user-thumbnail
2023년 9월 25일

좋은 글 감사합니다 👍

답글 달기
comment-user-thumbnail
2024년 1월 22일

형님 혹시 깃허브 주소는 따로 없으십니까... 해당 내용 클론코딩 중인데 참고하고 싶어서요

답글 달기