안녕하세요.
오늘은 데브코스에서 진행했던 프로젝트인 온라인 게임 플랫폼(뇌이싱)에서 구현했던 재연결 로직 개발 과정을 기록해보려합니다.
1. 재연결의 필요성
실시간 퀴즈 서비스를 개발하며 가장 큰 고민은 사용자 이탈이었습니다. 웹소켓 특성상 네트워크가 1초만 끊겨도 세션이 종료되는데, 게임을 진행하던 유저가 새로고침 한 번에 퀴즈방에서 튕겨 나간다면 사용자 경험이 저하될 것을 우려해 실수로 끊겨도 다시 돌아올 수 있는 안정적인 게임 환경을 만드는 것이 이번 구현의 목표였습니다.
2. 설계
재연결 로직을 구현하기 위해 크게 세 가지 장치를 설계했습니다.
- 감지: SessionDisconnectEvent와 커스텀 HeartbeatMonitor를 통해 유저의 상태를 실시간으로 추적합니다.
- 유예: 유저가 disconnected 되자마자 퇴장시키는 것이 아니라, DisconnectTaskManager를 통해 5초의 유예 시간을 부여했습니다.
- 복구: 5초 이내에 재접속 시, 예약된 퇴장 Task를 취소하고 유저에게 기존 게임 데이터를 다시 전송합니다.
3. 트러블 슈팅
설계할 때와는 달리 실제 구현 과정에서 두 가지 큰 난관에 봉착했습니다.
유령 세션 (Race Condition)
퇴장 로직이 실행되는 도중에 유저가 빛의 속도로 재입장하면 이전 세션 정보가 남아서 유령 세션이 발생합니다.
- 해결: DisconnectTaskManager를 도입해 5초의 시간차이를 두고 LockExecutor를 통해 세션 처리의 원자성을 보장했습니다.
handleDisconnectedListener 구현
// 1. 우선 사용자의 상태를 DISCONNECTED로 변경 (상태만 먼저 변경)
lockExecutor.executeWithLock(ROOM_LOCK_PREFIX, roomId, () ->
roomService.changeConnectedStatus(roomId, userId, ConnectionState.DISCONNECTED)
);
// 2. 5초 뒤에 실행될 스케줄링 태스크 등록
taskManager.scheduleDisconnectTask(userId, () -> {
// 5초 뒤, 다시 한번 락을 잡고 상태를 확인합니다.
lockExecutor.executeWithLock(USER_LOCK_PREFIX, userId, () -> {
lockExecutor.executeWithLock(ROOM_LOCK_PREFIX, roomId, () -> {
// 핵심: 5초 사이 재연결이 되었다면 상태가 CONNECTED로 바뀌었을 것!
// 여전히 DISCONNECTED인 경우에만 최종 퇴장 처리를 진행.
if (ConnectionState.DISCONNECTED.equals(roomService.getPlayerState(userId, roomId))) {
roomService.disconnectOrExitRoom(roomId, principal);
}
});
});
});
데드락(Deadlock)
처음에는 로직마다 락을 잡는 순서가 제각각이었습니다. (어떤 곳은 roomId 먼저, 어떤 곳은 userId 먼저) 이로 인해 스레드끼리 서로의 자원을 기다리는 데드락이 발생했습니다.
- 해결: 모든 서비스 로직에서 UserId -> RoomId 순서로 락을 획득하도록 표준화했습니다. 또한, AOP의 한계를 넘기 위해 직접 LockExecutor를 구현하여 중첩 락을 안전하게 관리했습니다.
LockExecutor 구현
public <T> T executeWithLock(String prefix, Object key, Supplier<T> supplier) {
String lockKey = formatLockKey(prefix, key);
RLock rlock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
acquired = rlock.tryLock(DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_TIME_UNIT);
if (!acquired) {
log.warn("[LockExecutor] Lock acquisition failed: {}", key);
throw new CustomException(CommonErrorCode.LOCK_ACQUISITION_FAILED);
}
log.info("[LockExecutor] Lock acquired: {}", key);
return supplier.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CustomException(CommonErrorCode.LOCK_INTERRUPTED);
} finally {
if (acquired && rlock.isHeldByCurrentThread()) {
rlock.unlock();
log.info("[LockExecutor] Lock released: {}", key);
}
}
}
RoomService - userId -> roomId 순서로 Lock 획득
public void enterRoom(RoomValidationRequest request) {
Long roomId = request.roomId();
Room room = findRoom(roomId);
/* 다른 방 접속 시 기존 방은 exit 처리 - 탭 동시 로그인 시 (disconnected 리스너 작동x) */
lockExecutor.executeWithLock(
USER_LOCK_PREFIX,
getCurrentUserId(),
() -> exitIfInAnotherRoom(room, getCurrentUserPrincipal()));
lockExecutor.executeWithLock(
ROOM_LOCK_PREFIX, roomId, () -> performEnterRoomLogic(request));
}

4. 결과 및 회고
initializeRoom 과 enterRoom 호출 순서
처음에는 initializeRoom(소켓 연결)을 먼저 실행하고 enterRoom을 호출하는 순서로 설계했었습니다.(단순히 소켓 연결 -> 게임 방 입장이라는 연결 후 처리를 생각) 하지만 실제 테스트를 해보니 소켓 연결이 완료되기도 전에 입장 처리가 시도되어 정합성이 깨지는 문제가 발생했습니다. 시행착오 끝에 "입장 권한을 먼저 확인하고, 그 뒤에 소켓을 연결한다"는 지금의 흐름으로 순서를 완전히 뒤바꾸며 로직을 리팩토링했습니다. 이 과정을 통해 이론적인 설계와 실제 런타임의 흐름은 다를 수 있다는 것을 느꼈습니다.
동시성 제어
단순히 "락을 걸면 되겠지"라고 가볍게 생각했던 동시성 제어는 예상보다 훨씬 까다로웠습니다. 락을 적용하는 과정에서 발생한 데드락은 결국 락의 계층 구조를 명확히 정의하고 나서야 해결할 수 있었습니다. (+ 덕분에 락에 대해서 공부할 수 있었고 동이 트는걸 볼 수 있었습니다...)
여러가지 락 중에서 Redisson을 선택한 이유는 분산환경으로의 확장성과 랭킹 기능을 위해 Redis를 도입했기에 별도의 추가 인프라 구축 없이 이미 도입된 Redis 를 활용할 수 있던 점이 가장 큰 비중을 차지합니다. 특히 직접 스핀 락을 구현하지 않고 Redisson의 라이브러리를 사용할 수 있기에 로직의 복잡성을 줄일 수 있다고 판단했습니다.
확장성과 실용성 사이의 고민
RoomRepository는 인터페이스로 추상화하여 확장성을 챙기면서도, UserRoomRepository는 성능을 위해 단일 클래스로 유지하는 등 확장성과 개발 효율성 사이에서 적절한 타협점을 찾는 과정도 큰 공부가 되었습니다. 무조건적인 오버 엔지니어링보다는 현재의 요구사항과 미래의 변경 가능성을 생각하며 개발했습니다. 이 과정에서는 팀원들과 의견조율을 하면서 더욱 더 돈독해졌습니다. 의견을 나누면서 깊어지는 건설적인 고민들이 인상 깊었습니다.
남겨진 과제
로직상의 정합성은 확보했지만, 아직 수천 명의 유저가 동시에 몰리는 대규모 부하 테스트를 거치지 못했습니다. 앞으로는 JMeter를 활용해 제가 설계한 락 구조와 재연결 로직이 대량의 트래픽에서도 성능 저하 없이 견디는지 검증해 볼 계획입니다. 또한, 현재의 메모리 기반 Task 관리를 Kafka 등 메시지 큐로 이관하여 진정한 분산 환경에 대응하는 설계를 완성해보고 싶습니다.
처음 도전해본 웹소켓, 동시성 이슈 등 .. 많이 고민했고 발전했습니다. 좋은 팀원들을 만나 더욱 더 몰입할 수 있던 프로젝트였습니다.
프로젝트 회고도 따로 쓰겠지만 이자리를 빌어 같이 해준 팀원들에게 감사의 말씀을 드리고싶습니다.
더 깊은 고민들로 돌아오겠습니다.
감사합니다 !
'프로그래밍 > JAVA' 카테고리의 다른 글
| JWT 기반 로그인 구현하기 (1) | 2025.08.25 |
|---|---|
| toArray(new String[0]); 이 권장되는 이유 (3) | 2025.08.06 |
| Collection 자료구조 (0) | 2025.02.15 |
| String.valueOf(),toString() 차이점 (0) | 2025.01.31 |
| 필드 선언 시 기본 데이터 타입,불변객체 / 참조 타입 차이점 (0) | 2025.01.24 |