[Spring boot 9일차] 파일업로드

이경영·2022년 11월 1일
0

스프링부트

목록 보기
10/12

MultipartFile

  • Multipart 인터페이스를 제공하면 클라이언트에서 선택한 파일정보를
    쉽게 받고, 사용이 가능하게 자동으로 지원이 된다.

application.yaml

servlet:
  multipart:
    enabled: true //파일 업로드 지원여부
    max-file-size: 50MB //최대 크기
    location: /fileupload //위치
    max-request-size: 50MB //요청의 최대 크기
  • file-size-threshold: 파일이 메모리에 기록되는 임계값 (기본: 0B)

만약 첨부파일이 최대가 넘는 경우라면?

: 자동으로 예외발생 시키면서 로그 남겨준다.
추후 해당 예외를 @ControllerAdvice에 등록해서 에러메시지 등을 custom으로 구현 가능하다.

member/form.html에 이미지 등록 폼 추가

			<div class="row mb-3">
				<label for="nickname" class="col-sm-2 col-form-label">파일업로드</label>
				<div class="col-sm-10">
					<input type="file" class="form-control" 
						name="profileImage" id="profileImage"/>
				</div>
			</div>

submit 발생시 FormData 클래스를 사용해서
기존에 $form을 넣어줌
contentType: false,
processData: false를 넣어줘야함.
이처럼 설정하면 jQuery에서도 FormData를 서버로 전송할 수 있다.
제이쿼리 내부적으로는 multipart/form-data로 전송이 됨.

	$(function(){
		var $form= $('#member-join-form'); //html의 element 찾음 
		$form.submit(function() {
			try{
/* 			var data = {
				account: $form.find('input[name=account]').val(),	
				password: $form.find('input[name=password]').val(),	
				nickname: $form.find('input[name=nickname]').val(),	
				
			} */
			var formData=new FormData($form[0])
			console.log('formData',formData);
			
		
		//서버 전송하기전에 Object형태를 String으로 바꿔줘야함
		
/* 		var jsonValue=JSON.stringify(data);
		
		console.log('jsonValue',jsonValue) */
		
		//form에서 submit이 발생하는 것은 핸들링.
		
			//비동기로 전송하기 위해
			$.ajax({
				url: '/member/join',
				type: 'post',
				data: formData,
				contentType:false,
				processData:false,
				success: function() { //서버로 따지면 컨트롤러
					location.href='/member/join-complete';
				},
				error: function(data){
					//console.log(data);
					console.log(data.responseJSON);
					alert(data.responseJSON.message);
				}
				
			});
			}catch(e){
				console.log(e);
			}
			//페이지가 전환되지 않게 방지
			return false;
		});
	});

sql쿼리 : 파일 업로드를 위한 필드 추가

ALTER TABLE `T_MEMBER`
	ADD COLUMN `PROFILE_IMAGE_PATH` VARCHAR(100) NULL DEFAULT NULL COMMENT '프로필 이미지 경로' AFTER `NICKNAME`,
	ADD COLUMN `PROFILE_IMAGE_NAME` VARCHAR(100) NULL DEFAULT NULL COMMENT '프로필 이미지 원본이름' AFTER `PROFILE_IMAGE_PATH`;

Member.java 추가

package com.example.domain;

import lombok.Data;

@Data
public class Member {
	
	private int memberSeq;
	private String account;
	private String password;
	private String	nickname;
	private String	joinDate;
	private String profileImagePath; //사진Path와
	private String profileImageName; //Image 이름 추가

}

MemberJoinForm.java 변경 : validation하는 도메인

package com.example.controller.form;

import javax.validation.GroupSequence;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;

import org.hibernate.validator.constraints.Length;
import org.springframework.web.multipart.MultipartFile;

import com.example.validation.ValidationSteps;
import com.example.validation.constraints.NotEmptyFile;

import lombok.Data;

@Data
@GroupSequence({
	MemberJoinForm.class,
	ValidationSteps.Step1.class,
	ValidationSteps.Step2.class,
	ValidationSteps.Step3.class,
	ValidationSteps.Step4.class,
	ValidationSteps.Step5.class,
	ValidationSteps.Step6.class,
	ValidationSteps.Step7.class //추가됨
})
public class MemberJoinForm {
	
	@NotEmpty(groups = ValidationSteps.Step1.class, message = "{MemberJoinForm.account.notEmpty}")
	@Email(groups = ValidationSteps.Step2.class, message = "{MemberJoinForm.account.pattern}")
	private String account;

	@NotEmpty(groups = ValidationSteps.Step3.class, message = "{MemberJoinForm.password.notEmpty}")
	@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[$@$!%*#?&])[A-Za-z\\d$@$!%*#?&]{8,12}$",groups = ValidationSteps.Step4.class, message = "{MemberJoinForm.password.pattern}")
	private String password;
	 
	@NotEmpty(groups = ValidationSteps.Step5.class, message = "{MemberJoinForm.nickname.notEmpty}")
	@Length(groups = ValidationSteps.Step6.class, min = 2, max = 10, message = "{MemberJoinForm.nickname.length}")
	private String nickname;
	
	@NotEmptyFile(groups=ValidationSteps.Step7.class, message = "{MemberJoinForm.profileImage.notNull}")
	private MultipartFile profileImage; //추가됨


}

Member.xml 변경

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.mapper.MemberMapper">

	<!-- 계정이 있는지 없는지 -->
  	<select id="selectMemberAccountCount" parameterType="String" resultType="int">
  		SELECT COUNT(1)
  		FROM T_MEMBER
  		WHERE ACCOUNT = #{account}
  	</select>
  	
  	<!-- 회원 계정 조회 -->
	<select id="selectMemberAccount" parameterType="String" resultType="com.example.domain.Member">
		SELECT MEMBER_SEQ, ACCOUNT, PASSWORD, NICKNAME
		FROM T_MEMBER
		WHERE ACCOUNT = #{account}
	</select>
  	
  	
  	<insert id="insertMember" 
  		parameterType="com.example.domain.Member">
  		INSERT INTO T_MEMBER
  		(
  			ACCOUNT,
  			PASSWORD,
  			NICKNAME,
  			PROFILE_IMAGE_PATH,
  			PROFILE_IMAGE_NAME,
  			JOIN_DATE
  		)
  		VALUES
  		(
  			#{account},
  			#{password},
  			#{nickname},
  			#{profileImagePath},
  			#{profileImageName},
  			NOW()
  		)
  	</insert>
	
</mapper>

MemberController.java 변경
클라이언트에서 파일첨부 한다면 서버에서 데이터를 받을경우
@RequestBody를 제거하고 받아야 합니다.

	@PostMapping("/join")
	//클라이언트에서 파일첨부를 한다면 서버에서 데이터를 받을경우 @RequestBody를제거하고받아
	public HttpEntity<Boolean> join(@Validated MemberJoinForm form) {
		// 계정 중복체크 : true면 사용중인 계정 : 
      //isuserAccount는 항상 true만 들어감.
		boolean isUseAccount = memberService.selectMemberAccount(form.getAccount()) > 0;
		logger.info("isUseAccount : {} ",isUseAccount);
		Assert.state(!isUseAccount, "이미 사용중인 계정입니다");
		logger.info("form profileImage : {} ",form.getProfileImage());
		memberService.insertMember(form);;
		
		return new ResponseEntity<Boolean>(true, HttpStatus.OK);
	}
	

com.example.validation.constraints
NotEmptyFile.java 생성

package com.example.validation.constraints;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

@Documented
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = { NotEmptyFileValidator.class })
public @interface NotEmptyFile {
	
	String message() default "파일을 선택해주세요";
	Class<?>[] groups() default {};
	Class<? extends Payload>[] payload() default {};
}
  • @Documented
    자바에는 Javadoc이라고 코드를 문서화 하는 기능이 있다. 이 애노테이션을 가지고 있는 애노테이션을 사용하면 이 정보를 해당 코드의 문서에 같이 보여준다
    다른 파일에서 @NotEmptyFile을 설정할 수 있다.
    클래스 생성시 mesaage를 넣어주기 위한 어노테이션 생성

  • @Constraint(validatedBy = { NotEmptyFileValidator.class })
    애노테이션을 Bean Validation Constraint로 만들어주는 애노테이션
    validatedBy 값으로 우리가 아래에서 정의할 validator를 전달해주면 Spring Boot는 우리가 API를 호출할 때 전달한 값을 가져오면서 validation을 수행함.

    속성설명
    String message() default [...];해당 attribute는 default message key 값을 가지고 있어야함
    Class<?>[] groups() default {};사용자들이 targeted group을 customize하기 위해 사용
    Class<? extends Payload>[] payload() default {};확장성을 위해 사용
  • @Target({ ElementType.FIELD })
    해당 애노테이션이 적용될 수 있는 contexts값을 의미. 즉, 어디에 사용될 수 있는지 정의 (필드값에 적용 가능)

  • @Retention(RetentionPolicy.RUNTIME)
    이 커스텀을 언제까지 유지할것인가

커스텀 어노테이션 출처

MemberJoinForm.java 에서 어노테이션으로 커스텀 어노테이션을 사용함.

	@NotEmptyFile(groups=ValidationSteps.Step7.class, message = "{MemberJoinForm.profileImage.notNull}")
	private MultipartFile profileImage; //추가됨

message.properties 에 에러메세지를 추가

application.yaml에서 환경설정 추가
: 나중에 개발서버/운영서버 환경마다 환경설정값이 달라질 수있음.

file:
  root-path: /fileupload

MemberService.java
: @Value 어노테이션을 사용해서 application.yaml에추가한 환경변수(파일업로드시 루트경로) 값을 가져옴
insertMember에 파일저장하는 로직을 추가 :
파일을 저장하는 경우 WAS에 구동된 ROOT 폴더에다가 저장하면 안되고, 아예 다른폴더에 저장해야 한다.
추후 서버2중화등 다양한 변수가 발생하는 경우 NAS(파일공유) 서버를 두고 WAS 서버에서 공유하는 방식으로 보통 사용한다.WAS ROOT 폴더에 저장된다면 서버재기동 또는 배포되는과정에 첨부파일이 모두 삭제가 될 수 있다.

package com.example.service;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.multipart.MultipartFile;

import com.example.controller.form.MemberJoinForm;
import com.example.domain.Member;
import com.example.mapper.MemberMapper;
import com.example.security.SecurityUserDetails;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
public class MemberService implements UserDetailsService{
	
	private final MemberMapper memberMapper;
	final Logger logger = LoggerFactory.getLogger(getClass());
	
	private final PasswordEncoder passwordEncoder;
	
	@Value("${file.root-path}")
	private String rootPath;
	
	public int selectMemberAccount(String account) {
		return memberMapper.selectMemberAccountCount(account);
	}
	
//	public void insertMember(MemberJoinForm form) {
//		memberMapper.insertMember(form);
//	}
	
	public void insertMember(MemberJoinForm form) {
		MultipartFile profileImage = form.getProfileImage();
		String originalFilename=profileImage.getOriginalFilename();
		String ext=originalFilename.substring(originalFilename.lastIndexOf(".")+1,
				originalFilename.length()); 
		// 탐색하는 문자열이 마지막으로 등장하는 위치에 대한 index를 반환  ex).jpg, .png의 .
		// ext : .jpg 출력
		
		String randomFilename=UUID.randomUUID().toString()+"."+ext; 
		//UUID 클래스를 사용해 유일한 식별자 생성가능. 
		// 1. 업로드된 파일명의 중복을 방지하기 위해 파일명을 변경할 때 사용.
		// 2. 첨부파일 파일다운로드시 다른 파일을 예측하여 다운로드하는것을 방지하는데 사용.
		// 3. 일련번호 대신 유추하기 힘든 식별자를 사용하여 다른 컨텐츠의 임의 접근을 방지하는데 사용.
		
		String addPath="/"+LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
		// 현재날짜 포매팅 /2021-09-02 표시
		// 출처 : https://suyou.tistory.com/287 
		
		// 저장경로
		String savePath = new StringBuilder(rootPath).append(addPath).toString();
		String imagePath = addPath + "/" + randomFilename; // imagePath:/2021-09-02/파일명.jpg 
		File saveDir = new File(savePath); // pathname가 생성자의 파라미터 
		logger.info("originalFilename : {}", originalFilename);
		logger.info("ext : {}", ext);
		logger.info("randomFilename : {}", randomFilename);
		
		// 폴더가 없는 경우
		// 폴더가 없는경우
		if (!saveDir.isDirectory()) {
			// 폴더 생성
			saveDir.mkdirs();
		}
		File out = new File(saveDir, randomFilename);
		
		try {
			FileCopyUtils.copy(profileImage.getInputStream(), new FileOutputStream(out));
			//in의 내용을 out에 복사하고 스트림 닫는다. byte수 return
		} catch (IOException e) {
			log.error("fileCopy", e);
			throw new RuntimeException("파일을 저장하는 과정에 오류가 발생하였습니다.");
		}
		
		
		Member member = new Member();
		member.setAccount(form.getAccount());
		//패스워드 암호화 
		String encodePassword=passwordEncoder.encode(form.getPassword());
		log.info("encodePassword : {}" , encodePassword);
		member.setPassword(encodePassword);
		
		member.setNickname(form.getNickname());
		member.setProfileImagePath(imagePath);
		member.setProfileImageName(originalFilename);

		memberMapper.insertMember(member);

	}

	

}
profile
꾸준히

0개의 댓글