달새 메모리 최적화 기록

최종 코드링크
달새 MK-IV의 경우 초기 메모리 최적화에 매우.. 실패를 했습니다.
메모리 최적화를 진행 하면서 몇가지 모델링을 정해놓고 진행을 했습니다.

  • 과거의 경험에서 나온 추론으로 string이 많은 게 문제일 것이다고 생각 하고 트위터에서 키값으로 주는 id_str을 전부 id: bigint로 변경
    • bigint로 했더니 JSON.stringyfy가 안 먹혀서 폐기.. 아니 대체 왜.......어이없다
  • 안 쓰는 값을 싹다 정리하고 interface형에서 class형으로 만들어서 생성자에서 쓰는 값만 할당
    • 추가로 new 연산 최소화하고 get property로 변경

  • 위에서부터 실제 사용중인 프로세스, 앞과 같은 버전, 켜놓고 방치
  • 아래 두 개의 프로세스는 동일하게 new, 오브젝트 생성을 최소화 한 버전. vue의 기본 __ob__제거 버전, 그냥 new 최소화만 한 버전

최종 선택은 가장 아래 그냥 new 최소화만 한 버전이 됐습니다.

내부 트윗 데이터 관리부터 알아야 합니다.

자체적으로 Tweet Object를 쉽게 쓰기 위해 orgTweet이라는 프로퍼티가 있습니다.
리트윗일 경우 retweeted_status라는 프로퍼티가 자식으로 있는데
해당 값이 리트윗 원본 데이터입니다.
그리고 리트윗 한 사람의 정보는 부모에 담겨있고요.
그래서 리트윗이 아닌 트윗, 리트윗인 트윗 둘 다 원본을 읽기 위해서 해당 값을 사용 하고 있습니다.
인용 트윗일 경우 자식으로 quoted_status가 있습니다.

new Tweet을 하던 곳

  • Vuex 모듈인 TweetStore.ts
  • 가상 스크롤 패널을 관리하는 ScrollPanelBase.tsCreateData(data: any)
  • TweetBase.tsget qtTweet
  • Tweet.ts의 생성자에서 orgTweet을 new 할당

new Tweet은 한 곳에서만

  • new Tweet(tweet)은 Vuex 모듈인 TweetStore.ts에서만 진행을 합니다.
    • 실제로는 다른데에서도 사용 하지만 최적화와 무관한 코드입니다.
      바뀐 Tweet 생성자 소스코드
 constructor(tweet?: Tweet) {
    if (tweet) {
      this.id_str = tweet.id_str;
      this.created_at = tweet.created_at;
      this.full_text = tweet.full_text;
      this.is_quote_status = tweet.is_quote_status;
      this.favorited = tweet.favorited;
      this.retweeted = tweet.retweeted;
      this.source = tweet.source;
      this.entities = tweet.entities;
      this.extended_entities = tweet.extended_entities;
      this.retweeted_status = tweet.retweeted_status;
      this.retweet_count = tweet.retweet_count;
      this.favorite_count = tweet.favorite_count;
      this.place = tweet.place;
      this.is_quote_status = tweet.is_quote_status;
      this.isRead = false;
      this.in_reply_to_status_id_str = tweet.in_reply_to_status_id_str;
      this.in_reply_to_user_id_str = tweet.in_reply_to_user_id_str;
      this.quoted_status_id_str = tweet.quoted_status_id_str;
      this.in_reply_to_status_id_str = tweet.in_reply_to_status_id_str;
      this.isDelete = false;
      this.user = new I.User(tweet.user);
      if (tweet.retweeted_status) {
        this.retweeted_status = new Tweet(tweet.retweeted_status);
      }
      if (tweet.quoted_status) {
        this.quoted_status = new Tweet(tweet.quoted_status);
      }
    } else {
        //..........
    }
  }

그리고 retweeted_status 아래와 같이 프로퍼티로 변경

  get orgTweet(): Tweet {
    if (this.retweeted_status) return this.retweeted_status;
    else return this;
  }

ScrollPanelBase.ts에서 CreateData를 폐기

new 연산 하던 걸 참조로 변경

  AddData(data: any, index: number) {
    const minHeight = moduleUI.minHeight;
    if (!this.stateData.setKey.has(data.id_str)) {
      this.stateData.setKey.add(data.id_str);
      const prev = this.stateData.listData[index - 1];
      const scrollTop = prev ? prev.scrollTop + prev.height : index * minHeight;
      const item: M.ScrollItem<any> = {
        data: data,//이 부분입니다.
        key: data.id_str,
        height: minHeight,
        isResized: true,
        scrollTop: scrollTop
      };
      this.stateData.listData.splice(index, 0, item);
    }
    if (this.isRendered()) {
      this.SetIndex();
    }
  }

__ob__제거 버전이 폐기된 이유

과거 MK-III개발 당시에 __ob__이 메모리를 상당히 먹는 문제가 있어 동일한 시도를 해보았으나
당시 사용하던 라이브러리의 메모리 누수로 추정될 정도로 메모리에 영향도가 없었기에
굳이 추가적인 로직 추가할 필요성을 못 느껴서 폐기했습니다.

마지막으로 트윗 최대 저장 수 지정

  • Vuex에서 들고있는 트윗의 최대 수를 1500개로 제한

위가 데이터 1500개를 들고있는 수준의 메모리입니다.
이정도로 1차 최적화는 만족을 하고 달새 메모리 최적화는 종료됩니다.

이 글의 교훈: new 작작하자

Vue.js에서 동적 높이 아이템을 렌더링하는 가상 스크롤 구현기

정말 많은 시도를 해보았고 많은 수정을 거친 최종 코드는 아래의 링크입니다
ScrollPanelBase.ts
ScrollItem.ts

내가 필요한 기능

  • 당연히 동적 높이가 적용 되는 가상 스크롤
  • 특정 아이템으로 스크롤이 이동 가능 해야 함
  • 타입스크립트가 지원 돼야 함

라이브러리 탐색

  • 두 종류의 라이브러리가 있었으나 동작을 하지 않거나 특정 아이템으로 스크롤 이동이 가능해야 함이 안 된다던가
    타입스크립트가 지원 돼야 함이 안 된다던가 모든 조건을 불만족 시킴

많은 코드 분석

  • 동적 가상스크롤을 지원하는 라이브러리의 코드가 다 공개 되어 있어 해당 라이브러리 및 동적 가상 스크롤 예제들의 코드를 분석 시작, 상당히 많은 수의 코드를 봤고... 내가 필요한 거에 맞는 로직을 찾음

달새의 특이사항

  • 채팅처럼 메시지가 아래로 쌓이는 방식이 아닌 위에 쌓이는 방식, 트위터가 그런 방식이죠

기본 로직

  • 해당 로직은 설계 초기부터 핵심 로직으로 변하지 않음
//스크롤 아이템 데이터
interface ScrollItem<T> { 
  data: T; // 내부 데이터
  height: number; // 스크롤 아이템 자체의 높이 
  scrollTop: number; // 스크롤 아이템의 scrollTop 위치
  key: string; // 데이터를 찾기 쉽게 하기 위한 key 값
}
list: ScrollItem[] = [];
const minHeight = 40;//스크롤 아이템의 최소 높이입니다. 
//스크롤 아이템이 렌더링 되면서 리사이즈 이벤트 발생 
Resized(key: string, oldHeight: number, newHeight: number){
  //key로 해당 리사이즈 이벤트가 일어난 index를 찾습니다. 
  //index가 5라고 가정 const idx = 5; 
  //oldHeight와 newHeight의 차이를 구합니다. 이 값을 idx 다음부터 모든 아이템의 scrollTop에 더해줄 거예요 
  const moveY = newHeight - oldHeight;
  for(let i = idx ; i < list.length - 1; i++){ 
    list[i].scrollTop += moveY; 
  }
}
  • 화면 상에 표시 되는 top 아이템의 index와 bottom 아이템의 index를 구해야 합니다.
    이 값을 구하는 방식은 두 가지 방식이 사용 됩니다.
    top 아이템은 scrollTop으로 이진탐색을 진행 하고
    bottom 아이템은 top아이템 index + 스크롤 패널 height / minHeight

간단하게 그림으로 봅시다

이게 기본 생성 된 스크롤 영역입니다.

여기서 2번 아이템의 높이가 60으로 바뀝니다.

index 3번부터 scrollTop이 모두 20씩 올라갔습니다.
이제 새 아이템이 추가 됩니다.

기본적인 로직은 여기까지입니다.

여기부터 삽질의 역사입니다.

스크롤의 ScrollTop이 바뀔 때 렌더링 index를 계산 하기

구현 방식

  • 스크롤 패널의 ScrollTop이 바뀔 때마다 렌더링 할 index를 계산하여 이전 index와 다를 경우 재 랜더링
//ScrollTop이 변경 되어 index가 변경 됨
//아이템이 추가 될 때에도 index가 변경 되어야 함. 기존 0번 아이템이 1번이 되기 때문에...

//이벤트 조건: ScrollTop이 변경 되거나 아이템이 추가 되었을 경우
async SetVisibleData() {
  //this.listData : 원본 데이터
  //this.state.listVisible : 렌더링 할 데이터
  if (this.listData.length === 0) return;
  this.state.listVisible = this.listData.slice(this.state.startIndex, this.state.endIndex);
}

문제점

  • 그렇다. index가 바뀔 때마다 전체를 새로 렌더링을 한다!
    리소스는 심각한... 문제까진 안 되는데 화면이 깜빡이는 문제가 발생 합니다.

결국 ScrollTop이 바뀔 떄 렌더링 index 계산하는 방식은 폐기

  • 이후로 많은 시행 착오가 있었지만 큰 맥락으로 기억이 안 납니다... 기억 나면 다시 추가 하는 거로

완전체 동적 가상스크롤 로직까지 최적화 삽질

props로 받는 listData가 없어지기까지

  • vuex에 등록 되어 있는 listData를 props로 받아서 진행 했을 당시 로직
class State {
  scrollTop = 0;
  startIndex = 0;
  endIndex = 50;
  translateY = 0;
  selectKey = '';
  index = 0;
}

class StateData<T> {
  setKey: Set<string>;
  listData: M.ScrollItem<T>[];
  listVisible: M.ScrollItem<any>[] = [];
  constructor() {
    this.setKey = new Set();
    this.listData = [];
  }
}
export class ScrollPanelBase extends Vue {
  state = new State();
  stateData = new StateData();
  @Watch('listData', { immediate: true, deep: true })
  OnChangeListData(newVal: any[]) {
    this.CreateScrollData();
    this.SetIndex();
  }
 CreateScrollData() {
    //props listData가 갱신 될 때만 호출
    const list = (this.listData as unknown) as any;
    if (!list) return;
    const minHeight = moduleUI.minHeight;
    for (let i = 0, len = list.length; i < len; i++) {
      const current = list[i];
      if (!this.stateData.setKey.has(current.id_str)) {
        this.stateData.setKey.add(current.id_str);
        const prev = this.stateData.listData[i - 1];
        const scrollTop = prev ? prev.scrollTop + prev.height : i * minHeight;
        const item: M.ScrollItem<any> = {
          data: current,
          key: current.id_str,
          height: minHeight,
          isResized: true,
          scrollTop: scrollTop
        };
        this.stateData.listData.splice(i, 0, item);
      }
    }
  }
  SetIndex() {
    this.state.scrollTop = this.scrollPanel.scrollTop;
    let scrollTop = this.state.scrollTop;
    if (scrollTop < 0) {
      scrollTop = 0;
    }
    let startIndex = this.BinarySearch(this.stateData.listData, scrollTop);
    startIndex -= 5; //버퍼
    if (startIndex < 0) startIndex = 0;
    if (this.scrollPanel.scrollTop === 0) {
      startIndex = 0;
    }
    this.state.startIndex = startIndex;
    this.state.endIndex = startIndex + Math.floor(this.$el.clientHeight / moduleUI.minHeight);
    if (this.isSmallTweet) {
      this.state.endIndex += 10;
    }
    this.stateData.listVisible = this.stateData.listData.slice(this.state.startIndex, this.state.endIndex);
    this.CreateComponent();
  }
}
  • 위 로직의 핵심은 Vuex의 listData가 바뀔 경우 어느 listData 전체를 루프 돌면서 내부 데이터에 복제하는 짓입니다.

  • for문 1000번 정도 돌고 set의 데이터만 체크하고 데이터 없는 거만 복사하는 거? 그게 그렇게 오래 안 걸리겠지
    라는 생각은 결국 로직을 엎게 됩니다.

진짜 최종체

  • 앞의 삽질에서 얻은 교훈으로 로직을 정리 하게 됩니다.
  • 생략되는 삽질에 오브젝트 풀이 있는데 해당 내용은 폐기 되었습니다. 이유로는
    1. vue component의 props를 강제로 바꿀 방법이 없음.
    2. data를 쓰자니 매번 함수 호출을 해야하고 props성격이 data가 되는 느낌이 싫어서
    3. 생각보다 create, destroy가 리소스를 잡아먹지 않는다는 점
  • 아래는 최종 로직
    1. 유지해야 할 로직 SetIndex와 @Watch('scrollTop')
    2. 데이터 추가는 @Watch('listData')가 아닌 AddData(data: any)로 함수 호출로 변경, 외부 종속성이 심하지만 어쩔 수 없습니다. @Watch('listData')를 사용하게 될 경우 생각보다 많은 시간 프로그램이 멈추게 되어 해당 로직은 폐기 합니다.
    3. 렌더링 dom은 필요 시 create하고 렌더링 영역에서 벗어났을 경우 destroy
//ScrollPanelBase.ts
class StatePool {
  listBench: Vue[] = [];
  constructor() {
    this.listBench = [];//렌더링 할 컴포넌트입니다.
  }
}
@Component
export class ScrollPanelBase extends Vue {
  statePool = new StatePool();
  CreateComponent() {//데이터가 추가 되거나 scrollTop이 변하면서 index가 변할 경우 호출 됩니다.
    //렌더링용 데이터 추가
    const keysBench = this.statePool.listBench.map(x => x.$props['data'].key);
    this.listVisible.forEach(item => {
      if (keysBench.includes(item.key)) {
        return true;
      }
      const selected = item.key === this.stateData.listData[this.state.index].key;
      const component = new ScrollItem({
        propsData: { data: item, itemType: this.itemType, selected: selected }
      });
      component.$on('on-resize', this.OnResizeTweet); //리사이즈 이벤트를 처리하기 위해 추가
      component.$vuetify = this.$vuetify; //뷰티파이를 사용 중이기에 할당 해줍니다.
      this.statePool.listBench.push(component);

      const div = document.createElement('div');
      div.id = item.key;

      this.scrollPort.appendChild(div);

      component.$mount(div);
      //이 부분을 조심 해야 합니다. div를 새로 만들고 해당 div에 mount해야 컴포넌트가 해당 div를 대체합니다.
      this.$children.push(component);
      //다음 현재 ScrollPanel의 자식으로 컴포넌트를 추가 해줍니다.
    });
    //이 아래는 렌더링 영역 밖으로 나간 컴포넌트를 삭제하는 로직입니다.
    const keysVisible = this.listVisible.map(x => x.key);
    for (let i = 0; i < this.statePool.listBench.length; ) {
      if (keysVisible.includes(this.statePool.listBench[i].$props['data'].key)) {
        i++;
      } else {
        const destroy = this.statePool.listBench[i];
        this.statePool.listBench.splice(i, 1);
        destroy.$el.remove();
        destroy.$destroy();
      }
    }
  }
}

개발 조건에 있던 특정 아이템으로 스크롤이 이동 가능 해야 함의 소스 코드

  //외부에서 호출
  //키보드 위, 아래, 맨 위로(홈키), 맨 아래로(엔드키) 등 특정 아이템으로 이동해야 할 경우 모두 이 로직을 거칩니다.
  //해당 함수를 통해서만 스크롤 이동이 되기에 스크롤을 내린 상태에서 아이템이 추가 되어도 보고 있는 렌더링 화면이 고정이 됩니다.
  ScrollToIndex(newVal: number) {
    if (newVal === -1) return;
    this.state.index = newVal;
    const selectData = this.stateData.listData[newVal];
    if (!selectData) return;
    this.state.selectKey = selectData.key;
    const idx = this.listVisible.findIndex(x => x.key === selectData.key);
    if (idx === -1) {
      this.scrollPanel.scrollTo({ top: selectData.scrollTop });
      return;
    }
    const component = this.statePool.listBench.find(x => x.$props.data.key === selectData.key);
    if (!component) return;
    const tweetPos = component.$el.getBoundingClientRect();
    const panelPos = this.scrollPanel.getBoundingClientRect();
    const tweetBottom = tweetPos.y + tweetPos.height;
    const panelBottom = panelPos.y + panelPos.height;
    if (tweetBottom > panelBottom) {
      //내려가는 로직
      const top = tweetBottom - panelBottom;
      this.scrollPanel.scrollTo({ top: this.state.scrollTop + top });
    } else if (tweetPos.top < panelPos.y) {
      //올라가는 로직
      this.scrollPanel.scrollTo({ top: selectData.scrollTop });
    }
  }

내용이 너무 많기도 하고 기억 안 나는 부분도 많아서 두서없이 적긴 했지만 최종 버전만 기억 해주시면 됩니다.
최종 버전은 포스팅 제일 첫 부분에 있는 소스코드 링크에서 확인 해주세요.

정말 이 기능을 만드는 데 오래 걸렸습니다. 시행착오도 많이 겪었고
로직 자체를 만드는 데도 오래 걸렸어요...
사실 이 기능 하나를 못 만들어서 달새 개발이 오랜 기간 멈춰있던 거도 있죠
근데 어쩌다보니 개발에 성공 해서 지금의 달새를 완성하고 공개 한 거 같습니다.

다음에 이 포스팅을 더 보기 좋게 수정을 하던가 해야겟어요
다음에는 또 엄청난 삽질 기록을 들고 오겠습니다.

오늘도 내가 안 까먹으려고 적는 포스트...
뷰 빌드 시 빈페이지가 나오는 경우가 있습니다.
해당 문제는 파일을 로컬에서 열어본다던가 electron에서 돌린다던가 할 때 발생 합니다.
해당 문제 해결 방법

우선 빌드 파일 확인

  • 빌드 된 index.html을 열어봅시다.
  • 개발자 도구로 element 탭을 확인 해봅니다.

위와 같이 경로가 /js/aaaa.js가 나올 경우

//vue.config.js 파일을 수정 합니다.
module.exports = {
  //뭔가 다른 설정들
  publicPath: './', //해당 설정을 추가 해줍니다. 상대경로인 ./ 로 설정 합니다.
  assetsDir: './', //만약 css, js, img 등 어셋 경로가 다를 경우 해당 경로도 수정 해야 합니다.
  //뭔가 다른 설정들
  }

  • 위의 설정을 바꾸시면 이렇게 경로가 변경이 되고 js, css도 정상적으로 불러와지는 걸 볼 수 있습니다.

위의 설정을 해도 안 될 경우

  • Vue Router의 modehistory일 경우입니다.
  // 보통은 router/index.js 파일. 혹은 main.js 파일에 있습니다.
  //VueRouter라는 텍스트를 찾습니다. 
  VueRouter({
  mode: 'history',
  base: process.env.BASE_URL, routes 
  });
  • mode가 history 모드일 경우 로컬 파일 & electron에서 정상 동작을 하지 않습니다. 쿨하게 빼버립시다.
  // 보통은 router/index.js 파일. 혹은 main.js 파일에 있습니다.
  //VueRouter라는 텍스트를 찾습니다. 
  VueRouter({
  base: process.env.BASE_URL, routes 
  });
  • 뺀 다음 라우터 사용 시 이동 URL을 수정 해야 합니다.
const url = '/ImageView'
//위의 url를 
const url = '#/ImageView'
//이와 같이 / 앞에 #을 붙여줘야 합니다.
  • 다른 해결방법이 있는 거 같지만 귀찮습니다 돌아가면 됐죠

이번에 달새 작업을 하면서 소스 코드의 데이터 전체를 Vuex로 전환 하는 작업을 하게 됐습니다.
왜 이런 짓을 하게 되었는가 하면....
Vuex에서 메모리 이슈를 크게 겪고 아 메모리 안터지게 할거야~ 했다가
바인딩 안 되는 지옥도를 맛 보고 결국 Vuex로 돌아오게 됐습니다.
님들은 제발 처음부터 Vuex 쓰세요
이번에도 너무 고생을 했기에 글로 한번 정리합니다.

Vuex에서 타입스크립트를 완벽(?)하게 사용하기

타입스크립트를 사용 하는 이유가 무엇이겠습니까
강타입! 자동완성! any가 없는 타입 추론!
Vuex를 도입하게 되고 겪은 문제가 타입 추론이 되지 않는다는 문제였습니다.

사용 라이브러리

  • Vuex
    • vuex-module-decorators

store/index.ts

//vuex를 모듈화해서 사용합니다. 다른 모듈을 import
import { ISwitterState } from './modules/SwitterStore';
import Vuex from 'vuex';
import Vue from 'vue';
Vue.use(Vuex);//Vue.use(Vuex)는 이 코드에만 사용 합니다.

//단순한 루트 State입니다.
export interface IRootState {
  //modules에서 가져온 SwitterStore의 State 인터페이스
  switter: ISwitterState;
}
//설정이 완료 된 Vuex 객체를 최종 export합니다.
export default new Vuex.Store<IRootState>({ getters: {} });

store/modules/SwitterStore.ts

import * as I from '@/Interfaces';//이건 제 개인적인 인터페이스 네임스페이스입니다
import { Module, VuexModule, Mutation, Action, getModule } from 'vuex-module-decorators';//라이브러리에서 사용 할 애들
import * as A from '@/store/Interface';//
import store from '@/store';//

//이 모듈의 State 인터페이스
export interface ISwitterState {
  switter: I.Switter;
}

//dynaic: Vuex가 동적으로 모듈을 로딩 하는 값
//store: root(store/index.ts)에서 export한 Vuex 루트 객체
//name: 뭔가 있어보임. ※실제로 어떻게 사용하는지 찾지 못 함
@Module({ dynamic: true, store, name: 'switter' })
class SwitterStore extends VuexModule {
  //State객체입니다. 사용하실 객체를 전부 넣어주시면 됩니다.
  switter!: I.Switter;
  /*
  예: user!: User;
  예2: count: number;
  */

  //컴포넌트에서 사용하게 될 computed입니다.
  get selectID() {
    let id = this.switter?.selectUser.user_id;
    id = id ? id : '';
    return id;
  }

  //※Muation과 Action의 이름은 서로 달라야 합니다.

  //일반적인 Mutation입니다 실제 State에 값을 할당 하는 로직을 구성 합니다.
  //명시적으로 private선언을 해서 IDE에서 잘못 호출 하지 않도록 합니다.
  @Mutation
  private setKey(setkey: A.SetKey) {
    this.tempUser = new I.DalsaeUser();
    this.tempUser.oauth_token = setkey.publicKey;
    this.tempUser.oauth_token_secret = setkey.secretKey;
  }
  //외부에서 호출 하게 될 Action 별 내용은 없습니다.
  @Action
  public SetKey(setKey: A.SetKey) {
    this.context.commit('setKey', setKey);//Mutation을 호출 합니다.
  }
}
//최종적으로 getModule에 Store클래스를 넘겨 모듈을 생성 후 export 합니다
//moduleSwitter는 외부에서 접근 할 이름입니다.
export const moduleSwitter = getModule(SwitterStore);

어딘가의 ts파일

import { moduleSwitter } from '@/store/modules/SwitterStore';
async Test1(){
 moduleSwitter.SetKey({
      publicKey: result.data.oauth_token,
      secretKey: result.data.oauth_token_secret
    });
}
async Test2(){
 moduleSwitter.SetKey({
  store.dispatch('SetKey', {
    publicKey: result.data.oauth_token,
    secretKey: result.data.oauth_token_secret
  }); 
}

Test1은 타입스크립트에 맞춘 형식이고

Test2는 고전적인 string dispatch입니다
하지만 우리는 타입스크립트를 사용하고 싶습니다...
자동 완성 좋아

Test1의 경우 IDE에서

Test1의 경우 정말 타입이 다 나오니 간단하게 파라메터를 넘길 수 있겠군요!

Test2의 경우 IDE에서

Test2의 경우 type: string으로 설정 되며 payload는 any형이 됩니다.

Vue디버거 툴에서 확인 되는 Vuex 업데이트

짜잔! vuex에 정상 반영이 되었습니다.

외부에서 state에 접근 하고 싶을 경우

Root Store부터 접근할 경우 이렇게 사용합니다.
모듈만 import 했을 경우

import store from '@/store';
import { moduleSwitter } from '@/store/modules/SwitterStore';
store.state.switter.switter.listUser//root Store부터 접근 시
moduleSwitter.switter.listUser//module store만 가져와 접근 시

왜 이렇게 동작 하나요? ※이 내용은 정확하지 않을 수 있습니다.

Node.js에서 import(js의 경우 require)를 할 경우
동기로 모듈을 읽어와 메모리에 캐시를 하게됩니다.
모듈에서 읽어와 캐시 작업을 하는 것은 export한 객체입니다.
캐시의 범위(스코프?)는 package를 로드하는 범위입니다.
같은 스코프 안에서는 import한 모듈이 공유 됩니다.

main.ts

import store from './store';
new Vue({
  router,
  store,
  vuetify,
  render: h => h(App)
}).$mount('#app');

main파일에서 Vuex의 store를 import하게 됩니다.
import를 하며 store파일을 읽어 해당 파일의 코드를 진행 합니다.

store/index.ts

import { ISwitterState } from './modules/SwitterStore';

index에서 import를 하며 해당 모듈이 node.js의 메모리에 올라가게 됩니다.
같은 패키지 내에서는 싱글톤처럼...? 메모리를 공유 하게 됩니다.
캐시 처리 되는 부분은 ts파일의 export된 모든 항목입니다.

store/modules/SwitterStore.ts

export const moduleSwitter = getModule(SwitterStore);

index에서 import를 하며 export된 항목을 쭉 훑습니다.
훑으면서 마지막에 moduleSwitter가 생성되어 메모리에 올라가게 되고

무언가.ts

import { moduleSwitter } from '@/store/modules/SwitterStore';

main, index, SwitterStore 파일을 거쳐오며 모듈이 메모리에 올라가게 되고
외부에서 모듈의 객체(State, Action)에 접근이 가능해지는 겁니다.

다시 말씀 드리지만 동작 관련 부분은 저도 정확히 알고 있는 내용은 아닙니다.

컴포넌트에서 접근, 사용하기

해당 내용은 제가 구현 한 뒤에 다시 수정하겠습니다.

회사 git이 수십개로 구성되어 있는데 node-modules를 다 깔 생각에 귀찮아서 만든스크립트입니다.

  • 구동 환경: 윈도우 Power Shell

폴더구조

  • 프로젝트 폴더
    • 저장소1
      • package.json
    • 저장소2
      • package.json

프로젝트 폴더에서 shift+우클릭으로 현재 경로에서 PowerShell 창 열기를 합니다

foreach($file in Get-ChildItem '경로')
{
cd($file)
npm install
cd..
}

입력 하시고 엔터를 칩시다
저장소1, 저장소2와 같은 폴더 구조를 순회하며 node-modules를 모두 설치합니다.

정말 단순하게 폴더를 순회하며 설치만 하는 스크립트입니다.

구조가 다르다면 환경에 맞게 좀 바꿔 사용하시면 됩니다.

달새 개발하다가 정말 이거로 3일 삽질 한 거 안 까먹고자 메모
개발 환경: Node.js

트위터에서는 GIF 파일을 15MB까지 지원 한 지 시간이 꽤 흘렀습니다.
물론 귀찮아서 기능을 넣지 않고 버티다 이번에 신규 개발을 하며 해당 기능을 넣고있는 상황입니다.
새로 나온 API는 API콜을 여러 차례로 나눠 보내야 하는데 마지막 FINALIZE에서 항상

Segments do not add up to provided total file size

이 에러가 떨어졌다 왜지 전송 데이터 bytes를 체크 해봐도 정상적으로 보냈는데?
저와 같은 삽질을 하는 분을 한 분이라도 더 구원하기 위해 이 글을 작성 합니다.


대용량 이미지 전송을 합시다

이미지 전송 제한

기본적으로 15MB를 넘지 않으면 되며
GIF의 가로, 세로, frame수 등도 영향이 있기는 합니다.
공식 문서 참고 바랍니다.

developer.twitter.com/en/docs/twitter-api/v1/media/upload-media/uploading-media/media-best-practices

새로운 이미지 전송

  • API 요청을 많이 해야 합니다.
  1. 이미지 전송 요청
  2. 이미지 분할 전송(1/3)
  3. 이미지 분할 전송(2/3)
  4. 이미지 분할 전송(3/3)
  5. 이미지 전송 완료 요청
  6. 이미지 처리 확인

이미지 전송 요청(INIT)

URL

upload.twitter.com/1.1/media/upload.json
모든요청의 URL은 동일합니다

메소드

  • POST

파라메터

  • command (필수)
    • "INIT" 고정값
  • total_bytes(필수)
    • 123456
  • media_type(필수)
    • image/gif MIME 타입을 적습니다.
  • media_category(필수 아님)
    • TweetImage, TweetVideo, TweetGif, DmImage, DmVideo, DmGif, Subtitles를 사용

요청 시 주의 할 점

  • 바디에 데이터를 넣지 말 것
  • 파라메터는 QueryParam으로 보낼 것
    • 바디에 넣어도 성공 하는 걸 확인은 했습니다만 언제 사양이 바뀔지 모르니 QueryParam으로 보냅시다.
  • OAuth 계산은 media, media_data를 제외하고 모두 계산에 포함 해야합니다.

리스폰스

{
  "media_id": 710511363345354753,
  "media_id_string": "710511363345354753",
  "size": 11065,
  "expires_after_secs": 86400,
  "image": {
    "image_type": "image/jpeg",
    "w": 800,
    "h": 320
  }
}

리스폰스에서 필요한 값

  • media_id 혹은 media_id_string
  • media_id_string는 8바이트 숫자도 표현 못하는 우리 똥같은 JS에서 써야 합니다.

이미지 전송 요청(APPEND) 이부분이 정말 중요합니다

URL

upload.twitter.com/1.1/media/upload.json

메소드

  • POST

파라메터

  • command (필수)
    • "APPEND" 고정값
  • media_id(필수), INIT에서 받은 media_id_string 값입니다.
    • 710511363345354753
  • media(필수)
    • 하지만 우리는 이걸 쓰지 않습니다.
  • media_data(필수)
    • 이미지를 여기에 할당 합니다.
  • segment_index(필수)
    • 이미지를 쪼갠 index입니다. 0~999를 사용하며 0 시작입니다.

media_data에 데이터 할당하기

이부분이 중요합니다.

  • 이미지를 Base64로 읽은 값을 예시로 들겠습니다.
    data:image/png;base64,이미지문자열
    읽은 Base64 값은 이렇게 구성 되어 있을 겁니다.
    우리가 보내야 할 건 data:image/png;base64,를 제외 한 이미지문자열 항목입니다.
  1. 이미지문자열 을 바이너리(Blob)로 변환합니다.이미지문자열 은 abcdef로 변환 되었습니다.
  2. const strBinary = atob("이미지문자열");
  3. 변환 된 바이너리를 쪼개기
  • abcdef 항목을 3개로 쪼갭니다. [ab, cd, ef]
  1. 쪼갠 바이너리를 Base64로 변환
  • [12, 34, 56] 이렇게 바뀌었습니다.

이미지 전송

  • INIT과 마찬가지로 QueryParam을 보냅니다. 단, media & media_data는 제외
  • segment_index 쪼갠 데이터 인덱스 순서에 주의!
  • 이미지 데이터는 FormData에 할당 후 보냅니다.
  • const body = new FormData(); body.append('media_data', 12);
  • OAuth 계산은 media, media_data를 제외하고 모두 계산에 포함 해야합니다.

리스폰스

  • 없음. 성공 시 HTTP StatusCode가 204로 응답이 옵니다.
  • 실패할 경우에만 리스폰스에 에러 메시지가 있습니다.

이미지 전송 완료 요청(FINALIZE)

이미지 다 보냈습니다 이미지 합쳐주세요.

URL

upload.twitter.com/1.1/media/upload.json
모든요청의 URL은 동일합니다

메소드

  • POST

파라메터

  • command (필수)
    • "FINALIZE" 고정값
  • media_id(필수), INIT에서 받은 media_id_string 값입니다.
    • 710511363345354753

요청 시 주의 할 점

  • 바디에 데이터를 넣지 말 것
  • 파라메터는 QueryParam으로 보낼 것
    • 바디에 넣어도 성공 하는 걸 확인은 했습니다만 언제 사양이 바뀔지 모르니 QueryParam으로 보냅시다.
  • OAuth 계산은 media, media_data를 제외하고 모두 계산에 포함 해야합니다.

리스폰스

// Example of sync FINALIZE response
{
  "media_id": 710511363345354753,
  "media_id_string": "710511363345354753",
  "size": 11065,
  "expires_after_secs": 86400,
  "video": {
    "video_type": "video/mp4"
  }
}

// Example of async FINALIZE response which requires further STATUS command call(s)
{
  "media_id": 710511363345354753,
  "media_id_string": "710511363345354753",
  "expires_after_secs": 86400,
  "size": 10240,
  "processing_info": {
    "state": "pending",
    "check_after_secs": 5 // check after 5 seconds for update using STATUS command
  }
}

우린 쿨합니다. 리스폰스에서 에러만 안 났다면 media_id를 담아 트윗을 전송합니다.

  • STATUS라는 별도의 API가 존재를 합니다.
    동영상 처리처럼 오래 걸리는 작업이 정상적으로 끝났는지를 확인하는 API입니다.
    별도의 확인 절차를 거치실 분은

이 글을 보고 누군가 삽질 하지 않고 구원 받길 기도하며...

사실 base64, blob변환 왜 저렇게 해야 되나 싶긴한데 서버에 base64 문자 자체를 보내는 방법이 있을 거 같긴 하지만...
정상 동작 하면 그만이죠
base64랑 blob이랑 읽는 bit수 자리가 다르다보니
서버에서 이미지 해석을 정상적으로 하지 못해 생기는 문제가 아닐까 싶습니다.


 

트위터 API글답게 블로그 댓글은 잘 안 보고 멘션은 잘 봅니다.
문의사항은 트위터로 와서 해주세요
twitter.com/hanalen_

Vue-Electron으로 달새 작업을 하면서 문제가 생겼다
트윗 데이터가 쌓이면 쌓일수록 메모리가 기하급수적으로 늘어나는 점이다

기절할 거 같은 메모리 사용량

그래서 원인 해결을 위해 메모리 힙을 살펴봤다

가장 많은 순으로 정렬을 해서 트리를 살펴 보았더니 이런 항목이 있더라
Object
▶get contributors
▶get created_at
...
▶set contributors
▶set created_at

이게 뭐지? 싶었는데 곰곰이 생각 해보니 트위터 API에서 오는 기본 json데이터에 있는 변수명들이고
동적 바인딩을 위해 자동으로 모든 변수에 할당 되는 watch였다
트윗 트리 하위 모든 변수에 할당이 되면서 메모리 사용량이 높아지는 문제로 추정이 된다.
추정이 된다면 해결을 해야지!

달새의 store index.js를 살펴보자
깃헙에서 코드보기

AddHome(){
  ....
  var resTweet=TweetDataAgent.CreateResponsiveTweet(tweet);//동적 동작을 해야 하는 변수만 할당 해줍니다.
  state.tweets.mention.splice(index, 0, resTweet);//vuex에 추가 해줍니다.
  TweetDataAgent.CreateNonResponsiveTweet(resTweet, tweet);//동적 동작을 하지 않아도 되는 변수를 등록 해줍니다.
}

트윗 데이터를 만드는 TweetDataAgent.js입니다.
깃헙에서 코드보기

    CreateResponsiveTweet(tweet){//vuex메모리 과부하를 방지하기 위해 동적 반응(watch)해야 할 변수만 할당 후 리턴 합니다.
        var obj=new Object();
        obj.isReaded=tweet.isReaded;
        obj.isDelete=tweet.isDelete;
        obj.isFocus=tweet.isFocus;
        obj.qtTweet=tweet.qtTweet;
        obj.id_str=tweet.id_str;
        obj.id=tweet.id;
        obj.full_text=tweet.full_text;
        obj.isMuted=tweet.isMuted;
        obj.created_at=tweet.created_at;

        return obj;
    },
    CreateNonResponsiveTweet(resTweet, tweet){//resTweet: 동적 변수 할당 된 트윗, tweet: 원본 트윗
        resTweet.orgTweet=tweet.orgTweet;
        resTweet.orgUser=tweet.orgUser;
        if(tweet.retweeted_status!=undefined){
            resTweet.user=tweet.user;
        }
        resTweet.user=tweet.user;
        resTweet.entities=tweet.entities;
        resTweet.id=tweet.id;
        resTweet.id_str=tweet.id_str;
        resTweet.retweeted_status=tweet.retweeted_status;
    },

아래 TweetDataAgent의 코드를 보면 동적 동작을 해야하는 부분의 데이터만 만든 후 리턴을 합니다.
그리고 Vuex Store에 insert를 하게 되죠
그 다음 동적 동작이 필요없는 변수를 할당해줍니다.

해당 기능 적용, 미적용으로 비교를 봅시다

기본적으로 처음 로드 시 메모리 차이입니다. 위의 프로세스가 적용된 아이입니다. 약 40mb가 차이나네요 이건 사실 큰 의미가 없죠

약 2500개의 트윗을 들고있는 메모리의 상태입니다.

이런 방법으로 메모리 과부하를 해결은 했습니다만...
더 좋은 방법이 있는지는 당장으로선 모르겠네요
역시 코딩은 뗌빵 아니겠습니까
메모리도 약 300인데 150정도까지 내려보도록 노오력 해봐야겠어요

달새 vue-electron로 개발하는 과정에서 문제가 생겼다.

기존 버전에서는 이미지를 열었을 때 팝업(새창)의 형태였다

근데 vue-electron환경에서는 이게 안 되더라

이유인 즉슨, vuex의 데이터가 새창과 공유가 되지 않는다.

근데 웃긴 건 dispatch는 먹힌다... 같은 이름은 다 존재하는데 데이터가 안 넘어온다

vue route 등등 별의별 똥꼬쑈를 하다가 우연히 보게 된 게 있다

항상 맨땅에 헤딩하면서 배우는 편이다보니 이거에 대한 존재를 몰랐다

ipcMainipcRenderer

이게 무엇이냐면 electron의 main proccess와 browserwindow와의 통신을 이어주는 모듈이다

앞에서 했던 삽질은 다 필요없고 이젠 기억도 안 나고 중요한 소스코드만 봅시다

모든 소스 코드는 달새 mk-iii(가칭)에서 사용하는 코드를 최대한 그대로 옮겨 적습니다.

 

electron의 main.js파일입니다.

https://github.com/hanalen-/Dalsae/blob/master/MainSrc/src/main/index.js

const {app, BrowserWindow, ipcMain, ipcRenderer} = require('electron');//main.js최상위에 선언 되었을 것입니다. 수많은 main.js중 electron이 들어간 파일을 찾으세요

ipcMain.on('child', (event, tweet, option)=>{//MainWindow에서 이미지를 띄우기 위해 데이터를 main으로 보냈고 main에서 수신하는 부분입니다.
  var win = new BrowserWindow({
    show:false,//show는 false로 해야 데이터를 보낼 수 있습니다. 이유는 바로 아래 주석 참조
  });
  const modalPath = process.env.NODE_ENV === 'development'? 'http://localhost:9080/#/Image' : `file://${__dirname}/index.html#Image`//개발환경일 경우의 route설정과 빌드파일일 경우의 route설정입니다.
  win.loadURL(modalPath);//이걸 안 하면 빈페이지만 뜨게 될 것입니다. route설정도 필수고요 아래에 있습니다.
  win.on('ready-to-show', ()=>{//ready-to-show는 필수입니다. ready-to-show이전에 데이터 전송 시 못받습니다. 그래서 BrowserWindow 생성 시 show: false가 필수입니다.
    win.webContents.send('tweet', tweet, option);//win 객체에 데이터를 보냅니다. 데이터는 tweet, option입니다.
    win.show();//이제 데이터도 보냈겠다 show해줍시다
  })
  }

이미지 창을 띄우기 위한 route설정파일인 index.js입니다. 해당 파일은 라우트 설정 파일에 있습니다.

https://github.com/hanalen-/Dalsae/blob/master/MainSrc/src/renderer/router/index.js

  {
    path: '/Image',
    name:'Image',
    component: require('@/components/Modals/ImagePopup').default,
  },  

아래는 Tweet.vue입니다. 데이터를 보내는 곳입니다.

https://github.com/hanalen-/Dalsae/blob/master/MainSrc/src/renderer/components/Tweet/Tweet.vue

   ImageClick(e){
     var ipcRenderer = require('electron').ipcRenderer;//ipcRenderer를 선언 후 데이터를 보내줍니다.
     ipcRenderer.send('child', this.tweet, this.option);//ipcRenderer.send('이벤트 이름', 데이터1, 데이터2, 데이터3....)
  }

아래는 ImagePopup.vue입니다. 데이터를 받는 곳이죠.

https://github.com/hanalen-/Dalsae/blob/master/MainSrc/src/renderer/components/Modals/ImagePopup.vue

  created: function(){//create에서 해야 정상 동작 했던 거로 기억 합니다.
    var ipcRenderer = require('electron').ipcRenderer;//ipcRenderer를 선언해야 합니다
    ipcRenderer.on('tweet', (event, tweet, uiOption) => {//ipcRenderer.on('이벤트이름', (event, 객체1, 객체2, ...) 첫 파라메터는 무조건 event입니다.
    this.tweet=tweet;//받은 데이터를 지지고 볶고 해줍시다.
    this.uiOption=uiOption;
  });

이렇게 하면 이미지 팝업에 데이터를 보낼 수 있습니다.

쉽게 요약하자면

1. 특정 window에서 ipcRender.send('이벤트', 데이터); 로 mainproccess에 데이터를 보냅니다.

2. mainproccess가 받아서 새 창을 생성 후 데이터를 전송, show:false로 한 후 ready-to-show이벤트로 생성하지 않으면 못 넘깁니다!

3. 새 창에서 ipcRenderer.on('이벤트', 데이터); 로 데이터 수신

짜잔! 이미지 팝업에서 데이터를 받아서 이미지를 표시 할 수 있습니다

실제 소스코드가 적용 된 파일의 링크도 첨부 했습니다.

폴더 경로가 바뀐다던가 하면 링크가 깨졌을 경우 아래의 메인 페이지에서 파일을 찾아가서 보시기 바랍니다.

https://github.com/hanalen-/Dalsae 

 

hanalen-/Dalsae

달새 MK-III vue-electron으로 제작했어요! Contribute to hanalen-/Dalsae development by creating an account on GitHub.

github.com

 

 

+ Recent posts