JavaScript 배열과 객체: 자주 쓰는 Array·Object 메서드

이 글의 핵심

sort가 원본 배열을 바꾸고 숫자를 문자열 순서로 정렬해 버리는 것처럼, 배열 메서드는 원본을 변경하는지 새 배열을 반환하는지부터 구분해야 안전합니다. forEach와 map의 차이, spread 복사가 얕은 복사라는 함정을 짚고, reduce와 체이닝으로 API 응답을 원하는 모양으로 바꾸는 예제를 다룹니다.

들어가며

배열과 객체

배열(Array)은 순서가 있는 값의 목록이며, 객체(Object)는 이름(키)으로 값을 묶은 맵에 가깝습니다. 둘 다 동적이므로 실행 중에 요소·속성을 자유롭게 넣고 빼는 패턴이 흔합니다.

이 글의 메서드들을 공부할 때 가장 먼저 붙잡아야 할 기준은 두 가지입니다. 하나는 원본을 바꾸는가, 새 값을 돌려주는가이고, 다른 하나는 복사가 얕은가 깊은가입니다. React의 상태나 Redux 스토어처럼 “새 객체인지”로 변경을 감지하는 코드에서는 원본을 바꾸는 push나 sort를 쓰면 화면이 갱신되지 않고, 얕은 복사를 깊은 복사로 착각하면 다른 컴포넌트의 데이터가 함께 바뀝니다. 아래 예제를 읽으면서 각 메서드가 어느 쪽인지 표시해 두면 실수가 크게 줄어듭니다.

참고로 예제들은 설명을 위해 같은 변수 이름(arr, numbers, users)을 여러 블록에서 let으로 다시 선언합니다. 블록을 한꺼번에 브라우저 콘솔에 붙여 넣으면 SyntaxError: Identifier 'arr' has already been declared가 나므로, 블록 하나씩 실행하거나 { }로 감싸서 실행하십시오.


배열 생성과 주요 메서드

배열 생성과 접근

// 배열 생성
let fruits = ["apple", "banana", "cherry"];
let numbers = [1, 2, 3, 4, 5];
let mixed = [1, "hello", true, null, { name: "홍길동" }];
// 빈 배열
let empty1 = [];
let empty2 = new Array();
// 특정 크기 배열
let arr = new Array(5);  // [empty × 5]
console.log(arr.length);  // 5
// 인덱싱 (0부터 시작)
console.log(fruits[0]);   // apple
console.log(fruits[1]);   // banana
console.log(fruits[-1]);  // undefined (음수 인덱스 없음)
// 마지막 요소
console.log(fruits[fruits.length - 1]);  // cherry
// 수정
fruits[1] = "grape";
console.log(fruits);  // ['apple', 'grape', 'cherry']

fruits[-1]이 undefined인 이유는 JavaScript 배열이 사실 인덱스를 문자열 키로 가진 객체이기 때문입니다. fruits[-1]은 "-1"이라는 이름의 속성을 찾는 것이고, 그런 속성이 없으니 undefined입니다. 에러가 나지 않으므로 Python 습관으로 음수 인덱스를 쓰면 조용히 틀린 값이 나옵니다. ES2022부터는 fruits.at(-1)로 마지막 요소를 읽을 수 있습니다.

new Array(5)는 값이 undefined인 칸 5개가 아니라 아무것도 없는 구멍(hole) 5개를 만듭니다. map, forEach, filter는 구멍을 건너뛰기 때문에 new Array(5).map((_, i) => i)는 기대와 달리 [empty × 5]를 그대로 돌려줍니다. 0부터 4까지 채운 배열이 필요하면 Array.from({ length: 5 }, (_, i) => i)를 씁니다. 또 new Array(5)와 new Array(5, 6)은 동작이 달라서(앞은 길이 5, 뒤는 요소 두 개) 헷갈리기 쉬우므로, 배열 생성은 리터럴 []과 Array.from을 쓰는 것이 일반적입니다.

배열 메서드: 추가/삭제

let arr = [1, 2, 3];
// push: 끝에 추가
arr.push(4);
console.log(arr);  // [1, 2, 3, 4]
// pop: 끝에서 제거
let last = arr.pop();
console.log(last);  // 4
console.log(arr);   // [1, 2, 3]
// unshift: 앞에 추가
arr.unshift(0);
console.log(arr);  // [0, 1, 2, 3]
// shift: 앞에서 제거
let first = arr.shift();
console.log(first);  // 0
console.log(arr);    // [1, 2, 3]
// splice: 중간 삽입/삭제
arr.splice(1, 1);  // 인덱스 1에서 1개 제거
console.log(arr);  // [1, 3]
arr.splice(1, 0, 2);  // 인덱스 1에 2 삽입
console.log(arr);  // [1, 2, 3]
arr.splice(1, 1, 10, 20);  // 인덱스 1에서 1개 제거하고 10, 20 삽입
console.log(arr);  // [1, 10, 20, 3]

push/pop은 배열 끝에서 동작하므로 요소 개수와 상관없이 빠르지만, unshift/shift는 앞에 추가·제거한 뒤 나머지 요소의 인덱스를 모두 한 칸씩 옮겨야 해서 배열이 크면 느려집니다. 큐처럼 앞에서 계속 꺼내는 구조를 큰 배열의 shift()로 구현하면 요소 수에 비례하는 비용이 매번 붙습니다. 이름이 비슷한 splice와 slice도 자주 헷갈리는데, splice는 원본을 수정하고 제거된 요소 배열을 반환하며, slice는 원본을 두고 잘라낸 복사본을 반환합니다. 원본을 유지하면서 splice처럼 쓰고 싶다면 ES2023의 toSpliced()를 쓸 수 있습니다.

배열 메서드: 검색

let numbers = [1, 2, 3, 4, 5, 3];
// indexOf: 첫 번째 인덱스
console.log(numbers.indexOf(3));  // 2
console.log(numbers.indexOf(10)); // -1 (없음)
// lastIndexOf: 마지막 인덱스
console.log(numbers.lastIndexOf(3));  // 5
// includes: 존재 여부 (ES2016+)
console.log(numbers.includes(3));  // true
console.log(numbers.includes(10)); // false
// find: 조건에 맞는 첫 요소
let found = numbers.find(x => x > 3);
console.log(found);  // 4
// findIndex: 조건에 맞는 첫 인덱스
let index = numbers.findIndex(x => x > 3);
console.log(index);  // 3
// some: 하나라도 조건 만족
console.log(numbers.some(x => x > 4));  // true
// every: 모두 조건 만족
console.log(numbers.every(x => x > 0));  // true
console.log(numbers.every(x => x > 2));  // false

indexOf와 includes는 비슷하지만 NaN 처리가 다릅니다. indexOf는 === 비교를 하므로 [NaN].indexOf(NaN)은 -1이고, includes는 SameValueZero 비교를 해서 [NaN].includes(NaN)은 true입니다. 두 메서드 모두 객체는 참조로 비교하므로, users.includes({ name: "홍길동" })은 내용이 같은 객체가 있어도 false입니다. 객체 배열에서는 some(u => u.name === "홍길동")이나 find를 써야 합니다.

find는 찾지 못하면 undefined, findIndex는 -1을 돌려줍니다. const user = users.find(...); user.name처럼 결과를 확인하지 않고 쓰면 TypeError: Cannot read properties of undefined (reading 'name')가 나는 것이 가장 흔한 실수입니다. find(...)?.name처럼 옵셔널 체이닝을 쓰거나 결과를 먼저 확인하십시오. some과 every는 답이 정해지는 순간 순회를 멈추므로, 큰 배열에서 “하나라도 있는가”를 확인할 때 filter(...).length > 0보다 효율적입니다. 빈 배열에서 every는 항상 true를 돌려준다는 점도 기억해 두면 좋습니다.

배열 메서드: 변환

배열을 변환하는 핵심 메서드들입니다:

let numbers = [1, 2, 3, 4, 5];
// 1. map: 각 요소를 변환하여 새 배열 생성
let doubled = numbers.map(x => x * 2);
// 동작:
// [1, 2, 3, 4, 5]
//  ↓  ↓  ↓  ↓  ↓  각 요소에 함수 적용
// [2, 4, 6, 8, 10]
// 
// 원본 배열은 변경되지 않음 (불변성)
console.log(doubled);  // [2, 4, 6, 8, 10]
console.log(numbers);  // [1, 2, 3, 4, 5] (원본 유지)
// map 실전 예시: 객체 배열 변환
let users = [
    { name: "홍길동", age: 25 },
    { name: "김철수", age: 30 }
];
let names = users.map(user => user.name);
console.log(names);  // ['홍길동', '김철수']
// 2. filter: 조건을 만족하는 요소만 선택
let evens = numbers.filter(x => x % 2 === 0);
// 동작:
// [1, 2, 3, 4, 5]
//  ↓  ✓  ↓  ✓  ↓  조건 체크 (짝수만 통과)
// [2, 4]
console.log(evens);  // [2, 4]
// filter 실전 예시: 성인만 필터링
let adults = users.filter(user => user.age >= 20);
// 3. reduce: 배열을 하나의 값으로 축약
let sum = numbers.reduce((acc, x) => acc + x, 0);
// acc: 누적값 (accumulator)
// x: 현재 요소
// 0: 초기값
// 
// 동작 과정:
// acc=0, x=1 → 0+1=1
// acc=1, x=2 → 1+2=3
// acc=3, x=3 → 3+3=6
// acc=6, x=4 → 6+4=10
// acc=10, x=5 → 10+5=15
console.log(sum);  // 15
// reduce로 평균 계산
let avg = numbers.reduce((acc, x, i, arr) => {
    // acc: 누적값
    // x: 현재 요소
    // i: 현재 인덱스
    // arr: 원본 배열
    acc += x;
    // 마지막 요소면 평균 계산
    return i === arr.length - 1 ? acc / arr.length : acc;
}, 0);
console.log(avg);  // 3
// reduce로 객체 변환 (배열 → 객체)
let words = ["apple", "banana", "cherry"];
let wordLengths = words.reduce((acc, word) => {
    // acc: 누적 객체 (처음엔 빈 객체 {})
    // word: 현재 문자열
    acc[word] = word.length;  // 객체에 프로퍼티 추가
    return acc;  // 누적 객체 반환
}, {});
console.log(wordLengths);
// { apple: 5, banana: 6, cherry: 6 }
// reduce로 최댓값 찾기
let max = numbers.reduce((acc, x) => Math.max(acc, x));
console.log(max);  // 5
// reduce로 배열 평탄화
let nested = [[1, 2], [3, 4], [5]];
let flattened = nested.reduce((acc, arr) => acc.concat(arr), []);
console.log(flattened);  // [1, 2, 3, 4, 5]

reduce는 가장 강력하지만 가장 읽기 어려운 메서드입니다. 몇 가지 규칙을 알아 두면 실수를 피할 수 있습니다.

첫째, 초기값을 생략하면 첫 요소가 초기값이 되고 순회는 두 번째 요소부터 시작합니다. 최댓값 예제처럼 초기값 없이 쓰면 간결하지만, 빈 배열에서 호출하면 TypeError: Reduce of empty array with no initial value가 납니다. API 응답처럼 비어 있을 수 있는 배열에는 항상 초기값을 주십시오. 최댓값이라면 Math.max(...numbers)가 더 읽기 쉽지만, 요소가 수십만 개 이상이면 인자 개수 제한으로 RangeError: Maximum call stack size exceeded가 날 수 있어 이때는 reduce가 안전합니다.

둘째, 평균 예제처럼 “마지막 요소에서만 다른 값을 반환”하는 방식은 동작은 하지만 누적값의 의미가 중간에 바뀌어 읽기 어렵습니다. numbers.reduce((a, x) => a + x, 0) / numbers.length로 합계와 나눗셈을 분리하는 편이 명확합니다. 셋째, 평탄화 예제의 acc.concat(arr)은 매 단계 새 배열을 만들어 요소가 많으면 O(n²)이 됩니다. 평탄화는 전용 메서드인 flat()을 쓰는 것이 맞습니다. 객체를 만드는 reduce는 누적 객체를 직접 수정하고 반환하는 위 방식이 효율적이며, 매번 { ...acc, [word]: word.length }처럼 새 객체를 펼쳐 만들면 역시 O(n²)이 됩니다. 메서드 체이닝:

// map, filter, reduce를 연결하여 사용
// 변수 선언 및 초기화
let result = numbers
    .filter(x => x > 2)      // [3, 4, 5]
    .map(x => x * 2)         // [6, 8, 10]
    .reduce((a, b) => a + b, 0);  // 24
console.log(result);  // 24

원본 배열 변경 여부:

  • 변경함: push, pop, shift, unshift, splice, sort, reverse, fill
  • 변경 안 함: map, filter, reduce, slice, concat, toSorted, toReversed, toSpliced, with

ES2023에서 추가된 toSorted(), toReversed(), toSpliced(), with(index, value)는 원본을 바꾸는 메서드들의 복사본을 반환하는 버전입니다. React 상태를 정렬할 때 [...list].sort(...) 대신 list.toSorted(...)로 쓸 수 있습니다. 최신 브라우저와 Node.js 20 이상에서 지원됩니다.

배열 메서드: 정렬

let numbers = [3, 1, 4, 1, 5, 9, 2];
// sort: 원본 변경
numbers.sort();
console.log(numbers);  // [1, 1, 2, 3, 4, 5, 9]
// 내림차순
numbers.sort((a, b) => b - a);
console.log(numbers);  // [9, 5, 4, 3, 2, 1, 1]
// 문자열 정렬
let words = ["banana", "apple", "cherry"];
words.sort();
console.log(words);  // ['apple', 'banana', 'cherry']
// 객체 배열 정렬
let users = [
    { name: "홍길동", age: 25 },
    { name: "김철수", age: 30 },
    { name: "이영희", age: 20 }
];
users.sort((a, b) => a.age - b.age);
console.log(users);
// [{ name: '이영희', age: 20 }, ...]
// reverse: 역순
numbers.reverse();
console.log(numbers);  // [1, 1, 2, 3, 4, 5, 9]

비교 함수는 두 요소 a, b를 받아 음수면 a를 앞에, 양수면 b를 앞에, 0이면 순서 유지를 뜻하는 숫자를 돌려줘야 합니다. a - b가 오름차순인 이유가 이것입니다. 흔한 실수는 (a, b) => a > b처럼 불리언을 돌려주는 것입니다. true/false는 1/0으로 변환되어 음수가 나오지 않으므로 엔진에 따라 정렬이 되다 말거나 전혀 되지 않습니다.

첫 줄의 numbers.sort()가 올바르게 정렬된 것은 모든 숫자가 한 자리여서 문자열 비교와 결과가 같았기 때문일 뿐입니다(아래 “실수 2” 참고). 한글 이름이나 대소문자가 섞인 문자열은 기본 정렬이 UTF-16 코드 단위 순서라서, 대문자가 소문자보다 먼저 오는 등 사람이 기대하는 순서와 다를 수 있습니다. 사용자에게 보여 줄 목록은 words.sort((a, b) => a.localeCompare(b, 'ko'))처럼 로캘 비교를 쓰는 것이 좋습니다. ES2019부터 sort는 안정 정렬이 보장되므로, 나이가 같은 사용자들은 원래 순서를 유지합니다.

배열 메서드: 기타

let arr = [1, 2, 3, 4, 5];
// slice: 부분 배열 (원본 유지)
let sub = arr.slice(1, 4);
console.log(sub);  // [2, 3, 4]
console.log(arr);  // [1, 2, 3, 4, 5] (원본 유지)
// concat: 배열 연결
let arr1 = [1, 2];
let arr2 = [3, 4];
let combined = arr1.concat(arr2);
console.log(combined);  // [1, 2, 3, 4]
// join: 문자열로 변환
let joined = arr.join(", ");
console.log(joined);  // 1, 2, 3, 4, 5
// flat: 평탄화 (ES2019+)
let nested = [1, [2, 3], [4, [5, 6]]];
console.log(nested.flat());     // [1, 2, 3, 4, [5, 6]]
console.log(nested.flat(2));    // [1, 2, 3, 4, 5, 6]
console.log(nested.flat(Infinity));  // [1, 2, 3, 4, 5, 6]
// flatMap: map + flat
let words = ["hello world", "foo bar"];
let chars = words.flatMap(word => word.split(" "));
console.log(chars);  // ['hello', 'world', 'foo', 'bar']

flat()의 기본 깊이는 1입니다. 그래서 nested.flat()은 [5, 6]을 그대로 남기고, 깊이를 모를 때는 flat(Infinity)를 씁니다. flatMap은 map 후 깊이 1로 flat하는 것과 같아서, 한 요소를 여러 개로 늘리거나(위 예제) 빈 배열을 반환해 요소를 없애는 용도로도 쓸 수 있습니다. arr.flatMap(x => x > 0 ? [x * 2] : [])는 filter와 map을 한 번의 순회로 처리합니다.


객체 생성과 Object 정적 메서드

객체 생성과 접근

// 객체 리터럴
let person = {
    name: "홍길동",
    age: 25,
    city: "서울"
};
// 빈 객체
let empty = {};
// 접근
console.log(person.name);      // 홍길동 (점 표기법)
console.log(person["age"]);    // 25 (대괄호 표기법)
// 동적 키
let key = "city";
console.log(person[key]);  // 서울
// 추가/수정
person.job = "개발자";  // 추가
person.age = 26;        // 수정
console.log(person);
// { name: '홍길동', age: 26, city: '서울', job: '개발자' }
// 삭제
delete person.city;
console.log(person);
// { name: '홍길동', age: 26, job: '개발자' }

점 표기법과 대괄호 표기법은 대부분 같지만, 키가 변수에 들어 있거나 "first-name"처럼 식별자로 쓸 수 없는 문자열이면 대괄호만 가능합니다. 흔한 실수는 person.key로 변수 key의 값을 속성 이름으로 쓰려는 것인데, 이것은 "key"라는 이름의 속성을 찾으므로 undefined가 나옵니다. 존재하지 않는 속성을 읽어도 에러가 아니라 undefined가 나오기 때문에, 오타(person.nmae)도 조용히 넘어간다는 점이 JavaScript 객체의 가장 큰 함정입니다.

사용자 입력을 키로 쓰는 “사전” 용도라면 일반 객체보다 Map이 적합합니다. 일반 객체에는 toString, constructor 같은 상속된 속성이 있어서 counts["constructor"]가 undefined가 아니라 함수가 나오고, "__proto__" 같은 특수 키는 프로토타입을 건드립니다. Map은 이런 충돌이 없고, 키로 객체도 쓸 수 있으며, 삽입 순서를 유지하고 size로 개수를 바로 알 수 있습니다.

객체 메서드

let person = {
    name: "홍길동",
    age: 25,
    greet: function() {
        console.log(`안녕하세요, ${this.name}입니다.`);
    },
    // 축약 문법 (ES6+)
    introduce() {
        console.log(`${this.age}살 ${this.name}입니다.`);
    }
};
person.greet();      // 안녕하세요, 홍길동입니다.
person.introduce();  // 25살 홍길동입니다.

객체 메서드 안의 this는 호출하는 방식으로 정해집니다. person.greet()처럼 점 앞의 객체로 호출하면 this가 person이지만, const fn = person.greet; fn();처럼 메서드를 떼어 내 호출하거나 setTimeout(person.greet, 1000)처럼 콜백으로 넘기면 this가 사라져 undefined(엄격 모드)나 전역 객체가 됩니다. 그 결과 this.name이 undefined로 나오거나 TypeError가 납니다. 또 메서드를 화살표 함수(greet: () => ...)로 정의하면 화살표 함수는 자기 this를 갖지 않아 person을 가리키지 않습니다. 객체 메서드에는 위의 축약 문법을 쓰고, 콜백으로 넘길 때는 () => person.greet()로 감싸거나 person.greet.bind(person)을 쓰십시오.

Object 정적 메서드

let person = { name: "홍길동", age: 25, city: "서울" };
// Object.keys(): 키 배열
console.log(Object.keys(person));
// ['name', 'age', 'city']
// Object.values(): 값 배열
console.log(Object.values(person));
// ['홍길동', 25, '서울']
// Object.entries(): [키, 값] 배열
console.log(Object.entries(person));
// [['name', '홍길동'], ['age', 25], ['city', '서울']]
// 순회
for (let [key, value] of Object.entries(person)) {
    console.log(`${key}: ${value}`);
}
// Object.assign(): 객체 병합
let defaults = { theme: "dark", lang: "ko" };
let userSettings = { lang: "en" };
let settings = Object.assign({}, defaults, userSettings);
console.log(settings);  // { theme: 'dark', lang: 'en' }
// Object.freeze(): 불변 객체
let frozen = Object.freeze({ x: 10 });
frozen.x = 20;  // 무시됨
console.log(frozen.x);  // 10
// Object.seal(): 추가/삭제 불가, 수정 가능
let sealed = Object.seal({ x: 10 });
sealed.x = 20;  // OK
sealed.y = 30;  // 무시됨
console.log(sealed);  // { x: 20 }

Object.keys와 Object.entries는 자기 자신의 열거 가능한 문자열 키만 돌려줍니다. 순서는 대체로 삽입 순서지만, "1", "2"처럼 정수 형태의 키는 항상 숫자 오름차순으로 먼저 나온다는 예외가 있습니다. { b: 1, 2: 2, a: 3, 1: 4 }의 키 순서는 ['1', '2', 'b', 'a']입니다.

Object.assign과 spread는 얕은 병합입니다. defaults에 { editor: { fontSize: 14, tabSize: 2 } } 같은 중첩 객체가 있고 사용자 설정이 { editor: { fontSize: 16 } }이면, 병합 결과의 editor는 통째로 교체되어 tabSize가 사라집니다. 설정 병합에서 흔히 겪는 버그이고, 중첩 구조를 유지하려면 깊은 병합이 필요합니다(아래 예제 2).

freeze와 seal의 “무시됨”은 비엄격 모드에서만 그렇습니다. ES 모듈(<script type="module">, import를 쓰는 파일)과 클래스 본문은 자동으로 엄격 모드이므로, 같은 코드가 TypeError: Cannot assign to read only property 'x' of object를 던집니다. 조용히 무시되는 것보다는 차라리 에러가 나는 편이 낫지만, 스크립트를 모듈로 옮긴 뒤 갑자기 에러가 난다면 이 차이 때문입니다. 또 freeze도 얕아서 Object.freeze({ inner: { x: 1 } }).inner.x = 2는 그대로 성공합니다.


배열·객체·매개변수 구조 분해

배열 구조 분해

let arr = [1, 2, 3, 4, 5];
// 기본
let [a, b] = arr;
console.log(a, b);  // 1 2
// 나머지
let [first, ...rest] = arr;
console.log(first);  // 1
console.log(rest);   // [2, 3, 4, 5]
// 건너뛰기
let [x, , z] = arr;
console.log(x, z);  // 1 3
// 기본값
let [p, q, r, s, t, u = 0] = arr;
console.log(u);  // 0
// 스왑
let [m, n] = [10, 20];
[m, n] = [n, m];
console.log(m, n);  // 20 10

객체 구조 분해

let person = { name: "홍길동", age: 25, city: "서울" };
// 기본
let { name, age } = person;
console.log(name, age);  // 홍길동 25
// 새 변수명
let { name: userName, age: userAge } = person;
console.log(userName, userAge);  // 홍길동 25
// 기본값
let { name, age, job = "없음" } = person;
console.log(job);  // 없음
// 나머지
let { name, ...rest } = person;
console.log(name);  // 홍길동
console.log(rest);  // { age: 25, city: '서울' }
// 중첩 구조 분해
let user = {
    id: 1,
    profile: {
        name: "홍길동",
        age: 25
    }
};
let { profile: { name, age } } = user;
console.log(name, age);  // 홍길동 25

구조 분해의 기본값은 값이 undefined일 때만 적용되고, null일 때는 적용되지 않습니다. API가 { job: null }을 보내면 let { job = "없음" } = data의 job은 null입니다. null까지 대체하려면 const job = data.job ?? "없음"처럼 널 병합 연산자를 씁니다.

중첩 구조 분해에서는 중간 객체가 없을 때 에러가 납니다. user에 profile이 없으면 let { profile: { name } } = user는 TypeError: Cannot read properties of undefined (reading 'name')를 던집니다. 응답 형태가 보장되지 않는다면 let { profile: { name } = {} } = user처럼 중간 단계에도 기본값을 두어야 합니다. 이 예제에서 profile은 변수가 되지 않고 경로로만 쓰인다는 점도 헷갈리기 쉬운데, profile 자체도 쓰고 싶다면 let { profile, profile: { name } } = user처럼 따로 적어야 합니다.

함수 매개변수 구조 분해

// 객체 구조 분해
function printUser({ name, age, city = "서울" }) {
    console.log(`${name} (${age}세, ${city})`);
}
printUser({ name: "홍길동", age: 25 });
// 홍길동 (25세, 서울)
// 배열 구조 분해
function getMinMax([first, ...rest]) {
    return {
        min: Math.min(first, ...rest),
        max: Math.max(first, ...rest)
    };
}
console.log(getMinMax([3, 1, 4, 1, 5]));
// { min: 1, max: 5 }

Spread와 Rest 연산자

Spread 연산자 (…)

// 배열 펼치기
let arr1 = [1, 2, 3];
let arr2 = [4, 5, 6];
let combined = [...arr1, ...arr2];
console.log(combined);  // [1, 2, 3, 4, 5, 6]
// 배열 복사
let original = [1, 2, 3];
let copy = [...original];
copy[0] = 100;
console.log(original);  // [1, 2, 3] (원본 유지)
console.log(copy);      // [100, 2, 3]
// 함수 인자로 펼치기
function add(a, b, c) {
    return a + b + c;
}
let numbers = [1, 2, 3];
console.log(add(...numbers));  // 6
// 객체 펼치기 (ES2018+)
let obj1 = { a: 1, b: 2 };
let obj2 = { c: 3, d: 4 };
let merged = { ...obj1, ...obj2 };
console.log(merged);  // { a: 1, b: 2, c: 3, d: 4 }
// 객체 복사
let person = { name: "홍길동", age: 25 };
let personCopy = { ...person };
personCopy.age = 26;
console.log(person.age);      // 25 (원본 유지)
console.log(personCopy.age);  // 26
// 속성 덮어쓰기
let defaults = { theme: "dark", lang: "ko" };
let userSettings = { ...defaults, lang: "en" };
console.log(userSettings);  // { theme: 'dark', lang: 'en' }

spread로 복사한 결과가 “원본 유지”로 나오는 것은 복사한 값이 숫자·문자열 같은 원시값이기 때문입니다. 중첩된 객체나 배열은 복사되지 않고 같은 참조가 공유되므로, copy.address.city = "부산"은 원본의 주소도 바꿉니다(아래 “실수 1”). 객체 spread에서는 뒤에 쓴 속성이 앞의 속성을 덮어쓰므로, 기본값을 먼저 펼치고 사용자 값을 나중에 두어야 합니다. 순서를 거꾸로 쓰면 사용자 설정이 기본값에 덮여 버립니다. 또 spread는 자기 자신의 열거 가능한 속성만 복사하므로, 클래스 인스턴스를 { ...instance }로 복사하면 프로토타입의 메서드가 사라진 평범한 객체가 됩니다.

Rest 연산자

// 함수 매개변수
function sum(...numbers) {
    return numbers.reduce((acc, x) => acc + x, 0);
}
console.log(sum(1, 2, 3));        // 6
console.log(sum(1, 2, 3, 4, 5));  // 15
// 배열 구조 분해
let [first, second, ...rest] = [1, 2, 3, 4, 5];
console.log(first);  // 1
console.log(second); // 2
console.log(rest);   // [3, 4, 5]
// 객체 구조 분해
let { name, ...otherInfo } = { name: "홍길동", age: 25, city: "서울" };
console.log(name);       // 홍길동
console.log(otherInfo);  // { age: 25, city: '서울' }

forEach·map·reduce와 메서드 체이닝

forEach vs map

let numbers = [1, 2, 3];
// forEach: 반환값 없음
numbers.forEach(x => console.log(x * 2));
// 2 4 6
// map: 새 배열 반환
let doubled = numbers.map(x => x * 2);
console.log(doubled);  // [2, 4, 6]

둘 중 무엇을 쓸지는 “결과 배열이 필요한가”로 정합니다. 새 배열이 필요하면 map, 로그 출력이나 DOM 조작처럼 부수 효과만 필요하면 forEach입니다. map을 결과를 쓰지 않는 반복문 대용으로 쓰면 버려지는 배열이 만들어지고, 읽는 사람도 결과가 어디 쓰이는지 찾게 됩니다.

forEach에는 알아 둘 제약이 두 가지 있습니다. break로 중간에 멈출 수 없고(return은 현재 콜백만 끝냅니다), async 콜백을 기다리지 않습니다. items.forEach(async item => { await save(item); }) 다음 줄은 저장이 끝나기 전에 실행되고, 저장 중 에러는 어디서도 잡히지 않습니다. 순서대로 처리하려면 for (const item of items) { await save(item); }, 병렬로 처리하려면 await Promise.all(items.map(item => save(item)))을 씁니다. 처음 비동기 코드를 쓸 때 가장 자주 만드는 버그 중 하나입니다.

reduce 심화

let numbers = [1, 2, 3, 4, 5];
// 합계
let sum = numbers.reduce((acc, x) => acc + x, 0);
console.log(sum);  // 15
// 최대값
let max = numbers.reduce((acc, x) => Math.max(acc, x));
console.log(max);  // 5
// 빈도수 세기
let words = ["apple", "banana", "apple", "cherry", "banana", "apple"];
let frequency = words.reduce((acc, word) => {
    acc[word] = (acc[word] || 0) + 1;
    return acc;
}, {});
console.log(frequency);
// { apple: 3, banana: 2, cherry: 1 }
// 배열을 객체로
let users = [
    { id: 1, name: "홍길동" },
    { id: 2, name: "김철수" }
];
let userMap = users.reduce((acc, user) => {
    acc[user.id] = user;
    return acc;
}, {});
console.log(userMap);
// { 1: { id: 1, name: '홍길동' }, 2: { id: 2, name: '김철수' } }
// 그룹화
let students = [
    { name: "홍길동", grade: "A" },
    { name: "김철수", grade: "B" },
    { name: "이영희", grade: "A" }
];
let grouped = students.reduce((acc, student) => {
    if (!acc[student.grade]) {
        acc[student.grade] = [];
    }
    acc[student.grade].push(student.name);
    return acc;
}, {});
console.log(grouped);
// { A: ['홍길동', '이영희'], B: ['김철수'] }

그룹화는 ES2024에서 Object.groupBy(students, s => s.grade)라는 전용 함수가 추가되었습니다. 결과는 학생 객체 전체를 담은 배열로 묶이며, 이름만 모으려면 이후에 map을 한 번 더 적용합니다. 키가 객체이거나 순서가 중요하면 Map.groupBy를 씁니다. 지원하지 않는 오래된 환경에서는 위의 reduce 방식이 여전히 유효합니다.

userMap처럼 배열을 id로 색인한 객체를 만들어 두면, 이후 users.find(u => u.id === 2)로 매번 전체를 훑는 대신 userMap[2]로 바로 꺼낼 수 있습니다. 반복 조회가 많은 코드에서 체감 성능 차이가 큰 패턴입니다. 다만 객체의 키는 항상 문자열로 변환되므로 userMap[2]와 userMap["2"]는 같은 값이고, 숫자 id를 그대로 키로 유지하고 싶다면 new Map(users.map(u => [u.id, u]))가 낫습니다.

메서드 체이닝

let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// 짝수만 필터 → 제곱 → 합계
let result = numbers
    .filter(x => x % 2 === 0)  // [2, 4, 6, 8, 10]
    .map(x => x * x)           // [4, 16, 36, 64, 100]
    .reduce((acc, x) => acc + x, 0);  // 220
console.log(result);  // 220
// 실전 예제: 사용자 데이터 처리
let users = [
    { name: "홍길동", age: 25, active: true },
    { name: "김철수", age: 17, active: false },
    { name: "이영희", age: 30, active: true },
    { name: "박민수", age: 20, active: true }
];
let activeAdultNames = users
    .filter(user => user.active && user.age >= 18)
    .map(user => user.name)
    .sort();
console.log(activeAdultNames);
// ['박민수', '이영희', '홍길동']

체이닝에서 .sort()를 맨 끝에 둔 것은 우연이 아닙니다. filter와 map이 새 배열을 만들었으므로, 그 새 배열을 정렬해도 원본 users는 바뀌지 않습니다. 반대로 users.sort(...).filter(...)처럼 정렬을 먼저 하면 원본 배열의 순서가 바뀝니다. 체이닝은 단계마다 중간 배열을 만들기 때문에 수십만 개 이상의 데이터에서는 for 루프 한 번보다 느릴 수 있지만, 일반적인 화면 데이터 크기에서는 읽기 쉬운 코드의 이점이 훨씬 큽니다. 순서도 중요해서, 요소 수를 줄이는 filter를 먼저 두면 뒤의 map이 처리할 양이 줄어듭니다.


배열·객체 유틸리티와 데이터 변환 예제

예제 1: 배열 유틸리티

// 중복 제거
function unique(arr) {
    return [...new Set(arr)];
}
console.log(unique([1, 2, 2, 3, 3, 3]));  // [1, 2, 3]
// 배열 청크
function chunk(arr, size) {
    const result = [];
    for (let i = 0; i < arr.length; i += size) {
        result.push(arr.slice(i, i + size));
    }
    return result;
}
console.log(chunk([1, 2, 3, 4, 5, 6, 7], 3));
// [[1, 2, 3], [4, 5, 6], [7]]
// 배열 차집합
function difference(arr1, arr2) {
    return arr1.filter(x => !arr2.includes(x));
}
console.log(difference([1, 2, 3, 4], [2, 4]));  // [1, 3]
// 배열 교집합
function intersection(arr1, arr2) {
    return arr1.filter(x => arr2.includes(x));
}
console.log(intersection([1, 2, 3, 4], [2, 3, 5]));  // [2, 3]

Set을 이용한 중복 제거는 원시값에만 동작합니다. Set도 객체를 참조로 비교하므로, [{ id: 1 }, { id: 1 }]은 중복이 제거되지 않습니다. 객체 배열은 new Map(arr.map(o => [o.id, o])).values()처럼 기준 키로 중복을 제거해야 합니다.

difference와 intersection은 filter 안에서 includes를 호출하므로 두 배열 크기의 곱만큼 비교합니다. 각각 1만 개라면 1억 번입니다. 한쪽을 먼저 Set으로 바꾸면(const s = new Set(arr2); arr1.filter(x => !s.has(x))) 조회가 상수 시간이 되어 크기의 합에 비례하는 시간으로 줄어듭니다. 최신 환경에서는 Set.prototype.difference, intersection 같은 집합 연산 메서드도 쓸 수 있습니다.

예제 2: 객체 유틸리티

// 객체 깊은 복사
function deepClone(obj) {
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }
    
    if (Array.isArray(obj)) {
        return obj.map(item => deepClone(item));
    }
    
    const cloned = {};
    for (let key in obj) {
        cloned[key] = deepClone(obj[key]);
    }
    return cloned;
}
let original = { a: 1, b: { c: 2 } };
let cloned = deepClone(original);
cloned.b.c = 100;
console.log(original.b.c);  // 2 (원본 유지)
console.log(cloned.b.c);    // 100
// 객체 병합 (깊은 병합)
function deepMerge(target, source) {
    for (let key in source) {
        if (source[key] instanceof Object && key in target) {
            Object.assign(source[key], deepMerge(target[key], source[key]));
        }
    }
    return Object.assign(target || {}, source);
}
// 객체 필터링
function filterObject(obj, predicate) {
    return Object.keys(obj)
        .filter(key => predicate(obj[key], key))
        .reduce((acc, key) => {
            acc[key] = obj[key];
            return acc;
        }, {});
}
let data = { a: 1, b: 2, c: 3, d: 4 };
let filtered = filterObject(data, value => value > 2);
console.log(filtered);  // { c: 3, d: 4 }

이 유틸리티들은 동작 원리를 보여 주기 위한 코드이며, 그대로 제품 코드에 쓰기에는 빠진 부분이 많습니다. deepClone은 Date, Map, Set, 정규식을 평범한 빈 객체로 바꿔 버리고, 자기 자신을 참조하는 순환 구조를 만나면 무한 재귀로 RangeError: Maximum call stack size exceeded가 납니다. for...in은 상속된 열거 가능 속성까지 순회한다는 점도 문제입니다. 현재 브라우저와 Node.js 17 이상에는 이 문제들을 모두 처리하는 내장 함수 structuredClone(obj)가 있으므로 깊은 복사는 이것을 쓰는 것이 정답입니다. 함수와 클래스 인스턴스의 프로토타입은 복사하지 못한다는 제한만 기억하면 됩니다.

deepMerge는 더 조심해야 합니다. 이 구현은 병합 과정에서 source 객체를 수정하고, target[key]가 원시값일 때 예상하지 못한 결과를 만듭니다. 무엇보다 JSON.parse로 받은 외부 입력을 병합하면 {"__proto__": {"isAdmin": true}} 같은 키를 통해 모든 객체의 프로토타입에 속성이 주입되는 프로토타입 오염(prototype pollution) 취약점이 생깁니다. 여러 npm 패키지의 깊은 병합 함수에서 실제로 보안 취약점으로 보고된 유형이므로, 외부 데이터를 병합할 때는 __proto__, constructor, prototype 키를 건너뛰거나 검증된 라이브러리(lodash의 merge 최신 버전 등)를 쓰십시오. filterObject는 ES2019의 Object.fromEntries(Object.entries(obj).filter(([k, v]) => predicate(v, k)))로 한 줄에 쓸 수 있습니다.

예제 3: 데이터 변환

// CSV → 객체 배열
function parseCSV(csv) {
    const lines = csv.trim().split("\n");
    const headers = lines[0].split(",");
    
    return lines.slice(1).map(line => {
        const values = line.split(",");
        return headers.reduce((obj, header, i) => {
            obj[header] = values[i];
            return obj;
        }, {});
    });
}
const csv = `name,age,city
홍길동,25,서울
김철수,30,부산
이영희,28,서울`;
console.log(parseCSV(csv));
// [
//   { name: '홍길동', age: '25', city: '서울' },
//   { name: '김철수', age: '30', city: '부산' },
//   { name: '이영희', age: '28', city: '서울' }
// ]
// 객체 배열 → CSV
function toCSV(arr) {
    if (arr.length === 0) return "";
    
    const headers = Object.keys(arr[0]);
    const headerRow = headers.join(",");
    
    const dataRows = arr.map(obj => {
        return headers.map(header => obj[header]).join(",");
    });
    
    return [headerRow, ...dataRows].join("\n");
}
let users = [
    { name: "홍길동", age: 25 },
    { name: "김철수", age: 30 }
];
console.log(toCSV(users));
// name,age
// 홍길동,25
// 김철수,30

parseCSV의 결과에서 age가 숫자가 아니라 문자열 '25'라는 점을 눈여겨보십시오. CSV에는 타입 정보가 없으므로 모든 값이 문자열로 읽히고, 이후 user.age + 1을 하면 26이 아니라 '251'이 됩니다. 필요한 열은 Number(obj.age)로 변환해야 합니다.

이 파서는 쉼표로만 나누기 때문에 "서울, 강남구"처럼 따옴표 안에 쉼표가 있는 값, 줄바꿈이 들어간 값, Windows에서 만든 파일의 \r\n 줄끝을 처리하지 못합니다. 마지막 경우에는 각 줄 끝에 \r이 남아 city 값이 '서울\r'이 되고, 비교가 전부 실패하는데 콘솔에서는 눈에 보이지 않아 찾기 어렵습니다. 실제 CSV를 다룬다면 Papa Parse 같은 검증된 파서를 쓰는 것이 좋습니다. toCSV도 값에 쉼표나 따옴표가 들어 있으면 열이 밀리므로 값을 따옴표로 감싸고 내부 따옴표를 ""로 이스케이프해야 합니다.


얕은 복사, sort, 객체 비교의 함정

실수 1: 배열 복사

// ❌ 참조 복사
let arr1 = [1, 2, 3];
let arr2 = arr1;
arr2[0] = 100;
console.log(arr1);  // [100, 2, 3] (원본도 변경!)
// ✅ 얕은 복사
let arr1 = [1, 2, 3];
let arr2 = [...arr1];  // 또는 arr1.slice()
arr2[0] = 100;
console.log(arr1);  // [1, 2, 3] (원본 유지)
// ⚠️ 중첩 배열은 얕은 복사
let nested = [[1, 2], [3, 4]];
let copy = [...nested];
copy[0][0] = 100;
console.log(nested);  // [[100, 2], [3, 4]] (원본도 변경!)
// ✅ 깊은 복사
let deepCopy = JSON.parse(JSON.stringify(nested));
deepCopy[0][0] = 999;
console.log(nested);    // [[100, 2], [3, 4]] (원본 유지)
console.log(deepCopy);  // [[999, 2], [3, 4]]

JSON.parse(JSON.stringify(...))는 오래 쓰인 깊은 복사 요령이지만 데이터가 바뀌는 경우가 있습니다. Date 객체는 문자열로 바뀌고, undefined와 함수를 값으로 가진 속성은 사라지며, NaN과 Infinity는 null이 되고, Map과 Set은 빈 객체가 됩니다. 순환 참조가 있으면 TypeError: Converting circular structure to JSON이 납니다. 앞에서 말한 structuredClone이 이 문제들을 대부분 해결하므로 새 코드에서는 그쪽을 권합니다.

실수 2: sort의 함정

// ❌ 숫자 정렬 (문자열로 비교됨!)
let numbers = [1, 10, 2, 20, 3];
numbers.sort();
console.log(numbers);  // [1, 10, 2, 20, 3] (잘못됨!)
// ✅ 비교 함수 사용
numbers.sort((a, b) => a - b);
console.log(numbers);  // [1, 2, 3, 10, 20]

실수 3: 객체 비교

// ❌ 객체는 참조 비교
let obj1 = { a: 1 };
let obj2 = { a: 1 };
console.log(obj1 === obj2);  // false
// ✅ 깊은 비교 (lodash 사용 또는 직접 구현)
function deepEqual(obj1, obj2) {
    if (obj1 === obj2) return true;
    
    if (typeof obj1 !== 'object' || typeof obj2 !== 'object' ||
        obj1 === null || obj2 === null) {
        return false;
    }
    
    const keys1 = Object.keys(obj1);
    const keys2 = Object.keys(obj2);
    
    if (keys1.length !== keys2.length) return false;
    
    for (let key of keys1) {
        if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
            return false;
        }
    }
    
    return true;
}
console.log(deepEqual({ a: 1 }, { a: 1 }));  // true

===가 객체를 참조로 비교하는 것은 결함이 아니라 설계입니다. 두 변수가 같은 객체를 가리키는지를 상수 시간에 확인할 수 있고, React의 useMemo나 React.memo가 “바뀌었는지”를 빠르게 판단하는 것도 이 참조 비교 덕분입니다. 매번 새 객체를 만들어 넘기면 내용이 같아도 “바뀐 것”으로 판단되어 불필요한 렌더링이 일어나는 이유이기도 합니다.

직접 구현한 deepEqual은 배열과 일반 객체에는 동작하지만, Date 두 개는 열거 가능한 키가 없어 날짜가 달라도 true를 돌려주고, NaN끼리는 false를 돌려줍니다. 테스트 코드라면 Jest의 toEqual이나 Node.js의 assert.deepStrictEqual을, 애플리케이션 코드라면 lodash의 isEqual을 쓰는 편이 이런 경계 사례를 모두 다룹니다.


연습 문제: 평탄화, 그룹화, 회전

문제 1: 배열 평탄화

중첩된 배열을 1차원 배열로 변환하세요.

function flatten(arr) {
    return arr.reduce((acc, item) => {
        return acc.concat(Array.isArray(item) ? flatten(item) : item);
    }, []);
}
// 테스트
console.log(flatten([1, [2, 3], [4, [5, 6]]]));
// [1, 2, 3, 4, 5, 6]

문제 2: 객체 배열 그룹화

객체 배열을 특정 키로 그룹화하세요.

function groupBy(arr, key) {
    return arr.reduce((acc, obj) => {
        const groupKey = obj[key];
        if (!acc[groupKey]) {
            acc[groupKey] = [];
        }
        acc[groupKey].push(obj);
        return acc;
    }, {});
}
// 테스트
let users = [
    { name: "홍길동", age: 25, city: "서울" },
    { name: "김철수", age: 30, city: "부산" },
    { name: "이영희", age: 28, city: "서울" }
];
console.log(groupBy(users, "city"));
// {
//   서울: [{ name: '홍길동', ... }, { name: '이영희', ... }],
//   부산: [{ name: '김철수', ... }]
// }

문제 3: 배열 회전

배열을 오른쪽으로 k번 회전하세요.

function rotateArray(arr, k) {
    k = k % arr.length;
    return [...arr.slice(-k), ...arr.slice(0, -k)];
}
// 테스트
console.log(rotateArray([1, 2, 3, 4, 5], 2));
// [4, 5, 1, 2, 3]

배열과 객체 요약

  1. 배열 메서드:
    • 추가/삭제: push, pop, shift, unshift, splice
    • 검색: indexOf, includes, find, findIndex
    • 변환: map, filter, reduce
    • 정렬: sort, reverse
  2. 객체:
    • 생성: 리터럴 {}
    • 접근: . 또는 []
    • 메서드: Object.keys(), Object.values(), Object.entries()
  3. 구조 분해:
    • 배열: let [a, b] = arr
    • 객체: let { name, age } = obj
  4. Spread/Rest:
    • Spread: 펼치기 ...arr
    • Rest: 나머지 모으기 ...rest
  5. 고차 함수:
    • map: 변환
    • filter: 필터링
    • reduce: 축약

배열·객체 다루는 습관

  1. ✅ map/filter/reduce 활용
  2. ✅ 불변성 유지 (원본 변경 최소화)
  3. ✅ 메서드 체이닝으로 가독성 향상
  4. ✅ Spread 연산자로 복사
  5. ✅ 구조 분해로 코드 간결화

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 숫자 배열에 sort()를 썼는데 [1, 10, 2, 20, 3]처럼 정렬되는 이유는 무엇인가요?

A. sort()는 비교 함수를 주지 않으면 요소를 문자열로 바꿔 사전 순으로 비교하기 때문에 “10”이 “2”보다 앞에 옵니다. 숫자를 크기 순으로 정렬하려면 numbers.sort((a, b) => a - b)처럼 비교 함수를 넘겨야 합니다. 또 sort()는 원본 배열을 직접 바꾸므로, 원본을 유지해야 한다면 복사본을 정렬하거나 toSorted()를 지원하는 환경에서는 그것을 씁니다.