[Flutter] 앱 스토어 리뷰 요청 기능 구현하기

리뷰 요청 기능을 추가한 이유

앱 출시 후 약 6개월이 지났지만, 아직 앱 스토어에 작성된 리뷰가 없다.

리뷰가 하나라도 있으면 앱의 신뢰도를 높이는 데 도움이 될 것 같다는 생각이 들었다.

그래서 앱 내에 리뷰 요청 기능을 추가해서 적극적으로 리뷰 작성을 유도하기로 했다.

 

리뷰를 요청하는 두 가지 방법

인앱 리뷰 API 사용하기

안드로이드와 iOS 모두 인앱 리뷰를 요청할 수 있는 API를 제공한다.

UI를 따로 구성할 필요가 없기 때문에 가장 간단하게 구현할 수 있는 방식이지만

OS별로 제한사항이 많기 때문에, 사용자 경험을 커스터마이징 하기 어렵다.

 

앱은 사용자에게 평점 버튼 또는 카드를 표시하기 전이나 표시하는 동안 사용자 의견 관련 질문 (예: '앱이 마음에 드십니까?') 또는 예측 질문 (예: '이 앱을 별 5개로 평가하시겠습니까?')을 포함하여 어떤 질문도 해서는 안 됩니다.

안드로이드의 경우에는 인앱 리뷰를 요청하기 전에 어떤 질문도 해서는 안 되고, 한 달에 2번 등 할당량이 정해져 있다.

 

사람들이 앱 또는 게임을 직접 체험하고 난 다음에만 평가를 요청하십시오.
사람들이 작업을 수행 중이거나 게임을 플레이하는 도중에 방해하는 것을 피하십시오.
반복적인 요청을 피하십시오.
가급적 시스템 제공 요청을 사용하십시오.

iOS의 경우에는 1년에 최대 3번까지만 인앱 리뷰를 요청할 수 있고, 상세한 가이드라인을 제시한다.

 

직접 구현하기

커스텀 다이얼로그를 노출시켜서 앱스토어 링크로 이동시킬 수도 있다.

이 경우에는 리뷰를 요청하기 전에 리뷰 작성을 유도하는 문구를 자유롭게 작성할 수 있지만

리뷰 페이지로 이동해야 한다는 점에서 인 앱 리뷰에 비해서 접근성이 떨어진다.

 

리뷰 요청 전략 설계 및 주의사항

사용자에게 리뷰를 요청할 때는 다음과 같은 사항을 고려해야 한다.

 

1. 언제 요청할까?

  • 사용자들이 앱을 충분히 사용하지 못한 상황에서 앱 평가를 요청하면 부정적인 평가를 남길 가능성이 크기 때문에 앱 설치 직후에 요청하는 것은 좋지 않다.
  • 사용자들이 앱 기능을 충분히 사용한 뒤에 요청하는 것이 좋다.
  • 기능 사용 직후 등 사용자가 성취감이나 만족감을 느꼈을 때 요청하는 것이 가장 좋다.
  • 사용자가 작업을 수행 중일 때 리뷰 요청을 하는 등 사용자 경험을 방해해서는 안 된다.

 

2. 얼마나 자주 요청할까?

  • 반복되는 평가 요청은 오히려 앱에 대한 사용자들의 의견에 부정적인 영향을 미칠 수 있다.
  • 최소 1~2주의 간격을 두고, 사용자들이 추가적으로 앱을 경험한 다음에 요청을 하는 것이 좋다.

 

리뷰 요청 로직

리뷰 요청 방식은 크게 두 가지로 나눌 수 있다.

 

첫 번째는 사용자에게 [지금 리뷰 작성하기] 또는 [나중에] 두 가지 선택지만 제공하는 방식이다.

나는 간단하게 구현하기 위해 이 방식을 사용했다.

 

두 번째는 앱에 대한 만족도를 먼저 질문하고, 사용자의 반응에 따라 각기 다른 행동을 유도하는 방식이다.

 

예를 들어 "앱이 만족스러우셨나요?"라는 질문에

"네"를 선택하면 리뷰 작성을 요청하고,

"아니오"를 선택하면 피드백 작성 페이지로 안내한다.

 

이 방식을 사용하면 긍정적인 이용 경험을 가진 사용자에게 높은 평점의 리뷰를 받을 확률이 높아지고,

불편함을 느낀 사용자에게는 직접 피드백을 받아 앱을 개선할 수 있는 기회가 생긴다는 장점이 있다.

 

실제 기능 구현

리뷰 요청 조건

교정일기 앱에서는 타임라인 탭에서 치료 내역 작성, 앨범 탭에서 치아 사진을 업로드할 수 있는데

타임라인+사진을 더해서 5건 이상 작성했을 때 최초로 리뷰 요청 다이얼로그를 노출하기로 했다.

 

구현 코드

// 상태 저장 (SharedPreferences)
class ReviewPrefs {
  final bool? reviewCompleted; // 리뷰 작성 완료 여부 - [리뷰 작성] 버튼 클릭만 해도 작성한 것으로 간주함
  final DateTime? lastRejectedAt; // 마지막으로 [나중에] 버튼 클릭한 시간

  ReviewPrefs({
    required this.reviewCompleted,
    required this.lastRejectedAt,
  });
}

`reviewCompleted`가 `true`가 아닌 경우에만 리뷰 요청 다이얼로그를 노출한다.

`lastRejectedAt`은 마지막 다이얼로그 노출 시점으로부터 한 달이 지났는지를 계산하기 위해 사용된다.

 

import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';

class ReviewRequest {
  // 글 작성할 때마다 리뷰 요청 조건 확인
  Future<bool> shouldShowReviewDialog() async {
    final reviewPrefs = await getReviewPrefs();
    final reviewCompleted = reviewPrefs.reviewCompleted;
    final lastRejectedAt = reviewPrefs.lastRejectedAt;

    // 이미 리뷰 작성 완료한 경우
    if (reviewCompleted == true) {
      return false;
    }

    // 30일 이내에 '나중에 보기'를 눌렀는지 확인
    if (lastRejectedAt != null) {
      final daysSince = DateTime.now().difference(lastRejectedAt).inDays;
      if (daysSince < 30) return false; // 아직 한 달 안 지남
    }

    // 일정 횟수 이상 앱 사용 조건 충족 여부 확인
    final hasEnoughActivity = await hasEnoughReviewActivity();
    if (!hasEnoughActivity) return false;

    return true;
  }

  // 타임라인 + 치아사진 5건 이상 작성했는지 확인
  Future<bool> hasEnoughReviewActivity() async {
    final timelineCount = Get.find<TimelineController>().timelineData.length;
    final photoCount = Get.find<TeethPhotoController>().teethPhotoData.length;

    return (timelineCount + photoCount) >= 5;
  }

  // [리뷰 작성] 버튼 클릭 시 리뷰 작성 완료 처리
  Future<void> markReviewCompleted() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool('review_completed', true);
  }

  // [나중에] 버튼 선택 시 마지막 요청 시간 업데이트
  Future<void> updateReviewRejecteddDate() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('last_rejected_at', DateTime.now().toIso8601String());
  }
}

아직 리뷰를 작성하지 않았고,

마지막 리뷰 요청 시점으로부터 한 달이 지났고,

게시물을 5건 이상 작성했다는 3가지 조건을

모두 충족한 경우에만 리뷰 요청 다이얼로그가 노출된다.

 

// 리뷰 요청 다이어로그
Future<void> showReviewDialog(BuildContext context) async {
  final reviewRequest = ReviewRequest();

  return showDialog(
    context: navigatorKey.currentContext!,
    builder: (BuildContext context) {
      return AlertDialog(
        title: const Text(
          "교정일기가 도움이 되었나요?",
        ),
        content: const Text(
          "앱을 이용해 주셔서 감사합니다. 잠시 시간을 내어 리뷰를 남겨 주시면 앱을 지속적으로 업데이트하는 데 큰 힘이 됩니다!",
        ),
        actions: [
          ElevatedButton(
            // 나중에 버튼 클릭 시 마지막 요청 시간 업데이트
            onPressed: () {
              reviewRequest.updateReviewRejecteddDate();
              Navigator.of(context).pop();
            },
            child: const Text("나중에"),
          ),
          FilledButton(
            // 리뷰 작성 버튼 클릭 시 앱스토어 이동 및 리뷰 작성 완료 처리
            onPressed: () async {
              final uri = Uri.parse(googleStoreUrl);
              await launchUrl(uri);
              reviewRequest.markReviewCompleted();
              reviewRequest.updateReviewRejecteddDate();
              Navigator.of(context).pop();
            },
            child: const Text("리뷰 작성하기"),
          ),
        ],
      );
    },
  );
}

사용자가 [리뷰 작성하기] 버튼을 누르면 앱 스토어 페이지로 이동하고,

리뷰를 작성한 것으로 간주하여 더 이상 다이얼로그를 노출하지 않는다.

(개인 정보 보호와 정책상 이유로 사용자의 리뷰 작성 여부를 확인할 수 있는 API를 제공하지 않는다고 한다...)

 

[나중에] 버튼을 누르면 마지막 요청 시간을 업데이트하고, 한 달 후에 다시 리뷰 작성을 요청한다.

 

// 새로 작성하는 경우에만 리뷰 요청 조건 확인: 탭 이동 후 1초 뒤에
if (widget.photo == null) {
    Future.delayed(const Duration(seconds: 1), () async {
      final reviewRequest = ReviewRequest();
      if (await reviewRequest.shouldShowReviewDialog()) {
        await showReviewDialog();
      }
    });
}

사용자가 게시물을 새로 작성할 때마다

`shouldShowReviewDialog()`를 실행해서 다이얼로그 노출 여부를 결정한다.

 

글 작성이 완료되면 스크린을 이동하고

1초 후에 리뷰 요청 조건을 확인해서 다이얼로그가 노출되도록 했다.

 

결과물

리뷰 요청 기능이 완성된 모습이다!

(iOS는 리뷰 작성 섹션으로 바로 이동할 수 있는 링크가 있는 반면에 안드로이드는 제공하지 않는 것 같다...)

 

참고 문서