
많은 React Native 개발자들이 앱에 달력 기능을 구현하기 위해 react-native-calendars 라이브러리를 사용한다.
해당 라이브러리는 dayComponent 라는 props를 활용해 날짜 부분을 커스터마이징 할 수 있도록 되어있고, 이를 활용해 달력 페이지를 아래와 같이 구현했다.
// src/pages/Calendar/index.tsx
...
function CalendarPage(): JSX.Element {
...
const handleDayPress = (date: DateData) => {
const { year, month, day } = date;
setPressedDate({ year, month, day });
if (bottomSheetRef.current) {
bottomSheetRef.current.expand();
}
};
return (
<S.SafeView>
<CalendarList
...
dayComponent={({ date }) => {
if (!date) return undefined;
return (
<DayComponent
date={date}
calendars={calendarsData}
onPress={() => handleDayPress(date)}
/>
);
}}
/>
<ChatButton />
<DiaryBottomSheet ref={bottomSheetRef} pressedDate={pressedDate} />
</S.SafeView>
);
}
export default CalendarPage;
dayComponent props에 DayComponent를 return하는 화살표 함수를 전달하는 방식으로 구현했다.
DayComponent는 date, calendars, onPress를 props로 받는다. 각 props의 역할은 아래와 같다.
date: 년, 월, 일 값calendars: 현재 년, 월에 존재하는 일기 데이터 배열, date를 사용해 현재 날짜로 필터링을 수행해 사용onPress: 날짜 클릭 시 bottom sheet를 펼치기 위한 함수
하지만 꽤 거슬리는 문제가 발생했다. 캘린더를 스와이프할 때 미리 일기 데이터가 렌더링 되어있도록 구현해 사용자 경험을 향상시키자는 내용을 회의 때 주고받았는데, 스와이프 시 이미지가 깜빡거리는 문제가 발생했다.
당시 expo-image를 사용 중이었는데, 기본 Image 컴포넌트로 교체하니 이미지가 깜빡거리는 문제가 해결되어 expo-image의 버그라고 생각하고 넘어갔었다.
그런데, 이후 이미지 캐싱 기능을 적용하니 기본 Image 컴포넌트에서도 동일하게 깜빡거리는 문제가 발생하게 되었다. expo-image의 버그가 아니었던 것이다.
문제의 원인이 react-native-calendars인지 찾아보던 중, 비슷한 일을 겪고 해결한 사례를 발견했다. Github issue

작성자는 CalendarDayView 컴포넌트를 return하는 useCallback 함수를 dayComponent props에 전달하도록 구현했더니 CalendarList에서 리렌더링이 발생했다고 한다.

그리고 이를 해결하기 위해 dayComponent props에 컴포넌트 자체인 CalendarDayContainer를 전달했다고 한다.
memo를 언급한 것으로 보아 react-native-calendars 내부적으로 dayComponent에 전달된 컴포넌트를 React.memo로 감싸도록 되어있는 것으로 예상했다.
// node_modules/react-native-calendars/src/calendar/day/index.js
const Day = React.memo((props) => {
...
return (
//@ts-expect-error
<Component {...props} accessibilityLabel={getAccessibilityLabel} {...dayComponentProps}>
{formatNumbers(_date?.getDate())}
</Component>);
}, areEqual);
export default Day;
라이브러리 코드를 살펴보면 실제로 Day 컴포넌트를 React.memo로 감싸 메모이제이션하고, areEqual이라는 props 비교 함수를 사용해 리렌더링 여부를 결정하도록 되어있는 것을 확인할 수 있다.
만약
areEqual함수 로직이 궁금하다면, 위 소스코드 경로에서 확인할 수 있다.
다시 내 코드를 살펴보자.
// src/pages/Calendar/index.tsx
<CalendarList
...
dayComponent={({ date }) => {
if (!date) return undefined;
return (
<DayComponent
date={date}
calendars={calendarsData}
onPress={() => handleDayPress(date)}
/>
);
}}
/>
내 코드 역시 DayComponent 컴포넌트를 return하는 화살표 함수를 dayComponent props에 전달하고 있다. 위 issue와 동일한 방식으로 구현한 것이다.
그렇기 때문에 CalendarList가 리렌더링 될 때마다 새로운 함수가 생성되어 dayComponent로 전달되기 때문에 메모이제이션이 작동하지 않아 이미지가 깜빡이는 것이었다.
원인은 찾았지만, 이에 맞게 구현하기 위해서는 DayComponent의 props를 dayComponent 쪽에 맞춰줘야 한다.
date는 이미 dayComponent가 전달하므로 상관없지만, 별도의 props인 calendars와 onPress를 어떻게 처리할지 고민되었다.
먼저 dayComponent로 전달할 컴포넌트가 받을 수 있는 props를 파악해야 했다.
// node_modules/react-native-calendars/src/calendar/day/index.d.ts
export interface DayProps extends BasicDayProps {
/** Provide custom day rendering component */
dayComponent?: React.ComponentType<DayProps & {
date?: DateData;
}>;
}
위 인터페이스를 살펴보면, dayComponent로 전달되는 컴포넌트는 DayProps에 date를 추가한 props를 받을 수 있다는 것을 이해할 수 있다.
그리고 DayProps는 BasicDayProps를 상속하므로 BasicDayProps 역시 살펴봐야 한다.
// node_modules/react-native-calendars/src/calendar/day/basic/index.d.ts
export interface BasicDayProps extends ViewProps {
/** Theme object */
theme?: Theme;
/** The Day's state ('selected' | 'disabled' | 'inactive' | 'today' | '') */
state?: DayState;
/** The marking object */
marking?: MarkingProps;
/** Date marking style ('dot' | 'multi-dot' | 'period' | 'multi-period' | 'custom'). Default = 'dot' */
markingType?: MarkingTypes;
/** onPress callback */
onPress?: (date?: DateData) => void;
/** onLongPress callback */
onLongPress?: (date?: DateData) => void;
/** The date to return from press callbacks */
date?: string;
/** Disable all touch events for disabled days (can be override with disableTouchEvent in markedDates) */
disableAllTouchEventsForDisabledDays?: boolean;
/** Disable all touch events for inactive days (can be override with disableTouchEvent in markedDates) */
disableAllTouchEventsForInactiveDays?: boolean;
/** Accessibility label */
accessibilityLabel?: string;
/** Test ID */
testID?: string;
}
여기서 눈에 띄는 props가 하나 있는데, 바로 onPress다.
BasicDayProps에 onPress가 포함되어 있다는 것은, 커스텀하지 않은 기본 Day 컴포넌트에도 onPress 함수가 전달될 수 있다는 것을 의미한다.
한편, react-native-calendars가 제공하는 캘린더 컴포넌트들에는 onDayPress 라는 props가 존재하며, 여기로 날짜 클릭 시 실행할 콜백 함수를 주입할 수 있다.
이를 살펴보며 "CalendarList의 onDayPress로 전달한 함수가 내부적으로 Day 컴포넌트의 onPress로 전달되는 구조일까?" 라는 의문이 들었다.
// node_modules/react-native-calendars/src/calendar/index.js
...
const Calendar = (props) => {
const { ... onDayPress, ... } = props;
...
const _onDayPress = useCallback((date) => {
if (date)
handleDayInteraction(date, onDayPress);
}, [handleDayInteraction, onDayPress]);
...
const renderDay = (day, id) => {
...
return (
<View>
<Day onPress={_onDayPress}/>
</View>);
};
...
};
...
export default Calendar;
라이브러리 코드를 보면 onDayPress가 _onDayPress를 거쳐 Day 컴포넌트의 onPress로 전달되는 구조임을 확인할 수 있다.
즉, 다시 말해 DayComponent의 onPress에 직접 handleDayPress를 전달하지 않고, CalendarList의 onDayPress에 handleDayPress를 전달해도 내부적으로 DayComponent가 onPress로 handleDayPress를 받을 수 있다는 것이다.
먼저 dayComponent에 컴포넌트 자체를 전달하도록 변경하고, onDayPress에 handleDayPress 함수를 전달하자.
// src/pages/Calendar/index.tsx
...
function CalendarPage(): JSX.Element {
...
const handleDayPress = (date: DateData) => {
const { year, month, day } = date;
setPressedDate({ year, month, day });
if (bottomSheetRef.current) {
bottomSheetRef.current.expand();
}
};
return (
<S.SafeView>
<CalendarList
...
onDayPress={handleDayPress}
dayComponent={DayComponent}
/>
<ChatButton />
<DiaryBottomSheet ref={bottomSheetRef} pressedDate={pressedDate} />
</S.SafeView>
);
}
export default CalendarPage;
하지만 dayComponent에 DayComponent를 직접 전달하면서, 기존 DayComponent가 기대하는 props 타입과 CalendarList가 실제로 전달하는 props 타입이 일치하지 않아 타입 에러가 발생한다.
이를 해결하기 전에 먼저 DayComponent 코드를 살펴보자.
// src/pages/Calendar/DayComponent/index.tsx
...
interface Props {
date: DateData;
calendars?: Day[];
onPress: () => void;
}
function DayComponent({ date, calendars, onPress }: Props) {
const dayDiary = calendars?.find(
(calendar) =>
calendar.year === date.year && calendar.month === date.month && calendar.day === date.day
);
if (!dayDiary || dayDiary.imageS3 === null) {
return (
<S.DayBox disabled={true}>
<S.ImageBox>
<S.DayText>{date.day}</S.DayText>
</S.ImageBox>
</S.DayBox>
);
}
return (
<S.DayBox onPress={onPress}>
<S.ImageBox>
<S.Image src={dayDiary.imageS3} />
</S.ImageBox>
<S.TagBox>
<S.TagText numberOfLines={1} ellipsizeMode="clip">
{dayDiary.hashTag1}
</S.TagText>
</S.TagBox>
<S.TagBox>
<S.TagText numberOfLines={1} ellipsizeMode="clip">
{dayDiary.hashTag2}
</S.TagText>
</S.TagBox>
</S.DayBox>
);
}
export default DayComponent;
DayComponent의 props 타입에서는 date가 DateData지만, CalendarList에서 전달하는 props 타입은 DateData | undefined이므로 일치하지 않는다.
또한 calendars와 같은 추가 props는 CalendarList에서 제공하지 않기 때문에 제거해야 한다.
타입을 일치시키기 위해 Props 타입을 아래와 같이 수정하자.
// src/pages/Calendar/DayComponent/index.tsx
...
interface Props extends Omit<BasicDayProps, "date"> {
date?: DateData;
}
function DayComponent({ date, onPress }: Props) {
...
}
export default DayComponent;
이제 기존 calendars props를 어떻게 DayComponent로 전달할지 고민해야 한다.
calendars props로 전달되던 calendarsData는 Tanstack-query의 useQueries를 활용해 fetch되는 데이터다.
예전에는 calendarsData를 props로 전달했지만, 이제 DayComponent가 CalendarList의 dayComponent로 직접 전달되면서 props로 넘길 수 없게 되었다.
하지만 calendarsData는 Tanstack-query에서 서버 상태로 관리되므로 queryKey를 활용해 DayComponent 내에서 직접 조회할 수 있다.
queryKey로 년, 월 데이터를 사용하고 있었기 때문에 date props로 충분히 해결할 수 있었다.
// src/pages/Calendar/DayComponent/index.tsx
...
interface Props extends Omit<BasicDayProps, "date"> {
date?: DateData;
}
function DayComponent({ date, onPress }: Props) {
// useQuery와 queryKey를 통해 calendarsData에 직접 접근!
const { data } = useQuery<MonthCalendar>({
queryKey: ["calendar", date?.year, date?.month],
});
// early return으로 타입 내로잉
if (!date || !data || !onPress) return null;
const dayDiary = data.result.find(
(calendar) =>
calendar.year === date.year && calendar.month === date.month && calendar.day === date.day
);
if (!dayDiary || dayDiary.imageS3 === null) {
return (
<S.DayBox disabled={true}>
<S.ImageBox>
<S.DayText>{date.day}</S.DayText>
</S.ImageBox>
</S.DayBox>
);
}
return (
<S.DayBox onPress={() => onPress(date)}>
<S.ImageBox>
<S.Image src={dayDiary.imageS3} />
</S.ImageBox>
<S.TagBox>
<S.TagText numberOfLines={1} ellipsizeMode="clip">
{dayDiary.hashTag1}
</S.TagText>
</S.TagBox>
<S.TagBox>
<S.TagText numberOfLines={1} ellipsizeMode="clip">
{dayDiary.hashTag2}
</S.TagText>
</S.TagBox>
</S.DayBox>
);
}
export default DayComponent;
이렇게 하면 DayComponent가 calendarsData를 props로 받지 않아도, useQuery를 활용해 자체적으로 가져와 사용할 수 있게 된다.

그리고 리렌더링 문제도 해결된 것을 확인할 수 있다!
사실 처음에는 이미지 깜빡임 현상의 원인이 expo-image라고 생각했다. 하지만 이미지 캐싱을 구현하다가 우연히 원인을 발견했고, 직접 해결할 수 있어 다행이었다.
또 Github issue를 발견하고 해결하는 과정에서는 구현에 집중하느라 React 리렌더링 원리에 대한 고려는 크게 하지 못했는데, 포스트를 작성하면서 좀 더 깊게 생각해보는 계기가 되었고, React의 메모이제이션과 리렌더링 시 함수를 재생성한다는 내용을 다시 한 번 돌아보게 되었다.
그리고 문제가 발생했을 때 라이브러리 소스 코드 규모가 크지 않다면, 한 번 직접 읽어보는 것만으로도 문제 해결에 큰 도움이 될 수 있다는 것을 깨달았다.
특히 react-native-calendars의 소스 코드는 어쩌다 보니 많이 읽게 되었는데, 이 과정에서 라이브러리 코드에 대한 막연한 부담감이 줄어든 것 같다.
글 읽어주셔서 감사합니다. 혹시 react-native-calendar 라이브러리의 날짜 컴포넌트의 리렌더링 문제를 겪고 계시다면 도움이 되었길 바랍니다.
이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!