일정관리 앱 만들기: 서버와 연동하기 (캐싱, Node.js, NestJS, Provider)

잠만보·2024년 11월 17일

사전 지식

상태 관리

지금까지는 State 클래스 내부에서 데이터(상태) 를 변경하고 setState() 함수 를 실행했다.

이 방식은 작은 프로젝트에서는 효율적이지만 프로젝트가 커질수록 같은 변수를 반복적으로 아래의 위젯으로 넘겨줘야 하니 데이터(상태) 관리가 어렵다.


최상위 위젯 Container 에서 Text 까지 color: Colors.blue 를 넘겨주려면 중간에 모든 위젯이 color: Colors.blue 를 가지고 있어야 한다.
혹시나 매개변수를 color: Colors.red 로 수정한다면? 모두 바꿔야 한다...

따라서 글로벌 상태 관리 툴 을 사용해서 데이터를 목표 위젯에서 직접적으로 가져온다.

플러터에는 Bloc , GetX , Riverpod, Provider 같은 상태 관리 플러그인 (글로벌 상태관리 툴) 이 있다.

여기서 가장 사용하기 쉬운 Provider 를 사용하겠다.

캐시와 긍정적 응답

캐시

실제 서버를 운영하는 상황에서는 서버를 구매하거나 클라우드에서 운영하게 된다.
이때 자연적으로 지연이 생긴다.

하지만 유저들은 이러한 지연을 마주치면 '앱이 느리다' 라는 느낌을 받을 수 있기 때문에 개발자들은 데이터를 기억하는 캐싱 을 사용한다.


현재 구현한 ScheduleProvider 에는 cache 라는 변수가 존재하며 이 변수에는 GET 메서드로 불러온 모든 일정 정보가 전부 담겨있다.
따라서 같은 날짜를 다시 DB에 요청할 때에는 지연이 없다.

긍정적 응답

요청이 성공하면 현재 캐시에 하나의 일정이 추가될 것이다.
또한 성공 시 추가될 일정은 유저의 입력을 받아서 생성한 일정이기 때문에 어떤 값이 추가될 지도 알고 있다.

따라서 API 요청을 보내기 전에 유저가 입력한 값으로 미리 캐시를 업데이트 해서 응답을 예측할 수 있다. (만약 에러가 발생한다면 임의로 넣어둔 값을 삭제하면 된다.)

이러한 기법을 긍정적 응답 이라고 한다.

사전 준비

이번에 사용할 서버는 일정 불러오기, 생성하기, 삭제하기 기능을 제공한다.

REST API 는 일관된 DB를 갖고 있어야 하고 접근한 리소스를 포함해야 하기 때문에 /schedules API 에 GET, POST, DELETE 메소드 기능을 제작해두었다.

구현하기

1. REST API 용으로 모델 구현하기

class ScheduleModel {
  final String id;
  final String content;
  final DateTime date;
  final int startTime;
  final int endTime;

  ScheduleModel({
    required this.id,
    required this.content,
    required this.date,
    required this.startTime,
    required this.endTime,
  });

  ScheduleModel.fromJson({ // json 으로부터 모델을 만들어내는 생성자, 서버로부터 받은 json 데이터를 ScheduleModel로 매핑
    required Map<String, dynamic> json,
  })  : id = json['id'],
        content = json['content'],
        date = DateTime.parse(json['date']),
        startTime = json['startTime'],
        endTime = json['endTime'];

  Map<String, dynamic> toJson() { // 모델을 다시 json 으로 변환, 서버로 네트워크 요청을 보낼 때 json 형식으로 데이터를 변환해야 함
    return {
      'id': id,
      'content': content,
      'date':
          '${date.year}${date.month.toString().padLeft(2, '0')}${date.day.toString().padLeft(2, '0')}',
      'startTime': startTime,
      'endTime': endTime,
    };
  }

  ScheduleModel copyWith({ // 현재 모델을 특정 속성만 변환해서 새로 생성, 이미 존재하는 인스턴스에서 몇개의 값만 변경하고 싶을 때 사용
    String? id,
    String? content,
    DateTime? date,
    int? startTime,
    int? endTime,
  }) {
    return ScheduleModel(
      id: id ?? this.id,
      content: content ?? this.content,
      date: date ?? this.date,
      startTime: startTime ?? this.startTime,
      endTime: endTime ?? this.endTime,
    );
  }
}

2. API 요청 기능 구현하기

제공되는 서버에는 3개의 엔드포인트가 존재하며 정보는 다음과 같다.

GET 메서드 구현하기

import 'dart:async';
import 'dart:io';

import 'package:calendar_scheduler/model/schedule_model.dart';
import 'package:dio/dio.dart';

class ScheduleRepository {
  final _dio = Dio();
  final _targetUrl =
      'http://${Platform.isAndroid ? '10.0.2.2' : 'localhost'}:3000/schedule'; // 안드로이드에서는 10.0.2.2 가 localhost 에 해당

  Future<List<ScheduleModel>> getSchedules({
    required DateTime date,
  }) async {
    final resp = await _dio.get(
      _targetUrl,
      queryParameters: { // Query 매개변수
        'date':
            '${date.year}${date.month.toString().padLeft(2, '0')}${date.day.toString().padLeft(2, '0')},'
      },
    );
    return resp.data // 모델 인스턴스로 데이터 매핑하기
        .map<ScheduleModel>(
          (x) => ScheduleModel.fromJson(
            json: x,
          ),
        )
        .toList();
  }
}

일정 생성하는 함수 작성

생성하고 싶은 일정에 해당하는 ScheduleModel 을 json 형식으로 Body에 제공한다.

.
.
.

Future<String> createSchedule({
    required ScheduleModel schedule,
  }) async {
    final json = schedule.toJson(); // json 으로 변환

    final resp = await _dio.post(_targetUrl, data: json);

    return resp.data?['id'];
  }

일정을 삭제하는 API 작업하기


.
.
.

Future<String> deleteSchedule({
    required String id,
  }) async {
    final resp = await _dio.delete(_targetUrl, data: {
      'id': id, // 삭제할 ID 값
    });
    return resp.data?['id']; // 삭제된 ID 값 반환
  }

3. 글로벌 상태 관리 구현하기: ScheduleProvider

Provider 툴ChangeNotifier 클래스 를 상속하기만 하면 어떤 클래스든 프로바이더로 상태 관리를 하도록 만들 수 있다.

ScheduleProvider 생성

import 'package:calendar_scheduler/model/schedule_model.dart';
import 'package:calendar_scheduler/repository/schedule_repository.dart';

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

class ScheduleProvider extends ChangeNotifier {
  final ScheduleRepository repository; // API 요청 로직을 담은 클래스

  DateTime selectedDate = DateTime.utc( // 선택한 날짜
    DateTime
        .now()
        .year,
    DateTime
        .now()
        .month,
    DateTime
        .now()
        .day,
  );
  Map<DateTime, List<ScheduleModel>> cache = {}; // 일정 정보를 저장해둘 캐시

  ScheduleProvider({
    required this.repository,
  }) : super() {
    getSchedules(date: selectedDate);
  }

날짜별 일정을 불러오는 기능 구현하기

void getSchedules({required DateTime date}) async {
    final resp = await repository.getSchedules(date: date); // GET 메서드 보내기

    cache.update(date, (value) => resp, ifAbsent: () => resp); // 선택한 날짜의 일정들 업데이트하기

    notifyListeners(); // 리슨하는 위젯들 업데이트 하기
  }

일정 생성하는 함수 작성

void createSchedule({
    required ScheduleModel schedule,
  }) async {
    final targetDate = schedule.date;

    final savedSchedule = await repository.createSchedule(schedule: schedule);

    cache.update(
      targetDate,
      (value) => [ // 현존하는 캐시 리스트 끝에 새로운 일정 추가
        ...value,
        schedule.copyWith(
          id: savedSchedule,
        ),
      ]..sort(
          (a, b) => a.startTime.compareTo(
            b.startTime,
          ),
        ),
      ifAbsent: () => [schedule], // 날짜에 해당되는 값이 없다면 새로운 캐시 리스트에 새로운 일정 하나만 추가
    );
    notifyListeners();
  }

일정을 삭제하는 함수 작성

void deleteSchedule({
    required DateTime date,
    required String id,
  }) async {
    final resp = await repository.deleteSchedule(id: id);
    cache.update( // 캐시에서 데이터 삭제
      date,
      (value) => value.where((e) => e.id != id).toList(),
      ifAbsent: () => [],
    );
    notifyListeners();
  }

selectedDate 변경하기: UPDATE

void changeSelectedDate({
    required DateTime date,
  }) {
    selectedDate = date; // 현재 선택된 날짜를 매개변수로 입력받은 날짜로 변경
    notifyListeners();
  }

Provider 초기화하기

ScheduleRepository & ScheduleProvider 인스턴스화 하기

import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:calendar_scheduler/provider/schedule_provider.dart';
import 'package:calendar_scheduler/repository/schedule_repository.dart';
import 'package:calendar_scheduler/screen/home_screen.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized(); // 플러터 프레임워크가 준비될 때 까지 대기

  await initializeDateFormatting(); // intl 패키지 초기화 (다국어화)

  final database = LocalDatabase(); // DB 생성

  GetIt.I.registerSingleton<LocalDatabase>(database); // GetIt에 DB 변수 주입

  final repository = ScheduleRepository();
  final scheduleProvider = ScheduleProvider(repository: repository);

  runApp(
    ChangeNotifierProvider( // Provider를 하위 위젯에 제공하기
      create: (_) => scheduleProvider,
      child: MaterialApp(
        home: HomeScreen(),
      ),
    ),
  );
}

Drift를 Provider 로 대체하기

드리프트를 사용할 때 StreamBuilder 를 사용해서 Stream 값을 리스닝 했지만 Provider는 데이터를 불러올 수 있는 watch()read() 함수를 제공해주기 때문에 더이상 StreamBuilder 를 사용할 필요가 없다.

HomeScreen 위젯 Stateless로 변경 & ScheduleProvider watch() 코드 작성

Provider 로 상태(데이터)를 관리할 것이기 때문에 메모리를 많이 차지하는 Stateful 위젯을 사용할 필요가 없다.
(앞선 그림에서 위젯에서 colors 속성을 모두 기억하지 않는 것을 상기해라)

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<ScheduleProvider>(); // Provider 변경이 있을 때마다 build() 함수 재실행
    final selectedDate = provider.selectedDate; // 선택된 날짜 가져오기

    final schedules = provider.cache[selectedDate] ?? []; // 선택된 날짜에 해당되는 일정들 가져오기

    return Scaffold()
    .
    .
    .
}    

StreamBuilder 제거


class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final provider =
        context.watch<ScheduleProvider>(); // Provider 변경이 있을 때마다 build() 함수 재실행
    final selectedDate = provider.selectedDate; // 선택된 날짜 가져오기

    final schedules =
        provider.cache[selectedDate] ?? []; // 선택된 날짜에 해당되는 일정들 가져오기

    return Scaffold(
      floatingActionButton: FloatingActionButton(
        // 새 일정 버튼
        backgroundColor: PRIMARY_COLOR,

        // 추가 버튼 눌렀을 때 동작할 콜백
        onPressed: () {
          showModalBottomSheet(
            // BottomSheet 열기
            context: context,
            isDismissible: true, // 배경 탭했을 때 BottomSheet 닫기
            builder: (_) => ScheduleBottomSheet(
              selectedDate: selectedDate, // 선택된 날짜 selectedDate 넘겨주기
            ),
            isScrollControlled: true, // 할 일 입력창 렌더링
          );
        },
        child: Icon(
          // '+' 모양 아이콘
          Icons.add,
        ),
      ),
      body: SafeArea(
        // 시스템 UI 피해서 UI 구현
        child: Column(
          // 달력과 리스트를 세로로 배치
          children: [
            // 달력 위젯
            MainCalendar(
              selectedDate: selectedDate, // 선택된 날짜 전달하기
              onDaySelected: onDaySelected, // 날짜가 선택됬을 떄 실행할 함수
            ),
            SizedBox(
              height: 8.0,
            ),
        //======================================================================
            TodayBanner(
              selectedDate: selectedDate,
              count: schedules.length,
        //======================================================================
            ),
            // 오늘 날짜와 할일 개수 보여주는 위젯
            SizedBox(
              height: 8.0,
            ),
        //======================================================================
            Expanded(
              // 남는 공간 모두 차지
              child: ListView.builder(
                itemCount: schedules.length,
                itemBuilder: (context, index) {
                  final schedule = schedules[index];

                  return Dismissible(
                    key: ObjectKey(schedule.id),
                    direction: DismissDirection.startToEnd,
                    onDismissed: (DismissDirection direction) {
                      provider.deleteSchedule(
                          date: selectedDate, id: schedule.id);
                    },
                    child: Padding(
                      padding: const EdgeInsets.only(
                          bottom: 8.0, left: 8.0, right: 8.0),
                      child: ScheduleCard(
                        startTime: schedule.startTime,
                        endTime: schedule.endTime,
                        content: schedule.content,
                      ),
                    ),
                  );
                },
              ),
            ),
            //======================================================================
          ],
        ),
      ),
    );
  }

  void onDaySelected(DateTime selectedDate, DateTime focusedDate) {
    // 날짜 선택될 때 마다 실행할 함수
  }
}

ScheduleBottomSheet 코드 Provider 로 대체


class ScheduleBottomSheet extends StatefulWidget {
  final DateTime selectedDate; // 선택한 날짜 상위 위젯에서 입력받기

  const ScheduleBottomSheet({super.key, required this.selectedDate});

  @override
  State<ScheduleBottomSheet> createState() => _ScheduleBottomSheetState();
}

class _ScheduleBottomSheetState extends State<ScheduleBottomSheet> {
  final GlobalKey<FormState> formKey = GlobalKey(); // Form 키 생성

  int? startTime; // 시작 시간 저장 변수
  int? endTime; // 종료 시간 저장 변수
  String? content; // 일정 내용 저장 변수

  @override
  Widget build(BuildContext context) {
    // 키보드 높이 가져오기
    final bottomInset = MediaQuery.of(context).viewInsets.bottom;

    return Form(
        // 텍스트 필드를 한번에 관리할 수 있는 폼
        key: formKey, // 폼을 조작할 키 값
        child: SafeArea(
          child: Container(
            height: MediaQuery.of(context).size.height / 2 +
                bottomInset, // 화면 반 높이에 키보드 높이 추가하기
            color: Colors.white,
            child: Padding(
              padding: EdgeInsets.only(
                  left: 8, right: 8, top: 8, bottom: bottomInset),
              child: Column(
                children: [
                  Row(
                    // 시작시간, 종료시간 필드는 가로로 배치
                    children: [
                      Expanded(
                        child: CustomTextField(
                          // 시작 시간 입력 필드
                          label: '시작 시간',
                          isTime: true,
                          onSaved: (String? val) {
                            startTime = int.parse(
                                val!); // 저장이 시작되면 startTime 변수에 텍스트 필드 값 저장
                          },
                          validator: timeValidator,
                        ),
                      ),
                      const SizedBox(width: 16.0),
                      Expanded(
                        child: CustomTextField(
                          // 종료 시간 입력 필드
                          label: '종료 시간',
                          isTime: true,
                          onSaved: (String? val) {
                            endTime = int.parse(
                                val!); // 저장이 시작되면 endTime 변수에 텍스트 필드 값 저장
                          },
                          validator: timeValidator,
                        ),
                      ),
                    ],
                  ),
                  SizedBox(height: 8.0),
                  Expanded(
                    child: CustomTextField(
                      // 내용 입력 필드
                      label: '내용',
                      isTime: false,
                      onSaved: (String? val) {
                        content = val; // 저장이 시작되면 content 변수에 텍스트 필드 값 저장
                      },
                      validator: contentValidator,
                    ),
                  ),
                    //========================================================
                  SizedBox(
                    width: double.infinity,
                    child: ElevatedButton(
                      // 저장 버튼
                      onPressed: ()=> onSavePressed(context), // 함수에 context 전달
                      style: ElevatedButton.styleFrom(
                        backgroundColor: PRIMARY_COLOR,
                      ),
                      child: Text('저장'),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ));
  }
//========================================================
  void onSavePressed(BuildContext context) async {
    if (formKey.currentState!.validate()) {
      // 폼 검증하기
      formKey.currentState!.save(); // 폼 저장하기

      context.read<ScheduleProvider>().createSchedule(
            schedule: ScheduleModel(
              id: 'new_model', // 임시 ID
              content: content!,
              date: widget.selectedDate,
              startTime: startTime!,
              endTime: endTime!,
            ),
          );
      Navigator.of(context).pop(); // 일정 생성 후 화면 뒤로가기
    }
  }
//========================================================
  // 시간 값 검증
  String? timeValidator(String? val) {
    if (val == null) {
      return '값을 입력해 주세요';
    }
    int? number;

    try {
      number = int.parse(val);
    } catch (e) {
      return '숫자를 입력해주세요';
    }

    if (number < 0 || number > 24) {
      return '0시부터 24시 사이를 입력해주세요';
    }
    return null;
  }

  // 내용 값 검증
  String? contentValidator(String? val) {
    if (val == null || val.length == 0) {
      return '값을 입력해 주세요';
    }
    return null;
  }
}

HomeScreen 날짜 선택 코드 변경

import 'package:calendar_scheduler/component/main_calendar.dart';
import 'package:calendar_scheduler/component/schedule_bottom_sheet.dart';
import 'package:calendar_scheduler/component/schedule_card.dart';
import 'package:calendar_scheduler/component/today_banner.dart';
import 'package:calendar_scheduler/const/colors.dart';
import 'package:calendar_scheduler/database/drift_database.dart';
import 'package:calendar_scheduler/main.dart';
import 'package:calendar_scheduler/provider/schedule_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final provider =
        context.watch<ScheduleProvider>(); // Provider 변경이 있을 때마다 build() 함수 재실행
    final selectedDate = provider.selectedDate; // 선택된 날짜 가져오기

    final schedules =
        provider.cache[selectedDate] ?? []; // 선택된 날짜에 해당되는 일정들 가져오기

    return Scaffold(
      floatingActionButton: FloatingActionButton(
        // 새 일정 버튼
        backgroundColor: PRIMARY_COLOR,

        // 추가 버튼 눌렀을 때 동작할 콜백
        onPressed: () {
          showModalBottomSheet(
            // BottomSheet 열기
            context: context,
            isDismissible: true, // 배경 탭했을 때 BottomSheet 닫기
            builder: (_) => ScheduleBottomSheet(
              selectedDate: selectedDate, // 선택된 날짜 selectedDate 넘겨주기
            ),
            isScrollControlled: true, // 할 일 입력창 렌더링
          );
        },
        child: Icon(
          // '+' 모양 아이콘
          Icons.add,
        ),
      ),
      body: SafeArea(
        // 시스템 UI 피해서 UI 구현
        child: Column(
          // 달력과 리스트를 세로로 배치
          children: [
            // 달력 위젯
            MainCalendar(
              selectedDate: selectedDate, // 선택된 날짜 전달하기
              //======================================================================
              onDaySelected: (selectedDate, focusedDate) => onDaySelected(
                  selectedDate, focusedDate, context), // 날짜가 선택됬을 떄 실행할 함수
                  //======================================================================
            ),
            SizedBox(
              height: 8.0,
            ),
            TodayBanner(
              selectedDate: selectedDate,
              count: schedules.length,
            ),
            // 오늘 날짜와 할일 개수 보여주는 위젯
            SizedBox(
              height: 8.0,
            ),
            Expanded(
              // 남는 공간 모두 차지
              child: ListView.builder(
                itemCount: schedules.length,
                itemBuilder: (context, index) {
                  final schedule = schedules[index];

                  return Dismissible(
                    key: ObjectKey(schedule.id),
                    direction: DismissDirection.startToEnd,
                    onDismissed: (DismissDirection direction) {
                      provider.deleteSchedule(
                          date: selectedDate, id: schedule.id);
                    },
                    child: Padding(
                      padding: const EdgeInsets.only(
                          bottom: 8.0, left: 8.0, right: 8.0),
                      child: ScheduleCard(
                        startTime: schedule.startTime,
                        endTime: schedule.endTime,
                        content: schedule.content,
                      ),
                    ),
                  );
                },
              ),
            ),
            // 할일 위젯
          ],
        ),
      ),
    );
  }
//======================================================================
  void onDaySelected(
      DateTime selectedDate, DateTime focusedDate, BuildContext context) {
    // 날짜 선택될 때 마다 실행할 함수
    final provider = context.read<ScheduleProvider>();
    provider.changeSelectedDate(
      date: selectedDate,
    );
    provider.getSchedules(date: selectedDate);
  }
}
//======================================================================

Provider 코드 전환 확인

드리프트를 사용했을때와 동일하게 잘 동작하는것을 확인할 수 있다!

캐시 적용하기

앞에서 알아봤던 캐시와 긍정적 응답을 이용해서 지연이 없는것 처럼 보이게 코드를 변경해보겠다.

schedule_provider 에 긍정적 응답 적용하기 : CREATE

void createSchedule({
    required ScheduleModel schedule,
  }) async {
    final targetDate = schedule.date;

    final savedSchedule = await repository.createSchedule(schedule: schedule);
    final uuid = Uuid();

    final tempId = uuid.v4(); // 유일한 ID 값을 생성합니다.
    final newSchedule = schedule.copyWith(
      id: tempId, // 임시 ID를 지정한다.
    );

    // 긍정적 응답 구간. 서버에서 응답을 받기 전에 캐시를 먼저 업데이트함
    cache.update(
      targetDate,
      (value) => [
        // 현존하는 캐시 리스트 끝에 새로운 일정 추가
        ...value,
        newSchedule,
      ]..sort(
          (a, b) => a.startTime.compareTo(
            b.startTime,
          ),
        ),
      ifAbsent: () => [newSchedule],
    );
    notifyListeners(); // 캐시 업데이트 반영
    try {
      // API 요청
      final savedSchedule = await repository.createSchedule(schedule: schedule);

      cache.update( // 서버 응답 기반으로 캐시 업데이트
        targetDate,
        (value) => value
            .map((e) => e.id == tempId
                ? e.copyWith(
                    id: savedSchedule,
                  )
                : e)
            .toList(),
      );
    } catch (e) {
      cache.update( // 삭제 실패 시 캐시 롤백하기
        targetDate,
        (value) => value.where((e) => e.id != tempId).toList(),
      );
    }
    notifyListeners();
  }

DELETE 긍정적 응답 구현하기

void deleteSchedule({
    required DateTime date,
    required String id,
  }) async {
    final targetSchedule = cache[date]!.firstWhere(
      (e) => e.id == id,
    ); // 삭제할 일정 기억

    cache.update(
      // 캐시에서 데이터 삭제
      date,
      (value) => value.where((e) => e.id != id).toList(),
      ifAbsent: () => [],
    ); // 긍정적 응답 (응답하기 전에 캐시 먼저 업데이트)
    notifyListeners(); // 캐시 업데이트 반영

    try {
      await repository.deleteSchedule(id: id); // 삭제 함수 실행
    } catch (e) { // 삭제 실패 시 캐시 롤백하기
      cache.update(
        date,
        (value) => [...value, targetSchedule]..sort(
            (a, b) => a.startTime.compareTo(
              b.startTime,
            ),
          ),
      );
    }
    notifyListeners();
  }

캐시 적용 전 후 비교

적용 전

적용 후

확실히 적용 후가 약간 더 빠른것 같다.

profile
아프지 말자 - (잘못된 정보, 수정 사항 있으면 언제든지 알려주시면 감사하겠습니다!)

0개의 댓글