본문 바로가기

객체와 배열 기초

객체의 속성·메서드와 배열의 순서 있는 요소를 생성·조회·수정·삭제하며 복합 데이터를 구조화합니다.

앞의 글들에서 숫자, 문자열, 불리언 같은 기본 데이터와 변수를 다뤘습니다. 하지만 실제 웹 애플리케이션은 사용자 정보, 상품 목록, 게시글처럼 서로 관련된 데이터를 묶어 다루는 경우가 훨씬 많습니다. 이런 묶음 데이터를 위해 자바스크립트는 객체(object)와 배열(array)을 제공합니다. 사용자 한 명의 이름과 나이처럼 이름으로 찾는 정보는 객체로, 여러 사용자를 차례로 처리하는 목록은 배열로 나타냅니다. 이 글에서는 객체와 배열을 만들고, 값을 읽고, 바꾸고, 지우는 기본 방법과, 객체를 다른 변수에 대입하는 것과 복사하는 것의 차이를 살펴봅니다.

객체·배열: 참조 공유와 복사 범위 — 상태 추적

alias·얕은 복사·structuredClone의 바깥/profile 동일성이 이후 속성 변경에 영향을 준다. 객체를 고정한 채 변경의 공유 범위를 보여 줄 가치가 있다.

객체·배열: 참조 공유와 복사 범위

DATA-FLOW

객체·배열: 참조 공유와 복사 범위

대입은 같은 객체를 공유하고, 얕은 복사는 바깥 객체만 새로 만든다. 중첩 객체까지 따로 변경하려면 그 참조도 복사해야 한다. 아래 번호는 일반 객체 예시의 동일성을 구분한다.

대입과 두 복사의 공유 범위같은 원본에서 세 경로를 각각 비교한다. 대입은 바깥 객체와 profile을 모두 공유한다. 얕은 복사는 새 바깥 객체를 만들지만 profile은 공유한다. 지원되는 일반 객체의 깊은 복사는 profile도 독립적으로 만든다. 번호는 개념상의 객체 동일성이며 실제 메모리 주소가 아니다. 원본 객체바깥 #1 · profile #9대입 · alias바깥 #1 · profile #9같은 객체 변경얕은 복사바깥 #2 · profile #9profile은 공유지원되는 깊은 복사바깥 #3 · profile #10중첩 객체도 독립alias = source{...source}structuredClone
대입과 두 복사의 공유 범위같은 원본에서 세 경로를 각각 비교한다. 대입은 바깥 객체와 profile을 모두 공유한다. 얕은 복사는 새 바깥 객체를 만들지만 profile은 공유한다. 지원되는 일반 객체의 깊은 복사는 profile도 독립적으로 만든다. 번호는 개념상의 객체 동일성이며 실제 메모리 주소가 아니다.원본 객체바깥 #1 · profile #9대입 · alias바깥 #1 · profile #9같은 객체 변경얕은 복사바깥 #2 · profile #9profile은 공유지원되는 깊은 복사바깥 #3 · profile #10중첩 객체도 독립

alias = source

바깥 객체: 같은 #1

중첩·변경 영향: 같은 #9 · mutation이 원본에 즉시 보임

{...source} / [...source]

바깥 객체: 새 #2

중첩·변경 영향: nested #9는 공유 · nested mutation은 원본에도 보임

map/filter/spread

바깥 객체: 새 배열 outer

중첩·변경 영향: map/filter의 결과는 새 배열이지만 element 객체는 공유될 수 있음

push/splice/property set

바깥 객체: 기존 outer 유지

중첩·변경 영향: 기존 graph를 직접 변경

structuredClone · 일반 객체 예시

바깥 객체: 새 #3

중첩·변경 영향: nested도 새 #10 · 비용과 지원 타입 확인

새 바깥 객체가 모든 중첩 객체의 독립을 뜻하지는 않는다

위 얕은 복사의 nested #9는 원본과 같다. 배열도 push/splice는 원본을 바꾸고, map/filter/spread는 새 바깥 배열을 만들지만 객체 요소가 공유될 수 있다. map의 콜백 자체는 원본을 변경할 수도 있다. structuredClone의 지원 타입·비용을 확인하며 함수는 복제할 수 없다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
원본과 별칭원본: #1 → #9 / 별칭: #1 → #9원본 outer#1/nested#9를 별칭이 공유한다대입은 객체를 복제하지 않는다
얕은 복사원본: #1 → #9 / shallow: #2 → #9새 outer#2도 nested#9를 가리킨다바깥 참조만 새로 생긴다
깊은 복사와 변경deep: #3 → #10 / 공유 nested: #9 변경 반영deep outer#3/nested#10은 독립하고 nested#9 변경은 공유 쪽에 보인다복사 깊이가 변경 전파 범위를 정한다

객체·배열: 참조 공유와 복사 범위 — 상태 추적

alias·얕은 복사·structuredClone의 바깥/profile 동일성이 이후 속성 변경에 영향을 준다. 객체를 고정한 채 변경의 공유 범위를 보여 줄 가치가 있다.

객체·배열: 참조 공유와 복사 범위

DATA-FLOW

객체·배열: 참조 공유와 복사 범위

대입은 같은 객체를 공유하고, 얕은 복사는 바깥 객체만 새로 만든다. 중첩 객체까지 따로 변경하려면 그 참조도 복사해야 한다. 아래 번호는 일반 객체 예시의 동일성을 구분한다.

대입과 두 복사의 공유 범위같은 원본에서 세 경로를 각각 비교한다. 대입은 바깥 객체와 profile을 모두 공유한다. 얕은 복사는 새 바깥 객체를 만들지만 profile은 공유한다. 지원되는 일반 객체의 깊은 복사는 profile도 독립적으로 만든다. 번호는 개념상의 객체 동일성이며 실제 메모리 주소가 아니다. 원본 객체바깥 #1 · profile #9대입 · alias바깥 #1 · profile #9같은 객체 변경얕은 복사바깥 #2 · profile #9profile은 공유지원되는 깊은 복사바깥 #3 · profile #10중첩 객체도 독립alias = source{...source}structuredClone
대입과 두 복사의 공유 범위같은 원본에서 세 경로를 각각 비교한다. 대입은 바깥 객체와 profile을 모두 공유한다. 얕은 복사는 새 바깥 객체를 만들지만 profile은 공유한다. 지원되는 일반 객체의 깊은 복사는 profile도 독립적으로 만든다. 번호는 개념상의 객체 동일성이며 실제 메모리 주소가 아니다.원본 객체바깥 #1 · profile #9대입 · alias바깥 #1 · profile #9같은 객체 변경얕은 복사바깥 #2 · profile #9profile은 공유지원되는 깊은 복사바깥 #3 · profile #10중첩 객체도 독립

alias = source

바깥 객체: 같은 #1

중첩·변경 영향: 같은 #9 · mutation이 원본에 즉시 보임

{...source} / [...source]

바깥 객체: 새 #2

중첩·변경 영향: nested #9는 공유 · nested mutation은 원본에도 보임

map/filter/spread

바깥 객체: 새 배열 outer

중첩·변경 영향: map/filter의 결과는 새 배열이지만 element 객체는 공유될 수 있음

push/splice/property set

바깥 객체: 기존 outer 유지

중첩·변경 영향: 기존 graph를 직접 변경

structuredClone · 일반 객체 예시

바깥 객체: 새 #3

중첩·변경 영향: nested도 새 #10 · 비용과 지원 타입 확인

새 바깥 객체가 모든 중첩 객체의 독립을 뜻하지는 않는다

위 얕은 복사의 nested #9는 원본과 같다. 배열도 push/splice는 원본을 바꾸고, map/filter/spread는 새 바깥 배열을 만들지만 객체 요소가 공유될 수 있다. map의 콜백 자체는 원본을 변경할 수도 있다. structuredClone의 지원 타입·비용을 확인하며 함수는 복제할 수 없다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
원본과 별칭원본: #1 → #9 / 별칭: #1 → #9원본 outer#1/nested#9를 별칭이 공유한다대입은 객체를 복제하지 않는다
얕은 복사원본: #1 → #9 / shallow: #2 → #9새 outer#2도 nested#9를 가리킨다바깥 참조만 새로 생긴다
깊은 복사와 변경deep: #3 → #10 / 공유 nested: #9 변경 반영deep outer#3/nested#10은 독립하고 nested#9 변경은 공유 쪽에 보인다복사 깊이가 변경 전파 범위를 정한다

위 그림은 같은 원본 객체에서 출발한 세 경로를 비교합니다. alias = source처럼 대입하면 바깥 객체 #1과 안쪽의 profile #9를 모두 함께 쓰므로, 어느 쪽에서 바꿔도 다른 쪽에 바로 보입니다. {...source}로 얕은 복사를 하면 바깥 객체는 새 #2가 되지만 profile은 여전히 #9를 함께 씁니다. structuredClone()으로 깊은 복사를 하면 profile까지 새 #10이 되어 완전히 따로 바꿀 수 있습니다. 그림의 번호는 같은 객체인지를 구분하려는 표시일 뿐, 실제 메모리 주소는 아닙니다.

그림 아래의 표는 배열에도 같은 원리가 적용된다는 것을 보여 줍니다. push(), splice(), 속성 대입은 기존 배열을 직접 바꾸고, map(), filter(), [...배열]은 새 배열을 만들지만 안의 객체 요소는 원본과 함께 쓸 수 있습니다. 새 바깥 객체가 생겼다고 해서 안쪽의 모든 객체까지 따로 만들어진 것은 아니며, structuredClone()도 함수는 복제할 수 없다는 점을 기억해 둡니다.


객체

객체는 여러 속성(property)을 담는 묶음이며, 각 속성은 이름(key)과 값(value)의 쌍입니다. 사전에서 단어와 뜻이 짝을 이루듯, 어떤 대상에 대한 정보를 이름으로 정리할 때 씁니다.

객체 만들기

객체는 보통 중괄호 {}로 감싸는 객체 리터럴(object literal)로 만듭니다. 다음 코드는 사용자 한 명의 정보를 속성 다섯 개로 묶고, console.log()로 객체 전체를 출력합니다. 값에는 문자열, 숫자, 불리언처럼 서로 다른 타입을 섞어 넣을 수 있습니다.

// 사용자 정보를 담는 객체
let user = {
    name: "김민준",      // 'name'은 속성 이름(키), "김민준"은 속성 값
    age: 28,           // 'age'는 숫자 타입 값
    isStudent: false,  // 'isStudent'는 불리언 타입 값
    email: "[email protected]",
    "favorite-color": "blue" // 속성 이름에 하이픈이 포함되면 따옴표로 묶어야 합니다.
};

console.log(user);
/*
결과:
{
  name: '김민준',
  age: 28,
  isStudent: false,
  email: '[email protected]',
  'favorite-color': 'blue'
}
*/

속성의 이름과 값은 콜론(:)으로, 속성과 속성은 쉼표(,)로 구분합니다. 속성의 이름은 문자열이나 Symbol이며, 1처럼 숫자 모양으로 쓴 이름도 문자열로 바뀝니다. favorite-color처럼 하이픈이 들어간 이름은 변수 이름 규칙에 맞지 않으므로 따옴표로 감쌉니다.

이 글의 주석에 적은 결과는 값의 모양을 나타냅니다. 브라우저의 Console 패널은 객체를 {name: '김민준', age: 28, ...}처럼, 배열을 (5) [10, 20, 30, 40, 50]처럼 길이와 함께 펼쳐 볼 수 있는 형태로 보여 줍니다.

속성 읽기

속성을 읽는 방법은 두 가지입니다. 점 표기법(dot notation)은 객체.속성이름으로 읽으며 가장 많이 씁니다. 속성 이름이 변수 이름 규칙에 맞을 때 쓸 수 있습니다.

console.log(user.name);         // 결과: 김민준
console.log(user.age);          // 결과: 28
console.log(user.email);        // 결과: [email protected]

대괄호 표기법(bracket notation)은 객체['속성이름']으로 읽습니다. 속성 이름에 하이픈처럼 변수 이름에 쓸 수 없는 문자가 있거나, 속성 이름이 변수에 들어 있을 때 씁니다.

console.log(user['name']);       // 결과: 김민준 (점 표기법과 동일한 결과)
console.log(user['favorite-color']); // 결과: blue (하이픈 때문에 대괄호 필수)

let keyName = "age";
console.log(user[keyName]);      // 결과: 28 (변수를 통해 속성 이름에 접근)

user[keyName]은 변수 keyName의 값인 "age"를 속성 이름으로 써서 28을 읽습니다. 반면 user.keyName은 글자 그대로 keyName이라는 속성을 찾으므로, 이 객체에서는 undefined입니다. 두 예제는 앞에서 만든 user를 이어서 쓰므로 함께 실행합니다.

속성 추가와 변경

없는 속성에 값을 대입하면 새 속성이 추가되고, 있는 속성에 대입하면 값이 바뀝니다. 다음 코드는 점 표기법과 대괄호 표기법으로 속성을 하나씩 추가한 뒤, 이미 있던 price의 값을 바꿉니다.

let product = {
    name: "노트북",
    price: 1200000
};

// 속성 추가
product.manufacturer = "XYZ전자"; // 새로운 manufacturer 속성 추가
product['release-date'] = "2024-05-01"; // 대괄호 표기법으로 속성 추가

console.log(product);
/*
결과:
{
  name: '노트북',
  price: 1200000,
  manufacturer: 'XYZ전자',
  'release-date': '2024-05-01'
}
*/

// 속성 값 변경
product.price = 1150000; // 기존 price 속성 값 변경
console.log(product.price); // 결과: 1150000

새로 추가한 두 속성은 출력에서 기존 속성 뒤에 추가한 순서대로 붙어 나옵니다. 대입 문법은 추가와 변경이 똑같으므로, 속성 이름을 잘못 적으면 오류 없이 엉뚱한 새 속성이 생긴다는 점에 주의합니다.

속성 삭제

delete 연산자로 속성을 지웁니다. 다음 코드는 지우기 전과 후에 객체를 출력해, grade 속성이 이름과 값째로 사라지는 것을 보여 줍니다.

let student = {
    id: 101,
    name: "박지성",
    grade: 3
};

console.log(student); // { id: 101, name: '박지성', grade: 3 }

delete student.grade; // grade 속성 삭제
console.log(student); // { id: 101, name: '박지성' }

// delete student['id']; // 대괄호 표기법으로도 삭제 가능

속성에 undefined를 대입하면 속성 이름은 남고 값만 undefined가 되지만, delete는 속성 자체를 없애므로 객체를 출력해도 grade가 보이지 않습니다. 마지막 줄은 주석이라 실행되지 않으며, 대괄호 표기법으로도 같은 일을 할 수 있다는 것을 보여 줄 뿐입니다.

메서드

속성의 값으로 함수도 넣을 수 있으며, 객체 안에 정의한 함수를 메서드(method)라고 합니다. 메서드는 그 객체의 데이터를 다루거나 객체와 관련된 동작을 합니다.

let person = {
    name: "이수진",
    age: 22,
    // sayHello라는 메서드 정의
    sayHello: function() {
        console.log(`안녕하세요, 제 이름은 ${this.name}입니다.`);
        // this는 현재 객체(여기서는 person)를 가리킵니다.
    },
    // ES6에서 도입된 간결한 메서드 정의 문법
    introduce() {
        console.log(`저는 ${this.age}살 ${this.name}입니다.`);
    }
};

person.sayHello();   // 결과: 안녕하세요, 제 이름은 이수진입니다.
person.introduce(); // 결과: 저는 22살 이수진입니다.

person.sayHello()처럼 일반 함수를 객체.메서드() 형태로 호출하면, 그 안의 this는 호출에 쓴 객체인 person을 가리킵니다. 그래서 this.name으로 같은 객체의 다른 속성을 읽을 수 있습니다. 하지만 메서드를 const hello = person.sayHello;처럼 변수에 꺼내 hello()로 호출하면 this가 더 이상 person을 가리키지 않습니다. 화살표 함수는 자기만의 this를 만들지 않으므로, 이런 메서드를 화살표 함수로 무작정 바꾸면 안 됩니다. this는 6장의 This 키워드와 바인딩에서 자세히 다룹니다.


배열

배열은 여러 값을 순서대로 저장하는 특별한 객체입니다. 객체가 이름으로 데이터를 찾는다면, 배열은 순서를 나타내는 번호인 인덱스(index)로 데이터를 찾습니다. 같은 종류의 데이터를 여러 개 모아 둘 때 씁니다.

배열 만들기

배열은 대괄호 []로 만들고, 요소는 쉼표로 구분합니다. 다음 코드는 숫자 배열, 문자열 배열, 여러 타입이 섞인 배열, 빈 배열을 차례로 만들고 앞의 두 배열을 출력합니다.

// 숫자 배열
let numbers = [10, 20, 30, 40, 50];

// 문자열 배열
let fruits = ["사과", "바나나", "오렌지", "포도"];

// 다양한 타입의 요소를 포함하는 배열 (권장하지는 않음)
let mixedArray = ["Hello", 123, true, { id: 1 }];

// 빈 배열
let emptyArray = [];

console.log(numbers); // 결과: [ 10, 20, 30, 40, 50 ]
console.log(fruits);  // 결과: [ '사과', '바나나', '오렌지', '포도' ]

한 배열에 여러 타입을 섞어 넣을 수도 있지만, 다룰 때 요소마다 타입을 따져야 하므로 보통은 같은 종류의 값만 넣습니다.

요소 읽기

배열의 요소는 0부터 시작하는 인덱스를 가지며, 배열[인덱스]로 읽습니다. 그래서 첫 번째 요소는 colors[0]이고, 요소가 세 개인 배열의 마지막 인덱스는 2입니다. 다음 코드의 마지막 줄은 일부러 없는 인덱스 3을 읽습니다.

let colors = ["red", "green", "blue"];

console.log(colors[0]); // 결과: red (첫 번째 요소)
console.log(colors[1]); // 결과: green (두 번째 요소)
console.log(colors[2]); // 결과: blue (세 번째 요소)

console.log(colors[3]); // 결과: undefined (인덱스를 벗어나면 undefined)

없는 인덱스를 읽으면 오류가 아니라 undefined가 나옵니다.

배열의 길이

length 속성은 배열의 길이를 나타냅니다. 앞의 fruits처럼 인덱스 0부터 빈자리 없이 채운 배열에서는 요소의 개수와 같습니다.

console.log(fruits.length); // 결과: 4

정확히 말하면 length는 마지막 인덱스에 1을 더한 값입니다. 빈 배열에 arr[3] = "x"만 대입하면 실제로 채운 요소는 하나지만 length는 4가 되고, 인덱스 0부터 2는 빈자리로 남습니다. 빈자리 없는 배열의 마지막 요소는 배열[배열.length - 1]로 읽으며, 빈 배열에는 마지막 요소가 없으므로 먼저 길이가 0인지 확인합니다.

요소 추가와 변경

인덱스에 값을 대입해 요소를 바꾸거나 추가할 수 있고, 배열의 메서드로도 추가할 수 있습니다. 다음 코드는 같은 tasks 배열을 인덱스 대입, length를 이용한 대입, push(), unshift() 순서로 바꾸면서 바꿀 때마다 배열 전체를 출력합니다.

let tasks = ["운동하기", "공부하기", "요리하기"];
console.log(tasks); // 결과: [ '운동하기', '공부하기', '요리하기' ]

// 요소 변경 (인덱스를 통해)
tasks[0] = "산책하기";
console.log(tasks); // 결과: [ '산책하기', '공부하기', '요리하기' ]

// 요소 추가 (length 속성을 이용)
tasks[tasks.length] = "책 읽기"; // 배열의 마지막에 추가
console.log(tasks); // 결과: [ '산책하기', '공부하기', '요리하기', '책 읽기' ]

// `push()` 메서드를 이용한 추가 (더 권장되는 방법)
tasks.push("청소하기"); // 배열의 마지막에 요소 추가
console.log(tasks); // 결과: [ '산책하기', '공부하기', '요리하기', '책 읽기', '청소하기' ]

// `unshift()` 메서드를 이용한 추가 (배열의 맨 앞에 추가)
tasks.unshift("명상하기"); // 배열의 맨 앞에 요소 추가
console.log(tasks); // 결과: [ '명상하기', '산책하기', '공부하기', '요리하기', '책 읽기', '청소하기' ]

push()는 맨 뒤에, unshift()는 맨 앞에 요소를 넣습니다. tasks[tasks.length] = 값도 맨 뒤에 넣지만, 의도가 분명히 드러나는 push()를 주로 씁니다.

요소 삭제

배열에서 요소를 지우는 메서드는 여러 가지입니다. 다음 코드의 주석에는 메서드마다 무엇을 지우는지 적혀 있고, 각 메서드 뒤의 console.log()가 지운 뒤의 배열과 돌려받은 값을 보여 줍니다.

let items = ["A", "B", "C", "D"];
console.log(items); // 결과: [ 'A', 'B', 'C', 'D' ]

// `pop()` 메서드: 배열의 마지막 요소를 제거하고 반환합니다.
let lastItem = items.pop();
console.log(items);     // 결과: [ 'A', 'B', 'C' ]
console.log(lastItem);  // 결과: D

// `shift()` 메서드: 배열의 첫 번째 요소를 제거하고 반환합니다.
let firstItem = items.shift();
console.log(items);     // 결과: [ 'B', 'C' ]
console.log(firstItem); // 결과: A

// `splice()` 메서드: 특정 인덱스부터 원하는 개수만큼 요소를 제거할 수 있습니다.
// items.splice(시작_인덱스, 제거할_개수);
items.splice(0, 1); // 인덱스 0부터 1개 제거 (B 제거)
console.log(items); // 결과: [ 'C' ]

items.push("D", "E"); // 다시 추가
console.log(items); // 결과: [ 'C', 'D', 'E' ]

items.splice(1, 2); // 인덱스 1부터 2개 제거 (D, E 제거)
console.log(items); // 결과: [ 'C' ]

pop()은 맨 뒤의 요소를, shift()는 맨 앞의 요소를 지우고 그 요소를 돌려주며, splice(시작 인덱스, 지울 개수)는 원하는 위치의 요소를 지웁니다. 이 코드는 같은 items를 차례로 바꾸므로 길이가 4, 3, 2, 1, 3, 1로 바뀝니다. 코드를 나눠 따로 실행하면 결과가 달라지므로 한 번에 실행합니다.

배열에 delete items[0]을 쓰면 그 자리만 비울 뿐, 뒤의 요소를 당기거나 length를 줄이지 않습니다. 순서를 유지하면서 요소를 지우려면 splice()를 씁니다. splice()는 지운 자리에 새 요소를 넣어 교체하는 데도 쓸 수 있습니다.

배열 순회

배열의 모든 요소에 차례로 같은 일을 할 때는 반복문을 씁니다. 다음 코드는 같은 세 이름을 for, for...of, forEach()의 세 가지 방법으로 출력합니다. 출력 내용은 거의 같지만 요소와 순서 번호를 얻는 방법이 다르므로, i, student, index가 각각 어디서 오는지 보세요.

let students = ["민수", "지영", "태호"];

// 1. for 문을 이용한 순회 (가장 기본적인 방법)
for (let i = 0; i < students.length; i++) {
    console.log(`${i + 1}번째 학생: ${students[i]}`);
}
/*
결과:
1번째 학생: 민수
2번째 학생: 지영
3번째 학생: 태호
*/

// 2. for...of 문을 이용한 순회 (ES6, 배열 요소에 직접 접근)
for (let student of students) {
    console.log(`학생 이름: ${student}`);
}
/*
결과:
학생 이름: 민수
학생 이름: 지영
학생 이름: 태호
*/

// 3. forEach() 메서드를 이용한 순회 (배열 메서드, 함수 활용)
// forEach(콜백함수)
students.forEach(function(student, index) { // 각 요소와 인덱스를 인자로 받습니다.
    console.log(`${index + 1}. ${student}`);
});
/*
결과:
1. 민수
2. 지영
3. 태호
*/

// 화살표 함수로 더 간결하게 표현 가능
students.forEach((student, index) => {
    console.log(`${index + 1}. ${student}`);
});

for는 인덱스를 직접 다루고, for...of는 요소를 하나씩 꺼내며, forEach()는 각 요소마다 실행할 함수를 넘겨받는 콜백 방식입니다. for와 for...of에서는 break로 반복을 일찍 끝낼 수 있지만, forEach()에 넘긴 콜백 안에서는 break를 쓸 수 없습니다. 중간에 멈춰야 한다면 for...of를 고릅니다.


대입과 복사

앞 그림의 세 경로를 코드로 확인해 봅니다. 각 줄을 실행한 뒤 속성의 값을 비교해 보세요.

const source = { name: "민수", profile: { score: 3 } };
const alias = source;
const shallow = { ...source };
const separate = { ...source, profile: { ...source.profile } };
shallow.name = "영희";
alias.profile.score = 4;
console.log(source.name, shallow.name);             // 민수 영희
console.log(source.profile.score, shallow.profile.score); // 4 4
console.log(separate.profile.score);                // 3

alias는 source와 같은 객체이므로, alias.profile.score = 4가 source에도 그대로 보입니다. shallow는 바깥 객체만 새로 만든 얕은 복사라서 shallow.name을 바꿔도 source.name은 그대로지만, profile은 같은 객체를 함께 쓰므로 shallow.profile.score도 4입니다. separate는 profile까지 새로 만들었으므로 3으로 남습니다. 다만 이 방식은 이 예제처럼 한 단계 안쪽만 따로 복사한 것이며, 모든 깊이를 복사하려면 structuredClone(source)를 씁니다. 배열의 [...배열]도 바깥 배열만 새로 만들므로, 객체 요소는 원본과 함께 씁니다.

더 깊이: structuredClone이 복사하지 못하는 것

structuredClone()은 자바스크립트 언어가 아니라 HTML 표준이 정한 웹 API로, 구조화 복제 알고리즘(structured clone algorithm)이라는 규칙으로 값을 깊게 복사합니다. 이 규칙은 워커에 메시지를 보내거나 IndexedDB에 값을 저장할 때도 쓰이는 것이라, 데이터는 깊이와 상관없이 복사하지만 동작까지 옮기지는 않습니다. Date, Map, Set, RegExp, 배열 같은 내장 객체는 그대로 복사되고, 객체가 자기 자신을 가리키는 순환 참조도 복사본 안에서 같은 모양으로 유지됩니다.

반면 함수는 복사할 수 없어서, 메서드를 가진 객체를 넘기면 DataCloneError 예외가 납니다. DOM 노드도 마찬가지입니다. 또 프로토타입 체인을 따라가지 않으므로 클래스로 만든 인스턴스는 메서드가 없는 일반 객체로 복사되고, getter와 setter, 읽기 전용 같은 속성 설정, 클래스의 비공개 필드도 옮겨지지 않습니다. 그래서 이 함수는 서버에서 받은 JSON 같은 순수한 데이터의 사본을 만들 때 알맞습니다.

예전에는 JSON.parse(JSON.stringify(값))으로 깊은 복사를 대신하는 일이 많았습니다. 하지만 이 방법은 Date를 문자열로 바꾸고, undefined인 속성을 빼 버리며, 순환 참조가 있으면 TypeError를 냅니다. structuredClone()은 2022년 3월부터 주요 브라우저에서 함께 쓸 수 있으므로, 지금은 이 함수를 먼저 검토합니다. 복사되는 타입의 목록은 MDN의 구조화 복제 알고리즘 설명에 있습니다.


정리

  • 객체는 이름과 값의 쌍인 속성을 묶으며, 점 표기법이나 대괄호 표기법으로 읽고, 대입으로 추가와 변경을, delete로 삭제를 합니다.
  • 대괄호 표기법은 속성 이름에 쓸 수 없는 문자가 있거나 속성 이름이 변수에 들어 있을 때 씁니다.
  • 메서드를 객체.메서드()로 호출하면 this가 그 객체를 가리키며, 꺼내서 호출하면 그렇지 않습니다.
  • 배열은 0부터 시작하는 인덱스로 요소를 다루고, length는 마지막 인덱스에 1을 더한 값입니다.
  • push, pop, shift, unshift, splice는 원래 배열을 바꾸며, 배열에 delete를 쓰면 빈자리만 남습니다.
  • 대입은 같은 객체를 함께 쓰고, 얕은 복사는 바깥만 새로 만들며, 안쪽까지 따로 바꾸려면 안쪽 객체도 복사합니다.

연습 문제

1. 장바구니 합계와 복사

빈자리 없는 배열 const cart = [{ price: 2000, quantity: 3 }, { price: 1500, quantity: 2 }];의 상품 금액을 반복문으로 더해 보세요. 그다음 const copiedCart = [...cart];를 만들고 copiedCart[0].quantity = 1을 실행하면, 원본의 수량과 다시 계산한 합계는 어떻게 될까요? 배송비와 할인은 없습니다.

해설 보기
const cart = [{ price: 2000, quantity: 3 }, { price: 1500, quantity: 2 }];
function cartTotal(lines) {
    let total = 0;
    for (const line of lines) total += line.price * line.quantity;
    return total;
}
console.log(cartTotal(cart)); // 9000
const copiedCart = [...cart];
copiedCart[0].quantity = 1;
console.log(cart[0].quantity); // 1
console.log(cartTotal(cart)); // 5000

처음 합계는 2,000 × 3 + 1,500 × 2 = 9,000원입니다. [...cart]는 바깥 배열만 새로 만들므로, 두 배열의 첫 요소는 같은 객체입니다. 그래서 복사한 배열에서 수량을 1로 바꾸면 원본의 수량도 1이 되고, 다시 계산한 합계는 2,000 × 1 + 1,500 × 2 = 5,000원입니다.

원본과 따로 수량을 바꾸려면 cart.map(line => ({ ...line }))처럼 상품 객체까지 복사해야 합니다.

2. 점과 대괄호

const config = { mode: "dark", "max-size": 10 };와 const key = "mode";가 있습니다. config.mode, config[key], config.key, config["max-size"]의 값은 각각 무엇일까요?

해설 보기

순서대로 "dark", "dark", undefined, 10입니다. config[key]는 변수 key의 값인 "mode"를 속성 이름으로 쓰지만, config.key는 글자 그대로 key라는 속성을 찾는데 그런 속성이 없어 undefined입니다.

max-size는 하이픈 때문에 config.max-size로 쓰면 config.max - size라는 뺄셈으로 해석되므로, 대괄호 표기법으로만 읽을 수 있습니다.

3. delete와 splice

const letters = ["a", "b", "c"];에서 delete letters[0]을 실행한 경우와 letters.splice(0, 1)을 실행한 경우, 배열의 length와 letters[0]은 각각 어떻게 될까요?

해설 보기

delete letters[0]을 실행하면 length는 3 그대로이고, 첫 자리가 비어 letters[0]은 undefined입니다. 뒤의 요소는 당겨지지 않습니다.

letters.splice(0, 1)을 실행하면 첫 요소가 지워지고 뒤의 요소가 당겨져, 배열이 ["b", "c"]가 되고 length는 2, letters[0]은 "b"입니다. 배열에서 요소를 지울 때는 splice()나 pop(), shift()를 씁니다.

규칙은 MDN의 배열 length와 this 문서에서 확인할 수 있습니다.

다음 글 DOM 조작과 이벤트 처리 기초에서는 자바스크립트로 페이지의 요소를 찾아 바꾸고, 사용자의 클릭과 입력에 반응하는 방법을 다룹니다.