서론
유저의 편의성을 위해 입장한 서버에서의 마지막 채널 위치를 기억하여 다시 해당 서버로 입장 시 마지막 채널 위치를 기반으로 페이지를 보여주기 위한 기능을 구현했습니다. 전체적인 로직은 아래와 같습니다.
로직 설계
1. 채널 조회 시 각 서비스는 채널을 조회한 유저의 id값과 조회한 채널 id값을 카프카를 통해 상태관리로 전송
- 텍스트 채널 조회는 채팅 서비스로 포럼 채널 조회는 커뮤니티 서비스로 음성 채널 조회는 시그널링 서비스로 요청이 갑니다.
- 요청을 받은 각 서비스들은 채널을 조회한 해당 유저의 id값과 조회한 채널 id값을 카프카를 통해 상태관리로 전송합니다.
- 상태관리 서비스는 유저가 입장한 서버에서의 마지막 채널 위치를 관리합니다.
- 서버 생성 또는 서버로 유저가 처음 입장 시 커뮤니티가 상태관리로 유저 id와 임의 채널 id 값을 전송합니다.
2. 유저가 서버(채팅방) 입장 시 커뮤니티 서비스는 유저의 마지막 채널 위치를 기반으로 값을 반환합니다.
- 서버 입장시 커뮤니티 서비스로 서버 조회를 요청합니다.
- 커뮤니티 서비스는 상태관리 서비스로 유저가 입장한 서버에서의 마지막 채널 위치를 조회합니다.
- 조회한 채널이 음성이나 포럼이면 값을 프론트로 그대로 반환합니다.
- 텍스트 채널인 경우 채팅 서비스로 해당 채널의 채팅 목록 데이터를 받아와 프론트로 값을 반환합니다.
구현 로직1
1. 채널 조회 시 각 서비스는 채널을 조회한 유저의 id값과 조회한 채널 id값을 카프카를 통해 상태관리로 전송
채널 조회시 각 서비스에서 상태관리로 보내는 흐름과 데이터는 같기에 채팅 서비스를 예시로 들어 설명하겠습니다.
채팅 서비스
컨트롤러
@Slf4j
@RestController
@RequiredArgsConstructor
public class ServerMessageCommandController {
private final FileStore fileStore;
private final ChatEventProducer producerService;
private final AlarmEventProducer alarmEventProducer;
private final StateEventProducer stateEventProducer;
private final ServerMessageCommandService commandService;
...
@PostMapping("/server/user/location")
public void saveUserLocation(@RequestBody UserLocationEventDto userLocationEventDto) {
stateEventProducer.sendToUserLocationEventTopic(userLocationEventDto);
}
}
- 채널 위치를 저장하기 위한 요청을 받아서 카프카를 통해 상태관리 서비스로 데이터를 보냅니다.
kafka producer
@Slf4j
@Component
@RequiredArgsConstructor
public class StateEventProducer {
...
@Value("${spring.kafka.topic.user-location-event}")
private String userLocationEventTopic;
...
private final KafkaTemplate<String, UserLocationEventDto> userLocationEventKafkaTemplate;
...
public void sendToUserLocationEventTopic(UserLocationEventDto userLocationEventDto) {
userLocationEventKafkaTemplate.send(userLocationEventTopic, userLocationEventDto);
}
}
- 유저의 위치 정보에 대한 데이터가 담겨있는 카프카 토픽으로 UserLocationEventDto를 전송합니다.
dto
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserLocationEventDto {
private Long userId;
private Long serverId;
private Long channelId;
}
- 상태 관리 서비스로 보내는 데이터로는 유저의 id값과 해당 유저가 마지막에 위치했던 서버와 채널 id값입니다.
상태관리 서비스
kafka consumer
@Slf4j
@Component
@RequiredArgsConstructor
public class UserLocationEventConsumer {
private final LocationStateCommandService locationStateCommandService;
@KafkaListener(topics = "${spring.kafka.topic.user-location-event}", groupId = "${spring.kafka.consumer.group-id.user-location-event}", containerFactory = "userLocationEventListenerContainerFactory")
public void userLocationEventListener(UserLocationEventDto userLocationEventDto) {
locationStateCommandService.saveLocationState(userLocationEventDto);
}
}
- 채팅 서비스에서 카프카를 통해 보낸 데이터를 받습니다.
유저 위치 저장 서비스 로직
@Slf4j
@Service
@RequiredArgsConstructor
public class LocationStateCommandServiceImpl implements LocationStateCommandService {
private static final String prefix = "LOCATION:STATE";
private final RedisTemplate<String, Object> redisTemplate;
@Override
public void saveLocationState(UserLocationEventDto userLocationEventDto) {
HashOperations<String, Long, Long> hashOperations = redisTemplate.opsForHash();
String hashKey = prefix + userLocationEventDto.getUserId();
Long serverId = userLocationEventDto.getServerId();
Long channelId = userLocationEventDto.getChannelId();
hashOperations.put(hashKey, serverId, channelId);
}
}
- prefix와 유저의 id를 조합하여 고유한 Redis 해시 키를 생성합니다.
- redisTemplate의 HashOperations을 사용하여 유저의 마지막에 위치했던 서버 id와 채널 id를 Redis 해시에 저장합니다.
구현 로직2
2. 유저가 서버(채팅방) 입장 시 커뮤니티 서비스는 유저의 마지막 채널 위치를 기반으로 값을 반환합니다.
커뮤니티 서비스
컨트롤러
@RestController
@RequiredArgsConstructor
@RequestMapping("/server")
public class ServerQueryController {
private final ServerQueryService serverQueryService;
@GetMapping("/{serverId}/{userId}")
public DataResponseDto<Object> read(@PathVariable("serverId") Long serverId, @PathVariable("userId") Long userId){
ServerReadResponse response = serverQueryService.read(serverId, userId);
return DataResponseDto.of(response);
}
...
}
- 유저가 서버(채팅방) 입장 시 커뮤니티 서비스로 보낸 서버 조회 요청을 받습니다.
- 서비스단에서 로직 처리 후 데이터를 컨트롤러로 보내고 컨트롤러는 프론트로 데이터를 보내 위 요청에 대해 응답합니다.
서비스 로직
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ServerQueryService {
private final StateServiceClient stateServiceClient;
private final ChatServiceClient chatServiceClient;
...
private final ChannelRepository channelRepository;
public ServerReadResponse read(Long serverId, Long userId) {
...
// 유저 마지막 채널 위치를 기반하여 데이터를 불러오는 로직
Page<ServerMessageDto> messages = getMessages(findServer.getId(), userId);
return createServerReadResponseDto
(
serverId,
findServer,
usersState,
messages
);
}
...
private Page<ServerMessageDto> getMessages(Long serverId, Long userId) {
UserLocationDto userLocation = stateServiceClient.getUserLocation(serverId, userId);
return validateChatChannel(userLocation.getChannelId()) ? chatServiceClient.getServerMessages(
userLocation.getChannelId(),
0,
30
) : null;
}
private boolean validateChatChannel(Long channelId) {
Channel findChannel = channelRepository.findById(channelId)
.orElseThrow(
() -> new ChannelException(
Code.NOT_FOUND, "Not Found Channel")
);
if(!findChannel.getChannelType()
.equals(ChannelType.CHAT))
{
return false;
}
return true;
}
}
UserLocationDto userLocation = stateServiceClient.getUserLocation(serverId, userId);
- 상태관리 서비스로부터 유저의 마지막 채널 위치를 받아옵니다.
return validateChatChannel(userLocation.getChannelId()) ? chatServiceClient.getServerMessages(
userLocation.getChannelId(),
0,
30
) : null;
- 상태관리 서비스로부터 받아온 channelId 값이 텍스트 채널인지 아닌지 검증합니다.
- 텍스트 채널일 경우 채팅 서비스로 텍스트 채널 채팅 목록 데이터를 요청하고 받아옵니다.
상태 관리 서비스
컨트롤러
@Slf4j
@RestController
@RequiredArgsConstructor
public class LocationStateQueryController {
private final LocationStateQueryService locationStateQueryService;
@GetMapping("/feign/{serverId}/{userId}")
public UserLocationDto getUserLocation(@PathVariable("serverId") Long serverId,
@PathVariable("userId") Long userId) {
UserLocationDto userLocationState = locationStateQueryService.getUserLocationState(serverId, userId);
return userLocationState;
}
}
- 커뮤니티 서비스에서 보낸 유저의 마지막 채널 위치 조회 요청을 받습니다.
- 서비스 로직에서 처리한 데이터를 받아서 프론트로 보내 위 요청에 대해 응답합니다.
서비스 로직
@Slf4j
@Service
@RequiredArgsConstructor
public class LocationStateQueryServiceImpl implements LocationStateQueryService {
private static final String prefix = "LOCATION:STATE";
private final RedisTemplate<String, Object> redisTemplate;
@Override
public UserLocationDto getUserLocationState(Long serverId, Long userId) {
HashOperations<String, Long, Long> hashOperations = redisTemplate.opsForHash();
String hashKey = prefix + userId;
Long userLocation = hashOperations.get(hashKey, serverId);
return new UserLocationDto(userLocation);
}
}
- redisTemplate을 통해 HashOperations 객체를 얻습니다.
- hashOperations.get 메서드를 사용하여 해시에 저장한 유저의 마지막에 위치한 채널 id 값을 조회합니다.
- 조회한 값을 UserLocationDto 객체로 반환합니다.
채팅 서비스
컨트롤러
@RestController
@RequiredArgsConstructor
public class ServerMessageQueryController {
private final ChatEventProducer producerService;
private final ServerMessageQueryService queryService;
@GetMapping("/feign/server/messages/channel")
public Page<ServerMessageDto> getFeignMessages(@RequestParam(value = "channelId") Long channelId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "30") int size) {
return queryService.getMessages(channelId, page, size);
}
...
}
- 커뮤니티 서비스에서 보낸 텍스트 채널에 대한 채팅 목록 조회 요청을 받습니다.
- 서비스 로직에서 처리한 데이터를 받아서 프론트로 보내 위 요청에 대해 응답합니다.
서비스 로직
@Service
@RequiredArgsConstructor
public class ServerMessageQueryServiceImpl implements ServerMessageQueryService {
private final EmojiRepository emojiRepository;
private final ServerMessageRepository messageRepository;
@Override
public Page<ServerMessageDto> getMessages(Long channelId, int page, int size) {
Page<ServerMessageDto> messageDtos = messagesToMessageDtos("message", channelId, page, size);
List<Long> messageIds = getMessageIds(messageDtos);
Map<Long, List<EmojiDto>> emojiMap = getEmojisForMessages(messageIds);
Map<Long, Long> commentCount = getCommentCountForMessages(messageIds);
for (ServerMessageDto messageDto : messageDtos) {
messageDto.setEmojis(emojiMap.getOrDefault(messageDto.getMessageId(), Collections.emptyList()));
messageDto.setCount(commentCount.getOrDefault(messageDto.getMessageId(), 0L));
}
return messageDtos;
}
private Page<ServerMessageDto> messagesToMessageDtos(String type, Long id, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
Page<ServerMessage> messages = null;
if (type.equals("message")) {
messages = messageRepository.findByChannelIdAndIsDeletedAndParentId(id, pageable);
} else if (type.equals("comment")) {
messages = messageRepository.findByParentIdAndIsDeleted(id, pageable);
}
return (messages != null) ? messages.map(ServerMessageDto::from) : Page.empty(pageable);
}
private Map<Long, List<EmojiDto>> getEmojisForMessages(List<Long> messageIds) {
List<Emoji> emojis = emojiRepository.findEmojisByServerMessageIds(messageIds);
Map<Long, List<EmojiDto>> emojiMap = new HashMap<>();
for (Long messageId : messageIds) {
List<EmojiDto> emojiDtos = new ArrayList<>();
for (Emoji emoji : emojis) {
if (messageId.equals(emoji.getServerMessageId())) {
EmojiDto emojiDto = EmojiDto.from(emoji);
emojiDtos.add(emojiDto);
}
}
emojiMap.put(messageId, emojiDtos);
}
return emojiMap;
}
private Map<Long, Long> getCommentCountForMessages(List<Long> messageIds) {
List<ServerMessage> messages = messageRepository.findCommentCountByParentIdsAndIsDeleted(messageIds);
Map<Long, Long> commentCount = new HashMap<>();
for (Long messageId : messageIds) {
long count = 0L;
for (ServerMessage message : messages) {
if (message.getParentId().equals(messageId)) {
count += 1;
}
}
commentCount.put(messageId, count);
}
return commentCount;
}
private List<Long> getMessageIds(Page<ServerMessageDto> messageDtos) {
return messageDtos.getContent().stream()
.map(ServerMessageDto::getMessageId)
.collect(Collectors.toList());
}
}
getMessages()
- 프론트로 보낼 채팅 목록 DTO를 생성 후 컨트롤러로 반환합니다.
messagesToMessageDtos()
- DB로부터 조회한 채팅 목록을 프론트로 보낼 DTO로 변환합니다.
getEmojisForMessages()
- 채팅 목록에 달린 이모지들을 조회합니다.
- 채팅에 달린 이모지들을 채팅 목록 하나하나에 매핑하여 Map에 저장합니다.
getCommentCountForMessages()
- 채팅 목록에 달린 댓글 개수들을 조회합니다.
- 채팅에 달린 댓글 개수를 채팅 목록 하나하나에 매핑하여 Map에 저장합니다.
getMessageIds()
- 채팅 목록들에서 채팅 id값을 뽑아서 List에 저장합니다.
마무리 하며
처음 구현 기능 목록 정리 할때는 계획하지 않았지만 개발 중간쯤에 유저의 편의성을 생각하여 추가한 기능입니다.
전체적인 로직을 설계하고 설계한 로직을 바탕으로 기능 구현에 들어가니 기능의 흐름을 정리할 수 있어 좋았고 개발에 대한 효율이 좋다라는 생각이 들었습니다.
'프로젝트 > FitTrip' 카테고리의 다른 글
개발 기록 - 온라인/오프라인 상태 메시지 전송 성능 최적화 (0) | 2024.07.14 |
---|---|
개발 기록 - 유저의 실시간 온/오프 상태 처리 기능 (1) | 2024.07.14 |
개발 기록 - 분산 시스템에서 데이터를 전달하는 효율적인 방법 (0) | 2024.07.12 |
트러블 슈팅 - SockJs를 사용한 웹소켓 연결 시 CORS 이슈 (0) | 2024.07.08 |
개발 기록 - WebSocket & STOMP 개발 이슈 (0) | 2024.07.05 |