diff --git a/build.gradle b/build.gradle index 1354a2e..691fdfd 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,10 @@ dependencies { // discord-error-log-system implementation 'com.github.napstr:logback-discord-appender:1.0.0' + + //S3 관련 의존성 부여 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + } tasks.named('test') { diff --git a/src/main/java/cotato/growingpain/common/exception/ErrorCode.java b/src/main/java/cotato/growingpain/common/exception/ErrorCode.java index cd338c5..a2a4f4b 100644 --- a/src/main/java/cotato/growingpain/common/exception/ErrorCode.java +++ b/src/main/java/cotato/growingpain/common/exception/ErrorCode.java @@ -36,7 +36,12 @@ public enum ErrorCode { CANNOT_LIKE_OWN_COMMENT(HttpStatus.BAD_REQUEST, "본인이 작성한 댓글은 좋아요를 누를 수 없습니다."), CANNOT_LIKE_OWN_REPLY_COMMENT(HttpStatus.BAD_REQUEST, "본인이 작성한 답글은 좋아요를 누를 수 없습니다."), ALREADY_DELETED(HttpStatus.CONFLICT, "이미 삭제되었습니다."), - ALREADY_SAVED(HttpStatus.CONFLICT, "이미 저장되었습니다."); + ALREADY_SAVED(HttpStatus.CONFLICT, "이미 저장되었습니다."), + + //S3 + IMAGE_PROCESSING_FAIL(HttpStatus.INTERNAL_SERVER_ERROR,"이미지 처리에 실패했습니다."); + + private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/cotato/growingpain/common/exception/ImageException.java b/src/main/java/cotato/growingpain/common/exception/ImageException.java new file mode 100644 index 0000000..7a30ac9 --- /dev/null +++ b/src/main/java/cotato/growingpain/common/exception/ImageException.java @@ -0,0 +1,11 @@ +package cotato.growingpain.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.io.IOException; +@AllArgsConstructor +@Getter +public class ImageException extends IOException { + ErrorCode errorCode; +} \ No newline at end of file diff --git a/src/main/java/cotato/growingpain/post/controller/PostController.java b/src/main/java/cotato/growingpain/post/controller/PostController.java index e7ceb39..e971f4d 100644 --- a/src/main/java/cotato/growingpain/post/controller/PostController.java +++ b/src/main/java/cotato/growingpain/post/controller/PostController.java @@ -12,18 +12,20 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.io.IOException; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -38,10 +40,10 @@ public class PostController { @Operation(summary = "게시글 등록", description = "게시글 등록을 위한 메소드") @ApiResponse(content = @Content(schema = @Schema(implementation = Response.class))) - @PostMapping("") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) - public Response registerPost(@Valid @RequestBody PostRequest request, - @AuthenticationPrincipal Long memberId) { + public Response registerPost(@RequestPart("postRequest") @Valid PostRequest request, + @AuthenticationPrincipal Long memberId) throws IOException { log.info("게시글 등록한 memberId: {}", memberId); postService.registerPost(request, memberId); return Response.createSuccessWithNoData("포스트 생성 완료"); @@ -87,11 +89,11 @@ public Response deletePost(@PathVariable Long postId, @Operation(summary = "게시글 수정", description = "게시글 수정을 위한 메소드") @ApiResponse(content = @Content(schema = @Schema(implementation = Response.class))) - @PostMapping("/{postId}/update") + @PostMapping(value = "/{postId}/update", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) public Response registerPost(@PathVariable Long postId, - @RequestBody PostRequest request, - @AuthenticationPrincipal Long memberId) { + @RequestPart("postRequest") @Valid PostRequest request, + @AuthenticationPrincipal Long memberId) throws IOException { log.info("게시글 {} 수정한 memberId: {}", postId, memberId); postService.updatePost(postId, request,memberId); return Response.createSuccessWithNoData("포스트 수정 완료"); diff --git a/src/main/java/cotato/growingpain/post/dto/request/PostRequest.java b/src/main/java/cotato/growingpain/post/dto/request/PostRequest.java index 678230d..0d170fa 100644 --- a/src/main/java/cotato/growingpain/post/dto/request/PostRequest.java +++ b/src/main/java/cotato/growingpain/post/dto/request/PostRequest.java @@ -3,6 +3,7 @@ import cotato.growingpain.post.PostCategory; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; public record PostRequest( @@ -14,7 +15,7 @@ public record PostRequest( @Size(max = 3000) String content, - String imageUrl, + MultipartFile postImage, PostCategory category ){ diff --git a/src/main/java/cotato/growingpain/post/service/PostService.java b/src/main/java/cotato/growingpain/post/service/PostService.java index fda9c40..94ac5ff 100644 --- a/src/main/java/cotato/growingpain/post/service/PostService.java +++ b/src/main/java/cotato/growingpain/post/service/PostService.java @@ -12,11 +12,14 @@ import cotato.growingpain.post.repository.PostLikeRepository; import cotato.growingpain.post.repository.PostRepository; import cotato.growingpain.replycomment.repository.ReplyCommentRepository; +import cotato.growingpain.s3.S3Uploader; +import java.io.IOException; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Slf4j @Service @@ -28,13 +31,21 @@ public class PostService { private final MemberRepository memberRepository; private final CommentRepository commentRepository; private final ReplyCommentRepository replyCommentRepository; + private final S3Uploader s3Uploader; @Transactional - public void registerPost(PostRequest request, Long memberId) { + public void registerPost(PostRequest request, Long memberId) throws IOException { Member member = memberRepository.getReferenceById(memberId); PostCategory parentCategory = request.category().getParent(); + + String imageUrl = null; + MultipartFile imageFile = request.postImage(); // postImage를 가져옴 + if (imageFile != null && !imageFile.isEmpty()) { // null 체크 추가 + imageUrl = s3Uploader.uploadFileToS3(imageFile, "post"); + } + postRepository.save( - Post.of(member, request.title(), request.content(), request.imageUrl(), parentCategory, + Post.of(member, request.title(), request.content(), imageUrl, parentCategory, request.category()) ); } @@ -72,14 +83,20 @@ public void deletePost(Long postId, Long memberId) { } @Transactional - public void updatePost(Long postId, PostRequest request, Long memberId) { + public void updatePost(Long postId, PostRequest request, Long memberId) throws IOException { Post post = findByPostIdAndMemberId(postId, memberId); if (post.isDeleted()) { throw new AppException(ErrorCode.ALREADY_DELETED); } - post.updatePost(request.title(), request.content(), request.imageUrl(), request.category()); + String imageUrl = null; + MultipartFile imageFile = request.postImage(); // postImage를 가져옴 + if (imageFile != null && !imageFile.isEmpty()) { // null 체크 추가 + imageUrl = s3Uploader.uploadFileToS3(imageFile, "post"); + } + + post.updatePost(request.title(), request.content(), imageUrl, request.category()); postRepository.save(post); } diff --git a/src/main/java/cotato/growingpain/s3/S3Config.java b/src/main/java/cotato/growingpain/s3/S3Config.java new file mode 100644 index 0000000..eb59e13 --- /dev/null +++ b/src/main/java/cotato/growingpain/s3/S3Config.java @@ -0,0 +1,35 @@ +package cotato.growingpain.s3; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.AmazonS3Client; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@NoArgsConstructor +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 AmazonS3Client amazonS3Client() { + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/cotato/growingpain/s3/S3Uploader.java b/src/main/java/cotato/growingpain/s3/S3Uploader.java new file mode 100644 index 0000000..b1843fb --- /dev/null +++ b/src/main/java/cotato/growingpain/s3/S3Uploader.java @@ -0,0 +1,79 @@ +package cotato.growingpain.s3; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.PutObjectRequest; +import cotato.growingpain.common.exception.ErrorCode; +import cotato.growingpain.common.exception.ImageException; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@RequiredArgsConstructor // final 멤버변수가 있으면 생성자 항목에 포함시킴 +@Component +@Service +public class S3Uploader { + + private final AmazonS3Client amazonS3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + // MultipartFile을 전달받아 File로 전환한 후 S3에 업로드 + public String uploadFileToS3(MultipartFile multipartFile, String dirName) throws IOException { + File uploadFile = convert(multipartFile) + .orElseThrow(() -> new ImageException(ErrorCode.IMAGE_PROCESSING_FAIL)); + return upload(uploadFile, dirName); + } + + private String upload(File uploadFile, String dirName) { + String fileName = dirName + "/" + uploadFile.getName(); + String uploadImageUrl = putS3(uploadFile, fileName); + + removeNewFile(uploadFile); // 로컬에 생성된 File 삭제 (MultipartFile -> File 전환 하며 로컬에 파일 생성됨) + + 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(); + } + + //로컬에 저장된 이미지 지우기 + 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") + "/" + UUID.randomUUID() + file.getOriginalFilename()); + try { + if (convertFile.createNewFile()) { // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능) + FileOutputStream fos = new FileOutputStream(convertFile); // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함 + fos.write(file.getBytes()); + fos.close(); + return Optional.of(convertFile); + } + } catch (IOException e) { + throw new ImageException(ErrorCode.IMAGE_PROCESSING_FAIL); + } + return Optional.empty(); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 019ca8d..8b8ba4a 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -8,6 +8,13 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} + servlet: + multipart: + enabled: true + file-size-threshold: 0B + max-file-size: 10MB + max-request-size: 10MB + jpa: hibernate: ddl-auto: update @@ -35,4 +42,14 @@ jwt: #logging: # discord: # webhook-url: ${DISCORD_WEBHOOK_URL} -# config: classpath:logback-spring.xml \ No newline at end of file +# config: classpath:logback-spring.xml + +cloud: + aws: + s3: + bucket: ${S3_BUCKET_NAME} + stack.auto: false + region.static: ap-northeast-2 + credentials: + accessKey: ${S3_ACCESS_KEY} + secretKey: ${S3_ACCESS_PASSWORD} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 9e9aad1..94e75ac 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -8,6 +8,13 @@ spring: username: ${LOCAL_DB_USERNAME} password: ${LOCAL_DB_PASSWORD} + servlet: + multipart: + enabled: true + file-size-threshold: 0B + max-file-size: 10MB + max-request-size: 10MB + jpa: hibernate: ddl-auto: update @@ -36,3 +43,13 @@ jwt: # discord: # webhook-uri: ${DISCORD_WEBHOOK_URI} # config: classpath:logback-test.xml + +cloud: + aws: + s3: + bucket: ${S3_BUCKET_NAME} + stack.auto: false + region.static: ap-northeast-2 + credentials: + accessKey: ${S3_ACCESS_KEY} + secretKey: ${S3_ACCESS_PASSWORD} \ No newline at end of file