기존 외부 API를 호출하는 코드를 MonogoDb를 활용해서 캐싱해서, 응답시간을 줄이자!!
이번 글에서는 성능에 관한 이야기 보다는, 어떻게 유지보수가 용이한 코드를 작성했는지 리팩토링 과정을 간결하게 적는다.
기존에는 다음과 같은 코드가 적혀있었다.
간단하게 인터페이스 정의부터
Place Service
public interface PlaceService {
/**
* 장소를 조회합니다.
*
* @param placeId 장소 id, not null
* @return 조회된 장소
* @throws PlaceRetrieveFailedException 장소 조회에 실패한 경우(없는 게 아니라, 조회 실패)
*/
Optional<PlaceView> getPlace(String placeId) throws PlaceRetrieveFailedException;
/**
* 근처 장소를 조회합니다.
*
* @param lat 위도, not null
* @param lng 경도, not null
* @param radius 반경, 0 이상 50000 이하
* @param maxResultCount 최대 조회 결과 수, null 이면 20개
* @param placeTypes 조회하려는 장소의 타입, null 이면 모든 타입을 조회합니다.
* @param distanceSort 거리순 정렬 여부, true - 거리순, false - 인기순, null - 인기순
* @return 조회된 장소 목록
* @throws PlaceRetrieveFailedException 외부 API 호출에 실패한 경우
*/
List<PlaceView> getNearbyPlaces(double lat, double lng, int radius, @Nullable Integer maxResultCount, @Nullable PlaceType[] placeTypes, @Nullable Boolean distanceSort) ;
}
CacheService
public interface PlaceCacheService {
Optional<PlaceView> get(String placeId);
void put(PlaceView placeView);
void putAll(List<PlaceView> placeViews);
}
보면 알겠지만 Place 도메인 서비스와, 그것을 캐시하는 작업을 추상화 해놓았다.
구현체 메서드중 일부를 보자
@Slf4j
@Service
@RequiredArgsConstructor
public class PlacePhotoServiceImpl implements PlaceService, PhotoService {
private final GooglePlaceRepository googlePlaceRepository;
private final GooglePlacePhotoRepository googlePlacePhotoRepository;
private final Mapper<GooglePlace, PlaceView> googlePlaceViewMapper;
private final PlaceCacheService placeCacheService;
@Override
public Optional<PlaceView> getPlace(String placeId) {
// check cache
Optional<PlaceView> cache = placeCacheService.get(placeId);
if (cache.isPresent()) {
log.debug("Cache Hit - Get Place From Cache : {}", placeId);
return cache;
}
// cache miss
log.debug("Cache Miss - Get Place From Google API : {}", placeId);
final Optional<PlaceView> place;
try {
place = googlePlaceRepository.placeDetails(placeId)
.map(googlePlaceViewMapper::map);
} catch (DataAccessException e) {
throw new PlaceRetrieveFailedException("Place Not Found", e);
}
if (place.isPresent()){
// cache
log.debug("Cache Put - Put Place To Cache : {}", placeId);
placeCacheService.put(place.get());
}
return place;
}
보면 알겠지만,
첫번째 생각은 간단하게 데코레이터 패턴
을 구현한 프록시 객체를 만드는 거였다.
@Slf4j
@RequiredArgsConstructor
public class PlaceServiceCacheProxy implements PlaceService {
private final PlaceService placeService;
private final PlaceCacheService placeCacheService;
@Override
public Optional<PlaceView> getPlace(String placeId) throws PlaceRetrieveFailedException {
// check cache
Optional<PlaceView> cache = placeCacheService.get(placeId);
if (cache.isPresent()) {
log.debug("Cache Hit - Get Place From Cache : {}", placeId);
return cache;
}
// cache miss
log.debug("Cache Miss - Get Place From : {}", placeService.getClass().getSimpleName());
final Optional<PlaceView> place = placeService.getPlace(placeId);
// load to cache
if (place.isPresent()){
// cache
log.debug("Cache Put - Put Place To Cache : {}", placeId);
placeCacheService.put(place.get());
}
return place;
}
이렇게 캐시로직을 Stubbing 하므로서 기존 PlaceService의 메인 로직을 캐시 로직이 감싸는 형태로 바꿀 수 있었다.
그러면 이것을 어떻게 Bean으로 등록하느냐, 물론 이것도 몇가지 방법이 있겠지만, 나는 BeanPostProcessor
를 활용하려고 했다.
@Component
public class BeanDecorator implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof PlaceService service) {
return new PlaceProxy(service);
}
return bean;
}
}
한개의 빈이 두개의 interface를 구현한 경우, 이것을 Proxy로 감싸면, 다른 interface의 접근이 막히는거 아니야?
이해를 쉽게 하기 위해 그려본다면 위와 같은 상황이 발생할것 같았다.
이렇게 된다면 이 Bean은 instanceof PhotoService에서도 False를 반환할 것이고, PhotoService의 메서드를 호출할 수도 없을 것이다.
그래서 한번 테스트를 해보았다.
데모 프로젝트를 만들고
자 어떻게 될것 같은가??
Spring Bean 저장소에서는 분명히 PhotoService 타입으로 등록되어 있었는데, 막상 주입하려고 보니, 다른 타입이기 때문에,
스프링 시작시점에 예외가 발생한다.
신기한거 하나 더 알았다.
BeanPostProcessor
를 사용해서 확장할때, 단순히 타입 비교로만 프록시를 입히는 것은 주의해야겠다.
그래서, 흠,,, 좀 귀찮지만 AOP를 사용하기로 했다. 애초에 작동 원리는 프록시니까 비슷할 것이다. 다만, 위와 같은 타입이 안맞는다던가 하는 문제가 없을 것이다.
@Aspect
@Slf4j
@Component
public class PlaceServiceCacheAspect {
private final PlaceCacheService placeCacheService;
public PlaceServiceCacheAspect(PlaceCacheService placeCacheService) {
this.placeCacheService = placeCacheService;
}
@SuppressWarnings("unchecked")
@Around("execution(java.util.Optional<click.porito.travel_core.place.dto.PlaceView> click.porito.travel_core.place.PlaceService.getPlace(String)) && args(placeId)")
public Object aroundGetPlace(ProceedingJoinPoint joinPoint, String placeId) throws Throwable {
// check cache
Optional<PlaceView> placeView = placeCacheService.get(placeId);
if (placeView.isPresent()) {
log.debug("Cache Hit - Get Place From Cache : {}", placeId);
return placeView;
}
// cache miss
log.debug("Cache Miss - Get Place From : {}", joinPoint.getTarget().getClass().getSimpleName());
Optional<PlaceView> result = (Optional<PlaceView>) joinPoint.proceed();
// load to cache
if (result.isPresent()) {
// cache
log.debug("Cache Put - Put Place To Cache : {}", placeId);
placeCacheService.put(result.get());
}
return result;
}
로직 자체는 동일하다.
그리고, 이것을 테스트 하는 테스트 코드도 작성했다.(실제로는 적용이 안된다던가 하면 곤란하니)
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = {PlaceServiceCacheAspect.class,PlacePhotoServiceImpl.class})
@Import(AnnotationAwareAspectJAutoProxyCreator.class) // activate aspect
class PlaceServiceCacheAspectTest {
@MockBean
private PlaceCacheService placeCacheService;
@SpyBean
private PlaceServiceCacheAspect cacheAspect;
@MockBean
private GooglePlacePhotoRepository googlePlacePhotoRepository;
@MockBean
private GooglePlaceRepository googlePlaceRepository;
@MockBean
private Mapper<GooglePlace, PlaceView> googlePlaceViewMapper;
@Autowired
private PlaceService placeService;
@Test
@DisplayName("getPlace 호출시 Aspect에 있는 캐시 로직 호출")
void getPlaceWithCacheAspect() throws Throwable {
// given
String placeId = "placeId";
// when
Optional<PlaceView> place = placeService.getPlace(placeId);
// then
//호출되었는지 체크
verify(placeCacheService, times(1)).get(any());
verify(cacheAspect, times(1)).aroundGetPlace(any(), any());
}
@Test
@DisplayName("getNearbyPlaces 호출시 Aspect에 있는 캐시 로직 호출")
void getNearbyPlacesWithCacheAspect() throws Throwable {
// given
double lat = 0;
double lng = 0;
int radius = 0;
// when
placeService.getNearbyPlaces(lat, lng, radius, null, null, null);
// then
//호출되었는지 체크
verify(cacheAspect, times(1)).aroundGetNearbyPlaces(any());
}
}
결과는 성공
당연히!! 유지보수가 용이해졌고, 각각의 객체는 자신만의 단일 책임을 갖게 되었다.
캐시 로직을 분리하거나 변경할 때, 제거할 때, 특정 프로파일에서만 캐싱을 적용할때 Aspect만 살짝 고치면 되므로, 다양하고 유연한 선택지가 생겼다.
아직 작업중인 프로젝트이지만 코드가 궁금한 경우 놀러오면 좋을것 같다
https://github.com/onjik/Managed-Travel-Service