자바스크립트의 객체

객체란 무엇인가

객체는 키-값의 쌍을 각각의 구성요소로 가지는 자료형입니다.

객체는 자바스크립트에서 가장 중요한 자료형으로써, 현대 자바스크립트 코드의 빌딩 블록과도 같은 역할을 합니다.

숫자나 문자열 같은 원시 자료형을 제외하고는 자바스크립트에서는 대부분의 데이터가 객체 혹은 유사 객체의 형태로 표현됩니다.

객체지향 프로그래밍(Object Oriented Programming)이라는 프로그래밍 방법론에서는 객체를 보다 더 추상적인 개념으로 설명합니다만,

일단 자료형의 측면과 코드내에서 다루는 방법론에 대해 학습을 하고, 이후 해당 방법론을 공부하면서 또 직접 사용해가면서 차근차근 추상적인 정의에 대해 이해를 높이는 것이 좋을 것 같습니다.

생김새

객체는 앞서 말씀드렸듯 키-값의 쌍들을 그 요소로 구성하는 자료형으로써, 아래와 같은 생김새를 가졌습니다.

// { ... } 로 표현된 객체를 objectSample에 할당합니다.
const objectSample = {
  a: 1,
  b: 2,
}

객체는 기본적으로 위 처럼 자바스크립트 코드내에서 { key: value, ... } 의 형태로 표현할 수 있습니다. 이를 객체의 리터럴 표기법 이라고 합니다.

중괄호(Curly brackets) 로 감싸여진 모양새를 가졌고,
각각의 요소를 나타내는 키-값의 쌍들은 반드시 쉼표(,)를 통해 구분이 되어야 합니다.

const emptyObject = {};

{} 사이에 아무 것도 표기하지 않으면, 빈 객체가 생성됩니다. 빈 객체는 null과 다릅니다.

객체는 위와 같은 리터럴 표기법으로 생성하는 방법 외에도 생성자 함수를 이용하거나, Object 객체의 정적 메소드들을 이용해 동적으로 생성하는 것이 가능합니다.

// 생성자 함수를 통한 객체 생성
function Dog(name, age) {
 this.name = name;
 this.age = age;
}
const objSample = new Dog("앵두",2);

이 부분은 후속 게시물에서 보다 자세히 다룰 수 있도록 하겠습니다.

객체에서 키(key)는 어떤 객체에 포함된 데이터의 '이름'을 나타냅니다.
이 이름을 이용해 객체 속 원하는 데이터에 직접적으로 접근이 가능합니다.

아래에 어떤 객체를 표현한 코드가 있습니다.

const objectSample = {
  a: 1,
  b: 2,
  "b.c": 3,
   var: 4
  "}": 5
};

위 코드에서 객체에 사용된 '키'들은 아래와 같습니다.

a, b, "b.c", var, "}"

객체의 키는 식별자가 아닌 "문자열"로 저장이 됩니다.

그래서 var의 경우와 같이 예약어임에도 불구하고 키값으로 사용이 가능하고, "b.c"나 "}"와 같이 프로그램 코드를 구성하는 토큰으로 사용되는 특수문자 역시도 따옴표로 묶어서 키값으로 사용이 가능한 것입니다.

  • ES6 부터는 Symbol 자료형 의 데이터도 키로 사용할 수 있게 되었습니다.

객체의 '값'은 특정한 '키'에 연결된 데이터를 의미합니다.

const objSample = {
  a: 1,
  b: "abc",
  c: Symbol.for("only One"),
  d: null,
  e: undefined,
  f: [1, 2, 3],
  g: {
    a: 1,
    b: null
  }
};

객체는 값으로 무엇이든 다 저장할 수 있습니다.

숫자, 문자열, 불리언, 심볼, null, undefined와 같은 원시 자료형이 하나의 자료형만을 값으로 저장할 수 있다면, 객체는 그 값으로 모든 원시타입과 객체타입(배열과 함수 포함)의 자료형을 저장할 수 있습니다.

키를 이용해 값을 수정하고 조회하는 방법

const Dog = {};
Dog.name = "멍멍이";
Dog.age = 2;
console.log(Dog.name); // 출력 : "멍멍이"

const Dog = {};
Dog["name"] = "멍멍이";
Dog["age"] = 2;
console.log(Dog["name"]); // 출력 : "멍멍이"

위에서 줄바꿈으로 구분된 두 개의 코드뭉치는 모두 같은 결과를 나타냅니다.

키값이 일반적인 변수명처럼 식별자로 활용이 가능한 문자열이라면 점 연산자(.)을 이용해 해당 키값의 자료에 접근할 수 있습니다.
키값에 스페이스가 있거나 점이나 괄호 같은 식별자로 활용이 불가한 문자열이 포함되었다면 두번째 코드와 같이 대괄호 연산자([])를 이용해 해당 키값의 자료에 접근 가능합니다.

속성

const Dog = {};
Dog.name = "멍멍이";
Dog.age = 2;

Dog 객체에서 Dog.name과 Dog.age 와 같은 각각의 구성요소들을 Dog 객체의 속성 혹은 멤버라고 부릅니다.

메서드

Dog.bark = function () {
  console.log("월월!");
}

Dog.bark(); // "월월!"이 출력된다.

속성은 이름이나 나이와 같이 객체의 정보나 상태를 나타내는 변수의 역할을 하기도 하지만, 위와 같이 값으로 함수를 저장해서 해당 객체와 관련된 어떤 동작을 저장하고 필요할 때 호출할 수 있습니다.

이렇게 어떤 함수가 어떤 객체의 멤버로써 존재할 때 이를 특별하게 '메서드' 라고 부릅니다.

Dog = {
  ... 기타 속성들,
  bark () { // (*)
  console.log("월월!");
  }
}

Dog.bark(); // "월월!"이 출력된다.

메서드는 위 처럼 function 키워드를 사용하지 않고 단축하여 표기하는 것도 가능합니다.
위와 같은 표기법은 객체의 리터럴 표기법 내에서만 유효하고 function 키워드를 사용할 때와 동일한 결과를 나타냅니다.

자신이 속한 객체를 참조하는 방법

많은 경우 메서드는 자신이 속한 객체에 저장된 다른 속성을 활용하여 어떤 동작을 수행합니다.

만약 어떤 메서드 내에서 자신이 속한 객체를 참조하고자 할 경우에는 this 라는 키워드를 사용하면 됩니다.

const Dog = {
    sound: "멍멍!",
    bark: function () {
        console.log(this.sound);
    }
}

Dog.bark(); // 출력 : "멍멍!"

this 는 함수의 호출 방법에 의해 참조가 달라집니다.

어떤 함수가 호출되면서 새롭게 실행 컨텍스트가 생성될 때, 함수 내부에서 참조할 this의 값을 결정짓게 되는데요.

이러한 과정을 this binding 이라고 합니다.

간단하게 정리한 규칙은 아래와 같습니다.

this binding 규칙
  • . 연산자나 [] 연산자를 이용해 어떤 객체의 멤버로써 메서드를 호출할 시, 해당 메서드 내의 this는 해당 객체로 binding 됩니다.
  • 위 경우가 아닌 함수 호출은 크게 세가지 결과를 나타냅니다.
    • call, apply, bind 로 강제로 binding 한 경우, 당연히 binding된 객체를 this로써 참조합니다.
    • window 혹은 global과 같은 전역 객체를 this로 참조합니다.
    • strict 모드의 경우 undefined 가 됩니다.
  • ECMASript6 부터 추가된 화살표 함수는 호출시 this binding을 하지 않습니다.
    • call 이나 apply 혹은 bind 로도 this를 강제로 binding 할 수 없습니다.
    • 또한, 화살표 함수의 내부에서 this는 렉시컬 환경으로 연결된 스코프 체인에 의해 식별됩니다.
  • addEventListener 의 경우 전달된 이벤트 핸들러가 호출될 시 이벤트 인자(보통 e라고 많이 표기하시는)의 currentTarget 속성을 핸들러에 binding 합니다.
    • 즉, 해당 이벤트가 호출된 요소의 노드 객체를 가리킵니다.
const Dog = {
    sound: "멍멍!",
    bark: () => {
        console.log(this.sound);
    }
}

Dog.bark();
// 오류출력 : TypeError: Cannot read property 'sound' of undefined
// this 객체에 sound 속성이 존재하지 않아 호출할 수 없다는 내용의 오류

객체의 메서드로 화살표 함수를 사용할 경우 원치않은 결과가 나타날 수 있습니다.

이유는 위에서 설명한 this binding과 관련이 있습니다.
(본인의 소속이 어딘지 분간을 못 하거든요..)

메서드를 표기할 때는 메서드이름() { ... }와 같은 단축 표기법이나 function 키워드를 이용한 일반 함수 표현법을 사용해주세요.

getter와 setter

객체는 또한 getter와 setter라는 이름의 특수한 종류의 속성을 가질 수 있습니다.
일반적인 객체의 속성을 값 속성(Data properties)이라고 하고 getter와 setter를 접근자 속성(Accessor properties)으로 별도 분류하기도 합니다.

용도

human1 객체에서 나이에 대한 속성을 가져올 때 human1.age와 같은 접근법을 사용합니다.
마찬가지로 나이를 새롭게 저장할때도 human1.age = 31; 과 같은 표현을 사용하지요.

메서드를 통해 같은 내용의 동작을 수행하고자 하면,
human1.getAge()의 반환값을 통해 나이 정보를 조회하고,
human1.setAge(32)와 같은 메서드를 통해 새로운 나이 정보를 저장하는 형태를 띄게 될 것입니다.

console.log(human1.age);
human1.age = 32;

console.log(human1.getAge());
human1.setAge(32);

보시다시피 전자의 예로 든 일반적인 데이터 속성은 메서드에 비해 데이터를 조회하고 할당하는 방법이 보다 직관적인 모양새를 갖췄습니다.

하지만 메서드로 데이터를 접근하고 설정할 때는 그 나름대로의 장점이 있어서,
아래와 같이 데이터에 대해 좀 더 복잡한 형태의 접근과 가공이 가능합니다.

const human1 = {
  firstName: "영재",
  lastName: "주",
  age: 30,
  getFullName() {
      return this.lastName + this.firstName;
  },
  setName(name) {
      this.lastName = name[0];
      this.firstName = name.substr(1,2);
  }
}

human1.getFullName(); // "주영재"를 반환
human1.setName("김개똥");
// human1 = { firstName: "김", lastName: "개똥", age: 30, ... }

이러한 데이터 속성과 메서드의 장단점을 섞어서 데이터 속성의 문법으로 메서드를 사용할 수 있도록 만들어진 접근자 속성을 getter와 setter라고 합니다.

const human1 = {
  firstName: "영재",
  lastName: "주",
  age: 30,
  get name() {
      return this.lastName + this.firstName;
  },
  set name(name) {
      this.lastName = name[0];
      this.firstName = name.substr(1,2);
  }
}

human1.name; // "주영재"를 반환
human1.name = "김개똥";
// human1 = { firstName: "김", lastName: "개똥", age: 30, ... }

가장 아래 두 줄의 human1 객체의 가공된 풀네임에 접근하고 이름을 새롭게 저장하는 코드에 주목해주십시오.
마치 데이터 속성에 접근하고 새로운 값을 할당하는 것 같아 보입니다만, 실제로는 해당 속성에 연결된 메서드를 동작시킵니다.

따라서 앞선 두 개의 코드는 동일한 동작을 나타냅니다.

표기방법

getter와 setter는 객체의 리터럴 표기법 상에서 [get|set] 이름(...매개변수들) { ... 내용 } 의 형태로ㅂ 표현이 가능합니다.

get function getDescription() {
  ... 내용
}

이러한 표현법은 잘못된 표현법이고 문법 에러를 발생시키며 정상적으로 동작하지 않습니다.

getter와 setter를 동적으로 추가하기

일반적으로 객체 리터럴 표기법으로 객체의 선언 단계에서 getter와 setter를 미리 설정해두고 사용 합니다만,
코드의 실행 단계에서 동적으로 새로운 setter와 getter를 추가하는 것도 가능합니다.

getter/setter를 동적으로 추가할 때는 __defineGetter__ 와 __defineSetter__ 메서드를 사용하며 용법은 아래와 같습니다.
(언더바가 두 개인 것에 유의하세요)

  • 객체.__defineGetter__(속성명, 연결될 함수)
  • 객체.__defineSetter__(속성명, 연결될 함수)
const human1 = {
  firstName: "영재",
  lastName: "주",
  age: 30,
}

human1.__defineGetter__("name", function() {
  return this.lastName + this.firstName;
});

human1.__defineSetter__("name", function(name) {
    this.lastName = name[0];
    this.firstName = name.substr(1,2);
});

human1.name;
human1.name = "김개똥";

역시 앞선 코드들과 동일한 동작을 나타냅니다.

변수에 실제로 저장되는 것

const Dog = {};
Dog.name = "멍멍이";
Dog.age = 2;

앞서보았던 코드들 중 하나입니다.
무언가 이상한 점이 느껴지시나요?

무심결에 지나쳤을수도 있습니다만, 분명 Dog 변수는 const 키워드를 이용해 재할당이 불가능하도록 설정이 되어있음에도 불구하고,
Dog 객체에 내용물을 이것저것 추가하는 모양새를 하고 있습니다.

원시 자료형과 참조 자료형의 차이

자바스크립트의 모든 변수는 메모리상 실제 데이터의 주소를 저장합니다.
즉, 변수의 이름(식별자)와 실제 데이터를 서로 다른 위치에 보관하는 것이죠.

따라서 원시 자료형과 참조 자료형의 위와같은 특성 차이에 대해, 참조 복사 값 복사의 차이라고 생각하시는 경우들이 많은데..

사실은 원시 자료형이든 참조 자료형이든 간에 엄밀히 말하면 모든 변수 할당 과정에서 참조 복사가 이루어지는 것입니다.

그렇다면 원시 자료형과 참조 자료형에는 과연 어떤 차이가 있어서 위와 같은 현상이 나타나는 걸까요?

const 를 통해 재할당을 막는다는건 변수에 기록된 데이터의 주소를 변경하는 것을 막는다는 것입니다.
원시 자료형의 경우 변수에 할당되는 데이터의 주소가 정확히 그 데이터를 가르키고, 모든 데이터는 같은 주소내에서 불변하기에 const는 의도한 바대로 동작하게됩니다.

단, 참조 자료형의 경우 변수에 기록된 데이터의 주소가 다시 해당 값의 속성들의 범위가 기록된 또 다른 데이터의 주소를 참조합니다.

생각보다 다소간 복잡한 구조이고 개념적으로 이해하면 되는 부분일뿐, 직접 우리가 다루어야할 대상은 아니기에 간략하게 알아보고 넘어가겠습니다.

위 내용을 익숙한 자바스크립트 문법으로 추상화를 시켜 표현한다면 다음과 같을겁니다.

const 원시자료형_변수 = 주소록.원시자료A
const 참조자료형_변수 = (주소록.객체B);

let 주소록 = {
  원시자료A: 데이터.원시자료A,
  객체B: 데이터.속성목록B,
  배열c: 데이터.속성목록F,
  ...
}

let 데이터 = {
  원시자료A: "원시자료입니다.",
  원시자료B: 12345,

  ...

  속성목록B: [
    속성1: 주소록.원시자료E,
    속성2: 주소록.원시자료F,
    속성3: 주소록.배열C,
    ...
  ],

  속성목록F: [
    속성1: 주소록: 원시자료Y,
    속성2: 주소록: 원시자료Z,
    ...
  ]
};

원시 자료형 값들은 할당 연산자(=)를 이용해 변수에 값을 할당하고자하면 해당 데이터의 주소를 직접 저장합니다.
그 주소를 찾아가면 어떤 자료를 보관할 수 있는 상자가 나타나고, 숫자나 문자열 같은 데이터를 그대로 상자에 보관되어있는 모습을 상상해보시면 됩니다.

하지만, 객체는 한 단계 더 복잡한 과정을 거치는데 어떤 변수에 참조형 데이터를 할당하고자 할 때에는 자신이 보유한 속성들의 주소록을 목록으로 가진 또 다른 목록 데이터를 저장합니다.

역시 상자에 비유하자면 객체 변수가 가리키는 상자는 개의 이름, 나이, 짖는방법과 같은 데이터들이 그대로 담겨있는 상자가 아니고,
그러한 데이터들이 저장된 곳의 '위치정보의 목록'을 담아둔 상자를 가리킨다라고 생각하시면 이해하시기가 훨씬 수월할 것입니다.

즉 const로 선언된 변수 상자는 한 번 내용물을 넣어두면 '읽기 전용'이 되어버리는 것은 틀림없지만, 그 변수상자에는 객체에 포함된 속성들의 주소록이 연결되어있을 뿐이며, 주소록 자체는 내용을 추가하거나 삭제하는 것이 자유롭습니다.

const emptyObject = {};

위 코드가 null이 아닌 이유 역시 위 내용으로 설명이 됩니다.
객체에 구성요소가 없을 뿐이지, emptyObject 변수에는 엄연히 어떤 주소가 저장이 되어있기 때문이죠.

재할당시의 차이점

const dog1 = { name:"앵두", age:2};
const dog2 = dog1;

첫번째 줄에서 선언된 내용으로 인해, dog1이라는 변수 상자에는 { name:"앵두" ... } 리터럴로 생성된 속성목록의 주소가 담기게 됩니다.
두번째 줄에서 dog1에 저장된 속성목록의 주소가 dog2라는 변수 상자에 저장이 됩니다.

즉, dog1과 dog2는 동일한 목록의 주소를 참조하게 되는 것입니다.

그리하여,

dog2.name = "바둑이"; // dog2의 이름을 변경.
console.log(dog1.name); // dog1의 이름을 출력.

dog2의 주소로 찾아간 속성의 목록에서 name 이라는 속성의 주소를 "바둑이"라는 데이터의 주소로 변경하고, 두번째 줄에서 dog1을 타고타고 name 이라는 데이터에 접근하게 되면,
결국 두 줄 모두 같은 목록을 참조하게 되므로 동일한 값을 출력하게 되는 것입니다.

dog2.name = "바둑이"; // dog2의 이름을 변경.
console.log(dog1.name); // dog1의 이름을 출력.

// 출력 : "바둑이"

역시 동일한 목록을 가리키고 있기에, 어느 한 쪽의 데이터 변경은 다른 한 쪽에서도 확인됩니다.

반대로

const str1 = "abcdefg";
const str2 = str1;

str1 = "hijklmnop";
console.log(str2);

// 출럭 : "abcdefg"

원시자료형은 각 데이터에 직접 연결되는 주소를 보관합니다.
즉, 4번째 줄의 str1에 새로운 문자열을 할당할 때에는 해당 문자열이 저장된 새로운 주소를 저장하게 되는 것이므로, 참조 자료형과 같이 동시 변경되는 현상이 일어나지 않습니다.

배열도 객체고 함수도 객체다

배열과 함수는 기술적으로 보면 모두 객체입니다.
다만, 언어 자체에서 제공하는 기능과 표현방법의 차이가 있을 뿐입니다.

그래서 다음과 같은 것이 가능합니다.

배열의 예

const arr = [1,2,3,4];
arr["4"] = 5;
console.log(arr);
// 출력 : [1,2,3,4,5]
arr.name = "1부터 4까지의 자연수";
console.log(arr.name);
// 출력 : "1부터 4까지의 자연수"

const obj = {};
const obj2 = Object.assign(obj,arr); // obj라는 빈객체에 arr의 내용을 할당
console.log(obj2);
// 출력 : Object {0: 1, 1: 2, 2: 3, 3: 4, 4: 5…}

함수의 예

function sayMyName(name) {
  console.log(name);
}

sayMyName["description"] = "이름을 말하는 함수입니다.";
console.log(sayMyName.description);
// 출력 : "이름을 말하는 함수입니다."

console.log(sayMyName.length);
// 출력 : 1 <- 함수의 매개변수 갯수를 의미하는 함수 객체의 내장함수입니다.
console.log(sayMyName.name);
// 출력 : sayMyName <- 함수의 이름을 의미하는 함수 객체의 내장함수입니다.

그래서 배열과 함수도 참조복사가 됩니다.

배열이나 함수가 저장된 변수 역시 속성목록의 주소만 저장되어있을 뿐, 값 자체가 저장되어있지 않기 때문이지요.

const emptyArr = [];

function InsertNumberTo(arr) {
  arr.push(100);
}

InsertNumberTo(emptyArr);
console.log(emptyArr); // 출력 : [100]

위 코드와 같이 어떤 함수에 전달인자(Arguments)로 배열이나 객체, 함수와 같이 참조형 자료를 넘길 경우,
함수에서 전달받은 매개변수(Parameters) 역시 동일한 목록의 주소가 할당됨을 유념해야 합니다.

그말인 즉슨, 함수 내부에서 전달받은 배열을 별도의 복사없이 그대로 수정할 경우 외부의 배열이 동시에 수정되는 외부효과(Side effect)가 나타난다는 이야기로, 원치않은 버그의 원인이 될 수 있습니다.

함수는 일급 객체 입니다

본문의 맥락에서 살짝 빗나간 여담입니다만,
특히 자바스크립트에서 함수는 1급 객체(First-class object)라고 불리웁니다.

프로그래밍 언어에서 1급 객체의 조건은 다음과 같습니다.

  1. 변수나 데이터 구조 안에 할당할 수 있어야 한다.
  2. 함수 등에 인자로 전달 할 수 있어야 한다.
  3. 반환값으로 사용할 수 있어야한다.

자바스크립트에서 함수는 위의 모든 것이 다 가능합니다.
이를 이용해 콜백같은 것을 구현하는데 이에 관련해서는 다른 문서에서 보강하여 설명해드리겠습니다.

본문의 맥락상에서 함수와 관련해 참고해야되는 부분은, 인자로 넘겨진 함수 역시 동일한 주소를 참조하고 있다는 사실입니다.

객체는 연관된 기능과 데이터의 집합이다

보통 객체는 위의 Dog나 human1과 같이 개개의 사물로 보고 해당 사물에 연관된 변수와 함수들을 속성으로 모아서 사용합니다.

예를들어 객체의 속성은 나이나 이름같은 사물을 설명하는 정보가 되며, 객체의 메서드는 청소하기, 공부하기와 같은 사물의 행동을 의미합니다.

단순히, 짖는다는 행동과 이름과 나이라는 정보가 이곳저곳에 별개의 것으로 존재하는 것 보다는 연관성있는 것들끼리 함께 모아둘 경우 데이터와 함수를 조금 더 효율적이고 직관적으로 활용할 수 있게 됩니다.

이것에 관련된 주제가 바로 객체지향 프로그래밍 입니다.

이후 문서들에서 객체지향과 관련된 내용을 구현 측면에서 다뤄볼 것입니다만,
개념적인 측면에서는 인터넷 상에 좋은 자료가 많으니 이후 따로 공부하시는 것을 권해드립니다.