'로또 사고 싶은날' 개발기 3편: 유지보수를 고려한 앱 아키텍처 및 폴더 구조 설계

2026. 4. 14. 07:11AI Product Building/Lotto App

반응형

 

안녕하세요! 직접 기획하고 개발하여 구글 플레이 스토어에 출시한 '로또 사고 싶은날' 1인 개발기, 그 세 번째 시간입니다. 지난 포스팅에서는 플러터(Flutter)와 안드로이드 스튜디오를 활용해 개발 환경을 완벽하게 구축하고 기본적인 라이브러리(pubspec.yaml)를 세팅하는 과정까지 진행했습니다. 자, 텅 빈 lib/main.dart 파일을 열어놓고 바로 UI 디자인 코딩을 시작하고 싶은 마음이 굴뚝같으실 텐데요. 하지만 스파게티 코드(얽히고설켜 수정하기 힘든 코드)의 늪에 빠지지 않으려면, 본격적인 코딩에 앞서 '앱 아키텍처(App Architecture)와 폴더 구조 설계'라는 설계도를 그리는 작업이 반드시 선행되어야 합니다. 오늘은 제가 어떤 기준으로 디렉토리 구조를 나누었으며, 상태 관리는 어떻게 설계했는지 그 고민의 흔적들을 자세히 공유해 보겠습니다.

👉 '로또 사고 싶은날' 구글스토어 다운로드 바로가기 👈


1. 왜 화면부터 그리지 않고 아키텍처와 폴더 구조부터 고민해야 할까?

처음 플러터를 비롯한 모바일 앱 개발을 시작할 때 범하기 쉬운 가장 큰 실수는 한두 개의 다트(Dart) 파일 안에 화면(UI) 코드와 비즈니스 로직(데이터 처리 로직), 그리고 서버 통신 코드까지 몽땅 때려 넣는 것입니다. 앱의 크기가 작고 화면이 두세 개일 때는 이런 방식이 개발 속도도 빠르고 편리할 수 있습니다. 하지만 '로또 사고 싶은날'처럼 수백 개의 역대 당첨 내역 데이터를 캐싱하고, 백그라운드 스레드에서 QR 코드를 인식하며, 로컬 DB(Hive)와 끊임없이 소통해야 하는 앱이라면 이야기가 다릅니다.

만약 로직과 UI가 강하게 결합되어 있다면, 훗날 '로또 버튼 디자인만 조금 바꾸고 싶은데' 무심코 코드를 건드렸다가 QR 스캐너가 먹통이 되는 일이 빈번하게 발생합니다. 이러한 불상사를 막기 위해 우리는 '관심사의 분리(Separation of Concerns)' 원칙을 적용해야 합니다. 그림을 그리는 화가(UI)와 물감을 섞는 조수(비즈니스 로직), 그리고 물감을 사 오는 창고 관리인(Data Repository)의 역할을 폴더 단위로 완벽하게 쪼개놓아야 합니다. 이렇게 모듈화가 잘 되어 있는 앱은 버그가 발생했을 때 정확히 어느 계층을 확인해야 할지 직관적으로 알 수 있어, 장기적인 앱 유지보수성과 업데이트 효율성을 비약적으로 높여줍니다.

2. 디렉토리 구조의 설계: 역할 중심(Layer-First)과 기능 중심(Feature-First)의 혼합법

플러터의 lib/ 폴더 내부를 구성하는 방식에는 크게 화면/위젯/모델 등 파일의 성격에 따라 나누는 '타입 페이스'와 인증/홈화면/설정 등 기능에 따라 나누는 '피처 베이스'가 있습니다. 저는 '로또 사고 싶은날'을 개발하며 두 가지의 장점을 섞은 유연한 디렉토리 구조를 설계했습니다. 아래는 제가 구성한 핵심 폴더 구조의 요약입니다.

lib/
 ┣ core/              # 앱 전역에서 공통으로 사용되는 상수, 테마, 에러 처리 클래스
 ┃ ┣ constants.dart     (색상, API URL, 라우팅 이름 등)
 ┃ ┗ theme.dart         (라이트/다크모드 전역 테마 정의)
 ┣ models/            # 데이터 객체 (LottoResult, HistoryItem 등)
 ┣ services/          # 독립적인 백그라운드 서비스 및 고수준 API
 ┃ ┗ notification_service.dart  (추첨일 푸시 알림 로직)
 ┣ repositories/      # 외부 API(동행복권)와 로컬 DB(Hive) 간의 데이터 중재소
 ┣ viewmodels/        # UI와 데이터의 다리 역할을 하는 상태 관리 클래스 (Provider)
 ┗ ui/                # 사용자에게 보여지는 시각적 요소들
   ┣ screens/         # 각 페이지 (HomeScreen, ScanHistoryScreen 등)
   ┗ widgets/         # 재사용 가능한 컴포넌트 
     ┗ animated_lotto_ball.dart (디테일을 살려주는 공 애니메이션)

이 구조의 핵심은 ui/ 폴더 안의 파일들은 결코 외부 API를 직접 호출하거나 DB에 직접 접근하지 못하게 차단했다는 점입니다. 모든 UI는 오직 viewmodels/에게 데이터를 달라고 요청(Request)할 뿐이며, 뷰모델은 repositories/를 통해 데이터를 정제하여 UI에게 넘겨줍니다. 이 규칙을 강제함으로써 코드의 응집도를 높였습니다.

3. 상태 관리(State Management) 패턴의 선택 - 왜 Provider였나?

플러터 개발자 최대의 화두는 단연 '상태 관리(State Management)'입니다. 상태란 앱 안에서 변할 수 있는 모든 데이터(예: 사용자의 다크모드 설정 값, 최근 업데이트된 로또 1등 번호, 스캔 기록 리스트 등)를 의미합니다. 플러터 기본 기능인 setState() 만으로 이 상태들을 관리하면, 데이터를 하위 위젯으로 계속해서 전달해야 하는(Prop Drilling) 지옥을 맛보게 됩니다.

저는 여러 상태 관리 라이브러리(GetX, BLoC, Riverpod 등) 중에서 Provider(프로바이더)를 채택했습니다. BLoC은 초기 진입 장벽과 보일러플레이트 코드가 너무 많아 1인 개발의 속도를 저하시켰고, GetX는 편하지만 플러터 기본 빌드 컨텍스트를 파괴하는 성향이 강해 장기적인 프레임워크 호환성이 걱정되었습니다. 반면 Provider는 모던 플러터 아키텍처의 교과서적인 접근법을 지원하며 직관적이고 안정적입니다. (만약 오늘 다시 시작한다면 Provider의 진화형인 Riverpod을 선택했겠지만, 개발 당시에는 Provider로도 충분한 퍼포먼스가 보장되었습니다.)

Provider를 통해 전역적으로 상태를 공급하기 위해 `main.dart` 최상단에 MultiProvider를 배치했습니다. 이렇게 하면 앱 안의 그 어떤 깊은 화면(위젯)에 있더라도, 필요할 때 단 한 줄의 코드로 로또 담첨 번호 상태나 테마 설정 상태를 즉시 구독(Listen)하고 업데이트할 수 있습니다.

4. 의존성 주입(DI)과 코드로 보는 초기 아키텍처 결합

아키텍처의 백미는 바로 '의존성 주입(Dependency Injection)'입니다. 쉽게 말해 앱이 실행될 때 창고 관리인(Repository)과 조수(ViewModel)를 미리 생성해 두고, 필요한 화면(UI)에 즉시 배급해 주는 세팅입니다. 이렇게 분리하면 테스트 코드를 작성할 때에도 가짜(Mock) 데이터를 쉽게 밀어넣어 테스트할 수 있는 극강의 장점이 생깁니다.

아래는 앞서 설명한 폴더 구조와 상태 관리 패턴이 앱의 심장부인 main.dart에서 어떻게 하나로 결합되어 초기화되는지 보여주는 실제 핵심 소스 코드 형태입니다.

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// 생성한 폴더 구조에서 파일들을 임포트 (예상 경로)
import 'package:my_feel_lotto/repositories/lotto_repository.dart';
import 'package:my_feel_lotto/services/api_service.dart';
import 'package:my_feel_lotto/services/local_db_service.dart';
import 'package:my_feel_lotto/viewmodels/theme_view_model.dart';
import 'package:my_feel_lotto/viewmodels/lotto_view_model.dart';
import 'package:my_feel_lotto/ui/screens/home_screen.dart';
import 'package:my_feel_lotto/core/theme.dart';

void main() async {
  // 플랫폼 채널 등의 초기화를 위해 필수
  WidgetsFlutterBinding.ensureInitialized();
  
  // 1. 서비스 및 레포지토리 초기화 (의존성 주입의 재료 생성)
  final apiService = ApiService();
  final localDbService = LocalDbService();
  await localDbService.initHive(); // Hive DB 인스턴스 준비
  
  final lottoRepository = LottoRepository(
    apiService: apiService, 
    localDbService: localDbService,
  );

  runApp(
    // 2. MultiProvider를 통한 최상단 전역 상태 주입
    MultiProvider(
      providers: [
        // 테마 상태 관리
        ChangeNotifierProvider(create: (_) => ThemeViewModel()),
        // 로또 데이터 상태 관리 (생성한 Repository를 주입)
        ChangeNotifierProvider(create: (_) => LottoViewModel(repository: lottoRepository)),
      ],
      child: const LottoAppRoot(),
    ),
  );
}

class LottoAppRoot extends StatelessWidget {
  const LottoAppRoot({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 테마 뷰모델을 구독하여 다크모드/라이트모드 자동 변경
    final themeViewModel = context.watch<ThemeViewModel>();
    
    return MaterialApp(
      title: '로또 사고 싶은날',
      debugShowCheckedModeBanner: false,
      theme: AppTheme.lightTheme,
      darkTheme: AppTheme.darkTheme,
      themeMode: themeViewModel.currentThemeMode,
      home: const HomeScreen(), // UI 계층의 진입점
    );
  }
}

이렇게 아키텍처와 폴더 구조 세팅을 마치고 나니, 앞으로 어떤 복잡한 기능이 주어지더라도 해당 코드를 어느 폴더의 어느 파일에 넣어야 할지 수학 공식처럼 바로 알 수 있게 되었습니다. 튼튼한 골조를 세웠으니, 이제 이 위에 예쁜 인테리어를 할 차례입니다! 다음 4회차 포스팅에서는 제가 머리를 싸매며 디자인했던 '메인 화면 UI 구성과 바이브 로또만의 시그니처 둥근 공 컴포넌트(animated_lotto_ball) 디자인' 과정으로 넘어가 보겠습니다.

💡 체계적인 아키텍처에서 뿜어져 나오는 빠르고 쾌적한 앱 성능을 직접 확인해보세요!
👉 [로또 사고 싶은날 - 안드로이드 앱 무료 다운로드]

```

반응형