JavaScript 살펴보기

응애개발자·2025년 8월 18일

Frontend

목록 보기
4/4

이 글은 알고 있으면 좋은 JavaScript 에 대한 다양한 기능들의 개념을 포함하고 있습니다.

기존에 C++ 과 C# 을 기본으로 많이 사용하다가 JavaScript 를 처음 다루게 되었을 때 큰 혼란이 있었습니다.

아마 많은 분들이 웹 개발을 위해 JavaScript 를 접할 때 이게 왜 돼? 이게 왜 안돼? 같은 일을 겪으시리라 예상합니다.

그럼 여러분들이 알아두시면 좋을 JavaScript 의 특징과 개념들에 대해 한번 알아봐볼까요?


JavaScript 의 실행 원리

🟢 인터프리터 언어 or 컴파일 언어?

기본적으로, JavaScript 가 인터프린터 언어인지 컴파일 언어인지 궁금해하실거라 생각합니다.

인터프리터 언어
소스를 한 줄씩 읽어서 즉시 실행하는 프로그래밍 언어

컴파일 언어
소스 코드를 기계어로 번역하여 실행 파일을 만들고, 이를 실행하는 프로그래밍 언어

초기 JavaScript 엔진은 전통적인 인터프리터 방식으로 작동하였지만,
최근에는 컴파일 방식의 장점과 인터프리팅 방식의 장점을 혼합한 JIT(Just-In-Time) 컴파일러로 JS 코드를 실행합니다.

JavaScript 는 "컴파일 과정에서 문법상 오류를 미리 감지하고 실행 중단 가능 하다" 는 컴파일 언어의 일부 특성도 지니고 있어
컴파일 언어라고 주장하는 의견도 있지만

MDN 의 내용을 보면

JavaScript (JS) is a lightweight interpreted (or just-in-time compiled) programming language with first-class functions.

번역) JavaScript(JS)는 가볍고 인터프리터 방식(또는 JIT, Just-In-Time 컴파일 방식)으로 실행되는 프로그래밍 언어이며, 일급 함수를 지원합니다.

라고 되어있기 때문에 간단히 인터프리터 방식 혹은 JIT 컴파일 방식이구나 라고 알아두시면 좋을 것 같습니다.

그럼 이걸 알아서 어디에 쓸까요?

먼저, JIT 컴파일러의 최적화 방식등을 알아보고 최적화에 방해되지 않는 코드를 작성할 수 있습니다.

JS 의 JIT 컴파일 과정에서는 파라미터의 타입을 추적해서 "이 속성은 항상 Number 을 반환했다" 이런식으로 기록을 남기게 됩니다.

그렇게 되면 “이 함수는 숫자만 처리하니, 숫자 관련 체크를 제거해도 안전하다” 같은 가정을 세우고 최적화를 진행하게 되죠.

이런 식으로 JavaScript 엔진이 사용하는 JIT 컴파일러의 최적화 특성을 알고 있으면 같은 속성에 String 을 저장했다가 Number 를 저장했다가 하는 경우를 최소화하게 될 것입니다.

또한 많은 개발자들이 TypeScript를 사용하는 몇가지 이유들을 이해할 수 있습니다.

인터프리터 특성상 오류가 실행 중에야 드러나는 경우가 많기 때문에,
코드 작성 단계에서 타입 검사를 통해 안정성을 확보하려는 수요가 자연스럽게 생겨난 것이죠.

그리고 일관된 타입 사용을 강제하기 때문에 위의 JIT 최적화에도 간접적으로 도움이 됩니다.

이런식으로 인터프리터 언어의 특징과 JIT 컴파일러의 특징, 추가적으로 JS 를 실행할 엔진의 특징등을 알고 있으면 기본적인 개발의 틀을 잡아가는데 도움이 될 것이라 예상합니다.


JavaScript 의 핵심 특징

🟢 동적 타입과 약한 타입

JavaScript 는 동적 타입 언어입니다.
즉, JavaScript의 변수는 어떤 특정 타입과 연결되지 않으며 모든 타입의 값으로 할당 (및 재할당) 가능하다는 뜻입니다.

let foo = 42; // foo는 이제 숫자입니다
foo = "bar"; // foo는 이제 문자열입니다
foo = true; // foo는 이제 불리언입니다

하나의 변수여러 타입의 값을 할당( 및 재할당 ) 할 수 있죠.

또한, JavaScript 는 약타입 언어이기도 합니다.
즉, 작업에 타입 오류가 발생하는 대신 일치하지 않는 타입이 포함되는 경우 암시적으로 타입 변환이 가능하다는 뜻이죠.

const foo = 42; // foo는 숫자입니다.
const result = foo + "1"; // JavaScript는 foo를 문자열로 강제 변환합니다.
console.log(result); // 421

암시적 형 변환은 매우 편리해 보이지만, 개발자가 의도하지 않을 수 있고 문자열을 숫자로 변환하려는 경우에는 문제가 발생할 수 있습니다.

// 문자열 + 숫자 = 문자열 연결
"5" + 3;     // "53"
"5" - 3;     // 2 (숫자로 변환)
"5" * 3;     // 15 (숫자로 변환)

// 불리언 변환 규칙
Boolean(0);           // false
Boolean("");          // false  
Boolean(null);        // false
Boolean(undefined);   // false
Boolean(NaN);         // false
Boolean("0");         // true (문자열이므로)
Boolean([]);          // true (빈 배열도 truthy)

그리고 위의 코드를 보면 알 수 있듯이 강제 형 변환이 조금 괴랄하기 때문에 주의해서 사용해야 합니다.

이런 특징 때문에 JavaScript 의 실행 원리 에서 언급한것처럼 JavaScript 엔진의 JIT 컴파일러가 런타임에 타입을 지속적으로 추적하고 최적화를 하는 것입니다.

이런 최적화가 없다면 동적 타입의 특징을 가지고 있는 JS 는 매번 타입을 체크하고 변환하는 과정을 거쳐야 합니다.

let value = 42;        // 숫자로 시작
value = "hello";       // 문자열로 변경
value = [1, 2, 3];     // 배열로 변경

// 엔진 입장에서는 value를 사용할 때마다
// "지금 value가 뭘까?" 확인해야 함
console.log(value.length); // 배열의 length? 문자열의 length? 체크 필요

🟢 일급 함수 & 일급 객체

위에서 잠시 언급했듯이
JavaScript 는 일급 함수를 지원합니다.

여기서 "일급" 은 다른 일반적인 변수처럼 다루어지는 것을 의미합니다.
즉, 변수에 할당하거나 인자로 전달하고 반환할 수 있는 것이죠.

JavaScript 에서 모든 값은 "일급" 으로 취급됩니다.

일급 함수

따라서 일급 함수는 함수를 변수에 할당하거나 인자로 전달하고 반환할 수 있다는 것을 의미하죠.

// 함수를 변수에 할당
const foo = () => {
  console.log("foobar");
};
foo(); // 변수를 사용해 호출
// 인자로 함수를 전달
function sayHello() {
  return "Hello, ";
}
function greeting(helloMessage, name) {
  console.log(helloMessage() + name);
}
// `sayHello`를 전달인자로 `greeting` 함수에 전달
greeting(sayHello, "JavaScript!");

이때, 다른 함수의 인자로 전달되는 함수를 "콜백 함수"
함수를 반환하거나 다른 함수를 인자로 전달받는 함수를 "고차함수"
라고 합니다.

위에서는 sayHello 가 콜백함수, greeting 이 고차함수가 되는것이죠.

일급 객체

그렇다면 "일급 객체" 는 뭘까요?
말그대로 일급인 객체를 의미합니다.

여기서 사실 JavaScript 의 함수도 객체이기 때문에 일급 함수는 일급 객체에 포함됩니다.

// 객체를 변수에 할당
var person1 = {
  name: "Chris",
  greeting: function () {
    alert("Hi! I'm " + this.name + ".");
  },
};

객체
JavaScript 에서는 정말 많은 것이 객체입니다.
함수, 배열 모두 객체에 포함되어 있습니다.

// 둘 모두 속성 추가가 가능
let arr = [1, 2, 3];
arr.name = "array";
let func = () => {
  return 1;
}
func.name = "function";

하지만 원시 값은 객체가 아닙니다.

let num = 1;
num.name = "number";
console.log(num.name); // undefined

보다시피 원시 값은 다른 객체처럼 추가 속성을 부여할 수 없죠.
하지만 앞에서 잠깐 언급했다시피, JavaScript 의 모든 값은 일급이기 때문에 원시 값 또한 일급시민입니다.


스코프와 실행 환경

🟢 호이스팅

호이스팅은 JavaScript 에서 매우 중요한 개념입니다.

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

위의 코드를 실행시키면 대부분의 언어에서는 오류가 발생할겁니다.
하지만 JavaScript 에서는

정상적으로 2 가 출력 됩니다.

이게 어찌 된 일일까요??

"호이스팅" 이란 간단히 말해 변수 선언과 함수 선언을 코드의 맨 위로 끌어올리는 현상 을 의미합니다.

console.log(a); // undefined
console.log(b()); // 2
console.log(c()); // Uncaught TypeError: c is not a function

var a = 1;

function b() {
  return 2;
}

var c = function() {
  return 3;
}

위 코드는 호이스팅으로 인해 컴파일이 아래처럼 이루어집니다.

var a;
function b() {
  return 2;
}
var c;

console.log(a); // undefined
console.log(b()); // 2
console.log(c()); // Uncaught TypeError: c is not a function

a = 1;
c = function() {
  return 3;
}

이렇게 보면 출력 결과가 납득이 가지 않나요?

  1. a 는 console.log(a) 후에 값이 할당되므로 undefined 가 출력
    (var 는 호이스팅 될 때 undefined 로 초기화 됩니다.)
  2. b 는 함수 그 자체가 호이스팅 되므로 정상적으로 2 가 출력
  3. c 는 a 와 마찬가지로 console.log(c()) 당시엔 undefined 인데 함수처럼 사용하려고 해서 에러가 발생합니다.

그럼 이제 내부적으로 호이스팅이 어떻게 동작하는지 한번 확인해봅시다!

실행 컨텍스트

MDN Execution model

호이스팅의 동작 원리를 알기 위해선 "실행 컨텍스트" 에 대해 알고 있어야 합니다.
이게 뭐야... 할 수 있겠지만 아주 어려운 개념은 아닙니다.

자바스크립트 엔진은 코드를 실행할 때 실행 컨텍스트를 생성합니다.
실행 컨텍스트는 변수와 함수의 메모리 공간을 미리 확보하고, 관리하죠.

  • 전역 실행 컨텍스트 : 프로그램이 처음 실행될 때 생성됩니다.
  • 함수 실행 컨텍스트 : 함수가 호출될 때마다 새로 생성됩니다.

MDN 의 예시를 한번 봅시다.

function foo(b) {
  const a = 10;
  return a + b + 11;
}

function bar(x) {
  const y = 3;
  return foo(x * y);
}

const baz = bar(7);

위 코드는 아래와 같이 실행 됩니다.

  1. 첫 번째 프레임 (전역 컨텍스트)
    프로그램이 시작되면 전역 실행 컨텍스트가 스택에 올라갑니다.
    이 안에는 foo, bar, baz 같은 전역 변수/함수 정의가 포함되어 있습니다.

  1. 두 번째 프레임 (bar 함수 실행)
    bar 함수가 호출되면 새로운 실행 컨텍스트가 생성되어 스택에 쌓입니다.
  • 매개변수 x = 7
  • 지역변수 y = TDZ 선언 시점에 y = 3
    여기서 x * y 를 계산하면 21 이 되고, foo(21) 을 호출합니다.

  1. 세 번째 프레임(foo 함수 실행)
    foo 가 호출되면 또 하나의 실행 컨텍스트가 생성되어 스택에 추가됩니다.
  • 매개변수 b = 21
  • 지역변수 a = TDZ 선언 시점에 a = 10
    여기서 a + b + 11 = 42 가 계산되고 반환됩니다.

  1. 스택 정리
  • foo 가 값을 반환하면 foo 프레임은 스택에서 제거됩니다.
  • barfoo 의 결과값을 반환하고, 이어서 bar 프레임도 제거됩니다.
  • baz 는 42 라는 값으로 초기화됩니다.
  • 마지막으로 전역 컨텍스트까지 실행을 마치고 제거되면, 스택이 비게 되고 프로그램이 종료됩니다.

위의 예제처럼 실행 컨텍스트를 생성할 때 변수와 함수의 선언을 미리 가져오게 되고
이것이 호이스팅이라고 부르는 것입니다.

TDZ(Temporal Dead Zone)

근데 위에서 TDZ(Temporal Dead Zone) 는 무엇일까요?

이전에 var 는 호이스팅 시 undefined 로 초기화 한다고 했죠?

letconst 의 경우에는 호이스팅이 될 때 이런 변수가 있구나 하고 미리 가져는 오지만 초기화를 하지 않습니다. 따라서 사용하려고 하면 오류가 발생하죠.

TDZ 는 이렇게 호이스팅은 됐지만 사용할 수는 없는 구간을 말합니다.
따라서 const a = 10; 이라는 선언문을 만나기 전까지는 TDZ 상태에 머물러 있다가
선언문을 만났을 때 비로소 초기화가 되어 사용할 수 있게 되는 것이죠.

여기까지 호이스팅에 대해 알아봤습니다.
실제 복잡한 코드를 작성하다보면 호이스팅 때문에 굉장히 헷갈려질 수 있습니다.

그렇기에 var 보단 let 이나 const 를 사용해서 선언되지 않은 상태에서 사용하려고 할 때 오류가 발생하도록 하는것이 더욱 직관적으로 다가올 수 있습니다.

🟢 클로저

MDN Closures

자 아래 코드를 실행하면 어떤 결과가 나타날까요?

function makeFunc() {
  const name = "Mozilla";
  function displayName() {
    console.log(name);
  }
  return displayName;
}

const myFunc = makeFunc();
myFunc();

makeFunc()displayName() 을 리턴합니다.
그럼 const myFunc = makeFunc() 을 하면
displayName()myFunc 에 할당될 것이고
myFunc() 으로 함수 호출을 한다면?

function displayName() {
  console.log(name);
}

결국 위 함수가 실행이 되는건데... 어라? name 이라는 변수는 없는데 오류가 발생하겠네요.

라고 생각하셨다면 경기도 오산입니다.
놀랍게도 Mozilla 가 출력되게 됩니다.

클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합입니다. 간단히 말해 내부 함수에서 외부 함수 범위에 접근을 할 수 있다는 것이죠.

myFunc 가 disdplayName() 뿐만 아니라 displayName() 의 외부 함수 makeFunc() 의 name 또한 접근이 가능하다는 것을 의미합니다.

이것이 어떻게 가능한 것일까요?
클로저도 위의 실행 컨텍스트와 아주 깊은 연관이 있습니다.

function makeFunc() {
  const name = "Mozilla";
  function displayName() {
    console.log(name);
  }
  return displayName;
}

const myFunc = makeFunc();
myFunc();
  1. 전역 실행 컨텍스트 생성

    프로그램이 시작되고 전역 실행 컨텍스트와 전역 렉시컬 환경이 생성됩니다.

렉시컬 환경
눈치 빠른 분들이라면 이전 호이스팅을 설명할 때 보던 이미지와 달리, 렉시컬 환경이라는 것이 있는 것을 알 수 있을것입니다.

사실 이전에는 쉬운 이해를 위해 뺐지만 실행 컨텍스트에서 변수와 함수는 렉시컬 환경이라는 공간에 저장되게 됩니다.

  1. makeFunc() 호출

    displayName 함수가 선언되는 순간, 클로저가 생성됩니다. 이 함수는 **자신이 생성된 렉시컬 환경(makeFunc의 환경)을 참조**합니다. 현 상황에서는 makeFunc 의 렉시컬 환경이 되겠죠.
  2. displayName 반환

    makeFunc가 displayName을 반환하고 실행이 완료됩니다. 실행 컨텍스트는 콜 스택에서 제거되지만, displayName은 여전히 makeFunc의 렉시컬 환경을 참조하고 있습니다.
  3. myFunc() 호출

    myFunc 가 호출됩니다. 새로운 실행 컨텍스트가 생성되고, 클로저를 통해 보존된 name 변수에 접근합니다.

이렇듯 makeFunc 함수는 종료되었지만 displayName 이 클로저로 makeFunc 의 렉시컬 환경을 참조하므로, makeFunc 의 변수에 접근이 가능해지는 것입니다.

그럼 이 클로저를 어디에 활용할 수 있을까요?

활용법 1 : 이벤트에 사용

function makeSizer(size) {
  return function () {
    document.body.style.fontSize = `${size}px`;
  };
}

이렇게 매개변수로 size 를 받아 bodyfontSize 를 바꾸는 함수가 있다고 가정해봅시다.

여러분이 버튼을 눌러서 fontSize 를 바꿀 수 있게 하고 싶다면
아래처럼 할 수 있습니다.

const size12 = makeSizer(12);
const size14 = makeSizer(14);
const size16 = makeSizer(16);

document.getElementById("size-12").onclick = size12;
document.getElementById("size-14").onclick = size14;
document.getElementById("size-16").onclick = size16;

makeSizer 의 인자로 원하는 숫자를 넣습니다.
그럼 makeSizer 함수의 렉시컬 환경에는 size = 12 같은 값이 들어가게 되고,

return 하는 함수는 makeSizer의 렉시컬 환경을 참조하므로
각각의 함수가 서로 다른 size 값을 기억하게 됩니다.

  • size12 함수 : size = 12 를 기억하는 클로저
  • size14 함수 : size = 14 를 기억하는 클로저
  • size16 함수 : size = 16 을 기억하는 클로저

만약 클로저를 사용하지 않는다면

document.getElementById("size-12").onclick = function () {
  document.body.style.fontSize = "12px";
};
document.getElementById("size-14").onclick = function () {
  document.body.style.fontSize = "14px";
};

이렇게 중복된 코드가 늘어나게 됐을겁니다.

활용법 2 : 데이터 은닉화

function createCounter() {
    let count = 0;
    
    return {
        increment: function() {
            count++;
            return count;
        },
        decrement: function() {
            count--;
            return count;
        },
        getCount: function() {
            return count;
        }
    };
}
 
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
console.log(counter.decrement()); // 1

이렇게 createCounter()count 변수에는 직접적으로 접근하지 못하게 하면서,
return 한 increment, decrement, getCount 의 함수들만 사용 가능하게 할 수 있습니다.

🟢 this 바인딩

JavaScript 에서의 this 는 조금 특이하게 동작합니다.

function foo() {
  const a = 10;
  console.log(this.a);
}

foo(); // ?

보통 다른 언어의 경우엔 foo() 의 출력값으로 10 을 기대합니다.
하지만 놀랍게도 위 코드의 결과는 undefined 입니다!

JavaScript 의 this 는 함수가 호출되는 방식에 따라 결정되기 때문인데,
4가지 함수 호출로 인한 방식과 화살표 함수, 이렇게 총 5개의 바인딩 방식이 있습니다.

기본 바인딩

다른 바인딩 규칙 중 해당하는 것이 없을 때 적용되는 기본규칙입니다.
기본 바인딩이 적용될 경우 this 는 전역 객체 에 바인딩 됩니다.
(브라우저일 경우 window, Node.js 환경일 경우 global)

따라서 아까 잠깐 언급했던 코드

function foo() {
  const a = 10;
  console.log(this.a);
}

foo(); // ?

의 출력 결과가 undefined 가 되는것입니다. 전역객체에는 a 라는 프로퍼티가 없기 때문이죠.

전역객체에 a 라는 프로퍼티가 없으면 오류가 떠야지 왜 undfined 를 출력하나요??

JavaScript 의 표준인 EcmaScript 문서를 보면
1. If O does not have an own property with key P, return undefined.
라는 규칙이 있습니다.
에러를 최대한 덜 내기 위해 그렇게 설계한 것이죠!

암시적 바인딩

암시적 바인딩은 함수가 객체의 메서드로서 호출되는 상황에서 this 가 바인딩되는 것을 말합니다.

이때, this 는 함수를 호출한 객체에 바인딩됩니다.

const foo = {
  a: 20,
  bar: function () {
    console.log(this.a);
  }
}

foo.bar(); // 20

this.a 가 객체의 a 를 가리키는 것을 볼 수 있습니다.

명시적 바인딩

명시적 바인딩은 말그대로 명시한 객체에 바인딩시키는 것입니다.
JavaScript 에서는 call(), apply(), bind() 를 통해 명시적으로 바인딩을 할 수 있습니다.

const foo = {
  a: 20,
}

function bar() {
  console.log(this.a);
}

bar.call(foo); // 20
bar.apply(foo); // 20
const foo = {
  a: 20,
}

function bar() {
  console.log(this.a);
}

const bound = bar.bind(foo)

bound(); // 20

new 바인딩

JavaScript 에서는 new 키워드를 사용해 객체를 초기화할 수 있는데,
이때 사용되는 함수를 생성자 함수라고 합니다.

생성자 함수의 this 는 새로 생성된 객체를 가리키게 됩니다.

function Foo() {
  this.a = 20;
}

const foo = new Foo();

console.log(foo.a); // 20

화살표 함수

화살표 함수는 ES6 에 추가되었습니다. 화살표 함수를 만들어낸 이유엔 여러가지가 있지만,
그 중 하나로 JavaScript 의 들쭉날쭉한 this 바인딩을 단순화하려는 목적도 있었습니다.

화살표 함수는 자체 바인딩이 this 에 없고, 상위 스코프의 this 를 그대로 사용합니다.

const foo = {
  a: 20,
  bar: function () {
    setTimeout(() => {
      console.log(this.a);
    }, 1);
  }
}

foo.bar(); // 20

위의 경우에 화살표 함수의 this 는 상위 스코프인 bar 의 this 를 따릅니다.

bar 는 객체의 메서드로써 호출되었기 때문에 암시적 바인딩 이 적용되어 bar 의 this 는 foo 에 바인딩 됩니다.


마치며...

이렇게 JavaScript 의 헷갈릴 수 있는 개념들에 대해 알아보았습니다.
간단하게 설명하고 넘어간 것이 많아 실제로는 더욱 복잡하지만,

제 글을 보고 어느정도 감을 잡을 수 있었으면 좋겠습니다.

참고자료

MDN JavaScript
MDN Data Structures
MDN Object basics
medium understand jit
MDN this

0개의 댓글