명령형 프로그래밍, 스코프와 콜 스택

스코프와 콜 스택에 대해서 알아보고, 가계부 프로그램을 만들며 명령형 프로그래밍에 대해 알아봅니다.
스코프, 콜 스택, 명령형 프로그래밍


스코프

코드상에서 이름(변수)들에 접근 가능한 유효범위를 스코프(Scope)라고 합니다.

a = [1, 2, 3];
function b() {
    return 1;
}
console.log(123);

지금까지 작성하던 방식으로 생성된 a나 b와 같은 이름 또는 console.log와 같은 이미 정의된 이름들은 기본적으로 전역 스코프(global scope)에 존재하게 됩니다. 전역 스코프는 모든 코드 위치에서 접근 할 수 있으며, 명시적으로 제거하지 않는 이상은 그 값이 프로세스가 종료 될 때까지 메모리에서 해제되지 않습니다. 또한 전역 스코프에 존재하는 이름을 전역 변수라고 부르기도 합니다.

전역 변수와 변수 해제

a = [1,2,3];
function printA() {
    console.log(a); // 함수 안에서도 전역 변수에 접근 할 수 있습니다.
}

printA(); // [1,2,3]

delete a; // 또는 a = null; a = undefined; 를 통해서 명시적으로 값을 해제 할 수 있습니다.

printA(); // 오류! Uncaught ReferenceError: a is not defined

지역 변수

그리고 특정 코드 위치에서만 지엽적으로 접근 할 수 있는 스코프를 지역 스코프, 그 변수를 지역 변수라고 부릅니다. 어떤 변수가 지역 스코프에 위치하게 되는가는 프로그래밍 언어별로 미세한 차이가 있을 수 있습니다. JavaScript에서는 함수 안에서 var 키워드를 통해 정의된 변수가 지역 스코프에 위치하게 됩니다. 또한 일반적으로 지역 변수는 함수가 종료됨과 동시에 해제됩니다.

function inner() {
    var a = 10; // inner함수 안에서만 접근 할 수 있는, 지역 변수를 생성합니다.
    b = 20; // 함수 안에서도 var 키워드 없이 변수에 접근하면 전역 변수로 처리됩니다.
}
d = 30; // 전역 변수로 처리됩니다.
var c = 40; // 함수 밖에서 var 키워드로 정의된 변수도 다를 바 없이 전역 변수로 생성됩니다.

inner();
console.log(b); // 20
console.log(a); // 오류! Uncaught ReferenceError: a is not defined

왜, 지역 변수라는 개념이 필요할까? 라고 물으시면 함수의 재사용성을 살리기 위해서, 메모리를 효율적으로 이용하기 위해서 라고 말 할 수 있겠습니다. 전역 스코프, 지역 스코프의 개념 없이 단일한 전역 스코프만 존재한다면, 수 많은 이름들이 함수가 실행될 때마다 생성되고, 덮어 씌워지고는 등, 이름들이 서로 뒤섞이게 될 것입니다.

전역 변수만을 이용한다면…

function aa() {
    a = 30;
    b = 40;
    return a + b;
}
function bb(x, y) {
    a = x + y;
    b = x - y;
    return a*b;
}

a = 10;
b = 20;
c = aa();
bb(a, b);

전역 스코프만으로는 프로그램을 안전하게 작성 할 수가 없습니다. 또한 코드의 흐름을 읽기도 힘이 듭니다. 이는 서로 할 일을 나누어서 자기일만 확실히 한다는 추상화, 캡슐화 원칙에도 어긋납니다. 또한 함수의 인자로 쓰이는 이름이 전역 스코프에 이미 존재하는 이름이라면 어떨까요?

a = 50;
function inc(a) {
    return a + 1;
}
inc(100);

전역 스코프만 존재한다면 inc 함수가 실행되면서 인자 a와 밖에서 이미 정의된 a라는 이름이 충돌하게 됩니다. 함수의 실행과 지역 스코프에 대해서 자세히 알아보도록 하겠습니다.

콜 스택

프로세스 메모리 모식도

function x(a, b, c) {
  console.log(a+10, b, c+20);
}
a = 10;             
b = "안녕";
for (i=0; i<10; i++) a++;
x(a, b, 100);

/*
STACK-------
...

HEAP--------
...

DATA-------
a=10, b="안녕", i=0

CODE--------
x: function ...
for ...
*/

JavaScript는 스크립트 언어이면서 또 고수준으로 추상화된 언어이기 때문에, 실제 메모리 상태는 일반적인 프로세스를 모식한 위 그림과는 큰 차이가 있습니다. 실제로 JavaScript의 함수 콜에 따른 실행 문맥(Execution Context)의 변동과 스코프라는 개념은 저수준에서 작동하지 않습니다. 여기에서는 저수준에서 프로세스의 개념을 이해할 수 있도록 스코프를 메모리의 스택 프레임에 비유하여 설명합니다. 자세한 내용이 궁금하신 분들은 고급 프로그래밍 언어의 컴파일, JavaScript의 실행 문맥에 대해서 더 공부해보시길 바랍니다.

플랫폼을 떠나서 모든 프로그램에는 진입점(Entry Point)이라는 위치가 존재합니다. C나 Java의 main, Node.js나 JavaScript의 스크립트 첫 라인 등, 플랫폼마다 그 구현 방식과 추상화의 정도에는 차이가 존재 할 수 있지만, 현대적 컴퓨터의 모든 프로그램은 구조적으로 동일하며 운영체제가 실행할 프로세스의 첫번째 코드 위치를 진입점이라고 합니다. 프로세스는 진입점에서부터 순차적으로 코드들을 수행하면서 메모리의 DATA 영역에 할당된 값들을 조작해 나갑니다. 이 때 이 DATA 영역을 전역 변수들이 존재하는 메모리 공간, 즉 전역 스코프라고 생각하셔도 좋겠습니다.

JavaScript 콜 스택 모식도JavaScript 콜 스택 모식도

그러던 도중에 함수를 실행하는 구문을 만나면, 즉 함수를 콜하게 되면, 메모리의 STACK 영역에 함수의 인자로 전달된 값들을 복사하며 스택 프레임(Stack Frame)이라는 영역을 할당합니다. 실제 스택 프레임에는 인자들 뿐만 아니라 함수가 종료(return)되면 다시 돌아갈 코드의 메모리 번지에 대한 정보 또한 포함되어 있습니다. 이 때 생긴 스택 프레임을 바로 지역 스코프라고 생각하셔도 좋겠습니다.
이렇게 함수가 실행되면서 인자로 전달되는 값들이 새로운 스택 프레임에 복사되기 때문에 함수 안에서의 a와 함수 밖의 a는 서로 다른 메모리 번지를 가리키게 됩니다.

JavaScript 스코프 체이닝JavaScript 스코프 체이닝

코드에서 변수를 참조하는 경우엔 코드가 실행되는 문맥에서 가장 가까운 스코프의 변수를 가리키게 됩니다. 이 때문에 변수명이 중복되는 경우엔 가장 가까운 지역 변수를 참조하게 되며, 변수명이 존재하지 않는 경우엔 이름을 찾을 때까지 스코프를 거슬러 올라갑니다. 최종적으로 전역 스코프에도 그 이름이 존재하지 않으면 오류가 발생합니다. 이러한 메커니즘을 스코프 체이닝(Scope Chaining)이라고 합니다.

함수의 실행과 새로운 스택 프레임 생성

function x(a, b, c) {
  console.log(a+10, b, c+20);
}
a = 10;             
b = "안녕";
for (i=0; i<10; i++) a++;
x(a, b, 100);

/*
STACK-------
x: a=20, b="안녕", c=100
console.log: 30, "안녕", 120

DATA--------
a=20, b="안녕", i=10
*/

그리고 함수가 종료되면서 함수가 실행된 순서의 역순으로 스택 프레임이 해제됩니다. 이를 통해서 지역 스코프의 변수 또한 해제됩니다.

사실 JavaScipt와 같은 고수준 언어에서는 지역 변수를 해제하는 과정에 조금 더 복잡한 메커니즘을 갖고 있습니다. 추후에 클로저라는 함수의 형태와 프로세스의 메모리를 관리하는 Garbage Collector라는 개념에 대해서 다룹니다.

명령형 프로그래밍

프로그래밍은 기본적으로 값(상태)을 변경시키는 명령문의 순차적인 나열입니다. 여기에 조건문 반복문 등의 제어 구문을 통해서 복잡한 로직을 표현 할 수 있습니다. 또한 초기 값을 다르게 반복되는 코드는 함수를 통해 추상화 할 수 있습니다.

…, 명령형 프로그래밍(Imperative Programming)은 … 프로그래밍의 상태와 상태를 변경시키는 구문의 관점에서 연산을 설명하는 프로그래밍 패러다임의 일종이다. 자연 언어에서의 명령법이 어떤 동작을 할 것인지를 명령으로 표현하듯이, 명령형 프로그램은 컴퓨터가 수행할 명령들을 순서대로 써 놓은 것이다… (위키백과)

지금까지 배운 내용을 토대로 가계부를 만들어 보겠습니다. 가계부는 잔액과 입출금 내역을 표시 할 수 있어야하고, 입금과 출금을 기록 할 수 있는 기능이 있습니다. 현재 있는 금액과 입출금 내역은 변수에 저장해야 합니다. 입출금 내역은 계속 늘어나므로 배열로 구현하고, 입금과 출금, 출력 기능은 함수로 선언한 변수에 접근하도록 구현해 보겠습니다.

var amounts = [];
var names = [];
var total = 0;

function deposit(amount, name) {
  if (amount + total < 0) {
    throw new Error(`Not enough balance for ${name}`);
  }

  amounts.push(amount);
  names.push(name);
  total += amount;
}

function print() {
  var result = '';
  for(var i=0; i < amounts.length; i++) {
    console.log(`${amounts[i] > 0 ? '입금' : '출금'}\t${names[i]}\t${amounts[i]}`);
  }
  console.log(`잔액:\t${total}`);
}

try {
  deposit(100, "월급");
  deposit(200, "용돈");
  deposit(-150, "월세");
  deposit(-300, "보험료");
} catch (e) {
  console.log(e);
}

print();

/**
Error: Not enough balance for 보험료 ...
입금 월급 100
입금 용돈 200
출금 월세 -150
잔액: 150
**/

사용자 인터페이스(CLI 또는 GUI)를 제공하진 않았지만, 간단히 가계부를 흉내내는 프로그램을 작성하였습니다. 이 코드의 문제점이 무엇일까요?

  • 가계부를 사용자마다 사용 할 수 있게 여러개로 늘려야 한다면?
  • 또 사용자마다 은행 계좌별로 가계부를 쓸 수 있게 한다면?
  • 입출금 내역에 입출금자를 추가하고 싶다면?
    등등…

목적에만 급급하게 짜내려간 코드는 추상화가 부족하기 때문에 확장성과 재사용성이 부족합니다. 또한 전역 스코프(Scope)가 지저분해지기 쉬우며, 코드 자체가 "말"이 되지 않기 때문에 사람이 쉽게 읽고 이해하기가 힘듭니다. 사람이 이해하고 작성하기 쉬운 코드로 나아가기 위해서 다음 파트에서 객체 지향 프로그래밍(OOP)에 대해서 알아보겠습니다.

목차
2. 프로그래밍 연습
3. 웹 프론트엔드
저자

김동욱

개발 경력 약 10년, 전문 분야는 웹 및 각종 응용 소프트웨어 플랫폼입니다. Codeflow를 운영하고 있습니다.

2018년 04월 21일 업데이트

지원되지 않는 웹 브라우저거나 예기치 않은 오류가 발생했습니다.