💡 Projects/드로잉 게임 [눈치 코치 캐치!]

[눈치 캐치 코치!] 본격 Spring으로 시그널링 서버 구축하기

오늘 ONEUL 2023. 1. 20. 23:39

 

시작하기 전에

지난 글에서 WebRTC를 구현하기 위한 방식으로는 Mesh 방식을,
그에 따라 필요한 시그널링 서버는 WebSocket을 통신 프로토콜로 사용하여 Spring Boot로 구축하기로 결정했다.

 

👇아직 이전글을 보지 않았다면?👇

 

[눈치 코치 캐치!] WebRTC를 어떻게 구현해야 할까?

시작하기 전에 지난 글에서 WebRTC에 대해 알아보았다. 그럼 이제 우리의 [눈치 코치 캐치!] 드로잉 게임 서비스에 맞게 WebRTC를 구현해야 한다. WebRTC를 어떻게 구현해야 할까? 👇아직 이전글을 보

oneul-losnue.tistory.com

 

[눈치 코치 캐치!] WebRTC 한 방에 정리하기

시작하기 전에 이번 프로젝트에서 실시간 게임을 개발하고 있다. 웹 게임이지만 함께 있는 듯한 느낌을 주기 위해 음성 채팅 기능을 고려하게 되었고, WebRTC를 접하게 되었다. 이것만으로 영상

oneul-losnue.tistory.com

 

 

이제부터 악으로 깡으로(?) 구축했던 시그널링 서버 구축기를 적어보려 한다!

 

 

 

본격 Spring으로 시그널링 서버 구축하기

Spring Boot에서 WebSocket을 사용하기 위한 설정부터 시작해 보자!

 

1. 먼저 build.gradle 에 다음과 같은 의존성을 추가한다.

// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

// sockJS
implementation 'org.webjars:sockjs-client:1.5.1'

// stompJS
implementation 'org.webjars:stomp-websocket:2.3.4'

 

2. WebSocketConfigurer를 상속받고, registerWebSocketHandlers 메서드를 오버라이드 하는 @Configuration 클래스를 작성한다.

package com.project.trysketch.global.config;

import com.project.trysketch.global.rtc.SignalingHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket // 웹 소켓에 대해 자동 설정
public class WebRtcConfig implements WebSocketConfigurer {

    /*TODO WebRTC관련*/

    // signal 로 요청이 왔을 때 아래의 signalingSocketHandler 가 동작하도록 registry 에 설정
    // 요청은 클라이언트 접속, close, 메시지 발송 등에 대해 특정 메서드를 호출한다
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(signalingSocketHandler(), "/signal")   // 연결될 Endpoint
                .setAllowedOriginPatterns("*")                     // CORS 설정
                .withSockJS();                                     // SockJS 설정
    }

    @Bean
    public org.springframework.web.socket.WebSocketHandler signalingSocketHandler() {
        return new SignalingHandler();
    }
}

 

3. 클라이언트와 주고받을 WebSocket 메시지 객체를 생성하기 위해 메시지 클래스를 작성한다.

package com.project.trysketch.global.rtc;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Message {
    private String sender;    // 보내는 유저 UUID
    private String type;      // 메시지 타입
    private String receiver;  // 받는 사람
    private String room;      // roomId
    private Object candidate; // 상태
    private Object sdp;       // sdp 정보
    private Object allUsers;  // 해당 방에 본인을 제외한 전체 유저
}

 

4. 여러 클라이언트로부터 받을 WebSocket 메시지를 처리하기 위한 메시지 핸들러를 만든다.
이 메시지 핸들러는 3단계에 걸쳐 구축되었다. 순서대로 살펴보자!

1️⃣ Node.js로 된 예제를 Spring으로 구현(DB 연결 x)
2️⃣ 메인 로직 연결, DB 연결
3️⃣ 전체적인 리팩토링

 

 

1️⃣ Node.js로 된 예제를 Spring으로 구현(DB 연결 x)

👉Github으로 전체 코드 보러 가기👈

 

WebRTC를 Spring으로 구현한 참고자료는 예상대로 많지 않았다.
있다 해도 그 방식이 너무 다양했기에 처음 이 기술을 익히는 나로서는 이해하기 어려웠다.
결국 Node.js로 잘 구성된 예제를 Spring으로 바꿔보기로 결정!

참고한 Node.js 코드는 다음과 같다. (주석이 친절히 달려있어 너무 감사했다.)

const express = require('express');

const app = express();
const http = require('http');
const { Server } = require('socket.io');

const server = http.createServer(app);

// cors 설정을 하지 않으면 오류가 생기게 됩니다. 설정해 줍니다.
const io = new Server(server, {
  cors: {
    origin: '<http://localhost:3000>',
    methods: ['GET', 'POST'],
    allowedHeaders: ['my-custom-header'],
    credentials: true,
  },
});

const PORT = process.env.PORT || 8080;

// 어떤 방에 어떤 유저가 들어있는지
const users = {};
// socket.id기준으로 어떤 방에 들어있는지
const socketRoom = {};

// 방의 최대 인원수
const MAXIMUM = 2;

io.on('connection', (socket) => {
  console.log(socket.id, 'connection');
  socket.on('join_room', (data) => {
    // 방이 기존에 생성되어 있다면
    if (users[data.room]) {
      // 현재 입장하려는 방에 있는 인원수
      const currentRoomLength = users[data.room].length;
      if (currentRoomLength === MAXIMUM) {
        // 인원수가 꽉 찼다면 돌아갑니다.
        socket.to(socket.id).emit('room_full');
        return;
      }

      // 여분의 자리가 있다면 해당 방 배열에 추가해줍니다.
      users[data.room] = [...users[data.room], { id: socket.id }];
    } else {
      // 방이 존재하지 않다면 값을 생성하고 추가해줍시다.
      users[data.room] = [{ id: socket.id }];
    }
    socketRoom[socket.id] = data.room;

    // 입장
    socket.join(data.room);

    // 입장하기 전 해당 방의 다른 유저들이 있는지 확인하고
    // 다른 유저가 있었다면 offer-answer을 위해 알려줍니다.
    const others = users[data.room].filter((user) => user.id !== socket.id);
    if (others.length) {
      io.sockets.to(socket.id).emit('all_users', others);
    }
  });

  socket.on('offer', (sdp, roomName) => {
    console.log(sdp);
    console.log('offer');
    // offer를 전달받고 방의 다른 유저들에게 전달해 줍니다.
    socket.to(roomName).emit('getOffer', sdp);
  });

  socket.on('answer', (sdp, roomName) => {
    console.log(sdp);
    console.log('answer');
    // answer를 전달받고 방의 다른 유저들에게 전달해 줍니다.
    socket.to(roomName).emit('getAnswer', sdp);
  });

  socket.on('candidate', (candidate, roomName) => {
    console.log('candidate');
    // candidate를 전달받고 방의 다른 유저들에게 전달해 줍니다.
    socket.to(roomName).emit('getCandidate', candidate);
  });

  socket.on('disconnect', () => {
    // 방을 나가게 된다면 socketRoom과 users의 정보에서 해당 유저를 지워줍니다.
    const roomID = socketRoom[socket.id];

    if (users[roomID]) {
      users[roomID] = users[roomID].filter((user) => user.id !== socket.id);
      if (users[roomID].length === 0) {
        delete users[roomID];
        return;
      }
    }
    delete socketRoom[socket.id];
    socket.broadcast.to(users[roomID]).emit('user_exit', { id: socket.id });
  });
});

server.listen(PORT, () => {
  console.log(`server running on ${PORT}`);
});

데이터베이스를 따로 거치지 않고, 서버에 유저를 담았다.

  • users : 어떤 방에 어떤 유저가 들어있는지
    • ex. { 방번호 : [ { id : 유저1 }, { id: 유저2 } , …], 방번호 : [ { id : 유저1 }, { id: 유저2 } , …] }
  • socketRoom : socket.id 기준으로 어떤 방에 들어있는지
    • ex. { id: socket.id, room: room객체 }

 

각 메시지 타입에 따라 이런 순서로 진행된다.

  • connection : 웹 소켓 연결 시작
  • join_room : 방에 입장
  • all_users : 본인을 제외한 같은 방의 모든 user 목록
  • offer : offer를 전달받고 방의 다른 유저들에게 sdp 전달
  • answer : answer를 전달받고 방의 다른 유저들에게 sdp 전달
  • candidate: candidate를 전달받고 방의 다른 유저들에게 candidate 전달
  • disconnect : 방을 나가서 웹 소켓 연결 종료

P2P방식이기 때문에 상대방과의 통신을 연결한 후부터는 서버가 관여하지 않고 피어간 통신만 이루어진다.

 

해당 예제를 Spring으로 구현해 보았다.

package com.project.trysketch.global.rtc;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

// 1. 기능   : Signaling Server 역할
// 2. 작성자 : 안은솔
// 3. 참고사항: 추후 게임방 DB와 연결 필요

@Slf4j
@Component
public class SignalingHandler extends TextWebSocketHandler {

    // 어떤 방에 어떤 유저가 들어있는지 저장 -> { 방번호 : [ { id : userUUID1 }, { id: userUUID2 }, …], ... }
    private final Map<String, List<Map<String, String>>> roomInfo = new HashMap<>();

    // userUUID 기준 어떤 방에 들어있는지 저장 -> { userUUID1 : 방번호, userUUID2 : 방번호, ... }
    private final Map<String, String> userInfo = new HashMap<>();

    // 세션 정보 저장 -> { userUUID1 : 세션객체, userUUID2 : 세션객체, ... }
    private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

    // 방의 최대 인원수
    private static final int MAXIMUM = 8;

    // 시그널링에 사용되는 메시지 타입 :
    // SDP Offer 메시지
    private static final String MSG_TYPE_OFFER = "offer";
    // SDP Answer 메시지
    private static final String MSG_TYPE_ANSWER = "answer";
    // 새로운 ICE Candidate 메시지
    private static final String MSG_TYPE_CANDIDATE = "candidate";
    // 방 입장 메시지
    private static final String MSG_TYPE_JOIN = "join_room";

    // 웹소켓 연결 시
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        log.info(">>> [ws] 클라이언트 접속 : 세션 - {}", session);
    }

    // 양방향 데이터 통신
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage textMessage) throws Exception {
        try {
            // 웹 소켓으로부터 전달받은 메시지를 deserialization(JSON -> Java Object)
            Message message = Utils.getObject(textMessage.getPayload());
            log.info(">>> [ws] 시작!!! 세션 객체 {}", session);

            // 유저 uuid 와 roomID 를 저장
            String userUUID = session.getId(); // 유저 uuid
            String roomId = message.getRoom(); // roomId
            log.info(">>> [ws] 메시지 타입 {}, 보낸 사람 {}", message.getType(), userUUID);

            // 메시지 타입에 따라서 서버에서 하는 역할이 달라진다
            switch (message.getType()) {

                // 클라이언트에게서 받은 메시지 타입에 따른 signal 프로세스
                case MSG_TYPE_OFFER:
                case MSG_TYPE_ANSWER:
                case MSG_TYPE_CANDIDATE:

                    // 전달받은 메시지로부터 candidate, sdp, receiver 를 저장
                    Object candidate = message.getCandidate();
                    Object sdp = message.getSdp();
                    String receiver = message.getReceiver();   // 클라이언트에서 보내주는 1명의 receiver
                    log.info(">>> [ws] receiver {}", receiver);

                    // sessions 에서 receiver 를 찾아 메시지 전달
                    sessions.values().forEach(s -> {
                        try {
                            if(s.getId().equals(receiver)) {
                                s.sendMessage(new TextMessage(Utils.getString(Message.builder()
                                        .type(message.getType())
                                        .sdp(sdp)
                                        .candidate(candidate)
                                        .sender(userUUID)
                                        .receiver(receiver).build())));
                            }
                        }
                        catch (Exception e) {
                            log.info(">>> 에러 발생 : offer, candidate, answer 메시지 전달 실패 {}", e.getMessage());
                        }
                    });
                    break;

                // 방 입장
                case MSG_TYPE_JOIN:

                    log.info(">>> [ws] {} 가 #{}번 방에 들어감", userUUID, roomId);

                    // 방이 기존에 생성되어 있다면
                    if (roomInfo.containsKey(roomId)) {

                        // 현재 입장하려는 방에 있는 인원수
                        int currentRoomLength = roomInfo.get(roomId).size();

                        // 인원수가 꽉 찼다면 돌아간다
                        if (currentRoomLength == MAXIMUM) {

                            // 해당 유저에게 방이 꽉 찼다는 메시지를 보내준다
                            session.sendMessage(new TextMessage(Utils.getString(Message.builder()
                                    .type("room_full")
                                    .sender(userUUID).build())));
                            return;
                        }

                        // 여분의 자리가 있다면 해당 방 배열에 추가
                        Map<String, String> userDetail = new HashMap<>();
                        userDetail.put("id", userUUID);
                        roomInfo.get(roomId).add(userDetail);
                        log.info(">>> [ws] #{}번 방의 유저들 {}", roomId, roomInfo.get(roomId));

                    } else {

                        // 방이 존재하지 않는다면 값을 생성하고 추가
                        Map<String, String> userDetail = new HashMap<>();
                        userDetail.put("id", userUUID);
                        List<Map<String, String>> newRoom = new ArrayList<>();
                        newRoom.add(userDetail);
                        roomInfo.put(roomId, newRoom);
                    }

                    // 세션 저장, user 정보 저장 -> 방 입장
                    sessions.put(userUUID, session);
                    userInfo.put(userUUID, roomId);

                    // 해당 방에 다른 유저가 있었다면 offer-answer 를 위해 유저 리스트를 만들어 클라이언트에 전달

                    // roomInfo = { 방번호 : [ { id : userUUID1 }, { id: userUUID2 }, …], 방번호 : [ { id : userUUID3 }, { id: userUUID4 }, …], ... }
                    // originRoomUser -> 본인을 제외한 해당 방의 다른 유저들
                    List<Map<String, String>> originRoomUser = new ArrayList<>();
                    for (Map<String, String> userDetail : roomInfo.get(roomId)) {

                        // userUUID 가 본인과 같지 않다면 list 에 추가
                        if (!(userDetail.get("id").equals(userUUID))) {
                            Map<String, String> userMap = new HashMap<>();
                            userMap.put("id", userDetail.get("id"));
                            originRoomUser.add(userMap);
                        }
                    }

                    log.info(">>> [ws] 본인 {} 을 제외한 #{}번 방의 다른 유저들 {}", userUUID, roomId, originRoomUser);

                    // all_users 라는 타입으로 메시지 전달
                    session.sendMessage(new TextMessage(Utils.getString(Message.builder()
                            .type("all_users")
                            .allUsers(originRoomUser)
                            .sender(userUUID).build())));
                    break;

                // 메시지 타입이 잘못되었을 경우
                default:
                    log.info(">>> [ws] 잘못된 메시지 타입 {}", message.getType());
            }
        } catch (IOException e) {
            log.info(">>> 에러 발생 : 양방향 데이터 통신 실패 {}", e.getMessage());
        }
    }

    // 소켓 연결 종료
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        log.info(">>> [ws] 클라이언트 접속 해제 : 세션 - {}, 상태 - {}", session, status);

        // 유저 uuid 와 roomID 를 저장
        String userUUID = session.getId(); // 유저 uuid
        String roomId = userInfo.get(userUUID); // roomId

        // 연결이 종료되면 sessions 와 userInfo 에서 해당 유저 삭제
        sessions.remove(userUUID);
        userInfo.remove(userUUID);

        // roomInfo = { 방번호 : [ { id : userUUID1 }, { id: userUUID2 }, …], 방번호 : [ { id : userUUID3 }, { id: userUUID4 }, …], ... }
        // 해당하는 방의 value 인 user list 의 element 의 value 가 현재 userUUID 와 같다면 roomInfo 에서 remove
        List<Map<String, String>> removed = new ArrayList<>();
        roomInfo.get(roomId).forEach(s -> {
            try {
                if(s.containsValue(userUUID)) {
                    removed.add(s);
                }
            }
            catch (Exception e) {
                log.info(">>> 에러 발생 : if문 생성 실패 {}", e.getMessage());
            }
        });
        roomInfo.get(roomId).removeAll(removed);

        // 본인을 제외한 모든 유저에게 user_exit 라는 타입으로 메시지 전달
        sessions.values().forEach(s -> {
            try {
                if(!(s.getId().equals(userUUID))) {
                    s.sendMessage(new TextMessage(Utils.getString(Message.builder()
                            .type("user_exit")
                            .sender(userUUID).build())));
                }
            }
            catch (Exception e) {
                log.info(">>> 에러 발생 : user_exit 메시지 전달 실패 {}", e.getMessage());
            }
        });

        log.info(">>> [ws] #{}번 방에서 {} 삭제 완료", roomId, userUUID);
        log.info(">>> [ws] #{}번 방에 남은 유저 {}", roomId, roomInfo.get(roomId));
    }

    // 소켓 통신 에러
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) {
        log.info(">>> 에러 발생 : 소켓 통신 에러 {}", exception.getMessage());
    }
}

TextWebSocketHandler를 상속받아 SignalingHandler를 생성하고, 3개의 메서드를 오버라이드 하였다.

  1. afterConnectionEstablished : 웹 소켓 연결 시
  2. handleTextMessage : 양방향 데이터 통신
  3. afterConnectionClosed : 웹 소켓 연결 종료

여기서 1번 메서드를 예제의 connection 메시지 타입으로,
3번 메서드를 disconnect 메시지 타입으로 가정하고,
2번 메서드 안에서 나머지 메시지 타입의 로직을 구현하였다.

 

// 시그널링에 사용되는 메시지 타입 :
// SDP Offer 메시지
private static final String MSG_TYPE_OFFER = "offer";
// SDP Answer 메시지
private static final String MSG_TYPE_ANSWER = "answer";
// 새로운 ICE Candidate 메시지
private static final String MSG_TYPE_CANDIDATE = "candidate";
// 방 입장 메시지
private static final String MSG_TYPE_JOIN = "join_room";

메시지 타입은 이렇게 상수로 선언하고, 메서드 안 쪽에서 switch문으로 분기 처리를 해주었다.

 

메시지를 보낼 때는 WebSocketSession 객체의 sendMessage 메서드를 이용하였다.
이때, TextMessage 객체를 만들어 보내야 하는데 Java 객체를 JSON으로 직렬화하는 작업이 필요하다.
이를 위해, Utils 클래스에서 ObjectMapper를 정의하여 직렬화, 역직렬화하는 로직을 분리하였다.

package com.project.trysketch.global.rtc;

import com.fasterxml.jackson.databind.ObjectMapper;

public class Utils {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    // JSON -> Java Objcet (deserialization)
    public static Message getObject(final String message) throws Exception {
        return objectMapper.readValue(message, Message.class);
    }

    // Java Object -> JSON (serialization)
    public static String getString(final Message message) throws Exception {
        return objectMapper.writeValueAsString(message);
    }
}

 

또한, @Slf4j 어노테이션을 이용하여 각 로직마다 로그를 작성했다.
프론트엔드와 계속 코드를 맞춰보며 테스트를 해야 했기 때문에 로그는 굉장히 중요한 역할을 해주었다.

 

이렇게 뼈대가 될 코드가 완성되었고, 프론트엔드와의 테스트도 성공적이었다.
내가 짠 코드로 음성 대화가 된다니..! 이때 얼마나 신났는지 모른다🤣

 

 

2️⃣ 메인 로직 연결, DB 연결

👉Github으로 전체 코드 보러 가기👈

 

이전 코드는 우리 서비스의 메인 로직과는 전혀 관련 없이 일단 기능만 구현해 놓은 상태였다.
이제 WebRTC의 구현을 확인했으니, 메인 로직과 데이터베이스에 연결해 보도록 하자.

먼저, 대부분의 메인 로직이 있는 GameRoomService 클래스의 의존성을 주입해 주었다.

// service 주입
@Autowired
private GameRoomService gameRoomService;

 

기존에 객체로 저장했던 유저 관련 정보를 데이터베이스에 저장하고,
GameRoomService를 통해 해당 데이터를 가져오는 방식으로 수정해 주었다.

// 유저 관련 정보는 데이터베이스에 저장

// 어떤 방에 어떤 유저가 들어있는지 저장 -> { 방번호 : [ { id : userUUID1 }, { id: userUUID2 }, …], ... }
private final Map<String, List<Map<String, String>>> roomInfo = new HashMap<>();

// userUUID 기준 어떤 방에 들어있는지 저장 -> { userUUID1 : 방번호, userUUID2 : 방번호, ... }
private final Map<String, String> userInfo = new HashMap<>();


// 세션 정보는 그대로 서버에 저장

// 세션 정보 저장 -> { userUUID1 : 세션객체, userUUID2 : 세션객체, ... }
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

WebSessionId와 WebSocketSession 객체는 ConcurrentHashMap을 통해 서버에 저장되고,
해당 WebSessionId를 데이터베이스에 저장해 주어 관련 유저들을 조회해 오는 방식이다.

그러기 위해, WebSessionId가 생성되면 데이터베이스에 업데이트해 주는 로직이 필요하다.

@Transactional
public void websessionIdUpate(Long gameRoomId, String token, String userUUID) {
    // 유저 정보 인증부
    Claims claims = jwtUtil.authorizeToken1(token);
    Long userId = (Long) claims.get("id");
    User user = userRepository.findById(userId).orElseThrow(
            () -> new CustomException(StatusMsgCode.USER_NOT_FOUND)
    );

    // 해당 User 데이터로 GameRoomUser 데이터 가져오기
    GameRoomUser gameRoomUser = gameRoomUserRepository.findByUserIdAndGameRoomId(user.getId(), gameRoomId);

    // 해당 GameRoomUser 업데이트
    GameRoomUser updateGameRoomUser = GameRoomUser.builder()
            .id(gameRoomUser.getId())
            .gameRoom(gameRoomUser.getGameRoom())
            .userId(gameRoomUser.getUserId())
            .nickname(gameRoomUser.getNickname())
            .webSessionId(userUUID) // WebSessionId 업데이트
            .build();
}

GameRoomService 클래스 내에 위와 같은 메서드를 작성하고, SignalingHandler 내에서 호출해 주었다.

이런 식으로 WebRTC 연결 외 다른 로직은 Service단에서 작성하고, 메서드를 호출해 오면 된다!

 

 

3️⃣ 전체적인 리팩토링

👉Github으로 전체 코드 보러 가기👈

 

메인 로직과 DB를 연결했기 때문에 게임 내에서 WebRTC가 동작하는 데는 전혀 문제가 없었다.
하지만 기본적인 틀은 계속 유지된 채로 여러 기능(강퇴 기능, 방장 위임 기능 등등..)이 추가되다 보니 SignalingHandler가 굉장히 거대해졌다.
코드 다이어트의 필요성을 느끼고, 대공사 리팩토링에 들어갔다.

 

가장 먼저, 불필요하게 주고받던 중복된 데이터를 하나의 구조로 컴팩트하게 변경해 주었다.
클라이언트에서 필요한 정보는 결국 입장한 방의 다른 유저 정보라는 사실에 주목했다.

기존에는 방장 여부와 방장의 WebSessionId(is_host), 게임 준비 상태(ready) 등을 전부 따로 조회하여 전달해주고 있었는데, 한 번의 조회로 모든 유저 정보를 담아 전달할 수 있도록 구조를 개선하였다.

// 리팩토링 전

// 방장 여부와 해당 방의 방장 session id 를 조회한 후 is_host 메시지 타입으로 전달
// 현재 방에 있는 모든 유저의 ready 상태와 방 인원을 조회한 후 ready 메시지 타입으로 전달
// 현재 방의 host 에게 게임 시작이 가능한 상태인지 조회한 후 all_ready 메시지 타입으로 전달


// 리팩토링 후

// 본인을 포함한 현재 방의 전체 유저 정보를 조회한 후 attendee 메시지 타입으로 전달
// 전체 정보를 한 번에 전달하면서 불필요하게 중복되던 데이터 구조 개선
// 예) [ { userId: 2, nickname: "닉네임", imgUrl: "avatar.png", isHost: true, isReady: true, socketId: "qw5lkvtn"}, { ... } ]

이렇게 하니 확장성이 높아져 attendee라는 메시지 타입으로 개선된 구조의 객체 하나만 클라이언트로 전달하면 대부분의 로직을 수행할 수 있었다.

 

또한, WebRTC 연결 로직(rtc)과 인게임 로직(ingame)을 구분하기 위해 상수명을 변경해 주었다.

// 시그널링에 사용되는 메시지 타입 :
// SDP Offer 메시지
private static final String MSG_TYPE_OFFER = "rtc/offer";
// SDP Answer 메시지
private static final String MSG_TYPE_ANSWER = "rtc/answer";
// 새로운 ICE Candidate 메시지
private static final String MSG_TYPE_CANDIDATE = "rtc/candidate";
// 본인을 제외한 현재방의 유저 리스트 반환 메시지
private static final String MSG_TYPE_ALL_USERS = "rtc/all_users";
// 방 나가기 메시지
private static final String MSG_TYPE_USER_EXIT = "rtc/user_exit";

// 방 입장 메시지
private static final String MSG_TYPE_JOIN_ROOM = "ingame/join_room";
// 게임 준비 메시지
private static final String MSG_TYPE_TOGGLE_READY = "ingame/toggle_ready";
// 현재 게임 방의 전체 유저 정보 메시지
private static final String MSG_TYPE_ATTENDEE = "ingame/attendee";
// 게임이 끝났을 때 반환 메시지
private static final String MSG_TYPE_ENDGAME = "ingame/end_game";
// 강퇴시 반환 메시지
private static final String MSG_TYPE_KICK = "ingame/kick";
// 강퇴당한 사람 반환 메시지
private static final String MSG_TYPE_BE_KICKED = "ingame/be_kicked";

 

이렇게 약 100줄가량의 코드 다이어트에 성공!
깔끔해진 코드를 보면서 내 속이 다 후련했다.

 

 

 

글을 마치며

처음에는 정말 맨땅에 헤딩하는 기분이었는데,
이렇게 작은 스코프로 먼저 기능을 구현한 뒤 살을 붙여나가는 방식으로 개발하면서
구현된 기능을 바로바로 확인할 수 있어 지치지 않고 헤쳐나갈 수 있었다.

뒤로 갈수록 게임 로직과 많이 엮이다 보니 글에는 전부 담지 못했지만,
WebRTC를 처음 마주하는 사람들에게 이 글이 도움이 되었으면 좋겠다!

👉Github으로 완성된 전체 코드 보러 가기👈

 

 

 

 

📚 참고자료

 

WebRTC 사용방법(예제)

1. 개요 두 브라우저가 통신해야하는 경우 일반적으로 통신을 조정하고 둘 사이에 메시지를 전달하기 위해 그 사이에 서버가 필요합니다. 그러나 중간에 서버가 있으면 브라우저 간의 통신이 지

memo-the-day.tistory.com

 

WebRTC 구현하기(1:N P2P)

WebRTC의 이론을 기반으로 1:N P2P 실시간 영상 송수신을 구현해보자.

millo-L.github.io

 

GitHub - Benkoff/WebRTC-SS: WebRTC Signaling Server

WebRTC Signaling Server. Contribute to Benkoff/WebRTC-SS development by creating an account on GitHub.

github.com