개발/React

이미지 업로드 기능 구현

진주만두찜 2025. 5. 3. 11:27
반응형
const handleClick = () => {
  fileInputRef.current?.click();
};

이미지시연.mp4
0.77MB



이거 구현하는 법?

굵고 짧게 가겠다.

 


 

 

 

핵심 부분 간단 설명

1. 이미지 선택흐름: 

- 사용자가 영역 클릭 -> 파일 선택창 열림 -> 이미지를 Base64 문자열로 변환 -> 화면에 미리 보기 표시

 

2. 이미지 삭제 흐름:

- 삭제 버튼 클릭 -> 이미지 데이터 초기화 -> "파일 선택" 화면으로 돌아감

 

 

 

 

 

 

컴포넌트 선언

export const ImageUpload = () => {
  // 여기에 코드가 들어갑니다
};

 

 

상태와 Ref 설정

const [preview, setPreview] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

- preview: 이미지 미리보기 데이터를 저장하는 상태 변수

- fileInputRef: 숨겨진 파일 입력 요소에 접근하기 위한 참조

 

 

파일 변경 처리 함수

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (file && file.type.startsWith("image/")) {
    const reader = new FileReader();
    reader.onload = () => {
      if (typeof reader.result === "string") {
        setPreview(reader.result);
      }
    };
    reader.readAsDataURL(file);
  }
};

- e.target.files?.[0]: 선택된 첫 번째 파일을 가져온다?. 은 files가 null일 수도 있기에 안전하게 접근하기 위해서 옵셔널로 작성했다.

- fifle.type.startsWith("image/"): 파일이 이미지인지 확인한다

- FileReader: 파일을 문자열로 읽기 위한 도구

- reader.onload: 파일 읽기가 완료되면 실행될 함수

- reader.readAsDataURL(file): 파일을 Base64 인코딩된 데이터 URL로 읽기 시작한다

- setPreview(reader.result): 읽은 이미지 데이터를 상태에 preview 상태에 저장한다

 

 

 

클릭 처리 함수

const handleClick = () => {
  fileInputRef.current?.click();
};

- fileInputRef.current?.click(): 숨겨진 파일 입력 요소를 프로그래밍적으로 클릭한다

 

 

 

삭제 처리 함수

const handleDelete = (e: React.MouseEvent) => {
  e.stopPropagation();
  setPreview(null);
  if (fileInputRef.current) {
    fileInputRef.current.value = "";
  }
};

- e.stopPropagation(): 이벤트가 상위 요소로 전파되는 것을 막는다(부모의 handleClick이 실행되지 않도록)

- setPreview(null): 미리보기 이미지 제거

- fileInputRef.current.value = "": 파일 입력 요소를 초기화하여 같은 파일을 다시 선택할 수 있게 한다

 

 

 

 

렌더링 부분

return (
  <ImageWrapper onClick={handleClick}>
    {preview ? (
      <PreviewContainer>
        <PreviewImage src={preview} alt="선택된 이미지" />
        <DeleteButton onClick={handleDelete}>
          <DeleteIcon />
        </DeleteButton>
      </PreviewContainer>
    ) : (
      <PlaceholderContent>
        <IconPlus />
        <UploadText>파일 선택</UploadText>
      </PlaceholderContent>
    )}
    <HiddenInput
      ref={fileInputRef}
      type="file"
      accept="image/*"
      onChange={handleFileChange}
    />
  </ImageWrapper>
);

 

 

 

전체 동작 흐름

1. 초기 상태: 처음에는 preview가 null이라 + 아이콘과 "파일 선택" 텍스트가 보임

2. 이미지 선택:

    - 사용자가 영역을 클릭하면 -> handleClick 실행 -> 숨겨진 파일 입력 클릭 -> 파일 선택 창 열림

    - 이미지 선택하면 -> handleFileChange 실행 -> 파일 읽기 -> preview 상태 업데이트

    - preview 상태가 변경되면 -> 컴포넌트 리렌더링 -> 이미지와 삭제 버튼 표시

3. 이미지 삭제:

    - 삭제 버튼 클릭하면 -> handleDelete 실행 -> preview를 null로 설정, 파일 입력 초기화

    - preview가 null로 변경되면 -> 컴포넌트 리렌더링 -> 다시 + 아이콘과 "파일 선택" 텍스트 표시

 

 

 

 


 

 

 

전체코드

import React, { useState, useRef } from "react";
import styled from "@emotion/styled";

export const ImageUpload = () => {
  const [preview, setPreview] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file && file.type.startsWith("image/")) {
      const reader = new FileReader();
      reader.onload = () => {
        if (typeof reader.result === "string") {
          setPreview(reader.result);
        }
      };
      reader.readAsDataURL(file);
    }
  };

  const handleClick = () => {
    fileInputRef.current?.click();
  };

  const handleDelete = (e: React.MouseEvent) => {
    e.stopPropagation();
    setPreview(null);
    // 파일 입력 초기화
    if (fileInputRef.current) {
      fileInputRef.current.value = "";
    }
  };

  return (
    <ImageWrapper onClick={handleClick}>
      {preview ? (
        <PreviewContainer>
          <PreviewImage src={preview} alt="선택된 이미지" />
          <DeleteButton onClick={handleDelete}>
            <DeleteIcon />
          </DeleteButton>
        </PreviewContainer>
      ) : (
        <PlaceholderContent>
          <IconPlus />
          <UploadText>파일 선택</UploadText>
        </PlaceholderContent>
      )}
      <HiddenInput
        ref={fileInputRef}
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />
    </ImageWrapper>
  );
};

const ImageWrapper = styled.div`
  width: 200px;
  height: 200px;
  border: 2px dashed #dddddd;
  border-radius: 8px;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  overflow: hidden;
  transition: all 0.2s ease-in-out;
  position: relative;

  &:hover {
    border-color: #4dabf7;
    background-color: rgba(77, 171, 247, 0.04);
  }
`;

const HiddenInput = styled.input`
  display: none;
`;

const PreviewContainer = styled.div`
  width: 100%;
  height: 100%;
  position: relative;
`;

const PreviewImage = styled.img`
  width: 100%;
  height: 100%;
  object-fit: cover;
`;

const DeleteButton = styled.button`
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background-color: rgba(255, 255, 255, 0.8);
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
  transition: all 0.2s ease;
  z-index: 2;

  &:hover {
    background-color: rgba(255, 255, 255, 1);
    transform: scale(1.1);
  }
`;

const DeleteIcon = styled.div`
  width: 12px;
  height: 12px;
  position: relative;

  &:before,
  &:after {
    content: "";
    position: absolute;
    height: 100%;
    width: 2px;
    background-color: #f03e3e;
    top: 0;
    left: 50%;
  }

  &:before {
    transform: translateX(-50%) rotate(45deg);
  }

  &:after {
    transform: translateX(-50%) rotate(-45deg);
  }
`;

const PlaceholderContent = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  color: #868e96;
`;

const IconPlus = styled.div`
  width: 40px;
  height: 40px;
  position: relative;
  margin-bottom: 8px;

  &:before,
  &:after {
    content: "";
    position: absolute;
    background-color: #868e96;
  }

  &:before {
    width: 2px;
    height: 100%;
    left: 50%;
    transform: translateX(-50%);
  }

  &:after {
    height: 2px;
    width: 100%;
    top: 50%;
    transform: translateY(-50%);
  }
`;

const UploadText = styled.p`
  margin: 0;
  font-size: 14px;
  font-weight: 500;
`;
반응형