평균 가격 호출 메서드 리팩터링
중간발표 당시 멘토님께서 기존 로직에 대해서 피드백을 주셨던 부분이 있었다. 바로 상품 상세페이지 내 차트용 평균 가격을 불러오는 부분이었다.
오늘은 해당 파트에 대한 리팩터링 과정을 개발 로그로 기록해보고자 한다.
멍냥마켓에서는 기존 중고거래 사이트들과의 차별화된 요소로 상품 상세페이지 내에서 해당 상품의 구매 당시 가격, 판매 희망 가격 그리고 현재 멍냥마켓 내 같은 상품 카테고리로 등록되어있는 상품들의 평균 가격을 보여주고 있다. 멘토님께서 지적해주신 부분이 바로 이 평균 가격을 불러오는 로직이었는데, 기존에는 사용자가 상세 페이지에 접근할 때 마다 DB 내 해당 상품과 동일한 상품 카테고리로 등록된 상품들의 평균 가격을 매번 계산하게끔 구현하였었다. 하지만 이럴 경우 매번 불필요한 로직을 거치게 될 뿐더러 현재는 DB 내 상품 데이터가 많지 않아 큰 이상이 없지만, 실제 서비스가 되어 수 많은 상품들이 등록된다고 가정하게된다면 서비스를 굉장히 느리게 만들 위험이 있었다.
ItemRepository - 리팩터링 이전 평균가격 호출 메서드
/* itemCategory 등록상품의 평균 가격 호출 */
@Query("select avg(i.sellingPrice) from Item i " +
"where i.itemCategory = :itemCategory ")
Long getAveragePrice(String itemCategory);
중간발표 이후 멘토님의 피드백을 바탕으로 어떻게 해당 어떻 개선시킬 수 있을까 고민하다 첫 번째로 고안해낸 방법은 조회한 상품이 속한 상품 카테고리에 등록된 모든 상품의 평균 가격을 가져오는 것이 아닌 최신100개의 평균가격을 가져오는 방식으로 수정하는 것이었다.
이렇게 할 경우에 만약 DB에 "사료"라는 상품 카테고리로 등록된 상품이 1억 개라는 전제하에, 기존의 방식대로라면 상품 카테고리가 간식으로 분류된 상품 상세 페이지에 접근 시 매번 DB를 조회하여 1억개 상품의 평균가격을 계산하여야하지만 변경된 방법의 경우 최근 등록된 100개만으로 평균값을 계산해내니 훨씬 간단해질 것이었다.
사용자 입장에서는 1억개 상품의 평균 가격이나 최근 등록된 100개의 상품의 평균 가격이나 변경되었을 때 크게 불편함을 느낄 포인트가 아니라는 생각이 들었다.
(오히려 최근 등록된 상품 위주로 평균 가격을 계산하여 보여주니 가격 변동을 더 민감하게 보여주는 지표로 활용될 수 있을 수 도)
그렇게 수정한 코드가 바로 다음과 같다.
ItemRepository - 특정 상품 카테고리 등록 상품 100개 리스트화 메서드(평균 가격 도출 위함)
/**
* 특정 itemCategory 등록상품 100개 리스트화 메소드(평균 가격 도출 위함)
*/
List<Item> getTop100ByItemCategoryOrderByIdDesc(@Param("itemCategory") String itemCategory);
ItemService - 평균가격 호출 메서드(1차 리팩터링 ver)
/* 해당 item이 속한 itemCategory 상품의 평균가격 도출(최근 등록된 해당 카테고리 상품 100개 기준) */
List<Item> itemList = itemRepository.getTop100ByItemCategoryOrderByIdDesc(item.getItemCategory());
long averagePrice = 0L;
for (Item items : itemList) {
averagePrice = averagePrice + items.getSellingPrice();
}
averagePrice = averagePrice / itemList.size();
하지만 위와 같은 방식으로도 원초적인 문제점은 해결할 수 없었다. 바로 로직을 간소화시켰을 뿐이지 상품 상세 페이지에 들어갈 때마다 평균 가격을 계산한다는 비효율적인 로직을 반복적으로 수행하고 있다는 것이다. 이에 대한 해결방법을 고민하던 중 캐시에 대해 접하게 되었다.
캐시
캐시는 데이터나 값을 미리 복사해 저장해두는 임시 저장소의 개념이다.
원본 데이터에 접근하는 시간이 오래 걸리는 경우 혹은 값을 다시 계산하는 시간을 절약하고 싶은 경우 사용된다. 캐시를 사용하면 데이터를 미리 복사해놓음으로써 추가적인 계산이나 접근 시간 없이 더 빠른 속도로 데이터에 접근할 수 있다.
즉, 캐시 데이터에 접근하는 속도는 높이고 비용은 줄이기 위하여 사용된다.
캐시를 통하여 미리 계산해둔 상품 카테고리별 평균 가격을 저장해 두고 상품 상세페이지에 접근할 때마다 꺼내 쓴다면 기존의 방식보다 서버에 부담이 훨씬 줄어들 것이었다. 물론 캐시 내 저장되어있는 평균 가격의 주기적인 업데이트는 필수적이겠지만 기존 방식에 비하면 혁신적인 방법임은 확실해 보였다.
Spring Cache
Cache 어노테이션을 사용하기 위해서는 build.gradle에 다음과 같은 코드를 우선적으로 추가해주어야 한다.
build.gradle
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-cache' /* SpringCache */
...
}
그리고 설정 클래스 - Application 클래스에도 @EnableCaching 어노테이션을 추가해줘야 한다.
MungNyangApplication
@EnableJpaAuditing
@EnableCaching
@SpringBootApplication
public class MungNyangApplication {
public static void main(String[] args) {
SpringApplication.run(MungNayngApplication.class, args);
}
}
위와 같은 세팅을 마치게 되면 @Cacheable 어노테이션 사용이 가능해진다.
ItemService - 상품 상세 조회 및 평균 가격 호출 메서드(스프링 캐시 적용)
/**
* 단일 상품 조회 메서드(detail)
*/
@Transactional(readOnly = true)
public ItemResponseDto getItem(UserDetails userDetails, Long itemId) {
Item item = validator.validateItemExistence(itemId); /* 상품 존재 여부 유효성 검사 및 반환 */
return buildItemResponseDto(userDetails, item);
}
/**
* 공통작업 - ItemResponseDto build(상품 상세페이지용)
*/
public ItemResponseDto buildItemResponseDto(UserDetails userDetails, Item item) {
int commentCnt = commentRepository.countByItem_Id(item.getId());
/* 해당 item이 속한 itemCategory 상품의 평균가격 도출(최근 등록된 해당 카테고리 상품 100개 기준) */
long averagePrice = (item.getItemCategory());
/* 해당 item의 이미지 호출 */
List<Image> imageList = imageRepository.findAllByItemId(item.getId());
List<String> imgUrlList = new ArrayList<>();
for (Image image : imageList) {
System.out.println();
imgUrlList.add(image.getImgUrl());
}
/* IsZzimed - 사용자가 찜한 상품인지 아닌지 확인 */
boolean isZzimed = false;
if (userDetails != null) {
Optional<Zzim> zzim = zzimRepository.findByItemIdAndZzimedBy(item.getId(), userDetails.getUsername());
if (zzim.isPresent()) isZzimed = true;
}
Member member = memberRepository.findByNickname(item.getNickname()); /* 상품 게시자 정보 가져오기 */
return ItemResponseDto.builder()
.id(item.getId())
.IsMine(userDetails != null && item.getNickname().equals(userDetails.getUsername()))
.nickname(item.getNickname())
.title(item.getTitle())
.content(item.getContent())
.petCategory(item.getPetCategory())
.itemCategory(item.getItemCategory())
.itemImgs(imgUrlList)
.location(item.getLocation())
.zzimCnt(item.getZzimCnt())
.commentCnt(commentCnt)
.viewCnt(item.getViewCnt())
.purchasePrice(item.getPurchasePrice())
.sellingPrice(item.getSellingPrice())
.averagePrice(averagePrice)
.IsComplete(item.isComplete())
.IsZzimed(isZzimed)
.memberId(member.getMemberId()) /* 상품 게시자의 memberId */
.time(TimeUtil.convertLocaldatetimeToTime(item.getCreatedAt()))
.build();
}
/**
* 상품 상세페이지 - 상품이 속한 itemCategory 등록 상품들의 평균가격 계산 메서드(캐시 등록)
*/
@Cacheable(value = "averagePrice", key = "#itemCategory")
public long getAveragePriceForCache(String itemCategory) {
/* 해당 item이 속한 itemCategory 상품의 평균가격 도출(최근 등록된 해당 카테고리 상품 100개 기준) */
log.info("평균가격 계산 시작");
List<Item> itemList = itemRepository.getTop100ByItemCategoryOrderByIdDesc(itemCategory);
long averagePrice = 0L;
for (Item items : itemList) {
averagePrice = averagePrice + items.getSellingPrice();
}
averagePrice = averagePrice / itemList.size();
log.info("평균가격 계산 종료");
return averagePrice;
}
하지만 기대와는 달리 캐시가 생성되지 않았다.
Spring Cache 사용 시 동일 클래스(Bean) 내 @Cacheable 설정된 메서드를 자기 호출(Self-Invocational) 할 경우 Proxy Class에서 이미 캐싱된 결과를 가져오지 못하고 메서드를 다시 실행하게 된다고 한다. 즉 내부 메서드를 통하여 캐싱을 시도할 경우 정상 작동하지 않을 수 있다는 것이다.
내가 작성한 코드의 경우 상품을 조회하는 getItem() 메서드 내부의 평균 가격을 계산하여 가져오는 getAveragePriceForCache() 메서드에 @Cacheable 어노테이션을 붙였기에 캐싱이 작동하지 않았던 것이다.
안된다는 건 알았고, 그러면 근본적인 원인은 어디에 있을까?
원인은 @Cacheable 어노테이션은 Spring AOP(Aspect Oriented Programming)을 기반으로 동작하는 데에 있다. AOP란 직역하자면 관점 지향 프로그래밍으로 쉽게 말해 특정 로직을 기준으로 핵심적인 관점과 부가적인 관점으로 나누어 보고 그 관점을 기준으로 모듈화(어떤 공통된 로직이나 기능을 기준으로 하나의 단위로 묶는 것)하는 것을 말한다. 이를 통해 트랙잭션, 캐시 처리 등 핵심 로직의 처리 중 반복되는 관심사를 모아서 분리 - 처리가 가능해지는 것이다.
AOP는 프록시를 기반으로 동작하기에 이러한 자기 호출(Self-Invocation) 문제가 발생하면 원하는 결과를 얻지 못할 수 있다.
프록시란, 특정 메서드의 앞 뒤에서 처리하는 개념이다. 쉽게 말해 프록시 -> 서비스 -> 프록시 와 같은 과정으로 메서드를 처리하는 것이다.
Spring AOP는 빈을 생성하는 시점에 AOP가 적용된다는 프록시 빈을 생성하는데, 이 프록시 빈은 지정된 메서드가 호출될 때 인터셉트하여 다른 동작이 가능하게 해주는 역할을 한다. 하지만 서비스 내에서 자신의 서비스의 다른 메서드를 호출하게 될 경우 프록시 객체를 거치지 않기에 원하는 결과를 얻지 못할 수 있다는 것이다.( 프록시 -> 서비스 -> 서비스 내부에서 자기 호출 -> 프록시 )
해결방법에는 어떤 것이 있을까?
Spring에서는 AOP 표준인 AspectJ를 추천한다고 한다. 하지만 이를 위하여 관련 의존성 추가 및 별도 plugin 등의 설정이 필요하여 현시점에서는 깊게 파고들며 학습하기는 다소 무리가 있다고 판단하였다. 대신 AopContext.currentProxy() 를 통한 비교적 간단한 방법으로 문제를 해결해보았고 이 과정을 포스팅에 담아보았다.
먼저 어플리케이션 파일에 @EnableAspectJAitoProxy(exposeProxy = true) 어노테이션을 추가해주어야 한다.
MungNyangApplication - 자기 호출 문제 해결을 위한 어노테이션 추가
@EnableJpaAuditing
@EnableCaching
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy=true)
public class MungNayngApplication {
public static void main(String[] args) {
SpringApplication.run(MungNayngApplication.class, args);
}
}
그리고 서비스 단에서 @Cacheable 어노테이션을 통해 캐싱한 데이터를 가져오는 부분의 코드를 다음과 같이 수정해주었다.
/**
* 공통작업 - ItemResponseDto build(상품 상세페이지용)
*/
public ItemResponseDto buildItemResponseDto(UserDetails userDetails, Item item) {
...
/* 해당 item이 속한 itemCategory 상품의 평균가격 도출(최근 등록된 해당 카테고리 상품 100개 기준) */
long averagePrice = ((ItemService) AopContext.currentProxy()).getAveragePriceForCache(item.getItemCategory()); /* Self-Invocation 문제 해결 */
...
}
위와 같은 코드를 추가함으로써 정상적으로 캐시가 동작하는 것이 확인 가능하였다.
초기에 계획했던 대로 캐시를 통한 평균 가격 호출 메서드를 개선시킬 수 있었다.
하지만 막상 구현하고 나니 더 큰 문제가 남아있었다. 바로 평균 가격은 변화한다는 것이다. 변화한 평균 가격도 주기적으로 업데이트해줘야 고객들에게 보다 유의한 데이터 전달이 가능하다. 하지만 순수 Spring Cache만으로는 캐시가 자동적으로 업데이트가 된다거나 삭제-재생성되도록 설정하지 못한다고 한다.
캐시가 만료되도록 설정할 수 있는 방법을 조사하던 중 EHCache와 Redis를 이용한 Cache 처리에 대해서 알게 되었다. 둘 중 어떤 것을 활용하여 문제를 해결할까 고민하던 중 일전에 사용했던 스케줄러가 생각났다.
스케줄러
특정 메서드에 스케줄러를 적용해주면 주기적으로 해당 메서드를 동작시키기에 보다 간편한 방법으로 저장되었던 캐시를 삭제시킬 수 있었다. 다만 스케줄러에도 큰 단점이 있는데 바로 다음의 것들이다.
- 스케줄러의 return 값은 void로 설정해주어야 한다.
- 스케줄러는 파라미터 값을 갖지 못한다.
위와 같은 단점이 있긴 하지만 CacheEvict(allEntries = true)를 통하여 설정해둔 value 값의 캐시를 모두 삭제하는 방식으로만 사용할 것이었기에 전혀 문제가 되지 않았다.
스케줄러는 캐시를 갱신시킬 수 있는 방법들 중 가장 간편하다는 최고의 장점을 갖고 있었고 단점은 내게 단점으로 다가오지 않았기에 이를 통하여 캐시를 갱신시켜보고자 하였다.
스케줄러를 사용하기 위해서는 우선적으로 application 파일에 @EnableScheduling 어노테이션을 추가해주어야 한다.
MungNyangApplication - 스케줄러 사용을 위한 어노테이션 추가
@EnableJpaAuditing
@EnableCaching
@EnableAspectJAutoProxy(exposeProxy=true)
@EnableScheduling
@SpringBootApplication
public class MungNayngApplication {
public static void main(String[] args) {
SpringApplication.run(MungNayngApplication.class, args);
}
}
ItemService - 스케줄러를 통한 평균 가격 캐시 자동 삭제 메서드
/**
* 스케줄러 - 상품이 속한 itemCategory 등록 상품들의 평균가격 캐시 자동 삭제 메서드
*/
@Scheduled(cron = "0 0 0/3 * * *")
@CacheEvict(value = "averagePrice", allEntries = true)
public void deleteAveragePrice() {
log.info("평균가격 캐시 자동 삭제");
}
평균 가격을 매 3시간마다 삭제되어 갱신될 수 있도록 설정해주었고 위와 같은 코드를 통해 비교적 간단하게 기존 캐시 데이터를 삭제시킬 수 있었다.
(공백을 제하고 6번째 줄까지 두 번째 조회이기에 캐시에서 평균 가격을 가져와 평균가격 계산을 하지 않지만 캐시가 스케줄러가 동작하여 삭제되자 세번째 조회를 할 때는 평균가격을 다시 계산하는 모습을 볼 수 있다.)
이번 평균 가격 호출 메서드 리팩터링을 통해서도 정말 많은 부분에 대해 배워나갈 수 있었다. 하지만 여러 개념적인 부분이 얽혀있었기에 모든 내용을 학습하고 이해할 수는 없었다. 아직까지 AOP와 프록시에 대한 개념이 절대 완벽하게 이해된 것은 아니기에 AspectJ에 대한 내용과 더불어 실전 프로젝트를 마치고 나서라도 제대로 학습한 후 정리하여 포스팅해볼 예정이다.
'✍️개발로그' 카테고리의 다른 글
실전프로젝트 5주차(Query 메서드 리팩터링 feat.QueryDSL) (0) | 2022.09.23 |
---|---|
20220909_실전 프로젝트 14일차(최근 조회한 상품 목록) (0) | 2022.09.09 |
20220909_실전 프로젝트 14일차(Swagger 도입) (0) | 2022.09.09 |
220830_실전프로젝트 8일차(feat.Item 수정부분 트러블슈팅🚀) (0) | 2022.09.03 |
220829_실전프로젝트 4일차 (0) | 2022.08.29 |