안녕하세요, 정말 오랜만이네요
이번에 프로젝트를 끝내게 되어서 회고할 겸 찾아왔습니다.
이번에 진행했던 프로젝트는 실시간 멀티 러닝 게임이었습니다. 일반적으로 게임은 유니티를 써서 구현하는데, 저는 웹 개발자기 때문에 스프링 부트를 썼고 프론트는 갤럭시 워치와의 연결을 위해 코틀린을 썼습니다.

목차는 아래와 같습니다.
1. 왜 Netty의 Socket.io를 썼는지
2. 기본적인 로직(코틀린과의 연결)
3. 어려웠던 부분
같은 게임에 참여중인 유저들끼리 서로 실시간으로 러닝 데이터를 공유하고 협동을 통해 보스를 쓰러뜨리는 점에서 웹소켓을 썼어야 했습니다.
웹소켓 중에서도 stomp를 쓸 지, socket.io를 쓸 지 부터가 큰 고민이었습니다.
처음에는 stomp를 위주로 개발을 했었다가 클라이언트 연결 끊김 처리때문에 socket.io로 선택하게 되었습니다.
말고도,
- 연결 복원 메커니즘 구현
- 유저 연결 상태 추적
- 모바일 환경 최적화
- 클라이언트 식별 및 세션 관리 지원
이런 점을 고려하여 socket.io를 채택하게 되었습니다.
socket.io는 이벤트 기반 모델이라서 아래와 같이 서버/클라이언트에서 이벤트를 송수신할 수 있습니다.

방 생성은 간단합니다.
유저가 방을 만들면 해당 방에 들어올 수 있는 초대코드를 생성하게 됩니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class SocketEventHandler {
private final SocketIOServer server;
private final GameService gameService;
private final GameRoomManager gameRoomManager;
@PostConstruct
public void init() {
server.addConnectListener(this::handleConnect);
server.addDisconnectListener(this::handleDisconnect);
// 비공개 방 생성 이벤트
server.addEventListener("createRoom", CreateRoomRequest.class,
(client, data, ack)->handleCreateRoom(client, data));
}
private void handleCreateRoom(SocketIOClient client, CreateRoomRequest request) {
String userId = client.get("userId");
String characterId = client.get("characterId");
String nickname = client.get("nickname");
String characterImage = client.get("characterImage");
if(userId==null){
client.sendEvent("error", "Not authenticated");
return;
}
try{
GameRoom room = gameService.createPrivateRoom(
userId, request, characterId, nickname, characterImage, client.getSessionId());
client.joinRoom(room.getId());
client.sendEvent("roomCreated", new roomCreatedResponse(
room.getId(),
room.getInviteCode(),
room.getPlayers().size(),
room.getMaxPlayers()
));
// 게임 시작 조건 체크
gameService.checkAndStartGame(room);
}catch (Exception e){
client.sendEvent("error", e.getMessage());
}
}
/**
* 방 생성
* @param userId 유저 식별자
* @param request 방 정보(보스 레벨, 참여 인원)
* @return
*/
public GameRoom createPrivateRoom(String userId, CreateRoomRequest request, String characterId, String nickname, String characterImage, UUID sessionId){
// 새로운 방 생성
GameRoom newRoom = new GameRoom(
UUID.randomUUID().toString(),
request.getBossLevel(),
request.getMaxPlayers(),
false
);
// 초대 코드 생성
String inviteCode = generateInviteCode();
inviteCodes.put(inviteCode, newRoom.getId());
newRoom.setInviteCode(inviteCode);
gameRoomManager.addRoom(newRoom);
handlePlayerJoin(newRoom, userId, characterId, nickname, characterImage, sessionId); // handlePlayerJoin 사용
return newRoom;
}
방 참여는 유저가 초대 코드를 입력해서 방에 들어가면, 해당 방에 있는 사람들은 들어온 사람의 정보를 받게되고 해당 방에 사람이 가득차게 되면 자동으로 게임이 시작되게 됩니다.
server.addEventListener("joinRoom", JoinRoomRequest.class,
(client, data,ack)->handleJoinRoom(client, data));
private void handleJoinRoom(SocketIOClient client, JoinRoomRequest request) {
String userId = client.get("userId");
String characterId = client.get("characterId");
String nickname = client.get("nickname");
String characterImage = client.get("characterImage");
if(userId==null){
client.sendEvent("error", "Not authenticated");
return;
}
try{
GameRoom room = gameService.joinRoomByInviteCode(userId, request.getInviteCode(), characterId, nickname, characterImage, client.getSessionId());
client.joinRoom(room.getId());
// 방 참여 성공 응답
client.sendEvent("roomJoined", new RoomJoinedResponse(
room.getId(),
room.getInviteCode(),
room.getPlayers().size(),
room.getMaxPlayers()
));
// 같은 방의 다른 유저들에게 새 유저 입장 알림
server.getRoomOperations(room.getId()).sendEvent("playerJoined", new PlayerJoinedResponse(
userId,
nickname,
room.getPlayers().size(),
room.getMaxPlayers()
));
// 게임 시작 조건 체크
gameService.checkAndStartGame(room);
}catch (Exception e){
client.sendEvent("error", e.getMessage());
}
}
어려웠던 부분은 진짜 많은데 그 중에서 재연결처리가 정말 힘들었습니다.
게임이 시작 전에 유저가 연결이 끊겼다면 그저 그 방에서 나감처리만 하면 됐는데, 게임 중에 의도치 않게 튕겼을 때는 재접속이 되게끔하고 싶었습니다.
그리고 만약 제한 시간 내에 들어오지 못한다면 방에 남아있는 사람들이 게임을 계속할건지 투표하는 기능도 넣고 싶었습니다.
유저가 연결이 끊겼을 때 해당 유저 정보를 레디스에 저장하고, 유저가 다시 연결을 시도할 때 유저의 아이디를 기준으로 레디스에 조회하고 있다면 유저가 속했던 방에 참여되게끔 했어야 했습니다.
private void handleDisconnect(SocketIOClient client) {
String socketId = client.getSessionId().toString();
String userId = client.get("userId"); // 직접 client에서 userId를 가져옴
String nickName = client.get("nickname");
if (userId != null) { // userId가 있는 경우에만 처리
gameRoomManager.findRoomByUserId(userId).ifPresent(room -> {
if (room.getStatus() == GameStatus.PLAYING) {
// 게임 중일 때는 연결 끊김 특수 처리
disconnectionManager.handlePlayerDisconnection(room, userId, nickName);
} else {
// 게임 중이 아닐 때는 기존 로직대로 처리
handleNormalDisconnection(room, userId, socketId, nickName);
}
});
sessionManager.removeSession(socketId);
}
log.info("Client disconnected: {}", socketId);
}
public void handlePlayerDisconnection(GameRoom room, String userId, String nickName) {
Player player = room.getPlayerById(userId);
if (player == null) return;
// Redis에 연결 끊긴 플레이어 정보 저장
DisconnectedPlayerData disconnectedData = new DisconnectedPlayerData(
room.getId(),
userId,
nickName,
player.getRunningData(),
player.getUsedItemCount(),
System.currentTimeMillis()
);
String redisKey = "disconnected:" + userId;
redisTemplate.opsForValue().set(redisKey, disconnectedData);
redisTemplate.expire(redisKey, RECONNECT_TIMEOUT, TimeUnit.SECONDS);
// 남은 플레이어들에게 알림
server.getRoomOperations(room.getId()).sendEvent("playerDisconnected",
new PlayerDisconnectedResponse(userId, nickName, RECONNECT_TIMEOUT));
// 3분 후 투표 시작 - Redis 데이터 체크 없이 바로 투표 시작
scheduler.schedule(() -> {
// 방이 아직 존재하고 게임 중인지 확인
GameRoom currentRoom = gameRoomManager.getRoom(room.getId()).orElse(null);
if (currentRoom != null && currentRoom.getStatus() == GameStatus.PLAYING) {
initiateGameEndVote(currentRoom);
}
}, RECONNECT_TIMEOUT, TimeUnit.SECONDS);
}
public boolean handlePlayerReconnection(String userId, SocketIOClient client) {
String redisKey = "disconnected:" + userId;
DisconnectedPlayerData data = redisTemplate.opsForValue().get(redisKey);
if (data == null || isReconnectTimeoutExpired(data.getDisconnectionTime())) {
return false;
}
GameRoom room = gameRoomManager.getRoom(data.getRoomId())
.orElse(null);
if (room == null || room.getStatus() != GameStatus.PLAYING) {
return false;
}
// 재접속 처리
Player player = new Player(userId);
player.setRunningData(data.getLastRunningData());
player.setUsedItemCount(data.getUsedItemCount());
room.addPlayer(player);
// 클라이언트 정보 설정
client.set("userId", userId);
// 세션 관리를 위한 추가
sessionManager.createSession(userId, client.getSessionId().toString());
// Redis에서 데이터 삭제
redisTemplate.delete(redisKey);
// 방에 재진입
client.joinRoom(room.getId());
// 다른 플레이어들에게 알림
server.getRoomOperations(room.getId()).sendEvent("playerReconnected",
new PlayerReconnectedResponse(userId, player.getNickname()));
return true;
}
네... 더 많은 코드가 있는데 더 많은 코드는 추후 깃허브에 공개하도록 하겠습니다.
네 읽어주셔서 감사합니다.