diff --git a/build.gradle b/build.gradle index e4b0ce70..c34c1980 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,8 @@ dependencies { // jwt implementation 'io.jsonwebtoken:jjwt-api:0.11.2' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2', 'io.jsonwebtoken:jjwt-jackson:0.11.2' + //s3 추가 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' implementation 'org.springframework.boot:spring-boot-starter-security' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/org/tenten/tentenbe/config/S3Config.java b/src/main/java/org/tenten/tentenbe/config/S3Config.java index b3137a4a..7f8c07a5 100644 --- a/src/main/java/org/tenten/tentenbe/config/S3Config.java +++ b/src/main/java/org/tenten/tentenbe/config/S3Config.java @@ -1,7 +1,32 @@ package org.tenten.tentenbe.config; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +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 { -} + @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() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/tenten/tentenbe/global/s3/ImageUploadDto.java b/src/main/java/org/tenten/tentenbe/global/s3/ImageUploadDto.java new file mode 100644 index 00000000..03f86a6f --- /dev/null +++ b/src/main/java/org/tenten/tentenbe/global/s3/ImageUploadDto.java @@ -0,0 +1,11 @@ +package org.tenten.tentenbe.global.s3; + +import lombok.Builder; + +@Builder +public record ImageUploadDto( + String imageUrl, + String message + +) { +} \ No newline at end of file diff --git a/src/main/java/org/tenten/tentenbe/global/s3/S3Controller.java b/src/main/java/org/tenten/tentenbe/global/s3/S3Controller.java new file mode 100644 index 00000000..271cbb64 --- /dev/null +++ b/src/main/java/org/tenten/tentenbe/global/s3/S3Controller.java @@ -0,0 +1,46 @@ +package org.tenten.tentenbe.global.s3; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.coyote.BadRequestException; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.tenten.tentenbe.global.response.GlobalDataResponse; + +import static org.tenten.tentenbe.global.common.constant.ResponseConstant.SUCCESS; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/images") +@Tag(name = "이미지 관련 API", description = "S3 이미지 업로드 API 입니다.") +@Slf4j +public class S3Controller { + private final S3Uploader s3Uploader; + + @Operation( + summary = "이미지 파일 업로드 API", + description = "MultipartFile 형태의 이미지 파일을 'images'라는 키로 form-data 형태로 전송해주세요. 이 API는 전송된 이미지를 S3에 저장하고, 저장된 이미지의 URL을 반환합니다." + ) + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> uploadImage(@RequestParam("images") MultipartFile multipartFile) throws BadRequestException { + try { + String uploadedUrl = s3Uploader.uploadFiles(multipartFile, "static"); + ImageUploadDto imageUpload = ImageUploadDto.builder() + .imageUrl(uploadedUrl) + .message("이미지 업로드에 성공했습니다.") + .build(); + return ResponseEntity.ok(GlobalDataResponse.ok(SUCCESS, imageUpload)); + } catch (Exception e) { + throw new BadRequestException("잘못된 요청입니다."); + } + } + + +} diff --git a/src/main/java/org/tenten/tentenbe/global/s3/S3Uploader.java b/src/main/java/org/tenten/tentenbe/global/s3/S3Uploader.java new file mode 100644 index 00000000..3966d5e2 --- /dev/null +++ b/src/main/java/org/tenten/tentenbe/global/s3/S3Uploader.java @@ -0,0 +1,70 @@ +package org.tenten.tentenbe.global.s3; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; + +@RequiredArgsConstructor +@Component +@Slf4j +public class S3Uploader { + + private final AmazonS3Client amazonS3Client; + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + // MultipartFile을 전달받아 File로 전환한 후 S3에 업로드 + public String uploadFiles(MultipartFile multipartFile, String dirName) throws IOException { + File uploadFile = convert(multipartFile) // 파일 변환할 수 없으면 에러 + .orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File 전환 실패")); + return upload(uploadFile, dirName); + } + + private String upload(File uploadFile, String filePath) { + String fileName = filePath + "/" + UUID.randomUUID() + uploadFile.getName(); // S3에 저장된 파일 이름 + String uploadImageUrl = putS3(uploadFile, fileName); + removeNewFile(uploadFile); + return uploadImageUrl; // 업로드된 파일의 S3 URL 주소 반환 + } + + // S3로 업로드 + private String putS3(File uploadFile, String fileName) { + amazonS3Client.putObject( + new PutObjectRequest(bucket, fileName, uploadFile) + .withCannedAcl(CannedAccessControlList.PublicRead)); // PublicRead 권한으로 업로드 됨 + return amazonS3Client.getUrl(bucket, fileName).toString(); + } + + // 로컬에 생성된 File 삭제 (MultipartFile -> File 전환 하며 로컬에 파일 생성됨) + private void removeNewFile(File targetFile) { + if (targetFile.delete()) { + log.info("파일이 삭제되었습니다."); + } else { + log.info("파일이 삭제되지 못했습니다."); + } + } + + // 로컬에 파일 업로드 하기 + private Optional convert(MultipartFile file) throws IOException { + File convertFile = new File(System.getProperty("user.dir") + "/" + file.getOriginalFilename()); + if (convertFile.createNewFile()) { // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능) + try (FileOutputStream fos = new FileOutputStream(convertFile)) { // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함 + fos.write(file.getBytes()); + } + return Optional.of(convertFile); + } + return Optional.empty(); + } + +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 881f5b35..0ee21990 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -45,4 +45,17 @@ open-api: key: ${OPEN_API_KEY} jwt: - secret: ${JWT_SECRET} \ No newline at end of file + secret: ${JWT_SECRET} + +#s3 +cloud: + aws: + credentials: + access-key: ${aws.credentials.access.key} + secret-key: ${aws.credentials.secret.key} + s3: + bucket: ${aws.s3.bucket} + region: + static: ap-northeast-2 + stack: + auto: 'false' \ No newline at end of file