TypeScript Omit - 필요한 것만 남기는 타입 설계
이런 상황을 경험해보신 적 있나요?
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
lastLoginAt: Date;
}
// API 응답으로 사용자 정보를 보낼 때...
function getUserProfile(userId: number): User {
const user = db.findUser(userId);
return user; // ⚠️ password까지 전부 노출!
}
사용자 정보를 API로 반환할 때, 비밀번호나 내부 메타데이터까지 노출되는 건 보안상 위험합니다. 그렇다고 매번 새로운 인터페이스를 만들자니 코드 중복이 너무 많아지죠.
// ❌ 이렇게 중복 코드를 작성해야 할까요?
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
lastLoginAt: Date;
}
interface UserProfile {
id: number;
name: string;
email: string;
// password, createdAt, updatedAt, lastLoginAt 제외
}
interface UserPublicInfo {
id: number;
name: string;
// email, password, createdAt, updatedAt, lastLoginAt 제외
}
처음 TypeScript를 배울 때, 이런 식으로 중복된 인터페이스를 여러 개 만들곤 했습니다. 하지만 원본 User 인터페이스가 변경될 때마다 모든 파생 인터페이스를 일일이 수정해야 했고, 실수로 속성 하나를 빠뜨려서 버그가 발생한 적도 있었어요.
TypeScript의 Omit 유틸리티 타입을 알게 된 후, 이런 문제를 훨씬 우아하게 해결할 수 있게 되었습니다.
// ✅ Omit을 사용하면 간단합니다
type UserProfile = Omit<User, 'password' | 'createdAt' | 'updatedAt' | 'lastLoginAt'>;
이 글에서는 Omit이 무엇인지, 왜 필요한지, 그리고 실무에서 어떻게 활용하는지를 깊이 있게 다룹니다.
먼저, 기초부터 이해하기: 타입 변환이란?
Omit을 이해하기 전에, 타입 변환(Type Transformation)의 개념을 먼저 알아야 합니다.
왜 타입 변환이 필요한가?
실제 애플리케이션에서는 하나의 데이터 모델이 여러 형태로 사용됩니다.
예를 들어, User 엔티티는:
[데이터베이스] → User (모든 필드 포함)
↓
[변환 과정]
↓
┌──────────────┼──────────────┐
↓ ↓ ↓
[API 응답] [생성 요청] [업데이트 요청]
UserProfile CreateUserInput UpdateUserInput
(민감정보 제외) (id 제외) (id 제외, 선택적)
각 상황마다 필요한 필드가 다릅니다:
- API 응답: 비밀번호, 내부 메타데이터 제외
- 생성 요청: id, createdAt 같은 시스템 생성 필드 제외
- 업데이트 요청: id 제외, 나머지 필드는 선택적
이때 타입 변환 유틸리티를 사용하면, 원본 타입을 기반으로 필요한 형태의 새 타입을 만들 수 있습니다.
TypeScript의 타입 변환 도구들
TypeScript는 타입 변환을 위한 여러 유틸리티 타입을 제공합니다:
Pick<T, K>: 특정 속성만 선택Omit<T, K>: 특정 속성만 제외Partial<T>: 모든 속성을 선택적으로Required<T>: 모든 속성을 필수로Readonly<T>: 모든 속성을 읽기 전용으로
이 중에서 Omit은 “이것만 빼고 나머지 다 주세요”라는 상황에서 사용합니다.
Omit이란 무엇인가?
정의
Omit<Type, Keys>는 기존 타입에서 특정 속성들을 제거한 새로운 타입을 만듭니다.
TypeScript 내부 구현을 보면:
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
이 정의는 다음과 같이 동작합니다:
keyof T로 T의 모든 키를 가져옴Exclude<keyof T, K>로 K를 제외한 나머지 키만 남김Pick<T, ...>으로 남은 키들만 선택
문법
Omit<원본타입, 제외할키1 | 제외할키2 | ...>
매개변수:
Type: 원본 타입Keys: 제외할 속성의 키 (문자열 리터럴 또는 유니온)
간단한 예제
interface Person {
name: string;
age: number;
email: string;
}
// 'email'을 제외한 나머지 속성만 가진 타입
type PersonWithoutEmail = Omit<Person, 'email'>;
// 결과: { name: string; age: number; }
const person: PersonWithoutEmail = {
name: "Alice",
age: 30
// email은 없음
};
왜 Omit이 필요한가?
이유 1: 코드 중복 제거
❌ Omit 없이
interface User {
id: number;
name: string;
email: string;
password: string;
}
// 속성을 하나하나 다시 정의
interface PublicUser {
id: number;
name: string;
email: string;
// password만 제외
}
// User가 변경되면 PublicUser도 수동으로 변경해야 함
✅ Omit 사용
interface User {
id: number;
name: string;
email: string;
password: string;
}
// 원본 타입을 참조하여 생성
type PublicUser = Omit<User, 'password'>;
// User에 새 속성 추가 시 PublicUser에도 반영됨
interface User {
id: number;
name: string;
email: string;
password: string;
phoneNumber: string; // 추가됨
}
// PublicUser에도 phoneNumber가 포함됨
이유 2: 타입 안전성
❌ 타입 없이
function getUserPublicInfo(user) {
// 실수로 password를 포함할 수 있음
return {
id: user.id,
name: user.name,
email: user.email,
password: user.password // ⚠️ 보안 문제!
};
}
✅ Omit으로 타입 보호
type PublicUser = Omit<User, 'password'>;
function getUserPublicInfo(user: User): PublicUser {
return {
id: user.id,
name: user.name,
email: user.email,
password: user.password // ❌ 컴파일 오류! password는 PublicUser에 없음
};
}
// 올바른 구현
function getUserPublicInfo(user: User): PublicUser {
const { password, ...publicInfo } = user;
return publicInfo; // ✅ password 제외
}
이유 3: 유지보수 편의성
원본 타입이 변경되면 파생 타입도 따라서 업데이트됩니다.
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = Omit<User, 'password'>;
// 나중에 User에 새 속성 추가
interface User {
id: number;
name: string;
email: string;
password: string;
bio: string; // 추가
avatarUrl: string; // 추가
}
// PublicUser는 bio, avatarUrl을 포함하게 됨
// 별도 수정 불필요!
기본 사용법과 예제
예제 1: 단일 속성 제외
interface Product {
id: number;
name: string;
price: number;
cost: number; // 원가 (내부용)
}
// 원가를 제외한 공개 정보
type PublicProduct = Omit<Product, 'cost'>;
const product: PublicProduct = {
id: 1,
name: "노트북",
price: 1500000
// cost는 없음
};
예제 2: 여러 속성 제외
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
// 민감한 정보와 메타데이터 제외
type SafeUser = Omit<User, 'password' | 'createdAt' | 'updatedAt'>;
const safeUser: SafeUser = {
id: 1,
name: "Alice",
email: "alice@example.com"
// password, createdAt, updatedAt는 없음
};
예제 3: API 요청/응답 타입 분리
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
// 1. 생성 요청 (id, createdAt, updatedAt는 서버가 생성)
type CreateUserRequest = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// 2. 업데이트 요청 (id, createdAt, updatedAt 변경 불가)
type UpdateUserRequest = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
// 3. API 응답 (password 제외)
type UserResponse = Omit<User, 'password'>;
// 사용
function createUser(data: CreateUserRequest): Promise<UserResponse> {
// data: { name, email, password }
// 반환: { id, name, email, createdAt, updatedAt }
}
function updateUser(id: number, data: UpdateUserRequest): Promise<UserResponse> {
// data: { name?, email?, password? }
// 반환: { id, name, email, createdAt, updatedAt }
}
예제 4: 폼 데이터 타입
interface Article {
id: number;
title: string;
content: string;
authorId: number;
publishedAt: Date;
viewCount: number;
likes: number;
}
// 폼에서 사용자가 입력하는 데이터만
type ArticleFormData = Omit<Article, 'id' | 'publishedAt' | 'viewCount' | 'likes'>;
const formData: ArticleFormData = {
title: "TypeScript 배우기",
content: "TypeScript는...",
authorId: 1
// id, publishedAt, viewCount, likes는 서버에서 생성
};
실전 활용 사례
사례 1: 데이터베이스 엔티티 vs API 응답
실무에서 가장 흔한 패턴입니다.
// 데이터베이스 엔티티 (모든 필드 포함)
interface UserEntity {
id: number;
username: string;
email: string;
passwordHash: string;
salt: string;
role: 'user' | 'admin';
isEmailVerified: boolean;
createdAt: Date;
updatedAt: Date;
lastLoginAt: Date | null;
deletedAt: Date | null;
}
// API 응답 (민감한 정보 제외)
type UserDTO = Omit<UserEntity, 'passwordHash' | 'salt' | 'deletedAt'>;
// 공개 프로필 (더 제한적)
type PublicProfile = Omit<UserEntity,
'passwordHash' |
'salt' |
'email' |
'isEmailVerified' |
'createdAt' |
'updatedAt' |
'lastLoginAt' |
'deletedAt'
>;
// 사용
async function getUserProfile(userId: number): Promise<UserDTO> {
const user = await db.users.findOne({ id: userId });
// 명시적으로 민감한 정보 제거
const { passwordHash, salt, deletedAt, ...userDTO } = user;
return userDTO; // ✅ 타입 안전
}
function getPublicProfile(userId: number): Promise<PublicProfile> {
// username과 role만 공개
}
사례 2: React Props에서 특정 속성 제외
import React from 'react';
// 기본 버튼 Props
interface BaseButtonProps {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
className?: string;
}
// 링크 버튼 (onClick 대신 href 사용)
interface LinkButtonProps extends Omit<BaseButtonProps, 'onClick' | 'type'> {
href: string;
target?: '_blank' | '_self';
}
const LinkButton: React.FC<LinkButtonProps> = ({
children,
href,
target = '_self',
className,
disabled
}) => {
return (
<a
href={disabled ? undefined : href}
target={target}
className={className}
style={{ pointerEvents: disabled ? 'none' : 'auto' }}
>
{children}
</a>
);
};
// 사용
<LinkButton href="https://example.com" disabled={false}>
이동하기
</LinkButton>
사례 3: 상태 관리에서 읽기 전용 데이터
interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
updatedAt: Date;
}
// 사용자가 수정할 수 있는 필드만
type TodoUpdateInput = Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>;
// 상태 업데이트 함수
function updateTodo(id: string, updates: Partial<TodoUpdateInput>): void {
// updates는 title과 completed만 포함 가능
// id, createdAt, updatedAt는 변경 불가
}
// ✅ 올바른 사용
updateTodo('todo-1', { title: '새 제목' });
updateTodo('todo-1', { completed: true });
// ❌ 컴파일 오류
updateTodo('todo-1', { id: 'todo-2' }); // id는 변경 불가
updateTodo('todo-1', { createdAt: new Date() }); // createdAt는 변경 불가
사례 4: 써드파티 라이브러리 타입 확장
// 써드파티 라이브러리의 타입
interface LibraryConfig {
apiKey: string;
endpoint: string;
timeout: number;
debug: boolean;
internal_flag: boolean; // 내부용, 사용자가 설정하면 안 됨
}
// 사용자에게 노출할 설정만
type UserConfig = Omit<LibraryConfig, 'internal_flag'>;
function initializeLibrary(config: UserConfig): void {
const fullConfig: LibraryConfig = {
...config,
internal_flag: false // 항상 false로 설정
};
// 라이브러리 초기화...
}
// ✅ 올바른 사용
initializeLibrary({
apiKey: 'xxx',
endpoint: 'https://api.example.com',
timeout: 5000,
debug: true
});
// ❌ 컴파일 오류
initializeLibrary({
apiKey: 'xxx',
endpoint: 'https://api.example.com',
timeout: 5000,
debug: true,
internal_flag: true // ❌ UserConfig에 없음
});
Pick vs Omit: 언제 무엇을 사용할까?
Pick과 Omit은 반대 개념입니다. 상황에 따라 더 간결한 쪽을 선택하세요.
Pick: “이것만 선택”
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
lastLoginAt: Date;
}
// 2-3개만 필요하면 Pick이 간결
type UserIdAndName = Pick<User, 'id' | 'name'>;
// { id: number; name: string; }
Omit: “이것만 제외”
// 대부분의 속성이 필요하면 Omit이 간결
type SafeUser = Omit<User, 'password'>;
// { id, name, email, createdAt, updatedAt, lastLoginAt }
선택 기준
interface LargeObject {
field1: string;
field2: string;
field3: string;
field4: string;
field5: string;
field6: string;
field7: string;
field8: string;
field9: string;
field10: string;
}
// ✅ 2개만 필요 → Pick 사용
type Small = Pick<LargeObject, 'field1' | 'field2'>;
// ✅ 8개 필요 (2개만 제외) → Omit 사용
type Large = Omit<LargeObject, 'field9' | 'field10'>;
경험 법칙:
- 필요한 속성이 적으면 →
Pick - 제외할 속성이 적으면 →
Omit
Before & After 비교
❌ 잘못된 선택
// 8개 필요한데 Pick으로 나열
type NeedMost = Pick<LargeObject,
'field1' |
'field2' |
'field3' |
'field4' |
'field5' |
'field6' |
'field7' |
'field8'
>; // 너무 김
✅ 올바른 선택
// 2개만 제외
type NeedMost = Omit<LargeObject, 'field9' | 'field10'>; // 간결
고급 패턴
패턴 1: Omit과 다른 유틸리티 타입 조합
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
// 1. Omit + Partial: 일부 필드 제외 후 선택적으로
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
// { name?: string; email?: string; password?: string; }
// 2. Omit + Readonly: 일부 필드 제외 후 읽기 전용
type ReadonlyPublicUser = Readonly<Omit<User, 'password'>>;
// { readonly id: number; readonly name: string; ... }
// 3. Omit + Required: 일부 필드 제외 후 필수로
interface OptionalUser {
id: number;
name?: string;
email?: string;
password?: string;
}
type RequiredUserFields = Required<Omit<OptionalUser, 'id'>>;
// { name: string; email: string; password: string; }
패턴 2: 중첩된 Omit
interface User {
id: number;
name: string;
email: string;
password: string;
}
// 여러 단계로 제외
type Step1 = Omit<User, 'password'>; // password 제외
type Step2 = Omit<Step1, 'email'>; // email도 제외
// { id: number; name: string; }
// ✅ 더 간결하게: 한 번에
type DirectOmit = Omit<User, 'password' | 'email'>;
// { id: number; name: string; }
패턴 3: 타입별로 속성 제외
interface Mixed {
id: number;
name: string;
age: number;
email: string;
isActive: boolean;
createdAt: Date;
}
// Date 타입 속성만 제외하고 싶다면?
type OmitDates<T> = {
[K in keyof T as T[K] extends Date ? never : K]: T[K];
};
type WithoutDates = OmitDates<Mixed>;
// { id: number; name: string; age: number; email: string; isActive: boolean; }
패턴 4: 조건부 Omit
interface BaseUser {
id: number;
name: string;
email: string;
}
interface AdminUser extends BaseUser {
role: 'admin';
permissions: string[];
}
interface RegularUser extends BaseUser {
role: 'user';
}
// 역할에 따라 다른 필드 제외
type PublicUser<T extends BaseUser> =
T extends AdminUser
? Omit<T, 'permissions'> // Admin은 permissions 제외
: Omit<T, 'email'>; // User는 email 제외
const admin: AdminUser = {
id: 1,
name: "Admin",
email: "admin@example.com",
role: "admin",
permissions: ["read", "write"]
};
const publicAdmin: PublicUser<AdminUser> = {
id: 1,
name: "Admin",
email: "admin@example.com",
role: "admin"
// permissions는 없음
};
함정과 주의사항
❌ 함정 1: 존재하지 않는 키 제외
interface User {
id: number;
name: string;
email: string;
}
// ⚠️ 경고: 'password'는 User에 없지만 오류 없음
type WithoutPassword = Omit<User, 'password'>;
// User와 동일한 타입
// 오타가 있어도 오류 없음
type Typo = Omit<User, 'emaillll'>; // 오타지만 컴파일 통과
해결책:
TypeScript 4.5+에서는 keyof로 제한할 수 있습니다.
// 더 안전한 버전 (User의 키만 허용)
type SafeOmit<T, K extends keyof T> = Omit<T, K>;
type WithoutPassword = SafeOmit<User, 'password'>; // ❌ 오류!
type WithoutEmail = SafeOmit<User, 'email'>; // ✅ 정상
❌ 함정 2: Union 타입에 Omit 사용 시 주의
interface Dog {
name: string;
breed: string;
bark: () => void;
}
interface Cat {
name: string;
color: string;
meow: () => void;
}
type Animal = Dog | Cat;
// ⚠️ 모든 Union 멤버에서 name이 제거됨
type AnimalWithoutName = Omit<Animal, 'name'>;
// Omit<Dog, 'name'> | Omit<Cat, 'name'>
// { breed: string; bark: () => void; } | { color: string; meow: () => void; }
❌ 함정 3: 인덱스 시그니처와 함께 사용
interface Dictionary {
[key: string]: string;
name: string;
id: string;
}
// ⚠️ 인덱스 시그니처는 제거되지 않음
type WithoutName = Omit<Dictionary, 'name'>;
// { [key: string]: string; id: string; }
const dict: WithoutName = {
id: "123",
name: "여전히 추가 가능", // ⚠️ 인덱스 시그니처 때문에 허용됨
other: "값"
};
⚠️ 주의: 너무 많은 속성 제외
interface LargeType {
field1: string;
field2: string;
// ... 20개의 필드
field20: string;
}
// ❌ 가독성이 떨어짐
type Subset = Omit<LargeType,
'field1' |
'field2' |
'field3' |
'field4' |
'field5' |
'field6' |
'field7' |
'field8' |
'field9' |
'field10'
>;
// ✅ 이럴 땐 Pick을 사용하거나 새 타입 정의
type Subset = Pick<LargeType,
'field11' |
'field12' |
'field13'
>;
활용 팁
팁 1: 타입 별칭으로 재사용
공통적으로 제외하는 필드는 타입 별칭으로 정의하세요.
// 메타데이터 필드
type MetadataFields = 'createdAt' | 'updatedAt' | 'deletedAt';
// 민감한 정보 필드
type SensitiveFields = 'password' | 'passwordHash' | 'salt';
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
}
// 재사용
type UserDTO = Omit<User, SensitiveFields>;
type UserFormData = Omit<User, 'id' | MetadataFields | SensitiveFields>;
팁 2: 헬퍼 타입 만들기
프로젝트 전체에서 사용할 헬퍼 타입을 정의하세요.
// 공통 헬퍼 타입
type WithoutId<T> = Omit<T, 'id'>;
type WithoutMetadata<T> = Omit<T, 'createdAt' | 'updatedAt' | 'deletedAt'>;
type WithoutSensitive<T> = Omit<T, 'password' | 'passwordHash' | 'salt'>;
// 조합해서 사용
type CreateInput<T> = WithoutId<WithoutMetadata<T>>;
type PublicData<T> = WithoutSensitive<T>;
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
type CreateUserInput = CreateInput<User>;
// { name: string; email: string; password: string; }
type PublicUser = PublicData<User>;
// { id: number; name: string; email: string; createdAt: Date; updatedAt: Date; }
팁 3: JSDoc으로 문서화
/**
* 사용자 정보 중 API 응답에 포함할 안전한 데이터
* @description password, salt 등 민감한 정보 제외
*/
type UserDTO = Omit<UserEntity, 'passwordHash' | 'salt' | 'deletedAt'>;
/**
* 사용자 생성 요청 데이터
* @description 서버에서 생성되는 필드(id, 타임스탬프) 제외
*/
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
팁 4: 제네릭과 함께 활용
// 모든 엔티티에서 메타데이터 제외
type WithoutMetadata<T extends { createdAt: Date; updatedAt: Date }> =
Omit<T, 'createdAt' | 'updatedAt'>;
interface User {
id: number;
name: string;
createdAt: Date;
updatedAt: Date;
}
interface Post {
id: number;
title: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
type UserWithoutMetadata = WithoutMetadata<User>;
type PostWithoutMetadata = WithoutMetadata<Post>;
마치며
TypeScript의 Omit 유틸리티 타입은 불필요한 속성을 제거하여 정확한 타입을 만드는 강력한 도구입니다.
핵심 포인트:
- 코드 중복 제거: 원본 타입을 재사용하여 파생 타입 생성
- 타입 안전성: 민감한 정보나 불필요한 필드를 타입 레벨에서 제거
- 유지보수성: 원본 타입 변경 시 파생 타입도 함께 업데이트
저도 처음에는 “이렇게까지 해야 하나?”라고 생각했지만, Omit을 사용하면서:
- API 응답 타입이 안전해졌고
- 중복 코드가 크게 줄었으며
- 리팩토링이 훨씬 쉬워졌습니다
특히 실무에서 API를 설계하거나 폼 데이터를 다룰 때, Omit은 없어서는 안 될 도구가 되었어요.
실전 활용 요약:
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
// 1. API 응답
type UserResponse = Omit<User, 'password'>;
// 2. 생성 요청
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// 3. 업데이트 요청
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
// 4. 공개 프로필
type PublicProfile = Omit<User, 'password' | 'email' | 'createdAt' | 'updatedAt'>;
여러분도 Omit을 활용하여 더 안전하고 유지보수하기 쉬운 TypeScript 코드를 작성하시길 바랍니다!
참고 자료
공식 문서
- TypeScript 공식 문서 - Utility Types
- TypeScript 3.5 Release Notes - Omit helper type
- TypeScript Handbook - Mapped Types
댓글