Skip to content

Commit b124df6

Browse files
committed
feat: s3에 의존적이지 않도록 이미지 업로드 추상화 (#658)
* refactor: StorageService 추상화 * refactor: s3 path 위치를 application.yml 파일에 설정할 수 있도록 변경 * refactor: s3버킷 설정을 AmazonS3StorageService가 가져가도록 변경 * feat: 로컬에 이미지를 저장할 수 있는 기능 구현 * refactor: lombok 어노테이션 변경 * refactor: 이미지 업로드 메서드 시그니처 번경
1 parent b62eed5 commit b124df6

File tree

12 files changed

+180
-67
lines changed

12 files changed

+180
-67
lines changed

backend/src/main/java/com/zzang/chongdae/offering/controller/OfferingController.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public ResponseEntity<OfferingUpdateResponse> updateOffering(
107107
@PostMapping("/offerings/product-images/s3")
108108
public ResponseEntity<OfferingProductImageResponse> uploadProductImageToS3(
109109
@RequestParam MultipartFile image) {
110-
OfferingProductImageResponse response = offeringService.uploadProductImageToS3(image);
110+
OfferingProductImageResponse response = offeringService.uploadProductImage(image);
111111
return ResponseEntity.ok(response);
112112
}
113113

backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ private void validateMeetingDate(LocalDateTime offeringMeetingDateTime) {
149149
}
150150
}
151151

152-
public OfferingProductImageResponse uploadProductImageToS3(MultipartFile image) {
153-
String imageUrl = storageService.uploadFile(image, "chongdae-market/images/offerings/product/");
152+
public OfferingProductImageResponse uploadProductImage(MultipartFile image) {
153+
String imageUrl = storageService.uploadFile(image);
154154
return new OfferingProductImageResponse(imageUrl);
155155
}
156156

backend/src/main/java/com/zzang/chongdae/storage/config/StorageConfig.java

+7-1
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
44
import com.amazonaws.regions.Regions;
55
import com.amazonaws.services.s3.AmazonS3;
66
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
7+
import com.zzang.chongdae.storage.service.AmazonS3StorageService;
8+
import com.zzang.chongdae.storage.service.StorageService;
79
import org.springframework.context.annotation.Bean;
810
import org.springframework.context.annotation.Configuration;
911

1012
@Configuration
1113
public class StorageConfig {
1214

1315
@Bean
14-
public AmazonS3 amazonS3() {
16+
public StorageService storageService() {
17+
return new AmazonS3StorageService(amazonS3());
18+
}
19+
20+
private AmazonS3 amazonS3() {
1521
return AmazonS3ClientBuilder.standard()
1622
.withCredentials(new DefaultAWSCredentialsProviderChain())
1723
.withRegion(Regions.AP_NORTHEAST_2)

backend/src/main/java/com/zzang/chongdae/storage/exception/StorageErrorCode.java

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
public enum StorageErrorCode implements ErrorResponse {
1515

1616
INVALID_FILE(BAD_REQUEST, "유효한 파일이 아닙니다."),
17+
INVALID_FILE_EXTENSION(BAD_REQUEST, "허용하지 않은 이미지 파일 확장자입니다."),
1718
STORAGE_SERVER_FAIL(INTERNAL_SERVER_ERROR, "이미지 서버에 문제가 발생했습니다.");
1819

1920
private final HttpStatus status;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.zzang.chongdae.storage.service;
2+
3+
import com.amazonaws.SdkClientException;
4+
import com.amazonaws.services.s3.AmazonS3;
5+
import com.amazonaws.services.s3.model.ObjectMetadata;
6+
import com.amazonaws.services.s3.model.PutObjectRequest;
7+
import com.zzang.chongdae.global.exception.MarketException;
8+
import com.zzang.chongdae.storage.exception.StorageErrorCode;
9+
import java.io.IOException;
10+
import java.io.InputStream;
11+
import java.util.UUID;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.beans.factory.annotation.Value;
14+
import org.springframework.web.multipart.MultipartFile;
15+
import org.springframework.web.util.UriComponentsBuilder;
16+
17+
@RequiredArgsConstructor
18+
public class AmazonS3StorageService implements StorageService {
19+
20+
private final AmazonS3 s3Client;
21+
22+
@Value("${amazon.s3.bucket}")
23+
private String bucketName;
24+
25+
@Value("${amazon.cloudfront.redirectUrl}")
26+
private String redirectUrl;
27+
28+
@Value("${amazon.cloudfront.storagePath}")
29+
private String storagePath;
30+
31+
@Override
32+
public String uploadFile(MultipartFile file) {
33+
try {
34+
String objectKey = storagePath + UUID.randomUUID();
35+
InputStream inputStream = file.getInputStream();
36+
ObjectMetadata metadata = createMetadata(file);
37+
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectKey, inputStream, metadata);
38+
s3Client.putObject(putObjectRequest);
39+
return createUri(objectKey);
40+
} catch (IOException e) {
41+
throw new MarketException(StorageErrorCode.INVALID_FILE);
42+
} catch (SdkClientException e) {
43+
throw new MarketException(StorageErrorCode.STORAGE_SERVER_FAIL);
44+
}
45+
}
46+
47+
private ObjectMetadata createMetadata(MultipartFile file) {
48+
ObjectMetadata metadata = new ObjectMetadata();
49+
metadata.setContentLength(file.getSize());
50+
metadata.setContentType(file.getContentType());
51+
return metadata;
52+
}
53+
54+
private String createUri(String objectKey) {
55+
return UriComponentsBuilder.newInstance()
56+
.scheme("https")
57+
.host(redirectUrl)
58+
.path("/" + objectKey)
59+
.build(false)
60+
.toString();
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.zzang.chongdae.storage.service;
2+
3+
import com.zzang.chongdae.global.exception.MarketException;
4+
import com.zzang.chongdae.storage.exception.StorageErrorCode;
5+
import java.io.IOException;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.nio.file.Paths;
9+
import java.nio.file.StandardCopyOption;
10+
import java.util.Set;
11+
import java.util.UUID;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.beans.factory.annotation.Value;
14+
import org.springframework.web.multipart.MultipartFile;
15+
import org.springframework.web.util.UriComponentsBuilder;
16+
17+
@RequiredArgsConstructor
18+
public class LocalStorageService implements StorageService {
19+
20+
private static final Set<String> ALLOW_IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "svg");
21+
22+
@Value("${storage.redirectUrl}")
23+
private String redirectUrl;
24+
25+
@Value("${storage.path}")
26+
private String storagePath;
27+
28+
@Override
29+
public String uploadFile(MultipartFile file) {
30+
try {
31+
String extension = getFileExtension(file);
32+
validateFileExtension(extension);
33+
String newFilename = UUID.randomUUID() + "." + extension;
34+
Path uploadPath = Paths.get(storagePath);
35+
Path filePath = uploadPath.resolve(newFilename);
36+
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
37+
return createUri(filePath.toString());
38+
} catch (IOException e) {
39+
throw new RuntimeException(e);
40+
}
41+
}
42+
43+
44+
private String getFileExtension(MultipartFile file) {
45+
String originalFilename = file.getOriginalFilename();
46+
if (originalFilename == null || !originalFilename.contains(".")) {
47+
throw new MarketException(StorageErrorCode.INVALID_FILE);
48+
}
49+
return originalFilename.substring(originalFilename.lastIndexOf('.') + 1).toLowerCase();
50+
}
51+
52+
private void validateFileExtension(String extension) {
53+
if (!ALLOW_IMAGE_EXTENSIONS.contains(extension)) {
54+
throw new MarketException(StorageErrorCode.INVALID_FILE_EXTENSION);
55+
}
56+
}
57+
58+
private String createUri(String objectKey) {
59+
return UriComponentsBuilder.newInstance()
60+
.scheme("https")
61+
.host(redirectUrl)
62+
.path("/" + objectKey)
63+
.build(false)
64+
.toString();
65+
}
66+
}
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,8 @@
11
package com.zzang.chongdae.storage.service;
22

3-
import com.amazonaws.SdkClientException;
4-
import com.amazonaws.services.s3.AmazonS3;
5-
import com.amazonaws.services.s3.model.ObjectMetadata;
6-
import com.amazonaws.services.s3.model.PutObjectRequest;
7-
import com.zzang.chongdae.global.exception.MarketException;
8-
import com.zzang.chongdae.storage.exception.StorageErrorCode;
9-
import java.io.IOException;
10-
import java.io.InputStream;
11-
import java.util.UUID;
12-
import lombok.RequiredArgsConstructor;
13-
import org.springframework.beans.factory.annotation.Value;
14-
import org.springframework.stereotype.Service;
153
import org.springframework.web.multipart.MultipartFile;
16-
import org.springframework.web.util.UriComponentsBuilder;
174

18-
@RequiredArgsConstructor
19-
@Service
20-
public class StorageService {
5+
public interface StorageService {
216

22-
private final AmazonS3 s3Client;
23-
24-
@Value("${amazon.s3.bucket}")
25-
private String bucketName;
26-
27-
@Value("${amazon.cloudfront.redirectUrl}")
28-
private String redirectUrl;
29-
30-
public String uploadFile(MultipartFile file, String path) {
31-
try {
32-
String objectKey = path + UUID.randomUUID();
33-
InputStream inputStream = file.getInputStream();
34-
ObjectMetadata metadata = createMetadata(file);
35-
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectKey, inputStream, metadata);
36-
s3Client.putObject(putObjectRequest);
37-
return createUri(objectKey);
38-
} catch (IOException e) {
39-
throw new MarketException(StorageErrorCode.INVALID_FILE);
40-
} catch (SdkClientException e) {
41-
throw new MarketException(StorageErrorCode.STORAGE_SERVER_FAIL);
42-
}
43-
}
44-
45-
private ObjectMetadata createMetadata(MultipartFile file) {
46-
ObjectMetadata metadata = new ObjectMetadata();
47-
metadata.setContentLength(file.getSize());
48-
metadata.setContentType(file.getContentType());
49-
return metadata;
50-
}
51-
52-
private String createUri(String objectKey) {
53-
return UriComponentsBuilder.newInstance()
54-
.scheme("https")
55-
.host(redirectUrl)
56-
.path("/" + objectKey)
57-
.build(false)
58-
.toString();
59-
}
7+
String uploadFile(MultipartFile file);
608
}

backend/src/main/resources/application.yml

+5
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ amazon:
4444
bucket: techcourse-project-2024
4545
cloudfront:
4646
redirectUrl: d3a5rfnjdz82qu.cloudfront.net
47+
storagePath: chongdae-market/images/offerings/product/
48+
49+
storage:
50+
path: /uploads
51+
redirectUrl: image.chongdae.site
4752

4853
security:
4954
jwt:

backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import com.zzang.chongdae.member.config.TestNicknameWordPickerConfig;
44
import com.zzang.chongdae.notification.config.TestNotificationConfig;
55
import com.zzang.chongdae.offering.config.TestCrawlerConfig;
6+
import com.zzang.chongdae.offering.config.TestStorageConfig;
67
import org.springframework.boot.test.context.TestConfiguration;
78
import org.springframework.context.annotation.Import;
89

910
@Import({TestCrawlerConfig.class,
1011
TestNicknameWordPickerConfig.class,
1112
TestClockConfig.class,
12-
TestNotificationConfig.class})
13+
TestNotificationConfig.class,
14+
TestStorageConfig.class})
1315
@TestConfiguration
1416
public class TestConfig {
1517
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.zzang.chongdae.offering.config;
2+
3+
import com.zzang.chongdae.offering.util.FakeStorageService;
4+
import com.zzang.chongdae.storage.service.StorageService;
5+
import org.springframework.boot.test.context.TestConfiguration;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Primary;
8+
9+
@TestConfiguration
10+
public class TestStorageConfig {
11+
12+
@Bean
13+
@Primary
14+
public StorageService testStorageService() {
15+
return new FakeStorageService();
16+
}
17+
}

backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java

+2-8
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document;
77
import static com.epages.restdocs.apispec.Schema.schema;
88
import static io.restassured.RestAssured.given;
9-
import static org.mockito.BDDMockito.given;
109
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
1110

1211
import com.epages.restdocs.apispec.ParameterDescriptorWithType;
@@ -31,15 +30,13 @@
3130
import org.junit.jupiter.api.DisplayName;
3231
import org.junit.jupiter.api.Nested;
3332
import org.junit.jupiter.api.Test;
34-
import org.springframework.boot.test.mock.mockito.MockBean;
33+
import org.springframework.beans.factory.annotation.Autowired;
3534
import org.springframework.http.MediaType;
36-
import org.springframework.mock.web.MockMultipartFile;
3735
import org.springframework.restdocs.payload.FieldDescriptor;
38-
import org.springframework.web.multipart.MultipartFile;
3936

4037
public class OfferingIntegrationTest extends IntegrationTest {
4138

42-
@MockBean
39+
@Autowired
4340
private StorageService storageService;
4441

4542
@DisplayName("공모 상세 조회")
@@ -771,9 +768,6 @@ class UploadProductImage {
771768
void setUp() {
772769
member = memberFixture.createMember("dora");
773770
image = new File("src/test/resources/test-image.png");
774-
MultipartFile mockImage = new MockMultipartFile("emptyImageFile", new byte[0]);
775-
given(storageService.uploadFile(mockImage, "path"))
776-
.willReturn("https://uploaded-image-url.com");
777771
}
778772

779773
@DisplayName("상품 이미지를 받아 이미지를 S3에 업로드한다.")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.zzang.chongdae.offering.util;
2+
3+
import com.zzang.chongdae.storage.service.StorageService;
4+
import org.springframework.web.multipart.MultipartFile;
5+
6+
public class FakeStorageService implements StorageService {
7+
8+
@Override
9+
public String uploadFile(MultipartFile file) {
10+
return "https://upload-image-url.com/";
11+
}
12+
}

0 commit comments

Comments
 (0)