문제는 항상 '사소한 것'에서 시작된다
레거시 코드베이스를 열거나 Figma 핸드오프 파일을 받을 때 #d9d9d9가 반복적으로 등장하는 걸 발견했다면, 당신은 이미 두 개의 폭탄 위에 앉아 있는 것이다. 하나는 접근성 폭탄이고, 다른 하나는 렌더링 성능 폭탄이다. 흥미롭게도 두 문제는 서로 다른 레이어에 있는 것처럼 보이지만, 뿌리는 같다. '지금 당장 돌아가니까 괜찮겠지'라는 설계 습관의 부재다.
색상 하나가 UI 전체를 망치는 방식
dev.to에 올라온 글 "Stop Hardcoding Hex #d9d9d9 In Your CSS"는 단순한 색상 코드 이야기처럼 보이지만, 실제로는 디자인 시스템 설계의 핵심 원칙을 꿰뚫는 내용이다. #d9d9d9는 밝기 85%의 중립 회색으로, 개발자들이 비활성 버튼·테두리·카드 배경에 별 생각 없이 쓰는 '기본값'이다. 문제는 세 방향에서 동시에 터진다.
첫째, 다크 모드. border: 1px solid #d9d9d9를 직접 박아두면 다크 테마로 전환하는 순간 85% 밝기의 회색선이 어두운 배경 위에서 번쩍이는 흰 선으로 변한다. CSS 변수나 디자인 토큰으로 추상화했다면 @media (prefers-color-scheme: dark) 안에서 #3d3d3d 같은 값으로 조용히 교체할 수 있다.
둘째, 접근성 함정. #d9d9d9 배경에 #9e9e9e 텍스트를 올리면 회색-위-회색 조합이 WCAG AA 기준을 완전히 위반한다. 비활성 상태를 색으로만 전달하는 건 색맹 사용자를 배제하는 패턴이다. cursor: not-allowed나 아이콘 같은 보조 인디케이터가 반드시 함께 있어야 한다.
셋째, 디스플레이 색공간 차이. Display P3를 지원하는 최신 맥북에서는 선명하게 보이는 #d9d9d9가, 저가형 TN 패널에서는 흰 배경과 거의 구분이 안 될 정도로 씻겨 나간다. OKLCH 기반 색상 시스템을 쓰면 디스플레이 환경에 관계없이 균일한 렌더링을 보장할 수 있다.
Context도 공짜가 아니다
색상 레이어를 정리했다 해도 컴포넌트 트리가 엉망이면 UI는 또 다른 방식으로 무너진다. dev.to의 "Context vs Prop Drilling: I Put the Re-render Blast Radius Side by Side"는 React 19 + TypeScript로 만든 라이브 데모를 통해 이 차이를 시각적으로 증명한다.
4단계 컴포넌트 트리에서 prop drilling은 버튼 하나를 클릭할 때마다 4개 컴포넌트가 동시에 리렌더된다. 경로 위의 모든 중간 컴포넌트가 값을 받아서 아래로 전달하는 것 외에 아무것도 하지 않으면서도 리렌더 비용을 치른다. React.memo를 달아도 prop이 바뀌면 탈출할 수 없다.
Context로 전환하면 1개 컴포넌트만 리렌더된다. useContext를 구독하는 리프 컴포넌트만 반응하고, 중간 컴포넌트들은 자신의 prop이 바뀌지 않았으므로 메모이제이션이 유효하게 작동한다. Provider 값이 바뀌면 React는 중간 트리를 거치지 않고 소비자에게 직접 신호를 보낸다.
다만 이 글이 명확히 지적하듯 Context는 '리렌더 없음 버튼'이 아니다. Provider 값이 바뀔 때 모든 소비자가 일괄 리렌더되는 건 여전하다. 자주 바뀌는 값(MousePositionContext)과 거의 바뀌지 않는 값(AuthContext)을 같은 Context에 묶으면 prop drilling보다 더 큰 폭발 반경을 만든다. useMemo로 provider 값을 안정화하거나, Zustand·use-context-selector 같은 선택적 구독 패턴을 쓰는 게 해법이다.
두 문제가 가리키는 하나의 원칙
색상 하드코딩과 Context 남용은 표면적으로 전혀 다른 문제처럼 보이지만, 실은 같은 설계 실수의 다른 표현이다. '지금 동작하니까 충분하다'는 판단이 토큰 추상화를 건너뛰게 만들고, 'Context 쓰면 된다'는 피상적 이해가 렌더링 경계 설계를 건너뛰게 만든다.
디자인 토큰은 단순히 색상 이름을 변수로 바꾸는 작업이 아니다. 다크 모드·고대비 모드·색공간 차이를 하나의 시스템 안에서 흡수하는 의미 레이어를 만드는 일이다. --color-border-subtle이라는 이름은 #d9d9d9라는 숫자보다 훨씬 많은 설계 의도를 담는다. 마찬가지로 Context를 어느 단위로 쪼개고, 어떤 값이 얼마나 자주 바뀌는지를 먼저 파악하는 것이 컴포넌트 렌더링 경계를 설계하는 일이다.
실무에서 지금 당장 적용할 수 있는 체크리스트
두 기사가 공통으로 제안하는 흐름을 정리하면 이렇다.
- 색상은 항상 의미 토큰으로 추상화한다.
#d9d9d9대신--color-border-subtle,--color-surface-disabled같은 시맨틱 변수를 쓴다. Tailwind를 쓴다면gray-300을 직접 쓰기보다 디자인 토큰 레이어를 한 겹 더 두는 게 장기적으로 유리하다. - 접근성은 색상만으로 전달하지 않는다. 비활성 상태, 에러 상태, 선택 상태 모두 색 외에 텍스트·아이콘·커서 등 보조 신호를 함께 제공한다.
- Context는 변경 빈도 기준으로 분리한다. 자주 바뀌는 값과 거의 바뀌지 않는 값을 같은 Context에 묶지 않는다. Provider 값은
useMemo로 안정화한다. - 리렌더 범위를 가시화한다. React DevTools Profiler나 위 기사의 라이브 데모처럼 실제 렌더 카운터를 달아두면 최적화 근거가 생긴다.
디자인 시스템은 성능과 접근성의 공통 기반이다
디자인 시스템을 '예쁜 컴포넌트 모음'으로 보는 시각이 아직 많다. 하지만 토큰 레이어가 제대로 설계되어 있어야 다크 모드 대응이 한 줄로 끝나고, 컴포넌트 경계가 명확해야 Context의 리렌더 폭발 반경이 예측 가능해진다. 접근성과 성능은 마지막에 얹는 옵션이 아니라, 설계 초반에 토큰과 경계를 어떻게 긋느냐에서 이미 결정된다.
AI가 컴포넌트를 빠르게 생성하는 시대일수록 이 원칙은 더 중요해진다. 에이전트는 #d9d9d9를 아무 맥락 없이 반복하고, Context를 편의대로 하나로 묶을 것이다. 그 결과물을 프로덕션에 올리기 전에, 토큰 추상화와 렌더링 경계라는 두 축이 설계 의도대로 잡혀 있는지를 사람이 확인해야 한다. 속도가 올라갈수록 기반 설계를 검토하는 눈이 더 예리해져야 하는 이유다.