포카리뷰 (Phoca Review) 개요
독서/영화 감상문을 포토카드 형태로 남길 수 있는 웹 어플리케이션.
분류: 토이프로젝트, 개인
기간: 2022.10.20 ~ 2023.02.15
주요 기능: 만든 카드 앨범 형태로 모아보기, 카드 상세보기, 카드 에디터, 이미지 컨버팅
사용 툴: React, Typescript, Emotion, html2canvas(이미지 컨버팅 library) / Express, ajv (JSON validator library)
예상 이용자: 작품 감상문을 간단하게 남기고 싶은 사람 / 포토티켓 처럼 작품의 한 장면을 넣은 카드를 만들고 싶은 사람
link: github
1. 만들게 된 계기
종종 문학작품이나 영화에서 좋아하는 구절을 발췌해 감상문을 쓰는데, 이미지도 많이 첨부하고 길게 작성해야 할 것 같은 부담감을 느껴 점점 안쓰게 된 게 떠올랐다. 그러다가 감상문을 카드 한장에 작성하면 부담감이 줄어들지 않을까? 라는 생각이 들었다. 카드라고 하니까 영화관에서 추억을 간직하기 위해 포토티켓을 뽑던것도 떠올랐다. 그래서 최종적으로 이미지를 첨부할 수 있는 카드 에디터를 만들기로 결정했다.
추가로 다음의 기능도 넣기로 했다.
- 만든 카드 이미지를 다운받을 수 있게 하기. 감상문을 SNS에 공유할 수 있을 것이다.
- 만든 카드를 포토카드 바인더처럼 보여주자. 수집하는 즐거움으로 인해 감상문을 열심히 쓰게 될 것이다.
2. 기획 및 디자인
2-1. 화면 구성 및 디자인
1) 메인 페이지
우리가 앨범을 넘기며 추억을 떠올릴 때에는 사물의 물성도 한 몫 한다고 생각한다. 그래서 카드를 보여줄 때 그냥 스크롤을 내리기보다는 앨범을 넘기며 인터랙션 하는 방식을 채택했다. 앨범 넘기기와 카드 뒤집는 애니메이션도 신경써서 구현하기로 했다.
2) 카드 에디터
카드 에디터는 감상문을 간단하게 작성한다는 목적에 방해되지 않게 핵심적인 기능만 집어넣기로 했다. 왼쪽에는 변경사항을 바로 확인할 수 있게 실시간으로 변하는 미리보기를 제공하고, 카드 하단의 앞/뒤 라벨을 통해 현재 어느 면을 편집하고 있는지 알 수 있도록 기획했다.
그라디언트는 작품의 분위기 별로 사용할 수 있도록 괜찮은 조합을 5개정도 만들고 각각 aurora, mango, greenSea 등 이름을 지어줬다. 이 이름대로 팔레트 파일에 정의할 것이다. 이런 에디터를 만들며 팔레트나 스타일을 한 곳에서 관리하는 디자인 시스템의 중요성이 더 와닿았다.
3) 카드 상세보기 페이지
메인화면에서는 카드 제목만 보이도록 하고, 상세보기 페이지로 이동해야 내용을 볼 수 있다.
삭제, 이미지 다운, 수정 버튼이 있고, 삭제를 누르면 모달창이 나와 정말 삭제할 것인지 묻고 수정 버튼을 누르면 카드 에디터로 이동한다.
2-2. 구현해야 할 사항 정리
여기까지를 바탕으로 뭘 해야할지 정리하면 다음과 같다.
- 카드 정보를 CRUD를 할 수 있는 API 서버를 작성하고, 앨범을 넘기면 일정 페이지마다 새로운 정보를 호출하도록 해야 한다.
- 3D 애니메이션으로 카드 뒤집기, 책장 넘기기를 구현해야 한다.
- 카드 에디터
- 카드에 넣을 글자 수 제한
- 배경을 꾸밀 때 그라디언트, 단색, color picker로 색상 선택, 이미지 첨부 기능을 지원해야 한다. 그리고 이렇게 선택한 옵션을 어떻게 저장할 지와, 다시 읽어들여 화면에 보여줄 때를 고려해 Schema를 설계해야 한다. - 그라디언트, 단색, 글자 색의 이름과 색상 코드를 관리하기 쉽게 palette 파일을 만들어서 관리한다.
- 화면를 캡처하고, 이미지로 변환해 다운하는 기능.
2-3. 기술 선택하기
사용 툴: React, Typescript, Emotion, html2canvas(이미지 컨버팅 library) / Express, ajv (JSON validator library)
이 기술들을 선택한 이유는...
프론트
카드 정보를 일정한 스키마에 따라 저장하거나 읽어야 하는 일이 많으니 값 검증을 위해 Typescript를 선택했다. 그리고 styled component와 똑같은 기능을 가지면서도 본래의 html 태그 이름을 간직할 수 있는 Emotion을 선택했다.
백엔드
로컬에서 돌릴 것이기 때문에, 일반 사용자도 쉽게 백업할 수 있도록 정보는 DB가 아닌 txt 파일에 JSON으로 저장하기로 했다. 그리고 이 프로젝트는 내 프론트 장난감을 만드는게 목적이기 때문에 일단 서버는 최소 필요 사항만 충족시키고 빨리 프론트로 넘어가려고 한 것도 있다.
간단한 API를 만들 때 좋고 multer로 이미지 업로드도 쉽게 해결할 수 있기 때문에 nodejs를 선택하고, mongoDB의 스키마 검증같은 기능이 필요해서 적당한 게 없을까 찾아보다가 ajv라는 라이브러리를 사용하기로 했다.
3. 주요 기능 구현 방법
카드 앨범 구현하기 - 인터랙션 및 데이터 fetch
인터랙션
사실 이 앨범 UI와 인터렉션을 구현해보고 싶은게 이 프로젝트를 진행하는 이유의 60%는 차지해서😂 신경을 많이 썼다. 일단 이런 인터랙션이 가능한지 html, css, vanilla js만으로 돌아가는 프로토타입을 만들고, 컴포넌트 분리를 해 React식으로 이식했다.
책 바깥을 css 속성인 perspective
로 감싸 3D scene 공간을 만들어 주고, 책에는 transform-style: preserve-3d
를 적용하면 내부 요소들이 입체적으로 회전한다.
Cover, Paper 컴포넌트들은 absolute로 겹치게 해서 page 순서대로 z-index를 부여했다. 만약 페이지를 넘기면 - (음수)값을 줘서 맨 위에 있던 게 맨 아래에 차례대로 깔린다. 그리고 transform-origin: left
에 의해 isFlipped가 true가 될 때마다 왼쪽 축을 기준으로 회전한다.
처음에는 isBookOpen 상태 하나만 있으면 boolean으로 Cover가 flip될지 여부와 책을 펼칠 때 책을 중앙으로 위치시키는 것 두 가지를 다 해결할 수 있다고 생각했다. 하지만 책의 상태는 생각보다 여러가지로 나뉜다.
일단 책의 상태는 총 3가지가 있다.
1) 책이 닫혀있는 상태. front Cover, Paper, back Cover 모두 flip되지 않았다.
2) 책이 열려있는 상태. front Cover는 flipped되어 있고 Paper는 flip된게 있을 수도 없을 수도 있다.
3) 책이 (마지막에 도달해) 닫혀있는 상태. front Cover, Paper, back Cover 모두 flip되어 있다.
그러므로 '책이 닫혀있다' 라는게 '커버가 아직 flip되지 않았다'를 보장할 수 없다. 맨 뒤 커버까지 넘겨져서 책 뒷면을 보이게 닫혀있을 수 있기 때문이다. 그래서 isBookOpen과 함께 따로 isCoverFlipped 배열 둬서 front / back Cover를 관리하고, 책을 열면 중앙에 위치시키는 위치시키는 문제도 isBookOpen과 isCoverFlipped[1] (back 커버) 둘 모두를 검사해서 해결했다.
👇중앙 위치시키는 문제 자세한 방법 및 Book 구현 코드
책이 펼쳐지면 translateX(50%)로 몸체의 반쪽만큼 오른쪽으로 책을 밀어 중앙에 위치시킨다. isBookOpen이 false면서 isCoverFlipped[1]이 true면 맨 마지막에 도달해 책이 닫힌 것이므로, 모두 flip되어 맨 왼쪽에 쏠린 책을 가운데로 밀기 위해 다시 50%정도 추가한 translateX(104%)을 부여했다. 104%는 커버의 크기까지 고려한 요소이다.
{/* <!-- Book --> */}
<div css={sceneStyle}>
<div css={bookStyle(isBookOpen, isCoversFlipped[1])}>
<Cover order="first" isFlipped={isCoversFlipped[0]} />
{paperList.map((cards) => {
return (
<Paper
key={cards.paper}
cardList={cards.cardData}
isFlipped={cards.isFlipped}
zIndex={cards.zIndex}
/>
);
})}
<Cover order="last" isFlipped={isCoversFlipped[1]} />
</div>
</div>
const sceneStyle = css`
perspective: 1500px;
`;
const bookStyle = (isBookOpen: boolean, isMoveToCenter: boolean) => css`
position: relative;
width: 366px;
height: 526px;
transform-style: preserve-3d;
${isBookOpen && 'transform: translateX(50%);'}
${isMoveToCenter && 'transform: translateX(104%);'}
`;
데이터 fetch
초기 데이터를 fetch해올 때 useEffect에서 가장 먼저 해야 하는 일은 앨범의 전체 페이지 수를 계산하는 일이다. 예를 들어 어떤 앨범이 총 40페이지 분량인 것을 알아야, 맨 위에 오는 종이에 zIndex: 40을 부여해서 다음 페이지를 fetch해왔을 때에도 종이 순서를 유지할 수 있기 때문이다. 어떻게 하면 전체 데이터를 fetch해서 개수를 세지 않고도 총 데이터 개수를 알 수 있을까 고민하다가, X-Total-Count
헤더에 총 카드 데이터의 개수를 실어서 보내기로 했다. 전체 데이터 개수는 약간 메타데이터같은 성격을 가졌다고 생각해서 body보다는 헤더에 담아 보내야 겠다고 판단했다. 앞에 'X-'를 붙이는 것은 암묵적으로 커스텀 헤더 명명 규칙이라고 한다.
// server의 getAllCards 컨트롤러 중 데이터 전송 부분
const data = {
page: page,
results: cards,
};
res.header('X-Total-Count', totalCardNum).json(data);
좌우 화살표 버튼을 누르면 location이 1씩 증감하고, location이 4의 배수일 때마다 API 요청을 해서 API 1페이지 분량(카드 32개)의 카드 데이터를 받아오는 구조이다. 단 무조건 4의 배수마다 새로운 데이터를 호출하게 되면 이전 페이지로 넘겨서 4의 배수에 위치한 경우에는 곤란해진다. 그래서 예를 들어 받아온 데이터가 8페이지 분량이 있다면 현재 location이 8일 때에만 새 fetch를 허용한다.
이 프로젝트에서 데이터 fetch 및 기타 CRUD 로직은 모두 CardApi 클래스에 정적 메소드로 작성했다. 이렇게 서비스 레이어를 분리하고, 또 리소스별로 작성하니 CardApi.getAllCards(page)처럼 어떤 통신을 하고 싶을 때 그 함수가 어디에 있을지와 이름을 예측할 수 있다는 장점이 있었다.
카드 에디터
카드 뒤집기
카드를 뒤집는 인터랙션도 책장 넘기기와 마찬가지로 perspective
와 transform-style: preserve-3d
를 통해 만들었다.
다음은 뒤집을 수 있는 카드를 생성하는 법이다.
- 가장 바깥쪽 요소로 perspective 속성을 적용한 <div>를 생성한다. 3d scene (3d 시야)를 생성하는 작업이다.
- 그 안에 preserve-3d 속성이 적용된 <div>로 카드 틀을 생성한다.
- 카드 틀 안에는 두 개의 cardFace(카드 면)를 absolute로 겹쳐놓는다.
- 카드 면에는 backface-visibility: hidden를 적용해서 뒤집혀있는 상태에서는 화면에 보이지 않는다. 그래서 맨 처음에는 뒷면만 미리 뒤집어놓는다. 그 다음 회전 버튼을 누를 때는 카드 틀을 통째로 뒤집으면 된다.
카드 옵션 선택 / submit
선택한 커스텀 사항은 각 면의 정보를 담은 state에 다음과 같이 저장된다.
const [cardCustomFront, setCardCustomFront] = useState({
type: 'color',
value: colorPalette.white,
fontColor: 'black',
file: new File([], 'file1'),
});
submit 할 때는 제목/작가/내용, 앞 뒷면 커스텀 사항을 Card 인스턴스로 취합해 전송한다. Card 클래스는 데이터 전송 양식을 통일하기 위해 만든 model 객체이다. 저장된 카드 정보를 읽어들이거나 서버에 전송할 때의 기준점이 되어준다. Card 클래스의 genSubmitData는 서버에 보낼 JSON 형식으로 데이터를 가공하는 역할을 한다. 서버를 만들 때 새로 알게 된 사실인데 formData는 POST method만 사용 가능하고 PUT은 안된다. 찾아보니 http 스펙이 그렇게 설계되어서 그렇다.
// onSubmit 중 일부
const card = new Card({
...cardInfo,
cardContents,
cardCustomFront,
cardCustomBack,
});
const data = card.genSubmitData();
formData.append('data', JSON.stringify(data));
그 외 작은 문제) 컬러 옵션 선택
props로 색상 정보를 담은 배열을 전달하면 map을 통해 컬러칩을 렌더링하는 <CardOptionItem> 컴포넌트를 만들 때 겪은 문제이다. 각 컬러칩은 colorPalette.yellow처럼 값을 받지만 이건 사실상 색상 코드 값(value)이기 때문에 자신의 이름(key)를 모르는 상태이다. 서버에는 yellow, aurora 이렇게 문자열로 저장해야 카드 값을 읽어올 때 colorPalette['yellow']처럼 참조할 수 있다. 그래서 클릭하는 순간 value를 바탕으로 key를 찾기 위해 for in을 써보려고 했는데 복잡해서, 더 간단하게 Object.keys()와 find()로해결했다.
colorName = Object.keys(colorPalette).find(
(key) => colorPalette[key] === filling
);
color picker는 html의 color <input>을 무지개 이미지 아래에 위치시키고, visibility: hidden으로 숨겨서 ref를 통해 열도록 구현했다. display: none으로 해놓고 ref로 열려고도 해봤는데, 그러면 엉뚱한 위치 (모니터 맨 왼쪽 위)에 나타나서 visibility로 숨겨야 한다.
카드 미리보기 영역
- 지정된 제목, 내용, 작가 영역을 넘치도록 글이 있는 경우에는 line-clamp로 줄임표 처리 하고, 영어를 입력할 때 띄어쓰기 없는 긴 단어를 입력할 때 다음 줄로 못넘어가고 넘치는 것을 끊기 위해 word-wrap: break-word을 사용했다. 그리고 내가 입력한 대로 줄바꿈이나 공백문자가 작동하기 위해서는 white-space: pre-line을 적용해줘야 한다.
- line-clamp를 쓸 때 display 속성을 -webkit-box 또는 -webkit-inline-box로, 그리고 -webkit-box-orient: vertical 속성을 설정해야 하고 overflow: hidden까지 써야 텍스트를 감출 수 있다는 것은 처음 알았다.
이 부분을 만들면서 원하는대로 글자가 화면에 보이게 하기 위해 신경써야 하는 텍스트 관련 속성이 많다는 것을 알게 되었다. 특히 디자인적으로 글자의 위치가 강제되는 경우 word-wrap이나 word-break를 잘 이해하는 것의 중요한 것 같다.
카드 상세보기 모달
카드를 클릭하면 상세정보가 화면 위에 모달로 나타난다. 처음에는 React의 Portal 기능으로 구현할까 했는데 이건 /cards
페이지에서 /cards/:id
로 이동한다는 맥락을 가지고 있기 때문에 <Route>에서 /cards 페이지의 하위 페이지로 넣고 Outlet으로 띄우는 방식으로 구현했다.
<Route element={<MainLayout />}>
<Route index element={<Navigate to="/cards" />} />
<Route path="/cards" element={<BookPage />}>
<Route path="/cards/:id" element={<CardDetailPage />} />
</Route>
</Route>
그리고 모달의 바깥쪽을 클릭하면 닫는 동작을 수행하기 위해 onClickElement라는 hook을 만들었다.
보통 하는 것처럼 onClickOutside로 만들었더니 상세페이지 모달 위에 카드 삭제 모달을 이중으로 띄웠을 때, 삭제 모달을 닫으려고 바깥을 클릭하면 그 아래의 모달까지 닫혔기 때문이다. 그래서 정확히 해당 모달이 속한 Dimmed 영역을 클릭할 때 닫히도록 hook을 만들었다.
또 이번 프로젝트를 하면서 처음으로 해 본 것은 정보를 가져오는 hook을 별도의 파일에 분리해 만드는 것이었다. 그동안은 fetch하는 함수를 같은 컴포넌트 안에 작성해서 결과값을 이미 존재하는 state에 저장하는 방식이었다. 이번에는 여러 컴포넌트에서 재사용하기 위해 별도의 파일에 hook을 작성했는데, hook 함수 내부에 만든 state에 불러온 정보를 set한 다음 state를 return하도록 했다. 이렇게 state 자체도 return해서 다른 변수에 저장할 수도 있구나 하고 깨달았다. 생각해보니 useState hook의 반환값이 배열이기 때문에 당연한 거기도 하지만... 별도의 함수에서 return해준다는 발상은 이렇게 실제 프로젝트를 해보지 않으면 못 떠올리는 것 같다.
카드 다운로드
상세 페이지에서 다운로드 버튼을 누르면 앞, 뒷면 카드 이미지 파일이 2장 다운로드 된다.
하지만 html2canvas는 화면 그대로를 캡처해주는 라이브러리가 아니라 refs가 부착된 html 영역을 canvas화 해주는 라이브러리이기 원하는 이미지를 얻기 위해 몇 가지 어려움을 겪었다.
일단 앞면과 뒷면에 ref를 부착하고 각각 캡처해서 canvas를 생성한다. 생성한 canvas를 then()으로 넘겨서 canvas.toDataURL()로 fileURL을 생성한 다음, a 태그를 생성하고 클릭해 자동으로 다운로드 하도록 코드를 짰다.
그런데 다운로드한 카드 이미지를 살펴보니 문제가 발생했다.
예상한 이미지
문제의 이미지
- 색깔이나 그라디언트로 배경을 설정하면 괜찮지만 이미지 첨부로 만든 카드인 경우 하얀 배경만 나온다.
- 뒷면 이미지는 거울에 비춘 것처럼 반대로 뒤집혀 보인다.
- 카드 모서리가 둥글게 나와야 하는데 뒷 배경색이 흰색이라 배경이 흰 색이 아닌 곳에 첨부하면 그 느낌이 안나고 이미지가 각지게 보인다.
MDN과 html2canvas document를 뒤져서 다음과 같이 문제를 해결했다.
💻canvas 이미지 캡처 안되는 문제 - CORS 설정
보통 때 이미지는 따로 설정을 안 해도 교차출처에서 가져오는 것이 허용되지만, canvas에서 교차출처 이미지를 조작하려는 경우 오염될 위험(아마 보안상 위험)때문에 exception이 발생, 이미지 가져오기가 중단된다.
이를 해결하기 위해서 base64로 인코딩한 값을 url로 줘도 되긴 하지만, html2canvas에 내장된 속성을 찾아 해결했다. allowTaint: true
로 CORS 이미지 사용을 허용하고 useCORS
로 canvas를 toDataURL()할 수 있게 허용했다.
💻뒷 배경이 흰색으로 나오는 문제 - 배경색 투명하게 하기
backgroundColor 옵션을 rgba(0,0,0,0)으로 줘서 opacity 0으로 설정하면 투명한 png 파일을 만드는 것처럼 배경이 투명한 카드 이미지를 생성할 수 있다. 까만 배경의 사이트에 업로드해봐도 모서리가 둥글게 잘 보인다.
💻뒷면이 뒤집혀보이는 문제 - canvas 좌우반전으로 다시 그리기
원인은 카드 뒷면을 180도 뒤집어놓고 backface-visibility로 보이지 않게 하는 방식으로 구현했다는 것에 있었다. 그래서 카드 앞면만 보인채로 캡처하려면 뒷면이 뒤집힌 채로 나오고, 그렇다고 이미지 다운할 때 앞면 캡처 후 카드를 휙 뒤집어서 뒷면 캡처한 다음 다시 앞면으로 돌려놓는 모습을 보여주는 것도 우습다.
그래서 일단은 뒷면을 캡처할 때는 then으로 canvas를 넘겨서, canvas를 뒤집어서 다시 그리는 작업을 하기로 했다.
이 부분이 제일 어려웠다.
주의할 점은 앞에서 캡처한 canvas의 url을 new Image()에 설정한 다음, img.onload()로 로드를 기다린 다음에 작업을 해야 한다는 것이다. new Image로 생성한 이미지 객체도 비동기적으로 load되기 때문이다.
이미지가 load되었다면 display: none으로 보이지 않는 canvas 공간을 마련한 다음, getContext('2d')로 가져온 context를 이용해 canvas에 그림을 그린다. ctx.scale(-1, 1)를 하면 크기는 그대로이면서 y축에 대하여 좌우대칭인 값을 얻을 수 있다.
그런데 drawImage를 하려면 보통은 draw를 시작하는 곳은 왼쪽부터이지만, 뒤집힌 canvas는 오른쪽부터 그리기 시작한다. 그리고 좌우대칭을 했기 때문에 y축 왼쪽인 음수의 세계에 그려지게 되므로 오른쪽부터 그리기 시작하는 좌우대칭 좌표계의 그림이 우리 모니터 화면에 나타나게 하려면 drawImage의 x좌표가 img.width * -1
여야 한다. 내가 이해한 바를 그림으로 설명하면 다음과 같다.
img.onload = () => {
ctx?.scale(-1, 1);
ctx?.drawImage(img, img.width * -1, 0);
// ...
}
이미지 컨버팅 및 다운로드 로직 (downloadFile 함수는 a 태그 만들고 클릭해서 다운되도록 하는 함수이다.)
const onClickDownload = () => {
if (cardRefs.front.current && cardRefs.back.current) {
const Front = cardRefs.front.current;
const Back = cardRefs.back.current;
/* html2canvas options 설명
- allowTaint : CORS 이미지를 허용할 것인지 true/false
- useCORS : CORS 이미지를 toDataURL() 할 수 있게 허용 true/false
- backgroundColor : 뒷 배경의 색깔 지정. rgba(0,0,0,0)으로 투명하게
*/
html2canvas(Front, {
useCORS: true,
allowTaint: true,
backgroundColor: 'rgba(0,0,0,0)',
}).then((canvas) => {
const img1URL = canvas.toDataURL('image/png');
downloadFile(img1URL, `${id}-${card.cardInfo.title}_front`);
});
html2canvas(Back, {
useCORS: true,
allowTaint: true,
backgroundColor: 'rgba(0,0,0,0)',
}).then((canvas) => {
/*
canvas에는 뒤집어진 뒷면이 캡쳐되어 있는 상태
이 이미지 다시 뒤집어서 그릴 새 캔버스 생성
*/
const flippedCanvas = document.createElement('canvas');
flippedCanvas.width = canvas.width;
flippedCanvas.height = canvas.height;
flippedCanvas.style.display = 'none';
document.body.appendChild(flippedCanvas);
const ctx = flippedCanvas.getContext('2d');
// canvas를 이미지화 해서 캔버스에 좌우반전해서 그림
const img = new Image();
img.src = canvas.toDataURL('image/png');
img.onload = () => {
ctx?.scale(-1, 1);
ctx?.drawImage(img, img.width * -1, 0);
const img2URL = flippedCanvas.toDataURL('image/png');
downloadFile(img2URL, `${id}-${card.cardInfo.title}_back`);
document.body.removeChild(flippedCanvas);
};
});
}
};
카드 삭제 확인 모달
<ConfirmModal
modalDimmedRef={modalDimmedRef}
isModalShown={isModalShown}
alertText="정말로 카드를 삭제하시겠습니까?"
cancleText="취소"
okText="삭제"
cancleHandler={() => setIsModalShown(false)}
okHandler={() => {
CardApi.deleteCard(id);
onDeleteCardHandler(id);
navigate('/cards');
}}
/>
모달로 띄운 카드 상세보기 화면 위에 모달창을 이중으로 또 띄워야 해서 상세페이지 내부에서 띄우도록 했는데 이거야말로 Portal로 만들어야 했던 것일지도 모른다... 이 프로젝트에서는 지금까지 이런 모달창을 필요로 하는 곳이 삭제 확인창밖에 없어서 이렇게 만들었지만, 아직 '찾는 데이터가 없는 경우' 등의 에러 메시지 띄우기를 구현하지 못했으므로 그럴 때를 위해 전체에서 띄우는 것으로 확장해봐야 겠다.
에디터 뒤로가기 방지 hook 직접 만들기
react-router-dom v5까지는 history.listen()을 이용해 뒤로가기 이벤트를 감지해서 block할 수 있었는데, v6로 업데이트를 하면서 history 객체가 없어졌기 때문에 직접 구현해봤다.
useBlock이라는 hook으로 만들어서 뒤로가기 방지를 원하는 페이지에서 useBlock(message)로 호출하면 되는 시스템이다.
let goBackMessage = '뒤로가기를 하면 변경사항이 저장되지 않고 사라지는데 괜찮으시겠어요?';
useBlock(goBackMessage);
에디터 화면에서 변경사항을 지킨다는 목적을 보면 뒤로가기를 방지할 뿐만 아니라 새로고침, 창 닫기도 방지해야 한다. 구현한 방법은 다음과 같다.
- 뒤로가기 방지 - useBlock이 실행되면 useEffect()의 맨 처음에 빈 히스토리를 스택에 집어넣는다. 뒤로가기를 눌러 popstate 이벤트가 발생하면 새 history 스택을 집어넣은 다음, confirm 창을 띄워 뒤로 갈지 여부를 물어보고 yes면 뒤로 보내준다. 뒤로가기를 누를 때마다 소모된 빈 history 객체를 다시 채워놓은 덕분에 다음에 또 뒤로가기를 눌렀을 때 빈 history 정보가 pop되어서 바로 뒤로가기가 되지 않는다. React에서 이벤트리스너를 직접 다룰 때에는 cleanup 하는 것도 잊지 말아야 한다.
- 창닫기, 새로고침 방지 - beforeunload 이벤트는 해당 창이 unload되려고 할 때 발생한다. 그리고 브라우저에서 confirm 창을 띄워 계속 진행할 건지 이벤트를 취소할 건지 물어본다. 확인 버튼을 누르면 unload 이벤트를 취소 가능하다.
크롬에서는 메시지 변경은 불가하고 브라우저에 내장된 메시지 사용이 강제된다. e.preventDefault()를 호출해야 하며 어차피 e.returnValue는 설정해도 화면에 보여주지는 않지만 값 설정을 꼭 해야 해서 빈 문자열을 줬다.
export const useBlock = (message: string) => {
const navigate = useNavigate();
const preventGoBack = useCallback(() => {
window.history.pushState(null, '', window.location.href);
const result = window.confirm(message);
if (result) {
navigate('/');
}
}, [message, navigate]);
const preventClose = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
useEffect(() => {
window.history.pushState(null, '', window.location.href);
window.addEventListener('popstate', preventGoBack);
window.addEventListener('beforeunload', preventClose);
return () => {
window.removeEventListener('popstate', preventGoBack);
window.removeEventListener('beforeunload', preventClose);
};
}, [preventGoBack]);
};
4. 소감 / 앞으로의 공부
클론코딩이나 기능 연습용으로 작게 만들던 것 말고 본격적인 토이프로젝트는 이게 처음인데, 첫 토이프로젝트 판을 너무 크게 벌렸나 싶어 힘들었던 4개월이었지만 이 프로젝트를 하며 서버, 클라이언트 온갖 문제를 맞닥뜨리고 해결하다보니 전보다는 어떤 기능을 구현하는데 드는 시간이 점점 짧아지는 것을 느꼈다.
*앞으로 할 공부*
server API는 간단하게 에러처리를 해주었고 세부적인 사각지대는 메꾸지 못한 상태이다. 클라이언트도 데이터 통신과 관련된 에러 처리를 해야 한다.
- server, client 에러처리 해주기. 더 나아가서 에러 스크립트를 따로 관리할 수 있도록 해보자.
- Typescript interface 중 카드와 관련된 interface가 중복으로 선언되었는데, 조금 더 공부해서 논리 결합 같은 방식을 통해 타입의 중복 선언을 제거하고 싶다.
- 카드가 너무 많이 생성되면 앨범을 넘기기 버거워진다.
- 한 앨범당 생성할 수 있는 카드의 수를 제한하고, 음악 재생목록처럼 새로운 앨범을 생성해서 관리하는 기능을 추가하고 싶다.
- 앨범 넘기는 애니메이션 on/off 기능 만들기 - 로컬용으로 배포 말고도 여러 사용자가 인터넷에 접속해 이용할 수 있도록 로그인/DB를 붙여 배포하는 것도 괜찮은 것 같다. 또 모바일로 접속하려면 현재는 absolute로 구현해야 하는 앨범의 특성상 반응형 디자인을 적용하기가 조금 어려운데 새로운 디자인이나 방법을 강구해야겠다. 먼저 에디터만이라도 디자인을 더 해서 반응형으로 개선해보도록 하자.
- Test 코드 작성하기. 테스트 코드 작성하는 방법은 알지만 mock 데이터로 테스트하는 법은 아직 모른다. 공부해서 서버에 적용해보자. React에서 적용할 수 있는 방법도 공부해보자.
'프로젝트 회고' 카테고리의 다른 글
[Project/React] 다크모드 구현으로 Context API 학습하기 (1) | 2023.04.17 |
---|---|
[Project] React Login 프로젝트로부터 배운 점 회고 (0) | 2022.09.07 |
[Project]Vanilla JS - Todo 리스트 만들기 (0) | 2022.04.25 |