From 3abee2180cb5bf128d05e22464aa9f16dcd68f74 Mon Sep 17 00:00:00 2001 From: Boyeon-Shin Date: Thu, 14 May 2026 01:30:28 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=98=81=EC=83=81=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20S3=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/storage/FileExtensions.java | 15 ++++ .../external/storage/LocalFileStorage.java | 36 ++++++++++ .../{FileUploader.java => S3FileStorage.java} | 22 +++--- .../event/AllAnalysisCompletedEvent.java | 5 +- .../event/AllAnalysisCompletedHandler.java | 13 ---- .../event/AnalysisCompletionTracker.java | 71 +++++++++++++------ .../VideoAnswerProcessor.java | 37 +++++----- .../answer/service/AnswerService.java | 40 +++-------- 8 files changed, 143 insertions(+), 96 deletions(-) create mode 100644 src/main/java/io/wisoft/prepair/prepair_api/external/storage/FileExtensions.java create mode 100644 src/main/java/io/wisoft/prepair/prepair_api/external/storage/LocalFileStorage.java rename src/main/java/io/wisoft/prepair/prepair_api/external/storage/{FileUploader.java => S3FileStorage.java} (65%) rename src/main/java/io/wisoft/prepair/prepair_api/interview/answer/{service => event}/VideoAnswerProcessor.java (77%) diff --git a/src/main/java/io/wisoft/prepair/prepair_api/external/storage/FileExtensions.java b/src/main/java/io/wisoft/prepair/prepair_api/external/storage/FileExtensions.java new file mode 100644 index 0000000..3ccabbb --- /dev/null +++ b/src/main/java/io/wisoft/prepair/prepair_api/external/storage/FileExtensions.java @@ -0,0 +1,15 @@ +package io.wisoft.prepair.prepair_api.external.storage; + +final class FileExtensions { + + private static final String DEFAULT = ".tmp"; + + private FileExtensions() {} + + static String extract(String filename) { + if (filename == null || filename.isBlank()) return DEFAULT; + int idx = filename.lastIndexOf('.'); + if (idx < 0 || idx == filename.length() - 1) return DEFAULT; + return filename.substring(idx); + } +} diff --git a/src/main/java/io/wisoft/prepair/prepair_api/external/storage/LocalFileStorage.java b/src/main/java/io/wisoft/prepair/prepair_api/external/storage/LocalFileStorage.java new file mode 100644 index 0000000..e1e878e --- /dev/null +++ b/src/main/java/io/wisoft/prepair/prepair_api/external/storage/LocalFileStorage.java @@ -0,0 +1,36 @@ +package io.wisoft.prepair.prepair_api.external.storage; + +import io.wisoft.prepair.prepair_api.common.exception.BusinessException; +import io.wisoft.prepair.prepair_api.common.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@Slf4j +@Component +public class LocalFileStorage { + + public Path save(MultipartFile file) { + try { + Path path = Files.createTempFile("video-", FileExtensions.extract(file.getOriginalFilename())); + file.transferTo(path); + return path; + } catch (Exception e) { + log.error("[로컬파일] 저장 실패 - filename: {}", file.getOriginalFilename(), e); + throw new BusinessException(ErrorCode.INTERNAL_ERROR); + } + } + + public void delete(Path path) { + if (path == null) return; + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("[로컬파일] 삭제 실패 - path: {}", path, e); + } + } +} diff --git a/src/main/java/io/wisoft/prepair/prepair_api/external/storage/FileUploader.java b/src/main/java/io/wisoft/prepair/prepair_api/external/storage/S3FileStorage.java similarity index 65% rename from src/main/java/io/wisoft/prepair/prepair_api/external/storage/FileUploader.java rename to src/main/java/io/wisoft/prepair/prepair_api/external/storage/S3FileStorage.java index f6a504f..24a8e09 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/external/storage/FileUploader.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/external/storage/S3FileStorage.java @@ -7,15 +7,19 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.exception.SdkClientException; 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; @Slf4j @Component @RequiredArgsConstructor -public class FileUploader { +public class S3FileStorage { private final S3Client s3Client; @@ -25,8 +29,13 @@ public class FileUploader { @Value("${cloud.aws.s3.endpoint}") private String endpoint; - public String upload(Path videoPath, String contentType, String email) { - String extension = getExtension(videoPath.getFileName().toString()); + @Retryable( + value = { SdkClientException.class, S3Exception.class }, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2) + ) + public String save(Path videoPath, String contentType, String email) { + String extension = FileExtensions.extract(videoPath.getFileName().toString()); String key = "interview-video/" + email + "/" + LocalDate.now() + "/" + UUID.randomUUID() + extension; s3Client.putObject(PutObjectRequest.builder() @@ -39,11 +48,4 @@ public String upload(Path videoPath, String contentType, String email) { return endpoint + "/" + bucket + "/" + key; } - - private String getExtension(String filename) { - if (filename == null || !filename.contains(".")) { - return ".webm"; - } - return filename.substring(filename.lastIndexOf(".")); - } } diff --git a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedEvent.java b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedEvent.java index 3a88810..c9d9484 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedEvent.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedEvent.java @@ -1,7 +1,6 @@ package io.wisoft.prepair.prepair_api.interview.answer.event; -import java.nio.file.Path; import java.util.UUID; -public record AllAnalysisCompletedEvent(UUID answerId, boolean hasFailed, Path videoPath) { -} \ No newline at end of file +public record AllAnalysisCompletedEvent(UUID answerId, boolean hasFailed) { +} diff --git a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedHandler.java b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedHandler.java index 6fb59ba..550dced 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedHandler.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AllAnalysisCompletedHandler.java @@ -21,9 +21,6 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -48,7 +45,6 @@ public class AllAnalysisCompletedHandler { @EventListener public void handle(AllAnalysisCompletedEvent event) { UUID answerId = event.answerId(); - deleteTempFile(event.videoPath()); if (event.hasFailed()) { log.error("[종합평가] 분석 실패로 종합평가 생략 - answerId: {}", answerId); @@ -225,15 +221,6 @@ private void failSession(UUID answerId, String message) { sseEmitterManager.complete(session.getId()); } - private void deleteTempFile(Path videoPath) { - if (videoPath == null) return; - try { - Files.deleteIfExists(videoPath); - } catch (IOException e) { - log.warn("[임시파일] 삭제 실패 - path: {}", videoPath, e); - } - } - private record AnalysisFeedbacks( InterviewFeedback stt, InterviewFeedback video diff --git a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AnalysisCompletionTracker.java b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AnalysisCompletionTracker.java index 3363870..c389c08 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AnalysisCompletionTracker.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/AnalysisCompletionTracker.java @@ -1,5 +1,6 @@ package io.wisoft.prepair.prepair_api.interview.answer.event; +import io.wisoft.prepair.prepair_api.external.storage.LocalFileStorage; import java.nio.file.Path; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -17,41 +18,67 @@ public class AnalysisCompletionTracker { private final ApplicationEventPublisher eventPublisher; + private final LocalFileStorage localFileStorage; + + private static final int ANALYSIS_TASKS = 2; private static final int TOTAL_TASKS = 3; - private final ConcurrentMap completionMap = new ConcurrentHashMap<>(); - private final ConcurrentMap failureMap = new ConcurrentHashMap<>(); - private final ConcurrentMap videoPathMap = new ConcurrentHashMap<>(); + private final ConcurrentMap stateMap = new ConcurrentHashMap<>(); public void init(UUID answerId, Path videoPath) { - completionMap.put(answerId, new AtomicInteger(0)); - failureMap.put(answerId, new AtomicBoolean(false)); - videoPathMap.put(answerId, videoPath); + stateMap.put(answerId, new TrackingState(videoPath)); + } + + public void completeAnalysis(UUID answerId) { + finishAnalysis(answerId, false); } - public void complete(UUID answerId) { - finish(answerId, false); + public void failAnalysis(UUID answerId) { + finishAnalysis(answerId, true); } - public void fail(UUID answerId) { - finish(answerId, true); + public void completeS3(UUID answerId) { + finishTask(answerId); } - private void finish(UUID answerId, boolean failed) { - AtomicInteger counter = completionMap.get(answerId); - if (counter == null) return; + public void failS3(UUID answerId) { + finishTask(answerId); + } + + private void finishAnalysis(UUID answerId, boolean failed) { + TrackingState state = stateMap.get(answerId); + if (state == null) return; + + if (failed) state.analysisFailed.set(true); - if (failed) { - failureMap.get(answerId).set(true); + int analysisCount = state.analysisCount.incrementAndGet(); + if (analysisCount == ANALYSIS_TASKS) { + eventPublisher.publishEvent( + new AllAnalysisCompletedEvent(answerId, state.analysisFailed.get()) + ); } + finishTask(answerId); + } + + private void finishTask(UUID answerId) { + TrackingState state = stateMap.get(answerId); + if (state == null) return; + + int totalCount = state.totalCount.incrementAndGet(); + if (totalCount == TOTAL_TASKS) { + stateMap.remove(answerId); + localFileStorage.delete(state.videoPath); + } + } - int count = counter.incrementAndGet(); + private static final class TrackingState { + final AtomicInteger analysisCount = new AtomicInteger(0); + final AtomicInteger totalCount = new AtomicInteger(0); + final AtomicBoolean analysisFailed = new AtomicBoolean(false); + final Path videoPath; - if (count == TOTAL_TASKS) { - boolean hasFailed = failureMap.remove(answerId).get(); - completionMap.remove(answerId); - Path videoPath = videoPathMap.remove(answerId); - eventPublisher.publishEvent(new AllAnalysisCompletedEvent(answerId, hasFailed, videoPath)); + TrackingState(Path videoPath) { + this.videoPath = videoPath; } } -} \ No newline at end of file +} diff --git a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/service/VideoAnswerProcessor.java b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/VideoAnswerProcessor.java similarity index 77% rename from src/main/java/io/wisoft/prepair/prepair_api/interview/answer/service/VideoAnswerProcessor.java rename to src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/VideoAnswerProcessor.java index 950f008..ae8ac01 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/service/VideoAnswerProcessor.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/event/VideoAnswerProcessor.java @@ -1,24 +1,27 @@ -package io.wisoft.prepair.prepair_api.interview.answer.service; +package io.wisoft.prepair.prepair_api.interview.answer.event; -import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackResult; -import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackDetail; -import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion; -import io.wisoft.prepair.prepair_api.interview.answer.entity.FeedbackType; import io.wisoft.prepair.prepair_api.common.exception.BusinessException; import io.wisoft.prepair.prepair_api.common.exception.ErrorCode; +import io.wisoft.prepair.prepair_api.external.storage.S3FileStorage; +import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackDetail; +import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackResult; +import io.wisoft.prepair.prepair_api.interview.answer.entity.FeedbackType; +import io.wisoft.prepair.prepair_api.interview.answer.service.AnswerPersistenceService; +import io.wisoft.prepair.prepair_api.interview.answer.service.FeedbackGenerator; +import io.wisoft.prepair.prepair_api.interview.answer.service.SpeechToTextService; +import io.wisoft.prepair.prepair_api.interview.answer.service.VideoFrameAnalysisService; +import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion; import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository; -import io.wisoft.prepair.prepair_api.interview.answer.event.AnalysisCompletionTracker; -import io.wisoft.prepair.prepair_api.external.storage.FileUploader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Component; import java.nio.file.Path; import java.util.UUID; @Slf4j -@Service +@Component @RequiredArgsConstructor public class VideoAnswerProcessor { @@ -26,20 +29,20 @@ public class VideoAnswerProcessor { private final SpeechToTextService speechToTextService; private final VideoFrameAnalysisService videoAnalysisService; private final QuestionRepository questionRepository; - private final FileUploader fileUploader; + private final S3FileStorage s3FileStorage; private final FeedbackGenerator feedbackGenerator; private final AnalysisCompletionTracker completionTracker; @Async("videoTaskExecutor") public void uploadToS3(final UUID answerId, final Path videoPath, final String contentType, final String email) { try { - String mediaUrl = fileUploader.upload(videoPath, contentType, email); + String mediaUrl = s3FileStorage.save(videoPath, contentType, email); answerPersistenceService.updateMediaUrl(answerId, mediaUrl); log.info("[VIDEO-S3] 업로드 완료 - answerId: {}", answerId); - completionTracker.complete(answerId); + completionTracker.completeS3(answerId); } catch (Exception e) { log.error("[VIDEO-S3] 업로드 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e); - completionTracker.fail(answerId); + completionTracker.failS3(answerId); } } @@ -58,10 +61,10 @@ public void analyzeSTT(final UUID answerId, final UUID questionId, final UUID me answerPersistenceService.saveVideoFeedback(answerId, result, detail, FeedbackType.STT); log.info("[VIDEO-STT] 분석 완료 - answerId: {}", answerId); - completionTracker.complete(answerId); + completionTracker.completeAnalysis(answerId); } catch (Exception e) { log.error("[VIDEO-STT] 분석 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e); - completionTracker.fail(answerId); + completionTracker.failAnalysis(answerId); } } @@ -74,10 +77,10 @@ public void analyzeVideo(final UUID answerId, final Path videoPath) { answerPersistenceService.saveVideoFeedback(answerId, result, detail, FeedbackType.VIDEO); log.info("[VIDEO-ANALYSIS] 분석 완료 - answerId: {}", answerId); - completionTracker.complete(answerId); + completionTracker.completeAnalysis(answerId); } catch (Exception e) { log.error("[VIDEO-ANALYSIS] 분석 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e); - completionTracker.fail(answerId); + completionTracker.failAnalysis(answerId); } } } diff --git a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/service/AnswerService.java b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/service/AnswerService.java index d1f78d7..8545f73 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/service/AnswerService.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/interview/answer/service/AnswerService.java @@ -7,11 +7,12 @@ import io.wisoft.prepair.prepair_api.interview.answer.entity.InterviewAnswer; import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion; import io.wisoft.prepair.prepair_api.external.member.MemberServiceClient; +import io.wisoft.prepair.prepair_api.external.storage.LocalFileStorage; import io.wisoft.prepair.prepair_api.common.exception.BusinessException; import io.wisoft.prepair.prepair_api.common.exception.ErrorCode; import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository; import io.wisoft.prepair.prepair_api.interview.answer.event.AnalysisCompletionTracker; -import java.nio.file.Files; +import io.wisoft.prepair.prepair_api.interview.answer.event.VideoAnswerProcessor; import java.nio.file.Path; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -25,11 +26,12 @@ public class AnswerService { private final AnswerPersistenceService answerPersistenceService; - private final VideoAnswerProcessor videoAnswerAnalyzer; + private final VideoAnswerProcessor videoAnswerProcessor; private final FeedbackGenerator feedbackGenerator; private final QuestionRepository questionRepository; private final MemberServiceClient memberServiceClient; private final AnalysisCompletionTracker completionTracker; + private final LocalFileStorage localFileStorage; public FeedbackResponse submitAnswer(final UUID questionId, final UUID memberId, final String answer) { // AI 피드백 생성 @@ -57,44 +59,20 @@ public void submitVideoAnswer(final UUID questionId, final UUID memberId, final String email = memberServiceClient.getMember(memberId).email(); // 임시 파일 먼저 생성 후 DB 저장 (실패 시 고아 레코드 방지) - Path videoPath = createTempFile(video); + Path videoPath = localFileStorage.save(video); InterviewAnswer answer = answerPersistenceService.saveVideoAnswer(questionId, memberId); - // 3개 비동기 작업 모두 완료 시 이벤트 발행을 위한 트래커 초기화 + // 3개 비동기 작업 추적을 위한 트래커 초기화 completionTracker.init(answer.getId(), videoPath); log.info("영상 답변 분석 시작 - questionId: {}, answerId: {}", questionId, answer.getId()); - videoAnswerAnalyzer.uploadToS3(answer.getId(), videoPath, video.getContentType(), email); - videoAnswerAnalyzer.analyzeSTT(answer.getId(), questionId, memberId, videoPath); - videoAnswerAnalyzer.analyzeVideo(answer.getId(), videoPath); + videoAnswerProcessor.uploadToS3(answer.getId(), videoPath, video.getContentType(), email); + videoAnswerProcessor.analyzeSTT(answer.getId(), questionId, memberId, videoPath); + videoAnswerProcessor.analyzeVideo(answer.getId(), videoPath); } private InterviewQuestion getQuestion(UUID questionId, UUID memberId) { return questionRepository.findByIdAndMemberId(questionId, memberId) .orElseThrow(() -> new BusinessException(ErrorCode.QUESTION_NOT_FOUND)); } - - private Path createTempFile(final MultipartFile video) { - try { - Path videoPath = Files.createTempFile("video-", getExtension(video.getOriginalFilename())); - video.transferTo(videoPath); - return videoPath; - } catch (Exception e) { - log.error("영상 임시 파일 생성 실패 - filename: {}", video.getOriginalFilename(), e); - throw new BusinessException(ErrorCode.INTERNAL_ERROR); - } - } - - private String getExtension(final String filename) { - if (filename == null || filename.isBlank()) { - return ".tmp"; - } - - int extensionIndex = filename.lastIndexOf('.'); - if (extensionIndex < 0 || extensionIndex == filename.length() - 1) { - return ".tmp"; - } - - return filename.substring(extensionIndex); - } }