[C++ / 네트워크] 자체 엔진 및 Windows IOCP 전용 서버 기반 2인 협동 실시간 타워 디펜스 개발 회고록
원티드 게임 개발 과정 5기 (Wanted PotenUp 5th)
프로젝트명: Project SK DEFENSE
개발 기간: 2026.08.21 ~ 2026.09.08 (1인 개발)
핵심 키워드:C++20,Windows IOCP,Dedicated Server,A* Pathfinding,Server-Authoritative,Multi-threading
📌 목차
- 프롤로그: 왜 단순한 콘솔 게임에 데디케이티드 서버를 붙였을까?
- 기술 스택 및 시스템 아키텍처
- 핵심 기능 구현: 클라이언트부터 서버까지
- 자체 제작 콘솔 엔진과 하이브리드 마우스 입력
- A* 동적 길찾기와 미로 봉쇄 방지(Dry-Run Rollback)
- 3-단 타워 자동 합성(3-Merge) 시스템
- Windows IOCP 기반의 고성능 멀티스레드 서버 엔진
- 치열했던 트러블슈팅 (Real Engineering Stories)
- "한쪽 클라에서 돈이 40골드 모자라요": 분산 처치 오차와 서버 멱등성
- "파트너가 강제 종료하면 서버가 터져요": 재귀 락(Self-Deadlock)의 함정
- "재접속하면 플레이어 ID가 3번, 4번이 돼요": 방 단위 최소 슬롯 배정
- 프로젝트를 마치며: 배운 점과 엔지니어링적 회고
1. 프롤로그: 왜 단순한 콘솔 게임에 데디케이티드 서버를 붙였을까?
원티드 게임 개발 과정 5기를 진행하며 주어진 과제는 '자체 C++ 콘솔 프레임워크(CraftEngine)'를 활용한 게임 개발이었습니다.
대부분의 콘솔 게임 프로젝트가 1인용 싱글 플레이나 로컬 2인 플레이에 머무르는 것을 보며, 저는 한 걸음 더 나아가고 싶었습니다.
*"상용 게임 엔진(Unity, Unreal)의 플러그인에 의존하지 않고, 밑바닥(Low-Level)부터 C++ 소켓 통신을 직접 작성해 본다면 어떨까?"*
*"실제 라이브 게임처럼 제3의 전용 서버(Dedicated Server)가 게임 룰과 경제를 통제하는 서버 권한형 멀티플레이를 구현해 보자!"*
그렇게 스타크래프트의 명작 유즈맵 '성큰 디펜스'의 전략성과 타워 합성의 재미를 계승하고, Windows IOCP(I/O Completion Port) 기반의 멀티스레드 전용 서버를 결합한 2인 실시간 협동 타워 디펜스(Project SK DEFENSE) 개발이 시작되었습니다.
2. 기술 스택 및 시스템 아키텍처
[ Client 1 (Project_SKDefense) ] <-- TCP Packet --> [ Dedicated Server (SK_Defense_Server) ]
- Win32 Console Double Buffer - Windows IOCP Core (4 Worker Threads)
- Hybrid Mouse Tracker - GameRoomManager & GameRoom (Room Loop)
- A* Dynamic Pathfinding - Server-Authoritative Logic (Gold / Spawner)
- Sliding Window RecvBuffer
[ Client 2 (Project_SKDefense) ] <-- TCP Packet -->- 언어 (Language): C++20
- 네트워크 / 서버 (Server Engine): Windows IOCP, Winsock2, Multi-threading (4 Worker Threads Pool), 전용 헤드리스 데디케이티드 서버
- 클라이언트 / 프레임워크: 자체 제작 CraftEngine, Windows API (Win32 Console, Double Buffering)
- 아키텍처 패턴:
- 서버 권한형 (Server-Authoritative): 클라이언트는 요청만 전달, 서버가 단일 진실 공급원(SSOT)으로서 잔고와 처치를 최종 확정
- 오브젝트 풀링 (Object Pooling): 몬스터 30기를 사전 할당하여 런타임 힙 단편화 제거
- 데이터 주도 설계 (Data-Driven Design): 게임 규칙 및 가이드를 외부 텍스트 파일(
GameInfo.txt)로 분리
3. 핵심 기능 구현: 클라이언트부터 서버까지
① 윈도우 가상 콘솔(ConPTY)의 한계를 넘은 하이브리드 마우스 입력
최신 윈도우 터미널 환경은 클릭이나 드래그를 하지 않으면 마우스 이동 이벤트(MOUSE_MOVED)를 콘솔 버퍼에 넘겨주지 않는 고질적인 문제가 있었습니다. 2x2 크기의 타워를 짓기 위해 실시간 마우스 오버 미리보기가 필수적이었던 터라 큰 걸림돌이었습니다.
이를 해결하기 위해 OS 물리 마우스 픽셀 좌표(GetCursorPos)를 윈도우 콘솔 창 핸들(GetConsoleWindow) 기준 상대 좌표로 변환한 뒤, 뷰포트 비율로 직접 칸(Cell) 단위 위치를 역계산하는 하이브리드 마우스 추적기(GetRealMousePos)를 직접 구현하여 완벽한 실시간 반응성을 확보했습니다.

② A* 동적 길찾기와 미로 봉쇄 방지 (Dry-Run Rollback)
타워 디펜스에서 유저는 자유롭게 타워를 배치해 몬스터의 이동 경로를 길게 유도하는 '미로(Mazing)'를 설계합니다. 하지만 악의적으로 입구를 완전히 틀어막아 버리면 몬스터가 길을 잃고 게임이 마비됩니다.
이를 방지하기 위해 트랜잭션 롤백(Dry-Run) 검증을 적용했습니다:
- 유저가 클릭한 자리에 타워를 가상으로 배치합니다.
- 스폰 지점(S)부터 아지트(D)까지의 경로뿐만 아니라, 현재 맵에 살아 숨 쉬는 모든 몬스터의 현재 위치에서 아지트까지의 경로가 여전히 유효한지 A* 알고리즘으로 전수 시뮬레이션합니다.
- 단 한 마리라도 길이 막힌다면 즉시 설치를 기각하고 맵을 원상태로 롤백합니다.
// 가상 배치 후 시뮬레이션 (Dry-run)
mapGrid[y][x] = 2; ...
if (!ValidateAllMonsterPaths()) {
// 경로 차단 감지 시 즉각 롤백
mapGrid[y][x] = 0; ...
return false;
}


③ 3-단 타워 자동 합성 (3-Merge Auto Upgrade)
화염(FF), 얼음(II), 전기(TT) 3가지 속성의 타워가 무작위로 지어지며, 동일 속성/동일 성급(1성) 타워가 맵에 3개 모이면 가장 먼저 지어졌던 타워가 상위 성급(2성 +, 3성 *)으로 승급하고 나머지 2개는 빈 땅으로 복구됩니다. 2성이 3개 모이면 즉시 3성으로 연쇄 승급되는 룰을 재귀 트리 탐색으로 구현했습니다.
④ Windows IOCP 기반의 고성능 멀티스레드 네트워크 엔진
단순 논블로킹 소켓의 폴링(Polling) 방식 대신, Windows 커널 레벨의 완료 포트(IOCP)를 이용한 정적 라이브러리 ServerCore.lib를 구축했습니다.
- 4개의 Worker Thread가 커널의 I/O 완료 통지(
GetQueuedCompletionStatus)를 받아 비동기 패킷 처리를 병렬 수행합니다. - TCP의 고질병인 패킷 뭉침(Clumping)과 쪼개짐(Fragmentation)을 완벽히 방어하기 위해 슬라이딩 윈도우 기반의 커스텀
RecvBuffer를 제작했습니다.
4. 치열했던 트러블슈팅 (Real Engineering Stories)
이번 프로젝트에서 가장 가치 있었던 순간은 "원인을 알 수 없는 동기화 결함"을 커널과 스레드 레벨에서 집요하게 파고들어 해결했던 과정이었습니다.
🔥 트러블슈팅 1: "한쪽 클라에서 돈이 40골드 모자라요"
문제: 1웨이브 30마리를 두 클라이언트가 모두 완파했는데, 호스트는 정상적으로 300G인 반면 다른 클라이언트는 260G~290G만 적립되는 기현상이 발생함. 한쪽 화면에서는 적이 다 죽었는데 다른 쪽에서는 몬스터가 살아서 맵을 배회함.
- 사실 수집 & 원인 분석:
- 터렛의 총알 히트 판정이 각 클라이언트 로컬에서 독립적으로 실행되고 있었습니다.
- 클라이언트마다 프레임 틱 오차(Delta Time)가 미세하게 달라, A 클라에서 먼저 죽은 몬스터가 B 클라에서는 아지트에 도달해 버리거나 타겟팅 우선순위가 갈려 로컬 피격 횟수가 어긋났습니다.
- 즉, 처치 판정과 보상 지급 권한이 서버에 없고 분산되어 있어 '분산 합의(Distributed Consensus)'가 깨진 것이 원인이었습니다.
- 해결 방안 (서버 멱등성 보장):
- 서버가 몬스터를 스폰할 때 고유한
monsterId를 패킷에 담아 발급합니다. - 클라이언트는 체력이 0이 되어도 로컬 골드를 직접 올리지 않고, 서버에
C_ENEMY_KILL(monsterId, 10)요청만 전송합니다. - 서버(
GameRoom)는std::set<int32_t> deadMonsters를 두어 해당 몬스터가 이미 죽었는지 멱등성(Idempotency) 검증을 수행합니다. 최초 1회 요청만 승인하여 방의 공통 골드를 올리고, 전원에게S_ENEMY_KILL을 브로드캐스트합니다. - 모든 클라이언트는
S_ENEMY_KILL을 수신한 즉시 해당 ID의 몬스터를 끄고 골드를 지급받습니다.
- 결과: 두 클라이언트 간 골드 오차 0원 달성, 몬스터 생사 상태 100% 일치 완성!
- 서버가 몬스터를 스폰할 때 고유한
🔥 트러블슈팅 2: "파트너가 강제 종료하면 서버가 터져요"
문제: 2인 플레이 도중 한 클라이언트가 창을 닫거나 강제 종료하면, 남은 플레이어가 로비로 나가지지 않고 서버 프로세스 전체가 즉시 크래시(Crash)를 내며 다운됨.
사실 수집 & 원인 분석:
- 윈도우 디버거 확인 결과
std::system_error (resource_deadlock_would_occur)예외가 발생하며std::terminate()로 서버가 강제 종료되고 있었습니다. GameRoom::Leave()함수가std::mutex lock을 소유한 상태에서, 방에 남아있던 파트너 세션의Disconnect()를 호출하고 있었습니다.- 문제는
Disconnect()가 동기식으로 세션 정리 콜백(OnDisconnected)을 호출했고, 이 콜백이 동일 스레드에서 다시GameRoom::Leave()에 진입하여 비재귀적(Non-recursive) 뮤텍스에 대한 이중 락(Self-Deadlock)을 유발한 것이었습니다.
- 윈도우 디버거 확인 결과
해결 방안 (락-프리 분리 호출):
- 락의 범위(Lock Granularity)를 최소화했습니다. 세션 목록 제거와 상태 변경만 락 내부에서 안전하게 끝마친 뒤,
lock.unlock()으로 락을 완전히 해제한 후에 파트너 세션의Disconnect()를 호출하도록 순서를 분리했습니다.// 1. 락 범위 안에서는 데이터만 안전하게 갱신하고 종료 대상 포인터만 수집 std::shared_ptr<GameSession> remainingSession = nullptr; { std::lock_guard<std::mutex> lock(roomLock); sessions.erase(leavingSession); if (!sessions.empty()) remainingSession = sessions.begin()->second; } // <-- 여기서 락이 완전히 풀림!
// 2. 락이 해제된 안전한 상태에서 네트워크 콜백 호출
if (remainingSession) {remainingSession->Disconnect();}
* **결과**: 파트너가 언제 강제 종료되더라도 서버가 0.1초의 중단 없이 유지되며, 남은 유저는 안전하게 메인 메뉴로 복귀하여 재접속할 수 있게 되었습니다.- 락의 범위(Lock Granularity)를 최소화했습니다. 세션 목록 제거와 상태 변경만 락 내부에서 안전하게 끝마친 뒤,
🔥 트러블슈팅 3: "방을 새로 파면 플레이어 ID가 3번, 4번이 돼요"
문제: 1번 유저와 2번 유저가 플레이하다가 1번 유저가 나가고 다시 새 클라이언트로 들어왔을 때, 의도한 번호(1번, 2번)가 아니라 3번, 4번으로 번호가 계속 증가하는 현상 발생.
- 원인: 서버 전역에서
std::atomic<uint64_t> nextPlayerId로 번호를 계속++증가시키고 있었기 때문이었습니다. - 해결: 플레이어 ID의 개념을 '서버 전역 고유 번호'가 아니라 '방 안에서의 좌석 슬롯(Slot 1, Slot 2)'으로 재정의했습니다. 방에 입장할 때 빈자리 중 가장 낮은 번호를 찾아 배정하는 알고리즘으로 교체했습니다.
uint64_t newPlayerId = 1; while (sessions.find(newPlayerId) != sessions.end()) { newPlayerId++; }- 결과: 1번 유저가 튕겨서 재접속하면 빈자리인 1번 슬롯을 바로 회수하여 채우고, 언제나 Player 1과 Player 2의 명확한 슬롯 체계를 유지하도록 개선되었습니다.
5. 프로젝트를 마치며: 배운 점과 엔지니어링적 회고
이번 프로젝트는 저에게 단순한 게임 기능 구현을 넘어, '진정한 백엔드/네트워크 프로그래밍'의 민낯을 마주하고 성장한 전환점이었습니다.
💡 얻은 교훈 (Takeaways)
- "상태는 단 하나여야 한다 (Single Source of Truth)"
클라이언트가 자체적으로 계산하고 사후 보고하는 방식은 멀티플레이 환경에서 필연적으로 오차와 치트 취약점을 낳는다는 것을 뼈저리게 느꼈습니다. '클라이언트는 오직 표현(Render)과 입력(Input)만을 담당하고, 판정(Rule)은 서버가 독점한다'는 서버 권한형 설계의 본질을 체득했습니다. - "락(Lock)을 잡은 채로 외부 함수를 부르지 말 것"
파트너 강제 종료 크래시를 겪으며, 락을 보유한 상태에서 콜백이나 다른 객체의 메서드를 호출하는 것이 얼마나 위험한 데드락을 초래하는지 몸소 겪었습니다. 임계 영역(Critical Section)은 최소한의 데이터 쓰기만 수행하고 즉시 해제해야 한다는 동시성 프로그래밍의 대원칙을 가슴 깊이 새겼습니다. - "데이터 주도 설계(Data-Driven Design)의 가치"
게임 밸런스나 도움말 UI를 코드에 하드코딩하지 않고 외부 파일로 분리함으로써, 빌드 오버헤드 없이 실시간으로 데이터를 교체하고 유지보수할 수 있는 생산성의 이점을 실감했습니다.
🚀 향후 발전 방향
- TCP_NODELAY 소켓 옵션 적용: 작은 킬 패킷 전송 시 Nagle 알고리즘에 의한 미세한 딜레이를 0ms로 줄여 동기화 즉각성 극대화.
- 네트워크 레이턴시 보정(Lag Compensation): 핑(Ping) 차이가 큰 원격 환경에서도 부드러운 위치 보간을 제공하는 데드 레커닝(Dead Reckoning) 알고리즘 도입.
밑바닥부터 콘솔 엔진의 버퍼를 제어하고, IOCP 커널 완료 큐를 다루며 네트워크 패킷 하나하나를 조립했던 이번 경험은 앞으로 더 거대하고 정밀한 분산 게임 서버를 설계하는 데 있어 가장 단단한 밑거름이 될 것입니다. 긴 글 읽어주셔서 감사합니다!
🔗프로젝트 링크
'원티드_포텐업_5기 > 콘솔 엔진 프로젝트' 카테고리의 다른 글
| Project 'PEACEMAKER' 개발 회고 (0) | 2026.08.19 |
|---|




