본문 바로가기
Spring

파일 업로드, 다운로드, 이미지 미리보기 구현(Spring boot With React)

by khds 2024. 5. 30.

 

들어가기

 

Spring boot로 프로젝트  도중 채팅방을 구현하면서 채팅 내용뿐만 아니라 파일을 업로드, 다운로드할 수 있도록 추가하려고 한다. 그러기 위해선 파일을 업로드 후 서버에서 파일을 보관하고, 다운로드 기능을 학습해야 한다. 

이 글에선 간단한 프로젝트로 파일 업로드, 다운로드 기능을 Spring boot, React로 구현하는 과정을 담았다.

HTTP를 통한 파일 업로드, 다운로드를 구현하였다. 업로드할 때는 MultiPartFile로 파일을 받고, 다운로드 시에는 byte[]와 같은 형태로 파일을 반환하도록 하였다.(또 다른 방법으로는 ResponseEntity<Resource>가 있다고 하지만 이 글에서는 사용하지 않았다.)

 

 

java 17, spring boot는 3.2.3를 사용하였고 데이터베이스는 h2 DB를 JPA와 함께 사용하였다.

h2 데이터베이스에 연결하는 방법은 https://khdscor.tistory.com/4를 참고하길 바란다.

 

JPA H2 데이터베이스 및 서버 실행시 자동 Insert

1. application.properties 에 아래 코드를 삽입한다. spring.h2.console.enabled=true spring.h2.console.path=/h2-console spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.dat

khdscor.tistory.com

 

파일 자체는 서버 정적 리소스(resources/static)에 저장하고, 파일 이름만을 따로 데이터 베이스에 저장하여 관리하기 쉽게 하였다. (처음 구상하였을 땐, 파일을 byte[]로 변환하여 데이터베이스에 담으려 했었는데, 데이터베이스는 비싼 저장소라 큰 파일을 담는 게 좋지 않다고 하네요... 그래서 파일을 따로 두고, 파일을 쉽게 탐색하기 위해 이름, 생성 날짜만을 가진 테이블을 생성하였습니다.)

 

목차는 아래와 같다.(클릭 시 이동)

1. 서버 파일 엔티티, 정적 리소스 설정

2. 파일 업로드(동시에 여러개 가능)

3. 파일 다운로드

4. React 전체 코드(JS, CSS)(참고)

 

아래는 간단한 시연영상이다.

 

 

본론

1. 서버 파일 엔티티, 정적 리소스 설정

 

우선 Spring boot 에 엔티티를 작성하자. 고유 아이디와 파일 이름, 생성 날짜 세 가지 필드를 가지도록 하였다.

@Getter
@NoArgsConstructor
@Entity(name = "file")
public class FileEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false, unique = true)
    private String filename;

    @Column(name = "create_date")
    private Date newDate;

    public FileEntity(String filename, Date newDate) {
        this.filename = filename;
        this.newDate = newDate;
    }
}

 

아래는 Spring Data JPA를 사용하기 위한 Repository이다.

public interface FileRepository extends JpaRepository<FileEntity, Long> {
}

 

이제 정적 리소스 설정을 해보려한다. 사실, Config 파일 설정 없이 서버 주소에 특정 파일(ex. http://localhost:8080/테스트 문서.txt)를 입력하면 프로젝트 내 'src/main/resource/static/테스트 문서.txt'로 접근이 가능하다고 한다. 하지만 계속 접근을 시도했는데도 되지가 않았다...

여기서 해결한 방법은 Config 파일을 통해 경로를 명확히 하는 것이었다. 아래의 코드를 봐보자.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Value("${file.path}")
    private String filepath;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/file/**")
            .addResourceLocations("file:" + filepath);
    }
}


//application.properties
file.path=src/main/resources/static/files/

 

WebMvcConfigurer를 구현하여 addResouceHandlers()를 통해 경로를 명확히 지정하였다. addResourceHandler()를 통해 url를 지정하고(/**로 하여 기본 주소로 할 수 있지만 file/을 추가하였다. ex.  http://localhost:8080/file/테스트 문서.txt ) addResourceLocations()를 통해 정적 리소스 경로를 지정하였다. @Value를 통해 application.properties에 작성한 경로(src/main/resources/static/files/)를 filepath로 변수화하였고, url 접근 시 해당 경로로 접근되도록 하였다. 이렇게 직접 경로를 설정하여 접근이 되지 않던 문제가 해결되었다. 

 

마지막으로 'CORS' 문제를 해결하기 위해 아래와 같은 코드를 추가하였다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private static final String DEVELOP_FRONT_ADDRESS = "http://localhost:3000";

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins(DEVELOP_FRONT_ADDRESS)
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .exposedHeaders("location")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

 

CORS에 대해 궁금한 사항은 https://khdscor.tistory.com/64를 참고하기 바란다.

 

백엔드, 프론트 서버를 연결할 때: 'CORS' 문제 및 해결법

나는 백엔드를 spring 프레임워크로, 프론트를 react 프레임워크로 개발을 할 때 가장 처음 직면한 문제가 있었다. 그것은 바로 'CORS' 문제였다. 아마 대부분의 개발자들이 프론트 담당이든 백엔드

khdscor.tistory.com

 

 

2. 파일 업로드(동시에 여러개 가능)

 

우선 파일 업로드할 시 주된 내용을 React를 사용하여 작성한 코드이다.(return 내부 일부 코드) 크게 세 부분으로 나뉜다. 

import React, {useState} from 'react';
import { Link } from 'react-router-dom';
import './File.css';
import axios from "axios";

...

//return()
{/* 업로드할 파일 선택, 여러개 가능 */}
<input
    type="file"
    id="fileInput"
    onChange={handleChangeFile}
    multiple
    style={{ display: 'none' }}
/>
<button onClick={handleButtonClick} className="button">
    파일 선택
</button>

{/* 선택한 파일 이름 확인 */}
{files && (files.length >= 1 ) ? (
    <div className="file-list">
    <ul>
        {Array.from(files).map((file, index) => (
        <li key={index}>{file.name}</li>
        ))}
    </ul>
    <button onClick={handleClearFiles} className="button">
        선택된 파일 지우기
    </button>
    </div>
) : (
    <p>선택된 파일 없음</p>
)}

{/* 파일 업로드 버튼 */}
<button onClick={uploadFile} className="upload-button">
    파일 업로드
</button>

 

필요한 변수는 파일을 담기 위한 변수 1개만 필요하다. 

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

 

우선 첫번째 부분이다.

{/* 업로드할 파일 선택, 여러개 가능 */}
<input
    type="file"
    id="fileInput"
    onChange={handleChangeFile}
    multiple
    style={{ display: 'none' }}
/>
<button onClick={handleButtonClick} className="button">
    파일 선택
</button>

 

'파일 선택' 버튼을 클릭하면 handleButtonClick()가 실행되며 "fileInput" id를 가진 input 태그가 클릭된다. 타입을 file로 설정하였고, multiple 옵션을 통해 여러 개의 파일을 넣을 수 있다. 

const handleButtonClick = () => {
    document.getElementById('fileInput').click();
};

 

이후 input 태그로 인해 파일이 선택 시 onChange가 반응하여 handleChangeFile()가 실행된다. 단순히 setFiles()를 통해 업로드를 위해 선택한 파일을 변수에 저장한다. (만약 다시 파일들을 등록하면 새로 등록한 파일들만 유지된다.)

const handleChangeFile = (event) => {
    setFiles(event.target.files);
};

 

이렇게 로컬 저장소에서 업로드할 파일을 변수해 저장하였다.

이제 저장한 변수들을 화면에 노출시킬 것이다. 아래와 같이 로직을 구성하였고, 파일 이름을 map을 통해 사용자에게 보여주도록 하였다. 

{/* 선택한 파일 이름 확인 */}
{files && (files.length >= 1 ) ? (
    <div className="file-list">
    <ul>
        {Array.from(files).map((file, index) => (
        <li key={index}>{file.name}</li>
        ))}
    </ul>
    <button onClick={handleClearFiles} className="button">
        선택된 파일 지우기
    </button>
    </div>
) : (
    <p>선택된 파일 없음</p>
)}

 

'선택된 파일 지우기' 버튼을 클릭 시 handleClearFiles()가 실행되어 파일들을 모두 제거한다.

const handleClearFiles = () => {
    setFiles(null);
};

 

마지막으로 파일 업로드 부분이다. 

{/* 파일 업로드 버튼 */}
<button onClick={uploadFile} className="upload-button">
    파일 업로드
</button>

 

'파일 업로드' 버튼 클릭 시 uploadFile()가 실행된다. 세부적인 부분은 주석을 작성하였으니 참고하길 바란다.

const uploadFile = () => {
    //파일이 한 개 이상 있을 경우
    if(files){
        const body = new FormData();
        // Post 요청에 함께 보낼 Body 작성(입력한 파일 추가)
        for(let i=0 ; i< files.length ; i++) {
            body.append("file", files[i]);
        }
        // 서버(Spring boot)로 요청(업로드)
        axios.post("http://localhost:8080/file/upload", body)
            .then(response => {
                if(response.data) {
                    setFiles(null);
                    alert("업로드 완료!");
                }
            })
            .catch((error) => {
                alert("실패!");
            })
    } 
    // 입력한 파일이 한 개도 없을 경우 
    else{
        alert("파일을 1개 이상 넣어주세요!")
    }
}

 

핵심 로직은 axios를 통해 입력한 파일들을 body에 담고 Spring boot로 POST 요청을 보내는 것이다. 이 요청을 통해 입력한 파일들을 서버에 저장하도록 하였다. 

그렇다면 서버(Spring boot)에선 어떻게 코드가 작성되어 있을까? 한 번 봐보도록 하자.

 

아래와 같이 Post 요청을 Controller에 구현하였다.

@Value("${file.upload.path}")
private String filepath;    // application.properties에 작성, file.upload.path=C:/Users/~개인 폴더 정보~/src/main/resources/static/files/

private final FileRepository fileRepository;

@PostMapping("/upload")
public ResponseEntity<List<FileEntity>> uploadFile(@RequestParam(value = "file") MultipartFile[] files) {
    List<FileEntity> entities = new ArrayList<>();
    // 서버에 파일을 저장하는 메인 로직
    for (MultipartFile file : files) {
        // 파일 이름을 생성, 중복 시 번호 증가 ex. (1), (2)...
        String fileName = generateFileName(Objects.requireNonNull(file.getOriginalFilename()));
        // 서버 정적 저장소에 파일 저장
        saveFile(file, fileName);
        // DB에 저장할 파일 담기
        entities.add(new FileEntity(fileName, new Date()));
    }
    // DB에 저장
    return ResponseEntity.ok().body(fileRepository.saveAll(entities));
}

 

RequestParam으로 프론트 서버로부터 파일들을 받았다!

이제 파일 하나하나 for문을 통해 다뤄 보자.

 

우선  generateFileName() 메서드를 통해 입력받은 파일이 이미 서버에 있는지 확인하고, 이름이 중복이 되지 않도록 번호를 부여하여 이름을 갱신할 것이다. 예를 들면 입력파일로 '테스트.txt'가 왔다면 '테스트(1).txt'로, 이 이름도 있다면 '테스트(2).txt'로 숫자를 증가시켜 이름을 갱신한다. 

 

그렇다면 주요 로직은 어떻게 동작하는 것일까? 아래를 봐보자.

private String generateFileName(String originalFileName) {
    int lastIndexOfDot = originalFileName.lastIndexOf(".");
    String name = originalFileName.substring(0, lastIndexOfDot);
    // 확장자
    String extension = originalFileName.substring(lastIndexOfDot);
    int fileNumber = 1;
    // 이름 중복 시 증가할 순번
    String fileSequence = "";
    while (new File(filepath + name + fileSequence + extension).exists()) {
        fileSequence = "(" + fileNumber + ")";
        fileNumber++;
    }
    return name + fileSequence + extension;
}

 

우선 lastIndexOF(".")을 통해 파일 이름과 확작자를 분리한다. 그리고 증가시킬 파일 번호를 지정한 후에, while문으로 중복된 이름일 경우 파일 번호를 증가시켜 계속 비교하도록 작성하였다. 

 

generateFileName()를 통해 이름을 중복이 되지 않도록 갱신하였으면, saveFile()을 통해 파일을 서버에 저장할 것이다. 아래는 saveFile() 로직이다.

private void saveFile(MultipartFile file, String fileName) {
    File targetFile = new File(filepath + fileName);
    try {
        file.transferTo(targetFile);
    } catch (IOException e) {
        throw new RuntimeException("File not saved");
    }
}

 

new File()을 통해 정적 리소스 경로와 파일 이름을 합쳐 파일이 어디에 저장할지를 지정하였다. 

이후 file.transferTo() 메서드를 통해 실제로 서버 내부에 지정한 경로에 파일을 저장이 된다. 

 

saveFile()이 끝나고 마지막으로 entities.add()를 통해 DB에 저장할 파일 리스트에 FileEntity의 형태로 저장한다. 

 

for문이 끝난 후 fileRepository.saveAll()을 통해 데이터베이스에 파일 이름을 담은 리스트를 저장한다.(사실 대량의 파일을 saveAll()로 저장하면 bulk Insert가 이뤄지지 않고 for문으로 insert가 진행되어 성능상 문제가 될 수 있습니다. 이에 대한 문제는 https://pidgey.tistory.com/23를 참고하길 바랍니다.)

 

[Spring Data JPA] saveAll은 만능일까?

개발 환경 필자의 개발 환경에 맞추어 포스팅되었으니 참고 바랍니다. Java / Gradle Spring Boot 2.7.8 Spring Data JPA MySQL 8.0.24 개요 이번에 간단한 팀 프로젝트를 진행하면서 Spring Data JPA를 사용했습니다.

pidgey.tistory.com

 

아래와 같이 데이터가 잘 저장된 것을 확인할 수 있다.

 

 

3. 파일 다운로드

 

다운로드를 위한 페이지에 들어왔을 시 파일 별로 이름과 이미지, 다운로드 버튼을 노출시키도록 구성하였다. 만약 파일이 이미지 파일 이라면 해당 이미지를, 이미지 파일이 아니라면 프론트 서버에 저장된 기본 이미지를 보여주도록 하였다.

 

우선 React 코드를 봐보자. 

import React, {useState, useEffect} from 'react';
import axios from "axios";
import './ReadFile.css';

...

return (
    <div className="file-list-container">
        <h2 className="title">파일 목록</h2>
        <div className="file-list">
            {/* 서버에 업로드된 파일들을 가져온 후 화면에 표시(이름, 이미지) */}
            {files ? files.map((item) => (
            <div key={item.pid} className="file-item">
                <img
                // src는 정적 리소스 경로
                src={`http://localhost:8080/file/${item.filename}`}
                alt={`img${item.filename}`}
                className="file-image"
                // 이미지 파일이 아닐 시 기본 이미지로 표시
                onError={(e) => {
                    e.target.src = "/noImage.png"; 
                }}
                />
                <p className="file-name">{item.filename}</p>
                {/* 파일 별 다운로드 버튼 노출, 클릭 시 다운로드 진행 */}
                <button onClick={() => downloadFile(item.filename)} className="button download-button">
                다운로드
                </button>
            </div>
            )) : <p>파일이 없습니다.</p>}
        </div>
    </div>
);

 

크게 두 부분으로 나뉜다. (주석)

첫번째 부분은 서버에 업로드된 파일들을 화면에 노출시키는 부분이다. 해당 페이지에 접근할 시 readFile()를 실행하여 서버로부터 업로드된 파일들을 모두 가져온다.

const [files, setFiles] = useState(null);
useEffect(() => {
    readFiles();
}, [])

const readFiles = async () => {
    return axios.get("http://localhost:8080/file/find/all" )
       .then(response => {
            setFiles(response.data);
    });
}

 

Spring boot에서 해당 url을 가진 메서드는 아래와 같다. 데이터베이스에 저장된 모든 FileEntity를 가져온다.

@GetMapping("find/all")
public ResponseEntity<List<FileEntity>> findFiles() {
    return ResponseEntity.ok().body(fileRepository.findAll());
}

 

이후 files.map()을 통해 파일 별로 이미지, 파일 이름, 다운로드 버튼을 구성한다. 

이미지 태그를 봐보자.

<img
    // src는 정적 리소스 경로
    src={`http://localhost:8080/file/${item.filename}`}
    alt={`img${item.filename}`}
    className="file-image"
    // 이미지 파일이 아닐 시 기본 이미지로 표시
    onError={(e) => {
        e.target.src = "/noImage.png"; 
    }}
/>

 

src 부분에 서버의 정적 리소스 경로를 지정해 주면 이를 img 태그의 이미지에 반영이 된다. 정적 리소스 설정은 본론 처음에 설명을 하였다.

하지만 만약 이미지 파일이 아니라면 에러가 발생할 것이다. 이를 대비하여 onError를 설정하였다. 이미지 파일이 아니라서 에러가 발생하면 src를 프론트 서버에 저장한 기본 이미지로 설정되도록 하였다.


마지막으로 확인해볼 사항은 다운로드 버튼이다.

{/* 파일 별 다운로드 버튼 노출, 클릭 시 다운로드 진행 */}
<button onClick={() => downloadFile(item.filename)} className="button download-button">
다운로드
</button>

 

다운로드 버튼을 클릭 시 해당 파일 이름을 매개 변수로 downloadFile() 메서드가 실행되도록 하였다.

const downloadFile =  (filename) => {
    const url = `http://localhost:8080/file/download/${filename}`;

    // 파일 다운로드 링크 생성
    const downloadLink = document.createElement('a');
    downloadLink.href = url;
    downloadLink.setAttribute('download', filename);
    downloadLink.click();
}

 

이는 링크를 생성하여 클릭 이벤트를 트리거하는 방식으로 파일을 다운로드한다.

a 태그로 서버의 다운로드 url을 주소로 갖도록 하여 클릭을 유도함으로써 서버의 해당 url로 요청을 보내도록 하였다.  이후 서버에선 요청을 받고, 클릭한 사용자가 바로 다운로드를 실시하도록 구현하였다.

 

그렇다면 서버에선 어떻게 코드가 작성되었을까? 

Spring boot Controller 단에 구현한 다운로드 메서드는 아래와 같다.

@GetMapping("/download/{fileName}")
    public ResponseEntity<byte[]> downloadFile(@PathVariable String fileName) {
        // 파일 읽어오기
        byte[] fileContent = readFileContent(fileName);
        // 파일 인코딩
        String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
        // 파일 다운로드 응답
        return createResponseEntity(encodedFileName, fileContent);
    }

 

크게 3부분으로 나눈다. 

첫 번째로 파일 읽어오는 readFileContent()이다. 단순히 filepath(미리 지정한 경로)과 fileName을 통해 byte[] 타입으로 파일을 읽어온다.

private byte[] readFileContent(String fileName) {
        Path filePath = Paths.get(filepath).resolve(fileName).normalize();
        try {
            return Files.readAllBytes(filePath);
        } catch (IOException e) {
            throw new RuntimeException("Failed to read file", e);
        }
    }

 

두 번째 부분으로 파일 이름을 인코딩해주고, 세 번째 메서드 실행 시 인코딩 된 파일 이름을 사용한다.

처음에는 인코딩하지 않고 실행하였더니 아래와 같은 에러가 발생했다.

java.lang.IllegalArgumentException: The Unicode character [김] at code point [44,608] cannot be encoded as it is outside the permitted range of 0 to 255

 

이 에러는 주어진 유니코드 문자(여기서는 "김"이라는 한글 문자)가 특정 인코딩 체계에서 표현할 수 없기 때문에 발생한 것이라고 한다. 찾아보니 일반적으로 파일이나 네트워크 데이터를 처리할 때 인코딩 과정을 거친다고 한다.

그래서 위 코드 상에서도 인코딩 과정을 덧붙였다. 

 

세 번째 부분으로 파일 다운로드를 응답한다. createResponseEntity() 메서드를 봐보자.

private ResponseEntity<byte[]> createResponseEntity(String fileName, byte[] fileContent) {
    String cleanFileName = StringUtils.cleanPath(fileName);
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
    headers.setContentDispositionFormData("attachment", cleanFileName);
    return ResponseEntity.ok()
        .headers(headers)
        .body(fileContent);
}

 

우선 첫번 째 줄이다.

String cleanFileName = StringUtils.cleanPath(fileName);

 

주어진 파일 이름을 안전하게 정리하기 위해 StringUtils.cleanPath() 메서드를 사용한다. 이 메서드는 경로 순회를 방지하고 파일 이름에 있는 불필요한 경로 요소들을 제거해 준다.

 

그다음 헤더 생성 부분이다.

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDispositionFormData("attachment", cleanFileName);

 

새로운 HttpHeaders 객체를 생성한다. 이 객체는 HTTP 응답의 헤더를 설정하는 데 사용된다.

헤더의 콘텐츠 유형을 APPLICATION_OCTET_STREAM으로 설정한다. 이 콘텐츠 유형은 일반적으로 바이너리 데이터 파일을 전송할 때 사용된다.

여기에 추가로 Content-Disposition 헤더를 설정하여 파일이 첨부 파일로 다운로드되도록 한다. 즉, 프론트 서버에서 url을 요청 시 응답하는 바이너리 데이터 파일을 사용자가 바로 다운로드되도록 설정하는 것이다.(프론트 서버에서 별도의 작업 x). 이 헤더는 파일 이름을 지정하며, 이를 통해 브라우저는 파일을 다운로드할 때 사용자에게 제공할 파일 이름을 인식하게 된다.

 

이후 헤더와 응답 데이터를 반환한다.

return ResponseEntity.ok()
    .headers(headers)
    .body(fileContent);

 

이렇게 React와 Spring boot를 설정하여 파일 업로드와 파일 다운로드를 구현하였다!

 

 

4. React 전체 코드(JS, CSS)(참고)

 

아래는 React에서 구성한 JS, CSS 파일이다.(참고)

 

File.js

import React, {useState} from 'react';
import { Link } from 'react-router-dom';
import './File.css';
import axios from "axios";

function File() {
    const [files, setFiles] = useState(null);

    const handleChangeFile = (event) => {
        setFiles(event.target.files);
    };
    
    const handleButtonClick = () => {
        document.getElementById('fileInput').click();
    };

    const handleClearFiles = () => {
        setFiles(null);
    };

    const uploadFile = () => {
        //파일이 한 개 이상 있을 경우
        if(files){
            const body = new FormData();
            // Post 요청에 함께 보낼 Body 작성(입력한 파일 추가)
            for(let i=0 ; i< files.length ; i++) {
                body.append("file", files[i]);
            }
            // 서버(Spring boot)로 요청(업로드)
            axios.post("http://localhost:8080/file/upload", body)
                .then(response => {
                    if(response.data) {
                        setFiles(null);
                        alert("업로드 완료!");
                    }
                })
                .catch((error) => {
                    alert("실패!");
                })
        } 
        // 입력한 파일이 한 개도 없을 경우 
        else{
            alert("파일을 1개 이상 넣어주세요!")
        }
    }


    return (
        <div className="container">
            {/* 이전에 업로드했던 파일들 확인 */}
            <Link to={`/readfile`} className="link">
                <h2 className="heading">업로드한 파일 보기</h2>
            </Link>
            {/* 업로드할 파일 선택, 여러개 가능 */}
            <input
                type="file"
                id="fileInput"
                onChange={handleChangeFile}
                multiple
                style={{ display: 'none' }}
            />
            <button onClick={handleButtonClick} className="button">
                파일 선택
            </button>
            {/* 선택한 파일 이름 확인 */}
            {files && (files.length >= 1 ) ? (
                <div className="file-list">
                <ul>
                    {Array.from(files).map((file, index) => (
                    <li key={index}>{file.name}</li>
                    ))}
                </ul>
                <button onClick={handleClearFiles} className="button">
                    선택된 파일 지우기
                </button>
                </div>
            ) : (
                <p>선택된 파일 없음</p>
            )}
            {/* 파일 업로드 버튼 */}
            <button onClick={uploadFile} className="upload-button">
                파일 업로드
            </button>
        </div>
    );
}

export default File;

 

File.css

body {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    background-color: #f5f5f5;
  }
  
  .container {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 20px;
    background-color: white;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    max-width: 500px;
    margin: 20px auto;
  }
  
  .link {
    text-decoration: none;
    color: #3f51b5;
  }
  
  .heading {
    margin: 0 0 20px 0;
  }
  
  .button {
    background-color: #3f51b5;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 4px;
    cursor: pointer;
    margin: 10px 0;
  }
  
  .button:hover {
    background-color: #303f9f;
  }
  
  .upload-button {
    border: 2px solid black;
    padding: 10px 20px;
    border-radius: 4px;
    cursor: pointer;
    margin-top: 20px;
  }
  
  .file-list {
    text-align: left;
    width: 100%;
  }
  
  .file-list ul {
    padding: 0;
  }
  
  .file-list li {
    list-style-type: none;
    padding: 5px 0;
  }

 

ReadFile.js

import React, {useState, useEffect} from 'react';
import axios from "axios";
import './ReadFile.css';

function File() {
    const [files, setFiles] = useState(null);
    useEffect(() => {
        readFiles();
    }, [])
    
    const readFiles = async () => {
        return axios.get("http://localhost:8080/file/find/all" )
           .then(response => {
                setFiles(response.data);
        });
    }

    const downloadFile =  (filename) => {
    const url = `http://localhost:8080/file/download/${filename}`;

    // 파일 다운로드 링크 생성
    const downloadLink = document.createElement('a');
    downloadLink.href = url;
    downloadLink.setAttribute('download', filename);
    downloadLink.click();
    }
    
    return (
        <div className="file-list-container">
            <h2 className="title">파일 목록</h2>
            <div className="file-list">
                {/* 서버에 업로드된 파일들을 가져온 후 화면에 표시(이름, 이미지) */}
                {files ? files.map((item) => (
                <div key={item.pid} className="file-item">
                    <img
                    // src는 정적 리소스 경로
                    src={`http://localhost:8080/file/${item.filename}`}
                    alt={`img${item.filename}`}
                    className="file-image"
                    // 이미지 파일이 아닐 시 기본 이미지로 표시
                    onError={(e) => {
                        e.target.src = "/noImage.png"; 
                    }}
                    />
                    <p className="file-name">{item.filename}</p>
                    {/* 파일 별 다운로드 버튼 노출, 클릭 시 다운로드 진행 */}
                    <button onClick={() => downloadFile(item.filename)} className="button download-button">
                    다운로드
                    </button>
                </div>
                )) : <p>파일이 없습니다.</p>}
            </div>
        </div>
    );
}

export default File;

 

ReadFile.css

body {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    background-color: #f5f5f5;
  }
  
  .file-list-container {
    max-width: 800px;
    margin: 0 auto;
    background-color: #fff;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  }
  
  .title {
    text-align: center;
    color: #333;
  }
  
  .file-list {
    display: flex;
    flex-wrap: wrap;
    gap: 20px;
  }
  
  .file-item {
    background-color: #f1f1f1;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
    text-align: center;
    width: 200px;
  }
  
  .file-image {
    width: 100%;
    height: 250px;
    object-fit: cover;
    border-radius: 4px;
  }
  
  .file-name {
    margin: 10px 0;
    color: #555;
  }
  
  .button {
    background-color: #3f51b5;
    color: white;
    border: none;
    padding: 8px 16px;
    border-radius: 4px;
    cursor: pointer;
  }
  
  .button:hover {
    background-color: #303f9f;
  }
  
  .download-button {
    margin-bottom: -40px;
  }

 

 

결론

 

이렇게 간단하게 MultipartFile[], byte[] 타입으로 파일을 업로드, 다운로드를 해보았다.

생각했던 것보다 헤매는 부분이 많았고, 이해하는데도 오래 걸렸지만, 코드를 완성하니 뿌듯하였다.

이와 다른 방식으로 파일을 업로드, 다운로드할 수 있는데, 바로 FTP 프로토콜을 이용하는 방법이다.

HTTP를 주로 이용하는 Spring boot 서버와 별도로 FTP 서버를 두고 파일 업로드, 다운로드를 하는 것인데, 대용량 파일 전송에 적합하며, FTP 프로토콜 자체가 다양한 클라이언트와 서버 간의 호환성이 높다고 한다.

즉, 대용량 파일 관리 혹은 서버 부하를 분산시킬 때 FTP를 사용한다고 한다.

다음에는 FTP를 통해 파일 처리를 한번 적용해 봐야겠다.

 

잘못된 부분이나 궁금한 사항은 댓글로 남겨주시면 감사합니다!

 

 

참고

 

https://pidgey.tistory.com/23

 

[Spring Data JPA] saveAll은 만능일까?

개발 환경 필자의 개발 환경에 맞추어 포스팅되었으니 참고 바랍니다. Java / Gradle Spring Boot 2.7.8 Spring Data JPA MySQL 8.0.24 개요 이번에 간단한 팀 프로젝트를 진행하면서 Spring Data JPA를 사용했습니다.

pidgey.tistory.com

 

https://g4daclom.tistory.com/77

 

정적 리소스 경로 설정해주기, 로컬에 저장된 업로드 이미지가 서버 재시작해야만 반영되는 문

keyword : Spring, Thymeleaf, static class : ERROR 에러내용 타임리프를 이용해서 정적 게시판을 만들어보는 중에, 이미지 업로드 기능을 구현하고 있었다. 이미지가 로컬에 저장은 되는데 해당 글에 들어가

g4daclom.tistory.com

 

https://heowc.tistory.com/32

 

Spring Boot - Resource 개선하기

이번 글은 Resource를 개선하기 위한 방법에 대한 글 입니다. Why..? 흔히 웹단를 같이 구성하게되는 WAS를 구성하게 되면 Spring 에서는 Static Resources(html, js, css, img)를 /resources에 경로로 잡아 관리 해줍

heowc.tistory.com

 

https://peachsoong.tistory.com/67

 

reactJS + springboot 파일 업로드 구현하기 (1)

✅ 개발 환경 Mac M1 노트북 React + Typescript SpringBoot + JPA - JDK 1.8 / Language Level 8 mySqk 사용 (로컬에서 돌렸습니다) IntelliJ 사용 React와 SpringBoot 환경이 모두 세팅되어 있다는 가정 하에 시작하겠습니다

peachsoong.tistory.com