본문 바로가기
개발 노트/React Native

[React Native] 통합 앱에서 프로파일별 controller와 model을 분리하는 기준

by pposooj 2026. 5. 20.

React Native 통합 앱에서 여러 프로파일을 하나의 코드베이스로 관리하다 보면 화면은 비슷한데 데이터 구조와 처리 방식이 조금씩 다른 경우가 많습니다. 처음에는 화면 컴포넌트 안에서 if 문으로 처리할 수 있습니다. 하지만 프로파일이 늘어나면 UI 파일이 데이터 변환, API 응답 정리, 화면 상태 판단까지 모두 떠안게 됩니다.

처음에는 화면에서 바로 데이터를 맞추는 방식이 빠르게 느껴질 수 있습니다. 그런데 실제로 정리해보면 통합 앱에서는 화면이 공통 자산이 되기 때문에, 화면 안에 프로파일별 예외가 쌓이는 순간 유지보수가 어려워지는 것 같습니다.

이럴 때 controllermodel을 분리하면 공통 UI의 조건문을 줄일 수 있습니다. model은 화면에서 사용할 데이터 형태를 정의하고, controller는 프로파일별 원본 데이터를 model로 바꾸거나 화면 동작을 연결하는 역할을 맡습니다.

이렇게 나누면 화면 컴포넌트는 어떤 프로파일인지보다 어떤 model을 렌더링할 것인지에 집중할 수 있습니다. 이 글에서는 React Native 통합 앱에서 프로파일별 controllermodel을 분리할 때 확인하면 좋은 기준을 정리해보겠습니다.

이 글에서 다룰 문제

프로파일별 로직이 화면 컴포넌트에 섞이면 다음 문제가 생길 수 있습니다.

  • 화면 파일 안에 profileId 조건문이 계속 늘어난다.
  • API 응답 구조가 다른데 UI에서 직접 변환한다.
  • 같은 카드 UI를 쓰지만 프로파일마다 필드명이 달라 중복 코드가 생긴다.
  • 새 프로파일 추가 시 화면, API, 라우팅 파일을 모두 수정해야 한다.
  • 테스트할 기준이 UI 렌더링인지 데이터 변환인지 모호해진다.

처음 개발할 때는 화면에서 바로 데이터를 맞추는 방식이 간단해 보입니다. 하지만 통합 앱에서는 프로파일별 예외를 화면 컴포넌트에 계속 넣기보다, 데이터 변환 기준을 별도로 분리하는 편이 더 안정적인 것 같습니다.

controller와 model을 나누는 이유

controllermodel을 나누는 목적은 거창한 아키텍처를 만들기 위해서가 아닙니다. 화면 컴포넌트가 너무 많은 책임을 갖지 않도록 역할을 분리하기 위한 것입니다.

구분 역할 예시
profile registry 앱별 설정 기준 profileId, feature flag, initialRoute
model 화면에서 사용할 데이터 형태 카드 제목, 설명, 좌표, 링크
controller 원본 데이터를 model로 변환하고 화면 동작 연결 API 응답 정규화, 클릭 이벤트 처리
UI component model을 렌더링 카드, 목록, 지도 marker
route guard 화면 접근 가능 여부 확인 enabledRoutes, fallbackRoute

이 구조에서 중요한 점은 UI가 원본 API 응답을 직접 알지 않도록 만드는 것입니다. UI는 title, description, imageUrl, actions 같은 화면용 model만 알면 됩니다.

피해야 할 구조: 화면에 조건문이 쌓이는 경우

아래 예시는 프로파일별 데이터 차이를 화면에서 직접 처리하는 방식입니다.

function PlaceCard({profileId, item}: {profileId: string; item: any}) {
  const title = profileId === 'sampleTour'
    ? item.placeName
    : item.title;

  const description = profileId === 'sampleTour'
    ? item.address
    : item.summary;

  const imageUrl = profileId === 'sampleFileData'
    ? item.thumbnail_url
    : item.imageUrl;

  return (
    <Card
      title={title}
      description={description}
      imageUrl={imageUrl}
    />
  );
}

이 방식은 처음에는 간단하지만 프로파일이 늘어나면 화면 파일이 데이터 변환 규칙까지 모두 갖게 되고, 특히 any 타입이 섞이면 필드 오타나 누락도 늦게 발견되게 됩니다.

처음에는 작은 조건문 하나로 끝날 것 같지만, 나중에는 화면 컴포넌트가 데이터 소스별 예외를 계속 기억해야 하는 구조가 되기 쉽습니다.

화면용 model로 변환하기

대신 화면에서 필요한 model을 먼저 정의해볼 수 있습니다. UI가 실제 API 응답 구조를 직접 보지 않도록 중간 형태를 만드는 방식입니다.

type CardAction = {
  label: string;
  routeName: 'Detail' | 'Map' | 'WebView';
  params?: Record<string, unknown>;
};

type PlaceCardModel = {
  id: string;
  title: string;
  description: string;
  imageUrl?: string;
  actions: CardAction[];
};

그리고 프로파일별 controller 또는 builder에서 원본 데이터를 PlaceCardModel로 변환합니다.

type TourApiItem = {
  contentId: string;
  placeName: string;
  address?: string;
  firstImage?: string;
};

type FileDataItem = {
  id: number;
  title: string;
  summary?: string;
  thumbnail_url?: string;
};

function buildTourPlaceCard(item: TourApiItem): PlaceCardModel {
  return {
    id: item.contentId,
    title: item.placeName,
    description: item.address || '주소 정보가 없습니다.',
    imageUrl: item.firstImage,
    actions: [
      {label: '상세 보기', routeName: 'Detail', params: {id: item.contentId}},
      {label: '지도 보기', routeName: 'Map', params: {id: item.contentId}},
    ],
  };
}

function buildFileDataPlaceCard(item: FileDataItem): PlaceCardModel {
  return {
    id: String(item.id),
    title: item.title,
    description: item.summary || '설명 정보가 없습니다.',
    imageUrl: item.thumbnail_url,
    actions: [
      {label: '상세 보기', routeName: 'Detail', params: {id: item.id}},
    ],
  };
}

이제 UI는 원본 데이터가 어떤 API에서 왔는지 몰라도 이미 정리된 PlaceCardModel만 받아서 렌더링하면 됩니다.

function PlaceCard({model}: {model: PlaceCardModel}) {
  return (
    <Card
      title={model.title}
      description={model.description}
      imageUrl={model.imageUrl}
      actions={model.actions}
    />
  );
}

이렇게 나누면 테스트도 쉬워집니다. UI 테스트는 PlaceCardModel을 렌더링하는지 확인하면 되고, 데이터 변환 테스트는 builder 함수가 올바른 model을 만드는지 확인하면 됩니다.

처음에는 파일이 늘어나서 복잡해 보일 수 있지만 화면과 데이터 변환을 분리해두면, 나중에 프로파일이 늘어났을 때 수정 범위를 더 쉽게 찾을 수 있습니다.

프로파일별 controller 선택하기

프로파일마다 사용할 controller가 다르다면 registry에서 controller key를 지정하거나, engine 타입을 기준으로 controller를 선택할 수 있습니다.

type Engine = 'tour' | 'filedata' | 'utility';

type AppProfile = {
  profileId: string;
  engine: Engine;
  enabledRoutes: string[];
};

type ProfileController = {
  buildCards: (items: unknown[]) => PlaceCardModel[];
};

const controllers: Record<Engine, ProfileController> = {
  tour: {
    buildCards: (items) => items.map((item) => buildTourPlaceCard(item as TourApiItem)),
  },
  filedata: {
    buildCards: (items) => items.map((item) => buildFileDataPlaceCard(item as FileDataItem)),
  },
  utility: {
    buildCards: () => [],
  },
};

function getController(profile: AppProfile): ProfileController {
  return controllers[profile.engine];
}

실제 프로젝트에서는 as 캐스팅을 줄이고, API 응답 검증을 별도로 두는 편이 더 안전합니다. 예제에서는 구조 설명을 위해 단순화했습니다.

처음에는 캐스팅으로 빠르게 맞추고 넘어가기 쉽습니다. 하지만 데이터 구조가 자주 바뀌는 API라면 응답 검증 없이 바로 캐스팅하는 방식은 나중에 런타임 오류로 이어질 수 있습니다.

controller에 넣을 것과 넣지 말아야 할 것

controller가 생기면 모든 로직을 controller에 넣고 싶어질 수 있습니다. 하지만 역할을 명확히 나눠야 합니다.

항목 controller에 적합한가 이유
API 응답을 화면 model로 변환 적합 UI에서 원본 구조를 몰라도 됨
클릭 시 이동할 route 구성 조건부 적합 route guard와 함께 관리 가능
feature flag에 따른 action 제거 적합 프로파일별 화면 정책 반영
실제 API 호출 URL 관리 부적합 환경 설정과 보안 관리 영역
서버 권한 판단 부적합 서버에서 검증해야 함
복잡한 비즈니스 정책 주의 controller가 너무 커질 수 있음
UI 스타일 계산 보통 부적합 responsive hook이나 component 역할

controller는 화면 로직을 정리하는 도구이지, 모든 비즈니스 로직을 몰아넣는 장소가 아닙니다. 특히 서버 권한이나 민감 설정은 controller에 넣으면 안 됩니다.

route guard와 함께 쓰기

controller가 action을 만들 때는 현재 프로파일에서 접근 가능한 route인지 확인해야 합니다. 예를 들어 지도 기능이 꺼진 프로파일에서는 Map action을 만들지 않는 편이 좋습니다.

type RouteName = 'Detail' | 'Map' | 'WebView';

function filterActionsByRoutes(
  actions: CardAction[],
  enabledRoutes: RouteName[],
): CardAction[] {
  return actions.filter((action) => enabledRoutes.includes(action.routeName));
}

function buildSafeCardModel(
  baseModel: PlaceCardModel,
  enabledRoutes: RouteName[],
): PlaceCardModel {
  return {
    ...baseModel,
    actions: filterActionsByRoutes(baseModel.actions, enabledRoutes),
  };
}

이 구조를 두면 UI에서 이 버튼을 보여도 되는지 다시 조건문을 작성할 필요가 줄어듭니다. 물론 최종 navigation 단계에서도 route guard를 한 번 더 확인하는 것이 안전합니다.

테스트 기준

controllermodel을 분리하면 테스트 기준도 조금 더 분명해집니다. 처음부터 모든 화면을 테스트하기 어렵다면 model builder 함수부터 테스트하는 것이 좋습니다. 데이터 변환은 단위 테스트로 잡기 쉽고, 회귀가 자주 생기는 부분이기 때문입니다.

  • 원본 응답이 PlaceCardModel로 정상 변환되는지 확인한다.
  • 필수 필드가 없을 때 기본 문구나 fallback이 적용되는지 확인한다.
  • feature flag가 꺼진 route action은 제거되는지 확인한다.
  • profile engine에 맞는 controller가 선택되는지 확인한다.
  • UI는 원본 API 응답이 아니라 model만 받는지 확인한다.
  • controller 테스트 fixture에 실제 URL, API 키, 사용자 정보가 없는지 확인한다.

자주 하는 실수

1. UI 컴포넌트에 원본 API 응답을 그대로 넘기는 경우가 있습니다. 이렇게 하면 UI가 데이터 소스와 강하게 묶여서 프로파일이 늘어날수록 조건문이 많아집니다. 화면에서는 원본 응답보다 화면용 model을 받는 구조가 더 안정적입니다.

2. controller에 secret이나 실제 서버 URL을 넣는 경우도 있습니다. controller는 공개 코드나 테스트 예제에 자주 등장할 수 있으므로 민감 정보가 들어가지 않도록 조심해야 합니다.

3. model을 너무 API 구조와 비슷하게 만드는 경우가 있습니다. model은 화면이 필요한 형태여야 합니다. API 필드명을 그대로 옮기기보다 화면에서 읽기 좋은 이름으로 정리하는 것이 좋습니다.

4. controller를 만들고도 UI에서 다시 profileId 조건문을 반복하는 경우도 있습니다. controller가 만든 model에 필요한 정보가 충분히 들어 있는지 먼저 확인해야 합니다.

결론

React Native 통합 앱에서 프로파일별 controllermodel을 분리하면 화면 컴포넌트의 조건문을 줄이고, 데이터 변환 기준을 명확히 만들 수 있습니다. UI는 화면용 model만 렌더링하고, controller는 프로파일별 원본 데이터를 model로 바꾸는 역할을 맡는 구조가 안정적입니다.

중요한 것은 분리 자체가 목적이 아니라 역할을 명확히 하는 것입니다. profile registry는 앱 설정, controller는 변환과 화면 동작 연결, model은 UI 입력 데이터, route guard는 접근 가능 여부를 담당해야 합니다.

처음에는 이런 분리가 조금 번거롭게 느껴질 수 있습니다. 하지만 프로파일이 늘어나고 공통 UI가 많아질수록 어디에서 데이터를 바꾸고, 어디에서 화면을 그리는지가 분명한 구조가 유지보수에 훨씬 유리한 것 같습니다.

참고 자료

  • React Native 공식 문서의 component, state, navigation 관련 문서
  • TypeScript discriminated union과 타입 가드 관련 문서
  • 프로젝트 내부 profile registry, selectedProfiles, route guard 문서