-
spring cache 커스텀으로 성능개선하기글또 2024. 1. 21. 12:15
0. 들어가며
안녕하세요.
이번에는 spring cache를 커스텀하게 사용한 이야기를 해보려 합니다.
개발 중인 서비스에서 성능 개선 요청이 있어서 캐시를 사용하게 되었습니다.
이로 인해 성능이 25% 정도 개선되었는데요.
이번 글의 전반적인 내용은 캐시 커스텀 이유와 구현 방법입니다.
캐시를 선택하게 된 이야기 등은 성능개선 글에서 따로 다룰 예정입니다.
JPA를 쓰지 않는 Mybatis환경입니다.
1. 문제 환경
성능 개선을 요청했던 기능은 많은 데이터를 한 번에 처리하는 대용량 작업이었습니다.
문제가 되는 상황을 살펴봅시다.
루프를 돌면서 주문과 상품을 조회해서 재고를 업데이트하는 형식입니다.
단건의 경우는 0.07초의 성능이 나오는 기능이었지만 주문이 10000건으로 늘어나게 되면 70초나 걸리게 됩니다.
이런 기능은 백그라운드에서 돌리고 사용자에게는 알림을 주는 방법 등의 해결방안이 있을 수 있습니다.
하지만 저희는 사용자에게 바로 응답을 줘야 했기 때문에 성능 개선이 필요했습니다.
성능 개선 방법으로는 사용되는 모든 재고와 상품을 조회해 메모리에 올리는 방법이 있을 수 있습니다.
다만 저희는 기본 구조를 건드리기보다는 재사용성에 중점을 두었고 이를 중심으로 성능 개발을 할 수 있는 방법을 찾고 있었습니다.
재고와 달리 상품은 중복 조회 SQL 호출이 많다는 것을 발견했습니다.
그래서 저희는 분산서버임을 고려하면서 캐시를 사용하기로 했습니다.
2. 서버 아키텍처 구성
대부분의 애플리케이션 서버가 안정성 높게 분산서버로 되어있듯 저희도 분산서버로 되어있습니다.
분산서버에서 캐시를 구성하는 방법은 여러 가지가 있지만 데이터 동기화를 중요하게 생각한다면 로컬캐시보다 글로벌 캐시를 선택하는 것이 좋습니다.
저희 또한 데이터 동기화가 중요하기 때문에 글로벌 캐시로 지정해야 하는 것이 베스트였습니다.
하지만 글로벌 캐시를 하나 두는 것은 관리 포인트를 하나 더 두는 것이고, 백오피스 애플리케이션의 성능을 위해 관리 포인트를 하나 더 두는 것이 좋은 것 일지 고민했습니다.
결국 분산환경에서 데이터의 일관성을 지키고, 글로벌 캐시는 도입하지 않으려면
로컬 캐시를 사용하면서 생명주기를 잘 지정해야 한다는 결론에 이르렀습니다.
로컬 캐시는 관리 포인트가 적고 성능상 이점은 있지만 타 노드와 데이터 공유가 되지 않기 때문에 데이터 동기화가 되지 않을 수 있습니다.
이를 해결하기 위해 캐시의 생명주기를 트랜잭션의 생명주기 와 동일하게 가져가기로 했습니다.
트랜잭션에서만 중복 SQL이 캐시 되며 분산환경에서 데이터 동기화를 신경 쓰지 않아도 됩니다.
3. 지원되는 기능 찾기
트랜잭션 안에서 캐시를 지원하는 것은 많이 있습니다.
JPA를 사용했다면 동일한 트랜잭션 내부에서는 1차 캐시의 값을 가져옵니다.
덕분에 DB와 네트워크 통신이 줄어들어 성능이 좋았을 것입니다.
하지만 저희는 Mybatis를 사용하고 있었기 때문에 JPA의 1차 캐시는 없습니다.
Mybatis도 로컬 캐시라는 1차 캐시가 있지만 캐시에 있는 내용은 insert, update, delete가 이뤄지면 지워집니다.
성능개선을 요청한 메서드에서는 재고 업데이트는 계속 이뤄지기 때문에 로컬 캐시의 성능을 제대로 발휘하기 힘들었습니다.
(secode cache도 있는데 이는 애플리케이션의 글로벌 캐시와도 같기에 데이터 동기화를 위해 사용하지 않았습니다.)
결국 원하는 것은 Mybatis의 로컬 캐시와 같은 기능을 같지만 insert, update, delete가 이뤄져도 캐시가 유지되는 것입니다.
4. 커스텀하기
그래서 커스텀을 하도록 방향을 잡았습니다.
기본적인 구현은 spring cache를 활용할 것이고 캐시의 생명주기를 지정하기 위해서 TransactionSynchronizationManager를 사용했습니다.
caffeine 등 다른 캐시 구현체들이 있지만 TTL (Time To Live)을 적절하게 설정하지 않았다면 다른 노드와 데이터 불일치가 일어날 수 있습니다.
TTL을 작게 갖는다고 해도 0에 수렴하지 않는 이상 데이터 불일치는 일어날 수 있습니다.
그래서 트랜잭션과 동일한 생명주기를 가지는 캐시를 만들기로 했습니다.
트랜잭션 내부에서만 캐시 이기 때문에 TransactionSynchronizationManager라는 것을 사용해서 트랜잭션 내부인지를 검사합니다.
그리고 값을 스레드 로컬에 저장해서 사용합니다.
같은 스레드 안에서는 계속해서 캐시 된 값을 사용할 수 있습니다.
여기서 주의할 점이 있습니다.
만약 스레드 풀을 사용할 경우 스레드 로컬의 값을 잘 지워줘야 한다는 것입니다.
그렇지 않으면 반납된 스레드를 다른 요청이 사용하게 되고 이전에 캐시 된 내용을 공유하게 됩니다.
이를 지우기 위해서는 커밋, 롤백 전에 스레드 로컬의 내용을 지우는 트리거를 추가했습니다.
해당 트리거는 TransactionSynchronization를 통해 정의가 가능합니다.
스레드 로컬에 캐시를 만드는 시점에 트리거를 걸어주었습니다.
트랜잭션 안에서 캐시 되고 트랜잭션이 끝날 때 스레드 로컬의 내용을 지우고 반납합니다.
트랜잭션이 길고 내부에서 여러 호출이 있을 때 사용할 수 있습니다.
물론 JPA 같은 1차 캐시를 지원하는 기능을 사용하면 굳이 구현하지 않아도 되는 내용입니다.
5. 끝으로
이로 인해서 관리포인트를 늘리지 않으면서 25%의 성능 개선을 할 수 있었습니다.
또한 공부하면서 새로운 개념들을 알게 되었습니다.
TransactionSynchronizationManager는 알지 못한 기능이었는데 트랜잭션 내부에서 데이터 동기화를 위해서 사용된다는 것을 알게 되었고 더 깊이 있게 스프링의 트랜잭션 구현 방법을 알 수 있었습니다.
Sample Code
'글또' 카테고리의 다른 글
성능 개선: 단계별 접근과 최적화 전략 (0) 2024.03.01 유데미 개발자 영어 (0) 2024.02.03 테크니컬 라이팅 강의를 듣고나서 (0) 2024.01.06 Spring Async에서 SecurityContextHolder 접근하기 (0) 2023.11.04 spring security white list 어노테이션 만들기 (0) 2023.11.01