Intro
1.
기존 버튼 기능의 대한 플로우 문제점
2.
Spinner UI 와 Optimistic UI
3.
Optimistic 적용하기
4.
Optimistic UI 디자인의 비관적 측면
문제점 :
SNS 플랫폼 특성상 쌍방향 커뮤니케이션 그리고 비교적 빠른 전파 속도로 인해 정보 전달 속도가 빠르다
그래서 빠르게 빠르게 넘어가는 게시글들에 대해 여러 버튼들이 빠르게 인터랙션 되어야 한다고 판단이 되었다.
1. 사용자가 버튼을 클릭
2. 서버에 신호가 전달
3. 서버에서 응답을 보낸다
4. 응답 결과를 보여주는 페이지가 새로 로딩된다.
Dart
복사
이러한 기존의 버튼 인터랙션은 예측이 가능하고 오류가 나타날 가능성이 적다. 하지만 로딩상태일때 비활성화 상태의 버튼이 서버에 신호가 간 것을 확인시켜주고, 서버가 응답을 주면 페이지가 업데이트 되면서 인터렉션이 끝났음을 알려주는 직관성을 가진다.
하지만 이러한 기존의 방식은 다음과 같은 문제들을 가진다.
•
사용자의 인내심을 필요로 한다. 서버 최소 응답시간이 길어질 수록 페이지와 브랜드 이미지에 부정적인 영향을 줄 수 있다.
•
기존의 페이지가 업데이트가 되는 것이 아닌 새로운 페이지가 업로드 되는 방식으로, 작업중이던 사용자의 맥락을 끊는다. 의도하지 않은 맥락의 전환을 보여주기 때문에, 부정적일수 있다.
⇒ SNS 플랫폼 성향에 맞게 해당 기능들을 개선할 적용할 필요가 있다.
How to
Spinner UI 적용?
기존의 플로우의 서버를 기다리는 점은 동일하지만, 스피너를 통해 사용자 측에서 진행중 지표를 눈으로 보여주는 단계가 추가된다.
1. 사용자가 버튼을 클릭
2. 버튼이 비활성화 되면서, 스피너가 버튼 위에 보이고 서버와 통신이 이뤄짐을 알려준다.
3. 서버에 신호가 전달
4. 서버에서 응답을 보낸다.
5. 응답에 따라 버튼과 페이지의 모습이 업데이트 된다.
Dart
복사
하지만 이와 동시에 서버로부터 오는 신호를 기다리는 점에서 사용자 측에서는 답답한건 매한가지 이므로 개선이 필요하다.
Optimistic UI 에 대해서
옵티미스틱 UI는 여전히 이전의 시나리오와 같은 사용자가 버튼을 클릭하는 동작으로 시작된다.
Optimistic(미래에 대해 희망과 확신이 있는) 의 뜻처럼 우리는 이러한 로직을 API가 안정적이고 응답이 예측가능한 수준일때, 유저가 처음에 취한 행동에 대한 응답으로 오류가 뜰 확률이 상당히 낮은 상황에 사용한다. 정확한 측정은 반복을 해봐야 알 수 있지만, 97-99%의 응답을 확신할 수 있다면, 우리는 새로운 버튼 인터렉션에 대해 완전히 새로운 이야기를 써 내려갈 수 있다.
즉 낙관적인 UI기법으로 서버를 신뢰하며 성공했다고 판단하여 UI를 변경하는 것이다.
사용자 측면
1. 사용자가 버튼을 클릭한다.
2. 버튼은 바로 성공일때와 같은 시각적 상태를 반영한다.
개발자 측면
1. 사용자가 버튼을 클릭한다.
2. 버튼은 바로 성공일때와 같은 시각적 상태를 반영한다.
3. 서버에 신호가 전달.
4. 서버에서 페이지로 응답을 보낸다.
5. 대부분의 경우 응답이 성공적일 것이므로, 사용자는 문제가 없다.
6. 실패한 경우에 시스템은 오류를 반환한다.
Dart
복사
이렇게 되면 사용자가 액션을 취하자마자 빠른 응답을 받을 수 있어 개선이 가능하다.
Optimistic 적용하기
SNS에서 많이 적용 되어 있는 “좋아요” 버튼에 대해 적용을 한다.
좋아요 버튼은 “좋아요” 의 상태가 활성화, 비활성화 두가지의 상태가 존재하므로 토글 상태가 된다.
1.
toggleLike 함수
•
기능 설명:
◦
주어진 contentIdx에 대해 좋아요 상태를 토글
◦
현재 상태를 변경하고, 여러 상태 프로바이더에 변경 사항을 반영
◦
최종적으로 handleLikeAction 함수를 호출하여 서버와 동기화
Future<void> toggleLike({
required int contentIdx,
}) async {
List<FeedData>? currentList = state.itemList;
int targetIdx = -1;
if (ref.read(firstFeedDetailStateProvider) != null && currentList != null) {
targetIdx = currentList.indexWhere((element) => element.idx == contentIdx);
if (targetIdx != -1) {
// 좋아요 상태를 변경하기 전의 상태를 저장합니다.
if (!_initialLikeStates.containsKey(contentIdx)) {
_initialLikeStates[contentIdx] = currentList[targetIdx].likeState == 1;
}
bool isCurrentlyLiked = currentList[targetIdx].likeState == 1;
currentList[targetIdx] = currentList[targetIdx].copyWith(
likeState: isCurrentlyLiked ? 0 : 1,
likeCnt: isCurrentlyLiked ? currentList[targetIdx].likeCnt! - 1 : currentList[targetIdx].likeCnt! + 1,
);
state.notifyListeners();
saveLike = currentList[targetIdx].likeState;
}
}
handleLikeAction(
contentIdx: contentIdx,
likeState: saveLike,
);
}
Dart
복사
2.
handleLikeAction 함수
•
기능 설명:
◦
좋아요 상태 변경 후 디바운스를 사용하여 일정 시간 동안 중복 요청을 방지
◦
초기 상태와 현재 상태를 비교하여, 서버에 좋아요 혹은 좋아요 취소 요청을 보낸다.
void handleLikeAction({
required int contentIdx,
required int? likeState,
}) {
// 디바운스 타이머 설정
EasyDebounce.debounce(
'handleLikeAction',
const Duration(
milliseconds: 500,
),
() async {
// 초기 상태와 현재 상태를 비교합니다.
bool initialLikeState = _initialLikeStates[contentIdx] ?? false;
if (likeState == 1 && !initialLikeState) {
// 좋아요 상태가 되었다면 API 호출
await postLike(contentIdx: contentIdx);
} else if (likeState == 0 && initialLikeState) {
// 좋아요 취소 상태가 되었다면 API 호출
await deleteLike(contentIdx: contentIdx);
}
// 처리 후 초기 상태 정보 삭제
_initialLikeStates.remove(contentIdx);
saveLike = null;
},
);
}
Dart
복사
3.
postLike deleteLike 함수
•
기능설명 :
◦
주어진 contentIdx에 대해 서버에 좋아요 또는 좋아요 취소 요청을 보냅니다.
◦
에러 핸들러 처리
Future<ResponseModel> postLike({
required contentIdx,
}) async {
try {
final result = await FeedRepository(dio: ref.read(dioProvider)).postLike(contentIdx: contentIdx);
return result;
} on APIException catch (apiException) {
await ref.read(aPIErrorStateProvider.notifier).apiErrorProc(apiException);
throw apiException.toString();
} catch (e) {
print('postLike error $e');
rethrow;
}
}
----------------------------------------------------------------
Future<ResponseModel> deleteLike({
required contentIdx,
}) async {
try {
final result = await FeedRepository(dio: ref.read(dioProvider)).deleteLike(contentsIdx: contentIdx);
return result;
} on APIException catch (apiException) {
await ref.read(aPIErrorStateProvider.notifier).apiErrorProc(apiException);
throw apiException.toString();
} catch (e) {
print('deleteLike error $e');
rethrow;
}
}
Dart
복사
그럼 이 기법으로 모든 기능을 적용하는게 맞을까?
Optimistic UI 디자인의 비관적 측면
Optimistic UI 디자인이 black pattern 이라는 의견이 있다. 하지만 Optimistic UI는 거짓말의 영역이라기보다 예측의 영역이다. 트위터와 인스타 같은 SNS의 경우 버튼의 상태를 되돌리는 것으로 이러한 응답의 실패상황을 핸들링하는데, 실제 적용한 프로젝트도 동일한 방법으로 구현이 되어있다.
이러한 실패 대응법에 추가로, 사용자에게 요청에 대한 실패상황이나, 오류를 알릴수 있는데 이는 알림을 나타내는 방식으로 구현하는 것이 일반적일 것이다. 하지만 이러한 알림의 방식은, 사용자의 맥락을 전환할 것이고 그렇게 새로운 맥락에서 다음 액션을 제공하는 방식으로 가이드를 제시해야한다. (오류상황을 알리면서, 취할 수 있는 해결책을 제시하지 않는 불친절한 방식은 페이지나 서비스 전체의 이미지에 부정적이다.)
물론 이러한 새로운 맥락을 만들어 내는 오류 대응법이 큰 형태의 웹사이트에서 오류를 핸들링하는 방식으로는 충분하지만, 버튼을 누르는 것 같은 단순한 액션에 대해서는 과잉일 수 있다. Optimistic UI는 실패에 대해 열려있어야하지만, 맥락에 따라 이루어져야한다.
무조건 서버의 응답을 신뢰하고 UI를 변경하므로 돈과 관련된 결제 기능이나 실패에 대한 리스크가 큰 기능에 대해서는 Optimistic 기법을 적용에 적절치 않아 보인다.
옵티미스틱 UI 디자인의 적용하기 전 확인 및 주의사항
•
API가 안정적이며 예측가능한 결과값을 보여주는지 확실하게 확인하라.
•
인터페이스는 서버에 요청이 보내지기 전에 잠재적 오류와 문제점을 발견해야 한다. 하지만 더 좋은 것은 API의 오류에 올 수 있는 모든 것을 제거해야 한다. UI 요소가 간단할수록 낙관적으로 만들기 간단하다.
•
API의 응답시간을 알고, 응답시간 자체가 매우 빠른 경우 대부분의 환경을 고려하더라도 시간이 2초 이내에 오는 경우