Spring Boot

AOP를 활용한 브라우저 캐시_3_AOP / @GetMapping

99duuk 2024. 9. 10. 23:34

다시 AOP로 돌아왔다.

 

@Aspect
@Component
@Log4j2
public class CacheControlAspect {

    @Around("@annotation(com.portal.util.annotation.CacheControl)")
    public Object handleCacheControl(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("handleCacheControl method invoked ????????????");
        // HTTP 응답 가져오기
        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = attrs.getRequest();
        HttpServletResponse response = attrs.getResponse();

        // 메서드와 어노테이션 가져오기
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        CacheControl cacheControl = method.getAnnotation(CacheControl.class);

        log.info("Handling cache control for method: {}", method.getName());

        // 메서드 실행
        Object result = joinPoint.proceed();

        if (cacheControl != null && result instanceof ActionResult) {
            ActionResult actionResult = (ActionResult) result;

            // ActionResult의 데이터 사용해 ETag 생성
            String eTag = generateETag(actionResult);
            log.info("Generated ETag: {}", eTag);

            // If-None-Match 헤더 확인
            String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
            log.info("If-None-Match header: {}", ifNoneMatch);
            if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) {
                log.debug("ETag matches. Returning 304 Not Modified");
                // ETag가 일치하면 304 Not Modified 반환
                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                return null;
            }

            // ETag와 Cache-Control 헤더 추가
            response.setHeader(HttpHeaders.ETAG, eTag);
            log.info("Setting ETag header: {}", eTag);

            String cacheControlHeader = "max-age=" + cacheControl.maxAge();
            log.info("Setting Cache-Control header: {}", cacheControlHeader);
            response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=" + cacheControl.maxAge());
        } else{
            log.info("Cache control not applied. Result is not ActionResult or CacheControl is null **********");
        }
        return result;
    }


    private String generateETag(ActionResult actionResult) {

        log.info("ActionResult content: {}", actionResult);
        
        StringBuilder content = new StringBuilder();
        content.append(actionResult.getResultCd());
        content.append(actionResult.getResultMessage());
        content.append(actionResult.getResultMessageCd());

        Map<String, Object> data = actionResult.getData();
        if (data != null) {
            for (Map.Entry<String, Object> entry : data.entrySet()) {
                content.append(entry.getKey()).append(entry.getValue());
            }
        }
        String eTag = DigestUtils.md5DigestAsHex(content.toString().getBytes(StandardCharsets.UTF_8));

        log.info("Generated ETag from ActionResult: {}", eTag);
        return eTag;
    }
}


/**
 * 메서드 실행 전:
 * @Around 어노테이션을 통해 @CacheControl이 적용된 메서드가 호출될 때마다 handleCacheControl 메서드가 실행.
 * RequestContextHolder를 사용하여 현재 요청의 HttpServletResponse 객체를 가져옴. 이를 통해 응답 헤더를 설정 가능
 * 메서드에 적용된 @CacheControl 어노테이션을 읽어 maxAge 값을 확인.
 *
 * 메서드 실행 후:
 * 원래의 메서드를 실행 (joinPoint.proceed())하고 그 결과를 반환.
 * 이 과정에서 캐시 관련 헤더가 응답에 설정.
 *
 * 결론
 * 이 AOP 모듈은 메서드의 캐시 관련 설정을 담당하며, 비즈니스 로직과 분리되어 있음. 캐시 헤더 설정과 같은 공통 관심사를 분리하여 재사용 가능하게 만듬.
 * Pointcut:
 *
 * @CacheControl 어노테이션이 적용된 메서드에 대해서만 이 AOP 로직이 적용됨. 이를 통해 필요한 경우에만 캐시 설정을 적용할 수 있음.
 *
 */

 

지피티와 함께 이렇게 주절주절 써봤다. 

이땐 api 통신에 되던 캐싱도 안됐다. 모든 응답이 200이었다..

 

저기서 ETag 부분을 삭제했더니 또 캐싱은 됐다. 뭐가 문제고 뭐 때문에 되고 뭐 때문에 또 안되는지 하나도 몰랐다.

 

import java.util.Base64;

@Aspect
@Component
@Log4j2
public class CacheControlAspect {

    @Around("@annotation(com.portal.util.annotation.CacheControl)")
    public Object handleCacheControl(ProceedingJoinPoint joinPoint) throws Throwable {

        // HTTP 요청 및 응답 가져오기
        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = attrs.getRequest();
        HttpServletResponse response = attrs.getResponse();

        // 메서드와 어노테이션 가져오기
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        CacheControl cacheControl = method.getAnnotation(CacheControl.class);

        log.info("&&&& Handling cache control for method: {}", method.getName());

        // 메서드 실행
        Object result = joinPoint.proceed();

        if (cacheControl != null && result instanceof ActionResult) {
            ActionResult actionResult = (ActionResult) result;

            // 응답 데이터를 JSON으로 정규화하여 ETag 생성
            ObjectMapper mapper = new ObjectMapper();
            mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); // JSON 필드 순서 고정
            String normalizedJson = mapper.writeValueAsString(actionResult.getData()); // 데이터 부분만 해시 생성에 사용
            log.info("&&&& $$$$ data = " + normalizedJson);

            // 해시 생성 (SHA-256 사용)
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(normalizedJson.getBytes(StandardCharsets.UTF_8));
            String eTag = Base64.getEncoder().encodeToString(hash);
            log.info("&&&& Generated ETag: {}", eTag);

            // If-None-Match 헤더 확인
            String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
            log.info("&&&& If-None-Match header: {}", ifNoneMatch);

            // ETag가 일치하면 304 Not Modified 반환
            if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) {
                log.debug("&&&& ETag matches. Returning 304 Not Modified");
                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                return null; // 본문 없이 304 응답 반환
            }

            // ETag와 Cache-Control 헤더 설정
            response.setHeader(HttpHeaders.ETAG, eTag);
            log.info("&&&& Setting ETag header: {}", eTag);

            String cacheControlHeader = "max-age=" + cacheControl.maxAge();
            log.info("&&&& Setting Cache-Control header: {}", cacheControlHeader);
            response.setHeader(HttpHeaders.CACHE_CONTROL, cacheControlHeader);

            return actionResult; // 200 응답 시 본문 포함
        } else {
            log.info("&&&& Cache control not applied. Result is not ActionResult or CacheControl is null");
        }

        return result;
    }

    }

일단 모든 응답이 캐싱되지 않았던 문제는 
컨트롤러에 달아주었던 

@CacheControl(maxAge = 60)

이 놈이 문제였다. 

maxAge를 주면 그 시간동안 캐싱되어서 데이터 변경이 되어도 알빠노 무시되고 캐싱되었기 때문에 아무리 요청해도 돌아오는 응답은 200 뿐이었다. 

 

 

그래서 

@CacheControl(maxAge = 0)

했더니 304로 캐싱이 잘 됐다.

그리고 데이터베이스에서 데이터를 하드코딩해서 변경하면 응답이 달라져서 ETag도 달라졌고,

그 땐 200으로 필요한 응답도 잘 돌아왔다. 

 

 

아 다 끝났다 ㅋ 

하고선 필요없는 이랍시고 로깅 지우고 뭐 지우고 하고 다시 테스트 해봤더니 또 안됐다.

 


 

콘솔에 요청마다 

     Generated ETag: abcdefg12345

     If-None-Match header: null

     Setting ETag header: abcdefg12345

라고 찍혔다.

분명 같은 요청에 대해 같은 ETag는 생성되는데 ... 대체 왜 ...

response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=" + cacheControl.maxAge());

이 부분을 지운게 문제였다.

이 부분을 지웠더니 헤더에 캐시 컨트롤 한다고 알리지 않게 되어버린 것이었던 것이다....

어쩐지 아예 메서드를 안타버리더라... 

 

// ETag와 Cache-Control 헤더 설정
response.setHeader(HttpHeaders.ETAG, eTag);
response.setHeader(HttpHeaders.CACHE_CONTROL, "public, max-age=0");
log.info("Setting ETag header: {}", eTag);

요로코롬 헤더를 두개 다 넣어줘야 ETag도 넘어가고 나 캐시 할 거임~도 잘넘어간다. 

 

아무튼 그래서 

 

최종적으로 

@Aspect
@Component
@Log4j2
public class CacheControlAspect {

    @Around("@annotation(org.springframework.web.bind.annotation.GetMapping)")
    public Object handleCacheControl(ProceedingJoinPoint joinPoint) throws Throwable {

        // HTTP 요청 및 응답 가져오기
        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = attrs.getRequest();
        HttpServletResponse response = attrs.getResponse();

        // 메서드와 어노테이션 가져오기
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        CacheControl cacheControl = method.getAnnotation(CacheControl.class);

        log.info("Handling cache control for method: {}", method.getName());

        // 메서드 실행
        Object result = joinPoint.proceed();

        ActionResult actionResult = (ActionResult) result;

        // 응답 데이터를 JSON으로 정규화하여 ETag 생성
        ObjectMapper mapper = new ObjectMapper();
        mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); // JSON 필드 순서 고정
        String normalizedJson = mapper.writeValueAsString(actionResult.getData()); // 데이터 부분만 해시 생성에 사용
        log.info("Response body Data = " + normalizedJson);

        // 해시 생성 (SHA-256 사용)
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(normalizedJson.getBytes(StandardCharsets.UTF_8));
        String eTag = Base64.getEncoder().encodeToString(hash);
        log.info("Generated ETag: {}", eTag);

        // If-None-Match 헤더 확인
        String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
        log.info("If-None-Match header: {}", ifNoneMatch);

        // ETag가 일치하면 304 Not Modified 반환
        if (ifNoneMatch != null && ifNoneMatch.equals(eTag)) {
            log.debug("ETag matches. Returning 304 Not Modified");
            response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
            return null; // 본문 없이 304 응답 반환
        }

        // ETag와 Cache-Control 헤더 설정
        response.setHeader(HttpHeaders.ETAG, eTag);
        response.setHeader(HttpHeaders.CACHE_CONTROL, "public, max-age=0");
        log.info("Setting ETag header: {}", eTag);

        return actionResult; // 200 응답 시 본문 포함

    }

이렇게 작성해주었다. 

 

조회에만 캐싱하면 될 것 같아서 일단 모든 @GetMapping 어노테이션에 캐싱되도록 설정했다. 

 

첫 요청 이후로 304 응답이 오는 것을 확인했다. 

우와 신기하다~ 하고서 서버 끄려다가 디비에 데이터 안바꿔본 걸 깜빡하고 디비에 값도 변경해주었는데 

데이터 바뀐 응답은 ETag도 잘 바뀐다 ~~ 아싸~