기록/BACKEND

[Spring] File Upload

5월._. 2022. 4. 22.
728x90

apache commons의 fileupload를 사용했다.

pom.xml

라이브러리를 추가한다.

<!-- https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload -->
<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.4</version>
</dependency>

 

servlet-context.xml

1.  인코딩, maxUploadSize, maxInMemorySize를 설정한다. 이 외에도 설정할 수 있다. (api문서 참고)

  • maxUploadSize : 한 요청당 업로드가 허용되는 최대 용량. 바이트 단위로 설정. 기본값(-1)은 용량에 제한이 없다.
  • maxInMemorySize : 디스크에 저장하지 않고 메모리에 유지하도록 허용하는 바이트 단위의 최대 용량. 사이즈가 이보다 클 경우 이 이상의 데이터는 파일에 저장된다. 기본값은 10240바이트다.
<beans:bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
	<beans:property name="defaultEncoding" value="UTF-8"/>
	<beans:property name="maxUploadSize" value="5242880"/> <!-- 5MB -->
	<beans:property name="maxInMemorySize" value="1048576"/> <!-- 1MB -->
</beans:bean>

ver. SpringBoot

스프링부트에서는 application.properties에 다음을 추가한다. 계산할 필요 없이 그대로 적으면 된다.

spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=1MB

 

.jsp

파일 추가하는 부분만 남겼다.

1.  form method는 꼭 post여야 한다.

2.  form enctype을 "multipart/form-data"로 지정한다.

3.  input type은 "file"이다.

4.  파일을 여러 개 저장할 수 있도록 하려면 multiple="multiple"을 지정한다.

5.  특정한 타입의 파일만 저장하려면 accept에 설정한다. 확장자를 제한할 수도 있다.

<form id="writeform" class="text-left mb-3" method="post" enctype="multipart/form-data" action="주소">
  <div class="form-group">
    <label for="___">파일:</label>
    <input type="file" class="form-control-file border" name="___" multiple="multiple" accept="image/*">
  </div>
</form>

 

Controller

1.  파일은 MultipartFile타입으로 들어온다. 파일이 여러 개라면 배열을 사용한다.

2.  file이 null이 아니고, 첫 번째 파일이 비어있지 않다면 다음을 수행한다.

2-1.  ServletContext를 이용해 실제로 서버가 사용하는 주소를 가져온다. (sts 등에서 사용하는 가상주소이긴하다.) workspace/프로젝트명 주소가 아니다. 아파치 톰캣 기준으로 workspace위치/.metadata/.plugins/org.eclipse.wst.Server.core/tmp0/wtpwebapps/프로젝트명이 실제 위치다. sts VM server는 sts설치위치/pivotal-tc-server/instances/base-instance/wtpwebapps/프로젝트명이 실제 위치다.

2-2.  날짜별로 파일을 분류하기 위해 오늘 날짜로 폴더명을 만든다. 해당 위치가 존재하지 않는다면 File의 mkdirs을 이용해서 새 폴더를 만든다. path를 만들 때, File.separator을 사용해서 운영체제별로 다르게 설정되도록 한다.

2-3.  파일 정보들을 저장할 리스트를 만든다. 파일 정보 객체는 1) 서버의 저장 폴더, 2) 원본 파일 이름, 3) 실제 저장 파일 이름을 담고 있다.

2-4.  파일 개수에 맞춰서 파일 정보 객체를 만든 뒤 set 한다. 이때, 실제 저장 파일 명은 UUID를 사용해 유니크 identifier을 원본 파일명 앞에 붙여서 만든다. 원본 파일명은 확장자 이전까지로 한다.

2-5.  현재 파일을 지정된 위치에 새 이름으로 전송한다. 

2-6.  전송까지 끝났다면 현재의 파일 정보 객체를 리스트에 추가한다. 이 리스트는 나중에 한꺼번에 게시글 dto에 추가한다. (당연히 게시글 dto에 이 리스트를 담을 멤버 변수가 있어야 한다.)

@Autowired
private ServletContext servletContext;

@PostMapping("주소")
public String register(게시글Dto dto, @RequestParam("___") MultipartFile[] files, Model model,
		HttpSession session, RedirectAttributes redirectAttributes)
		throws Exception {
	//..생략..

	if (files != null && !files[0].isEmpty()) {
		String realPath = servletContext.getRealPath("/upload");	//2-1번
		String today = new SimpleDateFormat("yyMMdd").format(new Date());
		String saveFolder = realPath + File.separator + today;
		File folder = new File(saveFolder);
		if (!folder.exists())	folder.mkdirs();
		List<FileInfoDto> fileInfos = new ArrayList<FileInfoDto>();
		for (MultipartFile mfile : files) {
			FileInfoDto fileInfoDto = new FileInfoDto();
			String originalFileName = mfile.getOriginalFilename();
			if (!originalFileName.isEmpty()) {
				String saveFileName = UUID.randomUUID().toString()
						+ originalFileName.substring(originalFileName.lastIndexOf('.'));
				fileInfoDto.setSaveFolder(today);	//서버 저장폴더명
				fileInfoDto.setOriginFile(originalFileName);	//원본 파일 이름
				fileInfoDto.setSaveFile(saveFileName);	//실제 저장 파일 이름
				mfile.transferTo(new File(folder, saveFileName));	//2-5번
			}
			fileInfos.add(fileInfoDto);
		}
		//list를 게시글dto에 추가 생략
	}

	//..생략..

 

DB insert - DAO

전체적인 순서는 아래 3단계다. 게시글 dto가 가진 file목록이 없다면 2),3)을 실행하지 않고 바로 commit 한다.

1) 게시글 insert

2) insert 된 AUTO_INCREMENT column값 가져오기

3) 2)를 fk로 파일 테이블에 하나씩 저장

중요한 점은, connection의 autocommit 설정을 false로 해서 자동 커밋이 안되게 해야 한다는 것이다. 1)부터 3)까지 중 하나라도 오류가 났을 때 커밋되면 안 되기 때문이다. 모든 작업이 정상적으로 끝났을 때 commit()한다. 예외가 발생하면 rollback()한다.

public void registerArticle(게시글dto dto) throws Exception {
	Connection conn = null;
	PreparedStatement pstmt = null;
	ResultSet rs = null;
	try {
		conn = dataSource.getConnection();
		conn.setAutoCommit(false);// 자동커밋못하게 해야함
		StringBuilder registerArticle = new StringBuilder();
		registerArticle.append("insert into 게시글테이블 (컬럼명, 컬럼명, ,..) \n");
		registerArticle.append("values (?, ?, ... , ?)");
		pstmt = conn.prepareStatement(registerArticle.toString());
		pstmt.setString(번호, sql에 넣을 값);
		pstmt.executeUpdate();
		pstmt.close();//여기까지가 게시글 테이블에 insert
        
		if (dto.getFileInfos() != null) {
			String lastNo = "select last_insert_id()";
			pstmt = conn.prepareStatement(lastNo);
			rs = pstmt.executeQuery();
			rs.next();
			int articleno = rs.getInt(1);
			pstmt.close();//여기까지가 fk 가져오는 부분(AUTO_INCREMENT column 값)
            
			List<FileInfoDto> fileInfos = guestBookDto.getFileInfos();
			if (!fileInfos.isEmpty()) {
				StringBuilder reigsterFile = new StringBuilder();
				reigsterFile.append("insert into file_info (articleno, savefolder, originfile, savefile) \n");
				reigsterFile.append("values");
				int size = fileInfos.size();
				for (int i = 0; i < size; i++) {// 파일 개수만큼 (?,?,..) 늘리기
					reigsterFile.append("(?, ?, ?, ?)");
					if (i != fileInfos.size() - 1)
						reigsterFile.append(",");
				}
				pstmt = conn.prepareStatement(reigsterFile.toString());
				int idx = 0;
				for (int i = 0; i < size; i++) {// 가져온 파일 sql에 setting
					FileInfoDto fileInfo = fileInfos.get(i);
					pstmt.setInt(++idx, articleno);
					pstmt.setString(++idx, fileInfo.getSaveFolder());
					pstmt.setString(++idx, fileInfo.getOriginFile());
					pstmt.setString(++idx, fileInfo.getSaveFile());
				}
				pstmt.executeUpdate();//여기까지가 게시글 insert
			}
			
		}
		conn.commit();// 전부 커밋
		} catch (SQLException e) {
		e.printStackTrace();
		conn.rollback();// 예외생겼으면 롤백
	} finally {
		dbUtil.close(rs, pstmt, conn);
	}
}

 

DB selectAll - DAO

1.  모든 게시글을 select해서 가져온다.

2.  게시글 하나를 가져올 때마다 해당 게시글 번호로 다시 파일테이블을 검색한다. 저장된 파일이 있다면 file list에 더해서 한꺼번에 dto에 set한다.

3.  한 게시글 정보를 파일까지 모두 설정했다면 list에 더한다.

4.  마지막으로 list를 반환한다.

public List<게시글dto> listArticle(Map<String, Object> map) throws Exception {
	List<게시글dto> list = new ArrayList<게시글dto>();

	Connection conn = null;
	PreparedStatement pstmt = null;
	ResultSet rs = null;
    
	try {
		conn = dataSource.getConnection();
		StringBuilder listArticle = new StringBuilder();
		listArticle.append("select 게시글정보 \n");
		listArticle.append("from 게시글 테이블 \n");
		pstmt = conn.prepareStatement(listArticle.toString());
		rs = pstmt.executeQuery();
		while (rs.next()) {
			게시글dto dto = new 게시글dto();
			//게시글dto set하기 생략
			//---여기서부터 해당 게시글 넘버로 검색한 파일들 가져오기
			PreparedStatement pstmt2 = null;
			ResultSet rs2 = null;
			try {
				StringBuilder fileInfos = new StringBuilder();
				fileInfos.append("select savefolder, originfile, savefile \n");
				fileInfos.append("from file_info \n");
				fileInfos.append("where 게시글번호 = ?");
				pstmt2 = conn.prepareStatement(fileInfos.toString());
				pstmt2.setInt(1, 게시글번호);
				rs2 = pstmt2.executeQuery();
                
				List<FileInfoDto> files = new ArrayList<FileInfoDto>();
				while (rs2.next()) {
					FileInfoDto fileInfoDto = new FileInfoDto();
					fileInfoDto.setSaveFolder(rs2.getString("savefolder"));
					fileInfoDto.setOriginFile(rs2.getString("originfile"));
					fileInfoDto.setSaveFile(rs2.getString("savefile"));
                    
					files.add(fileInfoDto);
				}
				dto.setFileInfos(files);//파일목록 dto에 set
			} finally {
				dbUtil.close(rs2, pstmt2);
			}
			list.add(dto);//게시글 목록에 해당 dto 저장
		}
	} finally {
		dbUtil.close(rs, pstmt, conn);
	}
	return list;
}

 

DB insert - MyBatis

mapper.xml

1.  게시글을 insert한 뒤에 selectKey를 이용해 마지막 AUTO_INCREMENT 값을 가져온다. 그 값을 다시 dto에 있는 변수에 저장한다.

2.  파일을 insert할 때 게시글 dto를 받아와서 foreach로 반복해 집어넣는다.

2-1.  collection에는 반복할 객체명을 넣는다. 여기서 fileInfos는 dto변수명이다.

2-2.  item은 현재 반복하고 있는 객체다. 간단하게 쓰려면 미리 별칭을 설정해야한다.

2-3.  separator는 반복하면서 뒤에 붙일 문자열을 저장한다. 알아서 마지막 반복에는 붙이지 않는다.

<insert id="registerArticle" parameterType="게시글dto">
	insert into 테이블명 (컬럼명, 컬럼명, 컬럼명)
	values (#{dto변수명}, #{dto변수명}, #{dto변수명})
	<selectKey resultType="int" keyProperty="dto변수명" order="AFTER">
		select last_insert_id()
	</selectKey>
</insert>

<insert id="registerFile" parameterType="게시글dto">
	insert into file_info (articleno, savefolder, originfile, savefile)
	values
	<foreach collection="fileInfos" item="fileinfo" separator=" , ">
		(#{articleNo}, #{fileinfo.saveFolder}, #{fileinfo.originFile}, #{fileinfo.saveFile})
	</foreach>
</insert>

 

Dao

먼저 registerArticle을 해서 fk로 쓸 값이 dto에 저장되도록 한다. 값이 변경된 dto로 file도 insert한다.

@Override
public void registerArticle(게시글Dto dto) throws SQLException {
	try (SqlSession sqlSession = SqlMapConfig.getSqlSession()) {
		sqlSession.insert(NAMESPACE + "registerArticle", dto);
		List<FileInfoDto> fileInfos = dto.getFileInfos();
		if (fileInfos != null && !fileInfos.isEmpty()) {
			sqlSession.insert(NAMESPACE + "registerFile", dto);
		}
		sqlSession.commit();
	}
}

 

DB selectAll - MyBatis

mapper.xml

1.  resultMap을 설정했다. 칼럼명과 프로퍼티명이 다른 경우에 대해 데이터베이스 별칭을 사용하는 것과 다른 방법으로 명시적인 resultMap을 선언하는 방법이다.

2.  collection을 사용해서 List<fileInfo>를 채웠다. 각 요소의 설명은 MyBatis User Guide에서 따왔다.

property 결과 칼럼에 매핑하기 위한 필드나 프로퍼티. 
자바빈 프로퍼티가 해당 이름과 일치한다면, 그 프로퍼티가 사용될 것이다. 반면에 MyBatis는 해당 이름이 필드를 찾을 것이다. 
점 표기를 사용하여 복잡한 프로퍼티 검색을 사용할 수 있다. 
예를 들어, “username”과 같이 간단하게 매핑될 수 있거나 “address.street.number” 처럼 좀더 복잡하게 매핑될수도 있다.
column 데이터베이스의 칼럼명이나 별칭된 칼럼 라벨.
resultSet.getString(columnName) 에 전달되는 같은 문자열이다. 
Note: 복합키를 다루기 위해서, column=”{prop1=col1,prop2=col2}” 문법을 사용해서 여러개의 칼럼명을 내포된 select 구문에 명시할 수 있다. 이것은 대상의 내포된 select 구문의 파라미터 객체에 prop1, prop2 형태로 셋팅하게 될 것이다.
javaType 패키지 경로를 포함한 클래스 전체명이거나 타입 별칭. 
자바빈을 사용한다면 MyBatis는 타입을 찾아낼 수 있다. 
반면에 HashMap 으로 매핑한다면, 기대하는 처리를 명확히 하기 위해 javaType을 명시해야 한다.
ofType 리스트의 타입. 미리 별칭을 설정해야 간단하다.
select 다른 매핑된 구문의 ID는 이 프로퍼티 매핑이 필요로 하는 복잡한 타입을 로드할 것이다. column속성의 칼럼으로 부터 가져온 값은 대상 select 구문에 파라미터로 전달될 것이다.
Note: 복합키를 다루기 위해서, column=”{prop1=col1,prop2=col2}” 문법을 사용해서 여러개의 칼럼명을 내포된 select 구문에 명시할 수 있다. 이것은 대상의 내포된 select 구문의 파라미터 객체에 prop1, prop2 형태로 셋팅하게 될 것이다.
<resultMap type="게시글dto" id="articleList">
    <result column="컬럼명" property="dto변수명"/>
    <result column="컬럼명" property="dto변수명"/>
    <collection property="fileInfos(dto변수명)" column="articleno=articleno" javaType="list" ofType="fileinfo" select="fileInfoList(파일리스트select할id)"/>
</resultMap>

<select id="listArticle" parameterType="map" resultMap="articleList(위에서_지정한_이름)">
    select 가져올컬럼명
    from 가져올테이블
</select>

<select id="fileInfoList" resultType="fileinfo">
    select savefolder, originfile, savefile
    from file_info
    where articleno = #{articleno}(조건)
</select>

 

Dao

mapper보다 dao가 훨씬 간단하다. selectList한 값을 return한다.

@Override
public List<게시글dto> listArticle(Map<String, Object> map) throws SQLException {
	try (SqlSession sqlSession = SqlMapConfig.getSqlSession()) {
		return sqlSession.selectList(NAMESPACE + "listArticle", map);
	}
}

 

 

 

 

'기록 > BACKEND' 카테고리의 다른 글

[Spring] Interceptor  (0) 2022.04.24
[Spring] File Download  (0) 2022.04.23
[Spring] 예외처리하기 - ControllerAdvice  (0) 2022.04.21
[Spring] DI  (0) 2022.04.20
[Spring] JNDI 설정하기  (0) 2022.04.19

댓글