공작소 서비스를 개발하고 테스트를 진행하고 있는 와중, 배너 정보를 반환하는 API 호출 수가 굉장히 많은 것을 확인했다. 프론트분들의 개발 과정에서 아직 최적화가 되지 않아서 발생하는 상황으로 예상이 되는데, 짧은 시간동안 show-sql 옵션이 걸려있어서인지 서버로그가 약 100만줄 이상 쌓여있었다. 그렇기에, 백엔드에서 이에 대해 API 요청이 올 시 캐싱을 통해 응답을 할 수 있도록 하여 성능 개선을 하고자 캐싱을 적용하게 되었다.
위 사진은 컴퓨터 구조 과목에서 캐시에 대하여 배울 때 이미지이다. 이렇게 Main Memory, 즉 백엔드 기준 API 요청에 대해 계속 DB에 쿼리를 통해 조회를 하는 것이 아닌 캐시에 정보를 저장해놓음으로써 DB까지 도달하지 않고 빠르게 API 요청에 대한 응답을 할 수 있도록 하는 것을 말한다. 동일한 요청에 대해 다시 DB에 쿼리를 활용하여 데이터를 조회하는 행동이 발생하지 않으므로, 불필요한 일을 반복하지 않게 된다.
추후 배너 등록/수정/삭제 등의 기능들을 기획자분들이 원할하게 하실 수 있도록 백오피스를 구축하고자 생각하고 있는데, 이에 맞추어 API화를 시키고 있는 상황에 같이 적용하고자 한다.
하위 두 개의 경우, 조회한 배너의 타입을 이용해서 특정 캐시만 삭제할 수 있지만, allEntries를 사용해보고자 모든 캐시를 삭제하는 방식으로 구현했다.
implementation 'org.springframework.boot:spring-boot-starter-cache'
null은 캐싱되지 않도록 설정해주기 위하여, CacheConfig를 별도로 구성했다.
별도로 추가하고자 하는 설정이 없다면, Application파일에 바로 @EnableCaching을 추가해도 된다.
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
// null값은 캐싱되지 않도록 설정
cacheManager.setAllowNullValues(false);
return cacheManager;
}
}
@EnableCaching: 캐시 활성화를 위한 어노테이션
@Cacheable: 캐시 조회, 저장 기능
동작 방식
관련 옵션
@CachePut: 캐시 저장 기능
동작 방식
관련 옵션
@CacheEvict: 캐시 삭제 기능
관련 옵션
true일 경우, 메소드 호출 전 캐시를 비우는 작업을 수행하게 된다. 기본 값은 false이다.
SpEL(Spring Expression Language) 관련 문서
@CacheConfig(cacheName = ?)를 설정하면, 메소드에서 별도로 value를 지정해줄 필요가 없다. 다만, value를 메소드 단위에서 설정해주면, 해당 value가 더 우선시된다.
@Cacheable(key = "'MAIN'")
public List<BannerRes> getMainImageList() {
// Business Logic
List<Banner> bannerList = bannerRepository.findAllByDomainTypeAndDeletedAtIsNullOrderByPriorityAsc(DomainType.MAIN);
// Response
return bannerList.stream()
.map(BannerRes::of)
.collect(Collectors.toList());
}
@Cacheable(key = "'PROJECT'")
public List<BannerRes> getProjectImageList() {
// Business Logic
List<Banner> bannerList = bannerRepository.findAllByDomainTypeAndDeletedAtIsNullOrderByPriorityAsc(DomainType.PROJECT);
// Response
return bannerList.stream()
.map(BannerRes::of)
.collect(Collectors.toList());
}
@Cacheable(key = "'CONTEST'")
public List<BannerRes> getContestImageList() {
// Business Logic
List<Banner> bannerList = bannerRepository.findAllByDomainTypeAndDeletedAtIsNullOrderByPriorityAsc(DomainType.CONTEST);
// Response
return bannerList.stream()
.map(BannerRes::of)
.collect(Collectors.toList());
}
@CacheEvict(key = "#bannerReq.domainType", condition = "#bannerReq != null && #bannerReq.domainType != null")
@Transactional
public BannerRes registerBanner(Member member, BannerReq bannerReq, MultipartFile multipartFile) {
// Validation
if(!member.getMemberType().equals(MemberType.ADMIN)) {
throw new ApplicationException(ErrorCode.UNAUTHORIZED_EXCEPTION);
}
if(multipartFile == null || multipartFile.isEmpty()) {
throw new ApplicationException(ErrorCode.INVALID_VALUE_EXCEPTION);
}
// Business Logic
String imageUrl = s3Client.upload(multipartFile, "banner");
Banner banner = bannerReq.from(imageUrl);
Banner savedBanner = bannerRepository.save(banner);
// Response
return BannerRes.of(savedBanner);
}
@CacheEvict(key = "#bannerReq.domainType", condition = "#bannerReq != null && #bannerReq.domainType != null")
@Transactional
public BannerRes updateBanner(Member member, Long bannerId, BannerReq bannerReq, MultipartFile multipartFile) {
// Validation
if(!member.getMemberType().equals(MemberType.ADMIN)) {
throw new ApplicationException(ErrorCode.UNAUTHORIZED_EXCEPTION);
}
Banner banner = bannerRepository.findById(bannerId).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION));
// Business Logic
String imageUrl = "";
if(multipartFile != null && !multipartFile.isEmpty()) {
s3Client.delete(banner.getImageUrl());
imageUrl = s3Client.upload(multipartFile, "banner");
}
banner.update(bannerReq, imageUrl);
Banner updatedBanner = bannerRepository.save(banner);
// Response
return BannerRes.of(updatedBanner);
}
@CacheEvict(allEntries = true)
@Transactional
public BannerRes changeIsPost(Member member, Long bannerId) {
// Validation
if(!member.getMemberType().equals(MemberType.ADMIN)) {
throw new ApplicationException(ErrorCode.UNAUTHORIZED_EXCEPTION);
}
Banner banner = bannerRepository.findById(bannerId).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION));
// Business Logic
banner.changeIsPost();
Banner updatedBanner = bannerRepository.save(banner);
// Response
return BannerRes.of(updatedBanner);
}
@CacheEvict(value = {"bannerMain", "bannerProject", "bannerContest"})
@Transactional
public void deleteBanner(Member member, Long bannerId) {
// Validation
if(!member.getMemberType().equals(MemberType.ADMIN)) {
throw new ApplicationException(ErrorCode.UNAUTHORIZED_EXCEPTION);
}
Banner banner = bannerRepository.findById(bannerId).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION));
// Business Logic
s3Client.delete(banner.getImageUrl());
bannerRepository.delete(banner);
// Response
}
주의사항
CacheEvict를 구현하는 과정에서, record 내의 enum을 key로 지정하고자 했는데, condition을 설정하지 않아 아래와 같은 오류가 떴었다.EL1007E: Property or field 'domainType' cannot be found on null
EL1011E: Method call: Attempted to call method name() on null context object
이러한 오류가 뜬다면, null check를 조건으로 걸어주어야 한다.
최초 호출 시
재호출 시
재호출 시, 별도로 쿼리가 실행되지 않고 바로 반환되는 것을 볼 수 있다.
삭제 후 호출 시
삭제 후 호출하게 되면, 쿼리가 실행되면서 캐시가 사라졌다는 것을 알 수 있다.
다음
현재는 Java 내부의 자료구조를 활용한 캐싱 방식으로 구현했지만, CacheManger의 종류를 변경하여 다른 방식으로도 캐싱을 구현할 수 있기에, Redis를 활용하여 캐싱하는 방식도 추후 다루어볼 예정이다.
레퍼런스