
마이그레이션 에이전트가 12개 파일 중 7번째 파일에서 중단되면 어떻게 될까?
요약
마이그레이션 에이전트의 실행 중단 문제를 해결하기 위해 EFS와 DynamoDB를 활용한 재개(resume) 메커니즘을 설명합니다. Lambda의 시간 제한을 극복하기 위해 Fargate 워커를 사용하며, 파일 시스템과 그래프 상태를 분리하여 저장함으로써 작업의 내구성을 확보합니다.
핵심 포인트
- Lambda의 15분 제한을 피하기 위해 Fargate를 워커로 활용
- EFS를 통해 편집 중인 리포지토리의 상태 유지
- DynamoDB와 LangGraph 체크포인트를 이용한 그래프 상태 저장
- 중단된 지점부터 작업을 재개하여 비용과 시간 낭비 방지
마이그레이션 에이전트(migration agent)는 실행당 실제 비용과 실제 시간을 소모합니다. 에이전트는 리포지토리(repo)를 복제하고, 의존성(deps)을 설치하며, 파일 순서를 계획한 다음, 파일을 하나씩 다시 작성하며 각 파일마다 대상의 테스트 스위트(test suite)를 실행합니다. 중간 규모의 리포지토리에서는 수많은 Bedrock 호출과 상당한 실제 시간(wall-clock time)이 소요됩니다.
그래서 제가 초기에 답해야 했던 질문이 있었습니다. 만약 프로세스가 12개 파일 중 7번째 파일에서 중단되면 어떻게 될까요?
만약 답이 "처음부터 다시 시작"이라면, 그 에이전트는 장난감에 불과합니다. 당신은 이미 6개의 마이그레이션된 파일과 각 파일에 대해 통과한 테스트 스위트에 대한 비용을 지불했는데, 스팟 회수(spot reclamation)나 재배포(redeploy)가 이 모든 것을 버리게 되기 때문입니다. 더 나쁜 것은 API 측이 15분의 엄격한 제한이 있는 Lambda에서 실행된다는 점입니다. 실제 마이그레이션은 이 제한을 훨씬 초과합니다. 따라서 장기적인 작업(long-horizon work)은 요청이 들어오는 곳에 머물 수 없으며, 작업을 수행하는 주체는 중단되더라도 살아남아야 합니다.
다음은 RepoModernizer가 이를 처리하는 방식과, 재개(resume) 기능을 도입했을 때 예상치 못하게 발생했던 버그에 대한 내용입니다.
분리: Lambda의 API와 Fargate의 워커(worker)
API는 API Gateway 뒤에서 실행되는 FastAPI 앱이며, Mangum을 통해 Lambda에서 실행됩니다. API는 요청을 받고, 중요한 데이터를 아무것도 쓰지 않은 채, SQS에 메시지를 던집니다. 그게 전부입니다. API는 마이그레이션을 절대 실행하지 않습니다.
SQS 컨슈머(consumer) Lambda가 메시지를 읽고 ecs.run_task를 호출하여 일회성 Fargate 태스크를 실행합니다. 이 Fargate 태스크가 워커(worker)입니다. 이 워커는 LangGraph 파이프라인을 완료할 때까지(또는 중단될 때까지) 실행한 후 종료됩니다. 상주하는(warm) 상태로 유지되는 것은 아무것도 없습니다. 유휴 상태인 것이 없으므로 유휴 비용(idle cost)은 0입니다.
# consumer_handler.py — SQS 메시지를 Fargate 실행으로 전환하는 Lambda
_ecs.run_task(
cluster=os.environ["ECS_CLUSTER"],
...
워커를 Lambda가 아닌 Fargate에 두는 전체적인 이유는 바로 15분 제한 때문입니다. Lambda는 요청을 처리하기에는 적합합니다. 하지만 30분 동안 실행될 수도 있는 작업에는 적합하지 않습니다.
두 가지 장소에 존재하는 두 가지 종류의 내구성(durable)
워커가 중단되었을 때 잃어서는 안 되는 두 가지가 있습니다. 바로 편집 중인 리포지토리(repo)와 어디까지 진행되었는지를 나타내는 그래프 상태(graph state)입니다. 이들은 의도적으로 두 개의 서로 다른 저장소에 저장됩니다.
저장소(repo)는 /mnt/workspace에 Fargate 태스크로 마운트된 EFS에 존재합니다. 태스크는 그곳에 대상 저장소를 클론(clone)하고 브랜치를 생성하며, 적용되는 모든 디프(diff)는 해당 파일 시스템에 저장됩니다. 새로운 태스크가 동일한 작업을 이어받을 때, 파일들은 마지막 실행이 남겨둔 상태 그대로 이미 그곳에 놓여 있습니다.
그래프 상태(graph state)는 DynamoDB에 저장됩니다. LangGraph 파이프라인이 완료하는 모든 노드(node)는 체크포인트(checkpoint)로 기록됩니다. 이를 통해 새로운 프로세스는 이미 데이터를 수집했고, 의존성(deps)을 설치했으며, 0번부터 6번 파일까지 마이그레이션을 완료했으므로 7번 파일부터 재개해야 한다는 것을 알 수 있습니다.
이 둘을 분리하는 것이 중요합니다. DynamoDB는 계획(plan), 커서(cursor), 그리고 파일별 상태를 보유합니다. EFS는 실제 바이트(bytes)를 보유합니다. 어느 하나만으로는 재개하기에 충분하지 않습니다. 어디에 있었는지 알기 위한 상태(state)와 이미 수행한 작업물을 갖기 위한 파일 시스템(filesystem)이 모두 필요합니다.
체크포인터(checkpointer)는 단순한 사이드 테이블이 아닌 진정한 LangGraph 세이버(saver)입니다
LangGraph에는 BaseCheckpointSaver라는 체크포인터 인터페이스가 있습니다. 기본적으로 제공되는 옵션은 인메모리(in-memory) 세이버(프로세스 간에는 무용지물임)와 사용자가 원할 수도 있고 원하지 않을 수도 있는 통합 기능들입니다. 저는 DynamoDB가 이미 스택에 포함되어 있었고 제가 제어할 수 있는 단일 테이블 액세스 패턴(single-table access patterns)을 원했기 때문에, DynamoDB를 직접 백엔드로 사용하는 세이버를 작성했습니다.
핵심은 put과 get_tuple입니다. put은 LangGraph 자체의 JsonPlusSerializer를 사용하여 체크포인트를 직렬화(serialize)하고, 태스크별 파티션 키(partition key) 아래에 기록합니다:
def put(self, config, checkpoint, metadata, new_versions):
thread_id = config["configurable"]["thread_id"]
checkpoint_id = checkpoint["id"]
...
get_tuple은 그 반대 작업을 수행합니다. 태스크 ID가 주어지고 특정 체크포인트가 지정되지 않은 경우, 가장 최신 체크포인트를 쿼리합니다:
resp = self._table.query(
KeyConditionExpression=Key("PK").eq(f"TASK#{thread_id}")
& Key("SK").begins_with("CKPT#"),
...
ScanIndexForward=False, Limit=1은 해당 태스크에 대한 가장 최근의 체크포인트 (checkpoint)를 반환합니다. 콜드 스타트 (cold start) 시 워커 (worker)는 이 체크포인터 (checkpointer)가 연결된 상태로 그래프 (graph)를 구축하며, LangGraph는 마지막으로 저장된 노드 (node)부터 다시 실행 (replay)합니다. 모든 체크포인트는 14일의 TTL (Time To Live)을 가지므로, 오래된 태스크 상태는 테이블에서 자동으로 삭제됩니다.
그래프 구축 단계에서 체크포인터가 연결됩니다:
graph = build_graph(deps, checkpointer=checkpointer)
config = {"configurable": {"thread_id": task_id}}
thread_id는 태스크 ID입니다. 이 단일 값이 재개되는 프로세스를 이전 프로세스가 작성한 모든 내용과 다시 연결합니다.
"재개 (resume)"가 실제로 하는 일과 하지 않는 일
이 부분을 과장하기 쉽기 때문에 정확하게 짚고 넘어가고자 합니다.
재개는 파일 내부의 바이트 (byte) 레벨이 아니라 노드 (node) 레벨에서 작동합니다. 만약 7번째 파일을 마이그레이션하는 동안 워커가 중단된다면, 7번째 파일의 중간부터 계속하는 것이 아닙니다. 다음 실행 시, 루프는 현재 커서 (cursor)에 대해 migrate_file 노드를 처음부터 다시 시작합니다. 즉, EFS에서 파일을 다시 읽고, 모델에 새로운 콘텐츠를 요청하며, 차이점 (diff)을 적용하고, 테스트를 다시 실행합니다. 0번부터 6번까지의 파일은 체크포인트된 상태에서 이미 완료된 것으로 표시되어 있으므로 건드리지 않습니다. 현재 커서에서 시작하여 앞으로 나아갑니다.
따라서 "마이그레이션 중간에 재개된다"는 말은 중요한 의미(완료되고 테스트를 통과한 6개의 파일을 다시 작업할 필요가 없다는 점)에서는 참이지만, 중단된 단일 파일은 처음부터 다시 작업해야 한다는 의미에서는 거짓입니다. 이것이 정직한 설명입니다.
버그: 재개를 두 번 하면 동일한 PR이 여섯 번 열린다
파이프라인에는 인간 참여형 (human-in-the-loop) 게이트가 있습니다. 플래너 (planner)가 파일의 위험도를 임계값 이상으로 평가하면, migrate_file 노드는 차이점 (diff)과 함께 LangGraph의 interrupt()를 호출하고 실행이 일시 중지됩니다. 사람이 이를 승인하거나 거절합니다. 승인은 Command(resume=...)와 함께 그래프를 재개하는 별도의 액션 (action)으로 돌아옵니다.
제가 놓친 부분은 바로 이것입니다. migrate_file 노드는 재개(resume)될 때마다 처음부터 다시 실행됩니다. 모델을 다시 읽고, 다시 적용하고, 테스트를 다시 실행합니다. 해결해야 할 실제 대기 중인 인터럽트(interrupt)가 있는 경우에는 문제가 되지 않습니다. 하지만 재개(resume) 액션이 두 번 실행될 때는 문제가 됩니다.
그리고 이 현상은 생각보다 훨씬 더 자주 발생합니다. SQS는 메시지를 한 번 이상 전달할 수 있습니다. 또한 Fargate의 느린 콜드 스타트(cold start)로 인해 체크포인트(checkpoint)가 아직 업데이트되지 않은 동안 사용자가 승인을 다시 클릭하면, UI에는 여전히 결정 대기 상태가 표시될 수 있습니다. 어떤 경우든, 첫 번째 호출에 의해 인터럽트가 이미 해제된 작업에 대해 두 번째 재개(resume)가 도착하게 됩니다.
invoke()를 다시 호출한다고 해서 아무 일도 일어나지 않는(no-op) 것은 아닙니다. 노드를 새로 실행하여 새로운 차이(diff)를 생성하고, 이를 적용하며, 실행을 최종 단계(finalize)까지 다시 몰아붙여 결국 풀 리퀘스트(pull request)를 생성합니다. 저는 이를 실제로 확인했습니다: 승인 클릭 한 번이 PR에 6개에서 9개의 동일한 커밋을 생성했습니다.
첫 번째 해결책은 재개(resume)하기 전에 실제로 대기 중인 인터럽트가 있는지 확인하는 것이었습니다:
snapshot = graph.get_state(config)
has_pending_interrupt = any(t.interrupts for t in snapshot.tasks)
이 방법은 도움이 되었지만, 문제를 완전히 해결하지는 못했습니다. 약 1초 간격으로 들어온 두 번의 승인 호출은 여전히 0개가 아닌 하나의 추가 커밋을 생성했습니다. 그 이유는 get_state 후 invoke를 호출하는 방식이 '확인 후 실행(check-then-act)' 방식이기 때문입니다. 두 호출자가 동일한 순간에 도착하면, 어느 한 쪽이 재개(resume)하기 전에 둘 다 has_pending_interrupt = True를 읽게 됩니다. 전형적인 레이스 컨디션(race condition)입니다.
진정한 해결책은 원자성(atomicity)이며, 이 시스템에서 이를 제공하는 유일한 것은 DynamoDB뿐입니다. 저는 조건부 Put(conditional put)을 수행하는 try_claim을 추가했습니다. 이 쓰기 작업은 아이템이 이미 존재하지 않을 때만 성공합니다.
def try_claim(self, task_id: str, key: str) -> bool:
try:
self._table.put_item(
...
여기서 키(key)는 재개되는 체크포인트 ID입니다. 해당 특정 체크포인트를 점유(claim)하는 첫 번째 호출자만이 invoke로 진행합니다. 두 번째 호출자는 False를 받고 아무것도 다시 실행하지 않은 채 기존 상태를 반환합니다.
claimed = checkpointer.try_claim(task_id, f"resume:{checkpoint_id}")
if has_pending_interrupt and claimed:
result = graph.invoke(Command(resume={...}), config=config)
...
마지막 단계인 finalize 단계에도 두 번째 방어 기제가 있습니다. PR(Pull Request)을 생성하기 전에, 이 작업에 이미 PR URL이 기록되어 있는지 확인하고, 기록되어 있다면 중단합니다. 중복 PR 생성의 비용은 짜증스럽고 공개적이기 때문에, 이중 안전장치(Belt and suspenders)를 마련한 것입니다.
또 다른 문제들: git, EFS, 그리고 root
git이 실행되는 컨테이너에 EFS를 마운트한다면 반드시 알아두어야 할 두 가지 작은 지뢰가 있습니다.
EFS 액세스 포인트(access point)는 누가 쓰든 상관없이 모든 파일을 uid 1000으로 고정합니다. 하지만 컨테이너는 root로 실행됩니다. Git 2.35.2 이상 버전은 자신이 다른 사용자의 소유라고 판단하는 리포지토리(repository)에서 작동하기를 거부합니다(이는 CVE-2022-24765의 dubious-ownership 체크입니다). 따라서 클론(clone) 직후의 첫 번째 git 명령어가 실패하게 됩니다. 해결책은 워커(worker) 시작 시점에 한 줄의 명령어를 실행하는 것입니다:
subprocess.run(["git", "config", "--global", "--add", "safe.directory", "*"], check=True)
두 번째 문제는 JS 리포지토리에서만 나타났습니다. diff 단계는 비교하기 전에 대상 파일의 끝에 줄바꿈(newline)이 오도록 정규화합니다. git apply는 실제 바이트와 컨텍스트를 매칭하는데, difflib은 "파일 끝에 줄바꿈 없음(No newline at end of file)" 마커를 절대 생성하지 않습니다. 따라서 끝에 줄바꿈이 없는 파일은 깔끔하게 적용(apply)되지 않습니다. Python 소스 코드는 거의 항상 줄바꿈으로 끝나기 때문에, 저의 Python 피스처(fixtures)로는 이 문제를 전혀 잡아낼 수 없었습니다. 직접 작성한 JS 및 JSON은 종종 줄바꿈이 없으며, 이로 인해 실제 JS 리포지토리에서 문제가 발생했습니다.
내가 다르게 했을 것이라면
중복 커밋(duplicate-commit) 버그는 흥미로운 사례이며, 여기서 얻는 교훈은 단순히 "claim을 추가하라"가 아닙니다. 교훈은 제가 멱등성(idempotency)을 처음부터 잘못된 계층(layer)에서 구축했다는 점입니다. 문제는 동시성(concurrency)이었는데, 저는 상태 확인(has_pending_interrupt)에 의존했습니다. 상태 확인으로는 동시성을 해결할 수 없습니다. 오직 조건부 쓰기(conditional write)만이 해결할 수 있습니다. 만약 제가 다시 한다면, 모든 재개(resume) 경계에서 처음부터 원자적 점유(atomic claim)를 수행할 것이며, 상태 확인은 보증 수단이 아닌 저렴한 조기 종료(early-out) 수단으로 취급할 것입니다.
또 다른 문제는 migrate_file이 재개(resume) 시 처음부터 다시 실행된다는 점인데, 이는 편리하지만 낭비적입니다. 이미 생성했을 수도 있는 파일에 대해 Bedrock 호출 비용을 다시 지불하게 됩니다. 더 깔끔한 설계는 체크포인트(checkpoint)에 생성된 콘텐츠를 캐싱(caching)하여, 단순히 중복 전달에 불과한 재개 작업에 비용이 전혀 들지 않도록 하는 것입니다. 저는 아직 이를 구현하지 않았습니다.
내구성(Durability)은 단 하나의 기능이 아닙니다. 그것은 내구성이 있는 상태(durable state), 내구성이 있는 저장소(durable storage), 그리고 멱등적 재개(idempotent resume)이며, 마지막 요소가 저를 괴롭혔습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기