값 더 알아보기

이 챕터에서는 값, 변수와 관련해서 알아두어야 할 여러 가지 성질들을 다룹니다.

let, const 변수와 블록 스코프

스코프에 대해서는 함수 챕터에서 설명했습니다. 함수 챕터에서는 주로 매개변수에 대한 함수 스코프만을 언급했는데, 사실 JavaScript에는 다른 종류의 스코프도 있습니다.

ES2015에서 도입된 let, const에는 이전의 변수와는 다른 몇 가지 특징이 있습니다. 먼저, letconst같은 이름을 갖는 변수의 재선언을 허용하지 않습니다.1

let foo = 1;
let foo = 2; // Duplicate declaration "foo"
const foo = 3; // Duplicate declaration "foo"

function func(param) {
  let param = 1; // Duplicate declaration "param"
}

그리고, 변수가 선언되기 전에 참조하려고 하면 에러가 납니다.

console.log(foo); // ReferenceError: foo is not defined
let foo = 1;

'이건 너무 당연한 소리 아닌가?' 싶으시겠지만, ES2015 이전에는 변수를 위와 같이 사용해도 에러가 나지 않았습니다. 오히려 위와 같은 방식이 하나의 프로그래밍 기법으로 활용되기도 했죠. 하지만, 가독성을 해치고 유지보수를 어렵게 만든다는 이유 때문에, ES2015에 들어와서 제약이 강화된 letconst가 도입된 것입니다.

또 한가지 주목해야 할 특징은, letconst가 바로 블록 스코프(block scope)를 갖는다는 것입니다.2

// 블록 안에서 선언된 변수는 외부에서 접근할 수 없습니다.
if (true) {
  let i = 0;
}
console.log(i); // ReferenceError: i is not defined

if, for, while, function 등의 구문을 사용하면 블록이 형성되어, 그 안에서 let 또는 const를 통해 선언된 변수는 외부에서 접근할 수 없습니다.

for (let i = 0; i < 10; i++) {
  console.log(i);
}
console.log(i); // ReferenceError: i is not defined

또는 특별한 기능이 없는 블록을 만들 수도 있습니다. 객체와 유사하게 중괄호로 코드의 일부분을 둘러싸면 됩니다.

{
  let i = 0;
}
console.log(i); // ReferenceError: i is not defined

var 변수와 함수 스코프

ES2015에서 let, const가 도입되기 전까지는, 모든 변수는 var 키워드를 통해 선언되었습니다.

var foo = 1;
foo = 2;

varlet과 유사하게, 값을 다시 대입할 수 있는 변수입니다. 그리고, var는 함수의 매개변수와 유사하게, 함수 스코프를 갖습니다.3

function func() {
  var foo = 1;
}
func();
console.log(foo); // ReferenceError: foo is not defined

var를 통해 선언된 변수는 let, const로 선언된 변수와는 다른 특징을 갖습니다. 먼저, var를 통한 변수 선언은 재선언을 허용합니다.

// 아무런 에러도 발생하지 않습니다.
var foo = 1;
var foo = 1;

그리고, var로 선언된 변수는 내부적으로 함수 혹은 파일의 맨 위로 끌어올려지는 과정을 거치기 때문에, 같은 스코프 안에만 있다면 변수가 선언되기 전에도 해당 변수에 접근할 수 있습니다. 이런 현상을 가지고 호이스팅(hosting)이라고 합니다. 단, 호이스팅이 일어난다고 하더라도, 변수의 선언만 위로 끌어올려질 뿐 값을 대입하는 과정은 코드의 순서에 맞게 이루어지기 때문에, 대입이 일어나기 전에 변수의 값을 읽으면 undefined가 불러와지게 됩니다.

console.log(foo); // undefined
var foo = 1;

function func() {
  console.log(bar); // undefined
  var bar = 1;
}

func();

마지막으로, var 변수는 함수 스코프를 갖습니다. 즉, 함수가 아닌 블록에서 정의된 var 변수는 해당 블록 바깥에서도 유효할 수 있다는 말입니다.

function func() {
  for (var i = 0; i < 10; i++) {
    // ...
  }
  console.log(i); // 10
}

func();

이 특징에 주의하지 않으면, 중첩된 for 루프와 같이 블록이 중첩된 코드에서 의도치 않은 동작을 할 수 있습니다.

for (var i = 0; i < 3; i++) {
  console.log('outer');
  // 위아래 두 `i` 변수는 같은 함수 스코프에서 정의된 같은 변수입니다.
  // 바깥쪽 루프를 한 번 도는 동안, 안쪽 루프를 도느라 이미 `i`의 값이 3이 되어버렸습니다.
  for (var i = 0; i < 3; i++) {
    console.log('inner');
  }
}

위와 같이, var는 코드의 문맥과 잘 맞지 않는 동작을 하기 때문에, 쓰지 않을 수 있다면 쓰지 않는 것이 좋습니다. 변수를 선언할 때에는 const를 애용하시고, 변수에 다른 값을 대입해야 할 일이 생기면 그 때에만 let을 사용하세요.

const let var
스코프 블록 스코프 블록 스코프 함수 스코프
재대입 X O O
재선언 X X O
호이스팅 X X O
사용 권장 1순위 2순위 3순위

전역 변수 (Global Variable)

전역 스코프는 스코프 체인의 가장 바깥쪽에 있는 스코프입니다.

let foo; // `foo`는 전역 스코프에서 선언되었습니다.

if (true) {
  let bar; // `bar`는 블록 스코프에서 선언되었습니다.
}

위 예제에서의 foo와 같이 전역 스코프에서 선언된 변수를 전역 변수(global variable)라고 합니다.4

변수를 명시적으로 전역 스코프에서 선언하지 않더라도, 한 번도 선언되지 않은 이름으로 안쪽 스코프에서 let, const, var를 붙여주지 않고 변수를 선언하면 전역 스코프에 변수가 만들어집니다.

function func() {
  variable = 1; // `variable`이라는 변수가 선언된 적 없으므로, 전역 변수가 됩니다.
}

func();
console.log(variable); // 1

전역 변수는 코드의 어떤 부분에서든 아무런 제한 없이 접근하고 조작할 수 있습니다. 이런 특징이 편하고 좋아보이지만, 음... 강남역 한복판에 누구나 아무런 제한없이 쓸 수 있는 사물함을 둔다고 생각해보세요.

어떤 프로그래밍 언어를 사용하건, 전역 변수에 의존해서 프로그래밍을 하는 것은 굉장히 금기시되는 일입니다. 전역 변수를 남용하다 보면 필시 다음과 같은 어려움을 겪게 됩니다.

  • 전역 변수에는 아무런 제한 없이 접근할 수 있으므로, 프로그램의 크기가 커짐에 따라 변수의 값이 어디서 어떻게 변경될지 예측하기 힙듭니다.
  • 전역 변수를 통해 프로그램의 너무 많은 부분이 결합(coupling)됩니다. 예를 들어, A.js 파일을 고쳤는데 아무런 상관도 없어 보이던 B.js 파일의 코드가 오동작하게 될 수도 있습니다.
  • 코드가 전혀 다른 곳에 위치한 부분에 의존하게 되므로, 전역 변수를 사용한 코드는 이해하기 어렵습니다.

이처럼 전역 변수를 잘못 사용하면 코드를 읽고, 쓰고, 변경하기 어려워집니다.

변수를 선언할 때는 그 변수를 필요로 하는 작은 스코프 안에서만 접근할 수 있도록 하세요. 불가피하게 코드의 여러 부분에서 특정한 값을 공유하고 그 값을 변경해야 할 일이 생긴다면, 전역 변수 대신에 다른 기법5을 활용하세요. 그리고 공유되는 값에 접근할 수 있는 코드의 범위를 최소한으로 줄이고, 그 값은 약속된 방식으로만 변경을 할 수 있도록 제약을 두시기 바랍니다.

전역 객체 (Global Object)

JavaScript 구동 환경은 모두 전역 객체(Global Object)라는 특별한 객체를 갖고 있습니다. 전역 변수가 선언되면, 이 변수는 또한 전역 객체의 속성이 되어 전역 객체를 통해서도 접근할 수 있게 됩니다.

let foo = 1;
window.foo; // 1

전역 객체의 이름은 JavaScript 구동 환경마다 다릅니다.

구동 환경 전역 객체 이름
웹 브라우저 window
웹 워커 self
Node.js global

전역 객체에는 구동 환경에서 유용하게 쓸 수 있는 속성과 함수가 미리 적재되어 있습니다. 예를 들어, 브라우저 환경에서는 서버와의 통신을 위한 fetch 함수가 미리 적재되어 있습니다. Node.js 환경에서는, 모듈을 불러와 사용할 수 있도록 해 주는 require 함수가 미리 적재되어 있습니다.

참조 (Reference)

JavaScript에는 모두 일곱 가지의 타입이 존재합니다.

  • Boolean
  • Null6
  • Undefined
  • Number
  • String
  • Symbol
  • Object

이 중에 Object 타입, 그러니까 객체를 제외하고는 모두 원시 타입(primitive type)으로 불립니다. 객체는 참조 타입(reference type)으로 불립니다. 이렇게 분류를 하는 이유는, 둘 사이에 몇 가지 유의할 만한 차이점이 있기 때문입니다.

여기에서 참조(reference)란, 객체가 컴퓨터 메모리 상에서 어디에 저장되었는지를 가리키는 값입니다. JavaScript에서는 우리가 참조를 직접 읽거나 조작할 수 없습니다. 하지만, 언어를 제대로 이해하기 위해서 참조가 무엇인지 알아야 할 필요는 있습니다.

우리가 객체라고 생각하고 다루어왔던 값은 실제로는 객체에 대한 참조입니다.

const obj = {}; // 변수 `obj`에는 객체에 대한 참조가 저장되었습니다.

객체의 속성에 접근하면, JavaScript 엔진은 참조를 통해 메모리에 저장되어 있는 객체에 접근해서 해당 객체의 속성을 읽습니다. 이런 동작을 가지고 역참조(dereference)라고 합니다.

const obj = {prop: 1};
obj.prop; // `obj`를 통해 역참조된 객체의 속성을 읽어왔습니다.

함수 호출

앞에서, 함수 호출 시에는 인수가 복사되어 매개변수에 대입된다고 설명했습니다. 만약 함수 호출 시에 객체를 인수로 넘긴다면, 이 때 역시 실제로 복사되는 것은 객체 자체가 아니라 참조입니다. 그래서, 우리는 이 참조를 이용해 원본 객체의 내용을 변경할 수 있습니다. 원본이나, 복사된 참조나 같은 객체를 가리키기 때문입니다.

const obj = {};

function addProp(o) {
  o.prop = 1;
}

// 변수 `obj`에 저장되어 있는 참조가 매개변수 `o`에 복사됩니다.
addProp(obj);

console.log(obj.prop); // 1

객체의 같음 (Equality)

우리는 이제까지 === 연산자를 이용해서 두 값이 같은지를 판별해 왔습니다. 그런데 객체에 대해서는 비교 연산 역시 오묘하게 동작합니다. 내용이 같은 두 객체를 === 연산자를 통해 비교해도, 결과값은 false로 나옵니다.

{prop: 1} === {prop: 1}; // false
[1, 2, 3] === [1, 2, 3]; // false

위의 등호 연산자 역시, 객체의 내용을 비교하는 것이 아니라 객체의 참조를 비교합니다. 우리가 생성자나 리터럴을 이용해 객체를 생성하면, 객체는 매 번 새로 생성되어 각각 메모리의 다른 위치에 저장됩니다. 그래서, 내용이 똑같아 보이는 두 객체일지라도 그에 대한 참조는 서로 다른 것입니다.

당연하게도, 두 참조가 정말로 같은 객체를 가리키고 있다면 등호 연산자는 true를 반환합니다.

const obj1 = {};
const obj2 = obj1;
obj1 === obj2; // true

프로그램을 작성하다가 객체에 대한 비교를 하는 코드를 짜고 있는 자신을 발견하게 되면, 지금 객체의 내용을 비교하려고 하는 것인지, 또는 객체의 참조를 비교하려고 하는 것인지를 꼭 생각해보세요. 객체의 내용을 통해 비교하고 싶다면, 깊은 비교 기능을 지원하는 라이브러리를 이용하거나, 정확히 어떤 내용을 비교하고 싶은지를 가지고 함수 혹은 메소드를 작성한 후 그것을 이용하세요.

// 계정 관리 시스템에서는, 사용자의 '아이디'가 같다면 같은 사용자라고 볼 수 있습니다.
function User(id) {
  this.id = id;
}

User.prototype.isEqual = function(other) {
  return this.id === other.id;
}

const john1 = new User('john');
const john2 = new User('john');

john1 === john2; // false
john1.isEqual(john2); // true

불변성 (Immutability)

이제 원시 타입의 특징에 대해서도 살펴보겠습니다.

원시 타입의 값 자체의 내용을 변경할 수 있는 방법은 없습니다. 이런 성질을 불변성(immutability)이라고 하고, "JavaScript의 원시 값은 불변(immutable)이다"라고 말합니다.

예를 들면, 문자열을 변형하는 메소드는 모두 기존 문자열의 내용을 바꾸는 것이 아니라 새 문자열을 반환합니다. 다른 원시 타입의 메소드들도 마찬가지입니다.

const str = 'JavaScript string is immutable!';

str.replace('!', '?'); // 'JavaScript string is immutable?'
str.slice(0, 10); // 'JavaScript'
str.toUpperCase(); // 'JAVASCRIPT STRING IS IMMUTABLE!'

console.log(str); // JavaScript string is immutable!

변수에 저장된 원시 타입의 값을 바꾸려면, 오직 변수에 다른 값을 대입하는 방법밖에 없습니다.

원시 타입을 인수로 해서 함수를 호출할 때에는, 원본이 변경될지도 모른다는 걱정을 할 필요가 없습니다. 값이 불변일 뿐더러, 애초에 함수 호출 시에는 값이 복사되어서 전달되기 때문에 원본을 변경할 수 있는 방법이 아예 없습니다.

let str = 'JavaScript string is immutable!';

function func(s) {
  // 여기서 무슨 짓을 해도, `str`에 새 값을 대입하지 않는 한 원본을 변경할 수 있는 방법은 없습니다.
}

func(str);

객체의 경우를 생각해보면, 객체 자체의 내용을 변경할 수 있는 방법이 얼마든지 많이 있습니다. 따라서 객체는 가변(mutable)입니다.

가변인 값은 어디서 어떻게 변경될지 알 수 없습니다. 변경되지 말아야 할 객체가 있다면, 정말로 변경되지 않도록 신경 써서 코드를 작성해야 합니다. 그러나 객체가 정말로 변경되지 않았는지를 확인하는 일은 쉽지 않아서, 때때로 객체의 가변성 때문에 프로그래밍이 어려워지기도 합니다.

객체의 가변성 때문에 어려움을 겪고 있다면, 두 가지 해결책을 시도해볼 수 있습니다.

먼저 Object.freeze의 사용을 고려해볼 수 있습니다. Object.freeze는 객체를 얼려서 속성의 추가/변경/삭제를 막습니다.

const obj = {prop: 1};

Object.freeze(obj);

// 모두 무시됩니다.
obj.prop = 2;
obj.newProp = 3;
delete obj.prop;

console.log(obj); // { prop: 1 }

다만 Object.freeze를 호출한다고 해서 객체 안에 있는 객체까지 얼려버리지는 않으므로, 중첩된 객체에는 Object.freeze를 사용하기가 조금 까다롭습니다. 그리고, 다음에 소개할 방법과 비교하면 여러모로 편의성이 떨어집니다.

다음으로 Immutable.js 같은 라이브러리의 사용을 고려해보세요. 이런 라이브러리들은 Object.freeze처럼 객체를 정말로 얼려버리지는 않지만, 객체를 마치 불변인 것처럼 다룰 수 있는 방법을 제공합니다. 다시 말하면, 이 객체들은 (문자열의 예에서 봤던 것처럼) 메소드를 통해 내용이 조금이라도 변경되면 아예 새로운 객체를 반환합니다. 즉, 내용이 달라지면 참조 역시 달라지게 되어 객체의 내용이 변경되었는지를 확인하는 작업이 아주 쉬워집니다. 아래는 Immutable.js에서 제공하는 List를 활용한 예제입니다.

import {List} from 'immutable';

// Immutable.js에서 제공하는 `List`는 배열과 유사하지만, 불변인 것처럼 다룰 수 있는 자료구조입니다.
const list = List.of(1, 2, 3);
const newList = list.push(4); // 새 List 인스턴스를 반환합니다.

// 내용이 달라지면, 참조도 달라집니다.
list === newList; // false

특히 React 생태계에서는 Immutable.js가 널리 사용되니, React를 공부하려고 준비중이시라면 불변성 및 관련 라이브러리에 대해 관심을 갖고 살펴볼 필요가 있습니다.

마지막으로, const와 불변성을 잘 구분하시길 바랍니다. const는 '한 번 초기화된 변수에 다른 값을 대입할 수 없다'는 제약을 걸어주는 것이고, 불변성은 '값 자체가 변하지 않는다'는 것입니다. 예를 들어서 const로 선언된 변수에 객체를 대입하면, 이 변수에 새로운 값을 대입할 수는 없지만 이 객체의 내용은 얼마든지 변경할 수 있습니다. 즉, 재대입이 불가능할지라도 가변일 수 있습니다.

const obj = {};
obj.a = 1; // 객체는 가변이므로 내용을 바꿀 수 있습니다.
obj = 1; // 에러! `obj`는 `const`로 선언되었으므로 다른 값을 대입할 수 없습니다.

래퍼 객체 (Wrapper Object)

앞에서 래퍼 객체에 대해 몇 번 언급한 적이 있습니다. 원시 타입의 값은 객체가 아님에도 불구하고, 원시 타입에 점 표기법을 써서 메소드를 호출하거나 속성을 읽어올 수 있는데, 이는 JavaScript가 래퍼 객체(wrapper object)라는 기능을 제공하기 때문입니다.

원시 타입의 값에 대해 속성을 읽으려고 시도하면, 그 값은 그 순간에만 객체로 변환되어 마치 객체인 것처럼 동작합니다.

const s = 'hello';
s.toUpperCase(); // 'HELLO'
s.length; // 5

const n = 1.2345;
n.toFixed(2); // '1.23'

const b = true;
b.toString(); // 'true'

아래는 래퍼 객체를 생성시키기 위해 사용되는 생성자들의 목록입니다.

  • String
  • Number
  • Boolean
  • Symbol

위 생성자들을 이용해 우리가 직접 객체를 생성할 수도 있습니다. 하지만 직접 객체를 생성해주지 않아도 원시 타입의 값에 대해 메소드를 호출하거나 속성을 읽을 수 있기 때문에, 직접 객체를 생성해줄 일은 잘 없습니다.

const stringObj = new String('hello'); // 생성자의 인수로 원시 타입의 값을 넘겨주면 됩니다.

stringObj.toUpperCase(); // 'HELLO'
stringObj.length; // 5

const string = stringObj.valueOf(); // 다시 원시타입의 값으로 되돌리기 위해 `valueOf` 메소드를 호출합니다.

위 생성자들에 대한 MDN 문서를 검색해서, 어떤 메소드와 정적 메소드가 있는지 살펴보세요.

1. 다만, 변수 가리기를 통해 안쪽 스코프에서 같은 이름을 갖는 변수를 선언할 수는 있습니다.
2. 정확한 명칭은 lexical environment 이지만, 좀 더 친숙한 블록 스코프라는 용어가 널리 사용되고 있습니다.
3. function 구문을 통해 선언된 함수, 함수의 매개 변수 등은 var로 선언된 변수와 같은 성질을 갖습니다.
4. 파일의 가장 바깥쪽에서 변수를 선언한다고 해서 그것이 항상 전역 변수가 되는 것은 아닙니다. 모듈 챕터를 참고하세요.
5. 모듈, 클래스, 의존성 주입, Redux, Mobx, ...
6. typeof null === 'object' 이기는 하지만, 이는 '객체의 없음'을 나타내는 null의 역할을 위해 과거의 JavaScript 엔진들이 했던 선택입니다. 명세 상으로는 null 값은 Null이라는 별도의 타입을 갖고 있습니다.

results matching ""

    No results matching ""