이전 포스팅 https://ipjaworld.tistory.com/63 에서 이어지는 내용입니다.

포스팅은 3편에 나눠서 업로드 됩니다.

A. 자바스크립트 조망하기
1. 강제변환과 깊은비교, 일치비교와 동등비교,
2. js의 this 키워드와 프로토타입, 그리고 메모리주소

B. 추가 주제
1. es6 모듈에서 class를 사용하지 않는 이유

 

B. 추가 주제

1. ES6 모듈에서 class를 사용하지 않는 이유

ES6(ECMAScript 2015)에서는 클래스 문법과 모듈 시스템이 동시에 도입되었다. 그러나 "You Don't Know JS Yet"을 읽으면서 흥미로웠던 점은 많은 자바스크립트 개발자들, 특히 함수형 프로그래밍을 선호하는 개발자들이 ES6 모듈에서 클래스 사용을 지양한다는 것이다. 왜 그럴까?

클래스의 겉모습과 실제 동작의 차이

자바스크립트의 클래스는 다른 언어(Java, C++ 등)의 클래스와 문법적으로 유사하지만, 내부적으로는 여전히 프로토타입 기반으로 동작한다. 이런 "문법적 설탕(syntactic sugar)"은 자바스크립트의 객체 지향 특성을 이해하지 못한 채 다른 언어의 패러다임을 그대로 가져오게 만든다.

// 클래스 문법
class Person {
  constructor(name) {
    this.name = name;
  }
  
  greet() {
    return `Hello, I'm ${this.name}`;
  }
}

// 위 코드는 내부적으로 다음과 같이 동작한다
function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  return `Hello, I'm ${this.name}`;
};

this 바인딩 문제

클래스 메서드를 이벤트 핸들러나 콜백으로 전달할 때 this 바인딩 문제가 자주 발생한다.

class Counter {
  constructor() {
    this.count = 0;
    this.button = document.getElementById('button');
    // this 바인딩을 수동으로 처리해야 함
    this.button.addEventListener('click', this.increment.bind(this));
  }
  
  increment() {
    this.count++;
    console.log(this.count);
  }
}

이런 코드는 this 바인딩을 수동으로 처리해야 하므로 실수하기 쉽고, 코드의 복잡성이 증가한다.

상태 관리와 불변성

함수형 프로그래밍에서는 상태 변경을 최소화하고 불변성(immutability)을 중요시한다. 클래스는 본질적으로 내부 상태를 가지며, 메서드를 통해 그 상태를 변경하는 방식으로 동작한다.

// 클래스 기반 접근법 (상태 변경)
class Cart {
  constructor() {
    this.items = [];
  }
  
  addItem(item) {
    this.items.push(item);  // 내부 상태 변경
  }
  
  getTotal() {
    return this.items.reduce((total, item) => total + item.price, 0);
  }
}

// 함수형 접근법 (불변성 유지)
const createCart = (items = []) => ({
  items,
  addItem: (item) => createCart([...items, item]),  // 새 객체 반환
  getTotal: () => items.reduce((total, item) => total + item.price, 0)
});

함수형 접근법은 원본 데이터를 변경하지 않고 새로운 상태를 반환하므로, 예측 가능성이 높아지고 부작용(side effects)이 감소한다.

모듈 패턴의 유연성

ES6 모듈은 그 자체로 격리된 스코프를 제공하므로, 클래스 없이도 캡슐화를 쉽게 구현할 수 있다.

// counter.js
let count = 0;  // 모듈 내부에서만 접근 가능한 private 상태

export const increment = () => ++count;
export const decrement = () => --count;
export const getCount = () => count;

// 사용
import { increment, getCount } from './counter.js';
increment();
console.log(getCount());  // 1

이 패턴은 클래스보다 더 간결하고, 필요한 기능만 노출할 수 있어 API 설계가 명확해진다.

상속보다 합성

클래스 상속은 종종 "깨지기 쉬운 기반 클래스(fragile base class)" 문제를 일으킨다. 부모 클래스의 변경이 예기치 않게 자식 클래스에 영향을 미칠 수 있기 때문이다.

// 상속 기반 접근법
class Animal {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  speak() {
    console.log(`${this.name} barks.`);
  }
}

// 합성 기반 접근법
const createAnimal = (name) => ({
  name,
  speak: () => console.log(`${name} makes a noise.`)
});

const createDog = (name) => ({
  ...createAnimal(name),
  speak: () => console.log(`${name} barks.`)
});

합성(composition)은 "has-a" 관계를 표현하며, 상속보다 더 유연하고 느슨한 결합을 제공한다.

테스트 용이성

함수형 접근법은 일반적으로 테스트하기 더 쉽다. 순수 함수(pure functions)는 동일한 입력에 대해 항상 동일한 출력을 반환하며, 외부 상태에 의존하지 않는다.

// 클래스 메서드 테스트 (상태에 의존)
test('Cart.addItem', () => {
  const cart = new Cart();
  cart.addItem({ name: 'Product', price: 10 });
  expect(cart.items.length).toBe(1);
});

// 함수형 접근법 테스트 (상태 독립적)
test('addItem', () => {
  const cart = createCart();
  const newCart = cart.addItem({ name: 'Product', price: 10 });
  expect(newCart.items.length).toBe(1);
  expect(cart.items.length).toBe(0);  // 원본은 변경되지 않음
});

클래스 vs 함수형 패턴 비교표

아래 표는 클래스 기반 접근법과 함수형 패턴의 주요 차이점을 비교한 것이다:

특성 클래스 기반 접근법 함수형 패턴

상태 관리 내부 상태를 변경 (mutable) 불변 상태 (immutable) 지향
코드 구조 객체와 메서드 중심 함수와 데이터 중심
상속 방식 클래스 상속 (is-a 관계) 객체 합성 (has-a 관계)
this 처리 컨텍스트 관리 필요 컨텍스트 의존성 최소화
부작용 객체 내부 상태 변경 흔함 부작용 최소화 지향
테스트 용이성 상태 의존적, 복잡할 수 있음 순수 함수로 테스트 용이
메모리 사용 인스턴스마다 메모리 할당 함수 공유, 효율적 메모리 사용 가능
디버깅 상태 추적이 어려울 수 있음 데이터 흐름 추적 용이
확장성 상속을 통한 확장 합성을 통한 확장
가독성 객체지향 개발자에게 친숙 함수형 패러다임 이해 필요

실제 프레임워크 사례: React의 함수형 컴포넌트 전환

React는 초기에 클래스 컴포넌트를 주로 사용했지만, 16.8 버전에서 Hooks가 도입되면서 함수형 컴포넌트로의 전환이 가속화되었다. 이는 프론트엔드 개발에서 함수형 접근법의 장점을 잘 보여주는 사례다.

// 클래스 컴포넌트
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.increment = this.increment.bind(this);
  }

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.increment}>Increment</button>
      </div>
    );
  }
}

// 함수형 컴포넌트 + Hooks
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

함수형 컴포넌트는 더 간결하고, this 바인딩 문제가 없으며, Hooks를 통해 상태 관리와 생명주기 기능을 필요한 만큼만 사용할 수 있다. React 팀은 공식적으로 함수형 컴포넌트와 Hooks 사용을 권장하고 있으며, 이는 자바스크립트 생태계에서 함수형 프로그래밍의 영향력을 보여준다.

또한 React의 state 업데이트 철학은 불변성에 기반하며, 이는 함수형 프로그래밍의 핵심 원칙과 일치한다:

// 권장되지 않는 방식 (직접 상태 변경)
const handleClick = () => {
  const newItems = this.state.items;
  newItems.push('New Item');
  this.setState({ items: newItems }); // 잘못된 패턴
};

// 권장되는 방식 (불변성 유지)
const handleClick = () => {
  this.setState(prevState => ({
    items: [...prevState.items, 'New Item'] // 새 배열 생성
  }));
};

다른 라이브러리와 프레임워크에서의 함수형 접근

React 외에도 많은 현대적인 자바스크립트 라이브러리와 프레임워크들이 함수형 접근법을 채택하고 있다:

  1. Redux: 예측 가능한 상태 관리를 위해 순수 함수인 리듀서(reducer)와 불변성 원칙을 따른다.
  2. Vue 3: Composition API를 도입하여 함수형 접근법을 지원한다.
  3. Svelte: 선언적 템플릿과 반응형 상태를 사용하며, 클래스 대신 함수형 패러다임을 채택한다.
  4. Functional CSS Libraries (Tailwind, Tachyons): 작은 단일 기능 클래스를 합성하여 스타일을 구성하는 방식은 합성 개념과 일치한다.

성능 고려사항

클래스 인스턴스 생성은 프로토타입 체인 설정과 new 연산자 오버헤드가 있다. 단순 객체나 클로저를 사용하는 팩토리 함수는 경우에 따라 더 효율적일 수 있다.

물론 현대 자바스크립트 엔진은 최적화가 잘 되어 있어 대부분의 경우 성능 차이는 미미하지만, 성능이 중요한 애플리케이션에서는 고려할 만한 요소다.

결론: 상황에 맞는 도구 선택하기

클래스가 항상 나쁜 것은 아니다. 특히 다음과 같은 경우에는 클래스가 적합할 수 있다:

  1. 객체 지향 설계가 자연스러운 도메인(예: UI 컴포넌트)
  2. 상속이 명확하게 "is-a" 관계를 표현할 때
  3. 팀이 클래스 기반 패러다임에 더 익숙한 경우
  4. 기존 클래스 기반 라이브러리/프레임워크와 통합해야 할 경우

하지만 모듈 시스템을 사용할 때는 항상 가장 단순하고 명확한 방법을 선택하는 것이 좋다. 대부분의 경우, 간단한 팩토리 함수나 객체 리터럴, 또는 순수 함수의 집합으로도 충분하다.

"You Don't Know JS Yet"에서 배운 가장 중요한 것 중 하나는 자바스크립트의 유연성을 이해하고, 특정 패러다임에 얽매이지 않고 상황에 가장 적합한 도구를 선택하는 것이다. 클래스가 필요하다면 사용하되, 단순히 다른 언어의 패턴을 모방하기 위해 사용하는 것은 피하자.

이전 포스팅 https://ipjaworld.tistory.com/63 에서 이어지는 내용입니다.

포스팅은 3편에 나눠서 업로드 됩니다.

A. 자바스크립트 조망하기
1. 강제변환과 깊은비교, 일치비교와 동등비교,
2. js의 this 키워드와 프로토타입, 그리고 메모리주소

B. 추가 주제
1. es6 모듈에서 class를 사용하지 않는 이유

 

A. 자바스크립트 조망하기

2. js의 this 키워드와 프로토타입, 그리고 메모리주소

"You Don't Know JS Yet"을 읽기 전까지 자바스크립트의 this 키워드와 프로토타입 시스템은 나에게 미지의 영역과 같았다. 특히 다른 언어에서의 this와 다르게 동작하는 자바스크립트의 this는 많은 혼란을 가져왔다. 이번에는 this와 프로토타입, 그리고 자바스크립트의 메모리 주소 관리에 대해 내가 새롭게 이해한 내용을 정리해보려 한다.

this 키워드: 호출 시점에 결정되는 컨텍스트

자바스크립트의 this는 다른 언어와 달리 함수가 호출되는 방식에 따라 결정된다. 이것은 내가 기존에 알고 있던 객체 지향 언어(예: Java, C++)의 this와 완전히 다른 개념이다.

가장 헷갈렸던 코드 예시를 살펴보자:

const person = {
  name: "Alice",
  greet: function() {
    console.log(`Hello, my name is ${this.name}`);
  }
};

person.greet();  // "Hello, my name is Alice"

const greetFunction = person.greet;
greetFunction();  // "Hello, my name is undefined"

왜 같은 함수를 호출했는데 다른 결과가 나올까? 그것은 this가 함수를 어떻게 호출했느냐에 따라 결정되기 때문이다.

this 바인딩 규칙

자바스크립트에서 this는 다음 규칙에 따라 결정된다(우선순위 순):

  1. 명시적 바인딩: call, apply, bind 메서드를 사용해 this를 명시적으로 지정할 수 있다.
  2. greetFunction.call(person); // "Hello, my name is Alice"
  3. new 바인딩: 생성자 함수를 new 키워드로 호출하면 this는 새로 생성된 객체를 가리킨다.
  4. function Person(name) { this.name = name; } const alice = new Person("Alice"); // this는 새로 생성된 객체를 가리킴
  5. 암시적 바인딩: 객체의 메서드로 호출되면 this는 그 객체를 가리킨다.
  6. person.greet(); // this는 person을 가리킴
  7. 기본 바인딩: 일반 함수 호출 시 this는 전역 객체(브라우저에서는 window, Node.js에서는 global)를 가리킨다. 엄격 모드('use strict')에서는 undefined가 된다.
  8. function showThis() { console.log(this); } showThis(); // window 또는 global (또는 엄격 모드에서는 undefined)
  9. 화살표 함수: 화살표 함수는 자신만의 this를 갖지 않고, 외부 스코프의 this를 그대로 사용한다.화살표 함수는 선언될 때의 외부 스코프 this를 유지하므로, 객체 메서드나 이벤트 핸들러에서 원래 객체의 컨텍스트를 유지하고 싶을 때 유용하다:
  10. const counter = { count: 0, increase: function() { // 화살표 함수는 counter의 this를 유지 setInterval(() => { console.log(++this.count); // counter.count를 증가 }, 1000); } }; counter.increase();
  11. const person = { name: "Alice" }; const normalFunction = function() { return this.name; }; const arrowFunction = () => this.name; console.log(normalFunction.call(person)); // "Alice" console.log(arrowFunction.call(person)); // undefined (또는 전역 객체의 name)

이 규칙들을 이해하기 전에는 this의 동작이 너무 혼란스러웠다. 특히 콜백 함수에서 this가 예상과 다르게 동작하는 경우가 많았는데, ES6의 화살표 함수는 이런 문제를 해결하는 데 큰 도움이 되었다.

프로토타입: 자바스크립트의 상속 메커니즘

자바스크립트의 프로토타입 시스템은 클래스 기반 언어와는 다른 방식으로 상속을 구현한다. 이를 "프로토타입 기반 상속"이라고 한다.

프로토타입 체인

모든 자바스크립트 객체는 내부 프로퍼티 [[Prototype]](또는 __proto__)를 갖고 있으며, 이는 다른 객체를 가리킨다. 객체의 프로퍼티를 참조할 때 그 객체에 해당 프로퍼티가 없으면, 자바스크립트 엔진은 프로토타입 체인을 따라 프로퍼티를 찾는다.

const animal = {
  eat: function() {
    console.log("Eating...");
  }
};

const dog = Object.create(animal);
dog.bark = function() {
  console.log("Woof!");
};

dog.eat();  // "Eating..." - dog 객체에 없지만 프로토타입 체인을 통해 animal에서 찾음

이 코드에서 dog의 프로토타입은 animal 객체다. dog.eat()을 호출하면, dog 객체에 eat 메서드가 없기 때문에 프로토타입 체인을 따라 animal 객체에서 eat 메서드를 찾는다.

__proto__와 prototype의 차이점

자바스크립트에서 __proto__와 prototype은 다른 개념이다:

  • __proto__: 모든 객체가 가지는 내부 프로퍼티로, 해당 객체의 프로토타입을 가리킨다.
  • prototype: 함수 객체만 가지는 프로퍼티로, 해당 함수를 생성자로 사용했을 때 만들어지는 객체의 프로토타입이 된다.
function Dog(name) {
  this.name = name;
}

Dog.prototype.bark = function() {
  console.log(`${this.name} says woof!`);
};

const rex = new Dog("Rex");

console.log(rex.__proto__ === Dog.prototype);  // true
console.log(Dog.__proto__ === Function.prototype);  // true

생성자 함수와 prototype 속성

함수는 특별한 prototype 속성을 갖는데, 이는 해당 함수를 생성자로 사용해 만든 객체의 프로토타입이 된다.

function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  console.log(`Hello, my name is ${this.name}`);
};

const alice = new Person("Alice");
alice.greet();  // "Hello, my name is Alice"

이 코드에서 alice의 프로토타입은 Person.prototype이다. 따라서 alice 객체는 Person.prototype에 정의된 greet 메서드에 접근할 수 있다.

클래스 문법과 프로토타입

ES6에서 도입된 class 문법은 프로토타입 기반 상속을 더 쉽고 명확하게 사용할 수 있게 해준다. 하지만 내부적으로는 여전히 프로토타입을 사용한다.

class Animal {
  constructor(name) {
    this.name = name;
  }
  
  eat() {
    console.log(`${this.name} is eating.`);
  }
}

class Dog extends Animal {
  bark() {
    console.log(`${this.name} says woof!`);
  }
}

const rex = new Dog("Rex");
rex.eat();  // "Rex is eating."
rex.bark();  // "Rex says woof!"

이 코드에서 rex의 프로토타입 체인은 Dog.prototype -> Animal.prototype -> Object.prototype이다.

클로저와 프로토타입의 관계

클로저는 자바스크립트의 또 다른 강력한 개념이다. 클로저는 함수와 그 함수가 선언된 렉시컬 환경의 조합으로, 내부 함수가 외부 함수의 변수에 접근할 수 있게 해준다.

프로토타입과 클로저를 함께 사용하면 강력한 패턴을 만들 수 있다:

function createCounter() {
  let count = 0;  // 클로저를 통해 접근 가능한 private 변수
  
  function Counter() {}
  
  Counter.prototype.increment = function() {
    count++;
    return count;
  };
  
  Counter.prototype.decrement = function() {
    count--;
    return count;
  };
  
  return new Counter();
}

const counter = createCounter();
console.log(counter.increment());  // 1
console.log(counter.increment());  // 2
console.log(counter.decrement());  // 1

이 예제에서 count 변수는 클로저를 통해 Counter 프로토타입의 메서드들이 접근할 수 있지만, 외부에서는 직접 접근할 수 없는 private 변수처럼 동작한다.

메모리 주소와 가비지 컬렉션

자바스크립트에서 변수의 메모리 관리는 대부분 자동으로 이루어진다. 하지만 그 동작 원리를 이해하는 것은 효율적인 코드를 작성하는 데 큰 도움이 된다.

원시 타입과 참조 타입의 메모리 관리

  1. 원시 타입: 원시 타입(string, number, boolean, null, undefined, symbol, bigint)은 값 자체가 변수에 저장된다. 변수에 다른 원시 값을 할당하면 이전 값은 덮어써진다.
  2. 참조 타입: 참조 타입(object, array, function 등)은 실제 객체는 힙(heap) 메모리에 저장되고, 변수에는 그 객체의 메모리 주소가 저장된다.
let x = { name: "John" };
let y = x;  // y는 x와 같은 객체를 참조

y.name = "Jane";
console.log(x.name);  // "Jane" - x와 y는 같은 객체를 참조하기 때문

가비지 컬렉션

자바스크립트는 자동으로 더 이상 참조되지 않는 객체를 메모리에서 해제하는 가비지 컬렉션을 수행한다. 이를 "도달 가능성(reachability)" 알고리즘이라고 한다.

let obj = { data: "some data" };
// 여기서 obj는 객체를 참조

obj = null;  
// obj가 더 이상 객체를 참조하지 않으므로, 그 객체는 가비지 컬렉션의 대상이 됨

클로저와 메모리 누수

클로저는 함수가 외부 환경의 변수를 참조할 수 있게 해주는데, 이로 인해 의도치 않은 메모리 누수가 발생할 수 있다.

function setupHandler() {
  let element = document.getElementById("button");
  let counter = 0;
  
  element.addEventListener("click", function() {
    console.log(++counter);
  });
}

이 코드에서 클로저(이벤트 핸들러 함수)는 counter 변수를 참조하고 있다. 만약 이벤트 리스너를 제거하지 않으면, counter 변수는 가비지 컬렉션의 대상이 되지 않아 메모리 누수가 발생할 수 있다.

메모리 누수를 방지하려면 다음과 같이 이벤트 리스너를 제거해야 한다:

function setupHandler() {
  let element = document.getElementById("button");
  let counter = 0;
  
  const clickHandler = function() {
    console.log(++counter);
  };
  
  element.addEventListener("click", clickHandler);
  
  // 나중에 이벤트 리스너 제거
  return function cleanup() {
    element.removeEventListener("click", clickHandler);
    element = null; // DOM 요소에 대한 참조도 제거
  };
}

const cleanup = setupHandler();
// 필요 없어지면 cleanup 함수 호출
// cleanup();

이터러블과 메모리 효율성

ES6에서 도입된 이터러블(iterable)과 제너레이터(generator)는 메모리 효율적인 데이터 처리를 가능하게 한다.

function* infiniteSequence() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

const iterator = infiniteSequence();
console.log(iterator.next().value);  // 0
console.log(iterator.next().value);  // 1

이 제너레이터 함수는 무한한 숫자 시퀀스를 생성하지만, 모든 값을 메모리에 저장하지 않고 필요할 때만 값을 생성한다. 이는 대용량 데이터를 처리할 때 매우 유용하다.

비동기 코드에서의 this 바인딩

비동기 코드에서 this 바인딩은 특히 주의해야 할 부분이다. 콜백 함수는 원래 컨텍스트와 다른 컨텍스트에서 실행될 수 있기 때문이다.

const user = {
  name: "Alice",
  greetLater: function() {
    setTimeout(function() {
      console.log(`Hello, my name is ${this.name}`);
    }, 1000);
  }
};

user.greetLater();  // "Hello, my name is undefined"

이 문제는 화살표 함수나 bind 메서드를 사용하여 해결할 수 있다:

// 화살표 함수 사용
const user1 = {
  name: "Alice",
  greetLater: function() {
    setTimeout(() => {
      console.log(`Hello, my name is ${this.name}`);
    }, 1000);
  }
};

// bind 메서드 사용
const user2 = {
  name: "Bob",
  greetLater: function() {
    setTimeout(function() {
      console.log(`Hello, my name is ${this.name}`);
    }.bind(this), 1000);
  }
};

user1.greetLater();  // "Hello, my name is Alice"
user2.greetLater();  // "Hello, my name is Bob"

결론: 자바스크립트의 유연한 객체 시스템

자바스크립트의 this, 프로토타입, 메모리 관리는 처음에는 복잡하게 느껴질 수 있지만, 그 원리를 이해하면 매우 강력하고 유연한 시스템임을 알 수 있다.

  1. this 키워드는 호출 시점에 결정되며, 함수가 어떻게 호출되는지에 따라 달라진다.
  2. 프로토타입은 객체 간 상속 관계를 구현하는 자바스크립트만의 방식이다.
  3. 메모리 관리는 대부분 자동으로 이루어지지만, 참조 타입과 클로저를 사용할 때는 메모리 누수에 주의해야 한다.
  4. 클로저는 함수와 그 함수가 선언된 환경의 조합으로, 강력한 캡슐화를 가능하게 한다.
  5. 이터러블과 제너레이터는 메모리 효율적인 데이터 처리를 가능하게 한다.

이러한 개념들은 서로 밀접하게 연결되어 있으며, 자바스크립트의 특성을 이해하는 데 핵심이다. 특히 this와 프로토타입은 자바스크립트만의 독특한 개념이기 때문에, 다른 언어에서 온 개발자들이 자주 혼란을 겪는 부분이기도 하다.

하지만 이제 이런 개념들의 동작 원리를 더 명확하게 이해했으니, 자바스크립트의 강력한 기능들을 더 효과적으로 활용할 수 있을 것이다.

부제: 왜 [1,2,3] === [1,2,3]이 false인가? 자바스크립트 비교 연산 이해하기

you don't know JS Yet 을 읽고 난 뒤, 그 내용을 학습하기 위한 포스팅입니다. 
이 책은 자바스크립트 자체에 대한 내용, 즉 자바스크립트 이름의 유래와 명세서, JS의 하위 호환성과 상위 호환성에 대한 내용을 시작으로 이야기를 풀어갑니다. (이 부분에 대해서는 독후감, 독서활동이 아닌 추후에 더 공부해서 다른 프로그래밍 언어와 비교해서 정리한 비교 포스팅을 작성해볼 계획입니다.)
이 책에서 가장 인상 깊었던 것은 챕터별로 딱딱 나눠져서 있는 느낌이 아니라 js는 어떻게 발전했고 자바스크립트는 어떻게 사용되고 인식되어져 왔으며, 어떠한 배경에서 es6 모듈이 나왔고 지금의 JS가 있을 수 있었는지 이런 식으로 자연스럽게 이어지는 흐름이 대단했습니다. 오늘은 제가 잘못 이해하고 있었던 개념에 대해서 중심적으로 파보는 시간을 가져보려고 합니다. 제가 오늘 다룰 주제는 다음과 같습니다.

포스팅은 3편에 나눠서 업로드 됩니다.

A. 자바스크립트 조망하기
    1. 강제변환과 깊은비교, 일치비교와 동등비교,
    2. js의 this 키워드와 프로토타입, 그리고 메모리주소 

B. 추가 주제
    1. es6 모듈에서 class를 사용하지 않는

 

A. 자바스크립트 조망하기

1. 강제변환과 깊은비교, 일치비교와 동등비교

자바스크립트를 공부하면서 가장 헷갈렸던 부분 중 하나가 바로 비교 연산이었다. 특히 배열이나 객체를 비교할 때 예상과 다른 결과가 나와서 당황했던 경험이 있다. "You Don't Know JS Yet"을 읽으면서 내가 왜 이런 부분을 잘못 이해하고 있었는지 깨닫게 되었다.

원시 타입 vs 참조 타입

먼저 내가 가장 헷갈렸던 코드 예시를 살펴보자:

var x = [1, 2, 3];
var y = x;
var a = [1, 2, 3, 4];
var b = [1, 2, 3, 4];
var c = {a: 1, b: 2};
var d = {a: 1, b: 2};

console.log(y === x);  // true
console.log(a === b);  // false
console.log(c === d);  // false
console.log(y = [1, 2, 3]);  // [1, 2, 3] (할당 연산의 결과)
console.log(x === [1, 2, 3]);  // false

처음에는 단순히 "값이 같으면 같다고 판단하지 않나?"라고 생각했는데, 이는 자바스크립트의 참조 타입에 대한 이해가 부족했기 때문이다. 이제 제대로 정리해보자.

자바스크립트에서 데이터 타입은 크게 두 가지로 나눌 수 있다:

  1. 원시 타입(Primitive Types): string, number, boolean, null, undefined, symbol, bigint
  2. 참조 타입(Reference Types): object, array, function 등 (사실 이들은 모두 객체의 일종이다)

원시 타입은 값 자체가 변수에 할당되지만, 참조 타입은 메모리상의 주소값(참조)이 변수에 할당된다. 이 차이가 비교 연산에서 혼란을 일으키는 주요 원인이다.

 

일치 비교(===)와 동등 비교(==)

자바스크립트에서는 비교 연산자로 ===(일치 비교)와 ==(동등 비교)를 제공한다.

 

일치 비교(===)

일치 비교는 타입 변환 없이 값과 타입이 모두 같은지 비교한다. 참조 타입의 경우, 같은 객체를 참조하는지(즉, 같은 메모리 주소를 가리키는지) 확인한다.

var x = [1, 2, 3];
var y = x;  // y는 x와 같은 배열 객체를 참조
console.log(y === x);  // true: 같은 메모리 주소를 참조

var a = [1, 2, 3, 4];
var b = [1, 2, 3, 4];  // a와 b는 내용은 같지만 다른 배열 객체
console.log(a === b);  // false: 다른 메모리 주소를 참조

그래서 a === b가 false로 나오는 것이다! 내용이 같아도 서로 다른 객체(다른 메모리 주소)이기 때문이다.

 

동등 비교(==)

동등 비교는 타입이 다르면 타입 변환을 시도한 후 비교한다. 하지만 객체 간 비교에서는 ===와 마찬가지로 참조가 같은지만 확인한다.

console.log("1" == 1);  // true: 문자열 "1"이 숫자 1로 변환됨
console.log([1, 2, 3] == [1, 2, 3]);  // false: 다른 객체

 

강제 변환(Coercion)

자바스크립트의 또 다른 흥미로운 부분은 강제 변환이다. 강제 변환은 명시적(explicit)과 암묵적(implicit) 두 가지로 나뉜다.

 

명시적 강제 변환

개발자가 의도적으로 타입을 변환하는 경우다:

var a = "42";
var b = Number(a);  // 명시적으로 숫자로 변환
console.log(b);  // 42

 

암묵적 강제 변환

자바스크립트 엔진이 자동으로 타입을 변환하는 경우다:

var a = "42";
var b = a * 1;  // 암묵적으로 숫자로 변환
console.log(b);  // 42

console.log("10" > 9);  // true: "10"이 숫자 10으로 변환됨

여기서 내가 놀랐던 점은 비교 연산자 >=, <=, >, < 모두 ==와 같이 타입 변환을 수행한다는 것이다. 이런 암묵적 변환이 예상치 못한 결과를 가져올 수 있기 때문에 주의해야 한다.

 

특이 케이스: NaN

자바스크립트의 또 다른 특이한 점은 NaN(Not a Number)이다:

console.log(NaN === NaN);  // false

NaN은 자기 자신과도 같지 않다! 이는 IEEE 754 부동소수점 표준을 따르기 때문인데, NaN을 확인하려면 isNaN() 함수나 ES6의 Number.isNaN()을 사용해야 한다.

 

깊은 비교(Deep Equality)

객체나 배열의 내용이 같은지 비교하려면 어떻게 해야 할까? 이를 위해 "깊은 비교"를 수행해야 한다:

  1. JSON을 활용한 방법:단, 이 방법은 객체에 함수, 심볼, undefined 등이 포함되어 있거나 순환 참조가 있는 경우 제대로 작동하지 않는다.
  2. JSON.stringify(obj1) === JSON.stringify(obj2)
  3. lodash나 underscore 같은 라이브러리 사용:
  4. _.isEqual(obj1, obj2) // lodash의 isEqual 메서드
  5. 직접 재귀 함수 작성:
  6. function deepEqual(obj1, obj2) { // 타입이 다르면 바로 false 반환 if (typeof obj1 !== typeof obj2) return false; // 원시 타입이거나 null이면 일치 비교 if (obj1 === null || typeof obj1 !== 'object') return obj1 === obj2; // 키 개수 비교 const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) return false; // 각 키의 값 재귀적으로 비교 return keys1.every(key => deepEqual(obj1[key], obj2[key])); }

하지만 실무에서 객체 비교에 대해 더 고민하면서 깨달은 점이 있다. 사실 객체 전체를 깊게 비교하는 것이 항상 필요한 것은 아니다. 종종 우리가 정말로 필요한 것은 특정 프로퍼티나 필드의 값만 비교하는 것이다.

예를 들어, 사용자 목록에서 특정 ID를 가진 사용자를 찾는 경우:

const findUserById = (users, id) => users.find(user => user.id === id);

또는 상품 객체들 중에서 가격이 특정 범위에 있는 것만 필터링하는 경우:

const affordableProducts = products.filter(product => product.price < 50);

이렇게 명확한 비교 기준(id, price 등)을 정하고 범위를 좁히는 접근법이 실제로 더 효율적이고 가독성도 좋다. 모든 필드를 일일이 비교하는 깊은 비교는 성능 비용이 크고, 실제로는 그런 완전한 비교가 필요한 경우가 생각보다 많지 않다는 것을 알게 되었다.

물론 두 객체가 정확히 같은 상태인지 테스트할 때는 깊은 비교가 필요하다. 하지만 그 전에 "내가 정말로 확인하고 싶은 것이 무엇인가?"를 먼저 생각해보는 것이 좋다.

 

React에서의 객체 비교와 렌더링

자바스크립트의 참조 타입 비교 특성은 React와 같은 프레임워크에서 특히 중요한 영향을 미친다. React의 렌더링 메커니즘을 이해하는 데 핵심이 되는 부분이다.

 

useState와 객체 상태의 문제점

React에서 useState를 사용해 객체 형태의 상태를 관리할 때, 객체 내부의 프로퍼티만 변경하면 컴포넌트가 재렌더링되지 않는 경우가 있다. 이는 앞서 설명한 참조 타입의 비교 방식 때문이다.

function UserProfile() {
  const [user, setUser] = useState({
    name: "Alice",
    age: 30,
    preferences: {
      theme: "dark",
      notifications: true
    }
  });

  const updateTheme = () => {
    // 잘못된 방식: 내부 객체 직접 수정
    user.preferences.theme = "light";
    setUser(user); // React는 참조가 같은 객체로 인식하여 재렌더링하지 않음!
  };

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Theme: {user.preferences.theme}</p>
      <button onClick={updateTheme}>Change Theme</button>
    </div>
  );
}

위 코드에서 updateTheme 함수는 user 객체의 내부 프로퍼티를 직접 수정한 후 같은 객체 참조를 setUser에 전달한다. React는 동일한 참조(메모리 주소)를 가진 객체라고 판단하여 상태가 변경되지 않았다고 간주하고 재렌더링을 수행하지 않는다.

 

불변성을 유지하는 올바른 방법

React에서 상태 업데이트 시 불변성(immutability)을 유지하는 것이 중요하다. 상태 객체를 직접 수정하지 않고, 새로운 객체를 생성해야 한다:

function UserProfile() {
  const [user, setUser] = useState({
    name: "Alice",
    age: 30,
    preferences: {
      theme: "dark",
      notifications: true
    }
  });

  const updateTheme = () => {
    // 올바른 방식: 새 객체 생성 (스프레드 연산자 사용)
    setUser({
      ...user,
      preferences: {
        ...user.preferences,
        theme: "light"
      }
    });
  };

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Theme: {user.preferences.theme}</p>
      <button onClick={updateTheme}>Change Theme</button>
    </div>
  );
}

이 방식은 새로운 객체와 그 내부 객체들을 생성하므로, React는 참조가 변경되었음을 감지하고 적절히 재렌더링한다.

 

중첩 객체의 복사 문제

중첩이 깊은 객체의 경우, 스프레드 연산자만으로는 모든 레벨의 불변성을 유지하기 어렵다. 다음과 같은 해결책이 있다:

  1. 중첩 스프레드 연산자: 위 예제처럼 각 레벨마다 스프레드 연산자를 사용
  2. 구조 분해 객체 업데이트: 명확한 코드를 위해 변수를 사용
  3. const updateTheme = () => { const updatedPreferences = { ...user.preferences, theme: "light" }; setUser({ ...user, preferences: updatedPreferences }); };
  4. immer 라이브러리 사용: 복잡한 중첩 객체를 직관적으로 업데이트
  5. import produce from 'immer'; const updateTheme = () => { setUser(produce(user, draft => { draft.preferences.theme = "light"; // 불변성이 내부적으로 처리됨 })); };

 

React.memo와 객체 비교

React.memo로 컴포넌트를 최적화할 때도 객체 타입 props의 비교 방식을 이해해야 한다:

function ExpensiveComponent({ config, onAction }) {
  // 비용이 많이 드는 렌더링 작업...
  return <div>...</div>;
}

// 부모 컴포넌트
function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // 렌더링마다 새 객체 생성 - memo가 제대로 작동하지 않음
  const config = { theme: "dark", size: "large" };
  
  // 렌더링마다 새 함수 생성 - memo가 제대로 작동하지 않음
  const handleAction = () => {
    console.log("Action triggered");
  };
  
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveComponent config={config} onAction={handleAction} />
    </>
  );
}

// memo로 감싸도 부모가 렌더링될 때마다 새로운 config와 onAction이 생성되므로 항상 재렌더링됨
export default React.memo(ExpensiveComponent);

위 코드에서 React.memo는 얕은 비교를 수행하기 때문에, 매 렌더링마다 새롭게 생성되는 config 객체와 handleAction 함수가 이전과 다른 참조를 가지게 되어 최적화 효과가 사라진다.

 

해결책: useMemo와 useCallback

이 문제를 해결하기 위해 React는 useMemo와 useCallback 훅을 제공한다:

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // 의존성 배열이 변경될 때만 새 객체 생성
  const config = useMemo(() => ({ theme: "dark", size: "large" }), []);
  
  // 의존성 배열이 변경될 때만 새 함수 생성
  const handleAction = useCallback(() => {
    console.log("Action triggered");
  }, []);
  
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveComponent config={config} onAction={handleAction} />
    </>
  );
}

이제 config와 handleAction은 컴포넌트가 재렌더링되어도 동일한 참조를 유지하므로, React.memo가 제대로 작동하여 불필요한 렌더링을 방지한다.

 

결론: 참조 타입 이해의 중요성

자바스크립트의 참조 타입 비교 특성을 이해하는 것은 React 애플리케이션의 성능 최적화에 필수적이다. 요약하자면:

  1. 상태 업데이트 시 불변성을 유지해야 React가 변경을 감지할 수 있다.
  2. 객체를 직접 수정하지 말고, 스프레드 연산자나 immer 같은 도구를 사용하여 새 객체를 생성하자.
  3. 함수형 업데이트를 활용하여 이전 상태를 기반으로 안전하게 업데이트하자.
    // 이전 상태에 의존하는 안전한 업데이트setUser(prevUser => ({  ...prevUser,  preferences: {    ...prevUser.preferences,    theme: "light"  }}));
    
  4. 복잡한 중첩 객체를 다룰 때는 immer 같은 라이브러리 사용을 고려하자.
  5. useMemo와 useCallback을 활용하여 참조 일관성을 유지하고 불필요한 렌더링을 방지하자.

이러한 React의 렌더링 메커니즘과 최적화 기법은 결국 자바스크립트의 참조 타입 비교 원리에 기반하고 있다. 따라서 자바스크립트의 기본 개념을 제대로 이해하는 것이 React를 효율적으로 사용하는 첫걸음이라고 할 수 있다.

 

이 체크리스트는 개발자 고용시장에서 자신의 가치를 높일 수 있는 체크리스트라고 하여 가져왔습니다.

 

1. 실제 서비스를 공개적으로 배포하고 운영해보는 경험을 해보았다.

2. 유저의 피드백에 따라 성능 / 사용성을 개선하고 신규 기능을 추가해보았다.

3. 발견되는 버그와 개선사항을 정리하고 쌓인 이슈들을 체계적으로 관리해보았다.

4. 코드를 지속적으로 리팩토링하고 디자인 패턴을 적용해보았다.

5. 위의 시도에서 더 좋은 설계와 더 빠른 개발 사이의 트레이드오프를 고민해보았다.

6. 반복되는 수정과 배포에 수반되는 작업들을 자동화해보았다.

7. 언어나 프레임워크의 기능만으로 구현할 수 없는 것들을 직접 구현해보았다.

8. 내가 사용한 라이브러리나 프레임워크의 문제점이나 한계를 느끼고 개선해보았다.

9. 코드나 제품의 퀄리티를 유지하기 위한 분석 툴이나 테스트 툴을 도입해보았다.

10. 타인과의 협업을 효율적으로 하기 위한 고민을 해보았다.

 

여기 있는 모든 점을 다 만족시키면서 일을 할 수는 없겠지만,

소모적이지 않고 계속해서 나를 발전시켜줄 수 있는 방향성이 무엇인가에 대해 생각해볼 수 있게 해줍니다.

 

참고

https://youtu.be/PJGsPohDuoA

코딩몬스터 TV

+ Recent posts