CloudFront CDN 실전 가이드 2편: Spring Boot + Kotlin 오리진을 CloudFront로 (Terraform 실습)

CloudFront CDN 실전 가이드 2편: Spring Boot + Kotlin 오리진을 CloudFront로 (Terraform 실습)


서론

1편에서 CDN과 CloudFront의 동작 원리(캐시 키·Cache-Control·TTL·hit/miss, 무효화 vs 버저닝)를 다뤘다. 이번 2편은 그 개념을 실제 코드로 옮긴다.

목표는 Spring Boot + Kotlin 앱을 오리진으로 두고, 앞단에 CloudFront를 붙이는 것이다. 핵심은 두 가지다.

  1. 오리진이 올바른 Cache-Control을 보내게 만든다 (Kotlin) — 정적은 길게, 동적은 캐시 금지.
  2. CloudFront가 경로별로 다르게 동작하게 만든다 (Terraform) — /static/*는 캐시, /api/*는 통과.

TL;DR

  • 오리진이 캐시 의도를 헤더로 말한다 — Kotlin에서 정적 리소스엔 Cache-Control: max-age=1년, immutable, 동적 API엔 no-store를 명시한다. CloudFront는 이 헤더를 따른다.
  • Behavior를 경로별로 나눈다/static/*는 캐시 정책(CachingOptimized), /api/*와 기본은 무캐시 정책(CachingDisabled)으로 분리한다.
  • 오리진 요청 정책으로 무엇을 넘길지 정한다 — 동적 경로는 쿠키·헤더·쿼리를 오리진에 전달해야 하므로 AllViewerExceptHostHeader를, 정적은 최소만 전달한다.
  • Terraform으로 재현한다aws_cloudfront_distribution에 origin 하나(ALB)와 ordered_cache_behavior로 경로 규칙을 선언한다.
  • X-Cache로 검증한다curl -I로 정적은 두 번째 요청에 Hit, 동적은 항상 Miss임을 확인한다.

1. 아키텍처

이번 실습 구성은 단순하다. 사용자 → CloudFront → ALB → Spring Boot 앱. 앱이 정적 리소스(/static/*)와 동적 API(/api/*)를 모두 제공한다.

flowchart LR
    user["사용자"] --> cf["CloudFront<br/>(엣지 캐시)"]
    cf -->|"/static/* (캐시)"| alb["ALB"]
    cf -->|"/api/* (통과)"| alb
    alb --> app["Spring Boot + Kotlin"]

참고: 정적 자산을 S3에 따로 두는 구성도 흔하다(오리진 2개). 여기서는 “앱 하나가 정적+동적을 모두 제공”하는 가장 단순한 형태로 시작한다. S3 + OAC 구성은 3편에서 보안과 함께 다룬다.


2. Spring Boot + Kotlin 오리진 — 올바른 Cache-Control 보내기

CloudFront는 똑똑하지 않다. 오리진이 보내는 Cache-Control 헤더를 그대로 따를 뿐이다. 그래서 캐싱의 절반은 앱 코드에서 결정된다.

2.1 정적 리소스 — 길게, immutable

WebMvcConfigurer로 정적 경로에 Cache-Control을 건다. 파일명에 해시가 박힌 자산(app.abc123.js)이라는 전제로 immutable + 1년을 준다(1편 4절의 버저닝 전략).

import org.springframework.context.annotation.Configuration
import org.springframework.http.CacheControl
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import java.util.concurrent.TimeUnit

@Configuration
class WebConfig : WebMvcConfigurer {
    override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
        registry.addResourceHandler("/static/**")
            .addResourceLocations("classpath:/static/")
            .setCacheControl(
                CacheControl.maxAge(365, TimeUnit.DAYS)
                    .cachePublic()
                    .immutable(),
            )
    }
}

응답 헤더는 이렇게 나간다:

Cache-Control: max-age=31536000, public, immutable

2.2 동적 API — 캐시 금지 명시

사용자별로 달라지는 응답은 반드시 no-store를 보내야 한다. 안 보내면 CloudFront Behavior의 Default TTL에 따라 엉뚱하게 캐시될 수 있다.

import org.springframework.http.CacheControl
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class ApiController(private val userService: UserService) {

    // 사용자별 응답 → 절대 캐시 금지
    @GetMapping("/api/me")
    fun me(): ResponseEntity<UserDto> =
        ResponseEntity.ok()
            .cacheControl(CacheControl.noStore())
            .body(userService.currentUser())

    // 모두에게 동일하고 자주 안 바뀌는 공개 데이터 → 짧게 캐시 허용
    @GetMapping("/api/products")
    fun products(): ResponseEntity<List<ProductDto>> =
        ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS).cachePublic())
            .body(userService.products())
}

핵심: /api/*라고 전부 무캐시일 필요는 없다. “모두에게 같고 잠깐 묵혀도 되는” 공개 목록은 max-age=60s로 짧게 캐시하면 원본 부하가 크게 준다. “사용자별”인 것만 no-store다.

2.3 ETag로 대역폭 절약 (선택)

ShallowEtagHeaderFilter를 켜면 응답 본문 해시를 ETag로 붙여준다. 클라이언트/CDN이 If-None-Match로 재검증할 때 내용이 같으면 본문 없이 304 Not Modified만 돌아가 대역폭을 아낀다.

import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.filter.ShallowEtagHeaderFilter

@Configuration
class EtagConfig {
    @Bean
    fun shallowEtagFilter(): FilterRegistrationBean<ShallowEtagHeaderFilter> =
        FilterRegistrationBean(ShallowEtagHeaderFilter()).apply {
            addUrlPatterns("/api/*")
        }
}

주의: ETag(재검증)는 no-store(저장 자체 금지)와 목적이 다르다. no-store 응답엔 ETag가 의미 없다. ETag는 “캐시는 하되 바뀌었는지 확인하고 싶은” no-cache/짧은 max-age 응답에 어울린다.

2.4 Cache-Control을 거는 여러 방법 (+ Spring Security 함정)

2.2절의 ResponseEntity.cacheControl()응답별 명시 방식이다. 가장 명확하지만 엔드포인트마다 반복된다. 스프링엔 @CacheControl 같은 어노테이션이 없으므로, 정책을 묶고 싶을 땐 아래 방식을 쓴다.

방법범위언제
ResponseEntity.cacheControl()엔드포인트 1개응답마다 다르게, 명시적으로
WebContentInterceptor경로 패턴별 일괄/api/**는 no-store” 같은 규칙을 한 곳에서
Filter(OncePerRequestFilter)경로·조건별 일괄더 저수준의 세밀한 제어
리소스 핸들러(WebMvcConfigurer)정적 리소스2.1절 방식

경로 규칙으로 한 번에 거는 WebContentInterceptor가 가장 깔끔하다.

@Configuration
class CacheConfig : WebMvcConfigurer {
    override fun addInterceptors(registry: InterceptorRegistry) {
        val interceptor = WebContentInterceptor().apply {
            addCacheMapping(CacheControl.noStore(), "/api/**")                       // 기본: 캐시 금지
            addCacheMapping(
                CacheControl.maxAge(60, TimeUnit.SECONDS).cachePublic(),
                "/api/products",                                                     // 예외: 공개 목록은 짧게
            )
        }
        registry.addInterceptor(interceptor)
    }
}

더 구체적인 패턴(/api/products)이 일반 패턴(/api/**)을 덮어쓴다. 컨트롤러는 비즈니스 로직만 남는다.

주의 — Spring Security의 기본 캐시 헤더: Spring Security를 쓰면 기본적으로 모든 응답에 Cache-Control: no-cache, no-store, max-age=0, must-revalidate를 강제로 붙인다(보안 페이지 캐시 방지용 기본 동작). 그래서 정적·공개 응답까지 캐시가 막혀 “왜 CloudFront가 캐시를 안 하지?”의 흔한 원인이 된다. 정적·공개 경로는 별도 SecurityFilterChain으로 분리하거나 해당 경로의 캐시 헤더를 풀어줘야 한다.


3. CloudFront Behavior 설계

이제 CloudFront가 경로별로 다르게 동작하도록 정책을 고른다. AWS가 제공하는 관리형 정책(managed policy)을 쓰면 직접 만들 필요가 없다.

경로캐시 정책오리진 요청 정책의도
/static/*CachingOptimized(불필요)길게 캐시, 쿠키 무시, 압축
/api/*CachingDisabledAllViewerExceptHostHeader캐시 안 함, 쿠키·헤더·쿼리 전달
기본(*)CachingDisabledAllViewerExceptHostHeader안전한 기본값(통과)
  • CachingOptimized: 쿠키를 캐시 키에서 빼고 Accept-Encoding만 본다 → 적중률이 높다. 정적 자산용.
  • CachingDisabled: 캐시하지 않는다. 동적 경로용.
  • AllViewerExceptHostHeader: 뷰어의 헤더·쿠키·쿼리를 오리진에 모두 전달하되 Host만 제외한다. ALB 같은 커스텀 오리진은 자신의 Host로 라우팅하므로, 뷰어 Host를 넘기면 안 되기에 이 정책을 쓴다.

왜 Host를 빼나: CloudFront가 뷰어의 Host(예: cdn.example.com)를 그대로 ALB에 넘기면, ALB의 호스트 기반 라우팅이나 백엔드 가상호스트가 깨질 수 있다. AllViewerExceptHostHeader는 Host를 오리진 도메인으로 두고 나머지만 전달한다.


4. Terraform으로 구성

ALB·앱·네트워크는 이미 있다고 보고(이전 Terraform 가이드 참조), CloudFront 배포에 집중한다.

4.1 관리형 정책 조회

data "aws_cloudfront_cache_policy" "optimized" {
  name = "Managed-CachingOptimized"
}

data "aws_cloudfront_cache_policy" "disabled" {
  name = "Managed-CachingDisabled"
}

data "aws_cloudfront_origin_request_policy" "all_viewer_except_host" {
  name = "Managed-AllViewerExceptHostHeader"
}

4.2 배포 (Distribution)

resource "aws_cloudfront_distribution" "app" {
  enabled = true
  comment = "spring-boot-kotlin-origin"

  origin {
    domain_name = aws_lb.app.dns_name   # ALB DNS 이름
    origin_id   = "alb-origin"

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"   # CloudFront ↔ ALB 구간도 HTTPS
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  # 기본: 캐시하지 않고 통과 (가장 안전한 기본값)
  default_cache_behavior {
    target_origin_id         = "alb-origin"
    viewer_protocol_policy   = "redirect-to-https"
    allowed_methods          = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods           = ["GET", "HEAD"]
    cache_policy_id          = data.aws_cloudfront_cache_policy.disabled.id
    origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer_except_host.id
  }

  # /static/* : 길게 캐시 + 압축
  ordered_cache_behavior {
    path_pattern           = "/static/*"
    target_origin_id       = "alb-origin"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    cache_policy_id        = data.aws_cloudfront_cache_policy.optimized.id
    compress               = true
  }

  # /api/* : 캐시 안 함 + 뷰어 요청 전달
  ordered_cache_behavior {
    path_pattern             = "/api/*"
    target_origin_id         = "alb-origin"
    viewer_protocol_policy   = "redirect-to-https"
    allowed_methods          = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods           = ["GET", "HEAD"]
    cache_policy_id          = data.aws_cloudfront_cache_policy.disabled.id
    origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer_except_host.id
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true   # 커스텀 도메인·ACM은 3편에서
  }
}

output "cdn_domain" {
  value = aws_cloudfront_distribution.app.domain_name
}

terraform applycdn_domain(예: d123abc.cloudfront.net)으로 접속하면 CloudFront를 통해 앱에 닿는다. 배포 전파에 보통 수 분 걸린다.


5. 검증 — X-Cache로 hit/miss 확인

curl -I로 응답 헤더만 보면 캐시 동작이 드러난다.

정적 자산 — 두 번째부터 Hit

$ curl -sI https://d123abc.cloudfront.net/static/app.abc123.js | grep -iE 'x-cache|cache-control|age'
cache-control: max-age=31536000, public, immutable
x-cache: Miss from cloudfront          # 첫 요청: 엣지에 사본 없음 → 오리진

$ curl -sI https://d123abc.cloudfront.net/static/app.abc123.js | grep -iE 'x-cache|age'
x-cache: Hit from cloudfront           # 두 번째: 엣지 사본 사용
age: 12                                # 캐시에 머문 시간(초)

동적 API — 항상 Miss

$ curl -sI https://d123abc.cloudfront.net/api/me | grep -iE 'x-cache|cache-control'
cache-control: no-store
x-cache: Miss from cloudfront          # no-store라 매번 오리진으로

해석: 정적은 두 번째 요청에서 Hit로 바뀌고 Age가 올라간다 → 캐시 정상. 동적은 no-store라 영원히 Miss(=항상 오리진) → 의도대로. 만약 /api/meHit로 나오면, 오리진이 no-store를 안 보냈거나 Behavior 정책이 잘못 매칭된 것이다.


6. 무효화 실습

HTML처럼 URL을 못 바꾸는 파일을 갱신했다면 무효화한다(1편 4절). 정적 해시 자산은 무효화가 필요 없다.

# index.html만 무효화 (와일드카드도 가능: "/*")
aws cloudfront create-invalidation \
  --distribution-id E123ABCDEF456 \
  --paths "/index.html"

Terraform으로도 트리거할 수 있지만, 무효화는 배포 시점의 일회성 작업이라 보통 CI 파이프라인에서 CLI로 호출한다.

비용 주의: 무효화는 월 1,000경로까지 무료, 이후 경로당 과금이다. "/*" 한 줄도 1경로로 치지만 광범위 무효화는 적중률을 떨어뜨린다. 그래서 정적 자산은 무효화가 아니라 버저닝이 정석이다.


정리

단계한 일
오리진(Kotlin)정적엔 max-age=1년 immutable, 동적엔 no-store를 명시
Behavior 설계/static/*=CachingOptimized, /api/*·기본=CachingDisabled + AllViewerExceptHostHeader
Terraformaws_cloudfront_distribution에 ALB 오리진 + ordered_cache_behavior로 선언
검증X-Cache로 정적=Hit, 동적=Miss 확인
갱신해시 자산은 버저닝, HTML은 무효화

캐싱의 절반은 오리진 헤더, 절반은 CloudFront Behavior다. 둘이 어긋나면(오리진은 캐시하라는데 Behavior가 끄거나, 그 반대) 캐시가 의도대로 안 된다.

다음 3편에서는 운영·심화를 다룬다. Signed URL/쿠키로 사설 콘텐츠를 보호하고, CloudFront Functions와 Lambda@Edge로 엣지에서 로직을 돌리고, 커스텀 도메인(ACM)·S3 OAC·WAF로 보안을 강화하고, 캐시 적중률·CloudWatch·로그로 운영을 모니터링한다.


부록

A. CacheControl 빌더 치트시트 (Spring)

목적Kotlin
1년 불변 정적CacheControl.maxAge(365, DAYS).cachePublic().immutable()
짧은 공개 캐시CacheControl.maxAge(60, SECONDS).cachePublic()
캐시 금지(개인)CacheControl.noStore()
매번 재검증CacheControl.noCache()

B. 트러블슈팅

증상흔한 원인
동적 API가 캐시됨오리진이 no-store 미전송 → Behavior Default TTL 적용
정적이 계속 Miss쿠키가 캐시 키에 포함됨(잘못된 정책) → CachingOptimized 사용
옛날 HTML이 계속 나옴max-age + 무효화 안 함 → 무효화 또는 짧은 TTL
ALB 503/라우팅 깨짐뷰어 Host 전달 → AllViewerExceptHostHeader 사용

C. 참고 자료

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.