Skip to main content

유지보수 가능한 앱 아키텍처

Views 11약 11분생성형 AI 활용

파일이 많아지는 순간, 진짜 개발이 시작된다

처음 Flutter를 배울 때는 대부분 main.dart 하나로 시작합니다. 화면에 버튼을 놓고, setState()로 숫자를 바꾸고, 카드 몇 개를 예쁘게 배치합니다. 이 단계에서는 앱 구조가 크게 중요하지 않습니다. 파일이 하나뿐이니 어디에 코드를 둘지 고민할 이유도 없습니다.

문제는 앱이 조금만 커져도 바로 찾아옵니다. 로그인 화면이 생기고, 목록 화면이 생기고, 상세 화면이 생깁니다. 서버에서 데이터를 받아오고, 로컬에 저장하고, Firebase 인증을 붙이고, 이미지 업로드까지 추가합니다. 그러다 어느 날 lib 폴더를 열었는데 이런 생각이 듭니다.

"이 코드는 어디에 둬야 하지?"

이 순간이 중요합니다. 실전에서 개발자를 가장 자주 멈추게 하는 것은 어려운 문법이 아니라, 코드의 자리입니다. 같은 기능을 구현할 수는 있지만, 어디에 두느냐에 따라 나중에 고치기 쉬운 앱이 되기도 하고, 손대기 무서운 앱이 되기도 합니다.

앱 아키텍처는 거창한 이론이 아닙니다. 쉽게 말해 "코드의 주소 체계"입니다. 화면 코드는 화면 근처에, 서버 통신 코드는 서버 통신을 담당하는 곳에, 비즈니스 규칙은 UI와 분리된 곳에 두자는 약속입니다. 이 장에서는 Flutter 앱이 커질 때 코드를 어떻게 나누면 좋은지, 입문자가 실전에서 바로 적용할 수 있는 수준으로 정리해 보겠습니다.

유지보수 가능한 앱 아키텍처

아키텍처가 필요한 이유

작은 앱에서는 구조가 없어도 개발이 됩니다. 오히려 처음부터 폴더를 너무 많이 나누면 피곤합니다. 버튼 하나 만들려고 파일 세 개를 오가야 한다면 학습 속도도 느려지고, 코드의 흐름도 잘 보이지 않습니다.

하지만 앱이 커질수록 구조가 없는 코드는 다음과 같은 문제를 만듭니다.

  • 화면 파일 안에 API 호출, 데이터 변환, 상태 변경 코드가 모두 섞인다.

  • 같은 서버 호출 코드가 여러 화면에 반복된다.

  • 데이터 형식이 바뀌면 앱 전체에서 수정할 곳을 찾아다녀야 한다.

  • 테스트를 작성하려고 해도 UI와 로직이 강하게 묶여 있어 분리하기 어렵다.

  • 새 기능을 추가할 때 기존 코드가 어디에 영향을 주는지 예측하기 어렵다.

예를 들어 노트 목록 화면에서 Firestore 데이터를 직접 읽고, JSON을 직접 파싱하고, 정렬 규칙까지 화면 코드 안에서 처리한다고 생각해 봅시다. 처음에는 빠릅니다. 하지만 나중에 Firestore 대신 REST API로 바꾸거나, 오프라인 캐시를 추가하거나, 정렬 규칙을 여러 화면에서 공유해야 한다면 어떻게 될까요? 화면 파일은 금세 모든 일을 떠안은 "만능 파일"이 됩니다.

좋은 아키텍처의 목표는 코드를 멋지게 나누는 것이 아닙니다. 바뀔 가능성이 높은 부분을 잘 격리하는 것입니다. 서버가 바뀌어도 화면은 덜 흔들리고, UI가 바뀌어도 데이터 저장 방식은 덜 흔들리게 만드는 것이 핵심입니다.

layer-first와 feature-first

Flutter 프로젝트를 정리하는 대표적인 방식은 크게 두 가지입니다. 하나는 계층을 먼저 나누는 layer-first 구조이고, 다른 하나는 기능을 먼저 나누는 feature-first 구조입니다.

먼저 layer-first 구조는 역할별로 폴더를 나눕니다.

lib/
  screens/
  widgets/
  models/
  services/
  repositories/
  providers/

처음 보기에는 이해하기 쉽습니다. 화면은 screens, 위젯은 widgets, 모델은 models에 넣으면 됩니다. 입문 단계나 아주 작은 앱에서는 이 방식이 편합니다. 파일의 역할이 눈에 바로 보이기 때문입니다.

하지만 기능이 많아지면 문제가 생깁니다. 예를 들어 로그인, 노트, 프로필, 설정 기능이 있다고 해 봅시다. 그러면 screens 폴더 안에는 로그인 화면, 노트 화면, 프로필 화면, 설정 화면이 모두 섞입니다. providers 폴더에도 여러 기능의 상태 코드가 모입니다. models 폴더에도 온갖 데이터 클래스가 쌓입니다.

결국 특정 기능 하나를 고치려면 프로젝트 곳곳을 돌아다녀야 합니다. 노트 기능을 수정하려면 screens/note_list_screen.dart, models/note.dart, repositories/note_repository.dart, providers/note_provider.dart를 각각 찾아야 합니다.

반대로 feature-first 구조는 기능별로 먼저 폴더를 나눕니다.

lib/
  features/
    auth/
    notes/
    settings/
  shared/
  app/

이 방식에서는 노트와 관련된 코드를 features/notes 안에 모읍니다. 로그인과 관련된 코드는 features/auth 안에 둡니다. 기능 단위로 코드가 모이기 때문에, 특정 기능을 수정할 때 이동 경로가 짧아집니다.

lib/
  features/
    notes/
      presentation/
      application/
      domain/
      data/

이 책에서는 입문자에게 완전한 feature-first도, 완전한 layer-first도 아닌 혼합 방식을 권장합니다. 큰 기준은 기능별로 나누되, 각 기능 안에서는 계층을 적당히 나누는 방식입니다. 즉, "기능 먼저, 그 안에서 역할 나누기"입니다.

입문자에게 권장하는 현실적인 폴더 구조

처음부터 거대한 클린 아키텍처를 만들 필요는 없습니다. 하지만 나중에 앱이 커질 여지를 남겨 두는 구조는 좋습니다. 다음은 이 책의 최종 프로젝트인 개인 지식 노트 앱에도 적용할 수 있는 기본 구조입니다.

lib/
  main.dart
  app/
    app.dart
    router.dart
    theme.dart
  core/
    errors/
    utils/
    constants/
  shared/
    widgets/
    extensions/
  features/
    auth/
      presentation/
      application/
      domain/
      data/
    notes/
      presentation/
      application/
      domain/
      data/
    settings/
      presentation/
      application/
      domain/
      data/

하나씩 살펴보겠습니다.

app 폴더에는 앱 전체를 묶는 코드가 들어갑니다. MaterialApp, 라우터 설정, 테마 설정처럼 특정 기능에 속하지 않고 앱 전체에 영향을 주는 코드입니다. 예를 들어 theme.dart에는 Material 3 기반의 ThemeData를 둘 수 있고, router.dart에는 go_router 설정을 둘 수 있습니다.

core 폴더에는 앱 전체에서 사용하는 낮은 수준의 공통 코드가 들어갑니다. 에러 타입, 날짜 포맷터, 상수, 네트워크 예외 처리 같은 코드입니다. 다만 core는 조심해야 합니다. 이름이 편하다는 이유로 아무 코드나 넣다 보면 금세 잡동사니 창고가 됩니다. "여러 기능에서 진짜로 공유하는가?"라는 질문에 답할 수 있을 때만 core에 넣는 것이 좋습니다.

shared 폴더에는 여러 기능에서 재사용하는 UI나 확장 코드가 들어갑니다. 예를 들어 공통 버튼, 빈 상태 위젯, 로딩 인디케이터, 스낵바 헬퍼 같은 것들입니다. 특정 기능에만 쓰는 위젯이라면 shared가 아니라 해당 기능의 presentation 아래에 두는 편이 낫습니다.

features 폴더에는 앱의 주요 기능이 들어갑니다. 로그인은 auth, 노트 작성과 목록은 notes, 설정은 settings처럼 나눕니다. 기능 하나는 작은 앱 안의 작은 앱처럼 생각하면 됩니다. 화면, 상태, 데이터, 규칙을 그 기능 폴더 안에서 관리합니다.

이 구조의 장점은 명확합니다. 노트 기능을 고치고 싶다면 우선 features/notes로 가면 됩니다. 로그인 기능을 고치고 싶다면 features/auth로 가면 됩니다. 길을 잃을 가능성이 줄어듭니다.

네 개의 계층: presentation, application, domain, data

기능 폴더 안에는 다시 역할별 계층을 둘 수 있습니다. 이름이 조금 딱딱해 보이지만, 실제 의미는 단순합니다.

features/notes/
  presentation/   // 화면과 위젯
  application/    // 상태와 유스케이스 흐름
  domain/         // 핵심 모델과 규칙
  data/           // 외부 데이터 연동

presentation은 사용자가 보는 부분입니다. Flutter의 Widget, 화면 파일, 화면 전용 위젯이 여기에 들어갑니다. 예를 들어 NoteListScreen, NoteEditorScreen, NoteCard 같은 코드입니다. 이 계층의 관심사는 "어떻게 보여줄 것인가?"입니다.

application은 화면과 데이터 사이에서 앱의 흐름을 조정합니다. Riverpod을 사용한다면 Notifier, AsyncNotifier, FutureProvider 같은 상태관리 코드가 여기에 들어갈 수 있습니다. 예를 들어 노트 목록을 불러오고, 새 노트를 추가하고, 삭제 후 목록을 갱신하는 흐름을 담당합니다. 이 계층의 관심사는 "사용자의 행동을 앱의 상태 변화로 어떻게 연결할 것인가?"입니다.

domain은 앱의 핵심 개념을 담습니다. 노트 앱이라면 Note, Tag, UserProfile 같은 엔티티가 들어갑니다. 또한 "제목이 비어 있으면 저장할 수 없다", "휴지통에 있는 노트는 검색 결과에서 제외한다" 같은 규칙도 이 계층에 둘 수 있습니다. 이 계층의 관심사는 "이 앱이 다루는 개념과 규칙은 무엇인가?"입니다.

data는 외부 세계와 연결되는 부분입니다. REST API, Firebase, 로컬 데이터베이스, SharedPreferences 같은 코드가 여기에 들어갑니다. DTO, API 클라이언트, Repository 구현체도 이 계층에 둡니다. 이 계층의 관심사는 "데이터를 어디서 가져오고 어디에 저장할 것인가?"입니다.

이 네 계층은 항상 모두 필요하지 않습니다. 작은 기능이라면 presentation과 data만으로도 충분할 수 있습니다. 하지만 앱이 커질수록 application과 domain을 분리하는 효과가 커집니다.

노트 기능을 기준으로 매핑해 보기

말로만 들으면 추상적이니, 개인 지식 노트 앱의 노트 기능을 기준으로 실제 파일을 배치해 봅시다.

features/notes/
  presentation/
    note_list_screen.dart
    note_editor_screen.dart
    widgets/
      note_card.dart
      empty_notes_view.dart
  application/
    note_list_controller.dart
    note_editor_controller.dart
  domain/
    note.dart
    note_repository.dart
  data/
    note_dto.dart
    note_repository_impl.dart
    remote_note_data_source.dart
    local_note_data_source.dart

note_list_screen.dart는 노트 목록을 보여줍니다. 이 파일은 가능하면 "화면을 그리는 일"에 집중해야 합니다. 서버에서 데이터를 어떻게 가져오는지, Firestore 문서 구조가 어떤지는 몰라도 됩니다.

note_list_controller.dart는 목록 화면의 상태를 관리합니다. 로딩 중인지, 데이터가 있는지, 에러가 났는지 같은 상태를 들고 있습니다. 사용자가 새로고침을 하면 Repository에 데이터를 요청하고, 결과에 따라 상태를 바꿉니다.

note.dart는 앱 내부에서 사용하는 핵심 노트 모델입니다. 화면과 비즈니스 규칙은 이 Note를 기준으로 동작합니다.

note_repository.dart는 Repository의 추상적인 약속입니다. "노트를 가져올 수 있다", "노트를 저장할 수 있다", "노트를 삭제할 수 있다" 같은 기능을 인터페이스로 표현합니다.

note_repository_impl.dart는 실제 구현입니다. 여기서 REST API를 호출하거나, Firebase를 읽거나, 로컬 DB를 조회합니다. 중요한 점은 화면과 상태관리 코드는 구현체의 자세한 내용을 몰라도 된다는 것입니다.

이렇게 나누면 데이터 저장 방식이 바뀌어도 앱의 중심부가 덜 흔들립니다. 오늘은 Firebase를 쓰다가 내일 REST API로 바꾼다고 해도, presentation과 application 계층은 크게 바뀌지 않게 만들 수 있습니다.

의존성 방향을 정하자

계층을 나눌 때 가장 중요한 규칙은 의존성 방향입니다. 쉽게 말해 "누가 누구를 알고 있어도 되는가?"입니다.

권장 방향은 다음과 같습니다.

presentation → application → domain
                      ↓
                    data

조금 더 정확히 말하면, 화면은 상태관리 코드를 사용할 수 있습니다. 상태관리 코드는 Repository를 사용할 수 있습니다. Repository 구현체는 API나 로컬 저장소를 사용할 수 있습니다. 하지만 반대 방향은 피해야 합니다.

예를 들어 domain의 Note가 Flutter의 BuildContext를 알고 있다면 이상합니다. 노트라는 개념은 화면과 상관없이 존재해야 합니다. data 계층의 DTO가 화면 위젯을 import하는 것도 이상합니다. 서버 데이터 형식을 표현하는 클래스가 UI를 알아야 할 이유가 없기 때문입니다.

좋은 기준은 간단합니다. 안쪽 계층일수록 Flutter나 Firebase 같은 구체적인 기술을 몰라도 되게 만드는 것입니다. 특히 domain은 최대한 순수한 Dart 코드로 유지하는 것이 좋습니다. 그래야 테스트하기 쉽고, 외부 기술 변화에도 덜 흔들립니다.

Repository Pattern이 필요한 이유

Repository Pattern은 처음 들으면 거창해 보이지만, 실제 역할은 단순합니다. 데이터를 가져오는 방법을 숨기고, 앱이 필요한 기능만 깔끔하게 제공하는 중간 창구입니다.

예를 들어 화면에서 직접 Firestore를 호출한다고 해 봅시다.

final snapshot = await FirebaseFirestore.instance
    .collection('users')
    .doc(userId)
    .collection('notes')
    .get();

이 코드가 화면 곳곳에 흩어지면 나중에 변경이 어렵습니다. 컬렉션 이름이 바뀌거나, 사용자별 경로가 바뀌거나, 캐싱을 추가해야 할 때 모든 화면을 뒤져야 합니다. 또한 테스트할 때도 실제 Firebase에 의존하게 되어 불편합니다.

Repository를 사용하면 화면은 이렇게 말할 수 있습니다.

final notes = await noteRepository.fetchNotes(userId);

이제 화면은 데이터가 Firestore에서 오는지, REST API에서 오는지, SQLite에서 오는지 몰라도 됩니다. Repository가 그 차이를 감춰 줍니다.

먼저 domain 계층에 Repository의 약속을 정의합니다.

abstract interface class NoteRepository {
  Future<List<Note>> fetchNotes(String userId);
  Future<Note> createNote(Note note);
  Future<void> updateNote(Note note);
  Future<void> deleteNote(String noteId);
}

여기에는 구체적인 기술 이름이 없습니다. Firestore, HTTP, SQLite 같은 단어가 보이지 않습니다. 앱 입장에서 필요한 동작만 표현합니다.

그다음 data 계층에서 실제 구현을 만듭니다.

class NoteRepositoryImpl implements NoteRepository {
  NoteRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
  });

  final RemoteNoteDataSource remoteDataSource;
  final LocalNoteDataSource localDataSource;

  @override
  Future<List<Note>> fetchNotes(String userId) async {
    final dtos = await remoteDataSource.fetchNotes(userId);
    final notes = dtos.map((dto) => dto.toEntity()).toList();

    await localDataSource.saveNotes(notes);
    return notes;
  }

  @override
  Future<Note> createNote(Note note) async {
    final dto = NoteDto.fromEntity(note);
    final createdDto = await remoteDataSource.createNote(dto);
    return createdDto.toEntity();
  }

  @override
  Future<void> updateNote(Note note) async {
    final dto = NoteDto.fromEntity(note);
    await remoteDataSource.updateNote(dto);
  }

  @override
  Future<void> deleteNote(String noteId) async {
    await remoteDataSource.deleteNote(noteId);
  }
}

이 구조에서는 캐싱 전략도 Repository 안에서 조정할 수 있습니다. 서버에서 받아온 데이터를 로컬에 저장할 수도 있고, 네트워크가 실패하면 로컬 데이터를 반환할 수도 있습니다. 화면 입장에서는 여전히 fetchNotes()를 호출할 뿐입니다.

Riverpod과 Repository 연결하기

앞 장에서 Riverpod을 배웠다면, Repository를 Provider로 주입할 수 있습니다. 이렇게 하면 상태관리 코드가 구체적인 구현체를 직접 생성하지 않아도 됩니다.

final noteRepositoryProvider = Provider<NoteRepository>((ref) {
  return NoteRepositoryImpl(
    remoteDataSource: RemoteNoteDataSource(),
    localDataSource: LocalNoteDataSource(),
  );
});

그리고 노트 목록 상태를 관리하는 컨트롤러에서는 Repository를 읽어 사용합니다.

final noteListControllerProvider =
    AsyncNotifierProvider<NoteListController, List<Note>>(
  NoteListController.new,
);

class NoteListController extends AsyncNotifier<List<Note>> {
  @override
  Future<List<Note>> build() async {
    final repository = ref.watch(noteRepositoryProvider);
    return repository.fetchNotes('current-user-id');
  }

  Future<void> refresh() async {
    state = const AsyncLoading();

    state = await AsyncValue.guard(() async {
      final repository = ref.read(noteRepositoryProvider);
      return repository.fetchNotes('current-user-id');
    });
  }
}

화면에서는 컨트롤러의 상태만 바라봅니다.

class NoteListScreen extends ConsumerWidget {
  const NoteListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notesAsync = ref.watch(noteListControllerProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('내 노트')),
      body: notesAsync.when(
        data: (notes) => ListView.builder(
          itemCount: notes.length,
          itemBuilder: (context, index) {
            return NoteCard(note: notes[index]);
          },
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stackTrace) => Center(
          child: Text('노트를 불러오지 못했어요.'),
        ),
      ),
    );
  }
}

이제 각 코드의 역할이 뚜렷해졌습니다. 화면은 UI를 그리고, 컨트롤러는 상태 흐름을 관리하고, Repository는 데이터 접근을 담당합니다. 이것만 지켜도 앱의 복잡도가 훨씬 낮아집니다.

Model, Entity, DTO 구분하기

앱 아키텍처를 공부하다 보면 Model, Entity, DTO라는 단어가 자주 나옵니다. 처음에는 모두 비슷해 보입니다. 전부 데이터를 담는 클래스처럼 보이기 때문입니다. 하지만 역할을 나누면 외부 데이터 변화에 강한 코드를 만들 수 있습니다.

Entity는 앱의 핵심 개념을 표현합니다. 노트 앱이라면 Note가 Entity입니다. 이 클래스는 앱 내부 규칙을 기준으로 설계합니다. 서버 필드 이름이나 Firestore 문서 구조에 휘둘리지 않는 것이 좋습니다.

class Note {
  const Note({
    required this.id,
    required this.title,
    required this.content,
    required this.tags,
    required this.createdAt,
    required this.updatedAt,
  });

  final String id;
  final String title;
  final String content;
  final List<String> tags;
  final DateTime createdAt;
  final DateTime updatedAt;

  bool get isEmpty => title.trim().isEmpty && content.trim().isEmpty;
}

DTO는 Data Transfer Object의 줄임말입니다. 말 그대로 데이터를 주고받기 위한 객체입니다. 서버 JSON, Firestore 문서, 로컬 DB 테이블 구조와 가깝습니다. 외부 데이터 형식을 Dart 객체로 옮긴 것이라고 생각하면 됩니다.

class NoteDto {
  const NoteDto({
    required this.id,
    required this.title,
    required this.body,
    required this.tagNames,
    required this.createdAtMillis,
    required this.updatedAtMillis,
  });

  final String id;
  final String title;
  final String body;
  final List<String> tagNames;
  final int createdAtMillis;
  final int updatedAtMillis;

  factory NoteDto.fromJson(Map<String, dynamic> json) {
    return NoteDto(
      id: json['id'] as String,
      title: json['title'] as String,
      body: json['body'] as String,
      tagNames: List<String>.from(json['tagNames'] as List),
      createdAtMillis: json['createdAt'] as int,
      updatedAtMillis: json['updatedAt'] as int,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'body': body,
      'tagNames': tagNames,
      'createdAt': createdAtMillis,
      'updatedAt': updatedAtMillis,
    };
  }

  Note toEntity() {
    return Note(
      id: id,
      title: title,
      content: body,
      tags: tagNames,
      createdAt: DateTime.fromMillisecondsSinceEpoch(createdAtMillis),
      updatedAt: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
    );
  }

  factory NoteDto.fromEntity(Note note) {
    return NoteDto(
      id: note.id,
      title: note.title,
      body: note.content,
      tagNames: note.tags,
      createdAtMillis: note.createdAt.millisecondsSinceEpoch,
      updatedAtMillis: note.updatedAt.millisecondsSinceEpoch,
    );
  }
}

여기서 눈여겨볼 점은 Note의 필드 이름은 content인데, DTO의 필드 이름은 body라는 것입니다. 실제 서버가 body라는 이름을 쓴다고 해서 앱 전체가 그 이름을 따라갈 필요는 없습니다. Repository 또는 DTO 변환 단계에서 한 번 바꿔 주면 됩니다.

그렇다면 Model은 무엇일까요? Flutter 입문 단계에서는 Model이라는 단어를 넓게 사용해도 됩니다. 데이터를 담는 클래스를 통칭해서 model이라고 부르기도 합니다. 다만 프로젝트가 커질수록 Entity와 DTO를 구분하면 좋습니다. 이 책에서는 앱 내부 핵심 데이터는 Entity, 외부 입출력용 데이터는 DTO로 구분하겠습니다.

왜 굳이 나눠야 할까?

"그냥 JSON에서 바로 Note를 만들면 안 되나요?"라고 물을 수 있습니다. 물론 됩니다. 작은 앱에서는 그게 더 빠르고 단순합니다.

하지만 외부 데이터 형식은 생각보다 자주 바뀝니다. 서버에서 body 필드를 content로 바꿀 수도 있고, 날짜 형식을 timestamp에서 ISO 문자열로 바꿀 수도 있습니다. Firestore를 쓰다가 REST API로 옮길 수도 있습니다. 이때 앱 내부의 Note까지 외부 형식에 강하게 묶여 있다면 수정 범위가 커집니다.

DTO를 따로 두면 변화의 충격을 data 계층 안에 가둘 수 있습니다. 외부 JSON이 바뀌면 NoteDto.fromJson()과 toEntity()를 수정하면 됩니다. 화면, 상태관리, 도메인 규칙은 그대로 둘 수 있습니다.

물론 모든 데이터에 DTO와 Entity를 무조건 따로 만들 필요는 없습니다. 단순한 설정값이나 앱 내부에서만 쓰는 임시 데이터라면 하나의 클래스로 충분합니다. 중요한 것은 "바뀔 가능성이 높은 외부 형식"과 "앱 내부에서 오래 유지하고 싶은 개념"을 구분하는 감각입니다.

화면 코드를 가볍게 유지하기

유지보수하기 좋은 Flutter 앱의 화면 파일은 생각보다 단순합니다. 화면은 상태를 읽고, 상태에 따라 위젯을 보여주며, 사용자의 입력을 컨트롤러에 전달합니다. 그 이상을 너무 많이 하려고 하면 화면이 무거워집니다.

피하고 싶은 화면 코드는 이런 모습입니다.

onPressed: () async {
  final response = await http.post(
    Uri.parse('https://api.example.com/notes'),
    body: jsonEncode({
      'title': titleController.text,
      'body': contentController.text,
    }),
  );

  if (response.statusCode == 200) {
    final json = jsonDecode(response.body);
    setState(() {
      notes.add(Note.fromJson(json));
    });
  } else {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('저장 실패')),
    );
  }
}

이 코드는 버튼 클릭 안에 너무 많은 일이 들어 있습니다. HTTP 요청, JSON 변환, 상태 변경, 에러 표시가 한곳에 섞여 있습니다. 지금은 동작할 수 있지만, 나중에 테스트하거나 수정하기 어렵습니다.

조금 더 나은 화면 코드는 이렇게 생겼습니다.

onPressed: () {
  ref.read(noteEditorControllerProvider.notifier).save(
        title: titleController.text,
        content: contentController.text,
      );
}

이제 화면은 "저장해 줘"라고 요청할 뿐입니다. 실제 저장 과정은 컨트롤러와 Repository가 처리합니다. 화면의 책임이 줄어들면 UI 변경도 쉬워지고, 로직 테스트도 쉬워집니다.

Use Case는 언제 필요할까?

클린 아키텍처를 공부하면 UseCase라는 개념도 자주 등장합니다. Use Case는 사용자의 의도를 하나의 작업 단위로 표현한 것입니다. 예를 들어 CreateNoteUseCase, SearchNotesUseCase, SignInUseCase 같은 클래스입니다.

class CreateNoteUseCase {
  CreateNoteUseCase(this.repository);

  final NoteRepository repository;

  Future<Note> call({
    required String title,
    required String content,
    required List<String> tags,
  }) async {
    if (title.trim().isEmpty) {
      throw ArgumentError('제목은 비워 둘 수 없습니다.');
    }

    final now = DateTime.now();

    final note = Note(
      id: '',
      title: title.trim(),
      content: content.trim(),
      tags: tags,
      createdAt: now,
      updatedAt: now,
    );

    return repository.createNote(note);
  }
}

Use Case를 두면 복잡한 비즈니스 규칙을 상태관리 코드 밖으로 뺄 수 있습니다. 여러 컨트롤러에서 같은 작업을 공유할 때도 유용합니다.

하지만 모든 작업마다 Use Case 클래스를 만들 필요는 없습니다. 단순히 Repository 메서드 하나를 호출하는 정도라면 컨트롤러에서 바로 호출해도 괜찮습니다. 예를 들어 fetchNotes()를 부르는 것뿐이라면 FetchNotesUseCase를 따로 만드는 것이 오히려 부담일 수 있습니다.

현실적인 기준은 이렇습니다. 작업 안에 검증, 여러 Repository 조합, 권한 확인, 로깅, 캐시 갱신처럼 의미 있는 규칙이 들어간다면 Use Case를 고려합니다. 반대로 단순 전달이라면 생략합니다. 좋은 구조는 파일 수가 많은 구조가 아니라, 복잡도를 적절한 위치로 옮겨 놓은 구조입니다.

작은 앱에 과한 클린 아키텍처를 적용하지 않기

아키텍처를 배우다 보면 모든 코드를 완벽하게 나누고 싶어집니다. domain, data, presentation, application, usecases, mappers, datasources, repositories를 전부 만들고 싶어집니다. 하지만 작은 앱에서 과한 구조는 오히려 생산성을 떨어뜨립니다.

예를 들어 설정 화면에 다크 모드 여부를 저장하는 기능이 있다고 해 봅시다. 이 기능을 위해 SettingsEntity, SettingsDto, SettingsRepository, SettingsRepositoryImpl, GetSettingsUseCase, UpdateSettingsUseCase, SettingsController를 모두 만드는 것은 과할 수 있습니다. 단순한 값 하나를 저장하기 위해 파일이 너무 많아집니다.

작은 기능은 작게 시작하는 것이 좋습니다.

features/settings/
  presentation/
    settings_screen.dart
  application/
    settings_controller.dart

나중에 설정 항목이 많아지고, 서버 동기화가 필요해지고, 여러 화면에서 설정을 공유해야 한다면 그때 data와 domain을 분리해도 늦지 않습니다.

아키텍처는 처음부터 완성하는 것이 아니라, 앱의 성장에 맞춰 진화시키는 것입니다. 중요한 것은 "나중에 나눌 수 있는 방향"으로 코드를 쓰는 습관입니다. 화면 안에 모든 것을 섞어 두지만 않으면, 리팩터링은 훨씬 쉬워집니다.

언제 계층을 나눌까?

계층 분리가 필요한지 판단하기 어렵다면 다음 기준을 사용해 보세요.

  • 같은 코드가 두 번 이상 반복되기 시작했다.

  • 화면 파일이 300줄을 넘어가며 UI와 로직이 뒤섞인다.

  • 서버, 로컬 저장소, Firebase 같은 외부 의존성이 화면 코드에 직접 등장한다.

  • 테스트하고 싶은 로직이 있는데 위젯과 강하게 묶여 있다.

  • 데이터 형식 변경이 여러 화면 수정으로 이어진다.

  • 한 기능을 수정하기 위해 프로젝트 여러 폴더를 계속 오가야 한다.

이 중 두세 가지 이상에 해당한다면 구조를 나눌 때가 된 것입니다. 처음부터 거창한 설계를 할 필요는 없지만, 통증이 생겼는데도 구조를 미루면 나중에 더 큰 비용을 치르게 됩니다.

기능 단위로 성장시키는 예시

처음 노트 기능을 만들 때는 이렇게 단순하게 시작할 수 있습니다.

features/notes/
  note_list_screen.dart
  note_editor_screen.dart
  note.dart
  note_repository.dart

이 정도면 작은 프로토타입에는 충분합니다. 하지만 노트 기능이 커지면 파일을 역할별로 나눕니다.

features/notes/
  presentation/
    note_list_screen.dart
    note_editor_screen.dart
    widgets/
      note_card.dart
  application/
    note_list_controller.dart
    note_editor_controller.dart
  domain/
    note.dart
    note_repository.dart
  data/
    note_repository_impl.dart
    note_dto.dart

여기에 오프라인 캐시가 추가되면 data 계층을 더 세분화할 수 있습니다.

features/notes/
  data/
    note_repository_impl.dart
    note_dto.dart
    remote_note_data_source.dart
    local_note_data_source.dart

검색 기능이 복잡해지면 application 또는 domain에 검색 조건 클래스를 둘 수도 있습니다.

features/notes/
  domain/
    note.dart
    note_repository.dart
    note_search_filter.dart

이처럼 구조는 한 번에 크게 만드는 것이 아니라, 필요가 생길 때 확장하는 편이 좋습니다. 처음부터 모든 방을 지어 놓는 것이 아니라, 집이 커질 방향으로 기초를 잡아 두는 것입니다.

공통 위젯은 언제 shared로 옮길까?

Flutter 앱을 만들다 보면 공통 위젯을 만들고 싶어집니다. PrimaryButton, EmptyStateView, AppTextField 같은 위젯입니다. 공통화는 좋지만 너무 빠른 공통화는 위험합니다.

예를 들어 노트 목록에서만 쓰는 NoteCard를 shared/widgets에 넣는 것은 좋지 않습니다. 이름부터 노트 기능에 강하게 묶여 있기 때문입니다. 이런 위젯은 features/notes/presentation/widgets에 두는 편이 자연스럽습니다.

반대로 여러 기능에서 같은 빈 상태 UI를 사용한다면 shared/widgets/empty_state_view.dart로 옮길 수 있습니다.

class EmptyStateView extends StatelessWidget {
  const EmptyStateView({
    super.key,
    required this.message,
    this.icon,
  });

  final String message;
  final IconData? icon;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (icon != null) Icon(icon, size: 48),
          const SizedBox(height: 12),
          Text(message),
        ],
      ),
    );
  }
}

공통화의 기준은 "한 번 더 쓸 것 같은가?"가 아니라 "실제로 여러 곳에서 같은 의미로 쓰이는가?"입니다. 미래를 예측해 미리 공통화하기보다, 반복이 확인된 뒤 옮기는 편이 안전합니다.

core 폴더를 쓰는 법

core 폴더는 편리하지만 위험합니다. 프로젝트가 커질수록 개발자는 애매한 코드를 core에 넣고 싶어집니다. 그러다 보면 core는 모든 것이 들어 있는 서랍이 됩니다. 찾기도 어렵고, 지우기도 어렵고, 의존성도 복잡해집니다.

core에는 앱 전체에서 공유하는 아주 기초적인 코드만 두는 것이 좋습니다.

core/
  constants/
    app_constants.dart
  errors/
    app_exception.dart
    failure.dart
  utils/
    date_formatter.dart

반대로 특정 기능과 관련된 코드는 되도록 해당 feature 안에 둡니다. 노트 검색에만 쓰는 날짜 필터라면 core/utils가 아니라 features/notes/domain 또는 features/notes/application이 더 어울립니다.

좋은 질문은 이것입니다.

"이 코드를 노트 기능이 사라져도 계속 사용할까?"

그렇다면 core나 shared로 갈 수 있습니다. 아니라면 해당 feature 안에 두는 편이 좋습니다.

import 규칙 정하기

폴더 구조만큼 중요한 것이 import 규칙입니다. 아무 계층에서나 아무 계층을 import하면 구조는 금방 무너집니다. 다음과 같은 규칙을 정해 두면 좋습니다.

  • presentation은 application과 domain을 사용할 수 있다.

  • application은 domain을 사용할 수 있고, Repository 추상화를 통해 데이터에 접근한다.

  • domain은 가능하면 다른 계층을 모른다.

  • data는 domain의 Entity와 Repository 인터페이스를 구현할 수 있다.

  • feature 간 직접 참조는 최소화하고, 필요하면 상위 흐름이나 shared 모델을 검토한다.

특히 feature 간 직접 참조는 조심해야 합니다. 예를 들어 notes 기능이 auth 기능의 내부 컨트롤러를 직접 import하면 두 기능이 강하게 묶입니다. 노트 기능에서 현재 사용자 ID가 필요하다면, auth의 세부 구현을 직접 가져오기보다 현재 사용자 정보를 제공하는 Provider나 앱 공통 인증 상태를 통해 접근하는 편이 좋습니다.

final currentUserProvider = Provider<User?>((ref) {
  // 인증 상태를 읽어 현재 사용자를 반환한다고 가정합니다.
  return null;
});

이렇게 하면 노트 기능은 "현재 사용자"라는 개념만 알면 되고, 로그인 방식이 이메일인지 구글 로그인인지, Firebase Auth인지 직접 알 필요가 줄어듭니다.

에러도 구조의 일부다

앱 아키텍처를 이야기할 때 에러 처리를 빼놓기 쉽습니다. 하지만 유지보수 가능한 앱에서는 에러도 제자리가 있어야 합니다.

서버 요청이 실패했을 때 Exception을 그대로 화면까지 던지면 화면은 너무 많은 것을 알아야 합니다. 네트워크 에러인지, 인증 에러인지, 권한 에러인지, 서버 점검인지 구분하기 어렵습니다.

간단한 앱에서는 공통 예외 타입을 정의해 둘 수 있습니다.

sealed class AppException implements Exception {
  const AppException(this.message);

  final String message;
}

class NetworkException extends AppException {
  const NetworkException(super.message);
}

class UnauthorizedException extends AppException {
  const UnauthorizedException(super.message);
}

class NotFoundException extends AppException {
  const NotFoundException(super.message);
}

data 계층에서는 HTTP 상태 코드나 Firebase 에러를 앱에서 이해할 수 있는 예외로 바꿉니다. application 계층에서는 이 에러를 상태로 변환하고, presentation 계층에서는 사용자에게 적절한 메시지를 보여줍니다.

이렇게 하면 화면에 기술적인 에러 메시지가 그대로 노출되는 일을 줄일 수 있습니다. 사용자는 "permission-denied"보다 "이 노트를 볼 권한이 없어요"라는 메시지를 이해하기 쉽습니다.

테스트하기 쉬운 구조

좋은 아키텍처는 테스트하기 쉽습니다. 이유는 단순합니다. 중요한 로직이 UI와 분리되어 있기 때문입니다.

예를 들어 NoteListController가 NoteRepository 인터페이스에 의존한다면, 테스트에서는 실제 Firebase 대신 가짜 Repository를 넣을 수 있습니다.

class FakeNoteRepository implements NoteRepository {
  @override
  Future<List<Note>> fetchNotes(String userId) async {
    return [
      Note(
        id: '1',
        title: '테스트 노트',
        content: '내용',
        tags: const [],
        createdAt: DateTime(2026, 1, 1),
        updatedAt: DateTime(2026, 1, 1),
      ),
    ];
  }

  @override
  Future<Note> createNote(Note note) async => note;

  @override
  Future<void> updateNote(Note note) async {}

  @override
  Future<void> deleteNote(String noteId) async {}
}

이 방식은 나중에 테스트 장에서 더 자세히 다루겠지만, 지금 기억할 핵심은 하나입니다. 직접 만들기 어려운 외부 의존성은 인터페이스 뒤로 숨기면 테스트가 쉬워집니다. Firebase, REST API, 로컬 DB 같은 것은 테스트에서 그대로 쓰기 번거롭습니다. Repository Pattern은 유지보수뿐 아니라 테스트 가능성도 높여 줍니다.

팀 프로젝트와 1인 프로젝트의 차이

아키텍처는 팀 규모에 따라서도 달라집니다. 1인 개발에서는 지나치게 엄격한 규칙이 부담이 될 수 있습니다. 혼자 모든 코드를 이해하고 있기 때문에 어느 정도 단순한 구조가 더 빠릅니다.

반면 팀 프로젝트에서는 규칙이 중요합니다. 여러 사람이 동시에 작업할 때는 코드의 위치와 이름 규칙이 맞아야 충돌이 줄어듭니다. 새로 합류한 사람이 "로그인 관련 코드는 어디에 있나요?"라고 물었을 때, features/auth라고 답할 수 있어야 합니다.

1인 프로젝트라도 출시를 목표로 한다면 최소한의 구조는 갖추는 것이 좋습니다. 특히 인증, 결제, 사용자 데이터, 로컬 저장소처럼 나중에 문제가 생기면 고치기 어려운 부분은 처음부터 분리해 두는 편이 안전합니다.

이름 짓기도 아키텍처다

폴더 구조를 잘 잡아도 이름이 애매하면 코드 읽기가 어려워집니다. Manager, Helper, Util, Service 같은 이름은 편리하지만 남용하면 의미가 흐려집니다.

예를 들어 NoteManager라는 클래스가 있다고 해 봅시다. 이 클래스가 노트를 가져오는지, 저장하는지, 상태를 관리하는지, 검색하는지 이름만 봐서는 알기 어렵습니다. 반면 NoteRepository, NoteListController, CreateNoteUseCase는 역할이 더 분명합니다.

물론 Service라는 이름을 절대 쓰지 말라는 뜻은 아닙니다. 외부 SDK를 감싸거나 특정 기술 작업을 담당하는 클래스에는 Service가 어울릴 수 있습니다. 예를 들어 ImagePickerService, AnalyticsService는 의미가 비교적 분명합니다.

좋은 이름은 코드를 읽는 사람에게 작은 설명서가 됩니다. 나중의 나 자신도 그 독자에 포함됩니다.

이 책에서 사용할 기본 원칙

앞으로 REST API, 로컬 저장소, Firebase, 테스트, 최종 프로젝트를 진행하면서 이 장의 구조를 계속 활용할 것입니다. 모든 기능에 똑같은 수준의 계층을 강제하지는 않지만, 다음 원칙은 유지하겠습니다.

  • 기능별로 코드를 모으고, 기능 안에서 역할을 나눈다.

  • 화면은 최대한 UI 표현에 집중한다.

  • 상태관리 코드는 사용자 행동과 앱 상태 변화를 연결한다.

  • Repository로 외부 데이터 접근을 감춘다.

  • 앱 내부 Entity와 외부 DTO를 필요에 따라 구분한다.

  • 작은 기능에는 작은 구조를, 큰 기능에는 더 명확한 구조를 적용한다.

이 원칙을 지키면 앱이 커져도 길을 잃지 않습니다. 새로운 기능을 추가할 때도 "어디에 만들지"가 비교적 분명해집니다.

마무리: 좋은 구조는 미래의 나를 돕는다

유지보수 가능한 앱 아키텍처의 핵심은 완벽함이 아닙니다. 변화를 견디는 힘입니다. 앱은 늘 바뀝니다. 화면 디자인이 바뀌고, 서버 응답이 바뀌고, 로그인 방식이 바뀌고, 저장소가 바뀝니다. 좋은 구조는 이런 변화가 왔을 때 수정 범위를 줄여 줍니다.

처음부터 모든 것을 분리하려고 애쓰지는 마세요. 대신 화면에 너무 많은 책임을 넣지 않는 것부터 시작하세요. API 호출은 Repository로 빼고, 상태 흐름은 컨트롤러로 옮기고, 앱 내부에서 오래 유지할 개념은 Entity로 정리하세요. 이것만으로도 코드의 표정이 달라집니다.

다음 장부터는 이 구조 위에 실제 데이터를 연결합니다. REST API를 호출하고, JSON을 Dart 객체로 바꾸고, 로딩·성공·실패 상태를 화면에 보여줄 것입니다. 이제 우리는 단순히 화면을 만드는 단계를 지나, 진짜 앱의 뼈대를 세우는 단계로 들어갑니다.

책 목록

처음부터 배포까지 Flutter 앱 개발 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기