PHpullh

TYPESCRIPT · 심층 가이드

TypeScript 컬렉션 완전 정리

map과 filter, reduce는 JavaScript와 같은 메서드지만 TypeScript에서는 누적자 타입과 요소 타입이 어디서 좁혀지고 어디서 넓어지는지가 코드의 안전성을 결정합니다.

주제 20개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

TypeScript의 컬렉션 작업에는 런타임 동작과 타입 추론이라는 두 층이 겹쳐 있습니다. 런타임에서는 그냥 배열 메서드지만, 컴파일러는 콜백의 반환값을 보고 결과 타입을 새로 만들어 냅니다. 초심자의 모델이 깨지는 지점은 대개 reduce입니다. 초기값으로 넘긴 []가 never[]로 추론되어 첫 push부터 에러가 나거나, 반대로 제네릭 인자를 명시하는 순간 콜백 안의 실수를 컴파일러가 더 이상 잡아 주지 못합니다.

앞쪽 map 변환과 filter 필터링은 배열의 길이와 요소 타입 중 무엇이 바뀌는지를 나누어 보는 연습입니다. reduce 합계에서 누적자라는 개념이 등장하고, 이 누적자를 객체로 바꾸면 곧바로 groupBy 구현과 빈도표 만들기가 됩니다. Set 중복 제거부터는 자료구조 선택 문제로 넘어가며, chunk 나누기와 sliding window는 같은 인덱스 계산을 다른 각도로 반복해 감을 굳히는 구간입니다.

덜 알려진 함정은 filter가 타입을 좁혀 주지 않는다는 점입니다. arr.filter(x => x !== undefined)를 써도 결과는 여전히 (T | undefined)[]이고, 뒤에서 !를 붙이는 습관으로 이어집니다. 콜백을 x is T 형태의 타입 서술어로 선언해야 컴파일러가 결과를 T[]로 인정합니다. 객체를 키로 쓰는 Set과 Map이 값이 아니라 참조로 비교한다는 점도 중복 제거에서 자주 발목을 잡습니다.

01map 변환

items.map이 각 요소에 2를 곱한 새 배열을 만들어 출력합니다. 원본은 그대로 남아 있어서 변환 결과만 따로 쓰고 싶을 때 적합합니다. map은 항상 원본과 같은 길이의 배열을 돌려주므로, 개수를 줄이려면 filter를 앞뒤에 붙여야 합니다.

TypeScript code

// map 변환
const items: number[] = [1, 2, 3, 1];
console.log(items.map(value => value * 2));
알아두면 좋은 점

map은 콜백의 반환 타입에서 결과 배열의 요소 타입을 추론하므로, 숫자 배열에 문자열을 돌려주는 콜백을 주면 결과는 string[]이 됩니다.

자주 하는 실수

map을 반환값 없이 부수 효과 용도로만 쓰면 undefined로 채워진 배열이 만들어져 메모리만 낭비합니다. 그럴 때는 forEach가 맞습니다.

02filter 필터링

문자열 배열을 Set에 넣었다가 스프레드로 다시 배열로 꺼내 중복된 값을 하나로 만듭니다. 필터링보다는 중복 제거에 가까운 코드입니다. Set은 === 기준으로 비교해서, 내용이 같은 객체라도 참조가 다르면 둘 다 남습니다.

TypeScript code

// filter 필터링
const words: string[] = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);
알아두면 좋은 점

[...new Set(words)]는 처음 등장한 순서를 그대로 유지하므로, 중복만 걷어내고 원래 배열의 순서를 지켜야 할 때 적합합니다.

자주 하는 실수

NaN은 ===로 자기 자신과 다르지만 Set에서는 하나로 취급되어, indexOf 기반 중복 제거와 결과가 달라집니다.

03reduce 합계

reduce가 누산 객체를 돌면서 team별 배열에 점수를 밀어 넣어 팀마다 점수 목록을 만듭니다. 합계가 아니라 그룹핑에 쓰인 형태입니다. ??= 덕분에 키가 없을 때만 빈 배열이 생기고, 누산기에 타입을 주지 않으면 인덱스 접근에서 오류가 납니다.

TypeScript code

// reduce 합계
const rows = [{ team: "a", score: 2 }, { team: "b", score: 3 }, { team: "a", score: 3 }];
const grouped = rows.reduce((acc, row) => { (acc[row.team] ??= []).push(row.score); return acc; }, {});
console.log(grouped);
알아두면 좋은 점

누산기 타입을 Record<string, number[]>로 명시하면 키 접근이 통과하고, 결과 객체의 타입도 호출부까지 그대로 이어집니다.

자주 하는 실수

초깃값 {}에 타입을 주지 않으면 인덱스 접근에서 컴파일 오류가 나고, any로 덮으면 결과 객체의 타입 정보까지 사라집니다.

04첫 요소 찾기

1부터 5까지의 배열을 slice로 두 개씩 잘라 조각 목록을 만듭니다. 첫 요소를 찾는 대신 구간을 나누는 코드입니다. 마지막 조각은 남은 개수만큼만 채워지므로, 크기가 항상 같다고 가정한 후속 코드는 깨집니다.

TypeScript code

// 첫 요소 찾기
const values: number[] = [1, 2, 3, 4, 5];
const chunks = [];
for (let i = 0; i < values.length; i += 2) chunks.push(values.slice(i, i + 2));
console.log(chunks);
알아두면 좋은 점

slice는 시작 인덱스가 길이를 넘어서면 빈 배열을 돌려주므로, 반복 조건만 맞으면 경계에서 별도 예외 처리를 하지 않아도 됩니다.

자주 하는 실수

const chunks = []는 any[]로 시작해 요소 타입이 사라집니다. number[][]로 선언해야 이후 조각 연산이 검사됩니다.

05Set 중복 제거

요소마다 2를 곱한 배열을 출력합니다. 중복 제거가 주제라면 new Set(items)로 감싸는 단계가 더 필요합니다. Set 조회는 배열의 includes와 달리 요소 수가 늘어도 비용이 거의 일정해서, 반복 검사에서 차이가 커집니다.

TypeScript code

// Set 중복 제거
const items: number[] = [1, 2, 3, 5];
console.log(items.map(value => value * 2));
알아두면 좋은 점

숫자 배열의 중복을 없애면 Set<number>로 추론되어, 다시 펼친 배열도 number[] 타입을 그대로 유지합니다.

자주 하는 실수

객체 배열에 Set을 그대로 쓰면 내용이 같아도 참조가 다르면 남으므로, 식별 키를 뽑아 Map에 모으는 방식으로 바꿔야 합니다.

06단어 수 세기

같은 단어가 섞인 배열에서 유일한 값만 남겨 세 개를 출력합니다. 개수를 세려면 여기서 멈추지 말고 단어별 카운터를 담을 Map이나 객체가 필요합니다. Set은 존재 여부만 알려 줄 뿐 몇 번 나왔는지는 기억하지 않습니다.

TypeScript code

// 단어 수 세기
const words: string[] = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);
알아두면 좋은 점

개수를 셀 때는 Map<string, number>에 (map.get(w) ?? 0) + 1을 넣는 형태가 짧고, 키 존재 확인을 따로 하지 않아도 됩니다.

자주 하는 실수

Set의 크기를 단어 개수로 착각하면 중복이 제거된 종류 수가 나옵니다. 총 등장 횟수는 원본 배열의 길이여야 합니다.

07스택 구현

행 목록을 팀 키로 묶어 점수 배열을 만드는 reduce입니다. 스택은 아니지만, 각 키의 배열이 push로 커진다는 점은 적재 동작과 같습니다. 배열을 스택으로 쓸 때는 push와 pop을 짝지어 쓰고, 빈 배열의 pop은 undefined를 돌려줍니다.

TypeScript code

// 스택 구현
const rows = [{ team: "a", score: 2 }, { team: "b", score: 3 }, { team: "a", score: 7 }];
const grouped = rows.reduce((acc, row) => { (acc[row.team] ??= []).push(row.score); return acc; }, {});
console.log(grouped);
알아두면 좋은 점

배열을 스택으로 쓸 때 pop의 반환 타입은 T | undefined이므로, 비었는지 확인하는 분기를 두어야 이후 연산이 검사를 통과합니다.

자주 하는 실수

빈 스택에서 꺼낸 undefined를 그대로 계산에 쓰면 예외 대신 NaN이 퍼져, 문제 지점이 한참 뒤에서야 드러납니다.

08큐 구현

두 칸씩 잘라 조각 배열을 만드는 반복문입니다. 큐를 배열로 흉내 내면 shift로 앞을 꺼내게 되는데, 이 연산은 나머지 요소를 모두 당기므로 길이가 커질수록 비싸집니다. 앞뒤 삽입과 삭제가 잦다면 인덱스를 옮기는 방식이 낫습니다.

TypeScript code

// 큐 구현
const values: number[] = [1, 2, 3, 4, 5];
const chunks = [];
for (let i = 0; i < values.length; i += 2) chunks.push(values.slice(i, i + 2));
console.log(chunks);
알아두면 좋은 점

앞을 자주 꺼내야 한다면 읽기 인덱스를 하나 두고 증가시키다가, 인덱스가 충분히 커졌을 때 한 번에 잘라 내는 방식이 훨씬 저렴합니다.

자주 하는 실수

shift는 호출마다 나머지 요소를 앞으로 당기므로, 큐 길이가 수만 개가 되면 전체 처리 시간이 길이의 제곱에 가깝게 늘어납니다.

092차원 배열 합계

네 개짜리 배열의 각 값을 두 배로 만듭니다. 2차원 배열이라면 바깥을 돌면서 안쪽 행 합계를 다시 더해야 합니다. flat()으로 한 겹 펼친 뒤 한 번만 더하는 방법도 있는데, 기본 깊이가 1이라 더 깊게 중첩됐다면 인자를 지정해야 합니다.

TypeScript code

// 2차원 배열 합계
const items: number[] = [1, 2, 3, 9];
console.log(items.map(value => value * 2));
알아두면 좋은 점

flat()의 기본 깊이는 1이며, 중첩 깊이를 모르는 데이터라면 flat(Infinity)로 끝까지 펼칠 수 있습니다.

자주 하는 실수

행마다 길이가 다른 배열을 고정 크기로 가정하고 인덱스로 접근하면, 없는 자리에서 undefined가 나와 합계가 NaN이 됩니다.

10groupBy 구현

중복 문자열을 걷어내 고유 목록을 만듭니다. 그룹핑이라면 값을 버리는 대신 키마다 모아야 하므로 Map이나 누산 객체가 필요합니다. 최신 런타임에는 Object.groupBy가 있지만, 반환 객체의 값은 undefined가 될 수 있는 타입으로 나옵니다.

TypeScript code

// groupBy 구현
const words: string[] = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);
알아두면 좋은 점

Map.groupBy를 쓰면 문자열이 아닌 키로도 묶을 수 있고, 결과가 Map이라 프로토타입 키와 이름이 겹칠 걱정이 없습니다.

자주 하는 실수

그룹 키를 객체나 배열로 만들면 문자열 변환 과정에서 전부 [object Object]로 뭉쳐, 서로 다른 값이 한 그룹에 섞입니다.

11flatten 펼치기

reduce로 팀별 점수 배열을 만드는 코드라, 결과는 펼쳐지는 대신 오히려 중첩됩니다. 이렇게 만든 값들을 한 줄로 늘어놓을 때 flat이나 flatMap을 씁니다. flatMap은 변환과 한 겹 펼치기를 함께 처리해 중간 배열을 남기지 않습니다.

TypeScript code

// flatten 펼치기
const rows = [{ team: "a", score: 2 }, { team: "b", score: 3 }, { team: "a", score: 11 }];
const grouped = rows.reduce((acc, row) => { (acc[row.team] ??= []).push(row.score); return acc; }, {});
console.log(grouped);
알아두면 좋은 점

flatMap은 콜백이 빈 배열을 돌려주면 그 항목이 결과에서 빠지므로, 변환과 걸러 내기를 한 번의 순회로 함께 처리할 수 있습니다.

자주 하는 실수

flat은 얕은 복사라 안쪽 요소가 객체이면 원본과 같은 참조가 결과에 들어가, 한쪽을 수정하면 다른 쪽에도 그대로 반영됩니다.

12zip 묶기

인덱스를 2씩 올리며 배열을 두 칸짜리 조각으로 나눕니다. 두 배열을 짝짓는 zip은 대신 하나의 인덱스로 양쪽을 동시에 읽는 형태가 됩니다. 길이가 다르면 짧은 쪽에 맞춰야 하며, 그러지 않으면 없는 인덱스에서 undefined가 섞여 들어옵니다.

TypeScript code

// zip 묶기
const values: number[] = [1, 2, 3, 4, 5];
const chunks = [];
for (let i = 0; i < values.length; i += 2) chunks.push(values.slice(i, i + 2));
console.log(chunks);
알아두면 좋은 점

두 배열을 짝지을 때 결과 타입을 [A, B][]로 적어 두면, 구조 분해로 받은 두 값의 타입이 각각 유지됩니다.

자주 하는 실수

긴 쪽 길이를 기준으로 돌면 짧은 배열에서 undefined가 나오는데, 타입상으로는 요소 타입으로 보여 검사에 걸리지 않고 실행 중에 터집니다.

13chunk 나누기

각 요소를 두 배로 바꾼 새 배열을 출력합니다. 실제로 나누는 코드는 없고, 나누려면 slice로 구간을 잘라 담는 반복문이 필요합니다. slice는 끝 인덱스가 길이를 넘어가도 예외 없이 남은 만큼만 돌려주어 경계 검사를 따로 하지 않아도 됩니다.

TypeScript code

// chunk 나누기
const items: number[] = [1, 2, 3, 13];
console.log(items.map(value => value * 2));
알아두면 좋은 점

조각 크기를 인자로 받는다면 0이나 음수가 들어왔을 때 반복문이 끝나지 않으므로, 1 이상인지 확인하는 검사를 앞에 두어야 합니다.

자주 하는 실수

slice 대신 splice로 잘라 내면 원본 배열이 비워지므로, 같은 배열을 뒤에서 다시 쓰는 코드가 빈 값을 받게 됩니다.

14key 기준 정렬

단어 배열에서 중복을 없앤 결과를 보여 줍니다. 키 기준 정렬을 붙이려면 여기에 비교 함수를 가진 sort가 이어져야 합니다. Set은 삽입 순서를 유지할 뿐 정렬해 주지는 않으므로, 순서가 필요하면 배열로 꺼낸 뒤 직접 정렬합니다.

TypeScript code

// key 기준 정렬
const words: string[] = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);
알아두면 좋은 점

객체 배열을 키로 정렬할 때는 값을 꺼내 비교하고, 동점이면 두 번째 키 비교를 이어 붙여야 순서가 실행마다 흔들리지 않습니다.

자주 하는 실수

sort는 원본을 바꾸므로 중복 제거로 새로 만든 배열이 아니라 원래 배열에 직접 부르면, 그 배열을 공유하던 코드의 순서까지 바뀝니다.

15빈도표 만들기

행들을 팀별로 묶어 점수 배열을 만듭니다. 빈도표라면 배열에 담는 대신 숫자를 1씩 올리는 형태로 바꾸면 됩니다. 누산 객체를 {}로 시작하면 toString 같은 상속 키와 이름이 겹칠 수 있어, Object.create(null)이나 Map이 더 안전합니다.

TypeScript code

// 빈도표 만들기
const rows = [{ team: "a", score: 2 }, { team: "b", score: 3 }, { team: "a", score: 15 }];
const grouped = rows.reduce((acc, row) => { (acc[row.team] ??= []).push(row.score); return acc; }, {});
console.log(grouped);
알아두면 좋은 점

Object.create(null)로 만든 객체는 프로토타입이 없어 constructor 같은 이름도 평범한 키로 다룰 수 있습니다.

자주 하는 실수

{}를 빈도표로 쓰면서 if (table[key])로 존재를 확인하면, 상속된 toString이 함수로 잡혀 카운트가 어긋납니다.

16대소문자 무시 중복 제거

숫자 배열을 두 개씩 나누는 코드라 대소문자 비교와는 관계가 없습니다. 대소문자를 무시하고 걸러내려면 toLowerCase()로 정규화한 값을 Set에 넣고, 출력용 원본은 따로 보관합니다. 일부 언어에서는 소문자 변환 결과가 로케일에 따라 달라집니다.

TypeScript code

// 대소문자 무시 중복 제거
const values: number[] = [1, 2, 3, 4, 5];
const chunks = [];
for (let i = 0; i < values.length; i += 2) chunks.push(values.slice(i, i + 2));
console.log(chunks);
알아두면 좋은 점

정규화한 키와 원본 값을 Map에 함께 담으면, 비교는 소문자로 하면서 출력은 처음 등장한 표기를 그대로 유지할 수 있습니다.

자주 하는 실수

toLocaleLowerCase()는 실행 환경 로케일을 따라가 터키어에서 결과가 달라지므로, 비교용 정규화에는 toLowerCase()를 써야 합니다.

17sliding window

각 값을 두 배로 만드는 변환 한 줄입니다. 슬라이딩 윈도우는 인덱스마다 고정 길이 구간을 보는 방식이라 slice(i, i + k)를 도는 반복문이 더해져야 합니다. 매 칸마다 합을 새로 구하면 낭비이므로, 빠지는 값을 빼고 들어오는 값을 더하는 편이 낫습니다.

TypeScript code

// sliding window
const items: number[] = [1, 2, 3, 17];
console.log(items.map(value => value * 2));
알아두면 좋은 점

창을 옮길 때마다 빠지는 값을 빼고 들어오는 값을 더하면, 창 크기와 무관하게 한 칸당 연산 횟수가 두 번으로 고정됩니다.

자주 하는 실수

slice(i, i + k)는 끝부분에서 길이가 k보다 짧은 조각도 만들므로, 반복 조건을 i + k <= length로 잡지 않으면 불완전한 창이 섞입니다.

18pair 목록을 맵으로 변환

문자열 중복을 제거해 배열로 되돌립니다. 짝 목록을 맵으로 바꿀 때는 new Map(pairs)처럼 키와 값 두 칸짜리 배열을 그대로 넘기면 됩니다. 같은 키가 두 번 나오면 나중 값이 앞 값을 덮어쓰므로 충돌 처리를 미리 정해 두어야 합니다.

TypeScript code

// pair 목록을 맵으로 변환
const words: string[] = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);
알아두면 좋은 점

new Map(pairs)에 넘길 배열을 [string, number][]로 고정해 두면 키와 값 타입이 그대로 Map에 전달됩니다.

자주 하는 실수

Object.fromEntries로 바꾸면 키가 전부 문자열이 되어, 숫자 키로 조회하던 코드가 값을 찾지 못하고 undefined를 받습니다.

19reverse 복사

그룹핑 reduce가 원본 rows를 건드리지 않고 새 객체만 만듭니다. 순서를 뒤집을 때도 같은 태도가 필요한데, reverse()는 원본 배열 자체를 뒤집어 놓습니다. 복사본만 뒤집으려면 [...arr].reverse()나 toReversed()를 씁니다.

TypeScript code

// reverse 복사
const rows = [{ team: "a", score: 2 }, { team: "b", score: 3 }, { team: "a", score: 19 }];
const grouped = rows.reduce((acc, row) => { (acc[row.team] ??= []).push(row.score); return acc; }, {});
console.log(grouped);
알아두면 좋은 점

toReversed와 toSorted는 원본을 남긴 채 새 배열을 돌려주므로, 복사 후 변형이라는 두 단계를 한 번으로 줄여 줍니다.

자주 하는 실수

reverse()는 반환값도 같은 배열이라 결과를 새 변수에 받아도 원본이 이미 뒤집혀, 원본을 참조하던 코드가 함께 어긋납니다.

20partition 나누기

반복문이 배열을 두 칸 단위로 잘라 담습니다. 조건에 따라 둘로 가르는 분할은 조각 대신 참과 거짓 두 배열에 나눠 넣는 형태가 됩니다. filter를 두 번 부르면 같은 배열을 두 번 훑게 되어, 한 번의 reduce로 처리하면 순회 횟수를 줄일 수 있습니다.

TypeScript code

// partition 나누기
const values: number[] = [1, 2, 3, 4, 5];
const chunks = [];
for (let i = 0; i < values.length; i += 2) chunks.push(values.slice(i, i + 2));
console.log(chunks);
알아두면 좋은 점

한 번의 reduce로 참·거짓 두 배열에 나눠 담으면 순회가 한 번으로 줄고, 누산기를 튜플 타입으로 적어 두면 구조 분해가 깔끔해집니다.

자주 하는 실수

filter를 조건과 그 부정으로 두 번 부를 때 조건 함수에 부수 효과가 있으면, 각 요소에 대해 두 번 실행되어 결과가 달라집니다.

정리하며

  • reduce의 초기값이 누적자 타입을 정하므로 빈 배열·빈 객체에는 타입을 명시합니다.
  • filter로 null을 걸러도 타입은 그대로이니 x is T 서술어를 씁니다.
  • Set과 Map은 객체를 참조로 비교하므로 중복 제거 전에 키 문자열을 만듭니다.
  • sort는 원본을 바꾸므로 정렬 전에 스프레드로 복사본을 먼저 만듭니다.

더 깊이 들어가고 싶다면 TypeScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.