프로덕션 환경 및 주요 라이브러리
- 윈도우즈 서버
- MSSQL
- Spring Framework 4.3.30.RELEASE
- Maven 리포지토리
- Java 1.8
- Redis 5.0.7
- Elastic Search 7.17
- Spring Session Data Redis 1.3.1.RELEASE
- Lettuce 6.1.4.RELEASE
- Mybatis
환경만 봐도 꽤 레거시 서비스이다.
히스토리와 개선 필요성
SI 업체에 의해 서비스가 만들어짐
- 서비스 자체가 3년이 안됐는데 스프링 4버전을 채택하는 등 기술 선택에 대한 미스가 많았다.
- '오래된 기술은 나쁘다.' 라는 단순한 이유가 아니라 기술 선택에 대한 이유 없이 그냥 쓴 것이 잘못이라고 생각한다.
- 초기엔 이용량이 적었기 때문에 인덱싱이나 쿼리 속도에 대한 고민 없이 만들어진 쿼리들이 많았다.
- 쿼리에서 많은 병목이 일어났다.
초기 Redis 도입
- 위에 언급했듯 쿼리에서 많은 병목이 있었고 이 때문에 접속자가 많을 때 DB 의 부하가 너무 많아서 서버가 자주 다운되었다.
- DB 의 부하를 줄이기 위해 인프라에 Redis 를 도입했다.
- 약 2년간 우여곡절 끝에 많은 쿼리 개선이 성공했고 Redis 도 어느정도 자리를 잡았다.
Redis 도입 이후 생긴 문제
- Redis 를 도입한 이후로 DB 서버의 부하는 성공적으로 줄였으나 애플리케이션 서버의 CPU 사용률이 갑자기 높아지거나 메모리 사용률이 갑자기 높아지는 문제가 발생했다.
- 그 외에 네트워크 I/O 를 너무 과도하게 사용하는 문제도 발견됐다.
- 이 문제는 사용자가 많을 때 더 심해졌다.
이후 방향성
- 임시방편으로 서버의 리소스를 늘려놨지만 굳이 이러한 일로 서버비를 늘릴 필요성은 없으므로 해결하고 가는 것이 맞다고 봤다.
- 내버려두면 이용자가 더 증가할수록 그에 비례해 기하급수적으로 필요한 리소스가 더 늘어날 것이다.
- 톰캣의 서버도 정상적으로 종료되지 않았다.
- 일정 시간이 지난 이후 Timeout 에 의해 서버가 강제로 종료되긴 했지만, 소위 표현하는 'graceful shutdown' 이 되지 않았다.
이전의 코드
public class RedisConnectionFactory {
private final String redisServerIp;
private final int redisServerPort;
private final char[] redisServerPasswd;
private final int redisServerDatabase;
private final String redisServerTimeout;
private final int expire;
public RedisConnectionFactory(int database) {
this.redisServerIp = EgovProperties.getProperty("REDIS.connection.serverIp");
this.redisServerPort = Integer.parseInt(EgovProperties.getProperty("REDIS.connection.port"));
this.redisServerPasswd = convertStringToCharArr(EgovProperties.getProperty("REDIS.connection.passwd"));
this.redisServerDatabase = database;
this.redisServerTimeout = EgovProperties.getProperty("REDIS.connection.timeout");
this.expire = Integer.parseInt(EgovProperties.getProperty("REDIS.connection.expire"));
}
public String nonClusterGet(String key) throws Exception {
String retValue;
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()) {
retValue = connection.sync().get(key);
} finally {
client.shutdown();
}
return retValue;
}
// 기타 레디스 오퍼레이션들... Set 등...
}
이전 코드의 문제점
매번 인스턴스를 생성해야 함
- 클라이언트 코드를 보면, 매번 메서드 내부에서
RedisConectionFactory
라는 인스턴스를 생성해서 쓰고 있었다.- 인스턴스를 생성한 이후에 메서드를 이용하면, 매번 커넥션 풀을 생성했다가 삭제한다.
- 이 과정에서 통신 오버헤드가 발생하고 동시접속자가 많은 경우, 네트워크 I/O 부하와 CPU, 메모리 부하가 심해졌다.
- 이 때문에 서버 성능이 조금 더 필요해지고 서버비가 조금 더 나오게 되었다.
직렬화와 관련된 코드가 비즈니스 로직에 포함됨
- 직렬화도 직접하므로 직렬화/역직렬화에 대한 코드 작성 비용도 필요하고 이게 로직속으로 들어가서 코드를 이해하기도 더 어려웠다.
- 모든 데이터 타입을
String
으로 변환해서 넣어야 하고, 뺄 때도String
에서 다시 변환시켜야 한다.- 이 과정에서 실수로 에러를 만들어내는 일도 있을 수 있다고 생각했다.
- 모든 데이터 타입을
네트워크 I/O 를 과도하게 많이 사용함
- 커넥션이 과도하게 많이 맺어지고 있어서 동시접속자가 많은 경우 많은 양의 포트 (약 5000개 이상의 포트) 를 이용했다.
- 커넥션이 맺었다 끊어졌다 하면서 포트가 TIME WAIT 상태로 계속 대기중이었다.
- 간혹가다 이용자가 많을 때는 포트가 부족하다는 메세지를 받기도 했다.
예외처리를 꼭 해주어야 했음
RedisConectionFactory
을 사용하는 경우 모든 메서드 내에서Exception
을 던져버리기 때문에Exception
을 매번 다뤄주어야 하는 부담이 있다.- 매번
try ... catch
나Exception
을 던져주어야 했다. - 보통
Exception
에서의 논리적인 흐름은 그냥Redis
연결이 실패했을 때 DB 로 연결해주는 것이었다.
- 매번
코드 중복의 문제
- 위와 같은 클라이언트 코드가 대략 메서드 30 곳 정도에 복붙되어 있었다.
- 공통 로직이 변해야 할 일이 있다면 모든 곳을 고쳐야 할 것이다.
개선 방향성
- 위에 언급된 문제를 모두 해결하자.
RedisTemplate
이용
RedisTemplate
은Spring Data Redis
라이브러리에서 제공하는 클래스이다.- 이 클래스 하나만으로 위에서 언급한 거의 모든 문제를 해결할 수 있다.
thread-safe
한 클래스로 멀티 스레딩 환경에서 걱정없이 이용할 수 있으므로 매번 인스턴스 생성을 해주지 않아도 된다.- 자체 제공
Serailizer
를 통해서 직렬화/역직렬화를 지원하여 직렬화 문제가 비즈니스 코드를 침범하는 것을 막을 수 있다. - 커넥션 풀 이용을 권장하고
Spring Data Redis
에서는 손쉽게 커넥션 풀에 있는 커넥션을 꺼내올 수 있도록 지원해준다.- 네트워크 I/O 에 대한 부담이 덜어진다.
- 자체적인 예외가 있어서 예외 처리에 대한 부담이 줄어든다.
- 아래는
RedisTemplate
클래스의 설명 주석이다.
Helper class that simplifies Redis data access code.
Performs automatic serialization/deserialization between the given objects and the underlying binary data in the Redis store. By default, it uses Java serialization for its objects (through JdkSerializationRedisSerializer ). For String intensive operations consider the dedicated StringRedisTemplate.
The central method is execute, supporting Redis access code implementing the RedisCallback interface. It provides RedisConnection handling such that neither the RedisCallback implementation nor the calling code needs to explicitly care about retrieving/closing Redis connections, or handling Connection lifecycle exceptions. For typical single step actions, there are various convenience methods.
Once configured, this class is thread-safe.
Note that while the template is generified, it is up to the serializers/deserializers to properly convert the given Objects to and from binary data.
This is the central class in Redis support.
유틸 코드 작성
- 어떻게 하면 팀원들이 레디스 관련 코드를 쉽게 작성할 수 있을까에 대한 고민을 많이 했다.
- 현재 회사는 Redis 를 Standalone 으로 사용 중이고 DB 번호를 나눠서 사용 중이다.
- 주워듣기론 일반적인 회사에서는 클러스터링을 이용하며, DB 번호는 한 번호만 사용한다고 들었다.
- 이 사실 때문에 혹시나 동시성 문제가 일어날까 어쩔 수 없이 각 DB 번호별 전용
ConnectionFactory
를 생성했다.- 해당 커넥션이 DB 1번에 연결되어 있고 아직 작업이 안 끝났는데, 다른 스레드가 풀에서 해당 커넥션을 가져가서 DB 번호를 바꾸는 일이 생길까 두려웠다.
public static <T> T getOrSetIfNotFound(RedisTemplate<String, ?> redisTemplate, Supplier<T> defaultValueSupplier, String ...keyElems) {
try {
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, 3, TimeUnit.HOURS);
}
return obj;
} catch(Exception e) {
// 예외 발생 시에 기본 값 지원
log.error("REDIS UTILS ERROR (레디스 유틸 에러): {}", e.getMessage());
e.printStackTrace();
return defaultValueSupplier.get();
}
}
- 위와 같은 공통 로직 메서드를 하나 추가했다.
- 기존 레거시에서 사용하는 부분을 전부 대체하고 싶어
static
메서드로 만들었는데, 조금 후회한다. - 컴포넌트 하나를 새로 파서 그냥 거기에
private
메서드로 만들고, 비즈니스 로직을 금방 알아볼 수 있는 이름의 개별 메서드를 구성했으면 어떨까 싶다.- 그럼 Redis 공통 비즈니스 로직을 처리하기도 유용하고, 기술적인 부분을 더 드러내지 않아도 됐을 것 같다.
- 기존 레거시에서 사용하는 부분을 전부 대체하고 싶어
클라이언트 코드 부분 변경
@RedisTemplate_1_jackson
RedisTemplate<String, Object> redisTemplate_1_jackson;
- 각 DB 별로 저장될 데이터의 형태를 분리해놨기 때문에
@RedisTemplate_1_jackson
과 같이 저장될 디비 번호, 데이터 형을 표기하여 애노테이션을 구성해두었다. - 내부적으로
@Qualifier
와@Autowire
가 있어서 디비 번호에 맞는RedisTemplate
을 불러올 수 있게 구성했다.
클라이언트 코드 변화 체감
변경 전
public Map<String, Object> getDetail(String dataId) {
redisConnectionFactory = new RedisConnectionFactory(redisUtil.DB_ONE, getRedisResetTime());
String redisInfoKey = redisConnectionFactory.createRedisKey(new String[]{dataId, "redisInfo"});
Map<String, Object> redisInfo = null;
try {
String redisResult = redisConnectionFactory.nonClusterGet(redisInfoKey);
if (redisResult != null && !redisResult.equals("")) {
redisInfo = redisConnectionFactory.jsonToMap(redisResult);
} else {
redisInfo = dbService.getDetail(dataId);
redisConnectionFactory.nonClusterSet(redisInfoKey, redisConnectionFactory.mapToJsonString(redisInfo));
}
} catch (RedisConnectionException redisConnectionException) {
logger.error("Failed to connection to Redis server : " + redisConnectionException.getMessage());
redisInfo = dbService.getDetail(dataId);
} catch (Exception exception) {
logger.error("dbService.getDetail Exception : " + exception);
}
return authorInfo;
}
변경 후
public Map<String, Object> getDetail(String dataId) {
return RedisUtils.getOrSetIfNotFound(
redisTemplate_1_jackson
, () -> dbService.getDetail(dataId)
, dataId, "redisInfo"
);
}
- 짧다고 무조건 좋은 것은 아니지만 알아보기 쉽게 짧아졌다.
Exception
이 일어날 시 디비에서 기본 값을 가져오는 부분을 공통화해서 에러의 여지를 줄였다.
개선 코드 적용 이후
- 잘 적용될 줄 알았던 코드가 적용 이후에 아주 많은 문제가 발생했다.
- 테스트 상에선 기능상 문제가 없었으나, 트래픽을 받은 이후 몇시간이 지나면 서버가 터져버리는 아주 심각한 문제가 발생했다.
- 로드 애버리지가 엄청나게 올라가서 서버가 폭발해버렸다.
- 프로젝트 내부의 자바 파일을 한 20~30개쯤 바꾸고 너무나 많은 메서드를 이미 바꿔버려서 롤백하기도 좀 뭐하고 롤백한다고 문제가 해결되는 것이 아니라 이전의 문제로 돌아가는 것이기 때문에 이 문제를 꼭 해결해야만 했다.
문제점 파악
- 무엇이 문제인지 진단하는 것이 우선이기 때문에 문제점을 파악해야 했다.
- 먼저 전략을 수립해야 했는데 전략은 외부 상황에서 접근하는 전략과 내부 상황에서 접근하는 전략이 있다고 생각했다.
- (공식 용어는 아니고 제가 지어낸겁니다..)
외부 상황에서 접근하기
- 직접 서비스에서 장애 재연하기
- 네트워크 I/O 모니터링
- 서버 PC 의 커넥션 이용정보 관찰
내부 상황에서 접근하기
- 내부 소스코드의 변화사항 파악하며 일일이 확인
- 스레드 덤프
- 메모리 덤프
이 방법을 선택했다.
다만 소스코드를 살펴보는 것은 작업한 프로젝트 2개가 한번에 코드로 머지됐으므로 모든 커밋내역을 보기엔 조금 힘들다고 생각했다.
문제 해결 및 정리
- 어찌저찌 모든 문제를 진단하고 해결했는데 해결한 문제를 정리하자면 아래와 같다.
겪었던 문제 1: 라이브러리 호환성 이슈 때문에 커넥션 풀링이 제대로 되지 않은 이슈
- 분명 lettuce 6.1.4 를 이용한 커넥션 풀링 Configuration 을 이용했는데 네트워크 커넥션을 살펴봤을 때는 전혀 풀링이 이루어지지 않는 것처럼 보였다.
- 왜일까? 한참을 고민하며 이코드 저코드 고치다가 lettuce 6.1.4 는 2021 년에 등장한 나름(?) 최신 라이브러리고 현재 이용하는 spring-session-data-redis 1.3.1.RELEASE 는 2017년 쯤 나온 라이브러리라는 것을 알게됐다.
- 혹시나 해서 spring-session-data-redis 가 자체적으로 제공하는 jedis 로 라이브러리를 변경하니 커넥션 풀링이 잘 먹기 시작했다.
- Jedis 보다 Lettuce 를 쓰자 글을 읽고 이전에 근무하시던 분이 Lettuce 를 선택하셨던 것 같은데, 그럴꺼면 한 Lettuce 4.대 버전을 썼어야 하지 않나 싶다.
- 어찌됐든 이 문제는 라이브러리를 jedis 옛날 버전으로 다운그레이드하며 해결됐다.
- jedis 의 처리 능력은 lettuce 에 비해 상대적으로 부족하지만, 현재 서비스에 들어오는 트래픽을 감당하기엔 너무나 충분했다.
문제와 해결된 내용은 간단하지만, 진단이 어려웠다. 로그에는 어디도 커넥션 풀링이 안됐다는 메세지는 없었으며, 그냥
java.net.BindException: Address already in use: no further information
이라는 로그만 찍혀있었다.netstat -ano
명령어로 계속 네트워크 커넥션 상황을 살펴보고 부하를 살펴보며 적절한 커넥션 풀을 설정해주었다. 이 과정에서 Redis 의 디비 번호 여러개를 쓰는 것도 조금 생각해볼 점이 있었는데, 자주 접근되는 디비는 풀을 좀 넓게 주고 자주 쓰이지 않는 디비는 그것보단 좀 적게 설정해주었다. jedis 구성정보 최적화 관련 자료
겪었던 문제 2: ElasticSearch 의 클라이언트가 제대로 닫히지 않은 이슈
- Redis 코드를 수정한 시점과 회사에서 ElasticSearch 를 도입한 시점이 마침 겹쳐있었다.
- 나는 동료의 작업 내용을 잘 몰라서 이번에 최초로 도입된 건지 전혀 인지하지 못하고 있었다.
- 동료 개발자가 개발한 ElasticSearch Client 이용에서 마지막에
client.close()
를 해주지 않아서 계속해서 스레드가 쌓이는 현상이 발생했었다. - 내가 직접 코드를 찾아
client.close()
를 일일이 추가해주고 앞으로는 이런 형식으로 쓰지 말고 템플릿을 하나 작성해서 사용하라고 말해두었다.- 아마 추후엔 spring-data-elasticsearch 를 사용하지 않을까 싶다.
이 역시 마찬가지로 문제와 해결된 내용은 간단하지만, 스레드 덤프 상에서는
sun.nio.ch.WindowsSelectorImpl$Subselector.poll0
와sun.nio.ch.WindowsSelectorImpl$StartLock.startThreads()
메서드에 관련된 내용 그리고I/O Dispatcher
라는 이름의 스레드와thread-pool-xx-…
라는 이름을 가진 스레드만 쌓여 있어서 엘라스틱서치 클라이언트 문제인지 단번에는 파악하지 못하고 많은 사고의 과정을 거쳐 파악했다.
겪었던 문제 3: Redis 직렬화 이슈
- Redis 의
GenericJackson2JsonRedisSerializer
를 믿었는데, 복잡한 객체에 대해서 가끔 직렬화 역직렬화를 제대로 못하는 것을 발견했다.- 우리 서비스의 데이터가 조금 이상하기도 해서 딱히 Serializer 의 문제라고 하기는 좀 애매하긴 하다.
@JsonProperty
와 같은 애노테이션을 이용해 에러가 나지 않도록 잘 설정해주었다.
- java 8 의
SubList
가SubList
타입을 반환하는 것도 직렬화에서 문제가 됐다.- Serializer 가 내부적으로 java Reflection 을 이용하는데
SubList
는 inner class 로 생성자가 없어서 Reflection 에서 생성자를 찾지 못해 역직렬화가 제대로 안된다.- 결국 마지막에
SubList
를ArrayList
와 같이 생성자가 있는 타입으로 한번 더 변경해주는 과정을 거쳤다.
- 결국 마지막에
- Serializer 가 내부적으로 java Reflection 을 이용하는데
이 문제 때문에 웹서비스를 종료했을 때 마지막에 어마어마한 로그를 쌓으며 끝났다. json 무한 순환 참조 에러에 걸리면 서버가 잠시동안 로그를 어딘가에 담아놨다가 서버를 끌 때서야 마구마구 뱉어냈다. 아직 정확한 이유는 모른다. 이 문제를 해결한 뒤에는 로그파일이 과도하게 쌓이는 현상이 사라졌다.
겪었던 문제 4: Redis keys()
메서드 이슈
- Redis
keys()
메서드는 애플리케이션 코드에서는 이용하지 않는 것이 좋은데, 팀원분 중keys()
메서드를 사용한 분이 있어 해당 코드를 좀 수정했다.- keys() 메서드 공식 문서 에도 warning 쪽에 애플리케이션 코드에서 사용하지 말라고 되어있다.
이는 SLOWLOG 라는 레디스 명령어를 통해 병목이 되는 레디스 명령어를 찾았고 코드에서도 사용되고 있음을 알아서 제거했다.
해결 이후
- 서버의 모든 지표가 안정적으로 변했다.
- 메모리, CPU 사용률, I/O 사용률 등 모든 것이 안정적이다.
깨달음
- 서버가 graceful shutdown 이 되지 않기 시작한다면, 서비스에 아무런 문제가 없더라도 해결하는 것이 좋다는 것을 깨달았다.
- 팀원들에게 네트워크 커넥션 작업이나 file I/O 작업 등을 할 때는 반드시 클라이언트를 종료시켰는지 한번 더 확인해달라고 부탁했다.
- 웬만하면 템플릿 형태로 안전하게 이용하는 것이 더 좋다고 이야기도 해두었다.
- 에러 로그에 무언가 뜨더라도 서비스가 돌아간다고해서 에러 로그에 뜨는 것을 가만히 두지 않아야겠다는 깨달음을 얻었다.
- 레디스 CLI 에 SLOWLOG 명령어는 주기적으로 한번씩 쳐봐야겠다.
- 레디스 직렬화는 너무 믿지 말고 복잡한 객체 직렬화할 때는 제대로 잘 되는지 한번 더 확인해봐야겠다는 깨달음을 얻었다.
- 스레드 덤프나 메모리 덤프는 문제 해결에 좋은 힌트가 되지만 최초로 저런 문제를 꽤나 막막해진다는 것을 깨달았다.
- 나중에 누군가 이런 문제로 헤매면 좀 도와줘야겠다는 생각이 든다.
'회고 > 주간 회고' 카테고리의 다른 글
Google Search Console 수집 불가 및 Page Insight 점수 안 뜨는 문제 관련 문제 회고 (쿠키, API 설계 관련 문제) (0) | 2023.06.14 |
---|---|
회사에서 있었던 레디스 코드 대형 리팩토링 회고 (2) | 2023.04.28 |
인텔리제이 톰캣 로그 실종 및 스프링 세션 springSessionRepositoryFilter(DelegatingFilterProxy 타입) 빈을 찾지 못하는 에러에 대한 회고 (0) | 2023.04.24 |
잘못된 도파민 보상 체계를 만들었던 경험 회고 (0) | 2023.04.23 |
스프링 초기 세팅 시 뜬 에러 메세지 관련 회고 (Invalid bean definition with name 'xxx' defined in null) (0) | 2023.04.17 |