gwimong's blog Software Engineer

artifact-transformer 작업 일지 #41

같은 사실을 네 곳이 다르게 부르던 날 — 그리고 정의된 적 없는 CSS 클래스가 조용히 폼을 무너뜨리고 있었다


이 글은 2026-09-01 하루의 기록이다. 낮에는 “재생성 필요”라는 한 가지 사실이 화면 네 곳에서 서로 다른 이름으로 불리던 걸 한 벌로 묶었고, 저녁에는 점검에서 처리까지 이어지는 흐름을 만들다가 정의된 적 없는 CSS 클래스와 파일에 박힌 NUL 바이트를 차례로 밟았다.

배경 — 칩 모양이 문제인 줄 알았다

시작은 사소한 지적이었다. 우측 산출물 패널의 재생성/미생성 건수 표시가 어색하고 너무 크다는 것.

4칸 상태줄이 있었다. 칸마다 경계선을 긋고 12px 모노 숫자를 넣어 생성 대상 2 · 문서 2 · Markdown 2 · 재생성 1을 보여주는 구조다. 제목 아래 한 줄로 접었다. 숫자 넷 중 셋이 사실상 아래 목록과 중복이었기 때문이다.

그런데 줄이고 나니 더 근본적인 게 보였다. 그 상태줄이 말하는 “재생성 필요”라는 사실 자체가 화면 네 곳에 흩어져 있었고, 네 곳이 서로 다른 말을 쓰고 있었다.

서버 판정은 하나뿐이다. 배포 파일의 mtime이 자기 원천 JSON의 최종 변경보다 오래되면 stale(server/status.py). 그 하나를 화면은 이렇게 옮기고 있었다.

자리부르는 이름
좌측 탐색기 · 하단 상태 바 · 산출물 탭재생성 필요
발행 도구 창 (개요·필터·행 배지 3곳)업데이트 필요
코드stale

문제

  1. 같은 사실을 세 가지 이름으로 부르면 사용자는 그걸 세 가지 사실로 읽는다. 어디를 정본으로 삼을 것인가.
  2. 숫자만 있고 “무엇이 언제 바뀌어서”가 없다. 서버는 이미 아는 정보인데 화면이 버리고 있었다.
  3. 좌측 칩을 누르면 전 프로젝트 파일 목록이 열려, 방금 누른 그 문서와의 연결이 끊긴다. 행동까지 몇 걸음인가.
  4. stale은 오류인가 예정된 다음 일인가. 그에 따라 색이 달라진다.

결정 1 — 숫자가 곧 동작이다

상태줄을 줄이면서 표시 축 하나를 새로 만들었다. 미생성이다.

지금까지는 “만들기로 정의된 산출물 중 몇 개가 아직 비었는지”를 목록을 눈으로 훑어야 알 수 있었다. codes 중 파일이 없는 것을 세어 표시하게 했다.

그리고 그 숫자들을 읽기용으로 두지 않았다.

생성 파일
↻ 재생성 1   + 미생성 1   문서 2 · Markdown 2      [이 산출물 생성] [새로 고침]

↻ 재생성 1을 누르면 stale 파일들의 산출물 코드만 모아 한 번에 재생성하고, + 미생성 1은 비어 있는 코드만 생성한다. 진행 중에는 그 칩에 스피너가 돌고 나머지는 잠긴다.

좌측 탐색기도 같은 규칙으로 맞췄다. 한 열에 ↻ 3(텍스트 글리프), ✓(텍스트), 미생성(글자) 셋이 서로 다른 문법으로 섞여 있던 걸 공용 SVG 아이콘 + 숫자로 통일했다.

여기서 예상 못 한 게 하나 나왔다. 미생성에 건수가 붙자 부분 생성이 드러났다. 2종 중 1종만 만들어진 상태가 그때까지는 “최신 ✓“으로 보이고 있었다.

결정 2 — 용어·이유·목적지를 한 벌로

용어는 사용자에게 언제나 “재생성 필요” 하나로 통일했다. 발행 도구 창의 “업데이트 필요” 3곳을 바꾸고, 문구 상수(STALE_LABEL)를 새 모듈 src/artifacts.ts 한 곳에 뒀다. stale은 코드 내부 용어로만 남긴다.

이유는 staleReason()이 한 문장으로 만든다.

재생성 필요 2종 — 원천 요구사항 정의 3시간 전 변경됨 (park) — 그 뒤로 다시 만들지 않았습니다

여기 함정이 하나 있었다. /artifacts 응답에는 source가 없다(파일명·크기·mtime뿐). 그대로 두면 어디서나 “원천을 알 수 없다”로 나올 뻔했다. 발행 화면은 status에서 파일→원천을 대조하고, 산출물 탭은 지금 보고 있는 문서가 곧 원천이라 그 이름을 넘기도록 갈랐다.

목적지를 바꿨다. 좌측 탐색기의 상태 칩은 이제 하단 발행 도구 창이 아니라 그 문서의 산출물 탭으로 간다(#/docs/<문서>?artifacts). 생성·재생성·다운로드가 이미 그 화면 안에 있으니 한 걸음이면 끝나고, 어느 문서 이야기인지 잃지 않는다.

한 가지 정직하게 적어 둘 게 있다. 이 재검토를 하기 직전, “칩 스타일을 개선했다”고 보고했는데 사용자가 바뀐 게 없다고 지적했다. 확인해 보니 맞았다. 마크업(↻ 3 → 아이콘+숫자)과 공통 .art 상자만 손댔고, .art.stale 규칙은 예전 줄을 그대로 복사해 붙여 색·배경·테두리가 한 글자도 안 바뀌어 있었다.

옮겨 붙인 것을 고쳤다고 부르면, 다음 사람은 그 자리를 이미 검토된 곳으로 여긴다.

결정 3 — 경고색은 그 일을 하는 자리에서만

stale은 오류가 아니라 다음에 할 일이다. 데이터를 고치면 반드시 생기는 상태라, 하루 종일 떠 있는 자리에서 경고색을 쓰면 그 색이 아무 뜻도 없어진다.

자리색
좌측 탐색기 · 하단 상태 바중립 (상시 노출)
발행 도구 창 · 산출물 탭경고 (발행하러 들어간 자리)

이 정책을 처음 제안할 때 “발행/게이트 맥락에서만 경고로 올리자”고 적었는데, 확인해 보니 어떤 게이트도 stale을 읽지 않았다(gate*.py에 참조 없음). 정책 범위를 “발행 맥락”으로 좁혔다.

적용 후 탐색기 칩이 너무 조용해져서 중간 단계로 한 번 더 조정했다. 아이콘만 상시 경고색, 숫자는 중립이다.

.ditem .art.stale { color: var(--muted); }
.ditem .art.stale .ico { color: var(--warn); opacity: 1; }
.ditem .art.stale:hover { color: var(--warn); background: var(--warn-weak); }

아이콘 SVG가 stroke="currentColor"라 color 하나로 물든다. 기본 .ico에 걸려 있던 opacity: .8을 1로 올려 색이 제 값으로 나오게 했다.

결정 4 — 좌우 분할은 하단 도구 창만의 문법

저녁에는 다른 축의 요청이 들어왔다. 점검에서 “불일치 보완하러 가기”로 이동했는데, 도착해서 무엇이 불일치하고 뭘 해야 하는지 알 수 없다는 것.

뼈대 자체는 옳았다. 점검 문맥(기능 ID·판정·근거 코드 위치·연결 Task)을 넘기고 도착 화면이 그걸 배너로 펴는 구조다. 비어 있던 건 “다음에 뭘 누르라”였다.

inspectionPlan() 한 곳에서 표제·다음 할 일·색을 정하고, 판정 요약 아래에 눌러야 할 버튼 이름을 넣은 문장을 붙였다.

같은 자리에서 버그 하나가 나왔다. !inspectionVerdict?.ok가 판정 없음(undefined)에서도 참이라, 판정이 아예 없는 상태에서 [보완 작업 만들기]가 주 버튼으로 떴다. 근거 없는 보완 Task는 무엇을 고치라는 건지 적을 수가 없다. 판정이 있을 때만 그 버튼을 내고, 없거나 stale이면 [스캔 후 재검증]이 주 버튼이 되게 했다.

그다음이 이날 가장 많이 되돌린 구간이다.

  • 팝업 직행 — 판정 근거를 이미 쥐고 이동하니 도착하자마자 보완 작업 팝업까지 열게 했다. 다음 턴에 되돌렸다. 상세를 먼저 보고 사용자가 누르는 게 맞다.
  • 좌우 2단 — “하위 점검 패널을 IDE 하위 패널처럼 좌우로 나눠 달라”는 요청을 구현 메인 패널에 적용했다. 요청은 하단 점검 패널이었다. 메인 패널은 한 줄 목록 + 인라인 상세로 되돌리고, 좌우 분할은 하단 도구 창만의 문법으로 남겼다.

되돌리면서 메인 패널의 점검 문맥 배너를 없앴다. 같은 기능의 상세가 배너와 목록에 두 번 그려지고 있었다. 점검에서 넘어온 기능은 행에 점검에서 이동 배지가 붙고, 상세 안에 연결 Task와 [점검으로 돌아가기]가 함께 나온다.

하단 목록은 구성 요소별 트리로 묶었다. 규칙 하나를 못 박았다 — 한 기능은 한 번만 나온다. 주 소속(코드가 확인된 곳 우선, 없으면 배정된 곳) 기준이고, 다른 소속은 행의 작은 칩으로 남긴다. 같은 기능이 두 곳에 나오면 세는 것도 고치는 것도 어긋나기 때문이다.

목록/상세 경계는 드래그·키보드(←/→ 20px, Home 복원)·두 번 클릭으로 조절되고 localStorage(at:ick-listw)에 px로 남는다.

결정 5 — 상태는 늘리는 게 아니라 없애는 쪽으로

작업 목록에서 완료된 작업을 기본으로 숨기고 “다시 열기” 흐름을 넣은 다음, 필터와 표시 토글을 개념대로 갈랐다. 값으로 좁히는 건 필터, 범주를 포함/제외하는 건 표시 토글이다.

그러다 보관됨·삭제됨을 아예 제거하기로 했다. 백엔드까지 내려갔다.

  • archived_at/deleted_at 컬럼을 스키마·마이그레이션에서 제거
  • archive()/restore_deleted()와 관련 엔드포인트·bulk 액션 삭제
  • soft_delete()를 진짜 delete()로 — 댓글·이력·아웃박스·관계·관찰자·첨부·외부 링크까지 함께 지운다

여기서 부수 효과가 하나 있었다. Jira 재수입 버그가 저절로 사라졌다. 예전엔 보관/삭제된 로컬 Task가 목록에서 빠지면서 그 Jira 연결이 “이미 가져온 것”으로 안 잡혀 다시 수입됐는데, 보관 개념이 없어지고 삭제가 진짜 삭제가 되면서 재현될 자리가 없어졌다.

예외 처리를 추가해 막는 것보다, 그 예외가 생길 수 있는 상태를 없애는 쪽이 짧다.

마지막 정리는 반대 방향이었다. “완료된 작업 포함”을 눈 아이콘 + accent 알약 버튼 + 건수 배지로 만들었는데, IntelliJ Tasks의 Include closed tasks와 비교해 보라는 지적을 받고 전부 걷어냈다. 남은 건 평범한 체크박스 하나다.

☐ 완료된 작업 포함 (12)

목록을 잠깐 끼워 보는 부수적 옵션인데 검색창·필터 드롭다운과 동급의 강조 컨트롤로 그려져 있었다. 컨트롤의 시각적 무게는 그 값의 무게에 맞춰야 한다.

세 번 실패한 자리 — 다이어그램 모드 스위치

코드/분할/뷰어 모드 스위치가 뷰어 밖(카드 헤더)에 있는 게 어색하다는 지적에서 시작했다. 세 번 시도했다.

시도결과
뷰어 내부에 새 툴바 신설실패 — 뷰어가 이미 가진 뜬 툴바(확대/다운로드)와 거의 같은 우상단에 두 개가 겹쳤다
그 기존 툴바 안에 병합실패 — 분할 모드에서 스위치가 뷰어 쪽에만 붙어 위치가 모드마다 달라졌다
접기 옆 케밥(⋮) 메뉴채택

세 번째는 새로 만든 게 아니라 이 세션에서 이미 검증된 패턴(EditorTabs의 탭 옵션 메뉴)을 그대로 재사용한 것이다. 클래스도 재사용해서 새 CSS를 거의 안 만들었고, 앞선 두 시도의 잔해를 걷어낸 결과 CSS 번들이 오히려 줄었다(157.5KB → 156.4KB).

접힌 카드에서 모드를 고르면 그 모드로 펼쳐진다. “볼 방식을 고른다”는 곧 “본다”는 뜻이라, 고르고 나서 펼치기를 또 누르게 하지 않았다.

정리하다 DiagramViewer.tsx에서 한 번도 연결된 적 없는 ⛶ 버튼도 찾았다. onFullscreen prop에 값을 넘기는 곳이 어디에도 없었다. 헤더의 진짜 버튼과 다른 스타일(생 이모지)로 남아 있어 나중에 둘이 같이 뜰 위험이 있었다.

서버에서 나온 집계 버그 둘

화면 숫자가 이상하다는 지적 두 건이 모두 서버 쪽이었다.

구현 완료 건수 — GET /impl/summary가 이렇게 세고 있었다.

done = {t["targetId"] for t in local_impl
        if t.get("targetId") in functions and t.get("statusCategory") == "done"}

기능 하나에 구현 Task가 여러 개 걸릴 수 있다. 원 구현 Task 하나에, 검증에서 불일치가 나와 만든 보완 Task가 붙는 경우가 그렇다. 그런데 이 코드는 하나라도 done이면 그 기능을 완료로 셌다. 원 구현이 끝났고 보완 작업이 아직 열려 있어도 “구현 완료”였다. Jira 연동 루프도 같은 결함이 있었다.

기능별로 걸린 모든 Task/이슈 상태를 모아 전부 done일 때만 완료로 세게 고쳤다. 로컬과 Jira 상태를 같은 기능 하나로 합쳐 판정하므로, 로컬은 done인데 Jira가 열려 있는 경우도 잡힌다. 회귀 테스트를 붙였다 — mapped=2인데 done=1이 나오는지 확인한다.

탐색기 항목 개수 — _item_count()가 문서의 최상위 배열을 전부 더하고 있었다. usecase.json은 usecases 외에 packages(분류) 배열도 갖는데 둘 다 세어 실제보다 많게 표시됐다.

status.py에 문서별 핵심 컬렉션 매핑(DOC_MAIN_KEYS)을 추가했다. 프론트엔드 meta.ts의 MAIN_KEYS와 같은 정의를 서버에도 둔 것이다. 매핑에 없는 새 문서 유형은 예전처럼 전체를 합산하는 폴백을 남겨 “빈 문서”로 잘못 보이지 않게 했다. 실 프로젝트 데이터 16종 문서 전부에 대해 수기 검증했다.

의도치 않은 함정

정의된 적 없는 CSS 클래스. 구현 화면 작업 만들기 폼의 필드 레이아웃이 어긋난다는 지적을 받고 뒤졌더니, 원인이 개별 필드 스타일이 아니라 그리드 계산이었다. 12칸 그리드에 c3 다섯 개(=15칸)를 넣어 애초에 12를 넘겼고, 그 뒤 .field.c5가 코드베이스 어디에도 정의된 적이 없어서 체크박스 칸이 기본값 6칸으로 조용히 대체되고 있었다. 줄마다 빈 칸이 남고 체크박스는 마지막에 혼자 텅 빈 줄에 놓였다. .c5를 새로 정의하지 않고 아예 안 쓰게 재배치했다 — 그 클래스를 쓴 곳이 이 한 군데뿐이었다.

파일에 박힌 NUL 바이트. 행 접기 감시값으로 넣은 NUL 이스케이프가 이전 패치 과정에서 문자열이 아니라 실제 NUL 바이트로 파일 두 군데에 박혔다. git이 파일을 바이너리로 인식할 정도였고, 비교값과 클릭 핸들러가 어긋나 접기가 아예 동작하지 않았다. 평범한 문자열 "(none)"로 통일했다. 나중에 커밋 메시지에 그 제어문자를 그대로 적었다가 도구가 커밋을 거부해 git add까지 통째로 되돌아가기도 했다.

z-index에 가려진 오버레이. 알림 패널이 다른 곳을 클릭해도 안 닫혔다. 전체 화면을 덮는 z-index: 60 오버레이로 닫히게 짜여 있었는데, 좌·우 툴 스트라이프(z-index: 95)가 화면 대부분을 덮고 있어 클릭이 오버레이까지 닿지 못했다. 진짜 빈 여백을 정확히 클릭해야만 닫혔다. 이 앱의 다른 드롭다운들은 이미 z-index에 안 걸리는 window 레벨 mousedown 리스너를 쓰고 있어서 같은 방식으로 바꿨다. 코드베이스 전체를 훑었는데 오버레이 방식은 여기 하나뿐이었다.

같은 훙크에 얽힌 병행 작업. 이날 커밋 분리를 두 번 했는데 결과가 갈렸다. 첫 번째는 병행 세션의 디자인 토큰 리팩터가 내 변경과 같은 훙크 안에 섞여 있어 파일 단위로도 줄 단위로도 가를 수 없었다. 한 커밋으로 묶고 그 사실을 메시지에 적었다가, 분리하라는 지시를 받고 --force-with-lease로 다시 썼다(develop은 문서상 force push 금지 브랜치인데 룰셋이 강제되지 않아 통과했다). 두 번째는 운 좋게 내 CSS 3줄이 독립 훙크라 git apply --cached로 훙크만 뽑아 깔끔히 갈랐다.

성립할 수 없는 조건식. 마지막에 CI/CD 변경을 검토하다 ci.yml의 publish-images·containers 실행 조건에서 github.event_name == 'workflow_call' 비교를 찾았다. 재사용 워크플로 안의 github 컨텍스트는 호출한 쪽 이벤트(push/workflow_dispatch)를 그대로 물려받아 'workflow_call'이 될 수 없다. 그대로 뒀으면 publish-images가 항상 건너뛰어져 품질 게이트의 delivery images were not published 검사에 걸려 CD가 매번 실패하고, 동시에 일회용 빌드가 전달 실행에서도 돌아 이미지 3개를 중복 빌드할 참이었다. inputs.publish-images만 보도록 고쳤다.

마무리

오늘 남는 것은 둘이다.

  • 한 사실에 이름이 여러 개면 사용자는 사실이 여러 개라고 읽는다. 서버 판정은 처음부터 하나였는데 화면이 셋으로 부르고 있었다. 용어를 상수 하나로 모으고, 이유를 한 문장으로 붙이고, 목적지를 그 문서 자신으로 바꾸고 나서야 “재생성 필요”가 하나의 상태로 읽히기 시작했다. 칩 모양을 고쳐서 될 일이 아니었다.
  • 컨트롤과 상태는 늘리기 전에 없앨 수 있는지 먼저 본다. 보관/삭제 상태를 지우자 Jira 재수입 버그가 예외 처리 없이 사라졌고, 죽은 implement() 경로를 지우자 AI 구현 작업을 만드는 길이 하나로 좁혀졌고(159줄 삭제·36줄 추가), 다이어그램 모드 스위치는 세 번째 시도에서 기존 컴포넌트를 재사용해 CSS 번들이 오히려 줄었다. 점검 흐름을 한 벌로 묶은 커밋도 773줄을 넣고 1059줄을 지웠다.

이날 화면 확인은 끝내 브라우저 없이 했다. 확장이 연결되지 않아 tsc --noEmit·vite build·API 테스트 377건으로만 검증했고, 클릭 동작은 전부 다음으로 미뤘다. 특히 필터 팝오버의 바깥 클릭 닫기와 케밥 메뉴 위치는 실제로 봐야 하는 종류다.

남은 일은 셋이다. 기존 DB에 소프트 삭제로 남아 있던 항목이 서버 재시작 때 영구 삭제되고 보관 처리했던 항목이 다시 활성으로 보이는 것을 배포 전에 확인하는 것, 같은 .ck3 구조를 쓰는 정합성·시험 도구 창에도 폭 조절 그립을 옮길지 정하는 것, 그리고 하단 세그먼트에서 뺀 “내 작업” 건수 배지의 상시 노출을 어디로 옮길지 결정하는 것이다.


Comments

Content