
AI 2대와 인간 1명으로 iOS 앱 개발 프로세스 운영하기
요약
iOS 앱 개발 시 이미지 구조화 정밀도를 높이기 위해 설계 AI와 구현 AI를 분리하고 인간이 검증하는 워크플로우를 제안합니다. AI 간의 상호 보완을 통해 자기 판정 오류를 극복하고 정밀도를 개선하는 운영 노하우를 다룹니다.
핵심 포인트
- 설계와 구현 AI를 별도 모델/세션으로 분리하여 상호 견제 유도
- AI의 자기 판정 오류를 방지하기 위해 로우 데이터(Raw Data) 포함 필수
- 자동화는 정보 운반에 국한되며, 판단의 횟수는 줄어들지 않음
- 정밀도 개선을 위해 인간이 최종 게이트 역할을 수행하는 구조
Xcode로 iOS 앱을 개발하고 있습니다. 이미지를 LLM에 전달하여 구조화된 결과를 얻는 기능의 정밀도 개선에 애를 먹은 결과, 설계를 생각하는 AI와 구현을 하는 AI를 별도의 모델 및 별도의 세션으로 구성하고, 그 사이를 인간이 게이트(Gate)로 구분하는 워크플로우에 도달했습니다.
이 기사는 "이렇게 만들었습니다"보다는 **"왜 그런 설계를 했는가"와 "운영해 보니 무엇을 알게 되었는가"**에 대해 쓰고 있습니다. 스크립트는 요점만 발췌합니다.
대상 독자는 Xcode로 앱을 만들고 있으며, Claude Code와 같은 에이전트 CLI를 워크플로우에 통합하려는 분들입니다.
사용한 모델은 설계 담당 = Opus 5 / 구현 담당 = Opus 4.8입니다. 일부러 세대를 맞추지 않은 덕분에, 이것이 **좋은 엇박자 콤비(Deco-boko Combi)**가 되었습니다.
먼저 결론 3가지를 말씀드립니다.
- AI를 2대 사용하는 가치는 "속도"가 아니라 **"한쪽의 선입견을 다른 한쪽이 잡아내는 것"**에 있다. 실제로 구현 측의 자기 판정이 4번 모두 설계 측의 재판정에서 뒤집혔다. - 단, 이것은 자동으로 일어나지 않는다.
- 보고에 로우 데이터(Raw Data)를 포함하고, 수신자가 다시 세어보게 하는 강제성이 없으면, 2대는 "서로 승인만 해주는 장치"로 전락한다. 이것이 최대의 실패 모드다.
- 자동화하여 없앨 수 있는 것은 정보의 운반 작업뿐이다. 판단의 횟수는 줄어들지 않는다.
이후에는 세세한 이야기가 많습니다만, 이 메커니즘을 생각하고 움직이기까지 많이 헤맸기 때문에(웃음) 어떤 항목이라도 누군가에게 힌트가 되지 않을까 싶어 작성하고 있습니다.
만들고 있었던 것은, 촬영한 이미지를 LLM에 전달하여 구조화된 결과를 반환하는 기능입니다. 단말기 내에서 처리하는 경로와 서버 측에서 처리하는 경로를 구분하여 사용하는 구성이었습니다. (NDA에 저촉될 수 있으므로 자세한 명칭은 밝히지 않습니다)
작동하는 단계까지는 금방이었습니다. 문제는 그 이후의 정밀도 개선입니다.
이런 종류의 기능은 좋아졌는지 여부를 기계적으로 판정할 수 없습니다.
- UnitTest/UITest는 모두 Green이다. 하지만 실기기에서 촬영하면 기대한 항목이 나오지 않는다.
- 프롬프트를 수정했더니 다른 이미지에서 악화되었다. 어느 쪽이 더 나은지는 나란히 놓고 세어보지 않으면 알 수 없다.
- 모델이 그럴듯하지만 사실이 아닌 결과를 반환한다. 언뜻 보기에는 자연스러워서 출력만 봐서는 알아챌 수 없다.
- "정밀도가 올라갔다"라는 보고가, 기대치를 완화했을 뿐인 경우가 있다.
즉, XCTAssert로 결판을 낼 수 없습니다. 실기기로 촬영하여 나온 항목을 하나씩 세어서 이전과 비교하는 수밖에 없습니다. 게다가 그것을 프롬프트나 전처리를 바꿀 때마다 반복해야 합니다.
처음에는 AI 1대에게 맡겼습니다. 구현하게 하고, 자기 판정을 보고하게 했습니다. 이것은 믿을 수 없었습니다.
실제로 분기점이 되었던 4번의 사례 모두에서, 구현 측의 자기 판정("실패했다", "이 방식으로는 무리다")이 보수적이거나 오류였으며, 다른 관점에서 로우 데이터를 다시 세어보니 결론이 뒤집혔습니다. 악의적이거나 태만한 것이 아니라, 자신이 작성한 코드의 출력을 스스로 엄격하게 다시 세어보는 것은 구조적으로 무리가 있다고 생각합니다 (인간도 자신의 일을 스스로 부정하는 것은 싫으니까요).
그래서,
- 세는 역할과 쓰는 역할을 나눈다 (별도 모델, 별도 세션)
- 다시 셀 수 있는 로우 데이터를 반드시 보고에 포함시킨다
- 이미지를 보거나 실기기로 체감하는 판정은 인간이 한다
이 세 가지가 필요합니다. 그리고 세 주체가 순차적으로 돌아간다면, "지금 누구의 차례인가"를 관리하는 무언가가 필요합니다. 그것이 "바통을 넘겨준다"라는 발상이었습니다.
이 루프의 본질은 "빠르게 돌리는 것"이 아닙니다. 한 바퀴 돌 때마다, 다음부터 기계적으로 측정할 수 있는 것이 하나씩 늘어나는 것입니다.
인간이 게이트에서 실기기를 보고 "이런 구도는 놓쳐서는 안 된다"라고 언어화합니다. 그것이 다음 루프부터 **수락 조건(Acceptance Criteria)**이 되어, 이후에는 AI가 자동으로 검산할 수 있게 됩니다. 사양이 루프 속에서 성장해 나갑니다.
어떤 의미에서는 인간을 평가 함수로 삼은 Hill Climbing입니다. 평가 함수를 처음부터 쓸 수 없기 때문에, 한 바퀴 돌 때마다 인간으로부터 조금씩 받아내고 있습니다.
루프의 필요성은 알았습니다. 문제는 당초 그것을 전부 수동으로 돌리고 있었다는 점입니다.
| # | 작업 |
|---|---|
| 1 | 인간이 설계 측 AI에게 요구사항을 전달한다 |
| ... |
이 중 4, 9, 10은 완전히 기계적이며, 게다가 횟수가 많습니다. 6(실기기)과 1(기점)은 인간만이 할 수 있습니다. 이 구분 짓기가 설계의 출발점이 되었습니다.
자동화의 대상은 운반 작업뿐입니다. 이 부분을 오해하여 "인간이 만지는 횟수를 줄이는 것"을 목표로 삼으면, 확인 게이트(Confirmation Gate)가 형식적으로 변질되어 "작동은 하지만 사용할 수 없는 것"들이 쌓이게 됩니다.
세부적인 이야기에 들어가기 전에, 완성된 형태를 먼저 제시합니다.
| 역할 | 모델 | 하는 일 | 질문 가능 여부 |
|---|---|---|---|
| 설계 담당 AI | Opus 5 | 보고를 검산하고 다음 지시서를 작성함. 코드는 한 줄도 쓰지 않음 | 불가능 (무인) |
| 구현 담당 AI | Opus 4.8 | 지시서대로 구현하고 보고서를 작성함. 방침을 의심하지 않음 | 불가능 (무인) |
| 인간 | — | 방침 판단 · 실기기 확인 · 정지. 게이트 시점에만 등장 | — |
이 세 주체 사이를 도는 것이 **바톤(Baton)**이며, 실체는 _bus/state.json이라는 파일 하나입니다. 내용은 실질적으로 "지금 누구의 차례인가"뿐입니다. 설계 담당도, 구현 담당도, 러너(Runner)도 우선 이것을 읽고 "자신의 차례가 아니라면 아무것도 하지 않고 대기"합니다.
바톤이 인간에게 돌아왔을 때, 인간은 맨손으로 작업하지 않습니다. 상대에 따라 도구를 바꿔 듭니다.
| 도구 | 누구와의 대화인가 | 하는 일 | 대응하는 게이트 |
|---|---|---|---|
| Cowork | 인간 ⇄ 설계 담당 | 방침을 결정함, 이미지를 보고 판정함, 결론을 언어화하여 남김 | policy |
| Xcode IDE | 인간 ⇄ 구현 담당 | 실기기에 실행함, 촬영함, 육안으로 확인함, 콘솔 로그를 취함 | device |
무인으로 움직이는 두 역할은 질문할 수 없습니다 (판단이 막히면 게이트를 세우고 종료함). 반면, Cowork와 Xcode를 통한 대화는 질문해도 좋습니다. 이 비대칭성이 워크플로우의 척추입니다.
무인 세션으로는 원리적으로 불가능한 "이미지를 보고 좋고 나쁨을 판정하는 것", "실기기의 거동이 체감상 어떤가"와 같은 판단을 인간이 이 두 손으로 해결합니다.
Xcode IDE나 Claude Cowork에는 다양한 도구가 준비되어 있습니다. 그것들을 충분히 활용한 뒤 인간이 조사 · 검증 · 판단 · 결정을 내리고, 다음으로 바톤을 넘겨줍니다.
설계 담당에는 Opus 5를, 구현 담당에는 Opus 4.8을 할당했습니다. 일부러 세대를 맞추지 않았습니다.
시점의 다양성 그 자체가 목적이기에, 세대를 맞추면 의미가 퇴색됩니다. 실제로 설계 담당은 "보고의 주장을 의심하여 로우 데이터(Raw Data)로 돌아가는" 움직임이 강하고, 구현 담당은 "눈앞의 한 점을 끝까지 파고드는" 움직임이 강합니다. 이 울퉁불퉁함이 적절하게 맞물렸습니다.
Opus 5와 4는 성격이 상당히 다르다는 실감이 듭니다. Opus 5는 말이 많아서 "결론부터 말해달라"고 CLAUDE.md에서 못을 박았습니다 (웃음).
역할별 모델은 설정 파일의 한 줄로 정의됩니다.
: "${MODEL_DESIGN:="claude-opus-5"}" # 설계 담당: 전체를 봄
: "${MODEL_IMPL:="claude-opus-4-8"}" # 구현 담당: 한 점에 집중함
- 인간이 기안하고, Cowork에 요구사항을 전달함 (이 부분만은 인간이 기점임. 요구사항은 사람의 머릿속에 있음)
- 설계 담당이 지시서를 쓰고, 바톤을 구현 담당에게 넘김
- 구현 담당이 "내 차례다"라고 인지하여 → 구현 → 빌드 → 보고 → 바톤을 설계 담당에게 반환
- 설계 담당이 보고를 **검증 (검산)**함. 계속 진행할 수 있으면 2번으로 돌아감 (
round + 1) - 판단이 필요함 / 실기기가 필요함 / 규정 회차에 도달함 → 게이트가 세워지고 모두 정지함. 바톤은 인간에게 전달됨
- 인간이 Cowork 또는 Xcode로 문제를 해결하고 해제 → 2번으로
2~4번이 자동으로 돌아가고, 5번에서만 인간이 호출됩니다. 그뿐인 구조입니다. 규정 회차에서 일시 정지하는 것은 휴식 시간입니다. Claude 쿼터(Quota)와의 조율 문제도 있습니다.
이후에는 이 도식의 각 부품을 하나씩 설명하겠습니다.
앞서도 조금 언급했지만 "한 명에게 설계와 구현을 모두 시키면 되지 않느냐"라는 질문에 대한 답이 바로 이 구조의 핵심입니다. 이유는 4가지가 있습니다.
설계 측과 구현 측은 별도의 모델 · 별도의 역할 · 별도의 정보로 움직입니다. 시점이 다른 두 가지를 대조하면, 한쪽의 착각이 다른 쪽에 걸리게 됩니다.
§1에 쓴 4번의 과정이 바로 이것으로, 한쪽에서만 돌렸다면 4번 모두 잘못된 결론인 채로 앞으로 나아갔을 것입니다.
n=4이므로 통계적인 주장은 할 수 없습니다. 다만 "단 한 번도 뒤집히지 않았다"면 2대를 사용하는 의미를 의심해야 하겠지만, 그렇지 않았다는 점에서 어느 정도 의미 있는 숫자라고 생각합니다. 이 숫자가 0에 가까워진다면, 이 구성은 그만두는 것이 좋다고도 생각합니다(§15).
한 세션에서 양쪽을 모두 수행하면, 구현의 세부 사항(타입, 빌드 에러, API의 특성 등)이 쌓인 상태에서 방침을 생각하게 되어, "지금 작성한 코드를 살리는 방향"으로 판단이 끌려가게 됩니다. 반대로 방침에 대한 논의를 안고 구현을 하면, 눈앞의 코드에 완전히 집중할 수 없습니다.
역할을 나누면, 설계 측은 코드를 한 줄도 쓰지 않는 대신 전체를 보고, 구현 측은 방침을 의심하지 않는 대신 한 점에 집중한다는 역할이 성립합니다.
양측이 공유하는 것은 바톤(Baton, 후술)과 지식 저장소(Knowledge Store)의 문서뿐이며, 작업 중의 사고 과정은 공유하지 않습니다. 설계 측의 코드 편집 금지는 이 경계를 지키기 위한 제약입니다.
작가가 가진 역할은 4가지이며, 그 어느 것도 AI에게 위임할 수 없습니다.
| 역할 | 수행 내용 | 게이트(Gate)와의 대응 |
|---|---|---|
| 발안자 | 무엇을 만들지 결정 | 티켓 발행, 요구사항 전달 |
| 멘토 | 방침의 타당성을 판단하고 망설임을 끊음 | policy 게이트 |
| QA | 실기기에서 구동하며 AI가 볼 수 없는 것을 확인 | device 게이트 |
| 사용자 | "동작함"과 "사용 가능함"의 차이를 체감으로 판정 | device / review 게이트 |
| 확인 게이트 | 폭주를 막고 공개 여부를 결정 | 정지 명령, push 및 main 머지 |
특히 "사용자로서의 체감"은, 모든 기능이 사양대로 동작하더라도 운영을 견딜 수 없다는 것을 알고 있는 인간만이 판정할 수 있습니다.
이 프로젝트의 본질적인 어려움은 **실세계의 투박함(Muddy reality)**에 있습니다. 카메라로 찍은 이미지의 보이는 모습, 피사체의 겹침, 줌 배율에 따라 변하는 입도(Granularity), 모델이 반환하는 "그럴듯하지만 사실이 아닌" 출력. 이것들은 코드를 읽는 것만으로는 알 수 없으며, 실기기로 찍어서 확인해 볼 수밖에 없습니다.
3자가 함께 운영하는 의미는, 그 투박한 부분에 인간의 시간을 집중시키기 위해 기계적인 부분을 기계에게 맡기는 것입니다. 만지는 횟수가 아니라, 만지는 내용을 "판단과 체감"에만 한정합니다.
당연히 생길 수 있는 의문입니다. 서브 에이전트(Sub-agent)를 여러 대 세우는 구성은 각 회사의 CLI나 프레임워크가 표준으로 제공하고 있으며, 그 편이 더 간편합니다.
제가 이해하는 바로는, 다음과 같이 나뉩니다.
| 구성의 목적 | 적합한 작업 | 인간의 위치 |
|---|---|---|
| 멀티 에이전트 (Multi-agent) | 컨텍스트를 나누어 전문 분야에 전념하게 하는 흐름 작업 | 사양이 결정되어 있어 분해하여 흘려보낼 수 있는 작업 |
| 에이전트 팀 (Agent teams) | 여러 에이전트가 메시지를 주고받으며 전문 분야를 처리하는 작업 | 사양이 결정되어 있어 담당 간의 조정이 필요한 작업 |
| 이 구성 | 인간과의 협업이 전제됨. 구동하며 알게 된 것을 사양으로 되돌리며 진행 | 프로토타입에서 제품으로 향하는 과정. 구동해 보지 않으면 사양이 결정되지 않는 작업 |
처음에 사양이 완성되어 있다면, 멀티 에이전트나 팀(teams) 방식이 좋습니다. 분해해서 흘려보내는 것이 더 빠르고 비용도 저렴합니다. 인간은 마지막에 결과물만 받으면 됩니다.
문제는 프로토타입에서 제품으로 향하는 과정입니다. 이 단계에서는 사양이 고정되어 있지 않습니다. 아니, 오히려 구동해 보고 나서야 비로소 "이것은 사양이 틀렸다 (특히 품질에 관하여)"라는 것을 알게 됩니다.
- 카메라로 찍어보니, LLM에 의한 이미지 인식이 예상치 못한 답변을 내놓음
- 모든 기능이 사양대로 동작하고 있음에도, 막상 사용하면 명백히 사용하기 불편함
- "동작함"과 "사용 가능함" 사이에 사양서에 적혀 있지 않은 간극이 존재함
이것들은 실행해 보기 전까지는 존재하지 않는 정보이며, 게다가 판정할 수 있는 것은 인간뿐입니다. AI는 자신이 작성한 코드를 실기기에 들고 다닐 수 없으며, "왠지 모르게 사용하기 불편하다"는 것을 감지할 수 없습니다.
따라서 이 구성에서 인간 게이트는 안전밸브가 아니라 사양의 생성 장치입니다. 게이트에서 인간이 확인한 것이 다음 주기의 지시서에 들어갑니다. 사양이 루프 안에서 성장해 나갑니다.
AI와 인간이 협력하며 다듬어 나가는 종류의 프로젝트에서는, 인간 게이트를 전제로 하지 않으면 좋은 제품이 되지 않는다는 것이 실제로 운영해 보며 느낀 실감입니다.
위의 구분점으로부터 구현 수준의 차이가 발생합니다.
(1) 부모-자식 관계가 아닌 대등한 관계. 서브 에이전트 (Sub-agent) 방식에서는 부모가 태스크를 나누어 자식에게 전달하고, 자식의 보고를 부모가 받습니다. 부모와 자식 모두 동일한 컨텍스트 (Context) 계열에 있기 때문에, 부모가 전달한 전제를 자식이 이어받고, 그 전제 그대로 돌아온 것을 부모가 승인하는 순환이 일어나기 쉽습니다. 하지만 본 방식에서는 설계 역할과 구현 역할 사이에 상하 관계가 없으며, 바톤(Baton)은 역할에서 역할로 전달하는 것이지, 호출/반환 값(Return value)이 아닙니다. 설계 역할은 구현 역할의 출력을 '내가 발주한 성과물'이 아니라, 외부에서 온 보고로서 의심합니다.
(2) 컨텍스트를 공유하지 않음. 두 역할이 공유하는 것은 바톤과 지식 스토어 (Knowledge Store)에 적힌 지시서·보고서뿐입니다. 작업 중의 사고 과정, 시행착오, 도중에 버린 가설은 공유하지 않습니다. 편리함과 맞바꾸어, 짐작(Assumption)의 전파를 차단하고 있습니다. (부차적으로, 한쪽이 컨텍스트 상한에 도달하더라도 다른 한쪽은 무사합니다. 장시간의 무인 루프(Unmanned loop)에서는 은근히 효과적입니다)
(3) 병렬화하지 않음. 동일한 하나의 태스크를 순차적으로, 다른 관점에서 2번 통과시킵니다. 빨라지지는 않습니다. 1회전 비용은 오히려 2배에 가깝습니다. 그럼에도 이것이 성립하는 이유는, 구현 측의 자기 판정이 4번 모두 뒤집혔다(설계대로 되지 않았다) (§3-(1))는 경험 때문입니다. 이 값이 0이라면 (설계대로 구현할 수 있다면), 솔직하게 멀티 에이전트 (Multi-agent) 방식으로 돌아가는 편이 좋습니다.
사양이 확정된 부분부터 순차적으로 멀티 에이전트적인 흐름 작업으로 분리해 나가는 것이 자연스럽다고 생각합니다. 이 루프의 출구는 '사양이 확정되는 것'이므로, 확정된 영역은 더 이상 2번 통과할 필요가 없습니다.
실제로 테스트가 green / red로 결판나는 범위는 이미 구현 역할 1개로 닫힌 작업이 되어 있습니다. 인간 게이트 (Human gate)를 끼워 넣는 것은 판정에 인간의 눈이 필요한 부분뿐입니다.
| 위치 | 내용 | 읽기/쓰기 주체 |
|---|---|---|
| 제어 플레인 (Control Plane, 바톤) | _bus/state.json (파일 1개) | 누구의 차례인지·게이트·회전 수·참조 ID |
| 내용 플레인 (Content Plane) | 지식 스토어 (Knowledge Store, MCP) | 지시서·보고서·분석 |
왜 나누는가? MCP 서버는 셸 (Shell)에서 호출할 수 없기 때문입니다. '지금 움직여도 되는가'의 판정을 AI 없이 python3만으로 완결 짓고 싶습니다. 그래서 판정에 필요한 정보만 JSON으로 추출하고, 내용은 ID로 참조합니다.
기술적인 편의에서 출발한 분리이지만, 결과적으로 §3-(2)의 컨텍스트 분리와 맞물렸습니다.
{
"ticket": "P0.5-S10.2",
"owner": "impl", // "design" | "impl" | "human"
...
}
| 주체 | 작동 가능 조건 |
|---|---|
| 러너 (Runner, Mac) | paused==false 이고 PAUSE 파일이 없고 owner=="impl" 이고 gate==null 이고 상한 이내 |
| 설계 역할 (정기 태스크) | paused==false 이고 owner=="design" 이고 gate==null |
| 인간 | owner=="human" (= 게이트가 서 있음) |
gate != null 이라면 반드시 owner == "human" 입니다. 이 불변 조건을 깨뜨리지 않는 한, 이중 실행도 경합(Race condition)도 발생하지 않습니다. 락(Lock)이나 타임아웃도 안전장치로 두었지만, 본질은 이 한 줄입니다.
색상은 owner (누구의 차례인가)입니다. 주황색 상자에 들어가는 순간, AI 측은 전부 멈춥니다. 구현 역할의 러너도 설계 역할의 정기 태스크도, 바톤을 읽고 '자신의 차례가 아니다'라고 판단하여 즉시 exit 할 뿐입니다.
기표 직후, 요건은 인간의 머릿속과 대화 세션의 대화 속에만 존재합니다. 정기 태스크는 별도의 세션이므로 그 대화를 볼 수 없습니다.
여기서 owner="design"으로 설정하면, 정기 태스크가 '보고가 없다'고 오판하여 매번 게이트를 세우게 되고, 첫 회에 반드시 헛바퀴를 돕니다. 실제로 한 번 그랬습니다.
init은 owner="human"
- 정기 태스크는
state=="design_requested"를 보면 말없이 종료한다 (게이트를 세우지 않는다) - 루프를 앞으로 진행시키는 것은 요건을 전달받은 대화 세션의 역할
| 게이트 (gate) | 발생하는 시점 | 설정 주체 | 인간이 할 일 |
|---|---|---|---|
policy | 방침의 분기, 스코프 변경, 루프 상한 도달, 설계 담당자가 확신을 갖지 못할 때 | 설계 담당자 | 판단을 전달하여 해제 |
device | 실기기에서의 촬영·육안 확인·콘솔 로그가 필요할 때 | 설계 담당자 또는 구현 담당자 | 실기기로 조작하고, 로그를 지정된 장소에 배치 |
review | 차분이 클 때 / 주의가 필요한 파일에 접근했을 때 / 보호된 브랜치(protected branch)에 영향을 줄 때 | 러너 (Runner, 자동 판정) | 차분을 육안으로 확인 |
AI는 스스로 게이트를 해제할 수 없습니다. 움직이는 것은 인간이 명령어를 입력하는 순간뿐입니다.
device
게이트에서는 「어떤 이미지를・어느 화면에서・어떤 로그 출력을 보고・어디에 저장할지」까지 작성하는 것을 의무화하고 있습니다.
"실기기 확인을 부탁합니다"라고만 하면, 인간이 번거로운 작업에 들어가기도 전에 준비 단계에서 시간을 허비하게 됩니다. §3-(4)의 목적이 여기서 무색해집니다.
게이트와 완료 시점에만 알림을 보냅니다. 알림이 너무 많으면 인간이 게이트의 이유를 읽지 않게 되어, 4가지 역할 중 3가지가 형식적으로 변합니다.
참고로, Cowork 환경에서 macOS의 푸시 알림을 보내는 수단은 찾을 수 없었습니다(후술). 채팅 도구의 자신에게 보내는 DM(Direct Message)으로 타협했습니다.
실제로 손을 움직여보지 않으면 알 수 없었던 부분입니다.
Cowork(데스크톱 AI 앱)에서 셸(Shell)을 호출할 수 있으므로, 거기서 xcodebuild나 claude CLI를 실행하여 구현 측을 움직일 수 있다고 처음에는 생각했습니다. 할 수 없습니다.
셸은 (클라우드 실행이라면 별도 호스트의 컨테이너, "사용자의 컴퓨터에서 실행"이라 하더라도) **Mac 상의 Linux 샌드박스(Sandbox)**에서 동작합니다. macOS 본체와는 별개의 호스트이므로, xcodebuild도 claude도 존재하지 않습니다.
→ 기동 트리거는 반드시 Mac 측에서 내보낸다. Mac 측에 상주 폴러(Poller)를 하나 두고, "자신의 차례인지"를 정기적으로 확인하는 풀(Pull) 방식으로 했습니다. 설계 측으로부터의 푸시(Push)는 존재하지 않습니다.
같은 벽에 부딪히는 사람이 많을 것이기에, 이 내용은 미리 적어둡니다.
샌드박스에서 보이는 경로는 실제 Mac의 경로가 아닙니다. $HOME은 매 세션마다 바뀌는 슬러그(Slug)입니다.
| 실제 Mac | 샌드박스 |
|---|---|
~/Documents/.../프로젝트 | $HOME/mnt/프로젝트 |
~/GitHub/리포지토리 | $HOME/mnt/리포지토리 |
절대 경로를 하드코딩하면 전멸합니다. 공통 라이브러리에 후보 경로 리스트를 갖게 하여 자동 판별하고, 환경 변수로 덮어쓸 수 있도록 했습니다.
더 까다로운 것은, 지식 스토어(Knowledge Store, MCP)는 경로 체계가 별도라서 실제 Mac의 경로를 그대로 사용한다는 점입니다. 셸의 경로와 혼동하면 조용히 아무것도 찾지 못하게 됩니다.
*/15 * * * *와 같은 1시간 미만의 지정은 통하지 않았습니다. 또한 0 * * * *는 "매시 0분"이 아니라 「생성된 분에 고정하여 1시간마다」(14:37에 생성 → 매시 37분에 발화) 동작합니다.
함의:
- 인간이 구현의 기점을 수행하는 페이즈에서는 실질적인 피해가 작다. 구현이 끝난 직후에 수동 발화하면 대기 시간은 제로.
- 완전 자동화하면 1회 루프당 최대 1시간의 공백이 생긴다. 하루의 실효 루프 횟수는 8~10회가 상한.
- 15분 상당의 간격이 필요하다면, 15분씩 차이를 두어 동일한 태스크를 4개 만드는 방법이 있다(생성 시 분에 고정되는 성질을 이용). 단, 동시 발화에 대한 경합 대책이 필요함.
클라우드에서 동작하는 세션에서 정기 태스크를 만들면, 발화 시점에 로컬로 도달할 수 있는 수단이 통째로 존재하지 않는 상태가 되어, Mac의 폴더에도 지식 스토어에도 닿지 않았습니다.
→ 반드시 데스크톱 앱의 "사용자의 컴퓨터에서 실행" 세션에서 생성한다.
러너(Runner)가 -destination 'platform=iOS Simulator,...'를 고정해서 사용했기 때문에, 구현 담당자의 코드와는 무관하게 BUILD FAILED가 발생하여, 루프 한 회차가 통째로 멈췄습니다.
원인은 일부 Apple 프레임워크(예: VisualIntelligence 등)가 device SDK에만 존재하기 때문입니다. Xcode IDE에서는 통과되었기에 알아차리기 어렵습니다. 에러는 다음과 같이 나타납니다.
Unable to resolve module dependency: 'VisualIntelligence'
** BUILD FAILED **
대응은 2단계로 진행합니다.
즉시 대응: destination 자체를 설정 파일에서 분리했습니다. 프레임워크 이름을 감지하여 분기하는 식의 교묘한 방식은 사용하지 않습니다. 시뮬레이터 (simulator)에서 통과되지 않는 프로젝트는 앞으로도 계속 나올 것이기 때문입니다.
# generic/platform=iOS 는 device SDK에서 컴파일하지만, 실제 기기 연결은 불필요
: "${BUILD_DESTINATION:="generic/platform=iOS"}"
영구 대응: 해당 import를 #if canImport(VisualIntelligence)로 감싸는 수정을 별도 티켓으로 발행했습니다. 이 작업이 완료되면 테스트의 거의 대부분을 시뮬레이터에서 돌릴 수 있습니다.
모든 테스트를 실제 기기에서 돌리면 루프가 멈추기 쉽습니다. 실제 기기의 연결, 잠금 해제, 신뢰 설정이 매번 전제 조건이 되기 때문입니다.
설정을 두 개로 나누었습니다.
# 일반 테스트. 시뮬레이터를 우선함
: "${TEST_DESTINATION:="platform=iOS Simulator,id=<UDID>"}"
# 시뮬레이터에서 확인할 수 없는 테스트 전용 (실제 기기). 비워두어도 됨. 비워두어도 루프는 멈추지 않음
...
일반 테스트가 green(성공)이라면 구현 담당자는 다음 단계로 진행해도 좋으며, 실제 기기에서만 확인할 수 있는 것이 나타났을 때만 device 게이트를 세우는 설계입니다.
두 역할이 동일한 리포지토리를 사용하므로, 가드(guard)를 3개 설정했습니다.
xcodebuild / Xcode 프로세스가 동작 중이라면 해당 회차를 스킵 (다음 폴링에서 재시도)
- 러너(runner)의 빌드는
-derivedDataPath "$REPO/.dd-runner"에 격리하여, IDE의 DerivedData와 섞이지 않게 함 - (사람이 실제 기기를 만지고 있는 중이므로)
gate=="device"인 동안에는 러너를 절대 동작시키지 않음
참고로 "IDE를 항상 열어두고 작업"한다면 첫 번째 가드는 무효화가 필요합니다. 이 부분을 기본적으로 안전한 쪽(보수적인 쪽)으로 설정해 두었다가, 구현 담당자가 단 한 번도 움직이지 못하는 함정에 빠졌었습니다.
샌드박스 측에서 git add / git commit을 실행하게 했더니, unlink가 거부되어 .git/index.lock / HEAD.lock이 남게 되고, Mac 측의 커밋을 차단하는 사고가 2번 발생했습니다.
설계 담당자는 git의 index를 건드리지 않음 (git add / commit / status 모두 금지). git log / git branch --show-current / grep은 안전함
- 러너는 실행 전에 락(lock) 파일의 존재를 확인하고, 있다면 실행하지 않고
blocked상태로 만들어 통지함 (함부로 삭제하지 않음) git push와 보호된 브랜치로의 머지(merge)는 사람만 수행
Xcode 측이라기보다 Claude Code 측의 이야기입니다만, 무인 실행(unattended execution)에 고유한 함정입니다.
--permission-mode acceptEdits는 파일 편집만 자동 승인합니다. Bash 명령어가 매번 승인 대기 상태가 되어, 무인 세션에서는 아무도 승인할 수 없기에 끝없이 루프를 돌게 됩니다.
사용하는 것은 dontAsk입니다. "허가 리스트에 있는 것만 실행하고, 그 외에는 자동으로 거부한다"는 동작입니다. 멈추지 않으면서도, 무엇이든 실행하지는 않습니다.
bypassPermissions(전체 허가)를 사용하지 않고 해결할 수 있습니다.
CLAUDE_FLAGS="--permission-mode dontAsk"
ALLOW_IMPL="Edit,Write,Bash(xcodebuild *),Bash(git add *),Bash(git commit *)"
ALLOW_DESIGN="Bash(ls *),Bash(cat *),Bash(git log *)" # 편집 계열은 넣지 않음
역할마다 허가 리스트를 다르게 설정함으로써, "설계 담당자는 코드를 작성하지 않는다"를 정신론이 아닌 권한으로 담보할 수 있습니다. 이 부분은 분리하는 것이 좋습니다.
패턴 구문 사용 시 한 가지 주의할 점: MCP 도구는 *를 사용할 수 없으며, 도구 이름을 하나씩 열거해야 합니다.
프로젝트 스코프의 .mcp.json은 무인 실행 시 승인 대기 상태가 되어 무시됩니다.
claude mcp add --scope user <이름> -- <실행 명령어>
이를 잊으면, 에이전트는 보관된 데이터베이스 파일을 직접 읽으려고 시도합니다 (실제로 발생한 일입니다). 내부 구조에 의존할 뿐만 아니라 권한도 통과되지 않아, 무의미한 시도만 반복하게 됩니다.
echo $ANTHROPIC_API_KEY # 아무것도 출력되지 않으면 OK
환경 변수에 API 키가 있으면, Claude Code는 구독 (Subscription) 상태를 무시하고 해당 키로 인증하여 종량제 (Pay-as-you-go)로 과금됩니다. 러너 (Runner)는 실행 전에 무조건 unset 하도록 설정했습니다.
Claude Code에는 사용량을 읽는 API가 없습니다 (claude usage --json과 같은 수단은 존재하지 않으며, 기능 요청도 'not planned'로 종료되었습니다). 무인 루프 (Unattended loop)에서는 한도에 도달하는 순간 조용히 종료됩니다.
수행한 작업:
claude -p --output-format json의 반환 값에 포함된 비용 (Cost)과 사용량 (Usage)을 1회 실행당 1행씩 장부에 기록. 한도에 도달한 순간의 직전 윈도우 (Window) 누적치를 '관측된 100%'로 저장
구독 한도는 구체적인 수치가 공개되어 있지 않으므로, "70%에서 휴식"을 위한 분모가 처음에는 존재하지 않습니다. 한 번 한도에 도달하면 분모를 얻을 수 있으며, 이후부터는 예방적인 휴식이 가능해집니다. 도달하기 전까지는 기록만 할 뿐 중단하지 않습니다 (중단할 방법이 없기 때문입니다).
이것은 완전히 여담입니다만, 1시간을 허비했기에 적어둡니다.
halt "기대=$BRANCH(변경해 주세요)" # ✗ 실패
halt "기대=${BRANCH}(변경해 주세요)" # ○
bash가 전각 문자를 변수명의 일부로 읽어 들여, set -u가 "미정의 변수"로 간주하고 즉시 종료시킵니다. $VAR 직후에 전각 문자를 두지 마세요. 중괄호 {}로 감싸세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기