본문 바로가기
💻 뚝딱뚝딱/북북클럽

[개발일지#026] 파일첨부 기능 만들기 (회원 프로필 이미지) (+) 피드백 반영

by 뚜루리 2024. 3. 15.
728x90
320x100
[참고]
김영한님 스프링 강의를 바탕으로 진행되는 토이프로젝트의 과정을 기록하는 글입니다. 
둥근 피드백은 언제나 환영입니다.
[오늘의 개발내용]
1. 파일첨부 기능 만들기 (회원 프로필 이미지)

 

[서론]

파일첨부 기능을 활용하여 회원 프로필 이미지를 넣는 걸 만들어 줄 것임. 

프로필은 이미지 한 장만 첨부가능 하도록 할 것임.

(추후에 내가 등록한 이미지를 다운 받을 수있도록 까지도 할껀데 그건 나중에...)

 

 

1. Member.java

package toyproject.bookbookclub.domain.Members;

import lombok.Getter;
import lombok.Setter;
import toyproject.bookbookclub.domain.UploadFile;

@Getter @Setter
public class Member {

    private String id;
    private String NickName;
    private String password;
    private UploadFile profileImage; //회원 프로필 사진을 받기 위한 객체 추가

	```생략```

}
  • 회원 클래스에 프로필사진을 담기 위한 객체를 하나 추가해줬다. 

 

 

2. UploadFile.java

package toyproject.bookbookclub.domain;

import lombok.Data;

@Data
public class UploadFile {

    private String uploadFileName;
    private String storeFileName;

    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
}
  • 회원이 업로드한 파일명과 서버 내부에서 관리하는 파일명이 둘 다 따로 필요하므로! 업로드 파일 정보를 보관을 위해 생성했음

 

 

3.FileStore.java

package toyproject.bookbookclub.domain;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Component
public class FileStore {

    @Value("${file.dir}")
    private String fileDir;


    public String getFullPath(String filename){
        return fileDir + filename;
    }

    public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles)  throws IOException {
        List<UploadFile> storeFileResult = new ArrayList<>();
        for (MultipartFile multipartFile : multipartFiles) {
            if (!multipartFile.isEmpty()) {
                storeFileResult.add(storeFile(multipartFile));
            } }
        return storeFileResult;
    }

    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
        if (multipartFile.isEmpty()) {
            return null;
        }
        String originalFilename = multipartFile.getOriginalFilename();
        String storeFileName = createStoreFileName(originalFilename);
        multipartFile.transferTo(new File(getFullPath(storeFileName)));
        return new UploadFile(originalFilename, storeFileName);
    }

    private String createStoreFileName(String originalFileName) {
        String ext = extractExt(originalFileName);
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + ext;
    }

    private String extractExt(String originalFileName) {
        int pos = originalFileName.lastIndexOf(".");
        return originalFileName.substring(pos+1);
    }


}
  • 파일 저장과 관련된 업무를 처리하기 위한 클래스를 하나 생성했다. 
  • 이 클래스에서는 서버 내부에서 관리하는 유일무이한 파일명을 생성하거나 확장자를 별도로 추출해서 내부에서 관리하는 파일명에도 붙여주는 등 역할도 함. 

 

 

4. MemberJoinForm.java

package toyproject.bookbookclub.domain.Members;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

@Data
public class MemberJoinForm {

    private String id;
    private String NickName;
    private String password;
    private MultipartFile profileImage;

}
  • 회원 가입용 전용 폼 객체도 따로 만들었다. 
  • 여기서는 프로필 이미지를 MultipartFile 형태로 생성한다. 

 

 

5. BasicMemberController.java

package toyproject.bookbookclub.web.member.basic;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriUtils;
import toyproject.bookbookclub.domain.FileStore;
import toyproject.bookbookclub.domain.Members.Member;
import toyproject.bookbookclub.domain.Members.MemberJoinForm;
import toyproject.bookbookclub.domain.Members.MemberRepository;
import toyproject.bookbookclub.domain.UploadFile;
import toyproject.bookbookclub.web.validation.MemberValidator;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.List;


@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/members")
public class BasicMemberController {

    private final FileStore fileStore; //fileStore 주입 받음. 

    ```생략```

    /**
     * 회원 가입
     * @param form
     * @param bindingResult
     * @param redirectAttributes
     * @return
     * @throws IOException
     */
    @PostMapping("/join")
    public String join(@Validated @ModelAttribute("member") MemberJoinForm form
    , BindingResult bindingResult
    , RedirectAttributes redirectAttributes) throws IOException {

        if (bindingResult.hasErrors()){
            return "basic/joinForm";
        }
        UploadFile profileImage = fileStore.storeFile(form.getProfileImage());

        Member member = new Member();
        member.setId(form.getId());
        member.setPassword(form.getPassword());
        member.setNickName(form.getNickName());
        member.setProfileImage(profileImage);

        Member savedMember = memberRepository.save(member);
        redirectAttributes.addAttribute("memberId", savedMember.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/basic/members/{memberId}";
    }

    /**
     * 이미지 조회
     * @param filename
     * @return
     * @throws MalformedURLException
     */
    @ResponseBody
    @GetMapping("/images/{filename}")
    public UrlResource downloadImage(@PathVariable String filename) throws MalformedURLException {
        return new UrlResource("file:" + fileStore.getFullPath(filename));
    }


```생략```
}

 

(+) 뻘 짓한 것

@ModelAttribute("member") MemberJoinForm form
  • @ModelAttribut MemberJoinForm form 형태로 처음에 입력하니까 받아오질 못함. 왜냐면 htm에서는 object를 MemberForm 이 아니라 member 로 이름을 지정했기 때문...

 

 

6. joinForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link href="../css/bootstrap.min.css"
          th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>

</head>
<body>
<div class="container">
    <div class="mb-3 text-center" >
        <h4 class="mb-3" th:text="#{page.joinMember}">회원 가입</h4>
    </div>

    <form action="member.html" th:action th:object="${member}" method="post" enctype="multipart/form-data">
        ===생략===
        <div>
            <label for="profileImage" th:text="#{label.member.profileImage}">프로필 이미지</label>
            <input type="file" id="profileImage" th:field="*{profileImage}" class="form-control">
        </div>
        ===생략===
    </form>
</div> <!-- /container -->
</body>
</html>
  • Form 태그 안에 enctype="multipart/form-data" 꼭 넣어주기
  • input type="file" 로 해주기

 

 

(+) 뻘짓한 것 

안되길래 GPT한테 물어봄.

알고보니 Form 태그 안에 enctype="multipart/form-data" 을 안 넣어 줌; 마 고맙다 피티야...

 

 

7. member.html

  === 생략 ===
  <div>
    <label>프로필 이미지</label>
    <a th:if="${member.profileImage}"
       th:href="|/attach/${member.id}|" th:text="${member.getProfileImage().getUploadFileName()}" /><br/>
    <img th:src="|/basic/members/images/${member.getProfileImage().getStoreFileName()}|" width="300" height="300"/>
  </div>
 === 생략 ===
  • <a>태그는 나중에 클릭하면 다운받는 기능 넣으려고 만들어 놓았음. 

 

 

(+) 뻘짓한 거

<img th:src="|/basic/members/images/${member.getProfileImage().getStoreFileName()}|" width="300" height="300"/>

src 주소부분을 영한님 강의 대로 하다보니까 /images~ 이런식으로 쓰니까 당연히 이미지가 안뜨고 엑박뜸.

하.....진짜 별거 아닌걸로 뭔 삽질을 했는지...!

 

엑박 뜬거 인증합니다....

 

 

 

 

이게 끝인줄 알았지만 아니였음. 제일 중요한 몇 가지들을 추가로 설정해줘야 함. 

8. application.properties

file.dir=/Users/ddururiiiiiii/dev/file/bookbookclub/
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=20MB
  • fileStore.java 에서 ${file.dir} 로 설정했던 것에 값을 넣어줘야 함. 즉, 이미지가 저장될 경로를 넣어줘야 함. 
  • 그리고 파일 업로드시 파일용량을 제한해줘야 하는데 두번째 줄이 파일당 제한, 막줄이 여러 첨부파일을 업로드 할 경우 총 용량이라고 보심 되겠다.

 

(+) 뻘짓한 것

파일 업로드 할라니까 갑자기 이런 에러가 듬. 413..세상 처음 보는 에러.  GPT한테 물어봄. 

 

아 파일 크기 설정 안해놔서 그렇구나....나는 어차피 테스트하는거라 어차피 큰 파일 안할 거고 크게 문제 없을 거라 생각해서 처음에 안넣었는데 저걸 넣어줘야 한다는걸 깨달음 마 고맙다 피티야....

 

 

9.Member.html

package toyproject.bookbookclub.web.validation;

import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
import toyproject.bookbookclub.domain.Members.Member;
import toyproject.bookbookclub.domain.Members.MemberJoinForm;

@Component
public class MemberValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Member.class.isAssignableFrom(clazz); 
    }

    @Override
    public void validate(Object target, Errors errors) {
        MemberJoinForm member = (MemberJoinForm) target; //수정

        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "id", "required");
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "nickName", "required");
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "password", "required");
    }

}
  • 안되길래 보니까 여기서 (Member) 로 원래 캐스팅 되어있어야 하는데 내가 memberForm을 우겨넣어서 안되는 거였음 그래서 캐스팅을 MemberForm으로 해줌. 

 

 

[구현]

이번에 파일 업로드...는 좀 고생을 했다........

영한님 강의를 들은지 좀 됐기도 한데다가 강의록을 제대로 안읽은 탓도 있고 여러모로 어려웠다. 

아직 수정해야 할 기능들이 많지만 그래도 한 단계 올라갔다.

 

 

 

728x90
320x100