[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


📌 목차

  1. 프롤로그: 왜 단순한 콘솔 게임에 데디케이티드 서버를 붙였을까?
  2. 기술 스택 및 시스템 아키텍처
  3. 핵심 기능 구현: 클라이언트부터 서버까지
    • 자체 제작 콘솔 엔진과 하이브리드 마우스 입력
    • A* 동적 길찾기와 미로 봉쇄 방지(Dry-Run Rollback)
    • 3-단 타워 자동 합성(3-Merge) 시스템
    • Windows IOCP 기반의 고성능 멀티스레드 서버 엔진
  4. 치열했던 트러블슈팅 (Real Engineering Stories)
      1. "한쪽 클라에서 돈이 40골드 모자라요": 분산 처치 오차와 서버 멱등성
      1. "파트너가 강제 종료하면 서버가 터져요": 재귀 락(Self-Deadlock)의 함정
      1. "재접속하면 플레이어 ID가 3번, 4번이 돼요": 방 단위 최소 슬롯 배정
  5. 프로젝트를 마치며: 배운 점과 엔지니어링적 회고

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) 검증을 적용했습니다:

  1. 유저가 클릭한 자리에 타워를 가상으로 배치합니다.
  2. 스폰 지점(S)부터 아지트(D)까지의 경로뿐만 아니라, 현재 맵에 살아 숨 쉬는 모든 몬스터의 현재 위치에서 아지트까지의 경로가 여전히 유효한지 A* 알고리즘으로 전수 시뮬레이션합니다.
  3. 단 한 마리라도 길이 막힌다면 즉시 설치를 기각하고 맵을 원상태로 롤백합니다.
// 가상 배치 후 시뮬레이션 (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)'가 깨진 것이 원인이었습니다.
  • 해결 방안 (서버 멱등성 보장):
    1. 서버가 몬스터를 스폰할 때 고유한 monsterId를 패킷에 담아 발급합니다.
    2. 클라이언트는 체력이 0이 되어도 로컬 골드를 직접 올리지 않고, 서버에 C_ENEMY_KILL(monsterId, 10) 요청만 전송합니다.
    3. 서버(GameRoom)는 std::set<int32_t> deadMonsters를 두어 해당 몬스터가 이미 죽었는지 멱등성(Idempotency) 검증을 수행합니다. 최초 1회 요청만 승인하여 방의 공통 골드를 올리고, 전원에게 S_ENEMY_KILL을 브로드캐스트합니다.
    4. 모든 클라이언트는 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초의 중단 없이 유지되며, 남은 유저는 안전하게 메인 메뉴로 복귀하여 재접속할 수 있게 되었습니다.
    

🔥 트러블슈팅 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)

  1. "상태는 단 하나여야 한다 (Single Source of Truth)"
    클라이언트가 자체적으로 계산하고 사후 보고하는 방식은 멀티플레이 환경에서 필연적으로 오차와 치트 취약점을 낳는다는 것을 뼈저리게 느꼈습니다. '클라이언트는 오직 표현(Render)과 입력(Input)만을 담당하고, 판정(Rule)은 서버가 독점한다'는 서버 권한형 설계의 본질을 체득했습니다.
  2. "락(Lock)을 잡은 채로 외부 함수를 부르지 말 것"
    파트너 강제 종료 크래시를 겪으며, 락을 보유한 상태에서 콜백이나 다른 객체의 메서드를 호출하는 것이 얼마나 위험한 데드락을 초래하는지 몸소 겪었습니다. 임계 영역(Critical Section)은 최소한의 데이터 쓰기만 수행하고 즉시 해제해야 한다는 동시성 프로그래밍의 대원칙을 가슴 깊이 새겼습니다.
  3. "데이터 주도 설계(Data-Driven Design)의 가치"
    게임 밸런스나 도움말 UI를 코드에 하드코딩하지 않고 외부 파일로 분리함으로써, 빌드 오버헤드 없이 실시간으로 데이터를 교체하고 유지보수할 수 있는 생산성의 이점을 실감했습니다.

🚀 향후 발전 방향

  • TCP_NODELAY 소켓 옵션 적용: 작은 킬 패킷 전송 시 Nagle 알고리즘에 의한 미세한 딜레이를 0ms로 줄여 동기화 즉각성 극대화.
  • 네트워크 레이턴시 보정(Lag Compensation): 핑(Ping) 차이가 큰 원격 환경에서도 부드러운 위치 보간을 제공하는 데드 레커닝(Dead Reckoning) 알고리즘 도입.

밑바닥부터 콘솔 엔진의 버퍼를 제어하고, IOCP 커널 완료 큐를 다루며 네트워크 패킷 하나하나를 조립했던 이번 경험은 앞으로 더 거대하고 정밀한 분산 게임 서버를 설계하는 데 있어 가장 단단한 밑거름이 될 것입니다. 긴 글 읽어주셔서 감사합니다!

🔗프로젝트 링크

ConsolGameProject

📝 [C++] 알고리즘 밑바닥부터 구현하기: 재귀·탐색부터 분할 정복 정렬까지

💡 들어가며

기초적인 재귀 알고리즘과 난수 생성을 시작으로, 선형 완전 탐색(Brute Force) 및 이진 탐색(Binary Search), 그리고 다양한 정렬(Sorting) 알고리즘을 밑바닥부터 직접 구현해 보았습니다.
O(N^2) 시간 복잡도를 가지는 기본 정렬(Bubble, Selection, Insertion)부터 분할 정복(Divide and Conquer) 패러다임 기반의 고급 정렬(Quick, Merge Sort)까지 단계별로 구현하고, Windows 고정밀 타이머를 활용하여 C 표준 qsort 및 직접 구현한 알고리즘들의 성능을 실측·비교하며 학습한 내용을 정리해 봅니다.


📚 구현 내용 및 학습 정리

1. 재귀 함수 (Recursion) 및 난수 생성 (Random)

  • 재귀의 기본 원리와 기저 조건(Base Case)
    • 1부터 N까지의 연속 합(Sum)과 거듭제곱(Power)을 재귀 호출로 구현했습니다.
    • 재귀 호출의 깊이를 제어하고 스택 오버플로우(Stack Overflow)를 방지하는 기저 조건(Base Case) 설정의 원리를 익혔습니다.
  • 범위 기반 난수 생성 (Random)
    • time(nullptr)를 시드(Seed)로 활용한 srand() 초기화를 적용했습니다.
    • 나머지 연산과 정규화 방식을 적용하여 정수 및 부동소수점(Float) 범위 난수를 생성하는 유틸리티 함수(RandomRange)를 구현했습니다.

2. 완전 탐색 (Brute Force) & 이진 탐색 (Binary Search)

  • 완전 탐색 (선형 탐색 및 패턴 매칭)
    • 배열을 순차 순회하며 원하는 값을 찾는 선형 탐색(FindValueIndex)과 최댓값 탐색(FindMaxValue)을 구현했습니다.
    • 텍스트 내에서 특정 부분 문자열의 일치 위치를 찾아내는 브루트포스 문자열 패턴 매칭(FindPatternIndex)을 작성했습니다.
  • 이진 탐색 (Binary Search)
    • 정렬된 배열을 대상으로 중간값(middle)과 타겟을 비교해 탐색 범위를 절반씩 좁혀나가는 O(log N) 탐색 알고리즘을 구현했습니다.
    • 재귀 방식(BinarySearchRecursive)반복문 방식(BinarySearchInterative) 두 가지 형태로 각각 구현하여 동작 원리를 비교했습니다.

3. 기본 정렬 알고리즘 (Bubble, Selection, Insertion Sort)

  • 버블 정렬 (Bubble Sort)
    • 인접한 두 원소를 비교하여 큰 값을 뒤로 밀어내는 O(N^2) 정렬을 구현했습니다.
    • 임시 변수 없이 비트 XOR 연산(a ^= b ^= a ^= b)을 이용해 값을 교환하는 Swap 기법을 적용했습니다.
  • 선택 정렬 (Selection Sort)
    • 매 패스마다 최소(또는 최대) 원소의 인덱스를 찾아 선두 위치의 원소와 교환하는 O(N^2) 정렬을 구현했습니다.
  • 삽입 정렬 (Insertion Sort)
    • 2번째 원소부터 시작하여 이미 정렬된 앞부분과 비교하면서 적절한 위치를 찾아 원소들을 우측으로 밀고 삽입하는 O(N^2) (최선의 경우 O(N)) 정렬을 구현했습니다.
  • 비교자(Comparer) 전략 패턴 주입
    • 함수 포인터(using Comparer), 함수 객체(Functor), C++11 람다(Lambda) 및 std::function을 활용하여 오름차순/내림차순 정렬 기준을 외부에서 동적으로 주입받을 수 있도록 유연하게 설계했습니다.

4. 분할 정복 기반 고급 정렬 (Quick Sort & Merge Sort) 및 성능 측정

  • 퀵 정렬 (Quick Sort)
    • 첫 번째 요소를 피벗(Pivot)으로 지정하고 lowhigh 인덱스를 양방향으로 교차 이동시키며 분할하는 Partition 함수를 구현했습니다.
    • 피벗을 기준으로 좌/우 서브 배열을 재귀적으로 분할 정복하여 평균 O(N log N) 속도를 달성하는 퀵 정렬을 구현했습니다.
  • 병합 정렬 (Merge Sort)
    • 배열을 절반 단위로 더 이상 쪼갤 수 없을 때까지 재귀 분할한 후, Merge 함수를 통해 두 개의 정렬된 서브 배열을 순차적으로 합치며 정렬하는 안정 정렬(Stable Sort, O(N log N))을 구현했습니다.
  • 고정밀 성능 측정 (StatTimer & qsort 비교)
    • Windows API인 QueryPerformanceCounterQueryPerformanceFrequency를 캡슐화한 StatTimer 클래스를 작성했습니다.
    • 대규모 난수 데이터셋(3,000 ~ 100,000개)을 대상으로 C 표준 라이브러리 qsort, BubbleSort, QuickSort, MergeSort의 실제 실행 시간(Elapsed Time)을 정밀하게 측정하고 성능 차이를 비교·분석했습니다.

📊 정렬 알고리즘 시간 복잡도 요약

알고리즘 평균 시간 복잡도 최악 시간 복잡도 공간 복잡도 정렬 특징
버블 정렬 (Bubble) O(N^2) O(N^2) O(1) 제자리 정렬(In-Place), 단순 구현
선택 정렬 (Selection) O(N^2) O(N^2) O(1) 교환 횟수 최소화, 불안정 정렬
삽입 정렬 (Insertion) O(N^2) O(N^2) O(1) 거의 정렬된 배열에서 O(N)으로 매우 빠름
퀵 정렬 (Quick) O(N log N) O(N^2) O(log N) 캐시 효율 우수, 가장 범용적인 고속 정렬
병합 정렬 (Merge) O(N log N) O(N log N) O(N) 최악에도 O(N log N) 보장, 안정 정렬
---

🤔 회고 및 느낀 점

  • 시간 복잡도(O(N^2) vs O(N log N))의 실제 체감
    • 데이터 개수가 3,000개, 100,000개로 증가함에 따라 O(N^2) 알고리즘(버블 정렬)과 O(N log N) 알고리즘(퀵/병합 정렬) 간의 실행 시간 차이가 기하급수적으로 벌어지는 것을 고정밀 타이머로 직접 계측하며 알고리즘 선택의 중요성을 깊이 체감했습니다.
  • 분할 정복(Divide & Conquer)과 재귀적 경계 처리
    • 퀵 정렬의 피벗 분할 인덱스 교차 처리와 병합 정렬의 부분 배열 인덱스 복사(memcpy 및 잔여 원소 병합) 과정에서 인덱스 경계값(start, end, middle) 계산의 엄밀함과 재귀 탈출 조건의 중요성을 배웠습니다.
  • C++ 동적 할당 및 메모리 관리
    • 병합 정렬에서 분할 배열을 동적 할당(new[])하고 해제(delete[])하는 과정에서 포인터를 nullptr로 초기화하여 댕글링 포인터를 방지하고, 메모리 누수 없이 자원을 관리하는 C++ 메모리 제어의 기본기를 다졌습니다.
  • 유연한 인터페이스 설계
    • 정렬 알고리즘의 코어 로직은 그대로 유지하면서 비교 함수 포인터, Functor, 람다 표현식을 통해 정렬 기준을 자유롭게 변경할 수 있는 유연한 코드 구조를 학습했습니다.

📝자료구조 밑바닥부터 구현하기: Tree부터 Binary Search Tree까지

💡 들어가며

해시 테이블(Hash Table) 구현에 이어, 이번에는 선형 자료구조를 벗어나 비선형 계층 구조인 트리(Tree)를 단계별로 직접 구현해 보았습니다. 임의의 개수의 자식을 가질 수 있는 일반 트리부터 시작하여, 최대 2개의 자식만을 가지는 이진 트리(Binary Tree), 그리고 정렬과 탐색에 특화된 이진 탐색 트리(Binary Search Tree, BST)까지 밑바닥부터 구현했습니다. 특히 재귀(Recursion)를 통한 트리 순회와 BST의 핵심인 노드 삭제(Remove) 알고리즘을 구현하며 학습한 내용을 정리해 봅니다.




📚 구현 내용 및 학습 정리

1. 일반 트리 (Tree / N-ary Tree) 구현

  • 다방향 트리 구조 (`std::vector` 활용): 부모 노드가 임의의 개수(N개)의 자식 노드를 가질 수 있는 계층형 트리를 구현했습니다. `Node<T>` 내부에 `std::vector<Node<T>*> children`을 두어 가변적인 자식 노드를 관리하도록 설계했습니다.
  • 재귀 기반 탐색과 삭제: 부모 데이터를 찾아 자식을 추가하는 `AddChild`와 재귀적으로 하위 서브트리를 탐색하는 `Find` 함수를 구현했습니다. 노드 삭제 시에는 부모의 `children` 벡터에서 해당 노드를 제거하고, 하위 서브트리까지 메모리를 누수 없이 안전하게 재귀 해제하도록 처리했습니다.
  • 전위(Preorder) 및 후위(Postorder) 순회: 노드의 깊이(`depth`)에 따른 들여쓰기 출력과 함께, 부모를 먼저 방문하는 전위 순회와 자식을 먼저 방문한 뒤 부모를 처리하는 후위 순회를 구현했습니다.



 2. 이진 트리 (Binary Tree) 구현

  • 좌/우 자식 포인터 구조: 각 노드가 최대 2개의 자식 노드(`left`, `right`)만을 가질 수 있는 이진 트리를 구현했습니다.
  • 좌우 명시적 자식 추가 및 삭제: `AddLeftChild`, `AddRightChild` 함수를 통해 특정 부모 노드의 왼쪽/오른쪽 자손을 명시적으로 추가하고, 노드 삭제 시 부모 노드와의 좌/우 연결 포인터를 끊고 메모리를 해제하는 로직을 작성했습니다.
  • 중위 순회 (Inorder Traversal): 왼쪽 서브트리 -> 부모 노드 -> 오른쪽 서브트리 순서로 방문하는 중위 순회 재귀 함수를 구현하여 트리의 계층 구조를 출력했습니다.

 



3. 이진 탐색 트리 (Binary Search Tree, BST) 구현

  • BST의 대소관계 규칙 적용: "왼쪽 서브트리 < 부모 노드 < 오른쪽 서브트리" 속성을 만족하는 이진 탐색 트리를 구현했습니다.
  • 효율적인 삽입(Insert)과 탐색(Find): 중복 데이터를 허용하지 않고, 현재 노드와의 크기 비교를 통해 적절한 리프 위치를 찾아 내려가는 O(log N) 방식의 반복문 기반 삽입 및 탐색 함수를 구현했습니다.
  • 최솟값(Minimum) / 최댓값(Maximum) 탐색: 트리의 가장 왼쪽 끝 노드와 가장 오른쪽 끝 노드를 따라가 최솟값과 최댓값을 구하는 함수를 구현했습니다.
  •  BST 노드 삭제 (Remove) - 3가지 케이스 완벽 처리:
        *   Case 1 (자식이 없거나 오른쪽 자식만 있는 경우): 오른쪽 자식을 부모 자리에 올리고 현재 노드 해제
        *   Case 2 (왼쪽 자식만 있는 경우): 왼쪽 자식을 부모 자리에 올리고 현재 노드 해제
        *   Case 3 (자식이 둘 다 있는 경우): 오른쪽 서브트리의 최솟값(후속자, Inorder Successor)을 찾아 현재 노드의 데이  터를 대체한 후, 오른쪽 서브트리에서 후속자 노드를 재귀적으로 삭제
  • 중위 순회와 오름차순 정렬: BST에서 중위 순회(Inorder)를 수행하면 데이터가 자동으로 오름차순으로 정렬되어 출력되는 특징을 직접 확인했습니다.


🤔 회고 및 느낀 점

  • 재귀(Recursion) 사고의 중요: 트리는 서브트리 자체가 또 다른 트리라는 재귀적 성질을 가지기 때문에, 탐색·순회·삭제 로직을 짤 때 재귀 함수의 종료 조건(`base case`)과 분기 처리의 중요성을 깊이 체감했습니다.
  • BST 노드 삭제 로직의 정교함: 단순 삽입/탐색과 달리, 자식이 둘 다 있는 노드를 삭제할 때 후속자(Successor)를 찾아 데이터를 대체하고 하위 노드를 재귀적으로 삭제하는 알고리즘을 직접 짜보면서 BST의 트리 구조 유지 원리를 완벽하게 이해할 수 있었습니다.
  • 소멸자와 메모리 관리: 노드 소멸자에서 자식 포인터를 연쇄적으로 `delete`할 때, 삭제/교체 과정에서 자식 포인터를 미리 `nullptr`로 끊어두지 않으면 의도치 않게 하위 트리 전체가 날아가는 문제를 겪으며 C++ 메모리 관리와 포인터 조작에 대한 세밀함을 다시 한번 배웠습니다.

📝 [C++] 자료구조 밑바닥부터 구현하기: Array부터 Hash Table까지


💡 들어가며

최근 며칠 동안(8.19 ~ 8.21) C++을 활용해 기본적이고 핵심적인 자료구조들을 직접 구현해 보는 시간을 가졌습니다. STL이 제공하는 컨테이너들을 단순히 가져다 쓰는 것을 넘어서, 그 내부 동작 원리를 깊이 이해하기 위해 배열부터 해시 테이블까지 단계별로 직접 코드를 작성하며 실습을 진행했습니다. 이번 포스팅에서는 그동안 구현한 자료구조들과 이를 응용해 본 내용들을 회고해보고자 합니다.

 📚 구현 내용 및 학습 정리

1. 배열(Array)과 동적 배열(Vector) 구현 (8.19 ~ 8.20)

  • 고정 배열 (Array): 가장 기초적인 연속된 메모리 구조인 정적 배열을 구현하며 데이터가 메모리에 어떻게 할당되고 접근되는지 확인했습니다.
  • 동적 배열 (Vector) 및 Iterator: 크기가 가변적으로 변하는 동적 배열(Vector)을 구현했습니다. Capacity가 꽉 찼을 때 메모리를 재할당하고 데이터를 복사하는 메커니즘을 짰습니다.
  • Vector Iterator: C++의 중요한 개념 중 하나인 반복자(Iterator)를 직접 구현해 보면서, 컨테이너의 내부 요소들을 포인터처럼 순회하고 접근하는 방식을 익혔습니다.

 

 2. 연결 리스트 (Linked List) 구현 (8.20)

*   단일 연결 리스트 (Single Linked List): 노드(Node) 구조체를 설계하고, 데이터와 다음 노드의 포인터를 연결하여 단일 연결 리스트를 구현했습니다. 배열과 달리 데이터의 추가/삭제 시 메모리 이동이 발생하지 않는 연결 리스트의 장점을 체감할 수 있었습니다.

 

3. 스택(Stack)과 큐(Queue), 그리고 미로 탐색 응용 (8.20 ~ 8.21)

  • 스택 (Stack)과 DFS: 후입선출(LIFO) 자료구조인 스택을 구현했습니다. 또한 구현한 스택을 단순히 저장소로 쓰는 데 그치지 않고, 깊이 우선 탐색(DFS) 알고리즘을 이용한 미로 찾기(StackMaze) 실습에 적용해 보며 응용력을 높였습니다.
  • 큐 (Queue), 덱(Deque)과 BFS: 선입선출(FIFO) 자료구조인 큐와 양방향에서 입출력이 가능한 덱을 구현했습니다. 스택과 마찬가지로 큐를 이용해 너비 우선 탐색(BFS) 기반의 미로 찾기(QueueMaze)를 구현하며, 자료구조의 특성이 탐색 알고리즘에 어떻게 영향을 미치는지 비교해 볼 수 있었습니다.

 

4. 해시 테이블 (Hash Table) (8.21)

  • 해시 테이블 기본 및 스마트 포인터 응용: 키(Key)를 해시 함수를 통해 인덱스로 변환하여 빠른 탐색을 지원하는 해시 테이블을 구현했습니다. 특히 모던 C++의 특징인 shared_ptr을 활용한 해시 테이블(SmartHashTable) 도 추가로 구현하여, 메모리 누수를 방지하고 객체 생명주기를 안전하게 관리하는 방법까지 함께 학습했습니다.

🤔 회고 및 느낀 점

  • 내부 동작의 이해: 그동안 당연하게 사용하던 std::vector, std::stack, std::unordered_map 등의 컨테이너들이 내부적으로 어떤 원리와 비용으로 동작하는지 명확하게 깨닫게 되었습니다.
  • 알고리즘과의 결합: 스택과 큐를 구현한 직후 미로 찾기(DFS/BFS)에 곧바로 적용해 본 것이 매우 유익했습니다. 자료구조가 실제 문제 해결에 어떻게 쓰이는지 직관적으로 다가왔습니다.
  • 모던 C++의 중요성: 메모리를 직접 관리해야 하는 자료구조 구현 특성상 포인터 실수가 나기 쉬운데, 해시 테이블 구현에서 shared_ptr을 도입해 보며 스마트 포인터의 강력함과 메모리 안정성의 중요성을 다시 한번 느꼈습니다.


최근 C++을 이용해 바닥부터 개발한  CraftEngine을 베이스로 하여 개발한 뱀파이어 서바이벌을 벤치마킹하여 만든 콘솔 게임

'PEACEMAKER'의 개발 과정을 회고해 보려고 합니다.

상용 게임 엔진에 의존하지 않고, 순수 C++과 Windows API를 기반으로 자체 'CraftEngine' 위에서 게임 루프부터 렌더링,

충돌 처리까지 구현해보는 뜻깊은 프로젝트였습니다.

 


1. 프로젝트 개요 💡

  • 프로젝트명: PEACEMAKER
  • 장르: 핵앤슬래시 서바이버 액션
  • 개발 환경: C++, Visual Studio 2026, 자체 제작 콘솔 엔진(CraftEngine)
  • 기획: 사방에서 몰려오는 적들을 물리치고, 무작위로 등장하는 증강을 선택해 매 게임마다 다른 빌드로 게임을 진행해 데몬을 처치하고 최대한 오래 사는 것이 목표입니다.

2. 주요 구현 기능 🛠️

1. 룰렛 휠 기반의 증강 시스템
로그라이크의 꽃은 역시 어떻게 성장할지 모르는 무작위 성장입니다. 단순히 1/N 확률로 증강이 등장하는 것이 아닌 가중치 기반 확률 알고리즘을 도입했습니다. 

  • 이동 속도나 경험치 증가 같은 일반 증강은 가중치 100을 부여하고, 바운싱 불릿이나 데스노바 같은 희귀한 증강은 가중치 5    를 부여해 적은 확률로 등장하도록 밸런스르 설계했습니다.
  • 가중치를 배열에 누적 합산하여 난수(Random) 와 비교하는 룰렛휠 알고리즘을 사용해 견고한 확률 통제를 이뤄냈습니다.

 

2. 몬스터 웨이브와 보스전 기믹

시간이 지날수록 몬스터 스폰 주기가 짧아지며 긴장감을 높였고, 다채로운 전투를 위해 특수 몬스터들을 추가했습니다.

  • 엘리트 보스: 플레이어를 천천히 쫓아오는 중간 보스로, 처치 시 대량의 경험치와 50% 확률로 증강 포인트를 드롭합니다.
  • 최종 보스(Demon): 필드의 모든 몬스터들이 전멸하면서 아스키 아트로 등장 이펙트와 함께 등장합니다. 360도 나선형, 십자    형, X자형으로 다채로운 탄막 슈팅 패턴을 구현했습니다.

 

3. 타임 프리즈 오버레이와 인게임 UI
ESC 메뉴나 레벨업 선택창이 떴을 때 배경의 게임 시간이 완벽히 멈추도록 구현햇습니다. 레벨을 완전히 넘기는 대신 최상위 게임 루프(Tick)에서 상태 플래그를 검사하여 연산만 return 하고 렌더링으 유지하는 오버레이 방식을 구현했습니다. 


3. 트러블 슈팅 🔥

개발 중 여러 문제와 부딪혔고 이를 해결해 나가는 과정이 가장 큰 배움이었습니다.

 

문제 1. 대량의 경험치 획득 시 다중 증강 선택 누락 현상

  • 상황: 엘리트 보스를 잡거나 자석 아이템 사용하면 순식간에 레벨업을 2이상 했을 때, 레벨업 선택 UI가 단 한 번만 뜨고 넘어가는 버그가 있었습니다.
  • 해결: 기존의 단순 상태 덮어쓰기가 원인이었습니다. 이를 해결하기 위해  pendingAugmentCount를 도입했습니다. 남는 경험치를 반복문으로 계산해 누적 레벨업 티켓을 발급하고, 유저가 선택을 마칠 때마다 큐를 검사하여 남은 횟수만큼 화면을 연속 팝업 시키는 아키텍처로 개편하여 해결했습니다
// Player.cpp
// 자석(DestroyMagnet) 획득 처리
if (other->IsTypeOf<DestroyMagnet>())
{
	other->Destroy();
	std::shared_ptr<Level> owner = GetOwner();
	if (owner)
	{
		std::shared_ptr<GameLevel> gameLevel = Cast<GameLevel>(owner);
		if (gameLevel)
		{
			int expLevelUps = 0;
			// 맵 전체의 모든 경험치 수집
			auto expList = gameLevel->FindActors<DestroyEXP>();
			for (auto& exp : expList)
			{
				if (exp && exp->IsActive())
				{
					exp->Destroy();
					playerEXP += EXP;
					while (playerEXP >= targetEXP)
					{
						playerLevel += 1;
						playerEXP -= targetEXP;
						targetEXP *= 1.5f;
						expLevelUps++;
					}
				}
			}

			// 맵 전체의 모든 증강 포인트 수집
			auto augList = gameLevel->FindActors<DestroyAugmentPoint>();
			int augCount = 0;
			for (auto& aug : augList)
			{
				if (aug && aug->IsActive())
				{
					aug->Destroy();
					augCount++;
				}
			}
			
			int totalMenuCount = expLevelUps + augCount;
			if (totalMenuCount > 0)
			{
				gameLevel->ShowLevelUpMenu(totalMenuCount);
			}
		}
	}
}

// GameLevel.cpp
void GameLevel::ShowLevelUpMenu(int times)
{
	if (times > 1)
	{
		pendingAugmentCount += (times - 1);
	}
    // ...생략.
}


// 남은 증강 선택 횟수가 있다면 메뉴를 다시 띄움
if (pendingAugmentCount > 0)
{
	pendingAugmentCount--;
	ShowLevelUpMenu(1);
}

 

문제 2. 데스 노바(Death Nova) 연쇄 자폭 판정 오류

  • 상황: 적 처치 시 4방향으로 탄이 퍼지는 데스 노바 증강을 구현했지만, 파편이 적 객체의 정중앙 지점에서 동시에 스폰되면서 최종 보스나 엘리트 보스에게 비정상적으로 5연타 타격하는 문제가 발생했습니다.
  • 해결: 파편 스폰 좌표에 X, Y 대각선 방향으로 +1, -1씩 밖으로 밀어내는 오프셋을 계산하여 몬스터 몸통 콜라이더 바깥엣허 생성되도록 수정하고, 불릿 또한 적의 체력이 0일때만 터질 수 있게끔 변경했습니다.
// Demon.cpp
// 이하 다른 적들도 똑같이 구현됨.
// 체력이 0이 되어 사망 시.
if (demonHp <= 0)
{
	// 데스노바 증강 발동 (사망 시 1회만 터짐).
	if (!isShrapnel)
	{
		auto player = owner->FindActor<Player>();
		if (player && player->HasDeathNova())
		{
			Vector2 pos = GetPosition();
			float baseAngles[4] = {45.0f, 135.0f, 225.0f, 315.0f};
			for (int i = 0; i < 4; ++i)
			{
				float rad = baseAngles[i] * 3.141592f / 180.0f;
				float dx = std::cos(rad);
				float dy = std::sin(rad);
				Vector2 spawnPos(pos.x + (dx > 0 ? 1 : -1), pos.y + (dy > 0 ? 1 : -1));
				owner->SpawnActor<PlayerBullet>(spawnPos, dx, dy, false, true);
			}
		}
	}
}

 

문제 3. 자석 기믹 구현을 위한 엔진 코어구조 한계

  • 상황: 자석 아이템을 구현하려면 맵 전체의 모든 경험치를 찾아내야 했습니다. 하지만 자체 엔진 클래스에는 특정 타입의 액터를 딱 1개만 찾아 변환해 주는 FindActor<T> 함수만 설계되어있었습니다.
  • 해결: 로직 단에서 억지로 우회하지 않고, 과감하게 엔진 코어 레이어(Level.h)를 열어 확장 시켰습니다. 조건에 맞는 모든 액터를 찾아 std::vector 동적 배열로 반환해주는 FindActors<T> 템플릿 함수를 신규 개발하여 엔진에 탑재하여 맵 전역의 다중 액터를 제어할 수 있었습니다.
// Level.h
// 기존 FindActor
// 액터 검색 함수(템플릿).
template<typename T,
	typename = std::enable_if_t<std::is_base_of<Actor, T>::value>>
	std::shared_ptr<T> FindActor()
{
	// 검색 - 형변환.
	for (const auto& actor : actorList)
	{
		// T 타입으로 형변환 시도.
		// T 타입이 아닌 경우에는 null 반환.
		std::shared_ptr<T> targetActor
			= std::dynamic_pointer_cast<T>(actor);
		if (targetActor)
		{
			return targetActor;
		}
	}
}

// 새로 확장시킨 FindActors
// 조건에 맞는 모든 액터 검색 함수(템플릿).
template<typename T,
	typename = std::enable_if_t<std::is_base_of<Actor, T>::value>>
	std::vector<std::shared_ptr<T>> FindActors()
{
	std::vector<std::shared_ptr<T>> result;
	for (const auto& actor : actorList)
	{
		std::shared_ptr<T> targetActor = Cast<T>(actor);
		if (targetActor)
		{
			result.push_back(targetActor);
		}
	}
	return result;
}

4. 아쉬운 점과 향후 개선 방향 🚀

프로젝트를 마무리하면서, AI에게 더 나은 프로그래머로 성장하기 위해 보완해야 할 점들을 짚어달라고 부탁을 해보았습니다.

  1. 오브젝트 풀링(Object Pooling)의 부재: 뱀서 장르 특성상 수만 개의 총알과 경험치가 스폰되고 파괴됩니다. 이로 인한 메모리 파편화와 연산 부하를 막기위해 다음 프로젝트에서는 객체를 재사용 하는 '오브젝트 풀링'기법을 엔진 최하단에 반드시 구현해야합니다.
  2. 데이터 주도 설계(Data-Driven Desing): 증강 확률이나 보스 체력 등의 수치가 C++ 코드 내부에 하드코딩되어 있어 밸런스 패치마다 재빌드가 필요했습니다. 기획 수치를 JSON이나 CSV 형태의 외부 파일로 분리하여 런타임에 읽어오는 구조로 개선해야 됩니다.

다음 프로젝트 혹은 이번 프로젝트를 다시 복기하면서 개선할 수 있으면 해야겠다는 생각이 들면서 새로운 기법들에 대해 더 공부 해야겠다고 생각했습니다.


5. 프로젝트를 마치며..✍️

이번 'PEACEMAKER' 프로젝트는 단순하게 화면에 총알을 쏘는 것을 넘어서, 객체지향 다형성, 타이머 동기화, 충돌 판정, 메모리 라이프사이클 등 C++ 프로그래밍과 컴퓨터 공학의 깊은 원리를 몸소 부딪치며 배울 수 있었던 경험이었습니다.

앞으로도 더 깊이 있는 코어 시스템 설계와 렌더링 최적화를 고민하는 개발자로 성장하고 싶습니다.


6. 프로젝트 링크 🔗

PEACEMAKER

'원티드_포텐업_5기 > 콘솔 엔진 프로젝트' 카테고리의 다른 글

SKDefense - 개발 회고  (0) 2026.09.08

📅 Day 6: 파일 입출력 (File I/O)

파일에 데이터를 저장하고 읽어오는 방법으로, C스타일 방식을 다룹니다.

 

파일 입출력은 프로그램 실행 중에 사용되는 휘발성 메모리 (RAM)의 데이터를 파일 형태로 하드디스크에 저장하거나, 반대로 저장된 데이터를 읽어와 프로그램으로 활용하는 기술입니다. C++에서는 C스타일 방식과 C++ 전용 라이브러리 방식을 모두 지원합니다.

여기서는 C-Style만 볼 예정.

C-Style 파일 입출력 ( stdio.h, cstdio )

파일 포인터(FILE*): 파일을 제어하기 위한 포인터로, fopen_s 함수를 통해 읽기(r), 쓰기(w), 이어쓰기(a) 모드로 파일 스트림을 엽니다.

입출력 함수: fprintf, fscanf_s, fputs, fgets 등의 함수를 이용해 데이터를 주고 받습니다.

수동 리소스 해제: 작업이 끝난 후 반드시 fclose를 호출하여 스트림을 닫아주어야 리소스 누수가 발생하지 않습니다.

 

💡 핵심 코드 예시: FileIO C-Style

const char* configFileName = "Setting.txt";
	//CreateConfigFile(configFileName);
	
	// 설정 파일 읽기.
	// 파일 로드 -> 읽은 값을 변수로 저장.
	FILE* configFile = nullptr;
	fopen_s(&configFile, configFileName, "rt");
	if (!configFile)
	{
		return 1;
	}
    
	// 파일 닫기.
	fclose(configFile);
	configFile = nullptr;
    
    // 파일에서 읽기.
	// 읽어온 데이터를 저장할 버퍼.
	const int size = 256;
	char buffer[size] = {};
	size_t readSize = fread(buffer, sizeof(char), size, file);

 

📅 Day 7: 스마트 포인터 (Smart Pointers)

기존 C++의 원시 포인터(Raw Pointer)는 new로 동적 할당한 후 delete를 누락하면 메모리 누수가 발생하고, 이미 해제된 메모리를 참조하면 댕글링 포인터(Dangling Pointer) 문제가 발생했습니다. C++11 부터는 객체의 수명을 자동으로 관리해주는 스마트 포인터를 제공합니다.

 

std::unique_ptr(단일 소유권 포인터)

- 특정 동적 할당 메모리를 오직 하나의 unique_ptr 객체만 가리킬 수 있도록 제한합니다. 

- 포인터의 복사 생성 및 대입 연산이 금지되어 다중 해제 문제를 예방합니다. std::move를 사용해 소유권을 다른 unique_ptr로 완전히 넘겨주는것은 가능합니다.

- 성능과 안전성을 위해 std::make_unique<T>() 함수를 사용해 생성하는 것이 권장됩니다.

 

std::shared_ptr(공유 소유권 포인터)

- 하나의 동적 할당 메모리를 여러 개의 shared_ptr이 동시에 참조할 수 있습니다.

- 내부적으로 해당 객체를 참조하는 shared_ptr의 개수를 추적합니다. 새로운 포인터가 가리키면 카운트가 증가하고, 포인터가 소멸하면 카운트가 감소합니다.

- 참조 카운트가 최종적으로 0이되는 수간, 해당 메모리가 자동으로 해제됩니다.

- 동적 할당 객체와 참조 카운트를 관리하는 제어 블록을 한 번에 할당해 주는 std::make_shared<T>()를 사용하는 것이 효율적입니다.

 

💡 핵심 코드 예시: unique_ptr

#include <iostream>
#include <memory>		// 스마트 포인터 사용을 위해 필요함.

class Actor
{
public:
	virtual ~Actor()=default;
};

class Player :public Actor
{
public:
	Player()
	{
		std::cout << "Player constructed\n";
	}

	~Player()
	{
		std::cout << "Player destroyed.\n";
	}

	void Attack() const
	{
		std::cout << "Player attacks.\n";
	}
};

// 함수로 전달 받기.
void AttackPlayer(Player* player)
{
	player->Attack();
	delete player;
}

void AttackPlayer2(const Player& player)
{
	player.Attack();
}

void AttackPlayer3(const std::unique_ptr<Player>& player)
{
	player->Attack();
}

int main()
{
	//Player* player = new Player();
	//delete player;
	//player = nullptr;

	// 스마트 포인터.
	// STL - Standard Template Library.
	std::unique_ptr<Player> player = std::make_unique<Player>();
	
	// 스마트 포인터 객체의 사용은 일반 포인터의 사용 방법과 같다.
	// -> 연산자로 참조 가능.
	player->Attack();

	// 레퍼런스(역참조) 방법도 기존 포인터와 동일.
	Player& playerRef = *player;
	
	// 다른 스마트 포인터.
	// unique_ptr은 소유권 공유가 안됨.
	// std::unique_ptr<Player> player2 = player;

	// 주소를 빌려서 사용하는 방법.
	// 1. RawPointer(원시 포인터)로 받아서 사용.
	// 위험! -> 실수로 delete를 할 수 있음.
	//Player* player2 = player.get();
	//delete player2;
	AttackPlayer(player.get());

	// 2. 레퍼런스로 전달 받기.
	// 원시 포인터보다 조금 더 안전한 방법.
	AttackPlayer2(*player);

	// 3. 스마트 포인터의 레퍼런스를 사용.
	// 3번 방법도 가능은 하나 unique_ptr 정의/사용 방식에 위배.
	AttackPlayer3(player);

	// unique_ptr의 소유권을 다른 곳으로 넘기는 방법.
	// ValueCategory와 연결
	// 이전을 다른 곳으로 한 이후로는 기존에 사용한 player를 사용하면 안됨.
	std::unique_ptr<Player> otherPlayer = std::move(player);
	// 이전을 한 뒤로는 기존 포인터를 리셋(초기화) 해주는게 좋음.
	player.reset();

}

 

💡 핵심 코드 예시: shared_ptr

#include <iostream>
#include <memory>

class Actor
{
public:
	virtual ~Actor()=default;
};

class Player : public Actor
{

};

int main()
{
	std::shared_ptr<Actor> actor = std::make_shared<Actor>();
	std::shared_ptr<Player>player = std::make_shared<Player>();

	// shared_ptr은 소유권 공유 가능.
	// 참조(사용) 회수를 추적.
	std::shared_ptr<Player> otherPlayer = player;
	
}

 

 

📅 Day 8: Value Category &  Move Semantics

C++11에 도입된 Value Category는 식별자(이름)의 유무와 메모리 주소 가용성을 기준으로 값의 종류를 나누고, 이를 통해 불필요한 메모리 복사를 줄여 성능을 최적화하는 Move Semantics의 핵심 기반입니다.

lvalue

- 메모리상에 명확한 주소를 가지고 있으며, 이름을 가지고 있는 지속적인 객체입니다.

- 대입 연산자의 좌변에 위치할 수 있으며, 표현식이 끝난 후에도 메모리에 남아 유지됩니다 (예: 일반 변수, 참조 변수).

 

rvalue

- 메모리 주소를 직접 취할 수 없으며, 표현식이 평가된 후 바로 사라지는 임시 값/객체입니다.

- 대입 연산자의 우변에만 위치할 수 있습니다 (예: 숫자 리터럴 10, 함수가 반환하는 임시 객체, 사칙연산 결과값 등).

 

rvalue 참조 (&&) 및 std::move

- rvalue 참조 (&&): 사라질 예정인 임시 객체(rvalue)만을 전용으로 바인딩하는 참조 형태입니다.

- 자원 이동 (Move Semantics): 기존에는 객체를 전달할 때 대규모 메모리를 새로 할당하고 내용을 복사해야 했습니다. 그러나 이동 구문을 활용하면 임시 객체가 가진 내부 포인터/자원의 소유권만 뺏어오는 방식(Shallow Copy 후 기존 포인터 null 처리)으로 빠르게 처리할 수 있습니다.

- std::move: lvalue 변수를 강제로 rvalue 형태로 변환(캐스팅)하여, 복사 연산 대신 이동 연산이 실행되도록 유도해 줍니다.

 

💡 핵심 코드 예시: Value Category & Move Semantics

#include <iostream>
#include <string>

class Actor
{
public:
	// const L-Value는 L-Value, R-Value 둘다 가능.
	Actor(const char* newName)
	{
		// 이름 값 설정.
		// 1. 공간 할당.
		size_t length = strlen(newName) + 1;
		name = new char[length] {};

		// 2. 값 복사.
		strcpy_s(name, length, newName);
	}

	~Actor()
	{
		if (name)
		{
			delete[] name;
			name = nullptr;
		}
	}

	// 복사 생성자.
	Actor(const Actor& other)
	{
		// 0. 기존 공간 해제.
		if (name)
		{
			delete[] name;
			name = nullptr;
		}

		// 1. 공간 할당.
		size_t length = strlen(other.name) + 1;
		name = new char[length] {};

		// 2. 값 복사.
		strcpy_s(name, length, other.name);
	}

	// 이동 생성자 - Move Constructor.
	Actor(Actor&& other) 
		//:name(other.name)
	{
		// 기존 메모리 해제.
		if (name)
		{
			delete[] name;
			name = nullptr;
		}

		// 주소 값 이전.
		name = other.name;

		// 주소값을 이전 시킨 후에 원본 주소를 null 대입.
		other.name = nullptr;
	}

private:
	char* name=nullptr;
};

// 함수 오버로딩 (같은 이름의 함수가 여러개 배치되는 형태).
void Use(int& value)
{
	std::cout << "void Use(int& value)\n";
}

void Use(int&& value)
{
	std::cout << "void Use(int&& value)\n";
}

class Item
{

};

void Test(Item& item)
{
	std::cout << "void Test(Item& item)\n";
}

void Test(Item&& item)
{
	std::cout << "void Test(Item&& item)\n";
}

template<typename T>
void Function(T&& value)
{ 
	Test(std::forward<T>(value));
}

int main() 
{
	int value = 10;
	Use(value);
	Use(std::move(value));

	Item item;
	Function(item);
	Function(Item());


	//std::string actor2Name = "name";
	//Actor actor1("Test");
	//Actor actor2(actor2Name);
	// count 는 L-Value.
	// L- value:
	// - 메모리를 차지하고,
	// - 이름이 있어 식별이 가능하면,
	// - 변경이 가능.
	// 10은 정수 리터럴로서 R-Value.
	// R-Value:
	// - 임시 값.
	int count = 10;

	// L-Value Reference(L-Value 참조).
	int& lRef = count;
	//int& lRef = 10; // 오류

	// R-Value Reference(R-Value 참조).
	int&& rRef = 10;
	rRef = 30;
	//int&& rRef = count; // 오류
	//int&& rRef2 = rRef; // 오류
}

 

📅 Day 9: 함수 포인터 (Function Pointer)

C++에서 함수 역시 실행될 코드가 메모리(Text 영역)에 올려지는 주소를 가집니다. 함수 포인터는 이러한 함수의 메모리 주소를 저장하여, 변수를 통해 함수를 동적으로 선택하고 호출할 수 있게 해주는 기법입니다.

 

선언 문법 및 동작 원리

- 함수의 반환 타입과 매개변수 목록의 타입/개수가 완전히 일치하는 포인터만 해당 함수를 가리킬 수 있습니다.

- 문법: 반환타입 (*포인터이름)(매개변수타입1, 매개변수타입2, ...)

- 예시: void (*funcPtr)(int)는 "반환형이 void이고 int형 매개변수를 1개 받는 함수"의 주소를 저장합니다.

 

주요 활용: 콜백 시스템

- 특정 함수를 실행할 때 '다른 함수'를 인자로 전달하여, 작업이 완료되거나 특정 조건이 발생했을 때 전달받은 함수를 나중에 호출(Call back)하도록 만들 수 있습니다.

- 게임 이벤트 처리, UI 버튼 클릭 이벤트, 정렬 기준 함수 전달 등 다양한 동적 로직 구현에 필수적으로 사용됩니다.

 

💡 핵심 코드 예시: 함수 포인터

#include <iostream>

void Test()
{
	std::cout << "Test()\n";
}

float Add(float a, float b)
{
	return a + b;
}

float Multiply(float a, float b)
{
	return a * b;
}

// 함수 포인터를 타입으로 선언.
//typedef bool (*Comparer)(int, int);
using Comparer = bool (*)(int, int);

// 버블 정렬.
void BubbleSort(int* array, int length, Comparer comparer)
{
	// 정렬 처리.
	for (int ix = 0;ix < length - 1;++ix)
	{
		for (int jx = 0;jx < length-1-ix;++jx)
		{
			// 오름차순 (작은 수에서 큰 값을 뒤로 정렬)
			//if (array[jx] > array[jx + 1])
			//if (array[jx] < array[jx + 1]) // 내림 차순.
			if (comparer(array[jx], array[jx + 1]))
			{
				// 교환.
				std::swap(array[jx], array[jx + 1]);
			}

		}
	}
}
// 오름차순 조건.
bool Ascending(int a, int b)
{
	return a > b;
}

// 내림차순 조건.
bool Descending(int a, int b)
{
	return a < b;
}


class Actor
{
public:
	void Tick(float deltaTime)
	{

	}
};

// 멤버 함수 포인터.
void (Actor::* ActorTick)(float);

int main()
{
	ActorTick = Actor::Tick;
	int array[] = { 5,3,7,2,1,4 };
	int length = sizeof(array) / sizeof(array[0]);
	BubbleSort(array, length,Descending);

	for (const int& num : array)
	{
		std::cout << num << " ";
	}
	std::cout << '\n';
}

 

📅 Day 10: Functor (함수 객체) & 람다 표현식 (Lambda)

함수 포인터보다 확장된 형태의 호출 가능 객체들로, 상태를 유지하거나 코드 내에서 즉석으로 짧은 함수를 만들어 전달할 때 활용됩니다.

 

Functor (함수 객체)

- 개념: 클래스나 구조체 내부에서 operator() (호출 연산자)를 오버로딩하여, 객체임에도 불구하고 객체이름() 형태로 함수처럼 호출할 수 있게 만든 형태입니다.

- 강점 (상태 유지): 일반 함수나 함수 포인터와 달리, Functor는 클래스 내부의 멤버 변수를 가질 수 있습니다. 즉, 함수를 호출할 때마다 내부 상태값을 변경하거나 기록하는 "상태를 가지는 함수"로 동작할 수 있습니다.

- 인라인 최적화: 템플릿과 함께 사용될 때 컴파일러에 의해 인라인화되기 쉬워, 함수 포인터 호출보다 실행 속도가 빠른 경우가 많습니다.

 

람다 표현식 (Lambda Expression)

- 개념: 별도의 클래스나 함수를 외부에 정의하지 않고, 필요한 시점에 즉석에서 이름 없는 익명 함수를 정의하여 사용하는 C++11 문법입니다.

- 기본 문법: [capture](parameters) -> return_type { body }

- 캡처 블록 ([]): 람다 표현식이 위치한 외부 스코프의 변수를 람다 내부로 가져와 쓸 수 있는 기능입니다.

- [=]: 외부 변수들을 값 복사 방식으로 가져옵니다 (읽기 전용).

- [&]: 외부 변수들을 참조 방식으로 가져옵니다 (내부에서 수정 가능).

- [a, &b]: 특정 변수를 지정하여 값(a) 또는 참조(b)로 가져옵니다.

- 활용: STL 알고리즘(예: std::sort, std::for_each 등)에 조건식이나 동작 로직을 간단하게 한 줄로 넘겨줄 때 코드를 매우 간결하게 만들어 줍니다.

 

💡 핵심 코드 예시: Functor & Lambda

#include <vector>
#include <algorithm>
#include <iostream>

// 정렬 조건에 사용할 함수 객체.
struct Greater
{
	bool operator()(int left, int right)
	{
		return left > right;
	}
};

struct Less
{
	bool operator()(int left, int right)
	{
		return left < right;
	}
};

int main()
{
	// 정렬에 사용할 벡터(동적 배열).
	std::vector<int> data = { 3,2,4,5,1,6,3,4,5,6,1,10 };
	
	// 정렬.
	//Greater greater;
	//Less less;
	//std::sort(data.begin(), data.end(), Less());

	std::sort(data.begin(), data.end(),
		[](int left, int right) //-> bool
		{
			return left < right;
		});


	int result = 0;
	auto add = [&result](int value) 
		{
			result += value;
		};
	add(10);
	add(20);

	std::cout << result << "\n";

	for (const int item : data)
	{
		std::cout << item << " ";
	}
	std::cout << "\n";

}

 

📝 2 주차 요약 및 회고

Modern C++ 개념 정립: 단순 문법 익히기에 그치지 않고, 왜 스마트 포인터나 이동 언어가 도입되었는지(메모리 안전성 및 성능 최적화) 맥락을 이해하며 학습했습니다.

개념 간의 연결고리 이해: lvalue/rvalue(Day 8) 학습이 앞서 배운 스마트 포인터(Day 7)의 std::move 동작 방식을 이해하는 데 큰 바탕이 되었고, 함수 포인터(Day 9)에서 시작해 상태를 가지는 Functor와 간결한 Lambda(Day 10)로 확장되는 흐름을 자연스럽게 파악했습니다.

실전 코드 적용: 스마트 포인터와 람다 표현식을 활용해 작은 크기의 콘솔 프로그램이나 데이터 관리 객체를 직접 설계해 볼 예정입니다.

메모리 흐름 시각화: 가상 함수 테이블, 스마트 포인터 제어 블록, rvalue 이동 시의 포인터 전환 흐름을 메모리 구조 관점에서 다이어그램으로 그리면서 복습하겠습니다.

 

 

📅 Day 1: C++ 빌드 과정 & 입출력 기본

C++ 코드가 실행 파일이 되는 과정과 콘솔 I/O 및 포인터의 기본 개념을 학습했습니다. 

1. 전처리기와 빌드 파이프 라인

컴파일 과전: 전처리(Preprocessing) -> 컴파일(Compilation) -> 어셈블(Assembly) -> 링크(Linking)

#inlcude 매크로는 해당 파일의 내용을 단순-붙여넣기하는 헤더 포함 문법입니다.

 

💡 핵심 코드 예시: 빌드 과정의 직관적 이해 (BuildProcess)

헤더 파일이 전처리기에 의해 어떻게 결합되는지 보여주는 예시입니다.

// Log.h
#pragma once

void Log(const char*);

// EndBrace.h
} // 단순히 닫는 괄호 하나도 전처리기에 의해 그대로 복사됩니다.

//Main.cpp
#include <iostream>
#include "Log.h"

// 전방 선언 하는 이유.
int main()
{
	Log("Hello");
	//std::cout << "Hello\n";
#include "EndBrace.h"

 

 

📅 Day 2: 메모리 관리 (포인터, 배열, 문자열)

C++ 메모리 모델의 중심인 포인터, 배열, 문자열 처리 방식을 익혔습니다.

1. 포인터와 배열

포인터: 메모리의 주소를 저장하는 변수

배열: 연속된 메모리 공간. 배열의 이름은 첫 번째 요소의 주소를 가리키는 포인터입니다.

문자열: const char* 형태의 널 종료 문자열(\0)과 C++ 표준 std::string의 동작차이 비교

 

💡 핵심 코드 예시: 포인터 및 메모리 접근 (Pointer)

#include<iostream>

void Release(int** t)
{
	if (t != nullptr)
	{
		delete *t;
		*t = nullptr;
	}
}

// 두 변수의 값을 서로 교환하는 함수.
void Swap(int* const a, int* const b)
{
	int temp = *a;
	*a = *b;
	*b = temp;
}

// 레퍼런스를 활용한 swap 함수.
void SwapRef(int& a, int& b)
{
	int temp = a;
	a = b;
	b = temp;
}

int main()
{
	// 두 수를 교환.
	int numberA = 10;
	int numberB = 20;
	//Swap(&numberA, &numberB);
	SwapRef(numberA, numberB);

	int var = 8;
	int* ptr = &var;

	int *testPtr = new int;
	Release(&testPtr);
	//delete testPtr;
	//testPtr = nullptr;

	// 동적 할당.
	const int length = 100;
	int* buffer = new int[length];
	int** bufferPtr = &buffer;
	//int*** bufferPtrPtr = &bufferPtr;

	memset(buffer, 0, sizeof(int));
	
	delete[] buffer;

	std::cin.get();
}

//함수 매개변수 int* a 일때 const int* a(읽기만 가능하게 즉, 값이 변하면 안될떄) 
//int* const a일떄(주소를 변경하고 싶지 않을때) 사용
//참조는 int& const a(이미 값의 주소를 바꾸지 못하기 떄문에 써도 의미없음)

 

 

📅 Day 3: 객체지향 기초 (클래스, 복사 생성자, 전방 선언)

C++ 클래스의 동작 원리, 객체 복사 시 발생하는 얕은 복사/깊은 복사 문제, 컴파일 속도를 향상시키는 전방 선언을 학습했습니다.

1. 복사 생성자(Copy Constructor)

객체가 복사될 때 호출되는 생성자 입니다.

기본 제공 복사 생성자는 얕은 복사(Shallow Copy)를 수행하므로, 포인터 멤버가 있을 경우 중복 해제(Double Free) 에러가 발생할 수 있어 깊은 복사(Deep Copy) 구현이 필요합니다.

 

2. 전방 선언(Forward Declaration)

#include 대신 class ClassName; 형태로 선언하여 헤더 간의 의존성을 줄이고 컴파일 시간을 대폭 단축시킵니다.

 

💡 핵심 코드 예시: 전방 선언 사용법(ForwardDeclaration)

// Player.h
#pragma once

//#include "Weapon.h"

// 전방선언.
// 1. 컴파일 시간 단축.
// 2. 헤더 순환 참조 방지.
class Weapon;

class Player
{
public:
	Player();
	~Player();

private:
	// Weapon 변수.
	// 전방 선언 한것으로는 타입의 크기를 모름.
	// 전방 선언은 포인터없이 사용 불가능.
	// 함수는 레퍼런스(멤버 변수 없이) 가능.
	Weapon* weapon = nullptr;
};

 

📅 Day 4: 다형성과 연산자 오버로딩

클래스의 유연성을 높여주는 다형성(Virtual Keyword) 및 연산자 재정의를 학습했습니다.

 

1. 가상 함수(Virtual) vtable

virtual 키워드를 사용하면 동적 바인딩(Dynamic Binding)이 일어나 런타임에 실제 객체 타입의 함수가 호출됩니다.

C++ 내부적으로 가상 함수 테이블(vtable)을 참조하여 함수를 호출합니다.

 

💡 핵심 코드 예시: 연산자 오버로딩(OperatorOverloadiong)

// Point.cpp
#include "Point.h"
#include <iostream>
Point::Point(int x, int y)
	:x(x),y(y)
{
 	// 복잡한 작업 or 동적 할당 및 여러 줄의 최적화가 필요할 때.
}

Point Point::operator+(const Point& other) const
{
	return Point(x + other.x, y + other.y);
}

void Point::Print() const
{
	std::cout << "(" << x << "," << y << ")\n";
}

Point operator-(const Point& left,const Point& right)
{
	return Point(left.x - right.x, left.y - right.y);
}


// Main.cpp
#include <iostream>
#include "Point.h"

// 출력 연산자 오버로딩.
std::ostream&<<(std::ostream& os, cost Point& point)
{
	std::cout << "(" << point.x << ", " << point.y << ")\n";
    return os;
}

int main()
{
	// 포인터 객체 간의 덧셈 연산.
    Point p1(1, 1);
    Point p1(2, 2);
    
    Point p3 = p1 + p2;
    // Point p3 = p1.operator+(p2);
    
    Point p4 = p1 - p2;
    // Point p4= operator-(p1, p2);
    
    // 결과 값 출력.
    std::cout << p3 << p4;
	
}

// 출력
(3, 3)
(-1, -1)

 

📅 Day 5: 정적 키워드, 템플릿, 그리고 커스텀 RTTI

C++의 정적 메모리 관리, 일반화 프로그래밍(Template), 그리고 표준 dynamic_cast 보다 가벼운 커스텀 RTTI 시스템 구축 방식을 다뤘습니다.

 

1. static 키워드

정적 변수: 프로그램 생명주기 동안 메모리가 유치되는 변수

정적 함수/변수: 객체 생성 없이 클래스 단위로 접근 가능한 멤버

 

2. 템플릿 & 커스텀 RTTI 시스템

표준 dynamic_cast는 성능 오버헤드가 큽니다.

주소 기반 Type ID와 가상 함수 상속 체인을 결합하면 오버헤드 없는 고속의 RTTI및 Cast 유틸리티를 구현할 수 있습니다.

 

💡핵심 코드 예시: 주소 기반 커스텀 RTTI & 고속 Cast (RTTI)

// CraftObject.h
#pragma once

#include <memory>
// 커스텀 RTTI를 제공하느 기반 클래스.
class CraftObject
{
public:
	// 현재 객체의 타입 아이디 반환 함수.
	// 순수 가상 함수.
	// 이 클래스를 상속하는 클래스는 필수로 구현해야 함(강제 효과).
	virtual size_t GetType() const = 0;

	// 타입 비교 함수.
	// CraftObject의 함수를 호출했다는 건 타입이 다른 것과 같음.
	virtual bool Is(size_t id) const
	{
		return false;
	}

	// 타입 질의 함수.
	template<typename T>
	bool IsTypeOf()const
	{
		return Is(T::TypeId());
	}


};

// 스마트 포인트 형변환 유틸 함수.
template<typename T, typename U>
std::shared_ptr<T> Cast(const std::shared_ptr<U>& object)
{
	//null 확인.
	if(!object)
	{
		return nullptr;
	}

	// object의 실제 타입이 T(또는 T의 하위) 타입인지 확인 후 형변환.
	// 재귀적 처리.
	if (object->Is(T::TypeId()))
	{
		return std::static_pointer_cast<T>(object);
	}

	// 형변환하려는 타입이 아니면 null 반환
	return nullptr;
}

// 타입 시스템에 제공할 함수를 매크로로 구현
#define TYPE_DECLARATIONS(Type, ParentType)							\
	using super=ParentType;											\
protected:															\
	static size_t TypeIdClass()										\
	{																\
		static int runTimeTypeId=0;									\
		return reinterpret_cast<size_t>(&runTimeTypeId);			\
	}																\
public:																\
	static size_t TypeId()											\
	{																\
		return Type::TypeIdClass();									\
	}																\
	virtual size_t GetType() const override							\
	{																\
		return Type::TypeIdClass();									\
	}																\
	virtual bool Is(size_t id)const override						\
	{																\
		return (id == TypeIdClass()) ? true : ParentType::Is(id);	\
	}

 

📝 1 주차 요약 및 회고

· 빌드/메모리 구조 이해: C++이 단순 언어가 아니라 메모리(주소)와 전처리기/링크 과정을 직접 다루는 언어임을 배웠습니다.

· 성능 최적화 의식: 전방 선언을 통한 컴파일 타임 최적화, static_pointer_cast 기반 커스텀 RTTI 구축을 통해 C++ 다운 고성능 프로그래밍 방식을 체득할 수 있었습니다.

Unduck 게임의 패스스루환경과 사물 인식은 완료해서 MR 환경을 맞추었다.

그래서 이를 이용한 MR 게임을 만드려 보려고 했다.

 

이럴수가.. Depth API를 활용한 모습을 보고 당연하게 MR 스테이지에 있는 종이학을 쉐이더를 넣으면 적용 될 줄 알았다.

Meta Quest2가 구 하드웨어라 이용할 수 없었다.............

 

그래도 포기 할 수 없어서 여러 방법을 찾아보았다.

비슷하게나마 구현 할 수 있도록 나는 생각했다. 결국에 얼추 비슷한 기능을 만들 수 밖에...

 

1. 가구 뒤에 오브젝트가 안보이면 된다.

2. 가구에 뭔가 설정을 해줘야 한다.

3. 종이학의 렌더러를 껐다 켜야한다.

 

이런 식으로 진행하려고 한다.

먼저 Quest2가 바라보는 화면은 카메라 시점이기 때문에 종이학(단서)가 가구 뒤에 있으면 안보이게 해야 했기때문에

카메라에 방향에 맞는 레이캐스트를 활용해줬다. 이때 레이캐스트는 "Wall" 이라는 태그를 가진 collider와 충돌하게 되면 그 종이학의 렌더러를 꺼주면 되는것이다.

그나저나 왜 Wall이냐.. 원래 벽만 설정하면 되는줄 알았지만 Wall로 가구들의 태그를 퉁쳐버리자... 그랬더니 확실하게 앞에 가구가 있으면 오브젝트는 보이지 않게 된다.. Depth API처럼 손가락 사이사이로 안보이게 하고 싶었지만 아직은 능력부족이다. 얼추 비슷하게나마 이 문제를 해결하여 몰입감을 더 느끼게 할 수 있었다..
만든 소스 코드는 아래와 같다.

using UnityEngine;

public class ObjectVisibilityController : MonoBehaviour
{
    public GameObject danseo;
    private Transform cameraTransform;

    void Start()
    {
        cameraTransform = GameObject.Find("OVRCameraRig/TrackingSpace/CenterEyeAnchor").transform;
    }

    void Update()
    {
        RaycastHit hit;
        Vector3 directionToObject = (danseo.transform.position - cameraTransform.position).normalized;

        if (Physics.Raycast(cameraTransform.position, directionToObject, out hit))
        {
            if (hit.collider.CompareTag("Wall"))
            {
                MeshRenderer renderer = danseo.GetComponentInChildren<MeshRenderer>();
                if (renderer != null)
                {
                    renderer.enabled = false;
                }
            }
            else
            {
                MeshRenderer renderer = danseo.GetComponentInChildren<MeshRenderer>();
                if (renderer != null)
                {
                    renderer.enabled = true;
                }
            }
        }
    }
}

 

'게임개발 > Unduck' 카테고리의 다른 글

Unity 와 Oculus Quest2 개발 환경 만들기  (1) 2024.05.07

+ Recent posts