Skip to content

Commit

Permalink
feat: s3에 의존적이지 않도록 이미지 업로드 추상화 (#658)
Browse files Browse the repository at this point in the history
* refactor: StorageService 추상화

* refactor: s3 path 위치를 application.yml 파일에 설정할 수 있도록 변경

* refactor: s3버킷 설정을 AmazonS3StorageService가 가져가도록 변경

* feat: 로컬에 이미지를 저장할 수 있는 기능 구현

* refactor: lombok 어노테이션 변경

* refactor: 이미지 업로드 메서드 시그니처 번경
  • Loading branch information
fromitive authored Nov 28, 2024
1 parent ad77f1f commit a1d7967
Show file tree
Hide file tree
Showing 12 changed files with 180 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public ResponseEntity<OfferingUpdateResponse> updateOffering(
@PostMapping("/offerings/product-images/s3")
public ResponseEntity<OfferingProductImageResponse> uploadProductImageToS3(
@RequestParam MultipartFile image) {
OfferingProductImageResponse response = offeringService.uploadProductImageToS3(image);
OfferingProductImageResponse response = offeringService.uploadProductImage(image);
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ private void validateMeetingDate(LocalDateTime offeringMeetingDateTime) {
}
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
import com.amazonaws.regions.Regions;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.zzang.chongdae.storage.service.AmazonS3StorageService;
import com.zzang.chongdae.storage.service.StorageService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class StorageConfig {

@Bean
public AmazonS3 amazonS3() {
public StorageService storageService() {
return new AmazonS3StorageService(amazonS3());
}

private AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard()
.withCredentials(new DefaultAWSCredentialsProviderChain())
.withRegion(Regions.AP_NORTHEAST_2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
public enum StorageErrorCode implements ErrorResponse {

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

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.zzang.chongdae.storage.service;

import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.storage.exception.StorageErrorCode;
import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;

@RequiredArgsConstructor
public class AmazonS3StorageService implements StorageService {

private final AmazonS3 s3Client;

@Value("${amazon.s3.bucket}")
private String bucketName;

@Value("${amazon.cloudfront.redirectUrl}")
private String redirectUrl;

@Value("${amazon.cloudfront.storagePath}")
private String storagePath;

@Override
public String uploadFile(MultipartFile file) {
try {
String objectKey = storagePath + UUID.randomUUID();
InputStream inputStream = file.getInputStream();
ObjectMetadata metadata = createMetadata(file);
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectKey, inputStream, metadata);
s3Client.putObject(putObjectRequest);
return createUri(objectKey);
} catch (IOException e) {
throw new MarketException(StorageErrorCode.INVALID_FILE);
} catch (SdkClientException e) {
throw new MarketException(StorageErrorCode.STORAGE_SERVER_FAIL);
}
}

private ObjectMetadata createMetadata(MultipartFile file) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
return metadata;
}

private String createUri(String objectKey) {
return UriComponentsBuilder.newInstance()
.scheme("https")
.host(redirectUrl)
.path("/" + objectKey)
.build(false)
.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.zzang.chongdae.storage.service;

import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.storage.exception.StorageErrorCode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;

@RequiredArgsConstructor
public class LocalStorageService implements StorageService {

private static final Set<String> ALLOW_IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "svg");

@Value("${storage.redirectUrl}")
private String redirectUrl;

@Value("${storage.path}")
private String storagePath;

@Override
public String uploadFile(MultipartFile file) {
try {
String extension = getFileExtension(file);
validateFileExtension(extension);
String newFilename = UUID.randomUUID() + "." + extension;
Path uploadPath = Paths.get(storagePath);
Path filePath = uploadPath.resolve(newFilename);
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
return createUri(filePath.toString());
} catch (IOException e) {
throw new RuntimeException(e);
}
}


private String getFileExtension(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || !originalFilename.contains(".")) {
throw new MarketException(StorageErrorCode.INVALID_FILE);
}
return originalFilename.substring(originalFilename.lastIndexOf('.') + 1).toLowerCase();
}

private void validateFileExtension(String extension) {
if (!ALLOW_IMAGE_EXTENSIONS.contains(extension)) {
throw new MarketException(StorageErrorCode.INVALID_FILE_EXTENSION);
}
}

private String createUri(String objectKey) {
return UriComponentsBuilder.newInstance()
.scheme("https")
.host(redirectUrl)
.path("/" + objectKey)
.build(false)
.toString();
}
}
Original file line number Diff line number Diff line change
@@ -1,60 +1,8 @@
package com.zzang.chongdae.storage.service;

import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.storage.exception.StorageErrorCode;
import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;

@RequiredArgsConstructor
@Service
public class StorageService {
public interface StorageService {

private final AmazonS3 s3Client;

@Value("${amazon.s3.bucket}")
private String bucketName;

@Value("${amazon.cloudfront.redirectUrl}")
private String redirectUrl;

public String uploadFile(MultipartFile file, String path) {
try {
String objectKey = path + UUID.randomUUID();
InputStream inputStream = file.getInputStream();
ObjectMetadata metadata = createMetadata(file);
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectKey, inputStream, metadata);
s3Client.putObject(putObjectRequest);
return createUri(objectKey);
} catch (IOException e) {
throw new MarketException(StorageErrorCode.INVALID_FILE);
} catch (SdkClientException e) {
throw new MarketException(StorageErrorCode.STORAGE_SERVER_FAIL);
}
}

private ObjectMetadata createMetadata(MultipartFile file) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
return metadata;
}

private String createUri(String objectKey) {
return UriComponentsBuilder.newInstance()
.scheme("https")
.host(redirectUrl)
.path("/" + objectKey)
.build(false)
.toString();
}
String uploadFile(MultipartFile file);
}
5 changes: 5 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ amazon:
bucket: techcourse-project-2024
cloudfront:
redirectUrl: d3a5rfnjdz82qu.cloudfront.net
storagePath: chongdae-market/images/offerings/product/

storage:
path: /uploads
redirectUrl: image.chongdae.site

security:
jwt:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import com.zzang.chongdae.member.config.TestNicknameWordPickerConfig;
import com.zzang.chongdae.notification.config.TestNotificationConfig;
import com.zzang.chongdae.offering.config.TestCrawlerConfig;
import com.zzang.chongdae.offering.config.TestStorageConfig;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Import;

@Import({TestCrawlerConfig.class,
TestNicknameWordPickerConfig.class,
TestClockConfig.class,
TestNotificationConfig.class})
TestNotificationConfig.class,
TestStorageConfig.class})
@TestConfiguration
public class TestConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.zzang.chongdae.offering.config;

import com.zzang.chongdae.offering.util.FakeStorageService;
import com.zzang.chongdae.storage.service.StorageService;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;

@TestConfiguration
public class TestStorageConfig {

@Bean
@Primary
public StorageService testStorageService() {
return new FakeStorageService();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document;
import static com.epages.restdocs.apispec.Schema.schema;
import static io.restassured.RestAssured.given;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;

import com.epages.restdocs.apispec.ParameterDescriptorWithType;
Expand All @@ -31,15 +30,13 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.web.multipart.MultipartFile;

public class OfferingIntegrationTest extends IntegrationTest {

@MockBean
@Autowired
private StorageService storageService;

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

@DisplayName("상품 이미지를 받아 이미지를 S3에 업로드한다.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.zzang.chongdae.offering.util;

import com.zzang.chongdae.storage.service.StorageService;
import org.springframework.web.multipart.MultipartFile;

public class FakeStorageService implements StorageService {

@Override
public String uploadFile(MultipartFile file) {
return "https://upload-image-url.com/";
}
}

0 comments on commit a1d7967

Please sign in to comment.