[제노사이드(Zenocide)] 스토리 랜덤 진행 로직을 만들어보자
서론
이번 포스팅에선 작년 <제노사이드>를 개발하며 어떤 흐름으로 로직을 설계했고, 최종적으로 완성된 로직의 모습은 무엇인지 살펴보겠다.
게임이 출시된지 어느덧 1년이 다 되어가지만, 아직도 개발할 때의 흐름은 생생하기에, 개인적으로 정리도 할 겸 다른 사람들과 공유도 할 겸 포스팅을 작성해본다.
왜 <서울 2033>인가?
<제노사이드>는 WAP에서 팀을 먼저 모집한 후, 함께 기획하며 만들어진 게임이다. 일반적으론 동아리 내에서도 팀장이 기획을 가져오는 경우가 많지만, 당시엔 게임 개발자가 극소수기도 하고, "내가 하고 싶은 걸 해야 재밌게 배운다."는 취지 하에 팀원들이 원하는 장르에 맞추곤 했다.
우리 팀의 유일한 디자이너가 뛰어난 솜씨를 지녔고, 선호하는 스타일이 "좀비"와 "아포칼립스"였기에, 게임의 배경은 쉽게 정해졌다.
그 다음이 게임의 장르였는데, 모든 팀원이 로그라이크를 선호했다. 이에 <Slay the Spire>같은 덱빌딩 로그라이크 장르를 만들기로 했는데, 당연히 그대로 만들기만 하면 차별점이 없지 않겠나. 이에 오랜 시간, 몇 주에 걸쳐 회의가 계속되었고, 같은 로그라이크 장르이자 2018년 내게 신선한 충격을 줬던 게임, <서울 2033>의 스토리 진행 방식이 시너지를 일으킬 수 있다 생각했다.
<Slay the Spire>는 덱빌딩 로그라이크의 명작이었지만, 스토리 진행에선 아쉬움을 느꼈다. <서울 2033>은 그런 점에서 랜덤한 스토리, 매 판 달라지는 전개가 흥미였지만, 텍스트로만 진행되어 플레이어의 조작이 선택지 외엔 없었다는 게 아쉬웠다.
이에 두 시스템이 서로를 보완하여 재밌는 게임을 만들 수 있을 거라 판단했고, 결과적으로도 꽤 괜찮은 게임이 나왔다.
(단 한 가지, 스토리와 전투의 완성도가 모두 떨어진다는 것만 제외했다면 말이다.)
어떤 시스템을 만들었는가?
<서울 2033>의 진행 방식은 매우 흥미롭다.

- 처음엔 튜토리얼 겸 메인 스토리로 시작한다.
이때 랜덤하게 2개의 능력을 얻게 된다. - 1과 관련없는 랜덤한 스토리를 진행한다.
선택지가 제시되며, 아이템 보유 여부에 따라 고를 수 있는 선택지가 달라진다. - 다시 랜덤한 스토리가 선택되고 진행된다. 이때 메인 스토리가 진행될 수도, 서브 스토리가 진행될 수도 있다.
단, 이전 스토리의 진행 방식에 따라 등장하는 스토리는 달라진다.
예를 들어, 메인 스토리 1이 진행되었고, 3개의 선택지(1-1, 1-2, 1-3)가 주어졌다고 하자. 이중 1-3이 선택되었다면, 이후 스토리는 1-3의 후속 스토리만 등장하는 식이다.
실제 예를 들어보자. 당신은 길을 걷다 부상당한 미군을 만났다. 그를 도와주거나, 죽게 내버려둔 후 아이템을 챙겨서 떠날 수 있다. 만약 전자를 골랐다면, 추후 등장하는 미군 부대 스토리에서 미군들이 당신을 우호적으로 대해준다. 하지만 죽게 내버려뒀다면, 미군들은 당신을 적대하며, 미군과 관련된 스토리를 진행할 수 없게 된다.
이처럼 랜덤한 스토리가 이어지며 진행되지만, 그 안에서는 스토리가 제대로 전개되는 방식이며, 이 시스템이 <서울 2033>의 흥행 핵심이라 생각한다. 오늘 살펴볼 로직은, 이렇게 랜덤하지만 순차적인 이벤트 진행 방식에 관한 것이다.
만약 로직이 구상되는 과정이 아닌 결과물만 보고 싶다면, 아래의 중간 점검이랑 완성본으로 바로 넘어가자.
어떻게 구현했는가?
가장 먼저 한 일은, 단위를 정하는 것이었다.
이 시스템은 스토리가 랜덤으로 진행되기에, 어찌보면 들쭉날쭉하게 진행되기도 한다. 그렇기에 어디부터 어디까지가 하나의 스토리라는 경계가 필요하다.
우린 이 하나의 스토리 단위를 이벤트(Event)로 정의하고, 이는 단순히 정보를 저장하는 데이터라고 정했다. 이벤트가 어떤 걸 담고 있게 되든, 그 안엔 스토리에 대한 데이터만 들어있을 뿐, 직접 이벤트를 진행하는 건 다른 클래스에서 할 일이라 정했다.
(코드 상에선 예약어 event와 겹쳐 EventData라는 이름으로 사용되었다.)
이벤트의 처음과 끝을 어떻게 구분할지, 어떤 방법으로 글자를 저장하는지는 여기서 다룰 일이 아니기에, 우린 그저 이벤트라는 빈 껍데기만 가지고 로직을 구상하기 시작했다.
1. 랜덤한 이벤트를 선택한다.
가장 먼저 떠올린 건 랜덤한 이벤트를 선택하는 것이다.

그러기 위해선 이벤트를 모아서 저장하는 공간이 필요하며, 또 그중 랜덤한 인덱스에 바로 접근이 가능해야 했다.
그렇다면 가장 적당한 자료구조는 배열(Array)이 되는데, 단점이라면 이벤트의 크기를 미리 예측할 수 없다.
그렇기에 우린 List<Event>를 자료구조로 채택했다. C#의 List는 ArrayList로 구현되어 있기에 랜덤 접근이 가능하고, 크기 또한 유동적으로 늘릴 수 있기 때문이다.
그렇게 랜덤한 이벤트 중 하나를 선택하고, 진행하라고 던져주는 함수가 완성되었다.
public List<EventData> processableEventList = new List<EventData>();
private EventData GetRandomEvent()
{
// 랜덤한 숫자 하나를 고르고
int randomIndex = Random.Range(0, processableEventList.Count);
// 해당 이벤트를 리스트에서 가져와 넣는다. (삭제)
EventData selectedEvent = processableEventList[randomIndex];
processableEventList.RemoveAt(randomIndex);
return selectedEvent;
}
2. 스토리는 진행되어야 한다.
랜덤한 스토리를 선택해 진행하는 건 단순하다.
그러나 <서울 2033>의 독특한 점은, 랜덤한 진행 속에서도 스토리는 전개된다는 사실이다.
학생회를 만나 일련의 사건들을 겪었다면, 이후엔 학생회를 만나 새로운 일을 하게 된다. 그들은 일전의 사건을 전부 기억하며, 그때의 선택들도 기억하고 있다.
그리고 바로 이어지지 않고, 랜덤한 서브 스토리들을 진행하다가 우연히 만나 메인 스토리(혹은 거대 서브 스토리)를 진행하게 된다.
그런데, 내가 고른 선택지에 따라, 이후 등장할 스토리는 전혀 달라지게 된다. 어디 한 곳에 내가 무엇을 했는지 전부 저장하는 건 어렵다고 판단했다.
이에 각 스토리가 자신의 후속 스토리를 저장하는 방식을 떠올렸고, 그렇게 구현시켰다.

선택하는 로직은 이미 있었으니, 리스트에 등록만 하면 일은 해결된다. 그렇게 간단하게 이 문제도 해결할 수 있었다.
// 이벤트를 종료한다.
private void EndEvent(EventData loadedEvent)
{
// 추가할 이벤트가 있다면
for (int j = 0; j < loadedEvent.addEvent.Length; ++j)
{
AddEventToList(loadedEvent.addEvent[j]);
}
}
3. 선택지가 갈라져야 한다.
2번의 로직을 만들면서 같이 만들어진 게, 바로 선택지 시스템이었다.
선택지를 골랐을 때 나타나는 이벤트를 하나의 이벤트로 본다면, 각기 다른 선택지 이벤트는 각기 다른, 자신만의 스토리를 리스트에 추가할 것이다. 그러니 우린 선택지를 골랐을 때, 해당 선택지 이벤트로 이동만 시키면 모든 문제가 해결된다.
그런데, 우린 지금까지 하나의 스토리가 끝나면 랜덤한 스토리를 선택하며 진행했다. 하지만 선택지는 고르는 순간 정해진 이벤트로 이동해야만 한다. 이에, 해당 스토리가 끝날 때, 고정된 다음 이벤트가 존재하는지 여부를 체크해야 했다.
이에 이벤트 안에 선택지 이벤트들을 저장하게 하고, 선택지(외부)가 그 중 하나를 고르게 했다. 이걸 Manager의 currentEvent에 등록시키면, 끝나고 해당 이벤트로 이동하는 식이다.
그럼 기존처럼 랜덤하게 이벤트를 뽑는 건 언제일까? currentEvent가 null일 때만 새 이벤트를 가져오게 변경했다.

// 랜덤 이벤트를 선택해 진행한다.
public IEnumerator ProcessRandomEvent()
{
// 게임이 끝나지 않았다면 무한 반복
while (isGameCleared == false)
{
// 현재 이벤트가 없다면
if(currentEvent == null)
{
// 랜덤한 이벤트를 가져온다.
currentEvent = GetRandomEvent();
}
// 이벤트를 진행한다.
processEvent = StartCoroutine(ProcessEvent(currentEvent));
yield return processEvent;
}
// 끝나면 엔딩.
endingPanel.Ending(endingName);
}
4. 선택지가 아니어도 이어질 수 있다.
선택지에서 조금 더 나아가, 선택지를 띄우지 않는 스토리도 즉시 다음 이벤트를 호출할 수도 있다.
사실 이건 어찌보면 불필요한 기능인데, 그 두 이벤트를 합치면 문제가 해결되기 때문이다.
시스템은 정립되었기에 코드 한 두 줄이면 가능하지만, 안 만들어도 지장은 없는 상황. 그럼에도 구현하기로 한 건, 이벤트 전환 시 이펙트를 띄우기로 했기 때문이다.

너무 긴 이벤트는 피곤함을 유발한다. 그렇지만 중간에 화면을 전환하며 잠깐의 쉬는 시간을 부여하면, 비교적 피로도가 덜하다. 특히나 우리 게임은 스토리 위주로 진행되기에 루즈해지기 쉬운데, 이 로직으로 적절히 대화 수를 조절할 수 있었다.
구현은 단순했다. 이벤트 안에 nextEvent를 두고, EndEvent에 한 줄만 추가하면 끝이었다.
// 이벤트를 종료한다.
private void EndEvent(EventData loadedEvent)
{
// 추가할 이벤트가 있다면
for (int j = 0; j < loadedEvent.addEvent.Length; ++j)
{
AddEventToList(loadedEvent.addEvent[j]);
}
// 바로 이어질 이벤트가 있다면 거기로 이동한다. 없으면 null이 된다.
currentEvent = loadedEvent.nextEvent;
}
중간 점검. 기본 시스템은 여기까지
여기까지, 스토리 진행의 기본 시스템이 완성되었다.
이를 정리하여 시퀀스 다이어그램으로 살펴보자.

이를 글로 정리해보면
- 하나의 스토리는 하나의 이벤트로 정의한다.
- currentEvent가 null이라면 processableList에서 랜덤한 이벤트를 하나 받아온다.
- 이벤트를 진행한다.
- 이벤트가 끝날 때, 후속 이벤트가 있다면 processableList에 추가한다.
- 선택지 또는 다음 이벤트가 있다면, currentEvent에 바로 대입한다.
이제, 여기서 추가로 개선된 로직들을 살펴보자. (여기부터 꽤나 복잡해진다.)
5. 메인 스토리가 등장할 확률이 너무 낮다.
완성된 로직은 스토리를 진행하는데 아무런 문제가 없지만, 개선점은 즐비한다.
예를 들어, 지금은 이벤트 리스트 내 동일 확률로 선택하기에, 메인 스토리가 등장할 확률이 서브 스토리에 비해 터무니없이 낮다.
출시 시점 기준, 리스트에 상주하는 이벤트는 약 40개로, 메인 스토리가 진행될 확률은 1/40, 2.5%에 불과하다.
이에, 메인 스토리가 등장할 확률을 별도로 계산하는 로직을 추가해야 한다.
우리가 한 생각은 단순했다. 스토리를 랜덤 선택하기 전, 메인 스토리인지 아닌지를 먼저 검사하고, 그 안에서 동일 확률로 이벤트를 선택하는 것. 이를 위해 기존 이벤트들을 담고 있던 processableEventList를 2가지로 나누었다.
// 메인 이벤트를 진행할 확률(백분률로 표기)
public int mainRate = 30;
[Header("진행 가능한 이벤트 데이터")]
public List<EventData> processableMainEventList = new List<EventData>();
public List<EventData> processableSubEventList = new List<EventData>();
메인 스토리를 등장할 확률을 변수로 저장하고, 인스펙터에서 바꿔가며 테스트를 진행했다.
랜덤 이벤트를 반환하던 GetRandomEvent 함수에도 변화가 생겼다.
private EventData GetRandomEvent()
{
// 확률 계산 (0~99)
int randomNumber = Random.Range(0, 100);
// 이벤트를 가져올 리스트
List<EventData> processableEventList;
// 메인 스토리가 선택되면
if (randomNumber < mainRate)
{
processableEventList = processableMainEventList;
// 메인 스토리가 없으면 서브만 등록
if (processableMainEventList.Count() == 0)
{
processableEventList = processableSubEventList;
}
}
else
{
processableEventList = processableSubEventList;
// 서브 스토리가 없으면 메인만 등록
if (processableSubEventList.Count() == 0)
{
processableEventList = processableMainEventList;
}
}
// 랜덤한 숫자 하나를 고르고
int randomIndex = Random.Range(0, processableEventList.Count);
// 해당 이벤트를 리스트에서 가져와 넣는다. (삭제)
EventData selectedEvent = processableEventList[randomIndex];
processableEventList.RemoveAt(randomIndex);
return selectedEvent;
}
이처럼 이벤트 선택 전 메인/서브 검사 로직을 추가하고, 리스트가 비었다면 반대쪽 리스트를 선택하게 해 혹시 모를 에러에도 대비했다.

6. 게임 내 밸런스를 조절할 수 없다 & 같은 이벤트가 계속 반복된다.
로그라이크 장르의 묘미는, 뒤로 갈수록 강해지는 적에게 있다. 그러나 현재의 방식으론 적들을 다채롭게 설계한다한들, 시작하자마자 최후반부 몬스터가 나올 수도, 종결급 세팅에도 초반 잡몹이 나올 수도 있다. 스토리 위주로 진행되는 <서울 2033>에선 파밍을 오래하면 쉬워지긴 해도 이러한 문제는 발생하지 않았지만, 카드 전투를 추가한 <제노사이드>에선 꽤 중요한 문제였다.
또한, 스토리가 끝날 때 자기 자신을 다시 추가하는 재귀 이벤트(단순 파밍 이벤트 등)도 존재하는데, 운이 나쁘면 연속으로 같은 이벤트가 등장하는 불상사도 발생했다.
이 문제를 해결하기 위해, 이벤트 안에 딜레이 변수를 추가하고, 딜레이 딕셔너리를 구현해 해결하였다. 적용된 로직은 다음과 같다.
- 이벤트가 처음 추가될 때, 즉시 processableList에 등록되지 않고 delayDictionary에 등록된다.
- 하나의 이벤트(선택지를 통해 넘어가는 경우는 제외)가 끝나는 시점에, delayDictionary 내의 모든 delay를 1 줄인다.
- delay가 0이 된 이벤트는 delayDictionary에서 꺼내고, 종류에 맞게 메인/서브 리스트에 추가한다.
이를 그림으로 표현하면 이렇게 된다.


코드로 작성해보자.
[Header("딜레이 딕셔너리")]
public Dictionary<EventData, int> delayDictionary = new Dictionary<EventData, int>();
private void ProcessDelay(EventData loadedData)
{
// 메인 리스트 딜레이 감소
List<EventData> events = delayDictionary.Keys.ToList();
for (int i = 0; i < delayDictionary.Count; i++)
{
// 딜레이 감소
delayDictionary[events[i]] -= 1;
// 딜레이 만큼 기다렸다면
if (delayDictionary[events[i]] <= 0)
{
// 리스트에 삽입 (이때 타입에 맞게 삽입된다.)
AddEventToList(events[i]);
// 딜레이 딕셔너리에서는 삭제
delayDictionary.Remove(events[i]);
}
}
}
// 리스트에 이벤트를 추가한다.
private void AddEventToList(EventData eventData)
{
// 딜레이 딕셔너리에 추가한다.
if (eventData.eventID == EventType.Main || eventData.eventID == EventType.MainIncarnage)
{
processableMainEventList.Add(eventData);
}
else if (eventData.eventID == EventType.Sub || eventData.eventID == EventType.SubIncarnage)
{
processableSubEventList.Add(eventData);
}
else
{
Debug.LogError("선택지 이벤트가 processable Event List에 추가되었습니다.");
}
}
이 딜레이는 후속 이벤트로 등록될 때는 물론, 처음 게임을 시작하며 등록할 때도 적용된다. 이를 이용해 초반과 후반 이벤트를 조율할 수도 있고, 대형 이벤트 진행 도중, 사이사이 몇 개의 서브 이벤트를 발동시킬지도 조절할 수 있다.
이벤트에 삭제 턴을 추가하고, 전체 진행 턴과 비교해 삭제시키면 초반부 이벤트가 후반에 뜨지 않게도 할 수 있지만, 거기까진 구현하지 않았다.
7. 선택지는 "이벤트 종료"로 판정하지 않는다.
위에선 선택지를 통해 이벤트를 골라도, 실제로는 이벤트 종료 -> currentEvent 확인 -> 해당 이벤트 실행의 프로세스를 갖는다. 그러나, 선택지는 이벤트가 종료되는 것이 아닌, 플레이어의 조작으로 하나의 이벤트 안에서 흐름을 바꾸는 느낌이기에 맞지 않는다.
이게 가장 크게 느껴진 건 전환 효과를 추가한 직후였는데, 선택지를 골랐을 때도 전환 효과가 나타나니 굉장히 어색했다.
이부분은 간단히 개선했는데, 본문엔 나오지 않았지만 CSV 파일 내에서 선택지 여부를 체크하는 부분이 이벤트 종료 판정보다 앞섰다. 이에 선택지가 있을 때, 선택 후 즉시 ProcessEvent를 빠져나오게 하여 이벤트 종료 판정을 회피했다.
// 이벤트를 진행한다.
public IEnumerator ProcessEvent(EventData loadedEvent)
{
// null이 들어오면 바로 종료한다.
if (loadedEvent == null)
{
yield break;
}
// 이벤트 진행
for (int i = loadedEvent.startIndex; i <= loadedEvent.endIndex; ++i)
{
// 이벤트의 끝이면
if (dataCSV[i]["Name"].ToString() == "END")
{
// 함수를 종료한다.
EndEvent(loadedEvent);
// 딜레이를 감소시킨다.
ProcessDelay(loadedEvent);
if(isGameCleared == false)
{
//화면 전환 효과를 준다.
yield return StartCoroutine(LoadingEffectManager.Instance.FadeOut(transitionDuration));
yield return new WaitForSeconds(transitionDuration / 2);
}
// 현재 이벤트를 종료한다. (ProcessRandomEvent로 이동)
yield break;
}
...
// 선택지가 나타나면 선택지 이벤트를 실행한다.
if (dataCSV[i]["Choice Count"].ToString() is not emptyString)
{
#region 선택지 표시 및 대기
// 선택지를 띄우고, 선택할 때까지 대기
yield return DisplayChoices(dataCSV[i]);
// 고른 선택지 번호 확인하고
int result = SelectManager.Instance.result;
// 선택된 이벤트를 캐싱해둔다.
EventData relationEvent = loadedEvent.relationEvent[result];
#endregion 선택지 표시 및 대기
#region 선택한 이벤트로 이동
// 선택된 이벤트가 null일 경우
if (relationEvent == null)
{
// 기존 이벤트를 이어서 진행한다.
isClicked = true;
// 다음 줄로 바로 이동한다.
continue;
}
// 그 외엔 선택지 이벤트로 교체한다.
currentEvent = relationEvent;
#endregion 선택한 이벤트로 이동
//선택지 로그 저장 함수 호출
LogManager.Instance.AddLog(dataCSV[i], result + 1);
yield break;
}
...
}
// 이벤트가 종료되지 않고 endIndex를 벗어난 경우 에러를 띄운다.
Debug.LogError("End Index가 틀렸습니다.");
}
이벤트의 끝이 아니고, 모든 if문을 회피해서 for문의 끝에 도달한다면? 다시 처음으로 돌아가 다음 줄을 출력한다.
8. EventData의 필요 데이터 정리
위 로직을 안정적으로 구현하기 위해, EventData엔 다음 정보가 필요함을 알 수 있다.
- 이벤트 종류 (메인/서브/선택지)
- 후속 이벤트 (먼 훗날 등장 가능한 이벤트)
- 다음 이벤트 (이벤트가 종료되고 즉시 이어지는 이벤트)
- 선택지 이벤트 (선택하면 해당 이벤트로 이동)
- 딜레이
- 이름, 일러스트, 대화 내용 등 대화에 필요한 기타 데이터들
실제 개발 시엔 EventData도 함께 개발하여, 로직을 완성하고 후에 EventData를 개발하는 일은 없었다. 다만, 위 정보들이 반드시 필요했고, 여기에 추가로 이벤트 진행을 위한 정보들을 추가해 EventData SO를 완성할 수 있었다.
이번 포스팅과 관련은 없지만, 참고삼아 완성본을 보여주자면 이렇다.

EventData를 CSV와 어떻게 연동하고, 또 그 많은 SO를 어떻게 관리했는지 궁금하다면 아래 포스팅을 참고하자.
[Unity] CSV에서 자동으로 에셋 생성하기
귀찮은 걸 못 버티는 사람이 성공한다우리 게임 제노사이드는 시나리오를 아래처럼 관리한다.그리고 이걸 바탕으로 SO를 생성한다. 학기 중엔 이걸 하나씩 복붙 후 이름 변경으로 만들었다.
autumncat.tistory.com
최종 결과물
이렇게 개선을 거친 스토리 진행 로직을 시퀀스 다이어그램으로 나타내면 아래와 같다.

상당히 복잡해졌지만, 이해하기 어렵진 않을 거라 생각한다. (아마도. 혹여나 이 글을 전부 읽고도 안 된다면... 더 잘 써보겠습니다....)
해당 프로젝트는 코드를 GitHub에 공개 중이다. 만약 전체 코드가 궁금하다면 아래 링크를 참조하자.
(당연하지만, 리소스는 올라가있지 않다. 코드는 몰라도 일러스트와 음악은 어디가서 쓰는 순간 용서 안 한다.)
GitHub - pknu-wap/Zenocide: 부경대학교 중앙동아리 WAP, 2024년 1학기 게임 2팀 프로젝트입니다.
부경대학교 중앙동아리 WAP, 2024년 1학기 게임 2팀 프로젝트입니다. Contribute to pknu-wap/Zenocide development by creating an account on GitHub.
github.com
마무리
개발할 땐 그냥 간단하게 만들었다 생각했는데, 글로 정리하고 다이어그램으로 정리하니 꽤나 머리 아프다.
(글 쓰기 시작한지도 어느덧 9시간이 다 돼간다.)
그래도 이 로직이 게임에 안정적으로 녹아들었고, 재밌는 게임이 완성되어 뿌듯했다. <제노사이드>는 개발에 있어, 당시 환경에서 최선의 결과를 냈다고 생각한다. 팀원 모두 유니티는 처음 다뤄봤지만, 다들 열정적이어서 분배된 일을 상상 이상으로 해냈다. 코드를 읽으며 나도 배웠을 정도로 다들 열심히 해줬다. 디자이너도 작업량이 많음에도 불구하고 게임 내 모든 에셋을 혼자 그려줬고, 몇 시간씩 길어지는 회의, 카페에 모여서 게임을 만드는 시간까지. 힘들지만 즐거운 시간들이 계속되었다.
내가 <제노사이드>를 개발하며 후회하는 건, 나의 기획 능력이 터무니없이 낮았기 때문이다. 이 게임의 스토리는 팀원들도 함께 작업했지만, 메인 스토리와 사이비 스토리는 내가 홀로 작업했다. 그러나 특히 중요한 메인 스토리의 품질이 좋지 않고, 거기에 몇 안 되는 몬스터 종류, 다채롭지 않은 카드들, 밸런스가 무너져 너무 쉬워진 전투 등, 기획 완성도가 너무 떨어지는 결과가 나타났다. 다같이 편한 분위기에서 아이디어를 내고, 다듬고, 개발한 건 좋았지만, 내가 조금 더 잘했더라면 어땠을까. 늘 아쉬움이 남는다.
지금은 다들 본인의 진로에 맞는 길을 걷고 있고, 군대에 간 친구도 있다. 사실 개발을 계속할 여건이 마련되지 않았지만, 그럼에도 조금이라도 더, 더 해보겠다며 완성시켜준 팀원들에게 감사할 따름이다. 오랜만에 그때의 코드들을 보니 옛 추억에 잠기게 된다.
딱 오늘만 과거에 살고, 내일부턴 다시 지금의 팀들에 집중하자.
