Spring

[SpringBoot - 파일 다루기] 4. 기존 게시판 프로젝트에 '파일 업로드' 기능 추가하기 (완)

MoveForward 2025. 1. 29. 00:31

0. 개요

- '파일 다루기' 시리즈의 궁극적 목표인 게시판 프로젝트에 파일 업로드 기능을 이식할 것이다.

 

[기존 게시판 프로젝트]

https://github.com/yashin20/board_service_spring 

 

GitHub - yashin20/board_service_spring: Spring Boot - Board Service

Spring Boot - Board Service. Contribute to yashin20/board_service_spring development by creating an account on GitHub.

github.com

 

 

1. 포스팅 목표

- 1. 기존 게시판 프로젝트에 파일 업로드 기능 이식하기

- 2. 다중 파일 업로드 기능 구현

- 3. 게시판 프로젝트의 정상적인 작동

 

위 3가지 목표의 완수를 위해 포스팅을 진행한다.

 

 

 2. 구현 방식

- 클라이언트가 게시글 생성을 할때 게시글에 파일을 업로드 할 수 있다.

- 물리적인 업로드된 파일은 서버의 로컬 저장소에 저장된다.

- 'SaveFile' 엔티티를 통해 업로드된 파일의 관계를 관리하여, 소속된 게시글과 매핑한다.

 

3. 기능구현 (코드)

1. 'application.yml' - 서버 로컬 저장소 경로 지정

spring:
  # 업로드 파일의 최대 크기를 설정
  servlet:
    multipart:
      enabled: true
      max-file-size: 10000MB #10GB
      max-request-size: 10000MB #10GB
  #      file-size-threshold: 0

#중략...

# 파일 업로드 경로
file:
  path: C:\SpringBoot\upload

 

+) WebConfig.java - HTTP 링크와 서버 로컬 저장소 주소 연결

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // HTTP 요청 경로 "/upload/**"를 로컬 디렉터리 "C:/SpringBoot/upload"와 연결
        registry.addResourceHandler("/upload/**")
                .addResourceLocations("file:///C:/SpringBoot/upload/");
    }
}

 

2. 'SaveFile.java' - 파일 Entity

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class SaveFile {

    @Id @GeneratedValue
    @Column(name = "saveFile_id")
    private Long id;

    private String originalName; //원본 파일명
    private String saveName; //저장 파일명
    private String fileType; //저장 파일 타입
    private Long size; //파일 크기

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post; //소속 게시글


    //연관 관계 설정
    public void setPost(Post post) {
        this.post = post;
    }
}

 

3. 'FileDto.java'

public class FileDto {

    @Data
    public static class Request {
        private Long id; //파일 번호 (PK)
        private MultipartFile file;

        /**
         * 게시글 번호 (FK) - 게시글을 먼저 저장하여, DB로 부터 POST_ID를 부여 받아야 넣을 수 있음!
         */
        // private Long postId; //게시글 번호 (FK)
        private String originalName; //원본 파일명
        private String saveName; //저장 파일명
        private String fileType;
        private Long size; //파일 크기

        public Request(MultipartFile file) {
            this.file = file;
        }

        //DTO -> toEntity
        public SaveFile toEntity() {
            return SaveFile.builder()
                    .originalName(originalName)
                    .saveName(saveName)
                    .fileType(fileType)
                    .size(size)
                    .build();
        }
    }

※ 'postId' 속성을 사용하지 않은 이유

- 'SaveFile' 엔티티가 소속된 게시글(Post)를 지정하기 위해, 'POST_ID'를 사용해야 한다고 생각하였다.

그러나 생성된 Post가 DB에 저장이 된 후 'POST_ID'가 발급되게 된다. 따라서 'SaveFile'은 그제서야 'POST_ID'를 이용할 수 있게 된다.

 

이러한 문제가 있어서, 'SaveFile' 과 'Post'를 매핑하기 위해 사용할 방식은 "연관관계 설정" 이다.

DB에 저장되기전 Post 객체 -> 'POST_ID' 없음

DB에 저장되기전 SaveFile 객체 -> 'SAVE_FILE_ID' 없음

 

각 매핑관계에 따라 객체의 연관관계 설정 메서드를 이용하여 진행한다.

public class SaveFile {
	
    중략...

	//연관 관계 설정 메서드
    public void setPost(Post post) {
        this.post = post;
    }

}

--------------------------------------
public class Post extends BaseEntity {

	중략...
    
    //연관 관계 메서드
    public void addFile(SaveFile file) {
        this.files.add(file);
        file.setPost(this); //파일에 게시글 연관 관계 설정
    }

}

 

4. 'FileRepository.java' - JPA 이용

 

5-1. 'FileService.java'

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FileService {

    private final FileRepository fileRepository;

    //파일 업로드 경로 지정
    @Value("${file.path}")
    public String uploadFolder;

    private static final long MAX_FILE_SIZE = 1024L * 1024L * 1024L * 10L; //10GB

    //다수의 파일을 한번에 저장
    @Transactional
    public List<SaveFile> uploadFiles(List<FileDto.Request> fileRequests) {
        List<SaveFile> savedFiles = new ArrayList<>();
        for (FileDto.Request request : fileRequests) {
            /* request - file */
            checkFileType(request.getFile());

            //저장 파일명 구성 - saveName
            UUID uuid = UUID.randomUUID();
            String originalFilename = request.getFile().getOriginalFilename();
            String saveFileName = uuid + "_" + originalFilename;
            System.out.println("원본 파일명: " + originalFilename);
            System.out.println("저장 파일명: " + saveFileName);

            //파일 경로 설정
            Path path = Paths.get(uploadFolder, saveFileName);

            try {
                Files.write(path, request.getFile().getBytes());
            } catch (Exception e) {
                e.printStackTrace();
            }

            String[] types = request.getFile().getContentType().split("/");//파일 타입 입력
            String fileType = types[0]; //image, video, text ...

            long size = request.getFile().getSize();

            request.setOriginalName(originalFilename);
            request.setSaveName(saveFileName);
            request.setFileType(fileType);
            request.setSize(size);
            /* request - file, originalName, saveName, fileType, size */

            SaveFile saveFile = request.toEntity();
            savedFiles.add(saveFile);
        }
        fileRepository.saveAll(savedFiles); //다수 파일 저장
        return savedFiles;
    }


    /**
     * 파일 타입 확인하기
     */
    private void checkFileType(MultipartFile file) {
        String contentType = file.getContentType();
        System.out.println("파일 타입: " + contentType);
    }

    public List<SaveFile> findAll() {
        return fileRepository.findAll();
    }

    public List<SaveFile> findByPostId(Long postId) {
        return fileRepository.findByPostId(postId);
    }
}

이전 포스팅에서 달라진 점은 매개변수를 List<FileDto.Request> 를 이용하여 다수의 파일을 한번에 생성한다는 것이다. 

5-2. 'PostService.java'

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {

    private final PostRepository postRepository;
    private final MemberRepository memberRepository;
    private final FileService fileService;

    /** Create Post **/
    @Transactional
    public Post createPost(PostDto.Request dto) {
        //1. request dto -> entity
        Post post = dto.toEntity();

        //2. 파일 저장 및 연관 관계 설정
        if (dto.getFileRequestDtoList() != null && !dto.getFileRequestDtoList().isEmpty()) {
            List<SaveFile> saveFiles = fileService.uploadFiles(dto.getFileRequestDtoList());
            for (SaveFile saveFile : saveFiles) {
                post.addFile(saveFile);
            }
        }

        //3. 게시글 저장
        return postRepository.save(post);
    }

앞서 언급한 "연관 관계 메서드"를 이용하여 'SaveFile' 과 'Post' 간의 연관관계를 설정한다.

 

6. 'PostController.java'

@PostMapping("/new")
public String createPost(@ModelAttribute("createPostForm") @Validated(PostDto.Request.Create.class) PostDto.Request dto,
                         @RequestParam("files") List<MultipartFile> files,
                         BindingResult bindingResult, Model model) {

    /*'유효성 검사' 에러처리*/
    if (bindingResult.hasErrors()) {
        FieldError titleError = bindingResult.getFieldError("title");
        if (titleError != null) {
            model.addAttribute("titleError", titleError.getDefaultMessage());
        }

        FieldError contentError = bindingResult.getFieldError("content");
        if (contentError != null) {
            model.addAttribute("contentError", contentError.getDefaultMessage());
        }

        return "posts/create";
    }

    try {
        //0. 현재 로그인된 회원을 글쓴이로 등록
        dto.setMember(memberService.getCurrentMember());

        //1. 첨부파일 DTO 리스트로 변환
        List<FileDto.Request> fileDtos = files.stream()
                .filter(file -> !file.isEmpty())
                .map(FileDto.Request::new)
                .toList();
        dto.setFileRequestDtoList(fileDtos);

        //2. 게시글 등록
        Post post = postService.createPost(dto);
    } catch (DataAlreadyExistsException | PasswordCheckFailedException ex) {
        bindingResult.reject("errorMessage", ex.getMessage());
        model.addAttribute("errorMessage", ex.getMessage());

        return "posts/create";
    }

    return "redirect:/";
}

 

!문제 발생 - 다수 파일 업로드 과정에서 에러 발생!

html로 부터 List<MultipartFile> files 를 변수로서 Controller에 받아오려 하는데 계속해서 아래와 같은 오류가 발생한다!

```

Internal server error: Validation failed for argument [0] in public java.lang.String project.board_service.controller.PostController.createPost(project.board_service.dto.PostDto$Request,java.util.List,org.springframework.validation.BindingResult,org.springframework.ui.Model): [Field error in object 'createPostForm' on field 'files': rejected value [[org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@7e147991, org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@6b61360f, org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@75e09733]]; codes [typeMismatch.createPostForm.files,typeMismatch.files,typeMismatch.java.util.List,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createPostForm.files,files]; arguments []; default message [files]]; default message [Failed to convert property value of type 'java.util.ArrayList' to required type 'java.util.List' for property 'files'; Cannot convert value of type 'org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile' to required type 'project.board_service.dto.FileDto$Request' for property 'files[0]': no matching editors or conversion strategy found]]

```

위 에러를 간략히 정리하자면, 

HTML에서 넘어오는 파일 변수인 " List<MultipartFile> files "가 PostDto.Create 에서 타입이 일치하지 않는다는 것이다.

 

테스트를 위해, 컨트롤러의 모든 기능을 주석처리하고 파일이 잘 넘어오는지 확인하기 위해

@PostMapping("/new")
    public String createPost(@ModelAttribute("createPostForm") @Validated(PostDto.Request.Create.class) PostDto.Request dto,
                             @RequestParam("files") List<MultipartFile> files,
                             BindingResult bindingResult, Model model) {

        // 1. 넘어온 파일들 처리
        for (MultipartFile file : files) {
            System.out.println("파일 이름: " + file.getOriginalFilename());
            System.out.println("파일 크기: " + file.getSize());
            System.out.println("파일 타입: " + file.getContentType());
        }


        return "redirect:/";
    }

이렇게만 남겨 두었기에 이해가 잘 되지 않았다.

"아니 변환할 것이 없는데 왜 에러가 발생하는거지? "

"PostDto.Request 와 files 변수는 별개로 취급되어서 HTML에서 백엔드로 넘어오는것인데 Spring이 왜 타입일치를 검사하는것이지?"  라는 의문이 들었다.

 

오랜 시간동안 고민을 하다가 그 이유를 알게 되었다.

 

문제원인은 'PostDto.Request' 에 있는 'List<FileDto.Request> files' 와 컨트롤러를 통해 넘어온 List<MultipartFile> files "가 서로 변수명이 같기 때문에, Spring은 당연히 두가지 변수가 매핑되는것이라고 판단하여 대입을 하려했지만, 둘의 타입이 다르기에 에러를 반환한것 이었다.

'PostDto.Request' 에 있는 'List<FileDto.Request> files'
컨트롤러를 통해 넘어온  "  List<MultipartFile> files "

해결방법은 간단했다.

두가지 변수명을 서로 다르게 바꾸면된다.
나는 PostDto.Request의 files를 -> fileRequestDtoList로 변환하였다.

 

정상적으로 작동하는 것을 볼 수 있다.

문제 해결!

 

7. Front - end (create.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Post Create Page</title>
    <!-- Core theme CSS (includes Bootstrap)-->
    <link href="/css/styles.css" rel="stylesheet"/>
</head>
<body>

<!--header-->
<header class="header" th:replace="~{fragments/header :: header}"></header>
<div class="d-flex" id="wrapper">
    <!-- Page content wrapper-->
    <div id="page-content-wrapper">

        <div class="container mt-5">
            <!-- Page content-->
            <div class="container-fluid">

                <h2 class="text-center mb-4">게시글 생성 페이지</h2>

                <form th:action="@{/posts/new}" th:object="${createPostForm}" method="post" enctype="multipart/form-data"
                      class="p-4 border rounded shadow-sm bg-light">

                    <!-- Title Field -->
                    <div class="mb-3">
                        <label for="create-title" class="form-label">제목</label>
                        <input id="create-title" th:field="*{title}" type="text"
                               class="form-control" placeholder="제목을 입력하세요" required>
                        <!-- Error message -->
                        <div th:if="${titleError}" class="form-text text-danger">
                            <p th:text="${titleError}"></p>
                        </div>
                    </div>

                    <!-- Content Field -->
                    <div class="mb-3">
                        <label for="create-content" class="form-label">내용</label>
                        <textarea id="create-content" th:field="*{content}" rows="20"
                                  class="form-control" placeholder="내용을 입력하세요" required></textarea>
                        <!-- Error message -->
                        <div th:if="${contentError}" class="form-text text-danger">
                            <p th:text="${contentError}"></p>
                        </div>
                    </div>

                    <!--파일 업로드-->
                    <div class="mb-3">
                        <label for="file-section" class="form-label">첨부 파일</label>
                        <div id="file-section">
                            <div class="file-input-row d-flex align-items-center mb-2">
                                <input type="file" class="form-control" name="files" multiple><!--multiple - 다중 파일을 위한 속성-->
                                <button type="button" class="btn btn-danger btn-sm remove-file">삭제</button>
                            </div>
                        </div>
                        <button type="button" id="add-file-button" class="btn btn-secondary btn-sm mt-2">파일 추가</button>
                    </div>

                    <!-- Submit Button -->
                    <div class="d-grid mb-3">
                        <button type="submit" class="btn btn-primary">작성하기</button>
                    </div>

                    <!-- General Error Message -->
                    <div th:if="${errorMessage}" class="form-text text-danger text-center">
                        <p th:text="${errorMessage}"></p>
                    </div>

                </form>


            </div>
        </div>
    </div>
</div>

<!--footer-->
<footer class="footer" th:replace="~{fragments/footer :: footer}"></footer>
<script>
    //파일 업로드 줄 추가
    document.getElementById('add-file-button').addEventListener('click', function () {
        const fileSection = document.getElementById('file-section');

        //파일 업로드 줄 생성
        const newRow = document.createElement('div');
        newRow.classList.add('file-input-row', 'd-flex', 'align-items-center', 'mb-2');

        newRow.innerHTML = `
            <input type="file" class="form-control me-2" name="files" required>
            <button type="button" class="btn btn-danger btn-sm remove-file">삭제</button>
        `;

        fileSection.appendChild(newRow);
    })

    //파일 업로드 줄 삭제
    document.getElementById('add-file-button').addEventListener('click', function (e) {
        if(e.target.classList.contains('remove-file')) {
            const row = e.target.closest('.file-input-row');
            row.remove();
        }
    });
</script>
<!-- Bootstrap core JS-->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

 

파일 업로드 관련 속성들은 앞선 포스팅에서 언급한 것과 새로운 것이 추가되었다.

 

1. <form> 태그의 [enctype="multipart/form-data"] 속성

- 이 속성이 있어야 form 을 통해 파일 업로드 POST 요청을 보낼 수 있다.

 

2. <input> 태그의 [type="file"] 속성

- <input> 태그가 다루는 것이 파일 임을 정의

 

3. <input> 태그의 [multiple] 속성

- 다수의 파일을 다루기 위함을 정의

 

3번째 속성이 다수의 파일을 한번에 업로드 하기 위해서 중요한 속성이다.

 

 

4. 작동확인

1. 업로드할 파일 목록

'CSV', 'xlsx', 'pptx', 'mp4', 'jpg', 'txt' 등 다양한 종류의 파일이 존재

 

2. 게시글 생성시 첨부파일 업로드

9개의 파일을 모두 추가한다.

'작성하기' 버튼을 눌러서 게시글 작성을 한다.

 

3. 업로드된 파일 확인

모든파일이 업로드 된 것을 확인할 수 있다.

 

4. DB 확인

'157' 번 게시글이 작성된 것을 확인할 수 있다.

 

[SaveFile Table]

'157'번 게시글 소속의 첨부파일로 파일 업로드가 된 것을 확인할 수 있다.

 

5. 웹 화면 확인

웹 상에서 업로드된 파일을 확인 할 수 있다.

 

 

5. 마무리

3가지 목적을 모두 이루었다.

 

게시판 프로젝트에서 조금 아쉬운 부분이라고 생각하고 있던, 파일 업로드 기능을 직접 구현해 보았다.

이 과정을 통해서 웹 서비스에서 '파일'을 다루는 것에 자신감을 가지게 되었고, 구현 과정에서 직면하는 문제를 해결하는 과정에서 배운 것이 정말 많았다. 

 

기초적인 웹 서비스인 '게시판 프로젝트'를 심적으로도 마무리 하고 끝낸 것 같아서 조금은 후련한 느낌이 들었다.


- 시리즈 포스팅 확인하기

[SpringBoot - 파일 다루기] 1. 이미지 파일 업로드 및 저장하기

https://notorious.tistory.com/422

 

[SpringBoot - 파일 다루기] 1. 이미지 파일 업로드 및 저장하기

0. 개요게시판 등 다양한 웹서비스에서 제공하는 기능인 파일 업로드 기능을 구현하는 것을 목표로 한다.다양한 파일 업로드 중 '이미지' 파일 업로드를 첫 대상으로 한다. 기존에 제작했던 게

notorious.tistory.com

 

 

[SpringBoot - 파일 다루기] 2. 업로드한 이미지 파일 웹에서 확인하기

https://notorious.tistory.com/423

 

[SpringBoot - 파일 다루기] 2. 업로드한 이미지 파일 웹에서 확인하기

0. 개요- 이전 포스팅에서 클라이언트가 '이미지 파일'을 업로드 하여, 서버에 저장하는 기능을 구현하였다.저장된 이미지를 클라이언트가 확인하는 기능이 필요하다는 것을 느꼈다.따라서, 클

notorious.tistory.com