LifeGame 앱 개발 회고 #Android

프로젝트를 시작하면서

LifeGame은 일상에서 해야 하는 일을 게임의 퀘스트처럼 다뤄보자는 생각에서 시작한 프로젝트다.

보통 할 일 관리 앱은 사용자가 직접 할 일을 등록하고 완료 여부를 체크하는 방식이다. 기능적으로는 충분하지만 개인적으로는 오래 사용하기 어렵다고 생각했다. 해야 할 일을 등록하는 것 자체가 또 하나의 일이 되기 때문이다.

그래서 접근 방식을 조금 바꿨다.

사용자가 "30분 동안 책 읽기"처럼 해야 할 일을 입력하면 이것을 그대로 보여주는 대신 RPG의 퀘스트처럼 만들어 주는 것이다. 퀘스트 이름을 붙이고, 예상 수행 시간을 정하고, 완료하면 코인을 지급한다. 그렇게 모은 코인은 상점에서 실제 보상으로 사용할 수 있도록 했다.

여기에 Android 홈 화면 위젯과 알람을 붙였다.

결과적으로 목표 입력 -> AI를 통한 퀘스트 생성 -> 퀘스트 수행 -> 보상 획득 -> 상점 이용이라는 하나의 게임 루프를 만드는 것이 이 프로젝트의 핵심 목표였다.


처음부터 AI를 핵심 기능으로 사용했다

LifeGame에서 AI는 단순한 대화 기능으로 사용하지 않았다.

사용자가 입력한 문장을 실제 게임 데이터로 변환하는 역할을 맡겼다.

예를 들어 사용자가 책을 읽겠다고 입력하면 Gemini에 목표 내용과 퀘스트 종류, 반복 여부를 전달하고 다음과 같은 정보를 생성하도록 했다.

  • 퀘스트 이름
  • 예상 수행 시간
  • 보상 코인

처음에는 AI에게 결과를 만들어 달라고만 하면 될 것 같았다. 실제로 구현해 보니 문제가 있었다.

LLM은 일반적인 API처럼 항상 동일한 형식의 결과를 반환하지 않는다. 프롬프트에서 세 줄로 출력하라고 해도 설명을 추가하거나 숫자 앞뒤에 문장을 붙이는 경우가 있었다.

그래서 응답 형식을 최대한 제한하고, 클라이언트에서도 그대로 신뢰하지 않도록 파싱 로직을 추가했다.

정규식을 이용해 숫자 값을 추출하고, 퀘스트 이름을 별도로 가져오는 식으로 여러 단계의 파싱 처리를 넣었다. 일정 시간 안에 응답이 오지 않는 경우를 처리하기 위해 withTimeout(15_000)도 적용했다.

이 과정에서 LLM을 일반적인 데이터베이스나 REST API와 동일하게 취급하면 안 된다는 것을 확인했다.

AI의 출력은 데이터가 아니라 입력값에 가깝다.

따라서 실제 도메인 로직에 사용하려면 검증과 보정 과정이 필요하다.


Android에서 실제 시간을 다루는 일이 생각보다 까다로웠다

LifeGame의 퀘스트는 단순히 완료 여부만 기록하는 구조가 아니다.

퀘스트를 시작하면 일정 시간이 지나고, 시간이 끝났을 때 사용자에게 알려줘야 했다.

처음부터 단순한 Coroutine delay()로 처리하는 방법은 적절하지 않다고 판단했다. 앱 프로세스가 종료되거나 백그라운드 상태가 되면 코루틴의 실행을 보장하기 어렵기 때문이다.

그래서 시스템 수준의 알람 기능을 사용했다.

AlarmManager를 이용해 퀘스트 종료 시각에 알람을 등록하고, 필요한 경우 setExactAndAllowWhileIdle()을 사용해 Doze 상태에서도 예약된 시각에 최대한 가깝게 알람이 발생하도록 구성했다.

알람이 발생하면 BroadcastReceiver가 이를 받고 AlarmActivity를 실행한다.

잠금 화면에서도 알람을 보여줄 필요가 있었기 때문에 Android 버전에 따라 setShowWhenLocked(), setTurnScreenOn() 등의 처리를 분기했다. 화면이 꺼져 있는 상황에서는 WakeLock을 사용해 화면을 깨우는 과정도 추가했다.

이 부분을 구현하면서 Android에서 백그라운드 실행은 단순히 코드를 실행시키는 문제가 아니라는 것을 다시 확인했다.

OS 버전, 권한, Doze, 백그라운드 제한, 잠금 화면 상태 등을 같이 고려해야 했다.


Android 권한과 버전 차이도 직접 처리했다

프로젝트의 target SDK를 최신 Android 환경에 맞추면서 이전 Android에서 당연하게 동작하던 기능들이 별도의 권한이나 정책 처리를 요구하는 것을 확인했다.

특히 정확한 알람과 알림, 잠금 화면에서의 전체 화면 알림을 구현하면서 Android 버전에 따른 처리가 필요했다.

프로젝트에서는 PermissionUtils를 별도로 만들어 관련 권한 확인과 사용자 설정 유도 로직을 한 곳에서 관리했다.

이 과정에서 알게 된 것은 Android 개발에서는 기능 구현 자체보다 OS가 해당 기능을 어떤 조건에서 허용하는지를 확인하는 일이 중요하다는 점이었다.

같은 코드라도 Android 버전에 따라 동작 조건이 달라질 수 있기 때문이다.


위젯은 Glance를 사용했다

LifeGame에서 위젯은 단순한 부가 기능이 아니었다.

사용자가 앱을 직접 실행하지 않아도 현재 퀘스트나 코인 상태를 확인할 수 있도록 만드는 것이 목적이었다.

기존 RemoteViews 방식 대신 AndroidX Glance를 사용했다.

Glance를 선택한 이유는 앱의 메인 UI에서 사용하던 Compose 기반 개발 방식과 어느 정도 비슷한 형태로 위젯을 구성할 수 있었기 때문이다.

문제는 위젯 상태 관리였다.

위젯은 일반적인 Compose 화면처럼 ViewModel의 상태를 그대로 바라보고 있지 않다. 별도의 위젯 생명주기와 상태 저장 방식이 필요했다.

그래서 QuestTrackerWidgetStateDefinition과 상태 직렬화 객체를 직접 구현하고 kotlinx.serialization을 이용해 위젯 상태를 저장했다.

이 작업을 하면서 화면 UI와 위젯 UI는 겉보기와 달리 생명주기가 상당히 다르다는 것을 확인했다.

앱 화면의 상태 관리와 시스템 위젯의 상태 관리를 동일하게 생각하면 구조가 금방 꼬인다.


처음에는 위젯을 주기적으로 갱신했다

위젯에 남은 시간을 표시하기 위해 처음에는 포그라운드 서비스에서 일정 주기로 위젯을 갱신하는 방식을 사용했다.

대략적인 구조는 다음과 같았다.

ForegroundService -> 일정 시간 대기 -> Widget Update -> 다시 대기

구현 자체는 단순했고 원하는 결과도 바로 확인할 수 있었다.

하지만 계속 실행되는 서비스를 사용하면서 구조적인 문제가 보이기 시작했다.

퀘스트 상태가 변하지 않았는데도 계속 업데이트를 수행하게 된다. 위젯이 설치되어 있지 않은 상황에서도 서비스가 실행될 가능성도 고려해야 했다.

무엇보다 Android에서 상시 실행되는 백그라운드 작업은 그 자체로 비용이 있다.

결국 이 부분은 주기적인 폴링보다 상태가 변경되는 순간 위젯을 갱신하는 이벤트 기반 구조가 더 적합하다는 결론을 내렸다.

현재 구현에서 가장 아쉬운 부분 중 하나다.

다시 만든다면 퀘스트 생성, 완료, 실패, 코인 변경과 같은 상태 변경 지점에서 위젯 업데이트를 직접 발생시키는 방향으로 설계할 것이다.


게임 시스템은 단순하게 만들었다

LifeGame의 게임 시스템 자체는 복잡하게 만들지 않았다.

핵심은 코인 하나였다.

퀘스트를 완료하면 코인을 얻고, 얻은 코인으로 상점의 아이템을 구매한다.

상점은 크게 일일 상점과 특별 상점으로 구분했다.

일일 상점은 하루가 지나면 구매 상태를 초기화하고, 특별 상점은 한 번 구매하면 다시 나타나지 않는 방식으로 구현했다.

이 구조를 만들면서 별도의 복잡한 경제 시스템을 추가하기보다는 사용자가 반복적으로 사용할 수 있는 최소한의 보상 루프를 만드는 데 집중했다.

목표 -> 수행 -> 보상 -> 소비

이 네 단계가 제대로 연결되면 나머지 시스템은 그 위에 추가할 수 있다고 판단했다.


일일 리셋은 날짜 기준으로 처리했다

반복 퀘스트와 일일 상점이 존재하기 때문에 날짜가 변경됐을 때 상태를 초기화해야 했다.

별도의 서버 스케줄러를 두기보다는 앱이 실행되는 시점에 마지막 리셋 날짜와 현재 날짜를 비교하는 방식으로 구현했다.

DataStore에 마지막 리셋 날짜를 저장하고 현재 날짜와 비교한다.

날짜가 변경됐다면 다음 작업을 수행한다.

  • 오늘 획득한 코인 초기화
  • 반복 퀘스트 상태 초기화
  • 일일 상점 구매 상태 초기화
  • 마지막 리셋 날짜 갱신

구조 자체는 단순하지만 날짜를 기준으로 상태가 변하는 시스템에서는 시간대와 날짜 경계에 대한 고려가 필요하다.

특히 서버와 클라이언트가 서로 다른 시간 기준을 사용하게 되면 문제가 생길 수 있기 때문에, 서비스 규모가 커진다면 날짜 계산의 기준을 명확하게 정의할 필요가 있다.


데이터 저장에는 Firestore와 DataStore를 사용했다

사용자 게임 데이터는 Firebase Firestore에 저장했고, 앱의 설정성 데이터는 DataStore를 사용했다.

Firestore는 퀘스트나 코인처럼 사용자 계정과 연결되는 데이터를 저장하기에 편했고, DataStore는 마지막 리셋 날짜나 로컬 설정처럼 비교적 작은 데이터를 저장하는 용도로 사용했다.

다만 현재 구조는 Firestore 데이터를 필요할 때 가져오는 방식에 가깝다.

앱의 규모가 커지거나 네트워크 연결이 불안정한 상황까지 고려한다면 이 구조는 한계가 있다.

현재 프로젝트에서는 오프라인 상태에서도 모든 기능이 자연스럽게 동작하도록 만드는 것까지는 구현하지 않았다.

다시 설계한다면 로컬 데이터를 먼저 사용하고 서버와 동기화하는 Offline-First 구조를 검토할 것이다.

다만 이것은 현재 프로젝트에서 구현한 기능이라기보다는 다음 단계의 개선 방향에 가깝다.


ViewModel과 Repository 구조를 사용했다

화면에서는 LifeGameViewModel이 상태와 이벤트를 관리하고, 실제 데이터 접근은 Repository 계층으로 분리했다.

대략적으로 다음과 같은 구조다.

Compose UI -> ViewModel -> Repository -> Firestore / Gemini / DataStore

비동기 작업은 Kotlin Coroutines를 사용했고 UI 상태는 StateFlow를 통해 전달했다.

이 구조 덕분에 UI 코드에서 Firestore나 Gemini API 호출을 직접 처리하지 않아도 됐다.

반면 Repository 객체를 직접 생성하는 부분은 아쉬움이 남았다.

Hilt나 Koin 같은 DI 프레임워크를 적용하지 않았기 때문에 여러 진입점에서 Repository를 직접 생성하는 코드가 발생했다.

작은 프로젝트에서는 크게 문제가 되지 않았지만 테스트와 유지보수를 생각하면 의존성 관리가 분산되어 있다는 문제가 명확했다.

다음 버전에서는 Hilt를 도입해 Repository와 DataStore 등의 의존성을 중앙에서 관리하는 편이 낫다고 판단했다.


개발하면서 보안 문제도 확인했다

개발 과정에서 가장 명확하게 잘못된 부분도 하나 있었다.

Gemini API Key를 클라이언트 코드에서 직접 사용했다.

프로토타입을 빠르게 만들기에는 편하지만 APK는 사용자의 기기에 설치되는 프로그램이다. 따라서 클라이언트에 포함된 키를 비밀로 취급하는 것은 의미가 없다.

난독화나 BuildConfig, local.properties를 사용하는 것은 소스 저장소에서 키가 노출되는 것을 막는 데는 도움이 되지만, 배포된 APK에서 키 자체를 완전히 숨겨주는 방법은 아니다.

이 프로젝트를 실제 서비스로 운영한다면 AI 호출을 서버 측으로 이동시키는 것이 더 적절하다.

클라이언트는 사용자의 목표만 서버에 전달하고, 서버가 Gemini API를 호출한 뒤 검증된 결과를 클라이언트에 반환하는 구조다.

Android Client -> Backend -> Gemini API

이렇게 하면 API Key를 클라이언트에 포함하지 않을 수 있고, 호출량 제한이나 사용자별 사용량 관리도 서버에서 처리할 수 있다.

프로토타입과 서비스 아키텍처에서 보안 요구사항이 다르다는 것을 확인한 부분이다.


프로젝트에서 가장 크게 얻은 것은 Android 시스템과의 경계였다

처음에는 LifeGame을 단순한 CRUD 앱 정도로 생각했다.

하지만 실제로 구현해 보니 앱 내부의 화면만 만드는 프로젝트는 아니었다.

AI API와 통신해야 했고, Firestore에 데이터를 저장해야 했으며, Android 시스템 알람과 백그라운드 실행을 사용해야 했다. 홈 화면 위젯도 별도의 생명주기를 가지고 있었다.

특히 다음과 같은 영역이 서로 얽혀 있었다.

Compose UI -> ViewModel -> Coroutine -> Repository -> Firestore / Gemini

그리고 별도의 시스템 영역에서는

AlarmManager -> BroadcastReceiver -> AlarmActivity

Glance Widget -> Widget State

같은 흐름이 존재했다.

각각은 개별적으로 구현할 수 있지만 실제 문제는 이들이 만나는 지점에서 발생했다.

네트워크가 늦을 수 있고, 앱 프로세스가 죽을 수 있고, 화면이 꺼져 있을 수 있고, 사용자가 권한을 허용하지 않을 수도 있다.

결국 모바일 애플리케이션은 단순히 화면을 만드는 작업이 아니라 앱 코드와 운영체제 사이의 상태를 관리하는 작업에 가깝다는 것을 체감했다.


아쉬웠던 부분

프로젝트 자체는 목표했던 핵심 기능을 구현했지만 완성도 측면에서는 부족한 부분이 있다.

가장 먼저 DI 구조가 없다.

Repository와 각종 의존성을 직접 생성하는 부분이 남아 있어 테스트하기 좋은 구조라고 보기는 어렵다.

두 번째는 데이터 동기화다.

현재는 Firestore를 적극적인 실시간 동기화 계층으로 사용하지 않았기 때문에 다른 환경에서 데이터가 변경됐을 때 즉각적으로 상태를 반영하는 구조는 아니다.

세 번째는 위젯 업데이트 방식이다.

초기 구현에서 주기적인 업데이트 서비스를 사용했는데, 결과적으로 상태 변화가 없는 상황에서도 작업이 계속 발생한다. 이 부분은 이벤트 기반으로 바꾸는 것이 맞다.

네 번째는 화면 네비게이션이다.

초기 구현에서는 화면 상태를 직접 관리하는 방식으로 빠르게 구성했기 때문에 프로젝트가 커졌을 때 Navigation Compose가 제공하는 백스택, Deep Link 등의 기능을 활용하기 어렵다.

그리고 일부 화면은 기능을 충분히 확장하지 못한 채 기본적인 형태로 남았다.


마무리

LifeGame은 거대한 서비스는 아니었다.

하지만 하나의 작은 아이디어를 실제 Android 애플리케이션으로 만들면서 AI, 클라우드 데이터베이스, Compose, 위젯, 시스템 알람, 백그라운드 작업을 한 프로젝트 안에서 직접 연결해 볼 수 있었다는 점에서 의미가 있었다.

특히 AI를 단순히 채팅 기능으로 붙이는 것과 실제 애플리케이션의 도메인 로직에 연결하는 것은 차이가 컸다.

LLM의 출력은 항상 신뢰할 수 있는 데이터가 아니기 때문에 검증과 파싱이 필요했고, Android의 백그라운드 작업 역시 단순한 코드 실행만으로 해결되지 않았다.

처음에는 기능을 만드는 데 집중했다.

개발이 진행될수록 중요한 것은 기능 자체보다 각 시스템의 경계를 어떻게 정의하고 연결하느냐라는 쪽으로 생각이 바뀌었다.

LifeGame을 다시 개발한다면 기능을 더 추가하기보다 현재 구조에서 드러난 문제부터 해결할 것이다.

AI 호출의 서버 분리, 이벤트 기반 위젯 갱신, DI 적용, 로컬 데이터 계층 추가.

이 네 가지를 정리한 뒤에야 새로운 기능을 추가하는 것이 맞다고 본다.

결국 이 프로젝트에서 가장 큰 결과물은 완성된 앱 하나가 아니라, 작동하는 프로토타입과 유지 가능한 서비스 구조 사이에 어떤 차이가 있는지 직접 확인했다는 것이었다.