guricode

[flutter]자취의 정석 -8 게시글, 댓글 페이지네이션 microtask 본문

앱/Flutter&Dart

[flutter]자취의 정석 -8 게시글, 댓글 페이지네이션 microtask

agentrakugaki 2025. 10. 2. 00:27

 

 

게시글을 불러올때 한번에 불러와서 리스트로 뿌리면 디바이스에 부담이 되기때문에 20개씩 불러오고

마지막 댓글을 불러왔을때 다음 댓글을 불러올수 있는 로직

 

아래는 데이터소스 임플이다.

하나하나씩 살펴보자

  CollectionReference<Map<String, dynamic>> get col =>
      fs.collection('community_comments');
      
@override //게시글 댓글 불러오기
  Future<PagedResult<CommentDto>> fetchByCommunity({
    required String communityId,
    required CommentOrder order,
    int limit = 20,
    DocumentSnapshot<Object?>? startAfterDoc,
  }) async {
    //게시글 id와 같고 삭제되지 않은 댓글
    Query<Map<String, dynamic>> q = col
        .where('community_id', isEqualTo: communityId)
        .where('comment_delete_yn', isEqualTo: false);

    //최신순일때 정렬
    if (order == CommentOrder.latest) {
      q = q.orderBy('comment_create_date', descending: true);
    } else {
      //최신순 아닐때(추천순) 정렬
      q = q
          .orderBy('like_count', descending: true)
          .orderBy('comment_create_date', descending: true);
    }
    //제한갯수
    q = q.limit(limit);
    if (startAfterDoc != null) q = q.startAfterDocument(startAfterDoc);

    final snap = await q.get();
    final items = snap.docs
        .map((d) => CommentDto.fromFirebase(d.id, d.data()))
        .toList();
    final lastDoc = snap.docs.isNotEmpty ? snap.docs.last : null;

    return PagedResult(
      items: items,
      lastDoc: lastDoc,
      hasMore: snap.docs.length == limit,
    );
  }

 


댓글은 커뮤니티라는 게시글 id를 FK로 가지고 있다.

그 키를 가진 댓글을 조회하고 소프트 딜리트가 false인 값을 조회한다.

    //게시글 id와 같고 삭제되지 않은 댓글
    Query<Map<String, dynamic>> q = col
        .where('community_id', isEqualTo: communityId)
        .where('comment_delete_yn', isEqualTo: false);

 

 

 

Comment DTO에 이넘으로 CommentOrder를 가지고있다.

이 이넘값이 최신일때 최신순 정렬을 보여주고 아닐땐 인기순 정렬을 보여준다

  //최신순일때 정렬
    if (order == CommentOrder.latest) {
      q = q.orderBy('comment_create_date', descending: true);
    } else {
      //최신순 아닐때(추천순) 정렬
      q = q
          .orderBy('like_count', descending: true)
          .orderBy('comment_create_date', descending: true);
    }

 

 

 

 

 


다음은 댓글 페이지네이션로직이다

  @override //게시글 댓글 불러오기
  Future<PagedResult<CommentDto>> fetchByCommunity({
    required String communityId,
    required CommentOrder order,
    int limit = 20,
    DocumentSnapshot<Object?>? startAfterDoc,
  }) async {
    //게시글 id와 같고 삭제되지 않은 댓글
    Query<Map<String, dynamic>> q = col
        .where('community_id', isEqualTo: communityId)
        .where('comment_delete_yn', isEqualTo: false);

    //최신순일때 정렬
    if (order == CommentOrder.latest) {
      q = q.orderBy('comment_create_date', descending: true);
    } else {
      //최신순 아닐때(추천순) 정렬
      q = q
          .orderBy('like_count', descending: true)
          .orderBy('comment_create_date', descending: true);
    }
    //제한갯수
    q = q.limit(limit);
    if (startAfterDoc != null) q = q.startAfterDocument(startAfterDoc);

    final snap = await q.get();
    final items = snap.docs
        .map((d) => CommentDto.fromFirebase(d.id, d.data()))
        .toList();
    final lastDoc = snap.docs.isNotEmpty ? snap.docs.last : null;

    return PagedResult(
      items: items,
      lastDoc: lastDoc,
      hasMore: snap.docs.length == limit,
    );
  }

limit은 firebase의 Query메서드이다.

최대 n개의 문서만 돌려주도록 상한을 걸수있다.

현재 limit은 20을 주고있는데 이 20개의 문서를 리스트로 담아 반환할수있다.

그래서 q에는 최종적으로 comminityid가 동일한 댓글의 20개의 리스트를 반환한다.(List<QueryDocumentSnapshot>)

 

startAfterDocument은 커서기반 페이지네이션용 Query메서드이다.

주어진 문서 스냅샷 바로 다음부터 결과를 가져오게한다.

startAfterDoc가 있으면 이 startAfterDoc를 기준으로 q가 시작한다.

비슷한 메서드로는

startAtDocument(doc): doc 포함해서 시작.

startAfter(values...) : 문서 대신 정렬 필드 값들로 커서 지정.

종료 커서도 유사: endAtDocument, endBeforeDocument.

 

get() 메서드는 쿼리를 실제로 실행하는 메서드이다. 한번 실행해서 결과를 받아오는 네트워크 호출이다.

Query는 조건만 담은 객체이기 때문에 get()이나 snapshots()를 호출해야 싱핼된다.

get()은 1회성

snapshots()은 실시간 갱신이 필요할때 사용한다.(Stream)

 

get()으로 쿼리를 실행해 문서들을 취합하고 map을 이용해 각 요소들을 CommentDto에 지정된 factory함수로 객체를 리스트로 생성한다.

DTO는 는 이렇게 만들어져있다.

 

 

  //댓글 불러오기(firebase에서 읽기)
  factory CommentDto.fromFirebase(String id, Map<String, dynamic> d) {
    return CommentDto(
      id: id,
      communityId: d['community_id'] as String, //FK
      uid: d['uid'] as String,
      noteDetail: d['note_detail'] as String,
      likeCount: (d['like_count'] ?? 0) as int,
      createAt: d['comment_create_date'] as Timestamp,
      updateAt: d['comment_update_date'] as Timestamp?,
      deleteAt: d['comment_delete_date'] as Timestamp?,
      deleteYn: (d['comment_delete_yn'] ?? false) as bool,
      commentLog: (d['comment_log'] as List?)?.cast<String>(),
    );
  }

 

 

 

 

리스트로 댓글들을 items로 담고

snap의 가장 마지막에 있는 문서를 lastDoc에 담는다.

그리고 pagedResult 객체로 생성한다

 

  final snap = await q.get();
    final items = snap.docs
        .map((d) => CommentDto.fromFirebase(d.id, d.data()))
        .toList();
    final lastDoc = snap.docs.isNotEmpty ? snap.docs.last : null;

    return PagedResult(
      items: items,
      lastDoc: lastDoc,
      hasMore: snap.docs.length == limit,
    );

 

class PagedResult<T> {
  final List<T> items; // 가져온 데이터들
  final DocumentSnapshot? lastDoc; // 다음 페이지를 위한 커서
  final bool hasMore; // 다음 페이지가 있을 수 있는지

  const PagedResult({
    required this.items,
    required this.lastDoc,
    required this.hasMore,
  });
}

 

 

 

 

레포지토리 임플

class CommentRepositoryImpl implements CommentRepository {
  final CommentDataSource ds;
  CommentRepositoryImpl(this.ds);

@override
  Future<PagedComments> fetchByCommunity({
    required String communityId,
    required CommentOrder order,
    int limit = 20,
    DocumentSnapshot? startAfter,
  }) async {
    final page = await ds.fetchByCommunity(
      communityId: communityId,
      order: order,
      limit: limit,
      startAfterDoc: startAfter,
    );
    final items = page.items.map(_toEntity).toList();
    return PagedComments(items, page.lastDoc, page.hasMore);
  }

 

 

유즈케이스

class FetchComments {
  final CommentRepository repo;
  FetchComments(this.repo);

  /// 댓글 페이지네이션 조회
  /// - repo는 PagedComments를 반환하므로, VM에서 쓰기 편하도록 record로 변환해 반환
  Future<({List<Comment> items, DocumentSnapshot? lastDoc, bool hasMore})>
  call({
    required String communityId,
    required CommentOrder order,
    int limit = 20,
    DocumentSnapshot? startAfter,
  }) async {
    final page = await repo.fetchByCommunity(
      communityId: communityId,
      order: order,
      limit: limit,
      startAfter: startAfter,
    );
    return (items: page.items, lastDoc: page.lastDoc, hasMore: page.hasMore);
  }
}

 

 

 

뷰모델

 

 class CommunityDetailState {
  final Community? post;
  final bool loadingPost;

  final List<Comment> comments;
  final bool loadingComments;
  final bool hasMore;
  final DocumentSnapshot? lastDoc;
  final CommentOrder order;

  //현재 로그인 사용자가 좋아요를 누른 commentId 집합
  final Set<String> likedIds;
  //게시글작성 후 돌아오는게시글id

  const CommunityDetailState({
    this.post,
    this.loadingPost = false,
    this.comments = const [],
    this.loadingComments = false,
    this.hasMore = true,
    this.lastDoc,
    this.order = CommentOrder.latest,
    this.likedIds = const {},
  });

  CommunityDetailState copyWith({
    Community? post,
    bool? loadingPost,
    List<Comment>? comments,
    bool? loadingComments,
    bool? hasMore,
    DocumentSnapshot? lastDoc,
    CommentOrder? order,
    Set<String>? likedIds,
  }) => CommunityDetailState(
    post: post ?? this.post,
    loadingPost: loadingPost ?? this.loadingPost,
    comments: comments ?? this.comments,
    loadingComments: loadingComments ?? this.loadingComments,
    hasMore: hasMore ?? this.hasMore,
    lastDoc: lastDoc ?? this.lastDoc,
    order: order ?? this.order,
    likedIds: likedIds ?? this.likedIds,
  );
}

 
 
 // 댓글 페이지 로드
  Future<void> _loadComments(WidgetRef ref, {bool reset = false}) async {
    if (state.loadingComments) return;
    if (!reset && !state.hasMore) return;

    state = state.copyWith(loadingComments: true);
    try {
      final page = await ref
          .read(fetchCommentsProvider)
          .call(
            communityId: communityId,
            order: state.order,
            limit: 20,
            startAfter: reset ? null : state.lastDoc,
          );

      state = state.copyWith(
        comments: reset ? page.items : [...state.comments, ...page.items],
        lastDoc: page.lastDoc,
        hasMore: page.hasMore && page.items.isNotEmpty,
      );
    } catch (e) {
      debugPrint('fetch comments error: $e');
    } finally {
      state = state.copyWith(loadingComments: false);
    }
  }
  
    //무한 스크롤 추가 로드
  Future<void> loadMore(WidgetRef ref) async {
    final beforeCount = state.comments.length;
    await _loadComments(ref, reset: false);
    // 새 댓글이 붙었다면 그 부분에 대한 좋아요 집합도 갱신
    if (state.comments.length > beforeCount) {
      await _loadLikedSet(ref);
    }
  }

 

 

 

 

게시글상세화면에서 댓글 리스트 호출하는 부분

 

Widget _pagedList(WidgetRef ref, CommunityDetailState st) {
  return NotificationListener<ScrollNotification>(
    // 자식(ListView 등) 스크롤 이벤트를 이 콜백에서 받는다.
    onNotification: (n) {
      // 위치가 변할 때마다 들어오는 업데이트 알림만 처리
      if (n is ScrollUpdateNotification) {
        // 바닥까지 남은 픽셀 거리
        final remain = n.metrics.maxScrollExtent - n.metrics.pixels;

        // 200px 이내로 접근했고, 현재 로딩 중이 아니며, 더 가져올 게 있으면 페이지 로드
        if (remain < 200 && !st.loadingComments && st.hasMore) {
    
          ref.read(provider.notifier).loadMore(ref);
        }
      }
      // 알림을 상위로 계속 올림
      return false;
    },
    child: CommentList(
      itemCount: st.comments.length,
      likeCountOf: (i) => st.comments[i].likeCount,
      uidOf: (i) => st.comments[i].uid,
      textOf: (i) => st.comments[i].noteDetail,
      loading: st.loadingComments,
      isLikedOf: (i) => st.likedIds.contains(st.comments[i].id),
      onToggleLike: (i) =>
          ref.read(provider.notifier).toggleLike(ref, st.comments[i].id),
      createdAtOf: (i) => st.comments[i].createAt,
    ),
  );
}

 

 

 

 

댓글섹션

// --- 댓글 리스트 ---
// 기존 CommentCard의 주석과 형태를 유지하되, VM 데이터로 교체
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ja_chwi/presentation/providers/user_profile_by_uid_provider.dart.dart';
import 'package:ja_chwi/presentation/screens/community/widgets/community_detail_screen_widget/heart_button.dart';

class CommentList extends ConsumerWidget {
  const CommentList({
    super.key,
    required this.itemCount,
    required this.uidOf,
    required this.textOf,
    required this.likeCountOf,
    required this.loading,
    required this.isLikedOf,
    required this.onToggleLike,
    required this.createdAtOf,
  });

  final int itemCount;
  final String Function(int) uidOf;
  final String Function(int) textOf;
  final int Function(int) likeCountOf;
  final bool loading;
  final bool Function(int) isLikedOf;
  final void Function(int) onToggleLike;
  final DateTime Function(int) createdAtOf;
  //댓글 시간표시 핼퍼
  String timeAgo(DateTime dt) {
    final now = DateTime.now();
    Duration diff = now.difference(dt);
    if (diff.isNegative) diff = Duration.zero; // 서버시간 오차 가드

    final mins = diff.inMinutes;
    if (mins < 60) return '${mins <= 0 ? 1 : mins}분 전';

    final hours = diff.inHours;
    if (hours < 24) return '$hours시간 전';

    final days = diff.inDays;
    return '$days일 전';
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    //댓글카운트
    if (itemCount == 0) {
      if (loading) {
        return const Center(child: CircularProgressIndicator());
      }
      return const Center(
        child: Padding(
          padding: EdgeInsets.only(bottom: 100),
          child: Text(
            '댓글이 아직 없습니다',
            style: TextStyle(color: Colors.grey),
          ),
        ),
      );
    }

    // shrinkWrap/physics 건드리지 않기
    return ListView.separated(
      padding: const EdgeInsets.only(left: 24, right: 24, bottom: 100),
      itemCount: itemCount + (loading ? 1 : 0),
      separatorBuilder: (_, __) => const Divider(height: 1),
      itemBuilder: (context, i) {
        if (i >= itemCount) {
          return const Padding(
            padding: EdgeInsets.all(16),
            child: Center(child: CircularProgressIndicator()),
          );
        }

        return GestureDetector(
          onLongPressStart: (details) async {
            //details = onLongPressStart했을떄 정보
            final scaffold = ScaffoldMessenger.of(context);
            //현재화면의 최상단 레이어(Overlay)를 찾고 그 랜더박스 정보 제공, 목적: 화면전체 크기를 얻어 메뉴 위치계산에 사용
            final overlay =
                Overlay.of(context).context.findRenderObject() as RenderBox;

            //showMenu : 팝업 메뉴 표시
            final selected = await showMenu<String>(
              //꾹 눌렀을때 나오는 메뉴 모양 커스텀
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadiusGeometry.circular(15),
              ),
              context: context,

              //작은사각형이 큰 사각형의 어디있는지 상대좌표로 변환하여 메뉴 시작위치가 터치 지점으로 잡힘
              position: RelativeRect.fromRect(
                //사용자가 누른 지점을 0,0사이즈의 사각형으로 표현
                Rect.fromLTWH(
                  details.globalPosition.dx,
                  details.globalPosition.dy,
                  0,
                  0,
                ),

                //화면 전체를 덮는 사각형
                Offset.zero & overlay.size,
              ),
              color: Colors.white,
              items: [
                PopupMenuItem(
                  value: 'report',
                  child: Row(
                    children: const [
                      Text('신고하기'),
                      SizedBox(width: 50),
                      Spacer(),
                      Icon(Icons.notifications_none),
                    ],
                  ),
                ),
                PopupMenuItem(
                  value: 'block',
                  child: Row(
                    children: const [
                      Text('차단하기'),
                      Spacer(),
                      Icon(Icons.do_not_disturb_on_outlined),
                    ],
                  ),
                ),
              ],
            );

            //selected의 value에 따라 기능실행
            switch (selected) {
              case 'report':
                // 신고 처리
                scaffold.showSnackBar(const SnackBar(content: Text('신고 완료')));
                break;
              case 'block':
                // 차단 처리
                scaffold.showSnackBar(const SnackBar(content: Text('차단 완료')));
                break;
              case null:
                // 메뉴 밖을 눌러 닫힘. 아무것도 하지 않음.
                break;
            }
          },
          child: Container(
            color: Colors.white,
            height: 80,
            child: Row(
              children: [
                SizedBox(
                  height: 45,
                  width: 45,
                  child: Builder(
                    builder: (_) {
                      final uid = uidOf(i);
                      final av = ref.watch(profileByUidProvider(uid));
                      return av.when(
                        data: (p) => ClipRRect(
                          borderRadius: BorderRadius.circular(22.5),
                          child: (p.thumbUrl.isNotEmpty
                              ? Image.asset(p.thumbUrl)
                              : (p.imageFullUrl.isNotEmpty
                                    ? Image.asset(p.imageFullUrl)
                                    : Image.asset(
                                        'assets/images/m_profile/m_black.png',
                                      ))),
                        ),
                        loading: () => Container(
                          decoration: const BoxDecoration(
                            shape: BoxShape.circle,
                          ),
                          child: const CircleAvatar(radius: 22.5),
                        ),
                        error: (_, __) => const CircleAvatar(
                          radius: 22.5,
                          child: Icon(Icons.person),
                        ),
                      );
                    },
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        children: [
                          //작성자이름
                          Builder(
                            builder: (_) {
                              final uid = uidOf(i);
                              final av = ref.watch(profileByUidProvider(uid));
                              final nickname = av.maybeWhen(
                                data: (p) => p.nickname,
                                orElse: () => uid, // 로딩/에러 시 임시로 uid
                              );
                              return Text(
                                nickname,
                                style: const TextStyle(
                                  fontWeight: FontWeight.w600,
                                ),
                                overflow: TextOverflow.ellipsis,
                              );
                            },
                          ),
                          SizedBox(
                            width: 8,
                          ),
                          Text(
                            timeAgo(createdAtOf(i)),
                            style: const TextStyle(
                              fontSize: 12,
                              color: Colors.grey,
                            ),
                          ),
                        ],
                      ),
                      Text(
                        //댓글내용
                        textOf(i),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ],
                  ),
                ),

                HeartButton(
                  liked: isLikedOf(i),
                  count: likeCountOf(i),
                  onPressed: () => onToggleLike(i),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

 

 

스크린에서 

 late final communityDetailVmProvider = communityDetailVmProvider(widget.id);

로 provider 인스턴스를 만든다.

 

 

그리고 initState에서 초기 로드를 진행한다.

Future.microtask(fn)은 지금 실행 중인 코드가 끝나자마자 fn을 바로 실행한다. 같은 프레임 안. 타이머나 Future((){})보다 먼저 돈다. initState에서 초기 로딩을 빨리 시작하되, 현재 호출 스택이 끝난 뒤 실행하고 싶을 때 사용한다.

  @override
  void initState() {
    super.initState();
    // 첫 진입 시 단건 게시글 + 댓글 초기 로드
    Future.microtask(
      () => ref.read(communityDetailVmProvider.notifier).loadInitial(ref),
    );
  }

**참고**

실행순서

print('A');
Future.microtask(() => print('B'));   // A 다음, 가장 먼저
Future(() => print('C'));             // B 다음
WidgetsBinding.instance.addPostFrameCallback((_) => print('D')); // 첫 렌더 후
print('E');
// 출력: A, E, B, C, D

 

*****

 

빌드에서 

  final st = ref.watch(communityDetailVmProvider);

로 스테이트를 구독하면 현재상태의 커뮤니티(게시글) 상세 정보가 불러와진다.

이 st에는 comments라는 리스트가 있기때문에 st.comments를 이용해서 댓글리스트를 컨트롤한다.