JAVASCRIPT · 심층 가이드
JavaScript 컬렉션 완전 정리
배열 하나로 맵, 스택, 큐, 빈도표를 전부 처리하는 JavaScript 컬렉션 코드 20편을 모아, 상황별로 어떤 자료구조가 맞는지 바로 판단하도록 돕습니다.
JavaScript에는 별도의 리스트 타입이 없습니다. Array 하나가 리스트이자 스택이자 큐 역할을 겸하고, 여기에 Map과 Set이 얹히는 구조입니다. 문제는 이 유연함이 초보자의 판단을 흐린다는 점입니다. 객체를 딕셔너리처럼 쓰다가 키가 전부 문자열로 강제 변환되어 1과 "1"이 같은 슬롯을 차지하는 일이 흔합니다. 자료구조를 고르는 기준을 먼저 세워야 코드가 흔들리지 않습니다.
앞쪽 map 변환, filter 필터링, reduce 합계 세 편은 나머지 열일곱 편의 재료입니다. groupBy 구현과 빈도표 만들기는 결국 reduce로 누적하는 같은 뼈대이고, partition 나누기는 filter를 두 번 도는 대신 한 번의 순회로 끝내는 변형입니다. chunk 나누기와 sliding window는 인덱스 계산만 다를 뿐 구조가 겹치니 나란히 보면 차이가 선명해집니다.
가장 자주 밟는 지뢰는 Set과 Map의 동등성 규칙입니다. 이들은 SameValueZero 비교를 쓰기 때문에 {a:1} 두 개는 내용이 같아도 서로 다른 키입니다. 대소문자 무시 중복 제거가 별도 항목으로 존재하는 이유도 여기 있습니다. splice나 sort처럼 원본을 바꾸는 메서드와 slice, toSorted처럼 새 배열을 주는 메서드를 구분해 두지 않으면 reverse 복사에서 원본이 조용히 뒤집힙니다.
01map 변환
items.map(value => value * 2)가 [1, 2, 3, 1]의 각 요소를 두 배로 만든 새 배열 [2, 4, 6, 2]를 돌려줍니다. 원본을 건드리지 않고 같은 길이의 결과가 필요할 때 씁니다. 콜백에서 값을 반환하지 않으면 그 자리가 undefined로 채워지므로, 부수 효과만 필요하면 forEach가 맞습니다.
JavaScript code
// map 변환
const items = [1, 2, 3, 1];
console.log(items.map(value => value * 2));map은 원본 길이와 인덱스를 그대로 유지하므로, 결과를 원본과 같은 인덱스로 짝지어 두 배열을 함께 다루는 코드에 쓰기 좋습니다.
map 콜백에서 로그만 찍고 반환을 잊으면 [undefined, undefined]가 나옵니다. 값이 필요 없는 순회에는 forEach를 써야 합니다.
02filter 필터링
new Set(words)가 중복된 'js'를 하나로 접고, 전개 연산자가 다시 배열 ['js', 'ts', 'rust']로 펴 냅니다. 조건으로 걸러 내는 filter가 아니라 같은 값을 걷어 내는 코드입니다. Set은 값 자체를 비교하므로 문자열은 잘 묶이지만 내용이 같은 객체 둘은 서로 다른 항목으로 남습니다.
JavaScript code
// filter 필터링
const words = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);new Set(words).size만 읽으면 배열로 되돌리지 않고도 서로 다른 값이 몇 개인지 바로 알 수 있습니다.
객체 배열을 Set에 넣으면 내용이 같아도 참조가 달라 하나도 걸러지지 않습니다. 식별에 쓸 키를 뽑아 Map에 모으는 방식이 필요합니다.
03reduce 합계
reduce가 빈 객체에서 시작해 각 행의 team을 키로 삼고, (acc[row.team] ??= []).push(row.score)로 배열을 만들며 점수를 밀어 넣어 { a: [2, 3], b: [3] }이 됩니다. 합계가 아니라 키별 묶음을 만드는 형태입니다. ??=는 값이 null이나 undefined일 때만 대입해서 이미 쌓인 배열을 덮어쓰지 않습니다.
JavaScript 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);값 전체를 그룹으로 모으는 것이라면 Object.groupBy가 같은 일을 한 줄로 해 주고, 여기처럼 특정 필드만 모을 때는 직접 reduce를 씁니다.
reduce 콜백에서 return acc를 빠뜨리면 다음 반복의 누적값이 undefined가 되어 곧바로 TypeError로 멈춥니다.
04첫 요소 찾기
인덱스를 2씩 건너뛰며 values.slice(i, i + 2)로 잘라 [[1,2],[3,4],[5]]를 만듭니다. 첫 요소 탐색이 아니라 배열을 일정 크기로 쪼개는 코드이고, 마지막 조각은 남은 개수만큼만 짧게 나옵니다. slice는 끝 인덱스가 길이를 넘어도 오류 없이 있는 만큼만 잘라 줍니다.
JavaScript code
// 첫 요소 찾기
const values = [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는 얕은 복사라 조각 안의 객체는 원본과 같은 참조를 가리킵니다. 조각 쪽 객체를 고치면 원본 배열의 요소도 함께 바뀝니다.
조각 크기를 변수로 받다가 0이 들어오면 i += 0이 되어 루프가 끝나지 않습니다. 크기는 1 이상인지 먼저 확인해야 합니다.
05Set 중복 제거
Set은 쓰이지 않고 map이 [1, 2, 3, 5]의 각 값을 두 배로 바꿉니다. 중복을 없애려면 같은 배열을 new Set(...)에 넣었다가 전개해 다시 펴면 됩니다. map은 결과 길이가 원본과 항상 같아서 요소 수를 줄이는 용도로는 쓸 수 없습니다.
JavaScript code
// Set 중복 제거
const items = [1, 2, 3, 5];
console.log(items.map(value => value * 2));순서를 지키면서 중복만 없앨 때는 Set이 가장 간단하고, 어떤 항목이 몇 번 나왔는지도 필요하면 개수를 세는 Map이 있어야 합니다.
숫자를 기대하는 콜백에 문자열이 섞이면 "3" * 2는 6이 되지만 "a" * 2는 NaN이 되어 결과가 조용히 오염됩니다.
06단어 수 세기
개수를 세는 대신 Set이 'js' 중복을 없애 서로 다른 단어 세 개만 남깁니다. 등장 횟수가 필요하다면 Map이나 객체에 단어를 키로 두고 값을 1씩 올리는 코드가 있어야 합니다. Set은 삽입 순서를 유지하므로 전개 결과의 순서가 원본에서 처음 나타난 순서와 같습니다.
JavaScript code
// 단어 수 세기
const words = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);빈도를 셀 때는 Map이 삽입 순서를 지키고 키 타입도 보존해, 나중에 항목을 정렬해 상위 단어를 뽑아내기 편합니다.
객체로 빈도표를 만들면 counts[word]++가 첫 등장에서 NaN이 됩니다. 초기값을 채워 주는 처리를 앞에 두어야 합니다.
07스택 구현
스택 자료구조 대신 reduce가 팀 이름별로 점수 배열을 모아 { a: [2, 7], b: [3] }을 만듭니다. push가 보이지만 이는 그룹 배열 뒤에 값을 붙이는 용도입니다. 스택으로 쓸 때는 push와 pop을 짝지어 끝에서만 다루면 되고, 앞쪽을 건드리는 shift는 나머지 요소를 모두 당겨야 해서 더 비쌉니다.
JavaScript 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);push와 pop은 배열 끝만 건드려 요소 수와 무관하게 비용이 일정합니다. 스택에 별도 자료구조가 필요하지 않은 이유가 여기에 있습니다.
빈 배열에 pop()을 부르면 오류 대신 undefined가 나옵니다. 값으로 undefined가 들어갈 수 있는 스택이라면 length로 따로 확인해야 합니다.
08큐 구현
큐 대신 반복문이 배열을 두 개씩 잘라 chunks에 담습니다. 넣은 순서대로 꺼내는 큐가 필요하면 배열 앞을 비우는 shift가 가장 단순하지만, 요소가 많아지면 매번 앞당기는 비용이 커지므로 머리 인덱스를 따로 두고 읽는 방식이 그 비용을 피합니다.
JavaScript code
// 큐 구현
const values = [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차원 배열 합계
중첩 배열은 없고 평평한 [1, 2, 3, 9]에 map을 걸어 각 값을 두 배로 만듭니다. 2차원이라면 바깥 배열을 reduce로 돌면서 행마다 다시 합을 구하는 식이 됩니다. map의 콜백은 두 번째 인자로 인덱스를 받으므로, 위치가 필요할 때 별도 카운터를 둘 필요가 없습니다.
JavaScript code
// 2차원 배열 합계
const items = [1, 2, 3, 9];
console.log(items.map(value => value * 2));중첩 배열의 총합은 flat() 뒤에 reduce를 붙여 한 번에 낼 수 있지만, 행별 결과도 필요하면 바깥과 안쪽을 나누는 편이 재사용하기 좋습니다.
map의 콜백으로 인자를 여러 개 받는 함수를 그대로 넘기면 인덱스가 두 번째 인자로 흘러들어, map(parseInt)처럼 엉뚱한 결과가 나옵니다.
10groupBy 구현
그룹핑이 아니라 Set으로 중복 문자열을 지우는 코드이며 결과는 세 원소짜리 배열입니다. 실제 그룹핑은 키를 계산해 같은 키끼리 배열에 모으는 작업이고, 최신 런타임에는 Object.groupBy가 그 역할을 대신합니다. 중복 제거는 그중 키가 값 자체이고 값은 버려지는 특수한 경우로 볼 수 있습니다.
JavaScript code
// groupBy 구현
const words = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);그룹 키를 만들 때 undefined가 섞이면 "undefined"라는 그룹이 생깁니다. 키가 없는 항목은 미리 걸러 두어야 결과가 깔끔합니다.
Object.groupBy는 비교적 최근에 추가된 기능이라 지원 범위를 확인하지 않고 쓰면 구형 환경에서 함수가 없다는 오류로 멈춥니다.
11flatten 펼치기
펼치기 대신 reduce가 평평한 행 목록을 팀별 배열로 접어 넣어 { a: [2, 11], b: [3] }을 만듭니다. 반대로 중첩된 배열을 평평하게 만들 때는 flat()이나 flatMap()을 쓰는데, flat은 기본이 한 단계라 더 깊은 구조는 깊이를 인자로 넘겨야 합니다.
JavaScript 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은 map 뒤에 한 단계 flat을 붙인 것과 같아, 요소 하나에서 0개나 여러 개를 만들어 낼 때 중간 배열 없이 처리됩니다.
깊이를 모르는 구조에 flat()을 그냥 부르면 한 단계만 펼쳐집니다. 끝까지 펼치려면 깊이를 Infinity로 넘겨야 합니다.
12zip 묶기
두 배열을 짝짓는 zip이 아니라, 한 배열을 인덱스 두 칸씩 slice해 [[1,2],[3,4],[5]]로 나눕니다. 결과 모양이 쌍의 목록이라 zip과 헷갈리기 쉽습니다. 진짜 zip은 짧은 쪽 길이에 맞춰 map으로 [a[i], b[i]]를 만드는 방식이 됩니다.
JavaScript code
// zip 묶기
const values = [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);길이가 다른 두 배열을 짝지을 때는 짧은 쪽에 맞춰 자를지 긴 쪽에 맞춰 빈 자리를 채울지를 먼저 정해야 결과 길이가 흔들리지 않습니다.
인덱스를 두 칸씩 밀면서 values[i + 1]을 직접 읽으면 마지막에 undefined가 딸려 옵니다. slice는 그런 자리를 만들지 않습니다.
13chunk 나누기
조각내기 코드는 없고 map이 [1, 2, 3, 13]의 값을 두 배로 바꿔 새 배열을 반환합니다. 일정 크기로 나누려면 인덱스를 크기만큼 건너뛰며 slice하는 반복문이 필요합니다. map은 배열의 모양을 바꾸지 못하고 요소 하나를 요소 하나로만 대응시킵니다.
JavaScript code
// chunk 나누기
const items = [1, 2, 3, 13];
console.log(items.map(value => value * 2));조각 나누기는 Array.from에 조각 수를 길이로 주고 콜백에서 slice를 부르면, 누적 배열 없이 표현식 하나로 끝낼 수 있습니다.
map으로 조각을 만들려 하면 결과 길이가 원본과 같아야 한다는 제약에 걸립니다. 요소 수가 바뀌는 변환에는 reduce나 반복문이 필요합니다.
14key 기준 정렬
정렬은 없고 Set이 중복 단어를 걷어 내며, 남은 순서는 처음 등장한 차례 그대로입니다. 키 기준 정렬이 필요하면 sort에 (a, b) => a.key.localeCompare(b.key) 같은 비교 함수를 넘기고, 한글이나 악센트가 섞인 문자열은 코드 포인트 비교보다 localeCompare 쪽이 자연스러운 순서를 냅니다.
JavaScript code
// key 기준 정렬
const words = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);localeCompare에 { numeric: true }를 주면 "item2"가 "item10"보다 앞에 오도록 숫자 부분을 값으로 비교합니다.
localeCompare는 호출마다 비교 규칙을 준비해 큰 배열에서는 느립니다. 항목이 많다면 Intl.Collator를 한 번 만들어 그 비교 함수를 넘기는 편이 낫습니다.
15빈도표 만들기
reduce가 팀별로 점수를 모아 { a: [2, 15], b: [3] }을 만듭니다. 빈도표라면 배열을 쌓는 대신 acc[key] = (acc[key] ?? 0) + 1처럼 숫자를 올리면 됩니다. 누적 객체를 초기값으로 넘기지 않으면 첫 요소가 초기값 자리를 차지해 타입이 어긋난 채 진행되므로 {}를 빠뜨리면 안 됩니다.
JavaScript 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);??=는 좌변이 null이나 undefined일 때만 대입하므로, 값이 0이거나 빈 배열인 그룹을 실수로 초기화해 버리지 않습니다.
누적 객체를 초기값으로 넘기지 않으면 첫 요소가 그 자리를 차지해, 배열이 비었을 때는 오류가 나고 아닐 때는 타입이 어긋난 채 계산이 진행됩니다.
16대소문자 무시 중복 제거
문자열이 아니라 숫자 배열을 두 개씩 잘라 담는 코드이고, 대소문자 처리는 어디에도 없습니다. 대소문자를 무시하고 중복을 없애려면 toLowerCase() 결과를 키로 쓰되 화면에 보일 원본 문자열은 값으로 따로 보관해야 합니다. 터키어의 I처럼 언어에 따라 소문자 변환 결과가 갈리는 경우도 있습니다.
JavaScript code
// 대소문자 무시 중복 제거
const values = [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);대소문자를 무시한 비교에는 toLowerCase보다 Intl.Collator의 sensitivity 옵션이 언어별 규칙까지 반영해 결과가 자연스럽습니다.
소문자로 바꾼 값만 남기고 원본을 버리면 화면에 표시할 표기가 사라집니다. 키는 정규화한 문자열로, 값은 처음 만난 원본으로 따로 보관해야 합니다.
17sliding window
윈도를 밀며 훑는 대신 map이 각 요소를 두 배로 바꾸는 한 줄뿐입니다. 슬라이딩 윈도는 시작 인덱스를 1씩 올리며 고정 길이 구간을 보는 방식이라, 구간이 겹치지 않는 조각내기와는 다릅니다. 구간 합을 유지하면서 빠진 값을 빼고 새 값을 더하면 매번 처음부터 다시 더하지 않아도 됩니다.
JavaScript code
// sliding window
const items = [1, 2, 3, 17];
console.log(items.map(value => value * 2));고정 길이 창이라면 합계를 유지하면서 나가는 값을 빼고 들어오는 값을 더해, 창 크기와 무관하게 요소마다 일정한 비용으로 처리할 수 있습니다.
창 크기가 배열 길이보다 크면 유효한 구간이 하나도 없습니다. 이 경우를 빈 결과로 볼지 오류로 볼지 정하지 않으면 호출부가 undefined를 만납니다.
18pair 목록을 맵으로 변환
Set으로 중복 문자열을 지워 배열 하나를 만드는 코드이고, 쌍이나 맵 변환은 들어 있지 않습니다. [[k, v], ...] 형태를 맵으로 바꿀 때는 new Map(pairs)가 그대로 받아 주고, 평범한 객체가 필요하면 Object.fromEntries(pairs)를 씁니다. 같은 키가 두 번 나오면 뒤쪽 값이 앞을 덮습니다.
JavaScript code
// pair 목록을 맵으로 변환
const words = ["js", "ts", "js", "rust"];
console.log([...new Set(words)]);Object.fromEntries는 Map도 그대로 받아 주므로 별도 변환 없이 Object.fromEntries(map)으로 일반 객체를 만들 수 있습니다.
그렇게 만든 객체는 키가 모두 문자열이 되어, 숫자 키를 쓰던 Map과 달리 obj[1]과 obj["1"]이 같은 자리를 가리킵니다.
19reverse 복사
역순 처리 대신 reduce로 팀별 점수 묶음을 만드는 코드입니다. 배열을 뒤집을 때 reverse()는 원본을 제자리에서 바꾸므로 사본이 필요하면 [...arr].reverse()로 감싸거나 원본을 남기는 toReversed()를 씁니다. 여기 reduce도 새 객체를 만들어 rows는 그대로 둡니다.
JavaScript 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는 원본을 두고 새 배열을 돌려주는 최근 메서드라, 전개 연산자로 감싸지 않아도 같은 결과를 얻습니다.
arr.reverse()의 반환값을 새 변수에 담아도 원본과 같은 배열입니다. 사본이라고 믿고 한쪽만 고치면 양쪽이 모두 바뀝니다.
20partition 나누기
조건으로 참과 거짓 두 묶음을 가르는 partition이 아니라, 인덱스를 두 칸씩 밀며 slice로 잘라 크기 2짜리 조각들을 만듭니다. 요소 수가 홀수면 마지막 조각만 짧아집니다. 조건 기준으로 나눌 때는 filter를 두 번 도는 대신 reduce 한 번으로 두 배열에 나눠 담으면 순회가 절반으로 줄어듭니다.
JavaScript code
// partition 나누기
const values = [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를 두 번 부르면서 조건을 x > 0과 x < 0처럼 짝이 맞지 않게 쓰면 0인 값이 어느 쪽에도 들어가지 않고 사라집니다.
정리하며
- 키가 문자열이 아니거나 순서가 중요하면 일반 객체 대신 Map을 씁니다
- groupBy, 빈도표, partition은 모두 reduce 누적 한 판으로 환원됩니다
- Set 중복 제거는 참조 비교이므로 객체 배열에는 키 함수를 따로 둡니다
- sort와 reverse는 원본을 바꾸므로 복사본을 만든 뒤 호출합니다
더 깊이 들어가고 싶다면 JavaScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.