-
[AI] iOS API 디버깅하기: AI와 만든 핑퐁 테스트 워크플로우Dev 2026. 7. 23. 19:11반응형
새 화면의 API 연동을 끝내면 늘 비슷한 질문이 남습니다.
- 지도에 진입했을 때 viewport API는 정확히 한 번만 호출될까?
- 필터를 탭하면 서버가 기대하는 쿼리 파라미터가 붙을까?
- 바텀시트를 크게 펼쳤을 때 detail API가 그 시점에만 호출될까?
단위 테스트는 요청 메서드가 호출됐는지 알려주지만, 실제 앱에서 실제 제스처로 서버에 무엇을 몇 번 보냈고 어떤 응답을 받았는지는 충분히 보여주지 못할 때가 있습니다.
DDD 13기 사이드 프로젝트 Pickflow에서 지도와 스팟 리스트 API를 검증하며 이 간극을 메우는 워크플로우를 만들었습니다. 프록시 도구 대신 앱의 네트워크 로그를 stdout으로 흘리고, 사람이 시뮬레이터를 한 단계씩 조작하면 AI 코딩 에이전트가 로그를 읽어 명세와 비교하는 방식입니다.
사람과 AI가 한 동작씩 주고받는 모습이 탁구 같아서 이 과정을 핑퐁 테스트라고 불렀습니다. 30분 정도의 한 세션에서 단위 테스트를 통과했던 실제 버그 7개를 찾았습니다.
왜 Charles 대신 텍스트 로그였나
Charles나 Proxyman은 훌륭한 네트워크 분석 도구입니다. 다만 이번 목표에는 다른 조건이 하나 있었습니다. AI 에이전트가 요청과 응답을 직접 읽고, 이전 단계와 비교하고, 이슈를 문서에 누적해야 했습니다.
그래서 관측 지점을 프록시가 아니라 앱 안으로 옮겼습니다.
- Alamofire
EventMonitor로 요청과 응답을 stdout에 기록합니다. simctl launch --console-pty로 앱을 실행해 콘솔을 파일에 저장합니다.- 사람은 화면을 조작하고 AI는 새 로그만 읽습니다.
- 명세와 다르면 수정부터 하지 않고 이슈를 기록한 뒤 다음 시나리오로 넘어갑니다.
이 구조에서 사람은 제스처와 시각적 상태 확인을 맡고, AI는 반복적인 로그 검색과 명세 비교를 맡습니다. 역할이 명확하니 서로 같은 화면을 설명하느라 쓰는 시간이 줄었습니다.
1. Alamofire EventMonitor로 관측 지점 만들기
네트워크 요청이 하나의
NetworkManager로 모여 있다면Session에 임시 모니터를 주입하는 것으로 충분했습니다.private static func defaultSession() -> Session { #if DEBUG return Session( interceptor: AuthInterceptor(), eventMonitors: [ConsoleNetworkLogger()] ) #else return Session(interceptor: AuthInterceptor()) #endif }요청은 최종
URLRequest가 만들어지는didCreateURLRequest에서, 완료 결과는didCompleteTask에서 기록했습니다.#if DEBUG private final class ConsoleNetworkLogger: EventMonitor { let queue = DispatchQueue(label: "com.pickflow.ConsoleNetworkLogger") func request(_ request: Request, didCreateURLRequest urlRequest: URLRequest) { let method = urlRequest.httpMethod ?? "?" let url = urlRequest.url?.absoluteString ?? "(no url)" print("🌐 [REQ] \(method) \(url)") } func request( _ request: Request, didCompleteTask task: URLSessionTask, with error: AFError? ) { let method = task.originalRequest?.httpMethod ?? "?" let path = task.originalRequest?.url?.absoluteString ?? "(no url)" let status = (task.response as? HTTPURLResponse)?.statusCode ?? -1 if let error { print("❌ [ERR] \(method) \(path) status=\(status) error=\(error.localizedDescription)") } else { print("✅ [RES] \(status) \(method) \(path)") } } } #endif이때 세 가지 규칙을 정했습니다.
- 응답 바디를 남길 때는 앞부분만 잘라 콘솔이 민감 정보와 대용량 데이터로 오염되지 않게 합니다.
- 로거는 반드시
#if DEBUG안에 두고 검증이 끝나면 제거하거나 별도 변경으로 분리합니다. - Alamofire 문서상
didCompleteTask는 재시도 시 한 요청에서 여러 번 호출될 수 있습니다. 따라서 중복 요청을 판정할 때 앱 이벤트의 중복과 네트워크 재시도를 구분해야 합니다. 실무에서는 요청 ID나 재시도 횟수를 함께 남기면 더 안전합니다.
2. Xcode 없이 시뮬레이터 콘솔 붙잡기
Tuist 프로젝트에서 사용한 풀사이클은 다음과 같습니다.
# 시뮬레이터 부팅 xcrun simctl boot "$UDID" 2>/dev/null || true open -a Simulator --args -CurrentDeviceUDID "$UDID" xcrun simctl bootstatus "$UDID" -b # 프로젝트 생성과 빌드 tuist generate --no-open xcodebuild \ -workspace Pickflow.xcworkspace \ -scheme Pickflow \ -configuration Debug \ -destination "platform=iOS Simulator,id=$UDID" \ -derivedDataPath build \ build # 설치 후 stdout을 파일로 캡처 xcrun simctl install "$UDID" build/Build/Products/Debug-iphonesimulator/Pickflow.app xcrun simctl launch --console-pty "$UDID" com.pickflow 2>&1 | tee /tmp/pickflow_console.log &핵심은 마지막 줄입니다. 앱의 stdout이 터미널과 로그 파일에 동시에 쌓이기 때문에 AI 에이전트도 매 단계의 최신 요청을 읽을 수 있습니다.
grep -E "\[REQ\]|\[RES\]|\[ERR\]" /tmp/pickflow_console.log | tail -203. 명세를 핑퐁 시나리오로 바꾸기
검증을 시작하기 전에 기능 명세를 트리거 → 기대 호출 형태로 바꿨습니다.
트리거 기대 호출 확인 포인트 지도 진입 GET /v1/spots/viewport1회좌표 4개, 중복 호출 여부 카메라 팬·줌 debounce 후 viewport 1회 이동 중 연속 호출 여부 무드 필터 탭 theme쿼리 추가화면용 라벨과 API 코드 구분 마커 탭 preview 호출 medium 시트 시점 시트 large 전환 detail 호출 지연 호출과 중복 방지 북마크 ON·OFF POST·DELETE 상태 코드와 UI 반영 그다음 랠리는 단순했습니다.
- AI: “지도에 진입해 주세요.”
- 사람: 시뮬레이터에서 조작한 뒤 “했어.”
- AI: 직전 시점 이후의 로그만 읽고 명세와 비교합니다.
- 이슈가 나오면 문서에 번호를 붙여 기록하고 다음 시나리오로 진행합니다.
여기서 가장 중요했던 규칙은 이슈를 발견해도 즉시 고치지 않는 것이었습니다. 발견할 때마다 수정, 빌드, 재검증을 반복하면 전체 흐름이 끊깁니다. 우선 모든 시나리오를 완주하니 하나의 디코딩 문제가 여러 화면을 동시에 실패시키는 식의 연결고리도 보였습니다.
실제로 발견한 7가지 문제
이 워크플로우로 다음 문제를 찾았습니다.
- 지도 진입 시 viewport API가 네 번 호출되고 모두 400을 반환했습니다.
- 카메라 bounds의 위·경도가 서버 허용 범위보다 긴 소수점 자릿수로 직렬화됐습니다.
- 무드 필터의 한국어 표시 라벨을 API 코드로 변환하지 못해
theme파라미터가 조용히 사라졌습니다. - 리스트 응답의 축약 enum 코드가 클라이언트 모델과 달라 200 응답 뒤에 디코딩이 실패했습니다.
- 바텀시트가 나타나고 커질 때 detail API가 합계 네 번 호출됐습니다.
- 상세 응답의
null필드와 non-optional 모델이 충돌해 화면 전체가 실패했습니다. - 같은 좌표 정밀도 문제가 시뮬레이터에서는 숨고 실기기에서만 400으로 재현됐습니다.
중복 호출은 “한 번 호출했다”는 테스트만으로 놓치기 쉽다
초기 지도 진입에는
onAppear, 카메라 idle 콜백, 위치 권한 콜백이 거의 동시에 들어왔습니다. 각각은 정상 경로였지만 합치면 같은 API가 네 번 나갔습니다. 300ms debounce와 이전Task취소를 적용해 마지막 이벤트만 요청하도록 정리했습니다.바텀시트도 비슷했습니다. preview와 detail의 책임을 분리하고, detail은 large detent에 들어왔을 때만 lazy fetch하도록 바꿨습니다.
.loading과.loaded상태에서는 다시 요청하지 않는 가드도 추가했습니다.HTTP 200은 화면 성공을 보장하지 않는다
리스트 API는 200 OK였지만 UI는 “불러오기 실패”를 표시했습니다. 응답의
theme값은 축약 코드였고 클라이언트 enum은 풀네임만 허용해 non-optional 필드에서 디코딩 전체가 실패했습니다.이 사례 이후 핑퐁 로그에서는 상태 코드만 보지 않고, 응답 바디의 핵심 필드와 앱의 디코딩 에러를 함께 확인하게 됐습니다.
시뮬레이터 통과가 위치 로직의 통과는 아니다
viewport는 카메라 bounds가 긴 소수점 좌표를 만들어 시뮬레이터에서도 바로 실패했습니다. 반면 당시 시뮬레이터가 반환한 테스트 위치는 우연히 서버의 소수점 제한을 만족해 list와 preview의 같은 문제가 숨어 있었습니다. 실기기 GPS 값으로 바뀌자 400이 재현됐습니다.
해결은 좌표를 보내는 모든 엔드포인트에서 동일한 정규화 규칙을 적용하고, 직렬화 결과를 단위 테스트로 고정하는 것이었습니다. Apple도 시뮬레이터가 실제 기기의 성능과 모든 하드웨어 특성을 그대로 재현하지는 않는다고 안내합니다.
AI를 붙였을 때 특히 좋았던 점
이 워크플로우의 핵심은 AI가 앱을 대신 사용하는 것이 아니라, 사람이 만든 관측 가능한 신호를 AI가 반복적으로 판정하게 한 데 있습니다.
- 로그가 텍스트라서 요청 횟수, URL, 파라미터, 상태 코드를 빠르게 비교할 수 있었습니다.
- 명세 표가 그대로 실행 순서이자 체크리스트가 됐습니다.
- 이슈 문서를 계속 갱신해 Jira 하위 작업과 PR 분할 기준으로 재사용할 수 있었습니다.
- 사람은 제스처와 화면 변화에 집중하고 AI는 로그 검색과 기록을 맡았습니다.
반대로 “로그를 보고 알아서 디버깅해 줘”처럼 기준 없이 맡기면 판정이 흔들립니다. AI에게 필요한 것은 더 긴 프롬프트보다 관측 가능한 로그, 명시적인 기대값, 단계 경계였습니다.
다시 사용한다면 추가할 체크리스트
- 시나리오 시작 전 로그 파일을 새로 만들고 단계별 시작 시각을 기록합니다.
- 요청 ID, retry 횟수, 화면 트리거 이름을 함께 남깁니다.
- 토큰, 쿠키, 개인정보가 로그에 포함되지 않도록 헤더와 바디를 필터링합니다.
- 중복 호출, 파라미터 누락, 상태 코드, 디코딩 성공을 서로 다른 판정 항목으로 둡니다.
- 시뮬레이터 시나리오가 끝나면 위치·카메라·센서 의존 경로는 실기기에서 한 번 더 확인합니다.
- 임시 로거의
#if DEBUG와 제거 여부를 PR 체크리스트에 넣습니다.
마무리
핑퐁 테스트는 새로운 테스트 프레임워크가 아닙니다. 앱의 실제 요청을 텍스트로 관측 가능하게 만들고, 명세를 짧은 시나리오로 쪼개고, 사람과 AI가 각자 잘하는 일을 번갈아 수행하는 운영 방식입니다.
거창한 도구 없이도 중복 호출, 좌표 직렬화, 서버·클라이언트 enum 불일치, 환경 의존 재현처럼 단위 테스트만으로 놓치기 쉬운 문제를 빠르게 드러낼 수 있었습니다.
AI 디버깅의 품질은 모델이 얼마나 많은 코드를 읽었는가보다, 실행 결과를 얼마나 명확하게 관측하고 기대값과 비교할 수 있는가에 더 크게 좌우된다는 것을 배운 경험이었습니다.
참고 자료
반응형'Dev' 카테고리의 다른 글
[AI] UI를 개발할 때 화면 하나를 TDD 3단계로 강제한 방법 (0) 2026.07.23 [AI] git worktree와 cmux 활용한 AI 병렬 개발 워크플로우 (0) 2026.07.23 youtube 요약 n8n 워크플로우 구축기 (0) 2026.05.21 Claude Desktop로 WWDC 요약하기 (Puppeteer, MCP) (0) 2025.04.11 [jekyll] 로컬에서 구동시 Dependency Error 문제 해결하기 (0) 2024.03.21