React Native 반응형 디자인 시스템: iPhone SE부터 폴더블까지
iPhone 12 Pro를 기준으로 잡고, iPhone SE와 폴더블 디바이스까지 대응하는 반응형 유틸리티를 만들면서 정리한 내용을 공유합니다.
문제
디자이너가 iPhone 12 Pro(390x844) 기준으로 피그마를 넘겨줍니다. 그대로 숫자를 넣으면 iPhone 12 Pro에서는 완벽하지만, iPhone SE에서는 레이아웃이 깨지고 Galaxy Z Fold에서는 어색하게 보입니다.
iPhone에서 맞춰놓은 레이아웃을 안드로이드에서 확인했더니 비율이 달라서 요소들이 어긋나 있었습니다. 아이폰 기준으로 하드코딩한 수치가 안드로이드에서는 전혀 다르게 보였고, 기기마다 일일이 조정하는 건 불가능하다고 판단해서 비율 기반 유틸리티를 만들게 됐습니다.
기본 아이디어
디자인 기준 크기 대비 실제 기기 크기의 비율로 값을 보정합니다.
보정된 값 = (디자인 값 / 기준 크기) × 실제 기기 크기
예를 들어 디자인에서 너비 200인 요소가 있으면:
- iPhone 12 Pro (390): 200 / 390 × 390 = 200 (그대로)
- iPhone SE (375): 200 / 375 × 375 = 192 (약간 축소)
- Galaxy Z Fold 펼침 (502): 200 / 502 × 502 = 200 (기준이 달라짐)
기준 화면 크기 정의
import { Dimensions, PixelRatio } from "react-native";
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// 기준 화면 크기 (iPhone 12 Pro)
const BASE_WIDTH = 390;
const BASE_HEIGHT = 844;
// iPhone SE
const SE_WIDTH = 375;
const SE_HEIGHT = 667;
// 폴더블 디바이스
const FOLD_BASE_WIDTH = 411;
const FOLD_BASE_HEIGHT = 731;
const FOLD_EXPAND_WIDTH = 502;
const FOLD_EXPAND_HEIGHT = 550;
기기마다 기준 크기가 다릅니다. iPhone SE는 화면이 작으니 기준도 작게, 폴더블은 접은 상태와 펼친 상태가 다르니 두 가지 기준을 둡니다.
디바이스 감지
// iPhone SE 감지: 정확한 해상도 매칭
const isSE = screenWidth === SE_WIDTH && screenHeight === SE_HEIGHT;
// 폴더블 감지: 화면 비율로 판단
const aspectRatio = screenHeight / screenWidth;
const isFoldable = aspectRatio < 1.9;
폴더블 감지에 화면 비율을 사용한 이유가 있습니다. 처음에는 디바이스 모델명으로 감지하려고 했는데, Galaxy Z Fold 시리즈만 해도 모델 번호가 SM-F900, SM-F916, SM-F926... 계속 늘어납니다. 새 기기가 나올 때마다 목록을 업데이트하는 건 현실적이지 않았습니다.
일반 스마트폰은 세로가 가로의 2배 이상(비율 2.0 이상)인데, 폴더블을 펼치면 비율이 1.0~1.4 정도로 떨어집니다. aspectRatio < 1.9로 잡으면 대부분의 폴더블을 잡아낼 수 있었습니다.
normalize 함수
const normalize = (size: number, based: "width" | "height" = "width") => {
let baseSize = based === "height" ? BASE_HEIGHT : BASE_WIDTH;
let deviceSize = based === "height" ? screenHeight : screenWidth;
// iPhone SE: 기준 크기를 SE로 바꾸고 95%로 축소
if (isSE) {
baseSize = based === "height" ? SE_HEIGHT : SE_WIDTH;
size = size * 0.95;
}
// 폴더블: 접힌 상태와 펼친 상태 구분
if (isFoldable) {
if (aspectRatio < 1.4) {
// 펼친 상태
baseSize = based === "height" ? FOLD_EXPAND_HEIGHT : FOLD_EXPAND_WIDTH;
} else {
// 접힌 상태
baseSize = based === "height" ? FOLD_BASE_HEIGHT : FOLD_BASE_WIDTH;
}
}
const newSize = (size / baseSize) * deviceSize;
return Math.round(PixelRatio.roundToNearestPixel(newSize));
};
iPhone SE에서 95%를 곱하는 이유는 단순 비율 계산만으로는 SE의 작은 화면에서 요소들이 너무 빽빽해 보이기 때문입니다. 5%를 추가로 줄여서 여유를 만들었습니다.
PixelRatio.roundToNearestPixel()은 계산 결과를 디바이스의 물리적 픽셀에 맞춰 반올림합니다. 이걸 안 하면 0.5px 같은 값이 나와서 텍스트나 테두리가 흐릿하게 렌더링될 수 있습니다.
외부에 노출하는 API
export const widthPercentage = (width: number) => {
return normalize(width);
};
export const heightPercentage = (height: number) => {
return normalize(height, "height");
};
export const fontPercentage = (size: number) => {
return normalize(size);
};
사용하는 쪽에서는 피그마에 적힌 숫자를 그대로 넣으면 됩니다.
<View style={{
width: widthPercentage(300),
height: heightPercentage(100),
borderRadius: widthPercentage(12),
}}>
<Text style={{ fontSize: fontPercentage(16) }}>
상품명
</Text>
</View>
언제 쓰고 언제 안 쓰는가
모든 곳에 widthPercentage를 넣을 필요는 없습니다.
쓰는 경우:
- 카드, 버튼 등 고정 크기 요소
- 폰트 사이즈
- 아이콘 크기
- border radius
안 쓰는 경우:
- 전체 화면 양쪽 패딩 (16px 같은 작은 고정값)
- flex 기반 레이아웃 (비율로 자동 조절)
- 이미
%단위로 처리되는 값
처음에는 모든 수치에 widthPercentage를 넣었는데, 오히려 flex 레이아웃과 충돌하는 경우가 있었습니다. 부모가 flex로 비율을 잡고 있는데 자식에 고정 보정값을 넣으면 의도와 다르게 동작합니다. 결국 flex가 처리할 수 있는 건 flex에 맡기고, 카드 크기나 폰트처럼 정말 고정이 필요한 곳에만 쓰는 게 맞았습니다.
한계
Dimensions.get("window")는 앱 시작 시점의 값을 캐싱합니다. 폴더블에서 접기/펼치기를 하면 값이 바뀌는데, 이걸 실시간으로 반영하려면Dimensions.addEventListener("change")로 처리해야 합니다.- 태블릿은 별도 대응이 필요합니다. 비율 계산만으로는 10인치 화면에서 요소가 너무 커질 수 있습니다.
결국 완벽한 반응형은 없고, 지원할 기기 범위를 정하고 그 안에서 최선을 찾는 게 현실적입니다.