왜 React Native 테마가 자주 망가지는가

React Native 테마 이슈는 단순히 다크/라이트 색상값 문제가 아니라 상태 출처가 여러 개인 데서 시작됩니다. 시스템 테마(Appearance), 로컬 저장값(AsyncStorage), 서버 프로필값이 서로 충돌하면 화면이 앱 활성화 시마다 바뀌는 현상이 생깁니다.

여기에 컴포넌트가 hex를 직접 들고 있으면 신규 테마를 추가해도 일부 화면은 이전 색을 계속 쓰게 됩니다. 결과적으로 테마는 늘어나는데 UI는 일관성을 잃습니다.

권장 구조: 2계층으로 역할을 고정한다

테마를 안정적으로 운영하려면 상태 처리와 색상 매핑을 분리해야 합니다. 파일 수보다 중요한 것은 책임의 경계가 명확한지입니다.

실전에서는 아래 2계층만으로도 대부분의 요구사항을 커버할 수 있습니다.

  • 상태 계층: themeMode(system/mono/light 등), resolvedTheme, 저장/동기화 정책 담당
  • 토큰 계층: ThemeVariant -> semantic palette/preset 매핑 담당
  • 컴포넌트 계층: 오직 semantic token만 소비, literal color 금지

상태 계층 구현 포인트 (useThemeStore)

핵심은 mode와 resolved theme를 분리하는 것입니다. mode는 사용자 의도(system/explicit)이고, resolved theme는 실제 렌더링 대상입니다.

또한 서버 동기화 정책을 먼저 정해야 합니다. UX 안정성을 위해 로컬 우선 정책을 쓰고, 서버와 불일치하면 서버를 로컬값으로 맞추는 방식이 안전합니다.

TS
type ThemeMode = "system" | "mono" | "generic_light" | "deep_space";

// 1) mode -> resolved theme
function resolveTheme(mode: ThemeMode) {
  if (mode !== "system") return mode;
  return Appearance.getColorScheme() === "light" ? "generic_light" : "mono";
}

// 2) optimistic apply -> local persist -> server sync
async function setTheme(nextMode: ThemeMode) {
  applyTheme(resolveTheme(nextMode));
  await AsyncStorage.setItem("user_theme_preference", nextMode);
  await syncThemeToServer(nextMode);
}
TS
// 앱 active 시 서버 동기화는 로컬 우선으로 처리
if (serverTheme !== localThemeMode) {
  await supabase.rpc("update_theme_preference", {
    p_theme: localThemeMode,
  });
}

토큰 계층 구현 포인트 (useThemePalette)

토큰 계층은 semantic 이름 중심이어야 합니다. 예를 들어 `warningIcon`, `pendingWorkoutCardBorder` 같은 의미 이름을 쓰면 색상 자체가 바뀌어도 컴포넌트 코드는 변경되지 않습니다.

실무에서는 feature palette와 screen preset을 함께 두는 것이 유용합니다. 재사용 컴포넌트는 palette를, 화면 특화 UI는 presets를 소비하게 하면 균형이 맞습니다.

TS
type AppThemeVariant = "mono" | "deep_space" | "generic_light";

const paletteMap: Record<AppThemeVariant, ThemePalette> = { ... };
const presetMap: Record<AppThemeVariant, ThemeColorPresets> = { ... };

export function useThemePalette() {
  const themeVariant = resolveThemeVariant(themeMode, resolvedTheme);
  return {
    palette: paletteMap[themeVariant],
    presets: presetMap[themeVariant],
  };
}

컴포넌트 규칙: literal color를 막는다

컴포넌트는 `useThemePalette()`에서 토큰을 읽고, hex/rgba를 직접 쓰지 않는 규칙을 강제해야 합니다.

특히 공통 UI(ActionModal, BottomSheet, HeaderActionButton)에서 literal color를 제거하면 신규 테마 추가 비용이 크게 줄어듭니다.

TS
const { presets } = useThemePalette();

const borderColor = presets.ui.actionModal.primary.border;
const backgroundColor = presets.ui.actionModal.primary.background;
const textColor = presets.ui.actionModal.primary.text;

회귀 방지 체크리스트

구조 정리만으로는 충분하지 않습니다. 배포 직전 자동 검증 루틴이 있어야 테마 회귀를 실제로 막을 수 있습니다.

최소 기준은 inline color 탐지 + 타입체크 + 주요 화면 스모크 테스트입니다.

  • 대상 파일에서 `#[hex]`, `rgba()`, `hsla()` 사용 탐지 스크립트 실행
  • TypeScript noEmit 검사로 매핑 누락/타입 오류 확인
  • 다크/라이트/시스템 전환 시 홈·목록·모달·입력 UI 육안 검증
BASH
cd frontend && npm run lint:theme-colors && npx tsc --noEmit

정리

React Native 테마 관리는 결국 상태 계층과 토큰 계층의 분리, 그리고 컴포넌트 소비 규칙의 일관성 문제입니다.

파일 개수를 줄이는 것보다 더 중요한 것은 책임 경계를 고정하고, 회귀를 자동으로 차단하는 운영 체계를 만드는 것입니다.