Skip to content

Commit 08fe948

Browse files
authored
feat: 영상 분석 비동기 파이프라인 최적화
feat: 영상 분석 비동기 파이프라인 최적화
2 parents e766df0 + 2156b5a commit 08fe948

9 files changed

Lines changed: 155 additions & 104 deletions

File tree

src/main/java/io/wisoft/prepair/prepair_api/entity/InterviewAnswer.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,12 @@ public InterviewAnswer(
5454
this.answerType = answerType;
5555
this.mediaUrl = mediaUrl;
5656
}
57+
58+
public void updateAnswer(final String answer) {
59+
this.answer = answer;
60+
}
61+
62+
public void updateMediaUrl(final String mediaUrl) {
63+
this.mediaUrl = mediaUrl;
64+
}
5765
}

src/main/java/io/wisoft/prepair/prepair_api/global/exception/ErrorCode.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ public enum ErrorCode {
2626
// Question
2727
QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다."),
2828

29+
// Answer
30+
ANSWER_NOT_FOUND(HttpStatus.NOT_FOUND, "답변을 찾을 수 없습니다."),
31+
2932
// Crawling
3033
CRAWLING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "채용공고 크롤링에 실패했습니다."),
3134

src/main/java/io/wisoft/prepair/prepair_api/service/answer/AnswerPersistService.java

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,28 +62,36 @@ public InterviewFeedback saveAnswerAndFeedback(
6262
}
6363

6464
@Transactional
65-
public void saveVideoAnalysisFeedback(
66-
final UUID questionId, final UUID memberId, final String answer,
67-
final FeedbackResult sttResult, final FeedbackDetail sttDetail,
68-
final String mediaUrl, final FeedbackResult videoResult,
69-
final FeedbackDetail videoDetail
70-
) {
65+
public InterviewAnswer createVideoAnswer(final UUID questionId, final UUID memberId) {
7166
InterviewQuestion question = getQuestion(questionId, memberId);
7267
question.updateStatus(QuestionStatus.ANSWERED);
7368

74-
InterviewAnswer interviewAnswer = answerRepository.save(
75-
new InterviewAnswer(question, answer, AnswerType.VIDEO, mediaUrl));
69+
return answerRepository.save(
70+
new InterviewAnswer(question, "", AnswerType.VIDEO, null));
71+
}
72+
73+
@Transactional
74+
public void updateMediaUrl(final UUID answerId, final String mediaUrl) {
75+
InterviewAnswer interviewAnswer = answerRepository.findById(answerId)
76+
.orElseThrow(() -> new BusinessException(ErrorCode.ANSWER_NOT_FOUND));
77+
interviewAnswer.updateMediaUrl(mediaUrl);
78+
}
79+
80+
@Transactional
81+
public void updateAnswer(final UUID answerId, final String answer) {
82+
InterviewAnswer interviewAnswer = answerRepository.findById(answerId)
83+
.orElseThrow(() -> new BusinessException(ErrorCode.ANSWER_NOT_FOUND));
84+
interviewAnswer.updateAnswer(answer);
85+
}
7686

77-
// STT 피드백
78-
feedbackRepository.save(new InterviewFeedback(interviewAnswer, serializeFeedback(sttDetail), FeedbackType.STT,
79-
sttResult.score()));
87+
@Transactional
88+
public void saveFeedback(final UUID answerId, final FeedbackResult result,
89+
final FeedbackDetail detail, final FeedbackType feedbackType) {
90+
InterviewAnswer interviewAnswer = answerRepository.findById(answerId)
91+
.orElseThrow(() -> new BusinessException(ErrorCode.ANSWER_NOT_FOUND));
8092

81-
// 비디오 분석 피드백
8293
feedbackRepository.save(
83-
new InterviewFeedback(interviewAnswer, serializeFeedback(videoDetail), FeedbackType.VIDEO,
84-
videoResult.score()));
85-
86-
log.info("비디오 분석 피드백 저장 완료 - answerId: {}", interviewAnswer.getId());
94+
new InterviewFeedback(interviewAnswer, serializeFeedback(detail), feedbackType, result.score()));
8795
}
8896

8997
private InterviewQuestion getQuestion(UUID questionId, UUID memberId) {

src/main/java/io/wisoft/prepair/prepair_api/service/answer/AnswerService.java

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.wisoft.prepair.prepair_api.dto.FeedbackResult;
44
import io.wisoft.prepair.prepair_api.dto.response.FeedbackDetail;
55
import io.wisoft.prepair.prepair_api.dto.response.FeedbackResponse;
6+
import io.wisoft.prepair.prepair_api.entity.InterviewAnswer;
67
import io.wisoft.prepair.prepair_api.entity.InterviewFeedback;
78
import io.wisoft.prepair.prepair_api.entity.InterviewQuestion;
89
import io.wisoft.prepair.prepair_api.entity.enums.AnswerType;
@@ -11,10 +12,10 @@
1112
import io.wisoft.prepair.prepair_api.global.exception.BusinessException;
1213
import io.wisoft.prepair.prepair_api.global.exception.ErrorCode;
1314
import io.wisoft.prepair.prepair_api.repository.QuestionRepository;
14-
import io.wisoft.prepair.prepair_api.storage.FileUploader;
15-
15+
import java.io.IOException;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
1618
import java.util.UUID;
17-
1819
import lombok.RequiredArgsConstructor;
1920
import lombok.extern.slf4j.Slf4j;
2021
import org.springframework.stereotype.Service;
@@ -30,7 +31,6 @@ public class AnswerService {
3031
private final FeedbackGenerator feedbackGenerator;
3132
private final QuestionRepository questionRepository;
3233
private final MemberServiceClient memberServiceClient;
33-
private final FileUploader fileUploader;
3434

3535
public FeedbackResponse submitAnswer(final UUID questionId, final UUID memberId, final String answer) {
3636
InterviewQuestion question = questionRepository.findByIdAndMemberId(questionId, memberId)
@@ -51,10 +51,27 @@ public void submitVideoAnswer(final UUID questionId, final UUID memberId, final
5151
.orElseThrow(() -> new BusinessException(ErrorCode.QUESTION_NOT_FOUND));
5252

5353
String email = memberServiceClient.getMember(memberId).email();
54-
String mediaUrl = fileUploader.upload(video, email);
54+
InterviewAnswer answer = answerPersistService.createVideoAnswer(questionId, memberId);
55+
56+
try {
57+
byte[] videoBytes = video.getBytes();
58+
Path videoPath = Files.createTempFile("video-", getExtension(video.getOriginalFilename()));
59+
Files.write(videoPath, videoBytes);
5560

56-
videoAnswerAnalyzer.analyzeSTT(questionId, memberId, mediaUrl, question.getQuestionTag());
61+
videoAnswerAnalyzer.uploadToS3(answer.getId(), videoBytes, video.getContentType(), video.getOriginalFilename(), email);
62+
videoAnswerAnalyzer.analyzeSTT(answer.getId(), questionId, memberId, videoPath, question.getQuestionTag());
63+
videoAnswerAnalyzer.analyzeVideo(answer.getId(), videoPath);
64+
65+
} catch (IOException e) {
66+
log.error("영상 임시파일 생성 실패", e);
67+
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
68+
}
69+
}
5770

58-
log.info("영상 S3 업로드 완료, STT 비동기 분석 트리거 - questionId: {}", questionId);
71+
private String getExtension(String filename) {
72+
if (filename != null && filename.contains(".")) {
73+
return filename.substring(filename.lastIndexOf("."));
74+
}
75+
return ".webm";
5976
}
6077
}

src/main/java/io/wisoft/prepair/prepair_api/service/answer/VideoAnswerAnalyzer.java

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import io.wisoft.prepair.prepair_api.dto.FeedbackResult;
44
import io.wisoft.prepair.prepair_api.dto.response.FeedbackDetail;
55
import io.wisoft.prepair.prepair_api.entity.InterviewQuestion;
6-
import io.wisoft.prepair.prepair_api.entity.enums.AnswerType;
76
import io.wisoft.prepair.prepair_api.entity.enums.FeedbackType;
87
import io.wisoft.prepair.prepair_api.global.exception.BusinessException;
98
import io.wisoft.prepair.prepair_api.global.exception.ErrorCode;
109
import io.wisoft.prepair.prepair_api.repository.QuestionRepository;
1110
import io.wisoft.prepair.prepair_api.service.stt.SpeechToTextService;
11+
import io.wisoft.prepair.prepair_api.service.vidoe.VideoAnalysisService;
1212
import io.wisoft.prepair.prepair_api.storage.FileUploader;
1313
import lombok.RequiredArgsConstructor;
1414
import lombok.extern.slf4j.Slf4j;
@@ -25,32 +25,53 @@ public class VideoAnswerAnalyzer {
2525

2626
private final AnswerPersistService answerPersistService;
2727
private final SpeechToTextService speechToTextService;
28+
private final VideoAnalysisService videoAnalysisService;
2829
private final QuestionRepository questionRepository;
2930
private final FileUploader fileUploader;
3031
private final FeedbackGenerator feedbackGenerator;
3132

32-
/**
33-
* S3에서 영상을 다운로드하여 STT 변환 후 피드백을 생성하고 저장한다.
34-
* 비동기로 실행되며, 실패 시 로그만 남기고 종료한다.
35-
*/
3633
@Async("videoTaskExecutor")
37-
public void analyzeSTT(final UUID questionId, final UUID memberId, final String mediaUrl, final String questionTags) {
34+
public void uploadToS3(final UUID answerId, final byte[] videoBytes,
35+
final String contentType, final String originalFilename, final String email) {
36+
try {
37+
String mediaUrl = fileUploader.upload(videoBytes, contentType, originalFilename, email);
38+
answerPersistService.updateMediaUrl(answerId, mediaUrl);
39+
log.info("[S3] 업로드 완료 - answerId: {}", answerId);
40+
} catch (Exception e) {
41+
log.error("S3 업로드 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
42+
}
43+
}
44+
45+
@Async("videoTaskExecutor")
46+
public void analyzeSTT(final UUID answerId, final UUID questionId, final UUID memberId,
47+
final Path videoPath, final String questionTags) {
3848
try {
39-
Path videoPath = fileUploader.download(mediaUrl);
4049
String answer = speechToTextService.convertToTextFromPath(videoPath, questionTags);
50+
answerPersistService.updateAnswer(answerId, answer);
4151

4252
InterviewQuestion question = questionRepository.findByIdAndMemberId(questionId, memberId)
4353
.orElseThrow(() -> new BusinessException(ErrorCode.QUESTION_NOT_FOUND));
4454

4555
FeedbackResult result = feedbackGenerator.generate(question, answer);
4656
FeedbackDetail detail = new FeedbackDetail(result.good(), result.improvement(), result.recommendation());
4757

48-
answerPersistService.saveAnswerAndFeedback(
49-
questionId, memberId, answer, result, detail, AnswerType.VIDEO, FeedbackType.STT);
58+
answerPersistService.saveFeedback(answerId, result, detail, FeedbackType.STT);
59+
log.info("[STT] 완료 - answerId: {}", answerId);
60+
} catch (Exception e) {
61+
log.error("STT 분석 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
62+
}
63+
}
64+
65+
@Async("videoTaskExecutor")
66+
public void analyzeVideo(final UUID answerId, final Path videoPath) {
67+
try {
68+
FeedbackResult result = videoAnalysisService.analyze(videoPath);
69+
FeedbackDetail detail = new FeedbackDetail(result.good(), result.improvement(), result.recommendation());
5070

51-
log.info("STT 분석 및 피드백 저장 완료 - questionId: {}", questionId);
71+
answerPersistService.saveFeedback(answerId, result, detail, FeedbackType.VIDEO);
72+
log.info("[VIDEO] 완료 - answerId: {}", answerId);
5273
} catch (Exception e) {
53-
log.error("STT 분석 실패 - questionId: {}, error: {}", questionId, e.getMessage(), e);
74+
log.error("비디오 분석 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
5475
}
5576
}
5677
}

src/main/java/io/wisoft/prepair/prepair_api/service/stt/SpeechToTextService.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ public String convertToTextFromPath(Path inputPath, String questionTags) {
4444
log.error("영상 변환 실패", e);
4545
throw new BusinessException(ErrorCode.VIDEO_CONVERSION_FAILED);
4646
} finally {
47-
deleteTempFile(inputPath);
4847
deleteTempFile(outputPath);
4948
}
5049
}

src/main/java/io/wisoft/prepair/prepair_api/service/vidoe/VideoAnalysisService.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
import io.wisoft.prepair.prepair_api.global.exception.ErrorCode;
99
import io.wisoft.prepair.prepair_api.prompt.VideoAnalysisPromptBuilder;
1010
import io.wisoft.prepair.prepair_api.video.FrameExtractor;
11+
import java.nio.file.Path;
1112
import java.util.List;
1213
import lombok.RequiredArgsConstructor;
1314
import lombok.extern.slf4j.Slf4j;
1415
import org.springframework.stereotype.Service;
15-
import org.springframework.web.multipart.MultipartFile;
1616

1717
@Slf4j
1818
@Service
@@ -24,17 +24,18 @@ public class VideoAnalysisService {
2424
private final VideoAnalysisPromptBuilder promptBuilder;
2525
private final ObjectMapper objectMapper;
2626

27-
public FeedbackResult analyze(final MultipartFile video) {
28-
// 1. 프레임 추출 + 샘플링
29-
List<String> frames = frameExtractor.extractFrames(video);
27+
public FeedbackResult analyze(final Path videoPath) {
28+
List<String> frames = frameExtractor.extractFrames(videoPath);
29+
return analyzeFrames(frames);
30+
}
31+
32+
private FeedbackResult analyzeFrames(List<String> frames) {
3033
log.info("프레임 추출 완료 - {}개", frames.size());
3134

32-
// 2. GPT-4o Vision 분석
3335
String prompt = promptBuilder.buildVisionPrompt();
3436
String result = openAiClient.analyzeWithVision(prompt, frames);
3537
log.info("비디오 분석 완료");
3638

37-
// 3. Vision 응답 → FeedbackResult 변환
3839
return parseVisionResponse(result);
3940
}
4041

src/main/java/io/wisoft/prepair/prepair_api/storage/FileUploader.java

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,30 @@ public class FileUploader {
4040

4141
public String upload(MultipartFile file, String email) {
4242
try {
43-
String extension = getExtension(file);
44-
String key = "interview-video/" + email + "/" + LocalDate.now() + "/" + UUID.randomUUID() + extension;
45-
46-
s3Client.putObject(PutObjectRequest.builder()
47-
.bucket(bucket)
48-
.key(key)
49-
.contentType(file.getContentType())
50-
.build(),
51-
RequestBody.fromBytes(file.getBytes())
52-
);
53-
54-
String url = endpoint + "/" + bucket + "/" + key;
55-
log.info("영상 S3 업로드 완료 - key: {}", key);
56-
return url;
43+
return upload(file.getBytes(), file.getContentType(), file.getOriginalFilename(), email);
5744
} catch (IOException e) {
5845
log.error("영상 S3 업로드 실패 - bucket: {}, error: {}", bucket, e.getMessage(), e);
5946
throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED);
6047
}
6148
}
6249

50+
public String upload(byte[] bytes, String contentType, String originalFilename, String email) {
51+
String extension = getExtension(originalFilename);
52+
String key = "interview-video/" + email + "/" + LocalDate.now() + "/" + UUID.randomUUID() + extension;
53+
54+
s3Client.putObject(PutObjectRequest.builder()
55+
.bucket(bucket)
56+
.key(key)
57+
.contentType(contentType)
58+
.build(),
59+
RequestBody.fromBytes(bytes)
60+
);
61+
62+
String url = endpoint + "/" + bucket + "/" + key;
63+
log.info("영상 S3 업로드 완료 - key: {}", key);
64+
return url;
65+
}
66+
6367
public Path download(String mediaUrl) {
6468
try {
6569
String key = extractKey(mediaUrl);
@@ -105,11 +109,10 @@ private String extractKey(String mediaUrl) {
105109
return mediaUrl.substring(prefix.length());
106110
}
107111

108-
private String getExtension(MultipartFile file) {
109-
String original = file.getOriginalFilename();
110-
if (original == null || !original.contains(".")) {
112+
private String getExtension(String filename) {
113+
if (filename == null || !filename.contains(".")) {
111114
return ".webm";
112115
}
113-
return original.substring(original.lastIndexOf("."));
116+
return filename.substring(filename.lastIndexOf("."));
114117
}
115118
}

0 commit comments

Comments
 (0)