리렌더링 하나가 UX를 망친다 : 장바구니 성능 개선기
최근 회사에서 진행한 앱 리뉴얼 프로젝트에서 고민했던 부분에 대해 남기는 글.
장바구니, 상품 구조에서 우려했던 부분
우선 이전 버전에서 발생했던 이슈 중 하나가 스크롤을 내리다 상품 수량을 변경하면 스크롤이 위로 올라가며 스크롤이 초기화되는 이슈가 발생했었다. (전체 리스트가 리렌더링 → FlatList의 key가 초기화되거나 레이아웃이 재계산 → 스크롤 위치 리셋)
하위 컴포넌트가 자체 state로 수량을 관리하다 보니, 부모 컴포넌트가 리렌더링될 때 자식 상태가 초기화되는 문제였다. 상태를 상위로 끌어올려(State Lifting) 해결했지만, 이 방식은 단일 상품 변경 시 전체 리스트를 리렌더링하는 새로운 문제를 낳았음.
관심사의 분리 (separation of concerns)
그래서 아키텍처를 설계할 때 크게 3가지 레이어로 나누어 역할을 분리했다.
┌─────────────────────────────────────────┐
│ Screen (category.tsx, product-list.tsx)│
│ - 라우팅 파라미터 받기
│ - 훅 호출 및 연결
└───────────────┬─────────────────────────┘
│
↓
┌─────────────────────────────────────────┐
│ Logic Hook (useProductListLogic) │
│ - 카테고리/필터/정렬 상태 관리
│ - 스크롤 애니메이션 로직
│ - UI 상태 조율
└───────────────┬─────────────────────────┘
│
↓
┌─────────────────────────────────────────┐
│ Action Hook (useProductActions) │
│ - 찜하기 토글
│ - 장바구니 추가
│ - 비즈니스 로직 처리
└───────────────┬─────────────────────────┘
│
↓
┌─────────────────────────────────────────┐
│ Layout Component (ProductListLayout) │
│ - UI 렌더링만 담당
│ - Props를 받아서 표시
│ - Presentational Component
└─────────────────────────────────────────┘
스크롤 애니메이션 성능을 위해 react-native-reanimated를 사용하여, UI 스레드에서 Worklet이 직접 애니메이션을 계산하도록 처리했다. 이를 통해 무거운 상품 리스트에서도 스크롤 시 60fps의 부드러운 UI를 유지할 수 있었다.
오늘 여기서 중요한 UI로직 레이어를 좀 더 깊게 알아보자.
1. useProductListLogic - UI 로직 레이어
export function useProductListLogic({
initialCategoryId,
initialPartnerIds = [],
autoScrollToCategory = true,
searchText,
}: UseProductListLogicParams) {
const [selectedCategory, setSelectedCategory] = useState<CategoryType>({
id: 'all',
name: '전체',
});
const scrollY = useSharedValue(0);
const filterAnimatedStyle = useAnimatedStyle(() => {
const opacity = interpolate(scrollY.value, [0, 30], [1, 0], Extrapolation.CLAMP);
const height = interpolate(scrollY.value, [0, 60], [110, 0], Extrapolation.CLAMP);
return {
opacity,
height: Math.floor(height),
overflow: 'hidden' as const,
};
});
const handleCategoryPress = useCallback((category: CategoryType) => {
setSelectedCategory(category);
}, []);
return {
selectedCategory,
onCategoryPress: handleCategoryPress,
scrollY,
filterAnimatedStyle,
// ... 기타 상태 및 핸들러
};
}
우선 상품 리스트 UI를 여러 페이지에서 재사용할 수 있도록 useProductListLogic을 나눴다.
이렇게 비즈니스 로직과 분리하면 테스트가 쉽고, 스크롤 애니메이션과 같은 복잡한 UI를 한 곳에서 관리하면 코드 관리에도 용이하단 점이 있기 때문이다.
1. react-native 작동 원리
1-1. 아키텍처

- 기본 구조 (구 아키텍처)
- JS thread에서 UI렌더링에 필요한 데이터를 가공하여 Bridge로 전달
- Bridge에선 JS thread에서 넘어온 데이터를 JSON으로 변환 하며, UI thread 비동기 방식으로 정보를 전달
- UI thread에서는 Bridge에서 넘어온 데이터를 해석 후, native code로 변환하여 필요한 데이터를 가공하고 UI를 업데이트
다만 여기서 비동기방식이고 직렬처리를 하기 때문에 bridge Queue가 바빠지면 프레임드랍이 발생한다. 그래서 나온 것이 JSI(Javascript Interface)

1-2 JSI(Javascript Interface)
JSI란 쉽게말해 JS와 Native(C++)가 직접 소통할 수 있도록 만든 것 데이터를 JSON으로 변환하고 직렬화할 필요 없이 바로 JS가 C++ 객체의 참조를 메모리에 들고 있을 수 있게 된 것.
| Bridge | JSI |
|---|---|
| JS ↔ Native 통신을 JSON 메시지 큐로 처리 | JS ↔ Native를 C++ 인터페이스로 직접 연결 |
| 비동기 + 직렬 메시지 | 동기 호출 가능 + 직접 메모리 접근 |
| 지연/오버헤드 큼 | 초고속, 저지연 |
| 초기 RN 구조 | 최신 RN 구조(Fabric / TurboModules 기반) |
1-3 Turbo Modules
Turbo Modules는 기존 아키텍처의 native modules를 대체하는 새로운 개념으로, 기존 react-native에서는 네이티브 모듈이 앱 초기화 단계에서 한번에 로드되었다.(블루투스,카메라,갤러리 등등.. )
- 문제점
- 사용하지 않는 모듈까지 불러와서 메모리 낭비
- 초기화 시간이 길어져서 앱 스타트 속도 느림
- 네이티브 모듈이 많을 수록 초기화의 비효율성 증가
이를 해결하기 위해 도입된 새로운 아키텍처로 Lazy loading, 모듈 호출 최적화를 통해 성능을 개선했다.
-
Lazy Loading
네이티브 모듈이 실제로 필요한 순간에만 동적으로 로드 -
모듈 호출 최적화
기존의 네이티브 모듈은 bridge를 통해 데이터를 전달했기 때문에 직렬화/역직렬화를 수없이 하게 되어 성능저하가 발생함.이를 개선하여 즉시 호출 가능한 네이티브모듈을 제공.
1-4 Fabric
- 문제점
- 비동기 방식이기 때문에 JS스레드와 Native UI 스레드 타이밍이 어긋나는 현상 발생
- Bridge 성능: 직렬화/역직렬화, 스레드간 전환등이 성능저하 초래
Fabric은 JSI를 활용하여 이런 문제를 해결한다. JS와 native 코드 간의 UI상태를 실시간으로 동기화하고, 레이아웃 계산을 더 효율적으로 처리할 수 있게 만든다.
1-5 렌더링 파이프라인의 진화
Fabric이 도입되면서 기존의 문제를 어떻게 해결했는지 이해하려면, 화면이 그려지는 3단계 파이프라인(Render -> Commit -> Mount) 과 Shadow Tree 개념을 알아야 한다.
-
Render (JS 설계)
JS 코드 (React)가 실행되어 가상 DOM을 생성. -
Commit (C++ 단계) - Shadow Tree와 Yoga
Shadow Tree : RN은 JS 객체들을 네이티브 쪽 표현으로 변환해야 함. 그래서 이 때 각 React Element에 대응하는 Shadow Node가 만들어진다.
Yoga : 그리고 이 때 Yoga 레이아웃 엔진이 Shadow Tree를 순회하며 레이아웃 관련 데이터를 계산.Shadow Node는 네이티브 레벨(C++/ObjC/Java)에 존재하는 객체로, 스타일(props), children, 그리고 레이아웃 관련 데이터(width/height/x/y)를 보관.
-
Mount (네이티브 UI 단계) - 실제 화면 그리기
계산이 완료된 Shadow Tree 정보를 바탕으로 실제 화면(View)에 렌더링.
이 파이프라인 이해를 바탕으로, 기존 Bridge 방식의 배치 처리 한계를 살펴보자.
1-6 Batch update
-
기존 Bridge 방식의 한계
예전에는 JS에서 수량 조절이나 스크롤 변경이 일어나면, 이 작은 명령들을 JSON으로 포장(직렬화)해서 Bridge로 보냈다. 명령이 많아지면 과부하(오버헤드)가 걸리니 이를 모아서(Batch) 보내야 했는데, 비동기 방식이다 보니 JS 스레드와 UI 스레드 타이밍이 어긋나면서 스크롤이 튀거나 애니메이션이 끊기는 문제가 발생했다. -
Fabric 과 JSI로 해결
이제는 JSI 덕분에 JS가 Bridge를 거치지 않고 C++ 영역의 Shadow Tree를 직접 조작한다. 그래서 Layout 계산도 네이티브 단에서 동기적으로 빠르게 실행되며, 훨씬 빠르게 Batch로 처리가 가능할 수 있게 되었다.
JS Thread (React 렌더)
└─> React Element 생성 (JS 객체)
└─> Shadow Node 생성 (네이티브(C++) 쪽 표현)
└─> Yoga가 Shadow Tree에서 레이아웃 계산
└─> 변경사항을 배치(batch)로 묶어 Commit 준비
└─> Commit -> Mount -> UI Thread가 실제 View 업데이트
여기까지가 react-native의 작동 방식이다. 이 개념을 토대로 react-native-reanimated라는 라이브러리를 알아보자.
2. Worklet : JS 스레드로부터의 독립
기존의 react-native 애니메이션은 JS thread가 관리했다.
-
기존 방식 : 스크롤 내릴 때마다 UI thread -> JS thread 요청합니다. 만약 JS thread가 바쁘다면 응답이 늦어지고, 화면이 버벅거리는 프레임드랍이 발생.
-
Worklet 방식(Reanimated 2/3) : JS thread가 미리 애니메이션 로직이 담긴 JS 코드 조각을 (worklet) 을 UI thread에 줍니다. 그럼 애니메이션 계산을 JS thread를 거치지 않고 UI thread에서 단독으로 진행한다.
이렇게 되면 동기적으로 빠르게 애니메이션이 실행됨.
2-1. 그래서 어떻게 쓰는거지?
내가 작성한 코드로 예시를 들자면,
const scrollY = useSharedValue(0);
JS 스레드와 분리된 UI 스레드 전용 값 저장소(shared memory) 이고, 애니메이션은 이 값을 기반으로 움직인다.
- React state → JS 스레드에서만 업데이트 → 렌더 비용 큼 → 매 프레임 업데이트 불가능
- shared value → UI 스레드에서 직접 업데이트 → 렌더 없음 → 60fps 애니메이션 가능
// 이 블록 안의 코드가 바로 'Worklet'으로 동작하여 UI 스레드에서 직접 실행됩니다.
const filterAnimatedStyle = useAnimatedStyle(() => {
// ...계산 로직...
return { opacity, height };
});
여기서 useAnimatedStyle 안에 들어있는 콜백함수가 worklet이다.
const opacity = interpolate(scrollY.value, [0, 30], [1, 0], Extrapolation.CLAMP);
Interpolate는 하나의 값이 변할 때, 그에 맞춰 다른 값을 부드럽게 변환해 주는 함수다.
-
입력값 (scrollY.value): 사용자가 스크롤한 Y축 위치
-
입력 범위 ([0, 30]): 스크롤 위치가 0(맨 위)에서 30만큼 내려갈 때까지를 추적
-
출력 범위 ([1, 0]): 스크롤이 0일 때는 투명도(opacity)가 1(완전 보임)이고, 스크롤이 30일 때는 투명도가 0(완전 투명)이 된다.
-
Extrapolation.CLAMP 는 스크롤이 50,100이 되더라도 투명도는 0으로 유지하라는 장치.
2-2. UI 스레드에서 JS 스레드로 통신하기 (runOnJS)
앞서 Worklet을 통해 애니메이션을 JS thread와 분리하여 UI thread에서 단독으로 실행한다고 이야기 했다.
그럼 애니메이션이 끝난 후 실제 데이터 변경을 실행하는 비즈니스 로직(api 호출, 상태 변경)은 어떻게 실행할까?
UI thread는 애니메이션 그리는 것만 가능할 뿐 네트워크 요청이나 전역상태를 직접 변경할 권한이 없다. 또한 Worklet 내부에서 외부 JS함수를 호출하려 한다면 에러가 발생하거나 stale closure 이 발생할 수 있음.
이때 두 thread 사이 통신을 가능하게 해주는 것이 runOnJS.
- runOnJS의 작동방식
UI thread에서 애니메이션이 마무리 되는 시점에, runOnJS를 사용하여 JS thread에 있는 함수를 호출한다.
// Action Hook (useProductActions.ts) - JS 스레드에서 실행될 함수
const removeProductFromCart = (productId: string) => {
// Zustand 스토어 업데이트 및 삭제 API 호출 로직
// Zustand store를 hook이 아닌 getState()로 직접 호출
useCartStore.getState().removeItem(productId);
};
const onSwipeEnd = () => {
'worklet'; // 이 함수는 UI 스레드에서 실행됨
if (translateX.value < -100) {
// 임계점을 넘으면 스와이프된 것으로 간주
// UI 스레드에서 JS 스레드의 함수를 안전하게 호출
runOnJS(removeProductFromCart)(product.id);
}
};
이렇게 runOnJS를 활용하면 스와이프나 스크롤 같은 무거운 애니메이션은 UI thread에서 부드럽게 처리하고, 애니메이션이 완료 된 후 JS thread로 돌아와 상태 업데이트나 데이터 패칭하는 비즈니스 로직을 안전하게 실행할 수 있었다.
3. 마치며
-
UI와 비즈니스 로직의 특성이 스레드와 렌더링 전략을 결정한다
이번 리뉴얼에서 무겁고 즉각적인 반응이 필요한 스크롤 애니메이션에는 UI 스레드(Worklet)를, 정확한 상태 동기화가 필요한 장바구니 데이터 처리에는 JS 스레드(Zustand + runOnJS)를 적용했다. 똑같은 화면에서 일어나는 동작이라도 그 특성(시각적 피드백 vs 데이터 무결성)에 따라 최적의 처리 방식은 달라진다. 화면의 특성을 먼저 분석하고 스레드를 분리한 덕분에 JS 스레드의 병목은 줄이면서도, 사용자에겐 끊김 없는 60fps의 쇼핑 경험을 제공할 수 있었다.
-
근본적인 성능 개선은 프레임워크의 코어 아키텍처에 대한 이해에서 출발한다
이번 개편에서 얻은 가장 큰 교훈은, 단순히 코드를 줄이거나(Props drilling 등) 라이브러리에 의존하는 것을 넘어, React Native가 화면을 그리는 근본적인 원리(Fabric, JSI, Shadow Tree)를 이해해야 제대로 된 문제 해결이 가능하다는 점이다. 이는 겉핥기식 최적화가 아닌, 프론트엔드 프레임워크의 렌더링 파이프라인에 정렬된 아키텍처(관심사의 분리 3계층)를 설계해야 한다는 엔지니어링의 본질과도 맞닿아 있다고 생각한다.
결국 프론트엔드 환경에서 가장 강력한 성능 최적화 도구는 맹목적인 최신 기술의 도입이 아닌, 렌더링할 화면과 상태 데이터의 본질을 꿰뚫어 보는 관점이었다.
버그와 함께 춤을