다른 언어를 하다 온 사람이 자바스크립트 배열을 처음 만지면 두 번 놀란다. 한 번은 편해서, 한 번은 무서워서. 반복문을 직접 돌리지 않고 메서드 한 줄로 변환과 걸러내기를 끝내는 게 편하고, 그 메서드 중 어떤 건 원본을 조용히 뜯어고쳐 놓는 게 무섭다. 나는 이 무서운 쪽을 모르고 넘어갔다가 원본 배열이 어디선가 헝클어지는 버그를 며칠씩 쫓았다.
이번 편은 매일 쓰는 배열 메서드를 상황과 실행 결과로 짚는다. 외울 건 이름이 아니라 성질이다. 새 배열을 주는가 원본을 바꾸는가, 무엇을 반환하는가. 이 둘만 붙으면 배열이 무섭지 않다.
배열은 순서 있는 값 묶음이다. 대괄호로 만들고 0부터 세는 인덱스로 꺼낸다. 길이는 length로 본다. 파이썬처럼 음수 인덱스로 뒤에서 꺼내는 건 안 되니, 뒤에서 꺼내려면 뒤에 나올 slice나 at을 써야 한다.
const nums = [10, 2, 33, 4];
console.log(nums.length);
console.log(nums[2]);
실행하면 길이는 4, 세 번째 요소인 nums[2]는 33이 찍힌다. 인덱스가 0부터라 세 번째가 2번이라는 이 한 칸 차이가, 초보 시절 경계선 버그의 절반을 만든다.
push와 pop은 원본을 바꾼다. 뒤에 넣는 push, 뒤에서 빼는 pop, 앞에 넣는 unshift, 앞에서 빼는 shift. 이 넷은 전부 원본 배열을 직접 수정한다. 그리고 반환값이 헷갈린다. push는 바뀐 길이를 주고, pop은 빠진 요소를 준다.
const arr = [1, 2, 3];
arr.push(4);
const out = arr.pop();
arr.push(4) 뒤 배열은 [1, 2, 3, 4]가 되고, arr.pop()은 꺼낸 값 4를 out에 담으면서 배열을 다시 [1, 2, 3]으로 되돌린다. 나는 예전에 const last = arr.push(x)라고 써놓고 last가 방금 넣은 값인 줄 알았다. 실제로는 길이가 담겨서, 한참 뒤 엉뚱한 계산이 터졌다.
map은 하나씩 변환해 새 배열을 만든다. 여기서부터가 자바스크립트 배열의 진짜 맛이다. map은 모든 요소에 함수를 먹여 같은 길이의 새 배열을 돌려준다. 원본은 안 건드린다. 가격 배열에 부가세를 붙인다고 하자.
const prices = [1000, 2000, 3000];
const withTax = prices.map(p => p * 1.1);
결과는 [1100, 2200, 3300.0000000000005]다. 마지막 값이 딱 떨어지지 않는 건 오타가 아니라 부동소수점(실수를 이진수로 근사 저장하는 방식)의 오차다. 돈 계산이라면 원 단위 정수로 다루거나 마지막에 반올림해야 한다. map 자체는 정확히 세 요소를 세 요소로 바꿔줬을 뿐이다.
filter는 조건에 맞는 것만 남긴다. 넘긴 함수가 참을 주는 요소만 골라 새 배열로 만든다. 60점 이상만 뽑아보자.
const scores = [85, 42, 90, 30, 70];
const passed = scores.filter(s => s >= 60);
passed는 [85, 90, 70]이 된다. 조건을 만족한 셋만 순서 그대로 넘어온다. map이 길이를 유지하며 값을 바꾼다면, filter는 값을 그대로 두고 개수를 줄인다. 이 차이를 붙잡으면 둘을 헷갈리지 않는다.
reduce는 배열을 하나의 값으로 접는다. 제일 강력하고 제일 오래 헤매는 메서드다. 누적값과 현재 요소를 받아 다음 누적값을 만든다. 장바구니 합계를 내보자.
const cart = [3000, 5000, 2000];
const total = cart.reduce((sum, price) => sum + price, 0);
결과는 10000이다. 여기서 마지막 인자 0이 초기값인데, 이걸 빼먹으면 함정에 빠진다. 초기값이 없으면 배열의 첫 요소가 초기값이 되고 두 번째부터 순회한다. 요소가 하나뿐인 배열에서 초기값을 빼면 그 요소가 그냥 반환되고 함수는 한 번도 안 불린다. 나는 빈 배열에 초기값 없이 reduce를 돌렸다가 프로그램이 통째로 멈추는 걸 겪은 뒤로, 초기값을 절대 안 빼먹는다.
find와 includes로 찾는다. 조건에 맞는 첫 요소가 필요하면 find, 위치가 필요하면 findIndex, 특정 값이 있는지만 알면 되면 includes다.
const users = [{id:1}, {id:2}, {id:3}];
const u = users.find(u => u.id === 2);
const has = [85, 90, 70].includes(90);
u는 {id:2} 객체를 통째로 주고, 없으면 undefined를 준다. has는 true다. filter는 맞는 걸 배열로 다 모으지만 find는 첫 하나만 찾고 멈춘다. 하나만 필요한데 filter로 다 훑는 건 낭비다.
sort는 기본이 문자열 정렬이다. 이게 자바스크립트에서 가장 악명 높은 함정이다. 비교 함수를 안 넘기면 요소를 문자열로 바꿔 사전순으로 정렬한다. 숫자 배열에도 그렇게 한다.
const n = [10, 2, 1, 33];
console.log([...n].sort());
console.log([...n].sort((a, b) => a - b));
기본 sort 결과는 [1, 10, 2, 33]이다. 10이 2보다 앞에 온다. 문자열로 보면 "10"이 "2"보다 앞서기 때문이다. 숫자로 제대로 정렬하려면 (a, b) => a - b를 넘겨야 [1, 2, 10, 33]이 나온다. 게다가 sort는 원본까지 바꾼다. 그래서 나는 항상 펼치기로 복사한 뒤 정렬한다.
slice는 안 건드리고 splice는 건드린다. 이름이 비슷해 자주 바꿔 쓰는데, 성질이 정반대다. slice는 잘라낸 조각을 새 배열로 주고 원본은 그대로 둔다. splice는 원본에서 요소를 잘라내고 제거한 걸 반환한다.
const a = [1, 2, 3, 4, 5];
const part = a.slice(1, 3);
const b = [1, 2, 3, 4, 5];
const cut = b.splice(1, 2);
part는 [2, 3]이고 a는 [1, 2, 3, 4, 5] 그대로다. 반면 splice는 cut에 [2, 3]을 담으면서 b를 [1, 4, 5]로 잘라 놓는다. 읽기만 할 땐 slice, 진짜 잘라내야 할 때만 splice. 이 한 글자 차이가 원본을 지키느냐 마느냐를 가른다.
펼치기로 복사하고 구조분해로 꺼낸다. 점 세 개 펼치기 연산자는 배열을 낱개로 흩어 새 배열에 담는다. 원본과 끊긴 얕은 복사본이 만들어진다.
const orig = [1, 2, 3];
const copy = [...orig];
copy.push(99);
const merged = [...[1, 2], ...[3, 4]];
const [first, second, ...rest] = [10, 20, 30, 40];
copy에 99를 넣어도 orig는 [1, 2, 3] 그대로다. 앞에서 sort가 원본을 망칠 때 이 복사가 방패가 된다. merged는 두 배열을 이어붙인 [1, 2, 3, 4]가 된다. 마지막 구조분해는 first에 10, second에 20을 담고 남은 [30, 40]을 rest에 몰아준다. 함수가 여러 값을 배열로 돌려줄 때 이렇게 한 줄로 풀어 받으면 코드가 확 짧아진다.
메서드는 이어붙일 수 있다. map, filter, reduce가 전부 새 배열이나 값을 반환하니 점으로 계속 연결된다. 짝수만 골라 제곱해서 합하는 걸 한 흐름으로 써보자.
const r = [1, 2, 3, 4, 5, 6]
.filter(x => x % 2 === 0)
.map(x => x * x)
.reduce((s, x) => s + x, 0);
짝수 [2, 4, 6]이 [4, 16, 36]으로 바뀌고 합해져 r은 56이 된다. 반복문으로 쓰면 중간 변수와 조건이 뒤엉키는데, 체이닝은 각 단계가 무슨 일을 하는지 위에서 아래로 읽힌다. 조건 검사만 필요하면 하나라도 맞는지 보는 some, 전부 맞는지 보는 every가 배열 전체를 안 돌고 빨리 끝나 유용하다.
배열 메서드를 고를 때 나는 매번 같은 질문을 한다. 이건 원본을 바꾸나 새 걸 주나, 반환값이 뭔가. map과 filter와 slice는 새 배열을 주고, push와 sort와 splice는 원본을 건드린다. 이 경계만 또렷하면 배열에서 나던 유령 버그의 대부분이 애초에 생기지 않는다. 헷갈리는 메서드는 만날 때마다 작은 배열로 직접 찍어보는 게, 문서를 열 번 읽는 것보다 오래 남는다.