// var 키워드로 선언된 변수는 같은 스코프 내에서 중복 선언을 허용한다. // 초기화문이 있는 변수 선언문은 자바스크립트 엔진에 의해 var 키워드가 없는 것처럼 동작한다. var x = 100; // 초기화문이 없는 변수 선언문은 무시된다. var y;
console.log(x); // 100 console.log(y); // 1
함수 레벨 스코프 var키워드로 선언한 변수는 함수 코드 블럭만을 지역 스코프로 인정하기 때문에 함수 코드블럭을 제외한 곳에서는 전역 변수가 된다.
1 2 3 4 5 6 7 8 9
var i = 10;
// for문에서 선언한 i는 전역 변수이다. 이미 선언된 전역 변수 i가 있으므로 중복 선언된다. for (var i = 0; i < 5; i++) { console.log(i); // 0 1 2 3 4 }
// 의도치 않게 i 변수의 값이 변경되었다. console.log(i); // 5
변수 호이스팅 변수 호이스팅에 의해 변수 선언문 이전에 변수를 참조할 수 있다. 하지만 선언문 이전에는 언제나 undefined를 반환한다.
1 2 3 4 5 6 7 8 9 10 11
// 이 시점에는 변수 호이스팅에 의해 이미 foo 변수가 선언되었다(1. 선언 단계) // 변수 foo는 undefined로 초기화된다. (2. 초기화 단계) console.log(foo); // undefined
// 변수에 값을 할당(3. 할당 단계) foo = 123;
console.log(foo); // 123
// 변수 선언은 런타임 이전에 자바스크립트 엔진에 의해 암묵적으로 실행된다. var foo;
let 키워드
let키워드는 같은 변수명으로 선언하면 문법 에러가 발생한다.
1 2 3
let bar = 123; // let이나 const 키워드로 선언된 변수는 같은 스코프 내에서 중복 선언을 허용하지 않는다. let bar = 456; // SyntaxError: Identifier 'bar' has already been declared
let키워드로 선언한 변수는 모든 코드 블록을 지역 스코프로 인정하는 블록 레벨 스코프를 따른다.
1 2 3 4 5 6 7
let foo = 1; // 전역 변수 { let foo = 2; // 지역 변수 let bar = 3; // 지역 변수
console.log(foo); // 1 console.log(bar); // ReferenceError: bar is not defined
변수 호이스팅
let키워드로 선언한 변수는 변수 호이스팅이 발생하지 않는 것처럼 동작한다. let 키워드로 선언한 변수는 “선언 단계”와 “초기화 단계”가 분리되어 진행된다. 스코프의 시작 지점부터 초기화 시작 지점(변수 선언문)까지 변수를참조할 수 없으며 이러한 구간을 **일시적 사각지대(Temporal Dead Zone; TDZ)**라고 부른다.
1 2 3 4 5 6 7 8 9 10 11
// var 키워드로 선언한 변수는 런타임 이전에 선언 단계와 초기화 단계가 실행된다. // 따라서 변수 선언문 이전에 변수를 참조할 수 있다. console.log(foo); // undefined var foo; // let 키워드로 선언한 변수는 런타임 이전에 선언 단계가 실행된다. 아직 변수가 초기화되지 않았다. // 초기화 이전의 일시적 사각 지대에서는 변수를 참조할 수 없다. console.log(foo); // ReferenceError: foo is not defined let foo; // 변수 선언문에서 초기화 단계가 실행된다. console.log(foo); // undefined foo = 1; // 할당문에서 할당 단계가 실행된다. console.log(foo); // 1
하지만 사실 let키워드로 선언한 변수도 변수 호이스팅이 발생한다.
1 2 3 4 5
let foo = 1; // 전역 변수 { console.log(foo); // ReferenceError: Cannot access 'foo' before initialization let foo = 2; // 지역 변수 }
변수 호이스팅이 발생하지 않는다면 전역 변수 값을 출력해야하지만 호이스팅으로 참조 에러가 발생한다. 자바스크립트는 ES6에서 도입된 let, const를 포함해서 모든 선언(var, let, const, function, function*, class 등)을 호이스팅한다. 단, ES6에서도입된 let, const, class를 사용한 선언문은 호이스팅이 발생하지 않는 것처럼 동작한다.
전역 객체와 let
var 키워드로 선언한 전역 변수와 전역 함수, 암묵적 전역(선언하지 않은 변수에 값을 할당)은 전역 객체 window의 프로퍼티가 된다. 전역 객체의 프로퍼티를 참조할 때 window를 생략할 수 있다.
// 함수 선언문으로 정의한 전역 함수는 전역 객체 window의 프로퍼티다. console.log(window.foo); // ƒ foo() {} // 전역 객체 window의 프로퍼티는 전역 변수처럼 사용할 수 있다. console.log(foo); // ƒ foo() {}
let 키워드로 선언한 전역 변수는 전역 객체의 프로퍼티가 아니다. let 전역 변수는 보이지 않는 개념적인 블록(전역 렉시컬 환경의 선언적 환경 레코드) 내에 존재한다.
1 2 3 4 5 6
// 이 예제는 브라우저 환경에서 실행해야 한다. let x = 1;
// let, const 키워드로 선언한 전역 변수는 전역 객체 window의 프로퍼티가 아니다. console.log(window.x); // undefined console.log(x); // 1
const 키워드
const 키워드는 상수(constant)를 선언하기 위해 사용한다. const 키워드의 특징은 let 키워드와 대부분 동일하다.
선언과 초기화
const 키워드로 선언한 변수는 반드시 선언과 동시에 초기화해야 한다.
1 2 3 4
const foo = 1;
// 선언과 동시에 초기화하지 않으면 문법 에러가 발생 const bar; // SyntaxError: Missing initializer in const declaration
변수는 자신이 선언된 위치에서 생성되고 소멸한다. 전역 변수의 생명 주기는 애플리케이션의 생명 주기와 같다.(var 키워드로 선언한 전역 변수의 생명 주기는 전역 객체의 생명 주기와 일치한다.) 하지만 함수 내부에서 선언된 지역 변수는 함수가 호출되면 생성되고 함수가 종료하면 소멸한다.
전역 변수는 선언문이 어디에 있뜬 호이스팅에 의해 runtime 이전에 가장 먼저 실행된다. 하지만 함수 내부 지역 변수는 함수가 호출된 직후 함수 몸체 코드가 실행되기 직전 JS 엔진에 의해 먼저 실행고 초기화된다. 그 후 함수가 종료되면 지역 변수도 소명되어 생명 주기가 종료된다. 즉, 지역 변수의 생명 주기는 함수의 생명 주기와 일치한다.
지역 변수의 생명 주기는 함수의 생명 주기와 대부분 일치하지만 지역 변수가 함수보다 오래 생존하는 경우도 있다. 함수 내부에서 선언된 지역 변수는 자신이 등록된 스코프가 소멸(스코프가 메모리에서 해제)될 때까지 유효하다. 할당된 메모리 공간은 더 이상 아무도 참조하지 않을 때 가비지 콜렉터에 의해 해제되어 가용 메모리 풀에 반환된다. 즉, 누군가가 메모리 공간을 참조하고 있으면 해제되지 않고 확보된 생태로 남아 있게 된다. 이는 스코프도 마찬가지다. 누군가 스코프를 참조하고 있으면 스코프는 소멸하지 않고 생존하게 된다.
1 2 3 4 5 6 7 8 9
var x = "global";
functionfoo() { console.log(x); // undefined var x = "local"; }
foo(); console.log(x); // global
이처럼 호이스팅은 스코프를 단위로 동작한다.
전역 변수의 문제점
암묵적 결합 어디서든 참조 가능한 전역변수는 모든 코드가 전역 변수를 참조하고 변경할 수 있는 **암묵적 결합(implicit coupling)**을 허용한ㄷ다. 변수의 유효 범위가 크면 클수록 코드의 가독성은 나빠지고 의도치 않게 상태가 변경될 수 있는 위험성도 높아진다.
긴 생명 주기 전역 변수는 생명 주기가 길다. 따라서 메모리 리소스도 오랜 기간 소비하며 var키워드는 변수의 중복 선언을 허용하여 변수 이름이 중복되어 의도치 않은 재할당이 이뤄질 수 있다. 지역 변수는 전역 변수보다 생명 주기가 휠씬 짧아 상태 변경에 따른 오류 발생 확률이 낮고 메모리 리소스도 비교적 짧은 기간 소비한다.
스코프 체인 상에서 종점에 존재 전역 변수는 스코프 체인 상에서 종점에 존재한다. 즉, 변수 검색 시 최상위 스코프에 존재한 전역 변수의 검색 속도가 가장 느리다.
네임스페이스 오염 JS는의 가장 큰 문제점 중 하나는 파일이 분리되어 있다 해도 하나의 전역 스코프를 공유한다는 것이다. 따라서 다른 파일 내에서 동일한 이름으로 명명된 전역 변수나 전역 함수가 같은 스코프 내에 존재할 경우 예상치 못한 결과를 가져올 수 있다.
전역 변수의 사용을 억제하는 방법
전역 변수를 반드시 사용해야 할 이유를 찾지 못한다면 지역 변수를 사용해야 한다. 변수의 스코프는 좁을수록 좋다.
즉시 실행 함수
1 2 3 4 5 6
(function () { var foo = 10; // 즉시 실행 함수의 지역 변수 // ... })();
console.log(foo); // ReferenceError: foo is not defined
모든 코드를 즉시 실행 함수로 감싸면 모든 변수는 즉시 실행 함수의 지역 변수가 된다. 이러한 특성으로 전역 변수의 사용을 제한한다.
네임스페이스 객체
1 2 3 4 5
var MYAPP = {}; // 전역 네임스페이스 객체
MYAPP.name = "Lee";
console.log(MYAPP.name); // Lee
전역에 네임스페이스(Namespace) 역할을 담당할 객체를 생성하고 전역 변수처럼 사용하고 싶은 변수를 프로퍼티로 추가한다.
위 예제의 즉시 실행 함수는 객체를 반환한다. 이 객체에는 외부에 노출하고 싶은 변수나 함수를 담아 반환한다. 이때 반환되는 객체의 프로퍼티는 외부에 노출되는 퍼블릭 멤버(public menber)이다. 외부로 노출하고 싶지 않은 변수나 함수는 반환하는 객체에 추가하지 않으면 외부에서 접근할 수 없는 프라이빗 멤버(private member)가 된다.
ES6 모듈 ES6 모듈은 파일 자체의 독자적인 모듈 스코프를 제공한다. 즉, 모듈 내에서 var 키워드로 선언한 변수는 더는 전역 변수가 아니며 window 객체의 프로퍼티도 아니다. 모던 브라우저(Chrome 61, FF 60, SF 10.1, Edge 16 이상)에서 ES6 모듈을 사용할 수 있다. script 태그에 type="module" 어트리뷰트를 추가하면 로드된 자바스크립트 파일은 모듈로서 동작한다. 모듈의 파일 확장자는 mjs를 권장한다.
모든 식별자(변수 이름, 함수 이름, 클래스 이름 등)는 자신이 선언된 위치에 의해 자신을 참조할 수 있는 유효 범위가 결정된다. 식별자의 유효 범위를 스코프라한다.
JS 엔진은 이름이 같은 두 개의 변수 중에서 어떤 변수를 참조해야 할 것인지를 결정하는 것을 식별자 결정(identifier resolution)이라 한다. 식별자 결정은 스코프를 통해 어떤 변수를 참조해야 할 것인지 결정한다.
1 2 3 4 5 6 7 8 9 10
var x = "global";
functionfoo() { var x = "local"; console.log(x); // local }
foo();
console.log(x); // global
위 예제의 두 개의 x 변수는 식별자 이름이 동일하지만 자신이 유효한 범위, 즉 스코프가 다른 별개의 변수다. 스코프라는 개념이 없다면 같은 이름을 갖는 변수는 충돌을 일으키므로 프로그램 전체에서 하나밖에 사용할 수 없다.
프로그래밍 언어에서는 스코프(유효 범위)를 통해 식별자인 변수 이름의 충돌을 방지하여 같은 이름의 변수를 사용할 수 있게 한다. 스코프 내에서 식별자는 유일해야 하지만 다른 스코프에는 같은 이름의 식별자를 사용할 수 있다.
스코프의 종류
코드는 전역(global)과 지역(local)으로 구분할 수 있다.
전역과 전역 스코프
전역이란 코드의 가장 바깥 영역을 말한다. 전역은 전역 스코프(global scope)를 만든다. 전역에 변수를 선언하면 전역 스코프를 갖는 어디서든 참조 가능한 전역 변수(global variable)가 된다.
지역과 지역 스코프
지역이란 함수 몸체 내부를 말한다. 지역은 지역 스코프(local scope)를 만든다. 지역에 변수를 선언하면 지역 스코프를 갖는 자신과 하위 지역에서 참조 가능한 지역 변수(local variable)가 된다.
스코프 체인
함수는 전역, 함수 몸체 내부 모두 정의할 수 있다. 함수 몸체 내부에서 함수가 정의되면 ‘함수의 중첩’이라 한다. 그리고 중첩 함수를 포함하는 함수를 ‘외부 함수’라고 한다.
함수의 중첩은 함수의 지역 스코프도 중첩될 수 있다는 것을 말한다. 이는 스코프가 함수의 중첩으로 계층적 구조를 갖음을 의미한다. 이때 외부 함수의 지역 스코프를 중첩 함수의 상위 스코프라 한다. 모든 스코프는 하나의 계층적 구조로 연결되며 전역 스코프가 최상위 스코프이다. 이와 같이 스코프가 계층적으로 연결된 것을 스코프 체인이라 한다.
식별자를 참조할 때 JS 엔진은 스코프 체인을 통해 식별자를 참조하는 코드의 스코프부터 상위 스코프 방향으로 이동하며 식별자를 검색한다. 이러한 방향성으로 하위 스코프의 유효한 식별자를 상위 스코프에서는 참조할 수 없다. 변수와 함수 객체가 할당된 식별자 모두 스코프 체인의 규칙에 따라 검색된다.
함수 레벨 스코프
C, JAVA 등에서는 모든 코드 블록이 지역 스코프를 만들지만(블록 레벨 스코프) JS에서는 코드 블록이 아닌 함수에 의해서만 지역 스코프가 생성(함수 레벨 스코프)된다.
1 2 3 4 5 6 7 8 9
var i = 10;
// for 문에서 선언한 i는 전역 변수다. 이미 선언된 전역 변수 i가 있으므로 중복 선언된다. for (var i = 0; i < 5; i++) { console.log(i); // 0 1 2 3 4 }
// 의도치 않게 변수의 값이 변경되었다. console.log(i); // 5
var로 선언된 변수는 함수의 코드 블록만을 지역 스코프로 인정한다. 하지만 ES6에서 도입된 let, const 키워드는 블록 레벨 스코프를 지원한다.
렉시컬 스코프
1 2 3 4 5 6 7 8 9 10 11 12 13
var x = 1;
functionfoo() { var x = 10; bar(); }
functionbar() { console.log(x); }
foo(); // 1 bar(); // 1
위 예제의 실행 결과는 bar 함수의 상위 스코프가 무엇인지에 따라 결정된다.
함수를 어디서 호출했는지에 따라 함수의 상위 스코프를 결정하는 동적 스코프#
함수를 어디서 정의했는지에 따라 함수의 상위 스코프를 결정하는 렉시컬 스코프(정적 스코프)
JS는 렉시컬 스코프를 따르므로 함수를 정의한 위치에 따라 상위 스코프가 결정된다. 즉, 함수의 상위 스코프는 언제나 자신이 정의된 스코프다. 이처럼 함수의 상위 스코프는 함수 정의가 실행될 때 정적으로 결정된다. 함수 정의(함수 선언문 또는 함수 표현식)가 실행되어 생성된 함수 객체는 이렇게 결정된 상위 스코프를 기억한다. 함수가 호출될 때마다 함수의 상위 스코프를 참조할 필요가 있기 때문이다.
위 예제의 bar() 함수는 자신이 정의된 전역 스코프를 기억하고 어디서 호출되든 자신이 정의됐던 전역 스코프를 상위 스코프로 사용한다.
1.toString() → 중의적 표현이라서 문맥에 따라 숫자 뒤의 .은 소수점으로 인식 (1).toString()으로 해야한다.
원시값과 객체의 비교
원시값
1 2 3
// y에는 x를 평가하여 x의 값을 참조하여 값을 전달한다. (값에 의한 전달) var x = 10; var y = x;
원시값은 변경 불가이다. 변수값 변경은 ‘재할당’이다. 변수값 변경과 원시값 변경은 다른 의미이다.
상수는 단 한 번만 할당이 허용되는 변수이므로 상수와 변경 불가능한 값을 동일시하는 것은 곤란하다. 상수는 재할당이 금지된 변수일 뿐이다.
값에 의한 전달
1 2
var score = 80; var copy = score;
score의 80이 평가되어 score의 값 80을 복사한 후 메모리에 마련한 뒤 copy에 할당해 준다.
객체
객체는 프로퍼티의 개수가 정해져 있지 않으며, 동적으로 추가되고 삭제할 수 있다. 또한 프로퍼티의 값에도 제약이 없다. 따라서 객체는 원시값과 같이 확보해야 할 메모리 공간의 크기를 사전에 정해 둘 수 없다.
클래스 기반 객체 지향 언어는 객체에 할당된 메모리량을 정의한 클래스를 이용해 알 수 있다. 하지만 프로토타입 기반 객체 지향 언어는 객체의 할당 메모리량을 알 수 없다. 만약 객체가 원시값 처럼 동작한다면 메모리 소비가 매우 많다. → 변경 불가능한 값을 변경을 위해서는 복사한 값에 대한 새로운 메모리 공간이 필요하기 때문이다.
1 2 3 4 5
var person = { name: "Kim", }; // 프로퍼티 값 갱신 person.name = "Lee";
1 2 3 4 5 6 7
var person = { name: "Kim", };
person = { name: "Lee", };
위 두 상황은 같은 과정일까? → 물론 아니다. 전자는 프로퍼티 값만을 갱신해주는 행위지만, 후자는 person 식별자에 새로운 객체 생성해서 참조해주는 것이다.
person1과 person2의 변수 값은 객체의 참조 주소이다. 물론 person1과 person2는 내용이 같을 뿐 다른 객체이기에 참조 주소도 다르다. 반면 person1.name과 person2.name은 모두 원시값 문자열 ‘Lee’의 표현식이기 때문에 같은 값으로 평가된다.
얕은 복사와 깊은 복사
1 2 3 4 5 6 7 8 9 10 11 12 13
// 얕은 복사 예 var person = { name: "Lee", };
// 참조에 의한 전달. // 두 식별자가 하나의 객체를 공유한다. var copy = person;
copy.name = "Kim";
console.log(person); // Kim console.log(copy === person); // true
얕은 복사는 항상 조심해야한다. 깊은 복사를 해야 객체의 불변성을 얻을 수 있다. 하지만 깊은 복사는 퍼포먼스 측면에서 좋지 않기 때문에 대부분 얕은 복사를 기본으로 한다. 깊은 복사를 사용하고 싶다면 라이브러리를 사용해는 것이 좋다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
const o = { a: { b: 2, }, f() {}, };
// lodash의 cloneDeep을 사용한 깊은 복사 // "npm install lodash"로 lodash를 설치한 후, Node.js 환경에서 실행 const _ = require("lodash");
let c = _.cloneDeep(o); console.log(o === c); // false console.log(o.a === c.a); // false console.log(c.f); // f
함수
1 2 3 4
// 변수에 함수 리터럴을 할당 (함수 표현식) var f = functionadd(x, y) { return x + y; };
;이 붙는 것은 표현식이라는 것이다. 값으로 평가되는 함수 리터럴이다. 함수 리터럴은 변수에 참조하기 직전에 생성한다.(함수 선언문은 runtime 전에 정의됨)
함수 이름 add는 함수 몸체 내에서만 참조할 수 있는 식별자다.(함수 이름 생략 가능)
함수가 객체라는 사실은 함수형 프로그래밍이 가능하다는 말이다. 함수가 객체가 아니라면 함수형 프로그래밍이 불가능하다.
1 2 3 4 5 6 7 8 9 10 11
// 함수 선언문(function declaration/function statement) functionadd(x, y) { return x + y; } // 함수 표현식(function expression) var add = function (x, y) { return x + y; };
// 화살표 함수 var add = (x, y) => x + y;
선언문은 익명함수가 불가능하고 ; 가 붙지 않으며 표현식처럼 값으로 평가되지 않는다. 값으로 평가되는 문맥에서는 함수는 표현식이다.
함수 호이스팅
1 2 3 4 5 6 7 8 9 10 11
// 함수 호이스팅 foo();
// 함수 선언문은 runtime 이전에 정의되고 함수 이름으로 동일한 식별자를 생성함. functionfoo() { console.log("foo"); }
// 변수 호이스팅 console.log(x); var x = 1;
가변인자 함수
1 2 3 4 5 6 7 8
functionsum() { var total = 0; for (var i = 0; i < arguments.length; i++) total += arguments[i]; return total; }
var result = sum(1, 2, 3, 4, 5); console.log(result); // 15
// (x,y) = 매개변수 functionadd(x, y) { // x + y = 반환값 return x + y; }
// 함수 호출 과정 / (2,5) = 인수 var result = add(2, 5);
함수는 코드의 재사용에 있어 유용하다.
함수 리터럴
JS에서 함수는 객체 타입의 값이다. 함수 리터럴로 생성할 수 있다. 함수 리터럴은 function 키워드, 함수 이름, 매개변수 목록, 함수 몸체로 구성된다.
1 2 3 4
// 변수에 함수 리터럴을 할당 var f = functionadd(x, y) { return x + y; };
함수 리터럴의 구성 요소는 다음과 같다.
함수 이름
함수 이름은 식별자로서 식별자 네이밍 규칙을 준수해야 한다.
함수 이름은 함수 몸체 내에서만 참조할 수 있는 식별자다.
이름이 있는 함수를 기명 함수(named function), 이름이 없는 함수를 무명/익명 함수(anonymous function)라 한다.
매개변수 목록
0개 이상의 매개변수를 소괄호로 감싸고 쉼표로 구분한다.
각 매개변수에는 함수를 호출할 때 지정한 인수가 순서대로 할당된다. 즉, 매개변수 목록은 순서에 의미가 있다.
매개변수는 함수 몸체 내에서 변수와 동일하게 취급된다. 따라서 매개변수도 식별자 네이밍 규칙을 준수해야 한다.
함수 몸체
함수가 호출되었을 때 일괄적으로 실행될 문들을 하나의 실행 단위로 정의한 코드 블록이다.
함수 몸체는 함수 호출에 의해 실행된다.
함수는 객체지만 일반 객체와는 다르다. 일반 객체는 호출할 수 없지만 함수는 호출할 수 있다. 그리고 일반 객체에는 없는 함수 객체만의 고유한 프로퍼티를 갖는다.
함수 정의
함수 정의 방식에는 4가지가 있다.
1. 함수 선언문
1 2 3 4 5 6 7 8 9 10 11 12
// 함수 선언문 functionadd(x, y) { return x + y; }
// 함수 참조 // console.dir은 console.log와는 달리 함수 객체의 프로퍼티까지 출력한다. // 단, Node.js 환경에서는 console.log와 같은 결과가 출력된다. console.dir(add); // ƒ add(x, y)
// 함수 호출 console.log(add(2, 5)); // 7
함수 리터럴은 함수 이름을 생략할 수 있으나 함수 선언문은 함수 이름을 생략할 수 없다.
1 2 3 4 5 6
functionadd(x,y){ return x + y; }
//크롬 개발자 도구 콘솔 <- undefined
함수 선언문은 표현식이 아닌 문이다. 크롬 콘솔에서 함수 선언문을 실행하면 완료 값 undefined가 출력된다.
1 2 3 4 5 6 7 8
// 함수 선언문은 표현식이 아닌 문이므로 변수에 할당할 수 없다. // 하지만 함수 선언문이 변수에 할당되는 것처럼 보인다. var add = functionadd(x, y) { return x + y; };
// 함수 호출 console.log(add(2, 5)); // 7
JS 엔진은 동일한 코드도 코드의 문맥에 따라 해석이 달라질 수 있다. 예를 들어 단독으로 존재하는 { }은 블록문으로 해석하지만 { }이 피연산자로써 값으로 평가되는 문맥에서는 객체 리터럴로 해석한다. 이와 같이 문맥에 따라 코드 해석이 달라진다.
함수의 경우에도 동일한 코드를 표현식이 아닌 함수 선언문, 표현식인 기명 함수 함수 리터럴에 대한 판단을 문맥에 따라 해석한다. 기명 함수 리터럴을 단독으로 사용하면 함수 선언문으로 해석하고, 표현식인 문맥(변수에 할당, 피연산자로 사용 등)에 사용되는 기명 함수 리터럴은 함수 리터럴 표현식으로 해석한다. 하지만 함수를 생성하는 내부 동작에 차이가 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13
// 기명 함수 리터럴을 단독으로 사용하면 함수 선언문으로 해석된다. // 함수 선언문에서는 함수 이름을 생략할 수 없다. functionfoo() { console.log("foo"); } foo(); // foo
// 함수 리터럴을 피연산자로 사용하면 함수 선언문이 아니라 함수 리터럴 표현식으로 해석된다. // 함수 리터럴에서는 함수 이름을 생략할 수 있다. (functionbar() { console.log("bar"); }); bar(); // ReferenceError: bar is not defined
함수는 함수 이름으로 호출하는 것이 아니라 함수 객체를 가리키는 식별자로 호출한다. 함수 선언문으로 생성한 함수를 호출한 것은 함수 이름이 아니라 자바스크립트 엔진이 암묵적으로 생성한 함수 이름과 동일한 이름의 식별자인 것이다. 함수 이름과 변수 이름이 일치하므로 함수 이름으로 호출되는 듯하지만 사실은 식별자로 호출된 것이다.
foo()는 함수 선언문으로 해석되어 호출이 가능하다. JS 엔진은 생성된 함수를 호출하기 위해 함수 이름과 동일한 이름의 식별자를 암묵적으로 생성하고 해당 함수 객체를 할당하기 때문이다. bar()는 함수 리터럴로 해석되었다. 때문에 함수 리터럴인 bar()는 변수에 참조되어야만 호출할 수 있기 때문에 참조되지 않은 함수 리터럴 bar()의 결과로 ReferenceError가 출력된다. 이는 “함수 리터럴의 함수 이름은 함수 몸체 내에서만 참조할 수 있는 식별자다” 규칙 때문이다.
2. 함수 표현식
자바스크립트의 함수는 객체 타입의 값이다. 자바스크립트의 함수는 값처럼 변수에 할당할 수도 있고 프로퍼티 값이 될 수도 있으며 배열의 요소가 될 수도 있다. 이처럼 값의 성질을 갖는 객체를 일급 객체(first-class object)(다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체) 라 한다. 함수가 일급 객체라는 것은 함수를 값처럼 자유롭게 사용할 수 있다는 의미다.
함수는 일급 객체이므로 함수 리터럴로 생성한 함수 객체를 변수에 할당할 수 있다. 이러한 함수 정의 방식을 함수 표현식이라 한다.
1 2 3 4 5 6 7
// 함수 표현식 // 함수 표현식의 함수 리터럴은 함수 이름을 생략하는 것(익명 함수)이 일반적이다. var add = function (x, y) { return x + y; };
// 함수 호출 console.log(add(2, 5)); // 7 console.log(sub(2, 5)); // TypeError: sub is not a function
// 함수 선언문 functionadd(x, y) { return x + y; }
// 함수 표현식 var sub = function (x, y) { return x - y; };
함수 선언문으로 정의한 함수는 함수 선언문 이전에 호출할 수 있다. 그러나 함수 표현식으로 정의한 함수는 함수 표현식 이전에 호출할 수 없다. 이는 함수 선언문으로 정의한 함수와 함수 표현식으로 정의한 함수의 생성 시점이 다르기 때문이다.
모든 선언문과 마찬가지로 함수 선언문도 runtime 이전에 JS 엔진에 의해 먼저 실행되어 함수 이름과 동일한 이름의 식별자를 암묵적으로 생성하고 함수 객체를 할당한다. 때문에 runtime에는 이미 함수 객체가 생성되어 식별자에 할당이 완료된 상태임으로 함수 선언문 이전에 함수를 참조하고 호출할 수 있다. 이처럼 함수 선언문이 코드의 선두로 끌어 올려진 것처럼 동작하는 특징을 함수 호이스팅이라 한다.
변수 호이스팅과 다른 점은 var 키워드로 선언된 변수는 undefined로 초기화되고, 함수 선언문을 통해 암묵적으로 생성된 식별자는 함수 객체로 초기화된다. 따라서 var 키워드를 사용한 변수 선언문 이전에 변수를 참조하면 변수 호이스팅에 의해 undefined로 평가되지만 함수 선언문으로 정의한 함수를 함수 선언문 이전에 호출하면 함수 호이스팅에 의해 호출이 가능하다.
함수 표현식의 경우 var 변수에 할당되는 함수 리터럴이기 때문에 runtime 이전에 변수가 undefined로 초기화되며 변수 할당문의 값은 할당문이 실행되는 시점, 즉 runtime에 평가되기 때문에 함수 표현식의 함수 리터럴도 runtime 때 평가되어 함수 객체가 된다. 따라서 함수 표현식으로 함수를 정의하면 함수 호이스팅이 아닌 변수 호이스팅이 발생한다.
함수 호이스팅은 함수 호출 전 함수를 선언해야하는 규칙을 무시하기 때문에 함수 선언문 대신 함수 표현식을 권장한다.
3. Function 생성자 함수
기본 제공하는 빌트인 함수인 Function 생성자 함수에 매개변수 목록과 함수 몸체를 문자열로 전달하면서 new 연산자와 함께 호출하면 함수 객체를 생성해서 반환한다. 사실 new 연산자 없이 호출해도 결과는 동일하다.
1 2 3
var add = newFunction("x", "y", "return x + y");
console.log(add(2, 5)); // 7
Function 생성자 함수로 함수를 생성하는 방식은 일반적이지 않으며 바람직하지도 않다. Function 생성자 함수로 생성한 함수는 클로저(closure)를 생성하지 않는 등, 함수 선언문이나 함수 표현식으로 생성한 함수와 다르게 동작한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
var add1 = (function () { var a = 10; returnfunction (x, y) { return x + y + a; }; })();
console.log(add1(1, 2)); // 13
// Function 생성자 함수로 생성한 함수는 클로저(closure)를 생성하지 않는다. var add2 = (function () { var a = 10; returnnewFunction("x", "y", "return x + y + a;"); })();
console.log(add2(1, 2)); // ReferenceError: a is not defined
4. 화살표 함수
function 키워드 대신 => 를 사용해 간략하게 익명 함수를 선언한다.
1 2 3
// 화살표 함수 const add = (x, y) => x + y; console.log(add(2, 5)); // 7
화살표 함수는 기존 함수보다 표현과 내부 동작이 간략화되었다. (화살표 함수는 생성자 함수로 사용할 수 없으며 기존의 함수와 this 바인딩 방식이 다르고, prototype 프로퍼티가 없으며 arguments 객체를 생성하지 않는다.)
함수 호출
매개변수와 인수
1 2 3 4 5 6 7 8
// 함수 선언문 functionadd(x, y) { return x + y; }
// 함수 호출 // 인수 1과 2는 매개변수 x와 y에 순서대로 할당되고 함수 몸체의 문들이 실행된다. var result = add(1, 2);
함수가 호출되면 함수 몸체 내에서 암묵적으로 매개변수가 생성되고 일반 변수와 마찬가지로 undefined로 초기화된 이후 인수가 순서대로 할당된다. 매개변수는 함수 몸체 내부에서만 참조할 수 있고 함수 몸체 외부에서는 참조할 수 없다. 매개변수의 스코프는 함수 내부이다.
함수는 매개변수의 개수와 인수의 개수가 일치하는지 체크하지 않는다. 함수를 호출할 때 매개변수의 개수만큼 인수를 전달하는 것이 일반적이지만 그렇지 않은 경우에도 에러가 발생하지는 않는다. 인수가 부족해서 인수가 할당되지 않은 매개변수의 값은 undefined이다.
1 2 3 4 5
functionadd(x, y) { return x + y; } // 2 + undefined = NaN console.log(add(2)); // NaN
1 2 3 4 5 6 7 8 9 10
// 인수가 더 많을 경우에 초과된 인수는 무시된다. 초과된 인수는 버려지는 것이 아니다. // 모든 인수는 암묵적으로 arguments 객체의 프로퍼티로 보관된다. functionadd(x, y) { console.log(arguments); // Arguments(3) [2, 5, 10, callee: ƒ, Symbol(Symbol.iterator): ƒ]
return x + y; }
add(2, 5, 10); // 7
인수 확인
1 2 3 4 5 6
functionadd(x, y) { return x + y; }
console.log(add(2)); // NaN console.log(add("a", "b")); // 'ab'
본래 목적은 숫자 타입 인수를 매개변수로 받으려했지만 문자열이 입력되었으며 이에 대한 연산이 진행됐다. 그 이유는 아래와 같다.
자바스크립트 함수는 매개변수와 인수의 개수가 일치하는지 확인하지 않는다.
자바스크립트는 동적 타입 언어다. 따라서 자바스크립트 함수는 매개변수의 타입을 사전에 지정할 수 없다.
해결 방법은 아래와 같은 코드이다.
1 2 3 4 5 6 7 8 9 10 11
functionadd(x, y) { if (typeof x !== "number" || typeof y !== "number") { // 매개변수를 통해 전달된 인수의 타입이 부적절한 경우 에러를 발생시킨다. thrownewTypeError("인수는 모두 숫자 값이어야 합니다."); }
return x + y; }
console.log(add(2)); // TypeError: 인수는 모두 숫자 값이어야 합니다. console.log(add("a", "b")); // TypeError: 인수는 모두 숫자 값이어야 합니다.
위 예제의 경우, 인수의 개수는 확인하고 있지 않다. 인수가 전달되지 않은 경우 단축 평가를 사용해 매개변수에 기본값을 할당하는 방법도 있다.
1 2 3 4 5 6 7 8 9 10 11
functionadd(a, b, c) { a = a || 0; b = b || 0; c = c || 0; return a + b + c; }
원시 타입 인수를 전달받은 매개변수의 경우, 원시값은 변경 불가능한 값이므로 재할당을 통해 할당된 원시값을 새로운 원시값으로 교체했고, 객체 타입 인수를 전달받은 매개변수의 경우, 객체는 변경 가능한 값이므로 직접 변경할 수 있기 때문에 재할당 없이 직접 할당된 객체를 변경했다.
함수가 외부 상태 (위 예제의 경우, 객체를 할당한 person 변수) 를 변경하면 상태 변화를 추적하기 어려워진다. 이는 복잡성을 증가시키고 가독성을 해친다. 이러한 현상은 객체가 변경할 수 있는 값이며, 참조에 의한 전달 방식으로 동작하기 때문에 발생하는 부작용이다. 여러 변수가 참조에 의한 전달 방식을 통해 참조값을 공유하고 있다면 언제든지 참조하고 있는 객체를 직접 변경할 수 있다.
이러한 문제의 해결 방법 중 하나는 깊은 복사를 통해 새로운 객체를 생성하고 재할당을 통해 교체한다. 이를 통해 외부 상태가 변경되는 부수 효과를 없앨 수 있다.
다양한 함수의 형태
즉시 실행 함수
즉시 실행 함수는 단 한 번만 호출되며 다시 호출할 수 없다.
1 2 3 4 5 6
// 익명 즉시 실행 함수 (function () { var a = 3; var b = 5; return a * b; })();
즉시 실행 함수는 함수 이름이 없는 익명 함수를 사용하는 것이 일반적이다. 즉시 실행 함수는 반드시 그룹 연산자 (…)로 감싸야 한다.
그룹 연산자 (…) 내의 기명 함수는 함수 리터럴로 평가되며 함수 이름은 함수 몸체에서만 참조할 수 있는 식별자이므로 즉시 실행 함수를 다시 호출할 수는 없다.
1 2 3 4 5 6 7 8
// 기명 즉시 실행 함수 (functionfoo() { var a = 3; var b = 5; return a * b; })();
foo(); // ReferenceError: foo is not defined
그룹 연산자로 함수를 묶은 이유는 먼저 함수 리터럴을 평가해서 함수 객체를 생성하기 위해서다. 함수 리터럴을 평가해서 함수 객체를 생성할 수 있다면 다음과 같이 그룹 연산자 이외의 연산자를 사용해도 좋다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
(function () { // ... })();
(function () { // ... })();
!(function () { // ... })();
+(function () { // ... })();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// 즉시 실행 함수도 일반 함수처럼 값을 반환할 수 있다. var res = (function () { var a = 3; var b = 5; return a * b; })();
console.log(res); // 15
// 즉시 실행 함수에도 일반 함수처럼 인수를 전달할 수 있다. res = (function (a, b) { return a * b; })(3, 5);
console.log(res); // 15
즉시 실행 함수도 일반 함수처럼 값을 반환할 수 있고 인수를 전달할 수도 있다.
즉시 실행 함수 내에 코드를 모아 두면 혹시 있을 수도 있는 변수나 함수 이름의 충돌을 방지할 수 있다.
재귀 함수
함수가 자기 자신을 호출하는 것을 재귀 호출(recursive call)이라 한다. 재귀 함수는 자기 자신을 호출하는 함수이다. 재귀 함수는 자신을 무한 재귀 호출한다. 따라서 재귀 함수 내에는 재귀 호출을 멈출 수 있는 탈출 조건을 반드시 만들어야 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
var factorial = functionfoo(n) { // 탈출 조건: n이 1 이하일 때 재귀 호출을 멈춘다. if (n <= 1) return1; // 재귀 호출 return n * factorial(n - 1); // foo(n-1)도 가능하다. };
함수 이름은 함수 몸체 내부에서만 유효하다. 따라서 함수 내부에서는 함수 이름과 함수를 가리키는 식별자 모두를 이용해 자기 자신을 호출할 수 있다. 단, 함수 외부에서는 함수를 가리키는 식별자를 이용해야한다.
중첩 함수
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// 중첩 함수를 포함하는 함수는 외부 함수라 한다. functionouter() { var x = 1;
// 중첩 함수 또는 내부 함수라 한다. // 일반적으로 중첩 함수는 자신을 포함하는 외부 함수를 돕는 헬퍼 함수의 역할을 한다. functioninner() { var y = 2; // 외부 함수의 변수를 참조할 수 있다. console.log(x + y); // 3 }
inner(); }
outer();
ES6 이전에는 코드의 최상위 또는 다른 함수 내부에서만 함수 선언문을 정의할 수 있었으나 ES6부터는 if 문이나 for 문 등의 코드 블록 내에서도 정의할 수 있다. 단, 호이스팅으로 인해 혼란이 발생할 수 있으므로 if 문이나 for 문 등의 코드 블록에서 함수 선언문을 통해 함수를 정의하는 것은 바람직하지 않다.
JS의 함수는 일급 객체이므로 매개변수를 통해 함수를 전달할 수 있다. repeat 함수는 외부에서 로직의 일부분을 함수로 전달받아 수행하므로 유연한 구조를 갖는다. 매개변수를 통해 다른 함수의 내부로 전달되는 함수를 콜백 함수라고 하며(위 logAll, logOdds 함수, 매개변수를 통해 함수의 외부에서 콜백 함수를 전달받은 함수를 고차 함수(Higher-Order Function, HOF)라고 한다.(위 repeat 함수) 매개변수를 통해 함수를 전달받거나 반환값으로 함수를 반환하는 함수를 함수형 프로그래밍 패러다임에서 고차 함수라 한다.
중첩 함수가 외부 함수를 돕는 헬퍼 함수의 역할을 하는 것처럼 콜백 함수도 고차 함수에 전달되어 헬퍼 함수의 역할을 한다. 단, 중첩 함수는 내부에 고정되어 있지만 콜백 함수는 함수 외부에서 고차 함수 내부로 주입하기 때문에 자유롭게 교체할 수 있다는 장점이 있다. 즉, 고차 함수는 콜백 함수를 자신의 일부분으로 합성한다.
고차 함수는 매개변수를 통해 전달받은 콜백 함수의 호출 시점을 결정해서 호출한다. 다시 말해, 콜백 함수는 고차 함수에 의해 호출되며 이때 고차 함수는 필요에 따라 콜백 함수에 인수를 전달할 수 있다. 따라서 고차 함수에 콜백 함수를 전달할 때 콜백 함수를 호출하지 않고 함수 자체를 전달해야 한다.
콜백 함수가 고차 함수 내부에만 호출된다면 콜백 함수를 익명 함수 리터럴로 정의하면서 곧바로 고차 함수에 전달하는 것이 일반적이다.
1 2 3 4 5
// 익명 함수 리터럴을 콜백 함수로 고차 함수에 전달한다. // 익명 함수 리터럴은 repeat 함수를 호출할 때마다 평가되어 함수 객체를 생성한다. repeat(5, function (i) { if (i % 2) console.log(i); }); // 1 3
콜백 함수를 다른 곳에서도 호출할 필요가 있거나, 콜백 함수를 전달받는 함수가 자주 호출된다면 함수 외부에서 콜백 함수를 정의한 후 함수 참조를 고차 함수에 전달하는 편이 효율적이다.
1 2 3 4 5 6 7
// logOdds 함수는 단 한 번만 생성된다. var logOdds = function (i) { if (i % 2) console.log(i); };
// 고차 함수에 함수 참조를 전달한다. repeat(5, logOdds); // 1 3
순수 함수와 비순수 함수
부수 효과가 없는 함수를 순수 함수(pure function)라 하고, 외부 상태에 의존하거나 외부 상태를 변경하는 부수 효과가 있는 함수를 비순수 함수(impure function)라고 한다.
순수 함수
1 2 3 4 5 6 7 8 9 10 11 12 13
var count = 0; // 현재 카운트를 나타내는 상태
// 순수 함수 increase는 동일한 인수가 전달되면 언제나 동일한 값을 반환한다. functionincrease(n) { return ++n; }
// 순수 함수가 반환한 결과값을 변수에 재할당해서 상태를 변경 count = increase(count); console.log(count); // 1
count = increase(count); console.log(count); // 2
비순수 함수
1 2 3 4 5 6 7 8 9 10 11 12 13
var count = 0; // 현재 카운트를 나타내는 상태: increase 함수에 의해 변화한다.
// 비순수 함수 functionincrease() { return ++count; // 외부 상태에 의존하며 외부 상태를 변경한다. }
// 비순수 함수는 외부 상태(count)를 변경하므로 상태 변화를 추적하기 어려워진다. increase(); console.log(count); // 1
increase(); console.log(count); // 2
함수 내부에서 외부 상태를 직접 참조하지 않더라도 매개변수를 통해 객체를 전달받으면 비순수 함수가 된다. 함수가 외부 상태를 변경하면 상태 변화를 추적하기 어렵기 때문에 함수 외부 상태 변경을 지양하는 순수 함수를 사용하는 것이 좋다.
함수형 프로그래밍
함수형 프로그래밍은 순수 함수와 보조 함수의 조합을 통해 외부 상태를 변경하는 부수 효과를 최소화해서 불변성(immutability)을 지향하는 프로그래밍 패러다임이다. 로직 내의 흐름을 어렵게하는 조건문과 반복문을 제거해서 복잡성을 해결하며, 변경될 가능성이 있는 변수 사용을 최소화해서 상태 변경을 피해 오류를 최소화하는 것을 목표로 한다. 함수형 프로그래밍은 결국 순수 함수를 통해 부수 효과를 최대한 억제해 오류를 피하고 프로그램의 안정성을 높이려는 노력의 일환이라 할 수 있다. 자바스크립트는 멀티 패러다임 언어이므로 객체지향 프로그래밍뿐만 아니라 함수형 프로그래밍을 적극적으로 활용하고 있다.
// 문자열은 유사배열이므로 for 문으로 순회할 수 있다. for (var i = 0; i < string.length; i++) { // 문자열의 개별 문자가 'l'이면 if (string[i] === search) { index = i; break; // 반복문을 탈출한다. } }
문자열은 원시값이지만 유사배열로서 객체로 암묵적 타입 변환 후 length 프로퍼티를 참조할 수 있다.
Object.prototype.toString / parseInt() 메서드를 사용하는 방법
연산자를 이용하는 방법
명시적인 것은 3 → 2 → 1 순이다.
2번 메서드를 사용하는 것도 좋지만 메서드 마다 사용법도 다르고 3번이 가장 짧고 명시적이다.
1번 생성자 함수는 본래 정석인 사용법이 아니기 때문에 지양한다.
객체 리터럴
객체
1 2 3 4 5 6
var person = { // 프로퍼티 키는 name, 프로퍼티 값은 'Lee' name: "Lee", // 프로퍼티 키는 age, 프로퍼티 값은 20 age: 20, };
프로퍼티 키는 식별자가 아니기 때문에 식별자 명명 규칙을 안 지켜도 문자열이기만 하면 된다.
1 2 3 4 5 6 7
var person = { // 프로퍼티 키는 name, 프로퍼티 값은 'Lee' "first-name": "Lee", // 이런식으로 묶기만해주면 식별자 명명 규칙을 안 지켜도됨 // 프로퍼티 키는 age, 프로퍼티 값은 20 age: 20, }; console.log(person["first-name"]);
식별자 명명 규칙을 지키지 않을 시에는 [‘first-name’] 대괄호 표기법을 따라야한다.
제일 좋은 건 식별자 명명 규칙을 지키고 편하게 쓰자.
프로퍼티 삭제 → delete 연산자
1
delete person.age;
사실 굳이 프로퍼티를 지울 일은 많지 않음. → 안티패턴(할 수는 있지만, 굳이 하는 것을 지양하는 것)이다.
delete 연산자는 안 쓰는게 좋다.
프로퍼티 축약표현 (중요!)
프로퍼티 키와 변수 이름이 같을 경우 축약할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// ES5 var x = 1, y = 2;
var obj = { x: x, y: y, };
console.log(obj); // {x: 1, y: 2}
// ES6 let x = 1, y = 2;
// 프로퍼티 축약 표현 const obj = { x, y }; // = {x: x, y: y}
console.log(obj); // {x: 1, y: 2}
객체 리터럴 내부에서 계산된 프로퍼티 키를 동적으로 생성할 수 있다. (ES5에서는 객체 리터럴 외부에서 해야했다.)
// 객체 리터럴 내부에서 계산된 프로퍼티 이름으로 프로퍼티 키 동적 생성 // 프로퍼티 키로 사용할 표현식을 대괄호([…])로 묶어야 한다. const obj = { [`${prefix}-${++i}`]: i, [`${prefix}-${++i}`]: i, [`${prefix}-${++i}`]: i, };
원시 타입의 값은 변경 불가능한 값(immutable value)이다. 객체(참조) 타입의 값은 변경 가능한 값(mutable value)이다.
원시값을 변수에 할당하면 변수(확보된 메모리 공간)에는 실제 값이 저장된다. 객체를 변수에 할당하면 변수에는 참조값이 저장된다.
원시값을 갖는 변수를 다른 변수에 할당하면 원본의 원시값이 복사되어 전달된다. 이를 값에 의한 전달(pass by value)이라 한다. 객체를 가리키는 변수를 다른 변수에 할당하면 원본의 참조값이 복사되어 전달된다. 이를 참조에 의한 전달(pass by reference)이라 한다.
원시값
원시값은 변경 불가 값, 읽기 전용 값이다. 원시값의 이러한 특성을 불변성(immutability)이라 한다. 때문에 원시값은 데이터의 신뢰성을 보장한다.
1 2 3 4 5 6 7
// const 키워드를 사용해 선언한 변수는 재할당이 금지된다. 상수는 재할당이 금지된 변수일 뿐이다. const o = {};
// const 키워드를 사용해 선언한 변수에 할당한 원시값(상수)은 변경할 수 없다. // 하지만 const 키워드를 사용해 선언한 변수에 할당한 객체는 변경할 수 있다. o.a = 1; console.log(o); // {a: 1}
원시값을 할당한 변수에 새로운 값을 재할당하면 메모리 공간에 저장되어 있는 원시값을 변경하는 것이 아니라 새로운 메모리 공간 확보 후 재할당한 원시값을 저장하고 변수는 새롭게 재할당한 원시값을 가리킨다.
문자열도 원시값이다.
1 2 3 4 5 6 7
var str = "string";
// 문자열은 유사 배열이므로 배열과 유사하게 인덱스를 사용해 각 문자에 접근할 수 있다. // 하지만 문자열은 원시값이므로 변경할 수 없다. 이때 에러가 발생하지 않는다. str[0] = "S";
console.log(str); // string
변수에 새로운 문자열을 재할당하는 것은 물론 가능하다. 이는 기존 문자열을 변경하는 것이 아니라 새로운 문자열을 새롭게 할당하는 것이기 때문이다.
객체
객체는 프로퍼티의 개수가 정해져 있지 않으며, 동적으로 추가되고 삭제할 수 있다. 또한 프로퍼티의 값에도 제약이 없다. 따라서 객체는 원시값과 같이 확보해야 할 메모리 공간의 크기를 사전에 정해 둘 수 없다. 객체를 생성하고 프로퍼티에 접근하는 것도 원시값과 비교할 때 비용이 많이 드는 일이다. 따라서 객체는 원시값과는 다른 방식으로 동작하도록 설계되어 있다.
javascript 객체 관리 방식
자바스크립트 객체는 프로퍼티 키를 인덱스로 사용하는 해시 테이블이라고 생각할 수 있다.
자바, C++ 같은 클래스 기반 객체지향 프로그래밍 언어는 사전에 정의된 클래스를 기반으로 객체(인스턴스)를 생성한다. 객체를 생성하기 이전에 이미 프로퍼티와 메서드가 정해져 있으며 그대로 객체를 생성한다. 객체가 생성된 이후에는 프로퍼티를 삭제하거나 추가할 수 없다.
자바스크립트는 클래스 없이 객체를 생성할 수 있으며 객체가 생성된 이후라도 동적으로 프로퍼티와 메서드를 추가할 수 있다. 이는 사용하기 매우 편리하지만 성능 면에서는 이론적으로 클래스 기반 객체지향 프로그래밍 언어의 객체보다 생성과 프로퍼티 접근에 비용이 더 많이 드는 비효율적인 방식이다.
따라서 V8 자바스크립트 엔진에서는 프로퍼티에 접근하기 위해 동적 탐색(dynamic lookup) 대신 히든 클래스(hidden class)라는 방식을 사용해 C++ 객체의 프로퍼티에 접근하는 정도의 성능을 보장한다. 히든 클래스는 자바와 같이 고정된 객체 레이아웃(클래스)과 유사하게 동작한다.
히든 클래스에는 다음과 같은 특징이 있습니다.
객체는 반드시 하나의 히든 클래스를 참조한다.
히든 클래스는 각 프로퍼티에 대해 메모리 오프셋을 가지고 있다.
동적으로 새로운 프로퍼티가 만들어질 때, 혹은 기존 프로퍼티가 삭제되거나 기존 프로퍼티의 데이터 타입이 바뀔 때는 신규 히든 클래스가 생성되며, 신규 히든 클래스는 기존 프로퍼티에 대한 정보를 유지하면서 추가적으로 새 프로퍼티의 오프셋을 가지게 된다.
히든 클래스는 프로퍼티에 대해 변경이 발생했을 때 참조해야 하는 히든 클래스에 대한 정보를 갖는다.
객체에 새로운 프로퍼티가 만들어지면, 현재 참조하고 있는 히든 클래스의 전환 정보를 확인한 후, 현재 프로퍼티에 대한 변경이 전환 정보의 조건과 일치하면, 객체의 참조 히든 클래스를 조건에 명시된 히든 클래스로 변경시킨다.
변경 가능한 값
객체(참조) 타입의 값, 즉 객체는 변경 가능한 값(mutable value)이다. 객체를 할당한 변수가 기억하는 메모리 주소를 통해 메모리 공간에 접근하면 참조값(reference value)에 접근할 수 있다. 참조값은 생성된 객체가 저장된 메모리 공간의 주소, 그 자체이며 참조값을 통해 실제 객체에 접근한다.
1 2 3 4 5 6 7
// 할당이 이뤄지는 시점에 객체 리터럴이 해석되고, 그 결과 객체가 생성된다. var person = { name: "Lee", };
// person 변수에 저장되어 있는 참조값으로 실제 객체에 접근해서 그 객체를 반환한다. console.log(person); // {name: "Lee"}
객체를 할당한 변수의 경우 “변수는 객체를 참조하고 있다” 또는 “변수는 객체를 가리키고(point) 있다”라고 표현한다.
객체는 크기가 매우 클 수도 있고, 원시값처럼 크기가 일정하지도 않으며, 프로퍼티 값이 객체일 수도 있어서 복사(deep copy)해서 생성하는 비용이 많이 들기 때문에 메모리의 효율적 소비가 어렵고 성능이 나빠진다. 따라서 메모리를 효율적으로 사용하고 객체를 복사해 생성하는 비용을 절약하여 성능을 향상시키기 위해 객체는 변경 가능한 값으로 설계되어 있다. 메모리 사용의 효율성과 성능을 위해 어느 정도의 구조적인 단점을 감안한 설계라고 할 수 있다.
객체는 이러한 구조적 단점에 따른 부작용이 있다. 그것은 원시값과는 다르게 여러 개의 식별자가 하나의 객체를 공유할 수 있다는 것이다.
얕은 복사(shallow copy)와 깊은 복사(deep copy) 객체를 프로퍼티 값으로 갖는 객체의 경우 얕은 복사는 한 단계까지만 복사하는 것을 말하고 깊은 복사는 객체에 중첩되어 있는 객체까지 모두 복사하는 것을 말한다.
// 얕은 복사 let c = { ...o }; // "35. 스프레드 문법" 참고 console.log(o === c); // false console.log(o.a === c.a); // true
// 얕은 복사 c = Object.assign({}, o); console.log(o === c); // false console.log(o.a === c.a); // true
// JSON.parse와 JSON.stringify를 사용한 깊은 복사 c = JSON.parse(JSON.stringify(o)); console.log(o === c); // false console.log(o.a === c.a); // false // 메서드가 사라진다! console.log(c.f); // undefined
// lodash의 cloneDeep을 사용한 깊은 복사 // "npm install lodash"로 lodash를 설치한 후, Node.js 환경에서 실행 const _ = require("lodash");
c = _.cloneDeep(o); console.log(o === c); // false console.log(o.a === c.a); // false console.log(c.f); // f
얕은 복사와 깊은 복사로 생성된 객체는 원본과는 다른 객체다. 즉, 원본과 복사본은 참조값이 다른 별개의 객체다. 하지만 얕은 복사는 객체에 중첩되어 있는 객체의 경우 참조값을 복사하고 깊은 복사는 객체에 중첩되어 있는 객체까지 모두 복사해서 원시값처럼 완전한 복사본을 만든다는 차이가 있다.
원본과 사본은 저장된 메모리 주소는 다르지만 동일한 참조값을 갖는다. 다시 말해, 원본과 사본 모두 동일한 객체를 가리킨다. 이것은 하나의 객체를 공유한다는 것을 의미한다. 따라서 어느 한쪽에서 객체를 변경하면 서로 영향을 주고받는다.
// copy와 person은 동일한 객체를 가리킨다. // 따라서 어느 한쪽에서 객체를 변경하면 서로 영향을 주고 받는다. console.log(person); // {name: "Kim", address: "Seoul"} console.log(copy); // {name: "Kim", address: "Seoul"}
심벌 값도 프로퍼티 키로 사용할 수 있지만 일반적으로 문자열을 사용한다. 프로퍼티 키는 문자열이므로 따옴표로 묶어야 한다. 하지만 식별자 네이밍 규칙을 준수하는 이름인 경우 따옴표를 생략할 수 있다. 그 외에 식별자 네이밍 규칙을 따르지 않는 이름에는 반드시 따옴표를 사용해야 한다.
1 2 3 4 5 6 7 8 9 10 11
var person = { firstName: 'Ung-mo', // 식별자 네이밍 규칙을 준수하는 프로퍼티 키 'last-name': 'Lee'// 식별자 네이밍 규칙을 준수하지 않는 프로퍼티 키 };
person.last-name; // -> 브라우저 환경: NaN // -> Node.js 환경: ReferenceError: name is not defined
person[last-name]; // -> ReferenceError: last is not defined person['last-name']; // -> Lee
// 프로퍼티 키가 숫자로 이뤄진 문자열인 경우 따옴표를 생략할 수 있다. person.1; // -> SyntaxError: Unexpected number person.'1'; // -> SyntaxError: Unexpected string person[1]; // -> 10 : person[1] -> person['1'] person['1']; // -> 10
Node.js 환경에서는 현재 어디에도 name이라는 식별자(변수, 함수 등의 이름) 선언이 없으므로 “ReferenceError: name is not defined”이라는 에러가 발생한다. 그런데 브라우저 환경에서는 name이라는 전역 변수(전역 객체 window의 프로퍼티)가 암묵적으로 존재한다. 전역 변수name은 창(window)의 이름을 가리키며, 기본값은 빈 문자열이다. 따라서 person.last-name은 undefined – ''과 같으므로 NaN이 된다.
프로퍼티 값 갱신
존재하는 프로퍼티에 값을 할당하면 프로퍼티 값이 갱신된다.
1 2 3 4 5 6 7 8
var person = { name: "Lee", };
// person 객체에 name 프로퍼티가 존재하므로 name 프로퍼티의 값이 갱신된다. person.name = "Kim";
console.log(person); // {name: "Kim"}
프로퍼티 동적 생성
존재하지 않는 프로퍼티에 값을 할당하면 프로퍼티가 동적으로 생성되고 값이 할당된다.
1 2 3 4 5 6 7 8 9
var person = { name: "Lee", };
// person 객체에는 age 프로퍼티가 존재하지 않는다. // 따라서 person 객체에 age 프로퍼티가 동적으로 생성되고 값이 할당된다. person.age = 20;
console.log(person); // {name: "Lee", age: 20}
프로퍼티 삭제
delete 연산자는 객체의 프로퍼티를 삭제한다. delete 연산자의 피연산자는 프로퍼티 값에 접근할 수 있는 표현식이어야 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
var person = { name: "Lee", };
// 프로퍼티 동적 생성 person.age = 20;
// person 객체에 age 프로퍼티가 존재한다. // 따라서 delete 연산자로 age 프로퍼티를 삭제할 수 있다. delete person.age;
// person 객체에 address 프로퍼티가 존재하지 않는다. // 따라서 delete 연산자로 address 프로퍼티를 삭제할 수 없다. 이때 에러가 발생하지 않는다. delete person.address;
console.log(obj); // {x: 1, y: 2} /****************************/ //ES6에서는 프로퍼티 값으로 변수를 사용하는 경우 변수 이름과 프로퍼티 키가 동일한 이름일 때, 프로퍼티 키를 생략(property shorthand)할 수 있다. 이때 프로퍼티 키는 변수 이름으로 자동 생성된다.
// ES6 let x = 1, y = 2;
// 프로퍼티 축약 표현 const obj = { x, y };
console.log(obj); // {x: 1, y: 2}
계산된 프로퍼티 이름
문자열 또는 문자열로 타입 변환할 수 있는 값으로 평가되는 표현식을 사용해 프로퍼티 키를 동적으로 생성할 수도 있다. 단, 프로퍼티 키로 사용할 표현식을 대괄호([…])로 묶어야 한다. 이를 계산된 프로퍼티 이름(computed property name)이라 한다.