[내배캠] 첫번째 팀 프로젝트

nbcamp flutter
Written on Jul 19, 2023


사전캠프에서 학습한 Flutter를 활용하여 첫번째 팀 프로젝트를 진행했습니다. (2023년 7월 10일 ~ 14일)

4일간 진행한 프로젝트로 목적은 앱 개발 전반의 과정을 익히기 위함이지만, 그보다 팀원분들과의 소통과 협업 관점에 비중으로 두고 프로젝트를 진행하였습니다.

프로젝트의 주제는 팀과 팀원의 소개를 담은 앱을 작성하는 것으로, 각자 자신의 소개를 담은 상세 페이지를 작성하고 합치기로 결정하였습니다. 저는 상세 페이지와 더불어 메인 페이지와 각 상세 페이지에서 쓰일 댓글 관리 서비스를 맡았습니다.

그 중에서도 댓글 관리 서비스를 어떻게 구현했는지 정리해보고자 합니다.


프로젝트 목표

  • 앱 개발 전반의 흐름을 이해할 수 있습니다.
  • CRUD를 이해하고 활용할 수 있습니다.
  • 상태 관리에 대해 이해하고 활용할 수 있습니다.
  • 기술적 구현보다 프로젝트 진행 방법과 소통에 대해 고민해봅니다.
    • 와이어 프레임 설계, 기능 분담
    • 팀원과의 소통, 협업 방법, 도움 요청 및 제공

의존성 주입을 위해 인터페이스 적용하기

사전캠프에서도 다뤘지만, Service 단에서 shared_preferences를 사용하기 위해 서비스 파일 내에서 직접 불러와서 사용해주는 방식이 아니라 main.dart에서 saveload 함수를 주입해주는 방식으로 구현했었습니다.

허나, 아래처럼 save 혹은 load 둘 다 구현해야하는 걸 강제할 수 없었습니다. 또한, shared_preferences외에 다른 걸로 변경할 때도 번거롭습니다

Language:dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  SharedPreferences pref await SharedPreferences.getInstance();
  runApp(MultiProvider(
    providers: [
      ChangeNotifierProvider(
        create: (_) => MemoService(
          save: (String payload) async => await pref.setString("memo", payload),
          // load: () async => pref.getString("memo"), // 에러를 발생시키지 않음
        ),
      ),
    ],
    child: const MyApp(),
  ));
}

이를 해결하기 위해 IO 인터페이스를 구현했습니다.

Language:dart
abstract class IO {
  Future<void> save(String payload);
  Future<String?> load();
}

dart에서는 인터페이스를 생성하기 위해 abstract class 키워드를 사용해야 합니다. IO 인터페이스는 saveload 함수에 대한 명세를 제공합니다. 이를 CommentService에 적용합니다.

Language:dart
class CommentService extends ChangeNotifier {
  final Map<String, List<Comment>> _comments = {};

  IO? io;

  CommentService({this.io}) {
    //
  }

  ...
}

이제 io 인스턴스를 외부에서 주입해줄 수 있습니다. shared_preferences를 사용할 예정이므로 SharedPreferencesIO 클래스를 생성합니다.

Language:dart
class SharedPreferencesIO implements IO {
  late Future<SharedPreferences> pref;

  SharedPreferencesIO() {
    pref = SharedPreferences.getInstance();
  }

  @override
  Future<String?> load() {
    return pref.then((pref) => pref.getString('comments'));
  }

  @override
  Future<void> save(String payload) async {
    pref.then((pref) => pref.setString('comments', payload));
  }
}
Language:dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MultiProvider(
    providers: [
      ChangeNotifierProvider(
        create: (_) => CommentService(
          io: SharedPreferencesIO(),
        ),
      ),
    ],
    child: MainApp(),
  ));
}

구현한 SharedPreferencesIO 클래스를 main.dart에서 CommentService에 주입해줍니다.

이로써, saveload 모두 구현해야 함을 강제할 수 있게 되었고, IO 인터페이스를 상속하는 클래스를 여러 개 추가하여 상황에 따라 교체할 수 있어 확장성을 높였습니다.

데이터 송수신 로직 추상화하기

CommentService에서 내부적으로 데이터를 저장하고 불러오는 _save_load 함수가 있었습니다.

Language:dart
Future<void> _save() {
  if (save == null) return Future.value();

  String payload = jsonEncode(_memos.map((m) => m.toJson()).toList());
  return save!(payload);
}

Future<void> _load() async {
  if (load == null) return;

  String? payload = await load!();
  if (payload == null) return;
  _memos.clear();

  _memos.addAll(jsonDecode(payload).map((e) => Memo.fromJson(e)));
}

여기서 데이터를 String 타입의 payload로 만들거나, 반대로 payload를 데이터로 변환하는 작업을 수행했었습니다. 언뜻보면 null 체크를 하고 종료하는 부분이나 payload를 처리하는 부분은 중복된 부분이기에 별도의 함수로 분리하고 싶었습니다.

그래서 DataHandler 추상 클래스르 작성하였습니다.

Language:dart
mixin DataHandler {
  IO? io;

  Future<void> import(String payload);
  Future<String> export();

  Future<void> save() async {
    if (io == null) return Future.value();
    var payload = await export();
    io?.save(payload);
  }

  Future<void> load() async {
    if (io == null) return Future.value();
    var payload = await io!.load();
    if (payload == null) return;
    import(payload);
  }
}

dart에서 mixin을 활용하여 추상 클래스를 작성할 수 있습니다. saveload 함수는 이미 구현되어 있어 동일한 작업을 수행하지만, importexport 함수는 재정의가 필요합니다. 이를 CommentService에 적용합니다.

Language:dart
class UseState {
  final List<Comment> comments;
  final void Function(List<Comment> Function()) setState;

  UseState({
    required this.comments,
    required this.setState,
  });
}

class CommentService extends ChangeNotifier with DataHandler {
  final Map<String, List<Comment>> _comments = {};

  CommentService({IO? io}) {
    this.io = io;

    try {
      load().then((_) => notifyListeners());
    } catch (error) {
      // ignore
    }
  }

  UseState useState(String name) {
    return UseState(
      comments: List.unmodifiable(_comments[name] ?? []),
      setState: (newComments) {
        _comments[name] = newComments();
        notifyListeners();
        save();
      },
    );
  }

  @override
  Future<void> import(String payload) async {
    jsonDecode(payload).forEach((key, values) {
      if (values is! List) return;
      _comments[key] = values.map((value) => Comment.fromJson(value)).toList();
    });
  }

  @override
  Future<String> export() async {
    return jsonEncode(
      _comments.map(
        (key, values) => MapEntry(
          key,
          values.map((comment) => comment.toJson()).toList(),
        ),
      ),
    );
  }
}

mixin을 상속하기 위해선 with 키워드를 사용해야 합니다. DataHandler를 상속하면서 CommentService에서 구현해야 하는 함수들을 재정의합니다. importexport 함수는 단순히 JSON 형태의 stringify와 parse 작업을 수행합니다. 이로써 상속을 통해 중복 코드를 제거하고 함수의 역할을 분리할 수 있었습니다.