Skip to content

Commit c543d83

Browse files
committed
feat: 이미지 업로드 기능 구현
- aws s3 연동을 위한 의존성 추가 - 환경변수 추가 - S3 연결을 위한 config 클래스 구현 - 서비스 레이어에 이미지 업로드 기능 구현 - 업로드 실패 예외와 예외 메세지 추 - test.yml에 테스트에 필요한 환경변수 추가 - 테스트 코드 작성및 검증 완료
1 parent 49bff8b commit c543d83

File tree

12 files changed

+313
-1
lines changed

12 files changed

+313
-1
lines changed

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ 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("파일 크기가 허용된 한도를 초과했습니다."),
1720
;
1821

1922
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+
}
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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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 lombok.RequiredArgsConstructor;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.stereotype.Service;
10+
import software.amazon.awssdk.core.sync.RequestBody;
11+
import software.amazon.awssdk.services.s3.S3Client;
12+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
13+
14+
import java.io.IOException;
15+
16+
import static com.somemore.global.exception.ExceptionMessage.UPLOAD_FAILED;
17+
18+
@RequiredArgsConstructor
19+
@Service
20+
public class ImageUploadService implements ImageUploadUseCase {
21+
22+
private final S3Client s3Client;
23+
24+
@Value("${cloud.aws.s3.bucket}")
25+
private String bucket;
26+
27+
@Value("${cloud.aws.s3.base-url}")
28+
private String baseUrl;
29+
30+
@Override
31+
public String uploadImage(ImageUploadRequestDto requestDto) {
32+
33+
String fileName = ImageUploadUtils.generateUniqueFileName(requestDto.imageFile().getOriginalFilename());
34+
35+
PutObjectRequest request = PutObjectRequest.builder()
36+
.bucket(bucket)
37+
.key(fileName)
38+
.contentType(requestDto.imageFile().getContentType())
39+
.build();
40+
41+
try {
42+
s3Client.putObject(request, RequestBody.fromInputStream(
43+
requestDto.imageFile().getInputStream(),
44+
requestDto.imageFile().getSize()
45+
));
46+
47+
return ImageUploadUtils.generateS3Url(baseUrl, fileName);
48+
} catch (IOException e) {
49+
throw new ImageUploadException(UPLOAD_FAILED.getMessage());
50+
}
51+
}
52+
53+
}
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+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.somemore.imageupload.util;
2+
3+
import java.util.UUID;
4+
5+
public final class ImageUploadUtils {
6+
7+
private ImageUploadUtils() {
8+
throw new UnsupportedOperationException("인스턴스화 할 수 없는 클래스 입니다.");
9+
}
10+
11+
public static String generateUniqueFileName(String originalFileName) {
12+
String uuid = UUID.randomUUID().toString();
13+
String fileExtension = extractFileExtension(originalFileName);
14+
return uuid + fileExtension;
15+
}
16+
17+
public static String extractFileExtension(String fileName) {
18+
return fileName.substring(fileName.lastIndexOf("."));
19+
}
20+
21+
public static String generateS3Url(String baseUrl, String fileName) {
22+
return String.format("%s/%s", baseUrl, fileName);
23+
}
24+
25+
}

src/main/resources/application.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@ app:
22
front-url: ${FRONT_URL}
33
back-url: ${BACK_URL}
44

5+
# AWS S3
6+
cloud:
7+
aws:
8+
credentials:
9+
access-key: ${ACCESS_KEY}
10+
secret-key: ${SECRET_KEY}
11+
region:
12+
static: ${BUCKET_REGION}
13+
s3:
14+
bucket: ${BUCKET_NAME}
15+
base-url: ${BASE_URL}
16+
stack:
17+
auto: false
18+
519
spring:
620
datasource:
721
driver-class-name: com.mysql.cj.jdbc.Driver
@@ -47,6 +61,12 @@ spring:
4761
locale: ko_KR
4862
locale-resolver: fixed
4963

64+
servlet:
65+
multipart:
66+
max-file-size: 10MB
67+
max-request-size: 10MB
68+
69+
5070
#swagger
5171
springdoc:
5272
swagger-ui:
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.somemore.imageupload.service;
2+
3+
import com.somemore.IntegrationTestSupport;
4+
import com.somemore.global.exception.ImageUploadException;
5+
import com.somemore.imageupload.dto.ImageUploadRequestDto;
6+
import org.junit.jupiter.api.BeforeEach;
7+
import org.junit.jupiter.api.DisplayName;
8+
import org.junit.jupiter.api.Test;
9+
import org.mockito.InjectMocks;
10+
import org.mockito.Mock;
11+
import org.springframework.test.util.ReflectionTestUtils;
12+
import org.springframework.web.multipart.MultipartFile;
13+
import software.amazon.awssdk.core.sync.RequestBody;
14+
import software.amazon.awssdk.services.s3.S3Client;
15+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
16+
17+
import java.io.IOException;
18+
import java.io.InputStream;
19+
20+
import static org.junit.jupiter.api.Assertions.*;
21+
import static org.mockito.Mockito.*;
22+
23+
class ImageUploadServiceTest extends IntegrationTestSupport {
24+
25+
@Mock
26+
private S3Client s3Client;
27+
28+
@InjectMocks
29+
private ImageUploadService imageUploadService;
30+
31+
@Mock
32+
private MultipartFile multipartFile;
33+
34+
@BeforeEach
35+
void setUp() throws IOException {
36+
imageUploadService = new ImageUploadService(s3Client);
37+
38+
ReflectionTestUtils.setField(imageUploadService, "bucket", "test-bucket");
39+
ReflectionTestUtils.setField(imageUploadService, "baseUrl", "https://amazonaws.com/");
40+
41+
when(multipartFile.getOriginalFilename()).thenReturn("testImage.jpg");
42+
when(multipartFile.getContentType()).thenReturn("image/jpeg");
43+
when(multipartFile.getInputStream()).thenReturn(mock(InputStream.class));
44+
when(multipartFile.getSize()).thenReturn(1000L);
45+
}
46+
47+
@DisplayName("업로드 요청 이미지를 S3에 업로드 할 수 있다.")
48+
@Test
49+
void testUploadImage_success() {
50+
// given
51+
ImageUploadRequestDto requestDto = new ImageUploadRequestDto(multipartFile);
52+
53+
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
54+
.thenReturn(null);
55+
56+
// when
57+
String result = imageUploadService.uploadImage(requestDto);
58+
59+
// then
60+
verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
61+
assertNotNull(result);
62+
assertTrue(result.startsWith("https://amazonaws.com/"));
63+
assertTrue(result.endsWith(".jpg"));
64+
}
65+
66+
@DisplayName("이미지 형식이 올바르지 않다면 업로드 할 수 없다.")
67+
@Test
68+
void testUploadImage_failure() throws IOException {
69+
// given
70+
when(multipartFile.getInputStream()).thenThrow(new IOException());
71+
72+
ImageUploadRequestDto requestDto = new ImageUploadRequestDto(multipartFile);
73+
74+
// when, then
75+
assertThrows(ImageUploadException.class, () -> imageUploadService.uploadImage(requestDto));
76+
}
77+
}

0 commit comments

Comments
 (0)