Spring AOP로 캐시 로직 분리하기, BeanPostProcessor 주의점

유알·2024년 1월 12일
0

기존 외부 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;
    }

보면 알겠지만,

  • 캐싱로직이랑 비즈니스 로직이 마구 뒤엉켜 있다.
  • 이것은 코드의 가독성도 해치고,
  • 단일책임 원칙에도 위반된다.
  • 무엇보다, 다음번에 PlaceService의 구현체를 하나 더 만든다고 해보자. 캐시 로직을 다시 한번 작성해야할 것이다.

첫번째 개선 시도 - Proxy 활용

첫번째 생각은 간단하게 데코레이터 패턴을 구현한 프록시 객체를 만드는 거였다.

@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사용

그래서, 흠,,, 좀 귀찮지만 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

profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글