본문 바로가기
Dev/JS

Javascript 정리 (1)

by HenryNoh 2021. 4. 27.

1. 컴파일러 언어 VS 인터프리터 언어

코딩, 프로그래밍 세계에서 언어는 여러 관점으로 분리가 된다.
저레벨 언어, 고레벨 언어 / 인터프리터 언어, 컴파일러 언어 / 객체지향 언어, 절차 지향 언어, 함수형 언어, 선언형 언어 등이 있다.
또한 함수형 프로그래밍, 반응형 프로그래밍, 논리 프로그래밍 등 다양한 프로그래밍 기법도 있다.

이 수많은 차이점을 가지고 불리는 언어들 중에서 컴파일러 언어와 인터프리터 언어의 차이점을 내 생각대로 쉽게 풀어써보려 한다.

우선 우리가 쓰는 코드라는 것은 기계가 절대로 이해하지 못한다. 이는 마치 한국어를 하나도 모르는 외국인에게 한국어로 말하는 것이나 다름없다. 그렇다면 우리는 그 말을 어떻게 하여 외국인에게 이해를 시킬 수 있을까. 바로 번역기를 사용하는 것이다. 이러한 번역기의 역할을 컴파일러와 인터프리터가 수행하게 되는 것이다.

번역기의 역할을 하는 두 가지의 프로그래밍 방식은 큰 차이를 띄고 있다.


컴파일러 언어는 빠르다!?

C언어, C++에서는 코드를 모두 작성한 후 IDE(혹은 gcc 컴파일러)에서 컴파일을 하게 되면 그에 따른 실행결과가 나오게 된다.
모든 소스코드에 대한 파일들은 컴파일을 진행한 후에 목적 파일이라는 것을 만들어 낸다. 이러한 목적 파일은 기계어에 준하는 이진 코드를 가진 코드로 생성이 되는데 간단하게 이러한 목적 파일들이 기계들이 이해할 수 있는 파일이라고 생각해도 좋다. (물론 더 복잡한 과정을 거쳐 실제로는 기계어로 바뀌게 되고 수행된다.)
목적 파일을 통해 생산된 실행파일이 최종적으로 흔히 Visual Studio 혹은 VSCode에서 보는 터미널 환경이 된다.

컴파일러 언어의 장점은 실행파일의 실행 속도에 있다. 만약 크고 방대한 프로그램이라면 실행파일을 생성하는 과정까지의 시간이 상당히 오래 걸릴 것이다. 또한 만약 실행파일을 만들고 있는 도중에 문제를 발견했다면 실행파일을 처음부터 다시 컴파일해야 하는 번거로움을 가지게 된다. 하지만 그에 반하여 확실한 실행파일을 만들었을 경우에는 인터프리터 언어에 비하여 훨씬 빠른 속도의 수행 능력을 자랑한다.


인터프리터 언어는 느리다!?

Python, Ruby, Scratch, Javascript, SQL 등의 언어는 소스코드를 작성한 후 실행 시에 코드를 한 줄씩 실행하게 된다. 즉 컴파일러 언어에서 모든 코드에 대한 번역을 한 후에 실행을 했다면 인터프리터 언어는 코드 한 줄마다 번역을 한 후 실행을 하게 되는 것이다. 이러한 이유로 인하여 인터프리터 언어는 컴파일러 언어에 비하여 느린 특성을 가지게 되었다.

(실질적으로 코딩테스트의 경우에도 C++과 파이썬의 효율성 테스트에 대한 속도 차이는 엄청나다.)


그렇다면 왜 인터프리터 언어를 쓰는 것일까.

인터프리터 언어의 가장 큰 장점은 프로그램 자체의 수정이 매우 간단하고 빠르다는 것이다. 위에서 말했듯 컴파일러는 실행 파일을 만드는 도중에 수정 사항이 생긴다면 소스 코드를 다시 컴파일을 해야 한다는 문제점이 생긴다. 하지만 인터프리터 언어의 경우 실행 도중에도 수정 사항을 수정한 후에 다시 실행을 시키면 된다는 것이다.

또한 이러한 문제는 보안적인 측면에서도 큰 장점을 가져온다. 이미 한 줄의 소스코드에서 문제가 발생한다면 그 뒤의 소스코드는 실행을 당연하게 멈추기 때문이다. 이러한 장점을 가져와서 만든 언어가 바로 스크립트 언어이고 그중에서 자바스크립트가 대표적이다. 

2. Hoisting이란?


변수의 Hoisting!

결론적으로 Hoisting이란 Javascript는 인터프리터로 실행이 되기 전에 함수 안에서 필요한 변수값들을 모아서 해당 유효 범위의 최상단에 먼저 선언을 해준 후 함수를 실행하는 것을 말한다.

아래와 같은 코드를 실행해보자.

//코드 실행
console.log(test1);
console.log(test2);
console.log(test3);

var test1 = "TEST1";
let test2 = "TEST2";
const test3 = "TEST3";
//Hoisting
var test1;
console.log(test1);
console.log(test2);
console.log(test3);

test1 = "TEST1";
let test2 = "TEST2";
const test3 = "TEST3";

test1은 Hoisting으로 변수 선언이 됐으나 test2는 되지 않음

이와 같이 var변수는 변수의 유효 범위 내에서만 프로그램이 실행되는 순간 먼저 선언만 되는 것이다.


다음 코드를 실행할 경우

console.log(test1);

var test1 = "TEST";

다음과 같은 결과가 나온다.


함수의 Hoisting!

그렇다면 함수는 과연 어떤 Hoisting이 일어날까. 위에서 보았듯 변수의 Hoisting은 아주 간단하고 쉽게 이해할 수 있다.

하지만 Javascript에서 함수의 경우에는 함수를 선언하는 방법이 있고 함수를 표현하는 방법이 있다. 또한 ES6 문법으로 접어들면서 화살표 함수가 생겨나고 다양한 방법으로 함수를 사용한다.

다음 코드처럼 함수를 선언하고 실행시켜보자.

//코드 실행
f1();
f2();

//함수 선언문
function f1() {
  console.log("f1");
}

//함수 표현식
var f2 = function () {
  console.log("f2");
};
//Hoisting
var f2; //변수 선언 Hoisting이 1순위

function f1(){ //함수 선언 Hoisting이 2순위
  console.log("f1");
}

f1();
f2();

f2 = function () {
  console.log("f2");
};

위 에러는 f2가 변수로만 선언이 되었는데 함수 표현으로 실행을 하려니 불가능하다는 뜻이다.

위 두 가지만 제대로 이해하고 있다면 Javascript에서 일어나는 모든 Hoisting에 대해서 이해할 수 있을 것이다.

그리고 솔직히 말하자면 Hoisting이란 개념이 분명 Javascript가 만들어진 당시 인터프리터 언어로 설계할 때 많은 장점을 가지고 만들었을 것이라고 추측은 해본다. 하지만 현재 Javascript의 ES6 문법이 대중화되고 상용화된 이후로 그 누구도 var, let 은 사용하지도 않으며 거의 모든 함수를 const와 화살표 함수로 선언하기에 이제는 Hoisting을 신경 쓰지 않아도 될 정도로 의미가 없어진 것이라고 생각한다.

Hoisting에 대한 더 자세한 정보가 알고 싶다면 다음 블로그의 글을 추천한다.

gmlwjd9405.github.io/2019/04/22/javascript-hoisting.html

 

[JavaScript] 호이스팅(Hoisting)이란 - Heee's Development Blog

Step by step goes a long way.

gmlwjd9405.github.io

(여담이지만 Facebook의 Engineer인  Dan Abramov의 Twitter만 보아도 얼마나 Hoisting이 이제는 의미 없어진 개념인지도 알 수 있을 것이다. twitter.com/dan_abramov/status/1362530955420987396)


3. 컨텍스트

컨텍스트를 번역하면 문맥이다. Javascript라는 언어의 컨텍스트는 전역 컨텍스트, 함수 컨텍스트가 있다.

Javascript의 컨텍스트는 기본적으로 후입 선출 구조를 띄고 있는 스택으로 생성이 된다. (필수로 알고 가야 한다.)


전역 컨텍스트함수 컨텍스트에 대하여 알아보기 위하여 다음의 코드를 보자.

var test1 = 'test1';

function f1() {
  var f1 = 'f1';
  console.log(test1 + ' ' + f1 + ' ' + test2 + ' ' + tes3);
}

var test2 = 'test2';
f1();
var test3 = 'test3';

결과 값은 test1 f1 test2 undefined가 나오게 된다.

어떻게 보면 test1과 test2는 함수 내부에서는 생성돼있지도 않고 심지어 test2는 f1이라는 함수보다 아래에 있는데 어떻게 저런 결과 값이 나오는지 이해가 안 될 수도 있다.

다음 설명에 따라서 천천히 이해보길 바란다.

  1. 우선 Javascript의 코드를 실행하면 1개의 전역 컨텍스트를 생성한다.
    이 전역 컨텍스트는 코드 전체에 있는 변수, 함수를 모두 뽑아내서 저장하게 된다.
  2. 그렇게 되면 전역 컨텍스트에는 test1, f1, test2이라는 변수가 저장된다.
  3. test3라는 변수도 전역 컨텍스트에 저장될 것 같지만!
    인터프리터 언어의 특성을 가진 Javascript는 1줄씩 코드를 읽기 때문에 전역 컨텍스트가 만들어지는 과정에서 함수를 실행하는 구문이 나온다면 함수 컨텍스트를 새로 만들게 된다.
  4. 함수 컨텍스트가 생성된 순간 전역 컨텍스트는 그대로 놔두고 함수를 실행하게 된다.
  5. 만약 함수 컨텍스트에서 사용되는 변수가 해당 컨텍스트 내부에 없을 경우 스택에 쌓여진 구조(스코프 체인)를 따라 상위로 올라가며 해당 변수를 찾게 된다. (스코프 체인에 대한 개념은 별도로 설명)
  6. 따라서 f1이라는 함수가 실행될 때 test1, test2, test3은 전역 컨텍스트에 저장된 값을 참조하게 된다.
  7. 호이스팅이라는 개념에 따라서 test3는 이미 undefined로 선언되었기에 위와 같은 결과가 나온다.

위 과정을 모두 이해했다면 컨텍스트에 대해서 이해를 했을 것이다.


각각의 컨텍스트 안에는 인자, 변수, 스코프 체인, this가 주어진다.

1. 인자

기본적으로 전역 컨텍스트는 인자를 가지고 있지 않다.

  • 전역 컨텍스트 : x
  • f1 함수 컨텍스트 : 인자를 받아오지 않았기에 x

2. 변수

위의 설명에서 보았듯 컨텍스트가 생성되는 안에 있는 모든 변수와 함수를 가져오게 된다. 따라서

  • 전역 컨텍스트 : test1 , f1, test2
  • f1 함수 컨텍스트 : f1

3. 스코프 체인

스코프 체인에 대한 개념이 가장 중요하다. 스코프 체인은 간단하게 생각하면 트리구조의 부모를 가지고 있는 것이다.
위 코드에서는 f1이라는 함수 하나만 있기에 f1 함수는 부모인 전역 컨텍스트의 인자와 변수를 모두 가지고 있게 된다.

  • 전역 컨텍스트 : 전역 컨텍스트의 모든 인자와 변수 (test1, f1, tes2)
  • f1 함수 컨텍스트 : 전역 컨텍스트의 모든 인자와 변수 + f1 함수 컨텍스트의 모든 인자와 변수 (test1, f1, test2, f1)

이와 같은 스코프 체인이라는 개념을 가지고 있기에 f1 함수가 실행될 때 비록 f1 함수 내부에 test1과 test2에 대한 변수를 가지고 있지 않다고 하더라도 출력이 가능한 것이다.

4. this

기본적으로 모든 컨텍스트의 this는 window를 가지고 있다. 하지만 this를 바꾸기 위하여 new를 호출하거나 다른 this 값을 bind 하는 방법이 있다.

(호이스팅과 컨텍스트, 클로저에 대한 더 깊은 설명이 필요하다면 zerocho님의 블로그를 참조 바란다.
www.zerocho.com/category/JavaScript/post/5741d96d094da4986bc950a0)

 

(JavaScript) 실행 컨텍스트 - 클로저와 호이스팅

안녕하세요. 이번 시간에는 범위에 이어 실행 컨텍스트와 클로저에 대해서 살펴보겠습니다. 제가 지난 시간에 실행 컨텍스트가 제일 중요하다고 하면서 강좌를 마쳤습니다. lexical scoping과 이것

www.zerocho.com


4. 클로저 함수!

클로저는 자바스크립트에서 가장 중요한 개념 중 하나이다.
MDN에서 클로저의 정의는 "클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다."라고 말한다.
클로저란 결국 함수 안의 함수를 의미한다. 그러면 함수안의 함수가 뭐가 중요하고 렉시컬 환경이 무엇인지 살펴보자.

function outFunc(){
    var x = 10;
    return function inFunc(){
        console.log(x);
    }
}

var test = outFunc();
test();

위 코드의 출력 값은 10이 나온다.

분명 test라는 변수에 outFunc을 집어넣었고 outFunc은 inFunc이라는 함수를 반환하므로 test에는 inFunc이라는 함수가 들어가 있을 것이다. inFunc 내부의 x는 3번 개념에서 설명한 컨텍스트로 인하여 10이 출력될 것이라고 우리는 알 수 있다.

하지만 위의 예시에서는 이미 outFunc이라는 함수 컨텍스트가 끝난 상황에서 어떻게 test라는 함수를 동작하였는데 10이 나오는가 라는 의문을 가질 수 있다.

이것이 바로 lexical 환경을 이용한 closure의 개념이다. 즉 inFunc이라는 함수가 선언된 그 순간의 환경을 해당 함수 외부에서 호출을 하더라도 기억하는 것을 의미한다. 결국 클로저는 자신이 선언된 순간의 환경을 어디에서든 사용할 수 있는 함수를 의미한다.

여기서 중요한 점은 렉시컬 환경들은 해당 변수들에 대한 참조가 아닌 직접적인 값에 대한 참조가 일어난다. 따라서 우리가 클로저 함수를 사용한 이후에 초기화를 해주지 않는다면 메모리가 남게 될 것이며 이것은 결국 속도 저하와 성능 저하로 따라오게 된다. 따라서 꼭 클로저 함수는 사용 후에 초기화를 해주어야 한다.


5. 원시 타입 vs 참조 타입

Javascript의 Data type에는 2가지가 존재한다. 원시 타입(Primitive Type)과 참조 타입(Reference Type)이다.

원시 타입의 종류에는 number(int, long, float, double), string, boolean, null, undefined 등이 있다.
참조 타입의 종류에는 원시 타입을 제외한 object, array, class, interface, function 등이 있다.

둘의 차이점은 값의 주소에 대한 직접적인 접근을 할 수 있느냐 vs 없느냐로 나뉜다.

원시 타입의 데이터는 변수를 선언하고 할당할 때 메모리 상에서 미리 고정된 크기로 저장이 되며 해당 변수 자체가 데이터를 보관하게 된다. 따라서 원시 타입의 자료형은 변수를 선언, 초기화, 할당하는 모든 동작에서 메모리 영역에 직접적으로 접근하게 된다.

참조 타입의 데이터는 변수를 선언하고 할당할 시에 값이 직접 해당 변수에 저장되지 않으며 변수에는 데이터에 대한 참조만이 저장된다. 따라서 참조 타입의 데이터 변수들은 변수의 값들이 저장된 힙의 메모리 주소 값만을 가지고 있게 된다.


다음과 같은 코드를 살펴보자.

원시 타입 변수1

let test = 1;

function f1(test){
	test += 1;
}

f1(test);
console.log(test);

test의 결과 값은 1이 나오게 된다.

5번째 줄의 f1 함수는 올바르게 test의 값을 찾아서 함수로 넣어주게 된다. (찾은 test의 값은 원시 값)
하지만 f1 함수로 들어가는 값은 test의 원시 값이 아닌 복사된 값을 넣어주게 된다.
따라서 function f1 내부에서 동작하는 test의 값은 let으로 선언된 test의 값에 직접적으로 참조하는 것이 아닌 복사된 새로운 값을 참조하게 되므로 결론적으로 1번째 줄에 선언된 test 변수의 값은 변하지 않게 된다.


원시 타입 변수2

var x = 1;
var y = x;

x = 2;
console.log(x);
console.log(y);

x는 2 y는 1이 나오게 된다.

1번째 줄에서 x는 1이라는 값을 원시 타입의 데이터에 저장해 주었다.
2번째 줄에서 y는 x를 참조하므로 x의 원시 값인 1을 y로 가져오게 된다.
3번째 줄에 원시 타입의 데이터인 x의 값을 직접적으로 2로 변경해주었다.
따라서 x는 2로 바뀌고 y는 그대로 1인 것이다.


참조 타입 변수

var x = { test: 1 };
var y = x;

x.test = 2;
console.log(y.test);

y.count의 결과값은 2가 나오게 된다.

1번째 줄에서 x는 참조 타입에 대한 객체를 선언하였다.
2번째 줄에서 y는 x를 참조하기로 하였다.
3번째 줄에서 x의 test를 2로 바꾸었다.
4번째 줄의 y.test의 y는 이미 x를 참조하고 있으므로 x가 참조하고 있는 x.test를 y.test가 참조하게 된다.


위 내용을 보면 다음과 같은 결과를 알 수 있다.

원시 타입 데이터는 모두 값 그 자체를 넘겨주고 참조 타입 데이터는 모두 참조 그 자체를 넘겨주게 된다.
따라서 원시 타입 데이터를 참조하고 난 이후에 기존의 원시 타입 데이터를 바꿔줄 경우 참조한 데이터의 값은 바뀌지 않으며
참조 타입 데이터를 참조한 이후에 기존의 참조 타입 데이터를 바꿔줄 경우 참조한 데이터의 값은 참조하고 있는 값이 바뀌므로 바뀌게 된다.


6. Call by value vs Call by reference 그리고 Call by ..??

정말 많이 보게 되는 개념 Call by value와 Call by reference이다.

Call by ~~ 는 함수를 호출하는 2가지 방식이다.

일반적으로 모두가 알듯 Call by value는 값을 직접적으로 참조하기에 그 결과값이 바뀌지 않는다.
반면 Call by reference는 참조값을 참조하기에 결과값이 바뀌어서 나오게 된다.


Call by value

var x = 1;

function test(x) {
  x = 2;
}

test(x);
console.log(x);

결과 값은 1로 나오게 된다. 이는 3번에서 보았던 원시 타입의 데이터는 그 값을 가지고 있기 때문에 함수 내에서 아무리 바뀌든 변경되지 않는 것이다.


Call by reference

var x = {};

function test(x) {
  x.y = 2;
}

test(x);
console.log(x.y);

결과 값은 2로 나오게 된다. x라는 데이터 자체가 참조 타입인 객체로 주어졌기에 x의 y를 2로 바꾸어주니 함수 밖에서도 해당 참조 값을 똑같이 보아서 2가 나오게 된다.


Call by ..??

var x1 = {};

function test(x2) {
  x2 = { y: 2 };
}

test(x1);
console.log(x1.y);

위와 정말 단 한 끗 차이가 나는 예제이다. 이 경우에 결과값은 undefined로 나타나게 된다.

  1. 1번째 줄의 x1은 객체 타입의 데이터를 참조하고 있다. (x1 : a  / {} : b)
  2. test 함수에 들어간 x2는 x1과는 다른 공간에서 똑같은 데이터를 참조하고 있다. (x2 : c  / {} : b)
  3. test 함수 안에서는 { y: 2 } 라는 새로운 객체가 생성이 된다. ( { y: 2 } : d)
  4. x2는 {}를 참조하다가 { y: 2 } 가 생성된 시점에서 { y: 2 }를 참조 한다. ( x2 : c / { y: 2 } : d)

따라서 함수 바깥의 x1의 y는 그 값 자체가 없는 것이다.

1번

x1 {}

2번

x1 {}
x2 {}

3번 ~ 4번

x1 {}
x2 { y: 2 }

결론적으로  원시 타입 : Call by value , 참조 타입 : Call by reference 이런 식으로 만 외우면 안 된다는 것이다.

원시 타입이든 참조 타입이든 어떤 식으로 변수가 넘어가고 돌아오는지에 대한 순서가 머릿속에 정확히 있어야지 그 의미를 이해할 수 있을 것이다.

 

댓글