-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
refacator: API 의존성 관리 로직 개선 #910
base: develop
Are you sure you want to change the base?
Changes from 15 commits
0bf56ea
a5563c6
3993eef
f26441f
a3a9a1a
d264ff6
5619da1
f8e0a9f
108b2ab
2e356de
837bf4d
37f9eb8
4edc449
b82d4fc
398963d
fdeb02a
bee05d0
1c40f28
8a25e37
e98e9da
f9b1cac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
package com.ody.common.redis; | ||
|
||
import java.util.Optional; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
import org.springframework.data.redis.core.RedisTemplate; | ||
import org.springframework.data.redis.serializer.RedisSerializer; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class CustomRedisTemplate extends RedisTemplate<String, String> { | ||
|
||
@Autowired | ||
public CustomRedisTemplate(RedisConnectionFactory connectionFactory) { | ||
this.setKeySerializer(RedisSerializer.string()); | ||
this.setValueSerializer(RedisSerializer.string()); | ||
this.setHashKeySerializer(RedisSerializer.string()); | ||
this.setHashValueSerializer(RedisSerializer.string()); | ||
this.setConnectionFactory(connectionFactory); | ||
this.afterPropertiesSet(); | ||
} | ||
|
||
public int increment(String key) { | ||
return Optional.ofNullable(opsForValue().increment(key)) | ||
.map(Long::intValue) | ||
.orElse(0); | ||
} | ||
|
||
public int getKeyCount(String key) { | ||
return Optional.ofNullable(opsForValue().get(key)) | ||
.map(Integer::parseInt) | ||
.orElse(0); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
package com.ody.common.redis; | ||
|
||
import java.time.Duration; | ||
import org.springframework.cache.CacheManager; | ||
import org.springframework.cache.annotation.EnableCaching; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.context.annotation.Profile; | ||
import org.springframework.data.redis.cache.RedisCacheConfiguration; | ||
import org.springframework.data.redis.cache.RedisCacheManager; | ||
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.core.RedisTemplate; | ||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; | ||
import org.springframework.data.redis.serializer.RedisSerializationContext; | ||
import org.springframework.data.redis.serializer.StringRedisSerializer; | ||
|
||
@Profile({"local", "dev", "prod"}) | ||
@Configuration | ||
@EnableCaching | ||
public class RedisConfig { | ||
|
||
@Bean | ||
public RedisConnectionFactory redisConnectionFactory() { | ||
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() | ||
.commandTimeout(Duration.ofSeconds(3)) | ||
.build(); | ||
|
||
return new LettuceConnectionFactory(redisStandaloneConfiguration(), clientConfig); | ||
} | ||
|
||
@Bean | ||
public RedisStandaloneConfiguration redisStandaloneConfiguration() { | ||
return new RedisStandaloneConfiguration(); | ||
} | ||
|
||
@Bean | ||
public RedisTemplate<String, Object> redisTemplate() { | ||
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); | ||
redisTemplate.setConnectionFactory(redisConnectionFactory()); | ||
redisTemplate.setDefaultSerializer(new StringRedisSerializer()); | ||
return redisTemplate; | ||
} | ||
|
||
@Bean | ||
public RedisCacheConfiguration redisCacheConfiguration() { | ||
return RedisCacheConfiguration.defaultCacheConfig() | ||
.serializeKeysWith( | ||
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) | ||
) | ||
.serializeValuesWith( | ||
RedisSerializationContext.SerializationPair.fromSerializer( | ||
new GenericJackson2JsonRedisSerializer() | ||
) | ||
) | ||
.entryTtl(Duration.ofHours(1L)); | ||
} | ||
|
||
@Bean | ||
public CacheManager cacheManager(RedisConnectionFactory cf) { | ||
return RedisCacheManager.RedisCacheManagerBuilder | ||
.fromConnectionFactory(cf) | ||
.cacheDefaults(redisCacheConfiguration()) | ||
.build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
package com.ody.route.domain; | ||
|
||
import com.ody.route.service.RouteClient; | ||
import lombok.RequiredArgsConstructor; | ||
|
||
@RequiredArgsConstructor | ||
public enum RouteClientKey { | ||
|
||
FAIL_KEY("route:fail:%s"), | ||
BLOCK_KEY("route:block:%s"), | ||
; | ||
|
||
private final String value; | ||
|
||
public static String getFailKey(RouteClient routeClient) { | ||
return String.format(FAIL_KEY.value, routeClient.getClass().getSimpleName()); | ||
} | ||
|
||
public static String getBlockKey(RouteClient routeClient) { | ||
return String.format(BLOCK_KEY.value, routeClient.getClass().getSimpleName()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
package com.ody.route.service; | ||
|
||
import com.ody.common.redis.CustomRedisTemplate; | ||
import com.ody.route.domain.RouteClientKey; | ||
import java.time.Duration; | ||
import java.time.LocalDateTime; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Slf4j | ||
@Component | ||
@RequiredArgsConstructor | ||
public class RouteClientCircuitBreaker { | ||
|
||
private static final int MAX_FAIL_COUNT = 3; | ||
public static final Duration FAIL_MINUTES_TTL = Duration.ofMinutes(31); // 지연 시간 고려해 31분으로 설정 | ||
public static final Duration BLOCK_HOUR_TTL = Duration.ofHours(3); | ||
|
||
private final CustomRedisTemplate redisTemplate; | ||
|
||
public void recordFailCountInMinutes(RouteClient routeClient) { | ||
String failClientKey = RouteClientKey.getFailKey(routeClient); | ||
int failCount = redisTemplate.increment(failClientKey); | ||
redisTemplate.expire(failClientKey, FAIL_MINUTES_TTL); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [질문] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 30분 내에 3번의 에러가 발생했을 때에만 격리 시키는 것을 의도했습니다! |
||
log.warn("{} 요청 실패 횟수 : {}", failClientKey, failCount); | ||
} | ||
|
||
public void determineBlock(RouteClient routeClient) { | ||
String failClientKey = RouteClientKey.getFailKey(routeClient); | ||
String blockKey = RouteClientKey.getBlockKey(routeClient); | ||
if (exceedFailCount(failClientKey)) { | ||
block(blockKey); | ||
clearFailCount(failClientKey); | ||
} | ||
} | ||
|
||
private boolean exceedFailCount(String failCountKey) { | ||
return redisTemplate.getKeyCount(failCountKey) >= MAX_FAIL_COUNT; | ||
} | ||
|
||
private void block(String blockKey) { | ||
redisTemplate.opsForValue().set(blockKey, "1"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [제안] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
redisTemplate.expire(blockKey, BLOCK_HOUR_TTL); | ||
log.warn("{}가 차단되었습니다. 해제 예정 시간 : {}", blockKey, LocalDateTime.now().plus(BLOCK_HOUR_TTL)); | ||
} | ||
|
||
private void clearFailCount(String failCountKey) { | ||
redisTemplate.unlink(failCountKey); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
} | ||
|
||
public boolean isBlocked(RouteClient routeClient) { | ||
return Boolean.TRUE.equals(redisTemplate.hasKey(RouteClientKey.getBlockKey(routeClient))); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
package com.ody.route.service; | ||
|
||
import com.ody.common.exception.OdyServerErrorException; | ||
import java.util.List; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Slf4j | ||
@Component | ||
@RequiredArgsConstructor | ||
public class RouteClientManager { | ||
|
||
private final List<RouteClient> routeClients; | ||
private final RouteClientCircuitBreaker routeClientCircuitBreaker; | ||
|
||
public List<RouteClient> getAvailableClients() { | ||
List<RouteClient> availableClients = routeClients.stream() | ||
.filter(this::isAvailable) | ||
.toList(); | ||
|
||
if (availableClients.isEmpty()) { | ||
log.error("모든 RouteClient 차단되었습니다."); | ||
throw new OdyServerErrorException("서버에 장애가 발생했습니다."); | ||
} | ||
return availableClients; | ||
} | ||
|
||
private boolean isAvailable(RouteClient routeClient) { | ||
return !routeClientCircuitBreaker.isBlocked(routeClient); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,38 +15,41 @@ public class RouteService { | |
|
||
private static final long CLOSEST_LOCATION_DURATION = 10L; | ||
|
||
private final List<RouteClient> routeClients; | ||
private final RouteClientManager routeClientManager; | ||
private final ApiCallService apiCallService; | ||
private final RouteClientCircuitBreaker routeClientCircuitBreaker; | ||
|
||
public RouteTime calculateRouteTime(Coordinates origin, Coordinates target) { | ||
for (RouteClient client : routeClients) { | ||
if (isDisabled(client)) { | ||
log.info("{} API 사용이 비활성화되어 건너뜁니다.", client.getClass().getSimpleName()); | ||
List<RouteClient> availableClients = routeClientManager.getAvailableClients(); | ||
for (RouteClient routeClient : availableClients) { | ||
if (isDisabled(routeClient)) { | ||
log.info("{} API 사용이 차단되어 건너뜁니다.", routeClient.getClass().getSimpleName()); | ||
continue; | ||
} | ||
|
||
try { | ||
RouteTime routeTime = calculateTime(client, origin, target); | ||
apiCallService.increaseCountByClientType(client.getClientType()); | ||
log.info("{}를 사용한 소요 시간 계산 성공", client.getClass().getSimpleName()); | ||
RouteTime routeTime = calculateTime(routeClient, origin, target); | ||
apiCallService.increaseCountByClientType(routeClient.getClientType()); | ||
log.info("{} API를 사용한 소요 시간 계산 성공", routeClient.getClass().getSimpleName()); | ||
return routeTime; | ||
} catch (Exception exception) { | ||
log.warn("Route Client 에러 : {} ", client.getClass().getSimpleName(), exception); | ||
log.warn("{} API 에러 발생 : ", routeClient.getClass().getSimpleName(), exception); | ||
routeClientCircuitBreaker.recordFailCountInMinutes(routeClient); | ||
routeClientCircuitBreaker.determineBlock(routeClient); | ||
Comment on lines
+36
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [제안] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재로썬 클라이언트 에러도 카운팅하고 있네요 |
||
} | ||
} | ||
log.error("모든 소요시간 계산 API 사용 불가"); | ||
log.error("모든 RouteClient API 사용 불가"); | ||
throw new OdyServerErrorException("서버에 장애가 발생했습니다."); | ||
} | ||
|
||
private RouteTime calculateTime(RouteClient client, Coordinates origin, Coordinates target) { | ||
RouteTime calculatedRouteTime = client.calculateRouteTime(origin, target); | ||
private boolean isDisabled(RouteClient routeClient) { | ||
return Boolean.FALSE.equals(apiCallService.getEnabledByClientType(routeClient.getClientType())); | ||
} | ||
|
||
private RouteTime calculateTime(RouteClient routeClient, Coordinates origin, Coordinates target) { | ||
RouteTime calculatedRouteTime = routeClient.calculateRouteTime(origin, target); | ||
if (calculatedRouteTime.equals(RouteTime.CLOSEST_EXCEPTION_TIME)) { | ||
return new RouteTime(CLOSEST_LOCATION_DURATION); | ||
} | ||
return calculatedRouteTime; | ||
} | ||
|
||
private boolean isDisabled(RouteClient client) { | ||
return Boolean.FALSE.equals(apiCallService.getEnabledByClientType(client.getClientType())); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,7 @@ | ||
spring: | ||
data: | ||
redis: | ||
host: ody-redis-001.ody-redis.vd1e5e.apn2.cache.amazonaws.com | ||
Comment on lines
+3
to
+4
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [제안] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 엘라스틱캐시의 경우 TLS 접속을 활성화 한 상태에서만 인증 토큰 설정이 가능한데 그래서 로컬 설정만 두려했는데 |
||
jpa: | ||
hibernate: | ||
ddl-auto: validate | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,9 @@ server: | |
shutdown: graceful | ||
|
||
spring: | ||
data: | ||
redis: | ||
port: 6379 | ||
jpa: | ||
open-in-view: false | ||
defer-datasource-initialization: false | ||
|
@@ -56,3 +59,11 @@ api-call: | |
|
||
allowed-origins: | ||
api-call: ENC(QicQ16sDQJlHTuf3E/wIS+VBG0UVfQ62HDBXQbtfM8pmnYsBAH8xmACD7PBVgpLjWAHdJXN6bOKv/Z8BZFFbFyfp5x5f7dNCgok83Cn6jSE=) | ||
|
||
route: | ||
client: | ||
cooldown: | ||
duration: 3600000 # 1시간 | ||
failure: | ||
max-count: 5 # 5회 연속 실패시 쿨다운 | ||
window: 300000 # 5분 내 실패 카운트 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [질문] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 프로퍼티로 관리하려했었는데 지금은 사용하고 있지 않아 제거했습니다! |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package com.ody.common; | ||
|
||
import com.google.firebase.messaging.FirebaseMessaging; | ||
import com.ody.common.redis.CustomRedisTemplate; | ||
import com.ody.notification.config.FcmConfig; | ||
import org.junit.jupiter.api.BeforeEach; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.boot.test.context.SpringBootTest; | ||
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; | ||
import org.springframework.boot.test.mock.mockito.MockBean; | ||
import org.springframework.context.annotation.Import; | ||
|
||
@Import({RedisTestContainersConfig.class, TestRouteConfig.class}) | ||
@SpringBootTest(webEnvironment = WebEnvironment.NONE) | ||
public abstract class BaseRedisTest { | ||
|
||
@Autowired | ||
protected CustomRedisTemplate redisTemplate; | ||
|
||
@MockBean | ||
private FcmConfig fcmConfig; | ||
|
||
@MockBean | ||
private FirebaseMessaging firebaseMessaging; | ||
|
||
@BeforeEach | ||
void init() { | ||
redisTemplate.getConnectionFactory() | ||
.getConnection() | ||
.serverCommands() | ||
.flushAll(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[질문]
value는 Integer 타입이어도 될 것 같은데, String으로 한 이유가 있나요?
increment
,getKeyCount
메서드는 범용적으로 사용되긴 어려울 것 같아요.[제안]
CustomRedisTemplate
보다RouteClientRedisTemplate
처럼 더 구체화 하는건 어떤가요?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redis에 모든 값들이 String으로 저장되어 형변환 없이 String으로 가져온 뒤 원하는 타입으로 변경하도록 의도했습니다!
카운팅, 카운트 조회 기능이 다른 도메인에서도 사용될 여지가 있다고 생각했었는데
각 도메인마다 가져오고 싶은 타입이 다를 수 있으니 구체화해도 좋을것 같네요!