Devin.KR
로그인

자바스크립트 배열과 객체 - 구조 분해, 전개 구문, 얕은 복사와 깊은 복사

개발자 조회 1

이 단원에서 배우는 것

5단원에서 함수를 다른 함수에 값으로 넘기는 구조(콜백)를 봤다. 배열 메서드는 그 구조를 그대로 쓴다. 이번 단원은 참가자 목록 같은 여러 건의 데이터를 걸러내고 바꾸고 합치는 방법, 그리고 값을 꺼내고 복사할 때 생기는 함정을 다룬다. 7단원에서 화면에 명단을 그리기 직전 단계다.

  • filter·map·find·reduce로 목록을 다루고, 원본을 바꾸는 메서드와 아닌 메서드를 구분한다.
  • 구조 분해와 전개 구문으로 값을 꺼내고 합친다.
  • 얕은 복사가 어디까지 복사하는지 설명하고, 필요할 때 깊은 복사를 쓴다.

기준은 ES2022다. at()은 ES2022, structuredClone()은 브라우저와 Node 17 이상에 내장된 함수다. toSorted()는 ES2023이라 아주 오래된 환경에서는 없을 수 있다.

왜 필요한가

회비 미납자에게 안내 메일을 보내려고 명단에서 미납자 이름만 뽑는다고 하자. 4단원까지 배운 것으로 쓰면 이렇다.

const members = [
  { name: "김지훈", sessions: 8, paid: true,  contact: { email: "jihun@example.com" } },
  { name: "박서연", sessions: 3, paid: false, contact: { email: "seoyeon@example.com" } },
  { name: "이도현", sessions: 0, paid: true,  contact: { email: "dohyun@example.com" } }
];

const unpaid = [];
for (const m of members) {
  if (!m.paid) {
    unpaid.push(m.name);
  }
}
console.log(unpaid);   // [ '박서연' ]

동작에는 문제가 없다. 문제는 읽는 사람이 세 줄을 다 읽어야 "거르는 코드"라는 걸 안다는 점이다. 빈 배열을 만들고, 돌고, 조건을 보고, 넣는다. 이 네 동작 중 실제 의미가 있는 것은 조건 하나뿐이다.

const unpaid = members.filter((m) => !m.paid).map((m) => m.name);
console.log(unpaid);   // [ '박서연' ]

filter는 "거른다", map은 "바꾼다"를 이름으로 선언한다. 게다가 두 메서드 모두 새 배열을 만들어 돌려주므로 원본을 건드리지 않는다. 원본이 안 바뀐다는 보장은 화면과 데이터를 함께 다룰 때 특히 값어치가 크다.

문법과 예제

자주 쓰는 배열 메서드

// 거르기 — 조건이 참인 요소만 모은 새 배열
console.log(members.filter((m) => m.paid).map((m) => m.name));   // [ '김지훈', '이도현' ]

// 바꾸기 — 각 요소를 다른 값으로 변환한 새 배열
console.log(members.map((m) => m.contact.email));

// 찾기 — 조건에 맞는 첫 요소(없으면 undefined)
console.log(members.find((m) => m.sessions >= 8)?.name);   // 김지훈

// 있는지만 확인
console.log(members.some((m) => !m.paid));    // true  — 한 명이라도 미납이면
console.log(members.every((m) => m.paid));    // false — 전원 납부인가

// 합계 — 누적값을 만들어 간다
console.log(members.reduce((sum, m) => sum + m.sessions, 0));   // 11

// 마지막 요소
console.log(members.at(-1).name);   // 이도현

find 결과에 ?.을 붙인 이유는 4단원에서 배운 그대로다. 조건에 맞는 사람이 없으면 undefined가 돌아오기 때문이다.

원본을 바꾸는가 아닌가

원본을 바꾼다새 배열을 만든다
push, pop, shift, unshiftmap, filter, slice, concat
splice, sort, reversetoSorted, toReversed (ES2023)

이 표를 외우는 것이 이 단원에서 가장 실용적이다. 특히 sortreverse가 원본을 바꾼다는 사실은 사고로 배우는 사람이 많다.

구조 분해 — 값을 이름 있는 변수로 꺼내기

const member = members[1];

// 지금까지 쓰던 방식
const oldName = member.name;
const oldSessions = member.sessions;
console.log(oldName, oldSessions);   // 박서연 3
// 구조 분해 — 키 이름 그대로 변수가 만들어진다
const { name, sessions } = members[1];
console.log(name, sessions);   // 박서연 3
// 이름 바꾸기 + 기본값 + 중첩까지 한 번에
const { name: memberName, sessions: count = 0, contact: { email } } = members[1];
console.log(memberName, count, email);   // 박서연 3 seoyeon@example.com

// 배열은 순서대로
const [first, second] = members;
console.log(first.name, second.name);    // 김지훈 박서연

// 앞 하나와 나머지 전부
const [head, ...rest] = members;
console.log(head.name, rest.length);     // 김지훈 2

함수 매개변수에서 쓰면 특히 읽기 좋다. 5단원의 gradeOf를 참가자 객체를 통째로 받도록 바꿔 본다.

function rowText({ name, sessions, paid }) {
  return `${name} / 출석 ${sessions}회 / ${paid ? "납부" : "미납"}`;
}
console.log(rowText(members[1]));   // 박서연 / 출석 3회 / 미납

함수 선언만 봐도 이 함수가 객체의 어떤 항목을 쓰는지 드러난다.

전개 구문 — 펼쳐서 새로 만들기

const a = ["김지훈", "박서연"];
const b = ["이도현"];

console.log([...a, ...b]);          // [ '김지훈', '박서연', '이도현' ]
console.log([...a].reverse());      // 원본 a 는 그대로 두고 뒤집는다

const defaults = { fee: 30000, notify: true };
const custom = { ...defaults, fee: 20000 };
console.log(custom);                // { fee: 20000, notify: true }

객체를 펼칠 때 같은 키가 있으면 뒤에 쓴 것이 이긴다. 설정 기본값 위에 사용자 설정을 얹는 패턴이 여기서 나온다. 5단원의 나머지 매개변수(...counts)와 기호가 같지만 방향이 반대다. 나머지 매개변수는 여러 값을 모으고, 전개 구문은 하나를 펼친다.

객체를 목록처럼 다루기

const counts = { 김지훈: 8, 박서연: 3, 이도현: 0 };

console.log(Object.keys(counts));     // [ '김지훈', '박서연', '이도현' ]
console.log(Object.values(counts));   // [ 8, 3, 0 ]

for (const [name, n] of Object.entries(counts)) {
  console.log(`${name}: ${n}회`);
}

// 배열을 객체로 되돌리기
const summary = Object.fromEntries(members.map((m) => [m.name, m.sessions]));
console.log(summary);   // { '김지훈': 8, '박서연': 3, '이도현': 0 }

4단원에서 배열에 for...in을 쓰지 말라고 한 이유가 여기 있다. 객체를 돌 때는 Object.entries가 표준적인 방법이다.

얕은 복사와 깊은 복사

const original = members[0];
const shallow = { ...original };

shallow.name = "김지훈(사본)";
console.log(original.name);            // 김지훈 — 1단계 값은 안전하다

shallow.contact.email = "changed@example.com";
console.log(original.contact.email);   // changed@example.com  ← 원본이 바뀌었다

전개 구문은 한 겹만 복사한다. contact 자리에는 객체가 아니라 그 객체를 가리키는 값이 들어 있고, 그것이 그대로 복사된다. 2단원에서 본 "객체를 대입하면 실체는 하나"가 그대로 재현된 것이다. Object.assign({}, original)이나 배열.slice()도 전부 얕은 복사다.

const deep = structuredClone(original);
deep.contact.email = "deep@example.com";
console.log(original.contact.email, deep.contact.email);
// changed@example.com deep@example.com — 완전히 분리됐다

structuredClone은 브라우저와 Node 17 이상에 내장돼 있고 Date·Map·Set·순환 참조까지 처리한다. 함수와 DOM 요소는 복사할 수 없어 오류가 난다.

인터넷 예제에 많이 나오는 JSON.parse(JSON.stringify(obj)) 방식도 깊은 복사이긴 하지만 손실이 있다.

const data = { joinedAt: new Date("2026-01-02"), memo: undefined, sessions: 8 };
console.log(JSON.parse(JSON.stringify(data)));
// { joinedAt: '2026-01-02T00:00:00.000Z', sessions: 8 }

Date가 문자열로 바뀌고 undefined 키는 아예 사라졌다. 날짜를 다루는 데이터에 이 방식을 쓰면 나중에 joinedAt.getFullYear is not a function으로 돌아온다.

실무에서 자주 틀리는 것

1. sort 가 원본을 바꾸고, 기본 정렬이 문자열 정렬이다

console.log([10, 9, 100].sort());              // [ 10, 100, 9 ]  ← 숫자 정렬이 아니다
console.log([10, 9, 100].sort((a, b) => a - b)); // [ 9, 10, 100 ]

sort는 비교 함수를 안 주면 각 요소를 문자열로 바꿔서 사전순으로 정렬한다. 3단원에서 본 "3" > "12"가 여기서 다시 나온다. 숫자 정렬은 항상 비교 함수를 준다.

// 출석 많은 순 정렬 — 원본을 지키려면 먼저 복사한다
const ranked = [...members].sort((a, b) => b.sessions - a.sessions);
console.log(ranked.map((m) => m.name));   // [ '김지훈', '박서연', '이도현' ]
console.log(members[0].name);             // 김지훈 — 원본 순서 유지

화면에 목록을 뿌린 상태에서 sort로 원본을 뒤섞으면, 정렬 버튼을 두 번 눌렀을 때 결과가 되돌아가지 않는 종류의 버그가 생긴다. [...arr].sort(...)나 ES2023의 arr.toSorted(...)를 쓴다.

2. 전개 구문으로 복사했으니 안전하다고 믿는다

위에서 본 얕은 복사 문제다. 특히 객체를 병합할 때 한 번 더 걸린다.

const defaults = { fee: 30000, notify: { email: true, sms: false } };
const merged = { ...defaults, notify: { sms: true } };
console.log(merged.notify);   // { sms: true }  ← email: true 가 사라졌다

전개는 키 단위로 덮어쓰기 때문에 notify 객체가 통째로 교체된다. 안쪽까지 합치려면 안쪽도 펼쳐야 한다.

const merged2 = { ...defaults, notify: { ...defaults.notify, sms: true } };
console.log(merged2.notify);   // { email: true, sms: true }

3. map 을 forEach 대신 쓴다

// 나쁜 예 — 결과를 안 쓸 거면 map 이 아니다
members.map((m) => console.log(m.name));

// 좋은 예
members.forEach((m) => console.log(m.name));

map은 결과 배열을 만드느라 메모리를 쓴다. 그보다 중요한 것은 읽는 사람이 "이 배열을 어디에 쓰나?" 하고 찾게 된다는 점이다. 결과가 필요하면 map, 그냥 실행만 하면 forEach, 조건에 따라 멈춰야 하면 for...of다. forEach 안에서는 break가 통하지 않는다.

4. 구조 분해 기본값이 null 에는 안 먹는다

const { memo = "메모 없음" } = { memo: null };
console.log(memo);   // null   ← 기본값이 적용되지 않는다

const { memo: rawMemo } = { memo: null };
console.log(rawMemo ?? "메모 없음");   // 메모 없음

5단원의 매개변수 기본값과 정확히 같은 규칙이다. 기본값은 undefined에만 적용된다. 서버 응답의 빈 항목은 null로 오는 경우가 많으므로, 구조 분해 기본값만 믿지 말고 ??를 함께 쓴다.

스스로 확인하기

  1. members에서 출석 4회 이상이면서 회비를 낸 사람의 이메일만 배열로 뽑는 한 줄을 쓰라.
  2. 다음 코드가 [ 10, 100, 9 ]를 출력하고, 게다가 원본 counts까지 바꿔 놓는다. 두 문제를 모두 고친 코드를 쓰라.
    const counts = [10, 9, 100];
    console.log(counts.sort());
  3. 다음 코드에서 original.contact.email이 바뀌는 이유를 설명하고, 바뀌지 않게 고치는 방법을 두 가지 쓰라.
    const copy = { ...original };
    copy.contact.email = "x@example.com";

정답

  1. members.filter((m) => m.sessions >= 4 && m.paid).map((m) => m.contact.email); — 결과는 [ 'jihun@example.com' ].
  2. console.log([...counts].sort((a, b) => a - b)); 또는 ES2023 환경이면 console.log(counts.toSorted((a, b) => a - b));. 비교 함수를 주지 않으면 문자열로 바꿔 정렬하고, sort는 원본 배열 자체를 재배치한다.
  3. 전개 구문은 한 겹만 복사하므로 contact 자리에는 원본과 같은 객체를 가리키는 값이 그대로 들어간다. 해결책은 (1) structuredClone(original)로 깊은 복사하기, (2) { ...original, contact: { ...original.contact } }처럼 안쪽 객체까지 펼쳐서 복사하기.