선언형 렌더링

개요

Flitter는 Flutter와 같은 선언형(Declarative) 렌더링 방식을 채택합니다. 이는 “화면이 어떻게 보여야 하는지”를 선언하면, Flitter가 알아서 렌더링을 처리하는 방식입니다. 이는 기존의 명령형(Imperative) 방식과는 근본적으로 다른 접근법입니다.

왜 중요한가?

선언형 프로그래밍은 다음과 같은 장점을 제공합니다:

  • 코드 가독성 향상: UI가 어떻게 보일지 직관적으로 이해 가능
  • 버그 감소: 상태 관리가 단순해져 예측 가능한 동작
  • 유지보수 용이: 복잡한 DOM 조작 없이 UI 업데이트
  • 생산성 증가: 더 적은 코드로 복잡한 UI 구현

핵심 개념

명령형 vs 선언형

명령형 방식 (D3.js 예시)

명령형 방식에서는 각 단계별로 무엇을 해야 하는지 명시합니다:

// D3.js로 텍스트를 가운데 정렬하기
const svg = d3.select("svg");
const width = 400;
const height = 200;

// 1. 텍스트 요소 생성
const text = svg.append("text")
  .text("Hello, World!");

// 2. 텍스트 크기 측정
const bbox = text.node().getBBox();

// 3. 중앙 위치 계산
const x = (width - bbox.width) / 2;
const y = (height + bbox.height) / 2;

// 4. 위치 설정
text.attr("x", x)
    .attr("y", y);

// 상태가 변경되면 모든 계산을 다시 해야 함
function updateText(newText) {
  text.text(newText);
  const newBbox = text.node().getBBox();
  const newX = (width - newBbox.width) / 2;
  const newY = (height + newBbox.height) / 2;
  text.attr("x", newX).attr("y", newY);
}

선언형 방식 (Flitter 예시)

선언형 방식에서는 원하는 결과만 선언합니다:

// Flitter로 텍스트를 가운데 정렬하기
Center({
  child: Text("Hello, World!")
})

// 상태가 변경되어도 선언만 바꾸면 됨
Center({
  child: Text(isKorean ? "안녕하세요!" : "Hello, World!")
})

명령형 vs 선언형 비교

D3.js와 Flitter로 동일한 기능을 구현한 예제입니다. 버튼을 클릭해 텍스트를 변경해보세요.

명령형 방식 (D3.js)

D3.js로 구현시 각 단계별로 DOM을 직접 조작해야 합니다:

// 1. SVG 요소 선택 및 설정
const svg = d3.select("svg");
const width = 400;
const height = 200;

// 2. 텍스트 요소 생성 및 위치 설정
const text = svg.append("text")
  .text("Hello, World!")
  .attr("x", width / 2)
  .attr("y", height / 2)
  .attr("text-anchor", "middle")
  .attr("font-size", "24px");

// 3. 버튼 생성 (여러 단계 필요)
const button = svg.append("g")
  .attr("transform", `translate(${width/2}, ${height/2 + 40})`);

const buttonRect = button.append("rect")
  .attr("x", -60)
  .attr("y", -15)
  .attr("width", 120)
  .attr("height", 30)
  .attr("fill", "#2196F3");

button.append("text")
  .text("텍스트 변경")
  .attr("text-anchor", "middle")
  .attr("fill", "white");

// 4. 이벤트 핸들러 (DOM 직접 조작)
let currentIndex = 0;
const texts = ["Hello!", "안녕!", "こんにちは!", "Bonjour!"];

buttonRect.on("click", () => {
  currentIndex = (currentIndex + 1) % texts.length;
  text.text(texts[currentIndex]);
  // 텍스트가 길어지면 위치 재계산 필요...
});

선언형 방식 (Flitter)

// UI가 어떻게 보일지만 선언
class MyWidget extends StatefulWidget {
  createState() {
    return new MyWidgetState();
  }
}

class MyWidgetState extends State<MyWidget> {
  texts = ["Hello!", "안녕!", "こんにちは!", "Bonjour!"];
  currentIndex = 0;

  build(context) {
    return Column({
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Center({
          child: Text(this.texts[this.currentIndex])
        }),
        GestureDetector({
          onClick: () => {
            this.setState(() => {
              this.currentIndex = (this.currentIndex + 1) % this.texts.length;
            });
          },
          child: Container({
            padding: EdgeInsets.all(12),
            color: Colors.blue,
            child: Text("텍스트 변경")
          })
        })
      ]
    });
  }
}

💡 핵심 차이점

  • 명령형 방식 (D3.js): 각 단계별로 무엇을 해야 하는지 명시적으로 지시
  • 선언형 방식 (Flitter): 원하는 결과만 선언하면 렌더링은 자동으로 처리

선언형 렌더링의 작동 원리

  1. 상태(State) 정의: 애플리케이션의 현재 상태를 정의
  2. UI 선언: 상태에 따라 UI가 어떻게 보여야 하는지 선언
  3. 자동 업데이트: 상태가 변경되면 Flitter가 자동으로 UI 업데이트
class _CounterWidget extends StatefulWidget {
  createState() {
    return new CounterWidgetState();
  }
}

class CounterWidgetState extends State<_CounterWidget> {
  count = 0;  // 상태 정의

  build(context: BuildContext): Widget {
    // UI 선언 - count 상태에 따라 UI가 결정됨
    return Column({
      children: [
        Text(`현재 카운트: ${this.count}`),
        GestureDetector({
          onClick: () => {
            this.setState(() => {
              this.count++;  // 상태 변경시 자동 리렌더링
            });
          },
          child: Container({
            padding: EdgeInsets.all(8),
            color: Colors.blue,
            child: Text("증가", { style: TextStyle({ color: Colors.white }) })
          })
        })
      ]
    });
  }
}

// 팩토리 함수로 내보내기
export default function CounterWidget(): Widget {
  return new _CounterWidget();
}

실습 예제

1. 조건부 렌더링

선언형 방식에서는 조건문을 통해 쉽게 다른 UI를 보여줄 수 있습니다:

class ConditionalExample extends StatefulWidget {
  createState() {
    return new ConditionalExampleState();
  }
}

class ConditionalExampleState extends State<ConditionalExample> {
  isLoggedIn = false;

  build(context: BuildContext): Widget {
    return Center({
      child: this.isLoggedIn 
        ? Column({
            children: [
              Text("환영합니다!"),
              GestureDetector({
                onClick: () => {
                  this.setState(() => {
                    this.isLoggedIn = false;
                  });
                },
                child: Text("로그아웃", { 
                  style: TextStyle({ color: Colors.red }) 
                })
              })
            ]
          })
        : GestureDetector({
            onClick: () => {
              this.setState(() => {
                this.isLoggedIn = true;
              });
            },
            child: Container({
              padding: EdgeInsets.all(12),
              color: Colors.blue,
              child: Text("로그인", { 
                style: TextStyle({ color: Colors.white }) 
              })
            })
          })
    });
  }
}

2. 리스트 렌더링

배열 데이터를 UI로 변환하는 것도 매우 직관적입니다:

class TodoList extends StatefulWidget {
  createState() {
    return new TodoListState();
  }
}

class TodoListState extends State<TodoList> {
  todos = ["Flitter 학습하기", "선언형 UI 이해하기", "앱 만들기"];

  build(context: BuildContext): Widget {
    return Column({
      children: [
        Text("할 일 목록", { 
          style: TextStyle({ fontSize: 20, fontWeight: FontWeight.bold }) 
        }),
        SizedBox({ height: 10 }),
        ...this.todos.map((todo, index) => 
          Container({
            padding: EdgeInsets.all(8),
            margin: EdgeInsets.only({ bottom: 4 }),
            color: Colors.grey.shade200,
            child: Row({
              children: [
                Text(`${index + 1}. ${todo}`),
                Spacer(),
                GestureDetector({
                  onClick: () => {
                    this.setState(() => {
                      this.todos.splice(index, 1);
                    });
                  },
                  child: Icon(Icons.delete, { color: Colors.red })
                })
              ]
            })
          })
        )
      ]
    });
  }
}

3. 복잡한 상태 관리

여러 상태가 서로 연관된 경우도 선언형으로 쉽게 처리할 수 있습니다:

class ShoppingCart extends StatefulWidget {
  createState() {
    return new ShoppingCartState();
  }
}

class ShoppingCartState extends State<ShoppingCart> {
  items = [
    { name: "사과", price: 1000, quantity: 0 },
    { name: "바나나", price: 1500, quantity: 0 },
    { name: "오렌지", price: 2000, quantity: 0 }
  ];

  get totalPrice() {
    return this.items.reduce((sum, item) => 
      sum + (item.price * item.quantity), 0
    );
  }

  build(context: BuildContext): Widget {
    return Column({
      children: [
        Text("장바구니", { 
          style: TextStyle({ fontSize: 24, fontWeight: FontWeight.bold }) 
        }),
        SizedBox({ height: 20 }),
        ...this.items.map(item => 
          Row({
            children: [
              Text(item.name),
              Spacer(),
              Text(`${item.price}원`),
              SizedBox({ width: 20 }),
              Row({
                children: [
                  GestureDetector({
                    onClick: () => {
                      this.setState(() => {
                        if (item.quantity > 0) item.quantity--;
                      });
                    },
                    child: Icon(Icons.remove_circle)
                  }),
                  Padding({
                    padding: EdgeInsets.symmetric({ horizontal: 10 }),
                    child: Text(`${item.quantity}`)
                  }),
                  GestureDetector({
                    onClick: () => {
                      this.setState(() => {
                        item.quantity++;
                      });
                    },
                    child: Icon(Icons.add_circle)
                  })
                ]
              })
            ]
          })
        ),
        Divider(),
        Text(`총액: ${this.totalPrice}원`, {
          style: TextStyle({ fontSize: 18, fontWeight: FontWeight.bold })
        })
      ]
    });
  }
}

주의사항

  1. 불변성 유지: 상태를 직접 수정하지 말고 항상 setState를 사용
  2. 순수 함수: build 메서드는 부작용이 없는 순수 함수여야 함
  3. 성능 고려: 불필요한 리렌더링을 피하기 위해 위젯을 적절히 분리
// ❌ 잘못된 예 - 직접 상태 변경
this.count++;  // UI가 업데이트되지 않음

// ✅ 올바른 예 - setState 사용
this.setState(() => {
  this.count++;
});

// ❌ 잘못된 예 - build 메서드에서 부작용
build(context: BuildContext): Widget {
  // API 호출 같은 부작용은 initState에서
  fetch('/api/data');  // 잘못됨!
  return Text("...");
}

// ✅ 올바른 예 - initState에서 부작용 처리
initState() {
  super.initState();
  fetch('/api/data').then(data => {
    this.setState(() => {
      this.data = data;
    });
  });
}

다음 단계

선언형 렌더링의 개념을 이해했다면, 다음으로 학습할 내용:

  • 위젯 시스템 - StatelessWidget과 StatefulWidget 이해하기
  • 상태 관리 - 복잡한 상태 관리 패턴
  • 애니메이션 기초 - 선언형 애니메이션 구현