Published on

ES2024 Set 메서드와 타입가드로 유니온 좁히기

Authors

서버/프론트 모두에서 “허용된 값인지 확인하고 분기한다”는 코드는 끝없이 등장합니다. 예를 들어 API 파라미터의 status가 특정 값 집합에 속하는지 검사하거나, 이벤트 타입에 따라 페이로드를 다르게 처리하는 경우가 그렇습니다.

이때 흔히 Array.includes로 멤버십 체크를 하고, 타입은 as로 눌러버리거나(위험), 복잡한 if 체인으로 좁히곤 합니다. ES2024의 Set 신규 메서드(union, intersection, difference, symmetricDifference, isSubsetOf, isSupersetOf, isDisjointFrom)는 “집합” 문제를 코드로 더 명확하게 표현하게 해주고, TypeScript의 타입가드와 결합하면 유니온 좁히기를 훨씬 안전하고 읽기 좋게 만들 수 있습니다.

아래에서는 ES2024 Set 메서드의 핵심 사용법과, 실제로 유니온 타입을 좁히는 타입가드 패턴을 단계적으로 정리합니다.

Set 기반 타입가드가 유리한가

1) 의도가 명확하다

includes는 “배열에 포함되나?” 정도의 의미라면, intersection이나 isSubsetOf 같은 메서드는 “집합 연산” 의도를 직접 드러냅니다. 도메인 규칙이 집합으로 표현될 때 유지보수성이 좋아집니다.

2) 성능과 중복 제거

Set은 멤버십 체크가 평균적으로 빠르고, 중복을 자동 제거합니다. 특히 허용 값 목록이 커지거나, 여러 번 검사하는 로직에서 이점이 큽니다.

3) 타입가드와 결합이 쉽다

TypeScript에서 타입가드는 “런타임 검사 결과를 타입 시스템에 전달”하는 장치입니다. Set.has 기반 타입가드를 만들어두면 어디서든 재사용 가능하고, as 캐스팅을 줄일 수 있습니다.

ES2024 Set 메서드 빠른 정리

ES2024에서는 Set에 아래 메서드들이 추가됩니다.

  • setA.union(setB): 합집합
  • setA.intersection(setB): 교집합
  • setA.difference(setB): 차집합(A - B)
  • setA.symmetricDifference(setB): 대칭차집합
  • setA.isSubsetOf(setB): 부분집합 여부
  • setA.isSupersetOf(setB): 상위집합 여부
  • setA.isDisjointFrom(setB): 서로소 여부

간단 예시는 다음과 같습니다.

const a = new Set(["ready", "running", "done"] as const);
const b = new Set(["running", "failed"] as const);

const u = a.union(b); // {"ready","running","done","failed"}
const i = a.intersection(b); // {"running"}
const d = a.difference(b); // {"ready","done"}
const s = a.symmetricDifference(b); // {"ready","done","failed"}

a.isSupersetOf(i); // true
new Set(["ready"]).isSubsetOf(a); // true
new Set(["x"]).isDisjointFrom(a); // true

이제 이 집합 연산을 “타입 좁히기”에 붙여보겠습니다.

패턴 1: Set.has 타입가드로 문자열 유니온 좁히기

가장 흔한 케이스는 문자열 유니온입니다.

type Status = "ready" | "running" | "done" | "failed";

const TERMINAL = new Set(["done", "failed"] as const);
type TerminalStatus = (typeof TERMINAL) extends Set<infer T> ? T : never;

위처럼 TerminalStatus를 뽑아내려는 시도는 현실적으로 잘 안 맞습니다. Set은 타입 파라미터를 노출하지 않기 때문에, infer로 원하는 리터럴 유니온을 안정적으로 얻기 어렵습니다. 그래서 실전에서는 “리터럴 유니온 소스는 as const 배열로 유지하고, 런타임 검사용으로 Set을 파생”시키는 방식을 추천합니다.

const TERMINAL_LIST = ["done", "failed"] as const;
type TerminalStatus = (typeof TERMINAL_LIST)[number];

const TERMINAL_SET: ReadonlySet<string> = new Set(TERMINAL_LIST);

function isTerminalStatus(s: Status): s is TerminalStatus {
  return TERMINAL_SET.has(s);
}

function handleStatus(s: Status) {
  if (isTerminalStatus(s)) {
    // 여기서 s는 "done" | "failed" 로 좁혀짐
    return { kind: "terminal", status: s };
  }

  // 여기서 s는 "ready" | "running" 으로 좁혀짐
  return { kind: "active", status: s };
}

핵심은 다음입니다.

  • 타입 정보는 as const 배열에서 가져온다
  • 런타임 멤버십 체크는 Set.has로 한다
  • 타입가드 함수가 유니온 좁히기의 단일 진입점이 된다

이 패턴은 API 파라미터 검증에도 그대로 적용됩니다.

패턴 2: 입력 검증(unknown)과 타입가드를 결합하기

실제로는 Status 타입으로 들어오는 게 아니라 unknown으로 들어오는 경우가 많습니다. 이때는 “유효한 Status인지”부터 확인해야 합니다.

const STATUS_LIST = ["ready", "running", "done", "failed"] as const;
type Status = (typeof STATUS_LIST)[number];

const STATUS_SET: ReadonlySet<string> = new Set(STATUS_LIST);

function isStatus(v: unknown): v is Status {
  return typeof v === "string" && STATUS_SET.has(v);
}

function parseStatus(v: unknown): Status {
  if (!isStatus(v)) {
    throw new Error("Invalid status");
  }
  return v;
}

이렇게 만들어두면, 애매한 캐스팅 없이도 파이프라인이 깔끔해집니다. Next.js API 라우트나 서버 액션에서도 특히 유용합니다. 캐시가 얽히는 문제를 다루는 글을 함께 읽으면, 입력 검증과 캐시 키 안정성의 관계를 이해하는 데 도움이 됩니다: Next.js RSC 캐시 꼬임, revalidateTag로 푸는 법

패턴 3: ES2024 Set 집합 연산으로 “허용 조합” 검증하기

유니온 좁히기는 단일 값뿐 아니라 “여러 옵션의 조합”에서도 필요합니다. 예를 들어 쿼리 파라미터 fields가 허용된 필드 집합의 부분집합인지 검사하는 경우입니다.

부분집합 검사: isSubsetOf

const ALLOWED_FIELDS = ["id", "name", "email", "createdAt"] as const;
type Field = (typeof ALLOWED_FIELDS)[number];

const ALLOWED_SET: ReadonlySet<string> = new Set(ALLOWED_FIELDS);

function isField(v: unknown): v is Field {
  return typeof v === "string" && ALLOWED_SET.has(v);
}

function parseFields(values: unknown[]): Field[] {
  const filtered = values.filter(isField);

  // 여기서 filtered는 Field[] 로 좁혀짐
  // 하지만 "유효하지 않은 값이 섞여 들어왔는지"를 엄밀히 보려면 길이 비교가 필요
  if (filtered.length !== values.length) {
    throw new Error("Invalid field in request");
  }

  // 중복 제거 및 부분집합 확인
  const reqSet = new Set(filtered);
  if (!reqSet.isSubsetOf(ALLOWED_SET)) {
    // 이론상 도달 불가지만, 방어적으로 남겨도 좋음
    throw new Error("Fields must be subset of allowed fields");
  }

  return [...reqSet];
}

여기서 포인트는 isSubsetOf가 “허용 집합의 부분집합인지”를 아주 직접적으로 표현한다는 점입니다. 예전에는 every로 풀어쓰거나, for 루프로 검사했을 겁니다.

서로소 검사: isDisjointFrom

옵션 조합에서 “동시에 올 수 없는 플래그”를 검증할 때도 집합이 깔끔합니다.

const MUTEX_FLAGS = new Set(["dryRun", "force"] as const);

type Flag = "dryRun" | "force" | "verbose";

type Request = {
  flags: Flag[];
};

function validateFlags(req: Request) {
  const s = new Set(req.flags);

  // dryRun과 force가 동시에 있으면 안 된다 같은 규칙을 표현
  const hasBoth = new Set(["dryRun", "force"]).isSubsetOf(s);
  if (hasBoth) {
    throw new Error("dryRun and force cannot be used together");
  }

  // 예: 특정 플래그는 특정 집합과 서로소여야 한다
  const forbiddenWithVerbose = new Set(["force"]);
  if (!new Set(["verbose"]).isDisjointFrom(s) && !forbiddenWithVerbose.isDisjointFrom(s)) {
    throw new Error("verbose cannot be used with force");
  }
}

이런 검증은 운영 환경에서 장애를 줄이는 데 직결됩니다. 권한/설정 조합이 꼬여 발생하는 문제를 빠르게 진단하는 관점에서는 IRSA 이슈 글도 결이 비슷합니다: EKS IRSA AccessDenied 권한 오류 빠른 해결

패턴 4: 교집합/차집합으로 “필터링 결과를 타입으로 설명”하기

ES2024 intersectiondifference는 “입력 집합에서 어떤 값들이 걸러졌는지”를 추적할 때 유용합니다. 예를 들어 로그/메트릭에서 “요청한 필드 중 허용된 필드만 통과”시키고, 나머지는 경고로 남기는 경우입니다.

const ALLOWED = new Set(["id", "name", "email"] as const);

type AllowedField = "id" | "name" | "email";

type FieldResult = {
  allowed: AllowedField[];
  rejected: string[];
};

function splitFields(input: string[]): FieldResult {
  const req = new Set(input);

  const allowedSet = req.intersection(ALLOWED);
  const rejectedSet = req.difference(ALLOWED);

  // 여기서는 런타임 결과를 반환하는 것이 목적이므로,
  // 타입은 반환 경계에서만 최소한으로 정리
  return {
    allowed: [...allowedSet] as AllowedField[],
    rejected: [...rejectedSet],
  };
}

위 코드에서 as AllowedField[]가 남는 이유는 Set 연산 결과가 타입 레벨에서 리터럴 유니온으로 보존되지 않기 때문입니다. 대신 다음처럼 “입력 자체를 타입가드로 좁힌 다음 집합 연산을 수행”하면 캐스팅을 더 줄일 수 있습니다.

const ALLOWED_LIST = ["id", "name", "email"] as const;
type AllowedField = (typeof ALLOWED_LIST)[number];
const ALLOWED_SET: ReadonlySet<string> = new Set(ALLOWED_LIST);

function isAllowedField(v: string): v is AllowedField {
  return ALLOWED_SET.has(v);
}

function splitFields2(input: string[]) {
  const allowed = input.filter(isAllowedField); // AllowedField[]
  const rejected = input.filter((x) => !isAllowedField(x)); // string[]

  // 중복 제거가 필요하면 Set을 여기서 사용
  return {
    allowed: [...new Set(allowed)],
    rejected: [...new Set(rejected)],
  };
}

정리하면, ES2024 Set 집합 연산은 “집합 결과가 필요한 로직”에 강하고, TypeScript 타입 좁히기는 “배열 필터링과 타입가드” 조합이 강합니다. 둘을 섞을 때는 각자의 장점을 살리는 구성이 좋습니다.

실전 팁: 타입가드 유틸을 표준화하기

프로젝트가 커지면 isXxx 타입가드가 흩어지기 쉽습니다. 아래처럼 재사용 가능한 유틸을 만들어두면 좋습니다.

export function makeStringEnumGuard<const T extends readonly string[]>(values: T) {
  const set: ReadonlySet<string> = new Set(values);
  return (v: unknown): v is T[number] => typeof v === "string" && set.has(v);
}

const isStatus = makeStringEnumGuard(["ready", "running", "done", "failed"] as const);

type Status = "ready" | "running" | "done" | "failed";

function demo(v: unknown) {
  if (!isStatus(v)) return;
  // v는 Status로 좁혀짐
  const s: Status = v;
  return s;
}

여기서 makeStringEnumGuard는 타입 레벨 소스(values)와 런타임 소스(Set)를 한 번에 묶어주기 때문에, “허용 값 추가/삭제”가 생겨도 한 곳만 수정하면 됩니다.

호환성과 트랜스파일 고려사항

  • ES2024 Set 메서드는 비교적 최신 런타임에서 동작합니다. 브라우저/Node 버전 정책에 따라 폴리필이 필요할 수 있습니다.
  • TypeScript는 타입 시스템이므로, 런타임 메서드 존재 여부는 별개입니다. 즉, 타입은 맞는데 런타임에서 intersection이 없으면 터집니다.
  • 번들러 환경이라면 core-js 등 폴리필 전략을 검토하세요.

서버리스/컨테이너 환경에서 런타임 버전 차이로 장애가 나는 경우가 많습니다. 예를 들어 Cloud Run에서 런타임/리소스 설정이 성능과 안정성에 미치는 영향은 다음 글과도 연결됩니다: GCP Cloud Run 503·콜드스타트 줄이는 설정 7가지

결론: “집합은 Set, 타입은 타입가드”로 역할 분리

  • ES2024 Set 메서드는 합집합/교집합/부분집합 같은 도메인 규칙을 코드로 정확히 표현하게 해줍니다.
  • TypeScript 유니온 좁히기는 Set.has 기반 타입가드가 가장 실용적입니다.
  • 타입 소스는 as const 배열로 유지하고, 런타임 검사는 Set으로 파생하는 구성이 안정적입니다.

이 조합을 표준화해두면, 입력 검증·권한/옵션 조합 검증·이벤트 라우팅 같은 영역에서 as 캐스팅을 줄이고, 로직을 더 선언적으로 만들 수 있습니다.