S3를 통한 이미지 저장 아키텍팅

S3 동작 흐름

스크린샷 2023-09-19 오후 1 44 21

Amazon S3 이미지 업로드 및 전달 흐름

  1. 클라이언트로부터 이미지 업로드: 클라이언트(웹 또는 모바일 애플리케이션)에서 이미지를 업로드하면, 업로드 요청은 백엔드 서버로 전송됩니다.

  2. 임시 저장소에 이미지 저장: 백엔드 서버는 클라이언트로부터 받은 이미지 파일을 임시 저장소에 저장합니다. 이 단계에서 이미지는 안전하게 보관됩니다.

  3. Amazon S3에 이미지 업로드: 백엔드 서버는 Amazon S3로 이미지를 영구 저장하기 위해 AWS SDK를 활용합니다. AWS SDK는 Amazon S3에 대한 API를 제공하여 이미지를 업로드하고 저장할 수 있도록 도와줍니다.

  4. 고유한 식별자 부여: 업로드된 이미지는 Amazon S3에서 고유한 식별자(일반적으로 파일 경로 또는 키)를 부여받아 저장됩니다. 이를 통해 이미지를 고유하게 식별하고 검색할 수 있습니다.

  5. 이미지 안정적 백업 및 확장 가능한 스토리지: Amazon S3는 안정적이고 내구성 있는 스토리지 서비스로 이미지를 저장합니다. 또한 Amazon S3는 확장 가능한 스토리지 서비스로서 대용량 이미지를 관리할 수 있도록 지원합니다.

  6. 이미지 전달: 클라이언트가 특정 이미지를 요청하면, 백엔드 서버는 해당 이미지를 Amazon S3에서 다운로드합니다. Amazon S3는 이미지의 URL을 제공하므로 서버는 해당 URL을 사용하여 이미지를 다운로드하고 클라이언트에게 전달합니다.

S3 구현

build.gradle

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

먼저 dependencies를 적용시켜주고, 아래 버킷 이름과, 엑세스 키와 시크릿 키를 발급 받아 넣어줬습니다.

application.yml

cloud:
  aws:
    s3:
      bucket: ${S3_BUCKET}
    stack.auto: false
    region.static: ap-northeast-2
    credentials:
      accessKey: ${S3_ACCESSKEY}
      secretKey: ${S3_SECRETKEY}

S3Config

@Configuration
public class S3Config {
    @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 AmazonS3 amazonS3Client() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
        return AmazonS3ClientBuilder
                .standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();
    }
}

먼저 S3에 대한 설정을 진행했습니다.

@Value를 이용해 yml에 작성한 각 값들을 넣어줬고,

amazonS3Client()를 이용하여 Amazon S3 클라이언트를 생성하고 구성했는데, 엑세스 키와 시크릿 키를 사용하여 AWS 자격 증명을 설정하고, 지정된 region에 대한 S3 클라이언트를 생성하여 반환합니다.

코드를 자세히 살펴보면, BasicAWSCredentials 클래스를 사용하여 AWS 자격 증명 객체를 생성하는데, 해당 객체는 AWS 서비스에 대한 접근을 인증하는 데 사용됩니다.

그 다음에는 AmazonS3ClientBuilder 클래스를 사용하여 AmazonS3 클라이언트를 생성했습니다. 해당 클라이언트는 앞에서 설정한 AWS 자격증명과 region을 사용하여 S3 서비스와 상호 작용합니다.

S3Uploader

@Slf4j
@RequiredArgsConstructor
@Component
@Service
public class S3Uploader {
    private final AmazonS3Client amazonS3Client;

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

    public String upload(MultipartFile multipartFile, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
        return upload(uploadFile, dirName);
    }

    private String upload(File uploadFile, String dirName) {
        String fileName = dirName + "/" + uploadFile.getName();
        String uploadImageUrl = putS3(uploadFile, fileName);
        removeNewFile(uploadFile);
        return uploadImageUrl;
    }

    private String putS3(File uploadFile, String fileName) {
        amazonS3Client.putObject(
                new PutObjectRequest(bucket, fileName, uploadFile)
                        .withCannedAcl(CannedAccessControlList.PublicRead)
        );
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    private void removeNewFile(File targetFile) {
        if(targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        }else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private Optional<File> convert(MultipartFile file) throws  IOException {
        File convertFile = new File(file.getOriginalFilename());
        if(convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }

}

S3Uploader 클래스는 AWS S3에 파일을 업로드하는 데 사용됩니다.

버킷도 마찬가지로 yml의 값으로 적용시켰습니다.

먼저 public upload() 메서드를 설명하자면, MultipartFile 인터페이스를 받아서 AWS S3에 업로드하고, 업로드 된 파일의 URL을 반환합니다. dirName 같은 경우는 파일의 디렉토리 이름입니다.

private upload() 메서드는 실제로 파일 업로드 작업을 진행하는 역할을 합니다. 업로드 파일의 이름과 경로를 결정하고, S3에 파일을 업로드한 다음 해당 파일의 URL을 반환 해줍니다.

putS3() 메서드는 파일을 실제로 S3에 업로드를 하는데, 파일을 S3 버킷에 업로드하고 공개 읽기 권한을 부여합니다.

removeNewFile() 메서드는 업로드 후에 로컬 파일을 삭제하고, 파일 삭제 여부에 대한 로그를 기록합니다.

convert()메서드는 MultipartFileFile로 변환합니다.

즉, 해당 클래스를 통해 클라이언트에서 업로드한 파일을 AWS S3에 업로드하고, 업로드된 파일에 대한 공개 URL을 반환하는 것 입니다.

그럼 간단하게 CustomRecipe에 S3를 적용한 코드를 살펴보겠습니다.

CustomRecipeController

@RestController
@RequiredArgsConstructor
@Api(tags = "customRecipe",description = "커스텀 레시피 API")
@RequestMapping(value = "/custom")
public class CustomRecipeController {

    private final CustomRecipeService customRecipeService;

    @ApiOperation(value = "커스텀 레시피 사진 등록", notes = "커스텀 레시피 사진 등록 API")
    @ApiImplicitParam(name = "recipe_id", value = "커스텀 레시피 아이디")
    @PostMapping(value="/submit/image/{recipe_id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ApiResponse<Void> createCustomRecipe(@RequestPart(value="image") MultipartFile image, @PathVariable("recipe_id") Long recipeId) throws IOException {
        if(image.getSize() == 0) return ApiResponse.ok();
        customRecipeService.saveImageCustomRecipe(image, recipeId);
        return ApiResponse.created();
    }
}

위 코드는 커스텀 레시피 사진을 업로드하고 저장하는 API입니다.

먼저 @PostMapping에서 제공하는 consumes를 이용해 멀티파트 요청을 처리하게 했습니다.

@RequestPart를 사용하여 클라이언트에서 전송된 imageMultipartFile 객체로 수신합니다. 즉, 업로드 된 이미지 파일을 나타내는 것 입니다.

그럼 customRecipeService.saveImageCustomRecipe()를 살펴보겠습니다.

CustomRecipeService

@Service
@RequiredArgsConstructor
public class CustomRecipeService {

    private final CustomRecipeRepository customRecipeRepository;
    private final S3Uploader s3Uploader;
    private final MemberService memberService;
   
    @Transactional
    public void saveImageCustomRecipe(MultipartFile image, Long recipeId) throws IOException {
        String storedFileName = s3Uploader.upload(image,"images");
        CustomRecipe customRecipe = customRecipeRepository.findById(recipeId)
                .orElseThrow(() -> new CocktailException(CocktailRtnConsts.ERR401));

        if(memberService.getLoginMember().getId() != customRecipe.getMemberId()) {
            throw new CocktailException(CocktailRtnConsts.ERR401);
        }
        if (customRecipe.isDeleted() != false) {
            throw new CocktailException(CocktailRtnConsts.ERR404);
        }
        customRecipe.insertImageUrl(storedFileName);
    }
}

saveImageCustomRecipe() 메서드를 살펴보겠습니다.

먼저 s3Uploader.upload(image,"images"); 를 이용하여 이미지를 S3에 업로드하고, 업로드 된 이미지의 URL을 storedFileName 변수에 저장합니다.

그리고 해당 커스텀 레시피의 Id를 찾고, 현재 유저와 로그인한 유저 Id와 커스텀 레시피의 Id를 비교하여 권한 검사를 진행하고, 커스텀 레시피가 삭제된 경우도 검사를 진행해 예외를 던집니다.

이 과정이 모두 통과하면, customRecipe.insertImageUrl(storedFileName); 를 호출해서 업로드 된 이미지의 URL을 커스텀 레시피에 추가합니다.

간단하게 구현 동작을 확인해보면,

기능 동작

로그인 한 회원만 등록을 할 수 있기 때문에 엑세스 토큰을 넣어주고 진행합니다.

스크린샷 2023-09-15 오전 12.58.14.png

성공하면, S3 버킷에 저장되는 것을 확인할 수 있습니다.

스크린샷 2023-09-15 오전 1 01 13
스크린샷 2023-09-15 오전 1 10 37
스크린샷 2023-09-18 오후 9 55 00

데이터베이스에도 잘 저장된 것을 볼 수 있습니다.

스크린샷 2023-09-15 오전 1.10.50.png

Last updated