도입 및 요구사항 파악
개발자라면 프로젝트를 진행하면서 파일 업로드를 구현해야 할 상황이 자주 올 것이다.
이번 포스팅은 SpringBoot를 이용해서 파일 업로드를 구현하는 과정에 대해 정리하였다.
현재 진행하고 있는 워디 프로젝트를 작업하면서, 이미지 파일 업로드를 구현해야할 상황이 생겼다.
다음 와이어프레임을 보자.
해당 와이어프레임은 멘토링 서비스에서 멘토 가입(멘토 등록) 페이지의 일부이다.
여기에 멘토 프로필 이미지를 등록하는 부분과 멘토임을 증명하는 증명서 파일을 등록하는 부분이 있다.
이곳에 이미지 파일과 증명서 파일을 첨부하고 서버에 요청을 하게 되면, 해당 파일들을 서버 내에 저장하고, 저장된 경로 등을 DB 내에 저장하여 필요할 때마다 저장된 파일에 접근할 수 있는 방식으로 구현해야 한다.
즉, 멘토 등록 페이지에서 프로필 이미지와 멘토 증명서 파일 업로드를 구현해야 하는 상황이다.
이번 포스팅에서는 멘토 등록 API에 파일 데이터와 멘토 등록 데이터가 함께 넘어오면, 해당 파일을 로컬에 저장시키고, 저장된 파일 경로와 함께 멘토 등록 데이터를 DB에 저장하는 과정을 구현해보고자 한다.
차후에는 AWS의 S3 저장소를 활용하여 로컬이 아닌 클라우드 저장소에 파일을 저장시키는 형태로 구현할 예정이다.
구현에 앞서, 파일 업로드에 필요한 간단한 이론을 몇 가지 소개하고자 한다.
multipart/form-data
파일을 업로드하고 출력하려면 어떻게 해야할까?
그전에 파일의 경우 어떤 식으로 HTTP 메세지에 담겨 전달이 되는지 알아야 한다.
일반적으로 우리가 POST 형태로 보내는 데이터는 application/x-www-form-urlencoded 방식으로 데이터가 전송된다.
하지만 파일의 경우를 생각해보자.
파일을 업로드하려면 파일은 문자가 아니라 바이너리 데이터를 전송해야 한다.
문자를 전송하는 application/x-www-form-urlencoded 방식으로 파일을 전송하기는 어렵다.
그리고 또 한 가지 문제가 더 있는데, 보통 폼을 전송할 때 파일만 전송하는 것이 아니라는 점이다.
일반적으로 사용하는 HTML Form을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 다음 두 가지 방식의 차이를 이해해야 한다.
multipart/form-data 방식은 문자와 바이너리를 동시에 전송해야 하는 상황에서 사용한다.
multipart/form-data 방식은 다른 종류의 여러 파일과 폼의 내용 함께 전송할 수 있다.
이 방식을 사용하려면 Form 태그에 별도의 enctype="multipart/form-data" 를 지정해야 한다.
폼의 입력 결과로 생성된 HTTP 메시지를 보면 각각의 전송 항목이 구분이 되어있는 걸 볼 수 있다.
multipart/form-data는 이렇게 각각의 항목을 구분해서, 한 번에 전송한다.
MultipartFile
스프링에서는 multipart 요청을 스프링에서는 다음과 같이 MultipartFile이라는 인터페이스로 손쉽게 받아낼 수 있다.
또한, MultipartFile의 transferTo() 메서드를 활용하여 간단하게 원하는 경로에 파일을 저장할 수 있다.
사실상 기본적으로는 아래 코드만 구현하더라도 파일 저장은 끝나게 된다.
@PostMapping("/upload")
public String saveFile(@RequestParam MultipartFile file) throws IOException {
// 파일 저장
file.transferTo(new File("저장할 경로"));
}
하지만 우리가 파일을 저장하는 상황을 생각해보자.
프로필 이미지를 저장하지 않는 사람이 있을 수도 있다.
그러면 사진에는 null이 들어오기 때문에 해당 상황을 고려한 예외처리를 해줘야 한다.
또한 저장할 파일명에 대한 문제이다.
사람 A도 이미지.png, 사람 B도 이미지.png로 똑같은 이름으로 파일 업로드를 요청할 수 있다.
그러면 같은 경로에 같은 이름이 저장되기 때문에 문제가 발생한다.
마지막으로 고려할 점은 우리가 받아야 할 데이터는 파일뿐만 아니라 멘토 정보에 대한 DTO 데이터도 함께 받는다는 점이다.
보통 Controller에서 DTO를 받을 때는 @RequestBody를 주로 사용한다.
그리고 File을 받을 때는 MultipartFile 객체를 사용하며, @RequestParam을 사용한다.
하지만 File과 Dto를 같이 받기 위해서는 @RequestPart라는 어노테이션을 사용해서 받아야 한다.
파일 업로드 구현
자, 이제 본격적으로 파일 업로드를 구현해보자.
일단은 로컬에 파일을 저장할 예정이기 때문에 yml 파일에 저장할 경로를 다음과 같이 설정해주자.
file:
path: C:/Users/userName/saveFolder
가장 먼저 파일 업로드에서 발생할 수 있는 중복 파일명에 관한 문제를 해결해보자.
이 부분은 UUID를 생성하여 해결할 수 있다.
자바 UUID는 범용 고유 식별자로써 고유한 값을 생성해주기 때문에 파일명 중복에 대한 문제를 해결할 수 있다.
파일 업로드와 관련된 처리는 특정 역할을 하는 메서드이기 때문에 이를 관리할 수 있게 따로 클래스를 만들어 구현하는 것이 좋다.
commons 패키지에 상수를 관리하는 utils 패키지에 file 패키지를 새로 만들고, 파일명 처리 메서드를 관리하는 FileNameUtils 클래스를 만들어준다.
FileNameUtils에는 파일명을 받아 UUID로 저장할 고유한 파일명을 만드는 메서드를 구현한다.
이때, 업로드한 파일 확장자를 알 수 있게 확장자를 추출하여 UUID 파일명에 붙여준다.
public class FileNameUtils {
public static String fileNameConvert(String fileName) {
StringBuilder builder = new StringBuilder();
UUID uuid = UUID.randomUUID();
String extension = getExtension(fileName);
builder.append(uuid).append(".").append(extension);
return builder.toString();
}
// 확장자 추출
private static String getExtension(String fileName) {
int pos = fileName.lastIndexOf(".");
return fileName.substring(pos + 1);
}
}
다음으로 우리가 받을 데이터가 멘토 등록 정보 DTO와 프로필 이미지 파일, 증명서 파일 3가지임을 고려하여 다음과 같이 @RequestPart 어노테이션을 활용하여 Controller를 구현한다.
(HttpServletRequest request는 인터셉터에서 로그인 인가 처리와 관련된 부분으로 파일 업로드와는 무관한 로직이다.)
@PostMapping("")
public ResponseEntity<HttpStatus> createMentor(@Validated @RequestPart CreateRequest createRequest,
@RequestPart(required = false) MultipartFile profileImage,
@RequestPart(required = true) MultipartFile certification,
HttpServletRequest request) {
Long userId = (Long)request.getAttribute("userId");
mentorService.createMentor(userId, createRequest, profileImage, certification);
return RESPONSE_CREATED;
}
파일 업로드를 구현한 Service 로직은 다음과 같다.
주석으로 표시된 프로필 이미지 파일 저장, 멘토 증명서 파일 저장 부분을 참고하자.
(예외처리 부분과 멘토 키워드 저장, 일정 저장 부분은 파일 업로드와 무관한 부분이다.)
@Value("${file.path}")
private String filePath;
@Transactional
public void createMentor(Long userId, CreateRequest createRequest, @Nullable MultipartFile profileImage, MultipartFile certification) {
User user = userRepository.findByIdAndStatus(userId, ACTIVE)
.orElseThrow(() -> new NoExistUserException("접속한 회원 정보와 일치하는 회원 정보가 없습니다."));
if(mentorRepository.countByUser(user)>=1) {
throw new ExistMentorException("이미 가입하신 멘토 정보가 있습니다.");
}
// 프로필 이미지 파일 저장
if(!profileImage.isEmpty()) {
String saveImgFileName = FileNameUtils.fileNameConvert(profileImage.getOriginalFilename());
String fullPath = filePath + saveImgFileName;
try {
profileImage.transferTo(new File(fullPath));
} catch (IOException e) {
throw new ImageSaveFailedException("이미지 저장에 실패하였습니다.");
}
createRequest.updateImageUrl(fullPath);
}
// 멘토 증명서 파일 저장
String saveCerFileName = FileNameUtils.fileNameConvert(certification.getOriginalFilename());
String certificationFullPath = filePath + saveCerFileName;
try {
certification.transferTo(new File(certificationFullPath));
} catch (IOException e) {
throw new CertificationFileSaveFailedException("증명서 파일 저장에 실패하였습니다.");
}
createRequest.updateCertificationUrl(certification.getOriginalFilename(), certificationFullPath);
// 멘토 정보 저장
Mentor mentor = mentorRepository.save(createRequest.toEntity(user));
// 멘토 키워드 저장
createRequest.getKeywordList().stream()
.forEach(k -> mentorKeywordRepository.save(new MentorKeyword(mentor, k)));
// 멘토 일정 저장
createRequest.getScheduleList().stream()
.forEach(s -> mentorScheduleRepository.save(s.toMentorSchedule(mentor)));
}
멘토 증명서 파일 같은 경우는 유저 또는 관리자가 파일을 다운로드하여볼 수 있도록 구현하고자 했다.
따라서 나중에 파일을 표시할 때, 유저가 올린 파일명으로 파일을 볼 수 있게 원본 파일명을 함께 저장했다.
Postman으로 테스트
파일 업로드가 제대로 구현되었는지 Postman으로 테스트해보자.
처음 파일을 포함한 데이터를 Postman으로 요청하고자 할 때, 어떻게 요청을 보내야 하는지 알기 어려운 부분이 있다.
Postman Request 설정을 다음과 같이 하여 요청을 보내면 된다.
Body 부분에서 form-data를 설정한다. 그리고 받을 데이터에 맞게 Key, Value를 설정해주면 된다.
이때, 파일 데이터는 KEY 부분에 마우스를 대면 text와 file을 선택하는 창이 뜨는데, 이 때 file을 선택해주면 된다.
DTO로 받을 JSON 데이터는 Content Type을 application/json으로 설정해주자. (처음에 해당 column은 보이지 않지만, ... 부분을 누르면 추가할 수 있다.)
참고
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
'👨💻 개발' 카테고리의 다른 글
SpringBoot에 CloudFront 적용 (0) | 2021.12.07 |
---|---|
AWS CloudFront 배포하기 (0) | 2021.12.07 |
SpringBoot로 AWS S3에 파일 업로드 하기 (0) | 2021.12.05 |
AWS S3 저장소 구축하기 (0) | 2021.12.05 |
QueryDSL을 활용한 동적 쿼리 구현 (0) | 2021.12.02 |