React Native 통합 앱 프로필별 데이터 분기 처리 해결 방법 (Normalizer, Controller 분리)
React Native로 여러 종류의 프로필(관광지 목록, 산행 코스, 파일 데이터 등)을 하나의 통합 앱으로 묶어 운영하다 보면, 화면보다 데이터 처리 쪽이 먼저 복잡해지기 시작합니다.
겉보기에는 리스트나 상세 화면 구조가 다 비슷해 보여서 공통 컴포넌트로 묶었는데, 막상 API 원본 데이터 필드를 보면 어떤 건 title이고 어떤 건 mntnNm이라서 화면 컴포넌트 내부에 조건문이 덕지덕지 붙곤 합니다. 저의 경우도 처음에는 대수롭지 않게 컴포넌트 안에 분기 처리를 넣었다가, 프로필이 늘어날수록 새 기능을 추가할 때마다 기존 코드가 깨지는 회귀 문제를 겪었습니다.
화면 컴포넌트가 원본 API 구조를 너무 많이 알면 유지보수가 불가능해집니다. 이를 해결하기 위해 원본 데이터 차이는 Normalizer에서 공통 UI 모델로 변환하고, 화면은 변환된 모델만 받아 렌더링하도록 Controller와 구조를 분리한 방법을 정리해 둡니다.
왜 Controller와 Model 분리가 필요한가
가장 위험한 패턴은 화면 컴포넌트가 tourName, mountainTitle, fileLabel 같은 프로필별 전용 필드를 직접 넘겨받아 처리하는 것입니다. 겉으로는 공통 컴포넌트 파일 하나로 보이지만, 실상을 열어보면 내부에서 프로필별 예외 조건문만 계속 늘어나는 형태가 되기 쉽습니다. 이렇게 되면 작은 수정 하나가 다른 프로필 화면까지 건드리게 됩니다.
운영하면서 겪은 문제점과 개선 방향을 정리하면 다음과 같습니다.
| 화면 조건문 증가 (프로필별 필드 직접 참조) | Normalizer를 도입하여 공통 UI Model 생성 |
| 상세 화면 중복 (데이터별 route 처리 분산) | Controller에서 상세 데이터 로딩 및 관리 |
| 회귀 버그 발생 (공통 화면 수정이 타 프로필에 영향) | 프로필별 독립된 테스트 코드 추가 |
| 새 패턴 난립 (기능마다 구조가 다르게 짜임) | 기존 프로젝트 관례 우선 적용 및 일관성 유지 |
핵심은 화면이 원본 데이터를 직접 알지 못하게 만드는 것입니다. 화면은 공통 UI Model만 바라보고, 데이터의 차이점은 전 단계에서 걷어내야 합니다.
계층별 역할 분리 기준
역할을 나눌 때는 각 계층의 책임을 명확히 서술해 두어야 나중에 코드가 다시 커지는 것을 막을 수 있습니다.
- Raw Model: API나 파일에서 들어오는 원본 데이터 구조를 그대로 표현합니다. (화면 표시 로직 금지)
- Normalizer: 원본 데이터를 화면 공통 UI Model로 끄집어내 변환하는 함수입니다. (네비게이션 처리 금지)
- Controller: 목록 및 상세 로딩, 에러/빈 화면 등의 상태 관리를 담당합니다. (JSX 렌더링 금지)
- UI Component: 변환된 공통 UI Model을 받아 순수하게 화면을 그리는 역할만 합니다. (원본 API 필드 참조 금지)
실무 적용 예시 코드
관광지 데이터(RawTourItem)와 산 정보 데이터(RawMountainItem)의 원본 필드는 완전히 다르지만, 화면에서는 동일한 ListItemModel 인터페이스로 처리할 수 있도록 변환 인터페이스를 잡아 줍니다.
// 원본 데이터 타입 정의
type RawTourItem = {
title?: string;
address?: string;
imageUrl?: string;
};
type RawMountainItem = {
mntnNm?: string;
location?: string;
photo?: string;
};
// 화면에서 바라볼 공통 UI 모델
type ListItemModel = {
id: string;
title: string;
subtitle: string;
thumbnailUrl?: string;
};
// 프로필별 변환 함수 (Normalizer)
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인지 산 정보 파일인지 컴포넌트 단에서는 알 필요가 없어집니다. 빈 값 처리, 타이틀 fallback(기본값), ID 생성 규칙이 모두 이 Normalizer 한곳에 모이게 됩니다.
Controller에서의 데이터 처리 상태 관리
Controller는 데이터를 직접 땡겨오고, 로딩/에러/성공/빈 화면 상태를 결정하여 화면에 전달하는 중간 다리 역할을 합니다.
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: '데이터를 불러오지 못했습니다.' };
}
}
만약 프로필 종류가 3~4개 이상으로 더 늘어난다면 위 코드의 if/else 조건문도 같이 비대해집니다. 이럴 때는 아래처럼 Strategy Map 구조로 묶어두면 분기 지점을 깔끔하게 관리할 palms 있습니다.
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));
}
무조건 패턴을 쪼개는 게 정답은 아닙니다. 프로필 숫자가 적고 기존 코드가 이미 단순 분기 위주로 구현되어 있다면, 구조를 억지로 바꾸기보다 기존 팀의 관례를 유지하면서 테스트 코드를 붙이는 편이 안전합니다.
새 프로필 추가할 때 체크리스트
나중에 다른 프로필을 새로 추가해야 하는 상황이 오면 화면 동작만 테스트할 게 아니라 다음 가이드라인을 체크해 보는 것이 좋습니다.
- [ ] 원본 Raw Model 타입을 명확히 정의했는가?
- [ ] UI Model로 안전하게 매핑해 주는 전용 Normalizer를 만들었는가?
- [ ] API에서 값이 누락되거나 빈 값으로 올 때의 Fallback 처리를 넣었는가?
- [ ] 목록 데이터의 ID와 상세 화면 진입 시의 ID 식별 기준이 동일한가?
- [ ] 공통 UI 컴포넌트 내부로 원본 필드를 직접 참조하는 코드가 새어 들어가지 않았는가?
- [ ] 기존에 잘 돌고 있던 대표 프로필 화면에 사이드 이펙트(회귀)가 없는가?
UI 없이 진행하는 핵심 로직 단위 테스트
Controller와 Normalizer 계층은 복잡한 UI 렌더링 과정을 거치지 않아도 순수 함수 형태로 쪼개져 있기 때문에 단위 테스트(Unit Test)를 작성하기 매우 좋습니다.
test('관광지 데이터가 공통 ListItemModel 구조로 정상 변환되는지 확인', () => {
const result = normalizeTourItem(
{ title: '제주 성산일출봉', address: '제주도 서귀포시', imageUrl: 'https://example.com/island.jpg' },
0,
);
expect(result).toEqual({
id: 'tour-0',
title: '제주 성산일출봉',
subtitle: '제주도 서귀포시',
thumbnailUrl: 'https://example.com/island.jpg',
});
});
test('산 정보 필드가 비어있을 때 기본값 대체 텍스트가 잘 나오는지 확인', () => {
const result = normalizeMountainItem({}, 1);
expect(result.title).toBe('이름 없는 산');
expect(result.subtitle).toBe('위치 정보 없음');
});
주의할 점 테스트 데이터를 구성할 때는 실서버 URL, 운영자 식별 Key, 실제 내부 계정 정보 등 보안에 민감한 데이터는 배제하고 가상의 도메인이나 더미 텍스트를 활용하여 격리된 환경을 만들어 주어야 안전합니다.
자주 범하기 쉬운 실수 정리
- 공통 컴포넌트 내부에 특정 프로필 하드코딩 예외 처리 넣기: 급하다고 컴포넌트 파일 안에 if (isTour) ...를 적는 순간 공통 컴포넌트로서의 생명은 끝납니다. 예외는 반드시 진입 전 단계인 Normalizer에서 걸러내야 합니다.
- API 응답 타입을 컴포넌트 깊숙한 곳까지 통째로 넘기기: 데이터 소스와 뷰 레이어가 강하게 결합되어 사소한 API 스펙 변경에도 화면 레이아웃 코드를 전부 뜯어고쳐야 하는 불상사가 생깁니다.
- 데이터 누락에 대한 방어 코드 누락: 빈 문자열, null, undefined 값이 들어왔을 때 Normalizer 단에서 기본 처리를 안 해주면 화면 곳곳에서 텍스트가 깨지거나 런타임 에러가 터질 수 있습니다.
결국 다중 프로필 구조를 가진 앱에서 공통 코드를 깔끔하게 끌고 가려면, 원본 데이터와 공통 UI 모델 사이의 경계선을 얼마나 튼튼하게 세우느냐가 핵심입니다. 기존 프로젝트의 구조적 일관성을 해치지 않는 선에서 점진적으로 코드를 분리해 보시기 바랍니다.