AWS

AWS S3 이미지 업로드

장진혁 2023. 3. 29. 00:31
이번에 번개장터를 클론 코딩하면서 상품이미지를 표시해야되는데
이미지 업로드 기능이 필수적으로 필요하기 때문에 기본 CRUD 기능을
먼저 구현하고 이미지 업로드 방법을 찾았다.

구글링하면서 찾아보던 중에 지금 쓰고있는 S3를 저장소로 이용해서
업로드, 삭제, 다운로드 까지 할 수 있다고 한다.

그래서 이번에 업로드를 찾아던 정보를 정리를 하려고한다.
(gpt도움도 받았다.)

민감한 key값들은 모두 환경변수에 넣어서 사용했습니다.

AWS S3 라이브러리 의존성 추가 
implementation 'software.amazon.awssdk:s3:2.20.32'

 

application.properties
cloud.aws.stack.auto=false
cloud.aws.region.static=ap-northeast-2
cloud.aws.credentials.access-key=${AWS_ACCESS_KEY_ID}
cloud.aws.credentials.secret-key=${AWS_SECRET_ACCESS_KEY}

# AWS S3 bucket Info
cloud.aws.s3.bucket=버킷 이름

# file upload max size
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB

 

AWS S3 config.java
@Configuration
public class S3Config {

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

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

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

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

    @Bean
    public Region s3Region() {
        return Region.of(region);
    }

    @Bean
    public String s3BucketName() {
        return bucketName;
    }

    @Bean
    public S3Client s3Client(Region region) {
        AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey);
        return S3Client.builder()
                .region(region)
                .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                .build();
    }

}

 

S3ImageController
@RestController
@RequestMapping("/s3")
public class S3ImageController {

    private final S3ImageService s3ImageService;
    private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
    private static final List<String> ALLOWED_IMAGE_CONTENT_TYPES = List.of("image/jpeg", "image/png", "image/gif");

    @Autowired
    public S3ImageController(S3ImageService s3ImageService) {
        this.s3ImageService = s3ImageService;
    }

    // 객체 1개 업로드
    @PostMapping("/upload-image")
    public ResponseEntity<String> uploadImage(@RequestParam("image") MultipartFile image) {
        try {
            validateImage(image);
            String key = s3ImageService.uploadImage(image);
            return ResponseEntity.ok(key);
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    // 객체 여러개 업로드
    @PostMapping("/upload-images")
    public ResponseEntity<List<String>> uploadImages(@RequestParam("image") List<MultipartFile> images) {
        List<String> keys = new ArrayList<>();
        for (MultipartFile image : images) {
            try {
                validateImage(image);
                String key = s3ImageService.uploadImage(image);
                keys.add(key);
            } catch (IOException e) {
                e.printStackTrace();
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }
        }
        return ResponseEntity.ok(keys);
    }

    // 객체 삭제
    @DeleteMapping("/delete-image/{key}")
    public ResponseEntity<Void> deleteImage(@PathVariable String key) {
        s3ImageService.deleteImage(key);
        return ResponseEntity.noContent().build();
    }

    // 객체 다운로드
    @GetMapping("/download-image/{key}")
    public ResponseEntity<InputStreamResource> downloadImage(@PathVariable String key) {
        try {
            ResponseInputStream<GetObjectResponse> s3Object = s3ImageService.downloadImage(key);
            return ResponseEntity.ok()
                    .contentType(MediaType.parseMediaType(s3Object.response().contentType()))
                    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + key)
                    .body(new InputStreamResource(s3Object));
        } catch (S3Exception e) {
            return ResponseEntity.notFound().build();
        }
    }

    private void validateImage(MultipartFile image) {
        if (image.getSize() > MAX_FILE_SIZE) {
            throw new IllegalStateException("File size exceeds the maximum allowed size (5MB).");
        }

        if (!ALLOWED_IMAGE_CONTENT_TYPES.contains(image.getContentType())) {
            throw new IllegalStateException("Invalid file format. Allowed formats: JPEG, PNG, GIF.");
        }
    }

}

 

S3ImageService
@Service
public class S3ImageService {

    private final S3Client s3Client;
    private final String bucketName;

    public S3ImageService(S3Client s3Client, String bucketName) {
        this.s3Client = s3Client;
        this.bucketName = bucketName;
    }

    public String uploadImage(MultipartFile image) throws IOException {
        String key = UUID.randomUUID().toString(); // 또는 다른 고유한 키 생성 방법

        PutObjectRequest request = PutObjectRequest.builder()
                .bucket(bucketName)
                .key("uploaded-image/" + key)
                .contentType(image.getContentType())
                .contentLength(image.getSize())
                .build();

        s3Client.putObject(request, RequestBody.fromInputStream(image.getInputStream(), image.getSize()));
        return key;
    }

    public void deleteImage(String key) {
        String div = "uploaded-image";
        s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucketName).key(div + "/" + key).build());
    }

    public ResponseInputStream<GetObjectResponse> downloadImage(String key) {
        return s3Client.getObject(GetObjectRequest.builder()
                .bucket(bucketName)
                .key("uploaded-image/" + key)
                .build());
    }

}

 

나는 이것을 통해서 이미지 업로드와 글쓰기를 동시에 서버로 보내서
text와 관한것은 데이터베이스에 저장하고
이미지는 S3에 업로드 할것이다.

ProductController
@RestController
@RequiredArgsConstructor
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;
    private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
    private static final List<String> ALLOWED_IMAGE_CONTENT_TYPES = List.of("image/jpeg", "image/jpg", "image/png", "image/gif");


    // 1. 상품 작성
    @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
    public MessageResponseDto createProduct(
            @RequestParam("image") MultipartFile image,
            @RequestPart("dto") @Valid ProductRequestDto productRequestDto,
            @AuthenticationPrincipal UserDetailsImpl userDetails) throws IOException {
        validateImage(image);
        return productService.createProduct(productRequestDto, userDetails.getUser(), image);
    }

    // 2. 상품 전체 조회
    @GetMapping("")
    public List<ProductListResponseDto> getProductList() {
        return productService.getProductList();
    }

    // 3. 상품 상세 조회
    @GetMapping("/{pdid}")
    public ProductDetailResponseDto getProductDetailList(
            @PathVariable Long pdid, @AuthenticationPrincipal UserDetailsImpl userDetails) {

        User user = null;
        if (userDetails != null){
            user = userDetails.getUser();
        }

        return productService.getProductDetailList(pdid, user);
    }

//   4. 상품 수정
    @PostMapping(value = "/{pdid}", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
    public MessageResponseDto update(@PathVariable("pdid") Long pdid,
                                     @RequestParam("image") MultipartFile image,
                                     @RequestPart("dto") @Valid ProductRequestDto productRequestDto,
                                     @AuthenticationPrincipal UserDetailsImpl userDetails) throws IOException {
        if (image.isEmpty()) {;
            return productService.textUpdate(pdid, productRequestDto, userDetails.getUser());
        }
        validateImage(image);
        return productService.update(pdid, productRequestDto, userDetails.getUser(), image);
    }

//    5. 상품 삭제
    @DeleteMapping("/{pdid}")
    public MessageResponseDto delete(@PathVariable Long pdid,
                                     @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return productService.delete(pdid, userDetails.getUser());
    }

    //   6. 상품 구매 완료.
    @PatchMapping("/{pdid}/done")
    public MessageResponseDto modifyDone(@PathVariable("pdid") Long pdid, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        return productService.modifyDone(pdid, userDetails.getUser());
    }


//    페이지 조회
    @GetMapping("/pages")
    public List<ProductListResponseDto> getPagingProducts(ReqProductPageableDto dto, HttpServletResponse resp) {

        Long count = productService.getCountAllProducts();

        resp.addHeader("Total_Count_Products", String.valueOf(count));

        return productService.getPageOfProduct(dto);

    }



//    이미지 유효성 검사
    private void validateImage(MultipartFile image) {
        if (image.isEmpty()) {
            throw new IllegalStateException("상품의 이미지를 업로드해주세요.");
        }

        if (image.getSize() > MAX_FILE_SIZE) {
            throw new IllegalStateException("파일 사이즈가 최대 사이즈(5MB)를 초과합니다.");
        }

        if (!ALLOWED_IMAGE_CONTENT_TYPES.contains(image.getContentType())) {
            throw new IllegalStateException("파일 형식은 JPEG, JPG, PNG, GIF 중 하나여야 합니다.");
        }
    }
}
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
이부분을 통해서 multipart form data와 application/json 타입을 정보를 가져올 것이다.

포스트맨 상품 이미지와 상품에 관련된 정보를 서버로 보냄

ProductService
package com.example.thundermarket.products.service;

import com.example.thundermarket.products.dto.*;
import com.example.thundermarket.products.entity.Product;
import com.example.thundermarket.products.repository.CategoryRepository;
import com.example.thundermarket.products.repository.ProductRepository;
import com.example.thundermarket.users.entity.User;
import com.example.thundermarket.users.entity.UserRoleEnum;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final S3Client s3Client;
    private final String bucketName;

    //    상품 작성
    @Transactional
    public MessageResponseDto createProduct(ProductRequestDto productRequestDto, User user, MultipartFile image) throws IOException {
        String key = UUID.randomUUID().toString() + "_" + image.getOriginalFilename(); // 또는 다른 고유한 키 생성 방법

        PutObjectRequest request = PutObjectRequest.builder()
                .bucket(bucketName)
                .key("uploaded-image/" + key)
                .contentType(image.getContentType())
                .contentLength(image.getSize())
                .build();

        s3Client.putObject(request, RequestBody.fromInputStream(image.getInputStream(), image.getSize()));

        productRepository.saveAndFlush(new Product(productRequestDto, user, key));
        return new MessageResponseDto(HttpStatus.OK, "상품이 등록되었습니다.");
    }

    //    전체 상품 조회
    @Transactional(readOnly = true)
    public List<ProductListResponseDto> getProductList() {
        List<ProductListResponseDto> productListResponseDtos = new ArrayList<>();
//        List<Product> products = productRepository.findAllByOrderByCreatedAtDesc();
        List<Product> products = productRepository.findAllByIsDoneFalseOrderByCreatedAtDesc();
        for (Product product : products) {
            productListResponseDtos.add(new ProductListResponseDto(product));
        }
        return productListResponseDtos;
    }

    //    선택 상품 상세 조회
    @Transactional(readOnly = true)
    public ProductDetailResponseDto getProductDetailList(Long pdid, User user) {
        Product getproduct = productRepository.findById(pdid).orElseThrow(
                () -> new IllegalArgumentException("게시물을 찾을 수 없습니다.")
        );
        boolean isAuth = false;
        if (user != null) {
            if (user.getRole() == UserRoleEnum.ADMIN || user.getId().equals(getproduct.getUser().getId())) {
                isAuth = true;
            }
        }

//        최신 상품 리스트 6개 같이반환
        List<ProductListResponseDto> productListResponseDtos = productRepository
//                .findTop6ByIdNotOrderByCreatedAtDesc(pdid).stream()
                .findLatestNotDoneProductsByCategoryIdExceptGivenId(6, pdid).stream()
                .map(ProductListResponseDto::new)
                .collect(Collectors.toList());

        return new ProductDetailResponseDto(getproduct, productListResponseDtos, isAuth);
    }

    //    상품 수정
    @Transactional
    public MessageResponseDto update(Long pdid, ProductRequestDto productRequestDto, User user, MultipartFile image) throws IOException {
        Product product = productRepository.findById(pdid).orElseThrow(
                () -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")
        );
        if (isMatchUser(product, user) || user.getRole() == UserRoleEnum.ADMIN) {
//           기존 이미지 삭제
            String img = product.getImg();
            String div = "uploaded-image";
            s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucketName).key(div + "/" + img).build());
//            새 이미지 등록
            String key = UUID.randomUUID().toString() + "_" + image.getOriginalFilename(); // 또는 다른 고유한 키 생성 방법

            PutObjectRequest request = PutObjectRequest.builder()
                    .bucket(bucketName)
                    .key("uploaded-image/" + key)
                    .contentType(image.getContentType())
                    .contentLength(image.getSize())
                    .build();

            s3Client.putObject(request, RequestBody.fromInputStream(image.getInputStream(), image.getSize()));
//          업데이트 메서드
            product.update(productRequestDto, key);
            return new MessageResponseDto(HttpStatus.OK, "게시글이 수정 되었습니다.");
        }
        throw new IllegalArgumentException("해당 권한이 없습니다");

    }

    // 상품 사진제외하고 수정
    @Transactional
    public MessageResponseDto textUpdate(Long pdid, ProductRequestDto productRequestDto, User user) {
        Product product = productRepository.findById(pdid).orElseThrow(
                () -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")
        );

        if (isMatchUser(product, user) || user.getRole() == UserRoleEnum.ADMIN) {
            product.textUpdate(productRequestDto);
            return new MessageResponseDto(HttpStatus.OK, "게시글이 수정 되었습니다.");
        }
        throw new IllegalArgumentException("해당 권한이 없습니다");
    }


    //    상품 삭제
    @Transactional
    public MessageResponseDto delete(Long pdid, User user) {

        Product product = productRepository.findById(pdid).orElseThrow(
                () -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")
        );

        if (isMatchUser(product, user) || user.getRole() == UserRoleEnum.ADMIN) {
            String img = product.getImg();
            String div = "uploaded-image";
            s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucketName).key(div + "/" + img).build());
            productRepository.deleteById(pdid);
            return new MessageResponseDto(HttpStatus.OK, "게시글이 삭제 되었습니다.");
        }
        throw new IllegalArgumentException("해당 권한이 없습니다");
    }

    //    구매 완료 메서드
    @Transactional
    public MessageResponseDto modifyDone(Long pdid, User user) {
        Product product = productRepository.findById(pdid).orElseThrow(
                () -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다.")
        );
//        해당 상품이 판매중일때만
        if (!product.isDone()) {
//            자신이 올린 상품이면 예외처리
            if (product.getUser().getId().equals(user.getId())) {
                throw new IllegalArgumentException("자신이 올린 상품은 구매하실 수 없습니다.");
            }
            product.setDone(true);
            productRepository.save(product);
            return new MessageResponseDto(HttpStatus.OK, "상품을 정상적으로 구매하셨습니다.");
        }
        throw new IllegalArgumentException("이미 판매 완료된 상품입니다.");


    }

    //    유저 검증 메서드
    private boolean isMatchUser(Product product, User user) {
        return product.getUser().getEmail().equals(user.getEmail());
    }


    @Transactional(readOnly = true)
    public Long getCountAllProducts() {

        return productRepository.countProducts();
    }

    @Transactional(readOnly = true)
    public List<ProductListResponseDto> getPageOfProduct(ReqProductPageableDto dto) {

        return productRepository.findAllByIsDoneFalse(configPageAble(dto))
                .stream().map(ProductListResponseDto::new).toList();
    }

    private Pageable configPageAble(ReqProductPageableDto dto) {

        Sort.Direction direction = dto.isAsc() ? Sort.Direction.ASC : Sort.Direction.DESC;
        Sort sort = Sort.by(direction, dto.getSortBy());

        return PageRequest.of(dto.getPage() - 1, dto.getSize(), sort);
    }


}