Javascript 렉시컬 스코프와 eval 함수의 문제점

fgStudy·2022년 3월 27일
0

자바스크립트

목록 보기
2/26
post-thumbnail

자바스크립트의 클로저, 스코프, 컨텍스트를 공부하다보면 항상 나오는 이야기들이 있다.

"스코프를 이해하지 못하면 클로저를 이해할수 없다."
"렉시컬 스코프를 이해해야 컨텍스트를 이해할 수 있다."
"클로저를 이해하기 위해서는 우선 실행 컨텍스트를 이해해야 한다."

그렇다면 대체 렉시컬 스코프란 무엇인가?


렉시컬 스코프란 무엇인가?

일반적으로 프로그래밍의 스코프는 두가지 방식으로 작동한다.

  1. 렉시컬 스코프(정적 스코프): 함수를 어디서 선언하였는지에 따라 상위스코프를 결정하는 방식
  2. 동적 스코프: 함수를 어디서 호출하였는지에 따라 상위스코프를 결정하는 방식

자바스크립트를 포함해 대부분의 언어는 렉시컬 스코프를 따른다.

렉시컬 스코프렉싱 타임에 정의되는 스코프를 말한다. 달리 말해 렉시컬 스코프는 개발자가 코드를 짤 때 변수와 스코프 블록( { ... } 등으로 표현되는)을 어디서 작성하는가에 기초해서 렉서가 코드를 처리할 때 확정된다.


렉서는 무엇인가?

전통적인 컴파일러 언어의 처리 과정은 보통 아래의 3단계를 거친다. (자바스크립트는 인터프리터 언어이지만 플랫폼에 따라 엔진 내부에서 컴파일 과정 거친다. )

  1. 토크나이징/렉싱

    • 토크나이징: js code를 분석할 수 있게 토큰화 하는 과정
    • 렉싱: 토크나이징 과정에서 생성된 토큰들을 분석하여 의미를 부여
  2. 파싱

  3. 코드 생성

렉싱 과정을 렉스타임이라 하며, 이 렉스타임에서 스코프가 결정된다. 이때 렉싱을 행하는 개체를 렉서 라고 칭한다.


렉싱

var a = 2; //해당 코드는 다음과 같은 토큰으로 나누어진다.

var //변수 선언 키워드
a //변수 이름
= //대입 연산자
2 //상수 2
; //코드의 끝

렉싱 단계에서 코드는 위와 같이 토큰 단위로 분리되어 각 토큰마다 의미가 매핑된다.

그 이유로 아래 두 코드는 다른 값을 반환한다.

// #1
return 1;

// #2
return
1;

자바스크립트는 세미콜론을 자동 삽입(ASI, automatic semicolon insertion) 해준다.
따라서 #1은 return, 1, ;로 렉싱되고, #2는 return, ;, 1, ;로 렉싱된다. 즉 #2는 컴파일시 return; 1;로 코드 생성된다.

그래서 #1은 1을 반환하고, #2는 undefined를 반환한다(return;은 undefined를 반환한다).


렉시컬 스코프는 변수와 스코프 블록을 어디서 작성되어 있는가에 기초해서 렉서(Lexer) 코드를 처리할 때 확정된다.

예제를 통해 이해해보자.

functio foo(a){
    var b = a * 2;

    function bar(c){
        console.log(a, b, c);
    }

    bar(b * 3);
}

foo(2); //2, 4, 12

이 예제에는 3개의 중첩 스코프가 있다.

  1. [글로벌 스코프]: 해당 스코프 안에는 오직 하나의 식별자 foo만 있다.
  2. [foo 스코프]: foo의 scope을 감싸고 있고, 해당 스코프 안에는 3개의 식별자 a, bar, b를 포함한다.
  3. [bar 스코프]: bar의 scope을 감싸고 있고, 해당 스코프는 하나의 식별자 c를 포함한다. bar의 버블은 foo의 버블 내부에 완전히 포함된다.

이처럼 스코프가 계층적으로 연결된 것을 스코프 체인이라고 한다.


스코프 체인 검색

엔진은 스코프 버블의 구조와 상대적 위치를 통해 어디를 검색해야 식별자를 찾을 수 있는지 안다. 스코프특정 장소에 변수를 저장하고, 그변수를 어떻게 찾을것인지에 대한 규칙이다. 엔진은 목표와 일치하는 대상을 찾는 즉시 검색을 중단한다.

여러 중첩 스코프 층에 걸쳐 같은 식별자 이름을 정의할 수 있고, 이를 섀도잉 Shadowing이라 한다.

섀도잉과 상관없이 변수를 참조할 때 엔진은 스코프체인을 통해 변수를 참조하는 코드의 스코프에서 시작하여 상위 스코프의 방향으로 이동하며 선언된 변수를 검색한다. 검색은 스코프의 목표와 일치하는 대상을 찾는 즉시 중단한다.

예제를 통해 이해해보자.

functio foo(a){
  var b = a * 2;

  function bar(c){
      console.log(a, b, c);
  }

  bar(b * 3);
}

foo(2); //2, 4, 12

bar 스코프 안의 console.log 구문을 살펴보면 3개의 참조된 변수 a, b, c를 검색한다. foo 함수에서 bar함수를 호출하므로 bar 함수로 들어간다. bar 함수는 변수 a,b,c를 참조하는 데 bar 함수에서부터 해당 변수들을 검색한다. bar에서 a,b를 찾지 못했기에 다음으로 가까운 foo()의 스코프로 올라가 검색을 하고, 이곳에서 a, b를 찾는다. a, b를 찾았으므로 검색을 종료한다.

만약 변수 c가 bar()와 foo() 내부에 모두 존재할지라도(섀도잉), bar() 내부에 있는 c를 찾아서 사용하고 foo()에서 찾으러 가지 않는다.


렉싱타임에 결정되는 렉시컬 스코프

조금 전 우리는, 렉시컬 스코프는 렉싱 타임에 생성된다고 이야기했다. 이는 함수(또는 객체, 변수)의 스코프가 렉싱 타임, 즉 실행이 아닌 선언 시점에 결정된다고 이해할 수 있다.

예제를 통해 이해해보자.

//index.js

const a = 1;

export function callA(){
  console.log(a)
}

//app.js
import callA from './index.js'

const a = 4;

callA(); //1

위와 같이 callA 는 실행 당시의 스코프가 아닌 생성 당시의 스코프를 따른다.

렉시컬 스코프렉싱 타임에 생성된 스코프를 정적으로 따르겠다는 것을 뜻한다. 이는 함수가 어디에서 실행되건 선언 당시의 스코프 내에서 변수들을 탐색한다는 이야기이다.


렉시컬 스코프를 수정하는 eval() 함수

에어비엔비의 자바스크립트 컨벤션을 보다보면 절대 eval() 함수를 사용하지 말라는 이야기가 있다.

그 이유는 eval() 함수는 런타임 때 렉시컬 스코프를 수정하기 때문이며, 이는 성능을 떨어트린다.

자바스크립트의 eval 함수는 문자열을 인자로 받아들여 실행 시점의 문자열의 내용을 코드의 일부분처럼 처리한다. 즉, 처음 작성한 코드에 프로그램에서 생성한 코드를 집어넣어 마치 처음 작성될 때부터 있던 것처럼 실행된다. 이 때 JS 인터프리터를 사용해야 하기 때문에 매우 느리다.

이 방식은 수많은 취약점들을 낳을 수 있다. 악의적인 코드를 eval을 통해 사용자의 환경에서 실행시킬 수 있는 통로가 될 수도 있다.

또한 eval 함수는 렉시컬 스코프를 수정한다. 즉 eval 함수는 인자로 받은 문자열을 다시 렉싱, 즉 렉시컬 스코프를 다시 계산하는 과정을 거치게 된다.

아래 코드를 예시로 이해해보자.

// normal code
(() => {
    let a = 1;
    return () => console.log(a);
})()();

// eval code
(() => {
    let a = 1;
    return () => eval('console.log(a)');
})()();

두 코드 모두 숫자 1을 반환한다.
만약 eval이 다시 렉시컬 스코프를 계산해주지 않는다면 아래의 코드는 undefined가 나올 것이다. 이처럼 eval은 런타임 때 인터프리터가 인자로 받은 문자열을 다시 계산하게끔 하여, 해당 함수가 사용된 곳의 스코프를 변경한다.

런타임에 렉시컬 스코프를 변경하는 행위는 정적 스코프라는 렉시컬 스코프의 개념을 깨트리고 성능적으로도 좋지 않은 영향을 끼친다.

성능 비교 사이트 위의 예제 코드들의 성능을 비교해보면 꽤 큰 차이가 있음을 확인할 수 있다.


(docs)
  1. 책 "you don’t know js - 타입과 문법, 스코프와 클로저"
  2. JavaScript, 인터프리터 언어일까?
  3. mdn - eval 함수
profile
지식은 누가 origin인지 중요하지 않다.

0개의 댓글