DAY 14 | Flutter 데모 앱 만들기: 데이터 구조 개선

Pt J·2026년 8월 1일
post-thumbnail

DAY 14 | Flutter 데모 앱 만들기: 데이터 구조 개선

앱 개발을 비롯한 클라이언트 개발은 기본적으로 화면과 로직을 구분하여 작성한다. 따라서 데이터를 UI와 구분하기 위해 data class를 사용한다. (아키텍처 관점에서는 Model이라고 칭한다.) 우리 코드는 그렇게 작성되어 있지 않지만 말이다.

같은 데이터도 상황과 맥락에 따라, 그리고 구현 스타일에 따라 다른 방식으로 구현될 수 있다. 이래도 되고 저래도 될 것 같을 땐 개발 시간은 조금 더 걸리더라도 유지보수성이 좋은 방식을 선택하는 게 좋다. (오버엔지니어링인지 적절히 판단하여 무엇이 최적인지 결정할 수 있다면 더 좋을 것이다.)

데이터 구조를 Class로 변환

기존 WorkoutListPage widget에는 이미지, 운동 이름, 운동 시간 데이터가 Map 에 담긴 채 List<Map> workoutList 를 구성하고 있다.

서로 연관되어 있는 데이터끼리 Map 으로 묶어서 사용하였기에 N개의 List 에 각각의 데이터를 따로 담아두는 것보다는 낫지만, 여전히 문제가 발생할 여지가 있다. key 값을 잘못 작성하는 실수를 할 수도 있고, 필요한 데이터 중 일부를 누락시킨 채 작성할 수도 있다.

그마저도 하지 않고 N개의 List 에 각각의 데이터를 따로 담아 놓으면 그들에 대한 규칙성이 코딩으로 드러나지 않아 유지보수 도중 잘못 사용될 여지가 많다. 누가 읽어도 연관성을 파악할 수 있도록 작성하는 게 좋다. 그리고 애초에 연관성을 해치지 못하도록 구조적 안정성을 가진 코드로 작성하면 더 좋다. “나 아닌 누군가가 이걸 봤을 때 의도대로 흘러갈 수 있는가”를 고려하며 작성해야 한다.

data class 생성하기

기존에 Map 으로 작성되어 있는 데이터를 class로 변환하여 필요한 데이터 항목과 자료형을 명시해 주도록 하겠다. 이미지, 운동 이름, 운동 시간뿐만 아니라 몇 가지 추가 데이터도 사용하겠다.

실습을 위한 데모 앱이므로 수업에서는 모든 데이터를 data class에 담지 않았지만, AI를 통해 텍스트를 뽑아내어 추가로 넣어 보았다. data class는 별도의 파일로 작성한다. 보통은 별도의 디렉토리를 생성하여 집어넣지만, 우리는 우선 파일 구조를 고려하지 않고 lib 에 바로 집어넣고 나중에 refactor 하도록 하겠다. (난 이런 방식을 썩 좋아하진 않는다.)

lib/workout.dart

class Workout {
  String name;
  int minutes;
  String imageName;
  String audioName;
  String description;
  String target;
  String recommend;
  int kcal;

  Workout({
    required this.name,
    required this.minutes,
    required this.imageName,
    required this.audioName,
    required this.description,
    required this.target,
    required this.recommend,
    required this.kcal,
  });
}

lib/workout_list_page.dart

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

class WorkoutListPage extends StatelessWidget {
  WorkoutListPage({super.key});

  final List<Workout> workouts = [
    Workout(
      name: '스쿼트',
      imageName: 'squat.png',
      minutes: 30,
      audioName: 'squat.mp3',
      description: '하체 근력을 강화하고\n탄력 있는 엉덩이 라인을 만드는\n대표적인 전신 운동입니다.',
      target: '배, 상체 근육',
      recommend: '뱃살이 고민이에요\n체지방 태우고 싶어요',
      kcal: 200,
    ),
    Workout(
      name: '마운틴 클라이머',
      minutes: 20,
      imageName: 'mountain_climber.png',
      audioName: 'mountain_climber.mp3',
      description: '엎드린 자세에서 다리를 빠르게 교차하며\n코어와 심폐 지구력을 동시에 기르는\n유산소성 운동입니다.',
      target: '전신, 복부 코어',
      recommend: '칼로리 태우고 싶어요\n전신 탄력 원해요',
      kcal: 50,
    ),
    // ...생략
  ];

  // 생략
}

데이터를 사용하는 코드 수정하기

List<Map> workoutList 를 사용하던 코드를 List<Workout> workouts 를 사용하도록 수정해야 한다.

getWorkoutList() method의 for 문 앞부분만 수정하면 된다.

기존 코드

    for (var i = 0; i < workoutList.length; i++) {
      String name = workoutList[i]['name'];
      String image = workoutList[i]['image'];
      int minutes = workoutList[i]['minutes'];
      //생략
    }

수정 코드

    for (var i = 0; i < workouts.length; i++) {
      String name = workouts[i].name;
      String image = workouts[i].imageName;
      int minutes = workouts[i].minutes;
      //생략
    }

overflow 해결

Challenge를 수행한 사람들은 ListView 로 refactor 하였기 때문에 overflow가 사라졌지만 그 작업을 하지 않은 사람들을 위해 overflow를 처리하는 시간을 가졌다.

Column widget을 SingleChildScrollView 로 감싸면 쉽게 overflow를 해결할 수 있다. 개별 항목을 클릭할 수 있는 형태로 만들기 위해서는 각 Row widget을 GestureDetector 로 감싸면 된다. ListTile 로 이미 변경한 경우에는 onTap property를 추가하는 것만으로 클릭 가능한 상태로 만들 수 있다.

데이터 전달하기

우리의 운동 데이터는 WorkoutListPage 에서만 사용하는 게 아니라 WorkoutGuidePage 에서도 사용한다. WorkoutGuidePageWorkout object에 담긴 데이터를 보여주며, 좌우 버튼을 누르면 List<Workout> workouts 의 앞뒤 인덱스에 담긴 데이터로 전환된다.

데이터를 페이지 내에서 정의하지 않고 외부 파일에서 정의한 후 양쪽에서 해당 파일을 사용하는 게 코드 중복을 줄일 수 있어 더 좋은 구조지만, 우리는 먼지 코드 중복이 있는 방식으로 작성 후 나중에 refactor 하도록 하겠다.

특정 데이터를 보여주는 페이지

페이지마다 데이터를 각자 가지고 있도록 중복으로 작성하는 것은 여러 페이지에서 동일한 데이터에 접근할 때 사용하는 가장 단순한 방식이다. 데이터를 일부 수정할 때 그것이 중복 작성되어 있는 모든 파일에 대해 수정 사항을 반영해야 하기 때문에 실수가 발생하기 쉬워 좋은 방식은 아니다.

일단 index 기반으로 적절한 Workout object에 접근하고 버튼을 작동시키는 기능을 구현하기 위해 WorkoutListPage 에서 정의한 데이터를 WorkoutGuidePage 에 그대로 붙여 넣은 후 진행해 보도록 하겠다.

데이터 관점에서 WorkoutGuidePage 는 데이터 하나당 페이지 하나처럼 구성되어 있다. 따라서 데이터에 접근할 때 매번 workouts[i] 로 접근하지 않고 Workout _currentWorkout = workouts[i]; 에 해당 페이지에서 보여줄 데이터를 담아놓고 _currentWorkout 을 사용하도록 하겠다.

주의 사항!

_WorkoutGuidePageState 에서 Workout _currentWorkout = workouts[0]; 를 시도하면 오류가 난다! workouts 가 아직 메모리에 올라가기 전에 그것에 접근하여 값을 뽑아오려고 하여 문제가 발생한 것이다.

이럴 땐 late Workout _currentWorkout; 으로 해두고 initState() 에서 _currentWorkout = workouts[_workoutIndex]; 해주어야 한다. initState() 에 대한 건 잠시 후에 lifecycle에 대해 이야기하며 좀 더 자세히 알아보자.

lib/WorkoutGuidePage.dart > _WorkoutGuidePageState

class _WorkoutGuidePageState extends State<WorkoutGuidePage> {
  final _player = AudioPlayer();
  Timer? _timer;
  bool _isPlaying = false;

  final List<Workout> workouts = [
    Workout(
      name: '스쿼트',
      imageName: 'squat.png',
      minutes: 30,
      audioName: 'squat.mp3',
      description: '하체 근력을 강화하고\n탄력 있는 엉덩이 라인을 만드는\n대표적인 전신 운동입니다.',
      target: '배, 상체 근육',
      recommend: '뱃살이 고민이에요\n체지방 태우고 싶어요',
      kcal: 200,
    ),
    //중략
    Workout(
      name: '햄스트링 스트레칭',
      minutes: 10,
      imageName: 'hamstring_stretch.png',
      audioName: 'hamstring_stretch.mp3',
      description: '허벅지 뒷쪽 근육을 시원하게\n늘려주어 하체 유연성을 높이고\n피로를 풀어주는 스트레칭입니다.',
      target: '허벅지 뒤쪽',
      recommend: '다리가 자꾸 부어요\n유연성을 기를래요',
      kcal: 30,
    ),
  ];

  late Workout _currentWorkout;
  late int _remainSeconds;
  int _workoutIndex = 0;

  
  void initState() {
    super.initState();
    _player.setReleaseMode(ReleaseMode.loop);
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
  }

  
  void dispose() {
    _player.dispose();
    _timer?.cancel();
    super.dispose();
  }
// 이하 생략
}

하드코딩되어 있는 모든 부분을 _currentWorkout 의 property를 참조하도록 수정한다.

이전/다음 데이터 보여주기

우리의 WorkoutGuidePage 에는 좌우 버튼이 존재한다. 이것을 누르면 운동 목록의 이전 운동 또는 다음 운동 화면으로 넘어가도록 작성할 것이다.

다음 버튼을 눌렀을 때 호출되는 void _next() method와 이전 버튼을 눌렀을 때 호출되는 void _prev() method를 작성하여 IconButton widget의 onPressed property에 전달하는 callback function 내에서 호출한다.

lib/WorkoutGuidePages.dart > _WorkoutGuidePageState

  void _next() {
    _workoutIndex = (_workoutIndex + 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
  }

  void _prev() {
    _workoutIndex = (_workoutIndex - 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
  }

이 때, 데이터가 변경되어 화면을 변경해야 하니 widget이 다시 build될 수 있도록 setState() 을 사용해야 한다.

lib/WorkoutGuidePages.dart

// 앞 부분 생략
child: Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  crossAxisAlignment: CrossAxisAlignment.center,
  children: [
    IconButton(
      onPressed: () {
        setState(() {
          _prev();
        });
      },
      icon: Icon(Icons.arrow_back_ios_new),
      iconSize: 70,
      color: Theme.of(context).colorScheme.primary,
    ),
    IconButton(
      onPressed: () {
        setState(() {
          _next();
        });
      },
      icon: Icon(Icons.arrow_forward_ios),
      iconSize: 70,
      color: Theme.of(context).colorScheme.primary,
    ),
  ],
),
// 뒷 부분 생략

이제 이전/다음 버튼을 누르면 이전/다음 운동의 정보로 화면 구성이 변경된다.

보다 효율적인 데이터 구조는 추후에 알아보도록 하겠다.

lifecycle에 대하여

Flutter의 widget들은 lifecycle을 가진다. StatelessWidget 은 생성/소멸의 단순한 lifecycle만을 가지기에 보통 Flutter widget의 lifestyle이라고 하면 StatefulWidget 의 lifecycle을 말한다.

  • constructor : widget object 생성
  • createState() : widget이 생성될 때 가장 먼저 호출된다. state object를 생성한다.
  • initState() : state object가 생성된 직후 단 한 번 호출된다. 초기화 및 listener 등록이 필요할 때 override 하여 사용한다.
  • didChangeDependencies() : initState() 직후 한 번 실행되며, 그 이후로는 widget이 의존하는 상위 데이터가 바뀔 때 호출된다. (Theme.of(context), Provider, Bloc 등) BuildContext 기반의 초기 데이터를 가져와 사용할 때 override 하여 사용한다.
  • build(): 상태가 바뀔 때마다 반복해서 호출된다. 화면에 UI를 그려준다. widget tree를 반환하며, 반드시 override 해야 한다.
  • didUpdateWidget() : parent widget이 변경되어 현재 widget의 configuration이 바뀔 때 호출된다. parent widget으로부터 받은 새 데이터와 기존 데이터를 비교하여 애니메이션을 다시 실행하거나 controller를 재설정하고자 할 때 override 하여 사용한다.
  • setState() : 내부 데이터 변화를 Flutter 프레임워크에 알려 build() 를 다시 실행하도록 유도할 때 callback function을 담아 호출한다. 데이터 변화를 야기하는 코드를 callback function에 담거나, 데이터 변화를 야기하는 코드 직후에 호출한다.
  • deactivate() : widget이 widget tree에서 제거될 때 호출된다. widget tree에서 제거되었으나 소멸하지 않고 다른 곳으로 옮겨지는 경우는 흔치 않아 거의 사용하지 않는다.
  • dispose() : widget이 영구적으로 소멸할 때 호출된다. initState() 에서 생성했던 리소스들을 해제하여 메모리 누수를 방지할 때 override 하여 사용한다.

setState() 만 직접 호출하며 나머지는 override하여 사용한다. override 시 super class의 method를 먼저 호출하고 필요한 코드를 작성한다. 단, dispose() 는 반대로 필요한 코드를 작성하고 super class의 method를 호출한다.


void initState() {
  super.initState();
  // 필요한 코드 작성
}


void dispose() {
  // 필요한 코드 작성
  super.dispose();
}

간단히 표로 정리하면,

lifecycle function호출 상황주요 역할 및 사용법
createState()Flutter가 StatefulWidget을 메모리에 올려 widget tree에 삽입할 때 가장 먼저 호출widget의 state를 관리하는 고유의 State object를 생성하여 반환.
initState()State 객체가 생성된 후 widget tree에 삽입될 때 단 한 번 호출BuildContext 나 widget 속성에 의존하는 일회성 데이터 초기화 수행
didChangeDependencies()initState() 호출 직후 또는 InheritedWidget 등 의존하는 데이터가 변경되었을 때 호출widget tree의 변경이나 데이터 의존성 변경에 대응하는 초기화 처리.
build()초기화 완료 후, 또는 setState(), didUpdateWidget() 등이 호출되어 화면을 다시 그려야 할 때 수시로 호출전달된 BuildContext 를 기반으로 해당 sub tree의 UI 구조를 정의하고 widget을 반환.
didUpdateWidget()parent widget이 rebuild되어 runtimeType과 Key가 동일한 새 widget 설정으로 변경을 요청할 때 호출이전 위젯과 새 위젯의 속성을 비교하여 설정 변경에 대응. 이후 자동으로 build() 호출.
setState()개발자가 내부 데이터를 변경하고 UI를 갱신하고 싶을 때 직접 호출Flutter에게 내부 state가 변경되었음을 알리고, build() 가 다시 실행되도록 요청.
deactivate()widget이 widget tree에서 일시적으로 제거될 때 호출.현재 요소와 widget tree 내 다른 요소 사이의 연결 관계를 정리.
dispose()제거된 sub tree가 현재 프레임 종료 시까지 재삽입되지 않아 영구적으로 소멸할 때 호출widget이 유지하고 있던 모든 리소스를 해제. 이후 mountedfalse 가 되며 setState() 호출 시 에러 발생.
reassemble()디버깅 중 애플리케이션이 Hot Reload 될 때 호출initState() 에서 준비했던 데이터들을 다시 초기화할 수 있는 기회를 제공.
activate()deactivate() 로 widget tree를 벗어났던 widget이 동일 프레임 내에 다른 위치로 다시 삽입될 때 호출새로운 위치의 widget tree에 맞추어 State object가 화면을 새로 그릴 수 있도록 적응할 기회를 제공.

>>> GitHub Repository at this point (b1c84b7)

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글