A부터 Z까지 혼자서 게임 만들기 도전기 (1탄. MVP 만들기)

Sep 23, 2025 · 30 min read

그동안 다양한 분야를 탐색하며 여러 직군을 경험해왔다.

게임 원화가를 꿈꾸며 시각디자인학과에 입학했고, 대학 시절에는 인디게임 개발 팀을 꾸려 팀장이자 기획자로 게임 제작에 참여했다.

이후 게임 회사에 기획자로 입사하여 1년간 근무하면서 PM 업무를 일부 맡았고, 지표 분석과 스프린트 운영 방법에 대한 이해를 넓혔다.

그러다 개발에 대한 관심이 커져 네이버 부스트캠프에 참가해 웹 프론트엔드 과정을 수료했다. 그 결과 디자인, 개발, 기획을 모두 경험할 수 있게 되었다.

이런 배경을 바탕으로 PM 취업을 목표로 준비하던 중, 직접 혼자서 게임을 만들어보자는 결심을 했다. 게임을 기획하고 제작하는 과정에서 게임 개발 전 과정에 대한 이해도를 높이고, 마케팅과 지표 분석, 운영까지 전 과정을 경험하면서 실제 유입을 만들어낼 수 있다면, 단순한 이력 이상의 배움과 통찰을 얻을 수 있을 것이라 생각했다. 결국 이러한 생각이 행동으로 이어졌고, 지금의 도전을 시작하게 되었다.

게임 기획

이전에 Despotism 3K라는 게임을 인상깊게 했었다. 플레이어가 로봇이 되어서 인간공장을 운영하는 게임이었다. (스팀 링크: https://store.steampowered.com/app/699920/Despotism_3k/)

해당 게임의 대략적인 시스템은 다음과 같다:

이 게임이 재밌긴 한데 19금 묘사에 잔인하고 고어한 장면이 있어서 전 연령층이 즐기기에는 한계가 있었다. 그래서 동일한 시스템과 구조를 유지하되, 캐주얼하게 재해석을 해보면 재밌겠다는 생각이 들었다.

플레이어는 고대 여신이 되어 신도들(인간)을 관리한다. 신도들은 시설에서 노동하며 여신의 힘인 신성을 만들어낸다. 게임의 목표는 가능한 한 오랫동안 문명을 존속시키는 것이고, 게임 오버 조건은 인구가 0명이 되는 것이다.즉 착취와 생존 요소를 유지하되 여신과 신도라는 관계로 변형시킨 것이다.

시설은 다음과 같다.

Despotism 3K처럼 고대 여신과 어울리는 이벤트를 30초마다 발생시켜서 선택에 따라 보상이나 처벌을 획득할 수 있게 했다. 이 이벤트는 게임 플레이 중간에 변수를 주어 게임에 변수가 되어 반복적인 게임 플레이를 유도하는 중요 장치였다.

상세 기획은 다음에서 확인할수 있다. (기획서 링크: https://www.notion.so/225ab3ee1cc1802cb31fc69bd2ecaa69)

개발 과정

MVP 제작에는 한 달 반 정도의 시간이 걸렸다.

진행상황은 노션에 보드를 만들어서 시작전 → 진행중 → 완료 → 아카이브 형태로 나눴고, 진행중에 오늘 작업할 카드를 배치해서 스프린트를 진행했다. 스프린트 단위는 1주일이었다.
(노션 링크: https://www.notion.so/225ab3ee1cc180cd9f26d8720fbf8f8e)

스프린트 요약:

개발

엔진은 Godot 엔진을 사용했다. 오픈소스에다가 파이썬 문법을 기반으로 된 엔진이라고 해서 호기심에 사용해보았다. Godot 엔진에 대한 경험이 없었던 지라 간단한 Godot 강의 영상 몇 개를 보고 바로 시작했다. (Claude Code의 도움을 크게 받았다.) Godot 경험이 전무했지만, 직접 만들어가며 배우니 훨씬 빠르고 재미있었다. 아래는 공부한 내용들을 정리해 보았다.

학습1: Godot 엔진의 구조 이해

Godot의 구조를 이해하지 못하고 짜다 보니 반복적으로 등장하는 get_tree()가 무엇인지 궁금해졌다.

Godot 엔진의 구조:

SceneTree 구조:
씬 트리 하위에는 루트 뷰포트가 있고 하위에 게임 씬들이 붙는다. 루트 뷰포트는 모든 화면의 최상위 부모다:

SceneTree
└──루트 뷰포트
	└── Game 씬 (게임플레이)
	    ├── Player
	    ├── Enemies
	    ├── UI
	    └── ...

노드 생명주기:

  1. _init(): 노드 인스턴스가 생성될 때(생성자 역할) 호출
  2. _enter_tree(): 노드가 씬 트리에 추가되는 즉시(부모→자식 순서로) 호출
  3. _ready(): 씬 트리 구조상 모든 자식 노드까지 추가가 완료된 뒤(자식→부모 순서로) 호출
  4. _process(delta) / _physics_process(delta): 씬 트리에 있는 동안, 게임이 실행되면 매 프레임마다 계속 호출
  5. _exit_tree(): 노드가 씬 트리에서 제거될 때 자동 호출

_enter_tree는 트리 상위에서 아래로 내려가고, _ready는 반대로 아래 → 위로 올라간다. 부모가 자식을 호출하려면 자식이 미리 준비되어있어야 하기 때문

씬을 전환할 때는 _exit_tree를 통해 기존 씬을 제거한 뒤 위 과정을 반복한다. 새 씬이 로딩되는 동안 게임이 완전히 멈추기 때문에 주의해야 한다.

스크립트는 노드가 생성되어 씬 트리에 추가된 이후에 부착된다.
스크립트가 부착된다는 의미는 해당 크립트가 메모리에 로드(컴파일) 된다는 뜻이다.
이후 위에 라이프 사이클에 맞는 메서드가 호출된다.

실제로 개발하면서는 주로 _ready만 활용했다. 초기화 및 시그널 연결을 담당했고, 이후 이벤트 기반으로 로직을 처리했다. _process를 쓸 정도로 연속 계산이 필요한 경우는 거의 없었다.

학습2: 시그널

시그널은 이벤트 시스템으로 동작한다: 발신자 → 시그널(이벤트) → 수신자

시그널은 메모리에 테이블 형식으로 저장되어 있다. (시그널-수신자)
이벤트가 발생시 모든 수신자에게 전달한다.
이후 독립적으로 이벤트를 처리한다.

시그널을 사용하니 기존 코드 수정없이 기존 이벤트를 구독하기만 하면 되서 너무 편리했다.

# ParameterManager.gd - 이벤트를 던지는 쪽
signal population_increased(amount: int)
signal population_decreased(amount: int)

func change_population(amount: int) -> void:
	var old_population = population
	
	if famine and amount > 0: 
		LogManager.display(tr("parametermanater_log1"))
		return
	
	population = max(0, population + amount)
	parameter_changed.emit("population", population)
		
	if amount > 0: population_increased.emit(amount)
	if amount < 0: population_decreased.emit(amount)
	check_game_over()
# HumanManager.gd - 이벤트를 받는 쪽
extends Node

func _ready():
	ParameterManager.population_increased.connect(spawn_humans)
	ParameterManager.population_decreased.connect(remove_humans)
	call_deferred("initialize_population")
	
func spawn_humans(amount: int):
	var residence = get_residence_facility()

	for i in amount:
		if residence:
			residence.spawn_human()
		else:
			print("[HUMAN_MANAGER] Residence를 찾을 수 없습니다.")

func remove_humans(amount: int):
	remove_weakest_humans(-amount)

하지만 문제도 있었다.
게임 플레이 타임이 길어질수록 씬 변경이 너무너무 오래 걸리는 것이다…
바로 시그널 중첩 연결로 인한 메모리 누수 문제였다.

시스템 중 파라미터 매니저는 각 자원을 모두 관리하는 만큼 중앙에 위치하는 스크립트다. 다른 스크립트에서 자원을 변동시킬 때마다 시그널을 받아와서 check_game_over(), check_status_conditions() 등으로 플레이어 상황을 업데이트 해놨다.

signal parameter_changed(param_name: String, new_value: int)
signal population_increased(amount: int)
signal population_decreased(amount: int)
signal year_changed(new_year: int)
signal status_changed(status_name: String, active: bool)
signal game_over(reason: String)

이후 시그널을 정리하지 않은채로 게임을 계속해서 반복 플레이를 하니 게임 시작버튼을 누를때 로딩이 너무나도 오래 걸렸다. 알고보니 시그널을 해지하지 않으면 계속 생겨서 그런 것이었다.

똑같은 시그널이 계속 생기고 또 생겨서 메모리 누수가 생긴 것이다.
자동으로 게임 내 모든 시그널을 해지하는 방법이 없었을까 고민했는데 찾아도 나오지 않아 아래처럼 일일이 시그널 연결을 해제해줬다.

func reset_all_signals():
	disconnect_all_connections(parameter_changed)
	disconnect_all_connections(population_increased)
	disconnect_all_connections(year_changed)
	disconnect_all_connections(status_changed)
	disconnect_all_connections(game_over)

func disconnect_all_connections(signal_obj: Signal):
	for connection in signal_obj.get_connections():
		signal_obj.disconnect(connection.callable)

나중에 알아보니 Godot은 queue_free()나 free()로 노드를 제거할 경우, 그 노드와 관련된 시그널 연결도 자동으로 정리한다. 문제는 게임 오브젝트가 남아있는데 연결만 계속 추가되는 경우인데, 이건 엔진이 자동으로 정리하지 않는다. 이유는 같은 시그널을 여러 군데서 동시에 쓸 수도 있기 때문이다.

학습3: GDScript의 런타임 동적 변경

흥미로웠던 점은 GDScript가 인터프리터 언어라 런타임 중에도 스크립트를 교체할 수 있다는 것이었다. Unity에서는 불가능한 일이다. 이번 프로젝트에서는 실제로 활용하지는 않았지만, 이런 유연함은 Godot의 큰 장점 중 하나라 생각된다.

# AIBehaviorManager.gd
extends Node

func change_ai_behavior(ai_node: Node, behavior_type: String):
    var behavior_script = load("res://ai_behaviors/" + behavior_type + ".gd")
    ai_node.set_script(behavior_script)
    print("AI 행동 패턴 변경: ", behavior_type)

# 사용 예시
func _on_difficulty_changed(new_difficulty: String):
    var all_enemies = get_tree().get_nodes_in_group("enemies")
    for enemy in all_enemies:
        match new_difficulty:
            "easy": change_ai_behavior(enemy, "PassiveAI")
            "hard": change_ai_behavior(enemy, "AggressiveAI")
            "nightmare": change_ai_behavior(enemy, "SmartAI")

하여튼 Godot으로 개발하는 건 정말 재밌었다. 다음 개발 때는 Unity를 사용해보려고 한다. 실제로 두 엔진을 모두 써봐야 각각의 장단점을 제대로 비교할 수 있을 것 같아서다.

디자인

디자인은 피그마로 진행했다. 참고한 게임 디자인은 레전드 오브 슬라임과 수확의 정석이다.
아트 스타일은 레전드 오브 슬라임을 많이 참고했고, 수확의 정석에서 UI배치를 참고했다.

UI와 캐릭터는 모두 벡터로 제작했으며, 작업물은 링크에서 확인할 수 있다. (피그마 링크: https://www.figma.com/design/5o3iQfNFI5FkFIFY70cCwT/Become_goddness?node-id=0-1&p=f&t=IflVjDRoGdmJr8su-0)

애니메이션은 Rive를 사용했다. 포토샵 외에는 툴 경험이 없었지만, Rive가 직관적이어서 금세 적응할 수 있었다.

게임 디자인을 하면서 배웠던 점은 다음과 같다:

로그 설계와 데이터 분석

MVP가 어느정도 마무리 되고 나서는 로그를 추가했다. 퍼널을 통해 유저 행동을 파악하기 위함이다.
나는 Google Analytics를 사용하기로 했다.

GA는 이벤트 명 {}키값 형태로 파라미터를 보내면 된다. 웹 스트림과 앱 스트림이 있는데 둘 다 시도해봤는데 웹은 성공했는데 앱은 실패해서 웹스트림으로 했다. (웹 스트림으로 하니 국가 아이디가 추적이 안 되는 것 같기도 하고… 아직 파악중이다)

게임의 사용자 플로우에 따른 로그

  1. 게임 시작 (first_visit, appstart)
  2. 여신 선택 (goddess_chosen)
  3. 게임 시작버튼 클릭 (game_start)
  4. 튜토리얼 시작 (tutorial_start)
  5. 튜토리얼 진행 (tutorial_progress)
  6. 튜토리얼 종료 (tutorial_complete)
  7. 게임 진행 (yearly_status_str)
  8. 게임 종료 (game_over)

yearly_status_str는 유저들이 실제로 어느 자원에서 막히는지, 몇 년까지 버티는지 등을 파악하기 위함이다. 이를 보고 밸런싱을 추후 진행할 예정이다.

카테고리트리거 시점로그 키 예시 (add_design_event)목적 / 설명
유입게임을 최초 실행했을 때(유저 기준)first_visit{user_id, play_count, session_id, ga_session_id, ga_session_number, engagement_time_msec}
앱 실행 시마다 발생(중복 가능)app_start{user_id, play_count, session_id, ga_session_id, ga_session_number, engagement_time_msec}
여신 선택구매 버튼 클릭 시goddess_purchase{goddness_name}구매 전환률 분석
게임 시작 시 선택된 여신goddess_chosen{goddness_name}실제 사용 여신 통계
게임 시작게임 시작 버튼 클릭 시game_start{play_count}세션당 플레이 횟수
튜토리얼튜토리얼 시작tutorial_start시작률 파악
튜토리얼 각 스텝 완료tutorial_progress{step}중도 이탈률 파악
튜토리얼 완료 시tutorial_complete완주율 분석
자원 현황매해 자원 기록yearly_status_str{year, faith, food, population}자원 밸런싱
게임 종료종료 시 자원 상태game_over{faith, food, population, year}종료 상황 분석

이렇게 추가한 이벤트를 기반으로 GA로 보고서를 만들어서 튜토리얼 퍼널 이탈률을 분석해보기도 했다.
튜토리얼 단계별로 보고서를 만들었는데 2 & 4 단계에서 많은 이탈이 일어난다는 것을 발견했다.

튜토리얼 2단계는 클릭하지 않아도 3초 뒤에 자동으로 다음 단계로 넘어가도록 수정했으며, 4단계는 드래그할 대상 위에 손 모양 아이콘 애니메이션을 추가하여 직관적으로 안내했다.

그 결과 튜토리얼 2단계 이탈률이 48% -> 17%로 감소했고, 4단계는 53% -> 0%로 해소되었따.
작은 UX 장벽을 제거하고 보완하여 이탈률이 크게 줄어들었다.

출시

이후 완성된 MVP를 스토어에 업로드하였다. 구글 플레이스토어, 앱스토어 두개 다 고려해봤을때 앱스토어 비용이 다소 부담스러워서 구글 플레이 스토어 부터 업로드를 했다. 이후 수익이 나오면 앱스토어 업로드를 고려해볼 생각이다.

구글 플레이스토어는 비공개 테스트를 거쳐야만 스토어에 등록할 수 있다. 14일간 12대를 매일 실행해야하는건데 이게 여간 까다로운게 아니다.

지인을 동원해서 하기에는 너무 어렵다고 느껴져서 크몽 전문가에게 맡겼다. 걱정을 많이 했는데 다행히 한번만에 승인이 되었다.

지인 테스트 및 재미 보강

스토어에 올린 뒤에 주변 지인에게 테스트를 부탁했다.
평가는 냉정했다 대체로 재미가 없다고 말했다. 그리고 게임 목표가 뭐냐고 묻는 사람이 많았다.

목표를 1차원적으로 바로 인지하지 못하는 게 문제였다. 왜 그럴까 생각하며 모티브로 삼은 Despotism 3K를 다시 살펴보니 왼쪽에 큰 게이지로 상시 전력량을 보여주며 다음에 닳을 전력량을 계속 보여주고 있었다. 이에 게이머는 상시 내가 얼마나 더 많은 전력을 생산해야 하는지 즉각적으로 파악이 가능했다.

해당 게임과 달리 내 게임의 플랫폼은 모바일이고 이미 다양한 UI로 인해 복잡한 상황이었다. 그래서 게이지나 다른 시각적인 표현 없이 단순한 박스로 표기했다(수확의 정석을 참고했다).

이게 있고 없고의 차이가 생각보다 컸다. 결국 매세기(20초)마다 신성이 줄어들고 신성이 없으면 게임이 오버가 된다는 새로운 제약이자 단기적인 목표를 추가하자 긴장감과 동기부여가 올라갔다.

수익화와 마케팅

MVP 단계라서 인터스티셜 광고, 자원 리워드 광고, 배너 광고만 추가했다.

3.8달러라는 귀여운 수익이 생겼는데 바로 광고 게재 제한되었다. 테스트할때도 테스트 id를 이용해 광고를 확인했는데 억울할 뿐이다. 알아보니 빈번하게 발생하는 일이라고 한다.

기다리면 30일 내에 자동으로 풀린다고 하여 막연히 기다리고 있다. 언제 풀릴런지…

광고 게재 제한 먹기 전에는 마케팅을 진행했었다. 게임 플레이 영상을 토대로 소재를 하나만 제작했다. 인구 유닛이 점점 폭발적으로 증가하는 영상이고 상단에 “너는 얼마나 인구를 만들 수 있냐?”라는 식의 도발 문구를 넣었다. 인구 애니메이션에서 나오는 소리로 ASMR 효과를 노렸다.

처음에는 메타 광고를 돌리려고 했는데 뭣 모르고 계정 열자마자 광고 올렸다가 영구 제재 당해버렸다. 알고 보니 페북 페이지도 만들고 계속 게시글도 쓰면서 어느 정도 신뢰를 쌓고 했어야 했다고 한다…

그래서 구글 애즈로 시작하기로 했다.

Google Ads 결과

미국, 캐나다, 호주, 스위스 등 광고 단가가 높은 나라들로만 돌렸다. 클릭수 최소 100은 되어야 지표를 볼 만하다는데 광고 게재 제한을 먹어버려서 일단은 일시정지 상태다. 게재 풀리면 계속 진행해볼 예정이다.

앞으로 방향성

광고 게재 제한이 풀리면 다시 광고를 열어서 클릭이 100이 될 때까지 기다려볼 예정이다.
그 이후 지표를 보고 액션을 취할 예정이다.

노출 → 클릭이 낮으면 → 광고 개선

회고

이번에 처음으로 게임 프로젝트를 처음부터 끝까지 진행하면서 부족한 점을 많이 느낄 수 있었다.
잘한점과 아쉬웠던 점을 정리해보면 다음과 같다.

잘한 점

아쉬운 점

배운 것들

다음 프로젝트에서는 이번 경험을 바탕으로 더 체계적이고 효율적인 개발을 진행할 수 있을 것이라 생각한다. 또한 Unity 엔진으로 전환하여 Godot과 비교하면서 두 엔진의 장단점을 직접 체험해보는 것도 좋은 학습이 될 것이다.


Share this article with your friends

Bluesky XformerlyTwitter LinkedIn Reddit
프로필 이미지

김다영

카비게임즈 대표이자 1인 인디게임 개발자입니다.