후기 목록이 이렇게 생겼다.
<div className="grid grid-3">
<div>후기 1</div>
<div>후기 2</div>
</div>
보이는 건 <ul>/<li> 로 만든 것과 똑같다. 다른 것은 눈에 안 보이는 쪽이다 — 검색 엔진은 「여기 후기가 몇 개 있다」를 못 읽고, 화면 읽기 도구는 「목록, 항목 3개」를 안 알려준다.
안 A div 로 만들고 필요하면 role 로 알린다
안 B 역할에 맞는 태그가 있으면 그것을 쓴다
먼저: 재보니 SEO 쪽은 잘 되어 있었다
이건 원칙이 아니라, 문제를 잘못 짚을 뻔한 기록이다.
처음엔 헤딩이 문제일 거라 보고 셌는데 두 번 틀렸다.
1차 h1 이 없는 라우트 6개 → 하위 폴더를 중복으로 세서 틀림
2차 h1 이 없는 라우트 6개 → grep -c 가 파일 하나일 때 형식이 달라 0으로 셈
3차 13개 라우트 전부 h1 이 있다 → 맞음
세 번째가 맞았다. 그리고 재보니 이쪽은 손댈 것이 없었다.
h1 13개 라우트 전부 정확히 하나 (분기 셋은 로딩·에러·정상이라 실제로는 하나)
헤딩 계층 Hero 가 h1, 각 섹션이 h2 — 순서가 맞다
JSON-LD lib/structured-data.js 에 LocalBusiness
metadata 14곳에서 내보낸다
「값이 말이 되는지 보고 다음으로 간다」가 두 번 걸렸다. /about 에 제목이 없다는 결과가 이상해서 파일을 열었고, 거기서 측정이 틀린 걸 찾았다.
먼저: 그런데 태그는 거의 전부 div 다
div 202 개
p 83 개
section 23 개 · h2 23 개 · main 20 개 · h1 18 개
article 5 개
ul 2 개 · li 2 개
aside 0 개
ul 이 2개다. 후기 목록도 FAQ 목록도 div 로 되어 있다.
후기 목록 <div className="grid grid-3">
FAQ 목록 <div className="faq-groups">
왜 그렇게 했는지 기록이 없다. 「격자 배치를 하려고」였을 수 있는데 ul 도 display:grid 가 되므로 그 이유는 성립하지 않는다.
원칙 1. 역할에 맞는 태그가 있으면 그것을 쓴다
무엇을 기준으로 태그를 고르는가.
안 A — div 로 만들고 필요하면 role 로 알린다. 태그를 외울 필요가 없고 div 하나로 다 된다. 버린 이유는 role 을 붙이는 것도 기억해야 하는 일이라는 것이다. 실제로 목록에는 role="list" 조차 안 붙어 있다. 태그를 안 외우는 대신 role 을 외워야 하면 아낀 것이 없다.
그리고 role 은 화면 읽기 도구에만 알린다. 검색 엔진은 role 을 구조로 읽지 않는다.
골랐다 — 안 B, 역할에 맞는 태그가 있으면 그것을 쓴다.
| 이런 것이면 | 이 태그 |
|---|---|
| 여러 개가 나열된다 | ul · li (순서가 뜻을 가지면 ol) |
| 용어와 설명이 짝이다 | dl · dt · dd |
| 떼어내도 말이 되는 글 하나 | article |
| 제목이 있는 구획 | section |
| 페이지의 본문 | main (페이지당 하나) |
| 길 안내 링크 묶음 | nav |
| 본문 옆에 딸린 것 | aside |
| 위 어디에도 안 맞는 순수 배치 | div |
div 는 마지막 칸이다. 「이 덩어리에 이름을 붙일 수 있나」를 먼저 묻고, 붙일 이름이 없을 때만 div 다.
대가는 CSS 를 다시 손봐야 하는 것이다. ul 은 기본 여백과 점(list-style)이 붙어 있어 지워야 한다. 그 몇 줄이 이 표준의 전체 비용이다.
원칙 2. 태그가 없는 것에만 role 을 쓴다
role 자체가 나쁜 것은 아니다. 지금 쓰이는 role 은 전부 올바르다.
role="tablist" · role="tab" HTML 에 「탭」 태그가 없다
role="dialog" 커스텀 팝업이다
role="status" 갱신되는 알림 영역이다
이것들은 네이티브 태그가 없어서 role 로 알리는 것이 정답이다.
판별은 한 줄이다 — 그 역할을 뜻하는 HTML 태그가 있는가.
있다 → 태그를 쓴다. role 을 붙이지 않는다
없다 → role 로 알린다
<ul role="list"> 처럼 태그와 role 을 겹쳐 쓰지 않는다. 태그가 이미 그 뜻이라 role 이 하는 일이 없다.
원칙 3. 안 쓰기로 했으면 그 이유를 파일에 적는다
원칙 1에 맞는 태그가 있는데 안 쓰기로 했다면, 왜 그랬는지를 그 자리에 적는다.
지금 후기 목록이 div 인 이유를 아무도 모른다. 판단이 있었는지, 그냥 그렇게 됐는지 알 수 없다. 이유가 안 적힌 판단은 되돌릴 근거도 없어서, 고치려 할 때마다 「혹시 무슨 이유가 있었나」를 처음부터 다시 확인해야 한다.
이건 만들 것인가 가져다 쓸 것인가 표준이 요구하는 것과 같은 기록이다. 거기서는 「왜 가져다 쓰지 않았나」를 적게 했고, 여기서는 「왜 그 태그를 안 썼나」를 적는다.
// ul 을 쓰지 않는 이유: <그 자리에서만 성립하는 사정>
<div className="...">
기본은 태그를 쓰는 쪽이다. 이유가 없으면 원칙 1을 따른다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 무엇으로 태그를 고르나 | 역할에 맞는 태그가 있는가 | 「이 덩어리에 이름을 붙일 수 있나」 |
| 여러 개가 나열된다 | ul · li |
검색 엔진이 개수를 읽고 읽기 도구가 알린다 |
div 는 언제 쓰나 |
붙일 이름이 없을 때 | 마지막 칸이지 첫 칸이 아니다 |
role 은 언제 쓰나 |
태그가 없을 때만 | 탭·팝업·알림처럼 HTML 에 없는 것 |
태그와 role 을 같이 |
안 쓴다 | 태그가 이미 그 뜻이라 하는 일이 없다 |
| 태그를 안 쓰기로 했다 | 이유를 파일에 적는다 | 안 적으면 되돌릴 근거가 없다 |
ul 로 격자 배치가 되나 |
된다 | display:grid 는 어느 요소에나 걸린다 |
이 표준을 정하기까지
SEO 를 고려해야 한다는 말에서 시작했고, 처음엔 헤딩을 의심했다. 세어 보니 h1 이 없는 라우트가 여섯이라고 나와서 「제목 없는 페이지가 절반」이라고 적을 뻔했다.
그 숫자가 이상해서 파일을 열었고, 측정이 틀린 걸 찾았다. 두 번 틀렸다 — 처음엔 하위 폴더를 중복으로 셌고, 고친 뒤에는 grep -c 가 파일 하나일 때 파일:개수 형식이 아니라 개수만 내놓는 걸 놓쳤다. 세 번째에 재니 13개 라우트 전부 h1 이 정확히 하나였다.
그래서 이 글에 「SEO 쪽은 잘 되어 있었다」를 먼저 적었다. 헤딩 계층도 JSON-LD 도 metadata 도 손댈 것이 없다. 문제를 잘못 짚었으면 안 고쳐도 될 것을 고치게 했을 것이다.
남은 것은 태그였다. div 202개에 ul 2개. 후기 목록도 FAQ 목록도 div 다.
그런데 role 쓰임새를 보니 방향이 하나 정해졌다. role="tablist" · role="dialog" · role="status" 가 쓰이고 있었고 전부 올바른 사용이다 — 탭도 팝업도 알림 영역도 HTML 에 태그가 없다. 접근성을 의식하고는 있었다. 다만 목록처럼 태그가 있는 것까지 div 로 간 것이 문제였다.
그래서 기준이 「div 를 쓰지 마라」가 아니라 「태그가 있으면 태그를 쓴다」가 됐다. div 는 나쁜 태그가 아니라 붙일 이름이 없을 때 쓰는 마지막 칸이다.
왜 div 로 만들었는지 물었더니 기록이 없었다. 「격자 배치 때문」이었을 수 있는데 ul 도 display:grid 가 되므로 그 이유는 성립하지 않는다. 그리고 이유가 안 적힌 판단은 되돌릴 근거도 없다 — 그래서 원칙 3으로 「안 쓰기로 했으면 이유를 적는다」를 넣었다. 앞 표준이 「직접 만들었으면 이유를 적는다」로 정한 것과 같은 자리다.
정리
- 역할에 맞는 태그가 있으면 그것을 쓴다. 「이 덩어리에 이름을 붙일 수 있나」를 먼저 묻는다.
div는 마지막 칸이다. 붙일 이름이 없을 때만 쓴다.role은 태그가 없을 때만 쓴다. 탭·팝업·알림은 HTML 에 태그가 없어role이 맞다.- 태그와
role을 겹쳐 쓰지 않는다. 태그가 이미 그 뜻이다. - 태그를 안 쓰기로 했으면 이유를 파일에 적는다. 안 적으면 되돌릴 근거가 없다.
ul로도 격자 배치가 된다. 기본 여백과 점을 지우는 몇 줄이 이 표준의 전체 비용이다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code · Codex — .claude/skills/semantic-markup-standard/SKILL.md · .agents/skills/semantic-markup-standard/SKILL.md
두 파일은 바이트까지 같아야 한다. .agents 쪽은 tools/agents-skills.rb 가 뽑는 생성물이다.
---
name: semantic-markup-standard
description: HTML 태그를 무엇으로 고르는지 정한다. 역할에 맞는 태그와 div 를 가르는 기준, role 을 쓰는 자리, 태그를 안 쓰기로 했을 때 남길 기록을 담는다. 화면 마크업을 쓸 때, 목록이나 구획을 만들 때, 접근성이나 SEO 를 손볼 때 적용한다.
---
# 태그 선택 표준
**역할에 맞는 태그가 있으면 그것을 쓴다. `div` 는 붙일 이름이 없을 때 쓰는 마지막 칸이다.**
검문은 한 줄이다 — **이 덩어리에 이름을 붙일 수 있나.**
## 태그 고르기
| 이런 것이면 | 이 태그 |
|---|---|
| 여러 개가 나열된다 | `ul` · `li` (순서가 뜻을 가지면 `ol`) |
| 용어와 설명이 짝이다 | `dl` · `dt` · `dd` |
| 떼어내도 말이 되는 글 하나 | `article` |
| 제목이 있는 구획 | `section` |
| 페이지의 본문 | `main` — **페이지당 하나** |
| 길 안내 링크 묶음 | `nav` |
| 본문 옆에 딸린 것 | `aside` |
| 위 어디에도 안 맞는 순수 배치 | `div` |
- **목록을 `div` 로 만들지 않는다.** 검색 엔진이 개수를 못 읽고, 화면 읽기 도구가 「목록, 항목 N개」를 안 알린다.
- **`ul` 로도 격자 배치가 된다.** `display:grid` 는 어느 요소에나 걸린다. 기본 여백과 `list-style` 을 지우는 몇 줄이면 된다.
- 조건부 분기(로딩·에러·정상)로 `main` 이나 `h1` 이 파일에 여러 번 나오는 것은 **실제로 하나만 뜨므로 괜찮다.**
## `role` 은 태그가 없을 때만
```
그 역할을 뜻하는 HTML 태그가 있나?
있다 → 태그를 쓴다. role 을 붙이지 않는다
없다 → role 로 알린다 탭 · 커스텀 팝업 · 알림 영역
```
- **`<ul role="list">` 처럼 겹쳐 쓰지 않는다.** 태그가 이미 그 뜻이라 `role` 이 하는 일이 없다.
- **`role` 은 화면 읽기 도구에만 알린다.** 검색 엔진은 `role` 을 구조로 읽지 않는다.
## 헤딩
- **페이지당 `h1` 하나.**
- **단계를 건너뛰지 않는다.** `h2` 다음에 `h4` 를 쓰지 않는다.
- 제목이 있는 구획은 `section` 과 그 안의 헤딩이 짝이다.
## 안 쓰기로 했으면 이유를 적는다
```jsx
// ul 을 쓰지 않는 이유: <그 자리에서만 성립하는 사정>
<div className="...">
```
**기본은 태그를 쓰는 쪽이다.** 이유가 없으면 태그를 쓴다. **이유가 안 적힌 판단은 되돌릴 근거도 없어서**, 고치려 할 때마다 「무슨 사정이 있었나」를 처음부터 확인해야 한다.
## 하지 않는 것
- **목록·구획·본문을 `div` 로 만들지 않는다.** 맞는 태그가 있다.
- **태그로 되는 것을 `role` 로 때우지 않는다.**
- **태그와 `role` 을 겹쳐 쓰지 않는다.**
- **`h1` 을 한 화면에 둘 이상 그리지 않는다.** 조건부 분기는 예외다.
- **헤딩 단계를 건너뛰지 않는다.**
- **맞는 태그를 안 쓰면서 이유를 안 적지 않는다.**
GitHub Copilot — .github/instructions/semantic-markup-standard.instructions.md
---
description: HTML 태그를 무엇으로 고르는가
applyTo: "**"
---
# 태그 선택
- **역할에 맞는 태그가 있으면 그것을 쓴다. `div` 는 붙일 이름이 없을 때 쓰는 마지막 칸이다.** 검문은 「이 덩어리에 이름을 붙일 수 있나」 한 줄이다.
- 태그 고르기 — **나열되면** `ul`·`li`(순서가 뜻을 가지면 `ol`), **용어와 설명이 짝이면** `dl`·`dt`·`dd`, **떼어내도 말이 되는 글이면** `article`, **제목이 있는 구획이면** `section`, **페이지 본문이면** `main`(페이지당 하나), **길 안내 링크면** `nav`, **본문 옆에 딸린 것이면** `aside`.
- **목록을 `div` 로 만들지 않는다.** 검색 엔진이 개수를 못 읽고 화면 읽기 도구가 「목록, 항목 N개」를 안 알린다. **`ul` 로도 격자 배치가 된다** — `display:grid` 는 어느 요소에나 걸리고, 기본 여백과 `list-style` 을 지우는 몇 줄이면 된다.
- **`role` 은 그 역할을 뜻하는 HTML 태그가 없을 때만 쓴다.** 탭·커스텀 팝업·알림 영역은 태그가 없어 `role` 이 맞다. **`<ul role="list">` 처럼 겹쳐 쓰지 않는다** — 태그가 이미 그 뜻이다. 그리고 **`role` 은 화면 읽기 도구에만 알리고 검색 엔진은 구조로 읽지 않는다.**
- **페이지당 `h1` 하나.** 헤딩 단계를 건너뛰지 않는다(`h2` 다음에 `h4` 를 쓰지 않는다). 조건부 분기(로딩·에러·정상)로 파일에 여러 번 나오는 것은 실제로 하나만 뜨므로 괜찮다.
- **맞는 태그를 안 쓰기로 했으면 그 이유를 파일에 적는다.** 기본은 태그를 쓰는 쪽이고, 이유가 없으면 태그를 쓴다 — **이유가 안 적힌 판단은 되돌릴 근거도 없다.**
자신만의 철학을 만들어가는 중입니다.
댓글남기기