신입 개발자가 입사 후 3개월만에 서비스 성능 98% 개선한 이야기

Eric Park | 박준열·2023년 12월 6일
143
post-thumbnail

본론으로 들어가기에 앞서...

요즘같이 취업하기 어려운 상황에서 취업 준비중이신 프론트엔드 개발자분들께 조금이라도 도움이 되기 위해 무료로 코드리뷰를 해드리고자 합니다.

저도 아직 신입이자 초보인 만큼, 이미 취업 하시고 잘하시는 분들보단 현재 취업 준비중이신 프론트엔드 개발자 분들에게 더 많은 도움이 될 수 있을 것 같아 취준생분들의 신청만 받겠습니다!

더 자세한 내용은: https://github.com/Bokdol11859/code-review 에서 확인 가능합니다.


내가 회사에서 속한 팀에서 만드는 서비스인 R.test는 미국 대입 시험인 SAT와 ACT의 모의고사와 연습문제를 제공하는 서비스인데,

최근 이 시험들이 "온라인"화 되었고, 실제 시험 환경과 유사한 경험을 제공하려고 한 우리는 당연히도 컴퓨터로 접속하는 유저의 비율이 압도적으로 높을 것이라고 예상했다.

하지만 예상과는 달리, 모바일로 서비스를 접속하는 유저의 비율(모바일 4 : 1 데스크탑)이 훨씬 더 높았고, 모바일로 문제를 푸는 사람은 없을거라고 생각했던 우리는 모바일에서의 문제풀이 뷰를 구현하지 않았었기에

데스크탑에서 이렇게 생긴 화면을,

모바일에선 이렇게 보여줬었다.

근데 서비스가 성장하면서 인지도가 높아지면 높아질수록, 모바일로 접속하려는 유저들이 눈의 띄게 많아졌고, 더이상 모바일 문제풀이 뷰를 방치해선 안되겠다고 생각했다.

그래서 그때 당시 입사한지 4개월 조금 덜 된 새파란 신입한테 주어진 업무는 바로 문제풀이 뷰를 모바일로 구현하는 것이었는데...


두 스프린트에 걸쳐서 구현한 결과물.
배포한지 10분만에 사용한 첫 모바일 유저의 녹화를 보면서 감동받은건 안비밀


0.1초마다 전체화면을 리렌더링하던 초기 상태

기존 문제풀이뷰를 모바일 반응형으로 구현하면서 맞닥뜨린 가장 큰 문제는

핸드폰이 금방 뜨거워진다는 것이었다.

그동안 컴퓨터로 문제풀이뷰를 개발할 때는, 워낙 요즘 컴퓨터의 성능이 좋다보니 문제를 겪은 적이 없었는데, 모바일 뷰를 구현을 하면서 핸드폰으로 직접 테스트를 하는 경우가 많아지다보니 문제를 발견하게 되었다.

팀원들끼리 우스갯소리로 캐나다에 있는 유저들은 무료 손난로가 생기니까 버그가 아니라 기능이다 라고 할정도로 금방, 그리고 엄청 뜨거워졌었다.

어느정도였냐면, 1분만 틀어놔도 핸드폰 뜨거워서 화면이 자동으로 어두워질 정도였다

처음에 왜일까 추측을 해보았을 때는, 문제풀이 뷰에서 5초에 한번씩 백엔드로 시간 경과 api를 호출하여 시간을 맞추는 로직이 있는데, 그 과정에서 시간 atom의 변경이 발생하고, 그로 인해 5초에 한번씩 전체 화면을 리렌더링하는게 아닌가 했었다.


이런식으로 5초에 한번씩 api 요청을 보낸다

사수분이랑 일단 얘기해본 결과, 유저가 문제를 푸는데에 직접적으로 영향을 주는 것이 아니기 때문에 우선 기능을 구현하자고 결론이 났었다.

하지만 개발을 하는 과정에서 계속 핸드폰을 통해 확인을 해야했는데, 시간이 길어지면 핸드폰이 버티지 못하고 뻗어버리는 증세가 계속 나타났고, 이 상태로는 정상적인 개발이 불가능하겠다 싶어서 나 혼자 몰래 우선순위를 높게 가져가고 있었다.

그러던 와중, 기본적인 기능 구현 (드래그 기능 없는) 이 끝나고, 생각보다 시간이 여유로워 이번 스프린트에 드래그 기능을 구현하려고 마음을 먹고 시작을 했었다.


드래그 기능이란 ?
ezgif-4-e5417d32a7

이렇게 화면의 크기가 제한적인 모바일에서 Passage를 필수적으로 봐야하는 상황들을 위한 기능이다.

React Draggable이라는 라이브러리를 이용했고, 처음부터 어떻게 구현을 하면 될지 얼추 머리속에 구상이 되어 있었기에 금방 구현을 할 수 있었다.

하지만 정말 큰 문제가 하나 있었다.

바로 드래그가 툭툭툭툭 끊긴다는 것이었다.

이렇게

혹시라도 라이브러리를 내가 잘못 사용했나 싶어서 코드를 자세히 살펴본 결과, 사용법에는 문제가 전혀 없는 것 같았고, 이건 절대 못쓰겠다 싶어서 고쳐야겠다고 생각을 했다.

GIF로 변환해서 보니까 덜 심각해보이는데, 실제로는 훨씬 더 심각했다..

그래서 일단 뭐가 어느정도로 문제고 뭐가 원인인지를 파악하기 위해 Performance를 측정해봤다.

그 결과, 정말 충격적이었다.

거의 0.1~0.2초 단위로 리렌더링이 계속해서 발생하고 있었고, 연산을 따라가지 못한다는 의미의 빨간 줄도 계속해서 보이고 있었다.

분명 문제가 맞았고, 무조건 개선을 해야했다.

그래서 코드를 계속 살펴봤다. 5초마다 보내는 요청이 문제인가?

분명 문제가 되는건 맞았다. 그로 인해 리렌더링이 발생하는 것도 있었고, 영향을 주는 것도 분명 있었다.

하지만 사진과 영상에서 보이듯이, 5초마다 나타나는 현상이 아니었다.

거의 0.1초마다 계속 리렌더링이 발생하고 있었다.

그래서 코드를 자세히 살펴본 결과,

React.useEffect(() => {
  if (elapsedTimeState === 'on') {
    let last = Date.now(); //실제로는 백엔드에서 받아온 시간
    const handle = setInterval(() => {
      const now = Date.now();
      dispatchElapsedTimeAction({type: 'tick', diff: now - last});
      last = now;
    }, 100);
    return () => clearInterval(handle);
  }
}, [..., dispatchElapsedTimeAction, elapsedTimeState]);

이런 코드를 발견했다.

100ms의 interval. 분명 연관이 있겠다 싶었다.

코드를 자세히 읽어 이해한 결과, 이 코드는 0.1초마다 시간을 업데이트하는 코드였다.

바로 이부분을 지속적으로 업데이트해주기 위한 코드였고, 왜 1초가 아니라 100ms로 설정했냐고 사수분께 여쭤봤더니, setInterval은 오차가 존재하기에 100ms로 잘게 나누어서 업데이트 하는게 더 오차를 줄이는 방법이라고 말씀하셨다.

그래서 같이 얘기해서 지금 이부분이 성능에 영향을 주는 것 같으니, 일단 오차를 배제하고 1000ms로 해보는건 어떻겠냐고 여쭤봤고, 사수분도 동의를 하셔서 일단 그렇게 고쳐보았다.

100ms를 1000ms로 늘린 결과, 훨씬 더 화면이 부드러워진 것을 확인할 수 있었다.

Performance에서 측정한 결과에서도 이전보다 훨씬 더 나아진 것을 확인할 수 있었다.

연산을 따라가지 못한다는 표시인 빨간색이 이전처럼 0.1초 단위로 발생하는 것이 아닌, 1초에 한번씩 발생하고 있었다.

벌써 1/10 만큼 개선을 한 셈이다.

하지만 여전히 문제가 존재했다.

드래그를 하고 있는 과정에서 1초가 지나면, 잠시 드래그가 툭 끊겼다가 따라오는 현상이 있었는데, 이게 생각보다 거슬렸다.

특히 폰에서 테스트를 했을때는 더욱 심하게 느껴졌고, 여전히 이건 문제가 되겠다 싶었다.

물론 실제로 사용하는 유저한테는 그만큼 거슬리지는 않겠지만, 기왕 최적화 하는거 할 수 있는만큼 최대한 하는게 좋겠다고 생각했다.

그래서 어떻게 하면 더 개선을 할 수 있을까 고민을 했었고, 방법 하나를 떠올리게 되었다.

처음에는 저 useEffect문을 커스텀훅으로 따로 빼버리면 되지 않을까라고 생각을 했었는데, 생각을 해보니 커스텀훅으로 빼도 같은 컴포넌트 레벨에서 useEffect가 돌아가는건 마찬가지이기에 도움이 될 수가 없었다.

커스텀훅으로는 개선을 할 수가 없었다.


Renderless Component / 렌더링을 하지 않는 컴포넌트

그럼 도대체 어떻게 해야지 개선이 가능할까... 고민을 하다가

고안해낸 방법이

const ElapsedTimeUpdater = React.memo(() => {
  
  /*
  * ...
  */
  
  const [elapsedTime, dispatchElapsedTimeAction] = useAtom(elapsedTimeAtom);
  const elapsedTimeState = elapsedTime.state;
  
  /*
  * ...
  */
  
  React.useEffect(() => {
    if (elapsedTimeState === 'on') {
      let last = Date.now(); //실제로는 백엔드에서 받아온 시간
      const handle = setInterval(() => {
        const now = Date.now();
        dispatchElapsedTimeAction({type: 'tick', diff: now - last});
        last = now;
      }, 1000);
      return () => clearInterval(handle);
    }
  }, [..., dispatchElapsedTimeAction, elapsedTimeState]);

  return null;
});

이 useEffect 로직을 아예 따로 컴포넌트로 빼버리는 것이다.

어떤 사람들은 이걸 보고 커스텀훅이랑 다를게 뭐냐고 생각할 수도 있다. 나도 처음엔 헷갈렸었다.

하지만 커스텀훅과 이 방식에는 큰 차이가 있다.

커스텀훅은 특정 로직을 간편하게 재사용하기 위해 따로 분리를 하는 것이다.
그렇기에 커스텀훅을 호출하면 결국 호출한 컴포넌트에서 로직이 돌아가는 것과 똑같았고, 현재 맞닥뜨린 문제를 해결하는 데에는 도움이 되지 않는다.

하지만 이 방식은 필요한 로직을 하위 컴포넌트로 빼버리고, 그 컴포넌트를 상위 컴포넌트에서 사용하는 방식이다.

그럼 useEffect 로직이 하위 컴포넌트에서 실행이 된다는 의미인데, 그말인즉슨, 상위 컴포넌트에는 영향을 주지 않는다는 의미.

아무튼 말보다 코드로 보여주자면, 저렇게 컴포넌트로 따로 빼버리고, 필요한 부분에서 저 컴포넌트를 사용을 해주자.

const SolvingViewContainer = React.memo(() => {

  // ...

  return (
    <>
      // 화면을 그리는 
      // 나머지 컴포넌트들
      <ElapsedTimeUpdater />
    </>
  );
});

이런식으로.

그러면 ElapsedTimeUpdater 내부에서 시간 업데이트 로직에 의해 리렌더링을 지속적으로 하겠지만, ElapsedTimeUpdaterRenderless Component이기 때문에 값싼 리렌더링이 가능하다.

더불어, 상위 컴포넌트에서는 더이상 useEffect가 호출되지 않기 때문에 내부에서 사용하는 나머지 컴포넌트들은 리렌더링되지 않을 것이다.

이렇게 최적화를 했으니 얼마나 큰 개선이 생겼을까를 확인해보자.

일단 시각적으로 훨씬 부드러워졌다는 사실을 확인할 수 있다.

진짜 실제로 보면 훨씬 더 부드러워진게 보이는데, GIF라서 티가 별로 안난다...

그럼 Performance를 측정했을 때도 과연 차이가 있을까?

그렇다. 동일하게 1초마다 업데이트 되는 시간 로직을 갖고 있음에도 불구하고, 기존 1초마다 보였던 빨간 줄이, 이젠 5초마다 보이는 것을 확인할 수 있다.

1/5만큼 추가적으로 개선을 한 셈이다.

근데 여기서 조금 더 나아갈 수 있다. 그게 무엇일까?

처음에 최적화를 하기 위해 떠올렸던 방법이 무엇이었는가?

맞다. 바로 0.1초마다 시간을 업데이트하는 로직을, 1초마다로 변경을 하여 리렌더링을 억지로 덜 하는 방법이었다.

분명 엄청난 개선이었지만, 그에 동반되는 문제가 하나 있었는데, 바로 시간의 오차를 배제한다는 점이다.

하지만 Renderless Component를 이용한 개선을 통해 시간 업데이트 로직이 발생해도 리렌더링을 하지 않아도 괜찮게 되었다.

그말인즉슨, 시간 계산 로직이 1000ms마다 돌던, 100ms마다 돌던, 1ms마다 돌던, 성능에 영향을 전혀 주지 않는다는 의미다.

그렇기 때문에 기존에 1000ms로 늘렸던 시간을 다시 100ms로 줄이자.

React.useEffect(() => {
  if (elapsedTimeState === 'on') {
    let last = Date.now(); //실제로는 백엔드에서 받아온 시간
    const handle = setInterval(() => {
      const now = Date.now();
      dispatchElapsedTimeAction({type: 'tick', diff: now - last});
      last = now;
    }, 100);
    return () => clearInterval(handle);
  }
}, [..., dispatchElapsedTimeAction, elapsedTimeState]);

이렇게 해도 이론상 차이가 전혀 없어야 하는데, 과연 진짜로 차이가 없을까?

역시나 차이가 없다.


결과, 진짜로 98%의 개선인가

기존 0.1초마다 리렌더링을 하던 화면이, 최적화를 통해 5초마다 리렌더링 하도록 개선이 되었다.

제목에 98%의 성능 개선이라고 적어놨는데, 과연 진짜로 98%가 맞을까?

한번의 리렌더링을 하는데에 드는 비용이 100이라고 가정을 하고, 화면을 10초간 켜놓았다고 가정을 하자.

최적화 이전에는 0.1초마다 리렌더링이 됐으니, 10초 동안 화면이 총 100번 리렌더링 됐을 것이다.

최적화 이전에는 10초동안 발생한 비용이 100 * 100 = 10,000 이다.

최적화 이후에는 5초마다 리렌더링이 됐으니, 10초동안 화면이 총 2번 리렌더링 됐을 것이다.

최적화 이후에는 10초동안 발생한 비용이 100 * 2 = 200 이다.

100 - ((200 / 10000) * 100) = 98

실제로 98%의 성능 개선이 이루어졌다.

결론

Renderless Component를 잘 활용하면 최적화에 도움이 될 수 있다.

왜인지는 모르겠지만, 아무리 검색을 해봐도 Renderless Component에 대한 자료가 정말 정말 적다.

특히 한글로 된 자료는 없다고 봐도 무방한데, 이상한 점은, Vue.js 또는 Angular 관련 Renderless Component에 대한 자료는 찾으면 나온다.

왜 리액트에서만 유독 관심을 받지 못하는걸까?
내가 모르는 안좋은 점이 있는건 아닌지, 사실은 antipattern이 아닌지...
자료조차 못찾겠어서 맞는건지 아닌건지 모르겠다.

혹시라도 관련해서 잘 알고 계시는 분이 계신다면 댓글로 알려주시면 감사하겠습니다!

profile
꾸준한 개발자

22개의 댓글

comment-user-thumbnail
2023년 12월 7일

api data 를 useEffect에서 dependency 에서 제거하거나 통제해서 해결할 수 있는 방법은 없었을까여?

3개의 답글
comment-user-thumbnail
2023년 12월 7일

저도 요구사항이나 전체 코드 구현이 어떻게 되어 있는지 잘은 모르지만 결국은 타이머를 구현하기 위해 useEffect랑 setInterval을 사용하신 것 같은데... 만약 타이머만 구현하면 되고 따로 useEffect랑 setInterval에 태울 비즈니스 로직이 없다고 하면... 타이머 라이브러리를 찾아서 써보시는게 어떠실지... 이런거 쓰면 좋습니다... https://ant.design/components/statistic

2개의 답글
comment-user-thumbnail
2023년 12월 7일

잘읽었습니당

1개의 답글
comment-user-thumbnail
2023년 12월 7일

Animated.div를 써야 할 거 같은데, 이걸 이래한가고?

1개의 답글
comment-user-thumbnail
2023년 12월 11일

실제 렌더링이 필요한 부분은 타이머 뿐일텐데
로직 부분만 뷰(타이머) 영역으로 넘기는건 왜 고려가 되지 않았는지도 궁금하네요

1개의 답글
comment-user-thumbnail
2023년 12월 12일

좋은 글 감사합니다.

1개의 답글
comment-user-thumbnail
2023년 12월 12일

저도 바텀시트때문에 고통받았는데, Fetch와 함께 연동해야할 때 참조할 수 있을 것 같습니다. 좋은 글 감사해요

1개의 답글
comment-user-thumbnail
2023년 12월 18일

좋은 글 잘 읽었습니다 감사합니다 ! 배포한지 10분만에 사용한 첫 모바일 유저의 녹화를 보면서 감동받은건 안비밀이라고 하셨는데 사용자들의 앱 구동 화면을 녹화해 저장하는 기능이 구현되어있는건가요? 아니면 그냥 사용자 섭외하셔서 녹화 화면을 받으신건가용?!

1개의 답글
comment-user-thumbnail
2023년 12월 19일

좋은 글 잘읽었습니다. 요약하면 렌더리스 컴포넌트로 로직을 빼고 실제로 1초마다 타이머에 렌더링이 이루어지기 때문에 타이머는 1초마다 리렌더링 되기 때문에 효율이 좋아졌다고 이해할수 있을까요?

답글 달기
comment-user-thumbnail
2023년 12월 20일

저렇게 리액트 렌더링 과정을 볼 수 있는 툴 이름이 뭔지 알려주실 수 있나요?

1개의 답글