본문 바로가기

항해 99/Spring

S3를 이용한 파일 업로드

AWS S3

  • AWS Simple Storeage Service의 줄임말로 파일 서버의 역할을 하는 서비스
  • 프로젝트 개발 중 파일을 저장하고 불러오는 작업이 필요한 경우에 프로젝트 내부 폴더에 저장할 수 있지만, AWS S3를 사용하여 파일을 관리할 수도 있음

AWS S3의 장점

  • 무제한 용량(하나의 파일에 대한 용량 제한은 있지만, 전체 용량은 무제한)
  • 파일 저장에 최적화 (개발자가 따로 용량을 추가하거나 성능을 높이는 작업을 하지 않아도 됨)
  • 99.999%라는 높은 내구도(파일이 유실될 가능성이 낮음)
  • 이 외에도 저렴한 비용, 높은 객체 가용성, 뛰어난 보안성 등의 장점이 있음

AWS S3 생성

  • 객체(Object) : 파일과 파일정보로 구성된 저장단위로 파일이라 생각하면 됨
  • 버킷(Bucket) : 저장된 객체 대한 컨테이너
  • 버킷은 최대 100개 생성 가능하며, 버킷에 저장할 수 있는 객체 수는 제한이 없음

Bucket 생성

  1. AWS Console 접속 후 S3 서비스 선택
  2. 버킷 만들기 클릭
  3. 원하는 버킷 이름 입력
  4. AWS 리전 선택(아시아 태평양(서울) ap-northeast-2)
  5. 객체 소유권 선택(ACL 비활성화)
  6. '모든 퍼블릭 액세스 차단' 해제
  7. 나머지도 Default

사용자 생성

  1. AWS Console 접속 후 IAM 서비스 선택
  2. 액세스 관리 사용자 → 사용자 추가
  3. 원하는 이름 입력 후 다음
  4. 직접 정책 연결 AmazonS3FullAccess 선택 후 다음
  5. 사용자 생성

액세스 키 생성

  1. 방금 생성한 사용자 선택 후 보안 자격 증명 탭 클릭
  2. 액세스 키 만들기 클릭
  3. 기타 선택 후 다음
  4. 원하는 설명 태그 입력 후 액세스 키 만들기
  5. 액세스 키를 만들면 아래와 같이 액세스 키와 비밀 액세스 키를 확인할 수 있는데 , 이 값들을 저장후 완료

 

버킷 정책 변경

  1. AWS Console에서 생성한 버킷으로 이동
  2. 권한 버킷 정책 편집
  3. 정책이 비어있으면 '+ 새 문 추가' 클릭
  4. 정책 내용을 아래와 같이 변경
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Principal": "*",
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::<버킷 이름>/*"
        }
    ]
}

 

Spring 프로젝트와 연동

라이브러리 추가(build.gradle dependency)

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

application.yml 설정

cloud:
  aws:
    s3:
      bucket: <S3 버킷 이름>
    credentials:
      access-key: <저장해놓은 액세스 키>
      secret-key: <저장해놓은 비밀 액세스 키>
    region:
      static: ap-northeast-2
      auto: false
    stack:
      auto: false

 

S3Config.java 생성

@Configuration
public class S3Config {

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

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

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

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

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

 

파일 업로드 구현

@Service
@RequiredArgsConstructor
public class S3UploadService {

    private final AmazonS3 amazonS3;

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

    public String saveFile(MultipartFile multipartFile) throws IOException {
        String originalFilename = multipartFile.getOriginalFilename();

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

        amazonS3.putObject(bucket, originalFilename, multipartFile.getInputStream(), metadata);
        return amazonS3.getUrl(bucket, originalFilename).toString();
    }
}
  • putObject() 메서드가 파일을 저장해주는 메서드
  • getURI()을 통해 파일이 저장된 URL을 return 해주고, 이 URL로 이동 시 해당 파일이 오픈됨(버킷 정책 변경을 하지 않았으면 파일은 업로드 되지만 해당 URL로 이동 시 accessDenied 됨)
  • 만약 MultipartFile에 대해 잘 모르거나 웹 페이지에서 form으로 파일을 입력받고 싶다면 [Spring Boot] 파일 업로드 참고

 

파일 다운로드 구현

public ResponseEntity<UrlResource> downloadImage(String originalFilename) {
    UrlResource urlResource = new UrlResource(amazonS3.getUrl(bucket, originalFilename));

    String contentDisposition = "attachment; filename=\"" +  originalFilename + "\"";

    // header에 CONTENT_DISPOSITION 설정을 통해 클릭 시 다운로드 진행
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
            .body(urlResource);

}
  • 로컬 파일 다운로드 할 때는 UrlResource()메서드에 "file:" + 로컬 파일 경로를 넣어주면 로컬파일이 다운로드 됨
  • S3에 올라간 파일은 위와 같이 amazonS3.getUrl(버킷이름, 파일이름)을 통해 파일 다운로드를 할 수 있음

 

HTML 파일에서 이미지 미리보기 구현

String url = amazonS3.getUrl(bucket, filename).toString();
  • 위에서 구한 amazonS3.getUrl(bucket, filename).toString();
  • 아래와 같이 Thymeleaf를 이용해 URL을 받고 src에 넣어주면 화면에서 미리보기 가능
<img th:src="${s3ImageUrl}"/>

 

파일 삭제 구현

public void deleteImage(String originalFilename)  {
    amazonS3.deleteObject(bucket, originalFilename);
}

 

 

실제 프로젝트 적용 예시

클론 코딩 프로젝트 일부

설정 파일

package global AWS config : S3Config

package com.sparta.market.global.aws.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.client.builder.AwsClientBuilder;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class S3Config {
    private final String endPoint = "https://kr.object.ncloudstorage.com";
    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

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

        return (AmazonS3Client) AmazonS3ClientBuilder
                .standard()
                .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endPoint, region))
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .build();
    }
}

 

package global AWS config : MultipartJackson2HttpMessageConverter

package com.sparta.market.global.aws.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;

import java.lang.reflect.Type;

@Component
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {

    /**
     * "Content-Type: multipart/form-data" 헤더를 지원하는 HTTP 요청 변환기
     * Swagger에서 multipart/form-data 를 확인하기 위해 추가
     */
    public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
        super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
    }

    @Override
    public boolean canWrite(Class<?> clazz, MediaType mediaType) {
        return false;
    }

    @Override
    public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
        return false;
    }

    @Override
    protected boolean canWrite(MediaType mediaType) {
        return false;
    }
}

 

서비스 파일

package global AWS service : S3UploadService

package com.sparta.market.global.aws.service;

import com.amazonaws.AmazonServiceException;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

@Slf4j(topic = "AWS S3 로그")
@Service
@RequiredArgsConstructor
public class S3UploadService {
    private final AmazonS3 amazonS3;

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

    public String saveFile(MultipartFile multipartFile, String filename) throws IOException {
        // String filename -> UUID 추가된 filename 저장 (중복 방지)
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(multipartFile.getSize());
        metadata.setContentType(multipartFile.getContentType());

        amazonS3.putObject(bucket, filename, multipartFile.getInputStream(), metadata);
        // 한글 깨짐 방지
        return URLDecoder.decode(amazonS3.getUrl(bucket, filename).toString(), StandardCharsets.UTF_8);
    }

    public void deleteFile(String originFilename) {
        amazonS3.deleteObject(bucket, originFilename);
    }
}

 

 

Application 컨트롤러

package domain community controller : CommunityController 클래스 코드 일부

@Slf4j(topic = "Community Controller")
@Tag(name = "Community Controller", description = "커뮤니티 게시글 컨트롤러")
@RestController
public class CommunityController {

    private final CommunityService communityService;

    public CommunityController(CommunityService communityService) {
        this.communityService = communityService;
    }

    @Operation(summary = "커뮤니티 게시글 등록", description = "커뮤니티 게시글을 등록합니다.")
    @PostMapping(value = "/community", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
    /*@RequestPart 사용 이미지 업로드 추가 리팩토링 예정*/
    public ResponseEntity<?> createCommunityPost(@RequestPart(value = "files", required = false)MultipartFile[] multipartFilesList,
                                                 @RequestPart(value = "CommunityRequestDto") CommunityRequestDto requestDto) throws IOException, java.io.IOException {

        CommunityResponseDto responseDto = communityService.createCommunityPost(requestDto, multipartFilesList);

        return ResponseEntity.ok().body(ResponseDto.success("커뮤니티 글 작성 성공", responseDto));
    }

 

Application 서비스 로직

package domain community service : CommunityService 클래스 코드 일부

@Slf4j(topic = "Community Service")
@Tag(name = "Community Service", description = "커뮤니티 게시글 서비스 로직 클래스")
@Service
public class CommunityService {

    private final CommunityRepository communityRepository;
    private final UserRepository userRepository;
    private final S3UploadService s3UploadService;
    private final CommunityImageRepository communityImageRepository;

    public CommunityService(CommunityRepository communityRepository, UserRepository userRepository, 
                            S3UploadService s3UploadService, CommunityImageRepository communityImageRepository) {
        this.communityRepository = communityRepository;
        this.userRepository = userRepository;
        this.s3UploadService = s3UploadService;
        this.communityImageRepository = communityImageRepository;
    }

    /*커뮤니티 게시글 생성 로직*/
    @Transactional
    public CommunityResponseDto createCommunityPost(CommunityRequestDto requestDto,
                                                    MultipartFile[] multipartFileList) throws IOException {
        /*유저 정보 검증*/
        User user = getAuthenticatedUser();

        /*Builder 사용 entity 객체 생성*/
        Community community = Community.builder()
                .title(requestDto.getTitle())
                .content(requestDto.getContent())
                .user(user)
                .category(requestDto.getCategory())
                .build();

        /*DB에 Community Post 정보 저장*/
        communityRepository.save(community);

        /* 이미지 파일이 없을 경우*/
        if (multipartFileList == null) {
            /*Community Post 정보 반환*/
            return new CommunityResponseDto(community);
        }

        /* 이미지 파일 정보 저장 리스트*/
        List<String> createImageUrlList = new ArrayList<>();
        List<String> createImageNameList = new ArrayList<>();

        /* image file S3 save*/
        saveImgToS3(multipartFileList, community, createImageUrlList, createImageNameList);

        /*Community Post 정보 반환*/
        return new CommunityResponseDto(community, createImageUrlList, createImageNameList);
    }

 

Application 엔티티 클래스

package domain community Entity : Community 클래스 코드 일부

@EntityListeners(AuditingEntityListener.class)
public class Community {

    ...

    @OneToMany(mappedBy = "community", cascade = CascadeType.ALL)
    @Schema(name = "community post image id", description = "커뮤니티 게시글 이미지 ID 리스트")
    private List<CommunityImage> communityImages;
    
    ...
}

 

Application Dto 클래스

package domain community Dto : CommunityResponseDto 클래스 코드 일부

@Getter
public class CommunityResponseDto {

    ...
    
    private List<String> imageNameList;
    private List<String> imageUrlList;
    
    ...
}

 

 

NCP Object Storge 가이드

'항해 99 > Spring' 카테고리의 다른 글

MapStruct  (0) 2024.03.22
대댓글 기능 구현  (1) 2024.03.21
CORS 에러 해결 방법, CORS 보안 취약점 예방 가이드  (0) 2024.03.19
CORS  (1) 2024.03.18
AWS로 HTTPS 연결  (0) 2024.03.16