후기 목록이 이렇게 생겼다.

<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">

왜 그렇게 했는지 기록이 없다. 「격자 배치를 하려고」였을 수 있는데 uldisplay: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 로 만들었는지 물었더니 기록이 없었다. 「격자 배치 때문」이었을 수 있는데 uldisplay: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` 를 쓰지 않는다). 조건부 분기(로딩·에러·정상)로 파일에 여러 번 나오는 것은 실제로 하나만 뜨므로 괜찮다.
- **맞는 태그를 안 쓰기로 했으면 그 이유를 파일에 적는다.** 기본은 태그를 쓰는 쪽이고, 이유가 없으면 태그를 쓴다 — **이유가 안 적힌 판단은 되돌릴 근거도 없다.**

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기