Engineering · Knowledge Graph

XGEN CREATOR: 클릭이 실행한 백엔드 코드 라인을 증거로 산출물을 만드는 라이브 트레이서

브라우저 클릭을 프론트엔드 소스에 연결하고, 그 클릭이 실행한 백엔드 Python 라인을 sys.monitoring 기반 트레이서로 캡처해 UI 정의서·테스트결과서·API 명세서를 생성하는 도구입니다. 실제 XGEN 스택에서 관측이 대상을 멈추는 문제, 산출물은 나오는데 트레이스가 0건인 조용한 실패, 콜스택 깊이 오차를 차례로 고쳤습니다.

기간
2026.07 – 2026.07
소속
개인 프로젝트
역할
설계·구현

Key Results

3,948건 · 442ms
단일 API 요청의 라인 이벤트 캡처실제 XGEN 스택의 노드 조회 API 직접 호출, sys.monitoring 백엔드, 실소스 9개 파일
60초+ → 93ms
대형 핸들러 추적 완료 시간벽시계 예산 도입 전후 같은 엔드포인트, 수정 후 라인 이벤트 3,965건
0 → 3,921건
전체 여정의 백엔드 라인 트레이스로그인부터 캔버스까지 같은 여정, 리라우트 매처와 벽시계 예산 수정 전후

납품 산출물(UI 정의서, 테스트결과서, API 명세서)은 보통 사람이 UI를 직접 조작하며 스크린샷을 찍고, 백엔드 동작은 코드를 읽어 추정해 씁니다. 문서와 실제 동작이 어긋나도 그 차이를 검증할 근거가 문서 안에 없습니다. XGEN CREATOR는 이 과정을 거꾸로 세운 개인 프로젝트입니다. IDE 디버거가 코드를 한 줄씩 따라가듯, 에이전트가 실제 브라우저에서 버튼을 누르면 그 요청이 실행한 백엔드 소스 라인을 따라가고, 스크린샷과 요소의 소스 위치, API 호출, 실행된 라인을 하나의 증거 단위로 묶어 산출물을 렌더합니다.

같은 시기에 만든 XGEN MAKER가 요청으로 코드를 바꾸는 정적 도구라면, CREATOR는 실행으로 문서를 만드는 동적 도구입니다. 대전제는 하나였습니다. 산출물의 모든 칸은 증거로 소급돼야 하고, 증거가 없으면 추정으로 채우지 않고 "증거 없음"으로 남깁니다.

코어는 하루 만에 만들었지만 실제 XGEN 스택에 연결하자 문제가 이어졌습니다. 관측 장치가 대상 서버를 수 분간 멈추게 했고, 재감사에서는 산출물이 멀쩡히 나오는데 백엔드 트레이스가 0건인 조용한 실패가 드러났고, 디버거 리플레이의 콜스택 깊이가 예외와 코루틴에서 틀어졌습니다. 이 글은 그 과정을 따라갑니다.

증거 단위와 비침습 원칙

설계는 아래 흐름 하나로 요약됩니다.

에이전트/사람 ──클릭──► 브라우저 (Playwright)
                          │ 스텝마다 트레이스 ID를 요청 헤더로 주입
                          ▼
     프론트엔드 (Next.js 모노레포) ──API──► 백엔드 (Python, ASGI)
                          │                    │ 트레이스 미들웨어 + 라인 트레이서
           요소 → 소스 리졸버                  │ 실행된 라인만 캡처
                          ▼                    ▼
     증거(Step) = 스크린샷 · 요소↔프론트엔드 소스 · API · 실행된 백엔드 라인
                          ▼
     빌드 파이프라인 → UI 정의서 · 테스트결과서 · API 명세서 (md, html, PDF)

증거의 연쇄를 여정(Journey)이라 부르고, 여정 JSON을 진실의 원천으로 둡니다. 문서는 그 렌더일 뿐이므로 여정 해시가 바뀐 경우에만 다시 렌더하고, 소스 변경을 감지하면 여정을 재수집합니다. 산출물 양식은 레지스트리로 관리해 새 양식은 렌더 함수 한 쌍을 등록하면 됩니다.

경계는 처음부터 보수적으로 잡았습니다. 대상 저장소는 읽기와 실행 관측만 하고 한 줄도 커밋하지 않습니다. 유일한 접점은 로컬에서 백엔드를 기동할 때 ASGI 앱을 감싸는 미들웨어 한 줄이고, 그것도 대상 저장소가 아니라 구동 스크립트에 둡니다. 관측은 opt-in이라 트레이스 헤더가 없는 요청은 오버헤드 없이 통과합니다. 의존성은 Python 표준 라이브러리 중심이고 Playwright만 선택 설치입니다.

from xgen_creator.trace import CreatorTraceMiddleware
app = CreatorTraceMiddleware(app, roots=["/path/to/backend/src"])

모델 역할도 분리했습니다. 트레이스와 렌더를 맡는 결정론 코어는 LLM 없이 완전히 동작합니다. 오케스트레이션 모델은 자연어 목표를 받아 UI 요소 목록을 관찰하고 다음 행동을 고르며, 서술 모델은 이미 캡처된 증거 위에 문장만 입힙니다. LLM은 서술을 입히는 존재이지 사실을 만드는 존재가 아니라는 원칙입니다. 실제 서술 결과에서도 "HTTP 상태 상세는 증거 없음"처럼 증거 밖의 내용은 쓰지 않는 것을 확인했습니다.

클릭과 실행을 1:1로 묶는 트레이스 상관

브라우저 액션과 백엔드 실행을 묶는 방법은 단순합니다. 브리지가 스텝마다 고유 ID를 발급해 요청 헤더로 주입하고, 미들웨어는 같은 ID로 실행 기록을 저장소에 쓰고, 브리지가 그 ID로 기록을 회수합니다. 미들웨어와 브리지는 파일시스템 저장소를 공유하고, 원자적 교체로 저장해 반쯤 쓴 파일이 읽히지 않게 했습니다.

클릭한 요소가 어느 프론트엔드 소스인지는 정적 리졸버가 후보 순위를 매깁니다. data-testid 일치에 100점, id에 60점, 텍스트에 45점, 클래스에 15점을 줍니다. 프로덕션 빌드에서는 소스맵과 컴포넌트 디버그 정보가 사라지기 때문에, 런타임 정보 대신 정적 검색과 라우트맵을 기본으로 삼았습니다.

미니 ASGI 데모 앱으로 첫 E2E를 실행했을 때 버튼 클릭 한 번이 POST 200과 라인 이벤트 70건으로 이어졌습니다. 실행 의미론도 맞았습니다. 입력 [4, 7, -2, 10, 3]에서 음수 분기의 return에는 실행 표시가 붙었고, 예외를 던지는 분기와 except 블록에는 붙지 않았고, 호출되지 않은 함수는 슬라이스에 아예 없었습니다. 클릭한 버튼은 data-testid 정확 일치로 해당 컴포넌트 파일의 3번째 줄에 연결됐습니다. 실제 XGEN 프론트엔드에서도 로그인 입력 필드가 모노레포의 로그인 feature 패키지 소스로 연결됐습니다. 이때 백엔드 트레이스는 게이트웨이 경로에 미들웨어가 없어 예상대로 "증거 없음"으로 표기됐습니다.

실제 스택에 연결하자 관측이 대상을 멈췄다

실제 XGEN 스택의 백엔드는 컨테이너 안에서 실행되고 있어 미들웨어를 직접 끼울 수 없었고, 게이트웨이와 인프라를 건드리지 않는다는 원칙도 지켜야 했습니다. 그래서 대상 워크플로우 서비스를 도커 밖에서 로컬로 기동하고 미들웨어로 감싸는 사이드카 방식을 택했습니다. 서비스가 Python 3.14를 요구해 별도 가상환경을 만들었고, 컨테이너의 환경 변수를 호스트에 공개된 DB, 캐시, 벡터 저장소, 오브젝트 스토리지로 다시 매핑했습니다. 일부 패키지는 3.14용 Windows wheel이 없어 가상환경 안에서만 버전 조건을 완화했고, 사내 SDK는 PyPI 배포본이 컨테이너의 소스 설치본보다 코드가 뒤처져 있어(버전 번호는 같았습니다) 컨테이너의 SDK를 그대로 복사했습니다. 그 결과 코드 수정 없이 사이드카가 실제 DB에 연결된 채 기동했습니다. 브라우저 수준에서는 특정 API 경로만 사이드카로 우회시키는 리라우트 규칙을 두어, 게이트웨이를 수정하지 않고도 해당 요청만 관측할 수 있게 했습니다.

첫 상관은 성공했지만 응답은 401이었습니다. 프론트엔드 프록시가 주입하던 사용자 식별 헤더가 우회 경로에는 없었기 때문입니다. 식별 헤더 세 개를 브리지 요청에 병합하자 인증을 통과해 실제 핸들러가 실행됐습니다. 그런데 인가된 노드 조회 요청을 추적하자 20만 이벤트 상한까지 기록하고 거대한 페이로드를 직렬화하는 동안 이벤트 루프가 수 분간 막혔습니다. 관측 도구가 관측 대상을 멈춘 것입니다.

저장 단계를 고쳤습니다. 슬라이스는 이벤트가 많은 상위 30개 파일까지만 저장하고 초과 개수를 표기하며, 저장은 압축하고, 저장 호출을 finally로 옮겨 클라이언트가 연결을 끊어도 증거가 남게 했습니다. 사이드카의 기본 이벤트 상한은 5만으로 낮췄습니다. 수정 뒤 인가된 헬스 체크는 라인 이벤트 52건을 6.87ms에 기록했고, 404 경로도 4개 이벤트로 추적돼 잘못된 호출도 증거로 남았습니다. 이 과정에서 sys.settrace의 오버헤드가 대형 앱에서는 임계 수준이라는 것도 실측으로 확인됐습니다.

settrace에서 sys.monitoring으로

P0 트레이서는 sys.settrace로 만들었습니다. 모든 Python 버전에서 동작하고 구현이 단순하기 때문입니다. 대신 함수 호출마다 전역 트레이스 함수가 불리므로, 스코프 밖 코드라 바로 반환하더라도 대형 앱에서는 비용이 쌓였습니다. Python 3.12의 sys.monitoring(PEP 669)에서는 콜백이 DISABLE을 반환하면 그 코드 위치의 이벤트가 영구히 꺼집니다. 스코프 밖 라이브러리 코드는 처음 한 번만 판정하고 그 뒤로는 비용이 들지 않습니다.

선택지 장점 단점 판단
sys.settrace 유지 전 버전 호환, 구현 단순 스코프 밖 코드에도 매번 콜백, 대형 앱에서 오버헤드 임계 P0에만 사용
sys.monitoring 단독 스코프 밖 위치를 영구 배제해 저오버헤드 3.12 이상 필요, 도구 ID 슬롯이 소진되면 사용 불가 단독 채택하지 않음
monitoring 기본 + settrace 폴백 대형 앱 성능과 호환성을 함께 확보 두 백엔드의 의미론을 모두 검증해야 함 채택

인터페이스는 그대로 두고 백엔드만 바꿨습니다. 3.12 이상이면 monitoring을 쓰고, 버전이 낮거나 도구 ID 슬롯을 얻지 못하면 settrace로 내려갑니다. monitoring은 실행 중이던 프레임의 이벤트도 잡기 때문에 settrace보다 정확했고, 대신 테스트의 스코프를 파일 단위로 좁혀야 했습니다.

실제 XGEN 스택에서 노드 조회 API를 직접 호출하면 실소스 9개 파일, 라인 이벤트 3,948건을 442ms에 캡처했습니다. 로그인부터 대시보드, 캔버스까지 이어지는 전체 여정에서는 캔버스 스텝의 같은 API가 3,921개 이벤트(7개 파일)로 기록됐고, 테스트결과서 부록에 실행된 라인이 표시와 함께 렌더됐습니다. 서술 모델은 보안 헤더 미들웨어에서 노드 목록 핸들러, 사용자 식별, DB 획득, 노드 스펙 조회로 이어지는 실제 흐름을 서술했습니다. 이후 로그인을 결정론 스텝으로 먼저 수행하고 오케스트레이션 모델이 이어받는 경로도 검증했는데, 모델이 앱에 없는 주소(about:blank 같은)로 이동하려는 결함이 나와 이동 대상을 앱 내부 경로로 제한했습니다.

산출물은 나오는데 트레이스는 0건이었다

다음 날 실제 스택에서 전체 경로를 다시 실행해 결과를 하나씩 셌습니다. 산출물, 프레임, 서술은 모두 나왔는데 백엔드 라인 트레이스가 0건이었습니다. 산출물만 보면 성공처럼 보였기 때문에 이전 회차의 "된다"는 판단을 그대로 믿을 수 없었습니다. 추측 대신 여정 기록, 사이드카 로그, 직접 curl 호출을 대조해 원인을 두 개로 좁혔습니다.

첫째, 리라우트 글롭이 쿼리스트링을 매칭하지 못했습니다. 노드 API 경로 패턴은 쿼리 파라미터가 붙은 실제 요청 URL을 잡지 못했고, 요청은 사이드카가 아니라 실서버로 조용히 새고 있었습니다. 에러가 나지 않으니 트레이스가 비어 있다는 사실 말고는 신호가 없었습니다. 쿼리를 떼고 경로에만 글롭을 적용하는 매처로 바꿨습니다.

def reroute_matcher(pattern: str):
    glob = pattern.replace("**", "*")
    def match(url: str) -> bool:
        parts = urlsplit(url)  # 쿼리스트링을 떼고 경로에만 글롭을 적용
        return fnmatch.fnmatch(f"{parts.scheme}://{parts.netloc}{parts.path}", glob)
    return match

둘째, 대형 핸들러를 추적하던 요청이 60초 이상 멈춰 완료되지 않았고, 그래서 저장도 되지 않았습니다. 전역 락으로 한 번에 한 요청만 추적하던 구조에서 락 대기가 연쇄된 것입니다. 트레이서에 벽시계 예산(미들웨어 10초, 사이드카 6초)을 두고, 예산을 넘기면 기록을 멈춘 뒤 결과에 timed_out을 명시하고 산출물에 경고를 표기하게 했습니다. 시계 호출 비용을 줄이려고 예산 검사는 이벤트 512개마다 합니다.

수정 전 직접 curl은 60초 동안 응답이 없었고(HTTP 000), 수정 후에는 93ms에 200과 3,965라인을 반환했습니다. 전체 경로를 다시 실행하자 캔버스 스텝에서 3,921라인(7개 파일), 디버거 리플레이, 프레임 6/6, 산출물 15개, PDF 4개가 모두 생성됐습니다. 이때 정한 원칙은 산출물이 나온다는 것과 캡처가 됐다는 것은 다르고, 실제 스택에서 백엔드 트레이스 건수를 직접 세야 "된다"고 말할 수 있다는 것입니다.

같은 감사의 연장선에서 블로킹 전역 락을 없앴습니다. 이미 추적 중인 요청이 있으면 두 번째 요청은 막지 않고 관측만 생략하며 그 사실을 기록합니다. 호출 깊이는 스레드 로컬로 바꿔 멀티스레드 핸들러의 스택도 정확하게 했고, 이벤트 기록에는 락을 두어 동시 append를 보호했습니다. 선택 옵션으로 라인마다 지역 변수 값을 스냅샷하는 data flow 추적도 더해, "어느 라인이 실행됐나"를 넘어 "그때 변수가 무엇이었나"까지 볼 수 있게 했습니다.

콜스택 깊이가 부풀던 이유

캡처한 트레이스는 IDE 디버거처럼 한 줄씩 재생하는 리플레이 뷰로 제공합니다. 현재 라인을 강조하고 각 이벤트의 깊이로 콜스택을 재구성하므로, 깊이 계산이 틀리면 리플레이 전체가 틀립니다.

코어를 정독하며 엣지 케이스를 실행하다 첫 결함을 찾았습니다. monitoring 백엔드는 함수 진입(PY_START)에서 깊이를 올리고 정상 반환(PY_RETURN)에서 내렸는데, 함수가 예외로 빠져나갈 때는 PY_RETURN이 아니라 PY_UNWIND가 발생합니다. 이 이벤트를 구독하지 않아 try/except가 있는 코드, 즉 대부분의 코드를 지난 뒤 모든 라인의 깊이가 하나씩 부풀었습니다. settrace는 예외로 빠져나갈 때도 return 이벤트가 오기 때문에 이미 정확했습니다.

두 번째는 일시 중단이었습니다. 제너레이터의 yield와 코루틴의 await에서 프레임은 스택을 떠났다가 돌아오는데, PY_YIELD와 PY_RESUME을 구독하지 않아 중단 이후 호출자 라인의 깊이가 부풀었습니다. 제너레이터를 소비하는 코드에서 [2]여야 할 깊이가 [2, 3]으로 기록됐습니다. XGEN 핸들러는 대부분 async이고 DB를 await하므로 실제로 지나는 경로였습니다.

events = (mon.events.LINE | mon.events.PY_START | mon.events.PY_RETURN
          | mon.events.PY_UNWIND   # 예외로 빠져나갈 때 깊이 -1
          | mon.events.PY_YIELD    # yield·await로 일시 중단될 때 깊이 -1
          | mon.events.PY_RESUME)  # 재개될 때 깊이 +1
mon.set_events(tool_id, events)

수정 후 두 백엔드에서 예외, 제너레이터, 재귀, async 경우를 모두 통과했고, 실제 노드 조회 API(3,965라인)의 깊이가 1~6 사이에서 급증 없이 연속되는 것을 확인했습니다. 저장소에 트레이스 100개를 동시에 써도 원자적 교체 덕분에 손상이 없어, 멀티프로세스 워커가 각자 트레이스를 남겨도 안전하다는 것도 확인했습니다.

결과와 남은 과제

지표 이전 이후 측정 조건
대형 핸들러 추적 완료 60초 이상 무응답(HTTP 000) 93ms, 3,965라인 같은 노드 조회 엔드포인트 직접 호출, 벽시계 예산 도입 전후
인가된 헬스 체크 추적 이벤트 루프 수 분 블로킹 6.87ms, 52라인 저장 상한·압축·finally 저장 적용 전후
전체 여정 백엔드 트레이스 0건(조용한 실패) 3,921라인, 7개 파일 로그인부터 캔버스까지 같은 여정, 리라우트 매처 수정 전후
노드 조회 API 캡처 - 3,948라인, 9개 파일, 442ms 실제 XGEN 스택, sys.monitoring 백엔드
자동 테스트 24건 82건 단위·통합 테스트 전부 통과

멀티프로세스 워커는 프로세스마다 독립 트레이스가 되는 설계상 한계가 있습니다. 사이드카는 상시 구동이 아니라 검증 때마다 기동했고, 무거운 응답을 처리한 뒤 가끔 종료돼 이벤트 상한을 1만 5천으로 낮춰 완화했을 뿐이라 상시화하려면 재기동 가드가 필요합니다. 산출물 인덱스를 제품 프론트엔드의 라우트로 서빙하는 통합은 템플릿까지만 만들었고, 실제 반영은 해당 저장소의 승인 절차를 거쳐야 합니다. 장기적으로는 CREATOR가 캡처한 런타임 엣지(버튼에서 핸들러, 실행 라인까지)가 정적 분석으로는 얻기 어려운 크로스레포 호출 관계라서, MAKER의 지식그래프를 실측으로 보강하는 데 쓸 수 있다고 보고 있습니다.

돌아보면 이 프로젝트에서 반복된 패턴은 "나왔다"와 "됐다"의 차이였습니다. 산출물 파일, 테스트 통과, 첫 E2E 성공은 모두 "나왔다"였고, 실제 스택에서 트레이스 건수와 콜스택 깊이를 직접 세었을 때 비로소 결함이 보였습니다. 관측 도구는 대상을 관측하기 전에 자기 자신이 무엇을 놓치는지부터 의심해야 한다는 것이 이 작업의 결론입니다.