[트러블슈팅] Redisson을 활용하여 다중 서버로 인한 동시성 이슈 해결하기

김강욱·2024년 5월 30일
0

Project-DoggyWalky

목록 보기
3/5
post-thumbnail

😢 오류 발생

프로젝트 진행 중에 서버 간의 통신에서 다중 서버에 의해 발생하는 동시성 문제가 발생하였습니다.

현재 진행중인 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에 대해 알고 싶으신 분은 아래 링크를 참고하시면 좋을 것 같습니다.

[Spring] Redis(Redisson) 분산락을 활용한 동시성 문제 해결

그럼 바로 Redisson을 적용하여 트러블 슈팅 해보도록 하겠습니다.

build.gradle 설정

dependencies {
	// Redisson
	implementation 'org.redisson:redisson-spring-boot-starter:3.16.0'
}

ChatRoomSubscriber

@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로 생성하고 RedissonClientgetLock() 메서드를 통해 RedissonLock 객체를 얻게 됩니다. 해당 RedissonLock 객체의 tryLock(), unlock() 메서드를 통해 락을 획득, 반환할 수 있습니다.

if-else 분기처리를 통해 락킹이 걸려있을 시 해당 로직을 수행하지 않고 생략하도록 구현하였습니다. tryLock()메서드의 파라미터는 Lock 획득을 시도하는 최대 시간, Lock을 획득한 후 점유하는 최대 시간, 시간 단위를 넣어줍니다.

여기서 Lock 획득을 시도하는 최대 시간을 짧게 주어 다중 채팅 서버 중 처음 락을 건 서버 외에 나머지 서버에서는 락 획득 재시도를 하지 않도록 하였습니다.

이제 제대로 락킹이 동작하는지 테스트를 진행해보도록 하겠습니다. 채팅 서버를 여러대 실행시키고 메인 서버에서 채팅방 생성에 대한 요청을 보내보도록 하겠습니다. 채팅 서버를 4대를 켜고 진행하도록 하겠습니다.

PostMan을 통해 채팅방 생성 요청을 보내봤습니다.

로깅을 확인한 결과 TEST Server 2에서 채팅방 생성에 대한 로직을 처리하고 있고 나머지 서버에서는 락을 얻지 못했다고 나옵니다.

DB에도 채팅방 하나가 생성된 것을 확인할 수 있습니다. 성공적으로 테스트를 마쳤네요.


☕ 마치며

이번 포스팅에서는 Redisson을 이용하여 락킹을 걸어 동시성 문제를 해결하는 시간을 가졌습니다. 아키텍처 설계 시 미처 고려하지 못한 동시성 문제를 해결하는 과정을 통해, 분산 시스템에서의 락 관리의 중요성을 다시 한번 느낄 수 있었습니다.

특히, Redisson을 활용한 락킹 메커니즘을 통해 쉽게 분산 락을 구현할 수 있었고, 이를 통해 애플리케이션의 안정성을 높일 수 있었습니다.

이번 포스팅에서 다룬 내용은 실무에서 동시성 문제를 해결하는 데 큰 도움이 될 것 같네요. ㅎㅎ

profile
TO BE DEVELOPER

0개의 댓글

관련 채용 정보