12월이다. 소집해제의 해가 다가온다ㅎ
12월 중순이 지나면 외교행낭 업무가 마감되기 때문에 급한 경우를 제외하고는 연말까지 발송을 하지 않는다. 마감 직전에는 발송이 몰리기 때문에 미뤄 두다가, 마감된 틈을 타 나머지 작업들을 재빠르게 수행하고 그 기록을 남긴다.
올해 업무 마감일이 정해짐에 따라 마감일 이후 발송 공관들을 모두 수정 처리해야 했다.

올해 행낭 마감일은 12월 12일 금요일이었으며, 이날 이후 발송되는 공관들은 다음과 같이 변동되어 발송되었다:
| 원래 일정 | 변동된 일정 | 비고 |
|---|---|---|
| 12월 15~19일 | 12월 01~05일 | 14일 앞당김 |
| 12월 22~26일 | 12월 8~12일 | 14일 앞당김 |
| 12월 29~31일 | 12월 01~03일 | 28일 앞당김 |
일정을 계산하는 입장에서 생각해 보면

Cloudfront 호스팅의 문제 중 하나는 요청 수가 늘어나면 요금이 천문학적으로 늘어난다는 거다. 물론 1편에서 밝힌 바와 같이 내부적으로 몇 명 쓰고 마는 간단한 서비스이기에 그 요금을 무시해도 되는 수준이지만, 갑자기 이 도메인이 좌표가 찍혀서 수백 달러의 요금이 나올 지도 모를 일 아닌가.
수백 달러가 아니라 몇 달러의 요금도 금전적인 부담으로 다가오는 내게는 행여 요청 수가 크게 늘어날까 신경이 쓰이던 상황이었다.
그러던 어느 날 AWS에 Flat-rate Pricing Plans, 즉 고정형 요금제가 도입되었다. 내가 필요한 CloudFront 콘텐츠 배포는 물론 WAF, Route 53, CloudWatch 로그, 엣지 컴퓨팅, S3 등에 모두 종량형 요금제를 택해 트래픽이 급증해도 비용 부담을 피할 수 있다.
CLoudfront 배포에 들어가면 새로운 요금 티어를 선택할 수 있다. 무료 요금제만 해도 월 100만 건의 요청과 100GB의 데이터 전송을 감당할 수 있기 때문에 개인 차원에서는 차고 넘치는 수준이다.


별도의 엣지 캐싱을 수행하지 않고 매번 필요한 DDB에서 정보들을 로드하는 데에는 다소 긴 시간이 걸린다. 최초 접속 시에는 1.5초 이상이 걸리는 경우도 흔히 발생했으며 캐싱된 정보가 있다면 좀 더 빨라지지만 실 사용 환경에서 400-700ms대의 지연 시간을 보여주었다.

가끔씩 이 페이지를 보면서 속도가 느리게 느껴진다는 피드백이 있었다. '이거 안 나오는데요?' 하다가 뒤늦게 '아 이제 되네요?' 하는 사람도 있었고, 나조차도 전화 응대하고 문서 접수하느라 한시가 급한데, 일정이 로드되는 걸 기다리지 못하고 달력 보면서 직접 일정을 계산하곤 했으니.
속도 개선을 위해서는 크게 두 가지 접근을 택했다:
1) 우선 사용자 체감 감소를 위한 스켈레톤 UI를 도입하고 즉시 배포하였으며,
2) 그 후 실질적인 로딩 시간 감소를 위한 캐싱 전략을 수립하였다.
아래와 같이 공관 및 일정 정보 로드 시 스켈레톤 UI를 도입하였다.
아래 gif는 인터페이스 표현을 위해 스켈레톤 UI를 강제 렌더링한 것으로, 실제 로드 시간과는 다소 차이가 있다.



위와 같이 드롭다운 메뉴와 발송 일정 모달에서 공관 목록이 로드되어 있는지를 탐지하는 isCitiesLoading 변수값을 참조하여 스켈레톤 UI와 실제 공관/발송목록 중 무엇을 표출할 지 정하게 된다.
S3를 Cloudfront의 Origin으로 하는 환경에서는 API Response를 Cloudfront로 캐싱하는 것이 불가능하다. 따라서 Cloudfront를 API 앞에도 배치하는 수정 작업이 필요하다.
즉, 아래와 같이 아키텍처 수정이 필요하다.
아키텍처 수정이 필요하기에 별도의 복제된 환경을 만들어 블루-그린 배포 원칙을 준수했다.
사용자 → CloudFront (단일 도메인)
├─ / → S3 Origin (React 앱)
└─ /api/* → API Gateway Origin (API 호출)

원래 계획은
| 경로 | TTL | 비고 |
|---|---|---|
/cities | 604800초 (7일) | 공관 정보는 거의 변경 없음 |
/notices | 86400초 (1일) | 일 1회 이하 불규칙적으로 공지 변경 |
/schedules | 3600초 (1시간) | 일정 변경이 일 1~3건 발생 |
이렇게 API마다 별개의 캐시 정책을 구현하는 것이었다. 하지만 CloudFront의 커스텀 캐싱은 월 $200짜리 Busienss Plan에만 적용된다. 따라서 기본 CacheOptimized 정책을 일괄 사용하기로 했다. 그래도 Lambda 응답에서 캐싱 정책을 설정하면 CloudFront가 허용하는 한도 내에서 해당 정책이 그대로 반영되기 때문에 별다른 문제는 되지 않는다.
아래와 같이 클라이언트가 CF로 접근했을 때 APIGW 동작을 가능케 하는 새 동작을 추가한다.
/product/*API-Gateway-OriginRedirect HTTP to HTTPSGET, HEAD, OPTIONS, PUT, POST, PATCH, DELETECachingOptimizedAllViewer
API URL 값을 APIGW의 것에서 Cloudfront에서 새로 설정한 경로 패턴으로 변경한다. 즉 기존의
const API_URL = 'https://XXXXX.execute-api.ap-northeast-2.amazonaws.com/product'
에서
const API_URL = 'https://XXXX.cloudfront.net/product'
으로.
이렇게 하면
1. 클라이언트에서 {API_URL}/cities로 요청을 전달하게 되고
2. CloudFront 원본에서 별도의 Orign Path(원본 경로)를 지정하지 않았기 때문에
3. 이는 그대로 APIGW의 /product/cities로 연결되는 흐름을 구성한다.
axios를 활용해 /cities, /notices, /schedules 세 경로를 동시 호출
// 모든 API를 병렬로 호출하여 로딩 시간 단축
const [schedulesResult, citiesResult, noticesResult] = await Promise.allSettled([
axios.get(`${API_URL}/schedules`),
axios.get(`${API_URL}/cities`),
axios.get(`${API_URL}/notices`)
]);
상술한 바와 같이 실제 캐시 컨트롤은 람다의 응답 헤더에 캐시 컨트롤을 추가하는 방식으로 구현하였다.
/cities 메소드 쪽만 보여주자면
// 1. /cities/GET의 경우 캐시 활용
if (requestPath === '/cities') {
if (method === 'GET') {
(중략: DDB 테이블 읽기 요청)
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=604800, s-maxage=604800' // 캐시 설정
},
body: JSON.stringify(result.Items || [])
};
}
// 2. /cities/POST 가 호출 즉 공관 변동이 발생할 경우 즉시 무효화
else if (method === 'POST') {
(중략: DDB 업데이트 쿼리 요청)
// CloudFront 캐시 무효화
await invalidateCloudFrontCache([
'/product/cities*',
'/product/schedules*'
]);
// 무효화 헤더 리턴
return {
statusCode: 201,
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
},
body: JSON.stringify({ message: '공관이 추가되었습니다.' })
};
}
//3. /cities/DELETE가 호출 즉 공관이 삭제되될 경우에도 마찬가지
else if (method === 'DELETE') {
(중략: DDB 삭제 쿼리 요청)
// CloudFront 캐시 무효화
await invalidateCloudFrontCache([
'/product/cities*',
'/product/schedules*'
]);
// 무효화 헤더 리턴
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
},
body: JSON.stringify({ message: '도시가 삭제되었습니다.' })
};
}
}
그리고 위에서 사용된 비동기 함수 invalidateCloudFrontCache()는 아래와 같다:
async function invalidateCloudFrontCache(paths) {
if (!DISTRIBUTION_ID) {
console.warn('⚠️ DISTRIBUTION_ID 미설정'); //DID를 찾지 못한 경우
return false;
}
// 새 무효화 명령 생성하고 CF로 전송
try {
const command = new CreateInvalidationCommand({
DistributionId: DISTRIBUTION_ID,
InvalidationBatch: {
CallerReference: `invalidation-${Date.now()}`,
Paths: {
Quantity: paths.length,
Items: paths
}
}
});
const response = await cloudfront.send(command);
console.log(`✅ 무효화 성공: ${response.Invalidation.Id}`);
console.log(`🧭 경로: ${paths.join(', ')}`);
return true;
// 에러 처리
} catch (error) {
console.error(`❌ 무효화 실패: ${error.message}`);
return false;
}
}
cities 외에 schedules, notices도 캐싱 시간만 다를 뿐 이와 동일한 방식으로 처리한다.
Lambda에 무효화 경로를 구현하는 것만으로 무효화가 되지는 않는다. 반드시 Lambda 함수에 CloudFrontFullAccess 등의 권한을 추가해 주어야 한다.

이렇게 수정하고 난 후 로드 시간이 크게 단축됨을 알 수 있었다.
좌측이 기존, 우측이 병렬 호출을 제외한 모든 변경을 구현한 버전이며, 브라우저 캐시를 비활성화했을 때 이 정도의 성능이 나왔다.

여기서 순차 호출 대신 동시 호출을 활성화하면 0.5초, 그리고 브라우저 캐시까지 사용하면 (당연히)밀리초 수준의 극도로 짧은시간을 보여주었다.
| /schedules | /notices | /cities | 합계 | |
|---|---|---|---|---|
| 기존 | 1.43s | 1.19s | 0.17s | 2.79s |
| 최적화 (Cache Disabled) | 0.54s | 0.27s | 0.08s | 0.89s |
| 최적화+병렬호출 (Cache Disabled) | -- | -- | -- | 0.54s |
개인이 할 수 있는 한 최대한 동일한 환경(동일 기기, 동일 브라우저, 동일 AP)에서 기록을 비교한 결과이다.
참고) 캐싱을 위해 수정한 최종 아키텍는 아래와 같다.

이 글을 작성하는 현재는 모든 업무가 중단되어 있기에 충분한 피드백을 얻기가 쉽지 않다. 연초 행낭 업무가 재개되었을 때 확실히 체감되는 변화가 있는지 좀 더 지켜보고자 한다. 최적화가 체감되었다는 피드백이 있어야 후련하게 글을 마무리지을 텐데 조금은 찝찝하다...ㅎㅎ