이번 글은 미션 step1을 진행하면서 리렌더링과 성능을 어떻게 하면 더 개선할 수 있을까에 대한 글이다.
이번 level 2에서 원정대를 `디버깅을 통한 리렌더링 성능 개선`을 하고 있다. 그래서인지 어떻게 하면 리렌더링을 줄이고 성능을 개선할 수 있을까에 대한 궁금증이 있는 상태이다. 마침 이번 미션에서 react를 이용한 카드 결제 정보 입력 페이지를 만들고 있다. 그래서, step1에서 만든 페이지를 react-developer-tools를 이용하여 현재 입력이 발생하는 동안 리렌더링이 어디서 발생하는지에 대해 알고자 했다.
JuHyeong424/react-payments at step1
GitHub - JuHyeong424/react-payments at step1
우테코 level 2. React 모바일 페이먼츠 애플리케이션. Contribute to JuHyeong424/react-payments development by creating an account on GitHub.
github.com
step1 미션 저장소이다. 해당 미션에서 만든 페이지가 과연 좋은 설계인지 먼저 확인해보고자 한다.

위 그림은 step1에서 만든 카드 정보 입력 페이지이다.
카드 번호, 유효기간, CVC에 대한 입력 form이 주어지고 있다. 카드 번호와 유효기간을 입력할 때마다 CardPreview에 해당 정보가 업데이트되는 방식이다. 각 입력마다 useState를 이용하여 상태로 두었다. useState가 바뀔 때마다 해당 상태를 가지는 컴포넌트가 리렌더링된다는 것은 알고 있었다. 그럼 step1에서 만든 페이지는 input 상태가 바뀔 때마다 리렌더링이 어디서 발생하는지 알아보아야겠다.

결과는 충격적이었다. 카드 번호를 입력하는 동안 모든 컴포넌트가 리렌더링되고 있었다. 현재 카드 번호가 입력되는 동안 리렌더링되어야할 부분은 오직 CardPreview의 카드 번호 출력 컴포넌트와 CardInfo의 카드 번호 입력 input이다. 위 사진을 보고 불필요한 부분에서도 리렌더링이 발생하고 있다는 것을 알게 되었다.
이러한 리렌더링에 대해 자세히 디버깅할 수 있는 탭이 있다고 들었다. React Developer tools의 Profiler를 통해 리렌더링이 어디서 일어나고 있는지, 리렌더링 시간은 어느 정도인지 등을 확인할 수 있다고 한다. 그래서 Profiler를 통해 해당 부분을 검사해보고자 했다.

Profiler 결과는 다음과 같이 나왔다. 해당 사진은 카드 번호 컴포넌트에서 입력을 작성했을 때, 어떻게 렌더링되고 있는지를 나타낸 사진다.
사진에 대한 간단히 설명을 하겠다. 우선 중앙 상단에 5 / 23이 있다. 이것은 React 생명 주기의 렌더링 페이즈 (Render Phase)와 커밋 페이즈 (Commit Phase)에 관련되었다.
- 렌더링 페이즈 (Render Phase): "어떤 컴포넌트들을 다시 호출해서 새로운 가상 DOM을 만들어야 하지?" 하고 계산하는 단계이다.
- 커밋 페이즈 (Commit Phase): 렌더링 페이즈에서 계산된 변경 사항들을 실제 브라우저 DOM에 반영하는 단계이다.
즉, 내가 입력을 누르면서 리액트가 실제 DOM에 변경 사항을 반영(커밋)한 총 횟수가 23번이라는 의미이다. 그리고 지금 보고있는 그림은 5번째 커밋이 발생했을 때이다.
그래프를 보면 회색, 초록색, 노란색 별로 그려져 있다. 회색은 이번 커밋에서 렌더링되지 않은 컴포넌트, 초록색은 렌더링이 되었지만 매우 빠르게 렌더링된 컴포넌트, 노란색은 초록색보다 렌더링이 시간이 오래 걸린 컴포넌트를 의미한다. App.tsx와 그와 관련한 style 컴포넌트는 커밋에서 렌더링되지 않았다. 그 이유는 결제 정보 페이지를 PaymentWidget.tsx에서 만들었기 때문에 해당 widget의 부모인 App.tsx에서는 변경될 사항이 없었다. 하지만, PaymentWidget.tsx에서 모든 결제 정보 입력 상태를 관리하다보니 한 개의 입력이 바뀌어도 모든 컴포넌트가 렌더링되고 있다. 예를 들어, 현재 카드 번호를 입력하고 있다. 그림에서 보면 가장 노란색인 CardInfoInput이 가장 활발하게 렌더링되고 있다. 하지만, 그 옆의 CardInfoInput도 초록색으로 렌더링되고 있다. 이 Input은 각각 유효 기간 입려과 CVC 입력에 해당한다. 즉, 카드 번호를 입력하는데 카드 번호 입력 컴포넌트만 렌더링하면 된다. 하지만, 불필요한 컴포넌트도 렌더링되고 있다.
React 렌더링 성능은 어떻게 측정할까? #1 - DevTools / Performance 편
React 렌더링 성능은 어떻게 측정할까? #1 - DevTools / Performance 편
“렌더링 성능이 좋아졌다”는 말, 어떤 기준으로 판단할 수 있을까요? > >성능 최적화를 했다면, 그 결과를 수치로 알 수 있어야 합니다. > > 이번 글에서는 React DevTools Profiler와 Chrome Performance 탭
velog.io
오른쪽 탭을 보면 해당 커밋동안 렌더링이 28.1ms 동안 발생했고, 해당 렌더링이 CardInfo와 PaymentWidget에 의해 발생하고 있다는 것을 알 수 있다. 부모 컴포넌트의 상태가 변하면서 자식 컴포넌트도 같이 렌더링된다는 것을 알 수 있다.

위 그래프를 dom 구조도로 그려보면 위의 사진과 같이 나온다. CardNumber와 ExpierDate 상태를 CardPreview에 주기 위해 PaymentWidget에서 관리하고 있다. 또한, CardInfo는 모든 Info에 대한 무거운 책임을 가지고 있다.
이를 통해 현재 코드 설계가 잘못 되었다는 것을 알 수 있었다. 실제 코드에서도 CardInfo.tsx에서 입력 관련 로직을 모두 처리하고 있었다. 또한, PaymentWidget에서 입력과 관련된 모든 상태를 가지고 있었다. 각각의 막대한 책임을 나누어줄 필요가 있다고 느꼈다. 해당 부분을 개선해보고자 한다.
우선 이전 코드를 각각 분리하기로 한다. <CardNumber>, <ExpireDate>, <Cvc> 컴포넌트로 분리하여 각 input value가 변경될 때만 해당 input 컴포넌트가 reRendering되게 수정하였다.

해당하는 input 컴포넌트만 리렌더링되게 수정하였다. 이 과정에서 한 가지 고민이 생겼다. 현재 하나의 input value가 변경되어도 4개의 input이 모두 리렌더링 되고 있다. 이를 각각 분리해야 할지 고민했지만, 모든 input을 분리하는 것은 오하려 비효율적이라고 판단했다. 각각의 input은 단독으로 의미를 갖기보다, 4개가 함께 모여 카드 번호라는 하나의 의미를 형성하기 때문이다. 또한, input 정도의 가벼운 컴포넌트는 리렌더링이 발생하더라도 성능에 큰 영향을 주지 않을 것이라고 판단했다. 따라서, 응집성을 고려해봤을 때, 이들을 하나로 묶고 함께 리렌더링되도록 유지하는 것이 더 적절하다고 판단했다.

각 input 컴포넌트를 나누었을 때의 구조도이다. 이제 각 input 컴포넌트 안에 자신의 상태를 가지고 있다. input value를 변경하더라도 각자의 input 컴포넌트만 리렌더링된다. 이제 CardPreview 컴포넌트를 추가해주어야한다. CardPreview 컴포넌트에서 필요한 상태를 CardNumber와 ExpireDate input 상태이다. 그럼 CardPreview와 CardNumber, ExpireDate 컴포넌트를 묶어주는 새로운 컴포넌트가 필요하다고 생각했다. 그 컴포넌트를 PaymentWidget으로 할지, 아니면 PaymentWidget 컴포넌트 아래에 새로운 중간 컴포넌트를 둘지 고민이 생겼다. 우선 PaymentWidget 컴포넌트에 CardNumber와 ExpireDate 상태를 두게 된다면 이 상태값이 변경될 때, 관련 없는 Cvc 컴포넌트도 리렌더링되므로 PaymentWidget에 두는 것이 옳지 않다고 판단했다. 그래서 PaymentWidget 아래에 CardTopSection이라는 새로운 중간 컴포넌트를 두어, 거기서 상태를 관리하도록 했다.

해당 트리 구조가 만들어졌다. 여기서 고민이 발생했다. 현재 페이지의 레이아웃이 CardPreview가 가장 상단에 있고, input 컴포넌트가 그 아래에 배치되어 있는 형태이다. 만약 CardTopSection 안에 3개의 컴포넌트를 두었을 때, 레이아웃을 어떻게 배치하냐에 대한 생각이 들었다. CardPreview, CardNumber, ExpireDate 3개의 컴포넌트를 묶어주면서 PaymentWidget의 레이아웃을 유지하는 방법에는 react의 Protals, React Fragment가 있었다.
Portal에 대해 잠시 공부했다. Portal은 보통 부모 컴포넌트에 overflow: hidden이 걸려있을 때 그것을 뚫고 나와야 하는 모달(Modal), 툴팁(Tooltip), 드롭다운(Dropdown) 등을 렌더링할 때 사용한다. 단순히 형제 요소들의 Flex 레이아웃을 맞추기 위해 Portal을 남용하게 되면 DOM 참조(Ref)를 관리해야 하고 코드를 읽는 다른 동료들이 UI의 흐름을 쫓아가기 매우 힘들어진다고 한다.
[React] React Portal 사용 이유와 방법
[React] React Portal 사용 이유와 방법
React Portal 사용 이유와 방법
velog.io
현재의 상황을 해결하기에 좋은 방법이지만 오버엔지니어링이라는 생각이 들었다. 현재 모달, 툴팁, 드롭다운 등을 렌더링하지 않는 상황에서 Portal을 사용한다면 DOM 관리와 코드 흐름 유지가 힘들 것 같다. 그래서 React Fragment를 이용해서 컴포넌트 별로 묶지만 PaymentWidget의 레이아웃을 받기로 결정했다.
<Fragment> (<>...</>) – React
The library for web and native user interfaces
ko.react.dev
리렌더링되었을 때의 결과는 다음과 같다.

카드 번호를 입력했을 때, 리렌더링 상황은 위와 같이 ExpireDate 컴포넌트도 렌더링된다. 이는 CardTopSection 컴포넌트에 있는 CardNumber와 ExpireDate 상태 중 하나라도 변경된다면 여기에 포함되어 있는 모든 컴포넌트가 리렌더링되기 때문이다. 그럼 CardNumber의 상태가 변경될 때는 CardNumber 컴포넌트만, ExpireDate의 상태가 변경될 때는 ExpireDate 컴포넌트만 리렌더링되도록 만들어야한다. 이를 위해 사용할 수 있는 API는 React.memo이다.
memo – React
The library for web and native user interfaces
ko.react.dev
React.memo는 컴포넌트를 Memoize하여 부모 컴포넌트가 리렌더링 되어도 Props가 변경되지 않았다면 리렌더링되지 않게 한다. 현재 CardNumber 컴포넌트와 ExpireDate 컴포넌트에는 각각 상태 객체와 useState의 상태 변경 함수가 props로 있다. 상태 객체는 값이 변경되어도 동일한 주소에서 값을 변경하며, 상태 변경 함수는 컴포넌트가 렌더링되어도 메모리 주소값이 변하지 않는다. 그래서 React.memo를 이용해 props가 변할 때만 리렌더링되게 하면 좋겠다고 생각했다.
하지만, 공식 문서를 읽어 보는 중에 React.memo가 필요한 곳이 어디인지에 대한 글이 있었다.
memo로 최적화하는 것은 컴포넌트가 정확히 동일한 Props로 자주 리렌더링 되고, 리렌더링 로직이 비용이 많이 드는 경우에만 유용합니다. 컴포넌트가 리렌더링 될 때 인지할 수 있을 만큼의 지연이 없다면 memo가 필요하지 않습니다. memo는 객체 또는 렌더링 중에 정의된 일반 함수처럼 항상 다른 Props가 컴포넌트에 전달되는 경우에 완전히 무용지물입니다.
현재 구조에서는 리렌더링 로직 비용이 많이 들지 않을 뿐더러, 리렌더링을 인지할 만큼의 지연이 거의 없다. 그래서 현재 컴포넌트에 React.memo를 적용해도 될까라는 고민이 생겼다. React.memo를 사용했을 때 좋은 점은 다음과 같다.
- 지금은 아니더라도 나중에 복잡하고 무거운 로직이 추가되었을 때, 불필요한 렌더링이 발생하는 것을 막을 수 있다.
- ExpireDate가 렌더링되는 것을 막으면 ExpireDate 컴포넌트의 모든 자식 컴포넌트의 렌더링을 막을 수 있다. 이로 인해 렌더링 전파를 완전히 막을 수 있다.
- 해당 컴포넌트가 부모가 내려주는 props에만 의존하는 순수한 컴포넌트임을 명확히 선언할 수 있다. 해당 컴포넌트가 언제 변경되는지 확실히 알기 쉬워진다.
- 문서에 있는 내용처럼 memo로 감싼다고 해도 크게 해가 되지 않기 때문에 일부 팀에서는 가능한 많이 사용하기도 한다.
성능 면에서는 크게 필요하지는 않지만 렌더링이 언제 일어나는지 확실히 하고 불필요한 렌더링을 방지하기 위해 React.memo를 사용하기로 했다. CardNumber와 ExpireDate 컴포넌트는 각자에 맞는 props가 변경될 때에만 렌더링되도록 수정하였다. Profiler를 통해 이전과 렌더링 시간이 얼마나 변했는지 알아보기로 했다.


CardNumber의 input 값을 변경할 때, 이전과 달리 CvC 컴포넌트와 ExpireDate 컴포넌트를 리렌더링하지 않는다. 또한, PaymentWidget을 리렌더링하는 대신 CardTopSection 중간 컴포넌트를 리렌더링한다. Cvc 컴포넌트는 다른 컴포넌트를 렌더링하지 않고 오직 자신의 컴포넌트만 리렌더링하고 있다. 렌더링 지속 시간도 이전의 28.1ms보다 현저히 줄어든 6.3ms와 0.9ms가 나왔다. 이전과 어떻게 성능이 개선되었는지 .json 파일로 비교 분석해보았다.
리렌더링 성능 최적화 전 결과 분석
1. 위험 수위에 도달한 렌더링 소요 시간 (Duration)
- 데이터 분석: 키보드를 입력할 때마다 발생하는 각 렌더링(Commit)의 duration은 16ms에서 최대 33.5ms 사이에 위치했다.
- 60fps에서 하나의 화면을 그리는데 16.6ms 이내로 모든 연산을 마쳐야 화면이 부드럽게 동작한다. 현재 소요 시간에서는 타이핑을 빠르게 치면 입력이 지연될 수 있다.
2. 최상단에서 시작되는 무거운 폭포수 (Updaters)
- 데이터 분석: 상태 변화를 일으킨 장소가 PaymentWidget이다. changeDescriptions을 보면 didHooksChange: true로 기록되어 있다.
- 한 글자를 입력할 때마다 가장 최상위 부모인 PaymentWidget 상태가 변하고 있다. 부모가 렌더링되면 그 아래에 있는 모든 자식 컴포넌트까지 렌더링되는 렌더링 Waterfall을 만들고 있다.
3. 불필요한 렌더링 전파 (Cascading Re-renders)
- 데이터 분석: fiberActualDurations와 changeDescriptions 항목을 보면 수십 개의 컴포넌트가 렌더링 사이클에 포함되어 있다. 그중 상당수가 본인의 상태나 중요한 Props가 변하지 않았음에도 단순히 부모가 렌더링되었다는 이유만으로 함께 렌더링되었다.
- 지금은 카드 번호만 입력하고 있는데, 입력과 전혀 상관없는 Cvc 입력창이나 ExpireDate 관련 컴포넌트들까지 전부 다시 계산하느라 브라우저의 CPU 자원을 낭비하고 있다.
성능 최적화 후 결과 분석
1. 상태 업데이트 진원지의 하향 이동 (State Colocation 성공)
- 데이터 분석: 수정 전 데이터에서는 상태 변화의 진원지(Updaters)가 최상위 컴포넌트인 PaymentWidget이었다. 하지만 수정 후, 상태 업데이트가 CardTopSection (또는 그 내부의 컴포넌트) 레벨에서만 발생하고 있다.
- 전체 앱의 레이아웃과 뼈대를 잡고 있는 가장 거대한 부모(PaymentWidget)가 더 이상 상태 변화에 흔들리지 않는다. 중간 관리자인 CardTopSection 선에서 상태 변화를 통제하게 된다. 그 바깥에 있는 Cvc 컴포넌트는 리렌더링 없이 유지할 수 있다.
2. 완벽한 렌더링 차단벽 작동 (Bailout 및 Memoization)
- 데이터 분석: 수정 전에는 수십 개의 Fiber 노드(컴포넌트)들이 렌더링의 늪에 빠져 fiberActualDurations(실제 렌더링 시간)을 소모했다. 반면 수정 후, 사용자가 카드 번호를 입력할 때 CardNumber와 상단의 CardPreview 관련 노드들만 렌더링에 참여하고 있습니다.
- 우리가 React.memo로 씌워둔 ExpireDate 컴포넌트가 리렌더링을 막는 역할을 해냈다는 물리적 증거입니다. 불필요한 자식 트리로의 렌더링 전파(Cascading)가 100% 차단되었다.
3. 압도적인 렌더링 소요 시간(Duration) 다이어트
- 데이터 분석: 불필요한 컴포넌트들이 렌더링 사이클에서 대거 제외되면서 브라우저가 변경 사항을 계산하고 화면을 다시 그리는 데 소요되는 전체 시간(Commit Duration)이 기존 20~30ms 대에서 한 자릿수 밀리초(ms) 단위로 감소했다.
- 브라우저가 화면의 버벅거림(Lag) 없이 부드러운 60fps를 유지하기 위한 마지노선이 16.6ms이다. 수정 전에는 이 한계선을 아슬아슬하게 넘나들었지만 이제는 사용자가 아무리 빠르게 타자를 쳐도 렌더링 연산이 즉각적으로 끝나게 되었다.
| 수정 전 | 수정 후 | |
| 평균 렌더링 소요 시간(Duration) | 24.47ms (총 23번의 커밋) | 7.58ms (총 24번의 커밋) |
이전까지는 렌더링이 어디서에서 발생하는지에 대해 생각하지 않고 공통 컴포넌트를 묶는데에만 급급하여 설계를 이상하게 하고 있었다. 하지만, React Developer Tool을 이용하여 렌더링이 어디서 발생하는지 확인하고 해당 문제를 해결하기까지의 과정을 적다보니 더 좋은 설계 구조에 대해서 생각해 보는 시간이었다. 단순하게 코드를 고치는 것을 넘어서 리액트가 렌더링을 어떻게 처리하는지 이해할 수 있었다. 지금 step2를 한 개도 안해서 이제부터라도 시작하려는데 시작하기 전에 리렌더링에 대해 깊이 생각할 수 있는 시간을 가져서 좋았다. 앞으로 디버깅 도구를 적극 이용해서 근거 있는 코드를 작성해 나가야겠다.
- 추가
현재 Cvc input의 상태는 Cvc 컴포넌트 안에 적었다. CardNumber와 ExpireDate의 상태는 중간 컴포넌트인 CardTopSection에 작성하였다. 여기서 생각하지 못한 상황이 step2에 있었다. 완성 버튼을 누르면 모든 카드 정보가 서버로 전달되는 상황이다. 그러기 위해서는 모든 카드 정보 상태를 PaymentWidget 컴포넌트에 모아서 버튼을 누르면 해당 정보가 나가도록 해야 한다. 그러면 CardNumber, ExpireDate에서 사용한 React.memo를 CvcNumber 컴포넌트에서도 사용하여 해당 props가 바뀔 때만 리렌더링되도록 설정해야한다. 입력 컴포넌트가 늘어날수록 React.memo로 확인한다는 것이 비효율적으로 느껴졌다. 또한, 최상위 컴포넌트에서 하위 컴포넌트로 데이터를 전달하기 위해 props drilling이 더 심해질 것으로 판단했다. 그래서 렌더링을 최적화하기 위해 Context API를 사용해보기로 했다. 단일 Context가 아닌 도메인별(카드 번호, 만료일, CVC 등)로 Context Provider를 분리하여 설계하였다. 특정 입력값이 변경될 때 해당 데이터를 구독(useContext)하는 컴포넌트만 독립적으로 리렌더링되게 하여 성능이 개선되게했다.
createContext – React
The library for web and native user interfaces
ko.react.dev
useContext – React
The library for web and native user interfaces
ko.react.dev

모든 컴포넌트가 props drilling을 거치는 것이 아닌 필요한 컴포넌트만 해당 context를 가지도록 설계하였다. 그럼 profiler로 어떻게 렌더링이 되는지 확인해보자.



이전에는 CardNumber와 Expire Date 입력 시, 해당 컴포넌트의 header도 함께 리렌더링되었다. 하지만, Context API를 통해 필요한 컴포넌트만 렌더링하다보니 입력 컴포넌트와 CardPreview만 리렌더링되었다. Cvc 컴포넌트는 이전과 동일하게 리렌더링되었다. 실제로 렌더링 지속 시간이 줄었는지 확인해보자.



그림을 보면 여러 Provider들이 렌더링 시작지로 되어 있다. 그리고, 각 입력 컴포넌트에 입력을 할 때마다, 업데이트되어야할 컴포넌트만 렌더링되고 있다. 이전 React.memo의 결과와 비교해보자.
| React.memo | Context API | 변화율 | |
| 평균 렌더링 소요 시간 | 7.58ms | 5.77ms | 약 24% 감소 |
| 렌더링된 컴포넌트 수 | 평균 52개 | 평균 43개 | 불필요한 컴포넌트 9 렌더링 제외 |
| 렌더링 진원지(Updaters) | CardTopSection, Cvc | CardNumberProvider | Provider로 이 |
전반적인 렌더링 수치가 좋아졌다. 그럼 왜 Context API가 더 빠르고 효율적이었을까?
1. 중간 부모의 렌더링 생략
- 이전 방식: 카드 번호가 바뀌면 상태를 쥐고 있는 중간 부모(CardTopSection)가 먼저 렌더링되어야 했다. 그 후 자식들에게 변경된 Props를 내려주는 방식이다.
- Context 방식: 상태는 오직 Provider가 가지고 있습니다. 사용자가 카드 번호를 입력하면 레이아웃을 담당하는 중간 부모들은 아예 렌더링 페이즈를 건너뛰고(Bypass), 오직 useContext(CardNumberContext)를 구독하고 있는 진짜 당사자들(CardPreview, CardNumber 입력창)만 콕 집어서 렌더링된다.
2. React.memo 없이도 가능한 렌더링
- 이전에는 ExpireDate가 중간 부모의 렌더링에 휩쓸리는 것을 막기 위해 명시적으로 React.memo를 사용했다.
- 하지만 Context 구조에서는 ExpireDate가 카드 번호 Context를 구독(useContext)하지 않는 이상, 부모 트리에서 상태가 변하더라도 리액트는 Context를 안쓰는 렌더링이 필요하지 않는 컴포넌트가 렌더링하는 것을 막는다. 때문에 렌더링에 참여하는 노드 수가 52개에서 43개로 줄어들었다.
이렇게 Context API까지 사용해보면서 렌더링 성능이 좋아지는 과정을 알아보았다. 컴포넌트를 잘 설계하면 React.memo나 Context API를 사용하지 않아도 좋은 렌더링 성능이 나올 것이라고 생각한다. 계속해서 컴포넌트를 어떻게 설계할 것인가에 대한 생각을 키워야겠다.
여기서 추가적으로 수정하고 싶은 부분이 있다. CardNumber와 ExpireDate를 입력하면 CardPreview 전체가 리렌더링되고 있다. 이것을 해당하는 부분만 업데이트 되도록 수정하고 싶은데 step2 미션을 시작해야해서 당장 할 시간은 없다. step2를 하면서 어떻게 개선하면 좋을지 생각해 보아야겠다.
최종 다이어그램



'우아한테크코스 8기 > level 2' 카테고리의 다른 글
| 원정대 저자 워크숍, step1 미션 시작 - 4/27 ~ 5/1 (1주차) (1) | 2026.05.03 |
|---|