Skip to content

Commit 228b4ac

Browse files
authored
refactor: 영상 분석 파이프라인에서 S3 업로드 분리 (#63)
refactor: 영상 분석 파이프라인에서 S3 업로드 분리 (#63)
2 parents 8dd4103 + 3abee21 commit 228b4ac

8 files changed

Lines changed: 143 additions & 96 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.wisoft.prepair.prepair_api.external.storage;
2+
3+
final class FileExtensions {
4+
5+
private static final String DEFAULT = ".tmp";
6+
7+
private FileExtensions() {}
8+
9+
static String extract(String filename) {
10+
if (filename == null || filename.isBlank()) return DEFAULT;
11+
int idx = filename.lastIndexOf('.');
12+
if (idx < 0 || idx == filename.length() - 1) return DEFAULT;
13+
return filename.substring(idx);
14+
}
15+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package io.wisoft.prepair.prepair_api.external.storage;
2+
3+
import io.wisoft.prepair.prepair_api.common.exception.BusinessException;
4+
import io.wisoft.prepair.prepair_api.common.exception.ErrorCode;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.stereotype.Component;
7+
import org.springframework.web.multipart.MultipartFile;
8+
9+
import java.io.IOException;
10+
import java.nio.file.Files;
11+
import java.nio.file.Path;
12+
13+
@Slf4j
14+
@Component
15+
public class LocalFileStorage {
16+
17+
public Path save(MultipartFile file) {
18+
try {
19+
Path path = Files.createTempFile("video-", FileExtensions.extract(file.getOriginalFilename()));
20+
file.transferTo(path);
21+
return path;
22+
} catch (Exception e) {
23+
log.error("[로컬파일] 저장 실패 - filename: {}", file.getOriginalFilename(), e);
24+
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
25+
}
26+
}
27+
28+
public void delete(Path path) {
29+
if (path == null) return;
30+
try {
31+
Files.deleteIfExists(path);
32+
} catch (IOException e) {
33+
log.warn("[로컬파일] 삭제 실패 - path: {}", path, e);
34+
}
35+
}
36+
}

src/main/java/io/wisoft/prepair/prepair_api/external/storage/FileUploader.java renamed to src/main/java/io/wisoft/prepair/prepair_api/external/storage/S3FileStorage.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@
77
import lombok.RequiredArgsConstructor;
88
import lombok.extern.slf4j.Slf4j;
99
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.retry.annotation.Backoff;
11+
import org.springframework.retry.annotation.Retryable;
1012
import org.springframework.stereotype.Component;
13+
import software.amazon.awssdk.core.exception.SdkClientException;
1114
import software.amazon.awssdk.core.sync.RequestBody;
1215
import software.amazon.awssdk.services.s3.S3Client;
1316
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
17+
import software.amazon.awssdk.services.s3.model.S3Exception;
1418

1519
@Slf4j
1620
@Component
1721
@RequiredArgsConstructor
18-
public class FileUploader {
22+
public class S3FileStorage {
1923

2024
private final S3Client s3Client;
2125

@@ -25,8 +29,13 @@ public class FileUploader {
2529
@Value("${cloud.aws.s3.endpoint}")
2630
private String endpoint;
2731

28-
public String upload(Path videoPath, String contentType, String email) {
29-
String extension = getExtension(videoPath.getFileName().toString());
32+
@Retryable(
33+
value = { SdkClientException.class, S3Exception.class },
34+
maxAttempts = 3,
35+
backoff = @Backoff(delay = 1000, multiplier = 2)
36+
)
37+
public String save(Path videoPath, String contentType, String email) {
38+
String extension = FileExtensions.extract(videoPath.getFileName().toString());
3039
String key = "interview-video/" + email + "/" + LocalDate.now() + "/" + UUID.randomUUID() + extension;
3140

3241
s3Client.putObject(PutObjectRequest.builder()
@@ -39,11 +48,4 @@ public String upload(Path videoPath, String contentType, String email) {
3948

4049
return endpoint + "/" + bucket + "/" + key;
4150
}
42-
43-
private String getExtension(String filename) {
44-
if (filename == null || !filename.contains(".")) {
45-
return ".webm";
46-
}
47-
return filename.substring(filename.lastIndexOf("."));
48-
}
4951
}
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package io.wisoft.prepair.prepair_api.interview.answer.event;
22

3-
import java.nio.file.Path;
43
import java.util.UUID;
54

6-
public record AllAnalysisCompletedEvent(UUID answerId, boolean hasFailed, Path videoPath) {
7-
}
5+
public record AllAnalysisCompletedEvent(UUID answerId, boolean hasFailed) {
6+
}

src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedHandler.java

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@
2121
import org.springframework.scheduling.annotation.Async;
2222
import org.springframework.stereotype.Component;
2323

24-
import java.io.IOException;
25-
import java.nio.file.Files;
26-
import java.nio.file.Path;
2724
import java.util.ArrayList;
2825
import java.util.List;
2926
import java.util.Map;
@@ -48,7 +45,6 @@ public class AllAnalysisCompletedHandler {
4845
@EventListener
4946
public void handle(AllAnalysisCompletedEvent event) {
5047
UUID answerId = event.answerId();
51-
deleteTempFile(event.videoPath());
5248

5349
if (event.hasFailed()) {
5450
log.error("[종합평가] 분석 실패로 종합평가 생략 - answerId: {}", answerId);
@@ -225,15 +221,6 @@ private void failSession(UUID answerId, String message) {
225221
sseEmitterManager.complete(session.getId());
226222
}
227223

228-
private void deleteTempFile(Path videoPath) {
229-
if (videoPath == null) return;
230-
try {
231-
Files.deleteIfExists(videoPath);
232-
} catch (IOException e) {
233-
log.warn("[임시파일] 삭제 실패 - path: {}", videoPath, e);
234-
}
235-
}
236-
237224
private record AnalysisFeedbacks(
238225
InterviewFeedback stt,
239226
InterviewFeedback video
Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.wisoft.prepair.prepair_api.interview.answer.event;
22

3+
import io.wisoft.prepair.prepair_api.external.storage.LocalFileStorage;
34
import java.nio.file.Path;
45
import java.util.UUID;
56
import java.util.concurrent.ConcurrentHashMap;
@@ -17,41 +18,67 @@
1718
public class AnalysisCompletionTracker {
1819

1920
private final ApplicationEventPublisher eventPublisher;
21+
private final LocalFileStorage localFileStorage;
22+
23+
private static final int ANALYSIS_TASKS = 2;
2024
private static final int TOTAL_TASKS = 3;
2125

22-
private final ConcurrentMap<UUID, AtomicInteger> completionMap = new ConcurrentHashMap<>();
23-
private final ConcurrentMap<UUID, AtomicBoolean> failureMap = new ConcurrentHashMap<>();
24-
private final ConcurrentMap<UUID, Path> videoPathMap = new ConcurrentHashMap<>();
26+
private final ConcurrentMap<UUID, TrackingState> stateMap = new ConcurrentHashMap<>();
2527

2628
public void init(UUID answerId, Path videoPath) {
27-
completionMap.put(answerId, new AtomicInteger(0));
28-
failureMap.put(answerId, new AtomicBoolean(false));
29-
videoPathMap.put(answerId, videoPath);
29+
stateMap.put(answerId, new TrackingState(videoPath));
30+
}
31+
32+
public void completeAnalysis(UUID answerId) {
33+
finishAnalysis(answerId, false);
3034
}
3135

32-
public void complete(UUID answerId) {
33-
finish(answerId, false);
36+
public void failAnalysis(UUID answerId) {
37+
finishAnalysis(answerId, true);
3438
}
3539

36-
public void fail(UUID answerId) {
37-
finish(answerId, true);
40+
public void completeS3(UUID answerId) {
41+
finishTask(answerId);
3842
}
3943

40-
private void finish(UUID answerId, boolean failed) {
41-
AtomicInteger counter = completionMap.get(answerId);
42-
if (counter == null) return;
44+
public void failS3(UUID answerId) {
45+
finishTask(answerId);
46+
}
47+
48+
private void finishAnalysis(UUID answerId, boolean failed) {
49+
TrackingState state = stateMap.get(answerId);
50+
if (state == null) return;
51+
52+
if (failed) state.analysisFailed.set(true);
4353

44-
if (failed) {
45-
failureMap.get(answerId).set(true);
54+
int analysisCount = state.analysisCount.incrementAndGet();
55+
if (analysisCount == ANALYSIS_TASKS) {
56+
eventPublisher.publishEvent(
57+
new AllAnalysisCompletedEvent(answerId, state.analysisFailed.get())
58+
);
4659
}
60+
finishTask(answerId);
61+
}
62+
63+
private void finishTask(UUID answerId) {
64+
TrackingState state = stateMap.get(answerId);
65+
if (state == null) return;
66+
67+
int totalCount = state.totalCount.incrementAndGet();
68+
if (totalCount == TOTAL_TASKS) {
69+
stateMap.remove(answerId);
70+
localFileStorage.delete(state.videoPath);
71+
}
72+
}
4773

48-
int count = counter.incrementAndGet();
74+
private static final class TrackingState {
75+
final AtomicInteger analysisCount = new AtomicInteger(0);
76+
final AtomicInteger totalCount = new AtomicInteger(0);
77+
final AtomicBoolean analysisFailed = new AtomicBoolean(false);
78+
final Path videoPath;
4979

50-
if (count == TOTAL_TASKS) {
51-
boolean hasFailed = failureMap.remove(answerId).get();
52-
completionMap.remove(answerId);
53-
Path videoPath = videoPathMap.remove(answerId);
54-
eventPublisher.publishEvent(new AllAnalysisCompletedEvent(answerId, hasFailed, videoPath));
80+
TrackingState(Path videoPath) {
81+
this.videoPath = videoPath;
5582
}
5683
}
57-
}
84+
}

src/main/java/io/wisoft/prepair/prepair_api/interview/answer/service/VideoAnswerProcessor.java renamed to src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/VideoAnswerProcessor.java

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,48 @@
1-
package io.wisoft.prepair.prepair_api.interview.answer.service;
1+
package io.wisoft.prepair.prepair_api.interview.answer.event;
22

3-
import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackResult;
4-
import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackDetail;
5-
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
6-
import io.wisoft.prepair.prepair_api.interview.answer.entity.FeedbackType;
73
import io.wisoft.prepair.prepair_api.common.exception.BusinessException;
84
import io.wisoft.prepair.prepair_api.common.exception.ErrorCode;
5+
import io.wisoft.prepair.prepair_api.external.storage.S3FileStorage;
6+
import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackDetail;
7+
import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackResult;
8+
import io.wisoft.prepair.prepair_api.interview.answer.entity.FeedbackType;
9+
import io.wisoft.prepair.prepair_api.interview.answer.service.AnswerPersistenceService;
10+
import io.wisoft.prepair.prepair_api.interview.answer.service.FeedbackGenerator;
11+
import io.wisoft.prepair.prepair_api.interview.answer.service.SpeechToTextService;
12+
import io.wisoft.prepair.prepair_api.interview.answer.service.VideoFrameAnalysisService;
13+
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
914
import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository;
10-
import io.wisoft.prepair.prepair_api.interview.answer.event.AnalysisCompletionTracker;
11-
import io.wisoft.prepair.prepair_api.external.storage.FileUploader;
1215
import lombok.RequiredArgsConstructor;
1316
import lombok.extern.slf4j.Slf4j;
1417
import org.springframework.scheduling.annotation.Async;
15-
import org.springframework.stereotype.Service;
18+
import org.springframework.stereotype.Component;
1619

1720
import java.nio.file.Path;
1821
import java.util.UUID;
1922

2023
@Slf4j
21-
@Service
24+
@Component
2225
@RequiredArgsConstructor
2326
public class VideoAnswerProcessor {
2427

2528
private final AnswerPersistenceService answerPersistenceService;
2629
private final SpeechToTextService speechToTextService;
2730
private final VideoFrameAnalysisService videoAnalysisService;
2831
private final QuestionRepository questionRepository;
29-
private final FileUploader fileUploader;
32+
private final S3FileStorage s3FileStorage;
3033
private final FeedbackGenerator feedbackGenerator;
3134
private final AnalysisCompletionTracker completionTracker;
3235

3336
@Async("videoTaskExecutor")
3437
public void uploadToS3(final UUID answerId, final Path videoPath, final String contentType, final String email) {
3538
try {
36-
String mediaUrl = fileUploader.upload(videoPath, contentType, email);
39+
String mediaUrl = s3FileStorage.save(videoPath, contentType, email);
3740
answerPersistenceService.updateMediaUrl(answerId, mediaUrl);
3841
log.info("[VIDEO-S3] 업로드 완료 - answerId: {}", answerId);
39-
completionTracker.complete(answerId);
42+
completionTracker.completeS3(answerId);
4043
} catch (Exception e) {
4144
log.error("[VIDEO-S3] 업로드 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
42-
completionTracker.fail(answerId);
45+
completionTracker.failS3(answerId);
4346
}
4447
}
4548

@@ -58,10 +61,10 @@ public void analyzeSTT(final UUID answerId, final UUID questionId, final UUID me
5861
answerPersistenceService.saveVideoFeedback(answerId, result, detail, FeedbackType.STT);
5962

6063
log.info("[VIDEO-STT] 분석 완료 - answerId: {}", answerId);
61-
completionTracker.complete(answerId);
64+
completionTracker.completeAnalysis(answerId);
6265
} catch (Exception e) {
6366
log.error("[VIDEO-STT] 분석 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
64-
completionTracker.fail(answerId);
67+
completionTracker.failAnalysis(answerId);
6568
}
6669
}
6770

@@ -74,10 +77,10 @@ public void analyzeVideo(final UUID answerId, final Path videoPath) {
7477
answerPersistenceService.saveVideoFeedback(answerId, result, detail, FeedbackType.VIDEO);
7578

7679
log.info("[VIDEO-ANALYSIS] 분석 완료 - answerId: {}", answerId);
77-
completionTracker.complete(answerId);
80+
completionTracker.completeAnalysis(answerId);
7881
} catch (Exception e) {
7982
log.error("[VIDEO-ANALYSIS] 분석 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
80-
completionTracker.fail(answerId);
83+
completionTracker.failAnalysis(answerId);
8184
}
8285
}
8386
}

0 commit comments

Comments
 (0)