프로그래밍/JAVA

끊김 없는 사용자 경험을 위한 WebSocket 세션 복구 (재연결) 구현

hwangsehee 2026. 3. 17. 00:44

안녕하세요. 

 

오늘은 데브코스에서 진행했던 프로젝트인 온라인 게임 플랫폼(뇌이싱)에서 구현했던 재연결 로직 개발 과정을 기록해보려합니다. 

 

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 등 메시지 큐로 이관하여 진정한 분산 환경에 대응하는 설계를 완성해보고 싶습니다.

 

처음 도전해본 웹소켓, 동시성 이슈 등 .. 많이 고민했고 발전했습니다. 좋은 팀원들을 만나 더욱 더 몰입할 수 있던 프로젝트였습니다.

프로젝트 회고도 따로 쓰겠지만 이자리를 빌어 같이 해준 팀원들에게 감사의 말씀을 드리고싶습니다. 

 

더 깊은 고민들로 돌아오겠습니다. 

 

감사합니다 !