
함수를 그냥 호출할 때 vs new로 호출할 때
자바스크립트에서 함수는 같은 형태여도 new를 붙이느냐 마느냐에 따라 다르게 동작한다. 이 과정을 좀 더 확인해보자.
함수 객체의 두 가지 내부 메서드: [[Call]]과 [[Construct]]
자바스크립트에서 함수는 호출 가능한 객체다. 엔진은 함수 객체 안에 숨겨진 내부 메서드로 "이 함수를 어떻게 호출할 수 있는지"를 구분한다.
[[Call]]을 가진 함수 객체 →callable. 일반 함수로 호출할 수 있다.[[Construct]]를 가진 함수 객체 →constructor. 생성자 함수로서, 즉new와 함께 호출할 수 있다.
호출 방식이 이 둘을 가른다.
foo(); // [[Call]] 이 호출됨 → 일반 함수
new foo(); // [[Construct]] 가 호출됨 → 생성자 함수
모든 함수는 callable, 하지만 모두가 constructor는 아니다
여기서 핵심 비대칭이 있다.
- 모든 함수는 호출 가능하다. 즉 모든 함수 객체는
[[Call]]을 가진다. 모든 함수는 callable이다. - 하지만 모든 함수가
new로 호출되는 건 아니다. 즉 모든 함수 객체가[[Construct]]를 가진 건 아니다. 함수는constructor이거나non-constructor둘 중 하나다.
그럼 무엇이 둘을 가르나? 정의 방식이다.
| 구분 | 정의 방식 |
|---|---|
constructor |
함수 선언문, 함수 표현식, 클래스 |
non-constructor |
ES6 메서드 축약 표현, 화살표 함수 |
말로만 보면 헷갈리니 코드로 보자. 똑같이 객체의 프로퍼티에 함수를 넣어도 문법 형태에 따라 갈린다.
const constructorSyntax = {
x: function () {}, // 함수 표현식 → constructor
};
const nonConstructorSyntax = {
x() {}, // ES6 메서드 축약 → non-constructor
};
new constructorSyntax.x(); // OK
new nonConstructorSyntax.x(); // TypeError: x is not a constructor
같은 x인데 한쪽만 new가 된다. 화살표 함수도 마찬가지로 non-constructor라 new를 붙이면 에러가 난다. (그래서 화살표 함수에는 prototype 프로퍼티도 없다.)
참고로 함수 이름의 첫 글자가 대문자인지 여부는 아무 상관이 없다.
Person이든person이든 엔진은 신경 쓰지 않는다. 대문자 컨벤션은 "이건 생성자로 쓰세요"라는 사람끼리의 약속일 뿐, 동작을 바꾸지 않는다.
같은 함수, 두 가지 호출 — 무엇이 달라지나
constructor이면서 callable인 함수(=대부분의 일반 함수)는 호출 방식을 우리가 고를 수 있다. 그냥 부르면 일반 함수, new를 붙이면 생성자 함수. 이때 달라지는 건 크게 두 가지다.
1. this 바인딩
- 일반 함수로 호출 →
this는 전역 객체 (strict mode면undefined) - 생성자 함수로 호출 →
this는 그 생성자가 새로 만드는 인스턴스 객체
2. 반환값
여기가 재밌는 부분이다.
- 일반 함수로 호출 →
return키워드가 반환하는 값을 그대로 돌려준다. 단순하다. - 생성자 함수로 호출 → 규칙이 셋으로 갈린다.
return이 없으면 → 새로 만든 인스턴스를 반환- 객체를
return하면 → 그 객체를 반환 (인스턴스는 버려짐) - 원시값을
return하면 → 그 원시값은 무시되고 인스턴스가 반환된다
function Person(name) {
this.name = name;
return 100; // 원시값 → 무시됨
}
new Person("Kim"); // Person { name: "Kim" } ← 인스턴스가 반환됨
null도 typeof null === "object"이지만 명세상 원시값으로 취급되어 무시된다. 이 "원시값은 무시한다"는 규칙은 실수로 return 5 같은 걸 써도 new의 동작이 깨지지 않게 막아주는 안전장치이자, 동시에 팩토리 패턴을 가능하게 하는 열쇠다.
new는 내부에서 무슨 일을 하나 — 생성 → 초기화 → 반환
new와 함께 호출하면 [[Construct]]가 동작한다고 했는데, 이 내부 동작은 사실 세 단계로 나뉜다. 이 순서를 알면 앞에서 본 this 바인딩과 반환값 규칙이 한 번에 이해된다.
function Person(name) {
// 1단계는 이 첫 줄이 실행되기 "이전"에 이미 끝나 있다
this.name = name; // 2단계: 초기화
// 3단계: 암묵적으로 this 반환
}
const me = new Person("Kim");
1단계 — 인스턴스 생성과 this 바인딩 (런타임 이전)
함수 몸체의 코드가 한 줄씩 실행되기 이전에, 엔진이 암묵적으로 빈 객체를 하나 만든다. 이게 바로 우리가 만들려던 인스턴스다. 그리고 이 빈 객체가 this에 바인딩된다.
여기서 "런타임 이전"이라는 표현이 핵심이다. this.name = name이라는 코드가 실행되는 시점에는 이미 빈 인스턴스가 만들어져 this에 묶여 있다. 그러니까 함수 몸체에서 this를 쓰면 그건 전역 객체가 아니라 방금 생성된 이 빈 인스턴스를 가리킨다. (이 단계에서 인스턴스의 [[Prototype]]이 Person.prototype으로 연결되는 처리도 함께 일어난다.)
function Person(name) {
console.log(this); // Person {} ← 이미 빈 인스턴스가 this에 바인딩돼 있음
this.name = name;
console.log(this); // Person { name: "Kim" }
}
new Person("Kim");
2단계 — 인스턴스 초기화
이제 함수 몸체의 코드가 실제로 실행된다. this에 바인딩된 빈 인스턴스에 프로퍼티를 추가하고, 인수로 받은 값으로 채워 넣는다. 비어 있는 인스턴스를 쓸 만한 상태로 채우는 과정이 이 단계다.
function Person(name, age) {
this.name = name; // 빈 인스턴스를 초기화
this.age = age;
this.greet = function () {
console.log(`저는 ${this.name}입니다`);
};
}
그래서 생성자 함수 몸체의 역할은 결국 "1단계에서 받은 빈 객체를 어떻게 초기화할지"를 적는 것이다.
3단계 — 인스턴스 반환
몸체 실행이 끝나면 this에 바인딩됐던 인스턴스가 암묵적으로 반환된다. 우리가 return을 쓰지 않아도 인스턴스가 나오는 이유가 바로 이것이다.
그리고 여기서 앞 섹션의 반환값 규칙이 끼어든다.
return이 없으면 → 3단계대로 this(인스턴스)가 반환된다.- 객체를
return하면 → 암묵적인 this 반환을 덮어쓰고 그 객체가 반환된다. - 원시값을
return하면 → 무시되고 다시 this(인스턴스)가 반환된다.
function Person(name) {
this.name = name;
// return 생략 → 3단계의 암묵적 this 반환이 그대로 적용됨
}
new Person("Kim"); // Person { name: "Kim" }
정리하면, new는 "빈 인스턴스를 미리 만들어 this에 쥐여주고(1) → 너는 그걸 채우기만 해(2) → 다 채워지면 내가 알아서 돌려줄게(3)"라는 약속이다. 생성자 함수를 작성할 때 우리가 신경 쓰는 건 사실상 2단계뿐이고, 1·3단계는 엔진이 보이지 않게 처리해준다.
객체 반환 규칙의 응용: 싱글톤
생성자가 객체를 반환하면 그 객체가 나간다는 규칙을 이용하면, new를 몇 번 부르든 항상 같은 인스턴스 하나만 돌려주게 만들 수 있다.
function Cache() {
if (Cache.instance) return Cache.instance; // 이미 있으면 그 객체를 반환
Cache.instance = this; // 처음이면 자신을 저장
}
new Cache() === new Cache(); // true
함수도 객체라서 Cache.instance처럼 함수 자체에 프로퍼티(메모장)를 붙일 수 있다는 점이 트릭의 출발점이다. 흐름을 따라가 보자.
첫 번째 new Cache()
new가 빈 인스턴스를 만들어this에 바인딩한다.Cache.instance는 아직undefined→if를 통과(return 안 함).Cache.instance = this→ 방금 만든 인스턴스를 함수에 저장.return생략 → 인스턴스가 반환된다.
두 번째 new Cache()
new가 또 빈 인스턴스를 만들어this에 바인딩한다. (잠깐 새로 만들긴 한다)- 이번엔
Cache.instance에 첫 번째 인스턴스가 들어 있다 → truthy →return Cache.instance. - 객체를 명시적으로 반환 → 그 객체(=첫 번째 인스턴스)가 나간다. 방금 만든 두 번째 인스턴스는 버려진다.
결국 두 호출 모두 같은 첫 번째 인스턴스를 돌려주므로 === 비교가 true다.
const a = new Cache();
const b = new Cache();
a.data = "hello";
console.log(b.data); // "hello" ← a와 b는 같은 객체
이게 싱글톤(Singleton)이다. "객체를 return하면 그 객체가 나간다"는 규칙이 없으면 이 트릭은 성립하지 않는다. 앞에서 배운 규칙이 실제로 쓰이는 자리다.
그래서 팩토리 패턴이 뭔데?
팩토리(factory)는 곧 공장이다. 객체를 직접 new로 찍어내는 대신, 객체를 만들어 돌려주는 함수를 두고 그걸 통해 객체를 받는 방식이다.
function createUser(name, role) {
return { name, role, sayHi() { console.log(`안녕, ${this.name}`); } };
}
const u = createUser("Kim", "admin"); // new 없이 호출
new 없이 그냥 부르면 완성된 객체가 나온다. 굳이 공장을 쓰는 이유는 셋이다.
- 생성 과정에 로직을 넣을 수 있다.
type에 따라 다른 객체를 돌려주는 식으로, 호출하는 쪽은 내부 구현을 몰라도 된다. - 캐싱·재사용이 가능하다. 위의
Cache가 바로 이 경우인데, 매번 새로 만들지 않고 이미 있으면 그걸 돌려준다. 싱글톤은 "항상 같은 하나를 돌려주는" 특수한 팩토리다. new의 함정을 피한다.new를 깜빡해this가 엉뚱하게 바인딩되는 사고가 없다.
Cache 예제는 생성자처럼 생겼지만, 객체 반환 규칙을 이용해 사실상 팩토리(싱글톤)처럼 동작하게 만든 것이라고 보면 정확하다.
약간의 환장 포인트 — 빌트인에서도 똑같이 작동한다
지금까지의 규칙은 우리가 매일 쓰는 빌트인 함수에도 그대로 적용된다.
Function은 new가 있든 없든 함수를 만든다
const withNew = new Function('x', 'return x * 3');
const withoutNew = Function('x', 'return x * 3');
// 둘 다 동일하게 함수. Function은 new 유무와 무관하게 같은 동작을 한다.
String / Number는 new 유무로 결과 타입이 갈린다
const objStr = new String(123); // String 래퍼 "객체"
const primStr = String(123); // 원시 문자열 "123"
const objNum = new Number('123'); // Number 래퍼 "객체"
const primNum = Number('123'); // 원시 숫자 123
new와 함께 → 래퍼 객체가 만들어진다 (typeof가"object").new없이 → 원시값으로 타입 변환된 결과가 나온다.
우리가 String(value), Number(value)로 자연스럽게 타입 변환을 해온 게 바로 이 동작 덕분이다. String/Number 같은 빌트인 생성자는 new 없이 호출되면 변환 함수로, new와 함께 호출되면 래퍼 객체 생성자로 동작하도록 설계돼 있다.
정리
- 모든 함수는 callable(
[[Call]])이지만, constructor([[Construct]])인 함수는 일부다. ES6 메서드 축약·화살표 함수는 non-constructor. - 같은 함수도
new유무로this(전역 vs 인스턴스)와 반환값 규칙이 달라진다. new는 내부에서 ① 인스턴스 생성 + this 바인딩(런타임 이전) → ② 초기화 → ③ 암묵적 반환의 3단계를 거친다.- 생성자의 "객체면 그 객체, 원시값이면 무시하고 인스턴스" 규칙이 싱글톤·팩토리 패턴을 떠받친다.
String(),Number()의 타입 변환도 결국 같은 규칙의 결과다.
함수 하나에 [[Call]]과 [[Construct]]라는 두 얼굴이 있다는 것 — 이거 하나만 잡으면 위의 현상들이 전부 한 줄기로 꿰어진다.
'Frontend > javascript' 카테고리의 다른 글
| 16. 프로퍼티 어트리뷰트 (0) | 2026.06.02 |
|---|---|
| [15] let, const & 블록 레벨 스코프 (0) | 2026.05.26 |
| 자바스크립트 스코프와 변수의 생명주기 (0) | 2026.05.19 |
| [JS] 원시 값과 객체의 비교 — 값, 메모리, 그리고 복사 (0) | 2026.04.15 |
| for문 읽다가 프로토타입 체인까지 온 건에 대하여 (0) | 2026.03.25 |
댓글