-
Notifications
You must be signed in to change notification settings - Fork 0
[Feature] S3 이미지 업로드 기능 구현 #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f6ca232
834f088
f295b09
cc51392
5ea3a92
62b7ed9
25243b4
cc63434
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| package com.daramg.server.common.application; | ||
|
|
||
| import com.daramg.server.common.exception.BusinessException; | ||
| import com.daramg.server.common.exception.ImageErrorStatus; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.web.multipart.MultipartFile; | ||
| import software.amazon.awssdk.core.sync.RequestBody; | ||
| import software.amazon.awssdk.services.s3.S3Client; | ||
| import software.amazon.awssdk.services.s3.model.PutObjectRequest; | ||
| import software.amazon.awssdk.services.s3.model.S3Exception; | ||
|
|
||
| import java.io.IOException; | ||
| import java.io.InputStream; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class S3ImageService { | ||
|
|
||
| private static final java.util.Set<String> ALLOWED_CONTENT_TYPES = java.util.Set.of( | ||
| "image/jpeg", "image/jpg", "image/png", "image/gif" | ||
| ); | ||
| private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB | ||
| private static final String S3_URL_FORMAT = "https://%s.s3.%s.amazonaws.com/%s"; | ||
|
|
||
| private final S3Client s3Client; | ||
|
|
||
| @Value("${aws.s3.bucket}") | ||
| private String bucketName; | ||
|
|
||
| @Value("${aws.region.static:ap-northeast-2}") | ||
| private String region; | ||
|
|
||
| public List<String> uploadImages(List<MultipartFile> images) { | ||
| return images.stream() | ||
| .map(this::uploadImage) | ||
| .toList(); | ||
| } | ||
|
|
||
| public String uploadImage(MultipartFile file) { | ||
| validateFile(file); | ||
|
|
||
| try { | ||
| String fileName = generateFileName(file.getOriginalFilename()); | ||
| String key = "images/" + fileName; | ||
|
|
||
| PutObjectRequest putObjectRequest = PutObjectRequest.builder() | ||
| .bucket(bucketName) | ||
| .key(key) | ||
| .contentType(file.getContentType()) | ||
| .contentLength(file.getSize()) | ||
| .build(); | ||
|
|
||
| try (InputStream inputStream = file.getInputStream()) { | ||
| s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); | ||
| } | ||
|
|
||
| String imageUrl = String.format(S3_URL_FORMAT, bucketName, region, key); | ||
| log.info("Image uploaded successfully: {}", imageUrl); | ||
| return imageUrl; | ||
|
|
||
| } catch (S3Exception e) { | ||
| log.error("Failed to upload image to S3", e); | ||
| throw new BusinessException(ImageErrorStatus.FILE_UPLOAD_FAILED); | ||
| } catch (IOException e) { | ||
| log.error("Failed to read file input stream", e); | ||
| throw new BusinessException(ImageErrorStatus.FILE_UPLOAD_FAILED); | ||
| } | ||
| } | ||
|
|
||
| private void validateFile(MultipartFile file) { | ||
| if (file == null || file.isEmpty()) { | ||
| throw new BusinessException(ImageErrorStatus.EMPTY_FILE); | ||
| } | ||
|
|
||
| if (file.getSize() > MAX_FILE_SIZE) { | ||
| throw new BusinessException(ImageErrorStatus.FILE_TOO_LARGE); | ||
| } | ||
|
|
||
| String contentType = file.getContentType(); | ||
| if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType.toLowerCase())) { | ||
| throw new BusinessException(ImageErrorStatus.INVALID_FILE_TYPE); | ||
| } | ||
| } | ||
|
|
||
| private String generateFileName(String originalFilename) { | ||
| String extension = ""; | ||
| if (originalFilename != null && originalFilename.contains(".")) { | ||
| extension = originalFilename.substring(originalFilename.lastIndexOf(".")); | ||
| } | ||
| return UUID.randomUUID() + extension; | ||
| } | ||
|
Comment on lines
+91
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 확장자 추출 로직은 private String generateFileName(String originalFilename) {
String extension = "";
if (originalFilename != null) {
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < originalFilename.length() - 1) {
extension = originalFilename.substring(dotIndex);
}
}
return UUID.randomUUID().toString() + extension;
} |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,12 +2,17 @@ | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import com.daramg.server.auth.resolver.AuthUserResolver; | ||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||
| import org.springframework.beans.factory.annotation.Value; | ||||||||||||||||||||||||||||||
| import org.springframework.context.annotation.Bean; | ||||||||||||||||||||||||||||||
| import org.springframework.context.annotation.Configuration; | ||||||||||||||||||||||||||||||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||||||||||||||||||||||||||||||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||||||||||||||||||||||||||||||
| import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||||||||||||||||||||||||||||||
| import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | ||||||||||||||||||||||||||||||
| import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; | ||||||||||||||||||||||||||||||
| import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; | ||||||||||||||||||||||||||||||
| import software.amazon.awssdk.regions.Region; | ||||||||||||||||||||||||||||||
| import software.amazon.awssdk.services.s3.S3Client; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -17,11 +22,29 @@ public class AppConfig implements WebMvcConfigurer { | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private final AuthUserResolver authUserResolver; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @Value("${aws.credentials.access-key}") | ||||||||||||||||||||||||||||||
| private String accessKey; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @Value("${aws.credentials.secret-key}") | ||||||||||||||||||||||||||||||
| private String secretKey; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @Value("${aws.region.static:ap-northeast-2}") | ||||||||||||||||||||||||||||||
| private String region; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @Bean | ||||||||||||||||||||||||||||||
| public PasswordEncoder passwordEncoder() { | ||||||||||||||||||||||||||||||
| return new BCryptPasswordEncoder(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @Bean | ||||||||||||||||||||||||||||||
| public S3Client s3Client() { | ||||||||||||||||||||||||||||||
| AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey); | ||||||||||||||||||||||||||||||
| return S3Client.builder() | ||||||||||||||||||||||||||||||
| .region(Region.of(region)) | ||||||||||||||||||||||||||||||
| .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) | ||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+40
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||
| public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { | ||||||||||||||||||||||||||||||
| resolvers.add(authUserResolver); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package com.daramg.server.common.dto | ||
|
|
||
| data class ImageUploadResponseDto( | ||
| val imageUrls: List<String> | ||
| ) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재 S3 객체 키를
images/{UUID}.{확장자}형식으로 생성하고 있습니다. 파일을 사용자별로 관리하고, 추후 사용자별 접근 정책을 적용하기 용이하도록 키 경로에 사용자 ID를 포함하는 것을 고려해볼 수 있습니다. 예를 들어,images/{userId}/{UUID}.{확장자}와 같은 구조를 사용하면 좋습니다. 이를 위해uploadImage와uploadImages메소드가User객체나userId를 파라미터로 받도록 수정이 필요합니다.