Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ dependencies {
testImplementation 'org.awaitility:awaitility:4.2.0'

// Etc
implementation platform('software.amazon.awssdk:bom:2.41.4')
implementation 'software.amazon.awssdk:s3'
implementation 'org.hibernate.validator:hibernate-validator'
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.782'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.example.solidconnection.mentor.repository.MentorRepository;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand All @@ -39,6 +40,7 @@
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

@Service
public class ChatService {
Expand Down Expand Up @@ -240,16 +242,19 @@ public void sendChatImage(ChatImageSendRequest chatImageSendRequest, long siteUs
ChatRoom chatRoom = chatRoomRepository.findById(roomId)
.orElseThrow(() -> new CustomException(INVALID_CHAT_ROOM_STATE));

ChatMessage chatMessage = new ChatMessage(
"",
senderId,
chatRoom
);
ChatMessage chatMessage = new ChatMessage("", senderId, chatRoom);

// 이미지 판별을 위한 확장자 리스트
List<String> imageExtensions = Arrays.asList("jpg", "jpeg", "png", "webp");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 상수로 빼는 건 어떤가요 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용하는 게 여기 밖에 없어서 상수로 빼는 게 나을까요??


for (String imageUrl : chatImageSendRequest.imageUrls()) {
String thumbnailUrl = generateThumbnailUrl(imageUrl);
String extension = StringUtils.getFilenameExtension(imageUrl);

ChatAttachment attachment = new ChatAttachment(true, imageUrl, thumbnailUrl, null);
boolean isImage = extension != null && imageExtensions.contains(extension.toLowerCase());

String thumbnailUrl = isImage ? generateThumbnailUrl(imageUrl) : null;

ChatAttachment attachment = new ChatAttachment(isImage, imageUrl, thumbnailUrl, null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thumbnailUrl이 null인 경우에 대해 방어 로직이 존재하나요?? 이번 pr 상에서는 안보여서...

(개인적으로 gif나 avif 이미지 파일 확장자도 잘 쓰인다고 생각해서 확인 한 번 해주시면 감사드리겠습니다!)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 생각에 해당 부분은 프론트에서 null일 경우 대체 이미지로 보여주는 게 맞는 것 같은데 어떻게 생각하시나요.......?
gif나 avif는 썸네일 생성이 되는지 알아보겠습니당 얘는 람다도 수정해야해서요!

chatMessage.addAttachment(attachment);
}

Expand All @@ -268,11 +273,9 @@ private String generateThumbnailUrl(String originalUrl) {

String thumbnailFileName = nameWithoutExt + "_thumb" + extension;

String thumbnailUrl = originalUrl.replace("chat/images/", "chat/thumbnails/")
return originalUrl.replace("chat/files/", "chat/thumbnails/")
.replace(fileName, thumbnailFileName);

return thumbnailUrl;

} catch (Exception e) {
return originalUrl;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import com.example.solidconnection.community.post.dto.PostUpdateRequest;
import com.example.solidconnection.community.post.dto.PostUpdateResponse;
import com.example.solidconnection.community.post.repository.PostRepository;
import com.example.solidconnection.s3.domain.ImgType;
import com.example.solidconnection.s3.domain.UploadPath;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.siteuser.domain.SiteUser;
Expand Down Expand Up @@ -88,7 +88,7 @@ private void savePostImages(List<MultipartFile> imageFile, Post post) {
if (imageFile.isEmpty()) {
return;
}
List<UploadedFileUrlResponse> uploadedFileUrlResponseList = s3Service.uploadFiles(imageFile, ImgType.COMMUNITY);
List<UploadedFileUrlResponse> uploadedFileUrlResponseList = s3Service.uploadFiles(imageFile, UploadPath.COMMUNITY);
for (UploadedFileUrlResponse uploadedFileUrlResponse : uploadedFileUrlResponseList) {
PostImage postImage = new PostImage(uploadedFileUrlResponse.fileUrl());
postImage.setPost(post);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import com.example.solidconnection.mentor.domain.MentorApplicationStatus;
import com.example.solidconnection.mentor.dto.MentorApplicationRequest;
import com.example.solidconnection.mentor.repository.MentorApplicationRepository;
import com.example.solidconnection.s3.domain.ImgType;
import com.example.solidconnection.s3.domain.UploadPath;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.siteuser.domain.SiteUser;
Expand Down Expand Up @@ -45,7 +45,7 @@ public void submitMentorApplication(
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
Term term = termRepository.findByName(mentorApplicationRequest.term())
.orElseThrow(() -> new CustomException(TERM_NOT_FOUND));
UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(file, ImgType.MENTOR_PROOF);
UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(file, UploadPath.MENTOR_PROOF);
MentorApplication mentorApplication = new MentorApplication(
siteUser.getId(),
mentorApplicationRequest.country(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import com.example.solidconnection.news.dto.NewsCreateRequest;
import com.example.solidconnection.news.dto.NewsUpdateRequest;
import com.example.solidconnection.news.repository.NewsRepository;
import com.example.solidconnection.s3.domain.ImgType;
import com.example.solidconnection.s3.domain.UploadPath;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.siteuser.domain.Role;
Expand Down Expand Up @@ -41,7 +41,7 @@ public NewsCommandResponse createNews(long siteUserId, NewsCreateRequest newsCre

private String getImageUrl(MultipartFile imageFile) {
if (imageFile != null && !imageFile.isEmpty()) {
UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS);
UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, UploadPath.NEWS);
return uploadedFile.fileUrl();
}
return newsProperties.defaultThumbnailUrl();
Expand Down Expand Up @@ -73,7 +73,7 @@ private void updateThumbnail(News news, MultipartFile imageFile, Boolean resetTo
deleteCustomImage(news.getThumbnailUrl());
news.updateThumbnailUrl(newsProperties.defaultThumbnailUrl());
} else if (imageFile != null && !imageFile.isEmpty()) {
UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS);
UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, UploadPath.NEWS);
deleteCustomImage(news.getThumbnailUrl());
news.updateThumbnailUrl(uploadedFile.fileUrl());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.example.solidconnection.s3.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;

@Configuration
public class AmazonS3Config {
Expand All @@ -21,12 +21,12 @@ public class AmazonS3Config {
private String region;

@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder
.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(credentials))
public S3Client s3Client() {
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);

return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.example.solidconnection.s3.controller;

import com.example.solidconnection.common.resolver.AuthorizedUser;
import com.example.solidconnection.s3.domain.ImgType;
import com.example.solidconnection.s3.domain.UploadPath;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.dto.urlPrefixResponse;
import com.example.solidconnection.s3.service.S3Service;
Expand Down Expand Up @@ -39,7 +39,7 @@ public class S3Controller {
public ResponseEntity<UploadedFileUrlResponse> uploadPreProfileImage(
@RequestParam("file") MultipartFile imageFile
) {
UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.PROFILE);
UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, UploadPath.PROFILE);
return ResponseEntity.ok(profileImageUrl);
}

Expand All @@ -48,7 +48,7 @@ public ResponseEntity<UploadedFileUrlResponse> uploadPostProfileImage(
@AuthorizedUser long siteUserId,
@RequestParam("file") MultipartFile imageFile
) {
UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.PROFILE);
UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, UploadPath.PROFILE);
s3Service.deleteExProfile(siteUserId);
return ResponseEntity.ok(profileImageUrl);
}
Expand All @@ -57,23 +57,23 @@ public ResponseEntity<UploadedFileUrlResponse> uploadPostProfileImage(
public ResponseEntity<UploadedFileUrlResponse> uploadGpaImage(
@RequestParam("file") MultipartFile imageFile
) {
UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.GPA);
UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, UploadPath.GPA);
return ResponseEntity.ok(profileImageUrl);
}

@PostMapping("/language-test")
public ResponseEntity<UploadedFileUrlResponse> uploadLanguageImage(
@RequestParam("file") MultipartFile imageFile
) {
UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.LANGUAGE_TEST);
UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, UploadPath.LANGUAGE_TEST);
return ResponseEntity.ok(profileImageUrl);
}

@PostMapping("/chat")
public ResponseEntity<List<UploadedFileUrlResponse>> uploadChatImage(
@RequestParam("files") List<MultipartFile> imageFiles
public ResponseEntity<List<UploadedFileUrlResponse>> uploadChatFile(
@RequestParam("files") List<MultipartFile> files
) {
List<UploadedFileUrlResponse> chatImageUrls = s3Service.uploadFiles(imageFiles, ImgType.CHAT);
List<UploadedFileUrlResponse> chatImageUrls = s3Service.uploadFiles(files, UploadPath.CHAT);
return ResponseEntity.ok(chatImageUrls);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@
import lombok.Getter;

@Getter
public enum ImgType {
public enum UploadPath {
PROFILE("profile"),
GPA("gpa"),
LANGUAGE_TEST("language"),
COMMUNITY("community"),
NEWS("news"),
CHAT("chat"),
CHAT("chat/files"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미 chat 을 통해 업로드된 파일이 있나요 ? 그렇다면 관련해서 데이터 마이그레이션이 필요한 것 같습니다 !

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s3에 파일이 없는 걸 확인해서 바로 수정했습니다!

MENTOR_PROOF("mentor-proof"),
;

private final String type;

ImgType(String type) {
UploadPath(String type) {
this.type = type;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,48 @@
import static com.example.solidconnection.common.exception.ErrorCode.S3_CLIENT_EXCEPTION;
import static com.example.solidconnection.common.exception.ErrorCode.S3_SERVICE_EXCEPTION;

import com.amazonaws.AmazonServiceException;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.example.solidconnection.common.exception.CustomException;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.exception.SdkException;
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;

@Component
@EnableAsync
@Slf4j
@RequiredArgsConstructor
public class FileUploadService {

private final AmazonS3Client amazonS3;

public FileUploadService(AmazonS3Client amazonS3) {
this.amazonS3 = amazonS3;
}
private final S3Client s3Client;

@Async
public void uploadFile(String bucket, String fileName, MultipartFile multipartFile) {
// 메타데이터 생성
String contentType = multipartFile.getContentType();
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(contentType);
metadata.setContentLength(multipartFile.getSize());

try {
amazonS3.putObject(new PutObjectRequest(bucket, fileName, multipartFile.getInputStream(), metadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
log.info("이미지 업로드 정상적 완료 thread: {}", Thread.currentThread().getName());
} catch (AmazonServiceException e) {
log.error("이미지 업로드 중 s3 서비스 예외 발생 : {}", e.getMessage());
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.contentType(multipartFile.getContentType())
.contentLength(multipartFile.getSize())
.build();

s3Client.putObject(putObjectRequest,
RequestBody.fromInputStream(multipartFile.getInputStream(), multipartFile.getSize()));

Comment on lines 26 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "FileUploadService.java" | head -5

Repository: solid-connection/solid-connect-server

Length of output: 158


🏁 Script executed:

cat -n ./src/main/java/com/example/solidconnection/s3/service/FileUploadService.java

Repository: solid-connection/solid-connect-server

Length of output: 2581


🌐 Web query:

Spring @async MultipartFile lifecycle issue temporary files

💡 Result:

Cause

  • Spring's MultipartFile stores contents in memory or a temporary disk file that is cleared at end of HTTP request processing — so an @Async task running after the request completes can see the temp file removed and get FileNotFound/NoSuchFile errors. [1][2]

Fixes (pick one)

  1. Persist the upload before returning from the controller
    • Save to disk: multipartFile.transferTo(targetFile). This moves or copies the temp file into a permanent location. Call this inside the request thread before calling the @Async method. [3]
  2. Read into memory and pass the bytes/stream to the async job
    • byte[] data = multipartFile.getBytes(); or copy the InputStream to a byte[]/ByteBuffer and hand that to the background task (or create a new Multipart-like wrapper). This avoids relying on request-scoped temp storage. [2]
  3. Store in external storage and pass a reference
    • Upload to S3/DB/blob store in the request, then pass the object key/URL to the async worker.
  4. Create your own persistent MultipartFile copy
    • Write the file to a temp/persistent File or create a new MockMultipartFile from bytes and pass that to the async method.

Minimal example (save to disk then async):

  • In controller (request thread):
    • File f = Files.createTempFile("upload-", multipartFile.getOriginalFilename()).toFile();
    • multipartFile.transferTo(f);
    • myAsyncService.processFileAsync(f.getAbsolutePath());
  • In async service: open the file path and process it.

References

  • Explanation and StackOverflow discussion of the problem and recommended copying before async processing. [1]
  • MultipartFile Javadoc noting temporary storage cleared at end of request. [2]
  • transferTo() behavior (move/copy to destination). [3]

🏁 Script executed:

# Check if there are any controllers or callers of this uploadFile method
rg "uploadFile" --type java -B 2 -A 2

Repository: solid-connection/solid-connect-server

Length of output: 28961


🏁 Script executed:

cat -n ./src/main/java/com/example/solidconnection/s3/service/S3Service.java

Repository: solid-connection/solid-connect-server

Length of output: 6579


🏁 Script executed:

# Check if there are any existing mitigations like reading bytes before async
rg "multipartFile\.getBytes|multipartFile\.transferTo" --type java -A 3 -B 3

Repository: solid-connection/solid-connect-server

Length of output: 63


🏁 Script executed:

# Check S3Service.uploadFile to confirm the direct call to async method
sed -n '55,71p' ./src/main/java/com/example/solidconnection/s3/service/S3Service.java

Repository: solid-connection/solid-connect-server

Length of output: 999


@async 메서드에서 MultipartFile 수명 이슈 반드시 확인 필요

HTTP 요청이 종료되면 임시 파일이 정리되어 비동기 업로드가 실패하거나 빈 파일이 될 수 있습니다.

권장 해결 방법:

  1. 콘텐츠를 미리 복사하여 전달
    요청 스레드 내에서 byte[]로 읽어 비동기 메서드에 넘기기

  2. 동기 처리로 전환
    @Async 제거하고 동기식으로 처리하기

🛠️ 동기 처리로 전환하는 최소 변경 예시
-    `@Async`
     public void uploadFile(String bucket, String fileName, MultipartFile multipartFile) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Async
public void uploadFile(String bucket, String fileName, MultipartFile multipartFile) {
// 메타데이터 생성
String contentType = multipartFile.getContentType();
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(contentType);
metadata.setContentLength(multipartFile.getSize());
try {
amazonS3.putObject(new PutObjectRequest(bucket, fileName, multipartFile.getInputStream(), metadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
log.info("이미지 업로드 정상적 완료 thread: {}", Thread.currentThread().getName());
} catch (AmazonServiceException e) {
log.error("이미지 업로드 중 s3 서비스 예외 발생 : {}", e.getMessage());
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.contentType(multipartFile.getContentType())
.contentLength(multipartFile.getSize())
.build();
s3Client.putObject(putObjectRequest,
RequestBody.fromInputStream(multipartFile.getInputStream(), multipartFile.getSize()));
public void uploadFile(String bucket, String fileName, MultipartFile multipartFile) {
try {
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.contentType(multipartFile.getContentType())
.contentLength(multipartFile.getSize())
.build();
s3Client.putObject(putObjectRequest,
RequestBody.fromInputStream(multipartFile.getInputStream(), multipartFile.getSize()));
🤖 Prompt for AI Agents
In `@src/main/java/com/example/solidconnection/s3/service/FileUploadService.java`
around lines 26 - 38, The uploadFile method in FileUploadService is annotated
with `@Async` and currently reads directly from MultipartFile, which can be
cleaned up when the HTTP request completes; fix by either (A) copying the file
content inside the request thread and passing immutable data into the async
method (e.g., read multipartFile.getBytes() or fully drain InputStream and pass
a byte[] plus contentType/size/fileName to uploadFile) or (B) remove `@Async` and
perform the upload synchronously; update the uploadFile signature and all
callers accordingly, and ensure s3Client.putObject is called with
RequestBody.fromBytes/fromInputStream using the pre-copied data if you choose
option A.

log.info("파일 업로드 정상 완료 thread: {}", Thread.currentThread().getName());
} catch (S3Exception e) {
String errorMessage = (e.awsErrorDetails() != null)
? e.awsErrorDetails().errorMessage()
: e.getMessage();
log.error("S3 서비스 예외 발생 : {}", errorMessage);
throw new CustomException(S3_SERVICE_EXCEPTION);
} catch (SdkClientException | IOException e) {
log.error("이미지 업로드 중 s3 클라이언트 예외 발생 : {}", e.getMessage());
} catch (SdkException | IOException e) {
log.error("S3 클라이언트 또는 IO 예외 발생 : {}", e.getMessage());
throw new CustomException(S3_CLIENT_EXCEPTION);
}
}
Expand Down
Loading
Loading