당신이 놓친 자바스크립트 - 3. 객체 기본 (1)

piecemaker·2021년 1월 4일
1
post-thumbnail

'당신이 놓친 자바스크립트' 시리즈는 모던 Javascript 튜토리얼를 공부하며 필자가 자바스크립트에 대해 몰랐던 점이나 헷갈렸던 점들을 정리한 시리즈입니다. 모든 출처는 위 사이트에 있습니다.

본 포스팅에서는 객체의 기본적인 개념과 관련해 쉽게 놓칠 수 있는 부분들을 다룹니다.

객체

  • 빈 객체를 만드는 방법에는 두 가지가 있습니다. 두 가지 방법들 중 객체를 선언할 때에는 주로 '객체 리터럴' 방법을 사용합니다.
let user = new Object(); // '객체 생성자' 문법
let user = {}; // '객체 리터럴' 문법
  • 객체를 만들 때 객체 리터럴 안의 프로퍼티 키가 대괄호로 둘러싸여 있는 경우, 이를 계산된 프로퍼티 (computed property)라고 합니다. 키 값으로 문자열만 사용할 수 있는 일반적인 프로퍼티와 달리 계산된 프로퍼티에는 키 값으로 변수 뿐만 아니라 복잡한 표현식이 올 수도 있습니다.
let fruit = 'apple';

let bag = {
  [fruit]: 5, // bag.apple = 5
  [fruit + 'Computers']: 5 // bag.appleComputers = 5
};
  • 자바스크립트에서는 객체에 존재하지 않는 프로퍼티에 접근하려 해도 에러가 발생하지 않고 undefined를 반환합니다. 따라서, 프로퍼티의 존재 여부를 확인하기 위해 in 연산자를 사용할 수 있습니다.

    일치 연산자(=== undefined)를 사용해서 프로퍼티 존재 여부를 알아내는 방법과 in 연산자와의 차이점은, 프로퍼티가 실제로 존재하지만 값으로 undefined를 가지는 경우에 일치 연산자는 이를 식별할 수 없으나 in 연산자는 이를 구분해준다는 점입니다. 따라서, in 연산자를 사용하는 것이 더 바람직합니다.

let obj = {
  test1: undefined,
};

alert( obj.test ); 
// test 프로퍼티가 obj에 존재하지 않지만, 얼럿 창엔 undefined가 출력됩니다. 
// 따라서, test 프로퍼티가 존재하지 않는 것인지, 아니면 undefined 값을 가지는지를 식별할 수 없습니다.

alert( "test" in obj ); // `in`을 사용하면 프로퍼티 유무를 제대로 확인할 수 있습니다(false가 출력됨).
  • 객체의 프로퍼티는 특별한 방식으로 정렬됩니다. 정수로 변환 가능한 문자열인 정수 프로퍼티("49", "1" 등)는 그 값을 기반으로 오름차순으로 자동 정렬되고, 그 외의 프로퍼티는 객체에 추가한 순서 그대로 정렬됩니다. 또한, 정수 프로퍼티는 그 외의 프로퍼티보다 우선순위가 더 높습니다.
let codes = {
  name: "John",
  "49": "독일",
  "41": "스위스",
  "44": "영국",
  surname: "Smith",
  "1": "미국"
};

for (let code in codes) {
  alert(code); // 1 41 44 49 name surname
}

참조에 의한 객체 복사

  • 자바스크립트에서 객체 복사는 두 가지로 나뉩니다. 얕은 복사깊은 복사가 그것입니다. 얕은 복사는 복사하려는 원본 객체가 프로퍼티의 값으로 객체(배열도 객체입니다)를 가질 경우, 해당 객체의 참조 값을 복사합니다. 따라서, 복사된 객체의 프로퍼티도 원본 객체와 동일한 객체를 가리키게 됩니다.

    자바스크립트에서 객체의 얕은 복사를 구현하려면 단순히 for...in 구문을 통해 원본 객체의 프로퍼티들을 순회해 일일히 복사하거나, Object.assign을 사용할 수 있습니다. ES6 문법 중 Spread Operator를 사용해도 Object.assign과 동일한 작업을 할 수 있습니다.

let user = {
  name: "John",
  age: 30
};

let clone1 = {};

// 얕은 복사 방법 1. 빈 객체에 user 프로퍼티 전부를 복사해 넣습니다.
for (let key in user) {
  clone1[key] = user[key];
}

// 얕은 복사 방법 2. Object.assign을 사용해 간단하게 객체를 복사할 수 있습니다.
let clone2 = Object.assign({}, user);

// 얕은 복사 방법 3. Spread Operator(...)를 사용해도 객체 복사가 가능합니다. 
let clone3 = {...user};
  • 얕은 복사는 객체 내부에 또 다른 객체가 존재하는 '중첩 객체'를 완전히 복사하지 못한다는 단점을 가집니다. 예를 들어 원본 객체의 프로퍼티 값으로 배열이 존재한다면, 객체를 복사한 후 복사된 객체의 배열에 값을 추가하거나 삭제했을 때 원본 객체의 배열도 변경 사항이 동일하게 적용되는 것이죠. 대개의 경우 이러한 동작 방식은 우리가 원하는 동작이 아닙니다. 우리는 복사된 객체가 원본 객체와 완전히 독립적으로 존재하기를 원합니다. 이를 위해 깊은 복사를 사용합니다.

    복사할 객체의 형태가 단순하거나 구조가 한정된 경우, 깊은 복사를 구현하는 가장 좋은 방법은 직접 객체를 복사하는 함수를 만드는 것입니다. 배열이 있다면 slice 함수를 통해 직접 복사하는 등의 방식으로 말이죠. 이러한 방법이 성능 면에서도 효율적입니다.

    하지만, 객체의 구조가 다양한데 하나의 함수만으로 깊은 복사를 처리하고 싶다면, Lodash라는 유명한 라이브러리의 deepclone 함수를 사용할 수 있습니다.

const clonedeep = require('lodash.clonedeep');

const orig = {
  name: "John",
  age: 30,
  hobby: ["movie", "jogging", "read"],
  print: function() {
    alert(`${this.name} is ${this.age} years old`);
  }
};

// deepclone 함수를 이용한 깊은 복사
const clone = clonedeep(orig);

JSON 객체의 메소드를 이용하여 깊은 복사를 구현하는 방법도 존재합니다. JSON.stringify 함수를 통해 객체를 JSON 문자열로 변환한 후에, JSON.parse 함수를 통해 JSON 문자열을 다시 자바스크립트 객체로 변환시키는 방법입니다.

이 방법은 JSON 문자열을 기반으로 처음부터 객체를 새로 만드는 것이기 때문에, 복사된 객체가 원본 객체와 완전히 별개로 존재할 수 있습니다. 하지만, 성능이 느리고 함수를 정상적으로 처리하지 못해 함수를 undefined로 처리한다는 단점이 존재하므로 사용에 유의해야 합니다.

const orig = {
  name: "John",
  print: function() {
    alert(`${this.name} is ${this.age} years old`);
  }
}

// JSON 객체 메서드를 활용한 깊은 복사
const clone = JSON.parse(JSON.stringify(orig));

alert(clone.name); // John
alert(clone.print); // undefined

가비지 컬렉션

  • 자바스크립트는 도달 가능성(reachability)라는 개념을 사용해 메모리 관리를 수행합니다. 도달 가능한 값은 쉽게 말해 어떻게든 접근하거나 사용할 수 있는 값을 의미합니다. 도달 가능한 값은 메모리에서 삭제되지 않습니다.

    자바스크립트 엔진 내에선 가비지 컬렉터(garbage collector)가 끊임없이 동작하며 모든 객체를 모니터링하고, 도달할 수 없는 객체는 삭제합니다.

  • 외부로 나가는 참조는 도달 가능한 상태에 영향을 주지 않습니다. 오직 외부에서 들어오는 참조만이 도달 가능한 상태에 영향을 줍니다. 즉, 상대가 나를 가리키는 것은 나의 도달 가능한 상태에 영향을 주지만, 내가 상대를 가리키는 것은 나에게 영향을 주지 않습니다.

  • 객체들이 연결되어 섬 같은 구조를 만드는데, 이 섬에 도달할 방법이 없는 경우 객체 전부가 메모리에서 삭제됩니다. 즉, 아래와 같이 복잡하게 얽혀있는 객체들도 루트에서부터 시작해서 도달할 수 없다면 모두 도달 가능하지 않다고 판단됩니다.

  • 가비지 컬렉션은 'mark-and-sweep'이라는 기본 알고리즘으로 동작하며, 알고리즘의 각 단계는 대개 다음과 같습니다.

    1. 가비지 컬렉터는 루트(최상위 객체) 정보를 수집하고 이를 mark(기억)합니다.
    2. 루트가 참조하고 있는 모든 객체를 방문하고 이것들을 mark합니다.
    3. mark된 모든 객체에 방문하고 그 객체들이 참조하는 객체도 mark합니다. 한번 방문한 객체는 모두 mark하기 때문에 같은 객체를 다시 방문하는 일은 없습니다.
    4. 루트에서 도달 가능한 모든 객체를 방문할 때 까지 위 과정을 반복합니다.
    5. mark되지 않은 모든 객체를 메모리에서 삭제합니다.

    new 연산자와 생성자 함수

  • new 연산자생성자 함수를 사용하면, 유사한 객체 여러 개를 쉽게 만들 수 있습니다. 생성자 함수(constructor function)와 일반 함수에 기술적인 차이는 없으나, 생성자 함수는 아래 두 관례를 따릅니다.

    1. 함수 이름의 첫 문자는 대문자로 시작합니다.
    2. 반드시 'new' 연산자를 붙여 실행합니다.

    아래와 같이 new 연산자를 써서 함수를 실행하면, 다음 알고리즘이 동작합니다.

    1. 빈 객체를 만들어 this에 할당합니다.
    2. 함수 본문을 실행합니다. this에 새로운 프로퍼티를 추가해 this를 수정합니다.
    3. this를 (암시적으로) 반환합니다.
let user = new User("Jack");

function User(name) {
  // this = {};  (빈 객체가 암시적으로 만들어지고 this에 할당됨)
  
  // 새로운 프로퍼티를 this에 추가함
  this.name = name;
  this.isAdmin = false;
  
  this.sayHi = function() {
    if(!this.isAdmin) {
      alert( "My name is: " + this.name );
    }
  };

  // return this;  (this가 암시적으로 반환됨)
}
  • 모든 함수는 생성자 함수가 될 수 있습니다. new를 붙여 실행한다면 어떤 함수라도 위에서 언급한 알고리즘이 실행됩니다. 첫 글자가 대문자여야 한다는 조건은 '관례'일 뿐이지 필수는 아니기 때문에, 대문자가 아니더라도 new를 붙여 실행이 가능하기는 합니다. 따라서, 아래와 같이 이름이 없는 '익명 생성자 함수'로 객체를 생성할 수도 있습니다.
let user = new function() {
  this.name = "John";
  this.isAdmin = false;
};
  • 생성자 함수엔 보통 return문이 없습니다. this가 암시적으로 반환되기 때문에 반환문을 명시적으로 써 줄 필요가 없기 때문입니다. 그런데, 만약 return문이 있다면 아래와 같은 규칙이 적용됩니다.

    • 객체를 return 한다면, this 대신 객체가 반환됩니다.
    • 원시형을 return 한다면, return 문이 무시됩니다.
function BigUser() {
  this.name = "John";
  
  return { name: "Godzilla" };  // <-- this가 아닌 새로운 객체를 반환함
}

function SmallUser() {
  this.name = "John";   
  return; // <-- this를 반환함 (return 문이 무시됨)
}

포스팅이 너무 길어져, 다음 포스팅에 이어서 설명드리겠습니다.

profile
풀스택 지망생

0개의 댓글