일정 시간마다 Redis의 데이터를 RDB에 저장해 보자
📌 서론
내가 만든 요리 SNS어플인 "레시피아"에서는 유저가 레시피를 조회하면 조회수 데이터를 Redis에 계속 누적시킨다.
만약 유저가 레시피 상세보기를 하면 Redis에 저장된 누적 조회수 데이터를 호출하여 화면에서 조회수를 보여주도록 기능을 구현했다.
이렇게 RDB에서 데이터를 가져오지 않고 Redis에서 바로 조회수 데이터를 호출하게 하여 빠른 반응속도와 RDB의 부하를 줄였다. 이렇게 행복하고 편리하게 기술이 적용되었다면 참 좋겠지만 Redis는 휘발성 데이터베이스이기 때문에 문제가 발생하면 데이터가 다 날아가기 때문에 복구 전략이 엄청 중요하다. 이미 Redis에서는 자체적인 복구기능으로 aof, rdb 방식을 지원하지만 이것만으로는 부족하다고 생각했다.
그래서 나는 팀원과 RDB(PostgreSQL)에도 데이터를 저장할지 회의를 진행했다. 처음에는 유저가 별로 없는 새벽시간을 이용하여 하루에 한 번만 저장하는 방향으로 흘러갔지만 계속된 회의에서 하루에 한 번은 위험하지 않을까?라는 고민이 이어졌고 결론적으로 6시간 단위로 Redis에 저장된 조회수 데이터를 모두 일괄 업데이트처리 하기로 결정했다.
이 결정에 따라 스프링에서는 PostgreSQL에 데이터를 저장하는 로직을 작성해야 했고 여기서 두 가지 방식을 고민했다. 그 고민사항은 Spring Batch를 사용하느냐 Scheduler를 사용하느냐였다.
우리는 처리할 로직이 크게 복잡하지 않고 하루에 4번만 동작해도 된다는 점을 토대로 Spring Batch를 사용하는것은 오버스펙이라고 결론 내리게 되었고 Scheduler를 사용하기로 결정했다. 이 결정이 내려진 직후 스케쥴러 코드를 작성했고 이 과정에서 여러 문제를 마주하게 되었다.
오늘 포스트에서는 스케쥴러를 도입하여 Redis의 데이터를 RDB에 저장하는 과정을 소개하고자 한다.
1. 레디스 조회수 데이터를 RDB에 저장하는 서비스 코드 작성
코드 작성하기
- "레시피아"는 헥사고날 아키텍처를 적용하여 개발을 진행했다. 그래서 나는 "도메인"이 중심이 되는 개발방식을 지키기 위해 서비스 레이어의 코드를 먼저 작성했다. 이때 어떤 방식으로 데이터를 저장할지 2가지 방식을 고민했다.
- Redis의 데이터를 모두 받아서 반복문으로 처리하기
- 첫 번째와 같이 반복문을 돌지만 배치성 로직을 추가하여 20개의 데이터마다 db에 flush처리하기
선택을 위해 두 방식의 코드를 모두 작성했다.
서비스에서 공통적으로 호출하는 메서드
- 두 메서드는 모두 redisPort에 작성된 fetchAllViewCounts() 메서드를 호출한다. 이 메서드는 먼저 redis의 db에서 "recipe:view:"로 시작하는 모든 key값을 조회해서 받아온다.
- 이후 받아온 key를 사용하여 recipeId를 추출하고 recipeId를 통해 관련된 레시피의 조회수를 받아와서 Map객체를 생성한다.
📌 redisTemplate.keys()의 동작 이해하기
레디스에서 데이터를 받아오는 과정이 이해하기 어려울 수 있다. 그래서 예시를 준비했다.
Redis에 다음과 같은 키-값 쌍이 저장되어 있다고 가정해보자
- "recipe:view:1" → 100
- "recipe:view:2" → 150
- "recipe:view:3" → 200
이 경우 redisTemplate.keys("recipe:view:*")는 ["recipe:view:1", "recipe:view:2", "recipe:view:3"]와 같은 Set<String>을 반환한다.
이후의 코드에서는 이 키들을 사용하여 각 키에 대한 실제 값을 (redisTemplate.opsForValue().get(key)) 조회하여 최종적으로 recipeId와 조회수 값을 매핑한 Map<Long, Integer>를 생성해서 반환한다.
recipeId를 추출하는 메서드 이해하기
- Map을 만들 때 key에는 아래의 extractRecipeIdFromKey 메서드를 통해 추출한 recipeId를 넣어줬다.
코드를 분석해 보자
- 코드를 보면 Redis에서 (recipe:view:*)로 받아온 keys를 stream을 돌려 Map으로 변환시키도록 되어있다.
- 위에서 말한 것처럼 extractRecipeIdFromKey 메서드를 호출해서 recipeId를 key로 세팅하는 것을 알 수 있다.
/**
* Redis에서 "recipe:view:*" 패턴에 일치하는 모든 키를 검색하고
* 각 키에 대한 조회수 값을 가져온 다음, recipeId와 조회수 값을 매핑하여 반환한다.
*/
@Override
public Map<Long, Integer> fetchAllViewCounts() {
Set<String> keys = redisTemplate.keys("recipe:view:*");
if (keys == null) {
return new HashMap<>();
}
return keys.stream()
.collect(Collectors.toMap(
this::extractRecipeIdFromKey,
key -> Optional.ofNullable(redisTemplate.opsForValue().get(key)).orElse(0)
));
}
위에서 말한 extractRecipeIdFromKey 메서드도 살펴보자
- 이 메서드는 key로 "recipe:view:10" 형태의 데이터를 받은 다음 2번째 :뒤의 내용을 split 해서 recipeId만 추출해서 반환한다.
/**
* 레시피 id를 추출한다. (이 id를 통해 레시피 내부의 like, view 갯수를 업데이트 한다.)
*/
public Long extractRecipeIdFromKey(String key) {
try {
// "recipe:like:123" 형식의 키에서 "123" 부분을 추출
return Long.parseLong(key.split(":")[2]);
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
// 숫자 형식의 오류 또는 배열 인덱스 관련 오류 처리
log.error("Invalid key format for Redis key: {}", key, e);
throw new RecipeApplicationException(ErrorCode.REDIS_RECIPE_ID_NOT_FOUND);
}
}
2. RDB에 조회수를 저장하는 어댑터 코드 작성
RDB에 Redis에서 받아온 조회수 데이터를 저장하는 로직을 작성해 보자
- 나는 어댑터에 DB처리에 관련된 로직을 작성했는데 이때 서비스 레이어에서 적었던 2가지 방식을 모두 적어봤다.
- 일반적인 update문 처리 (queryDsl)
- 배치 처리 로직 (queryDsl, flush, clear)
📌 잠깐! 어댑터 레이어가 뭔가요?
어댑터 레이어는 헥사고날 아키텍처에서 DB와의 상호작용을 담당하는 레이어다.
어댑터에서는 Repository 인터페이스(JPA, Mybatis 등)를 사용해서 DB와의 통신을 추상화하며, 필요한 DB처리 로직을 구현한다.
이러한 방식으로 어댑터 레이어는 Repository를 주입받아, 데이터의 영속성 관리 및 접근을 중재한다.
이 구조를 통해, 애플리케이션의 핵심 비즈니스 로직이 외부 시스템(DB, 웹 서비스, 메시징 시스템)의 구체적인 구현 세부사항으로부터 독립되어 유지보수, 확장성이 높아진다.
각 조회수마다 업데이트 (updateViewCountInDatabase 메서드)
- 이 방식에서는 @Transactional 어노테이션에 의해 정의된 트랜잭션 범위 내에서 forEach 루프를 사용하여 각 조회수를 개별적으로 업데이트한다. (서비스 클래스: 트랜잭션)
- queryDslRepository.updateViewCountInDatabase 호출은 각 조회수에 대한 업데이트 쿼리를 생성하고 실행한다. 하지만, 실제 데이터베이스에 대한 커밋은 @Transactional 어노테이션이 적용된 메서드가 성공적으로 완료되었을 때 발생한다.
배치를 통한 업데이트 (batchUpdateViewCounts메서드)
- 이 방식에서는 일정 수량의 업데이트를 처리한 후 entityManager.flush()를 호출하여 영속성 컨텍스트에 쌓인 변경사항을 데이터베이스에 반영하고, entityManager.clear()를 호출하여 영속성 컨텍스트를 초기화한다.
- 이러한 과정은 여전히 @Transactional 어노테이션에 의해 정의된 전체 트랜잭션 범위 내에서 이루어져서 문제가 발생하면 Rollback이 된다.
두 로직이 모두 호출하는 Querydsl 로직
- 이 로직은 RecipeViewCount에 조회수를 업데이트하는 로직이다.
얻게 된 결론
- 두 방식 모두 @Transactional 어노테이션에 의해 정의된 트랜잭션 범위 내에서 실행되며, 트랜잭션이 종료될 때 최종 커밋이 이루어진다. 그러나 일정 수량마다 flush와 clear를 호출하는 방식은 메모리 사용량을 최적화하고 성능을 개선할 수 있는 추가적인 이점을 제공한다.
3. flush, clear 메서드를 호출하는 이유
이것을 이해하기 위해 영속성 컨텍스트(Persistence Context)와 엔티티의 상태 관리에 대해 알아보자
영속성 컨텍스트(Persistence Context)와 엔티티 상태 관리
- JPA에서 영속성 컨텍스트는 엔티티를 영구 저장하는 환경이다. 이는 엔티티의 생명 주기를 관리하고, 엔티티의 상태 변경을 추적한다. 영속성 컨텍스트 내의 엔티티는 다음과 같은 상태를 가질 수 있다.
- 영속 (Managed): 영속성 컨텍스트에 저장된 엔티티. 엔티티의 변경사항이 추적되며, 트랜잭션이 커밋될 때 변경사항이 데이터베이스에 반영된다.
- 비영속 (New/Transient): 영속성 컨텍스트와 관계가 없는 새로운 엔티티.
- 준영속 (Detached): 영속성 컨텍스트에서 분리된 엔티티. 더 이상 엔티티의 상태 변경을 추적하지 않는다.
- 삭제 (Removed): 삭제된 엔티티. 트랜잭션이 커밋될 때 데이터베이스에서도 삭제된다.
flush 호출과 데이터베이스 상호작용
- entityManager.flush()를 호출하면, 영속성 컨텍스트가 관리하는 엔티티 중 변경된 엔티티(예: 수정된 엔티티)에 대한 변경사항이 데이터베이스에 반영된다. 이때, 실제 SQL 업데이트 문이 데이터베이스로 전송된다.
- 그러나, 이것은 아직 데이터베이스 트랜잭션의 커밋(commit)이 이루어진 것은 아니며, 데이터베이스 트랜잭션은 여전히 진행 중이다. 즉, flush는 데이터베이스와의 동기화를 강제하지만, 트랜잭션의 완결(커밋 또는 롤백)까지 하는 것은 아니라는 의미다.
flush를 통한 메모리 사용량 최적화
일정 수량의 업데이트(예: 20개)를 처리한 후 flush와 clear를 호출하면 다음과 같은 이점이 있다.
- 메모리 사용량 관리:
- flush 호출로 영속성 컨텍스트의 변경사항을 데이터베이스에 반영한 후, clear를 통해 영속성 컨텍스트를 초기화하면, 더 이상 관리하지 않아도 되는 엔티티에 대한 참조를 제거하여 메모리 사용량을 줄일 수 있다. 이는 특히 대량의 데이터를 처리하는 경우 유용하다.
- flush 호출로 영속성 컨텍스트의 변경사항을 데이터베이스에 반영한 후, clear를 통해 영속성 컨텍스트를 초기화하면, 더 이상 관리하지 않아도 되는 엔티티에 대한 참조를 제거하여 메모리 사용량을 줄일 수 있다. 이는 특히 대량의 데이터를 처리하는 경우 유용하다.
- 데이터베이스 부하 관리:
- 각 업데이트마다 데이터베이스에 즉시 반영되지 않고, flush가 호출될 때까지 변경사항이 영속성 컨텍스트에 쌓인다. 따라서, flush를 사용하면 데이터베이스 부하를 효율적으로 관리할 수 있다. 각 업데이트마다 개별적으로 SQL 문을 실행하는 것보다, 묶음으로 처리함으로써 네트워크 및 데이터베이스 리소스 사용을 최적화할 수 있다.
4. forEach를 통한 반복 처리와 배치를 통한 처리의 차이점
영속성 컨텍스트와 메모리 부하
- forEach 반복 처리
- 각 업데이트마다 영속성 컨텍스트에 변경사항이 쌓이고, 이러한 변경사항이 많아질수록 영속성 컨텍스트의 메모리 사용량도 증가한다. 데이터가 많을 경우, 이는 메모리 부하로 이어질 수 있으며, 영속성 컨텍스트의 성능에도 영향을 줄 수 있다.
- 각 업데이트마다 영속성 컨텍스트에 변경사항이 쌓이고, 이러한 변경사항이 많아질수록 영속성 컨텍스트의 메모리 사용량도 증가한다. 데이터가 많을 경우, 이는 메모리 부하로 이어질 수 있으며, 영속성 컨텍스트의 성능에도 영향을 줄 수 있다.
- 일정 수량마다 flush와 clear 처리
- 반면에 일정 수량(예: 20개)의 업데이트마다 flush를 호출하여 변경사항을 데이터베이스에 반영하고, clear를 통해 영속성 컨텍스트를 초기화하면, 영속성 컨텍스트가 관리해야 하는 엔티티 수를 줄일 수 있다. 이는 메모리 사용량을 줄이고 영속성 컨텍스트의 성능을 향상시킬 수 있다.
- 반면에 일정 수량(예: 20개)의 업데이트마다 flush를 호출하여 변경사항을 데이터베이스에 반영하고, clear를 통해 영속성 컨텍스트를 초기화하면, 영속성 컨텍스트가 관리해야 하는 엔티티 수를 줄일 수 있다. 이는 메모리 사용량을 줄이고 영속성 컨텍스트의 성능을 향상시킬 수 있다.
데이터 양에 따른 영향
- 데이터가 적은 경우
- 데이터 양이 적을 때는 두 방식 사이의 차이가 크게 눈에 띄지 않을 수 있다. 적은 양의 데이터 처리는 영속성 컨텍스트의 메모리 부하에 큰 영향을 주지 않기 때문이다.
- 데이터 양이 적을 때는 두 방식 사이의 차이가 크게 눈에 띄지 않을 수 있다. 적은 양의 데이터 처리는 영속성 컨텍스트의 메모리 부하에 큰 영향을 주지 않기 때문이다.
- 데이터가 많은 경우
- 대량의 데이터를 처리해야 할 경우, 일정 수량마다 flush와 clear를 사용하는 방식이 메모리 관리 측면에서 유리하다. 이 방식은 영속성 컨텍스트 내에서 한 번에 관리해야 하는 엔티티의 수를 줄여 메모리 부하를 감소시키고, 전체적인 성능을 향상시킬 수 있기 때문이다.
- 대량의 데이터를 처리해야 할 경우, 일정 수량마다 flush와 clear를 사용하는 방식이 메모리 관리 측면에서 유리하다. 이 방식은 영속성 컨텍스트 내에서 한 번에 관리해야 하는 엔티티의 수를 줄여 메모리 부하를 감소시키고, 전체적인 성능을 향상시킬 수 있기 때문이다.
📌 결론
데이터 양이 많아질수록 영속성 컨텍스트의 메모리 부하와 성능 관리는 중요한 고려 사항이 된다.
일정 수량마다 flush와 clear를 사용하는 방식은 영속성 컨텍스트의 메모리 부하를 효과적으로 관리하고, 대량의 데이터 처리 성능을 최적화하는 데 도움이 될 수 있다.
따라서, 데이터 처리 요구 사항이 크고 성능 최적화가 필요한 경우, 이 방식을 고려하는 것이 좋다.
5. 치명적인 실수를 했다.
- 지금까지 작성했던 로직에는 문제가 있다. RecipeViewCountQueryDslRepository에서updateViewCountInDatabase 메서드를 호출할 때마다 QueryDSL의 execute()가 호출되어 바로 데이터베이스 업데이트 쿼리가 실행되는 구조인데, 이 경우 flush()와 clear()의 호출이 실제 쿼리 실행에는 직접적인 영향을 미치지 않는다는 것이다.
- 왜 그런가 하면 QueryDSL을 사용하여 execute() 메서드를 호출하면, 그 시점에서 데이터베이스에 쿼리가 바로 전송되고 실행되기 때문에, 각 업데이트마다 이미 데이터베이스와의 상호작용이 이루어지기 때문이다.
- 따라서 batchUpdateViewCounts 메서드 내에서 20개의 업데이트마다 flush와 clear를 호출하는 것은 QueryDSL을 사용한 쿼리 실행과 관련해서는 배치 처리의 이점을 제공하지 못한다.
문제점 정리
- flush와 clear는 주로 JPA의 영속성 컨텍스트와 관련된 작업에서, 영속성 컨텍스트에 쌓인 변경사항을 데이터베이스에 반영하고 메모리를 정리하는 데 사용되는데, 내가 작성한 코드처럼 QueryDSL을 사용하여 execute메서드를 호출한 경우에는 이러한 변경사항이 영속성 컨텍스트에 쌓이지 않고 바로 적용되므로, 이 메서드들의 호출이 의미가 없게 된 것이다.
- 아래와 같이 queryDsl이 아니라 JpaRepository를 사용하도록 코드를 수정했는데 이번에는 count에서 문법 오류가 발생했다.
- 오류 메시지는 다음과 같았다. 이건 대체 무슨 오류 일까? 분석을 시작했다.
6. 오류 분석 및 문제해결
문제 분석 결과
- 이 부분에서 "Variable used in lambda expression should be final or effectively final" 오류가 발생하는 이유는, 람다 표현식 내에서 사용되는 변수는 변경이 불가능해야 하는데, `count` 변수가 람다 내부에서 변경되고 있기 때문이었다.
- Java에서 람다 표현식 내부에서 참조되는 지역 변수는 final이거나 effectively final(초기화 후 값이 변경되지 않는 변수)이어야 한다.
if (++count % 20 == 0) {
그럼 이 문제를 어떻게 해결해야 할까?
- 내가 택한 해결방법은 Java의 stream API를 사용하여 `entrySet`을 청크(20개 항목 단위로 분할된 부분집합)로 나누고, 각 청크를 별도로 처리하는 방법이다. 이 접근 방법을 사용하면 람다 표현식 내에서 카운터를 증가시킬 필요가 없어져서 "final or effectively final" 제약을 우회할 수 있다.
아래와 같이 코드를 수정했다.
- 여기서 20개마다 flush, clear를 날리도록 했는데 중요한 것은 이전 코드와 다르게 queryDSL을 사용하지 않고 JPARepository를 사용하기 때문에 영속성 컨텍스트에 데이터를 모아두고 한 번에 업데이트를 날릴 수 있게 되었다는 점이다.
- 여기서 주의 깊게 봐야 할 로직은 changeViewCount메서드를 사용하는 부분이다. 엔티티에 setter를 선언해 주면 위험하기 때문에 엔티티 내부에 setter대신 change메서드를 만들어 주고 이 메서드를 사용해서 변경감지를 적용시켰다.
chunk를 만들기 위해 사용한 subList 함수를 알아보자
- subList(int fromIndex, int toIndex) 메서드는 List 인터페이스에서 제공하는 메서드로, 리스트의 특정 범위에 해당하는 부분 리스트(view)를 반환한다.
- fromIndex는 부분 리스트의 시작 인덱스(포함되며), toIndex는 끝 인덱스(제외됨)를 나타낸다. 이 메서드를 사용하면 원본 리스트를 실제로 분할하지 않고도 특정 범위의 항목에 대한 뷰를 얻을 수 있다.
entries.subList(i, Math.min(entries.size(), i + 20));
toIndex의 인자로 들어가는 Math.min()은 어떤 로직인가?
- Math.min(entries.size(), i + 20)은 entries.size()와 i + 20 중에서 더 작은 값을 반환하는 것이다. 이는 subList 메서드의 toIndex 인자로 사용되어, 리스트의 범위를 초과하지 않게 하기 위한 목적이다.
- 예를 들어서, 만약 entries 리스트의 크기가 95이고, 현재 i의 값이 80일 경우 i + 20은 100이 되지만, entries.size()는 95이기 때문에 Math.min(entries.size(), i + 20)의 결과는 두 값 중 더 작은 값인 95를 반환한다.
- 이렇게 하면 subList(80, 95)가 호출되어, 인덱스 80부터 94까지의 항목을 포함하는 부분 리스트를 얻게 되어 리스트의 범위를 넘어가는 문제를 방지할 수 있다. 반면에 entries의 크기가 105이고 i가 80일 경우, Math.min(105, 100)은 100을 반환하므로 subList(80, 100)이 호출되어 인덱스 80부터 99까지의 항목을 포함하는 부분 리스트를 얻게 된다.
이게 끝인 줄 알았지만 팀 동료 "평양냉면"님께서 코드리뷰를 해주시면서 미처 발견하지 못했던 점을 지적해 주셨다.
- 평양냉면: 근데 만약 Redis에는 있는데 RDB 테이블에 저장된 데이터가 없으면 어떻게 해? 추가해줘야 하는 거 아니야?
- 그렇다. 모든 데이터를 저장하는 것이 중요하기 때문에 저장된 데이터가 없는 경우도 고려했어야 했다. 이 피드백을 듣고 바로 코드를 수정했다.
- 먼저 ifPresent 메서드를 ifPresentOrElse 메서드로 변경하여 존재하지 않을 때의 로직도 작성해 줬다.
7. 스케쥴러 코드 작성하기
배치코드를 완성한 다음 스케쥴러를 작성했다.
- 스케쥴러는 6시간마다 동작해서 하루에 4번 동작하도록 했다.
- SyncViewCountUseCase 인터페이스를 주입받아 서비스 로직을 사용했다. (헥사고날 아키텍처)
- 스케쥴러에서 오류가 발생한다면 5회까지는 재시도를 하고 이후 실패하면 로그를 남기도록 했다. (추가 로직을 고민 중이다.)
8. 결과 확인
결과는 아주 좋았다.
- 디버깅을 위해 6시간마다 동작하던 것을 1분으로 변경하고 검증했다.
- 코드가 잘 호출되었고 모든 메서드가 문제없이 동작하는 것을 확인했다.
이렇게 Redis에 저장된 조회수 데이터를 RDB에 저장하도록 하는 로직이 완성되었다.
이 과정에서 많은 것들을 배웠던 것 같다. 최근 잊고 있었던 영속성 컨텍스트의 동작을 다시 한번 이해할 수 있었고 스프링 배치를 사용하지 않고 스케쥴러만을 사용했을 때 배치를 어떻게 구성할지에 대해서도 공부할 수 있었다.
매번 느끼지만 코드리뷰를 해주는 "평양냉면" 님에게도 감사하다. 항상 양질의 리뷰를 주고받으며 크게 성장하고 있음을 느낀다. 부족한 부분은 메꿔줄 수 있는 동료는 정말 소중하다는 것을 다시 한번 느낀다.
항상 큰 도움 주시는 팀원 "평양냉면"님의 블로그도 방문해 보시는 것을 추천합니다!
'Spring Data JPA' 카테고리의 다른 글
[Spring] @TransactionalEventListener(AFTER_COMMIT)에서 업데이트가 반영되지 않는 문제 해결 (0) | 2024.11.08 |
---|---|
[Spring] JPA 엔티티에 왜 기본 생성자가 필수일까? (1) | 2024.10.21 |
JPA N+1 문제가 발생하는 상황과 해결방법 (5) | 2023.12.28 |
[SpringBoot] 3.x.x 버전에서 P6Spy 적용하기 (3) | 2023.11.17 |
[Spring] Data JPA의 구조를 알아보자 (1) | 2023.10.24 |