TypeScript 고급 패턴 | 조건부 타입, 템플릿 리터럴 타입
이 글의 핵심
라이브러리나 공통 모듈의 타입을 직접 짜다 보면 기본 유틸리티 타입만으로는 표현이 안 되는 순간이 옵니다. 조건부 타입의 분기와 infer 추출, 템플릿 리터럴 타입의 문자열 조작, 매핑 타입의 키 재매핑과 필터링을 차례로 쌓아 올리고, 중첩 조건부 타입에서 분기 순서가 결과를 바꾸는 함정도 짚습니다.
들어가며
TypeScript의 고급 타입 시스템(조건부 타입, infer, 템플릿 리터럴 등)은 명찰을 규칙으로 조합해 API 모양을 컴파일 타임에 맞추는 데 사용됩니다. 런타임 비용 없이 “이런 문자열만 허용” 같은 제약을 걸 수 있습니다.
조건부 타입과 중첩 조건부 타입
기본 문법
// T extends U ? X : Y
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true
T extends U ? X : Y의 extends는 상속이 아니라 “T를 U에 할당할 수 있는가”를 묻는 조건입니다. "hello" 리터럴 타입은 string에 할당할 수 있으므로 true가 됩니다. 이 판정은 전부 컴파일 시점에 일어나고, 자바스크립트로 변환된 코드에는 흔적이 남지 않습니다.
조건부 타입을 처음 쓸 때 가장 당황스러운 동작은 분배(distributive)입니다. T가 제네릭 매개변수 그대로 extends 왼쪽에 오면, 유니언 타입을 넘겼을 때 각 멤버에 따로 적용한 뒤 결과를 다시 유니언으로 합칩니다. 그래서 IsString<string | number>는 true | false, 즉 boolean이 됩니다. 기본 제공 Exclude<T, U>가 T extends U ? never : T 한 줄로 동작하는 것도 이 분배 덕분입니다. 분배를 원하지 않는다면 [T] extends [string] ? true : false처럼 양쪽을 대괄호로 감싸면 유니언 전체를 하나로 판정합니다. 또 IsString<never>는 false가 아니라 never가 되는데, 빈 유니언을 분배하면 결과도 비어 있기 때문입니다.
실전 예제
// 배열인지 확인
type IsArray<T> = T extends any[] ? true : false;
type D = IsArray<string[]>; // true
type E = IsArray<number>; // false
// 함수인지 확인
type IsFunction<T> = T extends (...args: any[]) => any ? true : false;
type F = IsFunction<() => void>; // true
type G = IsFunction<string>; // false
any[]는 읽기 전용 배열을 포함하지 않기 때문에 IsArray<readonly string[]>은 false입니다. as const로 만든 튜플이나 ReadonlyArray를 받는 코드라면 T extends readonly any[]로 검사해야 합니다(일반 배열은 읽기 전용 배열에 할당할 수 있으므로 이쪽이 더 넓습니다). 함수 검사에 any를 쓰는 이유는 매개변수 위치가 반공변이라서, (...args: unknown[]) => unknown으로 쓰면 (x: string) => void 같은 구체적인 함수가 조건을 통과하지 못하기 때문입니다.
중첩 조건부 타입
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<42>; // "number"
type T3 = TypeName<() => void>; // "function"
런타임의 typeof 연산자 결과를 타입 수준에서 흉내 낸 예제입니다. 삼항 연산자를 이어 붙인 것처럼 위에서부터 차례로 검사하므로 순서가 결과를 좌우합니다(자세한 내용은 FAQ 참고). 여기에도 분배가 적용되어 TypeName<string | (() => void)>는 "string" | "function"이 됩니다. null은 어느 분기에도 걸리지 않아 "object"가 되는데, 우연히 자바스크립트의 typeof null === "object"와 같은 결과입니다. strictNullChecks를 끈 프로젝트에서는 null과 undefined가 모든 타입에 할당 가능해져서 이런 조건부 타입의 결과가 달라지므로, 고급 타입을 쓰는 프로젝트라면 strict를 켜 두는 것이 전제입니다.
infer로 반환·매개변수·Promise 타입 추출
개념
조건부 타입에서 타입을 추론합니다.
// 배열 요소 타입 추출
type ElementType<T> = T extends (infer U)[] ? U : never;
type H = ElementType<string[]>; // string
type I = ElementType<number[]>; // number
type J = ElementType<string>; // never
infer U는 “이 자리에 오는 타입을 U라는 이름으로 잡아 두라”는 뜻이며, extends 조건 안에서만 쓸 수 있습니다. 패턴 매칭과 비슷하게, T가 (무언가)[] 모양이면 그 무언가를 U로 꺼내 참 분기에서 씁니다. 모양이 맞지 않으면 거짓 분기로 가므로, 추출에 실패했을 때 무엇을 돌려줄지(never, unknown, 원래 T)를 설계에 맞게 고르면 됩니다. never를 돌려주면 잘못된 타입을 넘겼을 때 이후 코드에서 바로 타입 에러가 나서 실수를 빨리 발견할 수 있습니다. 배열 요소 타입만 필요하다면 string[][number]처럼 인덱스 접근 타입으로도 같은 결과를 얻을 수 있습니다.
함수 반환 타입 추출
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: "U001", name: "홍길동" };
}
type User = MyReturnType<typeof getUser>;
// { id: string; name: string; }
function getNumber(): number {
return 42;
}
type Num = MyReturnType<typeof getNumber>; // number
기본 제공 ReturnType<T>와 같은 구현입니다. 이 패턴의 실용적인 가치는 함수 구현을 타입의 출처로 삼는 데 있습니다. 반환 객체의 모양을 별도 인터페이스로 적어 두지 않아도 getUser가 바뀌면 User 타입이 자동으로 따라 바뀝니다. 다만 typeof getUser처럼 값에서 타입을 얻으려면 typeof 연산자가 필요하고, MyReturnType<getUser>라고 쓰면 “값을 타입으로 쓸 수 없다”는 'getUser' refers to a value, but is being used as a type here 에러가 납니다.
오버로드된 함수에 쓰면 마지막 오버로드 시그니처만 기준으로 추출된다는 점도 알아 둘 만합니다. 여러 오버로드의 반환 타입이 모두 필요한 경우에는 이 방식으로는 얻을 수 없습니다. async 함수라면 반환 타입이 Promise<...>이므로 Awaited<ReturnType<typeof fn>>처럼 한 번 더 벗겨야 실제 결과 타입이 나옵니다.
함수 매개변수 타입 추출
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age: number, email: string) {
return { name, age, email };
}
type Params = MyParameters<typeof createUser>;
// [string, number, string]
// 첫 번째 매개변수 타입
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type First = FirstParam<typeof createUser>; // string
infer P가 ...args 자리에 있으므로 매개변수 목록 전체가 튜플 타입으로 추출됩니다. 튜플이라서 Params[0]처럼 인덱스로 특정 매개변수를 꺼낼 수 있고, FirstParam처럼 패턴 안에서 원하는 위치만 infer로 잡을 수도 있습니다. 이 패턴은 서드파티 라이브러리가 옵션 타입을 export하지 않았을 때 특히 유용합니다. type Options = Parameters<typeof libraryFn>[1]로 두 번째 인자의 타입을 꺼내 쓰면, 라이브러리 내부 타입을 직접 import하지 않아도 됩니다.
Promise 타입 추출
type MyAwaited<T> = T extends Promise<infer U> ? U : T;
type K = MyAwaited<Promise<string>>; // string
type L = MyAwaited<Promise<number>>; // number
type M = MyAwaited<string>; // string
TypeScript 4.5부터 Awaited는 기본 제공 타입이므로, 같은 이름으로 다시 선언하면 모듈이 아닌 파일에서는 Duplicate identifier 'Awaited' 에러가 나고 모듈 안에서는 기본 타입을 가려 버립니다. 그래서 예제는 MyAwaited로 이름을 바꿨습니다. 기본 제공 Awaited는 이 구현보다 정교해서, Promise<Promise<string>>처럼 중첩된 Promise를 재귀적으로 끝까지 벗기고, Promise가 아니더라도 then 메서드를 가진 thenable 객체까지 처리합니다. 위 구현으로 중첩 Promise를 넘기면 한 겹만 벗겨져 Promise<string>이 남습니다. 직접 만든 유틸리티 타입이 기본 제공 타입과 이름이 겹치지 않는지 확인하는 습관이 필요한 이유입니다.
템플릿 리터럴 타입과 API 엔드포인트
기본 사용
type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
interface Events {
onClick: () => void;
onFocus: () => void;
onBlur: () => void;
}
템플릿 리터럴 타입은 자바스크립트의 템플릿 문자열 문법을 타입 수준에 옮긴 것입니다. 자리표시자에 유니언이 들어가면 모든 조합이 만들어지므로, EventName 세 개에서 핸들러 이름 세 개가 나왔습니다. 위 Events 인터페이스를 손으로 적는 대신 { [K in EventHandler]: () => void }처럼 매핑 타입과 결합하면, 이벤트 이름을 추가할 때 핸들러 타입이 자동으로 늘어납니다.
문자열 조작 유틸리티
type Greeting = "hello world";
type Upper = Uppercase<Greeting>; // "HELLO WORLD"
type Lower = Lowercase<Greeting>; // "hello world"
type Cap = Capitalize<Greeting>; // "Hello world"
type Uncap = Uncapitalize<Greeting>; // "hello world"
네 가지는 컴파일러에 내장된(intrinsic) 타입이라 lib.d.ts에서도 구현이 보이지 않습니다. Capitalize는 첫 글자만 바꾸므로 "hello world"의 두 번째 단어는 그대로입니다. snake_case를 camelCase로 바꾸는 식의 변환은 이 도구들과 infer를 조합한 재귀 타입으로 만들 수 있는데, 예를 들어 S extends `${infer H}_${infer T}` ? `${H}${Capitalize<CamelCase<T>>}` : S 형태입니다.
API 엔드포인트
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Resource = "users" | "posts" | "comments";
type ApiEndpoint = `/${Resource}`;
// "/users" | "/posts" | "/comments"
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;
// "GET /users" | "POST /users" | ...
type IdEndpoint = `${ApiEndpoint}/${string}`;
// `/users/${string}` | `/posts/${string}` | ... ("/users/abc" 등과 매칭)
ApiRoute는 메서드 4개 × 리소스 3개로 12개 조합이 됩니다. 조합 폭발에는 주의해야 합니다. 자리표시자가 여러 개이고 각각 유니언이 크면 결과가 곱으로 늘어나며, TypeScript는 유니언 멤버가 10만 개를 넘으면 Expression produces a union type that is too complex to represent 에러를 냅니다. 그 전에도 조합이 수천 개만 되면 에디터 자동완성과 타입 검사가 눈에 띄게 느려집니다.
IdEndpoint처럼 ${string}을 넣으면 특정 목록이 아니라 패턴이 됩니다. "/users/U001"은 통과하고 "/users"나 "/accounts/1"은 거부됩니다. 주석에 적은 것처럼 :id 문자열이 만들어지는 것이 아니라 “뒤에 아무 문자열이나 붙은 형태”를 뜻합니다. 다만 ${string}은 빈 문자열도 허용하므로 "/users/"도 통과하고, 슬래시가 더 들어간 "/users/1/posts"도 통과한다는 한계가 있습니다. 타입만으로 모든 형식을 검증하려 하기보다, 이 정도의 1차 방어에 런타임 검증을 더하는 것이 현실적입니다.
매핑 타입: 키 재매핑과 필터링
기본 매핑
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
interface User {
name: string;
age?: number;
}
type ReadonlyUser = MyReadonly<User>;
type PartialUser = MyPartial<User>;
type RequiredUser = MyRequired<User>;
[K in keyof T]는 T의 모든 키를 돌면서 새 객체 타입을 만드는 반복문이고, T[K]는 그 키의 값 타입입니다. readonly와 ? 앞에 -를 붙이면 수식어를 제거하고, +(생략 가능)를 붙이면 추가합니다. 그래서 -?를 쓴 MyRequired는 age?: number를 age: number로 바꿉니다.
keyof T를 직접 순회하는 매핑 타입은 동형(homomorphic) 매핑 타입이라 부르며, 원래 속성의 readonly·선택적 여부를 그대로 물려받습니다. MyPartial<User>에서 원래 선택적이던 age가 그대로 선택적으로 남는 이유입니다. readonly는 얕게만 적용되므로 MyReadonly<User>의 중첩 객체 속성은 여전히 수정할 수 있고, 이를 깊게 적용한 것이 아래 DeepReadonly입니다.
키 재매핑
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User {
name: string;
age: number;
}
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// }
TypeScript 4.1에서 추가된 as 절은 순회 중인 키를 다른 키로 바꿉니다. string & K가 필요한 이유는 keyof T가 string | number | symbol일 수 있어서, symbol 키는 템플릿 리터럴에 넣을 수 없기 때문입니다. 교차 타입으로 문자열 키만 남기면 Capitalize에 넘길 수 있습니다. 이 교차를 빼면 Type 'K' does not satisfy the constraint 'string' 에러가 납니다.
키 필터링
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K];
};
interface User {
id: string;
name: string;
age: number;
isActive: boolean;
}
type StringProps = PickByType<User, string>;
// { id: string; name: string; }
type NumberProps = PickByType<User, number>;
// { age: number; }
as 절에서 never를 반환하면 그 키가 결과에서 빠집니다. 이 성질 덕분에 값 타입을 기준으로 키를 거르는 필터를 만들 수 있습니다. 선택적 속성은 값 타입에 undefined가 섞여 string | undefined가 되므로 extends string 조건을 통과하지 못해 빠진다는 점을 주의해야 합니다. 선택적 속성까지 포함하려면 NonNullable<T[K]> extends U로 비교합니다.
예제: 타입 안전한 이벤트·상태·쿼리 빌더
예제 1: 타입 안전한 이벤트 시스템
type EventMap = {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"post:create": { postId: string; title: string };
"post:delete": { postId: string };
};
class EventEmitter<T extends Record<string, any>> {
private listeners: {
[K in keyof T]?: Array<(data: T[K]) => void>;
} = {};
on<K extends keyof T>(event: K, listener: (data: T[K]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, data: T[K]) {
const listeners = this.listeners[event];
if (listeners) {
listeners.forEach(listener => listener(data));
}
}
}
const emitter = new EventEmitter<EventMap>();
emitter.on("user:login", (data) => {
console.log(`사용자 로그인: ${data.userId}`);
});
emitter.emit("user:login", {
userId: "U001",
timestamp: new Date()
});
이벤트 이름과 데이터 모양을 EventMap 하나에 모아 두고, on과 emit의 제네릭 K가 이벤트 이름에서 데이터 타입을 찾아 연결합니다. 그래서 emitter.on("user:login", (data) => ...)의 data가 자동으로 { userId: string; timestamp: Date }가 되고, emit("post:create", { postId: "1" })처럼 title을 빠뜨리면 컴파일 에러가 납니다. 문자열 이벤트 이름의 오타도 잡힙니다. 순수 자바스크립트 이벤트 시스템에서 가장 흔한 버그가 이벤트 이름 오타로 리스너가 조용히 불리지 않는 것이라, 이 패턴의 효과가 큽니다.
K extends keyof T처럼 제네릭으로 받는 것이 핵심입니다. event: keyof T로만 쓰면 data의 타입이 모든 이벤트 데이터의 유니언이 되어 연결이 끊깁니다. 이 예제에는 리스너 해제(off)가 없어서 컴포넌트가 사라져도 리스너가 남는 메모리 누수가 생길 수 있으므로, 실제로는 on이 해제 함수를 반환하게 만드는 것이 좋습니다.
예제 2: 타입 안전한 상태 관리
type State = {
user: { name: string; email: string } | null;
posts: Array<{ id: string; title: string }>;
loading: boolean;
};
type Action<T extends keyof State> = {
type: `SET_${Uppercase<string & T>}`;
payload: State[T];
};
type Actions = {
[K in keyof State]: Action<K>;
}[keyof State];
function reducer(state: State, action: Actions): State {
switch (action.type) {
case "SET_USER":
return { ...state, user: action.payload };
case "SET_POSTS":
return { ...state, posts: action.payload };
case "SET_LOADING":
return { ...state, loading: action.payload };
default:
return state;
}
}
const initialState: State = {
user: null,
posts: [],
loading: false
};
const newState = reducer(initialState, {
type: "SET_USER",
payload: { name: "홍길동", email: "[email protected]" }
});
Actions 타입을 만드는 { [K in keyof State]: Action<K> }[keyof State] 패턴은 자주 쓰이는 관용구입니다. 먼저 키마다 액션 타입을 담은 객체 타입을 만들고, 곧바로 [keyof State]로 모든 값을 꺼내 유니언으로 만듭니다. 결과는 { type: "SET_USER"; payload: ... } | { type: "SET_POSTS"; payload: ... } | ...이라는 판별 유니언(discriminated union)이 되고, switch (action.type)의 각 case 안에서 action.payload가 해당 타입으로 좁혀집니다. Action<keyof State>처럼 한 번에 쓰면 type과 payload가 서로 독립적인 유니언이 되어, SET_USER에 boolean payload를 넣어도 통과하는 잘못된 타입이 됩니다.
default 분기에서 const _exhaustive: never = action;을 두면, 나중에 State에 새 키를 추가하고 case를 빠뜨렸을 때 컴파일 에러로 알려 줍니다. 이 망라성 검사는 판별 유니언을 쓸 때 거의 공짜로 얻을 수 있는 안전장치입니다.
예제 3: 타입 안전한 쿼리 빌더
type QueryOperator = "eq" | "ne" | "gt" | "lt" | "gte" | "lte";
type Query<T> = {
[K in keyof T]?: {
[O in QueryOperator]?: T[K];
};
};
interface User {
id: string;
name: string;
age: number;
email: string;
}
function find<T>(query: Query<T>): T[] {
return [];
}
const users = find<User>({
age: { gte: 20, lt: 30 },
name: { eq: "홍길동" }
});
필드 이름과 값의 타입이 연결되어 있어 age: { gte: "20" }이나 존재하지 않는 phone 필드는 컴파일 에러가 됩니다. 한계도 분명합니다. 모든 연산자가 모든 타입에 허용되어 name: { gt: "a" } 같은 문자열 대소 비교도 통과하고, in처럼 배열을 받는 연산자나 AND/OR 조합은 표현하지 못합니다. 연산자를 값 타입별로 제한하려면 T[K] extends number ? NumberOps<T[K]> : StringOps 같은 조건부 타입을 매핑 안에 넣으면 됩니다. Prisma의 where 타입이 이런 방식으로 만들어져 있는데, 그 정도 규모가 되면 타입 정의 자체가 수천 줄이 되고 에디터가 느려지는 대가를 치르게 됩니다. 타입의 정밀도와 유지보수성 사이에서 적당한 선을 정하는 것이 고급 타입을 쓸 때 가장 중요한 판단입니다.
DeepPartial과 DeepReadonly 직접 만들기
DeepPartial
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface User {
id: string;
profile: {
name: string;
address: {
city: string;
zipCode: string;
};
};
}
const update: DeepPartial<User> = {
profile: {
address: {
city: "서울"
}
}
};
기본 Partial은 최상위 속성만 선택적으로 만들기 때문에, 중첩 객체를 일부만 갱신하는 설정 병합이나 PATCH 요청 본문에는 부족합니다. DeepPartial은 값이 객체면 자기 자신을 재귀적으로 적용합니다.
이 간단한 구현은 object의 범위가 넓다는 점에서 함정이 있습니다. 배열, Date, Map, 함수도 모두 object에 해당해서 Date 속성은 getTime, toISOString 같은 메서드가 모두 선택적인 이상한 타입이 되고, 배열은 length와 메서드까지 선택적인 객체처럼 취급됩니다. 실제로 쓸 때는 T[K] extends Function | Date ? T[K] : T[K] extends Array<infer U> ? Array<DeepPartial<U>> : ...처럼 예외를 먼저 걸러야 합니다. 재귀 타입이 깊거나 순환 참조가 있는 타입에 적용하면 Type instantiation is excessively deep and possibly infinite 에러가 날 수 있다는 점도 기억해 둘 만합니다.
DeepReadonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
const user: DeepReadonly<User> = {
id: "U001",
profile: {
name: "홍길동",
address: {
city: "서울",
zipCode: "12345"
}
}
};
DeepReadonly로 선언한 값은 user.profile.address.city = "부산"처럼 깊은 곳을 수정해도 컴파일 에러가 납니다. 하지만 이것은 타입 검사일 뿐 런타임 보호가 아닙니다. any로 캐스팅하거나 자바스크립트 코드에서 접근하면 그대로 수정되므로, 실제로 불변성을 보장해야 한다면 Object.freeze를 재귀적으로 적용하거나 Immer 같은 라이브러리를 함께 씁니다. DeepPartial과 같은 이유로 배열과 Map은 별도 처리가 필요하며, 배열이라면 ReadonlyArray<DeepReadonly<U>>로 바꿔야 push 같은 변경 메서드가 막힙니다.
제가 고급 타입을 도입하면서 가장 자주 본 문제는 타입 자체보다 에러 메시지의 가독성입니다. 재귀 조건부 타입이 여러 겹 쌓이면 에러 메시지가 수십 줄로 펼쳐져, 정작 무엇이 틀렸는지 알기 어려워집니다. 라이브러리 경계처럼 많은 사람이 쓰는 곳에만 정교한 타입을 두고, 애플리케이션 내부 코드는 단순한 인터페이스로 유지하는 편이 팀 전체의 생산성에는 더 도움이 됩니다.
고급 타입 요약
- 조건부 타입:
T extends U ? X : Y - infer: 타입 추론 (함수, Promise 등)
- 템플릿 리터럴: 문자열 타입 조합
- 매핑 타입: 타입 변환
- 키 재매핑:
as키워드
고급 패턴 활용
- 타입 안전한 이벤트 시스템
- 상태 관리 타입
- 쿼리 빌더 타입
- 유틸리티 타입 확장
다음 단계
같이 보면 좋은 글
- Kotlin 고급 기능 | DSL, 리플렉션, 애노테이션
- Python 데코레이터
- TypeScript 시작하기 | 설치, 설정, 기본 문법
- TypeScript 고급 타입 | Union, Intersection, Literal 타입
- TypeScript 인터페이스
- TypeScript 데코레이터
- TypeScript 실전 프로젝트 | REST API 서버 만들기
- Advanced TypeScript: Conditional Types, infer, Template Literal and Mapped Types
자주 묻는 질문 (FAQ)
Q. 중첩 조건부 타입에서 분기 순서가 결과에 영향을 주나요?
A. 영향을 줍니다. 중첩 조건부 타입은 위에서부터 차례로 extends를 검사하고 처음 맞는 분기의 결과를 반환하기 때문에, 더 넓은 조건을 앞에 두면 좁은 조건의 분기에는 도달하지 못합니다. 예를 들어 TypeName 예제에서 T extends object 같은 넓은 검사를 T extends Function보다 먼저 두면 함수 타입도 “object”로 분류됩니다. 구체적인 조건을 앞에, 어느 분기에도 맞지 않을 때의 기본값을 마지막에 두는 것이 원칙입니다.