앱 평점 전략: 리뷰 요청 타이밍과 SKStoreReviewAPI 구현

모바일 개발

앱 평점SKStoreReviewAPIIn-App Review앱스토어 최적화ASO

이 글은 누구를 위한 것인가

  • 앱 평점이 3.5점대에 머물러 있는 팀
  • 리뷰 요청을 언제, 어떻게 해야 하는지 모르는 개발자
  • 부정적인 리뷰를 줄이고 긍정적인 리뷰를 늘리고 싶은 팀

들어가며

앱 평점 4.5+ vs 3.5의 전환율 차이는 30%에 달한다. 리뷰 요청은 아무 때나 하면 역효과다. 사용자가 가치를 경험한 직후 적절한 방식으로 요청해야 한다.

이 글은 bluebutton.kr의 앱스토어 최적화 가이드 를 참고하여 작성했습니다.


1. 리뷰 요청 전략

[리뷰 요청 타이밍 원칙]

나쁜 타이밍:
  앱 첫 실행 직후
  에러 발생 직후
  긴 작업 도중 방해
  → 1점 리뷰 유발

좋은 타이밍:
  중요한 성공 이벤트 직후
    - 주문 완료
    - 게임 레벨 클리어
    - 파일 저장 완료
  N번째 사용 (3회, 5회, 10회)
  특정 기간 후 재방문 (7일 후)

[요청 전 만족도 확인]

2단계 접근:
  1단계: "이 앱이 마음에 드시나요?" (예/아니오)
  2단계:
    예 → SKStoreReviewAPI 호출
    아니오 → 피드백 폼 표시 (앱 내부)

효과:
  부정적 사용자를 내부 피드백으로 유도
  앱스토어에는 긍정적 사용자만 도달
  → 평균 평점 0.3-0.5점 상승

[Apple 제한 사항]
  SKStoreReviewAPI: 연 3회 제한
  직접 이동 금지 (앱스토어 팝업 직접 열기)
  인센티브 제공 금지 (정책 위반)
  타이밍은 앱이 제어하지만 UI는 시스템 제공

[Android]
  In-App Review API: Google Play 공식 지원
  연 횟수 제한 없음 (Google 내부 알고리즘)
  사용자가 앱을 이미 평가했으면 자동 스킵

2. 리뷰 요청 구현

// iOS Swift
import StoreKit
import SwiftUI

class ReviewManager {
    static let shared = ReviewManager()
    
    private let minActionsBeforePrompt = 3
    private let minDaysBetweenPrompts = 90
    
    private var actionCount: Int {
        get { UserDefaults.standard.integer(forKey: "action_count") }
        set { UserDefaults.standard.set(newValue, forKey: "action_count") }
    }
    
    private var lastPromptDate: Date? {
        get { UserDefaults.standard.object(forKey: "last_prompt_date") as? Date }
        set { UserDefaults.standard.set(newValue, forKey: "last_prompt_date") }
    }
    
    // 중요한 사용자 액션 기록
    func recordSignificantAction() {
        actionCount += 1
        checkAndRequestReview()
    }
    
    private func checkAndRequestReview() {
        guard actionCount >= minActionsBeforePrompt else { return }
        
        if let lastDate = lastPromptDate {
            let daysSinceLastPrompt = Calendar.current.dateComponents(
                [.day], from: lastDate, to: Date()
            ).day ?? 0
            guard daysSinceLastPrompt >= minDaysBetweenPrompts else { return }
        }
        
        requestReview()
    }
    
    private func requestReview() {
        DispatchQueue.main.async {
            if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
                SKStoreReviewController.requestReview(in: scene)
                self.lastPromptDate = Date()
                self.actionCount = 0
            }
        }
    }
    
    // 앱스토어 직접 이동 (리뷰 쓰러 가기 버튼)
    func openAppStoreForReview(appId: String) {
        let url = URL(string: "https://apps.apple.com/app/id\(appId)?action=write-review")!
        UIApplication.shared.open(url)
    }
}

// 2단계 만족도 체크 뷰
struct SatisfactionCheckView: View {
    @State private var showFeedbackForm = false
    @State private var isVisible = true
    
    var onDismiss: () -> Void
    
    var body: some View {
        if isVisible {
            VStack(spacing: 16) {
                Text("이 앱이 마음에 드시나요?")
                    .font(.headline)
                
                HStack(spacing: 20) {
                    Button("😊 네, 좋아요") {
                        isVisible = false
                        ReviewManager.shared.recordSignificantAction()
                        onDismiss()
                    }
                    .buttonStyle(.borderedProminent)
                    
                    Button("😞 아니요") {
                        showFeedbackForm = true
                        isVisible = false
                    }
                    .buttonStyle(.bordered)
                }
            }
            .padding()
            .background(.regularMaterial)
            .cornerRadius(12)
            .shadow(radius: 5)
            .sheet(isPresented: $showFeedbackForm) {
                FeedbackFormView(onSubmit: onDismiss)
            }
        }
    }
}

struct FeedbackFormView: View {
    @State private var feedback = ""
    var onSubmit: () -> Void
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        NavigationView {
            Form {
                Section("개선이 필요한 점을 알려주세요") {
                    TextEditor(text: $feedback)
                        .frame(height: 150)
                }
            }
            .navigationTitle("피드백")
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("보내기") {
                        submitFeedback()
                        dismiss()
                        onSubmit()
                    }
                }
                ToolbarItem(placement: .cancellationAction) {
                    Button("취소") { dismiss(); onSubmit() }
                }
            }
        }
    }
    
    private func submitFeedback() {
        // 피드백 API 전송
        print("피드백 전송: \(feedback)")
    }
}
// Android In-App Review
import com.google.android.play.core.review.ReviewManagerFactory
import com.google.android.play.core.review.ReviewInfo

class ReviewManager(private val activity: Activity) {
    
    private val reviewManager = ReviewManagerFactory.create(activity)
    
    fun requestInAppReview() {
        val request = reviewManager.requestReviewFlow()
        request.addOnCompleteListener { task ->
            if (task.isSuccessful) {
                val reviewInfo: ReviewInfo = task.result
                val flow = reviewManager.launchReviewFlow(activity, reviewInfo)
                flow.addOnCompleteListener {
                    // 리뷰 플로우 완료 (사용자가 평가했는지 알 수 없음 - 개인정보 보호)
                    // 다음 화면으로 계속 진행
                }
            }
        }
    }
    
    fun showSatisfactionDialog(onPositive: () -> Unit, onNegative: () -> Unit) {
        android.app.AlertDialog.Builder(activity)
            .setTitle("이 앱이 마음에 드시나요?")
            .setPositiveButton("네, 좋아요") { _, _ -> onPositive() }
            .setNegativeButton("아니요") { _, _ -> onNegative() }
            .show()
    }
}

마무리

리뷰 요청의 핵심은 타이밍과 문맥이다. 주문 완료 직후 "앱이 마음에 드셨나요?"는 자연스럽다. 만족도 2단계 체크로 부정적 사용자를 내부 피드백으로 분리하면 평점이 평균 0.4점 이상 오른다. iOS는 연 3회 제한이 있으니 아껴서 사용하라.