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>>;

이 정의는 다음과 같이 동작합니다:

  1. keyof T로 T의 모든 키를 가져옴
  2. Exclude<keyof T, K>로 K를 제외한 나머지 키만 남김
  3. 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: 언제 무엇을 사용할까?

PickOmit은 반대 개념입니다. 상황에 따라 더 간결한 쪽을 선택하세요.

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 코드를 작성하시길 바랍니다!


참고 자료

공식 문서

추가 학습 자료

다음에 읽을 글

댓글