Skip to content
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

Open
wants to merge 21 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0bf56ea
feat: Redis 설정 추가
hyeon0208 Nov 13, 2024
a5563c6
refactor: 서킷 브레이커 패턴을 적용해 RouteClient 관리
hyeon0208 Nov 13, 2024
3993eef
test: Redis 테스트 컨테이너 설정 추가
hyeon0208 Nov 13, 2024
f26441f
refactor: 범위 내 비트 개수 반환 로직 수정
hyeon0208 Nov 13, 2024
a3a9a1a
test: CustomRedisTemplate 테스트 추가
hyeon0208 Nov 13, 2024
d264ff6
refactor: 서킷브레이커 클래스명 수정
hyeon0208 Nov 13, 2024
5619da1
test: 레디스 테스트를 공통화한 추상 클래스 추가
hyeon0208 Nov 14, 2024
f8e0a9f
refactor: bit 자료형 대신 String 자료형으로 카운팅하도록 수정
hyeon0208 Nov 14, 2024
108b2ab
test: RouteClientCircuitBreaker 테스트 추가
hyeon0208 Nov 14, 2024
2e356de
test: RouteClientManager 테스트 추가
hyeon0208 Nov 14, 2024
837bf4d
refactor: 로깅 레벨 수정
hyeon0208 Nov 14, 2024
37f9eb8
test: redisTemplate 초기화 로직 개행 추가
hyeon0208 Nov 14, 2024
4edc449
chore: dev, prod elasticache 주소 추가
hyeon0208 Nov 15, 2024
b82d4fc
refactor: 보안을 위한 ssl 연결 설정 추가
hyeon0208 Nov 15, 2024
398963d
refactor: 로컬을 고려한 ssl 연결 설정 제거
hyeon0208 Nov 15, 2024
fdeb02a
chore: 미사용 설정 제거
hyeon0208 Nov 19, 2024
bee05d0
refactor: block 키 값 상수화
hyeon0208 Nov 19, 2024
1c40f28
refactor: RouteClientRedisTemplate로 클래스명 구체화 및 패키지 이동
hyeon0208 Nov 19, 2024
8a25e37
refactor: api 카운팅 범위 수정
hyeon0208 Nov 19, 2024
e98e9da
test: 0비교를 isZero()로 검증
hyeon0208 Nov 19, 2024
f9b1cac
test: 변경된 패키지에 맞춰 RouteClientRedisTemplateTest 패키지 위치 수정
hyeon0208 Nov 19, 2024
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
4 changes: 4 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,15 @@ dependencies {
implementation 'org.flywaydb:flyway-mysql'
implementation 'org.flywaydb:flyway-core'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.testcontainers:testcontainers-bom:1.20.2'

runtimeOnly 'com.mysql:mysql-connector-j:8.4.0'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:5.3.1'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:testcontainers'

testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testRuntimeOnly 'com.h2database:h2'
Expand Down
14 changes: 14 additions & 0 deletions backend/docker-compose-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,17 @@ services:
MYSQL_ROOT_PASSWORD: 1234
MYSQL_DATABASE: ody
TZ: Asia/seoul

redis:
image: redis:7.4.1-alpine3.20
container_name: redis-local
restart: always
ports:
- "6379:6379"
command: >
redis-server
--save ""
--appendonly yes
--auto-aof-rewrite-percentage 0
environment:
TZ: Asia/Seoul
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> {
Copy link
Contributor

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 처럼 더 구체화 하는건 어떤가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis에 모든 값들이 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);
}
}
68 changes: 68 additions & 0 deletions backend/src/main/java/com/ody/common/redis/RedisConfig.java
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();
}
}
22 changes: 22 additions & 0 deletions backend/src/main/java/com/ody/route/domain/RouteClientKey.java
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[질문]
실패 카운트가 될 때마다, FAIL TTL이 갱신되는 구조로 보여요. 매번 갱신하는 이유가 있을까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[제안]
1도 상수로 빼면 더 좋을 것 같아요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCK으로 상수화했습니다!

redisTemplate.expire(blockKey, BLOCK_HOUR_TTL);
log.warn("{}가 차단되었습니다. 해제 예정 시간 : {}", blockKey, LocalDateTime.now().plus(BLOCK_HOUR_TTL));
}

private void clearFailCount(String failCountKey) {
redisTemplate.unlink(failCountKey);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unlink 이런것도 있었네요! delete와 달리 비동기로 삭제되군요

}

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);
}
}
35 changes: 19 additions & 16 deletions backend/src/main/java/com/ody/route/service/RouteService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[제안]
서버 에러인 경우만 실패 카운트를 기록하는 게 더 좋을 것 같아요.
해당 스코프로 들어오는게 클라이언트 에러가 될 가능성도 있습니다. (ex. Dto에서 수도권 검증에 통과한 위경도 좌표가, 오디세이에서는 지원하지 않는 좌표일 수 있음)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재로썬 클라이언트 에러도 카운팅하고 있네요
외부 API 응답에 장애가 발생했을 때 던지는 에러인 OdyServerErrorException를 잡아 카운팅하도록 수정했습니다!

}
}
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()));
}
}
3 changes: 3 additions & 0 deletions backend/src/main/resources/application-dev.yml
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[제안]
레디스도 비밀번호 설정 해주면 어떨까요? 예전 프로젝트에서 비밀번호 미설정으로 레디스 해킹당한 적이 있습니다ㅎㅎ

Copy link
Contributor Author

@hyeon0208 hyeon0208 Nov 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엘라스틱캐시의 경우 TLS 접속을 활성화 한 상태에서만 인증 토큰 설정이 가능한데
현재는 내부망으로 안전해보여요
마이그레이션 시 AWS의 ElastiCache를 사용한다면 TLS 연결 설정을 적용하고 토큰을 설정하는 것이 좋아보이고
docker로 띄울 것이라면 local과 같은 방식으로 수정하면 좋을 것 같은데
ElastiCache를 띄울 의향은 없었던 것 같아요

그래서 로컬 설정만 두려했는데
비밀번호 설정 코드가 난잡해져서 마이그레이션 이후 설정을 적용하면 좋을 것 같습니다!

jpa:
hibernate:
ddl-auto: validate
Expand Down
3 changes: 3 additions & 0 deletions backend/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
spring:
data:
redis:
host: localhost
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:53306/ody
Expand Down
3 changes: 3 additions & 0 deletions backend/src/main/resources/application-prod.yml
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
jpa:
hibernate:
ddl-auto: validate
Expand Down
11 changes: 11 additions & 0 deletions backend/src/main/resources/common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ server:
shutdown: graceful

spring:
data:
redis:
port: 6379
jpa:
open-in-view: false
defer-datasource-initialization: false
Expand Down Expand Up @@ -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분 내 실패 카운트
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[질문]
사용되고 있는 부분이 없어 보여요. 이건 어디에서 사용하고 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프로퍼티로 관리하려했었는데 지금은 사용하고 있지 않아 제거했습니다!

33 changes: 33 additions & 0 deletions backend/src/test/java/com/ody/common/BaseRedisTest.java
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();
}
}
Loading