TypeScript의 Union과 Intersection 타입: 복잡한 데이터 구조, 이렇게 다뤄보세요
TypeScript의 Union 타입과 Intersection 타입은 복잡한 데이터 구조를 다루는 데 필수적인 도구입니다. 이 글에서는 두 타입의 기본 개념과 함께, 실제 프론트엔드 개발 시나리오에서 어떻게 활용할 수 있는지 예시 코드를 통해 자세히 설명합니다. API 응답 처리, 이벤트 핸들링, 상태 관리 등 다양한 상황에서의 적용 방법을 익혀 타입 안전성을 높여보세요.
"타입이 다르다고요?" - Union 타입으로 유연하게
프론트엔드 개발을 하다 보면 API 응답이나 사용자 입력 등 다양한 출처에서 오는 데이터를 다루게 됩니다. 때로는 동일한 필드가 여러 타입으로 올 수도 있고, 특정 조건에 따라 데이터의 형태가 달라지기도 합니다. 이런 상황에서 "이건 문자열일 수도 있고, 숫자일 수도 있어요" 또는 "이 값은 null이거나, 아니면 객체여야 해요"와 같이 여러 가능성을 하나의 타입으로 표현해야 할 때가 있습니다. 이때 TypeScript의 Union 타입이 빛을 발합니다.
Union 타입은 | (파이프) 기호를 사용하여 두 개 이상의 타입을 하나로 묶는 방법입니다. 즉, 해당 타입이 될 수 있는 여러 가능성 중 하나이면 유효하다고 판단합니다.
예시 1: API 응답 처리
1interface UserProfile { 2 id: number; 3 name: string; 4 email: string | null; // 이메일은 문자열이거나 null일 수 있습니다. 5} 6 7function displayUserProfile(profile: UserProfile) { 8 console.log(`ID: ${profile.id}, Name: ${profile.name}`); 9 if (profile.email) { 10 console.log(`Email: ${profile.email}`); 11 } else { 12 console.log('Email not provided.'); 13 } 14} 15 16const user1: UserProfile = { 17 id: 1, 18 name: 'Alice', 19 email: 'alice@example.com' 20}; 21 22const user2: UserProfile = { 23 id: 2, 24 name: 'Bob', 25 email: null 26}; 27 28displayUserProfile(user1); 29displayUserProfile(user2);
위 코드에서 email 필드는 string | null로 선언되어, email 값으로 문자열 또는 null이 올 수 있음을 명시합니다. displayUserProfile 함수 내에서는 if (profile.email)과 같이 Union 타입의 특성을 활용하여 null인 경우를 안전하게 처리할 수 있습니다. TypeScript는 profile.email이 null이 아님을 확인한 후에야 profile.email을 string으로 간주하여 접근을 허용합니다. 이는 타입 안정성을 높여 런타임 오류를 방지하는 데 도움을 줍니다.
예시 2: 이벤트 핸들러의 타겟 타입
웹 애플리케이션에서 이벤트 핸들러를 작성할 때, 이벤트가 발생한 DOM 요소의 타입을 명확히 해야 할 때가 있습니다. 예를 들어, input 이벤트는 HTMLInputElement에서 발생하지만, button 클릭 이벤트는 HTMLButtonElement에서 발생합니다. 두 경우 모두 HTMLElement의 하위 타입이지만, 특정 속성에 접근할 때는 더 구체적인 타입 정보가 필요할 수 있습니다.
1function handleInputOrClick(event: Event) { 2 const target = event.target as HTMLInputElement | HTMLButtonElement; 3 4 if (target instanceof HTMLInputElement) { 5 console.log('Input value:', target.value); 6 } else if (target instanceof HTMLButtonElement) { 7 console.log('Button clicked!'); 8 } 9} 10 11// document.addEventListener('input', handleInputOrClick); 12// document.addEventListener('click', handleInputOrClick);
이 예시에서는 event.target이 HTMLInputElement 또는 HTMLButtonElement일 수 있다고 가정하고 Union 타입을 사용했습니다. instanceof 연산자를 통해 실제 이벤트가 발생한 요소의 타입을 판별하여 안전하게 해당 요소의 속성에 접근할 수 있습니다.
"이것도 저것도 다 필요해" - Intersection 타입으로 조합하기
이번에는 여러 타입의 장점만을 모아 새로운 타입을 만들어야 하는 상황을 생각해 봅시다. 예를 들어, 기본적으로 모든 사용자에게 필요한 공통 정보가 있고, 관리자에게만 추가적으로 필요한 정보가 있다고 가정해 봅시다. 또는, 이미 정의된 여러 인터페이스의 속성을 모두 포함하는 새로운 타입을 정의하고 싶을 때도 있습니다. 이럴 때 사용하는 것이 바로 Intersection 타입입니다.
Intersection 타입은 & (앰퍼샌드) 기호를 사용하여 여러 타입을 하나로 결합합니다. 결합된 타입은 참여한 모든 타입의 속성을 모두 가져야 유효합니다.
예시 3: 공통 속성과 관리자 전용 속성 결합
1interface CommonUser { 2 id: number; 3 username: string; 4} 5 6interface AdminUser extends CommonUser { 7 // CommonUser의 모든 속성을 상속받습니다. 8 permissions: string[]; 9} 10 11// Intersection 타입을 사용하여 AdminUser와 유사한 구조를 만들 수도 있습니다. 12type FullAdmin = CommonUser & { 13 permissions: string[]; 14 level: number; 15}; 16 17const admin: FullAdmin = { 18 id: 101, 19 username: 'superadmin', 20 permissions: ['read', 'write', 'delete'], 21 level: 5 22}; 23 24console.log(admin.username); // 'superadmin' 25console.log(admin.permissions); // ['read', 'write', 'delete'] 26console.log(admin.level); // 5
FullAdmin 타입은 CommonUser 인터페이스의 id와 username 속성을 가지면서, 동시에 익명 객체 타입의 permissions와 level 속성까지 모두 포함해야 합니다. 마치 여러 개의 인터페이스를 extends로 상속받은 것과 같은 효과를 냅니다. AdminUser 인터페이스 예시처럼 extends를 사용할 수도 있지만, 여러 독립적인 타입을 조합할 때는 Intersection 타입이 더 유연하게 사용될 수 있습니다.
예시 4: 상태 관리에서의 활용
리덕스(Redux) 같은 상태 관리 라이브러리를 사용할 때, 액션(Action) 객체는 보통 type 필드를 필수로 가지며, 각 액션 타입별로 고유한 페이로드(payload)를 가집니다. 이를 Union 타입과 Intersection 타입을 조합하여 명확하게 표현할 수 있습니다.
1// 액션의 공통 부분 2interface BaseAction { 3 type: string; 4} 5 6// 특정 액션 타입 (예: 사용자 정보 업데이트) 7interface UpdateUserAction extends BaseAction { 8 type: 'UPDATE_USER'; 9 payload: { id: number; name: string }; 10} 11 12// 또 다른 액션 타입 (예: 상품 목록 로딩) 13interface LoadProductsAction extends BaseAction { 14 type: 'LOAD_PRODUCTS'; 15 payload: string[]; 16} 17 18// 모든 가능한 액션 타입을 Union으로 묶음 19type AppAction = UpdateUserAction | LoadProductsAction; 20 21function handleAction(action: AppAction) { 22 switch (action.type) { 23 case 'UPDATE_USER': 24 // action은 자동으로 UpdateUserAction 타입으로 추론됩니다. 25 console.log('Updating user:', action.payload.name); 26 break; 27 case 'LOAD_PRODUCTS': 28 // action은 자동으로 LoadProductsAction 타입으로 추론됩니다. 29 console.log('Loaded products:', action.payload); 30 break; 31 default: 32 // exhaustive check (모든 case를 다루었는지 확인) 33 const _exhaustiveCheck: never = action; 34 return _exhaustiveCheck; 35 } 36} 37 38const userUpdate: UpdateUserAction = { type: 'UPDATE_USER', payload: { id: 1, name: 'Alice' } }; 39handleAction(userUpdate); 40 41const productsLoad: LoadProductsAction = { type: 'LOAD_PRODUCTS', payload: ['apple', 'banana'] }; 42handleAction(productsLoad);
이 예시에서는 BaseAction이라는 공통 인터페이스와 각 액션별 구체적인 인터페이스(UpdateUserAction, LoadProductsAction)를 정의했습니다. 그리고 이들을 AppAction이라는 Union 타입으로 묶었습니다. handleAction 함수 내에서 switch (action.type) 문을 사용하면, TypeScript는 각 case 블록에서 action의 타입을 해당 액션 타입(UpdateUserAction 또는 LoadProductsAction)으로 정확하게 추론합니다. 따라서 action.payload에 안전하게 접근할 수 있습니다. 이는 복잡한 상태 관리 로직을 훨씬 명확하고 타입 안전하게 만들어 줍니다.
언제, 어떻게 사용해야 할까요?
- Union 타입 (
|): 데이터의 형태가 여러 가지 가능성을 가질 때 사용합니다. 예를 들어, API 응답 값이null일 수도 있고, 문자열일 수도 있을 때, 혹은 어떤 값은 필수가 아니어서undefined가 될 수도 있을 때 유용합니다. "이것 또는 저것"의 상황에 적합합니다. - Intersection 타입 (
&): 여러 타입의 속성을 하나의 타입으로 결합하고 싶을 때 사용합니다. "이것 그리고 저것"을 모두 포함해야 하는 상황에 적합합니다. 기존에 정의된 타입을 조합하여 새로운 기능을 구현하거나, 여러 소스에서 온 데이터를 통합해야 할 때 강력한 도구가 됩니다.
이 두 가지 타입 연산자는 TypeScript로 하여금 더욱 복잡하고 현실적인 데이터 구조를 명확하게 표현하고, 이를 통해 코드의 안정성과 가독성을 크게 향상시킬 수 있게 해줍니다.
참고 자료:
- TypeScript: Union Types
- TypeScript: Intersection Types (Note: The official handbook links Union and Intersection types under the same section due to their relationship.)