Skip to content

Commit 0be3598

Browse files
authored
Feature/36 s3 이미지 업로드 기능 구현 (#66)
* feat: 이미지 업로드 기능 구현 - aws s3 연동을 위한 의존성 추가 - 환경변수 추가 - S3 연결을 위한 config 클래스 구현 - 서비스 레이어에 이미지 업로드 기능 구현 - 업로드 실패 예외와 예외 메세지 추 - test.yml에 테스트에 필요한 환경변수 추가 - 테스트 코드 작성및 검증 완료 * chore: 환경변수 추가 - cicd workflow 에 이미지 업로드 관련 환경변수 추가 - appication.yml에 이미지 업로드 관련 환경변수 이름 변경 * feat: 이미지 파일 검증 기능 구현 - 이미지 크기를 8MB로 제한 - 이미지의 크기 초과를 검증하는 기능 구현 - 이미지의 확장자명을 검증하는 기능 구현 - 이미지 업로드시 검증 기능들을 수행하도록 수정 - 전역 예외처리 핸들러에 이미지 업로드 예외 추가 - 예외 메세지 추가 - 테스트 코드 작성및 검증 완 * fix: 코드 리뷰 사항 반영 - BASE_URL을 S3_BASE_URL라는 더 명시적인 이름으로 변경 - ImageUploadUtils private 생성자의 예외처리 메세지를 ExceptionMessage에 등록하여 사용하도록 수정 - 이미지 업로드 메서드의 내부 기능을 메서드로 추출하여 가독성 향상 - ImageUploadUtils의 일부 기능의 접근 제어자를 private으로 수정 - 테스트 코드의 when, then을 더 명확하게 분
1 parent 49bff8b commit 0be3598

File tree

18 files changed

+521
-1
lines changed

18 files changed

+521
-1
lines changed

.github/workflows/CD.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ jobs:
2727
JWT_SECRET: ${{ secrets.JWT_SECRET }}
2828
FRONT_URL: ${{secrets.FRONT_URL}}
2929
BACK_URL: ${{secrets.BACK_URL}}
30+
BUCKET_NAME: ${{secrets.BUCKET_NAME}}
31+
BUCKET_REGION: ${{secrets.BUCKET_REGION}}
32+
IMG_BASE_URL: ${{secrets.BASE_URL}}
33+
S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}}
34+
S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}}
3035

3136
steps:
3237
- name: Github Repository 파일 불러오기

.github/workflows/CI.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ jobs:
3535
JWT_SECRET: ${{ secrets.JWT_SECRET }}
3636
FRONT_URL: ${{secrets.FRONT_URL}}
3737
BACK_URL: ${{secrets.BACK_URL}}
38+
BUCKET_NAME: ${{secrets.BUCKET_NAME}}
39+
BUCKET_REGION: ${{secrets.BUCKET_REGION}}
40+
IMG_BASE_URL: ${{secrets.BASE_URL}}
41+
S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}}
42+
S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}}
3843

3944

4045
steps:

build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ dependencies {
5252
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
5353
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
5454

55+
// AWS
56+
implementation(platform("software.amazon.awssdk:bom:2.29.20"))
57+
implementation("software.amazon.awssdk:s3")
5558

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

64-
6567
//test
6668
testImplementation 'org.springframework.boot:spring-boot-starter-test'
6769
testImplementation 'org.springframework.security:spring-security-test'
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.somemore.global.configure;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
7+
import software.amazon.awssdk.auth.credentials.AwsCredentials;
8+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
9+
import software.amazon.awssdk.regions.Region;
10+
import software.amazon.awssdk.services.s3.S3Client;
11+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
12+
13+
@Configuration
14+
public class S3Config {
15+
@Value("${cloud.aws.credentials.access-key}")
16+
private String accessKey;
17+
18+
@Value("${cloud.aws.credentials.secret-key}")
19+
private String secretKey;
20+
21+
@Value("${cloud.aws.region.static}")
22+
private String region;
23+
24+
@Bean
25+
public AwsCredentials basicAWSCredentials() {
26+
return AwsBasicCredentials.create(accessKey, secretKey);
27+
}
28+
29+
@Bean
30+
public S3Presigner s3Presigner(AwsCredentials awsCredentials) {
31+
return S3Presigner.builder()
32+
.region(Region.of(region))
33+
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
34+
.build();
35+
}
36+
37+
@Bean
38+
public S3Client s3Client(AwsCredentials awsCredentials) {
39+
return S3Client.builder()
40+
.region(Region.of(region))
41+
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
42+
.build();
43+
}
44+
45+
}

src/main/java/com/somemore/global/exception/ExceptionMessage.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ public enum ExceptionMessage {
1414
NOT_EXISTS_LOCATION("존재하지 않는 위치 ID 입니다."),
1515
NOT_EXISTS_RECRUIT_BOARD("존재하지 않는 봉사 모집글 ID 입니다."),
1616
UNAUTHORIZED_RECRUIT_BOARD("자신이 작성한 봉사 모집글이 아닙니다."),
17+
UPLOAD_FAILED("파일 업로드에 실패했습니다."),
18+
INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."),
19+
FILE_SIZE_EXCEEDED("파일 크기가 허용된 한도를 초과했습니다."),
20+
EMPTY_FILE("파일이 존재하지 않습니다."),
21+
INSTANTIATION_NOT_ALLOWED("인스턴스화 할 수 없는 클래스 입니다.")
1722
;
1823

1924
private final String message;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.somemore.global.exception;
2+
3+
public class ImageUploadException extends RuntimeException{
4+
5+
public ImageUploadException(String message) {
6+
super(message);
7+
}
8+
}

src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33

44
import com.somemore.global.exception.BadRequestException;
5+
import com.somemore.global.exception.ImageUploadException;
6+
import org.springframework.data.crossstore.ChangeSetPersister;
57
import org.springframework.http.HttpStatus;
68
import org.springframework.http.ProblemDetail;
79
import org.springframework.web.bind.annotation.ExceptionHandler;
810
import org.springframework.web.bind.annotation.RestControllerAdvice;
11+
import org.springframework.web.context.request.WebRequest;
912
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
1013

1114

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

31+
@ExceptionHandler(ImageUploadException.class)
32+
ProblemDetail handleImageUploadException(final ImageUploadException e) {
33+
34+
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
35+
36+
problemDetail.setTitle("이미지 업로드 실패");
37+
problemDetail.setDetail("업로드 중 문제가 발생했습니다. 파일 크기나 형식이 올바른지 확인해 주세요.");
38+
39+
return problemDetail;
40+
}
41+
2842
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.somemore.imageupload.dto;
2+
3+
import org.springframework.web.multipart.MultipartFile;
4+
5+
public record ImageUploadRequestDto(
6+
MultipartFile imageFile
7+
) {
8+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.somemore.imageupload.service;
2+
3+
import com.somemore.global.exception.ImageUploadException;
4+
import com.somemore.imageupload.dto.ImageUploadRequestDto;
5+
import com.somemore.imageupload.usecase.ImageUploadUseCase;
6+
import com.somemore.imageupload.util.ImageUploadUtils;
7+
import com.somemore.imageupload.validator.ImageUploadValidator;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.web.multipart.MultipartFile;
12+
import software.amazon.awssdk.core.sync.RequestBody;
13+
import software.amazon.awssdk.services.s3.S3Client;
14+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
15+
16+
import java.io.IOException;
17+
18+
import static com.somemore.global.exception.ExceptionMessage.UPLOAD_FAILED;
19+
20+
@RequiredArgsConstructor
21+
@Service
22+
public class ImageUploadService implements ImageUploadUseCase {
23+
24+
private final S3Client s3Client;
25+
private final ImageUploadValidator imageUploadValidator;
26+
27+
@Value("${cloud.aws.s3.bucket}")
28+
private String bucket;
29+
30+
@Value("${cloud.aws.s3.base-url}")
31+
private String baseUrl;
32+
33+
@Override
34+
public String uploadImage(ImageUploadRequestDto requestDto) {
35+
imageUploadValidator.validateFileSize(requestDto.imageFile());
36+
imageUploadValidator.validateFileType(requestDto.imageFile());
37+
38+
try {
39+
return uploadToS3(requestDto.imageFile());
40+
} catch (IOException e) {
41+
throw new ImageUploadException(UPLOAD_FAILED.getMessage());
42+
}
43+
}
44+
45+
private String uploadToS3(MultipartFile file) throws IOException {
46+
String fileName = ImageUploadUtils.generateUniqueFileName(file.getOriginalFilename());
47+
48+
PutObjectRequest request = createPutObjectRequest(file, fileName);
49+
50+
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
51+
52+
return ImageUploadUtils.generateS3Url(baseUrl, fileName);
53+
}
54+
55+
private PutObjectRequest createPutObjectRequest(MultipartFile file, String fileName) {
56+
return PutObjectRequest.builder()
57+
.bucket(bucket)
58+
.key(fileName)
59+
.contentType(file.getContentType())
60+
.build();
61+
}
62+
63+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.somemore.imageupload.usecase;
2+
3+
import com.somemore.imageupload.dto.ImageUploadRequestDto;
4+
5+
public interface ImageUploadUseCase {
6+
String uploadImage(ImageUploadRequestDto requestDto);
7+
}

0 commit comments

Comments
 (0)