Spring Boot에서 AWS S3 파일 업로드/다운로드하기

KJ·2023년 12월 22일

파일 업로드/다운로드 기능을 만들어야 할 일이 생겼다. 그래서 Spring Boot에서 AWS S3로 파일 업로드/다운로드 기능을 구현하면서 어떻게 처리했는지 어떤 문제가 발생했는지 작성해 보려고 한다.

이 글에서는 AWS S3 설정 부분은 다루지 않는다. 나중에 S3에 대한 내용을 포스팅 예정이다.

파일 업로드/다운로드를 위한 Spring 기본 설정

추가 필요한 라이브러리

/** AWS S3 관련 */
// https://mvnrepository.com/artifact/io.awspring.cloud/spring-cloud-starter-aws
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4'

/** 파일 업로드 */
// https://mvnrepository.com/artifact/commons-io/commons-io
implementation 'commons-io:commons-io:2.14.0'// https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload
implementation 'commons-fileupload:commons-fileupload:1.5'

/** 파일 형식 체크를 위한 라이브러러 */
// https://mvnrepository.com/artifact/org.apache.tika/tika-core
implementation 'org.apache.tika:tika-core:2.9.1'

application.yml 설정

cloud:
  aws:
    credentials:
      accessKey: 액세스 키
      secretKey: 시크릿 키
    s3:
      bucket: 버킷명
    region:
      static: 리전명(ap-northeast-2) -> 리전을 서울로 사용 중이라 ap-northeast-2
    stack:
      auto: false
  • cloud.aws.stack.auto 옵션의 의미
    EC2에서 Spring Cloud 프로젝트를 실행시키면 기본으로 CloudFormation 구성을 시작한다. 설정한 CloudFormation이 없으면 프로젝트 시작이 안되니, 해당 내용을 사용하지 않도록 false를 등록한다.
    참고 : SpringBoot & AWS S3 연동하기

Bean 등록

/** S3 연결을 위한 Bean 등록 */
@Configuration
public class AWSS3Config {

    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        return (AmazonS3Client)AmazonS3ClientBuilder
                .standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
    }

}

/** 파일 타입 체크를 위한 Bean 등록 */
@Configuration
public class FileConfig {


    // 파일 타입 체크를 위한 라이브러리 Bean 등록
    @Bean
    public Tika getTika() {
        return new Tika();
    }
}

로직 작성

파일 업로드

  • 업로드 프로세스
    • 업로드할 정보(MultipartFile과 디렉토리)를 파라미터로 전달받음
    • 파일명을 UUID로 변환
    • 파일을 S3에 업로드
    • 파일 기본 정보를 DB에 저장
    • 정상적으로 업로드하면 파일의 기본 정보를 Return 하고 파일 업로드 종료
  • 참고 사항
    • 업무마다 파일 업로드하는 절차는 다르기 때문에 파일을 업로드하는 컨트롤러는 작성하지 않았다. 현재 업무 기준 파일만 업로드하는 경우는 필요하지 않다. 추후에 파일만 업로드해야 하는 케이스가 생길 경우 컨트롤러는 작성될 수 있다. 따라서 서비스 로직을 호출해서 파일을 업로드한 후 파일의 정보를 Return 하는 서비스 로직만 작성했다. 파일을 업로드한 후 각 업무에서 파일 정보와 업무를 맵핑 시켜 저장한다.
    • S3에는 파일명을 UUID로 저장한다.
    • Bean으로 등록해놓은 amazonS3Client의 putObject 메소드를 이용해 S3에 파일을 업로드한다.
    • DB에 저장하는 파일 정보는 S3 키값, 파일 content-type, 파일 사이즈, 원본 파일명, 상태(삭제 여부), 등록일, 수정일을 저장한다. 관리가 필요한 추가 정보가 있는 경우 추가 예정
/** 파일을 업로드 하는 메소드 */
public File uploadFile(MultipartFile multipartFile, String directory) {

        String fileName = createFileName(multipartFile.getOriginalFilename());
        String key = profile + "/" + directory + "/" + fileName;

        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(multipartFile.getSize());
        metadata.setContentType(multipartFile.getContentType());

        try (InputStream inputStream = multipartFile.getInputStream()) {
            amazonS3Client.putObject(new PutObjectRequest(bucket, key, inputStream, metadata));
        } catch (IOException e) {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드에 실패했습니다.");
        }

        File file =
                File
                .builder()
                .key(key)
                .contentType(multipartFile.getContentType())
                .size(multipartFile.getSize())
                .name(multipartFile.getOriginalFilename())
                .build();

        fileRepository.save(file);
        return file;
}



/**
파일명을 UUID로 변경. 뒤에 파일 확장자를 붙이기 위해 파일명을 파라미터로 받는다.
*/
private String createFileName(String fileName) {
        return UUID.randomUUID().toString().concat(getFileExtension(fileName));
}

파일 다운로드

  • 다운로드 프로세스
    • file 고유값으로 파일 다운로드를 요청
    • 파일 정보를 조회
    • 저장된 원본 파일명이 한글인 경우 인코딩이 필요해 인코딩 처리
    • 파일 정보에 저장된 S3 key를 이용해 S3에서 파일을 가져옴
    • 스트리밍 방식으로 데이터를 Client로 전달
  • 참고 사항
    • S3에서 파일을 가져올 때 amazonS3Client의 getObject 메소드를 이용해 가져온다.
  • 최초 코드
/** 파일을 다운로드하는 메소드 */
@Override
public ResponseEntity<?> downloadFileBlob(long id, HttpServletRequest request, HttpServletResponse response) {
	File file = findById(id);

	String downloadFileName = file.getName();

	try (S3Object s3Object = amazonS3Client.getObject(bucket, file.getKey()); S3ObjectInputStream objectInputStream = s3Object.getObjectContent()) {

		// 파일을 메모리에 로딩해 바이트로 변환
		byte[] bytes = IOUtils.toByteArray(objectInputStream);

		String fileName = makeFileName(request, Objects.requireNonNullElse(downloadFileName, file.getKey()));

		HttpHeaders headers = new HttpHeaders();
	    headers.setContentType(MediaType.parseMediaType(file.getContentType()));
    	headers.setContentLength(bytes.length);
	    headers.setContentDispositionFormData("attachment", fileName);

		return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
	} catch (IOException e) {
    	log.debug(e.getMessage(), e);
	    throw new BusinessException(ApiResponseCode.FILE_DOWNLOAD_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}
  • 문제점
    • 최초 구현 - 파일을 읽어와 전체 파일을 메모리에 로드해서 byte로 Client에 전달하는 코드를 작성하였다. 테스트 중 용량이 큰 파일은 메모리 소비가 커서 서버가 죽는 현상이 발생했다. (ec2의 프리티어인 t2.micro 사용)
    • 수정 1 - 파일을 직접 처리하지 않고 presignedURL을 발급받아서 처리
    • 수정 2 - 수정 1 방법으로 했을 때 정상 처리되었지만 만족스럽지 않아서 다른 방법을 찾아보니 전체 파일을 메모리에 로드하지 않고 스트리밍 방식으로 처리하면 메모리를 효율적으로 사용할 수 있는 것을 찾았다(feat. 챗GPT). 수정 후 테스트 시 서버가 죽지 않고 정상 다운로드 되는 것을 확인했다. 아래 코드는 전체 파일을 로드하지 않고 스트리밍 방식으로 처리한 코드이다.
  • 수정 코드
/** 파일을 다운로드하는 메소드 */
@Override
public ResponseEntity<?> downloadFileBlob(long id, HttpServletRequest request, HttpServletResponse response) {
        File file = findById(id);

        String downloadFileName = file.getName();

        try {
            String fileName = makeFileName(request, Objects.requireNonNullElse(downloadFileName, file.getKey()));
            S3Object s3Object = amazonS3Client.getObject(bucket, file.getKey());
            S3ObjectInputStream objectInputStream = s3Object.getObjectContent();
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.parseMediaType(file.getContentType()));
            headers.setContentDispositionFormData("attachment", fileName);
            
            // 파일을 스트리밍 방식으로 응답
            return new ResponseEntity<>(new InputStreamResource(objectInputStream), headers, HttpStatus.OK);
        } catch (IOException e) {
            log.debug(e.getMessage(), e);
            throw new BusinessException(ApiResponseCode.FILE_DOWNLOAD_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
    
/**
 * 파일명이 한글인 경우 URL encode이 필요함.
 * @param request
 * @param displayFileName
 * @return
 * @throws UnsupportedEncodingException
 */
 private String makeFileName(HttpServletRequest request, String displayFileName) throws UnsupportedEncodingException {
 	String header = request.getHeader("User-Agent");

	String encodedFilename = null;
    if (header.contains("MSIE")) {
    	encodedFilename = URLEncoder.encode(displayFileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
    } else if (header.contains("Trident")) {
    	encodedFilename = URLEncoder.encode(displayFileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
    } else if (header.contains("Chrome")) {
    	StringBuilder sb = new StringBuilder();
        for (int i = 0; i < displayFileName.length(); i++) {
        	char c = displayFileName.charAt(i);
            if (c > '~') {
            	sb.append(URLEncoder.encode("" + c, StandardCharsets.UTF_8));
			} else {
            	sb.append(c);
			}
        }
        encodedFilename = sb.toString();
	} else if (header.contains("Opera")) {
    	encodedFilename = "\"" + new String(displayFileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"";
	} else if (header.contains("Safari")) {
    	encodedFilename = URLDecoder.decode("\"" + new String(displayFileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"", StandardCharsets.UTF_8);
	} else {
    	encodedFilename = URLDecoder.decode("\"" + new String(displayFileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"", StandardCharsets.UTF_8);
	}
    
    return encodedFilename;

}

추후 고려해볼 사항들

  • 현재 업무 특성상 인증된 사용자만 파일을 다운로드할 수 있어 API를 거쳐야만 다운로드를 할 수 있는데, 인증이 필요하지 않을 경우 버킷을 퍼블릭으로 설정하고 객체 URL을 DB로 관리하는 것이 적절한지 고려
  • 스트리밍 처리에 대한 학습이 필요. 시간 잡아서 학습해 볼 예정

마무리

이전 회사에서 S3 파일 업로드/다운로드 시 인증이 필요하지 않아서 객체 URL을 DB로 관리하는 방식을 사용했는데, 이번 업무에는 인증이 필요해 API를 거쳐 업로드/다운로드하는 기능을 구현했다. 예전에 파일 다운로드 시 스트리밍 방식으로 다운로드를 처리했었는데, 그 방식을 잊어서 예상치 못한 삽질을 했다. 미리 정리를 해놓았으면 어땠을까 싶다.
내용 중 개선될 수 있는 코드나 내용이 있으면 댓글 남겨주세요 😃

0개의 댓글