자바스크립트 객체, 당신이 몰랐던 진짜 이야기

"객체 하나 만드는데 왜 이렇게 방법이 많아요?"
강의를 할 때마다 듣는 질문입니다. 자바나 C#을 먼저 배운 분들은 특히 당혹스러워합니다. 클래스를 정의하고, new로 인스턴스를 만들고, 메서드를 호출하는 익숙한 패턴이 자바스크립트에서는 통하지 않기 때문입니다. 중괄호 두 개만으로 객체가 뚝딱 만들어지고, 메서드를 정의하는 방법만 세 가지이고, 심지어 클래스 없이도 상속이 되니까요.
주변 개발자에게 물어보면 "그냥 화살표 함수 쓰면 되지 않나요?"라고 하는 사람도 있고, "아니, 축약 메서드가 맞다"는 사람도 있습니다. 둘 다 틀린 말은 아니지만, 정확히 맞는 말도 아닙니다. 상황에 따라 어떤 방식을 써야 하는지, 그리고 왜 그래야 하는지를 모르면 예기치 않은 버그를 만나게 됩니다. 특히 this 바인딩 문제는 자바스크립트 입문자가 가장 많이 겪는 함정 중 하나입니다.
이 글은 좀 깁니다. "중괄호로 객체 만드는 건 알겠는데, 왜 메서드 정의 방식이 세 개나 있는 거죠?"라는 질문에서 시작해서, 프로토타입 체인이나 ECMAScript 내부 슬롯 같은 이야기까지 갑니다.
객체 리터럴, 중괄호 하나로 시작하는 세계
자바스크립트에서 객체를 만드는 가장 간단한 방법은 중괄호를 여는 것입니다.
const user = { name: "김철수", age: 30 };
이 두 줄이 자바스크립트 객체의 전부이자 시작입니다. 클래스 정의도 없고, 생성자도 없습니다. 중괄호 안에 키와 값의 쌍을 나열하면 그것이 객체입니다. 이것을 객체 리터럴이라고 부릅니다.
자바에서는 같은 일을 하려면 클래스 파일을 만들고, 필드를 선언하고, 생성자를 작성하고, getter를 정의해야 합니다. 파이썬에서도 __init__ 메서드가 필요합니다. 자바스크립트는 이 모든 과정을 중괄호 하나로 해결합니다.
프로퍼티의 다양한 키
객체의 각 항목을 프로퍼티라고 부릅니다. 프로퍼티는 키와 값으로 구성되는데, 키에는 문자열뿐 아니라 다양한 형태가 올 수 있습니다.
const example = { // 일반 문자열 키 name: "김철수", // 숫자 키 (내부적으로 문자열로 변환됩니다) 1: "첫 번째", // 공백이나 특수문자가 포함된 키는 따옴표로 감쌉니다 "full name": "김철수", "content-type": "application/json", // 계산된 프로퍼티명 (ES6+) [`user_${Date.now()}`]: "동적 키" }; console.log(example.name); // "김철수" console.log(example[1]); // "첫 번째" console.log(example["full name"]); // "김철수"
한 가지 더 중요한 키 타입이 있습니다. ES6에서 도입된 Symbol입니다. ECMAScript 명세상 프로퍼티 키로 사용할 수 있는 타입은 문자열과 Symbol 두 가지뿐입니다. 숫자 키는 내부적으로 문자열로 변환되는 것이지, 별도의 키 타입이 아닙니다.
// Symbol 키 const id = Symbol("고유 ID"); const user = { [id]: 12345, name: "김철수" }; console.log(user[id]); // 12345 console.log(Object.keys(user)); // ["name"] - Symbol 키는 열거되지 않습니다
Symbol은 고유한 식별자를 만들 때 사용합니다. 같은 설명 문자열로 Symbol을 두 개 만들어도 서로 다른 값입니다. 라이브러리가 객체에 메타데이터를 붙일 때, 다른 코드의 키와 충돌할 걱정 없이 Symbol을 키로 사용할 수 있습니다. 자바스크립트 엔진 자체도 Symbol.iterator, Symbol.toPrimitive 같은 Well-known Symbol을 사용하여 객체의 내부 동작을 정의합니다.
프로퍼티에 접근하는 방법도 두 가지입니다. 점 표기법(example.name)은 간결하지만 키가 유효한 식별자일 때만 사용할 수 있습니다. 공백이나 하이픈이 포함된 키, 숫자로 시작하는 키에는 대괄호 표기법(example["full name"])을 써야 합니다.
축약 프로퍼티: 변수명이 곧 키 이름
ES6에서 도입된 축약 프로퍼티는 변수명과 키 이름이 같을 때 코드를 간결하게 만들어줍니다.
const name = "김철수"; const age = 30; const email = "kim@example.com"; // ES5 방식: 키와 값을 모두 작성 const userOld = { name: name, age: age, email: email }; // ES6 축약 프로퍼티: 키와 변수명이 같으면 한 번만 작성 const userNew = { name, age, email }; // 두 객체는 동일한 결과를 만듭니다 console.log(userNew.name); // "김철수" console.log(userNew.age); // 30
단순한 편의 기능처럼 보이지만, 실무에서는 함수의 반환값이나 모듈의 export에서 매우 자주 사용됩니다. React 컴포넌트에서 상태를 반환하거나, API 응답을 가공할 때 거의 매번 등장하는 패턴입니다.
// API에서 데이터를 가공하는 함수 function formatUser(rawData) { const name = rawData.first_name + " " + rawData.last_name; const email = rawData.contact_email; const joinDate = new Date(rawData.created_at); // 축약 프로퍼티 덕분에 깔끔합니다 return { name, email, joinDate }; }
메서드 정의의 세 가지 얼굴
객체에 함수를 넣으면 그것이 메서드입니다. 그런데 자바스크립트에서는 이 메서드를 정의하는 방법이 세 가지입니다. 하나씩 살펴보겠습니다.
첫 번째: function 표현식
자바스크립트가 처음 만들어진 1995년부터 존재한 가장 오래된 방식입니다.
const calculator = { value: 0, add: function(n) { this.value += n; return this; }, subtract: function(n) { this.value -= n; return this; }, getResult: function() { return this.value; } }; calculator.add(10).subtract(3).add(5); console.log(calculator.getResult()); // 12
add: function(n) { ... } 형태입니다. 키 이름 뒤에 콜론을 찍고, function 키워드로 함수를 할당합니다. 사실 이 방식은 "프로퍼티의 값으로 함수를 할당한 것"에 불과합니다. 문자열이나 숫자를 넣는 자리에 함수를 넣은 것이니까요.
이 방식의 특징은 this가 함수를 호출한 객체를 가리킨다는 것입니다. calculator.add(10)에서 this는 calculator 객체입니다. 그리고 이 함수는 생성자로도 사용할 수 있습니다. new calculator.add(10)처럼요. 물론 이런 사용은 의도치 않은 결과를 낳을 수 있어서 권장하지 않습니다.
두 번째: 메서드 축약 문법
ES6(2015년)에서 도입된 방식입니다. 현재 가장 권장되는 방법입니다.
const calculator = { value: 0, add(n) { this.value += n; return this; }, subtract(n) { this.value -= n; return this; }, getResult() { return this.value; } }; calculator.add(10).subtract(3).add(5); console.log(calculator.getResult()); // 12
add(n) { ... } 형태입니다. 콜론과 function 키워드가 사라졌습니다. 결과는 같아 보이지만, 내부적으로 중요한 차이가 있습니다. 이 부분은 바로 뒤에서 자세히 다루겠습니다.
세 번째: 화살표 함수
역시 ES6에서 도입된 화살표 함수를 메서드로 사용하는 방식입니다.
const calculator = { value: 0, add: (n) => { this.value += n; // 주의! 여기서 this는 calculator가 아닙니다 return this; }, getResult: () => { return this.value; // 역시 calculator가 아닙니다 } }; calculator.add(10); console.log(calculator.getResult()); // NaN
이 코드는 제대로 동작하지 않습니다. NaN이 출력됩니다. 화살표 함수는 자신만의 this를 가지지 않기 때문입니다. 대신 화살표 함수가 정의된 위치의 외부 스코프에서 this를 빌려옵니다. 이 경우 외부 스코프는 전역 객체이므로 this.value는 처음에 undefined입니다. 여기에 += 10을 수행하면 undefined + 10이 되어 NaN이 됩니다. 자바스크립트에서 undefined와 숫자의 산술 연산은 항상 NaN을 만든다는 점도 함께 기억해두면 좋습니다.
그렇다면 화살표 함수는 언제 쓰는 걸까요? 객체의 메서드 안에서 콜백으로 사용할 때 빛을 발합니다.
const team = { members: ["김철수", "이영희", "박민수"], teamName: "개발팀", introduce() { // 여기서 this는 team 객체입니다 // 화살표 함수는 외부의 this를 그대로 가져옵니다 return this.members.map((member) => { return `${this.teamName}의 ${member}`; }); } }; console.log(team.introduce()); // ["개발팀의 김철수", "개발팀의 이영희", "개발팀의 박민수"]
introduce 메서드는 축약 문법으로 정의했으므로 this가 team 객체입니다. 그 안에서 map의 콜백으로 사용된 화살표 함수는 자신만의 this가 없으므로 바깥의 this, 즉 team을 그대로 사용합니다. 만약 여기서 화살표 함수 대신 일반 함수를 콜백으로 쓰면 어떻게 될까요?
const team = { members: ["김철수", "이영희", "박민수"], teamName: "개발팀", introduce() { return this.members.map(function(member) { // 일반 함수의 this는 호출 방식에 따라 달라집니다 // map의 콜백으로 호출되면 this는 undefined(strict mode)이거나 전역 객체입니다 return `${this.teamName}의 ${member}`; }); } }; console.log(team.introduce()); // ["undefined의 김철수", "undefined의 이영희", "undefined의 박민수"]
this.teamName이 undefined가 됩니다. 일반 함수는 호출될 때마다 새로운 this가 바인딩되기 때문입니다. ES6 이전에는 이 문제를 해결하기 위해 var self = this; 같은 우회 방법을 썼습니다.
// ES5 시절의 우회법 introduce: function() { var self = this; // this를 별도 변수에 저장 return this.members.map(function(member) { return self.teamName + "의 " + member; }); }
화살표 함수는 바로 이 문제를 해결하기 위해 탄생했습니다.
세 가지 방식이 존재하는 이유
왜 하나로 통일하지 않고 세 가지 방식을 제공할까요? 답은 자바스크립트의 역사에 있습니다.
function 표현식은 1995년 자바스크립트가 처음 만들어질 때부터 존재한 유일한 방법이었습니다. 당시에는 자바스크립트가 웹 페이지에 간단한 인터랙션을 추가하는 스크립트 언어였고, 객체를 복잡하게 다룰 일이 거의 없었습니다.
그런데 2010년대에 Node.js가 나오고 싱글 페이지 애플리케이션이 퍼지면서 자바스크립트 코드 규모가 확 커졌습니다. function 키워드를 반복해서 쓰는 게 번거로워졌고, this 바인딩 문제로 버그가 끊이지 않았습니다.
2015년 ES6가 이 두 문제를 각각 다른 방식으로 해결했습니다. 메서드 축약 문법은 function 키워드를 없애면서 super 접근 같은 기능을 추가했고요. 화살표 함수는 this를 렉시컬 스코프에서 가져오게 해서 콜백에서의 this 혼란을 잡았습니다.
세 가지 방식이 각각 다른 시대, 다른 목적으로 만들어진 셈입니다. 하위 호환성 때문에 오래된 방식을 없앨 수 없으니 지금도 셋 다 남아 있는 거고요.
진짜 차이: this, super, 생성자, arguments
세 가지 방식의 차이를 코드로 직접 확인해보겠습니다.
1. this 바인딩
const obj = { name: "테스트 객체", // function 표현식: this는 호출 객체 (obj) funcMethod: function() { console.log("function:", this.name); }, // 메서드 축약: this는 호출 객체 (obj) shortMethod() { console.log("short:", this.name); }, // 화살표 함수: this는 외부 스코프 (전역) arrowMethod: () => { console.log("arrow:", this.name); } }; obj.funcMethod(); // "function: 테스트 객체" obj.shortMethod(); // "short: 테스트 객체" obj.arrowMethod(); // "arrow: undefined"
function 표현식과 메서드 축약은 this 측면에서 동일하게 동작합니다. 둘 다 메서드를 호출한 객체(obj)를 가리킵니다. 화살표 함수만 다릅니다.
2. super 키워드
const parent = { greet() { return "부모입니다"; } }; const child = { __proto__: parent, // 메서드 축약: super 사용 가능 greet() { return super.greet() + " + 자식입니다"; } }; console.log(child.greet()); // "부모입니다 + 자식입니다"
super 키워드는 오직 메서드 축약 문법으로 정의된 메서드에서만 사용할 수 있습니다. function 표현식이나 화살표 함수에서 super를 쓰면 SyntaxError가 발생합니다. 이것이 메서드 축약 문법이 단순한 편의 문법이 아닌 이유입니다. ECMAScript 명세에서 메서드 축약으로 정의된 함수에만 [[HomeObject]]라는 내부 슬롯을 부여하고, 이 슬롯이 있어야 super를 사용할 수 있습니다.
3. 생성자로 사용
const obj = { // function 표현식: new로 호출 가능 (하지만 하지 마세요) FuncMethod: function(x) { this.x = x; }, // 메서드 축약: new로 호출 불가 ShortMethod(x) { this.x = x; }, // 화살표 함수: new로 호출 불가 ArrowMethod: (x) => { this.x = x; } }; const a = new obj.FuncMethod(1); // 동작합니다 (x: 1) // const b = new obj.ShortMethod(2); // TypeError: obj.ShortMethod is not a constructor // const c = new obj.ArrowMethod(3); // TypeError: obj.ArrowMethod is not a constructor
function 표현식만 new 키워드로 호출할 수 있습니다. 메서드 축약 문법과 화살표 함수는 생성자로 사용할 수 없도록 설계되었습니다. 사실 객체의 메서드를 new로 호출하는 것은 의도치 않은 사용이므로, 메서드 축약 문법이 이를 막아주는 것은 오히려 안전장치입니다.
4. arguments 객체
const obj = { funcMethod: function() { console.log("function arguments:", arguments.length); }, shortMethod() { console.log("short arguments:", arguments.length); }, arrowMethod: () => { // console.log(arguments); // ReferenceError: arguments is not defined console.log("arrow: arguments 없음"); } }; obj.funcMethod(1, 2, 3); // "function arguments: 3" obj.shortMethod(1, 2, 3); // "short arguments: 3" obj.arrowMethod(1, 2, 3); // "arrow: arguments 없음"
화살표 함수에는 arguments 객체가 없습니다. 가변 인자를 처리하려면 나머지 매개변수(...args)를 사용해야 합니다. 사실 나머지 매개변수가 arguments보다 사용하기 편리하므로 이것은 큰 불편이 아닙니다.
한눈에 보는 비교표
직접 테스트한 결과를 정리하면 다음과 같습니다.
| 특성 | function 표현식 | 메서드 축약 | 화살표 함수 |
|---|---|---|---|
| this 바인딩 | 호출 객체 (동적) | 호출 객체 (동적) | 외부 스코프 (정적) |
| super 사용 | 불가 | 가능 | 불가 |
| new로 호출 | 가능 | 불가 | 불가 |
| arguments 객체 | 있음 | 있음 | 없음 |
| 도입 시기 | ES1 (1997) | ES6 (2015) | ES6 (2015) |
| 추천 용도 | 레거시 코드 호환 | 객체/클래스 메서드 | 콜백, 함수형 프로그래밍 |
정리하면, 객체의 메서드를 정의할 때는 메서드 축약 문법을 기본으로 사용하고, 콜백 함수에는 화살표 함수를 사용하면 대부분의 상황에서 문제가 없습니다. function 표현식은 레거시 코드를 유지보수할 때 만나게 되는 방식이라고 생각하면 됩니다.
클래스 없이 객체를 만든다고? 프로토타입의 세계
자바스크립트를 처음 배우는 자바나 C# 개발자가 가장 혼란스러워하는 부분이 바로 이것입니다. "클래스 없이 어떻게 객체를 만들어요?"
사실 대부분의 프로그래밍 언어에서 객체를 만들려면 먼저 클래스라는 설계도를 작성해야 합니다. 자바에서는 class User { ... }를 먼저 정의하고, new User()로 인스턴스를 만듭니다. 클래스는 붕어빵 틀이고, 객체는 그 틀에서 찍어낸 붕어빵이라는 비유를 많이 사용합니다.
자바스크립트는 다릅니다. 자바스크립트에서 객체는 "그냥 만들면 되는 것"입니다.
// 자바스크립트: 바로 만듭니다 const user = { name: "김철수", greet() { return `안녕하세요, ${this.name}입니다`; } }; console.log(user.greet()); // "안녕하세요, 김철수입니다"
클래스 정의도, 생성자 호출도 없습니다. 중괄호 안에 원하는 프로퍼티와 메서드를 넣으면 끝입니다.
그런데 클래스가 없어도 되나요?
결론부터 말하면, 됩니다. 자바스크립트는 태생부터 프로토타입 기반 언어이기 때문입니다.
프로그래밍 언어는 객체 지향을 구현하는 방식에 따라 크게 두 부류로 나뉩니다. 클래스 기반 언어(Java, C++, C#, Python)와 프로토타입 기반 언어(JavaScript, Lua, Self)입니다.
클래스 기반 언어에서는 "분류"가 먼저입니다. "사용자란 이런 것이다"를 클래스로 정의하고, 그 정의에 맞는 개별 인스턴스를 만듭니다. 마치 생물학에서 "포유류 > 영장류 > 사람"처럼 분류 체계를 먼저 세우는 것과 같습니다.
프로토타입 기반 언어에서는 "사례"가 먼저입니다. 구체적인 객체를 하나 만들고, 다른 객체가 그 객체를 참조하여 기능을 빌려 씁니다. "김철수라는 사람이 있고, 이영희는 김철수와 비슷한데 이름만 다르다"는 식입니다.
1995년 자바스크립트를 설계한 브렌던 아이크는 Self라는 프로토타입 기반 언어의 영향을 받아 이 방식을 채택했습니다. 당시 넷스케이프는 10일 만에 언어를 설계해야 하는 상황이었고, 프로토타입 방식이 클래스 방식보다 구현이 단순했습니다. 하지만 마케팅 이유로 문법은 자바를 닮게 만들었습니다. 그 혼란이 아직도 이어지고 있는 겁니다.
프로토타입 체인: 자바스크립트 상속의 비밀
자바스크립트에서 모든 객체는 다른 객체를 프로토타입으로 참조할 수 있습니다. 프로퍼티를 조회할 때 그 객체에 없으면 프로토타입에서 찾고, 거기에도 없으면 프로토타입의 프로토타입에서 찾습니다. 이것을 프로토타입 체인이라고 합니다.
// 동물 객체를 하나 만듭니다 const animal = { isAlive: true, breathe() { return "숨을 쉽니다"; } }; // dog는 animal을 프로토타입으로 참조합니다 const dog = Object.create(animal); dog.bark = function() { return "멍멍!"; }; // myDog은 dog를 프로토타입으로 참조합니다 const myDog = Object.create(dog); myDog.name = "바둑이"; console.log(myDog.name); // "바둑이" - myDog 자체 프로퍼티 console.log(myDog.bark()); // "멍멍!" - dog에서 찾음 console.log(myDog.breathe()); // "숨을 쉽니다" - animal에서 찾음 console.log(myDog.isAlive); // true - animal에서 찾음
myDog.breathe()를 호출하면 자바스크립트 엔진은 다음 순서로 찾습니다.
myDog객체에breathe가 있는가? 없습니다.myDog의 프로토타입인dog에breathe가 있는가? 없습니다.dog의 프로토타입인animal에breathe가 있는가? 있습니다. 실행합니다.
이 체인은 null에 도달할 때까지 계속됩니다. Object.prototype이 거의 모든 체인의 끝에 있고, Object.prototype의 프로토타입은 null입니다.
// 프로토타입 체인을 직접 확인해봅시다 console.log(Object.getPrototypeOf(myDog) === dog); // true console.log(Object.getPrototypeOf(dog) === animal); // true console.log(Object.getPrototypeOf(animal) === Object.prototype); // true console.log(Object.getPrototypeOf(Object.prototype)); // null (체인의 끝)
다음 그림은 프로토타입 체인이 어떻게 동작하는지를 보여줍니다. myDog.breathe()를 호출하면 자바스크립트 엔진이 체인을 따라 올라가며 메서드를 찾는 과정입니다.

myDog 자체에는 breathe 메서드가 없지만, 프로토타입 체인을 따라 dog을 거쳐 animal에서 해당 메서드를 찾아 실행합니다. 체인의 끝은 항상 null입니다.
Object.create(): 프로토타입을 직접 지정하는 방법
Object.create(proto)는 지정한 객체를 프로토타입으로 하는 새 객체를 만듭니다. 클래스 없이 상속 구조를 만드는 가장 직접적인 방법입니다.
// 프로토타입 역할을 할 객체 const vehicleProto = { start() { return `${this.brand} ${this.model} 시동을 겁니다`; }, stop() { return `${this.brand} ${this.model} 시동을 끕니다`; } }; // vehicleProto를 프로토타입으로 하는 새 객체 const myCar = Object.create(vehicleProto); myCar.brand = "현대"; myCar.model = "아이오닉 6"; console.log(myCar.start()); // "현대 아이오닉 6 시동을 겁니다" console.log(myCar.stop()); // "현대 아이오닉 6 시동을 끕니다"
myCar 객체에는 start와 stop 메서드가 없습니다. 하지만 프로토타입인 vehicleProto에 있으므로 사용할 수 있습니다. 여기서 중요한 점은 start() 안의 this가 vehicleProto가 아니라 myCar를 가리킨다는 것입니다. 메서드가 어디에 정의되었든, this는 항상 메서드를 호출한 객체를 가리킵니다.
Object.create(null)이라는 특별한 사용법도 있습니다. 프로토타입이 null인 객체, 즉 프로토타입 체인이 아예 없는 객체를 만듭니다.
const pureDict = Object.create(null); pureDict.key1 = "값1"; pureDict.key2 = "값2"; console.log(pureDict.key1); // "값1" console.log(pureDict.toString); // undefined (Object.prototype도 없음) console.log(pureDict.hasOwnProperty); // undefined
이 객체는 toString이나 hasOwnProperty 같은 Object.prototype의 메서드조차 없습니다. 순수한 키-값 저장소가 필요할 때, 예를 들어 사용자 입력을 키로 사용하는 경우에 toString 같은 내장 프로퍼티와 충돌할 걱정 없이 사용할 수 있습니다.
그런데 ES6에 class가 생겼잖아요?
맞습니다. ES6에서 class 키워드가 도입되었습니다. 다만 자바스크립트의 클래스는 자바의 클래스와 많이 다릅니다. 프로토타입 시스템 위에 얹힌 문법적 편의거든요. MDN 공식 문서에서도 이를 명확하게 언급하고 있습니다.
// class 문법 class Vehicle { constructor(brand, model) { this.brand = brand; this.model = model; } start() { return `${this.brand} ${this.model} 시동을 겁니다`; } } const myCar = new Vehicle("현대", "아이오닉 6"); console.log(myCar.start()); // "현대 아이오닉 6 시동을 겁니다"
이 코드가 내부적으로 하는 일은 다음과 같습니다.
// 위의 class와 동일한 동작을 프로토타입으로 구현 function Vehicle(brand, model) { this.brand = brand; this.model = model; } Vehicle.prototype.start = function() { return `${this.brand} ${this.model} 시동을 겁니다`; }; const myCar = new Vehicle("현대", "아이오닉 6"); console.log(myCar.start()); // "현대 아이오닉 6 시동을 겁니다"
class 키워드를 사용하든, 생성자 함수와 prototype을 직접 사용하든, 결과는 같습니다. 프로토타입 체인으로 연결된 객체가 만들어집니다.
// 두 방식 모두 동일한 프로토타입 구조를 만듭니다 console.log(typeof Vehicle); // "function" console.log(myCar instanceof Vehicle); // true console.log(Object.getPrototypeOf(myCar) === Vehicle.prototype); // true
그렇다면 클래스를 쓸 이유가 있을까요? 있습니다. 코드의 의도가 명확해지고, 상속 구조를 extends로 간결하게 표현할 수 있으며, private 필드(#field)와 static 메서드 같은 추가 기능을 사용할 수 있습니다. V8 같은 엔진에서는 클래스로 만든 객체의 구조가 일정하기 때문에 최적화가 더 잘 됩니다.
그래서 언제 뭘 쓸까요?
실무에서 자주 보이는 패턴을 정리하면 이렇습니다.
객체 리터럴: 설정 객체, 단일 인스턴스, 모듈 패턴에 적합합니다. 같은 구조의 객체를 여러 개 만들 필요가 없을 때 사용합니다.
// 설정 객체: 하나만 있으면 됩니다 const config = { apiUrl: "https://api.example.com", timeout: 5000, getFullUrl(path) { return `${this.apiUrl}${path}`; } };
클래스: 같은 구조의 객체를 여러 개 만들어야 할 때, 상속이 필요할 때 사용합니다. 현대 자바스크립트에서 가장 많이 사용하는 패턴입니다.
// 여러 인스턴스가 필요한 경우 class User { #password; // private 필드 constructor(name, email, password) { this.name = name; this.email = email; this.#password = password; } checkPassword(input) { return this.#password === input; } } const user1 = new User("김철수", "kim@test.com", "1234"); const user2 = new User("이영희", "lee@test.com", "5678");
Object.create(): 프로토타입 체인을 직접 제어해야 할 때, 또는 null 프로토타입 객체가 필요할 때 사용합니다.
// 프로토타입이 없는 순수 맵이 필요할 때 const cache = Object.create(null); cache["constructor"] = "이것은 안전합니다"; // Object.prototype.constructor와 충돌 없음
내부 슬롯: 자바스크립트 엔진의 비밀 장부
자바스크립트 커뮤니티에서 "슬롯"이라는 용어를 접하면, 이것은 ECMAScript 명세에 정의된 내부 슬롯을 의미합니다. 일반적인 프로퍼티처럼 접근할 수 없는, 엔진 내부에서만 사용하는 숨겨진 저장소입니다.
ECMAScript 명세서에서 내부 슬롯과 내부 메서드는 이중 대괄호 [[ ]]로 표기합니다. [[Prototype]], [[Extensible]], [[Get]] 같은 형태입니다. 이 표기법은 "자바스크립트 코드에서 직접 접근할 수 없다"는 것을 나타냅니다.
[[Prototype]]: 프로토타입 체인의 핵심
모든 자바스크립트 객체는 [[Prototype]]이라는 내부 슬롯을 가지고 있습니다. 이 슬롯에는 해당 객체의 프로토타입(다른 객체 또는 null)이 저장됩니다. 앞에서 설명한 프로토타입 체인이 바로 이 [[Prototype]] 슬롯을 통해 구현됩니다.
const obj = { x: 1 }; // [[Prototype]]에 직접 접근할 수는 없지만, // Object.getPrototypeOf()를 통해 간접적으로 읽을 수 있습니다 const proto = Object.getPrototypeOf(obj); console.log(proto === Object.prototype); // true // __proto__는 [[Prototype]]에 접근하는 비표준 방법이었습니다 // 현재는 호환성을 위해 유지되지만, Object.getPrototypeOf()를 권장합니다 console.log(obj.__proto__ === Object.prototype); // true
__proto__ 프로퍼티는 원래 비표준이었지만 너무 많은 코드에서 사용되어 ES6에서 공식 표준에 포함되었습니다. 하지만 MDN 공식 문서에서는 Object.getPrototypeOf()와 Object.setPrototypeOf()를 사용할 것을 권장합니다.
[[Extensible]]: 객체를 잠그는 스위치
[[Extensible]] 슬롯은 불리언 값을 저장하며, 객체에 새 프로퍼티를 추가할 수 있는지를 결정합니다. 기본값은 true입니다.
const config = { apiUrl: "https://api.example.com", timeout: 5000 }; // 새 프로퍼티 추가가 됩니다 config.retryCount = 3; console.log(config.retryCount); // 3 // [[Extensible]]을 false로 설정합니다 Object.preventExtensions(config); // 이제 새 프로퍼티를 추가할 수 없습니다 config.newProp = "추가 시도"; console.log(config.newProp); // undefined (조용히 실패) // strict mode에서는 에러가 발생합니다 // "use strict"; // config.newProp = "추가 시도"; // TypeError // 기존 프로퍼티의 수정과 삭제는 가능합니다 config.timeout = 10000; // 수정 가능 delete config.retryCount; // 삭제 가능
한 번 false가 되면 다시 true로 되돌릴 수 없습니다. 보안과 안정성을 위한 설계 결정인데, ECMAScript 명세에서도 이 점을 명시하고 있습니다.
객체를 얼리는 세 단계
자바스크립트는 객체의 변경을 제한하는 세 가지 메서드를 제공합니다. 각각이 제한하는 범위가 다릅니다.
다음 그림은 세 단계의 차이를 한눈에 보여줍니다. 왼쪽에서 오른쪽으로 갈수록 제한이 강해집니다.

preventExtensions는 추가만 막고, seal은 추가와 삭제를 막고, freeze는 모든 변경을 막습니다. 다만 세 가지 모두 중첩 객체까지는 동결하지 못하는 얕은 동결이라는 점을 기억해야 합니다.
const original = { name: "김철수", age: 30, address: { city: "서울" } }; // 1단계: Object.preventExtensions - 프로퍼티 추가만 차단 const obj1 = { ...original }; Object.preventExtensions(obj1); obj1.email = "test@test.com"; // 추가 불가 obj1.name = "이영희"; // 수정 가능 delete obj1.age; // 삭제 가능 // 2단계: Object.seal - 프로퍼티 추가/삭제 차단, 수정은 가능 const obj2 = { ...original }; Object.seal(obj2); obj2.email = "test@test.com"; // 추가 불가 obj2.name = "이영희"; // 수정 가능 delete obj2.age; // 삭제 불가 (조용히 실패) // 3단계: Object.freeze - 모든 변경 차단 const obj3 = { ...original }; Object.freeze(obj3); obj3.email = "test@test.com"; // 추가 불가 obj3.name = "이영희"; // 수정 불가 (조용히 실패) delete obj3.age; // 삭제 불가 (조용히 실패) // 다만 중첩 객체까지 얼리지는 않습니다 (얕은 동결) obj3.address.city = "부산"; console.log(obj3.address.city); // "부산" (변경됨!)
마지막 예제가 중요합니다. Object.freeze()는 직접 프로퍼티만 동결하거든요. 프로퍼티 값이 다른 객체를 참조하고 있으면, 그 객체는 여전히 바꿀 수 있습니다. 완전한 동결이 필요하면 중첩 객체를 재귀적으로 동결하는 함수를 따로 만들어야 합니다.
// 깊은 동결 함수 function deepFreeze(obj) { Object.freeze(obj); Object.getOwnPropertyNames(obj).forEach(prop => { const value = obj[prop]; if (typeof value === "object" && value !== null && !Object.isFrozen(value)) { deepFreeze(value); } }); return obj; } const frozen = deepFreeze({ name: "김철수", address: { city: "서울", zip: "12345" } }); frozen.address.city = "부산"; console.log(frozen.address.city); // "서울" (변경되지 않음)
11가지 내부 메서드
ECMAScript 2026 명세에 따르면, 모든 일반 객체는 11가지 필수 내부 메서드를 구현해야 합니다. 이 메서드들이 자바스크립트 객체의 모든 동작을 정의합니다.
프로퍼티를 읽을 때(obj.x) 엔진은 [[Get]] 내부 메서드를 호출합니다. 프로퍼티를 쓸 때(obj.x = 1) [[Set]]을 호출합니다. delete obj.x는 [[Delete]]를, x in obj는 [[HasProperty]]를 호출합니다.
const obj = { x: 1, y: 2, z: 3 }; // [[Get]] - 프로퍼티 읽기 console.log(obj.x); // 1 // [[Set]] - 프로퍼티 쓰기 obj.x = 10; // [[Delete]] - 프로퍼티 삭제 delete obj.z; // [[HasProperty]] - 프로퍼티 존재 확인 console.log("y" in obj); // true console.log("z" in obj); // false // [[OwnPropertyKeys]] - 모든 키 나열 console.log(Object.keys(obj)); // ["x", "y"] // [[GetPrototypeOf]] - 프로토타입 조회 console.log(Object.getPrototypeOf(obj)); // Object.prototype // [[IsExtensible]] - 확장 가능 여부 확인 console.log(Object.isExtensible(obj)); // true // [[PreventExtensions]] - 확장 차단 Object.preventExtensions(obj); console.log(Object.isExtensible(obj)); // false
직접 코드를 작성할 때 내부 메서드를 의식할 일은 거의 없습니다. 다만 Proxy를 사용하면 이 내부 메서드를 가로채서 커스텀 동작을 정의할 수 있습니다. Proxy의 트랩 이름이 바로 이 내부 메서드에서 온 것입니다.
const handler = { // [[Get]] 내부 메서드를 가로채는 트랩 get(target, prop) { if (prop in target) { return target[prop]; } return `"${prop}" 프로퍼티는 없습니다`; }, // [[Set]] 내부 메서드를 가로채는 트랩 set(target, prop, value) { if (typeof value !== "number") { throw new TypeError(`${prop}에는 숫자만 넣을 수 있습니다`); } target[prop] = value; return true; } }; const scores = new Proxy({}, handler); scores.math = 95; // 정상 동작 scores.english = 88; // 정상 동작 // scores.science = "A+"; // TypeError: science에는 숫자만 넣을 수 있습니다 console.log(scores.없는과목); // "없는과목" 프로퍼티는 없습니다
프로퍼티 디스크립터: 프로퍼티의 숨겨진 속성
자바스크립트의 모든 프로퍼티에는 눈에 보이지 않는 세 가지 플래그가 있습니다. writable(값 변경 가능 여부), enumerable(열거 가능 여부), configurable(설정 변경 가능 여부)입니다.
const user = { name: "김철수" }; // 프로퍼티 디스크립터를 확인합니다 const descriptor = Object.getOwnPropertyDescriptor(user, "name"); console.log(descriptor); // { // value: "김철수", // writable: true, // 값을 변경할 수 있습니다 // enumerable: true, // for...in 등에서 열거됩니다 // configurable: true // 디스크립터를 변경하거나 삭제할 수 있습니다 // }
일반적인 할당(obj.prop = value)으로 프로퍼티를 추가하면 세 플래그 모두 true입니다. 반면 Object.defineProperty()로 추가하면 기본값이 전부 false고요. 이 차이를 모르면 의도치 않게 변경 불가능한 프로퍼티를 만들 수 있습니다.
const obj = {}; // 일반 할당: 모든 플래그가 true obj.a = 1; // defineProperty: 명시하지 않은 플래그는 false Object.defineProperty(obj, "b", { value: 2 // writable: false (기본값) // enumerable: false (기본값) // configurable: false (기본값) }); console.log(obj.a); // 1 console.log(obj.b); // 2 obj.a = 100; // 가능 obj.b = 200; // 조용히 실패 (strict mode에서는 TypeError) console.log(obj.a); // 100 console.log(obj.b); // 2 (변경되지 않음) // for...in으로 열거 for (const key in obj) { console.log(key); // "a"만 출력됩니다. "b"는 enumerable: false이므로 안 보입니다. }
Object.defineProperty()가 유용한 실전 사례가 있습니다. 예를 들어 라이브러리 개발자가 객체에 메타데이터를 붙이면서 for...in에서는 보이지 않게 하고 싶을 때 사용합니다.
const element = { tag: "div", class: "container" }; // 내부 ID를 붙이되, 열거되지 않게 합니다 Object.defineProperty(element, "_internalId", { value: "el_001", enumerable: false, writable: false, configurable: false }); console.log(element._internalId); // "el_001" (직접 접근은 가능) // 하지만 열거에서는 보이지 않습니다 console.log(Object.keys(element)); // ["tag", "class"] console.log(JSON.stringify(element)); // '{"tag":"div","class":"container"}'
getter와 setter: 프로퍼티처럼 보이지만 함수인 것
객체 리터럴에서 get과 set 키워드를 사용하면, 프로퍼티에 접근하거나 값을 할당할 때 자동으로 함수가 실행되는 접근자 프로퍼티를 정의할 수 있습니다.
const user = { firstName: "철수", lastName: "김", // getter: user.fullName을 읽을 때 호출됩니다 get fullName() { return `${this.lastName}${this.firstName}`; }, // setter: user.fullName = "..." 할 때 호출됩니다 set fullName(value) { const parts = value.split(" "); if (parts.length !== 2) { throw new Error("'성 이름' 형식으로 입력해주세요"); } this.lastName = parts[0]; this.firstName = parts[1]; } }; console.log(user.fullName); // "김철수" (getter 호출) user.fullName = "이 영희"; // setter 호출 console.log(user.firstName); // "영희" console.log(user.lastName); // "이" console.log(user.fullName); // "이영희"
user.fullName은 프로퍼티처럼 보이지만, 실제로는 함수입니다. 값을 읽을 때 get fullName()이 실행되고, 값을 쓸 때 set fullName(value)가 실행됩니다. 코드를 사용하는 쪽에서는 일반 프로퍼티처럼 쓰면서, 내부적으로 유효성 검사나 계산 로직을 넣을 수 있는 거죠.
getter/setter는 프로퍼티 디스크립터의 관점에서 보면 접근자 디스크립터에 해당합니다. 일반 프로퍼티(데이터 디스크립터)가 value와 writable 플래그를 가지는 것과 달리, 접근자 디스크립터는 get과 set 함수를 가집니다.
const desc = Object.getOwnPropertyDescriptor(user, "fullName"); console.log(desc); // { // get: [Function: get fullName], // set: [Function: set fullName], // enumerable: true, // configurable: true // } // value와 writable이 없고, 대신 get과 set이 있습니다
실무에서 getter/setter가 유용한 경우를 하나 더 소개합니다. 비용이 큰 계산을 지연 실행하고 결과를 캐시하는 패턴입니다.
const report = { rawData: [/* 수천 건의 데이터 */], _cachedSummary: null, get summary() { if (this._cachedSummary === null) { console.log("계산 중... (이 메시지는 한 번만 출력됩니다)"); // 비용이 큰 계산을 수행합니다 this._cachedSummary = this.rawData.reduce((acc, item) => { // ... 복잡한 집계 로직 return acc; }, {}); } return this._cachedSummary; } }; // 첫 번째 접근: 계산 수행 const result1 = report.summary; // "계산 중..." 출력 // 두 번째 접근: 캐시된 결과 반환 const result2 = report.summary; // 아무것도 출력되지 않음 (캐시 사용)
실전에서 꼭 알아야 할 객체 테크닉
구조 분해 할당: 객체에서 원하는 값만 꺼내기
ES6의 구조 분해 할당은 객체에서 필요한 프로퍼티만 변수로 추출하는 문법입니다.
const response = { status: 200, data: { user: { id: 1, name: "김철수", email: "kim@test.com", role: "admin" }, token: "abc123" }, timestamp: "2026-02-27T10:00:00Z" }; // 기본 구조 분해 const { status, timestamp } = response; console.log(status); // 200 console.log(timestamp); // "2026-02-27T10:00:00Z" // 중첩 구조 분해 const { data: { user: { name, role }, token } } = response; console.log(name); // "김철수" console.log(role); // "admin" console.log(token); // "abc123" // 변수명 바꾸기 const { data: { user: { name: userName } } } = response; console.log(userName); // "김철수" // 기본값 지정 const { data: { user: { nickname = "없음" } } } = response; console.log(nickname); // "없음" (nickname 프로퍼티가 없으므로 기본값 사용)
함수 매개변수에 구조 분해를 적용하면 특히 유용합니다. 설정 객체를 받는 함수에서 매우 자주 사용됩니다.
// 구조 분해 + 기본값을 활용한 함수 시그니처 function createServer({ port = 3000, host = "localhost", ssl = false } = {}) { console.log(`서버 시작: ${ssl ? "https" : "http"}://${host}:${port}`); } createServer({ port: 8080 }); // "서버 시작: http://localhost:8080" createServer({ port: 443, ssl: true }); // "서버 시작: https://localhost:443" createServer(); // "서버 시작: http://localhost:3000"
스프레드 연산자: 객체를 펼치고 합치기
스프레드 연산자(...)는 객체의 모든 프로퍼티를 펼쳐서 새 객체에 복사합니다.
const defaults = { theme: "light", fontSize: 14, language: "ko" }; const userPrefs = { theme: "dark", fontSize: 16 }; // 기본값에 사용자 설정을 덮어씌웁니다 const finalConfig = { ...defaults, ...userPrefs }; console.log(finalConfig); // { theme: "dark", fontSize: 16, language: "ko" }
뒤에 오는 객체의 프로퍼티가 앞의 프로퍼티를 덮어씁니다. 이 패턴은 설정 병합에서 매우 자주 사용됩니다. 주의할 점은 스프레드 연산자가 얕은 복사만 한다는 겁니다.
const original = { name: "원본", nested: { value: 1 } }; const copy = { ...original }; copy.name = "복사본"; copy.nested.value = 999; console.log(original.name); // "원본" (변경되지 않음) console.log(original.nested.value); // 999 (변경됨! 같은 객체를 참조)
copy.nested와 original.nested는 같은 객체를 가리킵니다. 이것이 얕은 복사의 한계입니다. 깊은 복사가 필요하면 structuredClone()을 사용할 수 있습니다.
const deepCopy = structuredClone(original); deepCopy.nested.value = 777; console.log(original.nested.value); // 999 (deepCopy의 변경이 영향을 주지 않음)
객체 순회: keys, values, entries
객체의 프로퍼티를 순회하는 세 가지 메서드는 실무에서 매일 사용하는 도구입니다.
const scores = { 수학: 95, 영어: 88, 과학: 92 }; // Object.keys(): 키 배열을 반환합니다 console.log(Object.keys(scores)); // ["수학", "영어", "과학"] // Object.values(): 값 배열을 반환합니다 console.log(Object.values(scores)); // [95, 88, 92] // Object.entries(): [키, 값] 쌍의 배열을 반환합니다 console.log(Object.entries(scores)); // [["수학", 95], ["영어", 88], ["과학", 92]]
Object.entries()는 for...of와 구조 분해를 함께 사용하면 매우 깔끔한 코드가 됩니다.
// entries + 구조 분해로 객체 순회 for (const [subject, score] of Object.entries(scores)) { console.log(`${subject}: ${score}점`); } // "수학: 95점" // "영어: 88점" // "과학: 92점"
Object.entries()와 Object.fromEntries()를 조합하면 객체를 변환하는 강력한 패턴을 만들 수 있습니다. 배열의 map이나 filter를 객체에 적용하는 것과 같은 효과입니다.
const prices = { coffee: 4500, tea: 3000, juice: 5000 }; // 모든 가격에 10% 할인 적용 const discounted = Object.fromEntries( Object.entries(prices).map(([item, price]) => [item, price * 0.9]) ); console.log(discounted); // { coffee: 4050, tea: 2700, juice: 4500 } // 4000원 이상인 항목만 필터링 const expensive = Object.fromEntries( Object.entries(prices).filter(([item, price]) => price >= 4000) ); console.log(expensive); // { coffee: 4500, juice: 5000 }
Object.entries()로 배열로 바꾸고, 배열 메서드로 가공한 뒤, Object.fromEntries()로 다시 객체로 되돌리는 패턴입니다. API 응답 데이터를 가공할 때 자주 씁니다.
계산된 프로퍼티명: 동적으로 키를 만들기
대괄호 안에 표현식을 넣어 프로퍼티 키를 동적으로 생성할 수 있습니다.
// 동적 키 생성 const fieldName = "email"; const user = { name: "김철수", [fieldName]: "kim@test.com" }; console.log(user.email); // "kim@test.com" // 함수 반환값을 키로 사용 function getKey(prefix, id) { return `${prefix}_${id}`; } const data = { [getKey("user", 1)]: { name: "김철수" }, [getKey("user", 2)]: { name: "이영희" } }; console.log(data.user_1); // { name: "김철수" } console.log(data.user_2); // { name: "이영희" }
Redux 리듀서에서 액션 타입을 키로 쓰거나, 국제화(i18n) 처리에서 로케일 코드를 동적으로 키에 할당할 때 유용합니다.
안전한 프로퍼티 접근: optional chaining과 nullish coalescing
중첩된 객체에서 프로퍼티에 접근할 때 가장 흔한 에러가 TypeError: Cannot read properties of undefined입니다. ES2020에서 도입된 ?.(optional chaining)과 ??(nullish coalescing)은 이 문제를 깔끔하게 해결합니다.
const response = { data: { user: null } }; // optional chaining 없이: 매 단계마다 존재 여부를 확인해야 합니다 const name1 = response.data && response.data.user && response.data.user.name; console.log(name1); // null // optional chaining 사용: ?. 뒤의 프로퍼티가 없으면 undefined를 반환합니다 const name2 = response.data?.user?.name; console.log(name2); // undefined // nullish coalescing: null 또는 undefined일 때 기본값을 지정합니다 const name3 = response.data?.user?.name ?? "게스트"; console.log(name3); // "게스트"
?.은 왼쪽 값이 null이나 undefined이면 더 이상 체인을 따라가지 않고 즉시 undefined를 반환합니다. ??은 왼쪽 값이 null 또는 undefined일 때만 오른쪽 값을 사용합니다. || 연산자와 비슷해 보이지만, ||은 0, "", false 같은 falsy 값도 기본값으로 대체해버리는 반면, ??은 null과 undefined만 대체한다는 중요한 차이가 있습니다.
const config = { timeout: 0, // 의도적으로 0을 설정 retryCount: null // 설정하지 않음 }; // || 연산자: 0도 falsy이므로 기본값으로 대체됩니다 (의도와 다름) console.log(config.timeout || 5000); // 5000 (원하지 않은 결과) // ?? 연산자: 0은 null/undefined가 아니므로 그대로 유지됩니다 console.log(config.timeout ?? 5000); // 0 (의도한 결과) console.log(config.retryCount ?? 3); // 3 (null이므로 기본값 사용)
요즘 실무에서는 ?.와 ??가 사실상 기본입니다.
마무리
자바스크립트 객체 시스템이 처음에는 이상해 보이는 건 당연합니다. 클래스 없이 객체가 만들어지고, 메서드 정의 방법이 세 개나 되고, 프로토타입이라는 생소한 개념까지. 그런데 역사적 맥락을 알고 나면 대부분 납득이 됩니다. 프로토타입이라는 토대 위에 클래스 문법이 얹어진 구조라는 걸 이해하면, 자바스크립트에서 벌어지는 "이상한 일"이 이상하지 않게 되거든요.
실무에서 쓰는 원칙은 단순합니다. 설정 객체나 데이터 묶음에는 객체 리터럴을, 인스턴스를 여러 개 만들어야 하면 클래스를, 메서드에는 축약 문법을, 콜백에는 화살표 함수를 쓰면 됩니다. 자바스크립트를 오래 쓴 개발자일수록 객체 리터럴 하나로 해결하는 경우가 많다는 점도 재밌는 부분이고요.
내부 슬롯이나 프로퍼티 디스크립터는 평소에는 몰라도 괜찮습니다. 라이브러리 내부를 디버깅하거나 Proxy를 쓸 일이 생기면, 그때 이 글을 다시 꺼내보면 됩니다.
참고 자료
- MDN - Object initializer
- MDN - Method definitions
- MDN - Arrow function expressions
- MDN - this
- MDN - Inheritance and the prototype chain
- MDN - Object.defineProperty()
- MDN - Object.create()
- ECMAScript 2026 - Ordinary Object Internal Methods and Internal Slots
- V8 Blog - Understanding the ECMAScript spec
- James Sinclair - Named functions vs Arrow functions (2025)






댓글
댓글을 작성하려면 이 필요합니다.