개발하는 햄팡이

[Spring] 파일입출력 - 같은 파일 업로드 요청 시 계속 저장되는 문제 - Hash로 해결 본문

Back-End/Spring

[Spring] 파일입출력 - 같은 파일 업로드 요청 시 계속 저장되는 문제 - Hash로 해결

hampangee 2025. 7. 29. 19:16

파일입출력 담당을 맡아 여기저기 찾아보며 이미지 업로드를 구현했는데

 

제목을 UUID로 설정하고 저장했더니 중복된 이름을 걸러주어 경로를 신경써야하는 문제는 없어졌지만

같은 파일을 연달아 업로드 요청을 보냈을 경우 계속 저장되는 문제가 생겼다.

 

이렇게 되면 저장 공간이 낭비되겠지...

같은 파일은 안저장하고 싶다는 생각이 들었다...

 

 

그래서 gpt한테 물어본 결과 

파일 중복 업로드 방지를 위해선 파일의 내용(바이너리 데이터)을 InputStream으로 읽어서 그 데이터를 SHA-256으로 해싱하여 해시값을 사용하여 저장을 하면 된다고 한다.

 

 

 

gpt가 말은 해줬는데 뭐라는건지 모르겠어서 인터넷 여기저기 찾아본 결과

 

1. 해시값은 파일의 내용을 가지고 만들기 때문에 파일이 같으면 해시값도 같기 때문에 중복 저장 방지 가능(우리의 목적)

2. 내용이 조금이라도 다르면 전혀 다른 해시가 나옴 → 사용자가 같은 이름의 파일을 업로드해도 저장 경로는 충돌하지 않음.

3. 사용자 업로드 파일명을 그대로 저장할 경우, 외부 사용자가 파일명으로 접근할 수도 있음.(S3정책으로 막아놓긴했다..)

 

 

이전 프로젝트에서 누군가가 구현해놨던 파일 입출력이 엄청 복잡했는데

왜 그렇게 구현했는지 이해할 수 있었다. 그때 당시 코드를 가져와서 우리 프로젝트에 적용을 해보자.

 

일단 Hash값으로 바꿔주는 코드

▼ ResourceHashUtil.java

더보기

팀원들을 위해서 주석을 달아 좀 길어졌다...

package com.explorer.gabom.global.file.util;

import org.springframework.util.StringUtils;

import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

/**
 * 파일의 내용을 기반으로 SHA-256 해시를 생성하고,
 * 해당 해시 값을 디렉토리 경로와 파일명으로 분리하여 저장 경로로 활용할 수 있도록 도와주는 유틸리티 클래스입니다.
 *
 * <p><b>주요 목적</b>:
 * <ul>
 *   <li>파일 중복 업로드 방지</li>
 *   <li>고유한 파일 경로 생성</li>
 *   <li>디렉토리 구조 분할을 통한 저장소 최적화</li>
 * </ul>
 *
 * <p><b>제공 기능</b>:
 * <ul>
 *   <li>{@link #generateHash(InputStream)}:
 *       파일 내용을 읽어 SHA-256 해시를 생성하고 Base64 URL-safe 형식으로 반환합니다.</li>
 *   <li>{@link #getDirPath(String)}:
 *       해시 값의 앞 부분을 디렉토리 이름으로 사용합니다.</li>
 *   <li>{@link #getFilePath(String)}:
 *       해시 값의 나머지를 파일 이름으로 사용합니다.</li>
 * </ul>
 *
 * <p><b>사용 예</b>:
 * <pre>{@code
 * String hash = ResourceHashUtil.generateHash(inputStream); // 예: "abCDe123456789..."
 * String dir = ResourceHashUtil.getDirPath(hash);           // "ab"
 * String file = ResourceHashUtil.getFilePath(hash);         // "CDe123456789..."
 * String fullPath = dir + "/" + file;                       // "ab/CDe123456789..."
 * }</pre>
 *
 * <p><b>참고</b>:
 * <ul>
 *   <li>해시 계산 시 InputStream을 버퍼 단위(8192바이트)로 읽어 메모리 효율을 높입니다.</li>
 *   <li>Base64 인코딩은 URL-safe 방식이며, 패딩(`=`) 없이 반환됩니다.</li>
 * </ul>
 */
public class ResourceHashUtil {

	private static final int BUFFER_SIZE = 8192;
	private static final int DIR_LENGTH = 2;	// 디렉토리 구분용

	// 인스턴스화 방지를 위한 private 생성자
	private ResourceHashUtil() {
		throw new IllegalStateException("Utility class");
	}

	/**
	 * InputStream을 기반으로 SHA-256 해시 값을 생성
	 * 해시는 URL-safe Base64로 인코딩되어 반환됨
	 *
	 * @param inputStream 파일 InputStream
	 * @return 해시 문자열 (Base64 URL-safe, padding 제거됨)
	 * @throws IOException 입력 스트림 처리 중 오류
	 */
	public static String generateHash(InputStream inputStream) throws IOException {
		try {
			MessageDigest digest = MessageDigest.getInstance("SHA-256");
			byte[] buffer = new byte[BUFFER_SIZE];
			int length;
			while ((length = inputStream.read(buffer)) != -1) {
				digest.update(buffer, 0, length);
			}
			byte[] hash = digest.digest();
			return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
		} catch (NoSuchAlgorithmException e) {
			throw new RuntimeException("Could not find the algorithm", e);
		}
	}

	/**
	 * 해시 문자열에서 앞 N글자를 디렉토리 이름으로 추출
	 * (ex. 해시가 abcdef이면 "ab" 반환)
	 *
	 * @param hash 해시 문자열
	 * @return 디렉토리 이름
	 */
	public static String getDirPath(String hash) {
		if (!StringUtils.hasText(hash)) {
			throw new RuntimeException("Hash is EMPTY");
		}

		return hash.substring(0, DIR_LENGTH);
	}

	/**
	 * 해시 문자열에서 앞 N글자를 제외한 나머지를 파일 이름으로 추출
	 * (ex. 해시가 abcdef이면 "cdef" 반환)
	 *
	 * @param hash 해시 문자열
	 * @return 파일 이름 부분
	 */
	public static String getFilePath(String hash) {
		if (!StringUtils.hasText(hash)) {
			throw new RuntimeException("Hash is EMPTY");
		}

		return hash.substring(DIR_LENGTH);
	}
}

1. 파일 InputStream을 매개변수로 받아 SHA-256알고리즘을 사용하여 해시값을 만든다.

    - 이때 MessageDigest해시 함수를 제공해주고, 입력받은 파일을 해시 알고리즘을 적용하여 결과를 반환해준다.

2. 파일을 일정한 크기( BUFFER_SIZE=8192바이트 = 8KB )로 잘라 읽어들이기 위한 공간(buffer)을 만든다.

3. InputStream에서 buffer만큼 읽었을때 길이가 -1이 아닐때까지(내용이 남아있지 않을때까지) 데이터를 update()하여 누적한다.

4. .digest()메소드로 해시 함수를 적용한다.

5. 결과물을 인코딩해서 반환해준다.

 

 

 

이 Util클래스를 사용해서 FileService의 upload() 메소드에서 경로를 Hash값으로 만들어 주면 된다.

▼ AWSS3FileStorageService

더보기
@Service
@RequiredArgsConstructor
public class AWSS3FileStorageService implements FileStorageService {
	private final AWSS3ClientProviderForAttachment awsS3ClientProviderForAttachment;

	@Value("${spring.cloud.aws.s3.bucket}")
	private String bucket = "";

	@Override
	public String uploadFile(MultipartFile file) throws IOException {
		// 파일 내용을 기반으로 hash값 생성.
		String hash = ResourceHashUtil.generateHash(file.getInputStream());
		ObjectMetadata objectMetaData = new ObjectMetadata();
		objectMetaData.setContentType(file.getContentType());
		objectMetaData.setContentLength(file.getSize());
		awsS3ClientProviderForAttachment.getS3Client()
										.putObject(new PutObjectRequest(bucket,
																		this.getTargetPath(hash),
																		file.getInputStream(),
																		objectMetaData));

		return hash;
	}

	@Override
	public InputStream getInputStream(String hash) {
		S3Object object =
			awsS3ClientProviderForAttachment.getS3Client()
											.getObject(new GetObjectRequest(bucket, this.getTargetPath(hash)));
		return object.getObjectContent();
	}

	@Override
	public void deleteFile(String hash) {
		this.awsS3ClientProviderForAttachment.getS3Client().deleteObject(new DeleteObjectRequest(bucket,
																								 this.getTargetPath(
																									 hash)));
	}

	private String getTargetPath(String hash) {
		return Paths.get(keyPrefix, ResourceHashUtil.getDirPath(hash), ResourceHashUtil.getFilePath(hash)).toString();
	}

}

 

 

그리고 이제 파일 업로드가 필요한 곳에서 잘 갖다 쓰면 된다.