이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


들어가며#

Part 1에서 Flutter SDK 설치, 에디터 설정, 에뮬레이터 구성 등 개발 환경을 갖추었습니다. 이제 본격적으로 코드를 작성할 차례입니다.

이번 Part 2에서는 Dart 언어의 핵심 문법을 빠르게 훑어본 뒤, Flutter의 위젯 시스템을 이해하고, 실제로 동작하는 To-Do 리스트 앱의 UI를 구현합니다. 이 글을 따라 하면 할 일을 추가하고, 완료 표시하고, 삭제하는 기본 기능이 모두 갖춰진 앱을 만들 수 있습니다.

Dart 언어 핵심 문법#

Flutter는 Dart 언어를 사용합니다. 다른 프로그래밍 언어 경험이 있다면 금방 익숙해질 수 있습니다.

변수와 타입#

Dart는 정적 타입 언어이지만, var 키워드로 타입 추론도 지원합니다.

// 타입 명시
String name = '홍길동';
int age = 30;
double height = 175.5;
bool isStudent = false;

// 타입 추론
var city = '서울'; // String으로 추론

Null Safety#

Dart는 null safety를 기본으로 지원합니다. 변수가 null이 될 수 있으려면 ? 를 붙여야 합니다.

String name = '홍길동';   // null 불가
String? nickname;          // null 가능

// null 체크 연산자
print(nickname?.length);   // null이면 null 반환
print(nickname ?? '없음'); // null이면 '없음' 반환

클래스와 함수#

class Person {
  final String name;
  final int age;

  // 생성자 축약 문법
  Person({required this.name, required this.age});

  String greet() {
    return '안녕하세요, $name입니다. $age살입니다.';
  }
}

// 화살표 함수
int add(int a, int b) => a + b;

컬렉션#

// List
List<String> fruits = ['사과', '바나나', '포도'];
fruits.add('딸기');

// Map
Map<String, int> scores = {
  '국어': 90,
  '수학': 85,
  '영어': 92,
};
scores['과학'] = 88;

async/await#

네트워크 요청이나 파일 읽기 같은 비동기 작업에 async/await 를 사용합니다. Part 3에서 데이터를 저장할 때 본격적으로 활용하겠지만, 기본 문법만 미리 살펴봅니다.

Future<String> fetchData() async {
  // 2초 대기 (네트워크 요청 시뮬레이션)
  await Future.delayed(Duration(seconds: 2));
  return '데이터 로드 완료';
}

Flutter 위젯 시스템 이해#

Flutter에서 화면에 보이는 모든 것은 위젯(Widget) 입니다. 텍스트, 버튼, 레이아웃, 심지어 앱 자체도 위젯입니다.

Widget 트리#

위젯은 트리 구조로 구성됩니다. 상위 위젯이 하위 위젯을 포함하며, 이 트리가 곧 화면의 구조가 됩니다.

MaterialApp
 └── Scaffold
      ├── AppBar
      │    └── Text('To-Do')
      └── Body
           └── ListView
                ├── ListTile
                └── ListTile

StatelessWidget vs StatefulWidget#

  • StatelessWidget: 상태가 변하지 않는 위젯입니다. 한 번 그려지면 내용이 바뀌지 않습니다.
  • StatefulWidget: 상태가 변할 수 있는 위젯입니다. 사용자 입력이나 데이터 변경에 따라 화면이 다시 그려집니다.

To-Do 앱은 할 일을 추가/삭제/완료 처리해야 하므로 StatefulWidget 을 사용합니다.

BuildContext와 setState#

  • BuildContext: 위젯 트리에서 현재 위젯의 위치 정보를 담고 있습니다. 상위 위젯의 데이터에 접근하거나 테마 정보를 가져올 때 사용합니다.
  • setState(): StatefulWidget의 상태를 변경할 때 호출합니다. 이 함수를 호출하면 Flutter가 위젯을 다시 빌드하여 화면을 갱신합니다.
setState(() {
  todos.add(newTodo); // 상태 변경
});
// → 화면이 자동으로 다시 그려집니다

프로젝트 구조 설계#

Part 1에서 생성한 프로젝트의 lib/ 디렉토리를 아래와 같이 구성합니다.

lib/
├── main.dart            // 앱 진입점
├── models/
│   └── todo.dart        // To-Do 모델 클래스
├── screens/
│   └── home_screen.dart // 메인 화면
└── widgets/
    └── todo_item.dart   // 할 일 항목 위젯

터미널에서 디렉토리와 파일을 생성합니다.

cd my_todo_app
mkdir -p lib/models lib/screens lib/widgets
touch lib/models/todo.dart
touch lib/screens/home_screen.dart
touch lib/widgets/todo_item.dart

To-Do 모델 클래스 작성#

할 일 하나를 나타내는 데이터 모델을 정의합니다. lib/models/todo.dart 파일을 열고 아래 코드를 작성합니다.

class Todo {
  final String id;
  String title;
  bool isCompleted;

  Todo({
    required this.id,
    required this.title,
    this.isCompleted = false,
  });
}
  • id: 각 할 일을 구별하기 위한 고유 식별자입니다.
  • title: 할 일의 내용입니다.
  • isCompleted: 완료 여부입니다. 기본값은 false 입니다.

할 일 항목 위젯 작성#

개별 할 일을 표시하는 위젯을 분리합니다. lib/widgets/todo_item.dart 파일에 다음 코드를 작성합니다.

import 'package:flutter/material.dart';
import '../models/todo.dart';

class TodoItem extends StatelessWidget {
  final Todo todo;
  final ValueChanged<bool?> onToggle;
  final VoidCallback onDelete;

  const TodoItem({
    super.key,
    required this.todo,
    required this.onToggle,
    required this.onDelete,
  });

  @override
  Widget build(BuildContext context) {
    return CheckboxListTile(
      value: todo.isCompleted,
      onChanged: onToggle,
      title: Text(
        todo.title,
        style: TextStyle(
          decoration: todo.isCompleted
              ? TextDecoration.lineThrough
              : TextDecoration.none,
          color: todo.isCompleted ? Colors.grey : Colors.black,
        ),
      ),
      secondary: IconButton(
        icon: const Icon(Icons.delete, color: Colors.red),
        onPressed: onDelete,
      ),
      controlAffinity: ListTileControlAffinity.leading,
    );
  }
}

이 위젯은 세 가지 콜백을 외부에서 받습니다.

  • onToggle: 체크박스를 눌렀을 때 호출됩니다.
  • onDelete: 삭제 버튼을 눌렀을 때 호출됩니다.

위젯 자체는 상태를 갖지 않으므로 StatelessWidget 으로 구현합니다.

메인 화면 UI 구현#

lib/screens/home_screen.dart 파일에 메인 화면을 구현합니다. 이 화면이 앱의 핵심입니다.

import 'package:flutter/material.dart';
import '../models/todo.dart';
import '../widgets/todo_item.dart';

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

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final List<Todo> _todos = [];
  final TextEditingController _textController = TextEditingController();

  void _addTodo() {
    final text = _textController.text.trim();
    if (text.isEmpty) return;

    setState(() {
      _todos.add(Todo(
        id: DateTime.now().millisecondsSinceEpoch.toString(),
        title: text,
      ));
    });
    _textController.clear();
  }

  void _toggleTodo(int index, bool? value) {
    setState(() {
      _todos[index].isCompleted = value ?? false;
    });
  }

  void _deleteTodo(int index) {
    setState(() {
      _todos.removeAt(index);
    });
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('나의 To-Do'),
        centerTitle: true,
      ),
      body: Column(
        children: [
          // 할 일 입력 영역
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _textController,
                    decoration: const InputDecoration(
                      hintText: '할 일을 입력하세요',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (_) => _addTodo(),
                  ),
                ),
                const SizedBox(width: 12),
                ElevatedButton(
                  onPressed: _addTodo,
                  child: const Text('추가'),
                ),
              ],
            ),
          ),
          // 구분선
          const Divider(height: 1),
          // 할 일 목록
          Expanded(
            child: _todos.isEmpty
                ? const Center(
                    child: Text(
                      '할 일이 없습니다.\n새로운 할 일을 추가해 보세요!',
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        fontSize: 16,
                        color: Colors.grey,
                      ),
                    ),
                  )
                : ListView.builder(
                    itemCount: _todos.length,
                    itemBuilder: (context, index) {
                      return TodoItem(
                        todo: _todos[index],
                        onToggle: (value) => _toggleTodo(index, value),
                        onDelete: () => _deleteTodo(index),
                      );
                    },
                  ),
          ),
        ],
      ),
      // 플로팅 버튼 (선택적 추가 방법)
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // TextField에 포커스를 줍니다
          FocusScope.of(context).requestFocus(FocusNode());
          Future.delayed(const Duration(milliseconds: 100), () {
            _textController.clear();
          });
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

주요 구성 요소를 하나씩 살펴보겠습니다.

AppBar#

appBar: AppBar(
  title: const Text('나의 To-Do'),
  centerTitle: true,
),

화면 상단에 앱 제목을 표시합니다. centerTitle: true 로 중앙 정렬합니다.

입력 영역#

TextFieldElevatedButtonRow 위젯으로 나란히 배치합니다. onSubmitted 콜백을 설정하면 키보드의 Enter 키로도 할 일을 추가할 수 있습니다.

ListView.builder#

ListView.builder 는 목록의 항목 수만큼 위젯을 동적으로 생성합니다. 항목이 수백 개라도 화면에 보이는 것만 렌더링하므로 성능이 우수합니다.

빈 상태 처리#

할 일이 없을 때 빈 화면 대신 안내 메시지를 표시합니다. 삼항 연산자(_todos.isEmpty ? ... : ...)로 간단하게 분기합니다.

앱 진입점 수정#

lib/main.dart 파일을 열고 기존 코드를 모두 지운 뒤 아래 코드로 교체합니다.

import 'package:flutter/material.dart';
import 'screens/home_screen.dart';

void main() {
  runApp(const MyTodoApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '나의 To-Do',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const HomeScreen(),
      debugShowCheckedModeBanner: false,
    );
  }
}
  • MaterialApp: Material Design 스타일의 앱을 구성합니다.
  • ThemeData: colorScheme을 사용하여 앱 전체의 색상 테마를 설정합니다. useMaterial3: true 로 최신 Material 3 디자인을 적용합니다.
  • debugShowCheckedModeBanner: false: 우측 상단의 “DEBUG” 배너를 숨깁니다.

실행 확인#

터미널에서 앱을 실행합니다.

cd my_todo_app
flutter run

에뮬레이터 또는 연결된 기기에서 다음과 같은 동작을 확인할 수 있습니다.

  1. 할 일 추가: 텍스트 필드에 내용을 입력하고 “추가” 버튼을 누르거나 Enter를 누릅니다.
  2. 완료 토글: 항목 왼쪽의 체크박스를 누르면 취소선이 그어지며 완료 처리됩니다.
  3. 삭제: 항목 오른쪽의 빨간 휴지통 아이콘을 누르면 해당 할 일이 삭제됩니다.

전체 파일 구조 정리#

최종적으로 lib/ 디렉토리는 아래와 같습니다.

lib/
├── main.dart                 # 앱 진입점, 테마 설정
├── models/
│   └── todo.dart             # Todo 모델 (id, title, isCompleted)
├── screens/
│   └── home_screen.dart      # 메인 화면 (StatefulWidget)
└── widgets/
    └── todo_item.dart        # 개별 할 일 위젯 (StatelessWidget)

각 파일의 역할을 정리하면 다음과 같습니다.

파일 역할 위젯 종류
main.dart 앱 시작, 테마 설정 StatelessWidget
todo.dart 데이터 모델 정의 -
home_screen.dart 화면 레이아웃, 상태 관리 StatefulWidget
todo_item.dart 개별 항목 표시 StatelessWidget

이렇게 파일을 분리하면 코드의 가독성이 높아지고, 나중에 기능을 확장하기에도 유리합니다.

다음 단계#

Part 3에서는 앱을 종료해도 할 일 목록이 유지되도록 로컬 저장소를 연동하고, 상태 관리 패턴을 적용하여 코드 구조를 개선합니다.