ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 로컬 챗봇 시리즈 #10 — [hidden] 속성이 안 먹는 한 시간: HTML5의 작은 속성과 컴포넌트 CSS의 충돌
    IT 2026. 5. 9. 22:30
    로컬 챗봇 시리즈 #10 — [hidden] 속성이 안 먹는 한 시간: HTML5의 작은 속성과 컴포넌트 CSS의 충돌

    들어가며 — "X 버튼이 안 듣는다"는 사용자 보고에서 시작된 디버깅

    Claude.ai 스타일 Artifact 사이드 패널을 구현했다. 모델 응답에서 HTML/SVG/Mermaid 블록을 자동 추출해 우측 패널에 분리 렌더링한다. 구성은 정규식 한 줄, sandboxed iframe, 탭 UI다. 한 시간 만에 만들었다. 만족스럽게 배포했는데 사용자 보고가 즉각 왔다.

    "오른편에 '아티팩트'가 무조건 나오는데 동작하는 건지 모르겠다. X 버튼이 안 듣는다."

    처음에는 JavaScript 버그로 의심했다. onclick 핸들러가 제대로 안 붙었나, setAttribute('hidden', '')가 호출은 되는데 효과가 없나. 한 시간을 디버깅하다가 진짜 원인을 발견했다 — HTML5의 [hidden] 속성과 컴포넌트 CSS의 display: flex가 충돌하고 있었다. 이번 글은 이 한 줄짜리 CSS 함정을 풀어쓴다.


    1. HTML5 [hidden]은 사실 user-agent CSS 한 줄에 의존한다

    HTML5에는 hidden이라는 글로벌 속성이 있다. 어떤 element에 hidden 속성을 붙이면 그 element가 숨겨진다고 명세에 적혀있다. 그래서 element.setAttribute('hidden', '')로 자유롭게 토글하면 된다고 생각하기 쉽다.

    그런데 그 "숨김"이 어떻게 구현되는지 보면 깜짝 놀란다 — 브라우저의 user-agent stylesheet에 들어있는 단 한 줄이 전부다.

    diagram

    이 그림이 보여주는 핵심은 "hidden 속성의 효과가 CSS의 specificity 규칙을 따른다"는 점이다. 위쪽 노란색 박스가 브라우저 기본 user-agent stylesheet — 모든 브라우저에 내장된 디폴트 CSS인데 그 한 줄([hidden] { display: none; })이 hidden 속성을 시각적으로 동작하게 한다. 아래 두 박스가 두 시나리오의 결과를 보여준다. 왼쪽(녹색): 컴포넌트 CSS가 display를 명시하지 않으면 UA의 [hidden] 룰이 살아있어 hidden 속성이 정상 작동한다. 오른쪽(빨강): 컴포넌트 CSS가 display: flex를 명시하면 class 셀렉터(specificity 010)가 attribute 셀렉터(specificity 010)보다 같거나 큰 specificity를 가지면서 늦게 정의된 게 이긴다 — 결국 display: flex가 살아남아 hidden 속성이 무시된다. 이게 이번 버그의 정확한 원인이다.

    .artifact-panel 클래스에 display: flex를 명시한 순간 user-agent의 [hidden] { display: none }이 덮여버린다. setAttribute('hidden', '')를 호출해도 hidden 속성은 붙는데 렌더링은 그대로다.


    2. 한 줄로 해결 — 컴포넌트 CSS도 [hidden]을 인지하기

    해결은 컴포넌트 CSS에 [hidden] 케이스를 명시적으로 추가하는 것이다. 사실 다른 모달들(.modal-overlay, .cmdk-overlay)에는 이미 들어가 있었다 — 신규 패널에 빠뜨린 것뿐이다.

    diagram

    이 그림이 보여주는 fix의 동작 원리는 specificity 게임에서 이기는 셀렉터를 만드는 것이다. 추가한 룰의 셀렉터 .artifact-panel[hidden]은 class(0,1,0) + attribute(0,1,0) 합산해서 specificity (0,2,0)이다. 기존 .artifact-panel의 (0,1,0)보다 높다. 그래서 hidden 속성이 있을 때만 display: none이 적용되어 패널이 숨겨진다. !important는 추가 안전판이다 — 어떤 상황에서도 이 룰이 이기게 한다.

    아래 두 박스가 fix 전후를 대조한다. 왼쪽(빨강 — 버그 상태): JavaScript가 setAttribute로 hidden을 붙여도 CSS가 그것을 무시하니 패널이 그대로 보인다. 사용자에게는 "X 버튼을 눌러도 아무 일도 안 일어나는" 상태로 보인다. 오른쪽(녹색 — fix 후): hidden 속성이 정상적으로 시각화에 영향을 미친다. 페이지 로드 시 hidden 속성이 미리 붙어있어 패널이 안 보이고, artifact가 도착하면 removeAttribute로 보이게 하고, X 버튼을 누르면 setAttribute로 다시 숨긴다. JavaScript 코드는 한 줄도 안 바뀌었다 — 한 줄짜리 CSS 추가만으로 모든 동작이 정상화된다.

    /* chat.css에 한 줄 추가 */
    .artifact-panel[hidden],
    .artifact-toggle[hidden] { display: none !important; }
    

    한 줄짜리 fix지만 교훈은 깊다 — HTML5의 "쉬워 보이는" 속성도 사실 user-agent CSS의 한 줄에 의존하고, 그 룰은 컴포넌트 CSS에 의해 깨지기 쉽다.


    3. 일반화 — "내가 의존하는 속성이 어떻게 구현되는지" 알아야 한다

    같은 패턴의 함정이 다른 곳에도 있다. <input type="checkbox">의 기본 모양도 user-agent stylesheet가 그린다. 그 위에 appearance: none을 덮으면 모양이 사라진다. <a>의 파란 밑줄도 user-agent의 color: -webkit-link가 그린 것이다. "브라우저가 알아서 해주는 것"의 정체가 사실은 user-agent CSS이고, 컴포넌트 CSS가 그것을 의도치 않게 깨뜨릴 수 있다.

    방어책 — 새 컴포넌트를 만들 때마다 다음을 체크:

    • display 속성을 명시했으면 [hidden] 케이스도 명시한다
    • appearance를 덮었으면 hover/focus 같은 인터랙션 상태를 직접 구현한다
    • HTML5 표준 속성(hidden, disabled, readonly 등)을 쓸 거면 그것이 어떻게 시각화되는지 user-agent stylesheet 가정을 확인한다

    4. 트레이드오프 — Artifact 패널 디자인의 비용들

    4-1. iframe sandbox는 외부 폰트·CDN을 못 부른다 — 보안과 시각 충실도의 충돌

    HTML artifact는 sandbox="allow-scripts allow-same-origin" iframe + srcdoc으로 격리한다. 보안상 좋은 디자인이지만 부작용이 있다 — 모델이 만든 HTML이 외부 폰트(Google Fonts 등)나 CDN(jsdelivr 등)을 부르면 일부 리소스가 안 로드된다. allow-same-origin이 있어도 cross-origin 정책이 적용되는 경우가 있어서, 모델이 "<link href='https://fonts.googleapis.com/...'>" 같은 외부 폰트 import를 시도하면 차단되거나 보안 경고가 뜬다.

    구체적인 시나리오 — 사용자가 "예쁜 시간표 페이지를 HTML로 만들어줘"라고 부탁하면 모델이 보통 Tailwind CDN, Google Fonts, FontAwesome 같은 외부 의존성을 포함한 HTML을 만든다. 그게 sandboxed iframe에 들어가면 텍스트는 보이지만 폰트가 시스템 기본으로 떨어지고 아이콘은 안 보인다. 사용자에게는 "디자인이 깨져 보이는" 결과로 이어진다.

    완화책은 자주 쓰는 라이브러리(highlight.js, marked, KaTeX 등)는 챗봇이 직접 호스팅하고, 모델 응답에 추가하는 시스템 프롬프트로 "외부 의존을 최소화한 self-contained HTML"을 가이드하는 것이다. 또는 sandbox 제약을 좀 풀어서 allow-scripts allow-same-origin allow-popups 같은 추가 허용을 주는 것도 옵션이다 — 보안 위험은 살짝 늘지만 시각 충실도가 개선된다. 1인 사용자 챗봇에서는 자기 자신이 만든 HTML이라 보안 위험이 작아 후자도 합리적이다. 다중 사용자 환경에서는 보안을 우선하는 게 안전하다.

    4-2. 1500자 임계가 임의적이다 — 사용자별로 적정선이 다른 문제

    일반 코드 블록 중 1500자 이상이면 artifact로 격상한다는 룰을 뒀다. 70-80줄 정도다. 이 임계를 어떻게 정했나 — 운영하면서 조정한 결과다. 더 작게 잡으면(예: 800자) 짧은 코드 스니펫까지 분리되어 사용자가 답변을 읽다가 매번 패널을 봐야 한다 — 인터럽션 비용이 늘어난다. 더 크게 잡으면(예: 3000자) 사용자가 본문에서 긴 코드를 스크롤해야 한다 — 가독성 비용이 든다.

    1500자는 "70-80줄 정도의 코드는 패널로 분리하는 게 본문에서 스크롤하는 것보다 낫다"는 직관에 가깝지만, 이건 사용자 선호에 따라 다른 값이다. 코딩 위주 사용자는 짧은 코드도 패널에 분리하고 싶을 수 있고, 일반 사용자는 길어도 본문에서 보고 싶을 수 있다. 절대 정답이 없는 임계다.

    대안은 setting 페이지에 사용자가 직접 임계값을 조정할 수 있는 슬라이더를 노출하는 것이다. 또는 모델이 알아서 "이 코드는 본문 인라인이 좋겠다 vs 패널이 좋겠다"를 결정하게 하는 더 똑똑한 분기를 두는 방법도 있다. 후자는 모델 부담을 늘리니 현재는 단순 임계값으로 시작했고, 사용자 불만이 누적되면 setting 노출로 옮길 것이다.

    4-3. 모델 응답 완료 후에 추출 — 스트리밍 시각적 불연속

    artifact 추출은 응답 텍스트가 완성된 후 한 번에 한다. 스트리밍 중에는 "이게 fenced block이 될 것인가, 단순 코드 인용인가"를 알 수 없으니 안전한 선택을 한다 — 잘못된 추출(예: 미완성 fenced block을 artifact로 잘못 분리)을 피하기 위함이다.

    그러나 사용자 입장에서는 시각적 불연속이 생긴다. "긴 코드가 본문에 길게 올라오다가 응답이 끝나는 순간 갑자기 사라지고 패널에 뜬다". 응답 받는 동안 사용자는 본문에서 코드를 읽고 있는데, 응답 종료와 함께 그 코드가 본문에서 사라지고 우측 패널로 이동한다. 처음 보는 사용자는 "코드가 어디 갔지?"라며 당황한다.

    완화책은 응답 종료 시점에 작은 애니메이션(예: 본문의 코드가 우측 패널로 슬라이드)으로 이동을 시각화하는 것이다. 또는 본문에 placeholder("이 응답에는 코드 1개 — 우측 패널 보세요")만 남기고 처음부터 패널에서만 보이게 하는 방법도 있다. 두 방안 모두 구현 복잡도가 늘고, 첫 출시 단순함을 우선해 시각적 불연속을 받아들였다. 사용자가 한 번 학습하면 "응답 끝나면 패널 보기"가 자연스러운 패턴이 되긴 한다.


    5. 마무리

    "채팅 안의 긴 코드"라는 UX 결함은 사실상 모든 LLM 챗봇이 풀어야 하는 문제다. Claude.ai가 좋은 답을 보여줬고, 정규식 한 줄과 사이드 패널이면 80%가 따라잡힌다 — CSS [hidden] 한 줄을 빼먹지만 않으면 된다. 작은 함정이 한 시간을 잡아먹는다는 게 프론트엔드의 일반적 경험이다.

    다른 부수 — fenced block 정규식 추출, mermaid@10.9.0 CDN 다이어그램, highlight.js 코드 렌더, Alt+A 토글 단축키, 복사·다운로드 액션 — 는 표준 작업이다.

    다음 편은 UI/UX 잡탕이다. 다크모드, Cmd-K, 키보드 단축키, 마크다운/highlight.js/KaTeX, @ 멘션과 / 슬래시 자동완성 통합을 다룬다. 매일 매 채팅마다 체감하는 작은 것들이다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.