글 · 시리즈 내 사이트 만들기 · 1번째 글 / 총 1편
헤더에서 시작한 접근성 고민
2026.09.12
내 사이트를 만들면서 처음 고민했던 것 중 하나는 헤더 구조였다. 로고와 메뉴 링크를 배치하는 영역인데, 마크업은 어떻게 짜고 컴포넌트는 어디까지 나누면 좋을지 고민했다.
당시 메뉴에는 About, Portfolio, GitHub 링크가 있었다. 화면에 링크를 나열하는 것뿐 아니라 각 요소의 역할도 HTML에 담고 싶었다. 웹 접근성(a11y)을 생각하면서 시맨틱 마크업 구조와 컴포넌트 분리 설계를 AI에게 물었고, 받은 피드백을 코드에 적용했다.
당시 생각한 마크업은 이런 형태였다.
header
└── nav
└── ul
└── li
└── a
header는 페이지 상단의 소개와 내비게이션 영역, nav는 주요 탐색 링크를 묶는 영역이다. 그 안에서 ul과 li로 메뉴 목록을 표현하고, 각 항목에 링크를 둔다. 내부 링크에 사용하는 Link도 렌더링된 HTML에서는 a가 된다.
AI는 이 구조가 시맨틱 마크업과 접근성 측면에서 적절하다고 답했다. 다만 태그 구성이 적절하다는 평가만으로 접근성을 전부 확인한 것은 아니다. 당시 질문은 헤더의 마크업과 컴포넌트 구조에 관한 것이었고, 이 글에서 다루는 범위도 거기까지다.
태그 구성을 정하고 나니 컴포넌트를 나누는 기준이 남았다. Header, Nav, NavList, NavItem으로 분리하면 어떨까 하는 고민이었다. HTML 구조에 맞춰 이름을 붙이면 역할이 명확해 보였지만, 태그마다 컴포넌트를 만드는 게 필요한지는 확신이 없었다.
피드백에서 눈에 들어온 건 NavList였다. ul 하나만 감싸는 역할이라면 별도 컴포넌트로 두지 않고 Nav 안에 직접 작성해도 된다는 의견이었다. 컴포넌트를 나눌 때 태그의 경계만 기준으로 삼을 필요는 없었다.
반면 NavItem은 분리할 이유가 있었다. 메뉴 항목에는 링크 주소와 표시할 이름이 필요하고, 내부 링크인지 외부 링크인지에 따라 처리도 달라진다. 나중에 현재 페이지를 표시하는 스타일이 필요해진다면 그 로직을 둘 만한 자리이기도 했다. 당시 그런 기능을 모두 구현했다는 뜻은 아니고, 하나의 항목이 맡을 역할을 생각해 볼 수 있는 단위였다.
그래서 추천받은 구성은 Header, Nav, NavItem이었다. Header는 로고와 내비게이션을 배치하고, Nav는 nav와 ul을 함께 맡으며, NavItem은 li와 링크를 묶는다. 시맨틱 마크업 구조는 유지하면서 컴포넌트 경계는 조금 더 크게 잡은 셈이다.
당시 받은 코드 예시를 역할별로 나누면 아래와 같다. 먼저 Header와 Nav다. NavList를 따로 만들지 않고 Nav 안에 ul을 두었다. 여기서 사용하는 NAV_ITEMS와 NavItem은 이어서 살펴본다.
import Link from 'next/link';
export function Header() {
return (
<header className="flex justify-between">
<Link href="/" className="text-lg font-bold">
minjun.me
</Link>
<Nav />
</header>
);
}
function Nav() {
return (
<nav aria-label="메인 네비게이션">
<ul className="flex gap-7">
{NAV_ITEMS.map((item) => (
<NavItem key={item.href} {...item} />
))}
</ul>
</nav>
);
}
메뉴 데이터를 표현하는 방식도 피드백에 포함됐다. 표시할 텍스트를 'About' | 'Portfolio' | 'GitHub'처럼 제한하기보다 label, href, isExternal을 받도록 구성하라는 제안이었다. 링크 목록은 배열로 두고 map으로 렌더링한다. 메뉴 이름과 주소를 한곳에서 확인하고 수정하기 쉬운 형태였다.
interface NavLinkItem {
label: string;
href: string;
isExternal?: boolean;
}
const NAV_ITEMS: NavLinkItem[] = [
{ label: 'About', href: '/about' },
{ label: 'Portfolio', href: '/portfolio' },
{ label: 'GitHub', href: 'https://github.com', isExternal: true },
];
앞의 Nav는 이 배열을 순회하며 각 항목을 NavItem에 전달한다. 메뉴 이름이 바뀌어도 컴포넌트의 타입을 고칠 필요 없이 배열의 label을 수정하면 된다. GitHub 주소는 당시 예시에 있던 주소를 그대로 두었다.
접근성 속성도 함께 살폈다. nav에 aria-label="메인 네비게이션"을 붙여 탐색 영역의 이름을 명시하는 방식이었다. 외부 링크는 새 탭으로 열 경우 target="_blank"와 rel="noopener noreferrer"를 사용하는 예시를 받았다. 외부 링크라는 이유만으로 반드시 새 탭을 열어야 하는 것은 아니므로, 이 부분은 링크를 어떻게 열지 정하는 문제와 함께 봐야 한다.
이 예시에서는 isExternal이 참이면 새 탭으로 여는 a를, 나머지는 내부 이동을 위한 Link를 렌더링한다. 이런 분기를 한곳에서 처리하는 것이 NavItem의 역할이다. Props는 위에서 정의한 NavLinkItem 타입을 그대로 사용했다.
function NavItem({ label, href, isExternal }: NavLinkItem) {
return (
<li>
{isExternal ? (
<a href={href} target="_blank" rel="noopener noreferrer">
{label}
</a>
) : (
<Link href={href}>{label}</Link>
)}
</li>
);
}
이 피드백을 적용하면서 정리한 기준은 단순했다. HTML 태그는 그 영역의 의미에 맞게 고르고, 컴포넌트는 맡을 역할이 있는지 보고 나눈다. ul이 필요하다고 해서 NavList까지 꼭 필요한 건 아니었다.
지금은 사이트 구성이 바뀌어 헤더에서 당시의 nav를 없앴다. 그래서 현재 코드에는 이 구조가 그대로 남아 있지 않다. 그래도 처음 헤더를 만들며 어떤 질문을 했고, 어떤 기준으로 구조를 정했는지는 기록해 두고 싶었다. 이 시리즈에서는 이렇게 사이트를 만들면서 고민하고 바꿨던 일들을 하나씩 남겨보려 한다.