Temporal, Restate, DBOS, Resonate 같은 내구 실행 프레임워크에서 결정론이 왜 중요한지, 어디에 필요하고 어디에는 필요하지 않은지를 설명한다.
내구 실행(durable execution) 프레임워크(Temporal, Restate, DBOS, Resonate 등)를 사용할 때, **결정론(determinism)**은 꼭 이해해야 하는 핵심 개념이다. 문서를 읽어 보면 코드의 일부는 결정론적이어야 하지만, 다른 일부는 그럴 필요가 없다고 나온다. 이런 설명은 이들 프레임워크에 처음 접하는 개발자에게 혼란을 줄 수 있다.
이 글에서는 결정론이 왜 중요한지, 어디에서 필요하고 어디에서는 필요하지 않은지를 설명한다. 읽고 나면, 덜 헷갈리게 해 줄 좀 더 나은 **정신 모델(mental model)**을 갖게 되기를 바란다.
이 논의를 다음과 같이 나눌 수 있다.
이 글에서는 “제어 흐름(control flow)”과 “부수효과(side effect)”라는 용어를 사용하지만, 프레임워크마다 합의된 공통 용어 세트가 있는 것은 아니다. 예를 들어 Temporal은 각각에 대해 “workflow”와 “activity”라는 용어를 사용한다. Restate는 “handler”, “action”, “durable step” 같은 용어를 쓴다. 각 프레임워크는 어휘도 다르고, 그 뒤에 있는 아키텍처도 다양하다. 모든 것을 포괄하는 단일한 상위 개념은 없다. 이 글에서 설명하는 개념은 프레임워크에 구애받지 않는 방식으로 결정론 요구사항(determinism requirements) 을 생각할 수 있게 해 주는 단순한 모델이다.
내구 실행은 데이터베이스에 기록하기, API 호출하기, 이메일 보내기 같은 부수효과가 있는 함수를 받아, 복구(recovery)(그리고 그에 필요한 내구성(durability))를 통해 이를 신뢰할 수 있게 만든다.
예를 들어, 세 가지 부수효과를 수행하는 함수가 있다고 하자.
2단계에서(내부 재시도에도 불구하고) 실패한다면, 시스템을 비일관 상태에 남겨 두게 될 수 있다. (DB 호출은 성공했지만 API 호출은 실패한 상태)
내구 실행에서 복구란, 함수를 처음부터 다시 실행하면서, 이미 실행된 부수효과의 결과가 있다면 그것을 재사용하는 것을 의미한다. 예를 들어, DB 호출을 다시 실행하는 대신, 첫 번째 함수 실행에서의 결과를 재사용하고 그 단계를 건너뛴다. 이는 곧, 아직 실행되지 않은 첫 번째 단계로 점프한 후 그 지점에서부터 재개하는 것과 같다.
그림 1. 함수가 재시도되며, 가능한 경우 이전 부분 실행의 결과를 사용한다.
그림 2. 제어 흐름과 부수효과
먼저 고객 레코드를 가져온 뒤, 현재 시각이 프로모 종료 일자 이내인지 확인한다. 만약 그렇다면 카드에 10% 할인 금액을 청구하고, 아니라면 전액을 청구한다. 마지막으로 영수증 이메일을 보낸다. 여기에는 다음 섹션에서 다룰 버그가 하나 숨어 있다.
그림 3. 제어 흐름(초록색)과 부수효과(회색)이 섞여 있는 process_order 함수
그림 4. 비결정적 if/else 때문에 발생하는 이중 청구 버그
첫 번째 실행에서, 현재 시간이 프로모 기간 내에 있기 때문에 then 분기가 실행되어 카드에 할인 금액을 청구한다. 하지만 두 번째 실행에서는, 현재 시간이 프로모 종료일 이후가 되어 else 분기가 실행되고, 고객에게 두 번째 청구가 발생한다.
그림 5. 비결정적 제어 흐름 때문에 함수 재시도 시 다른 분기가 실행된다.
이 문제는 now()를 결정론적으로 만듦으로써 해결할 수 있다. 즉, 이를 **결과를 기록하는 내구 단계(durable step)**로 바꾸는 것이다. 그러면 두 번째 실행에서는 동일한 날짜·시간을 반환하게 되고(즉 결정론적이 된다), SDK들이 제공하는 결정론적 날짜·시간, 난수, UUID 등을 활용할 수 있다.
이 변형 버전에서는, 고객이 현재 가지고 있는 **포인트(Loyalty points)**를 기준으로 할인 여부를 결정한다고 하자. 어떤 문제가 보이는가?
만약 이메일 발송 부수효과가 실패하면, 함수는 재시도된다. 하지만 직전 실행에서 이 주문에 대한 포인트가 고객 계정에서 차감되었으므로, 두 번째 실행 시점에는 고객이 더 이상 충분한 포인트를 가지고 있지 않다. 따라서 이번에는 else 분기가 실행되어 고객의 신용카드가 청구된다. 또 다른 이중 결제 버그다.
여기서 기억해야 할 점은, 내구 함수는 원자적 트랜잭션이 아니라는 것이다. 이 함수는 "진행(progress)"에 대한 보장은 제공할 수 있지만, 여러 시스템에 걸친 하나의 원자적 변경을 보장하지는 않는다.
이 새로운 이중 청구 버그는, 각 실행마다 동일한 고객 레코드가 반환되도록 보장함으로써 해결할 수 있다. 이를 위해, 고객 레코드 조회를 **결과가 기록되는 내구 단계(durable step)**로 처리하여, 그 결과를 저장하고 재실행 시에는 해당 결과를 재사용하도록 하면 된다.
그림 6. 제어 흐름이 고객 레코드에 의존하는 경우, 고객 레코드 조회를 결정론적으로 만든다.
제어 흐름을 재실행하려면 결정론이 필요하다. 즉, 항상 동일한 결정 상태(decision state)에 기반해 실행되어야 하고, 항상 동일한 인자를 부수효과 코드에 전달해야 한다. 반면, 부수효과 자체는 결정론적일 필요가 없다. 부수효과에는 멱등성(idempotency) 혹은 **중복 허용(duplication tolerance)**만 필요하다.
내구 실행에서는 함수가 완전히 끝날 때까지, 필요한 만큼 제어 흐름을 여러 번 재실행한다. 하지만 이미 완료된 같은 부수효과를 다시 실행하는 일은 보통 피한다. 각 부수효과의 결과는 프레임워크에 의해 내구적으로 저장되고, 재실행(replay) 시에는 그 저장된 결과만 사용하면 된다.
따라서 부수효과는 결정론적일 필요가 없으며, 오히려 비결정적인 것이 바람직한 경우도 많다. 예를 들어, 현재 주문 개수나 고객의 현재 주소를 조회하는 DB 쿼리는 매번 다른 결과를 반환할 수 있다. 이는 오히려 좋은 일이다. 주문 개수는 변할 수 있고, 주소도 바뀔 수 있기 때문이다.
다만, 제어 흐름이 주문 개수나 현재 주소에 의존한다면, 제어 흐름이 항상 같은 답을 받도록 보장해야 한다. 이를 위해, 첫 번째 실행 결과를 저장하고, 이후 모든 재실행에서 그 결과를 사용함으로써(제어 흐름을 결정론적으로 만들면서) 이를 달성한다.
이제 멱등성을 보자. 만약 어떤 부수효과가 실제로는 완료되었으나, 어떤 종류의 실패로 인해 그 결과가 프레임워크에 의해 저장되지 못한다면 어떻게 될까? 내구 실행 프레임워크는 함수를 재실행하고, 저장된 결과가 없음을 보고 부수효과를 다시 실행한다. 이런 이유로 부수효과는 멱등적이거나, 아니면 **한 번 이상 실행되더라도 문제없이 허용(중복 허용)**할 수 있어야 한다.
예를 들어, 같은 이메일을 두 번 보내는 것은 괜찮다고 판단할 수도 있다. 확실한 멱등성을 구현하는 비용이 그만한 가치가 없을 수 있기 때문이다. 반면, 신용카드 결제는 반드시 멱등적이어야 한다.
일부 프레임워크(대표적으로 Temporal)는 제어 흐름과 부수효과를 명시적으로 분리한다. Temporal의 프로그래밍 모델에서, **워크플로 정의(workflow definition)**가 제어 흐름이고, 각 **액티비티(activity)**가 부수효과(또는 어떤 종류의 비결정적 연산)에 해당한다.
Resonate, Restate 같은 다른 프레임워크는, 함수들이 다른 함수를 호출하는 형태를 기반으로 하며, 이로 인해 **함수 호출 트리(function call tree)**가 만들어질 수 있다. 이 트리 내의 각 함수는 **일부 제어 흐름과 부수효과(로컬에서 실행되거나 다른 함수 호출을 통해 실행되는)**를 가진다.
그림 7. 각 함수에 제어 흐름이 들어 있는 함수 호출 트리
이들 각 함수에서도 제어 흐름에 대한 동일한 결정론 요구사항이 적용된다. 이를 보장하기 위해, 동일한 입력을 제공하고, 날짜/시간, 난수, ID, 조회된 객체처럼 본질적으로 비결정적인 연산들을 결정론적 버전으로 치환한다.
이 글에서 사용한 정신 모델은, 내구 함수를 제어 흐름과 부수효과로 분리하는 데 기반한다. 일부 프레임워크(Temporal 등)는 이 둘을 실제로 명시적으로 분리하지만, 다른 프레임워크는 구성 가능한(composable) 함수에 더 집중한다.
제어 흐름에서 결정론이 필요한 이유는, 복구가 함수의 재시도(retry)에 기반하기 때문이다. 만약 우리가 함수 내부로 마법처럼 들어가, 정확히 어느 줄에서 다시 시작해야 할지 찾아내고, 그 시점의 로컬 상태를 재구성한 뒤 거기서부터 실행을 이어갈 수 있다면, 결정론적인 제어 흐름 코드는 필요 없을 것이다. 하지만 실제로는 그렇게 동작하지 않는다. 함수는 항상 맨 위에서부터 다시 실행되며, 매번 같은 결정을 내리지 못하면 이상한 동작, 불일치, 심지어 고객에게 이중 청구를 하는 상황에까지 이를 수 있다.
반면, 부수효과는 얼마든지 비결정적일 수 있고, 또 그래야 하는 경우가 많다. 함수 자체는 여러 번 실행될 수 있더라도, 부수효과는 일반적으로 한 번만 실행되도록 설계되기 때문이다. 그리고, 결과가 내구적으로 저장되지 못한 소수의 실패 케이스에 대해서는 멱등성 또는 중복 허용에 의존한다.
이 모델은 꽤 일반화된 모델이다. 프레임워크마다 미묘한 차이와 다양한 특성이 존재한다. 예를 들어, Temporal의 경우 이벤트 히스토리를 기록하고 재실행 시 그와 일치하길 기대하는 방식 때문에, 이 글의 일부 예제가 실제로는 **비결정성 오류(non-determinism error)**로 이어질 수 있다. 개발자는 각 프레임워크의 고유한 특성을 따로 학습해야 한다. 이 글이 내구 실행 문맥에서의 결정론에 대한 전반적인 개요를 제공하는 데 도움이 되었기를 바란다.