-
[Flutter] 클라이언트의 이미지 처리 전략 두가지 feat. AWS amplify, re-sizingFlutter/project 2024. 2. 20. 05:43
이미지는 복잡한 정보를 간단하고 직관적으로 전달하며 시각적 몰입이 텍스트와 미디어에 비해 좋다는 장점이 있습니다. 또한 터치인터페이스로 작동되는 모바일에서는 이미지를 적극적으로 사용하고 있죠.
하지만 이미지는 텍스트보다 용량이 커서 전송이 상대적으로 오래걸리기때문에 좋은 사용자 경험을 위해 이미지 최적화는 필수적으로 진행되어야합니다. 이번에는 제가 이미지 최적화를 했던 방법에 대해 정리해보도록 하겠습니다.✓ 이미지 저장을 위해 S3를 사용하는 이유
보통 서버의 메인 데이터베이스로 MySql과 같은 관계형 데이터베이스(RDB)를 사용하기때문에 모든 데이터를 이곳에 저장해야한다고 생각할 수 있습니다. 하지만 이미지 저장에 대해 구글링을 해보면 대부분 외부 서버, 그중에서도 클라우드에 저장할 것을 권장하고있습니다.
왜 그런걸까요? 그 이유는 RDB가 이미지 파일을 저장하는 방식에서부터 시작됩니다.
📌 관계형 데이터베이스(RDB)와 이미지파일
출처: ERD cloud RDB는 관계형 데이터모델을 기반으로 한 데이터베이스입니다. 데이터가 들어있는 행과 열로 이루어진 테이블들의 관계를 정의하고 트랜잭션(Transaction)을 통해 데이터베이스에 접근하여 DB상태를 변화시키는 형태로 작동됩니다.
이런 작동 형태로 봤을때, RDB의 목적은"구조화된 데이터를 저장하고 관리하는 것"이라고 볼 수 있습니다.
하지만 이미지 파일은 비구조화된 데이터에 더 가깝습니다.
- 이미지 파일
- 픽셀로 이루어져 있으며, 각 픽셀의 색상 정보를 나타내는 정보들은 이진(bianry)형식으로 저장되어있음
- 각 픽셀은 독립적으로 존재하고 이미지의 크기나 해상도에 따라 픽셀의 배열이 달라질 수 있기때문에 비구조화된 데이터로 간주한다.
- 0과 1로 표현되는 이진데이터의 특성상 다른 데이터형식에 비해 많은 비트를 사용하므로 비교적 용량이 클 수 있음
이런 이미지 파일의 특징때문에 RDB에서는 다른 데이터형식과 동일하게 저장하는 것이 아니라 BLOB(Binary Lage Object)라는 데이터 형식을 사용해서 저장합니다.
- BLOB(Binary Lage Object)
- RDB에서 이미지, 사운드, 비디오와 같은 대용량 멀티미디어 데이터를 바이너리 형태로 저장하고 다룰때 사용되는 객체
- 일반적인 텍스트나 숫자 데이터와는 구별되는 별도의 데이터 형식으로 취급
이처럼 BLOB를 통해 이미지를 RDB에 저장할 수 있지만 흔히 사용되지않는 이유가 뭘까요?
✍🏼 RDB의 장점 퇴색
RDB의 장점은 데이터를 쉽게 조작하고 쿼리할 수 있도록 하는 것입니다. 그러나 BLOB 데이터 형식은 일반적으로 데이터를 분류하거나 정렬할 수 없으며, 해당 칼럼에 직접 인덱스를 만들 수도 없으므로 특별한 조치를 취해야합니다. 이러한 제약은 "구조화된 데이터의 검색 및 정렬에는 적합하지 않다"는 결론을 내릴 수 있습니다.
✍🏼 성능 저하
RDBMS(Relational Database Management System)는 데이터의 일관성과 무결성을 유지하기 위해 트랜잭션을 사용합니다. 대용량의 BLOB 데이터를 트랜잭션하는 과정에서 해당 데이터를 디스크에 저장하거나 검색하는 디스크 I/O 작업이 발생합니다. 이러한 디스크 I/O 작업은 일반적으로 외부 서버에 데이터를 저장하는 작업보다 더 많은 시간이 소요될 수 있습니다.
위와같은 이유로 오늘날에는 대용량 데이터를 저장하고 관리할 때 관계형 데이터베이스보다는 외부 서버, 특히 클라우드 기반의 스토리지 서비스를 선호하는 경향이 있습니다.
그중에서 보편적으로 사용되는 서비스 중 하나가 바로 AWS S3입니다.
📌 S3
- S3의 특징
- 클라우드 기반의 객체 스토리지 서비스로, 데이터를 안전하게 저장하고 관리하는데 사용된다.
- 데이터 레이크, 웹사이트, 클라우드 네이티브 어플리케이션, 백업, 아카이브, 기계학습 및 분석과 같은 다양한 사례에 대해 원하는 양의 데이터를 저장하고 보호한다.
- 확장성, 데이터 가용성 및 보안과 성능을 높은 품질의 서비스로 제공한다.
- 데이터 저장 및 엑세스 비용이 저렴하며 저장할 수 있는 용량도 무한정에 가깝게 사용할 수 있다.
S3는 안정성, 확장성, 보안성 및 다양한 기능 제공 등의 이유로 업계 표준까진 아니여도 그에 준할만큼 매우 인기있는 서비스입니다.
앞으로의 내용은 S3를 사용한다는 전재하에 진행됩니다.
✓ 이미지 처리 전략
클라이언트에서는 캐싱, 렌더링, lazy loading, API 요청 횟수 최소화 등 다양한 성능 최적화 방법이 존재합니다.
이전에 다룬 최적화 사례
- API 요청 횟수 최소화
[Flutter] API 요청 횟수를 줄여 앱 성능을 개선해보자! feat. 배민 북마크 버그
크로스플랫폼 앱개발을 혼자하면서 제일 힘들었던 부분은 '성능 개선'이었습니다. 개발 할때 구현이 중요한 것처럼 성능 개선에 대한 욕구도 개발자가 가져야할 덕목이라고 생각했기때문이었
nomal-dev.tistory.com
- lazy loading 도입 및 API 중복 요청 해결
[Flutter] 무한스크롤 성능 최적화를 해보자 feat. Lazy Loading, Throttle #3
*이 글의 페이지네이션 로직은 코드팩토리님의 강의 내용을 응용하여 제작되었습니다. 이전 글에서 계속 이어집니다. 필요하시면 무한스크롤 1편, 2편을 참고해주세요 모바일 환경에서 무한스
nomal-dev.tistory.com
그럼 이미지를 처리할때는 어떤 전략을 사용할 수 있을까요?
📌 네트워크 대역폭 절약하기 with Amplify
S3는 RDB와 달리 트랜젝션이란 개념이 없기때문에 이미지 파일을 업로드하거나 수정할 때는 S3에서 생성된 URL을 RDB에서 조회한 후 삭제하는 등 추가적인 작업이 필요합니다.
이는 도메인 서버를 통해 이미지를 업로드 할 때 발생하는 성능 저하현상입니다.
그럼 서버를 거치지않고 클라이언트에서 직접 S3에 업로드 하는 방법은 없을까요?
이럴 때, amplify를 활용하면 쉽게 고민을 해결 할 수 있습니다.
✍🏼 AWS Amplify
- amplify 특징
- 서버리스 아키텍처 - 도메인 서버를 통하지않고 클라이언트에서 직접 백엔드 함수를 호출 해 CRUD작업을 수행할 수 있도록 지원한다. 이를통해 빠르고 유연한 어플리케이션 구축이 가능해진다.
- 간편한 명령어를 통해 어플리케이션을 설정하고 빠르게 배포할 수 있다.
- AWS 클라우드 인프라를 기반으로 하기때문에 높은 확장성과 안정성을 제공므로 대규모 트래픽에도 안정적인 서비스를 유지 할 수있다.
amplify를 활용하면 도메인 서버를 통해 S3에 이미지를 업로드할때 소비되는 네트워크 리소스를 절약할 수 있습니다.
하지만 서버에서 데이터를 관리하는 것이 아니기때문에 롤백에대한 고민이 생기게됩니다.
무슨 말인지 알아보기위해 먼저 amplify를 통해 S3에 파일을 직접 업로드하는 시나리오를 작성해보겠습니다.
- 클라이언트에서 S3에 직접 업로드 후 URL 반환
- 클라이언트는 도메인 서버에 반환받은 URL을 DB에 저장하기위한 API 요청
- 서버는 DB에 URL을 저장하고 결과를 클라이언트에 알려줌
만약 2번과 3번에 문제가 발생하면 S3에는 해당 파일이 저장되었지만 서버는 해당 사실을 모르게 됩니다.
🤷🏻♂️ "그럼 다시 Amplify를 통해 S3에 업로드한 파일을 삭제하거나 롤백하면 되지않나요?"
S3 프리티어계층에서는 API 호출 비용이 발생하지않지만 저장 용량 한도를 초과하여 프리티어 계층을 벗어나는 순간, 소액이지만 API 호출에 의한 금전적인 비용이 부과될 수 있습니다.
amplify는 S3 롤백기능은 제공되지않지만 삭제기능은 제공하고 있기때문에 삭제하면 롤백과 동일한 효과를 줄수있는 첫 이미지 업로드 중에 서버와 통신에 문제가 생기는 상황에서만 S3에서 파일을 삭제하는 방법을 생각할 수 있습니다.
하지만 DB에 잘 반영이 되더라도 통신 에러는 발생할 수 있습니다. 이런 경우에는 불필요한 Delete 요청만 반복될테고 네트워크 리소스와 금전적 비용만 낭비하게 됩니다.
그러므로 서버를 거치지않고 S3에 이미지 업로드를 고려한다면 롤백을 하지않아도 유저에게 큰 불편함을 초래하지 않으면서, 효율적으로 사용 할 수있는 기능에 활용하는 것이 좋습니다.
예를 들어 커머스 기능이 핵심인 '미리'앱에서 유저의 프로필 업로드와 같이 이미지 업로드가 서브 기능에 포함된 경우, 이러한 방식을 활용하는 것이 합리적이라고 볼 수 있습니다.
[Project] 맛있는 외식의 시작, '미리'
✓ What Project? "지역 소상공인 식음료매장들의 당일 재고 상품을 파격적인 할인가로 제공하는 O2O 커머스 서비스" 코로나로 인해 전국의 학교들이 비대면수업을 하면서 지역 경제가 침체되던 때
nomal-dev.tistory.com
✍🏼 Amplify 적용 사례와 code: 유저 프로필 업로드 with riverpod, repositroy pattern
Set up Amplify CLI - Flutter - AWS Amplify Documentation
Getting started with Amplify - Prerequisites AWS Amplify Documentation
docs.amplify.aws
amplify 사용을위한 초기 설정은 생략했습니다. 필요하신 분은 위의 공식문서를 참고해주세요
유저 프로필 업로드 로직 흐름 @Riverpod(keepAlive: true) AmplifyRepository amplifyRepository(AmplifyRepositoryRef ref) { final storage = ref.read(secureStorageProvider); return AmplifyRepository(storage: storage); } class AmplifyRepository { AmplifyRepository({ required this.storage, }); final SecureStorage storage; // 📌 유저 프로필 사진 업로드 Future<Result<String?, CustomExceptions>> profileUpload( {required io.File profileImg}) async { return runCatchingExceptions(() async { final UserModel? userModel = await storage.readSocialInfo(); if (userModel == null) { print('[ERR] 소셜계정 없음'); return null; } final String s3EndPoint = dotenv.env['S3_END_POINT_URL']!; final String key = 'S3폴더경로/${Formats.removeSpecialCharacters(userModel.socialEmail)}.png'; const option = StorageUploadFileOptions(accessLevel: StorageAccessLevel.guest); final awsFile = AWSFilePlatform.fromFile(profileImg); final result = await Amplify.Storage.uploadFile( options: option, localFile: awsFile, key: key, ).result; safePrint('[ AWS ] Uploaded file: ${result.uploadedItem.key}'); return '$s3EndPoint$key'; }); } }
코드 속 중요한 부분들만 요약해서 설명하겠습니다.
⭐️ Key 설정하기
final UserModel? userModel = await storage.readSocialInfo(); if (userModel == null) { print('[ERR] 소셜계정 없음'); return null; }
S3에 파일을 업로드하려면 'key'라는 것이 필요합니다. 이는 객체를 식별하는 역할을 합니다. 따라서 새로운 파일을 업로드할 때는 기존에 존재하는 key와 중복되지 않아야 하며, 기존 파일을 수정할 때는 동일한 'key'를 사용하게 됩니다.
객체 개요 '미리'에서는 유저를 식별할때 email을 사용하고 자동로그인을 위해 secureStorage에 저장해 두기때문에 S3업로드를 위한 key로 사용하기 적합했습니다.
final String key = 'UserProfile/${Formats.removeSpecialCharacters(userModel.socialEmail)}.png'; const option = StorageUploadFileOptions(accessLevel: StorageAccessLevel.guest); final awsFile = AWSFilePlatform.fromFile(profileImg); final result = await Amplify.Storage.uploadFile( options: option, localFile: awsFile, key: key, ).result;
단, S3에 파일 업로드시 특수문자가 포함된 Key 값은 허용하지 않기 때문에 특수문자를 제거하는 함수를 따로 추가했습니다.
static String removeSpecialCharacters(String email) { return email.replaceAll(RegExp(r'[^\w\s]+'), ''); }
⭐️ URL 반환
final String s3EndPoint = dotenv.env['S3_END_POINT_URL']!; final String key = 'S3폴더경로/${Formats.removeSpecialCharacters(userModel.socialEmail)}.png'; ... return '$s3EndPoint$key';
S3에서 자동으로 작성되는 객체 URL은 "https:// + 버킷이름 + AWS 리전 + 버킷내 폴더 경로+ key"의 조합으로 이루어져있습니다. 이는 변경 될 일이 없으므로 서버에게 예상되는 객체 URL 값을 보낼수 있습니다.
🤷🏻♂️ "만약 AWS에서 엔드포인트 작성 양식을 바꾸면 어떡하나요?"
저도 이점을 고려해 S3에 업로드와 동시에 객체 URL을 반환 받을 수 있는지, 네트워크 리소스를 조금 낭비하더라도 객체 URL을 조회 하는 메서드가 있는지 확인해본 결과, 모두 존재하지 않았습니다.
// 객체 URL을 받을 것이라 예상했던 함수 final imageUrl = Amplify.Storage.getUrl(key: key); print('imageUrl: $imageUrl'); // print 결과: imageUrl: Instance of 'S3GetUrlOperation'
이밖에 다른 메서드들을 디버깅 해봐도 객체 url을 반환받은 적은 없었습니다. 혹시나 객체 URL을 반환 방법을 아시는 분은 피드백을 남겨주세요ㅠㅠ 🙇🏻♂️
엔드포인트 작성 양식이 계속 변하지않는다면 env에 엔드포인트 양식을 저장하는 것이 성능 측면에서 가장 유리하다고 생각해 해당 방법으로 진행했습니다.
📌 이미지 re-sizing
"원본 이미지의 해상도를 줄이거나 늘리는 행위"
다음으로 고려할 전략은 이미지의 크기를 품질이 손상되지 않을 만큼 줄여서 업로드하는 것입니다. 이렇게 하면 이미지 파일의 크기를 최적화하여 저장 공간을 절약하고, 이미지를 불러올 때의 로딩 시간을 최소화할 수 있습니다.
🤷🏻♂️ "굳이 업로드할때 이미지 리사이징을 해야하나요? 불러올때 리사이징을 하면 안되나요?"
물론 이미지를 불러올때 리사이징을 해도 상관없습니다. 하지만 이 경우에는 리사이징 메서드가 실행되는만큼 로딩 시간이 추가됩니다.
제가 유저의 입장에서 생각했을 때, 파일을 업로드하는 데 걸리는 시간보다 데이터를 받아오는 데 소요되는 시간에 더 민감할 것이라고 판단했습니다. 그래서 어떤 디바이스를 사용하더라도 이미지 품질이 저하되지 않을 정도로만 리사이징하는 메서드를 업로드 과정에 추가하고, 이미지를 조회할 때는 리사이징을 하지 않는 방향으로 진행했습니다.
그럼 이미지 리사이징을 실제로 적용한 방법을 코드와 함께 보도록 하겠습니다.
✍🏼 업로드할때 이미지 re-sizing 하는 방법 (이미지 압축 사례)
import 'dart:io' as io; @Riverpod(keepAlive: true) class ProfileController extends _$ProfileController { @override Profile build() { return const ProfileCommon(); } pickImage() async { if (state is ProfileCommon) { state = const ProfileLoading(); } try { final picker = ImagePicker(); final pickedFile = await picker.pickImage( source: ImageSource.gallery, requestFullMetadata: io.Platform.isIOS || io.Platform.isAndroid, ); if (pickedFile == null) { state = const ProfileCommon(); return; } io.File? img = io.File(pickedFile.path); img = await _cropImage(imageFile: img); if (img == null) { state = const ProfileCommon(); return; } state = const ProfileLoading(); img = await _resizeAndGetPng(cropImage: img); if (img == null) { state = const ProfileCommon(); return; } final result = await ref .read(userMyPageServiceProvider) .modifyUserProfile(profileImg: img); if (result is ModifyUserInfoError) { print(result.errorMapper!.message); showToast(msg: '프로필 업데이트 실패 🥲'); return; } else { result as ModifyUserInfoSuccess; await CachedNetworkImage.evictFromCache(result.imgUrl!); state = ProfileData(ProfileMapper(imageUrl: result.imgUrl!, imgFile: img)); } } on PlatformException catch (e) { print(e); } } // 크롭 Future<io.File?> _cropImage({required io.File imageFile}) async { CroppedFile? croppedImage = await ImageCropper().cropImage( sourcePath: imageFile.path, aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), ); if (croppedImage == null) { return null; } return io.File(croppedImage.path); } // 리사이징 및 포맷변환 Future<io.File?> _resizeAndGetPng({required io.File cropImage}) async { io.File targetImage = io.File(cropImage.path); print('이미지 경로:${targetImage.path}'); double targetImageSize = double.parse((targetImage.lengthSync() / 1024).toStringAsFixed(2)); print('리사이징 before: $targetImageSize KB'); int originMin = 720; while (targetImageSize > 100) { var compressedImage = await FlutterImageCompress.compressAndGetFile( targetImage.path, '${targetImage.path}_resizedImg.png', format: CompressFormat.png, minHeight: originMin, minWidth: originMin); if (compressedImage == null) { break; } targetImageSize = double.parse( (await compressedImage.length() / 1024).toStringAsFixed(2)); targetImage = io.File(compressedImage.path); print('리사이징 중: $targetImageSize KB'); originMin = originMin ~/ 1.5; } print('리사이징 after: $targetImageSize KB'); return targetImage; } }
코드 속 중요한 부분들만 요약해서 설명하겠습니다.
⭐️ Picker, Crop
image_picker | Flutter package
Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera.
pub.dev
image_cropper | Flutter package
A Flutter plugin for Android, iOS and Web supports cropping images
pub.dev
final pickedFile = await picker.pickImage( source: ImageSource.gallery, // Picker 리사이징 추가하는 방법 maxHeight: 720, maxWidth: 720, imageQuality: 80, requestFullMetadata: io.Platform.isIOS || io.Platform.isAndroid, ); ... Future<io.File?> _cropImage({required io.File imageFile}) async { CroppedFile? croppedImage = await ImageCropper().cropImage( sourcePath: imageFile.path, // Crop 리사이징 추가하는 방법 compressQuality: 50, aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1), ); if (croppedImage == null) { return null; } return io.File(croppedImage.path); }
🤷🏻♂️ "위의 코드와 같이 Picker와 Crop으로도 리사이징을 할 수 있는데 왜 실제 코드에서는 생략했나요?"
무분별한 리사이징은 이미지의 극심한 품질저하 현상을 일으킬수 있으므로 계속 피드백을 하면서 진행을 해야할 필요성이 있습니다.
피커와 크롭 패키지는 이미지를 '선택'하고 '크롭'하는 등의 작업을 간편하게 수행할 수 있도록 도와주는 패키지입니다.
하지만 이러한 작업은 사용자가 한 번의 클릭으로 실행되는 단발성 작업이기때문에 원하는 결과물을 얻을때까지 연속으로 리사이징하는 메서드를 추가 하는 것은 적절하지 않습니다.
유저는 이미지를 빠르게 선택하고 편집하여 결과를 확인하고자 할 것이기 때문에 이렇게 작동되는 기능은 좋지않은 사용자 경험을 주게 됩니다.
⭐️ 이미지 re-sizing (압축)
flutter_image_compress | Flutter package
Compress Pictures. Can effectively reduce the size of the transmission.
pub.dev
// 리사이징 및 포맷변환 Future<io.File?> _resizeAndGetPng({required io.File cropImage}) async { io.File targetImage = io.File(cropImage.path); print('이미지 경로:${targetImage.path}'); double targetImageSize = double.parse((targetImage.lengthSync() / 1024).toStringAsFixed(2)); print('리사이징 before: $targetImageSize KB'); int originMin = 720; while (targetImageSize > 100) { var compressedImage = await FlutterImageCompress.compressAndGetFile( targetImage.path, '${targetImage.path}_resizedImg.png', format: CompressFormat.png, minHeight: originMin, minWidth: originMin); if (compressedImage == null) { break; } targetImageSize = double.parse( (await compressedImage.length() / 1024).toStringAsFixed(2)); targetImage = io.File(compressedImage.path); print('리사이징 중: $targetImageSize KB'); originMin = originMin ~/ 1.5; } print('리사이징 after: $targetImageSize KB'); return targetImage; }
- 실제 리사이징이 일어나는 부분
- 사진을 고르고 크롭까지완료한 뒤, 다시 설정 화면으로 돌아온 시기에 동작하는 메서드
- 해상도를 줄여가며 이미지 용량이 100kb 미만이 될때까지 이미지를 압축한다.
- png로 파일 확장자를 변환하는 역할도 수행한다.
- png를 사용하면 jpg와 다르게 손실없이 이미지를 저장 할 수 있다.
- 여러번 저장하고 수정해도 품질이 유지되는 장점이 있다.
📌 전체 테스트
두가지 이미지 최적화를 모두 적용한 뒤, 처음부터 끝까지 작동되는 모습입니다.
로딩 인디케이터가 돌고있는 때 리사이징이 진행되고 있으며, 프로필 이미지가 바뀌는 순간이 이미지가 S3에 업로드가 된 시점입니다.
제 휴대폰에서는 6mb였던 파일인데 아마 이미지 크롭 기능으로 인해 이미지가 잘려가면서 용량이 줄어든 상태로 시작된 것 같습니다.
리사이징은 총 4번에 걸쳐 진행되었고 100kb 미만이되자 의도한대로 더이상 리사이징은 진행되지않았습니다.
S3 콘솔에서 직접 확인해본 결과, 화질이 많이 저하되긴 했으나 앱에서 유저 프로필 이미지는 굉장히 작은 크기로 노출하므로 육안으로 큰 차이점을 느끼긴 힘들었습니다. 😎
이번 이미지에 대한 최적화에 대해 생각을 정리하고 학습하면서 이전에 있었던 코드의 오류들도 많이 수정했습니다. 이를테면 JPG에서 PNG로 파일확장자를 변환하면 기존 용량보다 더 커져서 반드시 리사이징 기능을 하기전에 변환을 하거나 현재 수정된 코드처럼 두 기능이 함께 진행되도록 개발해야한다는 것과 같은 것들입니다.
개발할때는 이런 최적화를 하면서 진행한지도 모른채 당연히 해야할 것 같다는 생각만으로 진행해서그런지, 블로그를 쓸 때마다 이전 생각들을 차근차근 생각하면서 근거를 찾아보는게 굉장히 어렵습니다. 하지만 제 글을 보고 무언가 조금이나마 얻는 분이 생겼으면 좋겠습니다.
긴 글 읽어주셔서 감사합니다.
이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
'Flutter > project' 카테고리의 다른 글
[Flutter] 토스페이먼츠를 활용해 주문·결제 프로세스 적용해보기 feat. 인증 프로세스 #2 (0) 2024.02.21 [Flutter] 토스페이먼츠 결제모듈을 도입하다 #1 설계 (2) 2024.02.21 [Flutter] API 요청 횟수를 줄여 네트워크 대역폭을 절약해보자! feat. 배민 북마크 버그 (0) 2024.02.14 [Flutter] 무한스크롤 성능 최적화를 해보자 feat. Lazy Loading, Throttle #3 (0) 2024.02.13 [Flutter] 무한 스크롤 구현해보기 feat. cursor based pagination #2 (0) 2024.02.09 - 이미지 파일