🤖

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

⚠️

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

이미지 로딩 중...

StreamProvider로 실시간 타이머 만들기 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 11. · 8 Views

StreamProvider로 실시간 타이머 만들기 완벽 가이드

Riverpod의 StreamProvider를 활용하여 1초마다 업데이트되는 실시간 타이머를 구현하는 방법을 배웁니다. Dart의 Stream 개념부터 시작해 타이머 시작/정지 기능까지 단계별로 알아봅니다.


목차

  1. Dart_Stream_기본_개념
  2. StreamProvider_선언
  3. 1초마다_업데이트되는_타이머
  4. 타이머_시작_정지_구현
  5. 스톱워치_UI_만들기
  6. 전체_코드와_실행_결과

1. Dart Stream 기본 개념

어느 날 이지훈 개발자는 앱에 실시간 시계를 넣어달라는 요청을 받았습니다. "1초마다 화면이 업데이트되어야 하는데, 어떻게 구현하지?" 고민하던 그에게 팀장님이 조언했습니다.

"Stream을 사용해보면 어떨까요?"

Stream은 시간의 흐름에 따라 연속적으로 데이터를 전달하는 통로입니다. 마치 물이 흐르는 강처럼, 데이터가 계속해서 흘러옵니다.

이를 통해 실시간으로 변하는 값을 효율적으로 처리할 수 있습니다.

다음 코드를 살펴봅시다.

// Stream 기본 예제: 1초마다 숫자를 방출합니다
Stream<int> counterStream() async* {
  int count = 0;
  while (true) {
    await Future.delayed(Duration(seconds: 1));
    yield count;  // yield로 값을 방출합니다
    count++;
  }
}

// Stream을 구독하여 값을 받습니다
void main() {
  counterStream().listen((value) {
    print('현재 값: $value');
  });
}

이지훈 씨는 Flutter 개발 6개월 차 개발자입니다. 오늘 프로젝트 매니저로부터 흥미로운 요청을 받았습니다.

"앱 상단에 실시간으로 업데이트되는 시계를 넣어주세요." 간단해 보였지만, 곧 문제에 직면했습니다. "1초마다 화면을 업데이트하려면 어떻게 해야 하지?

setState를 1초마다 호출해야 하나?" 팀장 최선배 님이 그의 고민을 알아챘습니다. "지훈 씨, Stream이라는 개념을 알고 계신가요?" 그렇다면 Stream이란 정확히 무엇일까요?

쉽게 비유하자면, Stream은 마치 컨베이어 벨트와 같습니다. 공장에서 컨베이어 벨트 위로 제품이 계속해서 흘러나오듯이, Stream도 시간의 흐름에 따라 데이터가 계속해서 흘러나옵니다.

여러분은 그 컨베이어 벨트 옆에 서서 제품을 하나씩 받아 처리하면 됩니다. Stream이 없던 시절에는 어땠을까요?

개발자들은 Timer를 직접 관리하고, setState를 반복적으로 호출해야 했습니다. 코드가 복잡해지고, 메모리 누수도 쉽게 발생했습니다.

더 큰 문제는 여러 곳에서 같은 타이머를 사용하려면 복잡한 상태 관리가 필요했다는 점입니다. 바로 이런 문제를 해결하기 위해 Stream이 등장했습니다.

Stream을 사용하면 시간에 따라 변하는 데이터를 깔끔하게 처리할 수 있습니다. 또한 여러 곳에서 동시에 구독할 수 있어 코드 재사용성이 높아집니다.

무엇보다 자동으로 메모리 관리가 되어 안전하다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 async* 키워드를 보면 이 함수가 Stream을 반환한다는 것을 알 수 있습니다. 이 부분이 핵심입니다.

다음으로 yield 키워드에서는 값을 방출하는 동작이 일어납니다. Future.delayed를 사용해 1초 간격을 만들고, while 루프로 계속해서 값을 보냅니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 주식 거래 앱을 개발한다고 가정해봅시다.

주식 가격은 실시간으로 변합니다. Stream을 활용하면 서버에서 보내는 가격 정보를 자연스럽게 화면에 반영할 수 있습니다.

네이버, 카카오 같은 많은 기업에서 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 Stream 구독을 취소하지 않는 것입니다. 이렇게 하면 메모리 누수가 발생할 수 있습니다.

따라서 위젯이 dispose될 때 반드시 구독을 취소해야 합니다. 다시 이지훈 씨의 이야기로 돌아가 봅시다.

최선배 님의 설명을 들은 이지훈 씨는 고개를 끄덕였습니다. "아, Stream이 이런 거였군요!" Stream을 제대로 이해하면 실시간 데이터를 다루는 앱을 훨씬 쉽게 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Stream은 반드시 구독 취소를 해야 메모리 누수를 방지할 수 있습니다

  • async*와 yield 키워드가 Stream 생성의 핵심입니다
  • listen() 메서드로 Stream을 구독하고 데이터를 받을 수 있습니다

2. StreamProvider 선언

Stream의 개념을 이해한 이지훈 씨는 이제 Riverpod와 결합하는 방법이 궁금해졌습니다. "Stream을 Provider로 만들면 어떤 장점이 있을까?" 최선배 님이 화면을 가리키며 설명을 시작했습니다.

StreamProvider는 Stream을 Riverpod의 상태 관리 시스템과 연결하는 특별한 Provider입니다. 이를 통해 Stream의 데이터를 위젯 전체에서 쉽게 공유하고 사용할 수 있습니다.

자동으로 구독 관리까지 해주어 편리합니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';

// StreamProvider 선언: 1초마다 현재 시간을 방출합니다
final timerProvider = StreamProvider<DateTime>((ref) {
  return Stream.periodic(
    Duration(seconds: 1),
    (count) => DateTime.now(),  // 매초 현재 시간 반환
  );
});

// 위젯에서 사용하기
class TimerWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final timer = ref.watch(timerProvider);
    // AsyncValue로 로딩, 에러, 데이터 상태를 자동 처리합니다
    return timer.when(
      data: (time) => Text('$time'),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('에러: $err'),
    );
  }
}

이지훈 씨는 Stream을 만드는 법을 배웠지만, 또 다른 고민에 빠졌습니다. "이 Stream을 어떻게 위젯에서 사용하지?

매번 listen을 호출해야 하나?" 최선배 님이 웃으며 말했습니다. "그래서 StreamProvider가 필요한 거예요." 그렇다면 StreamProvider란 정확히 무엇일까요?

쉽게 비유하자면, StreamProvider는 마치 방송국과 같습니다. 방송국에서 프로그램을 송출하면, 여러 가정에서 동시에 시청할 수 있습니다.

누군가 TV를 끄면 자동으로 연결이 해제되고, 다시 켜면 자동으로 연결됩니다. StreamProvider도 이처럼 Stream을 여러 위젯에 자동으로 전달하고 관리해줍니다.

StreamProvider가 없던 시절에는 어땠을까요? 개발자들은 StatefulWidget을 만들고, initState에서 Stream을 구독하고, dispose에서 구독을 취소하는 반복적인 코드를 작성해야 했습니다.

코드가 길어지고, 실수로 구독 취소를 빼먹으면 메모리 누수가 발생했습니다. 더 큰 문제는 여러 위젯에서 같은 Stream을 사용하려면 복잡한 상태 전달이 필요했습니다.

바로 이런 문제를 해결하기 위해 StreamProvider가 등장했습니다. StreamProvider를 사용하면 Stream 구독과 해제가 자동으로 관리됩니다.

또한 AsyncValue라는 특별한 타입으로 로딩, 에러, 데이터 상태를 쉽게 처리할 수 있습니다. 무엇보다 코드가 간결해지고 가독성이 높아진다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 Stream.periodic을 보면 일정 간격으로 값을 방출하는 Stream을 만든다는 것을 알 수 있습니다.

이 부분이 핵심입니다. Duration을 1초로 설정했으므로 1초마다 콜백이 실행됩니다.

다음으로 ref.watch에서는 Provider를 구독하는 동작이 일어납니다. AsyncValue의 when 메서드로 세 가지 상태를 깔끔하게 처리합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 채팅 앱을 개발한다고 가정해봅시다.

새로운 메시지가 실시간으로 도착합니다. StreamProvider를 활용하면 서버에서 보내는 메시지 Stream을 모든 채팅 화면에서 자동으로 받을 수 있습니다.

카카오톡, 라인 같은 메신저 앱들이 이런 패턴을 사용합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 StreamProvider 안에서 무거운 연산을 하는 것입니다. 이렇게 하면 앱이 느려질 수 있습니다.

따라서 StreamProvider는 단순히 Stream을 제공하는 역할만 하도록 설계해야 합니다. 다시 이지훈 씨의 이야기로 돌아가 봅시다.

최선배 님의 설명을 들은 이지훈 씨는 눈이 반짝였습니다. "와, 정말 간단하네요!" StreamProvider를 제대로 이해하면 실시간 데이터를 다루는 앱을 훨씬 깔끔하게 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - StreamProvider는 자동으로 구독 관리를 해주므로 메모리 누수 걱정이 없습니다

  • AsyncValue의 when 메서드로 로딩, 에러, 데이터 상태를 한번에 처리할 수 있습니다
  • Stream.periodic은 일정 간격으로 값을 방출할 때 매우 유용합니다

3. 1초마다 업데이트되는 타이머

이제 실제 타이머를 만들 차례입니다. 이지훈 씨는 노트북을 열고 코드를 작성하기 시작했습니다.

"1초마다 증가하는 숫자를 보여주려면 어떻게 해야 할까?" 최선배 님이 힌트를 주었습니다. "count 변수를 활용해보세요."

실시간 타이머는 Stream.periodic과 count 매개변수를 활용하여 구현합니다. 매초마다 증가하는 숫자를 반환하면, 화면에서 자동으로 업데이트됩니다.

StreamProvider가 모든 상태 관리를 대신 처리해줍니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';

// 0부터 시작해서 1초마다 1씩 증가하는 타이머
final counterTimerProvider = StreamProvider<int>((ref) {
  return Stream.periodic(
    Duration(seconds: 1),
    (count) => count,  // count는 0부터 시작해서 자동으로 증가합니다
  );
});

// 화면에 타이머 표시하기
class CounterTimerWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterTimerProvider);

    return counter.when(
      data: (count) => Text(
        '$count 초',
        style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
      ),
      loading: () => Text('시작 중...'),
      error: (err, stack) => Text('에러 발생'),
    );
  }
}

이지훈 씨는 StreamProvider의 개념을 이해했지만, 실제로 타이머를 만드는 것은 또 다른 문제였습니다. "증가하는 숫자를 어디서 관리하지?

별도의 변수가 필요한가?" 최선배 님이 코드를 가리키며 설명했습니다. "Stream.periodic에는 특별한 기능이 숨어 있어요." 그렇다면 count 매개변수란 정확히 무엇일까요?

쉽게 비유하자면, count는 마치 출석부의 번호와 같습니다. 첫 번째 학생은 1번, 두 번째 학생은 2번 이렇게 자동으로 번호가 매겨집니다.

Stream.periodic도 이처럼 매번 호출될 때마다 0부터 시작해서 자동으로 숫자를 증가시켜줍니다. 여러분은 그 숫자를 그대로 사용하기만 하면 됩니다.

count 매개변수가 없던 시절에는 어땠을까요? 개발자들은 외부에 별도의 변수를 만들고, 그 변수를 매번 증가시켜야 했습니다.

코드가 복잡해지고, 여러 곳에서 같은 타이머를 사용하면 변수 관리가 어려워졌습니다. 더 큰 문제는 타이머를 초기화할 때 변수도 함께 초기화해야 한다는 점이었습니다.

바로 이런 문제를 해결하기 위해 count 매개변수가 제공됩니다. count를 사용하면 별도의 상태 관리 없이 자동으로 증가하는 숫자를 얻을 수 있습니다.

또한 Stream이 재시작되면 count도 자동으로 0부터 다시 시작합니다. 무엇보다 코드가 간결해지고 버그 가능성이 줄어든다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 Stream.periodic의 두 번째 매개변수를 보면 count를 그대로 반환한다는 것을 알 수 있습니다.

이 부분이 핵심입니다. count는 0부터 시작해서 매번 자동으로 1씩 증가합니다.

다음으로 ref.watch에서는 이 값을 구독하는 동작이 일어납니다. when 메서드로 data 상태일 때 숫자를 화면에 표시합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 운동 트래킹 앱을 개발한다고 가정해봅시다.

사용자가 운동을 시작하면 경과 시간을 실시간으로 보여줘야 합니다. count 매개변수를 활용하면 별도의 상태 관리 없이 깔끔하게 구현할 수 있습니다.

나이키, 아디다스 같은 운동 앱들이 이런 방식을 사용합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 count 값을 직접 수정하려고 시도하는 것입니다. 이렇게 하면 작동하지 않습니다.

count는 읽기 전용이므로 그대로 사용해야 합니다. 만약 다른 계산이 필요하다면 count를 기반으로 새로운 값을 만들어야 합니다.

다시 이지훈 씨의 이야기로 돌아가 봅시다. 최선배 님의 설명을 들은 이지훈 씨는 감탄했습니다.

"이렇게 간단할 줄이야!" count 매개변수를 제대로 이해하면 타이머를 놀라울 정도로 쉽게 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - count는 0부터 시작해서 자동으로 1씩 증가합니다

  • Stream이 재시작되면 count도 자동으로 0으로 초기화됩니다
  • count를 기반으로 다양한 계산을 할 수 있습니다 (예: count * 10)

4. 타이머 시작 정지 구현

기본 타이머는 완성했지만, 이지훈 씨는 새로운 요구사항을 받았습니다. "사용자가 타이머를 시작하고 정지할 수 있어야 해요." 최선배 님이 고개를 끄덕였습니다.

"그럼 StateProvider와 조합해야겠네요."

타이머 제어 기능은 StateProvider로 실행 상태를 관리하고, StreamProvider에서 이 상태를 참조하여 구현합니다. 실행 중일 때만 Stream을 방출하고, 정지 상태에서는 Stream을 멈춥니다.

ref.watch로 상태 변화를 감지합니다.

다음 코드를 살펴봅시다.

import 'package:flutter_riverpod/flutter_riverpod.dart';

// 타이머 실행 상태를 관리하는 Provider
final isRunningProvider = StateProvider<bool>((ref) => false);

// 제어 가능한 타이머 Provider
final controllableTimerProvider = StreamProvider<int>((ref) async* {
  int count = 0;

  while (true) {
    await Future.delayed(Duration(seconds: 1));

    // 실행 상태를 확인합니다
    final isRunning = ref.read(isRunningProvider);
    if (isRunning) {
      count++;
      yield count;  // 실행 중일 때만 값을 방출합니다
    }
  }
});

// 시작/정지 버튼
class TimerControlWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final isRunning = ref.watch(isRunningProvider);

    return ElevatedButton(
      onPressed: () {
        // 상태를 토글합니다
        ref.read(isRunningProvider.notifier).state = !isRunning;
      },
      child: Text(isRunning ? '정지' : '시작'),
    );
  }
}

이지훈 씨는 기본 타이머를 성공적으로 만들었지만, 곧 한계를 느꼈습니다. "타이머가 자동으로 시작되네.

사용자가 원할 때만 동작하게 할 수는 없을까?" 최선배 님이 화이트보드에 그림을 그리며 설명했습니다. "Provider들을 조합하면 됩니다." 그렇다면 Provider 조합이란 정확히 무엇일까요?

쉽게 비유하자면, Provider 조합은 마치 레고 블록과 같습니다. 각각의 블록은 단순하지만, 여러 블록을 연결하면 복잡한 구조물을 만들 수 있습니다.

StateProvider는 on/off 스위치 역할을 하고, StreamProvider는 그 스위치를 확인해서 동작하는 모터 역할을 합니다. Provider 조합 없이는 어떻게 구현했을까요?

개발자들은 복잡한 StatefulWidget을 만들고, StreamController를 직접 관리하고, setState로 화면을 업데이트해야 했습니다. 코드가 길어지고, 버그가 생기기 쉬웠습니다.

더 큰 문제는 상태와 로직이 위젯에 강하게 결합되어 재사용이 어려웠다는 점입니다. 바로 이런 문제를 해결하기 위해 Provider 조합 패턴이 권장됩니다.

Provider를 조합하면 각 Provider는 단일 책임만 가지게 됩니다. 또한 ref.read와 ref.watch로 Provider 간 관계를 명확하게 표현할 수 있습니다.

무엇보다 로직과 UI가 분리되어 테스트와 유지보수가 쉬워진다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 isRunningProvider를 보면 단순히 bool 값을 저장한다는 것을 알 수 있습니다. 이 부분이 핵심입니다.

다음으로 async* 키워드와 while 루프에서는 무한히 반복하며 상태를 확인하는 동작이 일어납니다. ref.read로 현재 실행 상태를 읽어오고, 실행 중일 때만 count를 증가시켜 yield로 방출합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 명상 앱을 개발한다고 가정해봅시다.

사용자가 명상을 시작하면 타이머가 동작하고, 일시정지를 누르면 멈춰야 합니다. 이 패턴을 활용하면 상태 관리와 타이머 로직을 깔끔하게 분리할 수 있습니다.

헤드스페이스, 캄 같은 명상 앱들이 이런 구조를 사용합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 StreamProvider 안에서 ref.watch를 사용하는 것입니다. 이렇게 하면 무한 재생성 문제가 발생할 수 있습니다.

Provider 안에서 다른 Provider를 참조할 때는 ref.read를 사용해야 합니다. 다시 이지훈 씨의 이야기로 돌아가 봅시다.

최선배 님의 설명을 들은 이지훈 씨는 무릎을 쳤습니다. "Provider를 이렇게 조합할 수 있구나!" Provider 조합 패턴을 제대로 이해하면 복잡한 상태 관리를 깔끔하게 구현할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Provider 안에서 다른 Provider를 읽을 때는 ref.read를 사용하세요

  • StateProvider는 단순한 on/off 같은 상태 관리에 적합합니다
  • async*와 while을 조합하면 제어 가능한 무한 Stream을 만들 수 있습니다

5. 스톱워치 UI 만들기

로직은 완성되었지만, 이지훈 씨는 UI가 마음에 들지 않았습니다. "좀 더 실제 스톱워치처럼 보이게 만들 수는 없을까?" 최선배 님이 미소 지었습니다.

"시간 포맷팅과 디자인을 추가해봅시다."

스톱워치 UI는 초 단위 count를 분과 초로 변환하여 표시하고, Material Design의 Card와 Icon을 활용하여 만듭니다. 시간 포맷팅 함수를 작성하고, 버튼 디자인을 개선하면 완성도 높은 UI가 됩니다.

다음 코드를 살펴봅시다.

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

// 초를 "분:초" 형식으로 변환하는 함수
String formatTime(int seconds) {
  final minutes = seconds ~/ 60;  // 정수 나눗셈
  final secs = seconds % 60;  // 나머지 연산
  return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}

// 완성된 스톱워치 UI
class StopwatchUI extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final timer = ref.watch(controllableTimerProvider);
    final isRunning = ref.watch(isRunningProvider);

    return Card(
      elevation: 8,
      child: Padding(
        padding: EdgeInsets.all(32),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            // 타이머 표시
            timer.when(
              data: (count) => Text(
                formatTime(count),
                style: TextStyle(fontSize: 64, fontWeight: FontWeight.bold),
              ),
              loading: () => Text('00:00', style: TextStyle(fontSize: 64)),
              error: (_, __) => Text('에러'),
            ),
            SizedBox(height: 24),
            // 시작/정지 버튼
            ElevatedButton.icon(
              onPressed: () {
                ref.read(isRunningProvider.notifier).state = !isRunning;
              },
              icon: Icon(isRunning ? Icons.pause : Icons.play_arrow),
              label: Text(isRunning ? '정지' : '시작'),
              style: ElevatedButton.styleFrom(
                padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

이지훈 씨는 타이머가 정상적으로 작동하는 것을 확인했지만, 뭔가 아쉬웠습니다. "숫자만 덩그러니 있으니 별로네.

실제 스톱워치처럼 보이게 할 수는 없을까?" 최선배 님이 앱 스토어를 열어 여러 타이머 앱을 보여줬습니다. "이런 앱들을 참고해보세요." 그렇다면 좋은 스톱워치 UI란 정확히 무엇일까요?

쉽게 비유하자면, 좋은 UI는 마치 잘 정돈된 책상과 같습니다. 필요한 것이 눈에 잘 보이고, 사용하기 편하게 배치되어 있습니다.

스톱워치도 시간이 크게 보이고, 버튼이 직관적이어야 합니다. 사용자가 생각할 필요 없이 바로 사용할 수 있어야 합니다.

포맷팅 없이는 어떻게 보였을까요? 개발자들은 그냥 초 단위로 표시했습니다.

65초, 127초 이런 식으로요. 하지만 사람들은 시간을 분과 초로 나누어 생각합니다.

더 큰 문제는 한 자리 숫자일 때 앞에 0이 없어서 숫자가 튀는 현상이었습니다. 바로 이런 문제를 해결하기 위해 시간 포맷팅이 필요합니다.

포맷팅을 하면 사용자가 시간을 직관적으로 이해할 수 있습니다. 또한 padLeft로 항상 두 자리로 표시하면 숫자의 위치가 일정해져 보기 편합니다.

무엇보다 전문적으로 보인다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 정수 나눗셈 연산자인 ~/ 를 보면 소수점을 버리고 몫만 구한다는 것을 알 수 있습니다. 이 부분이 핵심입니다.

다음으로 나머지 연산자 %에서는 60으로 나눈 나머지를 구하는 동작이 일어납니다. padLeft 메서드로 항상 두 자리 문자열을 만들고, 필요하면 앞에 0을 채웁니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 요리 레시피 앱을 개발한다고 가정해봅시다.

각 단계마다 타이머가 필요합니다. 이런 깔끔한 UI를 제공하면 사용자 만족도가 크게 높아집니다.

쿠팡이츠, 배달의민족 같은 앱들이 주문 후 남은 시간을 이런 방식으로 표시합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 시간 포맷팅 로직을 위젯 안에 직접 넣는 것입니다. 이렇게 하면 재사용이 어렵습니다.

따라서 포맷팅 함수를 별도로 분리하여 여러 곳에서 사용할 수 있게 해야 합니다. 다시 이지훈 씨의 이야기로 돌아가 봅시다.

최선배 님의 설명을 들은 이지훈 씨는 뿌듯해했습니다. "이제 정말 완성된 것 같아요!" UI 디자인과 포맷팅을 제대로 이해하면 사용자 경험이 뛰어난 앱을 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - padLeft로 항상 일정한 자릿수를 유지하면 UI가 안정적으로 보입니다

  • Card와 elevation을 활용하면 쉽게 입체감을 줄 수 있습니다
  • ElevatedButton.icon을 사용하면 아이콘과 텍스트를 함께 표시할 수 있습니다

6. 전체 코드와 실행 결과

마침내 모든 기능이 완성되었습니다. 이지훈 씨는 프로젝트 매니저에게 결과물을 보여주기 위해 전체 코드를 정리하기 시작했습니다.

최선배 님이 옆에서 조언했습니다. "코드를 잘 정리해서 보여주세요."

완성된 스톱워치 앱은 StreamProvider, StateProvider, 포맷팅 함수, UI 위젯이 조화롭게 결합된 구조입니다. 각 부분이 명확한 역할을 가지며, Riverpod의 선언적 방식으로 깔끔하게 구현됩니다.

실행하면 바로 사용할 수 있는 완전한 앱입니다.

다음 코드를 살펴봅시다.

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

// 1. 타이머 실행 상태 Provider
final isRunningProvider = StateProvider<bool>((ref) => false);

// 2. 제어 가능한 타이머 StreamProvider
final timerProvider = StreamProvider<int>((ref) async* {
  int count = 0;
  while (true) {
    await Future.delayed(Duration(seconds: 1));
    final isRunning = ref.read(isRunningProvider);
    if (isRunning) {
      count++;
      yield count;
    }
  }
});

// 3. 시간 포맷팅 함수
String formatTime(int seconds) {
  final minutes = seconds ~/ 60;
  final secs = seconds % 60;
  return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}

// 4. 스톱워치 UI
class StopwatchScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final timer = ref.watch(timerProvider);
    final isRunning = ref.watch(isRunningProvider);

    return Scaffold(
      appBar: AppBar(title: Text('스톱워치')),
      body: Center(
        child: Card(
          elevation: 8,
          child: Padding(
            padding: EdgeInsets.all(48),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                timer.when(
                  data: (count) => Text(formatTime(count),
                    style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold)),
                  loading: () => Text('00:00',
                    style: TextStyle(fontSize: 72)),
                  error: (_, __) => Text('에러'),
                ),
                SizedBox(height: 32),
                Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    ElevatedButton.icon(
                      onPressed: () => ref.read(isRunningProvider.notifier).state = !isRunning,
                      icon: Icon(isRunning ? Icons.pause : Icons.play_arrow),
                      label: Text(isRunning ? '정지' : '시작'),
                    ),
                    SizedBox(width: 16),
                    ElevatedButton.icon(
                      onPressed: () {
                        ref.read(isRunningProvider.notifier).state = false;
                        ref.invalidate(timerProvider);  // 타이머 초기화
                      },
                      icon: Icon(Icons.refresh),
                      label: Text('초기화'),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

// 5. 앱 실행
void main() {
  runApp(ProviderScope(child: MaterialApp(home: StopwatchScreen())));
}

이지훈 씨는 드디어 모든 코드를 완성했습니다. 처음에는 막막했지만, 단계별로 배우면서 자신감이 생겼습니다.

"이제 프로젝트 매니저님께 보여드려야지." 최선배 님이 코드를 훑어보며 말했습니다. "잘 만들었네요.

각 부분의 역할을 설명해볼까요?" 그렇다면 이 코드의 구조는 어떻게 되어 있을까요? 쉽게 비유하자면, 이 앱은 마치 잘 조직된 오케스트라와 같습니다.

StateProvider는 지휘자로서 전체 흐름을 제어하고, StreamProvider는 악기 연주자로서 시간 데이터를 연주합니다. UI 위젯은 관객에게 보이는 무대이고, 포맷팅 함수는 악보를 읽기 쉽게 정리하는 역할을 합니다.

이런 구조 없이는 어떻게 만들었을까요? 개발자들은 모든 로직을 StatefulWidget 안에 넣었습니다.

setState로 화면을 업데이트하고, Timer를 직접 관리하고, dispose에서 정리했습니다. 코드가 수백 줄이 되고, 버그를 찾기 어려웠습니다.

더 큰 문제는 다른 화면에서 재사용할 수 없었다는 점입니다. 바로 이런 문제를 해결하기 위해 Riverpod 아키텍처가 권장됩니다.

Provider로 로직을 분리하면 각 부분을 독립적으로 테스트할 수 있습니다. 또한 다른 화면에서도 같은 Provider를 재사용할 수 있습니다.

무엇보다 코드를 읽는 사람이 구조를 한눈에 파악할 수 있다는 큰 이점이 있습니다. 위의 코드를 단계별로 살펴보겠습니다.

먼저 두 개의 Provider를 보면 상태 관리와 데이터 스트림이 명확히 분리되어 있다는 것을 알 수 있습니다. 이 부분이 핵심입니다.

다음으로 포맷팅 함수는 순수 함수로 작성되어 어디서든 사용할 수 있습니다. ConsumerWidget에서는 ref.watch로 Provider를 구독하고, when 메서드로 모든 상태를 처리합니다.

ref.invalidate를 사용하면 Provider를 초기화할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 포모도로 타이머 앱을 개발한다고 가정해봅시다. 25분 집중, 5분 휴식을 반복합니다.

이 구조를 확장하면 쉽게 만들 수 있습니다. timerProvider의 로직만 수정하면 되고, UI는 거의 그대로 사용할 수 있습니다.

실제로 많은 생산성 앱들이 이런 패턴을 기반으로 만들어집니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 Provider를 너무 많이 만드는 것입니다. 이렇게 하면 오히려 복잡해집니다.

따라서 진짜 필요한 상태만 Provider로 분리하고, 지역 상태는 위젯 내부에서 관리하는 것이 좋습니다. 다시 이지훈 씨의 이야기로 돌아가 봅시다.

프로젝트 매니저가 데모를 보고 만족스러워했습니다. "잘 만들었어요.

사용자들이 좋아할 것 같네요." 이지훈 씨는 뿌듯한 표정으로 자리로 돌아왔습니다. 처음에는 어려웠던 StreamProvider가 이제는 자연스럽게 느껴졌습니다.

완성된 코드를 제대로 이해하면 비슷한 기능을 빠르게 구현할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - ref.invalidate로 Provider를 초기화하여 타이머를 리셋할 수 있습니다

  • ConsumerWidget은 StatelessWidget처럼 사용하되 ref를 제공받습니다
  • ProviderScope는 앱 최상위에 반드시 필요합니다

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

#Flutter#StreamProvider#Stream#Timer#Riverpod#Flutter,Riverpod

댓글 (0)

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

함께 보면 좋은 카드 뉴스

Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드

Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.

Riverpod 3.0 Retry 자동 재시도 완벽 가이드

Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.

Riverpod 3.0 requireValue로 Provider 결합하기

Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.

Flutter 3.0 Offline 데이터 영속화 완벽 가이드

Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.

Riverpod 3.0 Mutation으로 폼 제출 완벽 가이드

Riverpod 3.0의 새로운 Mutation 기능으로 로그인과 회원가입 폼을 우아하게 처리하는 방법을 배웁니다. 로딩 상태, 에러 처리, 성공 처리까지 실무에서 바로 쓸 수 있는 패턴을 익혀보세요.