posts

React Native + Unistyles v3 스타일링 컨벤션

Sep 24, 2025 updated Sep 24, 2025 architectureexporeact-nativestyling

목적: 가독성, 일관성, 성능(무리렌더 업데이트), 확장성을 보장하는 팀 표준.


0) 핵심 원칙 (요약)

  • 점 표기만 사용: styles.ui_text_root ✅ / styles["ui-text-root"]
  • 언더스코어 스네이크 케이스 키: ui_text_root, w_header_left
  • 파트 단위 쌍 구조: 앵커(객체) *_root ↔ 동적(함수) *_root_dyn
  • 의미/토글 = variants, 연속/런타임 = dynamic
  • style 배열 우선순위: 앵커 → 동적 → 사용자 오버라이드

1) 키/접근 규칙

  • 키 네이밍: snake_case + 접두어

    • 도메인 접두어: ui_(디자인 시스템/원자), w_(widget), scr_(screen), tpl_(template)
    • 로컬 접두어(컴포넌트 단위): 예) w_header_*, ui_text_*
    • 파트 명: _root, _left, _center, _right, _title, _subtitle, _caption, _action
    • 동적 함수 접미어: *_dyn
  • 접근 방식: 항상 점 표기만 사용해 Unistyles 바인딩을 보장.


2) 컴포넌트 프롭 표준

  • variants: 닫힌 집합(열거형) — 의미/의도/상태/사이즈 등
  • dynamic: 연속·런타임 값 — 측정/제스처/실시간/미세 보정
  • 내부에서 기본값을 초기화·병합 후 styles.useVariants로 적용
// 내부 병합 예시
const init = { tone: 'base', size: 'md' } as const;
styles.useVariants({ ...init, ...variants });

3) Variants vs Dynamic — 선택 기준

룰 오브 썸

  • 토글/열거형(의도, 톤, 상태, 사이즈) → variants
  • 연속/계산/실시간 값(opacity, lineHeight, radius, width/height, 측정치) → dynamic
  • 여러 속성이 함께 토글 → variants (+ 필요 시 compound variants)
  • 조합 폭발 기미가 보이면 → 핵심 축만 variants, 나머지는 dynamic으로 분리
  • 디자인 토큰 강제 필요 → variants 우선

안티패턴

  • 불리언 프롭 다발(isTitle, isMuted…) → 단일 variants로 수렴(tone)
  • 연속값을 억지 열거(opacity_10, opacity_20…) → dynamic 사용

4) 스타일 시트/컴포넌트 템플릿

4.1 텍스트 (UiText)

// ui-text.style.ts
import { StyleSheet } from 'react-native-unistyles';

export type UiTextVariants = {
  tone?: 'base'|'title'|'muted'|'subtitle'|'caption';
  size?: 'sm'|'md'|'lg';
};

export type UiTextRootDynamic = {
  opacity?: number;        // 0~1
  letterSpacing?: number;  // px
  lineHeight?: number;     // px
};

const styles = StyleSheet.create(theme => ({
  // 앵커(객체): variants를 선언적으로 모은다
  ui_text_root: {
    color: theme.colors.text,
    fontFamily: 'Pretendard',
    fontSize: theme.fontSizes.md,

    variants: {
      tone: {
        base: {},
        title: { fontWeight: '700' },
        muted: { color: theme.colors.muted },
        subtitle: { color: theme.colors.text },
        caption: { color: theme.colors.muted },
      },
      size: {
        sm: { fontSize: theme.fontSizes.sm },
        md: { fontSize: theme.fontSizes.md },
        lg: { fontSize: theme.fontSizes.lg },
      },
      compoundVariants: [
        { tone: 'title', size: 'lg', style: { letterSpacing: 0.2 } },
      ],
    },
  },

  // 동일 파트 전용 동적 함수(연속값만 다룸)
  ui_text_root_dyn: (d?: UiTextRootDynamic) => {
    const o = d ?? {};
    const opacity = typeof o.opacity === 'number' ? Math.max(0, Math.min(1, o.opacity)) : undefined;
    return { opacity, letterSpacing: o.letterSpacing, lineHeight: o.lineHeight };
  },
}));

export default styles;
// UiText.tsx
import type { ComponentProps } from 'react';
import { Text } from 'react-native';
import styles, { type UiTextVariants, type UiTextRootDynamic } from './ui-text.style';

export interface UiTextProps extends ComponentProps<typeof Text> {
  variants?: UiTextVariants;   // 의미/토글
  dynamic?: UiTextRootDynamic; // 연속/런타임
}

const UiText = ({ variants, dynamic, ...rest }: UiTextProps) => {
  const init: UiTextVariants = { tone: 'base', size: 'md' };
  styles.useVariants({ ...init, ...variants });

  return (
    <Text
      style={[
        styles.ui_text_root,           // 앵커(variants 적용 지점)
        styles.ui_text_root_dyn(dynamic), // 연속값 보정
        rest.style,                    // 사용자 오버라이드
      ]}
      {...rest}
    />
  );
};

export default UiText;

4.2 헤더 (WidgetHeader) - 파트별 _dyn 예시

// widget-header.style.ts
import { StyleSheet } from 'react-native-unistyles';

const styles = StyleSheet.create(theme => ({
  w_header_root: {
    flexDirection: 'row', alignItems: 'center', minHeight: 56,
    variants: {
      size: {
        sm: { minHeight: 48, paddingHorizontal: 12 },
        md: { minHeight: 56, paddingHorizontal: 16 },
        lg: { minHeight: 64, paddingHorizontal: 20 },
      },
    },
  },
  w_header_root_dyn: ({ insetTop }: { insetTop?: number } = {}) => ({ paddingTop: insetTop }),

  w_header_left:  { width: 56, justifyContent: 'center' },
  w_header_left_dyn:  ({ opacity }: { opacity?: number } = {}) => ({ opacity }),

  w_header_center:{ flex: 1, alignItems: 'center', justifyContent: 'center' },
  w_header_right: { width: 56, alignItems: 'flex-end', justifyContent: 'center' },
}));

export default styles;
// 사용 예
<View
  style={[
    styles.w_header_root,
    styles.w_header_root_dyn({ insetTop }),
  ]}
>
  <View style={[styles.w_header_left, styles.w_header_left_dyn({ opacity: 0.7 })]} />
  <View style={styles.w_header_center} />
  <View style={styles.w_header_right} />
</View>

5) style 배열 우선순위 (중요)

  1. 앵커 styles.<part>_root (variants 적용·바인딩 앵커)
  2. 동적 styles.<part>_root_dyn(dynamic) (연속값 보정)
  3. 사용자 오버라이드 props.style

규칙(필수): style은 분리해서 마지막 배열에 합치기 → [앵커, 동적, style]

예: <Text style={[styles.ui_text_root, styles.ui_text_root_dyn(dynamic), style]} />

컴포넌트별 패턴

  • View/Text/Image 등(비-Pressable)

    const { style, ...rest } = props;
    return (
      <View {...rest}
        style={[styles.ui_box_root, styles.ui_box_root_dyn(dynamic), style]}
      />
    );
    
  • Pressable — 상태 콜백 내에서 사용자 style이 함수면 호출해 병합

    const { style, ...rest } = props;
    return (
      <Pressable {...rest}
        style={(state) => [
          styles.ui_button_root,
          styles.ui_button_root_dyn(dynamic),
          styles.ui_button_root_state(state),
          typeof style === 'function' ? style(state) : style,
        ]}
      />
    );
    

안티패턴

  • init/merged 단계에서 style을 미리 넣고 ...rest로 얕은 병합 → 호출자 style이 통째로 덮어씀
  • style={[...]} {...rest} (스프레드가 ) → rest.style앞의 배열을 덮어씀

6) FAQ

Q. 대괄호 표기도 되는데 왜 점 표기만?

  • 점 표기는 Unistyles의 정적 바인딩(테마/브레이크포인트/variants 업데이트)을 확실히 연결한다. 대괄호는 계산형 접근으로 취급되어 최적화/분석이 스킵될 수 있어 리스크.

Q. dynamic을 root 외 다른 파트에 적용하고 싶다?

  • 각 파트에 대응하는 _dyn을 만들고, 같은 노드의 style 배열에서 그 파트의 앵커와 함께 사용.

Q. 공용 *_dyn을 써도 되나?

  • 전 파트 공통·안전 속성(opacity 단일 등)만 제한적으로. 권장은 파트 전용 _dyn.

7) PR/리뷰 체크리스트

  • 키는 snake_case이며 점 표기만 사용
  • 최소 1개 **앵커(객체)**가 style 배열에 포함되어 바인딩 확보
  • 의미/토글은 variants, 연속/런타임은 dynamic
  • 조합 폭발 징후 없음(과도한 compound variants 금지)
  • dynamic에서 토큰 성격 속성(색/폰트사이즈 등)을 임의로 덮지 않음
  • 연속값 클램핑/검증(예: opacity 0~1)
  • style 배열 순서: 앵커 → 동적 → 사용자 오버라이드

8) 마이그레이션 가이드

  • 하이픈 키 → 언더스코어로 변경: "w-header-root"w_header_root

  • 대괄호 접근 제거, 점 표기 앵커 확보

  • 불리언 프롭 다발 → 단일 variants 축으로 통합(tone, size 등)

  • 연속값 열거 제거 → 해당 파트의 *_dyn으로 이전

  • 임시 별칭으로 점진적 이전 가능:

    // deprecate 예정
    ui_text_dyn: (...args) => styles.ui_text_root_dyn(...args),
    

9) 확장 팁

  • DX 향상: tone/size를 최상위 프롭으로 받고 내부에서 variants로 래핑
  • 필요 시 *_dyn을 도메인별로 쪼개기: *_dynTypography, *_dynLayout (과분화 금지)
  • 팀 린트 규칙(권장): no-restricted-syntaxstyles["..."] 사용 금지, key-spacing으로 snake_case 강제 등

10) 예시 요약 (Good/Bad)

Good

<Text style={[styles.ui_text_root, styles.ui_text_root_dyn({ opacity: 0.8 }), props.style]} />

Bad

<Text style={[styles["ui-text-root"], props.style]} />              // 대괄호 전용
<Text style={[styles.ui_text_root, styles.ui_text_root, props.style]} /> // 중복 앵커
<Text style={[{ fontSize: 13 }, props.style]} />                      // 앵커 누락 → 바인딩 없음