C++ set/unordered_set | '중복 제거' 완벽 가이드
이 글의 핵심
set·unordered_set 성능 비교, multiset, 커스텀 비교자·해시, 실전 집합 연산, 반복자 무효화까지 정리한 가이드입니다.
set vs unordered_set
| 특성 | set | unordered_set |
|---|---|---|
| 정렬 | O (자동 정렬) | X |
| 속도 | O(log n) | O(1) |
| 중복 | 허용 안함 | 허용 안함 |
| 순회 | 정렬된 순서 | 랜덤 |
set vs unordered_set 성능 비교
시간 복잡도(평균/분할) 관점에서 보면 set은 균형 이진 탐색 트리(일반적으로 레드-블랙) 기반이라 삽입·삭제·탐색이 O(log n) 입니다. unordered_set은 해시 테이블이라 평균 O(1) 이지만, 해시 충돌이 많으면 최악 O(n) 에 가까워질 수 있습니다.
실무에서 체감되는 차이는 다음과 같습니다.
- 작은 n·순서가 필요: 원소 수가 수백~수천 수준이고 정렬된 순회나 lower_bound 같은 범위 탐색이 필요하면
set이 단순합니다. - 큰 n·키만 조회: 수백만 건 이상에서 “있는지/없는지”만 빠르게 보면 되고 순서가 중요하지 않으면
unordered_set이 유리한 경우가 많습니다. - 메모리: 해시 테이블은 버킷·로드 팩터 때문에 추가 오버헤드가 있을 수 있고, 트리는 노드당 포인터 오버헤드가 있습니다. n과 키 크기에 따라 달라지므로 측정이 안전합니다.
- 캐시 지역성: 연속 메모리가 아니라 둘 다 건너뛰기 접근이 잦아 캐시 미스는 상황에 따라 큽니다. 반복 순회만으로 “항상 unordered가 빠르다”고 단정하기 어렵습니다.
선택 요약: 순서·구간 검색이 필요하면 set, 해시에 적합한 키(정수, 잘 분포된 문자열 등)이고 조회가 대부분이면 unordered_set을 우선 검토하세요.
multiset과 unordered_multiset
set / unordered_set은 동일한 키를 하나만 저장합니다. 같은 값이 여러 번 필요하면 multiset, unordered_multiset을 씁니다.
| 특성 | multiset | unordered_multiset |
|---|---|---|
| 순서 | 정렬 유지(동일 값도 반복 삽입 순서는 구현 정의) | 순서 없음 |
| 조회 | count, equal_range로 동일 키 구간 | count, equal_range |
| 삭제 | erase(key)는 해당 키 전부 삭제, 하나만 지우려면 반복자 사용 | 동일 |
#include <set>
#include <unordered_set>
#include <iostream>
// 변수 선언 및 초기화
int main() {
std::multiset<int> ms{1, 1, 2, 2, 2};
std::cout << ms.count(2) << '\n'; // 3
auto [first, last] = ms.equal_range(2);
for (auto it = first; it != last; ++it) { /* ....*/ }
std::unordered_multiset<std::string> ums;
ums.insert("a"); ums.insert("a");
std::cout << ums.count("a") << '\n'; // 2
}
상위 k개만 필요할 때는 multiset이 정렬 상태를 유지해 최댓값/최솟값 끝단을 다루기 쉽습니다. 빈도만 세고 순서가 필요 없으면 unordered_multiset 또는 unordered_map<T, int>가 더 나을 때가 많습니다.
커스텀 비교자와 해시 함수
set: Compare와 operator<
set의 정렬 기준은 엄격 약순서(strict weak ordering) 를 만족해야 합니다. operator< 또는 std::less 커스텀 타입을 넣을 수 있습니다.
struct Person {
std::string name;
int id;
};
struct ById {
bool operator()(const Person& a, const Person& b) const {
return a.id < b.id;
}
};
std::set<Person, ById> by_id_set;
unordered_set: Hash와 KeyEqual
unordered_set<Key, Hash, KeyEqual>에서 동일한 키에 대해 항상 같은 해시가 나와야 하고, KeyEqual은 해시 충돌 시 실제 동등성을 판별합니다. 사용자 정의 타입은 보통 다음 둘을 함께 제공합니다.
struct Point {
int x, y;
bool operator==(const Point& o) const { return x == o.x && y == o.y; }
};
struct PointHash {
std::size_t operator()(const Point& p) const noexcept {
// 간단한 결합 예시 (프로젝트에 맞게 boost::hash_combine 등 고려)
return std::hash<int>{}(p.x) ^ (std::hash<int>{}(p.y) << 1);
}
};
std::unordered_set<Point, PointHash> pts;
주의: 문자열을 unordered_set에 넣을 때 커스텀 해시가 나쁘면 성능이 급격히 떨어집니다. 키가 std::string이면 기본 해시를 쓰는 것이 일반적입니다.
실전: 중복 제거·교집합·합집합 정리
- 중복 제거만이고 원래 순서 유지가 필요하면
set으로 넣었다가 벡터로 옮기면 순서가 바뀝니다. 순서 유지가 필요하면 정렬 후 unique 또는unordered_set+ 별도 순서 배열을 고려하세요. - 교집합·합집합·차집합은 위의
set_intersection/set_union/set_difference패턴이 전제로 두 범위가 정렬되어 있어야 합니다.set을 쓰면 반복자가 자동으로 정렬 순서를 만족합니다. - unordered_set끼리 집합 연산을 라이브러리 한 방으로 하기 어렵다면, 한쪽을
vector로 모아 정렬하거나, 작은 쪽을 순회하며 다른 쪽에count/find로 포함 여부를 확인하는 방식이 실용적입니다(크기에 따라 더 좋은 알고리즘 선택).
반복자 무효화
set / multiset
insert/emplace: 기존 요소의 반복자와 참조는 무효화되지 않습니다.erase(it): 해당 반복자만 무효. 다른 반복자는 유지.erase(key)/clear: 삭제되는 요소에 대한 반복자는 무효.
unordered_set / unordered_multiset
- 재해시(rehash) 가 일어나면 버킷 구조가 바뀌어 모든 반복자가 무효일 수 있습니다.
insert가 로드 팩터를 넘기면 재해시가 발생할 수 있으므로, 반복 중인 컨테이너에 무분별한 삽입은 위험합니다. reserve(n)(또는 적절한 버킷 수 예약)으로 재해시 횟수를 줄이면 반복자 무효가 덜 자주 일어납니다.erase: 삭제된 위치의 반복자만 무효, 다른 반복자는 C++11 이후 보통 유효하지만, 구현 의존적인 부분이 있으므로 삭제 시erase의 반환 반복자를 사용하는 패턴이 안전합니다.
for (auto it = s.begin(); it != s.end(); ) {
if (조건) it = s.erase(it); // erase는 다음 반복자 반환
else ++it;
}
set 기본 사용법
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s;
// 삽입
s.insert(3);
s.insert(1);
s.insert(2);
s.insert(1); // 중복 무시
// 순회 (자동 정렬: 1, 2, 3)
for (int x : s) {
cout << x << " ";
}
// 검색
if (s.find(2) != s.end()) {
cout << "\n2 존재" << endl;
}
// 삭제
s.erase(2);
// 크기
cout << "크기: " << s.size() << endl;
return 0;
}
unordered_set 기본
#include <unordered_set>
#include <iostream>
using namespace std;
int main() {
unordered_set<string> words;
words.insert("apple");
words.insert("banana");
words.insert("apple"); // 중복 무시
// 존재 확인 (빠름)
if (words.count("apple") > 0) {
cout << "apple 있음" << endl;
}
// 순회 (순서 보장 안됨)
for (const string& word : words) {
cout << word << " ";
}
return 0;
}
실전 예시
예시 1: 배열에서 중복 제거
#include <iostream>
#include <vector>
#include <set>
using namespace std;
vector<int> removeDuplicates(const vector<int>& arr) {
set<int> s(arr.begin(), arr.end());
return vector<int>(s.begin(), s.end());
}
int main() {
vector<int> arr = {5, 2, 8, 2, 9, 5, 1, 8};
cout << "원본: ";
for (int x : arr) cout << x << " ";
vector<int> unique = removeDuplicates(arr);
cout << "\n중복 제거 (정렬됨): ";
for (int x : unique) cout << x << " ";
return 0;
}
설명: set의 자동 정렬과 중복 제거 기능을 활용한 가장 기본적인 패턴입니다.
예시 2: 두 배열의 교집합/합집합
#include <iostream>
#include <set>
#include <algorithm>
using namespace std;
int main() {
set<int> a = {1, 2, 3, 4, 5};
set<int> b = {3, 4, 5, 6, 7};
// 교집합
set<int> intersection;
set_intersection(a.begin(), a.end(),
b.begin(), b.end(),
inserter(intersection, intersection.begin()));
cout << "교집합: ";
for (int x : intersection) cout << x << " "; // 3 4 5
// 합집합
set<int> union_set;
set_union(a.begin(), a.end(),
b.begin(), b.end(),
inserter(union_set, union_set.begin()));
cout << "\n합집합: ";
for (int x : union_set) cout << x << " "; // 1 2 3 4 5 6 7
// 차집합
set<int> difference;
set_difference(a.begin(), a.end(),
b.begin(), b.end(),
inserter(difference, difference.begin()));
cout << "\n차집합 (A-B): ";
for (int x : difference) cout << x << " "; // 1 2
return 0;
}
설명: 수학의 집합 연산을 그대로 구현할 수 있습니다. 알고리즘 문제에서 자주 사용됩니다.
예시 3: 방문 체크 (그래프 탐색)
#include <iostream>
#include <unordered_set>
#include <queue>
#include <vector>
using namespace std;
void bfs(int start, vector<vector<int>>& graph) {
unordered_set<int> visited;
queue<int> q;
q.push(start);
visited.insert(start);
while (!q.empty()) {
int node = q.front();
q.pop();
cout << node << " ";
for (int neighbor : graph[node]) {
if (visited.count(neighbor) == 0) {
visited.insert(neighbor);
q.push(neighbor);
}
}
}
}
int main() {
// 그래프: 0-1, 0-2, 1-3, 2-3
vector<vector<int>> graph = {
{1, 2}, // 0의 인접 노드
{0, 3}, // 1의 인접 노드
{0, 3}, // 2의 인접 노드
{1, 2} // 3의 인접 노드
};
cout << "BFS 순회: ";
bfs(0, graph);
return 0;
}
설명: unordered_set을 사용하여 O(1) 속도로 방문 여부를 확인합니다. 그래프 알고리즘의 필수 패턴입니다.
자주 발생하는 문제
문제 1: set의 요소 수정 불가
증상: set의 요소를 직접 수정하려고 하면 컴파일 에러
원인: set은 정렬을 유지해야 하므로 요소가 const
해결법:
// ❌ 잘못된 코드
set<int> s = {1, 2, 3};
auto it = s.find(2);
*it = 5; // 컴파일 에러! const int&
// ✅ 올바른 코드 (삭제 후 재삽입)
set<int> s = {1, 2, 3};
auto it = s.find(2);
if (it != s.end()) {
s.erase(it);
s.insert(5);
}
// ✅ 구조체의 경우 (mutable 사용)
struct Item {
int id;
mutable int count; // mutable은 수정 가능
bool operator<(const Item& other) const {
return id < other.id;
}
};
set<Item> items;
auto it = items.find(Item{1, 0});
if (it != items.end()) {
it->count++; // OK (mutable)
}
문제 2: 커스텀 비교 함수
증상: 커스텀 타입을 set에 넣으면 컴파일 에러
원인: operator< 또는 비교 함수 필요
해결법:
// ❌ 컴파일 에러
struct Point {
int x, y;
};
set<Point> points; // 에러! operator< 없음
// ✅ 방법 1: operator< 정의
struct Point {
int x, y;
bool operator<(const Point& other) const {
if (x != other.x) return x < other.x;
return y < other.y;
}
};
set<Point> points; // OK
// ✅ 방법 2: 비교 함수 객체
struct PointCompare {
bool operator()(const Point& a, const Point& b) const {
if (a.x != b.x) return a.x < b.x;
return a.y < b.y;
}
};
set<Point, PointCompare> points; // OK
// ✅ 방법 3: 람다 (C++11)
auto cmp = [](const Point& a, const Point& b) {
if (a.x != b.x) return a.x < b.x;
return a.y < b.y;
};
set<Point, decltype(cmp)> points(cmp); // OK
문제 3: multiset과 혼동
증상: 중복을 허용하고 싶은데 set은 안됨
원인: set은 중복 불가, multiset은 중복 가능
해결법:
// ❌ set은 중복 불가
set<int> s;
s.insert(1);
s.insert(1);
s.insert(1);
cout << s.size(); // 1 (중복 무시)
// ✅ multiset 사용
#include <set>
multiset<int> ms;
ms.insert(1);
ms.insert(1);
ms.insert(1);
cout << ms.size(); // 3 (중복 허용)
// 특정 값 개수 세기
cout << ms.count(1); // 3
// 특정 값 모두 삭제
ms.erase(1); // 1이 모두 삭제됨
// 하나만 삭제
auto it = ms.find(1);
if (it != ms.end()) {
ms.erase(it); // 하나만 삭제
}
성능 최적화
최적화 전략
-
효율적인 자료구조 선택
- 적용 방법: 상황에 맞는 STL 컨테이너 사용
- 효과: 시간복잡도 개선
-
불필요한 복사 방지
- 적용 방법: 참조 전달 사용
- 효과: 메모리 사용량 감소
-
컴파일러 최적화
- 적용 방법: -O2, -O3 플래그 사용
- 효과: 실행 속도 향상
벤치마크 결과
| 방법 | 실행 시간 | 메모리 사용량 | 비고 |
|---|---|---|---|
| 기본 구현 | 100ms | 10MB | - |
| 최적화 1 | 80ms | 8MB | 참조 전달 |
| 최적화 2 | 50ms | 5MB | STL 알고리즘 |
결론: 적절한 최적화로 2배 이상 성능 향상 가능
주의: 위 표의 절대 시간·메모리 수치는 예시입니다. 실제 서비스에서는 같은 입력·같은 컴파일 옵션으로 전후를 재측정하세요.
심화: reserve·rehash·부하 분포
unordered_set은 로드 팩터를 넘기면 재해시가 일어나며, 그 순간 모든 반복자가 무효될 수 있습니다. 삽입 전에 대략적인 원소 수를 알면:
std::unordered_set<int> s;
s.reserve(1'000'000); // 재해시 횟수 감소, 벤치 안정화
set은 reserve가 없지만, 불필요한 복사·임시 객체를 줄이는 편이 체감 성능에 도움이 됩니다.
심화: 이질적 조회 (C++20, unordered_set)
키가 std::string일 때 find("literal")이 임시 std::string을 만들지 않게 하려면 투명 해시·투명 동등성(std::hash<std::string_view>, std::equal_to<>)을 갖춘 커스텀 정책이 필요합니다. 표준 특수화는 구현·버전에 따라 다르니, 프로젝트의 표준 라이브러리 문서를 확인하세요.
// 개념 예: string_view로 조회 (프로젝트 정책·표준 버전 확인 필수)
// std::unordered_set<std::string, SVHash, SVEqual> 등
심화: node_type / extract (C++17)
set/unordered_set은 노드를 뽑아 다른 컨테이너로 옮길 수 있어, 재할당 없이 키를 갱신하는 패턴에 쓰입니다.
std::set<int> a{1, 2, 3};
auto nh = a.extract(2);
if (!nh.empty()) {
nh.value() = 5;
a.insert(std::move(nh));
}
unordered_set도 유사하지만 재해시 타이밍에 주의하세요.
심화: 벤치마크 방법 (현실적으로)
- 고정: 컴파일러 최적화(
-O2/-O3), CPU 고정 주파수(가능하면), 동일 시드. - 워밍업: 캐시·해시 테이블 워밍업 후 타이머 시작.
- 지표: 평균뿐 아니라 p95/p99, 특히
unordered_*는 최악 해시 입력을 별도 케이스로 넣습니다.
#include <chrono>
template<typename F>
auto bench(F&& f, int reps) {
using clock = std::chrono::steady_clock;
auto t0 = clock::now();
for (int i = 0; i < reps; ++i) f();
return clock::now() - t0;
}
심화: 디버깅 가이드
| 증상 | 점검 |
|---|---|
unordered_set에서 느림 | 해시 품질, 키 충돌, 로드 팩터, 잘못된 operator== |
| 반복 중 이상 동작 | 재해시로 반복자 무효 — reserve 또는 복사 후 순회 |
set 정렬이 이상함 | 비교 연산이 엄격 약순서를 만족하는지 |
심화: 흔한 실수 패턴 (추가)
unordered_set에vector를 키로 넣고 기본 해시만 쓰기 → 해시가 비효율적이거나 정의 누락. 불변 키로 식별자를 쓰거나, 커스텀 해시를 정의합니다.erasewhile 순회 시 잘못된 반복자 증가 →it = container.erase(it)패턴 사용.- 멀티스레드에서
const멤버만 읽기만으로 안전하다고 가정 →unordered_set은 읽기도 동기화 필요(읽기 전용 스냅샷 또는shared_mutex등 설계).
FAQ
Q1: 초보자도 배울 수 있나요?
A: 네, 이 가이드는 초보자를 위해 작성되었습니다. 기본 C++ 문법만 알면 충분합니다.
Q2: 실무에서 자주 사용하나요?
A: 네, 매우 자주 사용됩니다. 실무 프로젝트에서 필수적인 개념입니다.
Q3: 다른 언어와 비교하면?
A: C++의 장점은 성능과 제어력입니다. Python보다 빠르고, Java보다 유연합니다.
Q4: 학습 시간은 얼마나 걸리나요?
A: 기본 개념은 1-2시간, 숙달까지는 1-2주 정도 걸립니다.
Q5: 추천 학습 순서는?
A:
- 기본 문법 익히기
- 간단한 예제 따라하기
- 실전 프로젝트 적용
- 고급 기법 학습
Q6: 자주 하는 실수는?
A:
- 초기화 안 함
- 메모리 관리 실수
- 시간복잡도 고려 안 함
- 예외 처리 누락
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ string | “문자열 처리” 완벽 가이드 [실전 함수 총정리]
- C++ Algorithm Set | “집합 알고리즘” 가이드
- C++ STL vector | “배열보다 편한” 벡터 완벽 정리 [실전 예제]
관련 글
- C++ map vs unordered_map (STL 시리즈) |
- C++ map·set 완벽 가이드 | ordered vs unordered· 커스텀 키
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ set/unordered_set | ‘중복 제거’ 완벽 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ set/unordered_set | ‘중복 제거’ 완벽 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, set, unordered_set, 집합, STL, 중복제거 등으로 검색하시면 이 글이 도움이 됩니다.