7월과 함께 항해99 동북권 이노베이션 캠프의 리액트 입문주차가 시작되었다.
본격적인 리액트 공부에 앞서 JavaScript의 필수 개념을 초반에 확실히 짚고 넘어가고자 한다.
이번 주는 JavaScript의 특성 및 자료형, 객체의 특성과 불변 객체, 얕은 복사와 깊은 복사, 스코프와 호이스팅, 실행 컨텍스트 개념 등을 살펴볼 것이다.
컴파일러 vs 인터프리터 언어
전통적인 컴파일러 언어는 소스코드 전체를 한번에 머신 코드로 변환해 실행 파일을 만드는 컴파일 단계와 실행 단계가 분리되어 있어 코드 실행 속도가 빠르다.
인터프리터 언어의 경우 코드 실행 단계인 런타임에 문 단위로 한 줄씩 바이트코드로 변환하고 즉시 실행한다. 인터프리트 단계와 실행 단계가 분리되어 있지 않고 반복 수행되기 때문에 코드 실행 속도가 비교적 느리다.
JavaScript는 개발자가 별도의 컴파일 작업을 수행하지 않는 인터프리터 언어다.
느슨한 타입의 동적 언어
강력한 타입(strongly typed) 언어는 타입과 함께 변수를 선언해야 한다. 그래서 정적(static) 타입 언어라고도 한다. 해당 변수에 호환되지 않는 타입을 사용하면 컴파일 시점에 타입 체크를 통과하지 못해 에러가 발생한다.
반면, 느슨한 타입(loosely typed) 언어는 타입 없이 변수를 선언한다. 값을 변수에 할당하면 변수의 타입이 런타임에 결정된다.
JavaScript를 느슨한 타입의 동적(dynamic) 언어라고 말하는데, JavaScript의 변수는 어떤 특정 타입과 연결되지 않고 모든 타입의 값으로 할당 또는 재할당이 가능하다. 즉, 변수의 타입이 선언이 아닌 할당된 값에 따라 동적으로 바뀐다. 이는 JavaScript의 장점이자 단점이 된다. 형변환 측면에서 자유롭고 편리하지만 그만큼 TypeError의 발생 가능성이 높아지고 코드가 불안정해진다. 유연성은 높고 신뢰성은 떨어진다.
TypeScript는 JavaScript에 정적 타입 시스템을 도입해 이러한 단점을 보완한다.
JavaScript와 자료형
JavaScript의 자료형에는 원시 타입(primitive type)과 객체/참조 타입(object/reference type)이 있다.
원시 타입에는 숫자(Number), 문자열(String), 불리언(Boolean), undefined, null, 심벌(Symbol) 타입이 있고,
참조 타입은 객체(object), 배열(array), 함수(function) 등 원시 타입 외의 나머지 자료형을 포함한다.
> 비교 연산자
동등 비교 연산자 ==
는 암묵적 형변환을 통해 우선 타입을 일치시킨 후 값이 같은지 비교한다.
일치 비교 연산자 ===
는 타입까지 같은지 비교한다. 그래서 일반적으로 엄격한 비교인 ===
사용이 권장된다.
1 == '1'; // true (동등 비교 연산자)
1 === '1'; // false (일치 비교 연산자)
==
의 반대개념인 부동등 비교 연산자는 !=
, ===
의 반대개념인 불일치 비교 연산자는 !==
로 사용한다.
1 != '1'; // false (부동등 비교 연산자)
1 !== '1'; // true (불일치 비교 연산자)
> undefined vs null
undefined
와 null
은 둘 다 값이 없음을 의미하지만 쓰임에 있어 차이가 있다.
undefined는 자바스크립트 엔진이 변수를 초기화할 때 사용하는 값이다. 그래서 변수를 참조할 때 undefined
가 반환된다면 선언 이후 값이 할당된 적 없는 변수라는 것을 알 수 있다. 또한 객체 내부의 존재하지 않는 프로퍼티에 접근하거나 return이 호출되지 않는 함수의 실행 결과로 반환되기도 한다.
null은 개발자가 의도적으로 빈 값을 나타내기 위해(intentional absence) 사용하는 값이란 측면에서 undefined
와는 차이가 있다.
따라서 변수에 값이 없다는 것을 명시하고 싶을 때는 undefined
대신 null
을 할당하는 것이 권장된다.
undefined == null; // true 빈 값이라는 점에서 같지만,
undefined === null; // false 자료형까지 비교해보면 다르다.
> 타입(형) 변환
개발자가 의도적으로 값의 타입을 변환하는 것을 명시적 형변환(explicit coercion) 또는 타입 캐스팅(type casting)이라 한다.
하지만 개발자의 의도와 상관없이 문맥(context)에 따라 자바스크립트 엔진에 의해 타입이 자동 변환되기도 하는데, 이를 암묵적 형변환(implicit coercion) 또는 타입 강제 변환(type coercion)이라고 한다. 기존 변수 값을 새로운 타입으로 재할당해서 변경하는 것이 아니라 자바스크립트 엔진이 표현식을 에러 없이 평가하기 위해 한번 사용하고 버리는 것이다.
무조건 암묵적 형변환을 지양해야 하는 것은 아니다. 숫자형을 문자열로 바꿀 때 (10).toString()
보다는 10 + ''
이 더 가독성이 좋고 간결할 수도 있다. 따라서 형변환된 표현식이 어떻게 평가될 것인지 예측할 수 있는가가 중요하다.
JavaScript 객체와 불변성
원시 값을 변수에 할당하면 변수(확보된 메모리 공간)에는 실제 값 자체가 저장되지만, 객체를 변수에 할당하면 변수에는 참조(reference) 값이 저장된다. 참조 값은 생성된 객체가 저장된 메모리 공간의 주소를 가리킨다. 변수는 이 참조 값을 통해 실제 객체에 접근할 수 있다.
원시 값은 변경 불가능한 값(immutable value)이므로 원시 값을 갖는 변수의 값을 변경하려면 재할당해야 한다. 하지만 객체는 변경 가능한 값(mutable value)이다. 따라서 객체를 할당한 변수는 재할당 없이 객체를 직접 변경할 수 있다. 프로퍼티를 동적으로 추가하거나, 프로퍼티 값을 갱신하거나, 프로퍼티 자체를 삭제할 수도 있다. 이때 재할당을 하지 않았기 때문에 변수의 참조 값은 변경되지 않는다.
이러한 구조에 따른 단점은 원시 값과는 다르게 여러 개의 식별자가 하나의 객체를 공유할 수 있다는 것이다.
의도하지 않은 객체의 변경이 발생하는 대다수의 원인은 레퍼런스를 참조한 다른 객체에서 객체를 변경하기 때문이다.
이를 방지하기 위해서는 Object.assign(target, ...sources)
로 객체의 방어적 복사를 하는 방법과, Object.freeze()
를 사용해 불변 객체로 만드는 방법이 있다.
하지만 이 방법들은 번거롭고 성능상 이슈가 있기 때문에 대안으로 Immutable.js 를 사용할 수 있다.
> 얕은 복사와 깊은 복사
얕은 복사와 깊은 복사로 생성된 객체는 원본과는 다른 객체다.
하지만 객체에 중첩되어 있는 객체를 복사할 경우, 얕은 복사(shallow copy)는 객체인 프로퍼티 값을 참조 값만 복사해서 원본과 동일한 메모리 공간 주소를 가리키게 된다. 원본과 변화를 공유하기 때문에 사이드이펙트가 발생할 수 있다.
그런데 깊은 복사(deep copy)의 경우 모두 복사하기 때문에, 중첩된 객체와 동일한 객체를 새로 생성해서 프로퍼티 값으로 새로 생성한 객체의 참조 값을 저장한다. 따라서 완전히 독립적인 복사본을 만든다는 차이가 있다.
일반적으로 slice()
, 전개 연산자 ...
, Object.assign
등의 방법들은 모두 얕은 복사를 수행하므로, 깊은 복사를 하고 싶다면 lodash 라이브러리의 cloneDeep 메서드나 ramda 라이브러리의 clone 메서드를 사용하는 방법이 있다.
const obj = { x: { y: 1 } };
// 얕은 복사
const shallow = { ...obj };
console.log(shallow === obj); // false
console.log(shallow.x === obj.x); // true
// 깊은 복사 (lodash 사용)
const _ = require('lodash');
const deep = _.cloneDeep(obj);
console.log(deep === obj); // false
console.log(deep.x === obj.x); // false
호이스팅과 TDZ에 대해
호이스팅(hoisting)이란, 인터프리터가 변수와 함수의 메모리 공간을 선언 전에 미리 할당하는 것을 의미한다.
> 변수 호이스팅
변수 호이스팅은 변수 선언과 초기화를 분리한 후, 선언만 코드의 최상단으로 끌어올리는 것을 말한다.
var
로 선언한 변수의 경우, 호이스팅 시 선언과 undefined
로 초기화가 동시에 일어난다. 따라서 변수 선언문 이전에 변수를 참조하면 undefined
로 평가되어 사용 가능하다. 하지만 let
과 const
로 선언한 경우 호이스팅은 일어나지만 접근할 수 없다. (ReferenceError)
console.log(a); // undefined
var a = 'var';
console.log(a); // 'var'
// console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 'let';
console.log(b); // 'let'
// console.log(c); // ReferenceError: c is not defined
const c = 'const';
console.log(c); // 'const'
이렇게 스코프에 진입해 선언 전까지 접근할 수 없는 구간을 TDZ(Temporal Dead Zone)라 부른다. (There is a period between entering scope and being declared where they cannot be accessed.)
> 함수 호이스팅
함수 호이스팅의 경우, 함수 선언문과 함수 표현식에서 방식의 차이가 나타난다. 이는 함수 선언문으로 정의한 함수와 함수 표현식으로 정의한 함수의 생성 시점이 다르기 때문이다.
함수 선언문으로 함수를 정의하면 런타임 이전에 함수 객체가 먼저 생성된다. 자바스크립트 엔진이 함수 이름과 동일한 이름의 식별자를 암묵적으로 생성하고, 생성된 함수 객체를 할당하는 것이다. 따라서 런타임에는 이미 함수 객체가 생성되어 이름이 같은 식별자에 할당까지 완료된 상태이므로 함수 선언문 이전에 참조 및 호출이 가능하게 된다. 이를 함수 호이스팅이라 한다.
함수 표현식의 경우 변수에 할당되는 값이 함수 리터럴인 문이다. 변수 할당문의 값은 런타임에 평가되므로, 함수 표현식의 함수 리터럴도 할당문이 실행되는 시점에 평가되어 함수 객체가 된다. 따라서 함수 표현식으로 함수를 정의하면 함수 호이스팅이 아닌 변수 호이스팅이 발생한다.
// 함수 참조
console.dir(add); // [Function: add]
console.dir(sub); // undefined
// console.dir(mul); // ReferenceError: Cannot access 'mul' before initialization
// 함수 호출
console.log(add(2, 5)); // 7
// console.log(sub(2, 5)); // TypeError: sub is not a function
// console.log(mul(2, 5)); // ReferenceError: Cannot access 'mul' before initialization
// 함수 선언문
function add (x, y) {
return x + y
}
// 함수 표현식 (var)
var sub = (x, y) => x - y;
// 함수 표현식 (let)
let mul = (x, y) => x * y;
실행 컨텍스트
실행 컨텍스트(execution context)는 소스코드를 실행하고 관리하는 데 필요한 환경을 제공한다.
식별자(변수, 함수, 클래스 등의 이름) 등록 및 스코프는 실행 컨텍스트의 렉시컬 환경으로, 코드 실행 순서는 스택 자료구조로 관리된다.
자바스크립트 엔진이 함수 호출을 만나면 실행 컨텍스트를 생성하고 해당 함수를 실행 컨텍스트 스택(execution stack, call stack)에 삽입하게 된다. 그리고 함수의 실행이 끝나면 스택에서 해당 함수를 제거(pop)한다.
> 소스 코드의 종류
ECMAScript 사양은 소스코드(ECMAScript code)를 전역 코드, 함수 코드, eval 코드, 모듈 코드 4가지 타입으로 구분한다. 이렇게 구분하는 이유는 소스코드의 타입에 따라 실행 컨텍스트를 생성하는 과정과 관리 내용이 다르기 때문이다.
전역 코드(global code)는 전역 변수를 관리하기 위해 최상위 스코프인 전역 스코프를 생성해야 한다. 그리고 var 키워드로 선언된 전역 변수와 함수 선언문으로 정의된 전역 함수를 전역 객체의 프로퍼티와 메서드로 바인딩하고 참조하기 위해 연결한다. 이렇게 전역 코드가 평가되면 전역 실행 컨텍스트(global execution context)가 생성된다.
함수 코드(function code)는 지역 스코프를 생성하고 지역 변수, 매개변수, arguments
객체를 관리해야 한다. 그리고 생성한 지역 스코프를 전역 스코프에서 시작하는 스코프 체인의 일원으로 연결한다. 이렇게 함수 코드가 평가되면 함수 실행 컨텍스트가 생성된다.
eval 코드는 strict mode에서 독자적인 스코프를 생성하고, 모듈 코드는 모듈별로 독자적인 모듈 스코프를 생성한다. 각각 평가되면 eval 실행 컨텍스트와 모듈 실행 컨텍스트가 생성된다.
> 실행 컨텍스트의 역할
1. 전역 코드 평가 - 소스 코드 평가 과정에서는 전역 코드의 선언문만 먼저 실행된다. 그 결과 전역 변수와 전역 함수가 생성되어 실행 컨텍스트가 관리하는 전역 스코프에 등록된다. 이때 var
키워드로 선언된 전역 변수와 함수 선언문으로 정의된 전역 함수는 전역 객체의 프로퍼티와 메서드가 된다.
2. 전역 코드 실행 - 전역 코드 평가 과정이 끝나면 런타임이 시작되고, 선언문을 제외한 소스코드가 순차적으로 실행된다. 이때 전역 변수에 값이 할당되고 함수가 호출된다. 함수가 호출되면 전역 코드의 실행은 일시 중단되고, 코드 실행 순서를 변경해 함수 내부로 진입한다.
3. 함수 코드 평가 - 함수 내부로 진입하면 함수 코드 평가 과정이 시작되어 매개변수와 지역 변수 선언문이 실행된다. 그 결과 매개변수와 지역 변수가 생성되어 실행 컨텍스트가 관리하는 지역 스코프 즉, 렉시컬 환경의 환경 레코드에 등록된다. 렉시컬 환경은 특정 코드가 작성, 선언된 환경을 말한다. 또한 arguments
객체가 생성되고 this
바인딩도 결정된다.
4. 함수 코드 실행 - 함수 코드 평가 과정이 끝나면 런타임이 시작되고, 이때 매개변수와 지역 변수에 값이 할당된다. 식별자는 스코프 체인을 통해 검색하고, 함수 실행이 종료되면 함수 호출 이전으로 되돌아가 전역 코드 실행을 계속한다.
스코프 체인과 변수 은닉화
모든 식별자는 자신이 선언된 위치에 의해 다른 코드가 식별자 자신을 참조할 수 있는 스코프가 결정된다. 즉, 스코프는 식별자가 유효한 범위를 말한다.
그리고 스코프의 중첩 관계 즉, 실행 컨텍스트의 렉시컬 환경을 단방향 연결 리스트로 나타낸 스코프 체인이 형성되고, 이를 통해 상위 스코프로 이동하며 식별자를 검색할 수 있다. 식별자를 검색할 때는 식별자 결정(identifier resolution) 과정이 일어나는데, 스코프를 통해 어떤 변수를 참조할 것인지 결정하는 것을 말한다.
let a = 1;
let c = 10000;
function hi () {
// console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 100;
const b = 1;
c++;
console.log(a, b, c);
}
console.log(a); // 1
hi(); // 100 1 10001
console.log(a); // 1
// console.log(b); // ReferenceError: b is not defined
console.log(c); // 10001
function hi
의 지역 스코프에서 변수 a
가 존재하고 초기화는 안된 상태기 때문에, a
를 할당 전에 호출하면 ReferenceError가 발생한다. 전역에 a
라는 똑같은 이름을 가진 변수가 있지만 지역 변수 a
가 우선되는 것을 볼 수 있다. 이렇게 여러 스코프에서 동일한 식별자를 선언한 경우에 스코프 체인에서 먼저 검색한 식별자에 접근하는 것을 변수 은닉화(variable shadowing) 또는 name masking 이라고 부른다.
반면, 변수 c
의 경우 function hi
의 지역 스코프에서 찾을 수 없기 때문에 스코프 체인을 통해 전역 스코프의 변수 c
를 찾아 후위 증가 연산했다.
전역에서 console.log
로 a, b ,c
를 차례로 출력하면 a
는 전역 변수 a
의 값인 1을, b
는 전역에 존재하지 않기 때문에 ReferenceError를, c
는 hi
함수에서 1 증가시켰기 때문에 10001이 출력되는 것을 볼 수 있다.
Reference >
- 모던 자바스크립트 Deep Dive p.14, 59, 66-67, 70-73, 147-150, 164-165, 189-191, 359-366, 524
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Data_structures
- https://poiemaweb.com/js-type-coercion
- https://poiemaweb.com/js-immutability
- https://medium.com/watcha/깊은-복사와-얕은-복사에-대한-심도있는-이야기-2f7d797e008a
- https://developer.mozilla.org/ko/docs/Glossary/Hoisting
- https://stackoverflow.com/questions/33198849/what-is-the-temporal-dead-zone/33198850#33198850
- https://ui.toast.com/weekly-pick/ko_20191014
- https://262.ecma-international.org/13.0/#sec-types-of-source-code
- https://en.wikipedia.org/wiki/Variable_shadowing