From 17387e41927de54969ebef74bcde823f61b02cda Mon Sep 17 00:00:00 2001 From: youngsun Date: Mon, 10 Aug 2026 23:32:59 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EB=AA=A9=EC=86=8C=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EB=A3=A8?= =?UTF-8?q?=ED=8B=B4=20TTS=20=EC=9E=AC=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/member/entity/Member.java | 5 + .../member/MemberCommandServiceImpl.java | 43 ++++++++- .../domain/routine/entity/RoutineTTS.java | 11 ++- .../routine/event/RoutineTtsCreatedEvent.java | 3 +- .../event/RoutineTtsVoiceChangedEvent.java | 7 ++ .../repository/RoutineTTSRepository.java | 18 ++++ .../RoutineGroupCommandServiceImpl.java | 8 +- .../command/RoutineTTS/TTSAsyncService.java | 91 +++++++++++++++++-- 8 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/moru/server/domain/routine/event/RoutineTtsVoiceChangedEvent.java diff --git a/src/main/java/com/moru/server/domain/member/entity/Member.java b/src/main/java/com/moru/server/domain/member/entity/Member.java index 789b2ff..b3c4c84 100644 --- a/src/main/java/com/moru/server/domain/member/entity/Member.java +++ b/src/main/java/com/moru/server/domain/member/entity/Member.java @@ -45,6 +45,10 @@ public class Member extends BaseEntity { @JoinColumn(name = "tts_id") private TTS voiceType; + @Column(name = "voice_selection_version", nullable = false) + @Builder.Default + private Long voiceSelectionVersion = 0L; + @Column(name = "profile_image_key", length = 500) private String profileImageKey; @@ -66,5 +70,6 @@ public void completeOnboarding() { public void updateVoiceType(TTS voiceType) { this.voiceType = voiceType; + this.voiceSelectionVersion++; } } diff --git a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java index dad36ca..394a3f0 100644 --- a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java @@ -4,25 +4,40 @@ import com.moru.server.domain.member.dto.MemberResponseDTO; import com.moru.server.domain.member.entity.Member; import com.moru.server.domain.member.repository.MemberRepository; +import com.moru.server.domain.routine.entity.RoutineTTS; +import com.moru.server.domain.routine.event.RoutineTtsVoiceChangedEvent; +import com.moru.server.domain.routine.repository.RoutineTTSRepository; import com.moru.server.domain.tts.entity.TTS; import com.moru.server.domain.tts.repository.TTSRepository; import com.moru.server.global.exception.BusinessException; import com.moru.server.global.response.code.status.ErrorStatus; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; @Service +@Slf4j @RequiredArgsConstructor @Transactional public class MemberCommandServiceImpl implements MemberCommandService { private final MemberRepository memberRepository; private final TTSRepository ttsRepository; + private final RoutineTTSRepository routineTTSRepository; + private final ApplicationEventPublisher eventPublisher; + + @Value("${google.tts.enabled:false}") + private boolean ttsEnabled; - @Override + @Override public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) { - Member member = memberRepository.findById(memberId) + Member member = memberRepository.findByIdForUpdate(memberId) .orElseThrow(() -> new BusinessException(ErrorStatus.MEMBER_NOT_FOUND)); TTS tts = ttsRepository.findById(ttsId) @@ -30,6 +45,30 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) member.updateVoiceType(tts); + requestRegeneration(memberId, tts.getName(), member.getVoiceSelectionVersion()); + return MemberConverter.toTtsUpdateResponse(member); } + + private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) { + if (!ttsEnabled) { + // 처리할 워커가 없으므로 PENDING 으로 돌리면 영구히 멈춘다. 기존 음원을 그대로 둔다. + log.info("[TTS] 기능이 비활성화되어 재합성을 건너뜀. memberId={}", memberId); + return; + } + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException( + "재합성 이벤트는 트랜잭션 안에서 발행해야 한다. memberId=" + memberId); + } + + List targets = routineTTSRepository.findAllByMemberId(memberId); + for (RoutineTTS target : targets) { + target.markPending(); + eventPublisher.publishEvent( + new RoutineTtsVoiceChangedEvent(target.getId(), voiceName, voiceVersion)); + } + + log.info("[TTS] 목소리 변경으로 재합성 요청. memberId={}, 건수={}, voiceVersion={}", + memberId, targets.size(), voiceVersion); + } } diff --git a/src/main/java/com/moru/server/domain/routine/entity/RoutineTTS.java b/src/main/java/com/moru/server/domain/routine/entity/RoutineTTS.java index 94e5580..26d3e3d 100644 --- a/src/main/java/com/moru/server/domain/routine/entity/RoutineTTS.java +++ b/src/main/java/com/moru/server/domain/routine/entity/RoutineTTS.java @@ -47,17 +47,26 @@ public class RoutineTTS extends BaseEntity { @Column(name = "s3_url") private String s3Url; - public void markCompleted(String s3Url, String ttsIntro, String ttsDone) { + @Column(name = "voice_version") + private Long voiceVersion; + + public void markCompleted(String s3Url, String ttsIntro, String ttsDone, Long voiceVersion) { this.ttsStatus = TtsStatus.COMPLETED; this.s3Url = s3Url; this.ttsIntro = truncate(ttsIntro); this.ttsDone = truncate(ttsDone); + this.voiceVersion = voiceVersion; } public void markFailed() { this.ttsStatus = TtsStatus.FAILED; } + + public void markPending() { + this.ttsStatus = TtsStatus.PENDING; + } + public static String truncate(String text) { if (text == null || text.length() <= CONTENT_MAX_LENGTH) { return text; diff --git a/src/main/java/com/moru/server/domain/routine/event/RoutineTtsCreatedEvent.java b/src/main/java/com/moru/server/domain/routine/event/RoutineTtsCreatedEvent.java index db24b82..5d02ac2 100644 --- a/src/main/java/com/moru/server/domain/routine/event/RoutineTtsCreatedEvent.java +++ b/src/main/java/com/moru/server/domain/routine/event/RoutineTtsCreatedEvent.java @@ -2,5 +2,6 @@ public record RoutineTtsCreatedEvent( Long routineTtsId, - String voiceName) { + String voiceName, + Long voiceVersion) { } diff --git a/src/main/java/com/moru/server/domain/routine/event/RoutineTtsVoiceChangedEvent.java b/src/main/java/com/moru/server/domain/routine/event/RoutineTtsVoiceChangedEvent.java new file mode 100644 index 0000000..9a3c962 --- /dev/null +++ b/src/main/java/com/moru/server/domain/routine/event/RoutineTtsVoiceChangedEvent.java @@ -0,0 +1,7 @@ +package com.moru.server.domain.routine.event; + +public record RoutineTtsVoiceChangedEvent( + Long routineTtsId, + String voiceName, + Long voiceVersion) { +} diff --git a/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java b/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java index 57e6493..be90420 100644 --- a/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java +++ b/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface RoutineTTSRepository extends JpaRepository { @@ -28,4 +29,21 @@ public interface RoutineTTSRepository extends JpaRepository { order by t.routine.id asc, t.orderIndex asc """) List findAllByRoutineIdsOrdered(@Param("routineIds") List routineIds); + + @Query(""" + select t from RoutineTTS t + join t.routine r + join r.routineGroup g + where g.member.id = :memberId + order by t.id asc + """) + List findAllByMemberId(@Param("memberId") Long memberId); + + @Query(""" + select g.member.voiceSelectionVersion from RoutineTTS t + join t.routine r + join r.routineGroup g + where t.id = :routineTtsId + """) + Optional findCurrentVoiceVersion(@Param("routineTtsId") Long routineTtsId); } diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineGroup/RoutineGroupCommandServiceImpl.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineGroup/RoutineGroupCommandServiceImpl.java index 0ae6329..db6d61c 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineGroup/RoutineGroupCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineGroup/RoutineGroupCommandServiceImpl.java @@ -104,7 +104,7 @@ private RoutineGroupResponseDTO.DetailResponse doCreateRoutineGroup( for (Routine routine : saved.getRoutines()) { for (RoutineTTS tts : routine.getTtsList()) { - publishTtsEvent(tts, voiceName); + publishTtsEvent(tts, voiceName, member.getVoiceSelectionVersion()); } } @@ -196,7 +196,7 @@ private void attachSteps(Routine routine, List contents) { } - private void publishTtsEvent(RoutineTTS tts,String voiceName) { + private void publishTtsEvent(RoutineTTS tts,String voiceName,Long voiceVersion) { if (!TransactionSynchronizationManager.isActualTransactionActive()) { throw new IllegalStateException( "TTS 이벤트는 트랜잭션 안에서 발행해야 한다. routineTtsId=" + tts.getId()); @@ -206,7 +206,7 @@ private void publishTtsEvent(RoutineTTS tts,String voiceName) { tts.markFailed(); return; } - eventPublisher.publishEvent(new RoutineTtsCreatedEvent(tts.getId(),voiceName)); + eventPublisher.publishEvent(new RoutineTtsCreatedEvent(tts.getId(),voiceName,voiceVersion)); } @@ -350,7 +350,7 @@ private RoutineGroupResponseDTO.RoutineResponse doAddRoutine( Routine saved = routineRepository.save(routine); for (RoutineTTS tts : saved.getTtsList()) { - publishTtsEvent(tts, voiceName); + publishTtsEvent(tts, voiceName, member.getVoiceSelectionVersion()); } return saved; diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java index 166b51e..961de79 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java @@ -2,6 +2,7 @@ import com.moru.server.domain.routine.entity.RoutineTTS; import com.moru.server.domain.routine.event.RoutineTtsCreatedEvent; +import com.moru.server.domain.routine.event.RoutineTtsVoiceChangedEvent; import com.moru.server.domain.routine.repository.RoutineTTSRepository; import com.moru.server.global.ai.AiClient; import com.moru.server.global.ai.dto.GeminiResponseDTO; @@ -12,6 +13,7 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; @@ -26,6 +28,7 @@ public class TTSAsyncService { private final RoutineTTSRepository routineTTSRepository; private final AiClient aiClient; private final Executor ttsExecutor; + private final Executor ttsRegenerateExecutor; private static final String CONTENT_TYPE = "audio/mpeg"; public TTSAsyncService( @@ -33,28 +36,44 @@ public TTSAsyncService( S3Uploader s3Uploader, RoutineTTSRepository routineTTSRepository, AiClient aiClient, - @Qualifier("ttsExecutor") Executor ttsExecutor + @Qualifier("ttsExecutor") Executor ttsExecutor, + @Qualifier("ttsRegenerateExecutor") Executor ttsRegenerateExecutor ) { this.googleTtsClient = googleTtsClient; this.s3Uploader = s3Uploader; this.routineTTSRepository = routineTTSRepository; this.aiClient = aiClient; this.ttsExecutor = ttsExecutor; + this.ttsRegenerateExecutor = ttsRegenerateExecutor; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onRoutineTtsCreated(RoutineTtsCreatedEvent event) { Long routineTtsId = event.routineTtsId(); String voiceName = event.voiceName(); + Long voiceVersion = event.voiceVersion(); try { - ttsExecutor.execute(() -> synthesizeAndUpload(routineTtsId,voiceName)); + ttsExecutor.execute(() -> synthesizeAndUpload(routineTtsId,voiceName,voiceVersion)); } catch (RejectedExecutionException e) { log.error("[TTS] 스레드풀 포화로 작업이 거절됨. routineTtsId={}", routineTtsId, e); markFailedQuietly(routineTtsId); } } - private void synthesizeAndUpload(Long routineTtsId,String voiceName) { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onRoutineTtsVoiceChanged(RoutineTtsVoiceChangedEvent event) { + Long routineTtsId = event.routineTtsId(); + String voiceName = event.voiceName(); + Long voiceVersion = event.voiceVersion(); + try { + ttsRegenerateExecutor.execute(() -> regenerate(routineTtsId,voiceName,voiceVersion)); + } catch (RejectedExecutionException e) { + log.error("[TTS] 재합성 스레드풀 포화로 작업이 거절됨. routineTtsId={}", routineTtsId, e); + markFailedQuietly(routineTtsId); + } + } + + private void synthesizeAndUpload(Long routineTtsId,String voiceName,Long voiceVersion) { log.info("[TTS] 백그라운드 작업 시작. routineTtsId={}, thread={}", routineTtsId, Thread.currentThread().getName()); @@ -82,7 +101,7 @@ private void synthesizeAndUpload(Long routineTtsId,String voiceName) { RoutineTTS entity = routineTTSRepository.findById(routineTtsId) .orElseThrow(() -> new IllegalStateException("RoutineTTS 행 없음: " + routineTtsId)); - entity.markCompleted(key, ttsScript.ttsIntro(), ttsScript.ttsDone()); + entity.markCompleted(key, ttsScript.ttsIntro(), ttsScript.ttsDone(), voiceVersion); routineTTSRepository.save(entity); log.info("[TTS] 완료. routineTtsId={}, key={}, {} bytes", routineTtsId, key, audio.length); @@ -95,15 +114,75 @@ private void synthesizeAndUpload(Long routineTtsId,String voiceName) { } } + private void regenerate(Long routineTtsId,String voiceName,Long voiceVersion) { + + log.info("[TTS] 재합성 시작. routineTtsId={}, voiceVersion={}, thread={}", + routineTtsId, voiceVersion, Thread.currentThread().getName()); + + RoutineTTS entity = routineTTSRepository.findById(routineTtsId).orElse(null); + if (entity == null) { + log.warn("[TTS] 재합성 대상 행이 없어 중단. routineTtsId={}", routineTtsId); + return; + } + + String previousKey = entity.getS3Url(); + String uploadedKey = null; + + try { + String intro = entity.getTtsIntro(); + String done = entity.getTtsDone(); + + // 최초 합성이 끝난 적 없는 행은 재사용할 멘트가 없다. 이때만 생성 경로와 동일하게 만든다. + if (intro == null || intro.isBlank()) { + GeminiResponseDTO.AiTtsResult ttsScript = aiClient.generateTtsScript(entity.getContent()); + intro = ttsScript.ttsIntro(); + done = ttsScript.ttsDone(); + } + + byte[] audio = googleTtsClient.synthesize(intro,voiceName); + + String key = "tts/%d-%s.mp3".formatted(routineTtsId, UUID.randomUUID()); + s3Uploader.upload(key, audio, CONTENT_TYPE); + uploadedKey = key; + + Long currentVersion = routineTTSRepository.findCurrentVoiceVersion(routineTtsId).orElse(null); + if (!Objects.equals(currentVersion, voiceVersion)) { + log.info("[TTS] 재합성 중 목소리가 다시 바뀌어 결과를 폐기. routineTtsId={}, 작업버전={}, 현재버전={}", + routineTtsId, voiceVersion, currentVersion); + deleteQuietly(uploadedKey); + return; + } + + RoutineTTS target = routineTTSRepository.findById(routineTtsId) + .orElseThrow(() -> new IllegalStateException("RoutineTTS 행 없음: " + routineTtsId)); + + target.markCompleted(key, intro, done, voiceVersion); + routineTTSRepository.save(target); + + // 교체가 확정된 뒤에야 옛 음원을 지운다. 먼저 지우면 실패 시 들려줄 음원이 사라진다. + if (previousKey != null && !previousKey.equals(key)) { + deleteQuietly(previousKey); + } + + log.info("[TTS] 재합성 완료. routineTtsId={}, key={}, {} bytes", routineTtsId, key, audio.length); + + } catch (Exception e) { + + log.error("[TTS] 재합성 실패. 기존 음원은 유지한다. routineTtsId={}", routineTtsId, e); + deleteQuietly(uploadedKey); + markFailedQuietly(routineTtsId); + } + } + private void deleteQuietly(String key) { if (key == null) { return; } try { s3Uploader.delete(key); - log.info("[TTS] 실패로 업로드된 객체를 삭제함. key={}", key); + log.info("[TTS] 사용되지 않는 객체를 삭제함. key={}", key); } catch (Exception e) { - log.error("[TTS] 업로드된 객체 삭제 실패. key={}", key, e); + log.error("[TTS] 객체 삭제 실패. key={}", key, e); } } From 283d5770f965fa4999499df18f1760975a352afa Mon Sep 17 00:00:00 2001 From: youngsun Date: Mon, 10 Aug 2026 23:33:12 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20TTS=20selectionVersion=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/converter/MemberConverter.java | 1 + .../server/domain/member/dto/MemberResponseDTO.java | 10 +++++++++- .../domain/routine/dto/RoutineTTSResponseDTO.java | 10 +++++++++- .../query/RoutineTTS/RoutineTTSQueryServiceImpl.java | 1 + 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/moru/server/domain/member/converter/MemberConverter.java b/src/main/java/com/moru/server/domain/member/converter/MemberConverter.java index e57d4a9..8915609 100644 --- a/src/main/java/com/moru/server/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/moru/server/domain/member/converter/MemberConverter.java @@ -23,6 +23,7 @@ public static MemberResponseDTO.TtsUpdateResponse toTtsUpdateResponse(Member mem .ttsId(member.getVoiceType().getId()) .voiceCode(member.getVoiceType().getName()) .displayName(member.getVoiceType().getLabel()) + .selectionVersion(member.getVoiceSelectionVersion()) .build(); } diff --git a/src/main/java/com/moru/server/domain/member/dto/MemberResponseDTO.java b/src/main/java/com/moru/server/domain/member/dto/MemberResponseDTO.java index 33f9de4..421e318 100644 --- a/src/main/java/com/moru/server/domain/member/dto/MemberResponseDTO.java +++ b/src/main/java/com/moru/server/domain/member/dto/MemberResponseDTO.java @@ -41,7 +41,15 @@ public record TtsUpdateResponse( String voiceCode, @Schema(description = "TTS 목소리 표시 이름", example = "민서") - String displayName + String displayName, + + @Schema(description = """ + 이번 변경으로 올라간 최신 목소리 선택 버전. 목소리를 바꿀 때마다 1씩 증가한다. + 이 응답 시점에 회원의 모든 루틴 TTS 는 PENDING 으로 초기화되며, + 재합성이 끝난 스텝은 TTS 조회 응답의 selectionVersion 이 이 값과 같아진다. + 두 값이 같고 ttsStatus 가 COMPLETED 인 스텝만 새 목소리로 만들어진 음원이다.""", + example = "3") + Long selectionVersion ) { } diff --git a/src/main/java/com/moru/server/domain/routine/dto/RoutineTTSResponseDTO.java b/src/main/java/com/moru/server/domain/routine/dto/RoutineTTSResponseDTO.java index e402214..b5febf3 100644 --- a/src/main/java/com/moru/server/domain/routine/dto/RoutineTTSResponseDTO.java +++ b/src/main/java/com/moru/server/domain/routine/dto/RoutineTTSResponseDTO.java @@ -42,7 +42,15 @@ public record RoutineTTSStep( String ttsStatus, @Schema(description = "음원 파일 S3 URL", example = "https://.../101.mp3", nullable = true) - String s3Url + String s3Url, + + @Schema(description = """ + 이 음원이 만들어진 목소리 선택 버전. 합성에 성공한 시점에만 기록된다. + PATCH /members/me/tts 응답의 selectionVersion 과 이 값이 같고 + ttsStatus 가 COMPLETED 일 때만 최신 목소리로 만들어진 음원이다. + 값이 더 작으면 아직 재합성이 끝나지 않아 이전 목소리의 음원이 내려가는 중이다.""", + example = "3", nullable = true) + Long selectionVersion ) {} } } diff --git a/src/main/java/com/moru/server/domain/routine/service/query/RoutineTTS/RoutineTTSQueryServiceImpl.java b/src/main/java/com/moru/server/domain/routine/service/query/RoutineTTS/RoutineTTSQueryServiceImpl.java index dc727c2..c3f7921 100644 --- a/src/main/java/com/moru/server/domain/routine/service/query/RoutineTTS/RoutineTTSQueryServiceImpl.java +++ b/src/main/java/com/moru/server/domain/routine/service/query/RoutineTTS/RoutineTTSQueryServiceImpl.java @@ -84,6 +84,7 @@ private RoutineTTSResponseDTO.RoutineTTSRes.RoutineTTSStep toStep(RoutineTTS tts .ttsIntro(tts.getTtsIntro()) .ttsStatus(tts.getTtsStatus().name()) .s3Url(generatePresignedUrl(tts.getS3Url())) + .selectionVersion(tts.getVoiceVersion()) .build(); } From a6315ac910a00dae53c6430b14ef36fb833efbf1 Mon Sep 17 00:00:00 2001 From: youngsun Date: Mon, 10 Aug 2026 23:33:28 +0900 Subject: [PATCH 3/9] =?UTF-8?q?chore:=20TTS=20=EC=8A=A4=EB=A0=88=EB=93=9C?= =?UTF-8?q?=ED=92=80=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=20=EC=8B=9C=20=EC=9E=91=EC=97=85=20=EB=8C=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.prod.yml | 2 ++ .../server/global/config/AsyncConfig.java | 36 +++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d300f90..b09ec9e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -41,6 +41,8 @@ services: restart: unless-stopped + stop_grace_period: 90s + healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:${SERVER_PORT:-8080}/health || exit 1"] interval: 30s diff --git a/src/main/java/com/moru/server/global/config/AsyncConfig.java b/src/main/java/com/moru/server/global/config/AsyncConfig.java index 8a63100..3a047c0 100644 --- a/src/main/java/com/moru/server/global/config/AsyncConfig.java +++ b/src/main/java/com/moru/server/global/config/AsyncConfig.java @@ -12,27 +12,41 @@ public class AsyncConfig { - /** - * TTS 작업 전용 스레드풀. - * - *

이걸 정의하지 않으면 Spring 이 기본 실행기를 쓰는데, 기본값은 요청이 올 때마다 - * 스레드를 새로 만든다(상한 없음). 트래픽이 몰리면 스레드가 무한정 늘어나 서버가 죽는다. - * 그래서 개수를 정해둔 풀을 직접 만들어 쓴다. - */ + private static final int AWAIT_TERMINATION_SECONDS = 60; + + @Bean(name = "ttsExecutor") public Executor ttsExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(2); // 평소 유지할 스레드 수 - executor.setMaxPoolSize(4); // 큐가 가득 찼을 때까지 늘릴 수 있는 최대치 - executor.setQueueCapacity(50); // 대기열. 여기가 차야 비로소 스레드를 더 만든다 + executor.setCorePoolSize(4); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); - // 로그에서 "요청 스레드"와 "작업 스레드"를 눈으로 구분하기 위한 이름표. // 톰캣 요청 스레드는 http-nio-... 로 찍히고, 이쪽은 tts-1, tts-2 로 찍힌다. executor.setThreadNamePrefix("tts-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS); + executor.initialize(); return executor; } + @Bean(name = "ttsRegenerateExecutor") + public Executor ttsRegenerateExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(8); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(500); + + executor.setThreadNamePrefix("tts-regen-"); + + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS); + + executor.initialize(); + return executor; + } } From 9b2acce8d35b77310bfe344619db8031d54e3365 Mon Sep 17 00:00:00 2001 From: youngsun Date: Mon, 10 Aug 2026 23:50:05 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix=20:=20=EA=B0=99=EC=9D=80=20=EB=AA=A9?= =?UTF-8?q?=EC=86=8C=EB=A6=AC=20=EC=84=A0=ED=83=9D=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EC=A2=85=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/member/MemberCommandServiceImpl.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java index 394a3f0..217b081 100644 --- a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java @@ -43,6 +43,11 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) TTS tts = ttsRepository.findById(ttsId) .orElseThrow(() -> new BusinessException(ErrorStatus.TTS_NOT_FOUND)); + if (isSameVoice(member, ttsId)) { + log.info("[TTS] 동일한 목소리라 재합성하지 않음. memberId={}, ttsId={}", memberId, ttsId); + return MemberConverter.toTtsUpdateResponse(member); + } + member.updateVoiceType(tts); requestRegeneration(memberId, tts.getName(), member.getVoiceSelectionVersion()); @@ -50,6 +55,11 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) return MemberConverter.toTtsUpdateResponse(member); } + private boolean isSameVoice(Member member, Long ttsId) { + TTS current = member.getVoiceType(); + return current != null && current.getId().equals(ttsId); + } + private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) { if (!ttsEnabled) { // 처리할 워커가 없으므로 PENDING 으로 돌리면 영구히 멈춘다. 기존 음원을 그대로 둔다. From 4b536d8c076bae9050b5562ca06e47fef35f5940 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sat, 15 Aug 2026 23:06:36 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=EB=AA=A9=EC=86=8C=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=9E=AC=ED=95=A9=EC=84=B1=EC=9D=98=20?= =?UTF-8?q?=EB=AA=A9=EC=86=8C=EB=A6=AC=20=EC=9D=B4=EB=A6=84=C2=B7=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/member/entity/Member.java | 3 +++ .../member/MemberCommandServiceImpl.java | 23 ++++++++++++++----- .../response/code/status/ErrorStatus.java | 3 ++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/moru/server/domain/member/entity/Member.java b/src/main/java/com/moru/server/domain/member/entity/Member.java index b3c4c84..da75d1c 100644 --- a/src/main/java/com/moru/server/domain/member/entity/Member.java +++ b/src/main/java/com/moru/server/domain/member/entity/Member.java @@ -70,6 +70,9 @@ public void completeOnboarding() { public void updateVoiceType(TTS voiceType) { this.voiceType = voiceType; + } + + public void bumpVoiceSelectionVersion() { this.voiceSelectionVersion++; } } diff --git a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java index 217b081..53a0ca8 100644 --- a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java @@ -50,7 +50,13 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) member.updateVoiceType(tts); - requestRegeneration(memberId, tts.getName(), member.getVoiceSelectionVersion()); + if (!ttsEnabled) { + log.info("[TTS] 기능이 비활성화되어 재합성을 건너뜀. memberId={}", memberId); + return MemberConverter.toTtsUpdateResponse(member); + } + + member.bumpVoiceSelectionVersion(); + requestRegeneration(memberId, resolveVoiceName(tts), member.getVoiceSelectionVersion()); return MemberConverter.toTtsUpdateResponse(member); } @@ -60,12 +66,17 @@ private boolean isSameVoice(Member member, Long ttsId) { return current != null && current.getId().equals(ttsId); } - private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) { - if (!ttsEnabled) { - // 처리할 워커가 없으므로 PENDING 으로 돌리면 영구히 멈춘다. 기존 음원을 그대로 둔다. - log.info("[TTS] 기능이 비활성화되어 재합성을 건너뜀. memberId={}", memberId); - return; + private String resolveVoiceName(TTS tts) { + String voiceName = tts.getGoogleVoiceName(); + if (voiceName == null || voiceName.isBlank()) { + log.error("[TTS] 목소리 프리셋에 googleVoiceName 이 없어 재합성을 중단. ttsId={}, name={}", + tts.getId(), tts.getName()); + throw new BusinessException(ErrorStatus.TTS_VOICE_NAME_NOT_CONFIGURED); } + return voiceName; + } + + private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) { if (!TransactionSynchronizationManager.isActualTransactionActive()) { throw new IllegalStateException( "재합성 이벤트는 트랜잭션 안에서 발행해야 한다. memberId=" + memberId); diff --git a/src/main/java/com/moru/server/global/response/code/status/ErrorStatus.java b/src/main/java/com/moru/server/global/response/code/status/ErrorStatus.java index d530d17..65d9363 100644 --- a/src/main/java/com/moru/server/global/response/code/status/ErrorStatus.java +++ b/src/main/java/com/moru/server/global/response/code/status/ErrorStatus.java @@ -60,7 +60,8 @@ public enum ErrorStatus implements BaseCode { // TTS 관련 에러 - TTS_NOT_FOUND(HttpStatus.BAD_REQUEST, "TTS4001", "존재하지 않는 목소리입니다."); + TTS_NOT_FOUND(HttpStatus.BAD_REQUEST, "TTS4001", "존재하지 않는 목소리입니다."), + TTS_VOICE_NAME_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, "TTS5001", "목소리 설정이 완료되지 않았습니다. 잠시 후 다시 시도해 주세요."); private final HttpStatus httpStatus; private final String code; From a8dbb7bbc8bfe58057a76484379bbb2e8fd67010 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sun, 16 Aug 2026 02:18:02 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=20=EB=A0=88?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/MemberCommandServiceImpl.java | 17 +++++++++-- .../command/RoutineTTS/TTSAsyncService.java | 30 +++++++++++-------- src/main/resources/application.yml | 4 +++ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java index 53a0ca8..aa1b0fd 100644 --- a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java @@ -49,18 +49,31 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) } member.updateVoiceType(tts); + member.bumpVoiceSelectionVersion(); if (!ttsEnabled) { - log.info("[TTS] 기능이 비활성화되어 재합성을 건너뜀. memberId={}", memberId); + markAllFailed(memberId); return MemberConverter.toTtsUpdateResponse(member); } - member.bumpVoiceSelectionVersion(); requestRegeneration(memberId, resolveVoiceName(tts), member.getVoiceSelectionVersion()); return MemberConverter.toTtsUpdateResponse(member); } + /** + * 재합성을 수행할 워커가 없다. 버전만 올리고 두면 기존 음원이 새 버전인 것처럼 보이므로 + * 생성 경로(RoutineGroupCommandServiceImpl.publishTtsEvent)와 동일하게 FAILED 로 종결한다. + */ + private void markAllFailed(Long memberId) { + List targets = routineTTSRepository.findAllByMemberId(memberId); + for (RoutineTTS target : targets) { + target.markFailed(); + } + log.info("[TTS] 기능이 비활성화되어 재합성을 건너뛰고 FAILED 로 종결. memberId={}, 건수={}", + memberId, targets.size()); + } + private boolean isSameVoice(Member member, Long ttsId) { TTS current = member.getVoiceType(); return current != null && current.getId().equals(ttsId); diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java index f6b719d..3907a86 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java @@ -148,22 +148,26 @@ private void regenerate(Long routineTtsId, String voiceName, Long voiceVersion) log.info("[TTS] 재합성 시작. routineTtsId={}, voiceVersion={}", routineTtsId, voiceVersion); - RoutineTTS entity = routineTTSRepository.findById(routineTtsId).orElse(null); - Long memberId = routineTTSRepository.findMemberIdByRoutineTtsId(routineTtsId); - - if (entity == null || memberId == null) { - log.warn("[TTS] 재합성 대상 행이 없어 중단. routineTtsId={}", routineTtsId); - return; - } - if (memberWithdrawalLock.isLocked(memberId)) { - log.info("[TTS] 회원탈퇴 처리 중이므로 재합성을 중단함. routineTtsId={}", routineTtsId); - return; - } - - String previousKey = entity.getS3Url(); + String previousKey = null; String uploadedKey = null; try { + // DB·Redis 조회도 실패할 수 있다. try 밖에 두면 예외가 executor 로 빠져나가 + // markFailedQuietly 가 실행되지 않고, 행이 PENDING 에 갇힌다. + RoutineTTS entity = routineTTSRepository.findById(routineTtsId).orElse(null); + Long memberId = routineTTSRepository.findMemberIdByRoutineTtsId(routineTtsId); + + if (entity == null || memberId == null) { + log.warn("[TTS] 재합성 대상 행이 없어 중단. routineTtsId={}", routineTtsId); + return; + } + if (memberWithdrawalLock.isLocked(memberId)) { + log.info("[TTS] 회원탈퇴 처리 중이므로 재합성을 중단함. routineTtsId={}", routineTtsId); + return; + } + + previousKey = entity.getS3Url(); + String intro = entity.getTtsIntro(); String done = entity.getTtsDone(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa2387f..5a41fac 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,3 +44,7 @@ member: aws: s3: enabled: ${AWS_S3_ENABLED:false} + +google: + tts: + enabled: ${GOOGLE_TTS_ENABLED:false} \ No newline at end of file From 6e9db94147455be3ece1f556be0ffd161b189e13 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sun, 16 Aug 2026 02:26:08 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix=20:=20CI=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- src/test/resources/application-test.yml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5a41fac..be7983d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,7 +44,7 @@ member: aws: s3: enabled: ${AWS_S3_ENABLED:false} - + google: tts: enabled: ${GOOGLE_TTS_ENABLED:false} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 1942714..025b513 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -17,3 +17,11 @@ jwt: secret: moru-test-jwt-secret-key-must-be-at-least-32-bytes access-token-expiration: 1h refresh-token-expiration: 14d + +google: + tts: + enabled: false + +aws: + s3: + enabled: false \ No newline at end of file From 18ea50474cb2344e5e691d406bdcfdd9d89c52a8 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sun, 16 Aug 2026 02:28:18 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix=20:=20=EC=88=98=EC=A0=95=20=EC=9B=90?= =?UTF-8?q?=EC=83=81=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 3 --- src/test/resources/application-test.yml | 8 -------- 2 files changed, 11 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index be7983d..87e3b29 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,6 +45,3 @@ aws: s3: enabled: ${AWS_S3_ENABLED:false} -google: - tts: - enabled: ${GOOGLE_TTS_ENABLED:false} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 025b513..1942714 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -17,11 +17,3 @@ jwt: secret: moru-test-jwt-secret-key-must-be-at-least-32-bytes access-token-expiration: 1h refresh-token-expiration: 14d - -google: - tts: - enabled: false - -aws: - s3: - enabled: false \ No newline at end of file From 438de36a66d9f247d254f36babd9b51f477d5463 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sun, 16 Aug 2026 14:49:28 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix=20:=20=EB=A6=AC=EB=B7=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/RoutineTTS/TTSAsyncService.java | 17 +++++++++++++---- .../command/RoutineTTS/TTSAsyncServiceTest.java | 5 +++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java index 3907a86..c35bded 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java @@ -12,8 +12,11 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.TransactionTemplate; import java.util.Objects; import java.util.UUID; @@ -32,6 +35,7 @@ public class TTSAsyncService { private final Executor ttsExecutor; private final Executor ttsRegenerateExecutor; private final MemberWithdrawalLock memberWithdrawalLock; + private final TransactionTemplate newTransaction; private static final String CONTENT_TYPE = "audio/mpeg"; public TTSAsyncService( @@ -40,6 +44,7 @@ public TTSAsyncService( RoutineTTSRepository routineTTSRepository, AiClient aiClient, MemberWithdrawalLock memberWithdrawalLock, + PlatformTransactionManager transactionManager, @Qualifier("ttsExecutor") Executor ttsExecutor, @Qualifier("ttsRegenerateExecutor") Executor ttsRegenerateExecutor ) { @@ -50,6 +55,9 @@ public TTSAsyncService( this.memberWithdrawalLock = memberWithdrawalLock; this.ttsExecutor = ttsExecutor; this.ttsRegenerateExecutor = ttsRegenerateExecutor; + + this.newTransaction = new TransactionTemplate(transactionManager); + this.newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -243,10 +251,11 @@ private void deleteQuietly(String key, Long routineTtsId) { private void markFailedQuietly(Long routineTtsId) { try { - routineTTSRepository.findById(routineTtsId).ifPresent(entity -> { - entity.markFailed(); - routineTTSRepository.save(entity); - }); + newTransaction.executeWithoutResult(status -> + routineTTSRepository.findById(routineTtsId).ifPresent(entity -> { + entity.markFailed(); + routineTTSRepository.save(entity); + })); } catch (Exception e) { log.error("[TTS] 실패 상태 기록조차 실패. routineTtsId={}, exceptionType={}", routineTtsId, diff --git a/src/test/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncServiceTest.java b/src/test/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncServiceTest.java index abc99ff..083e5e4 100644 --- a/src/test/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncServiceTest.java +++ b/src/test/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncServiceTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.PlatformTransactionManager; import com.moru.server.domain.member.service.MemberWithdrawalLock; import com.moru.server.domain.routine.entity.RoutineTTS; @@ -38,6 +39,9 @@ class TTSAsyncServiceTest { @Mock private MemberWithdrawalLock memberWithdrawalLock; + @Mock + private PlatformTransactionManager transactionManager; + private TTSAsyncService ttsAsyncService; @BeforeEach @@ -49,6 +53,7 @@ void setUp() { routineTTSRepository, aiClient, memberWithdrawalLock, + transactionManager, directExecutor, directExecutor );