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

 

 

작성 기능 설명

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

+ Recent posts