Map과 Set
1. 제네릭과 컬렉션 타입
앞서 제네릭을 학습했습니다. 이번 장에서는 JavaScript의 내장 자료구조인 Map과 Set을 통해 제네릭을 실전에서 어떻게 활용하는지 학습합니다. Map<K, V>와 Set<T>는 제네릭의 대표적인 활용 사례입니다.
1.1 제네릭 복습
제네릭은 타입을 매개변수처럼 전달받아 재사용 가능한 코드를 작성하는 방법입니다.
// 제네릭 함수 복습
function getValue<T>(value: T): T {
return value;
}
const num = getValue<number>(10); // T = number
const str = getValue<string>("hello"); // T = string
Map과 Set도 이와 같은 원리로 타입을 지정합니다. Map<K, V>에서 K는 키 타입, V는 값 타입을 의미하고, Set<T>에서 T는 저장할 값의 타입을 의미합니다.
2. Map과 제네릭
2.1 Map<K, V> 이해하기
Map은 키-값 쌍을 저장하는 자료구조로 자바스크립트의 내장 객체입니다. 제네릭을 통해 키와 값의 타입을 각각 지정할 수 있습니다.
// Map<K, V> - K는 키 타입, V는 값 타입
const map1 = new Map<string, number>(); // 키: string, 값: number
const map2 = new Map<number, string>(); // 키: number, 값: string
const map3 = new Map<string, boolean>(); // 키: string, 값: boolean
타입을 지정하면 잘못된 타입의 값을 넣으려 할 때 컴파일 에러가 발생합니다.
const userAges = new Map<string, number>();
userAges.set("licat", 3); // OK
userAges.set("mura", 5); // OK
userAges.set("binky", "세살"); // 에러! number 타입이어야 함
userAges.set(123, 10); // 에러! 키는 string 타입이어야 함
2.2 Map 기본 메서드
const scores = new Map<string, number>();
// set(key, value): 값 추가/수정
scores.set("국어", 90);
scores.set("영어", 85);
scores.set("수학", 95);
// get(key): 값 조회 - 반환 타입은 V | undefined
const mathScore = scores.get("수학"); // number | undefined
console.log(mathScore); // 95
// has(key): 키 존재 여부 확인
console.log(scores.has("국어")); // true
console.log(scores.has("과학")); // false
// delete(key): 값 삭제
scores.delete("영어");
// size: 크기
console.log(scores.size); // 2
// clear(): 전체 삭제
scores.clear();
get() 메서드의 반환 타입
Map.get()은 키가 존재하지 않을 수 있으므로 V | undefined를 반환합니다. 값을 사용하기 전에 undefined 체크가 필요합니다.
const value = scores.get("국어");
if (value !== undefined) {
console.log(value * 2); // 안전하게 사용
}
2.3 객체를 키로 사용하기
일반 객체와 달리 Map은 어떤 타입이든 키로 사용할 수 있습니다.
interface User {
id: number;
name: string;
}
// User 객체를 키로, number를 값으로 사용
const userScores = new Map<User, number>();
const user1: User = { id: 1, name: "licat" };
const user2: User = { id: 2, name: "mura" };
userScores.set(user1, 95);
userScores.set(user2, 88);
console.log(userScores.get(user1)); // 95
console.log(userScores.get(user2)); // 88
2.4 Map 순회하기
Map은 삽입 순서를 유지하며 여러 방법으로 순회할 수 있습니다.
const fruits = new Map<string, number>([
["사과", 3],
["바나나", 5],
["오렌지", 2]
]);
// 1. for...of - 구조 분해 할당으로 [key, value] 추출
for (const [name, count] of fruits) {
console.log(`${name}: ${count}개`);
}
// 2. forEach - 콜백 함수 사용
fruits.forEach((count, name) => {
console.log(`${name}: ${count}개`);
});
// 3. keys(), values(), entries()
console.log([...fruits.keys()]); // ["사과", "바나나", "오렌지"]
console.log([...fruits.values()]); // [3, 5, 2]
console.log([...fruits.entries()]); // [["사과", 3], ["바나나", 5], ["오렌지", 2]]
3. Set과 제네릭
3.1 Set<T> 이해하기
Set은 중복 없는 값들의 집합을 저장합니다. 제네릭 T로 저장할 값의 타입을 지정합니다.
// Set<T> - T는 저장할 값의 타입
const set1 = new Set<number>(); // 숫자만 저장
const set2 = new Set<string>(); // 문자열만 저장
const set3 = new Set<boolean>(); // 불린만 저장
const numberSet = new Set<number>();
numberSet.add(1);
numberSet.add(2);
numberSet.add(3);
numberSet.add(2); // 중복! 무시됨
numberSet.add("4"); // 에러! number 타입이어야 함
console.log(numberSet.size); // 3
console.log([...numberSet]); // [1, 2, 3]
3.2 Set 기본 메서드
const tags = new Set<string>();
// add(value): 값 추가
tags.add("typescript");
tags.add("javascript");
tags.add("react");
// has(value): 값 존재 여부 확인
console.log(tags.has("typescript")); // true
console.log(tags.has("vue")); // false
// delete(value): 값 삭제
tags.delete("typescript");
// 값 삭제 후 출력
console.log(tags.has("typescript")); // false
// size: 크기
console.log(tags.size); // 2
// clear(): 전체 삭제
tags.clear();
3.3 배열 중복 제거
Set을 활용한 가장 흔한 패턴입니다.
// 제네릭을 활용한 중복 제거 함수
function removeDuplicates<T>(array: T[]): T[] {
return [...new Set(array)];
}
const numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5];
const uniqueNumbers = removeDuplicates(numbers);
console.log(uniqueNumbers); // [1, 2, 3, 4, 5]
const fruits = ["사과", "바나나", "사과", "오렌지"];
const uniqueFruits = removeDuplicates(fruits);
console.log(uniqueFruits); // ["사과", "바나나", "오렌지"]
4. 제네릭 함수와 Map/Set
4.1 제네릭 함수로 Map 다루기
제네릭 함수를 만들어 다양한 타입의 Map을 처리할 수 있습니다.
// Map의 모든 값을 배열로 변환
function mapValuesToArray<K, V>(map: Map<K, V>): V[] {
return [...map.values()];
}
const userAges = new Map<string, number>([
["licat", 3],
["mura", 5]
]);
const ages = mapValuesToArray(userAges); // number[]
console.log(ages); // [3, 5]
// Map의 값을 변환하는 함수
function mapValues<K, V, R>(map: Map<K, V>, transform: (value: V) => R): Map<K, R> {
const result = new Map<K, R>();
map.forEach((value, key) => {
result.set(key, transform(value));
});
return result;
}
const prices = new Map<string, number>([
["사과", 1000],
["바나나", 1500]
]);
// 모든 가격에 10% 할인 적용
const discounted = mapValues(prices, (price) => price * 0.9);
console.log([...discounted]); // [["사과", 900], ["바나나", 1350]]
4.2 제네릭 함수로 Set 다루기
// 두 Set의 합집합
function union<T>(setA: Set<T>, setB: Set<T>): Set<T> {
return new Set([...setA, ...setB]);
}
// 두 Set의 교집합
function intersection<T>(setA: Set<T>, setB: Set<T>): Set<T> {
return new Set([...setA].filter(x => setB.has(x)));
}
// 두 Set의 차집합
function difference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
return new Set([...setA].filter(x => !setB.has(x)));
}
// 사용 예시
const setA = new Set([1, 2, 3, 4]);
const setB = new Set([3, 4, 5, 6]);
console.log([...union(setA, setB)]); // [1, 2, 3, 4, 5, 6]
console.log([...intersection(setA, setB)]); // [3, 4]
console.log([...difference(setA, setB)]); // [1, 2]
5. 제네릭 클래스와 Map/Set
5.1 제네릭 클래스에서 Map 활용
// 제네릭 캐시 클래스
class Cache<K, V> {
private store: Map<K, V>;
private maxSize: number;
constructor(maxSize: number = 100) {
this.store = new Map<K, V>();
this.maxSize = maxSize;
}
get(key: K): V | undefined {
return this.store.get(key);
}
set(key: K, value: V): void {
// 최대 크기 초과 시 가장 오래된 항목 삭제
if (this.store.size >= this.maxSize && !this.store.has(key)) {
const firstKey = this.store.keys().next().value;
if (firstKey !== undefined) {
this.store.delete(firstKey);
}
}
this.store.set(key, value);
}
has(key: K): boolean {
return this.store.has(key);
}
delete(key: K): boolean {
return this.store.delete(key);
}
clear(): void {
this.store.clear();
}
get size(): number {
return this.store.size;
}
}
// 문자열 키, 객체 값으로 사용
const userCache = new Cache<string, { name: string; age: number }>(3);
userCache.set("user1", { name: "licat", age: 3 });
userCache.set("user2", { name: "mura", age: 5 });
console.log(userCache.get("user1")); // { name: "licat", age: 3 }
5.2 제네릭 클래스에서 Set 활용
// 제네릭 태그 관리 클래스
class TagCollection<T> {
private tags: Set<T>;
constructor(initialTags?: T[]) {
this.tags = new Set(initialTags);
}
add(tag: T): void {
this.tags.add(tag);
}
remove(tag: T): void {
this.tags.delete(tag);
}
has(tag: T): boolean {
return this.tags.has(tag);
}
getAll(): T[] {
return [...this.tags];
}
// 다른 TagCollection과 합치기
merge(other: TagCollection<T>): TagCollection<T> {
return new TagCollection([...this.tags, ...other.getAll()]);
}
get count(): number {
return this.tags.size;
}
}
// 문자열 태그
const stringTags = new TagCollection<string>(["typescript", "react"]);
stringTags.add("javascript");
console.log(stringTags.getAll()); // ["typescript", "react", "javascript"]
// 숫자 태그 (카테고리 ID 등)
const numericTags = new TagCollection<number>([1, 2, 3]);
numericTags.add(4);
console.log(numericTags.getAll()); // [1, 2, 3, 4]
6. 제네릭 제약조건과 Map/Set
6.1 extends를 활용한 타입 제한
제네릭에 제약조건을 걸어 특정 조건을 만족하는 타입만 허용할 수 있습니다.
// id 속성을 가진 타입만 허용
interface Identifiable {
id: number;
}
class EntityMap<T extends Identifiable> {
private entities: Map<number, T>;
constructor() {
this.entities = new Map<number, T>();
}
add(entity: T): void {
this.entities.set(entity.id, entity);
}
get(id: number): T | undefined {
return this.entities.get(id);
}
getAll(): T[] {
return [...this.entities.values()];
}
}
// User는 id 속성을 가지므로 사용 가능
interface User extends Identifiable {
id: number;
name: string;
email: string;
}
const userMap = new EntityMap<User>();
userMap.add({ id: 1, name: "licat", email: "licat@weniv.co.kr" });
userMap.add({ id: 2, name: "mura", email: "mura@weniv.co.kr" });
console.log(userMap.get(1)); // { id: 1, name: "licat", email: "licat@weniv.co.kr" }
6.2 keyof와 함께 사용하기
keyof T는 타입 T의 모든 키를 유니언 타입으로 추출합니다. 이를 활용해 객체의 특정 속성값들만 Set으로 추출하는 제네릭 함수를 작성할 수 있습니다.
// 객체의 특정 속성값들만 Set으로 추출
function extractToSet<T, K extends keyof T>(items: T[], key: K): Set<T[K]> {
return new Set(items.map(item => item[key]));
}
interface Product {
id: number;
name: string;
category: string;
}
const products: Product[] = [
{ id: 1, name: "노트북", category: "전자기기" },
{ id: 2, name: "마우스", category: "전자기기" },
{ id: 3, name: "책상", category: "가구" }
];
const categories = extractToSet(products, "category");
console.log([...categories]); // ["전자기기", "가구"]
const names = extractToSet(products, "name");
console.log([...names]); // ["노트북", "마우스", "책상"]
❓ 반환값 타입에 T[K]의 의미는 무엇인가요?
K가 정확히 T의 키로 제한되기 때문에, T[K]는 T 객체에서 K 키에 해당하는 값의 타입을 정확히 추론할 수 있습니다. 예를 들어, Product 타입에서 K가 "category"라면 T[K]는 string 타입이 됩니다. 이는 컴파일 시점에 반환 타입을 자동 추론 하게 하여 타입 안전성을 높입니다.
7. 실전 예제: Map과 Set 함께 사용하기
7.1 좋아요 관리 시스템
Map의 값으로 Set을 사용하는 패턴입니다.
class LikeManager<PostId, UserId> {
// Map<게시글ID, Set<사용자ID>>
private likes: Map<PostId, Set<UserId>>;
constructor() {
this.likes = new Map<PostId, Set<UserId>>();
}
like(postId: PostId, userId: UserId): void {
if (!this.likes.has(postId)) {
this.likes.set(postId, new Set<UserId>());
}
this.likes.get(postId)!.add(userId);
}
unlike(postId: PostId, userId: UserId): void {
this.likes.get(postId)?.delete(userId);
}
getLikeCount(postId: PostId): number {
return this.likes.get(postId)?.size ?? 0;
}
hasLiked(postId: PostId, userId: UserId): boolean {
return this.likes.get(postId)?.has(userId) ?? false;
}
getLikedUsers(postId: PostId): UserId[] {
return [...(this.likes.get(postId) ?? [])];
}
}
// 숫자 ID 사용
const postLikes = new LikeManager<number, string>();
postLikes.like(1, "user1");
postLikes.like(1, "user2");
postLikes.like(1, "user1"); // 중복 - Set이므로 무시됨
console.log(postLikes.getLikeCount(1)); // 2
console.log(postLikes.hasLiked(1, "user1")); // true
console.log(postLikes.getLikedUsers(1)); // ["user1", "user2"]
postLikes.unlike(1, "user1");
console.log(postLikes.getLikeCount(1)); // 1
7.2 그룹별 데이터 관리
// 제네릭을 활용한 그룹화 함수
function groupBy<T, K>(items: T[], keyFn: (item: T) => K): Map<K, Set<T>> {
const groups = new Map<K, Set<T>>();
for (const item of items) {
const key = keyFn(item);
if (!groups.has(key)) {
groups.set(key, new Set<T>());
}
groups.get(key)!.add(item);
}
return groups;
}
interface Student {
name: string;
grade: number;
class: string;
}
const students: Student[] = [
{ name: "licat", grade: 1, class: "A" },
{ name: "mura", grade: 1, class: "B" },
{ name: "binky", grade: 2, class: "A" },
{ name: "gary", grade: 1, class: "A" }
];
// 학년별 그룹화
const byGrade = groupBy(students, s => s.grade);
console.log(byGrade.get(1)?.size); // 3 (1학년 학생 수)
// 반별 그룹화
const byClass = groupBy(students, s => s.class);
console.log([...byClass.keys()]); // ["A", "B"]
8. 연습문제
8.1 제네릭 Map 유틸리티 함수
다음 요구사항을 만족하는 제네릭 함수를 작성하세요.
// TODO: Map의 키와 값을 뒤집는 함수를 작성하세요.
// 예: Map<string, number> → Map<number, string>
function reverseMap<K, V>(map: Map<K, V>): Map<V, K> {
// 구현하세요
}
// 테스트
const original = new Map<string, number>([
["one", 1],
["two", 2],
["three", 3]
]);
const reversed = reverseMap(original);
console.log(reversed.get(1)); // "one"
console.log(reversed.get(2)); // "two"
8.2 제네릭 Set 필터 함수
// TODO: Set에서 조건을 만족하는 요소만 필터링하는 함수를 작성하세요.
function filterSet<T>(set: Set<T>, predicate: (item: T) => boolean): Set<T> {
// 구현하세요
}
// 테스트
const numbers = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const evens = filterSet(numbers, n => n % 2 === 0);
console.log([...evens]); // [2, 4, 6, 8, 10]
8.3 제네릭 데이터 저장소
// TODO: 다음 요구사항을 만족하는 DataStore 클래스를 작성하세요.
// 1. 제네릭 타입 T를 받아 데이터를 저장 (id는 자동 생성)
// 2. add(item: T): number - 아이템 추가, 생성된 id 반환
// 3. get(id: number): T | undefined - id로 아이템 조회
// 4. getAll(): T[] - 모든 아이템 반환
// 5. find(predicate: (item: T) => boolean): T | undefined - 조건으로 검색
// 6. delete(id: number): boolean - 아이템 삭제
// 테스트
interface Todo {
title: string;
completed: boolean;
}
const todoStore = new DataStore<Todo>();
const id1 = todoStore.add({ title: "TypeScript 공부", completed: false });
const id2 = todoStore.add({ title: "React 공부", completed: true });
console.log(todoStore.get(id1)); // { title: "TypeScript 공부", completed: false }
console.log(todoStore.find(t => t.completed)); // { title: "React 공부", completed: true }
9. 연습문제 정답
9.1 제네릭 Map 유틸리티 함수
function reverseMap<K, V>(map: Map<K, V>): Map<V, K> {
const reversed = new Map<V, K>();
map.forEach((value, key) => {
reversed.set(value, key);
});
return reversed;
}
// 테스트
const original = new Map<string, number>([
["one", 1],
["two", 2],
["three", 3]
]);
const reversed = reverseMap(original);
console.log(reversed.get(1)); // "one"
console.log(reversed.get(2)); // "two"
console.log(reversed.get(3)); // "three"
9.2 제네릭 Set 필터 함수
function filterSet<T>(set: Set<T>, predicate: (item: T) => boolean): Set<T> {
const filtered = new Set<T>();
set.forEach(item => {
if (predicate(item)) {
filtered.add(item);
}
});
return filtered;
}
// 또는 스프레드 연산자 활용
function filterSet2<T>(set: Set<T>, predicate: (item: T) => boolean): Set<T> {
return new Set([...set].filter(predicate));
}
// 테스트
const numbers = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const evens = filterSet(numbers, n => n % 2 === 0);
console.log([...evens]); // [2, 4, 6, 8, 10]
9.3 제네릭 데이터 저장소
class DataStore<T> {
private data: Map<number, T>;
private nextId: number;
constructor() {
this.data = new Map<number, T>();
this.nextId = 1;
}
add(item: T): number {
const id = this.nextId++;
this.data.set(id, item);
return id;
}
get(id: number): T | undefined {
return this.data.get(id);
}
getAll(): T[] {
return [...this.data.values()];
}
find(predicate: (item: T) => boolean): T | undefined {
for (const item of this.data.values()) {
if (predicate(item)) {
return item;
}
}
return undefined;
}
delete(id: number): boolean {
return this.data.delete(id);
}
}
// 테스트
interface Todo {
title: string;
completed: boolean;
}
const todoStore = new DataStore<Todo>();
const id1 = todoStore.add({ title: "TypeScript 공부", completed: false });
const id2 = todoStore.add({ title: "React 공부", completed: true });
console.log(todoStore.get(id1)); // { title: "TypeScript 공부", completed: false }
console.log(todoStore.find(t => t.completed)); // { title: "React 공부", completed: true }
console.log(todoStore.getAll().length); // 2
todoStore.delete(id1);
console.log(todoStore.getAll().length); // 1