Vizend 구독 기능 설계 구현

Vizend 구독 기능 설계 구현

자기 자신을 배포하는 플랫폼의 구독 설계

Vizend Kollex 구독에서 마주친 분산 배포의 정합성 문제와 해결

1. 배경과 문제 정의

Vizend Showcase는 Kollex(다수의 마이크로서비스를 묶은 배포 단위)를 Pavilion(테넌트) 단위의 Kubernetes 클러스터에 GitOps 방식으로 프로비저닝하는 플랫폼입니다. Kollex는 사용자에게 노출되는 화면 단위인 Episode와, 그 Episode를 떠받치는 플랫폼 백엔드 서비스인 Drama(gate·metro·porto·showcase 등)로 구성됩니다. 사용자가 Kollex를 구독하면 Showcase는 구독 사실을 SubscriptionLifecycleEvent로 Metro(인가·구독 상태를 관리하는 플랫폼 서비스)에 발행하고, 동시에 GitOps 저장소에 Kubernetes 매니페스트를 작성한 뒤 ArgoCD로 동기화합니다. 즉 배포 오케스트레이션의 주체는 외부 서비스가 아니라 Showcase 자신입니다.

이 파이프라인은 구독 대상이 외부 워크로드인 한 단방향 비동기 흐름으로 깔끔하게 정리됩니다. 문제는 Vizend 플랫폼을 구성하는 Showcase 서비스 자체가 하나의 Kollex로 패키징되어 있다는 점에서 발생합니다. 즉 Showcase는 자기 자신을 구독 가능한 배포 대상으로 노출하며, 이 경우 구독을 처리하는 주체와 구독으로 인해 교체되는 대상이 동일한 프로세스가 됩니다. 본 글은 이 자기 참조적(self-referential) 배포 구조에서 발생하는 정합성 문제와, 이를 상태 머신 및 이벤트 분할로 해결한 과정을 다룹니다.

2. Builtin Kollex와 자기 참조적 배포

2-1. Builtin Kollex의 정의

Kollex 엔티티는 isBuiltin 속성으로 플랫폼 구성 번들 여부를 구분합니다. builtin=true인 Kollex는 Vizend 플랫폼 자체를 구성하는 서비스 집합을 가리킵니다. Pavilion이 신규 생성되면 Showcase는 부트스트랩 과정에서 이 Builtin Kollex 구독을 자동으로 개시하며, 그 결과 Showcase와 그 의존 인프라가 해당 Pavilion 클러스터에 배포됩니다.

일반 Kollex 구독과 Builtin Kollex 구독을 구분 짓는 본질적 제약은 다음과 같이 요약됩니다.

배포 완료 여부를 판정해야 하는 프로세스가, 배포 과정에서 교체되는 프로세스와 동일하다.

Showcase가 신규 버전으로 롤아웃되는 동안 기존 Showcase 프로세스는 종료됩니다. 배포 완료를 확인할 수 있는 주체는 롤아웃 이후 기동된 새 프로세스뿐이며, 이 프로세스는 직전 프로세스의 인메모리 상태를 전혀 공유하지 않습니다. 따라서 Builtin 구독의 라이프사이클은 단일 프로세스의 연속 실행을 전제하는 일반 구독과 분리된 상태 전이 경로를 가져야 합니다.

2-2. SubscribeLifecycleState 전이 경로

두 구독 유형은 동일한 SubscribeLifecycleState enum을 공유하되 서로 다른 경로를 밟습니다. 일반 구독이 단일 트랜잭션 흐름으로 종료되는 반면, Builtin 구독은 프로세스 재기동 지점을 경계로 전후가 분리됩니다.

// 일반 Kollex 구독
Preparing → SyncingMetro → Completed | Failed
// Builtin Kollex 구독 (Self-Deploy)
Preparing
  → SyncingMetro          // 1차 구독 이벤트 발행 (Metro)
  → PreparingSelfDeploy   // self-deploy 이벤트 발행 직전 단계
  → DeployingSelf         // 자기 배포 진행 — 프로세스 종료 임박
  ──────── 프로세스 재기동 경계 ────────
  → SelfDeployVerified    // 신규 프로세스의 이미지 버전 검증 통과
  → FinalizingMetro       // 최종 배포 이벤트 발행 (Drama 포함)
  → Completed
// 실패 전이
DeployingSelf   → SelfDeployFailed   // 이미지 버전 불일치
FinalizingMetro → FinalizeFailed     // 이벤트 발행 실패

DeployingSelf 상태에서 프로세스가 종료되어도 SubscribeLifecycle 레코드는 데이터베이스에 영속화되어 있습니다. 재기동된 프로세스는 이 레코드를 단일한 진실 공급원(source of truth)으로 삼아 중단된 라이프사이클을 이어받습니다. 상태를 인메모리가 아닌 영속 계층에 둔 것이 프로세스 교체를 견디는 핵심 설계입니다.

2-3. 배포 이벤트의 2단계 분할

Builtin 구독은 SubscriptionLifecycleEvent를 두 단계로 분할하여 발행합니다. 이 분할의 근거는 Episode와 Drama 간의 배포 의존 순서에 있습니다.

Showcase는 사용자에게 노출되는 Episode인 동시에, gate·metro·porto와 함께 배포되는 플랫폼 백엔드 계층, 즉 자기 자신을 가리키는 showcase Drama이기도 합니다. Builtin Kollex를 구독하면 이 showcase Drama가 구독 대상에 포함되며, 결과적으로 Showcase가 자기 자신을 새 버전으로 교체합니다. 만약 showcase Drama가 아직 새 버전으로 수렴(converge)되지 않은 상태에서 구버전 프로세스가 완료 신호를 발행하면, 교체가 끝나지 않았는데 구독이 완료 처리되는 거짓 양성(false positive)이 발생합니다.

이를 방지하기 위해 Metro로 발행하는 1차 구독 이벤트에는 Episode만 포함하고 showcase Drama는 제외합니다. showcase Drama의 교체는 별도의 self-deploy 이벤트로 Showcase 내부에 위임합니다. 새 프로세스가 정상 기동하여 이미지 버전 검증을 통과한 뒤에야, Drama까지 포함한 최종 이벤트를 Metro로 발행합니다. 이 최종 이벤트의 페이로드는 재기동 이후에도 복원할 수 있도록 라이프사이클 레코드에 JSON으로 직렬화하여 보관합니다.

// 1차 구독 이벤트 → Metro 발행. self-deploy 대상(showcase Drama)은 제외
eventProxy.produceEvent(subscriptionLifecycleEvent);   // Episode 포함, Drama 비움
// self-deploy 트리거 — Showcase 내부 이벤트. showcase Drama만 포함
SubscriptionLifecycleEvent selfDeployEvent = event.toBuilder()
    .episodes(List.of())
    .dramas(galleryDramas)
    .build();
applicationEventPublisher.publishEvent(selfDeployEvent);
// 최종 이벤트(Drama 포함)는 재기동 후 Metro 발행을 위해 직렬화 보관
lifecycle.setFinalEventPayloadJson(finalEvent.toJson());

▲ 최종 이벤트 페이로드를 영속화하는 이유 — 재기동된 프로세스는 직전 프로세스의 인메모리 컨텍스트를 승계하지 못한다

3. 재기동 후 자기 배포 검증

3-1. 기동 시점 검증 — ApplicationReadyEvent

신규 프로세스의 기동이 완료되면 BuiltinSubscribeLifecycleCompletionTask가 ApplicationReadyEvent를 수신하여 검증을 수행합니다. DeployingSelf 상태로 중단된 라이프사이클을 조회한 뒤, 라이프사이클에 기록된 기대 이미지 버전(expectedImageVersion)과 현재 실행 중인 프로세스의 관측 이미지 버전(observedImageVersion)을 대조합니다. 두 값이 일치하면 SelfDeployVerified로 전이하고, 불일치하면 SelfDeployFailed로 기록합니다.

@EventListener(ApplicationReadyEvent.class)
@Transactional
public void verifySelfDeployOnStartup() {
    if (!enabled) return;
    String observed = resolveObservedImageVersion();
    List<SubscribeLifecycle> deploying =
        subscribeLifecycleLogic.findByStateIn(List.of(DeployingSelf));
    for (SubscribeLifecycle lifecycle : deploying) {
        if (Objects.equals(lifecycle.getExpectedImageVersion(), observed)) {
            lifecycle.moveTo(SelfDeployVerified);
        } else {
            lifecycle.recordFailure(SelfDeployFailed, "image version mismatch");
        }
        subscribeLifecycleLogic.modifySubscribeLifecycle(lifecycle);
    }
}

버전 일치 검증을 통해 "구독이 의도한 바로 그 이미지가 실제로 기동했는가"를 보장합니다. 롤아웃이 지연되어 구버전 프로세스가 일시적으로 살아 있거나, 롤백으로 다른 버전이 기동한 경우를 실패로 식별할 수 있습니다.

3-2. 관측 버전 해석과 환경 독립성

관측 이미지 버전은 실행 환경에 따라 상이한 경로로 주입됩니다. Kubernetes 환경에서는 이미지 태그가 환경 변수로 전달되고, 로컬 개발 환경에는 해당 변수가 존재하지 않습니다. resolveObservedImageVersion()은 우선순위가 정의된 후보 속성을 순차 탐색하고, 모두 부재할 경우 패키징된 JAR 매니페스트의 Implementation-Version으로 폴백합니다.

private String resolveObservedImageVersion() {
    List<String> candidates = List.of(
        "vizend.gallery.self-deploy.observed-image-version",
        "VIZEND_GALLERY_IMAGE_TAG",
        "GALLERY_IMAGE_TAG",
        "IMAGE_TAG",
        "BUILD_VERSION"
    );
    for (String name : candidates) {
        String value = environment.getProperty(name);
        if (StringUtils.hasText(value)) return value.trim();
    }
    Package pkg = getClass().getPackage();          // 로컬 폴백
    return pkg != null ? pkg.getImplementationVersion() : null;
}

이 우선순위 체인 덕분에 검증 로직은 실행 환경을 조건 분기하지 않습니다. 동일한 코드가 CI/CD 파이프라인에서는 도커 이미지 태그로, 로컬에서는 빌드 산출물 버전으로 동작하며, 환경별 분기가 코드에 스며들지 않습니다.

3-3. 최종 이벤트 발행과 멱등성

SelfDeployVerified 상태의 라이프사이클은 고정 주기 스케줄러가 폴링하여 최종 이벤트를 발행합니다. 검증과 발행을 분리한 이유는, ApplicationReadyEvent 시점에 빈 초기화는 완료되었더라도 메시지 브로커 연결과 트랜잭션 매니저가 완전히 안정화되기까지 짧은 유예가 필요하기 때문입니다. 동기 발행 대신 폴링 기반 비동기 발행을 채택하여 기동 직후의 일시적 불안정 구간을 회피합니다.

@Scheduled(fixedDelayString =
    "${vizend.gallery.builtin-subscribe.lifecycle.finalize-interval-ms:10000}")
@Transactional
public void publishFinalMetroEvents() {
    List<SubscribeLifecycle> verified =
        subscribeLifecycleLogic.findByStateIn(List.of(SelfDeployVerified));
    for (SubscribeLifecycle lifecycle : verified) {
        if (lifecycle.isFinalEventPublished()) continue;   // 멱등 가드
        lifecycle.moveTo(FinalizingMetro);
        SubscriptionLifecycleEvent event =
            SubscriptionLifecycleEvent.fromJson(lifecycle.getFinalEventPayloadJson());
        eventProxy.produceEvent(event);
        lifecycle.markFinalEventPublished(LocalDateTime.now());
    }
}

isFinalEventPublished() 가드가 멱등성을 보장합니다. 스케줄러의 주기적 재실행, 발행 후 상태 갱신 실패, 동시 인스턴스 경합 등 어떤 상황에서도 동일 라이프사이클의 최종 이벤트가 중복 발행되지 않습니다. 이벤트 소비 측(Metro)에 at-least-once 전달이 전제되는 환경에서, 발행 측이 자체적으로 중복을 억제하는 것은 전체 파이프라인의 정합성을 단순화합니다.

4. External Drama — 기존 인프라와의 충돌 회피

Pavilion 클러스터에는 이미 운영 중인 공통 인프라가 존재할 수 있습니다. 신규 구독하는 Kollex의 Drama가 이러한 기존 인프라와 중복될 경우, 재설치는 운영 중인 워크로드를 교란할 위험이 있습니다. 이처럼 외부에서 이미 관리되는 Drama를 External Drama로 분류합니다. 사용자는 구독 시점에 특정 Drama를 External로 지정하며, 해당 식별자는 SubscribeCommand.externalTargetIds로 전달됩니다.

여기서 핵심 설계 판단은 "배포 제외"와 "이벤트 제외"를 분리한 것입니다. External Drama는 GitOps 매니페스트 생성 대상에서는 제외되어야 하지만, 구독 이벤트 페이로드에서는 제외되어선 안 됩니다. Metro는 인가·서비스 디스커버리를 위해 Drama의 역할 매핑 — 서비스명, 포트, 역할 식별자 — 을 필요로 하며, 이 메타데이터는 매니페스트 생성 여부와 무관하게 전달되어야 합니다.

// External Drama: 이벤트에는 포함, 매니페스트 생성만 skip 지시
List<SubscriptionTargetSpec> dramaTargets = dramas.stream()
    .map(drama -> {
        boolean external = command.getExternalTargetIds().contains(drama.getId());
        return SubscriptionTargetSpec.of(drama).withExternal(external);
    })
    .toList();
// Showcase 배포 단계는 isExternal 플래그로 매니페스트 생성 여부를 분기

초기 구현은 External Drama를 이벤트 목록에서 일괄 제외했고, 그 결과 Metro가 해당 Drama와 연결된 엔드포인트를 해석하지 못해 서비스 간 통신이 실패했습니다. "배포하지 않는다"는 결정이 "존재하지 않는다"로 잘못 전파된 사례로, 배포 정책과 토폴로지 정보를 같은 채널에서 분리해 표현해야 한다는 교훈을 남겼습니다.

5. GID — 멀티테넌트 식별자 체계

Vizend에서는 복수의 Pavilion이 동일한 Kollex를 독립적으로 구독합니다. 각 Pavilion은 분리된 클러스터를 가지며, Kollex를 구성하는 Episode·Drama는 Pavilion마다 별도의 로컬 식별자로 등록됩니다. 이 구조에서 로컬 식별자만으로는 서로 다른 테넌트의 리소스를 전역적으로 구분할 수 없고, 식별자 충돌이 발생합니다.

GID(Global ID)는 클러스터 컨텍스트 식별자(CID)와 Pavilion 로컬 식별자를 결합하여 전역 유일성을 확보합니다.

// 로컬 식별자 — Pavilion 내에서만 유일 (NT:1 = Pavilion ID)
KOLLEX_ID   = "NT:1-K0001"
EPISODE_ID  = "NT:1-E0002"
DRAMA_ID    = "NT:1-D000b"
// GID — 플랫폼 전역에서 유일 (X8FD = CID, 클러스터 컨텍스트)
KOLLEX_GID  = "X8FD-NT:1-K0001"
EPISODE_GID = "X8FD-NT:1-E0002"
DRAMA_GID   = "X8FD-NT:1-D000b"

SubscriptionLifecycleEvent에 GID가 함께 전달되므로, Metro와 Showcase의 배포 단계는 이벤트가 어느 클러스터·테넌트의 어떤 리소스를 지시하는지 모호함 없이 결정할 수 있습니다. 설계 가이드 역시 EpisodeKey·DramaKey·KollexKey의 full GID를 신뢰하며, GID가 비어 있으면 폴백 없이 실패해야 한다고 규정합니다. 구독 로직의 E2E 테스트가 이벤트 페이로드의 GID 구성을 명시적으로 검증하는 것도, 이 식별 체계가 멀티테넌트 정합성의 전제 조건이기 때문입니다.

// E2E 검증 — GID가 CID + 로컬 식별자 조합으로 구성되는지 확인
assertThat(event.getEpisodes().get(0)
    .getEpisodeInfo().getEpisodeKey().getGid())
    .isEqualTo(EPISODE_GID);   // "X8FD-NT:1-E0002"
assertThat(event.getEpisodes().get(0)
    .getEpisodeInfo().getEpisodeKey().getId())
    .isEqualTo(EPISODE_ID);    // "NT:1-E0002"

6. 정리

Kollex 구독 기능의 구현 난이도는 "구독"이라는 도메인 용어의 단순함에 반비례했습니다. 자기 참조적 배포라는 제약 하나가 다음과 같은 분산 시스템의 정합성 문제를 연쇄적으로 파생시켰습니다.

  • 상태 영속화 — 배포로 교체되는 프로세스의 진행 상태를 데이터베이스에 두어 재기동을 견딥니다

  • 이벤트 2단계 분할 — 인프라 의존성이 수렴하기 전의 거짓 완료를 차단합니다

  • 버전 검증 — 의도한 이미지가 실제로 기동했음을 환경 독립적으로 보장합니다

  • 멱등 발행 — at-least-once 전달 환경에서 발행 측이 중복을 억제합니다

  • 전역 식별자 — 멀티테넌트 로컬 식별자 충돌을 GID로 해소합니다

이들 중 어느 하나라도 누락되면, 구독 트랜잭션은 성공으로 종료되지만 실제 서비스는 동작하지 않는 부분 실패(partial failure)로 귀결됩니다. 도메인 표면의 단순함 아래에 놓인 분산 정합성 문제를, 영속 상태 머신과 이벤트 분할이라는 정형화된 패턴으로 해소할 수 있었다는 점이 이 기능에서 얻은 핵심적인 설계 경험입니다.

James

Site footer