Set으로 중복을 제거할 때 객체가 남는 이유
Set으로 중복을 제거할 때 객체가 남는 이유
Set은 중복 제거에 편리하지만 객체 배열에 적용했을 때 기대한 결과가 나오지 않을 수 있다. 내용이 같아 보여도 서로 다른 객체는 다른 참조이기 때문이다.
- 원시값은 값 기준으로, 객체는 참조 기준으로 중복을 판단한다.
- 객체의 의미상 중복은 id나 정규화된 키를 직접 선택해야 한다.
- 어떤 항목을 남길지 정책도 함께 결정해야 한다.
본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.
목차
- #원시값과 객체의 차이
- #키를 이용한 중복 제거
- #첫 항목과 마지막 항목 중 무엇을 남길까
- #JSON 문자열을 키로 쓰면 왜 불안정할까
- #복합 키는 충돌 없이 만들어야 한다
- #첫 값, 마지막 값, 병합 중 무엇을 남길까
- #대량 데이터에서는 DB 제약도 필요하다
- #결론
- #관련 노트
원시값과 객체의 차이
console.log([...new Set([1, 1, 2])]); // [1, 2]
const a = { id: 1 };
const b = { id: 1 };
console.log(new Set([a, b]).size); // 2
a와 b는 내용이 같아도 서로 다른 객체다. JSON 문자열로 바꾸는 방식은 프로퍼티 순서와 직렬화 불가능한 값 때문에 일반 해법이 되기 어렵다.
키를 이용한 중복 제거
function uniqueBy(items, getKey) {
const seen = new Set();
return items.filter((item) => {
const key = getKey(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
const users = uniqueBy(rows, (row) => row.email.toLowerCase());
중복의 정의를 호출부가 제공하도록 만들면 id, 이메일, 복합 키 등 도메인 규칙을 반영할 수 있다.
첫 항목과 마지막 항목 중 무엇을 남길까
filter 방식은 첫 항목을 남긴다. Map에 계속 set하면 같은 키의 마지막 값이 남는다. 최신 데이터가 우선인지, 최초 입력이 우선인지에 따라 결과가 달라진다.
조용히 하나를 버리기 전에 충돌을 로그로 남기거나 리뷰 큐로 보내야 하는 데이터인지 확인한다.
JSON 문자열을 키로 쓰면 왜 불안정할까
객체를 JSON.stringify한 결과로 중복을 제거하는 방법을 종종 볼 수 있다.
const unique = [
...new Map(items.map((item) => [JSON.stringify(item), item])).values(),
];
작은 예제에서는 동작하지만 “같은 객체”의 정의를 JSON 직렬화 결과에 맡긴다. 프로퍼티 삽입 순서가 다르면 의미가 같아도 문자열이 다를 수 있다.
const first = { id: 1, name: "A" };
const second = { name: "A", id: 1 };
console.log(JSON.stringify(first) === JSON.stringify(second));
// false
undefined, 함수, Symbol은 사라질 수 있고 Date와 BigInt도 별도 규칙이 필요하다. 무엇보다 중복 기준이 코드에서 드러나지 않는다.
복합 키는 충돌 없이 만들어야 한다
두 필드를 단순히 구분자로 이어 붙이면 값 자체에 구분자가 들어올 때 충돌할 수 있다.
function unsafeKey(item) {
return `${item.country}:${item.email}`;
}
문자열 배열을 JSON으로 직렬화하거나 중첩 Map을 사용하면 경계를 보존할 수 있다.
function accountKey(account) {
return JSON.stringify([
account.country.toUpperCase(),
account.email.trim().toLowerCase(),
]);
}
const accounts = uniqueBy(rows, accountKey);
여기서 JSON은 객체 전체를 동일성 기준으로 삼는 것이 아니라 이미 선택한 두 문자열의 경계를 보존하는 용도로만 사용한다.
첫 값, 마지막 값, 병합 중 무엇을 남길까
중복 제거는 항상 하나 이상의 데이터를 버리거나 합치는 일이다.
function keepLastBy(items, getKey) {
const result = new Map();
for (const item of items) {
result.set(getKey(item), item);
}
return [...result.values()];
}
최신 수정 시각을 기준으로 남기려면 입력 순서에 기대지 않고 비교 규칙을 명시하는 편이 낫다.
function keepLatestBy(items, getKey) {
const result = new Map();
for (const item of items) {
const key = getKey(item);
const current = result.get(key);
if (!current || item.updatedAt > current.updatedAt) {
result.set(key, item);
}
}
return [...result.values()];
}
충돌한 두 레코드의 일부 필드를 병합해야 한다면 자동 중복 제거보다 별도 도메인 함수가 필요하다.
function mergeCustomer(existing, incoming) {
return {
...existing,
phone: incoming.phone ?? existing.phone,
lastSeenAt: new Date(Math.max(existing.lastSeenAt, incoming.lastSeenAt)),
};
}
사용자 계정, 결제, 재고처럼 잘못 합치면 복구 비용이 큰 데이터는 충돌 레코드를 리뷰 큐로 보내는 편이 안전하다.
대량 데이터에서는 DB 제약도 필요하다
애플리케이션에서 Set으로 중복을 제거해도 동시에 들어온 다른 요청까지 막을 수는 없다. 영속 데이터의 유일성은 DB unique constraint가 마지막으로 보장해야 한다.
CREATE UNIQUE INDEX uq_accounts_country_email
ON accounts(country_code, normalized_email);
애플리케이션의 uniqueBy는 사용자에게 빠른 피드백을 주고 불필요한 요청을 줄이는 역할이다. DB 제약을 대체하지 않는다.
| 계층 | 중복 처리 역할 |
|---|---|
| UI | 같은 파일·항목의 반복 선택 방지 |
| 애플리케이션 | 정규화와 충돌 정책 적용 |
| 데이터베이스 | 동시 쓰기까지 포함한 유일성 보장 |
| 운영 도구 | 자동 병합하지 못한 충돌 검토 |
결론
Set은 중복의 정의를 정해 주지 않는다. 동일성 기준을 빠르게 적용하는 도구다. 객체 데이터에서는 먼저 도메인 키와 충돌 정책을 정한다.