Node.js/잡다 지식

JavaScript, TypeScript 에서 진짜 Private 구현하기

코딩크리처 2025. 3. 31. 23:51

제목 : 자바스크립트에서 진짜 private 구현하기


현대의 자바스크립트 문법(es6 이상) 에서는 "class" 문법이 지원된다.

그러나, 자바스크립트를 배우는 지난 과정 속, 데코레이터(Decorator) 를 배우면서,

실질적으로 JavaScript 의 Class 또한, function 의 일환이라는 것을 발견했다.

이를 발견하게 된 것은, 기존의 TypeScript Class 코드를 JavaScript ES5 버전으로 변경하면서 발견했다.

Example :

typescript

// 클래스 컴파일 결과 확인용
class FunctionClass {
    title : string;
    static staticalMember : number = 100;

    constructor() {
        this.title = "클래스가 어떻게 함수가 되는거지?"
    }
}

javascript -- tsc

// 클래스 컴파일 결과 확인용
var FunctionClass = /** @class */ (function () {
    function FunctionClass() {
        this.title = "클래스가 어떻게 함수가 되는거지?";
    }
    FunctionClass.staticalMember = 100;
    return FunctionClass;
}());

클래스와 프로토타입으로 생성된 인스턴스와,

클래스, 프로토타입, 인스턴스가 연결된 프로토타입 체인을 공부하며 헷갈렸던 연결 그 자체를,

컴파일 이후의 자바스크립트를 보면서 지식들을 찬찬히 연결시켜 나갔다.

클래스를 선언하는 방식이 언어 자체에 종속되어 있는 것이 아니라,

또 다른 함수를 변수에 담음으로서 클래스를 생성한다니, 정말 예상치도 못한 부분이였다.

  • var FunctionClass 는 결과적으로 내부의 function FunctionClass() {...} 를 의미한다.
  • var FunctionClass 에 클래스를 의미하는 함수 객체를 할당하는 로직을 작성하기 위해,
    function FuntionClass() 메서드에 속성을 주입하고 반환하는 로직을 익명 함수, 즉시 실행 으로 작성했다.
  • 예를 들어 const instance = new FunctionClass() 에서 instance.staticalMember
    클래스의 정적 부분을 바로 접근할 수 없던데, 그것은 "별개의 함수" 였기 때문이었다.
  • 별개의 함수이지만, 결국 인스턴스의 클래스, 프로토타입의 연결 정보는
    • prototype : Object.getPrototype(instance)
    • class : Object.getPrototype(instance).constructor 로 구할 수 있었다.
    • 클래스와 프로토타입 객체는 서로의 연결 정보를 가지고 있기 때문에 위의 방식이 가능하다.


타입스크립트를 다시 번역한 자바스크립트의 문법을 보면서, private 적용 여부도 궁금했다.

자바스크립트 문법에도, 타입스크립트 문법에도 private 은 존재한다.

그렇다면, "클래스" 가 함수로 번역되는 자바스크립트에서 private 은 어떻게 나타날 것인가?


typescript

// 클래스 컴파일 결과 확인용
class FunctionClass {
    private title : string;
    private author : string;
    static staticalMember : number = 100;

    constructor() {
        this.title = "private 으로 선언된 title";
        this.author = "private 으로 선언된 author";
    }
}

javascript -- tsc

// 클래스 컴파일 결과 확인용
var FunctionClass = /** @class */ (function () {
    function FunctionClass() {
        this.title = "private 으로 선언된 title";
        this.author = "private 으로 선언된 author";
    }
    FunctionClass.staticalMember = 100;
    return FunctionClass;
}());

이상하다? private 이 적용되지 않는데?

먼저 들었던 생각이다.

나는 분명히 private 을 선언하고, 해당 인스턴스 변수를

콘솔로 출력하려고 한다면, 에러가 떴다.

그런데, 자바스크립트로 출력된 내용은 완전히 public 이나 다름이 없었다.

즉, TypeScript 에서는 문법적으로 보호 해 주지만, 컴파일 된 자바스크립트는 그렇지 않다는 것이다.


이쯤되니, 이러한 생각이 들었다.

어플리케이션의 실행은 주로 타입스크립트의 내용이 해석된 dist or 다양한 이름을 가진 폴더에서 실행하는데,

dist 의 내용에 JavaScript 로, private 선언을 했던 객체에 직접적인 접근이 가능하다는 것이다.

그렇다면, 코드 내부적으로 JavaScript 의 보안을 지키기는 어렵다고 생각했다.

따라서, 나는 TypeScript 를 사용하면서도, JavaScript 로 번역되었을 때,

private 으로 선언된 변수들이 컴파일 되더라도 보안을 유지하는 방식이 궁금했다.

방대한 NPM 의 세계만큼, JavaScript 도 방법이 있을 거라고 확신했다.



JavaScript 에서 내부적인 프로퍼티는 어떻게 보호할까?

우선, JavaScript 자체적으로 private 을 사용할 수 있게 해 주기도 한다.

그런데, 자체 빌트인으로 사용하는 것이 아니라, 타입스크립트 상으로 target : 2015 이상의 문법을 사용해야 한다.

Example : javascript

class TestPrivate {
    #title;
    constructor() {
        this.#title = "private title";
    }

    get title() {
        return this.#title;
    }
}

const instance = new TestPrivate();

console.log(instance.title);

console.log(instance["#title"]);

Result :

$ node test-private.js
private title # instance.title
undefined # instance["#title"]

바닐라 자바스크립트로 작성했을 때, 현재 "25-3-30" 기준으로, 명세서에 등재되기 직전 상태라고 한다.

따라서, 잘 작동하고 있다.

그런데, 자바스크립트에서 이런 말이 있었다.

JavaScript 에서의 class 문은 "문법적 설탕" 이다.

따라서, 이것이 어떻게 원초적으로 작성되어 있을지 보기로 했다.


typescript :

class TsPrivate {
    #title : string;
    constructor() {
        this.#title = "TypeScript Private Title";
    }
}

const TsInstance = new TsPrivate();

javascript :

var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
    if (kind === "m") throw new TypeError("Private method is not writable");
    if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
    if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
    return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var _TsPrivate_title;
var TsPrivate = /** @class */ (function () {
    function TsPrivate() {
        _TsPrivate_title.set(this, void 0);
        __classPrivateFieldSet(this, _TsPrivate_title, "TypeScript Private Title", "f");
    }
    return TsPrivate;
}());
_TsPrivate_title = new WeakMap(); // 여기에 집중 
var TsInstance = new TsPrivate();

내부 프로퍼티를 위한 강력한 은닉화 방법으로 WeakMap 을 사용하는 것을 볼 수 있었다.

그러나, 실제 브라우저나 Node.js 환경 (es6 +) 에서, # 접두어를 붙여 private 를 전부 구현 할 수 있다.

컴파일 과정에서 WeakMap 이 나오는 이유는, 노후화 된 모듈과도 연결될 수 있기 때문에

아직 타입스크립트 private 구문이 WeakMap 을 이용하여 컴파일되는 것이 아닐까 조심스럽게 예측해 본다.


프로퍼티 은닉화 방법들

1. Underscore - _

컨벤션으로서 "이 변수는 private 처럼 운용하니까, 추출하지 마세요" 를 의미한다.

class TestClass {
  _title : string;
  constructor(title : string) {
    this._title = title;
  }

  getTitle() : string {
    return this._title;
  }
  setTitle(title : string) {
    this._title = title;
  }
}

const testInstance = new TestClass("Custom Title");

console.log(testInstance.getTitle());

console.log(testInstance._title);

Result :

$ ts-node class-and-function.js
Custom Title # console.log(testInstance.getTitle());
Custom Title # console.log(testInstance._title);

_(언더스코어) 로 작성된 변수는, 같은 프로젝트를 작성하는 개발자에게 있어

직접적으로 건드리지 않는 변수라고 알리는 것과 동일하다.

하지만, 결국 인스턴스를 통해 바로 직접적으로 접근이 가능하다.

WeakMap

먼저, WeakMap 이 (es6) - 2015 년에 도입되었다고 한다.. 따라서 지금부터 es6 로 바꾼다!!

JavaScript 의 일반적인 Map 과 달리, 객체 혹은 등록되지 않은 Symbol 만 키로 삼을 수 있다.

여기서 "등록되지 않은 Symbol" 이란,

Symbol.for("None Registered Symbol!") 와 같이,

전역 상태로 등록된 심볼을 의미한다.

일단 객체에서는 WeakMap 의 키로 "자기 자신" 을 넣으며,

값으로는 private 으로 사용할 값들을 객체 내부로 넣는다.

Example : typescript

const dataStore = new WeakMap<object, any>();

class TestClass {
  constructor(pValue1 : number, pValue2 : number) {
    dataStore.set(this, {
      pValue1,
      pValue2
    });
  }

  getPrivateValue1() : number {
    const data = dataStore.get(this);
    const value1  = data.pValue1;

    return value1;
  }
  plusPrivateValue1() : void {
    const data = dataStore.get(this);
    const value1 = data.pValue1 + 1;
    dataStore.set(this, {...data, pValue1 : value1})
  }

  getPrivateValue2() : number {
    const data = dataStore.get(this);
    const value2 = data.pValue2;

    return value2;
  }
  plusPrivateValue2() : void {
    const data = dataStore.get(this);
    const value2 = data.pValue2 + 1;

    dataStore.set(this, {...data, pValue2 : value2});
  }
}

const testInstance = new TestClass(1, 10);

// 1 번째 private 변수 관리

console.log(testInstance.getPrivateValue1());

testInstance.plusPrivateValue1();

console.log(testInstance.getPrivateValue1());

// 2 번째 private 변수 관리

console.log(testInstance.getPrivateValue2());

testInstance.plusPrivateValue2();

console.log(testInstance.getPrivateValue2());

Result :

$ ts-node class-and-function.ts
# $ tsc && node class-and-function.js
1
2
10
11

보는 것 처럼, WeakMap 에 저장된 private 한 변수들을 가져와서,

내부에서만 건드릴 수 있게 만들어 놓았다.


그런데, 이것도 "완전히 보안" 은 아니다.

왜냐하면 WeakMap 의 key 부분은 인스턴스로 접근이 가능한데,

"현재 파일 코드 영역" 에서만 접근이 가능하다.

만약 dataStore.get(this) 로 접근한다면,

여전히 TestClass 인스턴스의 private 변수에 접근이 가능하다.

그럼에도 불구하고 WeakMap 이 좋은 보안을 지니는 이유가,

내부 프로퍼티 추출 시 어떠한 private 처럼 운용하는 변수가 나오지 않기 때문이다.

우리는 전역 스코프에 존재하는 WeakMapprivate 변수들을 넣어놨지,

클래스에 선언하지 않았다. 이로서 어느정도 캡슐화에는 성공한 것이다.


하지만 다시 생각 해 보자.

내가 만든 이 클래스는, 이 파일에서만 사용되는 것이 아니라, 외부에서 가져와 사용 할 수 있다.

만약에 TestClassexport class TestClass 라면,

외부에서 접근 할 때, 파일 스코프로 선언된 const dataStore 은 접근 할 수가 없다!


Symbol

아까 위에서 WeakMap 선언을 "파일 스코프" 로 만들고,

클래스 내부에서 해당 위크맵에 인스턴스 스스로를 등록하여 은닉하도록 만들었다.

Symbol 사용 또한 동일하다.

// private 데이터 2개 선언.
const pData1 = Symbol("123");
const pData2 = Symbol("456");

class TestClass {
    // 내부에 심볼 데이터로 넣기
    [pData1] : number;
    [pData2] : string;
    constructor() {
        this[pData1] = 10;
        this[pData2] = "123";
    }

    getPData1PublicMethod() {
        return this[pData1];
    }

    getPData2PublicMethod() {
        return this[pData2];
    }
}

// 인스턴스 생성
const testInstance = new TestClass();

console.log(testInstance.getPData1PublicMethod());

console.log(testInstance.getPData2PublicMethod());

// 객체 내부에 존재하는 모든 심볼의 목록을 가져온다.
const instanceSymbol1 = Object.getOwnPropertySymbols(testInstance)[0].valueOf()

// 첫 번째 심볼을 출력
console.log(instanceSymbol1);

// 첫 번째 심볼로 인스턴스 내부의 private 한 변수에 접근이 가능하다.
console.log(testInstance[instanceSymbol1]);

Result :

$ tsc && node class-and-function.js
10 # testInstance.getPData1PublicMethod()
123 # testInstance.getPData2PublicMethod()
Symbol(123) # instanceSymbol1
10 # testInstance[instanceSymbol1]

하지만, 문제가 있다면, Object 라는 전역 객체가 인스턴스 내부의 심볼들을 가져올 수 있다.

이를 이용하여, 인스턴스 내부에서 변수들을 private 화 시키는 심볼들이 추출된다.

마지막에 보이는 것 처럼, 다시 인스턴스에 심볼을 넣어 내부 정보를 가져올 수 있다는 단점이 있다.

파일 스코프로 인해 직접적으로 심볼을 가져올 수는 없지만,

결국 private 데이터를 가져올 수 있다.


Closure

사실상 클로저가 가장 private 에 가깝지 않을까 생각된다.

"TOAST UI" 사이트에서 말하길, 모듈 패턴으로 구성되며,

이는 ES6 모듈을 사용하기 시작한 이래로 점차 사용하지 않는 형태라고 한다.

function TestModule() {
    const privateValue = "It is Private value";

    const publicGetPrivateValue = () => {
        return privateValue;
    }

    function functionGetValue () {
        return privateValue;
    }

    return {
        publicGetPrivateValue
    }
}

// TestModule 형태
console.log(TestModule());

// 클로저 내부의 private 값을 조회한다.
console.log(TestModule().publicGetPrivateValue());

// Object 로도 조회할 수 있는 프로퍼티는 return 시킨 메서드 뿐이다.
console.log(Object.getOwnPropertyNames(TestModule()));

Result :

➜  tempTs tsc && node class-and-function.js

{ publicGetPrivateValue: [Function: publicGetPrivateValue] } # TestModule()
It is Private value # TestModule().publicGetPrivateValue()
[ 'publicGetPrivateValue' ] # Object.getOwnPropertyNames(TestModule())

보다시피, 함수 자체에서 return 하지 않은 프로퍼티와 메서드들은 노출이 되지 않는다.

이는 WeakMap 을 사용하는 캡슐화 방식보다도 훨씬 더 강하게 캡슐화하고 있다.

만약에 return 문을 없애버리면, 내부 함수에 존재하는 어떠한 함수 혹은 프로퍼티도 접근할 수 없다.


Private Class Field - #

이번에는 클래스를 사용하고도 private 한 프로퍼티들을 접근할 수 없는 방식이다.

ES2019 부터 # 을 붙이면, 외부에서 접근 할 수 없게 되었다.


//
// // 클래스 컴파일 결과 확인용
// class FunctionClass {
//     #title : string;
//     #author : string;
//     static staticalMember : number = 100;
//
//     constructor() {
//         this.#title = "private 으로 선언된 title";
//         this.#author = "private 으로 선언된 author";
//     }
//
//     getTitle() : string {
//         return this.#title;
//     }
//     setTitle(title : string) {
//         this.#title = title;
//     }
// }

/*
const dataStore = new WeakMap<object, any>();

class TestClass {
    constructor(pValue1 : number, pValue2 : number) {
        dataStore.set(this, {
            pValue1,
            pValue2
        });
    }

    getPrivateValue1() : number {
        const data = dataStore.get(this);
        const value1  = data.pValue1;

        return value1;
    }
    plusPrivateValue1() : void {
        const data = dataStore.get(this);
        const value1 = data.pValue1 + 1;
        dataStore.set(this, {...data, pValue1 : value1})
    }

    getPrivateValue2() : number {
        const data = dataStore.get(this);
        const value2 = data.pValue2;

        return value2;
    }
    plusPrivateValue2() : void {
        const data = dataStore.get(this);
        const value2 = data.pValue2 + 1;

        dataStore.set(this, {...data, pValue2 : value2});
    }
}

const testInstance = new TestClass(1, 10);

// 1 번째 private 변수 관리

console.log(testInstance.getPrivateValue1());

testInstance.plusPrivateValue1();

console.log(testInstance.getPrivateValue1());

// 2 번째 private 변수 관리

console.log(testInstance.getPrivateValue2());

testInstance.plusPrivateValue2();

console.log(testInstance.getPrivateValue2());


const testInstance2 = new TestClass(5, 15);

console.log(dataStore.get(testInstance));

*/

/*
// private 데이터 2개 선언.
const pData1 = Symbol("123");
const pData2 = Symbol("456");

class TestClass {
    // 내부에 심볼 데이터로 넣기
    [pData1] : number;
    [pData2] : string;
    constructor() {
        this[pData1] = 10;
        this[pData2] = "123";
    }

    getPData1PublicMethod() {
        return this[pData1];
    }

    getPData2PublicMethod() {
        return this[pData2];
    }
}

// 인스턴스 생성
const testInstance = new TestClass();

console.log(testInstance.getPData1PublicMethod());

console.log(testInstance.getPData2PublicMethod());

// 객체 내부에 존재하는 모든 심볼의 목록을 가져온다.
const instanceSymbol1 = Object.getOwnPropertySymbols(testInstance)[0].valueOf()

// 첫 번째 심볼을 출력
console.log(instanceSymbol1);

// 첫 번째 심볼로 인스턴스 내부의 private 한 변수에 접근이 가능하다.
console.log(testInstance[instanceSymbol1]);

console.log(Object.getOwnPropertyNames(testInstance));
*/

/*
function TestModule() {
    const privateValue = "It is Private value";

    const publicGetPrivateValue = () => {
        return privateValue;
    }

    function functionGetValue () {
        return privateValue;
    }

    return {
        publicGetPrivateValue
    }
}

// TestModule 형태
console.log(TestModule());

// 클로저 내부의 private 값을 조회한다.
console.log(TestModule().publicGetPrivateValue());

// Object 로도 조회할 수 있는 프로퍼티는 return 시킨 메서드 뿐이다.
console.log(Object.getOwnPropertyNames(TestModule()));
*/

class TestClass {
  #pValue1 : number;
  #pValue2 : number;

  constructor() {
    this.#pValue1 = 10;
    this.#pValue2 = 20;
  }

  getPrivateValues() : number[] {
    return [this.#pValue1, this.#pValue2];
  }

  #setPValue1(pValue1 : number) {
    this.#pValue1 = pValue1;
  }
  #setPValue2(pValue2 : number) {
    this.#pValue2 = pValue2;
  }

  setPrivateValues(value1 : number, value2 : number) {
    this.#setPValue1(value1);
    this.#setPValue2(value2);
  }
}

const testInstance = new TestClass();

console.log(testInstance.getPrivateValues());

testInstance.setPrivateValues(1, 2);

console.log(testInstance.getPrivateValues());

console.log(Object.getOwnPropertyNames(testInstance));

Result :

$ tsc && node class-and-function.js

[ 10, 20 ] # testInstance.getPrivateValues()
[ 1, 2 ] # testInstance.getPrivateValues()
[] # Object.getOwnPropertyNames(testInstance)

보다시피, 클래스 내부에 선언되는 인스턴스 프로퍼티들은 전부 # 접두어(prefix) 를 가진다.

이는 ES2019 부터 추가된 스펙으로, C++, Java 와 같이 클래스 private 처럼 취급해 준다.



배운 점

이번에 JavaScript, TypeScript 에서 어떻게 private 을 구현 할 수 있을까?

이것을 작성하게 된 이유는 바로, TypeScriptprivate 구문을 가진 코드가

컴파일 된 이후, 자바스크립트에서 실제로 보안 처리가 되지 않았기 때문이었다.


JavaScript 를 열심히 사용 해 온 사람들이

클래스 내부의 프로퍼티와 함수들을 private 화 시키기 위해 해 온 노력들을 알게 되었다.

웹 사이트에서 사용하던 언어에서, 인지도 상승과 최적화를 거치며 영역이 넓어진 JS 가,

입문은 쉽지만, 깊은 이해는 여타 다른 언어와 비슷하게 배워야 할 것이 많다고 생각한다.


이 다음 Node.js 에 대한 내용은 아마 다중 스레드를 다루는 worker API 를 습득 할 것인데,

하나의 쓰레드에 비동기적 실행을 채택한 Node.js 가 어떻게 쓰레드를 사용하는지 배워 볼 것이다.


참고 사이트

MDN Symbol

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Symbol

MDN WeakMap

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/WeakMap

Tistory 프론트엔드 개발자 블로그

https://frontdev.tistory.com/entry/JavaScript%EC%97%90%EC%84%9C-Priavate-%EB%B3%80%EC%88%98-%EA%B5%AC%ED%98%84

모던 JavaScript 튜토리얼

https://ko.javascript.info/private-protected-properties-methods

TOAST UI 포스팅 - NHN 에서 개발한 UI 오픈소스 라이브러리

https://ui.toast.com/weekly-pick/ko_20200312