Edge와 Serverless 하이브리드: 사용자 가까이에서 코드 실행하기

1. 개요

서버에서 멀리 있는 사용자는 코드가 아무리 빠르게 실행되어도 높은 네트워크 지연을 겪습니다. edge와 serverless 하이브리드 모델이 이 문제를 해결합니다: 가벼운 로직은 사용자 근처의 여러 포인트에서 실행하고, 무거운 부분은 중앙 region의 여러 serverless 함수에서 실행합니다.

이 글에서는 개념, 이 모델이 주목받는 이유, 그리고 두 레이어 간에 작업을 나누는 방법을 설명합니다. Spring Boot 3.x, Spring Cloud Function, 그리고 간단한 edge function을 포함한 구체적인 예제를 볼 수 있습니다. 글의 끝에서는 이 모델을 사용하지 말아야 하는 경우들을 다룹니다.

2. Edge, Serverless 및 하이브리드 모델

2.1. Edge란 무엇인가

Edge는 여러 지역에 분산된 인프라로, 보통 CDN 네트워크와 함께합니다. 과거에는 edge가 주로 정적 파일을 캐시하는 데 사용되었습니다. 요즘에는 많은 플랫폼이 edge에서 직접 코드를 실행할 수 있게 하는데, 이를 edge function이라고 합니다.

Edge runtime은 보통 엄격한 제약이 있습니다:

  • 각 request의 실행 시간이 짧습니다.
  • 메모리가 작고 완전한 파일 시스템이 없습니다.
  • 보통 JavaScript/TypeScript 또는 WebAssembly를 지원하고, 완전한 JVM을 실행하는 경우는 드뭅니다.
  • 기존 데이터베이스에 대한 오래된 연결을 유지하기에는 적합하지 않습니다.

2.2. Serverless란 무엇인가

Serverless (Function-as-a-Service 또는 자동 확장 컨테이너)를 사용하면 서버를 관리하지 않고도 코드를 배포할 수 있습니다. 플랫폼이 request 수에 따라 자동으로 확장하며, 0으로 확장하는 것도 가능합니다. 대신 새로운 instance가 시작될 때 cold start를 감수해야 합니다.

Edge와 비교하면, serverless는 보통 더 적은 region에서 실행됩니다. 대신 더 많은 리소스가 있고 완전한 Java를 지원하며 데이터베이스에 더 가깝습니다.

2.3. 왜 하이브리드 모델이 필요한가

각 레이어마다 고유한 장점이 있습니다:

┌──────────┐    가까움   ┌──────────────┐   더 멀다   ┌────────────────────┐    ┌──────────┐
│ 사용자   │ ────────▶ │ Edge         │ ─────────▶ │ Serverless origin  │ ─▶ │ 데이터베이스│
│          │ ◀──────── │ (캐시, 라우팅,│ ◀───────── │ (Spring Boot /     │ ◀─ │          │
└──────────┘           │  인증)        │            │  Spring Cloud Func)│    └──────────┘
  • Edge는 빠르고 중앙 데이터에 의존하지 않는 작업을 처리합니다: 캐시, 언어별 리다이렉트, 토큰 기본 확인, 봇 차단, A/B 테스팅.
  • Serverless origin은 데이터 일관성이 필요한 비즈니스 로직을 처리합니다: 가격 계산, 주문 생성, 결제.

일반 원칙: 자주 읽고 거의 변경되지 않는 부분은 edge로, 쓰기와 일관성이 필요한 부분은 origin에 유지하세요.

3. 이 모델이 주목받는 이유

주요 이유들은 다음과 같습니다:

  1. 인지 지연 감소. 많은 request가 edge에서 즉시 응답되고 origin에 도달할 필요가 없습니다. 중앙 region에서 먼 사용자가 가장 큰 이점을 얻습니다.
  2. Origin 부하 및 비용 감소. 각 request가 edge에 캐시되면 serverless 함수가 한 번 덜 실행됩니다.
  3. 위치 기반 개인화. Edge는 사용자의 위치를 알고 있으므로 적절한 언어, 통화 또는 콘텐츠 버전을 선택할 수 있습니다.
  4. 지역별 데이터 저장 요구사항. Edge는 request를 올바른 법적 지역의 origin으로 라우팅할 수 있습니다.
  5. Java 생태계가 더 잘 적응했습니다. GraalVM을 사용한 native image와 빠른 시작 기법이 cold start를 줄여서 Java가 serverless에 더 적합해졌습니다.

4. Origin: Spring Boot가 edge에 캐시 방법을 “가르치기”

하이브리드 모델은 origin에서 시작합니다. Edge는 origin이 명확한 Cache-Control 헤더를 반환할 때만 제대로 캐시합니다. 상품 카탈로그 API를 살펴봅시다:

public record Product(String id, String name, BigDecimal price) {}

@Component
public class ProductCatalog {
    private final Map<String, Product> products = Map.of(
        "p-1", new Product("p-1", "Nón lá Huế", new BigDecimal("150000")),
        "p-2", new Product("p-2", "Áo bà ba", new BigDecimal("320000"))
    );

    public Optional<Product> findById(String id) {
        return Optional.ofNullable(products.get(id));
    }
}

Controller는 데이터 유형에 따라 다른 캐시 헤더를 반환합니다:

@RestController
@RequestMapping("/api")
public class ProductController {

    private final ProductCatalog catalog;

    public ProductController(ProductCatalog catalog) {
        this.catalog = catalog;
    }

    @GetMapping("/products/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable String id) {
        return catalog.findById(id)
            .map(product -> ResponseEntity.ok()
                .cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))
                    .cachePublic()
                    .sMaxAge(Duration.ofMinutes(5))
                    .staleWhileRevalidate(Duration.ofMinutes(1)))
                .body(product))
            .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/cart")
    public ResponseEntity<List<String>> getCart(@RequestHeader("Authorization") String token) {
        return ResponseEntity.ok()
            .cacheControl(CacheControl.noStore())
            .body(List.of("p-1"));
    }
}

각 지시사항의 의미:

  • max-age=30: 브라우저는 30초 동안 캐시된 버전을 사용할 수 있습니다.
  • public: 공용 캐시 (edge, CDN)가 response를 저장할 수 있습니다.
  • s-maxage=300: 공용 캐시만 5분 동안 캐시된 버전을 유지합니다. Edge는 사용자 기계의 캐시보다 더 오래 유지합니다. 왜냐하면 edge에서는 캐시를 능동적으로 삭제할 수 있지만 사용자 기계의 캐시는 삭제할 수 없기 때문입니다.
  • stale-while-revalidate=60: 만료 후 edge는 최대 60초 동안 이전 버전을 계속 제공하고 백그라운드에서 새로 고칩니다. 사용자는 origin을 기다릴 필요가 없습니다.
  • 장바구니는 개인 데이터이므로 캐시 계층이 저장하지 않도록 no-store를 사용합니다.

curl로 확인해봅시다:

curl -i http://localhost:8080/api/products/p-1
HTTP/1.1 200
Cache-Control: max-age=30, public, s-maxage=300, stale-while-revalidate=60
Content-Type: application/json

{"id":"p-1","name":"Nón lá Huế","price":150000}

5. Serverless의 비즈니스 로직 (Spring Cloud Function 사용)

중앙 데이터가 필요한 계산 부분은 serverless 함수에서 실행되어야 합니다. Spring Cloud Function을 사용하면 순수한 Function 형태로 로직을 작성한 후 로컬 HTTP 또는 serverless 플랫폼의 adapter를 통해 같은 코드를 실행할 수 있습니다.

의존성 추가 (Spring Boot와 호환되는 버전의 spring-cloud-dependencies BOM 선언):

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-function-web</artifactId>
    </dependency>
</dependencies>

국가별로 주문 가격을 계산하는 함수를 작성합시다:

public record OrderRequest(String country, BigDecimal subtotal) {}

public record OrderQuote(BigDecimal subtotal, BigDecimal tax, BigDecimal total) {}

@Configuration
public class PricingFunctions {

    private static final Map<String, BigDecimal> TAX_RATES = Map.of(
        "VN", new BigDecimal("0.10"),
        "SG", new BigDecimal("0.09")
    );

    @Bean
    public Function<OrderRequest, OrderQuote> quoteOrder() {
        return request -> {
            BigDecimal rate = TAX_RATES.getOrDefault(request.country(), BigDecimal.ZERO);
            BigDecimal tax = request.subtotal().multiply(rate).setScale(0, RoundingMode.HALF_UP);
            return new OrderQuote(request.subtotal(), tax, request.subtotal().add(tax));
        };
    }
}

로컬에서 실행할 때 spring-cloud-function-web은 quoteOrder bean을 자동으로 POST /quoteOrder 엔드포인트에 노출합니다:

curl -s -X POST http://localhost:8080/quoteOrder \
  -H "Content-Type: application/json" \
  -d '{"country":"VN","subtotal":150000}'
{"subtotal":150000,"tax":15000,"total":165000}

로직은 순수한 Function 내부에 있으므로 HTTP나 플랫폼에 의존하지 않습니다. 덕분에 비즈니스 로직을 수정하지 않고도 serverless 공급자 간에 전환할 수 있습니다.

참고: Java를 serverless에서 사용할 때 cold start는 실제 문제입니다. 함수를 간단하게 유지하고 불필요한 bean을 최소화하며 플랫폼이 지원하는 경우 native image를 고려해봅시다.

6. Edge Function: 라우팅, 조기 차단 및 전달

Edge 레이어는 보통 JavaScript를 실행합니다. 많은 edge runtime은 표준 웹 Fetch API 모델을 따릅니다: Request를 받고 Response를 반환합니다. 아래 예제는 개념을 보여줍니다. 위치 헤더의 이름과 배포 방식은 사용하는 플랫폼에 따라 다릅니다.

const ORIGIN = "https://origin.nonla.example";

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const country = request.headers.get("x-edge-country") ?? "VN";

    // 1. 홈페이지를 언어에 따라 리다이렉트, origin 호출 필요 없음
    if (url.pathname === "/") {
      const lang = country === "VN" ? "vi" : "en";
      return Response.redirect(`${url.origin}/${lang}/`, 302);
    }

    // 2. 토큰 없는 장바구니 request를 조기에 차단
    if (url.pathname.startsWith("/api/cart") && !request.headers.has("Authorization")) {
      return new Response("Unauthorized", { status: 401 });
    }

    // 3. Origin으로 전달, 국가 정보 포함
    const headers = new Headers(request.headers);
    headers.set("X-Edge-Country", country);
    return fetch(new Request(ORIGIN + url.pathname + url.search, {
      method: request.method,
      headers,
      body: request.body,
    }));
  },
};

이 edge function은 세 가지를 수행합니다:

  1. 국가별 리다이렉트를 edge에서 즉시 수행합니다. 홈페이지에 대한 request는 절대 origin에 도달하지 않습니다.
  2. 토큰 누락 요청을 조기에 차단합니다. Origin은 확실히 거부될 request에 대해 serverless 함수를 실행할 필요가 없습니다. 완전한 토큰 인증은 여전히 origin에서 수행됩니다.
  3. 나머지 request를 전달합니다. Origin의 response에는 섹션 4의 Cache-Control 헤더가 있으므로, edge의 캐시 레이어가 어떤 response를 저장할지, 얼마나 오래 저장할지 알 수 있습니다.

7. 주요 동작 테스트

Origin과 edge 간의 계약은 캐시 헤더입니다. 따라서 이 헤더가 의도하지 않게 변경되지 않도록 test를 작성해봅시다:

@WebMvcTest(ProductController.class)
@Import(ProductCatalog.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void productResponseIsCacheableAtEdge() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/products/p-1")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getHeader("Cache-Control"))
            .contains("public", "s-maxage=300", "stale-while-revalidate=60");
    }

    @Test
    void cartResponseIsNeverStored() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/cart")
                .header("Authorization", "Bearer demo"))
            .andReturn();

        assertThat(result.getResponse().getHeader("Cache-Control")).isEqualTo("no-store");
    }
}

Serverless 함수는 단순한 Function이므로 일반적인 Java 코드처럼 test합니다:

class PricingFunctionsTest {

    private final Function<OrderRequest, OrderQuote> quoteOrder =
        new PricingFunctions().quoteOrder();

    @Test
    void addsVietnameseTax() {
        OrderQuote quote = quoteOrder.apply(new OrderRequest("VN", new BigDecimal("150000")));

        assertThat(quote.tax()).isEqualByComparingTo("15000");
        assertThat(quote.total()).isEqualByComparingTo("165000");
    }

    @Test
    void unknownCountryHasNoTax() {
        OrderQuote quote = quoteOrder.apply(new OrderRequest("XX", new BigDecimal("100")));

        assertThat(quote.total()).isEqualByComparingTo("100");
    }
}

8. 일반적인 실수 및 사용하지 말아야 하는 경우

8.1. 일반적인 실수

  • 개인 데이터를 잘못 캐시. no-store 또는 private를 사용자 데이터가 포함된 response에서 빼먹으면, edge가 한 사용자의 장바구니를 다른 사용자에게 제공할 수 있습니다. 이는 가장 심각한 오류입니다.
  • 캐시 키가 개인화 방식과 맞지 않음. Origin이 X-Edge-Country 헤더에 따라 다른 콘텐츠를 반환하지만 edge의 캐시 키가 해당 헤더를 계산하지 않으면, 모든 사용자가 같은 버전을 받게 됩니다. 적절한 Vary 헤더를 추가하거나 edge에서 캐시 키를 구성해봅시다.
  • Edge에서 직접 데이터베이스에 접근. 각 request는 많은 edge 포인트에서 한 region의 데이터베이스로 이동합니다. 지연이 감소하지 않고 오히려 증가하며 연결이 고갈될 위험이 있습니다.
  • 비즈니스 로직 중복. 가격 계산 규칙을 JavaScript(edge)로 한 번, Java(origin)로 한 번 작성하면 시간이 지나면서 점점 달라집니다. Edge는 인프라 작업만 수행해야 합니다: 라우팅, 캐시, 필터링.
  • Edge 검사를 절대 신뢰. Edge에서의 토큰 확인은 조기에 걸러내기 위한 것입니다. Origin은 여전히 완전히 인증하고 권한을 확인해야 합니다.

8.2. 사용하지 말아야 하는 경우

참고: 하이브리드 모델은 디버그, 배포 및 모니터링할 레이어를 증가시킵니다. 측정 가능한 이점이 명확할 때만 사용해봅시다.

  • 사용자가 한 지역에 집중되어 있음. Origin region 근처에 있으면 edge compute는 거의 이점을 제공하지 않습니다. 정적 CDN 캐시로 충분한 경우가 많습니다.
  • 애플리케이션이 주로 데이터 작성 또는 강한 일관성이 필요한 경우 (예: 결제 시스템 또는 재고 관리). 대부분의 request가 여전히 origin으로 이동해야 합니다.
  • 장시간 또는 CPU 집약적인 작업 (예: 비디오 처리 또는 대규모 보고서). 이들은 edge의 제약에 적합하지 않으며 serverless 함수보다는 지속적으로 실행하는 서비스가 더 적합합니다.
  • 트래픽이 지속적이고 높음. 지속적으로 실행되는 서비스가 serverless보다 단순하고 저렴할 수 있습니다.
  • 팀이 작고 observability가 부족함. Edge, serverless, 데이터베이스에 걸친 오류는 완전한 tracing이 없으면 추적이 매우 어렵습니다.

9. 결론

Edge와 serverless의 하이브리드 모델은 기존 아키텍처를 대체하지 않습니다. 거리에 따라 작업을 나누는 방법입니다: 가볍고 자주 읽으며 중앙 데이터가 필요 없는 작업은 edge에서 실행하고, 일관성이 필요한 비즈니스 로직은 데이터베이스 근처의 serverless origin에서 실행합니다.

이 글에서는 origin Spring Boot가 Cache-Control에서 s-maxage와 stale-while-revalidate를 사용하여 edge의 캐시를 제어하는 방법을 배웠습니다. 또한 Spring Cloud Function으로 Function으로서 비즈니스 로직을 작성하여 테스트와 플랫폼 전환이 쉽도록 했습니다. 그리고 edge function을 사용하여 리다이렉트, 조기 차단, request 전달을 수행했습니다.

가장 간단한 단계부터 시작해봅시다: 각 엔드포인트에 올바른 캐시 헤더를 설정하고 효과를 측정하세요. 자신의 시스템 데이터가 그럴 가치가 있음을 보여줄 때만 로직을 edge로 이동시키세요.

edge computingserverlessspring bootspring cloud function아키텍처