개발 노트/React Native

React Native 통합 앱 profileId 조건문 지우기 (Controller와 Model 분리)

pposooj 2026. 5. 20. 13:41

여러 프로필을 하나의 코드베이스로 관리하는 React Native 통합 앱을 개발하고 운영하다 보면 꼭 마주치는 문제가 있습니다. 화면 레이아웃은 거의 비슷한데, 프로필마다 데이터 구조나 API 응답 필드, 처리 방식이 조금씩 다른 경우입니다.

저의 경우도 처음에는 귀찮아서 화면 컴포넌트 안에서 if (profileId === '...') 같은 조건문으로 대충 때우며 빠르게 개발했습니다. 하지만 프로필이 서너 개 이상 늘어나기 시작하니까 UI 파일 하나가 데이터 변환, API 응답 정규화, 화면 상태 판단까지 전부 떠안으면서 코드가 아주 난장판이 되더군요.

나중에 제가 다시 보면서 고치려고, 화면 컴포넌트의 복잡도를 낮추고 유지보수하기 편하게 Controller와 Model을 분리하는 기준을 정리해 둡니다.

내가 겪은 문제 (화면에 조건문이 쌓이는 구조)

처음에는 작은 조건문 하나로 시작하지만, 프로필이 늘어날수록 UI 컴포넌트가 온갖 데이터 소스의 예외 케이스를 전부 기억해야 하는 구조가 됩니다.

아래 코드가 딱 처음에 제가 실수했던 방식입니다.

TypeScript
 
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}
    />
  );
}

이 방식의 문제점

  • 화면 파일 하나에 profileId 조건문이 계속 늘어납니다.
  • API 응답 구조가 다른 걸 UI 컴포넌트 내부에서 직접 구르고 바꾸게 됩니다.
  • 타입스크립트에서 any 타입을 남발하게 되어, 필드 오타나 누락을 런타임 에러로 맞이하게 됩니다.
  • 새로운 프로필을 추가할 때 화면, API, 라우팅 파일을 전부 헤집어 놓아야 합니다.

해결 방법: Controller와 Model 분리하기

거창한 아키텍처를 도입하자는 게 아닙니다. 화면 컴포넌트가 너무 많은 책임을 갖지 않도록 역할을 쪼개는 것입니다. 통합 앱에서는 화면(UI)이 공통 자산이기 때문에, 화면은 오직 "어떤 데이터를 보여줄 것인가"에만 집중해야 합니다.

역할을 딱 아래처럼 정리하고 시작하면 편합니다.

구분역할예시
Profile Registry 앱별 설정 기준 관리 profileId, feature flag, initialRoute
Model 화면에서 사용할 순수 데이터 형태 정의 카드 제목, 설명, 이미지 주소, 클릭 액션 리스트
Controller 원본 데이터를 Model로 변환하고 화면 동작 연결 API 응답 정규화, 클릭 이벤트 핸들러 바인딩
UI Component 데이터(Model)를 받아서 화면에 렌더링 카드 UI, 목록, 지도 마커 등
Route Guard 프로필별 화면 접근 권한 확인 enabledRoutes, fallbackRoute 처리
 

실제 적용 코드

핵심은 "UI가 원본 API 응답 구조를 모르게 만드는 것"입니다. UI는 정형화된 화면용 Model 데이터만 받으면 됩니다.

1. 화면용 Model 정의

먼저 UI에서 필요한 데이터 구조를 확실하게 정의합니다.

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

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

2. 프로필별 변환 로직 (Builder / Controller) 분리

원본 API 응답 타입이 서로 다르더라도, 최종 결과물은 위에서 정의한 PlaceCardModel로 맞춰서 반환해 주는 함수를 만듭니다.

TypeScript
 
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}},
    ],
  };
}

3. 깔끔해진 UI 컴포넌트

이제 UI 컴포넌트는 어떤 프로필인지 신경 쓸 필요가 없습니다. 그냥 정해진 포맷의 model만 받아서 렌더링하면 끝입니다. 컴포넌트 내부의 그 많던 if 문이 싹 사라집니다.

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

프로필별 Controller 동적 선택하기

프로필마다 사용할 Controller가 다를 때는 아래처럼 engine 타입을 기준으로 레지스트리를 만들어두고 꺼내 쓰면 편합니다.

TypeScript
 
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를 다룰 때는 실제 응답 값을 검증(Validation)하는 단계를 앞단에 분리해 주는 것이 런타임 에러를 막는 지름길입니다.

Route Guard 연동으로 완성도 높이기

특정 프로필에서는 지도 기능이 아예 꺼져 있어야 할 수도 있습니다. Controller가 액션을 생성할 때, 현재 프로필에서 접근 가능한 라우트인지 필터링해 주면 UI 단에서 버튼 노출 여부를 제어하는 조건문을 또 쓸 필요가 없어집니다.

TypeScript
 
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),
  };
}

실무에서 자주 하는 실수 (주의할 점)

  1. Controller에 민감한 정보(Secret Key, 서버 URL 등)를 넣지 마세요.
    • Controller는 화면 로직을 연결하는 도구입니다. 환경 설정이나 보안 관리가 필요한 값은 별도 config 영역이나 서버 인프라 단계에서 처리해야 합니다.
  2. Model을 API 구조와 완전히 똑같이 복사하지 마세요.
    • Model은 어디까지나 화면(UI) 중심이어야 합니다. API 필드명을 그대로 노출하기보다, 화면에서 읽고 쓰기 좋은 직관적인 네이밍으로 한 단계 변환해 주는 게 좋습니다.
  3. 구조를 분리해 놓고 UI에서 또 profileId 조건문을 쓰지 마세요.
    • 만약 UI에서 또 조건문 분기가 필요하다면, Controller가 만든 Model에 정보가 부족하다는 신호입니다. Model 구조를 보완하는 방향이 맞습니다.

테스트 기준 잡기

이렇게 쪼개놓으면 단위 테스트(Unit Test) 짜기가 아주 수월해집니다. 화면 전체를 테스트하기 버겁다면, 데이터 변환을 담당하는 Builder 함수들부터 테스트해 보시길 권합니다.

  • 원본 API 응답 데이터가 PlaceCardModel 구조로 정상 변환되는가?
  • 필수 데이터 필드가 누락되었을 때, fallback(기본 문구) 처리가 제대로 먹히는가?
  • Feature Flag가 꺼진 라우트 액션은 정상적으로 필터링 되어 제거되는가?

처음에는 파일도 늘어나고 매번 변환 함수를 거쳐야 해서 번거롭게 느껴질 수 있습니다. 하지만 앱 규모가 커지고 공통 UI 자산이 많아질수록, 어디서 데이터를 만지고 어디서 화면을 그리는지 명확하게 구분해 놓는 것이 정신 건강과 앱 안정성에 훨씬 이롭습니다.