뮤피에서 홈 화면에 우리가 직접 선정한 곡을 매주 월요일에 추천해 주는 “이 주의 추천 곡”이라는 컨텐츠가 있다.
기본적으로 RestTemplate로 Spotify API를 호출하여 곡 정보를 불러오는데, “이 주의 추천 곡”은 현재 홈 화면에 있어서 조회를 많이 할 확률이 높기 때문에 불필요한 Spotify API 호출을 줄여야 했다.
1. 기존 방식 성능 측정하기
1.1 기존 방식
- [사용자] 이 주의 추천 곡에 있는 곡들 중 하나 클릭
- [클라이언트] 해당 곡의 track id로 곡 정보 조회 요청
- [서버] RestTemplate으로 Spotify API 호출하여 정보 조회
- 곡 정보와 사용자의 평점 정보 등을 함께 응답
곡 정보 요청에 사용자의 평점 정보 등을 함께 제공하기 때문에, 단순히 요청에 대한 API 응답 속도를 확인한다고해서 정확한 성능을 측정할 수 없다.
그래서 곡에 대한 요청을 하는 부분만 성능을 측정해야 한다. 이를 위해 Spring AOP를 활용하여 성능을 측정하였다.
1.2 원하는 부분만 성능 측정하기
곡에 대한 요청을 하는 부분인 TrackService의 getTrack()에 대해서만 측정을 하기 위해 아래와 같이 Aspect를 등록한다.
@Aspect
@Component
public class PerformanceAspect {
@Around("execution(* com.musicpedia.musicpediaapi.domain.track.service.TrackService.getTrack(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime;
System.out.println(joinPoint.getSignature() + " executed in " + executionTime + "ms");
return result;
}
}
- @Around: 대상 메서드의 실행 전과 후, 또는 예외 발생 시에 추가적인 동작을 정의한다. 메서드를 감싸고 프록시를 통해 메서드 호출 전후에 코드를 삽입할 수 있다.
- ProceedingJoinPoint: 메서드의 실행을 제어하고, 필요한 경우 실행 전후에 추가적인 작업을 수행할 수 있다.
- joinPoint.proceed(): 메서드를 실행한다.
- joinPoint.getSignature(): 메서드의 이름, 리턴 타입, 매개변수 타입 등이 포함되어 있고, 로깅이나 디버깅할 때 사용한다.
1.3 기존 성능 측정 결과
첫 호출이 그 이후의 호출에 비해 느린 것은 RestTemplate을 초기화할 때 생기는 오버헤드(연결 설정 등)로 추정이 된다. 이후 호출에서는 평균적으로 150 ~ 250ms 정도의 응답 속도를 확인할 수 있었다.
2. 성능 개선하기
2.1 개선할 방식
- [사용자] 이 주의 추천 곡에 있는 곡들 중 하나 클릭
- [클라이언트] 해당 곡의 track id로 곡 정보 조회 요청
- [서버] 이 주의 추천 곡에 해당하는 track id라면 redis에서 조회, 그렇지 않으면 RestTemplate으로 정보 조회
- 곡 정보와 사용자의 평점 정보 등을 함께 응답
3번을 제외하면 기존 방식과 동일하다. 요점은 이 주의 추천 곡에 해당하는 track id에 대한 곡 정보 요청이 들어온다면 redis에서 조회한다는 점이다.
2.2 문제점
하지만 이 방식에는 큰 문제점이 하나 있다.
이 주의 추천 곡에 해당하는 track id인지 확인하기 위해서는 당연히 이 주의 추천 곡의 track id들을 가지고 있어야 한다.
우리가 직접 데이터베이스의 데이터를 수정하여 추천 곡을 제공하고 있기 때문에 서버에서 변경 사항을 가져오려면 곡 정보 요청 시 업데이트(되지 않았을 수도 있는)된 track id들을 가져온 뒤 비교해야 한다.
그렇게 되면 이 주의 추천 곡이 아닌 곡 정보 요청에 대해서도 매번 데이터베이스에서 track id를 가져와 비교하는 작업이 추가되기 때문에 성능이 더 안 좋아질 수 있다.
2.2.1 해결방법 1 - redis에 직접 데이터 넣기
데이터베이스에 추천 곡을 업데이트할 때 redis에도 해당 추천 곡들의 track id와 그에 대한 응답을 직접 넣으면 해결할 수 있다. 하지만 Spotify API에 대한 응답을 뮤피에서 사용하는 정보로 직접 매핑을 하는 것은 매우 번거로운 일이고 불필요한 일이다.
2.2.2 해결방법 2 - Spring Scheduler 사용하기
Spring Scheduler를 사용하면 특정 시점에 코드를 실행할 수 있는데, 이를 사용해서 Runtime에 데이터를 수정할 수 있다. Scheduler를 사용해서 데이터베이스에서 업데이트된 추천 곡 track id들을 주기적으로 가져오면 해결할 수 있다.
@EnableScheduling
@SpringBootApplication
public class MusicpediaApiApplication {
public static void main(String[] args) {
SpringApplication.run(MusicpediaApiApplication.class, args);
}
}
우선 Application 클래스에 @EnableScheduling를 추가하여 Scheduler를 활성화한다.
// WeeklyRecommendationService
private List<String> weeklyRecommendationTrackIds;
@PostConstruct
public void init() {
this.weeklyRecommendationTrackIds = findWeeklyRecommendationTrackIds();
}
@Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 자정에 실행
public void updateWeeklyRecommendationTrackIds() {
this.weeklyRecommendationTrackIds = findWeeklyRecommendationTrackIds();
}
매주 월요일에 추천 곡을 업데이트하기 때문에 매주 월요일 자정에 업데이트된 추천 곡 track id들을 가져오도록 설정한다.
추가로 @PostConstruct를 사용하여 빈이 생성된 후(생성자 호출 이후) weeklyRecommendationTrackIds를 데이터베이스에서 가져와서 초기화한다.
이렇게 하면 데이터베이스 상의 업데이트된 데이터를 Runtime에 반영할 수 있다.
3. @Cacheable로 캐싱하기
문제를 해결했으니 이제 응답을 캐싱하면 된다.
// RedisCacheConfig
@Bean
public CacheManager weeklyRecommendationCacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration redisCacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofDays(7L));
return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration)
.build();
}
이 주의 추천 곡 캐시같은 경우 직렬화 방식을 key는 String, Value는 Json으로 설정하였다. 또한 TTL은 1주일로 설정하였다.
@Cacheable(value = "weeklyRecommendation", key = "#trackId", condition = "@weeklyRecommendationService.getWeeklyRecommendationTrackIds().contains(#trackId)")
public SpotifyTrack getTrack(long memberId, String trackId) {
// Spotify API 호출
// ...
}
위의 @Cacheable의 앞부분을 해석하면 캐시 이름은 weeklyRecommendation이고, 매개변수로 들어온 track id를 key로 설정하여 캐싱하겠다는 뜻이다.
condition은 위의 WeeklyRecommendationService에 있는 추천 곡 track id들을 가져와서 매개변수로 들어온 track id가 포함되어 있는지 확인한다. 포함되어 있다면 redis를 확인하여 응답하고, 그렇지 않으면 Spotify API를 호출하여 곡 정보를 받아온다.
4. 성능 개선 결과
일반 곡 정보 요청 시
이 주의 추천 곡이 아닌 곡에 대해 정보 요청을 하면 redis에 캐싱이 되지 않는다.
이 주의 추천 곡 정보 요청 시
이 주의 추천 곡을 요청하면 다음과 같이 캐시 이름은 weeklyRecommendation, key는 track id로 캐싱이 되는 것을 확인할 수 있다.
성능
캐싱이 된 상태에서는 TrackService의 getTrack 메서드가 실행되지 않고 바로 응답 값을 반환하기 때문에, 여기서는 API 요청에 대해 테스트를 진행했다.
그럼에도 무려 5ms밖에 안나온다. 여러 번 요청해 봐도 5~20ms 정도의 응답 속도를 확인할 수 있었다.
마무리
캐싱을 사용한다면 조회가 잦은 부분에만 사용해야 한다. 내 경우에는 “이 주의 추천 곡”과 같이 자주 조회될 수밖에 없는 부분에 적용하는 것은 좋은 선택이지만, 일반 곡 검색 같은 곳에 캐싱을 하면 Cache hit 없이 불필요한 캐시 조회, 덮어쓰기 작업만 반복되기 때문에 오히려 성능을 떨어뜨릴 수 있기에 조심할 필요가 있다.
'BE > Spring Boot' 카테고리의 다른 글
템플릿 메서드 패턴을 활용한 소셜 로그인 리팩토링하기 (with OIDC) (2) | 2023.11.28 |
---|---|
Open ID Connect 으로 구글 로그인 구현하기 (feat. SpringBoot) (1) | 2023.11.02 |
Jwt와 Interceptor 사용 시 CORS 에러가 발생하는 이유 (0) | 2023.06.09 |
로그인 시 id가 없을 때 status code는 200? 404? (0) | 2023.03.31 |
Spring Boot + Docker로 배포하기 (0) | 2022.07.24 |