본문 바로가기
👩‍💻 Programming/JavaScript

함수를 좀더 이해해보자!

by codingBear 2022. 4. 7.
728x90
반응형

 이번 글은 'JavaScriptの理解を深めた人がさらにもう1歩先に進むための本(JavaScript를 깊게 이해한 사람이 한 걸음 더 나아가기 위한 책)의 Chapter 6. 関数をもっと理解しよう!(함수를 좀더 이해해보자!)'를 바탕으로 작성하였습니다.


다시금 '함수'란 무엇인가

 JavaScript에서 함수는 언급할 필요도 없을 만큼 매우 중요하다. '함수'란 도대체 무엇인가. 사전적 정의가 아니라 어떤 원리로써 함수 안에 정의된 내용을 실행하는 것인가.

 우선 함수가 갖는 특징을 아래에서 살펴보자.

 

  • 함수도 객체이다.
  • 함수 자신을 인수나 반환값으로 설정할 수 있다.(고차함수)
  • 인수를 엄밀히 확인하지는 않는다.
  • '() 연산자'로 실행한다.

 

 특히 '함수도 객체이다'라는 점은 다른 언어에는 없는 JavaScript만의 특징이다. 하지만 이번에는 특징 중 맨 끝에 언급한 '() 연산자로 실행한다'라는 점에 대해 깊게 살펴보겠다. JavaScript 코드를 작성할 때 습관적으로 '() 연산자'를 붙여서 함수를 호출했을 것이다. 또 '즉시 함수'에도 '() 연산자'는 쓰인다.

 예를 한번 살펴보자.

 

// 가장 일반적인 함수 호출 방식
function func(val) { 
    console.log(val); 
} 
func('hoge');  // hoge 
 
// 함수의 즉시 실행
(function(val1 , val2) { 
    console.log(val1 + val2); 
})(1, 2);  // 3

 

 일반적인 함수 호출 방식이든 즉시 실행 방식이든 '() 연산자'를 붙인다. 그렇다면 함수를 '() 연산자'를 붙이지 않고 호출한 경우 어떻게 동작할까? 예를 살펴보자.

 

function func() { return 'hoge'; } 
console.log(func);  // function func() { return 'hoge'; }

 

 결과를 보면 알 수 있듯 '() 연산자'를 붙이지 않고 함수를 호출(혹은 참조)한 경우, '함수의 정의 내용을 참조'하게 된다. 위 예제에서는 개행을 하지 않았는데 만약 개행을 했다면 개행까지 그대로 출력된다.

 

 동작 자체만 보면 '함수'와 '변수'의 차이가 무엇인지 모를 것이다. 예를 들어 위의 예제 코드를 변수를 활용하여 아래처럼 바꿔 쓸 수도 있다.

 

let str = "function func() { return 'hoge'; }"; 
console.log(str);  // function func() { return 'hoge'; }

 

 위 두 예제의 실행 결과값만 놓고 보면 똑같아 보인다. 과연 '함수'와 '변수'의 차이는 무엇일까. 다음 예제를 살펴보자.

 

function func() { return 'hoge'; } 
console.log(func);  // function func() { return 'hoge'; }  ① 
console.log(func());  // hoge  ② 
 
let func2 = function() { return 'fuga'; } 
console.log(func2);  // function() { return 'fuga'; }  ③ 
console.log(func2());  // fuga  ④ 
 
let str = "function func() { return 'hoge'; }"; 
console.log(str);  // function func() { return 'hoge'; }  ⑤ 
console.log(str());  // エラー  ⑥

 

 ①과 ②의 차이와 ③과 ④의 차이는 이때까지 설명한 그대로이고, ⑤에 대해서도 다시 설명할 필요는 없을 것이다. ⑥이 문제인데 여기서는 'TypeError: str is not a function'이라는 에러가 발생한다. 즉, '함수가 아닌 것을 함수로 호출'했기 때문에 에러가 발생하는 것이다. 이 ① ~ ⑥에서 알 수 있는 것은 '함수로 정의해야 함수로서 실행할 수 있다'는 점이다. JavaScript에서는 함수식이나 함수 선언으로 '함수'를 정의한 경우 그 '내용에 대한 참조'를 함수명이나 변수로 설정한다. 덧붙이자면 '함수 객체에 대한 참조를 함수명이나 변수로 설정'한다는 것이다. 

 예제 코드의 'func'나 'func2'는 각각이 '정의한 함수 객체에 대한 참조를 유지'하고 있다. 그 때문에 '() 연산자'로 '함수 실행'이 가능한 것이다. 반대로 'str'가 유지하는 것은 함수 선언과 닮은 '단순한 문자열'이므로 함수로서는 호출 불가능한 것이다.


내부 프로퍼티(property) [[call]]

함수는 어째서 '함수'라고 인식되는 것일까. 그 답은 [[call]](※)에 있다.

※ JavaScript(정확히는 ECMAScript)에서는 다양한 객체 내부 프로퍼티를 정의하는데 이중 대괄호([[]])로써 표기한다.

 이 내부 프로퍼티는 '함수 객체만 갖는' 프로퍼티로, 코드에서 직접 접근하는 것은 불가능하다. 또한 함수만이 [[call]]을 가지므로 'typeof 연산자'는 함수를 대상으로 할 때만 'function'이라 반환하도록 정의되어 있다.

 

typeof {};  // 'object' 
typeof [];  // 'object' 
typeof new Date();  // 'object' 
typeof new RegExp();  // 'object' 
typeof null;  // 'object' 
typeof function(){};  // 'function'

 

 위와 같은 결과값은 JavaScript가 생겨나면서부터 정해진 것이기 때문에 있는 그대로 받아들이면 된다.


함수 호이스팅(hoisting)

 JavaScript에서는 함수의 정의 방법으로 '함수 선언'과 '함수식' 2종류가 있다.

 우선 예제부터 살펴보자.

 

// 예)6-3-①

// 함수 선언
function func(val) { 
    return val1 + val2; 
} 
 
// 함수식
let sum = function(val1, val2) { 
    return val1 + val2; 
};

 

 보통 용도에 맞게 각기 나눠 쓰는 정도로 쓰는 법이 다른 것 빼고는 특별히 신경쓰지 않을 것이다. 하지만 두 선언 방식에는 '엄청난 차이'가 있다.

 

// 예)6-3-②

// 함수 sum을 정의하기 전에 사용해보자
let result = sum(1, 2); 
 
function sum(val1, val2) { 
    return val1 + val2; 
} 
 
console.log(result);  // 3

 

 언뜻 보기에 에러가 날 것 같지만 정상적을 작동됨을 알 수 있다. 사실 이 코드는 JavaScript 엔진에 의헤 아래처럼 해석된다. 이 같은 해석을 '함수 호이스팅'이라고 한다.

 

// 예)6-3-③

// 함수 sum이 끌어올려져서 맨 앞으로 이동된다
function sum(val1, val2) { 
    return val1 + val2; 
} 
 
let result = sum(1, 2); 
 
console.log(result);

 

 '함수 선언'에서 정의된 함수는 코드 실행 시 '스코프의 맨 앞까지 끌어올려진다.' 따라서 예)6-3-②와 같은 함수 호출이 가능한 것이다.

 

 다음으로 '함수식'에서 함수가 어떻게 동작하는지 살펴보자.

 

// 예)6-3-④
 
let result = sum(1, 2);  // 에러
 
let sum = function(val1, val2) { 
    return val1 + val2; 
} 
 
console.log(result)

 

 '함수 호이스팅'은 '함수식'에서는 일어나지 않는다. '함수 선언의 경우는 미리 함수명이 확정'된 데 반해 '함수식은 변수를 통해서만 접근 가능'하기 때문에 끌어올리는 것이 불가능하다. 이것이 '함수선언'과 '함수식'의 엄청난 차이다.

 실제로 코딩을 할 때 '각종 함수의 정의는 코드 맨 앞에 작성'한다면 문제가 일어나지 않으리라 생각하지만 '함수의 참조 에러의 함정'에 빠졌을 때 이 '함수 호이스팅'을 떠올려 해결하길 바란다.


안전한 생성자(Constructor)

 알다시피 생성자의 진짜 정체는 '단순한 함수'이다. 그렇다는 말은 '모든 생성자는 new 연산자를 동반하지 않더라도 실행이 가능하다'는 뜻이다. 그리고 new 연산자를 동반하지 않는 생성자의 호출은 '단순한 함수 호출'이므로 생성자 안에서 사용하는 'this'는 전역 객체(global object)가 된다.

 즉 생성자를 호출할 때 new 연산자를 붙이지 않는다면 그대로 '전역 스코프를 오염'시켜버리는 것이다. 이를 방지하기 위한 코드 작성법을 소개한다.

 

// 예)6-4-①
 
function Phone(name) { 
    // this 확인을 수행 
    if (this instanceof Phone) { 
        this.name = name; 
    } else { 
        return new Phone(name); 
    } 
} 
 
Phone.prototype.getName = function() { 
    return this.name; 
}; 
 
let myPhone1 = new Phone('xxxx'); 
let myPhone2 = Phone('yyyy'); 
 
console.log(myPhone1.getName());  // xxxx 
console.log(myPhone2.getName());  // yyyy 
console.log(myPhone1 instanceof Phone);  // true 
console.log(myPhone2 instanceof Phone);  // true

 

 생성자는 'instanceof'를 사용하여 'this가 무엇인지' 확인을 할 뿐이지만 결과적으로 'new 연산자를 붙이지 않고 생성자를 호출해도 인스턴스(instance)를 반환'하게 되어 있다. 생성자는 '호출 방법이 보증되어 있지 않기' 때문에 특별한 사정이 없는 한 항상 이렇게 확인을 수행하는 편이 좋을 것이다. 참고로 '객체'나 '배열' 등의 네이티브 객체(native object)도 이런 '안전한' 생성자를 가진다. 다음 예를 통해 확인해보자.

 

// 예)6-4-②
 
let arr1 = Array(); 
let arr2 = new Array(); 
 
console.log(arr1);  // [] 
console.log(arr2);  // [] 
console.log(arr1 instanceof Array);  // true 
console.log(arr2 instanceof Array);  // true

오버로드(Overload)

JavaScript 함수는 인수를 확인하지 않는다. 또한 같은 이름의 함수가 정의되어 있는 경우 원칙적으로 '나중에 오는 함수'가 살아남는다. 즉 JavaScript의 함수는 '시그니처(signature)를 가지지 않는다'고도 할 수 있다.

 

// 예)6-5-①
 
function func() { 
    console.log('hoge'); 
} 
 
// 함수 이름이 같다면 밑에 있는 것을 취한다
function func(val) { 
    console.log(val); 
} 
 
func();  // undefined

 

 함수가 '시그니처를 갖지 않는다'는 말은 동시에 '오버로드라는 개념이 존재하지 않는다'는 뜻이다. 따라서 개발자가 직접 '인수 객체(arguments object)'를 활용하여 작성해야 한다. 이 객체에는 '함수에 전달된 인수의 정보가 모두 담겨 있기' 때문에 함수가 '어떤 식으로 호출되었는지'를 판별 할 수 있다. 

 요약하자면 인수 객체를 활용하면 '인수에 응한 조건 분기'나 '처리 분기' = '인공적인 오버로드'가 실현 가능하다는 것이다.

 

// 예)6-5-②
 
function func(val) { 
	// ※ 인수 확인 방법으로는 이외에도 '함수 === undefined', 'typeof나 instanceof'로 형식을 판별하는 등 다양한 방법이 있다.
    if (arguments.length === 0) {  
        val = 'hoge'; 
    } 
 
    console.log(val); 
} 
 
func();  // hoge 
func('fuga');  // fuga

게터(Getter)와 세터(Setter)

 ES5부터 프로퍼티에는 '게터'와 '세터'의 개념이 도입되었다. JavaScript라고 해서 게터와 세터의 개념이 다른 언어와 다르지 않다. 이 둘의 정체는 함수이며 일정한 법칙에 따른 작성하는 것 이외에는 일반적인 함수와 전혀 다르지 않다. 예를 통해 살펴보자.

 

// 예)6-6-①
 
let human = { 
    _name: '', 
    get name() { 
        return this._name; 
    }, 
    set name(val) { 
        this._name = val; 
    } 
} 
 
human.name = 'igarashi'; 
console.log(human.name);  // igarashi

 

 예제 코드는 평범한 게터와 세터의 예이다. 특이한 점은 각자의 선언 방법이다. 게터의 경우는 'get', 세터의 경우는 'set'부터 시작하여 각자 이용하는 함수를 'function 키워드를 붙이지 않고' 정의하는 것 빼고는 별반 복잡하지 않다. 또한 이름을 붙이는 데 특별한 규칙이 없기에 변수나 일반적인 함수와 같은 룰에 따라도 문제 되지 않는다.

 

 다음 예를 통해 게터와 세터에 대해 더 알아보자.

 

// 예)6-6-②

let human = { 
    _name: '', 
    get name() { 
        return this._name; 
    }, 
    set name(val) { 
        this._name = val; 
    } 
} 
 
human._name = 'igarashi'; 
console.log(human._name);  // igarashi

 

 예제를 보면 알 수 있듯 아무리 게터와 세터를 준비해놔도 사용하지 않으면 쓸모가 없다. 예를 들어 '게터'에서 값을 수정하거나 '세터'에서 입력 체크하도록 기능을 마련해놔도 '입력값을 직접 참조한다면 의미가 없다'는 말이다.

 이 같은 문제를 다음 예제에서 수정해보자.

 

// 예)6-6-③
 
let human = (function() { 
    let _name; 
 
    return {  ① 
        get name() { 
            return `이름은 ${_name}입니다`; 
        }, 
        set name(val) { 
            _name = val; 
        } 
    }; 
})();  ② 
 
human.name = 'igarashi'; 
console.log(human.name);  // 이름은 igarashi입니다
console.log(human._name);  // undefined 
 
human._name = 'tarama'; 
console.log(human.name);  // 이름은 igarashi입니다 
console.log(human._name);  // tarama

 

 결과값을 보면 알겠지만 앞서 발생했던 문제가 해결되었다.

 여기서 요점은 아래와 같다.

  • ①의 클로저
  • ②의 이름을 붙이지 않은 즉시 실행 함수

 일반적인 객체의 정의와 비교해서 아주 조금 코드를 늘리는 것만으로 실현 가능한 기술이기에 '게터'와 '세터'를 사용할 때 반드시 활용해보길 바란다.

728x90
반응형

댓글