치춘짱베리굿나이스

콜 스택 (호출 스택) 본문

Javascript + Typescript/이론과 문법

콜 스택 (호출 스택)

치춘 2022. 8. 27. 11:27

콜 스택 (호출 스택)

개념

프로그램이 함수 호출을 추적할 때 사용한다

현재 어떤 함수가 동작하고 있는지, 그 함수 내에서 어떤 함수가 동작하는지, 다음에는 어떤 함수를 동작하여야 하는지 등을 제어한다

스택의 LIFO (후입선출) 특성을 생각하면 호출 스택도 비슷한 원리로 동작함을 알 수 있다

메모리 구조에서 스택이 의미하는 것이 바로 이 콜 스택이다

힙이랑 다른 점

  • 스택은 액세스가 빠르고, 힙은 상대적으로 느리다
  • 힙은 사용자가 직접 변수 할당 및 해제를 관리해줘야 하지만, 스택은 관리할 필요가 없다 (CPU가 알아서 관리해줌)
  • 힙은 메모리 단편화가 일어날 수 있지만, 스택은 그렇지 않다
  • 스택 내의 변수는 해당 스코프 내에서만 접근할 수 있지만, 힙 내의 변수는 (주소값만 알고 있다면) 전역적으로 접근 가능하다

동작 방식

const say = () => {
    return "hi i am mouth";
}

const mouth = () => {
    say();
}

const body = () => {
    mouth();
}

body();

위와 같은 함수 구조가 있다고 생각해보자

 

  1. 스크립트가 함수를 호출하면 인터프리터는 이를 호출 스택에 추가하고, 함수를 수행하기 시작한다
    • 처음으로 함수가 호출되는 body() 에 도달할 때까지, 다른 모든 함수 선언들은 무시하고 지나간다
    • body()가 호출되었으므로, 호출 스택에 body() 가 추가된다 (push)
    • 스택의 상태는 다음과 같다: [body()]
  2. body 함수 내부의 코드를 실행한다
    • 실행하다 보니 새로운 함수 호출인 mouth()를 발견했다
    • mouth() 함수가 호출되었으므로, 호출 스택에 mouth()가 추가된다 (push)
    • 스택의 상태는 다음과 같다: [body(), mouth()]
  3. mouth 함수 내부의 코드를 실행한다
    • 실행하다 보니 새로운 함수 호출인 say()를 발견했다
    • say() 함수가 호출되었으므로, 호출 스택에 say()가 추가된다 (push)
    • 스택의 상태는 다음과 같다: [body(), mouth(), say()]
  4. say 함수 내부의 코드를 실행한다
    • say 함수는 “hi i am mouth” 를 반환하고, 함수가 끝난다
  5. mouth() 함수 내에서 say() 가 호출된 라인으로 돌아온다
    • say 함수는 종료되었으므로 호출 스택에서 제거한다 (pop)
    • 스택의 상태는 다음과 같다: [body(), mouth()]
  6. mouth() 함수의 라인을 마저 수행한다
    • mouth 함수의 마지막 라인까지 수행 후, 함수가 끝난다
  7. body() 함수 내에서 mouth() 가 호출된 라인으로 돌아온다
    • mouth 함수는 종료되었으므로 호출 스택에서 제거한다 (pop)
    • 스택의 상태는 다음과 같다: [body()]
  8. body() 함수의 나머지 라인을 마저 수행한다
    • body 함수의 마지막 라인까지 수행 후, 함수가 끝난다
  9. 스크립트에서 body() 함수를 호출한 라인으로 되돌아온다
    • body 함수는 종료되었으므로 호출 스택에서 제거한다 (pop)
    • 스택은 텅 비게 된다

처음에는 빈 호출 스택으로 시작하고, 모든 함수가 호출될 때마다 호출 스택에 함수가 추가되며, 호출이 완료되면 호출 스택에서 제거되므로 마지막에는 빈 호출 스택으로 코드가 모두 종료된다

호출 스택에 저장되는 것들

  • 함수가 받은 매개변수
  • 함수 내부에서 선언된 지역변수
  • 함수의 스택에 대한 정보
  • 호출이 끝난 뒤 돌아갈 주소값

이러한 함수 호출 정보들을 묶어서 스택 프레임이라고 부르고, 결과적으로 함수가 호출될 때마다 해당 함수의 스택 프레임이 스택에 쌓이는 것이라 볼 수 있다

스택 프레임은 스택에 push 됨으로써 추가되고, pop함으로써 추출된다

ebp, esp

  • ebp: 스택상에서의 베이스 포인터 (기준점)
    • 포인터 주소는 베이스 주소부터 얼마나 떨어져있는지를 이용한다
  • esp: 스택이 현재 가리키고 있는 주소
    • 스택에 값들이 쌓이다 보면 esp는 값들의 크기만큼 증가한다

스택 오버플로우

재귀함수를 돌리다가 무한루프에 걸리면 스택 오버플로우로 프로그램이 뻗어버리는 경우를 종종 봤을 것이다

함수 안에서 함수를 호출하니 스택에는 값이 계속 쌓이는데, 호출이 끝나질 않으니 스택에 쌓인 값이 사라지지 않는다

이렇게 무한정 저장되는 함수 정보가 스택의 총 용량을 넘어서게 되면 힙 영역을 침범하게 되고, 잘못된 영역에 스택 값이 들어가고 있기 때문에 스택 오버플로우가 일어나고 프로그램이 터져버리는 것이다

스택 오버플로우가 발생하면 값이 저장되어서는 안될 곳에 저장되기 때문에 프로그램이 오동작하거나 보안상 건드려선 안될 메모리를 건드리면서 취약점을 가지게 되므로, 특정 언어에서는 스택 오버플로우가 발생하면 프로그램을 강제 종료한다

재귀가 위험한 이유이기도 하다

Tail Call Optimization (TCO)

const foo = () => {
    // 함수의 동작
    // 함수의 동작
    bar();
}

이 예시에서 foo 함수는 맨 마지막에 bar 함수를 호출하고 종료한다

한 함수의 마지막 동작이 다른 함수를 호출하는 것이라면 그 함수는 다른 함수를 호출한 시점에서 이미 종료된 것과 다름없다

따라서 bar 함수를 호출함과 동시에 foo 함수가 사용하는 스택을 (어차피 사용하지 않으므로) 비워준다면 공간을 절약할 수 있다

이것을 Tail Call Optimization이라 부르고, 특정 언어에선 이를 지원해준다

자바스크립트에서의 호출 스택

자바스크립트 엔진은 크게 두 요소로 이루어져 있다

 

  • 메모리 힙 - 자바스크립트에서의 모든 객체는 힙 메모리에 할당된다
    • 참고: 자바스크립트에선 함수나 클래스도 객체이다
  • 호출 스택 - 코드가 실행될 때마다 함수가 쌓이는 공간

자바스크립트는 기본적으로 싱글 스레드 언어이기 때문에 한번에 하나의 작업만을 수행할 수 있다

파일 한두 개짜리 작은 프로그램이야 문제가 안 되겠지만 엄청나게 복잡한 프로그램일 수록 싱글 스레드가 단점으로 다가온다

한 함수에 대한 작업이 너무 오래 걸리게 되면 스택에 쌓여있는 다른 함수들은 실행되지도 않은 채로 무한정 대기할 수밖에 없기 때문이고, 특히 자바스크립트는 브라우저에서 돌아가는 만큼, 이전 작업 때문에 다른 작업 수행이 불가능해진다면 (blocked) 사용자 경험에 아주 좋지 않을 것이다

특히 각 작업이 처리되는 동안 페이지 렌더링이 일시정지된다면, 페이지 로딩에 사용자는 답답함을 느낄 것이다

이를 극복하기 위해 브라우저에서는 비동기 작업 (쉽게 말해서 병렬 작업) 을 가능하게 하도록 API를 제공한다

비동기 콜백 (Asynchronous Callback)

  1. 함수를 순서대로 호출하게 되면 여느때와 비슷하게 호출 스택에 스택 프레임이 쌓인다
  2. AJAX, setTimeout, DOM 이벤트 (onClick, onChange 등) 등 브라우저에서 지원하는 비동기 함수들을 실행하면, 자바스크립트 엔진은 이러한 함수들을 호출 스택에서 Web API로 보낸다
  3. WebAPI로 보내진 함수들은 정해진 시간 또는 이벤트가 발생한 순간에 호출 큐에 적재된다
  4. 호출 큐에 적재된 함수들은 호출 스택이 비게 되면 선입선출 (FIFO) 방식으로 차례로 호출 스택에 쌓여 실행된다
const foo = () => {
    console.log("b");
}

console.log("a");
setTimeout(foo, 1000);
console.log("c")

위와 같은 코드가 있다면, 아래의 순서로 실행된다

  1. console.log(”a”) 가 호출 스택에 들어간다 (push)
  2. console.log(”a”) 가 실행되고, 호출 스택에서 제거된다 (pop)
  3. setTimeout 내의 콜백함수 (foo) 가 호출 스택에 들어간다 (push)
    1. 이때 setTimeout은 자바스크립트에서 처리하지 않고 webAPI로 바로 넘어간다
    2. setTimeoutwebAPI로 넘어갔으므로, 호출 스택에서는 제거된다 (pop)
  4. console.log(”c”) 가 호출 스택에 들어간다 (push)
  5. console.log(”c”) 가 실행되고, 호출 스택에서 제거된다 (pop)
    1. 이 시점에서 호출 스택은 비어있다
  6. 1초의 대기가 끝나고 콜백 함수 foo가 호출 큐로 들어온다
  7. 호출 스택이 비어있으므로 호출 큐에 있던 foo가 호출 스택에 적재된다
  8. foo가 실행되고, 내부의 console.log(”b”) 가 스택에 적재 후 실행되고, 두 함수 모두 pop된다

web API로 넘어가는 비동기 함수들은 반드시 호출 스택이 비어있을 때에만 호출 스택으로 돌아올 수 있으므로, setTimeout의 대기 시간을 짧게 줄인다고 해도 호출 순서는 위와 같이 진행된다

Promise 등 비동기 함수를 사용할 때 처리가 완료될 때까지 async / await / then 등으로 기다리지 않으면 결과값이 undefined로 나오는 이유도 비슷하다

자세한 것은 이벤트 루프와 비동기를 공부하면 심도있게 알 수 있다


참고자료

호출 스택 - 용어 사전 | MDN

코딩교육 티씨피스쿨

코딩교육 티씨피스쿨

[기본개념] 콜 스택(Call Stack) 꿀이해

https://velog.io/@djaxornwkd12/%ED%98%B8%EC%B6%9C%EC%8A%A4%ED%83%9D%EA%B3%BC-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80

[자바스크립트] 비동기 처리 1부 - Callback

스택(Stack)

Comments