-
[Flutter] API 요청 횟수를 줄여 네트워크 대역폭을 절약해보자! feat. 배민 북마크 버그Flutter/project 2024. 2. 14. 07:18
크로스플랫폼 앱개발을 혼자하면서 제일 힘들었던 부분은 '성능 개선'이었습니다. 개발 할때 구현이 중요한 것처럼 성능 개선에 대한 욕구도 개발자가 가져야할 덕목이라고 생각했기때문이었죠. 백엔드 개발자와의 소통을 통해 통신 타이밍 등의 개선을 하는 것은 그리 어려운 것은 아니였습니다. 다만 클라이언트 내부적으로 이뤄지는 개선작업들은 오로지 저의 판단에 의해서 진행되는 것이기때문에 문제인 것을 인지하는 것 조차 쉽지않았습니다. 그렇기때문에 가장 와닿는, 혹은 비교적 근본적인 문제에 집중하다보니 api 요청 횟수를 최소화 하는 작업에 대해 많이 고민했었습니다. 이번에는 이러한 고민의 흔적을 정리해보는 시간을 가져보겠습니다.
해당 글은 '미리'앱의 한 기능을 주제로 작성되었습니다. '미리'에 관해 궁금하시다면 아래 글을 참고해주세요.
[Project] 맛있는 외식의 시작, '미리'
✓ What Project? "지역 소상공인 식음료매장들의 당일 재고 상품을 파격적인 할인가로 제공하는 O2O 커머스 서비스" 코로나로 인해 전국의 학교들이 비대면수업을 하면서 지역 경제가 침체되던 때
nomal-dev.tistory.com
네트워크 통신이 발생하는 것 자체가 마치 폭이 정해진 도로위에 차를 한대 보내는것처럼 한정된 네트워크의 리소스를 소비하는 과정입니다. 그렇기때문에 무한 스크롤에 적용할 페이지네이션 로직을 구현할때도 중복 요청을 하지않기위해 Throttle을 도입했었죠.
[Flutter] 무한스크롤 성능 최적화를 해보자 #3
*이 글의 페이지네이션 로직은 코드팩토리님의 강의 내용을 응용하여 제작되었습니다. 이전 글에서 계속 이어집니다. 필요하시면 무한스크롤 1편, 2편을 참고해주세요 모바일 환경에서 무한스
nomal-dev.tistory.com
이렇게 동일한 api 요청을 자주 보내는 기능에 대해서는 "어떻게하면 그 요청횟수를 최소화 할 수 있을까?" 고민하는 것은 앱 성능 최적화를 생각할 때 가장 먼저 떠오르는 보편적인 질문이 아닐까 생각됩니다.
'미리'앱에는 좋아하는 가게를 '찜하는 기능'이 있습니다. 찜 기능의 조건을 말하자면 다음과 같습니다.
- 유저가 원하는 가게를 북마크하면 '관심매장' 화면에 가게 목록이 추가되어있음
- 관심매장에서 해당 가게를 삭제하면 가게 상세화면에서도 찜 마크가 취소되어야함
✓ 문제 사항 파악
'찜'기능은 흔히 볼 수 있는 북마크 기능입니다. 일반적으로는 찜 버튼을 클릭할 때마다 API 요청이 발생할 것으로 예상됩니다.
하지만 위와같이 유저가 여러번 연속으로 클릭한다면 어떨까요?
이렇게 유저가 연속으로 클릭할때마다 계속 네트워크 통신을 요청한다면 서버에 부하가 발생하고 앱의 성능 저하로 나타나게 됩니다.
✓ 백엔드 개발자와 소통으로 어느정도 해결하기 - API 요청 타이밍 변경
그렇다면 첫번째로 해결할 수 있는 방법은 API 요청 타이밍을 찜 버튼을 클릭할때가 아니라 더이상 찜 버튼을 클릭할 수 없는 환경 즉, 이 화면을 완전히 벗어나는 경우에 API 요청을 실행하는 방법을 고려 할 수 있습니다.
- 찜 버튼의 클릭 유무상태를 기억하고 있는다.
- 가게 상세 화면을 벗어날 때, API 요청을 한다.
📌 Repository Code
Future<Result<int, CustomExceptions>> insertLikeStore({ required int storeId, }) { return runCatchingExceptions(() async { await dio.post( "$baseUrl/북마크 추가", options: Options(headers: {'accessToken': 'true'}), ); return storeId; }); } Future<Result<int, CustomExceptions>> deleteLikeStore({ required int storeId, }) { return runCatchingExceptions(() async { await dio.delete( "$baseUrl/북마크 삭제", options: Options(headers: {'accessToken': 'true'}), ); return storeId; }); }
만약 API 요청 타이밍을 백엔드와 함께 고려하지않았더라면 서버에서는 '찜 추가 API', '찜 삭제 API'로 나누지 않고 어쩌면 요청이 들어올때마다 '!'처럼 DB에 있는 값을 반대로 뒤집기만 하는 '찜 API'하나만 만들었을지도 모릅니다.
하지만 이렇게 설계를 한 덕분에 무분별한 API 요청을 방지 할 수 있었습니다.
✓ 클라이언트 내부적으로 해결하기 - 북마크 상태관리
위와같이 개선했지만 정작 찜 버튼 자체에서 기능이 실행되지않고 다른 버튼들을 누를때 실행된다는게 조금 불편했습니다. 누가봐도 뒤로가기, 홈으로가기, 장바구니 버튼인데 매번 찜 API이 함께 실행된다는게 이상하다는 생각이 들어 다른 방법을 찾아봤지만 API 요청 타이밍을 바꾸는 것 만큼 좋은 아이디어가 떠오르지않았습니다.
그래서 차라리 다른 버튼(뒤로가기, 홈, 장바구니)에서 찜 API 호출을 피할수없다면 최대한 요청 횟수만이라도 줄여보자는 생각을 하게 되었습니다.
📌 찜 상태에 따른 API 요청 타이밍 추가 설정
현재는 API 요청이 버튼의 '클릭 이벤트'와 결합되어있습니다. 즉 버튼이 클릭하는 순간 바로 요청되는 구조입니다.
그렇기때문에 클릭 이벤트와 API 요청을 분리하고 북마크의 상태에 따라 요청하는 방법에 대해 고민했었습니다.
- 개선 전: 클릭 이벤트 ➡️ 무조건 API 요청
- 개선 후: 클릭 이벤트 ➡️ 찜 상태 체크 ➡️ 찜 상태에 따라 API 요청 여부가 달라짐
그럼 상태에따라 API 요청 타이밍을 달리한다는게 무슨 말일까요?
찜 여부를 서버에게 전달하는 행위는 데이터 변경이 일어났을때만 일어나도 충분합니다.
데이터의 변경이 일어나지않았는데 굳이 요청을 할 필요는 없습니다. 오히려 불필요한 API 요청로 인해 네트워크 통신 리소스를 낭비하는 행위라고 판단했었죠.
따라서 변경되지않은 초기 상태를 기억하고있다가 찜의 상태가 변경되는 것을 감지해 초기 상태와 다른 경우에만 요청을 넣는 방법을 생각했습니다.
찜의 상태는 다음처럼 나눌 수 있습니다.
- common
- 가게 상세화면 조회시 받는 유저의 최초 '찜 상태(활성화, 비활성화)'
- nonProcess
- 찜 버튼을 누르다가 common 상태와 다른 상태
- 이 상태일 때 REQ를 보낸다
- processed
- 서버에 반영된 상태
- 다시 가게 화면에 접근했을때 common 상태로 변경
enum LikeType { common, nonProcess, processed, }
✍🏼 Flow
- controller에서 최초 찜 상태를 기억한다
- 버튼이 토글되면서 최종 찜상태를 기억한다.
- 찜 API를 호출하는 버튼들을 눌렀을때 nonProcess 상태일때만 API요청을 한다.
- API응답을 받았다면 processed 상태로 변경해준다.
유저는 여러 가게의 상세페이지에 접근 할 수 있으므로 다음과 같은 상황을 주의해야합니다.
- 접근했던 가게만 관리 할 수 있도록 List의 형태로 상태를 관리해야함
- 관리할 데이터는 API요청시 필요한 데이터와 UI 반영에 필요한 부분들로 구성되어야함(아래 StoreLikeMapper code 참고)
- API 요청에 필요한 데이터
- 요청 파라미터: 가게 Id
- 요청 여부 판단: LikeType enum class
- UI반영에 필요한 데이터: 유저의 찜 여부, 가게 찜 갯수
- API 요청에 필요한 데이터
✍🏼 StoreLikeMapper
@JsonSerializable() class StoreLikeMapper { // 가게 아이디 final int storeId; // 좋아요 상태(북마크 상태) final bool isLiked; // 가게 카운트 final int storeLikeCount; // 좋아요(북마크) 백엔드 처리 상태 final LikeType type; final LikeType originType; StoreLikeMapper({ required this.storeId, required this.isLiked, required this.storeLikeCount, required this.type, required this.originType, }); StoreLikeMapper copyWith({ int? storeId, bool? isLiked, int? storeLikeCount, LikeType? type, LikeType? originType, }) { return StoreLikeMapper( storeId: storeId ?? this.storeId, isLiked: isLiked ?? this.isLiked, storeLikeCount: storeLikeCount ?? this.storeLikeCount, type: type ?? this.type, originType: originType ?? this.originType); } }
📌 Controller Code
@Riverpod(keepAlive: true) class TotalStoreLikeController extends _$TotalStoreLikeController { @override List<StoreLikeMapper> build() { return []; } Future<void> addStoreLikeState({ required int storeId, required bool isLikeStore, required int storeLikeCount, }) async { // 이미 List에 존재하는 가게의 북마크 데이터는 추가하지않는다. if (state.isNotEmpty && state.any((e) => e.storeId == storeId)) { return; } state.add( StoreLikeMapper( originType: LikeType.common, type: LikeType.common, storeId: storeId, isLiked: isLikeStore, storeLikeCount: storeLikeCount, ), ); } changeLike({required int storeId}) { final updateList = state.map((e) { if (e.storeId == storeId) { return e.copyWith( isLiked: !e.isLiked, type: e.type != LikeType.nonProcess ? LikeType.nonProcess : e.originType, storeLikeCount: !e.isLiked ? e.storeLikeCount + 1 : e.storeLikeCount - 1); } return e; }).toList(); state = updateList; } deleteLikes({required List<int> storeIds}) { final updateList = state.map((e) { if (storeIds.contains(e.storeId)) { return e.copyWith( isLiked: false, type: LikeType.processed, originType: LikeType.processed, storeLikeCount: e.storeLikeCount - 1, ); } return e; }).toList(); state = updateList; } // nonProcess인 것을 찾아 찜관련 API요청을 한다. Future<void> saveLike() async { for (int i = 0; i < state.length; i++) { if (state[i].type == LikeType.nonProcess) { final result = state[i].isLiked ? await ref .read(favoriteServiceProvider) .insertLikeStore(storeId: state[i].storeId) : await ref .read(favoriteServiceProvider) .deleteLikeStore(storeId: state[i].storeId); if (result is StoreLikeError) { return; } else { final updateList = state.map((e) { return e.storeId == state[i].storeId ? e.copyWith( type: LikeType.processed, originType: LikeType.processed, ) : e; }).toList(); state = updateList; } return; } } } }
- deleteLikes({required List<int> storeIds}) {
final updateList = state.map((e) {
if (storeIds.contains(e.storeId)) {
return e.copyWith(
isLiked: false,
type: LikeType.processed,
originType: LikeType.processed,
storeLikeCount: e.storeLikeCount - 1,
);
}
return e;
}).toList();
state = updateList;
}- deleteLikes()은 TotalStoreLikeController가 아닌 관심매장을 관리하는 다른 컨트롤러에서
여러 가게를 삭제하는 API를 실행 후, 진행되는 메서드이기때문에 type과 originType을 processed로 설정 해 준다.
- deleteLikes()은 TotalStoreLikeController가 아닌 관심매장을 관리하는 다른 컨트롤러에서
- Future<void> saveLike() async {
...
if (result is StoreLikeError) {
return;
}
...
}
🤷🏻♂️ "에러가 발생했을때, 왜 아무런 조치를 취하지않고 그대로 메서드를 종료 해버리나요?"
팀원과 북마크(좋아요) 기능이 있는 앱들을 조사하다가 배달의 민족 앱에서 북마크(좋아요) 기능의 버그를 발견했던 적이 있습니다.
괄호는 버그가 일어나지않았을때, 반영되어야 하는 실제 데이터를 의미합니다.
- 북마크 갯수가 "17"개일 때, 하트를 눌러 북마크 갯수를 "18"개로 만들고 페이지를 나감 (북마크: on, 갯수: 18)
- 다시 페이지를 들어가니 북마크는 켜져있지만 북마크 갯수는 "17"이였음 (북마크:on, 갯수:18)
북마크를 끄고 북마크 갯수가 "16"인 것을 확인하고 페이지를 나감 (북마크:off, 갯수:17) - 다시 페이지를 들어가니 북마크는 꺼져있었고 북마크 갯수는 "17"이 되어있었음 (북마크:off, 갯수:17)
북마크를 누르고 북마크 갯수가 "18"인 것을 확인하고 페이지를 나감 (북마크:on, 갯수:18) - 다시 페이지를 들어가니 북마크는 켜져있고 북마크 갯수는 "17"이 되어있었음 (북마크:on, 갯수:18)
아마도 이 현상이 버그였다기보단 배달의 민족에서는 이부분에대해 크게 민감하게 생각하지않는 것 같다는 느낌을 받았습니다.
사실 UI에는 반전되는 두가지 상태만 보이는 것이 전부고, 북마크 갯수가 기껏해봐야 1~2 정도 차이나는 거라 배민측에서는 가게 매출에 큰 영향을 끼치지 못할 거라고 판단했을 것 같습니다.
그래서 결론은 북마크 기능은 사용자 경험 향상에 유의미한 변화를 주는 부분이 아니며, 치명적인 어플리케이션의 오류를 범하는 기능도 아니기때문에 큰 공수를 들여 여기에서 더 최적화를 하기보단 새로고침 기능을 도입해 혹시나 생길 버그를 방지하도록 했습니다.
개발자라면 개선하고싶은 부분이 보인다면 참지못하고 개선하려고 하는 변태(?)같은 기질이 있어야하는 것 같습니다. 하지만 서두에서도 말했다시피 아직 클라이언트단 개발시중에 치명적인 성능에 대한 문제를 느끼지못한 건지 발견을 못한건지 아쉬운 부분들이 있습니다. 아마 운영까지는 못해봤기때문에 그런게 아닌가 싶긴합니다. 요즘은 현업을 하면서 좀 더 다양한 상황을 마주해 생각하지도 못한 개선 방법들을 경험하고 싶다는 생각이 듭니다...🥲
긴글 읽어주셔서 감사합니다.
이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
'Flutter > project' 카테고리의 다른 글
[Flutter] 토스페이먼츠 결제모듈을 도입하다 #1 설계 (2) 2024.02.21 [Flutter] 클라이언트의 이미지 처리 전략 두가지 feat. AWS amplify, re-sizing (4) 2024.02.20 [Flutter] 무한스크롤 성능 최적화를 해보자 feat. Lazy Loading, Throttle #3 (0) 2024.02.13 [Flutter] 무한 스크롤 구현해보기 feat. cursor based pagination #2 (0) 2024.02.09 [Flutter] JWT 토큰관리 및 자동로그인 구현하기 feat. Dio Interceptor, Social Login (2) 2024.02.06