Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/CD.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ jobs:
JWT_SECRET: ${{ secrets.JWT_SECRET }}
FRONT_URL: ${{secrets.FRONT_URL}}
BACK_URL: ${{secrets.BACK_URL}}
BUCKET_NAME: ${{secrets.BUCKET_NAME}}
BUCKET_REGION: ${{secrets.BUCKET_REGION}}
BASE_URL:https: ${{secrets.BASE_URL}}
Copy link
Collaborator

@m-a-king m-a-king Nov 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BASE_URL:https: ${{secrets.BASE_URL}}

이 부분 조금 어색한데, 괜찮은지 여쭤보고 싶습니다~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

더 명시적인 이름으로 수정해보겠습니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

변수명 집어넣다가 실수했네요 수정하겠습니다

S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}}
S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}}

steps:
- name: Github Repository 파일 불러오기
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ jobs:
JWT_SECRET: ${{ secrets.JWT_SECRET }}
FRONT_URL: ${{secrets.FRONT_URL}}
BACK_URL: ${{secrets.BACK_URL}}
BUCKET_NAME: ${{secrets.BUCKET_NAME}}
BUCKET_REGION: ${{secrets.BUCKET_REGION}}
BASE_URL:https: ${{secrets.BASE_URL}}
S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}}
S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}}


steps:
Expand Down
4 changes: 3 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

// AWS
implementation(platform("software.amazon.awssdk:bom:2.29.20"))
implementation("software.amazon.awssdk:s3")

// Web Layer
implementation 'org.springframework.boot:spring-boot-starter-web'
Expand All @@ -61,7 +64,6 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.6.0'


//test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/com/somemore/global/configure/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.somemore.global.configure;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@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 AwsCredentials basicAWSCredentials() {
return AwsBasicCredentials.create(accessKey, secretKey);
}

@Bean
public S3Presigner s3Presigner(AwsCredentials awsCredentials) {
return S3Presigner.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}

@Bean
public S3Client s3Client(AwsCredentials awsCredentials) {
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ public enum ExceptionMessage {
NOT_EXISTS_LOCATION("존재하지 않는 위치 ID 입니다."),
NOT_EXISTS_RECRUIT_BOARD("존재하지 않는 봉사 모집글 ID 입니다."),
UNAUTHORIZED_RECRUIT_BOARD("자신이 작성한 봉사 모집글이 아닙니다."),
UPLOAD_FAILED("파일 업로드에 실패했습니다."),
INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."),
FILE_SIZE_EXCEEDED("파일 크기가 허용된 한도를 초과했습니다."),
EMPTY_FILE("파일이 존재하지 않습니다.")
;

private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.somemore.global.exception;

public class ImageUploadException extends RuntimeException{

public ImageUploadException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@


import com.somemore.global.exception.BadRequestException;
import com.somemore.global.exception.ImageUploadException;
import org.springframework.data.crossstore.ChangeSetPersister;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;


Expand All @@ -25,4 +28,15 @@ ProblemDetail handleBadRequestException(final BadRequestException e) {
return problemDetail;
}

@ExceptionHandler(ImageUploadException.class)
ProblemDetail handleImageUploadException(final ImageUploadException e) {

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());

problemDetail.setTitle("이미지 업로드 실패");
problemDetail.setDetail("업로드 중 문제가 발생했습니다. 파일 크기나 형식이 올바른지 확인해 주세요.");

return problemDetail;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.somemore.imageupload.dto;

import org.springframework.web.multipart.MultipartFile;

public record ImageUploadRequestDto(
MultipartFile imageFile
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.somemore.imageupload.service;

import com.somemore.global.exception.ImageUploadException;
import com.somemore.imageupload.dto.ImageUploadRequestDto;
import com.somemore.imageupload.usecase.ImageUploadUseCase;
import com.somemore.imageupload.util.ImageUploadUtils;
import com.somemore.imageupload.validator.ImageUploadValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import java.io.IOException;

import static com.somemore.global.exception.ExceptionMessage.UPLOAD_FAILED;

@RequiredArgsConstructor
@Service
public class ImageUploadService implements ImageUploadUseCase {

private final S3Client s3Client;
private final ImageUploadValidator imageUploadValidator;

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

@Value("${cloud.aws.s3.base-url}")
private String baseUrl;

@Override
public String uploadImage(ImageUploadRequestDto requestDto) {
imageUploadValidator.validateFileSize(requestDto.imageFile());
imageUploadValidator.validateFileType(requestDto.imageFile());

String fileName = ImageUploadUtils.generateUniqueFileName(requestDto.imageFile().getOriginalFilename());

PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.contentType(requestDto.imageFile().getContentType())
.build();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서비스 퍼블릭 메서드에 빌더 패턴을 추출해낼 수 있을 것 같습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private PutObjectRequest createPutObjectRequest(MultipartFile file) {
String fileName = ImageUploadUtils.generateUniqueFileName(file.getOriginalFilename());
return PutObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.contentType(file.getContentType())
.build();
}

혹시 이런 느낌을 말씀하신게 맞나요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네, 감사합니다.

fileName같은 경우에는

최하단 return에서 사용되고 있고
이미 추상화 수준을 적절히 맞춰뒀다고 생각해서

굳이 넣지 않으셔도 되지만, 넣으셔도 무방할 것 같습니다!

createPutObjectRequest 맞습니다


try {
s3Client.putObject(request, RequestBody.fromInputStream(
requestDto.imageFile().getInputStream(),
requestDto.imageFile().getSize()
));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리퀘스트바디에서 이미지를 가져오는 과정을 추출해낼 수 있을 것 같아요!


return ImageUploadUtils.generateS3Url(baseUrl, fileName);
} catch (IOException e) {
throw new ImageUploadException(UPLOAD_FAILED.getMessage());
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.somemore.imageupload.usecase;

import com.somemore.imageupload.dto.ImageUploadRequestDto;

public interface ImageUploadUseCase {
String uploadImage(ImageUploadRequestDto requestDto);
}
25 changes: 25 additions & 0 deletions src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.somemore.imageupload.util;

import java.util.UUID;

public final class ImageUploadUtils {

private ImageUploadUtils() {
throw new UnsupportedOperationException("인스턴스화 할 수 없는 클래스 입니다.");
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

명시적으로 처리하는 것을 처음 봤어요! 신기하네용

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분도 ENUM으로 처리하면 좋을 것 같습니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 좋은 의견 감사합니다 반영하겠습니다


public static String generateUniqueFileName(String originalFileName) {
String uuid = UUID.randomUUID().toString();
String fileExtension = extractFileExtension(originalFileName);
return uuid + fileExtension;
}

public static String extractFileExtension(String fileName) {
return fileName.substring(fileName.lastIndexOf("."));
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private가 되는 것이 맞다고 생각하는데 어떻게 생각하시나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞습니다 자동으로 메서드 추출해봤는데 실수가 있었던거 같아요


public static String generateS3Url(String baseUrl, String fileName) {
return String.format("%s/%s", baseUrl, fileName);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.somemore.imageupload.validator;

import com.somemore.global.exception.ImageUploadException;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import static com.somemore.global.exception.ExceptionMessage.*;

@Component
public class DefaultImageUploadValidator implements ImageUploadValidator {

private static final long MAX_FILE_SIZE = 8L * 1024 * 1024; // 8MB

public void validateFileSize(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ImageUploadException(EMPTY_FILE.getMessage());
}

if (file.getSize() > MAX_FILE_SIZE) {
throw new ImageUploadException(FILE_SIZE_EXCEEDED.getMessage());
}
}

public void validateFileType(MultipartFile file) {
String contentType = file.getContentType();
if (!isAllowedImageType(contentType)) {
throw new ImageUploadException(INVALID_FILE_TYPE.getMessage());
}
}

private boolean isAllowedImageType(String contentType) {
return contentType != null && (
contentType.equals("image/jpeg") ||
contentType.equals("image/png") ||
contentType.equals("image/gif") ||
contentType.equals("image/webp")
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.somemore.imageupload.validator;

import org.springframework.web.multipart.MultipartFile;

public interface ImageUploadValidator {

void validateFileSize(MultipartFile file);
void validateFileType(MultipartFile file);
}
20 changes: 20 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ app:
front-url: ${FRONT_URL}
back-url: ${BACK_URL}

# AWS S3
cloud:
aws:
credentials:
access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY}
region:
static: ${BUCKET_REGION}
s3:
bucket: ${BUCKET_NAME}
base-url: ${BASE_URL}
stack:
auto: false

spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
Expand Down Expand Up @@ -47,6 +61,12 @@ spring:
locale: ko_KR
locale-resolver: fixed

servlet:
multipart:
max-file-size: 8MB
max-request-size: 8MB


#swagger
springdoc:
swagger-ui:
Expand Down
Loading