Skip to content

AWS Lambda + AOP로 분산 캐시 무효화하기

AWS Lambda + AOP로 분산 캐시 무효화하기

서론

“관리자가 건물 정보를 수정했는데, 앱에서는 왜 이전 정보가 계속 보이나요?”

고객센터에서 이런 문의가 들어왔을 때, 처음엔 DB 반영 문제인 줄 알았습니다. 그런데 확인해보니 DB에는 최신 데이터가 있었거든요. 원인을 추적해보니 캐시 문제였습니다. 그것도 로컬 캐시요.

저희 서비스는 2대의 API 서버가 로드밸런서 뒤에서 돌아갑니다. 각 서버가 로컬 메모리에 캐시를 들고 있는데, 문제는 이 캐시가 서로 동기화되지 않는다는 점이었습니다. 관리자가 서버 A로 요청을 보내서 데이터를 수정하면 서버 A의 캐시는 무효화되지만, 서버 B는 여전히 오래된 캐시를 갖고 있는 거죠.

“그럼 Redis 같은 중앙 캐시를 쓰면 되잖아요?”

네, 맞습니다. 그게 정석입니다. 하지만 현실은 그렇게 이상적이지 않았습니다. 레거시 시스템에 이미 깊숙이 박혀있는 로컬 캐시를 당장 걷어낼 수도 없었고, 고객 불만이 계속 들어오는 상황에서 2주 안에 뭔가를 해야 했거든요.

이번 포스팅에서는 “완벽한 해결책은 아니지만, 우리 상황에서는 최선이었던” Lambda + AOP 방식의 분산 캐시 무효화 경험을 공유하려고 합니다.





문제 상황 - 캐시가 제각각

실제로 겪은 버그

상황을 좀 더 구체적으로 설명하면 이랬습니다.

  1. 관리자가 건물 정보 수정 (서버 A로 요청)
  2. 서버 A의 캐시는 무효화됨
  3. 서버 B의 캐시는 그대로 살아있음
  4. 사용자가 정보 조회 (서버 B로 요청)
  5. 서버 B는 오래된 캐시를 반환

로드밸런서가 요청을 분산하다 보니, 어떤 때는 최신 데이터가 보이고 어떤 때는 옛날 데이터가 보이는 겁니다. 사용자 입장에서는 “이 서비스 정상이 아닌 것 같은데?”라고 생각할 수밖에 없습니다.


sequenceDiagram
    participant 관리자
    participant LB as 로드밸런서
    participant A as 서버 A
    participant B as 서버 B
    participant 사용자

    관리자->>LB: 건물 정보 수정
    LB->>A: 요청 전달
    A->>A: DB 업데이트 + 캐시 무효화
    Note over B: 캐시 그대로 유지 (문제!)

    사용자->>LB: 건물 정보 조회
    LB->>B: 요청 전달
    B->>사용자: 오래된 캐시 반환

딱 봐도 답답한 상황이죠.





해결 방법 고민 - 이상과 현실

처음에는 당연히 “Redis로 갈아타자”가 나왔습니다.


Redis Pub/Sub - 이상적인 답

// 이론적으로는 이게 정답입니다
@Service
public class CacheInvalidationService {
    private final RedisMessagePublisher publisher;

    public void invalidateCache(String key) {
        publisher.publish("cache:invalidate", key);
    }
}

@Component
public class CacheInvalidationSubscriber implements MessageListener {
    private final CacheManager cacheManager;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = message.toString();
        cacheManager.getCache("building").evict(key);
    }
}

Redis Pub/Sub로 모든 인스턴스에 무효화 메시지를 브로드캐스트하면 됩니다. 이론적으로는 완벽합니다.

하지만 현실은 달랐습니다.


현실의 제약사항

1. 레거시 코드의 벽

캐시 사용 코드가 100개 넘는 곳에 산재해 있었습니다. @Cacheable 어노테이션 없이 직접 구현된 캐시 로직도 있었고, 일부는 Spring Cache 추상화도 안 쓰고 ConcurrentHashMap을 직접 사용하고 있더군요.


2. 시간의 압박

고객 불만이 계속 들어오는 상황이었습니다. 전면 리팩토링할 시간은 없었고, 2주 안에 해결해야 했습니다.


3. 인프라 제약

Redis 클러스터 구축하려면 운영팀 승인이 필요했고, 네트워크 레이턴시 검토도 해야 했습니다. 비용 검토까지… 2주 안에 끝낼 수 있는 범위가 아니었습니다.


팀 회의에서 이런 얘기가 나왔습니다.

“일단 급한 불부터 끄고, Redis 마이그레이션은 다음 분기에 하면 안 될까요?”

솔직히 저도 찜찜했습니다. 하지만 2주 vs 2개월의 선택이었습니다.





우리가 선택한 방법 - Lambda + AOP

결국 선택한 방법은 이렇습니다.

“데이터가 변경되면, Lambda를 통해 모든 API 서버에 캐시 무효화 요청을 날리자”


아키텍처 개요

flowchart LR
    A[Admin API<br/>데이터 수정] --> B[AOP Interceptor<br/>변경 감지]
    B --> C[AWS Lambda<br/>트리거]
    C --> D[API Server 1<br/>/cache/invalidate]
    C --> E[API Server 2<br/>/cache/invalidate]
    C --> F[API Server N<br/>/cache/invalidate]

    style A fill:#e8f5e9
    style C fill:#fff3e0
    style D fill:#e3f2fd
    style E fill:#e3f2fd
    style F fill:#e3f2fd

Lambda를 선택한 이유는 간단합니다.

  1. 기존 API 서버 코드 수정 최소화 - 어노테이션 하나만 붙이면 됨
  2. 빠른 구현 가능 - Lambda 함수 하나면 끝
  3. 서버 개수 동적 대응 - 오토스케일링 시에도 작동

구현 - AOP로 변경 감지

먼저 커스텀 어노테이션을 만들었습니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface InvalidateCache {
    String cacheName();
    String key();
}

그리고 AOP로 이 어노테이션이 붙은 메서드를 가로채서 Lambda를 호출합니다.

@Aspect
@Component
@Slf4j
public class CacheInvalidationAspect {

    private final LambdaClient lambdaClient;
    private final ObjectMapper objectMapper;

    @AfterReturning("@annotation(invalidateCache)")
    public void invalidateCacheAfterUpdate(
        JoinPoint joinPoint,
        InvalidateCache invalidateCache
    ) {
        try {
            String cacheKey = extractCacheKey(joinPoint, invalidateCache);
            String cacheName = invalidateCache.cacheName();

            // Lambda 비동기 호출
            InvocationRequest request = InvocationRequest.builder()
                .functionName("cache-invalidation-broadcaster")
                .payload(SdkBytes.fromUtf8String(
                    objectMapper.writeValueAsString(
                        Map.of("cacheName", cacheName, "key", cacheKey)
                    )
                ))
                .build();

            lambdaClient.invoke(request);

            log.info("Cache invalidation triggered: {}:{}", cacheName, cacheKey);

        } catch (Exception e) {
            // 캐시 무효화 실패는 서비스 실패로 전파하지 않음
            log.error("Failed to invalidate cache", e);
        }
    }

    private String extractCacheKey(JoinPoint joinPoint, InvalidateCache annotation) {
        Object[] args = joinPoint.getArgs();
        String keyExpression = annotation.key();

        // SpEL 평가 로직 (실제로는 SpelExpressionParser 사용)
        return evaluateKey(keyExpression, args);
    }
}

사용하는 쪽에서는 이렇게 어노테이션만 붙이면 됩니다.

@Service
public class BuildingService {

    @InvalidateCache(cacheName = "building", key = "#buildingId")
    @Transactional
    public void updateBuilding(Long buildingId, BuildingUpdateDto dto) {
        Building building = buildingRepository.findById(buildingId)
            .orElseThrow();

        building.update(dto);
        buildingRepository.save(building);

        // AOP가 트랜잭션 커밋 후 자동으로 Lambda 호출
    }
}

한 줄 어노테이션 추가로 끝입니다. 기존 코드를 거의 건드리지 않았죠.


구현 - Lambda Function

Lambda가 하는 일은 단순합니다. 현재 실행 중인 모든 API 서버 인스턴스를 찾아서, 각각에 캐시 무효화 요청을 보내는 거죠.

import json
import boto3
import requests

ec2 = boto3.client('ec2')

def lambda_handler(event, context):
    cache_name = event['cacheName']
    cache_key = event['key']

    # 현재 실행 중인 API 서버 인스턴스 목록 가져오기
    instances = get_api_server_instances()

    success_count = 0
    for instance in instances:
        private_ip = instance['PrivateIpAddress']
        url = f"http://{private_ip}:8080/internal/cache/invalidate"

        try:
            response = requests.post(url, json={
                'cacheName': cache_name,
                'key': cache_key
            }, timeout=3)

            if response.status_code == 200:
                success_count += 1

        except Exception as e:
            print(f"Failed to invalidate on {private_ip}: {e}")
            # 한 서버 실패해도 나머지는 계속 진행
            continue

    return {
        'statusCode': 200,
        'body': json.dumps(f'Invalidated on {success_count}/{len(instances)} instances')
    }

def get_api_server_instances():
    # Auto Scaling Group의 인스턴스 목록 조회
    response = ec2.describe_instances(Filters=[
        {'Name': 'tag:Name', 'Values': ['api-server']},
        {'Name': 'instance-state-name', 'Values': ['running']}
    ])

    instances = []
    for reservation in response['Reservations']:
        for instance in reservation['Instances']:
            instances.append(instance)

    return instances

핵심 포인트:

  • EC2 태그로 API 서버 인스턴스 목록을 동적으로 조회
  • 한 서버 실패해도 다른 서버는 계속 처리 (Fail-safe)
  • VPC 내부 통신이라 네트워크 비용 최소화

구현 - API 서버의 무효화 엔드포인트

@RestController
@RequestMapping("/internal/cache")
@Slf4j
public class CacheInvalidationController {

    private final CacheManager cacheManager;

    @PostMapping("/invalidate")
    public ResponseEntity<Void> invalidateCache(
        @RequestBody CacheInvalidationRequest request
    ) {
        String cacheName = request.getCacheName();
        String key = request.getKey();

        Cache cache = cacheManager.getCache(cacheName);
        if (cache != null) {
            cache.evict(key);
            log.info("Cache evicted: {}:{}", cacheName, key);
        }

        return ResponseEntity.ok().build();
    }
}

이 엔드포인트는 외부 노출되면 안 되므로, Security 설정으로 VPC 내부에서만 접근 가능하게 했습니다.





모니터링 - 정말 잘 작동하는지 확인

구현만 하고 끝내면 불안합니다. 정말 잘 작동하는지 눈으로 확인해야 했습니다.


APM 메트릭 추가

@Aspect
@Component
public class CacheInvalidationAspect {

    private final StatsDClient statsd;

    @AfterReturning("@annotation(invalidateCache)")
    public void invalidateCacheAfterUpdate(...) {
        long startTime = System.currentTimeMillis();

        try {
            lambdaClient.invoke(request);

            long duration = System.currentTimeMillis() - startTime;

            // Datadog 메트릭 전송
            statsd.increment("cache.invalidation.trigger");
            statsd.histogram("cache.invalidation.duration", duration);
            statsd.increment("cache.invalidation.success");

        } catch (Exception e) {
            statsd.increment("cache.invalidation.failure");
            log.error("Failed to invalidate cache", e);
        }
    }
}

APM 대시보드에서 확인한 지표 (배포 후 1주일 측정 결과):

항목수치비고
일일 트리거 횟수50~80회관리자 수정 빈도 수준
Lambda 응답 시간평균 200ms, P95 500ms대부분 200ms 이내
실패율0.5% 미만간헐적 네트워크 오류

처음 배포 후 1주일간 지켜봤는데, 다행히 캐시 불일치 문제는 완전히 사라졌습니다. 고객센터에서도 관련 문의가 더 이상 안 들어오더군요.





트레이드오프와 한계

이 방법이 완벽하냐고요? 전혀 아닙니다. 솔직히 말해서 여러 한계가 있습니다.


명백한 한계들

1. 동기화 지연

Lambda 호출부터 모든 서버 무효화까지 평균 200~500ms 정도 걸립니다. 이론적으로는 이 짧은 시간 동안 오래된 캐시를 읽을 수 있습니다.

다만 저희 서비스 특성상 (관리자가 수정 → 일반 사용자가 조회) 이 정도 지연은 크게 문제되지 않았습니다. 실시간 채팅 같은 서비스였다면 이 방식은 안 됐을 거예요.


2. Lambda 실패 시 대응 부족

Lambda가 아예 실패하면? 캐시 무효화 자체가 안 됩니다.

현재는 로그만 남기고 넘어갑니다. DLQ(Dead Letter Queue) 설정은 했지만, 재처리 로직은 없습니다. 솔직히 이건 기술 부채입니다.


3. 비용과 복잡도

Lambda 호출마다 비용이 발생합니다. 아직은 월 몇 달러 수준이라 괜찮지만, 트래픽이 10배 늘면 다시 고민해야 합니다.

그리고 솔직히, 캐시 무효화 하나에 Lambda까지 동원하는 건 좀 오버엔지니어링 느낌이 있습니다.


4. 근본적 해결 아님

이건 어디까지나 임시 방편입니다. 로컬 캐시를 쓰는 한 이런 문제는 계속 생길 수 있습니다. 장기적으로는 Redis 같은 중앙 캐시로 가야 맞습니다.


더 나은 방법들

지금 다시 설계한다면 이런 옵션들을 고려할 것 같습니다.

방식장점단점추천 상황
Redis Pub/Sub실시간, 검증된 방식인프라 추가 필요신규 프로젝트
Hazelcast자동 동기화러닝 커브대규모 클러스터
Caffeine + Redis로컬 캐시 속도 + 중앙 관리구현 복잡하이브리드 요구 시

하지만 당시 상황에서는 Lambda + AOP가 최선이었습니다.





결론

두 가지를 배웠습니다.


첫째, 완벽한 해결책보다 빠른 해결책이 필요할 때가 있다는 것입니다.

Lambda + AOP 방식은 이상적인 설계가 아닙니다. 교과서에도 안 나오는 방법이죠. 하지만 2주 만에 고객 불만을 해소하고, 기존 코드를 최소한만 건드리면서 문제를 해결했습니다.

만약 “완벽한 설계”를 고집했다면? Redis 마이그레이션 계획 수립하고, 전체 코드 리팩토링하고, 테스트하고… 아마 2~3개월은 걸렸을 겁니다. 그동안 고객은 계속 불편을 겪고요.


둘째, 기술 부채를 명확히 인식하고 관리하는 것의 중요성입니다.

저희 팀은 이 방식이 “임시 방편”이라는 걸 명확히 알고 있습니다. 그래서 다음 분기 Redis 마이그레이션을 백로그에 등록해뒀습니다. 언젠가는 갚아야 할 빚이라는 거죠.

기술 부채가 나쁜 게 아닙니다. 인식하지 못하고 방치하는 게 나쁜 겁니다.


기술 선택은 언제나 맥락 안에서 이루어집니다.

팀의 상황, 시간, 비용, 레거시 코드, 비즈니스 우선순위… 이 모든 걸 고려했을 때 “지금 우리한테 최선”인 선택을 하는 게 시니어 개발자의 역할이라고 생각합니다.

다만 그 선택의 한계를 정확히 알고, 언젠가는 더 나은 방법으로 갈아타야 한다는 걸 잊지 않는 것. 그게 중요합니다.


P.S. 이 글을 읽는 분들께 하나만 말씀드리고 싶습니다. 만약 지금 새로운 프로젝트를 시작하신다면, 처음부터 Redis 같은 중앙 캐시를 쓰세요. 로컬 캐시는… 정말 조심하셔야 합니다. 저희처럼 나중에 고생하지 마시고요.





참고 :

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop
https://docs.aws.amazon.com/lambda/latest/dg/welcome.html
https://spring.io/guides/gs/caching/
https://www.baeldung.com/spring-cache-tutorial




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site