[번역] 모든 프론트엔드 엔지니어가 알아야 할 TypeScript 패턴
Soshy·

TypeScript는 기능이 조금 더 많은 린터가 아닙니다. 설계 도구입니다. 그렇게 써야만 합니다.
이 글에서는 다음 주제들을 다룹니다. 교과서적 정의를 넘어선 제네릭, 불가능한 상태를 원천 차단하는 판별 유니온(Discriminated Unions), 실제 프로덕션에서 가치 있는 유틸리티 타입, 통제할 수 없는 데이터를 위한 타입 가드, 문서를 대체하는 좋은 타입 설계, 그리고 사용자가 완전한 인텔리센스(full intellisense)를 누릴 수 있도록 공개 SDK를 타이핑하는 방법입니다.
TypeScript를 사용하는 개발자 대부분은 실제 기능의 30% 정도만 활용합니다. 함수 파라미터에 타입을 붙이고, 인터페이스 하나둘 작성하고, 그걸로 충분하다고 생각합니다. 코드가 컴파일되고 빨간 밑줄이 사라지면 다 된 것처럼 느껴지니까요.
하지만 규모가 커지면 그걸로는 부족합니다.
저는 1,000만 명 이상의 사용자를 보유한 여러 tier-0 OTT 서비스의 코드베이스에서 일했습니다. 그 규모에서는 타입 시스템을 빠져나간 런타임 버그가 단순한 버그가 아닙니다 — 그건 사고입니다. 그런 사고를 피하는 팀은 테스트를 가장 많이 작성하는 팀이 아닙니다. 타입 설계를 잘 해서 특정 범주의 버그를 애초에 작성할 수 없게 만드는 팀입니다.
이 글은 바로 그 이야기입니다.
제네릭 - 교과서적 정의를 넘어서
모든 TypeScript 튜토리얼은 항등 함수(identity function)로 제네릭을 설명합니다. T를 받아서 T를 반환합니다. 훌륭합니다. 그렇다면 이걸 어떻게 활용할 수 있을까요?
실제로 제네릭은 타입 정보를 버리지 않으면서도 재사용 가능하고 타입 안전한 유틸리티를 작성하는 방법입니다.
실제 예시를 보겠습니다. 모든 응답을 envelope 패턴으로 감싸는 API 레이어가 있다고 가정해 보겠습니다.
interface ApiResponse<T> {
data: T
error: string | null
status: number
}
async function apiFetch<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url)
const json = await response.json()
return {
data: json.data as T,
error: json.error ?? null,
status: response.status
}
}
apiFetch는 제네릭 함수라서 호출할 때 타입을 지정하면 반환값의 타입이 자동으로 결정됩니다.
interface User {
id: string
email: string
plan: 'free' | 'premium'
}
const result = await apiFetch<User>('/api/user')
// result.data는 User로 타이핑됨, any가 아님
// result.data.plan은 'free' | 'premium', string이 아님
제네릭 없이 이걸 구현하려면 any를 쓰거나, 호출하는 곳마다 타입 단언을 반복적으로 붙여야 합니다. 제네릭을 쓰면 래퍼를 한 번만 작성하고 타입 정보가 계속 흐르도록 유지할 수 있습니다.
제약이 있는 제네릭(constrained generics)에서 이 개념은 더욱 유용해집니다.
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { id: '123', email: 'user@example.com', plan: 'premium' }
const email = getProperty(user, 'email') // string으로 타이핑됨
const plan = getProperty(user, 'plan') // string으로 타이핑됨, any가 아님
K extends keyof T는 key 파라미터를 객체에 실제로 존재하는 키로만 제한합니다. 키 이름에 오타가 있으면 런타임이 아닌 컴파일 타임에 잡아냅니다.
판별 유니온 - 불가능한 상태를 불가능하게 만들기
복잡한 상태를 다룰 때 제가 가장 자주 찾는 패턴이고, 실제로 런타임 버그를 가장 많이 막아주는 패턴입니다.
UI에는 로딩, 성공, 에러 같은 여러 상태가 존재하는데, 많은 경우 이를 불리언 플래그 조합으로 표현하곤 합니다. 이 패턴은 이러한 문제를 해결합니다.
// 문제가 있는 방식
interface RequestState {
isLoading: boolean
data: User | null
error: string | null
}
isLoading: true이면서 동시에 data: User인 상태를 막을 수 없습니다. error: 'something'이면서 동시에 data: User인 상태도 마찬가지입니다. 의미 없는 조합이지만 타입 시스템은 허용하고, 결국 누군가가 그 불가능한 상태를 건드리는 코드를 작성하게 됩니다.
판별 유니온은 불가능한 상태 자체를 표현할 수 없게 만듭니다.
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: string }
이제 data는 success 상태에만 존재합니다. error는 error 상태에만 존재합니다. 의미 없는 조합은 타입 시스템 안에 존재하지 않습니다.
이 상태를 소비할 때 TypeScript는 완전한 처리(exhaustive handling)를 강제합니다.
function renderState(state: RequestState) {
switch (state.status) {
case 'idle':
return null
case 'loading':
return <Spinner />
case 'success':
return <UserProfile user={state.data} /> // state.data는 User로 타이핑됨
case 'error':
return <ErrorMessage message={state.error} /> // state.error는 string으로 타이핑됨
}
}
유니온에 새로운 status를 추가하고 switch에서 처리하는 걸 빠뜨리면 TypeScript가 알려줍니다. 프로덕션이 아닌 컴파일 타임에요.
저는 이 패턴을 의미 있는 상태 전환이 있는 모든 곳에 씁니다 — 인증 상태, 결제 플로우 단계, 구독 등급, 비디오 플레이어 상태 등. "이것과 저것이 동시에 참일 수 없다"를 강제해야 하는 모든 상황에서요.
유틸리티 타입 - 실제로 유용한 것들
TypeScript는 기본 유틸리티 타입을 제공합니다. 제가 프로덕션에서 자주 쓰는 것들과 그 이유를 소개합니다.
Partial과 Required
interface UserConfig {
theme: 'light' | 'dark'
language: string
autoplay: boolean
}
// 모든 필드 선택적 — 업데이트 페이로드에 유용
function updateUserConfig(updates: Partial<UserConfig>) {
// ...
}
// 모든 필드 필수 — 완전성을 강제해야 할 때 유용
function initializeConfig(config: Required<UserConfig>) {
// ...
}
Pick과 Omit
interface User {
id: string
email: string
password: string
plan: 'free' | 'premium'
createdAt: Date
}
// 필요한 것만 노출
type PublicUser = Omit<User, 'password'>
// 더 큰 타입에서 필요한 부분만 추출
type UserSummary = Pick<User, 'id' | 'email' | 'plan'>
Omit과 Pick은 타입 정의를 중복하지 않고 내부 타입의 일부만 외부에 노출할 때, 특히 SDK 설계에서 유용합니다.
ReturnType과 Parameters
async function fetchUserEntitlements(userId: string, platform: string) {
// 복잡한 타입을 반환
}
// 반환 타입을 반복하지 않고 추출
type Entitlements = Awaited<ReturnType<typeof fetchUserEntitlements>>
// 파라미터 타입 추출
type FetchParams = Parameters<typeof fetchUserEntitlements>
// [userId: string, platform: string]
타입 정의를 직접 제어할 수 없는 서드파티 함수를 다룰 때 특히 유용합니다.
Record
type PlatformConfig = Record<string, {
apiUrl: string
clientId: string
features: string[]
}>
const configs: PlatformConfig = {
'platform-a': { apiUrl: 'https://...', clientId: 'abc', features: ['auth', 'payments'] },
'platform-b': { apiUrl: 'https://...', clientId: 'xyz', features: ['auth'] }
}
값의 형태를 미리 알고 있다면 인덱스 시그니처보다 Record가 더 깔끔합니다.
타입 가드 - 통제할 수 없는 데이터 다루기
API 응답, localStorage 값, 사용자 입력은 애플리케이션 경계에서 unknown이나 any로 들어옵니다. 타입 가드를 쓰면 이를 안전하게 좁힐 수 있습니다.
interface User {
id: string
email: string
plan: 'free' | 'premium'
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'email' in value &&
'plan' in value &&
(value as User).plan === 'free' || (value as User).plan === 'premium'
)
}
const response = await fetch('/api/user').then(res => res.json())
if (isUser(response)) {
// 이 블록 안에서 TypeScript는 response가 User임을 알고 있음
console.log(response.plan)
}
규모가 커지면 이 가드들은 공유 검증 레이어에 모아야 합니다. 외부 소스에서 읽는 코드는 어디서든 데이터를 사용하기 전에 타입 가드를 통과시켜야 합니다. 프로덕션에서 엣지 케이스 데이터로 발생하는 "undefined의 프로퍼티" 오류 범주 전체를 이렇게 예방할 수 있습니다.
더 복잡한 검증에는 zod 같은 라이브러리가 유용합니다. 스키마를 정의하고 거기서 TypeScript 타입을 파생시킬 수 있습니다.
import { z } from 'zod'
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
plan: z.enum(['free', 'premium'])
})
type User = z.infer<typeof UserSchema>
// 런타임에 검증하고 타입을 좁힘
const user = UserSchema.parse(apiResponse)
스키마는 단일 진실의 원천(single source of truth)입니다. 타입은 거기서 파생되므로 항상 동기화 상태를 유지합니다.
문서로서의 타입
TypeScript를 사용하는 엔지니어와 TypeScript를 이해하는 엔지니어를 나누는 건 사고방식의 차이입니다.
잘 타이핑된 함수 시그니처는 주석 하나 없이도 무엇을 하는지, 무엇을 받는지, 무엇을 반환하는지 전부 알려줍니다.
// 이 함수는 무엇을 하나요? 무엇을 반환하나요? 전혀 알 수 없습니다.
function process(data: any, options: any): any
// 이것은 전체 이야기를 담고 있습니다
async function authenticateUser(
credentials: UserCredentials,
options?: {
rememberDevice?: boolean
sessionDuration?: 'short' | 'long'
}
): Promise<AuthResult>
좋은 타입 위에 JSDoc 주석을 더하면 예시와 엣지 케이스를 보완할 수 있습니다. 하지만 함수가 무엇을 받고 무엇을 반환하는지, 그 계약은 타입이 정의합니다. 타입이 올바르면 주석은 선택 사항입니다. 타입이 잘못되었거나 없으면 주석이 아무리 많아도 다음 개발자를 구할 수 없습니다.
대규모 팀에서 좋은 타입은 회의 없이 팀 경계를 넘어 의도를 전달하는 수단입니다. 인증 모듈을 사용하는 개발자는 구현을 읽지 않아도 무엇을 전달하고 무엇을 돌려받는지 알 수 있어야 합니다. 타입이 그걸 알려줘야 합니다.
공개 SDK 타이핑 - 대부분의 TypeScript 글이 건너뛰는 부분
위의 내용은 모두 애플리케이션 코드에 해당합니다. 하지만 다른 개발자들이 설치하고 의존하는 SDK를 만든다면 위험 부담이 훨씬 커집니다. 당신이 작성한 타입이 그들의 IDE 경험이 됩니다.
타입을 명시적으로 내보내기
SDK가 노출하는 타입을 사용자들이 스스로 파악하게 만들면 안 됩니다. SDK를 올바르게 쓰는 데 필요한 타입은 전부 내보내야 합니다. (사용자가 import해서 쓸 수 있게 해야 합니다.)
// types.ts — 공개 타입 표면
export interface AuthConfig {
clientId: string
/** 기본값은 'memory'. SSR 환경에서는 'cookie'를 사용하세요. */
tokenStorage?: 'memory' | 'cookie'
onTokenRefresh?: (token: string) => void
}
export interface AuthResult {
user: User
accessToken: string
expiresAt: number
}
export interface User {
id: string
email: string
plan: 'free' | 'premium'
}
// index.ts — 공개 API
export type { AuthConfig, AuthResult, User } from './types'
export { authenticate, logout, getUser } from './auth'
개발자가 SDK를 설치하고 authenticate(를 입력했을 때, IDE가 함수가 기대하는 것을 정확히 보여줄 수 있어야 합니다. 타입이 내보내지고 함수 시그니처가 타입을 참조할 때만 가능한 일입니다.
공개 API에 any 쓰지 않기
애플리케이션 코드에서 any는 자신에게 지는 빚입니다. SDK 공개 API에서 any는 설치하는 모든 팀에게 부과하는 빚입니다.
// ❌ 모든 사용자의 코드베이스에 any를 퍼뜨림
function authenticate(config: any): Promise<any>
// ✅ 사용자에게 완전한 타입 안전성을 제공
function authenticate(config: AuthConfig): Promise<AuthResult>
아직 형태를 모른다면 unknown을 쓰고 사용자가 직접 좁혀야 한다고 문서화하면 됩니다. 솔직한 방식입니다. any는 다운스트림의 모든 사람에게 조용히 TypeScript를 꺼버립니다.
타입 변경은 브레이킹 체인지
대부분의 SDK 유지보수자들이 어렵게 배우는 교훈입니다.
내보낸 타입에서 필드를 제거하거나, 프로퍼티 이름을 바꾸거나, 함수 반환 타입을 변경하면 — 그건 브레이킹 체인지입니다. 업그레이드 후 사용자 코드가 컴파일되지 않습니다.
타입 변경을 API 변경과 동일한 기준으로 다뤄야 합니다. 브레이킹 타입 변경은 메이저 버전 업, 제거 전에는 deprecation 주석을 먼저 달아야 합니다.
interface AuthConfig {
clientId: string
/** @deprecated tokenStorage를 사용하세요. v4.0에서 제거될 예정입니다. */
storageStrategy?: 'memory' | 'cookie'
tokenStorage?: 'memory' | 'cookie'
}
@deprecated JSDoc 태그는 대부분의 IDE에서 취소선으로 표시됩니다. 릴리스 노트를 읽지 않아도 사용자들이 바로 알 수 있습니다.
패키지에 d.ts 파일 포함하기
tsconfig.json에 선언 파일 생성 옵션이 있는지 확인해야 합니다.
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist/types",
"outDir": "./dist"
}
}
그리고 package.json이 해당 파일을 가리켜야 합니다.
{
"main": "./dist/index.js",
"types": "./dist/types/index.d.ts"
}
이게 없으면 TypeScript 사용자들은 아무런 타입 정보도 얻지 못합니다. 이게 있으면 완전한 인텔리센스, 인라인 문서, 컴파일 타임 안전성을 얻습니다. 개발자 경험이 완전히 달라집니다.
마치며
TypeScript의 가치는 오타를 잡는 데 있지 않습니다. 시스템의 규칙을 컴파일러에 인코딩해서, 그 규칙을 어기는 것이 프로덕션 인시던트가 아닌 빌드 에러가 되도록 하는 데 있습니다.
판별 유니온은 불가능한 상태를 표현할 수 없게 만듭니다. 제네릭은 유틸리티 전반에 타입 정보가 흐르도록 유지합니다. 타입 가드는 외부 데이터가 시스템에 진입하는 경계를 지킵니다. 잘 타이핑된 공개 API는 당신의 코드에 의존하는 개발자들에게 줄 수 있는 가장 가치 있는 것 중 하나입니다.
규모가 커질수록, 좋은 타입을 작성하는 건 선택이 아닙니다. 대규모 팀이 서로의 코드를 망가뜨리지 않으면서 빠르게 움직이려면 타입 설계가 탄탄해야 합니다.
TypeScript를 설계 도구로 활용하세요.