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
8 changes: 6 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ dependencies {
implementation 'org.springframework.session:spring-session-core'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation('org.springframework.boot:spring-boot-starter-data-redis') {
exclude group: 'io.lettuce', module: 'lettuce-core'
}
implementation 'redis.clients:jedis'
implementation 'org.redisson:redisson:3.51.0'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation('org.springframework.ai:spring-ai-bedrock-converse-spring-boot-starter') {
exclude group: 'io.swagger.core.v3', module: 'swagger-annotations'
Expand All @@ -49,7 +53,7 @@ dependencies {
implementation 'com.sksamuel.scrimage:scrimage-webp:4.3.5'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
implementation 'com.bucket4j:bucket4j_jdk17-redis-common:8.15.0'
implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.15.0'
implementation 'com.bucket4j:bucket4j_jdk17-redisson:8.15.0'
implementation 'org.jsoup:jsoup:1.17.2'
implementation 'com.rometools:rome:2.1.0'
compileOnly 'org.projectlombok:lombok'
Expand Down
34 changes: 27 additions & 7 deletions src/main/java/com/linglevel/api/common/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.linglevel.api.common.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisClientConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

Expand All @@ -27,16 +31,16 @@ public class RedisConfig {
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);

LettuceClientConfiguration clientConfig;
JedisClientConfiguration clientConfig;
if (ssl) {
clientConfig = LettuceClientConfiguration.builder()
clientConfig = JedisClientConfiguration.builder()
.useSsl()
.build();
} else {
clientConfig = LettuceClientConfiguration.builder().build();
clientConfig = JedisClientConfiguration.builder().build();
}

return new LettuceConnectionFactory(config, clientConfig);
return new JedisConnectionFactory(config, clientConfig);
}

@Bean
Expand All @@ -52,4 +56,20 @@ public RedisTemplate<String, Object> redisTemplate() {
template.afterPropertiesSet();
return template;
}
}

@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
return container;
}

@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config config = new Config();
String scheme = ssl ? "rediss://" : "redis://";
config.useSingleServer()
.setAddress(scheme + host + ":" + port);
return Redisson.create(config);
}
}
Original file line number Diff line number Diff line change
@@ -1,51 +1,22 @@
package com.linglevel.api.common.ratelimit.bucket4j;

import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.codec.RedisCodec;
import io.lettuce.core.codec.StringCodec;
import org.springframework.beans.factory.annotation.Value;
import io.github.bucket4j.redis.redisson.Bucket4jRedisson;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

/**
* Bucket4j configuration for rate limiting with Redis backend.
*/
@Configuration
public class Bucket4jConfig {

@Value("${spring.data.redis.host}")
private String host;

@Value("${spring.data.redis.port}")
private int port;

@Value("${spring.data.redis.ssl.enabled}")
private boolean ssl;

@Bean
public ProxyManager<String> proxyManager() {
RedisURI.Builder uriBuilder = RedisURI.builder()
.withHost(host)
.withPort(port)
.withTimeout(Duration.ofSeconds(10));

if (ssl) {
uriBuilder.withSsl(true);
}

RedisClient redisClient = RedisClient.create(uriBuilder.build());
StatefulRedisConnection<String, byte[]> connection = redisClient.connect(
RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE)
);

return LettuceBasedProxyManager.builderFor(connection)
public ProxyManager<String> proxyManager(RedissonClient redissonClient) {
Redisson redisson = (Redisson) redissonClient;
return Bucket4jRedisson.casBasedBuilder(redisson.getCommandExecutor())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ public enum WordsErrorCode {
WORD_IS_MEANINGLESS(HttpStatus.BAD_REQUEST, "The word is meaningless."),
WORD_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "Word already exists."),
WORD_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "Word not found with id."),
WORD_ANALYSIS_TIMEOUT(HttpStatus.SERVICE_UNAVAILABLE, "Word analysis is temporarily delayed. Please try again."),
INVALID_WORD_FORMAT(HttpStatus.BAD_REQUEST, "Word contains invalid characters (spaces, tabs, newlines, or special characters are not allowed)."),
WORD_TOO_LONG(HttpStatus.BAD_REQUEST, "Word is too long (maximum 50 characters)."),
SAME_SOURCE_TARGET_LANGUAGE(HttpStatus.BAD_REQUEST, "Source and target languages cannot be the same.");

private final HttpStatus status;
private final String message;
}
}
31 changes: 25 additions & 6 deletions src/main/java/com/linglevel/api/word/service/WordService.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class WordService {
private final WordVariantRepository wordVariantRepository;
private final InvalidWordRepository invalidWordRepository;
private final WordAiService wordAiService;
private final WordSingleFlightRedisCoordinator singleFlightCoordinator;

public WordSearchResponse getOrCreateWords(String userId, String word, LanguageCode targetLanguage) {
List<WordVariant> wordVariants = getOrCreateWordEntities(word, targetLanguage);
Expand All @@ -49,10 +50,21 @@ public WordSearchResponse getOrCreateWords(String userId, String word, LanguageC
log.info("Word '{}' not found for targetLanguage {}, creating new one...",
wordVariant.getOriginalForm(), targetLanguage);

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

// Word 생성 및 저장 (빈 결과는 WordAiService에서 예외 발생)
Word newWord = convertAnalysisResultToWord(analysisResults.get(0));
Expand Down Expand Up @@ -102,7 +114,11 @@ public List<WordVariant> getOrCreateWordEntities(String word, LanguageCode targe
log.info("Word '{}' not found in database. Calling AI to analyze...", word);
List<WordAnalysisResult> analysisResults;
try {
analysisResults = wordAiService.analyzeWord(word, targetLanguage.getCode());
analysisResults = singleFlightCoordinator.execute(
word,
targetLanguage,
() -> wordAiService.analyzeWord(word, targetLanguage.getCode())
);

// AI 호출 성공 시 InvalidWord 캐시에서 제거 (일시적 오류였던 경우 복구)
cachedInvalidWord.ifPresent(invalidWord -> {
Expand All @@ -111,6 +127,9 @@ public List<WordVariant> getOrCreateWordEntities(String word, LanguageCode targe
word, invalidWord.getAttemptCount());
});

} catch (WordSingleFlightTimeoutException | WordSingleFlightLeaderFailureException e) {
log.warn("Single-flight temporary failure for word '{}'. Keeping invalid-word cache untouched.", word, e);
throw new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT);
Comment thread
SolfE marked this conversation as resolved.
} catch (Exception e) {
// AI 호출 실패 또는 무의미한 단어인 경우 InvalidWord로 캐싱
log.warn("AI call failed for word '{}'. Caching as invalid word to prevent retries.", word, e);
Expand Down Expand Up @@ -417,4 +436,4 @@ public WordSearchResponse forceReanalyzeWord(String word, LanguageCode targetLan
.results(results)
.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.linglevel.api.word.service;

public class WordSingleFlightLeaderFailureException extends RuntimeException {

public WordSingleFlightLeaderFailureException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.linglevel.api.word.service;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "word.single-flight")
public class WordSingleFlightProperties {

private boolean enabled = true;

private long lockTtlMs = 20_000;

private long waitTimeoutMs = 5_000;

private long resultTtlMs = 60_000;

private String promptVersion = "v1";

private String model = "default";

private String schemaVersion = "v2";
}
Loading
Loading