Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.linglevel.api.word.service;
package com.linglevel.api.word.config;

import lombok.Getter;
import lombok.Setter;
Expand All @@ -15,11 +15,5 @@ public class WordSingleFlightProperties {

private long waitTimeoutMs = 5_000;

private long resultTtlMs = 60_000;

private String promptVersion = "v1";

private String model = "default";

private String schemaVersion = "v2";
private String resultSchemaVersion = "v2";
}
144 changes: 84 additions & 60 deletions src/main/java/com/linglevel/api/word/service/WordService.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,27 +50,22 @@ public WordSearchResponse getOrCreateWords(String userId, String word, LanguageC
log.info("Word '{}' not found for targetLanguage {}, creating new one...",
wordVariant.getOriginalForm(), targetLanguage);

List<WordAnalysisResult> analysisResults;
try {
analysisResults = singleFlightCoordinator.execute(
wordVariant.getOriginalForm(),
targetLanguage,
() -> wordAiService.analyzeWord(
return singleFlightCoordinator.execute(
wordVariant.getOriginalForm(),
targetLanguage,
() -> {
List<WordAnalysisResult> analysisResults = wordAiService.analyzeWord(
wordVariant.getOriginalForm(),
targetLanguage.getCode()
)
);
} catch (WordSingleFlightTimeoutException e) {
log.warn("Single-flight temporary failure for originalForm '{}'. Returning timeout error.",
wordVariant.getOriginalForm(), e);
throw new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT);
} catch (WordSingleFlightLeaderFailureException e) {
throw mapLeaderFailure(wordVariant.getOriginalForm(), e, false);
}

// Word 생성 및 저장 (빈 결과는 WordAiService에서 예외 발생)
Word newWord = convertAnalysisResultToWord(analysisResults.get(0));
return wordRepository.save(newWord);
);
Word newWord = convertAnalysisResultToWord(analysisResults.get(0));
return wordRepository.save(newWord);
},
() -> wordRepository.findByWordAndTargetLanguageCode(
wordVariant.getOriginalForm(),
targetLanguage
)
);
});

boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, wordVariant.getOriginalForm());
Expand Down Expand Up @@ -102,6 +97,9 @@ public List<WordVariant> getOrCreateWordEntities(String word, LanguageCode targe

// 2. InvalidWord 캐시 확인 - 3회 유예 후 차단
Optional<InvalidWord> cachedInvalidWord = invalidWordRepository.findByWord(word);
int invalidAttemptCountBeforeSingleFlight = cachedInvalidWord
.map(InvalidWord::getAttemptCount)
.orElse(0);
if (cachedInvalidWord.isPresent()) {
InvalidWord invalidWord = cachedInvalidWord.get();
if (invalidWord.getAttemptCount() >= 3) {
Expand All @@ -112,43 +110,82 @@ public List<WordVariant> getOrCreateWordEntities(String word, LanguageCode targe
word, invalidWord.getAttemptCount(), invalidWord.getAttemptCount() + 1);
}

// 3. DB에 없으면 AI 호출 (실패 시에도 InvalidWord로 캐싱)
// 3. DB에 없으면 AI 호출 (AI 분석 실패 시에만 InvalidWord로 캐싱)
log.info("Word '{}' not found in database. Calling AI to analyze...", word);
List<WordAnalysisResult> analysisResults;
try {
analysisResults = singleFlightCoordinator.execute(
word,
targetLanguage,
() -> wordAiService.analyzeWord(word, targetLanguage.getCode())
);
return singleFlightCoordinator.execute(
word,
targetLanguage,
() -> {
List<WordAnalysisResult> analysisResults = analyzeWordAndUpdateInvalidCache(
word,
targetLanguage,
cachedInvalidWord
);

// AI 호출 성공 시 InvalidWord 캐시에서 제거 (일시적 오류였던 경우 복구)
cachedInvalidWord.ifPresent(invalidWord -> {
invalidWordRepository.delete(invalidWord);
log.info("Removed word '{}' from invalid word cache after successful AI analysis (was attempt {}/3)",
word, invalidWord.getAttemptCount());
});
List<WordVariant> savedVariants = new ArrayList<>();
for (WordAnalysisResult analysisResult : analysisResults) {
WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult);
savedVariants.add(savedVariant);
}

return savedVariants;
},
() -> findWordVariantsAfterSingleFlight(word, invalidAttemptCountBeforeSingleFlight)
);
}

} catch (WordSingleFlightTimeoutException e) {
log.warn("Single-flight temporary failure for word '{}'. Keeping invalid-word cache untouched.", word, e);
throw new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT);
} catch (WordSingleFlightLeaderFailureException e) {
throw mapLeaderFailure(word, e, true);
} catch (Exception e) {
// AI 호출 실패 또는 무의미한 단어인 경우 InvalidWord로 캐싱
private Optional<List<WordVariant>> findWordVariantsAfterSingleFlight(
String word,
int invalidAttemptCountBeforeSingleFlight
) {
List<WordVariant> existingVariants = wordVariantRepository.findAllByWord(word);
if (!existingVariants.isEmpty()) {
return Optional.of(existingVariants);
}

Optional<InvalidWord> currentInvalidWord = invalidWordRepository.findByWord(word);
if (currentInvalidWord.isPresent()) {
int currentAttemptCount = currentInvalidWord.get().getAttemptCount();

if (currentAttemptCount >= 3 || currentAttemptCount > invalidAttemptCountBeforeSingleFlight) {
throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS);
}
}

return Optional.empty();
}

private void cacheInvalidWordIfMeaningless(String word, WordsException e) {
if (e.getErrorCode() == WordsErrorCode.WORD_IS_MEANINGLESS) {
log.warn("AI classified word '{}' as meaningless. Updating invalid-word cache.", word, e);
saveInvalidWord(word);
}
}

private List<WordAnalysisResult> analyzeWordAndUpdateInvalidCache(
String word,
LanguageCode targetLanguage,
Optional<InvalidWord> cachedInvalidWord
) {
List<WordAnalysisResult> analysisResults;
try {
analysisResults = wordAiService.analyzeWord(word, targetLanguage.getCode());
} catch (WordsException e) {
cacheInvalidWordIfMeaningless(word, e);
throw e;
} catch (RuntimeException e) {
log.warn("AI call failed for word '{}'. Caching as invalid word to prevent retries.", word, e);
saveInvalidWord(word);
throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS);
}

// 4. 트랜잭션 내에서 DB 저장 처리
List<WordVariant> savedVariants = new ArrayList<>();
for (WordAnalysisResult analysisResult : analysisResults) {
WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult);
savedVariants.add(savedVariant);
}
cachedInvalidWord.ifPresent(invalidWord -> {
invalidWordRepository.delete(invalidWord);
log.info("Removed word '{}' from invalid word cache after successful AI analysis (was attempt {}/3)",
word, invalidWord.getAttemptCount());
});

return savedVariants;
return analysisResults;
}


Expand Down Expand Up @@ -352,19 +389,6 @@ private WordResponse convertToResponse(Word word, boolean isBookmarked, List<Var
.build();
}

private WordsException mapLeaderFailure(String word, WordSingleFlightLeaderFailureException e, boolean cacheInvalidWord) {
if (e.getLeaderErrorCode() == WordsErrorCode.WORD_IS_MEANINGLESS) {
log.warn("Single-flight leader classified word '{}' as meaningless.", word, e);
if (cacheInvalidWord) {
saveInvalidWord(word);
}
return new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS);
}

log.warn("Single-flight temporary failure for word '{}'. Keeping invalid-word cache untouched.", word, e);
return new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT);
}

/**
* 관리자 전용: 단어를 AI로 강제 재분석
*
Expand Down

This file was deleted.

Loading
Loading