«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
Archives
Today
Total
관리 메뉴

올해는 머신러닝이다.

Figma Mcp server + Cursor를 이용해서 Flutter 페이지 클론하기 본문

스터디/Flutter

Figma Mcp server + Cursor를 이용해서 Flutter 페이지 클론하기

행복한 수지아빠 2025. 3. 10. 14:23

Mcp Server를 이용해서 Figma에 있는 디자인을 커서로 그대로 Flutter 코드로 가져오는 방법을 공유 할려고 합니다. (클론 가능)

준비물

  • Node
  • Figma
  • cursor

🚀 MCP(Model Context Protocol)란?

MCP는 애플리케이션이 LLM(대형 언어 모델)과 맥락(Context)을 주고받는 방식을 표준화하는 프로토콜이에요. 쉽게 말해, LLM이 원하는 결과를 제대로 생성하도록 정확한 정보(맥락)를 전달하는 기술입니다.

💡 비유하자면, MCP는 USB-C와 같아요.
하나의 표준 인터페이스로 다양한 기기(LLM)와 연결할 수 있죠.

Figma 디자인을 Flutter 코드로 변환하려면, 올바른 맥락을 LLM에 제공해야 해요. 그렇지 않으면 결과물이 완전 엉망이 되거나, 원하는 수준에 도달하지 못할 수 있죠.


🛠️ MCP 아키텍처 살펴보기

MCP는 간단한 구조로 되어 있습니다.

  • MCP 호스트: LLM과 연결하는 클라이언트(예: Cursor IDE)
  • MCP 서버: LLM과 소통하며 실제 작업을 수행
  • 로컬 및 원격 데이터 소스: 디자인 파일, API, 데이터베이스 등

예를 들어, Cursor 같은 IDE는 동시에 MCP 호스트이자 클라이언트 역할을 합니다. 그리고 우리가 사용한 Figma Context MCP는 Figma 데이터를 읽어와 Flutter 코드로 변환하는 MCP 서버 역할을 해요.


🔧 Figma 디자인을 코드로 변환하는 과정

  1. Figma에서 디자인 링크 복사
  2. **Localhost 또는 Server에서 MCP 서버 실행
  3. Cursor에서 MCP 연결
  4. Figma Context MCP를 사용하여 코드 자동 변환
  5. 완성된 코드 확인 및 수정

제가 해본 결과, 단순한 미니멀 디자인은 거의 완벽하게 변환되었어요. 하지만 복잡한 페이지의 경우 일부 이미지 누락 또는 디자인 세부 조정 필요 등의 문제가 있었습니다. 그래도 레이아웃과 주요 요소는 거의 완벽하게 복사되었고, 이는 엄청난 생산성 향상을 의미해요.


순서

https://github.com/GLips/Figma-Context-MCP

 

GitHub - GLips/Figma-Context-MCP: MCP server to provide Figma layout information to AI coding agents like Cursor

MCP server to provide Figma layout information to AI coding agents like Cursor - GLips/Figma-Context-MCP

github.com

 

MCP Server을 localhost로 실행을 해주셔야 합니다. 그전에 피그마 키를 받으셔야 하는데 피그마 설정에 가시면 Security -> Access Token에서 받으실수 있습니다.

Figma Key

소스를 받으시고 env에 피그마 키를 입력해주세요

git clone https://github.com/GLips/Figma-Context-MCP.git
cp .env.example .env
pnpm install && pnpm dev (or start)

실행하면 localhost:3333 으로 실행이 될겁니다. 그럼 커서 설정에서 MCP 를 추가해주시면 되세요.

Cursor Mcp Server

피그마 링크를 추가로 받아오시고 (copy link to selection)

커서 채팅창에 링크 붙여넣기 하시고 figma 링크 접속해서 컴포넌트 또는 리소스 가져와달라고 하면 됩니다. (또는 클론하기)

아래 결과는 왼쪽이 피그마 파일이고 오른쪽은 클론한 내용입니다. 만드는 데 5분도 안걸려서 나왔네요.

소스 코드

아래는 어떠한 Rule도 없이 만든거라 하나의 dart파일에 있네요. 꼭 프롬프트 작성시 컴포넌트들을 나눠달라고 해주셔야 합니다.

import 'package:flutter/material.dart';
import 'package:mcptest/components/bottom_navigation.dart';
import 'package:mcptest/screens/home_screen.dart';
import 'package:mcptest/screens/portfolio_screen.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '암호화폐 지갑',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.dark(
          primary: const Color(0xFF00F4C8),
          background: Colors.black,
          surface: Colors.grey.shade900,
        ),
        scaffoldBackgroundColor: Colors.black,
        useMaterial3: true,
      ),
      home: const MainScreen(),
    );
  }
}

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

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  int _selectedIndex = 0;

  final List<Widget> _screens = [
    const HomeScreen(),
    const PortfolioScreen(),
    const SizedBox(), // 교환 화면 (아직 구현되지 않음)
    const SizedBox(), // 차트 화면 (아직 구현되지 않음)
    const SizedBox(), // 설정 화면 (아직 구현되지 않음)
  ];

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: _screens[_selectedIndex],
      bottomNavigationBar: BottomNavigation(
        currentIndex: _selectedIndex,
        onTap: _onItemTapped,
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: SafeArea(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 20.0),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  const SizedBox(), // 빈 공간
                  Container(
                    padding: const EdgeInsets.all(8),
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(6),
                      border: Border.all(color: Colors.grey.shade800),
                      color: Colors.grey.shade900,
                    ),
                    child: const Icon(
                      Icons.notifications_none_outlined,
                      color: Colors.white70,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 20),
            // 포트폴리오 잔액 섹션
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 20.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    'Portfolio Balance',
                    style: TextStyle(
                      fontSize: 14,
                      fontWeight: FontWeight.w500,
                      letterSpacing: 0.5,
                      color: Colors.white70,
                    ),
                  ),
                  const SizedBox(height: 6),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      const Text(
                        '\$12,550.50',
                        style: TextStyle(
                          fontSize: 34,
                          fontWeight: FontWeight.w500,
                          letterSpacing: 0.5,
                          color: Colors.white,
                        ),
                      ),
                      Container(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 12,
                          vertical: 4,
                        ),
                        decoration: BoxDecoration(
                          color: const Color(0xFF012500),
                          borderRadius: BorderRadius.circular(50),
                        ),
                        child: Row(
                          children: [
                            const Icon(
                              Icons.arrow_drop_up,
                              color: Color(0xFF04DC00),
                            ),
                            const Text(
                              '10.75%',
                              style: TextStyle(
                                fontSize: 11,
                                fontWeight: FontWeight.w500,
                                color: Color(0xFF04DC00),
                              ),
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
            const SizedBox(height: 24),
            // 내 포트폴리오 섹션
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 20.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      const Text(
                        'My Portfolio',
                        style: TextStyle(
                          fontSize: 14,
                          fontWeight: FontWeight.w500,
                          letterSpacing: 0.5,
                          color: Colors.white70,
                        ),
                      ),
                      Row(
                        children: [
                          const Text(
                            'Monthly',
                            style: TextStyle(
                              fontSize: 13,
                              fontWeight: FontWeight.w600,
                              color: Color(0xFF00F4C8),
                            ),
                          ),
                          const SizedBox(width: 4),
                          Icon(
                            Icons.keyboard_arrow_down,
                            color: Theme.of(context).colorScheme.primary,
                          ),
                        ],
                      ),
                    ],
                  ),
                  const SizedBox(height: 16),
                  // 암호화폐 카드 목록
                  Row(
                    children: [
                      _CryptoCard(
                        name: 'Bitcoin',
                        symbol: 'BTC',
                        amount: '\$6780',
                        percentage: '11.75%',
                        isUp: true,
                        iconBackgroundColor: Colors.purple.withOpacity(0.7),
                        iconColor: const Color(0xFFBE1AF7),
                        strokeColor: const Color(0xFFD250FF),
                      ),
                      const SizedBox(width: 16),
                      _CryptoCard(
                        name: 'Ethereum',
                        symbol: 'BTC',
                        amount: '\$1478.10',
                        percentage: '4.75%',
                        isUp: true,
                        iconBackgroundColor: Colors.blue.withOpacity(0.7),
                        iconColor: Colors.blue,
                        strokeColor: const Color(0xFF4B70FF),
                      ),
                    ],
                  ),
                ],
              ),
            ),
            const SizedBox(height: 30),
            // 리페럴 리워드 배너
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 20.0),
              child: Container(
                width: double.infinity,
                padding: const EdgeInsets.all(20),
                decoration: BoxDecoration(
                  color: const Color(0xFF00F4C8),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Stack(
                  children: [
                    Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        const Text(
                          'Refer Rewards',
                          style: TextStyle(
                            fontSize: 13,
                            fontWeight: FontWeight.w500,
                            letterSpacing: 0.5,
                            color: Colors.black87,
                          ),
                        ),
                        const SizedBox(height: 4),
                        const Text(
                          'Earn 5\$ rewards on every \nsuccessfull refers',
                          style: TextStyle(
                            fontSize: 20,
                            fontWeight: FontWeight.w500,
                            color: Colors.black,
                          ),
                        ),
                      ],
                    ),
                    Positioned(
                      right: 0,
                      top: 0,
                      child: IconButton(
                        icon: const Icon(Icons.close, color: Colors.black),
                        onPressed: () {},
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 30),
            // 시장 통계 섹션
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 20.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    'Market Statistics',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.w500,
                      letterSpacing: 0.5,
                      color: Colors.white,
                    ),
                  ),
                  const SizedBox(height: 10),
                  SingleChildScrollView(
                    scrollDirection: Axis.horizontal,
                    child: Row(
                      children: [
                        _FilterChip(label: '24 hrs', isSelected: true),
                        _FilterChip(label: 'Hot', isSelected: false),
                        _FilterChip(label: 'Profit', isSelected: false),
                        _FilterChip(label: 'Rising', isSelected: false),
                        _FilterChip(label: 'Loss', isSelected: false),
                        _FilterChip(label: 'Top Gain', isSelected: false),
                      ],
                    ),
                  ),
                  const SizedBox(height: 18),
                  // 암호화폐 목록
                  const _CryptoListItem(
                    name: 'Cardano',
                    symbol: 'ADA',
                    price: '\$123.77',
                    percentage: '11.75%',
                    isUp: true,
                    iconBackgroundColor: Color(0xFF0033AD),
                  ),
                  const Divider(height: 1, color: Colors.grey),
                  const _CryptoListItem(
                    name: 'Uniswap',
                    symbol: 'LTC',
                    price: '\$16.96',
                    percentage: '11.75%',
                    isUp: true,
                    iconBackgroundColor: Colors.purpleAccent,
                  ),
                  const Divider(height: 1, color: Colors.grey),
                  const _CryptoListItem(
                    name: 'Tether',
                    symbol: 'USDT',
                    price: '\$0.98',
                    percentage: '2.75%',
                    isUp: true,
                    iconBackgroundColor: Colors.teal,
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
      bottomNavigationBar: _BottomNavigationWidget(),
    );
  }
}

class _CryptoCard extends StatelessWidget {
  final String name;
  final String symbol;
  final String amount;
  final String percentage;
  final bool isUp;
  final Color iconBackgroundColor;
  final Color iconColor;
  final Color strokeColor;

  const _CryptoCard({
    required this.name,
    required this.symbol,
    required this.amount,
    required this.percentage,
    required this.isUp,
    required this.iconBackgroundColor,
    required this.iconColor,
    required this.strokeColor,
  });

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: Colors.white.withOpacity(0.12),
          borderRadius: BorderRadius.circular(18),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                    color: iconBackgroundColor,
                    shape: BoxShape.circle,
                  ),
                  child: Icon(
                    name == 'Bitcoin'
                        ? Icons.currency_bitcoin
                        : Icons.currency_exchange,
                    color: Colors.white,
                  ),
                ),
                const SizedBox(width: 8),
                Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      name,
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.w500,
                        color: Colors.white,
                      ),
                    ),
                    Text(
                      symbol,
                      style: const TextStyle(
                        fontSize: 12,
                        fontWeight: FontWeight.w400,
                        color: Colors.white60,
                      ),
                    ),
                  ],
                ),
              ],
            ),
            const Divider(color: Colors.grey, thickness: 0.5),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  amount,
                  style: const TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w600,
                    color: Colors.white,
                  ),
                ),
                Row(
                  children: [
                    Icon(
                      isUp ? Icons.arrow_drop_up : Icons.arrow_drop_down,
                      color: isUp ? const Color(0xFF04DC00) : Colors.red,
                    ),
                    Text(
                      percentage,
                      style: TextStyle(
                        fontSize: 10,
                        fontWeight: FontWeight.w500,
                        color: isUp ? const Color(0xFF04DC00) : Colors.red,
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class _FilterChip extends StatelessWidget {
  final String label;
  final bool isSelected;

  const _FilterChip({required this.label, required this.isSelected});

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.only(right: 10),
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
      decoration: BoxDecoration(
        color: isSelected ? const Color(0xFF4B4B4B) : const Color(0xFF282828),
        borderRadius: BorderRadius.circular(6),
      ),
      child: Text(
        label,
        style: TextStyle(
          fontSize: 11,
          fontWeight: FontWeight.w500,
          color: isSelected ? Colors.white : Colors.white60,
        ),
      ),
    );
  }
}

class _CryptoListItem extends StatelessWidget {
  final String name;
  final String symbol;
  final String price;
  final String percentage;
  final bool isUp;
  final Color iconBackgroundColor;

  const _CryptoListItem({
    required this.name,
    required this.symbol,
    required this.price,
    required this.percentage,
    required this.isUp,
    required this.iconBackgroundColor,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 10),
      child: Row(
        children: [
          Row(
            children: [
              Container(
                width: 40,
                height: 40,
                decoration: BoxDecoration(
                  color: iconBackgroundColor,
                  shape: BoxShape.circle,
                ),
                child: const Icon(Icons.currency_bitcoin, color: Colors.white),
              ),
              const SizedBox(width: 6),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    name,
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.w400,
                      letterSpacing: 0.5,
                      color: Colors.white,
                    ),
                  ),
                  Text(
                    symbol,
                    style: const TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w400,
                      color: Colors.white60,
                    ),
                  ),
                ],
              ),
            ],
          ),
          const Spacer(),
          Column(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              Text(
                price,
                style: const TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.w400,
                  letterSpacing: 0.5,
                  color: Colors.white,
                ),
              ),
              Row(
                children: [
                  Icon(
                    isUp ? Icons.arrow_drop_up : Icons.arrow_drop_down,
                    color: isUp ? const Color(0xFF04DC00) : Colors.red,
                  ),
                  Text(
                    percentage,
                    style: TextStyle(
                      fontSize: 10,
                      fontWeight: FontWeight.w500,
                      color: isUp ? const Color(0xFF04DC00) : Colors.red,
                    ),
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class _BottomNavigationWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      height: 80,
      decoration: BoxDecoration(
        color: Colors.white.withOpacity(0.12),
        borderRadius: const BorderRadius.only(
          topLeft: Radius.circular(32),
          topRight: Radius.circular(32),
        ),
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          Icon(Icons.home, color: Theme.of(context).colorScheme.primary),
          Icon(Icons.pie_chart, color: Colors.white.withOpacity(0.5)),
          Stack(
            alignment: Alignment.center,
            children: [
              Container(
                width: 50,
                height: 50,
                decoration: BoxDecoration(
                  color: Theme.of(context).colorScheme.primary,
                  shape: BoxShape.circle,
                ),
              ),
              const Icon(Icons.swap_horiz, color: Colors.black),
            ],
          ),
          Icon(Icons.bar_chart, color: Colors.white.withOpacity(0.5)),
          Icon(Icons.settings, color: Colors.white.withOpacity(0.5)),
        ],
      ),
    );
  }
}

디버깅 모드

MCP에서 피그마를 잘 가져오는 지 체크 하기 위해선 inspect가 가능합니다.
서버가 3333 실행해놓은 상태에서 다른 터미널을 열어서

pnpm inspect

실행시 localhost:5173 서버에 따로 실행된걸 확인이 가능합니다.

여기에서 fileKey 및 nodeId는 https://www.figma.com/design/[fileKey]/name?node-id=[nodeId] 입니다.

성공시 JSON으로 결과가 잘 받아오는걸 확인이 가능합니다

🎨 결과물 분석

레이아웃 완벽 재현
버튼, 입력 필드, 컬러, 구조 반영
파일 하나에 몰아서 만들어주기 때문에 프롬프트 작성시 파일을 나눠달라고 하거나 Rule을 정하는게 좋습니다
일부 SVG 이미지 누락
디자인에 창의적인 요소 부족 (AI의 한계)

하지만, AI가 만들어준 기본 틀을 바탕으로 세부적으로 다듬으면, 기존보다 훨씬 빠르게 완성도 높은 웹사이트를 개발할 수 있어요.


🏆 결론: 생산성 폭발!

과거에는 Figma 디자인을 코드로 변환하는 과정이 정말 고통스러웠어요. 하지만 MCP와 Cursor 덕분에 이제는 클릭 몇 번만으로 상당한 수준의 코드가 자동 생성됩니다.

물론 100% 완벽한 결과물을 얻으려면 추가 수정이 필요하겠지만, 초기 작업 시간을 대폭 단축할 수 있어요.

💡 한 줄 요약:

Figma 디자인을 Flutter 코드로 빠르게 변환하고 싶다면, MCP를 활용하라!

더 많은 AI 기반 개발 혁신이 기대되네요. 다음에 더 흥미로운 실험을 해보겠습니다. 🚀

추가로 MCP 프로토콜이 궁금하신 분들은 아래 링크를 참고해보세요

https://github.com/modelcontextprotocol

 

Model Context Protocol

An open protocol that enables seamless integration between LLM applications and external data sources and tools. - Model Context Protocol

github.com

Flutter Cursor Rule 참고

https://cursorrule.com/posts/flutter-cursor-rules

 

Flutter Cursor Rules | CursorRule

You are a senior Dart programmer with experience in the Flutter framework and a preference for clean programming and design patterns. Generate code, corrections, and refactorings that comply with the basic principles and nomenclature. Dart General Guidelin

cursorrule.com