이길어때가 더 궁금하다면?
제가 진행하고 있는 이길어때 프로젝트에는 사용자가 여러 방법으로 상호작용을 할 수 있답니다!
최초에 게시글을 작성하고 -> 해당 게시글에 좋아요 / 댓글을 달 수 있죠. 그리고 해당 게시글은 하나의 좌표를 저장하고 있는데요!
같은 좌표를 가리키는 게시글을 모아 장소라는 이름으로 사용자에게 데이터를 제공하게 됩니다.
그래서 장소에 대한 정보를 사용자에게 보여줄 때, 수 많은 정보를 함께 보내주게 되는데요!
예를 들어 한 장소 안의 게시글(리뷰) 개수
/ 리뷰의 댓글 개수
/ 리뷰의 좋아요 수
/ 평균 평점 조회
등
여러 정보를 표시하게 되고, 장소 하나를 표현하는데에도 위 부가정보들을 표시하기 위해 DB에 수많은 Count 쿼리를 보내게 됩니다.
그리고 이 Count 쿼리는 제가 느끼기에 개선해야할 포인트였습니다.
왜 Count 쿼리를 개선해야 했나?
Count 쿼리는 일반적인 조회에 비해서 분명 가벼운 조회입니다. 왜냐하면 해당 데이터 row의 내용을 읽는 것이 아닌, 있는지 없는지 여부만 파악하고 넘어가기 때문입니다.
하지만 대량의 데이터가 있는 경우 문제가 생깁니다. Count 쿼리는 모든 행을 스캔하여 총 수를 계산해야 합니다. 그러다보니 대량의 데이터가 존재하는 경우 처음부터 끝까지 모든 데이터를 스캔하기 때문에 상당한 시간이 소요됩니다.
Count 쿼리를 개선하는 방법은 다양합니다.
Count(*) 쿼리를 Count(id) 등 특정 컬럼을 세도록 바꾸고 해당 컬럼에 인덱스를 추가하거나,
데이터베이스 분할 및 파티셔닝 등으로 가능합니다.
하지만 이렇게 개선하더라도 대용량 데이터를 조회하는 count 쿼리는 큰 부담이었습니다.
무엇보다도 저희는 요구사항 상, 화면이 움직일때 마다 매번 수많은 count 요청을 DB에 남겨야 했습니다.
그래서 저희는 Redis
를 사용해서 해당 데이터를 캐싱하기로 하였습니다.
Redis로 Count 쿼리를 캐싱해보자!
Redis를 사용해서 데이터베이스로 날아가는 count query를 Redis로의 Select 쿼리로 변경합니다.
Redis는 메모리 기반의 데이터베이스로, Redis를 사용한 조회는 count(*)과 같은 집계 쿼리에 비해 훨씬 빠른 응답시간을 제공할 수 있습니다.
또한 해당 쿼리 결과를 캐시에 저장함으로써 데이터베이스로의 요청을 줄이게되고 이는 데이터베이스 서버의 성능과 안정성을 향상시키는 효과를 줄 수 있습니다.
그렇게되면 결국 데이터베이스 서버에 대한 의존도가 감소하게 되고 시스템의 확장성이 증가하는 효과를 가져옵니다.
자주 요청되는 데이터를 캐싱할때 가장 성능이 좋은 구조인데, 마침 저희 서비스의 잦은 count쿼리로 인한 성능 저하가 우려되는 문제를 해결할 수 있는 기술이라고 생각하여 프로젝트에 적용하게 되었습니다!
Spring 에서 Redis로 캐시 적용 방법
Spring에 Redis로 캐싱을 하는 방법은 여러가지 방법이 있는데요, 모든 방법을 다루면 좋겠지만 이는 추후에 다루고,
이번 글에서는 프로젝트에 적용한 경험을 위주로 작성해보겠습니다.
먼저 저희는 Spring Data JPA
를 적용하여 JPARepository 인터페이스를 통해 데이터를 간편하게 가져올 수 있었는데요, 이와 마찬가지로 Spring Data Redis
를 사용하면 비슷한 방식으로 캐시 메모리 데이터베이스와의 통신이 가능합니다.
이 부분이 일관성이 있는 구조를 만들 수 있겠다고 판단하여, Spring Data Redis를 사용하는 방법으로 캐시를 적용하기로 하였습니다.
먼저 의존성을 추가해야 합니다.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
이후 레디스를 설치하여 연결합니다.
application.yml
spring:
data:
redis:
host: localhost
port: 6379
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Value("{spring.data.redis.host}")
private String host;
@Value("{spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public CacheManager contentCacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(60L));
return RedisCacheManger.RedisCacheMangerBuilder
.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
이후 서버를 실행시켜서 문제가 없다면 Redis와의 연결이 잘 이루어진 것입니다!
그러면 이제 아까 위에서 설명한 요구사항대로, 장소안에 있는 게시글의 개수를 저장하는 레디스용 엔티티를 만들어봅시다!
@Getter
@AllArgsConstructor
@RedisHash("postCount")
public class PostCount {
@Id
private Long placeId;
private int postCount;
public void incrementPostCount() {
postCount++;
}
public void decrementPostCount() {
postCount--;
}
}
Redis의 Hash 자료구조를 활용하여, place의 id를 키로, 게시글의 수를 value로 하는 엔티티를 만들었습니다.
이제 레포지토리 인터페이스를 정의해보겠습니다.
public interface PostCountRepository extends CrudRepository<PostCount, Long> {
Optional<PostCount> findByPlaceId(Long placeId);
}
이렇게 작성하면 Spring Data JPA를 사용해서 데이터베이스에 쿼리를 전송한 것 처럼 간편하게 레디스와 상호작용할 수 있게 됩니다!
그러면 이제 실제 해당 PostCount를 저장하고 조회하는 방법을 알아보겠습니다!
@Service
@RequiredArgsConstructor
public class PostRedisIntegrityService {
private final PostRepository postRepository;
private final PostCountRepository postCountRepository
@Transactional
public PostCount ensurePostCount(Long placeId) {
return postCountRepository.findByPlaceId(placeId)
.orElseGet(() -> {
PostCount postCount = new PostCount(placeId, postRepository.countByPlaceId(placeId));
postCountRepository.save(postCount);
return postCount;
});
}
}
위 서비스 클래스는 먼저 postCount를 Redis로부터 조회요청을 하고 해당 Redis에 데이터가 없으면 실제 데이터베이스에 값을 요청해서 Redis에 저장하는 로직입니다.
이렇게 먼저 Redis에 조회를 요청하고, 해당 조회에 실패(캐시 미스)가 발생할 시 실제 DB에 조회하는 정책을 캐시 어사이드 패턴이라고 합니다.
이렇게 적용하여 postCount를 조회시 RedisIntegrityService를 거쳐 데이터를 받아오면 로직이 완성됩니다.
하지만 이 경우 큰 단점 또한 존재하였습니다.
기존 비즈니스 로직의 가독성을 해치는 캐시 정책
해당 방식으로 코드를 작성하면, 캐시에서 데이터를 불러올때마다 매번 orElseGet 등의 메서드를 통해 실제 DB로의 조회 요청을 만들어야 했고, 이 부분이 전체적인 비즈니스 로직의 가독성을 잃게 만든다고 생각이 들게 되었습니다.
그래서 해당 부분을 고칠수 있을까 고민하던 끝에 스프링에서 추상화된 캐시기능을 제공한다는 사실을 알았습니다.
해당 기능을 사용하면 메서드 단위로 해당 인자를 key로 value값을 캐싱할 수 있도록 할 수 있어서 얼른 이를 적용해보았습니다.
스프링의 캐시기능을 활용한 캐싱 적용
먼저 아까 작성한 Config 클래스에 어노테이션을 추가합니다.
@Configuration
@EnableRedisRepositories
@EnableCaching
public class RedisConfig {
....
}
이후 해당 postCount를 조회하는 코드 위에 어노테이션을 작성합니다.
@Service
@RequiredArgsConstructor
public class PlaceService {
private final PostRepository postRepository;
@Cacheable(value = "postCount")
public int getPostCount(Long placeId) { return postRepository.getPostCountInPlace(placeId); }
}
이렇게만 작성하면 해당 placeId를 키로 value인 postCount를 레디스에 캐싱이 가능합니다.
만약에 캐시에 값이 있다면 해당 메서드를 실행시키지 않고, 레디스에서 값을 조회한 후 꺼내오고
캐시에 값이 없다면 해당 메서드 내의 로직 (repository에서 실제 DB조회를 요청하는 부분)을 실행합니다.
이렇게 작성하면 AOP를 이용했을 때 처럼 캐싱에 대한 로직을 분리하여, 비즈니스 로직을 명확히 할 수 있는 장점이 있답니다.
물론 무작정 데이터를 Redis에 넣는것은 정답이 아닐지 모르지만, Redis를 적극적으로 사용하여 각자 프로젝트에 적용하고 개선하는 경험을 해보면 좋을 것 같습니당 👍👍
'회고 > 프로젝트 🖥️' 카테고리의 다른 글
[이길어때] SSE 방식의 실시간 알림 구현하기 (0) | 2024.03.19 |
---|---|
[이길어때] 우당탕탕 DB 레플리케이션 적용기 (0) | 2024.02.07 |
[이길어때] 이벤트 기반으로 파일 업로드 기능 구현하기 (0) | 2024.02.07 |
[이길어때] 전략 패턴으로 확장성있게 소셜 로그인 설계하기 (0) | 2024.02.07 |
[이길어때] 위치 정보 관리를 위한 PostGIS 적용기 (0) | 2024.02.07 |
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆