19장. MLOps 와 LLMOps, 워크플로 자동화
실습 1 — 작은 워크플로 실행기 만들기
Airflow 를 이 PC 에서 돌릴 수 없으니, DAG 의 핵심(순서, 다시 시도, 건너뛰기, 기록)만 담은 실행기를 파이썬으로 만듭니다. 실행기는 out_flow.py 파일로 저장해 실습 3, 6과 프로젝트가 불러 씁니다. 하늘숲 도서관 안내 문서(library/day0/)는 지어낸 연습용 데이터입니다.
1단계. 작업 순서 정하기
from graphlib import CycleError, TopologicalSorter
# 밤마다 도서관 안내 문서로 색인을 만드는 작업들: {작업: [먼저 끝나야 하는 작업]}
graph = {"문서 읽기": [], "검사": ["문서 읽기"], "조각 나누기": ["검사"],
"임베딩": ["조각 나누기"], "교체": ["임베딩"], "보고": ["조각 나누기"]}
# static_order(): 앞 작업이 늘 먼저 오도록 줄 세운다
print("차례:", list(TopologicalSorter(graph).static_order()))
# 실수로 문서 읽기가 교체 뒤에 오게 적으면 되돌아오는 길(순환)이 생긴다
bad = dict(graph, **{"문서 읽기": ["교체"]})
try:
list(TopologicalSorter(bad).static_order())
except CycleError as e:
# 결과: 순환을 이루는 작업들
print("순환:", e.args[1])차례: ['문서 읽기', '검사', '조각 나누기', '임베딩', '보고', '교체']
순환: ['문서 읽기', '검사', '조각 나누기', '임베딩', '교체', '문서 읽기']2단계. 실행기를 파일로 만들기
from pathlib import Path
# 실습 3, 6과 프로젝트가 불러 쓸 실행기를 파일로 만든다
Path("out_flow.py").write_text('''"""작은 워크플로 실행기(19장). Airflow 의 DAG 실행을 흉내 낸다."""
import time
from graphlib import TopologicalSorter
class Stop(Exception):
"""멈춤: 고장이 아니라 '더 갈 필요가 없다'는 신호. 다시 시도하지 않고 뒤 작업을 건너뛴다."""
def run_flow(tasks, retries=1, wait=0.0, log=print):
"""tasks: {이름: (함수, [앞 작업 이름, ...])}. 함수는 ctx(앞 작업들이 돌려준 값을 이름으로 담은 사전)를 받는다.
돌려주는 값: (작업마다의 기록 목록, ctx)"""
order = list(TopologicalSorter({n: deps for n, (f, deps) in tasks.items()}).static_order())
ctx, state, records = {}, {}, []
for name in order:
func, deps = tasks[name]
if any(state[d] != "성공" for d in deps): # 앞 작업이 하나라도 성공하지 않았으면 건너뛴다
state[name] = "건너뜀"
records.append({"작업": name, "상태": "건너뜀", "시도": 0, "초": 0.0, "메모": ""})
log(f"[건너뜀] {name}")
continue
for attempt in range(1, retries + 2): # 처음 한 번 + 다시 시도 retries 번
start = time.perf_counter()
try:
ctx[name] = func(ctx)
state[name], memo = "성공", ""
except Stop as e:
state[name], memo = "멈춤", str(e)
except Exception as e:
state[name], memo = "실패", f"{type(e).__name__}: {e}"
sec = round(time.perf_counter() - start, 2)
log(f"[{state[name]}] {name} ({attempt}번째 시도)" + (f" · {memo}" if memo else ""))
if state[name] != "실패" or attempt == retries + 1:
break
time.sleep(wait)
records.append({"작업": name, "상태": state[name], "시도": attempt, "초": sec, "메모": memo})
return records, ctx
''', encoding="utf-8")
import sys
sys.path.insert(0, ".") # 작업 폴더(data)에 만든 out_flow.py 를 찾게 한다
from out_flow import Stop, run_flow
# 결과: 불러온 함수 이름
print(run_flow.__name__, Stop.__name__)run_flow Stop3단계. 정상 흐름 돌리기
import pandas as pd
# 작업 함수는 모두 ctx 하나를 받는다. 앞 작업의 결과는 ctx["앞 작업 이름"] 으로 꺼낸다
def read_docs(ctx):
files = sorted(Path("library/day0").glob("*.txt"))
return {f.stem: f.read_text(encoding="utf-8") for f in files}
def check(ctx):
docs = ctx["문서 읽기"]
empty = [n for n, t in docs.items() if not t.strip()]
if empty: # 빈 문서가 있으면 고장이 아니라 '멈춤'
raise Stop(f"빈 문서 {empty}")
return len(docs)
def split(ctx):
# 문서를 빈 줄로 나누고, 첫 문단(제목)을 뺀 문단 하나를 조각 하나로 본다
return [p for t in ctx["문서 읽기"].values() for p in t.split("\n\n")[1:] if p.strip()]
def report(ctx):
return f"문서 {ctx['검사']}개, 조각 {len(ctx['조각 나누기'])}개"
tasks = {"문서 읽기": (read_docs, []), "검사": (check, ["문서 읽기"]),
"조각 나누기": (split, ["검사"]), "보고": (report, ["조각 나누기"])}
records, ctx = run_flow(tasks)
# 결과: 작업마다의 상태와 시도 횟수, 보고 작업이 돌려준 글
print(pd.DataFrame(records)[["작업", "상태", "시도"]].to_string(index=False))
print(ctx["보고"])[성공] 문서 읽기 (1번째 시도)
[성공] 검사 (1번째 시도)
[성공] 조각 나누기 (1번째 시도)
[성공] 보고 (1번째 시도)
작업 상태 시도
문서 읽기 성공 1
검사 성공 1
조각 나누기 성공 1
보고 성공 1
문서 10개, 조각 29개4단계. 실패하면 다시 시도하고, 멈추면 건너뛰기
# 공유 폴더가 첫 번째에만 끊기는 상황을 흉내 낸다(씨앗 대신 횟수로 정해 늘 같게)
calls = {"수": 0}
def flaky_read(ctx):
calls["수"] += 1
if calls["수"] == 1:
raise ConnectionError("공유 폴더 연결이 끊겼습니다")
return read_docs(ctx)
# 다시 시도 1번(retries=1): 첫 시도가 실패해도 두 번째에 성공한다
records, _ = run_flow(dict(tasks, **{"문서 읽기": (flaky_read, [])}), retries=1)
print(pd.DataFrame(records)[["작업", "상태", "시도"]].to_string(index=False))
# 빈 파일이 섞인 밤(day3)의 문서: 검사가 '멈춤'을 내고 뒤 작업은 건너뛴다
def read_day3(ctx):
return {f.stem: f.read_text(encoding="utf-8") for f in sorted(Path("library/day3").glob("*.txt"))}
records, _ = run_flow(dict(tasks, **{"문서 읽기": (read_day3, [])}))
# 결과: 검사는 멈춤(다시 시도하지 않는다), 뒤 두 작업은 건너뜀
print(pd.DataFrame(records)[["작업", "상태", "시도", "메모"]].to_string(index=False))[실패] 문서 읽기 (1번째 시도) · ConnectionError: 공유 폴더 연결이 끊겼습니다
[성공] 문서 읽기 (2번째 시도)
[성공] 검사 (1번째 시도)
[성공] 조각 나누기 (1번째 시도)
[성공] 보고 (1번째 시도)
작업 상태 시도
문서 읽기 성공 2
검사 성공 1
조각 나누기 성공 1
보고 성공 1
[성공] 문서 읽기 (1번째 시도)
[멈춤] 검사 (1번째 시도) · 빈 문서 ['분실물']
[건너뜀] 조각 나누기
[건너뜀] 보고
작업 상태 시도 메모
문서 읽기 성공 1
검사 멈춤 1 빈 문서 ['분실물']
조각 나누기 건너뜀 0
보고 건너뜀 0이 실습이 뜻하는 것
이 실습은 김장하는 날의 순서표와 반장을 만드는 것과 같습니다. 할 일과 "이것이 끝나야 저것을 한다"를 적어 줄을 세우고, 적다가 처음으로 돌아오는 고리가 생기면 알아챕니다(1단계). 순서표대로 일을 시키는 반장의 규칙을 공책에 적어 두어 다른 날에도 부릅니다(2단계). 평소처럼 네 가지 일을 차례로 시켜 보고(3단계), 수도가 한 번 끊기면 다시 틀어 보고, 배추가 상했으면 그 자리에서 멈추고 뒤 일은 하지 않습니다(4단계). 여기서 순서표가 DAG(Directed Acyclic Graph, 방향이 있고 고리가 없는 그래프), 반장이 run_flow(), 공책이 out_flow.py, 다시 틀어 보기가 다시 시도(retries), 그 자리에서 멈추기가 Stop 입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 차례 | ['문서 읽기', '검사', '조각 나누기', '임베딩', '보고', '교체'] | 앞뒤 관계를 지키는 차례입니다. 보고와 교체는 서로 기다리지 않으므로 어느 쪽이 먼저 와도 됩니다 |
| 1. 순환 | [..., '교체', '문서 읽기'] | 문서 읽기가 교체를 기다리게 잘못 적으면 처음으로 돌아오는 고리가 생겨, 차례를 정할 수 없다고 알려 줍니다 |
| 2. 파일로 | run_flow Stop | 실행기를 out_flow.py 로 저장하고 다시 불러왔습니다. 실습 3, 6과 프로젝트가 이 파일을 씁니다 |
| 3. 정상 흐름 | 네 작업 모두 성공, 시도 1 | 문제가 없는 밤에는 모든 작업이 한 번에 끝납니다 |
| 3. 보고 | 문서 10개, 조각 29개 | day0 의 문서 10개가 문단 조각 29개로 나뉘었습니다 |
| 4. 다시 시도 | 문서 읽기 성공, 시도 2 | 첫 시도의 연결 끊김(ConnectionError)을 두 번째 시도가 넘겼습니다 |
| 4. 멈춤 | 검사 멈춤 · 빈 문서 ['분실물'] | day3 에는 빈 파일이 있어 검사가 멈췄고, 다시 해도 같으므로 다시 시도하지 않았습니다(시도 1) |
| 4. 건너뜀 | 조각 나누기, 보고 건너뜀, 시도 0 | 앞 작업이 성공하지 않았으니 뒤 작업은 아예 돌지 않았습니다 |
한 줄로 줄이면, Airflow 가 해 주는 일(차례, 다시 시도, 멈추면 건너뛰기, 기록)을 파이썬 몇십 줄로 흉내 낸 실습입니다. 일시적인 고장은 다시 시도로, 나쁜 데이터는 멈춤으로 나눠 다룬다는 것이 핵심입니다. 이 실행기는 실습 3의 색인 갱신, 실습 6의 학습과 배포 파이프라인, 프로젝트의 밤 작업이 그대로 불러 씁니다. 정해진 시각에 저절로 도는 일정 기능은 없으므로, 그것은 프로젝트 6단계의 Airflow 안내 코드에서 봅니다.
코드 설명
TopologicalSorter(graph).static_order()는 앞뒤 관계를 지키는 차례를 돌려주고, 순환이 있으면CycleError를 냅니다.run_flow()는 차례대로 작업을 돌리며, 앞 작업이 "성공"이 아니면 뒤 작업을 "건너뜀"으로 둡니다. Airflow 의 기본 규칙(앞이 모두 성공해야 돈다)과 같습니다.- 작업이 예외를 내면
retries만큼 다시 시도합니다. 일시적인 끊김은 다시 시도로 넘어갑니다. Stop은 고장이 아닌 "멈춤"입니다. 데이터가 나쁘다는 판단은 다시 시도해도 같으므로 바로 멈추고 뒤를 건너뜁니다. Airflow 의 단락 작업이 같은 일을 합니다.- 작업은 결과를 돌려주고, 실행기가
ctx에 이름으로 담아 뒤 작업에 넘깁니다.