Skip to content

Commit

Permalink
✨ Feat: s3를 이용한 이미지 업로드 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
yunhacandy authored Sep 10, 2024
2 parents 6e9acc7 + d21fb3c commit eddbc29
Show file tree
Hide file tree
Showing 10 changed files with 202 additions and 14 deletions.
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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("포스트 생성 완료");
Expand Down Expand Up @@ -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("포스트 수정 완료");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Expand All @@ -14,7 +15,7 @@ public record PostRequest(
@Size(max = 3000)
String content,

String imageUrl,
MultipartFile postImage,

PostCategory category
){
Expand Down
25 changes: 21 additions & 4 deletions src/main/java/cotato/growingpain/post/service/PostService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
);
}
Expand Down Expand Up @@ -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);
}

Expand Down
35 changes: 35 additions & 0 deletions src/main/java/cotato/growingpain/s3/S3Config.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
79 changes: 79 additions & 0 deletions src/main/java/cotato/growingpain/s3/S3Uploader.java
Original file line number Diff line number Diff line change
@@ -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<File> 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();
}
}
19 changes: 18 additions & 1 deletion src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -35,4 +42,14 @@ jwt:
#logging:
# discord:
# webhook-url: ${DISCORD_WEBHOOK_URL}
# config: classpath:logback-spring.xml
# 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}
17 changes: 17 additions & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}

0 comments on commit eddbc29

Please sign in to comment.