🐸SpringBoot 파일 처리 총 정리 (파일 업로드/다운로드, 파일 경로, 파일 삭제) springboot + thymeleaf

2024. 5. 29. 17:38·SpringBoot

 

꽤나 애를 먹인 파일 처리 부분을 완벽 정리해봤다.

최대한 자세히 정리하겠지만, 이해가 안 가거나 어려운 내용이 있다면 댓글을 달아주세요!

 

 

이런 멋진 사진이나 영상, pdf, hwp 등 다양한 파일들을 업로드 해보자!


1. 파일 경로 결정

가장 먼저 파악해야 할 것은 파일 경로다.

스프링부트는 내장톰캣을 사용하기 때문에 (but 현재 회사에선 배포할 때 외장톰캣을 씀)
임시 폴더에 넣으면 톰캣을 재실행했을 때 날라가기 때문에 (⬅️이게 젤 큰 이유)

 

프로젝트 내부가 아닌 외부에 따로 폴더를 만들어서 저장 하기로 했다.(주로 이렇게 하는 듯)

 

OS 에 따라 조금씩 달라지는데

Window D:/upload

Linux /upload

MacOS /Users/upload

 

참고로 나는 Windows(회사) Linux(회사 서버) Mac(개인 프로젝트) 을 모두 사용하고 있고, 모두 잘 작동하는 걸 확인했다.

굳이 나처럼 최상단에 둘 필요는 없고, 원하는 곳으로 지정하면 된다. 핵심은 파일 경로를 외부에 두고 관리한다는 점!

 

 

 


2. 기본 설정(backend)

기본 세팅이 필요하다. 우선 springboot는 모든 기본 설정(DB 연결, 특정 경로 지정 등) 을 application.properties에서 한다. 아래와 같이 file을 어느 경로에, 한 번에 최대 어느 용량으로 저장할 건지 결정할 수 있다. 

 

 

application.properties

# file setting (default limits individual: 1MB / the total size of files: 10MB.)
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=30MB
spring.servlet.multipart.max-request-size=150MB

# file upload path
#(windows)
upload.folder.path=D:/upload

#(linux)
#upload.folder.path=/upload

#(macOS)
#upload.path.userphoto=/Users/upload

 

 

WebMvcConfigurer.java

 

그리고 application.properties에서 설정한 값을 특정 경로에 매핑하겠다고 설정하면 된다!  이 설정은 WebMvcConfigurer 을 implements 한 class 파일에 적어주면 된다. 나는 WebMvcConfigurer 라고 이름한 클래스 파일에 설정했다.

 

package com.***.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;

@Configuration
public class WebMvcConfigurer implements org.springframework.web.servlet.config.annotation.WebMvcConfigurer {
		

	// 파일 경로용 매핑
    @Value("${upload.folder.path}")
    private String uploadFolderPath;

	// '/upload-path'라는 경로를 application.properties에서 설정한 value로 매핑
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/upload-path/**")
                .addResourceLocations("file:///" + uploadFolderPath + "/");		// windows
//		        .addResourceLocations(uploadFolderPath + "/");					// linux
//        		.addResourceLocations("file://"+rentalUploadPath + "/");		// macOs
    }
	
}

 

 

 


3. 파일 업로드 처리 (view)

form 을 넘기는 2가지의 방법이 있는데, 둘다 잘 사용하고 있다만 방법이 조금 다르다.

 

 

옵션1) form 태그를 이용해서 넘길 때 

enctype="multipart/form-data" 를 form 에 추가해서 적어주고 submit 시키면 된다.

    <form id="fileSubmit" action="/경로" method="post" enctype="multipart/form-data">
		<input type="file" name="file" id="fileInput" class="form-input">
    </form>

 

 

 

옵션2) ajax로 form 값을 넘길 때

중요한 점은 ajax을 통해 백으로 form 을 넘길 때, formData를 사용해야 한다는 것이다! 아래 예시를 잘 확인해보도록!


이제 파일 업로드를 해보자. 참고로 뷰는 본인 하기 나름이라 최대한 간단하게 만들었다. 최대 5개의 파일을 30MB의 용량까지 업로드할 수 있게 해뒀다.


현재 alertify 를 쓰고 있는데 만약 싫다면, 그냥 alert로 바꿔도 무방하다.

본인에게 맞춰 view를 변경하시길!

 

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<title>Insert title here</title>
</head>
<body>
	<div>
		<div class="table-responsive">
		    <table class="table">
		        <thead>
		            <tr>
		                <th colspan="2"></th>
		            </tr>
		        </thead>
		        <tbody>
		            <tr>
		                <td><label for="title"><b>제목</b></label></td>
		                <td>
		                	<input type="text" id="title" class="form-control" value="" title="제목">
		                </td>
		            </tr>
		            <tr>
		                <td><b>작성자</b></td>
		                <td>관리자</td>
		            </tr>
		            <tr>
		                <td><b>내용</b></td>
		                <td>
		                	<textarea class="form-control" id="content" rows="3" title="내용" style="height:412px; resize:none;"></textarea>
		                </td>
		            </tr>
		            <tr>
		                <td><b>첨부파일</b></td>
		                <td>
		               		<input class="form-control mb-1" type="file" id="file_1">
		               		<input class="form-control mb-1" type="file" id="file_2">
		               		<input class="form-control mb-1" type="file" id="file_3">
		               		<input class="form-control mb-1" type="file" id="file_4">
		               		<input class="form-control mb-1" type="file" id="file_5">
		               		<small><em>첨부파일 가능 용량은 파일 당 최대 30MB 입니다.</em></small>
		               		
		                </td>
		            </tr>
		        </tbody>
		    </table>
		</div>
	                
        <div class="quform-submit-inner text-center">
        	<button class="butn" type="button" onclick="validateForm()"><span>전송</span></button>
        </div>

	</div>



	<script>
	    
	    function validateForm() {
	        
	    	const title = document.getElementById('title').value.trim();
	        const content = document.getElementById('content').value.trim();
	        const files = [
	            document.getElementById('file_1').files[0],
	            document.getElementById('file_2').files[0],
	            document.getElementById('file_3').files[0],
	            document.getElementById('file_4').files[0],
	            document.getElementById('file_5').files[0]
	        ];

	     	// 1) 제목 공백 유무
	        if (title === "") {
	        	alertify.notify("<strong><u>제목</u></strong>을 입력해주세요.", "error");
	            return;
	        }

	        // 2) 내용 공백 유무
	        if (content === "") {
	        	alertify.notify("<strong><u>내용</u></strong>을 입력해주세요.", "error");
	            return;
	        }

	        // 3) 파일 사이즈 확인
			// 3-1) 파일 사이즈 및 파일명 확인
			let totalSize = 0;
			let oversizedFiles = [];
			
			for (let fileInfo of files) {
			    if (fileInfo) { 								// fileInfo가 undefined가 아닌 경우
			        totalSize += fileInfo.size;
			        if (fileInfo.size > 30 * 1024 * 1024) { 	// 30MB
			            oversizedFiles.push(fileInfo.name);
			        }
			    }
			}
			
			// 3-2) 알림 
			if (totalSize > 30 * 1024 * 1024) {
			    alertify.notify("첨부파일 총 용량은 최대 <strong><u>30MB</u></strong> 입니다.", "error");
			    return;
			}
			
			if (oversizedFiles.length > 0) {
			    let oversizedFilesNames = oversizedFiles.join(', ');
			    alertify.notify(`파일 ${oversizedFilesNames}의 크기가 30MB를 초과합니다.`, "error");
			    return;
			}
			
			
			// 4-1) formData에 추가
	        const formData = new FormData();
	        formData.append('title', title);
	        formData.append('content', content);
	     	
	        
	        // 4-2) 파일 배열을 formData에 추가
	        files.forEach((file, index) => {
	            if (file) {
	                formData.append('files', file); // 'files' 키를 사용하여 배열 형태로 추가
	            }
	        });
	        
	        
			// formData 확인
// 			for (let [key, value] of formData.entries()) {
// 			    console.log(`${key}: ${value}`);
// 			}
			
			
 	     	// 5) 전송
	        $.ajax({
	            url: '/archive/register',
	            type: 'POST',
	            data: formData,
	            processData: false,
	            contentType: false,
	            success: function() {
	                alertify.notify("<strong>[등록]</strong><br/>성공적으로 업로드 되었습니다.<br/><u>페이지를 이동</u>합니다.", "success");
	                setTimeout(() => location.href = '/이동경로', 1500); 	// 1.5초 후 페이지 이동
// 	                setTimeout(() => location.reload(), 2500); 		  	// 2.5초 후 새로고침
	            },
	            error: function (error) {
	                alert('[오류 발생] 담당자에게 문의해주세요.');
	                console.log('Error 발생: ', error);
	            }
	        });
	    }
	</script>
</body>
</html>

 

 

 

 


 

4. 파일 업로드 처리 (back)

이제 중요한 back 부분을 다룰 건데, 파일 관련 코드만 적어뒀으니 그 부분만 주의깊게 보면 된다.

 

FileVO

@Data 는 비추 하지만, 편리를 위해 여기선 사용했다.

@Data
public class FileVO {

	int fileId;
	/*
    	중략
    */
	String originalName;
	String serverName;
	String filePath;
	String createDatetime;
	
}

 

 

controller

옵션2 ajax로 form 값을 넘길 때 를 기준으로 짜봤다!

	@ResponseBody
	@PostMapping("/경로")
	public void postTestRegister(
			HttpServletRequest request,
			@RequestParam("title") String archiveTitle,
            @RequestParam("content") String archiveContent,
            @RequestParam(name="files", required = false) MultipartFile[] files) throws IllegalStateException, IOException {
		
		/*
        	중략
        */
        
		// 파일 저장 
		testService.uploadFiles(files);
	}

 

 

service

동시에 여러 접속이 발생할 수 있기 때문에 세마포어를 활용했다. 필요없다면 지워도 무방하다.

세마포어(Semaphore)는 프로그래밍에서 여러 스레드가 공유 자원에 접근하는 것을 제어하는 데 사용되는 동기화 도구입니다.

 

 

주석을 열심히 달아두었으니 꼼꼼히 확인해볼 것!

앞서 WebMvcConfigurer.java에서 활용한 것 처럼 application.properties에 한 설정(uploadFolderPath)를 가져와서 파일을 만드는 걸 확인할 수 있다. 

	// 파일을 저장할 경로 설정 (static 디렉토리)
	@Value("${upload.folder.path}")
	private String uploadFolderPath;
    
    
	// =========================== 파일 업로드 로직 ===========================
	
	// 세마포어 객체 생성 (permit(1), fair(true) = 공유자원 1개, FIFO)
	private final Semaphore semaphore = new Semaphore(1, true); 
	
	public void uploadFiles(MultipartFile[] files) throws IllegalStateException, IOException {
		
		try {
			semaphore.acquire(); 		// 세마포어 획득
			
            		// 1) 파일 끝 경로
			String folderPath = "/test";
			
			for (int i=0; i<files.length; i++) {
	            MultipartFile file = files[i];

	            
				// 2) 파일 이름 변경
				String fileName = fileRename(file.getOriginalFilename());
//				log.info("file : {}" ,file);
//				log.info("folderPath : {}" ,folderPath);
//				log.info("fileName : {}" ,fileName);
//				log.info("============================");
				
				
				// 3) 이미지를 저장할 파일 객체 생성
				File uploadFile = new File(uploadFolderPath + folderPath, fileName);
				//throw new IllegalStateException("Illegal state occurred");
				
				
				// 4) 파일을 해당 위치에 저장
				file.transferTo(uploadFile);
				
				
				// 5) 파일 객체에 파일 정보 설정
				FileVO fileVo = new FileVO();
				
                /*
                	중략
                */
                
				fileVo.setOriginalName(file.getOriginalFilename());		// 	01.jpg
				fileVo.setServerName(fileName);							// 	240528103837555_88cf7914.jpg
				fileVo.setFilePath(folderPath + "/" + fileName);		// 	/test/240528103837555_88cf7914.jpg	
				
//				System.out.println("fileVo : " + fileVo);
				
				// 6) DB 저장
				testMapper.insertTestFile(fileVo);
			}
			
			
		} catch (InterruptedException e) {
			log.error("Semaphore acquisition interrupted : {}", e);
			Thread.currentThread().interrupt(); // 스레드 인터럽트 처리
		} catch (Exception e) {
			log.error("File data inserted Error : {}", archiveVo, e);
		} finally {
			semaphore.release(); 				// 세마포어 반환
		}
		
		
	}
    
    
    
    
    // 파일명 변경 메소드
	public static String fileRename(String originalFileName) {
		SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmssS");
		String date = sdf.format(new java.util.Date(System.currentTimeMillis()));

		UUID uuid = UUID.randomUUID();
		String uuidString = uuid.toString();
		String first8Characters = uuidString.substring(0, 8);

		String ext = originalFileName.substring(originalFileName.lastIndexOf("."));

//		return date + "_" + first8Characters + "_" + originalFileName;
		return date + "_" + first8Characters + ext;
	}

 

 

 

만약 날짜별로 값을 가져와, 폴더를 "/test/24/05/29/240528103837555_88cf7914.jpg" 이렇게 날짜 폴더를 생성시켜주고 싶다면 아래의 메소드를 활용하면 된다!

    // 1) 날짜별 폴더 생성 => 여기에 파일 넣음!
    String folderPath = makeFolder("/test");


    // 앞선 코드의 1)을 위처럼 변경해주면 된다.

//====================================================================================


    // 날짜 폴더 생성
	private String makeFolder(String uploadPath) {

		// 현재 날짜를 기준으로 년/월/일의 계층 구조를 가진 폴더 경로 생성
	    String str = LocalDate.now().format(DateTimeFormatter.ofPattern("yy/MM/dd"));
	    
	    // 파일 시스템에 맞는 구분자로 변경
	    String folderPath = str.replace("/", File.separator);
	    
	    // 폴더 객체 생성
	    File uploadPathFolder = new File(uploadPath, folderPath);
	    
	    // 폴더가 존재하지 않으면 생성
	    if (!uploadPathFolder.exists()) {
	        boolean mkdirs = uploadPathFolder.mkdirs();
	        
	        // 폴더 생성 로그 출력 (optional)
	        log.info("-------------------makeFolder------------------");
	        log.info("uploadPathFolder.exists(): " + uploadPathFolder.exists());
	        log.info("mkdirs: " + mkdirs);
	    }
	    
	    return "/" + folderPath;
	}

 

 

 

자 다시 돌아와서 FileVO를 DB에 잘 저장했다면, 이렇게 저장되어 있을 것이다.

 

 

 


5. 업로드 결과 보기

잘 업로드된 값을 확인해보자! 참고로 현재 이 코드는 thymeleaf를 사용 중!

// 1) 이미지 보기
<img th:src="'/upload-path' + ${entry.filePath}" alt="사진">

// 위 코드는 아래와 동일하다. local 서버에서 띄우면 이렇게 뜸
// <img th:src="http://localhost/upload-path/test/240529135713642_90278e8c.jpg" alt="사진">


// 2) 파일 다운로드
// 이렇게 적어주면 다운될 때 serverName이 아닌 originalName으로 다운된다.
<a th:href="'/upload-path' + ${entry.filePath}" th:download="${entry.originalName}" class="cursor-pointer">
    <span th:text="${entry.originalName}"></span>
</a>

 

 

 

좀 더 멋지게 파일을 뿌려주고 싶다면, https://www.lightgalleryjs.com/ 같은 라이브러리도 추천한다 😘😊

 

 

 

참고로 파일은 우리가 초기에 설정한 D드라이버에 잘 들어가 있다.

 

 

 

 

 


6. 업로드 한 파일 삭제하기

삭제는 정말 쉽다. service 만 확인해보자.

 

service

파일 정보 (경로+이름) 을 가져와서 해당 파일이 실제로 존재하면 삭제하는 코드를 적어줬다.

	
	public void deleteTestFiles(ArrayList<FileVO> files) throws IllegalStateException, IOException {
		
		try {
			semaphore.acquire(); 		// 세마포어 획득
			
			for (FileVO fileVo : files) {
				
				// 1) 파일명 
				String filePath = fileVo.getFilePath();
				String fullFilePath = uploadFolderPath + filePath;
				
				File file = new File(fullFilePath);
				
				// 2) 해당 파일 존재할 경우, 삭제처리
				if (file.exists()) {
					if (!file.delete()) {
						log.warn("Failed to delete file: {}", fullFilePath);
					}
				} 
				else log.warn("File not found: {}", fullFilePath);
				
				// 3) DB에서 삭제
				testMapper.deleteTestFile(fileVo);
			}
			
			
		} catch (InterruptedException e) {
			log.error("Semaphore acquisition interrupted : {}", e);
			Thread.currentThread().interrupt(); // 스레드 인터럽트 처리
		} catch (Exception e) {
			log.error("File data inserted Error : {}", archiveVo, e);
		} finally {
			semaphore.release(); 				// 세마포어 반환
		}
		
		
	}

 

 

저작자표시 비영리 변경금지 (새창열림)

'SpringBoot' 카테고리의 다른 글

🐸SpringBoot 파일 처리 총 정리 2탄 (static 경로에 파일 업로드/다운로드, 파일 경로, 파일 삭제) springboot + thymeleaf  (0) 2024.05.30
🐸iframe에서 부모창 함수 호출  (0) 2024.05.14
🐸[Select-Picker] Uncaught TypeError: Cannot read property of undefined (reading 'Constructor') 해결  (0) 2024.05.02
🐸STS 퀵 서치 (Quick Search) 플러그인 설치  (0) 2024.04.22
🐸스프링부트 A project with the name already exists. 해결  (0) 2024.03.18
'SpringBoot' 카테고리의 다른 글
  • 🐸SpringBoot 파일 처리 총 정리 2탄 (static 경로에 파일 업로드/다운로드, 파일 경로, 파일 삭제) springboot + thymeleaf
  • 🐸iframe에서 부모창 함수 호출
  • 🐸[Select-Picker] Uncaught TypeError: Cannot read property of undefined (reading 'Constructor') 해결
  • 🐸STS 퀵 서치 (Quick Search) 플러그인 설치
루룩lulook
루룩lulook
  • 루룩lulook
    bean말이 아니라 진짜 gradle합니다
    루룩lulook
  • 전체
    오늘
    어제
    • 분류 전체보기
      • PROJECT
        • 수학 학습플랫폼(예정)
        • 찬양 PPT 자동생성기(26.04)
        • 레시퓨(26.01)
      • INSIGHT
      • SpringBoot
      • 기술서적 정리
      • Krpano
      • AWS
      • 자격증
  • 링크

    • 일상 블로그
  • 공지사항

  • 인기 글

  • 태그

    Aprojectwiththenamealreadyexists
    bootstrap-select 적용
    cmux
    GPT-SoVITS
    GPT-SoVITS사용법
    GPT-SoVITS세팅
    GPT-SoVITS정리
    iframe 코드
    iframe에서 부모창 함수 호출
    iframe에서 부모창 함수 호출 코드
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
루룩lulook
🐸SpringBoot 파일 처리 총 정리 (파일 업로드/다운로드, 파일 경로, 파일 삭제) springboot + thymeleaf
상단으로

티스토리툴바