1. 요구사항 체크
리뷰 요청 기능에 대해서는 꽤나 오랜 시간동안 팀 내에서 이야기가 나왔어요.
별점 5점을 받고자 하는 것도 있었지만, 실제로 유저가 사용하면서 느끼고 있는 생각을 듣고 싶었거든요.
그래서 백업 기능 이후에 리뷰 요청 기능에 대해서 개발하자고 팀원과 이야기 하게 되었고,
제가 백업 기능 개발을 하고 있는 시간에 디자이너 팀원들은 리뷰 요청 기능을 먼저 기획/디자인을 시작했어요.
1-1. UI
BottomSheet을 통해서 리뷰 요청 팝업이 뜰 수 있도록 하는 요구사항이 핵심입니다!
어느 하나의 뷰에 종속되지 않았기 때문에, 실제로 어떤 뷰에서 띄워야 하는 지가 가장 중요한 요구사항이라고 생각했는데요.
그러기 위해서는 UX Flow를 참고해 어떤 뷰에서 떠야 하는지 자세히 체크해야,
BottomSheet 관련 Dependency를 어디에 걸어야 할 지 답이 나올 것 같았습니다.
1-2. UX Flow
프로덕트 디자인을 담당하는 팀원이 만든 UX Flow 입니다.
복잡해 보이지만(?) 핵심은 아래처럼 이해했어요.
리뷰 요청을 언제하는가?
- 메뉴얼을 5개, 10개, 20개를 작성했을 때
- 겹쓰기를 1개, 5개 작성했을 때
- 리뷰를 쓰지 않고 24시간이 지나고 위 1, 2번 트리거 상황
리뷰 요청을 어디서하는가?
- 글을 작성하고 나서 - 메뉴얼 홈 스크린
- 겹쓰기를 작성하고 나서 - 메뉴얼 읽기 스크린
리뷰 요청의 유저 액션은 무엇인가?
- 리뷰 작성하기
- 건의하기
- 그냥 꺼버리기 😭
위 UX Flow를 위처럼 언제, 어디서, 어떻게를 완벽하게 정리하고, 개발을 시작했습니다.
UI를 그리는 것은 그냥.. UI 요구사항에 따라서 그리면 되는데, DB 정의와 유저 액션을 어떻게 처리할 지 고민을 해야겠네요.
2. 구현해보자!
요구사항을 분석했으니 이제 실제로 코드를 치면서 개발할 시간입니다.
가장 먼저 필요한 DB를 설게하고 Realm Swift로 객체를 만들었어요.
2-1. DB
유저의 액션을 담을 수 있는 변수 2가지와 시간을 담는 필드를 만들었습니다.
그러면 유저가 실제로 리뷰 요청에 반응했는지, 거절했는지, 그리고 그 시기를 판단할 수 있기때문에,
주어진 요구사항을 이 3가지 필드면 만족할 수 있다고 생각했어요.
타입 | 필드명 | 역할 |
ObjectId | _id | PrimaryKey |
Bool | isRejected | 리뷰 요청에 거절했는지 |
Bool | isApproved | 리뷰 요청에 응했는지 (실제로 리뷰를 남겼는지는 체크할 수 X) |
Date | createdAt | 위 응답을 남긴 Date |
public class ReviewModelRealm: Object, Codable {
@Persisted(primaryKey: true) public var _id: ObjectId
@Persisted public var isRejected: Bool
@Persisted public var isApproved: Bool
@Persisted public var createdAt: Date?
}
2-2. Repository (Usecase)
메뉴얼에서는 큰 범위를 나누어 DB를 건드려야 하는 부분은 모두 Repository에게 위임하고 있어요.
Menual 글 작성/수정/삭제를 담당하는 DiaryRepository,
유저에게 추천 Menual을 선정/삭제를 담당하는 MomentsRepository 가 대표적이에요.
위 Repository는 각자 담당하는 기능이 명확하기 때문에, 리뷰 요청 기능을 위 Repository에게 위임하기에는 맞지 않다고 생각했습니다.
그래서 추가로 AppstoreReviewRepository를 생성하고, 리뷰 관련 기능은 이 Repository에게 위임하고자 합니다.
이 Repository는 메뉴얼 홈, 글 상세보기 스크린에 Dependency로 주입하고 있습니다.
기능
함수 | 기능 |
needReviewPopup() → Bool | 리뷰 팝업이 필요한지 체크하는 로직 (UX Flow 참고) |
rejectReview() | 리뷰에서 건의하기 버튼을 눌렀을 경우, DB에 있는 isRejected값을 true로 변경하는 로직 |
approveReview() | 리뷰에서 리뷰하기 버튼을 눌렀을 경우, DB에 있는 isApproved값을 true로 변경하는 로직 |
hasPast24Hours() | 리뷰 요청 거절 등 유저의 액션이 실제로 24시간이 지났는지 체크하는 함수 |
needReviewPopup() → Bool
리뷰 요청 중에 가장 중요한 로직이라고 할 수 있는데요.
어떤 시점에 리뷰 팝업을 띄울 지 결정하는 함수입니다.
트리거는 많지 않지만 경우의 수가 많아서 if/else로 Depth가 깊어지지 않게 코드를 플랫하게 짜도록 노력했어요.
public func needReviewPopup() -> Bool {
guard let realm = Realm.safeInit() else { return false }
// 기본적으로 리뷰는 필요하지 않다고 판단하고 로직 진행
var needReview: Bool = false
// 메뉴얼이 5개, 10개, 20개일 경우를 체크하기 위해
let checkCountArr: [Int] = [5, 10 , 20]
// 삭제되지 않은 메뉴얼 개수
let diaryCount: Int = realm.objects(DiaryModelRealm.self)
.filter ({ $0.isDeleted == false })
.count
// 삭제되지 않은 겹쓰기 개수
let replyCount: Int = realm.objects(DiaryReplyModelRealm.self)
.filter ({ $0.isDeleted == false })
.count
print("AppstoreReviewRepository :: diaryCount = \(diaryCount), replyCount = \(replyCount)")
// 5개, 10개, 20개에 포함되는 경우
// 일단 리뷰가 필요하다고 판단
if checkCountArr.contains(diaryCount) || checkCountArr.contains(replyCount) {
needReview = true
}
// 리뷰가 필요하지 않은 경우 (트리거에 해당하지 않는 경우)
// return false
if !needReview { return false }
// 유저가 이전에 이미 리뷰를 작성했거나 거절했을 경우 2차 판단을 위해 realm 가져옴
guard let reviewModelRealm: ReviewModelRealm = realm.objects(ReviewModelRealm.self).first else { return false }
// reviewModelRealm에서 createAt이 nil일 경우에는 리뷰 요청 가능하므로 true리턴
guard let date = reviewModelRealm.createdAt else { return true }
// 리뷰에 응답한 결과가 있을 경우
// 24시간 이내일 경우에는 return false
if !hasPast24Hours(from: date) {
return false
}
// 리뷰 요청을 했지만 24시간이 지난 경우
// 리뷰 작성을 했으면 다시 요청하지 않음
if reviewModelRealm.isApproved { return false }
// 리뷰 요청을 받았지만, 24시간이 지나고, 거절한 유저는 한 번 더 요청
return true
}
2-3. UI개발
이전까지 이미 사용하고 있는 BottomSheetViewControlelr가 있기때문에,
이 VC에 Review Type BottomSheet에서만 나타날 수 있는 ReviewComponentView를 생성했습니다.
BottomSheetViewController의 로직은 BottomSheetInteractor가 처리하고 있고,
이마저도 BottomSheet 자체에 로직이 꼭 필요한 경우가 아니라면
BottomSheet을 띄운 부모 RIB에서 실제 로직을 처리할 수 있도록 로직 처리를 위임하고 있어요.
그래서 생각보다 로직 처리의 단계가 많아지는 문제가 생겼습니다.
1. ComponenetView(Delegate) →
2. BottomSheetViewControlelr(ByPass) →
3. BottomSheetInteractor(ByPass) →
4. BottomSheet을 띄운 RIB (Destination)
CompoenentView에서도 대부분의 유저 액션을 Delegate로 넘겨주고 있고,
이 Delegate를 받아서 Interactor가 부모 RIB에게 실제 유저의 액션과 로직에 필요한 정보를 ByPass하고 있어요.
위처럼 개발한 이유는, 최대한 BottomSheetViewController 자체를 재사용하고,
ComponenetView만 갈아끼우면서 사용하고자 했거든요.
그러다보니, BottomSheet에 유저 액션이 많다면 위 ByPass하는 로직때문에, 약간 어지럽게 되었습니다. ㅠ
그래서 아래의 코드에서도 확인할 수 있듯, 유저의 액션은 직접 바인딩하지 않고 Delegate로 상위 View에게 넘기고 있답니다.
public protocol MenualBottomSheetReviewComponentViewDelegate: AnyObject {
func pressedPraiseBtn()
func pressedInquiryBtn()
}
public class MenualBottomSheetReviewComponentView: UIView {
public weak var delegate: MenualBottomSheetReviewComponentViewDelegate?
private let titleLabel = UILabel().then {
$0.font = UIFont.AppTitle(.title_5)
$0.textColor = Colors.grey.g100
$0.translatesAutoresizingMaskIntoConstraints = false
$0.text = "메뉴얼 사용은 즐거우신가요?"
}
private let imageView = UIImageView().then {
$0.image = Asset.Illurstration.suggestReview.image
}
private let subTitleLabel = UILabel().then {
$0.font = UIFont.AppBodyOnlyFont(.body_2)
$0.numberOfLines = 2
$0.textColor = Colors.grey.g100
$0.text = "여러분의 목소리를 통해\n더 나은 메뉴얼을 제공해드릴게요!"
$0.textAlignment = .center
$0.setLineHeight(lineHeight: 1.24)
}
private lazy var reviewBtn = BoxButton(frame: .zero, btnStatus: .active, btnSize: .large).then {
$0.addTarget(self, action: #selector(pressedReviewBtn), for: .touchUpInside)
$0.title = "칭찬하기"
$0.translatesAutoresizingMaskIntoConstraints = false
}
private lazy var inquiryBtn = UIButton().then {
$0.addTarget(self, action: #selector(pressedInquiryBtn), for: .touchUpInside)
$0.titleLabel?.textColor = Colors.grey.g200
$0.titleLabel?.font = UIFont.AppBodyOnlyFont(.body_4)
$0.setTitle("건의하기", for: .normal)
}
public override init(frame: CGRect) {
super.init(frame: frame)
setViews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func layoutSubviews() {
super.layoutSubviews()
}
func setViews() {
addSubview(titleLabel)
addSubview(imageView)
addSubview(subTitleLabel)
addSubview(reviewBtn)
addSubview(inquiryBtn)
titleLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalToSuperview().offset(32)
}
imageView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(45)
make.centerX.equalToSuperview()
}
subTitleLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalTo(imageView.snp.bottom).offset(40)
}
reviewBtn.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(20)
make.width.equalToSuperview().inset(20)
make.height.equalTo(56)
make.top.equalTo(subTitleLabel.snp.bottom).offset(16)
}
inquiryBtn.snp.makeConstraints { make in
make.top.equalTo(reviewBtn.snp.bottom).offset(16)
make.centerX.equalToSuperview()
}
}
}
extension MenualBottomSheetReviewComponentView {
@objc
func pressedReviewBtn() {
delegate?.pressedPraiseBtn()
}
@objc
func pressedInquiryBtn() {
delegate?.pressedInquiryBtn()
}
}
3. 마무리
리뷰 요청같은 경우, 아래 이미지처럼 애플이 제공해주는 API를 사용하면 쉽게 사용할 수 있어요.
하지만, 이 리뷰 요청같은 경우에 실제로 유저가 어떤 액션을 했는지 개발자 입장에서는 확인할 수 없고,
리뷰 요청 팝업을 띄우는 위치는 지정할 수 있지만 뜨는 시점이 랜덤이라는 단점이 있어요.
그만큼 리뷰 요청 Depth가 적어 유저는 즉각적으로 피드백을 남길 수 있다는 장점도 있겠죠!
(아주 빠르게 1점이나 5점을 받을 수 있는)
메뉴얼의 경우에는 실제로 유저가 특정한 메뉴얼 개수를 작성했을때, 유저의 경험을 묻고 싶었어요.
단순 별이 다섯개!뿐만 아니라, 리뷰를 거절했다면 혹시나 어떤 이유에 거절했는지 등..
아무래도 실제로 메일 앱을 켜서 메일을 보내는 건의하기 같은 경우에는,
유저 입장에서 약간 큰 미션을 안기는 것 같아 조금은 걱정되기는 합니다.
그래서 리뷰 요청하기 기능같은 경우에는..
서술형보다는 객관식으로 부족한 점을 간단하게 받아갈 수 있는 방법도 추가적으로 정리해야
원래 목적에 부합하는 리뷰 요청 기능이 되지 않을까합니다!
먼저 앱스토어에 업데이트하고 유저의 반응을 보고 추후에 업데이트하고자 합니다.
'Project > Menual' 카테고리의 다른 글
[iOS] 메뉴얼은 왜 이미지를 하나만 업로드할 수 있었을까? (0) | 2023.12.02 |
---|---|
[iOS] UX Writing을 보다 편하게 관리하기 위한 노력 (1) | 2023.03.05 |
[iOS] 출시 후 첫 신규기능 <온보딩> 업데이트 후기 (0) | 2023.02.12 |
[iOS] 메뉴얼(Menual)은 RIBs를 어떻게 활용했을까? (0) | 2023.01.01 |
[iOS] 일기장 어플리케이션 메뉴얼(Menual) 출시 회고 (0) | 2022.12.27 |