발행일

밑줄과 정렬은 취향이 아니라 규칙으로 — 이동하는 자리면 링크여야 한다

밑줄과 정렬은 취향이 아니라 규칙으로

권한 그룹 편집 화면의 이름 라벨이 하이퍼링크로 읽힌다는 제보가 들어왔습니다. 눌러도 아무 데도 안 가는데 파란색도 아닌 검은 글씨에 실선 밑줄이 그어져 있었어요.

범인은 CSS 한 줄이었습니다(0a661eeb).

.tooltip {
  @apply relative underline;
}

.tooltiphelp_text가 달린 폼 라벨에 붙는 클래스입니다. hover하면 설명이 뜨는 자리예요. 그러니까 이 화면에서 밑줄이 그어진 라벨은 전부 같은 증상이었습니다. 한 화면의 문제가 아니었어요.

밑줄을 어디에 쓸 것인가

밑줄을 통째로 빼는 건 답이 아니었습니다. 빼면 "여기에 설명이 달려 있다"는 단서까지 같이 사라지거든요. 그래서 abbr 태그의 오래된 관례대로 점선만 남겼습니다.

.tooltip {
  @apply relative underline decoration-gray-600 decoration-dotted underline-offset-4;
}

그리고 규칙을 네 갈래로 정리해 docs/ui-conventions.md §17에 적었어요.

밑줄쓰는 자리
실선클릭이 이동인 것. <a href>로 쓴다
점선hover하면 설명이 뜨는 것
hover 때만이동은 아니지만 누르는 것 — 펼침·토글·모달
없음누를 수 없는 것

"이동"의 경계를 어디에 둘지가 실제로 고민한 부분입니다. 주소가 바뀌는 것만 이동으로 치면 이 시스템의 절반이 빠져요. 트리 노드를 골라 오른쪽 상세를 통째로 갈아끼우는 자리, history.replaceState로 딥링크를 남기는 자리가 그렇습니다. 화면은 그대로지만 보고 있는 대상이 통째로 바뀌면 이동으로 쳤습니다.

밑줄을 떼는 게 아니라, 진짜 링크로 만들어야 하는 자리

판정 기준이 "이동하나"이지 "밑줄이 있나"가 아니라서, 전수 조사를 하다 보니 반대 방향의 문제가 나왔습니다. 링크처럼 생겼는데 링크가 아닌 자리요. 이쪽은 밑줄을 지울 게 아니라 요소를 고쳐야 합니다.

하나는 인스턴스 저장 실패 때 뜨는 오류 요약입니다. 항목을 누르면 해당 입력칸으로 보내 주는 자리인데, button으로 만들어 놓고 링크처럼 칠해 뒀었어요. 표준 error summary 패턴대로 앵커로 바꿨습니다.

const item = document.createElement(input ? "a" : "p");
if (input) {
  if (!input.id) input.id = "instance-field-" + key;
  item.href = "#" + input.id;
  item.addEventListener("click", function (event) {
    event.preventDefault();
    input.scrollIntoView({ block: "center" });
    input.focus({ preventScroll: true });
  });
}

href를 실제로 달아 두면 보조기술이 "그 항목으로 가는 링크"로 읽고 키보드 포커스와 Enter가 살아납니다. 다만 브라우저 기본 앵커 점프는 대상을 화면 맨 위에 딱 붙여서 앞뒤 맥락을 잘라 버려요. 그래서 클릭은 가로채 가운데로 옮기고 포커스까지 줍니다. 기본 동작을 쓰되 아쉬운 부분만 덮어쓰는 형태입니다.

다른 하나는 용어 화면의 클래스 참조 값입니다. <span>에 파란 글씨와 밑줄을 입혀 링크 행세를 하고 있었는데, 새 탭·가운데클릭·키보드 포커스가 전부 죽어 있었어요. 트리가 이미 읽고 있는 ?namespace=&selected_node= 규약이 있었으니 그걸 href로 노출하면 되는 일이었습니다.

function rangeClassLink(nodeId, prefix, label, title) {
  const href = "?namespace=" + encodeURIComponent(prefix) +
               "&selected_node=" + encodeURIComponent(nodeId);
  // …class='range-class-link' href=… data-node-id=… 로 <a> 를 만든다
}

평범한 클릭은 가로채서 화면 안에서 즉시 선택하고, 수식키가 섞인 클릭은 브라우저에 그대로 넘깁니다.

if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault();

이 마크업을 만드는 곳이 두 군데(클래스 참조·용어 참조)로 복붙돼 있었는데, 고치는 김에 rangeClassLink() 하나로 합쳤습니다. 같은 규약을 두 자리에서 각자 조립하고 있으면 다음에 한쪽만 고쳐집니다.

곁따라온 것도 있어요. 카탈로그 상세의 미리보기<span>type="button"을 달고 있었습니다. span은 키보드 포커스를 못 받고 type 속성은 그냥 무시돼요. 그냥 <button>으로 바꿨고, base button 규칙이 테두리와 색을 이미 주니 클래스는 배치만 남았습니다.

채팅 답변 안의 링크에서 팔레트 밖 색(text-blue-600)을 걷어낸 것도 같은 커밋입니다. 이미 .chat-message-content a가 색과 밑줄을 주고 있어서 덧칠이었어요.

이튿날 — 같은 화면들의 표 정렬

이튿날에는 사용자 접근 제어 화면들의 표를 봤습니다. 표 머리글은 가운데인데 값은 왼쪽이라 세로줄이 어긋나 보였어요(86568c93).

원인은 전역 table 규칙이 th에 정렬을 안 주는 것이었습니다. 그러면 th는 브라우저 기본값인 가운데, td는 기본값인 왼쪽이 되죠. 아무도 정한 적 없는 두 기본값이 한 표 안에서 부딪히고 있었던 겁니다.

전역 CSS에 th { text-align: left }를 넣는 게 제일 짧은 수정입니다. 그걸 안 했어요. 이 전역 규칙은 PDF 출력 템플릿도 같이 탑니다. 화면 여섯 개를 고치려고 인쇄물 레이아웃까지 흔들 이유가 없어서, 값이 왼쪽인 표에만 머리글 정렬을 명시했습니다. 값이 가운데인 순번 칸과 구성원 표는 그대로 뒀고요.

그리고 같은 날 오후 규칙을 §19로 적었습니다. 사내에서 이 규칙이 "표는 전부 왼쪽 정렬"로 전해지고 있었는데, 실제로 필요한 판단은 그게 아니라서요.

값의 성격정렬이유
사람이 읽는 값 — 텍스트·날짜·범주왼쪽눈이 왼쪽 모서리를 따라 내려간다
자릿수를 비교하는 숫자 — 금액·건수오른쪽자리가 맞아야 크기가 보인다
조작 — 버튼·체크박스, 그리고 순번가운데값이 아니라 누르는 자리다

머리글은 값을 따라갑니다. 정할 것은 값 쪽 한 번뿐이에요.

두 가지를 못 박아 뒀습니다. 하나는 "값의 가짓수가 적으면 가운데"가 근거가 아니라는 것입니다. 정렬을 정하는 건 가짓수가 아니라 왼쪽 모서리를 훑느냐예요. 오히려 가짓수가 적은 칸일수록 글자 수가 제각각이라(활성/해제 대기/만료) 가운데로 두면 모서리가 더 들쭉날쭉해집니다. 다른 하나는 날짜도 왼쪽이라는 것 — 표기 형식이 2026. 09. 10으로 고정돼 있어서 왼쪽에 세우면 자릿수가 저절로 맞습니다. 가운데로 옮기면 그 이점만 사라져요.

§17이 §19를 불러낸 자리

마지막 커밋에서 두 규칙이 만났습니다(61423cb3). 조직 상세 구성원 표의 계정 칸이 파랗게 떠 있었어요. 이동하는 자리면 §17대로 링크처럼 보여야 하고, 값처럼 보일 거면 값이어야 합니다. 그 행에서 누를 자리는 변경·해제 하나로 충분하고 사용자 상세로 가는 길은 사용자 목록 화면이 담당하니, 링크를 걷어 값 칸으로 되돌렸습니다.

같은 커밋에서 표 안 버튼 높이도 40px에서 32px로 낮췄습니다. 기본 버튼 높이가 행 높이(41px)를 꽉 채워서 통통해 보였거든요. 다만 배정 해제 버튼은 매크로를 상세 화면과 공유합니다. 매크로를 통째로 낮추면 상세 화면 버튼 줄에서 옆의 목록·삭제·수정과 높이가 어긋나요. 그래서 compact 인자를 받아 표 안에서만 낮췄습니다.

검증 · 남은 것

  • 조사 범위는 템플릿 전량과 static/js 58개 파일, CSS 소스, 단독 HTML 3개였습니다. 밑줄이 걸린 자리를 찾는 일이라 눈으로 훑는 것 말고 방법이 없었어요.
  • 회귀 잠금이 거의 없습니다. 같은 묶음에서 테스트가 붙은 건 권한 위젯에 표식 클래스가 붙는지 확인하는 것 한 건뿐이고(3a38a6fa), 밑줄과 정렬 자체를 잠그는 테스트는 안 만들었습니다. 규칙은 문서에만 있고, 다음에 누가 <span>에 밑줄을 칠해도 빨개지는 곳이 없어요.
  • .tooltip 한 줄을 바꾼 파급을 다 확인하지 못했습니다. 이 클래스는 help_text 라벨뿐 아니라 이력 화면의 잘린 값, 팝업·소식 목록의 이미지 hover 대상에도 붙습니다. 전부 "hover하면 설명이 뜬다"는 같은 성격이라 일괄 적용이 맞다고 봤지만, 각 화면을 하나하나 열어 본 건 아닙니다.
  • 이 규칙은 업계 통념보다 엄격합니다. Bootstrap의 btn-link처럼 이동하지 않으면서 밑줄을 단 link-style button을 두는 디자인 시스템이 흔해요. 우리는 트리 선택이 상세를 갈아끼우는 것처럼 이동/비이동 경계가 흐린 화면이 많아서 더 좁게 잡았습니다. 다른 팀이 그대로 가져다 쓸 규칙은 아니에요.
  • 권한 목록을 넓게 펴면서 높이 클래스를 문자열로 조립하지 않고 리터럴로 둔 자리가 있습니다(3a38a6fa). Tailwind는 소스에 글자 그대로 적힌 클래스만 찾아 CSS를 만들어서, 조립하면 그 클래스가 빌드에서 빠집니다. v4 전환 때 safelist로 한 번 본 함정이에요.

관련 글: 버튼 기본값이 w-full이었다 · disabled를 쓰면 왜 안 되는지를 말할 수 없다 · tv에 연결하니 테두리가 안 보였다 · 팔레트에 없는 색은 조용히 안 칠해진다