GS리테일 DX 블로그

Digital Transformation으로 고객 생활 가치의 이노베이션을 꿈꾸는 IT 사람들의 이야기

APP

Flutter App 실시간 CDN 이미지 변경 상태 적용 방안

ordinaryking 2023. 6. 20. 19:28

 

일반적으로 이미지를 로컬 캐시에 저장하는 이유는 보통 아래와 같습니다. 

 

같은 이미지를 자주 재 사용하는데, 매번 CDN으로부터 다운로드하기 부담스럽다.
같은 이미지를 재 다운로드하기 위한 네트워크 비용을 아끼고 싶다.
같은 이미지를 로컬에서 빠르게 로딩해서 보여주고 싶다. 

 

위에서 언급한 항목을 보면 모두 ‘같은’이라는 접두사가 붙어 있습니다. 

 

이유는 같은 이미지가 아니면 로컬 캐시를 이용할 수 없다는 간단한 상황 때문입니다. 

이러한 장점을 가진 이미지 로컬 캐시는 항상 장점만 있는 것은 아닙니다. 

 

라이브 서비스되고 있는 상황에서 방송용 이미지나 이벤트 이미지를 오타나 법적인 문제 등 

여러 이유에서 긴급하게 교체해야 할 필요가 있을 경우에는 어떻게 할 것인가? 

 

고객에게 이미지를 다시 다운 받을 수 있게 앱을 재 시작해 달라고 할 것인가? 

(앱이 구동되는 경우에만 이미지 캐싱을 하는 앱인 경우) 

(참고 :  대부분의 고객들은 대부분 앱을 종료하기보단 다른 앱을 열어 Backgroud 상태로 내려놓는다.)

 

게다가 어떻게 고객에게 고지할 것인가? 푸시를 보낼 것인가? 메인에서 전면 팝업을 띄울 것인가?  

 

일반적으로 로컬 이미지 캐싱을 이용하는 앱이라면

이런 상황에서는 별도의 이미지 파일을 새로 올리고, (신규 이미지 링크 주소 생성)

해당 신규 이미지 주소를 API에서 재 매핑한 다음 

고객이 해당 API 데이터를 다시 로드할 수 있는 행동을 하기를 기도할 뿐일 것입니다. 

 

물론, 잘못된 이미지를 보고 고객이 주문을 할 경우를 대비하여

그에 대한 대응팀 준비와 재발방지 대책도 세워야할 것입니다. 

 

일반적인 회사에서는 이미지가 조금 잘못되더라도 고객에게 사과하고 이미지를 교체하거나

안내 팝업을 내 보내는 등 후속 작업을 진행하면 큰 이슈는 없을 것입니다. 

잘못된 이미지로 하나로 자산이나 법적인 문제가 발생할 만한 업종은 그리 많지 않기 때문입니다.

 

 

그러나, 그러한 업종에 속해있거나, 높은 QOS를 중요시하는 회사라면,

예측 가능한 휴먼에러를 대비하는 방안을 준비하는 것도 나쁠 것은 없다고 생각합니다. 

 

 

그래서 저는,

앱에서 노출되는 이미지가 로컬 캐시에서 로드한 것보다는 약간 느리게 보이더라도 
CDN에서 긴급하게 변경되는 이미지가 빠르게 고객의 단말에서 보이게 할 수 없을까를 고민하였습니다.

 

물론,

매번 CDN으로부터 이미지를 다운로드하면 서버에서의 변경 사항이 빠르게 반영되겠지만, 

이미지 로딩이 너무 늦어지므로

로컬 캐시 기능을 이용하여 로컬 이미지를 재 사용 하면서도 

최소한의 비용으로 CDN의 이미지 변경 사항을 실시간으로 대응하기 위해 아래와 같은 기능을 만들어보았습니다. 

 

핵심은 CDN에서 HTTPS 프로토콜의 HEAD Method 이용해서 이미지 파일의 길이 정보랑 eTag 정도의 수준의 작은 데이터만 
얻어와서 로컬에 캐시 한 이미지 사이즈와 CDN 이미지 사이즈의 차이를 확인해 보는 방안입니다. 
 대단하지 않지만, 번뜩이는 아이디어로 승부를 보려는 것처럼 느껴진다면.. 삐빅. 정상입니다.

 

 

아무튼 다들 알겠지만, 이미지 사이즈는 이미지 크기와 이미지 픽셀의 데이터로 이루어집니다. 

즉, 수많은 픽셀 중 한 개가 달라지면 (픽셀의 색상이 변경되면) 이미지 파일 길이가 변경됩니다. 

 

이 점을 착안하여, 이미지 길이를 체크하는 기능을 적용해서 만들어보았습니다.  

 

물론, 모든 이미지마다 사이즈 체크를 넣기보단, 특별한 이미지에만 (이벤트, 법적인 구속력이 강한 영역의 이미지) 적용이 되도록

needSizeCheck 파라미터를 통해 상황에 따라 CDN 사이즈를 체크할 것인지? 

아니면, 그냥 로컬 캐시만 사용할 것인지를 결정하게 하였습니다. 

 

다른 외부 라이브러리를 이용할 수도 있었지만,

제가 원하는 기능을 가지고 있는 라이브러리를 찾지 못했기 때문에 

플러터 공부하는 셈 치고 최대한 심플하게 프로토타입을 만들어 보았습니다.

 

 

<Preference와 Model을 활용하기 위한 클래스>

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'gs_isolatemodel.dart';


class GSCachedImage {
  // Preference : 저장
  Future<void> savetoP(GSIsolateModel input) async {
    if (kDebugMode) {
      print('!!save!! key : ${input.key}, data : $input');
    }

    final perf = await SharedPreferences.getInstance();

    await perf.setStringList(input.key!, [
      input.url,
      input.localPath!,
      input.imageSize.toString(),
    ]);
  }

  // Preference : 로드
  Future<GSIsolateModel> loadFromP(String url) async {
    final perf = await SharedPreferences.getInstance();
    final model = GSIsolateModel(url);

    List<String>? datas = perf.getStringList(model.key!);
    if (datas == null) {
    } else {
      model.imageSize = int.parse(datas[2]);
      model.localPath = datas[1];
    }

    return model;
  }

  // Preference : 삭제
  Future<void> clearP(String key) async {
    final perf = await SharedPreferences.getInstance();

	// 모든 Preference에 저장된 캐시 정보 데이터 삭제
    if (key == 'All') {
      await perf.clear();
    } else {
      // 해당 Key의 캐시 정보 데이터만 삭제
      await perf.remove(key);
    }
  }

  // Cache file I/O : 캐시 폴더 생성
  Future<void> createCachedDirectory() async {
    final directory = await getTemporaryDirectory();
    var target = '${directory.path}/gsimages/';
    Directory(target).createSync();
    print('GS Cached path : $target');
  }

  // Cache file I/O : 대상 폴더
  Future<String> get _storagePath async {
    final directory = await getTemporaryDirectory();
    final target = '${directory.path}/gsimages/';
    return target;
  }

  // Cache file I/O : 캐시 파일명
  Future<String> _storageImageFile(String key) async {
    final path = await _storagePath;
    return path + key;
  }

  // Cache file I/O : 캐시 파일 저장
  Future<String> saveToDisk(GSIsolateModel model, Uint8List imageFile) async {
    if (imageFile.isEmpty) {
      return '';
    }

	// 파일 저장
    var file = File(await _storageImageFile(model.key!));
    file.writeAsBytesSync(imageFile, flush: true);

    if (kDebugMode) {
      print('Save to disk : ${file.path}');
    }

    // 포인터가 아니라 Value라서 적용 범위 한정적임.
    model.imageSize = imageFile.length;
    model.localPath = await _storageImageFile(model.key!);
    await savetoP(model);
    return model.localPath!;
  }

  // 비 사용 캐시 파일 삭제 (이런 작업은 별도 쓰레드로 돌리는 것이 좋음)
  void clearCachedImage() async {
    final day = 7; // 마지막 접근 일자
    var path = await _storagePath;
    final dayBefore = DateTime.now().subtract(Duration(days: day));
    final entities = Directory(path).listSync(recursive: true);
    print('@@ ${dayBefore.toString()} ');
    for (final file in entities) {
      if (dayBefore.compareTo(file.statSync().accessed) > 0) {
        print('OO ${file.statSync().accessed.toString()}');
        final key = file.path.split('/').last;
        print('OO $key');
        await clearP(key);
        await file.delete();
      } else {
        print('XX ${file.statSync().accessed.toString()}');
      }
    }
  }
}

 

 

<이미지 길이 체크, 다운로드와 이미지 Provider를 응답하기 위한 클래스>

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:flutter/foundation.dart';

import '../../extension/extension.dart';
import '../../widgets/image_widget/image_widget.dart';
import 'gs_isolatemodel.dart';
import 'gs_cachedImage.dart';


class GSDownload {
  String? userAgent;
  double? width;
  double? height;

  GSDownload({
    this.userAgent,
    this.width,
    this.height,
  });


  // 이미지 요청 함수 (url -> ImageProvider 응답)
  Future<ImageProvider?> getImage(
    String url, {
    bool needSizeCheck = true,
    double? width,
    double? height,
  }) async {
    if (url.isEmpty) {
      return _defaultErrorImage();
    }
    //
    this.width = width;
    this.height = height;

    ImageProvider? returnData;

    // http로 잘못 들어온 프로토콜을 https로 변경
    url = url.replaceFirst(
      'http://',
      'https://',
    );

    // Preference 에서 해당 URL의 모델 데이터 로드
    GSIsolateModel datamodel = await GSCachedImage().loadFromP(url);

    // 사이즈 체크가 필요없다면 일반 이미지 캐시처리를 한다.
    if (needSizeCheck == false) {
      if (datamodel.localPath.isNotNullOrEmpty) {
        var existFile = await File(datamodel.localPath!).exists();
        if (existFile) {
          return Image.file(
            File(datamodel.localPath!),
            height: height,
            width: width,
            cacheHeight: height?.toInt(),
            cacheWidth: width?.toInt(),
          ).image;
        }
      }
      // 이미지 다운로드 
      var response = await downloadfile(datamodel);

      if (response == null) {
        return _defaultErrorImage();
      } else {
        return _memoryImage(response.bodyBytes);
      }
    } else {
      // CDN 이미지와 로컬 캐시 이미지 사이즈 길이 비교
      bool isSame = await isSameSizeWithCDN(datamodel);

      if (isSame == true) {
        print('@Load file :  ${datamodel.localPath}');
        if (File(datamodel.localPath!).existsSync() == false) {
          var response = await downloadfile(datamodel);

          if (response == null) {
            return _defaultErrorImage();
          } else {
            return _memoryImage(response.bodyBytes);
          }
        }

        return Image.file(File(datamodel.localPath!)).image;
      } else {
        var response = await downloadfile(datamodel);

        if (response == null) {
          return _defaultErrorImage();
        } else {
          return _memoryImage(response.bodyBytes);
        }
      }
    }
  }

  // 이미지 다운로드 
  Future<Response?> downloadfile(GSIsolateModel datamodel) async {
    Response? response;
    try {
      response = (await Client().get(Uri.parse(datamodel.url)));
      if (kDebugMode) {
        print(
            '@@ Downloaded Image binary data size : ${response.contentLength}');
      }

      // Save to disk and update preference
      await GSCachedImage().saveToDisk(datamodel, response.bodyBytes);
      return response;
    } on SocketException {
      if (kDebugMode) {
        print('Internet connection failure');
      }
    } on HttpException {
      if (kDebugMode) {
        print('Could not find server');
      }
    } on ClientException {
      if (kDebugMode) {
        print('Exception occured');
      }
      httpStatusCodeResult(response! as HttpClientResponse);
    }

    // B1 이미지가 없을 경우에만 L1 이미지를 재 다운로드 시도한다.
    if (response!.statusCode == 404 && datamodel.url.contains('B1')) {
      var imageUrl = datamodel.url.replaceAll('B1', 'L1');
      response = await Client().get(Uri.parse(imageUrl));

      if (response.statusCode == 200) {
        // Save to disk and update preference
        await GSCachedImage().saveToDisk(datamodel, response.bodyBytes);
        return response;
      }
    }

    return null;
  }

  // CDN 과 로컬 이미지 길이 비교
  Future<bool> isSameSizeWithCDN(dynamic input) async {
    GSIsolateModel model = input;

    var response = await Client().head(Uri.parse(model.url));

    if (response.statusCode == 200) {
      //Content-Length
      String? cdnSize = response.headers['content-length']!;
      cdnSize = cdnSize.isEmpty ? '0' : cdnSize;
      final int icdnsize = int.parse(cdnSize);

      if (model.imageSize == 0 || model.imageSize != icdnsize) {
        return false;
      } else {
        if (kDebugMode) {
          print('!!!!! SAME !!!!!!');
        }
        return true;
      }
    } else {
      return false;
    }
  }

  // Http error status code 
  void httpStatusCodeResult(HttpClientResponse response) {
    switch (response.statusCode) {
      case 0:
        break;
      default:
        // 앞에서 안 잡힌 오류 확인용
        print('Http code : ${response.statusCode} message : ${response.reasonPhrase}');
    }
  }

  // 문제가 생겼을 때, 응답할 디폴트 이미지 
  ImageProvider _defaultErrorImage() {
    return Image.memory(
      kTransparentImage,
      width: width,
      height: height,
    ).image;
  }
  
  // Default ImageProvider from data
  ImageProvider _memoryImage(Uint8List byte) {
    return Image.memory(
      byte,
      width: width,
      height: height,
      cacheHeight: height?.toInt(),
      cacheWidth: width?.toInt(),
    ).image;
  }
}

 

<호출 테스트 클래스>

@override
  Widget build(BuildContext context) {
    return Semantics(
      label: description,
      child: FutureBuilder<ImageProvider?>(
        // 이미지 실시간+캐시 처리 호출
        future: GSDownload().getImage(
          url,
          width: width,
          height: height,
        ),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Container();
          } else {
            if (snapshot.connectionState == ConnectionState.done) {
              // 비동기가 끝나고 snapshot이 데이터가 있다!
              var image = snapshot.data!;

              return Image(
                semanticLabel: description,
                image: image,
                height: height,
                width: width,
                fit: (fit != null) ? fit : BoxFit.cover,
              );
            } else {
              // 동작 상태의 명확한 확인을 위한 빙글이 적용
              return CircularProgressIndicator();
            }
          }
        },
      ),
    );
   }
 }

 

 

 

 

아래 비교 영상은, 로딩 상태를 확실하게 보기 위하여 빙글 도는 위젯을 추가했으며 동작을 느리게 처리했습니다. (처리 속도 낮춤)

 

[실시간 체크 + 캐시]                                    [일반 다운로드]

 

 

 

 

이렇게 만들다 보니,  갑자기 이런 생각이 들었습니다. 

 

이미지 랜더링은 그냥 로컬 캐시에서 불러오고, 

동시에 백그라운드 쓰레드로 CDN에서 이미지 길이 체크와 이미지 다운로드를 한다면? 

 

문제는, 플러터가 백그라운드 쓰레드가 없는

Isolate 기반으로 싱글 쓰레드로만 동작하는 애플리케이션이었습니다.

싱글 쓰레드 예시

 

그래서, Isolate를 하나 더 만들어 서로 연결해서 멀티 스레드처럼 만들었습니다.

 

Main-Work Isolate

 

멀티 쓰레드 예시

 

 

 

<Work Isolate 및 이미지 길이체크 및 다운로드 클래스>

 // work isolate 통신을 위한 기본 바탕 생성
  Future<void> createIsolate() async {
    final initRP = ReceivePort();

    if (kDebugMode) {
      print('createIsolate : Create work isolate');
    }

    // Create work isolate with 1st data
    _mainIS =
        await Isolate.spawn(workIsolate, [initRP.sendPort, rootIsolateToken]);

    //  work 영역에서 받아온 send port를 멤버 변수에 등록한다.
    initRP.listen((response) {
      _mainToworkSendport = response as SendPort;
  
      // Close port for initialized
      initRP.close();
      _isReady = true;
    });
  }


// 처리 공간 생성
  static void workIsolate(dynamic inputData) async {
    if (kDebugMode) {
      print('workIsolate : start');
    }

    final SendPort ci_mainSP = inputData[0] as SendPort;
    final rootIsolateToken = inputData[1];

    BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken!);
    final ci_RP = ReceivePort()
      ..listen((message) async {
        final ci_RequestSP = message[0] as SendPort;
        GSIsolateModel requestData = message[1] as GSIsolateModel;

        // Processing..
        final sizeCDN = await GSIsolate().getImageSizeFromCDN(requestData.url);
        
      if (sizeCDN == requestData.imageSize) {
          if (kDebugMode) {
            print('Work isolated = SAME SIZE !!!!');
        }
      } else {
        print(
            'Work isolated = DIFFERENT SIZE XXXXX - CDN($sizeCDN) : cache(${requestData.imageSize})');
        requestData.imageSize = sizeCDN;

        // Image download
        requestData.localPath =
            await GSIsolate().imageDownloadProcess(requestData);
        requestData.needUpdate = true;
      }
        

        // Response to main isolate
        ci_RequestSP.send(requestData);
      });

    // 메인 Isolate의 Sendport용 설정
    ci_mainSP.send(ci_RP.sendPort);
  }
  
  
  /* CDN 사이즈 확인 */
  Future<int> getImageSizeFromCDN(String inputUrl) async {
    int returnValue = 0;
    String imageSize = '';

    var response = await Client().head(Uri.parse(inputUrl));

    if (response.statusCode == 200) {
      //Content-Length
      imageSize = response.headers['content-length']!;
      returnValue = int.parse(imageSize);
    } else {
      returnValue = 0;
    }

    return returnValue;
  }

  /* 이미지 다운로드 */
  Future<String> imageDownloadProcess(dynamic input) async {
    final GSIsolateModel inputValue = input;
    var response;

    try {
      response = await Client().readBytes(Uri.parse(inputValue.url));
    } on ClientException {
      if (kDebugMode) {
        print('Isolate image download - Exception occured');
      }
      response = Uint8List(0);
    }

	// 이미지를 캐시에 저장한다. DISK처리는 메모리공간이 다른 Isolate에서도 가능!
    return await GSCachedImage().saveToDisk(inputValue, response);
  }
}

 

위 소스를 테스트하기 전에, 

 

100개 정도 이미지를 랜덤하게 다운로드하는 테스트를 싱글, 멀티로 해봤더니, 

싱글 쓰레드보다 약 40% 정도 상승한 효과를 발휘하였습니다. 

싱글 쓰레드
flutter: [2023-06-12 18:17:15.367339] ### start click
flutter: [2023-06-12 18:17:17.397335] saveToDisk : SAVE - 38914


멀티 쓰레드
flutter: [2023-06-12 18:20:05.771530] ### start click
flutter: [2023-06-12 18:20:06.956449] saveToDisk : SAVE - 38914

Dart Architecture 상 서로 정보교환을 위한 딜레이가 있을 것이라 예상했지만

생각보다 성능이 안 나오는 이유는 메모리 공유가 안 되는 문제가 가장 크다고 생각되네요.

(포인터를 사용하는 것이 편한데...)

 

예상되는 걱정이 있었지만,  멀티 Isolate 적용 소스를 테스트 해보았습니다. 

 

싱글 쓰레드로 처리하는 것보다 20~30%정도 나아졌지만, 

간혹 보이는 Isolate의 다양한 상황을 따져봤을 때,

Work Isolate에게 작은 데이터를 대량으로 처리시키게 하는 것보다는,

CPU 소비가 다소 긴 부분의 소량의 큰 처리를 맡기는 것이 효율적으로 보였습니다.

 

실제로도 찾아보니, Flutter dev 쪽에서는 Json parsing을 처리하는 부분을 예시로 들었더군요.

(참고 : https://dart.dev/language/concurrency)

 

위의 Multi Isolate 적용 시

 

장점은 

메인 쓰레드에서는 일반 로컬 캐시와 동일한 이미지 로딩과 동시에

백그라운드 쓰레드에서는 CDN과 연동하여 기존 이미지 업데이트를 할 수 있음.

 

 

단점은 

고객이 보던 화면 내 이미지가 화면 밖으로 한번 나가서 안 보였다가 다시 보여야 새로운 이미지로 갱신된다는 점. 

(혹은 백그라운드에서 이미지 업데이트 완료 시 이벤트 리스너를 통한 화면 갱신이 필요 - 화면 깜박임)

 

이 단점으로 인해. 맨 처음  구상했던 목표가 오염된다고 판단하였습니다.

 

위 부분은 다양한 방식을 좀 더 테스트하면서 보완하면서,적용 영역을 한정하는 것도 검토하고 있습니다.

예를 들어, 메모리를 더 확보하여, 디스크 캐시에서 로드하기보다,  

메모리에 한 번 로딩한 이미지를 올려놓고, 이미지 데이터를 렌더링 한다던지....

(Disk I/O, Disk buffer 사용률은 낮추고, 메모리 사용율을 높이는 방식 = 이미지가 노출되는 속도 상승)

 

아무튼 보완과 검토가 완료되면 다양한 영역에 의견을 듣고 나서 운영 앱에 적용을 검토할 예정입니다.  

 

 

 

 


임성남 | 디지털서비스본부 > GSSHOP 개발팀
GSSHOP App 개발/ GSSHOP 메시징 시스템 운영을 담당

경험 언어 : C, C++, C#, Object Pascal, Objective-C, Swift, Dart(ing)

경험 프로젝트 : ITAM(시계열 예측기반), SMS(Server Management System), NMS, IPS, API, Windows service, IOCP Server, 2D Image filter engine,  iPhone OS 기반 여러 App 개발 등.