현재 매칭과 관련해서 Redis 캐시를 이용할 계획이다.
여러 방식으로 사용함에 따라 이 내용을 글로 정리해본다.
public void addMatchingCandidate(String loginId, String value) {
String key = loginId + "::matchingPartner";
redisTemplate.opsForList().rightPush(key, value);
}
public void createMatching(String requesterId, String receiverId) {
String key1 = requesterId + ":: matching";
String key2 = receiverId + ":: matching";
redisTemplate.opsForList().rightPush(key1, receiverId);
redisTemplate.opsForList().rightPush(key2, requesterId);
}
public List<String> getMatchingCandidate(String loginId) {
String key = loginId + "::matchingPartner";
return redisTemplate.opsForList().range(key, 0, -1);
// 0부터 -1은 리스트의 처음부터 끝까지를 의미
}
매칭 요청을 할 때, 매칭이 성사될 때 이용자들의 로그인 ID를 Redis에 저장한다.
이 코드의 문제는 레디스 장애가 SOF, 단일 장애지점이 될 수 있다는 점이다. Matching이 발생할 때마다 레디스에 요청이 간다고 생각하면 의존성이 상당히 높은 편이다. 흐름은 레디스를 먼저 update하고 db를 업데이트하는 방식이다.
이런 경우는 db에서 조회를 하고 레디스에 저장하는 방식을 쓰는 게 나을 거 같다. 롤백과 관련된 부분까지 신경쓰면 너무 복잡해질 것으로 보여서다.
@CircuitBreaker(name = SIMPLE_CIRCUIT_BREAKER_CONIFG, fallbackMethod = "candidateFallback")
public List<String> getMatchingCandidate(String loginId, int duration, TimeUnit timeUnit) {
String key = loginId + MATCHING_CANDIDATE_REDIS_KEY;
if (redisTemplate.hasKey(key)) {
if (!redisTemplate.opsForValue().get(key).equals(KEYWORD_DUMMY_CACHE)) {
return redisTemplate.opsForList().range(key, 0, -1);
} else {
return Collections.emptyList();
}
}
List<String> matchingCandidate = matchingServiceV1.getMatchingCandidate(loginId);
cacheList(key, matchingCandidate, duration, timeUnit);
return matchingCandidate;
}
public List<String> candidateFallback(String loginId, int duration, TimeUnit timeUnit, Exception ex) {
logError(ex);
return matchingServiceV1.getMatchingCandidate(loginId);
}
@CircuitBreaker(name = SIMPLE_CIRCUIT_BREAKER_CONIFG, fallbackMethod = "partnerFallback")
public List<String> getMatchingList(String loginId, int duration, TimeUnit timeUnit) {
String key = loginId + MATCHING_LIST_REDIS_KEY;
if (redisTemplate.hasKey(key)) {
if (!redisTemplate.opsForValue().get(key).equals(KEYWORD_DUMMY_CACHE)) {
return redisTemplate.opsForList().range(key, 0, -1);
} else {
return Collections.emptyList();
}
}
List<String> matchingPartner = matchingServiceV1.getMatchingPartner(loginId);
cacheList(key, matchingPartner, duration, timeUnit);
return matchingPartner;
}
public List<String> partnerFallback(String loginId, int duration, TimeUnit timeUnit, Exception ex) {
logError(ex);
return matchingServiceV1.getMatchingPartner(loginId);
}
private void cacheList(String key, List<String> values, int duration, TimeUnit timeUnit) {
if (values.isEmpty()) {
redisTemplate.opsForValue().set(key, KEYWORD_DUMMY_CACHE, duration, timeUnit);
} else {
redisTemplate.opsForList().rightPushAll(key, values);
redisTemplate.expire(key, duration, timeUnit);
}
}
레디스를 업데이트 -> DB 업데이트를 하는 구조를 서킷브레이커를 활용하는 방식으로 본경했다.
1)레디스에 key가 있는지 확인한다.
2)key가 없다면 matching 후보와 matching 파트너 모두 db에서 조회한 뒤, key와 함께 value로 Redis에 저장한다.
3)Redis에 저장할 때 value가 emptylist라면 dummy값을 저장해둔다.
4)이렇게 되면 Redis에 키가 있으므로, 더미값인지 실제값인지 확인하고 값을 반환한다.
이 메서드들은 자주 호출되는 것들이 아니다.
매칭 가능한 리스트를 불러올 때 1번씩 호출되는데
매칭 가능 리스트는 10~20개 정도의 프로필을 받는 형식으로 나온다.
이용자당 하루 1~2번만 사용할 법한 API라고 생각한다
(실제로 어떻게 이용될지는 후에 다시 분석해봐야 한다)
그렇기에 Fallback 매커니즘을 DB 연결로 바로 설정해두었다.
Redis는 DB 부하를 줄이기 위해서 사용하는 것이라서
Redis가 다운될 정도로 트래픽이 많은 경우에는
DB로 Fallback을 해버리면 DB도 바로 다운될 수 있다고 한다.
트래픽이 그렇게 많이 예상되지 않아서
혹시 모를 사고에 대비하기 위해서 Fallback을 DB로 연결해두었다.
캐시 패턴은?
캐시는 하루동안 Redis에서 저장해둔다.
그 이유는 다음과 같다.
보통 우리 앱의 이용자는 앱을 오전에 한번 누르고 좀 사용하다가 저녁에 다시 사용할 것으로 예상된다(그 사이에 틈틈이 올 수 있다)
그때마다 새로운 매칭 가능 리스트를 찾으려고 할 것이다.
그렇기에, 그날 처음 Matching 관련 데이터를 Redis에 저장하고
매칭 가능 프로필들 DB에서 조회한다. 그리고, 이 List에서 기존 Matching 관련 데이터를 제외한다.
Matching 관련 데이터는 변동 가능성이 높지 않다. 비용을 지불해야 신청할 수 있는 것이고, 매칭이 성사됐을 때 둘 중 한명이 채팅방을 나가야 Matching이 해제된다.
그렇기에 캐시로서 가치가 있다. 하루에 1~2개가 추가될 것인데, 이미 매칭이 됐거나 매칭 요청을 보낸 상대방이 매칭 가능 리스트에 포함되지 않도록 last profile Id를 Redis에 저장하고 있다.
매칭 가능 프로필을 확인하려고 할 때 last profile Id 이후로 데이터를 보내준다. 그렇기에 아까 매칭 신청을 했는데 또 오늘 매칭 가능 리스트에 포함되네? 이런 문제가 자주 발생하지는 않을 것이라고 생각한다.
이 API들은 추후에 비동기 방식을 쓰면 좀더 성능 개선이 가능할 수 있을 것으로 보인다.
@BeforeEach
void setup() {
// 각 테스트 전에 CircuitBreaker 상태 초기화
circuitBreakerRegistry.circuitBreaker(SIMPLE_CIRCUIT_BREAKER_CONIFG).reset();
userGenerator.generate("fnel123", "123", "1", "연호1");
userGenerator.addImages("연호1", "dd");
userGenerator.generate("fnel1234", "123", "1", "연호2");
userGenerator.addImages("연호2", "dd");
userGenerator.generate("fnel12345", "123", "1", "연호3");
userGenerator.addImages("연호3", "dd");
userGenerator.createPointKey("fnel123", 10);
}
@DisplayName("매칭 후보를 확인할 때 레디스에 문제가 있으면 fallback 매커니즘이 작동한다")
@Test
void test1() {
userGenerator.requestMatching("fnel123", "123", "fnel1234");
userGenerator.requestMatching("fnel123", "123", "fnel12345");
// Given
when(redisTemplate.hasKey(anyString()))
.thenThrow(new RedisConnectionException("Redis Connection Error"));
// When
List<String> result = redisService.getMatchingCandidate(
"fnel123",
10,
TimeUnit.MINUTES
);
// Then
verify(matchingServiceV1).getMatchingCandidate("fnel123");
assertThat(result).containsExactly("fnel1234", "fnel12345");
}
@DisplayName("매칭 리스트를 확인할 때 레디스에 문제가 있으면 fallback 매커니즘이 작동한다")
@Test
void test2() {
userGenerator.createMatching("fnel123", "123", "fnel1234", "123");
userGenerator.createMatching("fnel123", "123", "fnel12345", "123");
// Given
when(redisTemplate.hasKey(anyString()))
.thenThrow(new RedisConnectionException("Redis Connection Error"));
// When
List<String> result = redisService.getMatchingList(
"fnel123",
10,
TimeUnit.MINUTES
);
// Then
verify(matchingServiceV1).getMatchingPartner("fnel123");
assertThat(result).containsExactly("fnel1234", "fnel12345");
}
@DisplayName("매칭 후보 조회 에러가 반복되면 서킷브레이커가 open이 된다.")
@Test
void test3() {
userGenerator.requestMatching("fnel123", "123", "fnel1234");
userGenerator.requestMatching("fnel123", "123", "fnel12345");
// Given
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(SIMPLE_CIRCUIT_BREAKER_CONIFG);
when(redisTemplate.hasKey(anyString()))
.thenThrow(new RedisConnectionException("Redis Connection Error"));
// When: 임계치까지 에러 발생시키기
for (int i = 0; i < 10; i++) { // 설정된 임계치만큼 반복
redisService.getMatchingCandidate("fnel123", 10, TimeUnit.MINUTES);
}
// Then
assertEquals(CircuitBreaker.State.OPEN, circuitBreaker.getState());
verify(matchingServiceV1, atLeast(5)).getMatchingCandidate("fnel123");
}
@DisplayName("매칭 대상 조회 에러가 반복되면 서킷브레이커가 open이 된다.")
@Test
void test4() {
userGenerator.createMatching("fnel123", "123", "fnel1234", "123");
userGenerator.createMatching("fnel123", "123", "fnel12345", "123");
// Given
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(SIMPLE_CIRCUIT_BREAKER_CONIFG);
when(redisTemplate.hasKey(anyString()))
.thenThrow(new RedisConnectionException("Redis Connection Error"));
// When: 임계치까지 에러 발생시키기
for (int i = 0; i < 10; i++) { // 설정된 임계치만큼 반복
redisService.getMatchingList("fnel123", 10, TimeUnit.MINUTES);
}
// Then
assertEquals(CircuitBreaker.State.OPEN, circuitBreaker.getState());
verify(matchingServiceV1, atLeast(5)).getMatchingPartner("fnel123");
}
테스트는 위처럼 Circuit Breaker의 작동과 관련해서 우선 진행했다.
참고로 코드를 짜면서 알게 된건데
fallback 메서드는 기존 메서드의 인자를 모두 받아야 하고,
그리고 Redis에는 emptylist를 value로 저장할 수 없다.
@CircuitBreaker(name = SIMPLE_CIRCUIT_BREAKER_CONIFG, fallbackMethod = "redisError")
public void incrementSkipCount(String key, Long profileId, int countDuration, int profileIdDuration, TimeUnit timeUnit) {
String countKey = key + COUNT_REDIS_KEY;
redisTemplate.opsForValue().increment(countKey);
String profileKey = key + LAST_PROFILE_ID_REDIS_KEY;
redisTemplate.expire(countKey, countDuration, timeUnit);
redisTemplate.opsForValue().set(profileKey, String.valueOf(profileId), profileIdDuration, timeUnit);
}
public void redisError(String key, Long profileId, int countDuration, int profileIdDuration, TimeUnit timeUnit, Exception ex) {
logError(ex);
}
이 메서드는 이용자가 프로필을 skip한 것을 counting할 때 쓴다. Matching Candidate는 매칭이 성사된 게 아니고 매칭 요청만 간 상태다. 매칭 신청을 한 사용자 이후에 skip count가 150번이 넘으면, 해당 사용자에게 다시 매칭 신청을 보낼 수 있도록 하기 위해서 저장한다.
영속화를 할 정도로 중요한 데이터가 아니고, update가 너무 잦다. 우선은 Redis에 저장하고 fallback 매커니즘을 에러 로그만 찍기로 했다. 추후에 로컬 캐시와 연동해서 사용해도 좋을 것으로 보인다.