Published on

ES2024+ Set 메서드로 중복제거·타입추론 최적화

Authors

서로 다른 출처(검색 결과, 추천 피드, 이벤트 로그)에서 들어온 데이터는 거의 항상 “중복 제거 + 합치기”를 거칩니다. 예전에는 Array.prototype.filter + indexOf 같은 패턴, 혹은 new Set(arr)로 “값”만 중복 제거하는 정도가 전부였죠.

ES2024+에서는 Set이 사실상 “집합 연산”을 표준으로 제공하면서, 중복 제거를 넘어 의도를 드러내는 코드를 만들기 쉬워졌습니다. 동시에 TypeScript 코드베이스에서는 “중복 제거를 하다 타입이 any/unknown으로 퍼지거나, 리터럴 유니온이 깨지는 문제”가 자주 발생합니다.

이 글에서는 ES2024+ Set 메서드를 활용해 중복 제거 로직을 단순화하고, TypeScript에서 타입 추론을 최적화하는 실전 패턴을 정리합니다.

참고로 Set의 교집합/차집합을 한 줄로 처리하는 내용은 아래 글에서 더 깊게 다룹니다.

ES2024+ Set에 추가된 핵심 메서드 요약

ES2024(정확히는 최근 ECMAScript 스펙에 반영된 Set methods 제안)에서 Set에는 집합 연산 메서드가 추가되었습니다.

  • union(other)
  • intersection(other)
  • difference(other)
  • symmetricDifference(other)
  • isSubsetOf(other)
  • isSupersetOf(other)
  • isDisjointFrom(other)

중복 제거 관점에서 특히 유용한 건 uniondifference입니다. “합치되 중복은 제거”는 집합의 합집합이기 때문입니다.

주의할 점은 이 메서드들이 원본 Set을 변경하지 않고 새 Set을 반환한다는 것입니다. 즉, 불변 스타일로 안전하게 조합할 수 있습니다.

기존 중복 제거 패턴의 한계

1) new Set(array)는 “원시 값”엔 좋지만 객체엔 취약

const users = [
  { id: 1, name: "A" },
  { id: 1, name: "A" },
];

const dedup = [...new Set(users)];
// 기대: id=1 하나
// 현실: 객체 레퍼런스가 달라서 2개 그대로

Set은 동일성 비교가 참조 기반이므로, 객체 중복 제거는 키 기반으로 해야 합니다.

2) filter + findIndex는 느리고 의도가 흐림

const dedup = users.filter(
  (u, i) => users.findIndex(v => v.id === u.id) === i,
);
  • 시간복잡도 O(n^2)
  • “id 기준 중복 제거”라는 의도가 코드에서 잘 안 보임
  • 타입 추론은 되더라도, 실수로 비교 키를 바꾸기 쉬움

ES2024+ union으로 “중복 제거 + 합치기”를 의도적으로 표현

두 소스에서 온 ID 목록을 합치고 중복을 제거하는 코드는 이제 이렇게 쓸 수 있습니다.

const a = new Set([1, 2, 3]);
const b = new Set([3, 4, 5]);

const merged = a.union(b); // Set(1,2,3,4,5)

이 코드는 “합집합”이라는 도메인 의도를 그대로 표현합니다. new Set([...a, ...b])보다 읽기 쉽고, 조합이 자연스럽습니다.

실전: 여러 소스 합치기

const s1 = new Set(["feed:1", "feed:2"]);
const s2 = new Set(["feed:2", "feed:3"]);
const s3 = new Set(["feed:3", "feed:4"]);

const all = s1.union(s2).union(s3);

union 체이닝은 불변 Set을 반환하므로, 중간 상태를 안전하게 다룰 수 있습니다.

객체 중복 제거: Map으로 키 정규화 후 Set 메서드로 운영

객체는 Set만으로 “키 기반 중복 제거”가 안 되므로, 흔한 해법은 Map입니다.

type User = { id: string; name: string; updatedAt: number };

function dedupByIdKeepLatest(users: readonly User[]): User[] {
  const m = new Map<string, User>();
  for (const u of users) {
    const prev = m.get(u.id);
    if (!prev || u.updatedAt > prev.updatedAt) m.set(u.id, u);
  }
  return [...m.values()];
}

그런데 여기서 한 단계 더 나아가 “중복 제거 대상”을 Set으로 운영하면, ES2024+의 집합 메서드가 더 빛을 발합니다.

패턴: id 집합을 중심으로 필터링 파이프라인 구성

type User = { id: string; name: string };

const fetched: User[] = [
  { id: "u1", name: "A" },
  { id: "u2", name: "B" },
];
const cached: User[] = [
  { id: "u2", name: "B" },
  { id: "u3", name: "C" },
];

const fetchedIds = new Set(fetched.map(u => u.id));
const cachedIds = new Set(cached.map(u => u.id));

// 합쳐서 “필요한 사용자 id” 집합을 만든다
const targetIds = fetchedIds.union(cachedIds);

// id 집합을 기준으로 실제 객체를 구성한다 (예: fetched 우선)
const byId = new Map<string, User>();
for (const u of cached) byId.set(u.id, u);
for (const u of fetched) byId.set(u.id, u);

const result = [...targetIds].map(id => byId.get(id)!).filter(Boolean);

핵심은 “객체 dedup” 자체는 Map이 담당하되, 대상 선정/차집합/교집합 같은 집합 로직은 Set 메서드로 분리한다는 점입니다. 이렇게 하면 로직이 커져도 읽기 쉬운 상태를 유지할 수 있습니다.

TypeScript 타입 추론 최적화: Set을 “리터럴 유니온”으로 유지하기

중복 제거 로직을 타입 안전하게 유지하려면, Set을 만들 때부터 리터럴 타입이 깨지지 않도록 해야 합니다.

문제: 배열이 넓은 타입으로 확장되면 Set도 넓어짐

const roles = ["admin", "user"]; // string[] 로 확장될 수 있음
const s = new Set(roles); // Set<string>

이러면 이후에 s.has("admin") 같은 체크는 되지만, "admin" | "user" 같은 좁은 타입 정보를 잃습니다.

해결 1) as const로 리터럴 유지

const roles = ["admin", "user"] as const;
const s = new Set<(typeof roles)[number]>(roles);
// Set<"admin" | "user">

여기서 Set의 타입 인자를 명시해두면, 추론이 흔들릴 때도 안정적으로 유지됩니다.

해결 2) 재사용 가능한 헬퍼로 “타입 보존 Set” 만들기

function setOf<const T extends readonly (string | number | symbol)[]>(arr: T) {
  return new Set<T[number]>(arr);
}

const s = setOf(["admin", "user"] as const);
// Set<"admin" | "user">

const 타입 파라미터를 쓰면 입력 리터럴을 최대한 보존합니다. (TypeScript 버전에 따라 동작이 달라질 수 있어, 추론이 깨질 때의 대응은 아래 글도 참고하세요.)

Set 메서드 체이닝에서 타입이 흐려질 때의 방어

union/intersection 같은 메서드는 “반환 타입이 Set<T>로 고정”되는 구현이 많습니다. 문제는 서로 다른 원소 타입을 합칠 때입니다.

const a = new Set(["a"] as const); // Set<"a">
const b = new Set(["b"] as const); // Set<"b">

const u = a.union(b);
// 이상적인 기대: Set<"a" | "b">
// 환경에 따라: Set<string> 로 넓어질 수 있음

방어 패턴: Set 생성 시점에서 유니온을 명시

type Key = "a" | "b";

const a = new Set<Key>(["a"]);
const b = new Set<Key>(["b"]);

const u = a.union(b); // Set<Key>

집합 연산을 많이 할수록 “원소 타입의 공통 상위 타입으로 넓어지는 현상”이 발생할 수 있으니, 도메인에서 허용하는 키 타입이 명확하면 초기에 고정해두는 편이 유지보수에 유리합니다.

중복 제거를 “검증 가능한 계약”으로 만들기

중복 제거는 결과가 눈에 잘 안 보이기 때문에, 테스트 가능한 계약으로 만들면 안전합니다.

예: 이메일 수신 대상에서 차집합으로 블랙리스트 제거

const recipients = new Set(["a@x.com", "b@x.com", "c@x.com"]);
const blacklist = new Set(["b@x.com"]);

const allowed = recipients.difference(blacklist);
// Set("a@x.com", "c@x.com")

이 코드는 “블랙리스트를 뺀다”는 의도가 명확하고, 테스트도 allowed.has(...)로 단순해집니다.

성능 관점: Set은 빠르지만, 변환 비용을 줄여야 한다

중복 제거를 하겠다고 배열과 Set 사이를 과도하게 왕복하면 오히려 손해일 수 있습니다.

  • 좋은 패턴: 한 번 Set으로 올린 뒤, 집합 연산은 Set에서 끝내고 마지막에만 배열로 변환
  • 나쁜 패턴: 연산마다 [...set]로 배열화하고 다시 new Set으로 감싸기

예: 비효율적인 왕복

const out = new Set([...a].concat([...b]));
const out2 = new Set([...out].filter(x => x !== "x"));

예: Set 내부에서 끝내기

const merged = a.union(b);
const out = merged.difference(new Set(["x"]));

폴리필/런타임 지원 체크 포인트

ES2024+ Set 메서드는 런타임 지원이 중요합니다.

  • Node.js 버전(또는 브라우저)이 해당 메서드를 지원하는지 확인
  • 번들러 타깃이 낮으면 트랜스파일만으로는 부족하고 폴리필이 필요할 수 있음

실무에서는 “개발 환경에서는 되는데, 특정 런타임에서 set.union is not a function” 같은 장애가 자주 납니다. 배포 타깃이 다양하면 core-js 등 폴리필 전략을 함께 검토하세요.

정리: ES2024+ Set으로 중복 제거를 설계하는 기준

  1. “값”의 중복 제거는 Set이 최적이고, ES2024+부터는 union/difference로 의도를 더 잘 드러낼 수 있습니다.

  2. “객체”의 중복 제거는 Map이 중심이고, Set은 대상 선정(합집합/차집합/교집합) 같은 집합 로직을 담당하게 분리하면 코드가 깔끔해집니다.

  3. TypeScript에서는 as const, const 제네릭, 그리고 필요 시 Set<Key>처럼 타입을 초기에 고정해 리터럴 유니온을 보존하는 것이 타입 추론 최적화의 핵심입니다.

  4. 성능은 Set 자체보다 “배열 Set 변환을 몇 번 하느냐”가 좌우합니다. Set에서 연산을 끝내고 마지막에만 배열로 내리세요.

이 기준으로 리팩터링하면, 중복 제거 로직이 단순해질 뿐 아니라 “왜 이 코드가 맞는지”가 코드 자체에 남아 리뷰와 유지보수 비용이 크게 줄어듭니다.