본문으로 건너뛰기

적응형 UI

본 문서는 기기의 상호작용 특성에 따라 컴포넌트와 상호작용 모델을 교체하는 규칙을 정의합니다.

1. 반응형과 적응형의 구분

두 용어는 서로 다른 문제를 가리킵니다. 혼동하면 CSS로 풀어야 할 것을 런타임 분기로 만들거나 그 반대가 됩니다.

반응형 (Responsive) 적응형 (Adaptive)
바뀌는 것 레이아웃 컴포넌트와 상호작용 모델
수단 CSS 미디어 쿼리 런타임 분기
DOM 동일 교체
예시 그리드 3열이 1열로 드롭다운이 액션시트로

CSS로 해결되면 반응형입니다. 반응형으로 해결 가능한 것을 런타임 분기로 만드는 것은 금지합니다. 분기는 두 표현을 모두 유지해야 하고 테스트도 두 배가 됩니다.

2. 판정 축

판정 축은 화면 너비가 아닙니다. 액션시트가 필요한 이유는 화면이 작아서가 아니라 손가락으로 조작하고 호버가 없기 때문입니다.

미디어 쿼리 판정하는 것 역할
(pointer: coarse) 포인터가 부정확한가 1차 축
(hover: none) 호버가 없는가 1차 축
(min-width: ...) 화면이 넓은가 2차 축. 레이아웃 배치에만 사용

너비만으로 판정하면 다음이 오판됩니다.

  • 터치 노트북 — 넓지만 손가락으로 조작합니다
  • 태블릿 가로 — 임계값을 넘지만 호버가 없습니다
  • 좁게 줄인 데스크탑 창 — 마우스인데 터치용 UI가 뜹니다

널리 쓰이는 useMediaQuery("(min-width: 768px)") 형태의 레시피는 편의를 위한 근사이며 본 표준은 채택하지 않습니다.

3. 감지

3.1 의미 신호

컴포넌트가 미디어 쿼리 문자열을 직접 갖는 것을 금지합니다. shared/lib/adaptive/의 의미 신호만 사용합니다.

// shared/lib/adaptive/interaction-mode.ts
export function injectInteractionMode(): Signal<'pointer' | 'touch'> {
  const observer = inject(BreakpointObserver);
  const state = toSignal(
    observer.observe(['(pointer: coarse)', '(hover: none)']),
    { initialValue: null },
  );
  return computed(() => {
    const s = state();
    if (!s) return 'pointer';                     // 방어용. 3.2절 참조
    return s.breakpoints['(pointer: coarse)'] && s.breakpoints['(hover: none)']
      ? 'touch'
      : 'pointer';
  });
}

구현에는 CDK의 BreakpointObserver를 사용합니다. Spartan brain이 CDK를 요구하므로 이미 의존성에 있으며, 리스너 정리와 동일 쿼리 중복 구독 처리를 직접 만들 이유가 없습니다.

규칙의 핵심은 구현 수단이 아니라 래퍼의 존재입니다. 판정 기준이 바뀌면 이 파일 하나만 고칩니다.

3.2 서버 기본값

서버에는 포인터도 호버도 없으므로 판정 결과는 항상 pointer입니다.

이 값이 initialValue에서 오는 것이 아니라는 점이 중요합니다. CDK는 비브라우저 환경에서 MediaMatchernoopMatchMedia로 대체하며, 이 구현은 모든 쿼리에 matches: false를 담은 결과를 구독 즉시 동기로 방출합니다. 따라서 서버에서도 state()는 null이 아닌 실제 상태 객체를 갖고, 두 1차 축이 모두 false이므로 pointer로 판정됩니다. 위 코드의 null 분기는 방어용이며 서버 렌더 경로에서 도달하지 않습니다.

주의

서버의 오판은 예외로 드러나지 않습니다

예외가 발생한다면 정적 생성 경로에서 적응형 컴포넌트를 쓴 실수가 빌드 실패로 잡힙니다. 실제로는 조용히 pointer가 반환되므로 터치 기기에서 열었을 때만 표현이 교체되며 화면이 깜빡입니다. 이 침묵이 6절의 제약을 코드 리뷰로 확인해야 하는 이유입니다.

4. 구현 패턴

4.1 판정 트리

위에서부터 순서대로 판정하고 먼저 해당하는 항목에서 멈춥니다.

1. 표시 위치만 다른가?
   → 패턴 A: 오버레이 위치 전략 교체

2. 상호작용 모델 자체가 다른가?
   → 패턴 B: 표현 컴포넌트 교체

3. B인데 번들 측정에서 문제가 확인되었는가?
   → 패턴 C: B에 @defer 추가

네이티브 위임(터치에서 <input type="date"> 등 OS 기본 UI 사용)은 채택하지 않습니다. OS와 브라우저마다 외형이 달라져 화면 통제를 잃기 때문입니다. 그 결과 날짜 선택기 같은 컴포넌트를 직접 만들어야 하며, 접근성 부담은 brain이 제공하는 프리미티브로 흡수합니다.

4.2 패턴 A — 오버레이 위치 전략 교체

드롭다운과 바텀시트의 차이가 표시 위치뿐인 경우입니다. 컨텐츠 DOM을 유지하므로 컴포넌트를 두 벌 만들지 않습니다.

const strategy = mode() === 'touch'
  ? overlay.position().global().bottom('0').centerHorizontally()
  : overlay.position().flexibleConnectedTo(trigger).withPositions([...]);

brain이 이 교체를 허용합니다. BrnOverlaypositionStrategy 입력을 노출하며, 값이 주어지면 brain의 기본 전략(연결 위치 또는 화면 중앙) 대신 그것을 사용합니다. 적용 범위에 따라 세 가지 수단이 있습니다.

수단 적용 범위 사용 시점
[positionStrategy] 입력 해당 인스턴스 컴포넌트가 모드에 따라 전략을 바꿀 때
provideBrnOverlayDefaultOptions() 주입 범위 전체 앱 전역 기본값을 바꿀 때
[attachPositions] 입력 해당 인스턴스 전략은 유지하고 연결 위치 후보만 조정할 때

전략을 바꾼 뒤 위치를 다시 계산해야 하면 updatePosition()을 호출합니다.

한계는 바텀시트 고유 제스처(드래그로 닫기, 스냅 포인트)와 백드롭 동작이 빠진다는 점입니다. 그 수준이 필요하면 패턴 B로 올립니다.

4.3 패턴 B — 표현 컴포넌트 교체

shared/ui/date-picker/
├── date-picker.ts
├── date-picker-popover.ts
├── date-picker-sheet.ts
└── index.ts
파일 역할
date-picker.ts 공개 API. 값과 검증 상태만 소유합니다
date-picker-popover.ts pointer 표현
date-picker-sheet.ts touch 표현
index.ts date-picker만 내보냅니다
readonly mode = injectInteractionMode();
@if (mode() === 'touch') {
  <app-date-picker-sheet [value]="value()" (valueChange)="value.set($event)" />
} @else {
  <app-date-picker-popover [value]="value()" (valueChange)="value.set($event)" />
}

표현 컴포넌트는 공개 API로 내보내지 않습니다. 외부는 <app-date-picker> 하나만 알며 어느 표현이 뜨는지 신경 쓰지 않습니다. FSD의 슬라이스 공개 API 규칙이 이 캡슐화를 강제합니다.

두 표현은 동일한 입출력 계약을 갖습니다. 계약이 다르면 상위 컴포넌트가 분기를 알게 되어 캡슐화가 무너집니다.

4.4 패턴 C — 지연 로딩

두 표현이 모두 번들에 포함되는 것이 측정으로 문제가 확인된 경우에만 적용합니다. 추측으로 적용하는 것을 금지합니다. 얻는 것은 번들 감소이고 잃는 것은 첫 열기 지연입니다.

5. 어댑터가 필요한 컴포넌트

컴포넌트 pointer touch 패턴 전환 사유
Select · Combobox 앵커 드롭다운 바텀시트 A 작은 항목을 손가락으로 선택할 수 없습니다
Dropdown menu 앵커 팝오버 바텀시트 A 화면 밖으로 밀립니다
Dialog 중앙 모달 풀스크린 또는 바텀시트 B 가상 키보드가 뜨면 중앙 모달이 가려집니다
Date picker 팝오버 캘린더 풀스크린 B 날짜 셀이 최소 터치 타겟보다 작습니다
Context menu 우클릭 롱프레스 → 액션시트 B 우클릭이 존재하지 않습니다
Table 열 그리드 카드 리스트 B 가로 스크롤은 터치에서 조작이 어렵습니다
Navigation 사이드바 하단 탭 또는 드로어 B 엄지 도달 범위
Tooltip 호버 표시 사용하지 않음 호버 자체가 없습니다
Toast 우상단 하단 (노치·홈바 회피) A

주의

툴팁에만 담긴 정보는 터치 기기에서 소실됩니다

적응형이 항상 "다른 것으로 교체"인 것은 아니며, 때로는 "그 UI를 쓰지 않는다"가 답입니다. 호버로만 볼 수 있는 설명에 필수 정보를 담으면 터치 사용자는 그 정보에 도달할 방법이 없습니다. 툴팁은 보조 설명에만 사용하고, 필수 정보는 상시 표시하거나 별도 안내 요소로 제공합니다.

6. 렌더링 제약

정적 생성(RenderMode.Prerender) 경로에서 적응형 컴포넌트 사용을 금지합니다.

빌드 시점에는 포인터를 판정할 수 없어 injectInteractionMode()가 기본값 pointer를 반환합니다. 터치 기기에서 열면 하이드레이션 직후 표현이 교체되며 화면이 깜빡입니다.

공개 경로에는 CSS 미디어 쿼리로 해결되는 반응형만 사용합니다. 인증 화면은 RenderMode.Client이므로 이 제약을 받지 않습니다. 상세는 렌더링 전략에 있습니다.

자동 강제 수단이 없으므로 코드 리뷰에서 확인합니다.

7. 터치 대응 기준

적응형 여부와 무관하게 터치 환경에서 지켜야 하는 값입니다.

항목 기준
최소 터치 타겟 44 × 44 CSS 픽셀. 컨트롤 치수로 옮긴 규격은 디자인 시스템과 토큰 3.7절이며, 시각 치수를 함께 키우는 것이 기본입니다. 밀도가 촘촘해야 하는 자리에 한해 보이는 크기를 유지하고 터치 영역만 확보합니다
인접 타겟 간격 8 CSS 픽셀 이상
호버 전용 동작 금지 호버로만 접근 가능한 기능을 두지 않습니다
바텀시트와 가상 키보드 키보드가 시트를 가리지 않도록 처리합니다. 수단은 8절 확인 대상

8. 테스트

적응형 컴포넌트는 두 모드 각각에 대한 테스트를 갖습니다. 한쪽만 검증하면 다른 쪽은 실기기에서만 발견됩니다.

Vitest에서 BreakpointObserver를 대체 구현으로 주입해 두 경우를 검증합니다. 자동 강제 수단이 없으므로 코드 리뷰에서 확인합니다.

9. 확인이 필요한 항목

항목 확인할 내용 영향
helm의 입력 전달 복사된 helm 컴포넌트가 positionStrategy를 brain으로 전달하는지 전달하지 않으면 helm 사본에 입력을 추가합니다
@defer와 컨텐츠 프로젝션 표현 컴포넌트에 ng-content 전달 시 제약 패턴 C의 적용 범위
가상 키보드 대응 env(keyboard-inset-height)의 지원 범위 바텀시트 입력 화면의 구현 방식

brain의 오버레이 확장점, 캘린더의 키보드와 ARIA 범위, BreakpointObserver의 서버 동작은 확인을 마쳤습니다. 결과는 개발 환경 7절이 원본입니다.

© 2026 dev.goraebap