
프로젝트에서 책 넘기는 애니메이션을 구현할 일이 생겨 여러 자료를 찾아봤는데, 생으로 구현하기 생각보다 어려워 보였다. 그러던 도중 react-pageflip 이라는 라이브러리를 발견했다.

무려 마지막 업데이트가 4년 전이다..
원래 오래된 라이브러리는 비교적 사용하지 않는 편이지만, 기능 구현을 위해 어쩔 수 없이 사용하면서 경험한 것들을 정리해보려고 한다.
백엔드로부터 아래와 같은 형태의 동화 데이터를 수신한다고 가정하자.
{
"id": 2,
"title": "The Adventures of Tiny Tim",
"data": [
{
"page": 1,
"content": "In a small, cozy house at the edge of the forest, there lived a tiny, curious mouse named Tim. Tim loved to explore and discover new things.",
"nextPage": [2],
"prevPage": 0
},
{
"page": 2,
"content": "One sunny morning, Tim woke up with a big idea. He wanted to find the biggest cheese in the world! He packed his tiny backpack and set off on his adventure.",
"nextPage": [3],
"prevPage": 1
},
...
{
"page": 7,
"content": "Inside the castle, everything was made of cheese - the floors, the walls, even the furniture! Tim and Ruby were amazed. Luna guided them to the Cheese Room where the biggest cheese was kept.",
"nextPage": [8, 9],
"prevPage": 6
},
{
"page": 8,
"choice": "Explore the Secret Room",
"content": "Tim and Ruby, who had entered the secret room, discovered a hidden door.",
"nextPage": [10, 11],
"prevPage": 7
},
{
"page": 9,
"choice": "Follow Luna to the Cheese Room",
"content": "Luna suggested to Tim and Ruby to take on the challenge of finding the “King’s Cheese” hidden in the castle.",
"nextPage": [12, 13],
"prevPage": 7
},
{
"page": 10,
"choice": "Open the Secret Door",
"content": "When they opened the door, they found a magical cheese garden. Each tree was bearing different kinds of cheese. Tim and Ruby were astonished by what they saw.",
"nextPage": [14],
"prevPage": 8
},
...
]
}
이게 무엇이냐면, 동화책인데 선택지가 존재하는 동화책이다.
nextPage 배열에 index가 두 개인 page들이 있는데, 여기서 사용자에게 선택권을 주고, 선택한 page에 따라 트리 형태로 갈라지면서 여러 엔딩이 나오게 된다.
실제로 프론트엔드에서 이 데이터를 수신할 때는 image 필드와 talkinghead 필드를 추가로 받게 된다.
나는 이 데이터를 활용해 왼쪽 페이지에는 image, 오른쪽 페이지에는 content를 보여주면서, 오른쪽 페이지를 클릭하면 다음 장으로 넘기고, 왼쪽 페이지를 클릭하면 이전 장으로 넘기는 기능을 구현하려고 한다.
라이브러리에서 제공하는 예시 코드는 두 개가 존재한다. 먼저 기본적으로 어떻게 사용하는지는 아래 코드로 감을 잡을 수 있다.
import HTMLFlipBook from 'react-pageflip';
function MyBook(props) {
return (
<HTMLFlipBook width={300} height={500}>
<div className="demoPage">Page 1</div>
<div className="demoPage">Page 2</div>
<div className="demoPage">Page 3</div>
<div className="demoPage">Page 4</div>
</HTMLFlipBook>
);
}
HTMLFlipBook 라는 컴포넌트의 children으로 페이지를 전부 집어넣으면 된다.
하지만, 그렇다고 위 데이터를 map함수로 감싸서 렌더링하면, 한 페이지에 이미지와 동화책 내용이 동시에 담기게 된다.
나는 이걸 원하지 않았고, 왼쪽에는 이미지 오른쪽에는 내용을 배치하고 싶었다.
이제 두 번째 예시 코드를 봐보자.
const Page = React.forwardRef((props, ref) => {
return (
<div className="demoPage" ref={ref}>
/* ref required */
<h1>Page Header</h1>
<p>{props.children}</p>
<p>Page number: {props.number}</p>
</div>
);
});
function MyBook(props) {
return (
<HTMLFlipBook width={300} height={500}>
<Page number="1">Page text</Page>
<Page number="2">Page text</Page>
<Page number="3">Page text</Page>
<Page number="4">Page text</Page>
</HTMLFlipBook>
);
}
흠... 우선 컴포넌트를 HTMLFlipBook 컴포넌트의 자식으로 전달하는 것은 알겠는데, forwardRef로 감싼 컴포넌트를 전달하고 있다?
그리고 Page 컴포넌트의 루트 div에는 ref를 연결하는 것을 볼 수 있다.
사실 forwardRef에 대해서는 잘 모른다.
부모 컴포넌트가 자식 컴포넌트의 DOM에 접근할 수 있도록 참조를 전달하는 함수라는데, 그냥 props로 ref를 넘기는 것과 다른지 잘 모르겠다. 또, 컴포넌트 간에 ref를 전달해서 뭔가를 해본 적이 없는 것 같기도 하다..
때문에 우선은 루트에 ref를 연결한 forwardRef 컴포넌트를 HTMLFlipBook 컴포넌트의 children으로 전달하면 된다고 이해했다.
ref는 아마 HTMLFlipBook이 children들의 DOM을 조작하기 위해 쓰이지 않을까 싶다!
이미지 컴포넌트와 텍스트 컴포넌트가 필요하다. 그리고 forwardRef에서 사용되는 ref의 타입은 LegacyRef<ref 연결할 element>다.
나는 div에 연결할 것이므로 LegacyRef<HTMLDivElement>가 된다.
const Image = React.forwardRef(
(props: { image: string }, ref: LegacyRef<HTMLDivElement>) => {
return (
<div ref={ref}>
<div
className="w-full h-full p-8"
style={{
backgroundImage: "url('/img/paper.jpg')",
backgroundSize: "cover",
}}
>
<img src={props.image} alt="storybook" />
</div>
</div>
);
}
);
const Text = React.forwardRef(
(props: { text: string }, ref: LegacyRef<HTMLDivElement>) => {
return (
<div ref={ref}>
<div
className="w-full h-full"
style={{
backgroundImage: "url('/img/paper.jpg')",
backgroundSize: "cover",
}}
>
<p>{props.text}</p>
</div>
</div>
);
}
);
이 두 컴포넌트는 전혀 복잡하지 않다. 하지만 한 가지 주의점이 있다!
// 최상위 element에 style을 주면, 적용되지 않는다!
const Bad = React.forwardRef(
(props: { text: string }, ref: LegacyRef<HTMLDivElement>) => {
return (
<div
ref={ref}
className="w-full h-full"
style={{
backgroundImage: "url('/img/paper.jpg')",
backgroundSize: "cover",
}}
>
<p>{props.text}</p>
</div>
);
}
);
바로 위 Bad 컴포넌트는 스타일이 적용되지 않는다... (이유는 잘 모름)
스타일이 적용되지 않아서 한참동안 이것 저것 시도해보다가 p태그에 스타일을 줘봤는데, p태그에는 스타일이 적용되는 것을 보고 실마리를 얻었다.
스타일이 적용되게 하기 위해서는
최상위 element에ref를 연결하고,
그 자식에게 스타일을 줘야 한다.
즉시 실행 함수를 실제로 써볼 일이 있을 줄은 상상도 하지 못했다.
map 함수를 사용해서는 절대 구현이 불가능했다. 왜냐면 map함수의 콜백 함수는 당연히 1개만 return 할 수 있고, <></>로 묶으면 HTMLFlipBook 컴포넌트가 이상해졌으며, div로 감싸면 이미지와 내용은 한 페이지에 속해버리게 되기 때문이다.

그래서 나는 임시 배열을 두고, for문을 활용해 이미지 컴포넌트와 내용 컴포넌트를 순서대로 push한 뒤에 배열을 return 하도록 구현했다. 일종의 트릭 느낌이다.
임시 배열을 둬야 하므로 JSX 표현식 안에 함수를 작성해야 했고, 정의되자마자 실행하기 위해 즉시 실행 함수로 작성했다.
<HTMLFlipBook
width={400}
height={400}
showCover={false}
startZIndex={30}
>
{(() => {
const pages = [];
for (let i = 0; i < storyBooks.length; i += 1) {
const storyBook = storyBooks[i];
pages.push(<Image key={`${i}-image`} image={storyBook.image} />);
pages.push(<Text key={`${i}-text`} text={storyBook.content} />);
}
return pages;
})()}
</HTMLFlipBook>
즉시 실행 함수는 ( () => {} )() 형태의 구조를 가지는 함수다.
이렇게 하면 실행 결과 0번 이미지, 0번 내용, 1번 이미지, 1번 내용 ... 순으로 HTMLFlipBook 컴포넌트의 자식으로 전달되기 때문에 왼쪽 페이지에는 항상 이미지가 보이고, 오른쪽 페이지에는 항상 내용이 오게 된다.
또, showCover props를 false로 변경해야 책 커버 페이지를 고려하지 않기 때문에 펼쳐진 채로 시작할 수 있다.
해당 프로젝트에서는 책 커버를 고려하지 않았기 때문에 해당 props가 존재하는게 매우 소중했다..!
showCover가 true인 경우 1 / 2, 3 / 4, 5 ... 순으로 페이지가 배치되어 꼬이지만,
false인 경우 1, 2 / 3, 4 / 5, 6 / ... 순으로 페이지가 배치된다.
책을 넘기는 기능을 구현하기 전에는, 페이지 정보를 curPage라는 state로 관리하면서 이 curPage를 화면에 렌더링 했다.
그리고 다음 / 이전 버튼을 누르면 curPage를 변경시켰고, 변경될 때마다 curPage.nextPage 배열의 길이가 0인지, 1인지, 2인지에 따라 렌더링 되는 컴포넌트가 달라지고, 로직도 달랐다.
0: 동화책이 끝남 -> endStoryFlag를 true로 변경하고 <EndPopupComponent />를 렌더링. (모달창)
1: 동화책이 진행 중 -> 그대로 둠.
2: 동화책 분기에 도달 -> rightArrowFlag를 true로 변경하고 pageList를 업데이트한 뒤 <SelectPageComponent />를 렌더링.
하지만 위에서 설명한 로직들과 별개로 <BookPageComponent /> 내부만 react-pageflip 라이브러리를 사용하도록 변경했으니, 당연히 위 코드의 부차적인 로직들은 전부 동작하지 않는다.
이를 해결하기 위해서는 HTMLFlipBook 내부에서 현재 몇 페이지가 보여지고 있는지를 활용해서 curPage를 업데이트 해줘야 한다.
다행히 이런 기능을 라이브러리에서 제공하고 있다. HTMLFlipBook 컴포넌트의 onFlip props를 활용하면 된다.
onFlip = (flipEvent) => void 형태의 함수를 만들어서 넘기면, 다음장으로 넘기거나 이전장으로 돌아올 때마다 콜백 함수를 실행시켜 준다.
나는 이를 활용해서 curPage 값을 업데이트하고, prevBtnHandler와 nextBtnHandler 함수의 로직도 그대로 활용하기 위해 아래와 같은 방법을 사용했다.
...
function BookPageComponent({
curPage,
storyBooks,
onFlip,
}: BookPageProps): JSX.Element {
...
// 아래 함수를 보면 된다.
const onFlipHandler = (flipEvent) => {
console.log("onFlipHandler");
console.log(flipEvent.data);
onFlip(Math.floor(flipEvent.data / 2) + 1);
};
return (
<div className="w-full h-full relative px-14 pt-7 pb-[2rem] flex
flex-col justify-center items-center">
...
<HTMLFlipBook
width={400}
height={400}
showCover={false}
startZIndex={30}
onFlip={onFlipHandler}
>
{(() => {
const pages = [];
for (let i = 0; i < storyBooks.length; i += 1) {
const storyBook = storyBooks[i];
pages.push(<Image key={`${i}-image`} image={storyBook.image} />);
pages.push(<Text key={`${i}-text`} text={storyBook.content} />);
}
return pages;
})()}
</HTMLFlipBook>
</div>
);
}
export default BookPageComponent;
먼저 부모로부터 onFlip 이라는 함수를 props로 전달받는다. 그리고 onFlipHandler 함수에서 flipEvent.data에 접근한다.
flipEvent는 data: number | string와 object: PageFlip를 가지고 있으며, data는 사실상 index에 해당한다고 보면 된다.
하지만 curPage는 1 부터 시작하고, data는 0부터 시작해서 2의 배수로 커지기 때문에 값을 조정하기 위해 Math.floor(flipEvent.data / 2) + 1를 사용했다.
onFlip 함수는 아래와 같이 선언되어 있다.
const onFlip = (index: number) => {
console.log(`onFlipHandler index: ${index}`);
const newCurPage = storyBookData.data.find((page) => page.page === index);
if (newCurPage && newCurPage.page > curPage.page) {
nextBtnHandler(curPage);
} else if (newCurPage && newCurPage.page < curPage.page) {
prevBtnHandler(curPage);
}
};
원래는 이전 / 다음 버튼에 달려있던 핸들러인 nextBtnHandler와 prevBtnHandler에 매개변수만 전달하도록 수정하고, 조건문에 따라 실행되도록 해줬다.
newCurPage는 flipEvent.data 값을 조정해 얻은 index 값을 활용해서 구해줬다.
여기까지 해주면 정상적으로 gif처럼 작동하게 된다.
오랫동안 유지보수되지 않던 라이브러리를 도입해보면서 삽질 과정이 많았지만 해결할 수 있어서 좋았다.
하지만 추가로 해결해야 할 것이 남아있다. (선택지에서 선택을 마치고 다시 책으로 돌아오면 첫 번째 페이지로 돌아가있는 문제를 해결해야 함!)
이것도 해결하고 나면 간단하게 정리해둘 예정이다!