Skip to content

Commit a482f66

Browse files
authored
Merge pull request #68 from YAPP-Github/feat/PRODUCT-140
[Feat] 공통 이미지 처리 기능 구현
2 parents d6c66d4 + 93e4289 commit a482f66

File tree

9 files changed

+265
-3
lines changed

9 files changed

+265
-3
lines changed

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ dependencies {
8585

8686
// aws
8787
implementation 'io.awspring.cloud:spring-cloud-aws-starter-parameter-store:3.2.1'
88+
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.2.1'
8889
implementation 'software.amazon.awssdk:s3:2.31.77'
8990
}
9091

src/main/java/eatda/exception/BusinessErrorCode.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ public enum BusinessErrorCode {
4949
EXPIRED_TOKEN("AUTH002", "이미 만료된 토큰입니다.", HttpStatus.UNAUTHORIZED),
5050
UNAUTHORIZED_ORIGIN("AUTH003", "허용되지 않은 오리진입니다."),
5151
OAUTH_SERVER_ERROR("AUTH003", "OAuth 서버와의 통신 오류입니다.", HttpStatus.INTERNAL_SERVER_ERROR),
52-
;
52+
53+
// image
54+
INVALID_IMAGE_TYPE("CLIENT010", "지원하지 않는 이미지 형식입니다.", HttpStatus.BAD_REQUEST),
55+
FILE_UPLOAD_FAILED("SERVER002", "파일 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
56+
FILE_URL_GENERATION_FAILED("SERVER003", "파일 URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
57+
PRESIGNED_URL_GENERATION_FAILED("SERVER004", "Presigned URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);
5358

5459
private final String code;
5560
private final String message;

src/main/java/eatda/exception/EtcErrorCode.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ public enum EtcErrorCode {
1616
NO_HEADER_FOUND("CLIENT008", "필수 헤더 값이 존재하지 않습니다.", HttpStatus.BAD_REQUEST),
1717
NO_PARAMETER_FOUND("CLIENT009", "필수 파라미터 값이 존재하지 않습니다.", HttpStatus.BAD_REQUEST),
1818

19-
INTERNAL_SERVER_ERROR("SERVER001", "서버 내부 에러가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
20-
;
19+
INTERNAL_SERVER_ERROR("SERVER001", "서버 내부 에러가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);
2120

2221
private final String code;
2322
private final String message;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package eatda.service.common;
2+
3+
import eatda.exception.BusinessErrorCode;
4+
import eatda.exception.BusinessException;
5+
import java.io.IOException;
6+
import java.time.Duration;
7+
import java.util.Set;
8+
import java.util.UUID;
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.GetObjectRequest;
15+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
16+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
17+
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
18+
19+
@Service
20+
public class ImageService {
21+
22+
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png");
23+
private static final String PATH_DELIMITER = "/";
24+
private static final String EXTENSION_DELIMITER = ".";
25+
private static final Duration PRESIGNED_URL_DURATION = Duration.ofMinutes(30);
26+
27+
private final S3Client s3Client;
28+
private final String bucket;
29+
private final S3Presigner s3Presigner;
30+
31+
public ImageService(
32+
S3Client s3Client,
33+
@Value("${spring.cloud.aws.s3.bucket}") String bucket,
34+
S3Presigner s3Presigner) {
35+
this.s3Client = s3Client;
36+
this.bucket = bucket;
37+
this.s3Presigner = s3Presigner;
38+
}
39+
40+
public String upload(MultipartFile file, String domain) {
41+
validateContentType(file);
42+
String extension = getExtension(file.getOriginalFilename());
43+
String uuid = UUID.randomUUID().toString();
44+
String key = domain + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension;
45+
46+
try {
47+
PutObjectRequest request = PutObjectRequest.builder()
48+
.bucket(bucket)
49+
.key(key)
50+
.contentType(file.getContentType())
51+
.build();
52+
53+
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
54+
return key;
55+
} catch (IOException exception) {
56+
throw new BusinessException(BusinessErrorCode.FILE_UPLOAD_FAILED);
57+
}
58+
}
59+
60+
private void validateContentType(MultipartFile file) {
61+
String contentType = file.getContentType();
62+
if (!ALLOWED_CONTENT_TYPES.contains(contentType)) {
63+
throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_TYPE);
64+
}
65+
}
66+
67+
private String getExtension(String filename) {
68+
if (filename == null || filename.lastIndexOf(EXTENSION_DELIMITER) == -1 || filename.startsWith(EXTENSION_DELIMITER)) {
69+
return "bin";
70+
}
71+
return filename.substring(filename.lastIndexOf(EXTENSION_DELIMITER) + 1);
72+
}
73+
74+
public String getPresignedUrl(String key) {
75+
try {
76+
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
77+
.bucket(bucket)
78+
.key(key)
79+
.build();
80+
81+
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
82+
.getObjectRequest(getObjectRequest)
83+
.signatureDuration(PRESIGNED_URL_DURATION)
84+
.build();
85+
86+
return s3Presigner.presignGetObject(presignRequest).url().toString();
87+
} catch (Exception exception) {
88+
throw new BusinessException(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED);
89+
}
90+
}
91+
}

src/main/resources/application-dev.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ spring:
77
aws:
88
region:
99
static: ap-northeast-2
10+
s3:
11+
bucket: eatda-storage-dev
12+
13+
servlet:
14+
multipart:
15+
max-file-size: 5MB
16+
max-request-size: 20MB
17+
1018
config:
1119
import: "aws-parameterstore:/dev/"
1220

src/main/resources/application-local.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ spring:
1010
password: ${LOCAL_DB_PASSWORD}
1111
driver-class-name: com.mysql.cj.jdbc.Driver
1212

13+
cloud:
14+
aws:
15+
region:
16+
static: ap-northeast-2
17+
s3:
18+
bucket: eatda-storage-local
19+
20+
servlet:
21+
multipart:
22+
max-file-size: 5MB
23+
max-request-size: 20MB
24+
1325
jpa:
1426
hibernate:
1527
ddl-auto: validate

src/main/resources/application-prod.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ spring:
77
aws:
88
region:
99
static: ap-northeast-2
10+
s3:
11+
bucket: eatda-storage-prod
12+
13+
servlet:
14+
multipart:
15+
max-file-size: 5MB
16+
max-request-size: 10MB
17+
1018
config:
1119
import: "aws-parameterstore:/prod/"
1220

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package eatda.service.common;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.junit.jupiter.api.Assertions.assertAll;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
import static org.mockito.ArgumentMatchers.any;
7+
import static org.mockito.Mockito.mock;
8+
import static org.mockito.Mockito.verify;
9+
import static org.mockito.Mockito.when;
10+
11+
import eatda.exception.BusinessErrorCode;
12+
import eatda.exception.BusinessException;
13+
import java.io.IOException;
14+
import java.net.URL;
15+
import java.time.Duration;
16+
import org.junit.jupiter.api.BeforeEach;
17+
import org.junit.jupiter.api.Nested;
18+
import org.junit.jupiter.api.Test;
19+
import org.junit.jupiter.api.extension.ExtendWith;
20+
import org.mockito.ArgumentCaptor;
21+
import org.mockito.Mock;
22+
import org.mockito.junit.jupiter.MockitoExtension;
23+
import org.springframework.mock.web.MockMultipartFile;
24+
import software.amazon.awssdk.core.exception.SdkClientException;
25+
import software.amazon.awssdk.core.sync.RequestBody;
26+
import software.amazon.awssdk.services.s3.S3Client;
27+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
28+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
29+
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
30+
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
31+
32+
@ExtendWith(MockitoExtension.class)
33+
class ImageServiceTest {
34+
35+
private static final String TEST_BUCKET = "test-bucket";
36+
37+
@Mock
38+
private S3Client s3Client;
39+
40+
@Mock
41+
private S3Presigner s3Presigner;
42+
private ImageService imageService;
43+
44+
@BeforeEach
45+
void setUp() {
46+
imageService = new ImageService(s3Client, TEST_BUCKET, s3Presigner);
47+
}
48+
49+
@Nested
50+
class FileUpload {
51+
52+
@Test
53+
void 허용된_이미지_타입이면_정상적으로_업로드되고_생성된_Key를_반환한다() throws IOException {
54+
String originalFilename = "test-image.jpg";
55+
String contentType = "image/jpeg";
56+
String domain = "stores";
57+
MockMultipartFile file = new MockMultipartFile(
58+
"image", originalFilename, contentType, "image-content".getBytes()
59+
);
60+
61+
String key = imageService.upload(file, domain);
62+
63+
ArgumentCaptor<PutObjectRequest> putObjectRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
64+
verify(s3Client).putObject(putObjectRequestCaptor.capture(), any(RequestBody.class));
65+
PutObjectRequest capturedRequest = putObjectRequestCaptor.getValue();
66+
67+
assertAll(
68+
() -> assertThat(key).matches(domain + "/[a-f0-9\\-]{36}\\.jpg"),
69+
() -> assertThat(capturedRequest.key()).isEqualTo(key),
70+
() -> assertThat(capturedRequest.bucket()).isEqualTo(TEST_BUCKET),
71+
() -> assertThat(capturedRequest.contentType()).isEqualTo(contentType)
72+
);
73+
}
74+
75+
@Test
76+
void 허용되지_않은_파일_타입이면_BusinessException을_던진다() {
77+
MockMultipartFile file = new MockMultipartFile(
78+
"file", "test.txt", "text/plain", "file-content".getBytes()
79+
);
80+
81+
BusinessException exception = assertThrows(BusinessException.class,
82+
() -> imageService.upload(file, "etc"));
83+
84+
assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE);
85+
}
86+
}
87+
88+
@Nested
89+
class GeneratePresignedUrl {
90+
91+
@Test
92+
void 유효한_key로_요청_시_Presigned_URL을_성공적으로_반환한다() throws Exception {
93+
String key = "stores/image.jpg";
94+
String expectedUrlString = "https://example.com/presigned-url-for-image.jpg";
95+
URL expectedUrl = new URL(expectedUrlString);
96+
97+
PresignedGetObjectRequest presignedRequestResult = mock(PresignedGetObjectRequest.class);
98+
99+
when(presignedRequestResult.url()).thenReturn(expectedUrl);
100+
when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class)))
101+
.thenReturn(presignedRequestResult);
102+
103+
String presignedUrl = imageService.getPresignedUrl(key);
104+
105+
ArgumentCaptor<GetObjectPresignRequest> presignRequestCaptor =
106+
ArgumentCaptor.forClass(GetObjectPresignRequest.class);
107+
verify(s3Presigner).presignGetObject(presignRequestCaptor.capture());
108+
GetObjectPresignRequest capturedPresignRequest = presignRequestCaptor.getValue();
109+
110+
assertAll(
111+
() -> assertThat(presignedUrl).isEqualTo(expectedUrlString),
112+
() -> assertThat(capturedPresignRequest.getObjectRequest().key()).isEqualTo(key),
113+
() -> assertThat(capturedPresignRequest.getObjectRequest().bucket()).isEqualTo(TEST_BUCKET),
114+
() -> assertThat(capturedPresignRequest.signatureDuration()).isEqualTo(Duration.ofMinutes(30))
115+
);
116+
}
117+
118+
@Test
119+
void Presigner가_예외를_던지면_BusinessException으로_전환하여_던진다() {
120+
String key = "stores/image.jpg";
121+
122+
when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class)))
123+
.thenThrow(SdkClientException.create("AWS SDK 통신 실패"));
124+
125+
BusinessException exception = assertThrows(BusinessException.class,
126+
() -> imageService.getPresignedUrl(key));
127+
128+
assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED);
129+
}
130+
}
131+
}

src/test/resources/application.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ spring:
1111
import:
1212
- optional:file:.env-local[.properties]
1313

14+
cloud:
15+
aws:
16+
region:
17+
static: ap-northeast-2
18+
s3:
19+
bucket: eatda-storage-test
20+
1421
datasource:
1522
driver-class-name: org.h2.Driver
1623
url: jdbc:h2:mem:database

0 commit comments

Comments
 (0)