[2팀 유윤우] Chapter 4-2 코드 관점의 성능 최적화#24
Open
yunwoo-yu wants to merge 20 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
과제 체크포인트
배포 링크
https://yunwoo-yu.github.io/front_6th_chapter4-2/
과제 요구사항
배포 후 url 제출
API 호출 최적화(
Promise.all이해)SearchDialog 불필요한 연산 최적화
SearchDialog 불필요한 리렌더링 최적화
시간표 블록 드래그시 렌더링 최적화
시간표 블록 드롭시 렌더링 최적화
과제 셀프회고
마지막 과제를 진행하며
마지막 과제를 진행하며 "끝이 다가온다"는 아쉬움을 달래고, 끝까지 완주하자는 마음으로 임했습니다.
처음 항해에 합류했을 때 예상했던 것보다 훨씬 높은 난이도에 당황했습니다. BP도 받고 모든 과제를 올패스하며 많은 것을 배우겠다던 당초 목표와 달리, 거의 매일 밤을 새가며 과제 통과에만 매달린 제 모습이 아직도 생생합니다. 🥹
제가 느낀 BP의 핵심 기준은 단순한 코드 품질을 넘어서, 과제의 본질을 얼마나 깊이 이해하고 이를 글로 명확히 표현했는가, 그리고 학습 과정을 PR에서 얼마나 체계적으로 정리했는가에 있다고 생각합니다.
평소 글쓰기에 자신이 없었던 터라, 과제 완료 후 PR을 작성할 때면 이미 체력이 바닥나 있어 겪었던 경험들이 제대로 정리되지 않아 생략하는 경우가 많았습니다. 동료들로부터 "윤우님은 개발 잘 하시는데 아쉽네요"라는 말을 여러 번 들으면서, 항해 10주 동안 가장 성장하지 못한 부분이 바로 이 지점이었다는 아쉬움이 남습니다.
하지만 글을 거의 쓰지 않던 저였음에도 매주 PR 회고와 WIL 작성을 통해 예전보다 훨씬 자연스럽게 글을 쓸 수 있게 되었습니다. 회고의 중요성, 학습 내용을 체계적으로 정리하는 방법, 그리고 읽는 이를 배려하는 글쓰기 등 10주간 배운 소중한 경험들을 바탕으로 앞으로도 꾸준히 글을 써나가며 성장해나갈 계획입니다.
취업 준비 시절, 아낌없이 지식을 나눠주신 분을 만나 "저분처럼 지식을 공유하는 개발자가 되자"고 다짐했었는데, 다행히 팀원분들께 작은 도움이라도 드릴 수 있는 순간들이 있어서 그 다짐을 조금이나마 실천할 수 있었다고 생각합니다. 앞으로는 더 많은 분들께 실질적인 도움을 드릴 수 있도록 더욱 성장하겠습니다.
10주간 정말 많은 것을 배우고 경험할 수 있었습니다. 코치님들께 진심으로 감사드립니다. 😄🙇♂️
기술적 성장
Promise 란
Promise는 JavaScript에서 비동기 연산의 "최종 상태"와 "결과 값"을 표현하는 객체입니다. 가장 중요한 특징은 한 번 정해진 상태(성공 또는 실패)는 절대 변하지 않는다는 점입니다. 이는 Promise가 제공하는 신뢰성의 핵심이기도 합니다.
then, catch, finally
Promise의 힘은 체이닝에서 나옵니다. then, catch, finally 메서드는 각각 새로운 Promise를 반환하기 때문에 연쇄적으로 연결할 수 있습니다.
then(onFulfilled, onRejected)는 성공과 실패 모두를 처리할 수 있지만, 가독성을 위해 보통 성공 케이스만 처리하고 catch를 별도로 사용합니다. catch(onRejected)는 실패 케이스만 분리해서 처리할 때 사용하고, finally(onFinally)는 성공과 실패에 관계없이 정리 작업을 수행할 때 유용합니다.
finally는 결과 값을 변경하지 않습니다! 이는 정리 작업의 본질을 잘 표현한 설계라고 생각합니다.
Promise.resolve와 Promise.reject
Promise.resolve(value)는 값을 Promise로 감싸는 유틸리티입니다. 만약 value가 thenable(then 메서드를 가진) 객체라면 동화(assimilate) 과정을 거쳐 해당 객체의 상태를 따라갑니다. 그렇지 않다면 즉시 성공 상태로 감쌉니다.
Promise.reject(error)는 항상 즉시 실패 상태의 Promise를 만듭니다. 이 차이점을 이해하는 것이 중요합니다.
Promise 메서드들
Promise.all
Promise.all은 모든 입력이 성공해야만 성공하는 fail-fast 전략을 취합니다. 모든 Promise가 성공하면 입력 순서 그대로 결과 배열을 반환하고, 하나라도 실패하면 즉시 실패합니다.
과제를 진행하면서 잘못된 부분이 여기 있었습니다. 병렬 실행을 원한다면 "이미 시작된 Promise"들을 배열로 만들어 넘겨야 하는데, 배열 요소 내부에서 await를 사용하면 직렬 실행이 되어버립니다.
Promise.allSettled
Promise.allSettled는 모든 입력이 완료될 때까지 기다린 후, 각 항목의 상태와 값을 모두 돌려줍니다. 일부 실패를 허용하면서 최대한의 결과를 수집하고 싶을 때 매우 유용합니다.
Promise.race와 Promise.any
Promise.race는 가장 먼저 완료된 하나만 반환합니다(성공/실패 구분 없음). 타임아웃 제어나 "최초 응답 우선" 전략에 사용할 수 있습니다.
Promise.any는 첫 번째 "성공"만 반환합니다. 모두 실패하면 AggregateError가 발생하는데, 여러 백업 엔드포인트 중 하나의 성공만 필요할 때 유용합니다.
고려하면 좋을 포인트
실제 프로덕션 환경에서는 네트워크 요청의 취소가 중요합니다. fetch는 AbortController를 통해 취소할 수 있습니다.
클로저를 이용한 캐시처리
Promise.all을 개선하는 방향으로 저는 다양한 곳에서 재사용 가능하도록 createCachedFetcher 유틸 함수를 만들어 진행했습니다!
함수를 반환하는 클로저 형태이며 캐시를 체크하고 있다면 return. 없다면 콜백 함수의 호출 결과를 cache에 넣고 return 합니다
이 부분에서 모든 API 호출 완료가 굉장히 늦은 시간으로 뜨는 이슈가 있었는데 이 이슈는 SearchDialog가 open 상태값이 falsy한 값임에도 먼저 마운트 되어 발생하는 문제였습니다.
해당 이슈는 SearchDialog를 조건부 렌더링 처리하여 searchInfo가 있을때만 마운트 되도록 변경하여 해결했습니다 👍
불필요한 연산 개선
검색 필터링은 “필요할 때, 필요한 만큼만” 수행되어야 합니다. 기존에는 매 타이핑과 페이지 증가마다 모든 강의를 전부 다시 거르고, 각 강의의 스케줄 문자열을 매번 파싱해 불필요한 비용이 컸습니다. 이를 다음과 같이 정리했습니다.
지연 평가(Lazy evaluation)
lazy load와 달리, “데이터 로드”가 아니라 “연산 자체”를 미루는 데 초점을 둡니다. 반대 개념은 엄격한 평가(strict evaluation)로, 호출 즉시 전부 계산합니다. 저는 이번에 지연 평가에 대해 처음 알게 되어 아래와 같이 공부하고, 문제를 해결해봤습니다.
제너레이터로 구현하는 지연 평가
제네레이터란?
function*로 선언하는 “중단 가능한 함수”. 호출 시 즉시 실행되지 않고, 이터레이터를 반환합니다.
이터레이터의 next()가 호출될 때마다 내부 실행이 진행되며, yield에서 값을 내보내고 “일시 정지”합니다. 다음 next()가 오면 정지했던 지점부터 다시 이어서 실행합니다.
JavaScript 제너레이터는 이터러블을 “한 개씩” 흘려보내며 필요할 때만 다음 값을 계산합니다. 이를 이용하면 filter → map → take 같은 파이프라인을 전부 지연된 상태로 구성할 수 있습니다.
이렇게 제너레이터를 이용해 함수를 구성하면
필터/맵이 “당장” 전체를 돌지 않습니다. take(2)가 만족되면 나머지는 아예 계산하지 않습니다.
하지만 과제에서는 totalCount가 필요하기에 전체 데이터를 가져와야해서 제너레이터 함수는 사용하지 않았습니다.
처음 채택한 방법은 es-lodash 의 chain 메서드를 이용해서 지연평가를 적용하려 했습니다. 하지만 production 환경에서 chain 기반 API에 문제가 생겼습니다.
를 발견했고 내용은 아래와 같습니다.
체인 가능한 메서드들이 래퍼에 동적으로 결합되는 구조라, 번들러의 정적 분석과 상충합니다. 그 결과 사용하지 않는 메서드가 번들에 포함되거나, 프로덕션 번들에서 메서드가 잘려 나가 “wrapper.filter is not a function” 같은 런타임 에러가 발생한다는 내용이였습니다.
import _ from 'lodash-es형태로 사용하거나 tree shaking이 되지않도록 제외하는 방법도 있었지만, 거대한 lodash-es 라이브러리를 tree shaking 없이 사용하는건.. 마이너스가 크다고 생각했습니다.조금 더 서칭해봤을 때
chain메서드 없이 해당 코드로 사용할 경우 tree shaking도 가능했습니다만,, 이미 함수를 통해 구현했기에 고려하지 않았습니다.filter된 lecture 리스트, 메모이제이션된 매칭 함수, 필요한 count를 받아 반복문을 통해 필요한 개수만큼 반환해 렌더링을 진행했습니다. 아쉬운 점은 totalCount가 필요해 필터링 된 리스트를 한번 계산하긴 해야한다는 점이 아쉬웠습니다.
왜 Chakra , 가 느릴 수 있는가
준일 코치님이 Q&A 시간에 왜 느린지에 대해 설명해주셨지만 정확히 이해가 가지 않아 한번 코드를 찾아보았습니다. 저는 Tr 컴포넌트를 기준으로 어떻게 구성되어있는지를 한번 파헤쳐보았습니다!
TableRow는 createSlotRecipeContext({ key: "table" })가 만들어준 컨텍스트 유틸을 써서 생성됩니다.
withContext("tr", "row")가 내부적으로 chakra(Component)로 한 번 감싸고(SuperComponent), 다시 한 번 forwardRef한 StyledComponent를 반환합니다. StyledComponent는 매 렌더마다
이 형태만 봐도 왜 느려지는지 알 것 같지만.. 추측을 해보자면 느려지는 이유는
이걸 개선 하려면 바디 영역은 /로, 스타일은 정적 className(사전 CSS)로 처리하고 동적 스타일 최소화하며 가능한 클래스 분기로 처리하고, 스타일 계산은 상위에서 한 번만하는 방법들이 있습니다.
결론은 Tr/Td는 “컨텍스트 기반 스타일 레시피 + 스타일 프롭 파싱 + Emotion 직렬화”를 매 셀마다 수행하는 구조라, 대량 렌더/자주 리렌더 환경에서 네이티브 엘리먼트보다 느려집니다.
리스트 가상화 기법
리스트 가상화(Windowing)는 "사용자가 보고 있는 부분만 실제로 렌더링하자"는 이상적인 기법입니다.
화면 높이가 500px이고 각 항목이 50px라면, 동시에 볼 수 있는 항목은 최대 10개입니다. 그렇다면 1만 개 데이터가 있어도 실제 DOM에는 10~15개 정도만 존재하면 충분합니다. 사용자가 스크롤하면 보이지 않게 된 항목은 DOM에서 제거하고, 새로 보이게 될 항목을 동적으로 생성합니다.
대부분의 이 가상화 기법을 쓸 수 있는 라이브러리들은 아래와 같은 방식으로 설계되었습니다.
원래 성능 이슈가 있던 이 SearchItem을
리스트 가상화는 "보이는 것만 렌더링한다"는 단순한 아이디어에서 출발하지만, 대량 데이터 처리에서 혁신적인 성능 개선을 가져다줍니다. 특히 사용자 경험이 중요한 현대 웹 애플리케이션에서는 필수적인 기술 중 하나라고 생각합니다.
다만 모든 리스트에 가상화를 적용할 필요는 없습니다. 데이터 규모와 사용 패턴을 고려해 적절한 시점에 도입하는 것이 중요합니다. 작은 리스트에서는 오히려 복잡도만 증가시킬 수 있으니까요.
마지막으로 react-virtuoso 라이브러리를 통해 리스트 가상화를 진행(아래 GIF 참고)했지만 프로파일러로 확인 시 과제의 불합 여부 체크가 어려울 것 같아 현재 코드에서는 제거해서 올려두었습니다.🙇♂️
리뷰 받고 싶은 내용
gif를 보시면 현재 리스트 windowing (가상화)를 적용해봤습니다. 과제 제출은 제거한 버전으로 올려놨는데 현재 100개씩 데이터를 가져오는데 모바일 환경에서는 DOM의 갯수가 많아질 수록 영향이 있을 것 같아 한번 적용해보았습니다!
궁금한 점이 현재 상황에서 이 리스트 가상화를 적용해 DOM의 갯수를 줄인것과 리렌더링 시 메모이제이션을 통해 이전 데이터는 리렌더링 하지 않는 구조 중 어떤게 더 성능적으로 이득일까요?
DOM의 갯수는 10개지만 스크롤마다 동적으로 index가 바뀌어야 하는 방식과, DOM의 갯수는 1000개 지만 한번 불러오고 더 이상 영향을 주지 않는 현재 메모이제이션 방식 중 이 리스트의 경우 어떤게 올바른 선택일 지 궁금합니다!