<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>yundal.log</title>
        <link>https://velog.io/</link>
        <description>Mobile Developer</description>
        <lastBuildDate>Thu, 30 Apr 2026 14:58:29 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>yundal.log</title>
            <url>https://velog.velcdn.com/images/yun_dal/profile/9a9d81b4-3dda-4b2f-81e7-07ab1c4748c4/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. yundal.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/yun_dal" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[rebuild 이후 UI가 그려지는 과정을 렌더링 파이프라인을 토대로 설명하시오]]></title>
            <link>https://velog.io/@yun_dal/rebuild-%EC%9D%B4%ED%9B%84-UI%EA%B0%80-%EA%B7%B8%EB%A0%A4%EC%A7%80%EB%8A%94-%EA%B3%BC%EC%A0%95%EC%9D%84-%EB%A0%8C%EB%8D%94%EB%A7%81-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8%EC%9D%84-%ED%86%A0%EB%8C%80%EB%A1%9C-%EC%84%A4%EB%AA%85%ED%95%98%EC%8B%9C%EC%98%A4</link>
            <guid>https://velog.io/@yun_dal/rebuild-%EC%9D%B4%ED%9B%84-UI%EA%B0%80-%EA%B7%B8%EB%A0%A4%EC%A7%80%EB%8A%94-%EA%B3%BC%EC%A0%95%EC%9D%84-%EB%A0%8C%EB%8D%94%EB%A7%81-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8%EC%9D%84-%ED%86%A0%EB%8C%80%EB%A1%9C-%EC%84%A4%EB%AA%85%ED%95%98%EC%8B%9C%EC%98%A4</guid>
            <pubDate>Thu, 30 Apr 2026 14:58:29 GMT</pubDate>
            <description><![CDATA[<p>Q. rebuild 이후 UI가 그려지는 과정을 렌더링 파이프라인을 토대로 설명하시오.
저는 아래와 같이 답변했습니다.</p>
<blockquote>
<p>A: 상태가 변경되어 rebuild가 실행될 때, 위젯 트리가 다시 빌드되면서 변경된 UI가 화면에 갱신됩니다. 이 과정에서 build → layout → paint → composition → rasterize이라는 렌더링 파이프라인 과정을 거치게 됩니다. build에서는 runtimeType과 key의 값이 old widget과 new widget의 차이를 비교하면서 변경이 된다면 element 자체를 변경합니다. layout에서는 constraints와 size의 변경 사항이 있으면 변경됩니다. paint에서는 canvas를 통해 픽셀을 어떻게 그릴것인지 엔진에게 명령할 내용을 작성 후, composition &amp; rasterize 단계로 전달되어 엔진을 통해 실제 픽셀로 화면에 보여집니다.</p>
</blockquote>
<p>사실 답변이 핵심을 크게 벗어나지는 않았다고 생각합니다. 다만, 동작원리에 대해 온전히 이해하고 답변했냐고 되묻는다면 그렇지 않습니다. 렌더링 파이프라인의 단계별 핵심 문장만 훑어 학습만 한 느낌이었습니다. 개발자로서 코드의 동작 원리를 안다는 것이 매우 중요한데, 나중에 제대로 알아봐야지 라는 숙제로만 남아있던 내용이었습니다. </p>
<p>특히 최근에 애니메이션과 관련된 rebuild 이슈로 인해 버벅임이 심해지는 이슈를 경험하면서, 내가 짠 코드의 정확히 어느 단계로 인해 프레임드롭 등의 문제가 생기는지도 명확히 알고싶어졌습니다. 그렇게 공식 문서와 소스 코드를 파고들며 렌더링 파이프라인을 뜯어보는 과정에서, 궁금증을 갖던 아래의 질문에 대해 해결할 수 있었습니다.</p>
<blockquote>
<p>Q1. 픽셀이 화면에 찍히기까지 렌더링 파이프라인 단계별로 어떤 과정을 수행하는가?
Q2. rebuild가 호출되면 렌더링 파이프라인 전 과정을 다시 수행하는가?</p>
</blockquote>
<p>이 글은 위와 같은 의문들을 Flutter 공식 문서와 소스 코드를 기반으로, build와 rebuild의 원리 그리고 렌더링 파이프라인에 대해 깊게 탐구하면서 하나씩 풀어나가려고 합니다.</p>
<br>

<h2 id="1-렌더링-파이프라인이란-무엇인가">1. 렌더링 파이프라인이란 무엇인가?</h2>
<p>Flutter 공식문서에서는 Rendering Pipeline에 대해 아래와 같이 설명합니다.</p>
<blockquote>
<p><a href="https://docs.flutter.dev/resources/architectural-overview#rendering-and-layout"><strong>Rendering and layout</strong></a>
This section describes the rendering pipeline, which is the series of steps that Flutter takes to convert a hierarchy of widgets into the actual pixels painted onto a screen.</p>
<p>렌더링 파이프라인이란 Flutter가 위젯 계층 구조를 화면에 실제로 그려지는 픽셀로 변환하기 위해 수행하는 일련의 단계를 말합니다.</p>
</blockquote>
<p>해당 단계는 총 5단계로 되어있습니다. 공식문서에서는 해당 이미지를 사용합니다</p>
<p><img src="https://velog.velcdn.com/images/yun_dal/post/dbde2870-7f58-497c-b678-b83ff0ca4d2b/image.png" alt=""></p>
<p><strong>Q. 왜 굳이 Build ~ Rasterize 까지의 5단계로 분리했을까?</strong></p>
<p>바로 <strong>극한의 성능 최적화</strong>를 위해서입니다. 만약 버튼의 배경색 하나를 빨간색에서 파란색으로 바꾼다고 가정해 보겠습니다. 위젯의 크기나 위치는 전혀 변하지 않았는데, 전체 트리를 다시 Build하고, Layout과 Paint를 다시 계산하고 진행한다면 엄청난 리소스 낭비가 발생할 것입니다. 변경된 만큼만 변경한다는 Flutter의 철학에 맞게 파이프라인을 독립적인 단계로 쪼개놓은 방식대로 진행하는 것입니다.</p>
<p>그렇다면 여기서 드는 의문이 있습니다. 프레임워크의 상태가 변했을 때, 정확히 어느 단계부터 파이프라인을 돌려야 하는가?
구조가 바뀌었을 때, 크기나 위치만 바뀌었을 때 그리고 색상만 바뀌었을 때 각각 markNeedBuild(), markNeedsLayout(), markNeedsPaint()를 호출합니다. 이 부분은 아래에서 좀 더 자세히 설명드리겠습니다. </p>
<br>

<h2 id="2-build단계-무엇을-그릴-것인가">2. Build단계: 무엇을 그릴 것인가</h2>
<p>흔히 화면이 갱신되는 것을 <code>rebuild</code>라고 부릅니다. Rebuild가 어떻게 동작하는지 이해하기 위해서는 최초 Build가 어떻게 동작하는지를 알아야 합니다.</p>
<h3 id="최초-build의-동작-원리">최초 Build의 동작 원리</h3>
<pre><code class="language-swift">/// StatelessWidget 소스코드
abstract class StatelessWidget extends Widget {
  const StatelessWidget({ super.key });

    // Element 생성 (StatelessElement)
  @override
  StatelessElement createElement() =&gt; StatelessElement(this);

  @protected
  Widget build(BuildContext context);
}

/// StatefulWidget 소스코드
abstract class StatefulWidget extends Widget {
  const StatefulWidget({ super.key });

    // Element 생성 (StatefulElement)
  @override
  StatefulElement createElement() =&gt; StatefulElement(this);

  // State 생성
  @protected
  @factory
  State createState();
}

/// RenderObjectWidget 소스코드
abstract class RenderObjectWidget extends Widget {
  const RenderObjectWidget({ super.key });

    // Element 생성 (RenderObjectElement)
  @override
  @factory
  RenderObjectElement createElement();

    // RenderObject 생성
  @protected
  @factory
  RenderObject createRenderObject(BuildContext context);

    // RenderObject 업데이트
  @protected
  void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { }

  @protected
  void didUnmountRenderObject(covariant RenderObject renderObject) { }
}</code></pre>
<p>Flutter 개발자라면 화면을 구성할 때 StatelessWidget과 StatefulWidget 그리고 RenderObjectWidget을 사용합니다. 이때 모두가 알고 있듯 위젯의 진짜 본체는 <strong>Element</strong>이며, 위젯의 종류에 따라 생성되는 Element와 트리의 생김새도 완전히 달라집니다. 위젯이 화면에 그려지기 전, 프레임워크는 <code>createElement()</code>를 호출하여 각 위젯에 알맞은 Element를 메모리에 생성하면서 위젯트리와 함께 엘리먼트 트리가 함께 등록되며 Build는 역할을 다합니다. 흔히 말하는 3대 트리중 Widget Tree, Element Tree가 <code>createElement()</code> 메서드로 인해 순차적으로 만들어지는 것이죠.</p>
<p><img src="https://velog.velcdn.com/images/yun_dal/post/9f543d42-c6ae-46f5-bc9a-1b81eb86b921/image.png" alt=""></p>
<p>하지만 여기서 짚고 넘어가야 할 게 있습니다. 바로 Element의 종류인데요. Element의 종류에 따라 Render Tree 생성 가능 유무가 결정되기 때문입니다.</p>
<p>Element는 크게 ComponentElement와 RenderObjectElement로 나누어집니다. </p>
<ul>
<li><a href="https://api.flutter.dev/flutter/widgets/ComponentElement-class.html">ComponentElement</a><ul>
<li>StatelessWidget → StatelessElement</li>
<li>StatefulWidget → StatefulElement</li>
<li>(ProxyElement도 있습니다 공식문서의 Implementers 참고 바랍니다)</li>
</ul>
</li>
<li><a href="https://api.flutter.dev/flutter/widgets/RenderObjectElement-class.html">RenderObjectElement</a><ul>
<li><strong>SingleChildRenderObjectWidget →</strong> SingleChildRenderObjectElement (ex: Padding, SizedBox)</li>
<li>MultiChildRenderObjectWidget → MultiChildRenderObjectElement (ex: Flex)</li>
<li>(그밖에 여러 Implementers가 있으니 공식문서 참고 바랍니다)</li>
</ul>
</li>
</ul>
<p><code>ComponentElement</code> 계열은 <code>Widget.createRenderObject()</code> 메서드를 호출하지 않습니다. 즉, 자신만의 렌더 객체를 만들지 않습니다. 이는 위에 적힌 Widget별 소스코드를 통해서도 명확히 알 수 있습니다. RenderObjectWidget에는 createRenderObject()가 존재하고, 나머지 ComponentElement에는 메서드가 존재하지 않는다는 것을 확인할 수 있죠. 아래 ComponentElement의 공식문서를 통해서도 내용을 확인할 수 있습니다.</p>
<blockquote>
<p><a href="https://api.flutter.dev/flutter/widgets/ComponentElement-class.html"><strong>ComponentElement class</strong></a>
An <a href="https://api.flutter.dev/flutter/widgets/Element-class.html">Element</a> that composes other <a href="https://api.flutter.dev/flutter/widgets/Element-class.html">Element</a>s. Rather than creating a <a href="https://api.flutter.dev/flutter/rendering/RenderObject-class.html">RenderObject</a> directly, a <a href="https://api.flutter.dev/flutter/widgets/ComponentElement-class.html">ComponentElement</a> creates <a href="https://api.flutter.dev/flutter/rendering/RenderObject-class.html">RenderObject</a>s indirectly by creating other <a href="https://api.flutter.dev/flutter/widgets/Element-class.html">Element</a>s.</p>
<p>다른 Element들을 구성하는 Element입니다. <strong><em>직접 RenderObject를 생성하는 대신</em></strong>, 다른 Element들을 생성함으로써 간접적으로 RenderObject를 생성합니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/yun_dal/post/704f1a3a-ed48-4f32-bcf8-c347c42c0fc6/image.png" alt=""></p>
<p>결과적으로 <code>ComponentElement</code> 계열은 RenderObject를 직접 생성하지 않아 Render Tree가 존재하지 않으며, <code>RenderObjectElement</code>는 createRenderObject()를 통해 Render Tree를 생성하고 관리한다고 할 수 있습니다.</p>
<h3 id="rebuild가-호출되면-렌더링-파이프라인-전-과정을-다시-수행하는가">rebuild가 호출되면 렌더링 파이프라인 전 과정을 다시 수행하는가?</h3>
<p>트리가 유지되고 있는 상태에서 <code>setState()</code> 등으로 상태가 변해 rebuild가 발생하면 어떻게 될까요? 이때 프레임워크는 기존 트리를 없애지 않고, <code>Widget.canUpdate()</code>를 통해 기존 Element의 재사용 여부를 먼저 판단합니다.</p>
<pre><code class="language-swift">static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
      &amp;&amp; oldWidget.key == newWidget.key;
}</code></pre>
<p>새로 생성된 위젯과 기존 위젯의 <code>runtimeType</code>과 <code>key</code>가 동일하다면 canUpdate()는 true를 반환하고, 프레임워크는 기존 Element와 RenderObject를 파괴하지 않고 재사용합니다. 이후 <code>RenderObjectElement</code>는 <code>updateRenderObject()</code>를 통해 변경된 값만 RenderObject에 주입하고, Build 단계의 역할은 여기서 마무리됩니다. 반대로 runtimeType이나 key가 달라져 canUpdate()가 false를 반환하면, 기존 Element는 해제되고 새 Element와 RenderObject가 생성되어 파이프라인 전체가 재실행됩니다. 즉 rebuild는 트리를 처음부터 전부 다시 만드는 것이 아닙니다. <code>canUpdate()</code>를 통해 재사용 가능한 객체는 살려두고, 변경된 값만 흘려보내는 구조입니다.</p>
<br>

<h2 id="3-layout-단계-크기와-위치-결정">3. Layout 단계: 크기와 위치 결정</h2>
<p>Build 단계에서 RenderObjectElement는 새로운 Widget의 설정값을 기존 RenderObject에 전달합니다. 이때 변경된 값이 크기나 위치에 영향을 줄 수 있다면, RenderObject는 자신이 다시 배치되어야 한다고 프레임워크에 알립니다. 이때 호출되는 대표적인 메서드가 <code>markNeedsLayout()</code>입니다.</p>
<pre><code class="language-dart">/// RenderObject 객체의 메소드
void markNeedsLayout() {
  if (_needsLayout) {
    return;
  }

  _needsLayout = true;

  if (owner != null &amp;&amp; isRelayoutBoundary) {
    owner._nodesNeedingLayout.add(this);
    owner.requestVisualUpdate();
  } else if (parent != null) {
    markParentNeedsLayout();
  }
}</code></pre>
<p>실제 코드는 더 복잡하지만 핵심 흐름만 보면 단순합니다. 이미 layout이 필요한 상태라면 중복으로 처리하지 않고, 그렇지 않다면 <code>_needsLayout</code>을 <code>true</code>로 바꿉니다. 그리고 자신이 relayout boundary라면 <code>PipelineOwner</code>의 layout 대상 목록에 등록하고, 그렇지 않다면 부모에게 layout이 필요하다고 전파합니다. 즉, 값이 바뀌었다고 그 자리에서 바로 크기와 위치를 다시 계산하는 것이 아니라, <strong>다음 프레임에서 Layout 단계가 실행되도록 예약하는 방식</strong>입니다. Flutter의 <code>RenderObject</code> 문서에서도 layout에 영향을 주는 변화가 생기면 <code>markNeedsLayout()</code>을 호출해야 한다고 설명합니다. 이후 다음 프레임에서 <code>PipelineOwner.flushLayout()</code>이 실행되면, layout이 필요한 RenderObject들이 정리됩니다.</p>
<pre><code class="language-dart">/// PipelineOwner의 메소드
void flushLayout() {
  while (_nodesNeedingLayout.isNotEmpty) {
    final dirtyNodes = _nodesNeedingLayout;
    _nodesNeedingLayout = [];

    dirtyNodes.sort((a, b) =&gt; a.depth - b.depth);

    for (final node in dirtyNodes) {
      if (node._needsLayout) {
        node._layoutWithoutResize();
      }
    }
  }
}</code></pre>
<p>여기서 중요한 점은 dirty 상태인 RenderObject들을 깊이 기준으로 정렬한 뒤 layout을 수행한다는 점입니다. Layout은 부모가 자식에게 제약을 내려주고, 자식이 그 제약 안에서 자신의 크기를 정하는 과정이기 때문에, 기본적으로 부모의 제약이 먼저 정리되어야 합니다. Flutter 소스에서도 <code>flushLayout()</code>은 layout 정보를 갱신하는 렌더링 파이프라인의 핵심 단계이며, painting 전에 RenderObject가 최신 위치에 나타나도록 layout 정보를 정리한다고 설명합니다.</p>
<p>이때 Layout 단계를 이해하는 가장 유명한 문장이 있습니다.</p>
<blockquote>
<p><strong>Constraints go down. Sizes go up. Parent sets position.</strong>
<em>제약은 내려가고, 크기는 올라가며, 부모가 위치를 결정해야한다</em></p>
</blockquote>
<p>예를 들어 <code>Center</code> 안에 <code>SizedBox</code>가 있고, 그 안에 <code>Text</code>가 있다고 생각해 보겠습니다.</p>
<pre><code class="language-dart">Center(
  child: SizedBox(
    width: 200,
    child: Text(&#39;Hello Flutter&#39;),
  ),
)</code></pre>
<p>이 구조에서 부모는 자식에게 “너는 이 범위 안에서 크기를 정해야 한다”는 constraints를 내려줍니다. 자식은 그 constraints 안에서 자신의 size를 결정하고 다시 부모에게 알려줍니다. 그리고 최종적으로 부모는 자식의 위치를 결정합니다. 그래서 <code>Text</code>는 자신의 크기를 정할 수는 있지만, 화면의 어느 좌표에 놓일지는 <code>Center</code>, <code>SizedBox</code> 같은 부모 RenderObject의 layout 방식에 의해 결정됩니다.</p>
<p>RenderObject의 <code>layout()</code> 흐름을 보면 이 구조가 조금 더 명확해집니다.</p>
<pre><code class="language-dart">/// RenderObject 객체의 메소드
void layout(Constraints constraints, { bool parentUsesSize = false }) {
  if (!_needsLayout &amp;&amp; constraints == _constraints) {
    return;
  }

  _constraints = constraints;

  if (sizedByParent) {
    performResize();
  }

  performLayout();

  _needsLayout = false;
  markNeedsPaint();
}</code></pre>
<p>핵심은 <code>RenderObject</code>가 전달받은 constraints를 기준으로 <code>performLayout()</code>을 수행한다는 점입니다. 그리고 layout이 끝나면 <code>_needsLayout</code>은 false가 되고, 이어서 <code>markNeedsPaint()</code>가 호출됩니다. 크기와 위치가 바뀌었다면 당연히 이전에 기록해둔 그리기 결과도 더 이상 유효하지 않을 수 있기 때문입니다. 즉, Layout 단계는 Paint 단계의 전제 조건입니다. “어디에, 얼마만큼의 크기로 그릴 것인가”가 결정되어야 그다음에 “어떻게 그릴 것인가”를 기록할 수 있습니다. 결국 Layout 단계는 단순히 위젯을 배치하는 단계라기보다는, <em><strong>렌더 트리의 각 RenderObject가 부모로부터 받은 constraints를 바탕으로 자신의 size를 계산하고, 부모가 자식의 position을 확정하는 단계</strong></em> 라고 볼 수 있습니다. </p>
<br>

<h2 id="4-paint-단계-그리기-명령어를-기록"><strong>4. Paint 단계: 그리기 명령어를 기록</strong></h2>
<pre><code class="language-dart">/// RenderObject 객체의 메소드
void markNeedsPaint() {
  if (_needsPaint) {
    return;
  }

  _needsPaint = true;

  if (isRepaintBoundary &amp;&amp; _wasRepaintBoundary) {
    owner._nodesNeedingPaint.add(this);
    owner.requestVisualUpdate();
  } else if (parent != null) {
    parent.markNeedsPaint();
  }
}</code></pre>
<p>Layout 단계와 마찬가지로 Paint 단계도 값이 바뀌었다고 즉시 화면을 다시 그리지는 않습니다. <code>markNeedsPaint()</code>는 <code>_needsPaint</code> 값을 true로 변경하고, 다음 프레임에서 paint가 필요하다는 사실을 프레임워크에 알리는 역할을 합니다.</p>
<p>여기서 중요한 점은 <code>RepaintBoundary</code>입니다. 해당 RenderObject가 repaint boundary라면 자신만 paint 대상 목록에 등록될 수 있고, 그렇지 않다면 부모 방향으로 paint 필요 상태가 전파됩니다. 즉 RepaintBoundary는 불필요하게 넓은 영역이 다시 그려지는 것을 막고, repaint 범위를 분리하는 데 사용됩니다. 다음 프레임에서 <code>PipelineOwner.flushPaint()</code>가 실행되면, paint가 필요한 RenderObject들이 실제 Paint 단계로 넘어갑니다.</p>
<pre><code class="language-dart">/// PipelineOwner 객체의 메소드
void flushPaint() {
  final List&lt;RenderObject&gt; dirtyNodes = _nodesNeedingPaint;
  _nodesNeedingPaint = &lt;RenderObject&gt;[];

  dirtyNodes.sort((RenderObject a, RenderObject b) =&gt; b.depth - a.depth);

  for (final RenderObject node in dirtyNodes) {
    if (node._needsPaint) {
      PaintingContext.repaintCompositedChild(node);
    }
  }
}</code></pre>
<p><code>flushPaint()</code>는 paint가 필요한 RenderObject들을 정리하고, <code>PaintingContext</code>를 통해 다시 그리기 작업을 수행합니다. 이때 Paint 단계가 실제 픽셀을 바로 찍는 단계라고 오해하면 안 됩니다. 아래 내용처럼 Flutter 공식 문서에서 오해를 바로잡기 위해 설명을 덧붙였습니다.</p>
<blockquote>
<p><a href="https://api.flutter.dev/flutter/rendering/PaintingContext-class.html"><strong>PaintingContext class</strong></a>
Rather than holding a canvas directly, <a href="https://api.flutter.dev/flutter/rendering/RenderObject-class.html">RenderObject</a>s paint using a painting context. The painting context has a <a href="https://api.flutter.dev/flutter/dart-ui/Canvas-class.html">Canvas</a>, which receives the individual draw operations, and also has functions for painting child render objects.</p>
</blockquote>
<p>PaintingContext는 RenderObject가 직접 Canvas를 들고 그리는 방식이 아니라, painting context를 통해 그리며, 이 context 안의 Canvas가 개별 draw operation을 받는다</p>
<pre><code class="language-dart">/// RenderObject 객체의 메소드
void _paintWithContext(PaintingContext context, Offset offset) {
  if (_needsLayout) {
    return;
  }

  _needsPaint = false;
  paint(context, offset);
}</code></pre>
<p><code>_paintWithContext()</code>는 아직 layout이 필요한 상태라면 paint를 수행하지 않습니다. Paint는 Layout이 끝난 뒤, 즉 크기와 위치가 확정된 뒤에만 의미가 있기 때문입니다. 이후 <code>_needsPaint</code>를 false로 바꾸고 실제 <code>paint()</code> 메서드를 호출합니다. 예를 들어 커스텀 RenderObject나 CustomPainter 내부에서는 대략 아래와 같은 방식으로 Canvas에 그리기 명령을 전달합니다.</p>
<pre><code class="language-dart">@override
void paint(PaintingContext context, Offset offset) {
  final Canvas canvas = context.canvas;

  final Paint paint = Paint()
    ..color = color;

  canvas.drawRect(
    offset &amp; size,
    paint,
  );
}</code></pre>
<p>이 코드만 보면 <code>canvas.drawRect()</code>가 화면의 픽셀을 즉시 칠하는 것처럼 보입니다. 하지만 프레임워크의 Paint 단계에서 Canvas는 실제 디바이스 화면 그 자체라기보다, 엔진이 나중에 처리할 그리기 명령을 기록하는 통로에 가깝습니다.</p>
<pre><code class="language-dart">/// RendererBinding 객체의 메소드
ui.PictureRecorder createPictureRecorder() =&gt; ui.PictureRecorder();</code></pre>
<p>Flutter 공식 문서에서도 <code>createPictureRecorder()</code>는 <code>PaintingContext</code>가 RenderObject를 <code>Picture</code>로 paint하고, 이를 <code>PictureLayer</code>에 전달할 때 사용된다고 설명합니다. 즉 Paint 단계의 핵심은 실제 픽셀 출력이 아니라, <strong>RenderObject가 자신을 어떻게 그려야 하는지에 대한 명령을 Canvas에 기록하고, 그 결과를 PictureLayer에 담는 것</strong>입니다.</p>
<p>결국 Paint 단계는 Layout 단계에서 확정된 크기와 위치를 바탕으로, 각 RenderObject가 자신의 시각적 표현을 그리기 명령으로 기록하는 단계입니다. Layout이 “<strong>어디에, 얼마만큼의 크기로 놓을 것인가</strong>”를 결정한다면, Paint는 “<strong>그 위치에 무엇을 어떻게 그릴 것인가</strong>”를 기록하는 단계라고 볼 수 있습니다.</p>
<br>

<h2 id="5-compositing과-rasterize-layer를-scene으로-만들고-실제-픽셀로-변환">5. Compositing과 Rasterize: Layer를 Scene으로 만들고 실제 픽셀로 변환</h2>
<h3 id="compositing-layer-tree를-scene으로-조합">Compositing: Layer Tree를 Scene으로 조합</h3>
<p>Paint 단계에서 각 RenderObject는 Canvas에 그리기 명령을 기록하고, 그 결과는 <code>PictureLayer</code>와 같은 Layer에 담깁니다. 하지만 화면은 하나의 Layer만으로 구성되지 않습니다. <code>RepaintBoundary</code>, clip, opacity, transform 등 여러 이유로 화면은 여러 Layer로 나뉘어 관리될 수 있습니다. 이렇게 만들어진 Layer들은 Compositing 단계에서 하나의 Scene으로 조합됩니다. 렌더 트리의 최상단에는 <code>RenderView</code>가 있고, 최종적으로 <code>compositeFrame()</code>을 통해 Layer Tree를 Scene으로 만든 뒤 엔진에 전달합니다.</p>
<pre><code class="language-dart">/// RenderView 객체의 메소드
void compositeFrame() {
  final ui.SceneBuilder builder = ui.SceneBuilder();
  final ui.Scene scene = layer!.buildScene(builder);

  _view.render(scene);
  scene.dispose();
}</code></pre>
<p>핵심은 <code>layer!.buildScene(builder)</code>입니다. Paint 단계에서 만들어진 Layer Tree는 SceneBuilder를 통해 하나의 Scene으로 조합됩니다. 즉 Compositing은 개별 RenderObject가 “무엇을 그릴지” 기록한 결과들을 모아, 엔진이 이해할 수 있는 하나의 장면으로 만드는 단계입니다. Flutter의 <code>RenderingFlutterBinding</code> 공식 문서에서도 <code>drawFrame()</code>을 “렌더링 파이프라인을 실행하여 한 프레임을 생성하는 메서드”로 설명하고, <code>RenderingFlutterBinding</code>은 framework와 engine을 연결하는 glue 역할을 한다고 설명합니다. 이 맥락에서 Compositing은 프레임워크가 만든 Layer Tree를 엔진에 넘길 Scene으로 정리하는 마지막 프레임워크 단계라고 볼 수 있습니다.</p>
<h3 id="rasterize-scene을-실제-픽셀로-변환">Rasterize: Scene을 실제 픽셀로 변환</h3>
<p>Compositing 단계까지는 여전히 Flutter 프레임워크 영역에 가깝습니다. <code>RenderObject</code>가 그리기 명령을 기록하고, Layer Tree가 Scene으로 조합되었을 뿐입니다. 이후부터는 엔진 영역입니다. Flutter 공식 문서에서는 Flutter engine이 새로운 프레임을 그려야 할 때 composited scene을 rasterize하는 책임을 가진다고 설명합니다. 또한 엔진은 graphics, text layout, Dart runtime 등 Flutter core API의 low-level 구현을 제공한다고 설명합니다. 즉 Rasterize 단계에서는 앞에서 만들어진 Scene을 Skia 또는 Impeller 같은 그래픽 엔진이 GPU를 통해 실제 픽셀 데이터로 변환합니다. 우리가 Dart 코드에서 작성한 Widget, RenderObject, Canvas draw 명령은 이 단계에 이르러서야 디바이스 화면에 보이는 실제 픽셀로 출력됩니다.</p>
<p>정리하면 Compositing은 <strong>Layer Tree를 Scene으로 조합하는 단계</strong>이고, Rasterize는 <strong>그 Scene을 엔진이 실제 픽셀로 변환하는 단계</strong>입니다. 그래서 Paint 단계에서 <code>canvas.drawRect()</code>를 호출했다고 해서 그 순간 화면에 픽셀이 찍히는 것이 아니라, Paint → Compositing → Rasterize 단계를 거쳐 최종적으로 화면에 표시되는 것입니다.</p>
<br>

<h2 id="6-rebuild-이후-ui가-그려지는-과정을-렌더링-파이프라인을-토대로-설명하시오">6. <strong>rebuild 이후 UI가 그려지는 과정을 렌더링 파이프라인을 토대로 설명하시오.</strong></h2>
<p>이제 본문에 정리한 내용을 바탕으로 제목 질문에 답해보겠습니다.</p>
<p><strong>답변</strong></p>
<blockquote>
<p>rebuild가 발생하면 Build 단계에서 Widget.canUpdate()로 runtimeType과 key를 비교합니다. 동일하다면 기존 Element와 RenderObject를 유지한 채 변경된 설정값만 updateRenderObject()로 주입합니다. 이후 크기나 위치에 영향을 주는 변경이라면 markNeedsLayout()이 호출되어 Layout 단계로 이동합니다. </p>
</blockquote>
<p>Layout 단계에서는 부모가 자식에게 constraints를 내려주고, 자식이 size를 결정해 올려보내며, 부모가 position을 확정합니다. 시각적 표현만 바뀐 경우라면 markNeedsPaint()만 호출되어 Layout을 건너뛰고 Paint 단계부터 수행됩니다. </p>
<blockquote>
</blockquote>
<p>Paint 단계에서는 PaintingContext의 Canvas에 그리기 명령이 기록되고, 이 명령들은 PictureRecorder를 통해 PictureLayer에 담겨 Layer Tree를 구성합니다. 이때 즉시 픽셀을 출력하는 것이 아니며, RepaintBoundary가 설정된 RenderObject는 독립된 Layer를 가져 해당 영역만 선택적으로 repaint할 수 있습니다. </p>
<blockquote>
</blockquote>
<p>이후 Compositing 단계에서 Layer Tree가 SceneBuilder를 통해 하나의 Scene으로 조합되어 엔진에 전달되고, Rasterize 단계에서 Skia 또는 Impeller가 이를 실제 픽셀로 변환해 화면에 출력합니다.</p>
<blockquote>
</blockquote>
<br>

<h2 id="7-마치며">7. 마치며</h2>
<p>rebuild 남용을 넘어 극단적으로 rebuild는 안 좋은 것이라는 인식에 사로잡혀 개발하곤 했습니다. 하지만 렌더링 파이프라인과 rebuild의 동작에 대해 공부해보면서, rebuild에 대한 거부감보다는 렌더링 파이프라인의 어느 단계에서 비용이 발생하는지를 이해하고 파악하는 것이 중요함을 깨달았습니다. 이제는 “왜 이 화면이 버벅이는가?”라는 질문에 대해 단순히 감으로 추측하는 것이 아니라, 어떤 단계에서 문제인건지 감은 잡을 수 있게 되었습니다. </p>
<p>틀린 내용이 있다면 댓글로 작성해주시면 감사하겠습니다. 긴 글 읽어주셔서 감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[T.of(context) 동작 원리를 BuildContext를 토대로 서술하시오]]></title>
            <link>https://velog.io/@yun_dal/T.ofcontext-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EB%A5%BC-BuildContext%EB%A5%BC-%ED%86%A0%EB%8C%80%EB%A1%9C-%EC%84%9C%EC%88%A0%ED%95%98%EC%8B%9C%EC%98%A4-4%EC%A0%90</link>
            <guid>https://velog.io/@yun_dal/T.ofcontext-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EB%A5%BC-BuildContext%EB%A5%BC-%ED%86%A0%EB%8C%80%EB%A1%9C-%EC%84%9C%EC%88%A0%ED%95%98%EC%8B%9C%EC%98%A4-4%EC%A0%90</guid>
            <pubDate>Tue, 31 Mar 2026 14:09:27 GMT</pubDate>
            <description><![CDATA[<p>Q. T.of(context) 동작 원리를 BuildContext를 토대로 서술하시오</p>
<p>A: BuildContext의 위젯 위치 정보를 토대로 현재 위젯에서 가장 가까운 조상 위젯인 T를 찾는다</p>
<p>사실 오답은 아니지만 답변에 대해 스스로 만족스럽지 않았습니다. 학습한 내용을 암기하고 읊는 듯한 느낌을 지울 수 없었고, 그러다보니 Theme.of(context), Scaffold.of(context) 와 같은 <code>T.of(context)</code> 패턴을 직접 파고드는 계기가 되었습니다. 그 과정에서 아래와 같은 궁금증들이 생겨났고, 하나씩 해결할 수 있었습니다.</p>
<blockquote>
<p>Q1. context는 단순히 위치 정보인가 아니면 더 많은 역할을 하는가?
Q2. Flutter SDK가 of 메서드 구현을 강제하는가? 아니면 직접 구현하는 패턴인가?
Q3. Theme.of(context)와 Scaffold.of(context)는 둘 다 조상 위젯을 찾는 방식인데, 왜 내부 구현 방식이 다를까?</p>
</blockquote>
<p>이 글은 위와 같은 의문들을 Flutter 공식 문서와 소스 코드를 기반으로 하나씩 풀어나가려고 합니다.</p>
<p><strong>핵심 키워드 :</strong>  <code>BuildContext</code>, <code>Element</code>, <code>O(1) vs O(n)</code>, <code>InheritedWidget</code>, <code>HashMap</code>, </p>
<br>

<h2 id="1-buildcontext란-무엇인가">1. BuildContext란 무엇인가</h2>
<hr>
<blockquote>
<p>Q1. context는 단순히 위치 정보인가 아니면 더 많은 역할을 하는가?</p>
</blockquote>
<p>정답은 더 많은 역할을 수행한다 입니다. T.of(context)를 이해하려면 먼저 BuildContext에 대한 이해가 필요하니 잠시 설명 드리겠습니다.</p>
<p>Flutter 공식 문서는 <a href="https://api.flutter.dev/flutter/widgets/BuildContext-class.html">BuildContext</a>에 대해 아래와 같이 설명합니다.</p>
<blockquote>
<p><em>BuildContext objects are actually Element objects. The BuildContext interface is used to discourage direct manipulation of Element objects.</em></p>
<p>BuildContext는 사실 Element 객체이며, BuildContext라는 인터페이스는 Element를 직접 조작하지 못하도록 막는 역할을 한다.</p>
</blockquote>
<p>아래 소스코드를 보면 명확하게 이해가 됩니다. <code>Element</code>가 <code>BuildContext</code>를 구현(implement)하는 구조로 볼 수 있습니다.</p>
<pre><code class="language-dart">abstract class Element extends DiagnosticableTree implements BuildContext { ... }</code></pre>
<p>즉 build(BuildContext context)에서 받는 context는 실제로는 해당 위젯의 Element 객체입니다. context.mounted처럼 BuildContext를 통해 쓰는 기능들은 사실 Element의 기능을 인터페이스 너머로 사용하는 것입니다. 이 관계를 이해하면, context가 단순한 위치 정보가 아니라 <strong>엘리먼트 트리 위에서 탐색과 구독을 수행할 수 있는 객체</strong>임을 알 수 있습니다.</p>
<br>

<h2 id="2-tofcontext는-flutter의-convention">2. T.of(context)는 Flutter의 Convention</h2>
<hr>
<blockquote>
<p>Q2. Flutter SDK가 of 메서드 구현을 강제하는가? 아니면 직접 구현하는 패턴인가?</p>
</blockquote>
<p>정답은 강제하는 문법이 아닌 개발자가 직접 구현하는 패턴입니다.</p>
<p>현재까지 찾아본 내용으로는 of 메서드를 강제하는 내용의 글을 찾을 수 없었고, of 메서드가 쓰이는 대표적인 사례인 Scaffold, Navigator, Theme의 소스코드와 문서를 확인했을 때, Flutter에서 권장하는 <strong>정적 메서드 네이밍 convention</strong> 이라고 추측할 수 있는 뉘앙스만 확인을 했습니다.</p>
<blockquote>
<p><a href="https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html"><strong>InheritedWidget</strong></a>
The <code>convention</code> is to provide two static methods, <code>of</code> and <code>maybeOf</code>, on the <a href="https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html">InheritedWidget</a> which call <a href="https://api.flutter.dev/flutter/widgets/BuildContext/dependOnInheritedWidgetOfExactType.html">BuildContext.dependOnInheritedWidgetOfExactType</a>. This allows the class to define its own fallback logic in case there isn&#39;t a widget in scope.</p>
</blockquote>
<p> <strong>참고</strong>
최근 Flutter에서는 null safety를 고려해 maybeOf도 함께 제공하는 것이 권장됩니다. (of는 assert로 강제, maybeOf는 nullable 반환)</p>
<blockquote>
<p><a href="https://api.flutter.dev/flutter/material/Scaffold/of.html"><strong>Scaffold &gt; of static method</strong></a>
Finds the <a href="https://api.flutter.dev/flutter/material/ScaffoldState-class.html">ScaffoldState</a> from the <code>closest instance</code> of this class that encloses the given context.</p>
</blockquote>
<h3 id="of-메서드의-필요성">of 메서드의 필요성</h3>
<p>그렇다면 왜 of 메서드라는 convention이 필요할까요? <code>of</code> 메서드 패턴을 사용하지 않는다면 호출부 코드가 아래처럼 길어지기 때문입니다.</p>
<pre><code class="language-dart">// ❌ of 패턴 없이 직접 호출
// ColorProvider를 사용할 때마다 매번 context를 불러오는 방식
@override
Widget build(BuildContext context) {
  final color = context
      .dependOnInheritedWidgetOfExactType&lt;ColorProvider&gt;()!
      .color;

  return Container(color: color);
}

// ✅ of 패턴 적용 후
// ColorProvider에 of 메소드를 미리 선언 후 불러오는 방식
@override
Widget build(BuildContext context) {
  final color = ColorProvider.of(context).color;

  return Container(color: color);
}</code></pre>
<p>참고로 <code>ColorProvider.of(context)</code> 내부는 아래처럼 구현합니다. 핵심은 <code>static</code> 메서드로 선언한다는 점입니다.</p>
<pre><code class="language-dart">class ColorProvider extends InheritedWidget {
  final Color color;

  const ColorProvider({
    super.key,
    required this.color,
    required super.child,
  });

  // Convention: static of 메서드
  static ColorProvider of(BuildContext context) {
    final ColorProvider? result =
        context.dependOnInheritedWidgetOfExactType&lt;ColorProvider&gt;();
    assert(result != null, &#39;ColorProvider를 찾을 수 없습니다.&#39;);
    return result!;
  }

  @override
  bool updateShouldNotify(ColorProvider oldWidget) {
    return color != oldWidget.color;
  }
}</code></pre>
<p>이처럼 <code>T.of(context)</code>는 <code>context.dependOnInheritedWidgetOfExactType&lt;T&gt;()</code>처럼 장황한 내부 호출을 감추고, 짧고 명확한 인터페이스를 제공하는 패턴입니다.</p>
<br>


<h2 id="3-widget의-종류에-따라-달라지는-buildcontext의-접근-방식">3. Widget의 종류에 따라 달라지는 BuildContext의 접근 방식</h2>
<hr>
<blockquote>
<p>Q3. Theme.of(context)와 Scaffold.of(context)는 둘 다 조상 위젯을 찾는 방식인데, 왜 내부 구현 방식이 다를까?</p>
</blockquote>
<p><code>context</code>에는 조상 위젯을 찾는 메서드가 두 종류로 나뉩니다. 이 차이가 <code>T.of(context)</code> 동작 원리의 핵심입니다. </p>
<p>그리고 Theme과 Scaffold가 서로 다른 계열에 속하기 때문에 내부 구현이 달라집니다. 참고로 Theme 자체는 StatelessWidget이지만, 내부에서 반환하는 <code>_InheritedTheme</code>이 <code>InheritedWidget</code>입니다.</p>
<pre><code class="language-dart">/// theme.dart
class Theme extends StatelessWidget {
  /// Applies the given theme [data] to [child].
  const Theme({
    super.key,
    required this.data,
    required this.child,
  });

  // 기타 메서드 
  // ...

  @override
  Widget build(BuildContext context) {
    // 여기서 InheritedTheme이 InheritedWidget
    return _InheritedTheme(
      theme: this,
      child: CupertinoTheme(
        // If a CupertinoThemeData doesn&#39;t exist, we&#39;re using a
        // MaterialBasedCupertinoThemeData here instead of a CupertinoThemeData
        // because it defers some properties to the Material ThemeData.
        data: _inheritedCupertinoThemeData(context),
        child: _wrapsWidgetThemes(context, child),
      ),
    );
  }
 }</code></pre>
<h3 id="o1-탐색---inheritedwidget-계열">O(1) 탐색 - InheritedWidget 계열</h3>
<p>InheritedWidget 관련 조상을 찾을 때 사용하며, 각 Element가 내부적으로 보유한 <code>_inheritedWidgets</code> <code>HashMap</code>을 이용해 트리를 순회하지 않고 즉시 꺼내옵니다.
각 Element는 _inheritedWidgets: HashMap&lt;Type, InheritedElement&gt;를 유지합니다.
부모 Element로부터 이 맵을 복사 + 필요 시 자신을 추가하는 형태로 자식에게 전달하기 때문에, dependOnInheritedWidgetOfExactType<T>() 호출 시 트리 순회 없이 O(1)로 바로 찾아낼 수 있습니다.</p>
<blockquote>
<p>HashMap : 순서 상관없이 가장 빠르게 데이터를 찾고 싶을 때 사용하는 객체. Map을 Implementation 하고있습니다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://api.flutter.dev/flutter/widgets/BuildContext/dependOnInheritedWidgetOfExactType.html">dependOnInheritedWidgetOfExactType<T>()</a></td>
<td>조상을 찾고, 나를 구독자로 등록한다. 조상의 데이터가 바뀌면 이 위젯은 자동으로 리빌드된다. Theme.of, MediaQuery.of 등에서 사용.</td>
</tr>
<tr>
<td><a href="https://api.flutter.dev/flutter/widgets/BuildContext/getInheritedWidgetOfExactType.html">getInheritedWidgetOfExactType<T>()</a></td>
<td>조상을 찾되, 구독은 하지 않는다. 데이터를 한 번만 읽고 리빌드가 필요 없을 때 성능 최적화 용도로 사용.</td>
</tr>
<tr>
<td><a href="https://api.flutter.dev/flutter/widgets/BuildContext/getElementForInheritedWidgetOfExactType.html">getElementForInheritedWidgetOfExactType<T>()</a></td>
<td>위젯이 아닌 InheritedElement 자체를 꺼낸다. 엘리먼트 수준의 특수한 조작이 필요할 때 드물게 사용.</td>
</tr>
</tbody></table>
<h3 id="on-탐색---statefulwidget-계열">O(n) 탐색 - StatefulWidget 계열</h3>
<p>HashMap 없이 부모 포인터를 따라 Element Tree를 한 칸씩 위로 올라가며 탐색합니다. 최악의 경우 루트까지 전부 순회합니다.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://api.flutter.dev/flutter/widgets/BuildContext/findAncestorStateOfType.html">findAncestorStateOfType<T>()</a></td>
<td>조상 중 특정 State 객체를 찾는다. Scaffold.of(context), Navigator.of(context) 등에서 사용.</td>
</tr>
<tr>
<td><a href="https://api.flutter.dev/flutter/widgets/BuildContext/findAncestorWidgetOfExactType.html">findAncestorWidgetOfExactType<T>()</a></td>
<td>특정 타입의 조상 위젯을 찾는다. 구독은 발생하지 않는다.</td>
</tr>
<tr>
<td><a href="https://api.flutter.dev/flutter/widgets/BuildContext/findAncestorRenderObjectOfType.html">findAncestorRenderObjectOfType<T>()</a></td>
<td>조상의 렌더 객체를 찾는다. 부모의 레이아웃이나 페인트에 간섭해야 하는 특수한 경우에 사용.</td>
</tr>
</tbody></table>
<p>실제 Flutter 소스에서 이 차이를 확인할 수 있습니다.</p>
<pre><code class="language-dart">// Scaffold.dart — O(n), findAncestorStateOfType 사용
static ScaffoldState of(BuildContext context) {
  final ScaffoldState? result =
      context.findAncestorStateOfType&lt;ScaffoldState&gt;();
  if (result != null) {
    return result;
  }
  throw FlutterError.fromParts(&lt;DiagnosticsNode&gt;[
    ErrorSummary(
      &#39;Scaffold.of() called with a context that does not contain a Scaffold.&#39;,
    ),
  ]);
}

// Theme.dart — O(1), dependOnInheritedWidgetOfExactType 사용
static ThemeData of(BuildContext context) {
  final _InheritedTheme? inheritedTheme =
      context.dependOnInheritedWidgetOfExactType&lt;_InheritedTheme&gt;();
  // ...
  return ThemeData.localize(theme, theme.typography.geometryThemeFor(category));
}</code></pre>
<p>두 코드의 공통점은 <code>context.메서드()</code> 형태로 조상을 탐색한다는 점이고, 차이점은 내부에서 호출하는 메서드가 다르다는 것입니다. 이것이 곧 O(1)과 O(n)의 분기점입니다.</p>
<br>


<h2 id="4-tofcontext-동작-원리를-buildcontext를-토대로-서술하시오">4. T.of(context) 동작 원리를 BuildContext를 토대로 서술하시오</h2>
<hr>
<p>이제 본문에 정리한 내용을 바탕으로 제목 질문에 답해보겠습니다.</p>
<p><strong>답변</strong>
T.of(context)는 BuildContext를 시작점으로 삼아 위젯 트리에서 특정 조상을 찾는 <strong>정적 메서드 패턴</strong>입니다.</p>
<p>Theme.of 와 같은 InheritedWidget 계열에서는 dependOnInheritedWidgetOfExactType을 호출합니다. 모든 Element는 상위에서 전달받은 _inheritedWidgets라는 HashMap을 보유하고 있어, 트리 순회 없이 O(1) 방식으로 즉시 조상을 찾아 구독할 수 있습니다. 데이터 변경 시 구독자에게 리빌드 신호를 보냅니다.</p>
<p>반면 Scaffold.of 와 같은 StatefulWidget 계열에서는 findAncestorStateOfType을 호출합니다. 별도의 해시맵 없이 부모 포인터를 따라 한 칸씩 상향 탐색하는 O(n)방식을 사용하여 원하는 State 객체를 찾습니다. 최악의 경우 트리 높이만큼 순회하므로 비용이 더 들지만, 자주 호출되지 않고 메모리 오버헤드가 적다는 장점이 있습니다.</p>
<br>


<h2 id="5-마치며">5. 마치며</h2>
<hr>
<p>결국 <code>T.of(context)</code>는 단순한 API 호출이 아니라, Flutter가 위젯 트리에서 데이터를 효율적으로 공유하고 전파하기 위해 설계한 구조 위에 얹힌 패턴입니다. 공식 문서를 파고들수록 왜 이렇게 설계했는가에 대한 답이 보이는 것 같습니다. 틀린 내용이 있다면 댓글로 작성해주시면 감사하겠습니다. 글 읽어주셔서 감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 토스 스타일의 Domino Text 인터렉션 만들기]]></title>
            <link>https://velog.io/@yun_dal/Flutter-%ED%86%A0%EC%8A%A4-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Domino-Text-%EC%9D%B8%ED%84%B0%EB%A0%89%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@yun_dal/Flutter-%ED%86%A0%EC%8A%A4-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Domino-Text-%EC%9D%B8%ED%84%B0%EB%A0%89%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sun, 01 Mar 2026 14:56:53 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p>토스 앱을 사용하다 보면, 텍스트가 한 번에 나타나지 않고 글자 하나하나가 마치 도미노처럼 순차적으로 떠오르는 애니메이션을 볼 수 있습니다. 단순히 문장이 통째로 등장하는 것을 넘어, 글자 단위로 미세한 시차를 두고 나타나는 디테일한 UX에 큰 매력을 느끼게 되어 직접 구현해 보게 되었습니다.</p>
<p>이번 포스팅에서는 텍스트를 한 글자씩 분리하여 순차적으로 애니메이션을 적용하는 <code>DominoText</code> 인터렉션을 플러터에서 구현하는 과정을 공유하려 합니다. 문자열을 분리해 개별 <code>AnimationController</code>를 할당하는 방법부터, 약간의 무작위성(Jitter)을 부여해 기계적이지 않고 자연스러운 타이밍을 만드는 디테일한 구현 과정까지 설명드리겠습니다.</p>
<blockquote>
<p>💡 본 포스팅에서 소개하는 DominoText 위젯을 쉽게 적용할 수 있도록 패키지를 개발하였습니다. 향후 더 많은 멋진 인터렉션을 해당 패키지에 추가할 예정이니 많은 관심 부탁드립니다!</p>
<p>package : <code>cool_animation_flutter</code>
github : <a href="https://github.com/yundal8755/cool_animations">https://github.com/yundal8755/cool_animations</a></p>
</blockquote>
<br>

<h3 id="1-구현할-인터렉션-정의하기">1. 구현할 인터렉션 정의하기</h3>
<p>저는 이 인터렉션을 글자들이 도미노처럼 차례대로 반응한다는 특징에 착안하여 <code>DominoText</code> 위젯이라고 정의했습니다.</p>
<p>가장 큰 특징은 텍스트가 통째로 움직이는 것이 아니라, “1분 만에 대출 금리·한도 확인할 수 있다”라는 문장이 주어졌을 때 1, 분, &#39; &#39;, 만, 에 … 이런식으로 각 글자가 아주 짧은 시차를 두고 아래에서 위로 떠오르며 페이드 인 된다는 점입니다. 이를 통해 텍스트 자체에 생동감을 부여할 수 있습니다.</p>
<br>

<h3 id="2-다중-animationcontroller로-개별-제어하기">2. 다중 AnimationController로 개별 제어하기</h3>
<p>이전 포스팅이었던 <code>SlideUpFadeIn</code>에서는 하나의 위젯을 통째로 제어했기 때문에 단일 <code>AnimationController</code>로 충분했습니다. 하지만 글자마다 애니메이션의 시작 시점이 달라야 하는 도미노 텍스트의 특성상, 이번에는 텍스트 길이만큼의 다중 컨트롤러를 리스트로 관리해야 합니다.</p>
<p><strong>🤔 왜 컨트롤러를 여러 개 사용할까요?</strong></p>
<p>하나의 컨트롤러 안에서 Interval 등을 사용하여 쪼갤 수도 있지만, 텍스트의 길이가 가변적이고 긴 문장일 경우 0.0~1.0 사이의 값을 수십 개로 쪼개어 연산하는 것은 관리와 확장이 까다로울 수 있습니다. 따라서 각 글자마다 독립적인 생명주기를 가지는 <code>AnimationController</code>를 매핑해주는 직관적인 방식을 채택했습니다.</p>
<pre><code class="language-dart">void _initControllers() {
  // 줄바꿈 문자를 제외한 순수 글자 수를 계산합니다.
  final charCount = widget.text.replaceAll(&#39;\n&#39;, &#39;&#39;).length;

  for (int i = 0; i &lt; charCount; i++) {
    final controller = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    final curved = CurvedAnimation(
      parent: controller,
      curve: widget.curve,
    );

    _controllers.add(controller);
    _curvedAnimations.add(curved);
  }
}</code></pre>
<p>이렇게 하면 _controllers 리스트 안에 각 글자의 애니메이션을 책임질 컨트롤러들이 준비됩니다. 화면에 그려질 때는 Wrap 위젯 안에서 각 글자를 쪼개어 AnimatedBuilder로 감싸고, 이전 포스팅에서 다루었던 lerpDouble을 활용해 투명도와 Y축 위치를 제어해주면 됩니다.</p>
<br>

<h3 id="3-staggered-delay와-jitter로-도미노-효과-만들기">3. Staggered Delay와 Jitter로 도미노 효과 만들기</h3>
<p>컨트롤러들을 만들었다면, 이제 이 컨트롤러들을 언제 실행시킬 것인지가 이 위젯의 핵심 퀄리티를 결정합니다. 단순히 for문을 돌리며 일정한 staggerDelay만 주면 너무 기계적인 느낌이 들 수 있습니다.</p>
<p>그래서 저는 <code>jitterMs</code>라는 변수를 추가했습니다. 여기서는 각 글자가 등장하는 딜레이 시간에 약간의 랜덤한 오차를 부여하여, 훨씬 더 리듬감 있는 타이핑/도미노 효과를 만들어냅니다.</p>
<pre><code class="language-dart">Future&lt;void&gt; _startAnimation() async {
  if (widget.delay &gt; Duration.zero) {
    await Future&lt;void&gt;.delayed(widget.delay);
    if (!mounted) return;
  }

  final random = Random();

  for (int i = 0; i &lt; _controllers.length; i++) {
    if (!mounted) return;

    if (i &gt; 0) {
      // Jitter 값에 따라 딜레이 시간에 약간의 랜덤 오차를 줍니다.
      final jitter = widget.jitterMs &gt; 0
          ? random.nextInt(widget.jitterMs * 2 + 1) - widget.jitterMs
          : 0;

      final effectiveDelay = Duration(
        milliseconds: (widget.staggerDelay.inMilliseconds + jitter).clamp(0, 9999),
      );

      // 계산된 딜레이만큼 기다린 후 다음 글자 애니메이션을 시작합니다.
      await Future&lt;void&gt;.delayed(effectiveDelay);
      if (!mounted) return;
    }

    // 개별 컨트롤러 실행!
    _controllers[i].forward();
  }
}</code></pre>
<p>이 로직을 통해 글자들이 차례대로 나타나게되며, 앞서 적용한 lerpDouble 보간 함수에 의해 각 글자가 스르륵 제자리를 찾아가게 됩니다.</p>
<br>

<h3 id="마무리하며">마무리하며</h3>
<p>이번 글에서는 토스나 감각적인 UI를 가진 서비스에서 자주 활용하는 타이포그래피 도미노 애니메이션을 구현해 보았습니다.</p>
<p>단일 애니메이션 위젯에서 한 단계 더 나아가, 다중 AnimationController를 관리하고 Jitter 개념을 도입하여 애니메이션에 섬세한 리듬감을 불어넣는 방법을 알아보았습니다.</p>
<p>앱의 메인 온보딩 화면이나 특정 혜택을 강조해야 하는 타이틀에 이 <code>DominoText</code> 위젯을 적용해 보세요. 텍스트 하나만으로도 앱의 첫인상이 훨씬 고급스러워질 것입니다. 해당 기능이 포함된 <code>cool_animation_flutter</code> 패키지에도 많은 관심 부탁드립니다.</p>
<p>긴 글 읽어주셔서 감사합니다!</p>
<hr>
<p>원하시는 추가적인 인터렉션 분석이나 코딩 도움이 필요하시다면 언제든 말씀해주세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 토스 스타일의 Scale-Bounce 인터렉션 만들기]]></title>
            <link>https://velog.io/@yun_dal/Flutter-%ED%86%A0%EC%8A%A4-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Scale-Bounce-%EC%9D%B8%ED%84%B0%EB%A0%89%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0-2c69iehl</link>
            <guid>https://velog.io/@yun_dal/Flutter-%ED%86%A0%EC%8A%A4-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Scale-Bounce-%EC%9D%B8%ED%84%B0%EB%A0%89%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0-2c69iehl</guid>
            <pubDate>Sun, 08 Feb 2026 14:15:46 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yun_dal/post/99665243-9c22-4bbe-82f6-6f4ab059abd6/image.gif" alt=""></p>
<h2 id="들어가며">들어가며</h2>
<p>토스(Toss) 앱을 사용하면서, 탱탱볼처럼 살짝 커졌다가 제자리를 찾아가는 생동감있는 애니메이션을 보신 적이 있으신가요? 위에 보이는 gif는 토스 앱을 통해 혜택을 받을 수 있는 자몽다 서비스의 진입 화면인데요. 해당 애니메이션을 보고 구현을 해보고 싶다는 생각이 들어 구현해보게 되었습니다.</p>
<p>이번 포스팅에서는 토스 스타일의 Scale-Bounce 인터렉션을 플러터에서 구현하는 과정을 공유하려 합니다. 단순히 크기를 키우는 것을 넘어, TweenSequence를 활용한 정교한 바운스 효과와 Reduce Motion을 고려한 접근성 처리까지, 디테일한 구현 과정을 설명드리겠습니다.</p>
<blockquote>
<p>💡 본 포스팅에서 소개하는 ScaleBounceIn 위젯을 쉽게 적용할 수 있도록 패키지를 개발하였습니다. 향후 더 많은 멋진 인터렉션을 해당  패키지에 추가할 에정이니 많은 관심 부탁드립니다!
package : <a href="https://pub.dev/packages/cool_animation_flutter">cool_animation_flutter</a>
github : <a href="https://github.com/yundal8755/cool_animations">https://github.com/yundal8755/cool_animations</a></p>
</blockquote>
<br>

<h2 id="1-구현할-인터렉션-정의하기">1. 구현할 인터렉션 정의하기</h2>
<p>저는 해당 인터렉션을 ScaleBounceIn 위젯이라고 정의했습니다. 핵심은 <strong>관성</strong>입니다. 물체가 등장할 때 목표 크기(1.0)에서 딱 멈추는 것이 아니라, 힘에 의해 살짝 더 커졌다가(Peak Scale) 다시 원래 크기로 돌아오는 움직임이 필요합니다. 코드상으로는 initialScale(0.3)에서 시작해 peakScale(1.15)까지 커진 뒤, 최종적으로 1.0으로 수렴하는 과정을 거칩니다. 이 과정에 투명도(Opacity) 변화를 함께 주어, 마치 화면 안쪽에서 튀어나오는 듯한 입체감을 주었습니다.</p>
<br>

<h2 id="2-tweensequence로-바운스-효과-제어하기">2. TweenSequence로 바운스 효과 제어하기</h2>
<p>보통 단방향 애니메이션은 Tween 하나로 충분하지만, 커졌다가 작아지는 &quot;왕복&quot; 혹은 &quot;단계별&quot; 움직임은 TweenSequence를 사용하는 것이 훨씬 효율적입니다.</p>
<h3 id="🤔-tweensequence란">🤔 TweenSequence란?</h3>
<p>하나의 AnimationController 시간 축 안에서 여러 개의 Tween을 순차적으로 실행할 수 있게 해주는 클래스입니다. 각 단계마다 가중치를 두어 시간 배분을 정교하게 조절할 수 있습니다. 이번 구현에서는 전체 애니메이션 시간을 100으로 봤을 때, 60%의 시간 동안은 커지고, 나머지 40%의 시간 동안 제라리를 찾아가도록 설계했습니다.</p>
<pre><code class="language-dart">
_scaleAnimation = TweenSequence&lt;double&gt;([
  // 1단계: 0.3 -&gt; 1.15 (확 커지면서 등장)
  TweenSequenceItem(
    tween: Tween(begin: widget.initialScale, end: widget.peakScale)
        .chain(CurveTween(curve: Curves.easeOutCubic)),
    weight: 60,
  ),
  // 2단계: 1.15 -&gt; 1.0 (살짝 줄어들며 안착)
  TweenSequenceItem(
    tween: Tween(begin: widget.peakScale, end: 1.0)
        .chain(CurveTween(curve: Curves.easeInOut)),
    weight: 40,
  ),
]).animate(_controller);</code></pre>
<p>이렇게 TweenSequence를 활용하면 별도의 복잡한 상태 관리 없이도 쫀득한 바운스 효과를 하나의 컨트롤러로 깔끔하게 제어할 수 있습니다.</p>
<br>


<h2 id="3-스크롤을-하여-ui에-도달했을-때-보여주기">3. 스크롤을 하여 UI에 도달했을 때 보여주기</h2>
<p>해당 위젯은 사용자가 스크롤을 내려 해당 요소가 화면에 보일 때 애니메이션이 시작되어야 효과적입니다. visibility_detector를 활용해 위젯이 화면에 10%(visibilityThreshold) 이상 노출되었을 때 애니메이션을 트리거하도록 구현했습니다.</p>
<pre><code class="language-dart">
void _onVisibilityChanged(VisibilityInfo info) {
  if (_didAnimateOnce || !widget.enabled || _isReduceMotionEnabled) return;

  final isNowVisible = info.visibleFraction &gt;= widget.visibilityThreshold;

  // 화면에 등장하는 순간, 팝업!
  if (isNowVisible &amp;&amp; !_isVisible) {
    _isVisible = true;
    _scheduleAutoPlay();
  }
}</code></pre>
<br>

<h2 id="마무리하며">마무리하며</h2>
<p>이번 글에서는 토스나 최신 앱들에서 자주 보이는 Scale-Bounce 효과를 구현해 보았습니다. TweenSequence를 활용해 애니메이션의 단계를 나누고, Reduce Motion을 체크하여 접근성까지 고려한 견고한 위젯이 완성되었습니다. 강조하고 싶은 아이콘, 완료 체크 표시, 혹은 팝업 모달 등에 이 위젯을 감싸주기만 하면 앱의 생동감이 한층 살아날 것입니다. 해당 기능이 포함된 <a href="https://pub.dev/packages/cool_animation_flutter">cool_animation_flutter</a> 패키지에도 많은 관심 부탁드립니다.</p>
<p>긴 글 읽어주셔서 감사합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 토스 스타일의 Slide-Up 인터렉션 만들기]]></title>
            <link>https://velog.io/@yun_dal/Flutter-%ED%86%A0%EC%8A%A4-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Slide-Up-%EC%9D%B8%ED%84%B0%EB%A0%89%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@yun_dal/Flutter-%ED%86%A0%EC%8A%A4-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Slide-Up-%EC%9D%B8%ED%84%B0%EB%A0%89%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sun, 25 Jan 2026 14:57:19 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>토스(Toss) 앱을 사용할 때마다 앱이 참 부드럽다는 느낌을 받으신 적이 있나요?
저는 늘 적재적소에 배치된 애니메이션을 보고 자주 감탄을 하곤 합니다.</p>
<p>특히 <strong>아래에서 위로 부드럽게 떠오르는(Slide-Up)</strong> 느낌의 애니메이션에 매료되어 해당 위젯을 플러터에서도 만들어보자 라는 생각이 들었는데요, 이번 포스팅에서는 토스 스타일의 Slide-Up 인터렉션을 플러터에서 구현하는 과정을 공유하려 합니다. 단순히 움직이는 것을 넘어, lerpDouble을 활용한 수학적 보간과 VisibilityDetector를 이용한 스크롤 시점 제어까지, 디테일한 구현 과정에 대해 설명드리겠습니다.</p>
<blockquote>
<p>💡 본 포스팅에서 소개하는 SlideUpFadeIn 위젯을 쉽게 적용할 수 있도록 패키지를 개발하였습니다. 향후 더 많은 멋진 인터렉션을 해당  패키지에 추가할 에정이니 많은 관심 부탁드립니다!
package : <a href="https://pub.dev/packages/cool_animation_flutter">cool_animation_flutter</a>
github : <a href="https://github.com/yundal8755/cool_animations">https://github.com/yundal8755/cool_animations</a></p>
</blockquote>
<br>

<h2 id="1-구현할-인터렉션-정의하기">1. 구현할 인터렉션 정의하기</h2>
<p>저는 해당 인터렉션를 아래에서 위로 부드럽게 등장한다는 특징에 착안하여 SlideUpFadeIn 위젯이라고 정의했습니다. 단순히 위치만 이동하는 것이 아니라 투명도가 함께 변하는 것이 핵심입니다. GIF를 자세히 보시면 텍스트가 아주 먼 곳에서 날아오는 것이 아니라, Offset(0, 20) 정도의 아주 작은 폭으로 아래에서 위로 이동하며 동시에 불투명해지는 것을 볼 수 있습니다. 자연스럽게 콘텐츠가 등장하는 방식이죠. 이 짧은 거리의 이동과 페이드 효과가 결합되었을 때, 사용자는 정보가 갑자기 튀어나왔다가 아니라 &quot;부드럽게 건네받았다&quot;는 느낌을 받게 됩니다.</p>
<br>


<h2 id="2-lerpdouble로-제어하기">2. lerpDouble로 제어하기</h2>
<p>보통 플러터에서 애니메이션을 구현할 때 SlideTransition과 FadeTransition 위젯을 중첩해서 사용하곤 합니다. 하지만 이렇게 하면 위젯 트리가 깊어지고 코드가 복잡해질 수 있습니다.</p>
<p>저는 <strong>AnimatedBuilder</strong>와 lerpDouble 함수를 사용하여 더 깔끔하고 정교하게 제어하는 방식을 택했습니다. 여기서 lerpDouble이 무엇인지 잠깐 짚고 넘어가겠습니다.</p>
<h3 id="🤔-lerpdouble이란">🤔 lerpDouble이란?</h3>
<p>lerp는 Linear interpolation(선형 보간)의 약자입니다. 쉽게 말해, 시작점(a)과 끝점(b) 사이의 특정 지점(t)에 있는 값을 계산해 주는 함수입니다. 수식으로 표현하면 a + (b - a) * t와 같습니다. 여기서 t는 애니메이션의 진행률을 나타내며 0.0(시작)에서 1.0(끝) 사이의 값을 가집니다.</p>
<p>t = 0.0 일 때: 시작점(a) 반환
t = 0.5 일 때: 딱 중간값 반환
t = 1.0 일 때: 끝점(b) 반환</p>
<p>가장 먼저 구현해야 할 것은 위젯이 아래에서 위로 올라오면서 투명도가 0에서 1로 변하는 로직입니다. 저는 <code>AnimatedBuilder</code> 안에서 <code>lerpDouble</code>을 사용해 이 두 가지 속성을 하나의 애니메이션 값(0.0 ~ 1.0)으로 동기화하여 제어했습니다.</p>
<pre><code class="language-dart">return AnimatedBuilder(
  animation: _curvedAnimation,
  builder: (context, child) {
    final t = _curvedAnimation.value;

    // 시작 위치(beginOffset)에서 원래 위치(0)로 이동
    final dx = lerpDouble(beginOffset.dx, 0, t)!;
    final dy = lerpDouble(beginOffset.dy, 0, t)!;

    // 투명도는 0.0 -&gt; 1.0 으로 변화
    final opacity = lerpDouble(0.0, 1.0, t)!.clamp(0.0, 1.0);

    return Transform.translate(
      offset: Offset(dx, dy),
      child: Opacity(opacity: opacity, child: child),
    );
  },
  child: widget.child,
);</code></pre>
<p>이렇게 <code>lerpDouble</code>을 활용하면 애니메이션 컨트롤러가 실행될 때, 위젯이 살짝 아래(기본값 20px)에서 원래 위치로 스르륵 올라오며 선명해지는 효과를 깔끔한 코드로 구현할 수 있습니다.</p>
<br>

<h2 id="3-순차적으로-실행하기">3. 순차적으로 실행하기</h2>
<p>여러 줄의 텍스트나 리스트 아이템이 한 번에 통째로 올라오면 시각적으로 부담스럽고 오히려 산만해 보일 수 있습니다. 토스 증권 탭이나 피드를 보면 아이템들이 아주 미세한 시차를 두고 톡톡 올라오는 걸 볼 수 있는데요. 이러한 시차 애니메이션(Staggered Animation)을 구현하기 위해 <code>delay</code> 속성을 추가했습니다.</p>
<pre><code class="language-dart">Future&lt;void&gt; _scheduleAutoPlay() async {
  if (_didAnimateOnce) return;

  final delay = widget.delay;
  // 설정된 딜레이만큼 기다렸다가 애니메이션 실행
  if (delay != null &amp;&amp; delay &gt; Duration.zero) {
    await Future&lt;void&gt;.delayed(delay);
  }

  if (!mounted) return;
  play();
}</code></pre>
<p>이제 Column 안에서 SlideFadeIn 위젯들의 delay를 조금씩 다르게 주면, 물 흐르듯 순차적으로 정보가 표시되는 고급스러운 연출이 가능해집니다.</p>
<pre><code class="language-dart">Column(
  children: [
    SlideFadeIn(child: TitleText()), 
    // 100ms 뒤에 설명 등장
    SlideFadeIn(delay: Duration(milliseconds: 100), child: DescriptionText()),
  ],
)</code></pre>
<br>

<h2 id="4-스크롤을-하여-ui에-도달했을-때-보여주기">4. 스크롤을 하여 UI에 도달했을 때 보여주기</h2>
<p>앱을 켜자마자 화면 저 아래(스크롤 해야 보이는 영역)에 있는 위젯들까지 애니메이션이 끝나버린다면, 사용자가 스크롤해서 내려갔을 땐 이미 정적인 화면만 보게 되겠죠. 사용자가 보는 그 순간, 애니메이션이 시작되어야 합니다. 이를 위해 <code>visibility_detector</code> 패키지를 도입했습니다.</p>
<blockquote>
<p>visibility_detector란?
스크롤 뷰 내부에 있는 위젯이 현재 화면(Viewport)에 진입했는지, 얼마나 보이고 있는지를 감지해주는 라이브러리입니다. Flutter 프레임워크 자체적으로는 &#39;현재 이 위젯이 화면에 그려지고 있는가?&#39;를 판단하기 까다로운데, 이 패키지는 RenderObject를 통해 이를 쉽게 계산해줍니다.</p>
</blockquote>
<p>위젯이 화면에 일정 비율 이상 보일 때(<code>visibilityThreshold</code>) 애니메이션을 시작하도록 트리거를 걸어주었습니다.</p>
<pre><code class="language-dart">void _onVisibilityChanged(VisibilityInfo info) {
  // 이미 애니메이션을 했거나 비활성화 상태면 패스
  if (_didAnimateOnce || !widget.enabled || _isReduceMotionEnabled) return;

  // 설정한 임계값(예: 10%) 이상 보이면 애니메이션 시작!
  final isNowVisible = info.visibleFraction &gt;= widget.visibilityThreshold;

  if (isNowVisible &amp;&amp; !_isVisible) {
    _isVisible = true;
    _scheduleAutoPlay();
  }
}</code></pre>
<p><code>triggerOnVisible: true</code> 옵션만 켜주면, 긴 스크롤 화면에서도 사용자의 시선에 맞춰 위젯들이 살아 움직이게 됩니다.</p>
<br>

<h2 id="마무리하며">마무리하며</h2>
<p>이번 글에서는 토스 앱에서 느꼈던 부드러운 등장 효과를 <code>SlideFadeIn</code>이라는 위젯으로 직접 구현해 보았습니다.</p>
<p>처음에는 단순히 &quot;위젯이 예쁘게 떴으면 좋겠다&quot;는 생각으로 시작했지만, 스크롤 시점을 고려하고, 순차적인 등장까지 챙긴 모듈을 완성되었습니다.이제 여러분의 프로젝트에서도 복잡한 애니메이션 코드 없이, 이 위젯으로 감싸기만 하면 토스 스타일의 감성적인 UI를 손쉽게 구현하실 수 있을 겁니다. 이 기능이 포함된 <a href="https://pub.dev/packages/cool_animation_flutter">cool_animation_flutter</a> 패키지에도 많은 관심 부탁드립니다.</p>
<p>긴 글 읽어주셔서 감사합니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 백그라운드에서 BLE 비콘 통신하기]]></title>
            <link>https://velog.io/@yun_dal/Flutter-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C%EC%97%90%EC%84%9C-BLE-%EB%B9%84%EC%BD%98-%ED%86%B5%EC%8B%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yun_dal/Flutter-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C%EC%97%90%EC%84%9C-BLE-%EB%B9%84%EC%BD%98-%ED%86%B5%EC%8B%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 28 Feb 2025 08:31:07 GMT</pubDate>
            <description><![CDATA[<p>“내가 앱에 등록한 BLE(저전력 블루투스) 기기가 주변에 있는지 주기적으로 확인하는 기능을 구현하라” 라는 요구사항을 전달받았다면 어떻게 구현하시겠습니까?</p>
<p>회사에서 BLE 스캔을 통해 만들어진 앱을 백그라운드 상태에서도 구현해야하는 상황이었는데요. 기존에 작성된 코드에서는 <code>flutter_blue_plus</code>라는 BLE 스캔과 연결을 동시에 수행할 수 있는 대표적인 라이브러리를 사용중이었습니다. 백그라운드에서도 사용 가능하도록 프로젝트에서는 세팅이 되어있었지만, 이상하게도 백그라운드에서는 BLE 스캔이 동작하지 않았습니다.</p>
<h3 id="1-일반-ble-스캔flutter_blue_plus의-한계">1. 일반 BLE 스캔(flutter_blue_plus)의 한계</h3>
<p>기존에 많이 쓰이는 <code>flutter_blue_plus</code> 같은 라이브러리는 OS가 제공하는 표준 BLE 스캔 API를 그대로 호출합니다. 문제는 이 표준 BLE 스캔이 OS 차원의 절전 정책(DOZE, Background Execution Limit)이나 권한 제약에 가로막혀, <strong>백그라운드에서 안정적으로 작동하기 어렵다는 점</strong>입니다. 안드로이드 기기의 경우 Foreground Service를 사용하면 어느 정도 가능하지만, 화면을 껐을때 스캔이 멈추는 상황이 발생했습니다. 또한 iOS는 백그라운드 BLE 스캔을 더욱 제한적으로만 허용하여, 사실상 구현이 까다롭다는 한계가 있습니다.</p>
<h3 id="2-ibeacon-방식으로-해결하기">2. iBeacon 방식으로 해결하기</h3>
<p>이처럼 기본 BLE 스캔이 백그라운드에서 잘 동작하지 않는 이유는, OS가 ‘평범한 BLE 스캔’을 절전에 의해 제약하기 때문입니다. 반면 <strong>Beacon(iBeacon, Eddystone, AltBeacon 등)</strong> 방식을 쓰게 되면, 안드로이드, iOS 모두에서 “위치 서비스”로 분류되어 화면이 꺼진 상황에서도 스캔을 지속할 수 있습니다. 특히 iBeacon은 Apple이 정의한 BLE Manufacturer Data(0x004C) 규격으로, iOS에서는 이를 <code>Core Location</code> 서비스로 해석해 <strong>백그라운드에서도 지역(Region)을 모니터링</strong>해 주며, 안드로이드도 Android Beacon Library 등의 기술을 통해 유사한 형태를 지원합니다. 이 때문에 Beacon(특히 iBeacon)이 GATT 연결용 BLE 기기와 달리, 백그라운드 상태에서도 꾸준히 광고를 수신하고 진입·이탈 같은 이벤트를 파악할 수 있게 됩니다.</p>
<h3 id="3-ibeacon-스캔-예시-코드">3. iBeacon 스캔 예시 코드</h3>
<p><code>flutter_beacon</code> 패키지를 사용한 간단한 iBeacon 스캔 예시는 아래와 같습니다. 이 코드는 20초 간격으로 스캔을 시작하고, 10초 동안 Ranging을 수행한 뒤 결과를 서버에 데이터를 전송하는 구조입니다. 스캔 중에는 발견된 맥주소를 추려서 <code>scannedMacAddresses</code>에 담고, 앱 내 <code>AssetList</code>의 Asset과 대조하여 내가 등록한 에셋이 근처에서 스캔되었는가를 판단합니다.</p>
<pre><code class="language-dart">
class BLEProvider extends ChangeNotifier {
  Timer? _scanTimer;
  bool _isIBeaconScanning = false;

  // 스캔한 데이터의 맥주소 목록
  final Set&lt;String&gt; scannedMacAddresses = {};

  // BEACON - Region Monitoring 설정
  StreamSubscription&lt;RangingResult&gt;? _iBeaconRangingSub;
  final List&lt;Region&gt; mkTagRegions = [
    Region(
      identifier: &#39;식별할 ID&#39;,
      proximityUUID: &#39;식별할 UUID&#39;, // iOS는 not null이다
    ),
  ];

  /// 외부에서 사용할 메서드
  Future&lt;void&gt; beaconInitSetting() async {
    _scanTimer = Timer.periodic(
      const Duration(seconds: 20),
      (_) =&gt; startBeaconScan(),
    );
  }

  /// 20초마다 BEACON 스캔이 실행됨
  Future&lt;void&gt; startBeaconScan() async {
    if (_isIBeaconScanning) return;
    _isIBeaconScanning = true;

        // 스캔을 시작하기 전에 이전 값들을 비워놓기
    scannedMacAddresses.clear();

    try {
      await flutterBeacon.initializeScanning;
      _iBeaconRangingSub = flutterBeacon.ranging(mkTagRegions).listen((result) {
        for (final beacon in result.beacons) {
          final mac = beacon.macAddress;
          if (mac != null &amp;&amp; mac.isNotEmpty) {
            final macNoColon = mac.replaceAll(&#39;:&#39;, &#39;&#39;).toUpperCase();
            scannedMacAddresses.add(macNoColon);
          }
        }
      });
      await Future.delayed(const Duration(seconds: 10));
      await _iBeaconRangingSub?.cancel();

      final context = nav.currentState!.overlay!.context;
      final assetListProvider = Provider.of&lt;AssetListProvider&gt;(
        context,
        listen: false,
      );

      // 전역 Provider에서 불러온 에셋의 맥주소
      for (var asset in assetListProvider.allAssetList) {
        final tagId = asset.tagId.toUpperCase();
        if (scannedMacAddresses.contains(tagId)) {
          // 내가 가지고있는 에셋 목록과 일치하는 맥주소 발견!
        } else {
          // 내가 가지고있는 에셋 목록과 일치하는 맥주소 발견 못 함
        }
      }
    } catch (e, trace) {
      // 에러 처리
    } finally {
      _isIBeaconScanning = false;
    }
  }
}
</code></pre>
<p>코드에서 확인할 수 있듯이, <code>flutter_beacon</code>은 Region(Ranging)을 통해 iBeacon 데이터를 받아오고, 이를 바탕으로 원하는 로직을 수행합니다. 백그라운드 상태에서도 OS가 위치 서비스 차원에서 Beacon Ranging을 계속 시도해 주기 때문에, 기존의 표준 BLE 스캔 코드보다 훨씬 안정적으로 동작한다는 것이 특징입니다.</p>
<h3 id="4-os별-설정-및-주의사항">4. OS별 설정 및 주의사항</h3>
<p>iOS에서 이 코드를 동작시키려면 Info.plist에 위치 권한(Always)과 Background Modes(Location Updates)를 추가로 세팅해야 합니다. 안드로이드도 위치 권한(Bluetooth 스캔 권한 포함)과 포그라운드 서비스 사용, 그리고 일부 제조사(샤오미·화웨이 등)의 배터리 절전 예외 처리를 해 주어야 백그라운드 동작이 안정적입니다. 또한 iOS에서는 Beacon 프로토콜상 맥주소가 노출되지 않는 경우가 많아, MAC 기반 식별이 필요하다면 펌웨어 측에서 Manufacturer Data나 Service Data에 별도로 해당 정보를 넣는 방법을 고려해야 합니다.</p>
<h3 id="마무리">마무리</h3>
<p>백그라운드에서 저전력 블루투스 광고 데이터를 원활히 스캔하기 위해 iBeacon을 적용하는 방법에 대해 글을 작성해보았습니다. 일반 BLE 스캔 라이브러리에서는 광고를 단순히 수신하는 기능만 제공하기 때문에 OS의 절전 정책을 우회하기 어려운데요. Region Monitoring과 같은 &quot;위치 서비스&quot; 기반을 활용하여 안정적인 스캔을 할 수 있는 <code>flutter_beacon</code>이나 <code>beacons_plugin</code> 등 Beacon 전용 패키지를 사용해보시는 것을 권장합니다.</p>
<p>잘못된 내용이 있거나 추가로 궁금하신 부분은 편하게 댓글 남겨주세요! 확인 후 답변 남겨놓도록 하겠습니다.</p>
<p>글 읽어주셔서 감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 음성 SNS 앱 개발기 - 기획부터 출시까지]]></title>
            <link>https://velog.io/@yun_dal/%EC%9D%8C%EC%84%B1-SNS-%EC%95%B1-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-%EA%B8%B0%ED%9A%8D%EB%B6%80%ED%84%B0-%EC%B6%9C%EC%8B%9C%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@yun_dal/%EC%9D%8C%EC%84%B1-SNS-%EC%95%B1-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-%EA%B8%B0%ED%9A%8D%EB%B6%80%ED%84%B0-%EC%B6%9C%EC%8B%9C%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Thu, 13 Jun 2024 13:54:18 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이 글은 Flutter로 앱을 개발하고 출시하면서 느낀 점을 가볍게 작성한 글입니다. 
기술 위주의 개발기가 궁금하시다면 <a href="https://equable-jitterbug-e9a.notion.site/94af09276a7549e79912577fb6144708?v=e1ab4856173049daac90f2c6e3435ba3">밤하늘_개발일지</a> 를 클릭해주세요! 🙏</p>
</blockquote>
<br>

<h2 id="앱-소개">앱 소개</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/yun_dal/post/72cfab57-cfa2-4a11-a3c9-7379fa355d74/image.png" alt=""></p>
<p><strong>밤하늘 - 목소리로 소통하는 음성 SNS</strong></p>
<ul>
<li>목소리로 생각과 감정을 공유하고자 하는 모든 분들을 위한 음성 기반 SNS입니다.</li>
<li>주요 기능은 아래와 같습니다.</li>
</ul>
<ol>
<li><strong>음성 게시글 작성</strong> : 프로필 사진, 닉네임, 음성을 게시글로 업로드 가능</li>
<li><strong>음성 메시지 답장</strong> : 마음에 드는 게시글에 음성 메시지 전송 가능</li>
<li><strong>음성 채팅</strong> : 답장을 통해 시작된 채팅방에서 음성 메시지로 대화 가능<blockquote>
<p>🛠️ 깃허브 레포 : <a href="https://github.com/Yundal0/the_night_sky">https://github.com/Yundal0/the_night_sky</a>
🍎 앱스토어 : <a href="https://apps.apple.com/kr/app/id6503616413">https://apps.apple.com/kr/app/id6503616413</a></p>
</blockquote>
</li>
</ol>
<br>

<h2 id="기획">기획</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/yun_dal/post/a071b6ac-52de-4158-9f66-da6b120bca36/image.png" alt=""></p>
<p>초반에 기획을 대충하는 바람에 여러 번의 시행착오를 겪었고, 
개발 기간이 예상보다 길어지며 개발 의욕까지 꺾였습니다. </p>
<p><strong>&quot;이 앱은 왜 존재하고, 해당 기능을 왜 제공해야 하는가&quot;</strong> 에 대해 명확하게 설명하기 위해
기획일지를 따로 작성했는데요. 이후 기획일지를 작성하면서 시행착오가 줄었고, 
비교적 완성도가 높은 앱을 만들 수 있었습니다. </p>
<p>제가 작성한 일지의 내용이 궁금하시다면 <a href="https://equable-jitterbug-e9a.notion.site/_-8824bfeacad54758a724b9a8789d034d">밤하늘_기획일지</a>를 참고해보세요  😃</p>
<br>

<h2 id="디자인">디자인</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/yun_dal/post/fb3f60b4-3674-43c3-8937-ea682bf59cde/image.png" alt=""></p>
<p>저는 <code>사용자 경험</code>을 고려했을 때 디자인이 매우 중요한 부분이라고 생각했고, 직접 Figma로 Design Guide와 위젯을 디자인하여 앱을 제작했습니다.</p>
<p>제가 Figma를 학습할 때 사용한 유튜브 링크를 공유하겠습니다. 참고해보셔도 좋을 것 같습니다!</p>
<blockquote>
</blockquote>
<p>🔗 <a href="https://www.youtube.com/playlist?list=PLcuAyPF0aaZyrYFCEyXI85MdOy6kzRq_u">피그마 기본 익히기</a>
🔗 <a href="https://www.youtube.com/@yeonjung-figma">연정&#39;s Figma </a></p>
<br>

<h2 id="개발">개발</h2>
<hr>
<p>코드가 궁금하시다면 <a href="https://github.com/Yundal0/the_night_sky">깃허브 레포</a>를 참고해주세요.</p>
<h3 id="사용된-기술패키지">사용된 기술/패키지</h3>
<p>사용된 기술과 패키지는 다음과 같습니다.</p>
<ul>
<li>Dart, Flutter</li>
<li>Provider</li>
<li>Firebase Auth, Firebase Storage, Firestore</li>
<li>audioplayers, record</li>
</ul>
<br>

<h3 id="프로젝트-폴더-구조">프로젝트 폴더 구조</h3>
<p>MVP의 규모가 작아 학습과 설계에 많은 노력이 필요한 Clean Architecture를 적용하기보다는, 앱에서 공통적으로 사용되는 부분과 데이터 관련 요소들을 <code>app</code> 폴더에 모았습니다. </p>
<p>또한, <code>presentation</code> 폴더 내에는 MVVM 패턴을 적용한 pages 폴더와 재사용 가능한 위젯들을 모아둔 widgets 폴더를 하위 폴더로 구성하였습니다.</p>
<pre><code class="language-bash"> |-- lib
     |-- app
     |   |-- config
     |   |-- constants
     |   |-- enums
     |   |-- models
     |   |-- repository
     |   |-- utils
     |-- presentation
         |-- pages
         |   |-- chat_room
         |   |-- chat_thumbnail
         |   |-- login
         |   |-- post
         |   |-- profile
         |   |-- register_profile
         |   |-- reply
         |-- widgets
         |   |-- app_bar
         |   |-- audio_player
         |   |-- custom_buttons
         |   |-- layout
         |   |-- record_buttons
         |   |-- tiles
 |</code></pre>
<br>

<h3 id="문제-해결-과정">문제 해결 과정</h3>
<p>문제가 발생했을 때 <code>문제 현상</code> - <code>원인</code> - <code>해결 가설</code> - <code>결과</code> 순서로 해결했습니다. 
예를 들어, <strong>ViewModel과 Repository의 역할 갈등 사례</strong>를 해결한 과정을 공유합니다.</p>
<ul>
<li><strong>문제 현상</strong><ul>
<li>ViewModel과 Repository의 역할이 충돌하는 상황이다.</li>
<li>ViewModel은 Model Instance를 만든 후에 Repository로 Instance를 전달하는 역할을 수행한다.</li>
<li>Repository는 전달받은 Instance를 Firestore에 전달하는 역할을 수행한다.</li>
</ul>
</li>
<li><strong>원인</strong><ul>
<li>ChatModel의 경우 ChatId라는 필드 값을 생성해야 하는데, ChatId는 Firestore의 Doc ID이다.</li>
<li>Firestore의 Doc ID는 Firestore의 Collection에 Doc과 Field를 생성해야 만들어지는 값이다.</li>
<li>따라서 ChatModel의 Instance를 ViewModel에서 만들 경우 반드시 ViewModel 내부에서 Firestore에 DB를 생성하는 작업을 수행해야 하고, 이는 ViewModel과 Repository의 역할 갈등을 초래하고, ViewModel의 크기를 증가시킨다.</li>
</ul>
</li>
<li><strong>해결 가설</strong><ul>
<li>ChatModel에서 Doc ID로 사용되는 부분을 Nullable로 처리하면 된다.</li>
<li>Repository에서 할당받은 chatId와 messageId를 Model Instance에 나중에 할당하면 된다.</li>
</ul>
</li>
<li><strong>결과</strong><ul>
<li>ViewModel과 Repository의 역할이 충돌되는 상황을 해결했다.</li>
<li>문제 상황에 적어놓은대로 ViewModel과 Repository의 독립된 역할이 수행된다.</li>
</ul>
</li>
</ul>
<p>더 많은 문제 해결 과정이 궁금하시다면 <a href="https://equable-jitterbug-e9a.notion.site/b11ed3e7f92d4761b47f75a2835fc891?v=803502e0855942839298fa77cbf58499">밤하늘_문제해결일지</a>를 참고해주세요.</p>
<br>

<h2 id="앱-스토어-등록-과정">앱 스토어 등록 과정</h2>
<hr>
<h3 id="로고-만들기">로고 만들기</h3>
<p>저는 밤하늘을 떠올렸을 때 달과 별이 가장 먼저 떠올랐는데요. 그래서 Figma로 달과 별을 그리는 방법에 대해 고민했고, 이를 알려주는 유튜브 영상을 참고하여 로고를 제작했습니다.</p>
<p>그리고나서 앱의 브랜드 컬러를 그라데이션으로 잘 녹여내어 만족스러운 로고를 완성했습니다. </p>
<img src="https://velog.velcdn.com/images/yun_dal/post/a260a415-1a69-4c54-8a72-48215115099c/image.png" width="20%" height="20%">

<br>

<h3 id="약관-작성하기">약관 작성하기</h3>
<p>특정 항목을 선택하면 약관의 초안을 작성해주던 사이트가 운영을 중단하면서, 직접 필요한 약관을 작성하게 되었습니다. 처음부터 모든 약관을 작성하는데 시간이 너무 오래걸릴 것 같아, 밤하늘과 최대한 비슷한 서비스의 약관을 참고하여 <code>개인정보 처리방침</code> 과 <code>서비스 이용약관</code>을  수정하는 방식으로 진행했습니다.</p>
<br>

<h3 id="앱스토어에-앱-등록하기">앱스토어에 앱 등록하기</h3>
<ul>
<li><a href="https://velog.io/@minji0801/%EC%95%B1%EC%8A%A4%ED%86%A0%EC%96%B4%EC%97%90-%EC%95%B1-%EB%93%B1%EB%A1%9D%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95%EC%9D%84-%EB%AA%A8%EB%A5%B4%EA%B2%A0%EB%8B%A4">[AppStore] 앱스토어에 앱 등록하는 방법을 모르겠다. (velog.io)</a></li>
<li>저는 상단 링크의 블로그를 참고하면서 등록했습니다.</li>
<li>등록 절차를 밟으면서 겪은 시행착오와 꿀팁 등을 아래에 적어놓았으니 참고해주세요!</li>
</ul>
<br>

<p><strong>App Store Connect에 신규 앱 생성하기</strong>
<code>이름</code> : 밤하늘
<code>번들 ID</code> : com.example.everyonesTone 
(저는 이미 출시해서 변경이 불가능하지만 여러분들은 꼭 변경해주세요..)</p>
<br>

<p><strong>앱 미리보기 및 스크린샷 넣기</strong>
<code>주의사항</code> : 스크린샷을 등록할 때에는 반드시 파일 내보내기 후 알파 체크 해제를 해주세요. 체크 해제를 하지 않으면 에러가 발생합니다..</p>
<p><code>iPad 이슈</code> : 아이폰에서만 사용 가능한 앱을 출시하려고 했는데, iPad 스크린샷도 같이 제출해야 한다고 하네요. 게다가 iPad가 없는데 iPad 스크린샷을 첨부하라고 해서 당황스러웠는데, iPad Simulator로 캡쳐 후 등록을 했는데 운이 좋게도 통과됐습니다.</p>
<br>

<p><strong>빌드 업로드하기</strong>
<code>주의사항</code> : 빌드 업로드 하기 전에 버전 정보 입력하기에서도 나왔듯이 로고의 png 또한 알파 체크 해제를 해주셔야 합니다. 안그러면 에러가 뜹니다.</p>
<br>

<h2 id="마치며">마치며</h2>
<hr>
<p>단순한 앱 개발 경험을 넘어, 제가 직접 필요로 하는 서비스를 기획부터 배포까지 해결하면서 
<strong>&#39;내가 원하는 서비스를 직접 만들 수 있다&#39;</strong> 는 자신감을 얻게 되었습니다.</p>
<p>비록 <code>서비스 운영</code> 관점에서 보면, 앱 배포는 끝이 아닌 새로운 시작이지만,
가장 큰 난관을 넘긴 초보 개발자로서 두려움보다는 설렘이 앞서네요 😊</p>
<p>Flutter의 매력에 빠져 앱을 개발하고 과정을 기록한 이 글이 여러분에게 도움이 되었기를 바랍니다. 
읽어주셔서 감사합니다.</p>
]]></description>
        </item>
    </channel>
</rss>