728x90

결제·주문·정산 같은 장기 실행 로직을 다루는 팀이면 최근 1~2년 사이에 한 번쯤 "Temporal 도입 검토"라는 문서를 봤을 거다. 그리고 그 문서 옆에 요즘 Restate가 같이 올라온다. 원문 글의 요지는 딱 한 줄로 압축된다. "쓸 코드로 고르지 말고, 운영할 것으로 골라라(pick on what you have to operate, not what you have to write)."

실제로 두 엔진 모두 각 단계를 저널(journal)에 기록하고 크래시 후 리플레이해서 "카드는 긁혔는데 재고는 안 잡힌" 상태를 방지한다. 내구성 자체를 차별점으로 파는 영업은 걸러도 된다. 차이는 저널 주변에 있다. 어디에 저장하고, 무엇이 그걸 돌리고, 내 앱이 어떤 모양으로 쪼개져야 하는가.

Durable Execution이 필요해지는 순간

주문 처리 핸들러를 예로 들자. 카드 승인 → 재고 예약 → 메일 발송 → 배송 상태 갱신. 이 중간에 파드가 OOMKill 되면 메모리에 있던 진행 상태는 전부 날아간다. 그래서 우리는 보통 이런 걸 직접 만든다.

  • 진행 상태를 담는 order_saga 테이블과 상태 enum
  • 멱등키 테이블 + PG사 재조회로 "이미 승인됐나?" 확인
  • 실패 단계만 다시 태우는 재시도 워커, 그리고 지수 백오프 큐
  • 보상 트랜잭션(환불, 재고 해제) 코드와 그 코드의 실패 처리

이게 나쁘다는 게 아니다. 다만 이 코드는 도메인 로직보다 빨리 늘어나고, 장애 리포트의 절반은 여기서 나온다. Durable execution 엔진은 이 "상태 저장 + 재시도 + 이어서 실행"을 프레임워크로 흡수한다. 완료된 스텝은 저널에 남고, 프로세스가 되살아나면 저널을 리플레이해 끝난 단계를 건너뛰고 정확히 멈춘 지점부터 이어간다.

Temporal은 클러스터, Restate는 바이너리 한 개(단, 별표 붙음)

Temporal: 4개 서비스 + 외부 DB + 내 워커

Temporal 서버는 한 프로세스가 아니다. 게이트웨이 역할의 Frontend, 워크플로 상태와 타이머를 소유하는 History, 태스크 큐를 호스팅하는 Matching, 그리고 Temporal 자체 시스템 워크플로용 내부 Worker. 각각 별도 프로세스에 자기 gRPC 엔드포인트를 가지며, 규모가 커졌을 때 독립적으로 스케일하려고 이렇게 쪼개 놨다.

그리고 이 클러스터는 스스로 아무것도 기억하지 못한다. 영속성은 외부 DB(실무에선 PostgreSQL/MySQL, 로컬 개발용 SQLite)가 담당한다. 오래된 블로그 보고 Cassandra를 떠올렸다면 접어라. 원문에 따르면 Cassandra는 Server v1.21에서 deprecated, v1.24에서 제거됐다. 검색(Visibility)도 오해가 많은데, Server v1.20부터 SQL DB가 Advanced Visibility를 지원하므로 Elasticsearch가 필수는 아니다. "볼륨이 커지면 결국 쓰게 될 것"이 정직한 표현이다.

여기에 하나 더. 내 워크플로 코드는 Worker라는 별도 배포 단위에서 돌고, 클러스터의 태스크 큐를 long-poll한다. 즉 도입은 "서버 하나 띄우기"가 아니라 클러스터 + DB + (나중에) 검색 클러스터 + API/워커 분리다.

// workflows.ts — 오케스트레이션. 결정적(deterministic)이어야 한다
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';

const { greet } = proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});

export async function example(name: string): Promise<string> {
  // Date.now(), Math.random(), 직접 I/O 전부 금지
  return await greet(name);
}

greet를 직접 부르지 않고 proxyActivities를 통과시키는 이유가 핵심이다. 워크플로 코드는 복구할 때마다 이벤트 히스토리로 리플레이되므로, 같은 입력이면 항상 같은 커맨드 시퀀스를 뱉어야 한다. 아니면 Temporal이 비결정성 에러를 내고 진행을 거부한다. 대신 이 모델 덕분에 "30일 sleep 후 로컬 변수 그대로 깨어나기"가 가능하다. 프로세스를 살려두는 게 아니라 히스토리를 다시 재생해 상태를 복원하니까.

Restate: DB가 바이너리 안에 들어있다

Restate는 반대쪽에 베팅한다. Rust로 쓰인 단일 바이너리에 스트림 처리 아키텍처, 그리고 저널과 durable state를 담는 내장 RocksDB가 함께 들어있다. 프로비저닝할 Postgres도, 돌봐야 할 Elasticsearch도 없다.

다만 원문이 짚은 정직한 별표: "단일 바이너리"가 "프로세스 하나로 끝"을 뜻하지는 않는다. HA를 원하면 그 바이너리 여러 인스턴스를 띄우고, RocksDB가 주기적으로 오브젝트 스토어(S3/GCS/Azure Blob)에 스냅샷을 남겨야 한다. 그래야 노드가 죽어도 다른 노드가 상태를 복구하고 로그를 트림할 수 있다. 그래서 실제 비교는 "1 프로세스 vs 클러스터"가 아니라 "같은 바이너리 몇 개 + 버킷 하나" vs "4종 서비스 + RDB + (옵션) 검색 클러스터 + 쪼갠 워커"다. 그래도 운영 표면적 차이는 여전히 극적이다.

import * as restate from "@restatedev/restate-sdk";

// Virtual Object: id로 키잉된 상태 + 단일 writer 보장
export const myObject = restate.object({
  name: "MyObject",
  handlers: {
    myHandler: async (ctx: restate.ObjectContext, greeting: string) => {
      // ctx.sleep(), awakeable 등 durable 연산은 ctx 경유
      return `${greeting} ${ctx.key}!`;
    },
  },
});

restate.serve({ services: [myObject] });

Restate의 간판 프리미티브인 Virtual Object는 id로 키잉된 상태 엔티티다. 자기만의 KV 상태를 갖고, 특정 객체 상태는 한 번에 하나의 핸들러만 변경하는 단일 writer 보장이 붙는다. 별도 상태 저장소 없이 keyed state를 얻는 셈이다. 외부 이벤트를 기다리는 durable promise(awakeable), ctx.sleep 기반 durable timer도 있고, SDK는 TypeScript/Java·Kotlin/Python/Go/Rust를 지원한다. 중요한 건 Temporal식 강한 결정성 계약을 핸들러에 같은 방식으로 강요하지 않는다는 점이다. durable 스텝은 context를 통과하고, 프레임워크가 저널을 리플레이한다.

실무 관점: 5분 실습, 운영 표면적, 그리고 함정

일단 띄워보기

$ temporal server start-dev --db-filename temporal.db
CLI 1.x.x (Server 1.x.x, UI 2.x.x)

Server:  localhost:7233
UI:      http://localhost:8233
Metrics: http://localhost:64000/metrics

(버전 문자열과 포트는 릴리스마다 다르다. 이건 로컬 개발용 dev 서버이고, 프로덕션은 앞서 말한 4종 서비스 + RDB 구성이다. 여기서 "로컬은 한 줄인데 왜 운영은 이렇게 복잡하냐"는 갭이 도입 검토에서 가장 자주 새는 지점이다.)

$ docker run --name restate -d --rm \
    -p 8080:8080 -p 9070:9070 -p 9071:9071 \
    docker.io/restatedev/restate:latest

$ restate deployments register http://host.docker.internal:9080
✅ Successfully registered deployment  (services: MyObject)

# 서비스 호출 (ingress)
$ curl -s localhost:8080/MyObject/alice/myHandler -H 'content-type: application/json' -d '"Hello"'
"Hello alice!"

포트/플래그는 버전에 따라 달라질 수 있으니 공식 문서 확인이 필요하다. 다만 흐름 자체는 "바이너리 실행 → 내 핸들러 프로세스 등록 → HTTP 호출"이 끝이다.

운영 표면적 비교

  • 배포 토폴로지: Temporal = Frontend/History/Matching/내부 Worker + 내 Worker Deployment + API Deployment. Restate = 바이너리 N개 + 내 핸들러 서비스.
  • 스토리지 의존성: Temporal = PostgreSQL/MySQL(+ 볼륨 커지면 ES/OpenSearch). Restate = 로컬 디스크(RocksDB) + 오브젝트 스토리지 버킷.
  • 커플링: Temporal 구성은 API·워커·서버 셋이 서로를 물고 있어 하나가 죽으면 나머지가 같이 흔들린다. 원문이 인용한 DBOS 벤치마크(경쟁사가 자기 제품과 비교한 자료다)에서는 샘플 앱에 Temporal을 붙이며 100줄 이상 수정, 110→187줄 증가, 두 서비스 분리 + 세 번째(Temporal 서버) 런타임 의존이 생겼다고 한다. 자연법칙처럼 인용할 숫자는 아니고, Restate 수치로 옮겨 붙이면 더 안 된다. 다만 밑에 깔린 아키텍처 주장은 Temporal 공식 문서로도 검증되는 사실이다.
  • 장애 복구: Temporal은 DB가 진실의 원천이라 DB HA/백업 전략이 곧 워크플로 복구 전략이다. Restate는 노드 로컬 RocksDB + 오브젝트 스토어 스냅샷이라, 스냅샷 대상 버킷을 설정하지 않은 채 단일 노드로 굴리는 것이 가장 위험한 구성이다.

흔한 함정

1) 코드 한 줄 고쳤는데 진행 중 워크플로가 멈춘다. Temporal 도입 팀의 통과의례다. 워크플로 함수 안에서 액티비티 호출 순서를 바꾸거나 분기를 추가하면 리플레이 시 커맨드 시퀀스가 어긋난다. SDK·버전에 따라 문구는 다르지만 대략 이런 형태의 에러를 보게 된다.

DeterminismViolationError: Workflow activation completed with a different
command list than the one in the event history.
  expected: ScheduleActivityTask(greet)
  actual:   StartTimer

io.temporal.worker.NonDeterministicException: Failure handling event 5 of type
'EVENT_TYPE_ACTIVITY_TASK_SCHEDULED' during replay

해법은 정공법뿐이다. 워크플로 로직 변경은 버전 게이팅(patch/versioning API)으로 감싸고, 태스크 큐를 분리해 신규 워크플로만 새 코드로 받는 식으로 배포한다. Date.now()·Math.random()·직접 HTTP 호출은 워크플로 안에서 전부 금지어라는 걸 팀 컨벤션과 린트로 못 박아야 한다.

2) long-poll과 프록시 타임아웃. Temporal 워커는 태스크 큐를 long-poll 한다. 앞에 idle timeout이 짧은 LB/프록시(또는 사내 방화벽)를 두면 폴이 계속 끊기면서 context deadline exceeded 류의 로그가 도배된다. gRPC 경로에는 가급적 L4로 통과시키고 idle timeout을 폴 주기보다 넉넉히 잡는 편이 안전하다(정확한 기본값은 SDK 문서 확인 필요).

3) "ES 없이 가자"고 결정한 다음 운영에서 후회. v1.20부터 SQL만으로 Advanced Visibility가 되지만, 워크플로 수가 늘고 커스텀 속성으로 조회하기 시작하면 DB 부하로 돌아온다. "ES 필수"는 미신, "결국 필요해질 것"은 사실 — 이 중간 지점을 도입 문서에 명시해 두자.

4) Restate에서 상태를 ctx 밖에 두기. 핸들러 안에서 전역 변수나 외부 캐시에 진행 상태를 들고 있으면 리플레이 시 그 값은 복원되지 않는다. durable하게 남길 값은 반드시 context를 경유해야 한다. Virtual Object 키 설계도 초기에 잡아야 한다. 단일 writer 보장은 같은 키에 대한 것이라, 키를 너무 굵게 잡으면 그게 곧 직렬화 병목이 된다(주문번호 단위 vs 사용자 단위 같은 결정).

정리: 무엇을 언제 고를까

한 줄 요약: 코드 델타는 생각보다 작고, 운영 델타는 생각보다 크다. 새벽 2시에 당신이 붙잡을 대상이 무엇인지로 고르면 후회가 적다.

  • Restate 쪽에 기울 때: 팀에 전담 플랫폼 인력이 없다. 이미 굴리는 RDB/ES에 또 하나를 얹기 부담스럽다. 필요한 건 "이 핸들러를 크래시에 강하게 만들고 약간의 durable state를 붙이기"이고, 전면적인 워크플로 오케스트레이션 세계관까지는 원치 않는다. 마이크로서비스/이벤트 핸들러/에이전트처럼 백엔드 전반에 얇게 깔고 싶다.
  • Temporal 쪽에 기울 때: 30일 대기·복잡한 보상 트랜잭션·child workflow가 얽힌 정말 험한 장기 오케스트레이션이 있다. 재시도/타임아웃 설정을 세밀하게 통제해야 한다. 조직에 이미 Postgres·ES 운영 역량과 SRE가 있다. 혹은 그냥 Temporal Cloud에 돈을 내고 클러스터 운영을 아웃소싱할 수 있다(규모 있게 갈 거라면 보통 이게 합리적이다).

국내 환경 도입 팁

  • EKS: Temporal은 History/Matching/Frontend를 별도 Deployment + HPA로 잡고, 내 워커는 태스크 큐별로 분리해 스케일 축을 나눈다. RDS(Postgres) 파라미터·커넥션 수 산정을 초기에 해두지 않으면 워커 스케일아웃이 곧 DB 커넥션 고갈로 이어진다. Restate는 상태를 로컬 디스크에 두므로 StatefulSet + 적절한 PVC(가급적 gp3 이상), 스냅샷용 S3 버킷과 IRSA 권한이 세트다.
  • 온프레미스: 오브젝트 스토리지가 없으면 Restate HA 전제가 흔들린다. MinIO 같은 S3 호환 스토리지를 함께 검토하고, 지원 여부는 공식 문서로 확인하자.
  • 마이그레이션: 기존 사가 테이블을 한 번에 걷어내지 말 것. 신규 주문 흐름만 durable execution으로 받고 기존 건은 옛 워커가 소진시키는 이중 운영 기간을 두는 게 안전하다. 그리고 어느 쪽을 택하든 워크플로 히스토리/저널 보존 기간과 스토리지 증가율을 도입 첫 주에 측정해 두자. 이걸 놓치면 반년 뒤 디스크가 먼저 알려준다.

참고 자료

728x90

+ Recent posts