이번 글에서는 메인화면에 세미나 목록을 보여주는 기능에 ehCache를 활용해보려 합니다. 많은 사람들이 사이트에 들어오면 가장 많이 접속하는 곳이 메인화면입니다. 이떄, 저희 메인화면의 세미나 목록은, 관리자 혹은 그 전날까지의 일정기간 동안 가장 인기있는 세미나 목록을 하루동안 혹은 약 3,6,9시간 이상 보여주는 목록의 기능이 있습니다.
세미나 목록의 데이터가 잘 바뀌지 않습니다.
그렇기에, 이러한 정적인 점을 이용하여 ehCache를 통하여 메모리에 캐싱하여, 메인화면에 접속시 Memory에서 데이터를 가져오는 이득을 취하기 위해 ehCache를 적용하게 되었습니다.
1. Entity 구성
ehcache를 적용할 Seminar Entity 구성입니다.
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"member_seminar_list"})
public class Seminar extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long seminar_no;
@Column(length = 100, nullable = false, unique = true)
private String seminar_name;
@Column(length = 500)
private String seminar_explanation;
@Column
private Long seminar_price;
@Column(nullable=true)
private LocalDateTime del_dt;
@OneToMany(mappedBy = "seminar", fetch = FetchType.LAZY)
private List<Member_Seminar> member_seminar_list;
}
2. Build.gradle에 의존성을 추가합니다.
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-cache', version: '3.0.2'
implementation group: 'org.ehcache', name: 'ehcache', version: '3.10.8'
implementation group: 'javax.cache', name: 'cache-api', version: '1.1.1' //
- spring-boot-starter-cache를 통해 Spring Boot Application에서 캐시를 간단하게 설정하고 사용할 수 있게합니다.
- ehcache를 활용해 메모리 기반 캐싱을 구현합니다.
- cache-api를 활용해 JSR-107 캐시 API 구현체를 제공합니다. JSR-107 인터페이스를 활용하면 캐시 구현체를 쉽게 구현할 수 있습니다. 예를들어 EhCache에서 JCache API로의 전환이 가능합니다.
3. Main 함수에 @EnableCaching을 추가합니다.
@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
public class SeminarHubMonolithic {
public static void main(String[] args) {
System.out.println("Hello world!");
SpringApplication.run(SeminarHubMonolithic.class, args);;
}
}
3. Config 파일에 ehCacheConfig.class 를 추가합니다.
@Configuration
@EnableCaching
public class ehCacheConfig {
@Bean
public CacheManager getCacheManager(){
CachingProvider provider = Caching.getCachingProvider();
CacheManager cacheManager = provider.getCacheManager();
CacheConfiguration<String, ArrayList> configuration = CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class, ArrayList.class
, ResourcePoolsBuilder.heap(100).offheap(10, MemoryUnit.MB))
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofHours(3)))
.build();
javax.cache.configuration.Configuration<String, ArrayList> cacheConfiguration = Eh107Configuration.fromEhcacheCacheConfiguration(configuration);
cacheManager.createCache("mainPageSeminarList", cacheConfiguration);
return cacheManager;
}
}
- @Configuration 어노테이션: 이 클래스는 스프링의 Java 기반 환경 설정 클래스임을 나타냅니다.
- @EnableCaching : 이 어노테이션은 Spring의 캐시 기능을 활성화시킵니다. 이를 통해 @Cacheable, @CachePut, @CacheEvict 등의 캐시 관련 어노테이션을 사용할 수 있습니다.
- Caching.getCachingProvider(); : CachingProvider를 사용하여 캐시 프로바이더를 가져옵니다.
- getCacheManager() 메서드: 이 메서드는 CacheManager 빈을 생성하고 설정합니다.
- CacheConfigurationBuilder.newCacheConfigurationBuilder : 캐시의 기본구성을 정의합니다.
- String 형식의 Key와 ArrayList 형식의 Value를 가진 캐시를 생성합니다.
- 메모리에 저장할 캐시 항목의 최대 수는 100개 입니다. 최대 100개의 항목을 메모리에 보관합니다. Heap은 JVM의 Heap 메모리를 의미합니다.
- Heap에 대해 더 설명해보자면, JVM 힙은 Java에서 생성하는 객체를 저장하는 공간입니다.
- 모든 Class Instance와 배열은 Heap에 저장됩니다.
- Heap 메모리에 할당된 객체 중에 더 이상 사용되지 않는 객체들은 가비지 컬렉션에 의해 자동으로 해제됩니다.
- 10MB까지의 데이터를 OFF-heap으로 저장할 수 있습니다. Off-heap은 JVM 힙 영역 외부에 있는 메모리를 의미합니다. 이 영역은 일반적으로 직렬화된(Serializable) 형태로 데이터를 저장하며, JVM의 가비지 컬렉션에 영향을 주지 않습니다. Off-heap은 직접 관리해야하므로 사용에 주의가 필요합니다.
- 캐시항목은 10분동안 유효합니다.
- Eh107Configuration.fromEhcacheCacheConfiguration(configuration): JSR-107의 캐시 구성을 생성합니다.
- cacheManager.createCache("seminarPageList", cacheConfiguration);: 캐시 매니저에 seminarPageList라는 이름의 캐시를 등록합니다.
4. 우리가 메인페이지에서 호출할 Repository 를 생성합니다.
먼저 SQL을 짜봅니다.
가장 기본적인 페이징 SQL입니다.
SELECT s.*
FROM seminar s
order by s.seminar_no desc
limit 10 OFFSET 0;
- 메인페이지에서 위와 같이 사용해도 되지만, 페이지수가 많아지면 속도가 느려질 것입니다.
- 메인페이지에서는 사실 가장 최근글이나 특정한 글을 검색하여 진행하여서 위와 같이해도 문제는 없을것같다고 생각하지만 최악의 경우를 상정해서 진행해보겠습니다.
아래의 SQL구문을 활용하여 커버링 인덱스를 적용합니다.
select s1.*
from seminar as s1
join (select s2.seminar_no
from seminar s2
order by s2.seminar_no desc
limit 10 OFFSET 0
) as temp on temp.seminar_no = s1.seminar_no;
- 커버링인덱스가 올바르게 적용됩니다.
- 이제 위의 코드를 Querydsl로 변환합니다.
- @Cacheable 어노테이션 : 메서드의 리턴값을 캐시합니다. value 속성은 캐시의 이름을, key 속성은 캐시의 키를 정의합니다. 여기서는 pageNo와 pageSize를 기반으로 캐시를 생성합니다.
@RequiredArgsConstructor
@Repository
public class SeminarQuerydslRepository {
private final JPAQueryFactory queryFactory;
@Cacheable(value = "mainPageSeminarList", key = "'page-' + #pageNo + '-limit-' + #limit")
public List<SeminarPageResultDTO> mainPagePagingSeminarWithEhCache(int pageNo, int pageSize){
QSeminar seminar = QSeminar.seminar;
List<Long> ids = queryFactory
.select(seminar.seminar_no)
.from(seminar)
.orderBy(seminar.seminar_no.desc())
.limit(pageSize)
.offset(pageNo*pageSize)
.fetch();
if(CollectionUtils.isEmpty(ids)){
return new ArrayList<>();
}
List<SeminarPageResultDTO> seminarPageResultDTOList = queryFactory
.select(Projections.fields(SeminarPageResultDTO.class,
seminar.seminar_no,
seminar.seminar_name,
seminar.seminar_explanation,
seminar.seminar_price
))
.from(seminar)
.where(seminar.seminar_no.in(ids))
.fetch();
return seminarPageResultDTOList;
}
}
5. SeminarService와 SeminarServiceImpl
- Service Layer에 해당하는 함수들도 생성합니다.
public interface SeminarService {
List<SeminarPageResultDTO> mainPagelist(int pageNo, int pageSize);
}
@Service
@Log4j2
@RequiredArgsConstructor
public class SeminarServiceImpl implements SeminarService{
@Override
public List<SeminarPageResultDTO> mainPagelist(int pageNo, int pageSize) {
List<SeminarPageResultDTO> result = seminarQuerydslRepository.mainPagePagingSeminarWithEhCache(pageNo, pageSize);
return result;
}
}
6. SeminarController
- Controller에서 REST API를 만듭니다.
- 시간 검증을 위해 startTime과 endTime을 활용합니다.
@RestController
@Log4j2
@RequestMapping("/api/v1/seminar")
@RequiredArgsConstructor
@Tag(name = "3. Seminar API")
public class SeminarController {
private final SeminarService seminarService;
@GetMapping(value ="/mainPage/list")
@Operation(summary = "5. Get seminar list by ")
public ResponseEntity<List<SeminarPageResultDTO>> mainPagelist(@RequestParam("pageNo") int pageNo, @RequestParam("limit") int limit){
log.info("-------------------list----------------------");
log.info(pageNo + " "+ limit);
Long startTime = System.currentTimeMillis();
List<SeminarPageResultDTO> dtoList = seminarService.mainPagelist(pageNo, limit);
Long endTime = System.currentTimeMillis();
System.out.println("Execution Time:"+ (endTime - startTime) + "ms");
return new ResponseEntity<>(dtoList, HttpStatus.OK);
}
}
7. 작동테스트.
이제 실제로 캐시가 작동하는지 확인해보겠습니다.
우리가 호출해야하는 API는 "/api/v1/seminar/mainPage/list" 에 pageNo와 limit을 함께 보냅니다.
- 올바르게 SQL이 호출되었습니다.
- 시간은 226ms가 걸렸습니다.
- 이제 다시 한번 같은값을 가지고서 API를 호출합니다.
- 이번에는 5ms가 걸렸고, query를 실행하지 않았습니다.
- 또 다시 한번 호출하면
- 이번에는 아예 0 ms가 나옵니다. 쿼리 또한 실행되지 않습니다.
해당 메소드를 호출할시 캐시값을 가지고서 memory에 있는 key-value 형태의 값을 가지고 오는것을 알 수 있습니다.
정적인 홈페이지의 값들을 가져올떄 이러한 방식을 활용하면 빠른 Return 값을 가져오고, 불필요한 서버자원을 전혀 사용하지 않을 수 있습니다.
추가적인 @Cache 관련 기능들 정리
위에서는 단순히 @Cacheable 을 활용하여 캐시를 넣는 것을 테스트했습니다.
어떤 상황에서는 캐시를 강제로 저장, 캐시를 삭제, 캐시를 여러개 업데이트 하는 작업이 필요할 수 있습니다.
그때는 아래와 같은 어노테이션을 활용할 수 있습니다.
- @CachePut
- @CacheEvict
- @Caching
어노테이션 사용예시입니다.
1. @CachePut : 메서드의 결과를 캐시에 저장합니다. 메서드가 항상 실행되고, 그 결과를 캐시에 갱신합니다. 이를 통해 캐시를 업데이트할 때 사용할 수 있습니다.
@CachePut(value = "seminarCache", key = "#seminar.id")
public Seminar updateSeminar(Seminar seminar) {
// ...
}
2. @CacheEvict:: 캐시에서 특정 항목을 제거합니다. 메서드가 실행될 때, 캐시에서 지정된 키에 해당하는 값을 삭제합니다
@CacheEvict(value = "seminarCache", key = "#id")
public void deleteSeminarById(Long id) {
// ...
}
- Caching : 여러 개의 캐시 어노테이션을 한 번에 적용할 때 사용됩니다. 여러 캐시 액션을 하나의 메서드에 적용하려면 이 어노테이션을 사용할 수 있습니다.
@Caching(
cacheable = {
@Cacheable(value = "seminarCache", key = "#id") //파라미터 Long id값
},
put = {
@CachePut(value = "seminarCache", key = "#result.id") //메서드 반환값의 id
}
)
public Seminar findAndUpdateSeminarById(Long id) {
// ...
}