이전 포스팅에서 확인했듯이 다음의 API에 대한 성능 개선이 필요해보였다.
- 투두 조회 API
- 전체 & 소셜 랭킹 조회 API
- 투두 생성 API
이번 포스팅에서는 전체 & 소셜 랭킹 조회 API에 캐싱을 적용하여 성능을 개선시킨 경험에 대해 포스팅하도록 하겠다!
캐싱
모든 유저가 동일한 데이터를 하루 혹은 일주일 동안 본다면 이는 캐싱을 적용하기 아주 좋은 지점이다!
캐싱 전략 선택
일반적으로 백엔드 개발 시 고려하게 되는 캐시는 다음 2가지이다.
- Local Cachce
- 애플리케이션 내부에서만 유효하며, 동일한 애플리케이션 내의 여러 모듈이나 서비스 간에는 공유 X
- 메모리 내에 데이터를 저장해서 읽기 및 쓰기 성능이 매우 빠름
- 애플리케이션의 JVM 내부 또는 로컬 서버에 저장되며, 외부에서 접근 불가능
- Global Cache (ex. redis, memcached)
- 여러 서버 또는 애플리케이션 간에 데이터 공유 가능
- 주로 네트워크를 통해 데이터에 접근하므로 로컬 캐시에 비해 상대적으로 느린 읽기 및 쓰기 성능
나는 추후에 여러 모듈이 추가되고 스케일 아웃을 할 것음 감안했을 때 글로벌 캐시가 필요하다고 느꼈다. 그리고 글로벌 캐시 중 그나마 알고 있고 많이 사용되어 자료도 많은 Redis를 선택했다.
추가적으로 이후 배포 시 Upstash Redis라는 서버리스 Redis 서비스를 사용하기 위함도 있었다.
캐시를 사용하면 무조건 좋은가?
- 무조건 캐시에 데이터를 저장하는게 좋은게 아니라 상황에 맞게 설정을 해야 한
- 캐시에 필요한 데이터가 있는지 체크를 하고 없다면 DB에서 데이터를 조회하기 때문에 **캐시에 저장을 했지만 자주 히트율이 떨어지는 데이터인 경우, 캐시에서 데이터를 찾는 작업 + 디비에서 데이터를 조회하는 작업을 수행해야 함
- => 즉, 이중으로 작업을 하며 메모리 자원을 차지
- 캐시 히트율이 높을 것으로 예상되는 데이터에 한해서 캐싱을 적용해야 하며, 이는 자주 변하지 않는 데이터를 대상으로 하라는 의미
- 우리 프로젝트에서 전체 & 소셜 랭킹의 경우 하루 혹은 일주일에 한 번만 갱신되고 이후 모든 유저가 동일한 내용을 보게 되므로, 캐싱을 적용하기 적합하다고 판단
그럼 이제 Redis를 통해 캐싱을 도입해보자!
전체 랭킹 조회 API: 성능 개선 (feat. Redis)
의존성 추가 및 설정
build.gradle
추가
implementation "org.springframework.boot:spring-boot-starter-data-redis"
docker-compose.yml
추가
redis:
image: "redis:latest"
container_name: redis-container
ports:
- "6379:6379" # Redis 기본 포트 매핑
volumes:
- redis-data:/data # Redis 데이터를 유지할 볼륨
networks:
- todomon-network
application.yml
추가
spring:
data:
redis:
host: redis-container
port: 6379
RedisConfig
@EnableCaching
@Configuration
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 RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// Redis를 연결
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
@Bean
@Primary
public CacheManager dailyCacheManager(RedisConnectionFactory redisConnectionFactory) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime next1AM = now.toLocalDate().atStartOfDay().plusDays(1).plusHours(1);
Duration ttl = Duration.between(now, next1AM);
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(ttl)
.disableCachingNullValues(); // Null 캐싱 제외
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(cacheConfig)
.build();
}
@Bean
public CacheManager weeklyCacheManager(RedisConnectionFactory redisConnectionFactory) {
LocalDateTime now = LocalDateTime.now();
// 다음 주 월요일 오전 1시를 계산
LocalDateTime nextMonday1AM = now.with(java.time.temporal.TemporalAdjusters.next(java.time.DayOfWeek.MONDAY))
.toLocalDate().atStartOfDay().plusHours(1);
// 현재 시간부터 다음 주 월요일 오전 1시까지의 시간 계산
Duration ttl = Duration.between(now, nextMonday1AM);
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(ttl)
.disableCachingNullValues();
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(cacheConfig)
.build();
}
}
- 스프링의 캐시 추상화 어드바이스를 사용하기 위해
@EnableCaching
설정 - Redis 연결을 위한 커넥션으로
LettuceConnectionFactory
를 사용 (Jedis 보다 빠름) RedisTemplate
을 추가해주었으나 여기서는 사용하지 않을 예정CacheManager
로dailyCacheManager
와weeklyCacheManager
를 빈으로 등록해두었음- 랭킹 조회 기능은 일간 조회와 주간 조회가 있어서 서로 다른 TTL이 필요했음 -> 위 2가지
CacheManager
는 TTL의 길이에 따라 구분됨 CacheManager
가 2개 이상 등록되면 발생하는 빈 중복 문제를 해결하기 위해@Primary
사용
- 랭킹 조회 기능은 일간 조회와 주간 조회가 있어서 서로 다른 TTL이 필요했음 -> 위 2가지
스프링 Cache 추상화 적용
CollectedPetRankItem
@Getter
@NoArgsConstructor
public class CollectedPetRankItem extends AbstractMemberInfoItem {
private Long memberId;
private int petCnt;
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime lastCollectedAt;
@Builder
public CollectedPetRankItem(Long memberId, String username, String profileImageUrl, int petCnt, LocalDateTime lastCollectedAt, TitleNameItem title) {
super(username, profileImageUrl, title);
this.memberId = memberId;
this.petCnt = petCnt;
this.lastCollectedAt = lastCollectedAt;
}
}
- 데이터를 직렬화/역직렬화 처리할 때
SerializationException
이 발생 - 직렬화, 역직렬화 대상이 되는 클래스에
LocalDateTimeSerializer
,LocalDateTimeDeserializer
를 각각 지정해서 직렬화, 역직렬화에 사용
전체 랭킹 조회 API: 캐싱 적용 전/후 부하 테스트 결과 비교
Redis 캐싱 적용 전 결과
Redis 캐싱 적용 후 결과 - 평소 트래픽
- TPS:
0.5
→1812
- 3624배 개선
- MTT:
52550.41ms
→15.03ms
- 3495배 개선
Redis 캐싱 적용 후 결과 - 최대 트래픽
아주 만족스러운 성능이다!
에러 999개는 처음 테스트 시작 시 nGrinder script 쪽에서 무언가 문제가 있는 듯하다. 시작하는 순간에만 발생하는 에러라 일단 무시하긴 했는데.. 나중에 한번 찾아봐야겠다
java.util.NoSuchElementException: null
at io.jsonwebtoken.impl.lang.Services.loadFirst(Services.java:98)
at io.jsonwebtoken.impl.DefaultJwtBuilder.compact(DefaultJwtBuilder.java:511)
at io.jsonwebtoken.JwtBuilder$compact$30.call(Unknown Source)
at TestRunner.generateAccessToken(overallRankTest.groovy:111)
at TestRunner$generateAccessToken$11.callCurrent(Unknown Source)
at TestRunner.test(overallRankTest.groovy:76)
at jdk.internal.reflect.GeneratedMethodAccessor22.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at net.grinder.scriptengine.groovy.junit.GrinderRunner.run(GrinderRunner.java:164)
at net.grinder.scriptengine.groovy.GroovyScriptEngine$GroovyWorkerRunnable.run(GroovyScriptEngine.java:147)
at net.grinder.engine.process.GrinderThread.run(GrinderThread.java:118)
소셜 랭킹 조회 API (1): 성능 개선 (feat. 쿼리 튜닝)
개선 전 상황
소셜 랭킹 조회와 관련된 4가지 API 모두 데이터가 많아지니 한 번 호출할 때마다 50초 이상씩 걸리는 심각한 문제가 있었다..
- 소셜 주간 투두 달성 랭킹 조회 API
select
m1_0.id,
m1_0.username,
m1_0.profile_image_url,
tah1_0.cnt,
tn1_0.name,
tn1_0.color
from
member m1_0
left join
follow f1_0
on f1_0.followee_id=m1_0.id
and f1_0.status=?
left join
todo_achievement_history tah1_0
on tah1_0.member_id=m1_0.id
left join
title_name tn1_0
on tn1_0.id=m1_0.title_name_id
where
(
f1_0.follower_id=?
or m1_0.id=?
)
and tah1_0.date between ? and ?
group by
m1_0.id,
tah1_0.cnt
order by
sum(tah1_0.cnt) desc,
max(tah1_0.created_at),
m1_0.created_at
limit
?
- 소셜 일관성 레벨 랭킹 조회 API
select
m1_0.id,
m1_0.username,
m1_0.profile_image_url,
d1_0.level,
tn1_0.name,
tn1_0.color
from
member m1_0
left join
follow f1_0
on f1_0.followee_id=m1_0.id
and f1_0.status=?
join
diligence d1_0
on d1_0.id=m1_0.diligence_id
left join
title_name tn1_0
on tn1_0.id=m1_0.title_name_id
where
f1_0.follower_id=?
or m1_0.id=?
order by
d1_0.level desc,
d1_0.gauge desc,
m1_0.created_at
limit
?
이 API들은 캐싱을 적용하는 것도 적용하는거지만.. 쿼리 성능 자체를 향상시키는 방법이 필요해보인다.
모든 유저들이 동일한 정보를 받는 것이 아니라, 유저마다 자신의 팔로우 정보에 따라 다르게 보여야 하기 때문에, 최초 조회 시에는 반드시 50초 이상의 시간이 걸린다는 점이 문제다.
배치 작업을 통해 매일 데이터를 redis에 저장해 두도록 할 수 있겠으나, 모든 유저들에 대해 이러한 배치 작업을 수행하는 것은 너무 무거운 작업일 뿐더러 redis에 큰 부하가 될 것 같다..
⇒ 쿼리 자체의 성능 튜닝 + 캐싱이 필요해보인다.
현재 쿼리의 문제점 분석
- member를 기준으로 follow를 LEFT JOIN → 팔로우 관계가 없는 유저도 포함됨..
- OR 조건 (f1_0.follower_id=? OR m1_0.id=?)으로 불필요한 데이터까지 조회 → 성능 저하
위의 문제를 해결하기 위해서는 다음과 같은 개선 방안이 필요해보인다..
쿼리 튜닝 방안
- INNER JOIN을 활용한 필터링 최적화
- 기존 LEFT JOIN 방식에서 INNER JOIN으로 변경하여 불필요한 데이터를 줄여야 함
- WHERE 절이 아니라 JOIN 조건에서 f1_0.follower_id=?를 필터링하여 미리 데이터 양을 제한
- OR 조건 제거 → 별도 쿼리 분리
- 기존에는 (f1_0.follower_id=? OR m1_0.id=?) 조건이 있어 팔로우한 사람 + 자기 자신을 한 번에 조회했음
- => 팔로우한 사람만 조회하는 쿼리 + 자기 자신만 조회하는 쿼리를 별도로 실행할 필요가 있음
- 스프링 서버에서 두 결과를 합쳐서 정렬하자
쿼리 튜닝 후
- 소셜 주간 투두 달성 랭킹 조회 API
select
m1_0.id,
m1_0.username,
m1_0.profile_image_url,
tah1_0.cnt,
tn1_0.name,
tn1_0.color
from
follow f1_0
join
member m1_0
on f1_0.followee_id=m1_0.id
and f1_0.status=?
and f1_0.follower_id=?
left join
todo_achievement_history tah1_0
on tah1_0.member_id=m1_0.id
left join
title_name tn1_0
on tn1_0.id=m1_0.title_name_id
where
tah1_0.date=?
group by
m1_0.id,
tah1_0.cnt
order by
tah1_0.cnt desc,
max(tah1_0.created_at),
m1_0.created_at
limit
?
...
select
m1_0.id,
m1_0.username,
m1_0.profile_image_url,
tah1_0.cnt,
tn1_0.name,
tn1_0.color
from
member m1_0
left join
todo_achievement_history tah1_0
on tah1_0.member_id=m1_0.id
left join
title_name tn1_0
on tn1_0.id=m1_0.title_name_id
where
tah1_0.date=?
and m1_0.id=?
group by
m1_0.id,
tah1_0.cnt
- member 테이블과 follow 테이블을
LEFT JOIN
하던 기존 코드 → follow 테이블과 member 테이블을INNER JOIN
하도록 변경- 이때, 원래 where 절에 있던
f.follower_id = currentMemberId
조건을 조인 조건으로 추가 - => 필터링을 먼저 수행하게 함으로써 쿼리 성능 개선!
- 이때, 원래 where 절에 있던
- 하지만, 이로 인해 요청한 유저(로그인한 유저)와 관련된 정보는 함께 조회할 수 없게 된다. 때문에 따로 요청 유저에 대한 정보를 조회하는 쿼리 추가로 작성
- 이후 두 결과를 스프링 서버에서 정렬하도록 변경
public List<TodoAchievementRankItem> findTop10MembersByWeeklyAchievement(Long currentMemberId) {
LocalDate startOfCurrentWeek = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
LocalDate startOfLastWeek = startOfCurrentWeek.minusWeeks(1);
LocalDate endOfLastWeek = startOfCurrentWeek.minusDays(1);
List<TodoAchievementRankItem> followeeRankings = query.select(
Projections.fields(TodoAchievementRankItem.class,
member.id.as("memberId"),
member.username,
member.profileImageUrl,
todoAchievementHistory.cnt.sum().as("cnt"),
Projections.fields(AbstractMemberInfoItem.TitleNameItem.class,
titleName.name,
titleName.color
).as("title")
)
)
.from(follow)
.innerJoin(member).on(follow.followee.id.eq(member.id).and(isAccepted()).and(follow.follower.id.eq(currentMemberId)))
.leftJoin(todoAchievementHistory).on(todoAchievementHistory.memberId.eq(member.id))
.leftJoin(member.titleName, titleName)
.where(todoAchievementHistory.date.between(startOfLastWeek, endOfLastWeek))
.groupBy(member.id)
.orderBy(todoAchievementHistory.cnt.sum().desc(), todoAchievementHistory.createdAt.max().asc(), member.createdAt.asc())
.limit(10)
.fetch();
TodoAchievementRankItem myItem = query.select(
Projections.fields(TodoAchievementRankItem.class,
member.id.as("memberId"),
member.username,
member.profileImageUrl,
todoAchievementHistory.cnt.sum().as("cnt"),
Projections.fields(AbstractMemberInfoItem.TitleNameItem.class,
titleName.name,
titleName.color
).as("title")
)
)
.from(member)
.leftJoin(todoAchievementHistory).on(todoAchievementHistory.memberId.eq(member.id))
.leftJoin(member.titleName, titleName)
.groupBy(member.id)
.where(todoAchievementHistory.date.between(startOfLastWeek, endOfLastWeek).and(member.id.eq(currentMemberId)))
.fetchOne();
List<TodoAchievementRankItem> results = concatAndSortTodoAchievementRankItems(followeeRankings, myItem);
results.forEach(AbstractMemberInfoItem::setTitleToNullIfIsEmpty);
return results;
}
결과
57160ms
→253ms
(약99.56%
개선)
다른 API 들도 모두 똑같은 문제들이기에 같은 방식으로 쿼리 튜닝을 진행했으며, 그 결과만 정리하면 다음과 같다.
소셜 일관성 레벨 랭킹 조회 API
53220ms → 50ms
(약99.91%
개선)
소셜 도감 랭킹 조회 API
51510ms → 80ms
(약99.84%
개선)
소셜 랭킹 조회 API (2): 성능 개선 (feat. Redis)
Redis 캐싱 적용
현재까지 개선된 정도로도 매우 빠른 성능을 보여주긴하지만, 유저는 하루종일 같은 데이터를 보게될 것인데 이를 매 요청마다 DB를 건들게 한다면 너무 비효율적이라고 생각된다. 역시 여기에도 캐싱을 적용하는 것이 바람직해 보인다!
이미 캐싱을 적용해본 적 있으니 어렵지 않게 다음과 같이 적용할 수 있다!
@Cacheable(value = "socialDiligenceRankCache", key = "#memberId", cacheManager = "dailyCacheManager")
public List<DiligenceRankItem> getSocialRankingOfDiligence(Long memberId) {
return socialQueryRepository.findTop10MembersByDiligenceLevelAndGauge(memberId);
}
@Cacheable(value = "socialCollectedPetRankCache", key = "#memberId", cacheManager = "dailyCacheManager")
public List<CollectedPetRankItem> getSocialRankingOfCollection(Long memberId) {
return socialQueryRepository.findTop10MembersByCollectedPetCnt(memberId);
}
@Cacheable(value = "socialDailyAchievementRankCache", key = "#memberId", cacheManager = "dailyCacheManager")
public List<TodoAchievementRankItem> getSocialRankingOfDailyAchievement(Long memberId) {
return socialQueryRepository.findTop10MembersByYesterdayAchievement(memberId);
}
@Cacheable(value = "weeklyAchievementRankCache", key = "#memberId", cacheManager = "weeklyCacheManager")
public List<TodoAchievementRankItem> getSocialRankingOfWeeklyAchievement(Long memberId) {
return socialQueryRepository.findTop10MembersByWeeklyAchievement(memberId);
}
최종 성능 결과
쿼리 튜닝 후 캐싱까지 적용하였을 때 성능 결과는 다음과 같다
물론 아래 결과는 캐싱만 적용했을 때 결과와도 같다고 볼 수 있겠지만.. 쿼리 튜닝도 하긴했으니 ㅎㅎ
- 소셜 주간 투두 달성 랭킹 조회 API:
57160ms
→20ms
(약99.97%
개선) - 소셜 일관성 레벨 랭킹 조회 API:
53220ms → 50ms
(약99.96%
개선) - 소셜 도감 랭킹 조회 API:
51510ms → 21ms
(약99.96%
개선)
'Project' 카테고리의 다른 글
[TODOMON] EP.12 아무것도 모르는 나의 동시성 문제 해결기 (0) | 2025.02.05 |
---|---|
[TODOMON] EP.11 인덱스를 통한 투두 조회 API 성능 개선 feat. 인덱스 컨디션 푸시다운 (0) | 2025.02.04 |
[TODOMON] EP.9 부하 테스트 진행(야매) (0) | 2025.02.04 |
[TODOMON] EP.8 nGrinder + Prometheus + Grafana 설정 w/ Docker & Spring Actuator (1) | 2025.02.03 |
[TODOMON] EP.7 부하 테스트 계획 (0) | 2025.02.03 |