[Flutter 공부] 웹 개발. 크로스 플랫폼의 진정한 완성

반응형

네이티브급 성능의 웹 앱을 Flutter로 만들 수 있다고? 당신이 놓치고 있던 Flutter의 숨겨진 강점을 지금 공개합니다.

안녕하세요, 개발자 여러분. 오늘은 Flutter로 웹 애플리케이션을 개발하는 방법에 대해 이야기해보려고 합니다. Flutter는 이미 모바일 크로스 플랫폼 개발에서 강자로 자리잡았지만, 웹 개발에서의 Flutter는 여전히 많은 개발자들에게 미지의 영역입니다. 사실 많은 개발자들이 "Flutter로 웹 개발이 가능하긴 한가?"라는 의문을 가지고 있죠. React나 Vue, Angular 같은 전통적인 웹 프레임워크에 익숙한 개발자라면 Flutter 웹이 얼마나 실용적인지 의심스러울 수 있습니다. 그래서 오늘은 Flutter 웹의 장단점과 실제 활용법에 대해 파고들어 보겠습니다.

Flutter web

목차

Flutter 웹의 기본 개념과 아키텍처 {#flutter-web-basics}

Flutter 웹은 사실 모바일 앱을 위해 설계된 Flutter 프레임워크의 확장이라고 볼 수 있습니다. 당연히 모바일 중심으로 설계되었기에 웹으로 확장하는 과정에서 여러 제약이 있습니다. Flutter 웹은 Dart 코드를 HTML, CSS, JavaScript로 컴파일합니다. 이때 두 가지 렌더링 옵션을 제공하는데, HTML 요소를 사용하는 'HTML 렌더러'와 Canvas API를 활용하는 'CanvasKit 렌더러'입니다.

 

CanvasKit 렌더러는 Skia 그래픽 엔진(C++로 작성된)을 WebAssembly로 컴파일한 것으로, 모바일 Flutter와 가장 유사한 렌더링 결과를 보여줍니다. 하지만 초기 로딩 시간이 길고 번들 크기가 큰 단점이 있습니다. HTML 렌더러는 더 가볍지만 일부 복잡한 위젯이나 애니메이션에서 차이가 발생할 수 있어요.

 

Flutter 웹의 핵심 아키텍처를 간단히 요약하면:

  1. Flutter 프레임워크 (Dart로 작성)
  2. 웹 엔진 레이어 (Dart → JavaScript/WASM 브릿지)
  3. 렌더링 백엔드 (CanvasKit 또는 DOM/CSS)
  4. 브라우저 API 통합

개발 환경 설정 및 프로젝트 시작하기 {#dev-setup}

Flutter 웹 개발을 시작하기 전에 필요한 환경 설정을 살펴보겠습니다. 솔직히 설정은 크게 어렵지 않습니다.

요구사항 설명 비고
Flutter SDK 최신 버전 권장 (2.0 이상) 웹 지원이 안정화된 버전 필수
Dart SDK Flutter SDK와 함께 설치됨 별도 설치 불필요
IDE VS Code, Android Studio, IntelliJ Flutter/Dart 플러그인 설치 필요
브라우저 Chrome (디버깅용) 개발 시 크롬 권장
Git 버전 관리용 선택사항이지만 강력 권장

Flutter 웹 개발을 위한 프로젝트 설정은 간단합니다:

  1. Flutter SDK가 이미 설치되어 있다면, 웹 지원 활성화:
  2. flutter config --enable-web
  3. 기존 프로젝트에 웹 지원 추가:
  4. cd my_flutter_project flutter create .
  5. 새 프로젝트를 웹 지원과 함께 생성:
  6. flutter create my_web_project
  7. 웹 모드로 실행:
  8. flutter run -d chrome

반응형 레이아웃 구현하기 {#responsive-layout}

웹 개발에서 가장 중요한 부분 중 하나는 반응형 디자인입니다. 다양한 화면 크기에 대응해야 하는데, Flutter는 기본적으로 모바일 앱 개발을 위해 설계되었지만 다음과 같은 접근 방식으로 반응형 웹 UI를 구현할 수 있습니다.

  • LayoutBuilder 위젯 활용
  • MediaQuery로 화면 크기 감지
  • Flex, Expanded 위젯으로 유연한 레이아웃 구성
  • AspectRatio로 비율 유지
  • FittedBox로 컨텐츠 스케일링

다음은 간단한 반응형 레이아웃의 예시입니다:

LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth > 1200) {
      // 데스크탑 레이아웃
      return DesktopLayout();
    } else if (constraints.maxWidth > 600) {
      // 태블릿 레이아웃
      return TabletLayout();
    } else {
      // 모바일 레이아웃
      return MobileLayout();
    }
  },
)

📝 TIP: 효율적인 반응형 웹 개발

Flutter 웹에서 반응형 디자인을 구현할 때는 처음부터 디바이스 크기별 분기를 고려하세요. MediaQuery보다 LayoutBuilder를 사용하면 컨테이너 크기 기반으로 반응형 처리가 가능해 더 유연한 컴포넌트를 만들 수 있습니다. 또한 복잡한 UI의 경우 데스크탑, 태블릿, 모바일용 위젯을 별도로 구현하는 것이 유지보수에 유리합니다.

웹 최적화 기법과 성능 향상 전략 {#web-optimization}

Flutter 웹 애플리케이션을 개발할 때 가장 큰 도전 중 하나는 성능 최적화입니다. 특히 초기 로딩 속도와 인터랙션 반응성이 사용자 경험에 직접적인 영향을 미치죠. 솔직히 말해서, 기본 설정의 Flutter 웹 앱은 전통적인 웹 프레임워크로 만든 앱보다 초기 로딩 시간이 길 수 있습니다. 하지만 다음과 같은 최적화 기법을 적용하면 상당한 성능 향상을 기대할 수 있습니다.

 

첫째, 적절한 렌더러 선택이 중요합니다. CanvasKit은 더 일관된 시각적 결과를 제공하지만 초기 로딩 시간이 깁니다. HTML 렌더러는 더 빠른 초기 로딩을 제공하지만 일부 복잡한 UI에서 렌더링 차이가 발생할 수 있습니다. 타겟 사용자층과 애플리케이션 특성에 맞게 선택해야 합니다.

 

둘째, 효율적인 자산 관리가 필요합니다. 이미지와 폰트는 웹 앱의 크기를 크게 증가시킬 수 있으므로 압축과 최적화가 필수입니다. 특히 네트워크 이미지를 사용할 때는 캐싱 전략을 구현하는 것이 좋습니다.

 

셋째, 코드 분할(deferred loading)을 활용하여 필요한 코드만 초기에 로드하는 전략이 효과적입니다.

플러터 웹 vs 기존 웹 프레임워크 비교 {#frameworks-comparison}

Flutter 웹과 기존 웹 프레임워크 간의 객관적인 비교는 개발자가 기술 선택을 할 때 중요한 기준이 됩니다. 아래 표는 Flutter 웹과 React, Angular, Vue.js와 같은 주요 웹 프레임워크의 특성을 비교한 것입니다.

특성 Flutter 웹 React Angular Vue.js
러닝 커브 중간~높음 중간 높음 낮음
초기 로딩 속도 느림 빠름 중간 빠름
런타임 성능 우수 좋음 좋음 좋음
코드 재사용성 매우 높음 (모바일/데스크톱) 부분적 부분적 부분적
에코시스템 성장 중 매우 넓음 넓음 넓음
기업 지원 Google Facebook Google 커뮤니티 주도
SEO 친화적 제한적 SSR로 가능 SSR로 가능 SSR로 가능

실전 배포 및 호스팅 방법 {#deployment}

Flutter 웹 앱을 개발한 후에는 적절한 배포 방법을 선택해야 합니다. 기존 웹 애플리케이션과 마찬가지로 정적 호스팅 서비스나 클라우드 제공업체를 통해 배포할 수 있습니다.

배포 과정은 다음과 같습니다:

  1. 릴리스 빌드 생성
  2. flutter build web --release
  3. 빌드 최적화 옵션 적용
  4. flutter build web --web-renderer canvaskit --release # 또는 flutter build web --web-renderer html --release
  5. 배포 위치 선택
  • Firebase Hosting
  • GitHub Pages
  • Netlify
  • Vercel
  • AWS S3 + CloudFront
  • Google Cloud Storage
  1. 배포 자동화 구성
  • GitHub Actions
  • GitLab CI/CD
  • Jenkins
  • CircleCI
  1. 성능 모니터링 설정
  • Google Analytics
  • Firebase Performance Monitoring
  • Custom logging solutions

📝 TIP: SEO 최적화

Flutter 웹은 SPA(Single Page Application) 구조로 기본적으로 SEO에 불리합니다. 검색엔진 최적화가 중요한 프로젝트라면 pre-rendering 솔루션을 고려하거나, 메타데이터와 시맨틱 HTML 구조에 특별한 주의를 기울이세요. Flutter에서 직접적인 SSR(Server-Side Rendering)을 지원하지 않기 때문에, 주요 콘텐츠 페이지는 전통적인 웹 기술로 구현하고 Flutter를 웹 애플리케이션의 일부분으로만 사용하는 하이브리드 접근법도 고려할 수 있습니다.

📝 TIP: 디버깅 전략

Flutter 웹 개발에서 디버깅은 모바일 개발과 다른 접근이 필요합니다. Chrome DevTools와 Flutter DevTools를 함께 활용하세요. 특히 웹 전용 이슈는 Chrome의 Console과 Network 탭이 유용합니다. 렌더링 성능 문제는 Performance 탭에서 확인할 수 있으며, Flutter 위젯 관련 이슈는 Flutter DevTools의 Widget Inspector를 사용하세요. 디버깅 중에 "Preserve log" 옵션을 활성화하면 페이지 새로고침 시에도 로그가 유지됩니다.

Flutter 웹. 반응형 사이트 만들기

아래 코드는 Flutter 웹을 사용하여 간단한 반응형 포트폴리오 사이트를 구현한 예제입니다. 이 코드는 다양한 화면 크기에 적응하는 레이아웃과 웹에 최적화된 네비게이션을 포함하고 있습니다.

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

void main() {
  runApp(PortfolioApp());
}

class PortfolioApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '개발자 포트폴리오',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        fontFamily: 'Pretendard',
        textTheme: TextTheme(
          headline1: TextStyle(
            fontSize: 72.0,
            fontWeight: FontWeight.bold,
          ),
          headline2: TextStyle(
            fontSize: 36.0,
            fontWeight: FontWeight.bold,
          ),
          bodyText1: TextStyle(fontSize: 18.0),
        ),
      ),
      home: PortfolioHomePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

class PortfolioHomePage extends StatelessWidget {
  final List<Project> projects = [
    Project(
      title: '쇼핑몰 앱',
      description: 'Flutter로 개발한 크로스 플랫폼 쇼핑몰 애플리케이션',
      imageUrl: 'assets/project1.jpg',
      tags: ['Flutter', 'Firebase', 'GetX'],
    ),
    Project(
      title: '소셜 네트워킹 서비스',
      description: 'Flutter와 GraphQL을 활용한 실시간 소셜 미디어 플랫폼',
      imageUrl: 'assets/project2.jpg',
      tags: ['Flutter', 'GraphQL', 'AWS'],
    ),
    Project(
      title: '헬스케어 대시보드',
      description: '의료 데이터 시각화를 위한 반응형 웹 대시보드',
      imageUrl: 'assets/project3.jpg',
      tags: ['Flutter Web', 'REST API', 'Charts'],
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('홍길동의 포트폴리오'),
        elevation: 0,
        backgroundColor: Colors.white,
        foregroundColor: Colors.black87,
        actions: _buildAppBarActions(context),
      ),
      body: LayoutBuilder(
        builder: (context, constraints) {
          if (constraints.maxWidth > 1200) {
            return _buildDesktopLayout(context, constraints);
          } else if (constraints.maxWidth > 600) {
            return _buildTabletLayout(context, constraints);
          } else {
            return _buildMobileLayout(context, constraints);
          }
        },
      ),
    );
  }

  List<Widget> _buildAppBarActions(BuildContext context) {
    final isDesktop = MediaQuery.of(context).size.width > 800;

    if (isDesktop) {
      return [
        TextButton(
          onPressed: () {
            // 스크롤 섹션으로 이동
          },
          child: Text('소개'),
        ),
        TextButton(
          onPressed: () {
            // 스크롤 섹션으로 이동
          },
          child: Text('프로젝트'),
        ),
        TextButton(
          onPressed: () {
            // 스크롤 섹션으로 이동
          },
          child: Text('기술 스택'),
        ),
        TextButton(
          onPressed: () {
            // 스크롤 섹션으로 이동
          },
          child: Text('연락처'),
        ),
        SizedBox(width: 16),
      ];
    } else {
      return [
        IconButton(
          icon: Icon(Icons.menu),
          onPressed: () {
            Scaffold.of(context).openEndDrawer();
          },
        ),
      ];
    }
  }

  Widget _buildDesktopLayout(BuildContext context, BoxConstraints constraints) {
    return SingleChildScrollView(
      child: Column(
        children: [
          _buildHeroSection(context, large: true),
          _buildProjectsSection(context, columns: 3),
          _buildSkillsSection(context, horizontal: true),
          _buildContactSection(context),
        ],
      ),
    );
  }

  Widget _buildTabletLayout(BuildContext context, BoxConstraints constraints) {
    return SingleChildScrollView(
      child: Column(
        children: [
          _buildHeroSection(context, large: false),
          _buildProjectsSection(context, columns: 2),
          _buildSkillsSection(context, horizontal: true),
          _buildContactSection(context),
        ],
      ),
    );
  }

  Widget _buildMobileLayout(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          _buildHeroSection(context, large: false),
          _buildProjectsSection(context, columns: 1),
          _buildSkillsSection(context, horizontal: false),
          _buildContactSection(context),
        ],
      ),
    );
  }

  Widget _buildHeroSection(BuildContext context, {required bool large}) {
    return Container(
      height: large ? 600 : 400,
      width: double.infinity,
      color: Colors.blue.shade50,
      child: Center(
        child: Padding(
          padding: EdgeInsets.all(24.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CircleAvatar(
                radius: large ? 80 : 60,
                backgroundImage: AssetImage('assets/profile.jpg'),
              ),
              SizedBox(height: 24),
              Text(
                '홍길동',
                style: Theme.of(context).textTheme.headline2,
              ),
              SizedBox(height: 16),
              Text(
                '플러터 웹 & 모바일 개발자',
                style: Theme.of(context).textTheme.subtitle1,
              ),
              SizedBox(height: 24),
              Text(
                '크로스 플랫폼 앱 개발 3년 경력의 개발자입니다.\n사용자 경험을 중시하는 직관적인 UI/UX 설계가 강점입니다.',
                textAlign: TextAlign.center,
                style: Theme.of(context).textTheme.bodyText1,
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildProjectsSection(BuildContext context, {required int columns}) {
    return Padding(
      padding: EdgeInsets.all(24.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '프로젝트',
            style: Theme.of(context).textTheme.headline2,
          ),
          SizedBox(height: 32),
          GridView.builder(
            shrinkWrap: true,
            physics: NeverScrollableScrollPhysics(),
            gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: columns,
              crossAxisSpacing: 16,
              mainAxisSpacing: 16,
              childAspectRatio: 1.0,
            ),
            itemCount: projects.length,
            itemBuilder: (context, index) {
              return _buildProjectCard(context, projects[index]);
            },
          ),
        ],
      ),
    );
  }

  Widget _buildProjectCard(BuildContext context, Project project) {
    return Card(
      clipBehavior: Clip.antiAlias,
      elevation: 4,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          AspectRatio(
            aspectRatio: 16 / 9,
            child: Image.asset(
              project.imageUrl,
              fit: BoxFit.cover,
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  project.title,
                  style: Theme.of(context).textTheme.headline6,
                ),
                SizedBox(height: 8),
                Text(
                  project.description,
                  style: Theme.of(context).textTheme.bodyText2,
                ),
                SizedBox(height: 8),
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: project.tags.map((tag) {
                    return Chip(
                      label: Text(tag),
                      backgroundColor: Colors.blue.shade100,
                    );
                  }).toList(),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSkillsSection(BuildContext context, {required bool horizontal}) {
    final skills = {
      'Flutter': 0.9,
      'Dart': 0.85,
      'Firebase': 0.8,
      'React': 0.7,
      'Node.js': 0.65,
      'UI/UX': 0.75,
    };

    return Container(
      color: Colors.grey.shade100,
      padding: EdgeInsets.all(24),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '기술 스택',
            style: Theme.of(context).textTheme.headline2,
          ),
          SizedBox(height: 32),
          horizontal
              ? Row(
                  children: skills.entries
                      .map((e) => Expanded(
                            child: _buildSkillItem(context, e.key, e.value),
                          ))
                      .toList(),
                )
              : Column(
                  children: skills.entries
                      .map((e) => _buildSkillItem(context, e.key, e.value))
                      .toList(),
                ),
        ],
      ),
    );
  }

  Widget _buildSkillItem(BuildContext context, String skill, double level) {
    return Padding(
      padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
      child: Column(
        children: [
          Text(
            skill,
            style: Theme.of(context).textTheme.subtitle1,
          ),
          SizedBox(height: 8),
          LinearProgressIndicator(
            value: level,
            minHeight: 10,
            backgroundColor: Colors.grey.shade300,
            valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
          ),
          SizedBox(height: 4),
          Text('${(level * 100).toInt()}%'),
        ],
      ),
    );
  }

  Widget _buildContactSection(BuildContext context) {
    return Padding(
      padding: EdgeInsets.all(24),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '연락처',
            style: Theme.of(context).textTheme.headline2,
          ),
          SizedBox(height: 32),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              _buildContactItem(
                context,
                icon: Icons.email,
                title: '이메일',
                subtitle: 'example@gmail.com',
                onTap: () {
                  _launchURL('mailto:example@gmail.com');
                },
              ),
              SizedBox(width: 32),
              _buildContactItem(
                context,
                icon: Icons.phone,
                title: '전화번호',
                subtitle: '010-1234-5678',
                onTap: () {
                  _launchURL('tel:01012345678');
                },
              ),
              SizedBox(width: 32),
              _buildContactItem(
                context,
                icon: Icons.link,
                title: 'GitHub',
                subtitle: 'github.com/username',
                onTap: () {
                  _launchURL('https://github.com/username');
                },
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildContactItem(
    BuildContext context, {
    required IconData icon,
    required String title,
    required String subtitle,
    required VoidCallback onTap,
  }) {
    return InkWell(
      onTap: onTap,
      child: Column(
        children: [
          Icon(icon, size: 32),
          SizedBox(height: 8),
          Text(
            title,
            style: Theme.of(context).textTheme.subtitle1,
          ),
          SizedBox(height: 4),
          Text(
            subtitle,
            style: Theme.of(context).textTheme.caption,
          ),
        ],
      ),
    );
  }

  void _launchURL(String url) async {
    if (await canLaunch(url)) {
      await launch(url);
    }
  }
}

class Project {
  final String title;
  final String description;
  final String imageUrl;
  final List<String> tags;

  Project({
    required this.title,
    required this.description,
    required this.imageUrl,
    required this.tags,
  });
}

이 코드는 Flutter 웹에서 반응형 디자인을 구현하는 방법을 보여줍니다. 주요 특징은 다음과 같습니다:

  1. LayoutBuilder를 사용해 화면 크기에 따라 다른 레이아웃 구성
  2. 데스크탑, 태블릿, 모바일 각각에 맞는 레이아웃 제공
  3. GridView를 사용해 화면 크기에 따라 열 수 조정
  4. 외부 URL 열기 기능 구현 (url_launcher 패키지 사용)
  5. 컴포넌트 기반 구조로 코드 재사용성 향상

이 코드는 더 개선할 수 있는 부분이 많습니다. 예를 들어, 웹 성능 최적화를 위해 이미지 최적화, 지연 로딩 구현, 상태 관리 패턴 적용 등을 추가할 수 있습니다. 또한 실제 프로젝트에서는 라우팅, 다크 모드 지원, 애니메이션 추가 등으로 사용자 경험을 더욱 향상시킬 수 있습니다.

마무리

Flutter 웹 개발의 세계를 살펴보았습니다. 크로스 플랫폼 개발의 꿈을 좀 더 현실적으로 만들어주는 Flutter 웹은 분명 매력적인 선택지입니다. 하지만 모든 기술이 그렇듯 Flutter 웹도 만능은 아닙니다. 초기 로딩 시간이 길고, SEO 최적화가 제한적이며, 기존 웹 프레임워크 대비 웹 특화 기능이 부족한 점은 솔직히 인정해야 합니다.

 

그럼에도 불구하고 모바일, 데스크톱, 웹까지 하나의 코드베이스로 관리할 수 있다는 점은 개발 효율성 측면에서 무시할 수 없는 강점입니다. 특히 사내 관리 도구나 대시보드, 복잡한 인터랙션이 필요한 웹 애플리케이션에서는 Flutter 웹이 진가를 발휘할 수 있습니다. 궁극적으로 Flutter 웹의 선택은 프로젝트 요구사항, 팀의 기술 스택, 그리고 장기적인 유지보수 계획에 따라 달라질 것입니다. 완벽한 도구는 없지만, 적절한 상황에서 적절한 도구를 선택하는 것이 성공적인 개발의 핵심입니다.

 

Flutter 웹 여정을 시작하기 전에 이 글에서 다룬 장단점과 최적화 전략을 고려해보세요. 그리고 무엇보다 직접 작은 프로젝트로 테스트해보는 것이 가장 확실한 판단 기준이 될 것입니다.

 

2025.03.21 - [Developer/Flutter] - [Flutter 공부] 플랫폼 채널 - 네이티브 코드와 원활한 통신

 

[Flutter 공부] 플랫폼 채널 - 네이티브 코드와 원활한 통신

당신의 Flutter 앱이 네이티브 기능에 접근하지 못해 한계에 부딪혔나요? 플랫폼 채널이 그 답입니다.안녕하세요, 개발자 여러분! 오늘은 Flutter 개발에서 피할 수 없는 현실적 문제에 대해 이야기

dmoogi.tistory.com

 

2025.03.18 - [Developer/Flutter] - [Flutter 공부] 커스텀 페인팅(CustomPainter)

 

[Flutter 공부] 커스텀 페인팅(CustomPainter)

CustomPainter와 Canvas API를 다루려고 합니다. 처음에는 나도 CustomPaint 작업을 피했다. 문서화가 부실하고 디버깅이 어렵기 때문이다. 하지만 복잡한 차트, 게이지, 애니메이션이 필요하면 결국 Canvas

dmoogi.tistory.com

 

2025.03.18 - [Developer/Flutter] - [Flutter 공부] 복잡한 애니메이션 구현하기

 

[Flutter 공부] 복잡한 애니메이션 구현하기

안녕하세요, Flutter로 애니메이션을 구현할 때 대부분의 개발자들은 기본적인 페이드인/아웃이나 슬라이드 효과에만 머물러 있더라구요. 실제 경험을 토대로 보건대, 복잡한 애니메이션이 사용

dmoogi.tistory.com

 

 

 

Designed by JB FACTORY