ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 동료가 PR에 "이거 그냥 부탁이잖아요" 한 줄을 달았어요
    AI Agent 2026. 6. 25. 09:00
    728x90
    반응형

    대문자로 박아 넣고 나니까 마음이 좀 놓였어요.

    에이전트한테 일을 맡기기 직전이었거든요.
    프롬프트 맨 위 첫 줄에, 제가 끌어낼 수 있는 가장 단호한 표현을 다 모아서 한 줄을 적었어요.
    "이 검사가 빨강이면 절대 커밋하지 마.
    먼저 고쳐." 대문자에, 느낌표에, 번호까지 붙여서 999번 규칙이라고 못을 박았어요.
    999면 제일 위라는 뜻이었어요.
    무슨 일이 있어도 이건 어기면 안 된다는, 제 딴엔 최후의 줄이었던 거죠.
    그렇게 적어두고 나니까 이제 됐다 싶었어요.
    그날 밤은 별 걱정 없이 노트북을 덮었어요.

    그 안심은 다음 날 PR에서 깨졌어요

    그게 무너진 건 다음 날 PR이었어요.

    같은 변경을 동료가 슥 훑다가, 그 999 줄 옆에 코멘트를 하나 달았어요.
    길지도 않았어요.
    "이거 그냥 부탁이잖아요.
    모델이 무시하고 커밋해도 막는 게 없는데요." 거창한 리뷰도, 따지는 말투도 아니었어요.
    지나가다 본 거 한 줄 적은 정도였어요.
    그런데 그 한 줄을 읽고 나서 한참을 못 넘겼어요.
    맞는 말이었거든요.
    프롬프트는 손목까지 붙잡아주진 않더라고요.
    모델이 "네, 안 어기겠습니다" 대답하고 바로 그다음 줄에서 어겨도, 셸은 아무 일 없었다는 듯 커밋을 통과시키니까요.
    저는 그냥 적어두기만 한 거였어요.
    막히는 건 따로 만들어야 했고요.

    변명을 좀 해보고 싶긴 했어요.
    "프롬프트 우선순위를 더 올리면 되죠" 같은 게 머릿속에 떠올랐거든요.
    근데 그 반박을 끝까지 따라가 보니까, 전부 "모델이 잘 따라줄 거다" 위에 서 있더라고요.
    대부분 따라준다는 게 위안이 안 됐어요.
    한 번이라도 안 따른 그 한 번에, 빨강인 채로 커밋된 상태가 들어와 쌓이는 거니까요.
    그래서 변명을 접었어요.

    막는 손을, 모델이 못 닿는 자리로 옮겼어요

    규칙이 진짜이려면, 막는 손이 모델이 못 닿는 자리에 있어야 했어요.
    그래서 그날 그 한 줄을 프롬프트에서 떼어다가, 커밋 직전에 돌아가는 작은 게이트 스크립트 안에 옮겨 적었어요.
    거기서 계약서에 적힌 검사 명령들을 전부 실행하고, 하나라도 빨강이면 스크립트가 exit 1로 죽게요.
    0이 아닌 코드로 죽으면 pre-commit 훅이 커밋을 그냥 거부해요.
    이제 모델이 "고쳤습니다" 하고 말로 우겨도 셸은 종료 코드만 봐요.

    다 붙이고 나서, 진짜 막히는지 일부러 시험해봤어요.
    가짜 토큰 하나를 일부러 staged 해놓고 커밋 게이트를 돌렸거든요.
    시크릿 스캐너가 그걸 잡고 게이트가 exit 1로 죽으면서, 커밋이 거부됐어요.
    같은 리허설에서 스캐너를 detect 모드로 한 번 더 돌렸더니, 한 작업 레포에서 159건, 다른 레포에서 2건이 빨강으로 주르륵 떴어요.
    진짜 시크릿들이었어요.
    막힌다를 말로 믿는 거랑, 빨강 159라는 숫자가 화면에 차오르는 걸 보는 건 손맛이 달랐어요.

    함정 하나는 처음엔 못 봤어요.
    검사할 게 0개일 때요.
    테스트가 한 건도 수집 안 되면 종료 코드가 5로 떨어지는데, 5는 "실패 0건"이라 무심코 통과로 셀 뻔했어요.
    근데 0개면 안 돌린 거지 통과한 게 아니잖아요.
    게다가 CI처럼 비대화형 환경에선 확인용 env가 없으면 위험 작업이 exit 5로 abort하게 묶어뒀는데, 그것도 5라 헷갈렸어요.
    그날 게이트에 if 한 줄을 더 넣어서, 그 5들을 다 빨강에 묶었어요.

    그런데 기계 판정만으론 부족했어요

    기계가 판정하는 것만으론 부족하다는 건 며칠 전에 한 번 데여서 알았어요.
    그날도 테스트는 다 초록이었거든요.
    근데 습관처럼 빌드를 직접 띄워봤는데, 화면 한쪽 글자가 통째로 안 보이는 거예요.
    테스트는 그 요소가 화면에 붙어 있는지까진 봐요.
    근데 진짜로 눈에 그려지는지는, 거기까진 안 보더라고요.
    그래서 게이트에 하나를 더 묶었어요.
    모델이 만지는 코드 바깥, 호스트 쪽에서 화면을 캡처해 기준 이미지랑 픽셀 단위로 비교하는 검사요.
    폰트랑 뷰포트, device pixel ratio를 전부 고정해놓고 돌렸어요.
    안 고정하면 코드를 한 줄도 안 바꿔도 같은 화면이 4.49%씩 흔들리거든요.
    골든이 저 혼자 떨면, 진짜 회귀가 그 노이즈에 묻혀버려요.

    그 픽셀 디프가 제일 무덤덤하게 일을 했어요.
    per-channel로 12 미만 델타는 안티앨리어싱이라 일부러 흡수하고, 변경된 픽셀이 1%를 넘으면 HOLD를 내면서 exit 2로 멈춰요.
    0이면 PASS, 2면 HOLD, 크기가 안 맞으면 3으로 죽었어요.
    처음엔 이 숫자들이 자꾸 헷갈렸고요.

    디프에 뜬 X자 무늬의 정체

    한 화면 군을 레퍼런스 이미지에 픽셀 단위로 맞춰가는 수렴 루프를 돌리던 날이었어요.
    디프 마스크에 X자 무늬가 떴어요.
    사람 눈엔 그냥 가장자리가 흐릿한 노이즈처럼 보였어요.
    안티앨리어싱이라고 넘어갈 뻔했는데, 스크립트는 그 영역을 changed로 세서 HOLD를 냈더라고요.
    파보니까 노이즈가 아니었어요.
    두 캡처의 대각선 그라데이션 방향이 서로 반대였어요.
    그 각도를 손으로 좌표에 옮기는 과정에서 기울기 부호가 뒤집힌 거예요.
    마스크의 X자는 "두 그림의 선 방향이 다르다"는 신호였어요.
    고치고 나니 그 상태의 diff_ratio가 0.073788에서 0.068916으로 떨어졌고, 옆 상태도 0.036444에서 0.035898로 같이 내려갔어요.

    다른 사이클에선 9~11px짜리 깨끗한 가로 띠가 떴어요.
    색 문제인 줄 알고 한참 색만 봤어요.
    결국 자식 노드 목록을 쭉 훑고 나서야, 레퍼런스엔 있는 간격이랑 라벨 분기가 구현에선 통째로 빠진 걸 봤어요.
    색이 아니라, 거기 있어야 할 구조가 없던 거죠.
    마스크 무늬마다 원인이 다르다는 걸 그때서야 좀 알아챘어요.
    X자는 방향이 뒤집힌 거였고, 흐릿한 건 그냥 안티앨리어싱이었고.
    깔끔한 가로 띠는, 솔직히 그날은 뭔지 몰랐다가 한참 뒤에야 빠진 자식 노드인 걸 알았어요.

    그 수렴 루프는 한 번에 안 끝났어요.
    처음엔 13개 상태 중 3개만 PASS, 10개가 HOLD였어요.
    HOLD 범위가 0.027104에서 0.086223까지 벌어져 있었고요.
    한 바퀴 돌고 나서 6 PASS / 7 HOLD가 됐고, 범위가 0.073788까지 좁혀졌어요.
    또 한 바퀴 돌았는데 PASS 개수는 그대로 6/7인데 범위만 0.068916으로 더 좁아졌어요.
    화면 크기 매칭은 내내 13개 다 PASS였고요.
    숫자가 한 자리씩 내려가는 걸 보는 게 그날 일의 거의 전부였어요.
    0.07이 0.06으로 떨어질 때마다 어깨가 조금씩 풀렸는데, 정작 화면은 제 눈엔 똑같아 보여서 내가 대체 뭘 고치고 있는 건지 몇 번을 의심했어요.

    하나를 고치면 멀쩡하던 게 깨졌어요

    근데 그 수렴 루프에 생각 못 한 병목이 있었어요.
    이미 PASS인 상태가 회귀 감시견이더라고요.
    한 상태를 더 좋게 만들려고 공통 셸을 건드리면, 멀쩡하던 다른 PASS 상태가 깨졌어요.
    전역으로 보정을 하나 만들었다가 — 라이브 스트립 오프셋 하나, 카드 하단 패딩 1px 같은 실험이었어요 — 그게 다른 PASS 행을 깨자, 같은 사이클 안에서 바로 되돌렸어요.
    결국 보정을 그 상태 하나에만 국소적으로 가둬야만 앞으로 갈 수 있었어요.

    그 의도라는 게 또 병목이었어요.
    어느 무관한 상위 변경이 화면 상단의 작은 어포던스 하나를 제목 옆 인라인으로 옮긴 적이 있어요.
    본문 텍스트는 한 글자도 안 바뀌었어요.
    그런데도 그 화면의 골든 테스트가 빨강이 됐고, 통과시키려면 기준 PNG를 다시 찍어야 했어요.
    그것도 한 장이 아니었어요.
    CI랑 macOS 두 렌더 환경에, 한국어/영어 케이스까지 곱해서 6장을 한꺼번에 재생성했어요.
    갱신하고 다시 비-갱신 모드로 6개를 돌려서 전부 통과하는 걸 확인하는 게 그 슬라이스의 전부였는데, 노트엔 "이 슬라이스는 동작 커버리지를 늘리지 않는다"고 그대로 적혀 있더라고요.
    행동은 1%도 안 늘렸는데 PNG 6장을 다시 찍은 거예요.

    종료 코드를 너무 믿었다가 데인 적도 있어요.
    풀 테스트 출력을 | tail로 파이프해서 끝부분만 봤거든요.
    근데 화면엔 통과처럼 떴어요.
    tail이 자기 일을 끝내고 exit 0을 냈으니까요.
    실제로는 그 안에 209개가 실패해 있었어요.
    tail의 0을 테스트의 0으로 착각한 거예요.
    그래서 머신 판독용 JSON으로 실패한 이름 집합을 통째로 뽑아서, 베이스라인이랑 diff를 떴어요.
    회귀가 0이라는 걸 숫자로 봐야 그제야 믿겼어요.

    결국 둘 다 커밋 앞에서 만나요

    그러고 보니 커밋이라는 한 지점에, 기계 판정이랑 화면 검증이 같이 셸의 종료 코드로 합류하게 돼 있었어요.
    둘 중 하나라도 빨강이면 커밋은 못 들어가요.
    모델한테 부탁하는 999 줄은 그대로 둬도 돼요.
    읽고 따라주면 그건 그거대로 좋으니까요.
    막는 손만 그 줄 밖으로 옮긴 거예요.

    아직 못 정한 건, 막은 걸 푸는 손이에요

    근데 다 풀린 건 아니에요.
    픽셀 디프가 자기 혼자선 "내가 일부러 화면을 바꾼 경우"랑 "실수로 망가뜨린 경우"를 구분을 못 해요.
    그래서 의도한 변경도 일단 빨강으로 막히고, 누군가 기준 이미지를 손으로 갱신해줘야 다시 초록이 돼요.
    그 갱신 권한을 모델한테 주면 강제가 또 헐거워지고, 사람만 쥐고 있으면 그 6장 다시 찍는 자리가 병목이 되고요.
    막는 건 바깥으로 옮겼는데, 그 막은 걸 푸는 손을 어디에 둘지는 아직 못 정했어요.
    다음엔 이것도 종료 코드로 정리해보려는데, 될지는 아직 모르겠어요.

    728x90
    반응형
Designed by Tistory.