# Temporal 개념 정리

CloudOps 수집 파이프라인이 NATS에서 Temporal로 옮겨가고 있다. Temporal을 처음 보는 사람이 우리 코드를 읽을 수 있을 만큼만 개념을 정리한다.

## 1. 무엇을 푸는 도구인가

여러 단계가 순서대로 이어지는 작업을 안정적으로 끝까지 돌리는 도구다.

수집 하나만 봐도 계획을 세우고, 계정마다 리소스를 긁고, 저장하고, 관계를 맺고, 진단을 돌리고, 티켓을 만든다. 중간에 실패하면 어디까지 갔는지 알아야 하고, 재시도해야 하고, 몇 시간 걸리는 작업이 파드 재시작을 견뎌야 한다.

메시지 큐로 이걸 하려면 진행 상태를 직접 DB에 적고, 재시도 횟수를 세고, 중복 수신을 막고, 어느 단계에서 멈췄는지 조회하는 코드를 전부 손으로 짜야 한다. Temporal은 그 부분을 대신 한다.

| | 메시지 큐 방식 | Temporal 방식 |
|---|---|---|
| 진행 상태 | 직접 DB에 기록 | 엔진이 실행 이력으로 보관 |
| 재시도 | 직접 카운트하고 재발행 | 정책만 선언 |
| 중간 실패 복구 | 어디까지 갔는지 직접 추적 | 멈춘 지점부터 이어서 실행 |
| 순서 보장 | 메시지 순서에 의존 | 코드에 적은 순서 그대로 |
| 현황 조회 | 별도 조회 화면을 만들어야 함 | 기본 제공 화면에서 확인 |

## 2. 왜 옮겼나

수집은 원래 NATS로 돌았다. 아래는 커밋 기록과 코드 주석에 남아 있는 실제 이유다. 별도 결정 문서는 찾지 못해서, 옮기는 과정에서 무엇을 버렸고 무엇이 문제였는지를 역으로 정리한 것이다.

### 상태를 직접 관리하고 있었다

수집 진행 상황을 담는 테이블이 따로 있었다. 작업이 어디까지 갔는지, 얼마나 걸렸는지를 직접 적고 갱신했다.

그런데 메시지가 유실되거나 처리하던 쪽이 죽으면 그 행이 "진행 중"인 채로 영원히 남는다. 그래서 오래 멈춰 있는 행을 찾아 정리하는 청소 작업을 주기적으로 돌렸다. 그것도 두 종류였다.

Temporal로 옮기면서 그 테이블과 청소 작업을 전부 지웠다. 엔진이 재시도와 시간 제한으로 멈춘 것을 스스로 관리하기 때문이다.

### 실패를 표현할 방법이 없었다

메시지 처리가 실패하면 큐가 계속 다시 보낸다. 무한히 반복되는 것을 막으려고, 수집기가 **모든 예외를 삼키고** 정상 종료하도록 만들었다.

부작용이 컸다. 진짜 실패한 작업도 겉으로는 성공으로 끝나서, 실패했다는 사실을 결과 안에 따로 적어 보내야 했다. 지금도 이 구조가 남아 있고, 코드에 "엔진에 재시도를 맡기려면 예외를 그대로 던지도록 여는 것이 정석"이라고 갚아야 할 빚으로 적혀 있다.

### 하나가 막히면 전부 막혔다

수집기가 메시지를 한 번에 하나씩만 처리하는 설정이었다. 그래서 CSP 호출 하나가 응답 없이 매달리면 그 뒤에 쌓인 작업이 전부 멈췄다. 지금 코드에 시간 제한이 촘촘히 박혀 있는 것이 그 흔적이다.

### 트리거가 조용히 사라졌다

매핑은 수집이 끝나면 발행되는 신호를 받아 돌았다. 수집을 Temporal로 옮기면서 그 신호가 없어졌는데, 아무도 알아채지 못했다. 매핑만 안 걸린 채로 시간이 지났다.

메시지로 이어붙인 흐름의 약점이다. 중간 고리가 빠져도 아무 데서도 오류가 나지 않는다. 지금은 수집이 끝난 자리에서 매핑을 직접 부른다.

### 끝나는 지점을 알 수 없었다

관계 정리와 진단은 모든 계정의 적재가 끝난 뒤에 돌아야 한다. 반쯤 들어온 데이터로 관계를 맺으면 결과가 틀린다.

메시지 방식으로는 "전부 끝났다"를 알기 어렵다. 몇 건이 올지 세어두고 도착 개수를 맞춰봐야 한다. Temporal에서는 자식이 전부 끝나기를 기다리는 것이 코드 한 줄이다.

### 그래서 무엇이 남았나

| 버린 것 | 대체한 것 |
|---|---|
| 진행 상태 테이블 두 개 | 실행 이력 |
| 멈춘 행 청소 작업 두 개 | 재시도와 시간 제한 |
| 예외를 삼키는 방어 | 재시도 정책 선언 |
| 완료 개수 세기 | 자식 실행 대기 |
| 별도 조회 화면 | 기본 제공 화면 |

옮기는 데 대략 열 개가 넘는 단계를 밟았다. 자격증명 확인, 관계 정리, 매핑 트리거를 하나씩 옮기고, 전부 옮겨진 뒤에야 NATS 쪽 코드를 지웠다. 마지막 정리에서 core에서만 1500줄 넘게 지워졌다.

### NATS를 버린 것은 아니다

**이벤트 라인은 여전히 NATS로 돈다.** CSP가 보내는 사건을 받아 alert으로 묶고 티켓까지 가는 경로는 그대로다.

성격이 달라서다. 그쪽은 사건 하나가 짧게 끝나고, 순서를 보장할 필요도, 몇 시간짜리 작업을 이어붙일 필요도 없다. 오래 걸리는 여러 단계를 순서대로 엮어야 하는 것은 수집 쪽이었다.

## 3. 핵심 개념 여섯 가지

### Workflow

전체 순서를 적는 코드다. "계획을 받고, 그 결과로 작업을 펼치고, 다 끝나면 진단을 돌린다" 같은 흐름 자체를 담는다.

중요한 제약이 있다. 워크플로우 코드는 외부와 직접 대화하지 않는다. DB를 읽거나 API를 부르지 않는다. 오직 순서와 조건만 다룬다. 실제 일은 전부 아래 activity에 맡긴다.

### Activity

실제로 바깥 세상과 일하는 코드다. DB에 쓰고, CSP API를 호출하고, 파일을 읽는다. 실패할 수 있는 모든 것이 여기 들어간다.

워크플로우가 activity를 부르면 엔진이 그 호출을 기록한다. 그래서 재시도, 타임아웃, 실패 처리가 전부 엔진 관할이 된다.

### Worker

워크플로우와 activity 코드를 실제로 돌리는 프로세스다. 우리 파드가 곧 워커다.

여기서 자주 오해가 생긴다. **Temporal 서버는 코드를 실행하지 않는다.** 서버는 "다음에 뭘 해야 한다"는 지시만 관리하고, 그 지시를 워커가 가져다 자기 프로세스에서 실행한다. 그래서 워커가 하나도 없으면 워크플로우는 에러가 나는 게 아니라 그냥 아무 일도 일어나지 않는다.

### Task Queue

워커가 일감을 가져가는 줄이다. 이름표일 뿐이라 미리 만들 필요가 없고, 워커가 그 이름으로 폴링하면 그때부터 존재한다.

큐를 나누는 것이 곧 격리다. 우리가 큐를 여러 개 쓰는 이유가 여기 있다.

| 큐 | 누가 폴링하나 | 무엇이 도나 |
|---|---|---|
| `core-collect` | core 워커 | 워크플로우 전체와 core 쪽 activity |
| `plugin-aws` | AWS 플러그인 워커 | 리소스 수집 |
| `plugin-aws-plan` | AWS 플러그인 워커 | 수집 계획 수립 |
| `plugin-aws-heartbeat` | AWS 플러그인 워커 | 자격증명 검증 |

계획과 검증을 수집과 다른 큐로 뺀 이유는 실제 사고 때문이다. 큰 계정 하나가 수집 작업 수백 건을 큐에 쏟으면, 뒤에 들어온 계획 요청과 8초짜리 자격증명 검증이 순서를 기다리다 타임아웃 났다. 줄을 나누니 서로 굶기지 않는다.

### 실행 이력과 재생

Temporal이 신뢰성을 만드는 방식의 핵심이다.

워크플로우가 activity를 부를 때마다 엔진이 "무엇을 불렀고 무엇이 돌아왔는지"를 이력에 남긴다. 워커가 죽었다 살아나면, 엔진은 그 이력을 워커에게 다시 흘려보낸다. 워커는 워크플로우 코드를 **처음부터 다시 실행**하되, 이미 이력에 답이 있는 호출은 실제로 부르지 않고 기록된 값을 즉시 돌려준다. 이력이 끝나는 지점에 도달하면 그때부터 진짜 실행이 이어진다.

그래서 파드가 재시작돼도 작업이 처음부터 다시 돌지 않는다. 겉보기엔 멈춘 곳에서 이어지는 것처럼 보인다.

### 결정성

재생이 성립하려면 같은 입력에 같은 순서가 나와야 한다. 그래서 워크플로우 코드에는 제약이 붙는다.

쓰면 안 되는 것:

- 현재 시각 (`datetime.now()`)
- 난수
- 파일이나 네트워크 접근
- 매번 결과가 달라지는 모든 것

시각이 필요하면 `workflow.now()`를 쓴다. 이 값은 이력에 기록돼서 재생할 때 같은 값이 나온다.

Python SDK는 워크플로우 코드를 샌드박스에서 돌려 이런 호출을 막는다. 우리 코드에서 `with workflow.unsafe.imports_passed_through():` 로 감싼 import가 보이는 이유가 이것이다. 부작용 없는 모듈은 샌드박스 밖에서 그대로 가져다 쓰겠다는 표시다.

### 용어 대조표

이 문서는 평이한 말로 쓰여 있는데, 코드를 열거나 Temporal 화면을 보면 다른 이름이 나온다. 옮겨 다닐 때 참고한다.

**개념**

| 이 문서 | 코드와 화면 |
|---|---|
| 흐름, 실행 | Workflow |
| 작업 | Activity |
| 줄 | Task Queue |
| 실행 이력 | Event History |
| 재생 | Replay |
| 이름표 | Search Attribute |
| 자식 실행 | Child Workflow |
| 계기 정보 | Memo |
| 가로채기 | Interceptor |

**우리 흐름의 이름**

화면에서 이 이름으로 찾는다.

| 이 문서 | 화면에 나오는 이름 |
|---|---|
| 전체 실행 | `ResourceCollectionRun` |
| 계정별 수집 | `ResourceCollection` |
| 관계 정리 | `RelationshipExtractRun` |
| 매핑 | `MappingApplyRun` |
| 진단 | `WellArchitectedRun` |
| 자격증명 검증 | `CredentialValidationWorkflow` |
| 원장 보정 | `ReconcileWorkflowRunsWorkflow` |

**줄 이름**

| 이 문서 | 실제 이름 |
|---|---|
| core 줄 | `core-collect` |
| 수집 줄 | `plugin-aws` / `plugin-gcp` / `plugin-azure` |
| 계획 줄 | 위 이름 + `-plan` |
| 검증 줄 | 위 이름 + `-heartbeat` |

## 4. 지켜야 하는 원칙

Temporal은 대신 해주는 것이 많은 만큼 전제하는 것도 있다. 그 전제를 깨면 조용히 잘못 돈다. 아래는 코드를 쓸 때 반드시 지켜야 하는 것들이다.

### 원칙 1. activity는 여러 번 실행돼도 결과가 같아야 한다

가장 중요한 원칙이다.

Temporal은 activity를 마음대로 다시 부른다. 실패했을 때만이 아니다. **성공했는데도 다시 부를 수 있다.** activity가 일을 다 끝내고 결과를 돌려주는 도중에 워커가 죽으면, 엔진 입장에서는 그 일이 끝났는지 알 수 없다. 그래서 다시 시킨다.

그러니 "두 번 실행되면 값이 두 배가 되는" 코드는 반드시 깨진다. 우리 저장 단계가 이 원칙을 이렇게 지킨다.

```
같은 리소스면 새로 만들지 않고 덮어쓴다
키 = (CSP 원본 식별자, provider, 계정)
```

몇 번을 다시 돌려도 행이 늘지 않는다. 관계 정리와 관계 확정도 같은 이유로 여러 번 돌려도 안전하게 만들어져 있다.

**위험한 패턴은 이런 것들이다.**

| 이렇게 쓰면 안 된다 | 이렇게 쓴다 |
|---|---|
| 행을 새로 추가 | 키를 정해두고 덮어쓰기 |
| 값을 1 증가 | 최종 값을 계산해서 대입 |
| 알림을 보내고 끝 | 보냈다는 사실을 기록하고, 이미 보냈으면 건너뛰기 |
| 파일에 이어쓰기 | 정해진 이름으로 덮어쓰기 |

외부에 뭔가를 보내는 activity가 특히 위험하다. 저장은 덮어쓰면 되지만 이메일은 회수가 안 된다.

### 원칙 2. 워크플로우 코드는 항상 같은 순서를 만들어야 한다

워크플로우는 재생된다. 파드가 죽었다 살아나면 처음부터 다시 실행되면서, 이미 답이 있는 호출은 기록된 값으로 즉시 채워진다. 이게 성립하려면 **같은 입력에 항상 같은 순서**가 나와야 한다.

그래서 워크플로우 안에서는 이런 것들을 쓰면 안 된다.

- 현재 시각. `workflow.now()`를 쓴다
- 난수. 필요하면 입력으로 받는다
- 파일이나 네트워크 접근. activity로 뺀다
- 집합이나 사전을 순회하면서 순서에 의존하는 것

마지막 항목이 놓치기 쉽다. 우리 코드가 고객 목록을 만들 때 굳이 정렬하는 이유가 이것이다. 순서가 매번 달라지면 자식을 띄우는 순서도 달라지고, 재생할 때 기록과 어긋난다.

**시간 관련 실수가 특히 잦다.** 계정 수집을 마감할 때 찍는 시각도 `workflow.now()`로 만든다. 그냥 현재 시각을 쓰면 재생할 때마다 값이 달라져서 재생 자체가 깨진다.

### 원칙 3. 워크플로우와 activity의 역할을 섞지 않는다

| | 워크플로우 | activity |
|---|---|---|
| 하는 일 | 순서와 조건 | 실제 작업 |
| DB 접근 | 안 됨 | 됨 |
| 외부 호출 | 안 됨 | 됨 |
| 실패하면 | 재생됨 | 재시도됨 |

워크플로우에서 DB를 읽고 싶은 유혹이 생기는데, 그러면 재생할 때 그 값이 달라져 순서가 어긋난다. 읽어야 하면 activity로 빼서 결과를 받아야 한다. 그래야 그 값이 이력에 기록되고 재생 때 같은 값이 나온다.

### 원칙 4. 주고받는 값을 작게 유지한다

Temporal은 데이터를 나르는 통로가 아니다. 값 하나가 2MB를 넘으면 실행이 실패한다.

우리가 실제로 부딪힌 사례가 있다. 계정 하나의 작업 목록을 만들 때, 작업마다 자격증명을 복사해 넣었더니 큰 계정에서 한계를 넘었다. 지금은 자격증명을 한 벌만 두고 수집을 부르는 시점에 끼워 넣는다.

큰 값을 다뤄야 하면 세 가지 방법이 있다.

- 잘게 나눠 여러 번 주고받는다 (우리 저장 단계가 이 방식이다)
- 값 대신 식별자만 주고받고 받는 쪽이 조회한다
- 큰 값을 외부 저장소에 두는 기능을 켠다 (기본은 꺼짐)

### 원칙 5. 실행 식별자는 겹치면 안 된다

같은 이름의 실행이 이미 돌고 있으면 Temporal이 거부한다. 그래서 반복 실행되는 것에는 매번 다른 꼬리표를 붙인다.

우리 스케줄 수집이 실행마다 무작위 8자를 붙이는 이유다. 안 붙이면 두 번째 실행부터 시작 자체가 안 된다.

주의할 점이 하나 있다. 자식 실행 이름을 **부모 이름의 뒷자리에서 따온다.** 그래서 같은 뒷자리를 가진 부모를 다시 돌리면 자식 이름이 겹쳐서 부모가 실패한다. 지금은 무작위라 확률이 낮지만 구조적으로 남아 있는 위험이다.

### 원칙 6. 고칠 수 없는 실패는 재시도하지 않는다

기본 재시도는 무한이다. 횟수를 정하지 않으면 영원히 반복한다.

데이터 자체가 잘못돼서 몇 번을 다시 해도 같은 결과가 나오는 실패가 있다. 이런 것은 재시도 대상에서 빼야 한다. 우리 저장 단계가 그렇게 한다. 저장할 수 없는 데이터를 만나면 즉시 멈춘다.

이 구분이 없으면 고칠 수 없는 한 건 때문에 워커가 계속 같은 일을 반복하면서 다른 일을 못 하게 된다.

### 원칙 7. 워크플로우를 고칠 때 돌고 있는 것을 생각한다

이건 배포할 때의 문제다.

몇 시간 걸리는 실행이 도는 중에 워크플로우 코드를 바꿔 배포하면, 그 실행이 재생될 때 **새 코드로 옛 이력을 재생**하게 된다. 순서가 달라지면 재생이 깨진다.

안전한 변경과 위험한 변경이 갈린다.

| 안전 | 위험 |
|---|---|
| 로그 문구 수정 | activity 호출 순서 변경 |
| 타임아웃 값 조정 | activity 추가나 삭제 |
| activity 내부 구현 변경 | 조건 분기 변경 |

위험한 변경이 필요하면 돌고 있는 실행이 없는 시간을 고르거나, 버전을 나누는 기능을 써야 한다. 지금 우리 코드에는 버전 관리 장치가 없다. 야간 수집이 도는 시간대 배포를 피하는 것이 현재의 대응이다.

### 짧게 요약

1. activity는 여러 번 돌아도 결과가 같아야 한다
2. 워크플로우는 시각, 난수, 외부 접근을 쓰지 않는다
3. 순서만 워크플로우에, 일은 activity에
4. 주고받는 값은 작게
5. 실행 이름은 겹치지 않게
6. 고칠 수 없는 실패는 재시도에서 뺀다
7. 돌고 있는 실행이 있을 때 흐름을 바꾸지 않는다

## 5. 실패를 다루는 세 가지 손잡이

activity를 부를 때 셋을 지정한다.

**시간 제한.** 이 activity가 시작해서 끝날 때까지 허용하는 시간이다. 넘으면 실패로 보고 정책에 따라 재시도한다.

**재시도 횟수.** 몇 번까지 다시 해볼지다. 우리는 대부분 3회로 잡는다.

**되살릴 수 없는 실패.** 몇 번을 다시 해도 결과가 같은 실패가 있다. 데이터 자체가 제약을 위반한 경우가 그렇다. 이런 건 재시도하지 않고 즉시 멈추게 표시한다. 반대로 연결이 끊긴 것 같은 일시적 실패는 재시도에 맡긴다.

이 구분이 없으면 고칠 수 없는 한 건 때문에 워커가 영원히 같은 일을 반복한다.

## 6. 자식 워크플로우와 대기 정책

워크플로우가 다른 워크플로우를 띄울 수 있다. 우리는 계정 하나마다 자식을 하나씩 붙인다.

띄울 때 두 가지를 고른다.

**결과를 기다릴 것인가.** 기다리면 자식이 끝나야 다음 줄로 간다. 안 기다리면 띄워놓고 바로 진행한다.

**부모가 끝나면 자식을 어떻게 할 것인가.** 기본값은 부모가 끝날 때 자식도 종료시키는 것이다. 우리는 진단과 매핑에 `ABANDON`을 걸어 부모가 끝나도 자식이 계속 돌게 한다. 부모는 수집만 책임지고, 뒤따르는 처리는 독립적으로 끝나게 하려는 것이다.

이 선택이 실제 실행에서 어떻게 보이는지는 아래 로컬 실행 절의 타임라인에 나온다.

## 7. 검색 속성

실행에 이름표를 달아 나중에 조건으로 찾을 수 있게 하는 기능이다.

우리는 계정별 수집 결과를 `CollectStatus`라는 이름표로 단다. 값은 `success`, `warning`, `failed` 셋 중 하나다. 이걸 달아두면 실행을 하나씩 열어보지 않고 "어젯밤 실패한 계정만" 같은 조회가 된다.

```
CollectStatus='failed'
```

주의할 점이 있다. 커스텀 이름표는 클러스터에 미리 등록돼 있어야 한다. 등록 없이 값을 세팅하면 워크플로우가 계속 실패한다. 우리는 워커가 부팅할 때 스스로 등록하게 만들어서 이 문제를 없앴다. 등록이 실패해도 워커는 죽지 않는다.

## 8. 크기 제한과 대응

Temporal은 데이터를 나르는 통로가 아니라 순서를 지휘하는 장치다. 그래서 주고받는 값에 제한이 있다.

제한이 두 겹이라는 점을 먼저 알아야 한다.

**바깥층은 gRPC다.** 워커와 서버는 gRPC로 대화한다. 워커가 일감을 받아가는 것도, 결과를 돌려주는 것도 전부 gRPC 호출이다. 여기에 메시지 하나당 약 4MB라는 전송 한계가 있다. 이건 Temporal의 정책이 아니라 통신 방식 자체의 한계라, 설정으로 우회하기 어렵다.

**안쪽층은 Temporal이 정한 값 크기다.** 서버가 값을 받아 이력에 저장하기 전에 검사한다.

| 항목 | 경고 | 한계 |
|---|---|---|
| 값 하나 크기 | 256KB | 2MB |
| gRPC 메시지 하나 | 없음 | 약 4MB |
| 실행 이력 전체 크기 | 10MB | 50MB |
| 실행 이력 사건 수 | 10,240 | 51,200 |

값 하나가 2MB를 넘으면 서버가 거부한다. 그 전에 4MB를 넘으면 전송 자체가 안 된다. 앞의 것은 대응책이 있지만 뒤의 것은 값을 줄이는 수밖에 없다.

접속은 평문 gRPC다. 암호화 통신이나 인증 키를 다루는 코드가 없다.

우리는 두 가지로 대응했다.

**작업마다 바로 저장한다.** 계정 전체를 다 모아 한 번에 저장하지 않고, 수집 작업 하나가 끝나면 그 결과만 저장한다. 큰 계정에서 이력이 한계를 넘지 않게 하려는 것이다.

**큰 값은 밖에 둔다.** 임계값보다 큰 값은 별도 저장소에 넣고 실행 이력에는 그 위치만 남긴다. 받는 쪽이 필요할 때 꺼내 온다. 설정으로 켜고 끄며, 꺼져 있으면 값이 그대로 흐른다.

암호화도 같은 자리에 붙는다. 순서는 암호화를 먼저 하고 그 결과를 밖에 두는 쪽이라, 외부 저장소에는 암호문만 쌓인다.

### 이미 한계를 넘긴 작업이 있다

개발 환경 데이터를 실제로 재보니, 수집 작업 하나가 돌려주는 양이 값 크기 한계를 넘고 gRPC 한계에도 닿는 경우가 있었다. 권한 정보처럼 계정 전체를 한 번에 훑는 수집이 그렇다.

그런데 그 수집은 실패하지 않고 성공했다. 그렇다면 둘 중 하나가 이미 켜져 있다는 뜻이다.

- 큰 값을 밖에 두는 기능
- 서버 쪽에서 값 크기 한계를 올린 설정

어느 쪽인지는 배포 설정을 봐야 안다. **한계를 넘었다는 사실보다, 무엇이 받쳐주고 있는지 모른다는 쪽이 문제다.** 큰 값을 밖에 두는 쪽이라면 그 저장소가 죽었을 때 수집이 멈추고, 서버 설정을 올린 쪽이라면 gRPC 한계에 가까워질수록 위험해진다.

수집기는 리전 하나 결과를 통째로 돌려주고 나누거나 자르는 장치가 없다. 리소스가 늘어나면 이 값도 같이 늘어난다.

## 9. 로컬에서 띄우고 눈으로 보기

### 설치와 기동

```bash
brew install temporal
temporal server start-dev --db-filename ./.local/temporal-dev.db
```

`--db-filename`을 주면 껐다 켜도 실행 기록이 남는다. 생략하면 메모리에만 있다가 사라진다.

기동되면 두 개가 열린다.

| 용도 | 주소 |
|---|---|
| 화면 | http://localhost:8233 |
| 워커와 애플리케이션이 붙는 곳 | localhost:7233 |

우리 코드는 `TEMPORAL_ADDRESS`와 `TEMPORAL_NAMESPACE`를 읽는다. 기본값이 각각 `localhost:7233`과 `default`라서 로컬에서는 따로 설정할 게 없다.

### 실제 흐름을 화면에서 보기

`tools/local-trace.py`를 돌리면 운영과 똑같은 워크플로우 정의가 로컬 Temporal에 올라가 실행된다. DB도 CSP 자격증명도 필요 없다. 워크플로우는 진짜이고 activity만 가짜로 채운 것이라, 화면에 나오는 실행 트리와 순서는 실제 코드가 만들어낸 결과다.

```bash
cd <레포>/code/core
PYTHONPATH=src venv/bin/python <이 폴더>/tools/local-trace.py
```

돌리고 나면 화면에 이렇게 나온다.

![Temporal 화면의 실행 타임라인](temporal-ui-timeline.png)

이 그림에서 읽을 수 있는 것:

- 계정 목록을 가져오고, 전 계정을 한 번에 예약 표시한 뒤
- 계정 4개가 나란히 돌고
- 그 4개가 다 끝난 지점에서 진단과 관계 정리가 동시에 시작하고
- 매핑은 관계 정리가 끝난 뒤에야 시작한다

마지막 줄이 우리 순서 규칙의 근거다. 매핑이 관계 정리를 기다리는 이유는 관계 정리가 확정한 값을 매핑 조건이 쓰기 때문이다. 실행 시각으로도 확인된다.

```
관계 정리 종료  18.837
매핑 시작       18.851
```

### 자주 쓰는 명령

```bash
# 실행 목록
temporal workflow list

# 실패한 수집만 (검색 속성 활용)
temporal workflow list --query "CollectStatus='failed'"

# 실행 하나 자세히
temporal workflow describe --workflow-id <id>

# 실행 이력 전체
temporal workflow show --workflow-id <id>

# 등록된 검색 속성 확인
temporal operator search-attribute list
```

## 10. 화면에서 무엇을 보나

화면에 영어 용어가 많은데 대부분은 우리와 상관없다. 실제로 쓰는 것과 안 쓰는 것을 갈라서 정리한다.

### 왼쪽 메뉴

| 메뉴 | 무엇인가 | 우리가 쓰나 |
|---|---|---|
| Workflows | 실행 목록. 여기서 대부분의 일을 한다 | 항상 |
| Workers | 지금 붙어 있는 워커 목록 | 장애 볼 때 |
| Schedules | Temporal이 직접 관리하는 정기 실행 | 안 씀. 우리는 애플리케이션 크론을 쓴다 |
| Batch | 여러 실행을 한꺼번에 취소하거나 재시작 | 안 씀 |
| Standalone Activities | 워크플로우 없이 단독으로 도는 작업 | 안 씀 |
| Nexus | 다른 조직의 Temporal과 연결 | 안 씀 |
| Archive | 보관 기간이 지난 실행 저장소 | 설정 안 함 |
| Import | 실행 이력 파일을 가져와 열어보기 | 안 씀 |
| Namespaces | 실행을 나누는 논리 구획 | 우리는 하나만 쓴다 |

**절반 이상이 우리와 무관하다.** Workflows와 Workers만 알면 된다.

### 실행 목록의 열

| 열 | 무엇인가 |
|---|---|
| Status | 상태. Running, Completed, Failed, Terminated, Timed Out |
| Workflow ID | 우리가 정한 이름. 계정 ID 등이 들어 있어 검색에 쓴다 |
| Run ID | 시스템이 붙인 고유 번호. 같은 이름으로 다시 돌리면 이게 달라진다 |
| Type | 흐름 종류. `ResourceCollectionRun` 등 |
| Start / End | 시작과 종료 시각 |

**Workflow ID와 Run ID의 차이가 헷갈리기 쉽다.** 앞은 우리가 붙이는 이름이고 뒤는 실행 한 번마다 새로 생기는 번호다. 같은 이름으로 어제와 오늘 돌렸으면 Workflow ID는 같고 Run ID는 다르다.

### 실행 하나를 열면 나오는 정보

| 항목 | 무엇인가 | 볼 값어치 |
|---|---|---|
| Duration | 걸린 시간 | 평소보다 길면 의심 |
| Task Queue | 이 실행이 도는 줄 | 어느 워커가 처리하는지 |
| History Size | 실행 이력이 차지하는 크기 | 50MB에 가까우면 위험 |
| State Transitions | 상태가 바뀐 횟수 | 이력이 얼마나 복잡한지 |
| Workflow SDK | 어떤 언어와 버전으로 돌았는지 | 거의 안 봄 |
| Input / Result | 넘긴 값과 돌려준 값 | **가장 먼저 볼 것** |

수집 실행이면 Result에 판정이 다 들어 있다. 저장 건수, 경고 건수, 실패 건수를 여기서 바로 읽는다.

### 탭

| 탭 | 무엇인가 | 언제 |
|---|---|---|
| Timeline | 단계별 시작과 종료를 막대로 | 순서와 병렬 구조를 볼 때 |
| Event History | 일어난 모든 사건을 시간순으로 | 왜 실패했는지 파고들 때 |
| Relationships | 부모와 자식 실행 관계 | 후처리가 떴는지 확인할 때 |
| Workers | 이 줄을 받아가는 워커 | **아무 일도 안 일어날 때 제일 먼저** |
| Pending Activities | 지금 걸려 있는 작업과 다음 재시도 시각 | 멈춘 것 같을 때 |
| Search Attributes | 이 실행에 붙은 이름표 | 수집 결과 등급 확인 |
| Memo | 시작할 때 실어 보낸 부가 정보 | 무엇이 이 실행을 시작했는지 |
| Call Stack | 지금 워크플로우가 코드 어디에 멈춰 있는지 | 돌고 있을 때만 값이 있다 |
| Queries | 돌고 있는 실행에 상태를 물어보기 | 안 씀 |
| User Metadata | 개발자가 실행에 붙인 설명 | 안 씀 |

### Timeline에서 읽는 법

막대 색과 표시가 정보다.

- 초록 막대는 성공, 빨간 막대는 실패
- 막대 앞의 `↻3`은 세 번 재시도했다는 뜻
- 나란히 놓인 막대는 동시에 돈 것
- 아래로 들여쓰기된 막대는 자식 실행

수집 실행을 열면 계정별 실행이 나란히 놓이고, 그 막대들이 끝나는 지점에서 후처리 막대가 시작하는 것이 보인다. 그게 분기점이다.

### 장애일 때 보는 순서

이 순서가 중요하다.

| 순서 | 탭 | 판단 |
|---|---|---|
| 1 | Workers | 그 줄을 받는 워커가 있나 |
| 2 | Pending Activities | 뭐가 걸려 있고 다음 재시도가 언제인가 |
| 3 | Event History | 마지막에 무슨 일이 있었나 |

1번이 먼저인 이유는 **워커가 없으면 오류가 아니라 대기**이기 때문이다. 실행은 Running인 채로 아무 일도 일어나지 않는다. 이 상황이 제일 헷갈린다.

### 버튼 두 개

| 버튼 | 무엇을 하나 |
|---|---|
| Reset | 특정 시점으로 되돌려 다시 실행 |
| More Actions | 취소, 강제 종료 등 |

**둘 다 함부로 누르지 않는다.** Reset은 그 지점 이후를 다시 실행하므로 저장이 다시 일어난다. 우리 저장은 덮어쓰기라 안전하긴 하지만, 흐름 전체가 다시 도는 것은 별개 문제다. Terminate는 정리 없이 즉시 끊는다.

## 11. 무엇이 어디서 도나

### 프로세스 세 종류

core 저장소는 이미지 하나인데, 시작 명령과 설정에 따라 세 가지 역할로 뜬다.

| 역할 | 시작 명령 | 하는 일 |
|---|---|---|
| API 서버 | `python -m cloudops.core.main` | REST 요청 처리, NATS 구독 |
| 스케줄러 | 같은 명령 + 스케줄 켜기 | 정해진 시각에 흐름 시작 |
| Temporal 워커 | `python -m cloudops.core.worker` | 워크플로우와 activity 실행 |

이미지의 기본 시작 명령은 API 서버다. 워커로 띄우려면 배포 쪽에서 명령을 바꿔야 한다.

수집기는 별도 이미지이고 시작 명령이 하나다. 그 프로세스 하나가 안에서 워커를 셋 만들어 각각 다른 줄을 받는다.

### 스케줄러가 켜지는 조건

스케줄러는 기본이 꺼짐이다. 두 가지가 다 갖춰져야 실제로 돈다.

1. 그 파드에 스케줄 켜기 설정이 들어가 있을 것
2. 돌릴 작업이 담긴 모듈 경로가 목록에 등록돼 있을 것

두 번째를 놓치기 쉽다. 설정만 켜고 목록에 안 넣으면 스케줄러는 뜨지만 등록된 작업이 0개다. 조용히 아무것도 안 한다.

배포에서는 스케줄러 전용으로 파드를 따로 하나 띄우고 거기서만 켠다. **개수가 1개여야 한다.** 여러 개면 같은 시각에 여러 번 시작된다. 실행 이름에 무작위 꼬리표를 붙이기 때문에 Temporal이 중복으로 막아주지도 않는다.

그 파드는 NATS 구독을 일부러 건너뛴다. 같은 이미지라 그냥 두면 API 서버들과 같은 소비자 그룹에 끼어들어서, 이벤트 처리 일부가 스케줄러 쪽으로 흘러가기 때문이다.

시각 기준은 서울이다. 수집은 매일 01:00에 시작한다.

### 셋의 관계

```
스케줄러 (1개)       매일 01:00에 흐름 시작
      ↓
Temporal 서버        무엇을 할 차례인지 관리
      ↓
core 워커            흐름을 실행하고 DB 작업 수행
      ↓
수집기 워커          CSP API 호출
```

스케줄러는 시작만 시키고 빠진다. 결과를 기다리지 않는다. 실제 일은 전부 워커가 한다.

**워커가 없으면 조용히 멈춘다.** 스케줄러가 시작을 걸어도 워커가 없으면 오류가 나지 않고 실행이 대기 상태로 남는다. Temporal 화면의 Workers 탭을 가장 먼저 보라고 한 이유가 이것이다.

### 확인할 때

| 안 도는 것 | 볼 곳 |
|---|---|
| 야간 수집이 아예 안 시작됨 | 스케줄러 파드가 떠 있나, 스케줄이 켜져 있나, 작업 목록에 등록됐나 |
| 시작은 됐는데 진행이 없음 | core 워커가 떠 있나 |
| 계획이나 수집에서 멈춤 | 해당 CSP 수집기 워커가 떠 있나 |

### 파드가 죽을 때 무슨 일이 생기나

워커 설정에 "종료 유예 30초"라는 값이 있다. 이 숫자가 왜 30초인지, 왜 쿠버네티스와 엮이는지는 파드가 죽는 절차를 알아야 이해된다.

**쿠버네티스가 파드를 죽이는 순서**

배포, 재시작, 노드 정리 등으로 파드를 내릴 때 이 순서로 진행된다.

1. 파드에 삭제 표시가 붙고 **유예 시간 카운트다운이 시작된다**
2. 서비스 목록에서 그 파드가 빠진다 (새 요청이 안 들어옴)
3. 컨테이너에 종료 신호를 보낸다
4. 유예 시간이 끝나면 **강제 종료한다**

여기서 중요한 것은 4번이다. 유예 시간이 지나면 프로세스가 뭘 하고 있든 즉시 죽는다. 정리할 기회가 없다.

기본 유예 시간이 30초다.

**워커가 종료 신호를 받으면**

3번에서 신호를 받은 워커는 이렇게 한다.

1. 새 일감 받기를 멈춘다
2. 이미 받아서 돌고 있는 일이 끝나기를 기다린다
3. 설정한 시간이 지나도 안 끝나면 취소한다

2번에 허용하는 시간이 그 "종료 유예 30초"다. 이 값과 쿠버네티스 유예 시간의 관계가 전부다.

```
쿠버네티스 유예 30초    |--------------------------|  → 강제 종료
워커 드레인 30초        |--------------------------|  → 정리 완료
                                                   ↑ 여유 0
```

**둘이 같으면 여유가 없다.** 코드 주석은 "쿠버네티스 기본값보다 짧게 잡아 강제 종료 전에 정리가 끝나도록" 정했다고 설명하는데, 실제로는 둘 다 30초라 같은 값이다. 워커가 30초를 꽉 채워 정리하는 순간이 강제 종료 시각과 겹친다.

**정리를 못 하고 죽으면 무엇이 문제인가**

데이터가 사라지지는 않는다. Temporal이 재시도한다. 문제는 **언제 재시도하느냐**다.

서버는 워커가 죽었다는 것을 바로 알지 못한다. 일감을 가져간 워커가 응답이 없으면, 그 작업에 걸어둔 시간 제한이 지날 때까지 기다린다. 그때가 되어야 실패로 판정하고 다시 시킨다.

수집 작업의 시간 제한이 30분이다. 그러니 배포 중에 수집이 강제 종료되면 **그 작업은 최대 30분을 죽은 채로 기다렸다가** 재시도된다.

**진행 신호를 안 보내는 것과 맞물린다**

Temporal에는 작업이 살아 있다고 주기적으로 알리는 기능이 있다. 이걸 쓰면 서버가 워커의 죽음을 훨씬 빨리 알아채고 바로 재시도한다.

우리 수집 작업은 이 신호를 보내지 않는다. 의도적이다. 신호를 안 보내는데 신호 기준 제한을 걸면 정상 수집도 오탐으로 끊기기 때문이다.

그래서 지금은 **워커의 죽음을 알아채는 수단이 30분 시간 제한 하나뿐이다.** 여유 없는 종료 유예와 이 둘이 겹치면, 배포 한 번에 계정 여럿이 30분씩 멈춰 있게 된다.

**어떻게 하면 되나**

셋 중 하나다.

| 방법 | 효과 |
|---|---|
| 워커 드레인 시간을 줄인다 | 강제 종료 전에 정리가 끝날 여유가 생김 |
| 파드 유예 시간을 늘린다 | 같은 효과. 다만 배포가 느려짐 |
| 진행 신호를 보내게 한다 | 죽음을 빨리 알아채 재시도가 빨라짐 |

앞의 둘은 설정 하나만 바꾸면 되고, 마지막은 수집기 코드를 손봐야 한다. 코드에도 나중 과제로 적혀 있다.

**지금 문제가 안 드러난 이유**

실제 수집 작업이 30초 안에 끝나는 경우가 많아서일 가능성이 크다. 다만 큰 계정의 무거운 수집은 몇 분씩 걸리므로, 그때 배포가 겹치면 드러난다.

### 횡단 처리가 절반만 적용돼 있다

Temporal은 여러 워커에 걸치는 공통 관심사를 한 곳에 모아 처리할 자리를 준다. 우리는 그 자리를 절반만 썼다.

| 관심사 | 상태 |
|---|---|
| 값 암호화, 큰 값 외부화 | 한 곳에 모여 있음. 모든 접속이 그 자리를 거친다 |
| 실행 이력 원장 | 한 곳에 모여 있음. 워크플로우 코드를 고치지 않고 붙였다 |
| 검색 속성 등록 | 한 곳에 모여 있음 |
| 워커 실행 표준화 | core만 공통 함수를 쓴다. 수집기 셋은 각자 복제했다 |
| 실행 가로채기 | core만 쓴다. 수집기는 없다 |
| 로그 추적 식별자 | 아무도 안 한다 |

**수집기가 공통 함수를 안 쓰는 데는 이유가 있다.** 공통 함수는 부를 때마다 종료 신호 처리기를 설치하는데, 한 프로세스에서 세 번 부르면 뒤가 앞을 덮어써서 첫 워커가 종료 신호를 못 받는다. 그래서 수집기가 손으로 복제했다. 취향 문제가 아니라 공통 쪽에 "한 프로세스에 워커 여럿" 지원이 없어서 생긴 이탈이다.

지금은 값이 우연히 같아서 동작도 같다. 공통 쪽 기본값을 바꾸거나 공통 가로채기를 붙이면 core만 반영되고 수집기 셋은 그대로 남는다.

**로그 추적 식별자가 Temporal 경로에만 없다.** 스케줄러와 NATS 진입점은 요청마다 식별자를 붙여 로그 한 줄로 추적할 수 있게 감싸는데, Temporal 쪽은 감싸지 않았다. 그래서 수집이 왜 멈췄는지를 로그로 따라가기가 어렵다.

### 아직 확인 못 한 것

배포 설정 파일이 core 저장소 안에 없다. 그래서 아래는 코드 기본값과 주석으로만 알 수 있고, 실제 운영 값은 배포 저장소를 봐야 한다.

- Temporal 서버가 어디에 떠 있는지
- core 워커를 몇 개 띄우는지
- 값 암호화와 큰 값 외부 저장이 켜져 있는지

접속 설정에 TLS나 인증 키를 다루는 코드가 없다. 평문 연결만 지원한다는 뜻이라, 관리형 서비스보다는 자체 운영 쪽에 가까워 보이지만 확증은 아니다.

## 12. 우리 환경 기준값

| 항목 | 값 |
|---|---|
| Python SDK | temporalio 1.30.0 (요구사항 1.29 이상 2 미만) |
| 로컬 서버 | 1.31.2 |
| 로컬 화면 | 2.50.1 |
| CLI | 1.8.1 |
| 접속 기본값 | localhost:7233, 네임스페이스 default |
| 접속 방식 | 평문 gRPC (TLS나 API 키 배선 없음) |
| 워커 종료 유예 | 30초 |
| 워커 동시 activity 상한 | 100 |
| 큰 값 외부 저장 임계값 | 256KB (기본 꺼짐) |
| 값 암호화 | AES-256-GCM (기본 꺼짐) |

접속 주소와 네임스페이스는 `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`로 바꾼다. 동시 activity 상한은 `TEMPORAL_MAX_CONCURRENT_ACTIVITIES`다.

암호화와 큰 값 외부 저장은 둘 다 기본이 꺼짐이다. 켜지 않으면 값이 평문으로 실행 이력에 그대로 들어간다. 수집 작업에는 CSP 자격증명이 실려 다니므로, 운영 환경에서 이게 켜져 있는지는 확인이 필요하다. 저장소 코드만으로는 알 수 없다.

종료 유예 30초가 왜 그 값이고 무엇이 걸려 있는지는 11절의 "파드가 죽을 때 무슨 일이 생기나"에 정리해뒀다.

### 아직 안 쓰는 기능

Temporal이 제공하지만 우리가 쓰지 않는 것들이다. 설계를 논의할 때 선택지로 올릴 수 있어서 적어둔다.

| 기능 | 무엇을 하나 | 우리에게 쓸모 |
|---|---|---|
| 스케줄 | Temporal이 직접 시각을 관리한다 | 지금은 애플리케이션 크론을 쓴다. 파드가 여러 개면 중복 실행을 막아주지 못하는데, 이걸 쓰면 해결된다 |
| 버전 나누기 | 흐름을 바꿔도 돌던 실행은 옛 순서로 끝낸다 | 배포 시간대를 피할 필요가 없어진다 |
| 신호 | 돌고 있는 실행에 밖에서 값을 넣는다 | 진행 중인 수집을 멈추거나 범위를 바꾸는 용도 |
| 조회 | 돌고 있는 실행의 내부 상태를 물어본다 | "지금 몇 번째 계정 하는 중인지" 같은 것 |
| 이어가기 | 이력이 길어지면 새 실행으로 넘긴다 | 계정이 수백 개로 늘면 이력 한계에 걸릴 수 있다 |
| 진행 신호 | 작업이 살아 있다고 주기적으로 알린다 | 워커의 죽음을 30분 기다리지 않고 알아챈다 |

이 중 스케줄은 주기 실행 배선을 나중에 하겠다고 코드에 적혀 있다. 진행 신호도 나중 과제로 적혀 있다. 나머지 넷은 언급이 없다.
