🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

메모리와 성능 프로파일링 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2026. 2. 2. · 3 Views

메모리와 성능 프로파일링 완벽 가이드

Flutter 앱의 메모리 누수와 성능 병목을 찾아내는 프로파일링 기법을 다룹니다. DevTools 활용부터 CPU, GPU 분석, 배터리 최적화까지 실무에서 바로 적용할 수 있는 내용을 담았습니다.


목차

  1. DevTools_프로파일링
  2. 메모리_누수_탐지
  3. CPU_프로파일링
  4. GPU_렌더링_분석
  5. 프레임_타임_최적화
  6. 배터리_효율성

1. DevTools 프로파일링

김개발 씨는 최근 출시한 게임 앱에서 이상한 현상을 발견했습니다. 분명히 잘 돌아가던 앱이 플레이 시간이 길어질수록 점점 느려지는 것이었습니다.

"코드는 분명히 맞는데, 대체 뭐가 문제지?"

DevTools는 Flutter 앱의 내부를 들여다볼 수 있는 강력한 진단 도구입니다. 마치 의사가 엑스레이로 몸 속을 살펴보듯, DevTools는 앱의 메모리 사용량, CPU 점유율, 화면 렌더링 상태를 한눈에 보여줍니다.

이 도구를 제대로 활용하면 눈에 보이지 않던 성능 문제의 원인을 정확히 찾아낼 수 있습니다.

다음 코드를 살펴봅시다.

// DevTools와 함께 사용할 프로파일 모드 실행
// 터미널에서: flutter run --profile

import 'dart:developer' as developer;

class GameProfiler {
  // 특정 구간의 성능을 측정합니다
  void measureGameLoop() {
    // Timeline 이벤트 시작
    developer.Timeline.startSync('GameLoop');

    // 게임 로직 실행
    _updateGameState();
    _renderFrame();

    // Timeline 이벤트 종료
    developer.Timeline.finishSync();
  }

  void _updateGameState() {
    developer.log('Game state updated', name: 'GameProfiler');
  }

  void _renderFrame() {
    developer.log('Frame rendered', name: 'GameProfiler');
  }
}

김개발 씨는 입사 6개월 차 게임 개발자입니다. 그가 만든 모바일 게임은 출시 직후 좋은 반응을 얻었지만, 사용자 리뷰에 한 가지 불만이 자주 등장했습니다.

"30분 정도 플레이하면 렉이 심해져요." 김개발 씨는 자신의 코드를 몇 번이고 다시 살펴봤지만, 문제를 찾을 수 없었습니다. 선배 개발자 박시니어 씨가 다가와 물었습니다.

"DevTools로 프로파일링 해봤어요?" 김개발 씨는 고개를 갸웃했습니다. "DevTools요?

그게 뭔가요?" DevTools는 Flutter 팀이 공식적으로 제공하는 성능 분석 도구입니다. 쉽게 비유하자면, 자동차의 계기판과 같습니다.

속도계가 현재 속도를 보여주고, 연료 게이지가 남은 기름을 알려주듯이, DevTools는 앱이 얼마나 많은 메모리를 사용하고 있는지, CPU가 얼마나 바쁘게 일하고 있는지를 실시간으로 보여줍니다. DevTools가 없던 시절에는 어땠을까요?

개발자들은 print 문을 여기저기 찍어가며 어느 부분이 느린지 추측해야 했습니다. 마치 캄캄한 방에서 손으로 더듬어 물건을 찾는 것과 같았습니다.

문제를 찾는 데만 몇 시간, 심지어 며칠이 걸리기도 했습니다. 바로 이런 어려움을 해결하기 위해 DevTools가 탄생했습니다.

이 도구를 사용하면 앱의 모든 동작을 타임라인으로 기록하고, 어느 함수가 얼마나 오래 걸리는지 정확히 측정할 수 있습니다. 더 이상 추측에 의존할 필요가 없습니다.

위의 코드를 살펴보겠습니다. 먼저 dart:developer 패키지를 임포트합니다.

이 패키지는 DevTools와 통신하는 기능을 제공합니다. Timeline.startSyncfinishSync 사이에 있는 코드의 실행 시간이 DevTools의 타임라인 탭에 기록됩니다.

중요한 점은 프로파일링을 할 때 반드시 프로파일 모드로 앱을 실행해야 한다는 것입니다. 디버그 모드에서는 각종 검사 기능이 켜져 있어서 실제 성능보다 느리게 측정됩니다.

반대로 릴리스 모드에서는 DevTools 연결 자체가 불가능합니다. 프로파일 모드는 이 둘 사이의 균형을 맞춰줍니다.

실제 현업에서는 어떻게 활용할까요? 게임 개발에서는 매 프레임마다 실행되는 게임 루프의 성능이 특히 중요합니다.

위 코드처럼 게임 루프 전체를 Timeline으로 감싸면, DevTools에서 각 프레임이 얼마나 걸리는지 한눈에 확인할 수 있습니다. 16밀리초를 넘어가면 60fps를 유지할 수 없으므로, 이 수치를 기준으로 최적화해야 합니다.

주의할 점도 있습니다. Timeline 이벤트를 너무 많이 만들면 오히려 측정 자체가 성능에 영향을 줄 수 있습니다.

처음에는 큰 단위로 측정하고, 문제 구간을 좁혀나가면서 점점 세밀하게 측정하는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언대로 DevTools를 열어본 김개발 씨는 깜짝 놀랐습니다. 메모리 사용량이 시간이 지날수록 계속 증가하고 있었던 것입니다.

드디어 문제의 실마리를 찾은 것이었습니다.

실전 팁

💡 - DevTools는 Chrome 브라우저에서 열리며, flutter run 후 터미널에 표시되는 URL로 접속합니다

  • 프로파일링 결과는 JSON으로 내보내서 나중에 다시 분석하거나 팀원과 공유할 수 있습니다

2. 메모리 누수 탐지

DevTools를 통해 메모리가 계속 증가하는 것을 확인한 김개발 씨는 다음 단계로 넘어갔습니다. "메모리가 새고 있다는 건 알겠는데, 대체 어디서 새는 걸까요?"

메모리 누수란 더 이상 사용하지 않는 객체가 메모리에서 해제되지 않고 계속 남아있는 현상입니다. 마치 수도꼭지를 잠그지 않아 물이 계속 새는 것과 같습니다.

조금씩 새는 물이 모이고 모여 결국 욕조를 넘치게 하듯, 작은 메모리 누수가 쌓여 앱을 멈추게 만듭니다.

다음 코드를 살펴봅시다.

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

// 잘못된 예: 메모리 누수가 발생하는 코드
class LeakyGameScreen extends StatefulWidget {
  @override
  _LeakyGameScreenState createState() => _LeakyGameScreenState();
}

class _LeakyGameScreenState extends State<LeakyGameScreen> {
  Timer? _gameTimer;
  List<Sprite> _sprites = [];

  @override
  void initState() {
    super.initState();
    // 문제: 타이머가 위젯 해제 후에도 계속 실행됩니다
    _gameTimer = Timer.periodic(Duration(milliseconds: 16), (_) {
      _updateGame();
    });
  }

  void _updateGame() {
    // 문제: 스프라이트를 계속 추가만 하고 제거하지 않습니다
    _sprites.add(Sprite());
    setState(() {});
  }

  // 수정: dispose에서 반드시 정리해야 합니다
  @override
  void dispose() {
    _gameTimer?.cancel();  // 타이머 정리
    _sprites.clear();       // 리스트 정리
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Container();
}

class Sprite {}

김개발 씨는 메모리 탭을 유심히 살펴보기 시작했습니다. 그래프가 톱니 모양으로 오르락내리락하는 것이 아니라, 계단처럼 계속 올라가기만 했습니다.

이것은 전형적인 메모리 누수의 신호였습니다. 박시니어 씨가 설명을 이어갔습니다.

"메모리 누수는 마치 호텔 객실 같아요. 손님이 체크아웃했는데 방 청소를 안 하면 어떻게 될까요?

새 손님이 들어올 방이 점점 줄어들겠죠. 결국 호텔 전체가 마비됩니다." Dart는 가비지 컬렉터라는 자동 청소 시스템을 가지고 있습니다.

더 이상 참조되지 않는 객체를 자동으로 찾아서 메모리에서 해제해줍니다. 그런데 문제는 "더 이상 참조되지 않는"이라는 조건입니다.

어딘가에서 여전히 참조하고 있다면, 가비지 컬렉터는 그 객체를 건드리지 않습니다. 위의 코드에서 가장 흔한 두 가지 메모리 누수 패턴을 볼 수 있습니다.

첫 번째는 Timer입니다. Timer.periodic으로 생성한 타이머는 취소하지 않으면 위젯이 사라진 후에도 계속 실행됩니다.

더 심각한 것은 타이머의 콜백 함수가 State 객체를 참조하고 있어서, State 객체도 메모리에서 해제되지 않는다는 점입니다. 두 번째 문제는 무한히 커지는 리스트입니다.

게임에서 적 캐릭터나 총알을 생성할 때, 화면 밖으로 나간 객체를 제거하지 않으면 리스트는 계속 커집니다. 30분 플레이 동안 수만 개의 객체가 쌓일 수 있습니다.

DevTools의 메모리 탭에서는 힙 스냅샷을 찍을 수 있습니다. 현재 메모리에 어떤 객체들이 얼마나 있는지 사진처럼 기록하는 것입니다.

두 시점의 스냅샷을 비교하면 어떤 종류의 객체가 비정상적으로 증가했는지 알 수 있습니다. 실무에서 메모리 누수를 방지하는 핵심은 dispose 메서드입니다.

StatefulWidget이 화면에서 사라질 때 Flutter는 자동으로 dispose를 호출합니다. 이 메서드 안에서 Timer 취소, StreamSubscription 해제, AnimationController dispose 등 모든 정리 작업을 수행해야 합니다.

또 다른 흔한 실수는 클로저 안에서 BuildContext를 캡처하는 것입니다. 비동기 작업이 완료된 후 context를 사용하면, 위젯이 이미 사라졌는데도 context가 연결된 State를 메모리에 붙들고 있을 수 있습니다.

김개발 씨는 자신의 코드를 다시 살펴봤습니다. 아니나 다를까, 게임 루프 타이머를 dispose에서 취소하지 않고 있었습니다.

또한 화면 밖으로 나간 적 캐릭터들도 리스트에서 제거하지 않고 있었습니다. "이거였군요!" 문제의 원인을 찾은 김개발 씨는 dispose 메서드를 추가하고, 화면 밖으로 나간 객체를 제거하는 로직을 구현했습니다.

다시 테스트해보니 메모리 그래프가 안정적인 톱니 모양을 그리기 시작했습니다.

실전 팁

💡 - DevTools의 "Diff snapshots" 기능으로 두 시점의 메모리 상태를 비교하여 누수 객체를 찾을 수 있습니다

  • flutter_hooks 패키지를 사용하면 useEffect의 cleanup 함수로 리소스 정리를 더 명확하게 관리할 수 있습니다

3. CPU 프로파일링

메모리 누수를 해결한 김개발 씨는 한숨 돌렸습니다. 하지만 테스트 중에 또 다른 문제를 발견했습니다.

특정 상황에서 프레임이 뚝뚝 끊기는 것이었습니다. "메모리는 이제 괜찮은데, 왜 여전히 버벅거리죠?"

CPU 프로파일링은 앱의 연산 처리 과정을 분석하는 기법입니다. 어떤 함수가 얼마나 오래 실행되는지, 어디서 병목이 발생하는지를 정확히 측정합니다.

마치 마라톤 선수의 구간별 기록을 측정하여 어느 구간에서 속도가 떨어지는지 파악하는 것과 같습니다.

다음 코드를 살펴봅시다.

import 'dart:developer' as developer;
import 'dart:isolate';

class GameEngine {
  List<Enemy> enemies = [];

  // 문제: 매 프레임마다 모든 적과 충돌 검사 (O(n²))
  void checkCollisionsSlow(Player player) {
    developer.Timeline.startSync('CollisionCheck_Slow');
    for (var enemy in enemies) {
      for (var other in enemies) {
        if (enemy != other) {
          _checkPairCollision(enemy, other);
        }
      }
      _checkPlayerCollision(player, enemy);
    }
    developer.Timeline.finishSync();
  }

  // 개선: 공간 분할로 충돌 검사 최적화 (O(n))
  void checkCollisionsFast(Player player) {
    developer.Timeline.startSync('CollisionCheck_Fast');
    final grid = SpatialHashGrid();
    for (var enemy in enemies) {
      grid.insert(enemy);
    }
    for (var enemy in enemies) {
      final nearby = grid.getNearby(enemy.position);
      for (var other in nearby) {
        _checkPairCollision(enemy, other);
      }
    }
    developer.Timeline.finishSync();
  }

  // 무거운 연산은 Isolate로 분리
  Future<void> heavyCalculation() async {
    final result = await Isolate.run(() {
      // AI 경로 탐색 등 무거운 연산
      return _calculateAIPath();
    });
  }

  void _checkPairCollision(Enemy a, Enemy b) {}
  void _checkPlayerCollision(Player p, Enemy e) {}
  static List<dynamic> _calculateAIPath() => [];
}

class Enemy { Vector2 position = Vector2.zero(); }
class Player {}
class Vector2 { static Vector2 zero() => Vector2(); }
class SpatialHashGrid {
  void insert(Enemy e) {}
  List<Enemy> getNearby(Vector2 pos) => [];
}

박시니어 씨가 DevTools의 CPU Profiler 탭을 열었습니다. "버벅거림의 원인은 보통 두 가지예요.

하나는 무거운 연산이 메인 스레드에서 돌아가는 것이고, 다른 하나는 비효율적인 알고리즘이에요." CPU 프로파일링을 이해하려면 먼저 콜 스택을 알아야 합니다. 프로그램이 실행될 때, 함수가 다른 함수를 호출하면 마치 접시를 쌓듯이 호출 기록이 쌓입니다.

CPU 프로파일러는 이 쌓인 접시들을 일정 간격으로 사진 찍어서, 어떤 함수가 가장 오래 실행 중이었는지 통계를 냅니다. DevTools의 CPU Profiler를 사용하면 플레임 그래프라는 시각화를 볼 수 있습니다.

가로축은 시간이고, 세로축은 콜 스택의 깊이입니다. 넓은 막대일수록 오래 실행된 함수입니다.

불꽃 모양처럼 생겼다고 해서 플레임 그래프라고 부릅니다. 위 코드에서 checkCollisionsSlow 함수를 보겠습니다.

이중 반복문으로 모든 적 쌍을 검사합니다. 적이 100마리면 100 x 100 = 10,000번의 비교가 필요합니다.

적이 500마리로 늘면 250,000번입니다. 이런 O(n²) 알고리즘은 규모가 커지면 급격히 느려집니다.

반면 checkCollisionsFast 함수는 공간 분할 기법을 사용합니다. 게임 화면을 격자로 나누고, 각 적이 어느 격자에 있는지 기록합니다.

충돌 검사할 때는 같은 격자나 인접한 격자에 있는 적만 비교합니다. 멀리 떨어진 적끼리는 어차피 충돌할 수 없으니까요.

이렇게 하면 적이 아무리 많아도 검사 횟수가 선형적으로만 증가합니다. 또 다른 중요한 기법은 Isolate입니다.

Dart는 기본적으로 단일 스레드로 동작합니다. UI 그리기, 이벤트 처리, 게임 로직이 모두 하나의 스레드에서 실행됩니다.

만약 AI 경로 탐색처럼 무거운 연산이 오래 걸리면, 그동안 화면이 멈춥니다. Isolate는 별도의 메모리 공간을 가진 독립된 실행 환경입니다.

마치 다른 컴퓨터에서 계산을 시키고 결과만 받아오는 것과 비슷합니다. Isolate.run을 사용하면 무거운 연산을 백그라운드에서 처리하고, 그동안 메인 스레드는 자유롭게 UI를 업데이트할 수 있습니다.

주의할 점은 Isolate 간에 데이터를 주고받는 데도 비용이 든다는 것입니다. 너무 자주, 너무 많은 데이터를 주고받으면 오히려 성능이 나빠질 수 있습니다.

일반적으로 50밀리초 이상 걸리는 연산에 Isolate를 적용하는 것이 좋습니다. 김개발 씨는 CPU Profiler로 자신의 게임을 분석했습니다.

예상대로 충돌 검사 함수가 가장 넓은 막대를 차지하고 있었습니다. 적 캐릭터가 많아지는 보스 스테이지에서 특히 심했습니다.

공간 분할 알고리즘을 적용하자 프레임 드랍이 사라졌습니다.

실전 팁

💡 - Timeline.startSync에 같은 이름을 사용하면 DevTools에서 해당 구간만 필터링해서 볼 수 있습니다

  • compute 함수는 Isolate.run의 간편한 버전으로, 간단한 백그라운드 연산에 적합합니다

4. GPU 렌더링 분석

CPU 최적화를 마친 김개발 씨는 자신감이 붙었습니다. 그런데 테스트 기기를 저사양 폰으로 바꾸자 다시 문제가 생겼습니다.

"코드는 빠른데, 왜 화면 그리기가 느리죠?"

GPU 렌더링 분석은 화면에 그래픽을 그리는 과정을 최적화하는 기법입니다. Flutter는 Skia라는 그래픽 엔진을 통해 GPU에게 그리기 명령을 내립니다.

마치 화가가 그림을 그리듯, GPU도 복잡한 그림일수록 더 오래 걸립니다. 불필요하게 복잡한 그리기 명령을 줄이는 것이 핵심입니다.

다음 코드를 살펴봅시다.

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

// 문제: 불필요한 클리핑과 그림자로 GPU 과부하
class HeavyGameCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(20),
        boxShadow: [
          BoxShadow(
            blurRadius: 30,  // 큰 블러는 GPU 집약적
            spreadRadius: 10,
          ),
        ],
      ),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(20),
        child: _buildContent(),  // 클리핑은 saveLayer 호출
      ),
    );
  }

  Widget _buildContent() => Container();
}

// 개선: RepaintBoundary로 독립적인 레이어 분리
class OptimizedGameUI extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        // 배경은 거의 변하지 않음 -> 별도 레이어
        RepaintBoundary(
          child: GameBackground(),
        ),
        // 캐릭터는 자주 움직임 -> 별도 레이어
        RepaintBoundary(
          child: PlayerSprite(),
        ),
        // UI는 가끔 업데이트 -> 별도 레이어
        RepaintBoundary(
          child: GameHUD(),
        ),
      ],
    );
  }
}

class GameBackground extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Container(color: Colors.blue);
}

class PlayerSprite extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Container();
}

class GameHUD extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Container();
}

박시니어 씨가 DevTools의 Performance Overlay를 켰습니다. 화면 위에 두 개의 그래프가 나타났습니다.

"위쪽 그래프가 GPU 렌더링 시간이에요. 초록색 선을 넘으면 프레임 드랍이 발생한다는 뜻이에요." Flutter의 렌더링 파이프라인을 이해해봅시다.

CPU가 위젯 트리를 분석하고 어떻게 그릴지 계획을 세웁니다. 이 계획을 레이어 트리라고 부릅니다.

그 다음 GPU가 이 계획대로 실제 픽셀을 그립니다. CPU와 GPU는 병렬로 일합니다.

CPU가 다음 프레임을 계획하는 동안 GPU는 현재 프레임을 그립니다. 문제는 GPU에게 너무 복잡한 일을 시킬 때 발생합니다.

위 코드에서 BoxShadow를 보세요. 그림자는 아름답지만, 블러 반경이 클수록 GPU가 더 많은 계산을 해야 합니다.

특히 저사양 기기에서는 큰 그림자 하나가 전체 성능을 떨어뜨릴 수 있습니다. 또 다른 무거운 연산은 클리핑입니다.

ClipRRect, ClipPath 같은 위젯은 내부적으로 saveLayer를 호출합니다. 이것은 새로운 그리기 영역을 만들고, 나중에 합성하는 작업입니다.

필요할 때는 써야 하지만, 무분별하게 사용하면 성능이 급격히 떨어집니다. DevTools의 Debug Paint 기능을 켜면 saveLayer가 호출되는 위젯에 빨간 테두리가 표시됩니다.

예상치 못한 곳에서 빨간 테두리가 보인다면 그것이 성능 병목일 가능성이 높습니다. RepaintBoundary는 렌더링 최적화의 핵심 도구입니다.

이 위젯으로 감싼 부분은 별도의 레이어로 분리됩니다. 마치 애니메이션 셀화에서 배경과 캐릭터를 따로 그리는 것과 같습니다.

캐릭터만 움직여도 배경을 다시 그릴 필요가 없습니다. 게임에서는 이런 분리가 특히 중요합니다.

배경, 캐릭터, UI는 각각 다른 주기로 업데이트됩니다. 배경은 스크롤할 때만, 캐릭터는 매 프레임, UI는 점수가 바뀔 때만 다시 그리면 됩니다.

RepaintBoundary로 이들을 분리하면 불필요한 다시 그리기를 방지할 수 있습니다. 주의할 점은 RepaintBoundary를 너무 많이 사용하면 오히려 역효과가 날 수 있다는 것입니다.

각 레이어는 메모리를 차지하고, 레이어를 합성하는 것도 비용이 듭니다. 실제로 측정해보면서 적절한 균형을 찾아야 합니다.

김개발 씨는 Debug Paint를 켜고 게임 화면을 살펴봤습니다. 생각보다 많은 곳에서 빨간 테두리가 보였습니다.

불필요한 클리핑을 제거하고, 배경과 캐릭터를 RepaintBoundary로 분리하자 저사양 기기에서도 부드럽게 동작했습니다.

실전 팁

💡 - flutter run --profile --trace-skia 옵션으로 Skia 레벨의 상세한 렌더링 정보를 얻을 수 있습니다

  • Opacity 위젯 대신 색상의 alpha 값을 조절하면 saveLayer 호출을 피할 수 있습니다

5. 프레임 타임 최적화

김개발 씨의 게임은 이제 대부분의 상황에서 부드럽게 동작했습니다. 하지만 보스전처럼 화면에 많은 것이 벌어지는 순간에는 여전히 가끔 끊김이 있었습니다.

"평소에는 괜찮은데, 딱 그 순간만 버벅거려요."

프레임 타임 최적화는 각 프레임이 정해진 시간 안에 완료되도록 보장하는 기법입니다. 60fps를 유지하려면 한 프레임이 16.67밀리초 안에 끝나야 합니다.

마치 레스토랑에서 모든 요리가 정해진 시간 안에 나와야 손님이 기다리지 않는 것처럼, 모든 프레임이 제 시간에 완료되어야 화면이 부드럽습니다.

다음 코드를 살펴봅시다.

import 'package:flutter/scheduler.dart';
import 'package:flutter/foundation.dart';

class FrameOptimizedGame {
  static const int targetFps = 60;
  static const Duration frameTime = Duration(milliseconds: 16);

  // 프레임 시간 측정 및 적응형 품질 조절
  int _frameCount = 0;
  int _droppedFrames = 0;
  QualityLevel _quality = QualityLevel.high;

  void startGameLoop() {
    SchedulerBinding.instance.addPersistentFrameCallback((timeStamp) {
      final stopwatch = Stopwatch()..start();

      // 게임 업데이트 (품질에 따라 작업량 조절)
      _updateGame(_quality);

      stopwatch.stop();
      _frameCount++;

      // 프레임 시간이 초과되면 품질 낮춤
      if (stopwatch.elapsed > frameTime) {
        _droppedFrames++;
        _adjustQuality();
      }

      // 성능 로깅 (디버그 모드에서만)
      if (kDebugMode && _frameCount % 60 == 0) {
        final dropRate = _droppedFrames / _frameCount * 100;
        debugPrint('Frame drop rate: ${dropRate.toStringAsFixed(1)}%');
      }
    });
  }

  void _adjustQuality() {
    // 최근 드랍률에 따라 품질 조절
    final dropRate = _droppedFrames / _frameCount;
    if (dropRate > 0.1 && _quality != QualityLevel.low) {
      _quality = QualityLevel.values[_quality.index + 1];
      debugPrint('Quality reduced to $_quality');
    }
  }

  void _updateGame(QualityLevel quality) {
    // 품질에 따라 파티클 수, 그림자 등 조절
  }
}

enum QualityLevel { high, medium, low }

박시니어 씨가 DevTools의 Frame Chart를 가리켰습니다. "이 막대 하나하나가 한 프레임이에요.

초록색이면 16ms 안에 끝난 거고, 빨간색이면 초과한 거예요. 빨간 막대가 연속으로 나타나면 사용자가 끊김을 느끼게 됩니다." 60fps라는 숫자의 의미를 생각해봅시다.

1초에 60장의 그림을 그린다는 뜻입니다. 따라서 한 장의 그림을 그리는 데 1000ms / 60 = 약 16.67ms가 주어집니다.

이 시간 안에 게임 로직 업데이트, 위젯 빌드, GPU 렌더링까지 모두 완료해야 합니다. 문제는 항상 같은 양의 일이 주어지지 않는다는 점입니다.

평소에는 적 10마리를 처리하면 되지만, 보스전에서는 100마리의 적과 200개의 총알, 50개의 폭발 효과를 처리해야 할 수 있습니다. 일의 양은 10배가 됐는데 주어진 시간은 똑같습니다.

위 코드에서 구현한 적응형 품질 시스템이 이 문제를 해결합니다. 매 프레임의 처리 시간을 측정하고, 16ms를 초과하는 프레임이 많아지면 자동으로 품질을 낮춥니다.

파티클 효과를 줄이거나, 그림자를 단순화하거나, 먼 거리의 적을 생략하는 식입니다. SchedulerBinding.addPersistentFrameCallback은 Flutter의 프레임 콜백을 등록하는 방법입니다.

이 콜백은 매 프레임마다 호출되며, vsync 신호에 동기화됩니다. 게임 루프를 구현할 때 Timer.periodic보다 이 방법이 더 정확합니다.

실제 상용 게임에서는 더 정교한 품질 조절 시스템을 사용합니다. 단순히 품질을 낮추는 것뿐 아니라, 시간이 지나 여유가 생기면 다시 품질을 올립니다.

또한 사용자에게 품질 설정을 제공하여 직접 선택할 수 있게 합니다. jank라는 용어도 알아두면 좋습니다.

연속된 프레임 드랍으로 인한 눈에 띄는 끊김 현상을 말합니다. 단일 프레임 드랍은 거의 인지되지 않지만, 2-3개가 연속되면 사용자가 불쾌감을 느낍니다.

DevTools의 jank 탐지 기능은 이런 연속 드랍을 자동으로 찾아서 표시해줍니다. 주의할 점은 프레임 타임 측정 자체도 시간을 소비한다는 것입니다.

너무 상세한 측정은 오히려 성능에 영향을 줄 수 있습니다. 릴리스 빌드에서는 kDebugMode 체크를 통해 불필요한 측정 코드를 건너뛰어야 합니다.

김개발 씨는 적응형 품질 시스템을 구현했습니다. 보스전에서 폭발이 많이 일어날 때 자동으로 파티클 수가 줄어들고, 상황이 진정되면 다시 늘어났습니다.

사용자들은 끊김 없이 게임을 즐길 수 있게 되었습니다.

실전 팁

💡 - DevTools의 "Track widget builds" 옵션으로 어떤 위젯이 불필요하게 자주 빌드되는지 확인할 수 있습니다

  • 프레임 드랍의 원인이 UI인지 래스터인지 구분하여 CPU 또는 GPU 최적화에 집중하세요

6. 배터리 효율성

게임이 드디어 완성되었습니다. 부드럽고 아름다운 그래픽, 재미있는 게임플레이.

하지만 출시 후 또 다른 불만이 들어왔습니다. "게임 진짜 재밌는데, 배터리가 너무 빨리 닳아요."

배터리 효율성은 모바일 앱 개발에서 종종 간과되는 중요한 요소입니다. 아무리 멋진 앱이라도 배터리를 빨리 소모하면 사용자들은 떠나갑니다.

CPU와 GPU 사용을 최소화하고, 불필요한 작업을 줄이며, 앱이 백그라운드에 있을 때는 모든 활동을 멈추는 것이 핵심입니다.

다음 코드를 살펴봅시다.

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

class BatteryEfficientGame extends StatefulWidget {
  @override
  _BatteryEfficientGameState createState() => _BatteryEfficientGameState();
}

class _BatteryEfficientGameState extends State<BatteryEfficientGame>
    with WidgetsBindingObserver {
  bool _isRunning = true;
  late GameLoop _gameLoop;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _gameLoop = GameLoop();
  }

  // 앱 생명주기 변화 감지
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.paused:
        // 백그라운드로 갈 때 모든 활동 중지
        _pauseGame();
        break;
      case AppLifecycleState.resumed:
        // 포그라운드로 돌아올 때 재개
        _resumeGame();
        break;
      case AppLifecycleState.inactive:
        // 전화 등 잠시 중단 시
        _gameLoop.reduceFrameRate();
        break;
      default:
        break;
    }
  }

  void _pauseGame() {
    _isRunning = false;
    _gameLoop.stop();
    // 센서, 위치 서비스 등도 중지
  }

  void _resumeGame() {
    _isRunning = true;
    _gameLoop.start();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _gameLoop.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Container();
}

class GameLoop {
  void start() {}
  void stop() {}
  void reduceFrameRate() {}
  void dispose() {}
}

박시니어 씨가 마지막 조언을 해주었습니다. "성능 최적화의 최종 목표는 사실 배터리 효율성이에요.

CPU와 GPU를 적게 쓸수록 배터리도 오래 가고, 발열도 줄어듭니다." 배터리 소모의 주요 원인을 이해해봅시다. 첫째, CPU 사용입니다.

계산을 많이 할수록 전기를 많이 씁니다. 둘째, GPU 사용입니다.

화면을 자주 다시 그릴수록 전기가 듭니다. 셋째, 네트워크 통신입니다.

데이터를 주고받을 때 라디오 모듈이 전력을 소비합니다. 넷째, 센서입니다.

자이로스코프, 가속도계 등을 사용하면 추가 전력이 듭니다. 위 코드에서 가장 중요한 부분은 WidgetsBindingObserver입니다.

이것을 통해 앱이 백그라운드로 갔는지, 다시 포그라운드로 돌아왔는지 감지할 수 있습니다. 사용자가 홈 버튼을 누르거나 다른 앱으로 전환하면 paused 상태가 됩니다.

백그라운드에서 게임이 계속 돌아가면 어떻게 될까요? 사용자는 게임을 하고 있지 않은데 CPU와 GPU가 쉬지 않고 일합니다.

화면에 아무것도 보이지 않는데도 60번 프레임을 그립니다. 이것은 순수한 전력 낭비입니다.

inactive 상태도 주목해야 합니다. 전화가 오거나 알림창을 내리면 이 상태가 됩니다.

완전히 멈출 필요는 없지만, 프레임 레이트를 낮추거나 애니메이션을 간소화하는 것이 좋습니다. 게임이 아닌 일반 앱에서도 배터리 효율은 중요합니다.

애니메이션이 필요 없는 화면에서는 AnimationController를 멈춰야 합니다. 리스트를 무한 스크롤하면서 이미지를 계속 로드하는 것도 좋지 않습니다.

캐싱을 활용하여 네트워크 요청을 최소화해야 합니다. 다크 모드도 배터리 효율에 도움이 됩니다.

OLED 화면에서 검은색 픽셀은 아예 꺼져 있습니다. 게임의 UI나 메뉴를 어두운 테마로 디자인하면 실제로 배터리 소모가 줄어듭니다.

프로파일링 관점에서는 앱을 장시간 실행한 후의 배터리 소모를 측정해야 합니다. 10분 플레이 후 몇 퍼센트가 줄었는지 기록하고, 최적화 전후를 비교합니다.

Android에서는 Battery Historian 도구를, iOS에서는 Instruments의 Energy Log를 사용할 수 있습니다. 김개발 씨는 모든 것을 정리했습니다.

DevTools로 메모리 누수를 잡고, CPU 프로파일링으로 알고리즘을 개선하고, GPU 렌더링을 최적화하고, 프레임 타임을 관리하고, 마지막으로 배터리 효율까지 챙겼습니다. 몇 주 후, 앱 스토어 리뷰에 새로운 댓글이 달렸습니다.

"업데이트 이후로 끊김도 없고 배터리도 오래 가요. 개발자분 최고!" 김개발 씨는 흐뭇하게 웃었습니다.

프로파일링의 힘을 실감한 순간이었습니다.

실전 팁

💡 - 개발 중에는 기기를 충전하지 않은 상태로 테스트하여 실제 배터리 소모를 체감하세요

  • 앱이 백그라운드로 갈 때 진행 상태를 저장해두면 돌아왔을 때 자연스럽게 이어서 할 수 있습니다

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#DevTools#Profiling#MemoryLeak#Performance

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.