런타임 어댑터 핫스와핑과 포트 & 어댑터 — 알리스테어 코크번이 문서화하지 않은 패턴

발행: (2026년 2월 13일 오전 06:07 GMT+9)
14 분 소요
원문: Dev.to

Source: Dev.to

위에 제공된 링크의 전체 텍스트를 알려주시면, 해당 내용을 한국어로 번역해 드리겠습니다. 코드를 포함한 마크다운 형식과 기술 용어는 그대로 유지하면서 번역해 드립니다.

Alistair Cockburn가 2005년에 Hexagonal Architecture를 설명했을 때, 그는 어댑터 교체 가능성을 패턴의 핵심 속성으로 문서화했습니다. Chapter 5에서 그는 다음과 같이 씁니다:

“각 포트마다 여러 어댑터가 있을 수 있으며, 다양한 기술이 해당 포트에 연결될 수 있다.”

원래 패턴은 다음과 같은 부분을 남겨 둡니다: 런타임에 어댑터를 교체해야 하고, 분산 시스템 전반에 걸쳐 장애에 대응하며, 다른 인스턴스가 동일한 문제를 겪기 전에 어떻게 해야 하는가?

이 글에서는 긴급 어댑터 핫스와핑을 다음을 이용해 구체적으로 구현하는 방법을 제시합니다:

  • Spring Boot 4
  • Spring Cloud Bus
  • Resilience4j

하나의 서비스 인스턴스가 실패한 어댑터를 감지하면, 메시지 버스를 통해 그 실패를 브로드캐스트합니다. 다른 모든 인스턴스는 자신이 타임아웃에 도달하기 전에 폴백 어댑터로 전환합니다.

Cockburn의 LinkedIn 반응:

“멋지네요! 장시간 실행 시스템을 위한 라이브 스와핑을 문서화하는 데는 성공했지만, 긴급 핫스와핑에 대해서는 감히 생각해보지 못했습니다 — 감사합니다!”

문제

전형적인 Ports & Adapters 아키텍처에서 도메인 서비스는 어댑터를 통해 업스트림 API와 통신합니다. 해당 API가 타임아웃되기 시작하면 서비스의 각 인스턴스가 독립적으로 실패를 감지하고 자체 타임아웃 창을 소진합니다.

  • 10개의 인스턴스3초 타임아웃을 가질 경우 → 30초의 누적된 성능 저하 경험(최소).
  • 실제로는 재시도, 스레드 풀 포화, 연쇄적인 실패 등이 영향을 훨씬 더 악화시킵니다.

고전적인 서킷‑브레이커 패턴(Hystrix, Resilience4j)은 인스턴스별로 동작합니다: 각 서비스는 실패를 감지한 뒤 회로를 차단합니다. 조정이 없으며—인스턴스 7은 인스턴스 1이 이미 2초 전에 타임아웃에 도달했다는 사실을 알지 못합니다.

해결책: 브로드캐스트 기반 어댑터 전환

핵심 통찰: 실패를 감지한 첫 번째 인스턴스가 다른 모든 인스턴스에 경고해야 합니다.

Instance 1: timeout detected → broadcast "switch to fallback"
Instance 2: receives event → switches adapter (avoids timeout)
Instance 3: receives event → switches adapter (avoids timeout)
...
Instance N: receives event → switches adapter (avoids timeout)
  • 이는 인프라에 적용된 최종 일관성입니다.
  • 어댑터 상태가 클러스터 전체에 비동기적으로 전파됩니다.
  • 모든 인스턴스가 첫 번째 감지 후 몇 밀리초 이내에 동일한 폴백 어댑터로 수렴합니다.

Source:

구현

전체 소스 코드

(레포지토리 링크는 기사 뒤에 제공됩니다.)

포트

public interface AnimalPort {
    String getAnimal();
    String name();
}

어댑터 레지스트리

레지스트리는 사용 가능한 어댑터들을 보관하고, 활성 어댑터를 가리키는 AtomicReference 를 유지합니다. 레퍼런스를 교체하는 작업은 단일 lock‑free, 스레드‑안전 연산입니다.

@Component
public class AdapterConfig {

    private final Map<String, AnimalPort> adapters;
    private final AtomicReference<AnimalPort> activeAdapter;

    public AdapterConfig(
            @Qualifier("primaryAdapter")   final AnimalPort primary,
            @Qualifier("fallbackAdapter") final AnimalPort fallback) {

        this.adapters = Map.of(
                "primary",  primary,
                "fallback", fallback
        );
        this.activeAdapter = new AtomicReference<>(primary);
    }

    /** 현재 사용 중인 어댑터를 반환합니다. */
    public AnimalPort getActiveAdapter() {
        return activeAdapter.get();
    }

    /**
     * 활성 어댑터를 전환합니다.
     *
     * @param adapterName {@code adapters} 에 있는 키 중 하나
     * @return 어댑터가 실제로 변경되었으면 {@code true}
     */
    public boolean switchTo(final String adapterName) {
        final AnimalPort target   = adapters.get(adapterName);
        final AnimalPort previous = activeAdapter.getAndSet(target);
        return !previous.name().equals(adapterName);
    }
}

Noteold 어댑터로 이미 요청을 실행 중인 스레드들은 정상적으로 종료됩니다; 새로운 요청은 즉시 새로운 어댑터를 사용합니다. synchronized 블록은 필요하지 않습니다.

타임아웃 감지 + 브로드캐스트

서비스 레이어는 각 어댑터 호출을 Resilience4j TimeLimiter 로 감싸습니다. 호출이 타임아웃되면, 최초로 실패를 감지한 인스턴스가 커스텀 Spring Cloud Bus 이벤트를 발행하여 모든 노드가 fallback 어댑터로 전환하도록 합니다.

@Service
public class AnimalService {

    private final AdapterConfig               adapterConfig;
    private final ApplicationEventPublisher   publisher;
    private final BusProperties               busProperties;
    private final TimeLimiter                 timeLimiter;
    private final ExecutorService             executor;

    public AnimalService(final AdapterConfig adapterConfig,
                         final ApplicationEventPublisher publisher,
                         final BusProperties busProperties) {

        this.adapterConfig = adapterConfig;
        this.publisher     = publisher;
        this.busProperties = busProperties;

        this.timeLimiter = TimeLimiter.of(
                TimeLimiterConfig.custom()
                        .timeoutDuration(Duration.ofSeconds(3))
                        .cancelRunningFuture(true)
                        .build()
        );

        // Java 25 virtual threads keep the timeout wrapper lightweight.
        this.executor = Executors.newVirtualThreadPerTaskExecutor();
    }

    public String getAnimal() {
        final AnimalPort adapter = adapterConfig.getActiveAdapter();

        try {
            // Run the potentially‑slow call on a virtual thread.
            final CompletableFuture<String> future =
                    CompletableFuture.supplyAsync(adapter::getAnimal, executor);

            // Apply the timeout.
            return timeLimiter.executeFutureSupplier(() -> future);
        } catch (final Exception e) {
            // First instance to detect the failure → broadcast to all.
            publisher.publishEvent(new AdapterSwitchEvent(
                    this,
                    busProperties.getId(),
                    "**",               // source identifier (optional)
                    "fallback"
            ));

            // Fallback locally in case the broadcast is delayed.
            return adapterConfig.getActiveAdapter().getAnimal();
        }
    }
}

핵심 결정

결정
Reason
Virtual threads (Executors.newVirtualThreadPerTaskExecutor())Java 25의 가상 스레드는 OS 스레드 차단을 피하고, 타임아웃 래퍼를 가볍게 유지합니다.
AtomicReference for the active adapter락‑프리이며 스레드‑안전한 교체를 보장합니다.
Spring Cloud Bus event publishing스위치를 모든 인스턴스로 브로드캐스트하는 간단한 분산 메시지 버스를 제공합니다.
Resilience4j TimeLimiter회로 차단기와 결합되지 않은 상태에서 호출당 타임아웃 감지를 처리합니다.

End‑to‑end flow

  1. Instance 1AnimalService.getAnimal()을 호출합니다.
  2. 호출이 3초 TimeLimiter를 초과합니다.
  3. catch 블록이 버스에 AdapterSwitchEvent를 퍼블리시합니다.
  4. 모든 인스턴스(타임아웃을 감지한 인스턴스 포함)가 이벤트를 수신하고 AdapterConfig.switchTo("fallback")를 호출합니다.
  5. 이후 모든 인스턴스의 호출은 타임아웃에 절대 걸리지 않고 폴백 어댑터를 사용합니다.

주요 내용

  • Fast failure propagation 클러스터 전반에 걸쳐 빠른 장애 전파는 상위 서비스 장애 시 누적 지연 시간을 크게 줄입니다.
  • Lock‑free adapter swaps 경쟁을 피하고 요청 지연 시간을 낮게 유지합니다.
  • Virtual threads는 timeout‑wrapper를 각 요청마다 사용해도 충분히 저렴하게 만듭니다.
  • 이 패턴은 핫‑스와핑이 필요한 모든 포트/어댑터 쌍에 일반화할 수 있습니다.

전체 예제 저장소를 자유롭게 살펴보고, 여러분의 Hexagonal Architecture 프로젝트에 이 접근 방식을 적용해 보세요!

Overview

  • Destination – 버스 이벤트는 특정 인스턴스가 아니라 모든 서비스에 대상이 됩니다.
  • Immediate local fallback – 브로드캐스트 후, 현재 요청은 버스 왕복을 기다리지 않고 로컬에서 바로 폴백됩니다.

버스 이벤트

대상 어댑터 이름을 전달하는 사용자 정의 RemoteApplicationEvent:

public class AdapterSwitchEvent extends RemoteApplicationEvent {

    private final String targetAdapter;

    public AdapterSwitchEvent(final Object source,
                              final String originService,
                              final String destinationService,
                              final String targetAdapter) {
        super(source != null ? source : new Object(),
              originService,
              () -> destinationService != null ? destinationService : "**");
        this.targetAdapter = targetAdapter;
    }

    // getter
    public String getTargetAdapter() {
        return targetAdapter;
    }
}

Spring Cloud Bus는 이 이벤트를 JSON으로 직렬화하고 RabbitMQ에 푸시하며, 연결된 모든 인스턴스가 이를 수신합니다. 각 인스턴스의 리스너는 스위치를 수행합니다:

@EventListener
public void onAdapterSwitch(final AdapterSwitchEvent event) {
    adapterConfig.switchTo(event.getTargetAdapter());
}

복구 감지

스케줄된 헬스‑체커가 기본 서비스에 ping을 보내고 복구되면 스위치‑백 이벤트를 방송합니다:

@Scheduled(fixedDelayString = "${health.check.interval:5000}")
public void checkPrimaryHealth() {
    // If we are already using the primary adapter, no need to check.
    if ("primary".equals(adapterConfig.getActiveAdapterName())) {
        return;
    }

    try {
        String response = restClient.get()
                                   .uri("/health")
                                   .retrieve()
                                   .body(String.class);

        // When the primary reports it is up, publish a switch‑back event.
        if ("UP".equals(response)) {
            publisher.publishEvent(new AdapterSwitchEvent(
                this,
                busProperties.getId(),
                "**",          // placeholder for correlation ID or similar
                "primary"
            ));
        }
    } catch (Exception e) {
        // Primary is still down – ignore and retry on the next schedule.
    }
}

Running the Demo

git clone https://github.com/charles-hornick/adapter-hotswap-spring.git
cd adapter-hotswap-spring
docker-compose up --build

데모는 핫스왑 서비스 두 인스턴스, 불안정한 기본 서비스(주기적인 타임아웃) 및 안정적인 폴백을 실행합니다. 로그를 확인하세요:

Response: Chien          ← primary adapter
Response: Chien
EVENT: Primary adapter timeout — broadcasting switch to fallback
EVENT: Switching adapter from 'primary' to 'fallback'
(instance 2) EVENT RECEIVED: Switch to 'fallback'
Response: Chat            ← fallback adapter
HEALTHCHECK: Primary adapter responding again
EVENT: Switching adapter from 'fallback' to 'primary'
Response: Chien          ← back to primary

인스턴스 2는 타임아웃을 한 번도 경험하지 않고 전환됩니다.

제한 사항 및 트레이드‑오프

이것은 데모이며, 프로덕션‑레디 프레임워크가 아닙니다. 실제 환경에서 고려해야 할 사항은 다음과 같습니다:

IssueDescriptionMitigation
Split‑brain riskRabbitMQ가 파티션될 경우, 인스턴스들이 어떤 어댑터가 활성화될지 서로 다르게 판단할 수 있습니다.합의 메커니즘을 사용하거나 헬스‑체커를 통해 최종 수렴을 허용합니다.
Thundering herd on recovery‘스위치 백’이 방송될 때 모든 인스턴스가 동시에 기본 인스턴스를 호출합니다.헬스‑체크 간격에 지터를 추가하거나, 하나의 인스턴스만 먼저 스위치 백하도록 카나리 방식을 사용합니다.
Single point of decision장애를 감지한 첫 번째 인스턴스가 전체 클러스터의 결정을 내리며, 오탐은 불필요한 전환을 초래합니다.브로드캐스트 전에 N 연속 실패를 요구하거나, 쿼럼 기반 결정을 사용합니다.
Bus latency브로드캐스트와 수신 사이에 다른 인스턴스가 아직 실패한 어댑터를 호출할 수 있는 시간이 존재합니다.최종 일관성을 허용합니다; RabbitMQ 전파는 대부분의 사용 사례에 충분히 빠릅니다.

Stack: Java 25, Spring Boot 4.0.2, Spring Cloud 2025.1.0, Resilience4j 2.3.0, RabbitMQ

Author: Charles Hornick – 스마트 리팩토링 및 레거시 애플리케이션 구조 개선을 전문으로 하는 Java 컨설턴트.

charles-hornick.be

0 조회
Back to Blog

관련 글

더 보기 »