
0. 들어가며
출시 한 달 만에 가장 많이 들었던 문의였습니다.
“앱 전환하고 돌아오면 게임이 멈춰버려요.”
“카톡 잠깐 보고 왔는데, 다시 들어가 보니 이미 끝나 있었어요.”
문제는 네트워크가 아니라, 우리가 모바일 유저를 이해하지 못했다는 것이었습니다.
처음에는 문제를 단순하게 생각했습니다.
“앱을 벗어나는 순간 소켓 연결을 종료하면 되겠구나.”
하지만 직접 테스트해 보니 상황은 달랐습니다. 카톡 알림을 확인하고 돌아올 때마다 게임에서는 즉시 퇴장 처리되었고, 유저는 처음부터 다시 시작해야 했습니다. 문제에 대응하려다, 오히려 유저의 경험을 통째로 망치고 있었던 셈입니다.

우리 팀은 PC와 모바일, 모든 브라우저에서 동일하게 동작해야 한다는 전제를 가지고 있었습니다.
하지만 모바일 유저의 현실은 달랐습니다. 지하철을 타며 변하는 네트워크, 와이파이와 데이터를 오가는 전환, 급한 연락을 확인하는 앱 스위칭. 이런 당연한 행동들이 실시간 소켓 연결에서는 모두 예외 상황이었습니다.
이 글에서는 연결 단절을 전제로, 그 이후의 사용자 경험을 어떻게 설계했는지를 다룹니다. 구조 분리, 서버 기준 세션 관리, 그리고 끊김 이후의 복구 전략을 통해 실시간 게임에서 프론트엔드가 책임질 수 있는 역할을 정리합니다.
📌 이 글은 이런 분들을 위해 썼습니다
- 실시간 소켓 통신을 처음 다뤄보는 프론트엔드 개발자
- 모바일 환경에서의 연결 불안정성 문제를 겪고 있는 분
- "연결 끊김"을 예외가 아닌 전제로 설계하고 싶은 분
1. 문제 배경
프로젝트 초기에는 빠른 기능 검증과 런칭이 가장 중요한 목표였습니다. 실시간 게임의 기본 흐름을 빠르게 구현하기 위해, 모든 소켓 관련 로직을 하나의 SocketProvider에 집중시키는 구조를 선택했습니다. 연결 관리, 이벤트 처리, 게임 상태 변경까지 한 곳에서 관리하는 방식은 구현 속도 면에서는 충분히 합리적인 선택이었습니다.
하지만 서비스가 운영 단계에 접어들면서 이 구조의 한계가 점점 드러나기 시작했습니다. 모바일 환경에서의 탭 전환, 네트워크의 일시적인 끊김, 그리고 재연결과 같은 상황들이 반복되면서 단일한 소켓 흐름으로는 다양한 상태를 안정적으로 처리하기 어려워졌습니다.
문제는 단순히 연결이 끊어진다는 사실이 아니었습니다. 연결이 끊겼을 때 얼마 동안 유저를 기다려야 하는지, 돌아온 유저를 어떤 상태로 복구해야 하는지, 끝내 돌아오지 않는 연결은 언제 정리해야 하는지에 대한 기준이 구조 안에 존재하지 않았습니다.

이로 인해 하나의 수정이 다른 흐름에 영향을 주거나, 특정 환경(특히 모바일)에서만 재현되는 문제들이 반복되었습니다. 단순한 예외 처리가 아니라, 연결 단절을 전제로 한 설계 자체가 필요하다는 판단에 이르게 되었습니다.
2. 어떻게 해결했는가
문제를 단순히 "연결을 유지하는 것"으로 접근하지 않고, 연결이 끊어지는 상황을 전제로 한 구조로 다시 설계하는 것이 목표였습니다.
이를 위해 해결 과정은 크게 네 단계로 나누어 진행했습니다.
2-1. 구조 개선: 역할 단위로 책임을 분리하다

가장 먼저 손댄 것은 소켓 구조 자체였습니다. 기존에는 모든 소켓 로직이 하나의 SocketProvider에 집중되어 있었고, 연결 관리, 이벤트 처리, 재연결, 탭 전환 정책까지 서로 다른 책임이 한 곳에 뒤섞여 있었습니다.
이 구조에서는 특정 정책을 수정하면 다른 흐름에 영향을 주기 쉬웠고 모바일 환경에서만 발생하는 문제를 국소적으로 해결하기 어려웠습니다.
그래서 SocketProvider를 유지하되, 역할별로 책임을 분리한 커스텀 훅들의 조합 구조로 리팩토링했습니다.
- useSocketConnection → 소켓 인스턴스와 핵심 연결 생명주기 관리
- useSocketEvents → 이벤트 리스너를 한 곳에서 관리
- useSocketSync → 서버 기준 게임 상태 동기화
- useTabSwitchPolicy → 탭 전환 시 즉시 종료를 막는 Grace Period 정책
useGameExitPolicy → 의도된 종료 흐름 관리 - useNetworkStatus→ 연결 상태를 단일 인터페이스로 제공
이렇게 구조를 나누면서 각 정책을 독립적으로 수정·확장할 수 있게 되었고, 문제의 원인을 훨씬 좁은 범위에서 추적할 수 있게 되었습니다. 구조 변경 후의 코드는 다음과 같습니다.
// SocketProvider.tsx (After)
const SocketProvider = ({ children }) => {
// 각 역할을 담당하는 전문 훅들
const { socket, isSocketConnected } = useSocketConnection();
const { isTabVisible } = useTabSwitchPolicy(socket);
const { exitGame } = useGameExitPolicy(socket);
const { hasNetworkConnection, connectionStatus } = useNetworkStatus({ isSocketConnected });
// 중앙 집중형 이벤트 관리
useSocketEvents({
socket,
handleGameStateSync, // useSocketSync에서 주입
forceExitGame: () => exitGame(clearReconnectionState),
// ... 기타 핸들러
});
return (
<SocketContext.Provider value={{ socket, isSocketConnected, connectionStatus }}>
{children}
</SocketContext.Provider>
);
};
동시에 추적 가능한 구조를 위해 디버깅 기반을 다졌습니다.
- Zustand를 활용해 게임 상태와 채팅 상태를 명확히 분리
- 개발 환경 전용 공통 로깅 모듈(appLogger) 도입 → 임시 console.log 제거, 상태 추적 가시성 확보
- 서버 측에서는 게임 엔진 핸들러를 리팩토링해 cleanup / ending / timer 로직을 분리
- 플레이어 활동 추적 미들웨어 추가
이를 통해 클라이언트와 서버 모두에서 “언제, 왜, 어떤 상태로 바뀌었는지”를 명확하게 파악할 수 있는 구조를 만들었습니다.
2-2. 세션 관리: "클라이언트 신뢰"에서 "서버 강제 세션"으로 전환
다음으로 해결해야 할 문제는 게임 흐름을 클라이언트가 너무 많이 통제하고 있다는 점이었습니다. 기존 구조에서는 URL 직접 접근이나 새로고침과 같은 행동이 게임 세션 흐름을 쉽게 우회할 수 있었고, 이는 보안과 안정성 측면에서 취약한 구조였습니다.
이를 해결하기 위해 세션의 시작과 유효성을 서버가 명확하게 통제하는 방식으로 전환했습니다.

- 서버 액션(startSetupSession, startGameSession)을 통해 세션 시작을 명시적으로 관리
- 쿠키 기반 미들웨어로 접근 제어 강제
- /setup/* → nyant_setup_session 필수
- /game/[id], /waiting/[id] → URL의 gameId와 일치하는 nyant_game_session 필수
// middleware.ts
export const middleware = (request: NextRequest) => {
const { pathname } = request.nextUrl;
const gameSessionId = request.cookies.get('nyant_game_session')?.value;
if (pathname.startsWith('/game')) {
const pathGameId = pathname.split('/')[2];
// URL의 게임ID와 쿠키의 세션ID가 일치할 때만 허용
if (gameSessionId && gameSessionId === pathGameId) {
return NextResponse.next();
}
return NextResponse.redirect(new URL('/', request.url));
}
}
이를 통해 의도치 않은 새로고침이나 직접 URL 접근으로 게임 흐름이 깨지는 문제를 구조적으로 차단할 수 있었습니다.
2-3. 복구 전략: 끊김 이후의 경험을 설계하다
연결이 끊어지는 상황 자체를 완전히 막을 수는 없었습니다. 대신 끊어진 이후의 흐름을 어떻게 설계할 것인가에 집중했습니다.

서버 측: Grace Period 도입
- startGraceTimer를 도입해 플레이어가 일정 시간 안에 재연결할 수 있는 유예 기간을 제공
클라이언트 측: 서버 Snapshot 기반 동기화
- useSocketSync를 통해 재연결 시 서버 Snapshot 기반으로 게임 상태를 강제로 동기화
- 탭 전환이나 네트워크 일시 중단 이후에도 게임 상태가 어긋나지 않고 이어질 수 있도록 설계
시각적 피드백
유저가 현재 상황을 인지할 수 있도록 연결 상태에 대한 즉각적인 시각적 피드백을 추가했습니다.
- ConnectionIndicator
- NetworkBanner
- SnackBar


HTTP Polling: 사파리의 '좀비 연결' 문제 해결
💡 좀비 연결(Zombie Connection)이란?
소켓 연결이 즉시 종료되지 않고 서버나 클라이언트 중 한쪽에서만 연결이 '살아 있다'고 인식하는 상황
특정 환경(특히 iOS 사파리)에서는 모바일에서 네트워크를 강제로 끊거나 앱을 전환했을 때, 브라우저가 백그라운드에 있을 때 disconnect 이벤트가 지연되거나 누락되는 경우가 있었습니다.
그래서 실시간 소켓 이벤트에만 의존하지 않고, 세션의 생존 여부를 서버 기준으로 명시적으로 확인할 수 있는 보조 수단으로 HTTP Polling을 도입했습니다.

- 실시간 데이터 흐름: 소켓이 담당
- 세션 유효성 판단: HTTP 요청을 통해 서버에 직접 확인
HTTP Polling은 실시간성만 놓고 보면 소켓보다 느린 선택일 수 있습니다. 하지만 이 지점에서는 즉각적인 반응보다 "지금 이 연결이 실제로 유효한가"를 정확히 판단하는 것이 더 중요했습니다.
그 결과, 사파리 환경에서도 좀비 세션을 방치하지 않고 안정적으로 정리할 수 있었고, 서버 리소스와 게임 흐름 모두를 일관되게 유지할 수 있었습니다.
끊김 자체보다, 끊김 이후의 불확실성을 줄이는 것이 목표였습니다.
3. 설계를 통해 얻은 기준
이번 작업을 통해 단순히 문제를 해결하는 것보다, 실시간 시스템을 어떤 관점으로 바라봐야 하는지에 대한 기준이 남았습니다.
첫 번째 기준은, 실시간 서비스에서 연결 끊김은 예외 상황이 아니라 항상 발생할 수 있는 전제라는 점입니다. 모바일 환경에서는 탭 전환, 네트워크 변경, 백그라운드 전환이 언제든 일어날 수 있습니다. 따라서 “연결을 끊기지 않게 만드는 것”보다, 끊긴 이후의 상태를 어떻게 다룰 것인지가 설계의 핵심이라는 판단에 이르렀습니다.
두 번째 기준은, 정책이 개입되는 지점은 구조적으로 분리되어야 한다는 점입니다. 초기에는 하나의 SocketProvider에서 모든 흐름을 처리했지만, 유예, 재연결, 종료, 동기화 같은 정책이 늘어날수록 문제의 원인과 영향 범위를 파악하기 어려워졌습니다. 역할 단위로 책임을 나누면서, 각 정책을 독립적으로 수정하고 검증할 수 있었고, 문제가 발생했을 때 접근해야 할 범위도 명확해졌습니다.
세 번째 기준은, 게임의 흐름과 세션의 유효성은 클라이언트가 아니라 서버가 책임져야 한다는 점입니다. 클라이언트는 상태를 표현하고 복구하는 역할에 집중하고, 서버가 흐름을 강제할 때 새로고침이나 재연결 같은 예외 상황에서도 일관된 상태를 유지할 수 있었습니다.
마지막 기준은, 모든 상황을 사전에 통제하려 하기보다 상태 변화를 관찰할 수 있는 기반을 먼저 마련하는 것이 현실적인 해결책이 될 수 있다는 점이었습니다. 테스트 코드만으로는 다루기 어려운 문제들이 있었지만, 상태 전이를 명확히 추적할 수 있는 구조를 갖추는 것만으로도 문제를 훨씬 빠르게 좁혀갈 수 있었습니다.
이 기준들은 특정 구현에 국한된 결론이 아니라, 이후 실시간 기능을 설계할 때 계속 참고하고 싶은 판단의 기준이 되었습니다.
4. 정리하며
실시간 소켓 통신에서 가장 어려웠던 점은 끝없이 등장하는 엣지 케이스들이었습니다. 특히 모바일 환경에서는 탭 전환과 네트워크 변화가 언제든 발생할 수 있다는 점을 다시 한 번 체감했습니다.
이번 작업을 통해 얻은 가장 큰 깨달음은, 문제를 완벽하게 통제하려 하기보다 상태를 관찰하고, 끊어진 이후의 흐름을 설계하는 것이 더 중요하다는 점이었습니다. 이를 위해 구조를 나누고, 서버가 책임져야 할 경계를 명확히 하며, 디버깅이 가능한 기반을 먼저 마련하는 선택을 했습니다.
이렇게 바꾸고 나서, 적어도 "앱 전환하고 돌아오면 게임이 멈춰요"라는 문의는 더 이상 받지 않게 되었습니다. 완벽한 해결은 아니지만, 모바일 환경을 전제로 설계한다는 것이 무엇인지 배울 수 있었습니다.
이 경험을 통해 확신하게 되었습니다. 기술적인 완벽함보다 중요한 것은 문제를 마주했을 때 함께 고민할 수 있는 사람과, 그 과정에서 얻은 통찰을 다음 선택에 반영하는 태도라는 것을요.
이 글을 정리하는 과정에서 함께 고민을 나눠주신 종길 님, 원규 님, 종호 님, 윤우 님, 다은 님, 혁준 님께 감사드립니다.
덧붙이며, 실제 동작이 궁금하다면 아래 주소에서 간단히 확인해볼 수 있습니다.
👉 배포사이트 | https://www.nyantcoin.space/
👉 깃허브 | https://github.com/mbti-dle/nyant-coin
모바일 환경에서 앱 전환이나 네트워크 끊김 상황을 함께 테스트해보면, 이 글에서 다룬 흐름을 조금 더 잘 체감할 수 있습니다.
버그 제보도 언제든 환영입니다.
'냥트코인' 카테고리의 다른 글
| [냥트코인 제작기 3편] Socket.io 재연결과 동기화, 800줄 리팩토링까지 (0) | 2025.09.19 |
|---|---|
| [냥트코인 제작기 2편] 프론트엔드 개발자가 어떻게 디자인과 퍼블리싱을 했을까? (0) | 2025.02.25 |
| [냥트코인 제작기 1편] 프론트엔드 개발자가 어떻게 게임 기획을 했을까? (0) | 2025.01.15 |