초기의 상황 설명
- 회사에 Redis 를 이용하는 코드가 있었다.
- 환경은 자바, 스프링 프레임워크 (4.3.30), Lettuce 를 사용했다.
- 레디스 이용시 CPU 나 메모리가 팍팍 튀는 현상이 생겼다.
- 레디스의 코드를 살펴보니 커넥션 풀을 직접 관리하고 있었고, 클라이언트 코드도 정제가 잘 안되어있어 코드의 의도도 알아보기 힘들었다.
- 리팩토링을 결심했다.
기존 레디스 코드 살펴보기
레디스 유틸
// 레디스 유틸 함수쪽 내용
public void nonClusterAppend(String key, String value) throws Exception {
RedisClient client = RedisClient.create(getRedisUri());
client.setOptions(ClientOptions.builder().autoReconnect(true).build());
try (GenericObjectPool<StatefulRedisConnection<String, String>> redisPool
= ConnectionPoolSupport.createGenericObjectPool(client::connect, createPoolConfig());
StatefulRedisConnection<String, String> connection = redisPool.borrowObject()) {
connection.async().append(key, value);
connection.async().expire(key, expire);
} finally {
client.shutdown();
}
}
public String clusterGet(String key) throws Exception {
RedisClusterClient clusterClient = RedisClusterClient.create(getRedisUri());
clusterClient.setOptions(ClusterClientOptions.builder().autoReconnect(true).build());
String retValue;
try (GenericObjectPool<StatefulRedisConnection<String, String>> redisPool
= ConnectionPoolSupport.createGenericObjectPool(clusterClient::connect, createPoolConfig());
StatefulRedisConnection<String, String> connection = redisPool.borrowObject()) {
retValue = connection.sync().get(key);
} finally {
clusterClient.shutdown();
}
return retValue;
}
레디스 클라이언트
// 레디스 클라이언트쪽 내용
private List<ResultListVO> getResultListRedis(String dataId) {
// redis client 생성
deprecatedRedisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO);
// redis key 생성
String key = deprecatedRedisConnectionFactory.createRedisKey(new String[]{dataId, "resultList"});
List<ResultListVO> resultList = new ArrayList<>();
try {
String resultList = deprecatedRedisConnectionFactory.nonClusterGet(key);
if (resultList != null && !resultList.equals("")) {
resultList = objectMapperUtil.specifiedList(resultList, ResultListVO.class);
logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
} else {
String jsonStr = deprecatedRedisConnectionFactory.listMapToJsonString(inter.getListRedis(dataId));
resultList = objectMapperUtil.specifiedList(jsonStr, ResultListVO.class);
deprecatedRedisConnectionFactory.nonClusterSet(key, jsonStr);
logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
}
} catch (RedisConnectionException redisConnectionException) {
logger.error("대략 데이터를 찾는데 실패했다는 로깅");
resultList = inter.getList(dataId);
} catch (Exception exception) {
logger.error("대략 데이터를 찾다가 예외가 발생했다는 로깅");
}
return resultList;
}
private List<ResultListVO> getResultListRedis2(String dataId) {
// redis client 생성
deprecatedRedisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO);
// redis key 생성
String key = deprecatedRedisConnectionFactory.createRedisKey(new String[]{dataId, "resultList"});
List<ResultListVO> resultList = new ArrayList<>();
try {
String resultList = deprecatedRedisConnectionFactory.nonClusterGet(key);
if (resultList != null && !resultList.equals("")) {
resultList = objectMapperUtil.specifiedList(resultList, ResultListVO.class);
logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
} else {
String jsonStr = deprecatedRedisConnectionFactory.listMapToJsonString(inter.getListRedis(dataId));
resultList = objectMapperUtil.specifiedList(jsonStr, ResultListVO.class);
deprecatedRedisConnectionFactory.nonClusterSet(key, jsonStr);
logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
}
} catch (RedisConnectionException redisConnectionException) {
logger.error("대략 데이터를 찾는데 실패했다는 로깅");
resultList = inter.getList(dataId);
} catch (Exception exception) {
logger.error("대략 데이터를 찾다가 예외가 발생했다는 로깅");
}
return resultList;
}
private List<ResultListVO> getResultListRedis3(String dataId) {
// redis client 생성
deprecatedRedisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO);
// redis key 생성
String key = deprecatedRedisConnectionFactory.createRedisKey(new String[]{dataId, "resultList"});
List<ResultListVO> resultList = new ArrayList<>();
try {
String resultList = deprecatedRedisConnectionFactory.nonClusterGet(key);
if (resultList != null && !resultList.equals("")) {
resultList = objectMapperUtil.specifiedList(resultList, ResultListVO.class);
logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
} else {
String jsonStr = deprecatedRedisConnectionFactory.listMapToJsonString(inter.getListRedis(dataId));
resultList = objectMapperUtil.specifiedList(jsonStr, ResultListVO.class);
deprecatedRedisConnectionFactory.nonClusterSet(key, jsonStr);
logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
}
} catch (RedisConnectionException redisConnectionException) {
logger.error("대략 데이터를 찾는데 실패했다는 로깅");
resultList = inter.getList(dataId);
} catch (Exception exception) {
logger.error("대략 데이터를 찾다가 예외가 발생했다는 로깅");
}
return resultList;
}
기존 레디스 코드의 문제점
- DRY 원칙을 위배한다.
- 코드의 중복이 너무 많다.
- SRP 원칙을 위배한다.
- 너무 많은 책임이 존재한다.
- 새 레디스 커넥션을 연결, 키를 생성, JSON 형태로 변환, JSON 형태를 다시 자바 객체로 변환 등...
- 너무 많은 책임이 존재한다.
- 커넥션 풀을 직접 관리한다.
try ... catch ... finally
구문을 직접 이용하여 모듈화가 힘들다.- 거기에
throw Exception
을 던지기 까지해서 호출하는 메서드에서 받아줘야 한다.
- 거기에
- 커넥션 풀 혹은 리소스 정리에 대한 기술적인 관심사에 대한 코드가 대부분이라 실제로 무슨 작업을 하고 싶은건지 알기 어렵다.
- JSON 변환에 Serialize 를 사용하지 않고, 직접 자바 애플리케이션 단에서 수행한다.
- 퍼포먼스상 손해가 발생한다.
- 키를 생성할 때마다
new String[]{dataId, "resultList"}
을 통해 새로운 문자열 배열 객체를 생성한다.
문제 해결 방향성
레디스 클라이언트 헬퍼 클래스를 사용하자
- Redis 연결에서 생길 수 있는 다양한 문제를 해결한 라이브러리를 사용하자.
- 스프링에서 제공하는 RedisTemplate 이 있으니 이를 이용하여 관리하는 방향으로 변경하자.
- 이를 사용함으로써 커넥션 풀과 같은 커넥션 기술적인 코드의 추상화가 가능하다.
- 리소스 정리도 물론 자동으로 가능해진다.
중복 코드는 최대한 없애자
- 중복 코드는 유지보수를 어렵게 하고 전체적으로 코드의 변화를 막는다.
비효율적인 코드는 다시 짜자
- 문자열 배열을 통해 키를 생성하는 부분 등의 코드는 비효율적으로 구성된 것이 보이므로 이런 것들은 하나씩 개편하며 진행해야 할 것 같다.
try ... catch 나 Exception 은 없앨 수 있다면 없애자
Exception
을 잡아서 정상 흐름으로 돌리지 않는 이상은 어차피 에러 메세지 로그만 남기고 끝나므로 별다른 의미가 없다.- 차라리 호출 메서드에서 이런것들을 신경쓸 필요 없도록 없애는 게 낫겠다.
깔끔하게 만들자
- 개발자가 하고 싶은 건 그냥 레디스 키를 이용해서 데이터를 가져오거나 저장하는 것. 데이터가 없으면 추가하는 부분조차도 신경쓰고 싶지 않다.
주요하게 바뀐 코드
레디스 유틸 (기존)
public void nonClusterAppend(String key, String value) throws Exception {
RedisClient client = RedisClient.create(getRedisUri());
client.setOptions(ClientOptions.builder().autoReconnect(true).build());
try (GenericObjectPool<StatefulRedisConnection<String, String>> redisPool
= ConnectionPoolSupport.createGenericObjectPool(client::connect, createPoolConfig());
StatefulRedisConnection<String, String> connection = redisPool.borrowObject()) {
connection.async().append(key, value);
connection.async().expire(key, expire);
} finally {
client.shutdown();
}
}
- 직접 커넥션 풀과 같은 리소스를 관리했다.
- 거기에 템플릿 코드가 일부 결합되어 있어 커넥션 + 템플릿 코드의 역할을 동시에 했기 때문에 SRP 원칙을 위배하고 있다.
- 커넥션은 연결에 대한 풀만 제공하고, 템플릿이 키와 유효기간 만료를 설정하는 것이 좋아보인다.
레디스 유틸 (개편)
@Bean("redisConnectionFactory")
@Primary
public RedisConnectionFactory redisConnectionFactory() {
return getLettuceConnectionFactory(0);
}
@Bean("redisTemplate")
@Primary
public RedisTemplate<?, ?> redisTemplate(@Qualifier("redisConnectionFactory") RedisConnectionFactory redisConnectionFactory) {
return getRedisTemplate(redisConnectionFactory, RedisSerializerType.STRING);
}
LettuceConnectionFactory
에 커넥션을 맺는 역할을 위임했다.- 자바의 NIO 네트워크 프레임워크인 netty 를 통해 구현된 커넥션 관리 도구이다.
RedisTemplate
에게는 템플릿 코드와 관련된 역할을 위임했다.- 템플릿에는
RedisConnectionFactory
를 주입받아서 사용하는데 이전에 만들어두었던LettuceConnectionFactory
를 주입하여 이용하고 있다.
- 템플릿에는
레디스 클라이언트 (기존)
/**
* Redis key 생성
*
* @param params key Value
* @return redis Key
*/
public String createRedisKey(String[] params) {
StringBuilder retKeyVal = new StringBuilder("");
for (String param : params) {
if (!retKeyVal.toString().equals("")) {
retKeyVal.append("_");
}
retKeyVal.append(param);
}
return retKeyVal.toString();
}
private List<ResultListVO> getResultListRedis(String dataId) {
// redis client 생성
deprecatedRedisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO);
// redis key 생성
String key = deprecatedRedisConnectionFactory.createRedisKey(new String[]{dataId, "resultList"});
List<ResultListVO> resultList = new ArrayList<>();
try {
String resultList = deprecatedRedisConnectionFactory.nonClusterGet(key);
if (resultList != null && !resultList.equals("")) {
resultList = objectMapperUtil.specifiedList(resultList, ResultListVO.class);
logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
} else {
String jsonStr = deprecatedRedisConnectionFactory.listMapToJsonString(inter.getListRedis(dataId));
resultList = objectMapperUtil.specifiedList(jsonStr, ResultListVO.class);
deprecatedRedisConnectionFactory.nonClusterSet(key, jsonStr);
logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
}
} catch (RedisConnectionException redisConnectionException) {
logger.error("대략 데이터를 찾는데 실패했다는 로깅");
resultList = inter.getList(dataId);
} catch (Exception exception) {
logger.error("대략 데이터를 찾다가 예외가 발생했다는 로깅");
}
return resultList;
}
- 위의 코드가 각 메서드마다 복붙되어 있었다.
- 위에서 언급한 몇가지 문제가 있다.
- DRY 원칙 위배
- 핵심 로직을 보기 어려움
- 결국 핵심 로직은 레디스에 값이 있으면 레디스의 값을 활용하고 없으면 DB 에서 끌어와 값을 먼저 레디스에 넣고 다음부터는 그 값을 활용하는 것임
- 퍼포먼스 낮음, 가독성 안좋음
- 직렬화를 이용한 방식이 아니라 데이터를 문자열로 가져와서 파싱
- 단순히
_
로 연결된 키를 생성하는데new String()
으로 문자열 객체를 생성하고 있음.
레디스 클라이언트 (개편)
/**
* 레디스 키값을 생성할 때 이용되는 메서드
* 인자로 받은 문자들 사이를 _ 로 이은 단일 문자열을 생성한다.
* @param strings
* @return
*/
public static String joinStringsByUnderscore(String ...strings) {
return String.join("_", strings);
}
/**
* key 문자열을 이용해 레디스에서 값을 가져온다.
* 값이 존재하지 않는다면 defaultValueSupplier 에 있는 값을 가져와 저장한다.
* @param redisTemplate
* @param defaultValueSupplier
* @param keyElems
* @return
* @param <T>
*/
public static <T> T getOrSetIfNotFound(RedisTemplate<String, ?> redisTemplate, Supplier<T> defaultValueSupplier, String ...keyElems) {
String key = joinStringsByUnderscore(keyElems);
@SuppressWarnings("unchecked")
ValueOperations<String, T> ops = (ValueOperations<String, T>) redisTemplate.opsForValue();
T obj = ops.get(key);
if (obj == null || "".equals(obj)) {
obj = defaultValueSupplier.get();
ops.set(key, obj);
}
return obj;
}
public List<Map<String, Object>> getResultList(String id) {
return RedisUtils.getOrSetIfNotFoundSet(
redisTemplate_1_jackson
, () -> inter.getResultList(id)
, id, "field", "v2"
);
}
- 기존에 가지던 문제를 개선
- DRY 원칙을 위배하던 문제
getOrSetIfNotFoundSet
이라는 새로운 레디스 전용 유틸 메서드를 추가하여 기존의 로직 추상화
- SRP 원칙을 위배하던 문제
- 커넥션 관련은
RedisTemplate
이 처리하고 직렬화 등은 미리 만들어진Serializer
에서 처리해준다. - 개발자는 오직 비즈니스 로직만 생각하면 된다.
- 커넥션 관련은
- 핵심 로직이 잘 보이지 않던 문제
- 짧고 간략한 구성과 메서드 이름으로 핵심 로직을 파악하기 쉽도록 수정
- 퍼포먼스 문제
String
객체를 따로 생성하지 않아도 되도록 변경
- 기존 코드 가독성 문제
- 의미 있는 네이밍을 주어 개선
- DRY 원칙을 위배하던 문제
- 기존의 인터페이스 훼손하지 않는 식으로 1차적 리팩토링
- 이후 2차 리팩토링이 필요할 수 있음
- 두번째 인자인
defaultValueSupplier
에는 다양한 값이 등장할 수 있음- 무엇이든 기본값으로 정하고 싶은 값이 오면 됨
변경된 레디스 클라이언트 사용법
DB 번호에 맞는 컴포넌트를 @RedisTemplate_n_serializer
애노테이션으로 끌고오기
@RedisTemplate_n_serializer
애노테이션으로 해당 DB 의 번호를 담당하는RedisTemplate
객체를 주입받을 수 있다.
@RedisTemplate_1_jackson
RedisTemplate<String, Object> redisTemplate_1_jackson;
- 기존 코드처럼 객체를 생성할 필요가 없다.
- 커넥션은 thread-safe 하므로 사실 매번 새로운 객체를 생성할 필요가 없다.
- 메모리나 CPU 같은 리소스의 낭비가 덜하다.
DeprecatedRedisConnectionFactory redisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO)
Case1: RedisTemplate
에서 제공하는 기본 동작 이용하기
RedisTemplate
에서 제공하는 기본 동작을 이용해도 된다.
redisTemplate_1_jackson.opsForValue().set("key", "value");
Case2: RedisUtils
를 이용한 동작 이용하기
redisTemplate
를 인자로 이용하면 된다.
RedisUtils.<Map<String, Object>>getOrSetIfNotFoundSet(
redisTemplate_1_jackson
, () -> inter.getAuthorDetail(ancId)
, ancId, "authorInfo", "v2"
);
Case3: RedisTaskComponent
를 이용한 동작 이용하기
- 여러 컴포넌트에서 재활용될 수 있는 레디스 공통로직을 작성해두고 재활용하면 좋다.
- 범용적으로 사용되는 유틸 개념과는 다르게 명확한 비즈니스가 있을 때 유용하다.
- 아래는 데이터 이용수 실시간 조회에 대한 비즈니스 로직을 구현해둔 것이다.
@Component
public class RedisTaskComponent {
@RedisTemplate_11_integer
private RedisTemplate<String, Object> redisTemplate_11_integer;
public int getOrSetNodeUsedCount(String nodeId, int value) {
return RedisUtils.getOrSetIfNotFound(
redisTemplate_11_integer,
() -> value,
nodeId
);
}
public void incrementUsedCount(String nodeId) {
int count = getOrSetNodeUsedCount(nodeId, 0);
redisTemplate_11_integer.opsForValue().set(nodeId, count + 1);
}
}
반응형