Spring Cloud Gateway 기반의 마이크로서비스 가나리 배포(Gray Release) 설계 및 구현 전략

1. 마이크로서비스 환경에서의 가나리 배포 개요

가나리 배포(Canary Release 또는 Gray Release)는 새로운 소프트웨어 버전을 전체 사용자에게 공개하기 전, 일부 트래픽만 새로운 버전으로 라우팅하여 안정성을 검증하는 점진적 배포 전략입니다. 특히 시스템 가용성이 중요한 의료 서비스나 금융 시스템에서는 특정 병원, 특정 사용자 또는 특정 지역을 대상으로 기능을 우선 오픈함으로써 잠재적 장애 리스크를 최소화할 수 있습니다.

본 설계안은 Spring Cloud Gateway와 커스텀 LoadBalancer를 활용하여 트래픽 제어권을 확보하고, Redis를 통한 동적 설정 관리 및 TransmittableThreadLocal(TTL)을 이용한 분산 트레이싱 환경에서의 컨텍스트 전파를 핵심으로 합니다.

2. 전체 아키텍처 및 핵심 설계 방향

이 솔루션은 요청의 유입부터 서비스 간 호출까지 전 과정에서 가나리 식별자를 유지하는 것을 목표로 합니다.

  • 트래픽 식별 (Gateway Filter): HTTP 헤더, 사용자 ID, 병원 코드 등을 분석하여 해당 요청이 가나리 대상인지 판별하고 전파용 헤더를 주입합니다.
  • 동적 설정 관리 (Redis): 배포 대상 버전, 가나리 대상 사용자 화이트리스트 등을 Redis에 저장하여 서버 재시작 없이 실시간으로 규칙을 변경합니다.
  • 지능형 부하 분산 (Custom LoadBalancer): 서비스 인스턴스의 메타데이터(Version)를 확인하여 가나리 트래픽은 가나리 인스턴스로, 일반 트래픽은 운영 인스턴스로 라우팅합니다.
  • 컨텍스트 전파 (TTL & Feign Interceptor): 서비스 간 호출 시에도 가나리 식별자가 유실되지 않도록 ThreadLocal을 확장하여 전달합니다.

3. 핵심 구성 요소 구현

3.1 가나리 설정 및 메타데이터 상수

가나리 제어에 필요한 HTTP 헤더와 메타데이터 키를 정의합니다.

public class CanaryConstants {
    public static final String HEADER_VERSION = "X-Service-Version";
    public static final String HEADER_HOSPITAL_CODE = "X-Hospital-Code";
    public static final String HEADER_USER_ID = "X-User-Id";
    
    public static final String META_VERSION = "version";
    public static final String META_ENV_TAG = "env-tag";
    public static final String TAG_CANARY = "canary";
}

3.2 Gateway 필터를 통한 트래픽 마킹

가장 앞단의 게이트웨이에서 요청 속성을 분석하여 가나리 여부를 결정하는 핵심 필터입니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class CanaryRoutingFilter implements GlobalFilter, Ordered {

    private final CanaryConfigService configService;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        HttpHeaders headers = request.getHeaders();
        
        // 1. Redis 기반 설정 로드
        CanaryConfigSpec config = configService.getLatestConfig();
        String targetVersion = config.getReleaseVersion();

        // 2. 가나리 대상 여부 판단 (사용자 ID 또는 병원 코드 기준)
        String userId = headers.getFirst(CanaryConstants.HEADER_USER_ID);
        String hospitalCode = headers.getFirst(CanaryConstants.HEADER_HOSPITAL_CODE);

        if (config.getWhitelistedUsers().contains(userId) || 
            config.getWhitelistedHospitals().contains(hospitalCode)) {
            targetVersion = config.getCanaryVersion();
            log.info("[Canary] Routing User {} to Version {}", userId, targetVersion);
        }

        // 3. 요청 헤더 변조를 통한 하위 서비스 전파
        ServerHttpRequest modifiedRequest = request.mutate()
                .header(CanaryConstants.HEADER_VERSION, targetVersion)
                .build();

        return chain.filter(exchange.mutate().request(modifiedRequest).build());
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

3.3 커스텀 로드밸런서 구현

Spring Cloud LoadBalancer를 확장하여 인스턴스의 메타데이터와 요청 헤더의 버전을 매칭합니다.

public class VersionAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {

    private final String serviceId;
    private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;
    private final AtomicInteger position = new AtomicInteger(new Random().nextInt(1000));

    public VersionAwareLoadBalancer(String serviceId, ObjectProvider<ServiceInstanceListSupplier> supplierProvider) {
        this.serviceId = serviceId;
        this.supplierProvider = supplierProvider;
    }

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        ServiceInstanceListSupplier supplier = supplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new);
        return supplier.get(request).next().map(instances -> processRouting(instances, request));
    }

    private Response<ServiceInstance> processRouting(List<ServiceInstance> instances, Request request) {
        if (instances.isEmpty()) return new EmptyResponse();

        // 요청 컨텍스트에서 목표 버전 추출
        DefaultRequest<RequestDataContext> dr = (DefaultRequest<RequestDataContext>) request;
        String requiredVersion = dr.getContext().getClientRequest().getHeaders().getFirst(CanaryConstants.HEADER_VERSION);

        // 메타데이터 매칭 필터링
        List<ServiceInstance> filtered = instances.stream()
                .filter(inst -> {
                    String instVersion = inst.getMetadata().get(CanaryConstants.META_VERSION);
                    return Objects.equals(instVersion, requiredVersion);
                })
                .collect(Collectors.toList());

        // 매칭되는 가나리 인스턴스가 없으면 운영 버전으로 폴백
        if (filtered.isEmpty()) {
            filtered = instances.stream()
                    .filter(i -> !CanaryConstants.TAG_CANARY.equals(i.getMetadata().get(CanaryConstants.META_ENV_TAG)))
                    .collect(Collectors.toList());
        }

        return getRoundRobinInstance(filtered.isEmpty() ? instances : filtered);
    }

    private Response<ServiceInstance> getRoundRobinInstance(List<ServiceInstance> instances) {
        int pos = Math.abs(this.position.incrementAndGet());
        return new DefaultResponse(instances.get(pos % instances.size()));
    }
}

3.4 서비스 간 컨텍스트 전파 (TTL 활용)

비동기 처리나 쓰레드 풀 환경에서도 가나리 버전 정보를 유지하기 위해 TransmittableThreadLocal을 사용합니다.

public class CanaryContextHolder {
    private static final ThreadLocal<String> CONTEXT = new TransmittableThreadLocal<>();

    public static void setVersion(String version) {
        CONTEXT.set(version);
    }

    public static String getVersion() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

// Feign 요청 시 헤더 주입
@Component
public class CanaryFeignInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        String version = CanaryContextHolder.getVersion();
        if (version != null) {
            template.header(CanaryConstants.HEADER_VERSION, version);
        }
    }
}

4. 가나리 배포 운영 시나리오

  1. 신규 버전 가동: 인스턴스 기동 시 metadata.version=1.2.0, metadata.env-tag=canary 설정을 부여하여 등록합니다.
  2. 대상 지정: 운영 관리 도구를 통해 Redis에 특정 병원 코드 또는 테스트 사용자 ID를 가나리 그룹으로 등록합니다.
  3. 트래픽 검증: 게이트웨이는 등록된 사용자의 요청에만 가나리 헤더를 부여하고, 로드밸런서는 이를 인식하여 신규 버전 인스턴스로 전달합니다.
  4. 전체 적용: 신규 버전의 에러율, 성능 지표가 정상임을 확인하면 Redis의 releaseVersion을 신규 버전으로 업데이트하여 전체 트래픽을 전환합니다.

5. 설계의 장점 및 고려사항

이 방식은 비즈니스 로직의 수정 없이 인프라 계층(Gateway, LoadBalancer)에서 라우팅을 제어하므로 침투성이 매우 낮습니다. 또한 Redis를 통한 중앙 집중식 관리는 긴급 상황 발생 시 즉각적인 롤백(Rollback)을 가능하게 합니다.

다만, 데이터베이스 스키마 변경이 동반되는 배포의 경우 하위 호환성을 반드시 고려해야 하며, 분산 환경에서 로그 추적을 위해 Trace ID와 가나리 버전을 함께 로깅하는 전략이 병행되어야 합니다.

태그: Spring Cloud Gateway Canary Release Load Balancing Redis microservices

8월 7일 04:57에 게시됨