General14분 읽기

모던 자바스크립트 Deep Dive 8장-12장 리뷰

5

오늘도 파이팅 야무지게 들어가보자.


8장. 제어문

제어문은 대부분 자바와 비슷해서 가볍게 넘겼다. if, for, while, switch 다 익숙한 구조다.

근데 switch문에서 폴스루(fall-through) 얘기가 나왔을 때 한 번 멈췄다. 자바에서도 break를 빠뜨리면 다음 case로 떨어지는 똑같은데 이걸 어케 활용할까 고민해보면 뭐 아래처럼은 가능할듯..?

근데 가독성 개박살나고 굳이 이렇게 안쓸 것 같다...

hljs js
switch (month) {
  case 1: case 3: case 5: case 7: case 8: case 10: case 12:
    days = 31;
    break;
  case 4: case 6: case 9: case 11:
    days = 30;
    break;
  case 2:
    days = 28;
    break;
}

솔직히 그냥 원래 알던 내용이라 skip


9장. 타입 변환과 단축 평가

명시적 vs 암묵적, 여기까진 같다

개발자가 직접 타입을 바꾸면 명시적 타입 변환(타입 캐스팅), 엔진이 표현식 평가 중에 알아서 바꾸면 암묵적 타입 변환(타입 강제 변환). 여기까진 자바에서도 비슷한 개념이라 별 감흥이 없었다.

문제는 자바스크립트의 암묵적 변환 범위가 자바보다 훨씬 넓다는 거다. 자바에서 int + String하면 문자열 연결이 되긴 하지만, 그 외에는 컴파일러가 막아준다. 자바스크립트는 런타임에서 온갖 변환이 일어나니까 조심해야 할 게 많다.

단축 평가 — "어? 자바랑 다르네"

단축 평가를 읽기 전엔 자바의 &&, ||랑 같을 거라고 생각했다. 자바에서 &&는 왼쪽이 false면 오른쪽을 평가하지 않고 false를 반환한다. ||도 마찬가지로 왼쪽이 true면 바로 true.

근데 자바스크립트는 boolean을 반환하는 게 아니라 피연산자 값 자체를 반환한다. 이게 결정적인 차이였다.

hljs js
'CAT' || 'DOG'   // 'CAT'
false || 'DOG'   // 'DOG'
'CAT' && 'DOG'   // 'DOG'
false && 'DOG'   // false

처음에 이게 좀 혼란스러웠다. 'CAT' && 'DOG'가 왜 'DOG'지? &&는 둘 다 truthy일 때 true를 반환해야 하는 거 아닌가?

다시 읽어보니 이해가 됐다. &&는 "첫 번째 falsy 값을 찾아 반환하고, 없으면 마지막 값을 반환"하는 거다. ||는 반대로 "첫 번째 truthy 값을 반환". boolean이 아니라 값을 다루는 언어라서 가능한 동작이었다.

그래서 이런 패턴이 나온다.

hljs js
// 자바 스타일이라면 이렇게 쓸 텐데
if (done) message = '완료';

// 자바스크립트에선 이렇게도 된다
message = done && '완료';

읽으면서 "이거 직관적인가?" 하는 의문이 들었다. done && '완료'를 처음 보는 사람이 바로 이해할 수 있을까.
한번 익히면 1줄로 줄이는 게 매력적인 건 맞는데, 팀 코드에서 쓸지는 좀 고민이 필요할 것 같다. 가독성이 우선이니까.

null 체크에서 빛나는 단축 평가

진짜 유용하다고 느낀 건 객체의 null/undefined 체크였다.

hljs js
var elem = null;
var value = elem.value;  // TypeError!

// 단축 평가로 안전하게
var value = elem && elem.value;  // null

자바에서 null 체크할 때 if (obj != null) 이런 식으로 매번 분기 태웠던 게 떠올랐다. 물론 자바에도 Optional이 있긴 하지만, 자바스크립트의 이 패턴은 더 간결하긴 하다.

옵셔널 체이닝 ?.과 null 병합 ??

그런데 &&를 이용한 null 체크보다 더 명확한 문법이 있었다.

hljs js
var elem = null;
var value = elem?.value;  // undefined (에러 안 남)

옵셔널 체이닝 연산자 ?.은 좌항이 null이나 undefined면 undefined를 반환하고, 아니면 프로퍼티 참조를 이어간다. && 패턴보다 의도가 명확해서 이쪽이 더 낫겠다 싶었다.

null 병합 연산자 ??도 비슷한 맥락이다.

hljs js
var foo = null ?? 'default string';  // 'default string'

좌항이 null이나 undefined일 때만 우항을 반환한다. 여기서 하나 걸린 게 있었는데, 책에서 "falsy 값이 0이나 ''도 기본값으로 유효하다면 예기치 않은 동작이 발생할 수 있다"는 설명이었다.

처음엔 무슨 말인지 바로 와닿지 않았다. 예를 들어보니까 이해가 됐다.

hljs js
const count = userInput || 10;
// 사용자가 진짜 0을 입력했다면?
const count = 0 || 10;  // 10이 됨. 0이 falsy니까.

||는 falsy 값을 모두 걸러버리기 때문에 0이나 빈 문자열처럼 유효한 값도 날아간다. ??는 null과 undefined만 체크하니까 이런 문제가 없다. 이 차이, 실무에서 버그 만들기 딱 좋은 지점이라 확실히 기억해둬야겠다.


10장. 객체 리터럴 — 자바 객체와 많이 다르다

"원시 값 빼고 다 객체"

자바에서도 참조 타입은 다 객체지만, 자바스크립트에서 "원시 값을 제외한 나머지 값은 모두 객체"라고 딱 잘라 말하는 게 인상적이었다. 함수도, 배열도, 정규식도 다 객체.

hljs js
var person = {
  name: 'Lee',
  age: 20
};

자바에서는 필드라고 부르는 걸, 자바스크립트에서는 프로퍼티라고 부른다. 사소한 차이인데, 용어가 다르니 처음엔 살짝 어색했다.

프로퍼티 키의 규칙 — 따옴표가 필요한 경우

프로퍼티 키가 자바스크립트의 식별자 네이밍 규칙을 따르면 따옴표를 생략할 수 있다. 규칙을 따르지 않으면 반드시 따옴표로 감싸야 한다.

hljs js
var person = {
  firstName: 'Ung-mo',      // 규칙 준수 → 따옴표 생략 가능
  'last-name': 'Lee'         // 하이픈이 빼기 연산자로 해석됨 → 따옴표 필수
};

여기서 한 가지 헷갈렸던 게 있다. "식별자 네이밍 규칙을 안 따르면 키로 못 쓴다"는 건가? 아니다. 키로는 쓸 수 있다. 다만 따옴표로 감싸야 하고, .으로 접근이 안 될 뿐이다. 이 구분이 처음에 좀 모호했다.

마침표 표기법 vs 대괄호 표기법

정리하고 나니 본질은 간단했다.

.은 키 이름을 직접 적는 것이고, []는 키 이름을 문자열이나 변수로 넣는 것이다.

hljs js
person.name        // 키를 직접 적음
person["name"]     // 문자열로 넣음
person[key]        // 변수로 넣음

.은 직접 적는 거니까 자바스크립트 문법 규칙을 따라야 한다. 그래서 숫자로 시작하는 키, 하이픈 있는 키, 공백 있는 키는 .으로 접근할 수 없다. []는 문자열을 넣는 거니까 뭐든 된다.

hljs js
person.first-name     // ❌ first 빼기 name으로 해석
person["first-name"]  // ✅

person.1              // ❌ 문법 에러
person[1]             // ✅

실무에서 자주 쓰일 것 같은 패턴도 하나 눈에 들어왔다.

hljs js
const fields = ["name", "age"];
fields.forEach(key => {
  console.log(person[key]);  // .으로는 절대 못 함
});

키가 동적으로 바뀌는 상황에서는 대괄호가 필수다. 자바에서 리플렉션 쓰듯이 필드명을 문자열로 다루는 느낌이랄까. 훨씬 가볍긴 하다.

함수도 프로퍼티 값이 된다

자바에서는 메서드가 클래스에 속하는 거지, "필드 값으로 함수를 넣는다"는 개념이 좀 낯설다. 자바스크립트에서는 함수가 값으로 취급되니까 프로퍼티 값에 넣을 수 있다. 프로퍼티 값이 함수이면 그걸 메서드라고 부른다.

이게 나중에 일급 함수, 콜백, 클로저 같은 개념으로 이어질 텐데, 10장에서는 "아 함수도 값이구나" 정도만 잡고 넘어갔다.

ES6 프로퍼티 축약 표현

hljs js
const name = "Kim";
const age = 25;

// ES5
const person = { name: name, age: age };

// ES6 — 키와 변수명이 같으면 한 번만
const person = { name, age };

작은 차이지만 코드가 깔끔해진다. 자바에서 new Person(name, age) 쓰듯이, 자바스크립트에선 이렇게 객체를 즉석에서 만든다. 클래스 정의 없이 객체를 바로 찍어내는 게 처음엔 어색한데, 익숙해지면 오히려 편할 것 같기도 하다.


11장. 원시 값과 객체의 비교 — 자바의 기본형/참조형과 닮았지만

원시 값은 변경 불가능하다 (immutable)

원시 타입의 값은 한번 생성되면 변경할 수 없다. 여기서 "변경 불가능"이라는 건 변수가 아니라 값에 대한 말이다. 변수는 재할당으로 얼마든지 바꿀 수 있지만, 메모리에 저장된 원시 값 자체를 직접 고치는 건 안 된다.

hljs js
var str = 'string';
str[0] = 'S';
console.log(str);  // 'string' — 안 바뀜

이걸 보고 자바의 String이 떠올랐다. 자바에서도 String은 불변이고, 수정하면 새 객체가 만들어진다. 비슷한 구조인데, 자바스크립트는 문자열이 원시 타입이라는 점이 다르다.

재할당이 일어나면 기존 메모리 주소의 값을 바꾸는 게 아니라, 새로운 메모리 공간에 새 값을 저장하고 변수가 그쪽을 가리키게 된다. 이 특성을 불변성(immutability)이라고 부른다.

값에 의한 전달 (pass by value)

hljs js
var score = 80;
var copy = score;

score = 100;
console.log(copy);  // 80 — score를 바꿔도 copy는 안 바뀜

자바의 기본형(int, double 등) 동작과 같다. 값 자체가 복사(pass by value)되니까 원본을 바꿔도 복사본은 영향 없다. 여기까진 익숙했다.

그런데 객체를 변수에 할당하면 어떻게 될까. 앞에서 본 것처럼 참조 값(메모리 주소)이 복사된다. 이걸 보고 "pass by reference 아닌가?" 싶을 수 있는데, 책에서는 이 표현을 쓰지 않았다. 엄밀히 말하면 자바스크립트에는 pass by reference가 없다. 객체를 넘기더라도 참조 값 자체를 복사해서 전달하는 거지, 변수의 바인딩 자체를 공유하는 게 아니다.

이게 무슨 차이냐면, 진짜 pass by reference라면 함수 안에서 매개변수에 새 객체를 할당하면 바깥 변수도 바뀌어야 한다. 근데 자바스크립트에서는 그렇지 않다.

hljs js
function replace(obj) {
  obj = { name: 'new' };  // 매개변수에 새 객체를 할당
}

var person = { name: 'Lee' };
replace(person);
console.log(person.name);  // 'Lee' — 안 바뀜!

obj에 새 객체를 넣어도 바깥의 person은 그대로다. objperson의 참조 값을 복사받은 별도의 변수이기 때문이다. 이걸 보면 "참조가 전달된다"보다 "참조 값이 값으로 전달된다"가 더 정확하다. 이 동작을 pass by sharing 또는 call by sharing이라고 부르기도 한다.

자바도 정확히 같은 구조다. "자바는 항상 pass by value이고, 참조 타입의 경우 참조 값이 복사된다"는 말을 예전에 들었는데, 자바스크립트도 동일했다.

다만 책에서 한 가지 재밌는 언급이 있었다. ECMAScript 사양상 "할당 시점에 같은 주소를 참조하다가, 재할당이 일어날 때 비로소 새 공간을 확보할 수도 있다"는 거다. 이건 엔진 구현에 따라 다를 수 있다는 뜻인데, 최적화 관점에서 흥미로운 부분이었다. 자바의 String pool 같은 느낌이랄까.

객체는 변경 가능하다 (mutable)

객체는 원시 값과 반대다. 프로퍼티를 추가하거나 삭제하거나 값을 바꿀 수 있다.

이유가 납득이 갔다. 객체는 프로퍼티 개수에 제한이 없고, 크기가 동적으로 변한다. 원시 값처럼 매번 새 메모리 공간을 확보해서 복사하면 비용이 크다. 그래서 변수에 객체를 할당하면, 변수에는 객체가 저장된 메모리의 참조 값(주소) 이 들어간다.

hljs js
var person = { name: 'Lee' };
var copy = person;

copy.name = 'Kim';
console.log(person.name);  // 'Kim' — 같은 객체를 참조하니까

자바에서 참조 타입이 동작하는 방식과 똑같다. personcopy는 서로 다른 변수지만 같은 객체를 가리키고 있으니, 한쪽에서 수정하면 다른 쪽에서도 보인다.

얕은 복사와 깊은 복사

여기서 궁금해진 게 있었다. 그럼 객체를 "진짜로" 복사하려면 어떻게 해야 하나?

한 줄로 정리하면 이렇다. 얕은 복사는 바깥 껍데기만 새로 만들고, 깊은 복사는 안에 들어 있는 것까지 전부 새로 만드는 것이다.

좀 더 풀어보자. 자바스크립트에서 객체를 복사하는 상황을 생각해보면, 객체 안에 또 다른 객체가 들어 있는 경우가 흔하다.

hljs js
const original = {
  name: 'Lee',
  address: {
    city: 'Seoul',
    zip: '06000'
  }
};

이 상태에서 얕은 복사를 하면 바깥 객체(original)는 새로 만들어지지만, 안에 들어 있는 address 객체는 새로 만들지 않는다. 원본과 복사본이 같은 address를 참조하게 된다.

hljs js
// 얕은 복사 — spread 연산자
const shallow = { ...original };

shallow.name = 'Kim';
console.log(original.name);  // 'Lee' — 원시 값이니까 안 바뀜. OK.

shallow.address.city = 'Busan';
console.log(original.address.city);  // 'Busan' — 같은 객체를 참조하니까 바뀜!

name은 원시 값(문자열)이라 값이 복사됐으니 괜찮다. 근데 address는 객체다. 얕은 복사는 이 참조 값(주소)만 복사하니까, shallow.addressoriginal.address가 같은 객체를 가리킨다. 한쪽을 고치면 다른 쪽도 바뀌는 거다.

이걸 읽었을 때 "아 이거 자바에서 clone() 쓸 때랑 같은 문제네" 싶었다. 자바의 Object.clone()도 기본이 얕은 복사라서, 내부 참조 객체까지 복사하려면 직접 구현해야 했다. 같은 구조의 함정이었다.

그럼 깊은 복사는 어떻게 하냐. 몇 가지 방법이 있다.

hljs js
// 방법 1: JSON 직렬화/역직렬화
const deep1 = JSON.parse(JSON.stringify(original));

// 방법 2: structuredClone (비교적 최근 API)
const deep2 = structuredClone(original);

JSON 방식은 오래전부터 쓰이던 트릭인데, 한계가 좀 있다. 함수, undefined, Symbol, Date 객체, 정규식 같은 건 제대로 복사가 안 된다. JSON으로 직렬화할 수 없는 값들이니까. 예를 들어 Date 객체를 JSON으로 돌리면 문자열이 되어버리고, 함수는 아예 사라진다.

hljs js
const obj = {
  date: new Date(),
  fn: function() { return 1; },
  undef: undefined
};

const copied = JSON.parse(JSON.stringify(obj));
console.log(copied.date);   // "2026-03-31T..." — Date가 아니라 문자열
console.log(copied.fn);     // undefined — 함수가 날아감
console.log(copied.undef);  // 키 자체가 사라짐

이런 걸 보면 JSON 방식이 만능은 아니란 게 바로 느껴진다.

structuredClone은 이런 한계를 상당 부분 해결해주는 브라우저/Node.js 내장 API다. Date, Map, Set, ArrayBuffer 같은 타입도 제대로 복사한다. 다만 함수는 여전히 복사 못 한다. 함수를 프로퍼티로 갖는 객체를 넣으면 에러가 난다.

hljs js
// structuredClone은 Date도 제대로 복사
const deep = structuredClone({ date: new Date(), nested: { a: 1 } });
console.log(deep.date instanceof Date);  // true — Date 객체 유지됨

// 하지만 함수는 안 됨
structuredClone({ fn: () => {} });  // DataCloneError!

정리하면 이렇다.

방법복사 깊이Date/Map/Set함수비고
{ ...obj } / Object.assign얕은 복사 (1단계)참조만 복사참조만 복사가장 간단하고 빠름
JSON.parse(JSON.stringify())깊은 복사❌ 문자열로 변환❌ 사라짐undefined, Symbol도 날아감
structuredClone()깊은 복사✅ 제대로 복사❌ 에러순환 참조도 처리 가능

실무에서 이게 왜 중요한지 아직 직접 부딪히진 않았는데, React 같은 프레임워크에서 상태를 불변으로 관리할 때 "이 객체를 정말 새로 만든 건지, 참조만 복사한 건지"가 렌더링 여부를 결정한다고 한다. 얕은 복사로 중첩 객체를 다루다가 의도치 않게 원본 상태를 오염시키면, 화면은 안 바뀌는데 데이터는 바뀌어 있는 괴상한 버그가 생길 수 있다는 거다.

11장에서 배운 원시 값과 객체의 메모리 구조 차이가 여기서 직접 연결되는 셈이다. 이건 나중에 React를 다루면서 체감할 것 같다.


12장. 함수 — 호이스팅

함수 선언문 vs 함수 표현식

자바에서는 메서드를 정의하는 방법이 하나다. 클래스 안에 쓰면 끝. 자바스크립트에서는 함수를 만드는 방법이 여러 가지라서 처음에 좀 정신없었다.

hljs js
// 함수 선언문
function add(x, y) {
  return x + y;
}

// 함수 표현식
var sub = function (x, y) {
  return x - y;
};

둘 다 함수를 만드는 건데, 차이가 뭘까? 읽어보니 호이스팅 때문에 동작이 달라진다.

함수 호이스팅

hljs js
console.log(add(2, 5));  // 7 — 됨
console.log(sub(2, 5));  // TypeError: sub is not a function

function add(x, y) { return x + y; }        // 함수 선언문
var sub = function (x, y) { return x - y; }; // 함수 표현식

함수 선언문은 코드가 실행되기 전(런타임 이전)에 함수 객체가 먼저 생성된다. 그래서 선언 위치보다 위에서 호출해도 동작한다. "코드 위로 끌어올려진 것처럼" 보이는 거다.

함수 표현식은 다르다. var sub은 호이스팅되지만 undefined로 초기화된다. 함수 객체가 할당되는 건 해당 라인이 실행될 때다. 그러니까 그 전에 sub(2, 5)를 호출하면 undefined(2, 5)를 실행하는 꼴이 되어 TypeError가 난다.

자바에서는 메서드 정의 순서가 호출에 영향을 주지 않으니까 (같은 클래스 안에서는), 이 차이가 꽤 낯설었다.

그래서 뭘 쓰라는 건데

책에서는 함수 호이스팅이 "함수를 호출하기 전에 반드시 선언해야 한다는 규칙을 무시"하기 때문에 함수 표현식 사용을 권장한다고 한다.

이건 공감이 갔다. 코드를 위에서 아래로 읽을 때, 아직 정의되지 않은 함수가 호출되는 건 읽는 사람 입장에서 혼란스럽다. constlet을 쓰면서 변수 호이스팅의 영향을 줄이는 것처럼, 함수 표현식을 쓰면 함수 호이스팅도 막을 수 있다.

다만 현실에서는 함수 선언문을 쓰는 코드베이스도 많으니, 둘 다 알아야 한다는 건 변하지 않는다.

함수는 일급 객체다

자바스크립트의 함수는 값처럼 변수에 할당할 수 있고, 다른 함수의 인수로 전달할 수 있고, 반환값으로 쓸 수 있다. 이게 "일급 객체"라는 뜻이다.

자바 8에서 람다가 도입되면서 비슷한 게 가능해졌지만, 자바스크립트는 태생부터 함수가 일급 객체다. 10장에서 "함수도 프로퍼티 값이 된다"고 적었는데, 이 장에서 그 이유가 나온 셈이다.

call by value, call by reference — 그래서 자바스크립트는 뭔데

함수에 인수를 넘길 때도 11장의 원리가 그대로 적용된다. 여기서 용어를 정리하고 넘어가는 게 좋을 것 같다.

전통적으로 함수 호출 시 인수 전달 방식을 call by value(값에 의한 호출)와 call by reference(참조에 의한 호출)로 나눈다.

  • call by value: 인수의 값을 복사해서 매개변수에 전달. 함수 안에서 매개변수를 바꿔도 원본에 영향 없음.
  • call by reference: 인수의 변수 자체(바인딩)를 전달. 함수 안에서 매개변수에 새 값을 할당하면 바깥 변수도 바뀜.

C++의 참조자(&)나 C#의 ref 키워드가 진짜 call by reference다. 함수 안에서 매개변수에 완전히 다른 값을 넣으면 바깥 변수까지 바뀐다.

자바스크립트는 이 중 어디에 해당할까. 코드를 보면 좀 헷갈린다.

hljs js
function changeVal(primitive, obj) {
  primitive += 100;
  obj.name = 'Kim';
}

var num = 100;
var person = { name: 'Lee' };

changeVal(num, person);

console.log(num);          // 100 — 안 바뀜
console.log(person.name);  // 'Kim' — 바뀜

num은 안 바뀌고 person.name은 바뀌었다. 이것만 보면 "원시 값은 call by value, 객체는 call by reference"라고 생각하기 쉽다. 처음에 나도 그렇게 정리했다.

근데 11장에서 확인했듯이 이건 틀렸다. 객체를 넘길 때도 참조 값(주소)이 복사되어 전달되는 거지, 변수의 바인딩 자체를 넘기는 게 아니다. 진짜 call by reference라면 아래 코드에서 바깥 변수도 바뀌어야 한다.

hljs js
function replace(obj) {
  obj = { name: 'completely new' };  // 매개변수에 새 객체를 할당
}

var person = { name: 'Lee' };
replace(person);
console.log(person.name);  // 'Lee' — 안 바뀜. call by reference가 아닌 증거.

obj에 새 객체를 할당해도 바깥의 person은 그대로다. objperson이 가진 참조 값을 복사받은 별개의 변수이기 때문이다.

그래서 정리하면 자바스크립트는 항상 call by value다. 다만 객체를 넘길 때는 "참조 값"이라는 값이 복사될 뿐이다. 이 동작을 구분해서 call by sharing이라고 부르는 사람도 있다. 자바도 완전히 같은 구조라서, "자바는 항상 call by value"라는 그 유명한 말이 자바스크립트에도 그대로 적용된다.

obj.name = 'Kim'이 원본을 바꾸는 이유는 call by reference여서가 아니라, 복사된 참조 값이 같은 객체를 가리키고 있기 때문이다. 이 차이가 미묘하긴 한데, 한번 잡고 나면 헷갈릴 일이 없다.


남은 질문들

읽으면서 정리가 안 된 것들이 몇 가지 있다.

  • 단축 평가(&&, ||)와 옵셔널 체이닝(?.), null 병합(??)이 공존하는 코드베이스에서 어떤 컨벤션이 주류인지 아직 감이 없다. 실무 코드를 더 봐야 할 것 같다.

  • Function 생성자로 함수를 만들면 클로저가 생성되지 않는다는 언급이 있었는데, 클로저를 아직 제대로 안 다뤄서 이게 왜 중요한지 체감이 안 된다. 나중에 클로저 장에서 다시 돌아와야겠다.


마치며

8장부터 12장까지 읽으면서 느낀 건, 자바스크립트가 "친절한 척하면서 방심하면 물어뜯는 언어"라는 거다. 타입 변환이 암묵적으로 일어나고, 함수가 값이 되고, 호이스팅으로 실행 순서가 뒤집히고. 자바의 엄격함에 익숙한 뇌로는 꽤 불안하다.

근데 반대로 말하면, 이 유연함이 자바스크립트의 표현력이기도 하다. 단축 평가로 null 체크를 한 줄에 하고, 객체를 클래스 없이 바로 만들고, 함수를 변수에 담아 넘기고. 이런 게 되니까 프론트엔드에서 자바스크립트가 살아남은 거겠지.

다음은 13장 스코프부터다. 여기서부터 실행 컨텍스트로 이어지면서 자바스크립트의 진짜 복잡한 부분이 시작된다고 하는데, 좀 긴장된다.

Comments 0

0/500

No comments yet. Be the first to leave one.

인기 글