폴백 패턴: 마이크로서비스의 우아한 성능 저하 설계

폴백 패턴: 마이크로서비스의 우아한 성능 저하 설계

마이크로서비스 아키텍처에서 서비스는 분산 네트워크 호출의 웹을 형성합니다. 이를 통해 팀은 독립적으로 서비스를 구축하고 확장할 수 있지만 시스템의 전반적인 안정성은 가장 약한 링크만큼만 강력하다는 의미이기도 합니다. 중요한 서비스가 다운되거나 응답하지 않게 되면 전체 애플리케이션을 중단시키는 연쇄 오류가 발생할 수 있습니다.

단일 종속성이 실패하는 순간 일반적인 “500 내부 서버 오류” 또는 빈 페이지를 사용자에게 반환하는 것은 좋지 않은 사용자 경험입니다. 대신, 문제가 발생할 경우 정상적으로 성능이 저하되도록 탄력적인 시스템이 구축됩니다.

여기서 폴백 패턴이 등장합니다. 기본 서비스 호출이 실패할 때 안전한 대체 실행 경로를 정의하면 성능이 저하된 상태에서도 애플리케이션의 기능을 유지할 수 있습니다.

이 가이드에서는 Fallback 패턴, 이를 구현하기 위한 일반적인 전략, 다른 탄력성 패턴과 상호 작용하는 방법, Java(Resilience4j) 및 Go에서 대체 논리를 작성하는 방법을 살펴보겠습니다.


실제 비유: 커피숍 백업 계획

라떼를 사러 동네 커피숍에 들어갔다고 상상해 보세요. 바리스타가 주문을 입력하지만 카드를 탭하려고 하면 결제 단말기에 연결 오류가 깜박입니다. 매장의 인터넷 서비스 제공업체가 중단되었습니다.

커피숍은 즉시 불을 끄고 문을 잠그고 모든 고객을 집으로 돌려보냅니까?

물론 그렇지 않습니다. 대체 전략을 구현합니다.

  • 현금이 있으면 현금으로 결제해도 되는지 물어봅니다.
  • 단골고객이라면 바리스타가 장부에 이름과 주문을 적고 다음 방문시 결제를 요구할 수도 있습니다.
  • 카드 토큰을 로컬에 저장하고 나중에 인터넷이 복구되면 결제를 처리하는 오프라인 카드 리더기를 사용할 수도 있습니다.
캐시/백업 데이터를 검색하기 위해 기본 서비스 오류 및 대체 핸들러로의 라우팅을 보여주는 대체 패턴 아키텍처 다이어그램

소프트웨어 설계에서:

  • 커피 주문은 고객의 요청사항입니다.
  • 카드 단말기는 기본 다운스트림 서비스(예: 결제 게이트웨이 API)입니다.
  • 인터넷 중단은 네트워크 시간 초과 또는 서비스 중단을 의미합니다.
  • Ledger / Offline Reader는 대체 실행 경로입니다.

일반적인 대체 전략

비즈니스 논리와 실패한 서비스의 중요도에 따라 여러 대체 전략 중에서 선택할 수 있습니다.

1. 정적 기본값

가장 간단한 전략은 안전하고 미리 구성된 정적 값을 반환하는 것입니다. 이는 공백 또는 기본 데이터 표시가 허용되는 중요하지 않은 기능에 매우 효과적입니다.

  • : 프로필 개인화 서비스가 실패하는 경우 기본 아바타 이미지와 일반 인사말을 반환합니다.
  • : 추천 서비스가 실패하면 오류를 발생시키는 대신 빈 목록이나 하드코딩된 범용 베스트셀러 목록을 반환합니다.

2. 캐시된 응답(재검증하는 동안 오래된 응답)

라이브 데이터를 사용할 수 없는 경우 로컬 캐시나 Redis와 같은 빠른 분산 메모리 저장소의 읽기 전용 오래된 데이터로 대체할 수 있습니다.

  • : 제품 재고 서비스가 중단된 경우 데이터가 완전히 최신이 아닐 수 있음을 나타내는 미묘한 UI 메시지와 함께 5분 전에 캐시된 재고 수량을 표시합니다.
  • : 사용자 설정 서비스가 실패하는 경우 로그인 흐름을 차단하는 대신 캐시된 사용자 프로필을 로드합니다.

3. 대체 서비스(다중 제공자)

성공해야 하는 중요한 작업을 실행할 때 보조 서비스 공급자를 백업으로 구성할 수 있습니다.

  • : 기본 결제 게이트웨이(예: Stripe)가 5xx 오류를 반환하거나 시간 초과되는 경우 대체 메커니즘은 즉시 거래 요청을 보조 게이트웨이(예: PayPal 또는 Adyen)로 리디렉션합니다.
  • : 지오코딩 API가 실패하면 보조 매핑 공급자로 대체합니다.

4. 나중을 위한 큐(비동기 버퍼)

즉각적인 동기 처리가 필요하지 않은 쓰기 작업의 경우 폴백은 나중에 다시 시도하기 위해 로컬 큐 또는 데이터베이스에 요청을 버퍼링할 수 있습니다.

  • : 이메일 알림 서비스가 다운된 경우 알림 페이로드를 배달 못한 편지 대기열이나 로컬 데이터베이스 테이블에 기록합니다. 알림 서비스가 다시 정상 상태가 되면 백그라운드 작업자가 이 대기열에서 이메일을 읽고 이메일을 전달합니다.

탄력성 트리오: 재시도, 회로 차단기, 대체

탄력성이 뛰어난 아키텍처를 구축하려면 대체 패턴을 재시도회로 차단기 패턴과 결합해야 합니다. 이들은 3단계 방어선을 형성합니다.

패턴 역할 액션 시나리오
재시도 패턴 일시적인 일시적인 결함을 해결합니다. 짧은 지연(백오프 + 지터) 후에 요청을 반복합니다. 짧은 네트워크 패킷 삭제, 소켓 재설정.
회로 차단기 자원 고갈을 방지합니다. 실패한 서비스에 대한 호출을 즉시 차단하기 위해 트립이 열립니다(빠른 실패). 지속적인 서비스 가동 중지 시간, 데이터베이스 교착 상태.
대체 패턴 사용자 경험을 보존합니다. 기본 통화가 실패하거나 차단되면 대체 작업을 실행합니다. 재시도 횟수가 소진되거나 회로 차단기가 열린 경우.

그들이 함께 일하는 방식

  1. 들어오는 요청이 서비스 게이트웨이에 도달합니다.
  2. 요청은 회로 차단기를 통과합니다.
  3. 회로 차단기가 닫히면 요청은 Retry 래퍼로 이동하여 실제 호출을 수행합니다.
  4. 일시적인 오류가 발생하면 재시도 메커니즘이 호출을 다시 시도합니다.
  5. 모든 재시도가 실패하거나 회로 차단기가 이미 열려 있는 경우(리소스 절약이 빠르게 실패하는 경우) 폴백 핸들러는 오류를 차단하고 저하/캐시된 응답을 반환합니다.

코드 구현

Java와 Go 모두에서 대체 논리를 구현하는 방법을 살펴보겠습니다.

1. 자바(Resilience4j)

Java에서 Resilience4j는 내결함성에 대한 업계 표준입니다. 주석을 사용하여 선언적으로 또는 프로그래밍 방식으로 대체 메서드를 정의할 수 있습니다.

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;

@Service
public class ProductService {

    private final InventoryClient inventoryClient;
    private final CacheManager cacheManager;

    public ProductService(InventoryClient inventoryClient, CacheManager cacheManager) {
        this.inventoryClient = inventoryClient;
        this.cacheManager = cacheManager;
    }

    // Bind this method to a circuit breaker. If it fails or is open, route to fallback
    @CircuitBreaker(name = "inventoryService", fallbackMethod = "getInventoryFallback")
    public List<String> getProductInventory(String category) {
        return inventoryClient.fetchStockByCategory(category);
    }

    // Fallback method must have the same return type and accept the same parameters,
    // plus a Throwable parameter containing the error that triggered it
    public List<String> getInventoryFallback(String category, Throwable throwable) {
        System.err.println("Primary inventory service failed: " + throwable.getMessage());
        
        // Attempt to fetch from local cache (Strategy 2)
        List<String> cachedStock = cacheManager.get("inventory:" + category, List.class);
        if (cachedStock != null) {
            return cachedStock;
        }

        // Return static empty list if cache is empty (Strategy 1)
        return Collections.emptyList();
    }
}

2. 고(고랭)

Go에서는 기능적 프로그래밍 패턴을 사용하여 깔끔한 fallback 데코레이터를 작성할 수 있으며 이를 통해 모든 실행 핸들러를 보조 핸들러로 래핑할 수 있습니다.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// Request represents a simple payload
type Request struct {
	UserID string
}

// Response represents the returned data
type Response struct {
	Data   string
	Status string
}

// ServiceFunc represents our primary function signature
type ServiceFunc func(ctx context.Context, req Request) (Response, error)

// WithFallback wraps a service function with fallback logic
func WithFallback(primary ServiceFunc, fallback ServiceFunc) ServiceFunc {
	return func(ctx context.Context, req Request) (Response, error) {
		res, err := primary(ctx, req)
		if err != nil {
			fmt.Printf("[Warning] Primary call failed: %v. Running fallback...\n", err)
			return fallback(ctx, req)
		}
		return res, nil
	}
}

func main() {
	// 1. Define primary service that occasionally fails
	primaryService := func(ctx context.Context, req Request) (Response, error) {
		return Response{}, errors.New("database connection timeout (504)")
	}

	// 2. Define fallback service that retrieves cached data
	fallbackService := func(ctx context.Context, req Request) (Response, error) {
		// Simulating cached read
		return Response{
			Data:   fmt.Sprintf("Stale cached profile data for user %s", req.UserID),
			Status: "DEGRADED (CACHED)",
		}, nil
	}

	// 3. Wrap them together
	resilientService := WithFallback(primaryService, fallbackService)

	// 4. Execute
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	req := Request{UserID: "user_992"}
	response, err := resilientService(ctx, req)
	if err != nil {
		fmt.Printf("Operation completely failed: %v\n", err)
	} else {
		fmt.Printf("Response Received:\n - Status: %s\n - Data: %s\n", response.Status, response.Data)
	}
}

대체 패턴 모범 사례

  1. 폴백 경로 종속성 없는 유지: 폴백 논리는 방금 실패한 동일한 다운스트림 시스템이나 인프라에 의존해서는 안 됩니다. 데이터베이스가 다운된 경우 동일한 서버의 다른 데이터베이스 쿼리로 대체하는 것도 실패할 가능성이 높습니다.
  2. 빠른 실행: 대체 논리는 빠르게 실행되어야 합니다. 대체 경로에서 복잡한 계산이나 느린 중첩 호출을 피하세요. 목표는 가능한 한 빨리 사용자에게 응답을 반환하는 것입니다.
  3. 경고 및 모니터링: 항상 대체 실행을 기록하고 원격 측정 카운터를 증가시킵니다. 시스템이 대체 경로를 실행 중인 경우 이는 시스템이 저하된 상태에서 작동하고 있음을 의미합니다. 대체 비율이 정상 임계값을 초과하는 경우를 알려면 경고가 필요합니다.
  4. 탄력적인 UI 만들기: 프런트엔드 엔지니어와 긴밀히 협력하여 UI가 클라이언트측 레이아웃을 손상시키지 않고 대체 페이로드(예: 빈 컬렉션 또는 부분 상태)를 허용하도록 설계되었는지 확인합니다.

결론

폴백 패턴은 마이크로서비스 안정성을 위한 궁극적인 보험 정책입니다. 오류를 예측하고 우아한 폴백 논리를 제공함으로써 심각한 충돌을 미묘하고 관리 가능한 중단으로 전환할 수 있습니다. 이 패턴을 재시도회로 차단기와 결합하면 애플리케이션이 사용자에게 계속 서비스를 제공하면서 주요 외부 중단을 견딜 수 있습니다.