コンテンツにスキップ

AWS Secret Manager 호출을 인스턴스 캐시로 줄여 비용 절감한 기록

このコンテンツはまだ日本語訳がありません。

거의 바뀌지 않는 Secret 값을 요청마다 다시 읽고 있어서 AWS Secret Manager 호출 수와 비용이 함께 증가하고 있었다.

AWS 요금에서 AWS Secret Manager 비중이 계속 커지고 있어 원인을 확인해 달라는 요청을 받았다.

서비스에서는 외부 서비스로 리다이렉트할 때 필요한 URL을 동적으로 만들고 있었고, 이 과정에서 인코딩이나 인증에 필요한 Secret 값을 AWS Secret Manager에서 읽어 오고 있었다.

문제는 이 값들이 거의 바뀌지 않는데도 요청이 들어올 때마다 다시 조회하고 있었다는 점이다. 사용자 수와 리다이렉트 처리 수가 늘어날수록 Secret Manager 호출 수도 함께 증가했다.

요청 단위로 Secret Manager를 호출하는 구조였다.

Secret Manager에서 읽어 오는 값은 주로 인코딩, 암호화, 인증용 키였고 자주 바뀌는 데이터가 아니었다.

그런데 애플리케이션은 이 값을 프로세스 메모리에 유지하지 않고, URL 생성이 필요할 때마다 다시 조회하고 있었다. 결과적으로 캐시 없이 요청당 1회 이상 Secret Manager를 호출하는 구조가 되었고, 트래픽 증가가 그대로 비용 증가로 이어졌다.

Secret 값을 인스턴스 메모리에 캐시했다.

이 값들은 변경 빈도가 낮고 개수도 많지 않았기 때문에, Redis 같은 별도 스토리지를 두기보다 서버 인스턴스 메모리에 보관하는 편이 더 단순하다고 판단했다.

전체 흐름은 아래와 같다.

캐시 있음

캐시 없음

Lambda

ECS Fargate

Secret 조회 로직 진입

인스턴스 캐시 확인

캐시 값 반환

AWS Secret Manager 호출

Secret 값 조회

인스턴스 캐시에 저장

응답 반환

인스턴스별 개별 캐시

서버 간 캐시 공유 안 됨

프로세스 메모리 사용

프로세스 재시작 시 캐시 초기화

실행 환경

재사용 예측이 어려워 효과 제한적

컨테이너 생존 동안 비교적 안정적

아래처럼 TTL 기반 캐시 클래스를 두고 서비스에서 재사용했다.

export class Cache<T> {
private readonly cache = new Map<string, { expiresAt: number; value: T }>();
constructor(private readonly ttlMs: number) {}
get(key: string): T | undefined {
const item = this.cache.get(key);
if (!item) {
return undefined;
}
if (Date.now() >= item.expiresAt) {
this.cache.delete(key);
return undefined;
}
return item.value;
}
set(key: string, value: T) {
this.cache.set(key, {
expiresAt: Date.now() + this.ttlMs,
value,
});
}
clear() {
this.cache.clear();
}
}

서비스에서는 아래처럼 Secret 조회 전에 캐시를 먼저 확인했다.

@Injectable()
export class ExampleService {
private readonly cache = new Cache<string>(1000 * 60 * 60 * 24 * 30); // 30 days
async fetchSecretManagerValue() {
const key = 'SECRET_KEY';
const cached = this.cache.get(key);
if (cached) {
return cached;
}
// === AWS Secret Manager에서 값을 조회하는 처리
...
// ===
const value = 'AWS Secret Manager에서 가져온 데이터';
this.cache.set(key, value);
return value;
}
}

이 방식을 적용한 뒤 AWS Secret Manager 관련 비용이 80% 이상 줄었다는 보고를 받았다.

1) 왜 인스턴스 캐시를 선택했는가

섹션 제목: “1) 왜 인스턴스 캐시를 선택했는가”
  • 캐시 대상 데이터 수가 적었다.
  • 값 변경 빈도가 낮았다.
  • 서버 메모리에 올려도 운영 부담이 크지 않았다.
  • 별도 캐시 스토리지를 도입하는 것보다 구현과 운영이 단순했다.

이 구조는 서버 인스턴스 메모리를 그대로 사용하므로 아래 제약이 있다.

  • 캐시 데이터는 인스턴스마다 따로 존재하며 서로 공유되지 않는다.
  • 프로세스가 종료되면 캐시도 함께 사라진다.
  • 만료 여부를 get()에서만 확인하면, 다시 조회되지 않는 키는 메모리에 더 오래 남을 수 있다.

서버 간 캐시는 공유되지 않는다

섹션 제목: “서버 간 캐시는 공유되지 않는다”

이 캐시는 NestJS 앱 프로세스 내부 메모리에 존재한다. 따라서 인스턴스가 200개면 같은 Secret 값도 인스턴스별로 최대 200개까지 따로 캐시될 수 있다.

즉, 캐시 TTL을 30일로 두었다고 해도 전체 시스템 기준 호출 수가 완전히 1회로 줄어드는 것은 아니다. 인스턴스 수만큼 초기 조회는 여전히 발생할 수 있다.

프로세스가 재시작되면 캐시는 리셋된다

섹션 제목: “프로세스가 재시작되면 캐시는 리셋된다”

배포, 스케일링, 장애 복구, 재시작이 발생하면 프로세스 메모리가 초기화되므로 캐시도 함께 사라진다.

우리 팀은 AWS Lambda와 ECS Fargate를 함께 사용하고 있었는데, 두 환경은 이 점에서 차이가 있었다.

  • AWS Lambda
콜드 스타트 -> Node.js 프로세스 시작 -> NestJS DI 컨테이너 생성 -> 캐시 생성
|
리퀘스트 처리
|
실행 환경 종료 -> 프로세스 종료 -> 캐시 삭제

Lambda는 실행 환경이 재사용될 때는 캐시 효과가 있을 수 있지만, 실행 환경 수명과 재사용 여부를 보장할 수 없다. 그래서 장기 TTL을 줘도 효과가 제한적이고 예측이 어렵다.

  • ECS Fargate
컨테이너 시작 -> Node.js 프로세스 시작 -> NestJS DI 컨테이너 생성 -> 캐시 생성

ECS Fargate는 컨테이너가 계속 살아 있는 동안 같은 프로세스 메모리를 재사용하므로, 배포나 재시작 전까지는 캐시 효과가 비교적 안정적으로 유지됐다.


1) 다시 같은 비용 이슈를 보면 먼저 확인할 것

섹션 제목: “1) 다시 같은 비용 이슈를 보면 먼저 확인할 것”
  • Secret 값이 요청마다 다시 조회되는지 확인한다.
  • 실제로 자주 바뀌는 값인지 먼저 구분한다.
  • 호출 수 증가가 트래픽 증가와 비례하는지 본다.
  • 인스턴스 수가 많다면 캐시 후에도 초기 호출 수가 얼마나 되는지 계산한다.

2) NestJS에서 이 캐시가 유지되는 이유

섹션 제목: “2) NestJS에서 이 캐시가 유지되는 이유”

NestJS에서 @Injectable()로 등록한 provider는 기본적으로 singleton scope다. 그래서 앱 프로세스가 살아 있는 동안 서비스 인스턴스와 그 안의 캐시 객체도 함께 유지된다.

즉, 같은 인스턴스 안에서는 최초 조회 이후 캐시 TTL이 끝날 때까지 Secret Manager 호출을 줄일 수 있다.

아래의 구조에서 ExampleAService와 ExampleBService는 각각 별도의 캐시 데이터를 가진다.

Node.js 프로세스 메모리
├── Stack
└── Heap (Node.js의 모든 객체는 Heap에 저장된다.)
└── Nest.js DI 컨테이너
└── ExampleAService 인스턴스
| └── cache: Cache
| └── cacheA: Map<string, { expiresAt, value }>
| └── "key1" -> { expiresAt, value }
| └── "key2" -> { expiresAt, value }
└── ExampleBService 인스턴스
└── cache: Cache
└── cacheB: Map<string, { expiresAt, value }>
└── "key1" -> { expiresAt, value }
└── "key2" -> { expiresAt, value }
  • 여러 인스턴스가 같은 캐시를 공유해야 할 때
  • 캐시 무효화를 중앙에서 제어해야 할 때
  • Lambda처럼 실행 환경 수명이 짧거나 재사용 예측이 어려운 환경에서 효과를 안정적으로 보장해야 할 때

이런 경우에는 Redis 같은 외부 캐시나 별도 설정 저장소를 검토하는 편이 낫다.