Skip to content

Commit 4d80ad0

Browse files
authored
Feat: [S3] 이미지 업로드를 s3로 변경 (#70)
1 parent 95dede1 commit 4d80ad0

10 files changed

Lines changed: 111 additions & 93 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ jobs:
5050
Firebase_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
5151
GOOGLE_MAP_API_KEY: ${{ secrets.GOOGLE_MAP_API_KEY }}
5252
SPRING_DOMAIN: ${{ secrets.SPRING_DOMAIN }}
53-
FILE_UPLOAD_DIR: ./uploads
53+
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
54+
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
55+
S3_REGION: ${{ secrets.S3_REGION }}
56+
S3_BUCKET: ${{ secrets.S3_BUCKET }}
57+
S3_BASE_URL: ${{ secrets.S3_BASE_URL }}
5458

5559
steps:
5660
- name: Checkout source code

.github/workflows/deploy.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ jobs:
4242
echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env
4343
echo "KAKAO_REDIRECT_URI=${{ secrets.KAKAO_REDIRECT_URI }}" >> .env
4444
echo "SPRING_DOMAIN=${{ secrets.SPRING_DOMAIN }}" >> .env
45-
echo "FILE_UPLOAD_DIR=/app/uploads" >> .env
45+
echo "S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }}" >> .env
46+
echo "S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }}" >> .env
47+
echo "S3_REGION=${{ secrets.S3_REGION }}" >> .env
48+
echo "S3_BUCKET=${{ secrets.S3_BUCKET }}" >> .env
49+
echo "S3_BASE_URL=${{ secrets.S3_BASE_URL }}" >> .env
4650
4751
mkdir -p ./src/main/resources/firebase
4852
echo '${{ secrets.FCM_JSON }}' > ./src/main/resources/${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}

runtracker/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ dependencies {
5656
implementation 'com.sksamuel.scrimage:scrimage-core:4.1.1'
5757
implementation 'com.sksamuel.scrimage:scrimage-webp:4.1.1'
5858

59+
// AWS S3
60+
implementation 'software.amazon.awssdk:s3:2.25.0'
61+
5962
compileOnly 'org.projectlombok:lombok'
6063
developmentOnly 'org.springframework.boot:spring-boot-devtools'
6164
runtimeOnly 'com.mysql:mysql-connector-j'

runtracker/docker-compose.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ services:
1313
- .env # DB, Redis 접속 정보 등을 담을 파일
1414
volumes:
1515
- ./firebase:/app/secrets
16-
- uploads:/app/uploads
1716
depends_on:
1817
mysql:
1918
condition: service_healthy
@@ -66,4 +65,3 @@ services:
6665
volumes:
6766
mysql-data:
6867
redis-data:
69-
uploads:

runtracker/src/main/java/com/runtracker/domain/upload/controller/UploadController.java

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
import com.runtracker.domain.upload.service.FileStorageService;
44
import com.runtracker.global.response.ApiResponse;
55
import lombok.RequiredArgsConstructor;
6-
import org.springframework.core.io.Resource;
7-
import org.springframework.http.HttpHeaders;
8-
import org.springframework.http.MediaType;
6+
import org.springframework.http.HttpStatus;
97
import org.springframework.http.ResponseEntity;
108
import org.springframework.web.bind.annotation.*;
119
import org.springframework.web.multipart.MultipartFile;
1210

11+
import java.net.URI;
1312
import java.util.HashMap;
1413
import java.util.Map;
1514

@@ -33,13 +32,9 @@ public ApiResponse<Map<String, String>> uploadImage(
3332
}
3433

3534
@GetMapping("/image/{filename:.+}")
36-
public ResponseEntity<Resource> getImage(@PathVariable String filename) {
37-
38-
Resource resource = fileStorageService.loadFileAsResource(filename);
39-
40-
return ResponseEntity.ok()
41-
.contentType(MediaType.parseMediaType("image/webp"))
42-
.header(HttpHeaders.CONTENT_DISPOSITION, "inline")
43-
.body(resource);
35+
public ResponseEntity<?> getImage(@PathVariable String filename) {
36+
return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
37+
.location(URI.create(fileStorageService.getImageUrl(filename)))
38+
.build();
4439
}
4540
}

runtracker/src/main/java/com/runtracker/domain/upload/service/FileStorageService.java

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,19 @@
22

33
import com.runtracker.domain.upload.exception.*;
44
import com.runtracker.global.util.ImageConverter;
5-
import com.runtracker.global.config.FileUploadConfig;
5+
import com.runtracker.global.config.S3Config;
66
import lombok.RequiredArgsConstructor;
77
import lombok.extern.slf4j.Slf4j;
8-
import org.springframework.core.io.Resource;
9-
import org.springframework.core.io.UrlResource;
108
import org.springframework.stereotype.Service;
119
import org.springframework.util.StringUtils;
1210
import org.springframework.web.multipart.MultipartFile;
11+
import software.amazon.awssdk.core.sync.RequestBody;
12+
import software.amazon.awssdk.services.s3.S3Client;
13+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
1314

1415
import java.io.ByteArrayInputStream;
1516
import java.io.IOException;
1617
import java.io.InputStream;
17-
import java.net.MalformedURLException;
18-
import java.nio.file.Files;
19-
import java.nio.file.Path;
20-
import java.nio.file.Paths;
21-
import java.nio.file.StandardCopyOption;
2218
import java.util.Base64;
2319
import java.util.UUID;
2420

@@ -27,7 +23,8 @@
2723
@RequiredArgsConstructor
2824
public class FileStorageService {
2925

30-
private final FileUploadConfig fileUploadConfig;
26+
private final S3Client s3Client;
27+
private final S3Config s3Config;
3128

3229
// 이미지 파일을 업로드하고 URL을 반환
3330
public String uploadImage(MultipartFile file) {
@@ -43,7 +40,7 @@ public String uploadImage(MultipartFile file) {
4340
return storeFile(file);
4441
}
4542

46-
// 파일을 WebP 포맷으로 변환
43+
// 파일을 WebP 포맷으로 변환하여 S3에 업로드
4744
private String storeFile(MultipartFile file) {
4845
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
4946

@@ -54,21 +51,36 @@ private String storeFile(MultipartFile file) {
5451
try {
5552
String storedFilename = UUID.randomUUID() + ".webp";
5653

57-
Path targetLocation = Paths.get(fileUploadConfig.getUploadDir()).resolve(storedFilename);
58-
5954
InputStream webpInputStream = ImageConverter.convertToWebP(file);
55+
byte[] fileBytes = webpInputStream.readAllBytes();
6056

61-
Files.copy(webpInputStream, targetLocation, StandardCopyOption.REPLACE_EXISTING);
57+
uploadToS3(storedFilename, fileBytes);
6258

63-
return fileUploadConfig.getBaseUrl() + "/api/upload/image/" + storedFilename;
59+
return s3Config.getBaseUrl() + "/" + storedFilename;
6460

6561
} catch (IOException ex) {
6662
log.error("Failed to store file: {}", originalFilename, ex);
6763
throw new FileStorageFailedException(originalFilename);
6864
}
6965
}
7066

71-
// Base64 인코딩된 이미지를 파일로 저장하고 URL을 반환
67+
private void uploadToS3(String filename, byte[] fileBytes) {
68+
try {
69+
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
70+
.bucket(s3Config.getBucketName())
71+
.key(filename)
72+
.contentType("image/webp")
73+
.build();
74+
75+
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes));
76+
log.info("File uploaded to S3: {}", filename);
77+
} catch (Exception ex) {
78+
log.error("Failed to upload file to S3: {}", filename, ex);
79+
throw new FileStorageFailedException(filename);
80+
}
81+
}
82+
83+
// Base64 인코딩된 이미지를 S3에 업로드하고 URL을 반환
7284
public String uploadBase64Image(String base64Data) {
7385
if (base64Data == null || base64Data.trim().isEmpty()) {
7486
return null;
@@ -83,34 +95,22 @@ public String uploadBase64Image(String base64Data) {
8395
byte[] imageBytes = Base64.getDecoder().decode(base64Image);
8496

8597
String storedFilename = UUID.randomUUID() + ".webp";
86-
Path targetLocation = Paths.get(fileUploadConfig.getUploadDir()).resolve(storedFilename);
8798

8899
InputStream imageInputStream = new ByteArrayInputStream(imageBytes);
89100
InputStream webpInputStream = ImageConverter.convertToWebP(imageInputStream);
101+
byte[] webpBytes = webpInputStream.readAllBytes();
90102

91-
Files.copy(webpInputStream, targetLocation, StandardCopyOption.REPLACE_EXISTING);
103+
uploadToS3(storedFilename, webpBytes);
92104

93-
return fileUploadConfig.getBaseUrl() + "/api/upload/image/" + storedFilename;
105+
return s3Config.getBaseUrl() + "/" + storedFilename;
94106

95107
} catch (Exception ex) {
96108
log.error("Failed to store base64 image", ex);
97109
throw new FileStorageFailedException("base64-image");
98110
}
99111
}
100112

101-
public Resource loadFileAsResource(String filename) {
102-
try {
103-
Path filePath = Paths.get(fileUploadConfig.getUploadDir()).resolve(filename).normalize();
104-
Resource resource = new UrlResource(filePath.toUri());
105-
106-
if (resource.exists()) {
107-
return resource;
108-
} else {
109-
throw new FileNotFoundException(filename);
110-
}
111-
} catch (MalformedURLException ex) {
112-
log.error("Failed to load file: {}", filename, ex);
113-
throw new FileNotFoundException(filename);
114-
}
113+
public String getImageUrl(String filename) {
114+
return s3Config.getBaseUrl() + "/" + filename;
115115
}
116116
}

runtracker/src/main/java/com/runtracker/global/config/FileUploadConfig.java

Lines changed: 0 additions & 27 deletions
This file was deleted.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.runtracker.global.config;
2+
3+
import lombok.Getter;
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
8+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
9+
import software.amazon.awssdk.regions.Region;
10+
import software.amazon.awssdk.services.s3.S3Client;
11+
12+
@Getter
13+
@Configuration
14+
public class S3Config {
15+
16+
@Value("${aws.access-key}")
17+
private String accessKey;
18+
19+
@Value("${aws.secret-key}")
20+
private String secretKey;
21+
22+
@Value("${aws.region}")
23+
private String region;
24+
25+
@Value("${aws.s3.bucket-name}")
26+
private String bucketName;
27+
28+
@Value("${aws.s3.base-url}")
29+
private String baseUrl;
30+
31+
@Bean
32+
public S3Client s3Client() {
33+
return S3Client.builder()
34+
.region(Region.of(region))
35+
.credentialsProvider(
36+
StaticCredentialsProvider.create(
37+
AwsBasicCredentials.create(accessKey, secretKey)
38+
)
39+
)
40+
.build();
41+
}
42+
}

runtracker/src/main/resources/application.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ google:
7676
maps:
7777
api-key: ${GOOGLE_MAP_API_KEY}
7878

79-
file:
80-
upload-dir: ${FILE_UPLOAD_DIR}
81-
base-url: ${SPRING_DOMAIN}
79+
aws:
80+
access-key: ${S3_ACCESS_KEY}
81+
secret-key: ${S3_SECRET_KEY}
82+
region: ${S3_REGION}
83+
s3:
84+
bucket-name: ${S3_BUCKET}
85+
base-url: ${S3_BASE_URL}

runtracker/src/test/java/com/runtracker/domain/upload/controller/UploadControllerTest.java

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
import com.runtracker.RunTrackerDocumentApiTester;
55
import com.runtracker.domain.upload.service.FileStorageService;
66
import org.junit.jupiter.api.Test;
7-
import org.springframework.core.io.ByteArrayResource;
8-
import org.springframework.core.io.Resource;
7+
import org.springframework.http.HttpHeaders;
98
import org.springframework.http.MediaType;
109
import org.springframework.mock.web.MockMultipartFile;
1110
import org.springframework.restdocs.payload.JsonFieldType;
@@ -20,8 +19,9 @@
2019
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
2120
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart;
2221
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
23-
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
22+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
2423
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
24+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
2525

2626
class UploadControllerTest extends RunTrackerDocumentApiTester {
2727

@@ -38,7 +38,7 @@ void uploadImage() throws Exception {
3838
"test image content".getBytes()
3939
);
4040

41-
String mockUrl = "http://localhost:8080/api/upload/image/550e8400-e29b-41d4-a716-446655440000.webp";
41+
String mockUrl = "https://test-bucket.s3.region.amazonaws.com/550e8400-e29b-41d4-a716-446655440000.webp";
4242
when(fileStorageService.uploadImage(any())).thenReturn(mockUrl);
4343

4444
// when & then
@@ -70,26 +70,21 @@ void uploadImage() throws Exception {
7070
void getImage() throws Exception {
7171
// given
7272
String filename = "550e8400-e29b-41d4-a716-446655440000.webp";
73-
byte[] imageBytes = "test image content".getBytes();
74-
Resource mockResource = new ByteArrayResource(imageBytes) {
75-
@Override
76-
public String getFilename() {
77-
return filename;
78-
}
79-
};
80-
when(fileStorageService.loadFileAsResource(anyString())).thenReturn(mockResource);
73+
String s3Url = "https://test-bucket.s3.region.amazonaws.com/" + filename;
74+
when(fileStorageService.getImageUrl(anyString())).thenReturn(s3Url);
8175

8276
// when & then
8377
this.mockMvc.perform(get("/api/upload/image/{filename}", filename))
84-
.andExpect(status().isOk())
85-
.andExpect(content().contentType(MediaType.parseMediaType("image/webp")))
78+
.andExpect(status().isMovedPermanently())
79+
.andExpect(header().string(HttpHeaders.LOCATION, s3Url))
8680
.andDo(document("upload-get-image",
8781
resource(
8882
ResourceSnippetParameters.builder()
8983
.tag("upload")
9084
.summary("업로드된 이미지 조회")
91-
.description("업로드된 이미지 파일을 조회합니다. " +
92-
"브라우저나 이미지 뷰어에서 직접 표시할 수 있도록 inline으로 제공됩니다. url에 그냥 검색해도 이미지 나옵니다.")
85+
.description("업로드된 이미지 파일을 S3에서 조회합니다. " +
86+
"301 Moved Permanently로 S3 URL로 리다이렉트됩니다. " +
87+
"클라이언트는 반환된 Location 헤더의 URL로 직접 접근할 수 있습니다.")
9388
.pathParameters(
9489
parameterWithName("filename").description("조회할 파일명 (예: uuid.webp)")
9590
)

0 commit comments

Comments
 (0)