본문 바로가기

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
Map과 Set - TypeScript 베이스캠프 | 위니버시티