이번 글에서는 메인화면에 세미나 목록을 보여주는 기능에 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) {
    // ...
}

 

+ Recent posts