CSS Safe Area Insets

이런 경험 있으신가요?

여러분도 이런 상황을 겪어본 적 있나요? 완벽하게 만든 모바일 웹사이트를 iPhone에서 열었는데, 헤더가 노치에 가려지거나 하단 버튼이 홈 인디케이터와 겹쳐서 클릭이 안 되는 경우.

저도 최근에 정확히 이 문제를 만났습니다. PWA(Progressive Web App)를 전체 화면 모드로 실행했는데, 고정된 헤더의 뒤로가기 버튼이 노치 영역에 완전히 숨어버렸어요. 사용자가 버튼을 아예 볼 수도 누를 수도 없는 상황이었습니다.

바로 그때 CSS Safe Area Insets가 등장합니다.

Safe Area란 무엇인가요?

Safe Area는 기기의 물리적 제약(노치, 둥근 모서리, 홈 인디케이터)을 피해서 콘텐츠를 안전하게 표시할 수 있는 영역입니다.

왜 필요한가요?

iPhone X 이후로 스마트폰 디자인이 변했습니다.

전통적인 디스플레이 (iPhone 8)        노치가 있는 디스플레이 (iPhone X+)
┌────────────────────┐                ┌────────────────────┐
│   Status Bar       │                │ ╔══════╗  Notch    │
├────────────────────┤                │ ║      ║            │
│                    │                ├─╚══════╝────────────┤
│                    │                │                    │
│    Content Area    │                │    Content Area    │
│    (전체 사용 가능)  │                │    (일부 제한됨)    │
│                    │                │                    │
│                    │                │                    │
├────────────────────┤                ├────────────────────┤
│   Home Button      │                │ ═══ Home Indicator │
└────────────────────┘                └────────────────────┘

문제점:

  • 노치: 상단 중앙이 잘림
  • 둥근 모서리: 모서리 콘텐츠가 보이지 않음
  • 홈 인디케이터: 하단 버튼과 겹침
  • 전체 화면 모드: 문제가 더 심각해짐
/* ❌ 옛날 방식: 고정된 패딩 */
.header {
  position: fixed;
  top: 0;
  padding-top: 20px; /* iPhone 8에서는 OK, iPhone X에서는 노치에 가림 */
}

.footer {
  position: fixed;
  bottom: 0;
  padding-bottom: 20px; /* 홈 인디케이터와 겹칠 수 있음 */
}
/* ✅ 현대적 방식: Safe Area Insets */
.header {
  position: fixed;
  top: 0;
  padding-top: env(safe-area-inset-top); /* 기기에 맞게 자동 조정 */
}

.footer {
  position: fixed;
  bottom: 0;
  padding-bottom: env(safe-area-inset-bottom); /* 홈 인디케이터 피함 */
}

장점:

  • 자동 적응: 기기마다 다른 safe area에 자동 대응
  • 미래 지향적: 새로운 기기에도 자동 적용
  • PWA 필수: 전체 화면 모드에서 필수
  • 범용성: iOS뿐 아니라 Android 폴더블에도 사용

기본 사용법

1단계: viewport-fit 설정

먼저 meta 태그를 추가해야 합니다.

<!-- ❌ 기본 viewport (safe area 미사용) -->
<meta name="viewport" content="width=device-width, initial-scale=1">

<!-- ✅ viewport-fit=cover (safe area 사용) -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

viewport-fit 옵션:

  • auto (기본): 전체 뷰포트가 safe area 내부에 위치
  • contain: auto와 동일
  • cover: 전체 화면을 사용하되, safe area insets 제공

2단계: env() 함수로 insets 사용

CSS에서 env() 함수로 safe area insets를 가져옵니다.

.header {
  padding-top: env(safe-area-inset-top);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

.footer {
  padding-bottom: env(safe-area-inset-bottom);
}

4가지 insets:

  • safe-area-inset-top: 상단 (노치 영역)
  • safe-area-inset-right: 오른쪽 (가로 모드 노치)
  • safe-area-inset-bottom: 하단 (홈 인디케이터)
  • safe-area-inset-left: 왼쪽 (가로 모드 노치)

3단계: Fallback 값 제공

구형 브라우저를 위한 fallback:

.header {
  /* fallback: env()를 지원하지 않는 브라우저용 */
  padding-top: 20px;

  /* 지원하는 브라우저는 이 값 사용 */
  padding-top: env(safe-area-inset-top);
}

/* 또는 max() 사용 */
.header {
  /* 최소 20px, safe area가 더 크면 그 값 사용 */
  padding-top: max(20px, env(safe-area-inset-top));
}

실전 예제

예제 1: 고정 헤더 (Fixed Header)

시나리오: 상단 고정 헤더가 노치에 가려지지 않게

.header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 60px;

  /* 기본 패딩 */
  padding: 10px 20px;

  /* Safe area 적용 */
  padding-top: max(10px, env(safe-area-inset-top));
  padding-left: max(20px, env(safe-area-inset-left));
  padding-right: max(20px, env(safe-area-inset-right));

  background: white;
  z-index: 1000;
}

/* 헤더 높이만큼 본문 여백 */
.main-content {
  /* 헤더 높이 + safe area */
  margin-top: calc(60px + env(safe-area-inset-top));
}

핵심 포인트:

  • max()로 최소 패딩 보장
  • 본문은 헤더 높이 + safe area 고려
  • 가로 모드도 대응 (left, right)

예제 2: 고정 하단 버튼 (Fixed Bottom Button)

시나리오: “구매하기” 버튼이 홈 인디케이터와 겹치지 않게

.sticky-buy-button {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;

  /* 버튼 자체 패딩 */
  padding: 16px 20px;

  /* Safe area 적용 */
  padding-bottom: max(16px, env(safe-area-inset-bottom));
  padding-left: max(20px, env(safe-area-inset-left));
  padding-right: max(20px, env(safe-area-inset-right));

  background: #007aff;
  color: white;
  font-size: 18px;
  font-weight: bold;
  text-align: center;
  border: none;
}

/* 버튼 높이만큼 본문 여백 */
.content {
  /* 버튼 영역 확보 */
  padding-bottom: calc(52px + env(safe-area-inset-bottom));
}

실제 결과:

기기 safe-area-inset-bottom 최종 padding-bottom
iPhone 8 0px 16px (기본값)
iPhone 14 (세로) 34px 34px
iPhone 14 (가로) 21px 21px

예제 3: 전체 화면 배경 (Full-Screen Background)

시나리오: 배경은 전체 화면, 콘텐츠는 safe area 내부

.full-screen-container {
  /* 전체 화면 사용 */
  min-height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

  /* 콘텐츠는 safe area 내부 */
  padding: env(safe-area-inset-top)
           env(safe-area-inset-right)
           env(safe-area-inset-bottom)
           env(safe-area-inset-left);
}

/* 또는 shorthand */
.full-screen-container {
  min-height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

  /* 위 오른쪽 아래 왼쪽 순서 */
  padding: env(safe-area-inset-top)
           env(safe-area-inset-right)
           env(safe-area-inset-bottom)
           env(safe-area-inset-left);
}

핵심 포인트:

  • 배경은 100vh로 전체 화면
  • 패딩으로 콘텐츠만 safe area 내부
  • 시각적으로 몰입감 유지

예제 4: 하단 탭 네비게이션 (Bottom Tab Navigation)

시나리오: 모바일 앱 스타일 하단 탭

.bottom-nav {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: space-around;
  background: white;
  border-top: 1px solid #e0e0e0;

  /* Safe area 적용 */
  padding-bottom: env(safe-area-inset-bottom);
}

.bottom-nav__item {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 8px 0;
  text-decoration: none;
  color: #666;
}

.bottom-nav__item--active {
  color: #007aff;
}

.bottom-nav__icon {
  width: 24px;
  height: 24px;
  margin-bottom: 4px;
}

.bottom-nav__label {
  font-size: 10px;
}

/* 본문 여백 */
.page-content {
  /* 탭 높이 + safe area */
  padding-bottom: calc(56px + env(safe-area-inset-bottom));
}

HTML:

<nav class="bottom-nav">
  <a href="/" class="bottom-nav__item bottom-nav__item--active">
    <svg class="bottom-nav__icon">...</svg>
    <span class="bottom-nav__label"></span>
  </a>
  <a href="/search" class="bottom-nav__item">
    <svg class="bottom-nav__icon">...</svg>
    <span class="bottom-nav__label">검색</span>
  </a>
  <a href="/profile" class="bottom-nav__item">
    <svg class="bottom-nav__icon">...</svg>
    <span class="bottom-nav__label">프로필</span>
  </a>
</nav>

예제 5: PWA 전체 화면 모드

시나리오: PWA manifest에서 standalone 모드 사용

// manifest.json
{
  "name": "My PWA",
  "short_name": "PWA",
  "display": "standalone",  // 전체 화면 모드
  "start_url": "/",
  "background_color": "#ffffff",
  "theme_color": "#007aff"
}
/* 전체 화면 PWA용 레이아웃 */
:root {
  /* CSS 변수로 관리 */
  --safe-top: env(safe-area-inset-top);
  --safe-right: env(safe-area-inset-right);
  --safe-bottom: env(safe-area-inset-bottom);
  --safe-left: env(safe-area-inset-left);
}

.pwa-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 56px;

  /* 상태바 영역 피하기 */
  padding-top: var(--safe-top);
  padding-left: var(--safe-left);
  padding-right: var(--safe-right);

  background: var(--theme-color);
  color: white;
  z-index: 100;
}

.pwa-content {
  /* 헤더 + safe area 고려 */
  padding-top: calc(56px + var(--safe-top));
  padding-left: var(--safe-left);
  padding-right: var(--safe-right);

  /* 하단 탭 + safe area 고려 */
  padding-bottom: calc(60px + var(--safe-bottom));

  min-height: 100vh;
}

.pwa-bottom-nav {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding-bottom: var(--safe-bottom);
  background: white;
}

핵심 포인트:

  • CSS 변수로 재사용성 향상
  • 모든 영역에 일관되게 적용
  • calc()로 복잡한 계산 처리

고급 기법

1. calc()와 함께 사용

.element {
  /* safe area + 추가 여백 */
  padding-top: calc(env(safe-area-inset-top) + 20px);

  /* safe area + 기존 패딩 */
  padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
}

2. CSS 변수로 관리

:root {
  /* 기본값과 함께 정의 */
  --inset-top: env(safe-area-inset-top, 0px);
  --inset-bottom: env(safe-area-inset-bottom, 0px);

  /* 디자인 시스템과 통합 */
  --spacing-top: calc(var(--inset-top) + var(--header-padding));
}

.header {
  padding-top: var(--spacing-top);
}

3. max()로 최소값 보장

.element {
  /* 최소 20px, safe area가 더 크면 그 값 사용 */
  padding-top: max(20px, env(safe-area-inset-top));

  /* 최소 16px 보장 */
  padding-bottom: max(16px, env(safe-area-inset-bottom));
}

4. clamp()로 범위 제한

.element {
  /* 최소 10px, 최대 50px */
  padding-top: clamp(
    10px,
    env(safe-area-inset-top),
    50px
  );
}

5. 조건부 적용 (@supports)

/* env() 지원 여부 확인 */
@supports (padding: env(safe-area-inset-top)) {
  .header {
    padding-top: env(safe-area-inset-top);
  }
}

/* 또는 */
@supports not (padding: env(safe-area-inset-top)) {
  .header {
    padding-top: 20px; /* fallback */
  }
}

가로 모드 대응

가로 모드에서는 노치가 왼쪽이나 오른쪽에 위치합니다.

.container {
  /* 세로 모드: 상단/하단 safe area */
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);

  /* 가로 모드: 좌우 safe area도 필요! */
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

가로 모드 시각화:

iPhone 가로 모드 (노치가 왼쪽)
┌─╔══╗─────────────────────────────────┐
│ ║  ║  <-- safe-area-inset-left       │
│ ╚══╝                                 │
│                                      │
│        Content Area                  │
│                                      │
│ ═══ Home Indicator                   │
└──────────────────────────────────────┘
     safe-area-inset-bottom

실전 활용: 완전한 모바일 레이아웃

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>Safe Area Demo</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    :root {
      /* CSS 변수 정의 */
      --safe-top: env(safe-area-inset-top, 0px);
      --safe-right: env(safe-area-inset-right, 0px);
      --safe-bottom: env(safe-area-inset-bottom, 0px);
      --safe-left: env(safe-area-inset-left, 0px);

      /* 디자인 토큰 */
      --header-height: 56px;
      --bottom-nav-height: 60px;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    }

    /* 고정 헤더 */
    .app-header {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      height: var(--header-height);
      background: #007aff;
      color: white;
      display: flex;
      align-items: center;
      z-index: 100;

      /* Safe area 적용 */
      padding-top: var(--safe-top);
      padding-left: max(16px, var(--safe-left));
      padding-right: max(16px, var(--safe-right));
    }

    .app-header__title {
      font-size: 18px;
      font-weight: 600;
    }

    /* 본문 */
    .app-content {
      /* 상단: 헤더 + safe area */
      padding-top: calc(var(--header-height) + var(--safe-top));

      /* 좌우: safe area */
      padding-left: max(16px, var(--safe-left));
      padding-right: max(16px, var(--safe-right));

      /* 하단: 탭 네비게이션 + safe area */
      padding-bottom: calc(var(--bottom-nav-height) + var(--safe-bottom));

      min-height: 100vh;
    }

    /* 하단 탭 네비게이션 */
    .bottom-nav {
      position: fixed;
      bottom: 0;
      left: 0;
      right: 0;
      height: var(--bottom-nav-height);
      background: white;
      border-top: 1px solid #e0e0e0;
      display: flex;
      justify-content: space-around;

      /* Safe area 적용 */
      padding-bottom: var(--safe-bottom);
      padding-left: var(--safe-left);
      padding-right: var(--safe-right);
    }

    .bottom-nav__item {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      text-decoration: none;
      color: #666;
      font-size: 10px;
    }

    .bottom-nav__item--active {
      color: #007aff;
    }

    /* 플로팅 버튼 */
    .floating-button {
      position: fixed;
      right: 16px;
      bottom: calc(var(--bottom-nav-height) + var(--safe-bottom) + 16px);
      width: 56px;
      height: 56px;
      border-radius: 50%;
      background: #007aff;
      color: white;
      border: none;
      font-size: 24px;
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
      cursor: pointer;
    }

    /* 디버그 정보 */
    .debug-info {
      position: fixed;
      top: calc(var(--header-height) + var(--safe-top) + 16px);
      left: max(16px, var(--safe-left));
      background: rgba(0, 0, 0, 0.8);
      color: white;
      padding: 12px;
      font-size: 11px;
      border-radius: 8px;
      font-family: monospace;
    }
  </style>
</head>
<body>
  <header class="app-header">
    <h1 class="app-header__title">Safe Area Demo</h1>
  </header>

  <main class="app-content">
    <div class="debug-info">
      <div>Top: <span id="top-value">0px</span></div>
      <div>Right: <span id="right-value">0px</span></div>
      <div>Bottom: <span id="bottom-value">0px</span></div>
      <div>Left: <span id="left-value">0px</span></div>
    </div>

    <!-- 콘텐츠 -->
    <p>Lorem ipsum dolor sit amet...</p>
  </main>

  <nav class="bottom-nav">
    <a href="#" class="bottom-nav__item bottom-nav__item--active">
      <span>🏠</span>
      <span></span>
    </a>
    <a href="#" class="bottom-nav__item">
      <span>🔍</span>
      <span>검색</span>
    </a>
    <a href="#" class="bottom-nav__item">
      <span>👤</span>
      <span>프로필</span>
    </a>
  </nav>

  <button class="floating-button">+</button>

  <script>
    // Safe area 값 표시 (디버그용)
    function updateSafeAreaValues() {
      const root = document.documentElement;
      const computedStyle = getComputedStyle(root);

      const top = computedStyle.getPropertyValue('--safe-top') || '0px';
      const right = computedStyle.getPropertyValue('--safe-right') || '0px';
      const bottom = computedStyle.getPropertyValue('--safe-bottom') || '0px';
      const left = computedStyle.getPropertyValue('--safe-left') || '0px';

      document.getElementById('top-value').textContent = top;
      document.getElementById('right-value').textContent = right;
      document.getElementById('bottom-value').textContent = bottom;
      document.getElementById('left-value').textContent = left;
    }

    updateSafeAreaValues();

    // 화면 회전 시 값 업데이트
    window.addEventListener('resize', updateSafeAreaValues);
    window.addEventListener('orientationchange', updateSafeAreaValues);
  </script>
</body>
</html>

흔한 실수 TOP 5

1. viewport-fit 설정 안 함

<!-- ❌ safe area insets가 0으로 반환됨 -->
<meta name="viewport" content="width=device-width, initial-scale=1">

<!-- ✅ viewport-fit=cover 필수 -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

2. 가로 모드 미대응

/* ❌ 상하만 처리 */
.element {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  /* 가로 모드에서 좌우 노치 문제 발생! */
}

/* ✅ 4방향 모두 처리 */
.element {
  padding: env(safe-area-inset-top)
           env(safe-area-inset-right)
           env(safe-area-inset-bottom)
           env(safe-area-inset-left);
}

3. Fallback 값 누락

/* ❌ 구형 브라우저에서 패딩 없음 */
.element {
  padding-top: env(safe-area-inset-top);
}

/* ✅ Fallback 제공 */
.element {
  padding-top: 20px; /* fallback */
  padding-top: env(safe-area-inset-top);
}

/* ✅ 또는 max() 사용 */
.element {
  padding-top: max(20px, env(safe-area-inset-top));
}

4. 100vh 문제 미해결

/* ❌ 100vh는 safe area 미포함 */
.full-height {
  height: 100vh;
  /* 노치 영역까지 확장됨 */
}

/* ✅ safe area 제외한 높이 */
.full-height {
  height: 100vh;
  height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
}

/* ✅ 또는 패딩으로 처리 */
.full-height {
  min-height: 100vh;
  padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
  box-sizing: border-box;
}

5. CSS 변수 기본값 누락

/* ❌ env() 미지원 시 변수가 invalid */
:root {
  --safe-top: env(safe-area-inset-top);
}

/* ✅ 기본값 제공 */
:root {
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
}

브라우저 지원

브라우저 버전 비고
Safari iOS 11.0+ 최초 도입
Chrome Android 69+  
Samsung Internet 9.2+  
Edge 79+  
Firefox 미지원 (2025년 기준)

중요: Firefox는 현재 미지원이므로 반드시 fallback 제공!

테스트 방법

1. iOS 시뮬레이터

# Xcode 설치 후
open -a Simulator

2. Chrome DevTools

  1. DevTools 열기 (F12)
  2. Device Toolbar 활성화 (Cmd+Shift+M)
  3. iPhone X 이상 선택
  4. “Show device frame” 활성화

3. 실제 기기

  • iPhone X 이상에서 테스트
  • PWA로 설치 후 전체 화면 모드 확인
  • 가로/세로 모드 전환 테스트

실전 체크리스트

Safe Area를 사용할 때 다음을 확인하세요:

  • viewport-fit=cover meta 태그 설정
  • env() fallback 값 제공 (구형 브라우저용)
  • 4방향 모두 처리 (top, right, bottom, left)
  • 가로 모드 대응 확인
  • max()로 최소값 보장
  • PWA 전체 화면 모드 테스트
  • 본문 여백 계산 (헤더/푸터 높이 + safe area)
  • 실제 기기에서 테스트

마무리

CSS Safe Area Insets는 모던 모바일 웹 개발의 필수 기술입니다.

핵심 요약:

  • viewport-fit=cover: 전체 화면 사용
  • env(): safe area insets 가져오기
  • 4방향: top, right, bottom, left 모두 처리
  • Fallback: 구형 브라우저 대응
  • max(): 최소값 보장
  • ⚠️ 주의사항: 가로 모드, 100vh, PWA

다음에 이런 경우를 만나면:

  1. 노치가 콘텐츠 가림 → env(safe-area-inset-top)
  2. 홈 인디케이터와 겹침 → env(safe-area-inset-bottom)
  3. PWA 전체 화면 → 4방향 모두 적용
  4. 가로 모드 문제 → left, right도 처리

여러분도 Safe Area를 적용해보신 적 있나요? 어떤 기기에서 테스트하셨는지 궁금합니다!

Happy coding! 📱✨

참고 자료

댓글