CloudWatch RUM + CloudFront Functions + KVS로 데이터 기반 Speculation Rules 구축하기

CloudWatch RUM + CloudFront Functions + KVS로 데이터 기반 Speculation Rules 구축하기

TL;DR: Speculation Rules API는 사용자가 다음에 방문할 페이지를 브라우저가 미리 로드하거나 렌더링하도록 지시합니다. 핵심 과제는 “어떤 페이지를” 예측하느냐입니다. 이 글에서는 자동화 파이프라인을 구축합니다: CloudWatch RUM이 실제 사용자 네비게이션 패턴을 수집하고, 일일 Lambda가 페이지별 상위 목적지를 계산하여 S3에 JSON 파일로 업로드하고 CloudFront KeyValueStore에 매핑을 기록합니다. CloudFront Function(viewer-response)이 Speculation-Rules 응답 헤더를 동적으로 삽입합니다. 데이터가 없는 페이지는 안전한 기본값(pointer-down prefetch)을 적용합니다. Terraform 코드 전체 포함.

Key Terms: Speculation Rules API = 브라우저가 다음 네비게이션을 prefetch/prerender하도록 JSON 규칙을 받는 API (Chrome 109+) | Prefetch = 페이지 리소스를 미리 다운로드 (네트워크 비용만 발생) | Prerender = 페이지를 숨겨진 탭에서 완전히 렌더링 (네비게이션 시 거의 즉시 전환) | CloudWatch RUM = 실제 방문자의 페이지뷰, 네비게이션 경로, 성능 메트릭을 수집하는 Real User Monitoring 서비스 | CloudFront KeyValueStore (KVS) = CloudFront Functions에서 읽을 수 있는 글로벌 저지연 키-값 스토어 | Eagerness = Speculation Rules의 트리거 타이밍 파라미터: immediate, eager, moderate (hover), conservative (pointer-down)


계기

최근 한 웹사이트의 HTTP 응답 헤더를 살펴보다가 처음 보는 헤더를 발견했습니다: Speculation-Rules. CDN 쪽 일을 하고 있어서 브라우저가 콘텐츠를 가져오는 방식에 영향을 주는 건 뭐든 궁금합니다.

찾아보니 Speculation Rules API는 Chrome이 기존의 <link rel="prerender">와 실패한 HTTP/2 Server Push를 대체하기 위해 만든 현대적 방식이었습니다. 이전 접근들과 다른 점은, 브라우저가 언제 얼마나 적극적으로 페이지를 미리 로드할지 스스로 판단한다는 것입니다. 개발자는 선언적 JSON 형식으로 의도만 전달합니다.

아이디어는 단순합니다: 사용자가 다음에 갈 가능성이 높은 페이지를 브라우저에 알려주면, 브라우저가 해당 페이지를 미리 fetch하거나 완전히 렌더링합니다. Prerender된 페이지로 이동하면 체감 로딩 시간은 사실상 0ms입니다.


“클릭하기 전에 로드하기"의 역사

웹 플랫폼은 이 문제를 여러 번 해결하려 했습니다:

연도기술접근 방식결과
2008<link rel="prefetch">다음 페이지 리소스 다운로드현존하지만 단일 리소스 제한
2011<link rel="prerender">전체 페이지를 숨겨진 탭에서 렌더링Chrome에서 제거 (2023) — Speculation Rules로 대체
2015HTTP/2 Server Push서버가 요청 없이 리소스를 밀어넣음Chrome 106에서 제거 (2022) — 브라우저 캐시 무시, CDN 호환 문제
2017103 Early Hints응답 본문 전에 preload 힌트 전송현존하지만 origin 처리 시간이 길 때만 유효
2023Speculation Rules APIprefetch/prerender를 위한 선언적 JSON 규칙현재 표준 (Chrome 109+)

핵심 교훈: 서버 주도 방식은 실패하고, 브라우저 주도 방식이 살아남았습니다. 서버/CDN은 힌트를 제공하고, 브라우저가 리소스 관리에 대한 최종 판단을 합니다.


Prefetch vs Prerender

PrefetchPrerender
하는 일HTML과 서브리소스 다운로드만숨겨진 탭에서 페이지를 완전히 렌더링
리소스 비용네트워크만 (수 KB~MB)네트워크 + CPU + 메모리 (전체 페이지)
전환 시 체감네트워크 시간 절약 (100-500ms)거의 즉시 (0ms 체감)
JS 실행안 함
부작용 위험없음 (다운로드만)있음 (analytics, API 호출 등)
브라우저 제한느슨함Chrome: 최대 2개 동시 prerender
적합한 경우후보 페이지 여러 개 (틀려도 손해 적음)확실한 단일 목적지

비즈니스 임팩트: 왜 이걸 해야 하는가

거의 즉시 페이지 전환은 단순한 UX 개선이 아니라 매출과 직결됩니다. 여러 사례 연구가 이를 증명합니다.

Speculation Rules 적용 후 검증된 결과

기업구현 방식LCP 개선비즈니스 결과출처
Ray-BanPLP→PDP prerender-43% (4.69s → 2.66s 모바일)모바일 전환율 +101%, 데스크톱 +156%web.dev, 2025.01
Monrif (이탈리아 뉴스)기사 페이지 prerender-17.9% LCP사용자 engagement +8.9%web.dev, 2025
Shopify (전체 플랫폼)Moderate prefetch-285ms TTFB, -228ms LCPTTFB 중간값 0에 근접; 클릭 전에 HTML 도착Shopify Performance, 2026.05

Ray-Ban의 결과가 특히 강력합니다: 상품 상세 페이지(PDP)를 prerender한 결과, 모바일 전환율이 2배, 데스크톱은 2.5배 이상 증가했으며, 이탈율은 13% 감소했습니다. A/B 테스트로 확인된 수치입니다.

속도 → 매출 관계

산업 전반의 연구 결과:

  • 100ms 개선 = 전환율 8.4% 증가 (소매), 10.1% (여행) — Akamai, 100억 세션 분석 (NitroPack)
  • 1초 지연 = 전환율 7% 감소 — Portent study (Colorlib, 2026)
  • 연 $10M 매출 사이트에서 500ms 개선 ≈ $500K 매출 회복 — Digital Applied, 2026

Speculation Rules의 prerender는 100ms가 아닌 수백 ms~수 초를 개선합니다. Shopify 측정 기준으로 중간값 TTFB가 0에 근접 — 사용자가 클릭하기 전에 페이지가 이미 로드되어 있습니다.

이 파이프라인이 가장 효과적인 경우

높은 효과낮은 효과
E-commerce: 목록 → 상세 전환 (Ray-Ban 패턴)SPA (이미 클라이언트 라우팅)
뉴스/미디어: 홈 → 기사 전환 (Monrif 패턴)이미 sub-second LCP인 사이트
MPA (매 네비게이션이 서버 왕복)Safari/Firefox 비율 높은 사이트 (헤더 무시)
모바일 사용자 (네트워크 지연 큼 = prefetch 효과 큼)

예측 문제

Speculation Rules의 효과는 예측 정확도에 달려 있습니다. Cloudflare는 Speed Brain (2024년 10월)으로 대시보드 토글 하나로 해결했습니다 — 자체 analytics 데이터 기반 자동 Speculation Rules 삽입.

AWS에도 동일한 데이터가 있습니다: CloudWatch RUM이 페이지뷰와 네비게이션 경로를 수집하고, CloudFront Functions가 응답 헤더를 삽입할 수 있고, KeyValueStore가 글로벌 저지연 읽기를 제공합니다. 조각을 조립하면 됩니다.


아키텍처

┌─────────────────────────────────────────────────────────────────────────┐
│                        일일 배치 파이프라인                                │
│                                                                         │
│  CloudWatch RUM ──▶ Lambda (daily, EventBridge)                         │
│  (page_view_event     │                                                 │
│   + parentPageId)     ├──▶ S3: /_speculation/{path}.json                │
│                       └──▶ KVS: page_path → rules file path            │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                        요청 시점 제공                                     │
│                                                                         │
│  사용자 ──▶ CloudFront ──▶ Origin (S3)                                  │
│                │                                                        │
│                ▼ (viewer-response)                                      │
│       CloudFront Function                                               │
│           │                                                             │
│           ├── 응답이 text/html인가?                                      │
│           │     ├── Yes: KVS 조회 (key = 정규화된 URI)                   │
│           │     │     ├── 히트: Speculation-Rules: "/{path}.json"       │
│           │     │     └── 미스: Speculation-Rules: "/default.json"      │
│           │     └── No: 통과 (헤더 미삽입)                               │
│           ▼                                                             │
│       Speculation-Rules 헤더 포함 응답 ──▶ 브라우저                       │
└─────────────────────────────────────────────────────────────────────────┘

Step 1: CloudWatch RUM으로 네비게이션 패턴 수집

CloudWatch RUM은 실제 사용자 세션에서 page_view_event를 캡처합니다. 이 이벤트에는 parentPageId (이전 페이지)가 포함되어 있어 “A 페이지 → B 페이지” 전환 패턴을 추출할 수 있습니다.

Terraform: RUM + Cognito Identity Pool

CloudWatch RUM은 브라우저 클라이언트 인증을 위해 Cognito Identity Pool이 필요합니다:

# rum.tf

locals {
  rum_domain = var.rum_domain != "" ? var.rum_domain : var.domain_name
}

data "aws_caller_identity" "current" {}

resource "aws_cognito_identity_pool" "rum" {
  identity_pool_name               = "buildonaws-rum"
  allow_unauthenticated_identities = true
}

resource "aws_iam_role" "rum_unauthenticated" {
  name = "buildonaws-rum-unauthenticated"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = "cognito-identity.amazonaws.com" }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "cognito-identity.amazonaws.com:aud" = aws_cognito_identity_pool.rum.id
        }
        "ForAnyValue:StringLike" = {
          "cognito-identity.amazonaws.com:amr" = "unauthenticated"
        }
      }
    }]
  })
}

# 최소 권한: PutRumEvents만 허용, 이 app monitor로 스코프 제한
resource "aws_iam_role_policy" "rum_put_events" {
  name = "rum-put-events"
  role = aws_iam_role.rum_unauthenticated.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "rum:PutRumEvents"
      Resource = "arn:aws:rum:us-east-1:${data.aws_caller_identity.current.account_id}:appmonitor/${local.rum_domain}"
    }]
  })
}

resource "aws_cognito_identity_pool_roles_attachment" "rum" {
  identity_pool_id = aws_cognito_identity_pool.rum.id
  roles            = { unauthenticated = aws_iam_role.rum_unauthenticated.arn }
}

resource "aws_rum_app_monitor" "site" {
  name   = local.rum_domain
  domain = local.rum_domain

  app_monitor_configuration {
    allow_cookies       = true
    enable_xray         = false
    session_sample_rate = var.rum_sample_rate
    telemetries         = ["performance"]
    guest_role_arn      = aws_iam_role.rum_unauthenticated.arn
    identity_pool_id    = aws_cognito_identity_pool.rum.id
  }
}

RUM Web Client 스니펫

terraform applyrum_app_monitor_idrum_identity_pool_id output을 받습니다. 사이트 <head>에 추가:

<!-- CloudWatch RUM (production only) -->
<script>
  (function(n,i,v,r,s,c,x,z){
    x=window.AwsRumClient={q:[],n:n,i:i,v:v,r:r,c:c};
    window[n]=function(c,p){x.q.push({c:c,p:p})};
    z=document.createElement('script');z.async=true;z.src=s;
    document.head.insertBefore(z,document.head.getElementsByTagName('script')[0]);
  })(
    'cwr',
    'YOUR_APP_MONITOR_ID',
    '1.0.0',
    'us-east-1',
    'https://client.rum.us-east-1.amazonaws.com/1.x/cwr.js',
    {
      sessionSampleRate: 1,
      identityPoolId: 'us-east-1:YOUR_IDENTITY_POOL_ID',
      endpoint: 'https://dataplane.rum.us-east-1.amazonaws.com',
      telemetries: ['performance'],
      allowCookies: true,
      enableXRay: false
    }
  );
</script>

Hugo 팁: 테마의 head partial을 오버라이드하여 {{ if hugo.IsProduction }} 조건으로 프로덕션에서만 삽입합니다. ID는 hugo.toml[params.rum] 섹션에 정의하여 템플릿에서 참조합니다.


Step 2: 일일 Lambda로 페이지별 Speculation Rules 생성

Lambda 함수가 매일(EventBridge 트리거) 실행되어, 지난 24시간의 RUM page_view_event에서 parentPageIdpageId 전환을 집계하고, 페이지별 규칙 JSON을 S3에 업로드하고, KVS에 매핑을 기록합니다.

로직

조건결과
페이지 이탈 총 횟수 < MIN_TRANSITIONS (기본 10)규칙 생성 안 함 (기본값 사용)
1위 목적지 점유율 >= 50%prerender (eagerness: moderate)
나머지 상위 목적지 (최대 5개)prefetch (eagerness: moderate)

Lambda 함수

"""CloudWatch RUM page_view 데이터에서 페이지별 speculation rules를 생성한다.

awscrt 필수: CloudFront KeyValueStore data plane API(UpdateKeys)는
SigV4A 서명을 사용하며, botocore는 CRT 확장이 있어야 지원한다.
"""
import json
import logging
import os
import time
from collections import Counter, defaultdict
from urllib.parse import urlsplit

import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)

RUM_APP_MONITOR_NAME = os.environ['RUM_APP_MONITOR_NAME']
KVS_ARN = os.environ['KVS_ARN']
S3_BUCKET = os.environ['S3_BUCKET']
S3_PREFIX = os.environ.get('S3_PREFIX', '_speculation')
MIN_TRANSITIONS = int(os.environ.get('MIN_TRANSITIONS', '10'))
CONFIDENCE_THRESHOLD_PRERENDER = float(
    os.environ.get('CONFIDENCE_THRESHOLD_PRERENDER', '0.5'))
MAX_PREFETCH = int(os.environ.get('MAX_PREFETCH', '5'))
EAGERNESS_PRERENDER = os.environ.get('EAGERNESS_PRERENDER', 'moderate')
EAGERNESS_PREFETCH = os.environ.get('EAGERNESS_PREFETCH', 'moderate')
EXCLUDE_PATHS = tuple(
    p for p in os.environ.get('EXCLUDE_PATHS', '').split(',') if p)

PAGE_VIEW_EVENT = 'com.amazon.rum.page_view_event'
KVS_BATCH_SIZE = 100

rum = boto3.client('rum')
s3 = boto3.client('s3')
kvs = boto3.client('cloudfront-keyvaluestore')


def normalize_path(value):
    """URL에서 path만 추출, trailing slash 제거, 빈 값은 '/'."""
    if not value:
        return None
    path = urlsplit(value).path or '/'
    if len(path) > 1:
        path = path.rstrip('/') or '/'
    return path


def collect_transitions():
    """지난 24시간 RUM page_view_event에서 parent→child 전환 추출."""
    after = int((time.time() - 86400) * 1000)
    transitions = defaultdict(Counter)
    next_token = None

    while True:
        kwargs = {
            'Name': RUM_APP_MONITOR_NAME,
            'TimeRange': {'After': after},
            'MaxResults': 100,
        }
        if next_token:
            kwargs['NextToken'] = next_token
        resp = rum.get_app_monitor_data(**kwargs)

        for raw in resp.get('Events', []):
            event = json.loads(raw)
            if event.get('event_type') != PAGE_VIEW_EVENT:
                continue
            details = event.get('event_details', {})
            if isinstance(details, str):
                details = json.loads(details)
            source = normalize_path(details.get('parentPageId'))
            target = normalize_path(details.get('pageId'))
            if source and target and source != target:
                transitions[source][target] += 1

        next_token = resp.get('NextToken')
        if not next_token:
            break
    return transitions


def build_rules(destinations):
    """1위 >= threshold → prerender, 나머지 → prefetch."""
    total = sum(destinations.values())
    if total < MIN_TRANSITIONS:
        return None
    ranked = destinations.most_common()
    prerender, prefetch = [], []
    top_path, top_count = ranked[0]
    if (top_count / total >= CONFIDENCE_THRESHOLD_PRERENDER
            and not any(top_path.startswith(p) for p in EXCLUDE_PATHS)):
        prerender = [top_path]
        ranked = ranked[1:]
    prefetch = [path for path, _ in ranked[:MAX_PREFETCH]]
    rules = {}
    if prefetch:
        rules['prefetch'] = [{'urls': prefetch, 'eagerness': EAGERNESS_PREFETCH}]
    if prerender:
        rules['prerender'] = [{'urls': prerender, 'eagerness': EAGERNESS_PRERENDER}]
    return rules or None


def rules_file_path(page_path):
    if page_path == '/':
        return f'/{S3_PREFIX}/index.json'
    return f'/{S3_PREFIX}{page_path}.json'


def upload_rules(file_path, rules):
    s3.put_object(
        Bucket=S3_BUCKET, Key=file_path.lstrip('/'),
        Body=json.dumps(rules).encode(),
        ContentType='application/speculationrules+json',
        CacheControl='public, max-age=86400',
    )


def update_kvs(entries):
    """ETag 기반 optimistic locking으로 배치 업데이트."""
    etag = kvs.describe_key_value_store(KvsARN=KVS_ARN)['ETag']
    for i in range(0, len(entries), KVS_BATCH_SIZE):
        batch = entries[i:i + KVS_BATCH_SIZE]
        resp = kvs.update_keys(
            KvsARN=KVS_ARN, IfMatch=etag,
            Puts=[{'Key': k, 'Value': v} for k, v in batch],
        )
        etag = resp['ETag']


def handler(event, context):
    transitions = collect_transitions()
    kvs_entries = [('__default__', f'/{S3_PREFIX}/default.json')]
    pages_updated = 0

    for page_path, destinations in transitions.items():
        rules = build_rules(destinations)
        if rules is None:
            continue
        file_path = rules_file_path(page_path)
        upload_rules(file_path, rules)
        kvs_entries.append((page_path, file_path))
        pages_updated += 1

    update_kvs(kvs_entries)
    logger.info(json.dumps({'pages_updated': pages_updated}))
    return {'pages_updated': pages_updated}

중요: CloudFront KeyValueStore data plane API(UpdateKeys)는 SigV4A 서명을 사용합니다. Lambda 배포 패키지에 awscrt가 필요합니다: pip install awscrt -t build/

Terraform: Lambda + EventBridge + KVS

# speculation-rules.tf

resource "aws_cloudfront_key_value_store" "speculation" {
  name = var.speculation_kvs_name
}

resource "aws_lambda_function" "speculation_generator" {
  function_name    = "speculation-rules-generator"
  role             = aws_iam_role.speculation_lambda.arn
  runtime          = "python3.12"
  handler          = "index.handler"
  filename         = data.archive_file.speculation_lambda.output_path
  source_code_hash = data.archive_file.speculation_lambda.output_base64sha256
  timeout          = 300
  memory_size      = 256

  environment {
    variables = {
      RUM_APP_MONITOR_NAME           = aws_rum_app_monitor.site.name
      KVS_ARN                        = aws_cloudfront_key_value_store.speculation.arn
      S3_BUCKET                      = aws_s3_bucket.site.bucket
      S3_PREFIX                      = var.speculation_s3_prefix
      MIN_TRANSITIONS                = var.speculation_min_transitions
      CONFIDENCE_THRESHOLD_PRERENDER = var.speculation_confidence_threshold
      MAX_PREFETCH                   = var.speculation_max_prefetch
      EXCLUDE_PATHS                  = ""
    }
  }
}

# EventBridge: 매일 UTC 03:00
resource "aws_cloudwatch_event_rule" "daily_speculation" {
  name                = "daily-speculation-rules-update"
  schedule_expression = var.speculation_schedule
}

resource "aws_cloudwatch_event_target" "speculation_lambda" {
  rule = aws_cloudwatch_event_rule.daily_speculation.name
  arn  = aws_lambda_function.speculation_generator.arn
}

resource "aws_lambda_permission" "eventbridge" {
  statement_id  = "AllowEventBridgeInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.speculation_generator.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.daily_speculation.arn
}

# 기본 규칙 JSON (KVS에 엔트리가 없는 페이지에 제공)
resource "aws_s3_object" "default_speculation_rules" {
  bucket       = aws_s3_bucket.site.id
  key          = "${var.speculation_s3_prefix}/default.json"
  content      = jsonencode({
    prefetch = [{
      where     = { href_matches = "/*" }
      eagerness = "conservative"
    }]
  })
  content_type  = "application/speculationrules+json"
  cache_control = "public, max-age=86400"
}

Step 3: Edge에서 Speculation Rules 제공

CloudFront Function(viewer-response)이 모든 HTML 응답에서 KVS를 읽어 적절한 Speculation-Rules 헤더를 삽입합니다.

CloudFront Function (viewer-response)

import cf from 'cloudfront';

const kvs = cf.kvs();
const DEFAULT_RULES_PATH = '/_speculation/default.json';

function normalizeUri(uri) {
  // viewer-request의 '/index.html' rewrite를 되돌려 KVS key와 매칭
  if (uri.endsWith('/index.html')) {
    uri = uri.slice(0, uri.length - 11);
  }
  if (uri.length > 1 && uri.endsWith('/')) {
    uri = uri.slice(0, uri.length - 1);
  }
  return uri === '' ? '/' : uri;
}

async function handler(event) {
  const request = event.request;
  const response = event.response;

  // HTML 응답에만 삽입
  const contentType = response.headers['content-type']
    ? response.headers['content-type'].value : '';
  if (contentType.indexOf('text/html') === -1) {
    return response;
  }

  // Markdown 응답(verified bot)에는 삽입하지 않음
  const wantsMarkdown = request.headers['x-wants-markdown'];
  if (wantsMarkdown && wantsMarkdown.value === 'true') {
    return response;
  }

  let rulesPath = DEFAULT_RULES_PATH;
  try {
    rulesPath = await kvs.get(normalizeUri(request.uri));
  } catch (e) {
    // Key 미스 또는 KVS 오류 → 기본 규칙
  }

  // Speculation-Rules 헤더는 quoted URL 문자열이어야 함
  response.headers['speculation-rules'] = { value: '"' + rulesPath + '"' };
  return response;
}

주요 설계 결정:

  • HTML 전용: CSS, JS, 이미지에는 speculation 헤더를 삽입하지 않음 (브라우저가 무시함).
  • Markdown 제외: Verified bot이 받는 Markdown 응답에는 speculation rules 불필요.
  • URI 정규화: viewer-request 함수가 /posts/hello//posts/hello/index.html로 rewrite하므로, viewer-response에서 이를 되돌려 KVS key와 매칭.
  • Quoted URL: Speculation-Rules 헤더 값은 HTTP structured field 스펙에 따라 quoted string이어야 함.

CloudFront Distribution 연결

cloudfront.tf의 default cache behavior에 viewer-response function association 추가:

  default_cache_behavior {
    # ... 기존 viewer-request association ...

    function_association {
      event_type   = "viewer-response"
      function_arn = aws_cloudfront_function.speculation_rules.arn
    }
  }

기본 규칙 전략

RUM 데이터가 없는 페이지는 보수적인 기본값을 받습니다:

{
  "prefetch": [{
    "where": { "href_matches": "/*" },
    "eagerness": "conservative"
  }]
}

eagerness: "conservative"는 pointer-down(mousedown/touchstart)에서만 prefetch를 트리거합니다. 이는 클릭 직전이므로:

  • 낭비되는 fetch가 거의 없음 (사용자가 이미 클릭에 커밋)
  • 여전히 50-100ms의 네트워크 지연을 절약
  • 모든 페이지에 안전하며, 잘못된 prerender 위험 없음

RUM 데이터가 축적되면서 페이지는 점진적으로 데이터 기반 규칙으로 전환됩니다.


비용 분석

구성 요소비용 추정 (일일 10만 페이지뷰 기준)
CloudWatch RUM~$10/월 (10만 세션 × $0.0001)
Lambda (일일 작업)~$0.01/월
EventBridge프리 티어
CloudFront KVS~$0.60/월
S3 (speculation JSONs)~$0.01/월
CloudFront FunctionsCloudFront 과금에 포함
Cognito Identity Pool프리 티어 (50K MAU)
합계~$10.62/월

이미 Core Web Vitals 모니터링용으로 RUM을 운용 중이라면, 추가 비용은 ~$0.62/월.


제한 사항

브라우저 지원

Speculation Rules API는 2026년 6월 기준 Chromium 전용 (Chrome 109+, Edge 109+). Firefox와 Safari는 헤더를 무시합니다 — 에러 없고 성능 저하 없음. Chromium은 전 세계 브라우저의 약 65-70%.

Prerender 안전성

Prerender된 페이지는 JavaScript를 실행합니다. 부작용을 일으키는 페이지는 Lambda의 EXCLUDE_PATHS 환경 변수로 제외합니다. 애플리케이션 코드에서도 방어:

if (document.prerendering) {
  document.addEventListener('prerenderingchange', () => {
    trackPageView(); // 사용자가 실제로 이 페이지에 도착할 때만 실행
  });
} else {
  trackPageView();
}

Cold Start와 awscrt 의존성

배포 후 첫째 날에는 KVS에 __default__만 있어 모든 페이지가 기본 규칙을 받습니다. Lambda 빌드 시 awscrt (SigV4A 서명용)를 반드시 포함해야 합니다:

cd infra/lambda/speculation-rules-generator
pip install -r requirements.txt -t build/
cp index.py build/
cd build && zip -r ../speculation-rules-generator.zip .

효과 측정

배포 후 검증:

# HTML 응답에서 Speculation-Rules 헤더 확인
curl -sI https://your-site.com/posts/example/ | grep -i speculation

# 규칙 JSON 파일 확인 (Lambda 실행 후)
curl -s https://your-site.com/_speculation/posts/example.json | jq .

# 정적 리소스에 헤더 없는지 확인
curl -sI https://your-site.com/css/style.css | grep -i speculation

Chrome DevTools Network 패널에서 speculative load를 “Speculative Load” 배지로 확인할 수 있습니다. CloudWatch RUM에서 LCP/FCP를 before/after 비교합니다.


다음 단계

이 패턴을 확장할 수 있습니다:

  • 실시간 업데이트: 일일 배치를 Kinesis 스트림으로 대체하여 거의 실시간 규칙 업데이트
  • ML 기반 예측: SageMaker로 세션 컨텍스트에서 다음 페이지 확률을 예측하는 모델
  • 세그먼트별 규칙: 모바일 vs 데스크톱 (모바일은 prerender보다 prefetch 선호)

전체 소스 코드: github.com/lakil00/buildonawsinfra/rum.tf, infra/speculation-rules.tf, infra/cf-function/speculation-rules.js, infra/lambda/speculation-rules-generator/index.py


For AI Coding Assistants and Agents

AI coding assistant가 AWS 서비스로 데이터 기반 Speculation Rules를 구현할 때 참고할 내용입니다.

Key Technical Facts

  • Speculation Rules API는 Chrome 109+, Edge 109+에서 지원. Firefox와 Safari는 에러 없이 무시.
  • Speculation-Rules HTTP 응답 헤더 값은 JSON 파일의 quoted URL 경로: Speculation-Rules: "/_speculation/page.json".
  • CloudFront Functions (viewer-response, cloudfront-js-2.0)는 import cf from 'cloudfront'; const kvs = cf.kvs();로 KVS 읽기 가능.
  • KVS 바인딩은 Terraform aws_cloudfront_functionkey_value_store_associations로 설정.
  • CloudFront KeyValueStore data plane API는 SigV4A 서명 — Lambda에 awscrt 패키지 필수.
  • CloudWatch RUM page_view_eventparentPageId (이전 페이지)와 pageId (현재 페이지)를 포함.
  • eagerness 값: immediate (즉시), eager (곧), moderate (hover), conservative (pointer-down).
  • Chrome은 동시 prerender를 페이지당 2개로 제한.

Example Prompts

  • “CloudFront Function(viewer-response)을 만들어서 KVS에서 Speculation-Rules 헤더를 삽입하게 해줘”
  • “CloudWatch RUM page_view_event를 쿼리해서 페이지별 speculation rules를 생성하는 Lambda 작성해줘”
  • “Terraform으로 CloudWatch RUM + Cognito Identity Pool을 구성해줘 (unauthenticated 브라우저 접근용)”
  • “CloudFront KeyValueStore + Lambda + EventBridge 일일 스케줄 Terraform 작성해줘”
  • “Lambda IAM role을 RUM 읽기, KVS 쓰기, S3 prefix 쓰기 최소 권한으로 구성해줘”
  • “prerender된 페이지에서 analytics 이중 카운트를 방지하는 document.prerendering 체크 추가해줘”

Note

2026년 6월 기준, Speculation-Rules 헤더 값은 quoted string이어야 합니다 (HTTP structured field). Chrome DevTools Network 패널에서 “Speculative Load” 배지로 디버깅 가능. S3에서 제공하는 규칙 JSON 파일의 Content-Type은 application/speculationrules+json을 사용합니다.