[Growbit 4편] TypeScript strict 런타임 크래시 방어로 완성한 모바일 생존기

웹 프론트엔드 환경에서는 예상치 못한 에러가 발생하더라도 사용자에게 새로고침을 유도하거나, Vercel 등을 통해 몇 분 만에 핫픽스(Hot-fix)를 배포하여 비교적 쉽게 사태를 수습할 수 있습니다.

하지만 Capacitor로 패키징하여 iOS 앱스토어에 올린 모바일 앱에서는 이야기가 완전히 달라집니다. 앱스토어의 까다로운 심사 과정, 사용자의 업데이트 지연, 그리고 모바일 웹뷰 디버깅의 불편함 때문에 사소한 undefined 접근 하나가 하얀 빈 화면(White Screen of Death)을 유발하고, 결국 제품 경험 전체를 망치는 치명적인 결과로 이어집니다.

이번 글에서는 Growbit 습관 앱을 1인 개발로 구축하며, 어떻게 TypeScript strict 런타임 크래시 방어 전략을 모바일 품질 관리의 핵심 도구로 활용했는지 깊이 있게 다루어 보겠습니다. noUncheckedIndexedAccess, exactOptionalPropertyTypes 같은 강력한 옵션들이 실제 습관 데이터 구조에서 어떤 버그를 막아주었는지, 그리고 Next.js 환경에서 데이터 가드를 어떻게 설계했는지 실전 사례를 공유합니다.

1. 모바일 앱 환경에서 타입 안정성이 생명줄인 이유

Growbit은 Next.js로 만든 웹앱을 Capacitor로 감싸 iOS 앱으로 배포하는 하이브리드 아키텍처를 가지고 있습니다. [지난 3편]에서는 모바일 UX와 햅틱 피드백을 통해 겉모습을 다듬었다면, 이번 4편의 주제는 앱의 뼈대인 ‘데이터 안정성’입니다.

웹 프론트엔드 개발을 하다 보면 undefinednull 때문에 발생하는 참조 에러를 흔하게 만납니다. 목록이 아직 로딩되지 않았는데 첫 번째 요소의 프로퍼티에 접근하거나, 객체에 특정 날짜 키(Key)가 없는데 .toLowerCase() 같은 문자열 메서드를 호출하여 화면이 터지는 식입니다.

iOS 앱으로 패키징된 상태에서 이런 크래시가 발생하면, 새로운 빌드를 아카이빙하고, App Store Connect에 업로드한 뒤, 애플의 심사를 거쳐 사용자가 직접 업데이트 버튼을 누르기까지 최소 하루 이상의 시간이 소요됩니다. 1인 개발 환경에서 이 시간은 사용자 이탈로 직결됩니다.

따라서 런타임에서 터질 수 있는 모든 예외 상황을 런타임(Runtime)이 아닌 컴파일 타임(Compile-time)에 잡아내는 극단적인 방어선이 필요했습니다. 이것이 바로 우리가 TypeScript의 엄격한 모드를 단순한 코드 스타일이 아닌, TypeScript strict 런타임 크래시 예방을 위한 생존 도구로 다루어야 하는 이유입니다.

2. 모바일 배포를 위한 tsconfig 핵심 방어선 설정

Growbit 프로젝트에서 모바일 앱의 런타임 안정성을 보장하기 위해 설정한 tsconfig.json의 핵심 옵션들은 다음과 같습니다.

JSON

{
  "compilerOptions": {
    "target": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "forceConsistentCasingInFileNames": true,
    "noImplicitReturns": true
  }
}

각 옵션은 모바일 환경에서 각기 다른 유형의 에러를 사전에 차단하여, 치명적인 TypeScript strict 런타임 크래시를 방어하는 역할을 수행합니다.

  • strict: true: 타입스크립트의 엄격한 타입 검사를 한꺼번에 활성화합니다. 암묵적인 any, nullundefined 처리 등을 방어하여 예상치 못한 타입 추론으로 인한 버그를 줄여줍니다.
  • noUncheckedIndexedAccess: true: 배열이나 객체를 인덱스로 조회할 때, 해당 값이 존재하지 않을 수 있음을 타입(undefined)에 강제로 반영합니다. Growbit처럼 ‘날짜별 기록’을 다루는 앱에서 가장 훌륭한 방패가 됩니다.
  • exactOptionalPropertyTypes: true: 선택적 속성(Optional Property)에 명시적인 undefined를 대입하는 것을 엄격하게 제어합니다. API 응답이나 로컬 저장소 데이터를 다룰 때 데이터의 존재 여부를 더 분명하게 구분하게 해줍니다.
  • noImplicitReturns: true: 함수 내의 모든 코드 분기(if-else 등)에서 반드시 값을 반환하도록 강제하여, 개발자의 실수로 암묵적 undefined가 리턴되는 것을 막아줍니다.

이 설정들을 처음 켜게 되면 프로젝트 전체가 붉은 에러 밑줄로 가득 찰 수 있습니다. 하지만 그 빨간 줄은 모두 “언젠가 모바일 기기에서 사용자의 앱을 꺼지게 만들었을 시한폭탄”을 개발 단계에서 미리 제거할 기회를 주는 셈입니다.

3. 날짜별 습관 기록: undefined를 먼저 인정하는 설계

TypeScript strict 런타임 크래시 방어 흐름도

습관 형성 앱은 본질적으로 날짜(Date) 중심의 데이터 구조를 가집니다. 사용자가 특정 날짜에 어떤 스티커(보상)를 받았는지 기록하는 아래의 구조를 살펴보겠습니다.

TypeScript

interface PriorityTask {
  id: string;
  title: string;
  stickerHistory: Record<string, string>; // 예: { "2026-07-15": "star_sticker" }
}

초기 개발 단계에서는 무의식적으로 다음과 같이 값을 꺼내어 쓰기 쉽습니다.

TypeScript

// ❌ 위험한 접근 방식 (런타임 에러 유발 가능성 높음)
function getTodaySticker(task: PriorityTask, today: string) {
  const stickerId = task.stickerHistory[today];
  // stickerId가 없으면 바로 여기서 Cannot read properties of undefined 에러 발생!
  return stickerId.toUpperCase();
}

문제는 사용자가 오늘 아직 미션을 완료하지 않았거나, 날짜 포맷이 어긋났다면 stickerHistory[today]의 값은 존재하지 않는다는 점입니다.

noUncheckedIndexedAccess 옵션을 켜면 컴파일러는 stickerId를 단순한 string이 아니라 string | undefined로 강제 추론합니다. 이를 해결하려면 반드시 방어 코드를 작성해야만 빌드가 통과됩니다.

TypeScript

// ✅ 안전한 접근 방식 (컴파일러의 요구사항 충족)
function getTodaySticker(task: PriorityTask, today: string) {
  const stickerId = task.stickerHistory[today];

  if (!stickerId) {
    return 'default_sticker'; // 데이터가 비어있는 상태를 자연스럽게 처리
  }

  return stickerId.toUpperCase();
}

이러한 TypeScript strict 런타임 크래시 방어 코드는 작은 변화 같지만 결과는 거대합니다. 앱이 예외 상태를 크래시로 처리하는 대신, 기본 스티커나 빈 상태 UI로 유연하게 흘려보내기 때문입니다. 완벽한 데이터만 들어온다고 맹신하기보다, 비어 있는 데이터를 정상적인 상태의 일부로 인정하는 것이 훨씬 안전합니다.

4. 배열의 첫 번째 요소 접근조차 의심하라

우선순위 목록을 배열로 다루는 메인 대시보드 화면을 예로 들어보겠습니다.

TypeScript

interface HabitPriority {
  id: string;
  title: string;
  completed: boolean;
}

// ❌ 배열이 비어있다면 런타임 크래시 발생
function getMainPriority(priorities: HabitPriority[]) {
  return priorities[0].title;
}

이 코드 역시 사용자가 앱을 처음 설치하여 아직 우선순위를 생성하지 않았다면 priorities[0]undefined가 되어버립니다. noUncheckedIndexedAccess가 켜져 있다면 컴파일러가 이를 허락하지 않습니다.

TypeScript

// ✅ 안전한 배열 접근과 UX 개선
function getMainPriority(priorities: HabitPriority[]) {
  const mainPriority = priorities[0];

  if (!mainPriority) {
    return '🚀 오늘의 새로운 우선순위를 정해보세요!';
  }

  return mainPriority.title;
}

이처럼 강제된 예외 처리는 에러를 막는 수준을 넘어 UX(사용자 경험) 개선으로 직결됩니다. 빈 배열 상태일 때 하얀 에러 화면이 아니라, 사용자의 다음 행동(Call to Action)을 자연스럽게 유도하는 문구를 보여줄 수 있기 때문입니다.

5. API 응답과 로컬 저장소(Storage) 데이터의 불확실성 제어

타입스크립트가 제공하는 타입은 개발자가 작성한 코드 내부에서만 강력할 뿐, 외부에서 들어오는 데이터(API 응답, Capacitor Local Storage, 구버전 앱의 찌꺼기 데이터 등)까지 자동으로 안전하게 보장해주지는 않습니다.

따라서 Growbit에서는 외부 경계에서 들어오는 데이터는 결코 신뢰하지 않고, 반드시 데이터 정규화(Normalization) 과정을 거치도록 아키텍처를 설계했습니다.

TypeScript

interface StoredHabit {
  id: string;
  title: string;
  completedDates: string[];
}

/**
 * ⚠️ 외부 저장소에서 불러온 알 수 없는 데이터를 안전한 내부 도메인 모델로 정규화합니다.
 */
function normalizeHabit(input: Partial<StoredHabit>): StoredHabit {
  return {
    id: typeof input.id === 'string' ? input.id : crypto.randomUUID(),
    title: typeof input.title === 'string' ? input.title : '이름 없는 습관',
    completedDates: Array.isArray(input.completedDates)
      ? input.completedDates.filter((date): date is string => typeof date === 'string')
      : [],
  };
}

이 정규화 로직은 Zod 같은 무거운 라이브러리를 쓰지 않더라도, 예상치 못한 TypeScript strict 런타임 크래시를 유발할 수 있는 외부 데이터의 형태를 단단하게 규격화해 줍니다.

사용자는 비행기 모드(오프라인), 1년 전의 오래된 캐시, 혹은 마이그레이션이 안 된 구버전 데이터를 들고 앱을 실행할 수 있습니다. 내부 모델을 이렇게 단단하게 정제해 두면 어떤 엣지 케이스(Edge Case)에서도 화면이 무너지는 일은 발생하지 않습니다.

6. 트러블슈팅: Strict 모드를 켰을 때 쏟아지는 에러 대처법

기존 프로젝트에 Strict 옵션을 뒤늦게 켜면 수백 개의 타입 에러가 터져 나와 멘탈이 흔들릴 수 있습니다. 이때 모든 것을 한 번에 고치려 하면 금방 지쳐버립니다. 효과적으로 TypeScript strict 런타임 크래시 문제를 해결하는 실전 가이드입니다.

  1. 사용자 핵심 액션부터 수정하라: 가장 먼저 완료 버튼, 저장 로직, 결제 등 사용자의 핵심 행동과 닿아있는 흐름(Critical Path)부터 고쳐나갑니다.
  2. 타입 단언(Type Assertion)을 경계하라: 에러를 지우기 위해 as Habit 이나 as string을 남발하면 Strict 모드를 켠 의미가 퇴색됩니다.
  3. 가드(Guard)와 기본값을 활용하라:TypeScript// ❌ 최악의 회피 (에러를 숨길 뿐 런타임에 터집니다) const title = maybeHabit.title as string; // ✅ 최선의 방어 (타입 좁히기 및 기본값 할당) const title = typeof maybeHabit.title === 'string' && maybeHabit.title.trim() ? maybeHabit.title : '이름 없는 습관';

7. 결론: 1인 개발자에게 Strict는 가장 빠르고 안전한 지름길이다

처음 이 옵션들을 활성화하면 개발 속도가 확연히 느려진 것처럼 느껴질 수 있습니다. 예전에는 대충 넘어가던 코드가 계속 에러를 뿜어내고, 간단한 객체 조회에도 끈질기게 undefined 방어 처리를 요구하기 때문입니다.

하지만 iOS 앱스토어까지 배포해야 하는 제품이라면, 이 약간의 불편함은 엄청나게 값싼 생명 보험과 같습니다.

Growbit 프로젝트에서 구축한 TypeScript strict 런타임 크래시 방어 시스템은 빈 상태(Empty State)를 자연스럽게 설계하게 만들고, 오래된 데이터의 예외 상황을 앱 안에서 안전하게 흡수하도록 이끌어 주었습니다.

모바일 앱에서 완벽한 데이터란 존재하지 않습니다. 좋은 타입 설계란, 사용자가 이상한 상태에 도달하지 않도록 길을 막아주고, 실수로 도달하더라도 앱이 죽지 않고 침착하게 다음 화면을 보여주도록 만드는 힘입니다. 개발 단계의 컴파일 에러 처리는 귀찮지만, 배포 후 발생하는 참담한 TypeScript strict 런타임 크래시 수습에 비하면 수백 배는 빠르고 쾌적한 지름길입니다.

다음 글에서는 코드를 넘어 제품 기획 관점으로 넘어가, 습관 형성 앱에서 사용자를 압박하지 않고 스스로 내일 다시 앱을 켜게 만드는 ‘우선순위와 보상 루프’ 설계 방식에 대해 정리해 보겠습니다.

댓글 남기기