[React Native] profile별 controller/model을 분리해서 공통 코드량을 줄이는 방법
React Native 통합 앱에서 profile이 늘어나면 화면보다 먼저 복잡해지는 부분이 데이터 처리입니다. 어떤 앱은 관광지 목록을 보여주고, 어떤 앱은 산행 코스를 보여주며, 또 다른 앱은 파일 기반 데이터를 보여줄 수 있습니다. 화면 구조는 비슷해 보여도 원본 데이터 필드와 상세 화면에서 필요한 값은 조금씩 다릅니다.
이때 모든 화면에서 profile별 조건문을 직접 작성하면 공통 코드가 오히려 더 복잡해질 수 있습니다. 화면 컴포넌트가 각 profile의 원본 필드를 모두 알고 있으면 새 profile을 추가할 때마다 화면 코드가 커지고, 작은 수정도 다른 profile에 영향을 줄 가능성이 생깁니다.
그래서 profile별 controller와 model normalizer를 분리해 원본 데이터 차이와 화면 공통 구조를 나누는 방식이 필요합니다. 원본 데이터는 normalizer에서 공통 UI model로 바꾸고, 화면은 그 model만 받아서 렌더링하도록 만드는 구조입니다.
실제로 구현 흐름을 따라가다 보면 데이터 변환 기준을 어디에 둘지가 생각보다 중요해집니다. 이 글에서는 React Native 통합 앱에서 profile별 controller와 model normalizer를 분리할 때 확인하면 좋은 기준을 정리해보겠습니다.
왜 controller/model 분리가 필요한가
통합 앱에서 가장 위험한 패턴은 화면 컴포넌트가 원본 API 응답 구조를 너무 많이 아는 것입니다. 화면이 tourName, mountainTitle, fileLabel 같은 profile별 필드를 모두 직접 처리하면 새 profile을 추가할 때마다 화면 코드가 커집니다.
공통 화면을 만들었는데 내부에는 profile별 예외가 계속 늘어나는 상황도 생길 수 있습니다. 이런 구조는 겉으로는 공통 컴포넌트처럼 보이지만, 실제로는 여러 profile의 조건을 한 파일에 모아둔 형태가 되기 쉽습니다.
| 문제 | 원인 | 개선 방향 |
|---|---|---|
| 화면 조건문 증가 | profile별 필드 직접 참조 | normalizer로 UI model 생성 |
| 상세 화면 중복 | 데이터별 route 처리 분산 | controller에서 상세 데이터 로딩 |
| 회귀 발생 | 공통 화면 수정이 특정 profile에 영향 | profile별 테스트 추가 |
| 새 패턴 난립 | 기능마다 다른 구조 도입 | 기존 관례 우선 적용 |
핵심은 화면이 원본 데이터를 직접 알지 않도록 만드는 것입니다. 화면은 공통 UI model만 바라보고, profile별 데이터 차이는 normalizer와 controller에서 정리하는 편이 유지보수에 더 좋습니다.
역할을 나누는 기준
controller와 model을 나눌 때는 각 계층의 책임을 먼저 정해두는 것이 좋습니다. 역할이 섞이면 분리한 것처럼 보여도 결국 화면이나 controller가 다시 커질 수 있습니다.
| 계층 | 역할 | 넣지 말아야 할 것 |
|---|---|---|
| raw model | API/파일 원본 구조 표현 | 화면 표시 로직 |
| normalizer | 원본 데이터를 UI model로 변환 | 네비게이션 처리 |
| controller | 목록/상세 로딩, 상태 관리 | JSX 렌더링 |
| UI component | 공통 화면 렌더링 | 원본 API 필드 직접 참조 |
계층을 나누는 일이 처음 접할 때는 조금 과하게 느껴질 수 있습니다. 다만 profile이 늘어날수록 데이터 변환을 어디에서 하는지가 코드 품질을 크게 좌우합니다. 특히 공통 화면을 오래 유지하려면 원본 데이터와 UI model 사이의 경계를 분명히 해두는 편이 좋습니다.
예시 코드
아래 코드는 실제 프로젝트 코드를 그대로 옮긴 것이 아니라 구조 설명용 예시입니다. 관광지 데이터와 산 정보 데이터의 원본 필드는 다르지만, 화면에서는 같은 ListItemModel로 렌더링할 수 있도록 변환합니다.
type RawTourItem = {
title?: string;
address?: string;
imageUrl?: string;
};
type RawMountainItem = {
mntnNm?: string;
location?: string;
photo?: string;
};
type ListItemModel = {
id: string;
title: string;
subtitle: string;
thumbnailUrl?: string;
};
export function normalizeTourItem(raw: RawTourItem, index: number): ListItemModel {
return {
id: `tour-${index}`,
title: raw.title?.trim() || '이름 없는 관광지',
subtitle: raw.address?.trim() || '주소 정보 없음',
thumbnailUrl: raw.imageUrl,
};
}
export function normalizeMountainItem(raw: RawMountainItem, index: number): ListItemModel {
return {
id: `mountain-${index}`,
title: raw.mntnNm?.trim() || '이름 없는 산',
subtitle: raw.location?.trim() || '위치 정보 없음',
thumbnailUrl: raw.photo,
};
}
이렇게 변환해두면 화면은 ListItemModel만 알면 됩니다. 원본 데이터가 관광 API인지 산 정보 파일인지 몰라도 같은 리스트 컴포넌트를 사용할 수 있습니다.
코드를 나눠보다 보면 normalizer는 단순 변환 함수처럼 보이지만 꽤 중요한 역할을 합니다. 빈 값 처리, 이미지 필드 정리, 제목 fallback, id 생성 기준이 모두 이 지점에 모이기 때문입니다.
controller에서 처리할 것
controller는 데이터를 가져오고, 로딩/에러/빈 상태를 결정하고, normalizer를 호출하는 역할을 맡을 수 있습니다. 화면이 직접 원본 데이터를 가져와서 변환까지 처리하지 않도록 중간 계층을 두는 방식입니다.
type LoadState<T> =
| {status: 'loading'}
| {status: 'error'; message: string}
| {status: 'empty'}
| {status: 'success'; data: T};
export async function loadProfileList(
profileType: 'tour' | 'mountain',
): Promise<LoadState<ListItemModel[]>> {
try {
const rawItems = await fetchSampleItems(profileType);
const items = rawItems.map((item, index) =>
profileType === 'tour'
? normalizeTourItem(item, index)
: normalizeMountainItem(item, index),
);
return items.length ? {status: 'success', data: items} : {status: 'empty'};
} catch {
return {status: 'error', message: '데이터를 불러오지 못했습니다.'};
}
}
위 예시에서는 설명을 위해 profileType === 'tour' 조건을 사용했습니다. 실제 구현에서는 registry나 strategy map으로 더 깔끔하게 분리할 수 있습니다.
다만 기존 코드베이스에 이미 쓰는 패턴이 있다면 새 패턴을 억지로 만들기보다 기존 관례에 맞춰 확장하는 편이 안전합니다. 통합 앱에서는 구조의 세련됨보다 팀이 일관되게 이해하고 수정할 수 있는지가 더 중요해질 때가 많습니다.
strategy map으로 normalizer 선택하기
profile 종류가 더 늘어난다면 controller 안의 조건문도 점점 커질 수 있습니다. 이때는 profile type별 normalizer를 map으로 묶어두면 분기 지점을 줄일 수 있습니다.
type ProfileType = 'tour' | 'mountain';
type Normalizer = (raw: unknown, index: number) => ListItemModel;
const normalizers: Record<ProfileType, Normalizer> = {
tour: (raw, index) => normalizeTourItem(raw as RawTourItem, index),
mountain: (raw, index) => normalizeMountainItem(raw as RawMountainItem, index),
};
export function normalizeByProfile(
profileType: ProfileType,
rawItems: unknown[],
): ListItemModel[] {
const normalize = normalizers[profileType];
return rawItems.map((item, index) => normalize(item, index));
}
이 방식은 profile별 변환 기준을 한곳에서 찾기 쉽게 만들어줍니다. 새 profile을 추가할 때도 normalizer를 만들고 map에 등록하면 되기 때문에 수정 범위가 비교적 분명해집니다.
물론 무조건 strategy map을 써야 하는 것은 아닙니다. profile 수가 적고 기존 코드가 단순한 조건문 중심이라면 현재 구조를 유지하면서 테스트를 보강하는 편이 더 나을 수도 있습니다. 중요한 것은 profile별 데이터 차이가 화면 컴포넌트로 새어 나가지 않게 막는 것입니다.
새 profile 추가 시 확인할 것
새 profile을 추가할 때는 화면이 정상적으로 보이는지만 확인하면 부족합니다. 원본 데이터가 공통 UI model로 안전하게 변환되는지, 목록과 상세가 같은 id 기준을 사용하는지까지 함께 봐야 합니다.
- raw model 타입을 정의했는지 확인한다.
- UI model로 변환하는 normalizer가 있는지 확인한다.
- 빈 값, 누락 값, 이미지 실패를 처리했는지 확인한다.
- 목록과 상세가 같은 id 기준을 사용하는지 확인한다.
- 공통 UI가 원본 필드를 직접 참조하지 않는지 확인한다.
- 대표 profile 회귀 테스트가 있는지 확인한다.
- profile별 차이를 문서로 남겼는지 확인한다.
특히 공통 UI가 원본 필드를 직접 참조하지 않는지 확인하는 것이 중요합니다. 처음에는 한두 필드만 직접 읽어도 괜찮아 보이지만, 이런 예외가 쌓이면 공통 화면의 의미가 흐려집니다.
테스트 기준
controller와 normalizer는 UI를 띄우지 않고도 테스트하기 좋은 부분입니다. 원본 데이터 샘플을 넣었을 때 기대하는 ListItemModel이 나오는지 확인하면 됩니다.
test('tour item is normalized to list item model', () => {
const result = normalizeTourItem(
{title: '샘플 관광지', address: '서울시 중구', imageUrl: 'https://example.com/sample.jpg'},
0,
);
expect(result).toEqual({
id: 'tour-0',
title: '샘플 관광지',
subtitle: '서울시 중구',
thumbnailUrl: 'https://example.com/sample.jpg',
});
});
test('mountain item uses fallback text when fields are missing', () => {
const result = normalizeMountainItem({}, 1);
expect(result.title).toBe('이름 없는 산');
expect(result.subtitle).toBe('위치 정보 없음');
});
테스트 데이터에는 실제 서버 URL, 사용자 식별자, 내부 관리 필드를 넣지 않는 편이 좋습니다. 예시 URL이 필요하다면 공개 가능한 더미 도메인으로 바꿔두는 것이 안전합니다.
작은 normalizer 테스트는 사소해 보일 수 있습니다. 그래도 profile이 늘어났을 때 원본 필드 변경으로 목록 화면이 깨지는 문제를 빠르게 잡는 데 도움이 됩니다.
자주 하는 실수
1. 공통 컴포넌트 안에 모든 profile의 예외를 넣는 경우가 있습니다. 공통 컴포넌트는 공통 UI model만 받아야 유지보수하기 쉽습니다. profile별 차이는 normalizer와 controller에서 정리하는 편이 좋습니다.
2. 원본 API 응답 타입을 화면까지 그대로 전달하는 경우도 있습니다. 이렇게 되면 화면이 데이터 소스와 강하게 묶여서 새 profile을 추가할 때 수정 범위가 커집니다.
3. 빈 값과 누락 값을 normalizer에서 처리하지 않는 경우가 있습니다. 화면마다 fallback 문구를 따로 처리하면 문구가 달라지고 중복 코드가 늘어날 수 있습니다.
4. 기존 프로젝트 관례를 확인하지 않고 새 패턴을 추가하는 경우도 있습니다. 새 구조가 더 좋아 보여도 기존 controller, registry, hook 구조와 어긋나면 유지보수가 더 어려워질 수 있습니다.
결론
profile별 controller/model 분리는 통합 앱의 공통 코드량을 줄이는 핵심 기준입니다. 원본 데이터 차이는 normalizer에서 흡수하고, 화면은 공통 UI model만 바라보게 만드는 것이 좋습니다.
좋은 구조는 raw model, normalizer, controller, UI component의 책임을 나누는 것입니다. raw model은 원본 구조를 표현하고, normalizer는 공통 UI model을 만들며, controller는 목록/상세 로딩과 상태 관리를 맡고, UI component는 렌더링에 집중해야 합니다.
새 profile을 추가할 때는 기존 프로젝트의 구조와 이름 규칙을 먼저 확인하는 편이 안전합니다. 기존 관례를 유지하면서 normalizer와 controller를 확장하면, profile이 늘어나도 화면 코드가 과하게 복잡해지는 일을 줄일 수 있습니다.