개발 노트/React Native

[React Native] 통합 앱 프로파일별 Feature Flag 설계 기법 (딥링크, 권한 처리 오류 막기)

pposooj 2026. 5. 20. 14:14

하나의 코드 베이스로 여러 프로파일의 앱을 묶어서 운영하다 보면, 앱마다 기능을 다르게 켜고 꺼야 할 때가 많습니다. 어떤 앱은 지도가 들어가고, 어떤 앱은 단순 공지사항 알림판 역할만 하기도 하죠.

처음에는 단순히 화면에서 버튼 하나 숨기면 끝날 줄 알았는데, 이래저래 운영하다 보니 UI만 막는다고 해결될 일이 아니었습니다. 메뉴에서는 숨겼는데 딥링크로 뚫고 들어오거나, 쓰지도 않는 위치 권한 팝업이 갑자기 뜨는 등 신경 쓸 게 한두 가지가 아니더군요.

나중에 제가 또 까먹지 않으려고, 실무에서 놓치기 쉬운 통합 앱 Feature Flag 설계 기준을 코드로 정리해 둡니다.

UI만 숨기면 터지는 문제들

Feature Flag 기준이 흐트러지면 운영 중에 아래와 같은 귀찮은 일들이 터집니다.

  • 메뉴에서는 숨겼는데, 딥링크 주소를 치거나 푸시 알림을 누르면 해당 화면이 그냥 열림
  • 지도 기능이 꺼져 있는 프로파일인데, 앱 켜지자마자 위치 권한 요청 팝업이 뜸
  • 알림 기능이 없는 앱인데 백그라운드에서 푸시 토큰 등록 코드가 계속 실행됨
  • 설정 화면은 보이지만, 특정 프로파일에 매칭되는 개인정보 처리방침 링크가 비어 있음
  • 화면마다 showMap, useMap, enableMap 처럼 이름을 제각각 만들어서 나중에 찾기 힘듦
  • 클라이언트 Feature Flag만 믿고 백엔드 API 보안 검증을 놓침

단순히 "빨리 처리해야지" 하고 화면마다 야금야금 flag를 추가하다 보면 프로파일이 늘어났을 때 감당이 안 됩니다. 처음부터 구조를 잡고 가는 편이 무조건 이득입니다.

1. 기본 구조 잡기: Type 고정과 Default 값 세팅

가장 먼저 할 일은 사용할 기능의 키(Key)를 고정하고 기본값을 false로 지정하는 것입니다. 새 프로파일을 추가할 때 누락하더라도 undefined 에러가 나지 않도록 방어 코드를 짜야 합니다.

TypeScript
 
type FeatureKey =
  | 'map'
  | 'search'
  | 'push'
  | 'favorite'
  | 'externalLinks'
  | 'settings';

type FeatureFlags = Record<FeatureKey, boolean>;

type AppProfile = {
  profileId: string;
  displayName: string;
  features: FeatureFlags;
  enabledRoutes: string[];
};

// 모든 기능의 기본값은 무조건 false로 닫아둔다
const defaultFeatures: FeatureFlags = {
  map: false,
  search: false,
  push: false,
  favorite: false,
  externalLinks: false,
  settings: true, // 설정은 기본 오픈
};

// 프로파일별로 필요한 기능만 덮어쓰기
const profileRegistry: Record<string, AppProfile> = {
  sampleTour: {
    profileId: 'sampleTour',
    displayName: '샘플 관광 앱',
    features: {
      ...defaultFeatures,
      map: true,
      search: true,
      favorite: true,
      externalLinks: true,
    },
    enabledRoutes: ['Home', 'Map', 'Detail', 'Settings'],
  },
  sampleNotice: {
    profileId: 'sampleNotice',
    displayName: '샘플 공지 앱',
    features: {
      ...defaultFeatures,
      push: true,
    },
    enabledRoutes: ['Home', 'Notice', 'Settings'],
  },
};

// 기능 활성화 여부 체크 헬퍼 함수
function hasFeature(profile: AppProfile, feature: FeatureKey): boolean {
  return profile.features[feature] === true;
}

💡 작성 시 팁

  • 긍정형 이름 사용: disableMap 같은 부정형을 쓰면 조건문 읽을 때 !disableMap 처럼 꼬여서 헷갈립니다. 그냥 map: true/false가 직관적입니다.
  • 잘게 쪼개기: tourMode 같은 큰 덩어리 하나로 지도, 검색을 다 켜지 마세요. 나중에 "관광 앱인데 지도만 빼주세요" 하면 코드 다 뜯어내야 합니다. 기능 단위로 쪼개는 게 맞습니다.

2. UI 메뉴와 라우트 가드(Route Guard) 연동

메뉴판에서 치웠다고 손님이 안 들어오는 게 아닙니다. 딥링크나 푸시 링크로 우회해서 들어오는 라우트(Route) 진입로 자체를 막아야 합니다.

TypeScript
 
type MenuItem = {
  label: string;
  routeName: string;
  requiredFeature?: FeatureKey;
};

// 1. 메뉴 정의
const menuItems: MenuItem[] = [
  {label: '홈', routeName: 'Home'},
  {label: '지도', routeName: 'Map', requiredFeature: 'map'},
  {label: '검색', routeName: 'Search', requiredFeature: 'search'},
  {label: '설정', routeName: 'Settings', requiredFeature: 'settings'},
];

// 프로파일별 노출할 메뉴 필터링
function getVisibleMenuItems(profile: AppProfile): MenuItem[] {
  return menuItems.filter((item) => {
    if (!item.requiredFeature) return true;
    return hasFeature(profile, item.requiredFeature);
  });
}

// 2. 라우트 가드 정의 (딥링크, 푸시 진입 시에도 재사용)
const routeFeatureMap: Record<string, FeatureKey | undefined> = {
  Home: undefined,
  Map: 'map',
  Search: 'search',
  Settings: 'settings',
};

function canAccessRoute(profile: AppProfile, routeName: string): boolean {
  // 프로파일 허용 라우트에 없으면 차단
  if (!profile.enabledRoutes.includes(routeName)) {
    return false;
  }

  const requiredFeature = routeFeatureMap[routeName];
  if (!requiredFeature) {
    return true;
  }

  return hasFeature(profile, requiredFeature);
}

네비게이션 컨테이너나 딥링크 오픈 로직 초입에서 canAccessRoute()를 태우면 화면 뚫리는 문제를 깔끔하게 예방할 수 있습니다.

3. 권한 요청(Permission)과 Feature Flag 연결

지도 기능이 꺼진 프로파일인데 앱이 켜지자마자 위치 권한을 달라고 하면 사용자는 황당합니다. 권한 요청 로직도 Flag 상태를 따라가야 안전합니다.

TypeScript
 
type PermissionName = 'location' | 'notification';

function getRequiredPermissions(profile: AppProfile): PermissionName[] {
  const permissions: PermissionName[] = [];

  if (hasFeature(profile, 'map')) {
    permissions.push('location');
  }

  if (hasFeature(profile, 'push')) {
    permissions.push('notification');
  }

  return permissions;
}

단, 권한 요청 코드가 제공된다고 해서 앱 시작하자마자 다 띄우는 건 피하는 게 좋습니다. 지도는 지도 탭을 눌렀을 때, 알림은 알림 설정을 켤 때처럼 사용자가 납득할 수 있는 맥락(Context)에서 띄워야 이탈률이 적습니다.

⚠️ 주의: 클라이언트 Flag는 보안 기능이 아닙니다

가장 흔하게 하는 착각이 앱에서 화면을 숨겼으니 안전하다고 생각하는 것입니다. Feature Flag는 화면 제어용일 뿐, 서버 보안을 대체할 수 없습니다.

구분역할예시
Feature Flag 앱에서 기능을 보이게 할지 단순 결정 지도 메뉴 표시 여부 결정
Route Guard 진입 경로(딥링크 등) 차단 Map 라우트 접근 시 Fallback 처리
Permission Flow 단말기 권한 요청 시점 제어 실제 지도 진입 시 위치 권한 팝업
Server Auth 실제 데이터 접근 권한 최종 검증 API 호출 시 토큰 및 권한 유효성 체크
 

앱에서 map: false로 처리했더라도, 악의적인 사용자가 API 엔드포인트를 직접 찌르면 데이터가 털릴 수 있습니다. 중요 데이터는 백엔드 서버에서 세션과 권한 검증을 무조건 다시 해야 합니다.

배포 전 실무 체크리스트 (테스트 범위)

프로파일이 늘어나면 손으로 다 눌러보기 힘듭니다. 아래 항목들을 기준으로 핵심 헬퍼 함수(hasFeature, canAccessRoute)의 단위 테스트라도 짜두는 것을 추천합니다.

  • [ ] 모든 프로파일이 새롭게 추가된 FeatureKey의 기본값을 누락 없이 가지고 있는가?
  • [ ] flag가 false인 기능의 메뉴가 UI에서 제대로 숨겨지는가?
  • [ ] flag가 false인 화면으로 딥링크 진입 시 메인이나 대체 화면으로 튕겨내는가?
  • [ ] 사용하지 않는 기능의 단말기 권한(위치, 알림 등) 요청 코드가 격리되었는가?
  • [ ] push flag가 false일 때 알림 백그라운드 토큰 등록 로직이 실행되지 않는가?
  • [ ] 새로 추가한 flag 이름이 기존 이름과 의미가 겹치거나 헷갈리지 않는가?
  • [ ] 클라이언트 flag와 별개로 서버 API단 보안 검증이 완결되었는가?

마무리

React Native 통합 앱 구조에서 Feature Flag는 단순히 버튼 숨기기용 토글 스위치가 아닙니다. UI, 라우팅, 권한, 알림, 딥링크까지 다 엮여 있는 앱의 중앙 정책 컨트롤러로 접근해야 나중에 고생을 안 합니다.

조건문 분기 처리가 여기저기 흩어져서 코드가 지저분해지기 전에, 헬퍼 함수 몇 개로 깔끔하게 기준을 세워두면 프로파일이 10개, 20개로 늘어나도 든든합니다.