eval() 전역 함수

eval() 전역 함수

eval()은 문자열로 표현된 JavaScript 코드를 평가하고 그 완료 값(completion value)을 반환하는 전역 함수입니다. 소스는 스크립트로 파싱됩니다. 기능이 강력하지만, 보안 위험(XSS)과 성능 저하 때문에 사용을 피하고 반드시 필요한 경우에만 신중하게 사용해야 합니다.

출처: eval() - MDN Web Docs

본문

경고: 이 함수에 전달된 인자는 동적으로 파싱되어 JavaScript로 실행됩니다. 이러한 API는 injection sinks로 알려져 있으며, 잠재적으로 교차 사이트 스크립팅(XSS) 공격의 벡터가 될 수 있습니다. 문자열 대신 항상 TrustedScript 객체를 전달하고 trusted types를 강제함으로써 이 위험을 완화할 수 있습니다.

개요

eval() 함수는 문자열로 표현된 JavaScript 코드를 평가하고 그 완료 값을 반환합니다. 소스는 스크립트로 파싱됩니다.

기본적인 사용 예는 다음과 같습니다.

console.log(eval("2 + 2"));
// Expected output: 4

console.log(eval(new String("2 + 2")));
// Expected output: 2 + 2

console.log(eval("2 + 2") === eval("4"));
// Expected output: true

console.log(eval("2 + 2") === eval(new String("2 + 2")));
// Expected output: false

구문(Syntax)

eval(script)

매개변수

  • script — JavaScript 표현식, 문 또는 문의 시퀀스를 나타내는 TrustedScript 인스턴스 또는 문자열입니다. 표현식은 기존 객체의 변수와 프로퍼티를 포함할 수 있습니다. 스크립트로 파싱되므로 import 선언(모듈에서만 존재할 수 있음)은 허용되지 않습니다.

반환값 — 주어진 코드를 평가한 완료 값입니다. 완료 값이 비어 있으면 undefined가 반환됩니다. scriptTrustedScript 또는 문자열 원시 값이 아니면 eval()은 인자를 변경하지 않고 그대로 반환합니다.

예외(Exceptions)

또한 이 메서드는 코드 평가 중 발생하는 모든 예외를 던집니다.

설명(Description)

eval()은 전역 객체의 함수 프로퍼티입니다.

eval() 함수의 인자는 문자열입니다. 이 함수는 소스 문자열을 스크립트 본문으로 평가하는데, 즉 문과 표현식이 모두 허용됩니다. 코드의 완료 값을 반환합니다. 표현식의 경우 그 표현식이 평가되는 값입니다. 많은 문과 선언도 완료 값을 가지지만 그 결과가 예상 밖일 수 있으므로(예: 할당의 완료 값은 할당된 값이지만 let의 완료 값은 undefined임), 문의 완료 값에 의존하지 않는 것이 좋습니다.

strict mode에서 eval이라는 이름의 변수를 선언하거나 eval을 재할당하는 것은 SyntaxError입니다.

"use strict";

const eval = 1; // SyntaxError: Unexpected eval or arguments in strict mode

eval()의 인자가 TrustedScript 또는 문자열이 아니면, eval()은 인자를 변경하지 않고 반환합니다. 다음 예에서 원시 값 대신 String 객체를 전달하면 eval()이 문자열을 평가하는 대신 String 객체를 반환합니다.

eval(new String("2 + 2")); // returns a String object containing "2 + 2"
eval("2 + 2"); // returns 4

이 문제를 일반적으로 해결하려면 eval()에 전달하기 전에 인자를 직접 문자열로 강제 변환할 수 있습니다.

const expression = new String("2 + 2");
eval(String(expression)); // returns 4

직접 eval과 간접 eval(Direct and indirect eval)eval() 호출에는 직접(direct) eval과 간접(indirect) eval 두 가지 모드가 있습니다. 이름에서 알 수 있듯 직접 eval은 eval(...)으로 전역 eval 함수를 직접 호출하는 것을 의미합니다. 별칭 변수, 멤버 접근, 기타 표현식, 또는 선택적 체이닝 ?. 연산자를 통해 호출하는 것을 포함한 그 밖의 모든 호출은 간접입니다.

// Direct call
eval("x + y");

// Indirect call using the comma operator to return eval
(0, eval)("x + y");

// Indirect call through optional chaining
eval?.("x + y");

// Indirect call using a variable to store and return eval
const myEval = eval;
myEval("x + y");

// Indirect call through member access
const obj = { eval };
obj.eval("x + y");

간접 eval은 코드가 별도의 <script> 태그 내에서 평가되는 것처럼 볼 수 있습니다. 이는 다음을 의미합니다.

  • 간접 eval은 로컬 스코프가 아니라 전역 스코프에서 동작하며, 평가되는 코드는 호출되는 스코프 내의 로컬 변수에 접근할 수 없습니다.
function test() {
  const x = 2;
  const y = 4;
  // Direct call, uses local scope
  console.log(eval("x + y")); // Result is 6
  // Indirect call, uses global scope
  console.log(eval?.("x + y")); // Throws because x is not defined in global scope
}
  • 간접 eval은 주변 컨텍스트의 strictness를 상속하지 않으며, 소스 문자열 자체에 "use strict" 지시어가 있는 경우에만 strict mode입니다.
function nonStrictContext() {
  eval?.(`with (Math) console.log(PI);`);
}
function strictContext() {
  "use strict";
  eval?.(`with (Math) console.log(PI);`);
}
function strictContextStrictEval() {
  "use strict";
  eval?.(`"use strict"; with (Math) console.log(PI);`);
}
nonStrictContext(); // Logs 3.141592653589793
strictContext(); // Logs 3.141592653589793
strictContextStrictEval(); // Uncaught SyntaxError: Strict mode code may not include a with statement

반면 직접 eval은 호출 컨텍스트의 strictness를 상속합니다.

function nonStrictContext() {
  eval(`with (Math) console.log(PI);`);
}
function strictContext() {
  "use strict";
  eval(`with (Math) console.log(PI);`);
}
function strictContextStrictEval() {
  "use strict";
  eval(`"use strict"; with (Math) console.log(PI);`);
}
nonStrictContext(); // Logs 3.141592653589793
strictContext(); // Uncaught SyntaxError: Strict mode code may not include a with statement
strictContextStrictEval(); // Uncaught SyntaxError: Strict mode code may not include a with statement
  • 소스 문자열이 strict mode로 해석되지 않으면 var로 선언된 변수와 함수 선언은 주변 스코프로 들어갑니다 — 간접 eval의 경우 전역 변수가 됩니다. strict mode 컨텍스트의 직접 eval이거나 eval 소스 문자열 자체가 strict mode라면 var와 함수 선언은 주변 스코프로 "새어나가지" 않습니다.
// Neither context nor source string is strict,
// so var creates a variable in the surrounding scope
eval("var a = 1;");
console.log(a); // 1
// Context is not strict, but eval source is strict,
// so b is scoped to the evaluated script
eval("'use strict'; var b = 1;");
console.log(b); // ReferenceError: b is not defined

function strictContext() {
  "use strict";
  // Context is strict, but this is indirect and the source
  // string is not strict, so c is still global
  eval?.("var c = 1;");
  // Direct eval in a strict context, so d is scoped
  eval("var d = 1;");
}
strictContext();
console.log(c); // 1
console.log(d); // ReferenceError: d is not defined

평가된 문자열 내의 letconst 선언은 항상 그 스크립트 안으로 범위가 제한됩니다.

  • 직접 eval은 추가적인 컨텍스트 표현식에 접근할 수 있습니다. 예를 들어 함수 본문에서 new.target을 사용할 수 있습니다.
function Ctor() {
  eval("console.log(new.target)");
}
new Ctor(); // [Function: Ctor]

직접 eval()을 절대 사용하지 마십시오! 직접 eval()을 사용하면 여러 문제가 발생합니다.

  • eval()은 호출자의 권한으로 전달된 코드를 실행합니다. 악의적인 당사자의 영향을 받을 수 있는 문자열로 eval()을 실행하면 웹페이지/확장 프로그램의 권한으로 사용자 머신에서 악성 코드를 실행하게 될 수 있습니다. 더 중요하게는, (직접 eval이라면) 제3자 코드가 eval()이 호출된 스코프에 접근하게 하는 것은 로컬 변수를 읽거나 변경하는 공격으로 이어질 수 있습니다.
  • eval()은 JavaScript 인터프리터를 호출해야 하므로 대안보다 느립니다. 많은 다른 구조는 현대 JS 엔진에 의해 최적화됩니다.
  • 현대 JavaScript 인터프리터는 JavaScript를 기계어 코드로 변환합니다. 이는 변수 이름 지정의 개념이 모두 사라지는 것을 의미합니다. 따라서 eval()을 사용하면 브라우저가 변수가 기계어 코드의 어디에 있는지 알아내고 값을 설정하기 위해 길고 비용이 드는 변수 이름 조회를 강제로 수행해야 합니다. 또한 eval()을 통해 변수에 새로운 것이 도입될 수 있어(변수 타입 변경 등) 브라우저가 생성된 기계어 코드를 모두 다시 평가하도록 강제합니다.
  • 미니파이어는 스코프가 eval()에 전이적으로 의존하면 축소(minification)를 포기합니다. 그렇지 않으면 eval()이 런타임에 올바른 변수를 읽을 수 없기 때문입니다.

eval()이나 관련 메서드의 사용을 최적화하거나 완전히 피할 수 있는 경우가 많습니다.

간접 eval() 사용하기 — 다음 코드를 고려해 보십시오.

function looseJsonParse(obj) {
  return eval(`(${obj})`);
}
console.log(looseJsonParse("{ a: 4 - 1, b: function () {}, c: new Map() }"));

간단히 간접 eval을 사용하고 strict mode를 강제하면 코드가 훨씬 좋아질 수 있습니다.

function looseJsonParse(obj) {
  return eval?.(`"use strict";(${obj})`);
}
console.log(looseJsonParse("{ a: 4 - 1, b: function () {}, c: new Map() }"));

위 두 코드 조각은 동일하게 동작하는 것처럼 보일 수 있지만 그렇지 않습니다. 첫 번째(직접 eval 사용)는 여러 문제가 있습니다.

  • 스코프 검사가 많아 훨씬 느립니다. 평가된 문자열에서 c: new Map()을 주목하십시오. 간접 eval 버전에서는 객체가 전역 스코프에서 평가되므로 MapMap이라는 로컬 변수가 아니라 전역 Map() 생성자를 가리킨다고 안전하게 가정할 수 있습니다. 그러나 직접 eval을 사용하는 코드에서 인터프리터는 이를 가정할 수 없습니다.
function looseJsonParse(obj) {
  class Map {}
  return eval(`(${obj})`);
}
console.log(looseJsonParse(`{ a: 4 - 1, b: function () {}, c: new Map() }`));

따라서 eval() 버전의 코드에서 브라우저는 Map()이라는 로컬 변수가 있는지 확인하는 비용이 드는 조회 호출을 강제로 수행해야 합니다.

  • strict mode를 사용하지 않으면 eval() 소스 내의 var 선언이 주변 스코프의 변수가 됩니다. 이는 문자열이 외부 입력에서 얻어질 때 디버깅하기 어려운 문제로 이어집니다. 특히 같은 이름의 기존 변수가 있는 경우 더욱 그렇습니다.
  • 직접 eval은 주변 스코프의 바인딩을 읽고 변경할 수 있어, 외부 입력이 로컬 데이터를 손상시킬 수 있습니다.
  • 직접 eval을 사용할 때, 특히 eval 소스가 strict mode임을 증명할 수 없을 때, 엔진과 빌드 도구는 인라이닝 관련 모든 최적화를 비활성화해야 합니다. eval() 소스가 주변 스코프의 어떤 변수 이름에도 의존할 수 있기 때문입니다.

그러나 간접 eval()을 사용하면 평가된 소스가 읽을 수 있는 기존 전역 변수 외에 추가 바인딩을 전달하지 못합니다. 평가 소스가 접근해야 하는 추가 변수를 지정해야 한다면 Function() 생성자를 사용하는 것을 고려하십시오.

Function() 생성자 사용하기Function() 생성자는 위의 간접 eval 예제와 매우 유사합니다. 전달된 JavaScript 소스를 전역 스코프에서 평가하며 로컬 바인딩을 읽거나 변경하지 않으므로 직접 eval()보다 더 많은 최적화를 엔진이 수행할 수 있습니다.

eval()Function()의 차이는 Function()에 전달된 소스 문자열이 스크립트가 아니라 함수 본문으로 파싱된다는 점입니다. 몇 가지 미묘한 점이 있습니다 — 예를 들어 함수 본문의 최상위에서는 return 문을 사용할 수 있지만 스크립트에서는 사용할 수 없습니다.

Function() 생성자는 변수를 매개변수 바인딩으로 전달해 eval 소스 내에 로컬 바인딩을 만들고 싶을 때 유용합니다.

function add(a, b) {
  return a + b;
}
function runCodeWithAddFunction(obj) {
  return Function("add", `"use strict";return (${obj});`)(add);
}
console.log(runCodeWithAddFunction("add(5, 7)")); // 12

eval()Function() 모두 암시적으로 임의 코드를 평가하며, 엄격한 CSP 설정에서는 금지됩니다. 일반적인 사용 사례에 대해 eval()이나 Function()보다 더 안전하고(또한 더 빠른!) 추가 대안도 있습니다.

대괄호 접근자(Bracket accessors) 사용하기 — 프로퍼티를 동적으로 접근하는 데 eval()을 사용해서는 안 됩니다. 접근할 객체의 프로퍼티가 코드 실행 시점까지 알려지지 않는 다음 예를 고려하십시오. 이것은 eval()로 할 수 있습니다.

const obj = { a: 20, b: 30 };
const propName = getPropName(); // returns "a" or "b"

const result = eval(`obj.${propName}`);

하지만 여기서 eval()은 필요하지 않습니다 — 사실, propName이 유효한 식별자가 아니면 문법 오류로 이어지기 때문에 오류가 더 많이 발생합니다. 더구나 getPropName이 제어하는 함수가 아니면 임의 코드 실행으로 이어질 수 있습니다. 대신 훨씬 빠르고 안전한 프로퍼티 접근자를 사용하십시오.

const obj = { a: 20, b: 30 };
const propName = getPropName(); // returns "a" or "b"
const result = obj[propName]; // obj["a"] is the same as obj.a

이 방법으로 하위 프로퍼티에 접근할 수도 있습니다.

function getDescendantProp(obj, desc) {
  const arr = desc.split(".");
  while (arr.length) {
    obj = obj[arr.shift()];
  }
  return obj;
}

const obj = { a: { b: { c: 0 } } };
const propPath = getPropPath(); // suppose it returns "a.b.c"
const result = getDescendantProp(obj, propPath); // 0

프로퍼티 설정도 비슷하게 동작합니다.

function setDescendantProp(obj, desc, value) {
  const arr = desc.split(".");
  while (arr.length > 1) {
    obj = obj[arr.shift()];
  }
  return (obj[arr[0]] = value);
}

const obj = { a: { b: { c: 0 } } };
const propPath = getPropPath(); // suppose it returns "a.b.c"
const result = setDescendantProp(obj, propPath, 1); // obj.a.b.c is now 1

단, 제약 없는 입력과 함께 대괄호 접근자를 사용하는 것도 안전하지 않다는 점에 유의하십시오 — 객체 주입 공격(object injection attacks)으로 이어질 수 있습니다.

콜백 사용하기 — JavaScript에는 일급 함수(first-class functions)가 있어, 함수를 다른 API의 인자로 전달하고, 변수와 객체의 프로퍼티에 저장하는 등이 가능합니다. 많은 DOM API가 이를 염두에 두고 설계되었으므로 다음과 같이 작성할 수(그리고 해야) 있습니다.

// Instead of setTimeout("…", 1000) use:
setTimeout(() => {
  // …
}, 1000);

// Instead of elt.setAttribute("onclick", "…") use:
elt.addEventListener("click", () => {
  // …
});

클로저(Closures)는 문자열을 연결하지 않고 매개변수화된 함수를 만드는 방법으로도 유용합니다.

JSON 사용하기eval()을 호출하는 문자열이 코드가 아닌 데이터(예: 배열 "[1, 2, 3]")를 담고 있다면, JavaScript 구문의 부분 집합을 사용해 데이터를 표현하게 하는 JSON으로 전환을 고려해야 합니다. JSON 구문은 JavaScript 구문에 비해 제한적이므로 많은 유효한 JavaScript 리터럴이 JSON으로 파싱되지 않는다는 점에 유의하십시오. 예를 들어 JSON에서는 후행 쉼표가 허용되지 않으며 객체 리터럴의 프로퍼티 이름(키)은 따옴표로 묶어야 합니다. 나중에 JSON으로 파싱될 문자열을 생성할 때는 JSON 직렬화 도구를 사용하십시오.

임의 코드 대신 신중하게 제한된 데이터를 전달하는 것은 일반적으로 좋은 생각입니다. 예를 들어 웹페이지 내용을 긁어내도록 설계된 확장 프로그램은 긁기 규칙을 JavaScript 코드 대신 XPath로 정의할 수 있습니다.

보안 고려 사항(Security considerations) — 이 메서드는 호출자의 권한으로 임의 입력을 실행하는 데 사용될 수 있습니다. 입력이 사용자가 제공한 잠재적으로 안전하지 않은 문자열이라면 교차 사이트 스크립팅(XSS) 공격의 가능성 있는 벡터입니다.

예를 들어 다음 코드는 eval()이 사용자가 제공한 untrustedCode를 실행할 수 있는 방법을 보여줍니다.

const untrustedCode = "alert('Potentially evil code!');";
const adder = eval(untrustedCode);

script-src 또는 default-src를 지정하는 Content Security Policy (CSP)가 있는 웹사이트는 기본적으로 이러한 코드 실행을 방지합니다. eval()을 통해 스크립트를 실행하도록 허용해야 한다면, 문자열 대신 항상 TrustedScript 인스턴스를 할당하고 require-trusted-types-for CSP 지시어를 사용해 trusted types를 강제함으로써 위험을 완화할 수 있습니다. 이는 입력이 변환 함수를 통과함을 보장합니다.

eval()이 실행되도록 하려면 CSP script-src 지시어에 trusted-types-eval 키워드를 추가로 지정해야 합니다. unsafe-eval 키워드도 eval()을 허용하지만, trusted types를 지원하지 않는 브라우저에서도 실행을 허용하므로 trusted-types-eval보다 훨씬 덜 안전합니다.

예를 들어 웹사이트에 필요한 CSP는 다음과 같을 수 있습니다.

Content-Security-Policy: require-trusted-types-for 'script'; script-src '<your_allowlist>' 'trusted-types-eval'

예제

TrustedScript 사용하기 — XSS 위험을 완화하려면 항상 TrustedScript 인스턴스를 script 매개변수에 할당해야 합니다. 다른 이유로 trusted types를 강제하면서 (CSP: script-src로) 허용된 일부 스크립트 소스를 허용하고 싶어도 마찬가지입니다. trusted types는 아직 모든 브라우저에서 지원되지 않으므로 먼저 trusted types tinyfill을 정의합니다. 이는 Trusted Types JavaScript API의 투명한 대체 역할을 합니다.

if (typeof trustedTypes === "undefined")
  trustedTypes = { createPolicy: (n, rules) => rules };

다음으로 입력 문자열을 TrustedScript 인스턴스로 변환하는 createScript() 메서드를 정의하는 TrustedTypePolicy를 만듭니다. 이 예제를 위해 변환/필터링 로직을 정의하는 transformedScript() 함수가 있다고 가정합니다.

const policy = trustedTypes.createPolicy("script-policy", {
  createScript(input) {
    const transformed = transformedScript(input); // Our filter method
    return transformed;
  },
});

그런 다음 policy 객체를 사용해 잠재적으로 안전하지 않은 입력 문자열에서 TrustedScript 객체를 만듭니다.

// The potentially malicious string
const untrustedScript = "alert('Potentially evil code!');";

// Create a TrustedScriptURL instance using the policy
const trustedScript = policy.createScript(untrustedScript);

TrustedScript 객체는 이제 eval()에 전달될 수 있습니다.

eval(trustedScript);

eval() 사용하기 — 다음 코드에서 eval()을 포함한 두 문 모두 42를 반환합니다. 첫 번째는 문자열 "x + y + 1"을, 두 번째는 문자열 "42"를 평가합니다.

const x = 2;
const y = 39;
const z = "42";
eval("x + y + 1"); // 42
eval(z); // 42

eval()은 문의 완료 값을 반환eval()은 문의 완료 값을 반환합니다. if의 경우 마지막으로 평가된 표현식 또는 문이 됩니다.

const str = "if (a) { 1 + 1 } else { 1 + 2 }";
let a = true;
let b = eval(str);

console.log(`b is: ${b}`); // b is: 2

a = false;
b = eval(str);

console.log(`b is: ${b}`); // b is: 3

다음 예제는 eval()을 사용해 문자열 str을 평가합니다. 이 문자열은 x가 5면 z에 42를 할당하고, 그렇지 않으면 z에 0을 할당하는 JavaScript 문들로 구성됩니다. 두 번째 문이 실행될 때 eval()은 이 문들을 수행하고, 문 집합도 평가한 뒤 z에 할당된 값을 반환합니다. 할당의 완료 값은 할당된 값이기 때문입니다.

const x = 5;
const str = `if (x === 5) {
  console.log("z is 42");
  z = 42;
} else {
  z = 0;
}`;

console.log("z is ", eval(str)); // z is 42  z is 42

여러 값을 할당하면 마지막 값이 반환됩니다.

let x = 5;
const str = `if (x === 5) {
  console.log("z is 42");
  z = 42;
  x = 420;
} else {
  z = 0;
}`;

console.log("x is", eval(str)); // z is 42  x is 420

함수를 정의하는 문자열로서의 eval()은 접두·접미에 "("와 ")"가 필요 — 함수 선언은 완료 값이 없지만, 함수 표현식은 값을 반환합니다.

// This is a function declaration
const fctStr1 = "function a() {}";
// This is a function expression
const fctStr2 = "(function b() {})";
const fct1 = eval(fctStr1); // return undefined, but `a` is available as a global function now
const fct2 = eval(fctStr2); // return the function `b`

더 알아보기