Published on

ES2024+ Set 메서드로 교집합·차집합 구현하기

Authors

서버·프론트 어디서든 “두 목록의 공통 항목만 뽑기(교집합)”, “A에는 있는데 B에는 없는 항목만 뽑기(차집합)”는 흔한 요구입니다. 예전에는 Set을 만들고 filter로 순회하거나, 직접 루프를 돌며 결과를 쌓는 방식이 일반적이었습니다.

ES2024+에서는 Set에 집합 연산을 위한 메서드들이 추가되면서, 이런 로직을 훨씬 선언적으로 작성할 수 있게 됐습니다. 이 글에서는 교집합·차집합을 중심으로, 새 메서드의 사용법과 기존 구현 대비 장점/주의점을 실전 관점에서 정리합니다.

참고: 브라우저/런타임 지원이 아직 완전히 균일하지 않을 수 있으니, 프로덕션에서는 타깃 환경에 따라 폴리필 또는 트랜스파일 전략을 함께 고려하세요.

ES2024+ Set 집합 연산 메서드 개요

ES2024+의 Set에는 다음과 같은 집합 연산 메서드들이 도입되었습니다.

  • intersection(other): 교집합
  • difference(other): 차집합(좌측 기준)
  • union(other): 합집합
  • symmetricDifference(other): 대칭차집합
  • isSubsetOf(other), isSupersetOf(other), isDisjointFrom(other): 관계 판별

이 글의 핵심은 intersectiondifference입니다. 둘 다 새로운 Set을 반환하며, 원본 Set을 변경하지 않는(immutable 스타일) 점이 중요합니다.

교집합: intersection으로 공통 원소만 추출

기본 사용법

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

const common = a.intersection(b);
console.log(common); // Set { 3, 4 }

교집합은 “A와 B 모두에 존재하는 원소”만 남깁니다. 기존에는 다음처럼 작성하곤 했습니다.

const commonLegacy = new Set([...a].filter((x) => b.has(x)));

intersection을 쓰면 의도가 더 명확해지고, 실수 여지가 줄어듭니다.

배열 입력도 가능할까?

새 메서드들은 보통 Set뿐 아니라 Set-like(반복 가능한 컬렉션) 을 인자로 받도록 설계되었습니다. 즉, otherSet이 아니어도 이터러블이면 동작하는 경우가 많습니다.

const a = new Set(["read", "write", "admin"]);
const common = a.intersection(["write", "delete"]);
console.log([...common]); // ["write"]

다만 런타임별 구현 차이가 있을 수 있으니, 안정성을 최우선으로 한다면 othernew Set(other)로 명시 변환하는 습관도 좋습니다.

차집합: difference로 “A에서 B를 뺀” 결과 만들기

기본 사용법

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

const onlyA = a.difference(b);
console.log(onlyA); // Set { 1, 2 }

difference좌측 기준입니다. 즉 a.difference(b)a에는 있지만 b에는 없는 원소를 반환합니다.

권한/기능 플래그에서 유용한 패턴

예를 들어 사용자에게 부여된 권한 집합에서 “금지된 권한”을 제거해 최종 권한을 만들고 싶다면, 차집합이 직관적입니다.

const granted = new Set(["read", "write", "admin"]);
const forbidden = new Set(["admin"]);

const effective = granted.difference(forbidden);
console.log([...effective]); // ["read", "write"]

이런 로직은 인증/인가(OAuth2, RBAC)와 맞물려 자주 등장합니다. 토큰/인가 오류를 점검하는 체크리스트가 필요하다면 OAuth2 PKCE에서 invalid_grant 뜰 때 7가지 점검도 함께 참고해 두면 실무에서 도움이 됩니다.

기존 구현 대비 장점: 가독성, 안정성, 실수 방지

1) 의도가 코드에 그대로 드러난다

filter 기반 구현은 “배열로 펼친 뒤 필터링하고 다시 Set으로 감싼다”는 기계적 단계가 노출됩니다. 반면 intersection, difference는 함수명 자체가 요구사항을 설명합니다.

2) 중간 배열 생성 최소화

레거시 방식은 종종 ...[set]로 배열을 만들고 filter를 돌립니다. 원소 수가 많아질수록 중간 배열 비용이 무시하기 어려워집니다.

새 메서드는 내부적으로 더 효율적인 경로를 사용할 여지가 있고, 무엇보다 “불필요한 펼치기”를 습관적으로 하지 않게 만들어 줍니다.

3) 불변 스타일로 예측 가능성 증가

a.intersection(b)a를 바꾸지 않습니다. 상태 공유가 많은 프론트 상태 관리나 서버 캐시 로직에서, 원본 변경으로 인한 버그를 줄이는 데 유리합니다.

성능 관점: 큰 집합에서의 체크 비용 줄이기

교집합/차집합의 핵심 비용은 결국 has 조회입니다. Set의 평균 조회는 O(1)로 기대할 수 있어, 큰 데이터에서도 배열 includes보다 유리합니다.

다만 “어느 쪽을 기준으로 순회하느냐”는 중요합니다.

  • 교집합: 더 작은 집합을 순회하면서 큰 집합에서 has로 확인하는 편이 유리
  • 차집합: 기준이 되는 좌측 집합을 순회하면서 우측에서 has 확인

새 메서드가 내부적으로 이 최적화를 얼마나 적용하는지는 엔진 구현에 따라 달라질 수 있습니다. 극단적으로 큰 데이터라면 간단한 벤치마크를 붙여 확인하는 편이 안전합니다.

function intersectionManual(a, b) {
  // 작은 쪽을 순회
  const [small, big] = a.size <= b.size ? [a, b] : [b, a];
  const out = new Set();
  for (const x of small) if (big.has(x)) out.add(x);
  return out;
}

위처럼 “작은 쪽 우선” 최적화가 필요할 정도로 데이터가 크다면, 새 메서드로 단순화하되 성능이 목표를 충족하는지 확인하세요.

실전 예제: 이벤트 스트림에서 중복 처리 방지

서버에서 이벤트를 폴링하거나 컨슈밍할 때, “이미 처리한 ID”와 “이번 배치에서 수신한 ID”의 관계를 계산하는 일이 많습니다.

  • 이번 배치에서 새로 들어온 것만 처리: incoming.difference(processed)
  • 이번 배치 중 이미 처리된 것만 추출(중복 감지): incoming.intersection(processed)
const processed = new Set(["e1", "e2", "e3"]);
const incoming = new Set(["e3", "e4", "e5"]);

const duplicates = incoming.intersection(processed); // e3
const fresh = incoming.difference(processed);        // e4, e5

console.log([...duplicates], [...fresh]);

이런 중복/정확히-한번(exactly-once) 처리 문제는 메시징에서도 빈번합니다. 카프카 기반에서 exactly-once가 깨질 때의 패턴은 Kafka Exactly-Once 깨질 때 Outbox 패턴 구현 글이 좋은 보완재가 됩니다.

호환성 및 배포 전략: 폴리필, 트랜스파일, 점진 적용

Set 메서드는 최신 런타임에서 점진적으로 지원됩니다. 팀/서비스 환경이 다양하다면 다음 전략을 고려하세요.

  1. 서버(Node.js) 우선 적용

서버는 런타임을 비교적 통제하기 쉬워 최신 기능을 빠르게 도입하기 좋습니다.

  1. 프론트는 타깃 브라우저 확인 후 적용

사파리 구버전 등이 타깃에 포함되면, 트랜스파일만으로는 내장 객체 메서드가 자동으로 생기지 않을 수 있습니다. 이 경우 폴리필이 필요합니다.

  1. 폴리필이 부담되면 유틸 함수로 캡슐화

프로젝트 전역에서 intersection을 직접 호출하기보다, 아래처럼 래퍼를 두면 환경별 분기나 폴리필 교체가 쉽습니다.

export function setIntersection(a, b) {
  if (typeof a.intersection === "function") return a.intersection(b);

  const bs = b instanceof Set ? b : new Set(b);
  const out = new Set();
  for (const x of a) if (bs.has(x)) out.add(x);
  return out;
}

export function setDifference(a, b) {
  if (typeof a.difference === "function") return a.difference(b);

  const bs = b instanceof Set ? b : new Set(b);
  const out = new Set();
  for (const x of a) if (!bs.has(x)) out.add(x);
  return out;
}

이 방식은 점진 도입에 특히 유리합니다. 호출부는 최신 메서드처럼 읽히고, 런타임이 지원하면 자동으로 네이티브 구현을 사용합니다.

자주 하는 실수와 주의점

1) 객체 원소는 “값”이 아니라 “참조”로 비교된다

Set은 객체를 담을 수 있지만, 동일성은 구조적 동등성이 아니라 참조 동일성입니다.

const a = new Set([{ id: 1 }]);
const b = new Set([{ id: 1 }]);

const common = a.intersection(b);
console.log(common.size); // 0

객체 기반 교집합이 필요하면, id 같은 키를 추출해 키 집합으로 연산하거나, 맵을 활용하는 방식이 필요합니다.

2) 정렬된 결과가 필요하면 별도 정렬이 필요

Set은 삽입 순서를 유지하지만, 교집합/차집합 결과의 순서가 업무 요구사항(예: 숫자 오름차순)과 일치한다는 보장은 없습니다. 필요하면 배열로 변환 후 정렬하세요.

const sorted = [...a.intersection(b)].sort((x, y) => x - y);

3) 타입스크립트에서의 타입 추론

TS가 최신 lib 정의를 포함하지 않으면 intersection 자체가 타입 에러로 보일 수 있습니다. 이 경우 TS 버전 및 lib 설정을 올리거나, 앞서 제시한 래퍼 함수를 통해 타입을 통제하는 편이 깔끔합니다.

정리

ES2024+의 Set 집합 연산 메서드는 교집합·차집합 같은 흔한 로직을 “의미 그대로” 표현하게 해 줍니다.

  • 교집합: a.intersection(b)
  • 차집합: a.difference(b)
  • 원본 불변, 중간 배열 생성 습관 감소, 의도 명확
  • 대규모 데이터에서는 기준 집합 선택 및 간단 벤치마크로 확인
  • 호환성 이슈가 있으면 래퍼 함수로 점진 적용

프로젝트에서 목록 비교 로직이 반복된다면, 먼저 유틸로 캡슐화한 뒤 런타임 지원이 확보되는 시점에 네이티브 메서드로 자연스럽게 넘어가는 전략이 가장 안전하고 비용 대비 효과가 큽니다.