[AWS] S3 에 이미지 업로드/삭제하기

정재현·2024년 4월 1일
0

AWS

목록 보기
4/4
post-thumbnail

버킷 생성하기

  • AWS Console 에서 S3 입력해서 접속
  • 버킷 만들기 클릭
  • 지역 및 이름 설정
    • 버킷이 많이 사용될 지역을 설정해 주는 것이 좋다
  • 버킷 이름 설정 예시
  • 소유권 설정
    • 내 AWS계정으로 EC2를 빌드하였고, 한 계정으로 관리 할 것이기 때문에 ACL 비활성화를 선택
  • 퍼블릭 엑세스 권한을 설정
    • 액세스 권한을 모두에게 오픈할 것인지 접근 권한을 제한할 것인지 설정하는 것
    • 외부에서 접속할 수 있도록하기 위해 일단은 모두 해제(추후에 세부적으로 설정하는게 좋음)
    • 버킷 안의 객체들을 외부에 공개해야하는 케이스 등이 아닌 이상 퍼블릭 액세스는 차단하는게 좋다
  • 액세스 권한 설정
  • 버킷 버전 관리 설정
    • 버킷 버전 관리기능을 활성화하면 파일을 버전별로 관리 하기 때문에 비용이 발생하게 된다.
    • 대신 사용자가 실수로 파일을 삭제해도 복원할 수 있다고 한다.
  • 태그 설정
    • 태그를 추가하면 스토리지를 분류하는데 도움이 된다
  • 버킷 암호화 방식 설정
    • 기본인 SSE-S3을 선택
    • SSE-KMS를 선택하면 암호화 비용을 줄일 수 있다고 한다.

생성한 버킷의 정책 수정하기

  • 버킷 정책 편집 선택
  • 버킷 ARN 을 복사하고 정책 생성기 클릭
  • 외부에서 이미지 업로드를 하기 위해 GetObject & PutObject Actions 추가
  • 외부에서 이미지 제거 요청을 하기 위해 DeleteObject Actions 추가
    Effect : 적용 여부.
    Principal : 권한을 부여할 계정. (을 입력하면 모두에게 부여)
    Actions : 부여할 권한. (GetObject : 읽기, PutObject : 쓰기)
    ARN : ARN 입력 ( 이때 `버킷 ARN/
    ` 으로 작성해야 한다 )
  • 생성된 JSON 파일을 Generate Policy 클릭
  • 생성한 JSON 파일의 내용을 확인하고 복사
  • 복사한 내용을 버킷 정책에 추가
  • 버킷에 정책이 추가된 모습
  • 버킷의 이름 옆에 퍼블릭 액세스 가능 으로 바뀐것 확인 (시간이 조금 걸릴 수 있음)

IAM 생성

  • 좌측의 사용자 로 이동
  • 사용자 생성 클릭
  • IAM 이름 작성(외부에서 접근하는 유저를 만들기 위함)
  • 외부에서 접근하는 유저의 권한 설정
    • AmazonS3FullAccess 권한 추가
  • 입력 정보를 다시 확인해보고 맞으면 사용자 생성 클릭 후 사용자 추가 완료

IAM 권한 설정(사용자 액세스 키 생성)

앞선 과정에 생성한 사용자에 IMA 권한 을 부여하는 작업이 필요하다

  • 앞선 과정에서 생성한 사용자를 눌러 상세정보 확인
    • 기본적으로 비밀번호를 변경하기 위한 IAMUserChangePassword 권한과 이전에 추가한 S3 에 접근이 가능한 AmazonS3FullAccess 권한이 존재한다.
  • 사용자 정보 > 보안 자격 증명 > 액세스 키 에서 액세스 키 만들기 클릭
  • 사용하고자 하는 경우에 맞게 선택 후 다음 클릭
    • 여기서는 EC2 로 배포하고 로컬에서도 테스트를 해보기 위해 AWS 컴퓨팅 서비스에서 실행되는 애플리케이션 을 선택했다.
  • 액세스 키에 대한 설명 작성
  • 액세스 키가 생성된 모습
    • 비밀 액세스키는 다시 확인이 불가능하니 사용자명_accessKeys.csv 파일은 반드시 다운받도록 하자!
  • 액세스 키가 생성된 모습

코드 작성

자세한 코드는 Github 를 참고해주세요

S3 의존성 추가

implementation "com.amazonaws:aws-java-sdk-s3:1.12.281"

application.properties 에 필요한 값 추가

  • 지역 추가
# AWS S3 configuration
cloud.aws.region.static=ap-northeast-2
  • 배포가 실패할 때 자동으로 롤백하는 기능을 비활성화
# Disable automatic detection of Spring Cloud AWS stack
cloud.aws.stack.auto-=false
  • 인증키 설정
    • IAM 권한 설정 후 다운받은 IAM이름_accessKey.csv 파일의 Access key IDSecret access key 를 각각 accessKeysecretKey 에 입력
# AWS S3 credentials(Key)
cloud.aws.s3.credentials.accessKey=
cloud.aws.s3.credentials.secretKey=
  • 버킷의 이름 지정
    • 퍼블릭 액세스 가능으로 된 버킷의 이름 입력
cloud.aws.s3.bucket=wuzuzu-test
  • 디폴트로 설정할 이미지를 업로드 후 확인 후 업로드 URL 과 디폴트 경로 설정
# Default image path
upload.path=https://wuzuzu-test.s3.ap-northeast-2.amazonaws.com/
defaultImage.path=https://wuzuzu-test.s3.ap-northeast-2.amazonaws.com/aws.png

S3Config

@Configuration
public class S3Config {

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

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

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

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

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

S3Service

@Service
@RequiredArgsConstructor
public class S3Service {

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

    private final AmazonS3 amazonS3;

    /* 파일 업로드 */
    public String upload(MultipartFile multipartFile, String s3FileName) throws IOException {
        // 업로드할 파일의 메타데이터 생성
        ObjectMetadata objMeta = new ObjectMetadata();
        objMeta.setContentLength(multipartFile.getInputStream().available());   // 파일의 크기를 가져와 업로드할 파일의 크기 설정
        objMeta.setContentType(MediaType.IMAGE_JPEG_VALUE);                     // 업로드할 파일의 유형을 설정 : JPEG 이미지의 MIME 유형 설정

        // putObject(버킷명, 파일명, 파일데이터, 메타데이터)로 S3에 객체 등록
        amazonS3.putObject(bucket, s3FileName, multipartFile.getInputStream(), objMeta);

        // 등록된 객체의 url 반환
        // getUrl : 업로드된 객체의 URL(AWS S3에 업로드된 파일에 대한 고유한 위치) 가져오기
        // decode: url 안의 한글 or 특수문자 깨짐 방지
        return URLDecoder.decode(amazonS3.getUrl(bucket, s3FileName).toString(), "utf-8");
    }

    /* 파일 삭제 */
    public void delete(String key){
        try {
            // deleteObject(버킷명, 키값)으로 객체 삭제
            amazonS3.deleteObject(bucket, key);
        } catch (AmazonServiceException e) {
            log.error(e.toString());
        }
    }
}

Image 테이블

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "images")
public class Image extends Timestamped {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long imageId;

    @Column(nullable = false)
    private String imageUrl;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "salePost_id")
    private SalePost salePost;

    public Image(String imageName, Object object) {
        this.imageUrl = imageName;
        if(object instanceof SalePost){
            this.salePost = (SalePost) object;
        }
    }
}

Image Service

@Service
@RequiredArgsConstructor
public class ImageService {

    @Value("${defaultImage.path}")
    private String defaultImagePath;
    @Value("${upload.path}")
    private String uploadPath;

    private final S3Service s3Service;
    private final ImageRepository imageRepository;

    @Transactional
    public void createImage(MultipartFile file, Object object) throws IOException {
        String imageName = getImageName(file);
        imageRepository.save(new Image(imageName, object));
    }

    @Transactional
    public void deleteImage(String url, Object object){
        Optional<Image> imageUrlToDelete;

        // Object 에 속한 이미지 목록에서 해당 URL 을 가진 이미지 탐색
        if(object instanceof SalePost salePost){
            imageUrlToDelete = salePost.getImageUrl().stream()
                .filter(imageUrl -> imageUrl.getImageUrl().equals(uploadPath + url))
                .findFirst();
        } else {
            throw new IllegalArgumentException("Object 가 잘못되었습니다.");
        }

        if (imageUrlToDelete.isPresent()) {
            // 해당 URL 을 가진 이미지가 존재하면 삭제
            Image image = imageUrlToDelete.get();
            salePost.getImageUrl().remove(image);
            imageRepository.delete(image);
        }else {
            // 해당 URL 을 가진 이미지가 없는 경우 예외 발생
            throw new IllegalArgumentException("SalePost 에 해당 URL 을 가진 이미지가 없습니다: " + uploadPath + url);
        }

        // S3 에 이미지 제거 요청
        s3Service.delete(url);
    }

    private String getImageName(MultipartFile file) throws IOException {
        if (file != null) {
            // UUID.randomUUID() : UUID 클래스를 이용해 시간과 공간을 기반으로 128비트의 고유한 식별자 생성
            // file.getOriginalFilename() : 클라이언트가 업로드한 파일의 원래 파일 이름 반환
            String originalFileName = UUID.randomUUID() + file.getOriginalFilename();
            return s3Service.upload(file, originalFileName);
        }
        return defaultImagePath;
    }
}

이미지 업로드에 사용할 SalePost 엔터티

@Getter
@Builder
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "sale_posts")
public class SalePost extends Timestamped {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long salePostId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
    private User user;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String description;

    @Column(nullable = false)
    private Long views = 0L;

    @Column(columnDefinition = "TINYINT(1) default 0")
    private Boolean status = true;

    @Column(nullable = false)
    private String goods;

    @Column(nullable = false)
    private Long price;

    @Column(nullable = false)
    private Long stock;

    @ManyToOne
    @JoinColumn(name = "category_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
    private Category category;

    @OneToMany(mappedBy = "salePost")
    private List<Image> imageUrl;

    public SalePost(User user, SalePostRequest requestDto, Category category) {
        this.user = user;
        this.title = requestDto.getTitle();
        this.description = requestDto.getDescription();
        this.goods = requestDto.getGoods();
        this.price = requestDto.getPrice();
        this.stock = requestDto.getStock();
        this.category = category;
    }

    public void update(SalePostRequest requestDto, Category category) {
        this.title = requestDto.getTitle();
        this.description = requestDto.getDescription();
        this.goods = requestDto.getGoods();
        this.price = requestDto.getPrice();
        this.stock = requestDto.getStock();
        this.category = category;
    }

    public void increaseViews(){
        views++;
    }

    public void goodsOrder(Long count){
        stock -= count;
    }

    public void delete() {
        status = false;
    }
}

SalePostService

    @Transactional
    public void uploadImage(User user, Long salePostId, List<MultipartFile> imageFiles) throws IOException {
        SalePost salePost = checkSalePost(user, salePostId);

        for (MultipartFile imageFile : imageFiles) {
            imageService.createImage(imageFile, salePost);
        }
    }

    public void deleteImage(User user, Long salePostId, String key){
        SalePost salePost = checkSalePost(user, salePostId);
        imageService.deleteImage(key, salePost);
    }

SalePostVo

@Getter
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(Include.NON_NULL)
public class SalePostVo {
    private Long salePostId;
    private String title;
    private String description;
    private Long views;
    private String author;
    private String category;
    private Boolean status;
    private String goods;
    private Long price;
    private Long stock;
    private List<String> imageUrls;
}

SalePostQueryRepositoryImpl

@Repository
@RequiredArgsConstructor
public class SalePostQueryRepositoryImpl implements SalePostQueryRepository {
    private final JPAQueryFactory jpaQueryFactory;
    private final QSalePost salePost = QSalePost.salePost;
    private final QImage image = QImage.image;
    @Override
    public SalePostVo findPostByPostId(Long postId) {
    	// 출력할 url 을 서브쿼리로 찾기
        List<String> imageUrls = jpaQueryFactory
            .select(image.imageUrl)
            .from(image)
            .where(image.salePost.salePostId.eq(postId))
            .fetch();

        return jpaQueryFactory
            .select(Projections.constructor(SalePostVo.class,
                salePost.salePostId,
                salePost.title,
                salePost.description,
                salePost.views,
                salePost.user.userName,
                salePost.category.name,
                salePost.status,
                salePost.goods,
                salePost.price,
                salePost.stock,
                Expressions.asSimple(imageUrls)
            ))
            .from(salePost)
            .where(salePost.salePostId.eq(postId))
            .leftJoin(salePost.user)            // Fetch Join 으로 N+1 문제 해결
            .leftJoin(salePost.category)        // Fetch Join 으로 N+1 문제 해결
            .fetchOne();
    }
}

Postman 으로 S3 에 요청보내기

S3 에 이미지 업로드 요청

  • POST 방식으로 매핑된 URL 을 입력 후 Body 부분을 form-data 로 요청
  • 이때 form-data의 Key 값은 @RequestPartvalue 값과 일치해야 한다.

S3 에 이미지 삭제 요청

  • 이미지 삭제 요청은 기존 매서드 방식과 동일하다.

참고한 글


profile
공부 기록 보관소

0개의 댓글