- Published on
ES2024+ Set 메서드로 중복제거·타입추론 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 출처(검색 결과, 추천 피드, 이벤트 로그)에서 들어온 데이터는 거의 항상 “중복 제거 + 합치기”를 거칩니다. 예전에는 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)
중복 제거 관점에서 특히 유용한 건 union과 difference입니다. “합치되 중복은 제거”는 집합의 합집합이기 때문입니다.
주의할 점은 이 메서드들이 원본 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으로 중복 제거를 설계하는 기준
“값”의 중복 제거는
Set이 최적이고, ES2024+부터는union/difference로 의도를 더 잘 드러낼 수 있습니다.“객체”의 중복 제거는
Map이 중심이고,Set은 대상 선정(합집합/차집합/교집합) 같은 집합 로직을 담당하게 분리하면 코드가 깔끔해집니다.TypeScript에서는
as const,const제네릭, 그리고 필요 시Set<Key>처럼 타입을 초기에 고정해 리터럴 유니온을 보존하는 것이 타입 추론 최적화의 핵심입니다.성능은
Set자체보다 “배열Set변환을 몇 번 하느냐”가 좌우합니다.Set에서 연산을 끝내고 마지막에만 배열로 내리세요.
이 기준으로 리팩터링하면, 중복 제거 로직이 단순해질 뿐 아니라 “왜 이 코드가 맞는지”가 코드 자체에 남아 리뷰와 유지보수 비용이 크게 줄어듭니다.