React에서 어떻게 날짜를 포맷팅 했는지 정리.

 

 

Intl.DateTimeFormat

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'); // 언어 등록

//...

 

플러그인은 필요에 따라 사용하면 될 것 같다.

현재 프로젝트에도 몇분전 이런건 하지 않아서 isLeapYear만 처리했다.

 

relativeTime에 대한 것은 아래 문서에서 확인할 수 있다.

https://day.js.org/docs/en/plugin/relative-time

 

Day.js · 2kB JavaScript date utility library

2kB JavaScript date utility library

day.js.org

 

 

사용은 아주 간단하다.

<span>
    {dayjs(board.boardDate).format('YYYY-MM-DD')}
</span>

컴포넌트에서 이렇게 처리하는 것으로 2024-04-14 이렇게 포맷팅 할 수 있다.

 

format으로는 YY-MM-DD 나 YYYY/MM/DD, YYYY/MM/DD HH:mm:ss, hh:mm:ss 가 있다.

 

dayjs를 이용해 입력할 수 있는 날짜 타입으로는

string, number, Date, dayjs.Dayjs, null, undefined가 있다.

그렇게 때문에 이전 Intl.DateTimeFormat을 사용할때처럼 Date 객체를 만들어줄 필요도 없고 null이나 undefined가 들어간다고 해서 오류가 발생하지도 않는다.

그래서 여러 조건에 대해 따로 처리하지 않고 맡겨둘 수 있어서 좋다.

 

dayjs 참고 블로그

https://jforj.tistory.com/267

 

[React] Dayjs로 간편하게 날짜 처리하기

안녕하세요. J4J입니다. 이번 포스팅은 Dayjs로 날짜 처리하는 방법에 대해 적어보는 시간을 가져보려고 합니다. Dayjs란? Dayjs는 날짜 처리를 간편하게 할 수 있도록 도와주는 라이브러리입니다. 일

jforj.tistory.com

 

React 프로젝트를 진행하면서 페이징 처리한 것에 대한 정리.

 

 

구현 방법

리액트에서 페이지네이션을 구현하기 위해서는 라이브러리를 사용하는 방법과 직접 구현하는 방법이 있었다.

라이브러리는 알아보니까 material-ui 아니면 react-js-pagination을 많이 사용하는 것 같았다.

하지만 스프링 프로젝트 진행하면서 직접 구현했던 경험이 있으니 이번에도 직접 구현하는 방법을 택했다.

 

 

페이지네이션이 적용되는 페이지 구성

첫 리액트 프로젝트이기 때문에 작은 프로젝트 먼저 진행해서 게시글 리스트에서의 페이지네이션과 댓글에서의 페이지네이션 기능이 필요했다.

게시판에서는 전체 게시글을 대상으로 하는 페이지네이션과 검색 결과에 대한 페이지네이션을 처리해야 했고, 댓글은 해당 게시글의 전체 댓글에 대한 페이지네이션을 처리하면 됐다.

 

 

서버에서 응답받는 페이지네이션 데이터

서버에서는 Spring Data JPA로 처리하고 있었고 Pageable을 통해 처리하고 있다.

그래서 게시글 리스트인 content, 리스트가 비어있는지에 대한 여부인 empty, 첫 페이지인지에 대한 first, 마지막 페이지인지에 대한 last, 현재 조회중인 페이지 번호인 number, 총 페이지 개수인 totalPages를 전달 받도록 했다.

 

스프링 프로젝트로 진행할때도 이게 다 필요하진 않았고 totalPages 정도만 필요했었는데 React에서는 뭐가 필요할 지 몰라서 일단 필요해 보이는 데이터를 넘기도록 해놔서 좀 많이 받게 되었다..ㅎㅎ

그리고 결과적으로 이번에도 totalPages만 사용했다.

 

 

처리 방법

일단 페이지네이션 구조는 한번에 10개의 버튼이 출력되고 있고 가장 끝 버튼의 페이지 이후 페이지가 또 존재한다면 next가 출력되며 마찬가지로 가장 앞 버튼 페이지 앞쪽으로 페이지가 존재한다면 prev 버튼이 출력된다.

 

출력할 페이지 버튼의 범위를 계산하고, prev, next 버튼의 출력을 판단하기 위해 현재 페이지 번호와 totalPages 값을 통해 계산 한 뒤 객체 형태로 구성해 state에 저장하도록 했다.

이 처리과정을 리액트에서 모듈을 통해 처리하도록 했는데 서버에서 다~ 처리한 뒤에 보내주는 게 더 편할것 같다.

 

 

하지만 아직은 서버에서 처리하도록 수정하지 않은 상태고, 서버로 처리를 옮긴다고 해도 계산하는 부분만 달라지니 현재 구조대로 일단은 정리한다.

 

 

게시글 리스트 컴포넌트

import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

import Paging form '../pagination/Paging';

import { createPagingObject } from '../../../modules/pagingModule';
import { axiosErrorHandling, boardAxios } from '../../../modules/customAxios';

function BoardPage() {
    const [pageNum, setPageNum] = useState(1);
    const [data, setData] = useState([]);
    const [pagingData, setPagingData] = useState({
        startPage: 0,
        endPage: 0,
        prev: false,
        next: false,
        activeNo: pageNum,
    });
    const navigate = useNavigate();
    
    useEffect(() => {
        getboardList(pageNum);
    }, [pageNum]);
    
    const getBoardList = async(pageNum) => {
        await boardAxios.get(`?pageNum=${pageNum}`)
            .then(res => {
                const pagingObject= createPagingObject(pageNum, res.data.totalPages);
                
                setData(res.data.content);
                setPagingData({
                    startPage: pagingObject.startPage,
                    endPage: pagingObject.endPage,
                    prev: pagingObject.prev,
                    next: pagingObject.next,
                    activeNo: pageNum,
                });
            })
            .catch(err => {
                axiosErrorHandling(err);
            });
    }
    
    //페이지 이동 클릭 이벤트
    const handlePageNoBtnOnClick = (e) => {
        setPageNum(e.target.textContent);
    }
    
    //이전 페이지 이동 클릭 이벤트
    const handlePagePrevBtnOnClick = (e) => {
        setPageNum(pagingData.startPage - 1);
    }
    
    //다음 페이지 이동 클릭 이벤트
    const handlePageNextBtnOnClick = (e) => {
        setPageNum(pagingData.endPage + 1);
    }
    
    return (
        <div>
            //list component
            <Paging
                pagingData={pagingData}
                pageNumOnClick={handlePageNoBtnOnClick}
                prevOnClick={handlePagePrevBtnOnClick}
                nextOnClick={handlePageNextBtnOnClick}
            />
        </div>
    )
}

 

module. createPagingObject 

export const createPagingObejct = (pageNum, totalPage) => {
   let endPage = Number(Math.ceil(pageNum / 10.0) * 10);
   const startPage = endPage - 9;
   if(totalPages < endPage)
       endPage = totalPages;
   
   const prev = startPage > 1;
   const next = endPage < totalPages;
   
   return {
       startPage: startPage,
       endPage: endPage,
       prev: prev,
       next: next,
   }
}

 

가장 먼저 테스트해보기 위해 작성했던 코드다.

게시판은 두개로 나눠져 있고, 댓글에서도 이 계산은 필요했기 때문에 모듈화를 했다.

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>
    )
}

 

이것도 중첩 라우팅으로 해서 중첩해 개선하는 방법도 있었는데 욕심은 하나로 줄이고 싶어서 아직 수정하고 있지 않는 중...

 

이런식으로 여러개의 쿼리 스트링에 대해 게시글 리스트 페이지로 라우팅을 하도록 처리해뒀다.

 

 

게시글 리스트 컴포넌트

import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';

import SearchPaging from '../pagination/SearchPaging';

import { createPagingObject } from '../../../modules/pagingModule';
import { axiosErrorHandling, boardAxios } from '../../../modules/customAxios';

function BoardPage() {
    const [params] = useSearchParams();
    const pageNum = params.get('pageNum') == null ? 1 : params.get('pageNum');
    const keyword = params.get('keyword');
    const searchType = params.get('searchType');
    const [data, setData] = useState([]);
    const [pagingData, setPagingData] = useState({
        startPage: 0,
        endPage: 0,
        prev: false,
        next: false,
        activeNo: pageNum,
    });
    const navigate = useNavigate();
    
    useEffect(() => {
        getBoardList(pageNum, keyword, searchType);
    }, [pageNum, keyword, searchType]);
    
    const getBoardList = async (pageNum, keyword, searchType) => {
        await boardAxios.get(
            `?keyword=${keyword}&searchType=${searchType}&pageNum=${pageNum}`
        )
        .then(res => {
            const pagingObject = createPagingObject(pageNum, res.data.totalPages);
            
            setData(res.data.content);
            setPagingData({
                startPage: pagingObject.startPage,
                endPage: pagingObject.endPage,
                prev: pagingObject.prev,
                next: pagingObject.next,
                activeNo: pageNum,
            });
        })
        .catch(err => {
            axiosErrorHandling(err);
        }
    }
    
    return (
        <div>
            //list component
            <SearchPaging
                pagingData={pagingData}
                keyword={keyword}
                searchType={searchType}
            />
        </div>
    )
}

 

쿼리 스트링 값을 받기 위해 useSearchParams hook을 통해 받고 pageNum은 아무것도 없는 ' / ' 경로로 접근하더라도 1이라는 값이 필요하기 때문에 삼항연산자를 통해 처리한다.

keyword와 searchType의 경우 null이어도 문제가 되지 않기 때문에 그냥 두었고 axios 요청의 경우 null인 값은 무시하기 때문에 문제가 되지 않았다.

 

pagingObject의 처리는 수정없이 이전과 동일하게 처리하고 state에 대한 set 역시 동일하게 두었다.

 

페이징 컴포넌트의 경우 조금 수정이 있었는데 Paging 컴포넌트와 Search 컴포넌트를 생성하고 이 두 컴포넌트를 갖고 있는 SearchPaging 컴포넌트를 통해 처리하도록 했다.

이유는 두개의 게시판에서 동일하게 검색 및 페이징 기능을 갖고 있어야 하는데 각 최상위 컴포넌트에서 이벤트에 대한 처리를 담당하지 않도록 하기 위해서였다.

그 이벤트에 대한 처리는 SearchPaging 컴포넌트에서 담당하도록 해서 분리했다.

 

 

module. pagingModule

export function handlePageNumBtn (e, navigate, keyword, searchType) {
    const clickNo = e.target.textContent;

    paginationNavigate(clickNo, keyword, searchType, navigate);
}

export function handlePrevBtn (startPage, navigate, keyword, searchType) {
    const prevNumber = startPage - 1;

    paginationNavigate(prevNumber, keyword, searchType, navigate);
}

export function handleNextBtn (endPage, navigate, keyword, searchType) {
    const nextNumber = endPage + 1;

    paginationNavigate(nextNumber, keyword, searchType, navigate);
}

export function handleSearchBtnOnClick (navigate, keyword, searchType) {
    searchNavigate(navigate, keyword, searchType);
}

const searchNavigate = (navigate, keyword, searchType) => {

    navigate(`?keyword=${keyword}&searchType=${searchType}`);
}

const paginationNavigate = (clickNo, keyword, searchType, navigate) => {

    if(keyword == null){
        navigate(`?pageNum=${clickNo}`);
    }else {
        navigate(`?keyword=${keyword}&searchType=${searchType}&pageNum=${clickNo}`);
    }
}

 

SearchPaging 컴포넌트

import React from 'react';
//...

import { 
    handlePageNumBtn
    , handlePrevBtn
    , handleNextBtn 
} from '../../../modules/pagingModule';

function SearchPaging(props) {
    const { pagingData, keyword, searchType } = props;
    const navigate = useNavigate();
    
    const handlePageNoBtnOnClick = (e) => {
        handlePageNumBtn(e, navigate, keyword, searchType);
    }
    
    const handlePagePrevBtnOnClick = (e) => {
        handlePrevBtn(pagingData.startPage, navigate, keyword, searchType);
    }
    
    const handlePageNextBtnOnClick = (e) => {
        handleNextBtn(pagingData.endPage, navigate, keyword, searchType);
    }
    
    return (
        <>
            <Search
                keyword={keyword}
                searchType={searchType}
            />
            <Paging
                pagingData={pagingData}
                pageNumOnClick={handlePageNoBtnOnClick}
                prevOnClick={handlePagePrevBtnOnClick}
                nextOnClick={handlePageNextBtnOnClick}
            />
        </>
    )
}

 

 

Paging 컴포넌트

import React from 'react';
import styled from 'styled-components';

import PagingButton from './PagingButton';

const PagingliWrapper = styled.li`
    list-style: none;
    float: left;
    padding: 5px;
`

function Paging(props) {
    const { pagingData, pageNumOnClick, prevOnClick, nextOnClick } = props;
    
    let prevElem = null;
    let nextElem = null;
    const pagingNumberArr = [];
    
    if(pagingData.endPage !== 1) {
        for(let i = pagingData.startPage; i <= pagingData.endPage; i++) {
            let pagingClassName = 'pagingNumber';
            
            if(i === Number(pagingData.activeNo))
                pagingClassName = pagingClassName + ' active';
            
            const body = {
                pageNum: i,
                className: pagingClassName,
            }
            
            pagingNumberArr.push(body);
        }
    }
    
    if(pagingData.prev)
        prevElem = <PagingliWrapper>
                       <PagingButton
                           btnText={'prev'}
                           className={'pagingPrev'}
                           onClick={prevOnClick}
                       />
                   </PagingliWrapper>;
    
    if(pagingData.next)
        nextElem = <PagingliWrapper>
                       <PagingButton
                           btnText={'next'}
                           className={'pagingNext'}
                           onClick={nextOnClick}
                       />
                   </PagingliWrapper>;
    
    return(
        <div className='paging'>
            <ul>
                {prevElem}
                {pagingNumberArr.map((pagingNum, index) => {
                    return (
                        <PagingNumber
                            key={index}
                            pagingNumberData={pagingNum}
                            btnOnClick={pageNumOnClick}
                        />
                    )
                })}
                {nextElem}
            </ul>
        </div>
    )
}

function PagingNumber(props) {
    const { pagingNumberData, btnOnClick } = props;
    
    return (
        <PagingliWrapper>
            <PagingButton
                btnText={pagingNumberData.pageNum}
                className={pagingNumberData.className}
                onClick={btnOnClick}
            />
        </PagingliWrapper>
    )
}

 

위 코드는 현재 구현된 코드다.

SearchPaging에서 페이지네이션 버튼 클릭 이벤트에 대한 핸들링을 하고 있고 각 처리는 모듈화를 통해 pagingModule.js에서 처리하고 있다.

페이지 이동을 위해 useNavigate를 같이 전달해 사용하도록 처리했다.

이때 좀 신기했던게 paginationNavigate나 searchNavigate에서 볼 수 있듯이 앞에 게시판의 기본 url 이 필요하지 않다.

이게 어떻게 처리되는지 좀 알아보고자 했는데 아직까지 해답을 찾지 못했다.

그냥 개인적인 생각이지만 아마도 쿼리스트링만 작성하면 현재 url 뒤에 붙여서 처리하기 때문이지 않나 라는 생각을 한다.

굳이 페이지네이션 이벤트에 대한 처리를 모듈화한 이유는 각 게시판에서만 처리하는 경우였다면 SearchPage 컴포넌트에서 처리했겠지만 댓글의 경우 Paging 컴포넌트만 필요하기 때문에 SearchPage 컴포넌트를 하위 컴포넌트로 두지 않는다.

그래서 댓글의 페이지네이션 이벤트 처리를 위해 모듈화를 하게 되었다.

 

Paging 컴포넌트에서는 조건에 맞는 버튼을 출력하기 위해 각 엘리먼트에 대해 처리한 뒤 렌더링을 수행할 수 있도록 처리했다.

 

'Front > React' 카테고리의 다른 글

React 날짜 포맷  (0) 2024.04.14
React 이미지 처리  (0) 2024.04.14
React 이미지 다중 업로드 및 수정, 요청 전송  (0) 2024.04.13
React에서 state 배열 관리 및 반복문에서의 state  (0) 2024.04.13
React 페이지 이동  (1) 2024.04.12

이전 포스팅으로 React에서 이미지 미리보기와 파일 저장 요청등의 기능을 정리했다.

이번에는 이미지 출력에 대해 어떻게 처리하는지에 대한 정리.

 

 

FileReader로 처리

FileReader는 blob 또는 file과 같은 객체로부터 데이터를 읽어들이기 위한 목적으로 사용되는 객체다.

보통 img 태그의 src 속성에 리소스를 다루기 위해서는 readAsDataURL을 통해 처리한다.

 

가장 먼저 사용자가 파일 선택한 뒤 미리보기 출력을 위한 처리 코드다.

//files는 하나의 이미지 파일이라고 가정.
const { files } = props;
const [imgSrc, setImgSrc] = useState('');

useEffect(() => {
    const reader = new FileReader();
    reader.onload = image => {
        setImgSrc(String(image.target.result));
    }
    
    reader.readAsDataURL(files);
});

 

FileReader 객체 생성 후 onload가 작성되어있지만 readAsDataURL을 통해 파일을 먼저 읽어 변환한다.

readAsDataURL은 base 64 형태의 data URL로 데이터를 읽고 변환하게 되므로 base64 형태의 data URL로 files를 읽어 변환한다.

데이터를 모두 읽고 나면 onload 이벤트가 발생하게 되고 .result를 통해 성공 시 읽어 들인 결과를 문자열 타입으로 imgSrc state에 set을 하게 된다.

 

다음은 서버로부터 파일을 요청해 전달 받은 뒤 FileReader로 처리하는 코드다.

const { imageName } = props;
const [imgSrc, setImgSrc] = useState('');

useEffect(() => {
    displayImage(imageName);
}, [imageName]);

const displayName = async (imageName) => {
    await axios.get(`display/${imageName}`
        , {
            headers: {'Content-Type': 'application/json'}
            , responseType: 'blob'
        })
        .then(res => {
            const file = new File([res.data], 'imageName')
            const reader = new FileReader()
            reader.onload = image => {
                setImgSrc(String(image.target.result)
            }
            reader.readAsDataURL(file);
        })
        .catch(err => {
            console.error('display axios error : ', err);
        });
}

 

서버에 파일 요청을 한뒤 응답으로 받기 위해서는 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/~~~~ 형태로 생성되고 간결해진 것을 확인할 수 있다.

//새로운 파일 선택.
//동일하게 files는 하나의 파일이라고 가정.
const { files } = props;
const [imgSrc, setImgSrc] = useState('');

useEffect(() => {
    const url = window
                .URL
                .createObjectURL(
                    files
                );
    setImgSrc(url);
});


//서버에 파일을 요청해 blob으로 반환 받은 뒤 처리.
const { imageName } = props;
const [imgSrc, setImgSrc] = useState('');

useEffect(() => {
    displayImage(imageName);
}, [imageName]);

const displayName = async (imageName) => {
    await axios.get(`display/${imageName}`
        , {
            headers: {'Content-Type': 'application/json'}
            , responseType: 'blob'
        })
        .then(res => {
            const url = window
                        .URL
                        .createObjectURL(
                            new Blob([res.data], {type: res.headers['content-type']})
                        );
            setImgSrc(url);
        })
        .catch(err => {
            console.error('display axios error : ', err);
        });
}

 

이 방법의 단점으로는 메모리 이슈가 존재한다.

blob 객체가 URL로 변환되어 매핑이 이루어진 채 메모리에 저장되면 명시적으로 해당 URL이 해제되기 전까지 브라우저는 URL이 유효하다고 판단하기 때문에 JS 엔진에서 가비지 컬렉션이 이루어지지 않는다.

따라서 Blob URL을 사용한 이후 더이상 사용하지 않을 시점이라고 판단되는 경우에는 명시적으로 해제해주는 것이 좋다.

이때 명시적으로 해제하는 코드는 window.URL.revokeObjectURL(createObjectURL) 이다.

 

이미지를 화면에 출력하지 않고 다운로드로 blob을 사용하고자 한다면 다운로드 클릭시에만 필요하기 때문에 처리 후 바로 해제하는 방법으로 메모리 누수를 방지할 수 있다.

 

 

참고 블로그

 

 

https://avengersrhydon1121.tistory.com/280

 

[vue.js] URL.createObjectURL 을 이용한 이미지 처리

우선 설명하기전에 Blob에 대해서 설명하고 넘어가려 한다. Blob: Binary Large Object의 줄임말로 써 이미지, 사운드 파일 같이 하나의 큰 파일을 의미하며, 이런 파일들은 특별한 방법으로 다루어야하

avengersrhydon1121.tistory.com

 

https://growth-msleeffice.tistory.com/74

 

Binary / Base64 / Blob / ArrayBuffer / File

1. Binary Binary란 이진 데이터를 의미하며 1과 0만을 사용하여 2개의 수를 나타내는 진법을 뜻하는, 컴퓨터를 다루는데 있어 가장 근본이 되는 체계라고 한다. 2. Base64 컴퓨터는 모든 데이터를 0과 1

growth-msleeffice.tistory.com

 

'Front > React' 카테고리의 다른 글

React 날짜 포맷  (0) 2024.04.14
React 페이지네이션  (1) 2024.04.14
React 이미지 다중 업로드 및 수정, 요청 전송  (0) 2024.04.13
React에서 state 배열 관리 및 반복문에서의 state  (0) 2024.04.13
React 페이지 이동  (1) 2024.04.12

게시판 프로젝트 진행중 처리했던 이미지 게시글 작성 및 수정에 대한 처리를 정리.

 

 

작성 기능 설명

1. 파일 확장자명이 유효한지 체크해야 한다.

2. 확장자명이 유효하다면 파일을 담고 미리보기를 출력해야 한다.

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이 출력된다.

 

 

ImageNewPreviewForm

 

//ImageWritePage component


//...
return (
    //...
    <div className='content' id='preview'>
        {files.map((files, index) => {
            return (
                <ImageNewPreviewForm
                    key={index}
                    files={files}
                    handleOnClick={deletePreview}
                />
            )
        })}
    </div>
    //....
)
//ImageNewPreviewForm component
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';

function ImageNewPreviewForm(props) {
    const { handleOnClick, files } = props;
    const [imgSrc, setImgSrc] = useState('');
    
    useEffect(() => {
        const url = window.URL
                    .createObjectURL(files.file);
        
        setImgSrc(url);
        
        /*
           다르게 처리하는 코드
           
           const reader = new FileReader();
           reader.onload = image => {
               setImgSrc(String(image.target.result));
           }
           reader.readAsDataURL(files.file);
        */
    }, [files]);
    
    return (
        <div className='preview-box'>
            <img src={imgSrc} alt={''} className={'thumbnail'}/>
            <p>{files.file.name}</p>
            <Link onClick={handleOnClick} value={files.fileNo}>삭제</Link>
        </div>
    )
}

 

사용자가 파일을 선택한 뒤 정상적으로 처리가 된다면 files state에 선택한 파일값이 set 될 것이고 그럼 재 렌더링이 발생해 ImageNewPreviewForm에 파일이 전달되게 된다.

이때 map으로 처리해 하나의 파일이 전달되게 되며 useEffect를 통해 파일을 처리하고 imgSrc state에 set을 해주게 된다.

그럼 img 태그의 src에서 imgSrc state를 담아주면 파일이 정상적으로 출력된다.

 

이때 두가지 방법으로 처리하는 코드를 적었는데 window.URL.createObjectURL로 처리한다면 개발자도구에서 확인하는 src는 blob.http://localhost:3000/~~~ 형태로 출력된다.

하지만 주석처리 해놓은 FileReader로 읽는 방법으로 처리한다면 src에 파일 바이너리 코드가 base64 형태의 data url로 들어가게 된다.

이 내용에 대해서는 아래 포스팅에서 따로 정리했다.

https://myyoun.tistory.com/227

 

React 이미지 처리

이전 포스팅으로 React에서 이미지 미리보기와 파일 저장 요청등의 기능을 정리했다. 이번에는 이미지 출력에 대해 어떻게 처리하는지에 대한 정리. FileReader로 처리 FileReader는 blob 또는 file과 같

myyoun.tistory.com

 

사용자가 파일을 잘못 올려 삭제하는 경우 처리할 수 있도록 deletePreview를 같이 넘겨 파일 관리와 미리 보기 삭제가 이루어지도록 한다.

 

 

사용자의 파일 삭제 요청 이벤트

위 코드에서 보면 deletePreview를 넘겨 파일을 삭제할 수 있도록 미리보기 컴포넌트에 넘겨 처리했다.

 

//ImageWritePage component

const deletePreview = (e) => {
    const deleteResultArray = deleteNewImagePreview(e, files);
    
    setFiles(deleteResultArray);
}



//module deleteNewImagePreview
export const deleteNewImagePreview = (e, files) => {
    const deleteNo = Number(e.target.getAttribute('value'));
    let arr = [...files];
    
    const delObejct = arr.find(function (item) {
        return item.fileNo === deleteNo;
    });
    
    const delIndex = arr.indexOf(delObject);
    arr.splice(delIndex, 1);
    
    return arr;
}

모듈에서 보면 deleteNo를 가져와야 한다.

삭제할 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에서 서버에 파일 바이너리 코드를 따로 요청해 받기 때문이다.

 

이 부분은 사실 구현하기 나름이라고 생각하고 애초에 이미지 파일 정보를 받을 때 바이너리 코드를 같이 받아 추가적인 요청을 안해도 될 것 같다.

첫 프로젝트이다보니 기존 스프링 프로젝트꺼랑 비슷하게 작성하다보니 이렇게 된거라..

 

//ImageDisplayElem Component

import React, { useStaet, useEffect } form 'react';
import { axiosErrorHandling, imageDisplayAxios } from '../../modules/customAxios';

function ImageDisplayElem(props) {
    const { imageClassName, imageName } = props;
    const [imageSrc, setImageSrc] = useState('');
    
    useEffect(() => {
        getImageDisplay(imageName);
    }, [imageName]);
    
    const getImageDisplay = async (imageName) => {
        await imageDisplayAxios.get(`display/${imageName}`)
            .then(res => {
                const url = window.URL
                    .createObejctURL(
                        new Blob([res.data], { type: res.headers['content-type']})
                    );
                setImageSrc(url);
            })
            .catch(err => {
                axiosErrorHandling(err);
            })
    };
    
    return (
        <>
            <img alt={''} className={imageClassName} src={imageSrc} />
        </>
    )
}

 

newPreviewForm 코드와 비교하면 axios 요청이 있다는 점도 있지만 createObjectURL 파라미터가 차이가 있다는 것을 볼 수 있다.

사실 그냥 둘다 blob 타입이기 때문에 동일하게 처리되는 것이긴 하지만..

 

그리고 이렇게 img 태그 하나만 렌더링되는 컴포넌트를 만든 이유는 여기 말고도 이미지 게시판 리스트나 상세 페이지에서도 동일하게 처리해야하기 때문에 분리하게 되었다.

이렇게 분리하면 각 컴포넌트에서 따로따로 display 요청을 보내지 않아도 되고 그럼 관리하기도 쉬워진다.

 

 

기존 이미지 파일의 삭제

기존 이미지 파일의 삭제는 대부분은 새로운 이미지 파일의 삭제와 비슷하다.

//기존 이미지 파일 삭제
const handleOldImageDelete = (e) => {
    const deleteImageStep = Number(e.target.getAttribute('value'));
    const imageDataArr = [...imageDataValue];
    const deleteObejct = imageDataArr.find(function (item) {
        return item.imageStep === deleteImageStep;
    });
    
    const deleteIndex = imageDataArr.indexOf(deleteObject);
    imageDataArr.splice(deleteIndex, 1);
    
    setImageDataValue(imageDataArr);
    
    const deleteFileName = deleteObject.imageName;
    setDeleteImageName( [...deleteImageName, deleteFileName] );
}

이건 여기서만 딱 사용하기 때문에 따로 모듈화를 하진 않았다.

새로운 이미지 파일 삭제와 같이 처리하도록 모듈화를 하기에도 조금 애매한 부분이 있었고..

setImageDataValue() 처리까지는 새로운 이미지 삭제 처리와 동일하게 처리했다.

 

그 후 서버에 삭제할 파일명을 보내줘야 했기 때문에 deleteImageName state에 위에서 찾은 객체의 imageName을 추가할 수 있도록 했다.

그리고 여기서 역시 기존 데이터가 날아가면 안되기 때문에 전개 연산자를 통해 state의 기존 값을 먼저 담도록 했다.

 

 

작성 버튼 클릭 이벤트 발생 시 FormData에 데이터 담기

submit 이벤트가 발생했을 때 FormData에 게시글 정보와 파일을 담은 뒤 axios 요청을 보내게 된다.

formData에 데이터를 담는 것 역시 모듈화해서 처리했다.

 

//module. setFormData
export const setFormData = (values, files) => {
    let FormData = new FormData();
    
    formData.append('imageTitle', values.title);
    formData.append('imageContent', values.content);
    files.forEach(file => formData.append('files', file.file));
    
    return formData;
}



//update component handleSubmit
const handleSubmit = async (e) => {
    e.preventDefault();
    
    let formData = setFormData(values, files);
    deleteImageName.forEach(
        fileName => formData.append('deleteFiles', fileName)
    );
    
    //axios 요청
}

기본적으로 작성과 수정 모두 제목, 내용, 새로 등록할 파일을 formData에 담아야 한다.

그래서 모듈화를 통해 이 세가지 데이터를 formData에 담도록 처리하고 파라미터로 values state와 files state를 받도록 했다.

 

수정의 경우 추가적으로 기존 파일의 삭제 데이터를 담아야 하기 때문에 수정 컴포넌트에서는 setFormData 모듈 호출 이후 deleteImageName state를 담도록 처리했다.

 

 

axios 설정

그럼 이제 모든 기능 구현이 끝났다.

사용자가 선택한 파일 관리 및 미리보기, 삭제 버튼 클릭으로 인한 미리보기 삭제 및 파일 관리, 수정 페이지에서 기존 데이터 출력 및 관리 formData 추가까지.

 

프로젝트를 진행하면서 axios 역시 모듈화를 진행했는데 image 관련해서는 3개의 모듈을 만들게 되었다.

단순 게시글 데이터를 가져오는 모듈, post 또는 patch 요청 모듈, display 요청 모듈.

 

//axios module

const default_header = {
    'Content-Type' : 'application/json',
};


//기본 이미지 게시판 관련 axios
export const imageAxios = axios.create({
    baseURL: `${default_url}${image_default}`,
    headers: default_header,
    withCredentials: true,
});

//이미지 파일 전송(post, patch) axios
export const imageInsertAxios = axios.create({
    baseURL: `${default_url}${image_default}`,
    headers: {
        'Content-Type' : 'multipart/form-data',
    },
    withCredentials: true,
});

//display 요청
export const imageDisplayAxios = axios.create({
    baseURL: `${default_url}${image_default}`,
    headers: default_header,
    withCredentials: true,
    responseType: 'blob',
});

 

기본적인 요청에 대해서는 content-type을 application/json으로 설정해 보내면 되지만 파일을 담아 보내는 경우에는 multipart/form-data로 설정해줘야 한다.

그리고 display 요청에 대해서는 파일 바이너리 코드를 받는 것이기 때문에 responseType이 blob이어야 정상적으로 받을 수 있게 된다.

 

'Front > React' 카테고리의 다른 글

React 페이지네이션  (1) 2024.04.14
React 이미지 처리  (0) 2024.04.14
React에서 state 배열 관리 및 반복문에서의 state  (0) 2024.04.13
React 페이지 이동  (1) 2024.04.12
React useParams(), useSearchParams()  (1) 2024.04.12

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에 담아줄 때 반복문을 통해서 처리하면 되겠다는 생각을 했었다.

const [files, setFiles] = useState([]);

const handleInputOnChange = (e) => { 
    //[ {file1}, {file2}, {file3}]
    const fileList = e.target.files;
    
    for(let i = 0; i < fileList.length; i++){
        setFiles([...files, fileList[i]]);
    }
}

 

이 처리가 완료된 후 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에 넣어주도록 해야 한다.

 

공식 문서에서도 반복문, 조건문, 중첩된 함수내에서는 hook을 호출하지 말라고 한다.

'Front > React' 카테고리의 다른 글

React 이미지 처리  (0) 2024.04.14
React 이미지 다중 업로드 및 수정, 요청 전송  (0) 2024.04.13
React 페이지 이동  (1) 2024.04.12
React useParams(), useSearchParams()  (1) 2024.04.12
React에서 .env 사용  (0) 2024.04.11

프로젝트를 진행하면서 페이지 전환을 어떻게 사용했는지에 대한 정리.

 

 

프로젝트에서는 react-router-dom을 통해 페이지 라우팅을 처리하고 있다.

대부분의 블로그를 보면 다들 react-router-dom은 기본적으로 사용하는 것 같다.

react를 배운 강의에서도 react-router-dom을 사용했고 그로 인해 자연스럽게 useNavigate를 통해 페이지 전환을 처리했다.

 

react에서 페이지 전환은 몇가지 방법이 있었다.

1. useNavigate

2. Link

3. a Tag

4. window.location.href

 

그외 연관될만한 것으로는 ESLint를 사용했다.

따로 ESLint에 대한 설정을 하진 않았고 기본 상태 그대로 사용했다.

 

 

useNavigate

useNavigate는 react-router V6에서 새로 추가된 hook이다.

이전 버전까지는 useHistory가 해당 기능을 담당했다고 한다.

useNavigate는 react-router-dom을 통해 import 한 뒤 사용하게 되며 이벤트 핸들링에서 페이지 전환을 하도록 하거나 이벤트 액션으로 바로 전달할 수 있다.

하지만 클릭 이벤트에 대한 처리로 useNavigate를 바로 처리하는 것 보다는 Link 를 사용하는 것이 좋고, 핸들링을 통해 이벤트 로직 처리 후 페이지 전환을 하는 경우에 useNavigate를 사용한다고 한다.

 

사용 코드

import React from 'react';
import { useNavigate } from 'react-router-dom';
//...

function BoardPage() {
    //...
    const navigate = useNavigate();
    //...
    
    const handleOnClick = (e) => {
        //event handling code
        naviagte('/');        
    }
    
    return (
        <div>
            <BoardChildComponent
                data={data}
                onClickBtn={() => {
                    navigate('/board/insert')
                }}
            />
        </div>
    )
}

 

위 코드같은 방법으로 사용할 수 있다.

이벤트 핸들링 이후 페이지 전환을 처리하기 위해 사용하거나 하위 컴포넌트에 넘겨줘서 사용할 수 있도록 하는 방법이 있다.

useNavigate는 V6가 되면서 useHistory가 변화한 것인 만큼 useHistory에서 사용하던 window의 history를 이용한 navigate 기능도 가능하다.

간단하게 navigate(-1)을 하면 뒤로가기가 된다거나 하는 기능을 처리할 수 있다.

 

 

Link

다음은 Link 다.

기본적인 태그처럼 사용하게 되는데 Link 역시 react-router 에서 제공한다.

사용하는 것은 a 태그와 비슷하게 사용이 가능하다.

a 태그에서 href를 Link에서는 to 속성이 담당한다.

 

사용코드

import { Link } from 'react-router-dom';

//...

return (
    <Link to={'/'}>메인</Link>
    <Link onClick={handleOnClick}>onClickEvent</Link>
)

 

Link는 위와 같이 사용하게 되며 to 를 통해 직접 url을 작성해 이동하거나 onClick을 통해 핸들링을 하도록 할 수도 있다.

 

 

a Tag

이건 솔직히 react에서만 사용하는 것은 아니고 html 문법이니 정리할지 말지 고민했지만 정리한다.

href를 통해 url을 작성해준다.

 

사용코드

<a href={`/board/${board.boardNo}`})>{board.boardTitle}</a>
<a href={'#') onClick={handleOnClick}>{board.boardTitle}</a>

 

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 태그 이동처럼 전체 페이지가 렌더링 된다고 한다.

현재 프로젝트에서 사용하는 경우는 에러 핸들링에서 오류 페이지로 전환에 사용했기 때문에 전체 렌더링이 된다고 해도 딱히 뭐가 없어서 문제가 되진 않지만 그렇지 않은 경우에는 어떤 방법으로 해야할지 테스트와 경험이 필요할 것 같다.

페이지 접근시 URL 파라미터를 사용해야 하는 경우가 있다.

이 경우 해당 파라미터를 어떻게 받아오고 어떻게 설정해야 하는지에 대해 정리한다.

 

 

react-router

어떻게 받을지 알기 전에 react-router에 대해 알아야 한다.

routing이라는 것은 기본적으로 네트워크에서 경로를 선택하는 프로세스를 의미한다.

즉, 다양한 주소의 요청이 들어오는 것을 각각에 맞는 컴포넌트로 이동시켜 주는 작업이라고 볼 수 있다.

 

리액트에서 이 router를 사용하기 위해 react-router-dom을 사용했다.

리액트로 생성된 SPA 내부에서 페이지 이동이 가능하도록 만들어주는 라이브러리이다.

 

설치

npm install react-router-dom

 

가장 기본적인 세팅은 두가지 방법으로 하는것 같았다.

말이 두가지 방법이지 동일한데 어디에 쓰느냐의 차이만 있다.

//1. index.js에 작성

//index.js
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
//...

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
    <>
        <BrowserRouter>
            <App />
        </BrowserRouter>
    </>
);


//App.js
import React from 'react';
import { Routes, Route } from 'react-router-dom';
//...

//import component
//...

function App() {

    return (
        <Routes>
            <Route index element={<BoardPage />} />
            <Route path='?pageNum=:pageNum' element={<BoardPage />} />
            //...
        </Routes>
    )
}




//App.js에 작성

//index.js
import React from 'react';
//...

const root ReactDOM.createRoot(document.getElementById('root'));

root.render(
    <>
        <App />
    </>
)


//App.js
import React from 'react';
import {
    BrowserRouter,
    Routes,
    Route
} from 'react-router-dom';
//...

//import component
//...

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 />} 
                />
                <Route path='board/:boardNo' element={<BoardDetailPage />} />
                //...
            </Routes>
        </BrowserRouter>
    )
}

 

위 코드에서 볼 수 있듯이 포인트는 <BrowserRouter>로 App.js의 내부를 감싸줘야 한다는 것이다.

 

<Routes>는 여러 Route를 감싸고 그 중 규칙이 일치하는 Route를 렌더링한다.

<Route>는 path, element 속성을 작성하는데 의미 그대로 path에는 url 경로, element 속성은 렌더링할 컴포넌트를 정의한다.

 

 

useParams와 useSearchParams

 

그럼 이제 useParams와 useSearchParams를 정리한다.

useParams는 URL 파라미터를 받는데 사용하고 useSearchParams는 쿼리 스트링을 받는데 사용한다.

여기서 값을 의미하는 것은 앞에 : 를 붙여 표시하게 된다.

위 코드에서 볼 수 있듯이 url 파라미터 값도, 쿼리스트링의 값도 : 로 처리되고 있는것을 볼 수 있다.

 

사용코드

//userParams
//localhost:3000/board/{boardNo}

import { useParams } from 'react-router-dom';
//...

function BoardDetailpage() {
    const { boardNo } = userParams();
    //...
}


//useSearchParams
//localhost:3000/
//localhost:3000?pageNum=:pageNum
//localhost:3000?keyword=:keyword&searchType=:searchType
//localhost:3000?keyword=:keyword&searchType=:searchType&pageNum=:pageNum

import { useSearchParams } from 'react-router-dom';
//...

function BoardPage() {
    const [params] = useSearchParams();
    const pageNum = params.get('pageNum') == null ? 1 : params.get('pageNum');
    const keyword = params.get('keyword');
    const searchType = params.get('searchType');
    //...
}

 

userParams와 useSearchParams는 둘다 동일하게 react-router-dom을 통해 import 한 뒤 사용할 수 있다.

 

useParams()는 url 파라미터를 받는다고 했다

쿼리스트링처럼 객체 형태의 구조가 아니기 때문에 바로 boardNo로 받을 수 있다.

 

useSearchParams는 객체 구조로 들어오고 [params]와 같은 형태로 받을 수 있다.

그리고 그 안의 값을 꺼낼 때는 마치 map 구조에서 값을 조회하듯이 꺼낼 수 있다.

useSearchParams 코드 위 주석 처리한 url을 보면 여러개의 url이 존재하는데 각각 쿼리스트링이 차이나는 것을 확인할 수 있다.

이전 Route 설정에 대한 코드에서도 볼 수 있었듯이 여러 쿼리스트링에 대해서 하나의 컴포넌트를 연결할 수 있다.

그래서 여러개의 url에 대한 컴포넌트 접근을 제어할 수 있게 된다.

그럼 아무것도 전달되지 않는 경우 모든 쿼리스트링은 존재하지 않기 때문에 값을 조회했을 때 null이 반환되게 된다.

pageNum 값의 경우 null이면 조회요청을 보내는데 문제가 생기기 때문에 삼항연산자를 통해 null인 경우 1을 담을 수 있도록 처리했고, 검색어와 검색 타입은 굳이 없어도 상관없기 때문에 null인 경우 그대로 유지하도록 처리한 코드다.

 


 

아쉬운점으로는 Route 작성을 좀 어떻게 개선할 수 없을까 하는 점이 있다.

 

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 />} 
                />
                <Route path='board/:boardNo' element={<BoardDetailPage />} />
                //...
            </Routes>
        </BrowserRouter>
    )
}

 

여러 url에 대한 처리를 위와 같이 여러개의 Route를 작성해 처리하고 있는데

이게 생각보다 좀 눈엣가시다...

하나의 Route에서 여러개의 url을 작성하고 element를 연결해주면 참 깔끔해보이겠는데.........

Route path={ [ ' ', ' ', ... ] } 형태로 v5부터 가능하다고는 하는데 안됨....ㅠㅠ

'Front > React' 카테고리의 다른 글

React에서 state 배열 관리 및 반복문에서의 state  (0) 2024.04.13
React 페이지 이동  (1) 2024.04.12
React에서 .env 사용  (0) 2024.04.11
useState 사용 정리  (0) 2024.04.11
React Axios 모듈화  (0) 2024.04.11

axios를 모듈화 하기 이전 .env를 통해 요청 url을 관리하려고 했다.

 

.env 파일은 최상위 root에 존재해야 한다.

즉, pakage.json, .gitignore와 같은 위치에 존재해야 한다.

 

리액트에서 .env 파일을 사용할 때 주의할 점이 있는데 변수명 앞에 REACT_APP_이 꼭 붙어야 한다는 점이다.

REACT_APP_이 붙어있지 않다면 해당 변수를 무시하기 때문에 사용할 수 없다.

create-react-app에서는 보안이 필요한 환경변수의 외부 유출을 방지하기 위해 이렇게 처리한다고 한다.

 

이 환경변수를 컴포넌트에서 사용할때는 import 를 통해 사용하는 것이 아닌 아래와 같은 코드로 사용한다.

const api_url = process.env.REACT_APP_URL;

 

 

.env 파일은 환경변수를 작성하는 만큼 파일이 git에 올라가면 안되기 때문에 .gitignore에 추가해야 한다. 꼭!!!!!!

 

 

참고 블로그

https://shape-coding.tistory.com/entry/React-%EB%A6%AC%EC%95%A1%ED%8A%B8%EC%97%90%EC%84%9C-env-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

 

[React] 리액트에서 .env 환경변수 사용하기!

env 사용이유 개발을 하다보면 외부로 알려지면 안되는 API_KEY나 db관련 정보 등등 보안이 필요한 값들이 있습니다. 이러한 값들을 보안이나 유지보수를 용이하게 하기 위해 .env 파일에 환경변수

shape-coding.tistory.com

 

 

'Front > React' 카테고리의 다른 글

React에서 state 배열 관리 및 반복문에서의 state  (0) 2024.04.13
React 페이지 이동  (1) 2024.04.12
React useParams(), useSearchParams()  (1) 2024.04.12
useState 사용 정리  (0) 2024.04.11
React Axios 모듈화  (0) 2024.04.11

+ Recent posts