Flutter

블로그 프로젝트 - 게시글 쓰기 구현 하기 ( Riverpod 상태관리 )

whs5758 2025. 9. 4. 19:30

사전 기반 지식

1. 왜 글쓰기 폼에서 상태관리가 필요한가?

// 나쁜 예: 상태관리 없이 구현하면...
void writePost() {
  // 버튼을 여러 번 눌러도 막을 방법이 없음
  // 성공/실패 상태를 관리하기 어려움
  // 다른 화면과 데이터 동기화 불가능
}

// 좋은 예: 상태관리로 구현하면...
enum PostWriteStatus { initial, loading, success, failure }
// 각 단계별 상태를 명확히 관리
// UI가 상태에 따라 자동으로 업데이트

 

2. Riverpod의 핵심 개념

  • Provider: 상태를 제공하는 객체
  • Notifier: 상태를 변경하는 로직을 담당
  • Consumer: Provider의 상태를 감시하는 위젯

재사용 가능한 위젯 개선

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

// 재사용 가능한 위젯으로 설계 하기 위함.
// 맞춤 기능을 추가 하기 위해 재 설계 한다.
class CustomTextFormField extends StatelessWidget {
  final String hint;
  final bool obscureText;
  final TextEditingController controller;
  final String? initValue; // 초기 값 (CustomTextFormField - 글쓰기, 글 수정)
  //CustomTextFormField() -->  추가 (String) { ... 구현 부 정의 }
  final String? Function(String?)? validator;

  const CustomTextFormField({
    Key? key,
    required this.hint,
    this.obscureText = false,
    required this.controller,
    this.initValue = "",
    this.validator, // 선택적 매개 변수 (옵션값) - 유효성 검사
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    if (initValue != null && initValue!.isNotEmpty) {
      controller.text = initValue!;
    }
    return TextFormField(
      validator: validator,
      controller: controller,
      obscureText: obscureText,
      decoration: InputDecoration(
        hintText: "Enter $hint",
        enabledBorder: OutlineInputBorder(
          // 3. 기본 TextFormField 디자인
          borderRadius: BorderRadius.circular(20),
        ),
        focusedBorder: OutlineInputBorder(
          // 4. 손가락 터치시 TextFormField 디자인
          borderRadius: BorderRadius.circular(20),
        ),
        errorBorder: OutlineInputBorder(
          // 5. 에러발생시 TextFormField 디자인
          borderRadius: BorderRadius.circular(20),
        ),
        focusedErrorBorder: OutlineInputBorder(
          // 5. 에러가 발생 후 손가락을 터치했을 때 TextFormField 디자인
          borderRadius: BorderRadius.circular(20),
        ),
      ),
    );
  }
}

 

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

class CustomTextArea extends StatelessWidget {
  final String hint;
  final TextEditingController controller;
  final String? Function(String?)? validator;

  const CustomTextArea(
      {Key? key, required this.hint, required this.controller, this.validator})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 5),
      child: TextFormField(
        validator: validator,
        controller: controller,
        maxLines: 15,
        decoration: InputDecoration(
          hintText: "Enter $hint",
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(20),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(20),
          ),
          errorBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(20),
          ),
          focusedErrorBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(20),
          ),
        ),
      ),
    );
  }
}

 

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

class CustomTextButton extends StatelessWidget {
  final String text;
  // 비활성화 기능 추가하기 위해서 nullable 변경 처리 함.
  // 중복 클릭 방지 용
  final VoidCallback? click;

  CustomTextButton({required this.text, required this.click});

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: click,
      child: Text(text,
          style: const TextStyle(
              color: Colors.black87, decoration: TextDecoration.underline)),
    );
  }
}

 

post_list_notifier.dart 파일에 코드 추가
  // 게시글 목록 새로 고침 기능 추가
  Future<void> refreshAfterWriter() async {
    Logger().d("게시글 작성 후 목록 새로고침 시작");
    await fetchPosts(page: 0);
  }

 


게시글 작성 상태 관리 생성

import 'package:flutter/cupertino.dart';
import 'package:flutter_blog/data/models/post.dart';
import 'package:flutter_blog/data/models/repository/post_repository.dart';
import 'package:flutter_blog/main.dart';
import 'package:flutter_blog/providers/global/post/post_list_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// 게시글 작성 진행 상태를 나타내는 열거형
enum PostWriteStatus {
  initial, // 초기 상태 (아무것도 하지 않은)
  loading, // 작성 중(서버 통신 중)
  success, //  게시글 작성 성공
  failure, //  게시글 작성 실패
}

// 게시글 작성 상태를 설계 (창고 데이터)
class PostWriteModel {
  final PostWriteStatus status; // 현재 게시글 작성 진행 상태
  final String? message; // 사용자에게 보여줄 메시지
  final Post? createdPost;

  PostWriteModel({
    this.status = PostWriteStatus.initial,
    this.message,
    this.createdPost,
  }); // 작성 성공시 생성된 게시글

  // 불변성 패턴
  PostWriteModel copyWith({
    PostWriteStatus? status,
    String? message,
    Post? createdPost,
  }) {
    return PostWriteModel(
      status: status ?? this.status,
      message: message ?? this.message,
      createdPost: createdPost ?? this.createdPost,
    );
  }

  @override
  String toString() {
    return 'PostWriteModel{status: $status, message: $message, createdPost: $createdPost}';
  }
} // end of PostWriteModel

// 창고 매뉴얼 설계 + 가능한 순수 비즈니스 로직 담당 (단 SRP - 단일 책임에 원칙)
class PostWriteNotifier extends Notifier<PostWriteModel> {
  @override
  PostWriteModel build() {
    return PostWriteModel();
  }

  Future<void> writePost(String title, String content) async {
    // 기본적으로 try .. 사용해야 한다 (서버가 불량인 경우를 위해)
    // 생략..
    // 중복 클릭 방지를 위해서 상태 변경을 한다.
    state = state.copyWith(status: PostWriteStatus.loading);
    // UI 단에서 loading 상태라면 VoidCallback 값을 null 처리면 비활성화 버튼으로 변경이 된다.

    Map<String, dynamic> response =
        await PostRepository().write(title, content);

    if (response['success'] == true) {
      Post createdPost = Post.fromMap(response['response']);
      state = state.copyWith(
        status: PostWriteStatus.success,
        message: "게시글이 작성되었습니다",
        createdPost: createdPost,
      );
    } else {
      state = state.copyWith(
        status: PostWriteStatus.failure,
        message: "${response['errorMessage']} - 게시글 작성에 실패했습니다",
      );
    }
  }
} // end of notifier

final postWriteProvider = NotifierProvider<PostWriteNotifier, PostWriteModel>(
    () => PostWriteNotifier());

게시글 작성 폼과 상태관리 연동

import 'package:flutter/material.dart';
import 'package:flutter_blog/_core/constants/size.dart';
import 'package:flutter_blog/_core/utils/validator_util.dart';
import 'package:flutter_blog/providers/global/post/post_list_notifier.dart';
import 'package:flutter_blog/providers/global/post/post_write_notifier.dart';
import 'package:flutter_blog/ui/widgets/custom_elavated_button.dart';
import 'package:flutter_blog/ui/widgets/custom_text_area.dart';
import 'package:flutter_blog/ui/widgets/custom_text_form_field.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class PostWriteForm extends ConsumerWidget {
  final _formKey = GlobalKey<FormState>(); // 폼 유효성 검사용 키
  final _title = TextEditingController(); // 제목 입력 컨트롤러
  final _content = TextEditingController(); // 컨텐츠 입력 컨트롤러

  PostWriteForm({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 게시글 작성 상태 데이터
    // 상태가 변경될 때 마다 build 메서드 다시 호출되어 UI를 업데이트 처리 한다.
    final PostWriteModel model = ref.watch(postWriteProvider);

    // 복습 정리
    // 상태 관련 메서드
    // ref.read(provider), ref.watch(provider)

    // ref.listen 활용해보자.
    // 1. build 메서드 내부에서 또는 initState 내부 에서 사용 가능
    // 2. 상태 변화에 따른 사이드 이펙트 처리
    // 참고 : 주로 네이게이션, 다이얼로그, 스낵바 등 일회성 액션이 필요할 때 사용함
    ref.listen(
      postWriteProvider,
      (previous, next) {
        if (next.status == PostWriteStatus.success) {
          // 사이드 이펙트 1 : 성공 메시지 표시
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('게시글 작성 완료'),
              backgroundColor: Colors.green,
            ),
          );

          // 사이드 이펙트 2 : 게시글 목록 새로 고침 ( 가능한 notifier 끼리 통신은 자제 )
          ref.read(postListProvider.notifier).refreshAfterWriter();

          // 사이드 이펙트 3 : 화면 이동
          Navigator.pop(context);
        } else if (next.status == PostWriteStatus.failure) {
          // 사이드 이펙트 1 : 성공 메시지 표시
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('게시글 작성 싥패'),
              backgroundColor: Colors.redAccent,
            ),
          );
        }
      },
    );

    return Form(
      key: _formKey,
      child: ListView(
        shrinkWrap: true,
        children: [
          CustomTextFormField(
            controller: _title,
            hint: "Title",
            validator: (value) =>
                value?.trim().isEmpty == true ? "제목을 입력하세요" : null,
          ),
          const SizedBox(height: smallGap),
          CustomTextArea(
            controller: _content,
            hint: "Content",
            validator: (value) =>
                value?.trim().isEmpty == true ? "내용을 입력해주세요" : null,
          ),
          const SizedBox(height: largeGap),
          CustomElevatedButton(
              text: model.status == PostWriteStatus.loading ? "작성중..." : "글쓰기",
              click: model.status == PostWriteStatus.loading
                  ? null
                  : () => _handleSubmit(ref)),
        ],
      ),
    );
  } // end of build

  void _handleSubmit(WidgetRef ref) {
    // 유효성 검사
    // 사용자가 작성한 값 들고 오기
    // 상태관리 비즈니스 로직을 호출(게시글 작성)
    if (_formKey.currentState!.validate()) {
      final title = _title.text.trim();
      final content = _content.text.trim();
      ref.read(postWriteProvider.notifier).writePost(title, content);
    }
  }
}

 

사이드 이펙트(Side Effect)란?

사이드 이펙트는 함수나 메서드가 주요 목적 외에 추가로 발생시키는 작업을 의미합니다.

// 일반 로직 (순수 함수)
int add(int a, int b) {
  return a + b; // 입력받은 값으로 계산만 함
}

// 사이드 이펙트가 있는 함수
int addAndLog(int a, int b) {
  int result = a + b;           // 주요 목적
  
  // 사이드 이펙트들
  print("계산 완료: $result");    // 콘솔 출력
  _database.save(result);       // 데이터베이스 저장
  _analytics.track('add_used'); // 분석 데이터 전송
  
  return result;
}
ref.listen(postWriteProvider, (previous, next) {
  if (next.status == PostWriteStatus.success) {
    // 이 모든 작업들이 사이드 이펙트
    Navigator.pop(context);          // 1. 화면 네비게이션
    ScaffoldMessenger.of(context)    // 2. 사용자 알림
        .showSnackBar(...);
    _clearForm();                    // 3. 폼 초기화
    _trackAnalytics('post_created'); // 4. 분석 데이터 전송
  }
});

 


핵심 개념 정리

1. 중복 클릭 방지 패턴

// 상태에 따른 버튼 제어
text: isLoading ? "처리 중..." : "액션",
click: isLoading ? null : () => action(),

// Flutter에서 onPressed가 null이면 자동으로 버튼 비활성화

 

2. 상태 기반 UI 업데이트

// ref.watch(): 상태 감시 → UI 리빌드
final state = ref.watch(provider);

// ref.listen(): 상태 변화 감지 → 사이드 이펙트 실행
ref.listen(provider, (previous, next) {
  // 네비게이션, 다이얼로그, 스낵바 등
});

 

3. Provider 간 통신

// A Provider에서 B Provider의 메서드 호출
await ref.read(otherProvider.notifier).someMethod();

 

4. 불변성과 copyWith 패턴

// 기존 상태는 변경하지 않고 새로운 상태 객체 생성
state = state.copyWith(
  status: NewStatus.loading,
  message: null,
);