프로젝트 진행 중에 서버 간의 통신에서 다중 서버에 의해 발생하는 동시성 문제가 발생하였습니다.
현재 진행중인 DoggyWalky
프로젝트의 아키텍처 설계도입니다. Main Server
에서 채팅 관련 로직을 요청 받은 후 Redis PUB/SUB System
을 통해 다중 채팅 서버에 해당 요청 메시지를 전송하는 방식으로 설계하였는데 이러한 설계 방식에 문제점이 있었습니다.
Redis PUB/SUB System
은 특정 토픽을 구독하고 있는 모든 클라이언트에게 메시지를 전송합니다. 이는 특정 토픽에 메시지가 발행될 때, 해당 토픽을 구독하고 있는 모든 클라이언트가 그 메시지를 수신하게 되는 것을 의미합니다.
예를 들어, 사용자가 채팅방을 생성하는 요청을 Main Server
에 전송하고 Main Server
에서는 채팅방 생성하라는 메시지를 Redis PUB/SUB System
에 발행하게 됩니다. 다중의 채팅 서버는 모두 Redis PUB/SUB System
에 채팅 관련 토픽을 구독하고 있는 상태이기 때문에 모든 채팅 서버에 채팅방 생성에 대한 메시지가 도착하게 됩니다. 즉, 채팅방 생성 관련 로직이 여러번 동작하게 되어 중복된 로직을 수행하게 되는 것입니다.
채팅 서버 간의 이미 실행되고 있는 로직에 대해서는 로직 수행을 생략하도록 해야하는데 어떻게 해야할까요? 서로 로직 수행에 대한 여부를 공유할 수 있으면 좋을텐데 말이죠. 이런 부분을 간단하게 구현할 수 있도록 해주는 것이 바로 Redisson
입니다.
Redisson
에 대해 알고 싶으신 분은 아래 링크를 참고하시면 좋을 것 같습니다.
그럼 바로 Redisson
을 적용하여 트러블 슈팅 해보도록 하겠습니다.
dependencies {
// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.16.0'
}
@Slf4j
@RequiredArgsConstructor
@Component
public class ChatRoomSubscriber {
private final ObjectMapper objectMapper;
private final ChatService chatService;
private final SimpMessageSendingOperations messagingTemplate;
private final RedissonClient redissonClient;
/**
* Redis에서 메시지가 발행(publish)되면 대기하고 있던 RedisSubscriber가 해당 메시지를 받아 처리함
*/
public void sendMessage(String publishMessage) {
ChatRoomMessage chatRoomMessage = null;
String lockKey = "lock:chatRoom:" + publishMessage.hashCode();
RLock lock = redissonClient.getLock(lockKey);
try {
//ChatMessage 객체로 매핑
chatRoomMessage = objectMapper.readValue(publishMessage, ChatRoomMessage.class);
System.out.println("chatRoom Message 도착");
System.out.println(chatRoomMessage.toString());
if (lock.tryLock(100,5000, TimeUnit.MILLISECONDS)) {
try {
// 타입(QUIT, UNVISIBLE, CREATE)에 따른 처리
if (chatRoomMessage.getType() == ChatRoomMessage.Type.CREATE) {
// 채팅방 생성 로직(채팅 생성 로직 포함)
ChatMessage message = chatService.createChatRoom(chatRoomMessage);
// 채팅방을 구독한 클라이언트에게 메시지 발송(Redis의 토픽에 메시지 발행 후 작업)
// 채팅 전송 로직
messagingTemplate.convertAndSend("/sub/chat-room/renew/"+chatRoomMessage.getReceiverId(),message);
messagingTemplate.convertAndSend("/sub/chat-room/renew/"+chatRoomMessage.getSenderId(),message);
} else if (chatRoomMessage.getType() == ChatRoomMessage.Type.UNVISIBLE) {
// 채팅방 안보이게 설정
chatService.unvisibleChatRoom(chatRoomMessage);
// 채팅방을 구독한 클라이언트에게 메시지 발송
messagingTemplate.convertAndSend("/sub/chat-room/renew/"+chatRoomMessage.getSenderId(),new ChatStatusResponse(ResponseCode.UNVISIBLE_COMPLETED));
} else if (chatRoomMessage.getType() == ChatRoomMessage.Type.QUIT) {
// 채팅방 나가도록 설정
ChatMessageResponse chatMessageResponse = chatService.quitChatRoom(chatRoomMessage);
if (chatMessageResponse != null) {
// 채팅방 자체에 나가기 메시지 전송
messagingTemplate.convertAndSend("/sub/chat/room/"+chatRoomMessage.getChatRoomId(),chatMessageResponse);
}
// 채팅방을 구독한 클라이언트에게 메시지 발송
messagingTemplate.convertAndSend("/sub/chat-room/renew/"+chatRoomMessage.getSenderId(),new ChatStatusResponse(ResponseCode.QUIT_COMPLETED));
messagingTemplate.convertAndSend("/sub/chat-room/renew/"+chatRoomMessage.getReceiverId(),new ChatStatusResponse(ResponseCode.QUIT_COMPLETED));
}
} finally {
lock.unlock();
}
} else {
log.info("Test Server[number]");
log.info("Could not acquire lock for key: " + lockKey);
}
} catch (ApplicationException e) {
// TODO: 예외 발생 시 해당 구독자(클라이언트)에게 예외 메시지 보내기 구현
log.error("Exception {}", e);
messagingTemplate.convertAndSend("/sub/error-message/" +chatRoomMessage.getSenderId(), new ChatStatusResponse(e.getErrorCode()));
} catch (Exception e) {
log.error("Exception {}", e);
messagingTemplate.convertAndSend("/sub/error-message/" +chatRoomMessage.getSenderId(), new ChatStatusResponse(ErrorCode.INTERNAL_SERVER_ERROR));
}
}
}
해당 코드는 Redis PUB/SUB System
에서 발행된 메세지를 처리하는 Subscriber
입니다. sendMessage
메서드가 수행될 때 락킹을 걸어두고 락이 걸려있으면 채팅 관련 로직의 수행을 생략하도록 구현하면 해결됩니다.
발행된 메시지의 해시코드를 lockKey
로 생성하고 RedissonClient
의 getLock()
메서드를 통해 RedissonLock
객체를 얻게 됩니다. 해당 RedissonLock
객체의 tryLock()
, unlock()
메서드를 통해 락을 획득, 반환할 수 있습니다.
if-else
분기처리를 통해 락킹이 걸려있을 시 해당 로직을 수행하지 않고 생략하도록 구현하였습니다. tryLock()
메서드의 파라미터는 Lock
획득을 시도하는 최대 시간, Lock
을 획득한 후 점유하는 최대 시간, 시간 단위를 넣어줍니다.
여기서 Lock
획득을 시도하는 최대 시간을 짧게 주어 다중 채팅 서버 중 처음 락을 건 서버 외에 나머지 서버에서는 락 획득 재시도를 하지 않도록 하였습니다.
이제 제대로 락킹이 동작하는지 테스트를 진행해보도록 하겠습니다. 채팅 서버를 여러대 실행시키고 메인 서버에서 채팅방 생성에 대한 요청을 보내보도록 하겠습니다. 채팅 서버를 4대를 켜고 진행하도록 하겠습니다.
PostMan
을 통해 채팅방 생성 요청을 보내봤습니다.
로깅을 확인한 결과 TEST Server 2
에서 채팅방 생성에 대한 로직을 처리하고 있고 나머지 서버에서는 락을 얻지 못했다고 나옵니다.
DB에도 채팅방 하나가 생성된 것을 확인할 수 있습니다. 성공적으로 테스트를 마쳤네요.
이번 포스팅에서는 Redisson
을 이용하여 락킹을 걸어 동시성 문제를 해결하는 시간을 가졌습니다. 아키텍처 설계 시 미처 고려하지 못한 동시성 문제를 해결하는 과정을 통해, 분산 시스템에서의 락 관리의 중요성을 다시 한번 느낄 수 있었습니다.
특히, Redisson
을 활용한 락킹 메커니즘을 통해 쉽게 분산 락을 구현할 수 있었고, 이를 통해 애플리케이션의 안정성을 높일 수 있었습니다.
이번 포스팅에서 다룬 내용은 실무에서 동시성 문제를 해결하는 데 큰 도움이 될 것 같네요. ㅎㅎ