프론트엔드 UI 엔지니어링은(는) 무엇을 하나요?
개요
접근성, 성능, 시각적으로 완성된 프로덕션 품질의 사용자 인터페이스를 구축합니다. 목표는 최고의 회사에서 디자인을 아는 엔지니어가 구축한 것처럼 보이는 UI를 만드는 것입니다. AI가 생성한 것처럼 보이지 않아야 합니다. 이는 실제 디자인 시스템 준수, 적절한 접근성, 세심한 상호작용 패턴, 그리고 일반적인 'AI 미학'이 없음을 의미합니다.
사용 시기
- 새 UI 컴포넌트 또는 페이지 구축
- 기존 사용자 대면 인터페이스 수정
- 반응형 레이아웃 구현
- 상호작용 또는 상태 관리 추가
- 시각적 또는 UX 문제 수정
컴포넌트 아키텍처
파일 구조
컴포넌트와 관련된 모든 파일을 한 곳에 모읍니다:
src/components/
TaskList/
TaskList.tsx # 컴포넌트 구현
TaskList.test.tsx # 테스트
TaskList.stories.tsx # Storybook 스토리 (사용하는 경우)
use-task-list.ts # 사용자 정의 훅 (복잡한 상태인 경우)
types.ts # 컴포넌트 특정 타입 (필요한 경우)
컴포넌트 패턴
설정보다 합성을 선호하세요:
// 좋음: 합성 가능
<Card>
<CardHeader>
<CardTitle>작업</CardTitle>
</CardHeader>
<CardBody>
<TaskList tasks={tasks} />
</CardBody>
</Card>
// 피해야 할 것: 지나친 설정
<Card
title="작업"
headerVariant="large"
bodyPadding="md"
content={<TaskList tasks={tasks} />}
/>
컴포넌트를 집중적으로 유지하세요:
// 좋음: 한 가지 일만 함
export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<li className="flex items-center gap-3 p-3">
<Checkbox checked={task.done} onChange={() => onToggle(task.id)} />
<span className={task.done ? 'line-through text-muted' : ''}>{task.title}</span>
<Button variant="ghost" size="sm" onClick={() => onDelete(task.id)}>
<TrashIcon />
</Button>
</li>
);
}
데이터 가져오기와 프레젠테이션을 분리하세요:
// 컨테이너: 데이터 처리
export function TaskListContainer() {
const { tasks, isLoading, error } = useTasks();
if (isLoading) return <TaskListSkeleton />;
if (error) return <ErrorState message="작업을 불러오지 못했습니다" retry={refetch} />;
if (tasks.length === 0) return <EmptyState message="아직 작업이 없습니다" />;
return <TaskList tasks={tasks} />;
}
// 프레젠테이션: 렌더링 처리
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul role="list" className="divide-y">
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
</ul>
);
}
상태 관리
가장 간단한 접근 방식을 선택하세요:
로컬 상태 (useState) → 컴포넌트별 UI 상태
상태 끌어올리기 → 2-3개의 형제 컴포넌트 간 공유
컨텍스트 → 테마, 인증, 로케일 (읽기 위주, 쓰기 드묾)
URL 상태 (searchParams) → 필터, 페이지네이션, 공유 가능한 UI 상태
서버 상태 (React Query, SWR) → 캐싱이 있는 원격 데이터
전역 저장소 (Zustand, Redux) → 앱 전체에서 공유되는 복잡한 클라이언트 상태
3단계 이상의 prop 드릴링은 피하세요. 사용하지 않는 컴포넌트를 통해 props를 전달하고 있다면, 컨텍스트를 도입하거나 컴포넌트 트리를 재구성하세요.
디자인 시스템 준수
참조 기반 UI 품질
제품이 뚜렷한 시각적 방향을 필요로 할 때는 레이아웃을 선택하기 전에 증거를 수집하세요:
- 신뢰할 수 있는 참조 카탈로그를 검색하거나 제품 팀이 제공한 참조를 사용하세요.
- 관련 화면 2~3개를 연구하세요. 계층 구조, 밀도, 내비게이션, 컨트롤, 반응형 동작, 상호작용 상태에 대한 결정을 기록하세요.
- 구현하기 전에 그 결정을 간단한 디자인 계약으로 전환하세요. 화면의 역할, 기본 작업, 필요한 상태, 반응형 규칙, 거부할 패턴을 명명하세요.
- 유용한 구조를 제품 자체의 컴포넌트, 토큰, 콘텐츠, 시각적 언어로 재구축하세요. 다른 제품의 브랜딩, 독점 텍스트, 이미지 또는 정확한 레이아웃을 절대 복사하지 마세요.
참조를 템플릿이 아닌 증거로 사용하세요. 참조를 사용할 수 없는 경우, 가정을 문서화하고 결과를 제품의 기존 디자인 시스템에 대해 검증하세요.
AI 미학 피하기
AI 생성 UI는 인식 가능한 패턴을 가지고 있습니다. 모두 피하세요:
| AI 기본값 | 문제점 | 프로덕션 품질 |
|---|---|---|
| 모든 것이 보라색/인디고 | 모델이 시각적으로 '안전한' 팔레트를 기본으로 사용하여 모든 앱이 동일하게 보임 | 프로젝트의 실제 색상 팔레트 사용 |
| 과도한 그라데이션 | 그라데이션이 시각적 노이즈를 추가하고 대부분의 디자인 시스템과 충돌함 | 디자인 시스템과 일치하는 평면 또는 미묘한 그라데이션 |
| 모든 것을 둥글게 (rounded-2xl) | 최대 둥글기가 '친근함'을 신호하지만 실제 디자인의 모서리 반경 계층 구조를 무시함 | 디자인 시스템의 일관된 border-radius |
| 일반적인 히어로 섹션 | 실제 콘텐츠나 사용자 요구와 관련이 없는 템플릿 기반 레이아웃 | 콘텐츠 우선 레이아웃 |
| Lorem ipsum 스타일 복사본 | 실제 콘텐츠가 드러내는 레이아웃 문제(길이, 줄 바꿈, 오버플로)를 플레이스홀더 텍스트가 숨김 | 현실적인 플레이스홀더 콘텐츠 |
| 모든 곳에 과도한 패딩 | 동일한 넉넉한 패딩이 시각적 계층 구조를 파괴하고 화면 공간을 낭비함 | 일관된 간격 척도 |
| 기본 카드 그리드 | 균일한 그리드는 정보 우선 순위와 스캔 패턴을 무시하는 레이아웃 지름길임 | 목적 기반 레이아웃 |
| 그림자 과다 디자인 | 계층화된 그림자는 콘텐츠와 경쟁하고 저사양 기기에서 렌더링을 느리게 함 | 디자인 시스템에서 지정하지 않는 한 미묘하거나 그림자 없음 |
간격 및 레이아웃
일관된 간격 척도를 사용하세요. 값을 임의로 만들지 마세요:
/* 척도 사용: 0.25rem 단위 (또는 프로젝트가 사용하는 것) */
/* 좋음 */ padding: 1rem; /* 16px */
/* 좋음 */ gap: 0.75rem; /* 12px */
/* 나쁨 */ padding: 13px; /* 어떤 척도에도 없음 */
/* 나쁨 */ margin-top: 2.3rem; /* 어떤 척도에도 없음 */
타이포그래피
타입 계층 구조를 존중하세요:
h1 → 페이지 제목 (페이지당 한 개)
h2 → 섹션 제목
h3 → 하위 섹션 제목
body → 기본 텍스트
small → 보조/도움말 텍스트
제목 수준을 건너뛰지 마세요. 제목이 아닌 콘텐츠에 제목 스타일을 사용하지 마세요.
색상
- 의미론적 색상 토큰 사용:
text-primary,bg-surface,border-default— 원시 hex 값 사용 금지 - 충분한 대비 보장 (일반 텍스트의 경우 4.5:1, 큰 텍스트의 경우 3:1)
- 정보를 전달하는 데 색상에만 의존하지 마세요 (아이콘, 텍스트 또는 패턴도 사용)
접근성 (WCAG 2.1 AA)
모든 컴포넌트는 다음 기준을 충족해야 합니다:
키보드 내비게이션
// 모든 상호작용 요소는 키보드로 접근 가능해야 함
<button onClick={handleClick}>클릭하세요</button> // ✓ 기본적으로 포커스 가능
<div onClick={handleClick}>클릭하세요</div> // ✗ 포커스 불가능
<div role="button" tabIndex={0} onClick={handleClick} // ✓ 하지만 <button> 권장
onKeyDown={e => {
if (e.key === 'Enter') handleClick();
if (e.key === ' ') e.preventDefault();
}}
onKeyUp={e => {
if (e.key === ' ') handleClick();
}}>
클릭하세요
</div>
ARIA 레이블
// 보이는 텍스트가 없는 상호작용 요소에 레이블 지정
<button aria-label="대화상자 닫기"><XIcon /></button>
// 폼 입력에 레이블 지정
<label htmlFor="email">이메일</label>
<input id="email" type="email" />
// 또는 보이는 레이블이 없는 경우 aria-label 사용
<input aria-label="작업 검색" type="search" />
포커스 관리
// 콘텐츠 변경 시 포커스 이동
function Dialog({ isOpen, onClose }: DialogProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) closeRef.current?.focus();
}, [isOpen]);
// 열려 있을 때 대화상자 내부에 포커스 고정
return (
<dialog open={isOpen}>
<button ref={closeRef} onClick={onClose}>닫기</button>
{/* 대화상자 콘텐츠 */}
</dialog>
);
}
의미 있는 빈 상태 및 오류 상태
// 빈 화면을 표시하지 마세요
function TaskList({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return (
<div role="status" className="text-center py-12">
<TasksEmptyIcon className="mx-auto h-12 w-12 text-muted" />
<h3 className="mt-2 text-sm font-medium">작업 없음</h3>
<p className="mt-1 text-sm text-muted">새 작업을 만들어 시작하세요.</p>
<Button className="mt-4" onClick={onCreateTask}>작업 만들기</Button>
</div>
);
}
return <ul role="list">...</ul>;
}
반응형 디자인
모바일 우선으로 디자인한 다음 확장하세요:
// Tailwind: 모바일 우선 반응형
<div className="
grid grid-cols-1 /* 모바일: 단일 열 */
sm:grid-cols-2 /* 소형: 2열 */
lg:grid-cols-3 /* 대형: 3열 */
gap-4
">
다음 중단점에서 테스트하세요: 320px, 768px, 1024px, 1440px.
로딩 및 전환
// 스켈레톤 로딩 (콘텐츠에 스피너 사용 금지)
function TaskListSkeleton() {
return (
<div className="space-y-3" aria-busy="true" aria-label="작업 로딩 중">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
);
}
// 인지된 속도를 위한 낙관적 업데이트
function useToggleTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleTask,
onMutate: async (taskId) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
const previous = queryClient.getQueryData(['tasks']);
queryClient.setQueryData(['tasks'], (old: Task[]) =>
old.map(t => t.id === taskId ? { ...t, done: !t.done } : t)
);
return { previous };
},
onError: (_err, _taskId, context) => {
queryClient.setQueryData(['tasks'], context?.previous);
},
});
}
참조
자세한 접근성 요구 사항 및 테스트 도구는 ../../references/accessibility-checklist.md를 참조하세요.
일반적인 합리화
| 합리화 | 현실 |
|---|---|
| "접근성은 있으면 좋은 기능이다" | 많은 관할권에서 법적 요구 사항이자 엔지니어링 품질 기준입니다. |
| "나중에 반응형으로 만들겠다" | 반응형 디자인을 다시 적용하는 것은 처음부터 구축하는 것보다 3배 더 어렵습니다. |
| "디자인이 최종본이 아니므로 스타일링을 건너뛰겠다" | 디자인 시스템 기본값을 사용하세요. 스타일이 없는 UI는 리뷰어에게 좋지 않은 첫인상을 줍니다. |
| "이건 그냥 프로토타입이다" | 프로토타입은 프로덕션 코드가 됩니다. 기초를 올바르게 구축하세요. |
| "AI 미학은 지금은 괜찮다" | 이는 낮은 품질을 신호합니다. 처음부터 프로젝트의 실제 디자인 시스템을 사용하세요. |
위험 신호
- 200줄이 넘는 컴포넌트 (분할하세요)
- 인라인 스타일 또는 임의의 픽셀 값
- 오류 상태, 로딩 상태 또는 빈 상태 누락
- 키보드 내비게이션 테스트 없음
- 상태의 유일한 지표로 색상을 사용(텍스트나 아이콘 없이 빨간색/녹색)
- 일반적인 "AI 룩" (보라색 그라데이션, 과도하게 큰 카드, 기본 레이아웃)
검증
UI 구축 후:
- 컴포넌트가 콘솔 오류 없이 렌더링됨
- 모든 상호작용 요소가 키보드로 접근 가능함 (Tab 키로 페이지 탐색)
- 스크린 리더가 페이지의 콘텐츠와 구조를 전달할 수 있음
- 반응형: 320px, 768px, 1024px, 1440px에서 작동함
- 해당되는 경우 로딩, 빈, 오류, 성공 및 권한 상태가 처리됨
- 프로젝트의 디자인 시스템(간격, 색상, 타이포그래피)을 따름
- 렌더링된 결과가 최종 UI 특정 마감 게이트 검토를 통과함
- 개발자 도구 또는 axe-core에서 접근성 경고 없음