JavaScript 내장 라이브러리로 Intl.DateTimeFormat을 사용해 날짜를 포맷팅 할 수 있다.
별도의 라이브러리 설치를 하지 않아도 되고, import 역시 할 필요가 없기 때문에 최소한 알고는 있어야 한다고 생각해 정리한다.
const date = new Date();
const formattedDate = new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
}).format(date);
return (
<div>
{formattedDate}
</div>
)
date 객체를 만들어두고 Intl.DateTimeFormat을 통해 표현하고자 하는 범위까지 설정한 뒤 처리해주면 된다.
하지만 이 방법은 문제가 하나 있다.
하위 컴포넌트에서 처리하는 경우 상위 컴포넌트에서 날짜 데이터가 내려오기 전이라면 오류가 발생한다.
예를들어 게시글 작성일을 처리한다고 하자.
그럼 상위 컴포넌트에서 useEffect를 통해 게시글 정보 조회 요청을 보내고 일단 렌더링이 수행되게 될 것이다.
그럼 하위 컴포넌트에서는 전달받은 data.createdAt 이런 날짜 데이터를 통해 객체를 만들어야 한다.
new Date(data.createdAt) 이런 식으로 만들게 될텐데 저 값이 존재하지 않기 때문에 오류가 발생하게 된다.
오류가 발생하더라도 렌더링 후 useEffect가 수행되어 재 렌더링 되면서 하위 컴포넌트로 정상적인 데이터를 넘기더라도 재 렌더링은 일어나지 않는다.
그래서 이 방법을 통해 날짜를 포맷팅 하고자 한다면 Date 객체 생성에 있어서 undefined가 들어오지 않고 무조건 존재하는 값이 들어오는 경우에만 사용할 수 있을 것이라고 생각한다.
dayjs
위 방법에서 발생한 문제로 인해 고민하다 결국에는 라이브러리를 사용하기로 했다.
여러 라이브러리가 있었지만 사용하기로 한 라이브러리는 dayjs다.
dayjs는 설치한 뒤에 사용할 수 있다.
npm install dayjs
그리고 index.js 에 기본 설정을 해줘야 한다고 한다.
//index.js
//...
import dayjs from 'dayjs';
import 'dayjs/locale/ko'; // 한국어 가져오기
//윤년을 판단하는 플러그인
import isLeapYear from 'dayjs/plugin/isLeapYear';
//0분전, 1달전, 1년전 이렇게 상대 시간을 표현하는 플러그인
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(isLeapYear, relativeTime); // 플러그인 등록
dayjs.locale('ko'); // 언어 등록
//...
startPage, endPage는 최종적인 페이지에 대한 값이 아닌 현재 출력시킬 범위의 시작과 끝을 의미한다.
그래서 endPage를 계산한 뒤 10개씩 출력할 것이기 때문에 startPage에서 9를 뺀 값을 담도록 했다.
그리고 만약 totalpages 보다 endPage가 큰 경우 존재하지 않는 페이지에 대한 버튼을 출력하지 않도록 하기 위해 endpage 값을 totalPages로 바꿔줬다.
prev는 11페이지에 접근했을 때부터 출력되어야 하고 1 ~ 10 페이지를 보는 동안에는 출력되면 안되기 때문에 startPage가 1보다 큰 경우에만 처리하도록 했으며, next 역시 endPage가 totalPages 보다 작은 경우에만 출력할 수 있도록 처리했다.
연산에 대한 결과를 객체로 담아 반환하도록 했고 호출한 컴포넌트에서는 반환받은 객체를 state에 담아 Page 컴포넌트에 전달한다.
Page 컴포넌트 코드는 여기 작성하지 않았는데 startPage ~ endPage 까지 버튼을 출력하도록 하고 prev, next는 boolean이니 그 값에 따라 버튼을 출력하도록 처리했다.
포스팅 하단에 최종적인 코드를 참고.
페이지네이션 버튼에 대한 클릭 이벤트는 pageNum state를 변경하는 것으로 재 렌더링을 수행하도록 해 처리했다.
그럼 작성해둔 이벤트 핸들러를 통해 버튼의 textContent를 알아내 pageNum state에 set을 수행하기 때문에 다음 데이터를 요청하고 렌더링을 수행해 정상적으로 출력해준다.
하지만 여기서 문제가 발생한다.
저렇게 처리하면 URL은 그대로 유지된다.
localhost:3000/board 라는 주소에서 게시글 리스트가 출력된다고 했을 때, 2번 페이지 버튼을 누르더라도 그대로 /board 를 유지하고 데이터만 변경된 상태로 렌더링 된다는 것이다.
물론 이것 자체가 문제가 되진 않는다. state 변경으로 인해 axios 요청도 제대로 처리하고 있고 리스트 데이터도 정상적으로 변경되기 때문이다.
하지만! 뒤로가기 버튼을 누르게 되면 이전 1번 페이지를 보여주는 것이 아닌 아예 다른 페이지를 보여준다.
예를들어 /image 주소의 이미지 게시판에 있다가 /board 로 넘어왔고 여기서 2페이지 버튼을 눌러 봤다고 가정하자.
그러다 1 페이지가 다시 보고 싶어 1페이지 버튼을 누르면 괜찮지만 뒤로가기 버튼을 누르는 경우 1페이지가 아닌 /image 로 이동하게 된다.
url 변경이 일어나지 않는 시점에서 보자마자 어? 했던 부분이었는데 역시나 의도하는 대로 동작하지 않았다.
방법을 찾아보니 뒤로가기를 감지할 수 있고 이전 데이터를 보여주기 위해 history를 통해 처리하도록 한다거나
슬쩍 본거긴 한데 storage에 저장해뒀다가 가져와서 보여준다거나 하는 방법들이 있었다.
이 문제에 대한 해결은 좀 더 원초적인 방법으로 해결하기로 했다.
url을 통해 페이지 이동을 하도록 하는 방법.
위 코드처럼 처리하는 것은 state를 업데이트해 재 렌더링을 발생시키고 그로 인한 변경을 하고 있지만,
그렇게 처리하지 않고 URL을 통해 페이지 번호를 넘겨 새로 렌더링 하는 방법을 택한 것이다.
일단 위 코드에서는 페이지네이션에 대해서만 처리가 되어 있지만, 검색기능도 있어야 하기 때문에 일단은 URL을 통한 처리로 먼저 해결하기로 했다.
뒤로가기 이슈 해결
페이지네이션 버튼 또는 검색으로 인한 이벤트 발생 시 쿼리 스트링을 통해 요청하고 컴포넌트에서는 그것을 받아 데이터를 요청한 뒤 렌더링 하도록 해야 한다.
그러기 위해 일단 App.js에서 쿼리 스트링을 통한 접근을 처리했다.
function App() {
return (
<BrowserRouter>
<Routes>
// 게시판 리스트.
<Route index element={<BoardPage />} />
//기본 리스트 페이지 이동
<Route path='?pageNum=:pageNum' element={<BoardPage />} />
//리스트 내 검색
<Route
path='?keyword=:keyword&searchType=:searchType'
element={<BoardPage />}
/>
//리스트 내 검색 데이터 페이지 이동
<Route
path='?keyword=:keyword&searchType=:searchType&pageNum=:pageNum'
element={<BoardPage />}
/>
//...
</Routes>
</BrowserRouter>
)
}
이것도 중첩 라우팅으로 해서 중첩해 개선하는 방법도 있었는데 욕심은 하나로 줄이고 싶어서 아직 수정하고 있지 않는 중...
이런식으로 여러개의 쿼리 스트링에 대해 게시글 리스트 페이지로 라우팅을 하도록 처리해뒀다.
서버에 파일 요청을 한뒤 응답으로 받기 위해서는 axios의 responseType이 blob이어야 한다.
res.data인 응답받은 파일을 new File로 파일 객체를 만들어준다.
readAsDataURL로 해당 파일 객체를 읽은 뒤 변환하도록 하고 onload가 발생했을 때 읽어들인 결과를 imgSrc state에 set 하도록 처리한다.
이 두 방법의 차이는 file 객체를 생성하는지 안하는지 정도의 차이다.
사실 둘다 file 객체를 readAsDataURL에 넘겨주는 것이긴 한데, 선택한 파일 미리보기의 경우 파일을 전달받았기 때문에 굳이 file 객체를 새로 만들어줄 필요가 없는것이고, 서버에 요청해 받는 경우는 응답받은 데이터를 파일 객체로 만들어주는 것에서 차이가 발생할 뿐이다.
FileReader를 통해 처리하는 이 방법은 데이터를 base64 형태로 읽는 만큼 src에 적용할때마다 만들어진 객체를 공유하지 않고 새로 문자열을 파싱해 이미지로 만드는 작업을 하기 때문에 속도가 느리다.
또한, 이렇게 처리한 뒤에 개발자 도구에서 src 속성의 값을 보면 어마어마하게 긴 문자열을 볼 수 있다.
createObjectURL로 처리
가장 불편했던 건 너무 긴 문자열이 출력된다는 것이었다.
그래서 다른 방법을 여러 방면으로 찾아봤는데 그 방법이 createObjectURL을 사용하는 방법이었다.
createObjectURL을 사용하면 blob 객체의 url 주소값으로 이미지를 불러올 수 있다.
이렇게 생성된 주소값은 blob 객체를 바라보고 메모리에 올라가있기 때문에 객체를 새로 만들지 않고 바로 가져다 쓰므로 FileReader를 사용할 때 보다 속도도 빠르다.
src에 생성되는 주소는 실제 서버에는 존재하지 않고 해당 브라우저에서만 사용 가능한 URL이다.
이때 유효한 범위는 '해당 문서'다.
새로고침하거나 다른 페이지에서 사용하려고 한다면 제대로 사용할 수 없다.
그리고 주소 역시 blob:http://localhost:3000/~~~~ 형태로 생성되고 간결해진 것을 확인할 수 있다.
3. 미리보기에서 삭제 버튼을 눌렀을 때 해당 미리보기 화면을 제거하면서 파일 리스트에서 해당 파일 역시 삭제해줘야 한다.
4. 글 작성 버튼 클릭 시 formData에 글 제목, 내용, 이미지 파일을 담아 axios로 서버에 요청을 보낸다.
컴포넌트 구조
ImageWritePage
values -> useState로 글제목(title)과 글 내용(content) 값을 관리
files -> useState로 등록할 파일 값 관리
module
1. setZeroToPreviewNo -> 모듈에서 previewNo를 0으로 초기화 2. imageInputChange -> 사용자가 이미지 파일 선택 시 파일 확장자명 체크와 files에 담을 파일 배열 작성 후 반환 3. deleteNewImagePreview -> 사용자가 파일 삭제 클릭시 files에서 해당 파일 삭제 후 배열 반환 4. setFormData -> 등록 버튼 클릭시 title, content, files 데이터를 formData에 담아주는 모듈
childComponent
1. BoardWriteForm -> 제목과 내용에 대한 input 이 담겨있는 컴포넌트 2. Button -> 프로젝트에서 전반적으로 사용되는 버튼 컴포넌트 3. ImageNewPreviewForm -> 사용자가 선택한 이미지 미리보기 출력을 위한 컴포넌트
ImageNewPreviewForm
props
1. handleOnClick -> 삭제 버튼 onClick 핸들링 2. files -> 출력할 이미지 파일
처리 코드
1. 사용자의 이미지 선택시 이벤트 핸들링 2. ImageNewPreviewForm Component(사용자가 선택한 이미지 미리보기 컴포넌트) 3. 사용자의 파일 삭제 요청 이벤트 처리 4. 이미지 게시글 수정에서의 처리 5. 기존 이미지 파일의 삭제 처리 6. 작성 버튼(submit) 클릭시 formData에 데이터 담기 7. axios 설정
사용자의 이미지 선택시 이벤트 핸들링
//add Image
const handleImageInputChange = (e) => {
const inputResultArray = imageInputChange(e, files);
if(inputResultArray !== null)
setFiles(inputResultArray);
}
//modules -> imageInputChange
export const imageInputChange = (e, files) => {
//이미지 확장자명 체크. 코드 생략
//결과 반환 타입 boolean
const validationResult = imageValidation(e);
if(validationResult) {
//사용자가 선택한 파일 리스트
const fileList = e.target.files;
//파일을 담을 배열.
//초기값으로 spread operator를 통해 files 데이터를 담아준다.
let fileArr = [...files];
//fileArr에 사용자가 선택한 file들을 담아준다.
for(let i = 0; i < fileList.length; i++) {
fileArr.push({
fileNo: ++previewNo,
file: fileList[i],
})
}
return fileArr;
}else {
return null;
}
}
파일 업로드 input에서 사용자의 파일 선택으로 인해 onChange 이벤트가 발생하면 모듈화한 imageInputChange를 호출한다.
이때 state인 files를 같이 넘겨 준다.
모듈에서는 가장먼저 파일 확장자명을 체크하고 결과는 boolean 타입으로 반환받는다.
모두 정상이라면 true가 반환될 것이고 false가 반환되는 경우 컴포넌트에서는 files state에 set을 수행하지 않도록 한다.
확장자 명이 모두 정상이라면 가장 먼저 file을 담아줄 배열을 생성하는데 이때 같이 받은 files를 전개 연산자를 통해 초기값으로 담아준다.
이렇게 하지 않는다면 사용자가 추가적으로 파일을 선택하는 경우에 이전 파일이 모두 날아간다.
files에 아무것도 없다면 알아서 빈 배열로 생성되니 문제가 발생할 일도 없다.
사용자가 선택한 파일은 e.target.files로 변수에 담아두고 해당 변수에 대해 반복문으로 풀어주며 fileArr 배열에 하나씩 담아준뒤 배열을 반환한다.
그럼 결과를 반환받은 컴포넌트에서는 setFiles를 통해 state 값을 업데이트하게 되고 재 렌더링을 수행하게 되면서 미리보기 컴포넌트인 ImageNewPreviewForm이 출력된다.
삭제할 previewNo를 가져오도록 하는 것인데 파일 선택 처리시에 만들었던 배열 객체의 fileNo를 의미한다.
그리고 이 fileNo는 미리보기 컴포넌트에서 Link의 value로 담아두었다.
그래서 e.target.getAttribute('value') 로 삭제 이벤트가 발생한 fileNo를 알 수 있다.
그리고 Number로 변환을 해 두는데 배열에서의 fileNo가 Number 타입으로 들어가있기 때문에 변환하도록 했다.
처리는 파일 선택때와 마찬가지로 배열을 하나 생성하면서 files를 전개 연산자로 담아준다.
그리고 find를 통해 배열에 있는 fileNo와 deleteNo가 일치하는 객체를 찾아낸 뒤 해당 객체가 몇번 index에 있는지 indexOf를 통해 알아낸다.
그 후 splice를 통해 해당 인덱스에 위치한 객체를 삭제하도록 하고 배열을 반환하면 컴포넌트에서는 setFiles로 반환받은 배열을 저장하게 된다.
예시를 들어서 다시 정리.
1, 2, 3, 4, 5의 previewNo를 가진 파일을 사용자가 선택한 상황에서 3번 파일을 삭제한다.
그럼 deleteNo = 3이 되고 arr에는 1, 2, 3, 4, 5 파일을 전개 연산자를 통해 담아 생성한다.
find를 통해 배열 객체 중 fileNo가 3인 것을 찾아내 delObejct에 담아준다.
indexOf를 통해 해당 객체의 인덱스인 2를 알아내게 되고 splice를 통해 2번 인덱스에 저장된 객체를 제거한 뒤 컴포넌트에게 배열을 반환한다.
그럼 컴포넌트에서는 1, 2, 4, 5 객체만 들어있는 배열을 반환받게 되고 setFiles로 해당 배열을 files state에 set 했으니 미리보기 컴포넌트는 1, 2, 4, 5 객체에 대해 재 렌더링 되기 때문에 따로 미리보기 컴포넌트를 제어해 제거하는 처리를 해줄 필요가 없게 된다.
수정은?
수정 역시 별 차이가 없다.
단지 기존에 저장해둔 파일을 삭제하는지 새로 선택한 파일을 삭제하는지를 체크해 따로 처리할 수 있어야 한다는 점 밖에는 없다.
거의 대부분 동일한 코드고 수정 페이지는 렌더링 시 서버에 데이터를 요청해 게시글 데이터와 저장된 파일 데이터를 받아와야 한다.
그리고 동일하게 previewNo를 0로 초기화 하도록 모듈을 호출해 처리한다.
state의 경우는 새로 등록하는 파일을 관리하는 files, 기존 파일 중 삭제하는 파일을 관리하는 deleteImageName, 기존 이미지 파일을 관리하는 imageDataValue, 게시글 제목 및 내용을 관리하는 values로 처리한다.
그럼 페이지 접근 시 useEffect를 통해 게시글 정보는 values state에, 기존 이미지 파일은 imageDataValue state에 set을 해주고 모듈의 previewNo를 0으로 초기화 하도록 한다.
그리고 기존 이미지 파일과 새로운 이미지 파일 관리를 두개의 state에서 하는만큼 미리보기 컴포넌트 역시 두개의 컴포넌트를 사용했다.
새로운 이미지 파일은 작성 페이지와 동일하게 ImageNewPreviewForm 컴포넌트를 그대로 사용하고, 기존 이미지 파일은 ImageOldpreviewForm 컴포넌트로 처리한다.
두 컴포넌트에 넘겨주는 props의 차이는 state도 있지만 삭제 버튼 onClick 이벤트 핸들링도 차이가 발생한다.
그럼 일단 ImageOldPreviewForm의 코드.
import React from 'react';
import ImageDisplayElem from '../../ui/ImageDisplayElem';
import { Link } from 'react-router-dom';
function ImageOldPreviewForm(props) {
const { imageData, handleOnClick } = props;
return (
<div className='preview-box'>
<ImageDisplayElem
imageClassName={'thumbnail'}
imageName={imageData.imageName}
/>
<p>{imageData.oldName}</p>
<Link onClick={handleOnClick} value={imageData.imageStep}>삭제</Link>
</div>
)
}
newPreviewForm 컴포넌트는 바로 img 태그를 사용해 출력했지만 여기서는 ImageDisplayElem 이라는 컴포넌트를 통해 처리했다.
이유는 새로운 파일의 경우 바로 읽어서 처리할 수 있지만 기존 파일의 경우 이미지 파일의 저장명, 기존 파일명 등의 정보를 받고 ImageDisplayElem에서 서버에 파일 바이너리 코드를 따로 요청해 받기 때문이다.
이 부분은 사실 구현하기 나름이라고 생각하고 애초에 이미지 파일 정보를 받을 때 바이너리 코드를 같이 받아 추가적인 요청을 안해도 될 것 같다.
React 프로젝트를 진행하면서 state를 배열로 관리해야 하는 경우와 반복문에서 state 주의사항 정리.
프로젝트를 진행하면서 state에 배열을 담는 경우나 배열 구조의 state를 제어해 삭제 또는 추가해야 하는 경우가 있었다.
처음 리액트를 배울때나 프로젝트 초반에는 axios로 응답받은 데이터를 state에 set 해주는 케이스만 있었기 때문에 배열 구조의 state 관리에 크게 문제도 없었고 고민도 없었다.
하지만 이미지 파일 관리에 대해 구현하면서부터 문제가 생기기 시작했다.
state는 readOnly이므로 state 자체를 수정할 수 없다.
그럼 setState를 통해 수정된 값을 다시 넣어줘야 하는데 어떻게 처리할 것인가에 대한 문제였다.
그리고 이 문제를 어떻게 할까 고민하면서 추가적으로 문제가 생긴게 배열에 어떻게 담고 어떻게 삭제하지? 라는 문제였다.
그래서 이 문제에 대해 가장 근본적인 부분부터 정리하고자 한다.
Javascript에서의 배열
배열 생성이나 제어에 대해서는 사실 React에서의 배열이라고 하기 보다는 ES6 문법이다.
ES6 문법에 대해 계속해서 공부하고 있긴 하지만 이런 활용에 대해서는 무지했다.
이번에 알게된 것에 대해 코드를 먼저 정리한다.
//빈 배열 선언
const arr = [];
//전개 연산자
const arr = [...value];
//배열의 끝에 추가
arr.push(value);
//배열의 앞에 추가
arr.unshift(value);
//배열의 2번 인덱스 위치에 추가
arr.splice(2, 0, value);
//배열의 끝에 위치한 요소 제거
arr.pop();
//배열의 끝에 위치한 요소를 제거하고 변수에 받음
const popValue = arr.pop();
//배열의 첫번째 요소 제거
arr.shift();
//배열의 첫번째 요소를 제거하고 변수에 받음
const shiftValue = arr.shift();
//배열의 2번 인덱스부터 1개의 요소를 제거
arr.splice(2, 1);
//배열의 2번 인덱스부터 1개의 요소를 제거하고 값을 변수에 받음
const removeValue = arr.splice(2, 1);
//delete를 통한 삭제
delete arr[1];
//함수를 사용하지 않는 방법
//배열의 끝에 요소를 추가
arr[arr.length] = value;
//배열의 크기를 줄임(가장 끝 요소가 삭제)
arr.length = arr.length - 1;
//요소 추가
//길이 3의 arr에 5 인덱스 value를 넣도록 한다면
//4번 인덱스의 값은 undefined로 생성
arr[5] = value;
가장 먼저 전개연산자.
전개 연산자는 배열 또는 객체를 넘기는 용도로 사용한다.
앞에 ...이 붙게 되고 새로운 배열에 [...values] 이렇게 초기화를 하는 경우 values라는 배열 또는 객체에 담겨있던 데이터가 새로운 배열에 들어가게 된다.
자바스크립트 배열에 대해 알아보면서 편하다고 생각했던 점이 크기를 지정하지 않아도 된다는 점이었다.
물론 잘못사용하면 독이 될만한 문제이긴 하지만 그래도 편한건 편하다..
자바스크립트 배열에서는 배열의 끝이나 가장 앞에 요소를 추가할 수 있다.
.push(value)를 통해 가장 끝에 요소를 추가할 수 있고, .unshift(value)를 통해 가장 앞에 요소를 추가할 수 있다.
이걸 알게 되면서 느낀건데 덱(deque)의 개념이지 않나? 라는 생각이 들었다.
삭제의 경우 pop(), shift()로 가장 끝, 가장 앞 요소를 제거 및 추출할 수 있다.
splice 함수는 요소를 추가할수도, 삭제할수도 있다.
요소의 추가는 splice(index, 0, [요소1, 요소2]) 이렇게 사용하고,
요소의 삭제는 splice(index, 개수) 이렇게 사용한다.
추가의 경우 index의 위치에 요소를 추가하게 된다.
요소는 꼭 배열이 아니어도 되고 단일 객체여도 상관없다.
삭제 splice는 index 위치에서 개수만큼 요소를 제거한다.
삭제 처리를 하는 splice는 제거한 요소를 반환받을 수 있다.
삭제 처리는 delete를 통해서도 처리할 수 있는데 delete를 통한 처리는 해당 위치 값만 삭제된다.
즉, arr = [1, 2, 3, 4, 5] 가 있다고 하고 splice(2, 1)을 처리하면 arr = [1, 2, 4, 5] 가 되지만
delete arr[2]를 하게 되면 arr = [1, 2, undefined, 4, 5]가 된다.
그래서 잘 구분해서 사용해야 할 것 같다.
함수를 사용하지 않고 추가 삭제도 가능한데 index 부분에 length를 넣어주면 가장 끝에 요소가 들어가게 된다.
그리고 배열의 length를 length - 1 로 수정하게 되면 크기가 1 줄어들면서 가장 끝 요소가 삭제된다.
마지막으로 정말 특이했던 것 중 하나.
arr = [1, 2, 3]이라고 하고 arr[5] = 6 이렇게 처리하게 되면
arr = [1, 2, 3, undefined, undefined, 6] 이렇게 처리가 된다.
입력한 그 위치에 요소를 넣어주긴 하지만 기존 배열에서 끝 Index의 위치와 차이가 발생한다면 undefined를 넣고 해당 인덱스에 요소를 넣어준다.
React 에서 state 배열 관리
useState는 배열 객체를 받고 관리할 수 있다.
보통 axios 요청 응답을 state에 담아 map으로 풀어 사용했다.
근데 이미지 파일 관리를 해야 되기 시작하면서 배열을 제어할 수 있어야 했다.
사용자가 처음 이미지 파일을 선택했을 때는 state도 비어있고 배열화 해서 넣어주면 간단하겠지만 이후 사용자가 그 중 일부를 삭제하거나 추가하는 경우 배열을 제어할 수 있어야 한다고 생각했다.
state는 readOnly로 동작하기 때문에 수정이 불가능할 것이라고 생각했고 setState를 통해 새로운 배열을 넣어줘야 겠다고 생각했기 때문이다.
state을 배열 구조로 계속해서 추가와 삭제를 할 수 있도록 관리하기 위해서는 전개 연산자를 사용하면 된다.
이벤트가 발생했을 때 전개 연산자를 통해 배열구조의 state를 새로 생성되는 배열에 담아주고 그 배열내에서 추가 또는 삭제 처리가 이루어진 뒤 setState를 처리하면 된다.
예시코드
//files는 배열구조 state
//[ {fileNo: 1, file: file1}, {fileNo: 2, file: file2}, ... ]
const [files, setFiles] = useState([]);
//사용자의 이미지 파일 선택 이벤트 핸들링
const handleInputOnChange = (e) => {
//fileArray 배열을 생성하면서
//전개 연산자를 통해 초기값으로 files를 담도록 처리
const fileArray = [...files];
//선택된 파일 리스트
const fileList = e.target.files;
//배열에 파일 추가 처리
for(let i = 0; i < fileList.length; i++) {
//push로 파일 배열에 요소 추가
fileArray.push({
fileNo: ++previewNo,
file: fileList[i],
});
}
//setState로 배열을 state에 저장
setFiles(fileArray);
}
//사용자의 선택 이미지 삭제 이벤트 핸들링
const handleDeleteOnClick = (e) => {
//삭제할 파일 fileNo
const deleteNo = Number(e.target.getAttribute('value');
//fileArray 배열을 생성하면서
//전개 연산자를 통해 초기값으로 files를 담는다.
const fileArray = [...files];
//삭제할 요소를 찾는다.
//find 함수를 사용해 fileNo가 deleteNo와 일치하는 요소를 찾아 변수에 담아준다.
const delObject = fileArray.find(function (item) {
return item.fileNo === deleteNo;
});
//find 함수를 통해 찾은 객체가 몇번 인덱스에 위치하는지 찾는다.
const delIndex = fileArray.indexOf(delObject);
//splice 함수를 통해 삭제하고자 하는 인덱스 위치의 요소를 삭제한다.
fileArray.splice(delIndex, 1);
//삭제가 완료된 배열을 state에 저장
setFiles(fileArray);
}
배열구조의 state 제어는 이런방법으로 할 수 있었다.
state가 readOnly라 직접 제어할 수 없으니 배열로 꺼낸 뒤에 제어하고 다시 setState로 넣어주는 방법으로 처리할 수 있다.
반복문에서의 state
배열 구조의 state를 제어하면서 가장 먼저 시도해봤던 방법은 반복문을 통한 state 관리였다.
가장 먼저 사용자가 선택한 파일을 state에 담아줄 때 반복문을 통해서 처리하면 되겠다는 생각을 했었다.
이 처리가 완료된 후 files를 콘솔에 찍어보면 가장 마지막 파일인 3번 파일만 들어가있는 것을 확인할 수 있다.
이유는 setState는 호출되는 즉시 상태를 업데이트 하지 않고 Promise를 통해 비동기적으로 상태를 변경시키기 때문이다.
즉, file1, file2, file3을 set 했다고 하더라도 반복문이 끝나기 전에 state가 변경되지 않았기 때문에 전개 연산자로 files를 불러와도 비어있는 또는 원래 files데이터만 들어오기 때문에 file1, file2에 대한 set은 수행되지 않게 되어 마지막 호출인 file3만 들어가게 된다.
좀 더 세부적으로 보자면 files state가 빈 배열 상태에서 수행한다고 했을 때
//[ {file1}, {file2}, {file3} ]
const fileList = e.target.files;
// i = 0
// ...files = []
// fileList[0] = file1
// setFiles([], file1) -> 변경되지 않는다.
// i = 1
// ...files = []
// fileList[1] = file2
// setFiles([], file2) -> 변경되지 않는다.
// i = 2
// ...files = []
// fileList[2] = file3
// setFiles([], file3) -> 변경되지 않는다.
// 반복문 종료.
// 마지막 요청이었던 setFiles([], file3) 으로 state 변경.
// 결과 -> files = [ { file3 } ]
for(let i = 0; i < fileList.length; i++){
setFiles([...files, fileList[i]]);
}
이런 과정을 거치게 된다.
원래 생각대로라면 i = 1일때 ...files를 통해 file1이 들어와야 한다고 생각했지만 set이 수행되지 않았기 때문에 계속 시작값인 빈 배열이 들어와 데이터가 쌓이지 않는 것.
그래서 반복문을 통해 setState를 사용하는 것이 아닌 반복문을 통해 state에 담아줄 객체를 만든 뒤 반복문이 모두 종료된 이후 결과물을 state에 넣어주도록 해야 한다.
a 태그는 기본적으로 href 속성을 통한 url이동을 처리하고 따로 무언가를 수행하지 않고 클릭에 대한 이벤트 핸들링이 필요한 경우에는 href 속성 값으로 #을 넣어 처리한다.
#을 넣어주게 되면 아무것도 실행하지 않고 페이지 최상단으로 이동하게 된다.
근데 문제는 React에서 a 태그의 href 속성에 #을 넣게 되면 경고문이 발생한다.
The href attribute requires a valid value to be accessible. Provide a valid, navigable address as the href value. If you cannot provide a valid href, but still need the element to resemble a link, use a button and change it with appropriate styles. Learn more: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/anchor-is-valid.md jsx-a11y/anchor-is-valid
href 속성에 액세스 하려면 유효한 값이 필요하니 유효하고 탐색 가능한 주소를 href 값으로 제공해야 한다.
유효한 href를 제공할 수 없지만 link와 유사한 요소가 필요한 경우 button을 사용하고 적절한 스타일로 변경하라는 내용이다.
프로젝트에서 a 태그가 사용되는 부분으로는 게시판에서 상세 페이지로 이동하기 위해 게시글 정보를 감싸는 것과 페이징 버튼에 대해 a 태그로 처리했었다.
페이징 버튼의 경우 button으로 수정했지만 게시글에 대해서는 button으로 처리하기는 좀 그랬다.
이런 경우에는 Link를 통해 처리하면 좋다.
그리고 a 태그에서 href 속성에 # 을 넣어주게 되면 url에 #이 붙는 경우가 생기는데 Link를 사용하면 굳이 # 으로 처리할 필요도 없다보니 url에도 #이 붙을일이 없어서 그건 좋다!
그리고 a 태그를 통한 페이지 이동은 페이지 전체를 새로 불러오게 된다고 한다.
수정이 발생하는 컴포넌트만 재 렌더링 되는 것이 아닌 전체가 렌더링 되기 때문에 그런면에서 상황을 고려해 a 태그를 사용하는 것 보다 Link 태그를 사용해야 한다는 의견이 있었다.
window.location.href
보통 JQuery 사용했을 때 많이 사용하던 방법이다.
사용코드
window.location.href = '/';
거의 이 방법은 사용하지 않았는데 모듈에서 사용하게 되었다.
react hook은 컴포넌트에서만 사용할 수 있다는 조건이 있기 때문에 모듈에서 사용하기 위해서는 호출하는 컴포넌트에서 useNavigate를 같이 보내줘야 했다.
하지만 모듈 처리에서 조건에 따라 페이지 이동이 발생할수도, 없을수도 있고 navigate를 사용하지 않는 컴포넌트에서도 이 모듈 요청때문에 navigate를 보내줘야 하는 상황도 발생했다.
그래서 location.href 로 처리해보고자 했으나 ESLint 경고문이 발생했다.
ESLint: Unexpected use of 'location'.(no-restricted-globals)
JQuery에서는 location.href 만으로도 사용이 가능하지만 여기서는 location이 예기치 않게 사용되었다는 위와 같은 경고문이 발생한다.
IntelliJ 를 통해 문제를 해결하면 // eslint-disable-next-line no-restricted-globals 이런 주석이 달리면서 경고문이 사라지는데 ESLint 가 전역변수를 참조할 수 있게 주석으로 명시해주는 것이라고 한다.
그래서 window.location.href로 사용하는 것이 더 나은 방법이라고 생각한다.
하지만 window.location.href는 a 태그 이동처럼 전체 페이지가 렌더링 된다고 한다.
현재 프로젝트에서 사용하는 경우는 에러 핸들링에서 오류 페이지로 전환에 사용했기 때문에 전체 렌더링이 된다고 해도 딱히 뭐가 없어서 문제가 되진 않지만 그렇지 않은 경우에는 어떤 방법으로 해야할지 테스트와 경험이 필요할 것 같다.