blog logo
Published on

객체 리터럴 & 원시값과 객체의 비교

들어가며

현재 모던 자바스크립트 Deep Dive 책 완전 정복을 목표로 스터디를 하고 있다. 스터디 분들과 번갈아가며 맡은 내용에 대해 발표를 진행한다. 그 중에서 모던 자바스크립트 10장, 11장 발표를 맡았으며 발표 자료에 쓰기 위해 책을 읽고 정리한 글이다.
(내용을 정리하며 나의 생각도 적어보았다.)

10장 객체 리터럴

const intro = {
  title: "객체 리터럴",
  description: "자바스크립트 객체 리터럴에 대해서 공부해보자!",
  like: 0,
  thumbsUp: function () {
    this.like++;
  },
};

10.1 객체란?

자바스크립트는 객체(Object) 기반의 프로그래밍 언어이며, 자바스크립트를 구성하는 거의 "모든 것"이 객체이다. 즉, 원시 값을 제외한 나머지 값(함수, 배열, 정규 표현식 등)은 모두 객체이다.

잠깐! 🖐

자바스크립트는 객체 기반의 프로그래밍이라고 설명했다. 그러면 과연 자바스크립트는 객체 지향 언어라고 말할 수 있을까? 보통 객체 지향 언어라고 한다면 흔히 알고 있는 자바, C++를 떠올릴 것이다. 하지만 자바스크립트는 이 둘과 다른 방식으로 객체가 생성된다. 자바스크립트 객체는 실행 시간에 빈 객체를 오버라이딩하여 메서드와 프로퍼티를 연결하는 방식으로 생성한다. 자바, C++와는 다른 방식이다. 그리고 자바스크립트는 유연한 프로그래밍을 추구하는 만큼 객체 지향 언어의 문법을 지원하면서 함수형 프로그래밍의 특징도 가지고 있다. 그렇기 때문에 객체 지향언어와 절차 지향 언어 두가지 형태로 만들 수 있다.

그리고 객체 지향 언어에서 함수는 특정한 객체나 클래스에 종속된 형태로 보지만, 자바스크립트에서 함수는 객체에 종속될 수도, 객체의 메서드가 될 수도 있다. 또는 함수 앞에 'new'를 붙여서 객체로 생성할 수도 있다. 이와 같은 형태로 자바스크립트는 유연한 언어라고 할 수 있다.

그러면 다시 돌아가서 자바스크립트는 과연 객체 지향의 언어라고 말할 수 있는가? 결론은 객체 지향 언어는 아니다. 다만 객체 지향 프로그래밍이 가능하다. 이 말의 뜻은 ES6 문법인 Class를 이용해서 객체 지향 프로그래밍을 할 수 있다는 뜻이다. 그리고 애초에 자바스크립트는 객체 지향 언어로 개발되지 않았다.


다시 본문으로 돌아와서,

객체의 특징으로는 단 하나의 값만 나타내는 원시 타입의 값과 다르게 객체는 다양한 타입의 값(원시 값 또는 다른 객체)을 하나의 단위로 구성한 복잡한 자료구조이다. 원시 타입의 값은 변경 불가능한 값이지만 객체는 변경 가능한 값(mutable value)이다. 객체는 0개 이상의 프로퍼티로 구성된 집합이며, 프로퍼티는 키(key)와 값(value)으로 구성된다.

const counter = {
  num: 0, // 프로퍼티
  increase: function () {
    // 메서드
    this.num++;
  },
};

자바스크립트에서 객체의 상태를 나타내는 값을 프로퍼티라고 한다. 그리고 그 프로퍼티(상태 데이터)를 참조하고 조작할 수 있는 동작을 메서드라고 부른다. 이 프로퍼티와 메서드들의 집합이 바로 객체이다. 그래서 객체는 프로퍼티와 메서드를 모두 포함할 수 있기 때문에 상태와 동작을 하나의 단위로 구조화할 수 있어 유용하다. 책의 팁 중에서 자바스크립트의 객체와 함수는 밀접한 관계를 가진다고 한다. 그래서 책에서는 함수와 객체를 분리해서 생각할 수 없는 개념이라고 설명한다.

10.2 객체 리터럴에 의한 객체 생성

C++나 자바같은 클래스 기반 객체 지향 언어는 클래스를 사전에 정의하고 필요한 시점에 new 연산자와 함께 생성자(constructor)를 호출하여 인스턴스를 생성하는 방식으로 객체를 생성한다.

인스턴스란 클래스에 의해 생성되어 메모리에 저장된 실체를 말한다. 객체 지향 프로그래밍에서 객체는 클래스와 인스턴스를 포함한 개념이다. 클래스는 인스턴스를 생성하기 위한 템플릿 역할을 한다. 인스턴스는 객체가 메모리에 저장되어 실제로 존재하는 것에 초점을 맞춘 용어다.

그렇다면 프로토타입 기반으로 객체를 지향하는 자바스크립트는 어떻게 객체를 생성할까?

  • 객체 리터럴
  • Object 생성자 함수
  • 생성자 함수
  • Object.create 메서드
  • 클래스(ES6)

객체 생성 방법 중에서도 가장 일반적이고 간단한 방법은 객체 리터럴을 사용하는 방법이다.(객체 리터럴 이외의 방법들은 모두 함수를 사용한 객체 생성 방법이다.) 리터럴은 사람이 이해할 수 있는 문자 또는 약속된 기호를 사용하여 값을 생성하는 표기법을 말한다. 그러면 객체 리터럴은 어떻게 표기하는 방법일까?

객체 리터럴은 중괄호({...}) 내에 0개 이상의 프로퍼티를 정의하는 것을 뜻한다. 즉 객체 리터럴을 사용하면 프로퍼티 값이 없어도 객체로 정의한다. 변수에 할당되는 시점에 자바스크립트 엔진은 객체 리터럴을 해석해 객체를 생성한다.

const person = {
  name: "Ayaan",
  sayHello: function () {
    console.log(`Hello, My name is ${this.name}.`);
  },
};

console.log(typeof person); // object
console.log(person); // {name: "Ayaan", sayHello: ƒ}

객체 리터럴은 자바스크립트의 유연함과 강력함을 대표하는 객체 생성 방식이다. 객체를 생성하기 위해 클래스를 먼저 정의하고 new 연산자와 함께 생성자를 호출할 필요가 없다.

10.3 프로퍼티

const person = {
  name: "Ayaan",
  age: 29,
};

객체는 프로퍼티의 집합이며, 프로퍼티는 키와 값으로 구성된다.

  • 프로퍼티 키: 빈 문자열을 포함하는 모든 문자열 또는 심벌 값
  • 프로퍼티 값: 자바스크립트에서 사용할 수 있는 모든 값

프로퍼티 키는 프로퍼티 값에 접근할 수 있는 이름으로서 식별자 역할을 한다. 그리고 프로퍼티 키는 따옴표를 사용('...')해서 문자열로 묶어야 하지만 식별자 네이밍 규칙을 준수한다면 생략할 수 있다. 하지만 반대로 식별자 네이밍 규칙을 따르지 않는다면 반드시 따옴표를 사용해야 한다.

const person = {
  fistName: "Useong", // 식별자 네이밍 준수
  "last-name": "Lee", // 식별자 네이밍 준수
  nick-name: "Ayaan" // SyntaxError: Unexpected token -
};

따옴표가 없는 nick-name은 자바스크립트가 - 이 부분을 빼기 연산자(-)로 해석을 한다. 그렇기 때문에 문법 오류가 발생하는 것이다. 그리고 몇가지 더 재밌는 부분이 있다. 다음 코드를 보자.

const foo = {
  "": "", // 빈 문자열도 프로퍼티 키로 사용 가능
  0: 0,
  1: 1,
};

프로퍼티 키는 빈 문자열로도 사용할 수 있다. 하지만 키로서 의미를 갖지 못하니 권장하지 않는 방법이라고 책에서 설명한다. 그리고 넘버 타입도 키로 사용할 수 있다. 하지만 자바스크립트는 내부적으로 문자열로 변환한다. 그래서 넘버 타입으로 사용해도 결국 프로퍼티 키는 문자열이 된다.

const foo = {
  var: "",
  function: "",
};

var, function과 같이 예약어를 프로퍼티 키로 사용해도 에러가 나지 않는다. 하지만 이 부분도 예상치 못한 곳에서 에러가 날 수 있으므로 권장하지 않는다고 책에서 설명하고 있다.

const foo = {
  name: "Lee",
  name: "Kim",
};

console.log(foo); // { name: 'Kim' }

이미 존재하는 프로퍼티 키를 중복 선언한다면 나중에 선언한 프로퍼티가 앞서 선언한 프로퍼티를 덮어 쓴다. 이 부분에서도 에러가 나지 않기 때문에 주의를 해야한다. 가장 좋은 방법은 프로퍼티 키도 변수명처럼 중복되지 않게 코드를 작성하는 것이 좋은 것 같다고 생각한다.

10.4 메서드

자바스크립트에서 사용할 수 있는 모든 값은 프로퍼티 값으로 사용할 수 있다. 그리고 자바스크립트의 함수는 객체(일급 객체)다. 따라서 함수는 값으로 취급을 할 수 있다. 그렇다면 자바스크립트의 함수도 프로퍼티 값이 될 수 있다는 뜻이다. 함수와 메서드라는 단어를 구분짓기 위해 프로퍼티 값으로 사용되는 함수는 메서드라고 부른다.

const 자바스크립트숙련도 = {
  level: 0, // <- 프로퍼티

  // 메서드
  study: function () {
    return this.level++;
  },
};

10.5 프로퍼티 접근

  • 마침표 표기법 (ex. person.name)
  • 대괄호 표기법 (ex. person['name'])

프로퍼티 값에 접근하는 방법은 위와 같이 두가지 방법이 있다. 하지만 주의사항이 있는데, 대괄호 프로퍼티 접근 연산자 내부에 지정하는 프로퍼티 키는 반드시 따옴표로 감싼 문자열이어야 한다. 그렇지 않으면 자바스크립트 엔진은 대괄호안에 있는 키를 프로퍼티 키가 아닌 식별자로 해석하기 때문에 에러가 발생한다.

const person = {
  name: "Ayaan",
};

console.log(person[name]); // ReferenceError: name is not defined

console.log(person.age); // undefined

그리고 객체에 없는 프로퍼티 키에 접근을 하면 undefined를 반환한다. 에러가 발생하지 않는 다는 점을 주의해야 한다. 그리고 다음과 같은 에러 상황도 있다.

const person = {
  "my-name": "Ayaan",
};

// person.my-name 을 실행시키면 어떻게 될까?

// ---아래와 같은 에러 발생---
// 브라우저 환경 -> NaN
// Node.js 환경 -> ReferenceError: name is not defined

브라우저 환경에서는 NaN 그리고 Node.js 환경에서는 참조에러가 발생한다. 이 이유는 무엇일까? person.my-name을 실행하면 자바스크립트 엔진은 먼저 person.my를 평가한다. -는 빼기 연산자로 해석하기 때문이다. 그러면 위 코드에서 person.my를 실행한다면 undefined를 반환할 것이다. 그래서 자바스크립트 엔진은 undefined - name 으로 평가를 하게 된다. 하지만 여기서 또 알아야 할 점은 자바스크립트에서 name은 식별자로서 전역 객체인 window를 가리키며 기본값은 빈 문자열이다. 결과적으로 undefined - ''가 된 셈이다. undefined에서 빼기 연산자를 사용했으므로 NaN을 반환하게 된 것이다.

10.9 ES6에서 추가된 객체 리터럴의 확장 기능

const [x, y] = [1, 2];

const obj = {
  x: x,
  y: y,
};

console.log(obj); // {x: 1, y: 2}

ES6에서 프로퍼티 축약 표현이 추가되었다. 변수에 할당된 값을 즉시 사용할 수 있게 축약 표현이 추가된 것이다. 그리고 다음 아래와 같은 코드로도 사용이 가능하다.

const [hello, world] = [1, 2];

const obj = { hello, world };

console.log(obj); // {hello: 1, world: 2}

변수이름(hello)와 프로퍼티 키(hello)가 같다면 프로퍼티 키를 생략할 수 있고, 프로퍼티 키는 자동으로 생성된다. 이 방법은 우리가 자주 사용하는 React에서도 사용할 수 있다.

// app.jsx
function App() {
  const [state, setState] = useState("안녕 디지몬");

  return (
    // 기존
    <Component state={state} />

    // ES6
    <Component state />
  )
}

그리고 객체의 프로퍼티 키는 계산된 값을 사용해서 프로퍼티 키를 동적으로 생성할 수도 있다.

const obj = {};
const prefix = "prop";
let i = 0;

obj[prefix + "-" + ++i] = i;
obj[`${prefix}-${++i}`] = i;


원시 값과 객체의 비교

자바스크립트의 타입은 원시 타입과 객체 타입으로 구분할 수 있다. 원시 타입과 객체 타입으로 구분하는 이유는 근본적으로 다르기 때문이다. 원시 타입은 변경 불가능한 값이다. 반대로 객체 타입은 변경이 가능한 값이다. 원시 값을 변수에 할당하면 메모리 공간을 확보하고 실제 값이 저장된다. 이에 비해 객체를 변수에 할당하면 메모리 공간에 실제 값이 아니라 참조 값이 저장된다.

원시 값을 변수에 할당하고 재할당을 할 경우 자바스크립트는 원본의 원시 값을 복사하여 새로운 메모리 공간에 전달된다. 이를 값에 의한 전달이라고 표현한다. 그렇다면 객체는 참조에 의한 전달일까? 이것은 뒤에서 더 다룰 예정이다.

11.1 원시 값

원시 타입은 변경 불가능한 값이라고 앞서 설명했다. 하지만 명확하게 구분지어서 이해해야 할 부분은 원시 값을 변경할 수 없다는 뜻이지 변수의 값을 변경할 수 없다는 뜻은 아니다. 변수는 메모리 공간을 확보하고 메모리 공간을 식별하기 위한 용도이다. 그렇기 때문에 재할당을 통해서 원본의 값을 복사해온 후 새로운 메모리 공간에 값을 넣고 그 변수가 참조하고 있는 값을 변경 시킬 수 있다. 하지만 값 그 자체인 원시 값은 변경할 수 없다. 즉 "원시 값은 변경할 수 없다"의 뜻은 원시 값 자체를 변경할 수 없을 뿐이지, 변수의 값은 변경할 수 있다.

변수의 상대 개념인 상수도 있다. 변수는 언제든 재할당을 할 수 있지만, 상수는 선언 시점에 단 한 번만 할당이 허용된다. 상수는 재할당이 금지된 변수일 뿐이다.

const num = 0;
const obj = {};

num = 1;
obj.a = 1;

console.log(num); // Uncaught TypeError: Assignment to constant variable.
console.log(obj); // {a: 1}

// const 키워드를 사용하면 변수에 할당한 원시 값은 변경할 수 없다.
// 하지만 const 키워드에 할당한 객체는 변경할 수 있다.

원시 값을 할당한 변수에 새로운 원시 값을 재할당하면 새로운 메모리 공간을 확보하고 재할당한 원시 값을 저장한 후, 변수는 새롭게 재할당한 원시 값을 가리킨다. 이때 변수가 참조하던 메모리 공간의 주소가 바뀐다. 그리고 더이상 참조하지 않는 메모리 공간(아래 사진에서 회색 부분)은 가비지 컬렉션에 의해 해제된다.

원시값 재할당 메모리 참조 이미지

변수 값을 변경하려면 위와 같이 재할당을 통해 새로운 메모리 공간을 확보하고 재할당할 값을 저장한 후, 변수가 참조하던 메모리 공간의 주소를 변경한다. 값의 이러한 특성을 우리는 불변성(immutability) 이라 한다. 불변성을 갖는 원시 값을 할당한 변수는 재할당 이외에 변수 값을 변경할 수 있는 방법이 없다.

11.1.2 문자열과 불변성

우리는 원시 값을 저장하기 위해 메모리 공간의 크기를 확보하고 결정해야 한다. 그래서 이를 위해 메모리 공간의 크기가 미리 정해져있다. 명확하게 정해져있는 것은 문자열 타입(2byte)와 숫자 타입(8byte)이다.

여기서 재미있는 사실은 숫자 값은 얼마인지에 상관없이 8byte로 결정된다. 하지만 문자열 타입은 몇 개의 문자가 있는지에 따라 메모리 공간의 크기가 결정된다.

const num1 = 1; // 8byte
const num2 = 1000000; // 8byte

const str1 = "a"; // 2byte
const str2 = "ayaan"; // 10 byte

문자열에는 유사 배열 객체라는 것이 있다. 유사 배열 객체란 마치 배열처럼 인덱스로 프로퍼터 값에 접근할 수 있고 length 프로퍼티를 갖는 객체를 말한다. 그리고 length가 있기 때문에 for 문을 통해서 문자열의 문자들을 조회할 수 있다. 하지만 배열은 아니므로 배열의 메서드인 push, pop, shift, unshift 등은 사용할 수 없다.

const name = "ayaan";

console.log(name.length); // 5
console.log(name[0]); // 'a'

for (let i = 0; i < name.length; i++) {
  console.log(name[i]); // 'a', 'y', 'a', 'a', 'n'
}

그렇다면, 유사 배열 객체인 문자열의 값을 수정할 수 있을까?

let name = "ayaan";

name[0] = "k";

console.log(name); // undefined

결과는 undefined가 나왔다. 문자열은 유사 배열 객체이지만 결국 원시 값이므로 값을 변경할 수 없다. 그리고 이때 에러가 나지 않기 때문에 주의해야 한다. 이러한 문자열의 상태는 데이터의 신뢰성을 보장한다. 하지만 문자열을 재할당을 해서 변수의 값을 변경하는 것은 당연히 가능하다.


잠깐! 🖐 자바스크립트에는 또 한가지 유사 배열 객체가 있다.

또 어떤 유사 배열 객체가 있을까? 그건 바로 DOM을 조작할 때 볼 수 있다. 바로 코드를 살펴보자.

<ul id="list">
  <li class="item">1</li>
  <li class="item">2</li>
  <li class="item">3</li>
  <li class="item">4</li>
  <li class="item">5</li>
  <li class="item">6</li>
  <li class="item">7</li>
  <li class="item">8</li>
  <li class="item">9</li>
  <li class="item">10</li>
</ul>;

const getAllListItems = document.querySelectorAll("#list > .item");

console.log(getAllListItems);
// NodeList(10)
// [li.item, li.item, li.item, li.item, li.item, li.item, li.item, li.item, li.item, li.item]

getAllListItems.push("li.item");
// Uncaught TypeError: getAllListItems.push is not a function

위에서 querySelectorAll을 통해 li tag를 전부 불러왔고, 값을 getAllListItems 변수에 할당했다. 그리고 배열 형태의 값이 저장되었다. 하지만 getAllListItems 변수에 push를 사용했을 때 에러가 난다. 분명 배열의 형태인데 왜 에러가 날까?

배열처럼 보이지만 DOM을 통해 값을 가져온 NodeList는 유사 배열 객체이다. 그래서 배열 메서드를 사용하면 에러가 난다. NodeList와 같이 배열 형태이고 유사 배열 객체일 경우에 배열 메서드를 사용하고 싶다면 Array.from, Array.slice, 스프레드 연산자 등 실제 배열로 변경한 후 배열 메서드를 사용하면 된다.

그리고 또 한가지, 유사 배열인 NodeList 프로토타입에는 forEach가 있어서 유사 배열이지만 forEach로 각 엘리먼트 태그들을 순회할 수 있다. 하지만 getElementsBy*로 가져온 유사 배열 HTMLCollection의 프로토타입에는 forEach가 없기 때문에 forEach로 엘리먼트 태그들을 순회할 수 없다.

NodeList []
  length: 0
  [[Prototype]]: NodeList
  entries: ƒ entries()
  forEach: ƒ forEach()
  item: ƒ item()
  keys: ƒ keys()
  length: (...)
  values: ƒ values()
  // ...생략

HTMLCollection []
  length: 0
  [[Prototype]]: HTMLCollection
  item: ƒ item()
  length: (...)
  namedItem: ƒ namedItem()
  // ...생략

11.1.3 값에 의한 전달

let score = 80;
let copy = score;

console.log(score, copy); // 80 80
console.log(score === copy); // true

copy = 100;

console.log(score === copy); // false

변수(여기서는 copy)에 이미 할당된 값(여기서는 score 값)을 전달받는 것을 값에 의한 전달이라고 한다. 이때 copy는 복사된 score의 값을 전달받는 것이다. score와 copy는 같은 값을 같는건 맞지만 전혀 다른 메모리 공간에 저장된 별개의 값이다. 그렇기 때문에 copy의 값을 변경해도 score 변수에 전혀 영향을 주지 않는다.


변수 값에 의한 전달 참고 이미지

score는 copy에게 값에 의한 전달을 하지만 사실 값에 의한 전달은 자바스크립트 용어가 아니라고 책에서 설명하고 있다. 엄격하게 표현하면 변수에는 값이 전달되는 것이 아니라 메모리 주소가 전달되기 때문이라고 설명한다. 이는 변수와 같은 식별자는 값이 아니라 메모리 주소를 기억하고 있기 때문이라고 한다. 결국 이 말의 핵심은 두 값은 서로 다른 메모리 공간에 저장된 별개의 값이라는 것이며, 재할당을 통해 한 쪽에서 값을 변경해도 서로 간섭할 수 없다는 뜻이다.


11.2 객체

객체는 프로퍼티 값의 개수가 정해져 있지 않으며(0개 이상의 값만 있으면 되기 때문), 동적으로 추가되고 삭제할 수 있다. 따라서 객체는 원시 값과 같이 확보해야 할 메모리 공간의 크기를 사전에 정해 둘 수 없다. 그래서 원시 값은 미리 공간을 확보하고 값을 저장하는 부분에 있어서 상대적으로 적은 메모리를 소비하지만 객체는 경우에 따라 크기가 매우 클 수도 있다. 그래서 객체는 원시 값과는 다르게 동작하도록 설계되어 있다.

그렇다면 자바스크립트 객체는 어떻게 설계되어 있을까? 대부분의 자바스크립트 엔진은 해시 테이블과 유사하지만 높은 성능을 위해 일반적인 해시 테이블보다 나은 방법으로 객체를 구현한다.

객체 관리 방식 이미지

자바스크립트 엔진에서는 프로퍼티에 접근하기 위해 동적 탐색 대신 히든 클래스라는 방식을 사용해서 C++이 객체 프로퍼티에 접근하는 것과 유사한 성능을 보장한다. 자바스크립트 엔진 최적화 기법에 대해서 더 자세한 설명을 한 사이트는 아래에 있다.
자바스크립트 엔진 최적화 기법

11.2.1 변경 가능한 값

객체는 참조 타입의 값, 즉 객체는 변경 가능한 값이다 원시 값은 변수가 기억하는 메모리 주소를 통해 메모리 공간에 접근하면 원시 값에 접근할 수 있다. 하지만 객체를 할당한 변수가 기억하는 메모리 주소를 통해 메모리 공간에 접근하면 참조 값에 접근할 수 있다. 그렇기 때문에 원시 값과는 다르게 직접 값을 수정할 수 있다. 즉 재할당없이 값을 수정할 수 있다는 뜻이 된다.

객체는 원시 값과는 다르게 0개부터 수백개의 프로퍼티 값을 가질 수도 있게 된다. 그래서 값이 매우 클 수도 있고, 원시 값 처럼 크기가 일정하지 않으며 프로퍼티 값이 객체일 수도 있어서 복사(deep copy)해서 생성하는 비용이 많이 든다. 객체를 복사해서 생성하는 부분이 메모리의 효율적 소비가 어렵고 성능이 나빠진다는 뜻이다.

그래서 자바스크립트는 이러한 점을 보완하고자 객체를 복사해서 생성하는 비용을 절약하고, 성능을 향상시킬 수 있도록 설계했다. 바로 객체를 변경 가능한 값으로 설계한 부분이 이러한 이유때문이다. 정리하자면 원시 값은 재할당을 해서 값을 수정하지만 객체는 그렇게 하면 복잡하고 메모리 효율이 좋지 않기 때문에 재할당없이 값을 수정할 수 있도록 설계했다고 보면 된다.

하지만 이렇게 편리하게 사용할 수 있지만 부작용도 따른다. 값을 재할당없이 마음대로 수정하기 때문에 여러개의 식별자가 하나의 객체를 공유할 수도 있게 된다. 이 부분은 다음 파트에서 더 살펴보자.

11.2.2 참조에 의한 전달

식별자가 하나의 객체를 공유할 수도 있다? 이건 도대체 무슨 말인가..

const person = {
  name: "Ayaan",
};

// 참조에 의한 전달 (얕은 복사)
const copy = person;

객체를 가리키는 변수(원본, person)를 다른 변수(사본, copy)에 할당하면 원본의 참조 값이 복사되어 전달된다. 이를 참조에 의한 전달이라고 한다.

객체 참조에 의한 전달 자료 이미지

위 사진의 두번째를 보면 person의 참조 값(0x00001332)를 복사해서 copy에 저장한다. 즉 객체는 기존 변수에 할당되어 있는 객체를 복사하면 객체의 참조 값을 저장하기 때문에 저장된 메모리 주소는 다르지만(0x000000F2와 0x00000524가 다름) 참조 값(0x00001332)은 갖다고 할 수 있다. 그리고 두 객체는 동일한 객체를 가리키고 있다. 그래서 이 두 개의 식별자가 하나의 객체를 공유한다라고 표현할 수 있는 것이다. 그리고 원본 또는 사본 중 한쪽에서 객체의 값을 수정하면 원본과 사본 모두에 영향을 미친다. 그 이유는 같은 객체를 바라보고 있기 때문이다. 다음 예제를 살펴보자.

const person = {
  name: "Ayaan",
};

const copy = person;

console.log(person === copy); // true

copy.name = "Lee";

person.address = "Seoul";

console.log(person); // {name: 'Lee', address: 'Seoul'}
console.log(copy); // {name: 'Lee', address: 'Seoul'}

값에 의한 전달이나 참조에 의한 전달이나 식별자(여기서는 person 또는 copy)가 기억하는 메모리 공간에 저장되어 있는 값을 복사해서 전달한다는 면에서 동일하다. 다만 메모리 공간에 있는 값(변수에 할당된 값)이 원시 값이냐 참조 값이냐의 차이만 있을 뿐이다. 이러한 이유 때문에 책에서는 값에 의한 전달만 있고 참조에 의한 전달은 존재하지 않다고 말하고 있다.

마지막으로 다음 코드를 살펴보자.

const person1 = {
  name = 'Lee';
}

const person2 = {
  name = 'Lee';
}

console.log(person1 === person2); // ??
console.log(person1.name === person2.name); // ??

다음 물음표의 값들을 유추해보자. 첫번째 콘솔로그의 값에는 무엇이 찍힐까? 바로 false가 찍힌다. person1과 person2는 객체 리러털에 의해 객체를 생성한다. 하지만 이 둘의 변수가 가지고 있는 값은 같다. 그럼에도 왜 false일까? 안에 값이 같아도 결국 다른 메모리에 저장하기 때문이다. 즉 변수마다 참조하고 있는 객체가 다르다는 뜻이다. 그렇기 때문에 완전 별개의 객체가 되는 것이고, 그래서 false 값이 나온다.

그러면 두번째는 어떠한 값이 찍힐까? 두번째는 true가 찍힌다. 그 이유는 객체가 아니라 값을 비교하고 있다. person1.name을 통해 person1의 프로퍼티 값에 접근을 했다. 그리고 person1.name의 값은 원시 값을 가지고 있다. person2도 마찬가지이므로 이 둘은 결국 원시 값을 비교를 하고 있는 것이다. 그래서 true 값이 나온다고 할 수 있다.

[추가] {} === {} 그리고 {} == {}

책에는 나오지 않지만 알아두면 좋은 개념이 있다.

{} === {} // false
{} == {} // false

이 둘의 비교 값에 대해서 왜 둘 다 false일까?에 대한 개념이다. 첫번째는 바로 위에서 공부한 내용과 유사하다. person1 === person2와 같이 객체는 참조 값을 가진다. 즉 객체라는 것은 원시 값처럼 메모리 공간에 값을 가지고 있는 것이 아니라 어느 객체에 참조하고 있다는 뜻인 객체의 번지수, 즉 주소 값을 가지고 있는 것이다. 그렇기 때문에 하나의 객체는 하나의 참조 값을 가지고 있으므로, 따로 객체 리터럴을 통해 객체를 만들었다면 별개의 객체가 되는 것이다. 그래서 첫번째는 false 값을 가진다.

그렇다면 두번째는 왜 false일까? 느슨한 동등 연산자(==)는 값이 달라도 타입이 같으면 true를 반환한다. 두번째는 결국 object == object를 하고 있는 것인데 왜 false일까? 사실 객체에는 타입의 여부도 상관이 없다. 즉 객체앞에서 엄격한 연산자(===)와 느슨한 동등 연산자(==)는 차이가 없다는 뜻이다. 이 부분 또한 결국 객체는 참조 값을 가지고 있기 때문이라고 할 수 있다.

[추가] 얕은 복사와 깊은 복사

객체에는 얕은 복사와 깊은 복사의 개념이 있다. 이러한 개념이 생긴 이유는 어떠한 값을 복사하느냐에 따라 달라지기 때문인 것 같다고 생각한다. 간단히 설명하면 얕은 복사는 참조 값을, 깊은 복사는 원시 값처럼 완전한 복사를 한다.

하지만 알아야 할 사실은 얕은 복사든 깊은 복사든 복사를 통해 생성된 객체는 원본과는 다른 객체라는 것이다. 단지 얕은 복사는 객체에 중첩되어 있는 객체일 경우 참조 값을 복사하는 것이고, 깊은 복사는 객체에 중첩되어 있는 객체까지 완전한 복사를 한다는 차이만 있을 뿐이다. 그렇다면 얕은 복사와 깊은 복사를 하는 방법을 알아보자.

const obj = {
  num: 1,
  item: {
    str: "hello",
  },
};

// 얕은 복사
const shallowCopy1 = { ...obj };
const shallowCopy2 = Object.assign({}, obj);

console.log(obj === shallowCopy1); // false
console.log(obj.item === shallowCopy2.item); // true

얕은 복사는 객체를 복사할 때 참조 값을 복사한다고 앞서 설명했다. 그래서 기존 객체(obj)와 복사본(shalloCopy)는 같은 객체를 참조하고 있다. 그리고 객체안에 있는 객체 즉 한개 이상의 중첩되어 있는 객체도 기존 변수에 할당되어 있는 객체(obj)의 객체를 참조하고 있다면 이를 얕은 복사라고 할 수 있다.

const obj = {
  num: 1,
  item: {
    str: "hello",
  },
};

// 깊은 복사
// lodash 라이브러리 사용
import clonedeep from "lodash/clonedeep";

const deepCopy1 = clonedeep(obj);

// JSON parse/stringify 사용
const deepCopy2 = JSON.parse(JSON.stringify(obj));

console.log(obj. === deepCopy1) // false
console.log(obj.item === deepCopy1.item) // false

console.log(obj. === deepCopy2) // false
console.log(obj.item === deepCopy2.item) // false

깊은 복사는 복사본 객체(deepCopy)가 원본 객체(obj)와의 참조를 완전히 끊어버린 객체라고 할 수 있다. 이러한 이유로 복사본 객체(deepCopy)와 원본 객체(obj)는 별개의 객체를 참조하고 있다고 말할 수 있다. 위의 코드에서 콘솔로그의 결과는 모두 false가 나왔다. 먼저 JSON parse/stringify 사용을 통해 깊은 복사를 했는데, 이 부분은 JSON.stringify를 통해 객체를 json 문자열 형태로 바꾸면서 원본과의 참조를 완전히 끊어버렸다고 할 수 있다. 그리고 다시 json 문자열을 파싱한 것이다.

JSON.stringify를 이용한 방법은 간편하지만 주의해야 할 점이 있다. 바로 메서드는 깊은 복사가 안된다는 점이다. getter/setter등과 같이 JSON으로 변경될 수 없는 파일은 생략될 수도 있기 때문에 주의해서 사용해야 한다. 그래서 JSON.stringify를 사용해야 할 때는 객체안에 있는 메서드가 아닌 단순히 객체 프로퍼티 값만을 사용할 때 쓰면 좋을 것 같다.

const obj = {
  num: 1,
  method: function () {
    console.log("hi");
  },
};

const deepCopy = JSON.parse(JSON.stringify(obj));

console.log(deepCopy);
// { num: 1 }

그리고 JSON parse/stringify 이외에 structuredClone()라는 것도 있다. (얼마전에 우연히 깊은 복사 글을 읽다가 알게 된 사실...) 이것도 JSON parse/stringify과 같은 기능을 하며, 마찬가지로 메서드는 삭제된다. mdn 설명에 의하면 자바스크립트 객체의 직렬화를 위해서 HTML5 specification에 정의된 새로운 알고리즘 이라고 한다. 순환그래프를 포함하는 객체의 직렬화를 지원하기 때문에 JSON보다 더 유용하다고 설명하고 있다. 이 알고리즘은 본질적으로 원본 객체의 모든 필드를 거치고 각 필드의 값들을 새로운 객체로 복제한다. 만약 필드가 객체를 가졌다면 모든 필드와 그 서브필드가 새로운 객체로 복제될 때 까지 재귀적으로 동작한다. 깊은 복사를 할 일이 있다면 structuredClone 알고리즘을 써보는 것을 추천한다.
MDN - structuredClone()

const obj = {
  num: 1,
  method: function () {
    console.log("hi");
  },
};

const deepCopy = structuredClone(obj);

console.log(deepCopy);
// { num: 1 }

마무리

자바스크립트 객체는 다른 객체 지향 언어(C++, Java)와 다르게 유연한 장점이 있고, 특별한 점이 많았다. 객체를 쉽게 생성할 수 있고, 편리하게 관리할 수 있다는 장점과 잘못된 방식에도 오류가 나지 않는다는 점 등이 있다. 그럼에도 자바스크립트에서 객체는 정말 중요한 자료구조이며 OOP 개념 이외에도 클린 코드 아키텍쳐 사례, 테스트 코드를 위한 모듈화 예시 등으로도 많이 사용된다. 그렇기 때문에 특별한 자료구조이며 반드시 딥하게 공부할 필요가 있는 부분이라고 생각한다. 이번 스터디를 통해 객체를 딥다이브 해볼 수 있어서 뜻깊은 시간이 된 것 같다.