🍞 상품의 캐싱처리
현재 나의 서비스는 대규모 요청을 처리하는 것에 포커싱이 맞춰져있다. 그렇다면 서비스를 사용할때 사용자가 가장 많이 요청을 하는 곳이 어디일까? 바로 전체 상품을 보는 API 요청이 가장 많이 오게 된다는 것이다.
그렇다면 현재 코드를 한 번 돌아봐야 한다. 지금은 사용자가 전체 상품을 보기 위해 요청을 하면 우리의 서비스 DB로 들어가서 전체 상품을 가져와서 응답을 한다 물론 DBMS의 성능이 무척 좋기 때문에 개인이 테스트하는 수준으로는 DB의 과부하를 만들기는 쉽지 않겠지만 가정을 해본다면 DB의 과부하를 발생시킬수 있다는 것이다.
어쨌든 DB의 과부하는 굉장히 위험하다. 해당 서비스가 장애가 발생하면서 아예 먹통이 되어 버릴수도 있다는 것이다.
1차 해결 방법 JPA의 Pagenation
그래서 전체 List를 반환하는 것에서 JPA의 Pagenation을 통해 일부분만 반환하는 것을 사용해서 성능을 조금 더 높이는 것을 택했다.
Page를 반환하면 Full Scan 쿼리로 전체 상품의 갯수를 확인해서 몇 페이지로 만들어질지 확인하고 그다음 limit 과 offset을 이용한 쿼리문을 통해서 상품을 반환한다.
하지만 굳이 Full Scan 쿼리가 꼭 필요할까? 그래서 다른 Interface인 Slice를 사용해보았다.
Pageable의 Page 와 Slice의 성능 테스트
List 와 Page 그리고 Slice 현재 나의 홈화면은 모든 상품을 나타내는 화면이다. 즉, 홈화면을 나타내는 것만으로도 모든 데이터를 접근하는 쿼리문이 실행이 된다는 것인데 그렇다면 그 상품을 가
latewalk.tistory.com
문제
하지만 역시 DB를 직접적으로 들어가서 내용을 반환 받는 다는 것은 여전히 RDB에 의존하여 데이터를 다루고 있다. 이것은 결국 어쨌든 부하는 생길수 있고 결과는 하나 안하나 별로 크게 상관이 없다는 것이다.
캐시
앞서 말했던 것처럼 자주 접촉하는 데이터를 더욱 빠르게 답하고 서버에 무리도 덜 가게 하기 위해서 캐시 저장소를 사용할 필요가 있었다. 데이터가 하드디스크에 있는 MySQL보다 서버메모리에 존재하는 즉, In-Memory로 존재하는 데이터를 읽어 오는 것이 빠르기 떄문에 이번엔 캐시를 사용해서 빠른 응답을 나타나게 해보려고 한다
🍞 Spring의 Cache 인터페이스 활용
Spring 에서는 빈의 메소드에 AOP를 적용해서 캐싱을 할 수 있는 인터페이스를 제공한다. Spring의 캐시 추상화는 특정 구현체에 종속되지 않으며 어플리케이션 코드를 수정하지 않고 캐시 부가기능을 추가할 수 있다. 기본적으로 ConcurrentHashMap을 통한 캐싱을 제공하고, Redis, Memcached등 캐시를 이용한 서비스가 변경되어도 어플리케이션 코드에 영향을 주지 않고 캐싱을 처리할 수 있다.
종류
1. @Cacheable() : 캐시 채우기
해당 어노테이션이 걸린 메소드가 동작하기 이전에 cache를 먼저 확인한다. cache에 데이터가 없다면 메소드를 호출하고 캐시에 저장한다. 이때 캐시는 여러개를 호출하는 것도 가능하다
- value: 캐시의 이름을 지정
- key : 캐시의 키를 지정한다 SpEL을 사용해서 표현한다. 기본값은 메소드의 모든 파라미터를 사용해서 생성된 키
- condition : 캐시를 적용할 조건을 SpEL로 지정한다
- unless : 캐시를 사용하지 않을 조건을 SpEL로 지정한다. true인 경우 캐시가 되지 않는다
- sync : 캐시를 동시에 여러 스레드에서 안전하게 접근할 수 있도록 한다
2. @CacheEvict() : 캐시 제거
주로 삭제하는 API에서 많이 사용된다. 메소드가 실행되기 전에 해당 키에 관한 내용을 삭제하고 메소드가 실행된다
- allEntries : true로 설정하면 캐시의 모든 엔트리를 제거한다.
- beforeInvocation : true로 설정하면 메소드의 실행 전에 캐시를 제거한다. 기본값은 false이다
3. @CachePut() : 캐시 갱신
메서드의 실행 결과를 캐시에 저장하고 메소드는 항상 실행된다. 캐시를 업데이트한다.
4. @Caching() : 복잡한 캐싱 작업
서로 같은 종류는 종류끼리 캐시 메소드를 사용하고 싶을때 사용한다
- 위에 설명한 Cache작업들을 안에서 사용할 수 있다.
🍞 Local Cache 와 Global Cache
캐싱을 하는 방법은 크게 Local 과 Global로 나뉘어져 있다. 두 캐싱에는 무슨 차이가 있을까
Local Cache
- WAS의 메모리에 데이터를 저장한다
- 별도의 infrastructure가 필요하지 않다
- 서버를 scale out 하는 경우, 서버마다 캐싱된 데이터가 달라질 수 있다
- 캐싱 데이터가 커지면 메모리의 사용량이 많아진다
❓ Local Cache가 뭘까?
Local Cache가 뭘까?
대부분의 사람들은 캐시를 사용하기 위해 redis라는 infra를 구축하고 사용한다. 하지만 Local Cache가 있다는 사실
그럼 과연 Local Cache를 대표하는 Ehcache 와 Caffeine 의 차이를 알아보자
- Ehcache는 많은 기능을 제공하지만 Caffeine에 비해 적용시키는 것이 까다롭다. 왜냐하면 Ehcache는 XML을 사용하고 각각의 설정을 해줘야하기 때문이다.
- Caffeine은 최신 알고리즘을 사용하기 때문에 Ehcache보다 훨씬 성능이 좋다.
❤ 추가
Ehcache 2버전과 3버전의 차이
- 3버전 부터는 javax.cache API (JSR-107)과의 호환성을 제공한다
- offheap이라는 저장공간을 제공한다 : 힙 메모리를 벗어난 메모리로 java GC에 의해 데이터가 정리되지 않은 공간이다
ehcache3버전은 캐싱할 데이터를 외부 메모리에 저장하기 위해서는 저장할 데이터가 Serializable이 구현되어 있어야 한다. 즉, 캐싱할 데이터는 Serializable을 상속받은 클래스여야 한다는 것이다.
왜냐하면, ehcache가 JVM의 힙 메모리가 아닌 곳에 캐시를 저장하기 위해서는 JVM 메모리에 인스턴스화 되어 있는 객체의 데이터를 외부에서 사용할 수 있게 하기 위해 직렬화가 필요하다
Global Cache
- Redis 등의 방법으로 캐싱을 활용하는 방법이다
- WAS를 scale out 하더라도, 캐싱 데이터가 동일하다 즉, 확장에 유리하다는 것이다
- 별도의 infrastructure가 필요하다
- 네트워크 비용이 발생하기 때문에 LocalCaching보다 속도는 느릴 수 있다.
♬ 어떤 캐시를 사용해야할까?
기본적으로 MSA 환경의 프로젝트이기 때문에 서버의 증설이 무조건 발생한다고 두고 캐싱 작업을 진행해야 한다. 그렇다면 확장에 유리한 Redis를 사용해야 다른 서버로 요청이 갔을때 같은 데이터를 전송할 수 있다.
성능은 Redis에 비해 훨씬 유리할 수는 있어도 장기적으로 데이터를 업데이트하거나 캐시에 올릴때 비용이 너무 빈번하게 발생한다.
🍞 Redis
MemCache가 아닌 Redis를 사용하는 이유는 다양한 컬렉션을 제공하고 원자성을 보장하기 때문에 자원 경쟁을 피할 수 있고 트랜잭션의 경합을 덜 받는다. 또한 싱글스레드로 만들어져 있기 때문에 쓰레드가 안전하고 멀티쓰레드의 이슈가 존재한다.
♬ 캐싱 전략
Write는 이미 적혀있는 내용을 가져오는 것이기 때문에 굳이 설명하지 않고 데이터를 가져오는 Read에 초점을 맞춰보았다. Look Aside와 Read Through 중에 보편적으로 사용되는 Look Aside방식을 이용하고자 한다. 이용하기전에 간단하게 설명을 하자면 Redis에 접촉하여 데이터를 읽고 만약 데이터가 DB와 일치하지 않는다면 서버는 데이터를 새롭게 DB에서 읽어 Redis에게 전달한다.
♬ 구현에 앞서...
내가 구현해보려고 하는 것을 글로 한번 더 설명해보려고 한다.
- 현재 나의 상품 전체 리스트를 가져오는 것은 Slice방식이다. 하지만Page형식으로 변경해서 해당 페이지의 데이터를 Redis에 저장하고 나타내려고 한다.
- 이것을 Redis로 구현하기 위해 Spring에서 Cache인터페이스를 활용해서 캐시에 넣는 방식을 구현한다.
이렇게 생각을 해보았고 그대로 구현을 해보고자 한다.
♬ RedisConfig 구현
1. RedisConnectionFactory 빈 생성
- 사용하는 모드에 따라 Configuration 인스턴스를 생성한 후, host와 port를 작성해 setting 해주었다
- 이후 LettuceConnectionFactory에 해당 configuration을 넣어서 반환하면 빈 생성이 완료된다
2. RedisCacheManager 빈 생성
- defaultCacheConfig() : 만료시간이 따로 없이, null value, prefix key를 허용하고 캐시이름을 prefix로 설정한다
- serializeValueWith() : Serialize의 기본인 JdkSerializationRedisSerializer에서 GenericJackson2JsonRedisSerializer로 변경했다. 이유는 JSON을 사용하기 위함이다.
- return 값으로 build를 사용해서 Lettuce기반의 커넥션을 사용하고 cacheDefaults에 위에 정의한 내용을 넣어주면서 Manager를 커스터마이징한다
♬ Redis의 캐싱처리를 한 메소드
이렇게 로직을 짜면 페이지 별로 상품을 redis에 저장하고 후에 누군가가 저장되어 있는 페이지에 들어가면 굉장히 빠른 속도로 데이터를 가져오는 것을 확인 할 수 있다.
여기엔 꽤나 고생했던 흔적이 있다...여기에 오류를 적어놓았다
🍞 테스트
기본적인 테스트 환경은 3분동안 10000개의 요청이 들어오고 시간이 지남에 따라 응답 속도를 통해 부하테스트를 하고자 한다
상품의 총 갯수는 1만개이다.
🎞 1번 테스트 : List 방식
리스트 형식은 역시 응답속도가 형편없었다. 1만개의 데이터를 계속해서 요청을 함에따라 응답속도는 점점 느려지는 것을 확인할 수 있다. 심지어 2분50초 가량을 보면 갑자기 응답이 빨라지는 것을 볼 수 있는데 이맘때 갑자기 오류량이 급격하게 증가되었다. 그 이유가 뭘까
- 쓰레드 풀의 한계
- 네트워크 대기 시간
- 자원 부족
- 병목현상
등등 다양한 이유가 있겠지만 중요한건 어쨌든 테스트 환경 즉, 서비스를 이용하려는 사람이 많으면 많을 수록 점점 화면을 응답받는 속도가 급격하게 저하되고 심지어 응답을 못받는 상황까지 올 수 있다는 것이다.
그래서 1만개의 데이터를 전부 전환받는 것이 아닌 1만개의 데이터를 페이지화해서 일부 값만 가져오게 만든다.
결과
시간이 지나면서 점점 응답 속도가 느려지고 위에 설명했던 이유를 기반으로 응답을 아예 못하는 상황까지 오며 서버의 성능이 안좋다는 것을 증명한다.
3분동안의 1만 요청 처리 시간이 : 70초...
🎞 2번 테스트 : Page 방식
놀라운 결과를 볼 수 있다. 밑에 사진이 조금 짤렸지만..(캡쳐 능력이..) 어쨌든 TPS가 굉장히 평균적으로 나오는것을 확인 할 수 있다. 그리고 응답시간 또한 굉장히 낮은 속도로 나오는 것을 확인 할 수 있다. 한번 튄 것때문에 표가 보기 어려워 졌지만 평균 응답 속도가 0.012초라는 것만 봐도 굉장히 빠르다는 것을 확인할 수 있다.
결과
List 보다 훨씬 더 빠른 성능을 보이고 또한 TPS도 엄청나게 빠르다는 것을 확인했다. 어쨌든 Page를 통해 1만개의 요청정도는 버틸 수 있다는 것을 알게되었다. 하지만 Page도 역시 DB에 직접적으로 접근하는 것이고 쿼리문이 발생한다는 것 자체를 없애기 위해 캐싱을 도입해보고자 한다.
🎞 3번 테스트 : Redis를 이용한 캐싱 방식
Redis를 사용한 캐싱의 효율성을 굉장히 잘 보여주는 사진이다. 데이터 처리량 TPS은 비슷하다. 하지만 중요한 건 응답 속도이다. 놀랍게도 평균값이 5ms 라는 말도 안되는 성능을 보여준것이다. 또 그래프 상으로는 시간이 지나면 지날수록 점점 빨라지는 것까지 볼 수도 있다는 것이다.
결과
1만개의 요청을 굉장히 일정하게 처리하고 직접적인 DB에 접근도 하지 않으며 그것에 대한 응답속도까지 굉장히 빠른 캐싱을 사용하는 이유를 알 수 있게 되었다.
🍞 참고
'프로젝트 > 항해99 개인 프로젝트' 카테고리의 다른 글
🚢 쿠폰 발급 시 사용자에게 전달하는 비동기통신 Kafka (0) | 2024.05.15 |
---|---|
🚢 Redis를 사용해서 선착순으로 쿠폰 발급하기 (0) | 2024.05.13 |
🚢 중간테이블로 인한 JPA의 N+1문제 (0) | 2024.05.09 |
🚢 MSA의 동시성 제어를 위한 Lock 사용(feat. Redis의 분산락) (1) | 2024.04.30 |
🚢 Feign Client 와 RestTemplate (0) | 2024.04.30 |