AppOrbit
← blog

Flutter 앱을 통째로 갈아엎다 — NeonBoard 2.0 아키텍처 설계기 (1/5)

Flutter, Riverpod, Hive, GoRouter, 아키텍처, NeonBoard

1. 들어가며: 1.0의 한계, 그리고 "갈아엎자"는 결심

NeonBoard는 스마트폰을 LED 전광판으로 바꿔주는 앱입니다. 콘서트장에서 응원 문구를 띄우거나, 소규모 매장에서 안내 문구를 보여주는 용도로 시작한 프로젝트였습니다. 1.0 버전은 정말 단순했습니다. 화면 하나, 설정 리스트 하나, 그리고 외부 패키지에 의존하는 마키(Marquee) 스크롤이 전부였습니다.

문제는 사용자 피드백이 쌓이면서 시작됐습니다. "문구를 여러 개 저장하고 싶어요", "폰트를 바꾸고 싶어요", "글자가 네온처럼 빛나면 좋겠어요" 같은 요청이 계속 들어왔습니다. 그런데 1.0의 구조로는 이런 기능을 덧붙이는 것 자체가 불가능에 가까웠습니다. 상태 관리라고 할 것도 없이 setState로 모든 것을 처리하고 있었고, 데이터 영속화 로직은 아예 존재하지 않았습니다. 앱을 껐다 켜면 모든 설정이 초기화되는 구조였습니다.

결국 기능을 하나씩 추가하는 접근이 아니라, 설계부터 다시 하자는 결론에 도달했습니다. 이 글은 NeonBoard 2.0을 처음부터 다시 설계하면서 내린 아키텍처 결정들을 정리한 기록입니다. 시리즈 5편 중 첫 번째 글로, 전체적인 구조와 기술 스택 선정 과정을 다룹니다.


2. 2.0에서 달라진 것들: 핵심 기능과 UX

2.0의 방향은 명확했습니다. "하나의 전광판"에서 "나만의 전광판 컬렉션"으로 전환하는 것이었습니다.

구체적으로 달라진 점을 정리하면 이렇습니다.

멀티보드 시스템: 전광판을 여러 개 만들어 저장하고, 홈 화면 그리드에서 한눈에 관리할 수 있게 됐습니다. 각 보드 카드에는 실제 설정이 반영된 미니 프리뷰가 표시됩니다.

4탭 에디터: 텍스트, 디자인, 모션, 화면 설정을 탭으로 분리했습니다. 에디터 상단에는 실시간 프리뷰가 항상 보이기 때문에, 설정을 바꾸는 즉시 결과를 확인할 수 있습니다.

네온 렌더링 엔진: 외부 마키 패키지를 걷어내고, 4방향 스크롤, 네온 글로우, 테두리 발광, 블링크/페이드 애니메이션을 모두 자체 엔진으로 구현했습니다. 이 부분은 시리즈 2편에서 자세히 다룹니다.

Undo/Redo와 자동 저장: 에디터에서 최대 20단계까지 되돌리기를 지원하고, 30초 간격으로 자동 저장이 이루어집니다. 시리즈 3편의 주제입니다.

다국어 지원과 프리미엄 콘텐츠: 영어/한국어 앱 내 전환을 지원하고, 보상형 광고를 통해 프리미엄 폰트와 이펙트를 해금하는 구조를 도입했습니다.


3. 기술 스택 선정 이유

상태 관리: Riverpod

1.0에서는 setState가 유일한 상태 관리 도구였습니다. 화면이 하나뿐이었으니 그럭저럭 돌아갔지만, 멀티보드 + 에디터 + 디스플레이 + 설정이라는 4개의 화면이 서로의 상태를 참조해야 하는 2.0에서는 근본적으로 다른 접근이 필요했습니다.

Riverpod을 선택한 가장 큰 이유는 Provider 간 의존성 선언이 명시적이라는 점이었습니다. 예를 들어, 보드 목록 Provider가 앱 설정 Provider를 참조해서 새 보드의 기본값을 결정하는 구조를 자연스럽게 표현할 수 있었습니다. ref.watchref.read의 구분도 "이 위젯은 이 상태가 바뀔 때 리빌드돼야 하는가?"라는 질문에 코드 레벨에서 답을 줍니다.

최종적으로 완성된 Provider 계층은 다음과 같습니다.

ProviderScope (루트)
├── appSettingsProvider     → 전역 설정 + 잠금 해제 상태
├── boardListProvider       → 보드 CRUD + 복제 (appSettings 참조)
├── currentBoardProvider    → 현재 편집 중인 보드 + Undo/Redo
├── adProvider              → 보상형 광고 프리로드 + 표시
├── unlockProvider          → 프리미엄 콘텐츠 해금 (ad + appSettings 참조)
└── stringsProvider         → 다국어 문자열 (appSettings.locale 참조)

핵심은 appSettingsProvider가 전체 앱의 허브 역할을 한다는 것입니다. 언어 설정, 해금 상태, 보드 기본값이 모두 여기서 출발합니다. 나머지 Provider들은 필요한 만큼만 이 허브를 watch합니다.

로컬 저장소: Hive

보드 데이터를 영속화하기 위한 선택지로 SharedPreferences, SQLite, Hive를 비교했습니다.

SharedPreferences는 단순 key-value에는 적합하지만, 40개가 넘는 필드를 가진 Board 객체 목록을 저장하기엔 직렬화/역직렬화 관리가 번거로웠습니다. SQLite는 강력하지만, 관계형 데이터가 아닌 단순 오브젝트 저장에는 오버스펙이라고 판단했습니다.

Hive는 Dart 네이티브 바이너리 저장소로, TypeAdapter를 통해 Board 객체를 그대로 읽고 쓸 수 있다는 점이 결정적이었습니다. build_runner로 TypeAdapter를 자동 생성하면, Board 모델의 필드가 추가되더라도 어댑터 코드를 수동으로 관리할 필요가 없었습니다.

저장소 구조는 두 개의 Box로 나뉩니다.

StorageService (싱글턴)
├── boardsBox    → Board 객체 리스트 (typeId: 0)
└── settingsBox  → AppSettings 객체 (typeId: 1)

8개의 Enum도 각각 고유한 typeId(10~17)를 부여받아 Hive에 등록됩니다. ScrollDirection, AnimationType, EdgeThickness 등이 모두 타입 안전하게 저장되고 복원됩니다.

라우팅: GoRouter + ShellRoute

GoRouter를 선택한 이유는 단순히 "선언적 라우팅"이 필요해서가 아닙니다. ShellRoute 때문이었습니다.

NeonBoard의 화면 구성에는 한 가지 특수한 요구사항이 있었습니다. 홈, 에디터, 설정 화면에는 하단에 배너 광고가 표시되어야 하지만, 디스플레이 화면(전광판 모드)만은 광고 없이 완전한 풀스크린이어야 한다는 것이었습니다.

ShellRoute는 이 요구사항에 딱 맞는 솔루션이었습니다.

GoRouter
├── ShellRoute (배너 광고 포함)
│   ├── /               → 홈 (보드 그리드)
│   ├── /editor/new     → 새 보드 에디터
│   ├── /editor/:id     → 기존 보드 에디터
│   └── /settings       → 앱 설정
│
└── /display/:id        → 디스플레이 (ShellRoute 바깥 = 광고 없음)

ShellRoute 안에 있는 화면들은 공통 레이아웃(배너 광고 영역)을 공유합니다. 디스플레이 페이지는 ShellRoute 바깥에 선언되어 있으므로, 별도의 조건 분기 없이 자연스럽게 광고가 제외됩니다. 이 구조 덕분에 각 화면의 build 메서드에 "지금 광고를 보여줘야 하나?"라는 로직이 단 한 줄도 들어가지 않습니다.

화면 전환 애니메이션도 의도적으로 다르게 설정했습니다. 에디터는 아래에서 위로 슬라이드되며, 디스플레이는 페이드 + 스케일 전환으로 "프리뷰가 확대되어 풀스크린이 되는" 느낌을 연출합니다. 사소해 보이지만, 이런 디테일이 앱의 완성도를 좌우한다고 생각합니다.


4. 데이터 모델 설계: 40개 필드의 Board 클래스

2.0 설계에서 가장 많은 시간을 들인 부분은 Board 모델이었습니다. 하나의 전광판이 가질 수 있는 모든 속성을 담아야 했기 때문입니다.

최종 Board 모델은 9개 카테고리, 40개 이상의 필드로 구성됩니다.

| 카테고리 | 주요 필드 | 비고 | |---------|---------|-----| | 메타 | id, name, createdAt, updatedAt | UUID 기반 고유 식별 | | 텍스트 | mainText, subText, textAlignment | 서브 텍스트 on/off 지원 | | 폰트 | fontFamily, fontSize, isAutoTextSize | 자동 크기 조절 모드 | | 색상 | textColor, backgroundColor, gradientEndColor | ARGB 정수로 저장 | | 네온 이펙트 | isNeonGlow, glowIntensity, isTextStroke | 1~5단계 강도 | | 테두리 | isNeonEdge, edgeColor, edgeThickness | Enum으로 두께 관리 | | 스크롤 | scrollDirection, scrollSpeed, scrollGap | 4방향 + 속도 10단계 | | 디스플레이 | isFlipped, orientation | 거울 모드 + 방향 잠금 | | 애니메이션 | animationType, animationSpeed | 6종 (3종은 프리미엄) |

여기서 한 가지 기술적으로 흥미로운 결정이 있었습니다. 색상 값을 Flutter의 Color 객체가 아닌 ARGB 정수(int)로 저장한 것입니다. Hive는 Dart 기본 타입을 네이티브로 지원하기 때문에, int로 저장하면 별도의 TypeAdapter 없이 바로 영속화할 수 있습니다. 화면에서 사용할 때만 Color(board.textColorValue)로 변환하면 됩니다.

copyWith와 _Absent 센티넬 패턴

Board 객체는 불변(immutable)으로 다루고 싶었지만, 40개 필드 중 일부만 바꿔야 하는 상황이 대부분입니다. Dart의 copyWith 패턴을 사용하되, nullable 필드에서 한 가지 문제가 있었습니다.

예를 들어 gradientEndColor는 nullable입니다. 그라데이션을 쓰지 않으면 null이어야 합니다. 그런데 일반적인 copyWith 구현에서는 "값을 전달하지 않은 것"과 "명시적으로 null을 전달한 것"을 구분할 수 없습니다.

// 의사코드로 표현한 문제 상황
board.copyWith(gradientEndColor: null)
// 이것이 "그라데이션을 해제하겠다"인지
// "이 필드는 건드리지 않겠다"인지 구분 불가

이 문제를 해결하기 위해 _Absent 센티넬 클래스를 도입했습니다. 내부적으로만 사용되는 특수 객체를 기본값으로 설정하고, 이 값이 전달되면 "변경 없음", null이 명시적으로 전달되면 "null로 설정"으로 처리합니다.

// 의사코드
class _Absent { const _Absent(); }
const _absent = _Absent();

Board copyWith({
  Object? gradientEndColor = _absent,
  ...
}) {
  return Board(
    gradientEndColor: gradientEndColor == _absent
        ? this.gradientEndColor  // 변경 없음
        : gradientEndColor,       // null 포함 새 값 적용
    ...
  );
}

이 패턴 덕분에 Undo/Redo 스택에서 이전 상태를 복원할 때도 nullable 필드를 정확하게 되돌릴 수 있게 됐습니다. 작은 디테일이지만, 40개 필드를 다루는 데이터 모델에서는 이런 정밀함이 필수적이었습니다.


5. 프로젝트 구조: 역할 기반 디렉토리

최종 프로젝트 구조는 기능(feature)이 아닌 역할(role) 기반으로 나누었습니다.

lib/
├── Components/     → 재사용 위젯 (배너 광고 등)
├── config/         → 광고 설정, 상수 정의
├── data/           → 정적 데이터 (폰트 레지스트리)
├── engine/         → 커스텀 렌더링 엔진 (스크롤, 텍스트, 테두리, 애니메이션)
├── l10n/           → 다국어 문자열
├── models/         → Board, AppSettings, Enum 정의
├── pages/          → 화면별 디렉토리 (home, editor, display, settings)
├── providers/      → Riverpod 상태 관리
├── routes/         → GoRouter 설정
├── services/       → Hive 저장소 서비스
└── theme/          → 테마, 색상, 간격 토큰

특히 engine/ 디렉토리를 별도로 분리한 것은 의도적인 결정이었습니다. 네온 렌더링 로직은 에디터의 실시간 프리뷰와 디스플레이 화면 모두에서 사용됩니다. 특정 화면의 하위 디렉토리에 넣으면 의존 관계가 꼬이기 때문에, 독립적인 "엔진" 레이어로 분리했습니다.


6. 마치며

이번 글에서는 NeonBoard 2.0의 전반적인 아키텍처와 기술 스택 선정 과정을 살펴봤습니다. 핵심을 요약하면 이렇습니다.

  • Riverpod으로 Provider 간 의존성을 명시적으로 관리
  • Hive로 40개 필드의 Board 객체를 타입 안전하게 영속화
  • GoRouter의 ShellRoute로 광고 표시/비표시를 구조적으로 분리
  • _Absent 센티넬 패턴으로 nullable 필드의 copyWith 문제 해결

돌이켜보면, 1.0을 "수선"하는 대신 처음부터 다시 설계하기로 한 판단이 옳았다고 생각합니다. 기존 코드에 기능을 억지로 얹었다면, 지금의 확장성은 얻지 못했을 것입니다.

다음 글에서는 NeonBoard 2.0의 심장이라고 할 수 있는 커스텀 렌더링 엔진 이야기를 다루겠습니다. 외부 마키 패키지를 걷어내고, 4방향 스크롤과 네온 글로우를 직접 구현한 과정이 궁금하시다면 기대해 주세요.