Skip to content

Commit 89d0a53

Browse files
authored
[Feat]: S3 이미지 업로드 구현 (#160)
* [Feat]: 이미지 파일 접근 허용 * [Rename]: LocalFileService * [Feat]: S3FileService * [Feat]: s3 관련 main.tf 설정 * [Test]: S3FileServiceTest
1 parent fde9c6e commit 89d0a53

File tree

16 files changed

+434
-19
lines changed

16 files changed

+434
-19
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ src/main/generated/
4343
k6-tests/results
4444
*.pem
4545
*.key
46+
uploads/
4647

4748
### env ###
4849
.env

build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ dependencies {
3434
implementation("org.springframework.boot:spring-boot-starter-webflux")
3535
implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
3636

37+
// AWS S3
38+
implementation(platform("software.amazon.awssdk:bom:2.33.6"))
39+
implementation("software.amazon.awssdk:s3")
40+
// implementation("io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.1") // S3Template 사용 시
3741

3842
// 스프링부트 추가 기능
3943
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")

src/main/java/com/backend/domain/member/service/MemberService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import com.backend.domain.member.dto.MemberInfoResponseDto;
1010
import com.backend.domain.member.entity.Member;
1111
import com.backend.domain.member.repository.MemberRepository;
12-
import com.backend.domain.product.service.FileService;
12+
import com.backend.global.file.service.LocalFileService;
1313
import com.backend.global.exception.ServiceException;
1414
import com.backend.global.response.RsData;
1515
import com.backend.global.security.JwtUtil;
@@ -31,7 +31,7 @@ public class MemberService {
3131
private final PasswordEncoder passwordEncoder;
3232
private final JwtUtil jwtUtil;
3333
private final RedisUtil redisUtil;
34-
private final FileService fileService;
34+
private final LocalFileService fileService;
3535

3636
public RsData<MemberSignUpResponseDto> signup(MemberSignUpRequestDto memberSignUpRequestDto) {
3737
checkEmailDuplication(memberSignUpRequestDto.email());

src/main/java/com/backend/domain/product/service/ProductImageService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.backend.domain.product.entity.ProductImage;
55
import com.backend.domain.product.exception.ProductException;
66
import com.backend.domain.product.repository.ProductImageRepository;
7+
import com.backend.global.file.service.LocalFileService;
78
import lombok.RequiredArgsConstructor;
89
import org.springframework.stereotype.Service;
910
import org.springframework.web.multipart.MultipartFile;
@@ -14,7 +15,7 @@
1415
@Service
1516
@RequiredArgsConstructor
1617
public class ProductImageService {
17-
private final FileService fileService;
18+
private final LocalFileService fileService;
1819
private final ProductImageRepository productImageRepository;
1920

2021
// ======================================= public methods ======================================= //
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.backend.global.aws.s3;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.context.annotation.Profile;
6+
import software.amazon.awssdk.regions.Region;
7+
import software.amazon.awssdk.services.s3.S3Client;
8+
9+
@Configuration
10+
@Profile("prod")
11+
public class AwsS3Config {
12+
@Bean
13+
public S3Client s3Client() {
14+
return S3Client.builder()
15+
.region(Region.AP_NORTHEAST_2)
16+
// credentials 지정 안 함 → EC2 IAM Role 자동 사용
17+
.build();
18+
}
19+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.backend.global.file.service;
2+
3+
import org.springframework.web.multipart.MultipartFile;
4+
5+
public interface FileService {
6+
String uploadFile(MultipartFile file, String directory);
7+
void deleteFile(String fileUrl);
8+
9+
default String getFileExtension(String filename) {
10+
if (filename == null || !filename.contains(".")) {
11+
return "";
12+
}
13+
return filename.substring(filename.lastIndexOf("."));
14+
}
15+
}

src/main/java/com/backend/domain/product/service/FileService.java renamed to src/main/java/com/backend/global/file/service/LocalFileService.java

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
package com.backend.domain.product.service;
1+
package com.backend.global.file.service;
22

33
import com.backend.domain.product.exception.ProductException;
44
import lombok.extern.slf4j.Slf4j;
55
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.context.annotation.Profile;
67
import org.springframework.stereotype.Service;
78
import org.springframework.web.multipart.MultipartFile;
89

@@ -14,7 +15,8 @@
1415

1516
@Slf4j
1617
@Service
17-
public class FileService {
18+
@Profile("!prod")
19+
public class LocalFileService implements FileService {
1820
@Value("${file.upload.path}")
1921
private String uploadPath;
2022

@@ -49,13 +51,6 @@ public String uploadFile(MultipartFile file, String directory) {
4951
}
5052
}
5153

52-
private String getFileExtension(String filename) {
53-
if (filename == null || !filename.contains(".")) {
54-
return "";
55-
}
56-
return filename.substring(filename.lastIndexOf("."));
57-
}
58-
5954
public void deleteFile(String fileUrl) {
6055
try {
6156
if (!fileUrl.startsWith(baseUrl)) {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.backend.global.file.service;
2+
3+
import com.backend.domain.product.exception.ProductException;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.context.annotation.Profile;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.web.multipart.MultipartFile;
10+
import software.amazon.awssdk.core.sync.RequestBody;
11+
import software.amazon.awssdk.services.s3.S3Client;
12+
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
13+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
14+
15+
import java.util.HashMap;
16+
import java.util.Map;
17+
import java.util.UUID;
18+
19+
@Slf4j
20+
@Service
21+
@Profile("prod")
22+
@RequiredArgsConstructor
23+
public class S3FileService implements FileService {
24+
private final S3Client s3Client;
25+
26+
@Value("${file.upload.s3.bucket}")
27+
private String bucketName;
28+
29+
@Value("${file.upload.s3.base-url}")
30+
private String baseUrl;
31+
32+
@Override
33+
public String uploadFile(MultipartFile file, String directory) {
34+
try {
35+
// 고유한 파일명 생성
36+
String key = directory + "/" + UUID.randomUUID() + getFileExtension(file.getOriginalFilename());
37+
38+
// S3 키 생성
39+
Map<String, String> metadata = new HashMap<>();
40+
metadata.put("original-filename", file.getOriginalFilename());
41+
42+
// S3 업로드
43+
PutObjectRequest request = PutObjectRequest.builder()
44+
.bucket(bucketName)
45+
.key(key)
46+
.contentType(file.getContentType())
47+
.contentLength(file.getSize())
48+
.metadata(metadata)
49+
// .acl(ObjectCannedACL.PUBLIC_READ) // 공개 읽기 권한 -> 버킷 정책으로 처리 (Terraform)
50+
.build();
51+
52+
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
53+
54+
String fileUrl = baseUrl + "/" + key;
55+
log.info("S3 파일 업로드 성공: {}", fileUrl);
56+
57+
return fileUrl;
58+
} catch (Exception e) {
59+
log.error("S3 파일 업로드 실패: {}", file.getOriginalFilename(), e);
60+
throw ProductException.fileUploadFailed();
61+
}
62+
}
63+
64+
@Override
65+
public void deleteFile(String fileUrl) {
66+
try {
67+
// URL에서 S3 키 추출
68+
String key = fileUrl.replace(baseUrl + "/", "");
69+
70+
DeleteObjectRequest request = DeleteObjectRequest.builder()
71+
.bucket(bucketName)
72+
.key(key)
73+
.build();
74+
75+
s3Client.deleteObject(request);
76+
log.info("S3 파일 삭제 성공: {}", fileUrl);
77+
} catch (Exception e) {
78+
log.error("S3 파일 삭제 실패: {}", fileUrl, e);
79+
}
80+
}
81+
}

src/main/java/com/backend/global/security/SecurityConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
4949
"/api/*/products", "/api/*/products/{productId:\\d+}", "/api/*/products/es",
5050
"/api/*/products/members/{memberId:\\d+}", "/api/*/products/es/members/{memberId:\\d+}",
5151
"/api/v1/members/{memberId:\\d+}").permitAll()
52+
.requestMatchers("/uploads/**").permitAll()
5253
.requestMatchers("/api/*/test-data/**").permitAll()
5354

5455
.anyRequest().authenticated()

src/main/resources/application-prod.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ file:
3131
upload:
3232
path: /data
3333
base-url: https://api.bid-market.shop/uploads
34+
s3:
35+
bucket: team12-bid-market-bucket
36+
base-url: https://team12-bid-market-bucket.s3.ap-northeast-2.amazonaws.com
3437
testdata:
3538
generation:
3639
enabled: false

0 commit comments

Comments
 (0)