YOU DON'T KNOW JS 6장~ 끝까지

박정훈·2022년 11월 9일
1

YOU DON'T KNOW JS

목록 보기
5/6

정말 기대하고 고대하던 스코프 내용이 시작된다! (사실 이미 this와 객체 프로토타입, 비동기와 성능으로 넘어갔다. 1권 다 읽고 게을러서 이제 정리한다.🙃 간단하게만 정리해야징)

스코프란 무엇인가🧐

프로그래밍 언어의 기본 패러다임 중 하나는 변수에 값을 저장하고 저장된 값을 가져다 쓰고 수정하는 것이다.

  • 변수는 어디에 살아있는가? 다른 말로 하면 변수는 어디에 저장되는가?
  • 필요할 때 프로그램은 어떻게 변수를 찾는가?

특정 장소에 변수를 저장하고 나중에 그 변수를 찾는데는 잘 정의된 규칙이 필요하다.
즉, 이런 규칙을 스코프라고 한다. 우왕

자바스크립트는 일반적으로 동적 또는 인터프리터언어로 분류하나 사실은 컴파일러 언어다.

각 단어의 의미를 좀 더 찾아봤다.

인터프리터?

고급 언어로 작성된 원시코드 명령어들을 한번에 한 줄씩 읽어들여서 실행한다.
기계어 명령어들이 만들어지는 컴파일 단계를 거칠 필요가 없기에 프로그램을 즉시
실행시킬 수 있다.

컴파일러?

고급 언어를 실행 프로그램으로 만들기 위해 저급 프로그래밍 언어(어셈블리 언어)로 바꾸는데 사용된다. 실행하기 전에 프로그램 코드를 기계어로 번역하는 것이다. 만약 코드의 양이 많다면 컴파일 하는 시간은 그만큼 오래 걸릴 것이다. 다만 한번 컴파일 되고 난 뒤에는 속도가 빠르다.

근데 잠시만... 여러 글들을 참고한 결과 압도적으로 인터프리터라고 하는 글들이 많다. 흠!
다만 JIT(Just In Time)컴파일러도 알면 좋을 거 같다. JIT컴파일 방식이 나오면서 위 둘의 경계가 흐릿해 지고 있다고 한다.
JIT컴파일러는 실제로 프로그램을 실행하는 시점에 기계어로 번역하는 컴파일 기법이다. 위 두 가지 방식을 혼합한 방식인데, 실행 시점에 인터프리터 방식으로 기계어 코드를 생성하면서 그 코드를 캐싱, 같은 함수가 여러 번 불릴 때 매번 기계어 코드를 생성하는 것을 방지한다. 크롬의 V8에서 JIT컴파일을 지원한다.

전통적인 컴파일러 언어의 처리 과정에서는 프로그램을 이루는 소스 코드가 실행되기 전에 보통 다음과 같은 3단계를 거치는데 이를 컴파일레이션이라고 한다.

토크나이징(Tokenizing)/렉싱(Lexing)

문자열을 나누어 토큰이라 불리는 의미있는 조각으로 만드는 과정이다.
const malza = 19;를 보자.

  • const
  • malza
  • =
  • 19
  • ;

빈칸은 하나의 토큰으로 남을 수도 있고 아닐 수도 있다. 이는 빈칸이 의미가 있느냐, 없느냐에 달렸다.

파싱

토큰 배열을 프로그램의 문법 구조를 반영하여 중첩 원소를 갖는 트리 형태로 바꾸는 과정!
이러한 트리를 AST(Abstract Syntax Tree)라 부른다.

코드 생성

AST를 컴퓨터에서 실행 코드로 바꾸는 과정이다. 이 과정은 언어에 따라 또는 목표하는 플랫폼에 따라 크게 달라진다. const malza = 19;를 나타내는 AST를 기계어 집합으로 바꾸어 실제로 malza라는 변수를 생성(메모리 확보 등)하고 값을 저장한다고 치자.

어떤 자바스크립트 조각이라도 실행되려면 먼저 컴파일되어야 한다.

🙌LHS, RHS

const malza = "malza"

엔진은 위 코드를 두 개의 서로 다른 구문으로 본다. 하나는 컴파일러가 컴파일레이션에서 처리할 구문이고, 다른 하나는 실행 과정에서 엔진이 처리할 구문이다.

컴파일러는 앞서 다뤘듯이 렉싱을 통해 구문을 토큰으로 쪼갠다. 그 후 토큰을 파싱해서 트리 구조로 만든다.

컴파일러가 const malza"를 만나면 스코프에게 변수 malza가 특정한 스코프 컬렉션 안에 있는지 묻는다. 변수 a가 이미 있다면 컴파일러는 선언을 무시하고 지나가고, 그렇지 않으면 새로운 변수 malza를 스코프 컬렉셕 내에 선언하라고 요청한다.

컴파일러는 malza="malza"대입문을 처리하기 위해 나중에 엔진이 실행할 수 있는 코드를 생성한다. 엔진이 실행하는 코드는 먼저 스코프에게 malza라 부르는 변수가 현재 스코프 컬렉션 내에서 접근할 수 있는지 확인하고 가능하면 사용하고, 아니라면 다른곳에서 찾는다.

여기서 엔진은 변수 malza를 찾기 위해 LHS검색을 수행한다. 다른 종류는 RHS라 부른다. Left,Right Hand Side.
방향을 의미하며, 이는 대입 연산의 방향이다.
LHS는 변수가 대입 연산자의 왼쪽, RHS는 대입연산자의 오른쪽에 있을 때 수행한다.

RHS는 왼편이 아닌 쪽이 더 알맞은 표현이다. 가서 값을 가져오라 라는 정도로 이해할 수 있다.

💥오류

RHS 검색이 중첩 스코프 안 어디에서도 변수를 찾지 못하면 ReferenceError를 발생시킨다.

LHS 검색을 수행하여 변수를 찾지 못하고 최상위 층에 도착할 때 프로그램이 Strict Mode로 동작하고 있는 것이 아니라면, 글로벌 스코프는 엔진이 검색하는 이름을 가진 새로운 변수를 생성해서 엔진에게 넘겨준다. Strict Mode라면? 자동으로 생성할 수 없으르모 ReferenceError를 뱉어낸다.

RHS 검색 결과 변수를 찾았지만 이 값으로 불가능한 일을 하려고 한다면 TypeError를 발생시킨다.
함수가 아닌 겂을 함수처럼 실행하거나, null이나 undefined 값을 참조할 때 같은 경우 말이다.

ReferenceError는 스코프에서 대상을 찾았는지와 관계가 있지만, TypeError는 스코프 검색은 성공했으나 그 값을 가지고 불가능한 시도를 한 경우다.

렉시컬 스코프✨

렉시컬 스코프는 렉싱 타임에 정의되는 스코프다. 즉, 렉시컬 스코프는 개발자가 코드를 짤 때 변수와 스코프 블록을 어디서 작성하는가에 기초해서 렉서가 코드를 처리할 때 확정된다.

검색

엔진은 스코프 버블의 구조와 상대적 위치를 통해 어디를 검색해야 확인자를 찾을 수 있는지 안다! 검색은 가장 안쪽의 스코프부터 시작해서 한단계씩 올라가면 수행된다.

스코프는 목표와 일치하는 대상을 찾는 즉시 검색을 중단한다. 여러 중첩 스코프 층에 걸쳐 같은 확인자 이름을 정의할 수 있는데 이를 shadowing이라고 한다. 더 안쪽의 확인자가 더 바깥쪽의 확인자를 가리는 것이다. 당연히 찾으면 검색을 멈출테니까 이를 가린다고 표현한거겠지?

어떤 함수가 어디서 또는 어떻게 호출되는지에 상관없이 함수의 렉시컬 스코프는 함수가 선언된 위치에 따라 정의된다.

👿eval()과 with은 렉시컬 스코프를 속일 수 있다. eval()은 하나 이상의 선언문을 포함하는 코드 문자열을 해석하여 렉시컬 스코프가 있다면 런타임에 이를 수정한다. with는 객체 참조를 하나의 스코프로, 속성을 확인자로 간주하여 런타임에 완전히 새로운 렉시컬 스코프를 생성한다.

이러한 방식들은 기껏 엔진이 컴파일 단계에서 수행한 스코프 검색과 관련된 최적화 작업을 무산시킨다는 단점이 있기에 성능을 떨어트린다.

❌책에서는 eval과 with을 정말 사용하지 말것을 강력하게 권고하고 있다.❌

함수 vs 블록 스코프🥊

스코프는 컨테이너 또는 바구니 구실을 하는 일련의 버블이며 변수나 함수 같은 확인자가 그 안에서 선언된다. 이 버블은 경계가 분명하게 중첩되고, 그 경계는 개발자가 코드를 작성할 때 결정된다.

그렇다면 정확히 어떤 것이 새로운 버블을 만들까?

함수 기반 스코프

function foo(a) {
  var b = 2;
  // ...
  function bar() {
  // ...  
  }
  var c = 3;
}

foo()의 스코프 버블은 확인자 a,b,c와 bar를 포함한다. 선언문이 스코프의 어디에 있는지는 중요치 않다! 스코프 안에 있는 모든 변수와 함수는 그 스코프 버블에 속한다. a,b,c bar 모두 foo()의 스코프 버블에 속하므로 foo() 바깥에서는 이들에게 접근할 수 없다.

일반 스코프에 숨기

숨긴다? 스코프에 숨기는 이유는 무엇일까? 소프트웨어 디자인 원칙인 최소 권한의 원칙과 관련이 있다. 이 원칙은 모듈/객체의 API와 같은 소프트웨어를 설계할 때 필요한 것만 최소한으로 남기고 나머지는 숨겨야 한다는 것을 의미한다.

function doSomething(a) {
  function doSomethingElse(a){
  	return a - 1;  
  }
  var b;
  b = a + doSomethingElse(a * 2)
  console.log(b*3)
}
doSomething(2) // 15

b와 doSomethingElse는 외부에서 접근할 수 없어서 바깥의 영향을 받지 않으며, doSomething()에서 이들을 다룬다.

충돌 회피

같은 이름을 가졌지만 다른 용도로 가진 두 확인자가 충돌하는 것을 피할 수 있다.

스코프 역할을 하는 함수

var a = 2;
(function foo(){
  var a = 3;
  console.log(a);
})();
console.log(a)

위처럼 함수를()로 감싼것을 볼 수 있다. 이 코드는 이제 함수 표현식으로 취급된다. foo는 함수를 둘러싼 스코프에 묶이는 대신 함수 자신의 내부 스코프에 묶였다. (function foo() {...})에서 확인자 foo는 오직 ...가 가리키는 스코프에서만 찾을 수 있다. 이렇게 foo를 자기 내부에 숨기면 함수를 둘러싼 스코프를 불필요하게 오염시키지 않는다.

익명 vs 가명

익명함수 표현식은 쉽게 이력할 수 있다. 그렇지만 몇가지 단점이 있다.

  • 스택 추적 시 표시할 이름이 없어 디버깅이 어려울 수 있다.
  • 이름 없이 함수 스스로 재귀 호출 시 폐기 예정이 arguments.callee 참조가 필요하다.
  • 이름은 기능을 잘 나타낼 수 있는 방법이다. 이름 자체로 함수를 설명하는데 도움이 된다.

인라인 함수 표현식은 매우 효과적이며 유용하다. 함수 표현식을 사용할 때 이름을 항상 쓰는 것이 가장 좋다.

setTimeout(function timeoutHandler() {
	console.log("I waited 1 second!")
}, 1000)

함수 표현식 즉시 호출

함수를 ()로 감싸면 표현식으로 바꾼다. 거기서 마지막에 또 다른 ()를 붙이면 함수를 실행할 수 있다.

스코프 역할을 하는 블록

let

let은 var같이 변수를 선언하는 다른 방식이다. 키워드 let은 선언된 변수를 둘러싼 아무 블록의 스코프에 붙인다. 즉 let은 선언한 별수를 위해 해당 블록 스코프를 이용한다고 말할 수 있다.

let을 이용해 변수를 현재 블록에 붙이는 것은 약간 비명시적이다. 블록 스코프에 사용하는 블록을 명시적으로 생성하면 변수가 어디에 속했는지 훨씬 명료하게 파악할 수 있다.

if (foo) {
  { // explicit block
    // ...
  }
}

ES6에서 let과 함께 const도 추가됐다. 키워드 const 역시 블록 스코프를 생성하지만, 선언된 값은 고정된다.

호이스팅👆

JS의 호이스팅은 정말 유명하다. 실제로 면접에서도 질문 들어왔었고...

console.log(a); // 2
var a = 2;

컴파일러는 두 번 공격한다.🤸‍♀️

컴파일레이션 단계 중에는 모든 선언문을 찾아 적절한 스코프에 연결해주는 과정이 있었다. 이 과정이 렉시컬 스코프의 핵심이다.

변수와 함수 선언문 모두 코드가 실제 실행되기 전에 먼저 처리된다고 보면 된다.

var a;
a = 2;

첫째 구문은 선언문으로 컴파일레이션 단계에서 처리된다. 둘째 두문은 대입문으로 실행 단계까지 내버려둔다.

var a;
a = 2;
console.log(a); // 2

// =========
  
var a;
console.log(a); // undefined
a = 2;

첫째 부분은 컴파일레이션, 둘째 부분은 실행과정이다. 이 과정을 비유적으로 말하면 변수와 함수 선언문은 선언된 위치에서 코드의 꼭대기로 끌어올려진다. 이런 동작을 호이스팅이라고 한다.

호이스팅은 스코프별로 작동한다.

foo();

function foo() {
  console.log(a) // undefined
  var a = 2
}

foo() 내에서 변수 a가 foo()의 꼭대기로 끌어올려진다.

그렇지만 함수 표현식은 다르다.

foo(); // TypeError!

var foo = function bar() {
  // ...
}

foo는 아직 값을 가지고 있지 않은데, foo()가 undefined 값을 호출하려해서 TypeError를 뱉는다.

var foo;
foo(); // TypeError
bar(); // ReferenceError

foo = function() {
  var bar = ...
}

함수가 먼저다

함수와 변수 선언은 모두 끌어올려진다. 그러나 미묘한 차이가 있다! 함수가 먼저 끌어올려지고 다음으로 변수가 올려진다.

블록 내 함수 선언은 지양하는 것이 좋다.

스코프 클로저🙄

클로저는 렉시컬 스코프에 의존해 코드를 작성한 결과로 그냥 발생한다.
클로저는 함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 때에도 이 스코프에 접근할 수 있게 하는 기능을 뜻한다.

function foo() {
  var a = 2;
  function bar() {
    console.log(a)
  }
  return bar;
}

var baz = foo();
baz(); // 2

함수 bar()는 foo()의 렉시컬 스코프에 접근할 수 있고, bar() 함수 자체를 값으로 넘긴다. bar를 참조하는 함수 객체 자체를 반환하는 것이다. 이 경우에 함수 bar는 함수가 선언된 렉시컬 스코프 밖에서 실행됐다.

foo()가 실행된 후에는 foo()의 내부 스코프가 사라졌다고 생각할 것이다. 이것은 엔진이 가비지 콜렉터를 고용해 더는 사용하지 않는 메모리를 해제시킨다는 사실을 알기 때문이다. 더는 foo()의 내용을 사용하지 않는 상황이라면 사라졌다고 보는게 맞다.

그러나 클로저는 그렇게 두지 않아! foo의 내부 스코프는 여전히 사용 중이므로 해제되지 않는다.
그럼 누가 사용하고 있지? 바로 bar() 자신이다. 선언된 위치 덕에 bar()는 foo() 스코프에 대한 렉시컬 스코프 클로저를 가지고, foo()는 bar()가 나중에 참조할 수 있도록 스코프를 살려둔다. bar()는 여전히 해당 스코프에 대한 참조를 가지는데, 이 참조가 바로 클로저다.

마법과 같은(더이상은 마법이 아닌) 스코프까지 끝이났다. 글을 정리하며 책을 다시 읽었는데 확실히 다시 읽으니까 처음 읽었을 때보다 더 이해가 잘된다. 물론 여전히 이해하기 힘든 부분들도 있었고... 나중에 다시 돌아오도록 하고 얼른 2권 읽어야겠다. 그리고 코어 자바스크립트 책도 읽어보고 싶어서 구매했다. 히히 기대된다.

profile
그냥 개인적으로 공부한 글들에 불과

0개의 댓글