<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>PJOS</title>
        <link>https://velog.io/</link>
        <description>Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)</description>
        <lastBuildDate>Fri, 21 Aug 2026 09:05:27 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>PJOS</title>
            <url>https://images.velog.io/images/peeeeeter_j/profile/ee9e1790-4d3d-4dba-a678-b8788cfef636/20200820_003148.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. PJOS. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/peeeeeter_j" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[DAY 20 | Flutter 데모 앱 만들기: Firebase 인증 추가]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-20</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-20</guid>
            <pubDate>Fri, 21 Aug 2026 09:05:27 GMT</pubDate>
            <description><![CDATA[<h1 id="day-20--flutter-데모-앱-만들기-firebase-인증-추가">DAY 20 | Flutter 데모 앱 만들기: Firebase 인증 추가</h1>
<p>Firebase SDK를 사용하는 페이지는 해당 기능에 집중하기 위해 UI를 직접 작성하지 않고 실습 자료의 페이지를 가져다 사용하도록 하겠다. 사용하기에 앞서 어떻게 구성되어 있는지 살펴본 후 필요한 코드를 추가한다.</p>
<h2 id="로그인-페이지">로그인 페이지</h2>
<p>로그인 페이지의 로고는 <code>Flexible</code> 로 감싸져 있다. 이 녀석은 <code>Expanded</code> 처럼 공간을 확장하거나 축소할 수 있지만 child의 크기를 억지로 잡아늘리지 않고 자기 크기를 유지할 수 있도록 하는 녀석이다. 이것을 사용하여 다음과 같이 키보드가 올라와 공간이 좁아지면 로고 크기가 작아지도록 구현할 수 있다.</p>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/peeeeeter_j/post/7d4d9b2e-fecc-4234-9024-018636e918f0/image.png" alt=""></th>
<th align="center"><img src="https://velog.velcdn.com/images/peeeeeter_j/post/414ea8e7-6514-418d-8746-af9fa4bed2d0/image.png" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">기본 상태</td>
<td align="center">키보드 올라온 상태</td>
</tr>
</tbody></table>
<p>로그인을 위한 email 입력창과 password 입력창은 <code>TextFormField</code> 를 통해 작성되어 있다. 이것들은 <code>TextEditingController</code> 를 사용하여 개별적으로 제어할 수도 있고 <code>Form</code> 으로 묶어서 관리할 수도 있다.</p>
<blockquote>
<h4 id="두-방식의-차이">두 방식의 차이</h4>
<table>
<thead>
<tr>
<th align="center"></th>
<th align="center"><code>TextEditingController</code></th>
<th align="center"><code>Form</code></th>
</tr>
</thead>
<tbody><tr>
<td align="center">장점</td>
<td align="center">입력창의 세부 제어 및 실시간 상태 감지 가능</td>
<td align="center">다중 입력창의 유효성 검사 및 일괄 데이터 수집 가능</td>
</tr>
<tr>
<td align="center">단점</td>
<td align="center">보일러플레이트 코드가 많고 일괄 처리 불편</td>
<td align="center">실시간 제어 한계</td>
</tr>
<tr>
<td align="center">적합한 상황</td>
<td align="center">검색창, 실시간 글자수 제한, OTP 인증번호 입력 등</td>
<td align="center">회원가입, 로그인, 글쓰기, 설문조사 등 데이터 제출 폼</td>
</tr>
</tbody></table>
</blockquote>
<p><code>TextEditingController</code> 사용 시 유효성 검사 로직도 일일이 작성해야 하고 종료 시 <code>dispose()</code> 를 해야 하는 등 작성해야 할 게 많지만 더 많은 기능을 사용할 수 있다.
인간 개발자는 <code>Form</code> 을 사용하는 게 합리적인 상황도 AI와 함께 하면 <code>TextEditingController</code> 방식도 괜찮을 것 같기도 하고. 아직까지는 trade-off 관점에서 상황에 맞게 사용하는 것 같다.</p>
<h3 id="form-으로-사용자-입력-제어하기"><code>Form</code> 으로 사용자 입력 제어하기</h3>
<p><code>Form</code> 을 사용할 때는 <code>GlobalKey&lt;FormState&gt;</code> 가 필요하다. <code>LoginPage</code> class의 property로 다음과 같은 key를 추가한다.</p>
<blockquote>
<p><code>lib/login_page.dart</code> &gt; <code>LoginPage</code></p>
</blockquote>
<pre><code class="language-dart">final GlobalKey&lt;FormState&gt; _formKey = GlobalKey&lt;FormState&gt;();</code></pre>
<p><code>TextFormField</code> 를 를 포함한 <code>Column</code> 째로 <code>Form</code> 으로 감싸고 <code>key</code> 에 방금 만든 key를 전달한다.</p>
<blockquote>
<p><code>lib/login_page.dart</code> &gt; <code>LoginPage</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
return Scaffold(
  body: SafeArea(
    child: Padding(
      padding: const EdgeInsets.all(16.0),
      child: Form(
        key: _formKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // TextFormField를 비롯한 widget들
          ],
        ),
      ),
    ),
  ),
);
// 뒷 부분 생략</code></pre>
<p>이제 <code>_formKey</code> 를 통해 입력창에 입력된 텍스트를 한 번에 검증하거나 입력된 값을 일괄적으로 변수에 저장하는 등의 작업을 수행할 수 있다.</p>
<p>검증 코드는 <code>TextFormField</code> 의 <code>validator</code> property에 <code>String? Function(String?)</code> callback function으로 작성하며 검증 오류 메시지를 return한다. 오류가 없다면 <code>null</code> 을 반환한다.</p>
<p>값을 변수에 저장하는 코드는 <code>TextFormField</code> 의 <code>onSaved</code> property에 <code>void Function(String?)</code> callback function으로 작성한다.</p>
<p>위 두 개의 callback function은 모두 입력된 텍스트를 parameter로 받는다.</p>
<blockquote>
<p><code>lib/login_page.dart</code> &gt; <code>LoginPage</code> &gt; email 입력창</p>
</blockquote>
<pre><code class="language-dart">TextFormField(
  style: TextStyle(color: Colors.black),
  decoration: InputDecoration(
    labelText: &#39;Email&#39;,
    labelStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.w500,),
    filled: true,
    fillColor: Colors.grey.shade200,
    hintText: &#39;example@example.com&#39;,
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(10),
      borderSide: BorderSide.none,
    ),
    prefixIcon: Icon(Icons.email_outlined),
  ),
  onSaved: (value) {
    _email = value;
  },
  validator: (value) {
    if (value == null || value.isEmpty) {
      return &#39;텍스트를 입력하세요.&#39;;
    }
    return null;
  },
),</code></pre>
<blockquote>
<p><code>lib/login_page.dart</code> &gt; <code>LoginPage</code> &gt; password 입력창</p>
</blockquote>
<pre><code class="language-dart">TextFormField(
  style: TextStyle(color: Colors.black),
  decoration: InputDecoration(
    labelText: &#39;Password&#39;,
    labelStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.w500,),
    filled: true,
    fillColor: Colors.grey.shade200,
    prefixIcon: Icon(Icons.lock_outline, color: Colors.black),
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(10),
      borderSide: BorderSide.none,
    ),
  ),
  obscureText: true,
  onSaved: (value) {
    _password = value;
  },
  validator: (value) {
    if (value == null || value.isEmpty) {
      return &#39;텍스트를 입력하세요.&#39;;
    }
    if (value.length &lt; 6) {
      return &#39;6자리 이상 입력하세요.&#39;;
    }
    return null;
  },
),</code></pre>
<blockquote>
<h4 id="사용자-입력을-돕는-요소들">사용자 입력을 돕는 요소들</h4>
<table>
<thead>
<tr>
<th align="center">property</th>
<th align="center">description</th>
<th align="center">detail</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>labelText</code></td>
<td align="center">무엇을 입력하는 곳인지 알려주는 이름표</td>
<td align="center">입력창이 비어있을 때는 입력칸 내부에 표시되다가, 입력창을 클릭하거나 글자를 입력하면 입력창 위쪽으로 작게 이동</td>
</tr>
<tr>
<td align="center"><code>hintText</code></td>
<td align="center">어떤 형식으로 입력해야 하는지 예시</td>
<td align="center">입력창 내부 배경에 연한 회색으로 표시되며, 사용자가 글자를 입력하기 시작하면 사라짐</td>
</tr>
<tr>
<td align="center"><code>helperText</code></td>
<td align="center">입력창 아래쪽에 표시되는 부연 설명이나 주의 사항</td>
<td align="center">하단에 작고 연한 글씨로 노출되다가 에러 텍스트 발생 시 사라짐</td>
</tr>
<tr>
<td align="center"><code>errorText</code></td>
<td align="center">유효성 검사에 실패했을 때 나타나는 경고 메시지</td>
<td align="center">입력창 아래쪽에 붉은색 글씨로 표시되며, 동시에 입력창의 테두리와 라벨 등도 함께 붉은색으로 변경되어 시각적 경고</td>
</tr>
</tbody></table>
</blockquote>
<p>로그인 버튼을 누르면 유효성 검사를 수행 후 property에 값을 저장하도록 <code>onPressed</code> 에 callback function을 작성한다.</p>
<blockquote>
<p><code>lib/login_page.dart</code> &gt; <code>LoginPage</code> &gt; 로그인 버튼</p>
</blockquote>
<pre><code class="language-dart">onPressed: () {
  if (_formKey.currentState?.validate() ?? false) {
     _formKey.currentState?.save();
  }
},</code></pre>
<h2 id="회원가입-페이지">회원가입 페이지</h2>
<p>회원가입 페이지도 실습 자료에서 가져오는데, 이 녀석은 아직 우리의 Router에 들어 있지 않으므로 거기에도 추가해 주어야 한다.</p>
<blockquote>
<p><code>lib/workout_router.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
StatefulShellBranch(
  navigatorKey: _settingsNavigatorKey,
  routes: [
    GoRoute(
      path: &#39;/settings&#39;,
      builder: (context, state) =&gt; SettingsPage(),
      routes: [
        GoRoute(
          path: &#39;login&#39;,
          builder: (context, state) =&gt; LoginPage(),
          routes: [
            GoRoute(
              path: &#39;registration&#39;,
              builder: (context, state) =&gt; RegistrationPage(),
            ),
          ],
        ),
      ]
    ),
  ],
),
// 뒷 부분 생략</code></pre>
<p><code>lib/login_page.dart</code> 에서 [Sign up] 버튼을 누르면 <code>&#39;/settings/login/registration&#39;</code> 으로 이동하도록 router를 작성한다.</p>
<blockquote>
<p><code>lib/login_page.dart</code> &gt; <code>LoginPage</code> &gt; 회원가입 버튼</p>
</blockquote>
<pre><code class="language-dart">onPressed: () {
  context.go(&#39;/settings/login/registration&#39;);
},</code></pre>
<h3 id="form-으로-사용자-입력-제어하기-1"><code>Form</code> 으로 사용자 입력 제어하기</h3>
<p>로그인 페이지에서의 코드와 크게 다르지 않다. 특이사항은 &#39;비밀번호 확인&#39; 기능 정도? 이 녀석은 controller를 사용하여 비밀번호 입력창에 들어온 텍스트와 일치하는지 확인해야 한다. 이를 위해 다음과 같은 controller를 추가한다.</p>
<blockquote>
<p><code>lib/registration_page.dart</code> &gt; <code>RegistrationPage</code></p>
</blockquote>
<pre><code class="language-dart">final TextEditingController _passwordController = TextEditingController();</code></pre>
<p>그리고 비밀번호 입력창에 이 controller를 연결한 뒤, 비밀번호 확인 입력창의 검증 코드에 이를 활용하는 코드를 작성한다.</p>
<blockquote>
<p><code>lib/registration_page.dart</code> &gt; <code>RegistrationPage</code> &gt; 비밀번호 입력창 및 비밀번호 확인 입력창</p>
</blockquote>
<pre><code class="language-dart">TextFormField(
  controller: _passwordController,
  decoration: InputDecoration(
    labelText: &#39;비밀번호&#39;,
    labelStyle: textTheme.headlineSmall,
    hintText: &#39;비밀번호를 입력하세요&#39;,
    helperText: &#39;*비밀번호는 6자 이상 입력해주세요.&#39;,
    helperStyle: textTheme.bodySmall?.copyWith(
      color: colorScheme.primary,
    ),
    border: UnderlineInputBorder(),
    floatingLabelBehavior: FloatingLabelBehavior.always,
  ),
  obscureText: true,
  onSaved: (value) {
    _password = value;
  },
  validator: (value) {
    if (value == null || value.isEmpty) {
      return &#39;텍스트를 입력하세요.&#39;;
    }
    if (value.length &lt; 6) {
      return &#39;6자리 이상 입력하세요.&#39;;
    }
    return null;
  },
),
SizedBox(height: 16),
TextFormField(
  obscureText: true,
  decoration: InputDecoration(
    labelText: &#39;비밀번호확인&#39;,
    labelStyle: textTheme.headlineSmall,
    hintText: &#39;비밀번호를 한번 더 입력하세요&#39;,
    border: UnderlineInputBorder(),
    floatingLabelBehavior: FloatingLabelBehavior.always,
  ),
  validator: (value){
    if(value==null || value.isEmpty){
      return &#39;비밀번호를 한번 더 입력하세요.&#39;;
    }else if(value != _passwordController.text){
      return &#39;비밀번호가 일치하지 않습니다.&#39;;
    }
    return null;
  },
),</code></pre>
<h2 id="아키텍처-살펴보기">아키텍처 살펴보기</h2>
<p><a href="https://console.firebase.google.com">Firebase console</a>에서 생성한 프로젝트 콘솔에 들어가서 좌측 메뉴에서 [보안 &gt; Authentiation] 선택하고 [시작하기] 눌러 [로그인 방법]에서 [이메일/패스워드]를 추가한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/654f7f7c-69ad-484b-9cdd-153cb3b5b3cc/image.png" alt=""></p>
<p>이메일/패스워드 방식은 보안 상의 이슈가 있을 수 있다는 알림이 뜨지만 우리는 실습용이기에 이대로 진행한다.</p>
<p><a href="https://pub.dev/packages/firebase_auth">Firebase Auth</a>를 사용하기 위해서는 <code>pubspec.yaml</code> 에 등록한 뒤 [pub get] 해주어야 한다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml">dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
  flex_color_scheme: ^8.4.0
  intl: ^0.20.3
  go_router: ^17.4.0
  shared_preferences: ^2.5.5
  firebase_core: ^4.13.0
  firebase_auth: ^6.5.7</code></pre>
<p>우리는 Backend에 해당하는 Firebase와의 소통을 하는 녀석을 별도로 작성할 것이다. Service라고 칭하며 다음과 같은 구조로 구현된다.</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/2c20900d-9680-4354-87da-4647ef72ef2c/image.png" alt=""></p>
<p>Backend 기능별로 하나의 Service를 만들 건데 일단은 Firebase Auth를 다루는 Service를 작성하도록 하겠다.</p>
<blockquote>
<p><code>lib/firebase_auth_sercice.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:firebase_auth/firebase_auth.dart&#39;;
&gt;
class FirebaseAuthService {
  final FirebaseAuth _auth;
&gt;
  FirebaseAuthService() : _auth = FirebaseAuth.instance {
    _auth.setLanguageCode(&#39;kr&#39;);
  }
&gt;
  Future&lt;void&gt; signUpWithEmail() async {}
&gt;
  Future&lt;void&gt; signInWithEmail() async {}
&gt;
  Future&lt;void&gt; resetPassword() async {}
&gt;
  Future&lt;void&gt; deleteAccount() async {}
&gt;
  Future&lt;void&gt; signOut() async {}
&gt;
  Future&lt;void&gt; updateProfile() async {}
}</code></pre>
<p>이 중에서 먼저 회원가입 기능을 작성하여 버튼에 연결해 보자. Backend와 직접 소통할 경우 암호화, 통신 등을 고려해 주어야 하지만 SDK를 통해 소통하므로 많은 것들이 내부적으로 처리된다. 다만, 네트워크 이슈 등이 있을 수 있기 때문에 <code>try</code>-<code>catch</code> 를 통해 예외처리를 해주는 게 좋다.</p>
<p>일관성 있는 예외처리 규칙은 유지보수를 좋게 한다. 에러 코드에 따른 개별적인 예외처리를 상세하게 해주는 게 좋지만 여기서는 간단하게 하고 넘어가겠다. 에러 코드에 대한 명세는 Backend 개발자가 작성할 의무가 있는데, SDK를 사용할 경우 SDK 문서에서 이를 확인할 수 있다.</p>
<blockquote>
<p><code>lib/firebase_auth_service.dart</code></p>
</blockquote>
<pre><code class="language-dart">Future&lt;void&gt; signUpWithEmail({
  required String email,
  required String password,
  String? name,
}) async {
  String? errorMessage;
  try {
    await _auth.createUserWithEmailAndPassword(
      email: email,
      password: password,
    );
    await _auth.currentUser?.updateDisplayName(name);
    await _auth.currentUser?.sendEmailVerification();
  } on FirebaseAuthException catch (authError) {
    switch (authError.code) {
      case &#39;weak-password&#39;:
        errorMessage=&#39;패스워드가 취약합니다.&#39;;
      case &#39;email-already-in-use&#39;:
        errorMessage=&#39;이미 사용 중인 이메일입니다.&#39;;
      default:
        errorMessage=authError.message;
    }
  } catch(e) {
    errorMessage = &#39;회원가입 에러: $e&#39;;
  }
  if (errorMessage != null) {
    throw Exception(errorMessage);
  }
}</code></pre>
<p>이제 <code>RegistrationPage</code> 의 회원가입 버튼에서 입력창의 내용을 property에 저장한 후 회원가입을 수행하도록 작성할 수 있다.</p>
<blockquote>
<p><code>lib/registration_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;firebase_auth_service.dart&#39;;
&gt;
class RegistrationPage extends StatelessWidget {
  RegistrationPage({super.key});
  final _formKey = GlobalKey&lt;FormState&gt;();
  String? _name;
  String? _email;
  String? _password;
  final TextEditingController _passwordController = TextEditingController();
  final FirebaseAuthService _auth = FirebaseAuthService();
&gt;
  //생략
}</code></pre>
<blockquote>
<p><code>lib/registration_page.dart</code> &gt; <code>RegistrationPage</code> &gt; 회원가입 버튼</p>
</blockquote>
<pre><code class="language-dart">onPressed: () {
  if (_formKey.currentState!.validate()) {
    _formKey.currentState!.save();
    _auth.signUpWithEmail(
      email: _email!,
      password: _password!,
      name: _name,
    );
  }
},</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/35dad98f-bad9-4209-8d24-32eb7ad16bd3/image.png" alt=""></p>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/edbeb9b9341edd1d8f8bb0f3ae6a66e574752887">&gt;&gt;&gt; GitHub Repository at this point (edbeb9b)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 19 | Flutter 데모 앱 만들기: 비휘발 데이터 사용하기]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-19</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-19</guid>
            <pubDate>Fri, 14 Aug 2026 14:11:27 GMT</pubDate>
            <description><![CDATA[<h1 id="day-19--flutter-데모-앱-만들기-비휘발-데이터-사용하기">DAY 19 | Flutter 데모 앱 만들기: 비휘발 데이터 사용하기</h1>
<p>우리는 <a href="https://pub.dev/packages/shared_preferences">shared_preferences</a>를 통해 앱이 종료된 후에도 데이터가 남아있도록 구현할 것이다. 이 녀석은 앱 바깥 로컬 영역에 데이터를 저장하는데, 덮어씌워질 수도 있고 날아갈 수도 있어서 안전성은 조금 떨어진다. 주로 환경 설정 데이터처럼 데이터가 날아가도 default 값을 사용하여 운영할 수 있는 데이터를 담아둔다.</p>
<p>지금까지 우리가 한 것과 같은 앱 boundary 내 통신은 메모리 통신이기 때문에 속도가 빠르다. 앱 바깥에 저장되어 있는 로컬 데이터와 통신할 때는 속도가 느려지고 리모트 데이터와 통신할 땐 훨씬 더 느리다. 따라서 외부 데이터 통신을 할 땐 concurrency를 통한 제어를 해야 한다.</p>
<p><code>Future</code> 로 감싼 채 전달된 비동기 데이터를 화면에 뿌리는 방법은 여러 가지가 있지만 가장 쉬운 건 <code>FutureBuilder</code> 다. 필요에 따라 나중엔 난이도가 높지만 손이 덜 가는 방식을 쓰게 될 수도 있지만 이걸로도 충분히 잘 구현할 수 있다.</p>
<h2 id="futurebuilder-를-사용한-widget"><code>FutureBuilder</code> 를 사용한 widget</h2>
<p><code>FutureBuilder</code> 는 <code>future</code> property와 <code>builder</code> property를 필수로 갖는다. <code>future</code> property는 <code>Future&lt;T&gt;</code> type이며, 이 녀석의 Generic type을 <code>FutureBuilder&lt;T&gt;</code> 로 명시해 주는 게 일반적이다. <code>builder</code> property는 <code>Widget Function(BuildContext, AsyncSnapshot&lt;T&gt;)</code> type이며, <code>future</code> property에 전달된 값을 적절히 활용하여 widget을 build하는 코드를 가진다.</p>
<p>우리는 <a href="https://velog.io/@peeeeeter_j/sesac-2026-flutter-06">Dart에서 <code>Future</code> 를 배울 때</a> <code>Future</code> 가 세 가지 상태를 갖는다는 것을 배웠다.</p>
<blockquote>
</blockquote>
<ul>
<li>Uncompleted <span style="color:#787878"><em>(아직 asynchronous 작업이 끝나지 않음)</em></span></li>
<li>Completed<ul>
<li>Completed with a value <span style="color:#787878"><em>(asynchronous 작업이 정상 종료됨)</em></span></li>
<li>Completed with an error <span style="color:#787878"><em>(asynchronous 작업 중 오류 발생)</em></span></li>
</ul>
</li>
</ul>
<p>이에 따라 <code>builder</code> property에 전달되는 callback function에도 각 상태에 따른 코드를 작성해 주어야 한다. 따라서 <code>FutureBuilder</code> 의 기본 형태는 다음과 같다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">FutureBuilder&lt;Object?&gt;(
  future: myFuture,
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      // Uncompleted 상태에 대한 처리
    }
    if (snapshot.hasError) {
      // Completed with an error 상태에 대한 처리
    }
    // Completed with a value 상태에 대한 처리
    data = snapshot.data;
    return Container();
  },
)</code></pre>
<p>메인 화면 대시보드의 오늘의 운동 시간을 <code>FutureBuilder</code> 로 감싸 보자. 오늘의 운동 시간을 담은 <code>DashboardCard</code> 의 <code>info</code> property로 전달된 <code>Column</code> 을 <code>FutureBuilder</code> 로 감쌀 것이다. 마우스 호버 시 전구 모양으로 뜨는 Context Action에서 [Wrap with FutureBuilder]를 선택하여 감쌀 수 있다.</p>
<p><code>future</code> 에는 <code>_dailyMinutes</code> 에 해당하는 <code>int</code> 값이 들어가므로 우리의 <code>FutureBuilder</code> 는 <code>FutureBuilder&lt;int&gt;</code> 로 작성한다. <code>future</code> 에 <code>WorkoutManager.getTodayWorkoutMinutes()</code> 를 직접 전달하여 이 녀석이 return하는 <code>Future&lt;int&gt;</code> 를 <code>future</code> property에 담아도 되지만, 이렇게 할 경우 function이 필요 이상으로 자주 호출되는 이슈가 발생할 수 있다. 지금 상황에서는 크게 문제되지 않지만 비용이 많이 드는 remote database를 사용하는 등의 상황에서는 리소스 낭비가 문제로 이어질 수 있다. 따라서 별도의 variable을 선언하여 사용하도록 한다.</p>
<p>variable을 선언하여 사용할 경우 값이 자동으로 갱신되지 않기 때문에 lifecycle function을 사용해야 한다. <code>initState()</code> 를 통해 최초로 생성될 때 <code>WorkoutManager.getTodayWorkoutMinutes()</code> 를 호출하게 하고,<code>didUpdateWidget()</code> 을 통해 <code>WorkoutHomePage</code> 로의 화면 이동이 있을 때 또 호출하게 할 수 있다. go_router 방식의 특성 상 다른 화면에 다녀 오면 widget tree가 갱신되어 <code>didUpdateWidget()</code> 이 trigger되기 때문이다.</p>
<p><code>builder</code> property에 전달되는 callback function에는 <code>Future</code> 의 상태에 따른 예외처리 코드를 추가해 준다.</p>
<blockquote>
<p><code>lib/workout_home_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
&gt;
class _WorkoutHomePageState extends State&lt;WorkoutHomePage&gt; {
  // final int _dailyMinutes = 450;
  final int _dailyGoal = 500;
  final int _dailyKcal = 2400;
  final int _monthlyHours = 403;
  final int _monthlyGoal = 450;
  final int _lastMonthlyHours = 393;
&gt;
  final NumberFormat commaThousands = NumberFormat.decimalPattern();
&gt;
  late Future&lt;int&gt; _todayMinutes;
&gt;
  @override
  void initState() {
    super.initState();
    _todayMinutes = WorkoutManager.getTodayWorkoutMinutes();
  }
&gt;
  @override
  void didUpdateWidget(covariant WorkoutHomePage oldWidget) {
    super.didUpdateWidget(oldWidget);
    _todayMinutes = WorkoutManager.getTodayWorkoutMinutes();
  }
&gt;
  // 생략
}</code></pre>
<blockquote>
<p><code>lib/workout_home_page.dart</code> &gt; <code>_WorkoutHomePageState</code> &gt; <code>build()</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
info: FutureBuilder&lt;int&gt;(
  future: _todayMinutes,
  builder: (context, snapshot) {
    if (snapshot.connectionState ==
        ConnectionState.waiting) {
      return Center(child: CircularProgressIndicator());
    }
    if (snapshot.hasError) {
      return Center(child: Text(&#39;Data Error&#39;));
    }
    int? data = snapshot.data;
    return Column(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        Stack(
          alignment: Alignment.center,
          children: [
            SizedBox(
              width: 120,
              height: 120,
              child: CircularProgressIndicator(
                value: data! / _dailyGoal,
                color: Colors.blue,
                backgroundColor:
                    colorScheme.outlineVariant,
              ),
            ),
            Padding(
              padding: EdgeInsetsGeometry.symmetric(
                vertical: 20,
              ),
              child: Text.rich(
                textAlign: TextAlign.center,
                TextSpan(
                  children: [
                    TextSpan(
                      text: &#39;운동 시간\n&#39;,
                      style: textTheme.titleMedium
                          ?.copyWith(
                            color: colorScheme.outline,
                          ),
                    ),
                    TextSpan(
                      text: &#39;$data분&#39;,
                      style: textTheme.titleLarge
                          ?.copyWith(
                            color: Colors.blue,
                            fontWeight: FontWeight.bold,
                          ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
        Text.rich(
          textAlign: TextAlign.center,
          TextSpan(
            children: [
              TextSpan(
                text: &#39;소모 칼로리\n&#39;,
                style: textTheme.bodyMedium?.copyWith(
                  color: colorScheme.outline,
                  ),
              ),
              TextSpan(
                text:
                    &#39;${commaThousands.format(_dailyKcal)} kcal&#39;,
                style: textTheme.bodyLarge?.copyWith(
                  color: Colors.blue,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ],
          ),
        ),
      ],
    );
  },
),
// 뒷 부분 생략</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/3b36a8a2-8953-4c17-926c-dd771f25add9/image.png" alt=""></p>
<blockquote>
</blockquote>
<p>좌측은 아무 운동이 이루어지지 않은 상태. 우측은 운동 하나 하고 나온 상태.</p>
<h3 id="여러-개의-데이터를-사용하는-futurebuilder">여러 개의 데이터를 사용하는 <code>FutureBuilder</code></h3>
<p>지금은 오늘의 운동 시간만 shared_preferences 및 <code>FutureBuilder</code> 를 사용하도록 구현되어 있다. 그런데 논리적으로 생각할 때 비휘발 데이터로 로컬에 저장되어야 할 것들이 더 남아 있다.</p>
<blockquote>
</blockquote>
<ul>
<li>오늘의 운동 시간 (구현 완료)</li>
<li>오늘의 소모 칼로리</li>
<li>이번 달 운동 시간</li>
<li>지난 달 운동 시간 (&quot;N시간 더 했어요&quot; 용)</li>
<li>마지막으로 하던 운동 ([운동 이어서 하기] 용)</li>
</ul>
<p>오늘의 운동 시간과 오늘의 소모 칼로리는 하나의 대시보드 카드에 묶여 있는 연관 데이터이기 때문에 <code>FutureBuilder</code> 를 공유하도록 구현하면 좋을 것 같다. 마찬가지로 우측 월간 운동 시간 카드도 하나의 <code>FutureBuilder</code> 를 사용한다. 그리고 하단의 [운동 이어서 하기]도 하나의 <code>FutureBuilder</code> 를 사용하면 좋을 것 같다.</p>
<p>개별 <code>FutureBuilder</code> 를 따로 구현하는 건 지금까지 배웠던 걸로 할 수 있는데 두 개 이상의 데이터가 묶여 있는 <code>FutureBuilder</code> 는 어떻게 구현할 수 있을지 생각해 보아야 한다.</p>
<p>여기서 쓸 만한 방식은 class와 record가 있다. 가독성과 타입 안정성 등을 고려했을 때 class로 구현하는 게 가장 좋지만, boilerplate가 많아 개발 performance가 떨어진다. record를 사용하는 게 그럭저럭 합리적인 지점이다.</p>
<p>record는 Python의 tuple과 비슷한 느낌이다. 생성 후 값을 바꿀 수 없고, 서로 다른 type의 데이터를 한 번에 담을 수 있어 function에서 여러 개의 값을 return 하고자 할 때 유용하다는 공통점이 있다.</p>
<p>Dart의 record는 Python의 tuple과 달리 이름을 지정할 수 있어 Map에 타입 안정성을 더한 절충안으로 쓸 수 있을 것 같다. 보통은 이름 없이 positional로 쓴 다는 모양이지만.</p>
<p>Python이었으면 tuple을 썼을 것 같고, Rust였으면 <code>impl</code> 없는 <code>struct</code> 를 썼을 것 같다. class는 struct보다 boilerplate가 많은 느낌이라 애매하군. 여기선 record가 확실히 합리적일 것 같다.</p>
<p>데이터가 같이 바뀔 수도 있고 따로 바뀔 수도 있는 경우라면 각각을 읽고 쓰는 static method를 구현하고 하나로 묶어주어야겠지만, 어차피 운동 하나 하면 시간과 칼로리는 같이 오를 테니 기존에 작성한 <code>getTodayWorkoutMinutes()</code> 와    <code>increaseTodayWorkoutMinutes()</code> 를 수정하도록 하겠다.</p>
<blockquote>
<p><code>lib/workout_home_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
import &#39;package:intl/intl.dart&#39;;
import &#39;dashboard_card.dart&#39;;
import &#39;workout_manager.dart&#39;;
&gt;
class WorkoutHomePage extends StatefulWidget {
  const WorkoutHomePage({super.key});
&gt;
  @override
  State&lt;WorkoutHomePage&gt; createState() =&gt; _WorkoutHomePageState();
}
&gt;
class _WorkoutHomePageState extends State&lt;WorkoutHomePage&gt; {
  // final int _dailyMinutes = 450;
  final int _dailyGoal = 500;
  // final int _dailyKcal = 2400;
  final int _monthlyHours = 403;
  final int _monthlyGoal = 450;
  final int _lastMonthlyHours = 393;
&gt;
  final NumberFormat commaThousands = NumberFormat.decimalPattern();
&gt;
  // late Future&lt;int&gt; _todayMinutes;
  late Future&lt;({int calories, int minutes})&gt; _todayData;
&gt;
  @override
  void initState() {
    super.initState();
    // _todayMinutes = WorkoutManager.getTodayWorkoutMinutes();
    // WorkoutManager.resetTodayWorkoutData();
    _todayData = WorkoutManager.getTodayWorkoutData();
  }
&gt;
  @override
  void didUpdateWidget(covariant WorkoutHomePage oldWidget) {
    super.didUpdateWidget(oldWidget);
    // _todayMinutes = WorkoutManager.getTodayWorkoutMinutes();
    _todayData = WorkoutManager.getTodayWorkoutData();
  }
&gt;
  // 생략
}</code></pre>
<blockquote>
<p><code>lib/workout_home_page.dart</code> &gt; <code>_WorkoutHomePageState</code> &gt; <code>build()</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
info: FutureBuilder&lt;({int minutes, int calories})&gt;(
  // future: _todayMinutes,
  future: _todayData, // MODIFIED!
  builder: (context, snapshot) {
    if (snapshot.connectionState ==
        ConnectionState.waiting) {
      return Center(child: CircularProgressIndicator());
    }
    if (snapshot.hasError) {
      return Center(child: Text(&#39;Data Error&#39;));
    }
    // int? data = snapshot.data;
    final workoutMinutes = snapshot.data?.minutes ?? 0; // MODIFIED!
    final workoutCalories = snapshot.data?.calories ?? 0; // MODIFIED!
    return Column(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        Stack(
          alignment: Alignment.center,
          children: [
            SizedBox(
              width: 120,
              height: 120,
              child: CircularProgressIndicator(
                value: workoutMinutes / _dailyGoal, // MODIFIED!
                color: Colors.blue,
                backgroundColor:
                    colorScheme.outlineVariant,
              ),
            ),
            Padding(
              padding: EdgeInsetsGeometry.symmetric(
                vertical: 20,
              ),
              child: Text.rich(
                textAlign: TextAlign.center,
                TextSpan(
                  children: [
                    TextSpan(
                      text: &#39;운동 시간\n&#39;,
                      style: textTheme.titleMedium
                          ?.copyWith(
                            color: colorScheme.outline,
                          ),
                    ),
                    TextSpan(
                      text: &#39;$workoutMinutes분&#39;,  // MODIFIED!
                      style: textTheme.titleLarge
                          ?.copyWith(
                            color: Colors.blue,
                            fontWeight: FontWeight.bold,
                          ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
        Text.rich(
          textAlign: TextAlign.center,
          TextSpan(
            children: [
              TextSpan(
                text: &#39;소모 칼로리\n&#39;,
                style: textTheme.bodyMedium?.copyWith(
                  color: colorScheme.outline,
                  ),
              ),
              TextSpan(
                text:
                    &#39;${commaThousands.format(workoutCalories)} kcal&#39;, // MODIFIED!
                style: textTheme.bodyLarge?.copyWith(
                  color: Colors.blue,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ],
          ),
        ),
      ],
    );
  },
),
// 뒷 부분 생략</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/47aea544-62f3-40a5-a64e-d62d24bac1ed/image.png" alt=""></p>
<p>여기선 월간 운동 카드 데이터까지 다루지는 않겠다.</p>
<h3 id="운동-이어서-하기">운동 이어서 하기</h3>
<p><code>WorkoutGuidePage</code> 에 들어갈 때마다 현재 <code>groupIndex</code> 및 <code>workoutIndex</code> 를 저장하고 [운ㄷ농 이어서 하기]를 누르면 해당 운동으로 이동하는 로직을 작성한다. 처음에는 <code>WorkoutListPage</code> 에 들어가면 <code>groupIndex</code> 를, <code>WorkoutGuidePage</code> 에 들어가면 <code>workoutIndex</code> 를 저장하게 할까 했는데, 언젠가 deep link를 통해 <code>WorkoutGuidePage</code> 에 들어가는 상황이 발생할 경우 정상적으로 작동하지 않게 되어 버리기 때문에 이와 같이 작성하였다.</p>
<p>저장되어 있는 값이 없을 경우 읽어들일 때 <code>-1</code> 이라는 값이 들어가는데, 이 값이 전달되었을 경우에는 최근 운동이 존재하지 않는다는 알림을 띄우도록 했다.</p>
<blockquote>
<p><code>lib/workout_mananger.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
&gt;
  static Future&lt;({int groupIndex, int workoutIndex})&gt; getRecentWorkout() async {
    int recentGuideIndex = await asyncPrefs.getInt(&#39;recentGuideIndex&#39;) ?? -1;
    int recentWorkoutIndex = await asyncPrefs.getInt(&#39;recentWorkoutIndex&#39;) ?? -1;
    return (groupIndex: recentGuideIndex, workoutIndex: recentWorkoutIndex);
  }
&gt;
  static Future&lt;void&gt; setRecentWorkout(int groupIndex, int workoutIndex) async {
    await asyncPrefs.setInt(&#39;recentGuideIndex&#39;, groupIndex);
    await asyncPrefs.setInt(&#39;recentWorkoutIndex&#39;, workoutIndex);
  }
&gt;
// 뒷 부분 생략</code></pre>
<blockquote>
<p><code>lib/workout_home_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
Expanded(
  flex: 2,
  child: DashboardCard(
    routeOnTap: () {
      continueWorkout(); // NEW!
    },
    labelIcon: Icon(
      Icons.repeat_outlined,
      size: textTheme.titleMedium?.fontSize,
      color: Colors.blue,
      fontWeight: FontWeight.bold,
    ),
    labelText: Text(
      &#39;운동 이어서 하기&#39;,
      style: textTheme.titleMedium?.copyWith(
        color: Colors.blue,
        fontWeight: FontWeight.bold,
      ),
    ),
    info: Stack(
      children: [
        Align(
          alignment: Alignment.bottomLeft,
          child: Image.asset(&#39;assets/home/continue.png&#39;),
        ),
        Align(
          alignment: Alignment.centerRight,
          child: Padding(
            padding: EdgeInsetsGeometry.only(right: 20),
            child: Text(
              &#39;당신의 몸은 해낼 수 있다.\n당신의 마음만 설득하면 된다.&#39;,
              style: textTheme.titleLarge,
            ),
          ),
        ),
      ],
    ),
  ),
),
// 뒷 부분 생략</code></pre>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
  @override
  void initState() {
    super.initState();
    _player.setReleaseMode(ReleaseMode.loop);
    workouts = WorkoutManager.groups[widget.groupIndex].workouts;
    _workoutIndex = widget.workoutIndex;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
&gt;
    WorkoutManager.increaseTodayWorkoutData(minutes: _currentWorkout.minutes, calories: _currentWorkout.kcal);
    WorkoutManager.setRecentWorkout(widget.groupIndex, _workoutIndex); // NEW!
  }
&gt;
// 생략
&gt;
  void _next() {
    _workoutIndex = (_workoutIndex + 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
    WorkoutManager.increaseTodayWorkoutData(minutes: _currentWorkout.minutes, calories: _currentWorkout.kcal);
    WorkoutManager.setRecentWorkout(widget.groupIndex, _workoutIndex); // NEW!
  }
&gt;
  void _prev() {
    _workoutIndex = (_workoutIndex - 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
    WorkoutManager.increaseTodayWorkoutData(minutes: _currentWorkout.minutes, calories: _currentWorkout.kcal);
    WorkoutManager.setRecentWorkout(widget.groupIndex, _workoutIndex); // NEW!
  }
// 뒷 부분 생략</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/31c5e892-d0ad-44c3-8b4b-f059a81f46c0/image.png" alt=""></p>
<h2 id="firebase-설정">Firebase 설정</h2>
<p>Firebase는 앱 개발 시 backend를 서비스로서 제공해 준다. 이런 걸 BaaS(Backend as a Service)라고 한다. 처음에는 RealtimeDatabase 및 부가적인 몇 가지로 시작된 서비스인데 점점 확장되어 이제는 중소형 앱 서비스 수준에서 흔히 선택하는 서비스가 되었다.</p>
<blockquote>
<h4 id="백엔드를-구측하는-것과의-차이">백엔드를 구측하는 것과의 차이</h4>
<table>
<thead>
<tr>
<th align="center">백엔드 서버 구측</th>
<th align="center">Firebase 사용</th>
</tr>
</thead>
<tbody><tr>
<td align="center">낮은 서버 비용</td>
<td align="center">높은 서버 비용</td>
</tr>
<tr>
<td align="center">새로운 게 필요해도 인프라 비용 추가 안됨</td>
<td align="center">새로운 거 추가되는 만큼 인프라 비용 추가</td>
</tr>
<tr>
<td align="center">높은 인건비</td>
<td align="center">낮은 인건비</td>
</tr>
<tr>
<td align="center">규모가 큰 앱 개발 시 유리</td>
<td align="center">중소형 서비스 개발 시 유리</td>
</tr>
</tbody></table>
</blockquote>
<p>보통은 규모가 커지면 앱 자체를 리뉴얼하기 때문에 대형 서비스를 목표로 하더라도 Firebase로 시작하여 추후 서버 비용이 인건비를 넘어서는 규모가 되었을 때 그 외의 아키텍처도 재설계하며 리뉴얼하는 게 유리하다.</p>
<p>Firebase 설정을 해 보자. 나는 지금은 방치되어 있는 옛 <a href="https://danhwi.web.app">포트폴리오 사이트</a>를 Firebase로 호스팅하기도 했고 학생 때 건드려 보던 것도 있어 언젠가의 흔적들이 좀 있더라. 아무튼 [새 프로젝트 만들기] 를 눌러 프로젝트를 생성한다.</p>
<p>이때, 프로젝트 이름은 중복될 수 있지만 프로젝트 ID는 중복되지 않는다. 내 프로젝트뿐만 아니라 전세계 모두에게서 중복될 수 없다. 프로젝트 ID는 기본적으로 프로젝트 이름과 동일하게 설정되지만 기존에 존재하는 프로젝트와 이름이 겹칠 경우 임의의 해시 값이 뒤에 붙는다.</p>
<p>프로젝트 이름을 지을 때는 중복된 이름의 프로젝트가 없었지만 최종적으로 생성 버튼을 눌렀을 때 그 사이에 그 이름을 가진 프로젝트가 생성된다면 ID 충돌이 발생하여 오류가 발생할 수 있다. (그렇기 때문에 강사님과는 다른 이름으로 프로젝트를 생성했는데 그 사이에 어느 수강생이 같은 이름으로 프로젝트 생성을 해버렸는지 강사님이 ID 충돌을 겪으시더라.)</p>
<p>하여간 프로젝트를 생성하고 나면 다음과 같은 화면이 뜬다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/f97bc3c2-1d80-499c-a495-2a05d7db2480/image.png" alt=""></p>
<p>앱 추가 버튼을 누르고 Flutter 프로젝트를 선택한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/2a2d5506-7352-4d30-9918-adb8c011baf1/image.png" alt=""></p>
<p>Firebase는 이미 설치되어 있으니 그냥 다음을 누르면 되고, FlutterFire는 프로젝트 root에서 활성화 해주도록 하자.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/85171817-de58-4202-b95d-3ad5be4fe09c/image.png" alt=""></p>
<p>이때, Flutter 프로젝트 연결이 처음이라면 다음과 같은 메시지가 뜨는데, 환경변수 설정을 해주어야 FlutterFire CLI를 사용할 수 있다는 것을 놓치지 말도록 하자. </p>
<blockquote>
</blockquote>
<pre><code class="language-bash">Warning: Pub installs executables into $HOME/.pub-cache/bin, which is not on your path.
You can fix that by adding this to your shell&#39;s config file (.zshrc, .bashrc, .bash_profile, etc.):
&gt;
  export PATH=&quot;$PATH&quot;:&quot;$HOME/.pub-cache/bin&quot;
&gt;
Activated flutterfire_cli 1.4.1.</code></pre>
<p>강사님은 기존에 프로젝트를 했었기에 이런 경고가 뜨지 않았기에 수강생들이 안 된다고 할 때 당황하신 듯하다. 나는 이게 떴을 때 습관처럼 <code>~/.zshrc</code> 에 저걸 붙여 넣었는데 개발이 익숙하지 않은 분들은 이걸 쉽게 놓치는 모양이다.</p>
<span style="color:#aaa">
이런 걸 쉽게 하는 것도 그렇고 난 역시 클라이언트 쪽보다는 백엔드, 시스템, 데브옵스, 이런 쪽에 흥미와 적성이 있는 것 같다는 생각이 든다. 단지 Java를 너무 싫어해서 Java/Spring을 피하다 보니 갈 곳을 잃었을 뿐. 아무래도 Rust나 Python/FastAPI는 주류가 되긴 힘드니까. 차라리 개발자의 범위를 벗어나 시스템/데브옵스 쪽으로 가는 게 나을까 싶다가도 거긴 대체로 백엔드 경력이 있는 사람을 원하지 신입을 원하진 않는다. 생각이 많아진다.
<br><br>
</span>



<span style="color:#aaa">
흥미와 적성에 맞진 않아도 그럭저럭은 할 수 있는 클라이언트 개발을 밀고 나가는 게 맞는지, 다른 길을 찾는 게 맞는지. 사실 더 생각하다가는 생각만 하고 행동하지 않을 것 같아서, 그래도 뭐라도 시도해 보는 게 낫겠지 싶어서 삼성디딤돌장학금 준다길래 SeSAC에 그나마 개설된 개발 과정을 신청했던 거긴 하다.
</span>

<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/ce010290-c1f4-4fe4-bfd5-ebb1aaa0bfe4/image.png" alt=""></p>
<p><a href="https://pub.dev/packages/firebase_core">firebase_core</a>를 가져와서 [pub get] 해준 뒤 <code>lib/main.dart</code> 에 코드를 추가한다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:firebase_core/firebase_core.dart&#39;;
import &#39;firebase_options.dart&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:flex_color_scheme/flex_color_scheme.dart&#39;;
import &#39;workout_router.dart&#39;;
&gt;
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}
&gt;
// 이하 생략</code></pre>
<p>이대로 Restart를 하면 Minimal SDK 오류가 뜬다. 안드로이드 애뮬레이터는 잘 build 되었지만 iOS 시뮬레이터는 15.0 이상의 platform version이 필요한 모양이다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/4cf8e8b2-3dbf-4afb-a3ec-a34531ea82a2/image.png" alt=""></p>
<p><code>ios/Runner.xcworkspace</code> 를 XCode에서 열어서 [Targets &gt; Runner]의 [Minimum Deployments]를 15.0 이상의 값으로 수정한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/e68ceba3-5af6-49e7-880f-b367685f720b/image.png" alt=""></p>
<p>수정 후 다시 시도하면 잘 실행된다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/c6d3ca3f-6c62-4974-8db1-c525f1ded4a8/image.png" alt=""></p>
<p>만약 안드로이드에서도 비슷한 오류가 발생한다면 <code>android/app/build.gradle.kts</code> 에서 <code>android.defaultConfig.minSdk</code> 를 적절한 값으로 수정하면 된다.</p>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/51fa9e48b9da28411a7a594003b814142005b1e3">&gt;&gt;&gt; GitHub Repository at this point (51fa9e4)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 18-1 | bottom navigation 구현에는 StatefulShellRoute 를 사용한다]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-18-1</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-18-1</guid>
            <pubDate>Wed, 12 Aug 2026 11:08:49 GMT</pubDate>
            <description><![CDATA[<h1 id="day-18-1--bottom-navigation-구현에는-statefulshellroute-를-사용한다">DAY 18-1 | bottom navigation 구현에는 StatefulShellRoute 를 사용한다</h1>
<p>요즘 모바일 앱에는 bottom navigation이 포함되어 있는 경우가 많다. 인스타그램의 [홈 / 릴스 / 메시지 / 탐색 / 프로필]도 그렇고 브런치스토리의 [홈 / 발견 / 독서클럽 / 구독 / 내 서랍]이라거나 텔레그램의 [대화 / 연락처 / 설정 / 프로필]처럼 말이다. 물론 모든 모바일 앱이 이런 형태인 건 아니고 디스코드처럼 bottom navigation이 없는 형태의 앱도 얼마든지 있을 수 있다.</p>
<p>Flutter에서는 bottom navigation을 구현할 때 <code>StatefulShellRoute</code> 를 사용한다. 다른 방식으로도 구현할 수 있긴 하지만 이게 가장 효율적이기 때문에 굳이 다른 방식을 고집할 이유가 없다. <code>StatefulShellRoute</code> 는 Flutter의 사실상 표준 routing package인 <code>go_router</code> package에 포함되어 있다.</p>
<h2 id="go_router-package"><code>go_router</code> package</h2>
<p>Flutter의 기본적인 navigation 시스템은 Stack 구조를 기반으로 작동한다. 전통적인 방식의 앱 개발에서는 <code>Navigator.push()</code> 와 <code>Navigator.pop()</code> 을 이용하여 하나의 navigator 위에서 widget을 쌓고 빼는 동작을 수행한다. 그런데 앱의 규모가 커지고, 특히 웹 지원이나 deep link 같은 요구사항이 추가되는 순간 이러한 방식은 꼬이기 쉽다. 같은 화면에 도달하는 다양한 방법이 있다면 앱의 현재 네비게이션 상태를 파악할 수 없게 되며, 특정 페이지에 도달하기 위해 필요 이상으로 많은 화면을 밑에 깔아야 할 수도 있다.</p>
<p>이러한 명령형 방식의 단점을 보완하기 위해 선언형으로 접근하고자 하여 대두된 게 <code>go_router</code> package다. 이 녀석은 특정 path에 어떤 widget이 매핑되는지 사전에 정의해 둔다. <code>context.go(&#39;path_url&#39;)</code> 을 통해 사전 정의된 path에 접근하면 내부적으로 navigation stack을 알아서 재조정하기 때문에 사용자가 어떤 경로로 그 페이지에 도달하든 동일한 path와 stack을 갖는다. 또한 화면을 쌓지 않고 원하는 페이지에 바로 접근하는 deep link를 구현하고자 할 때도 이 path를 통해 쉽게 구현 가능하다.</p>
<p>그런데 명령형 방식에서는 사용자의 행동에 의해 UI flow가 결정되므로 bottom navigation에서 선택하는 것에 따라 내용물을 바꿔 끼우기가 수월했는데, 선언형 방식의 경우 부분적으로 바꿔 끼우는 게 아니라 path가 바뀌면 모든 것을 새로 그리게 되어 공통적인 부분에 대한 구현 비효율이 발생했다. 이를 보완하기 위해 나온 게 <code>ShellRoute</code> 다.</p>
<h2 id="공통-ui를-담은-뼈대-shellroute">공통 UI를 담은 뼈대 <code>ShellRoute</code></h2>
<p>Shell, 그러니까 껍데기다. 이 껍데기에 공통 UI를 구현해 놓고 내용물을 바꿔 끼우기 위한 widget이다. <code>ShellRoute</code> 를 사용하면 bottom navigation이 정의된 <code>Scaffold</code> 를 껍데기로 사용하며 <code>body</code> 의 내용물을 바꿔 끼울 수 있다. 현재 bottom navigation에 어떤 탭이 활성화되어 있는지에 대한 state를 따로 가진 채 선언형 path에 따라 화면을 그릴 수 있게 된 것이다.</p>
<p>하지만 이걸로도 완전하지 않다. 이대로는 홈 화면에서 피드를 살펴보다가 검색 탭으로 이동하여 무언가 찾아본 후 다시 홈 화면으로 돌아왔을 때 기존에 보던 화면에 대한 정보가 완전히 사라지고 화면이 새로 그려진다. 많은 경우 사용자는 원래 보던 걸 마저 보고 싶어하는데 말이다. <del>(사람을 짜증나게 하는 방법에는 두 가지가 있는데 첫 번째는 말을 하다 마는 것이고)</del></p>
<p>이는 <code>ShellRoute</code> 가 bottom navigation의 각 탭에 있는 하위 route를 관리할 때 단일 navigator를 사용하기 때문에 발생하는 일이다. 탭을 이동할 때마다 기존 탭의 route를 제거하고 새 탭의 route를 담기 때문에 기존 탭에 대한 state가 완전히 사라진다.</p>
<p>이를 보완하기 위해 각 탭이 독립적인 history를 가지도록 개별 navigator를 사용하는 방식을 채택한 게 <code>StatefulShellRoute</code> 다.</p>
<h2 id="shellroute-아니고-statefulshellroute"><code>ShellRoute</code> 아니고 <code>StatefulShellRoute</code></h2>
<p><code>StatefulShellRoute</code> 는 각 탭(정확히는 <code>branch</code> 라고 한다)이 서로 다른 독립적인 <code>Navigator</code> object를 갖도록 구현한다. 이로써 운영체제의 multi-thread 프로그래밍에서 각 thread가 고유의 call stack을 가지는 것과 마찬가지로 각 탭은 고유의 navigation stack과 <code>NavigatorState</code> 를 갖는다.</p>
<p><code>StatefulShellRoute</code> 는 두 가지 핵심 component를 통해 각 navigator의 병렬 구조를 제어한다.</p>
<h3 id="독립된-route-context를-가지는-statefulshellbranch">독립된 route context를 가지는 <code>StatefulShellBranch</code></h3>
<p><code>StatefulShellBranch</code> 는 navigation tree의 한 갈래를 나타낸다. 간단히 말해 bottom navigation의 탭 하나당 하나의 <code>StatefulShellBranch</code> 가 존재하는 것이다. <code>StatefulShellRoute</code> 의 <code>branches</code> property가 <code>List&lt;StatefulShellBranch</code> 다. 각 branch는 고유한 <code>GlobalKey&lt;NavigatorState&gt;</code> 를 가지며 독자적인 routing stack을 관리한다.</p>
<p><code>GlobalKey&lt;NavigatorState&gt;</code> 는 각 <code>StatefulShellBranch</code> 의 <code>navigatorKey</code> 에 서로 다른 object으로 하나씩 할당해야 한다. 이때, 최상위 <code>GoRouter</code> 에도 <code>navigatorKey</code> 를 할당해야 함을 유의하자. 이것을 빼먹으면 key가 꼬여서 제대로 작동하지 않는다.</p>
<h3 id="branch간의-context-switch를-위한-statefulnavigationshell">branch간의 context switch를 위한 <code>StatefulNavigationShell</code></h3>
<p><code>StatefulShellRoute</code> 의 <code>builder</code> property에 전달되는 callback function의 세 번째 parameter가 바로 <code>StatefulNavigationShell</code> 이다. 공통 UI를 담는 껍데기로 사용할 widget에 반드시 포함되어 있어야 한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">builder: (context, state, navigationShell) =&gt;
  BottomNavShell(navigationShell: navigationShell),</code></pre>
<p><code>StatefulNavigationShell</code> 은 각각의 branch navigator를 감싸고 관리하는 container다. 현재 활성화된 index를 추적하고 <code>goBranch()</code> method를 통해 branch 간의 전환을 수행한다. 운영체제에서 CPU가 실행 중인 process를 전환할 때 state를 저장하고 복원하는 context switching과 비슷한 역할을 한다고 볼 수 있겠다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
&gt;
class BottomNavShell extends StatelessWidget {
  final StatefulNavigationShell _navigationShell;
  const BottomNavShell({super.key, required this._navigationShell});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _navigationShell.currentIndex,
        destinations: [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: &#39;home&#39;,
          ),
          NavigationDestination(
            icon: Icon(Icons.settings_outlined),
            selectedIcon: Icon(Icons.settings),
            label: &#39;settings&#39;,
          ),
        ],
        onDestinationSelected: (index) {
          _navigationShell.goBranch(
            index,
            initialLocation: index == _navigationShell.currentIndex,
          );
        },
      ),
    );
  }
}</code></pre>
<p>공통 UI를 담는 껍데기로 사용하는 widget이 생성될 때 constructor에서 <code>StatefulNavigationShell</code> object를 주입받고, 그것을 그대로 <code>body</code> 에 넣어 알맹이로 사용한다.</p>
<p>그러는 동시에, bottom navigation에서 현재 활성화된 탭을 나타내는 <code>selectedIndex</code> 에 <code>StatefulNavigationShell</code> object가 가진 <code>currentIndex</code> 값을 넣어 적절한 탭이 활성화되도록 한다.</p>
<p>bottom navigation을 클릭하여 다른 탭으로 넘어가고자 할 때도 <code>onDestinationSelected</code> 에 전달된 callback function에서 <code>StatefulNavigationShell</code> 의 method인 <code>goBranch</code> 를 사용한다.</p>
<p>이때, callback function의 parameter로 전달되는 목적지 index와 <code>StatefulNavigationShell</code> 가 가진 <code>currentIndex</code> 를 비교하여 현재 탭을 클릭했을 경우 해당 route의 root로 이동하도록 구현할 수 있다.</p>
<h3 id="rendering-tree에서-소멸되지-않게-하는-offstage">rendering tree에서 소멸되지 않게 하는 <code>Offstage</code></h3>
<p>일반적으로 화면에서 widget이 사라지면 프레임워크는 Garbage Collector를 사용하여 해당 widget을 rendering tree에서 unmount한다. 그렇게 되면 기껏 독립적인 navigator를 사용해도 이전 탭을 유지하지 못하게 될 수 있다. 따라서 <code>StatefulShellRoute</code> 는 당장 화면에 존재하지 않는 branch에 대해서 rendering tree에서 소멸되지 않게 하기 위해 <code>Offstage</code> 를 사용한다.</p>
<p><code>go_router</code> 가 기본적으로 제공하는 <code>StatefulShellRoute.indexedStack</code> factory constructor를 이용하면 이를 쉽게 구현할 수 있다. <code>IndexedStack</code> widget은 내부적으로 자식 widget들을 rendering tree에 유지한 채, 현재 활성화되지 않은 자식 widget들을 <code>Offstage</code> widget으로 감싼다.</p>
<p><code>Offstage</code> 로 감싸진 widget들은 다른 widget과 마찬가지로 build 된 채 메모리에 상주하고 있지만 공간을 차지하지 않는 것처럼 처리되며, 화면에 그려지는 paint 연산이 생략되고 사용자 입력에 대한 이벤트 수신이 비활성화된다. 화면을 전환할 땐 메모리에 상주 중인 녀석을 다시 build 하지 않고 paint만 새로 해주는 것이다.</p>
<p><code>StatefulShellRoute</code> 를 사용할 땐 숨겨진 모든 탭의 instance를 들고 있어 메모리 점유율 상승할 수 있음을 유의하도록 하자. 요즘 기기 성능이 많이 좋아져 웬만해서는 <code>StatefulShellRoute</code> 를 사용할 때의 이점이 메모리 점유율 상승이라는 단점을 충분히 커버할 수 있을 것으로 판단되긴 한다.</p>
<p>사용자는 앱을 사용할 때 내부의 복잡한 구조를 알지 못한 채 탭을 오갈 때의 UX만을 가지고 앱의 완성도를 판단한다. <code>StatefulShellRoute</code> 를 통해 bottom navigation을 구현하면 이러한 UX적인 완성도를 높일 수 있다.</p>
<blockquote>
<p><a href="https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html">&gt;&gt;&gt; 참고 자료: <strong><code>go_router</code> package &gt; <code>StatefulShellRoute</code> class</strong></a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 18 | Flutter 데모 앱 만들기: 화면 스택을 유지한 채 탭 전환]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-18</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-18</guid>
            <pubDate>Wed, 12 Aug 2026 08:45:05 GMT</pubDate>
            <description><![CDATA[<h1 id="day-18--flutter-데모-앱-만들기-화면-스택을-유지한-채-탭-전환">DAY 18 | Flutter 데모 앱 만들기: 화면 스택을 유지한 채 탭 전환</h1>
<p>지난 시간에 배운 내용을 가볍게 훑고 중간평가를 했다. 수업을 들었다면 금방 풀 수 있는 정도의 문제였다. 교육과정 절차 상 필요한 과정이라나 뭐라나. 개인적으로는 수업에서 이런 것들을 다뤘었지, 하며 가볍게 리마인드하는 느낌이었다.</p>
<h2 id="bottom-navigation-구현하기">bottom navigation 구현하기</h2>
<p><a href="https://velog.io/@peeeeeter_j/sesac-2026-flutter-17">지난 시간</a> 끝자락에 이론적으로만 훑어본 <code>StatefulShellRouter</code> 를 사용하여 bottom navigation을 구현할 것이다. 이것이 <code>StatefulShellRouter</code> 의 주요 usecase라고 하더라.</p>
<h3 id="router-수정하기">Router 수정하기</h3>
<p>기존 우리의 Router는 대략 다음과 같이 구성되어 있었다.</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/ea72bc4b-24d3-4e80-8bcc-8d82831fdf20/image.png" alt=""></p>
<p>이를 다음과 같이 수정할 것이다. bottom navigation을 통해 다른 탭으로 이동해도 기존 탭의 정보를 유지하도록 할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/58590bf3-79d6-4db5-9848-5ad796dc82b1/image.png" alt=""></p>
<p>가령 <code>&#39;workout_home/workout_list/0/workout_guide/2&#39;</code> 에 해당하는 화면에 있다가 setting 탭으로 이동 후 다시 home 탭으로 이동했을 때, branch root인 <code>&#39;workout_home&#39;</code> 으로 돌아가는 게 아니라 <code>&#39;workout_home/workout_list/0/workout_guide/2&#39;</code> 에서 보고 있던 화면으로 돌아올 수 있는 것이다. (물론 원한다면 <code>&#39;workout_home&#39;</code> 으로 돌아가게 구현할 수도 있다.)</p>
<p><code>StatefulShellRoute</code> 를 통해 navigation을 구현하려면 각 탭을 식별하는 key가 필요하다. root에 key가 하나 있어야 하며 navigator 하나당 key가 하나씩 필요하다. <code>StatefulShellRoute</code> 의 <code>branches</code> 로 전달되는 각각의 <code>StatefulShellBranch</code> 가 하나의 navigator이므로 우리는 다음과 같은 세 개의 key가 필요하다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">final GlobalKey&lt;NavigatorState&gt; _rootNavigatorKey = GlobalKey&lt;NavigatorState&gt;(
  debugLabel: &#39;root&#39;,
);
final GlobalKey&lt;NavigatorState&gt; _homeNavigatorKey = GlobalKey&lt;NavigatorState&gt;(
  debugLabel: &#39;home&#39;,
);
final GlobalKey&lt;NavigatorState&gt; _settingsNavigatorKey =
    GlobalKey&lt;NavigatorState&gt;(debugLabel: &#39;settings&#39;);</code></pre>
<p>이렇게 생성한 key를 <code>navigatorKey</code> 로 전달하지 않으면 제대로 작동하지 않으니 빼먹지 말도록 하자.</p>
<p>bottom navigation으로 사용될 widget은 아직 안 만들었으므로 그것을 <code>Placeholder()</code> 로 둔 채 작성하면 다음과 같다.</p>
<blockquote>
<p><code>lib/workout_router.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_home_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
import &#39;workout_guide_page.dart&#39;;
import &#39;settings_page.dart&#39;;
import &#39;login_page.dart&#39;;
&gt;
final GlobalKey&lt;NavigatorState&gt; _rootNavigatorKey = GlobalKey&lt;NavigatorState&gt;(
  debugLabel: &#39;root&#39;,
);
final GlobalKey&lt;NavigatorState&gt; _homeNavigatorKey = GlobalKey&lt;NavigatorState&gt;(
  debugLabel: &#39;home&#39;,
);
final GlobalKey&lt;NavigatorState&gt; _settingsNavigatorKey =
    GlobalKey&lt;NavigatorState&gt;(debugLabel: &#39;settings&#39;);
&gt;
final router = GoRouter(
  navigatorKey: _rootNavigatorKey,
  routes: [
    GoRoute(path: &#39;/&#39;, builder: (context, state) =&gt; LandingPage()),
    StatefulShellRoute.indexedStack(
      parentNavigatorKey: _rootNavigatorKey,
      builder: (context, state, navigationShell) =&gt; Placeholder(), // (임시)
      branches: [
        StatefulShellBranch(
          navigatorKey: _homeNavigatorKey,
          routes: [
            GoRoute(
              path: &#39;/workout_home&#39;,
              builder: (context, state) =&gt; WorkoutHomePage(),
              routes: [
                GoRoute(
                  path: &#39;workout_list/:group_index&#39;,
                  builder: (context, state) {
                    String? groupIndexStream =
                        state.pathParameters[&#39;group_index&#39;];
                    final int groupIndex = int.parse(groupIndexStream!);
                    return WorkoutListPage(groupIndex: groupIndex);
                  },
                  routes: [
                    GoRoute(
                      path: &#39;workout_guide/:workout_index&#39;,
                      builder: (context, state) {
                        String? workoutIndexStream =
                            state.pathParameters[&#39;workout_index&#39;];
                        final int workoutIndex = int.parse(workoutIndexStream!);
                        String? groupIndexStream =
                            state.pathParameters[&#39;group_index&#39;];
                        final int groupIndex = int.parse(groupIndexStream!);
                        return WorkoutGuidePage(
                          workoutIndex: workoutIndex,
                          groupIndex: groupIndex,
                        );
                      },
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
        StatefulShellBranch(
          navigatorKey: _settingsNavigatorKey,
          routes: [
            GoRoute(
              path: &#39;/settings&#39;,
              builder: (context, state) =&gt; SettingsPage(),
              routes: [
                GoRoute(
                  path: &#39;login&#39;,
                  builder: (context, state) =&gt; LoginPage(),
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  ],
);</code></pre>
<p>설정 페이지와 로그인 페이지는 일단 단순한 형태로 만들어 넣어둔다.</p>
<h3 id="shell-생성하기">shell 생성하기</h3>
<p><code>StaetfulShellRoute</code> 의 <code>builder</code> 에 <code>Placeholder()</code> 를 담아두었던 부분을 구현해 보자. 이것은 <code>navigationShell</code> 을 주입받는 widget이며 <code>Scaffold</code> 를 사용해야 한다.</p>
<p>constructor로 전달받은 <code>navigationShell</code> 은 이 widget의 <code>body</code> 로 사용된다. 그리고 <code>bottomNavigationBar</code> 에 적절한 widget을 만들어 넣어 bottom navigation을 구현할 것이다.</p>
<p>여기에는 <code>BottomNavigationBar</code> 를 사용할 수도 있고 <code>NavigationBar</code> 를 사용할 수도 있지만 우리는 Material 3에서 권장하는 것에 따라 <code>NavigationBar</code> 를 사용할 것이다.</p>
<p><code>NavigationBar</code> 의 <code>destinations</code> 는 Router에 작성한 <code>StatefulShellRoute</code> 의 <code>branches</code> 와 index를 공유하기 때문에 순서를 맞춰 주어야 한다. <code>StatefulShellRoute</code> 로부터 전달받는 <code>navigationShell</code> object에 그 index를 포함한 메타데이터들이 들어 있다.</p>
<p>클릭했을 때 원하는 탭 화면으로 넘어가는 코드는 <code>onDestinationSelected</code> 에 <code>Function(int)</code> 형태의 callback function으로 전달한다. 이 때, parameter로 전달되는 <code>int</code> 값은 선택된 탭의 index다. <code>goBranch()</code> method에 대상 index를 전달하여 탭을 이동하며, <code>initialLocation</code> 이 <code>true</code> 냐 <code>false</code> 냐에 따라 보던 화면을 띄울지 해당 탭의 루트 페이지를 띄울지가 결정된다.</p>
<p>우리는 다른 탭으로 이동 시 보던 화면이 뜨고, 현재 탭과 같은 탭 버튼을 누를 경우 해당 탭의 루트로 돌아가도록 구현할 것이다.</p>
<blockquote>
<p><code>lib/workout_shell.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
&gt;
class WorkoutShell extends StatelessWidget {
  final StatefulNavigationShell _navigationShell;
  const WorkoutShell({super.key, required this._navigationShell});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _navigationShell.currentIndex,
        destinations: [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: &#39;home&#39;,
          ),
          NavigationDestination(
            icon: Icon(Icons.settings_outlined),
            selectedIcon: Icon(Icons.settings),
            label: &#39;settings&#39;,
          ),
        ],
        onDestinationSelected: (index) {
          _navigationShell.goBranch(
            index,
            initialLocation: index == _navigationShell.currentIndex,
          );
        },
      ),
    );
  }
}</code></pre>
<p>이제 <code>Placeholder()</code> 로 임시 작성했던 부분을 bottom navigation으로 갈아끼우면 다음과 같다.</p>
<blockquote>
<p><code>lib/workout_router.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
import &#39;workout_shell.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_home_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
import &#39;workout_guide_page.dart&#39;;
import &#39;settings_page.dart&#39;;
import &#39;login_page.dart&#39;;
&gt;
final GlobalKey&lt;NavigatorState&gt; _rootNavigatorKey = GlobalKey&lt;NavigatorState&gt;(
  debugLabel: &#39;root&#39;,
);
final GlobalKey&lt;NavigatorState&gt; _homeNavigatorKey = GlobalKey&lt;NavigatorState&gt;(
  debugLabel: &#39;home&#39;,
);
final GlobalKey&lt;NavigatorState&gt; _settingsNavigatorKey =
    GlobalKey&lt;NavigatorState&gt;(debugLabel: &#39;settings&#39;);
&gt;
final router = GoRouter(
  navigatorKey: _rootNavigatorKey,
  routes: [
    GoRoute(path: &#39;/&#39;, builder: (context, state) =&gt; LandingPage()),
    StatefulShellRoute.indexedStack(
      parentNavigatorKey: _rootNavigatorKey,
      builder: (context, state, navigationShell) =&gt;
          WorkoutShell(navigationShell: navigationShell),
      branches: [
        // 생략
      ],
    ),
  ],
);</code></pre>
<blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/1eaacecc-7dc0-4198-8d81-60f81aa05bde/image.png" alt=""></p>
</blockquote>
<h2 id="persist-data">Persist Data</h2>
<p>우리가 만들고 있는 앱의 메인 화면에 위치한 대시보드에는 오늘의 운동 시간이 있다. 그런데 데이터가 휘발성을 가진 변수에 담겨 있어 앱을 종료하면 데이터가 날아간다. 이를 방지하기 위해서는 비휘발 데이터를 사용해야 한다.</p>
<p>key-value store, file storage, local database, remote datastore 등 비휘발 데이터를 사용할 수 있는 방식은 다양하지만 우리는 <a href="https://pub.dev/packages/shared_preferences">shared_properties</a>를 통한 key-value store를 사용할 것이다. 데이터의 성격과 요구사항을 고려하여 가장 적합한 방식을 사용하면 된다.</p>
<p>shared_properties는 <code>int</code>, <code>bool</code>, <code>string</code> 세 가지 type만을 다룬다. <code>set</code> 으로 쓰고 <code>get</code> 으로 읽고 <code>remove</code> 로 제거하는 단순한 로직이라 데이터베이스에 비해 가볍다.</p>
<p><code>sync</code>, <code>async</code>, <code>cache</code> 세 가지 방식을 사용할 수 있지만 공식 문서는 <code>async</code> 로 할 것을 권장하기에 우리는 <code>async</code> 로 할 것이다.</p>
<p>늘 그렇듯 <code>pubspec.yaml</code> 에 작성하고 [Pub Get] 하는 걸로 시작한다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
  flex_color_scheme: ^8.4.0
  intl: ^0.20.3
  go_router: ^17.4.0
  shared_preferences: ^2.5.5
# 뒷 부분 생략</code></pre>
<p>우리에게는 UI가 아닌 데이터를 다루는 파일이 몇 개 있다. Model의 역할을 하는 <code>lib/workout.dart</code> 와 <code>lib/workout_group.dart</code>, 그리고 Controller의 역할을 하는 <code>lib/workout_manager.dart</code> 가 이에 해당한다.</p>
<p>비휘발 데이터를 다루는 function은 <code>WorkoutManager</code> 의 static method로 작성하도록 하겠다.</p>
<blockquote>
<p><code>lib/workout_manager.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:shared_preferences/shared_preferences.dart&#39;;
import &#39;workout.dart&#39;;
import &#39;workout_group.dart&#39;;
&gt;
// 생략
&gt;
  static final asyncPrefs = SharedPreferencesAsync();
&gt;
  static Future&lt;int&gt; getTodayWorkoutMinutes() async {
    int todayMinutes = await asyncPrefs.getInt(&#39;todayMinutes&#39;) ?? 0;
    return todayMinutes;
  }
&gt;
  static Future&lt;void&gt; increaseTodayWorkoutMinutes(int minutes) async {
    int todayMinutes = await asyncPrefs.getInt(&#39;todayMinutes&#39;) ?? 0;
    await asyncPrefs.setInt(&#39;todayMinutes&#39;, todayMinutes + minutes);
  }
}</code></pre>
<p>비휘발 데이터를 다루는 것이 실습의 핵심이므로 비즈니스 로직의 적합성은 배제하고 생각하겠다. 엄밀하게 구현하고자 한다면 <code>WorkoutGuidePage</code> 에서 재생 버튼을 눌러 시간이 흐른 만큼만 운동 시간으로 더해주어야겠지만 우리는 <code>WorkoutGuidePage</code> 에 들어가면 아무튼 그 운동을 그 시간만큼 했다고 치고 넘어가겠다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;dart:async&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:audioplayers/audioplayers.dart&#39;;
import &#39;workout_manager.dart&#39;;
import &#39;workout.dart&#39;;
&gt;
// 생략
&gt;
class _WorkoutGuidePageState extends State&lt;WorkoutGuidePage&gt; {
  final _player = AudioPlayer();
  Timer? _timer;
  bool _isPlaying = false;
&gt;
  late List&lt;Workout&gt; workouts;
&gt;
  late Workout _currentWorkout;
  late int _remainSeconds;
  int _workoutIndex = 0;
&gt;
  @override
  void initState() {
    super.initState();
    _player.setReleaseMode(ReleaseMode.loop);
    workouts = WorkoutManager.groups[widget.groupIndex].workouts;
    _workoutIndex = widget.workoutIndex;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
&gt;
    WorkoutManager.increaseTodayWorkoutMinutes(_currentWorkout.minutes);
  }
&gt;
  // 생략
&gt;
  void _next() {
    _workoutIndex = (_workoutIndex + 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
    WorkoutManager.increaseTodayWorkoutMinutes(_currentWorkout.minutes);
  }
&gt;
  void _prev() {
    _workoutIndex = (_workoutIndex - 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
    WorkoutManager.increaseTodayWorkoutMinutes(_currentWorkout.minutes);
  }
&gt;
  // 생략
}</code></pre>
<p>변화한 오늘의 운동 시간 데이터를 메인 화면 대시보드에 반영하는 작업은 다음 시간에 마저 하도록 하겠다.</p>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/4889180173a705606ff737c5b66480699cf4e31e">&gt;&gt;&gt; GitHub Repository at this point (4889180)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 17 | Flutter 데모 앱 만들기: Route를 통한 페이지 연결]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-17</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-17</guid>
            <pubDate>Fri, 07 Aug 2026 08:39:19 GMT</pubDate>
            <description><![CDATA[<h1 id="day-17--flutter-데모-앱-만들기-route를-통한-페이지-연결">DAY 17 | Flutter 데모 앱 만들기: Route를 통한 페이지 연결</h1>
<h2 id="flutter에서-사용할-수-있는-route">Flutter에서 사용할 수 있는 Route</h2>
<p>앱 화면을 관리할 때 Route를 사용한다. Route는 화면 정보와 화면들의 경로, 전환 정보를 담고 있다. Flutter 앱 개발에서 주로 쓰이는 Route package로는 <a href="https://pub.dev/packages/go_router">go_router</a>와 <a href="https://pub.dev/packages/auto_route">auto_route</a>가 있다. go_route는 Flutter SDK에 포함되어 있지는 않지만 Flutter 팀에서 관리하는 package라 압도적으로 많이 쓰인다.</p>
<p>예전에는 화면 정보를 담는 Route Stack에 후입선출의 형태로 페이지를 차곡차곡 쌓아 가장 위에 있는 것만 렌더링하는 것만 신경쓰면 되었다. 요즘은 모바일 시장이 성장하며 앱 복잡도가 높아졌기 때문에 데이터를 어떻게 주고받는지 등 추가적인 부분도 신경을 써 줘야 한다.</p>
<p>우리는 go_route를 사용할 것인데 이 녀석은 두 가지 방식의 Route를 제공한다. <code>push</code> 방식과 <code>go</code> 방식 중 <code>go</code> 방식이 이 package가 지향하는 방식이고, <code>push</code> 방식은 기존 개발자들을 위한 과도기적 인터페이스에 가깝다.</p>
<blockquote>
<h4 id="push-방식과-go-방식"><code>push</code> 방식과 <code>go</code> 방식</h4>
<table>
<thead>
<tr>
<th align="center"><code>push</code></th>
<th align="center"><code>go</code></th>
</tr>
</thead>
<tbody><tr>
<td align="center">imperative (명령형)</td>
<td align="center">declarative (선언형)</td>
</tr>
<tr>
<td align="center">모바일에선 좋지만 웹에는 부적절</td>
<td align="center">모바일과 웹 모두 적절</td>
</tr>
<tr>
<td align="center">deep link 부적절</td>
<td align="center">deep link 잘 동작</td>
</tr>
<tr>
<td align="center"><code>context.push(&#39;/&#39;)</code></td>
<td align="center"><code>context.go(&#39;/&#39;)</code></td>
</tr>
</tbody></table>
</blockquote>
<p>여기서 <a href="https://docs.flutter.dev/ui/navigation/deep-linking">deep link</a>는 앱의 특정 화면을 열 수 있게 해주는 URL이다. 부적절하다는 것은 불가능한 건 아닌데 구현이 필요 이상으로 복잡해지는 것을 말한다.</p>
<p><code>push</code> 방식을 사용하면 사용자의 행동에 따라 Route Stack이 쌓인다. 이곳저곳 돌아다니다 보면 Route Stack이 메모리를 가득 채우기도 한다. 뒤로가기를 하면 그 동안 쌓여 있던 화면을 모두 거쳐 처음 위치로 돌아간다.</p>
<p><code>go</code> 방식을 사용하면 사용자가 어떤 경로로 그곳에 도달했든 간에 사전에 선언되어 있는 구조로 Route Stack이 쌓인다. 따라서 개발자가 예측 가능한 범위에서 흘러간다.</p>
<p><code>go</code> 방식도 Route Stack을 안 쓰는 건 아니다. 내부적으로는 Stack 구조를 가지는데 Stack 관리를 개발자가 하지 않고 package 내부에서 수행할 뿐이다.</p>
<h2 id="route를-통해-이동하고-데이터-전달하기">Route를 통해 이동하고 데이터 전달하기</h2>
<p>go_route를 통해 화면을 이동하고 데이터를 전달하는 방법을 알아보도록 하겠다.</p>
<p>먼저 <code>pubspec.yaml</code> 파일에 package를 추가한다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
  flex_color_scheme: ^8.4.0
  intl: ^0.20.3
  go_router: ^17.4.0
# 뒷 부분 생략</code></pre>
<p>Route 파일을 생성하여 페이지 정보를 작성한다. go_route를 통해 페이지를 이동하려면 이렇게 별도로 페이지 정보를 담아놓는 <code>GoRouter</code> object가 필요하다. 이것을 <code>MaterialApp</code> 의 <code>routeConfig</code> 로 전달해야 비로소 이동이 가능해진다. 선언되지 않은 곳으로는 이동할 수 없다.</p>
<blockquote>
<p><code>workout_route.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:go_router/go_router.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_home_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
import &#39;workout_guide_page.dart&#39;;
&gt;
final router = GoRouter(
  routes: [
    GoRoute(path: &#39;/&#39;, builder: (context, state) =&gt; LandingPage()),
    GoRoute(
      path: &#39;/workout_home&#39;,
      builder: (context, state) =&gt; WorkoutHomePage(),
      routes: [
        GoRoute(
          path: &#39;workout_list&#39;,
          builder: (context, state) =&gt; WorkoutListPage(),
          routes: [
            GoRoute(
              path: &#39;workout_guide&#39;,
              builder: (context, state) =&gt; WorkoutGuidePage(),
            ),
          ],
        ),
      ],
    ),
  ],
);</code></pre>
<p><code>GoRoute</code> 는 중첩될 수 있다. <code>/route_name</code> 은 최상위 Route이며, 뒤로가기를 하면 앱이 종료된다. 중첩되어 있는 하위 Route에서 뒤로가기를 누르면 사용자가 어떤 흐름으로 그 페이지에 도달하였든 중첩 상위 Route로 이동한다. <code>&#39;/workout_home&#39;</code> 하위에 있는<code>&#39;workout_list&#39;</code> 로 이동하고자 할 때는 <code>&#39;/workout_home/workout_list&#39;</code> 와 같이 적어주어야 한다.</p>
<p><code>lib/main.dart</code> 에서 go_route를 사용하는 방식으로 코드를 수정하면 <code>/</code> 로 정의되어 있는 Route가 화면에 뜨고 사전에 선언한 Route를 따라 이동할 수 있게 된다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:flex_color_scheme/flex_color_scheme.dart&#39;;
import &#39;workout_router.dart&#39;;
// import &#39;landing_page.dart&#39;;
// import &#39;workout_list_page.dart&#39;;
// import &#39;workout_guide_page.dart&#39;;
// import &#39;workout_home_page.dart&#39;;
&gt;
void main() {
  runApp(const MyApp());
}
&gt;
class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      // home: const LandingPage(),
      // home: WorkoutListPage(),
      // home: WorkoutGuidePage(),
      // home: WorkoutHomePage(),
      routerConfig: router,
      theme: FlexThemeData.light(
        scheme: FlexScheme.blueWhale,
        fontFamily: &#39;Pretendard&#39;,
        subThemesData: const FlexSubThemesData(
          appBarBackgroundSchemeColor: SchemeColor.primary,
        )
      ),
    );
  }
}</code></pre>
<h3 id="페이지-이동하기">페이지 이동하기</h3>
<p><code>lib/landing_page.dart</code> 하단의 [시작하기] 버튼을 누르면 <code>WorkoutHomePage</code> 로 이동하는 코드를 작성해 보자. 버튼을 클릭했을 때 <code>context.go()</code> 를 통해 <code>&#39;/workout_home&#39;</code> 으로 이동하게 하는 것이다.</p>
<blockquote>
<p><code>lib/landing_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
&gt;
// 중략
&gt;
SizedBox(
  width: 360,
  height: 60,
  child: ElevatedButton(
    onPressed: () {
      context.go(&#39;/workout_home&#39;); // NEW!
    },
    style: ElevatedButton.styleFrom(
      backgroundColor: Colors.black87,
      shadowColor: Colors.white,
    ),
    child: Text(&#39;시작하기&#39;,
      style: TextStyle(fontSize: 24, color: Colors.white),
    ),
  ),
),
&gt;
// 이하 생략</code></pre>
<p><code>&#39;/workout_home&#39;</code> 은 <code>LandingPage</code> 에 해당하는 <code>&#39;/&#39;</code> 와 같은 level의 Route이기 때문에 Stack 위에 쌓이지 않고 대체된다. 따라서 뒤로가기를 누르면 <code>LandingPage</code> 로 돌아가지 않고 앱이 종료된다.</p>
<h3 id="하위-페이지로-이동하기">하위 페이지로 이동하기</h3>
<p><code>WorkoutHomePage</code> 의 <code>DashboardCard</code> 를 클릭하면 <code>WorkoutListPage</code> 로 이동하고, <code>WorkoutListPage</code> 의 항목을 누르면 <code>WorkoutGuidePage</code> 로 이동하는 코드를 작성한다.</p>
<p>이에 앞서, <code>DashboardCard</code> 를 클릭 가능한 형태로 만들고 callback function을 통해 이동하고자 하는 Route를 받아야 한다.</p>
<blockquote>
<p><code>lib/dashboard_card.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class DashboardCard extends StatelessWidget {
  final Icon _labelIcon;
  final Text _labelText;
  final Widget _info;
  final Color _bgColor;
  final Function()? _routeOnTap;
&gt;
  const DashboardCard({
    super.key,
    required this._labelIcon,
    required this._labelText,
    required this._info,
    this._bgColor = Colors.white,
    this._routeOnTap,
  });
&gt;
  @override
  Widget build(BuildContext context) {
    ColorScheme colorScheme = Theme.of(context).colorScheme;
&gt;
    return GestureDetector(
      onTap: _routeOnTap,
      child: Container(
        // 생략
      ),
    );
  }
}</code></pre>
<p>callback function을 선택적으로 받아 <code>onTap</code> 에 전달한다.</p>
<p><code>WorkoutHomePage</code> 의 [그룹 1], [그룹 2], [그룹 3]을 누르면 <code>WorkoutListPage</code> 로 연결되도록 작성한다. 그룹에 따라 적절한 운동 목록을 전달해야 하지만 우선은 페이지 연결만 하고 이후에 원하는 데이터를 전달하는 방식으로 수정하겠다.</p>
<blockquote>
<p><code>lib/workout_home_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
AspectRatio(
  aspectRatio: 1 / 1,
  child: DashboardCard(
    routeOnTap: () {
       context.go(&#39;/workout_home/workout_list&#39;); // NEW!
    },
    labelIcon: Icon(
      Icons.run_circle_outlined,
      size: textTheme.labelLarge?.fontSize,
      fontWeight: FontWeight.bold,
    ),
    labelText: Text(
      &#39;그룹 1&#39;,
      style: textTheme.labelLarge?.copyWith(
        fontWeight: FontWeight.bold,
      ),
    ),
    info: Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        Flexible(
          flex: 4,
          child: Image.asset(&#39;assets/home/group1.png&#39;),
        ),
        Flexible(
          flex: 6,
          child: Text(
            &#39;아침을 여는\n5가지 운동&#39;,
            style: textTheme.bodyMedium,
          ),
        ),
      ],
    ),
    bgColor: Colors.yellow.shade50,
  ),
),
// 뒷 부분 생략</code></pre>
<h3 id="데이터-전달하기">데이터 전달하기</h3>
<p>마찬가지로 <code>WorkoutListPage</code> 에서도 항목을 클릭하여 <code>WorkoutGuidePage</code> 로 이동하도록 코드를 작성해야 하는데, 어떤 운동을 띄워줄 것인지 index를 전달해야 한다.</p>
<p>데이터 object를 직접 던져주는 전통적인 방식을 사용할 경우 deep link 사용 시 데이터 object를 받지 못해 화면을 제대로 띄워줄 수 없다. 따라서 Route의 URL에 데이터를 담아주어야 한다. Route에 데이터를 전달하는 방법은 크게 path parameter와 query parameter가 있다.</p>
<p>path parameter는 <code>/workout_home/workout_list/worktout_guide/1</code> 와 같은 형태로 작성하며 query parameter는 <code>/workout_home/workout_list/workout_guide?id=1</code> 와 같은 형태로 작성한다. path parameter를 사용하는 편이 더 안정적이고 체계적이다. 우리는 path parameter를 통해 데이터를 전달하도록 하겠다.</p>
<p><code>WorkoutGuidePage</code> 를 열 때 어떤 운동을 나타낼 것인지 path parameter로 받겠다는 내용을 Route 정보 파일에 선언해 두어야 하므로 다음과 같이 내용을 수정한다.</p>
<blockquote>
<p><code>lib/workout_router.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:go_router/go_router.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_home_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
import &#39;workout_guide_page.dart&#39;;
&gt;
final router = GoRouter(
  routes: [
    GoRoute(path: &#39;/&#39;, builder: (context, state) =&gt; LandingPage()),
    GoRoute(
      path: &#39;/workout_home&#39;,
      builder: (context, state) =&gt; WorkoutHomePage(),
      routes: [
        GoRoute(
          path: &#39;workout_list&#39;,
          builder: (context, state) =&gt; WorkoutListPage(),
          routes: [
            GoRoute(
              // 여기서부터 수정됨.
              path: &#39;workout_guide/:workout_index&#39;,
              builder: (context, state) {
                String? workoutIndexStream =
                    state.pathParameters[&#39;workout_index&#39;];
                final int workoutIndex = int.parse(workoutIndexStream!);
                return WorkoutGuidePage(workoutIndex: workoutIndex);
              },
            ),
          ],
        ),
      ],
    ),
  ],
);</code></pre>
<p>그리고 이에 따라 <code>WorkoutGuidePage</code> 의 constructor도 <code>workoutIndex</code> 를 받는 방식으로 수정해 주어야 한다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;dart:async&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:audioplayers/audioplayers.dart&#39;;
import &#39;workout.dart&#39;;
&gt;
class WorkoutGuidePage extends StatefulWidget {
  final int workoutIndex; // NEW!
&gt;
  const WorkoutGuidePage({super.key, required this.workoutIndex}); // MODIFIED!
&gt;
  @override
  State&lt;WorkoutGuidePage&gt; createState() =&gt; _WorkoutGuidePageState();
}
&gt;
class _WorkoutGuidePageState extends State&lt;WorkoutGuidePage&gt; {
  final _player = AudioPlayer();
  Timer? _timer;
  bool _isPlaying = false;
&gt;
  final List&lt;Workout&gt; workouts = [
    // 내용 생략
  ];
&gt;
  late Workout _currentWorkout;
  late int _remainSeconds;
  int _workoutIndex = 0;
&gt;
  @override
  void initState() {
    super.initState();
&gt;    _player.setReleaseMode(ReleaseMode.loop);
    _workoutIndex = widget.workoutIndex; // NEW!
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
  }
  // 이하 생략
}</code></pre>
<p><code>WorkoutListPage</code> 의 각 항목을 클릭했을 때 그 항목에 해당하는 index가 담긴 Route로 이동하도록 코드를 작성한다.</p>
<blockquote>
<p><code>lib/workout_list_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
import &#39;workout.dart&#39;;
&gt;
class WorkoutListPage extends StatelessWidget {
  WorkoutListPage({super.key});
&gt;
  final List&lt;Workout&gt; workouts = [
    // 내용 생략
  ];
&gt;
  List&lt;ListTile&gt; getWorkoutList(BuildContext context) {
    TextTheme textTheme = Theme.of(context).textTheme;
    ColorScheme colorScheme = Theme.of(context).colorScheme;
&gt;
    List&lt;ListTile&gt; workoutTiles = [];
&gt;
    for (var i = 0; i &lt; workouts.length; i++) {
      String name = workouts[i].name;
      String image = workouts[i].imageName;
      int minutes = workouts[i].minutes;
&gt;
      workoutTiles.add(
        ListTile(
          onTap: () {
            context.go(&#39;/workout_home/workout_list/workout_guide/$i&#39;); // NEW!
          },
          // 생략
        ),
      };
    }
  }
  return workoutTiles;
}
// 이하 생략</code></pre>
<h3 id="데이터-따로-관리하기">데이터 따로 관리하기</h3>
<p>지금은 <code>WorkoutListPage</code> 와 <code>WorkoutGuidePage</code> 에 데이터가 중복 정의되어 있다. 앱 전체에서 접근할 수 있는 외부 팡리에 데이터를 담아놓고 가져다 쓰는 방식으로 수정하겠다.</p>
<p><code>lib/workout_manager.dart</code> 파일을 작성하여 <code>WorkoutManager</code> class를 생성하고, instance 생성 없이 사용할 수 있는 static property로 데이터를 담아둘 것이다. <code>static</code> keyword가 붙으면 메모리에 상주하고 있어 이상적이지는 않지만 합리적인 이유로 실무에서는 많이 사용된다고 한다.</p>
<blockquote>
<p><code>lib/workout_manager.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;workout.dart&#39;;
&gt;
class WorkoutManager {
  static final List&lt;Workout&gt; workouts = [
    // 생략
  ];
}</code></pre>
<p>이에 따라 <code>WorkoutGuidePage</code> 및 <code>WorkoutListPage</code> 의 코드를 수정한다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;dart:async&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:audioplayers/audioplayers.dart&#39;;
import &#39;workout_manager.dart&#39;; // NEW!
import &#39;workout.dart&#39;;
&gt;
class WorkoutGuidePage extends StatefulWidget {
  final int workoutIndex;
&gt; 
  const WorkoutGuidePage({super.key, required this.workoutIndex});
&gt;
  @override
  State&lt;WorkoutGuidePage&gt; createState() =&gt; _WorkoutGuidePageState();
}
&gt;
class _WorkoutGuidePageState extends State&lt;WorkoutGuidePage&gt; {
  final _player = AudioPlayer();
  Timer? _timer;
  bool _isPlaying = false;
&gt;  
  List&lt;Workout&gt; workouts = WorkoutManager.workouts; // MODIFIED!
&gt;
  // 생략
}</code></pre>
<blockquote>
<p><code>lib/workout_list_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
import &#39;workout.dart&#39;;
import &#39;workout_manager.dart&#39;;
&gt;
class WorkoutListPage extends StatelessWidget {
  WorkoutListPage({super.key});
&gt;  
  final List&lt;Workout&gt; workouts = WorkoutManager.workouts;
&gt;
  // 생략
}</code></pre>
<h3 id="그룹-데이터-전달하기">그룹 데이터 전달하기</h3>
<p>그런데 지금은 어떤 그룹을 선택해도 같은 목록을 띄운다. 따라서 그룹을 관리하는 데이터를 생성하고 <code>WorkoutHomePage</code> 에서 어떤 <code>DashboardCard</code> 를 누르냐에 따라 다른 목록이 뜨도록 코드를 수정하겠다.</p>
<p>이를 위해 먼저 <code>WorkoutGroup</code> class 를 생성하고 <code>WorkoutManager</code> class에 그룹 목록을 생성한다.</p>
<blockquote>
<p><code>lib/workout_group.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;workout.dart&#39;;
&gt;
class WorkoutGroup {
  String groupDescription;
  List&lt;Workout&gt; workouts;
&gt;
  WorkoutGroup({required this.groupDescription, required this.workouts});
}</code></pre>
<blockquote>
<p><code>lib/workout_mananger.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;workout.dart&#39;;
import &#39;workout_group.dart&#39;;
&gt;
class WorkoutManager {
  static final List&lt;Workout&gt; workouts = [
    // 생략
  ];
&gt;
  static List&lt;WorkoutGroup&gt; groups = [
    WorkoutGroup(
      groupDescription: &#39;아침을 여는 5가지 운동&#39;,
      workouts: [
        workouts[0],
        workouts[2],
        workouts[5],
        workouts[3],
        workouts[7],
      ],
    ),
    WorkoutGroup(
      groupDescription: &#39;근력을 키우는 7가지 운동&#39;,
      workouts: [
        workouts[1],
        workouts[2],
        workouts[3],
        workouts[6],
        workouts[7],
        workouts[8],
        workouts[0],
      ],
    ),
    WorkoutGroup(
      groupDescription: &#39;하루를 마무리하는 4가지 운동&#39;,
      workouts: [workouts[3], workouts[0], workouts[2], workouts[6]],
    ),
  ];
}</code></pre>
<p>주제랑 안 어울리는 운동이 들어가 있을 수 있지만 임의로 담아 보았다.</p>
<p>이와 같이 class를 생성하여 데이터를 관리하면 이해하기 쉽고 확장성도 좋다. 그룹 설명과 운동 목록뿐만 아니라 추가적인 데이터가 필요할 때 파편화된 데이터를 추가하는 게 아니라 class의 property를 추가하면 되니 말이다.</p>
<p><code>group_index</code> 를 통해 원하는 그룹의 데이터를 화면에 띄워주도록 코드를 수정해 보자.</p>
<blockquote>
<p><code>lib/workout_router.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:go_router/go_router.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_home_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
import &#39;workout_guide_page.dart&#39;;
&gt;
final router = GoRouter(
  routes: [
    GoRoute(path: &#39;/&#39;, builder: (context, state) =&gt; LandingPage()),
    GoRoute(
      path: &#39;/workout_home&#39;,
      builder: (context, state) =&gt; WorkoutHomePage(),
      routes: [
        GoRoute(
          // 여기서부터 수정됨.
          path: &#39;workout_list/:group_index&#39;,
          builder: (context, state) {
            String? groupIndexStream = state.pathParameters[&#39;group_index&#39;];
            final int groupIndex = int.parse(groupIndexStream!);
            return WorkoutListPage(groupIndex: groupIndex);
          },
          routes: [
            GoRoute(
              path: &#39;workout_guide/:workout_index&#39;,
              builder: (context, state) {
                String? workoutIndexStream =
                    state.pathParameters[&#39;workout_index&#39;];
                final int workoutIndex = int.parse(workoutIndexStream!);
                return WorkoutGuidePage(workoutIndex: workoutIndex);
              },
            ),
          ],
        ),
      ],
    ),
  ],
);</code></pre>
<p>Route가 변경됨에 따라 <code>WorkoutListPage</code> 의 constructor를 수정해야 하는데 여기서 중요한 건, class가 정의되는 시점에는 아직 데이터가 메모리에 올라가지 않았기 때문에 object를 참조할 수 없다는 것이다. 따라서 initializer list를 사용하여 object 생성 후에 초기화되도록 작성해야 한다.</p>
<blockquote>
<p><code>lib/workout_list_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:go_router/go_router.dart&#39;;
import &#39;workout.dart&#39;;
import &#39;workout_manager.dart&#39;;
&gt;
class WorkoutListPage extends StatelessWidget {
  final int groupIndex;
  final List&lt;Workout&gt; workouts;
  WorkoutListPage({super.key, required this.groupIndex}):
    workouts = WorkoutManager.groups[groupIndex].workouts;
&gt;
  // final List&lt;Workout&gt; workouts = WorkoutManager.workouts;
  // 생략
}</code></pre>
<p>이제 <code>WorkoutHomePage</code> 의 적절한 <code>DashboardCard</code> 를 클릭했을 때 전체 목록이 아닌 원하는 그룹의 목록이 뜨도록 수정한다.</p>
<blockquote>
<p><code>lib/workout_home_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
AspectRatio(
  aspectRatio: 1 / 1,
  child: DashboardCard(
    routeOnTap: () {
       context.go(&#39;/workout_home/workout_list/0&#39;); // MODIFIED!
    },
    labelIcon: Icon(
      Icons.run_circle_outlined,
      size: textTheme.labelLarge?.fontSize,
      fontWeight: FontWeight.bold,
    ),
    labelText: Text(
      &#39;그룹 1&#39;,
      style: textTheme.labelLarge?.copyWith(
        fontWeight: FontWeight.bold,
      ),
    ),
    info: Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        Flexible(
          flex: 4,
          child: Image.asset(&#39;assets/home/group1.png&#39;),
        ),
        Flexible(
          flex: 6,
          child: Text(
            &#39;아침을 여는\n5가지 운동&#39;,
            style: textTheme.bodyMedium,
          ),
        ),
      ],
    ),
    bgColor: Colors.yellow.shade50,
  ),
),
// 뒷 부분 생략</code></pre>
<p>이제 잘 작동할 것 같지만 <strong>한 가지 문제가 있다!</strong></p>
<p><code>WorkoutGuidePage</code> 로 이동하는 코드를 다음과 같이 작성해 놓았는데,</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">context.go(&#39;/workout_home/workout_list/workout_guide/$i&#39;);</code></pre>
<p><code>workout_list</code> 와 <code>workout_guide</code> 사이에 <code>group_index</code> 가 끼어들면서 경로가 맞지 않게 되어 버렸다. 다음과 같이 경로를 수정해야 한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">context.go(&#39;/workout_home/workout_list/$groupIndex/workout_guide/$i&#39;);</code></pre>
<p>그런데 이렇게만 수정하면 <code>WorkoutGuidePage</code> 가 그룹 내 운동 목록이 아닌 전체 운동 목록을 기준으로 화면을 띄워주므로, <code>WorkoutGuidePage</code> 자체가 <code>workoutManager.groups[groupIndex].workouts</code> 를 기준으로 화면을 띄워주도록 코드를 수정해야 한다.</p>
<p>중첩되어 있는 Route는 상위 Route의 path parameter에 접근할 수 있으므로 이를 활용하여 <code>WorkoutGuidePage</code> 가 <code>groupIndex</code> 를 받게 수정한다.</p>
<blockquote>
<p><code>lib/workout_router.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:go_router/go_router.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_home_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
import &#39;workout_guide_page.dart&#39;;
&gt;
final router = GoRouter(
  routes: [
    GoRoute(path: &#39;/&#39;, builder: (context, state) =&gt; LandingPage()),
    GoRoute(
      path: &#39;/workout_home&#39;,
      builder: (context, state) =&gt; WorkoutHomePage(),
      routes: [
        GoRoute(
          path: &#39;workout_list/:group_index&#39;,
          builder: (context, state) {
            String? groupIndexStream = state.pathParameters[&#39;group_index&#39;];
            final int groupIndex = int.parse(groupIndexStream!);
            return WorkoutListPage(groupIndex: groupIndex);
          },
          routes: [
            GoRoute(
              path: &#39;workout_guide/:workout_index&#39;,
              builder: (context, state) {
                String? workoutIndexStream =
                    state.pathParameters[&#39;workout_index&#39;];
                final int workoutIndex = int.parse(workoutIndexStream!);
                // 여기서부터 수정됨.
                String? groupIndexStream = state.pathParameters[&#39;group_index&#39;];
                final int groupIndex = int.parse(groupIndexStream!);
                return WorkoutGuidePage(
                  workoutIndex: workoutIndex,
                  groupIndex: groupIndex,
                );
              },
            ),
          ],
        ),
      ],
    ),
  ],
);</code></pre>
<blockquote>
<p><code>lib/workout_group_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;dart:async&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:audioplayers/audioplayers.dart&#39;;
import &#39;workout_manager.dart&#39;;
import &#39;workout.dart&#39;;
&gt;
class WorkoutGuidePage extends StatefulWidget {
  final int workoutIndex;
  final int groupIndex; // NEW!
&gt;
  const WorkoutGuidePage({
    super.key,
    required this.workoutIndex,
    required this.groupIndex, // NEW!
  });
&gt;
  @override
  State&lt;WorkoutGuidePage&gt; createState() =&gt; _WorkoutGuidePageState();
}
&gt;
class _WorkoutGuidePageState extends State&lt;WorkoutGuidePage&gt; {
  final _player = AudioPlayer();
  Timer? _timer;
  bool _isPlaying = false;
&gt;
  late List&lt;Workout&gt; workouts; // MODIFIED!
&gt;
  late Workout _currentWorkout;
  late int _remainSeconds;
  int _workoutIndex = 0;
&gt;
  @override
  void initState() {
    super.initState();
    _player.setReleaseMode(ReleaseMode.loop);
    workouts = WorkoutManager.groups[widget.groupIndex].workouts; // NEW!
    _workoutIndex = widget.workoutIndex;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
  }
&gt;
  // 생략
}</code></pre>
<p>이제 비로소 <code>WorkoutHomePage</code> 에서 원하는 그룹을 선택하면 해당 그룹의 운동 목록이 <code>WorkoutListPage</code> 로 뜨고, 거기서 또 원하는 운동을 선택하면 그 운동이 <code>WorkoutGuidePage</code> 로 뜨며, 좌우 화살표를 클릭하면 그 그룹의 운동 목록 내에서 순회하는 방식으로 정상적으로 작동한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/7849dddb-2f63-46e1-987d-ba77f506c719/image.png" alt=""></p>
<blockquote>
</blockquote>
<p>좌측 iOS 시뮬레이터에는 [아침을 여는 5가지 운동]을, 우측 Android 애뮬레이터에는 [근력을 키우는 7가지 운동]을 띄워 보았다.</p>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/945dc0306240ec49787b4189ede8a33fb329d712">&gt;&gt;&gt; GitHub Repository at this point (945dc03)</a></p>
</blockquote>
<h2 id="더-알아보기-stateful-shell-route">더 알아보기: Stateful Shell Route</h2>
<p>bottom navigation bar라고 불리는, 하단 탭바를 구현할 때 유용한 기술이다. 하단 탭바를 구현하는 방식은 크게 세 가지가 있는데 특별한 일 없으면 Stateful Shell Route를 사용하는 게 좋다.</p>
<p>첫 번째 방식인 widget 방식은 모든 페이지에 sub widget을 넣고 어느 화면이 활성화되어 있는지 일일이 관리해야 하여 복잡하고 아무도 사용하지 않는 방식이다.</p>
<p>두 번째 방식인 Shell Route 방식은 Shell(껍데기)을 만들어 놓고 그 안에 화면 내용물을 넣어두는 방식이다. <code>ShellRoute</code> 에 하단 탭바와 <code>Scaffold</code> 를 넣어두고, route 될 때마다 <code>Scaffold</code> 를 바꿔 끼우는 거다. 어느 탭이 활성화되어 있는지 관리하기는 수월해졌지만 탭 이동 시 그 동안의 navigation stack history가 날아간다는 단점이 있다.</p>
<p>이를 보완한 게 세 번째 방식인 Stateful Shell Route 방식이다. 기본적으로 Shell Route 방식처럼 <code>child</code> 를 바꿔 끼우는 방식인데, 각 화면에서의 navigation stack을 State 데이터로 갖고 있는다. 이러한 방식으로 구현하면 홈 화면의 타임라인을 훑다가 탐색 탭으로 이동하여 이것저것 구경 후 다시 홈 화면의 타임라인으로 돌아왔을 때, 기존에 보고 있던 타임라인 화면을 이어서 볼 수 있다.</p>
<p>이에 대한 구체적인 구현 방식은 다음에 알아보기로 하고 오늘은 이론적인 부분만 살펴 보았다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 16 | Figma를 살펴보고 넘어가자]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-16</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-16</guid>
            <pubDate>Wed, 05 Aug 2026 14:51:54 GMT</pubDate>
            <description><![CDATA[<h1 id="day-16--figma를-살펴보고-넘어가자">DAY 16 | Figma를 살펴보고 넘어가자</h1>
<p>Flutter와 직접적인 관련은 없지만 client 개발 시 알아두면 좋은 디자인 툴, Figma에 대해 알아보는 특강이 진행되었다. 나는 대학생 때 조금 건드려 보고 작년에 <a href="https://brunch.co.kr/brunchbook/study-dbedu-dcd">기술교육원 디지털콘텐츠디자인과</a> 다니며 좀 더 구체적으로 다뤄 보았기에 어느 정도 알고 있었다. auto-layout이랑 component, varients, prototyping까지 다뤄 보았으니 사살상 AI를 활용하는 Make 기능 말고는 대체로 다뤄본 거 아닐까.</p>
<p>오늘은 수업 내용을 가볍게 들으며 내 프로젝트 기획을 했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 15 | Flutter 데모 앱 만들기: 홈 화면]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-15</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-15</guid>
            <pubDate>Mon, 03 Aug 2026 09:18:52 GMT</pubDate>
            <description><![CDATA[<h1 id="day-15--flutter-데모-앱-만들기-홈-화면">DAY 15 | Flutter 데모 앱 만들기: 홈 화면</h1>
<p>애플리케이션의 홈 화면을 구성하고, 작성한 widget을 custom widget으로 빼서 관리해 보자.</p>
<h2 id="홈-화면-구성하기">홈 화면 구성하기</h2>
<p>먼저 <code>lib/workout_home_page.dart</code> 파일을 만들고 <code>StatefulWidget</code> 으로 <code>WorkoutHomePage</code> widget을 생성한 뒤, 필요한 widget을 순차적으로 작성한다.</p>
<p>우선은 여백을 고려하지 않고 전체적인 틀을 작성한 뒤, 이후에 여백을 조정하도록 하겠다.</p>
<h3 id="part-01-상단-바">part 01: 상단 바</h3>
<p><code>AppBar</code> widget을 사용하지 않고 자체적인 상단 바를 사용한다. 이것은 좌측 로고 아이콘과 우측 알림 아이콘으로 구성된다.</p>
<p><code>AppBar</code> widget을 사용하지 않기에 <code>Scaffold</code> 아래에 <code>SafeArea</code> 를 넣고 그 아래에 <code>Column</code> 을 넣는다.</p>
<blockquote>
<p><code>lib/workout_home_page.dart</code> &gt; <code>WorkoutHomePage</code></p>
</blockquote>
<pre><code class="language-dart">// Scaffold &gt; SafeArea &gt; Column
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: [
      Image.asset(&#39;assets/home/runner_icon.png&#39;, width: 24),
      Image.asset(&#39;assets/home/notifications_icon.png&#39;, width: 24),
    ],
),</code></pre>
<blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/cf888761-ddea-4084-af21-2397e8407b17/image.png" alt=""></p>
</blockquote>
<h3 id="part-02-프로필">part 02: 프로필</h3>
<p>프로필 부분은 텍스트와 이미지로 구성된다. 텍스트 부분에는 여러 가지 스타일이 섞여 사용되기 때문에 <code>TextSpan</code> widget을 사용한다. 이미지 부분은 배경 위에 사진을 올릴 것이기에 <code>Stack</code> widget을 사용한다. 이 때, 프로필 사진을 둥글게 자르는 건 <code>WidgetListPage</code> 에서 했던 것처럼 <code>Container</code> 를 원형으로 꾸며주면 된다.</p>
<p><code>TextTheme</code> 는 매번 <code>Theme.of(context)</code> 에서 꺼내 쓰지 않고 변수로 할당해서 사용하도록 하겠다.</p>
<p>텍스트에 스타일을 지정할 때 특정 부분만 <code>textTheme</code> 에서 가져오고 나머지는 직접 작성하거나 기본값을 사용하고자 한다면 다음과 같이 작성한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">style: TextStyle(
  fontSize: textTheme.titleMedium?.fontSize,
  fontWeight: FontWeight.bold,
)</code></pre>
<p>반면, 특정 부분만 직접 작성하고 나머지는 <code>textTheme</code> 에서 가져오고 싶다면 <code>style</code> property에 직접 전달 후 따로 지정하고 싶은 부분만 <code>copyWith</code> 로 전달한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">style: textTheme.titleMedium?.copyWith(
  fontWeight: FontWeight.bold,
)</code></pre>
<p><code>copyWith</code> 는 object의 일부만 수정하여 복사할 때 사용하는 녀석으로, 자주 쓰이니 기억해 두도록 하자.</p>
<p>이전 영역에서는 두 아이콘 사이에 여백을 주는 것이기에 <code>mainAxisAlignment: MainAxisAlignment.spaceBetween</code> 으로 작성했다. 여기서는 텍스트와 이미지 사이에 여백을 주기보다는 텍스트 쪽에 여백을 주고자 하여 <code>Expanded</code> 로 감싸는 방식을 사용했다. (보여지는 모습은 같지만 개념적인 차이다.)</p>
<blockquote>
<p><code>lib/workout_home_page.dart</code> &gt; <code>WorkoutHomePage</code></p>
</blockquote>
<pre><code class="language-dart">// Scaffold &gt; SafeArea &gt; Column
Row(
  children: [
    Expanded(
      child: Text.rich(
        TextSpan(
          children: [
            TextSpan(
              text: &#39;반가워요.&#39;,
              style: textTheme.titleMedium?.copyWith(
                fontWeight: FontWeight.bold,
              ),
            ),
            TextSpan(
              text: &#39;건강을 위한 한 걸음\n&#39;,
              style: textTheme.titleMedium?.copyWith(
                fontWeight: FontWeight.bold,
                color: Colors.blue,
              ),
            ),
            TextSpan(
              text: &#39;오늘도 힘차게 운동을 해 볼까요?\n&#39;,
              style: textTheme.bodyMedium,
            ),
            TextSpan(
              text: &#39;&gt; 내 프로필&#39;,
              style: textTheme.bodySmall?.copyWith(
                color: Colors.grey,
              ),
            ),
          ],
        ),
      ),
    ),
    Stack(
      children: [
        Image.asset(&#39;assets/home/half_circle.png&#39;, width: 132),
          Positioned(
            left: 16,
            child: Container(
            width: 80,
            height: 80,
            decoration: BoxDecoration(
              image: DecorationImage(
                image: AssetImage(&#39;assets/home/me.png&#39;),
                fit: BoxFit.cover,
              ),
              shape: BoxShape.circle,
              border: Border.all(color: Colors.blue, width: 1),
            ),
          ),
        ),
      ],
    ),
  ],
),</code></pre>
<blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/fa09a6ee-6bf0-469a-bffc-b184cba7991b/image.png" alt=""></p>
</blockquote>
<h3 id="part-03-운동-요약">part 03: 운동 요약</h3>
<p>여기서부터 세 개 영역은 <code>Expanded</code> widget과 <code>flex</code> property를 이용하여 3:2:2 비율로 세로 화면을 가득 채우도록 작성할 것이다.</p>
<p>그 중에서도 운동 요약 영역은 오늘의 운동과 주간 운동이 가로로 반씩 차지하고 있는 영역이다.</p>
<p>대시보드 화면에 들어가는 모든 카드는<code>DashboardCard</code> 라는 이름의 custom widget을 만들어 통일감을 줄 것이다.</p>
<blockquote>
<p><code>lib/dashboard_card.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class DashboardCard extends StatelessWidget {
&gt;
  const DashboardCard({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    ColorScheme colorScheme = Theme.of(context).colorScheme;
&gt;
    return Container(
      margin: EdgeInsets.all(8),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(10),
        border: Border.all(color: colorScheme.outlineVariant),
      ),
      child: Placeholder(),
    );
  }
}</code></pre>
<blockquote>
<p><code>lib/workout_home_page.dart</code> &gt; <code>WorkoutHomePage</code></p>
</blockquote>
<pre><code class="language-dart">// Scaffold &gt; SafeArea &gt; Column
 Expanded(
  flex: 3,
  child: Row(
    children: [
      Expanded(
        child: DashboardCard(),
      ),
      Expanded(
        child: DashboardCard(),
      ),
    ],
  ),
),</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/22bf76e3-d566-49dd-a40b-860b9260db68/image.png" alt=""></p>
<h3 id="part-04-운동-추천">part 04: 운동 추천</h3>
<p>운동 추천 영역은 가로 스크롤 영역에 추천하는 운동 카드를 순차적으로 배치하는 영역이다.</p>
<p><code>SingleChildScrollView</code> widget 을 통해 스크롤 가능하게 하고 스크롤 방향을 가로로 변경한다. 그 안에 앞서 만든 custom widget인 <code>DashboardCard</code> 를 넣는다.</p>
<blockquote>
<p><code>lib/workout_home_page.dart</code> &gt; <code>WorkoutHomePage</code></p>
</blockquote>
<pre><code class="language-dart">// Scaffold &gt; SafeArea &gt; Column
Expanded(
  flex: 2,
  child: SingleChildScrollView(
    scrollDirection: Axis.horizontal,
    child: Row(
    children: [
      AspectRatio(
        aspectRatio: 1 / 1,
        child: DashboardCard(),
      ),
      AspectRatio(
        aspectRatio: 1 / 1,
        child: DashboardCard(),
      ),
      AspectRatio(
        aspectRatio: 1 / 1,
        child: DashboardCard(),
      ),
    ],
  ),
),</code></pre>
<blockquote>
<h4 id="수업에서와-다르게-한-점">수업에서와 다르게 한 점</h4>
<p>수업에서는 <code>DashboardCard</code> 를 <code>SizedBox</code> 로 감싸 <code>width</code> 값을 강제하였다. 화면 크기에 따라 종횡비가 달라질 것이다.
나는 <code>DashboardCard</code> 를 <code>AspectRatio</code> 로 감싸 종횡비를 1:1로 강제하였다. 화면 크기에 따라 가로 길이가 달라질 것이다.</p>
</blockquote>
<blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/2df5ca95-689f-4f1b-8fb5-9c03d0de0a5a/image.png" alt=""></p>
</blockquote>
<h3 id="part-05-이어서-하기">part 05: 이어서 하기</h3>
<p>이어서 하기 영역은 가로로 가득 채우는 영역이다.</p>
<p>여기에는 앞서 만든 custom widget인 <code>DashboardCard</code> 만으로 가득 채운다.</p>
<blockquote>
<p><code>lib/workout_home_page.dart</code> &gt; <code>WorkoutHomePage</code></p>
</blockquote>
<pre><code class="language-dart">// Scaffold &gt; SafeArea &gt; Column
Expanded(
  flex: 2,
  child: DashboardCard(),
),</code></pre>
<blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/59194397-bd43-4417-a727-55995a2c0134/image.png" alt=""></p>
</blockquote>
<h2 id="세부-구현하기">세부 구현하기</h2>
<p>둥근 사각형의 테두리에 쌓여 있고 우상단에 아이콘과 텍스트 라벨이 있는 것까지가 대시보드 카드들의 공통 사항이다. 구체적인 아이콘과 텍스트, 그리고 그 스타일에는 차이가 있으며, 그 아래로 이어지는 내용도 제각각이다.</p>
<p>따라서 공통 사항을 제외하고는 property로 받아서 사용한다. 우상단에 출력할 <code>Icon</code>, <code>Text</code> 그리고 그 아래로 이어질 <code>Widget</code> 이 필요하다.</p>
<blockquote>
<p><code>lib/dashboard_card.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class DashboardCard extends StatelessWidget {
  final Icon _labelIcon;
  final Text _labelText;
  final Widget _info;
&gt;
  const DashboardCard({
    super.key,
    required this._labelIcon,
    required this._labelText,
    required this._info,
  });
&gt;
  @override
  Widget build(BuildContext context) {
    ColorScheme colorScheme = Theme.of(context).colorScheme;
&gt;
    return Container(
      margin: EdgeInsets.all(8),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(10),
        border: Border.all(color: colorScheme.outlineVariant),
      ),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: [_labelIcon, SizedBox(width: 4), _labelText],
          ),
          Expanded(child: _info),
        ],
      ),
    );
  }
}</code></pre>
<h3 id="part-03-운동-요약-1">part 03: 운동 요약</h3>
<p>그래프 부분은 추후에 하기로 하고 텍스트 위주로 배치한다. 오늘의 운동과 월간 운동의 기본 형식은 비슷하니 복사해서 조금만 수정해 주면 된다.</p>
<blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/44576abf-cbfa-4d82-b99b-fae092e56ccc/image.png" alt=""></p>
</blockquote>
<h3 id="part-04-운동-추천-1">part 04: 운동 추천</h3>
<p>좌측에 이미지, 우측에 텍스트가 들어가고 배경에 그라데이션이 들어가는 형태다. 이를 위해 <code>DashboardCard</code> widget에 선택 사항인 <code>_color</code> property를 추가하고, 이 값이 전달되면 해당 색상으로 흘러가는 그라데이션을 깔도록 하겠다.</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/86416b9d-5a16-498f-bd3d-59c036961dbb/image.png" alt=""></p>
<h3 id="part-05-이어서-하기-1">part 05: 이어서 하기</h3>
<p>배경 이미지를 깔고 텍스트를 올리는 형태다. <code>DashboardCard</code> 의 <code>decoration</code> property를 통해 배경 이미지를 깔아도 되겠지만 이 widget을 최대한 단순하게 유지하기 위해 이건 <code>Stack</code> 으로 쌓았다.</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/e708545f-d507-41c7-b85a-63e506b2a5b2/image.png" alt=""></p>
<h2 id="좀-더-꾸며주기">좀 더 꾸며주기</h2>
<p><code>Stack</code> 을 사용하여 그래프를 추가한다. <code>CircularProgressIndicator</code> widget 에 백분율 값을 넣어 원형 그래프를 만들 수 있다.</p>
<p>소모 칼로리 부분에는 지금은 숫자가 하드코딩되어 있어서 천 단위로 콤마를 찍어 주었지만 실제 데이터가 들어오면 자동으로 되지 않고 따로 콤마를 찍어 주어야 한다. <a href="https://pub.dev/packages/intl">intl</a> package를 사용하면 이를 자동으로 설정되게 할 수 있다.</p>
<p>숫자 데이터를 변수로 빼서 이를 구현해 보았다.</p>
<blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/fd01478f-b2c3-4cf9-a8db-9975bbd5aec3/image.png" alt=""></p>
</blockquote>
<p>그리고 적절한 여백을 주어 화면을 개선해 보면,</p>
<blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/d756972c-0563-45df-aae6-0855014aa6af/image.png" alt=""></p>
</blockquote>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/e57725716de2e3b4702d49d6506f78c25f6de0db">&gt;&gt;&gt; GitHub Repository at this point (e577257)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 14 | Flutter 데모 앱 만들기: 데이터 구조 개선]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-14</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-14</guid>
            <pubDate>Sat, 01 Aug 2026 00:27:23 GMT</pubDate>
            <description><![CDATA[<h1 id="day-14--flutter-데모-앱-만들기-데이터-구조-개선">DAY 14 | Flutter 데모 앱 만들기: 데이터 구조 개선</h1>
<p>앱 개발을 비롯한 클라이언트 개발은 기본적으로 화면과 로직을 구분하여 작성한다. 따라서 데이터를 UI와 구분하기 위해 data class를 사용한다. (아키텍처 관점에서는 Model이라고 칭한다.)  우리 코드는 그렇게 작성되어 있지 않지만 말이다.</p>
<p>같은 데이터도 상황과 맥락에 따라, 그리고 구현 스타일에 따라 다른 방식으로 구현될 수 있다. 이래도 되고 저래도 될 것 같을 땐 개발 시간은 조금 더 걸리더라도 유지보수성이 좋은 방식을 선택하는 게 좋다. (오버엔지니어링인지 적절히 판단하여 무엇이 최적인지 결정할 수 있다면 더 좋을 것이다.)</p>
<h2 id="데이터-구조를-class로-변환">데이터 구조를 Class로 변환</h2>
<p>기존 <code>WorkoutListPage</code> widget에는 이미지, 운동 이름, 운동 시간 데이터가 <code>Map</code> 에 담긴 채 <code>List&lt;Map&gt; workoutList</code> 를 구성하고 있다.</p>
<p>서로 연관되어 있는 데이터끼리 <code>Map</code> 으로 묶어서 사용하였기에 N개의 <code>List</code> 에 각각의 데이터를 따로 담아두는 것보다는 낫지만, 여전히 문제가 발생할 여지가 있다. key 값을 잘못 작성하는 실수를 할 수도 있고, 필요한 데이터 중 일부를 누락시킨 채 작성할 수도 있다.</p>
<p>그마저도 하지 않고 N개의 <code>List</code> 에 각각의 데이터를 따로 담아 놓으면 그들에 대한 규칙성이 코딩으로 드러나지 않아 유지보수 도중 잘못 사용될 여지가 많다. 누가 읽어도 연관성을 파악할 수 있도록 작성하는 게 좋다. 그리고 애초에 연관성을 해치지 못하도록 구조적 안정성을 가진 코드로 작성하면 더 좋다. “나 아닌 누군가가 이걸 봤을 때 의도대로 흘러갈 수 있는가”를 고려하며 작성해야 한다.</p>
<h3 id="data-class-생성하기">data class 생성하기</h3>
<p>기존에 <code>Map</code> 으로 작성되어 있는 데이터를 class로 변환하여 필요한 데이터 항목과 자료형을 명시해 주도록 하겠다. 이미지, 운동 이름, 운동 시간뿐만 아니라 몇 가지 추가 데이터도 사용하겠다.</p>
<p>실습을 위한 데모 앱이므로 수업에서는 모든 데이터를 data class에 담지 않았지만, AI를 통해 텍스트를 뽑아내어 추가로 넣어 보았다. data class는 별도의 파일로 작성한다. 보통은 별도의 디렉토리를 생성하여 집어넣지만, 우리는 우선 파일 구조를 고려하지 않고 <code>lib</code> 에 바로 집어넣고 나중에 refactor 하도록 하겠다. (난 이런 방식을 썩 좋아하진 않는다.)</p>
<blockquote>
<p><code>lib/workout.dart</code></p>
</blockquote>
<pre><code class="language-dart">class Workout {
  String name;
  int minutes;
  String imageName;
  String audioName;
  String description;
  String target;
  String recommend;
  int kcal;
&gt;
  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,
  });
}</code></pre>
<blockquote>
<p><code>lib/workout_list_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;workout.dart&#39;;
&gt;
class WorkoutListPage extends StatelessWidget {
  WorkoutListPage({super.key});
&gt;
  final List&lt;Workout&gt; workouts = [
    Workout(
      name: &#39;스쿼트&#39;,
      imageName: &#39;squat.png&#39;,
      minutes: 30,
      audioName: &#39;squat.mp3&#39;,
      description: &#39;하체 근력을 강화하고\n탄력 있는 엉덩이 라인을 만드는\n대표적인 전신 운동입니다.&#39;,
      target: &#39;배, 상체 근육&#39;,
      recommend: &#39;뱃살이 고민이에요\n체지방 태우고 싶어요&#39;,
      kcal: 200,
    ),
    Workout(
      name: &#39;마운틴 클라이머&#39;,
      minutes: 20,
      imageName: &#39;mountain_climber.png&#39;,
      audioName: &#39;mountain_climber.mp3&#39;,
      description: &#39;엎드린 자세에서 다리를 빠르게 교차하며\n코어와 심폐 지구력을 동시에 기르는\n유산소성 운동입니다.&#39;,
      target: &#39;전신, 복부 코어&#39;,
      recommend: &#39;칼로리 태우고 싶어요\n전신 탄력 원해요&#39;,
      kcal: 50,
    ),
    // ...생략
  ];
&gt;
  // 생략
}</code></pre>
<h3 id="데이터를-사용하는-코드-수정하기">데이터를 사용하는 코드 수정하기</h3>
<p><code>List&lt;Map&gt; workoutList</code> 를 사용하던 코드를 <code>List&lt;Workout&gt; workouts</code> 를 사용하도록 수정해야 한다.</p>
<p><code>getWorkoutList()</code> method의 <code>for</code> 문 앞부분만 수정하면 된다.</p>
<blockquote>
<p>기존 코드</p>
</blockquote>
<pre><code class="language-dart">    for (var i = 0; i &lt; workoutList.length; i++) {
      String name = workoutList[i][&#39;name&#39;];
      String image = workoutList[i][&#39;image&#39;];
      int minutes = workoutList[i][&#39;minutes&#39;];
      //생략
    }</code></pre>
<blockquote>
<p>수정 코드</p>
</blockquote>
<pre><code class="language-dart">    for (var i = 0; i &lt; workouts.length; i++) {
      String name = workouts[i].name;
      String image = workouts[i].imageName;
      int minutes = workouts[i].minutes;
      //생략
    }</code></pre>
<h2 id="overflow-해결">overflow 해결</h2>
<p><a href="https://velog.io/@peeeeeter_j/sesac-2026-flutter-12-1">Challenge</a>를 수행한 사람들은 <code>ListView</code> 로 refactor 하였기 때문에 overflow가 사라졌지만 그 작업을 하지 않은 사람들을 위해 overflow를 처리하는 시간을 가졌다.</p>
<p><code>Column</code> widget을 <code>SingleChildScrollView</code> 로 감싸면 쉽게 overflow를 해결할 수 있다. 개별 항목을 클릭할 수 있는 형태로 만들기 위해서는 각 <code>Row</code> widget을 <code>GestureDetector</code> 로 감싸면 된다. <code>ListTile</code> 로 이미 변경한 경우에는 <code>onTap</code> property를 추가하는 것만으로 클릭 가능한 상태로 만들 수 있다.</p>
<h2 id="데이터-전달하기">데이터 전달하기</h2>
<p>우리의 운동 데이터는 <code>WorkoutListPage</code> 에서만 사용하는 게 아니라 <code>WorkoutGuidePage</code> 에서도 사용한다. <code>WorkoutGuidePage</code> 는 <code>Workout</code> object에 담긴 데이터를 보여주며, 좌우 버튼을 누르면 <code>List&lt;Workout&gt; workouts</code> 의 앞뒤 인덱스에 담긴 데이터로 전환된다.</p>
<p>데이터를 페이지 내에서 정의하지 않고 외부 파일에서 정의한 후 양쪽에서 해당 파일을 사용하는 게 코드 중복을 줄일 수 있어 더 좋은 구조지만, 우리는 먼지 코드 중복이 있는 방식으로 작성 후 나중에 refactor 하도록 하겠다.</p>
<h3 id="특정-데이터를-보여주는-페이지">특정 데이터를 보여주는 페이지</h3>
<p>페이지마다 데이터를 각자 가지고 있도록 중복으로 작성하는 것은 여러 페이지에서 동일한 데이터에 접근할 때 사용하는 가장 단순한 방식이다. 데이터를 일부 수정할 때 그것이 중복 작성되어 있는 모든 파일에 대해 수정 사항을 반영해야 하기 때문에 실수가 발생하기 쉬워 좋은 방식은 아니다.</p>
<p>일단 index 기반으로 적절한 <code>Workout</code> object에 접근하고 버튼을 작동시키는 기능을 구현하기 위해 <code>WorkoutListPage</code> 에서 정의한 데이터를 <code>WorkoutGuidePage</code> 에 그대로 붙여 넣은 후 진행해 보도록 하겠다.</p>
<p>데이터 관점에서 <code>WorkoutGuidePage</code> 는 데이터 하나당 페이지 하나처럼 구성되어 있다. 따라서 데이터에 접근할 때 매번 <code>workouts[i]</code> 로 접근하지 않고 <code>Workout _currentWorkout = workouts[i];</code> 에 해당 페이지에서 보여줄 데이터를 담아놓고 <code>_currentWorkout</code> 을 사용하도록 하겠다.</p>
<blockquote>
<h4 id="주의-사항">주의 사항!</h4>
<p><code>_WorkoutGuidePageState</code> 에서 <code>Workout _currentWorkout = workouts[0];</code> 를 시도하면 오류가 난다! <code>workouts</code> 가 아직 메모리에 올라가기 전에 그것에 접근하여 값을 뽑아오려고 하여 문제가 발생한 것이다.</p>
</blockquote>
<p>이럴 땐 <code>late Workout _currentWorkout;</code> 으로 해두고 <code>initState()</code> 에서 <code>_currentWorkout = workouts[_workoutIndex];</code> 해주어야 한다. <code>initState()</code> 에 대한 건 잠시 후에 lifecycle에 대해 이야기하며 좀 더 자세히 알아보자.</p>
<blockquote>
<p><code>lib/WorkoutGuidePage.dart</code> &gt; <code>_WorkoutGuidePageState</code></p>
</blockquote>
<pre><code class="language-dart">class _WorkoutGuidePageState extends State&lt;WorkoutGuidePage&gt; {
  final _player = AudioPlayer();
  Timer? _timer;
  bool _isPlaying = false;
&gt;
  final List&lt;Workout&gt; workouts = [
    Workout(
      name: &#39;스쿼트&#39;,
      imageName: &#39;squat.png&#39;,
      minutes: 30,
      audioName: &#39;squat.mp3&#39;,
      description: &#39;하체 근력을 강화하고\n탄력 있는 엉덩이 라인을 만드는\n대표적인 전신 운동입니다.&#39;,
      target: &#39;배, 상체 근육&#39;,
      recommend: &#39;뱃살이 고민이에요\n체지방 태우고 싶어요&#39;,
      kcal: 200,
    ),
    //중략
    Workout(
      name: &#39;햄스트링 스트레칭&#39;,
      minutes: 10,
      imageName: &#39;hamstring_stretch.png&#39;,
      audioName: &#39;hamstring_stretch.mp3&#39;,
      description: &#39;허벅지 뒷쪽 근육을 시원하게\n늘려주어 하체 유연성을 높이고\n피로를 풀어주는 스트레칭입니다.&#39;,
      target: &#39;허벅지 뒤쪽&#39;,
      recommend: &#39;다리가 자꾸 부어요\n유연성을 기를래요&#39;,
      kcal: 30,
    ),
  ];
&gt;
  late Workout _currentWorkout;
  late int _remainSeconds;
  int _workoutIndex = 0;
&gt;
  @override
  void initState() {
    super.initState();
    _player.setReleaseMode(ReleaseMode.loop);
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
  }
&gt;
  @override
  void dispose() {
    _player.dispose();
    _timer?.cancel();
    super.dispose();
  }
// 이하 생략
}</code></pre>
<p>하드코딩되어 있는 모든 부분을 <code>_currentWorkout</code> 의 property를 참조하도록 수정한다.</p>
<h3 id="이전다음-데이터-보여주기">이전/다음 데이터 보여주기</h3>
<p>우리의 <code>WorkoutGuidePage</code> 에는 좌우 버튼이 존재한다. 이것을 누르면 운동 목록의 이전 운동 또는 다음 운동 화면으로 넘어가도록 작성할 것이다.</p>
<p>다음 버튼을 눌렀을 때 호출되는 <code>void _next()</code> method와 이전 버튼을 눌렀을 때 호출되는 <code>void _prev()</code> method를 작성하여 <code>IconButton</code> widget의 <code>onPressed</code> property에 전달하는 callback function 내에서 호출한다.</p>
<blockquote>
<p><code>lib/WorkoutGuidePages.dart</code> &gt; <code>_WorkoutGuidePageState</code></p>
</blockquote>
<pre><code class="language-dart">  void _next() {
    _workoutIndex = (_workoutIndex + 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
  }
&gt;
  void _prev() {
    _workoutIndex = (_workoutIndex - 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
  }</code></pre>
<p>이 때, 데이터가 변경되어 화면을 변경해야 하니 widget이 다시 build될 수 있도록 <code>setState()</code> 을 사용해야 한다.</p>
<blockquote>
<p><code>lib/WorkoutGuidePages.dart</code></p>
</blockquote>
<pre><code class="language-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,
    ),
  ],
),
// 뒷 부분 생략</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/d4c025d1-8ecc-4222-836b-c937f01f0864/image.png" alt=""></p>
<p>이제 이전/다음 버튼을 누르면 이전/다음 운동의 정보로 화면 구성이 변경된다.</p>
<p>보다 효율적인 데이터 구조는 추후에 알아보도록 하겠다.</p>
<h3 id="lifecycle에-대하여">lifecycle에 대하여</h3>
<p>Flutter의 widget들은 lifecycle을 가진다. <code>StatelessWidget</code> 은 생성/소멸의 단순한 lifecycle만을 가지기에 보통 Flutter widget의 lifestyle이라고 하면 <code>StatefulWidget</code> 의 lifecycle을 말한다.</p>
<blockquote>
</blockquote>
<ul>
<li><strong>constructor</strong> : widget object 생성<blockquote>
</blockquote>
</li>
<li><strong><code>createState()</code></strong> : widget이 생성될 때 가장 먼저 호출된다. state object를 생성한다.</li>
<li><strong><code>initState()</code></strong> : state object가 생성된 직후 단 한 번 호출된다. 초기화 및 listener 등록이 필요할 때 override 하여 사용한다.</li>
<li><strong><code>didChangeDependencies()</code></strong> : <code>initState()</code> 직후 한 번 실행되며, 그 이후로는 widget이 의존하는 상위 데이터가 바뀔 때 호출된다. (<code>Theme.of(context)</code>, <code>Provider</code>, <code>Bloc</code> 등) <code>BuildContext</code> 기반의 초기 데이터를 가져와 사용할 때 override 하여 사용한다.<blockquote>
</blockquote>
</li>
<li><strong><code>build()</code></strong>: 상태가 바뀔 때마다 반복해서 호출된다. 화면에 UI를 그려준다. widget tree를 반환하며, 반드시 override 해야 한다.<blockquote>
</blockquote>
</li>
<li><strong><code>didUpdateWidget()</code></strong> : parent widget이 변경되어 현재 widget의 configuration이 바뀔 때 호출된다. parent widget으로부터 받은 새 데이터와 기존 데이터를 비교하여 애니메이션을 다시 실행하거나 controller를 재설정하고자 할 때 override 하여 사용한다.</li>
<li><strong><code>setState()</code></strong> : 내부 데이터 변화를 Flutter 프레임워크에 알려 <code>build()</code> 를 다시 실행하도록 유도할 때 callback function을 담아 호출한다. 데이터 변화를 야기하는 코드를 callback function에 담거나, 데이터 변화를 야기하는 코드 직후에 호출한다.<blockquote>
</blockquote>
</li>
<li><strong><code>deactivate()</code></strong> : widget이 widget tree에서 제거될 때 호출된다. widget tree에서 제거되었으나 소멸하지 않고 다른 곳으로 옮겨지는 경우는 흔치 않아 거의 사용하지 않는다.</li>
<li><strong><code>dispose()</code></strong> : widget이 영구적으로 소멸할 때 호출된다. <code>initState()</code> 에서 생성했던 리소스들을 해제하여 메모리 누수를 방지할 때 override 하여 사용한다.</li>
</ul>
<p><code>setState()</code> 만 직접 호출하며 나머지는 override하여 사용한다. override 시 super class의 method를 먼저 호출하고 필요한 코드를 작성한다. 단, <code>dispose()</code> 는 반대로 필요한 코드를 작성하고 super class의 method를 호출한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">@override
void initState() {
  super.initState();
  // 필요한 코드 작성
}
&gt;
@override
void dispose() {
  // 필요한 코드 작성
  super.dispose();
}</code></pre>
<blockquote>
<h4 id="간단히-표로-정리하면">간단히 표로 정리하면,</h4>
<table>
<thead>
<tr>
<th align="center">lifecycle function</th>
<th align="center">호출 상황</th>
<th align="center">주요 역할 및 사용법</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>createState()</code></td>
<td align="center">Flutter가 <code>StatefulWidget</code>을 메모리에 올려 widget tree에 삽입할 때 가장 먼저 호출</td>
<td align="center">widget의 state를 관리하는 고유의 <code>State</code> object를 생성하여 반환.</td>
</tr>
<tr>
<td align="center"><code>initState()</code></td>
<td align="center"><code>State</code> 객체가 생성된 후 widget tree에 삽입될 때 단 한 번 호출</td>
<td align="center"><code>BuildContext</code> 나 widget 속성에 의존하는 일회성 데이터 초기화 수행</td>
</tr>
<tr>
<td align="center"><code>didChangeDependencies()</code></td>
<td align="center"><code>initState()</code> 호출 직후 또는 <code>InheritedWidget</code> 등 의존하는 데이터가 변경되었을 때 호출</td>
<td align="center">widget tree의 변경이나 데이터 의존성 변경에 대응하는 초기화 처리.</td>
</tr>
<tr>
<td align="center"><code>build()</code></td>
<td align="center">초기화 완료 후, 또는 <code>setState()</code>, <code>didUpdateWidget()</code> 등이 호출되어 화면을 다시 그려야 할 때 수시로 호출</td>
<td align="center">전달된 <code>BuildContext</code> 를 기반으로 해당 sub tree의 UI 구조를 정의하고 widget을 반환.</td>
</tr>
<tr>
<td align="center"><code>didUpdateWidget()</code></td>
<td align="center">parent widget이 rebuild되어 runtimeType과 Key가 동일한 새 widget 설정으로 변경을 요청할 때 호출</td>
<td align="center">이전 위젯과 새 위젯의 속성을 비교하여 설정 변경에 대응. 이후 자동으로 <code>build()</code> 호출.</td>
</tr>
<tr>
<td align="center"><code>setState()</code></td>
<td align="center">개발자가 내부 데이터를 변경하고 UI를 갱신하고 싶을 때 직접 호출</td>
<td align="center">Flutter에게 내부 state가 변경되었음을 알리고, <code>build()</code> 가 다시 실행되도록 요청.</td>
</tr>
<tr>
<td align="center"><code>deactivate()</code></td>
<td align="center">widget이 widget tree에서 일시적으로 제거될 때 호출.</td>
<td align="center">현재 요소와 widget tree 내 다른 요소 사이의 연결 관계를 정리.</td>
</tr>
<tr>
<td align="center"><code>dispose()</code></td>
<td align="center">제거된 sub tree가 현재 프레임 종료 시까지 재삽입되지 않아 영구적으로 소멸할 때 호출</td>
<td align="center">widget이 유지하고 있던 모든 리소스를 해제. 이후 <code>mounted</code> 는 <code>false</code> 가 되며 <code>setState()</code> 호출 시 에러 발생.</td>
</tr>
<tr>
<td align="center"><code>reassemble()</code></td>
<td align="center">디버깅 중 애플리케이션이 Hot Reload 될 때 호출</td>
<td align="center"><code>initState()</code> 에서 준비했던 데이터들을 다시 초기화할 수 있는 기회를 제공.</td>
</tr>
<tr>
<td align="center"><code>activate()</code></td>
<td align="center"><code>deactivate()</code> 로 widget tree를 벗어났던 widget이 동일 프레임 내에 다른 위치로 다시 삽입될 때 호출</td>
<td align="center">새로운 위치의 widget tree에 맞추어 <code>State</code> object가 화면을 새로 그릴 수 있도록 적응할 기회를 제공.</td>
</tr>
</tbody></table>
</blockquote>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/b1c84b797f7f4f03af9cbf58412aad18d4a0826b">&gt;&gt;&gt; GitHub Repository at this point (b1c84b7)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 13-1 | Flutter App architecture (2) 앱 아키텍처 가이드]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-13-1</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-13-1</guid>
            <pubDate>Thu, 30 Jul 2026 05:36:38 GMT</pubDate>
            <description><![CDATA[<h1 id="day-13-1--flutter-app-architecture-2-앱-아키텍처-가이드">DAY 13-1 | Flutter App architecture (2) 앱 아키텍처 가이드</h1>
<p>이것은 어디까지나 가이드라인일 뿐 확고한 규칙은 아니다. 프로젝트의 요구사항에 맞게 적절히 적용하도록 하자.</p>
<p><strong>관심사 분리</strong>는 Flutter 앱을 설계할 때 가장 우선적으로 따라야 하는 핵심 원칙이다. 크게 UI 레이어와 데이터 레이어라는 두 가지 넓은 영역으로 나누고, 각 레이어는 다시 명확한 역할과 책임, 잘 정의된 인터페이스, 경계 및 의존성을 가진 여러 컴포넌트로 세분화된다.</p>
<h2 id="mvvm">MVVM</h2>
<p>Model-View-ViewModel 아키텍처 패턴은 애플리케이션을 <code>Model</code>, <code>View</code>, <code>ViewModel</code>의 세 개 영역으로 구분한다. <code>View</code> 와 <code>ViewModel</code> 은 애플리케이션의 UI 레이어를 구성하고, <code>Repository</code> 와 서비스가 MVVM의 <code>Model</code> 에 해당하는 데이터를 나타낸다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/8e37de2d-1203-4025-b7e6-0e4ec1a52556/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/9bf2232f-4e83-42ee-a80a-56409e1834a8/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><span style="color:#787878; font-size:75%">이미지 출처: <a href="https://docs.flutter.dev/app-architecture/guide">Flutter 공식문서</a></span></p>
<blockquote>
<h4 id="애플리케이션-기능-구성">애플리케이션 기능 구성</h4>
</blockquote>
<ul>
<li><code>View</code> : UI를 표현하는 것 (1개)</li>
<li><code>ViewModel</code> : 로직을 처리하는 것 (1개)</li>
<li><code>Repository</code> : 데이터의 SSOT역할을 하는 것 (1개 이상)</li>
<li><code>Service</code> : 외부 API와 상호작용하는 것 (0개 이상)</li>
</ul>
<p>복잡한 로직을 가진 앱은 UI 레이어와 데이터 레이어 사이에 별도의 로직 레이어를 두기도 한다. 앞서 살펴본 계층형 아키텍처와 단방향 데이터 흐름에서처럼 말이다. 그런 로직 레이어를 &#39;도메인 레이어&#39;라고 부르기도 한다.</p>
<h2 id="ui-레이어">UI 레이어</h2>
<p>사용자와 상호작용하는 영역. 애플리케이션의 데이터를 사용자에게 보여주고 사용자의 입력을 받는다. 여기서 입력이란 입력 폼에 작성한 것뿐만 아니라 tap event 같은 gesture 입력도 포함한다. UI는 데이터 변화 및 사용자 입력의 영향을 받아 반응한다.</p>
<p>MVVM 패턴에서는 UI 레이어가 두 개의 아키텍처 컴포넌트로 구성된다.</p>
<blockquote>
</blockquote>
<ul>
<li><strong>Views</strong>
애플리케이션 데이터를 사용자에게 어떻게 보여줄지 기술하는, 하나의 기능을 구성하는 여러 widget의 조합이다. 사용자 상호작용에 반응하여 View Model에 event를 전달하는 역할도 한다.</li>
<li><strong>View Models</strong>
애플리케이션 데이터를 사용자에게 보여줄 형식으로 변형하여 UI State로 변환하는 로직을 담고 있다. <blockquote>
</blockquote>
이 둘은 일대일 관계를 형성한다. View Model이 UI State를 관리하고 View가 그 State를 화면에 보여주는 거다.</li>
</ul>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/4ccfff83-3df9-4a24-80b1-a58329c0967a/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><span style="color:#787878; font-size:75%">이미지 출처: <a href="https://docs.flutter.dev/app-architecture/guide">Flutter 공식문서</a></span></p>
<p>View에는 비즈니스 로직이 포함되지 않으며 조건에 따른 widget 보이기/숨기기, 애니메이션 로직, 기기 정보 및 크기에 따른 레이아웃 로직, 단순한 라우팅 로직 정도만 포함된다고 한다. Flutter의 <code>State&lt;StatefulWidget&gt;</code> 에 들어가는 로직 정도인 것 같다.</p>
<p>ViewModel에는 애플리케이션 데이터를 가져와 View에 표시하기 적합한 형태로 변환하는 로직이 포함된다. UI가 rebuild되어도 State가 유지되도록 하는 역할도 수행한다. event handler에 제공할 수 있는 callback function을 View에 제공하기도 하는데, 이런 callback function을 command라고 부르는 모양이다. 이는 command 패턴이라는 디자인 패턴에서 따온 거라나.</p>
<p>command는 View Model class의 method로 작성되어 View class에서 그 내부 구현을 알지 못해도 복잡한 로직을 실행할 수 있도록 한다.</p>
<p>Flutter에서 MVVM을 어떻게 구현하는지 더 알고 싶다면 <a href="https://docs.flutter.dev/data-and-backend/state-mgmt/intro">state 관리</a>에 대한 문서를 살펴보면 좀 더 알 수 있다는 듯.</p>
<h2 id="데이터-레이어">데이터 레이어</h2>
<p>Repository와 Service로 이루어진 데이터 레이어가 비즈니스 데이터 및 로직을 본격적으로 다룬다. 입출력을 잘 정의해야 재사용성과 테스트 용이성을 높일 수 있다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/2ba739ba-1d05-4175-a506-c9930f6f8f12/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><span style="color:#787878; font-size:75%">이미지 출처: <a href="https://docs.flutter.dev/app-architecture/guide">Flutter 공식문서</a></span></p>
<p>앞서 <code>Repository</code> class가 SSOT로서 존재한다는 것을 살펴본 바와 같이 Repository는 진실 공급원으로서 작용하는데, Service로부터 데이터를 주기적으로 가져와 raw data를 View Model에서 사용하기 적절한 도메인 모델로 변환한다.</p>
<p>Repository와 View 모델은 N:M 관계를 갖는다. View 모델은 여러 개의 Repository로부터 데이터를 가져올 수 있고 Repository도 여러 개의 View 모델에서 데이터를 쓸 수 있게 한다.</p>
<p>Repository끼리는 서로 알지 못하며, 둘 이상의 Repository의 조합이 필요하다면 그것은 View 모델이나 도메인 레이어에서 수행해야 한다.</p>
<p>애플리케이션의 SSOT인 만큼 Repository는 앱 전반의 lifecycle state를 관리하기 이상적이다. 영구 저장할 필요는 없지만 여러 View Model 간에 공유해야 할 것들을 Repository에 담는 거다.</p>
<p>Service는 앱 가장 밑단에 존재하며, 자체적인 state를 가지지 않는다. API 엔드포인트를 wrap하여 비동기 응답 객체로 외부에 내보낸다. Dart 코드 외부에 존재하는 데이터를 필요로 할 때 유용하다. Service와 Repository도 N:M 관계를 갖는다.</p>
<h2 id="도메인-레이어">도메인 레이어</h2>
<p>도메인 레이어는 항상 존재하는 건 아니고, 앱 규모에 따라 View 모델을 복잡하게 만드는 로직들을 별도로 추상화하고자 할 때 사용한다. Repository로부터 데이터를 가져와 UI 계층에 적합한 형태로 가공하여 상호작용을 더 단순하고 재사용 가능하게 만든다. 이런 역할을 하는 class는 흔히 interactor 혹은 use case라고 칭한다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/faff26c2-2bbc-4982-ae1c-9ed5430b6faa/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><span style="color:#787878; font-size:75%">이미지 출처: <a href="https://docs.flutter.dev/app-architecture/guide">Flutter 공식문서</a></span></p>
<p>주로 여러 Repository의 데이터를 결합행 햐거나 로직이 복잡하거나 서로 다른 View 모델에서 로직이 반복될 때 사용된다. 도메인 레이어를 추가할 땐 여러 장단점이 있기에 필요하다고 판단되었을 경우에만 추가하도록 하자.</p>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">장점</th>
<th align="center">단점</th>
</tr>
</thead>
<tbody><tr>
<td align="center">View 모델 코드 중복 방지</td>
<td align="center">복잡도 증가 및 인지 부하 상승</td>
</tr>
<tr>
<td align="center">로직을 분리하여 테스트 용이성 상승</td>
<td align="center">테스트에 추가적인 mock 필요</td>
</tr>
<tr>
<td align="center">View 모델의 가독성 증가</td>
<td align="center">추가적인 boilerplate 코드 필요</td>
</tr>
</tbody></table>
<h3 id="데이터-접근">데이터 접근</h3>
<p>도메인 레이어 도입을 고려할 때 View 모델이 Repository에 직접 접근할 수 있게 열어둘 것인지 반드시 use case를 거치도록 규칙을 강제할 것인지도 결정해야 한다. 전자의 경우 중복 코드 등 use case로 추가할 필요가 있는 부분을 발견할 때마다 새로운 use case를 추가하게 된다. 그리고 후자의 경우 단순한 로직이어도 무조건 use case를 추가하게 된다.</p>
<p>후자의 경우 도메인 레이어 도입의 장단점이 극명하게 드러난다. 필요한 순간에만 use case를 추가하는 전자의 방식이 꽤나 합리적인 접근법이다. 대부분의 코드가 use case를 거치고 있다면 그때 가서 나머지 코드도 use case를 거도록 refactor 해도 좋다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/18177c41-bf3c-4999-8da0-9dbbf6a86d77/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><span style="color:#787878; font-size:75%">이미지 출처: <a href="https://docs.flutter.dev/app-architecture/guide">Flutter 공식문서</a></span></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 13 | Flutter 데모 앱 만들기: 상세 페이지에 기능 추가하기]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-13</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-13</guid>
            <pubDate>Wed, 29 Jul 2026 09:04:22 GMT</pubDate>
            <description><![CDATA[<h1 id="day-13--flutter-데모-앱-만들기-상세-페이지에-기능-추가하기">DAY 13 | Flutter 데모 앱 만들기: 상세 페이지에 기능 추가하기</h1>
<p>Stateless로 작성한 상세 페이지 화면에 <code>onPressed</code> 제스처에 대한 콜백함수를 작성하고 state를 사용하는 방식으로 기능을 추가해 보자.</p>
<blockquote>
<h4 id="모바일에서-사용되는-주요-제스처">모바일에서 사용되는 주요 제스처</h4>
<table>
<thead>
<tr>
<th align="center">gesture</th>
<th align="center">description</th>
</tr>
</thead>
<tbody><tr>
<td align="center">tap</td>
<td align="center">손가락 하나로 건드린다 (툭 치는 느낌)</td>
</tr>
<tr>
<td align="center">double tap</td>
<td align="center">손가락 하나로 연달아 건드린다</td>
</tr>
<tr>
<td align="center">drag</td>
<td align="center">손가락 하나로 누른 채 다른 방향으로 움직인다</td>
</tr>
<tr>
<td align="center">flick</td>
<td align="center">손가락 하나로 누르다 다른 방향으로 튕긴다</td>
</tr>
<tr>
<td align="center">pinch</td>
<td align="center">손가락 두 개로 누른 채 오므린다</td>
</tr>
<tr>
<td align="center">spread</td>
<td align="center">손가락 두 개로 누른 채 벌린다</td>
</tr>
<tr>
<td align="center">press</td>
<td align="center">손가락 하나로 누른다 (지긋이 누르는 느낌)</td>
</tr>
<tr>
<td align="center">press and tap</td>
<td align="center">손가락 하나로 press 한 채 다른 손가락으로 tap 한다</td>
</tr>
<tr>
<td align="center">press and drag</td>
<td align="center">손가락 하나로 press 한 채 다른 손가락으로 drag 한다</td>
</tr>
<tr>
<td align="center">rotate</td>
<td align="center">두 손가락으로 누른 채 상대적 위치를 회전한다</td>
</tr>
</tbody></table>
</blockquote>
<p>그 외에도 여러 제스처가 있을 수 있다.</p>
<p><a href="https://api.flutter.dev/flutter/widgets/GestureDetector-class.html">GestureDector 공식 문서</a>에서 Flutter에서 사용 가능한 제스처를 확인할 수 있다. 커스텀 제스처도 정의할 수는 있지만 UX 측면에서 얼마나 좋을지는, 글쎄.</p>
<p>버튼은 이 중에서 press에 대한 <code>onPressed</code> property에 callback function을 전달해야 한다.</p>
<h2 id="state를-포함하는-widget으로-변환하기">state를 포함하는 widget으로 변환하기</h2>
<p>상세 페이지의 재생 버튼이 작동하도록 기능을 추가해 보자. <code>assets/audio</code> 디렉토리를 생성하여 MP3 파일을 넣어놓고 버튼을 누르면 소리가 재생되도록 할 것이다.</p>
<p>먼저 Stateless Widget으로 작성되어 있는 <code>WorkoutGuidePage</code> widget을 Stateful Widget으로 변경한다. 이는 class 이름에 마우스를 올리고 Context Actions에서 [Convert to StatefulWidge]을 눌러 쉽게 할 수 있다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class WorkoutGuidePage extends StatefulWidget {
  const WorkoutGuidePage({super.key});
&gt;
  @override
  State&lt;WorkoutGuidePage&gt; createState() =&gt; _WorkoutGuidePageState();
}
&gt;
class _WorkoutGuidePageState extends State&lt;WorkoutGuidePage&gt; {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // 생략
    );
  }
}</code></pre>
<blockquote>
<h4 id="stateful-widget">Stateful Widget</h4>
</blockquote>
<ul>
<li><code>StatefulWidget</code> 을 <code>extends</code> 하는 widget class와 <code>State&lt;StatefulWidget&gt;</code> 을 <code>extends</code> 하는 state class로 구성된다.</li>
<li>state는 widget에 binding되며 lifecycle이 다르다.</li>
<li>state가 더 오래 살아 있고, 데이터 변경에 따라 widget이 갱신된다.</li>
<li>데이터는 widget이 아닌 state 쪽에 두고, widget은 데이터에 변화에 따라 state에 의해 다시 build된다.</li>
<li>데이터가 변경되었을 때 build를 다시 하여 화면을 다시 그려주는 lifecycle function은 <code>setState()</code> 다.</li>
</ul>
<p>MP3 재생을 위한 package를 <a href="https://pub.dev">pub.dev</a>에서 탐색한다. 대중적인 건 <a href="https://pub.dev/packages/just_audio">just_audio</a>와 <a href="https://pub.dev/packages/audioplayers">audioplayers</a>가 있다. <code>just_audio</code> 는 응용 능력이 많고 <code>audioplayers</code> 는 좀 더 순수한 기능을 담고 있다. 추구하는 바에 따라 선택하면 되는데 우리는 <code>audioplayers</code> 를 사용한다.</p>
<h3 id="package-가져오기">package 가져오기</h3>
<p><code>pubspec.yaml</code> 의 <code>dependencies:</code> 부분에 패키지 정보를 추가해야 사용할 수 있다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
# 뒷 부분 생략</code></pre>
<p><code>assets/audio</code> 디렉토리에 MP3 파일을 담고 이것도 등록해 준다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
flutter:
&gt;
  uses-material-design: true
&gt;
  assets:
    - assets/
    - assets/audio/
# 뒷 부분 생략</code></pre>
<h3 id="로직-작성하기">로직 작성하기</h3>
<p>공식문서의 <a href="https://pub.dev/packages/audioplayers">README.md</a> 문서를 따라 패키지를 사용하는 코드를 작성한다. 버튼을 누르면 정지 버튼으로 바뀌며 소리가 나기 시작하고 다시 누르면 재생 버튼으로 바뀌며 재생이 멈추는 코드로 작성할 것이므로, 기존에 작성한 <code>IconButton</code> widget에서 [refactor &gt; Extract Method]을 수행한다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
&gt;
class _WorkoutGuidePageState extends State&lt;WorkoutGuidePage&gt; {
  final player = AudioPlayer();
&gt;
  IconButton buildPlayButton() {
    return player.state == PlayerState.playing
        ? IconButton(
            onPressed: () async {
              await player.stop();
              setState(() {});
            },
            icon: Icon(Icons.stop_circle, color: Colors.blue),
            iconSize: 70,
          )
        : IconButton(
            onPressed: () async {
              await player.play(AssetSource(&#39;audio/squat.mp3&#39;));
              setState(() {});
            },
            icon: Icon(Icons.play_circle, color: Colors.blue),
            iconSize: 70,
          );
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text(&#39;Workout Guide&#39;), centerTitle: true),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          Row(
              // 생략: 제목이 들어가는 곳
          ),
          AspectRatio(
              // 생략: 사진이 들어가는 곳
          ),
          Padding(
              // 생략: 부연 설명이 들어가는 곳
          ),
          Padding(
              // 생략: 소요 시간이 들어가는 곳
          ),
          buildPlayButton(),
        ],
      ),
    );
  }
}</code></pre>
<p><code>onPressed</code> property에 전달하는 callback function은 <code>AudioPlayer</code> 를 사용하는 method가 concurrency 하기 때문에 <code>async</code> 를 붙여 주어야 한다.</p>
<p><code>setState(() {});</code> 를 해주지 않으면 화면이 갱신되지 않는데, 이것이 &quot;state가 변경되었으니 build를 다시하여 widget을 갱신하라&quot; 하고 말해주는 녀석이기 때문이다. 보통은 <code>setState(() {});</code> 의 callback function에 state가 변경되는 코드를 집어넣어 무엇이 state를 바꾸는지 명시해 주는 경우가 많다. 우리 코드에서는 state가 바뀌는 부분에 concurrency가 적용되어 있어 <code>await</code> 후에 <code>setState(() {});</code> 한다.</p>
<blockquote>
<h4 id="setstate-async--하면-안-되나"><code>setState(() async {});</code> 하면 안 되나?</h4>
<blockquote>
</blockquote>
</blockquote>
<pre><code class="language-dart">onPressed: () async {
  await player.play(AssetSource(&#39;audio/squat.mp3&#39;));
  setState(() {});
}</code></pre>
<blockquote>
</blockquote>
<p>와 같이 작성하는 대신</p>
<blockquote>
<blockquote>
</blockquote>
</blockquote>
<pre><code class="language-dart">onPressed: () {
  setState(() async {
    await player.play(AssetSource(&#39;audio/squat.mp3&#39;));
  });
}</code></pre>
<blockquote>
</blockquote>
<p>와 같이 작성하면 안 되는지 의문이 들 수 있다. 하지만 이렇게 사용할 수 없다.
<code>setState()</code> 에 concurrent function을 전달하면 lifecycle이 꼬여 원하는 대로 화면이 그려지지 않을 수 있다.</p>
<blockquote>
</blockquote>
<p>[소리 재생 시작 → setState 호출 → 화면 갱신]의 흐름으로 진행되어야 하는데, 순서가 꼬이면 [setState 호출 → 화면 갱신 → 소리 재생 시작]이 되어 소리가 재생되기 시작해도 화면이 갱신되지 않을 수 있다.</p>
<blockquote>
</blockquote>
<p>결론적으로 <code>await</code> 가 필요한 작업은 <code>setState()</code> 외부에서 처리해야 한다.</p>
<h2 id="앱의-전체-디자인-설정-파일-theme">앱의 전체 디자인 설정 파일, <code>Theme</code></h2>
<p>디자인 명세를 Design System으로 정의해 놓고 하나의 파일로 관리하면 앱 전반적인 디자인 설정을 쉽게 변경할 수 있다. 강조색을 하드코딩했을 경우에는 모두 찾아서 바꾸어야 하지만 <code>accentColor</code> 로 지정해 놓았다면 이것만 바꾸면 된다거나. 텍스트 크기도 미리 설정해 놓고 사용하는 게 일관성 있게 구현하는 데 도움이 된다.</p>
<p>색상 및 폰트 크기 등을 앱 내에서 하드코딩하기보다는 theme를 지정해 놓고 그 안에 있는 값들을 사용하는 편이 유지보수하기 좋다. 가령 다크모드를 만들 경우 전체 테마만 다크모드로 바꾸면 세부적인 코드 구현을 수정하지 않고 한 번에 다크 모드로 변경할 수 있다.</p>
<p>Flutter에서는 <code>MaterialApp</code> 의 <code>theme</code> property를 사용하여 디자인 명세를 정의할 수 있다. <code>ThemeData</code> widget을 사용해도 되고 이를 기반으로 한 custom widget이나 외부 package를 사용해도 되는데, 우리는 <a href="https://pub.dev/packages/flex_color_scheme">flex_color_scheme</a> package를 사용하여 theme를 지정할 것 이다. <a href="https://rydmike.com/flexcolorscheme/themesplayground-latest">Themes Playground</a>에서 원하는 색 조합을 탐색할 수 있다.</p>
<p>이 또한 <code>pubspec.yaml</code> 의 <code>dependencies:</code> 부분에 패키지 정보를 추가해야 사용할 수 있다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter
&gt;
  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
  flex_color_scheme: ^8.4.0
# 뒷 부분 생략</code></pre>
<p>나는 Blue Whale이라는 이름의 theme를 적용해 보았다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;package:flex_color_scheme/flex_color_scheme.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
import &#39;workout_guide_page.dart&#39;;
&gt;
void main() {
  runApp(const MyApp());
}
&gt;
class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      //home: const LandingPage(),
      // home: WorkoutListPage(),
      home: WorkoutGuidePage(),
      theme: FlexThemeData.light(
        scheme: FlexScheme.blueWhale,
        fontFamily: &#39;Pretendard&#39;,
        subThemesData: const FlexSubThemesData(
          appBarBackgroundSchemeColor: SchemeColor.primary,
        )
      ),
    );
  }
}</code></pre>
<p>하드코딩 되어 있는 색상 및 폰트 크기를 theme에 정의된 값으로 변경하였다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<ul>
<li>재생 버튼 색상: <code>Colors.blue</code> → <code>Theme.of(context).colorScheme.secondary</code></li>
<li>제목 색상: default (black) → <code>Theme.of(context).colorScheme.primary</code></li>
<li>제목 크기: <code>30</code> → <code>Theme.of(context).textTheme.headlineMedium?.fontSize</code></li>
<li>설명 색상: <code>Colors.grey</code> → <code>Theme.of(context).colorScheme.primaryFixedDim</code></li>
<li>설명 크기: <code>12</code>  → <code>Theme.of(context).textTheme.bodySmall?.fontSize</code></li>
<li>(...나머지도 마찬가지로 변경)</li>
</ul>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/e244fedc-bc4b-462a-a246-3a4cb164f831/image.png" alt=""></p>
<p>매번 <code>Theme.of(context).colorScheme</code> 로 접근하여 색을 가져오고 <code>Theme.of(context).textTheme</code> 로 접근하여 텍스트 정보를 가져오고 하면 탐색 과정이 비효율적일 수 있다. 이 때, 상단에서 미리 <code>ColorScheme colorScheme = Theme.of(context).colorScheme;</code> 와 같이 가져와 놓으면 탐색 과정을 줄일 수 있다.</p>
<h2 id="기능-추가하기">기능 추가하기</h2>
<p>MP3 파일이 반복 재생되다가 운동 시간이 끝나면 자동으로 종료되도록 코드를 작성해 보자. 테스트를 위해 운동 시간을 30분이 아니라 3분으로 변경해 보겠다.</p>
<p>시간이 흐르는 건 <a href="https://api.flutter.dev/flutter/dart-async/Timer-class.html">Timer</a>를 사용하여 구현한다. <a href="https://api.flutter.dev/flutter/dart-async/Timer/Timer.periodic.html">Timer.periodic</a>라는 named constructor를 통해 정해진 시간마다 callback function을 수행하는 타이머를 생성할 수 있다.</p>
<p>재생 버튼을 누르면 소리가 나오기 시작하며 시간이 흐르기 시작하고, 버튼을 다시 누르면 (정지가 아닌!) 일시정지가 되며 시간이 멈추도록 작성하였다. 이에 따라 아이콘도 <code>Icons.pause_circle</code> 로 변경하였다.</p>
<p><code>_player.setReleaseMode(ReleaseMode.loop);</code> 를 통해 파일의 끝에 도달했을 때 처음부터 다시 실행되게 할 수 있다. 그런데 끝에 도달하고 다시 실행하기 전에 gap이 있어 버튼이 일시정지로 바뀌었다가 잠시 후 재생으로 바뀌더라. 이 gap을 없앨 수 있나 찾아보니 package가 native MediaPlayer 설정을 그대로 가져다 사용하는데 거기서 발생한 이슈라 어떻게 못 하는 모양이다. 음성이 끝날 때쯤 새 음성을 맞물리는 방식으로 어떻게 할 수 있다는데 그것까지는 여기선 너무 복잡하니까.</p>
<p>그래서 <code>buildPlayButton()</code> 에서 <code>_player.state == PlayerState.playing</code> 에 따라 버튼을 바꾸는 게 아니라 사전 정의된 <code>_isPlaying</code> 에 따라 바꾸는 것으로 수정했다.</p>
<p>수업에서는 <code>void initState()</code> 와 <code>void dispose()</code> 에서 플레이어의 lifecycle을 관리하는 건 다루지 않았지만 아무래도 있는 게 좋을 테니 그것도 추가해놨다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
class _WorkoutGuidePageState extends State&lt;WorkoutGuidePage&gt; {
  final _player = AudioPlayer();
  int _remainTime = 180;
  Timer? _timer;
  bool _isPlaying = false;
&gt;
  @override
  void initState() {
    super.initState();
&gt;   _player.setReleaseMode(ReleaseMode.loop);
  }
&gt;
  @override
  void dispose() {
    _player.dispose();
    _timer?.cancel();
    super.dispose();
  }
&gt;
  IconButton buildPlayButton() {
    // _isPlaying = _player.state == PlayerState.playing;
    return _isPlaying
        ? IconButton(
            onPressed: () async {
              await _player.pause();
              setState(() {
                _isPlaying = false;
              });
              _timer?.cancel();
            },
            icon: Icon(
              Icons.pause_circle,
              color: Theme.of(context).colorScheme.secondary,
            ),
            iconSize: 70,
          )
        : IconButton(
            onPressed: () async {
              await _player.play(AssetSource(&#39;audio/squat.mp3&#39;));
              setState(() {
                _isPlaying = true;
              });
              _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
                setState(() {
                  _remainTime--;
                });
                if (_remainTime &lt;= 0) {
                  timer.cancel();
                  _remainTime = 180;
                  _player.stop();
                  setState(() {
                    _isPlaying = false;
                  });
                }
              });
            },
            icon: Icon(
              Icons.play_circle,
              color: Theme.of(context).colorScheme.secondary,
            ),
            iconSize: 70,
          );
  }
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text(&#39;Workout Guide&#39;), centerTitle: true),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          Row(
              // 생략: 제목이 들어가는 곳
          ),
          AspectRatio(
              // 생략: 사진이 들어가는 곳
          ),
          Padding(
              // 생략: 부연 설명이 들어가는 곳
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 30),
            child: Row(
              children: [
                Expanded(
                  child: Container(
                    padding: EdgeInsets.all(16),
                    decoration: BoxDecoration(
                      border: Border.all(
                        color: Theme.of(context).colorScheme.outlineVariant,
                      ),
                      borderRadius: BorderRadius.all(Radius.circular(16)),
                    ),
                    child: Text(
                      &#39;${_remainTime ~/ 60}분 ${_remainTime % 60}초&#39;,
                      style: TextStyle(
                        fontSize: Theme.of(
                          context,
                        ).textTheme.titleLarge?.fontSize,
                        fontWeight: FontWeight.bold,
                        color: Theme.of(context).colorScheme.secondary,
                      ),
                      textAlign: TextAlign.center,
                    ),
                  ),
                ),
              ],
            ),
          ),
          buildPlayButton(),
    };
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/e04a61eb-7af8-4f98-a38d-b7f41ac1520d/image.png" alt=""></p>
<p>재생 버튼을 누르면 소리가 재생되며 [3분 0초]였던 게 [2분 59초]부터 해서 줄어들기 시작하고, 일시정지 버튼을 누르면 소리와 타이머가 멈추며, 다시 재생 버튼을 누르면 소리와 타이머가 멈췄던 곳부터 다시 시작한다. 끝에 도달하여 [0분 0초]가 되면 타이머가 멈추고 소리도 정지하며, 남은 시간이 초기화된다.</p>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/f4206183e61649fc863b0a0e6366afb296024092">&gt;&gt;&gt; GitHub Repository at this point (f420618)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 12-1 | Flutter 데모 앱 만들기: 화면 개선하기]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-12-1</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-12-1</guid>
            <pubDate>Mon, 27 Jul 2026 08:45:02 GMT</pubDate>
            <description><![CDATA[<h1 id="day-12-1--flutter-데모-앱-만들기-화면-개선하기">DAY 12-1 | Flutter 데모 앱 만들기: 화면 개선하기</h1>
<h2 id="리스트-화면-개선하기">리스트 화면 개선하기</h2>
<blockquote>
<h4 id="workoutlistpage-challenge"><a href="https://healthyclass.kr/markdown_contents/QZaFfIFFz5CBleIlnGgX/pVHVCaVgx7plEmTCkwbR">WorkoutListPage Challenge</a></h4>
<p><code>Column</code> 과 <code>Row</code> 를 통해 구현했던 것을 <code>ListView</code> 와 <code>ListTile</code> 로 리팩토링 해보자.</p>
</blockquote>
<ul>
<li><a href="https://api.flutter.dev/flutter/widgets/ListView-class.html">ListView 공식 문서</a></li>
<li><a href="https://api.flutter.dev/flutter/material/ListTile-class.html">ListTile 공식 문서</a></li>
</ul>
<p>기존의 <code>workout_list_page.dart</code> 파일을 <code>legacy_workout_list_page.dart</code> 로 복사해 놓고 수정했다.</p>
<p>먼저, <code>Row[Image, Text, Text]</code> 구조를 <code>ListTile[leading, title, trailing]</code> 구조로 변경하였다. 그리고 <code>Column[Row, Row, ...]</code> 구조를 <code>ListView[ListTile, ListTile, ...]</code> 구조로 변경하였다. </p>
<blockquote>
<p><code>lib/workout_list_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class WorkoutListPage extends StatelessWidget {
  WorkoutListPage({super.key});
&gt;
  final List&lt;Map&lt;String, dynamic&gt;&gt; workoutList = [
    {&#39;name&#39;: &#39;스쿼트&#39;, &#39;image&#39;: &#39;squat.png&#39;, &#39;minutes&#39;: 30},
    {&#39;name&#39;: &#39;마운틴 클라이머&#39;, &#39;image&#39;: &#39;mountain_climber.png&#39;, &#39;minutes&#39;: 20},
    {&#39;name&#39;: &#39;푸시업&#39;, &#39;image&#39;: &#39;push_up.png&#39;, &#39;minutes&#39;: 15},
    {&#39;name&#39;: &#39;윗몸 일으키기&#39;, &#39;image&#39;: &#39;sit_up.png&#39;, &#39;minutes&#39;: 15},
    {&#39;name&#39;: &#39;사이드 런지&#39;, &#39;image&#39;: &#39;side_lunge.png&#39;, &#39;minutes&#39;: 20},
    {&#39;name&#39;: &#39;덩키 킥&#39;, &#39;image&#39;: &#39;donkey_kick.png&#39;, &#39;minutes&#39;: 30},
    {&#39;name&#39;: &#39;사이드 플랭크&#39;, &#39;image&#39;: &#39;side_plank.png&#39;, &#39;minutes&#39;: 20},
    {&#39;name&#39;: &#39;리버스 플랭크&#39;, &#39;image&#39;: &#39;reverse_plank.png&#39;, &#39;minutes&#39;: 15},
    {&#39;name&#39;: &#39;힙 브릿지&#39;, &#39;image&#39;: &#39;hip_bridge.png&#39;, &#39;minutes&#39;: 25},
    {&#39;name&#39;: &#39;어깨 스트레칭&#39;, &#39;image&#39;: &#39;shoulder_stretch.png&#39;, &#39;minutes&#39;: 15},
    {&#39;name&#39;: &#39;햄스트링&#39;, &#39;image&#39;: &#39;hamstring_stretch.png&#39;, &#39;minutes&#39;: 10},
  ];
&gt;
  List&lt;ListTile&gt; getWorkoutList() {
    List&lt;ListTile&gt; workoutTiles = [];
&gt;
    for (var i = 0; i &lt; workoutList.length; i++) {
      String name = workoutList[i][&#39;name&#39;];
      String image = workoutList[i][&#39;image&#39;];
      int minutes = workoutList[i][&#39;minutes&#39;];
&gt;
      workoutTiles.add(
        ListTile(
          contentPadding: EdgeInsets.symmetric(vertical: 20, horizontal: 10),
          leading: Container(
            width: 100,
            height: 100,
            decoration: BoxDecoration(
              image: DecorationImage(image: AssetImage(&#39;assets/$image&#39;)),
              shape: BoxShape.circle,
            ),
          ),
          title: Text(
            &#39;${i + 1}. $name&#39;,
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          trailing: Text(
            &#39;$minutes분&#39;,
            style: TextStyle(fontSize: 20, color: Colors.blueAccent),
          ),
        ),
      );
    }
&gt;
    return workoutTiles;
  }
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Center(child: Text(&#39;Workout List&#39;))),
      body: Padding(
        padding: EdgeInsets.all(12),
        child: ListView(
          children: [
            ListTile(
              contentPadding: EdgeInsets.only(left: 40, right: 10),
              title: Text(
                &#39;운동&#39;,
                style: TextStyle(fontSize: 16, color: Colors.grey),
              ),
              trailing: Text(
                &#39;세트 당 소요시간&#39;,
                style: TextStyle(fontSize: 16, color: Colors.grey),
              ),
            ),
            ...getWorkoutList(),
          ],
        ),
      ),
    );
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/dac88646-e1d9-41e4-93a4-3b4e27e17a47/image.png" alt=""></p>
<p><code>ListView</code> widget은 기본적으로 scrollable하다. children이 <code>ListView</code> 의 범위를 넘어서면 overflow 되지 않고 scroll이 생긴다. 필요에 따라 <code>ListTile</code> widget의 <code>onTap</code> property에 적절한 callback function을 전달하여 각 항목을 선택 시 어떤 기능을 하도록 작성할 수 있다. 가령 각 항목을 선택하면 해당 운동에 대한 상세 페이지(<code>WorkoutGuidePage</code>)를 띄운다거나.</p>
<h2 id="상세-페이지-개선하기">상세 페이지 개선하기</h2>
<blockquote>
<h4 id="workoutguidepage-challenge"><a href="https://healthyclass.kr/markdown_contents/QZaFfIFFz5CBleIlnGgX/uEcMRl0lCKmoVTa9j02e">WorkoutGuidePage Challenge</a></h4>
<p>상세 페이지 화면을 좀 더 꾸며 보자.</p>
</blockquote>
<p>이것도 마찬가지로 기존의 <code>workout_guide_page.dart</code> 파일을 <code>legacy_workout_guide_page.dart</code> 로 복사해 놓고 수정했다.</p>
<p>기존 화면은 이미지가 너무 작게 들어가 있으니 키우도록 하겠다. 기존에 <code>Row[Icon, Image, Icon]</code> 으로 구현되어 있던 것을 <code>Container[Row[Icon, Icon]]</code> 구조로 변경하고, <code>Container</code> widget의 <code>decoration</code> property를 사용하여 이미지를 깔도록 하겠다.</p>
<p><code>Container</code> widget의 <code>decoration</code> 으로 이미지를 깔면 height가 <code>Row</code> 기준으로 shrink 하여 이미지가 잘려 나온다. 이 때, <code>Container</code> 에 임의의 height를 지정할 수도 있지만, 그렇게 하드코딩하지 않고 방법을 찾아 보았다.</p>
<p>이미지 비율이 정해져 있는 경우에는 <code>AspectRatio</code> widget으로 감싸는 방법이 있다고 한다. 네트워크 이미지를 사용하거나 같은 코드베이스를 여러 비율의 이미지를 띄울 때 사용하는 등 비율을 알 수 없는 경우에는 코드가 더 복잡해지기에, 여기서는 정방형 이미지를 기준으로 작성해 보았다. <code>Stack</code> widget을 사용하는 방법도 있지만 <code>Container</code> widget의 배경 이미지를 까는 방식으로 요구사항이 적혀 있으니 <code>Stack</code> widget은 배제하겠다.</p>
<p>이미지는 좌우 여백 없이 화면 가득 채우므로 <code>Column</code> 전체를 감싼 <code>Padding</code> widget을 제거하고, 하단 <code>Container</code> 들을 담은 <code>Row</code> 를 <code>Padding</code> 으로 감쌌다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">// 앞 부분 생략
            AspectRatio(
              aspectRatio: 1 / 1,
              child: Container(
                decoration: BoxDecoration(
                  image: DecorationImage(
                    image: AssetImage(&#39;assets/squat.png&#39;),
                    fit: BoxFit.fitWidth,
                  ),
                ),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: [
                    IconButton(
                      onPressed: () {},
                      icon: Icon(Icons.arrow_back_ios_new),
                      iconSize: 70,
                    ),
                    IconButton(
                      onPressed: () {},
                      icon: Icon(Icons.arrow_forward_ios),
                      iconSize: 70,
                    ),
                  ],
                ),
              ),
            ),
// 뒷 부분 생략</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/c42fcce2-f996-4e9e-a734-a0502d6bf6a5/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 12 | Flutter 데모 앱 만들기: 상세 페이지 만들기]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-12</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-12</guid>
            <pubDate>Mon, 27 Jul 2026 08:44:45 GMT</pubDate>
            <description><![CDATA[<h1 id="day-12--flutter-데모-앱-만들기-상세-페이지-만들기">DAY 12 | Flutter 데모 앱 만들기: 상세 페이지 만들기</h1>
<p><code>Expanded</code> widget에 대한 복습 설명을 듣다가 문득 <code>Expanded</code> widget과 <code>SizedBox</code> wigdet에 대한 생각을 해 보았다. 다양한 목적으로 사용하지만 <code>Expanded</code> widget의 usecase 중 하나는 shrink하는 widget의 child가 shrink되지 않도록 감싸는 것이다. 또, child에게 크기 제약을 주는 역할도 할 수 있다.</p>
<p>그 역할은 <code>SizedBox</code> 도 할 수 있었던 것 같은데, 하고 생각해 보니 expand하여 화면을 채울 수 있는 만큼 최대한 채우느냐, 정해진 크기로 고정하여 채우느냐의 차이인 것 같다.</p>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">비교</th>
<th align="center"><code>Expanded</code> widget</th>
<th align="center"><code>SizedBox</code> widget</th>
</tr>
</thead>
<tbody><tr>
<td align="center">크기</td>
<td align="center">남은 공간 채우기</td>
<td align="center">픽셀 단위로 고정</td>
</tr>
<tr>
<td align="center">특징</td>
<td align="center"><code>flex</code> 속성으로 공간 나누어 갖기</td>
<td align="center">child 없이 여백용으로 사용 가능</td>
</tr>
<tr>
<td align="center">목적</td>
<td align="center">화면 크기가 달라져도 크기 유연하게 변해야 할 때</td>
<td align="center">버튼이나 박스의 크기를 고정하거나 일정한 간경을 줄 때</td>
</tr>
</tbody></table>
<p>코드 중복은 리팩토링을 통해 최소화하는 게 좋다는 이야기를 했다. 두 번 중복까지는 상황에 따라 그대로 두는 게 더 합리적일 때도 있지만 세 번 이상은 리팩토링하라고. <a href="https://velog.io/@peeeeeter_j/sesac-2026-flutter-11">지난 시간</a>에 일련의 <code>Row</code> wigdet들을 method로 빼낸 것처럼 말이다.</p>
<p>##</p>
<p>먼저 새로운 파일<code>WorkoutGuidePage.dart</code> 를 생성하고 임시로 <code>main.dart</code> 에서 이 화면을 띄우도록 한다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
import &#39;workout_guide_page.dart&#39;;
&gt;
void main() {
  runApp(const MyApp());
}
&gt;
class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      //home: const LandingPage(),
      //home: const WorkoutListPage(),
      home: WorkoutGuidePage(),
      theme: ThemeData(
        fontFamily: &#39;Pretendard&#39;,
      ),
    );
  }
}</code></pre>
<h3 id="part-01-제목이-들어가는-곳">part 01: 제목이 들어가는 곳</h3>
<p>먼저 제목을 부분을 작성한다. 좌측에 운동 이름이 들어가고 우측에 운동 설명이 들어가는 구조다. 학습 자료에는 스쿼트에 마운틴 클라이머 설명이 적혀 있길래 AI한테 설명을 작성해 달라고 하여 넣었다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class WorkoutGuidePage extends StatelessWidget {
  const WorkoutGuidePage({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text(&#39;Workout Guide&#39;), centerTitle: true),
      body: Column(
        children: [
          Row(
            spacing: 20,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                &#39;스쿼트&#39;,
                style: TextStyle(
                  fontSize: 30,
                  fontWeight: FontWeight.bold,
                  decoration: TextDecoration.underline,
                  decorationColor: Colors.blue,
                  decorationThickness: 2.0,
                ),
              ),
              Text(
                &#39;하체 근력을 강화하고\n탄력 있는 엉덩이 라인을 만드는\n대표적인 전신 운동입니다.&#39;,
                style: TextStyle(fontSize: 12, color: Colors.grey),
              ),
            ],
          ),
        ],
      ),
    );
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/6b979e28-d26a-42c1-afd5-8013cbe0507d/image.png" alt=""></p>
<p>수업에서 설명한 거랑은 조금 다르게 작성해 보았다. 같은 화면도 구현하는 방식은 개인차가 있으니까. &#39;난 이게 더 좋은 것 같은데&#39; 싶은 부분은 내 방식으로 작성한다.</p>
<blockquote>
<h4 id="수업과-다른-부분">수업과 다른 부분</h4>
</blockquote>
<ul>
<li><code>spacing: 20</code><ul>
<li>수업에서는 <code>SizedBox</code> widget을 통해 여백을 주는 방식을 사용했다. child 수가 셋 이상일 때 여백 크기를 개별적으로 지정할 수 있다.</li>
<li><code>spacing</code> 은 Flutter 최신 버전(3.27+)에서 추가된 property라서 오뢔된 버전에서는 사용할 수 없다.</li>
<li>child 수가 셋 이상일 때는 각 여백을 개별 설정할 수는 없지만 코드 한 줄로 모든 child 사이에 여백을 줄 수 있다.</li>
</ul>
</li>
<li><code>mainAxisAlignment: MainAxisAlignment.center</code><ul>
<li>수업에서는 양 옆에 <code>Spacer</code> widget을 배치했다. 화면 크기가 달라질 때 불필요한 레이아웃 연산 비용이 들 수 있다.</li>
</ul>
</li>
</ul>
<h3 id="part-02-사진이-들어가는-곳">part 02: 사진이 들어가는 곳</h3>
<p>중앙에 사진이 있고 좌우에 화면을 넘기는 버튼이 있는 구조다. 화면을 넘기는 버튼은 <code>IconButton</code> widget을 사용한다. <a href="https://api.flutter.dev/flutter/material/Icons-class.html">Icon 문서</a>를 참고하자. <a href="https://fonts.google.com/icons">Google Fonts</a>에서 Material Icons를 확인할 수 있다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class WorkoutGuidePage extends StatelessWidget {
  const WorkoutGuidePage({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text(&#39;Workout Guide&#39;), centerTitle: true),
      body: Column(
        children: [
          Row(
            // ...생략: 제목이 들어가는 곳
          ),
          Row(
            children: [
              IconButton(
                onPressed: () {},
                icon: Icon(Icons.arrow_back_ios_new),
                iconSize: 70,
              ),
              Expanded(child: Image.asset(&#39;assets/squat.png&#39;)),
              IconButton(
                onPressed: () {},
                icon: Icon(Icons.arrow_forward_ios),
                iconSize: 70,
              ),
            ],
          ),
        ],
      ),
    );
  }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/cd868860-fb05-47ae-a0ff-1bc72bc6dcae/image.png" alt=""></p>
<p>asset 이미지를 사용할 땐 <code>Image(image: AssetImage(&#39;IMAGE_URL&#39;))</code> 해도 되지만 <code>Image.asset(&#39;IMAGE_URL&#39;)</code> 해도 된다. 완전히 같은 것이고 constructor 차이다. generative constructor냐 named constructor냐 하는 정도.</p>
<p>여기서는 overflow 방지를 위해 <code>Image</code> widget을<code>Expanded</code> widget으로 감쌌다. 양 옆의 <code>IconButton</code> widget들이 공간을 차지하고 남은 공간에 이미지를 그리도록 한 것이다.</p>
<blockquote>
<h4 id="수업과-다른-부분-1">수업과 다른 부분</h4>
</blockquote>
<ul>
<li><code>Icons.arrow_back_ios_new</code><ul>
<li><code>Icons.arrow_back_ios</code> 는 우측에 <code>Icons.arrow_forward_ios</code> 를 함께 사용할 것을 전제로 만들어져 좌측으로 치우쳐 있다. 단독 사용을 위해 추가된 아이콘을 사용하여 좌우 여백에 통일감을 주었다.</li>
</ul>
</li>
</ul>
<h3 id="part-03-부연-설명이-들어가는-곳">part 03: 부연 설명이 들어가는 곳</h3>
<p>운동 내용과 추천 대상을 나란히 보여주는 영역을 작성한다. <code>Row</code> widget에 <code>Container</code> widget 두 개를 넣고, 각각의 <code>Container</code> widget 안에 <code>Column</code> widget으로 텍스트를 배치할 것이다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class WorkoutGuidePage extends StatelessWidget {
  const WorkoutGuidePage({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text(&#39;Workout Guide&#39;), centerTitle: true),
      body: Column(
        children: [
          Row(
            // ...생략: 제목이 들어가는 곳
          ),
          Row(
            // ...생략: 사진이 들어가는 곳
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              Container(
                width: 160,
                height: 100,
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey.shade300),
                  borderRadius: BorderRadius.all(Radius.circular(16)),
                ),
                child: Column(
                  children: [
                    Text(
                      &#39;운동 내용&#39;,
                      style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    Text(
                      &#39;배, 상체 근육&#39;,
                      style: TextStyle(fontSize: 18, color: Colors.grey),
                    ),
                  ],
                ),
              ),
              Container(
                width: 160,
                height: 100,
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey.shade300),
                  borderRadius: BorderRadius.all(Radius.circular(16)),
                ),
                child: Column(
                  children: [
                    Text(
                      &#39;이런 사람에게 강추!&#39;,
                      style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    Text(
                      &#39;뱃살이 고민이에요\n체지방 태우고 싶어요&#39;,
                      style: TextStyle(fontSize: 14, color: Colors.grey),
                      textAlign: TextAlign.center,
                    ),
                  ],
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}</code></pre>
<p>여기서는 폰트 크기를 조정하여 overflow를 해결했지만, 실무 관점에서는 좀 더 고민하여 반응형으로 구현하는 게 좋다. 폰트뿐만 아니라 다른 widget들에 대해서도 이를 고려하여 작성하는 게 좋지만 지금은 이대로 넘어가자. 당장은 큰 부분만 고려하고 사용자가 늘어났을 때 확장하는 게 개발 비용과의 타협점이라고 볼 수도 있고.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/f7b02fb7-9e24-449c-8ff2-7850f4c6325a/image.png" alt=""></p>
<h3 id="part-04-소요-시간이-들어가는-곳">part 04: 소요 시간이 들어가는 곳</h3>
<p>이것은 [part 03]에서 작성한 <code>Container</code> 를 가져다가 수정할 수 있다. 이번에는 너비를 하드코딩하지 않고 <code>Expanded</code> 로 처리할 것이다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class WorkoutGuidePage extends StatelessWidget {
  const WorkoutGuidePage({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text(&#39;Workout Guide&#39;), centerTitle: true),
      body: Column(
        children: [
          Row(
            // ...생략: 제목이 들어가는 곳
          ),
          Row(
            // ...생략: 사진이 들어가는 곳
          ),
          Row(
            // ...생략: 부연 설명이 들어가는 곳
          ),
          Row(
            children: [
              Expanded(
                child: Container(
                  padding: EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey.shade300),
                    borderRadius: BorderRadius.all(Radius.circular(16)),
                  ),
                  child: Text(
                    &#39;30분&#39;,
                    style: TextStyle(
                      fontSize: 24,
                      fontWeight: FontWeight.bold,
                      color: Colors.blue,
                    ),
                    textAlign: TextAlign.center,
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/5da081b1-2378-40bb-8c45-9d49cf236261/image.png" alt=""></p>
<h3 id="part-05-버튼이-들어가는-곳">part 05: 버튼이 들어가는 곳</h3>
<p>버튼은 [part 02]에서 화살표 버튼을 생성했던 것처럼 <code>IconButton</code> widget을 사용한다.</p>
<blockquote>
<p><code>lib/workout_guide_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class WorkoutGuidePage extends StatelessWidget {
  const WorkoutGuidePage({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text(&#39;Workout Guide&#39;), centerTitle: true),
      body: Column(
        children: [
          Row(
            // ...생략: 제목이 들어가는 곳
          ),
          Row(
            // ...생략: 사진이 들어가는 곳
          ),
          Row(
            // ...생략: 부연 설명이 들어가는 곳
          ),
          Row(
            // ...생략: 소요 시간이 들어가는 곳
          ),
          IconButton(
            onPressed: () {},
            icon: Icon(Icons.play_circle, color: Colors.blue,),
            iconSize: 70,
          ),
        ],
      ),
    );
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/30c80bf5-d8e1-403d-a386-50970231aff0/image.png" alt=""></p>
<h3 id="part-06-레이아웃-맞추기">part 06: 레이아웃 맞추기</h3>
<p>전체 widget을 감싸는 <code>Column</code> widget에 <code>mainAxisAlignment: MainAxisAlignment.spaceAround,</code> 도 넣고 <code>Padding</code> 으로도 감싸고 보기 좋게 만든다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/41e4c06e-403a-48f4-b2c6-4b977f17a21b/image.png" alt=""></p>
<h2 id="이미지-채우기">이미지 채우기</h2>
<p>이미지에서만 쓰는 건 아니지만 이미지랑 같이 쓰일 때가 많다. 간단히 알아만 두자.</p>
<blockquote>
<h4 id="boxfit-widget"><code>BoxFit</code> widget</h4>
<table>
<thead>
<tr>
<th align="center">값</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>BoxFit.fill</code></td>
<td align="center">비율을 무시하고 가득 차도록 채워 넣는다</td>
</tr>
<tr>
<td align="center"><code>BoxFit.contain</code></td>
<td align="center">비율을 유지한 채 잘리지 않게 넣는다 (여백 남음)</td>
</tr>
<tr>
<td align="center"><code>BoxFit.cover</code></td>
<td align="center">비율을 유지한 채 여백이 남지 않게 채운다</td>
</tr>
<tr>
<td align="center"><code>BoxFit.fitWidth</code></td>
<td align="center">비율을 유지한 채 높이가 넘치든 모자라든 너비를 기준으로 채운다</td>
</tr>
<tr>
<td align="center"><code>BoxFit.fitHeight</code></td>
<td align="center">비율을 유지한 채 너비가 넘치든 모자라든 높이를 기준으로 채운다</td>
</tr>
<tr>
<td align="center"><code>BoxFit.none</code></td>
<td align="center">크기 변경 없이 가운데 배치하여 넘칠 수도 있고 여백이 남을 수도 있다</td>
</tr>
<tr>
<td align="center"><code>BoxFit.scaleDown</code></td>
<td align="center">여백이 남으면 원본을 크기를 유지하고 넘치면 <code>contain</code> 처럼 줄인다</td>
</tr>
</tbody></table>
</blockquote>
<h2 id="package-활용">Package 활용</h2>
<p>모든 코드를 직접 구현하지는 않는다. 공개되어 있는 외부 package를 사용하는 경우도 많다. Flutter에서 사용할 package를 찾을 땐 보통은 Google에서 운영하는 <a href="https://pub.dev">pub.dev</a>에서 탐색하고, 경우에 따라 이곳에 없는 마이너한 repository를 GitHub 같은 데서 찾아 쓰기도 한다.</p>
<p>검색해서 나오는 package의 메타데이터를 보고, target platform이 적절한지, 유지보수가 잘 되고 있는지, 수요가 많은지 등을 판단하여 어떤 것을 사용할지 정할 수 있다.</p>
<p>본격적으로 package를 사용하는 실습은 다음 시간에 할 것이다. 일단은 package는 <code>import</code> keyword로 불러오며, <code>show</code>, <code>hide</code> keyword를 통해 package 내부 요소를 선택적으로 가져올 수도 있다는 것만 알아두자. 선택적으로 가져오는 건 보통 충돌 방지를 목적으로 하는 경우가 많다나.</p>
<p>package version은 주로 <code>a.b.c</code> 형식으로 작성되는데, <code>[major version].[minor version].[patch version]</code> 을 의미한다. <code>pubspec.yaml</code> 파일에 <code>package_name: ^a.b.c</code> 와 같이 caret(<code>^</code>)을 사용하여 작성함으로써 <code>a.b.c</code> 버전 이상 <code>a+1.b.c</code> 버전 미만 버전을 사용한다.</p>
<blockquote>
<h4 id="semantic-version">semantic version</h4>
<table>
<thead>
<tr>
<th align="center">version</th>
<th align="center">description</th>
</tr>
</thead>
<tbody><tr>
<td align="center">major</td>
<td align="center">큰 기능이 변경되어 기존과 호환 불가능할 때 올린다</td>
</tr>
<tr>
<td align="center">minor</td>
<td align="center">호환성을 유지하며 신규 기능을 추가할 때 올린다</td>
</tr>
<tr>
<td align="center">patch</td>
<td align="center">기존 기능의 버그만 수정할 때 올린다</td>
</tr>
</tbody></table>
</blockquote>
<p>모든 프로젝츠가 이런 semantic versioning을 사용하는 건 아니다. 우리가 사용하는 Android Studio만 해도 <code>2026.1.2 Patch 1</code> 라는 버전을 사용한다. 반년마다 새 메이저 버전이 올라오는 Ubuntu는 <code>26.04</code> 와 같이 몇 년 몇 월에 출시된 버전인지 나타내기도 하고 말이다.</p>
<h2 id="오늘의-실습">오늘의 실습</h2>
<p><a href="https://healthyclass.kr/markdown_contents/QZaFfIFFz5CBleIlnGgX/pVHVCaVgx7plEmTCkwbR">WorkoutListPage Challenge</a>와 <a href="https://healthyclass.kr/markdown_contents/QZaFfIFFz5CBleIlnGgX/uEcMRl0lCKmoVTa9j02e">WorkoutGuidePage Challenge</a> 를 해 보았다. 이에 대한 내용은 <a href="https://velog.io/@peeeeeter_j/sesac-2026-flutter-12-1">DAY 12-1</a>에서 확인할 수 있다.</p>
<p>그리고 남은 시간에는 프로젝트와 이것저것이것저것이것저것이 나를 기다리고 있다.</p>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/543f85149f6d7af3ca2f4080ce28daac93600137">&gt;&gt;&gt; GitHub Repository at this point (543f851)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 11-1 | Flutter App architecture (1) 아키텍처 개념]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-11-1</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-11-1</guid>
            <pubDate>Sun, 26 Jul 2026 03:06:30 GMT</pubDate>
            <description><![CDATA[<h1 id="day-11-1--flutter-app-architecture-1-아키텍처-개념">DAY 11-1 | Flutter App architecture (1) 아키텍처 개념</h1>
<p><a href="https://docs.flutter.dev/app-architecture">Flutter 공식문서</a>를 따라 Flutter App architecture를 살펴보자.</p>
<blockquote>
<p>In this guide, &#39;architecture&#39; refers to how to structure, organize, and design your Flutter app in order to scale as your project requirements and team grow.
이 가이드에서 &#39;아키텍처&#39;는 프로젝트 요구사항이 늘어나고 팀이 성장함에 따라 Flutter 앱을 구조화하고 조직화하며 설계하는 방법을 의미한다.</p>
</blockquote>
<p>좋은 아키텍처가 주는 이점은 다음과 같다.</p>
<blockquote>
</blockquote>
<ul>
<li><strong>Maintainability (유지보수성)</strong>
아키텍처는 시간이 지남에 따라 발생할 수 있는 수정, 업데이트 및 문제 해결을 한층 수월하게 만든다.</li>
<li><strong>Scalability (확장성)</strong>
애플리케이션을 면밀하게 설계하면 많은 사람들이 동시에 동일 코드베이스에 기여하더라도 코드 충돌을 최소화할 수 있다.</li>
<li><strong>Testability (테스트 용이성)</strong>
아키텍처를 의도적으로 설계한 애플리케이션은 대개 입출력이 명확하고 클래스가 단순하여 모킹 및 테스트를 수행하기 수월하다.</li>
<li><strong>Lower cognitive load (인지 부하 감소)</strong>
코드를 이해하기 쉽게 작성하면 프로젝트에 새로 합류한 개발자가 더 빠르게 생산성을 발휘할 수 있으며, 전반적인 코드 리뷰 시간도 단축된다.</li>
<li><strong>A better user experience (향상된 사용자 경험)</strong>
기능을 더 빠르게 출시할 수 있고 발생하는 버그도 감소한다.</li>
</ul>
<h2 id="일반적인-아키텍처-개념">일반적인 아키텍처 개념</h2>
<p>Flutter의 아키텍처와 관련된 일반적인 개념들을 몇 가지 살펴보자.</p>
<blockquote>
<h4 id="separation-of-concerns--관심사-분리">Separation of concerns | 관심사 분리</h4>
<p>애플리케이션의 기능을 독립적이고 자체 포함된 단위로 나누어 모듈성과 유지보수성을 높이는 앱 개발의 핵심 원칙</p>
</blockquote>
<ul>
<li>거시적 관점에서 보면 UI 로직과 비즈니스 로직을 분리하는 것
→ 계층형 아키텍처(Layered architecture)</li>
<li>Flutter에서는 이에 따라 로직을 최대한 덜어낸, 가볍고 재사용 가능한 UI widget을 작성한다.</li>
</ul>
<p>Dijkstra에 의하면 다양한 측면들을 한꺼번에 해결하려고 덤벼들면 얻을 수 있는 것은 아무것도 없으며, 오히려 역효과만 난다. 따라서 지금 들여다보고 있는 관점에서는 다른 측면이 당장 고려 대상이 아니라는 사실을 명확히 인정하고, 하나의 트랙에 집중하면서도 동시에 여러 트랙을 의식하는 영리한 사고방식이 필요하다.</p>
<blockquote>
<h4 id="layered-architecture--계층형-아키텍처">Layered architecture | 계층형 아키텍처</h4>
<p>애플리케이션을 각각의 명확한 역할과 책임을 가진 독립된 계층으로 조직화하는 소프트웨어 디자인 패턴
직접 맞닿아 있지 않은 레이어는 서로 소통하기는 커녕 서로의 존재조차 알지 못하는 구조</p>
</blockquote>
<ul>
<li><strong>UI 레이어</strong>
 데이터를 사용자에게 화면으로 보여주고 사용자의 상호작용을 처리하는 레이어</li>
<li><strong>로직 레이어</strong>
핵심 비즈니스 로직을 구현하며 데이터 레이어와 UI 레이어의 상호작용을 중재하는 레이어
(단순히 사용자에게 데이터를 보여주고 그 데이터를 수정하는 기능만 제공하는 경우 생략되기도 한다.)</li>
<li><strong>데이터 레이어</strong>
데이터 소스와의 상호작용을 관리하며 비즈니스 로직 레이어에서 사용할 수 있도록 데이터를 외부에 노출하는 레이어</li>
</ul>
<p>서로 직접적으로 닿아 있지 않은 영역이 서로의 영향을 받지 않도록 하는 게 핵심이다. 역할과 책임이 명확하여 독립적인 개발 및 테스트가 가능하고, 어느 레이어에 대한 코드 수정이 다른 레이어에 영향을 주지 않아 유지보수성이 좋다.</p>
<blockquote>
<h4 id="single-source-of-truth-ssot--단일-진실-공급원">Single source of truth; SSOT | 단일 진실 공급원</h4>
<p>모든 데이터와 정보가 오직 하나의 신뢰할 수 있는 출처에서 생성 및 수정되도록 조직하는 방식
일반적으로 데이터 레이어의 일부인 <code>Repository</code> class가 담당하며, 각 data type마다 하나의 <code>Repository</code> class가 매칭된다.</p>
</blockquote>
<p>SSOT는 Rust로 작성된 엔진과 Python(FastAPI) 게이트웨이 간의 통신에 gRPC를 사용할 때 살펴봤던 개념이다. 데이터 무결성 및 일관성 측면에서 중요하다. 이를 통해 데이터 불일치에 대한 불필요한 휴먼 에러를 사전에 차단할 수 있다.</p>
<blockquote>
<h4 id="unidirectional-data-flow-udf--단방향-데이터-흐름">Unidirectional data flow; UDF | 단방향 데이터 흐름</h4>
<p>이벤트는 UI 레이어에서 시작하여 로직 레이어를 거쳐 데이터 레이어 방향으로 흐른다. 상태는 반대로, 데이터 레이어에서 시작하여 로직 레이어를 거쳐 UI 레이어 방향으로 흐른다. </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/1db79941-eddd-4026-bc4e-1c74e2e48ee2/image.png" alt=""></p>
<p>때로는 사용자 상호작용 없이 데이터 레이어에서 먼저 흐름이 시작되기도 한다. 어찌 되었건 데이터 변경은 데이터 레이어의 SSOT에서만 수행된다.</p>
<blockquote>
<h4 id="ui는-immutable-state의-function이다">UI는 (immutable) state의 function이다</h4>
<p>Flutter는 선언형 UI로, 데이터가 UI를 주도한다.
state가 달라지면 rebuild를 트리거하고 UI를 새로 그려 state를 반영한다.</p>
</blockquote>
<blockquote>
<h4 id="extensibility--확장성">Extensibility | 확장성</h4>
<p>아키텍처의 각 구성 요소는 잘 정의된 입력과 출력 목록을 가져야 한다.
clean interface를 사용하여 기존 코드를 수정하지 않고도 class의 구체적인 구현체를 언제든지 자유롭게 교체할 수 있어야 한다.</p>
</blockquote>
<blockquote>
<h4 id="testability--테스트-용이성">Testability | 테스트 용이성</h4>
<p>소프트웨어의 확장성을 높여주는 원칙들은 테스트를 용이하게 만드는 데도 기여한다.
구체적인 구현체를 자유롭게 교체할 수 있는 만큼 쉽게 모킹하여 테스트할 수 있는 것이다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 11 |  Flutter 데모 앱 만들기: 목록 페이지 만들기]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-11</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-11</guid>
            <pubDate>Fri, 24 Jul 2026 07:57:34 GMT</pubDate>
            <description><![CDATA[<h1 id="day-11---flutter-데모-앱-만들기-목록-페이지-만들기">DAY 11 |  Flutter 데모 앱 만들기: 목록 페이지 만들기</h1>
<p>다음주에 있을 현직자 멘토링에 대한 안내를 받았다. 보니까 옆동네더라. 지난 번에 <strong>@보리스</strong>와 <strong>@다람</strong>이 강의를 들으러 간다고 하길래 <strong>@이안</strong>과 함께 갔던 바로 그곳. 수업 마친 이후 시간대에 진행되는데 어차피 집 가는 방향이랑 비슷해서 한 번 들려보자 하고 신청했는데, 같이 수업을 듣는 분들 중 대부분이 신청을 하신 모양이다. 다음주에 수업 마치고 같이 이동하면 좋을 것 같다.</p>
<p>난 아직 구체적인 방향성을 잡지 못했는데 그래도 아무튼 개발자의 범위 내에서 고민하게 될 테니 그룹 멘토링은 개발자 분과의 만남으로 신청했다. 토스 프론트엔드/웹 개발자와 카뱅 서비스/서버 개발자가 계시더라. 선착순 안에 늦지 않게 신청한 거길 바라며... 사실 디자인도 좀 배웠고 콘텐츠 디자인이나 제품 디자인은 어려웠지만 UI/UX 디자인은 그럭저럭 괜찮았어서 UIUX 분야도 살펴볼 만할 것 같긴 하지만 당분간은 개발 쪽에 좀 더 집중해 봐야지.</p>
<h2 id="폰트를-적용해-보자">폰트를 적용해 보자</h2>
<p>앱 개발 시 폰트를 지정하지 않으면 시스템 기본 폰트가 사용된다. 플랫폼마다 폰트가 달라지기도 하고, 폰트 크기 비율이 달라져 화면에 보여지는 배치가 의도치 않게 틀어질 수도 있다. 이를 방지하기 위해서 텍스트에 폰트를 지정해 주는 게 좋다.</p>
<p>폰트를 다운로드 받을 수 있는 곳은 다양하고, 대표적인 곳으로 <a href="https://fonts.google.com">Google Fonts</a>가 있다. 폰트 라이선스를 포함한 다양한 정보를 살펴볼 수 있고, 원하는 조건으로 필터링하여 검색해 볼 수도 있다. 다운로드 받아서 사용할 수도 있고 webfont로 사용할 수도 있는데 각각 장단점이 있다.</p>
<p>다운로드한 폰트는 <code>assets/fonts</code> 등의 적절한 디렉토리에 모아놓고 <code>pubspec.yaml</code> 파일에 등록하여 사용한다. 그런데 사용하는 폰트가 많아질수록 앱 자체의 크기가 늘어난다. webfont로 사용할 경우에는 앱 크기를 작게 유지할 수 있지만 약간의 통신 딜레이가 있을 수 있다. 한 가지 방식을 고수하기 보다는 로딩 화면 및 주요 폰트 한두 개는 asset으로 추가하고 그 외에 더 필요하다면 webfont 사용하는 게 좋다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
flutter:
&gt;
  uses-material-design: true
&gt;
  assets:
    - assets/
&gt;
  fonts:
    - family: Pretendard
      fonts:
        - asset: assets/fonts/Pretendard/Pretendard-Thin.otf
          weight: 100
        - asset: assets/fonts/Pretendard/Pretendard-ExtraLight.otf
          weight: 200
        - asset: assets/fonts/Pretendard/Pretendard-Light.otf
          weight: 300
        - asset: assets/fonts/Pretendard/Pretendard-Regular.otf
          weight: 400
        - asset: assets/fonts/Pretendard/Pretendard-Medium.otf
          weight: 500
        - asset: assets/fonts/Pretendard/Pretendard-SemiBold.otf
          weight: 600
        - asset: assets/fonts/Pretendard/Pretendard-Bold.otf
          weight: 700
        - asset: assets/fonts/Pretendard/Pretendard-ExtraBold.otf
          weight: 800
        - asset: assets/fonts/Pretendard/Pretendard-Black.otf
          weight: 900</code></pre>
<p>특정 텍스트에 폰트를 적용하고자 한다면 <code>pubspec.yaml</code> 파일에 지정한 <code>family</code> 값을 <code>fontFamily</code> 에 대입한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">Text(
  &#39;My Perfect Workout Mate&#39;,
  style: TextStyle(
    fontSize: 16,
    color: Colors.white,
      fontFamily: &#39;Pretendard&#39;, // THIS!
  ),
),</code></pre>
<p>앱 전체의 기본 폰트를 적용하고자 한다면 <code>theme</code> 에 <code>ThemeData</code> 를 전달하여 적용할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const LandingPage(),
      theme: ThemeData(
        fontFamily: &#39;Pretendard&#39;,
      ),
    );
  }
}</code></pre>
<h2 id="목록-페이지-작성하기">목록 페이지 작성하기</h2>
<p>우리 프로젝트의 개발 규칙을 설정하고 가면 일관성을 유지하며 작업을 해 나갈 수 있다.</p>
<blockquote>
</blockquote>
<ul>
<li>페이지 widget의 이름은 <code>Page</code> 로 끝나도록 한다.</li>
<li>모든 페이지 widget의 <code>build()</code> method는 <code>Scaffold</code> widget을 반환한다.</li>
<li>모든 페이지 widget은 반드시 파일 분리를 한다.</li>
<li>페이지 widget을 담는 파일의 이름은 해당 widget의 이름을 사용한다.</li>
<li>import 시 상대경로인지 절대경로인지는 개인 취향이지만 하나로 정하면 일관성 있게 작성한다.</li>
</ul>
<p>이에 따라 프로젝트 구조를 잡아 보자. 이 구조가 최종 구조는 아니고, 프로젝트를 진행하며 구조를 개선해 나갈 것이다. 처음부터 구조를 명확하게 잡고 가지 않고 나중에 수정하는 건 내 성향에는 안 맞지만, 일단은 내 프로젝트 구성 방식을 고집하기보다는 이 방식도 경험해 봐야지.</p>
<blockquote>
<h4 id="lib-디렉토리-구조"><code>lib</code> 디렉토리 구조</h4>
</blockquote>
<pre><code>lib
├── main.dart
├── landing_page.dart
└── workout_list_page.dart</code></pre><p><code>lib/landing_page.dart</code> 에는 <a href="https://velog.io/@peeeeeter_j/sesac-2026-flutter-10">지난 시간</a>에 작성한 <code>LandingPage</code> widget을 옮겨 넣는다. 그리고 <code>lib/workout_list_page.dart</code> 을 새로 생성하여 구성해 보도록 하겠다.</p>
<p>이제 <code>lib/main.dart</code> 는 다음과 같이 단순해졌다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;landing_page.dart&#39;;
&gt;
void main() {
  runApp(const MyApp());
}
&gt;
class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const LandingPage(),
      theme: ThemeData(
        fontFamily: &#39;Pretendard&#39;,
      ),
    );
  }
}</code></pre>
<p>앱 실행 흐름 차원에서는 <code>LandingPage</code> widget이 먼저 뜨고 버튼을 누르면 다음 화면으로 넘어가야 하지만, 우리는 아직 화면 넘기는 것을 배우지 않았으므로 임시로 처음부터 <code>LandingPage</code> widget이 아닌 <code>WorkoutListPage</code> widget을 실행시키도록 수정한 뒤 <code>WorkoutListPage</code> widget을 작성해 보도록 하겠다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
import &#39;landing_page.dart&#39;;
import &#39;workout_list_page.dart&#39;;
&gt;
void main() {
  runApp(const MyApp());
}
&gt;
class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      //home: const LandingPage(),
      home: const WorkoutListPage(),
      theme: ThemeData(
        fontFamily: &#39;Pretendard&#39;,
      ),
    );
  }
}</code></pre>
<blockquote>
<p><code>lib/workout_list_page.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
class WorkoutListPage extends StatelessWidget {
  WorkoutListPage({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Center(child: Text(&#39;Workout List&#39;))),
      body: Placeholder(),
    );
  }
}</code></pre>
<h3 id="목록의-항목-구성하기">목록의 항목 구성하기</h3>
<p>목록은 <code>ListView</code> widget으로 작성하는 게 효과적이겠지만 일단 우리가 배운 <code>Column</code> widget으로 목록 페이지를 구성해 보도록 하겠다.</p>
<p>운동 목록의 역할을 하는 <code>Column</code> widget의 <code>children</code>으로는 각 운동 항목을 담는 widget을 넣는다. 운동 항목은 [사진][이름][시간]으로 구성되므로 <code>Row</code> widget을 사용하여 작성할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">Row(
  children: [
    Image(image: AssetImage(&#39;assets/squat.png&#39;),
    Text(&#39;1. 스쿼트&#39;),
    Text(&#39;05:30&#39;),
  ],
)</code></pre>
<p>적절한 모양과 크기로 조정하면,</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">Row(
  spacing: 20,
  children: [
    Container(
      width: 100,
      height: 100,
      decoration: BoxDecoration(
        image: DecorationImage(
          image: AssetImage(&#39;assets/squat.png&#39;),
        ),
        shape: BoxShape.circle,
      ),
    ),
    Text(
      &#39;1. 스쿼트&#39;,
      style: TextStyle(fontSize: 20),
    ),
    Text(
      &#39;05:30&#39;,
      style: TextStyle(fontSize: 20, color: Colors.blueAccent),
    ),
  ],
),</code></pre>
<p>그리고 적절한 여백을 지정하면,</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">Row(
  spacing: 20,
  children: [
    Container(
      width: 100,
      height: 100,
      decoration: BoxDecoration(
        image: DecorationImage(
          image: AssetImage(&#39;assets/squat.png&#39;),
        ),
        shape: BoxShape.circle,
      ),
    ),
    Expanded(
      child: Text(
        &#39;1. 스쿼트&#39;,
        style: TextStyle(fontSize: 20),
      ),
    ),
    Text(
      &#39;05:30&#39;,
      style: TextStyle(fontSize: 20, color: Colors.blueAccent),
    ),
  ],
),</code></pre>
<p>다음과 같이 UI가 그려진 것을 확인할 수 있다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/c0d7dcd9-d32e-43cf-b49e-c1663ca5e48e/image.png" alt=""></p>
<h3 id="하드코딩-제거하기">하드코딩 제거하기</h3>
<p>지금은 데이터가 하드코딩 되어 있지만 양이 늘어나면 일일이 작성하는 게 번거롭다. 따라서 적절한 collection object를 생성하여 반복 생성하는 편이 좋다.</p>
<p><code>List</code> 를 사용할지 <code>Map</code> 을 사용할지 등등 적절한 방식으로 데이터를 작성한다. 수업에서는 이름, 사진, 시간을 담는 <code>List</code> 를 각각 작성하여 사용했는데 나는 <code>List&lt;Map&lt;dynamic&gt;&gt;</code> 하나에 다 집어넣었다.</p>
<p><code>Map</code> 을 사용하는 편이 관련된 정보끼리 모여 있으니 응집도가 높고, 그래서 내용을 추가하거나 수정할 때 실수할 확률이 낮아져 무결성 차원에서도 이득이다. 그런데 운동 목록은 순서가 있는 목록이니 그것을 <code>List</code>로 묶었다. <code>{&#39;name&#39;: &#39;스쿼트&#39;, &#39;image&#39;: &#39;squat.png&#39;, &#39;minutes&#39;: 30},</code> 와 같은 데이터를 담는 <code>List</code> 인 것이다.</p>
<p>기존에 작성했던 <code>Row</code> 를 우클릭하고 [Refactor &gt; Extract Method] 를 선택하여 그것을 기반으로 helper function을 작성한다. collection object의 내용대로 <code>Row</code> widget을 반복적으로 생성하여 <code>List</code> 형태로 return 하는 거다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">List&lt;Row&gt; getWorkoutList() {
  List&lt;Row&gt; workoutRows = [];
&gt;
  for (var i = 0; i &lt; workoutList.length; i++) {
    String name = workoutList[i][&#39;name&#39;];
    String image = workoutList[i][&#39;image&#39;];
    int minutes = workoutList[i][&#39;minutes&#39;];
&gt;
    workoutRows.add(
      Row(
        spacing: 20,
        children: [
          Container(
            width: 100,
            height: 100,
            decoration: BoxDecoration(
              image: DecorationImage(image: AssetImage(&#39;assets/$image&#39;)),
              shape: BoxShape.circle,
            ),
          ),
          Expanded(
            child: Text(&#39;${i + 1}. $name&#39;, style: TextStyle(fontSize: 20)),
          ),
          Text(
            &#39;$minutes 분&#39;,
            style: TextStyle(fontSize: 20, color: Colors.blueAccent),
          )
        ],
      ),
    );
  }
&gt;
  return workoutRows;</code></pre>
<p><code>Row</code> 를 refactor 한 것이므로 처음에 <code>Row</code> widget이 정의되어 있던 부분이 <code>getWorkoutList()</code> 로 대치되었다. 그런데 작성한 helper function는 <code>Row</code> 가 아닌 <code>List&lt;Row&gt;</code> 를 return하므로 <code>children: [ getWorkoutList() ]</code> 가 아니라 <code>children: getWorkoutList()</code> 로 수정한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: Center(child: Text(&#39;Workout List&#39;))),
    body: Padding(
      padding: EdgeInsetsGeometry.all(20),
      child: Column(
        spacing: 20,
        children: getWorkoutList(),
      ),
    ),
  );
}</code></pre>
<p>아직 화면을 넘어가는 크기를 가지고 있어 overflow 되는 widget에 대한 처리를 하지 않아 오류가 뜨지만 다음과 같이 목록은 잘 생성되는 것을 확인할 수 있다.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/96f04aa5-2f02-40cf-88f5-54dac01f185d/image.png" alt=""></p>
<h2 id="좀-더-공부해-보자">좀 더 공부해 보자</h2>
<p><a href="https://docs.flutter.dev/app-architecture/concepts">Flutter 공식문서</a>의 architecture에 대한 설명을 읽어 보자. 서비스 개발은 배우지 않고 그냥 예제 따라해 보는 수준으로 해 본 게 전부라 이런 걸 익힐 필요가 있다. 제대로 공부하려면 양도 좀 되는 것 같고 강의실 분위기에서는 집중해서 읽긴 어려울 테니 주말에 읽어 봐야지. 얼마나 걸릴진 잘 모르겠다. 어떤 문서는 몇 페이지고 금방 읽고 어떤 문서는 한 페이지를 하루 종일 붙잡고 있기도 하는데 이건 공부해 본 적 없는 영역이라.</p>
<p>그리고 앱 기획에 대한 것도 좀 더 고민해 볼 필요가 있고. 바쁘다 바빠.</p>
<hr>
<blockquote>
<p><a href="https://github.com/neont21/flutter_practice_yongsan/tree/93fe8b0fa7b9a8b8f5f8a7a9c18958ff827cde15">&gt;&gt;&gt; GitHub Repository at this point (93fe8b0)</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 10 | Flutter의 대표적인 layout widget들을 살펴보자]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-10</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-10</guid>
            <pubDate>Wed, 22 Jul 2026 10:08:56 GMT</pubDate>
            <description><![CDATA[<h1 id="day-10--flutter의-대표적인-layout-widget들을-살펴보자">DAY 10 | Flutter의 대표적인 layout widget들을 살펴보자</h1>
<h2 id="주요-layout-widget-훑어보기">주요 layout widget 훑어보기</h2>
<h3 id="화면-구성에-사용되는-가장-기본적인-widget-container">화면 구성에 사용되는 가장 기본적인 widget <code>Container</code></h3>
<p>Flutter는 계층적인 widget들로 구성된다. 어떤 widget은 그 자체로 가장 atomic한 형태로 존재하고, 어떤 widget은 다른 widget을 <code>child</code> (혹은 <code>children</code>)으로 받아 계층을 이룬다. 화면을 구성하는 가장 기초적인 widget으로 <code>Container</code> widget이 있다. 이것은 사각형의 투명한 박스라고 생각할 수 있다.</p>
<p><code>Container</code> widget의 크기는 직접 지정해 줄 수도 있고 내부에 담긴 <code>child</code> 에 따라 결정되도록 둘 수도 있다. 직접 지정해 주는 게 가장 우선순위가 높다. <code>child</code> 가 있을 경우 딱 그것만 들어가는 크기로 수축한다. shrink 된다고 표현한다. <code>child</code> 가 없을 경우 차지할 수 있는 가장 큰 크기로 확장된다. expand 된다고 표현한다.</p>
<p>Flutter에서 크기 단위는 logical pixel이다. 이것은 기기의 실제 물리 픽셀이 아닌 소프트웨어적으로 계산된 가상의 단위다. 기기의 화면 밀도에 따라 물리 픽셀로 자동 변환되어 화면에 그려진다.</p>
<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">구분</th>
<th align="center">물리 픽셀</th>
<th align="center">로지컬 픽셀</th>
</tr>
</thead>
<tbody><tr>
<td align="center">정의</td>
<td align="center">디스플레이의 실제 하드웨어 화면 점</td>
<td align="center">UI 레이아웃을 잡는 가상의 소프트웨어 단위</td>
</tr>
<tr>
<td align="center">기준</td>
<td align="center">기기 제조사의 하드웨어 명세</td>
<td align="center">Flutter 등의 앱 개발 플랫폼</td>
</tr>
<tr>
<td align="center">타 플랫폼 표현</td>
<td align="center">px</td>
<td align="center">dp (안드로이드) / pt (iOS)</td>
</tr>
<tr>
<td align="center">크기 변동성</td>
<td align="center">기기 화면 밀도에 따라 실제 크기가 달라짐</td>
<td align="center">기기 밀도가 달라도 화면 상의 실제 크기는 일정함</td>
</tr>
<tr>
<td align="center">개발자 역할</td>
<td align="center">직접 제어하지 않음 (고해상도 이미지 처리 제외)</td>
<td align="center">코드에서 위젯의 크기(width, height) 지정 시 사용</td>
</tr>
<tr>
<td align="center">Flutter 코드 조회</td>
<td align="center"><code>View.of(context).physicalSize</code></td>
<td align="center"><code>MediaQuery.of(context).size</code></td>
</tr>
</tbody></table>
<p>Android Studio에서 어떤 widget을 다른 widget으로 감싸고 싶을 땐, Context Actions 기능(💡)을 사용하면 된다. widget 이름에 마우스 호버 시 💡가 뜨는데, 그걸 눌러 뜨는 것 중 [Wrap with widget]을 누르고 <code>widget</code> 이라고 써 있는 부분을 원하는 widget으로 대치하면 된다.</p>
<p>가령 다음과 같은 코드가 있을 때,</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">class LandingPage extends StatelessWidget {
  const LandingPage({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        color: Colors.yellow,
        child:Text(&#39;My perfect workout mate&#39;),
      ),
    );
  }
}</code></pre>
<p><code>Container</code> 에 마우스를 올려 다음과 같이 <code>SafeArea</code> widget으로 감쌀 수 있다. 이렇게 어떤 widget을 다른 widget으로 감싸는 걸 wrap 한다고 한다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">class LandingPage extends StatelessWidget {
  const LandingPage({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Container(
          color: Colors.yellow,
          child:Text(&#39;My perfect workout mate&#39;),
        ),
      ),
    );
  }
}</code></pre>
<p><code>Container</code> widget의 <code>padding</code>, <code>border</code>, <code>margin</code> 은 웹이나 디자인에서 이야기하는 것과 완전히 일치하기에 따로 익힐 건 없다.</p>
<p>헷갈린다면, <code>padding</code> 은 패딩을 껴입는 걸 생각하자. 피터가 패딩을 입었다면 피터 본인과 패딩 부피만큼을 차지하고 있다. 패딩 겉면이 피터와 외부 사이의 경계선이고, 그게 <code>border</code> 다. <code>margin</code>은 “마진을 남기다”를 생각하자. 원가를 제하고 남은 이익을 흔히 마진이라고 한다. 그러니까 본체 외의 플러스 알파 영역인 거다.</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/1b391980-c188-4165-8714-d4db3e1d5703/image.png" alt=""></p>
<p><code>padding</code> 이나 <code>margin</code> 에는 <code>EdgeInsets</code> 를 대입하여 여백을 지정한다. 상하좌우 모두 동일한 여백을 줄지, 상하와 좌우로 나누어 줄지 등에 따라 다른 constructor를 사용할 수 있다. constructor마다 적절한 parameter를 사용한다.</p>
<blockquote>
<h4 id="edgeinsets-의-constructor"><code>EdgeInsets</code> 의 constructor</h4>
<table>
<thead>
<tr>
<th align="center">constructor</th>
<th align="center">parameter</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>all</code></td>
<td align="center">positional 숫자 하나</td>
<td align="center">상하좌우 모두 동일하게 여백 설정</td>
</tr>
<tr>
<td align="center"><code>symmentic</code></td>
<td align="center"><code>vertical</code>, <code>horizonal</code></td>
<td align="center">상하 여백과 좌우 여백을 따로 설정</td>
</tr>
<tr>
<td align="center"><code>fromLTRB</code></td>
<td align="center">positional 숫자 넷</td>
<td align="center">상하좌우 여백을 전부 개별적으로 설정</td>
</tr>
<tr>
<td align="center"><code>only</code></td>
<td align="center"><code>left</code>, <code>top</code>, <code>right</code>, <code>bottom</code></td>
<td align="center">상하좌우 여백 중 원하는 것만 개별적으로 설정</td>
</tr>
</tbody></table>
</blockquote>
<h3 id="한-방향으로-widget을-담는-widget-column--row">한 방향으로 widget을 담는 widget <code>Column</code> &amp; <code>Row</code></h3>
<p><code>Column</code> widget과 <code>Row</code> widget은 방향만 다를 뿐 같은 성질을 가졌다. 아무튼 한 방향으로 widget을 담는 리스트 형태의 layout widget이다. widget이 쌓이는 main axis 방향으로는 기본적으로 expand 하며, 그것과 수직인 cross axis 방향으로는 기본적으로 shrink 한다.</p>
<p>뭐가 <code>Column</code> 이고 뭐가 <code>Row</code> 인지 헷갈린다면, 첫 글자인 [C]와 [R]의 시작점과 끝점의 상대적 위치로 기억하자. [C]의 끝점은 시작점의 아래에 있고 <code>Column</code> 은 widget을 아래로 쌓는다. [R]의 끝점은 시작점의 오른쪽에 있고 <code>Row</code> 는 widget을 오른쪽으로 쌓는다. (물론 아래서 위로, 오른쪽에서 왼쪽으로 방향을 바꿀 수 있지만 가로 세로가 그렇다는 거다.)</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/c37a56cb-6819-4f37-987d-d8ff1f044861/image.png" alt=""></p>
<p>화면을 구상할 땐 그리드 형태로 기획하는 게 편하다. <code>Column</code> 은 가로가 한 칸이고 세로로 여러 칸 배치할 수 있는 그리드다. <code>Row</code> 는 세로가 한 칸이고 가로로 여러 칸 배치할 수 있는 그리드다. 중첩해서 사용하면 기본적으로는 가로가 한 칸이고 세로로 여러 칸 배치할 수 있는 그리드이지만 특정 영역에서는 가로로 여러 칸으로 나뉘는 등의 형태를 만들 수 있다.</p>
<p><code>Column</code> 과 <code>Row</code> 는 기본적으로 크기를 지정할 수 없다. 단지 그 안에 담긴 widget들에 의해 크기가 결정된다. 크기를 지정하고 싶다면 <code>Container</code> 나 <code>SizedBox</code> 같은 widget으로 감싸거나 내부에 있는 widget의 크기를 늘려 그만큼의 공간을 차지하게 할 수 있다.</p>
<p><code>Column</code> 은 기본적으로 가로로는 shrink하는데, 그 안에 가로로 expand하는 <code>Row</code> 를 넣으면 그 <code>Row</code> 를 담기 위해 <code>Column</code> 도 expand 되어 차지할 수 있는 최대 한도로 넓어진다.</p>
<p>main axis 방향으로는 <code>start</code> 정렬, cross axis 방향으로는 <code>center</code> 정렬되는 게 기본이지만 <code>mainAxisAlignment</code> 에 <code>MainAxisAlignment</code> object를 대입하거나 <code>crossAxisAlignment</code> 에 <code>CrossAxisAlignment</code> object를 대입하여 정렬을 변경할 수 있다. 이것도 <code>padding</code>, <code>margin</code> 개념과 마찬가지로 디자인 공통 영역이다. 특정 위치로 몰아주는 것과 남은 여백을 상대적인 크기로 고르게 나누어 채우는 것, 그리고 텍스트 같은 건 baseline 기준으로 정렬할 수 있고 하는 것 등은 다른 데에서 쓰는 것과 동일하다.</p>
<h3 id="한-방향으로-다-안-들어가면-다음-줄로-넘어가는-wrap">한 방향으로 다 안 들어가면 다음 줄로 넘어가는 <code>Wrap</code></h3>
<p><code>Column</code> 이나 <code>Row</code> 는 한 방향으로 widget을 쌓아가다가 화면이 가득 차면 overflow되거나 스크롤이 생긴다. 이 때 <code>Wrap</code> widget을 사용하면 한 방향으로 가득 찼을 때 다음 줄로 넘어가도록 배치할 수 있다.</p>
<p>화면의 크기에 따라 네 장씩 배치하기도 하고 세 장씩 배치하기도 하고 아무튼 다음 건 다음 줄에 있는 갤러리 같은 걸 생각하면 쉽게 이해할 수 있다.</p>
<h3 id="그리드를-무시하고-사용하는-stack">그리드를 무시하고 사용하는 <code>Stack</code></h3>
<p><code>Stack</code> widget은 한 방향으로 순차적으로 쌓이지 않고 전부 위치를 지정해 주어야 한다. 서로 겹칠 수도 있고 완전히 별개의 좌표에 존재할 수도 있다. 여러 해상도를 고려하여 적절한 위치에 배치하기 쉽지 않으니 자주 쓰이진 않는다.</p>
<h2 id="랜딩-페이지를-작성해-보자">랜딩 페이지를 작성해 보자</h2>
<p>수업 시간의 라이브 코딩 때는 <a href="https://healthyclass.kr/markdown_contents/QZaFfIFFz5CBleIlnGgX/Ep3fOvYpBvlqJkHgpFnX">랜딩 페이지의 기본 뼈대</a>를 잡아 보았다. widget들을 적절한 계층과 순서로 사용하여 목표 화면과 동일하게 만드는 거다. </p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/2e678b8c-9900-4f0d-aa4c-2f906c943129/image.png" alt=""></p>
<h3 id="그리고-과제-challenge">그리고 과제 Challenge</h3>
<p>수업을 마친 후 실습 시간에는 <a href="https://healthyclass.kr/markdown_contents/QZaFfIFFz5CBleIlnGgX/Qf02knQywtoQMx73k4Jk">Layout Challenge</a>의 화면 구성대로 랜딩 페이지를 보완하는 것을 과제로 내주셨다. 수업 시간에 명확하게 다루지 않은 부분에 대해서는 힌트가 나와 있어서 해 나가는 데 어렵지 않았다.</p>
<p><code>Container</code> widget을 생성할 때 <code>decoration</code> parameter에 어떤 object를 넣어야 하는지도 약간만 삽질하면 IDE 자체 기능이 힌트를 줘서 무난하게 따라갈 수 있었다. &#39;이런 방식을 사용하면 될 것 같은데 그걸 Flutter에서는 어떻게 하지?&#39; 하는 것만 검색해 보았다. <code>Container</code> 로 화면을 가득 채우고 거기에 이미지를 가득 채우면 될 것 같은데, 하면서 말이다.</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/357563fc-9883-4287-9995-601107e8c5dc/image.png" alt=""></p>
<h2 id="고민-그리고-또-고민">고민, 그리고 또 고민</h2>
<p>기획은 어렵다. 늘 그래왔다. 아이디어는... 글쎄. 나 자신을 설득시킬 수 없는 아이디어는 심도 깊은 기획으로 이어지지 않는다. 일단 나와 있는 아이디어 가지고 wireframe이나 잡아봐야지 했는데 Figma를 건드리다 보니 최소한의 디자인은 들어간 prototype이 작성되었다. 잘은 모르겠다. 페이지는 약 열 페이지 정도 되는데 기능이 이것저것 있는 게 아니라 비슷한 유형이 반복되는 것들이기도 하고.</p>
<p>지금은 백엔드가 내려갔지만 <a href="https://velog.io/@peeeeeter_j/pt-study-35">감정 단어 사전</a> 때 살펴본 434개의 감정 단어 목록을 활용해 볼까. <a href="https://accesson.kr/ksppa/assets/pdf/14556/journal-19-1-109.pdf">《한국어 감정 단어의 목록 작성과 차원 탐색》 논문</a> 기반으로 정리한 감정 단어 목록은 있긴 한데 이걸 그대로 사용해도 되는지는 모르겠다. 아무래도 저작권이 어쩌고 하는 이슈가 있으니 말이다. 개인 실습이야 비상업적으로 개인이 해본 거니까 괜찮아도 앱 배포는 다른 문제다.</p>
<p>지금의 기획에도 러셀의 2차원 감정 원형 모형이 사용되긴 하지만 그건 그냥 넣는 김에 기능의 복잡도를 조금이나마 높이기 위해 넣은 것뿐이고.</p>
<p>기획이 나오면 그걸 기반으로 prototype을 작성하고 구현하는 건 할 만한데 기획이 늘 어렵다. Figma로 prototype을 만드는 것부터는 거부감이 크지 않다. 충분히 할 수 있다. 하지만 아무튼 기획, 고놈의 기획... 와닿지 않는 기획 말고.</p>
<p>AI와 브레인스토밍을 하다 보면 &#39;그건 웹으로 있어도 쓸까 말까 한데 앱으로 다운로드 받진 않을 것 같다&#39; 싶은 아이디어나 &#39;그런 건 있어도 안 쓸 것 같다&#39; 싶은 아이디어나 &#39;누군가에게는 니즈가 있을 순 있지만 난 그게 왜 필요한지 납득되지 않는다&#39; 싶은 아이디어를 많이 제시하더라. 역시 잘 모르겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 09 | Flutter 실습을 시작해 보자]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-09</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-09</guid>
            <pubDate>Mon, 20 Jul 2026 09:57:52 GMT</pubDate>
            <description><![CDATA[<h1 id="day-09--flutter-실습을-시작해-보자">DAY 09 | Flutter 실습을 시작해 보자</h1>
<p>git 복습을 하는 동안 적당히 들으면서 자기소개서를 준비했다. 어떤 이야기를 담아야 할지 Gemini에게 물어보면서 작업했다. 이런 식으로 작성하면 되는지는 잘 모르겠지만 일단 초안을 작성해서 강사님이랑 잡코디님하고 얘기해 봐야지. 아무것도 없는 상태에서 얘기하는 것보다는 어설픈 초안이라도 들고 얘기하는 게 나을 테니 말이다.</p>
<h2 id="flutter-실습을-위한-준비">Flutter 실습을 위한 준비</h2>
<p>설치할 건 처음에 다 설치했고, 프로젝트를 생성해 보자. 그 전에 Flutter에 대해 간단히 다시 살펴보고 넘어가면...</p>
<blockquote>
<h4 id="flutter-개요">[Flutter 개요]</h4>
</blockquote>
<ul>
<li>구글에서 제작하여 배포한 오픈소스 프레임워크</li>
<li>크로스 플랫폼 개발 지원<ul>
<li>구글의 차세대 OS인 Fuchsia도 지원한다!</li>
<li>모바일 중심으로 배우겠지만 단일 코드베이스로 다양하게 개발 가능</li>
<li>전반적인 크로스플랫폼: 기초적인 기능은 호환률이 높지만 실무적인 수준에서는 70% 정도로 떨어진다.</li>
<li>데스크톱 앱: 상용 애플리케이션을 만들 정도는 아니고 개인용 앱 정도는 만들 수 있다.</li>
<li>웹: 성숙도가 낮은 편이라 일반적인 웹 프레임워크보다는 부족하지만 약간 불편하게 쓸 수는 있다.</li>
</ul>
</li>
</ul>
<blockquote>
<h4 id="여담-안드로이드-스튜디오의-가상머신">[여담: 안드로이드 스튜디오의 가상머신]</h4>
<table>
<thead>
<tr>
<th align="center">구분</th>
<th align="center">에뮬레이터 (Android)</th>
<th align="center">시뮬레이터 (iOS 등)</th>
</tr>
</thead>
<tbody><tr>
<td align="center">동작 방식</td>
<td align="center">하드웨어 + OS 모두 가상화</td>
<td align="center">소프트웨어(OS)만 흉내</td>
</tr>
<tr>
<td align="center">목적</td>
<td align="center">실제 기기를 완벽히 대체하여 테스트</td>
<td align="center">기기의 논리적 동작과 UI 프로토타입 확인</td>
</tr>
<tr>
<td align="center">성능/속도</td>
<td align="center">하드웨어를 전부 흉내 내므로 상대적으로 리소스 소모가 큼</td>
<td align="center">컴퓨터의 리소스를 직접 사용하여 상대적으로 빠름</td>
</tr>
<tr>
<td align="center">정확도</td>
<td align="center">실기기와 매우 유사하여 하드웨어 센서 등 검증에 유리</td>
<td align="center">실제 기기와 내부 구조가 달라 하드웨어적 요소 테스트는 제한됨</td>
</tr>
</tbody></table>
</blockquote>
<h3 id="프로젝트-생성">프로젝트 생성</h3>
<p>Andorid Studio를 열고 [New Flutter Project] &gt; [Flutter] &gt; [Flutter SDK path] 설정을 하여 프로젝트를 생성한다. 이 때, path가 잘못 설정되면 다음과 같은 경고가 뜬다.</p>
<blockquote>
<p>The folder specified as the Flutter SDK home does not exist.</p>
</blockquote>
<p>프로젝트 이름 및 설명은 임의로 작성하면 되지만, 너무 대충 짓진 말고 의미 있는 내용을 담는 습관을 들이도록 하자.</p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/14fb6d92-b1a1-4c7c-b5ad-c381e2f803f1/image.png" alt=""></p>
<p>프로젝트를 시작할 때는 새로 생성한 거든 온라인에 가져온 거든 Flutter 버전을 확인하고 config가 꼬이지 않았는지 한 번 빌드해 보는 게 좋다.</p>
<h3 id="구조-살펴보기">구조 살펴보기</h3>
<blockquote>
</blockquote>
<pre><code class="language-bash">❯ tree -aL 1
.
├── .dart_tool
├── .gitignore
├── .idea
├── .metadata
├── README.md # 프로젝트에 대한 설명
├── analysis_options.yaml # 정적 분석 옵션
├── android # android 네이티브 코드
├── build # 빌드 결과물
├── ios # iOS 네이티브 코드
├── lib # 우리가 Flutter 코드 작성할 곳
├── pubspec.lock # 하위 의존성까지 정확한 버전 고정 (직접 수정 X)
├── pubspec.yaml # 의존성 정보 (사용할 패키지 버전 범위 명시)
├── test # 테스트 코드
├── web # 웹 네이티브 코드
└── workout_tracker_2026.iml # 모듈 설정 파일
&gt;
9 directories, 7 files</code></pre>
<p>Dart는 depth가 깊어지기 쉬운데, lint rule에 따라 가독성 차이가 있다. 예전에는 콤마 기준으로 개행되게 설정되어 있었는데 지금은 라인 길이 기준으로 기본값이 바뀌었다더라. <code>analysis_options.yaml</code> 파일에서 lint rule을 수정할 수 있다.</p>
<blockquote>
<p><code>analysis_options.yaml</code></p>
</blockquote>
<pre><code class="language-yaml">include: package:flutter_lints/flutter.yaml
&gt;
linter:
  rules:
    prefer_single_quotes: true # Enable the prefer_single_quotes rule
    require_trailing_commas: true # Require trailing commas for functions, methods, etc.</code></pre>
<p>나는 추가하는 김에 큰 따옴표 사용을 지양하고 작은 따옴표를 강제하는 옵션도 추가했다. 이게 섞여 있는 것도 은근 거슬리니 말이다.</p>
<h3 id="데모-앱-뜯어보기">데모 앱 뜯어보기</h3>
<p>Flutter 앱은 <code>main.dart</code> 파일의 <code>main()</code> function으로 시작한다. <code>main.dart</code> 가 시작지점인 건 구성 파일에 그렇게 작성되어 있는 거지만 <code>main()</code> 가 시작인 건 정해진 규칙이다.</p>
<p>Dart 레벨에서 보면 <code>StatelessWidget</code> 을 extends한 <code>MyApp</code> class를 생성하여 <code>runApp(const MyApp());</code> 한다. 이걸 Flutter 관점에서는 <code>StatelessWidget</code> 인 <code>MyApp</code> 위젯을 생성하여 실행한다고 본다.</p>
<p>Dart의 모든 것은 object였다. 그리고 Flutter의 대부분으 것(모든까지는 아니고)은 widget이다. 모든 widget은 근본적으로 불변이며, 변할 수 있는 state object를 별도로 결합할 수 있다.</p>
<blockquote>
<h4 id="ui--fstate">$$UI = f(State)$$</h4>
<p>위젯($$f$$)에 데이터($$State$$)를 집어넣으면 화면($$UI$$)이 튀어나오는 구조
기존의 앱 개발은 데이터의 변화에 따라 개발자가 UI를 적절히 수정해 주는 코드를 작성해야 했지만, Flutter는 데이터가 변경되면 그 부분의 UI를 새로 그려 버린다. 이를 <strong>선언형 UI</strong> 라고 한다.
widget은 불변이고 state의 변화에 따라 새로 그릴 뿐이다.</p>
</blockquote>
<p>Flutter 프로젝트의 UI 흐름은 기본적으로 다음과 같다.</p>
<blockquote>
<p><code>MyApp</code> → <code>MaterialApp</code> → <code>MyHomepage</code> → <code>Scaffold</code></p>
</blockquote>
<p><code>MyApp</code> 과 <code>MyHompage</code> 는 개발자가 작성한 widget으로, 원하는 이름을 설정할 수 있다. 이 녀석들은 반드시 <code>StatefulWidget</code> 혹은 <code>StatelessWidget</code> 을 extends 해야 하며, 이에 따라 <code>Widget</code> 을 반환하는 <code>build()</code> method를 작성해야 한다.</p>
<p><code>MyApp</code> 은 앱의 기본 테마나 라우팅 설정 등을 초기화하므로 주로 <code>StatelessWidget</code> 을 사용한다. <code>MyHomepage</code> 가 무엇을 extends 할지는 이것이 가변 데이터를 포함하는지 여부에 따라 달라진다.</p>
<p><code>MaterialApp</code> 은 Flutter가 제공하는 기본 Material Design 컴포넌트들을 사용할 수 있게 해주는 built-in widget이다. 구글 진영의 Material Design을 사용할 땐 이걸 쓰고, iOS의 Cupertino Design을 사용할 땐 <code>CupertinoApp</code> 을 사용한다. 이 녀석은 앱 전체를 감싸는 틀, 최상단 root widget이라고 할 수 있다.</p>
<p><code>Scaffold</code> 도 Flutter의 built-in widget인데, 화면의 기본 뼈대를 잡아주는 역할을 한다. <code>Scaffold</code> 는 앱 page의 시작으로, 앱의 화면 개수만큼 고유한 <code>Scaffold</code> widget이 존재한다.</p>
<p><code>MaterialApp</code> 이나 <code>Scaffold</code> 같은 Flutter SDK에서 제공하는 built-in widget은 extends 없이 그대로 생성하여 사용하면 되고, <code>MyApp</code> 이나 <code>MyHomepage</code> 같은 custom widget은 적절한 widget을 extends 하여 구현해야 한다.</p>
<blockquote>
<h4 id="여담-runapp-에-materialapp-widget을-직접-넣지-않고-myapp-widget을-거치는-것에-관하여">[여담: <code>runApp()</code> 에 <code>MaterialApp</code> widget을 직접 넣지 않고 <code>MyApp</code> widget을 거치는 것에 관하여]</h4>
<p><code>runApp(const MaterialApp(...))</code> 와 같이 작성해도 작동은 한다. 하지만 <code>MaterialApp</code> widget을 build하는 <code>MyApp</code> widget을 사용하는 게 좋다.</p>
</blockquote>
<p>우선, <strong>코드 분리와 가독성</strong> 측면에서 이점이 있다.</p>
<blockquote>
</blockquote>
<p><code>main()</code> function에는 직접적인 기능을 구현하기보다는 entry point 역할만 수행하도록 두는 편이 가독성이 좋다. 우리 코드에서는 <code>MaterialApp</code> widget에 <code>home: HelloWorld()</code> 만 전달했지만 프로젝트가 커지면 테마 설정, 라우팅, 전역 상태 관리 등 수많은 설정 코드가 들어갈 수 있다. 이 때, <code>main()</code> function에서 <code>runApp(const MyApp());</code> 만 하지 않고 <code>MaterialApp</code> widget의 세부 설정 코드를 작성할 경우, <code>main()</code> function이 지나치게 복잡해진다.</p>
<blockquote>
</blockquote>
<p>그리고, <strong>hot reload</strong> 기능이 활용성 측면에서도 이점이 있다.</p>
<blockquote>
</blockquote>
<p>Flutter의 강력한 기능 중 하나인 hot reload는 <code>build()</code> function 내부의 변경 사항을 감지하여 화면에 바로 반영한다. 그런데 <code>build()</code> function을 거치지 않고 widget을 바로 전달했을 경우에는 hot reload 기능을 사용할 수 없게 된다. 기본 상태값을 유지하지 못하고 모든 상태를 초기화하는 hot restart만 가능만 사용할 수 있다.</p>
<blockquote>
</blockquote>
<p><strong>구조적 확장성</strong> 측면에서도 이점이 있다.</p>
<blockquote>
</blockquote>
<p>프로젝트가 커져 앱 전체에서 사용하는 데이터나 상태를 관리하는 widget을 사용하게 될 경우, 코드가 분리되어 있는 편이 확장하기 용이하다. <code>Provider</code>, <code>Bloc</code>, <code>Riverpod</code> 등을 사용하게 되면 이들은 <code>MaterialApp</code> 을 감싸서 작성해야 하는데 <code>runApp(const MaterialApp(...))</code> 로 구현되어 있다면 <code>main()</code> function이 점점 더 복잡해져 유지보수 하기 어려워진다.</p>
<blockquote>
<h4 id="여담-hot-reload와-hot-restart">[여담: hot reload와 hot restart]</h4>
<table>
<thead>
<tr>
<th align="center">기능</th>
<th align="center">핫 리로드 (Hot Reload)</th>
<th align="center">핫 리스타트 (Hot Restart)</th>
</tr>
</thead>
<tbody><tr>
<td align="center">상태 유지</td>
<td align="center">기존 상태 유지 (variable 값, navigation 유지)</td>
<td align="center">모든 상태 초기화 (처음부터 다시 시작)</td>
</tr>
<tr>
<td align="center">속도</td>
<td align="center">매우 빠름 (약 1초 내외)</td>
<td align="center">빠름 (약 3~5초 내외)</td>
</tr>
<tr>
<td align="center">실행 함수</td>
<td align="center"><code>build()</code> method만 재실행</td>
<td align="center"><code>main()</code>, <code>initState()</code> 부터 전체 재실행</td>
</tr>
<tr>
<td align="center">단축키 (터미널)</td>
<td align="center">소문자 <code>r</code> 입력</td>
<td align="center">대문자 <code>R</code> 입력</td>
</tr>
<tr>
<td align="center">용도</td>
<td align="center">위젯의 디자인 수정, 색상 변경, 간단한 UI 구조 변경에 적합</td>
<td align="center">앱의 상태를 완전히 초기화하고 처음부터 다시 실행할 때 full restart보다 빠르게 수행</td>
</tr>
</tbody></table>
</blockquote>
<p>native 설정이나 native 코드를 수정하거나 <code>pubspec.yaml</code> 파일을 수정했을 경우에는 앱을 완전히 끄고 재시작하는 full restart를 해야 한다.</p>
<h2 id="코드를-직접-작성해-보자">코드를 직접 작성해 보자</h2>
<h3 id="텍스트-출력">텍스트 출력</h3>
<p>데모 앱 코드를 다 지우고 <code>Hello World</code> 를 출력하는 앱 화면을 만들어 보자.</p>
<p>Android Studio에는 code snippet 기능이 있어 <code>stless</code> 라고 입력하고 엔터를 치면 <code>StatelessWidget</code> 을 extends 하는 코드를 자동완성 해준다. 정식 명칭은 Live Templates인 모양이다.</p>
<p><code>StatefulWidget</code> 을 extends 하는 <code>HelloWorld</code> widget을 생성하고 <code>Scaffold</code> widget을 반환하도록 작성한다. 이 <code>Scaffold</code> widget의 <code>body</code> 에 <code>Text</code> widget을 담은 <code>Center</code> widget을 넣으면 원하는 텍스트를 중앙 정렬이 되게 출력할 수 있다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
void main() {
  runApp(const MyApp());
}
&gt;
class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HelloWorld(),
    );
  }
}
&gt;
class HelloWorld extends StatelessWidget {
  const HelloWorld({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text(&#39;Hello World&#39;),
      ),
    );
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/2d8a014c-71e7-4e17-a8bb-310d6cbf9687/image.png" alt=""></p>
<h3 id="네트워크-너머의-이미지-출력">네트워크 너머의 이미지 출력</h3>
<p>먼저 로컬에 있는 asset 이미지가 아닌 URL로 접근하는 network 이미지를 출력해 보도록 하겠다. 둘 다 장단점은 있다. asset 이미지는 빠르고 안정적이지만 많아지면 앱 크기가 커지고 network 이미지는 통신이 늘어나지만 앱 크기는 작게 유지할 수 있다. 로고 같은 건 asset 이미지로 담고 저장 공간을 많이 차지하는 건 network 이미지로 하는 등 유동적으로 사용할 수 있다.</p>
<p>network 이미지를 출력할 땐 <code>NetworkImage</code> widget을 <code>Image</code> widget에 전달하여 사용한다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
void main() {
  runApp(const MyApp());
}
&gt;
class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HelloWorld(),
    );
  }
}
&gt;
class HelloWorld extends StatelessWidget {
  const HelloWorld({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        // child: Text(&#39;Hello World&#39;),
        child: Image(image: NetworkImage(&#39;https://images.unsplash.com/photo-1609377375724-8fadc82cd50e?w=500&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxzZWFyY2h8NTB8fHJ1bm5pbmd8ZW58MHx8MHx8fDA%3D&#39;)),
      ),
    );
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/3d7994e0-f9cf-4fb2-affd-7117cc4f418b/image.png" alt=""></p>
<h3 id="앱-내부의-이미지-출력">앱 내부의 이미지 출력</h3>
<p>네트워크 너머의 사진을 사용하지 않고 앱 내부에 이미지를 저장해 놓으려면 <code>pubspec.yaml</code> 파일을 수정해야 한다. 여기에 assets 디렉토리를 추가해 놓아도 되고 구체적인 이미지 파일명을 지정해 놓아도 된다. 이 때, 디렉토리를 추가할 경우 하위 디렉토리에 있는 리소스는 포함되지 않고 해당 계층에 있는 것만 포함된다. 디렉토리 이름을 적어놓을 경우 뒤에 <code>/</code> 를 붙여 디렉토리임을 명시하도록 하자.</p>
<p>assets 디렉토리는 말 그대로 <code>assets</code> 라는 이름을 사용하는 게 암묵적인 규칙이다. assets을 추가하고 나면 hot reload가 아닌 rebuild를 해야 제대로 불러올 수 있다.</p>
<blockquote>
<p><code>pubspec.yaml</code></p>
</blockquote>
<pre><code class="language-yaml"># 앞 부분 생략
&gt;
flutter:
&gt;
  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true
&gt;
  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg
  assets:
    - assets/</code></pre>
<p>나는 Gemini가 내 그림을 리터칭해주었던 것을 넣어 보았다.</p>
<blockquote>
<p><code>lib/main.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:flutter/material.dart&#39;;
&gt;
void main() {
  runApp(const MyApp());
}
&gt;
class MyApp extends StatelessWidget {
  const MyApp({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HelloWorld(),
    );
  }
}
&gt;
class HelloWorld extends StatelessWidget {
  const HelloWorld({super.key});
&gt;
  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        // child: Text(&#39;Hello World&#39;),
        // child: Image(image: NetworkImage(&#39;https://images.unsplash.com/photo-1609377375724-8fadc82cd50e?w=500&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxzZWFyY2h8NTB8fHJ1bm5pbmd8ZW58MHx8MHx8fDA%3D&#39;)),
        child: Image(image: AssetImage(&#39;assets/IMG_2119.JPG&#39;)),
      ),
    );
  }
}</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/c3b4ca39-55b4-4def-a304-44257ff5aa46/image.png" alt=""></p>
<h2 id="실습-시간">실습 시간</h2>
<p>오늘은 Hello World 수준의 가벼운 내용을 다루어 실습은 따로 하지 않았다. 대신 이력서, 자기소개서, 포트폴리오 초안을 검토했다. 전부 v0.1의 아주 날 것 그대로의 draft다. 주변에 일경험, 일자리 사업을 제외한 본격적인 취업 준비를 해 본 사람이 별로 없고 내가 그나마 선발 주자 같은 느낌이라 도움을 받을 곳은 SeSAC의 강사님과 잡코디님뿐이더라. 사회인들은 당장은 무직 상태인 경우에도 대체로 경력이 몇 년 정도는 되는 반면 우리 같은 고립은둔 출신 청년들은 서른 언저리에서야 아둥바둥 하고 있으니.</p>
<p>그래도 고립 상태에서 은둔 상태로 파고 내려가기 전에 오픈소스 컨트리뷰션 아카데미 같은 활동을 했던 게 있어서 뭐라도 적을 건 있더라. 대학생 때도 친목 집단은 없어도 멘토링이나 스터디 같은 건 있었고. 은둔 상태의 몇 년도 요즘 워낙 취업 시장이 어려워서 공백기는 다들 있으니까 크게 문제되지 않을 것 같다. 서울시 지원사업 참여하면서는 3년 차쯤 되었을 때부터 이것저것 연계받아 서울시 기술교육원도 수료하고 SeSAC도 고립은둔청년 전형으로 가산점 받고 들어올 수 있었고 그렇게 꾸준히 뭔가 해 나가기 시작했으니까.</p>
<p>일단은 초안이니까 디테일을 잡기보다는 내용을 채워 넣는 걸 중점으로 했다. 처음부터 문장을 다듬고 완성도 높게 가려고 하면 초안부터가 안 만들어지니까. 주변에 레퍼런스 삼을 만한 것도 없고 검색해 봐도 잘 모르겠고 해서 어떤 톤으로 어떻게 표현해야 할지는 뒤로 미뤄 두었다. 그랬더니 문장이 너무 딱딱하고 전문 용어가 많이 들어가 있는 글이 되었다.</p>
<p>문학 소설을 어려워하고 인문 교양서나 기술 문서 같은 걸 주로 읽는 게 나의 문체에 영향을 많이 줬을 것 같기도 하고. 초안이라 AI의 도움을 많이 받았기에 내 표현보다는 AI의 표현이 더 많은 것도 한몫 했을 거고. 좀 더 가볍고 부드럽고 인간적인 톤을 사용하는 건 역시 잘 되지 않는다. 공적인 글이라고 생각해서 더 그런가.</p>
<p>일단 넣을 수 있는 건 다 넣어보자는 식으로 끌어모아 적다 보니 특별히 &quot;이 경험에서는 이걸 강조해야지!&quot; 하고 목적이 있는 주장을 하기보다는 그냥 내용을 채워 넣은 것뿐이라 읽는 사람 입장에서 &quot;그래서 무슨 말을 하고 싶은 거지?&quot; 하게 되는 것도 있는 것 같다. 그걸 통해 어떤 걸 보여주고 싶은지를 고민하며 보완할 필요가 있다. 지금은 그냥 사실을 나열했을 뿐, 정보가 되지 못한 자료 같은 느낌?</p>
<p>조만간 좀 더 다등고 잡코디님 피드백도 받아봐야지...ㅎ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 08 | git을 가볍게 살펴 보자]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-08</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-08</guid>
            <pubDate>Wed, 15 Jul 2026 13:05:18 GMT</pubDate>
            <description><![CDATA[<h1 id="day-08--git을-가볍게-살펴-보자">DAY 08 | git을 가볍게 살펴 보자</h1>
<p>FOSS 분산 버전 관리 시스템, git. 컴퓨터공학과를 졸업했다면 누구나 건드려 보았을 그것. 나 또한 대학교 3학년 때 GitHub 계정을 처음 만들며 사용해 보았다. 사실 컴퓨터공학과 학생치고는 상당히 늦은 편이었다. 팀 프로젝트에서 몇 번 써보긴 했지만 대부분은 개인 작업 백업용으로 사용하여 협업 도구로 사용해 본 경험은 거의 없다. Jenkins나 Sonarqube 같은 것도 같이 사용하며 이것저것 해봤던 것 같은데 그것도 이미 오래 전 기억이다.</p>
<p>누군가와 함께 작업해 본 지 한참 되었다 보니 confilct가 어쩌고 merge가 어쩌고 하는 것들은 어렴풋하게 남아 있다. 몇 번 다시 써보면 익숙해질 듯. 그래서 오늘 수업은 가볍게 들으며 기억 저편의 지식들을 되짚어 보는 시간을 갖기로 했다. 대체로 <a href="https://git-scm.com/cheat-sheet">Git Cheat Sheet</a> 보면서 사용하면 무리 없이 사용할 수 있을 것 같긴 하지만.</p>
<p>깊이 있는 공부를 원한다면 <a href="https://opentutorials.org/course/3838">생활코딩</a>을 추천하시더라.</p>
<h2 id="git이란-무엇인가">git이란 무엇인가</h2>
<p>git은 Linux의 창시자 리누스 토발즈가...하는 내용으로 시작하며 공부했던 학생 때의 기억이 스멀스멀 기어 올라온다. git의 주요 목적은 버전 관리, 백업 및 복구, 협업이다. 버전 관리가 핵심인데 개인 개발자는 백업 및 복구 목적으로 많이 사용한다고. 협업은 GitHub이 나오면서 크게 발전했다는 모양이다.</p>
<blockquote>
<h4 id="누군가의-질문으로-살펴본-버전-관리와-형상-관리">누군가의 질문으로 살펴본 버전 관리와 형상 관리</h4>
<table>
<thead>
<tr>
<th align="center">구분</th>
<th align="center">버전 관리 (Version Control)</th>
<th align="center">형상 관리 (Configuration Management)</th>
</tr>
</thead>
<tbody><tr>
<td align="center">개념</td>
<td align="center">소스 코드 파일의 변화를 기록하고 관리함</td>
<td align="center">소프트웨어 생명주기 전체의 산출물을 통제함</td>
</tr>
<tr>
<td align="center">범위</td>
<td align="center">하위 개념 (형상관리의 기술적 수단)</td>
<td align="center">상위 개념 (프로젝트 관리 방법론)</td>
</tr>
<tr>
<td align="center">주요 대상</td>
<td align="center">소스 코드, 리소스 파일, 텍스트 문서 등</td>
<td align="center">요구사항 정의서, 설계서, 테스트 시나리오, 소스 코드, 빌드 파일, 매뉴얼 등</td>
</tr>
<tr>
<td align="center">핵심 목적</td>
<td align="center">개발자 간의 코드 충돌 방지 및 이력 추적</td>
<td align="center">제품의 품질 보증 및 변경 승인 절차 통제</td>
</tr>
<tr>
<td align="center">수행 주체</td>
<td align="center">주로 개발자</td>
<td align="center">개발자, QA, 프로젝트 매니저(PM), 고객, 형상관리자(CCB)</td>
</tr>
</tbody></table>
</blockquote>
<p>git은 자동으로 버전을 만들지 않는다. 기록하고자 할 때마다 Commit을 해 주어야 한다.</p>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/b3e7a4c8-45fa-4174-b2f6-4b6015b72ac1/image.png" alt=""></p>
<h3 id="git-명령어-살펴보기">git 명령어 살펴보기</h3>
<blockquote>
<h4 id="주요-git-명령어">[주요 Git 명령어]</h4>
<table>
<thead>
<tr>
<th align="center">명령어</th>
<th align="center">의미</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><code>git init</code></td>
<td align="center">현 위치를 git repository로 만든다. (woriking directory; untracked files)</td>
</tr>
<tr>
<td align="center"><code>git add</code></td>
<td align="center">working directory에 있는 파일의 변경사항을 stage에 올려 tracked file로 만든다.</td>
</tr>
<tr>
<td align="center"><code>git status</code></td>
<td align="center">working directory 및 stage에 있는 디렉토리/파일 목록을 출력한다.</td>
</tr>
<tr>
<td align="center"><code>git commit</code></td>
<td align="center">현재 stage에 존재하는 변경사항을 모아 버전 기록을 한다.</td>
</tr>
<tr>
<td align="center"><code>git log</code></td>
<td align="center">지금까지의 commit 내역을 확인한다.</td>
</tr>
<tr>
<td align="center"><code>git remote add</code></td>
<td align="center">commit을 push/pull 할 remote repository를 등록한다.</td>
</tr>
<tr>
<td align="center"><code>git push</code></td>
<td align="center">local repository의 commit을 remote repository로 내보낸다.</td>
</tr>
<tr>
<td align="center"><code>git pull</code></td>
<td align="center">remote repository의 commit을 local repository로 불러온다.</td>
</tr>
<tr>
<td align="center"><code>git fetch</code></td>
<td align="center">remote repository의 commit을 local repository로 가져오되, 바로 합치진 않고 별도 branch에 불러온다.</td>
</tr>
<tr>
<td align="center"><code>git merge</code></td>
<td align="center">Fetch한 branch와의 conflict를 해결하여 병합한다.</td>
</tr>
</tbody></table>
</blockquote>
<p><code>git init</code> 은 프로젝트 루트에서 해 주어야 한다. Dart 프로젝트는 항상 프로젝트 루트에 <code>pubspec.yaml</code> 파일이 있으므로 이 파일이 있는 위치에서 수행하면 된다. git reopository에는 관리용 <code>.git</code> 디렉토리가 생성된다. 존재하지 않을 경우 <code>.gitignore</code> 파일도 함께 생성된다. tracking 하지 않을 대상을 명시해 놓는 파일이다.</p>
<p><code>git add</code> 뒤에 디렉토리/파일 경로를 붙이면 해당 디렉토리/파일의 현재 상태가 Stage로 올라간다. <code>git add .</code> 하면 현재 디렉토리와 그 하위 모든 디렉토리/파일의 현재 상태가 Stage에 올라가고,<code>git add --all</code> 하면 프로젝트 내 모든 디렉토리/파일의 현재 상태가 Stage에 올라간다. 프로젝트 루트에서 실행하면 이 둘이 동일하지만, 하위 디렉토리에서 실행하면 <code>git add .</code> 는 상위 디렉토리에 있는 건 담지 못한다.</p>
<p><code>git status</code> 했을 때 <code>Changes to be committed:</code> 에 뜨는 녀석들이 Stage에 있는 것이며 <code>Untracked files:</code> 에 있는 녀석들이 Working Directory에 있는 것이다. Commit까지 완료되어 Local Repository에 있는 녀석은 이 목록에 뜨지 않는다.</p>
<p><code>git commit</code> 에는 <code>-m</code> 옵션을 사용하여 description message를 남겨야 한다. 나는 문서를 수정했을 땐 <code>&quot;docs: description&quot;</code> 와 같이 작성하고 기능을 추가했을 땐 <code>&quot;feat: description&quot;</code> 와 같이 작성한다. 그리고 특별한 일 없으면 <code>-S</code> 옵션을 사용하여 <a href="https://velog.io/@peeeeeter_j/GitHub%EC%97%90-GPG-key-%EB%93%B1%EB%A1%9D%ED%95%98%EA%B8%B0">GPG 서명</a>도 추가한다.</p>
<p><code>git log</code> 로 그 동안의 Commit 내역을 확인할 때, 구체적인 Commit 시점을 파악해야 하는 게 아니라면 나는 보통 <code>--oneline</code> 옵션을 함께 사용하는 편이다. 어떻게 흘러갔는지 가볍게 훑어보는 용이다.</p>
<p>remote repository는 어느 것이든 쓸 수 있지만 보통은 GitHub, GitLab, BitBucket 등의 서비스를 이용한다. GitHub이 압도적 1위라고. 난 이걸 셋 다 써본 적 있던 것 같은데 어느 프로젝트에서였더라. 문득 궁금해져서 살펴 보니 GitLab은 Inkscape 문서 오탈자 제보하고 한국어 번역할 때 써 봤군. BitBucket은 계정이 만들어져 있긴 했는데 뭔가 들어있진 않아서 잘 기억나진 않는다.</p>
<p>하여간 <code>git remote add REMOTE_NAME REMOTE_URL</code> 형태로 등록한 remote repository는 <code>git remote -v</code> 명령어를 통해 확인할 수 있다. 특별한 일 없으면 가장 대표적으로 사용되는 원격 저장소 별칭은 <code>origin</code> 으로 한다. 보통은 GitHub 저장소 하나만 사용하며 그것을 <code>origin</code> 으로 사용한다.</p>
<p><code>git push REMOTE_NAME BRANCH_NAME</code> 명령어를 사용하면 해당 remote repository의 해당 branch에 그 동안의 commit이 올라간다. <code>-u</code> 옵션을 사용하면 해당 branch를 upstream으로 설정할 수 있다. upstream으로 설정된 remote repository에 push/pull 할 땐 이름을 생략하고 <code>git push</code> 및 <code>git pull</code> 만으로 수행할 수 있다.</p>
<p>conflict가 발생하지 않을 상황에서는 <code>git pull</code> 로 충분하지만 여러 사람과 작업을 할 경우에는 <code>git fetch</code> 후 <code>git merge</code> 를 해야 할 수 있다. conflict를 대처하는 방법까지는 우리 수업에서는 다루지 않는다. 팀 프로젝트를 할 땐 종종 마주쳤던 것 같다.</p>
<h2 id="실습은-생략하고">실습은 생략하고,</h2>
<p>진행하다 더 좋은 무언가가 떠올라 기존의 것을 엎게 되더라도 일단 프로젝트를 시작해 보기로 했다. 가칭 <strong>NF</strong> 라고 하겠다. Figma로 autolayout 다루고 등등 하는 건 익숙한 편이니까 wireframe 잡는 것도 조금 찾아보면 할 수 있겠지. (위의 git 관련 이미지도 Figma에서 autolayout 기반으로 그린 거다.)</p>
<p>wireframe을 잡는 데 도움이 되는 Figma plugin으로는 <a href="https://www.figma.com/community/plugin/742764242781786818">Wireframe</a>이 있는 모양이다. 이를 활용하여 제작된 <a href="https://www.figma.com/community/wireframes/websites?resource_type=files&amp;editor_type=figma&amp;price=free&amp;sort_by=all_time">템플릿</a>들도 많이 공유되어 있는 모양이고.</p>
<p>일단 기획을 좀 더 구체화하고 어떻게 구성되어 있어야 할지 생각하며 wireframe 작업을 해 보기로 했다. 완벽하게 해내려고 하기 보다는 할 수 있겠는 만큼 작업하고 나중에 보완하는, 약간은 spiral한 방식으로 진행될 것 같기도 하고.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 07 | Dart 언어의 객체 지향 개념들을 살펴보자]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-07</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-07</guid>
            <pubDate>Mon, 13 Jul 2026 09:30:11 GMT</pubDate>
            <description><![CDATA[<h1 id="day-07--dart-언어의-객체-지향-개념들을-살펴보자">DAY 07 | Dart 언어의 객체 지향 개념들을 살펴보자</h1>
<h2 id="객체-지향-관점에서의-class">객체 지향 관점에서의 class</h2>
<p>객체 지향의 4대 요소가 Dart에서 어떻게 구현되어 있는지 알아보겠다.</p>
<blockquote>
<h4 id="객체-지향의-4대-요소">[객체 지향의 4대 요소]</h4>
</blockquote>
<ul>
<li>Encapsulation</li>
<li>Inheritance</li>
<li>Polymorphism</li>
<li>Abstraction</li>
</ul>
<p>추가로 SOLID라는 원칙이 있다고 한다. 객체지향 수업을 들으면 배운다는데, 나는 이번에 처음 들었다. 그러고 보니 나는 객체지향개발방법론 전공선택 과목을 수강하지 않았다. 내가 수강한 전공선택 과목은 시스템엔지니어링이나 소프트웨어공학 쪽에 치우쳐져 있다. 응용프로그램 같은 경우엔 애초에 우리 학교가 시스템 쪽에 치중되어 있는 컴퓨터공학과와 응용프로그램 쪽에 치중되어 있는 소프트웨어공학과로 나뉘어 있었기에 컴공에서는 거의 건드릴 일도 없었고. 아무튼 이런 부분은 이제라도 살펴볼 필요가 있을 것 같다.</p>
<blockquote>
<h4 id="solid-5대-원칙-요약">[SOLID 5대 원칙 요약]</h4>
</blockquote>
<ul>
<li><strong>S (단일 책임 원칙, Single Responsibility Principle)</strong>: 하나의 클래스는 하나의 책임(바뀌는 이유도 단 하나)만 가져야 합니다.</li>
<li><strong>O (개방-폐쇄 원칙, Open-Closed Principle)</strong>: 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 합니다. 기존 코드를 수정하지 않고 기능을 추가할 수 있어야 합니다.</li>
<li><strong>L (리스코프 치환 원칙, Liskov Substitution Principle)</strong>: 자식 클래스는 언제나 부모 클래스의 역할을 대체할 수 있어야 합니다. 다형성의 성질을 올바르게 지키는 원칙입니다.</li>
<li><strong>I (인터페이스 분리 원칙, Interface Segregation Principle)</strong>: 자신이 사용하지 않는 메서드에 의존하지 않아야 합니다. 큰 인터페이스 하나보다 작고 구체적인 인터페이스 여러 개가 좋습니다.</li>
<li><strong>D (의존역전 원칙, Dependency Inversion Principle)</strong>: 구현체(변하는 것)가 아니라 추상화(인터페이스, 변하지 않는 것)에 의존해야 합니다. 고수준 모듈이 저수준 모듈을 직접 참조해서는 안 됩니다.<blockquote>
</blockquote>
<table>
<thead>
<tr>
<th align="center">원칙</th>
<th align="center">핵심 목적</th>
<th align="center">위반 시 발생하는 대표적 문제</th>
</tr>
</thead>
<tbody><tr>
<td align="center">SRP (Single Responsibility)</td>
<td align="center">코드 응집도 향상</td>
<td align="center">하나의 기능 수정 시 전혀 상관없는 코드까지 오작동함</td>
</tr>
<tr>
<td align="center">OCP (Open-Closed)</td>
<td align="center">결합도 낮추기</td>
<td align="center">새로운 기능을 추가할 때마다 기존 소스코드를 모두 고쳐야 함</td>
</tr>
<tr>
<td align="center">LSP (Liskov Substitution)</td>
<td align="center">다형성의 안정성 보장</td>
<td align="center">부모 인스턴스 대신 자식 인스턴스를 넣었을 때 프로그램이 터짐</td>
</tr>
<tr>
<td align="center">ISP (Interface Segregation)</td>
<td align="center">클라이언트 맞춤 설계</td>
<td align="center">불필요한 빈 메서드를 억지로 구현해야 하는 상황이 발생함</td>
</tr>
<tr>
<td align="center">DIP (Dependency Inversion)</td>
<td align="center">코드 변화에 유연한 대처</td>
<td align="center">하위 모듈(ex. 데이터베이스 종류)을 바꿀 때 상위 로직을 뜯어고쳐야 함</td>
</tr>
</tbody></table>
</li>
</ul>
<h3 id="encapsulation">Encapsulation</h3>
<p>지난 시간에 살펴 보았던 것. class의 형태로 property와 method를 하나로 묶고 <code>_</code> 를 붙여 private으로 만들어 외부 접근을 제한하여 데이터를 보호하는 것이 Encapsulation에 해당한다. getter와 setter도 이와 관련된 개념이고. </p>
<p>Encapsulation에 대한 건 길게 말하지 않겠다.</p>
<h3 id="inheritance">Inheritance</h3>
<p>흔히 상속이라고 불리는 것. &quot;부모의 특성과 기능을 자식에게 전달해주는 것&quot;으로 정의된다.</p>
<p><code>extends</code> 키워드를 통해 상속이 이루어진다. 어떤 class를 <code>extends</code> 한 class를 또 <code>extends</code> 하고... 이런 식으로 depth를 늘려가는 것은 가능하지만 둘 이상의 class를 동시에 <code>extends</code> 하지는 못한다. 바로 위의 부모가 가진 것을 온전히 물려받아 자식에서 추가적인 구현이 이루어진다.</p>
<p><code>class A extends B</code> 일 때, <code>B</code> 는 <code>A</code> 의 부모이며 superclass라고 부른다. 반대로 <code>A</code> 는 <code>B</code> 의 subclass다. 최상위가 아니라 바로 위의 class를 superclass라고 부른다는 걸 유의하자. Dart에서는 class 자체와 바로 위의 superclass만 따진다. 어차피 <code>A</code> 의 superclass <code>B</code> 의 superclass <code>C</code> 가 있다고 할 때, <code>C</code> 에 대한 건 <code>B</code> 에 다 구현되어 있다.</p>
<p><code>B</code> 에 정의되어 있는 <code>foo</code> 라는 property가 있을 때, <code>B</code> 를 <code>extends</code> 한 <code>A</code> 에서는 그것에 <code>super.foo</code> 로 접근할 수 있다. <code>bar()</code> 라는 method에 대해서도 마찬가지로 <code>super.bar()</code> 할 수 있다.</p>
<p><code>B</code> 의 constructor가 <code>B({required this.foo})</code> 로 property의 초기화를 요구한다면 이를 <code>extends</code> 한 <code>A</code> 에서는 <code>A({required super.foo})</code> 와 같은 방식으로 superclass의 property를 초기화하는 constructor를 작성해야 한다.</p>
<h3 id="polymorphism">Polymorphism</h3>
<p>흔히 다형성이라고 불리는 것. overloading과 overriding이 있는데 Dart에서는 overriding만 사용한다. <code>@override</code> 키워드를 사용하여 superclass에 정의된 method를 subclass에서 더 구체적인 방식으로 재정의할 수 있다.</p>
<p>이 때, superclass에 정의된 내용을 완전히 대체하는 게 아니라 supclass의 기능을 그대로 사용하며 subclass만의 기능을 추가하는 게 좋다. <code>B</code> 에 정의되어 있는 <code>bar()</code> 라는 method를 overriding 할 때, 보통 다음의 세 가지 방식 중 하나를 사용하는 게 일반적이다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">class A extends B {
  @override
  void bar() {
    super.bar(); // 부모의 기능을 먼저 수행하고
    TASK;         // 독자적인 기능을 수행한다.
  }
}</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-dart">class A extends B {
  @override
  void bar() {
    TASK;         // 독자적인 기능을 먼저 수행하고
    super.bar(); // 부모의 기능을 수행한다.
  }
}</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-dart">class A extends B {
  @override
  void bar() {
    TASK1;         // 독자적인 기능을 먼저 수행하다
    super.bar(); // 부모의 기능을 수행한 후
    TASK2;         // 독자적인 기능을 마저 수행한다.
  }
}</code></pre>
<p>물론 경우에 따라 overriding된 <code>bar()</code> 에서 <code>super.bar()</code> 를 호출하지 않고 독자적인 기능만 사용할 수도 있긴 하다.</p>
<h3 id="abstraction">Abstraction</h3>
<p>흔히 추상화라고 불리는 것. Dart에서는 <code>abstract</code> 키워드를 class에 붙여 사용하는 게 대표적이다. abstract class는 추상화를 목적으로 한 class로, abstract member를 포함한다. 직접 instance를 생성할 수 없으며, 이를 <code>extends</code> 하고 abstract member를 overriding 하여 concrete class를 작성해야 비로소 instance를 생성할 수 있는 형태가 된다.</p>
<blockquote>
<h4 id="abstract-members">[abstract members]</h4>
</blockquote>
<ul>
<li>body 부분을 정의하지 않고 선언만 한 abstract method</li>
<li>body 부분을 정의하지 않고 선언만 한 getter/setter</li>
<li>abstract class 내부의 초기화되지 않은 property → 암묵적으로 추상 getter/setter로 취급</li>
</ul>
<p>abstract class는 class가 구현해야 할 공통된 contract를 정의하는 interface 역할을 한다. (<code>interface</code> 키워드를 사용하는 class가 Dart 3.0에서 추가되었는데 약간 개념이 다르니 유의하자.)</p>
<p>이 class를 상속(<code>extends</code>)하여 기능을 확장할 수도 있고, 완전히 껍데기만 빌려와 새롭게 구현(<code>implements</code>)할 수도 있다. <code>extends</code> 는 superclass에 구현되어 있는 property와 method를 가져다 쓰며 subclass만의 기능을 추가하는 반면, <code>implements</code> 는 interface에 구현되어 있는 property와 method의 껍데기만 사용하고 모든 method를 overriding한다. 단 하나의 class만 <code>extends</code> 할 수 있는 것과 달리 <code>implements</code> 는 여러 개를 할 수 있다.</p>
<p>interface는 구현체의 가이드 역할을 하기도 하고, 종속성을 낮추고 확장성을 확보하는 데에도 사용된다. &quot;이 interface만 충족시키면 어떤 class든 상관없다!&quot; 라는 느낌? concrete class가 아닌 interface를 사용하여 작성된 것은 그것을 <code>extends</code> 혹은 <code>implements</code> 한 class를 모두 사용할 수 있는 거다. SOLID의 Open-Closed Principle과 유관한 부분인 것 같다.</p>
<blockquote>
<h4 id="abstract-와-interface">[<code>abstract</code> 와 <code>interface</code>]</h4>
<table>
<thead>
<tr>
<th align="center"><code>abstract</code></th>
<th align="center"><code>interface</code></th>
</tr>
</thead>
<tbody><tr>
<td align="center">기능 확장 및 일부 공통 코드 재사용</td>
<td align="center">동일한 규격 정의 및 구현 강제</td>
</tr>
<tr>
<td align="center">어디에서나 <code>extends</code> 가능</td>
<td align="center">같은 파일에서만 <code>extends</code> 가능</td>
</tr>
<tr>
<td align="center">어디에서나 <code>implements</code> 가능</td>
<td align="center">어디에서나 <code>implements</code> 가능</td>
</tr>
<tr>
<td align="center">instance 생성 불가</td>
<td align="center">instance 생성 가능</td>
</tr>
<tr>
<td align="center">body가 없는 abstract method는 반드시 재정의하여 사용</td>
<td align="center">방식에 따라 다름</td>
</tr>
<tr>
<td align="center">공통으로 사용될 로직이나 상태(필드 변수)가 많아서<br>자식 클래스들의 중복 코드를 줄여주고 싶을 때 주로 사용</td>
<td align="center">이 클래스를 직접 가져다 써도 좋은데,<br>만약 이 기능을 커스텀하고 싶다면 상속받아 뜯어고치지 말고<br>설계도대로 직접 새로 <code>implements</code> 해서 바꿔 끼워라</td>
</tr>
</tbody></table>
</blockquote>
<ul>
<li><code>interface</code> 를 <code>implements</code> 할 경우: 정의된 모든 method는 예시일 뿐 전부 재정의하여 사용<ul>
<li><code>abstract</code> 도 <code>implements</code> 할 경우에는 abstract/concrete 여부와 무관하게 전부 재정의 필요</li>
</ul>
</li>
<li><code>interface</code> 를 <code>extends</code> 할 경우: 정의된 것을 그대로 사용할 수도 있고 선택적으로 재정의하여 사용</li>
</ul>
<h2 id="lexical-scope와-callback-function">lexical scope와 callback function</h2>
<p>Dart에서는 선언된 위치에 따라 scope가 결정된다. function을 호출하는 위치가 어디든 그 function은 그것을 작성한 위치에 scope를 갖는다는 거다. 이런 걸 lexical scope라고 부른다. 흔히 &#39;정적 스코프&#39;라고 번역되는 모양이다.</p>
<p><code>main()</code> 밖에서 선언된 녀석들은 global scope를 갖는데, Dart에서는 일반적으로 <code>main()</code> 안에 대부분의 코드를 작성하므로 사용 빈도가 낮다. function을 비롯한 코드 블록 내부에서 선언된 녀석들은 local scope를 갖는데, 해당 코드 블록을 벗어나면 더 이상 사용할 수 없게 된다.</p>
<p>상위 scope에 선언된 값에 접근할 수 있지만 그러한 코드는 의도치 않은 side effect를 야기할 수도 있으니 조심해서 써야 한다. state를 상위 scope에 두지 말고 local scope를 사용하여 동일 입력에 대해 동일 출력을 하는 pure function으로 작성하는 게 좋다. 상위 scope에 있는 값을 받아다가 잘 활용하는 대표적인 방식으로는 closure가 있다. closure는 상위 임의의 scope에 선언된 값을 사용하는 게 아니라 closure를 생성하는 function에 선언된 값을 사용하기 때문에 global scope를 접근하는 등의 다른 방식보다 비교적 안전하다. closure가 참조하고 있는 값은 Garbage Collector가 처리하지 못하니 memory leak에 유의하여 사용해야 한다.</p>
<p>Dart에서는 function도 object다. <code>Function</code> 타입을 갖는다. parameter와 return type을 명시해 <code>Bool Function(int, int)</code> 와 같이 구체적으로 표현할 수도 있다. <code>Bool Function(int, int)</code> 와 같은 type에 대해 <code>typedef</code> 를 통해 별칭을 설정하면 코드 가독성을 높일 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">typedef CompareNumbers = Bool Function(int, int);</code></pre>
<p>function도 object이기에 어떤 함수에서 parameter로 받을 수 있다. parameter로 전달되어야 하는 function과 parameter와 return type만 맞으면 일반적인 function이든 annonymous function이든 뭐든 다 argument로 전달할 수 있다. 이렇게 전달되는 function을 callback function이라고 한다. callback function으로 전달되는 녀석이 parameter를 갖는다면 그것도 함께 전달하는 경우가 많다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">void checkCondition(CompareNumbers condition, int numberA, int numberB) {
  print(condition(numberA, numberB));
}
&gt;
checkCondition(isCoprime, 13, 7);</code></pre>
<h2 id="실습도-하고-취업-준비도-하고">실습도 하고 취업 준비도 하고</h2>
<p>주말에는 이력서 초안과 포트폴리오 초안 작성을 시작했다. 자기소개서는 일단 파편화된 조각들만 조금 만들어졌다. 그래도 봄에 노원청년카페 3월 진로테라피 참여할 때보다는 훨씬... 뭐라도 진행되는 느낌이다. 보완해야 할 부분이야 많겠지만. 일단은 Flutter 앱 개발 기획이 좀 더 우선순위 위로 올라오고 있다. 그렇다고 이력서/자기소개서/포트폴리오를 마냥 미룰 수도 없다. 바쁘다 바빠.</p>
<blockquote>
<h4 id="현재-1순위-아이디어에-대한-gemini와의-후속-대화">[현재 1순위 아이디어에 대한 Gemini와의 후속 대화]</h4>
</blockquote>
<p><img src="https://velog.velcdn.com/images/peeeeeter_j/post/5c934f0e-e039-48e1-a2d0-12530a200618/image.png" alt=""></p>
<blockquote>
<p>Gemini야 재밌는 소리 하네? <code>flutter_rust_bridge</code> FFI라고???
<del>어 근데 이거 하면 Rust 지식을 그냥 주장하는 게 아니라 포트폴리오에 담을 수 있는 거잖아...? 재밌겠ㄷ...?</del>
<del>FastAPI에 Py03/Maturin 붙이거나 gRPC 쓸 때부터 드러나 있던 사실이지만 나 좀 이런 거 좋아한다.</del></p>
</blockquote>
<p>ReceiptMemo... 기각. WeMeet... 기각. AppMon... 기각. Drag2Time... 아니 이건 기능이 약간 추가되긴 했지만 바이브코딩 실습했던 Drag2Plan 재탕이잖아. 기각. 그러고 있다. Gemini 녀석 이름은 꽤나 그럴듯하게 짓는군. 하여간 계속 고민만 하는 것보다는 중간에 더 좋은 아이디어가 떠오르면 갈아치우더라도 뭐라도 시작해 보는 게 좋지 않을까 싶기도 하더라. 일단 부딛혀 보는 게 생각만 하는 것보다 의미있다고 생각한다. 무지성으로 저지르는 게 아니라 고민하며 해나가는 거라면 말이다.</p>
<p>일단 오늘은 내가 객체지향개발방법론을 안 배웠기 때문에 공부해야 할 영역이 꽤 있었다. 아래는 진행한 실습의 일부다.</p>
<blockquote>
<p><code>lib/classes_intermediate/practice09.dart</code></p>
</blockquote>
<pre><code class="language-dart">class Employee {
  final String name;
  final double baseSalary;
&gt;
  Employee(this.name, this.baseSalary);
&gt;
  double getSalary() =&gt; baseSalary;
}
&gt;
class Manager extends Employee {
  int teamSize;
&gt;
  Manager(super.name, super.baseSalary, this.teamSize);
&gt;
  @override
  double getSalary() =&gt; baseSalary + 100 * teamSize;
}
&gt;
class Director extends Manager {
  double stockOption;
&gt;
  Director(super.name, super.baseSalary, super.teamSize, this.stockOption);
&gt;
  @override
  double getSalary() =&gt; super.getSalary() + stockOption;
}
&gt;
void answer() {
  final e = Employee(&#39;철수&#39;, 3000);
  final m = Manager(&#39;영희&#39;, 4000, 5);
  final d = Director(&#39;길동&#39;, 5000, 10, 2000);
&gt;
  print(e.getSalary());
  print(m.getSalary());
  print(d.getSalary());
}</code></pre>
<blockquote>
<p><code>lib/classes_intermediate/practice10.dart</code></p>
</blockquote>
<pre><code class="language-dart">class Notifier {
  void notify(String msg) =&gt; print(&#39;Sending: $msg&#39;);
}
&gt;
class SlackNotifier extends Notifier {
  String channel;
&gt;
  SlackNotifier(this.channel);
&gt;
  @override
  void notify(String msg) =&gt; print(&#39;Slack[$channel]: $msg&#39;);
}
&gt;
class BufferedNotifier extends Notifier {
  final List&lt;String&gt; _msgList = [];
&gt;
  @override
  void notify(String msg) =&gt; _msgList.add(msg);
&gt;
  void flush() {
    for (String msg in _msgList) {
      print(msg);
    }
&gt;
    _msgList.clear();
  }
}
&gt;
void answer() {
  final s = SlackNotifier(&#39;general&#39;);
  s.notify(&#39;Hello!&#39;);
&gt;
  final b = BufferedNotifier();
  b.notify(&#39;msg1&#39;);
  b.notify(&#39;msg2&#39;);
  b.flush();
  print(&#39;---flushed---&#39;);
  b.notify(&#39;msg3&#39;);
  b.notify(&#39;msg4&#39;);
  b.flush();
}</code></pre>
<blockquote>
<p><code>bin/dart_practice.dart</code></p>
</blockquote>
<pre><code class="language-dart">import &#39;package:dart_practice/classes_intermediate/practice09.dart&#39; as classes_intermediate_practice09;
import &#39;package:dart_practice/classes_intermediate/practice10.dart&#39; as classes_intermediate_practice10;
&gt;
void main(List&lt;String&gt; arguments) {
  print(&#39;[&gt;&gt;&gt; practice 09]&#39;);
  classes_intermediate_practice09.answer();
  print(&#39;[&gt;&gt;&gt; practice 10]&#39;);
  classes_intermediate_practice10.answer();
}</code></pre>
<blockquote>
</blockquote>
<pre><code>[&gt;&gt;&gt; practice 09]
3000.0
4500.0
8000.0
[&gt;&gt;&gt; practice 10]
Slack[general]: Hello!
msg1
msg2
---flushed---
msg3
msg4</code></pre><p><code>extends</code> 라던가 <code>@override</code> 같은 걸 사용하는 건 문제 없는데 이론적인 background가 부족한 것 같다. 당장 개발 실력 향상에 직접적인 도움이 되는 건 아니지만 알아두면 유의미한 기반 지식이 되는 것들. 수업이랑 별개로 학습해 볼 필요가 있겠다. Rust / FastAPI(Python) / Svelte(TypeScript) 에서 알 수 있듯이 컴퓨터공학을 전공하는 동안 주류 개발보다는 내가 생각하는 이상적인 것들을 주로 건드려 왔으니 이제 주류 개발을 따라잡아야지.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 06 | Dart 언어의 asynchronous task를 살펴보자]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-06</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-06</guid>
            <pubDate>Fri, 10 Jul 2026 09:43:50 GMT</pubDate>
            <description><![CDATA[<h1 id="day-06--dart-언어의-asynchronous-task를-살펴보자">DAY 06 | Dart 언어의 asynchronous task를 살펴보자</h1>
<h2 id="코딩하고는-무관합니다만">코딩하고는 무관합니다만,</h2>
<p>오전에는 class의 constructor에 대한 걸 다시 짚고 넘어가다가, 코딩과는 살짝 무관한, 하지만 SeSAC과 유관한 이야기를 했다. 아무튼 우리는 취업(혹은 창업)을 해야 하니, 어떻게 준비할 것인가. 이력서 포맷은 크게 중요한 건 아니니까 일단 준비해 보고 너무 아니다 싶으면 적당한 포맷을 추천해 주신다고 하시길래 처음부터 적당한 포맷을 추천해 주실 수 있는지 여쭤봤다. 기존에 만들어서 개선해 온 게 있는 사람은 그걸 쓰면 되지만, 이제 막 시작하는 사람 입장에서는 엄한 것보다는 적당한 것을 쓰는 게 나을 테니까.</p>
<p>사진이나 포맷 같은 건 엄청 중요한 건 아니지만 우리는 0.1점의 차이도 무시하지 않겠다는 태도로 준비해야 한다고. 스펙이 그걸 압도할 수준이 되면 괜찮지만 우리는 집요하게 다듬어야 한다고 하시는데 무슨 말인지는 알 것 같다.</p>
<p>아무튼 틈틈이 이력서/포트폴리오/자기소개서를 준비해 보자. Flutter랑은 무관하지만 요즘 한창 준비 중인 니트컴퍼니 전시의 방명록 시각화 웹 페이지도 포트폴리오에 넣고 해야지. <span style="color:#787878"><em>(전시장 입구에서 사용자가 <code>/register</code> 등록 화면에 접속하여 등록하면 전시장 내부에 있는 <code>/</code> 전광판에 그 사람의 정보가 뜨고, 비밀번호 입력해서 들어가는 <code>/admin</code> 대시보드에서 시간대별 방문자 수를 확인할 수 있는 바이브코딩 프로젝트다. 어제 오후에 스탭 회의에서 말 나와서 어제 밤에 만들고 오늘 아침에 배포해 봤다. 그리고 오늘 점심에 스탭 채팅방에 피드백 달라고 링크 공유했는데 아직 보완 의견은 없다.)</em></span></p>
<p>분명 &quot;어떤 앱 만들고 싶은지 주말동안 생각해 오자&quot; 한지 얼마 안 된 것 같은데 벌써 또 &quot;자기소개서에 어필하고 싶은 장점 다섯 가지 주말동안... 벌써 주말이야?????&quot; 하고 있다.</p>
<h2 id="class를-마저-살펴보고-넘어간다">class를 마저 살펴보고 넘어간다</h2>
<p>지난 시간에는 constructor만 살펴 보았다. property에 대해 좀 더 알아보자.</p>
<p>모든 property는 기본적으로 private으로 작성하는 게 객체지향의 원칙이다. encapsulation 관점에서 그게 맞다. 하지만 Dart는 public을 기본으로 한다. 모든 property에 getter와 setter를 설정할 거면 그냥 public으로 하는 게 낫다는 입장이다.</p>
<blockquote>
</blockquote>
<p>다음과 같은 property에 대해 getter/setter를 사용하면</p>
<pre><code class="language-dart">class User {
  // 다른 property 생략
  int _age;
  // constructor 및 method도 생략
}</code></pre>
<p>getter로는 이렇게 그냥 가져가면서</p>
<pre><code class="language-dart">int get age =&gt; _age;</code></pre>
<p>setter로 값을 설정할 땐 유효성 검사를 할 수 있다.</p>
<pre><code class="language-dart">set age(int age) {
    (age &gt; 0) ? _age = age : print(&#39;Invalid age&#39;);
}</code></pre>
<p>Dart의 getter/setter는 method가 아닌 property다. 다음과 같이 property 접근하듯 사용한다. 정의할 땐 method와 유사하게 생겼지만 사용하는 입장에서 property처럼 사용하기에 property로 취급한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">User anon = User();
user.age = 30;
print(user.age);</code></pre>
<h2 id="터지지-않게-error-handling">터지지 않게 error handling</h2>
<p>코드가 실행되다가 error 상황을 마주치면 crash되며 중단된다. 이를 막을 수 있는 가장 일반적인 방법은 error handling이다. error 상황을 마주치면 적절한 조치를 취하는 거다.</p>
<p>성공/실패 경우를 모두 처리하지 않으면 컴파일 자체가 되지 않아, error 처리를 누락하는 실수를 원천 차단하는 Rust에 익숙한 사람은 잘 마주치지 않는 로직이다. 복구 가능한 error는 <code>Result&lt;T, E&gt;</code> 로 작성하고 그렇지 않은 error는 <code>panic!</code> 으로 작성하여 error를 처리하면 흐름 제어를 보다 명확하게 할 수 있다. 하지만 Dart를 비롯한 대부분의 언어들은 <code>try</code>-<code>catch</code> 를 통해 error를 처리한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">try {
  // 작업을 시도한다
  TASK1;
  TASK2;
  TASK_WITH_ERROR; // 여기서 error 발생!
  TASK3; // 이건 실행되지 않는다
} on FormatException {
  // 숫자 써야 하는데 문자 쓴다거나 할 때 발생한 error 처리
} on IntegerDivisonByZeroException {
  // 0으로 나누는 나눗셈 시도 시 발생한 error 처리
} catch (e) {
  // 앞에서 errorType으로 필터링되지 않은 error 처리
} finally {
  // error 발생 여부와 별개로 항상 실행되는 영역
}</code></pre>
<p><code>on</code> 뒤에 올 수 있는 error type은 다양한데 모든 걸 외우고 있진 않아도 된다. 필요할 때 &quot;이런 error에 대한 error type은 뭐라고 부르지?&quot; 하고 찾아가며 작성해도 충분하다.</p>
<p>내부적으로 error 발생이 구현되어 있는 코드에 대해서는 위와 같이 구현하면 되고, 그 외 특정 상황에서 error handling을 하고자 한다면 <code>throw Exception(&#39;임의의 error message&#39;);</code> 하여 적절한 error message를 띄울 수 있다. <code>Excpetion</code> class를 상속받아 <code>catch (e) {}</code> 에서 이에 대한 처리를 구현할 수도 있다.</p>
<h2 id="순차적으로-실행되지-않는-영역">순차적으로 실행되지 않는 영역</h2>
<p>그냥 작성한 코드는 synchronous하게 실행된다. 순차적으로 실행된다는 것이다. 하지만 오래 걸리는 작업을 마냥 기다리는 건 비효율적인 일이다. 때로는 asynchronous하게 실행하여 병렬적인 작업을 수행할 필요가 있다.</p>
<blockquote>
<h4 id="병렬-비동기">병렬? 비동기?</h4>
<p>순차적으로 실행하지 않는 것은 concurrent와 parallel이 있다.
concurrent는 한 녀석이 여러 작업을 번갈아가며 수행하는 것이고 parallel은 여러 녀석이 각자의 작업을 수행하여 전체로 봤을 때 여러 작업이 동시에 실행되는 것이다.
concurrent하면서도 parallel한 경우에는 여러 녀석이 여러 녀석을 번갈아가며 수행하는 것이다.</p>
</blockquote>
<p>Dart에서는 function에 <code>async</code> 키워드를 붙여 asynchronous function으로 만들 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">Future&lt;TYPE&gt; FUNCTION_NAME(TYPE PARAMETER) async {
  TASK;
}</code></pre>
<p>asynchronous와 syncrhonous가 섞여 있다면 synchronous가 먼저 실행된다고 볼 수 있다. 모든 명령은 최종적으로 하나의 thread로 이루어진 main isolate에서 실행되는데, synchronous는 바로 main isolate로 들어가지만 asynchronous는 event loop의 스케줄링을 거쳐 main isolate에 들어가기 때문이다.</p>
<blockquote>
<p><a href="https://dart.dev/language/concurrency">&gt;&gt;&gt; event loop에 대해 더 알고 싶다면</a></p>
</blockquote>
<p>asynchronous function의 return type은 <code>Future&lt;Generic&gt;</code> 이다. return 값이 없어도 <code>void</code> 보다는 <code>Future&lt;void&gt;</code> 로 작성하는 게 좋다. 오류가 나지는 않아도 코드의 명확성과 유지보수성 관점에서 중요하다.</p>
<p>Dart에서 asyncrhonous를 담는 type은 <code>Future</code> 와 <code>Stream</code> 이 있는데, <code>Future</code> 는 한 번의 asynchronous 요청에 대한 응답의 느낌이고, <code>Stream</code> 은 터널을 열어놓고 데이터를 계속 asynchronous로 주고받을 수 있는 느낌이다. <code>Stream</code> 은 <code>async*</code> 키워드와 <code>yield</code> 키워드를 통해 사용할 수 있는데 여기서는 길게 얘기하지 않겠다. 나중에 Flutter에서 다룰 때 좀 더 자세히 살펴보자.</p>
<p><code>Future</code> 는 세 가지 상태를 갖는다. 처음에는 Uncompleted 상태로 존재하다가 asynchronous 처리가 완료되면 Completed 상태로 변경된다. Completed 상태는 Completed with a value 상태와 Completed with an error 상태로 나뉜다.</p>
<blockquote>
</blockquote>
<ul>
<li>Uncompleted <span style="color:#787878"><em>(아직 asynchronous 작업이 끝나지 않음)</em></span></li>
<li>Completed<ul>
<li>Completed with a value <span style="color:#787878"><em>(asynchronous 작업이 정상 종료됨)</em></span></li>
<li>Completed with an error <span style="color:#787878"><em>(asynchronous 작업 중 오류 발생)</em></span></li>
</ul>
</li>
</ul>
<p>Completed 상태의 <code>Future</code> 는 asynchronous 작업의 결과값 또는 error 데이터를 가지고 있다.</p>
<p>Completed with a value 상태의 <code>Future</code> instance는 <code>then()</code> method를 통해 사용할 수 있다. 중간에 error가 발생하여 Completed with an error 상태로 전달된 <code>Future</code> instance는 <code>then()</code> method를 무시하고, <code>catchError()</code> 를 통해 처리할 수 있다. 반대로 Completed with a value 상태로 전달되어 <code>then()</code> method가 실행된 경우 <code>catchError()</code> method는 무시된다.</p>
<p>error handling에서 error 발생 여부와 상관 없이 <code>finally</code> 코드 블록이 실행되듯이, Completed 상태의 <code>Future</code> 는 error 발생 여부와 상관 없이 <code>whenCompleted()</code> method를 사용하여 공통 작업을 수행할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">futureInstance
  .then((value) {
    TASK;
  })
  .catchError((error) {
    ERROR_HANDLING;
  })
  .whenCompleted(() {
    DO_ALWAYS;
  });</code></pre>
<h2 id="실습을-하자">실습을 하자</h2>
<p>concurrency는 intermediate 수준의 내용이기도 하고, 개념은 알고 있지만 내가 많이 안 다뤄본 부분이기도 해서 실습을 진행했다. 개념을 이해하는 데 시간이 걸리는 영역이라 intermediate 수준인 거지 개념을 이해하고 있으면 코드를 작성하는 건 어렵지 않았다.</p>
<p>이력서/자기소개서/포트폴리오에 대한 가이드(정답은 아니고 제안? 참고사항?)를 받았으니 주말에 좀 준비해 봐야겠다. 내일은 청년정책박람회에서 하는 특강 두 개 선정되어서 거기 다녀올 거고 일요일쯤 여유가 되려나. 계속 미루면 어느 순간 한참 지나 있으니 이번 주말에는 조금이라도 건드려 보도록 하자!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DAY 05 | Dart 언어의 class의 constructor를 살펴보자]]></title>
            <link>https://velog.io/@peeeeeter_j/sesac-2026-flutter-05</link>
            <guid>https://velog.io/@peeeeeter_j/sesac-2026-flutter-05</guid>
            <pubDate>Wed, 08 Jul 2026 10:02:26 GMT</pubDate>
            <description><![CDATA[<h1 id="day-05--dart-언어의-class의-constructor를-살펴보자">DAY 05 | Dart 언어의 class의 constructor를 살펴보자</h1>
<p>수업하고는 무관하지만 무언가 발견해 버렸다. 지난 봄에 이지스퍼블리싱의 《Do it! 바이브코딩 + 안티그래비티》 라는 책의 서평단에 선정된 김에 출판사에서 하는 스터디에도 참여했는데, 참여 완주 보상으로 받을 수 있는 이지스퍼블리싱 도서 목록을 살펴보다가 작년 일이 떠올랐다.</p>
<p>작년에 <a href="https://brunch.co.kr/brunchbook/study-dbedu-dcd">서울시 기술교육원 동부캠퍼스 디지털콘텐츠디자인과</a>에 다닐 때 교재로 이지스퍼블리싱의 《Do it! HTML+CSS+자바스크립트 웹 표준의 정석》이라는 책을 받았는데, 그때도 이 책으로 진도를 나가고 각자 복습도 해야 하는 김에 (사실 나로서는 대체로 아는 내용이라 복습할 것도 별로 없었지만) 출판사 스터디도 참여하고 완주 보상을 받은 바가 있다.</p>
<p>도서 목록에서 그래도 좀 받아서 공부해 볼 만한 게 뭐가 있을까 하고 살펴보고 고른 게 그 당시 이제 막 개정판이 나왔던 《Do it! 플러터 앱 개발 &amp; 출시하기 - 개정판》이었다. 받아놓고 그걸로 공부를 하지는 않았지만. 아무튼 나한테 Flutter 3.x 버전을 다루는 책이 있었다...?</p>
<p>취향과 대중픽의 타협점이 Flutter라는 건 어쩌면 생각보다 오래 전부터 자명한 사실이었을지도 모르겠다. 잊고 지냈는데 나도 참 한결같은 녀석이다. <span style="color:#787878"><em>(일상적인 것부터 나 자신에 대한 것까지 너무 잘 잊어버리는 게 내가 가진 큰 이슈 중 하나라 앱 개발 아이디어 중에도 이를 보완할 수 있는 것을 잠깐 생각해 보기도 했는데 결국 그 맥락의 앱은 투두리스트/스케줄러 계열의 앱에서 벗어나지는 못할 것 같아 그만두었다는 건 여담.)</em></span></p>
<p>하여간 책 내용 중 [플러터 프로젝트에 AI 챗봇 추가하기] 같은 내용도 있어서 경우에 따라 개인 프로젝트에서 Gemini를 활용하게 된다면 그 내용을 참고하게 될 수도 있겠다.</p>
<h2 id="function과-class에-대하여">function과 class에 대하여</h2>
<p>function은 statement를 묶는 게 핵심이고 class는 variable을 묶는 게 핵심이다. class로 묶은 variable은 property라고 하며 property와 관련된 기능을 method라고 한다.</p>
<p>정의한 class는 instance로 생성하여 메모리에 올려야 의미 있다. class는 개발자 정의 type으로 사용할 수 있다. 이를 통해 동일한 메모리 크기를 가진, 내용물만 다른 데이터를 생성할 수 있다. </p>
<blockquote>
<p>다음과 같은 class에 대하여</p>
</blockquote>
<pre><code class="language-dart">class User {
  String name;
  String id;
  String password;
  int dateOfBirth;
&gt;    
  User({
    required this.name,
    required this.id,
    required this.password,
    required this.dateOfBirth,
  });
}</code></pre>
<blockquote>
<p>다음과 같이 instance를 생성할 수 있다.</p>
</blockquote>
<pre><code class="language-dart">User daniil = User(name: &#39;Daniil A. Np.&#39;, id=&#39;edenjint3927&#39;, password=&#39;qwer1424&#39;, dateOfBirth=&#39;19990108&#39;);
User hayu = User(name: &#39;Yujin Ha&#39;, id=&#39;tlvr21&#39;, password=&#39;qwer1234&#39;, dateOfBirth=&#39;19980707&#39;);</code></pre>
<p>instance는 기본적으로 복사가 아닌 레퍼런스로 작동한다. <code>User neo-hayu = hayu;</code> 를 하면 <code>hayu</code> 와 같은 값을 가진 <code>neo-hayu</code> 가 생성되는 게 아니라 <code>hayu</code> 에 접근할 수 있는 다른 이름이 생기는 것뿐이다. 복사해서 새로 만들려면 <code>.copyWith()</code> 를 사용해야 한다.</p>
<p>찾아보니 <code>.copyWith()</code> 는  <code>??</code> 를 사용하여 값이 제공되지 않으면 기존 객체의 값을 유지하도록 설계하는 게 일반적이라는 모양이다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">class User {
  String name;
  String id;
  String password;
  int dateOfBirth;
&gt;    
  User({
    required this.name,
    required this.id,
    required this.password,
    required this.dateOfBirth,
  });
&gt; 
  User copyWith({String? name, String? id, String? password, int? dateOfBirth}) {
    return User(
      name: name ?? this.name,
      id: id ?? this.id,
      password: password ?? this.password,
      dateOfBirth: dateOfBirth ?? this.dateOfBirth,
    );
  }
}</code></pre>
<h2 id="instance-내부에-접근할-땐--operator">instance 내부에 접근할 땐 . operator</h2>
<p>[. operator] &#39;닷 오퍼레이터&#39;라고 읽는다. &#39;도트 연산자&#39;라고도 부르는 모양이지만 그냥 [dot operator]라고 하겠다. 이것은 instance의 정보에 접근할 때 사용한다. property에 접근할 때에도 사용하고 method에 접근할 때도 사용한다.</p>
<p>다음과 같이 사용할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">print(daniil.id);
User neo-hayu = hayu.copyWith(id: &#39;tlvr210&#39;, password: &#39;asdf1234&#39;);</code></pre>
<p>물론 <code>daniil.id</code> 와 같이 instance의 property에 직접 접근하는 건 encapsulation 관점에서 좋지 않다. property는 <code>_</code> 를 붙여 private으로 두고 method를 통해서만 읽고 쓸 수 있게 하는 게 좋지만 여기서는 그냥 넘어간다.</p>
<h2 id="instance를-생성할-땐-constructor">instance를 생성할 땐 constructor</h2>
<p>property를 초기화하고 instance를 생성하는 역할을 하는 녀석. 따로 정의하지 않으면 내부적으로 숨겨진 constructor를 사용하게 되는데, 그것을 default constructor라고 부른다. <strong>nullable이 아니면서 default 값도 지정되지 않은 property가 하나라도 있으면 default constructor를 사용할 수 없고 반드시 constructor를 작성</strong>해야 한다.</p>
<h3 id="generative-constructor">generative constructor</h3>
<p>개발자가 정의하는 가장 기본적인 형태의 constructor는 <strong>generative constructor</strong>다. 앞서 사용한 constructor도 generative constructor에 해당한다. instance를 생성하는 시점에 값을 주입(injection)하는 것이다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">User({
  required this.name,
  required this.id,
  required this.password,
  required this.dateOfBirth,
});</code></pre>
<p>여기서 <code>this</code> 는 생성되는 instance를 의미한다. <strong>class가 아닌 instance!</strong> constructor를 통해 instance를 생성할 때 <code>name</code> 에 대입한 값이 <code>this.name = name;</code> 처럼 들어간다.</p>
<p>initializer list를 사용하여 다음과 같이 작성한 것과 동일하게 작동한다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">User({
  required String name,
  required String id,
  required String pw,
  required int date,
}) : name = name,
    id = id,
    password = pw,
    dateOfBirth = date;</code></pre>
<p>주입된 값을 그대로 사용하지 않고 변형해서 사용할 필요가 있을 경우, 연산을 포함한 constructor body를 작성할 수도 있다. <code>this</code> 와 initializer list, constructor body를 모두 사용할 경우 그 순서대로 수행된다.</p>
<p>constructor body 영역은 객체가 생성된 직후에 실행되므로 default 값도 없고 nullable도 아닌 property는 반드시 <code>this</code> 혹은 initializer list에서 지정해 주어야 한다. 따라서 Dart의 constructor body는 constructor의 일부가 아니라 객체가 생성되면 수행해야 할 life cycle상의 루틴을 작성하는 용도로 사용된다.</p>
<p>instance에 값을 대입할 때, 처음부터 그 값을 가지고 있어야 하는 것은 나중에 값을 설정하는 것보다 constructor로 생성할 때부터 그 값을 주입하여 생성하는 게 좋다. 가독성 측면에서도 나중에 설정된 값이 아니라 처음부터 그 값이었음을 명시할 수 있고, 생성과 대입 사이에 값을 먼저 사용하는 실수도 예방할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">class Point {
  double? x;
  double? y;
&gt;    
  Point({this.x, this.y});
}</code></pre>
<p>에 대하여</p>
<pre><code class="language-dart">Point point = Point(); // default constructor 사용
point.x = 3;
point.y = 4;</code></pre>
<p>라고 하기 보다는</p>
<pre><code class="language-dart">Point point = Point(x: 3, y: 4);</code></pre>
<p>하라는 것이다.</p>
<h3 id="named-constructor">named constructor</h3>
<p>특정 property를 어떤 값으로 고정한 채 생성하고자 한다면 <strong>named constructor</strong>를 사용하면 편리하다.</p>
<blockquote>
<p>x축 혹은 y축 위에 있는 점을 생성하는 constructor를 따로 만든다면</p>
</blockquote>
<pre><code class="language-dart">class Point {
  double? x;
  double? y;
&gt;    
  Point({this.x, this.y});
&gt;
  // 이렇게 initializer list를 사용할 수도 있고
  Point.onXAxis({this.x}):
    y = 0;
&gt;
  // 이렇게 constructor body를 사용할 수도 있고
  Point.onYAxis({this.y}) {
    x = 0;
  }
}</code></pre>
<h3 id="const-constructor">const constructor</h3>
<p>immutable 객체를 생성하고 싶다면 <strong>const constructor</strong> 를 사용하며, 모든 property에 <code>final</code> 을 사용한다. property를 <code>const</code> 로 하면 컴파일 시점에 정해지고 <code>final</code> 로 하면 런타임에 시점에 정해진다. const constructor는 constructor 정의 앞에 <code>const</code> 를 붙인다.</p>
<p>여기선 그냥 그런 게 있구나 하고 넘어가자.</p>
<h3 id="redirecting-constructor">redirecting constructor</h3>
<p>특정 값만 설정하고 다른 constructor에게 위임하는 <strong>redirecting constructor</strong> 라는 녀석도 있다. named constructor로 작성했던 녀석들을 다음과 같이 작성할 수 있다.</p>
<blockquote>
</blockquote>
<pre><code class="language-dart">class Point {
  double? x;
  double? y;
&gt;    
  Point({this.x, this.y});
&gt;
  // redirecting constructor는
  // constructor body를 사용하지 않고
  // initializer list를 사용한다
  Point.onXAxis({this.x}): this(x, 0);
&gt;
}</code></pre>
<h3 id="factory-constructor">factory constructor</h3>
<p><strong>factory constructor</strong> 를 사용하면 다른 instance를 return할 수 있다. singleton 패턴에서 주로 사용한다. 가령 <code>name</code> property가 특정 값을 가진 instance가 존재하지 않을 경우에만 새로 생성하고, 이미 존재할 경우에는 존재하는 instance를 반환한다거나. factory constructor는 constructor 정의 앞에 <code>factory</code> 를 붙인다.</p>
<p>constructor에 대해 더 알고 싶다면 <a href="https://dart.dev/language/constructors">공식문서</a>를 확인해 보자.</p>
<h2 id="어디까지-노출할-것인가">어디까지 노출할 것인가</h2>
<p>instance 밖에서 instance 내부의 것을 접근할 때 dot operator를 사용한다고 했다. instance 내부의 모든 것에 접근할 수 있는 건 아니고 public 으로 설정된 것만 접근할 수 있다.</p>
<p>Dart에서는 기본적으로 모든 property와 method가 public으로 설정되며, property나 method의 이름 앞에 <code>_</code> 를 붙이면 private으로 설정되어 외부에서 접근할 수 없게 된다. <strong>이 때, &#39;외부&#39;의 기준은 class가 아니라 파일임을 유의할 것.</strong></p>
<p>constructor도 <code>_</code> 를 붙여 private constructor로 만들 수 있다. private constructor는 외부에서 instance를 직접 생성하지 못하게 할 때 사용한다. factory constructor를 통해서만 instance를 생성할 수 있게 하고 싶으면 기본 생성자는 private으로 만든다.</p>
<p>다른 언어에서는 constructor에서도 private property에 접근하지 못하는 경우가 많지만, Dart에서는 constructor에 한해 외부에서 private property에 직접 접근할 수 있다.</p>
<p>Dart의 instance에서 property에 접근할 땐 <code>this</code> 를 생략할 수 있다. 생략하는 것을 권장한다. method의 parameter로 해당 property랑 같은 이름을 가진 녀석이 들어오는 경우에만 명시해 준다.</p>
<hr>
<h2 id="실습-시간">실습 시간</h2>
<p>실습은 생략이다. 실습 예제를 가볍게 훑어보며 뇌코딩 &amp; 문법 체크만 했다. 지금은 그것보다는 어떤 앱을 만들면 좋을지 생각을 해 보아야 한다. Gemini와 이것저것 논의해 보았는데 이렇다 할 만한 건 아직 못 찾았다.</p>
<p>IMU 센서 기반 뭐시깽이는 얘가 과연 얼마나 잘 인식해서 데이터를 뽑아낼 수 있을지 잘 모르겠고.... 해보면 재밌을 것 같긴 한데 flutter의 특장점 영역은 아니긴 하다. 좀 더 native의 영역. 못할 건 없긴 한데... 일단 A안으로 킵해두고 좀 더 탐색해 보기로 했다.</p>
<p>영수증 어쩌고 하는 거랑 약속 장소 어쩌고 하는 거는 내가 그런 거에 대한 니즈가 없어서 영 동기부여가 안 되는 느낌. 누군가는 쓸 수도 있겠지만 나는 필요성 자체를 못 느끼는? 누군가 만들어 놓은 게 있어도 안 쓸 것 같다.</p>
<p>방치형 소셜 아카이브도 뭔가 애매하다. 인간관계 스케줄링이니 라이프 대시보드니 게릴라 모임이니 하는 것보단 나은 것 같긴 한데.</p>
<p>나의 니즈를 충족시키는 쪽으로 가면 결국 투두리스트/스케줄러 계열인데, 이와 관련된 건 <a href="https://velog.io/@peeeeeter_j/do-it-vc-study-week4">바이브코딩 스터디</a>에서 어느 정도 충족된 영역이다. <a href="https://stashbay.vercel.app">그때 구현한 웹</a>은 실제로 사용 중이기도 하고. (<a href="https://gist.github.com/neont21/6d8407d4761bba424b6882110df461e2#file-doit-week4-gemini-md"><em>&gt;&gt;&gt; 이 프로젝트의 기반이 된 AI와의 대화</em></a>)</p>
<p>일단은 이력서와 자기소개서를 먼저 건드리는 게 나으려나. 너무 길어지면 다른 작업으로 일단 넘어가는 방식의 스케줄링으로 병렬 처리를 하는 게 그나마 뭐라도 진행이 될 것 같다.</p>
]]></description>
        </item>
    </channel>
</rss>