Skip to content
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

파일 청크 업로드 비동기 도입 #279

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
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
18 changes: 12 additions & 6 deletions src/backend/file_server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,30 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

implementation(platform("software.amazon.awssdk:bom:2.27.21"))
implementation 'software.amazon.awssdk:s3'

implementation 'org.apache.tika:tika-core:2.8.0'

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'commons-fileupload:commons-fileupload:1.4'

//common-module
implementation project(":common_module")

// AWS SDK v2
implementation platform('software.amazon.awssdk:bom:2.17.106')
implementation 'software.amazon.awssdk:s3'
implementation 'software.amazon.awssdk:sts'
implementation 'software.amazon.awssdk:netty-nio-client'

// Spring AOP (비동기 처리 관련)
implementation 'org.springframework.boot:spring-boot-starter-aop'
}


tasks.named('test') {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.jootalkpia.file_server.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

@Bean(name = "fileUploadExecutor")
public Executor fileUploadExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("File-Upload-Executor-");
executor.setKeepAliveSeconds(60);
executor.setAllowCoreThreadTimeOut(true);
executor.initialize();
return executor;
}

// ✅ 병합 작업 전용 스레드 풀
@Bean(name = "fileMergeExecutor")
public Executor fileMergeExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 병합 작업은 비교적 적은 스레드로 처리
executor.setMaxPoolSize(10); // 최대 10개의 병합 작업 처리
executor.setQueueCapacity(50); // 병합 작업 대기열 설정
executor.setThreadNamePrefix("File-Merge-Executor-");
executor.setKeepAliveSeconds(30);
executor.setAllowCoreThreadTimeOut(true);

// ✅ 병합 작업 우선순위 높이기 (우선순위: 1이 가장 높음)
executor.setThreadPriority(Thread.MAX_PRIORITY);

executor.initialize();
return executor;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,17 @@
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.http.async.SdkAsyncHttpClient;
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.core.retry.RetryPolicy;
import software.amazon.awssdk.core.retry.backoff.FullJitterBackoffStrategy;

import java.net.URI;
import java.time.Duration;

@Configuration
public class S3Config {
Expand All @@ -20,13 +29,61 @@ public class S3Config {
@Value("${spring.cloud.aws.credentials.secret-key}")
private String secretKey;

@Value("${spring.cloud.aws.s3.bucket}")
private String bucketName;

/**
* 비동기 S3AsyncClient 설정
*/
@Bean
public S3AsyncClient s3AsyncClient() {
// NettyNioAsyncHttpClient 설정
SdkAsyncHttpClient httpClient = NettyNioAsyncHttpClient.builder()
.maxConcurrency(100) // 최대 동시 연결 수 제한
.maxPendingConnectionAcquires(5000) // 최대 대기 연결 수 제한
.connectionAcquisitionTimeout(Duration.ofSeconds(60)) // 연결 획득 대기 시간 연장
.connectionTimeout(Duration.ofSeconds(30)) // 연결 타임아웃
.readTimeout(Duration.ofSeconds(60)) // 데이터 읽기 타임아웃
.writeTimeout(Duration.ofSeconds(60)) // 데이터 쓰기 타임아웃
.build();

// Retry 정책 설정 (Full Jitter Backoff)
RetryPolicy retryPolicy = RetryPolicy.builder()
.backoffStrategy(FullJitterBackoffStrategy.builder()
.baseDelay(Duration.ofMillis(500)) // 최소 지연 시간
.maxBackoffTime(Duration.ofSeconds(10)) // 최대 지연 시간
.build())
.numRetries(3) // 최대 재시도 횟수
.build();

// S3AsyncClient 설정
return S3AsyncClient.builder()
.region(Region.of(region))
.endpointOverride(URI.create("https://s3." + region + ".amazonaws.com")) // S3 엔드포인트
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)
))
.httpClient(httpClient)
.overrideConfiguration(c -> c.retryPolicy(retryPolicy)) // Retry 정책 설정
.build();
}

/**
* 동기 S3Client 설정
*/
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(region))
.endpointOverride(URI.create("https://s3." + region + ".amazonaws.com")) // S3 엔드포인트
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)
))
.serviceConfiguration(S3Configuration.builder()
.checksumValidationEnabled(false) // Checksum 검증 비활성화 (선택)
.build()
)
.build();
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
import com.jootalkpia.file_server.utils.ValidationUtils;
import com.jootalkpia.passport.anotation.CurrentUser;
import com.jootalkpia.passport.component.UserInfo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.InputStreamResource;
Expand All @@ -23,6 +28,7 @@
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
Expand All @@ -45,12 +51,34 @@ public ResponseEntity<String> testEndpoint() {
return ResponseEntity.ok("Test successful");
}

// 시간 측정을 위한 Map 선언
public static final Map<Long, Long> UPLOAD_TIME_TRACKER = new ConcurrentHashMap<>();
public static final List<Long> RESPONSE_TIMES = Collections.synchronizedList(new ArrayList<>());

@GetMapping("/init-upload/{tempFileIdentifier}")
public ResponseEntity<Map<String, Object>> initFileUpload(@PathVariable String tempFileIdentifier) {
public ResponseEntity<Map<String, Object>> initFileUpload(@PathVariable String tempFileIdentifier, @RequestParam String mimeType) {
log.info("init-upload 요청 받음: {}", tempFileIdentifier);
return ResponseEntity.ok(Map.of("code", 200, "status", "complete"));

// ✅ 요청 시작 시간 기록
long startTime = System.currentTimeMillis();
log.info("✅ 요청 시작 시간 (Unix Timestamp): {}, tempFileIdentifier: {}", startTime / 1000, tempFileIdentifier);
log.info("✅ 요청 시작 시간 (UTC): {}", java.time.Instant.now());

// 초기화 처리
Long fileId = fileService.initiateMultipartUpload(tempFileIdentifier, mimeType);

// ✅ UPLOAD_TIME_TRACKER에 시작 시간 저장
UPLOAD_TIME_TRACKER.put(fileId, startTime);

// 초기화 완료 응답
Map<String, Object> response = new HashMap<>();
response.put("fileId", fileId);
response.put("status", "initialized");
return ResponseEntity.ok(response);
}



// @DeleteMapping("/fileId")
// public ResponseEntity<Void> deleteFile(@PathVariable Long fileId) {
// log.info("got deleteFile id: {}", fileId);
Expand All @@ -72,30 +100,31 @@ public ResponseEntity<Map<String, Object>> uploadThumbnail(@RequestParam Long fi
public ResponseEntity<?> uploadFileChunk(
@RequestParam("workspaceId") Long workspaceId,
@RequestParam("channelId") Long channelId,
@RequestParam("tempFileIdentifier") String tempFileIdentifier,
@RequestParam("fileId") Long fileId,
@RequestParam("totalChunks") Long totalChunks,
@RequestParam("totalSize") Long totalSize,
@RequestParam("chunkIndex") Long chunkIndex,
@RequestParam("mimeType") String mimeType,
@RequestPart("chunk") MultipartFile chunk) {

log.info("청크 업로드 요청: chunkIndex={}, totalChunks={}", chunkIndex, totalChunks);

ValidationUtils.validateWorkSpaceId(workspaceId);
ValidationUtils.validateChannelId(channelId);
ValidationUtils.validateFile(chunk);
ValidationUtils.validateFileId(tempFileIdentifier);
// ValidationUtils.validateFileId(fileId);
ValidationUtils.validateTotalChunksAndChunkIndex(totalChunks, chunkIndex);

// DTO로 변환
MultipartChunk multipartChunk = new MultipartChunk(chunkIndex, chunk);
UploadChunkRequestDto request = new UploadChunkRequestDto(
workspaceId, channelId, tempFileIdentifier, totalChunks, totalSize, multipartChunk
workspaceId, channelId, fileId, totalChunks, mimeType, multipartChunk
);

Object response = fileService.uploadFileChunk(request);
Object response = fileService.uploadChunk(request);
return ResponseEntity.ok(response);
}


@PostMapping("/small")
public ResponseEntity<UploadFileResponseDto> uploadFile(@ModelAttribute UploadFileRequestDto uploadFileRequest) {
log.info("got uploadFileRequest: {}", uploadFileRequest.getWorkspaceId());
Expand All @@ -107,48 +136,32 @@ public ResponseEntity<UploadFileResponseDto> uploadFile(@ModelAttribute UploadFi
return ResponseEntity.ok(response);
}


@PostMapping
public ResponseEntity<UploadFilesResponseDto> uploadFiles(@ModelAttribute UploadFilesRequestDto uploadFileRequest) {
log.info("got uploadFileRequest: {}", uploadFileRequest);
ValidationUtils.validateLengthOfFilesAndThumbnails(uploadFileRequest.getFiles().length, uploadFileRequest.getThumbnails().length);
ValidationUtils.validateWorkSpaceId(uploadFileRequest.getWorkspaceId());
ValidationUtils.validateChannelId(uploadFileRequest.getChannelId());
ValidationUtils.validateFiles(uploadFileRequest.getFiles());
ValidationUtils.validateFiles(uploadFileRequest.getThumbnails());

Long userId = 1L;

log.info("got uploadFileRequest: {}", uploadFileRequest.getFiles().length);
UploadFilesResponseDto response = fileService.uploadFiles(userId, uploadFileRequest);
return ResponseEntity.ok(response);
}

@GetMapping("/{fileId}")
public ResponseEntity<InputStreamResource> downloadFile(@PathVariable Long fileId) {
log.info("got downloadFile id: {}", fileId);
ValidationUtils.validateFileId(fileId);

ResponseInputStream<GetObjectResponse> s3InputStream = fileService.downloadFile(fileId);

// response 생성
long contentLength = s3InputStream.response().contentLength();

// Content-Type 가져오기 기 본값: application/octet-stream
String contentType = s3InputStream.response().contentType() != null
? s3InputStream.response().contentType()
: MediaType.APPLICATION_OCTET_STREAM_VALUE;

// 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(contentType));
headers.setContentLength(contentLength);
headers.setContentDispositionFormData("attachment", "file-" + fileId);

return ResponseEntity.ok()
.headers(headers)
.body(new InputStreamResource(s3InputStream));
}
// TODO: url 제공으로 수정
// @GetMapping("/{fileId}")
// public ResponseEntity<InputStreamResource> downloadFile(@PathVariable Long fileId) {
// log.info("got downloadFile id: {}", fileId);
// ValidationUtils.validateFileId(fileId);
//
// ResponseInputStream<GetObjectResponse> s3InputStream = fileService.downloadFile(fileId);
//
// // response 생성
// long contentLength = s3InputStream.response().contentLength();
//
// // Content-Type 가져오기 기본값: application/octet-stream
// String contentType = s3InputStream.response().contentType() != null
// ? s3InputStream.response().contentType()
// : MediaType.APPLICATION_OCTET_STREAM_VALUE;
//
// // 헤더 설정
// HttpHeaders headers = new HttpHeaders();
// headers.setContentType(MediaType.parseMediaType(contentType));
// headers.setContentLength(contentLength);
// headers.setContentDispositionFormData("attachment", "file-" + fileId);
//
// return ResponseEntity.ok()
// .headers(headers)
// .body(new InputStreamResource(s3InputStream));
// }

@PostMapping("/profile-image")
public ResponseEntity<ChangeProfileResponseDto> changeProfile(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
public class UploadChunkRequestDto {
private Long workspaceId;
private Long channelId;
private String tempFileIdentifier;
private Long fileId;
private Long totalChunks;
private Long chunkSize;
private String mimeType;
private MultipartChunk chunkInfo;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Files extends BaseTimeEntity{
@Table(name = "Files")
public class FilesEntity extends BaseTimeEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long fileId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public enum ErrorCode {
CHUNK_PROCESSING_FAILED("F50008", "청크 처리 중 오류가 발생했습니다."),
CHUNK_MERGING_FAILED("F50007", "청크 병합 중 오류가 발생했습니다."),
MIMETYPE_DETECTION_FAILED("F50009", "mimetype 감지에 실패했습니다."),
CHUNK_INITIALIZE_FAILED("F50010", "chunk 업로드 초기화가 되어있지 않습니다."),
CONTENT_TYPE_SETTING_FAILED("F50011", "Content-Type 설정에 실패하였습니다. "),
UNEXPECTED_ERROR("F50006", "예상치 못한 오류가 발생했습니다.");

private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.jootalkpia.file_server.repository;

import com.jootalkpia.file_server.entity.Files;
import com.jootalkpia.file_server.entity.FilesEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface FileRepository extends JpaRepository<Files, Long> {
public interface FileRepository extends JpaRepository<FilesEntity, Long> {

}
Loading